15 Commits
Author SHA1 Message Date
liuwenxinandClaude Code fbf044f240 fix(system): code-review 15 项修复(预览基数/维度校验/脱敏穿透/并发版本号等)
- 信息员预览基数改用本人销售额(原误用折算毛利润额,金额算错且口径自相矛盾)
- 产品类别入参与维度值加存在性校验(拼错静默回退默认阈值 30% 会算错轨交工资)
- submit 校验顺序前置查库(未注册 param_code 由 500 修正为 400)
- 金额拒绝 NaN/Infinity(比较抛 InvalidOperation、计算抛溢出均穿透为 500)
- version_no 并发冲突翻译为 400(对齐 user/role service 的 IntegrityError 处理)
- 旧值留痕改取条目生效日时点的值(多未来批次下留痕链不再错位)
- 脱敏红线:phone/mobile/id_card 键名整值抹除 + 手机号正则兼容分隔符写法
- 前端:撤回按钮与导出文件名改本地日期(UTC/本地混算)、维度行内精确「改」入口、
  detailOf 补 catch、抽屉分页 size 生效、blob 错误读出真实 message
- e2e 密码移除硬编码 fallback(缺失即报错退出);按钮文案同步「改」
- 全量回归:后端 406 + 集成 104 + 前端 type-check/build + e2e 7 步 全绿

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-13 02:28:20 +08:00
liuwenxinandClaude Code 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
liuwenxinandClaude Code d7fbec1177 feat(system): 登录页重做、布局白化与首页 Bento 仪表盘(视觉体系统一落地)
- 登录页:雾灰点阵底 + 右上涟漪同心圆 + 顶部靛蓝天光;居中纸白卡片(品牌区/欢迎回来/表单);逻辑不变
- BasicLayout:侧栏深色面板改为中性灰微渐变 + 白底浮起当前项(左 2px 靛蓝条);logo 区水线;内容区雾灰点阵
- 首页:Bento Grid 仪表盘(参数 16 项/岗位 3 个为真实数据;绩效/回款/待办为业务上线前的精致空态)
- AppMenu:激活项白底浮起样式,菜单变量随浅色侧栏调整

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-13 00:09:41 +08:00
liuwenxinandClaude Code 4c442b8cf2 feat(system): 前端岗位角色与计提参数配置页面
- 岗位角色页:列表/新增/编辑/启停/删除,内置岗位保护、模板受控下拉(含声明参数引导)
- 计提参数页:分组目录(多维度生效值)→ 修改入「待提交」→ 批次提交(立即/指定日期 + 二次确认);
  版本管理抽屉(记录分页/明细旧值→新值/两日期对比/撤回未生效/xlsx 导出);计算预览抽屉(按回款到账日取参数)
- request.ts 对 responseType=blob 直通(导出文件流非统一信封)
- 路由注册岗位角色/计提参数(系统管理组 order 5/6);日志模块词表补两处

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-12 21:48:00 +08:00
liuwenxinandClaude Code fd801455f0 feat(system): 全局品牌 token 迁移到靛蓝轻质感视觉体系
- 主色改靛蓝 #2E4BA8(白字对比 7.5:1),EP 色阶按混合公式重算
- 中性灰白体系:雾灰底/纸白/半透明界线/三级墨色文字
- 旧 token 名保留映射(深水青→冷墨、金色→靛蓝),存量引用平滑过渡,迁移完成后移除
- 设计依据:docs/design/视觉方向预览.html(2026-10-12 确认)

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-12 21:47:59 +08:00
liuwenxinandClaude Code 8ad3650dd3 feat(system): 计提参数版本管理、变更留痕与计算预览接口
- 9 个端点:目录快照(asOf)/单参数历史/变更批次提交与撤回/批次分页与明细/两日期对比/Excel 导出/计算预览
- submit_change 全量前置校验(注册表/维度岗位/取值域/同批去重)→ 建批次 → 逐项留痕(旧值取提交时生效值)
- 撤回:FOR UPDATE 锁行 + 已生效禁撤,删除后解析自然回退;同日多次变更取最新留痕
- 预览按岗位模板分派公式(经理聚合口径明确拒绝),出参含所用参数与生效时点;导出走 openpyxl
- ECharts 决策记录:前端统计图使用 echarts(用户指定)

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-12 20:24:17 +08:00
liuwenxinandClaude Code 7a0f3f4b98 feat(system): 业务岗位角色管理接口与保护规则
- 四层实现:仓储(FOR UPDATE 锁行 + 引用计数钩子预留)/ schemas / service / api 共 7 个端点
- 保护规则:内置岗位禁删禁停、编码创建后不可改、模板须在注册表内(未注册 400)
- 写操作全走审计上下文;冒烟验证 7 项(含成功/失败双路径落日志)
- 端点:列表/新增/详情/编辑/删除/启停 + meta/templates 模板下拉

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-12 16:39:08 +08:00
liuwenxinandClaude Code 1b3509b20f feat(system): 计提参数按业务日期时点化取值服务
- param_repository.resolve_values:窗口函数一条 SQL 取全量快照(同日多次变更取 effective_date DESC, id DESC 首条)
- param_service.ParamSnapshot:冻结快照 + 覆盖链(角色 > 产品类别 > 默认行)+ 注册表默认值兜底;fraction/integer/boolean/text 类型化取值
- 12 项单元用例:覆盖链优先级、兜底、同日取最新、冻结不可变

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-12 16:27:56 +08:00
liuwenxinandClaude Code 5ed5665a54 feat(system): 计提公式模板注册表与精确分数计算函数
- formula.py 八个纯函数:回款天数(首日计1)、业务员分段递减(360=6‰/361=4‰)、折算毛利润额化简式(防除零)、乘法方向绩效、以物抵顶/R×R/起付
- param_registry.py:16 项参数元数据(与迁移种子同源)+ Fraction 精确解析(4/360 禁截断)+ 类型/取值域/枚举校验
- calc_templates.py:5 个公式模板注册表(岗位绑定的受控集合)
- 新增 43 项单元用例:精确分数断言(899/90000、27 天、9/25),畸形值拒绝

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-12 16:22:32 +08:00
liuwenxinandClaude Code bc019cadf5 feat(system): 新增岗位角色与计提参数四表及 V3 迁移
- business_roles/param_definitions/param_change_batches/param_values 四表 + 种子(3 内置岗位/16 参数定义/19 版本条目/初始批次 P20260101-00)
- 双轨迁移:Alembic bfbda9c3191d + db/migration/V3 导出 + init-dev.sql 同步(schema_sync 校验通过)
- 参数值以 Fraction 可解析字符串存储(4/360 禁 0.011111);企业户 2‰→3‰ 双种子支持按到账日回溯

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-12 16:13:13 +08:00
liuwenxinandClaude Code c7212cd93f chore: 修正 gitignore 并同步项目文档
- .gitignore:logs/ 收窄为 /backend/logs/ —— 原规则(无前导斜杠)匹配任意
  层级同名目录,曾把 frontend/src/views/system/logs 整个吞出版本库
- 同步根/frontend README(进度、e2e 运行方式与环境变量)
- 收录业务确认问题清单(完整版)

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-10 16:31:45 +08:00
liuwenxinandClaude Code 8355e0cc7d feat(system): 完成前端系统管理(布局框架、三个管理页面、e2e)
- BasicLayout:侧边栏/顶栏/面包屑/移动端抽屉;菜单从路由表派生;
  路由守卫新增 requiresAdmin 层(菜单隐藏 + 直连 URL 弹回)
- 用户/角色/日志三个页面:分页请求序号防竞态、确认动作流抽 useConfirmAction、
  时间范围配 default-time、日志详情按需懒加载并缓存
- 空 catch 统一改 handledError 显式声明;词表/选项常量集中(与后端对齐),
  「停用」文案全站统一为「禁用」
- 品牌色提升为全局 token,Element Plus 主色指向赭金
- e2e:公共工具抽 common.py;系统管理 13 项(含详情懒加载脱敏断言),
  管理员密码改 E2E_ADMIN_PASSWORD 环境变量;login_flow 8 项回归通过

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-10 16:28:53 +08:00
liuwenxinandClaude Code c83287819a feat(system): 完成后端系统管理(审计/角色/操作日志/日志查询)
- 操作日志审计机制: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>
2026-10-10 16:28:04 +08:00
liuwenxinandClaude Code e22c4bcdc9 feat(system): 实现用户管理接口
需求 2.5.1 用户账号管理,全部要求管理员权限(require_admin 依赖):

- 接口:列表/详情/新增/编辑/删除/重置密码/启停/角色下拉,
  角色下拉放 /users/meta/roles 以避开 /{user_id} 的路由匹配冲突
- 删除、禁用、改角色三处自我保护:不允许对当前登录账号执行,
  避免管理员误操作后立刻失去全部管理权限且无法自行恢复
- 重置密码与禁用均提升 token_version,使已签发令牌立即失效,
  不依赖 Redis 黑名单
- 密码按字节数校验(bcrypt 5.0 硬上限 72 字节,中文一字 3 字节)
- 用户列表按 id 倒序:create_time 为秒级精度,同秒数据分页结果不确定
- 修复 replace_roles 未同步关系集合的缺陷:改用 user.user_roles
  集合追加,此前 session.add(UserRole(...)) 会表现为保存成功但返回空角色
- update_user 的自我保护原先依赖返回 None 的占位函数,恒不生效,
  改为显式传入 operator_id

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-10 10:44:13 +08:00
liuwenxinandClaude Code ad5fe4a411 feat(system): 新增操作日志表与 V2 迁移
需求 2.5.1 要求记录用户管理操作,先落表结构:

- operation_logs 表:冗余 username 保证账号删除后仍可追溯,
  user_id 外键 ON DELETE SET NULL,success 默认 '0' 与 login_logs 口径一致
- 截断常量(USERNAME/ERROR_MESSAGE/REQUEST_DATA)集中定义,供写入方引用
- 修复既有 V1 迁移与迁移模板的 downgrade:索引是外键唯一支撑索引,
  先 drop_index 会报 errno 1553,改为只 drop_table
- 同步 db/migration/V2 与 init-dev.sql,迁移版本号更新为 a45a8087831b

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-10 10:44:05 +08:00
96 changed files with 13346 additions and 448 deletions

No files matched your search

+6 -1
View File
@@ -26,7 +26,10 @@ out/
# 日志 # 日志
*.log *.log
logs/ # 后端运行日志目录(core/logging.py 创建)。
# 不要写成裸 `logs/` —— 那会匹配任意层级的同名目录,曾把
# frontend/src/views/system/logs 整个吞掉(文件进不了版本库)
/backend/logs/
# IDE # IDE
.idea/ .idea/
@@ -54,3 +57,5 @@ frontend/e2e/*.png
# 备份 # 备份
backup/ backup/
.claude
CLAUDE.md
+3 -2
View File
@@ -2,7 +2,7 @@
营销人员绩效工资核算系统。后端 Python/FastAPI,前端 Vue 3,数据存 MySQL,会话与缓存在 Redis。 营销人员绩效工资核算系统。后端 Python/FastAPI,前端 Vue 3,数据存 MySQL,会话与缓存在 Redis。
> 当前进度:登录鉴权链路已跑通(验证码 / JWT / 限流 / 登出黑名单 / 登录日志)。业务模块(基础数据、业务数据、绩效核算、报表)待需求澄清后开发。 > 当前进度:登录鉴权与系统管理(用户/角色/操作日志/日志查询)已完成,前端具备应用布局框架。业务模块(基础数据、业务数据、绩效核算、报表)待需求澄清后开发。
## 快速开始 ## 快速开始
@@ -81,7 +81,8 @@ pnpm type-check # 类型检查
pnpm build # 类型检查 + 生产构建 pnpm build # 类型检查 + 生产构建
# 浏览器端到端(需容器、后端、前端都在运行) # 浏览器端到端(需容器、后端、前端都在运行)
python e2e/login_flow.py python e2e/login_flow.py # 登录链路 8 项
python e2e/system_management.py # 系统管理 13 项
``` ```
## 目录结构 ## 目录结构
+37 -1
View File
@@ -1,11 +1,13 @@
# 后端 # 后端
> 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`。已实现登录鉴权、系统管理(用户/角色/操作日志/日志查询)与 M1 参数体系(岗位角色管理、计提参数版本化/留痕/对比/预览/导出,2026-10-13);业务模块(基础/业务数据、核算、报表)按路线图推进。
## 职责 ## 职责
- 提供 REST 接口(统一前缀 `/api`) - 提供 REST 接口(统一前缀 `/api`)
- 登录鉴权:图形验证码、JWT、登录限流、登出黑名单、登录日志 - 登录鉴权:图形验证码、JWT、登录限流、登出黑名单、登录日志
- 系统管理(需求 2.5.1):用户管理、角色管理、岗位角色(#28 可扩展+公式模板绑定)、操作日志(自动审计)、操作与登录日志查询
- 计提参数体系(需求 #28):16 项参数目录、按生效日期版本化(回款到账日取值)、变更批次留痕/对比/导出、计算预览
- 后续承担:基础数据、业务数据、绩效核算、报表统计、T+ 数据同步 - 后续承担:基础数据、业务数据、绩效核算、报表统计、T+ 数据同步
## 技术栈与版本 ## 技术栈与版本
@@ -53,6 +55,24 @@ app/
| POST | `/api/v1/auth/logout` | 是 | 令牌入黑名单,立即失效 | | POST | `/api/v1/auth/logout` | 是 | 令牌入黑名单,立即失效 |
| GET | `/api/v1/auth/me` | 是 | 当前用户与角色 | | 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`。 统一响应 `{code, message, data}`,分页为 `list/total/pageNum/pageSize`。
状态码:400 参数错误 / 401 未登录 / 403 无权限 / 404 不存在 / 500 服务端异常。 状态码:400 参数错误 / 401 未登录 / 403 无权限 / 404 不存在 / 500 服务端异常。
@@ -118,6 +138,22 @@ Redis 黑名单只负责「用户主动登出」。
bcrypt 5.0 起对超过 72 字节的密码**直接抛 ValueError**(此前是静默截断)。 bcrypt 5.0 起对超过 72 字节的密码**直接抛 ValueError**(此前是静默截断)。
中文一个字符占 3 字节,24 个汉字即到顶——`max_length=20` 这类字符数校验拦不住。 中文一个字符占 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 是安全问题,必须启动即失败。 **起服务报 `APP_SECRET_KEY` 相关错误** —— `backend/.env` 未填或不足 32 字符。这是刻意的 fail-fast:空密钥签发 JWT 是安全问题,必须启动即失败。
+12 -6
View File
@@ -5,7 +5,7 @@ Revises: ${down_revision | comma,n}
Create Date: ${create_date} Create Date: ${create_date}
""" """
from typing import Sequence, Union from collections.abc import Sequence
from alembic import op from alembic import op
import sqlalchemy as sa import sqlalchemy as sa
@@ -13,16 +13,22 @@ ${imports if imports else ""}
# revision identifiers, used by Alembic. # revision identifiers, used by Alembic.
revision: str = ${repr(up_revision)} revision: str = ${repr(up_revision)}
down_revision: Union[str, Sequence[str], None] = ${repr(down_revision)} down_revision: str | Sequence[str] | None = ${repr(down_revision)}
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)} branch_labels: str | Sequence[str] | None = ${repr(branch_labels)}
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)} depends_on: str | Sequence[str] | None = ${repr(depends_on)}
def upgrade() -> None: def upgrade() -> None:
"""Upgrade schema.""" """应用迁移。"""
${upgrades if upgrades else "pass"} ${upgrades if upgrades else "pass"}
def downgrade() -> None: def downgrade() -> None:
"""Downgrade schema.""" """回滚迁移。
**注意(autogenerate 不会替你处理)**:生成的 `op.drop_index` 与
`op.drop_table` 顺序需人工检查 —— 若某索引是外键的唯一支撑索引,
单独 drop_index 会报 MySQL 1553(needed in a foreign key constraint)。
**整表回滚应只保留 `op.drop_table`**,DROP TABLE 会连带删除索引与外键。
"""
${downgrades if downgrades else "pass"} ${downgrades if downgrades else "pass"}
@@ -337,17 +337,19 @@ def upgrade() -> None:
def downgrade() -> None: def downgrade() -> None:
"""Downgrade schema.""" """回滚迁移。
# ### commands auto generated by Alembic - please adjust! ###
op.drop_index("idx_user_roles_role_id", table_name="user_roles") 只保留 drop_table —— DROP TABLE 会连带删除索引与外键。
原 autogenerate 输出里逐个 drop_index 会失败:idx_user_roles_role_id 与
idx_role_permissions_permission_id 分别是外键的唯一支撑索引,
MySQL 拒绝单独删除(errno 1553),回滚会中断在半途。
删除顺序遵循外键依赖:先子表(user_roles / role_permissions / login_logs),
再父表(users / roles / permissions)。
"""
op.drop_table("user_roles") op.drop_table("user_roles")
op.drop_index("idx_role_permissions_permission_id", table_name="role_permissions")
op.drop_table("role_permissions") op.drop_table("role_permissions")
op.drop_index("idx_login_logs_username", table_name="login_logs")
op.drop_index("idx_login_logs_create_time", table_name="login_logs")
op.drop_table("login_logs") op.drop_table("login_logs")
op.drop_index(op.f("idx_users_phone"), table_name="users")
op.drop_table("users") op.drop_table("users")
op.drop_table("roles") op.drop_table("roles")
op.drop_table("permissions") op.drop_table("permissions")
# ### end Alembic commands ###
@@ -0,0 +1,109 @@
"""新增操作日志表
Revision ID: a45a8087831b
Revises: 6165691326d4
Create Date: 2026-10-10 10:06:17.435697
"""
from collections.abc import Sequence
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import mysql
# revision identifiers, used by Alembic.
revision: str = "a45a8087831b"
down_revision: str | Sequence[str] | None = "6165691326d4"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
"""应用迁移。"""
# ### commands auto generated by Alembic - please adjust! ###
op.create_table(
"operation_logs",
sa.Column(
"id", mysql.BIGINT(unsigned=True), autoincrement=True, nullable=False, comment="主键ID"
),
sa.Column(
"user_id",
mysql.BIGINT(unsigned=True),
nullable=True,
comment="操作人ID(账号删除后置空)",
),
sa.Column(
"username",
sa.String(length=64),
nullable=False,
comment="操作人用户名(冗余存储便于追溯)",
),
sa.Column("module", sa.String(length=64), nullable=False, comment="所属模块,如 用户管理"),
sa.Column(
"action", sa.String(length=32), nullable=False, comment="操作类型:新增/修改/删除/导出"
),
sa.Column(
"summary", sa.String(length=255), nullable=False, comment="操作摘要(中文,供人阅读)"
),
sa.Column("method", sa.String(length=8), nullable=True, comment="HTTP 方法"),
sa.Column("path", sa.String(length=255), nullable=True, comment="请求路径"),
sa.Column("ip", sa.String(length=45), nullable=True, comment="客户端IP(45位兼容IPv6)"),
sa.Column(
"user_agent",
sa.String(length=255),
nullable=True,
comment="客户端User-Agent(截断存储)",
),
sa.Column("request_data", sa.Text(), nullable=True, comment="请求参数(已脱敏)"),
sa.Column(
"success",
mysql.TINYINT(),
server_default="0",
nullable=False,
comment="是否成功:1成功 0失败",
),
sa.Column(
"error_message",
sa.String(length=500),
nullable=True,
comment="失败原因(写入方须截断)",
),
sa.Column("duration_ms", sa.Integer(), nullable=True, comment="处理耗时(毫秒)"),
sa.Column(
"create_time",
sa.DateTime(),
server_default=sa.text("CURRENT_TIMESTAMP"),
nullable=False,
comment="创建时间",
),
sa.ForeignKeyConstraint(
["user_id"], ["users.id"], name=op.f("fk_operation_logs_user_id"), ondelete="SET NULL"
),
sa.PrimaryKeyConstraint("id", name=op.f("pk_operation_logs")),
comment="操作日志表",
mysql_charset="utf8mb4",
mysql_collate="utf8mb4_general_ci",
mysql_engine="InnoDB",
)
op.create_index(
"idx_operation_logs_create_time", "operation_logs", ["create_time"], unique=False
)
op.create_index("idx_operation_logs_module", "operation_logs", ["module"], unique=False)
op.create_index("idx_operation_logs_user_id", "operation_logs", ["user_id"], unique=False)
op.create_index("idx_operation_logs_username", "operation_logs", ["username"], unique=False)
# ### end Alembic commands ###
def downgrade() -> None:
"""回滚迁移。
**注意(autogenerate 不会替你处理)**:生成的 `op.drop_index` 与
`op.drop_table` 顺序需人工检查 —— 若某索引是外键的唯一支撑索引,
单独 drop_index 会报 MySQL 1553(needed in a foreign key constraint)。
**整表回滚应只保留 `op.drop_table`**,DROP TABLE 会连带删除索引与外键。
"""
# 整表回滚只保留 drop_table —— DROP TABLE 会连带删除索引与外键。
# 逐个 drop_index 会失败:idx_operation_logs_user_id 是外键
# fk_operation_logs_user_id 的唯一支撑索引,MySQL 拒绝删除(errno 1553)。
op.drop_table("operation_logs")
@@ -0,0 +1,810 @@
"""新增岗位角色与计提参数四表
Revision ID: bfbda9c3191d
Revises: a45a8087831b
Create Date: 2026-10-12 16:01:48.760620
"""
from collections.abc import Sequence
from datetime import date
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import mysql
# revision identifiers, used by Alembic.
revision: str = "bfbda9c3191d"
down_revision: str | Sequence[str] | None = "a45a8087831b"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
"""应用迁移。"""
# ### commands auto generated by Alembic - please adjust! ###
op.create_table(
"business_roles",
sa.Column(
"id", mysql.BIGINT(unsigned=True), autoincrement=True, nullable=False, comment="主键ID"
),
sa.Column(
"role_code",
sa.String(length=32),
nullable=False,
comment="岗位编码:salesperson营销员 sales_manager营销经理 info_officer信息员,创建后不可修改",
),
sa.Column("role_name", sa.String(length=64), nullable=False, comment="岗位名称"),
sa.Column(
"calc_template",
sa.String(length=32),
nullable=False,
comment="计提公式模板编码(以代码内模板注册表为准)",
),
sa.Column("description", sa.String(length=255), nullable=True, comment="岗位说明"),
sa.Column(
"sort_order", sa.Integer(), server_default="0", nullable=False, comment="显示排序"
),
sa.Column(
"status",
mysql.TINYINT(),
server_default="1",
nullable=False,
comment="状态:1启用 0禁用",
),
sa.Column(
"is_builtin",
mysql.TINYINT(),
server_default="0",
nullable=False,
comment="是否内置岗位:1内置(禁删禁停) 0自定义",
),
sa.Column(
"create_time",
sa.DateTime(),
server_default=sa.text("CURRENT_TIMESTAMP"),
nullable=False,
comment="创建时间",
),
sa.Column(
"update_time",
sa.DateTime(),
server_default=sa.text("CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP"),
nullable=False,
comment="更新时间",
),
sa.PrimaryKeyConstraint("id", name=op.f("pk_business_roles")),
sa.UniqueConstraint("role_code", name=op.f("uk_business_roles_role_code")),
comment="业务岗位角色表(与登录角色roles区分,决定计提公式与参数)",
mysql_charset="utf8mb4",
mysql_collate="utf8mb4_general_ci",
mysql_engine="InnoDB",
)
op.create_table(
"param_definitions",
sa.Column(
"id", mysql.BIGINT(unsigned=True), autoincrement=True, nullable=False, comment="主键ID"
),
sa.Column(
"param_code",
sa.String(length=64),
nullable=False,
comment="参数编码,如 salesperson.rate.slope",
),
sa.Column("param_name", sa.String(length=64), nullable=False, comment="参数名称"),
sa.Column(
"param_group",
sa.String(length=32),
nullable=False,
comment="参数分组:业务员个人户/经理分成/企业户/信息员/支付环节/产品阈值/月工资口径",
),
sa.Column(
"value_type",
sa.String(length=16),
nullable=False,
comment="值类型:DECIMAL/INT/BOOL/ENUM/TEXT",
),
sa.Column(
"unit", sa.String(length=16), nullable=True, comment="展示单位:‰/%/元/天,无单位为空"
),
sa.Column(
"dimension_type",
sa.String(length=32),
server_default="NONE",
nullable=False,
comment="维度类型:NONE/PRODUCT_CATEGORY/BUSINESS_ROLE",
),
sa.Column(
"options_text",
sa.String(length=255),
nullable=True,
comment="枚举选项(竖线分隔),仅 ENUM 使用",
),
sa.Column(
"default_value",
sa.String(length=64),
nullable=True,
comment="系统默认值(Fraction 可解析字符串)",
),
sa.Column(
"value_min", sa.String(length=64), nullable=True, comment="取值下限(含),校验用"
),
sa.Column(
"value_max", sa.String(length=64), nullable=True, comment="取值上限(含),校验用"
),
sa.Column(
"description", sa.String(length=255), nullable=True, comment="口径说明(配置页展示)"
),
sa.Column(
"sort_order", sa.Integer(), server_default="0", nullable=False, comment="显示排序"
),
sa.Column(
"status",
mysql.TINYINT(),
server_default="1",
nullable=False,
comment="状态:1启用 0停用",
),
sa.Column(
"create_time",
sa.DateTime(),
server_default=sa.text("CURRENT_TIMESTAMP"),
nullable=False,
comment="创建时间",
),
sa.Column(
"update_time",
sa.DateTime(),
server_default=sa.text("CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP"),
nullable=False,
comment="更新时间",
),
sa.PrimaryKeyConstraint("id", name=op.f("pk_param_definitions")),
sa.UniqueConstraint("param_code", name=op.f("uk_param_definitions_param_code")),
comment="计提参数定义表",
mysql_charset="utf8mb4",
mysql_collate="utf8mb4_general_ci",
mysql_engine="InnoDB",
)
op.create_table(
"param_change_batches",
sa.Column(
"id", mysql.BIGINT(unsigned=True), autoincrement=True, nullable=False, comment="主键ID"
),
sa.Column(
"version_no", sa.String(length=32), nullable=False, comment="版本号,如 P20261012-01"
),
sa.Column("effective_date", sa.Date(), nullable=False, comment="生效日期(业务日期口径)"),
sa.Column(
"change_type",
mysql.TINYINT(),
nullable=False,
comment="生效方式:1立即生效 2指定日期生效",
),
sa.Column("remark", sa.String(length=255), nullable=True, comment="变更说明"),
sa.Column(
"operator_id",
mysql.BIGINT(unsigned=True),
nullable=True,
comment="操作人ID(账号删除后置空)",
),
sa.Column(
"operator_name",
sa.String(length=64),
nullable=False,
comment="操作人用户名(冗余固化)",
),
sa.Column(
"item_count", sa.Integer(), server_default="0", nullable=False, comment="变更参数项数"
),
sa.Column(
"create_time",
sa.DateTime(),
server_default=sa.text("CURRENT_TIMESTAMP"),
nullable=False,
comment="创建时间",
),
sa.ForeignKeyConstraint(
["operator_id"],
["users.id"],
name=op.f("fk_param_change_batches_operator_id"),
ondelete="SET NULL",
),
sa.PrimaryKeyConstraint("id", name=op.f("pk_param_change_batches")),
sa.UniqueConstraint("version_no", name=op.f("uk_param_change_batches_version_no")),
comment="参数变更批次表",
mysql_charset="utf8mb4",
mysql_collate="utf8mb4_general_ci",
mysql_engine="InnoDB",
)
op.create_table(
"param_values",
sa.Column(
"id", mysql.BIGINT(unsigned=True), autoincrement=True, nullable=False, comment="主键ID"
),
sa.Column(
"param_id",
mysql.BIGINT(unsigned=True),
nullable=False,
comment="参数定义ID(param_definitions.id)",
),
sa.Column(
"dimension_value",
sa.String(length=32),
server_default=sa.text("''"),
nullable=False,
comment="维度取值:NONE维度为空串;产品类别/岗位编码为具体值",
),
sa.Column(
"effective_date", sa.Date(), nullable=False, comment="生效日期(按业务日期时点化取值)"
),
sa.Column(
"value_text",
sa.String(length=64),
nullable=False,
comment="参数值(Fraction 可解析字符串/枚举码/开关)",
),
sa.Column(
"old_value_text", sa.String(length=64), nullable=True, comment="变更前值(留痕)"
),
sa.Column(
"batch_id",
mysql.BIGINT(unsigned=True),
nullable=False,
comment="变更批次ID(param_change_batches.id)",
),
sa.Column(
"create_time",
sa.DateTime(),
server_default=sa.text("CURRENT_TIMESTAMP"),
nullable=False,
comment="创建时间",
),
sa.ForeignKeyConstraint(
["batch_id"],
["param_change_batches.id"],
name=op.f("fk_param_values_batch_id"),
ondelete="RESTRICT",
),
sa.ForeignKeyConstraint(
["param_id"],
["param_definitions.id"],
name=op.f("fk_param_values_param_id"),
ondelete="RESTRICT",
),
sa.PrimaryKeyConstraint("id", name=op.f("pk_param_values")),
comment="参数版本条目表(append-only,按生效日期时点化取值)",
mysql_charset="utf8mb4",
mysql_collate="utf8mb4_general_ci",
mysql_engine="InnoDB",
)
op.create_index("idx_param_values_batch_id", "param_values", ["batch_id"], unique=False)
op.create_index(
"idx_param_values_effective_date", "param_values", ["effective_date"], unique=False
)
op.create_index(
"idx_param_values_param_id_dimension_value_effective_date",
"param_values",
["param_id", "dimension_value", "effective_date"],
unique=False,
)
# ### end Alembic commands ###
# ---- 种子数据(生产与开发同源;口径依据 2026-10-12 业务裁决对照表)----
# 显式指定 id:四张表均为本迁移新建、应用时必为空表,id 稳定可控;
# param_values 按 param_id 引用定义,无法依赖自增(bulk_insert 取不回自增值)。
op.bulk_insert(
sa.table(
"business_roles",
sa.column("id", sa.BigInteger),
sa.column("role_code", sa.String),
sa.column("role_name", sa.String),
sa.column("calc_template", sa.String),
sa.column("description", sa.String),
sa.column("sort_order", sa.Integer),
sa.column("is_builtin", sa.Integer),
),
[
{
"id": 1,
"role_code": "salesperson",
"role_name": "营销员",
"calc_template": "TPL_SALESPERSON_DECAY",
"description": "业务员个人户:回款天数线性递减(10‰ − 天数×4/360‰,361 天起固定 4‰)",
"sort_order": 1,
"is_builtin": 1,
},
{
"id": 2,
"role_code": "sales_manager",
"role_name": "营销经理",
"calc_template": "TPL_MANAGER_SHARE",
"description": "经理绩效:∑本团队业务员绩效×12.5% + ∑全公司业务员绩效×5%",
"sort_order": 2,
"is_builtin": 1,
},
{
"id": 3,
"role_code": "info_officer",
"role_name": "信息员",
"calc_template": "TPL_INFO_SALES_FIXED",
"description": "信息员绩效:本人销售额×1‰",
"sort_order": 3,
"is_builtin": 1,
},
],
)
op.bulk_insert(
sa.table(
"param_change_batches",
sa.column("id", sa.BigInteger),
sa.column("version_no", sa.String),
sa.column("effective_date", sa.Date),
sa.column("change_type", sa.Integer),
sa.column("remark", sa.String),
sa.column("operator_name", sa.String),
sa.column("item_count", sa.Integer),
),
[
{
"id": 1,
"version_no": "P20260101-00",
"effective_date": date(2026, 1, 1),
"change_type": 1,
"remark": "初始版本(系统初始化种子,口径依据 2026-10-12 业务裁决)",
"operator_name": "system",
"item_count": 19,
}
],
)
op.bulk_insert(
sa.table(
"param_definitions",
sa.column("id", sa.BigInteger),
sa.column("param_code", sa.String),
sa.column("param_name", sa.String),
sa.column("param_group", sa.String),
sa.column("value_type", sa.String),
sa.column("unit", sa.String),
sa.column("dimension_type", sa.String),
sa.column("options_text", sa.String),
sa.column("default_value", sa.String),
sa.column("value_min", sa.String),
sa.column("value_max", sa.String),
sa.column("description", sa.String),
sa.column("sort_order", sa.Integer),
),
[
{
"id": 1,
"param_code": "salesperson.rate.start",
"param_name": "业务员起始计提比例",
"param_group": "业务员个人户",
"value_type": "DECIMAL",
"unit": "‰",
"dimension_type": "BUSINESS_ROLE",
"options_text": None,
"default_value": "10",
"value_min": "0",
"value_max": None,
"description": "线性递减段起始值:比例 = 起始 − 回款天数 × 斜率(≤分界天数)",
"sort_order": 1,
},
{
"id": 2,
"param_code": "salesperson.rate.slope",
"param_name": "业务员日递减斜率",
"param_group": "业务员个人户",
"value_type": "DECIMAL",
"unit": "‰/天",
"dimension_type": "BUSINESS_ROLE",
"options_text": None,
"default_value": "4/360",
"value_min": "0",
"value_max": None,
"description": "精确分数 4/360‰/天(=1/90),禁止按 0.011111 截断存储",
"sort_order": 2,
},
{
"id": 3,
"param_code": "salesperson.rate.floor",
"param_name": "业务员计提下限",
"param_group": "业务员个人户",
"value_type": "DECIMAL",
"unit": "‰",
"dimension_type": "BUSINESS_ROLE",
"options_text": None,
"default_value": "4",
"value_min": "0",
"value_max": None,
"description": "回款天数超过分界天数后固定取此比例(≥361 天 = 4‰)",
"sort_order": 3,
},
{
"id": 4,
"param_code": "salesperson.days.boundary",
"param_name": "线性递减分界天数",
"param_group": "业务员个人户",
"value_type": "INT",
"unit": "天",
"dimension_type": "BUSINESS_ROLE",
"options_text": None,
"default_value": "360",
"value_min": "0",
"value_max": None,
"description": "≤分界天数按公式线性递减(360 天=6‰);超过则取下限",
"sort_order": 4,
},
{
"id": 5,
"param_code": "manager.share.team",
"param_name": "经理本团队分成比例",
"param_group": "经理分成",
"value_type": "DECIMAL",
"unit": "%",
"dimension_type": "BUSINESS_ROLE",
"options_text": None,
"default_value": "12.5",
"value_min": "0",
"value_max": "100",
"description": "∑本人带领业务员绩效 × 此比例(限本团队)",
"sort_order": 5,
},
{
"id": 6,
"param_code": "manager.share.company",
"param_name": "经理全公司分成比例",
"param_group": "经理分成",
"value_type": "DECIMAL",
"unit": "%",
"dimension_type": "BUSINESS_ROLE",
"options_text": None,
"default_value": "5",
"value_min": "0",
"value_max": "100",
"description": "∑全部业务员绩效 × 此比例(全公司)",
"sort_order": 6,
},
{
"id": 7,
"param_code": "enterprise.rate",
"param_name": "企业户计提比例",
"param_group": "企业户",
"value_type": "DECIMAL",
"unit": "‰",
"dimension_type": "PRODUCT_CATEGORY",
"options_text": None,
"default_value": "3",
"value_min": "0",
"value_max": None,
"description": "所有企业户适用;2026-08-13 起由 2‰ 提高至 3‰",
"sort_order": 7,
},
{
"id": 8,
"param_code": "info.rate",
"param_name": "信息员计提比例",
"param_group": "信息员",
"value_type": "DECIMAL",
"unit": "‰",
"dimension_type": "BUSINESS_ROLE",
"options_text": None,
"default_value": "1",
"value_min": "0",
"value_max": None,
"description": "信息员绩效 = 本人销售额 × 此比例",
"sort_order": 8,
},
{
"id": 9,
"param_code": "prepay.rate",
"param_name": "预付款计提比例",
"param_group": "支付环节",
"value_type": "DECIMAL",
"unit": "‰",
"dimension_type": "NONE",
"options_text": None,
"default_value": "10",
"value_min": "0",
"value_max": None,
"description": "预付款统一按此比例计提",
"sort_order": 9,
},
{
"id": 10,
"param_code": "barter.discount",
"param_name": "以物抵顶折算比例",
"param_group": "支付环节",
"value_type": "DECIMAL",
"unit": "%",
"dimension_type": "NONE",
"options_text": None,
"default_value": "80",
"value_min": "0",
"value_max": "100",
"description": "以物抵顶回款绩效按应发绩效的此比例折算",
"sort_order": 10,
},
{
"id": 11,
"param_code": "payment.start.ratio",
"param_name": "支付起付比例",
"param_group": "支付环节",
"value_type": "DECIMAL",
"unit": "%",
"dimension_type": "NONE",
"options_text": None,
"default_value": "60",
"value_min": "0",
"value_max": "100",
"description": "单项业务回款额达销售额(不含运费)此比例后起付;达标前累计",
"sort_order": 11,
},
{
"id": 12,
"param_code": "payment.weight.mode",
"param_name": "支付权重应用方式",
"param_group": "支付环节",
"value_type": "ENUM",
"unit": None,
"dimension_type": "NONE",
"options_text": "FULL|BALANCE",
"default_value": "FULL",
"value_min": None,
"value_max": None,
"description": "FULL=按 R×R 乘当月全额应发绩效;BALANCE=乘余额(语义待 M3 前确认)",
"sort_order": 12,
},
{
"id": 13,
"param_code": "product.threshold",
"param_name": "折算毛利润额阈值",
"param_group": "产品阈值",
"value_type": "DECIMAL",
"unit": "%",
"dimension_type": "PRODUCT_CATEGORY",
"options_text": None,
"default_value": "30",
"value_min": "0",
"value_max": "100",
"description": "折算毛利润额 = 销售额 ×(毛利润率 − 阈值);市政水利 30%、轨交 20%",
"sort_order": 13,
},
{
"id": 14,
"param_code": "salary.basic.mode",
"param_name": "月工资基本工资口径",
"param_group": "月工资口径",
"value_type": "ENUM",
"unit": None,
"dimension_type": "BUSINESS_ROLE",
"options_text": "INCLUDE|DEDUCT|EXCLUDE",
"default_value": "INCLUDE",
"value_min": None,
"value_max": None,
"description": "INCLUDE含基本工资 / DEDUCT扣除基本工资 / EXCLUDE不计",
"sort_order": 14,
},
{
"id": 15,
"param_code": "probation.annual.threshold",
"param_name": "试用期年累计结算阈值",
"param_group": "月工资口径",
"value_type": "DECIMAL",
"unit": "元",
"dimension_type": "NONE",
"options_text": None,
"default_value": "18000",
"value_min": "0",
"value_max": None,
"description": "试用期年累计绩效超过此值部分年底一次性结算",
"sort_order": 15,
},
{
"id": 16,
"param_code": "probation.salary.basic",
"param_name": "试用期基本工资标准",
"param_group": "月工资口径",
"value_type": "DECIMAL",
"unit": "元",
"dimension_type": "NONE",
"options_text": None,
"default_value": "3000",
"value_min": "0",
"value_max": None,
"description": "试用期仅发基本工资、不发放绩效(归属待 M2 复核)",
"sort_order": 16,
},
],
)
op.bulk_insert(
sa.table(
"param_values",
sa.column("id", sa.BigInteger),
sa.column("param_id", sa.BigInteger),
sa.column("dimension_value", sa.String),
sa.column("effective_date", sa.Date),
sa.column("value_text", sa.String),
sa.column("batch_id", sa.BigInteger),
),
[
# 业务员个人户(2026-02-17 折算公式执行日起)
{
"id": 1,
"param_id": 1,
"dimension_value": "salesperson",
"effective_date": date(2026, 2, 17),
"value_text": "10",
"batch_id": 1,
},
{
"id": 2,
"param_id": 2,
"dimension_value": "salesperson",
"effective_date": date(2026, 2, 17),
"value_text": "4/360",
"batch_id": 1,
},
{
"id": 3,
"param_id": 3,
"dimension_value": "salesperson",
"effective_date": date(2026, 2, 17),
"value_text": "4",
"batch_id": 1,
},
{
"id": 4,
"param_id": 4,
"dimension_value": "salesperson",
"effective_date": date(2026, 2, 17),
"value_text": "360",
"batch_id": 1,
},
# 经理与信息员(2026-08-13 执行日起)
{
"id": 5,
"param_id": 5,
"dimension_value": "sales_manager",
"effective_date": date(2026, 8, 13),
"value_text": "12.5",
"batch_id": 1,
},
{
"id": 6,
"param_id": 6,
"dimension_value": "sales_manager",
"effective_date": date(2026, 8, 13),
"value_text": "5",
"batch_id": 1,
},
{
"id": 7,
"param_id": 7,
"dimension_value": "",
"effective_date": date(2026, 2, 17),
"value_text": "2",
"batch_id": 1,
},
{
"id": 8,
"param_id": 7,
"dimension_value": "",
"effective_date": date(2026, 8, 13),
"value_text": "3",
"batch_id": 1,
},
{
"id": 9,
"param_id": 8,
"dimension_value": "info_officer",
"effective_date": date(2026, 8, 13),
"value_text": "1",
"batch_id": 1,
},
# 支付环节与通用口径(需规默认值,无历史冲突)
{
"id": 10,
"param_id": 9,
"dimension_value": "",
"effective_date": date(2026, 1, 1),
"value_text": "10",
"batch_id": 1,
},
{
"id": 11,
"param_id": 10,
"dimension_value": "",
"effective_date": date(2026, 1, 1),
"value_text": "80",
"batch_id": 1,
},
{
"id": 12,
"param_id": 11,
"dimension_value": "",
"effective_date": date(2026, 1, 1),
"value_text": "60",
"batch_id": 1,
},
{
"id": 13,
"param_id": 12,
"dimension_value": "",
"effective_date": date(2026, 1, 1),
"value_text": "FULL",
"batch_id": 1,
},
# 产品阈值(按产品类别维度)
{
"id": 14,
"param_id": 13,
"dimension_value": "municipal_water",
"effective_date": date(2026, 2, 17),
"value_text": "30",
"batch_id": 1,
},
{
"id": 15,
"param_id": 13,
"dimension_value": "rail_transit",
"effective_date": date(2026, 2, 17),
"value_text": "20",
"batch_id": 1,
},
# 月工资口径(按岗位维度)
{
"id": 16,
"param_id": 14,
"dimension_value": "salesperson",
"effective_date": date(2026, 1, 1),
"value_text": "INCLUDE",
"batch_id": 1,
},
{
"id": 17,
"param_id": 14,
"dimension_value": "sales_manager",
"effective_date": date(2026, 1, 1),
"value_text": "DEDUCT",
"batch_id": 1,
},
{
"id": 18,
"param_id": 15,
"dimension_value": "",
"effective_date": date(2026, 1, 1),
"value_text": "18000",
"batch_id": 1,
},
{
"id": 19,
"param_id": 16,
"dimension_value": "",
"effective_date": date(2026, 1, 1),
"value_text": "3000",
"batch_id": 1,
},
],
)
def downgrade() -> None:
"""回滚迁移。
**注意(autogenerate 不会替你处理)**:生成的 `op.drop_index` 与
`op.drop_table` 顺序需人工检查 —— 若某索引是外键的唯一支撑索引,
单独 drop_index 会报 MySQL 1553(needed in a foreign key constraint)。
**整表回滚应只保留 `op.drop_table`**,DROP TABLE 会连带删除索引与外键。
"""
# 整表回滚只保留 drop_table —— DROP TABLE 会连带删除索引与外键。
# 原 autogenerate 输出的逐个 drop_index 会失败:组合索引最左列 param_id 与
# idx_param_values_batch_id 分别是两个外键的唯一支撑索引(MySQL errno 1553)。
# 删除顺序:先删引用方 param_values,再删被引用方。
op.drop_table("param_values")
op.drop_table("param_change_batches")
op.drop_table("param_definitions")
op.drop_table("business_roles")
+44 -2
View File
@@ -2,16 +2,18 @@
from typing import Annotated from typing import Annotated
from fastapi import Depends from fastapi import Depends, Request
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.core.constants import ROLE_ADMIN
from app.core.db import get_db from app.core.db import get_db
from app.core.exceptions import AuthError from app.core.exceptions import AuthError, ForbiddenError
from app.core.security import decode_token from app.core.security import decode_token
from app.models import User from app.models import User
from app.repositories import user_repository from app.repositories import user_repository
from app.services import token_blacklist from app.services import token_blacklist
from app.services.audit_service import RequestMeta
# auto_error=False:令牌缺失时由我们自己抛 AuthError, # auto_error=False:令牌缺失时由我们自己抛 AuthError,
# 才能返回统一响应信封,而不是 FastAPI 默认的 {"detail": "Not authenticated"} # 才能返回统一响应信封,而不是 FastAPI 默认的 {"detail": "Not authenticated"}
@@ -21,6 +23,29 @@ BearerCredentials = Annotated[HTTPAuthorizationCredentials | None, Depends(_bear
DbSession = Annotated[Session, Depends(get_db)] 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: def get_raw_token(credentials: BearerCredentials) -> str:
"""取原始令牌字符串。登出时需要它来解析 jti。""" """取原始令牌字符串。登出时需要它来解析 jti。"""
if credentials is None or not credentials.credentials: if credentials is None or not credentials.credentials:
@@ -71,3 +96,20 @@ def _is_digits(value: object) -> bool:
令牌内容不可信,直接 int() 会因非法输入抛 ValueError 而不是 401。 令牌内容不可信,直接 int() 会因非法输入抛 ValueError 而不是 401。
""" """
return isinstance(value, str) and value.isdigit() return isinstance(value, str) and value.isdigit()
def require_admin(current_user: Annotated[User, Depends(get_current_user)]) -> User:
"""要求当前用户具备管理员角色。
需求 2.5.1 只定义了「系统管理员(全部权限)」与「普通用户(业务操作)」两个
角色,故权限判定用**角色粗粒度**即可,不必引入权限码表 —— `permissions`
与 `role_permissions` 表已建好,等业务方给出权限清单后再启用。
:raises ForbiddenError: 已登录但不具备管理员角色(403)
"""
if ROLE_ADMIN not in user_repository.get_role_codes(current_user):
raise ForbiddenError("需要管理员权限")
return current_user
AdminUser = Annotated[User, Depends(require_admin)]
+16
View File
@@ -8,6 +8,11 @@ from fastapi import APIRouter
from app.api import health from app.api import health
from app.api.v1 import auth from app.api.v1 import auth
from app.api.v1.system import business_roles as system_business_roles
from app.api.v1.system import logs as system_logs
from app.api.v1.system import params as system_params
from app.api.v1.system import roles as system_roles
from app.api.v1.system import users as system_users
api_router = APIRouter() api_router = APIRouter()
@@ -17,4 +22,15 @@ api_router.include_router(health.router)
# 业务接口:/api/v1/* # 业务接口:/api/v1/*
v1_router = APIRouter(prefix="/v1") v1_router = APIRouter(prefix="/v1")
v1_router.include_router(auth.router) 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_business_roles.router)
system_router.include_router(system_params.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) api_router.include_router(v1_router)
+2 -12
View File
@@ -4,7 +4,7 @@ from typing import Annotated
from fastapi import APIRouter, Depends, Request 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.core.response import ApiResponse, ok
from app.models import User from app.models import User
from app.repositories.user_repository import get_role_codes 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=["认证"]) 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="获取图形验证码") @router.get("/captcha", response_model=ApiResponse[CaptchaOut], summary="获取图形验证码")
def get_captcha() -> ApiResponse[CaptchaOut]: def get_captcha() -> ApiResponse[CaptchaOut]:
"""签发图形验证码。无需认证。 """签发图形验证码。无需认证。
@@ -52,7 +42,7 @@ def login(payload: LoginIn, request: Request, session: DbSession) -> ApiResponse
password=payload.password, password=payload.password,
captcha_id=payload.captcha_id, captcha_id=payload.captcha_id,
captcha_code=payload.captcha_code, captcha_code=payload.captcha_code,
ip=_client_ip(request), ip=client_ip(request),
user_agent=request.headers.get("user-agent"), user_agent=request.headers.get("user-agent"),
) )
return ok(result, message="登录成功") return ok(result, message="登录成功")
+7
View File
@@ -0,0 +1,7 @@
"""系统管理路由(需求 2.5.1)。
含用户管理、角色管理、日志查询(操作日志 + 登录日志)四部分,全部要求管理员权限。
与 `api/v1/auth.py` 的分工:`auth` 是登录链路(未认证即可访问的验证码与登录,
以及任何登录用户都能访问的 `/me` 与登出);本包是**管理功能**。
"""
+122
View File
@@ -0,0 +1,122 @@
"""业务岗位角色管理接口(需求 #28:岗位可扩展 + 绑定公式模板)。
保护规则(内置岗位禁删禁停、编码不可改、模板须在注册表内)见
`business_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.business_role import (
BusinessRoleCreateIn,
BusinessRoleOut,
BusinessRoleQuery,
BusinessRoleStatusIn,
BusinessRoleUpdateIn,
TemplateOptionOut,
)
from app.services import business_role_service
router = APIRouter(prefix="/business-roles", tags=["系统管理 · 岗位角色"])
@router.get("", response_model=ApiResponse[PageResult[BusinessRoleOut]], summary="岗位列表")
def list_roles(
session: DbSession,
_: AdminUser,
query: Annotated[BusinessRoleQuery, Query()],
) -> ApiResponse[PageResult[BusinessRoleOut]]:
"""分页查询业务岗位角色(含停用)。"""
return ok(business_role_service.list_roles(session, query))
@router.post("", response_model=ApiResponse[BusinessRoleOut], summary="新增岗位")
def create_role(
session: DbSession,
current_user: AdminUser,
payload: BusinessRoleCreateIn,
meta: RequestMetaDep,
) -> ApiResponse[BusinessRoleOut]:
"""新增岗位。编码全局唯一、创建后不可修改;模板须在注册表内;新岗位默认启用。"""
return ok(
business_role_service.create_role(session, payload, operator=current_user, meta=meta),
message="岗位已创建",
)
@router.get(
"/meta/templates",
response_model=ApiResponse[list[TemplateOptionOut]],
summary="公式模板下拉",
)
def list_template_options(session: DbSession, _: AdminUser) -> ApiResponse[list[TemplateOptionOut]]:
"""取公式模板下拉(含声明参数,供岗位表单展示)。
定义在 `/{role_id}` 之前:静态路径优先,避免 "meta" 被当作路径参数解析。
"""
return ok(business_role_service.list_template_options())
@router.get("/{role_id}", response_model=ApiResponse[BusinessRoleOut], summary="岗位详情")
def get_role(
session: DbSession,
_: AdminUser,
role_id: Annotated[int, Path(ge=1)],
) -> ApiResponse[BusinessRoleOut]:
"""按主键查询岗位详情。"""
return ok(business_role_service.get_role(session, role_id))
@router.put("/{role_id}", response_model=ApiResponse[BusinessRoleOut], summary="编辑岗位")
def update_role(
session: DbSession,
current_user: AdminUser,
role_id: Annotated[int, Path(ge=1)],
payload: BusinessRoleUpdateIn,
meta: RequestMetaDep,
) -> ApiResponse[BusinessRoleOut]:
"""编辑岗位名称/模板/说明/排序。
**不含 role_code** —— 编码是参数维度值与模板绑定的锚点。
"""
return ok(
business_role_service.update_role(
session, role_id, payload, operator=current_user, meta=meta
),
message="已保存",
)
@router.put(
"/{role_id}/status", response_model=ApiResponse[BusinessRoleOut], summary="启用/禁用岗位"
)
def set_status(
session: DbSession,
current_user: AdminUser,
role_id: Annotated[int, Path(ge=1)],
payload: BusinessRoleStatusIn,
meta: RequestMetaDep,
) -> ApiResponse[BusinessRoleOut]:
"""启用或禁用岗位。内置岗位不可停用。"""
result = business_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]:
"""删除岗位。内置岗位不可删除;有人员引用的岗位不可删除。"""
business_role_service.delete_role(session, role_id, operator=current_user, meta=meta)
return ok(message="岗位已删除")
+67
View File
@@ -0,0 +1,67 @@
"""日志查询接口(需求 2.5.1:支持日志查询)。
操作日志与登录日志是两个独立端点:两表字段与筛选项不同(前者含模块/操作/
请求详情,后者含失败原因),合并会让 `action` 等参数产生二义;前端正好是
两个 tab。操作日志另有单条详情(列表为性能排除 `request_data`)。
均要求管理员权限;只读查询不写审计(读操作记日志只会淹没审计表)。
"""
from typing import Annotated
from fastapi import APIRouter, Path, Query
from app.api.deps import AdminUser, DbSession
from app.core.response import ApiResponse, PageResult, ok
from app.schemas.log import (
LoginLogOut,
LoginLogQuery,
OperationLogDetailOut,
OperationLogOut,
OperationLogQuery,
)
from app.services import log_service
operation_router = APIRouter(prefix="/operation-logs", tags=["系统管理 · 日志"])
login_router = APIRouter(prefix="/login-logs", tags=["系统管理 · 日志"])
@operation_router.get(
"", response_model=ApiResponse[PageResult[OperationLogOut]], summary="操作日志"
)
def list_operation_logs(
session: DbSession,
_: AdminUser,
query: Annotated[OperationLogQuery, Query()],
) -> ApiResponse[PageResult[OperationLogOut]]:
"""分页查询操作日志,支持按操作人/模块/操作类型/结果/时间范围筛选。
按 id 倒序(时间倒序的稳定实现)。列表**不含** `request_data`(大字段),
展开详情请调 `GET /operation-logs/{log_id}`。
"""
return ok(log_service.list_operation_logs(session, query))
@operation_router.get(
"/{log_id}", response_model=ApiResponse[OperationLogDetailOut], summary="操作日志详情"
)
def get_operation_log(
session: DbSession,
_: AdminUser,
log_id: Annotated[int, Path(ge=1)],
) -> ApiResponse[OperationLogDetailOut]:
"""查询单条操作日志详情(含已脱敏的请求参数)。
前端展开日志行时按 id 按需加载 —— 避免列表翻页携带大字段。
"""
return ok(log_service.get_operation_log(session, log_id))
@login_router.get("", response_model=ApiResponse[PageResult[LoginLogOut]], summary="登录日志")
def list_login_logs(
session: DbSession,
_: AdminUser,
query: Annotated[LoginLogQuery, Query()],
) -> ApiResponse[PageResult[LoginLogOut]]:
"""分页查询登录日志,支持按用户名/IP/结果/时间范围筛选。"""
return ok(log_service.list_login_logs(session, query))
+147
View File
@@ -0,0 +1,147 @@
"""计提参数管理接口(需求 #28:参数配置化 + 版本管理 + 留痕)。
全部要求管理员权限(开放问题 #5 的当前默认,后续可放宽只读);
写操作(提交/撤回)经 `RequestMetaDep` 带入请求元信息写入操作日志,
变更批次本身另行留痕(变更人/时间/项/旧值/新值/生效日期)——双保险。
导出为文件流(非统一信封),前端以 blob 接收。
"""
from datetime import date
from typing import Annotated
from fastapi import APIRouter, Path, Query
from fastapi.responses import StreamingResponse
from app.api.deps import AdminUser, DbSession, RequestMetaDep
from app.core.response import ApiResponse, PageResult, ok
from app.schemas.param import (
ParamBatchDetailOut,
ParamBatchOut,
ParamBatchQuery,
ParamCatalogItemOut,
ParamChangeBatchOut,
ParamChangeIn,
ParamDiffOut,
ParamHistoryOut,
ParamHistoryQuery,
ParamPreviewIn,
ParamPreviewOut,
)
from app.services import param_service
router = APIRouter(prefix="/params", tags=["系统管理 · 计提参数"])
@router.get(
"", response_model=ApiResponse[list[ParamCatalogItemOut]], summary="参数目录(含当前生效值)"
)
def get_catalog(
session: DbSession,
_: AdminUser,
as_of: Annotated[date | None, Query(description="业务日期,默认今日")] = None,
) -> ApiResponse[list[ParamCatalogItemOut]]:
"""全部启用参数的定义元数据与截至 asOf 的生效值(前端按分组展示)。"""
return ok(param_service.get_catalog(session, as_of=as_of))
@router.post("/changes", response_model=ApiResponse[ParamChangeBatchOut], summary="提交变更批次")
def submit_change(
session: DbSession,
current_user: AdminUser,
payload: ParamChangeIn,
meta: RequestMetaDep,
) -> ApiResponse[ParamChangeBatchOut]:
"""提交一批参数变更(1~50 项),返回版本号;立即生效或指定日期生效。"""
return ok(
param_service.submit_change(session, payload, operator=current_user, meta=meta),
message="变更已提交",
)
@router.get(
"/changes", response_model=ApiResponse[PageResult[ParamBatchOut]], summary="变更记录分页"
)
def list_changes(
session: DbSession,
_: AdminUser,
query: Annotated[ParamBatchQuery, Query()],
) -> ApiResponse[PageResult[ParamBatchOut]]:
"""按生效日期区间分页查询变更批次(创建时间倒序)。"""
return ok(param_service.list_batches(session, query))
@router.get(
"/changes/{batch_id}", response_model=ApiResponse[ParamBatchDetailOut], summary="批次明细"
)
def get_batch(
session: DbSession,
_: AdminUser,
batch_id: Annotated[int, Path(ge=1)],
) -> ApiResponse[ParamBatchDetailOut]:
"""批次头 + 逐项旧值 → 新值。"""
return ok(param_service.get_batch_detail(session, batch_id))
@router.delete("/changes/{batch_id}", response_model=ApiResponse[None], summary="撤回未生效批次")
def withdraw_batch(
session: DbSession,
current_user: AdminUser,
batch_id: Annotated[int, Path(ge=1)],
meta: RequestMetaDep,
) -> ApiResponse[None]:
"""撤回尚未生效的批次;已生效批次不可撤回。"""
param_service.withdraw_batch(session, batch_id, operator=current_user, meta=meta)
return ok(message="批次已撤回")
@router.get("/compare", response_model=ApiResponse[list[ParamDiffOut]], summary="两日期快照对比")
def compare(
session: DbSession,
_: AdminUser,
from_date: Annotated[date, Query(description="起始业务日期(含)")],
to_date: Annotated[date, Query(description="截止业务日期(含)")],
) -> ApiResponse[list[ParamDiffOut]]:
"""两个业务日期的全量参数快照逐项对比,changed 标记是否变化。"""
return ok(param_service.compare(session, from_date=from_date, to_date=to_date))
@router.get("/export", summary="导出变更记录 Excel")
def export_changes(
session: DbSession,
_: AdminUser,
start_date: Annotated[date | None, Query(description="生效日期起(含)")] = None,
end_date: Annotated[date | None, Query(description="生效日期止(含)")] = None,
) -> StreamingResponse:
"""变更记录导出为 Excel 文件流(非统一信封,前端以 blob 接收)。"""
content = param_service.export_changes(session, start_date=start_date, end_date=end_date)
filename = f"param_changes_{date.today():%Y%m%d}.xlsx"
return StreamingResponse(
iter([content]),
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
)
@router.post("/preview", response_model=ApiResponse[ParamPreviewOut], summary="计算预览")
def preview(
session: DbSession,
_: AdminUser,
payload: ParamPreviewIn,
) -> ApiResponse[ParamPreviewOut]:
"""输入业务场景(岗位/产品/发货/回款/金额),按回款到账日取参数实时试算。"""
return ok(param_service.preview(session, payload))
@router.get(
"/{param_code}/history",
response_model=ApiResponse[PageResult[ParamHistoryOut]],
summary="参数版本历史",
)
def get_history(
session: DbSession,
_: AdminUser,
param_code: Annotated[str, Path(max_length=64)],
query: Annotated[ParamHistoryQuery, Query()],
) -> ApiResponse[PageResult[ParamHistoryOut]]:
"""单参数的版本历史(含版本号、变更人与旧值 → 新值),最新在前。"""
return ok(param_service.get_history(session, param_code, query))
+100
View File
@@ -0,0 +1,100 @@
"""角色管理接口(自主完善模块:需求 2.5.1 仅定义 admin / user 两个角色)。
保护规则(admin 禁删禁停、有绑定的角色禁删、role_code 不可改)见
`role_service` 模块 docstring。前端会禁用相应按钮,但**后端校验是唯一防线**。
全部要求管理员权限;写操作经 `RequestMetaDep` 带入请求元信息由 service
层写入操作日志。
"""
from typing import Annotated
from fastapi import APIRouter, Path, Query
from app.api.deps import AdminUser, DbSession, RequestMetaDep
from app.core.response import ApiResponse, PageResult, ok
from app.schemas.role import RoleCreateIn, RoleManageOut, RoleQuery, RoleStatusIn, RoleUpdateIn
from app.services import role_service
router = APIRouter(prefix="/roles", tags=["系统管理 · 角色"])
@router.get("", response_model=ApiResponse[PageResult[RoleManageOut]], summary="角色列表")
def list_roles(
session: DbSession,
_: AdminUser,
query: Annotated[RoleQuery, Query()],
) -> ApiResponse[PageResult[RoleManageOut]]:
"""分页查询角色。
与用户表单的角色下拉口径不同:**本列表含停用角色**(下拉只给启用项)。
每行附带 `user_count`(绑定用户数),供前端提示删除保护。
"""
return ok(role_service.list_roles(session, query))
@router.post("", response_model=ApiResponse[RoleManageOut], summary="新增角色")
def create_role(
session: DbSession,
current_user: AdminUser,
payload: RoleCreateIn,
meta: RequestMetaDep,
) -> ApiResponse[RoleManageOut]:
"""新增角色。编码全局唯一、创建后不可修改;新角色默认启用。"""
return ok(
role_service.create_role(session, payload, operator=current_user, meta=meta),
message="角色已创建",
)
@router.put("/{role_id}", response_model=ApiResponse[RoleManageOut], summary="编辑角色")
def update_role(
session: DbSession,
current_user: AdminUser,
role_id: Annotated[int, Path(ge=1)],
payload: RoleUpdateIn,
meta: RequestMetaDep,
) -> ApiResponse[RoleManageOut]:
"""编辑角色名称/说明/排序。
**不含 role_code** —— 编码是授权判定(`ROLE_ADMIN` 常量)的锚点。
"""
return ok(
role_service.update_role(session, role_id, payload, operator=current_user, meta=meta),
message="已保存",
)
@router.put("/{role_id}/status", response_model=ApiResponse[RoleManageOut], summary="启用/禁用角色")
def set_status(
session: DbSession,
current_user: AdminUser,
role_id: Annotated[int, Path(ge=1)],
payload: RoleStatusIn,
meta: RequestMetaDep,
) -> ApiResponse[RoleManageOut]:
"""启用或禁用角色。
停用**不解除**用户绑定、也不影响既有授权(授权判定只看编码、不看状态);
内置管理员角色不可停用。
"""
result = role_service.set_status(
session, role_id, payload.status, operator=current_user, meta=meta
)
return ok(result, message="已启用" if payload.status == 1 else "已禁用")
@router.delete("/{role_id}", response_model=ApiResponse[None], summary="删除角色")
def delete_role(
session: DbSession,
current_user: AdminUser,
role_id: Annotated[int, Path(ge=1)],
meta: RequestMetaDep,
) -> ApiResponse[None]:
"""删除角色。
内置管理员角色不可删除;仍有关联用户的角色不可删除(提示先解除绑定)——
`user_roles` 外键是 CASCADE,后端拦截是防止静默剥离权限的唯一防线。
"""
role_service.delete_role(session, role_id, operator=current_user, meta=meta)
return ok(message="角色已删除")
+146
View File
@@ -0,0 +1,146 @@
"""用户管理接口(需求 2.5.1:用户账号管理)。
全部要求管理员权限 —— 按需求「系统管理员(全部权限)、普通用户(业务操作)」,
用户管理属管理员专属。
删除、禁用、改角色三处都有**自我保护**:不允许对当前登录账号执行,
否则管理员误操作会立刻失去全部管理权限且无法自行恢复。
全部写操作经 `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 RoleOut
from app.schemas.user import (
PasswordResetIn,
UserCreateIn,
UserOut,
UserQuery,
UserStatusIn,
UserUpdateIn,
)
from app.services import user_service
router = APIRouter(prefix="/users", tags=["系统管理 · 用户"])
@router.get("", response_model=ApiResponse[PageResult[UserOut]], summary="用户列表")
def list_users(
session: DbSession,
_: AdminUser,
query: Annotated[UserQuery, Query()],
) -> ApiResponse[PageResult[UserOut]]:
"""分页查询用户,支持按用户名/姓名模糊、状态与角色筛选。"""
return ok(user_service.list_users(session, query))
@router.get("/{user_id}", response_model=ApiResponse[UserOut], summary="用户详情")
def get_user(
session: DbSession,
_: AdminUser,
user_id: Annotated[int, Path(ge=1)],
) -> ApiResponse[UserOut]:
"""查询用户详情。"""
return ok(user_service.get_user(session, user_id))
@router.post("", response_model=ApiResponse[UserOut], summary="新增用户")
def create_user(
session: DbSession,
current_user: AdminUser,
payload: UserCreateIn,
meta: RequestMetaDep,
) -> ApiResponse[UserOut]:
"""新增用户。用户名须唯一,至少指定一个角色。"""
return ok(
user_service.create_user(session, payload, operator=current_user, meta=meta),
message="用户已创建",
)
@router.put("/{user_id}", response_model=ApiResponse[UserOut], summary="编辑用户")
def update_user(
session: DbSession,
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=current_user, meta=meta),
message="已保存",
)
@router.delete("/{user_id}", response_model=ApiResponse[None], summary="删除用户")
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=current_user, meta=meta)
return ok(message="用户已删除")
@router.put("/{user_id}/password", response_model=ApiResponse[None], summary="重置密码")
def reset_password(
session: DbSession,
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, operator=current_user, meta=meta
)
return ok(message="密码已重置,该用户需重新登录")
@router.put("/{user_id}/status", response_model=ApiResponse[UserOut], summary="启用/禁用用户")
def set_status(
session: DbSession,
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=current_user, meta=meta
)
return ok(result, message="已启用" if payload.status == 1 else "已禁用")
@router.get("/meta/roles", response_model=ApiResponse[list[RoleOut]], summary="角色下拉选项")
def list_roles(session: DbSession, _: AdminUser) -> ApiResponse[list[RoleOut]]:
"""取全部启用角色,供用户表单的角色选择使用。
路径放在 `/users/meta/roles` 而非 `/roles`,是为了避免与
`/{user_id}` 的路由匹配冲突(`roles` 会被当成 user_id 解析而报 422)。
"""
return ok(user_service.list_roles(session))
+24
View File
@@ -36,3 +36,27 @@ PASSWORD_MAX_BYTES = 72
# 剔除易混淆字符 0/O/1/I/l,降低人工识别错误率 # 剔除易混淆字符 0/O/1/I/l,降低人工识别错误率
CAPTCHA_CHARSET = "23456789ABCDEFGHJKLMNPQRSTUVWXYZ" CAPTCHA_CHARSET = "23456789ABCDEFGHJKLMNPQRSTUVWXYZ"
CAPTCHA_LENGTH = 4 CAPTCHA_LENGTH = 4
# ===== 业务岗位编码(与 db/migration 种子一致,business_roles.role_code)=====
BUSINESS_ROLE_SALESPERSON = "salesperson"
BUSINESS_ROLE_SALES_MANAGER = "sales_manager"
BUSINESS_ROLE_INFO_OFFICER = "info_officer"
# ===== 产品类别码(与 param_values.dimension_value 口径一致)=====
PRODUCT_CATEGORY_MUNICIPAL_WATER = "municipal_water"
PRODUCT_CATEGORY_RAIL_TRANSIT = "rail_transit"
PRODUCT_CATEGORIES = {PRODUCT_CATEGORY_MUNICIPAL_WATER, PRODUCT_CATEGORY_RAIL_TRANSIT}
# ===== 操作日志词表(operation_logs 的 module / action 取值)=====
# 集中定义避免各处硬编码导致日志值漂移;前端筛选下拉项与此保持一致
MODULE_USER = "用户管理"
MODULE_ROLE = "角色管理"
MODULE_BUSINESS_ROLE = "岗位角色"
MODULE_PARAM = "计提参数"
ACTION_CREATE = "新增"
ACTION_UPDATE = "修改"
ACTION_DELETE = "删除"
ACTION_RESET_PASSWORD = "重置密码"
ACTION_ENABLE = "启用"
ACTION_DISABLE = "禁用"
+2 -2
View File
@@ -29,8 +29,8 @@ _SENSITIVE_RULES: tuple[tuple[re.Pattern[str], str], ...] = (
), ),
# Authorization: Bearer xxx # Authorization: Bearer xxx
(re.compile(r"(Bearer\s+)[A-Za-z0-9\-._~+/]+=*"), r"\1***"), (re.compile(r"(Bearer\s+)[A-Za-z0-9\-._~+/]+=*"), r"\1***"),
# 手机号:138****8888 # 手机号:138****8888(兼容带空格/横线分隔的写法,如 138 0013 8000)
(re.compile(r"\b(1[3-9]\d)\d{4}(\d{4})\b"), r"\1****\2"), (re.compile(r"\b(1[3-9]\d)[-\s]?(\d{4})[-\s]?(\d{4})\b"), r"\1****\3"),
# 18 位身份证:保留前 6 位与后 4 位 # 18 位身份证:保留前 6 位与后 4 位
(re.compile(r"\b(\d{6})\d{8}(\d{3}[\dXx])\b"), r"\1********\2"), (re.compile(r"\b(\d{6})\d{8}(\d{3}[\dXx])\b"), r"\1********\2"),
) )
+8
View File
@@ -10,13 +10,21 @@
""" """
from app.models.base import Base, TimestampMixin from app.models.base import Base, TimestampMixin
from app.models.business_role import BusinessRole
from app.models.login_log import LoginLog from app.models.login_log import LoginLog
from app.models.operation_log import OperationLog
from app.models.param import ParamChangeBatch, ParamDefinition, ParamValue
from app.models.role import Permission, Role, RolePermission from app.models.role import Permission, Role, RolePermission
from app.models.user import User, UserRole from app.models.user import User, UserRole
__all__ = [ __all__ = [
"Base", "Base",
"BusinessRole",
"LoginLog", "LoginLog",
"OperationLog",
"ParamChangeBatch",
"ParamDefinition",
"ParamValue",
"Permission", "Permission",
"Role", "Role",
"RolePermission", "RolePermission",
+46
View File
@@ -0,0 +1,46 @@
"""业务岗位角色模型。
与系统登录角色(`roles`,权限维度)严格区分:本表是**业务岗位**维度,
决定人员适用的计提公式模板与参数。需求 #28 要求岗位角色可增删、
新增岗位时在系统设置中绑定公式与参数(无需改代码),故模板编码
存于本表、公式实现留在代码内注册表(一期不引入规则引擎)。
"""
from sqlalchemy import Integer, String
from sqlalchemy.orm import Mapped, mapped_column
from app.models.base import BIGINT_UNSIGNED, TINYINT_TYPE, Base, TimestampMixin, mysql_table_args
class BusinessRole(Base, TimestampMixin):
"""业务岗位角色表。内置三个岗位(营销员/营销经理/信息员)随迁移初始化。"""
__tablename__ = "business_roles"
__table_args__ = mysql_table_args("业务岗位角色表(与登录角色roles区分,决定计提公式与参数)")
id: Mapped[int] = mapped_column(
BIGINT_UNSIGNED, primary_key=True, autoincrement=True, comment="主键ID"
)
role_code: Mapped[str] = mapped_column(
String(32),
nullable=False,
unique=True,
comment="岗位编码:salesperson营销员 sales_manager营销经理 info_officer信息员,创建后不可修改",
)
role_name: Mapped[str] = mapped_column(String(64), nullable=False, comment="岗位名称")
calc_template: Mapped[str] = mapped_column(
String(32), nullable=False, comment="计提公式模板编码(以代码内模板注册表为准)"
)
description: Mapped[str | None] = mapped_column(String(255), comment="岗位说明")
sort_order: Mapped[int] = mapped_column(
Integer, nullable=False, server_default="0", comment="显示排序"
)
status: Mapped[int] = mapped_column(
TINYINT_TYPE, nullable=False, server_default="1", comment="状态:1启用 0禁用"
)
is_builtin: Mapped[int] = mapped_column(
TINYINT_TYPE,
nullable=False,
server_default="0",
comment="是否内置岗位:1内置(禁删禁停) 0自定义",
)
+99
View File
@@ -0,0 +1,99 @@
"""操作日志模型。
需求 2.5.1 要求「记录用户登录、操作行为及计提参数/系数表变更,支持日志查询」。
登录行为已在 `login_logs` 中单独记录(登录失败也要留痕,且不依赖认证上下文);
本表记录**已认证用户的操作行为**,两者分工明确、互不重叠。
不继承 TimestampMixin:日志是追加写、不可变的,没有"更新时间"语义。
"""
from datetime import datetime
from sqlalchemy import DateTime, ForeignKey, Index, Integer, String, Text, text
from sqlalchemy.orm import Mapped, mapped_column
from app.models.base import BIGINT_UNSIGNED, TINYINT_TYPE, Base, mysql_table_args
# 各文本列的上限。**写入方必须按此截断**,否则 MySQL 严格模式抛 1406
# (Data too long),而按"审计失败不得影响业务"的约定该异常会被吞掉 ——
# 结果是整条日志静默丢失。截断值集中定义于此,供 repository 层引用。
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
ERROR_MESSAGE_MAX_LEN = 500
# request_data 是 MySQL TEXT(上限 65535 字节)。脱敏后的 JSON 可能超限
# (如批量操作提交数千个 ID),超限同样会抛 1406,故写入方需按字节截断。
REQUEST_DATA_MAX_BYTES = 60000
class OperationLog(Base):
"""操作日志表。
记录原则与 `login_logs` 一致:
- **请求参数必须脱敏后入库**(复用 `core.logging.scrub`),日志是最容易泄漏
密码与令牌的地方
- 用户名冗余存储而非只存 user_id —— 账号被删除后仍能追溯操作者,
故 `username` 单独建索引(与 `login_logs` 保持一致)
- 写入失败不得影响业务(审计可丢,业务不可断)
"""
__tablename__ = "operation_logs"
__table_args__ = mysql_table_args(
"操作日志表",
Index("idx_operation_logs_user_id", "user_id"),
# 账号删除后 user_id 被置空,只能按用户名追溯,故该列必须有索引,
# 否则日志表增长后查询退化为全表扫描
Index("idx_operation_logs_username", "username"),
Index("idx_operation_logs_module", "module"),
Index("idx_operation_logs_create_time", "create_time"),
)
id: Mapped[int] = mapped_column(
BIGINT_UNSIGNED, primary_key=True, autoincrement=True, comment="主键ID"
)
user_id: Mapped[int | None] = mapped_column(
BIGINT_UNSIGNED,
ForeignKey("users.id", ondelete="SET NULL"),
comment="操作人ID(账号删除后置空)",
)
username: Mapped[str] = mapped_column(
String(USERNAME_MAX_LEN), nullable=False, comment="操作人用户名(冗余存储便于追溯)"
)
module: Mapped[str] = mapped_column(
String(MODULE_MAX_LEN), nullable=False, comment="所属模块,如 用户管理"
)
action: Mapped[str] = mapped_column(
String(ACTION_MAX_LEN), nullable=False, comment="操作类型:新增/修改/删除/导出"
)
summary: Mapped[str] = mapped_column(
String(SUMMARY_MAX_LEN), nullable=False, comment="操作摘要(中文,供人阅读)"
)
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(
String(USER_AGENT_MAX_LEN), comment="客户端User-Agent(截断存储)"
)
# 请求参数,**入库前已脱敏**;长文本故用 TEXT
request_data: Mapped[str | None] = mapped_column(Text, comment="请求参数(已脱敏)")
# 默认 0 而非 1:与 login_logs 保持一致,且方向更保守 ——
# 写入方漏传时记为「失败」,管理员会去核查;反之漏传记为「成功」则是
# 审计漏报,且无声无息。写入方应显式传值(见 repository 层)。
success: Mapped[int] = mapped_column(
TINYINT_TYPE, nullable=False, server_default="0", comment="是否成功:1成功 0失败"
)
error_message: Mapped[str | None] = mapped_column(
String(ERROR_MESSAGE_MAX_LEN), comment="失败原因(写入方须截断)"
)
duration_ms: Mapped[int | None] = mapped_column(Integer, comment="处理耗时(毫秒)")
create_time: Mapped[datetime] = mapped_column(
DateTime, nullable=False, server_default=text("CURRENT_TIMESTAMP"), comment="创建时间"
)
+159
View File
@@ -0,0 +1,159 @@
"""计提参数模型:定义、变更批次与版本条目。
存储设计(M1 评审定稿):
- 参数值按**生效日期时点化**存储(逐项版本条目,append-only),核算按业务
日期回溯取值(对照表 #11:按回款到账日匹配参数版本),禁读「当前值」算历史
- 「版本」由变更批次承载:一次保存 = 一个批次 = 一条变更记录;条目逐项留痕
(旧值/新值),满足 #28 版本管理与留痕(需规 2.3.1 四)
- 值一律为 Fraction 可解析字符串(如 "4/360",禁 0.011111),解析不经 float
- **不设 (param_id, dimension_value, effective_date) 唯一键**:同日多次修正
是合法留痕,解析取 (effective_date, id) 最新一条;唯一键会把同日二次变更
逼成覆盖写、丢失中间值
- 批次与条目均为 append-only(仅 create_time、无 update_time,同 login_logs
先例):带 ON UPDATE 的列会让「未改也被刷新」造成留痕假象
"""
from datetime import date, datetime
from sqlalchemy import Date, DateTime, ForeignKey, Index, Integer, String, text
from sqlalchemy.orm import Mapped, mapped_column
from app.models.base import BIGINT_UNSIGNED, TINYINT_TYPE, Base, TimestampMixin, mysql_table_args
class ParamDefinition(Base, TimestampMixin):
"""计提参数定义表。目录元数据(类型/单位/维度/取值域),随迁移种子初始化。"""
__tablename__ = "param_definitions"
__table_args__ = mysql_table_args("计提参数定义表")
id: Mapped[int] = mapped_column(
BIGINT_UNSIGNED, primary_key=True, autoincrement=True, comment="主键ID"
)
param_code: Mapped[str] = mapped_column(
String(64), nullable=False, unique=True, comment="参数编码,如 salesperson.rate.slope"
)
param_name: Mapped[str] = mapped_column(String(64), nullable=False, comment="参数名称")
param_group: Mapped[str] = mapped_column(
String(32),
nullable=False,
comment="参数分组:业务员个人户/经理分成/企业户/信息员/支付环节/产品阈值/月工资口径",
)
value_type: Mapped[str] = mapped_column(
String(16), nullable=False, comment="值类型:DECIMAL/INT/BOOL/ENUM/TEXT"
)
unit: Mapped[str | None] = mapped_column(String(16), comment="展示单位:‰/%/元/天,无单位为空")
dimension_type: Mapped[str] = mapped_column(
String(32),
nullable=False,
server_default="NONE",
comment="维度类型:NONE/PRODUCT_CATEGORY/BUSINESS_ROLE",
)
options_text: Mapped[str | None] = mapped_column(
String(255), comment="枚举选项(竖线分隔),仅 ENUM 使用"
)
default_value: Mapped[str | None] = mapped_column(
String(64), comment="系统默认值(Fraction 可解析字符串)"
)
value_min: Mapped[str | None] = mapped_column(String(64), comment="取值下限(含),校验用")
value_max: Mapped[str | None] = mapped_column(String(64), comment="取值上限(含),校验用")
description: Mapped[str | None] = mapped_column(String(255), comment="口径说明(配置页展示)")
sort_order: Mapped[int] = mapped_column(
Integer, nullable=False, server_default="0", comment="显示排序"
)
status: Mapped[int] = mapped_column(
TINYINT_TYPE, nullable=False, server_default="1", comment="状态:1启用 0停用"
)
class ParamChangeBatch(Base):
"""参数变更批次表(append-only)。
一次提交 = 一个批次;版本号(如 P20261012-01)供对比与追溯引用。
未生效批次可整批撤回(删批次与条目);已生效条目禁改禁删。
"""
__tablename__ = "param_change_batches"
__table_args__ = mysql_table_args("参数变更批次表")
id: Mapped[int] = mapped_column(
BIGINT_UNSIGNED, primary_key=True, autoincrement=True, comment="主键ID"
)
version_no: Mapped[str] = mapped_column(
String(32), nullable=False, unique=True, comment="版本号,如 P20261012-01"
)
effective_date: Mapped[date] = mapped_column(
Date, nullable=False, comment="生效日期(业务日期口径)"
)
change_type: Mapped[int] = mapped_column(
TINYINT_TYPE, nullable=False, comment="生效方式:1立即生效 2指定日期生效"
)
remark: Mapped[str | None] = mapped_column(String(255), comment="变更说明")
operator_id: Mapped[int | None] = mapped_column(
BIGINT_UNSIGNED,
ForeignKey("users.id", ondelete="SET NULL"),
comment="操作人ID(账号删除后置空)",
)
operator_name: Mapped[str] = mapped_column(
String(64), nullable=False, comment="操作人用户名(冗余固化)"
)
item_count: Mapped[int] = mapped_column(
Integer, nullable=False, server_default="0", comment="变更参数项数"
)
create_time: Mapped[datetime] = mapped_column(
DateTime, nullable=False, server_default=text("CURRENT_TIMESTAMP"), comment="创建时间"
)
class ParamValue(Base):
"""参数版本条目表(append-only)。
解析规则:按 (param_id, dimension_value) 取 effective_date <= 业务日期 的
ORDER BY effective_date DESC, id DESC 首条(同日多次变更取最新)。
dimension_value 用空串而非 NULL:MySQL 组合索引对 NULL 不去重,空串语义更稳。
"""
__tablename__ = "param_values"
__table_args__ = mysql_table_args(
"参数版本条目表(append-only,按生效日期时点化取值)",
Index(
"idx_param_values_param_id_dimension_value_effective_date",
"param_id",
"dimension_value",
"effective_date",
),
Index("idx_param_values_effective_date", "effective_date"),
Index("idx_param_values_batch_id", "batch_id"),
)
id: Mapped[int] = mapped_column(
BIGINT_UNSIGNED, primary_key=True, autoincrement=True, comment="主键ID"
)
param_id: Mapped[int] = mapped_column(
BIGINT_UNSIGNED,
ForeignKey("param_definitions.id", ondelete="RESTRICT"),
nullable=False,
comment="参数定义ID(param_definitions.id)",
)
dimension_value: Mapped[str] = mapped_column(
String(32),
nullable=False,
server_default=text("''"),
comment="维度取值:NONE维度为空串;产品类别/岗位编码为具体值",
)
effective_date: Mapped[date] = mapped_column(
Date, nullable=False, comment="生效日期(按业务日期时点化取值)"
)
value_text: Mapped[str] = mapped_column(
String(64), nullable=False, comment="参数值(Fraction 可解析字符串/枚举码/开关)"
)
old_value_text: Mapped[str | None] = mapped_column(String(64), comment="变更前值(留痕)")
batch_id: Mapped[int] = mapped_column(
BIGINT_UNSIGNED,
ForeignKey("param_change_batches.id", ondelete="RESTRICT"),
nullable=False,
comment="变更批次ID(param_change_batches.id)",
)
create_time: Mapped[datetime] = mapped_column(
DateTime, nullable=False, server_default=text("CURRENT_TIMESTAMP"), comment="创建时间"
)
@@ -0,0 +1,88 @@
"""业务岗位角色数据访问。
本层只 flush 不 commit —— 事务边界由 services 层控制。
"""
from sqlalchemy import Select, func, or_, select
from sqlalchemy.orm import Session
from app.models import BusinessRole
# ==================== 读取 ====================
def get_by_id(session: Session, role_id: int, *, for_update: bool = False) -> BusinessRole | None:
"""按主键查询。删除校验走 ``for_update`` 锁行(同 role_repository 的理由)。"""
if for_update:
stmt = select(BusinessRole).where(BusinessRole.id == role_id).with_for_update()
return session.execute(stmt).scalar_one_or_none()
return session.get(BusinessRole, role_id)
def get_by_code(session: Session, role_code: str) -> BusinessRole | None:
"""按编码查询岗位。编码是参数维度值(dimension_value)的锚点。"""
stmt = select(BusinessRole).where(BusinessRole.role_code == role_code)
return session.execute(stmt).scalar_one_or_none()
def list_roles(
session: Session,
*,
offset: int,
limit: int,
keyword: str | None = None,
status: int | None = None,
) -> list[BusinessRole]:
"""分页查询岗位(含停用)。排序 (sort_order, id),与系统角色口径一致。"""
stmt = _apply_filters(select(BusinessRole), keyword=keyword, status=status)
stmt = stmt.order_by(BusinessRole.sort_order, BusinessRole.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(BusinessRole.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_(
BusinessRole.role_code.contains(keyword, autoescape=True),
BusinessRole.role_name.contains(keyword, autoescape=True),
)
)
if status is not None:
stmt = stmt.where(BusinessRole.status == status)
return stmt
def count_personnel_by_role(session: Session, role_ids: list[int]) -> dict[int, int]:
"""批量统计各岗位下的人员数(删除保护的依据)。
② 基础数据管理落地 personnel 表后在此实现真实计数(一次 GROUP BY,
禁止列表循环内逐行查询);当前无人员表,恒返回空,删除校验对空字典放行。
"""
return {}
# ==================== 写入 ====================
def create(session: Session, role: BusinessRole) -> BusinessRole:
"""新增岗位。只 flush 以取得自增 id。"""
session.add(role)
session.flush()
return role
def delete(session: Session, role: BusinessRole) -> None:
"""删除岗位。调用方须先完成保护规则校验。"""
session.delete(role)
session.flush()
@@ -1,8 +1,10 @@
"""登录日志数据访问。""" """登录日志数据访问。"""
from sqlalchemy import Select, func, select
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.models import LoginLog from app.models import LoginLog
from app.repositories.query_utils import LogFilters, apply_log_filters
# user_agent 列宽,超长需截断否则插入报错 # user_agent 列宽,超长需截断否则插入报错
_USER_AGENT_MAX_LEN = 255 _USER_AGENT_MAX_LEN = 255
@@ -36,3 +38,40 @@ def add(
) )
) )
session.flush() 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
@@ -0,0 +1,214 @@
"""操作日志数据访问。
本层只 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 结构。
# phone/mobile/id_card 系 CLAUDE.md「日志禁输出手机号/身份证」红线的键名口径 ——
# 带分隔符的号码会穿过文本正则,必须在这里按整值抹除
_SENSITIVE_KEY_PARTS = (
"password",
"passwd",
"pwd",
"secret",
"token",
"app_key",
"access_key",
"phone",
"mobile",
"id_card",
"idcard",
)
_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")
@@ -0,0 +1,227 @@
"""计提参数数据访问。
本层只 flush 不 commit —— 事务边界由 services 层控制。
时点化解析查询(窗口函数版)留存 db/queries/计提参数.sql。
"""
from datetime import date
from sqlalchemy import Select, delete, func, select
from sqlalchemy.orm import Session
from app.models import ParamChangeBatch, ParamDefinition, ParamValue
# ==================== 读取 ====================
def resolve_values(session: Session, biz_date: date) -> list[tuple[str, str, str, date]]:
"""时点化解析:每个 (param_id, dimension_value) 取生效日期 <= 业务日期的最新条目。
排序 (effective_date DESC, id DESC) 保证同日多次变更取最新一条
(不设日期唯一键,同日修正是合法留痕)。仅返回启用状态的参数定义。
返回 (param_code, dimension_value, value_text, effective_date)。
"""
row_number = (
func.row_number()
.over(
partition_by=(ParamValue.param_id, ParamValue.dimension_value),
order_by=(ParamValue.effective_date.desc(), ParamValue.id.desc()),
)
.label("rn")
)
subq = (
select(
ParamDefinition.param_code.label("param_code"),
ParamValue.dimension_value.label("dimension_value"),
ParamValue.value_text.label("value_text"),
ParamValue.effective_date.label("effective_date"),
row_number,
)
.join(ParamDefinition, ParamDefinition.id == ParamValue.param_id)
.where(ParamValue.effective_date <= biz_date, ParamDefinition.status == 1)
.subquery()
)
stmt = select(
subq.c.param_code, subq.c.dimension_value, subq.c.value_text, subq.c.effective_date
).where(subq.c.rn == 1)
return [
(row.param_code, row.dimension_value, row.value_text, row.effective_date)
for row in session.execute(stmt)
]
def latest_value_before(
session: Session, param_id: int, dimension_value: str, biz_date: date
) -> ParamValue | None:
"""取某参数某维度在业务日期(含)之前的最新条目 —— 提交变更时记录「旧值」。"""
stmt = (
select(ParamValue)
.where(
ParamValue.param_id == param_id,
ParamValue.dimension_value == dimension_value,
ParamValue.effective_date <= biz_date,
)
.order_by(ParamValue.effective_date.desc(), ParamValue.id.desc())
.limit(1)
)
return session.execute(stmt).scalar_one_or_none()
def list_definitions(session: Session, *, status: int | None = None) -> list[ParamDefinition]:
"""参数定义列表(目录)。"""
stmt = select(ParamDefinition).order_by(ParamDefinition.sort_order, ParamDefinition.id)
if status is not None:
stmt = stmt.where(ParamDefinition.status == status)
return list(session.execute(stmt).scalars())
def get_definition_by_code(session: Session, param_code: str) -> ParamDefinition | None:
"""按编码取参数定义。"""
stmt = select(ParamDefinition).where(ParamDefinition.param_code == param_code)
return session.execute(stmt).scalar_one_or_none()
def list_history(
session: Session,
param_id: int,
*,
offset: int,
limit: int,
dimension_value: str | None = None,
) -> list[tuple[ParamValue, ParamChangeBatch]]:
"""单参数的版本历史(含批次信息),最新在前。"""
stmt = _history_stmt(session, param_id, dimension_value=dimension_value)
return [(value, batch) for value, batch in session.execute(stmt.offset(offset).limit(limit))]
def count_history(session: Session, param_id: int, *, dimension_value: str | None = None) -> int:
"""统计单参数的历史条目数。"""
stmt = select(func.count()).select_from(
_history_stmt(session, param_id, dimension_value=dimension_value).subquery()
)
return int(session.execute(stmt).scalar_one())
def _history_stmt(session: Session, param_id: int, *, dimension_value: str | None) -> Select:
"""历史查询基语句:条目 join 批次(版本号/操作人),最新在前。"""
stmt = (
select(ParamValue, ParamChangeBatch)
.join(ParamChangeBatch, ParamChangeBatch.id == ParamValue.batch_id)
.where(ParamValue.param_id == param_id)
.order_by(ParamValue.effective_date.desc(), ParamValue.id.desc())
)
if dimension_value is not None:
stmt = stmt.where(ParamValue.dimension_value == dimension_value)
return stmt
# ==================== 批次 ====================
def create_batch(session: Session, batch: ParamChangeBatch) -> ParamChangeBatch:
"""新增变更批次。只 flush 以取得自增 id。"""
session.add(batch)
session.flush()
return batch
def get_batch_by_id(
session: Session, batch_id: int, *, for_update: bool = False
) -> ParamChangeBatch | None:
"""按主键取批次。撤回校验走 ``for_update`` 锁行(防并发双撤)。"""
if for_update:
stmt = select(ParamChangeBatch).where(ParamChangeBatch.id == batch_id).with_for_update()
return session.execute(stmt).scalar_one_or_none()
return session.get(ParamChangeBatch, batch_id)
def list_batches(
session: Session,
*,
offset: int,
limit: int,
start_date: date | None = None,
end_date: date | None = None,
) -> list[ParamChangeBatch]:
"""分页查询变更批次(按创建时间倒序)。"""
stmt = _batch_filters(select(ParamChangeBatch), start_date=start_date, end_date=end_date)
return list(
session.execute(
stmt.order_by(ParamChangeBatch.create_time.desc(), ParamChangeBatch.id.desc())
.offset(offset)
.limit(limit)
).scalars()
)
def count_batches(
session: Session, *, start_date: date | None = None, end_date: date | None = None
) -> int:
"""统计符合条件的批次总数。"""
stmt = _batch_filters(
select(func.count(ParamChangeBatch.id)), start_date=start_date, end_date=end_date
)
return int(session.execute(stmt).scalar_one())
def _batch_filters(stmt: Select, *, start_date: date | None, end_date: date | None) -> Select:
"""批次筛选(生效日期区间,含边界)。列表与计数共用。"""
if start_date is not None:
stmt = stmt.where(ParamChangeBatch.effective_date >= start_date)
if end_date is not None:
stmt = stmt.where(ParamChangeBatch.effective_date <= end_date)
return stmt
def list_values_by_batch(
session: Session, batch_id: int
) -> list[tuple[ParamValue, ParamDefinition]]:
"""批次内全部条目(join 参数定义取编码/名称),按 id 排序。"""
stmt = (
select(ParamValue, ParamDefinition)
.join(ParamDefinition, ParamDefinition.id == ParamValue.param_id)
.where(ParamValue.batch_id == batch_id)
.order_by(ParamValue.id)
)
return [(value, definition) for value, definition in session.execute(stmt)]
def delete_values_by_batch(session: Session, batch_id: int) -> None:
"""删除批次下全部条目(撤回未生效批次时使用,2.0 风格 delete 语句)。"""
session.execute(delete(ParamValue).where(ParamValue.batch_id == batch_id))
def delete_batch(session: Session, batch: ParamChangeBatch) -> None:
"""删除批次本体(调用方须先删条目并完成保护校验)。"""
session.delete(batch)
session.flush()
# ==================== 写入 ====================
def create_value(session: Session, value: ParamValue) -> ParamValue:
"""新增版本条目(append-only,禁改禁删已生效条目)。只 flush。"""
session.add(value)
session.flush()
return value
# ==================== 导出 ====================
def list_export_rows(
session: Session, *, start_date: date | None = None, end_date: date | None = None
) -> list[tuple[ParamChangeBatch, ParamValue, ParamDefinition]]:
"""导出行:批次 × 条目 × 定义(变更人/时间/参数/维度/旧值/新值/生效日期)。"""
stmt = (
select(ParamChangeBatch, ParamValue, ParamDefinition)
.join(ParamValue, ParamValue.batch_id == ParamChangeBatch.id)
.join(ParamDefinition, ParamDefinition.id == ParamValue.param_id)
.order_by(ParamChangeBatch.create_time.desc(), ParamChangeBatch.id.desc(), ParamValue.id)
)
if start_date is not None:
stmt = stmt.where(ParamChangeBatch.effective_date >= start_date)
if end_date is not None:
stmt = stmt.where(ParamChangeBatch.effective_date <= end_date)
return [(batch, value, definition) for batch, value, definition in session.execute(stmt)]
+54
View File
@@ -0,0 +1,54 @@
"""repositories 层公共查询工具。
集中两类易错点,避免各 repository 各写一份、口径漂移:
- **模糊匹配的 LIKE 通配符转义**:直接 ``f"%{value}%"`` 拼接时,输入中的
下划线会作为单字符通配执行(本系统用户名普遍含下划线,误配是常态而非
理论),百分号则命中全表并触发全表扫描
- **两类日志共有的筛选条件**:用户名/结果/时间范围在操作日志与登录日志
之间逐字重复,抽为同一实现后「列表与计数」「操作与登录」不可能对不上
"""
from dataclasses import dataclass
from datetime import datetime
from typing import Any
from sqlalchemy import Select
@dataclass(frozen=True, slots=True)
class LogFilters:
"""两类日志(操作/登录)共有的筛选条件。"""
username: str | None = None
success: int | None = None
start_time: datetime | None = None
end_time: datetime | None = None
def fuzzy(stmt: Select, column: Any, value: str) -> Select:
"""模糊匹配(含通配符自动转义)。
``contains(autoescape=True)`` 会转义输入中的 ``%``/``_`` 并附加 ``ESCAPE``
子句,把用户输入当字面量匹配。
"""
return stmt.where(column.contains(value, autoescape=True))
def apply_log_filters(stmt: Select, model: Any, filters: LogFilters) -> Select:
"""应用两类日志共有的筛选条件。
``model`` 传 ``OperationLog`` / ``LoginLog`` 的模型类(两者字段同名同型)。
列表与计数共用本函数,避免「列表按条件过滤而总数不按条件过滤」——
漏改一侧时表现为翻到最后一页是空页或页码虚增,且没有任何报错。
"""
if filters.username:
stmt = fuzzy(stmt, model.username, filters.username)
if filters.success is not None:
stmt = stmt.where(model.success == filters.success)
if filters.start_time:
stmt = stmt.where(model.create_time >= filters.start_time)
if filters.end_time:
stmt = stmt.where(model.create_time <= filters.end_time)
return stmt
+123
View File
@@ -0,0 +1,123 @@
"""角色数据访问。
本层只 flush 不 commit —— 事务边界由 services 层控制。
`get_all_roles` 与 `get_roles_by_codes` 原位于 `user_repository`,
因角色管理成为独立模块而迁入本文件。
"""
from sqlalchemy import Select, func, or_, select
from sqlalchemy.orm import Session
from app.models import Role, UserRole
# ==================== 读取 ====================
def get_by_id(session: Session, role_id: int, *, for_update: bool = False) -> Role | None:
"""按主键查询角色。不存在返回 None。
``for_update=True`` 走 ``SELECT ... FOR UPDATE`` 锁定角色行 —— 删除前的
「有用户绑定禁删」校验必须用它:校验与 DELETE 之间若发生并发绑定,
`user_roles` 的 CASCADE 会把新绑定一并静默剥离。锁行后,并发绑定的
外键检查会等待,删除得以基于最新状态做出(对方失败而非数据被吞)。
"""
if for_update:
stmt = select(Role).where(Role.id == role_id).with_for_update()
return session.execute(stmt).scalar_one_or_none()
return session.get(Role, role_id)
def get_by_code(session: Session, role_code: str) -> Role | None:
"""按编码查询角色。不存在返回 None。"""
return session.execute(select(Role).where(Role.role_code == role_code)).scalar_one_or_none()
def get_roles_by_codes(session: Session, role_codes: list[str]) -> list[Role]:
"""按编码批量取角色。用于校验前端传来的角色是否存在。"""
if not role_codes:
return []
stmt = select(Role).where(Role.role_code.in_(role_codes))
return list(session.execute(stmt).scalars())
def get_all_roles(session: Session) -> list[Role]:
"""取全部启用角色,供前端下拉选择。"""
stmt = select(Role).where(Role.status == 1).order_by(Role.sort_order, Role.id)
return list(session.execute(stmt).scalars())
def list_roles(
session: Session,
*,
offset: int,
limit: int,
keyword: str | None = None,
status: int | None = None,
) -> list[Role]:
"""分页查询角色(管理列表:含停用角色)。
排序与下拉口径一致(sort_order, id),保持同一套相对次序。
"""
stmt = _apply_filters(select(Role), keyword=keyword, status=status)
stmt = stmt.order_by(Role.sort_order, Role.id).offset(offset).limit(limit)
return list(session.execute(stmt).scalars())
def count_roles(session: Session, *, keyword: str | None = None, status: int | None = None) -> int:
"""统计符合条件的角色总数,供分页使用。"""
stmt = _apply_filters(select(func.count(Role.id)), keyword=keyword, status=status)
return int(session.execute(stmt).scalar_one())
def _apply_filters(stmt: Select, *, keyword: str | None, status: int | None) -> Select:
"""应用通用筛选条件。列表与计数共用,避免两处口径不一致。
关键词匹配走 `contains(autoescape=True)`(转义 LIKE 通配符)——
角色编码普遍含下划线,直接拼接会让 `_` 当单字符通配执行。
"""
if keyword:
stmt = stmt.where(
or_(
Role.role_code.contains(keyword, autoescape=True),
Role.role_name.contains(keyword, autoescape=True),
)
)
if status is not None:
stmt = stmt.where(Role.status == status)
return stmt
def count_users_by_role(session: Session, role_ids: list[int]) -> dict[int, int]:
"""批量统计各角色关联的用户数。
一次 GROUP BY 取回全部计数,而非在列表循环里逐行查询(N+1)。
"""
if not role_ids:
return {}
stmt = (
select(UserRole.role_id, func.count())
.where(UserRole.role_id.in_(role_ids))
.group_by(UserRole.role_id)
)
return {int(role_id): int(count) for role_id, count in session.execute(stmt)}
# ==================== 写入 ====================
def create(session: Session, role: Role) -> Role:
"""新增角色。只 flush 以取得自增 id。"""
session.add(role)
session.flush()
return role
def delete(session: Session, role: Role) -> None:
"""删除角色。
调用方(service)必须先校验该角色无用户绑定 —— `user_roles` 外键是
CASCADE,直接删会静默剥离绑定用户的权限。
"""
session.delete(role)
session.flush()
+126 -4
View File
@@ -1,14 +1,23 @@
"""用户与角色数据访问。 """用户数据访问(角色相关的读取与维护见 `role_repository`)。
本层只 flush 不 commit —— 事务边界由 services 层控制。 本层只 flush 不 commit —— 事务边界由 services 层控制。
""" """
from datetime import datetime from datetime import datetime
from sqlalchemy import select from sqlalchemy import Select, func, select
from sqlalchemy.orm import Session from sqlalchemy.orm import Session, selectinload
from app.models import User from app.models import Role, User, UserRole
from app.repositories.query_utils import fuzzy
def _base_query() -> Select[tuple[User]]:
"""用户查询基座。
预加载角色关联,避免列表渲染时逐行查角色(N+1)。
"""
return select(User).options(selectinload(User.user_roles).selectinload(UserRole.role))
def get_by_username(session: Session, username: str) -> User | None: def get_by_username(session: Session, username: str) -> User | None:
@@ -34,3 +43,116 @@ def touch_last_login(session: Session, user: User, moment: datetime) -> None:
"""更新最后登录时间。只 flush,提交由 service 决定。""" """更新最后登录时间。只 flush,提交由 service 决定。"""
user.last_login_time = moment user.last_login_time = moment
session.flush() session.flush()
# ==================== 列表查询 ====================
def list_users(
session: Session,
*,
offset: int,
limit: int,
username: str | None = None,
real_name: str | None = None,
status: int | None = None,
role_code: str | None = None,
) -> list[User]:
"""分页查询用户列表。
**按 id 倒序而非 create_time**:create_time 是秒级精度,批量导入的用户
可能落在同一秒,按它排序的分页结果不确定 —— 翻页时会出现重复或遗漏。
id 是自增主键,天然唯一且与创建顺序一致。
"""
stmt = _base_query()
stmt = _apply_filters(stmt, username=username, real_name=real_name, status=status)
if role_code:
# 按角色筛选需 join 关联表;角色是字典数据,数量极少,join 代价可忽略
stmt = stmt.join(UserRole).join(Role).where(Role.role_code == role_code)
stmt = stmt.order_by(User.id.desc()).offset(offset).limit(limit)
return list(session.execute(stmt).scalars().unique())
def count_users(
session: Session,
*,
username: str | None = None,
real_name: str | None = None,
status: int | None = None,
role_code: str | None = None,
) -> int:
"""统计符合条件的用户总数,供分页使用。"""
stmt = select(func.count(User.id))
stmt = _apply_filters(stmt, username=username, real_name=real_name, status=status)
if role_code:
stmt = stmt.join(UserRole).join(Role).where(Role.role_code == role_code)
return int(session.execute(stmt).scalar_one())
def _apply_filters(
stmt: Select,
*,
username: str | None,
real_name: str | None,
status: int | None,
) -> Select:
"""应用通用筛选条件。列表与计数共用,避免两处口径不一致。
模糊匹配统一走 `query_utils.fuzzy`(转义 LIKE 通配符)—— 用户名普遍
含下划线,直接拼接会让 `_` 当单字符通配执行、误配无关账号。
"""
if username:
stmt = fuzzy(stmt, User.username, username)
if real_name:
stmt = fuzzy(stmt, User.real_name, real_name)
if status is not None:
stmt = stmt.where(User.status == status)
return stmt
# ==================== 写入 ====================
def create(session: Session, user: User) -> User:
"""新增用户。只 flush 以取得自增 id。"""
session.add(user)
session.flush()
return user
def delete(session: Session, user: User) -> None:
"""物理删除用户。
本项目对用户采用「删除即禁用」的**业务**约定(见模型注释),
但保留物理删除能力供测试清理与误建账号的彻底移除使用。
`user_roles` 与 `operation_logs` 的外键分别配置了 CASCADE 与 SET NULL,
故不会留下悬挂引用。
"""
session.delete(user)
session.flush()
# ==================== 角色关联 ====================
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()
+90
View File
@@ -0,0 +1,90 @@
"""业务岗位角色的请求/响应模型。"""
import re
from pydantic import BaseModel, ConfigDict, Field, field_validator
from app.core.response import PageQuery
# 岗位编码规则与系统角色一致:小写字母开头,后接小写字母/数字/下划线,长度 2–32。
# 编码会作为参数维度值(param_values.dimension_value)与核算模板绑定的锚点,
# 保持单一风格避免大小写/全角混用造成对照困难
BUSINESS_ROLE_CODE_PATTERN = re.compile(r"^[a-z][a-z0-9_]{1,31}$")
class BusinessRoleOut(BaseModel):
"""岗位响应(列表与详情共用)。"""
model_config = ConfigDict(from_attributes=True)
id: int
role_code: str
role_name: str
calc_template: str
description: str | None = None
sort_order: int = 0
status: int = 1
is_builtin: int = 0
class TemplateParamOut(BaseModel):
"""模板声明的参数(供岗位表单展示,含当前口径说明)。"""
code: str
name: str
class TemplateOptionOut(BaseModel):
"""公式模板下拉项。"""
code: str
name: str
description: str
params: list[TemplateParamOut]
class BusinessRoleCreateIn(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="岗位名称")
calc_template: str = Field(min_length=1, max_length=32, 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 BUSINESS_ROLE_CODE_PATTERN.match(value):
raise ValueError("岗位编码须以小写字母开头,仅可含小写字母、数字与下划线")
return value
class BusinessRoleUpdateIn(BaseModel):
"""编辑岗位。
**不含 role_code** —— 编码是参数维度值与模板绑定的锚点(同系统角色的保护理由)。
前端表单全量提交,故为全量替换语义。
"""
model_config = ConfigDict(str_strip_whitespace=True)
role_name: str = Field(min_length=1, max_length=64, description="岗位名称")
calc_template: str = Field(min_length=1, max_length=32, description="计提公式模板编码")
description: str | None = Field(default=None, max_length=255)
sort_order: int = Field(default=0, ge=0, description="显示排序,越小越靠前")
class BusinessRoleStatusIn(BaseModel):
"""启用/禁用岗位。"""
status: int = Field(ge=0, le=1, description="1启用 0禁用")
class BusinessRoleQuery(PageQuery):
"""岗位列表查询条件。"""
keyword: str | None = Field(default=None, max_length=64, description="编码或名称,模糊匹配")
status: int | None = Field(default=None, ge=0, le=1, description="状态筛选")
+105
View File
@@ -0,0 +1,105 @@
"""日志查询的请求/响应模型。
操作日志与登录日志字段不同(前者含模块/操作/请求详情,后者含失败原因),
故各自定义 Query 与 Out,仅共用「用户名 + 结果 + 时间范围」的筛选基类。
"""
from datetime import datetime
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
from app.core.response import PageQuery
class LogQueryBase(PageQuery):
"""日志公共查询条件:模糊用户名 + 结果 + 时间范围。"""
username: str | None = Field(default=None, max_length=64, description="用户名,模糊匹配")
success: int | None = Field(default=None, ge=0, le=1, description="1成功 0失败")
start_time: datetime | None = Field(default=None, description="起始时间(含)")
end_time: datetime | None = Field(default=None, description="结束时间(含)")
@field_validator("start_time", "end_time")
@classmethod
def _to_local_naive(cls, value: datetime | None) -> datetime | None:
"""统一为本地时区的 naive 时间。
库列存本地时间(容器 TZ=Asia/Shanghai),而 API 接受 ISO 8601
(可带 ``Z`` 等时区后缀)。不归一时有两种故障:aware 与 naive
混用比较抛 TypeError(穿透成 500);aware 直接透传给驱动会
**静默丢弃偏移**(查询窗口整体偏 8 小时且无任何提示)。
aware 先转系统本地时区、再摘除 tzinfo 是唯一正确姿势;
naive 视为本地时间原样保留。
"""
if value is not None and value.tzinfo is not None:
return value.astimezone().replace(tzinfo=None)
return value
@model_validator(mode="after")
def _check_time_range(self) -> "LogQueryBase":
if self.start_time and self.end_time and self.start_time > self.end_time:
raise ValueError("起始时间不能晚于结束时间")
return self
class OperationLogQuery(LogQueryBase):
"""操作日志查询条件。"""
module: str | None = Field(default=None, max_length=64, description="所属模块,精确匹配")
action: str | None = Field(default=None, max_length=32, description="操作类型,精确匹配")
class OperationLogOut(BaseModel):
"""操作日志列表项。
**不含 request_data**(TEXT,单行最大 60000 字节):列表页默认不展示它,
随列表返回会让翻页负担随日志表增长线性放大;展开详情走
`GET /operation-logs/{id}`(`OperationLogDetailOut`),前端按需加载。
"""
model_config = ConfigDict(from_attributes=True)
id: int
user_id: int | None = None
username: str
module: str
action: str
summary: str
method: str | None = None
path: str | None = None
ip: str | None = None
user_agent: str | None = None
success: int = 0
error_message: str | None = None
duration_ms: int | None = None
create_time: datetime | None = None
class OperationLogDetailOut(OperationLogOut):
"""操作日志详情:列表字段 + 请求参数。
`request_data` 入库时已经结构化 + 文本两层脱敏,可直接展示。
"""
request_data: str | None = None
class LoginLogQuery(LogQueryBase):
"""登录日志查询条件。"""
ip: str | None = Field(default=None, max_length=45, description="客户端IP,精确匹配")
class LoginLogOut(BaseModel):
"""登录日志响应。"""
model_config = ConfigDict(from_attributes=True)
id: int
user_id: int | None = None
username: str
ip: str | None = None
user_agent: str | None = None
success: int = 0
fail_reason: str | None = None
create_time: datetime | None = None
+238
View File
@@ -0,0 +1,238 @@
"""计提参数管理的请求/响应模型。
字段命名与 user/role 一致采用蛇形直出(前端 TS 接口同构)。
金额类入参用字符串承载(Decimal 解析),杜绝 float 精度损失。
"""
from datetime import date, datetime
from decimal import Decimal, InvalidOperation
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
from app.core.response import PageQuery
def _parse_amount(value: str, *, field_name: str, allow_negative: bool) -> str:
"""金额字符串校验:必须可被 Decimal 解析;默认拒绝负数(毛利润额除外)。"""
try:
amount = Decimal(value.strip())
except InvalidOperation as exc:
raise ValueError(f"{field_name}必须是有效数字:{value}") from exc
if not amount.is_finite():
# 'NaN' 的比较会抛 InvalidOperation、'Infinity' 会在后续计算抛溢出,均须在入口拦下
raise ValueError(f"{field_name}必须为有限数字:{value}")
if not allow_negative and amount < 0:
raise ValueError(f"{field_name}不得为负数:{value}")
return value
class ParamDimensionValueOut(BaseModel):
"""某参数单个维度的当前生效值。"""
dimension_value: str
value_text: str
effective_date: date | None = None
class ParamCatalogItemOut(BaseModel):
"""参数目录项:定义元数据 + 各维度截至查询时点的生效值。"""
param_code: str
param_name: str
param_group: str
value_type: str
unit: str | None = None
dimension_type: str
options: list[str] = Field(default_factory=list)
default_value: str | None = None
value_min: str | None = None
value_max: str | None = None
description: str | None = None
values: list[ParamDimensionValueOut] = Field(default_factory=list)
class ParamHistoryOut(BaseModel):
"""单参数的版本历史条目(append-only 留痕)。"""
version_no: str
dimension_value: str
old_value_text: str | None = None
value_text: str
effective_date: date
operator_name: str
create_time: datetime
class ParamChangeItemIn(BaseModel):
"""变更条目:参数 + 维度 + 新值。"""
model_config = ConfigDict(str_strip_whitespace=True)
param_code: str = Field(min_length=1, max_length=64)
dimension_value: str = Field(default="", max_length=32)
value: str = Field(min_length=1, max_length=64)
class ParamChangeIn(BaseModel):
"""提交变更批次。
change_type=1 立即生效(生效日期取今日);change_type=2 指定日期生效,
生效日期必填且不得早于今日(过去没有「预约」的意义,历史回溯走重算授权)。
"""
model_config = ConfigDict(str_strip_whitespace=True)
change_type: int = Field(ge=1, le=2, description="生效方式:1立即生效 2指定日期生效")
effective_date: date | None = Field(default=None, description="指定日期生效时必填")
remark: str | None = Field(default=None, max_length=255)
items: list[ParamChangeItemIn] = Field(min_length=1, max_length=50)
@model_validator(mode="after")
def _check_effective_date(self) -> "ParamChangeIn":
if self.change_type == 2 and self.effective_date is None:
raise ValueError("指定日期生效时必须提供生效日期")
return self
class ParamChangeBatchOut(BaseModel):
"""变更批次(提交/撤回/列表共用)。"""
id: int
version_no: str
effective_date: date
change_type: int
remark: str | None = None
operator_name: str
item_count: int
create_time: datetime
class ParamBatchItemOut(BaseModel):
"""批次明细条目(旧值 → 新值)。"""
param_code: str
param_name: str
dimension_value: str
old_value_text: str | None = None
value_text: str
effective_date: date
class ParamBatchOut(BaseModel):
"""变更批次(列表项)。"""
id: int
version_no: str
effective_date: date
change_type: int
remark: str | None = None
operator_name: str
item_count: int
create_time: datetime
class ParamBatchDetailOut(BaseModel):
"""批次明细:批次头 + 逐项变更。"""
id: int
version_no: str
effective_date: date
change_type: int
remark: str | None = None
operator_name: str
item_count: int
create_time: datetime
items: list[ParamBatchItemOut] = Field(default_factory=list)
class ParamDiffOut(BaseModel):
"""两日期快照对比项(全量参数,changed 标记是否变化)。"""
param_code: str
param_name: str
param_group: str
dimension_value: str
old_value: str | None = None
new_value: str | None = None
changed: bool
class ParamPreviewIn(BaseModel):
"""计算预览入参。金额用字符串承载,Decimal 解析。"""
model_config = ConfigDict(str_strip_whitespace=True)
business_role: str | None = Field(
default=None, max_length=32, description="岗位编码,如 salesperson"
)
customer_type: str | None = Field(
default=None, max_length=32, description="客户类型:personal个人户 enterprise企业户"
)
product_category: str | None = Field(
default=None, max_length=32, description="产品类别:municipal_water/rail_transit"
)
ship_date: date = Field(description="发货日")
receipt_date: date = Field(description="回款到账日")
revenue: str = Field(min_length=1, description="销售额(不含运费)")
profit: str = Field(min_length=1, description="毛利润额(可为负)")
received_amount: str = Field(min_length=1, description="本笔回款金额")
barter: bool = Field(default=False, description="是否以物抵顶")
probation: bool = Field(default=False, description="是否试用期")
@field_validator("revenue", "received_amount")
@classmethod
def _check_non_negative(cls, value: str, info) -> str:
return _parse_amount(value, field_name=info.field_name, allow_negative=False)
@field_validator("profit")
@classmethod
def _check_profit(cls, value: str, info) -> str:
return _parse_amount(value, field_name=info.field_name, allow_negative=True)
@model_validator(mode="after")
def _check_dates(self) -> "ParamPreviewIn":
if self.receipt_date < self.ship_date:
raise ValueError("回款到账日不得早于发货日")
return self
class ParamUsedOut(BaseModel):
"""预览实际用到的参数(对账透明:取值与生效时点)。"""
param_code: str
value_text: str
effective_date: date | None = None
class ParamPreviewOut(BaseModel):
"""计算预览出参。分数串(如 97/10000)为精确值,金额为 Decimal 串。"""
payment_days: int
rate: str
rate_text: str
profit_rate: str
converted_profit: str
performance_amount: str
barter_applied: bool
performance_after_barter: str | None = None
start_payment_ok: bool
payment_weight_r: int
payment_weight: str
payable_amount: str
probation_note: str | None = None
params_used: list[ParamUsedOut] = Field(default_factory=list)
class ParamBatchQuery(PageQuery):
"""变更记录查询条件。"""
start_date: date | None = Field(default=None, description="生效日期起(含)")
end_date: date | None = Field(default=None, description="生效日期止(含)")
class ParamHistoryQuery(PageQuery):
"""参数历史查询条件。"""
dimension_value: str | None = Field(
default=None, max_length=32, description="按维度值筛选,空为全部"
)
+80
View File
@@ -0,0 +1,80 @@
"""角色管理的请求/响应模型。
`RoleOut` 原位于 `schemas/user.py`,因角色管理成为独立模块而迁入本文件 ——
用户表单的角色下拉与角色管理列表共用同一响应模型。
"""
import re
from pydantic import BaseModel, ConfigDict, Field, field_validator
from app.core.response import PageQuery
# 角色编码规则:小写字母开头,后接小写字母/数字/下划线,长度 2–32(与列宽一致)。
# 不允许大写与中文 —— 编码会出现在授权判断(`ROLE_ADMIN` 常量按编码比对)与
# 操作日志里,保持单一风格避免大小写/全角混用造成对照困难
ROLE_CODE_PATTERN = re.compile(r"^[a-z][a-z0-9_]{1,31}$")
class RoleOut(BaseModel):
"""角色基础响应(用户表单下拉与角色列表共用)。"""
model_config = ConfigDict(from_attributes=True)
id: int
role_code: str
role_name: str
description: str | None = None
sort_order: int = 0
status: int = 1
class RoleManageOut(RoleOut):
"""角色管理列表项:额外携带绑定用户数,供前端提示删除保护的原因。"""
user_count: int = 0
class RoleCreateIn(BaseModel):
"""新增角色。"""
model_config = ConfigDict(str_strip_whitespace=True)
role_code: str = Field(min_length=2, max_length=32, description="角色编码,创建后不可修改")
role_name: str = Field(min_length=1, max_length=64, description="角色名称")
description: str | None = Field(default=None, max_length=255)
sort_order: int = Field(default=0, ge=0, description="显示排序,越小越靠前")
@field_validator("role_code")
@classmethod
def _check_code(cls, value: str) -> str:
if not ROLE_CODE_PATTERN.match(value):
raise ValueError("角色编码须以小写字母开头,仅可含小写字母、数字与下划线")
return value
class RoleUpdateIn(BaseModel):
"""编辑角色。
**不含 role_code** —— 编码是授权判定的锚点,修改会让既有授权语义静默改变。
前端表单全量提交,故为全量替换语义。
"""
model_config = ConfigDict(str_strip_whitespace=True)
role_name: str = Field(min_length=1, max_length=64, description="角色名称")
description: str | None = Field(default=None, max_length=255)
sort_order: int = Field(default=0, ge=0, description="显示排序,越小越靠前")
class RoleStatusIn(BaseModel):
"""启用/禁用角色。"""
status: int = Field(ge=0, le=1, description="1启用 0禁用")
class RoleQuery(PageQuery):
"""角色列表查询条件。"""
keyword: str | None = Field(default=None, max_length=64, description="编码或名称,模糊匹配")
status: int | None = Field(default=None, ge=0, le=1, description="状态筛选")
+113
View File
@@ -0,0 +1,113 @@
"""用户管理的请求/响应模型(角色相关模型见 `role.py`)。
与 `auth.py` 的分工:`auth.py` 管登录链路(验证码、登录、当前用户),
本模块管**用户管理**(增删改查、重置密码、启停、角色分配)。
字段名驼峰(规范约定)与蛇形属性之间用 alias 映射,与响应模型保持同一套做法。
"""
from datetime import datetime
from pydantic import BaseModel, ConfigDict, Field, field_validator
from app.core.constants import PASSWORD_MAX_BYTES, PASSWORD_MIN_BYTES
from app.core.response import PageQuery
def _validate_password_bytes(value: str) -> str:
"""按**字节数**校验密码长度。
必须在这里拦下:bcrypt 5.0 起对超过 72 字节的密码直接抛 ValueError,
而中文一个字符占 3 字节 —— 按字符数校验(如 max_length=20)根本拦不住。
与 `auth.py` 的登录校验共用同一规则,避免两处口径不一致。
"""
length = len(value.encode("utf-8"))
if length < PASSWORD_MIN_BYTES:
raise ValueError(f"密码不足 {PASSWORD_MIN_BYTES} 字节")
if length > PASSWORD_MAX_BYTES:
raise ValueError(f"密码超过 {PASSWORD_MAX_BYTES} 字节上限")
return value
class UserOut(BaseModel):
"""用户列表/详情响应。
不含 password —— 密码哈希绝不外泄,这是硬约束而非疏漏。
"""
model_config = ConfigDict(from_attributes=True)
id: int
username: str
real_name: str | None = None
phone: str | None = None
email: str | None = None
status: int = Field(description="状态:1启用 0禁用")
roles: list[str] = Field(default_factory=list, description="角色编码列表")
last_login_time: datetime | None = None
remark: str | None = None
create_time: datetime | None = None
class UserCreateIn(BaseModel):
"""新增用户。"""
model_config = ConfigDict(str_strip_whitespace=True)
username: str = Field(min_length=2, max_length=64, description="登录用户名,须唯一")
password: str = Field(description="初始密码")
real_name: str | None = Field(default=None, max_length=64)
phone: str | None = Field(default=None, max_length=20)
email: str | None = Field(default=None, max_length=128)
roles: list[str] = Field(default_factory=list, description="角色编码列表,至少一个")
remark: str | None = Field(default=None, max_length=255)
@field_validator("password")
@classmethod
def _check_password(cls, value: str) -> str:
return _validate_password_bytes(value)
class UserUpdateIn(BaseModel):
"""编辑用户。
**不含 username** —— 用户名是登录凭据与审计追溯的锚点,改名会让历史
操作日志与登录日志对不上。确需改名应走专门的流程(本期不提供)。
"""
model_config = ConfigDict(str_strip_whitespace=True)
real_name: str | None = Field(default=None, max_length=64)
phone: str | None = Field(default=None, max_length=20)
email: str | None = Field(default=None, max_length=128)
roles: list[str] | None = Field(default=None, description="传 null 表示不改角色")
remark: str | None = Field(default=None, max_length=255)
class PasswordResetIn(BaseModel):
"""重置密码(管理员操作,不需要原密码)。"""
new_password: str = Field(description="新密码")
@field_validator("new_password")
@classmethod
def _check_password(cls, value: str) -> str:
return _validate_password_bytes(value)
class UserStatusIn(BaseModel):
"""启用/禁用用户。"""
status: int = Field(ge=0, le=1, description="1启用 0禁用")
class UserQuery(PageQuery):
"""用户列表查询条件。
继承分页基类,额外支持按用户名/姓名模糊、按状态与角色筛选。
"""
username: str | None = Field(default=None, max_length=64, description="用户名,模糊匹配")
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="角色编码筛选")
+173
View File
@@ -0,0 +1,173 @@
"""操作日志审计服务。
需求 2.5.1 要求记录用户的操作行为。与登录行为的分工:登录在 `login_logs`
单独记录(失败也要留痕且不依赖认证上下文),本模块记录**已认证用户的管理类
操作**。
设计要点:
- **成功与失败都留痕** —— 失败的操作(如试图删除不存在的用户)同样是审计
对象,模型里专门有 `success` / `error_message` 两列承载它
- **审计失败不影响业务** —— 落库过程整体兜底,失败只记应用日志、绝不外抛
(与 `auth_service._record` 同一原则:"审计可丢,业务不可断")
- **独立提交** —— 日志单独 commit,不与业务同事务。同事务时业务失败回滚会
连带丢掉最该留痕的失败记录
用法(上下文管理器)::
target = user_repository.get_by_id(session, user_id) # 只读预取,供摘要
with audit(session, operator=current_user,
record=AuditRecord(MODULE_USER, ACTION_DELETE,
f"删除用户 {target.username}"),
meta=meta):
...业务操作与 session.commit()...
return out
约定:
- 业务代码在上下文内自行 commit(事务边界仍在 service 层)。**commit 之后
的任何数据库查询、ORM 属性读取都必须移出上下文**(提到 commit 之前或
用已固化的局部变量)—— 业务已提交却因后置查询抛错被记成「失败」,会
产生与事实相反的审计记录
- 上下文内不得吞掉异常后正常退出——吞掉后会被记为成功
- 摘要需要"目标实体名"时,应在进入上下文**之前**只读预取,
这样目标不存在(NotFound 失败)时也能留下可追溯的记录
- 不支持嵌套(在 audit 内再调用会自行 commit 的服务)——进入侦测到
未提交变更会记 warning
"""
import logging
import time
from collections.abc import Iterator
from contextlib import contextmanager
from dataclasses import dataclass
from sqlalchemy.orm import Session
from app.core.exceptions import AppException
from app.models import User
from app.repositories import operation_log_repository
from app.repositories.operation_log_repository import OperationLogEntry
logger = logging.getLogger(__name__)
@dataclass(frozen=True, slots=True)
class RequestMeta:
"""HTTP 请求元信息。由路由层依赖 `RequestMetaDep` 提取后传入 service。"""
ip: str | None = None
user_agent: str | None = None
method: str | None = None
path: str | None = None
@dataclass(frozen=True, slots=True)
class AuditRecord:
"""审计的业务侧内容(模块 / 操作 / 摘要 / 请求参数)。"""
module: str
action: str
summary: str
request_data: dict | None = None
@contextmanager
def audit(
session: Session,
*,
operator: User,
record: AuditRecord,
meta: RequestMeta | None = None,
) -> Iterator[None]:
"""审计上下文:正常退出记成功;抛异常记失败并**原样重抛**。"""
started = time.perf_counter()
meta = meta or RequestMeta()
# 固化操作人为普通标量,之后不再触碰 ORM 对象 —— 失败路径先 rollback
# 会使其全部过期,再读 id/username 将触发额外 SELECT;若账号恰被并发
# 删除,ObjectDeletedError 还会被下面的兜底吞掉、失败审计静默丢失
base: dict = {
"user_id": operator.id,
"username": operator.username,
"module": record.module,
"action": record.action,
"summary": record.summary,
"method": meta.method,
"path": meta.path,
"ip": meta.ip,
"user_agent": meta.user_agent,
"request_data": record.request_data,
}
# 进入审计时应无未提交变更(业务写操作在上下文内才发生)。出现变更
# 意味着调用方可能嵌套了会自行 commit 的服务:失败路径的回滚将无法
# 撤销已提交的部分,审计与数据状态会不一致
if session.new or session.dirty or session.deleted:
logger.warning(
"进入审计上下文时存在未提交的变更 | module=%s | action=%s",
record.module,
record.action,
)
try:
yield
except Exception as exc:
# 先回滚再写日志:业务异常可能源于 IntegrityError 等数据库错误,
# 不回滚的话 session 处于「必须回滚」状态,写日志会抛
# PendingRollbackError —— 最该留下的失败记录反而被丢掉
_rollback_quietly(session)
_safe_write(
session,
entry=OperationLogEntry(
**base,
success=False,
error_message=_error_text(exc),
duration_ms=_elapsed_ms(started),
),
)
raise
else:
_safe_write(
session,
entry=OperationLogEntry(**base, success=True, duration_ms=_elapsed_ms(started)),
)
def _safe_write(session: Session, *, entry: OperationLogEntry) -> None:
"""落库并独立提交。整体兜底:任何失败只记应用日志,绝不外抛。"""
try:
operation_log_repository.add(session, entry)
session.commit()
except Exception:
logger.warning(
"操作日志写入失败(不影响业务) | module=%s | action=%s | summary=%s",
entry.module,
entry.action,
entry.summary,
exc_info=True,
)
_rollback_quietly(session)
def _rollback_quietly(session: Session) -> None:
try:
session.rollback()
except Exception:
logger.warning("会话回滚失败", exc_info=True)
def _error_text(exc: Exception) -> str:
"""失败原因文案。
业务异常取其面向用户的消息;未预期异常返回通用文案 —— 完整堆栈由全局
异常处理器记入应用日志,审计表不落内部细节。
"""
if isinstance(exc, AppException):
return exc.message
return "服务端异常,请稍后重试"
def _elapsed_ms(started: float) -> int:
"""度量业务处理耗时(不含响应序列化)。"""
return max(0, int((time.perf_counter() - started) * 1000))
@@ -0,0 +1,287 @@
"""业务岗位角色管理业务编排。
需求 #28 的落点:岗位可增删、绑定公式模板(有限集合,代码内注册表),
新增岗位无需改代码。保护规则(前端会禁用相应按钮,**后端校验是唯一防线**):
- 内置岗位(is_builtin=1:营销员/营销经理/信息员)**禁删禁停** ——
参数种子与核算模板按编码锚定(如 salesperson 的递减公式参数行)
- `role_code` 创建后不可改 —— 它是参数维度值(param_values.dimension_value)
与模板绑定语义的锚点
- `calc_template` 必须是模板注册表(calc_templates.TEMPLATES)内的编码
- 有人员引用的岗位禁删 —— ② 落地 personnel 表后由引用计数生效,
当前钩子恒为空(见 business_role_repository.count_personnel_by_role)
"""
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_BUSINESS_ROLE,
)
from app.core.exceptions import BadRequestError, ForbiddenError, NotFoundError
from app.core.response import PageResult
from app.models import BusinessRole, User
from app.repositories import business_role_repository
from app.schemas.business_role import (
BusinessRoleCreateIn,
BusinessRoleOut,
BusinessRoleQuery,
BusinessRoleUpdateIn,
TemplateOptionOut,
TemplateParamOut,
)
from app.services import audit_service
from app.services.audit_service import AuditRecord, RequestMeta
from app.services.calc_templates import TEMPLATES
from app.services.param_registry import PARAM_REGISTRY
logger = logging.getLogger(__name__)
def _to_out(role: BusinessRole) -> BusinessRoleOut:
"""ORM 对象转响应模型。禁止把 ORM 对象直接当 response_model。"""
return BusinessRoleOut(
id=role.id,
role_code=role.role_code,
role_name=role.role_name,
calc_template=role.calc_template,
description=role.description,
sort_order=role.sort_order,
status=role.status,
is_builtin=role.is_builtin,
)
def _ensure_template(calc_template: str) -> None:
"""模板必须存在于代码内注册表(前端为受控下拉,后端仍须校验)。"""
if calc_template not in TEMPLATES:
raise BadRequestError(f"计提公式模板 {calc_template!r} 不存在")
def list_roles(session: Session, query: BusinessRoleQuery) -> PageResult[BusinessRoleOut]:
"""分页查询岗位(含停用)。"""
roles = business_role_repository.list_roles(
session,
offset=query.offset,
limit=query.page_size,
keyword=query.keyword,
status=query.status,
)
total = business_role_repository.count_roles(
session, keyword=query.keyword, status=query.status
)
return PageResult[BusinessRoleOut](
records=[_to_out(role) for role in roles],
total=total,
page_num=query.page_num,
page_size=query.page_size,
)
def get_role(session: Session, role_id: int) -> BusinessRoleOut:
"""岗位详情。
:raises NotFoundError: 岗位不存在
"""
role = business_role_repository.get_by_id(session, role_id)
if role is None:
raise NotFoundError("岗位不存在")
return _to_out(role)
def list_template_options() -> list[TemplateOptionOut]:
"""公式模板下拉(含声明参数,供岗位表单展示与引导配置)。"""
options: list[TemplateOptionOut] = []
for tpl in TEMPLATES.values():
options.append(
TemplateOptionOut(
code=tpl.code,
name=tpl.name,
description=tpl.description,
params=[
TemplateParamOut(code=code, name=PARAM_REGISTRY[code].name)
for code in tpl.params
],
)
)
return options
def create_role(
session: Session,
payload: BusinessRoleCreateIn,
*,
operator: User,
meta: RequestMeta | None = None,
) -> BusinessRoleOut:
"""新增岗位。编码唯一、创建后不可修改,新岗位固定启用、非内置。
:raises BadRequestError: 编码已存在 / 模板不存在
"""
_ensure_template(payload.calc_template)
with audit_service.audit(
session,
operator=operator,
record=AuditRecord(
module=MODULE_BUSINESS_ROLE,
action=ACTION_CREATE,
summary=f"新增岗位 {payload.role_name}({payload.role_code})",
request_data=payload.model_dump(exclude_none=True),
),
meta=meta,
):
if business_role_repository.get_by_code(session, payload.role_code) is not None:
raise BadRequestError(f"岗位编码 {payload.role_code!r} 已存在")
role = BusinessRole(
role_code=payload.role_code,
role_name=payload.role_name,
calc_template=payload.calc_template,
description=payload.description,
sort_order=payload.sort_order,
status=1,
is_builtin=0,
)
try:
business_role_repository.create(session, role)
out = _to_out(role)
session.commit()
except IntegrityError as exc:
# 先查后写不是原子操作:并发提交同一编码会撞 uk_business_roles_role_code
session.rollback()
raise BadRequestError(f"岗位编码 {payload.role_code!r} 已存在") from exc
logger.info(
"新增岗位 | role_code=%s | template=%s", payload.role_code, payload.calc_template
)
return out
def update_role(
session: Session,
role_id: int,
payload: BusinessRoleUpdateIn,
*,
operator: User,
meta: RequestMeta | None = None,
) -> BusinessRoleOut:
"""编辑岗位名称/模板/说明/排序(全量替换,不含 role_code)。
:raises NotFoundError: 岗位不存在
:raises BadRequestError: 模板不存在
"""
role = business_role_repository.get_by_id(session, role_id)
_ensure_template(payload.calc_template)
with audit_service.audit(
session,
operator=operator,
record=AuditRecord(
module=MODULE_BUSINESS_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.calc_template = payload.calc_template
role.description = payload.description
role.sort_order = payload.sort_order
# 响应与后置动作全部在 commit 之前完成(见 user_service 顶部说明)
out = _to_out(role)
session.commit()
logger.info("修改岗位 | role_id=%s | template=%s", role_id, payload.calc_template)
return out
def set_status(
session: Session,
role_id: int,
status: int,
*,
operator: User,
meta: RequestMeta | None = None,
) -> BusinessRoleOut:
"""启用或禁用岗位。内置岗位不可停用(参数种子按编码锚定)。
:raises NotFoundError: 岗位不存在
:raises ForbiddenError: 试图停用内置岗位
"""
role = business_role_repository.get_by_id(session, role_id)
verb = "启用" if status == 1 else "禁用"
with audit_service.audit(
session,
operator=operator,
record=AuditRecord(
module=MODULE_BUSINESS_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.is_builtin == 1 and status == 0:
raise ForbiddenError("内置岗位不可停用")
role.status = status
out = _to_out(role)
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`` 锁行:校验与删除之间若发生并发引用(M2 接入后),
锁行使并发写入失败(可见)而非引用被静默悬空(不可见)。
:raises NotFoundError: 岗位不存在
:raises ForbiddenError: 试图删除内置岗位
:raises BadRequestError: 岗位下仍有人引用
"""
role = business_role_repository.get_by_id(session, role_id, for_update=True)
with audit_service.audit(
session,
operator=operator,
record=AuditRecord(
module=MODULE_BUSINESS_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.is_builtin == 1:
raise ForbiddenError("内置岗位不可删除")
counts = business_role_repository.count_personnel_by_role(session, [role_id])
person_count = counts.get(role_id, 0)
if person_count > 0:
raise BadRequestError(f"该岗位下仍有 {person_count} 名人员,请先调整归属")
role_code = role.role_code # 固化:对象随删除失效
business_role_repository.delete(session, role)
session.commit()
logger.info("删除岗位 | role_code=%s", role_code)
+72
View File
@@ -0,0 +1,72 @@
"""计提公式模板注册表(代码内有限集合)。
一期不引入规则引擎的落地方式:公式骨架固定(本表枚举),变化的是参数值
(系统设置维护)。岗位角色(business_roles.calc_template)绑定模板编码,
新增岗位 = 选模板 + 配参数,无需改代码(需求 #28)。
新增公式形态(如未来「按产品数量计提」)需要新增模板 —— 一期接受,
岗位表单中模板为带说明的受控下拉,避免误以为可自由写公式。
"""
from dataclasses import dataclass
@dataclass(frozen=True, slots=True)
class TemplateSpec:
"""公式模板元数据。"""
code: str
name: str
params: tuple[str, ...]
description: str
TEMPLATES: dict[str, TemplateSpec] = {
"TPL_SALESPERSON_DECAY": TemplateSpec(
code="TPL_SALESPERSON_DECAY",
name="业务员个人户·天数线性递减",
params=(
"salesperson.rate.start",
"salesperson.rate.slope",
"salesperson.rate.floor",
"salesperson.days.boundary",
),
description="分段:≤分界天数 = 起始 − 天数×斜率;>分界天数 = 下限",
),
"TPL_MANAGER_SHARE": TemplateSpec(
code="TPL_MANAGER_SHARE",
name="营销经理·团队与全公司分成",
params=("manager.share.team", "manager.share.company"),
description="聚合:∑本团队业务员绩效×12.5% + ∑全公司业务员绩效×5%",
),
"TPL_ENTERPRISE_FIXED": TemplateSpec(
code="TPL_ENTERPRISE_FIXED",
name="企业户·固定计提",
params=("enterprise.rate",),
description="乘法:折算毛利润额 × 计提比例",
),
"TPL_INFO_SALES_FIXED": TemplateSpec(
code="TPL_INFO_SALES_FIXED",
name="信息员·销售额固定比例",
params=("info.rate",),
description="乘法:本人销售额 × 计提比例",
),
"TPL_PREPAY_FIXED": TemplateSpec(
code="TPL_PREPAY_FIXED",
name="预付款·固定计提",
params=("prepay.rate",),
description="乘法:回款额 × 计提比例",
),
}
def get_template(code: str) -> TemplateSpec:
"""取模板元数据。未注册编码属代码缺陷(500),不是用户错误。"""
tpl = TEMPLATES.get(code)
if tpl is None:
raise ValueError(f"公式模板编码未注册:{code}")
return tpl
def required_params(code: str) -> tuple[str, ...]:
"""模板声明的参数编码列表(岗位表单展示与校验用)。"""
return get_template(code).params
+92
View File
@@ -0,0 +1,92 @@
"""计提公式纯函数(无 IO、无会话)。
契约(与 M3 核算、计算预览共用,口径见 2026-10-12 业务裁决):
- 参数值按**存储口径**传入(含单位:比例参数为 ‰ 数如 10、百分比参数为 % 数如 60)
- 计算出的中间比率(如毛利润率)为**纯比例**(Fraction,如 9/20)
- 金额用 Decimal、比率用 Fraction,全程禁 float;Fraction→Decimal 转换在
分母仅含 2/5 因子时精确,否则按 Decimal 上下文精度(28 位)
- 绩效 = 折算毛利润额 × 计提比例(乘法方向,对照表 #3,禁按"互为倒数"实现)
"""
from datetime import date
from decimal import Decimal
from fractions import Fraction
def to_decimal(value: Fraction) -> Decimal:
"""Fraction → Decimal。有限小数精确;循环小数按 Decimal 上下文精度(28 位)。"""
return Decimal(value.numerator) / Decimal(value.denominator)
# 模块内沿用私有名;公开别名供 service 层金额换算复用
_to_decimal = to_decimal
def payment_days(ship_date: date, receipt_date: date) -> int:
"""回款天数:发货日首日计第 1 天(对照表 #1;需规「26 天」示例作废)。"""
return (receipt_date - ship_date).days + 1
def salesperson_rate(
days: int,
*,
start: Fraction,
slope: Fraction,
floor: Fraction,
boundary_days: int,
) -> Fraction:
"""业务员计提比例(纯比例)。
start/floor 为 ‰ 数、slope 为 ‰/天(存储口径,slope 为精确分数 4/360)。
≤分界天数按「起始 − 天数×斜率」线性递减;超过分界天数固定取下限
(对照表 #4:360 天=6‰,361 天起固定 4‰)。
"""
if days > boundary_days:
return floor / 1000
return (start - days * slope) / 1000
def profit_rate(profit: Decimal, revenue: Decimal) -> Fraction:
"""毛利润率(纯比例):毛利润额 / 销售额(**不含运费**口径,对照表 #2)。
销售额为 0 时返回 0(防除零;折算额随之为 0)。
"""
if revenue == 0:
return Fraction(0)
return Fraction(profit) / Fraction(revenue)
def converted_profit(revenue: Decimal, rate: Fraction, threshold: Fraction) -> Decimal:
"""折算毛利润额(化简式):销售额 × (毛利润率 − 阈值)。
rate 为纯比例(profit_rate 的输出);threshold 为 % 数(存储口径,如 30)。
化简式等价于定义式「毛利润额 / 毛利润率 × (毛利润率 − 阈值)」,且避免
毛利润率为 0 时除零(对照表 #12/#13)。毛利润率低于阈值时结果为负 ——
业务确认负值正常计算、不归零。
"""
return _to_decimal(Fraction(revenue) * (rate - threshold / 100))
def performance_amount(base: Decimal, rate: Fraction) -> Decimal:
"""绩效金额 = 折算毛利润额 × 计提比例(乘法方向,对照表 #3)。"""
return _to_decimal(Fraction(base) * rate)
def apply_barter(amount: Decimal, discount: Fraction) -> Decimal:
"""以物抵顶折算:应发绩效 × 折算比例(discount 为 % 数,默认 80,对照表 #24)。"""
return _to_decimal(Fraction(amount) * discount / 100)
def payment_weight(r: int) -> Fraction:
"""支付权重 R×R(纯比例):R 为回款占销售额百分比向下取整(对照表 #22)。
60 → 36/100;100 → 1(全额)。
"""
return Fraction(r, 100) * Fraction(r, 100)
def start_payment_ok(received: Decimal, revenue: Decimal, ratio: Fraction) -> bool:
"""起付判定:单项业务回款额达到销售额(不含运费)的 ratio%(对照表 #21)。"""
if revenue == 0:
return False
return Fraction(received) / Fraction(revenue) >= ratio / 100
+82
View File
@@ -0,0 +1,82 @@
"""日志查询编排。
操作日志与登录日志分别提供分页查询(两表字段与筛选项不同),操作日志另有
单条详情(列表为性能排除的 `request_data` 走详情按需加载)。
纯只读操作,不写审计 —— 读操作记日志只会淹没审计表。
"""
from sqlalchemy.orm import Session
from app.core.exceptions import NotFoundError
from app.core.response import PageResult
from app.repositories import login_log_repository, operation_log_repository
from app.repositories.query_utils import LogFilters
from app.schemas.log import (
LoginLogOut,
LoginLogQuery,
OperationLogDetailOut,
OperationLogOut,
OperationLogQuery,
)
def list_operation_logs(session: Session, query: OperationLogQuery) -> PageResult[OperationLogOut]:
"""分页查询操作日志(列表不含 request_data,详情走 `get_operation_log`)。"""
filters = _filters_from(query)
logs = operation_log_repository.list_logs(
session,
offset=query.offset,
limit=query.page_size,
filters=filters,
module=query.module,
action=query.action,
)
total = operation_log_repository.count_logs(
session, filters=filters, module=query.module, action=query.action
)
return PageResult[OperationLogOut](
records=[OperationLogOut.model_validate(log) for log in logs],
total=total,
page_num=query.page_num,
page_size=query.page_size,
)
def get_operation_log(session: Session, log_id: int) -> OperationLogDetailOut:
"""查询单条操作日志详情(含已脱敏的 request_data)。
:raises NotFoundError: 日志不存在
"""
log = operation_log_repository.get_by_id(session, log_id)
if log is None:
raise NotFoundError("日志不存在")
return OperationLogDetailOut.model_validate(log)
def list_login_logs(session: Session, query: LoginLogQuery) -> PageResult[LoginLogOut]:
"""分页查询登录日志。"""
filters = _filters_from(query)
logs = login_log_repository.list_logs(
session,
offset=query.offset,
limit=query.page_size,
filters=filters,
ip=query.ip,
)
total = login_log_repository.count_logs(session, filters=filters, ip=query.ip)
return PageResult[LoginLogOut](
records=[LoginLogOut.model_validate(log) for log in logs],
total=total,
page_num=query.page_num,
page_size=query.page_size,
)
def _filters_from(query: OperationLogQuery | LoginLogQuery) -> LogFilters:
"""两类 Query 的公共筛选字段 → LogFilters(口径与 repository 共用一个实现)。"""
return LogFilters(
username=query.username,
success=query.success,
start_time=query.start_time,
end_time=query.end_time,
)
+304
View File
@@ -0,0 +1,304 @@
"""计提参数目录注册表。
注册表是**迁移种子与运行期校验的唯一来源**:编码、默认值、取值域在此定义
一次;新增参数 = 改这里 + 生成迁移种子(两者一致性由测试锁定)。
值一律以 Fraction 可解析字符串表示(如 "4/360",禁 0.011111 —— 见算法红线)。
"""
from dataclasses import dataclass
from fractions import Fraction
from app.core.exceptions import BadRequestError
@dataclass(frozen=True, slots=True)
class ParamSpec:
"""单个参数的元数据(与 param_definitions 表字段一一对应)。"""
code: str
name: str
group: str
value_type: str
unit: str | None
dimension_type: str
options: tuple[str, ...] | None
default_value: str | None
value_min: str | None
value_max: str | None
description: str | None
sort_order: int
PARAM_REGISTRY: dict[str, ParamSpec] = {
spec.code: spec
for spec in [
ParamSpec(
code="salesperson.rate.start",
name="业务员起始计提比例",
group="业务员个人户",
value_type="DECIMAL",
unit="‰",
dimension_type="BUSINESS_ROLE",
options=None,
default_value="10",
value_min="0",
value_max=None,
description="线性递减段起始值:比例 = 起始 − 回款天数 × 斜率(≤分界天数)",
sort_order=1,
),
ParamSpec(
code="salesperson.rate.slope",
name="业务员日递减斜率",
group="业务员个人户",
value_type="DECIMAL",
unit="‰/天",
dimension_type="BUSINESS_ROLE",
options=None,
default_value="4/360",
value_min="0",
value_max=None,
description="精确分数 4/360‰/天(=1/90),禁止按 0.011111 截断存储",
sort_order=2,
),
ParamSpec(
code="salesperson.rate.floor",
name="业务员计提下限",
group="业务员个人户",
value_type="DECIMAL",
unit="‰",
dimension_type="BUSINESS_ROLE",
options=None,
default_value="4",
value_min="0",
value_max=None,
description="回款天数超过分界天数后固定取此比例(≥361 天 = 4‰)",
sort_order=3,
),
ParamSpec(
code="salesperson.days.boundary",
name="线性递减分界天数",
group="业务员个人户",
value_type="INT",
unit="天",
dimension_type="BUSINESS_ROLE",
options=None,
default_value="360",
value_min="0",
value_max=None,
description="≤分界天数按公式线性递减(360 天=6‰);超过则取下限",
sort_order=4,
),
ParamSpec(
code="manager.share.team",
name="经理本团队分成比例",
group="经理分成",
value_type="DECIMAL",
unit="%",
dimension_type="BUSINESS_ROLE",
options=None,
default_value="12.5",
value_min="0",
value_max="100",
description="∑本人带领业务员绩效 × 此比例(限本团队)",
sort_order=5,
),
ParamSpec(
code="manager.share.company",
name="经理全公司分成比例",
group="经理分成",
value_type="DECIMAL",
unit="%",
dimension_type="BUSINESS_ROLE",
options=None,
default_value="5",
value_min="0",
value_max="100",
description="∑全部业务员绩效 × 此比例(全公司)",
sort_order=6,
),
ParamSpec(
code="enterprise.rate",
name="企业户计提比例",
group="企业户",
value_type="DECIMAL",
unit="‰",
dimension_type="PRODUCT_CATEGORY",
options=None,
default_value="3",
value_min="0",
value_max=None,
description="所有企业户适用;2026-08-13 起由 2‰ 提高至 3‰",
sort_order=7,
),
ParamSpec(
code="info.rate",
name="信息员计提比例",
group="信息员",
value_type="DECIMAL",
unit="‰",
dimension_type="BUSINESS_ROLE",
options=None,
default_value="1",
value_min="0",
value_max=None,
description="信息员绩效 = 本人销售额 × 此比例",
sort_order=8,
),
ParamSpec(
code="prepay.rate",
name="预付款计提比例",
group="支付环节",
value_type="DECIMAL",
unit="‰",
dimension_type="NONE",
options=None,
default_value="10",
value_min="0",
value_max=None,
description="预付款统一按此比例计提",
sort_order=9,
),
ParamSpec(
code="barter.discount",
name="以物抵顶折算比例",
group="支付环节",
value_type="DECIMAL",
unit="%",
dimension_type="NONE",
options=None,
default_value="80",
value_min="0",
value_max="100",
description="以物抵顶回款绩效按应发绩效的此比例折算",
sort_order=10,
),
ParamSpec(
code="payment.start.ratio",
name="支付起付比例",
group="支付环节",
value_type="DECIMAL",
unit="%",
dimension_type="NONE",
options=None,
default_value="60",
value_min="0",
value_max="100",
description="单项业务回款额达销售额(不含运费)此比例后起付;达标前累计",
sort_order=11,
),
ParamSpec(
code="payment.weight.mode",
name="支付权重应用方式",
group="支付环节",
value_type="ENUM",
unit=None,
dimension_type="NONE",
options=("FULL", "BALANCE"),
default_value="FULL",
value_min=None,
value_max=None,
description="FULL=按 R×R 乘当月全额应发绩效;BALANCE=乘余额(语义待 M3 前确认)",
sort_order=12,
),
ParamSpec(
code="product.threshold",
name="折算毛利润额阈值",
group="产品阈值",
value_type="DECIMAL",
unit="%",
dimension_type="PRODUCT_CATEGORY",
options=None,
default_value="30",
value_min="0",
value_max="100",
description="折算毛利润额 = 销售额 ×(毛利润率 − 阈值);市政水利 30%、轨交 20%",
sort_order=13,
),
ParamSpec(
code="salary.basic.mode",
name="月工资基本工资口径",
group="月工资口径",
value_type="ENUM",
unit=None,
dimension_type="BUSINESS_ROLE",
options=("INCLUDE", "DEDUCT", "EXCLUDE"),
default_value="INCLUDE",
value_min=None,
value_max=None,
description="INCLUDE含基本工资 / DEDUCT扣除基本工资 / EXCLUDE不计",
sort_order=14,
),
ParamSpec(
code="probation.annual.threshold",
name="试用期年累计结算阈值",
group="月工资口径",
value_type="DECIMAL",
unit="元",
dimension_type="NONE",
options=None,
default_value="18000",
value_min="0",
value_max=None,
description="试用期年累计绩效超过此值部分年底一次性结算",
sort_order=15,
),
ParamSpec(
code="probation.salary.basic",
name="试用期基本工资标准",
group="月工资口径",
value_type="DECIMAL",
unit="元",
dimension_type="NONE",
options=None,
default_value="3000",
value_min="0",
value_max=None,
description="试用期仅发基本工资、不发放绩效(归属待 M2 复核)",
sort_order=16,
),
]
}
def get_spec(code: str) -> ParamSpec:
"""取参数元数据。未注册编码属代码缺陷(500),不是用户错误。"""
spec = PARAM_REGISTRY.get(code)
if spec is None:
raise ValueError(f"参数编码未注册:{code}")
return spec
def parse_fraction(text: str) -> Fraction:
"""Fraction 精确解析("4/360" → 1/90,不经 float)。非法输入抛 400。"""
try:
return Fraction(text.strip())
except (ValueError, ZeroDivisionError) as exc:
raise BadRequestError(f"参数值无法解析为数值:{text}") from exc
def validate_and_normalize(spec: ParamSpec, raw: str) -> str:
"""按元数据校验并规范化参数值,返回可入库字符串。
DECIMAL:Fraction 解析 + 取值域校验,分数式原样保留("4/360" 不截断);
INT:必须整数;BOOL:仅 0/1;ENUM:必须命中 options;TEXT:原样返回。
"""
value = raw.strip()
if spec.value_type in {"DECIMAL", "INT"}:
fraction = parse_fraction(value)
if spec.value_type == "INT" and fraction.denominator != 1:
raise BadRequestError(f"参数 {spec.code} 必须为整数:{raw}")
if spec.value_min is not None and fraction < parse_fraction(spec.value_min):
raise BadRequestError(f"参数 {spec.code} 低于取值下限 {spec.value_min}:{raw}")
if spec.value_max is not None and fraction > parse_fraction(spec.value_max):
raise BadRequestError(f"参数 {spec.code} 高于取值上限 {spec.value_max}:{raw}")
return value
if spec.value_type == "BOOL":
if value not in {"0", "1"}:
raise BadRequestError(f"参数 {spec.code} 仅接受 0/1:{raw}")
return value
if spec.value_type == "ENUM":
if not spec.options or value not in spec.options:
allowed = "/".join(spec.options or ())
raise BadRequestError(f"参数 {spec.code} 取值须为 {allowed}:{raw}")
return value
return value
+664
View File
@@ -0,0 +1,664 @@
"""计提参数服务:时点化取值、版本管理(变更/撤回/历史/对比)、计算预览与导出。
ParamSnapshot 是 M3 核算与计算预览共用的输入契约:按业务日期(回款到账日)
冻结当时口径,禁读「当前值」计算历史数据(算法红线)。
变更批次 = 版本(一次提交一批次);条目逐项留痕(旧值/新值),append-only。
"""
import logging
from collections.abc import Iterable
from dataclasses import dataclass
from datetime import date
from decimal import Decimal
from fractions import Fraction
from io import BytesIO
from sqlalchemy import select
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session
from app.core.constants import (
ACTION_DELETE,
ACTION_UPDATE,
MODULE_PARAM,
PRODUCT_CATEGORIES,
)
from app.core.exceptions import BadRequestError, NotFoundError
from app.core.response import PageResult
from app.models import ParamChangeBatch, ParamDefinition, ParamValue, User
from app.repositories import business_role_repository, param_repository
from app.schemas.param import (
ParamBatchDetailOut,
ParamBatchItemOut,
ParamBatchOut,
ParamBatchQuery,
ParamCatalogItemOut,
ParamChangeBatchOut,
ParamChangeIn,
ParamDiffOut,
ParamDimensionValueOut,
ParamHistoryOut,
ParamHistoryQuery,
ParamPreviewIn,
ParamPreviewOut,
ParamUsedOut,
)
from app.services import audit_service, formula
from app.services.audit_service import AuditRecord, RequestMeta
from app.services.formula import to_decimal
from app.services.param_registry import get_spec, parse_fraction, validate_and_normalize
logger = logging.getLogger(__name__)
# ==================== 快照 ====================
@dataclass(frozen=True, slots=True)
class ParamSnapshot:
"""某业务日期下的参数快照(冻结不可变,核算期间保持一致)。
覆盖链:角色维度 > 产品类别维度 > 默认行('');均无匹配时回退注册表默认值。
"""
biz_date: date
_values: dict[str, dict[str, str]]
_effective: dict[tuple[str, str], date]
@classmethod
def from_rows(
cls, biz_date: date, rows: Iterable[tuple[str, str, str, date]]
) -> "ParamSnapshot":
"""由 (param_code, dimension_value, value_text, effective_date) 行构造。
rows 须按仓储输出顺序(同一 (code, dim) 的最新生效值在前)——以先出现者为准。
"""
values: dict[str, dict[str, str]] = {}
effective: dict[tuple[str, str], date] = {}
for code, dimension, value_text, effective_date in rows:
if (code, dimension) in effective:
continue
effective[(code, dimension)] = effective_date
values.setdefault(code, {}).setdefault(dimension, value_text)
return cls(biz_date=biz_date, _values=values, _effective=effective)
def get(
self, code: str, *, role_code: str | None = None, product_category: str | None = None
) -> str:
"""取参数原始值(覆盖链解析)。无值且无注册表默认值时抛 400。"""
candidates = self._values.get(code)
if candidates:
for dimension in (role_code, product_category, ""):
if dimension is not None and dimension in candidates:
return candidates[dimension]
spec = get_spec(code) # 未注册编码 → ValueError(代码缺陷)
if spec.default_value is None:
raise BadRequestError(f"参数 {code} 无生效值且无默认值,请先在系统设置中维护")
return spec.default_value
def effective_date_of(
self, code: str, *, role_code: str | None = None, product_category: str | None = None
) -> date | None:
"""取参数当前生效值的生效日期(回退注册表默认值时为 None)。"""
candidates = self._values.get(code)
if candidates:
for dimension in (role_code, product_category, ""):
if dimension is not None and (code, dimension) in self._effective:
return self._effective[(code, dimension)]
return None
def iter_items(self) -> Iterable[tuple[str, str, str]]:
"""遍历快照全部 (param_code, dimension_value, value_text),供对比。"""
for code, dims in self._values.items():
for dimension, value_text in dims.items():
yield code, dimension, value_text
def fraction(
self, code: str, *, role_code: str | None = None, product_category: str | None = None
) -> Fraction:
"""取参数值并精确解析为 Fraction("4/360" → 1/90,不经 float)。"""
return parse_fraction(
self.get(code, role_code=role_code, product_category=product_category)
)
def integer(
self, code: str, *, role_code: str | None = None, product_category: str | None = None
) -> int:
"""取参数值为整数。配置成非整数属数据缺陷(写入经校验,不应发生)。"""
value = self.fraction(code, role_code=role_code, product_category=product_category)
if value.denominator != 1:
raise ValueError(f"参数 {code} 配置为非整数:{value}")
return int(value)
def boolean(
self, code: str, *, role_code: str | None = None, product_category: str | None = None
) -> bool:
"""取布尔参数(0/1)。"""
return self.get(code, role_code=role_code, product_category=product_category) == "1"
def text(
self, code: str, *, role_code: str | None = None, product_category: str | None = None
) -> str:
"""取参数原文(枚举码/文本)。"""
return self.get(code, role_code=role_code, product_category=product_category)
def resolve_snapshot(session: Session, biz_date: date) -> ParamSnapshot:
"""按业务日期解析全量参数快照(M3 核算与计算预览共用入口)。"""
return ParamSnapshot.from_rows(biz_date, param_repository.resolve_values(session, biz_date))
# ==================== 目录 ====================
def get_catalog(session: Session, *, as_of: date | None = None) -> list[ParamCatalogItemOut]:
"""参数目录:全部启用定义 + 各维度截至 asOf(默认今日)的生效值。"""
biz_date = as_of or date.today()
resolved = param_repository.resolve_values(session, biz_date)
by_code: dict[str, list[ParamDimensionValueOut]] = {}
for code, dimension, value_text, effective_date in resolved:
by_code.setdefault(code, []).append(
ParamDimensionValueOut(
dimension_value=dimension, value_text=value_text, effective_date=effective_date
)
)
items: list[ParamCatalogItemOut] = []
for definition in param_repository.list_definitions(session, status=1):
spec = get_spec(definition.param_code)
items.append(
ParamCatalogItemOut(
param_code=definition.param_code,
param_name=definition.param_name,
param_group=definition.param_group,
value_type=definition.value_type,
unit=definition.unit,
dimension_type=definition.dimension_type,
options=list(spec.options or ()),
default_value=definition.default_value,
value_min=definition.value_min,
value_max=definition.value_max,
description=definition.description,
values=by_code.get(definition.param_code, []),
)
)
return items
# ==================== 变更与版本管理 ====================
def _next_version_no(session: Session) -> str:
"""生成批次版本号 P{YYYYMMDD}-{当日序号}(当日自增,两位补零)。"""
today = date.today()
prefix = f"P{today:%Y%m%d}"
seq = 0
for (version_no,) in session.execute(
select(ParamChangeBatch.version_no).where(ParamChangeBatch.version_no.like(f"{prefix}-%"))
):
try:
seq = max(seq, int(version_no.rsplit("-", 1)[1]))
except (IndexError, ValueError): # 手工造数容错:跳过不合规格的版本号
continue
return f"{prefix}-{seq + 1:02d}"
def submit_change(
session: Session,
payload: ParamChangeIn,
*,
operator: User,
meta: RequestMeta | None = None,
) -> ParamChangeBatchOut:
"""提交变更批次:全量校验 → 建批次 → 逐项写条目(旧值取提交时生效值)。
:raises BadRequestError: 参数未定义 / 维度不合法 / 值校验失败 / 生效日期早于今日 /
同批次重复条目
"""
effective = payload.effective_date if payload.change_type == 2 else date.today()
with audit_service.audit(
session,
operator=operator,
record=AuditRecord(
module=MODULE_PARAM,
action=ACTION_UPDATE,
summary=(
f"提交计提参数变更 {len(payload.items)} 项,"
f"{'立即生效' if payload.change_type == 1 else f'{effective} 起生效'}"
),
request_data=payload.model_dump(exclude_none=True),
),
meta=meta,
):
if payload.change_type == 2 and effective < date.today():
raise BadRequestError("指定生效日期不得早于今日")
# 先全量校验、再写入:任何一项不合法整批拒绝(不留半批脏数据)
normalized: list[tuple[ParamDefinition, str, str]] = []
seen: set[tuple[str, str]] = set()
for item in payload.items:
definition = param_repository.get_definition_by_code(session, item.param_code)
if definition is None:
# 用户输入先行查库给 400;未注册编码出现在库里属数据与注册表漂移
raise BadRequestError(f"参数 {item.param_code} 未在系统中定义")
spec = get_spec(item.param_code)
dimension = item.dimension_value
if spec.dimension_type == "NONE" and dimension != "":
raise BadRequestError(f"参数 {item.param_code} 不带维度,维度值须为空")
if (
spec.dimension_type == "BUSINESS_ROLE"
and dimension
and business_role_repository.get_by_code(session, dimension) is None
):
raise BadRequestError(
f"参数 {item.param_code} 的维度值 {dimension!r} 不是已存在的岗位"
)
if (
spec.dimension_type == "PRODUCT_CATEGORY"
and dimension
and dimension not in PRODUCT_CATEGORIES
):
raise BadRequestError(
f"参数 {item.param_code} 的维度值 {dimension!r} 不是合法的产品类别"
)
value = validate_and_normalize(spec, item.value)
key = (item.param_code, dimension)
if key in seen:
raise BadRequestError(
f"同一批次内重复提交参数 {item.param_code}({dimension or '默认'})"
)
seen.add(key)
normalized.append((definition, dimension, value))
try:
batch = param_repository.create_batch(
session,
ParamChangeBatch(
version_no=_next_version_no(session),
effective_date=effective,
change_type=payload.change_type,
remark=payload.remark,
operator_id=operator.id,
operator_name=operator.username,
item_count=len(normalized),
),
)
# 旧值留痕取「本条目生效日时点」的值:存在多个未来生效批次时,
# 每条记录的是它实际顶替的那一版(而非提交时刻的当前值)
for definition, dimension, value in normalized:
old = param_repository.latest_value_before(
session, definition.id, dimension, effective
)
param_repository.create_value(
session,
ParamValue(
param_id=definition.id,
dimension_value=dimension,
effective_date=effective,
value_text=value,
old_value_text=old.value_text if old else None,
batch_id=batch.id,
),
)
session.commit()
except IntegrityError as exc:
# version_no 唯一键的并发冲突:当日序号是无锁查询后 +1,两名管理员
# 并发提交会生成相同版本号 —— 回滚并翻译为可重试的 400
session.rollback()
raise BadRequestError("提交冲突(版本号生成重复),请重试") from exc
session.commit()
logger.info("提交参数变更 | version_no=%s | items=%s", batch.version_no, batch.item_count)
return ParamChangeBatchOut(
id=batch.id,
version_no=batch.version_no,
effective_date=batch.effective_date,
change_type=batch.change_type,
remark=batch.remark,
operator_name=batch.operator_name,
item_count=batch.item_count,
create_time=batch.create_time,
)
def withdraw_batch(
session: Session, batch_id: int, *, operator: User, meta: RequestMeta | None = None
) -> None:
"""撤回未生效批次。已生效禁撤;条目删除后解析自然回退到更早版本。
:raises NotFoundError: 批次不存在
:raises BadRequestError: 批次已生效
"""
batch = param_repository.get_batch_by_id(session, batch_id, for_update=True)
with audit_service.audit(
session,
operator=operator,
record=AuditRecord(
module=MODULE_PARAM,
action=ACTION_DELETE,
summary=f"撤回参数变更批次 {batch.version_no if batch else f'#{batch_id}'}",
),
meta=meta,
):
if batch is None:
raise NotFoundError("变更批次不存在")
if batch.effective_date <= date.today():
raise BadRequestError("批次已生效,不可撤回")
param_repository.delete_values_by_batch(session, batch.id)
param_repository.delete_batch(session, batch)
session.commit()
logger.info("撤回参数变更批次 | version_no=%s", batch.version_no)
def get_history(
session: Session, param_code: str, query: ParamHistoryQuery
) -> PageResult[ParamHistoryOut]:
"""单参数版本历史(分页,最新在前)。
:raises NotFoundError: 参数不存在
"""
definition = param_repository.get_definition_by_code(session, param_code)
if definition is None:
raise NotFoundError("参数不存在")
rows = param_repository.list_history(
session,
definition.id,
offset=query.offset,
limit=query.page_size,
dimension_value=query.dimension_value,
)
total = param_repository.count_history(
session, definition.id, dimension_value=query.dimension_value
)
return PageResult[ParamHistoryOut](
records=[
ParamHistoryOut(
version_no=batch.version_no,
dimension_value=value.dimension_value,
old_value_text=value.old_value_text,
value_text=value.value_text,
effective_date=value.effective_date,
operator_name=batch.operator_name,
create_time=value.create_time,
)
for value, batch in rows
],
total=total,
page_num=query.page_num,
page_size=query.page_size,
)
def list_batches(session: Session, query: ParamBatchQuery) -> PageResult[ParamBatchOut]:
"""变更批次分页(按创建时间倒序)。"""
batches = param_repository.list_batches(
session,
offset=query.offset,
limit=query.page_size,
start_date=query.start_date,
end_date=query.end_date,
)
total = param_repository.count_batches(
session, start_date=query.start_date, end_date=query.end_date
)
return PageResult[ParamBatchOut](
records=[
ParamBatchOut(
id=batch.id,
version_no=batch.version_no,
effective_date=batch.effective_date,
change_type=batch.change_type,
remark=batch.remark,
operator_name=batch.operator_name,
item_count=batch.item_count,
create_time=batch.create_time,
)
for batch in batches
],
total=total,
page_num=query.page_num,
page_size=query.page_size,
)
def get_batch_detail(session: Session, batch_id: int) -> ParamBatchDetailOut:
"""批次明细:批次头 + 逐项旧值 → 新值。
:raises NotFoundError: 批次不存在
"""
batch = param_repository.get_batch_by_id(session, batch_id)
if batch is None:
raise NotFoundError("变更批次不存在")
items = [
ParamBatchItemOut(
param_code=definition.param_code,
param_name=definition.param_name,
dimension_value=value.dimension_value,
old_value_text=value.old_value_text,
value_text=value.value_text,
effective_date=value.effective_date,
)
for value, definition in param_repository.list_values_by_batch(session, batch_id)
]
return ParamBatchDetailOut(
id=batch.id,
version_no=batch.version_no,
effective_date=batch.effective_date,
change_type=batch.change_type,
remark=batch.remark,
operator_name=batch.operator_name,
item_count=batch.item_count,
create_time=batch.create_time,
items=items,
)
# ==================== 对比与预览 ====================
def compare(session: Session, *, from_date: date, to_date: date) -> list[ParamDiffOut]:
"""两日期快照逐项对比(全量参数,changed 标记变化)。
:raises BadRequestError: 起始日期晚于截止日期
"""
if from_date > to_date:
raise BadRequestError("起始日期不得晚于截止日期")
old_items = {
(code, dimension): value
for code, dimension, value in resolve_snapshot(session, from_date).iter_items()
}
new_items = {
(code, dimension): value
for code, dimension, value in resolve_snapshot(session, to_date).iter_items()
}
out: list[ParamDiffOut] = []
for code, dimension in sorted(set(old_items) | set(new_items)):
spec = get_spec(code)
old_value = old_items.get((code, dimension))
new_value = new_items.get((code, dimension))
out.append(
ParamDiffOut(
param_code=code,
param_name=spec.name,
param_group=spec.group,
dimension_value=dimension,
old_value=old_value,
new_value=new_value,
changed=old_value != new_value,
)
)
return out
def _preview_rate(
session: Session,
payload: ParamPreviewIn,
snapshot: ParamSnapshot,
days: int,
take,
) -> tuple[Fraction, str, Decimal | None]:
"""按岗位模板分派计提比例,返回(纯比例, 人读说明, 绩效基数覆盖值)。
基数覆盖值非 None 表示该模板的绩效基数不是折算毛利润额(如信息员按
「本人销售额」计提),预览层直接采用并跳过折算与阈值。
聚合模板(营销经理 12.5%+5%)依赖月度团队数据,不支持单笔预览。
"""
if payload.customer_type == "enterprise":
rate = take("enterprise.rate")
return rate, "企业户固定计提比例", None
role_code = payload.business_role
template = "TPL_SALESPERSON_DECAY"
if role_code:
role = business_role_repository.get_by_code(session, role_code)
if role is None:
raise BadRequestError(f"岗位 {role_code!r} 不存在")
template = role.calc_template
if template == "TPL_MANAGER_SHARE":
raise BadRequestError(
"营销经理绩效为团队聚合口径(本团队 12.5% + 全公司 5%),不支持单笔预览"
)
if template == "TPL_INFO_SALES_FIXED":
rate = take("info.rate")
# 信息员的基数是本人销售额(无折算与阈值),覆盖默认基数
return rate, "信息员:本人销售额 × 计提比例", Decimal(payload.revenue)
if template == "TPL_SALESPERSON_DECAY":
start = take("salesperson.rate.start")
slope = take("salesperson.rate.slope")
floor = take("salesperson.rate.floor")
boundary = int(take("salesperson.days.boundary"))
rate = formula.salesperson_rate(
days, start=start, slope=slope, floor=floor, boundary_days=boundary
)
return (
rate,
f"业务员递减:{to_decimal(start)}‰ 起,{days} 天(分界 {boundary} 天,超界取 {to_decimal(floor)}‰)",
None,
)
raise BadRequestError(f"公式模板 {template} 暂不支持预览")
def preview(session: Session, payload: ParamPreviewIn) -> ParamPreviewOut:
"""计算预览:入参(发货/回款/金额/岗位)→ 公式纯函数 → 出参(含所用参数)。"""
# 参数按回款到账日取值(对照表 #11)
snapshot = resolve_snapshot(session, payload.receipt_date)
revenue = Decimal(payload.revenue)
profit = Decimal(payload.profit)
received = Decimal(payload.received_amount)
used: list[ParamUsedOut] = []
def take(code: str) -> Fraction:
raw = snapshot.get(
code,
role_code=payload.business_role,
product_category=payload.product_category,
)
used.append(
ParamUsedOut(
param_code=code,
value_text=raw,
effective_date=snapshot.effective_date_of(
code,
role_code=payload.business_role,
product_category=payload.product_category,
),
)
)
return parse_fraction(raw)
if payload.product_category and payload.product_category not in PRODUCT_CATEGORIES:
raise BadRequestError(
f"产品类别 {payload.product_category!r} 不存在,可选:municipal_water / rail_transit"
)
days = formula.payment_days(payload.ship_date, payload.receipt_date)
rate, rate_text, base_override = _preview_rate(session, payload, snapshot, days, take)
profit_rate = formula.profit_rate(profit, revenue)
if base_override is not None:
# 基数覆盖(信息员按本人销售额计提):不走折算与阈值
converted = base_override
else:
threshold = take("product.threshold")
converted = formula.converted_profit(revenue, profit_rate, threshold)
performance = formula.performance_amount(converted, rate)
barter_applied = payload.barter
after_barter: Decimal | None = None
if barter_applied:
discount = take("barter.discount")
after_barter = formula.apply_barter(performance, discount)
start_ratio = take("payment.start.ratio")
start_ok = formula.start_payment_ok(received, revenue, start_ratio)
weight_r = min(100, int(Fraction(received) / Fraction(revenue) * 100)) if revenue > 0 else 0
weight = formula.payment_weight(weight_r)
base_amount = after_barter if barter_applied else performance
payable = to_decimal(Fraction(base_amount) * weight)
probation_note: str | None = None
if payload.probation:
annual = take("probation.annual.threshold")
probation_note = f"试用期绩效照算不发放;年累计超 {to_decimal(annual)} 元部分年底一次性结算"
return ParamPreviewOut(
payment_days=days,
rate=str(rate),
rate_text=rate_text,
profit_rate=str(profit_rate),
converted_profit=str(converted),
performance_amount=str(performance),
barter_applied=barter_applied,
performance_after_barter=str(after_barter) if after_barter is not None else None,
start_payment_ok=start_ok,
payment_weight_r=weight_r,
payment_weight=str(weight),
payable_amount=str(payable),
probation_note=probation_note,
params_used=used,
)
# ==================== 导出 ====================
def export_changes(
session: Session, *, start_date: date | None = None, end_date: date | None = None
) -> bytes:
"""变更记录导出 Excel(openpyxl)。列:版本号/变更人/变更时间/生效日期/参数/维度/旧值/新值。"""
from openpyxl import Workbook
rows = param_repository.list_export_rows(session, start_date=start_date, end_date=end_date)
workbook = Workbook()
sheet = workbook.active
sheet.title = "参数变更记录"
sheet.append(
[
"版本号",
"变更人",
"变更时间",
"生效日期",
"参数编码",
"参数名称",
"维度值",
"旧值",
"新值",
]
)
for batch, value, definition in rows:
sheet.append(
[
batch.version_no,
batch.operator_name,
batch.create_time.strftime("%Y-%m-%d %H:%M:%S"),
value.effective_date.strftime("%Y-%m-%d"),
definition.param_code,
definition.param_name,
value.dimension_value,
value.old_value_text,
value.value_text,
]
)
buffer = BytesIO()
workbook.save(buffer)
return buffer.getvalue()
+253
View File
@@ -0,0 +1,253 @@
"""角色管理业务编排。
需求 2.5.1 只定义 admin / user 两个角色,本模块属用户确认的**自主完善**
(轻量版:不含权限点分配 —— `permissions` / `role_permissions` 表继续闲置,
待业务方给出权限清单后再启用)。
保护规则(都是系统自举与授权安全的最低防线,不只是界面体验):
- `admin` 角色**禁删禁停** —— `cli` 建管理员与 `require_admin` 判定都依赖
该编码存在,删除会使系统无法自举
- **有用户绑定的角色禁删** —— `user_roles` 外键是 CASCADE,直接删除会静默
剥离绑定用户的权限,属安全与审计事故;删除前的校验走 `FOR UPDATE`
锁行,堵住「校验与删除之间并发绑定」的窗口
- `role_code` 创建后不可改 —— 它是授权判定(`ROLE_ADMIN` 常量按编码比对)的锚点
- 停用角色**不解除绑定、不影响既有授权**(授权判定只看编码、不看状态),
但**不可再分配给用户**(`user_service._resolve_roles` 拦截)
"""
import logging
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session
from app.core.constants import (
ACTION_CREATE,
ACTION_DELETE,
ACTION_DISABLE,
ACTION_ENABLE,
ACTION_UPDATE,
MODULE_ROLE,
ROLE_ADMIN,
)
from app.core.exceptions import BadRequestError, ForbiddenError, NotFoundError
from app.core.response import PageResult
from app.models import Role, User
from app.repositories import role_repository
from app.schemas.role import RoleCreateIn, RoleManageOut, RoleQuery, RoleUpdateIn
from app.services import audit_service
from app.services.audit_service import AuditRecord, RequestMeta
logger = logging.getLogger(__name__)
def _to_out(role: Role, user_count: int) -> RoleManageOut:
"""ORM 对象转响应模型。禁止把 ORM 对象直接当 response_model。"""
return RoleManageOut(
id=role.id,
role_code=role.role_code,
role_name=role.role_name,
description=role.description,
sort_order=role.sort_order,
status=role.status,
user_count=user_count,
)
def _user_count(session: Session, role_id: int) -> int:
"""单个角色的绑定用户数(复用批量统计,保持口径一致)。"""
return role_repository.count_users_by_role(session, [role_id]).get(role_id, 0)
def list_roles(session: Session, query: RoleQuery) -> PageResult[RoleManageOut]:
"""分页查询角色。
与用户表单下拉(`/users/meta/roles`)口径不同:**管理列表含停用角色**,
下拉只给启用项。
"""
roles = role_repository.list_roles(
session,
offset=query.offset,
limit=query.page_size,
keyword=query.keyword,
status=query.status,
)
total = role_repository.count_roles(session, keyword=query.keyword, status=query.status)
counts = role_repository.count_users_by_role(session, [role.id for role in roles])
return PageResult[RoleManageOut](
records=[_to_out(role, counts.get(role.id, 0)) for role in roles],
total=total,
page_num=query.page_num,
page_size=query.page_size,
)
def create_role(
session: Session,
payload: RoleCreateIn,
*,
operator: User,
meta: RequestMeta | None = None,
) -> RoleManageOut:
"""新增角色。编码唯一、创建后不可修改,新角色固定启用。
:raises BadRequestError: 角色编码已存在(含并发提交的兜底)
"""
with audit_service.audit(
session,
operator=operator,
record=AuditRecord(
module=MODULE_ROLE,
action=ACTION_CREATE,
summary=f"新增角色 {payload.role_name}({payload.role_code})",
request_data=payload.model_dump(exclude_none=True),
),
meta=meta,
):
if role_repository.get_by_code(session, payload.role_code) is not None:
raise BadRequestError(f"角色编码 {payload.role_code!r} 已存在")
role = Role(
role_code=payload.role_code,
role_name=payload.role_name,
description=payload.description,
sort_order=payload.sort_order,
status=1,
)
try:
role_repository.create(session, role)
out = _to_out(role, 0)
session.commit()
except IntegrityError as exc:
# 先查后写不是原子操作:并发提交同一编码会撞 uk_roles_role_code。
# 回滚并把唯一键冲突翻译为可读的 400,而非 500
session.rollback()
raise BadRequestError(f"角色编码 {payload.role_code!r} 已存在") from exc
logger.info("新增角色 | role_code=%s", payload.role_code)
return out
def update_role(
session: Session,
role_id: int,
payload: RoleUpdateIn,
*,
operator: User,
meta: RequestMeta | None = None,
) -> RoleManageOut:
"""编辑角色名称/说明/排序(全量替换,不含 role_code)。
:raises NotFoundError: 角色不存在
"""
role = role_repository.get_by_id(session, role_id)
with audit_service.audit(
session,
operator=operator,
record=AuditRecord(
module=MODULE_ROLE,
action=ACTION_UPDATE,
summary=f"修改角色 {role.role_name if role else f'#{role_id}'}",
request_data=payload.model_dump(exclude_none=True),
),
meta=meta,
):
if role is None:
raise NotFoundError("角色不存在")
role.role_name = payload.role_name
role.description = payload.description
role.sort_order = payload.sort_order
# 响应与后置动作全部在 commit 之前完成(见 user_service 顶部说明)
out = _to_out(role, _user_count(session, role_id))
session.commit()
logger.info("修改角色 | role_id=%s", role_id)
return out
def set_status(
session: Session,
role_id: int,
status: int,
*,
operator: User,
meta: RequestMeta | None = None,
) -> RoleManageOut:
"""启用或禁用角色。
停用不解除用户绑定、不影响既有授权(授权判定只看编码不看状态),
但该角色不可再分配给用户(`user_service._resolve_roles` 拦截);
内置管理员角色不可停用。
:raises NotFoundError: 角色不存在
:raises ForbiddenError: 试图停用内置管理员角色
"""
role = role_repository.get_by_id(session, role_id)
verb = "启用" if status == 1 else "禁用"
with audit_service.audit(
session,
operator=operator,
record=AuditRecord(
module=MODULE_ROLE,
action=ACTION_ENABLE if status == 1 else ACTION_DISABLE,
summary=f"{verb}角色 {role.role_name if role else f'#{role_id}'}",
request_data={"status": status},
),
meta=meta,
):
if role is None:
raise NotFoundError("角色不存在")
if role.role_code == ROLE_ADMIN and status == 0:
raise ForbiddenError("内置管理员角色不可停用")
role.status = status
out = _to_out(role, _user_count(session, role_id))
session.commit()
logger.info("变更角色状态 | role_id=%s | status=%s", role_id, status)
return out
def delete_role(
session: Session,
role_id: int,
*,
operator: User,
meta: RequestMeta | None = None,
) -> None:
"""删除角色。
内置管理员角色不可删除;仍有关联用户的角色不可删除(提示先解除绑定)。
查询走 `FOR UPDATE` 锁行:校验与删除之间若发生并发绑定,CASCADE 会把
新绑定一并静默剥离 —— 锁行使并发绑定失败(可见)而非数据被吞(不可见)。
:raises NotFoundError: 角色不存在
:raises ForbiddenError: 试图删除内置管理员角色
:raises BadRequestError: 角色下仍有绑定的用户
"""
role = role_repository.get_by_id(session, role_id, for_update=True)
with audit_service.audit(
session,
operator=operator,
record=AuditRecord(
module=MODULE_ROLE,
action=ACTION_DELETE,
summary=f"删除角色 {role.role_name if role else f'#{role_id}'}",
),
meta=meta,
):
if role is None:
raise NotFoundError("角色不存在")
if role.role_code == ROLE_ADMIN:
raise ForbiddenError("内置管理员角色不可删除")
# user_roles 外键是 CASCADE:不拦截的话,删除会静默剥离绑定用户的权限
user_count = _user_count(session, role_id)
if user_count > 0:
raise BadRequestError(f"该角色下仍有 {user_count} 个用户,请先解除绑定")
role_code = role.role_code # 固化:对象随删除失效
role_repository.delete(session, role)
session.commit()
logger.info("删除角色 | role_code=%s", role_code)
+360
View File
@@ -0,0 +1,360 @@
"""用户管理业务编排。
**事务边界在本层**:每个公开方法自行决定何时 commit。
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 (
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 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__)
def _to_out(user: User) -> UserOut:
"""ORM 对象转响应模型。
禁止把 ORM 对象直接当 response_model —— 表结构变更会直接泄漏到接口契约上。
"""
return UserOut(
id=user.id,
username=user.username,
real_name=user.real_name,
phone=user.phone,
email=user.email,
status=user.status,
roles=user_repository.get_role_codes(user),
last_login_time=user.last_login_time,
remark=user.remark,
create_time=user.create_time,
)
def list_users(session: Session, query: UserQuery) -> PageResult[UserOut]:
"""分页查询用户列表。"""
users = user_repository.list_users(
session,
offset=query.offset,
limit=query.page_size,
username=query.username,
real_name=query.real_name,
status=query.status,
role_code=query.role_code,
)
total = user_repository.count_users(
session,
username=query.username,
real_name=query.real_name,
status=query.status,
role_code=query.role_code,
)
return PageResult[UserOut](
records=[_to_out(user) for user in users],
total=total,
page_num=query.page_num,
page_size=query.page_size,
)
def get_user(session: Session, user_id: int) -> UserOut:
"""查询用户详情。
:raises NotFoundError: 用户不存在
"""
user = user_repository.get_by_id(session, user_id)
if user is None:
raise NotFoundError("用户不存在")
return _to_out(user)
def create_user(
session: Session,
payload: UserCreateIn,
*,
operator: User,
meta: RequestMeta | None = None,
) -> UserOut:
"""新增用户。
:raises BadRequestError: 用户名已存在,或角色编码无效/已停用
"""
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)
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", payload.username, payload.roles)
return out
def update_user(
session: Session,
user_id: int,
payload: UserUpdateIn,
*,
operator: User,
meta: RequestMeta | None = None,
) -> UserOut:
"""编辑用户的基本信息与角色。
**不含用户名与密码**:用户名是审计追溯的锚点;密码走专门的重置接口,
以免"改资料"顺手改掉密码这类越权操作。
空串字段(`''`)归一为 None:编辑表单会把未修改的字段以空串提交,
若原样写入会把库中的 NULL 污染成空串(两类空值语义漂移且不可逆);
归一后「清空输入框」= 写回 NULL,语义正确。
:raises NotFoundError: 用户不存在
:raises BadRequestError: 角色编码无效或已停用
"""
user = user_repository.get_by_id(session, user_id)
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 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("不能移除自己的管理员角色")
session.flush()
if payload.roles is not None:
roles = _resolve_roles(session, payload.roles)
user_repository.replace_roles(session, user, roles)
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,
*,
operator: User,
meta: RequestMeta | None = None,
) -> None:
"""重置用户密码。
同时把 `token_version` 加一,使该用户**已签发的令牌立即失效** ——
改密后旧令牌仍可用是常见疏漏,这里与「离职即失效」复用同一机制。
:raises NotFoundError: 用户不存在
"""
user = user_repository.get_by_id(session, user_id)
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)
def set_status(
session: Session,
user_id: int,
status: int,
*,
operator: User,
meta: RequestMeta | None = None,
) -> UserOut:
"""启用或禁用用户。
禁用时同样提升 `token_version`,使已签发令牌立即失效 ——
否则被禁用者仍可凭旧令牌访问直到过期(最长 2 小时)。
:raises NotFoundError: 用户不存在
:raises ForbiddenError: 试图禁用自己
"""
user = user_repository.get_by_id(session, user_id)
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("不能禁用自己")
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: User,
meta: RequestMeta | None = None,
) -> None:
"""删除用户。
物理删除。历史操作日志通过 `user_id` 置空 + 冗余 `username` 保留可追溯性。
:raises NotFoundError: 用户不存在
:raises ForbiddenError: 试图删除自己
"""
user = user_repository.get_by_id(session, user_id)
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("不能删除自己")
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 role_repository.get_all_roles(session)]
def _resolve_roles(session: Session, role_codes: list[str]) -> list:
"""把角色编码解析为角色对象,校验全部存在且**未停用**。
停用角色不出现在表单下拉中;接口层若不拦截,直接调 API(或使用停用前
加载的旧选项)仍可绑定,与「停用后不可再分配」的语义矛盾。已绑定停用
角色的用户需先转为启用角色再保存。
:raises BadRequestError: 存在无效编码,或角色已停用
"""
if not role_codes:
raise BadRequestError("至少需要指定一个角色")
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)
+1
View File
@@ -34,6 +34,7 @@ dependencies = [
# 图形验证码(纯 Python,依赖 Pillow) # 图形验证码(纯 Python,依赖 Pillow)
"captcha>=0.6", "captcha>=0.6",
"pillow>=11.0", "pillow>=11.0",
"openpyxl>=3.1.5",
] ]
[dependency-groups] [dependency-groups]
+41 -6
View File
@@ -1,6 +1,6 @@
# 后端测试 # 后端测试
> AI摘要:本项目后端的测试用例清单与运行记录。按「基础设施层随实现即时测、业务模块整模块完成时统一测」分层组织。当前 184 个用例全绿(单元 155 / 集成 29)。 > AI摘要:本项目后端的测试用例清单与运行记录。按「基础设施层随实现即时测、业务模块整模块完成时统一测」分层组织。当前 301 个用例全绿(单元 223 / 集成 78)。
## 一、测试约定 ## 一、测试约定
@@ -57,6 +57,23 @@ uv run pytest tests/integration -q # 仅集成
| `unit/test_auth_schemas.py` | 密码**按字节**校验 | 9 | 24 个汉字 = 72 字节正好、25 个被拒 —— 按字符数校验(如 `max_length=20`)根本拦不住,会让登录接口 500 | | `unit/test_auth_schemas.py` | 密码**按字节**校验 | 9 | 24 个汉字 = 72 字节正好、25 个被拒 —— 按字符数校验(如 `max_length=20`)根本拦不住,会让登录接口 500 |
| `integration/test_auth_flow.py` | 整条链路**串联正确性** | 16 | 验证码→登录→持令牌访问→登出失效;验证码重放被拒;失败原因如实落库且**日志表无任何密码字段**;账号不存在与密码错误文案完全相同(防枚举);禁用账号与 `token_version` 提升均使旧令牌立即失效 | | `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 |
### 应用骨架与交付产物 ### 应用骨架与交付产物
| 测试文件 | 守什么 | 用例数 | 关键覆盖点 | | 测试文件 | 守什么 | 用例数 | 关键覆盖点 |
@@ -64,6 +81,17 @@ uv run pytest tests/integration -q # 仅集成
| `integration/test_health.py` | 统一信封与全局异常 | 7 | `/api/health` 始终 200 且依赖状态如实上报;404/405 返回统一信封而非框架默认 `detail` 数组;`/api/docs` 可访问 | | `integration/test_health.py` | 统一信封与全局异常 | 7 | `/api/health` 始终 200 且依赖状态如实上报;404/405 返回统一信封而非框架默认 `detail` 数组;`/api/docs` 可访问 |
| `integration/test_schema_sync.py` | DDL 不漂移 | 6 | `db/migration/V1__*.sql` 与 `db/init/init-dev.sql` 的**表集合与列集合逐表比对**——两份 DDL 手工同步迟早漂移,且漂移后果隐蔽(开发库结构与生产不一致,上线才发现) | | `integration/test_schema_sync.py` | DDL 不漂移 | 6 | `db/migration/V1__*.sql` 与 `db/init/init-dev.sql` 的**表集合与列集合逐表比对**——两份 DDL 手工同步迟早漂移,且漂移后果隐蔽(开发库结构与生产不一致,上线才发现) |
### M1 计提参数与岗位角色(2026-10-13 完成)
| 文件 | 守什么 | 用例数 |
| --- | --- | --- |
| `tests/integration/test_business_roles.py` | 岗位 CRUD 链路、内置岗位禁删禁停(种子 3 个)、模板编码校验、编辑换模板、权限 403、模板下拉 5 项 | 8 |
| `tests/integration/test_param_management.py` | 目录 16 项与七分组、企业户 2‰→3‰ 按到账日回溯、斜率 4/360 分数原样存储、变更生命周期(立即/未来生效不泄漏/同日取最新/撤回回退/已生效禁撤)、值校验(上限/枚举/除零分数/过去日期)、预览金标准(27 天=97/10000、折算 15000、绩效 145.5、可付 52.38;361 天=4‰)、经理聚合口径拒绝、权限 403 | 18 |
| `tests/unit/test_formula.py`、`tests/unit/test_param_registry.py` | 八个公式纯函数精确分数断言(第 1 天=899/90000、360=6‰、361=4‰、27 天、防除零、负绩效不归零、R×R)、注册表 16 项与迁移种子同源、模板声明参数闭环 | 43 |
| `tests/unit/test_param_snapshot.py` | 快照覆盖链(角色>产品类别>默认)、注册表默认值兜底、同日取最新、冻结不可变 | 12 |
运行:`uv run pytest -q` → **406 passed**(2026-10-13 全量含回归;T5 冒烟另验证提交/生效/撤回/对比/导出/预览全链路)。
## 三、运行记录 ## 三、运行记录
| 日期 | 范围 | 用例数 | 结果 | 备注 | | 日期 | 范围 | 用例数 | 结果 | 备注 |
@@ -71,6 +99,8 @@ uv run pytest tests/integration -q # 仅集成
| 2026-10-09 | 全部 | 120 | ✅ 通过(4.99s) | 阶段 1 骨架 + 阶段 2 数据库完成后首次全量运行 | | 2026-10-09 | 全部 | 120 | ✅ 通过(4.99s) | 阶段 1 骨架 + 阶段 2 数据库完成后首次全量运行 |
| 2026-10-09 | 全部 | 119 | ✅ 通过(5.01s) | ORM 由异步改回同步后回归;净减 1 例(`sync_database_url` 独立配置已合并) | | 2026-10-09 | 全部 | 119 | ✅ 通过(5.01s) | ORM 由异步改回同步后回归;净减 1 例(`sync_database_url` 独立配置已合并) |
| 2026-10-09 | 全部 | 184 | ✅ 通过(25.46s) | **登录鉴权模块完成**后统一编写并执行该模块测试套件(新增 65 例)。集成用例需容器在跑 | | 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 +115,19 @@ uv run pytest tests/integration -q # 仅集成
| 2026-10-09 | 验证码沿用 `captcha` 库默认渲染 | 随机彩色字符 + 重噪点,与界面视觉体系冲突;该库最后更新于 2025-03,内部还引用了 Pillow 的旧别名 | 改为自绘(`core/captcha_image.py`):配色与界面统一,难度由逐字旋转、抖动、干扰曲线与噪点保证;仅沿用其内置字体文件 | | 2026-10-09 | 验证码沿用 `captcha` 库默认渲染 | 随机彩色字符 + 重噪点,与界面视觉体系冲突;该库最后更新于 2025-03,内部还引用了 Pillow 的旧别名 | 改为自绘(`core/captcha_image.py`):配色与界面统一,难度由逐字旋转、抖动、干扰曲线与噪点保证;仅沿用其内置字体文件 |
| 2026-10-09 | 噪点用 `ellipse` 绘制 | 3×3 包围盒画出的椭圆恰好填充中心与四个边中点,形状就是「+」,形成规则的十字纹理——既显眼又容易被整体滤波抹掉,起不到干扰作用 | 改用单像素点与 2×2 方块 | | 2026-10-09 | 噪点用 `ellipse` 绘制 | 3×3 包围盒画出的椭圆恰好填充中心与四个边中点,形状就是「+」,形成规则的十字纹理——既显眼又容易被整体滤波抹掉,起不到干扰作用 | 改用单像素点与 2×2 方块 |
| 2026-10-09 | `tests/unit/test_jwt.py` 两处赋值了未使用的 `settings`(ruff F841) | 变量未被引用,属无效代码 | 删除赋值;该文件的 `get_settings` 导入仍被其他用例使用 | | 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 的用户增删改查、密码重置、启用/禁用、角色分配 — 该模块功能尚未实现 | | 基础数据管理 | 未开始 | 需求已澄清(2026-10-12);依赖 T+ 对接方式确认 |
| 系统管理 · 操作日志 | 待编写 | 除登录日志外的操作审计(当前仅登录日志落库) | | 业务数据管理 | 未开始 | 需求已澄清(28 项裁决见 `docs/问题与解决方案对照表_v2.xlsx`) |
| 基础数据管理 | 未开始 | 依赖 T+ 对接方式确认 | | 绩效工资核算 | 未开始 | 需求已澄清;金额精度需专项用例(`Fraction` 与 1/90 日斜率已在 M1 公式层覆盖) |
| 业务数据管理 | 未开始 | 依赖需求澄清(回款天数口径等 10 项) |
| 绩效工资核算 | 未开始 | 依赖需求澄清;金额精度需专项用例(`Fraction` 与 1/90 日斜率) |
| 报表统计 | 未开始 | 依赖报表口径确认 | | 报表统计 | 未开始 | 依赖报表口径确认 |
> 系统管理模块(用户/角色/日志查询)已于 2026-10-10 完成并补齐用例,见上表。
+169
View File
@@ -0,0 +1,169 @@
"""系统管理集成测试共用夹具(需要 MySQL 与 Redis)。
运行前置:``docker compose -f docker/docker-compose.dev.yml up -d``
数据卫生约定:
- 测试账号/角色统一 ``t_`` 前缀;工厂夹具清理自己创建的对象,另有
autouse 兜底按前缀全量清理(用例经**接口**创建的数据不在工厂名下,
失败中断时会残留并影响后续用例的 total 断言)
- 操作日志的清理同时按 username 与 user_id —— 账号被删除后其 ``user_id``
会被 SET NULL,只能靠冗余的 username 追溯,清理逻辑必须覆盖两种状态
"""
import uuid
import pytest
from sqlalchemy import delete, select
from app.core.constants import ROLE_ADMIN, ROLE_USER
from app.core.db import get_session_factory
from app.core.security import create_access_token, hash_password
from app.models import OperationLog, Role, User, UserRole
TEST_PASSWORD = "TestPwd@2026"
# 模块级预计算一次:bcrypt(rounds=12)单次约 0.2–0.4 秒,逐用例重复哈希
# 会让整个集成套件多花 20 秒以上;这些测试账号全部走直签 JWT,
# 哈希值不被任何断言消费,复用一个哈希与每次重算等价
_TEST_PASSWORD_HASH = hash_password(TEST_PASSWORD)
def _purge_test_data() -> None:
"""按 `t_` 前缀清理测试残留(先日志后账号;角色先断关联再删)。
``t\\_%`` 中的下划线做了转义 —— 不转义时 `_` 是 LIKE 的单字符通配,
会把 tn... 这类无关账号一并卷入。
"""
factory = get_session_factory()
with factory() as session:
users = list(
session.execute(select(User.id, User.username).where(User.username.like("t\\_%"))).all()
)
ids = [row[0] for row in users]
names = [row[1] for row in users]
if ids:
session.execute(delete(OperationLog).where(OperationLog.user_id.in_(ids)))
if names:
session.execute(delete(OperationLog).where(OperationLog.username.in_(names)))
session.execute(delete(User).where(User.id.in_(ids)))
role_ids = list(
session.execute(select(Role.id).where(Role.role_code.like("t\\_%"))).scalars()
)
if role_ids:
session.execute(delete(UserRole).where(UserRole.role_id.in_(role_ids)))
session.execute(delete(Role).where(Role.id.in_(role_ids)))
session.commit()
@pytest.fixture(autouse=True)
def _purge_after_each_test():
"""每个集成用例结束后兜底清理,避免残留影响后续用例的计数断言。"""
yield
_purge_test_data()
@pytest.fixture
def make_user():
"""独立测试账号工厂:``make_user(["admin"])`` → dict(id/username/password)。
不复用真实 admin —— 测试会改密码、提 token_version、删除账号,
污染真实账号会让后续手工验证产生困惑。
"""
def _factory(role_codes: list[str] | None = None, username: str | None = None) -> dict:
codes = role_codes if role_codes is not None else [ROLE_USER]
name = username or f"t_{uuid.uuid4().hex[:8]}"
factory = get_session_factory()
with factory() as session:
roles = list(session.execute(select(Role).where(Role.role_code.in_(codes))).scalars())
assert len(roles) == len(codes), f"角色不存在:{codes}"
user = User(
username=name,
password=_TEST_PASSWORD_HASH,
real_name="测试账号",
status=1,
)
user.user_roles = [UserRole(role_id=role.id) for role in roles]
session.add(user)
session.commit()
return {"id": user.id, "username": name, "password": TEST_PASSWORD}
return _factory
@pytest.fixture
def auth_headers():
"""令牌直签工厂:``auth_headers(user)`` → Authorization 请求头。
绕开验证码与登录接口 —— 令牌只承载身份,授权判定以数据库中的角色为准
(`require_admin` 每次查库),直签不影响测试有效性,省去清理 Redis
限流键与黑名单。令牌版本变化(如重置密码、禁用)后旧令牌会失效,
再次调用本工厂可换新令牌。
"""
def _factory(user: dict) -> dict[str, str]:
factory = get_session_factory()
with factory() as session:
db_user = session.get(User, user["id"])
assert db_user is not None, "账号已被删除,无法签发令牌"
roles = [link.role.role_code for link in db_user.user_roles if link.role]
token, _, _ = create_access_token(
user_id=db_user.id,
token_version=db_user.token_version,
roles=roles,
)
return {"Authorization": f"Bearer {token}"}
return _factory
@pytest.fixture
def make_role():
"""独立测试角色工厂(ORM 直建,用后删除)。
清理时先删除 user_roles 关联(用例失败时可能残留绑定),再删角色。
"""
created: list[int] = []
def _factory(**overrides: object) -> dict:
code = str(overrides.pop("role_code", f"t_role_{uuid.uuid4().hex[:6]}"))
name = str(overrides.pop("role_name", "测试角色"))
factory = get_session_factory()
with factory() as session:
role = Role(
role_code=code,
role_name=name,
description=overrides.pop("description", None),
sort_order=overrides.pop("sort_order", 99),
status=overrides.pop("status", 1),
**overrides,
)
session.add(role)
session.commit()
created.append(role.id)
return {"id": role.id, "role_code": code, "role_name": name}
yield _factory
factory = get_session_factory()
with factory() as session:
session.execute(delete(UserRole).where(UserRole.role_id.in_(created)))
session.execute(delete(Role).where(Role.id.in_(created)))
session.commit()
@pytest.fixture
def admin(make_user, auth_headers) -> dict:
"""管理员账号(含请求头)。"""
user = make_user([ROLE_ADMIN])
user["headers"] = auth_headers(user)
return user
@pytest.fixture
def normal(make_user, auth_headers) -> dict:
"""普通用户账号(含请求头)。"""
user = make_user([ROLE_USER])
user["headers"] = auth_headers(user)
return user
+198
View File
@@ -0,0 +1,198 @@
"""日志查询接口集成测试(需要 MySQL 与 Redis)。
守的是:两类日志的筛选、分页、权限,以及**写入与查询的口径一致** ——
写操作产生的审计记录必须能被查询接口按同样口径检索到(否则"有写入
没查询"或"查询条件对不上",日志功能形同虚设)。
登录日志造数直接 ORM 插入,不经过登录接口:绕开验证码依赖,且能精确
控制 success/fail_reason/ip 组合。
运行前置:``docker compose -f docker/docker-compose.dev.yml up -d``
"""
import uuid
from datetime import datetime, timedelta
import pytest
from fastapi.testclient import TestClient
from sqlalchemy import delete
from app.core.db import get_session_factory
from app.models import LoginLog
pytestmark = pytest.mark.integration
_OPERATION_LOGS = "/api/v1/system/operation-logs"
_LOGIN_LOGS = "/api/v1/system/login-logs"
_USERS = "/api/v1/system/users"
def _make_audit(client: TestClient, admin: dict, action: str = "删除") -> None:
"""触发一条审计记录(失败操作最省事、不留业务数据)。"""
resp = client.delete(f"{_USERS}/999999999", headers=admin["headers"])
assert resp.status_code == 404
assert action == "删除"
class TestOperationLogQuery:
"""操作日志查询。"""
def test_写操作后可按模块与操作筛选(self, client: TestClient, admin: dict, make_user) -> None:
target = make_user()
resp = client.delete(f"{_USERS}/{target['id']}", headers=admin["headers"])
assert resp.status_code == 200
resp = client.get(
_OPERATION_LOGS,
params={"username": admin["username"], "module": "用户管理", "action": "删除"},
headers=admin["headers"],
)
data = resp.json()["data"]
assert data["total"] >= 1
row = data["list"][0]
assert row["success"] == 1
assert target["username"] in row["summary"]
assert row["module"] == "用户管理"
def test_按结果筛选出失败记录(self, client: TestClient, admin: dict) -> None:
_make_audit(client, admin)
resp = client.get(
_OPERATION_LOGS,
params={"username": admin["username"], "success": 0},
headers=admin["headers"],
)
data = resp.json()["data"]
assert data["total"] >= 1
assert data["list"][0]["error_message"]
def test_时间范围筛选(self, client: TestClient, admin: dict) -> None:
_make_audit(client, admin)
now = datetime.now()
fmt = "%Y-%m-%d %H:%M:%S"
resp = client.get(
_OPERATION_LOGS,
params={
"username": admin["username"],
"start_time": (now - timedelta(minutes=5)).strftime(fmt),
"end_time": (now + timedelta(minutes=5)).strftime(fmt),
},
headers=admin["headers"],
)
assert resp.json()["data"]["total"] >= 1
# 已过去的时间窗(排除测试刚产生的记录)
resp = client.get(
_OPERATION_LOGS,
params={
"username": admin["username"],
"start_time": "2020-01-01 00:00:00",
"end_time": "2020-01-02 00:00:00",
},
headers=admin["headers"],
)
assert resp.json()["data"]["total"] == 0
def test_起始晚于结束返回400(self, client: TestClient, admin: dict) -> None:
resp = client.get(
_OPERATION_LOGS,
params={"start_time": "2026-10-02 00:00:00", "end_time": "2026-10-01 00:00:00"},
headers=admin["headers"],
)
assert resp.status_code == 400
def test_列表响应不含请求参数字段(self, client: TestClient, admin: dict) -> None:
"""列表为性能排除 request_data(TEXT 大字段),防止翻页负担随日志表增长。"""
resp = client.get(_OPERATION_LOGS, headers=admin["headers"])
rows = resp.json()["data"]["list"]
if rows:
assert "request_data" not in rows[0]
def test_详情端点返回已脱敏的请求参数(self, client: TestClient, admin: dict, make_user) -> None:
"""列表 → 详情 的两段式:详情含 request_data 且已脱敏。"""
secret = "S3cret@2026!"
target = make_user()
resp = client.put(
f"{_USERS}/{target['id']}/password",
json={"new_password": secret},
headers=admin["headers"],
)
assert resp.status_code == 200
resp = client.get(
_OPERATION_LOGS,
params={"username": admin["username"], "action": "重置密码"},
headers=admin["headers"],
)
row = resp.json()["data"]["list"][0]
assert "request_data" not in row
detail = client.get(f"{_OPERATION_LOGS}/{row['id']}", headers=admin["headers"])
assert detail.status_code == 200
request_data = detail.json()["data"]["request_data"] or ""
assert secret not in request_data
assert "***" in request_data
def test_详情端点不存在返回404(self, client: TestClient, admin: dict) -> None:
resp = client.get(f"{_OPERATION_LOGS}/999999999", headers=admin["headers"])
assert resp.status_code == 404
def test_详情端点普通用户被拒(self, client: TestClient, normal: dict) -> None:
resp = client.get(f"{_OPERATION_LOGS}/1", headers=normal["headers"])
assert resp.status_code == 403
def test_普通用户被拒(self, client: TestClient, normal: dict) -> None:
assert client.get(_OPERATION_LOGS, headers=normal["headers"]).status_code == 403
class TestLoginLogQuery:
"""登录日志查询。"""
@pytest.fixture
def seeded_logs(self):
"""直插两条登录日志(一成功一失败),用后清理。"""
username = f"t_login_{uuid.uuid4().hex[:6]}"
factory = get_session_factory()
with factory() as session:
session.add(
LoginLog(
username=username,
ip="10.1.2.3",
user_agent="pytest",
success=0,
fail_reason="用户名或密码错误",
)
)
session.add(LoginLog(username=username, ip="10.1.2.4", user_agent="pytest", success=1))
session.commit()
yield username
with factory() as session:
session.execute(delete(LoginLog).where(LoginLog.username == username))
session.commit()
def test_按用户名查询并返回两端结果(
self, client: TestClient, admin: dict, seeded_logs: str
) -> None:
resp = client.get(_LOGIN_LOGS, params={"username": seeded_logs}, headers=admin["headers"])
data = resp.json()["data"]
assert data["total"] == 2
assert {row["success"] for row in data["list"]} == {0, 1}
def test_按IP精确筛选(self, client: TestClient, admin: dict, seeded_logs: str) -> None:
resp = client.get(_LOGIN_LOGS, params={"ip": "10.1.2.3"}, headers=admin["headers"])
data = resp.json()["data"]
assert data["total"] == 1
assert data["list"][0]["fail_reason"] == "用户名或密码错误"
def test_按结果筛选(self, client: TestClient, admin: dict, seeded_logs: str) -> None:
resp = client.get(
_LOGIN_LOGS,
params={"username": seeded_logs, "success": 0},
headers=admin["headers"],
)
data = resp.json()["data"]
assert data["total"] == 1
assert data["list"][0]["success"] == 0
def test_普通用户被拒(self, client: TestClient, normal: dict) -> None:
assert client.get(_LOGIN_LOGS, headers=normal["headers"]).status_code == 403
@@ -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
+54 -9
View File
@@ -1,13 +1,16 @@
"""DDL 漂移检测。 """DDL 漂移检测。
结构定义同时存在于两处: 结构定义同时存在于两处:
- `db/migration/V1__*.sql` —— 事实来源,生产迁移用 - `db/migration/V*.sql` —— 事实来源,生产迁移用
- `db/init/init-dev.sql` —— 开发环境重置脚本,Docker 首次启动唯一会自动执行的位置 - `db/init/init-dev.sql` —— 开发环境重置脚本,Docker 首次启动唯一会自动执行的位置
两份 DDL 手工同步迟早会漂移,且后果隐蔽:开发库的表结构与迁移脚本不一致, 两份 DDL 手工同步迟早会漂移,且后果隐蔽:开发库的表结构与迁移脚本不一致,
新人克隆下来建出的库和生产不一样,等到上线才发现。 新人克隆下来建出的库和生产不一样,等到上线才发现。
本测试把「靠自觉」变成「可自动拦截」,成本极低。 本测试把「靠自觉」变成「可自动拦截」,成本极低。
**必须比对全部 V*.sql 的合集**:早期版本只解析 V1,新增 V2 之后,
两份 DDL 同时缺表仍会被判为「一致」,漂移守卫形同虚设。
注:本文件不需要容器,放在 integration 下是因为它校验的是交付产物而非应用代码。 注:本文件不需要容器,放在 integration 下是因为它校验的是交付产物而非应用代码。
""" """
@@ -17,8 +20,9 @@ from pathlib import Path
import pytest import pytest
PROJECT_ROOT = Path(__file__).resolve().parents[3] PROJECT_ROOT = Path(__file__).resolve().parents[3]
MIGRATION_FILE = PROJECT_ROOT / "db" / "migration" / "V1__init_rbac_and_login_logs.sql" MIGRATION_DIR = PROJECT_ROOT / "db" / "migration"
INIT_DEV_FILE = PROJECT_ROOT / "db" / "init" / "init-dev.sql" INIT_DEV_FILE = PROJECT_ROOT / "db" / "init" / "init-dev.sql"
ALEMBIC_VERSIONS_DIR = PROJECT_ROOT / "backend" / "alembic" / "versions"
_CREATE_TABLE = re.compile(r"CREATE TABLE\s+`?(\w+)`?\s*\((.*?)\n\)", re.DOTALL) _CREATE_TABLE = re.compile(r"CREATE TABLE\s+`?(\w+)`?\s*\((.*?)\n\)", re.DOTALL)
_COLUMN_LINE = re.compile(r"^\s{4}`?(\w+)`?\s", re.MULTILINE) _COLUMN_LINE = re.compile(r"^\s{4}`?(\w+)`?\s", re.MULTILINE)
@@ -38,9 +42,38 @@ def _parse_tables(sql: str) -> dict[str, set[str]]:
return tables return tables
def _alembic_head() -> str:
"""从迁移脚本推断当前 head 版本号。
纯文本解析而非调用 alembic 运行时 —— 后者需要连数据库,会让本测试
从「秒级、无需容器」变成「必须起库」。
head 的定义:没有被任何其他迁移的 down_revision 引用过的那个 revision。
"""
revisions: set[str] = set()
referenced: set[str] = set()
for path in ALEMBIC_VERSIONS_DIR.glob("*.py"):
text = path.read_text(encoding="utf-8")
if found := re.search(r"^revision: str = ['\"](\w+)['\"]", text, re.MULTILINE):
revisions.add(found.group(1))
if found := re.search(r"^down_revision:.*?= ['\"](\w+)['\"]", text, re.MULTILINE):
referenced.add(found.group(1))
heads = revisions - referenced
assert len(heads) == 1, f"迁移链存在分叉或缺失,heads={sorted(heads)}"
return heads.pop()
@pytest.fixture(scope="module") @pytest.fixture(scope="module")
def migration_tables() -> dict[str, set[str]]: def migration_tables() -> dict[str, set[str]]:
return _parse_tables(MIGRATION_FILE.read_text(encoding="utf-8")) """合并解析 db/migration/ 下全部 V*.sql。"""
files = sorted(MIGRATION_DIR.glob("V*.sql"))
assert files, f"未找到迁移脚本:{MIGRATION_DIR}"
merged: dict[str, set[str]] = {}
for path in files:
for table, columns in _parse_tables(path.read_text(encoding="utf-8")).items():
merged.setdefault(table, set()).update(columns)
return merged
@pytest.fixture(scope="module") @pytest.fixture(scope="module")
@@ -52,7 +85,7 @@ class TestSchemaSync:
"""两份 DDL 的结构一致性。""" """两份 DDL 的结构一致性。"""
def test_文件存在(self) -> None: def test_文件存在(self) -> None:
assert MIGRATION_FILE.is_file(), f"缺少迁移脚本:{MIGRATION_FILE}" assert list(MIGRATION_DIR.glob("V*.sql")), f"缺少迁移脚本:{MIGRATION_DIR}"
assert INIT_DEV_FILE.is_file(), f"缺少开发初始化脚本:{INIT_DEV_FILE}" assert INIT_DEV_FILE.is_file(), f"缺少开发初始化脚本:{INIT_DEV_FILE}"
def test_表集合一致(self, migration_tables: dict, init_dev_tables: dict) -> None: def test_表集合一致(self, migration_tables: dict, init_dev_tables: dict) -> None:
@@ -74,16 +107,28 @@ class TestSchemaSync:
class TestSeedData: class TestSeedData:
"""字典数据在两处应保持同步。""" """字典数据与版本标记。"""
@pytest.mark.parametrize("role_code", ["admin", "user"]) @pytest.mark.parametrize("role_code", ["admin", "user"])
def test_角色初始化数据在两处都存在(self, role_code: str) -> None: def test_角色初始化数据在两处都存在(self, role_code: str) -> None:
migration_sql = MIGRATION_FILE.read_text(encoding="utf-8") migration_sql = "\n".join(
path.read_text(encoding="utf-8") for path in sorted(MIGRATION_DIR.glob("V*.sql"))
)
init_sql = INIT_DEV_FILE.read_text(encoding="utf-8") init_sql = INIT_DEV_FILE.read_text(encoding="utf-8")
assert f"'{role_code}'" in migration_sql assert f"'{role_code}'" in migration_sql
assert f"'{role_code}'" in init_sql assert f"'{role_code}'" in init_sql
def test_开发脚本标记了迁移版本(self) -> None: def test_开发脚本标记的版本号等于当前head(self) -> None:
"""不写 alembic_version 的话,在开发库上执行 upgrade head 会重复建表而失败。""" """版本号必须等于 head,且不能只断言「存在」。
落后的后果:开发库重建后执行 `alembic upgrade head`,alembic 认为该迁移
尚未应用,重复建表并报 1050 Table already exists,只能手工补版本或 down -v。
"""
init_sql = INIT_DEV_FILE.read_text(encoding="utf-8") init_sql = INIT_DEV_FILE.read_text(encoding="utf-8")
assert "INSERT INTO alembic_version" in init_sql found = re.search(
r"INSERT INTO alembic_version \(version_num\) VALUES \('(\w+)'\)", init_sql
)
assert found, "init-dev.sql 未写入 alembic_version"
assert found.group(1) == _alembic_head(), (
f"init-dev.sql 标记的版本号 {found.group(1)!r} 落后于当前 head {_alembic_head()!r}"
)
@@ -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
@@ -0,0 +1,141 @@
"""操作日志入库前处理单元测试。
守的是入库前的**截断与脱敏**:
- 截断:各列超长在 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
# phone 键已加入结构化抹除清单(整值 ***)—— 带空格/横线分隔的号码
# (如 138 0013 8000)会穿过分值文本正则,键名整值抹除是更强的防线
assert '"phone": "***"' in text
def test_带分隔符的手机号被脱敏(self) -> None:
"""修复回归:'138 0013 8000' 类分隔符写法曾同时穿过分值正则与键名清单。"""
for payload in (
{"phone": "138 0013 8000"},
{"phone": "138-0013-8000"},
{"mobile": "13800138000"},
):
text = _serialize_request_data(payload) or ""
assert "13800138000" not in text.replace(" ", "").replace("-", "")
assert "***" 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
+268
View File
@@ -0,0 +1,268 @@
"""操作日志审计服务单元测试。
守的是 `audit()` 上下文管理器的语义:
- 正常退出记成功、抛异常记失败,**失败记录必须先生成再让异常继续传播**
- 失败路径必须先 `rollback` 再写日志 —— 业务异常若来自 IntegrityError 等
数据库错误,不回滚时 session 处于待回滚状态,写日志会抛 PendingRollbackError,
最该留下的失败记录反而被静默丢掉
- 操作人信息在进入上下文时**固化为标量** —— 失败路径 rollback 会过期 ORM
对象,再读属性会触发额外查询,账号并发被删时甚至 ObjectDeletedError
令失败审计静默丢失
- 审计自身的任何失败都不得外抛("审计可丢,业务不可断")
"""
import logging
from datetime import datetime
from types import SimpleNamespace
import pytest
from app.core.exceptions import BadRequestError
from app.models import OperationLog
from app.services.audit_service import AuditRecord, RequestMeta, audit
class _StubSession:
"""最小会话桩:记录调用顺序与写入的日志对象,可注入失败。"""
def __init__(self) -> None:
self.events: list[str] = []
self.logs: list[OperationLog] = []
self.fail_add = False
self.fail_commit = False
# audit 入口会巡检未提交变更(嵌套调用防护)
self.new: list = []
self.dirty: list = []
self.deleted: list = []
def add(self, obj: object) -> None:
if self.fail_add:
raise RuntimeError("模拟写入失败")
self.events.append("add")
self.logs.append(obj) # type: ignore[arg-type]
def flush(self) -> None:
self.events.append("flush")
def commit(self) -> None:
if self.fail_commit:
raise RuntimeError("模拟提交失败")
self.events.append("commit")
def rollback(self) -> None:
self.events.append("rollback")
class _FrozenOperator:
"""只允许读取固定轮数属性的操作人桩。
模拟「rollback 后 ORM 对象过期」:若失败路径仍读取 operator 属性,
真实环境会触发额外 SELECT(且账号并发被删时抛 ObjectDeletedError),
此处直接以异常暴露该访问。audit 入口读 id + username 恰为 2 次。
"""
def __init__(self, max_reads: int = 2) -> None:
self._max_reads = max_reads
self._reads = 0
def _touch(self) -> None:
self._reads += 1
if self._reads > self._max_reads:
raise RuntimeError("进入审计后不应再访问操作人 ORM 属性")
@property
def id(self) -> int:
self._touch()
return 7
@property
def username(self) -> str:
self._touch()
return "admin"
def _operator() -> SimpleNamespace:
"""模拟已认证用户(audit 只读取 id 与 username 两个属性)。"""
return SimpleNamespace(id=7, username="admin")
def _record(**overrides: object) -> AuditRecord:
base: dict = {"module": "用户管理", "action": "新增", "summary": "新增用户 zhangsan"}
base.update(overrides)
return AuditRecord(**base)
def _audit(
stub: _StubSession,
*,
request_data: dict | None = None,
meta: RequestMeta | None = None,
):
return audit(
stub, # type: ignore[arg-type]
operator=_operator(),
record=_record(request_data=request_data),
meta=meta,
)
class TestSuccessPath:
"""正常退出:业务完成后记成功并独立提交。"""
def test_成功路径写入一条成功日志(self) -> None:
stub = _StubSession()
with _audit(stub):
pass
assert len(stub.logs) == 1
log = stub.logs[0]
assert log.success == 1
assert log.error_message is None
assert log.module == "用户管理"
assert log.action == "新增"
assert log.username == "admin"
assert log.user_id == 7
def test_日志提交发生在业务逻辑之后(self) -> None:
"""审计是业务的"后置动作":业务先 commit,日志再独立 commit。"""
stub = _StubSession()
with _audit(stub):
stub.events.append("business")
assert stub.events == ["business", "add", "flush", "commit"]
def test_请求元信息与耗时被写入(self) -> None:
stub = _StubSession()
meta = RequestMeta(
ip="10.0.0.8", user_agent="pytest-agent", method="POST", path="/api/v1/system/users"
)
with _audit(stub, meta=meta):
pass
log = stub.logs[0]
assert log.method == "POST"
assert log.path == "/api/v1/system/users"
assert log.ip == "10.0.0.8"
assert log.user_agent == "pytest-agent"
assert log.duration_ms is not None
assert log.duration_ms >= 0
def test_请求参数经脱敏后写入(self) -> None:
stub = _StubSession()
with _audit(stub, request_data={"username": "zhangsan", "password": "Secret@2026"}):
pass
request_data = stub.logs[0].request_data or ""
assert "Secret@2026" not in request_data
assert "zhangsan" in request_data
class TestFailurePath:
"""业务异常:先回滚、记失败、异常原样重抛。"""
def test_失败时原异常原样重抛(self) -> None:
stub = _StubSession()
with pytest.raises(BadRequestError, match="角色编码无效"), _audit(stub):
raise BadRequestError("角色编码无效")
def test_失败记录在回滚之后写入(self) -> None:
"""顺序断言:rollback 必须早于 add。颠倒时,来自数据库异常的失败
记录会因 session 待回滚而抛 PendingRollbackError,整条丢失。"""
stub = _StubSession()
with pytest.raises(BadRequestError), _audit(stub):
stub.events.append("business")
raise BadRequestError("用户名重复")
assert stub.events == ["business", "rollback", "add", "flush", "commit"]
log = stub.logs[0]
assert log.success == 0
assert log.error_message == "用户名重复"
def test_未预期异常不泄漏内部信息(self) -> None:
"""未预期异常的堆栈由全局处理器记入应用日志;审计表只落通用文案。"""
stub = _StubSession()
with pytest.raises(ValueError, match="内部实现细节"), _audit(stub):
raise ValueError("内部实现细节")
assert stub.logs[0].error_message == "服务端异常,请稍后重试"
class TestOperatorFrozen:
"""操作人标量在进入上下文时固化,之后(尤其 rollback 后)不得再触碰 ORM 对象。"""
def test_成功路径只读取一轮操作人属性(self) -> None:
stub = _StubSession()
with audit(stub, operator=_FrozenOperator(), record=_record()): # type: ignore[arg-type]
pass
assert stub.logs[0].username == "admin"
def test_失败路径不再触碰操作人对象(self) -> None:
"""回归:此前失败路径在 rollback 之后读 operator.id/username ——
真实环境会触发额外 SELECT,账号被并发删除时 ObjectDeletedError
还会被兜底吞掉,最该留痕的失败审计静默丢失。"""
stub = _StubSession()
with (
pytest.raises(BadRequestError, match="用户不存在"),
audit(
stub, # type: ignore[arg-type]
operator=_FrozenOperator(),
record=_record(action="删除", summary="删除用户 x"),
),
):
raise BadRequestError("用户不存在")
assert stub.logs[0].user_id == 7
assert stub.logs[0].username == "admin"
class TestDirtySessionGuard:
"""进入审计时存在未提交变更 → 记 warning(防嵌套调用造成半提交)。"""
def test_脏会话触发警告(self, caplog: pytest.LogCaptureFixture) -> None:
stub = _StubSession()
stub.dirty.append(object())
with caplog.at_level(logging.WARNING), _audit(stub):
pass
assert any("未提交的变更" in message for message in caplog.messages)
def test_干净会话不告警(self, caplog: pytest.LogCaptureFixture) -> None:
stub = _StubSession()
with caplog.at_level(logging.WARNING), _audit(stub):
pass
assert not caplog.messages
class TestResilience:
"""审计失败不得影响业务。"""
def test_写入失败不外抛且业务继续(self) -> None:
stub = _StubSession()
stub.fail_add = True
reached = False
with _audit(stub):
reached = True
assert reached is True # 审计写入失败不打断业务流
def test_提交失败不外抛(self) -> None:
stub = _StubSession()
stub.fail_commit = True
with _audit(stub):
pass # 正常退出,不抛异常
def test_业务失败时审计失败不以审计异常替换原异常(self) -> None:
"""业务异常是用户看到的原因,不能因审计自身失败而被替换。"""
stub = _StubSession()
stub.fail_add = True
with pytest.raises(BadRequestError, match="业务错误"), _audit(stub):
raise BadRequestError("业务错误")
class TestSerializationEdge:
"""审计参数的序列化边界(细节覆盖见 test_audit_serialization)。"""
def test_请求参数可为None(self) -> None:
stub = _StubSession()
with _audit(stub, request_data=None):
pass
assert stub.logs[0].request_data is None
def test_不可JSON序列化的值被降级为字符串(self) -> None:
"""如 datetime 等对象经 default=str 降级,序列化不应抛异常。"""
stub = _StubSession()
with _audit(stub, request_data={"at": datetime(2026, 10, 10, 12, 0, 0)}):
pass
assert "2026-10-10" in (stub.logs[0].request_data or "")
+131
View File
@@ -0,0 +1,131 @@
"""公式纯函数测试。
守的是:算钱公式的精确性与边界 —— 一旦按 float 或字面量截断计算,
360/361 天分界、4/360 斜率、27 天回款示例会全部错位,直接影响工资金额。
断言一律用 Fraction/Decimal 精确值,禁近似比较。
"""
from datetime import date
from decimal import Decimal
from fractions import Fraction
from app.services.formula import (
apply_barter,
converted_profit,
payment_days,
payment_weight,
performance_amount,
profit_rate,
salesperson_rate,
start_payment_ok,
)
# 与迁移种子一致的业务员参数(存储口径:start/floor 为 ‰ 数,slope 为 ‰/天)
SP_START = Fraction(10)
SP_SLOPE = Fraction(4, 360)
SP_FLOOR = Fraction(4)
SP_BOUNDARY = 360
def _sp_rate(days: int) -> Fraction:
return salesperson_rate(
days, start=SP_START, slope=SP_SLOPE, floor=SP_FLOOR, boundary_days=SP_BOUNDARY
)
class TestPaymentDays:
"""回款天数:发货日首日计第 1 天(对照表 #1;需规「26 天」示例作废)。"""
def test_发货日当天回款为第1天(self) -> None:
d = date(2026, 1, 1)
assert payment_days(d, d) == 1
def test_12月15日发货1月10日回款为27天(self) -> None:
assert payment_days(date(2025, 12, 15), date(2026, 1, 10)) == 27
def test_跨年按自然日连续累计(self) -> None:
assert payment_days(date(2025, 12, 31), date(2026, 1, 1)) == 2
class TestSalespersonRate:
"""业务员计提比例:1~360 天线性递减、≥361 天固定下限(对照表 #4)。"""
def test_第360天恰为千分之6_线性段终点(self) -> None:
assert _sp_rate(360) == Fraction(6, 1000)
def test_第361天固定下限千分之4_分界线(self) -> None:
# 公式外推为 5.989‰,业务确认「360 为分界线,360 以后按千分之四」
assert _sp_rate(361) == Fraction(4, 1000)
def test_第1天按公式为899比90000_非取整为10(self) -> None:
# 需规图示「第 1 天对应 10‰」为上限参考;公式值 = (10 − 4/360)/1000 = 899/90000
assert _sp_rate(1) == Fraction(899, 90000)
def test_第180天线性段中点千分之8(self) -> None:
assert _sp_rate(180) == Fraction(8, 1000)
def test_返回值为纯比例_非千分数(self) -> None:
# 绩效 = 折算额 × 比例,比例必须是 0.006 而非 6
assert _sp_rate(360) == Fraction(6, 1000)
assert _sp_rate(360) != Fraction(6)
class TestProfitRate:
"""毛利润率:不含运费口径(对照表 #2),毛利润额 / 销售额。"""
def test_毛利润率_不含运费(self) -> None:
assert profit_rate(Decimal("45"), Decimal("100")) == Fraction(9, 20)
def test_销售额为零_返回零_防除零(self) -> None:
assert profit_rate(Decimal("0"), Decimal("0")) == Fraction(0)
class TestConvertedProfit:
"""折算毛利润额(化简式):销售额 ×(毛利润率 − 阈值)(对照表 #12/#13)。"""
def test_化简式结果_市政水利30点阈值(self) -> None:
# 100 × (9/20 − 30/100) = 15
assert converted_profit(Decimal("100"), Fraction(9, 20), Fraction(30)) == Decimal("15")
def test_化简式与定义式等价(self) -> None:
# 定义式:毛利润额 / 毛利润率 × (毛利润率 − 阈值) = 45 / (9/20) × (9/20 − 3/10) = 15
revenue, profit = Decimal("100"), Decimal("45")
rate = profit_rate(profit, revenue)
assert converted_profit(revenue, rate, Fraction(30)) == Decimal("15")
def test_毛利润额为零_折算为零_不除零(self) -> None:
assert converted_profit(Decimal("0"), Fraction(0), Fraction(30)) == Decimal("0")
def test_毛利润率低于阈值_折算为负_不归零(self) -> None:
# 100 × (1/10 − 3/10) = -20(业务确认负值正常计算)
assert converted_profit(Decimal("100"), Fraction(1, 10), Fraction(30)) == Decimal("-20")
class TestPerformanceAmount:
"""绩效 = 折算毛利润额 × 计提比例(乘法方向,对照表 #3)。"""
def test_乘法方向_折算额乘比例(self) -> None:
# 15000 × 97/10000 = 145.5
assert performance_amount(Decimal("15000"), Fraction(97, 10000)) == Decimal("145.5")
def test_负折算额_绩效为负不归零(self) -> None:
assert performance_amount(Decimal("-20"), Fraction(6, 1000)) == Decimal("-0.12")
class TestModifiers:
"""修饰器:以物抵顶、支付权重 R×R、起付判定(对照表 #21/#22/#24)。"""
def test_以物抵顶_按80点折算(self) -> None:
assert apply_barter(Decimal("145.5"), Fraction(80)) == Decimal("116.4")
def test_支付权重_R为60时0点36(self) -> None:
assert payment_weight(60) == Fraction(36, 100)
def test_支付权重_R为100时全额(self) -> None:
assert payment_weight(100) == Fraction(1)
def test_起付判定_达到60点(self) -> None:
assert start_payment_ok(Decimal("60000"), Decimal("100000"), Fraction(60)) is True
def test_起付判定_未达到60点(self) -> None:
assert start_payment_ok(Decimal("59999"), Decimal("100000"), Fraction(60)) is False
+92
View File
@@ -0,0 +1,92 @@
"""日志查询模型单元测试。
守的是**时间范围校验**与**时区归一**:起始晚于结束时必须拒绝 —— 放过去
的话 SQL 条件互斥、查询恒为空,使用者会以为是"没有数据"而不是"条件写反了";
带时区的输入必须转换为本地 naive 时间 —— 不转换时 aware/naive 混用比较
抛 TypeError(穿透成 500),或偏移被驱动静默丢弃(查询窗口偏 8 小时)。
"""
from datetime import UTC, datetime
import pytest
from pydantic import ValidationError
from app.schemas.log import LoginLogQuery, OperationLogQuery
class TestTimeRange:
"""start_time ≤ end_time 校验(两类日志共用基类)。"""
def test_合法范围通过(self) -> None:
query = OperationLogQuery(
start_time=datetime(2026, 10, 1, 0, 0, 0), end_time=datetime(2026, 10, 10, 0, 0, 0)
)
assert query.start_time is not None
def test_起始晚于结束被拒(self) -> None:
with pytest.raises(ValidationError):
OperationLogQuery(start_time=datetime(2026, 10, 2), end_time=datetime(2026, 10, 1))
def test_登录日志同样校验(self) -> None:
with pytest.raises(ValidationError):
LoginLogQuery(start_time=datetime(2026, 10, 2), end_time=datetime(2026, 10, 1))
def test_边界相等通过(self) -> None:
moment = datetime(2026, 10, 1, 12, 0, 0)
assert OperationLogQuery(start_time=moment, end_time=moment).start_time == moment
def test_仅传一端不校验(self) -> None:
assert OperationLogQuery(start_time=datetime(2026, 10, 1)).end_time is None
assert OperationLogQuery(end_time=datetime(2026, 10, 1)).start_time is None
class TestQueryFields:
"""筛选字段边界。"""
def test_成功标记只能为0或1(self) -> None:
assert OperationLogQuery(success=0).success == 0
with pytest.raises(ValidationError):
OperationLogQuery(success=2)
def test_分页别名与偏移(self) -> None:
query = OperationLogQuery(pageNum=2, pageSize=50)
assert query.offset == 50
def test_操作日志筛选字段(self) -> None:
query = OperationLogQuery(module="用户管理", action="删除", username="adm")
assert (query.module, query.action, query.username) == ("用户管理", "删除", "adm")
def test_登录日志按IP精确筛选(self) -> None:
assert LoginLogQuery(ip="10.0.0.1").ip == "10.0.0.1"
class TestTimezoneNormalization:
"""时区归一。
回归背景:混合 aware/naive 比较曾抛 TypeError 穿透成 500;aware 直传
曾被驱动静默丢弃偏移(查询窗口整体偏 8 小时且无提示)。
"""
def test_utc后缀被转换为本地时间(self) -> None:
"""Z 后缀 = UTC 零点 = 东八区本地 08:00(与库列的本地时间口径对齐)。"""
query = OperationLogQuery(start_time="2026-10-01T00:00:00Z")
assert query.start_time == datetime(2026, 10, 1, 8, 0, 0)
def test_混合aware与naive不再抛比较错误(self) -> None:
query = OperationLogQuery(
start_time=datetime(2026, 10, 1, 0, 0, 0),
end_time=datetime(2026, 10, 2, 0, 0, 0, tzinfo=UTC),
)
assert query.end_time is not None
assert query.end_time.tzinfo is None # 已摘除
def test_归一后范围校验仍按本地口径生效(self) -> None:
with pytest.raises(ValidationError):
OperationLogQuery(
start_time="2026-10-02T00:00:00Z", # 本地 10-02 08:00
end_time="2026-10-01 12:00:00", # 本地 10-01 12:00,早于起始
)
def test_naive时间视为本地原样保留(self) -> None:
moment = datetime(2026, 10, 1, 12, 0, 0)
assert OperationLogQuery(start_time=moment).start_time == moment
+14 -2
View File
@@ -11,7 +11,19 @@ from sqlalchemy.dialects import mysql
from app.models import Base from app.models import Base
EXPECTED_TABLES = {"users", "roles", "user_roles", "permissions", "role_permissions", "login_logs"} EXPECTED_TABLES = {
"users",
"roles",
"user_roles",
"permissions",
"role_permissions",
"login_logs",
"operation_logs",
"business_roles",
"param_definitions",
"param_change_batches",
"param_values",
}
def _ddl(table_name: str) -> str: def _ddl(table_name: str) -> str:
@@ -101,7 +113,7 @@ class TestTimestampColumns:
def test_带时间戳的表含ON_UPDATE子句(self, table_name: str) -> None: def test_带时间戳的表含ON_UPDATE子句(self, table_name: str) -> None:
assert "ON UPDATE CURRENT_TIMESTAMP" in _ddl(table_name) assert "ON UPDATE CURRENT_TIMESTAMP" in _ddl(table_name)
@pytest.mark.parametrize("table_name", ["login_logs"]) @pytest.mark.parametrize("table_name", ["login_logs", "operation_logs"])
def test_仅追加的日志表不含ON_UPDATE子句(self, table_name: str) -> None: def test_仅追加的日志表不含ON_UPDATE子句(self, table_name: str) -> None:
"""日志是不可变记录,没有"更新时间"语义。""" """日志是不可变记录,没有"更新时间"语义。"""
assert "ON UPDATE CURRENT_TIMESTAMP" not in _ddl(table_name) assert "ON UPDATE CURRENT_TIMESTAMP" not in _ddl(table_name)
+154
View File
@@ -0,0 +1,154 @@
"""参数注册表与公式模板注册表测试。
守的是:注册表是参数种子数据与运行期校验的唯一来源 —— 一旦编码或默认值
与迁移种子漂移,参数解析会取到错误口径;一旦取值校验失守,
畸形值(如 "1/0")会落库并在核算时崩溃或算错钱。
"""
from fractions import Fraction
import pytest
from app.core.exceptions import BadRequestError
from app.services.calc_templates import TEMPLATES, get_template, required_params
from app.services.param_registry import (
PARAM_REGISTRY,
get_spec,
parse_fraction,
validate_and_normalize,
)
# 与 db/migration/V3 迁移种子保持一致的 16 项编码
EXPECTED_CODES = {
"salesperson.rate.start",
"salesperson.rate.slope",
"salesperson.rate.floor",
"salesperson.days.boundary",
"manager.share.team",
"manager.share.company",
"enterprise.rate",
"info.rate",
"prepay.rate",
"barter.discount",
"payment.start.ratio",
"payment.weight.mode",
"product.threshold",
"salary.basic.mode",
"probation.annual.threshold",
"probation.salary.basic",
}
class TestRegistry:
"""注册表元数据完整性(与迁移种子同源)。"""
def test_注册表共16项(self) -> None:
assert len(PARAM_REGISTRY) == 16
def test_编码集合与迁移种子一致(self) -> None:
assert set(PARAM_REGISTRY) == EXPECTED_CODES
def test_每项必填字段非空(self) -> None:
for spec in PARAM_REGISTRY.values():
assert spec.code, "code 为空"
assert spec.name, f"{spec.code} 缺中文名称"
assert spec.group, f"{spec.code} 缺分组"
assert spec.value_type in {"DECIMAL", "INT", "BOOL", "ENUM", "TEXT"}
def test_默认值可按类型解析(self) -> None:
for spec in PARAM_REGISTRY.values():
if spec.default_value is None:
continue
if spec.value_type in {"DECIMAL", "INT"}:
parse_fraction(spec.default_value) # 不可解析会抛 BadRequestError
elif spec.value_type == "ENUM":
assert spec.options, f"{spec.code} 为 ENUM 但无选项"
assert spec.default_value in spec.options
def test_取值域上下限可解析(self) -> None:
for spec in PARAM_REGISTRY.values():
if spec.value_min is not None:
parse_fraction(spec.value_min)
if spec.value_max is not None:
parse_fraction(spec.value_max)
def test_取未注册编码抛ValueError_属代码缺陷(self) -> None:
with pytest.raises(ValueError):
get_spec("not.exist.code")
class TestParseFraction:
"""Fraction 精确解析:4/360 不许经 float 中转。"""
def test_分数式精确解析_4除以360(self) -> None:
assert parse_fraction("4/360") == Fraction(1, 90)
def test_整数解析(self) -> None:
assert parse_fraction("10") == Fraction(10)
def test_小数解析(self) -> None:
assert parse_fraction("12.5") == Fraction(25, 2)
def test_除零分数拒绝(self) -> None:
with pytest.raises(BadRequestError):
parse_fraction("1/0")
def test_非数字拒绝(self) -> None:
with pytest.raises(BadRequestError):
parse_fraction("abc")
class TestValidateAndNormalize:
"""参数值校验:类型/取值域/枚举(审查重点 1:畸形值不得落库)。"""
def test_去除首尾空白后返回规范值(self) -> None:
spec = PARAM_REGISTRY["barter.discount"]
assert validate_and_normalize(spec, " 80 ") == "80"
def test_超取值域上限拒绝(self) -> None:
spec = PARAM_REGISTRY["barter.discount"] # value_max=100
with pytest.raises(BadRequestError):
validate_and_normalize(spec, "120")
def test_低于取值域下限拒绝(self) -> None:
spec = PARAM_REGISTRY["barter.discount"] # value_min=0
with pytest.raises(BadRequestError):
validate_and_normalize(spec, "-1")
def test_枚举越界拒绝(self) -> None:
spec = PARAM_REGISTRY["payment.weight.mode"]
with pytest.raises(BadRequestError):
validate_and_normalize(spec, "PARTIAL")
def test_枚举合法值通过(self) -> None:
spec = PARAM_REGISTRY["payment.weight.mode"]
assert validate_and_normalize(spec, "BALANCE") == "BALANCE"
def test_整数类型拒绝小数(self) -> None:
spec = PARAM_REGISTRY["salesperson.days.boundary"]
with pytest.raises(BadRequestError):
validate_and_normalize(spec, "360.5")
def test_分数式斜率原样保留不截断(self) -> None:
spec = PARAM_REGISTRY["salesperson.rate.slope"]
assert validate_and_normalize(spec, "4/360") == "4/360"
class TestTemplates:
"""公式模板注册表:有限集合,声明参数必须真实存在。"""
def test_共五个模板(self) -> None:
assert len(TEMPLATES) == 5
def test_模板声明参数均在注册表内(self) -> None:
for tpl in TEMPLATES.values():
for code in tpl.params:
assert code in PARAM_REGISTRY, f"{tpl.code} 声明了未注册参数 {code}"
def test_取模板声明参数(self) -> None:
params = required_params("TPL_SALESPERSON_DECAY")
assert "salesperson.rate.slope" in params
def test_未知模板抛ValueError_属代码缺陷(self) -> None:
with pytest.raises(ValueError):
get_template("TPL_NOT_EXIST")
+95
View File
@@ -0,0 +1,95 @@
"""参数快照(时点化取值)测试。
守的是:覆盖链与兜底 —— 一旦取错维度(如企业户取到市政阈值行)
或漏兜底,核算会用错口径或直接崩溃;同日多次变更必须取最新留痕。
仓储 SQL 的排序语义(同日取 id 最大)在集成测试中覆盖。
"""
from dataclasses import FrozenInstanceError
from datetime import date
from fractions import Fraction
import pytest
from app.services.param_service import ParamSnapshot
def _snap(rows: list[tuple[str, str, str]]) -> ParamSnapshot:
"""构造快照。rows 须模拟仓储输出:同一 (code, dim) 的最新值在前。"""
# 仓储实际返回 4 元组(含生效日期);单测关注覆盖链,统一补同一个日期
return ParamSnapshot.from_rows(
date(2026, 10, 1), [(code, dim, value, date(2026, 1, 1)) for code, dim, value in rows]
)
class TestCoverChain:
"""覆盖链:角色维度 > 产品类别维度 > 默认行('')。"""
def test_产品类别维度优先于默认行(self) -> None:
snap = _snap([("enterprise.rate", "", "3"), ("enterprise.rate", "municipal_water", "2")])
assert snap.get("enterprise.rate", product_category="municipal_water") == "2"
def test_无匹配维度时落默认行(self) -> None:
snap = _snap([("enterprise.rate", "", "3"), ("enterprise.rate", "municipal_water", "2")])
assert snap.get("enterprise.rate", product_category="rail_transit") == "3"
def test_角色维度优先于产品类别维度(self) -> None:
snap = _snap([("some.param", "municipal_water", "A"), ("some.param", "salesperson", "B")])
got = snap.get("some.param", role_code="salesperson", product_category="municipal_water")
assert got == "B"
def test_无任何维度匹配且无默认行_回退注册表默认值(self) -> None:
snap = _snap([("enterprise.rate", "municipal_water", "2")])
# 轨交无行、无默认行 → 回退注册表 default_value(3)
assert snap.fraction("enterprise.rate", product_category="rail_transit") == Fraction(3)
class TestFallback:
"""注册表默认值兜底(种子缺失时的安全网)。"""
def test_未定义参数按注册表默认值兜底(self) -> None:
snap = _snap([])
assert snap.fraction("prepay.rate") == Fraction(10)
def test_未注册编码抛ValueError_属代码缺陷(self) -> None:
snap = _snap([])
with pytest.raises(ValueError):
snap.fraction("not.registered.code")
class TestSameDayChanges:
"""同日多次变更:仓储 SQL 已按 (effective_date, id) 倒序,快照取先出现者。"""
def test_同日多次变更取最新一条(self) -> None:
snap = _snap([("enterprise.rate", "", "3"), ("enterprise.rate", "", "2")])
assert snap.fraction("enterprise.rate") == Fraction(3)
class TestTypedGetters:
"""类型化取值:Fraction 精确解析、整数、布尔、文本。"""
def test_fraction精确解析分数式(self) -> None:
snap = _snap([("salesperson.rate.slope", "salesperson", "4/360")])
assert snap.fraction("salesperson.rate.slope", role_code="salesperson") == Fraction(1, 90)
def test_integer(self) -> None:
snap = _snap([("salesperson.days.boundary", "salesperson", "360")])
assert snap.integer("salesperson.days.boundary", role_code="salesperson") == 360
def test_text返回枚举原文(self) -> None:
snap = _snap([("payment.weight.mode", "", "FULL")])
assert snap.text("payment.weight.mode") == "FULL"
def test_boolean(self) -> None:
snap = _snap([("some.flag", "", "1")])
assert snap.boolean("some.flag") is True
assert snap.boolean("some.flag") is not False
class TestImmutability:
"""快照冻结:解析结果不得被调用方改写(核算期间一致性)。"""
def test_字段不可赋值(self) -> None:
snap = _snap([])
with pytest.raises(FrozenInstanceError):
snap.biz_date = date(2026, 1, 1) # type: ignore[misc]
+98
View File
@@ -0,0 +1,98 @@
"""角色请求模型单元测试。
守的是**角色编码的格式红线**:编码会进入授权判断(`ROLE_ADMIN` 按编码
比对)与操作日志,必须限定为小写字母开头的小写/数字/下划线组合 ——
放开大写或中文会让对照与检索困难,超长则与列宽(32)不符。
"""
import pytest
from pydantic import ValidationError
from app.schemas.role import RoleCreateIn, RoleQuery, RoleStatusIn, RoleUpdateIn
def _create(**overrides: object) -> RoleCreateIn:
base: dict[str, object] = {"role_code": "finance", "role_name": "财务"}
base.update(overrides)
return RoleCreateIn(**base)
class TestRoleCodePattern:
"""编码规则:^[a-z][a-z0-9_]{1,31}$"""
def test_合法编码通过(self) -> None:
assert _create().role_code == "finance"
def test_下划线与数字通过(self) -> None:
assert _create(role_code="sales_01").role_code == "sales_01"
def test_大写字母被拒(self) -> None:
with pytest.raises(ValidationError):
_create(role_code="Finance")
def test_中文被拒(self) -> None:
with pytest.raises(ValidationError):
_create(role_code="财务")
def test_数字开头被拒(self) -> None:
with pytest.raises(ValidationError):
_create(role_code="1abc")
def test_下划线开头被拒(self) -> None:
with pytest.raises(ValidationError):
_create(role_code="_abc")
def test_单字符被拒(self) -> None:
with pytest.raises(ValidationError):
_create(role_code="a")
def test_超长被拒(self) -> None:
with pytest.raises(ValidationError):
_create(role_code="a" * 33)
def test_首尾空白被自动去除(self) -> None:
assert _create(role_code=" finance ").role_code == "finance"
class TestRoleUpdate:
"""编辑模型:不含 role_code。"""
def test_编辑不接受role_code(self) -> None:
"""编码是授权判定锚点,编辑接口有意不接收;多余字段被忽略而非报错
(前端表单可能整份回填提交)。"""
payload = RoleUpdateIn(role_name="财务二组", role_code="hacked")
assert not hasattr(payload, "role_code")
def test_名称为空被拒(self) -> None:
with pytest.raises(ValidationError):
RoleUpdateIn(role_name="")
def test_排序负数被拒(self) -> None:
with pytest.raises(ValidationError):
RoleUpdateIn(role_name="财务", sort_order=-1)
class TestRoleStatusAndQuery:
"""状态与查询模型。"""
def test_状态只能为0或1(self) -> None:
assert RoleStatusIn(status=0).status == 0
assert RoleStatusIn(status=1).status == 1
with pytest.raises(ValidationError):
RoleStatusIn(status=2)
def test_查询默认分页(self) -> None:
query = RoleQuery()
assert query.page_num == 1
assert query.page_size == 10
assert query.keyword is None
assert query.status is None
def test_分页别名照常工作(self) -> None:
"""pageNum/pageSize 是前端契约(PageQuery 的 alias)。"""
query = RoleQuery(pageNum=3, pageSize=5)
assert query.offset == 10
def test_关键词超长被拒(self) -> None:
with pytest.raises(ValidationError):
RoleQuery(keyword="a" * 65)
+23
View File
@@ -221,6 +221,15 @@ wheels = [
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/f6/b6/a1faf3a27ae9405fb34b1713cc73b2d8a26b04d5c561578fa2e6ef3e5bb9/cryptography-50.0.2-cp39-abi3-win_amd64.whl", hash = "sha256:4e81d95e5bafc2d6e34e4bed780e53e4d5b9a2f928573428aa4d35fbec1eb0de", size = 3854095, upload-time = "2026-09-30T15:29:46.782Z" }, { url = "https://pypi.tuna.tsinghua.edu.cn/packages/f6/b6/a1faf3a27ae9405fb34b1713cc73b2d8a26b04d5c561578fa2e6ef3e5bb9/cryptography-50.0.2-cp39-abi3-win_amd64.whl", hash = "sha256:4e81d95e5bafc2d6e34e4bed780e53e4d5b9a2f928573428aa4d35fbec1eb0de", size = 3854095, upload-time = "2026-09-30T15:29:46.782Z" },
] ]
[[package]]
name = "et-xmlfile"
version = "2.0.0"
source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" }
sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/d3/38/af70d7ab1ae9d4da450eeec1fa3918940a5fafb9055e934af8d6eb0c2313/et_xmlfile-2.0.0.tar.gz", hash = "sha256:dab3f4764309081ce75662649be815c4c9081e88f0837825f90fd28317d4da54", size = 17234, upload-time = "2024-10-25T17:25:40.039Z" }
wheels = [
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/c1/8b/5fe2cc11fee489817272089c4203e679c63b570a5aaeb18d852ae3cbba6a/et_xmlfile-2.0.0-py3-none-any.whl", hash = "sha256:7a91720bc756843502c3b7504c77b8fe44217c85c537d85037f0f536151b2caa", size = 18059, upload-time = "2024-10-25T17:25:39.051Z" },
]
[[package]] [[package]]
name = "fakeredis" name = "fakeredis"
version = "2.39.0" version = "2.39.0"
@@ -378,6 +387,18 @@ wheels = [
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/c5/8e/4c24208776a65878d656996945aacfbfe010d3720d1a98fc0eb8491fc03b/markupsafe-3.0.4-cp312-cp312-win_arm64.whl", hash = "sha256:a4bbd2d87dd233b9fc5812160c3d0ffbe42edc22a26ce0469f58479ede633fe9", size = 14174, upload-time = "2026-10-02T23:05:07.617Z" }, { url = "https://pypi.tuna.tsinghua.edu.cn/packages/c5/8e/4c24208776a65878d656996945aacfbfe010d3720d1a98fc0eb8491fc03b/markupsafe-3.0.4-cp312-cp312-win_arm64.whl", hash = "sha256:a4bbd2d87dd233b9fc5812160c3d0ffbe42edc22a26ce0469f58479ede633fe9", size = 14174, upload-time = "2026-10-02T23:05:07.617Z" },
] ]
[[package]]
name = "openpyxl"
version = "3.1.5"
source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" }
dependencies = [
{ name = "et-xmlfile" },
]
sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/3d/f9/88d94a75de065ea32619465d2f77b29a0469500e99012523b91cc4141cd1/openpyxl-3.1.5.tar.gz", hash = "sha256:cf0e3cf56142039133628b5acffe8ef0c12bc902d2aadd3e0fe5878dc08d1050", size = 186464, upload-time = "2024-06-28T14:03:44.161Z" }
wheels = [
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/c0/da/977ded879c29cbd04de313843e76868e6e13408a94ed6b987245dc7c8506/openpyxl-3.1.5-py2.py3-none-any.whl", hash = "sha256:5282c12b107bffeef825f4617dc029afaf41d0ea60823bbb665ef3079dc79de2", size = 250910, upload-time = "2024-06-28T14:03:41.161Z" },
]
[[package]] [[package]]
name = "opentelemetry-api" name = "opentelemetry-api"
version = "1.45.1" version = "1.45.1"
@@ -407,6 +428,7 @@ dependencies = [
{ name = "alembic" }, { name = "alembic" },
{ name = "captcha" }, { name = "captcha" },
{ name = "fastapi" }, { name = "fastapi" },
{ name = "openpyxl" },
{ name = "pillow" }, { name = "pillow" },
{ name = "pwdlib", extra = ["bcrypt"] }, { name = "pwdlib", extra = ["bcrypt"] },
{ name = "pydantic" }, { name = "pydantic" },
@@ -433,6 +455,7 @@ requires-dist = [
{ name = "alembic", specifier = ">=1.14" }, { name = "alembic", specifier = ">=1.14" },
{ name = "captcha", specifier = ">=0.6" }, { name = "captcha", specifier = ">=0.6" },
{ name = "fastapi", specifier = ">=0.115" }, { name = "fastapi", specifier = ">=0.115" },
{ name = "openpyxl", specifier = ">=3.1.5" },
{ name = "pillow", specifier = ">=11.0" }, { name = "pillow", specifier = ">=11.0" },
{ name = "pwdlib", extras = ["bcrypt"], specifier = ">=0.2" }, { name = "pwdlib", extras = ["bcrypt"], specifier = ">=0.2" },
{ name = "pydantic", specifier = ">=2.9" }, { name = "pydantic", specifier = ">=2.9" },
+176 -5
View File
@@ -5,16 +5,22 @@
-- 数据卷已存在时修改本文件不会生效,需要: -- 数据卷已存在时修改本文件不会生效,需要:
-- docker compose -f docker/docker-compose.dev.yml down -v && up -d -- docker compose -f docker/docker-compose.dev.yml down -v && up -d
-- --
-- 内容 = V1 迁移的完整结构(额外带 DROP,开发环境允许重置) -- 内容 = db/migration/ 下**全部迁移**的最终结构(额外带 DROP,开发环境允许重置)
-- 与 db/migration/V1__init_rbac_and_login_logs.sql 必须保持一致, -- 必须与 db/migration/V*.sql 的合集保持一致,
-- 由 backend/tests/integration/test_schema_sync.py 自动校验,防止两份 DDL 漂移 -- 由 backend/tests/integration/test_schema_sync.py 自动校验,防止两份 DDL 漂移
-- --
-- 结尾写入 alembic_version 记录,使 `alembic upgrade head` 在本库上成为空操作 -- 结尾写入 alembic_version 记录(**须等于当前 head**,否则开发库重建后
-- 执行 alembic upgrade head 会重复建表并报 1050 Table already exists)
-- ============================================================================ -- ============================================================================
SET NAMES utf8mb4; SET NAMES utf8mb4;
-- 删除顺序遵循外键依赖:先删子表 -- 删除顺序遵循外键依赖:先删子表
DROP TABLE IF EXISTS `param_values`;
DROP TABLE IF EXISTS `param_change_batches`;
DROP TABLE IF EXISTS `param_definitions`;
DROP TABLE IF EXISTS `business_roles`;
DROP TABLE IF EXISTS `operation_logs`;
DROP TABLE IF EXISTS `role_permissions`; DROP TABLE IF EXISTS `role_permissions`;
DROP TABLE IF EXISTS `user_roles`; DROP TABLE IF EXISTS `user_roles`;
DROP TABLE IF EXISTS `login_logs`; DROP TABLE IF EXISTS `login_logs`;
@@ -134,6 +140,120 @@ CREATE TABLE user_roles (
CREATE INDEX idx_user_roles_role_id ON user_roles (role_id); CREATE INDEX idx_user_roles_role_id ON user_roles (role_id);
-- ---------------------------------------------------------------------------
-- 操作日志表(V2 迁移)
-- ---------------------------------------------------------------------------
CREATE TABLE operation_logs (
id BIGINT UNSIGNED NOT NULL COMMENT '主键ID' AUTO_INCREMENT,
user_id BIGINT UNSIGNED COMMENT '操作人ID(账号删除后置空)',
username VARCHAR(64) NOT NULL COMMENT '操作人用户名(冗余存储便于追溯)',
module VARCHAR(64) NOT NULL COMMENT '所属模块,如 用户管理',
action VARCHAR(32) NOT NULL COMMENT '操作类型:新增/修改/删除/导出',
summary VARCHAR(255) NOT NULL COMMENT '操作摘要(中文,供人阅读)',
method VARCHAR(8) COMMENT 'HTTP 方法',
path VARCHAR(255) COMMENT '请求路径',
ip VARCHAR(45) COMMENT '客户端IP(45位兼容IPv6)',
user_agent VARCHAR(255) COMMENT '客户端User-Agent(截断存储)',
request_data TEXT COMMENT '请求参数(已脱敏)',
success TINYINT NOT NULL COMMENT '是否成功:1成功 0失败' DEFAULT '0',
error_message VARCHAR(500) COMMENT '失败原因(写入方须截断)',
duration_ms INTEGER COMMENT '处理耗时(毫秒)',
create_time DATETIME NOT NULL COMMENT '创建时间' DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT pk_operation_logs PRIMARY KEY (id),
CONSTRAINT fk_operation_logs_user_id FOREIGN KEY(user_id) REFERENCES users (id) ON DELETE SET NULL
)CHARSET=utf8mb4 ENGINE=InnoDB COMMENT='操作日志表' COLLATE utf8mb4_general_ci;
CREATE INDEX idx_operation_logs_create_time ON operation_logs (create_time);
CREATE INDEX idx_operation_logs_module ON operation_logs (module);
CREATE INDEX idx_operation_logs_user_id ON operation_logs (user_id);
CREATE INDEX idx_operation_logs_username ON operation_logs (username);
-- ---------------------------------------------------------------------------
-- 业务岗位角色表(V3 迁移)
-- ---------------------------------------------------------------------------
CREATE TABLE business_roles (
id BIGINT UNSIGNED NOT NULL COMMENT '主键ID' AUTO_INCREMENT,
role_code VARCHAR(32) NOT NULL COMMENT '岗位编码:salesperson营销员 sales_manager营销经理 info_officer信息员,创建后不可修改',
role_name VARCHAR(64) NOT NULL COMMENT '岗位名称',
calc_template VARCHAR(32) NOT NULL COMMENT '计提公式模板编码(以代码内模板注册表为准)',
description VARCHAR(255) COMMENT '岗位说明',
sort_order INTEGER NOT NULL COMMENT '显示排序' DEFAULT '0',
status TINYINT NOT NULL COMMENT '状态:1启用 0禁用' DEFAULT '1',
is_builtin TINYINT NOT NULL COMMENT '是否内置岗位:1内置(禁删禁停) 0自定义' DEFAULT '0',
create_time DATETIME NOT NULL COMMENT '创建时间' DEFAULT CURRENT_TIMESTAMP,
update_time DATETIME NOT NULL COMMENT '更新时间' DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT pk_business_roles PRIMARY KEY (id),
CONSTRAINT uk_business_roles_role_code UNIQUE (role_code)
)CHARSET=utf8mb4 ENGINE=InnoDB COMMENT='业务岗位角色表(与登录角色roles区分,决定计提公式与参数)' COLLATE utf8mb4_general_ci;
-- ---------------------------------------------------------------------------
-- 计提参数定义表(V3 迁移)
-- ---------------------------------------------------------------------------
CREATE TABLE param_definitions (
id BIGINT UNSIGNED NOT NULL COMMENT '主键ID' AUTO_INCREMENT,
param_code VARCHAR(64) NOT NULL COMMENT '参数编码,如 salesperson.rate.slope',
param_name VARCHAR(64) NOT NULL COMMENT '参数名称',
param_group VARCHAR(32) NOT NULL COMMENT '参数分组:业务员个人户/经理分成/企业户/信息员/支付环节/产品阈值/月工资口径',
value_type VARCHAR(16) NOT NULL COMMENT '值类型:DECIMAL/INT/BOOL/ENUM/TEXT',
unit VARCHAR(16) COMMENT '展示单位:‰/%/元/天,无单位为空',
dimension_type VARCHAR(32) NOT NULL COMMENT '维度类型:NONE/PRODUCT_CATEGORY/BUSINESS_ROLE' DEFAULT 'NONE',
options_text VARCHAR(255) COMMENT '枚举选项(竖线分隔),仅 ENUM 使用',
default_value VARCHAR(64) COMMENT '系统默认值(Fraction 可解析字符串)',
value_min VARCHAR(64) COMMENT '取值下限(含),校验用',
value_max VARCHAR(64) COMMENT '取值上限(含),校验用',
description VARCHAR(255) COMMENT '口径说明(配置页展示)',
sort_order INTEGER NOT NULL COMMENT '显示排序' DEFAULT '0',
status TINYINT NOT NULL COMMENT '状态:1启用 0停用' DEFAULT '1',
create_time DATETIME NOT NULL COMMENT '创建时间' DEFAULT CURRENT_TIMESTAMP,
update_time DATETIME NOT NULL COMMENT '更新时间' DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT pk_param_definitions PRIMARY KEY (id),
CONSTRAINT uk_param_definitions_param_code UNIQUE (param_code)
)CHARSET=utf8mb4 ENGINE=InnoDB COMMENT='计提参数定义表' COLLATE utf8mb4_general_ci;
-- ---------------------------------------------------------------------------
-- 参数变更批次表(V3 迁移)
-- ---------------------------------------------------------------------------
CREATE TABLE param_change_batches (
id BIGINT UNSIGNED NOT NULL COMMENT '主键ID' AUTO_INCREMENT,
version_no VARCHAR(32) NOT NULL COMMENT '版本号,如 P20261012-01',
effective_date DATE NOT NULL COMMENT '生效日期(业务日期口径)',
change_type TINYINT NOT NULL COMMENT '生效方式:1立即生效 2指定日期生效',
remark VARCHAR(255) COMMENT '变更说明',
operator_id BIGINT UNSIGNED COMMENT '操作人ID(账号删除后置空)',
operator_name VARCHAR(64) NOT NULL COMMENT '操作人用户名(冗余固化)',
item_count INTEGER NOT NULL COMMENT '变更参数项数' DEFAULT '0',
create_time DATETIME NOT NULL COMMENT '创建时间' DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT pk_param_change_batches PRIMARY KEY (id),
CONSTRAINT fk_param_change_batches_operator_id FOREIGN KEY(operator_id) REFERENCES users (id) ON DELETE SET NULL,
CONSTRAINT uk_param_change_batches_version_no UNIQUE (version_no)
)CHARSET=utf8mb4 ENGINE=InnoDB COMMENT='参数变更批次表' COLLATE utf8mb4_general_ci;
-- ---------------------------------------------------------------------------
-- 参数版本条目表(V3 迁移,append-only)
-- ---------------------------------------------------------------------------
CREATE TABLE param_values (
id BIGINT UNSIGNED NOT NULL COMMENT '主键ID' AUTO_INCREMENT,
param_id BIGINT UNSIGNED NOT NULL COMMENT '参数定义ID(param_definitions.id)',
dimension_value VARCHAR(32) NOT NULL COMMENT '维度取值:NONE维度为空串;产品类别/岗位编码为具体值' DEFAULT '',
effective_date DATE NOT NULL COMMENT '生效日期(按业务日期时点化取值)',
value_text VARCHAR(64) NOT NULL COMMENT '参数值(Fraction 可解析字符串/枚举码/开关)',
old_value_text VARCHAR(64) COMMENT '变更前值(留痕)',
batch_id BIGINT UNSIGNED NOT NULL COMMENT '变更批次ID(param_change_batches.id)',
create_time DATETIME NOT NULL COMMENT '创建时间' DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT pk_param_values PRIMARY KEY (id),
CONSTRAINT fk_param_values_batch_id FOREIGN KEY(batch_id) REFERENCES param_change_batches (id) ON DELETE RESTRICT,
CONSTRAINT fk_param_values_param_id FOREIGN KEY(param_id) REFERENCES param_definitions (id) ON DELETE RESTRICT
)CHARSET=utf8mb4 ENGINE=InnoDB COMMENT='参数版本条目表(append-only,按生效日期时点化取值)' COLLATE utf8mb4_general_ci;
CREATE INDEX idx_param_values_batch_id ON param_values (batch_id);
CREATE INDEX idx_param_values_effective_date ON param_values (effective_date);
CREATE INDEX idx_param_values_param_id_dimension_value_effective_date ON param_values (param_id, dimension_value, effective_date);
-- --------------------------------------------------------------------------- -- ---------------------------------------------------------------------------
-- 字典数据:角色(与 V1 迁移中的初始化数据一致) -- 字典数据:角色(与 V1 迁移中的初始化数据一致)
-- --------------------------------------------------------------------------- -- ---------------------------------------------------------------------------
@@ -142,11 +262,62 @@ INSERT INTO roles (role_code, role_name, description, sort_order) VALUES ('admin
INSERT INTO roles (role_code, role_name, description, sort_order) VALUES ('user', '普通用户', '业务操作', 2); INSERT INTO roles (role_code, role_name, description, sort_order) VALUES ('user', '普通用户', '业务操作', 2);
-- --------------------------------------------------------------------------- -- ---------------------------------------------------------------------------
-- 迁移版本记录:标记 V1 已应用,使 alembic upgrade head 成为空操作 -- 字典数据:业务岗位与计提参数(与 V3 迁移种子一致;口径依据 2026-10-12 业务裁决)
-- 显式指定 id:param_values 按 param_id 引用定义,与迁移种子保持同源一致
-- ---------------------------------------------------------------------------
INSERT INTO business_roles (id, role_code, role_name, calc_template, description, sort_order, is_builtin) VALUES
(1, 'salesperson', '营销员', 'TPL_SALESPERSON_DECAY', '业务员个人户:回款天数线性递减(10‰ − 天数×4/360‰,361 天起固定 4‰)', 1, 1),
(2, 'sales_manager', '营销经理', 'TPL_MANAGER_SHARE', '经理绩效:∑本团队业务员绩效×12.5% + ∑全公司业务员绩效×5%', 2, 1),
(3, 'info_officer', '信息员', 'TPL_INFO_SALES_FIXED', '信息员绩效:本人销售额×1‰', 3, 1);
INSERT INTO param_change_batches (id, version_no, effective_date, change_type, remark, operator_name, item_count) VALUES
(1, 'P20260101-00', '2026-01-01', 1, '初始版本(系统初始化种子,口径依据 2026-10-12 业务裁决)', 'system', 19);
INSERT INTO param_definitions (id, param_code, param_name, param_group, value_type, unit, dimension_type, options_text, default_value, value_min, value_max, description, sort_order) VALUES
(1, 'salesperson.rate.start', '业务员起始计提比例', '业务员个人户', 'DECIMAL', '‰', 'BUSINESS_ROLE', NULL, '10', '0', NULL, '线性递减段起始值:比例 = 起始 − 回款天数 × 斜率(≤分界天数)', 1),
(2, 'salesperson.rate.slope', '业务员日递减斜率', '业务员个人户', 'DECIMAL', '‰/天', 'BUSINESS_ROLE', NULL, '4/360', '0', NULL, '精确分数 4/360‰/天(=1/90),禁止按 0.011111 截断存储', 2),
(3, 'salesperson.rate.floor', '业务员计提下限', '业务员个人户', 'DECIMAL', '‰', 'BUSINESS_ROLE', NULL, '4', '0', NULL, '回款天数超过分界天数后固定取此比例(≥361 天 = 4‰)', 3),
(4, 'salesperson.days.boundary', '线性递减分界天数', '业务员个人户', 'INT', '天', 'BUSINESS_ROLE', NULL, '360', '0', NULL, '≤分界天数按公式线性递减(360 天=6‰);超过则取下限', 4),
(5, 'manager.share.team', '经理本团队分成比例', '经理分成', 'DECIMAL', '%', 'BUSINESS_ROLE', NULL, '12.5', '0', '100', '∑本人带领业务员绩效 × 此比例(限本团队)', 5),
(6, 'manager.share.company', '经理全公司分成比例', '经理分成', 'DECIMAL', '%', 'BUSINESS_ROLE', NULL, '5', '0', '100', '∑全部业务员绩效 × 此比例(全公司)', 6),
(7, 'enterprise.rate', '企业户计提比例', '企业户', 'DECIMAL', '‰', 'PRODUCT_CATEGORY', NULL, '3', '0', NULL, '所有企业户适用;2026-08-13 起由 2‰ 提高至 3‰', 7),
(8, 'info.rate', '信息员计提比例', '信息员', 'DECIMAL', '‰', 'BUSINESS_ROLE', NULL, '1', '0', NULL, '信息员绩效 = 本人销售额 × 此比例', 8),
(9, 'prepay.rate', '预付款计提比例', '支付环节', 'DECIMAL', '‰', 'NONE', NULL, '10', '0', NULL, '预付款统一按此比例计提', 9),
(10, 'barter.discount', '以物抵顶折算比例', '支付环节', 'DECIMAL', '%', 'NONE', NULL, '80', '0', '100', '以物抵顶回款绩效按应发绩效的此比例折算', 10),
(11, 'payment.start.ratio', '支付起付比例', '支付环节', 'DECIMAL', '%', 'NONE', NULL, '60', '0', '100', '单项业务回款额达销售额(不含运费)此比例后起付;达标前累计', 11),
(12, 'payment.weight.mode', '支付权重应用方式', '支付环节', 'ENUM', NULL, 'NONE', 'FULL|BALANCE', 'FULL', NULL, NULL, 'FULL=按 R×R 乘当月全额应发绩效;BALANCE=乘余额(语义待 M3 前确认)', 12),
(13, 'product.threshold', '折算毛利润额阈值', '产品阈值', 'DECIMAL', '%', 'PRODUCT_CATEGORY', NULL, '30', '0', '100', '折算毛利润额 = 销售额 ×(毛利润率 − 阈值);市政水利 30%、轨交 20%', 13),
(14, 'salary.basic.mode', '月工资基本工资口径', '月工资口径', 'ENUM', NULL, 'BUSINESS_ROLE', 'INCLUDE|DEDUCT|EXCLUDE', 'INCLUDE', NULL, NULL, 'INCLUDE含基本工资 / DEDUCT扣除基本工资 / EXCLUDE不计', 14),
(15, 'probation.annual.threshold', '试用期年累计结算阈值', '月工资口径', 'DECIMAL', '元', 'NONE', NULL, '18000', '0', NULL, '试用期年累计绩效超过此值部分年底一次性结算', 15),
(16, 'probation.salary.basic', '试用期基本工资标准', '月工资口径', 'DECIMAL', '元', 'NONE', NULL, '3000', '0', NULL, '试用期仅发基本工资、不发放绩效(归属待 M2 复核)', 16);
INSERT INTO param_values (id, param_id, dimension_value, effective_date, value_text, batch_id) VALUES
(1, 1, 'salesperson', '2026-02-17', '10', 1),
(2, 2, 'salesperson', '2026-02-17', '4/360', 1),
(3, 3, 'salesperson', '2026-02-17', '4', 1),
(4, 4, 'salesperson', '2026-02-17', '360', 1),
(5, 5, 'sales_manager', '2026-08-13', '12.5', 1),
(6, 6, 'sales_manager', '2026-08-13', '5', 1),
(7, 7, '', '2026-02-17', '2', 1),
(8, 7, '', '2026-08-13', '3', 1),
(9, 8, 'info_officer', '2026-08-13', '1', 1),
(10, 9, '', '2026-01-01', '10', 1),
(11, 10, '', '2026-01-01', '80', 1),
(12, 11, '', '2026-01-01', '60', 1),
(13, 12, '', '2026-01-01', 'FULL', 1),
(14, 13, 'municipal_water', '2026-02-17', '30', 1),
(15, 13, 'rail_transit', '2026-02-17', '20', 1),
(16, 14, 'salesperson', '2026-01-01', 'INCLUDE', 1),
(17, 14, 'sales_manager', '2026-01-01', 'DEDUCT', 1),
(18, 15, '', '2026-01-01', '18000', 1),
(19, 16, '', '2026-01-01', '3000', 1);
-- ---------------------------------------------------------------------------
-- 迁移版本记录:须等于当前 head,使 alembic upgrade head 成为空操作
-- --------------------------------------------------------------------------- -- ---------------------------------------------------------------------------
CREATE TABLE alembic_version ( CREATE TABLE alembic_version (
version_num VARCHAR(32) NOT NULL, version_num VARCHAR(32) NOT NULL,
CONSTRAINT alembic_version_pkc PRIMARY KEY (version_num) CONSTRAINT alembic_version_pkc PRIMARY KEY (version_num)
)CHARSET=utf8mb4 ENGINE=InnoDB COLLATE utf8mb4_general_ci; )CHARSET=utf8mb4 ENGINE=InnoDB COLLATE utf8mb4_general_ci;
INSERT INTO alembic_version (version_num) VALUES ('6165691326d4'); INSERT INTO alembic_version (version_num) VALUES ('bfbda9c3191d');
+32
View File
@@ -0,0 +1,32 @@
-- Running upgrade 6165691326d4 -> a45a8087831b
CREATE TABLE operation_logs (
id BIGINT UNSIGNED NOT NULL COMMENT '主键ID' AUTO_INCREMENT,
user_id BIGINT UNSIGNED COMMENT '操作人ID(账号删除后置空)',
username VARCHAR(64) NOT NULL COMMENT '操作人用户名(冗余存储便于追溯)',
module VARCHAR(64) NOT NULL COMMENT '所属模块,如 用户管理',
action VARCHAR(32) NOT NULL COMMENT '操作类型:新增/修改/删除/导出',
summary VARCHAR(255) NOT NULL COMMENT '操作摘要(中文,供人阅读)',
method VARCHAR(8) COMMENT 'HTTP 方法',
path VARCHAR(255) COMMENT '请求路径',
ip VARCHAR(45) COMMENT '客户端IP(45位兼容IPv6)',
user_agent VARCHAR(255) COMMENT '客户端User-Agent(截断存储)',
request_data TEXT COMMENT '请求参数(已脱敏)',
success TINYINT NOT NULL COMMENT '是否成功:1成功 0失败' DEFAULT '0',
error_message VARCHAR(500) COMMENT '失败原因(写入方须截断)',
duration_ms INTEGER COMMENT '处理耗时(毫秒)',
create_time DATETIME NOT NULL COMMENT '创建时间' DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT pk_operation_logs PRIMARY KEY (id),
CONSTRAINT fk_operation_logs_user_id FOREIGN KEY(user_id) REFERENCES users (id) ON DELETE SET NULL
)CHARSET=utf8mb4 ENGINE=InnoDB COMMENT='操作日志表' COLLATE utf8mb4_general_ci;
CREATE INDEX idx_operation_logs_create_time ON operation_logs (create_time);
CREATE INDEX idx_operation_logs_module ON operation_logs (module);
CREATE INDEX idx_operation_logs_user_id ON operation_logs (user_id);
CREATE INDEX idx_operation_logs_username ON operation_logs (username);
UPDATE alembic_version SET version_num='a45a8087831b' WHERE alembic_version.version_num = '6165691326d4';
@@ -0,0 +1,153 @@
-- Running upgrade a45a8087831b -> bfbda9c3191d
CREATE TABLE business_roles (
id BIGINT UNSIGNED NOT NULL COMMENT '主键ID' AUTO_INCREMENT,
role_code VARCHAR(32) NOT NULL COMMENT '岗位编码:salesperson营销员 sales_manager营销经理 info_officer信息员,创建后不可修改',
role_name VARCHAR(64) NOT NULL COMMENT '岗位名称',
calc_template VARCHAR(32) NOT NULL COMMENT '计提公式模板编码(以代码内模板注册表为准)',
description VARCHAR(255) COMMENT '岗位说明',
sort_order INTEGER NOT NULL COMMENT '显示排序' DEFAULT '0',
status TINYINT NOT NULL COMMENT '状态:1启用 0禁用' DEFAULT '1',
is_builtin TINYINT NOT NULL COMMENT '是否内置岗位:1内置(禁删禁停) 0自定义' DEFAULT '0',
create_time DATETIME NOT NULL COMMENT '创建时间' DEFAULT CURRENT_TIMESTAMP,
update_time DATETIME NOT NULL COMMENT '更新时间' DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT pk_business_roles PRIMARY KEY (id),
CONSTRAINT uk_business_roles_role_code UNIQUE (role_code)
)CHARSET=utf8mb4 ENGINE=InnoDB COMMENT='业务岗位角色表(与登录角色roles区分,决定计提公式与参数)' COLLATE utf8mb4_general_ci;
CREATE TABLE param_definitions (
id BIGINT UNSIGNED NOT NULL COMMENT '主键ID' AUTO_INCREMENT,
param_code VARCHAR(64) NOT NULL COMMENT '参数编码,如 salesperson.rate.slope',
param_name VARCHAR(64) NOT NULL COMMENT '参数名称',
param_group VARCHAR(32) NOT NULL COMMENT '参数分组:业务员个人户/经理分成/企业户/信息员/支付环节/产品阈值/月工资口径',
value_type VARCHAR(16) NOT NULL COMMENT '值类型:DECIMAL/INT/BOOL/ENUM/TEXT',
unit VARCHAR(16) COMMENT '展示单位:‰/%/元/天,无单位为空',
dimension_type VARCHAR(32) NOT NULL COMMENT '维度类型:NONE/PRODUCT_CATEGORY/BUSINESS_ROLE' DEFAULT 'NONE',
options_text VARCHAR(255) COMMENT '枚举选项(竖线分隔),仅 ENUM 使用',
default_value VARCHAR(64) COMMENT '系统默认值(Fraction 可解析字符串)',
value_min VARCHAR(64) COMMENT '取值下限(含),校验用',
value_max VARCHAR(64) COMMENT '取值上限(含),校验用',
description VARCHAR(255) COMMENT '口径说明(配置页展示)',
sort_order INTEGER NOT NULL COMMENT '显示排序' DEFAULT '0',
status TINYINT NOT NULL COMMENT '状态:1启用 0停用' DEFAULT '1',
create_time DATETIME NOT NULL COMMENT '创建时间' DEFAULT CURRENT_TIMESTAMP,
update_time DATETIME NOT NULL COMMENT '更新时间' DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT pk_param_definitions PRIMARY KEY (id),
CONSTRAINT uk_param_definitions_param_code UNIQUE (param_code)
)CHARSET=utf8mb4 ENGINE=InnoDB COMMENT='计提参数定义表' COLLATE utf8mb4_general_ci;
CREATE TABLE param_change_batches (
id BIGINT UNSIGNED NOT NULL COMMENT '主键ID' AUTO_INCREMENT,
version_no VARCHAR(32) NOT NULL COMMENT '版本号,如 P20261012-01',
effective_date DATE NOT NULL COMMENT '生效日期(业务日期口径)',
change_type TINYINT NOT NULL COMMENT '生效方式:1立即生效 2指定日期生效',
remark VARCHAR(255) COMMENT '变更说明',
operator_id BIGINT UNSIGNED COMMENT '操作人ID(账号删除后置空)',
operator_name VARCHAR(64) NOT NULL COMMENT '操作人用户名(冗余固化)',
item_count INTEGER NOT NULL COMMENT '变更参数项数' DEFAULT '0',
create_time DATETIME NOT NULL COMMENT '创建时间' DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT pk_param_change_batches PRIMARY KEY (id),
CONSTRAINT fk_param_change_batches_operator_id FOREIGN KEY(operator_id) REFERENCES users (id) ON DELETE SET NULL,
CONSTRAINT uk_param_change_batches_version_no UNIQUE (version_no)
)CHARSET=utf8mb4 ENGINE=InnoDB COMMENT='参数变更批次表' COLLATE utf8mb4_general_ci;
CREATE TABLE param_values (
id BIGINT UNSIGNED NOT NULL COMMENT '主键ID' AUTO_INCREMENT,
param_id BIGINT UNSIGNED NOT NULL COMMENT '参数定义ID(param_definitions.id)',
dimension_value VARCHAR(32) NOT NULL COMMENT '维度取值:NONE维度为空串;产品类别/岗位编码为具体值' DEFAULT '',
effective_date DATE NOT NULL COMMENT '生效日期(按业务日期时点化取值)',
value_text VARCHAR(64) NOT NULL COMMENT '参数值(Fraction 可解析字符串/枚举码/开关)',
old_value_text VARCHAR(64) COMMENT '变更前值(留痕)',
batch_id BIGINT UNSIGNED NOT NULL COMMENT '变更批次ID(param_change_batches.id)',
create_time DATETIME NOT NULL COMMENT '创建时间' DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT pk_param_values PRIMARY KEY (id),
CONSTRAINT fk_param_values_batch_id FOREIGN KEY(batch_id) REFERENCES param_change_batches (id) ON DELETE RESTRICT,
CONSTRAINT fk_param_values_param_id FOREIGN KEY(param_id) REFERENCES param_definitions (id) ON DELETE RESTRICT
)CHARSET=utf8mb4 ENGINE=InnoDB COMMENT='参数版本条目表(append-only,按生效日期时点化取值)' COLLATE utf8mb4_general_ci;
CREATE INDEX idx_param_values_batch_id ON param_values (batch_id);
CREATE INDEX idx_param_values_effective_date ON param_values (effective_date);
CREATE INDEX idx_param_values_param_id_dimension_value_effective_date ON param_values (param_id, dimension_value, effective_date);
INSERT INTO business_roles (id, role_code, role_name, calc_template, description, sort_order, is_builtin) VALUES (1, 'salesperson', '营销员', 'TPL_SALESPERSON_DECAY', '业务员个人户:回款天数线性递减(10‰ − 天数×4/360‰,361 天起固定 4‰)', 1, 1);
INSERT INTO business_roles (id, role_code, role_name, calc_template, description, sort_order, is_builtin) VALUES (2, 'sales_manager', '营销经理', 'TPL_MANAGER_SHARE', '经理绩效:∑本团队业务员绩效×12.5% + ∑全公司业务员绩效×5%', 2, 1);
INSERT INTO business_roles (id, role_code, role_name, calc_template, description, sort_order, is_builtin) VALUES (3, 'info_officer', '信息员', 'TPL_INFO_SALES_FIXED', '信息员绩效:本人销售额×1‰', 3, 1);
INSERT INTO param_change_batches (id, version_no, effective_date, change_type, remark, operator_name, item_count) VALUES (1, 'P20260101-00', '2026-01-01', 1, '初始版本(系统初始化种子,口径依据 2026-10-12 业务裁决)', 'system', 19);
INSERT INTO param_definitions (id, param_code, param_name, param_group, value_type, unit, dimension_type, options_text, default_value, value_min, value_max, description, sort_order) VALUES (1, 'salesperson.rate.start', '业务员起始计提比例', '业务员个人户', 'DECIMAL', '‰', 'BUSINESS_ROLE', NULL, '10', '0', NULL, '线性递减段起始值:比例 = 起始 − 回款天数 × 斜率(≤分界天数)', 1);
INSERT INTO param_definitions (id, param_code, param_name, param_group, value_type, unit, dimension_type, options_text, default_value, value_min, value_max, description, sort_order) VALUES (2, 'salesperson.rate.slope', '业务员日递减斜率', '业务员个人户', 'DECIMAL', '‰/天', 'BUSINESS_ROLE', NULL, '4/360', '0', NULL, '精确分数 4/360‰/天(=1/90),禁止按 0.011111 截断存储', 2);
INSERT INTO param_definitions (id, param_code, param_name, param_group, value_type, unit, dimension_type, options_text, default_value, value_min, value_max, description, sort_order) VALUES (3, 'salesperson.rate.floor', '业务员计提下限', '业务员个人户', 'DECIMAL', '‰', 'BUSINESS_ROLE', NULL, '4', '0', NULL, '回款天数超过分界天数后固定取此比例(≥361 天 = 4‰)', 3);
INSERT INTO param_definitions (id, param_code, param_name, param_group, value_type, unit, dimension_type, options_text, default_value, value_min, value_max, description, sort_order) VALUES (4, 'salesperson.days.boundary', '线性递减分界天数', '业务员个人户', 'INT', '天', 'BUSINESS_ROLE', NULL, '360', '0', NULL, '≤分界天数按公式线性递减(360 天=6‰);超过则取下限', 4);
INSERT INTO param_definitions (id, param_code, param_name, param_group, value_type, unit, dimension_type, options_text, default_value, value_min, value_max, description, sort_order) VALUES (5, 'manager.share.team', '经理本团队分成比例', '经理分成', 'DECIMAL', '%', 'BUSINESS_ROLE', NULL, '12.5', '0', '100', '∑本人带领业务员绩效 × 此比例(限本团队)', 5);
INSERT INTO param_definitions (id, param_code, param_name, param_group, value_type, unit, dimension_type, options_text, default_value, value_min, value_max, description, sort_order) VALUES (6, 'manager.share.company', '经理全公司分成比例', '经理分成', 'DECIMAL', '%', 'BUSINESS_ROLE', NULL, '5', '0', '100', '∑全部业务员绩效 × 此比例(全公司)', 6);
INSERT INTO param_definitions (id, param_code, param_name, param_group, value_type, unit, dimension_type, options_text, default_value, value_min, value_max, description, sort_order) VALUES (7, 'enterprise.rate', '企业户计提比例', '企业户', 'DECIMAL', '‰', 'PRODUCT_CATEGORY', NULL, '3', '0', NULL, '所有企业户适用;2026-08-13 起由 2‰ 提高至 3‰', 7);
INSERT INTO param_definitions (id, param_code, param_name, param_group, value_type, unit, dimension_type, options_text, default_value, value_min, value_max, description, sort_order) VALUES (8, 'info.rate', '信息员计提比例', '信息员', 'DECIMAL', '‰', 'BUSINESS_ROLE', NULL, '1', '0', NULL, '信息员绩效 = 本人销售额 × 此比例', 8);
INSERT INTO param_definitions (id, param_code, param_name, param_group, value_type, unit, dimension_type, options_text, default_value, value_min, value_max, description, sort_order) VALUES (9, 'prepay.rate', '预付款计提比例', '支付环节', 'DECIMAL', '‰', 'NONE', NULL, '10', '0', NULL, '预付款统一按此比例计提', 9);
INSERT INTO param_definitions (id, param_code, param_name, param_group, value_type, unit, dimension_type, options_text, default_value, value_min, value_max, description, sort_order) VALUES (10, 'barter.discount', '以物抵顶折算比例', '支付环节', 'DECIMAL', '%', 'NONE', NULL, '80', '0', '100', '以物抵顶回款绩效按应发绩效的此比例折算', 10);
INSERT INTO param_definitions (id, param_code, param_name, param_group, value_type, unit, dimension_type, options_text, default_value, value_min, value_max, description, sort_order) VALUES (11, 'payment.start.ratio', '支付起付比例', '支付环节', 'DECIMAL', '%', 'NONE', NULL, '60', '0', '100', '单项业务回款额达销售额(不含运费)此比例后起付;达标前累计', 11);
INSERT INTO param_definitions (id, param_code, param_name, param_group, value_type, unit, dimension_type, options_text, default_value, value_min, value_max, description, sort_order) VALUES (12, 'payment.weight.mode', '支付权重应用方式', '支付环节', 'ENUM', NULL, 'NONE', 'FULL|BALANCE', 'FULL', NULL, NULL, 'FULL=按 R×R 乘当月全额应发绩效;BALANCE=乘余额(语义待 M3 前确认)', 12);
INSERT INTO param_definitions (id, param_code, param_name, param_group, value_type, unit, dimension_type, options_text, default_value, value_min, value_max, description, sort_order) VALUES (13, 'product.threshold', '折算毛利润额阈值', '产品阈值', 'DECIMAL', '%', 'PRODUCT_CATEGORY', NULL, '30', '0', '100', '折算毛利润额 = 销售额 ×(毛利润率 − 阈值);市政水利 30%、轨交 20%', 13);
INSERT INTO param_definitions (id, param_code, param_name, param_group, value_type, unit, dimension_type, options_text, default_value, value_min, value_max, description, sort_order) VALUES (14, 'salary.basic.mode', '月工资基本工资口径', '月工资口径', 'ENUM', NULL, 'BUSINESS_ROLE', 'INCLUDE|DEDUCT|EXCLUDE', 'INCLUDE', NULL, NULL, 'INCLUDE含基本工资 / DEDUCT扣除基本工资 / EXCLUDE不计', 14);
INSERT INTO param_definitions (id, param_code, param_name, param_group, value_type, unit, dimension_type, options_text, default_value, value_min, value_max, description, sort_order) VALUES (15, 'probation.annual.threshold', '试用期年累计结算阈值', '月工资口径', 'DECIMAL', '元', 'NONE', NULL, '18000', '0', NULL, '试用期年累计绩效超过此值部分年底一次性结算', 15);
INSERT INTO param_definitions (id, param_code, param_name, param_group, value_type, unit, dimension_type, options_text, default_value, value_min, value_max, description, sort_order) VALUES (16, 'probation.salary.basic', '试用期基本工资标准', '月工资口径', 'DECIMAL', '元', 'NONE', NULL, '3000', '0', NULL, '试用期仅发基本工资、不发放绩效(归属待 M2 复核)', 16);
INSERT INTO param_values (id, param_id, dimension_value, effective_date, value_text, batch_id) VALUES (1, 1, 'salesperson', '2026-02-17', '10', 1);
INSERT INTO param_values (id, param_id, dimension_value, effective_date, value_text, batch_id) VALUES (2, 2, 'salesperson', '2026-02-17', '4/360', 1);
INSERT INTO param_values (id, param_id, dimension_value, effective_date, value_text, batch_id) VALUES (3, 3, 'salesperson', '2026-02-17', '4', 1);
INSERT INTO param_values (id, param_id, dimension_value, effective_date, value_text, batch_id) VALUES (4, 4, 'salesperson', '2026-02-17', '360', 1);
INSERT INTO param_values (id, param_id, dimension_value, effective_date, value_text, batch_id) VALUES (5, 5, 'sales_manager', '2026-08-13', '12.5', 1);
INSERT INTO param_values (id, param_id, dimension_value, effective_date, value_text, batch_id) VALUES (6, 6, 'sales_manager', '2026-08-13', '5', 1);
INSERT INTO param_values (id, param_id, dimension_value, effective_date, value_text, batch_id) VALUES (7, 7, '', '2026-02-17', '2', 1);
INSERT INTO param_values (id, param_id, dimension_value, effective_date, value_text, batch_id) VALUES (8, 7, '', '2026-08-13', '3', 1);
INSERT INTO param_values (id, param_id, dimension_value, effective_date, value_text, batch_id) VALUES (9, 8, 'info_officer', '2026-08-13', '1', 1);
INSERT INTO param_values (id, param_id, dimension_value, effective_date, value_text, batch_id) VALUES (10, 9, '', '2026-01-01', '10', 1);
INSERT INTO param_values (id, param_id, dimension_value, effective_date, value_text, batch_id) VALUES (11, 10, '', '2026-01-01', '80', 1);
INSERT INTO param_values (id, param_id, dimension_value, effective_date, value_text, batch_id) VALUES (12, 11, '', '2026-01-01', '60', 1);
INSERT INTO param_values (id, param_id, dimension_value, effective_date, value_text, batch_id) VALUES (13, 12, '', '2026-01-01', 'FULL', 1);
INSERT INTO param_values (id, param_id, dimension_value, effective_date, value_text, batch_id) VALUES (14, 13, 'municipal_water', '2026-02-17', '30', 1);
INSERT INTO param_values (id, param_id, dimension_value, effective_date, value_text, batch_id) VALUES (15, 13, 'rail_transit', '2026-02-17', '20', 1);
INSERT INTO param_values (id, param_id, dimension_value, effective_date, value_text, batch_id) VALUES (16, 14, 'salesperson', '2026-01-01', 'INCLUDE', 1);
INSERT INTO param_values (id, param_id, dimension_value, effective_date, value_text, batch_id) VALUES (17, 14, 'sales_manager', '2026-01-01', 'DEDUCT', 1);
INSERT INTO param_values (id, param_id, dimension_value, effective_date, value_text, batch_id) VALUES (18, 15, '', '2026-01-01', '18000', 1);
INSERT INTO param_values (id, param_id, dimension_value, effective_date, value_text, batch_id) VALUES (19, 16, '', '2026-01-01', '3000', 1);
UPDATE alembic_version SET version_num='bfbda9c3191d' WHERE alembic_version.version_num = 'a45a8087831b';
+58
View File
@@ -0,0 +1,58 @@
-- ============================================================================
-- 计提参数查询留档(M1 参数体系)
--
-- 时点化取值核心规则:按 (param_id, dimension_value) 分组,取
-- effective_date <= :biz_date 的 ORDER BY effective_date DESC, id DESC 首条
-- (同日多次变更取最新一条,不设唯一键以保证完整留痕)。
-- 参数总量约百行级,全量快照一次取回后在应用层按覆盖链
-- (角色维度 > 产品类别维度 > 默认行)选择,无需在 SQL 层做维度回退。
-- ============================================================================
-- 1. 全量快照:某业务日期下每个(参数, 维度值)的最新生效值
-- 窗口函数版(MySQL 8.0+),供 param_repository.resolve_values 使用
SELECT t.param_code, t.dimension_value, t.value_text, t.effective_date
FROM (
SELECT pd.param_code,
pv.dimension_value,
pv.value_text,
pv.effective_date,
ROW_NUMBER() OVER (
PARTITION BY pv.param_id, pv.dimension_value
ORDER BY pv.effective_date DESC, pv.id DESC
) AS rn
FROM param_values pv
JOIN param_definitions pd ON pd.id = pv.param_id
WHERE pv.effective_date <= :biz_date
AND pd.status = 1
) t
WHERE t.rn = 1;
-- 2. 单参数版本历史(变更留痕,供参数历史接口与导出)
SELECT pv.effective_date,
pv.dimension_value,
pv.old_value_text,
pv.value_text,
b.version_no,
b.operator_name,
b.create_time
FROM param_values pv
JOIN param_change_batches b ON b.id = pv.batch_id
WHERE pv.param_id = :param_id
ORDER BY pv.effective_date DESC, pv.id DESC;
-- 3. 区间变更条目(对比视图辅助:应用层对两日期各执行查询 1 后逐项 diff,
-- 本查询列出区间内实际发生过的变更,供变更记录分页与导出)
SELECT pd.param_code,
pd.param_name,
pv.dimension_value,
pv.old_value_text,
pv.value_text,
pv.effective_date,
b.version_no,
b.operator_name
FROM param_values pv
JOIN param_definitions pd ON pd.id = pv.param_id
JOIN param_change_batches b ON b.id = pv.batch_id
WHERE pv.effective_date > :from_date
AND pv.effective_date <= :to_date
ORDER BY pv.effective_date, pv.id;
Binary file not shown.
+37 -9
View File
@@ -1,11 +1,13 @@
# 前端 # 前端
> AI摘要:衍水集团营销人员工资管理系统的前端。Vue 3 + TypeScript + Vite + Element Plus + Pinia。关键文件 `src/api/request.ts`(统一请求封装)、`src/router/index.ts`(路由守卫)、`src/store/user.ts`(会话状态)、`src/views/login/index.vue`(登录页)。当前完成登录链路,业务模块页面待接入。 > AI摘要:衍水集团营销人员工资管理系统的前端。Vue 3 + TypeScript + Vite + Element Plus + Pinia。关键文件 `src/api/request.ts`(统一请求封装)、`src/components/layout/BasicLayout.vue`(应用布局与导航)、`src/router/index.ts`(路由守卫)、`src/store/user.ts`(会话状态)。已实现登录链路(2026-10 视觉方向重做:点阵底/涟漪/天光/居中卡片)、系统管理页面、岗位角色与计提参数配置页、Bento 仪表盘首页;视觉体系与设计预览见 `docs/design/视觉方向预览.html`。
## 职责 ## 职责
- 登录页与图形验证码交互 - 登录页与图形验证码交互
- 会话状态管理与路由守卫 - 会话状态管理与路由守卫
- 岗位角色管理页(模板绑定)、计提参数配置页(分组目录/批次提交/版本对比/导出/计算预览)
- 首页 Bento 仪表盘(M1 空态设计;业务上线后接入真实统计,图表用 ECharts)
- 后续:基础数据、业务数据、绩效核算、报表等业务页面 - 后续:基础数据、业务数据、绩效核算、报表等业务页面
## 技术栈与版本约束 ## 技术栈与版本约束
@@ -26,18 +28,26 @@
src/ src/
├── api/ ├── api/
│ ├── request.ts # axios 实例、拦截器、统一信封解包 │ ├── request.ts # axios 实例、拦截器、统一信封解包
│ └── auth.ts # 认证接口封装与类型 │ ├── auth.ts # 认证接口封装与类型
├── assets/styles/ # 全局样式 │ ├── users.ts / roles.ts / logs.ts # 系统管理接口封装
├── components/ # 通用组件(待补充) │ └── types.ts # 分页等公共类型
├── composables/ # 组合式函数(待补充) ├── assets/styles/ # 全局样式与品牌 token(:root 的 --ys-*)
├── router/index.ts # 路由表与全局守卫 ├── components/
│ ├── layout/ # BasicLayout(侧边栏/顶栏/面包屑)、AppMenu
│ └── table/ # StatusTag(0/1 状态标签)、AppPagination
├── composables/
│ ├── useAppMenu.ts # 菜单从路由表派生(meta.title/group/order/requiresAdmin)
│ ├── useConfirmAction.ts # 确认框→执行→提示→刷新 动作流(启停/删除共用)
│ └── usePagedList.ts # 分页列表状态(请求序号防竞态、删除后页码回退)
├── router/index.ts # 路由表与全局守卫(四层,含 requiresAdmin)
├── store/ ├── store/
│ ├── index.ts # Pinia 实例 │ ├── index.ts # Pinia 实例
│ └── user.ts # 会话状态:token、用户信息、登录/登出 │ └── user.ts # 会话状态:token、用户信息、登录/登出
├── utils/storage.ts # localStorage 读写 ├── utils/ # storage/format/validate/constants/error(handledError)
└── views/ └── views/
├── login/ # 登录页 ├── login/ # 登录页
└── dashboard/ # 首页(登录链路验证页) ├── dashboard/ # 首页(登录链路验证页)
└── system/ # 系统管理:users(用户)/ roles(角色)/ logs(日志查询)
``` ```
## 启动 ## 启动
@@ -85,6 +95,8 @@ pnpm build # 类型检查 + 生产构建
**字体**:品牌名用**宋体**(衬线,拟正式、拟账册),正文用系统无衬线,数字与验证码用等宽。 **字体**:品牌名用**宋体**(衬线,拟正式、拟账册),正文用系统无衬线,数字与验证码用等宽。
**界面不引入网络字体**——内网环境字体加载不可靠,靠字重、字距与留白做层次更稳妥。新页面请沿用这套字号节奏。 **界面不引入网络字体**——内网环境字体加载不可靠,靠字重、字距与留白做层次更稳妥。新页面请沿用这套字号节奏。
**落地方式**:品牌色已提升为全局 CSS 变量(`src/assets/styles/index.css` 的 `:root`,`--ys-*` 前缀),登录页与布局、系统管理页面共用;全局还把 `--el-color-primary` 指向赭金,Element Plus 组件默认走品牌色。登录页 scoped 里的同名局部变量只是全局 token 的别名。
**签名元素**:品牌面板的水纹层理线,越往下越密(如潜向深处),载入时用 `stroke-dashoffset` 逐条画出——**一个统一的入场动作,而不是散落的特效**。 **签名元素**:品牌面板的水纹层理线,越往下越密(如潜向深处),载入时用 `stroke-dashoffset` 逐条画出——**一个统一的入场动作,而不是散落的特效**。
**构图**:品牌内容(名称 / 金线 / 系统名 / 主标语 / 环节行)**垂直居中聚成一块**,配一层柔和径向光晕作落点。 **构图**:品牌内容(名称 / 金线 / 系统名 / 主标语 / 环节行)**垂直居中聚成一块**,配一层柔和径向光晕作落点。
@@ -104,14 +116,20 @@ pnpm build # 类型检查 + 生产构建
## 端到端测试 ## 端到端测试
`e2e/login_flow.py` 用 Playwright 驱动真实浏览器,覆盖 8 项:路由守卫、验证码渲染、失败后自动刷新、登录跳转、用户信息展示、刷新恢复会话、登出、登出后令牌失效。 `e2e/` 下两个脚本用 Playwright 驱动真实浏览器,公共工具抽在 `e2e/common.py`(登录动作、从 Redis 取验证码答案、提示等待、失败断言与控制台错误收集):
- `login_flow.py`(8 项):路由守卫、验证码渲染、失败后自动刷新、登录跳转、用户信息展示、刷新恢复会话、登出、登出后令牌失效
- `system_management.py`(13 项):菜单导航、用户增删改查(含重名负例)、重置密码、启用/禁用、角色保护与 CRUD、操作日志留痕与详情懒加载脱敏、登录日志、普通用户菜单隐藏与路由守卫
```bash ```bash
# 前置:MySQL/Redis 容器、后端、前端 dev server 均在运行 # 前置:MySQL/Redis 容器、后端、前端 dev server 均在运行
python e2e/login_flow.py python e2e/login_flow.py
# 管理员密码经环境变量传入(不写死在源码里)
E2E_ADMIN_PASSWORD=<开发库admin密码> python e2e/system_management.py
``` ```
需要 Python `playwright` 包与 chromium(`python -m playwright install chromium`)。 需要 Python `playwright` 包与 chromium(`python -m playwright install chromium`)。
脚本自建的测试数据(`e2e_` 前缀)在正常流程内清理;中途失败会残留在开发库。
## 常见问题 ## 常见问题
@@ -120,3 +138,13 @@ python e2e/login_flow.py
**验证码图片不显示** —— 多为代理未生效。访问 `http://localhost:5173/api/health` 应返回统一信封格式 JSON。 **验证码图片不显示** —— 多为代理未生效。访问 `http://localhost:5173/api/health` 应返回统一信封格式 JSON。
**`pnpm install` 报 peer 依赖错误** —— 确认 `@vue/devtools-api` 在 `dependencies` 中(pinia 4 的必需 peer)。 **`pnpm install` 报 peer 依赖错误** —— 确认 `@vue/devtools-api` 在 `dependencies` 中(pinia 4 的必需 peer)。
## 端到端测试(2026-10-13 追加 · M1 参数体系)
| 脚本 | 覆盖 | 结果 |
| --- | --- | --- |
| `e2e/login_flow.py` | 登录链路 8 项 | ✓ 全通过(登录页视觉重做后回归) |
| `e2e/system_management.py` | 系统管理 13 项 | ✓ 全通过(布局白化后回归) |
| `e2e/param_management.py` | 计提参数 7 步:目录分组 → 修改入待提交 → 批次提交(立即生效)→ 目录反映新值 → 版本记录 → 恢复种子值 → 计算预览金标准(27 天 / 9.7‰ / 145.5 元) | ✓ 全通过(首跑) |
运行前置:容器 + 后端(8000)+ 前端 dev(5173);`E2E_ADMIN_PASSWORD` 传管理员密码。
+153
View File
@@ -0,0 +1,153 @@
"""端到端测试公共工具。
抽取自 `login_flow.py`,供各流程脚本复用:环境常量、Redis 读取(取验证码
答案,仅自动化测试可用)、完整登录动作、失败处理与控制台错误收集。
约定:断言失败抛 `E2EFailure`(消息格式 `步骤:原因`),由各脚本的 `main()`
统一截图并返回退出码 1。
"""
import os
import subprocess
import sys
import time
from urllib.parse import urlparse
from playwright.sync_api import Page
BASE_URL = "http://127.0.0.1:5173"
REDIS_CONTAINER = "ys-redis-dev"
# Windows 终端默认 cp936,✓/✗ 等符号会抛 UnicodeEncodeError 而非打印失败原因。
# 与后端 core/logging.py 的处理一致,显式重配标准输出编码。
if hasattr(sys.stdout, "reconfigure"):
sys.stdout.reconfigure(encoding="utf-8")
class E2EFailure(Exception):
"""步骤断言失败。"""
def redis_password() -> str:
"""从 docker/.env 读取 Redis 密码。
测试脚本无法用应用配置(那需要后端 venv),故直接读环境文件。
"""
if value := os.environ.get("REDIS_PASSWORD"):
return value
env_file = os.path.join(os.path.dirname(__file__), "..", "..", "docker", ".env")
with open(env_file, encoding="utf-8") as handle:
for line in handle:
if line.startswith("REDIS_PASSWORD="):
return line.split("=", 1)[1].strip()
raise RuntimeError("未能从 docker/.env 读取 REDIS_PASSWORD")
def redis_get(key: str) -> str | None:
"""读取 Redis 键值。仅用于自动化测试 —— 生产当然不能这样取验证码答案。"""
result = subprocess.run(
["docker", "exec", REDIS_CONTAINER, "redis-cli", "-a", redis_password(),
"--no-auth-warning", "get", key],
capture_output=True, text=True, encoding="utf-8", check=False,
)
return result.stdout.strip() or None
def fail(step: str, detail: str) -> None:
"""断言失败:抛异常而非进程退出 —— 由 main() 统一截图后再退出。"""
raise E2EFailure(f"{step}:{detail}")
def current_path(page: Page) -> str:
"""当前 URL 的路径部分。
比较路径**不能**用 `**/dashboard` 这类 glob —— 它会把
`/login?redirect=/dashboard` 也匹配上,"登录失败"会被误判为成功。
"""
return urlparse(page.url).path
def collect_console_errors(page: Page, sink: list[str] | None = None) -> list[str]:
"""注册控制台错误收集(失败诊断时打印最近几条)。
:param sink: 可选的外部列表(如脚本的模块级变量,供 main() 的失败路径读取)
"""
errors = sink if sink is not None else []
page.on("console", lambda msg: errors.append(msg.text) if msg.type == "error" else None)
page.on("pageerror", lambda exc: errors.append(str(exc)))
return errors
def toast_text(page: Page) -> str:
"""当前显示的全局提示文本(el-message),无提示时为空串。"""
locator = page.locator(".el-message")
return locator.first.text_content().strip() if locator.count() else ""
def wait_toast(page: Page, expected: str, timeout_ms: int = 5000) -> str:
"""等待包含 expected 的全局提示出现,返回其文本(超时返回当前文本)。
直接读 `toast_text` 会在「上一条提示尚未消失、新提示尚未出现」的窗口里
读到旧文本,产生误判;轮询等待把这类时序问题挡在断言之外。
"""
deadline = time.monotonic() + timeout_ms / 1000
text = ""
while time.monotonic() < deadline:
text = toast_text(page)
if expected in text:
return text
page.wait_for_timeout(200)
return text
def expect_toast(page: Page, expected: str, step: str, timeout_ms: int = 5000) -> None:
"""断言出现了包含指定文本的全局提示。"""
text = wait_toast(page, expected, timeout_ms)
if expected not in text:
fail(step, f"未出现提示「{expected}」,当前提示:{text or '(无)'}")
def login(page: Page, username: str, password: str) -> None:
"""完整登录:打开登录页 → 从 Redis 取验证码答案 → 提交 → 等待进入首页。
验证码 id 经响应监听捕获(前端把它内联在响应体里,不是 URL 参数)。
"""
captcha_ids: list[str] = []
def on_response(response) -> None:
if "/api/v1/auth/captcha" not in response.url or response.status != 200:
return
try:
captcha_ids.append(response.json()["data"]["captcha_id"])
except Exception:
# 页面导航后响应体已不可读(会抛协议错误),属可忽略的噪声
pass
page.on("response", on_response)
try:
page.goto(f"{BASE_URL}/login", wait_until="networkidle")
for _ in range(50):
if captcha_ids:
break
page.wait_for_timeout(100)
if not captcha_ids:
fail("登录", "未捕获到验证码请求")
captcha_id = captcha_ids[-1]
answer = redis_get(f"ys:captcha:{captcha_id}")
if not answer:
fail("登录", f"Redis 中未找到验证码 ys:captcha:{captcha_id}")
page.fill('input[autocomplete="username"]', username)
page.fill('input[autocomplete="current-password"]', password)
page.fill('input[autocomplete="off"]', answer)
page.click("button:has-text('登 录')")
page.wait_for_timeout(2000)
if current_path(page) != "/dashboard":
detail = f"未跳转到首页,当前 {page.url}"
if message := toast_text(page):
detail += f",提示:{message}"
fail("登录", detail)
page.wait_for_load_state("networkidle")
finally:
page.remove_listener("response", on_response)
+33 -69
View File
@@ -1,4 +1,4 @@
"""浏览器端端到端验证:登录 + 图形验证码。 """浏览器端到端验证:登录 + 图形验证码。
验证的是**只有真实浏览器才能暴露**的问题: 验证的是**只有真实浏览器才能暴露**的问题:
@@ -20,53 +20,28 @@
cd frontend && python e2e/login_flow.py cd frontend && python e2e/login_flow.py
依赖:Python `playwright` 包与 chromium(`python -m playwright install chromium`)。 依赖:Python `playwright` 包与 chromium(`python -m playwright install chromium`)。
公共工具见 `e2e/common.py`。
""" """
import os
import subprocess
import sys
from playwright.sync_api import Page, sync_playwright from playwright.sync_api import Page, sync_playwright
BASE_URL = "http://127.0.0.1:5173" from common import (
BASE_URL,
E2EFailure,
collect_console_errors,
current_path,
fail,
redis_get,
toast_text,
)
USERNAME = "admin" USERNAME = "admin"
PASSWORD = "Admin@2026" PASSWORD = "Admin@2026"
REDIS_CONTAINER = "ys-redis-dev"
def _redis_password() -> str:
"""从 docker/.env 读取 Redis 密码。
测试脚本无法用应用配置(那需要后端 venv),故直接读环境文件。
"""
if value := os.environ.get("REDIS_PASSWORD"):
return value
env_file = os.path.join(os.path.dirname(__file__), "..", "..", "docker", ".env")
with open(env_file, encoding="utf-8") as handle:
for line in handle:
if line.startswith("REDIS_PASSWORD="):
return line.split("=", 1)[1].strip()
raise RuntimeError("未能从 docker/.env 读取 REDIS_PASSWORD")
def redis_get(key: str) -> str | None:
"""读取 Redis 键值。仅用于自动化测试 —— 生产当然不能这样取验证码答案。"""
result = subprocess.run(
["docker", "exec", REDIS_CONTAINER, "redis-cli", "-a", _redis_password(),
"--no-auth-warning", "get", key],
capture_output=True, text=True, encoding="utf-8", check=False,
)
return result.stdout.strip() or None
def _fail(step: str, detail: str) -> None:
print(f" ✗ {step}:{detail}")
sys.exit(1)
def run(page: Page) -> None: def run(page: Page) -> None:
captured_captcha_ids: list[str] = [] captured_captcha_ids: list[str] = []
console_errors: list[str] = [] console_errors = collect_console_errors(page)
def on_response(response) -> None: def on_response(response) -> None:
if "/api/v1/auth/captcha" not in response.url or response.status != 200: if "/api/v1/auth/captcha" not in response.url or response.status != 200:
@@ -79,27 +54,11 @@ def run(page: Page) -> None:
pass pass
page.on("response", on_response) page.on("response", on_response)
page.on("console", lambda msg: console_errors.append(msg.text) if msg.type == "error" else None)
page.on("pageerror", lambda exc: console_errors.append(str(exc)))
def at_dashboard() -> bool:
"""判断是否真的在首页。
不能用 `**/dashboard` 这类 glob —— 它会把
`/login?redirect=/dashboard` 也匹配上,导致"登录失败"被误判为成功。
"""
from urllib.parse import urlparse
return urlparse(page.url).path == "/dashboard"
def toast_text() -> str:
locator = page.locator(".el-message")
return locator.first.text_content().strip() if locator.count() else ""
# ---- 1. 路由守卫:未登录访问首页应被拦回登录页 ---- # ---- 1. 路由守卫:未登录访问首页应被拦回登录页 ----
page.goto(BASE_URL, wait_until="networkidle") page.goto(BASE_URL, wait_until="networkidle")
if "/login" not in page.url: if "/login" not in page.url:
_fail("路由守卫", f"未登录访问首页未被拦截,当前 URL={page.url}") fail("路由守卫", f"未登录访问首页未被拦截,当前 URL={page.url}")
print(f"✓ 1. 路由守卫 未登录访问 / → 重定向到 {page.url}") print(f"✓ 1. 路由守卫 未登录访问 / → 重定向到 {page.url}")
# ---- 2. 验证码渲染(证明 浏览器→Vite代理→uvicorn→Redis 全链路通)---- # ---- 2. 验证码渲染(证明 浏览器→Vite代理→uvicorn→Redis 全链路通)----
@@ -107,9 +66,9 @@ def run(page: Page) -> None:
image.wait_for(state="visible", timeout=10_000) image.wait_for(state="visible", timeout=10_000)
src = image.get_attribute("src") or "" src = image.get_attribute("src") or ""
if not src.startswith("data:image/png;base64,"): if not src.startswith("data:image/png;base64,"):
_fail("验证码渲染", f"图片 src 前缀异常:{src[:40]}") fail("验证码渲染", f"图片 src 前缀异常:{src[:40]}")
if len(src) < 1000: if len(src) < 1000:
_fail("验证码渲染", f"图片数据过小({len(src)}),可能未真正渲染") fail("验证码渲染", f"图片数据过小({len(src)}),可能未真正渲染")
print(f"✓ 2. 验证码渲染 PNG base64 长度 {len(src)}(代理链路已通)") print(f"✓ 2. 验证码渲染 PNG base64 长度 {len(src)}(代理链路已通)")
# ---- 3. 输错验证码 → 应提示且验证码自动刷新 ---- # ---- 3. 输错验证码 → 应提示且验证码自动刷新 ----
@@ -121,38 +80,38 @@ def run(page: Page) -> None:
page.wait_for_timeout(1500) page.wait_for_timeout(1500)
if "/login" not in page.url: if "/login" not in page.url:
_fail("失败处理", f"登录失败后不应离开登录页,当前 URL={page.url}") fail("失败处理", f"登录失败后不应离开登录页,当前 URL={page.url}")
second_id = captured_captcha_ids[-1] second_id = captured_captcha_ids[-1]
if second_id == first_id: if second_id == first_id:
_fail("验证码刷新", "登录失败后验证码未刷新 —— 第二次提交必然报「验证码错误」") fail("验证码刷新", "登录失败后验证码未刷新 —— 第二次提交必然报「验证码错误」")
print("✓ 3. 失败自动刷新 验证码已换新(GETDEL 消费后的必要处理)") print("✓ 3. 失败自动刷新 验证码已换新(GETDEL 消费后的必要处理)")
# ---- 4. 正确登录 ---- # ---- 4. 正确登录 ----
answer = redis_get(f"ys:captcha:{second_id}") answer = redis_get(f"ys:captcha:{second_id}")
if not answer: if not answer:
_fail("取验证码", f"Redis 中未找到 ys:captcha:{second_id}") fail("取验证码", f"Redis 中未找到 ys:captcha:{second_id}")
page.fill('input[autocomplete="off"]', answer) page.fill('input[autocomplete="off"]', answer)
page.click("button:has-text('登 录')") page.click("button:has-text('登 录')")
page.wait_for_timeout(2000) page.wait_for_timeout(2000)
if not at_dashboard(): if current_path(page) != "/dashboard":
detail = f"仍停留在 {page.url}" detail = f"仍停留在 {page.url}"
if message := toast_text(): if message := toast_text(page):
detail += f",提示:{message}" detail += f",提示:{message}"
if console_errors: if console_errors:
detail += f",控制台错误:{console_errors[:3]}" detail += f",控制台错误:{console_errors[:3]}"
_fail("登录", detail) fail("登录", detail)
page.wait_for_load_state("networkidle") page.wait_for_load_state("networkidle")
print(f"✓ 4. 登录成功 跳转到 {page.url}") print(f"✓ 4. 登录成功 跳转到 {page.url}")
if USERNAME not in page.content(): if USERNAME not in page.content():
_fail("用户信息", "首页未显示当前用户名") fail("用户信息", "首页未显示当前用户名")
print("✓ 5. 用户信息 首页已展示当前账号") print("✓ 5. 用户信息 首页已展示当前账号")
# ---- 6. 刷新页面 → 守卫应调用 /me 恢复会话 ---- # ---- 6. 刷新页面 → 守卫应调用 /me 恢复会话 ----
page.reload(wait_until="networkidle") page.reload(wait_until="networkidle")
if "/dashboard" not in page.url: if current_path(page) != "/dashboard":
_fail("会话恢复", f"刷新后被踢回登录页,当前 URL={page.url}") fail("会话恢复", f"刷新后被踢回登录页,当前 URL={page.url}")
print("✓ 6. 会话恢复 刷新页面后仍保持登录(/me 恢复成功)") print("✓ 6. 会话恢复 刷新页面后仍保持登录(/me 恢复成功)")
# ---- 7. 登出 → 令牌立即失效 ---- # ---- 7. 登出 → 令牌立即失效 ----
@@ -162,8 +121,8 @@ def run(page: Page) -> None:
page.goto(f"{BASE_URL}/dashboard", wait_until="networkidle") page.goto(f"{BASE_URL}/dashboard", wait_until="networkidle")
page.wait_for_timeout(1000) page.wait_for_timeout(1000)
if at_dashboard(): if current_path(page) == "/dashboard":
_fail("登出失效", "登出后仍能访问受保护页面") fail("登出失效", "登出后仍能访问受保护页面")
print("✓ 8. 令牌失效 登出后直接访问 /dashboard 被拦回登录页") print("✓ 8. 令牌失效 登出后直接访问 /dashboard 被拦回登录页")
@@ -174,9 +133,14 @@ def main() -> int:
page.set_default_timeout(10_000) page.set_default_timeout(10_000)
try: try:
run(page) run(page)
except E2EFailure as exc:
page.screenshot(path="e2e/failure.png", full_page=True)
print(f"\n ✗ {exc}")
print(" 已保存截图 e2e/failure.png")
return 1
except Exception as exc: # noqa: BLE001 — 测试脚本需要打印完整失败原因 except Exception as exc: # noqa: BLE001 — 测试脚本需要打印完整失败原因
page.screenshot(path="e2e/failure.png", full_page=True) page.screenshot(path="e2e/failure.png", full_page=True)
print(f"\n✗ 执行中断:{type(exc).__name__}: {exc}") print(f"\n ✗ 执行中断:{type(exc).__name__}: {exc}")
print(" 已保存截图 e2e/failure.png") print(" 已保存截图 e2e/failure.png")
return 1 return 1
finally: finally:
+145
View File
@@ -0,0 +1,145 @@
"""计提参数管理端到端测试(需要容器、后端、前端都在运行)。
覆盖链路(M1 验收):目录分组展示 → 修改参数入「待提交」→ 批次提交(立即
生效)→ 目录反映新值 → 版本记录可见 → 计算预览金标准复现(27 天 / 9.7‰ /
145.5 元)→ 值改回种子值恢复现场。
运行:``E2E_ADMIN_PASSWORD=... python e2e/param_management.py``
"""
import sys
sys.path.insert(0, __import__("os").path.dirname(__file__))
from playwright.sync_api import sync_playwright
from common import (
BASE_URL,
E2EFailure,
collect_console_errors,
expect_toast,
fail,
login,
wait_toast,
)
if hasattr(sys.stdout, "reconfigure"):
sys.stdout.reconfigure(encoding="utf-8")
PARAMS_URL = f"{BASE_URL}/system/params"
console_errors: list[str] = []
def step(title: str) -> None:
print(f"—— {title}")
def main() -> int:
failures: list[str] = []
with sync_playwright() as p:
browser = p.chromium.launch()
page = browser.new_page(viewport={"width": 1440, "height": 860})
errors = collect_console_errors(page, console_errors)
try:
admin_password = __import__("os").environ.get("E2E_ADMIN_PASSWORD")
if not admin_password:
print("✗ 缺少环境变量 E2E_ADMIN_PASSWORD(管理员密码不入库)")
return 1
login(page, "admin", admin_password)
page.goto(PARAMS_URL, wait_until="networkidle")
step("1. 目录分组展示")
if page.locator(".group-title", has_text="支付环节").count() == 0:
fail("目录分组", "未找到「支付环节」分组")
# 展开「支付环节」分组
page.locator(".el-collapse-item__header", has_text="支付环节").click()
page.wait_for_timeout(300)
step("2. 修改预付款比例 10 → 12(立即生效)")
row = page.locator(".param-table tbody tr", has_text="预付款计提比例").first
row.get_by_role("button", name="改", exact=True).click()
dialog = page.locator(".el-dialog")
dialog.locator("input").fill("12")
dialog.get_by_role("button", name="加入待提交").click()
if "待改为 12" not in page.content():
fail("待提交", "参数行未出现「待改为 12」标记")
step("3. 提交批次")
page.get_by_role("button", name="提交变更").click()
page.locator(".el-message-box").get_by_role("button", name="提交").click()
expect_toast(page, "已提交", "提交批次")
step("4. 目录反映新值")
page.wait_for_timeout(500)
row = page.locator(".param-table tbody tr", has_text="预付款计提比例").first
if "12" not in row.text_content():
fail("目录生效", "提交后目录未显示新值 12")
step("5. 版本记录可见")
page.get_by_role("button", name="版本管理").click()
page.wait_for_timeout(500)
first_version = page.locator(".el-drawer .el-table tbody tr").first.text_content()
if "P2026" not in first_version:
fail("版本记录", f"最新记录异常:{first_version}")
page.keyboard.press("Escape")
page.wait_for_timeout(300)
step("6. 恢复种子值 10")
row = page.locator(".param-table tbody tr", has_text="预付款计提比例").first
row.get_by_role("button", name="改", exact=True).click()
dialog = page.locator(".el-dialog")
dialog.locator("input").fill("10")
dialog.get_by_role("button", name="加入待提交").click()
page.get_by_role("button", name="提交变更").click()
page.locator(".el-message-box").get_by_role("button", name="提交").click()
wait_toast(page, "已提交")
page.wait_for_timeout(400)
step("7. 计算预览金标准(27 天 / 9.7‰ / 145.5)")
page.get_by_role("button", name="计算预览").click()
page.wait_for_timeout(600)
# 多个抽屉组件常驻 DOM(隐藏未销毁),按标题过滤出预览抽屉
drawer = page.locator(".el-drawer").filter(has_text="计算预览").last
# 岗位下拉默认 salesperson(v-model 初始值),无需操作
drawer.locator(".el-form-item", has_text="发货日").locator("input").click()
page.keyboard.type("2025-12-15")
page.keyboard.press("Enter")
drawer.locator(".el-form-item", has_text="回款到账日").locator("input").click()
page.keyboard.type("2026-01-10")
page.keyboard.press("Enter")
drawer.locator(".el-form-item", has_text="销售额").locator("input").fill("100000")
drawer.locator(".el-form-item", has_text="毛利润额").locator("input").fill("45000")
drawer.locator(".el-form-item", has_text="本笔回款").locator("input").fill("60000")
drawer.get_by_role("button", name="试 算").click()
page.wait_for_timeout(900)
result_text = drawer.locator(".preview-result").text_content()
for expected in ["27 天", "145.5", "97/10000"]:
if expected not in result_text:
fail("预览金标准", f"结果缺少 {expected}:{result_text[:200]}")
except E2EFailure as exc:
failures.append(str(exc))
page.screenshot(path="e2e/failure.png")
except Exception as exc: # noqa: BLE001
import traceback
traceback.print_exc()
failures.append(f"未预期异常:{exc}")
page.screenshot(path="e2e/failure.png")
finally:
browser.close()
if failures:
print(f"✗ {failures[0]}")
if console_errors:
print(f" 控制台错误(最近 3 条):{console_errors[-3:]}")
return 1
print("✓ 计提参数管理端到端:7 步全部通过(目录/待提交/提交/生效/版本/恢复/预览金标准)")
if console_errors:
print(f" (控制台非致命消息 {len(console_errors)} 条,见上方日志)")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+401
View File
@@ -0,0 +1,401 @@
"""系统管理端到端验证:用户 / 角色 / 日志 / 权限。
覆盖的链路(只有真实浏览器才能验证的组合):
- 侧边菜单导航与「系统管理」分组
- 用户增删改查:新建(含重名负例)、编辑、重置密码、启用/禁用、删除
- 角色保护:内置 admin 行的禁用/删除按钮禁用;角色 CRUD 全流程
- 操作日志:写操作后可按条件查到记录,展开详情懒加载出脱敏后的请求参数
- 登录日志:能查到当前会话的成功登录
- 权限:普通用户看不到「系统管理」菜单,直连管理页面被守卫弹回首页
**运行前置**(缺一不可)::
docker compose -f docker/docker-compose.dev.yml up -d # MySQL + Redis
cd backend && uv run uvicorn app.main:app --port 8000 # 后端
cd frontend && pnpm dev # 前端
管理员密码经环境变量传入(不写死在源码里)::
cd frontend && E2E_ADMIN_PASSWORD=<开发库admin密码> python e2e/system_management.py
脚本自建的测试数据(`e2e_` 前缀)在流程内清理;中途失败会残留在开发库,
可用后端集成测试的前缀清理或手工删除。公共工具见 `e2e/common.py`。
"""
import os
import time
from playwright.sync_api import Page, sync_playwright
from common import (
BASE_URL,
E2EFailure,
collect_console_errors,
current_path,
expect_toast,
fail,
login,
toast_text,
wait_toast,
)
ADMIN = "admin"
E2E_PASSWORD = "E2ePwd@2026"
RESET_PASSWORD = "E2eReset@2026"
SUFFIX = str(int(time.time()))
NEW_USERNAME = f"e2e_user_{SUFFIX}"
VIEWER_USERNAME = f"e2e_viewer_{SUFFIX}"
NEW_ROLE_CODE = f"e2e_role_{SUFFIX}"
# 控制台错误收集(失败诊断):模块级列表,main() 的失败路径也要读取
console_errors: list[str] = []
# ==================== 页面操作辅助 ====================
def open_menu(page: Page, title: str) -> None:
"""点击侧边菜单项,必要时先展开「系统管理」分组。
分组已展开时再次点击会收拢,因此先判断菜单项是否已经可见。
"""
menu = page.locator(".app-menu")
item = menu.get_by_text(title, exact=True)
if not item.is_visible():
menu.get_by_text("系统管理", exact=True).click()
page.wait_for_timeout(400)
item.click()
page.wait_for_timeout(800)
def fill_filter(page: Page, label: str, value: str) -> None:
"""在**当前可见**的筛选表单中按标签填写输入框。
选择器一律带 `:visible` —— 日志页两个 tab 各有一个表单(非激活 tab
用 v-show 隐藏),不带可见性过滤会命中隐藏表单的元素。
"""
form_item = page.locator(".filter-form:visible .el-form-item", has_text=label).first
form_item.locator("input").first.fill(value)
def select_filter_option(page: Page, label: str, option: str) -> None:
"""展开可见筛选表单中指定标签的下拉并选择选项。
标签用 `:text-is` 精确匹配 —— 子串匹配会让「操作」命中「操作人」。
"""
item = page.locator(
f'.filter-form:visible .el-form-item:has(.el-form-item__label:text-is("{label}"))'
)
item.locator(".el-select").click()
page.wait_for_timeout(300)
page.locator(".el-select-dropdown:visible .el-select-dropdown__item", has_text=option).first.click()
page.wait_for_timeout(200)
def click_query(page: Page) -> None:
"""点击可见筛选表单中的查询按钮。"""
page.locator(".filter-form:visible button:has-text('查询')").first.click()
page.wait_for_timeout(800)
def table_rows(page: Page):
"""当前可见的表格行(日志页非激活 tab 的表格隐藏,需过滤)。"""
return page.locator(".el-table__body .el-table__row:visible")
def confirm_message_box(page: Page, button_text: str) -> None:
"""点击确认框(ElMessageBox)中的指定按钮。"""
page.locator(".el-message-box").locator(f"button:has-text('{button_text}')").click()
page.wait_for_timeout(800)
def visible_dialog(page: Page):
"""当前可见的对话框(同页存在多个 dialog 实例,按可见性取)。"""
return page.locator(".el-dialog:visible").last
def create_user_via_ui(
page: Page, username: str, real_name: str, expect_success: bool = True
) -> None:
"""经界面新增用户(角色固定选择「普通用户」)。"""
page.click("button:has-text('新增用户')")
page.wait_for_timeout(500)
dialog = visible_dialog(page)
dialog.locator('input[placeholder="登录用户名,创建后不可修改"]').fill(username)
dialog.locator('input[placeholder="8–72 字节(中文一个字符约 3 字节)"]').fill(E2E_PASSWORD)
dialog.locator('input[placeholder="真实姓名"]').fill(real_name)
# 角色多选:展开下拉后选择「普通用户」
dialog.locator(".el-select").click()
page.wait_for_timeout(300)
page.locator(".el-select-dropdown:visible .el-select-dropdown__item", has_text="普通用户").first.click()
page.wait_for_timeout(200)
dialog.locator(".el-dialog__header").click() # 收起下拉
page.wait_for_timeout(200)
dialog.locator(".el-dialog__footer button:has-text('保存')").click()
if expect_success:
expect_toast(page, "用户已创建", "新增用户")
page.wait_for_timeout(600)
def find_user_row(page: Page, username: str):
"""在用户列表中按用户名筛选并返回目标行。"""
fill_filter(page, "用户名", username)
click_query(page)
return table_rows(page).filter(has_text=username).first
# ==================== 流程步骤 ====================
def step_user_create(page: Page, admin_password: str) -> None:
"""登录、菜单导航、新增用户与重名负例。"""
login(page, ADMIN, admin_password)
open_menu(page, "用户管理")
if current_path(page) != "/system/users":
fail("菜单导航", f"未进入用户管理页,当前 {page.url}")
print("✓ 1. 菜单导航 经侧边菜单进入用户管理")
create_user_via_ui(page, NEW_USERNAME, "E2E测试")
row = find_user_row(page, NEW_USERNAME)
if row.count() == 0:
fail("新增用户", f"列表中未找到新建的 {NEW_USERNAME}")
print(f"✓ 2. 新增用户 创建 {NEW_USERNAME} 并可在列表查到")
create_user_via_ui(page, NEW_USERNAME, "E2E重复", expect_success=False)
# 用 wait_toast 而非直读:上一条「用户已创建」提示尚在停留窗口内时,
# 直读会命中它造成偶发误判
if "已存在" not in wait_toast(page, "已存在"):
fail("重名负例", f"重复用户名未提示已存在,当前提示:{toast_text(page) or '(无)'}")
visible_dialog(page).locator(".el-dialog__footer button:has-text('取消')").click()
page.wait_for_timeout(500)
print("✓ 3. 重名负例 重复用户名被拒并给出中文提示")
def step_user_edit_and_status(page: Page) -> None:
"""编辑用户、重置密码、启用/禁用。"""
row = find_user_row(page, NEW_USERNAME)
row.locator("button:has-text('编辑')").click()
page.wait_for_timeout(500)
dialog = visible_dialog(page)
dialog.locator('input[placeholder="真实姓名"]').fill("E2E已改名")
dialog.locator(".el-dialog__footer button:has-text('保存')").click()
expect_toast(page, "已保存", "编辑用户")
row = find_user_row(page, NEW_USERNAME)
if "E2E已改名" not in row.inner_text():
fail("编辑用户", "列表未显示修改后的姓名")
print("✓ 4. 编辑用户 姓名修改生效")
row = find_user_row(page, NEW_USERNAME)
row.locator("button:has-text('重置密码')").click()
page.wait_for_timeout(500)
dialog = visible_dialog(page)
dialog.locator('input[placeholder="8–72 字节(中文一个字符约 3 字节)"]').fill(RESET_PASSWORD)
dialog.locator(".el-dialog__footer button:has-text('确认重置')").click()
expect_toast(page, "密码已重置", "重置密码")
page.wait_for_timeout(600)
print("✓ 5. 重置密码 提示该用户需重新登录")
row = find_user_row(page, NEW_USERNAME)
row.locator("button:has-text('禁用')").click()
page.wait_for_timeout(400)
confirm_message_box(page, "禁用")
expect_toast(page, "已禁用", "禁用用户")
row = find_user_row(page, NEW_USERNAME)
if "禁用" not in row.inner_text():
fail("禁用用户", "列表中状态未更新为禁用")
row.locator("button:has-text('启用')").click()
page.wait_for_timeout(400)
confirm_message_box(page, "启用")
expect_toast(page, "已启用", "启用用户")
print("✓ 6. 启用/禁用 状态切换与后端联动正常")
def step_role_management(page: Page) -> None:
"""角色保护与角色 CRUD。"""
open_menu(page, "角色管理")
if current_path(page) != "/system/roles":
fail("菜单导航", f"未进入角色管理页,当前 {page.url}")
admin_row = table_rows(page).filter(has_text="系统管理员").first
if not admin_row.locator("button:has-text('禁用')").is_disabled():
fail("角色保护", "内置 admin 角色的禁用按钮未禁用")
if not admin_row.locator("button:has-text('删除')").is_disabled():
fail("角色保护", "内置 admin 角色的删除按钮未禁用")
print("✓ 7. 角色保护 内置 admin 行的禁用/删除按钮均禁用")
page.click("button:has-text('新增角色')")
page.wait_for_timeout(500)
dialog = visible_dialog(page)
dialog.locator('input[placeholder="如 sales_manager,创建后不可修改"]').fill(NEW_ROLE_CODE)
dialog.locator('input[placeholder="如 销售主管"]').fill("E2E测试角色")
dialog.locator(".el-dialog__footer button:has-text('保存')").click()
expect_toast(page, "角色已创建", "新增角色")
fill_filter(page, "关键词", NEW_ROLE_CODE)
click_query(page)
role_row = table_rows(page).filter(has_text=NEW_ROLE_CODE).first
if role_row.count() == 0:
fail("新增角色", f"列表中未找到 {NEW_ROLE_CODE}")
role_row.locator("button:has-text('编辑')").click()
page.wait_for_timeout(500)
dialog = visible_dialog(page)
dialog.locator('input[placeholder="如 销售主管"]').fill("E2E角色改名")
dialog.locator(".el-dialog__footer button:has-text('保存')").click()
expect_toast(page, "已保存", "编辑角色")
role_row = table_rows(page).filter(has_text=NEW_ROLE_CODE).first
role_row.locator("button:has-text('禁用')").click()
page.wait_for_timeout(400)
confirm_message_box(page, "禁用")
expect_toast(page, "已禁用", "禁用角色")
print("✓ 8. 角色管理 新增/编辑/禁用全流程正常")
def step_logs_query(page: Page) -> None:
"""操作日志(含详情懒加载与脱敏断言)与登录日志。"""
open_menu(page, "日志查询")
if current_path(page) != "/system/logs":
fail("菜单导航", f"未进入日志查询页,当前 {page.url}")
fill_filter(page, "操作人", ADMIN)
select_filter_option(page, "操作", "新增")
click_query(page)
log_row = table_rows(page).filter(has_text=NEW_USERNAME).first
if log_row.count() == 0:
fail("操作日志", f"未查到「新增用户 {NEW_USERNAME}」的审计记录")
log_row.locator(".el-table__expand-icon").click()
# 详情按 id 懒加载:等「加载中…」被替换为内容(含脱敏标记)
detail = ""
deadline = time.monotonic() + 8
while time.monotonic() < deadline:
detail = page.locator(".log-detail__data:visible").first.text_content() or ""
if "***" in detail:
break
page.wait_for_timeout(200)
if "***" not in detail:
fail("日志脱敏", f"展开详情未加载出已脱敏参数,当前内容:{detail[:80]}")
if E2E_PASSWORD in detail:
fail("日志脱敏", "请求参数中出现明文密码 —— 脱敏未生效")
print("✓ 9. 操作日志 写操作留痕、详情懒加载且密码已脱敏")
page.locator(".el-tabs__item:has-text('登录日志')").click()
page.wait_for_timeout(800)
fill_filter(page, "用户名", ADMIN)
click_query(page)
if table_rows(page).filter(has_text="成功").count() == 0:
fail("登录日志", "未查到当前会话的成功登录记录")
print("✓ 10. 登录日志 当前会话登录记录可查")
def step_permission_check(page: Page, admin_password: str) -> None:
"""普通用户菜单隐藏与路由守卫。"""
open_menu(page, "用户管理")
create_user_via_ui(page, VIEWER_USERNAME, "E2E访客")
page.click("text=退出登录")
page.wait_for_url("**/login**", timeout=10_000)
login(page, VIEWER_USERNAME, E2E_PASSWORD)
if page.locator(".app-menu").get_by_text("系统管理", exact=True).count() != 0:
fail("菜单权限", "普通用户看到了「系统管理」菜单")
print("✓ 11. 菜单权限 普通用户看不到「系统管理」分组")
page.goto(f"{BASE_URL}/system/users", wait_until="networkidle")
page.wait_for_timeout(800)
if current_path(page) != "/dashboard":
fail("路由守卫", f"普通用户直连管理页未被弹回,当前 {page.url}")
if "需要管理员权限" not in toast_text(page):
fail("路由守卫", "直连管理页未给出权限提示")
print("✓ 12. 路由守卫 直连 /system/users 被弹回首页")
page.click("text=退出登录")
page.wait_for_url("**/login**", timeout=10_000)
login(page, ADMIN, admin_password)
def step_cleanup(page: Page) -> None:
"""删除测试用户与角色(用 expect_toast 校验,失败不假绿)。"""
open_menu(page, "用户管理")
for username in (NEW_USERNAME, VIEWER_USERNAME):
row = find_user_row(page, username)
if row.count() == 0:
continue
row.locator("button:has-text('删除')").click()
page.wait_for_timeout(400)
confirm_message_box(page, "删除")
expect_toast(page, "已删除", "数据清理")
open_menu(page, "角色管理")
fill_filter(page, "关键词", NEW_ROLE_CODE)
click_query(page)
role_row = table_rows(page).filter(has_text=NEW_ROLE_CODE).first
if role_row.count() > 0:
role_row.locator("button:has-text('删除')").click()
page.wait_for_timeout(400)
confirm_message_box(page, "删除")
expect_toast(page, "已删除", "数据清理")
print("✓ 13. 数据清理 e2e 测试用户与角色已删除")
# ==================== 入口 ====================
def run(page: Page) -> None:
collect_console_errors(page, console_errors)
admin_password = os.environ.get("E2E_ADMIN_PASSWORD", "")
if not admin_password:
fail(
"环境变量",
"未设置 E2E_ADMIN_PASSWORD(开发库 admin 密码),"
"示例:E2E_ADMIN_PASSWORD=xxx python e2e/system_management.py",
)
step_user_create(page, admin_password)
step_user_edit_and_status(page)
step_role_management(page)
step_logs_query(page)
step_permission_check(page, admin_password)
step_cleanup(page)
if console_errors:
print(f" (控制台错误 {len(console_errors)} 条:{console_errors[:2]})")
def main() -> int:
with sync_playwright() as playwright:
browser = playwright.chromium.launch(headless=True)
page = browser.new_page()
page.set_default_timeout(10_000)
try:
run(page)
except E2EFailure as exc:
page.screenshot(path="e2e/failure.png", full_page=True)
print(f"\n ✗ {exc}")
if console_errors:
print(f" 控制台错误:{console_errors[-5:]}")
print(" 已保存截图 e2e/failure.png")
return 1
except Exception as exc: # noqa: BLE001 — 测试脚本需要打印完整失败原因
page.screenshot(path="e2e/failure.png", full_page=True)
print(f"\n ✗ 执行中断:{type(exc).__name__}: {exc}")
if console_errors:
print(f" 控制台错误:{console_errors[-5:]}")
print(" 已保存截图 e2e/failure.png")
return 1
finally:
browser.close()
print("\n全部通过。")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+90
View File
@@ -0,0 +1,90 @@
import { request } from './request'
import type { PageData } from './types'
/** 公式模板下拉项(含声明参数,供岗位表单展示) */
export interface TemplateOption {
code: string
name: string
description: string
params: { code: string; name: string }[]
}
/** 岗位角色列表项(对应后端 `BusinessRoleOut`) */
export interface BusinessRoleItem {
id: number
role_code: string
role_name: string
calc_template: string
description: string | null
sort_order: number
status: number
/** 1内置(禁删禁停) 0自定义 */
is_builtin: number
}
export interface BusinessRoleQueryParams {
pageNum?: number
pageSize?: number
keyword?: string
status?: number
}
export interface BusinessRoleCreateParams {
role_code: string
role_name: string
calc_template: string
description?: string
sort_order?: number
}
/** 编辑:不含 role_code(创建后不可修改,编码是参数维度与模板绑定的锚点) */
export interface BusinessRoleUpdateParams {
role_name: string
calc_template: string
description?: string
sort_order?: number
}
/** 分页查询岗位角色(含停用) */
export function listBusinessRoles(
params: BusinessRoleQueryParams,
): Promise<PageData<BusinessRoleItem>> {
return request({ url: '/v1/system/business-roles', method: 'get', params })
}
/** 新增岗位 */
export function createBusinessRole(
data: BusinessRoleCreateParams,
): Promise<BusinessRoleItem> {
return request({ url: '/v1/system/business-roles', method: 'post', data })
}
/** 编辑岗位 */
export function updateBusinessRole(
roleId: number,
data: BusinessRoleUpdateParams,
): Promise<BusinessRoleItem> {
return request({ url: `/v1/system/business-roles/${roleId}`, method: 'put', data })
}
/** 删除岗位(内置岗位后端拒绝,前端同步禁用按钮) */
export function deleteBusinessRole(roleId: number): Promise<null> {
return request({ url: `/v1/system/business-roles/${roleId}`, method: 'delete' })
}
/** 启用 / 禁用(内置岗位后端拒绝停用) */
export function setBusinessRoleStatus(
roleId: number,
status: number,
): Promise<BusinessRoleItem> {
return request({
url: `/v1/system/business-roles/${roleId}/status`,
method: 'put',
data: { status },
})
}
/** 公式模板下拉(含声明参数说明) */
export function listTemplateOptions(): Promise<TemplateOption[]> {
return request({ url: '/v1/system/business-roles/meta/templates', method: 'get' })
}
+78
View File
@@ -0,0 +1,78 @@
import { request } from './request'
import type { PageData } from './types'
/** 操作日志列表项(对应后端 `OperationLogOut`;不含 request_data 大字段)。 */
export interface OperationLogItem {
id: number
user_id: number | null
username: string
module: string
action: string
summary: string
method: string | null
path: string | null
ip: string | null
user_agent: string | null
/** 1成功 0失败 */
success: number
error_message: string | null
duration_ms: number | null
create_time: string | null
}
/** 操作日志详情(对应后端 `OperationLogDetailOut`;request_data 已脱敏) */
export interface OperationLogDetail extends OperationLogItem {
request_data: string | null
}
export interface OperationLogQueryParams {
pageNum?: number
pageSize?: number
username?: string
module?: string
action?: string
success?: number
/** 格式 YYYY-MM-DD HH:mm:ss(el-date-picker 需配 value-format) */
start_time?: string
end_time?: string
}
/** 登录日志项(对应后端 `LoginLogOut`)。 */
export interface LoginLogItem {
id: number
user_id: number | null
username: string
ip: string | null
user_agent: string | null
/** 1成功 0失败 */
success: number
fail_reason: string | null
create_time: string | null
}
export interface LoginLogQueryParams {
pageNum?: number
pageSize?: number
username?: string
ip?: string
success?: number
start_time?: string
end_time?: string
}
/** 分页查询操作日志(列表不含 request_data,详情走 getOperationLog) */
export function listOperationLogs(
params: OperationLogQueryParams,
): Promise<PageData<OperationLogItem>> {
return request({ url: '/v1/system/operation-logs', method: 'get', params })
}
/** 操作日志详情(含已脱敏的请求参数,展开行时按需加载) */
export function getOperationLog(logId: number): Promise<OperationLogDetail> {
return request({ url: `/v1/system/operation-logs/${logId}`, method: 'get' })
}
/** 分页查询登录日志 */
export function listLoginLogs(params: LoginLogQueryParams): Promise<PageData<LoginLogItem>> {
return request({ url: '/v1/system/login-logs', method: 'get', params })
}
+208
View File
@@ -0,0 +1,208 @@
import service, { request } from './request'
import type { PageData } from './types'
/** 参数目录项(含各维度截至查询时点的生效值) */
export interface ParamDimensionValue {
dimension_value: string
value_text: string
effective_date: string | null
}
export interface ParamCatalogItem {
param_code: string
param_name: string
param_group: string
value_type: string
unit: string | null
dimension_type: string
options: string[]
default_value: string | null
value_min: string | null
value_max: string | null
description: string | null
values: ParamDimensionValue[]
}
/** 参数目录(asOf 缺省为今日) */
export function getCatalog(asOf?: string): Promise<ParamCatalogItem[]> {
return request({ url: '/v1/system/params', method: 'get', params: asOf ? { as_of: asOf } : {} })
}
/** 变更条目:参数 + 维度 + 新值 */
export interface ParamChangeItem {
param_code: string
dimension_value: string
value: string
}
export interface ParamChangePayload {
/** 生效方式:1立即生效 2指定日期生效 */
change_type: number
effective_date?: string
remark?: string
items: ParamChangeItem[]
}
export interface ParamChangeBatch {
id: number
version_no: string
effective_date: string
change_type: number
remark: string | null
operator_name: string
item_count: number
create_time: string
}
/** 提交变更批次 */
export function submitChanges(data: ParamChangePayload): Promise<ParamChangeBatch> {
return request({ url: '/v1/system/params/changes', method: 'post', data })
}
export interface ParamBatchItem {
param_code: string
param_name: string
dimension_value: string
old_value_text: string | null
value_text: string
effective_date: string
}
export interface ParamBatchDetail {
id: number
version_no: string
effective_date: string
change_type: number
remark: string | null
operator_name: string
item_count: number
create_time: string
items: ParamBatchItem[]
}
/** 批次明细(旧值 → 新值) */
export function getBatchDetail(batchId: number): Promise<ParamBatchDetail> {
return request({ url: `/v1/system/params/changes/${batchId}`, method: 'get' })
}
export interface ParamBatch {
id: number
version_no: string
effective_date: string
change_type: number
remark: string | null
operator_name: string
item_count: number
create_time: string
}
/** 变更记录分页 */
export function listBatches(params: {
pageNum?: number
pageSize?: number
start_date?: string
end_date?: string
}): Promise<PageData<ParamBatch>> {
return request({ url: '/v1/system/params/changes', method: 'get', params })
}
/** 撤回未生效批次 */
export function withdrawBatch(batchId: number): Promise<null> {
return request({ url: `/v1/system/params/changes/${batchId}`, method: 'delete' })
}
export interface ParamDiff {
param_code: string
param_name: string
param_group: string
dimension_value: string
old_value: string | null
new_value: string | null
changed: boolean
}
/** 两日期快照对比 */
export function compareParams(fromDate: string, toDate: string): Promise<ParamDiff[]> {
return request({
url: '/v1/system/params/compare',
method: 'get',
params: { from_date: fromDate, to_date: toDate },
})
}
export interface ParamHistory {
version_no: string
dimension_value: string
old_value_text: string | null
value_text: string
effective_date: string
operator_name: string
create_time: string
}
/** 单参数版本历史 */
export function getParamHistory(
paramCode: string,
params: { pageNum?: number; pageSize?: number; dimension_value?: string },
): Promise<PageData<ParamHistory>> {
return request({ url: `/v1/system/params/${paramCode}/history`, method: 'get', params })
}
/**
* 导出变更记录(文件流)。
*
* 走 axios 实例(带令牌)而非 request 封装:响应是 xlsx 文件流,
* 不是统一信封,拦截器已对 responseType=blob 直通。
*/
export async function exportChanges(params: {
start_date?: string
end_date?: string
}): Promise<Blob> {
const response = await service.get('/v1/system/params/export', {
params,
responseType: 'blob',
})
return response.data as Blob
}
/** 计算预览入参(金额为字符串,Decimal 承载) */
export interface ParamPreviewPayload {
business_role?: string
customer_type?: string
product_category?: string
ship_date: string
receipt_date: string
revenue: string
profit: string
received_amount: string
barter: boolean
probation: boolean
}
export interface ParamUsed {
param_code: string
value_text: string
effective_date: string | null
}
export interface ParamPreviewResult {
payment_days: number
rate: string
rate_text: string
profit_rate: string
converted_profit: string
performance_amount: string
barter_applied: boolean
performance_after_barter: string | null
start_payment_ok: boolean
payment_weight_r: number
payment_weight: string
payable_amount: string
probation_note: string | null
params_used: ParamUsed[]
}
/** 计算预览 */
export function previewCalc(data: ParamPreviewPayload): Promise<ParamPreviewResult> {
return request({ url: '/v1/system/params/preview', method: 'post', data })
}
+17 -1
View File
@@ -51,6 +51,10 @@ function handleUnauthorized(message: string): void {
service.interceptors.response.use( service.interceptors.response.use(
(response: AxiosResponse<ApiResult>) => { (response: AxiosResponse<ApiResult>) => {
// 文件流(导出下载)不是统一信封,直通给调用方处理
if (response.config.responseType === 'blob') {
return response
}
const body = response.data const body = response.data
if (body.code !== 200) { if (body.code !== 200) {
// 防御性分支:后端非 2xx 走 reject 拦截器,这里是 2xx 但业务码异常的情况 // 防御性分支:后端非 2xx 走 reject 拦截器,这里是 2xx 但业务码异常的情况
@@ -61,7 +65,19 @@ service.interceptors.response.use(
}, },
(error: AxiosError<ApiResult>) => { (error: AxiosError<ApiResult>) => {
const status = error.response?.status const status = error.response?.status
const message = error.response?.data?.message ?? '网络异常,请稍后重试' let message = error.response?.data?.message ?? '网络异常,请稍后重试'
// blob 请求失败时 data 是 Blob(如导出接口返回的 JSON 信封错误),需读出真实 message
if (error.response?.data instanceof Blob) {
message = '导出失败,请稍后重试'
void error.response.data.text().then((text) => {
try {
const body = JSON.parse(text) as { message?: string }
if (body.message) ElMessage.error(body.message)
} catch {
/* 非 JSON 响应,保持兜底提示 */
}
})
}
if (status === 401) { if (status === 401) {
// 401 严格表示「受保护接口的鉴权失败」。登录接口自身失败返回 400/403, // 401 严格表示「受保护接口的鉴权失败」。登录接口自身失败返回 400/403,
+54
View File
@@ -0,0 +1,54 @@
import { request } from './request'
import type { PageData, RoleOption } from './types'
/** 角色管理列表项(对应后端 `RoleManageOut`,比下拉多绑定用户数)。 */
export interface RoleItem extends RoleOption {
/** 绑定用户数:>0 的角色不可删除,前端据此禁用按钮 */
user_count: number
}
export interface RoleQueryParams {
pageNum?: number
pageSize?: number
keyword?: string
status?: number
}
export interface RoleCreateParams {
role_code: string
role_name: string
description?: string
sort_order?: number
}
/** 编辑:不含编码(编码是授权判定锚点,创建后不可改) */
export interface RoleUpdateParams {
role_name: string
description?: string
sort_order?: number
}
/** 分页查询角色(含停用角色) */
export function listRoles(params: RoleQueryParams): Promise<PageData<RoleItem>> {
return request({ url: '/v1/system/roles', method: 'get', params })
}
/** 新增角色 */
export function createRole(data: RoleCreateParams): Promise<RoleItem> {
return request({ url: '/v1/system/roles', method: 'post', data })
}
/** 编辑角色 */
export function updateRole(roleId: number, data: RoleUpdateParams): Promise<RoleItem> {
return request({ url: `/v1/system/roles/${roleId}`, method: 'put', data })
}
/** 启用 / 禁用角色(内置 admin 不可停用) */
export function setRoleStatus(roleId: number, status: number): Promise<RoleItem> {
return request({ url: `/v1/system/roles/${roleId}/status`, method: 'put', data: { status } })
}
/** 删除角色(内置 admin 与仍有绑定用户的角色受后端保护) */
export function deleteRole(roleId: number): Promise<null> {
return request({ url: `/v1/system/roles/${roleId}`, method: 'delete' })
}
+25
View File
@@ -0,0 +1,25 @@
/** 请求/响应公共类型。 */
/**
* 分页响应 —— 与后端 `PageResult` 的序列化字段一致。
*
* 分页字段是驼峰(list/total/pageNum/pageSize,规范约定),而业务筛选
* 参数是蛇形(username、real_name…,与后端 Query 模型一致),
* 这是既有契约,不要"顺手统一"。
*/
export interface PageData<T> {
list: T[]
total: number
pageNum: number
pageSize: number
}
/** 已启用角色的下拉选项 */
export interface RoleOption {
id: number
role_code: string
role_name: string
description: string | null
sort_order: number
status: number
}
+86
View File
@@ -0,0 +1,86 @@
import { request } from './request'
import type { PageData, RoleOption } from './types'
/** 用户列表项(对应后端 `UserOut`)。 */
export interface UserItem {
id: number
username: string
real_name: string | null
phone: string | null
email: string | null
/** 1启用 0禁用 */
status: number
/** 角色编码列表 */
roles: string[]
last_login_time: string | null
remark: string | null
create_time: string | null
}
/** 列表查询:分页参数为驼峰,业务筛选为蛇形(与后端契约一致,勿"统一") */
export interface UserQueryParams {
pageNum?: number
pageSize?: number
username?: string
real_name?: string
status?: number
role_code?: string
}
export interface UserCreateParams {
username: string
password: string
real_name?: string
phone?: string
email?: string
roles: string[]
remark?: string
}
/** 编辑:不含用户名与密码(后者走专门的重置接口) */
export interface UserUpdateParams {
real_name?: string
phone?: string
email?: string
roles?: string[]
remark?: string
}
/** 分页查询用户 */
export function listUsers(params: UserQueryParams): Promise<PageData<UserItem>> {
return request({ url: '/v1/system/users', method: 'get', params })
}
/** 新增用户 */
export function createUser(data: UserCreateParams): Promise<UserItem> {
return request({ url: '/v1/system/users', method: 'post', data })
}
/** 编辑用户 */
export function updateUser(userId: number, data: UserUpdateParams): Promise<UserItem> {
return request({ url: `/v1/system/users/${userId}`, method: 'put', data })
}
/** 删除用户(物理删除,建议优先用禁用) */
export function deleteUser(userId: number): Promise<null> {
return request({ url: `/v1/system/users/${userId}`, method: 'delete' })
}
/** 重置密码(该用户已签发的令牌立即失效,需重新登录) */
export function resetPassword(userId: number, newPassword: string): Promise<null> {
return request({
url: `/v1/system/users/${userId}/password`,
method: 'put',
data: { new_password: newPassword },
})
}
/** 启用 / 禁用 */
export function setUserStatus(userId: number, status: number): Promise<UserItem> {
return request({ url: `/v1/system/users/${userId}/status`, method: 'put', data: { status } })
}
/** 角色下拉选项(仅启用角色) */
export function listRoleOptions(): Promise<RoleOption[]> {
return request({ url: '/v1/system/users/meta/roles', method: 'get' })
}
+59 -3
View File
@@ -1,4 +1,51 @@
/* 全局样式:重置 + 基础版式 */ /* 全局样式:重置 + 品牌 token + 基础版式 */
:root {
/*
* 品牌色(2026-10-12 视觉方向:企业级扁平 · 轻质感 · 靛蓝点缀)。
* 靛蓝只作重点点缀(主按钮/链接/焦点/当前菜单),大面积用中性灰白。
* 设计预览与规范:docs/design/视觉方向预览.html
*/
--ys-indigo: #2e4ba8;
--ys-indigo-deep: #223a85;
--ys-indigo-wash: #edf1fa;
--ys-paper: #ffffff;
--ys-mist: #f4f5f8;
--ys-line: rgba(28, 35, 51, 0.09);
--ys-ink: #1c2333;
--ys-ink-2: #5b6577;
--ys-ink-3: #9aa3b2;
/*
* 旧 token 名保留映射(BasicLayout / 登录页存量引用,值迁移到新体系:
* 深水青 → 冷墨系、金色 → 靛蓝系),存量页面迁移完成后移除。
*/
--ys-ink-900: #16203a;
--ys-ink-800: #1c2333;
--ys-ink-700: #242e4a;
--ys-ink-600: #3a4a6e;
--ys-gold-bright: #6d81c2;
--ys-gold-deep: #2e4ba8;
--ys-text: #1c2333;
--ys-muted: #5b6577;
/* 品牌字:思源黑体(实施时自托管 4 字子集,内网可用) */
--ys-font-brand: 'Noto Sans SC', 'PingFang SC', 'Microsoft YaHei', sans-serif;
/*
* Element Plus 主色 = 靛蓝(白字对比约 7.5:1)。
* 色阶为按 Element Plus 公式「主色与白色混合」预先算好的静态值 ——
* 不用 color-mix(),避免依赖较新的浏览器特性。
*/
--el-color-primary: #2e4ba8;
--el-color-primary-light-3: #6d81c2;
--el-color-primary-light-5: #97a5d4;
--el-color-primary-light-7: #c0c9e5;
--el-color-primary-light-8: #d5dbee;
--el-color-primary-light-9: #eaedf6;
--el-color-primary-dark-2: #253c86;
}
* { * {
box-sizing: border-box; box-sizing: border-box;
@@ -16,7 +63,16 @@ body {
font-family: 'Helvetica Neue', Helvetica, 'PingFang SC', 'Hiragino Sans GB', font-family: 'Helvetica Neue', Helvetica, 'PingFang SC', 'Hiragino Sans GB',
'Microsoft YaHei', Arial, sans-serif; 'Microsoft YaHei', Arial, sans-serif;
font-size: 14px; font-size: 14px;
color: #303133; color: var(--ys-ink);
background-color: #f5f7fa; background-color: var(--ys-mist);
-webkit-font-smoothing: antialiased; -webkit-font-smoothing: antialiased;
} }
/* 系统管理列表页共用的版式(原在三个页面 scoped 中各自重复) */
.filter-form {
margin-bottom: 4px;
}
.toolbar {
margin-bottom: 12px;
}
@@ -0,0 +1,65 @@
<script setup lang="ts">
import { useRoute } from 'vue-router'
import { useAppMenu } from '@/composables/useAppMenu'
/** 侧边导航菜单:桌面端 aside 与移动端抽屉复用同一份(路由派生)数据。 */
const route = useRoute()
const { topLevelMenus, groupedMenus } = useAppMenu()
</script>
<template>
<el-menu class="app-menu" :default-active="route.path" router>
<el-menu-item v-for="item in topLevelMenus" :key="item.path" :index="item.path">
{{ item.title }}
</el-menu-item>
<el-sub-menu v-for="group in groupedMenus" :key="group.name" :index="group.name">
<template #title>{{ group.name }}</template>
<el-menu-item v-for="item in group.items" :key="item.path" :index="item.path">
{{ item.title }}
</el-menu-item>
</el-sub-menu>
</el-menu>
</template>
<style scoped>
/*
* 中性灰侧栏上的菜单:覆盖 Element Plus 的主题变量而非直接写颜色 ——
* 后者与组件库规则同权重,受样式注入顺序影响而不稳定。
* 激活项 = 白底浮起(微渐变 + 内高光 + 分层阴影)+ 左侧 2px 靛蓝条。
*/
.app-menu {
--el-menu-bg-color: transparent;
--el-menu-text-color: var(--ys-ink-2);
--el-menu-active-color: var(--ys-indigo);
--el-menu-hover-bg-color: rgba(255, 255, 255, 0.6);
--el-menu-item-height: 40px;
border-right: none;
}
.app-menu :deep(.el-menu-item.is-active) {
position: relative;
background: linear-gradient(180deg, #ffffff, #fbfcfe);
box-shadow:
inset 0 1px 0 #fff,
0 1px 2px rgba(28, 35, 51, 0.06),
0 4px 10px rgba(28, 35, 51, 0.06);
font-weight: 500;
}
.app-menu :deep(.el-menu-item.is-active)::before {
content: '';
position: absolute;
left: -14px;
top: 9px;
bottom: 9px;
width: 2px;
border-radius: 1px;
background: var(--ys-indigo);
}
.app-menu :deep(.el-sub-menu.is-active > .el-sub-menu__title) {
color: var(--ys-indigo);
}
</style>
@@ -0,0 +1,236 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import AppMenu from '@/components/layout/AppMenu.vue'
import { useUserStore } from '@/store/user'
/** 基础布局:左侧品牌导航(窄屏收为抽屉)+ 顶栏(面包屑/用户/退出)+ 内容区。 */
const route = useRoute()
const router = useRouter()
const userStore = useUserStore()
const drawerVisible = ref(false)
/** 面包屑:首页 + 分组(如有)+ 当前页标题(首页自身不重复) */
const breadcrumbs = computed<string[]>(() => {
const items: string[] = []
if (route.meta.group) items.push(route.meta.group)
if (route.meta.title && route.path !== '/dashboard') items.push(route.meta.title)
return items
})
// 移动端在抽屉里点完菜单即收起(路由变化意味着导航已发生)
watch(
() => route.path,
() => {
drawerVisible.value = false
},
)
async function handleLogout(): Promise<void> {
await userStore.logout()
ElMessage.success('已退出登录')
await router.replace('/login')
}
</script>
<template>
<el-container class="app-layout">
<el-aside class="app-aside" width="220px">
<div class="app-brand">
<span class="app-brand__name">衍水集团</span>
<span class="app-brand__sub">营销人员工资管理系统</span>
<span class="app-brand__line"></span>
</div>
<AppMenu class="app-aside__menu" />
</el-aside>
<el-container class="app-body">
<el-header class="app-header">
<div class="app-header__left">
<button
type="button"
class="app-header__menu-toggle"
aria-label="打开导航菜单"
@click="drawerVisible = true"
>
<svg viewBox="0 0 20 20" width="18" height="18" aria-hidden="true">
<path
d="M3 5h14M3 10h14M3 15h14"
stroke="currentColor"
stroke-width="1.6"
stroke-linecap="round"
/>
</svg>
</button>
<el-breadcrumb separator="/">
<el-breadcrumb-item :to="{ path: '/dashboard' }">首页</el-breadcrumb-item>
<el-breadcrumb-item v-for="item in breadcrumbs" :key="item">
{{ item }}
</el-breadcrumb-item>
</el-breadcrumb>
</div>
<div class="app-header__user">
<el-tag v-for="role in userStore.roles" :key="role" size="small" type="info">
{{ role }}
</el-tag>
<span class="app-header__username">{{ userStore.displayName }}</span>
<el-button link type="primary" @click="handleLogout">退出登录</el-button>
</div>
</el-header>
<el-main class="app-main">
<router-view />
</el-main>
</el-container>
<el-drawer
v-model="drawerVisible"
class="app-drawer"
direction="ltr"
size="240px"
:with-header="false"
>
<div class="app-brand">
<span class="app-brand__name">衍水集团</span>
<span class="app-brand__sub">营销人员工资管理系统</span>
<span class="app-brand__line"></span>
</div>
<AppMenu />
</el-drawer>
</el-container>
</template>
<style scoped>
.app-layout {
height: 100%;
}
.app-aside {
display: flex;
flex-direction: column;
/* 中性灰侧栏:比页面底沉一档,与纸白卡片拉开层次 */
background: linear-gradient(180deg, #f2f4f8 0%, #ecedf3 100%);
border-right: 1px solid var(--ys-line);
}
.app-brand {
display: flex;
flex-direction: column;
gap: 3px;
padding: 18px 20px 12px;
}
.app-brand__name {
font-family: var(--ys-font-brand);
font-size: 16px;
font-weight: 500;
letter-spacing: 4px;
color: var(--ys-ink);
}
.app-brand__sub {
font-size: 11px;
letter-spacing: 0.06em;
color: var(--ys-ink-3);
}
/* 品牌签名:logo 下的渐隐水线(全站唯一装饰元素,页面标题处同款) */
.app-brand__line {
width: 24px;
height: 2px;
border-radius: 1px;
margin-top: 8px;
background: linear-gradient(90deg, var(--ys-indigo), rgba(46, 75, 168, 0));
}
.app-aside__menu {
flex: 1;
overflow-y: auto;
}
.app-header {
display: flex;
align-items: center;
justify-content: space-between;
height: 56px;
background: linear-gradient(180deg, #ffffff, #fdfdfe);
border-bottom: 1px solid var(--ys-line);
}
.app-header__left {
display: flex;
align-items: center;
gap: 12px;
}
/* 窄屏才出现的汉堡按钮;不引入图标库,用内联 SVG */
.app-header__menu-toggle {
display: none;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
padding: 0;
color: var(--ys-text);
cursor: pointer;
background: none;
border: none;
border-radius: 6px;
}
.app-header__menu-toggle:hover {
background-color: #f2f3f5;
}
.app-header__menu-toggle:focus-visible {
outline: 2px solid var(--ys-indigo);
outline-offset: 2px;
}
.app-header__user {
display: flex;
align-items: center;
gap: 10px;
}
.app-header__username {
font-size: 14px;
color: var(--ys-text);
}
.app-main {
padding: 20px;
/* 雾灰底 + 点阵网格:内容区的秩序与密度 */
background-color: var(--ys-mist);
background-image: radial-gradient(rgba(28, 35, 51, 0.05) 1px, transparent 1px);
background-size: 24px 24px;
}
@media (max-width: 768px) {
.app-aside {
display: none;
}
.app-header__menu-toggle {
display: inline-flex;
}
}
@media (prefers-reduced-motion: reduce) {
.app-header__menu-toggle {
transition: none;
}
}
</style>
<style>
/* 抽屉内容铺满侧栏同款灰(非 scoped:目标元素由 el-drawer 渲染在组件根之外) */
.app-drawer .el-drawer__body {
padding: 0;
background: linear-gradient(180deg, #f2f4f8 0%, #ecedf3 100%);
}
</style>
@@ -0,0 +1,36 @@
<script setup lang="ts">
/**
* 分页条:统一四个列表页的布局与页码尺寸选项。
* 与 `usePagedList` 的分页字段(pageNum/pageSize/total)配套使用。
*/
defineProps<{
total: number
pageNum: number
pageSize: number
}>()
defineEmits<{
(e: 'page-change', page: number): void
(e: 'size-change', size: number): void
}>()
</script>
<template>
<el-pagination
class="app-pager"
layout="total, sizes, prev, pager, next, jumper"
:total="total"
:current-page="pageNum"
:page-size="pageSize"
:page-sizes="[10, 20, 50]"
@current-change="$emit('page-change', $event)"
@size-change="$emit('size-change', $event)"
/>
</template>
<style scoped>
.app-pager {
justify-content: flex-end;
margin-top: 16px;
}
</style>
@@ -0,0 +1,29 @@
<script setup lang="ts">
/**
* 状态标签:0/1 语义的通用展示。
*
* 用户状态、角色状态、操作结果、登录结果四处共用,故提取;
* 语义文字由调用方通过 props 定制(如同一组件展示「启用/禁用」与「成功/失败」)。
*/
withDefaults(
defineProps<{
value: number
activeText?: string
inactiveText?: string
activeType?: 'success' | 'warning' | 'danger' | 'info' | 'primary'
inactiveType?: 'success' | 'warning' | 'danger' | 'info' | 'primary'
}>(),
{
activeText: '启用',
inactiveText: '禁用',
activeType: 'success',
inactiveType: 'info',
},
)
</script>
<template>
<el-tag :type="value === 1 ? activeType : inactiveType" size="small" disable-transitions>
{{ value === 1 ? activeText : inactiveText }}
</el-tag>
</template>
+65
View File
@@ -0,0 +1,65 @@
import { computed } from 'vue'
import { useRouter } from 'vue-router'
import { useUserStore } from '@/store/user'
import { ROLE_ADMIN } from '@/utils/constants'
/** 菜单节点:从路由表派生,避免菜单标题与页面标题两处维护漂移。 */
export interface MenuNode {
path: string
title: string
order: number
group?: string
}
export interface MenuGroup {
name: string
items: MenuNode[]
}
/**
* 侧边菜单数据。
*
* 规则:
* - 取路由表中带 `meta.title` 且未被 `meta.menu === false` 排除的记录
* - `meta.requiresAdmin` 的项仅管理员可见(与路由守卫双保险,隐藏入口在前)
* - `meta.group` 相同的项聚合为一个子菜单;无 group 的为顶层菜单项
* - 按 `meta.order` 排序
*/
export function useAppMenu() {
const router = useRouter()
const userStore = useUserStore()
const isAdmin = computed(() => userStore.roles.includes(ROLE_ADMIN))
const menuNodes = computed<MenuNode[]>(() =>
router
.getRoutes()
.filter((record) => Boolean(record.meta.title) && record.meta.menu !== false)
.filter((record) => !record.meta.requiresAdmin || isAdmin.value)
.map((record) => ({
path: record.path,
title: record.meta.title ?? '',
order: record.meta.order ?? 99,
group: record.meta.group,
}))
.sort((a, b) => a.order - b.order),
)
/** 无分组的顶层菜单项 */
const topLevelMenus = computed(() => menuNodes.value.filter((node) => !node.group))
/** 分组菜单(按 group 名聚合,组内保持 order 顺序) */
const groupedMenus = computed<MenuGroup[]>(() => {
const groups = new Map<string, MenuNode[]>()
for (const node of menuNodes.value) {
if (!node.group) continue
const items = groups.get(node.group) ?? []
items.push(node)
groups.set(node.group, items)
}
return [...groups.entries()].map(([name, items]) => ({ name, items }))
})
return { topLevelMenus, groupedMenus }
}
@@ -0,0 +1,52 @@
import { ElMessage, ElMessageBox } from 'element-plus'
import { handledError } from '@/utils/error'
/**
* 「确认框 → 执行 → 成功提示 → 收尾刷新」动作流。
*
* 用户/角色两页的启用禁用、删除共四处结构逐行相同,收敛到这里:
* 取消确认静默返回 false;接口失败由请求拦截器统一提示,此处仅兜底
* 阻止 Promise 继续传播(handledError 显式标注,不写空 catch)。
*/
export function useConfirmAction() {
/**
* 弹出确认框并执行;返回是否真正执行成功(用户取消或执行失败均为 false)。
*/
async function confirmAndRun(options: {
/** 确认框正文 */
confirmText: string
/** 确认框标题 */
title?: string
/** 确认按钮文案 */
confirmButtonText?: string
/** 执行成功后的全局提示 */
successText: string
/** 真正的业务动作 */
run: () => Promise<unknown>
/** 成功后的收尾(如刷新列表) */
after?: () => Promise<void> | void
}): Promise<boolean> {
try {
await ElMessageBox.confirm(options.confirmText, options.title ?? '操作确认', {
type: 'warning',
confirmButtonText: options.confirmButtonText ?? '确定',
cancelButtonText: '取消',
})
} catch {
return false // 用户取消,静默返回
}
try {
await options.run()
ElMessage.success(options.successText)
await options.after?.()
return true
} catch (error) {
handledError(error) // 失败提示由请求拦截器统一完成
return false
}
}
return { confirmAndRun }
}
+86
View File
@@ -0,0 +1,86 @@
import { ref, type Ref } from 'vue'
import type { PageData } from '@/api/types'
import { handledError } from '@/utils/error'
/**
* 分页列表状态管理。
*
* 四个列表页(用户/角色/操作日志/登录日志)共用同一套分页交互,要点:
*
* - 「删除当前页最后一条后页码回退」是高频踩坑点:不回退就会停在空页
* - **请求序号防竞态**:快速翻页/改条件时并发请求乱序返回,只有最新
* 请求的响应允许写入状态,否则旧数据会覆盖新数据、与分页器错位
* - 请求失败不外抛(提示由拦截器统一完成),loading 仅由最新请求复位
*/
export function usePagedList<T, F extends object>(
fetcher: (params: F & { pageNum: number; pageSize: number }) => Promise<PageData<T>>,
getFilters: () => F,
) {
const records = ref([]) as Ref<T[]>
const total = ref(0)
const pageNum = ref(1)
const pageSize = ref(10)
const loading = ref(false)
/** 自增请求序号:仅最新请求的响应可写入状态 */
let requestSeq = 0
/** 按当前页码与筛选条件加载 */
async function load(): Promise<void> {
const seq = ++requestSeq
loading.value = true
try {
const data = await fetcher({
...getFilters(),
pageNum: pageNum.value,
pageSize: pageSize.value,
})
if (seq !== requestSeq) return // 已有更新的请求发出,丢弃过期响应
records.value = data.list
total.value = data.total
} catch (error) {
handledError(error) // 失败提示由请求拦截器统一完成
} finally {
if (seq === requestSeq) loading.value = false
}
}
/** 条件查询:回到第一页再加载(停留旧页码会查到空页) */
function search(): void {
pageNum.value = 1
void load()
}
function onPageChange(page: number): void {
pageNum.value = page
void load()
}
function onSizeChange(size: number): void {
pageSize.value = size
pageNum.value = 1
void load()
}
/** 删除后刷新:当前页已被删空且不在第一页时,先回退页码再加载 */
async function reloadAfterDelete(removedCount = 1): Promise<void> {
if (records.value.length - removedCount <= 0 && pageNum.value > 1) {
pageNum.value -= 1
}
await load()
}
return {
records,
total,
pageNum,
pageSize,
loading,
load,
search,
onPageChange,
onSizeChange,
reloadAfterDelete,
}
}
+70 -7
View File
@@ -1,20 +1,76 @@
import { createRouter, createWebHistory, type RouteRecordRaw } from 'vue-router' import { createRouter, createWebHistory, type RouteRecordRaw } from 'vue-router'
import { ElMessage } from 'element-plus'
import { useUserStore } from '@/store/user' import { useUserStore } from '@/store/user'
import { ROLE_ADMIN } from '@/utils/constants'
declare module 'vue-router' {
interface RouteMeta {
/** 免登录页面(登录页) */
public?: boolean
/** 页面标题:浏览器标题 / 面包屑 / 菜单共用,新页面必须写 */
title?: string
/** 仅管理员可访问 —— 菜单隐藏 + 守卫兜底(直接输 URL 会被弹回首页) */
requiresAdmin?: boolean
/** 是否出现在侧边菜单(默认 true);false 用于排除登录页等 */
menu?: boolean
/** 菜单分组名(同名聚合为一个子菜单);不设则为顶层菜单项 */
group?: string
/** 菜单排序,越小越靠前 */
order?: number
}
}
const routes: RouteRecordRaw[] = [ const routes: RouteRecordRaw[] = [
{ path: '/', redirect: '/dashboard' },
{ {
path: '/login', path: '/login',
name: 'login', name: 'login',
component: () => import('@/views/login/index.vue'), component: () => import('@/views/login/index.vue'),
meta: { public: true, title: '登录' }, meta: { public: true, title: '登录', menu: false },
}, },
{ {
path: '/dashboard', // 应用主框架:菜单、顶栏、面包屑由布局提供,业务页面作为子路由渲染
name: 'dashboard', path: '/',
component: () => import('@/views/dashboard/index.vue'), component: () => import('@/components/layout/BasicLayout.vue'),
meta: { title: '首页' }, redirect: '/dashboard',
children: [
{
path: '/dashboard',
name: 'dashboard',
component: () => import('@/views/dashboard/index.vue'),
meta: { title: '首页', order: 1 },
},
{
path: '/system/users',
name: 'system-users',
component: () => import('@/views/system/users/index.vue'),
meta: { title: '用户管理', group: '系统管理', requiresAdmin: true, order: 2 },
},
{
path: '/system/roles',
name: 'system-roles',
component: () => import('@/views/system/roles/index.vue'),
meta: { title: '角色管理', group: '系统管理', requiresAdmin: true, order: 3 },
},
{
path: '/system/business-roles',
name: 'system-business-roles',
component: () => import('@/views/system/business-roles/index.vue'),
meta: { title: '岗位角色', group: '系统管理', requiresAdmin: true, order: 5 },
},
{
path: '/system/params',
name: 'system-params',
component: () => import('@/views/system/params/index.vue'),
meta: { title: '计提参数', group: '系统管理', requiresAdmin: true, order: 6 },
},
{
path: '/system/logs',
name: 'system-logs',
component: () => import('@/views/system/logs/index.vue'),
meta: { title: '日志查询', group: '系统管理', requiresAdmin: true, order: 7 },
},
],
}, },
// 未匹配路由回首页;若未登录会被守卫拦到登录页 // 未匹配路由回首页;若未登录会被守卫拦到登录页
{ path: '/:pathMatch(.*)*', redirect: '/dashboard' }, { path: '/:pathMatch(.*)*', redirect: '/dashboard' },
@@ -28,11 +84,13 @@ const router = createRouter({
/** /**
* 全局前置守卫。 * 全局前置守卫。
* *
* 三层判断: * 四层判断:
* 1. `meta.public` 标记的页面(登录页)直接放行 * 1. `meta.public` 标记的页面(登录页)直接放行
* 2. 无令牌 → 跳登录页,并把原目标记在 query 里供登录后回跳 * 2. 无令牌 → 跳登录页,并把原目标记在 query 里供登录后回跳
* 3. 有令牌但内存中无用户信息(说明是刷新页面)→ 调 /me 恢复会话; * 3. 有令牌但内存中无用户信息(说明是刷新页面)→ 调 /me 恢复会话;
* 失败则视为令牌已失效,清状态并跳登录页 * 失败则视为令牌已失效,清状态并跳登录页
* 4. `meta.requiresAdmin` 的页面要求管理员角色 —— 此时用户信息已就绪,
* 这是"隐藏菜单"之外的第二道防线(防手输 URL)
*/ */
router.beforeEach(async (to) => { router.beforeEach(async (to) => {
const userStore = useUserStore() const userStore = useUserStore()
@@ -54,6 +112,11 @@ router.beforeEach(async (to) => {
} }
} }
if (to.meta.requiresAdmin && !userStore.roles.includes(ROLE_ADMIN)) {
ElMessage.error('需要管理员权限')
return { path: '/dashboard' }
}
return true return true
}) })
+23
View File
@@ -0,0 +1,23 @@
/** 跨页面共享常量。 */
/** 系统管理员:可见「系统管理」菜单,全部管理接口可用(与后端 core/constants.py 一致) */
export const ROLE_ADMIN = 'admin'
/**
* 操作日志的模块 / 操作词表 —— 与后端 `core/constants.py` 的
* `MODULE_*` / `ACTION_*` 保持一致;新增模块时两处同步(后端是权威来源)。
*/
export const MODULE_OPTIONS = ['用户管理', '角色管理', '岗位角色', '计提参数'] as const
export const ACTION_OPTIONS = ['新增', '修改', '删除', '重置密码', '启用', '禁用'] as const
/** 启用/禁用筛选选项(文案与 StatusTag 的展示一致) */
export const STATUS_OPTIONS = [
{ label: '启用', value: 1 },
{ label: '禁用', value: 0 },
] as const
/** 成功/失败筛选选项 */
export const RESULT_OPTIONS = [
{ label: '成功', value: 1 },
{ label: '失败', value: 0 },
] as const
+13
View File
@@ -0,0 +1,13 @@
/** 异常处理工具。 */
/**
* 显式声明「异常已按约定处理」。
*
* 本项目请求失败的提示统一由 axios 拦截器完成,页面 catch 块只需阻止
* Promise 继续传播;但**空 catch 会掩盖非预期异常**(如本地 TypeError),
* 也违反「禁空 catch」规范,故以本函数显式标注意图并留痕。
* 用 debug 级别:既不污染 e2e 收集的 error 级控制台信息,也便于排查时开启。
*/
export function handledError(error: unknown): void {
console.debug('[handled]', error)
}
+12
View File
@@ -0,0 +1,12 @@
/** 显示格式化工具。 */
/**
* 时间展示:把后端的时间串规范为 `YYYY-MM-DD HH:mm:ss`。
*
* Pydantic 序列化 datetime 为 ISO 8601(含 `T`),直接展示不友好;
* 值缺失(空串/null/undefined)时返回占位符而非空白。
*/
export function formatDateTime(value?: string | null, placeholder = '—'): string {
if (!value) return placeholder
return value.replace('T', ' ').slice(0, 19)
}
+38
View File
@@ -0,0 +1,38 @@
/** 表单校验工具。 */
/**
* 密码校验:按 **UTF-8 字节数** 而非字符数。
*
* 后端 bcrypt 对超过 72 字节的密码直接拒绝;中文一个字符占 3 字节,
* 24 个汉字即到顶 —— 按字符数校验(如 maxlength=20)根本拦不住,
* 会出现"前端放行、后端 400"的错位体验。与后端 `PASSWORD_*_BYTES` 对齐。
*/
export const PASSWORD_MIN_BYTES = 8
export const PASSWORD_MAX_BYTES = 72
const BYTE_HINT = '(中文一个字符约 3 字节)'
/** 计算 UTF-8 字节数 */
export function passwordByteLength(value: string): number {
return new TextEncoder().encode(value).length
}
/** Element Plus 表单校验器(用于 el-form 的 rules.validator) */
export function passwordRule(
_rule: unknown,
value: string,
callback: (error?: Error) => void,
): void {
if (!value) {
callback(new Error('请输入密码'))
return
}
const length = passwordByteLength(value)
if (length < PASSWORD_MIN_BYTES) {
callback(new Error(`密码不足 ${PASSWORD_MIN_BYTES} 字节${BYTE_HINT}`))
} else if (length > PASSWORD_MAX_BYTES) {
callback(new Error(`密码超过 ${PASSWORD_MAX_BYTES} 字节上限${BYTE_HINT}`))
} else {
callback()
}
}
+318 -66
View File
@@ -1,98 +1,350 @@
<script setup lang="ts"> <script setup lang="ts">
import { ElMessage } from 'element-plus' import { onMounted, ref } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { useUserStore } from '@/store/user' import * as businessRoleApi from '@/api/businessRoles'
import * as paramsApi from '@/api/params'
import { handledError } from '@/utils/error'
const router = useRouter() const router = useRouter()
const userStore = useUserStore()
async function handleLogout(): Promise<void> { /**
await userStore.logout() * 首页仪表盘(Bento Grid,2026-10-12 视觉方向)。
ElMessage.success('已退出登录') *
await router.replace('/login') * M1 阶段:计提参数与岗位配置已就绪(真实数据);绩效/回款/待办等业务卡
} * 展示空态 —— 业务数据管理上线后接入真实统计(图表用 ECharts 渲染)。
* 空态文案遵循全局规范:空态是邀请,说明「这里将出现什么、怎么让它出现」。
*/
const roleCount = ref<number | null>(null)
const paramCount = ref<number | null>(null)
const latestVersion = ref<string | null>(null)
onMounted(() => {
// 三项统计各自容错:一张卡失败不影响其余卡片
void (async () => {
try {
const page = await businessRoleApi.listBusinessRoles({ pageNum: 1, pageSize: 1 })
roleCount.value = page.total
} catch (error) {
handledError(error)
}
})()
void (async () => {
try {
const catalog = await paramsApi.getCatalog()
paramCount.value = catalog.length
} catch (error) {
handledError(error)
}
})()
void (async () => {
try {
const page = await paramsApi.listBatches({ pageNum: 1, pageSize: 1 })
latestVersion.value = page.list[0]?.version_no ?? null
} catch (error) {
handledError(error)
}
})()
})
</script> </script>
<template> <template>
<el-container class="layout"> <div>
<el-header class="layout-header"> <div class="page-head">
<span class="layout-title">衍水集团营销人员工资管理系统</span> <h1 class="page-title">仪表盘</h1>
<div class="layout-user"> <p class="page-desc">系统概览 · 计提参数与岗位配置就绪,业务数据管理上线后接入核算统计</p>
<el-tag v-for="role in userStore.roles" :key="role" size="small" type="info"> <span class="waterline"></span>
{{ role }} </div>
</el-tag>
<span class="layout-username">{{ userStore.displayName }}</span> <div class="bento">
<el-button link type="primary" @click="handleLogout">退出登录</el-button> <!-- 绩效总额:业务上线前的空态 -->
<div class="bento-card b-2x2">
<div class="b-eyebrow">本月绩效总额</div>
<div class="b-empty">
<div class="b-empty-title">暂无核算数据</div>
<div class="b-empty-desc">回款录入并核算后,这里将显示本月绩效总额与趋势</div>
</div>
</div> </div>
</el-header>
<el-main class="layout-main"> <!-- 回款趋势:业务上线前的空态 -->
<el-card shadow="never"> <div class="bento-card b-2x2">
<template #header> <div class="b-eyebrow">近 6 个月回款</div>
<span>登录状态验证通过</span> <div class="b-empty">
</template> <div class="b-empty-title">暂无回款数据</div>
<div class="b-empty-desc">业务数据管理上线后,此处展示按月回款趋势</div>
</div>
</div>
<el-descriptions :column="2" border> <!-- 待办 -->
<el-descriptions-item label="用户 ID">{{ userStore.userInfo?.id }}</el-descriptions-item> <div class="bento-card b-2x1">
<el-descriptions-item label="用户名">{{ userStore.userInfo?.username }}</el-descriptions-item> <div class="b-eyebrow">待办</div>
<el-descriptions-item label="姓名"> <div class="b-empty-mini">业务数据管理上线后,待核算回款会出现在这里</div>
{{ userStore.userInfo?.real_name || '—' }} </div>
</el-descriptions-item>
<el-descriptions-item label="角色">
{{ userStore.roles.join('、') || '—' }}
</el-descriptions-item>
<el-descriptions-item label="上次登录">
{{ userStore.userInfo?.last_login_time || '—' }}
</el-descriptions-item>
</el-descriptions>
<el-alert <!-- 营销人员:人员管理上线后接入 -->
class="layout-hint" <div class="bento-card">
type="info" <div class="b-eyebrow">营销人员</div>
:closable="false" <div class="b-empty-mini">人员管理上线后同步</div>
show-icon </div>
title="这是登录链路的验证页"
description="刷新页面可验证会话恢复(守卫会调用 /me);点击右上角退出登录可验证令牌即时失效。业务模块页面将在后续阶段接入。" <!-- 岗位角色:真实数据 -->
/> <div class="bento-card">
</el-card> <div class="b-eyebrow">岗位角色</div>
</el-main> <div class="b-num">{{ roleCount ?? '—' }}<small>个</small></div>
</el-container> <button class="b-link" type="button" @click="router.push('/system/business-roles')">
管理 ›
</button>
</div>
<!-- 计提参数:真实数据 -->
<div class="bento-card">
<div class="b-eyebrow">计提参数</div>
<div class="b-num">{{ paramCount ?? '—' }}<small>项</small></div>
<button class="b-link" type="button" @click="router.push('/system/params')">
维护 ›
</button>
</div>
<!-- 常用操作 -->
<div class="bento-card b-2x1">
<div class="b-eyebrow">常用操作</div>
<div class="b-links">
<button class="b-op" type="button" @click="router.push('/system/params')">
计提参数
</button>
<button class="b-op" type="button" @click="router.push('/system/business-roles')">
岗位角色
</button>
<button class="b-op" type="button" @click="router.push('/system/logs')">
操作日志
</button>
<span class="b-op b-op-disabled" title="业务数据管理上线后开放">录入回款</span>
</div>
</div>
<!-- T+ 同步 -->
<div class="bento-card">
<div class="b-eyebrow">T+ 同步</div>
<div class="b-state">待接入</div>
<div class="b-sub">每周一次 · 手动触发</div>
</div>
</div>
</div>
</template> </template>
<style scoped> <style scoped>
.layout { .page-head {
height: 100%; margin-bottom: 18px;
} }
.layout-header { .page-title {
display: flex; margin: 0;
align-items: center; font-size: 18px;
justify-content: space-between;
color: #fff;
background-color: #1f4e79;
}
.layout-title {
font-size: 16px;
font-weight: 600; font-weight: 600;
letter-spacing: -0.01em;
color: var(--ys-ink);
} }
.layout-user { .page-desc {
margin: 4px 0 0;
font-size: 12px;
color: var(--ys-ink-3);
}
.waterline {
display: block;
width: 56px;
height: 2px;
margin-top: 10px;
border-radius: 1px;
background: linear-gradient(90deg, var(--ys-indigo), rgba(46, 75, 168, 0));
}
/* Bento Grid:4 列基格,卡片按重要性混排大小 */
.bento {
display: grid;
grid-template-columns: repeat(4, 1fr);
grid-auto-rows: 102px;
gap: 14px;
}
.bento-card {
display: flex; display: flex;
flex-direction: column;
padding: 17px 20px;
overflow: hidden;
background: linear-gradient(180deg, #ffffff, #fcfcfe);
border: 1px solid var(--ys-line);
border-radius: 10px;
box-shadow:
0 1px 2px rgba(28, 35, 51, 0.04),
0 3px 10px rgba(28, 35, 51, 0.03);
transition:
transform 180ms cubic-bezier(0.25, 0.1, 0.25, 1),
box-shadow 180ms cubic-bezier(0.25, 0.1, 0.25, 1);
}
.bento-card:hover {
transform: translateY(-2px);
box-shadow:
0 1px 2px rgba(28, 35, 51, 0.05),
0 6px 18px rgba(28, 35, 51, 0.07);
}
.b-2x2 {
grid-column: span 2;
grid-row: span 2;
}
.b-2x1 {
grid-column: span 2;
}
.b-eyebrow {
font-size: 12px;
font-weight: 500;
letter-spacing: 0.04em;
color: var(--ys-ink-2);
}
.b-num {
margin-top: auto;
font-size: 24px;
font-weight: 600;
font-variant-numeric: tabular-nums;
line-height: 1.2;
letter-spacing: -0.01em;
color: var(--ys-ink);
}
.b-num small {
margin-left: 3px;
font-size: 12px;
font-weight: 400;
color: var(--ys-ink-3);
}
.b-sub {
margin-top: 3px;
font-size: 11px;
color: var(--ys-ink-3);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.b-state {
margin-top: auto;
font-size: 15px;
font-weight: 600;
color: var(--ys-ink-2);
}
.b-empty {
display: flex;
flex: 1;
flex-direction: column;
align-items: center; align-items: center;
gap: 10px; justify-content: center;
text-align: center;
} }
.layout-username { .b-empty-title {
font-size: 14px; font-size: 14px;
font-weight: 500;
color: var(--ys-ink-2);
} }
.layout-main { .b-empty-desc {
padding: 20px; max-width: 260px;
margin-top: 6px;
font-size: 12px;
color: var(--ys-ink-3);
line-height: 1.7;
} }
.layout-hint { .b-empty-mini {
margin-top: 18px; margin-top: auto;
margin-bottom: auto;
font-size: 12px;
color: var(--ys-ink-3);
line-height: 1.7;
}
.b-links {
display: flex;
gap: 8px;
align-items: center;
margin-top: auto;
}
.b-link {
padding: 0;
margin-top: 6px;
font-size: 12px;
color: var(--ys-indigo);
text-align: left;
cursor: pointer;
background: none;
border: none;
}
.b-link:hover {
color: var(--ys-indigo-deep);
}
.b-op {
height: 30px;
padding: 0 13px;
font-size: 12px;
font-family: inherit;
color: var(--ys-ink);
cursor: pointer;
background: linear-gradient(180deg, #ffffff, #fbfcfe);
border: 1px solid var(--ys-line);
border-radius: 7px;
transition:
color 150ms cubic-bezier(0.25, 0.1, 0.25, 1),
border-color 150ms cubic-bezier(0.25, 0.1, 0.25, 1);
}
button.b-op:hover {
color: var(--ys-indigo);
border-color: rgba(46, 75, 168, 0.45);
}
.b-op-disabled {
color: var(--ys-ink-3);
cursor: not-allowed;
}
@media (max-width: 1024px) {
.bento {
grid-template-columns: repeat(2, 1fr);
}
}
@media (max-width: 640px) {
.bento {
grid-template-columns: 1fr;
grid-auto-rows: auto;
}
.bento-card {
min-height: 90px;
}
.b-2x2,
.b-2x1 {
grid-column: span 1;
grid-row: span 1;
min-height: 130px;
}
}
@media (prefers-reduced-motion: reduce) {
.bento-card {
transition: none;
}
} }
</style> </style>
+149 -233
View File
@@ -77,53 +77,29 @@ onMounted(refreshCaptcha)
<template> <template>
<div class="login"> <div class="login">
<!-- 品牌面板:以「水」为意象 —— 公司名衍水,业务是流水之账 --> <!-- 背景装饰:右上涟漪同心圆(「衍水」品牌意象);顶部天光由 .login::before 承担 -->
<section class="brand"> <div class="login-ripple" aria-hidden="true">
<!-- 水纹层理:越往下越密,如潜向深处。纯装饰,对读屏隐藏 --> <i></i><i></i><i></i><i></i>
<svg class="brand-flow" viewBox="0 0 600 1000" preserveAspectRatio="xMidYMid slice" aria-hidden="true"> </div>
<path class="brand-flow__line" style="--i: 0" d="M-40,150 C 150,95 260,205 420,155 S 620,95 660,120" />
<path class="brand-flow__line" style="--i: 1" d="M-40,265 C 170,200 300,320 460,255 S 630,190 660,215" />
<path class="brand-flow__line" style="--i: 2" d="M-40,372 C 160,318 320,420 470,360 S 640,300 660,320" />
<path class="brand-flow__line" style="--i: 3" d="M-40,470 C 180,428 310,510 480,462 S 645,410 660,425" />
<path class="brand-flow__line" style="--i: 4" d="M-40,562 C 150,532 330,596 470,560 S 640,520 660,530" />
<path class="brand-flow__line" style="--i: 5" d="M-40,650 C 190,626 300,678 490,648 S 650,618 660,624" />
<path class="brand-flow__line" style="--i: 6" d="M-40,732 C 160,714 320,752 460,730 S 640,708 660,712" />
<path class="brand-flow__line" style="--i: 7" d="M-40,810 C 175,798 305,826 485,808 S 645,792 660,795" />
<path class="brand-flow__line" style="--i: 8" d="M-40,882 C 155,873 330,894 470,880 S 645,868 660,870" />
<path class="brand-flow__line" style="--i: 9" d="M-40,946 C 185,939 300,955 490,944 S 650,934 660,935" />
</svg>
<!-- 品牌内容整体居中成块。此前用 space-between 把品牌与标语顶到两端, <div class="login-wrap">
中间空出大片,两处都成了孤岛;聚成一块才有构图 --> <div class="login-card">
<div class="brand-inner"> <div class="card-brand">
<div class="brand-block"> <div class="card-brand__name">衍水集团</div>
<h1 class="brand-name">衍水集团</h1> <p class="card-brand__slogan">把每一笔回款,算成一份踏实。</p>
<hr class="brand-rule" /> <span class="waterline"></span>
<p class="brand-system">营销人员工资管理系统</p>
<!-- 断行位置显式控制:中文可在任意字间断行,交给浏览器自动换行
会把「楚」这样的单字挤到末行 -->
<p class="brand-hero">
<span>每一笔回款,</span>
<span>都算得清楚</span>
</p>
<p class="brand-stages">回款 · 计提 · 核算 · 发放</p>
</div> </div>
</div>
</section>
<!-- 表单面板 --> <div class="card-head">
<section class="panel"> <h2 class="card-title">欢迎回来</h2>
<div class="panel-inner"> <p class="card-hint">请输入账号密码进入系统</p>
<h2 class="panel-title">登录</h2> </div>
<p class="panel-hint">请使用管理员分配的账号</p>
<el-form <el-form
ref="formRef" ref="formRef"
:model="form" :model="form"
:rules="rules" :rules="rules"
label-position="top" label-position="top"
size="large"
@keyup.enter="handleSubmit" @keyup.enter="handleSubmit"
> >
<el-form-item label="用户名" prop="username"> <el-form-item label="用户名" prop="username">
@@ -173,175 +149,149 @@ onMounted(refreshCaptcha)
</el-button> </el-button>
</el-form-item> </el-form-item>
</el-form> </el-form>
<p class="panel-foot">衍水集团 · 内部系统</p>
</div> </div>
</section>
<p class="login-foot">© 衍水集团 · 内部系统</p>
</div>
</div> </div>
</template> </template>
<style scoped> <style scoped>
/* /* 视觉体系(2026-10-12 确认):企业级扁平 · 轻质感 · 品牌色点缀 · 微动效 */
* 配色说明:金色分两档 —— 亮金只用于深色底、深金只用于浅色底,
* 保证两处对比度都达标(深金配白字约 6.2:1)。
*/
.login { .login {
--ink-900: #061a22;
--ink-800: #0a2833;
--ink-700: #0f3646;
--ink-600: #1a4f63;
--gold-bright: #c9a227;
--gold-deep: #8f6520;
--paper: #fafaf7;
--text: #16262d;
--muted: #6b7b84;
display: flex;
min-height: 100vh;
}
/* ============ 品牌面板 ============ */
.brand {
position: relative; position: relative;
display: flex; display: flex;
flex: 1 1 56%; flex-direction: column;
align-items: center;
justify-content: center;
min-height: 100vh;
padding: 40px 24px;
overflow: hidden; overflow: hidden;
background: linear-gradient(158deg, var(--ink-800) 0%, var(--ink-900) 62%, #04141b 100%); background-color: #f2f4f8;
/* 点阵网格:秩序与密度 */
background-image: radial-gradient(rgba(28, 35, 51, 0.07) 1px, transparent 1px);
background-size: 24px 24px;
} }
/* 水下的光:给居中的内容一个落点,否则深色面板会显得空 */ /* 顶部天光:靛蓝微光从上方洒下,给页面空气感 */
.brand::before { .login::before {
position: absolute;
inset: 0;
content: ''; content: '';
background: radial-gradient(58% 46% at 32% 46%, rgb(32 116 146 / 26%), transparent 72%); position: absolute;
top: -180px;
left: 50%;
width: 900px;
height: 480px;
transform: translateX(-50%);
pointer-events: none;
background: radial-gradient(closest-side, rgba(46, 75, 168, 0.09), rgba(46, 75, 168, 0));
}
/* 右上涟漪:同心圆水波,部分出画 */
.login-ripple {
position: absolute;
top: -190px;
right: -150px;
width: 560px;
height: 560px;
pointer-events: none; pointer-events: none;
} }
.brand-flow { .login-ripple i {
position: absolute; position: absolute;
inset: 0; inset: 0;
width: 100%; border-radius: 50%;
height: 100%; border: 1px solid rgba(46, 75, 168, 0.1);
} }
.brand-flow__line { .login-ripple i:nth-child(2) {
fill: none; inset: 70px;
stroke: var(--ink-600); border-color: rgba(46, 75, 168, 0.075);
stroke-width: 1;
opacity: 0.55;
/* 逐条画出:一个统一的入场动作,而非散落特效 */
stroke-dasharray: 1200;
stroke-dashoffset: 1200;
animation: flow-draw 2.4s cubic-bezier(0.22, 0.61, 0.36, 1) forwards;
animation-delay: calc(var(--i) * 0.13s);
} }
@keyframes flow-draw { .login-ripple i:nth-child(3) {
to { inset: 140px;
stroke-dashoffset: 0; border-color: rgba(46, 75, 168, 0.055);
}
} }
.brand-inner { .login-ripple i:nth-child(4) {
inset: 210px;
border: none;
background: radial-gradient(circle, rgba(46, 75, 168, 0.05), rgba(46, 75, 168, 0.015));
}
.login-wrap {
position: relative; position: relative;
z-index: 1;
display: flex; display: flex;
flex-direction: column;
align-items: center; align-items: center;
width: 100%;
padding: 56px clamp(40px, 6vw, 104px);
} }
/* 内容成块并限宽:超宽屏下文字块不会被拉散 */ /* 登录卡:纸白微渐变 + 半透明描边 + 分层阴影 + 入场上浮 */
.brand-block { .login-card {
max-width: 30rem; width: 400px;
max-width: 100%;
padding: 30px 36px 32px;
overflow: hidden;
animation: card-rise 320ms cubic-bezier(0.25, 0.1, 0.25, 1) backwards;
background: linear-gradient(180deg, #ffffff, #fcfcfe);
border: 1px solid rgba(28, 35, 51, 0.09);
border-radius: 14px;
box-shadow:
0 2px 4px rgba(28, 35, 51, 0.04),
0 16px 40px rgba(28, 35, 51, 0.1);
} }
.brand-name { @keyframes card-rise {
margin: 0;
font-family: 'Source Han Serif SC', 'Noto Serif SC', 'Songti SC', 'SimSun', serif;
font-size: clamp(36px, 3.6vw, 54px);
font-weight: 600;
line-height: 1.15;
letter-spacing: 0.2em;
color: #f2f6f7;
}
.brand-rule {
width: 52px;
height: 2px;
margin: 24px 0 18px;
background-color: var(--gold-bright);
border: 0;
}
.brand-system {
margin: 0;
font-size: clamp(13px, 1.1vw, 16px);
font-weight: 300;
letter-spacing: 0.28em;
color: #9db6c0;
}
.brand-hero {
margin: 58px 0 0;
font-size: clamp(16px, 1.4vw, 21px);
font-weight: 400;
line-height: 1.8;
letter-spacing: 0.06em;
color: #d9e5ea;
}
.brand-hero span {
display: block;
}
/* 三个业务环节:用间隔点连成一行,比带框的标签轻得多,
也不会被误读成可点击的按钮 */
.brand-stages {
margin: 18px 0 0;
font-size: 12px;
letter-spacing: 0.24em;
color: #6f8b97;
}
/* ============ 表单面板 ============ */
.panel {
display: flex;
flex: 1 1 44%;
align-items: center;
justify-content: center;
padding: 48px clamp(28px, 5vw, 80px);
background-color: var(--paper);
}
.panel-inner {
width: 100%;
max-width: 356px;
animation: panel-rise 0.7s cubic-bezier(0.22, 0.61, 0.36, 1) 0.35s both;
}
@keyframes panel-rise {
from { from {
opacity: 0; opacity: 0;
transform: translateY(14px); transform: translateY(8px);
} }
} }
.panel-title { .card-brand {
margin: 0 0 6px; text-align: center;
font-size: 25px;
font-weight: 600;
letter-spacing: 0.16em;
color: var(--text);
} }
.panel-hint { .card-brand__name {
margin: 0 0 30px; font-family: var(--ys-font-brand);
font-size: 13px; font-size: 17px;
color: var(--muted); font-weight: 500;
letter-spacing: 0.34em;
color: var(--ys-ink);
}
.card-brand__slogan {
margin-top: 8px;
font-size: 12.5px;
letter-spacing: 0.02em;
color: var(--ys-ink-3);
}
/* 签名元素:渐隐靛蓝水线 */
.waterline {
display: block;
width: 40px;
height: 2px;
margin: 14px auto 0;
border-radius: 1px;
background: linear-gradient(90deg, var(--ys-indigo), rgba(46, 75, 168, 0));
}
.card-head {
margin-top: 26px;
}
.card-title {
margin: 0;
font-size: 15px;
font-weight: 600;
color: var(--ys-ink);
}
.card-hint {
margin: 4px 0 0;
font-size: 12px;
color: var(--ys-ink-3);
} }
.captcha { .captcha {
@@ -370,14 +320,15 @@ onMounted(refreshCaptcha)
padding: 0; padding: 0;
overflow: hidden; overflow: hidden;
cursor: pointer; cursor: pointer;
background-color: #fff; background: linear-gradient(180deg, #f7f8fb, #f1f3f8);
border: 1px solid #dcdfe6; border: 1px solid var(--ys-line);
border-radius: 4px; border-radius: 7px;
transition: border-color 0.2s; box-shadow: inset 0 1px 2px rgba(28, 35, 51, 0.04);
transition: border-color 150ms var(--ys-ease, ease);
} }
.captcha__image:hover { .captcha__image:hover {
border-color: var(--gold-deep); border-color: var(--ys-indigo);
} }
.captcha__image img { .captcha__image img {
@@ -389,89 +340,54 @@ onMounted(refreshCaptcha)
.captcha__placeholder { .captcha__placeholder {
font-size: 12px; font-size: 12px;
color: var(--muted); color: var(--ys-ink-3);
} }
.form-actions :deep(.el-form-item__content) { .form-actions :deep(.el-form-item__content) {
margin-top: 4px; margin-top: 4px;
} }
/* 覆盖 Element Plus 的按钮变量而非直接写 background-color ——
后者与 .el-button--primary 同权重,会受样式注入顺序影响而不稳定 */
.submit { .submit {
width: 100%; width: 100%;
letter-spacing: 0.4em; letter-spacing: 0.4em;
--el-button-bg-color: var(--gold-deep);
--el-button-border-color: var(--gold-deep);
--el-button-hover-bg-color: #a3762a;
--el-button-hover-border-color: #a3762a;
--el-button-active-bg-color: #7d5719;
--el-button-active-border-color: #7d5719;
} }
.panel-foot { .login-foot {
margin: 32px 0 0; margin: 16px 0 0;
font-size: 12px; font-size: 11px;
text-align: center; letter-spacing: 0.08em;
letter-spacing: 0.1em; color: var(--ys-ink-3);
color: #9aa8af;
} }
/* 输入框聚焦态也落在同一套配色上 */ /* 输入聚焦落在体系主色上(Element Plus 主色已随全局 token 换为靛蓝) */
.panel :deep(.el-input__wrapper) { .login :deep(.el-input__wrapper) {
--el-input-focus-border-color: var(--gold-deep); box-shadow: 0 0 0 1px rgba(28, 35, 51, 0.13) inset;
} }
/* ============ 响应式 ============ */ .login :deep(.el-input__wrapper.is-focus) {
box-shadow:
0 0 0 1px var(--ys-indigo) inset,
0 0 0 3px rgba(46, 75, 168, 0.13);
}
@media (max-width: 900px) { @media (max-width: 640px) {
.login { .login {
flex-direction: column; padding: 24px 16px;
} }
.brand { .login-card {
flex: 0 0 auto; padding: 26px 24px 28px;
padding: 36px 26px 32px;
} }
.brand-inner { /* 窄屏上涟漪出画更多,避免挤压卡片 */
padding: 0; .login-ripple {
} top: -260px;
right: -240px;
.brand-flow {
opacity: 0.5;
}
.brand-name {
font-size: 30px;
letter-spacing: 0.16em;
}
.brand-rule {
margin: 16px 0 12px;
}
/* 窄屏上只留品牌识别,标语与环节行让位给表单 */
.brand-hero,
.brand-stages {
display: none;
}
.panel {
flex: 1;
padding: 38px 26px 46px;
} }
} }
/* 尊重系统的「减弱动效」偏好:保留终态,去掉过渡 */
@media (prefers-reduced-motion: reduce) { @media (prefers-reduced-motion: reduce) {
.brand-flow__line { .login-card {
stroke-dashoffset: 0;
animation: none;
}
.panel-inner {
opacity: 1;
animation: none; animation: none;
} }
} }
@@ -0,0 +1,374 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'
import { ElMessage, type FormInstance, type FormRules } from 'element-plus'
import * as businessRoleApi from '@/api/businessRoles'
import type { BusinessRoleItem, TemplateOption } from '@/api/businessRoles'
import AppPagination from '@/components/table/AppPagination.vue'
import StatusTag from '@/components/table/StatusTag.vue'
import { useConfirmAction } from '@/composables/useConfirmAction'
import { usePagedList } from '@/composables/usePagedList'
import { STATUS_OPTIONS } from '@/utils/constants'
import { handledError } from '@/utils/error'
/**
* 岗位角色管理(需求 #28)。
*
* 与后端约定对齐:
* - 内置岗位(营销员/营销经理/信息员)禁删禁停 —— 前端禁用按钮,后端仍会拦截
* - 岗位编码创建后不可修改(它是参数维度值与模板绑定的锚点)
* - 公式模板为受控下拉(代码内注册表的有限集合);新增岗位 = 选模板 + 配参数,
* 无需改代码
*/
const { confirmAndRun } = useConfirmAction()
// ==================== 列表 ====================
const filters = reactive({
keyword: '',
status: undefined as number | undefined,
})
const { records, total, pageNum, pageSize, loading, load, search, onPageChange, onSizeChange, reloadAfterDelete } =
usePagedList<BusinessRoleItem, businessRoleApi.BusinessRoleQueryParams>(
businessRoleApi.listBusinessRoles,
() => ({
keyword: filters.keyword,
status: filters.status,
}),
)
const templateOptions = ref<TemplateOption[]>([])
async function loadTemplateOptions(): Promise<void> {
try {
templateOptions.value = await businessRoleApi.listTemplateOptions()
} catch (error) {
handledError(error) // 拦截器已提示;下拉为空不阻断列表使用
}
}
/** 模板编码 → 名称(表格列展示用,下拉未加载完成时回退显示编码) */
function templateName(code: string): string {
return templateOptions.value.find((tpl) => tpl.code === code)?.name ?? code
}
function resetFilters(): void {
filters.keyword = ''
filters.status = undefined
search()
}
// ==================== 新增 / 编辑 ====================
const dialogVisible = ref(false)
const editingId = ref<number | null>(null)
/** 单一状态源:编辑中 ⇔ editingId 非空(避免双状态漂移) */
const isEdit = computed(() => editingId.value !== null)
const formRef = ref<FormInstance>()
const submitting = ref(false)
const form = reactive({
role_code: '',
role_name: '',
calc_template: '',
description: '',
sort_order: 0,
})
/** 编辑中的行(内置岗位的模板仍可改,但编码与删除/停用受保护) */
const editingRow = computed(() =>
editingId.value === null ? null : records.value.find((r) => r.id === editingId.value) ?? null,
)
const rules = computed<FormRules>(() => ({
role_code: isEdit.value
? []
: [
{ required: true, message: '请输入岗位编码', trigger: 'blur' },
{ pattern: /^[a-z][a-z0-9_]{1,31}$/, message: '编码须以小写字母开头,仅可含小写字母、数字与下划线', trigger: 'blur' },
],
role_name: [
{ required: true, message: '请输入岗位名称', trigger: 'blur' },
{ max: 64, message: '岗位名称最长 64 个字符', trigger: 'blur' },
],
calc_template: [{ required: true, message: '请选择计提公式模板', trigger: 'change' }],
description: [{ max: 255, message: '说明最长 255 个字符', trigger: 'blur' }],
}))
const selectedTemplate = computed(() =>
templateOptions.value.find((tpl) => tpl.code === form.calc_template) ?? null,
)
function openCreate(): void {
editingId.value = null
Object.assign(form, {
role_code: '',
role_name: '',
calc_template: '',
description: '',
sort_order: 0,
})
dialogVisible.value = true
formRef.value?.clearValidate()
}
function openEdit(row: BusinessRoleItem): void {
editingId.value = row.id
Object.assign(form, {
role_code: row.role_code,
role_name: row.role_name,
calc_template: row.calc_template,
description: row.description ?? '',
sort_order: row.sort_order,
})
dialogVisible.value = true
formRef.value?.clearValidate()
}
async function handleSubmit(): Promise<void> {
const valid = await formRef.value?.validate().catch(() => false)
if (!valid) return
submitting.value = true
try {
if (editingId.value !== null) {
await businessRoleApi.updateBusinessRole(editingId.value, {
role_name: form.role_name,
calc_template: form.calc_template,
description: form.description,
sort_order: form.sort_order,
})
ElMessage.success('已保存')
} else {
await businessRoleApi.createBusinessRole({
role_code: form.role_code,
role_name: form.role_name,
calc_template: form.calc_template,
description: form.description || undefined,
sort_order: form.sort_order,
})
ElMessage.success('岗位已创建')
}
dialogVisible.value = false
await load()
} catch (error) {
handledError(error) // 失败提示由请求拦截器统一完成
} finally {
submitting.value = false
}
}
// ==================== 启停 / 删除 ====================
/** 内置岗位禁删禁停(按钮禁用 + title 说明原因) */
function isBuiltin(row: BusinessRoleItem): boolean {
return row.is_builtin === 1
}
function builtinHint(row: BusinessRoleItem): string {
return isBuiltin(row) ? '内置岗位不可删除或停用' : ''
}
async function handleToggleStatus(row: BusinessRoleItem): Promise<void> {
const next = row.status === 1 ? 0 : 1
const action = next === 1 ? '启用' : '禁用'
await confirmAndRun({
confirmText:
next === 0
? `确定禁用岗位「${row.role_name}」?禁用后新核算不再匹配该岗位。`
: `确定启用岗位「${row.role_name}」?`,
title: `${action}确认`,
confirmButtonText: action,
successText: `已${action}`,
run: () => businessRoleApi.setBusinessRoleStatus(row.id, next),
after: load,
})
}
async function handleDelete(row: BusinessRoleItem): Promise<void> {
await confirmAndRun({
confirmText: `确定删除岗位「${row.role_name}」?删除为物理删除、不可恢复;该岗位编码相关的参数维度值不会随之清理。`,
title: '删除确认',
confirmButtonText: '删除',
successText: '岗位已删除',
run: () => businessRoleApi.deleteBusinessRole(row.id),
after: reloadAfterDelete,
})
}
// ==================== 初始化 ====================
onMounted(() => {
// 两个请求各自容错(load 内部已兜底),互不拖累
void load()
void loadTemplateOptions()
})
</script>
<template>
<div>
<el-card shadow="never">
<el-form class="filter-form" :inline="true" @submit.prevent>
<el-form-item label="关键词">
<el-input
v-model="filters.keyword"
placeholder="岗位编码或名称"
clearable
style="width: 180px"
@keyup.enter="search"
/>
</el-form-item>
<el-form-item label="状态">
<el-select v-model="filters.status" placeholder="全部" clearable style="width: 110px">
<el-option
v-for="item in STATUS_OPTIONS"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="search">查询</el-button>
<el-button @click="resetFilters">重置</el-button>
</el-form-item>
</el-form>
<div class="toolbar">
<el-button type="primary" @click="openCreate">新增岗位</el-button>
</div>
<el-table v-loading="loading" :data="records" stripe>
<el-table-column prop="role_code" label="岗位编码" min-width="130">
<template #default="{ row }">
<span class="role-code">{{ row.role_code }}</span>
</template>
</el-table-column>
<el-table-column prop="role_name" label="岗位名称" min-width="110" />
<el-table-column label="公式模板" min-width="170">
<template #default="{ row }">{{ templateName(row.calc_template) }}</template>
</el-table-column>
<el-table-column prop="description" label="说明" min-width="200">
<template #default="{ row }">{{ row.description || '—' }}</template>
</el-table-column>
<el-table-column prop="sort_order" label="排序" width="70" align="center" />
<el-table-column label="状态" width="90" align="center">
<template #default="{ row }">
<StatusTag :value="row.status" />
</template>
</el-table-column>
<el-table-column label="操作" width="190" fixed="right">
<template #default="{ row }">
<el-button link type="primary" size="small" @click="openEdit(row)">编辑</el-button>
<span :title="builtinHint(row)">
<el-button
link
type="warning"
size="small"
:disabled="isBuiltin(row)"
@click="handleToggleStatus(row)"
>
{{ row.status === 1 ? '禁用' : '启用' }}
</el-button>
</span>
<span :title="builtinHint(row)">
<el-button
link
type="danger"
size="small"
:disabled="isBuiltin(row)"
@click="handleDelete(row)"
>
删除
</el-button>
</span>
</template>
</el-table-column>
</el-table>
<AppPagination
:total="total"
:page-num="pageNum"
:page-size="pageSize"
@page-change="onPageChange"
@size-change="onSizeChange"
/>
</el-card>
<!-- 新增 / 编辑 -->
<el-dialog
v-model="dialogVisible"
:title="isEdit ? '编辑岗位' : '新增岗位'"
width="540px"
destroy-on-close
>
<el-form ref="formRef" :model="form" :rules="rules" label-width="90px">
<el-form-item label="岗位编码" prop="role_code">
<el-input
v-model="form.role_code"
:disabled="isEdit"
:placeholder="editingRow ? '' : '如 senior_sales,创建后不可修改'"
/>
</el-form-item>
<el-form-item label="岗位名称" prop="role_name">
<el-input v-model="form.role_name" placeholder="如 高级业务员" />
</el-form-item>
<el-form-item label="公式模板" prop="calc_template">
<el-select
v-model="form.calc_template"
placeholder="选择计提公式模板"
style="width: 100%"
>
<el-option
v-for="tpl in templateOptions"
:key="tpl.code"
:label="tpl.name"
:value="tpl.code"
/>
</el-select>
</el-form-item>
<el-form-item v-if="selectedTemplate" label-width="90px">
<div class="tpl-info">
<div>{{ selectedTemplate.description }}</div>
<div v-if="selectedTemplate.params.length" class="tpl-params">
涉及参数:{{ selectedTemplate.params.map((p) => p.name).join('、') }}(在「计提参数」中维护)
</div>
</div>
</el-form-item>
<el-form-item label="岗位说明" prop="description">
<el-input v-model="form.description" type="textarea" :rows="2" placeholder="选填" />
</el-form-item>
<el-form-item label="排序" prop="sort_order">
<el-input-number v-model="form.sort_order" :min="0" :max="9999" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" :loading="submitting" @click="handleSubmit">保存</el-button>
</template>
</el-dialog>
</div>
</template>
<style scoped>
.role-code {
font-family: 'SFMono-Regular', Consolas, 'Courier New', monospace;
font-size: 12px;
color: var(--ys-ink-2);
}
.tpl-info {
width: 100%;
padding: 8px 12px;
border-radius: 6px;
background: var(--ys-indigo-wash);
color: var(--ys-ink-2);
font-size: 12px;
line-height: 1.7;
}
.tpl-params {
color: var(--ys-ink-3);
}
</style>
+393
View File
@@ -0,0 +1,393 @@
<script setup lang="ts">
import { onMounted, reactive, ref, watch } from 'vue'
import * as logApi from '@/api/logs'
import type {
LoginLogItem,
LoginLogQueryParams,
OperationLogDetail,
OperationLogItem,
OperationLogQueryParams,
} from '@/api/logs'
import StatusTag from '@/components/table/StatusTag.vue'
import AppPagination from '@/components/table/AppPagination.vue'
import { usePagedList } from '@/composables/usePagedList'
import { ACTION_OPTIONS, MODULE_OPTIONS, RESULT_OPTIONS } from '@/utils/constants'
import { handledError } from '@/utils/error'
import { formatDateTime } from '@/utils/format'
/**
* 日志查询(需求 2.5.1「支持日志查询」)。
*
* 操作日志与登录日志分开两个 tab(后端也是两个端点:字段与筛选项不同)。
*
* 时间筛选的两个关键点:
* - 必须给 el-date-picker 指定 `value-format` —— 否则绑定值是 Date 对象,
* axios 会序列化为 UTC ISO 串,随本地时区产生 8 小时偏移
* - 必须配 `default-time` —— datetimerange 只选日期时时刻默认 00:00:00,
* 跨天查询会漏掉结束日整天(起点补 00:00:00、终点补 23:59:59)
*
* 操作日志列表**不含请求参数**(大字段,随列表返回会让翻页负担随日志表
* 增长);展开行时按 id 调详情端点懒加载并缓存。
*/
/** datetimerange 的起止时刻补全(与 value-format 配合) */
const RANGE_DEFAULT_TIME: [Date, Date] = [
new Date(2000, 0, 1, 0, 0, 0),
new Date(2000, 0, 1, 23, 59, 59),
]
const activeTab = ref('operation')
// ==================== 操作日志 ====================
const opFilters = reactive({
username: '',
module: undefined as string | undefined,
action: undefined as string | undefined,
success: undefined as number | undefined,
})
const opTimeRange = ref<[string, string] | null>(null)
const {
records: opRecords,
total: opTotal,
pageNum: opPageNum,
pageSize: opPageSize,
loading: opLoading,
load: loadOperationLogs,
search: searchOperationLogs,
onPageChange: onOpPageChange,
onSizeChange: onOpSizeChange,
} = usePagedList<OperationLogItem, OperationLogQueryParams>(logApi.listOperationLogs, () => ({
username: opFilters.username,
module: opFilters.module,
action: opFilters.action,
success: opFilters.success,
start_time: opTimeRange.value?.[0],
end_time: opTimeRange.value?.[1],
}))
function resetOpFilters(): void {
opFilters.username = ''
opFilters.module = undefined
opFilters.action = undefined
opFilters.success = undefined
opTimeRange.value = null
searchOperationLogs()
}
/** 详情缓存与加载中集合(列表无 request_data,展开时按需拉取) */
const detailMap = reactive(new Map<number, OperationLogDetail>())
const detailLoading = reactive(new Set<number>())
async function onExpandChange(row: OperationLogItem, expandedRows: OperationLogItem[]): Promise<void> {
const isExpanded = expandedRows.some((item) => item.id === row.id)
if (!isExpanded || detailMap.has(row.id)) return
detailLoading.add(row.id)
try {
detailMap.set(row.id, await logApi.getOperationLog(row.id))
} catch (error) {
handledError(error) // 拦截器已提示,展开处显示占位
} finally {
detailLoading.delete(row.id)
}
}
/** 展开行展示的请求参数:加载中 / 暂无 / 格式化后的 JSON(已脱敏) */
function detailText(logId: number): string {
if (detailLoading.has(logId)) return '加载中…'
const detail = detailMap.get(logId)
if (!detail) return '—'
return formatRequestData(detail.request_data)
}
function formatRequestData(raw: string | null): string {
if (!raw) return '—'
try {
return JSON.stringify(JSON.parse(raw), null, 2)
} catch {
return raw
}
}
// ==================== 登录日志 ====================
const loginFilters = reactive({
username: '',
ip: '',
success: undefined as number | undefined,
})
const loginTimeRange = ref<[string, string] | null>(null)
const {
records: loginRecords,
total: loginTotal,
pageNum: loginPageNum,
pageSize: loginPageSize,
loading: loginLoading,
load: loadLoginLogs,
search: searchLoginLogs,
onPageChange: onLoginPageChange,
onSizeChange: onLoginSizeChange,
} = usePagedList<LoginLogItem, LoginLogQueryParams>(logApi.listLoginLogs, () => ({
username: loginFilters.username,
ip: loginFilters.ip,
success: loginFilters.success,
start_time: loginTimeRange.value?.[0],
end_time: loginTimeRange.value?.[1],
}))
function resetLoginFilters(): void {
loginFilters.username = ''
loginFilters.ip = ''
loginFilters.success = undefined
loginTimeRange.value = null
searchLoginLogs()
}
// ==================== 初始化 ====================
onMounted(loadOperationLogs)
// 登录日志 tab 每次激活都刷新(首屏不打多余请求;此前"只加载一次"的
// 标志在首次失败后不会重置,切回时停在空表且无从自查)
watch(activeTab, (tab) => {
if (tab === 'login') {
void loadLoginLogs()
}
})
</script>
<template>
<el-card shadow="never">
<el-tabs v-model="activeTab">
<!-- 操作日志 -->
<el-tab-pane label="操作日志" name="operation" lazy>
<el-form class="filter-form" :inline="true" @submit.prevent>
<el-form-item label="操作人">
<el-input
v-model="opFilters.username"
placeholder="模糊匹配"
clearable
style="width: 140px"
@keyup.enter="searchOperationLogs"
/>
</el-form-item>
<el-form-item label="模块">
<el-select v-model="opFilters.module" placeholder="全部" clearable style="width: 130px">
<el-option v-for="item in MODULE_OPTIONS" :key="item" :label="item" :value="item" />
</el-select>
</el-form-item>
<el-form-item label="操作">
<el-select v-model="opFilters.action" placeholder="全部" clearable style="width: 120px">
<el-option v-for="item in ACTION_OPTIONS" :key="item" :label="item" :value="item" />
</el-select>
</el-form-item>
<el-form-item label="结果">
<el-select v-model="opFilters.success" placeholder="全部" clearable style="width: 110px">
<el-option
v-for="item in RESULT_OPTIONS"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
</el-form-item>
<el-form-item label="时间范围">
<el-date-picker
v-model="opTimeRange"
type="datetimerange"
range-separator="至"
start-placeholder="开始时间"
end-placeholder="结束时间"
value-format="YYYY-MM-DD HH:mm:ss"
:default-time="RANGE_DEFAULT_TIME"
/>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="searchOperationLogs">查询</el-button>
<el-button @click="resetOpFilters">重置</el-button>
</el-form-item>
</el-form>
<el-table
v-loading="opLoading"
:data="opRecords"
stripe
@expand-change="onExpandChange"
>
<el-table-column type="expand">
<template #default="{ row }">
<div class="log-detail">
<p>
<span class="log-detail__label">请求:</span>
{{ row.method || '—' }} {{ row.path || '—' }}
</p>
<p>
<span class="log-detail__label">User-Agent:</span>
{{ row.user_agent || '—' }}
</p>
<p class="log-detail__label">请求参数(已脱敏):</p>
<pre class="log-detail__data">{{ detailText(row.id) }}</pre>
</div>
</template>
</el-table-column>
<el-table-column label="时间" min-width="165">
<template #default="{ row }">{{ formatDateTime(row.create_time) }}</template>
</el-table-column>
<el-table-column prop="username" label="操作人" min-width="110" />
<el-table-column prop="module" label="模块" width="100" />
<el-table-column prop="action" label="操作" width="90" />
<el-table-column prop="summary" label="摘要" min-width="200" show-overflow-tooltip />
<el-table-column label="结果" width="80" align="center">
<template #default="{ row }">
<el-tooltip
:content="row.error_message ?? ''"
:disabled="row.success !== 0 || !row.error_message"
placement="top"
>
<StatusTag
:value="row.success"
active-text="成功"
inactive-text="失败"
inactive-type="danger"
/>
</el-tooltip>
</template>
</el-table-column>
<el-table-column label="耗时(ms)" width="90" align="right">
<template #default="{ row }">{{ row.duration_ms ?? '—' }}</template>
</el-table-column>
<el-table-column prop="ip" label="IP" min-width="120">
<template #default="{ row }">{{ row.ip || '—' }}</template>
</el-table-column>
</el-table>
<AppPagination
:total="opTotal"
:page-num="opPageNum"
:page-size="opPageSize"
@page-change="onOpPageChange"
@size-change="onOpSizeChange"
/>
</el-tab-pane>
<!-- 登录日志 -->
<el-tab-pane label="登录日志" name="login" lazy>
<el-form class="filter-form" :inline="true" @submit.prevent>
<el-form-item label="用户名">
<el-input
v-model="loginFilters.username"
placeholder="模糊匹配"
clearable
style="width: 160px"
@keyup.enter="searchLoginLogs"
/>
</el-form-item>
<el-form-item label="IP">
<el-input
v-model="loginFilters.ip"
placeholder="精确匹配"
clearable
style="width: 150px"
@keyup.enter="searchLoginLogs"
/>
</el-form-item>
<el-form-item label="结果">
<el-select v-model="loginFilters.success" placeholder="全部" clearable style="width: 110px">
<el-option
v-for="item in RESULT_OPTIONS"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
</el-form-item>
<el-form-item label="时间范围">
<el-date-picker
v-model="loginTimeRange"
type="datetimerange"
range-separator="至"
start-placeholder="开始时间"
end-placeholder="结束时间"
value-format="YYYY-MM-DD HH:mm:ss"
:default-time="RANGE_DEFAULT_TIME"
/>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="searchLoginLogs">查询</el-button>
<el-button @click="resetLoginFilters">重置</el-button>
</el-form-item>
</el-form>
<el-table v-loading="loginLoading" :data="loginRecords" stripe>
<el-table-column label="时间" min-width="165">
<template #default="{ row }">{{ formatDateTime(row.create_time) }}</template>
</el-table-column>
<el-table-column prop="username" label="用户名" min-width="130" />
<el-table-column label="结果" width="80" align="center">
<template #default="{ row }">
<StatusTag
:value="row.success"
active-text="成功"
inactive-text="失败"
inactive-type="danger"
/>
</template>
</el-table-column>
<el-table-column prop="fail_reason" label="失败原因" min-width="150">
<template #default="{ row }">{{ row.fail_reason || '—' }}</template>
</el-table-column>
<el-table-column prop="ip" label="IP" min-width="120">
<template #default="{ row }">{{ row.ip || '—' }}</template>
</el-table-column>
<el-table-column label="User-Agent" min-width="200">
<template #default="{ row }">
<span class="ua-cell" :title="row.user_agent || ''">{{ row.user_agent || '—' }}</span>
</template>
</el-table-column>
</el-table>
<AppPagination
:total="loginTotal"
:page-num="loginPageNum"
:page-size="loginPageSize"
@page-change="onLoginPageChange"
@size-change="onLoginSizeChange"
/>
</el-tab-pane>
</el-tabs>
</el-card>
</template>
<style scoped>
.log-detail {
padding: 4px 16px;
color: var(--ys-text);
}
.log-detail__label {
font-weight: 600;
}
.log-detail__data {
padding: 8px 12px;
margin: 6px 0 12px;
overflow-x: auto;
font-family: Consolas, 'Courier New', monospace;
font-size: 12px;
background-color: var(--ys-paper);
border: 1px solid #e5e7eb;
border-radius: 4px;
}
.ua-cell {
display: inline-block;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
vertical-align: bottom;
}
</style>
+998
View File
@@ -0,0 +1,998 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import * as businessRoleApi from '@/api/businessRoles'
import * as paramsApi from '@/api/params'
import type {
ParamBatch,
ParamBatchDetail,
ParamCatalogItem,
ParamDiff,
ParamHistory,
ParamPreviewResult,
} from '@/api/params'
import AppPagination from '@/components/table/AppPagination.vue'
import { useConfirmAction } from '@/composables/useConfirmAction'
import { handledError } from '@/utils/error'
/**
* 计提参数配置(需求 #28)。
*
* 与后端约定对齐:
* - 目录按「分组 → 参数 → 维度值」展示当前生效值;修改先进「待提交」,
* 以批次提交(一次一批,逐项留痕旧值/新值)
* - 支持立即生效或指定日期生效;未生效批次可整批撤回
* - 图表/导出:统计用 ECharts(用户指定),导出为 xlsx 文件流
*/
const { confirmAndRun } = useConfirmAction()
// ==================== 目录 ====================
const catalog = ref<ParamCatalogItem[]>([])
const catalogLoading = ref(false)
async function loadCatalog(): Promise<void> {
catalogLoading.value = true
try {
catalog.value = await paramsApi.getCatalog()
} catch (error) {
handledError(error)
} finally {
catalogLoading.value = false
}
}
/** 分组后的目录(保持后端 sort_order 顺序) */
const groupedCatalog = computed(() => {
const groups = new Map<string, ParamCatalogItem[]>()
for (const item of catalog.value) {
groups.set(item.param_group, [...(groups.get(item.param_group) ?? []), item])
}
return [...groups.entries()].map(([name, items]) => ({ name, items }))
})
/** 维度值 → 展示名(NONE 维度为空串) */
const DIMENSION_LABELS: Record<string, string> = {
'': '默认',
municipal_water: '市政水利',
rail_transit: '轨道交通',
}
function dimensionLabel(dimension: string): string {
return DIMENSION_LABELS[dimension] ?? dimension
}
/** 带单位的值展示 */
function formatValue(item: ParamCatalogItem, value: string): string {
return item.unit ? `${value} ${item.unit}` : value
}
// ==================== 修改(待提交) ====================
interface PendingChange {
item: ParamCatalogItem
dimension_value: string
old_value: string
new_value: string
}
const pending = reactive<Record<string, PendingChange>>({})
const pendingList = computed(() => Object.entries(pending).map(([key, change]) => ({ key, ...change })))
function pendingKey(paramCode: string, dimension: string): string {
return `${paramCode}|${dimension}`
}
function pendingOf(item: ParamCatalogItem, dimension: string): PendingChange | undefined {
return pending[pendingKey(item.param_code, dimension)]
}
const editVisible = ref(false)
const editTarget = ref<{ item: ParamCatalogItem; dimension: string } | null>(null)
const editValue = ref('')
/** 可选维度:该参数已有维度值(无则视为默认) */
const editDimensions = computed(() => {
if (!editTarget.value) return []
const dims = new Set<string>(editTarget.value.item.values.map((v) => v.dimension_value))
if (dims.size === 0) dims.add('')
return [...dims]
})
function currentValue(item: ParamCatalogItem, dimension: string): string | undefined {
return item.values.find((v) => v.dimension_value === dimension)?.value_text
}
function openEdit(item: ParamCatalogItem, dimension: string): void {
editTarget.value = { item, dimension }
editValue.value = currentValue(item, dimension) ?? item.default_value ?? ''
editVisible.value = true
}
function confirmEdit(): void {
if (!editTarget.value) return
const { item, dimension } = editTarget.value
const value = editValue.value.trim()
if (!value) {
ElMessage.warning('请输入新值')
return
}
const old = currentValue(item, dimension) ?? item.default_value ?? ''
if (value === old) {
ElMessage.info('新值与当前值相同,未加入待提交')
return
}
pending[pendingKey(item.param_code, dimension)] = {
item,
dimension_value: dimension,
old_value: old,
new_value: value,
}
editVisible.value = false
ElMessage.success('已加入待提交,提交前不会生效')
}
function removePending(key: string): void {
delete pending[key]
}
// ==================== 提交批次 ====================
const changeType = ref<1 | 2>(1)
const effectiveDate = ref<string>('')
const remark = ref('')
const submitting = ref(false)
const effectiveText = computed(() =>
changeType.value === 1 ? '立即生效' : `${effectiveDate.value} 起生效`,
)
async function submitAll(): Promise<void> {
if (changeType.value === 2 && !effectiveDate.value) {
ElMessage.warning('请选择生效日期')
return
}
try {
await ElMessageBox.confirm(
`共 ${pendingList.value.length} 项变更,${effectiveText.value}。提交后逐项留痕,未生效前可整批撤回。`,
'提交确认',
{ confirmButtonText: '提交', cancelButtonText: '再想想', type: 'warning' },
)
} catch {
return
}
submitting.value = true
try {
const batch = await paramsApi.submitChanges({
change_type: changeType.value,
effective_date: changeType.value === 2 ? effectiveDate.value : undefined,
remark: remark.value || undefined,
items: pendingList.value.map((p) => ({
param_code: p.item.param_code,
dimension_value: p.dimension_value,
value: p.new_value,
})),
})
ElMessage.success(`已提交,版本 ${batch.version_no}`)
Object.keys(pending).forEach((key) => delete pending[key])
remark.value = ''
await loadCatalog()
} catch (error) {
handledError(error)
} finally {
submitting.value = false
}
}
// ==================== 版本管理抽屉 ====================
const versionVisible = ref(false)
const batches = ref<ParamBatch[]>([])
const batchesTotal = ref(0)
const batchesPage = ref(1)
const batchesPageSize = ref(10)
const batchesLoading = ref(false)
const detailCache = reactive<Record<number, ParamBatchDetail>>({})
async function loadBatches(): Promise<void> {
batchesLoading.value = true
try {
const page = await paramsApi.listBatches({ pageNum: batchesPage.value, pageSize: batchesPageSize.value })
batches.value = page.list
batchesTotal.value = page.total
} catch (error) {
handledError(error)
} finally {
batchesLoading.value = false
}
}
async function detailOf(batch: ParamBatch): Promise<ParamBatchDetail> {
if (detailCache[batch.id]) return detailCache[batch.id]
const detail = await paramsApi.getBatchDetail(batch.id)
detailCache[batch.id] = detail
return detail
}
function changeTypeText(changeType: number): string {
return changeType === 1 ? '立即生效' : '指定日期'
}
/** 本地日期(YYYY-MM-DD):toISOString 是 UTC,北京时间 0–8 点会差一天 */
function localToday(): string {
const d = new Date()
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
}
async function handleWithdraw(batch: ParamBatch): Promise<void> {
await confirmAndRun({
confirmText: `确定撤回批次「${batch.version_no}」(${batch.item_count} 项,${batch.effective_date} 起生效)?撤回后这些变更不再生效。`,
title: '撤回确认',
confirmButtonText: '撤回',
successText: '批次已撤回',
run: () => paramsApi.withdrawBatch(batch.id),
after: () => {
loadBatches()
void loadCatalog()
},
})
}
// ---------- 对比 ----------
const compareFrom = ref('')
const compareTo = ref('')
const comparing = ref(false)
const diffResult = ref<ParamDiff[]>([])
const diffSearched = ref(false)
async function doCompare(): Promise<void> {
if (!compareFrom.value || !compareTo.value) {
ElMessage.warning('请选择两个对比日期')
return
}
comparing.value = true
try {
diffResult.value = await paramsApi.compareParams(compareFrom.value, compareTo.value)
diffSearched.value = true
} catch (error) {
handledError(error)
} finally {
comparing.value = false
}
}
// ---------- 导出 ----------
const exporting = ref(false)
async function doExport(): Promise<void> {
exporting.value = true
try {
const blob = await paramsApi.exportChanges({
start_date: compareFrom.value || undefined,
end_date: compareTo.value || undefined,
})
const url = URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = `param_changes_${localToday()}.xlsx`
link.click()
URL.revokeObjectURL(url)
ElMessage.success('已导出')
} catch (error) {
handledError(error)
} finally {
exporting.value = false
}
}
function openVersionDrawer(): void {
versionVisible.value = true
batchesPage.value = 1
void loadBatches()
}
// ==================== 历史抽屉 ====================
const historyVisible = ref(false)
const historyItem = ref<ParamCatalogItem | null>(null)
const historyRows = ref<ParamHistory[]>([])
const historyTotal = ref(0)
const historyPage = ref(1)
const historyPageSize = ref(10)
const historyLoading = ref(false)
function openHistory(item: ParamCatalogItem): void {
historyItem.value = item
historyVisible.value = true
historyPage.value = 1
void loadHistory()
}
async function loadHistory(): Promise<void> {
if (!historyItem.value) return
historyLoading.value = true
try {
const page = await paramsApi.getParamHistory(historyItem.value.param_code, {
pageNum: historyPage.value,
pageSize: historyPageSize.value,
})
historyRows.value = page.list
historyTotal.value = page.total
} catch (error) {
handledError(error)
} finally {
historyLoading.value = false
}
}
// ==================== 计算预览抽屉 ====================
const previewVisible = ref(false)
const previewLoading = ref(false)
const previewResult = ref<ParamPreviewResult | null>(null)
const roleOptions = ref<businessRoleApi.BusinessRoleItem[]>([])
const previewForm = reactive({
business_role: 'salesperson',
customer_type: 'personal',
product_category: 'municipal_water',
ship_date: '',
receipt_date: '',
revenue: '',
profit: '',
received_amount: '',
barter: false,
probation: false,
})
const PRODUCT_OPTIONS = [
{ label: '市政水利', value: 'municipal_water' },
{ label: '轨道交通', value: 'rail_transit' },
]
const CUSTOMER_OPTIONS = [
{ label: '个人户', value: 'personal' },
{ label: '企业户', value: 'enterprise' },
]
async function loadRoleOptions(): Promise<void> {
try {
const page = await businessRoleApi.listBusinessRoles({ pageNum: 1, pageSize: 100 })
roleOptions.value = page.list
} catch (error) {
handledError(error)
}
}
function openPreviewDrawer(): void {
previewVisible.value = true
if (roleOptions.value.length === 0) void loadRoleOptions()
}
async function runPreview(): Promise<void> {
if (!previewForm.ship_date || !previewForm.receipt_date) {
ElMessage.warning('请选择发货日与回款到账日')
return
}
previewLoading.value = true
try {
previewResult.value = await paramsApi.previewCalc({
business_role: previewForm.business_role || undefined,
customer_type: previewForm.customer_type || undefined,
product_category: previewForm.product_category || undefined,
ship_date: previewForm.ship_date,
receipt_date: previewForm.receipt_date,
revenue: previewForm.revenue,
profit: previewForm.profit,
received_amount: previewForm.received_amount,
barter: previewForm.barter,
probation: previewForm.probation,
})
} catch (error) {
handledError(error)
} finally {
previewLoading.value = false
}
}
// ==================== 初始化 ====================
onMounted(() => {
void loadCatalog()
})
</script>
<template>
<div>
<el-card shadow="never">
<div class="page-toolbar">
<span class="page-desc">
计提比例、系数、阈值、分成与起付比例在此统一维护;变更逐项留痕并支持按日期生效。
</span>
<span>
<el-button @click="openPreviewDrawer">计算预览</el-button>
<el-button @click="openVersionDrawer">版本管理</el-button>
<el-button @click="doExport" :loading="exporting">导出记录</el-button>
</span>
</div>
<el-collapse v-loading="catalogLoading">
<el-collapse-item v-for="group in groupedCatalog" :key="group.name" :name="group.name">
<template #title>
<span class="group-title">{{ group.name }}</span>
</template>
<table class="param-table">
<thead>
<tr>
<th style="width: 190px">参数</th>
<th>当前生效值</th>
<th style="width: 110px">操作</th>
</tr>
</thead>
<tbody>
<tr v-for="item in group.items" :key="item.param_code">
<td>
<div class="param-name">{{ item.param_name }}</div>
<div class="param-desc">{{ item.description || item.param_code }}</div>
</td>
<td>
<template v-if="item.values.length">
<div v-for="v in item.values" :key="v.dimension_value" class="value-row">
<span class="dim-label">{{ dimensionLabel(v.dimension_value) }}</span>
<b class="num">{{ formatValue(item, v.value_text) }}</b>
<span class="eff">{{ v.effective_date }} 起</span>
<el-tag
v-if="pendingOf(item, v.dimension_value)"
size="small"
type="warning"
disable-transitions
>
待改为 {{ pendingOf(item, v.dimension_value)!.new_value }}
</el-tag>
<el-button
link
type="primary"
size="small"
@click="openEdit(item, v.dimension_value)"
>
改
</el-button>
</div>
</template>
<span v-else class="no-value">
未设置(默认 {{ item.default_value ?? '—' }})
</span>
</td>
<td>
<el-button
v-if="!item.values.length"
link
type="primary"
size="small"
@click="openEdit(item, '')"
>
设置
</el-button>
<el-button link type="primary" size="small" @click="openHistory(item)">历史</el-button>
</td>
</tr>
</tbody>
</table>
</el-collapse-item>
</el-collapse>
</el-card>
<!-- 待提交批次 -->
<el-card v-if="pendingList.length" shadow="never" class="pending-card">
<template #header>
<div class="pending-head">
<b>待提交变更({{ pendingList.length }} 项)</b>
<span class="pending-hint">提交前不会生效;提交后未生效前可整批撤回</span>
</div>
</template>
<el-table :data="pendingList" size="small">
<el-table-column label="参数" min-width="160">
<template #default="{ row }">{{ row.item.param_name }}</template>
</el-table-column>
<el-table-column label="维度" width="110">
<template #default="{ row }">{{ dimensionLabel(row.dimension_value) }}</template>
</el-table-column>
<el-table-column label="旧值" min-width="110">
<template #default="{ row }">{{ row.old_value || '—' }}</template>
</el-table-column>
<el-table-column label="新值" min-width="110">
<template #default="{ row }"><b class="num">{{ row.new_value }}</b></template>
</el-table-column>
<el-table-column label="操作" width="80">
<template #default="{ row }">
<el-button link type="danger" size="small" @click="removePending(row.key)">移除</el-button>
</template>
</el-table-column>
</el-table>
<div class="submit-row">
<el-radio-group v-model="changeType">
<el-radio-button :value="1">立即生效</el-radio-button>
<el-radio-button :value="2">指定日期生效</el-radio-button>
</el-radio-group>
<el-date-picker
v-if="changeType === 2"
v-model="effectiveDate"
type="date"
value-format="YYYY-MM-DD"
placeholder="生效日期"
style="width: 150px"
/>
<el-input
v-model="remark"
placeholder="变更说明(如:四季度系数调整)"
style="width: 240px"
maxlength="255"
/>
<el-button type="primary" :loading="submitting" @click="submitAll">提交变更</el-button>
</div>
</el-card>
<!-- 修改参数 -->
<el-dialog v-model="editVisible" title="修改参数" width="440px" destroy-on-close>
<template v-if="editTarget">
<div class="edit-name">{{ editTarget.item.param_name }}</div>
<div class="edit-desc">{{ editTarget.item.description || editTarget.item.param_code }}</div>
<el-form label-width="80px" style="margin-top: 16px" @submit.prevent>
<el-form-item v-if="editDimensions.length > 1 || editTarget.item.dimension_type !== 'NONE'" label="维度">
<el-select v-model="editTarget.dimension" style="width: 100%">
<el-option
v-for="dim in editDimensions"
:key="dim"
:label="dimensionLabel(dim)"
:value="dim"
/>
</el-select>
</el-form-item>
<el-form-item label="当前值">
<span class="num">{{ currentValue(editTarget.item, editTarget.dimension) ?? editTarget.item.default_value ?? '—' }}</span>
<span v-if="editTarget.item.unit" style="color: var(--ys-ink-3)"> {{ editTarget.item.unit }}</span>
</el-form-item>
<el-form-item label="新值">
<el-select
v-if="editTarget.item.value_type === 'ENUM'"
v-model="editValue"
style="width: 100%"
>
<el-option v-for="opt in editTarget.item.options" :key="opt" :label="opt" :value="opt" />
</el-select>
<el-input v-else v-model="editValue" placeholder="输入新值(分数式如 4/360 原样保留)" />
</el-form-item>
</el-form>
<div class="edit-tip">
取值范围:{{ editTarget.item.value_min ?? '−' }} ~ {{ editTarget.item.value_max ?? '不限' }};
精确分数(如 4/360)原样存储、不做小数截断。
</div>
</template>
<template #footer>
<el-button @click="editVisible = false">取消</el-button>
<el-button type="primary" @click="confirmEdit">加入待提交</el-button>
</template>
</el-dialog>
<!-- 版本管理抽屉 -->
<el-drawer v-model="versionVisible" title="版本管理" size="760px">
<div class="drawer-block">
<div class="drawer-row">
<el-date-picker
v-model="compareFrom"
type="date"
value-format="YYYY-MM-DD"
placeholder="对比起始日期"
style="width: 150px"
/>
<el-date-picker
v-model="compareTo"
type="date"
value-format="YYYY-MM-DD"
placeholder="对比截止日期"
style="width: 150px"
/>
<el-button :loading="comparing" @click="doCompare">对比</el-button>
<el-button :loading="exporting" @click="doExport">导出记录</el-button>
</div>
<template v-if="diffSearched">
<div class="drawer-title">快照对比({{ compareFrom }} → {{ compareTo }})</div>
<el-table :data="diffResult" size="small" max-height="260">
<el-table-column prop="param_name" label="参数" min-width="130" />
<el-table-column label="维度" width="90">
<template #default="{ row }">{{ dimensionLabel(row.dimension_value) }}</template>
</el-table-column>
<el-table-column label="旧值" min-width="90">
<template #default="{ row }">{{ row.old_value ?? '—' }}</template>
</el-table-column>
<el-table-column label="新值" min-width="90">
<template #default="{ row }">
<span :class="{ changed: row.changed }">{{ row.new_value ?? '—' }}</span>
</template>
</el-table-column>
<el-table-column label="变化" width="70" align="center">
<template #default="{ row }">
<el-tag v-if="row.changed" size="small" type="warning" disable-transitions>变化</el-tag>
</template>
</el-table-column>
</el-table>
</template>
</div>
<div class="drawer-block">
<div class="drawer-title">变更记录</div>
<el-table v-loading="batchesLoading" :data="batches" size="small">
<el-table-column type="expand">
<template #default="{ row }">
<div class="batch-items">
<template v-if="detailCache[row.id]">
<div v-for="item in detailCache[row.id].items" :key="item.param_code + item.dimension_value" class="batch-item">
<span class="param-name">{{ item.param_name }}</span>
<span class="dim-label">{{ dimensionLabel(item.dimension_value) }}</span>
<span class="num">{{ item.old_value_text ?? '—' }} → <b>{{ item.value_text }}</b></span>
</div>
</template>
<el-button v-else link size="small" @click="detailOf(row).catch(() => {})">
加载明细…
</el-button>
</div>
</template>
</el-table-column>
<el-table-column prop="version_no" label="版本号" width="120" />
<el-table-column prop="effective_date" label="生效日期" width="110" />
<el-table-column label="方式" width="90">
<template #default="{ row }">{{ changeTypeText(row.change_type) }}</template>
</el-table-column>
<el-table-column prop="item_count" label="项数" width="60" align="center" />
<el-table-column prop="operator_name" label="操作人" width="90" />
<el-table-column label="操作" width="80">
<template #default="{ row }">
<el-button
v-if="row.effective_date > localToday()"
link
type="danger"
size="small"
@click="handleWithdraw(row)"
>
撤回
</el-button>
</template>
</el-table-column>
</el-table>
<AppPagination
:total="batchesTotal"
:page-num="batchesPage"
:page-size="batchesPageSize"
@page-change="(p: number) => { batchesPage = p; void loadBatches() }"
@size-change="(s: number) => { batchesPageSize = s; batchesPage = 1; void loadBatches() }"
/>
</div>
</el-drawer>
<!-- 历史抽屉 -->
<el-drawer v-model="historyVisible" :title="`参数历史 · ${historyItem?.param_name ?? ''}`" size="560px">
<el-table v-loading="historyLoading" :data="historyRows" size="small">
<el-table-column prop="effective_date" label="生效日期" width="105" />
<el-table-column label="维度" width="90">
<template #default="{ row }">{{ dimensionLabel(row.dimension_value) }}</template>
</el-table-column>
<el-table-column label="旧值 → 新值" min-width="130">
<template #default="{ row }">
<span class="num">{{ row.old_value_text ?? '—' }} → {{ row.value_text }}</span>
</template>
</el-table-column>
<el-table-column prop="version_no" label="版本" width="115" />
<el-table-column prop="operator_name" label="操作人" width="80" />
</el-table>
<AppPagination
:total="historyTotal"
:page-num="historyPage"
:page-size="historyPageSize"
@page-change="(p: number) => { historyPage = p; void loadHistory() }"
@size-change="(s: number) => { historyPageSize = s; historyPage = 1; void loadHistory() }"
/>
</el-drawer>
<!-- 计算预览抽屉 -->
<el-drawer v-model="previewVisible" title="计算预览" size="480px">
<el-form label-width="90px" @submit.prevent>
<el-form-item label="客户类型">
<el-select v-model="previewForm.customer_type" style="width: 100%">
<el-option v-for="o in CUSTOMER_OPTIONS" :key="o.value" :label="o.label" :value="o.value" />
</el-select>
</el-form-item>
<el-form-item label="岗位">
<el-select v-model="previewForm.business_role" clearable style="width: 100%">
<el-option
v-for="role in roleOptions"
:key="role.role_code"
:label="role.role_name"
:value="role.role_code"
/>
</el-select>
</el-form-item>
<el-form-item label="产品类别">
<el-select v-model="previewForm.product_category" style="width: 100%">
<el-option v-for="o in PRODUCT_OPTIONS" :key="o.value" :label="o.label" :value="o.value" />
</el-select>
</el-form-item>
<el-form-item label="发货日">
<el-date-picker
v-model="previewForm.ship_date"
type="date"
value-format="YYYY-MM-DD"
style="width: 100%"
/>
</el-form-item>
<el-form-item label="回款到账日">
<el-date-picker
v-model="previewForm.receipt_date"
type="date"
value-format="YYYY-MM-DD"
style="width: 100%"
/>
</el-form-item>
<el-form-item label="销售额">
<el-input v-model="previewForm.revenue" placeholder="不含运费" />
</el-form-item>
<el-form-item label="毛利润额">
<el-input v-model="previewForm.profit" placeholder="可为负" />
</el-form-item>
<el-form-item label="本笔回款">
<el-input v-model="previewForm.received_amount" />
</el-form-item>
<el-form-item label="以物抵顶">
<el-switch v-model="previewForm.barter" />
</el-form-item>
<el-form-item label="试用期">
<el-switch v-model="previewForm.probation" />
</el-form-item>
<el-form-item>
<el-button type="primary" :loading="previewLoading" @click="runPreview">试 算</el-button>
</el-form-item>
</el-form>
<template v-if="previewResult">
<div class="preview-result">
<div class="pr-row big">
<span>单笔绩效</span><b class="num">{{ previewResult.performance_amount }} 元</b>
</div>
<div class="pr-row"><span>回款天数</span><b class="num">{{ previewResult.payment_days }} 天</b></div>
<div class="pr-row"><span>计提比例</span><b class="num">{{ previewResult.rate }}</b></div>
<div class="pr-row"><span>计提说明</span><b>{{ previewResult.rate_text }}</b></div>
<div class="pr-row"><span>折算毛利润额</span><b class="num">{{ previewResult.converted_profit }} 元</b></div>
<div class="pr-row"><span>毛利润率</span><b class="num">{{ previewResult.profit_rate }}</b></div>
<div class="pr-row"><span>起付判定</span><b>{{ previewResult.start_payment_ok ? '已达标' : '未达标(累计中)' }}</b></div>
<div class="pr-row"><span>支付权重</span><b class="num">R={{ previewResult.payment_weight_r }},权重 {{ previewResult.payment_weight }}</b></div>
<div class="pr-row"><span>当月可支付</span><b class="num">{{ previewResult.payable_amount }} 元</b></div>
<div v-if="previewResult.probation_note" class="pr-note">{{ previewResult.probation_note }}</div>
</div>
<div class="pr-used">
<div class="drawer-title">所用参数(按回款到账日取值)</div>
<div v-for="u in previewResult.params_used" :key="u.param_code" class="pr-used-row">
{{ u.param_code }} = {{ u.value_text }}
<span v-if="u.effective_date" class="eff">({{ u.effective_date }} 起)</span>
</div>
</div>
</template>
</el-drawer>
</div>
</template>
<style scoped>
.page-toolbar {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 14px;
}
.page-desc {
font-size: 13px;
color: var(--ys-ink-2);
}
.group-title {
font-weight: 600;
color: var(--ys-ink);
}
.param-table {
width: 100%;
border-collapse: collapse;
}
.param-table th {
text-align: left;
font-size: 12px;
font-weight: 500;
letter-spacing: 0.04em;
color: var(--ys-ink-2);
padding: 6px 8px;
border-bottom: 1px solid var(--ys-line);
}
.param-table td {
padding: 10px 8px;
border-bottom: 1px solid var(--ys-line);
font-size: 13px;
vertical-align: top;
}
.param-name {
font-weight: 500;
}
.param-desc {
font-size: 12px;
color: var(--ys-ink-3);
margin-top: 2px;
}
.value-row {
display: flex;
align-items: baseline;
gap: 8px;
line-height: 1.9;
}
.dim-label {
font-size: 12px;
color: var(--ys-ink-3);
min-width: 56px;
}
.num {
font-variant-numeric: tabular-nums;
}
.eff {
font-size: 12px;
color: var(--ys-ink-3);
}
.no-value {
color: var(--ys-ink-3);
font-size: 12px;
}
.pending-card {
margin-top: 14px;
}
.pending-head {
display: flex;
align-items: baseline;
gap: 12px;
}
.pending-hint {
font-size: 12px;
color: var(--ys-ink-3);
}
.submit-row {
display: flex;
gap: 10px;
align-items: center;
margin-top: 14px;
}
.edit-name {
font-size: 15px;
font-weight: 600;
}
.edit-desc {
font-size: 12px;
color: var(--ys-ink-3);
margin-top: 4px;
}
.edit-tip {
font-size: 12px;
color: var(--ys-ink-3);
background: var(--ys-mist);
border-radius: 6px;
padding: 8px 12px;
}
.drawer-block {
margin-bottom: 24px;
}
.drawer-row {
display: flex;
gap: 10px;
align-items: center;
margin-bottom: 12px;
}
.drawer-title {
font-size: 13px;
font-weight: 600;
color: var(--ys-ink-2);
margin: 10px 0 8px;
}
.batch-items {
padding: 6px 12px;
background: var(--ys-mist);
border-radius: 6px;
}
.batch-item {
display: flex;
gap: 10px;
align-items: baseline;
font-size: 12.5px;
line-height: 2;
}
.batch-item .param-name {
min-width: 130px;
color: var(--ys-ink);
}
.changed {
color: var(--ys-indigo);
font-weight: 600;
}
.preview-result {
margin-top: 18px;
border: 1px solid var(--ys-line);
border-radius: 10px;
padding: 16px 18px;
background: linear-gradient(180deg, #ffffff, #fcfcfe);
}
.pr-row {
display: flex;
justify-content: space-between;
align-items: baseline;
padding: 6px 0;
font-size: 13px;
color: var(--ys-ink-2);
}
.pr-row.big {
border-bottom: 1px solid var(--ys-line);
margin-bottom: 6px;
padding-bottom: 10px;
}
.pr-row.big b {
font-size: 20px;
color: var(--ys-indigo);
}
.pr-row b {
color: var(--ys-ink);
font-weight: 600;
}
.pr-note {
margin-top: 10px;
font-size: 12px;
color: var(--ys-warn);
background: #fbf4e4;
border-radius: 6px;
padding: 8px 12px;
}
.pr-used {
margin-top: 16px;
}
.pr-used-row {
font-size: 12px;
color: var(--ys-ink-2);
line-height: 2;
font-variant-numeric: tabular-nums;
}
</style>
+305
View File
@@ -0,0 +1,305 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'
import { ElMessage, type FormInstance, type FormRules } from 'element-plus'
import * as roleApi from '@/api/roles'
import type { RoleItem, RoleQueryParams } from '@/api/roles'
import AppPagination from '@/components/table/AppPagination.vue'
import StatusTag from '@/components/table/StatusTag.vue'
import { useConfirmAction } from '@/composables/useConfirmAction'
import { usePagedList } from '@/composables/usePagedList'
import { ROLE_ADMIN, STATUS_OPTIONS } from '@/utils/constants'
import { handledError } from '@/utils/error'
/**
* 角色管理(轻量版:不含权限点分配,权限清单待业务方给出后启用)。
*
* 与后端的保护规则对齐(后端是唯一防线,此处禁用按钮只是体验):
* - 内置 admin 角色不可删除、不可禁用
* - 仍有绑定用户的角色不可删除(`user_roles` 外键是 CASCADE,
* 直接删会静默剥离这些用户的权限)
* - 编码创建后不可修改(授权判定的锚点)
* - 禁用角色不再出现在用户表单下拉中,且**不可再分配给用户**;
* 已绑定用户的既有授权不受影响
*/
const { confirmAndRun } = useConfirmAction()
const filters = reactive({
keyword: '',
status: undefined as number | undefined,
})
const { records, total, pageNum, pageSize, loading, load, search, onPageChange, onSizeChange, reloadAfterDelete } =
usePagedList<RoleItem, RoleQueryParams>(roleApi.listRoles, () => ({
keyword: filters.keyword,
status: filters.status,
}))
function resetFilters(): void {
filters.keyword = ''
filters.status = undefined
search()
}
/** 内置管理员角色 */
function isBuiltin(row: RoleItem): boolean {
return row.role_code === ROLE_ADMIN
}
/** 删除保护的悬停说明(禁用按钮自身不触发悬浮事件,由外层 span 提供 title) */
function deleteHint(row: RoleItem): string {
if (isBuiltin(row)) return '内置管理员角色不可删除'
if (row.user_count > 0) return `仍有 ${row.user_count} 个用户绑定该角色,请先解除绑定`
return ''
}
// ==================== 新增 / 编辑 ====================
const dialogVisible = ref(false)
const editingId = ref<number | null>(null)
/** 单一状态源:编辑中 ⇔ editingId 非空(避免双状态漂移) */
const isEdit = computed(() => editingId.value !== null)
const formRef = ref<FormInstance>()
const submitting = ref(false)
const form = reactive({
role_code: '',
role_name: '',
description: '',
sort_order: 0,
})
const rules = computed<FormRules>(() => ({
role_code: [
{ required: true, message: '请输入角色编码', trigger: 'blur' },
{
pattern: /^[a-z][a-z0-9_]{1,31}$/,
message: '以小写字母开头,仅可含小写字母、数字与下划线(2–32 位)',
trigger: 'blur',
},
],
role_name: [
{ required: true, message: '请输入角色名称', trigger: 'blur' },
{ max: 64, message: '最长 64 个字符', trigger: 'blur' },
],
description: [{ max: 255, message: '最长 255 个字符', trigger: 'blur' }],
}))
function openCreate(): void {
editingId.value = null
Object.assign(form, { role_code: '', role_name: '', description: '', sort_order: 0 })
dialogVisible.value = true
formRef.value?.clearValidate()
}
function openEdit(row: RoleItem): void {
editingId.value = row.id
Object.assign(form, {
role_code: row.role_code,
role_name: row.role_name,
description: row.description ?? '',
sort_order: row.sort_order,
})
dialogVisible.value = true
formRef.value?.clearValidate()
}
async function handleSubmit(): Promise<void> {
const valid = await formRef.value?.validate().catch(() => false)
if (!valid) return
submitting.value = true
try {
if (editingId.value !== null) {
await roleApi.updateRole(editingId.value, {
role_name: form.role_name,
description: form.description,
// el-input-number 清空后为 undefined,被 JSON 省略时后端会取默认 0,
// 排序被静默重置 —— 提交前兜底为 0 并明确表达「未填即 0」的意图
sort_order: form.sort_order ?? 0,
})
ElMessage.success('已保存')
} else {
await roleApi.createRole({
role_code: form.role_code,
role_name: form.role_name,
description: form.description || undefined,
sort_order: form.sort_order ?? 0,
})
ElMessage.success('角色已创建')
}
dialogVisible.value = false
await load()
} catch (error) {
handledError(error) // 失败提示由请求拦截器统一完成
} finally {
submitting.value = false
}
}
// ==================== 启停 / 删除 ====================
async function handleToggleStatus(row: RoleItem): Promise<void> {
const next = row.status === 1 ? 0 : 1
const action = next === 1 ? '启用' : '禁用'
await confirmAndRun({
confirmText:
next === 0
? `确定禁用角色「${row.role_name}」?禁用后该角色不再出现在用户表单的下拉中且不可再分配,但已绑定用户的权限不受影响。`
: `确定启用角色「${row.role_name}」?`,
title: `${action}确认`,
confirmButtonText: action,
successText: `已${action}`,
run: () => roleApi.setRoleStatus(row.id, next),
after: load,
})
}
async function handleDelete(row: RoleItem): Promise<void> {
await confirmAndRun({
confirmText: `确定删除角色「${row.role_name}」(${row.role_code})?删除后不可恢复。`,
title: '删除确认',
confirmButtonText: '删除',
successText: '角色已删除',
run: () => roleApi.deleteRole(row.id),
after: reloadAfterDelete,
})
}
onMounted(load)
</script>
<template>
<div>
<el-card shadow="never">
<el-form class="filter-form" :inline="true" @submit.prevent>
<el-form-item label="关键词">
<el-input
v-model="filters.keyword"
placeholder="编码或名称,模糊匹配"
clearable
style="width: 200px"
@keyup.enter="search"
/>
</el-form-item>
<el-form-item label="状态">
<el-select v-model="filters.status" placeholder="全部" clearable style="width: 110px">
<el-option
v-for="item in STATUS_OPTIONS"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="search">查询</el-button>
<el-button @click="resetFilters">重置</el-button>
</el-form-item>
</el-form>
<div class="toolbar">
<el-button type="primary" @click="openCreate">新增角色</el-button>
</div>
<el-table v-loading="loading" :data="records" stripe>
<el-table-column label="编码" min-width="150">
<template #default="{ row }">
<span class="role-code">{{ row.role_code }}</span>
<el-tag v-if="isBuiltin(row)" class="builtin-tag" size="small" type="warning" disable-transitions>
内置
</el-tag>
</template>
</el-table-column>
<el-table-column prop="role_name" label="名称" min-width="120" />
<el-table-column prop="description" label="说明" min-width="180">
<template #default="{ row }">{{ row.description || '—' }}</template>
</el-table-column>
<el-table-column prop="sort_order" label="排序" width="80" align="center" />
<el-table-column label="状态" width="90" align="center">
<template #default="{ row }">
<StatusTag :value="row.status" />
</template>
</el-table-column>
<el-table-column label="用户数" width="90" align="center">
<template #default="{ row }">{{ row.user_count }}</template>
</el-table-column>
<el-table-column label="操作" width="170" fixed="right">
<template #default="{ row }">
<el-button link type="primary" size="small" @click="openEdit(row)">编辑</el-button>
<span :title="isBuiltin(row) ? '内置管理员角色不可禁用' : ''">
<el-button
link
type="warning"
size="small"
:disabled="isBuiltin(row)"
@click="handleToggleStatus(row)"
>
{{ row.status === 1 ? '禁用' : '启用' }}
</el-button>
</span>
<span :title="deleteHint(row)">
<el-button
link
type="danger"
size="small"
:disabled="isBuiltin(row) || row.user_count > 0"
@click="handleDelete(row)"
>
删除
</el-button>
</span>
</template>
</el-table-column>
</el-table>
<AppPagination
:total="total"
:page-num="pageNum"
:page-size="pageSize"
@page-change="onPageChange"
@size-change="onSizeChange"
/>
</el-card>
<el-dialog
v-model="dialogVisible"
:title="isEdit ? '编辑角色' : '新增角色'"
width="480px"
destroy-on-close
>
<el-form ref="formRef" :model="form" :rules="rules" label-width="90px">
<el-form-item label="角色编码" prop="role_code">
<el-input
v-model="form.role_code"
:disabled="isEdit"
placeholder="如 sales_manager,创建后不可修改"
/>
</el-form-item>
<el-form-item label="角色名称" prop="role_name">
<el-input v-model="form.role_name" placeholder="如 销售主管" />
</el-form-item>
<el-form-item label="说明" prop="description">
<el-input v-model="form.description" type="textarea" :rows="2" placeholder="选填" />
</el-form-item>
<el-form-item label="排序" prop="sort_order">
<el-input-number v-model="form.sort_order" :min="0" :max="9999" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" :loading="submitting" @click="handleSubmit">保存</el-button>
</template>
</el-dialog>
</div>
</template>
<style scoped>
.role-code {
font-family: Consolas, 'Courier New', monospace;
}
.builtin-tag {
margin-left: 6px;
}
</style>
+467
View File
@@ -0,0 +1,467 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'
import { ElMessage, type FormInstance, type FormRules } from 'element-plus'
import type { RoleOption } from '@/api/types'
import * as userApi from '@/api/users'
import type { UserItem, UserQueryParams } from '@/api/users'
import AppPagination from '@/components/table/AppPagination.vue'
import StatusTag from '@/components/table/StatusTag.vue'
import { useConfirmAction } from '@/composables/useConfirmAction'
import { usePagedList } from '@/composables/usePagedList'
import { useUserStore } from '@/store/user'
import { STATUS_OPTIONS } from '@/utils/constants'
import { handledError } from '@/utils/error'
import { formatDateTime } from '@/utils/format'
import { passwordRule } from '@/utils/validate'
/**
* 用户管理(需求 2.5.1)。
*
* 与后端约定对齐:
* - 不能删除/禁用自己、不能移除自己的管理员角色 —— 前端对当前登录行
* 禁用相应按钮(后端仍会拦截,界面禁用只是体验)
* - 密码按字节校验(8–72),中文一个字符占 3 字节
* - 重置密码会使用户已登录的会话立即失效
* - 编辑表单把未修改字段以空串提交,后端将 '' 归一为 NULL(清空即写回
* NULL),前端无需特殊处理
*/
const userStore = useUserStore()
const { confirmAndRun } = useConfirmAction()
// ==================== 列表 ====================
const filters = reactive({
username: '',
real_name: '',
status: undefined as number | undefined,
role_code: undefined as string | undefined,
})
const { records, total, pageNum, pageSize, loading, load, search, onPageChange, onSizeChange, reloadAfterDelete } =
usePagedList<UserItem, UserQueryParams>(userApi.listUsers, () => ({
username: filters.username,
real_name: filters.real_name,
status: filters.status,
role_code: filters.role_code,
}))
const roleOptions = ref<RoleOption[]>([])
async function loadRoleOptions(): Promise<void> {
try {
roleOptions.value = await userApi.listRoleOptions()
} catch (error) {
handledError(error) // 拦截器已提示;下拉为空不阻断列表使用
}
}
function resetFilters(): void {
filters.username = ''
filters.real_name = ''
filters.status = undefined
filters.role_code = undefined
search()
}
/** 是否当前登录用户本人(其危险操作按钮需禁用) */
function isSelf(row: UserItem): boolean {
return row.id === userStore.userInfo?.id
}
/** 自我保护提示:按钮外层 span 的 title(disabled 按钮自身不触发悬浮事件) */
function selfHint(row: UserItem): string {
return isSelf(row) ? '不能对自己执行此操作' : ''
}
// ==================== 新增 / 编辑 ====================
const dialogVisible = ref(false)
const editingId = ref<number | null>(null)
/** 单一状态源:编辑中 ⇔ editingId 非空(避免双状态漂移) */
const isEdit = computed(() => editingId.value !== null)
const formRef = ref<FormInstance>()
const submitting = ref(false)
const form = reactive({
username: '',
password: '',
real_name: '',
phone: '',
email: '',
roles: [] as string[],
remark: '',
})
/** 编辑时不校验密码(密码走专门的重置入口) */
const rules = computed<FormRules>(() => ({
username: [
{ required: true, message: '请输入用户名', trigger: 'blur' },
{ min: 2, max: 64, message: '用户名长度 2–64 个字符', trigger: 'blur' },
],
...(isEdit.value ? {} : { password: [{ required: true, validator: passwordRule, trigger: 'blur' }] }),
roles: [
{ required: true, type: 'array', min: 1, message: '至少选择一个角色', trigger: 'change' },
],
phone: [{ max: 20, message: '手机号最长 20 个字符', trigger: 'blur' }],
email: [{ max: 128, message: '邮箱最长 128 个字符', trigger: 'blur' }],
remark: [{ max: 255, message: '备注最长 255 个字符', trigger: 'blur' }],
}))
function openCreate(): void {
editingId.value = null
Object.assign(form, {
username: '',
password: '',
real_name: '',
phone: '',
email: '',
roles: [],
remark: '',
})
dialogVisible.value = true
formRef.value?.clearValidate()
}
function openEdit(row: UserItem): void {
editingId.value = row.id
Object.assign(form, {
username: row.username,
password: '',
real_name: row.real_name ?? '',
phone: row.phone ?? '',
email: row.email ?? '',
roles: [...row.roles],
remark: row.remark ?? '',
})
dialogVisible.value = true
formRef.value?.clearValidate()
}
async function handleSubmit(): Promise<void> {
const valid = await formRef.value?.validate().catch(() => false)
if (!valid) return
submitting.value = true
try {
if (editingId.value !== null) {
await userApi.updateUser(editingId.value, {
real_name: form.real_name,
phone: form.phone,
email: form.email,
roles: form.roles,
remark: form.remark,
})
ElMessage.success('已保存')
} else {
await userApi.createUser({
username: form.username,
password: form.password,
real_name: form.real_name || undefined,
phone: form.phone || undefined,
email: form.email || undefined,
roles: form.roles,
remark: form.remark || undefined,
})
ElMessage.success('用户已创建')
}
dialogVisible.value = false
await load()
} catch (error) {
handledError(error) // 失败提示由请求拦截器统一完成
} finally {
submitting.value = false
}
}
// ==================== 重置密码 ====================
const resetVisible = ref(false)
const resetFormRef = ref<FormInstance>()
const resetSubmitting = ref(false)
const resetTarget = reactive({ id: 0, username: '', isSelf: false })
const resetForm = reactive({ newPassword: '' })
const resetRules: FormRules = {
newPassword: [{ required: true, validator: passwordRule, trigger: 'blur' }],
}
function openReset(row: UserItem): void {
resetTarget.id = row.id
resetTarget.username = row.username
resetTarget.isSelf = isSelf(row)
resetForm.newPassword = ''
resetVisible.value = true
resetFormRef.value?.clearValidate()
}
async function handleResetSubmit(): Promise<void> {
const valid = await resetFormRef.value?.validate().catch(() => false)
if (!valid) return
resetSubmitting.value = true
try {
await userApi.resetPassword(resetTarget.id, resetForm.newPassword)
ElMessage.success('密码已重置,该用户需重新登录')
resetVisible.value = false
} catch (error) {
handledError(error) // 拦截器已提示
} finally {
resetSubmitting.value = false
}
}
// ==================== 启停 / 删除 ====================
async function handleToggleStatus(row: UserItem): Promise<void> {
const next = row.status === 1 ? 0 : 1
const action = next === 1 ? '启用' : '禁用'
await confirmAndRun({
confirmText:
next === 0
? `确定禁用用户「${row.username}」?其已登录的会话将立即失效。`
: `确定启用用户「${row.username}」?`,
title: `${action}确认`,
confirmButtonText: action,
successText: `已${action}`,
run: () => userApi.setUserStatus(row.id, next),
after: load,
})
}
async function handleDelete(row: UserItem): Promise<void> {
await confirmAndRun({
confirmText: `确定删除用户「${row.username}」?删除为物理删除、不可恢复;如仅为停用账号,建议使用「禁用」以保留审计线索。`,
title: '删除确认',
confirmButtonText: '删除',
successText: '用户已删除',
run: () => userApi.deleteUser(row.id),
after: reloadAfterDelete,
})
}
// ==================== 初始化 ====================
onMounted(() => {
// 两个请求各自容错(load 内部已兜底),互不拖累
void load()
void loadRoleOptions()
})
</script>
<template>
<div>
<el-card shadow="never">
<el-form class="filter-form" :inline="true" @submit.prevent>
<el-form-item label="用户名">
<el-input
v-model="filters.username"
placeholder="模糊匹配"
clearable
style="width: 160px"
@keyup.enter="search"
/>
</el-form-item>
<el-form-item label="姓名">
<el-input
v-model="filters.real_name"
placeholder="模糊匹配"
clearable
style="width: 140px"
@keyup.enter="search"
/>
</el-form-item>
<el-form-item label="状态">
<el-select v-model="filters.status" placeholder="全部" clearable style="width: 110px">
<el-option
v-for="item in STATUS_OPTIONS"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
</el-form-item>
<el-form-item label="角色">
<el-select v-model="filters.role_code" placeholder="全部" clearable style="width: 150px">
<el-option
v-for="role in roleOptions"
:key="role.role_code"
:label="role.role_name"
:value="role.role_code"
/>
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="search">查询</el-button>
<el-button @click="resetFilters">重置</el-button>
</el-form-item>
</el-form>
<div class="toolbar">
<el-button type="primary" @click="openCreate">新增用户</el-button>
</div>
<el-table v-loading="loading" :data="records" stripe>
<el-table-column prop="username" label="用户名" min-width="120" />
<el-table-column prop="real_name" label="姓名" min-width="100">
<template #default="{ row }">{{ row.real_name || '—' }}</template>
</el-table-column>
<el-table-column prop="phone" label="手机号" min-width="120">
<template #default="{ row }">{{ row.phone || '—' }}</template>
</el-table-column>
<el-table-column label="角色" min-width="140">
<template #default="{ row }">
<el-tag
v-for="role in row.roles"
:key="role"
class="role-tag"
size="small"
type="info"
disable-transitions
>
{{ role }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="状态" width="90" align="center">
<template #default="{ row }">
<StatusTag :value="row.status" />
</template>
</el-table-column>
<el-table-column label="最后登录" min-width="170">
<template #default="{ row }">{{ formatDateTime(row.last_login_time) }}</template>
</el-table-column>
<el-table-column label="操作" width="250" fixed="right">
<template #default="{ row }">
<el-button link type="primary" size="small" @click="openEdit(row)">编辑</el-button>
<el-button link type="primary" size="small" @click="openReset(row)">
重置密码
</el-button>
<span :title="selfHint(row)">
<el-button
link
type="warning"
size="small"
:disabled="isSelf(row)"
@click="handleToggleStatus(row)"
>
{{ row.status === 1 ? '禁用' : '启用' }}
</el-button>
</span>
<span :title="selfHint(row)">
<el-button
link
type="danger"
size="small"
:disabled="isSelf(row)"
@click="handleDelete(row)"
>
删除
</el-button>
</span>
</template>
</el-table-column>
</el-table>
<AppPagination
:total="total"
:page-num="pageNum"
:page-size="pageSize"
@page-change="onPageChange"
@size-change="onSizeChange"
/>
</el-card>
<!-- 新增 / 编辑 -->
<el-dialog
v-model="dialogVisible"
:title="isEdit ? '编辑用户' : '新增用户'"
width="520px"
destroy-on-close
>
<el-form ref="formRef" :model="form" :rules="rules" label-width="90px">
<el-form-item label="用户名" prop="username">
<el-input
v-model="form.username"
:disabled="isEdit"
placeholder="登录用户名,创建后不可修改"
/>
</el-form-item>
<el-form-item v-if="!isEdit" label="初始密码" prop="password">
<el-input
v-model="form.password"
type="password"
show-password
placeholder="8–72 字节(中文一个字符约 3 字节)"
/>
</el-form-item>
<el-form-item label="姓名" prop="real_name">
<el-input v-model="form.real_name" placeholder="真实姓名" />
</el-form-item>
<el-form-item label="手机号" prop="phone">
<el-input v-model="form.phone" placeholder="选填" />
</el-form-item>
<el-form-item label="邮箱" prop="email">
<el-input v-model="form.email" placeholder="选填" />
</el-form-item>
<el-form-item label="角色" prop="roles">
<el-select v-model="form.roles" multiple placeholder="至少选择一个角色" style="width: 100%">
<el-option
v-for="role in roleOptions"
:key="role.role_code"
:label="`${role.role_name}(${role.role_code})`"
:value="role.role_code"
/>
</el-select>
</el-form-item>
<el-form-item label="备注" prop="remark">
<el-input v-model="form.remark" type="textarea" :rows="2" placeholder="选填" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" :loading="submitting" @click="handleSubmit">保存</el-button>
</template>
</el-dialog>
<!-- 重置密码 -->
<el-dialog v-model="resetVisible" title="重置密码" width="440px" destroy-on-close>
<p class="reset-tip">
将重置用户「{{ resetTarget.username }}」的密码。
重置后该用户<strong>已登录的所有会话立即失效</strong>,需用新密码重新登录。
<template v-if="resetTarget.isSelf">
<br />
注意:<strong>你正在重置自己的密码</strong>,保存后当前会话也会失效。
</template>
</p>
<el-form ref="resetFormRef" :model="resetForm" :rules="resetRules" label-width="80px">
<el-form-item label="新密码" prop="newPassword">
<el-input
v-model="resetForm.newPassword"
type="password"
show-password
placeholder="8–72 字节(中文一个字符约 3 字节)"
/>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="resetVisible = false">取消</el-button>
<el-button type="primary" :loading="resetSubmitting" @click="handleResetSubmit">
确认重置
</el-button>
</template>
</el-dialog>
</div>
</template>
<style scoped>
.role-tag {
margin-right: 4px;
}
.reset-tip {
margin: 0 0 16px;
color: var(--ys-text);
line-height: 1.7;
}
</style>