Compare commits
17
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e568ebde61 | ||
|
|
8668e58c30 | ||
|
|
fbf044f240 | ||
|
|
6219698a39 | ||
|
|
d7fbec1177 | ||
|
|
4c442b8cf2 | ||
|
|
fd801455f0 | ||
|
|
8ad3650dd3 | ||
|
|
7a0f3f4b98 | ||
|
|
1b3509b20f | ||
|
|
5ed5665a54 | ||
|
|
bc019cadf5 | ||
|
|
c7212cd93f | ||
|
|
8355e0cc7d | ||
|
|
c83287819a | ||
|
|
e22c4bcdc9 | ||
|
|
ad5fe4a411 |
No files matched your search
+6
-1
@@ -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
|
||||||
@@ -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
@@ -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 是安全问题,必须启动即失败。
|
||||||
|
|||||||
@@ -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
@@ -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)]
|
||||||
@@ -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)
|
||||||
@@ -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="登录成功")
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
"""系统管理路由(需求 2.5.1)。
|
||||||
|
|
||||||
|
含用户管理、角色管理、日志查询(操作日志 + 登录日志)四部分,全部要求管理员权限。
|
||||||
|
|
||||||
|
与 `api/v1/auth.py` 的分工:`auth` 是登录链路(未认证即可访问的验证码与登录,
|
||||||
|
以及任何登录用户都能访问的 `/me` 与登出);本包是**管理功能**。
|
||||||
|
"""
|
||||||
@@ -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="岗位已删除")
|
||||||
@@ -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))
|
||||||
@@ -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))
|
||||||
@@ -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="角色已删除")
|
||||||
@@ -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))
|
||||||
@@ -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 = "禁用"
|
||||||
@@ -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"),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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自定义",
|
||||||
|
)
|
||||||
@@ -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="创建时间"
|
||||||
|
)
|
||||||
@@ -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)]
|
||||||
@@ -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
|
||||||
@@ -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()
|
||||||
@@ -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()
|
||||||
@@ -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="状态筛选")
|
||||||
@@ -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
|
||||||
@@ -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="按维度值筛选,空为全部"
|
||||||
|
)
|
||||||
@@ -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="状态筛选")
|
||||||
@@ -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="角色编码筛选")
|
||||||
@@ -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)
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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,
|
||||||
|
)
|
||||||
@@ -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
|
||||||
@@ -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()
|
||||||
@@ -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)
|
||||||
@@ -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)
|
||||||
@@ -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
@@ -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 完成并补齐用例,见上表。
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,155 @@
|
|||||||
|
"""岗位角色管理接口集成测试(需要 MySQL 与 Redis)。
|
||||||
|
|
||||||
|
守的是岗位管理的保护规则与模板绑定 —— 内置岗位被删停会让参数维度值
|
||||||
|
失效、核算模板断锚;模板编码不校验会让核算引擎取不到公式。
|
||||||
|
|
||||||
|
运行前置:``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/business-roles"
|
||||||
|
_TEMPLATES = "/api/v1/system/business-roles/meta/templates"
|
||||||
|
|
||||||
|
|
||||||
|
def _new_code() -> str:
|
||||||
|
return f"t_br_{uuid.uuid4().hex[:6]}"
|
||||||
|
|
||||||
|
|
||||||
|
class TestCrudFlow:
|
||||||
|
"""正向链路:建 → 查(列表 + 模板下拉)→ 改 → 删。"""
|
||||||
|
|
||||||
|
def test_新增自定义岗位并联动列表(self, client: TestClient, admin: dict) -> None:
|
||||||
|
code = _new_code()
|
||||||
|
resp = client.post(
|
||||||
|
_ROLES,
|
||||||
|
json={
|
||||||
|
"role_code": code,
|
||||||
|
"role_name": "集成测试岗位",
|
||||||
|
"calc_template": "TPL_SALESPERSON_DECAY",
|
||||||
|
"description": "集成测试",
|
||||||
|
"sort_order": 50,
|
||||||
|
},
|
||||||
|
headers=admin["headers"],
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
body = resp.json()["data"]
|
||||||
|
assert body["role_code"] == code
|
||||||
|
assert body["is_builtin"] == 0
|
||||||
|
assert body["status"] == 1
|
||||||
|
|
||||||
|
listed = client.get(_ROLES, params={"keyword": code}, headers=admin["headers"])
|
||||||
|
assert any(r["role_code"] == code for r in listed.json()["data"]["list"])
|
||||||
|
|
||||||
|
# 清理
|
||||||
|
assert client.delete(f"{_ROLES}/{body['id']}", headers=admin["headers"]).status_code == 200
|
||||||
|
|
||||||
|
def test_重复编码拒绝(self, client: TestClient, admin: dict) -> None:
|
||||||
|
code = _new_code()
|
||||||
|
payload = {
|
||||||
|
"role_code": code,
|
||||||
|
"role_name": "首个",
|
||||||
|
"calc_template": "TPL_SALESPERSON_DECAY",
|
||||||
|
}
|
||||||
|
assert client.post(_ROLES, json=payload, headers=admin["headers"]).status_code == 200
|
||||||
|
dup = client.post(_ROLES, json=payload, headers=admin["headers"])
|
||||||
|
assert dup.status_code == 400
|
||||||
|
assert "已存在" in dup.json()["message"]
|
||||||
|
client.delete(
|
||||||
|
f"{_ROLES}/{client.get(_ROLES, params={'keyword': code}, headers=admin['headers']).json()['data']['list'][0]['id']}",
|
||||||
|
headers=admin["headers"],
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_模板编码非法拒绝(self, client: TestClient, admin: dict) -> None:
|
||||||
|
resp = client.post(
|
||||||
|
_ROLES,
|
||||||
|
json={
|
||||||
|
"role_code": _new_code(),
|
||||||
|
"role_name": "坏模板岗位",
|
||||||
|
"calc_template": "TPL_NOT_EXIST",
|
||||||
|
},
|
||||||
|
headers=admin["headers"],
|
||||||
|
)
|
||||||
|
assert resp.status_code == 400
|
||||||
|
|
||||||
|
def test_编辑岗位换模板生效(self, client: TestClient, admin: dict) -> None:
|
||||||
|
code = _new_code()
|
||||||
|
created = client.post(
|
||||||
|
_ROLES,
|
||||||
|
json={
|
||||||
|
"role_code": code,
|
||||||
|
"role_name": "待改岗位",
|
||||||
|
"calc_template": "TPL_SALESPERSON_DECAY",
|
||||||
|
},
|
||||||
|
headers=admin["headers"],
|
||||||
|
).json()["data"]
|
||||||
|
updated = client.put(
|
||||||
|
f"{_ROLES}/{created['id']}",
|
||||||
|
json={
|
||||||
|
"role_name": "已改岗位",
|
||||||
|
"calc_template": "TPL_ENTERPRISE_FIXED",
|
||||||
|
"sort_order": 7,
|
||||||
|
},
|
||||||
|
headers=admin["headers"],
|
||||||
|
)
|
||||||
|
assert updated.status_code == 200
|
||||||
|
body = updated.json()["data"]
|
||||||
|
assert body["role_name"] == "已改岗位"
|
||||||
|
assert body["calc_template"] == "TPL_ENTERPRISE_FIXED"
|
||||||
|
assert body["role_code"] == code # 编码不变
|
||||||
|
client.delete(f"{_ROLES}/{created['id']}", headers=admin["headers"])
|
||||||
|
|
||||||
|
def test_模板下拉含五个模板且声明参数已注册(self, client: TestClient, admin: dict) -> None:
|
||||||
|
options = client.get(_TEMPLATES, headers=admin["headers"]).json()["data"]
|
||||||
|
assert len(options) == 5
|
||||||
|
for tpl in options:
|
||||||
|
assert tpl["params"], f"{tpl['code']} 未声明参数"
|
||||||
|
|
||||||
|
|
||||||
|
class TestBuiltinProtection:
|
||||||
|
"""内置岗位(种子 3 个)禁删禁停 —— 参数维度与模板按编码锚定。"""
|
||||||
|
|
||||||
|
def test_停用内置岗位_403(self, client: TestClient, admin: dict) -> None:
|
||||||
|
rows = client.get(
|
||||||
|
_ROLES, params={"keyword": "salesperson"}, headers=admin["headers"]
|
||||||
|
).json()["data"]["list"]
|
||||||
|
target = next(r for r in rows if r["role_code"] == "salesperson" and r["is_builtin"] == 1)
|
||||||
|
resp = client.put(
|
||||||
|
f"{_ROLES}/{target['id']}/status", json={"status": 0}, headers=admin["headers"]
|
||||||
|
)
|
||||||
|
assert resp.status_code == 403
|
||||||
|
|
||||||
|
def test_删除内置岗位_403(self, client: TestClient, admin: dict) -> None:
|
||||||
|
rows = client.get(
|
||||||
|
_ROLES, params={"keyword": "sales_manager"}, headers=admin["headers"]
|
||||||
|
).json()["data"]["list"]
|
||||||
|
target = next(r for r in rows if r["role_code"] == "sales_manager")
|
||||||
|
resp = client.delete(f"{_ROLES}/{target['id']}", headers=admin["headers"])
|
||||||
|
assert resp.status_code == 403
|
||||||
|
|
||||||
|
def test_种子内置岗位三个且不可删标记正确(self, client: TestClient, admin: dict) -> None:
|
||||||
|
rows = client.get(_ROLES, params={"pageSize": 50}, headers=admin["headers"]).json()["data"][
|
||||||
|
"list"
|
||||||
|
]
|
||||||
|
builtins = [r for r in rows if r["is_builtin"] == 1]
|
||||||
|
assert {r["role_code"] for r in builtins} == {
|
||||||
|
"salesperson",
|
||||||
|
"sales_manager",
|
||||||
|
"info_officer",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class TestPermission:
|
||||||
|
"""权限:普通用户全部 403(当前默认管理员专用)。"""
|
||||||
|
|
||||||
|
def test_普通用户访问岗位列表_403(self, client: TestClient, make_user, auth_headers) -> None:
|
||||||
|
user = make_user([ROLE_USER])
|
||||||
|
headers = auth_headers(user)
|
||||||
|
assert client.get(_ROLES, headers=headers).status_code == 403
|
||||||
@@ -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,347 @@
|
|||||||
|
"""计提参数管理接口集成测试(需要 MySQL 与 Redis)。
|
||||||
|
|
||||||
|
守的是参数体系的口径与留痕 —— 时点化取值错一天、同日变更取错条目、
|
||||||
|
旧值丢失、未来值泄漏到当前快照,任何一条都会直接算错钱。
|
||||||
|
|
||||||
|
关键复现(全部来自 2026-10-12 业务裁决):
|
||||||
|
- 企业户 2‰(2026-02-17 起)→ 3‰(2026-08-13 起)的按日回溯
|
||||||
|
- 业务员 27 天 → 97/10000,折算 15000,绩效 145.5,可付 52.38(预览金标准)
|
||||||
|
- 同日多次变更取最新一条;撤回未生效批次后解析回退
|
||||||
|
|
||||||
|
运行前置:``docker compose -f docker/docker-compose.dev.yml up -d``
|
||||||
|
"""
|
||||||
|
|
||||||
|
from datetime import date, timedelta
|
||||||
|
from fractions import Fraction
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
from sqlalchemy import delete
|
||||||
|
|
||||||
|
from app.core.constants import ROLE_USER
|
||||||
|
from app.core.db import get_session_factory
|
||||||
|
from app.models import ParamChangeBatch, ParamValue
|
||||||
|
|
||||||
|
pytestmark = pytest.mark.integration
|
||||||
|
|
||||||
|
_PARAMS = "/api/v1/system/params"
|
||||||
|
|
||||||
|
# 种子基线(与 V3 迁移一致):断言的「预期值」均以此为准
|
||||||
|
_SEED_PREPAY = "10"
|
||||||
|
_SEED_START_RATIO = "60"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def track_batches():
|
||||||
|
"""记录测试创建的批次 id,用例结束整批清理(条目 + 批次头)。"""
|
||||||
|
created: list[int] = []
|
||||||
|
|
||||||
|
def _track(batch_id: int) -> None:
|
||||||
|
created.append(batch_id)
|
||||||
|
|
||||||
|
yield _track
|
||||||
|
|
||||||
|
factory = get_session_factory()
|
||||||
|
with factory() as session:
|
||||||
|
if created:
|
||||||
|
session.execute(delete(ParamValue).where(ParamValue.batch_id.in_(created)))
|
||||||
|
session.execute(delete(ParamChangeBatch).where(ParamChangeBatch.id.in_(created)))
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def _snapshot_value(client: TestClient, admin: dict, param_code: str, *, as_of: str) -> str | None:
|
||||||
|
"""从目录接口取某参数默认维度的当前生效值。"""
|
||||||
|
items = client.get(_PARAMS, params={"as_of": as_of}, headers=admin["headers"]).json()["data"]
|
||||||
|
match = next(item for item in items if item["param_code"] == param_code)
|
||||||
|
dims = [v for v in match["values"] if v["dimension_value"] == ""]
|
||||||
|
return dims[0]["value_text"] if dims else None
|
||||||
|
|
||||||
|
|
||||||
|
class TestCatalog:
|
||||||
|
"""参数目录:种子完整性与时点化回溯。"""
|
||||||
|
|
||||||
|
def test_目录共16项且覆盖七个分组(self, client: TestClient, admin: dict) -> None:
|
||||||
|
items = client.get(_PARAMS, headers=admin["headers"]).json()["data"]
|
||||||
|
assert len(items) == 16
|
||||||
|
groups = {item["param_group"] for item in items}
|
||||||
|
assert groups == {
|
||||||
|
"业务员个人户",
|
||||||
|
"经理分成",
|
||||||
|
"企业户",
|
||||||
|
"信息员",
|
||||||
|
"支付环节",
|
||||||
|
"产品阈值",
|
||||||
|
"月工资口径",
|
||||||
|
}
|
||||||
|
|
||||||
|
def test_企业户比例按到账日回溯_2千分3千分双种子(self, client: TestClient, admin: dict) -> None:
|
||||||
|
before = _snapshot_value(client, admin, "enterprise.rate", as_of="2026-08-12")
|
||||||
|
after = _snapshot_value(client, admin, "enterprise.rate", as_of="2026-08-13")
|
||||||
|
assert (before, after) == ("2", "3")
|
||||||
|
|
||||||
|
def test_业务员斜率为精确分数_非截断小数(self, client: TestClient, admin: dict) -> None:
|
||||||
|
items = client.get(_PARAMS, headers=admin["headers"]).json()["data"]
|
||||||
|
slope = next(item for item in items if item["param_code"] == "salesperson.rate.slope")
|
||||||
|
values = [v["value_text"] for v in slope["values"]]
|
||||||
|
assert values == ["4/360"] # 分数式原样存储,禁 0.011111 截断
|
||||||
|
|
||||||
|
|
||||||
|
class TestChangeLifecycle:
|
||||||
|
"""变更生命周期:提交 → 生效 → 同日取最新 → 撤回回退。"""
|
||||||
|
|
||||||
|
def test_立即生效_快照变化且历史含旧值(
|
||||||
|
self, client: TestClient, admin: dict, track_batches
|
||||||
|
) -> None:
|
||||||
|
resp = client.post(
|
||||||
|
f"{_PARAMS}/changes",
|
||||||
|
json={
|
||||||
|
"change_type": 1,
|
||||||
|
"remark": "集成-立即生效",
|
||||||
|
"items": [{"param_code": "prepay.rate", "dimension_value": "", "value": "12"}],
|
||||||
|
},
|
||||||
|
headers=admin["headers"],
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
batch = resp.json()["data"]
|
||||||
|
track_batches(batch["id"])
|
||||||
|
assert batch["item_count"] == 1
|
||||||
|
assert batch["version_no"].startswith("P")
|
||||||
|
|
||||||
|
value = _snapshot_value(client, admin, "prepay.rate", as_of=str(date.today()))
|
||||||
|
assert value == "12"
|
||||||
|
|
||||||
|
history = client.get(
|
||||||
|
f"{_PARAMS}/prepay.rate/history",
|
||||||
|
params={"pageNum": 1, "pageSize": 5},
|
||||||
|
headers=admin["headers"],
|
||||||
|
).json()["data"]
|
||||||
|
assert history["total"] >= 2 # 种子 1 条 + 本次 1 条
|
||||||
|
assert history["list"][0]["old_value_text"] == _SEED_PREPAY
|
||||||
|
assert history["list"][0]["value_text"] == "12"
|
||||||
|
|
||||||
|
def test_未来生效_当前快照不泄漏_到期后生效(
|
||||||
|
self, client: TestClient, admin: dict, track_batches
|
||||||
|
) -> None:
|
||||||
|
future = date.today() + timedelta(days=30)
|
||||||
|
resp = client.post(
|
||||||
|
f"{_PARAMS}/changes",
|
||||||
|
json={
|
||||||
|
"change_type": 2,
|
||||||
|
"effective_date": future.isoformat(),
|
||||||
|
"remark": "集成-未来生效",
|
||||||
|
"items": [
|
||||||
|
{"param_code": "payment.start.ratio", "dimension_value": "", "value": "65"}
|
||||||
|
],
|
||||||
|
},
|
||||||
|
headers=admin["headers"],
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
batch = resp.json()["data"]
|
||||||
|
track_batches(batch["id"])
|
||||||
|
|
||||||
|
today_value = _snapshot_value(client, admin, "payment.start.ratio", as_of=str(date.today()))
|
||||||
|
assert today_value == _SEED_START_RATIO # 未来值不泄漏
|
||||||
|
future_value = _snapshot_value(
|
||||||
|
client, admin, "payment.start.ratio", as_of=future.isoformat()
|
||||||
|
)
|
||||||
|
assert future_value == "65" # 到期即生效
|
||||||
|
|
||||||
|
def test_同日多次变更_取最新一条(self, client: TestClient, admin: dict, track_batches) -> None:
|
||||||
|
for value in ("12", "11"):
|
||||||
|
resp = client.post(
|
||||||
|
f"{_PARAMS}/changes",
|
||||||
|
json={
|
||||||
|
"change_type": 1,
|
||||||
|
"items": [{"param_code": "prepay.rate", "dimension_value": "", "value": value}],
|
||||||
|
},
|
||||||
|
headers=admin["headers"],
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
track_batches(resp.json()["data"]["id"])
|
||||||
|
value = _snapshot_value(client, admin, "prepay.rate", as_of=str(date.today()))
|
||||||
|
assert value == "11"
|
||||||
|
|
||||||
|
def test_撤回未生效批次_解析回退(self, client: TestClient, admin: dict, track_batches) -> None:
|
||||||
|
future = date.today() + timedelta(days=30)
|
||||||
|
batch = client.post(
|
||||||
|
f"{_PARAMS}/changes",
|
||||||
|
json={
|
||||||
|
"change_type": 2,
|
||||||
|
"effective_date": future.isoformat(),
|
||||||
|
"items": [{"param_code": "barter.discount", "dimension_value": "", "value": "85"}],
|
||||||
|
},
|
||||||
|
headers=admin["headers"],
|
||||||
|
).json()["data"]
|
||||||
|
track_batches(batch["id"])
|
||||||
|
|
||||||
|
future_value = _snapshot_value(client, admin, "barter.discount", as_of=future.isoformat())
|
||||||
|
assert future_value == "85"
|
||||||
|
|
||||||
|
assert (
|
||||||
|
client.delete(f"{_PARAMS}/changes/{batch['id']}", headers=admin["headers"]).status_code
|
||||||
|
== 200
|
||||||
|
)
|
||||||
|
rolled_back = _snapshot_value(client, admin, "barter.discount", as_of=future.isoformat())
|
||||||
|
assert rolled_back == _SEED_BARTER
|
||||||
|
|
||||||
|
def test_撤回已生效批次_400(self, client: TestClient, admin: dict, track_batches) -> None:
|
||||||
|
batch = client.post(
|
||||||
|
f"{_PARAMS}/changes",
|
||||||
|
json={
|
||||||
|
"change_type": 1,
|
||||||
|
"items": [{"param_code": "prepay.rate", "dimension_value": "", "value": "12"}],
|
||||||
|
},
|
||||||
|
headers=admin["headers"],
|
||||||
|
).json()["data"]
|
||||||
|
track_batches(batch["id"])
|
||||||
|
resp = client.delete(f"{_PARAMS}/changes/{batch['id']}", headers=admin["headers"])
|
||||||
|
assert resp.status_code == 400
|
||||||
|
assert "已生效" in resp.json()["message"]
|
||||||
|
|
||||||
|
|
||||||
|
_SEED_BARTER = "80"
|
||||||
|
|
||||||
|
|
||||||
|
class TestValidation:
|
||||||
|
"""值校验:畸形值在写入前拒绝(审查重点 1)。"""
|
||||||
|
|
||||||
|
def test_超取值上限拒绝(self, client: TestClient, admin: dict) -> None:
|
||||||
|
resp = client.post(
|
||||||
|
f"{_PARAMS}/changes",
|
||||||
|
json={
|
||||||
|
"change_type": 1,
|
||||||
|
"items": [{"param_code": "barter.discount", "dimension_value": "", "value": "120"}],
|
||||||
|
},
|
||||||
|
headers=admin["headers"],
|
||||||
|
)
|
||||||
|
assert resp.status_code == 400
|
||||||
|
|
||||||
|
def test_枚举越界拒绝(self, client: TestClient, admin: dict) -> None:
|
||||||
|
resp = client.post(
|
||||||
|
f"{_PARAMS}/changes",
|
||||||
|
json={
|
||||||
|
"change_type": 1,
|
||||||
|
"items": [
|
||||||
|
{"param_code": "payment.weight.mode", "dimension_value": "", "value": "PARTIAL"}
|
||||||
|
],
|
||||||
|
},
|
||||||
|
headers=admin["headers"],
|
||||||
|
)
|
||||||
|
assert resp.status_code == 400
|
||||||
|
|
||||||
|
def test_除零分数拒绝(self, client: TestClient, admin: dict) -> None:
|
||||||
|
resp = client.post(
|
||||||
|
f"{_PARAMS}/changes",
|
||||||
|
json={
|
||||||
|
"change_type": 1,
|
||||||
|
"items": [{"param_code": "barter.discount", "dimension_value": "", "value": "1/0"}],
|
||||||
|
},
|
||||||
|
headers=admin["headers"],
|
||||||
|
)
|
||||||
|
assert resp.status_code == 400
|
||||||
|
|
||||||
|
def test_指定日期早于今日拒绝(self, client: TestClient, admin: dict) -> None:
|
||||||
|
yesterday = date.today() - timedelta(days=1)
|
||||||
|
resp = client.post(
|
||||||
|
f"{_PARAMS}/changes",
|
||||||
|
json={
|
||||||
|
"change_type": 2,
|
||||||
|
"effective_date": yesterday.isoformat(),
|
||||||
|
"items": [{"param_code": "prepay.rate", "dimension_value": "", "value": "9"}],
|
||||||
|
},
|
||||||
|
headers=admin["headers"],
|
||||||
|
)
|
||||||
|
assert resp.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
class TestPreview:
|
||||||
|
"""计算预览:业务裁决的「金标准」数值精确复现。"""
|
||||||
|
|
||||||
|
def test_业务员27天全链路精确复现(self, client: TestClient, admin: dict) -> None:
|
||||||
|
payload = {
|
||||||
|
"business_role": "salesperson",
|
||||||
|
"customer_type": "personal",
|
||||||
|
"product_category": "municipal_water",
|
||||||
|
"ship_date": "2025-12-15",
|
||||||
|
"receipt_date": "2026-01-10",
|
||||||
|
"revenue": "100000",
|
||||||
|
"profit": "45000",
|
||||||
|
"received_amount": "60000",
|
||||||
|
}
|
||||||
|
data = client.post(f"{_PARAMS}/preview", json=payload, headers=admin["headers"]).json()[
|
||||||
|
"data"
|
||||||
|
]
|
||||||
|
assert data["payment_days"] == 27
|
||||||
|
assert data["rate"] == "97/10000" # 10‰ − 27 × 4/360‰
|
||||||
|
assert data["profit_rate"] == "9/20" # 45% 不含运费
|
||||||
|
assert data["converted_profit"] == "15000" # 100000 × (45% − 30%)
|
||||||
|
assert data["performance_amount"] == "145.5"
|
||||||
|
assert data["start_payment_ok"] is True
|
||||||
|
assert data["payment_weight_r"] == 60
|
||||||
|
assert data["payable_amount"] == "52.38" # 145.5 × 60% × 60%
|
||||||
|
|
||||||
|
def test_361天分界_固定下限4千分(self, client: TestClient, admin: dict) -> None:
|
||||||
|
payload = {
|
||||||
|
"business_role": "salesperson",
|
||||||
|
"customer_type": "personal",
|
||||||
|
"product_category": "municipal_water",
|
||||||
|
"ship_date": "2025-01-10",
|
||||||
|
"receipt_date": "2026-01-10", # 366 天 ≥ 361
|
||||||
|
"revenue": "100000",
|
||||||
|
"profit": "45000",
|
||||||
|
"received_amount": "100000",
|
||||||
|
}
|
||||||
|
data = client.post(f"{_PARAMS}/preview", json=payload, headers=admin["headers"]).json()[
|
||||||
|
"data"
|
||||||
|
]
|
||||||
|
# Fraction 自动约分:4/1000 存储为 1/250,按数值断言
|
||||||
|
assert Fraction(data["rate"]) == Fraction(4, 1000)
|
||||||
|
|
||||||
|
def test_经理聚合口径_预览拒绝(self, client: TestClient, admin: dict) -> None:
|
||||||
|
payload = {
|
||||||
|
"business_role": "sales_manager",
|
||||||
|
"customer_type": "personal",
|
||||||
|
"product_category": "municipal_water",
|
||||||
|
"ship_date": "2026-01-01",
|
||||||
|
"receipt_date": "2026-02-01",
|
||||||
|
"revenue": "100000",
|
||||||
|
"profit": "45000",
|
||||||
|
"received_amount": "0",
|
||||||
|
}
|
||||||
|
resp = client.post(f"{_PARAMS}/preview", json=payload, headers=admin["headers"])
|
||||||
|
assert resp.status_code == 400
|
||||||
|
assert "聚合" in resp.json()["message"]
|
||||||
|
|
||||||
|
def test_回款日早于发货日_400(self, client: TestClient, admin: dict) -> None:
|
||||||
|
payload = {
|
||||||
|
"business_role": "salesperson",
|
||||||
|
"customer_type": "personal",
|
||||||
|
"product_category": "municipal_water",
|
||||||
|
"ship_date": "2026-01-10",
|
||||||
|
"receipt_date": "2026-01-01",
|
||||||
|
"revenue": "100000",
|
||||||
|
"profit": "45000",
|
||||||
|
"received_amount": "0",
|
||||||
|
}
|
||||||
|
assert (
|
||||||
|
client.post(f"{_PARAMS}/preview", json=payload, headers=admin["headers"]).status_code
|
||||||
|
== 400
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestPermission:
|
||||||
|
"""权限:普通用户全部 403。"""
|
||||||
|
|
||||||
|
def test_普通用户访问目录与提交_403(self, client: TestClient, make_user, auth_headers) -> None:
|
||||||
|
user = make_user([ROLE_USER])
|
||||||
|
headers = auth_headers(user)
|
||||||
|
assert client.get(_PARAMS, headers=headers).status_code == 403
|
||||||
|
resp = client.post(
|
||||||
|
f"{_PARAMS}/changes",
|
||||||
|
json={
|
||||||
|
"change_type": 1,
|
||||||
|
"items": [{"param_code": "prepay.rate", "dimension_value": "", "value": "9"}],
|
||||||
|
},
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
assert resp.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
|
||||||
@@ -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
|
||||||
@@ -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 "")
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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")
|
||||||
@@ -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]
|
||||||
@@ -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)
|
||||||
Generated
+23
@@ -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
@@ -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');
|
||||||
@@ -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';
|
||||||
|
|
||||||
@@ -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;
|
||||||
Vendored
+45
@@ -0,0 +1,45 @@
|
|||||||
|
|
||||||
|
/*
|
||||||
|
* Licensed to the Apache Software Foundation (ASF) under one
|
||||||
|
* or more contributor license agreements. See the NOTICE file
|
||||||
|
* distributed with this work for additional information
|
||||||
|
* regarding copyright ownership. The ASF licenses this file
|
||||||
|
* to you under the Apache License, Version 2.0 (the
|
||||||
|
* "License"); you may not use this file except in compliance
|
||||||
|
* with the License. You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing,
|
||||||
|
* software distributed under the License is distributed on an
|
||||||
|
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||||
|
* KIND, either express or implied. See the License for the
|
||||||
|
* specific language governing permissions and limitations
|
||||||
|
* under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
!function(t,e){"object"==typeof exports&&"undefined"!=typeof module?e(exports):"function"==typeof define&&define.amd?define(["exports"],e):e((t="undefined"!=typeof globalThis?globalThis:t||self).echarts={})}(this,(function(t){"use strict";
|
||||||
|
/*! *****************************************************************************
|
||||||
|
Copyright (c) Microsoft Corporation.
|
||||||
|
|
||||||
|
Permission to use, copy, modify, and/or distribute this software for any
|
||||||
|
purpose with or without fee is hereby granted.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||||
|
REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||||
|
AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||||
|
INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||||
|
LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||||
|
OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||||
|
PERFORMANCE OF THIS SOFTWARE.
|
||||||
|
***************************************************************************** */var e=function(t,n){return e=Object.setPrototypeOf||{__proto__:[]}instanceof Array&&function(t,e){t.__proto__=e}||function(t,e){for(var n in e)Object.prototype.hasOwnProperty.call(e,n)&&(t[n]=e[n])},e(t,n)};function n(t,n){if("function"!=typeof n&&null!==n)throw new TypeError("Class extends value "+String(n)+" is not a constructor or null");function i(){this.constructor=t}e(t,n),t.prototype=null===n?Object.create(n):(i.prototype=n.prototype,new i)}var i=function(){this.firefox=!1,this.ie=!1,this.edge=!1,this.newEdge=!1,this.weChat=!1},r=new function(){this.browser=new i,this.node=!1,this.wxa=!1,this.worker=!1,this.svgSupported=!1,this.touchEventsSupported=!1,this.pointerEventsSupported=!1,this.domSupported=!1,this.transformSupported=!1,this.transform3dSupported=!1,this.hasGlobalWindow="undefined"!=typeof window};"object"==typeof wx&&"function"==typeof wx.getSystemInfoSync?(r.wxa=!0,r.touchEventsSupported=!0):"undefined"==typeof document&&"undefined"!=typeof self?r.worker=!0:"undefined"==typeof navigator||0===navigator.userAgent.indexOf("Node.js")?(r.node=!0,r.svgSupported=!0):function(t,e){var n=e.browser,i=t.match(/Firefox\/([\d.]+)/),r=t.match(/MSIE\s([\d.]+)/)||t.match(/Trident\/.+?rv:(([\d.]+))/),o=t.match(/Edge?\/([\d.]+)/),a=/micromessenger/i.test(t);i&&(n.firefox=!0,n.version=i[1]);r&&(n.ie=!0,n.version=r[1]);o&&(n.edge=!0,n.version=o[1],n.newEdge=+o[1].split(".")[0]>18);a&&(n.weChat=!0);e.svgSupported="undefined"!=typeof SVGRect,e.touchEventsSupported="ontouchstart"in window&&!n.ie&&!n.edge,e.pointerEventsSupported="onpointerdown"in window&&(n.edge||n.ie&&+n.version>=11),e.domSupported="undefined"!=typeof document;var s=document.documentElement.style;e.transform3dSupported=(n.ie&&"transition"in s||n.edge||"WebKitCSSMatrix"in window&&"m11"in new WebKitCSSMatrix||"MozPerspective"in s)&&!("OTransition"in s),e.transformSupported=e.transform3dSupported||n.ie&&+n.version>=9}(navigator.userAgent,r);var o="sans-serif",a="12px "+o;var s,l,u=function(t){var e={};if("undefined"==typeof JSON)return e;for(var n=0;n<t.length;n++){var i=String.fromCharCode(n+32),r=(t.charCodeAt(n)-20)/100;e[i]=r}return e}("007LLmW'55;N0500LLLLLLLLLL00NNNLzWW\\\\WQb\\0FWLg\\bWb\\WQ\\WrWWQ000CL5LLFLL0LL**F*gLLLL5F0LF\\FFF5.5N"),h={createCanvas:function(){return"undefined"!=typeof document&&document.createElement("canvas")},measureText:function(t,e){if(!s){var n=h.createCanvas();s=n&&n.getContext("2d")}if(s)return l!==e&&(l=s.font=e||a),s.measureText(t);t=t||"";var i=/((?:\d+)?\.?\d*)px/.exec(e=e||a),r=i&&+i[1]||12,o=0;if(e.indexOf("mono")>=0)o=r*t.length;else for(var c=0;c<t.length;c++){var p=u[t[c]];o+=null==p?r:p*r}return{width:o}},loadImage:function(t,e,n){var i=new Image;return i.onload=e,i.onerror=n,i.src=t,i}};function c(t){for(var e in h)t[e]&&(h[e]=t[e])}var p=V(["Function","RegExp","Date","Error","CanvasGradient","CanvasPattern","Image","Canvas"],(function(t,e){return t["[object "+e+"]"]=!0,t}),{}),d=V(["Int8","Uint8","Uint8Clamped","Int16","Uint16","Int32","Uint32","Float32","Float64"],(function(t,e){return t["[object "+e+"Array]"]=!0,t}),{}),f=Object.prototype.toString,g=Array.prototype,y=g.forEach,v=g.filter,m=g.slice,x=g.map,_=function(){}.constructor,b=_?_.prototype:null,w="__proto__",S=2311;function M(){return S++}function I(){for(var t=[],e=0;e<arguments.length;e++)t[e]=arguments[e];"undefined"!=typeof console&&console.error.apply(console,t)}function T(t){if(null==t||"object"!=typeof t)return t;var e=t,n=f.call(t);if("[object Array]"===n){if(!pt(t)){e=[];for(var i=0,r=t.length;i<r;i++)e[i]=T(t[i])}}else if(d[n]){if(!pt(t)){var o=t.constructor;if(o.from)e=o.from(t);else{e=new o(t.length);for(i=0,r=t.length;i<r;i++)e[i]=t[i]}}}else if(!p[n]&&!pt(t)&&!J(t))for(var a in e={},t)t.hasOwnProperty(a)&&a!==w&&(e[a]=T(t[a]));return e}function C(t,e,n){if(!q(e)||!q(t))return n?T(e):t;for(var i in e)if(e.hasOwnProperty(i)&&i!==w){var r=t[i],o=e[i];!q(o)||!q(r)||Y(o)||Y(r)||J(o)||J(r)||K(o)||K(r)||pt(o)||pt(r)?!n&&i in t||(t[i]=T(e[i])):C(r,o,n)}return t}function D(t,e){for(var n=t[0],i=1,r=t.length;i<r;i++)n=C(n,t[i],e);return n}function A(t,e){if(Object.assign)Object.assign(t,e);else for(var n in e)e.hasOwnProperty(n)&&n!==w&&(t[n]=e[n]);return t}function k(t,e,n){for(var i=G(e),r=0;r<i.length;r++){var o=i[r];(n?null!=e[o]:null==t[o])&&(t[o]=e[o])}return t}var L=h.createCanvas;function P(t,e){if(t){if(t.indexOf)return t.indexOf(e);for(var n=0,i=t.length;n<i;n++)if(t[n]===e)return n}return-1}function O(t,e){var n=t.prototype;function i(){}for(var r in i.prototype=e.prototype,t.prototype=new i,n)n.hasOwnProperty(r)&&(t.prototype[r]=n[r]);t.prototype.constructor=t,t.superClass=e}function R(t,e,n){if(t="prototype"in t?t.prototype:t,e="prototype"in e?e.prototype:e,Object.getOwnPropertyNames)for(var i=Object.getOwnPropertyNames(e),r=0;r<i.length;r++){var o=i[r];"constructor"!==o&&(n?null!=e[o]:null==t[o])&&(t[o]=e[o])}else k(t,e,n)}function N(t){retuLine truncated
|
||||||
|
/*!
|
||||||
|
* ZRender, a high performance 2d drawing library.
|
||||||
|
*
|
||||||
|
* Copyright (c) 2013, Baidu Inc.
|
||||||
|
* All rights reserved.
|
||||||
|
*
|
||||||
|
* LICENSE
|
||||||
|
* https://github.com/ecomfe/zrender/blob/master/LICENSE.txt
|
||||||
|
*/
|
||||||
|
var Fr={},Gr={};var Wr,Hr=function(){function t(t,e,n){var i=this;this._sleepAfterStill=10,this._stillFrameAccum=0,this._needsRefresh=!0,this._needsRefreshHover=!0,this._darkMode=!1,n=n||{},this.dom=e,this.id=t;var o=new rn,a=n.renderer||"canvas";Fr[a]||(a=G(Fr)[0]),n.useDirtyRect=null!=n.useDirtyRect&&n.useDirtyRect;var s=new Fr[a](e,o,n,t),l=n.ssr||s.ssrOnly;this.storage=o,this.painter=s;var u,h=r.node||r.worker||l?null:new or(s.getViewportRoot(),s.root),c=n.useCoarsePointer;(null==c||"auto"===c?r.touchEventsSupported:!!c)&&(u=rt(n.pointerSize,44)),this.handler=new Ye(o,s,h,s.root,u),this.animation=new Wi({stage:{update:l?null:function(){return i._flush(!0)}}}),l||this.animation.start()}return t.prototype.add=function(t){!this._disposed&&t&&(this.storage.addRoot(t),t.addSelfToZr(this),this.refresh())},t.prototype.remove=function(t){!this._disposed&&t&&(this.storage.delRoot(t),t.removeSelfFromZr(this),this.refresh())},t.prototype.configLayer=function(t,e){this._disposed||(this.painter.configLayer&&this.painter.configLayer(t,e),this.refresh())},t.prototype.setBackgroundColor=function(t){this._disposed||(this.painter.setBackgroundColor&&this.painter.setBackgroundColor(t),this.refresh(),this._backgroundColor=t,this._darkMode=function(t){if(!t)return!1;if("string"==typeof t)return oi(t,1)<.4;if(t.colorStops){for(var e=t.colorStops,n=0,i=e.length,r=0;r<i;r++)n+=oi(e[r].color,1);return(n/=i)<.4}return!1}(t))},t.prototype.getBackgroundColor=function(){return this._backgroundColor},t.prototype.setDarkMode=function(t){this._darkMode=t},t.prototype.isDarkMode=function(){return this._darkMode},t.prototype.refreshImmediately=function(t){this._disposed||(t||this.animation.update(!0),this._needsRefresh=!1,this.painter.refresh(),this._needsRefresh=!1)},t.prototype.refresh=function(){this._disposed||(this._needsRefresh=!0,this.animation.start())},t.prototype.flush=function(){this._disposed||this._flush(!1)},t.prototype._flush=function(t){var e,n=Bi();this._needsRefresh&&(e=!0,this.refreshImmediately(t)),this._needsRefreshHover&&(e=!0,this.refreshHoverImmediately());var i=Bi();e?(this._stillFrameAccum=0,this.trigger("rendered",{elapsedTime:i-n})):this._sleepAfterStill>0&&(this._stillFrameAccum++,this._stillFrameAccum>this._sleepAfterStill&&this.animation.stop())},t.prototype.setSleepAfterStill=function(t){this._sleepAfterStill=t},t.prototype.wakeUp=function(){this._disposed||(this.animation.start(),this._stillFrameAccum=0)},t.prototype.refreshHover=function(){this._needsRefreshHover=!0},t.prototype.refreshHoverImmediately=function(){this._disposed||(this._needsRefreshHover=!1,this.painter.refreshHover&&"canvas"===this.painter.getType()&&this.painter.refreshHover())},t.prototype.resize=function(t){this._disposed||(t=t||{},this.painter.resize(t.width,t.height),this.handler.resize())},t.prototype.clearAnimation=function(){this._disposed||this.animation.clear()},t.prototype.getWidth=function(){if(!this._disposed)return this.painter.getWidth()},t.prototype.getHeight=function(){if(!this._disposed)return this.painter.getHeight()},t.prototype.setCursorStyle=function(t){this._disposed||this.handler.setCursorStyle(t)},t.prototype.findHover=function(t,e){if(!this._disposed)return this.handler.findHover(t,e)},t.prototype.on=function(t,e,n){return this._disposed||this.handler.on(t,e,n),this},t.prototype.off=function(t,e){this._disposed||this.handler.off(t,e)},t.prototype.trigger=function(t,e){this._disposed||this.handler.trigger(t,e)},t.prototype.clear=function(){if(!this._disposed){for(var t=this.storage.getRoots(),e=0;e<t.length;e++)t[e]instanceof Br&&t[e].removeSelfFromZr(this);this.storage.delAllRoots(),this.painter.clear()}},t.prototype.dispose=function(){var t;this._disposed||(this.animation.stop(),this.clear(),this.storage.dispose(),this.painter.dispose(),this.handler.dispose(),this.animation=this.storage=this.painter=this.handler=null,this._disposed=!0,t=this.id,delete Gr[t])},t}();function Yr(t,e){var n=new Hr(M(),t,e);return Gr[n.id]=n,n}function Xr(t,e){Fr[t]=e}function Ur(t){if("function"==typeof Wr)return Wr(t)}function Zr(t){Wr=t}var jr=Object.freeze({__proto__:null,init:Yr,dispose:function(t){t.dispose()},disposeAll:function(){for(var t in Gr)Gr.hasOwnProperty(t)&&Gr[t].dispose();Gr={}},getInstance:function(t){return Gr[t]},registerPainter:Xr,getElementSSRData:Ur,registerSSRDataGetter:Zr,version:"5.6.0"}),qr=1e-4;function Kr(t,e,n,i){var r=e[0],o=e[1],a=n[0],s=n[1],l=o-r,u=s-a;if(0===l)return 0===u?a:(a+s)/2;if(i)if(l>0){if(t<=r)return a;if(t>=o)return s}else{if(t>=r)return a;if(t<=o)return s}else{if(t===r)return a;if(t===o)return s}return(t-r)/l*u+a}function $r(t,e){switch(t){case"center":case"middle":t="50%";break;case"left":case"top":t="0%";break;case"right":case"bottom":t="100%"}return U(t)?(n=t,n.replace(/^\s+|\s+$/g,"")).match(/%$/)?parseFloat(t)/100*e:parseFloat(t):null==t?NaN:+t;var n}function Jr(t,e,n){return null==e&&(e=10),e=Math.min(Math.max(0,e),20),t=(+t).toFixed(e),n?t:+t}function Qr(t){return t.sort((funLine truncated
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 1.1 MiB |
@@ -0,0 +1,976 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<title>衍水工资系统 · 视觉方向预览</title>
|
||||||
|
<style>
|
||||||
|
/* ============================================================
|
||||||
|
设计体系 v3(质感版):中性灰白 · 靛蓝点缀 · 微质感
|
||||||
|
质感来源(而非装饰):
|
||||||
|
1) 表面微渐变 —— 1–2% 明度差,暗示受光方向
|
||||||
|
2) 半透明描边 —— 边界在不同底色上自然融合
|
||||||
|
3) 分层阴影 —— 近距实影 + 远距弥散影叠加
|
||||||
|
4) 内高光 / 内阴影 —— 浮起与凹陷的材质暗示
|
||||||
|
5) 极微噪点 —— 去除纯色的「塑料感」
|
||||||
|
6) 精确排版 —— 字距、等宽数字、行高的一致节律
|
||||||
|
============================================================ */
|
||||||
|
:root {
|
||||||
|
--ys-indigo: #2e4ba8;
|
||||||
|
--ys-indigo-hi: #3a58bc; /* 渐变亮端 */
|
||||||
|
--ys-indigo-deep: #243c8b;
|
||||||
|
--ys-indigo-wash: #edf1fa;
|
||||||
|
--ys-paper: #ffffff;
|
||||||
|
--ys-mist: #f4f5f8; /* 页面底 */
|
||||||
|
--ys-aside-1: #f2f4f8; /* 侧栏渐变起 */
|
||||||
|
--ys-aside-2: #ecedf3; /* 侧栏渐变止 */
|
||||||
|
--ys-ink: #1c2333;
|
||||||
|
--ys-ink-2: #5b6577;
|
||||||
|
--ys-ink-3: #9aa3b2;
|
||||||
|
--ys-line: rgba(28, 35, 51, 0.09); /* 半透明描边 */
|
||||||
|
--ys-line-soft: rgba(28, 35, 51, 0.065);
|
||||||
|
|
||||||
|
--ys-ok: #1e9e6a; --ys-ok-wash-1: #ecf8f2; --ys-ok-wash-2: #e4f3ec;
|
||||||
|
--ys-warn: #c98a1b; --ys-warn-wash-1: #fbf4e4; --ys-warn-wash-2: #f8efd9;
|
||||||
|
--ys-danger: #c94f4f; --ys-danger-wash: #fbeded;
|
||||||
|
|
||||||
|
--ys-radius: 10px;
|
||||||
|
--ys-radius-sm: 7px;
|
||||||
|
--ys-font: 'PingFang SC', 'Microsoft YaHei', 'Noto Sans SC', -apple-system,
|
||||||
|
'Segoe UI', 'Helvetica Neue', Arial, sans-serif;
|
||||||
|
--ys-ease: cubic-bezier(0.25, 0.1, 0.25, 1);
|
||||||
|
|
||||||
|
/* 分层阴影:近距定义轮廓,远距弥散氛围 */
|
||||||
|
--ys-shadow-card: 0 1px 2px rgba(28, 35, 51, 0.04), 0 3px 10px rgba(28, 35, 51, 0.03);
|
||||||
|
--ys-shadow-raise: 0 1px 2px rgba(28, 35, 51, 0.05), 0 6px 18px rgba(28, 35, 51, 0.07);
|
||||||
|
|
||||||
|
/* 极微噪点:去除大色块的塑料感(内联 SVG,无外部依赖) */
|
||||||
|
--ys-noise: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='160' height='160'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.8' numOctaves='2' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='160' height='160' filter='url(%23n)'/%3E%3C/svg%3E");
|
||||||
|
|
||||||
|
/* 品牌字:思源黑体(现代几何黑体;实施时自托管 4 字子集,内网可用) */
|
||||||
|
--ys-font-brand: 'Noto Sans SC', 'PingFang SC', 'Microsoft YaHei', sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
body {
|
||||||
|
font-family: var(--ys-font);
|
||||||
|
color: var(--ys-ink);
|
||||||
|
background-color: var(--ys-mist);
|
||||||
|
/* 点阵网格:22px 间距的 1px 微点 —— 给背景「秩序与密度」,不再空旷 */
|
||||||
|
background-image: radial-gradient(rgba(28, 35, 51, 0.07) 1px, transparent 1px);
|
||||||
|
background-size: 24px 24px;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.6;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
}
|
||||||
|
/* 噪点覆盖层:全页 2.2% 强度,肉眼几乎无感,但色块不再是「纯色塑料」 */
|
||||||
|
body::after {
|
||||||
|
content: ''; position: fixed; inset: 0; z-index: 2147483647;
|
||||||
|
pointer-events: none; opacity: 0.022; background-image: var(--ys-noise);
|
||||||
|
}
|
||||||
|
.num { font-variant-numeric: tabular-nums; }
|
||||||
|
|
||||||
|
.wrap { max-width: 1180px; margin: 0 auto; padding: 48px 32px 96px; }
|
||||||
|
.section { margin-top: 64px; }
|
||||||
|
.section > h2 {
|
||||||
|
font-size: 13px; font-weight: 500; letter-spacing: 0.08em;
|
||||||
|
color: var(--ys-ink-3); text-transform: uppercase;
|
||||||
|
display: flex; align-items: center; gap: 10px; margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
.section > h2::after { content: ''; height: 1px; flex: 1; background: var(--ys-line); }
|
||||||
|
|
||||||
|
/* 签名元素:水线 */
|
||||||
|
.waterline {
|
||||||
|
width: 56px; height: 2px; border-radius: 1px;
|
||||||
|
background: linear-gradient(90deg, var(--ys-indigo), rgba(46, 75, 168, 0));
|
||||||
|
margin: 10px 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- 封面 ---------- */
|
||||||
|
.cover { padding: 24px 0 8px; }
|
||||||
|
.cover .brand {
|
||||||
|
font-family: var(--ys-font-brand);
|
||||||
|
font-size: 15px; font-weight: 500; letter-spacing: 0.32em; color: var(--ys-ink-2);
|
||||||
|
}
|
||||||
|
.cover h1 { font-size: 30px; font-weight: 600; margin-top: 18px; letter-spacing: -0.01em; }
|
||||||
|
.cover .sub { color: var(--ys-ink-2); margin-top: 10px; font-size: 15px; }
|
||||||
|
.cover .tags { display: flex; gap: 8px; margin-top: 22px; flex-wrap: wrap; }
|
||||||
|
.tag {
|
||||||
|
font-size: 12px; color: var(--ys-ink-2);
|
||||||
|
background: linear-gradient(180deg, #ffffff, #fbfcfe);
|
||||||
|
border: 1px solid var(--ys-line); border-radius: 999px; padding: 4px 12px;
|
||||||
|
box-shadow: 0 1px 2px rgba(28, 35, 51, 0.03);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- 色板 ---------- */
|
||||||
|
.swatches { display: grid; grid-template-columns: repeat(4, 1fr); gap: 14px; }
|
||||||
|
.swatch {
|
||||||
|
background: linear-gradient(180deg, #ffffff, #fcfcfe);
|
||||||
|
border: 1px solid var(--ys-line); border-radius: var(--ys-radius);
|
||||||
|
overflow: hidden; box-shadow: var(--ys-shadow-card);
|
||||||
|
}
|
||||||
|
.swatch .chip { height: 76px; }
|
||||||
|
.swatch .meta { padding: 10px 14px 12px; }
|
||||||
|
.swatch .name { font-size: 13px; font-weight: 600; }
|
||||||
|
.swatch .hex { font-size: 12px; color: var(--ys-ink-2); margin-top: 2px; }
|
||||||
|
.swatch .use { font-size: 12px; color: var(--ys-ink-3); margin-top: 6px; line-height: 1.5; }
|
||||||
|
|
||||||
|
/* ---------- 字体 ---------- */
|
||||||
|
.type-sheet {
|
||||||
|
background: linear-gradient(180deg, #ffffff, #fcfcfe);
|
||||||
|
border: 1px solid var(--ys-line); border-radius: var(--ys-radius);
|
||||||
|
padding: 26px 28px; box-shadow: var(--ys-shadow-card);
|
||||||
|
}
|
||||||
|
.type-row { display: flex; align-items: baseline; gap: 24px; padding: 12px 0; border-bottom: 1px dashed var(--ys-line-soft); }
|
||||||
|
.type-row:last-child { border-bottom: 0; }
|
||||||
|
.type-row .label { width: 150px; flex: none; font-size: 12px; color: var(--ys-ink-3); }
|
||||||
|
.t-page-title { font-size: 18px; font-weight: 600; letter-spacing: -0.01em; }
|
||||||
|
.t-card-title { font-size: 15px; font-weight: 600; }
|
||||||
|
.t-body { font-size: 14px; }
|
||||||
|
.t-th { font-size: 12px; font-weight: 500; letter-spacing: 0.04em; color: var(--ys-ink-2); }
|
||||||
|
.t-amount { font-size: 15px; font-weight: 600; font-variant-numeric: tabular-nums; }
|
||||||
|
|
||||||
|
.amount-demo {
|
||||||
|
margin-top: 18px;
|
||||||
|
background: linear-gradient(180deg, #ffffff, #fcfcfe);
|
||||||
|
border: 1px solid var(--ys-line); border-radius: var(--ys-radius);
|
||||||
|
padding: 18px 28px; box-shadow: var(--ys-shadow-card);
|
||||||
|
}
|
||||||
|
.amount-demo table { width: 100%; border-collapse: collapse; font-variant-numeric: tabular-nums; }
|
||||||
|
.amount-demo th {
|
||||||
|
text-align: right; font-size: 12px; font-weight: 500; letter-spacing: 0.04em;
|
||||||
|
color: var(--ys-ink-2); padding: 6px 0; border-bottom: 1px solid var(--ys-line);
|
||||||
|
}
|
||||||
|
.amount-demo th:first-child { text-align: left; }
|
||||||
|
.amount-demo td { text-align: right; padding: 9px 0; border-bottom: 1px solid var(--ys-line-soft); font-size: 13px; }
|
||||||
|
.amount-demo td:first-child { text-align: left; color: var(--ys-ink-2); }
|
||||||
|
.amount-demo tr:last-child td { border-bottom: 0; font-weight: 600; font-size: 15px; }
|
||||||
|
|
||||||
|
/* 品牌字候选(思源黑体已装于本机;实施时自托管子集) */
|
||||||
|
.brand-candidates { display: grid; grid-template-columns: repeat(3, 1fr); gap: 14px; margin-top: 14px; }
|
||||||
|
.bc {
|
||||||
|
background: linear-gradient(180deg, #ffffff, #fcfcfe);
|
||||||
|
border: 1px solid var(--ys-line); border-radius: var(--ys-radius);
|
||||||
|
box-shadow: var(--ys-shadow-card); padding: 20px 24px; text-align: center;
|
||||||
|
}
|
||||||
|
.bc-cap { font-size: 11px; color: var(--ys-ink-3); margin-top: 12px; }
|
||||||
|
.bc-rec { color: var(--ys-indigo); font-weight: 500; margin-left: 4px; }
|
||||||
|
|
||||||
|
/* ---------- 组件 ---------- */
|
||||||
|
.comp-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; }
|
||||||
|
.comp-card {
|
||||||
|
background: linear-gradient(180deg, #ffffff, #fcfcfe);
|
||||||
|
border: 1px solid var(--ys-line); border-radius: var(--ys-radius);
|
||||||
|
padding: 22px 24px; box-shadow: var(--ys-shadow-card);
|
||||||
|
}
|
||||||
|
.comp-card h3 { font-size: 13px; font-weight: 600; color: var(--ys-ink-2); margin-bottom: 16px; letter-spacing: 0.02em; }
|
||||||
|
.row { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; }
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
height: 34px; padding: 0 16px; border-radius: var(--ys-radius-sm);
|
||||||
|
font-size: 13px; font-family: inherit; cursor: pointer;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
transition: all 150ms var(--ys-ease);
|
||||||
|
}
|
||||||
|
/* 主按钮:微渐变 + 内高光 + 静置浅影 —— hover 抬升 1px、影加深 */
|
||||||
|
.btn-primary {
|
||||||
|
color: #fff;
|
||||||
|
background: linear-gradient(180deg, var(--ys-indigo-hi), var(--ys-indigo));
|
||||||
|
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.18), 0 1px 2px rgba(46, 75, 168, 0.22);
|
||||||
|
}
|
||||||
|
.btn-primary:hover {
|
||||||
|
background: linear-gradient(180deg, #31509f, var(--ys-indigo-deep));
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.18), 0 4px 12px rgba(46, 75, 168, 0.3);
|
||||||
|
}
|
||||||
|
.btn-primary:active { transform: translateY(0); box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.18); }
|
||||||
|
.btn-default {
|
||||||
|
background: linear-gradient(180deg, #ffffff, #fbfcfe); color: var(--ys-ink);
|
||||||
|
border-color: var(--ys-line); box-shadow: 0 1px 2px rgba(28, 35, 51, 0.03);
|
||||||
|
}
|
||||||
|
.btn-default:hover { color: var(--ys-indigo); border-color: rgba(46, 75, 168, 0.45); }
|
||||||
|
.btn-danger { background: linear-gradient(180deg, #ffffff, #fbfcfe); color: var(--ys-danger); border-color: rgba(201, 79, 79, 0.28); }
|
||||||
|
.btn-danger:hover { border-color: rgba(201, 79, 79, 0.6); }
|
||||||
|
.btn-text { background: none; color: var(--ys-indigo); padding: 0 6px; height: auto; }
|
||||||
|
.btn-text:hover { color: var(--ys-indigo-deep); }
|
||||||
|
.btn-ghost-hover { pointer-events: none; } /* 预览:静态展示 hover 态 */
|
||||||
|
|
||||||
|
.input {
|
||||||
|
height: 34px; padding: 0 12px; border-radius: var(--ys-radius-sm);
|
||||||
|
border: 1px solid rgba(28, 35, 51, 0.13); background: #fff;
|
||||||
|
font-size: 13px; font-family: inherit; color: var(--ys-ink); width: 190px;
|
||||||
|
box-shadow: inset 0 1px 2px rgba(28, 35, 51, 0.035); /* 微凹陷:输入区的材质暗示 */
|
||||||
|
transition: border-color 150ms var(--ys-ease), box-shadow 150ms var(--ys-ease);
|
||||||
|
}
|
||||||
|
.input::placeholder { color: var(--ys-ink-3); }
|
||||||
|
.input:focus, .input.focused {
|
||||||
|
outline: none; border-color: var(--ys-indigo);
|
||||||
|
box-shadow: inset 0 1px 2px rgba(28, 35, 51, 0.03), 0 0 0 3px rgba(46, 75, 168, 0.13);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pill { display: inline-flex; align-items: center; gap: 6px; font-size: 12px; padding: 2px 10px; border-radius: 999px; white-space: nowrap; }
|
||||||
|
.pill::before { content: ''; width: 6px; height: 6px; border-radius: 50%; background: currentColor; }
|
||||||
|
.pill-ok { color: var(--ys-ok); background: linear-gradient(180deg, var(--ys-ok-wash-1), var(--ys-ok-wash-2)); }
|
||||||
|
.pill-off { color: var(--ys-ink-2); background: linear-gradient(180deg, #f4f5f8, #eff1f5); }
|
||||||
|
.pill-warn { color: var(--ys-warn); background: linear-gradient(180deg, var(--ys-warn-wash-1), var(--ys-warn-wash-2)); }
|
||||||
|
|
||||||
|
.pager { display: flex; gap: 6px; align-items: center; font-size: 13px; color: var(--ys-ink-2); }
|
||||||
|
.pager .pg {
|
||||||
|
min-width: 30px; height: 30px; display: inline-flex; align-items: center; justify-content: center;
|
||||||
|
border-radius: var(--ys-radius-sm); border: 1px solid var(--ys-line);
|
||||||
|
background: linear-gradient(180deg, #ffffff, #fbfcfe); font-variant-numeric: tabular-nums;
|
||||||
|
box-shadow: 0 1px 2px rgba(28, 35, 51, 0.03);
|
||||||
|
}
|
||||||
|
.pager .pg.cur {
|
||||||
|
background: linear-gradient(180deg, var(--ys-indigo-hi), var(--ys-indigo));
|
||||||
|
border-color: transparent; color: #fff;
|
||||||
|
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.18), 0 1px 2px rgba(46, 75, 168, 0.22);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- 应用界面 ---------- */
|
||||||
|
.app-shot {
|
||||||
|
border: 1px solid var(--ys-line); border-radius: 14px; overflow: hidden;
|
||||||
|
background: var(--ys-mist); box-shadow: 0 12px 40px rgba(28, 35, 51, 0.1);
|
||||||
|
}
|
||||||
|
.app { display: grid; grid-template-columns: 220px 1fr; min-height: 620px; }
|
||||||
|
.aside {
|
||||||
|
background: linear-gradient(180deg, var(--ys-aside-1), var(--ys-aside-2));
|
||||||
|
border-right: 1px solid var(--ys-line);
|
||||||
|
padding: 20px 14px; display: flex; flex-direction: column;
|
||||||
|
}
|
||||||
|
.aside .logo { font-family: var(--ys-font-brand); font-size: 15px; font-weight: 500; letter-spacing: 0.3em; color: var(--ys-ink); padding: 0 10px; }
|
||||||
|
.aside .logo-sub { font-size: 11px; color: var(--ys-ink-3); letter-spacing: 0.06em; padding: 0 10px; margin-top: 4px; }
|
||||||
|
.menu-group { margin-top: 26px; }
|
||||||
|
.menu-label { font-size: 11px; letter-spacing: 0.1em; color: var(--ys-ink-3); padding: 0 10px; margin-bottom: 8px; }
|
||||||
|
.menu-item {
|
||||||
|
display: flex; align-items: center; gap: 10px; padding: 9px 10px; border-radius: var(--ys-radius-sm);
|
||||||
|
font-size: 13px; color: var(--ys-ink-2); position: relative; margin-bottom: 2px;
|
||||||
|
transition: all 150ms var(--ys-ease);
|
||||||
|
}
|
||||||
|
.menu-item:hover { color: var(--ys-ink); background: rgba(255, 255, 255, 0.55); }
|
||||||
|
/* 当前项:白底「浮起」——微渐变 + 内高光 + 分层阴影 */
|
||||||
|
.menu-item.active {
|
||||||
|
color: var(--ys-indigo); font-weight: 500;
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
.menu-item.active::before {
|
||||||
|
content: ''; position: absolute; left: -14px; top: 8px; bottom: 8px; width: 2px;
|
||||||
|
border-radius: 1px; background: var(--ys-indigo);
|
||||||
|
}
|
||||||
|
.menu-item svg { width: 16px; height: 16px; flex: none; opacity: 0.7; }
|
||||||
|
.menu-item.active svg { opacity: 1; }
|
||||||
|
.aside-foot { margin-top: auto; padding: 0 10px 2px; }
|
||||||
|
.aside-foot-text { font-size: 11px; color: var(--ys-ink-3); letter-spacing: 0.06em; }
|
||||||
|
|
||||||
|
.main { display: flex; flex-direction: column; }
|
||||||
|
.topbar {
|
||||||
|
height: 52px;
|
||||||
|
background: linear-gradient(180deg, #ffffff, #fdfdfe);
|
||||||
|
border-bottom: 1px solid var(--ys-line);
|
||||||
|
box-shadow: 0 1px 0 rgba(28, 35, 51, 0.015);
|
||||||
|
display: flex; align-items: center; justify-content: space-between; padding: 0 22px;
|
||||||
|
}
|
||||||
|
.crumb { font-size: 13px; color: var(--ys-ink-3); }
|
||||||
|
.crumb b { color: var(--ys-ink); font-weight: 500; }
|
||||||
|
.topbar .who { display: flex; align-items: center; gap: 12px; font-size: 13px; color: var(--ys-ink-2); }
|
||||||
|
.topbar .who .logout { color: var(--ys-ink-3); }
|
||||||
|
.topbar .who .avatar {
|
||||||
|
width: 26px; height: 26px; border-radius: 50%;
|
||||||
|
background: linear-gradient(180deg, var(--ys-indigo-wash), #e4eaf7);
|
||||||
|
box-shadow: inset 0 1px 0 #fff, 0 1px 2px rgba(28, 35, 51, 0.06);
|
||||||
|
color: var(--ys-indigo); font-size: 12px;
|
||||||
|
display: inline-flex; align-items: center; justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content {
|
||||||
|
padding: 22px; flex: 1;
|
||||||
|
/* 顶部受光:白光 + 靛蓝微光叠加,内容区有「空气感」 */
|
||||||
|
background:
|
||||||
|
radial-gradient(1100px 320px at 50% -110px, rgba(46, 75, 168, 0.055), rgba(46, 75, 168, 0) 65%),
|
||||||
|
radial-gradient(900px 220px at 50% -80px, rgba(255, 255, 255, 0.85), rgba(255, 255, 255, 0) 70%);
|
||||||
|
}
|
||||||
|
.page-head { display: flex; align-items: flex-end; justify-content: space-between; margin-bottom: 18px; }
|
||||||
|
.page-head h1 { font-size: 18px; font-weight: 600; letter-spacing: -0.01em; }
|
||||||
|
.page-head .desc { font-size: 12px; color: var(--ys-ink-3); margin-top: 4px; }
|
||||||
|
|
||||||
|
.filter {
|
||||||
|
background: linear-gradient(180deg, #ffffff, #fcfcfe);
|
||||||
|
border: 1px solid var(--ys-line); border-radius: var(--ys-radius);
|
||||||
|
padding: 14px 16px; display: flex; gap: 10px; align-items: center; margin-bottom: 14px;
|
||||||
|
box-shadow: var(--ys-shadow-card);
|
||||||
|
}
|
||||||
|
.filter .spacer { flex: 1; }
|
||||||
|
|
||||||
|
.card {
|
||||||
|
background: linear-gradient(180deg, #ffffff, #fcfcfe);
|
||||||
|
border: 1px solid var(--ys-line); border-radius: var(--ys-radius);
|
||||||
|
box-shadow: var(--ys-shadow-card); overflow: hidden;
|
||||||
|
}
|
||||||
|
table.grid { width: 100%; border-collapse: collapse; }
|
||||||
|
table.grid th {
|
||||||
|
text-align: left; font-size: 12px; font-weight: 500; letter-spacing: 0.04em;
|
||||||
|
color: var(--ys-ink-2); background: linear-gradient(180deg, #fafbfc, #f5f6f9);
|
||||||
|
padding: 10px 16px; border-bottom: 1px solid var(--ys-line);
|
||||||
|
}
|
||||||
|
table.grid td { padding: 12px 16px; border-bottom: 1px solid var(--ys-line-soft); font-size: 13px; }
|
||||||
|
table.grid tr:last-child td { border-bottom: 0; }
|
||||||
|
table.grid tbody tr { transition: background 120ms var(--ys-ease); }
|
||||||
|
table.grid tbody tr:hover { background: #f7f9fd; }
|
||||||
|
.cell-code { font-family: 'SFMono-Regular', Consolas, monospace; font-size: 12px; color: var(--ys-ink-2); }
|
||||||
|
.cell-muted { color: var(--ys-ink-2); }
|
||||||
|
.cell-actions a { color: var(--ys-indigo); font-size: 13px; margin-right: 12px; cursor: pointer; }
|
||||||
|
.cell-actions a.muted { color: var(--ys-ink-3); }
|
||||||
|
.card-foot { padding: 12px 16px; display: flex; justify-content: flex-end; border-top: 1px solid var(--ys-line); background: linear-gradient(180deg, #fdfdfe, #fbfcfe); }
|
||||||
|
|
||||||
|
/* ---------- 全局规范表 ---------- */
|
||||||
|
.spec-table { width: 100%; border-collapse: collapse; font-size: 12.5px; }
|
||||||
|
.spec-table td { padding: 7px 8px; border-bottom: 1px solid var(--ys-line-soft); color: var(--ys-ink-2); vertical-align: top; line-height: 1.6; }
|
||||||
|
.spec-table tr:last-child td { border-bottom: 0; }
|
||||||
|
.spec-table td:first-child { color: var(--ys-ink); font-weight: 500; white-space: nowrap; }
|
||||||
|
.spec-table td.num { text-align: right; white-space: nowrap; font-variant-numeric: tabular-nums; }
|
||||||
|
.spec-table td.bad { color: var(--ys-ink-3); text-decoration: line-through; text-decoration-color: rgba(201, 79, 79, 0.35); }
|
||||||
|
.spec-table td.good { color: var(--ys-ink); }
|
||||||
|
|
||||||
|
/* ---------- 首页仪表盘(Bento Grid) ---------- */
|
||||||
|
.bento { display: grid; grid-template-columns: repeat(4, 1fr); grid-auto-rows: 102px; gap: 14px; }
|
||||||
|
.bento-card {
|
||||||
|
background: linear-gradient(180deg, #ffffff, #fcfcfe);
|
||||||
|
border: 1px solid var(--ys-line); border-radius: var(--ys-radius);
|
||||||
|
box-shadow: var(--ys-shadow-card);
|
||||||
|
padding: 17px 20px; display: flex; flex-direction: column;
|
||||||
|
/* 微动效:入场交错上浮 + hover 抬升 2px(prefers-reduced-motion 下全部关闭) */
|
||||||
|
animation: rise 320ms var(--ys-ease) backwards;
|
||||||
|
transition: transform 180ms var(--ys-ease), box-shadow 180ms var(--ys-ease);
|
||||||
|
}
|
||||||
|
.bento-card:hover { transform: translateY(-2px); box-shadow: var(--ys-shadow-raise); }
|
||||||
|
.b-head { display: flex; align-items: center; justify-content: space-between; }
|
||||||
|
.b-icon { width: 15px; height: 15px; color: var(--ys-ink-3); opacity: 0.8; }
|
||||||
|
.bento-card:nth-child(1) { animation-delay: 0ms; }
|
||||||
|
.bento-card:nth-child(2) { animation-delay: 45ms; }
|
||||||
|
.bento-card:nth-child(3) { animation-delay: 90ms; }
|
||||||
|
.bento-card:nth-child(4) { animation-delay: 135ms; }
|
||||||
|
.bento-card:nth-child(5) { animation-delay: 180ms; }
|
||||||
|
.bento-card:nth-child(6) { animation-delay: 225ms; }
|
||||||
|
.bento-card:nth-child(7) { animation-delay: 270ms; }
|
||||||
|
.bento-card:nth-child(8) { animation-delay: 315ms; }
|
||||||
|
@keyframes rise { from { opacity: 0; transform: translateY(6px); } to { opacity: 1; transform: translateY(0); } }
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.bento-card, .login-card { animation: none; transition: none; }
|
||||||
|
}
|
||||||
|
.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-big { font-size: 30px; font-weight: 600; letter-spacing: -0.02em; font-variant-numeric: tabular-nums; line-height: 1.15; margin-top: auto; }
|
||||||
|
.b-big small { font-size: 13px; font-weight: 500; color: var(--ys-ink-3); margin-left: 4px; letter-spacing: 0; }
|
||||||
|
.b-num { font-size: 24px; font-weight: 600; letter-spacing: -0.01em; font-variant-numeric: tabular-nums; line-height: 1.2; margin-top: auto; }
|
||||||
|
.b-num small { font-size: 12px; font-weight: 400; color: var(--ys-ink-3); margin-left: 3px; }
|
||||||
|
.b-sub { font-size: 11px; color: var(--ys-ink-3); margin-top: 3px; }
|
||||||
|
.b-up { color: var(--ys-ok); font-weight: 500; }
|
||||||
|
.b-todo { display: flex; align-items: baseline; gap: 8px; font-size: 13px; color: var(--ys-ink-2); }
|
||||||
|
.b-todo .cnt { color: var(--ys-indigo); font-weight: 600; font-variant-numeric: tabular-nums; }
|
||||||
|
|
||||||
|
/* ---------- 登录页 ---------- */
|
||||||
|
.login-shot { border: 1px solid var(--ys-line); border-radius: 14px; overflow: hidden; box-shadow: 0 12px 40px rgba(28, 35, 51, 0.1); }
|
||||||
|
.login { display: grid; grid-template-columns: 460px 1fr; min-height: 520px; }
|
||||||
|
.login-brand {
|
||||||
|
background: linear-gradient(168deg, #2c4aa6 0%, #223a85 55%, #17275c 100%);
|
||||||
|
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.12);
|
||||||
|
padding: 46px 44px; display: flex; flex-direction: column; justify-content: space-between;
|
||||||
|
position: relative; overflow: hidden;
|
||||||
|
}
|
||||||
|
.login-brand .brandmark { font-family: var(--ys-font-brand); font-size: 19px; font-weight: 500; letter-spacing: 0.36em; color: #fff; }
|
||||||
|
.login-brand .slogan { font-size: 15px; color: rgba(255, 255, 255, 0.82); margin-top: 14px; line-height: 1.9; }
|
||||||
|
.login-brand .foot { font-size: 12px; color: rgba(255, 255, 255, 0.42); letter-spacing: 0.08em; }
|
||||||
|
.ripple { position: absolute; inset: auto 0 0 0; height: 220px; pointer-events: none; }
|
||||||
|
.ripple i { display: block; height: 1px; margin-top: 26px; }
|
||||||
|
.ripple i:nth-child(1) { background: linear-gradient(90deg, rgba(255,255,255,0.36), rgba(255,255,255,0.02)); }
|
||||||
|
.ripple i:nth-child(2) { margin-top: 22px; width: 92%; background: linear-gradient(90deg, rgba(255,255,255,0.30), rgba(255,255,255,0.02)); }
|
||||||
|
.ripple i:nth-child(3) { margin-top: 18px; width: 84%; background: linear-gradient(90deg, rgba(255,255,255,0.25), rgba(255,255,255,0.02)); }
|
||||||
|
.ripple i:nth-child(4) { margin-top: 15px; width: 74%; background: linear-gradient(90deg, rgba(255,255,255,0.20), rgba(255,255,255,0.02)); }
|
||||||
|
.ripple i:nth-child(5) { margin-top: 12px; width: 62%; background: linear-gradient(90deg, rgba(255,255,255,0.15), rgba(255,255,255,0.02)); }
|
||||||
|
.ripple i:nth-child(6) { margin-top: 10px; width: 48%; background: linear-gradient(90deg, rgba(255,255,255,0.11), rgba(255,255,255,0.02)); }
|
||||||
|
.login-form {
|
||||||
|
background: linear-gradient(180deg, #ffffff, #fdfdff);
|
||||||
|
box-shadow: inset 0 1px 0 #fff;
|
||||||
|
padding: 46px 56px; display: flex; flex-direction: column; justify-content: center;
|
||||||
|
}
|
||||||
|
.login-form h2 { font-size: 18px; font-weight: 600; letter-spacing: -0.01em; }
|
||||||
|
.login-form .hint { font-size: 13px; color: var(--ys-ink-3); margin: 6px 0 26px; }
|
||||||
|
.field { margin-bottom: 16px; }
|
||||||
|
.field label { display: block; font-size: 13px; color: var(--ys-ink-2); margin-bottom: 6px; }
|
||||||
|
.field .input { width: 100%; height: 38px; }
|
||||||
|
.captcha-row { display: flex; gap: 10px; }
|
||||||
|
.captcha-row .input { flex: 1; }
|
||||||
|
.captcha-box {
|
||||||
|
width: 104px; height: 38px; border: 1px solid var(--ys-line); border-radius: var(--ys-radius-sm);
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
font-family: 'SFMono-Regular', Consolas, monospace; font-size: 17px; letter-spacing: 0.28em;
|
||||||
|
color: var(--ys-indigo); background: linear-gradient(180deg, #f7f8fb, #f1f3f8);
|
||||||
|
box-shadow: inset 0 1px 2px rgba(28, 35, 51, 0.04); user-select: none;
|
||||||
|
}
|
||||||
|
.login-btn { width: 100%; height: 38px; margin-top: 6px; font-size: 14px; }
|
||||||
|
|
||||||
|
/* 方案 B:居中卡片 */
|
||||||
|
.login-center {
|
||||||
|
position: relative; overflow: hidden;
|
||||||
|
min-height: 480px; display: flex; align-items: center; justify-content: center;
|
||||||
|
background-color: #f2f4f8;
|
||||||
|
background-image: radial-gradient(rgba(28, 35, 51, 0.07) 1px, transparent 1px);
|
||||||
|
background-size: 24px 24px;
|
||||||
|
padding: 40px;
|
||||||
|
}
|
||||||
|
/* 顶部光晕:靛蓝的「天光」,柔和地洒在登录卡上 */
|
||||||
|
.login-center::before {
|
||||||
|
content: ''; position: absolute; top: -160px; left: 50%; transform: translateX(-50%);
|
||||||
|
width: 860px; height: 460px; pointer-events: none;
|
||||||
|
background: radial-gradient(closest-side, rgba(46, 75, 168, 0.08), rgba(46, 75, 168, 0));
|
||||||
|
}
|
||||||
|
/* 背景涟漪:同心圆水波(「衍水」的品牌装饰,右上入画) */
|
||||||
|
.ripple-bg { position: absolute; top: -170px; right: -130px; width: 520px; height: 520px; pointer-events: none; }
|
||||||
|
.ripple-bg i {
|
||||||
|
position: absolute; inset: 0; border-radius: 50%;
|
||||||
|
border: 1px solid rgba(46, 75, 168, 0.09);
|
||||||
|
}
|
||||||
|
.ripple-bg i:nth-child(2) { inset: 65px; border-color: rgba(46, 75, 168, 0.07); }
|
||||||
|
.ripple-bg i:nth-child(3) { inset: 130px; border-color: rgba(46, 75, 168, 0.055); }
|
||||||
|
.ripple-bg i:nth-child(4) { inset: 195px; border: none; background: radial-gradient(circle, rgba(46, 75, 168, 0.05), rgba(46, 75, 168, 0.015)); }
|
||||||
|
.login-card {
|
||||||
|
position: relative; overflow: hidden;
|
||||||
|
animation: rise 320ms var(--ys-ease) backwards;
|
||||||
|
width: 400px;
|
||||||
|
background: linear-gradient(180deg, #ffffff, #fcfcfe);
|
||||||
|
border: 1px solid var(--ys-line); border-radius: 14px;
|
||||||
|
box-shadow: 0 2px 4px rgba(28, 35, 51, 0.04), 0 16px 40px rgba(28, 35, 51, 0.1);
|
||||||
|
padding: 30px 36px 32px;
|
||||||
|
}
|
||||||
|
.lc-brand { text-align: center; }
|
||||||
|
.lc-brand-name { font-family: var(--ys-font-brand); font-size: 17px; font-weight: 500; letter-spacing: 0.34em; color: var(--ys-ink); }
|
||||||
|
.lc-slogan { font-size: 12.5px; color: var(--ys-ink-3); margin-top: 8px; letter-spacing: 0.02em; }
|
||||||
|
.lc-form-head { font-size: 15px; font-weight: 600; margin-top: 26px; }
|
||||||
|
.lc-form-hint { font-size: 12px; color: var(--ys-ink-3); margin: 4px 0 18px; }
|
||||||
|
.lc-foot { text-align: center; margin-top: 16px; font-size: 11px; color: var(--ys-ink-3); letter-spacing: 0.08em; }
|
||||||
|
.login-card .input { width: 100%; height: 38px; }
|
||||||
|
|
||||||
|
.note { margin-top: 18px; font-size: 13px; color: var(--ys-ink-2); line-height: 1.9; }
|
||||||
|
.note b { color: var(--ys-ink); font-weight: 600; }
|
||||||
|
.states { display: flex; gap: 14px; margin-top: 12px; }
|
||||||
|
.state { text-align: center; }
|
||||||
|
.state .cap { font-size: 11px; color: var(--ys-ink-3); margin-top: 6px; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="wrap">
|
||||||
|
|
||||||
|
<!-- ============ 封面 ============ -->
|
||||||
|
<header class="cover" id="sec-cover">
|
||||||
|
<div class="brand">衍水集团</div>
|
||||||
|
<h1>营销人员工资管理系统 · 视觉方向</h1>
|
||||||
|
<div class="sub">企业级扁平 · 轻质感 · Bento 布局 · 品牌色点缀 · 微动效 · 精致文案</div>
|
||||||
|
<div class="waterline"></div>
|
||||||
|
<div class="tags">
|
||||||
|
<span class="tag">表面微渐变(受光)</span>
|
||||||
|
<span class="tag">半透明描边</span>
|
||||||
|
<span class="tag">分层阴影</span>
|
||||||
|
<span class="tag">内高光 / 内阴影</span>
|
||||||
|
<span class="tag">2% 噪点</span>
|
||||||
|
<span class="tag">等宽数字</span>
|
||||||
|
<span class="tag">120–180ms 轻动效</span>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<!-- ============ 色板 ============ -->
|
||||||
|
<section class="section" id="sec-palette">
|
||||||
|
<h2>01 · 色板与材质</h2>
|
||||||
|
<div class="swatches">
|
||||||
|
<div class="swatch">
|
||||||
|
<div class="chip" style="background:linear-gradient(180deg,#3a58bc,#2e4ba8)"></div>
|
||||||
|
<div class="meta"><div class="name">靛蓝</div><div class="hex num">#2E4BA8 ↑#3A58BC</div>
|
||||||
|
<div class="use">品牌色。按钮/链接/焦点/当前菜单,配微渐变与内高光</div></div>
|
||||||
|
</div>
|
||||||
|
<div class="swatch">
|
||||||
|
<div class="chip" style="background:linear-gradient(180deg,#f2f4f8,#ecedf3)"></div>
|
||||||
|
<div class="meta"><div class="name">侧栏灰</div><div class="hex num">#F2F4F8 → #ECEDF3</div>
|
||||||
|
<div class="use">侧栏微渐变(自上而下微沉)</div></div>
|
||||||
|
</div>
|
||||||
|
<div class="swatch">
|
||||||
|
<div class="chip" style="background:#f4f5f8"></div>
|
||||||
|
<div class="meta"><div class="name">雾灰</div><div class="hex num">#F4F5F8</div>
|
||||||
|
<div class="use">页面底</div></div>
|
||||||
|
</div>
|
||||||
|
<div class="swatch">
|
||||||
|
<div class="chip" style="background:linear-gradient(180deg,#ffffff,#fcfcfe);border-bottom:1px solid #e3e6ec"></div>
|
||||||
|
<div class="meta"><div class="name">纸白</div><div class="hex num">#FFFFFF → #FCFCFE</div>
|
||||||
|
<div class="use">卡片/表格:顶白底微灰的「受光」渐变</div></div>
|
||||||
|
</div>
|
||||||
|
<div class="swatch">
|
||||||
|
<div class="chip" style="background:#e4e7ec"></div>
|
||||||
|
<div class="meta"><div class="name">界线</div><div class="hex num">rgba(28,35,51,.09)</div>
|
||||||
|
<div class="use">1px 半透明描边:在不同底色上自然融合</div></div>
|
||||||
|
</div>
|
||||||
|
<div class="swatch">
|
||||||
|
<div class="chip" style="background:#1c2333"></div>
|
||||||
|
<div class="meta"><div class="name">墨色</div><div class="hex num">#1C2333</div>
|
||||||
|
<div class="use">主文字(冷调近黑)</div></div>
|
||||||
|
</div>
|
||||||
|
<div class="swatch">
|
||||||
|
<div class="chip" style="background:#5b6577"></div>
|
||||||
|
<div class="meta"><div class="name">次墨</div><div class="hex num">#5B6577</div>
|
||||||
|
<div class="use">次级文字、表头、说明</div></div>
|
||||||
|
</div>
|
||||||
|
<div class="swatch">
|
||||||
|
<div class="chip" style="background:linear-gradient(90deg,#1e9e6a,#c98a1b,#c94f4f)"></div>
|
||||||
|
<div class="meta"><div class="name">语义色</div><div class="hex num">#1E9E6A · #C98A1B · #C94F4F</div>
|
||||||
|
<div class="use">成功/警示/危险,浅底微渐变 + 深字</div></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ============ 字体与数字 ============ -->
|
||||||
|
<section class="section" id="sec-type">
|
||||||
|
<h2>02 · 字体与数字</h2>
|
||||||
|
<div class="type-sheet">
|
||||||
|
<div class="type-row"><span class="label">页面标题</span><span class="t-page-title">岗位角色</span><span class="label" style="width:auto">18px / 600 · 字距 −0.01em</span></div>
|
||||||
|
<div class="type-row"><span class="label">卡片标题</span><span class="t-card-title">计提参数 · 业务员个人户</span><span class="label" style="width:auto">15px / 600</span></div>
|
||||||
|
<div class="type-row"><span class="label">正文 / 表格</span><span class="t-body">回款天数 = 发货日首日起算,按自然日累计</span><span class="label" style="width:auto">14px / 400 · 13px / 400</span></div>
|
||||||
|
<div class="type-row"><span class="label">表头 / 标签</span><span class="t-th">岗位编码 公式模板 生效日期</span><span class="label" style="width:auto">12px / 500 · 字距 +0.04em</span></div>
|
||||||
|
<div class="type-row"><span class="label">品牌字</span><span style="font-weight:600;letter-spacing:.35em;font-size:15px">衍水集团</span><span class="label" style="width:auto">无衬线 600 · 大字距 · 仅品牌落款一处</span></div>
|
||||||
|
</div>
|
||||||
|
<div class="amount-demo">
|
||||||
|
<table>
|
||||||
|
<thead><tr><th>回款日期</th><th>回款金额(元)</th><th>回款天数</th><th>计提比例</th><th>绩效金额(元)</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
<tr><td>2026-01-10</td><td>386,200.00</td><td>27</td><td>9.7‰</td><td>14,062.31</td></tr>
|
||||||
|
<tr><td>2026-02-18</td><td>1,240,000.00</td><td>361</td><td>4‰</td><td>19,840.00</td></tr>
|
||||||
|
<tr><td>合计</td><td>1,626,200.00</td><td>—</td><td>—</td><td>33,902.31</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<div class="note">金额与比例一律<b>等宽数字(tabular-nums)右对齐</b>,合计行加粗放大半档——列对列、位对位,扫一眼就能核对。</div>
|
||||||
|
</div>
|
||||||
|
<div class="brand-candidates">
|
||||||
|
<div class="bc">
|
||||||
|
<div style="font-family:'Noto Sans SC',sans-serif;font-weight:500;letter-spacing:.32em;font-size:16px">衍水集团</div>
|
||||||
|
<div class="bc-cap">A · 思源黑体 500 · 0.32em <span class="bc-rec">当前应用</span></div>
|
||||||
|
</div>
|
||||||
|
<div class="bc">
|
||||||
|
<div style="font-family:'Noto Sans SC',sans-serif;font-weight:300;letter-spacing:.42em;font-size:16px">衍水集团</div>
|
||||||
|
<div class="bc-cap">B · 思源黑体 300 细体 · 0.42em</div>
|
||||||
|
</div>
|
||||||
|
<div class="bc">
|
||||||
|
<div style="font-family:'Microsoft YaHei',sans-serif;font-weight:600;letter-spacing:.25em;font-size:16px">衍水集团</div>
|
||||||
|
<div class="bc-cap">C · 微软雅黑 600 · 0.25em</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ============ 组件 ============ -->
|
||||||
|
<section class="section" id="sec-comp">
|
||||||
|
<h2>03 · 组件基调</h2>
|
||||||
|
<div class="comp-grid">
|
||||||
|
<div class="comp-card">
|
||||||
|
<h3>按钮 · 三态</h3>
|
||||||
|
<div class="states">
|
||||||
|
<div class="state"><button class="btn btn-primary btn-ghost-hover">新增岗位</button><div class="cap">静置</div></div>
|
||||||
|
<div class="state"><button class="btn btn-primary" style="background:linear-gradient(180deg,#31509f,#243c8b);transform:translateY(-1px);box-shadow:inset 0 1px 0 rgba(255,255,255,.18),0 4px 12px rgba(46,75,168,.3)">新增岗位</button><div class="cap">悬停:抬升 1px · 影加深</div></div>
|
||||||
|
<div class="state"><button class="btn btn-primary" style="box-shadow:inset 0 1px 2px rgba(0,0,0,.18)">新增岗位</button><div class="cap">按下:回落 · 内阴影</div></div>
|
||||||
|
</div>
|
||||||
|
<div class="note">微渐变 + 内高光给按钮「实体感」;次级按钮一律白底细边框,无阴影污染。</div>
|
||||||
|
</div>
|
||||||
|
<div class="comp-card">
|
||||||
|
<h3>输入框 · 状态</h3>
|
||||||
|
<div class="row">
|
||||||
|
<input class="input" placeholder="搜索岗位编码或名称" />
|
||||||
|
<input class="input focused" value="4/360" />
|
||||||
|
</div>
|
||||||
|
<div class="row" style="margin-top:14px">
|
||||||
|
<span class="pill pill-ok">启用</span>
|
||||||
|
<span class="pill pill-off">禁用</span>
|
||||||
|
<span class="pill pill-warn">待生效</span>
|
||||||
|
</div>
|
||||||
|
<div class="note">输入框带微内阴影(凹陷暗示);聚焦 = 靛蓝描边 + 3px 柔光外环。</div>
|
||||||
|
</div>
|
||||||
|
<div class="comp-card">
|
||||||
|
<h3>分页</h3>
|
||||||
|
<div class="pager">
|
||||||
|
<span class="pg">‹</span><span class="pg cur">1</span><span class="pg">2</span><span class="pg">3</span><span class="pg">›</span>
|
||||||
|
<span style="margin-left:10px">共 28 条 · 每页 10 条</span>
|
||||||
|
</div>
|
||||||
|
<div class="note" style="margin-top:14px">当前页与主按钮同材质:微渐变 + 内高光。</div>
|
||||||
|
</div>
|
||||||
|
<div class="comp-card">
|
||||||
|
<h3>动效原则</h3>
|
||||||
|
<div class="note">
|
||||||
|
过渡 120–180ms,<b>只做颜色、阴影与 1px 内的位移</b>;弹层淡入 + 4px 上移;
|
||||||
|
数字与表格不加动画;尊重 <b>prefers-reduced-motion</b>。
|
||||||
|
动效的目标是「顺滑可信」,不是表演。
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ============ 应用界面 ============ -->
|
||||||
|
<section class="section" id="sec-app">
|
||||||
|
<h2>04 · 应用界面(布局与列表页)</h2>
|
||||||
|
<div class="app-shot">
|
||||||
|
<div class="app">
|
||||||
|
<aside class="aside">
|
||||||
|
<div class="logo">衍水集团</div>
|
||||||
|
<div class="logo-sub">营销人员工资管理系统</div>
|
||||||
|
<div class="waterline" style="width:22px;margin:10px 10px 0;"></div>
|
||||||
|
<div class="menu-group">
|
||||||
|
<div class="menu-item">
|
||||||
|
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linejoin="round"><path d="M2.5 7 8 2.5 13.5 7v6.5H10V9.5H6v4H2.5Z"/></svg>
|
||||||
|
首页
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="menu-group">
|
||||||
|
<div class="menu-label">系统管理</div>
|
||||||
|
<div class="menu-item">
|
||||||
|
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"><circle cx="8" cy="5.5" r="2.5"/><path d="M3 13.5c0-2.5 2.2-4 5-4s5 1.5 5 4"/></svg>
|
||||||
|
用户管理
|
||||||
|
</div>
|
||||||
|
<div class="menu-item">
|
||||||
|
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linejoin="round"><path d="M8 2 13 4v4c0 3-2 5-5 6-3-1-5-3-5-6V4Z"/></svg>
|
||||||
|
角色管理
|
||||||
|
</div>
|
||||||
|
<div class="menu-item active">
|
||||||
|
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"><rect x="2.5" y="3.5" width="11" height="9" rx="1.5"/><circle cx="6" cy="7.5" r="1.2"/><path d="M4.5 11c.3-1 1-1.5 1.5-1.5s1.2.5 1.5 1.5M9.5 6.5h2M9.5 9h2"/></svg>
|
||||||
|
岗位角色
|
||||||
|
</div>
|
||||||
|
<div class="menu-item">
|
||||||
|
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"><path d="M3 5h10M3 11h10"/><circle cx="6" cy="5" r="1.6"/><circle cx="10" cy="11" r="1.6"/></svg>
|
||||||
|
计提参数
|
||||||
|
</div>
|
||||||
|
<div class="menu-item">
|
||||||
|
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"><path d="M4 4.5h8M4 8h8M4 11.5h5"/></svg>
|
||||||
|
操作日志
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="aside-foot">
|
||||||
|
<div class="waterline" style="width:22px;margin:0 0 8px;"></div>
|
||||||
|
<div class="aside-foot-text">内部系统 · v0.1</div>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
<div class="main">
|
||||||
|
<div class="topbar">
|
||||||
|
<div class="crumb">系统管理 <span>/</span> <b>岗位角色</b></div>
|
||||||
|
<div class="who"><span class="avatar">管</span><span>管理员</span><span class="logout">退出登录</span></div>
|
||||||
|
</div>
|
||||||
|
<div class="content">
|
||||||
|
<div class="page-head">
|
||||||
|
<div>
|
||||||
|
<h1>岗位角色</h1>
|
||||||
|
<div class="desc">决定人员适用的计提公式模板与参数;新增岗位无需改代码</div>
|
||||||
|
<div class="waterline"></div>
|
||||||
|
</div>
|
||||||
|
<button class="btn btn-primary">新增岗位</button>
|
||||||
|
</div>
|
||||||
|
<div class="filter">
|
||||||
|
<input class="input" placeholder="岗位编码或名称" />
|
||||||
|
<input class="input" placeholder="状态:全部" style="width:130px" />
|
||||||
|
<button class="btn btn-primary">查询</button>
|
||||||
|
<button class="btn btn-default">重置</button>
|
||||||
|
<span class="spacer"></span>
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<table class="grid">
|
||||||
|
<thead>
|
||||||
|
<tr><th>岗位编码</th><th style="width:110px">岗位名称</th><th>公式模板</th><th>说明</th><th style="width:90px">状态</th><th style="width:150px">操作</th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td class="cell-code">salesperson</td><td>营销员</td>
|
||||||
|
<td class="cell-muted">业务员个人户 · 天数线性递减</td>
|
||||||
|
<td class="cell-muted">回款天数线性递减(360 天=6‰,361 天起 4‰)</td>
|
||||||
|
<td><span class="pill pill-ok">启用</span></td>
|
||||||
|
<td class="cell-actions"><a>编辑</a><a class="muted" title="内置岗位不可删除">删除</a></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td class="cell-code">sales_manager</td><td>营销经理</td>
|
||||||
|
<td class="cell-muted">营销经理 · 团队与全公司分成</td>
|
||||||
|
<td class="cell-muted">本团队 12.5% + 全公司 5%</td>
|
||||||
|
<td><span class="pill pill-ok">启用</span></td>
|
||||||
|
<td class="cell-actions"><a>编辑</a><a class="muted" title="内置岗位不可删除">删除</a></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td class="cell-code">info_officer</td><td>信息员</td>
|
||||||
|
<td class="cell-muted">信息员 · 销售额固定比例</td>
|
||||||
|
<td class="cell-muted">本人销售额 × 1‰</td>
|
||||||
|
<td><span class="pill pill-ok">启用</span></td>
|
||||||
|
<td class="cell-actions"><a>编辑</a><a class="muted" title="内置岗位不可删除">删除</a></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td class="cell-code">senior_sales</td><td>高级业务员</td>
|
||||||
|
<td class="cell-muted">业务员个人户 · 天数线性递减</td>
|
||||||
|
<td class="cell-muted">自定义岗位(新增岗位无需改代码)</td>
|
||||||
|
<td><span class="pill pill-off">禁用</span></td>
|
||||||
|
<td class="cell-actions"><a>编辑</a><a>删除</a></td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<div class="card-foot">
|
||||||
|
<div class="pager">
|
||||||
|
<span class="pg">‹</span><span class="pg cur">1</span><span class="pg">›</span>
|
||||||
|
<span style="margin-left:10px">共 4 条</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="note">
|
||||||
|
质感的三层材料:<b>侧栏</b>灰渐变(沉)→ <b>内容底</b>雾灰 + 顶部受光 → <b>卡片</b>纸白微渐变 + 半透明描边 + 分层阴影。
|
||||||
|
当前项白底浮起,白色被灰托住,不再扎眼。
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ============ 首页仪表盘 ============ -->
|
||||||
|
<section class="section" id="sec-bento">
|
||||||
|
<h2>05 · 首页仪表盘(Bento Grid)</h2>
|
||||||
|
<div class="app-shot">
|
||||||
|
<div class="app">
|
||||||
|
<aside class="aside">
|
||||||
|
<div class="logo">衍水集团</div>
|
||||||
|
<div class="logo-sub">营销人员工资管理系统</div>
|
||||||
|
<div class="waterline" style="width:22px;margin:10px 10px 0;"></div>
|
||||||
|
<div class="menu-group">
|
||||||
|
<div class="menu-item active">
|
||||||
|
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linejoin="round"><path d="M2.5 7 8 2.5 13.5 7v6.5H10V9.5H6v4H2.5Z"/></svg>
|
||||||
|
首页
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="menu-group">
|
||||||
|
<div class="menu-label">系统管理</div>
|
||||||
|
<div class="menu-item">
|
||||||
|
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"><circle cx="8" cy="5.5" r="2.5"/><path d="M3 13.5c0-2.5 2.2-4 5-4s5 1.5 5 4"/></svg>
|
||||||
|
用户管理
|
||||||
|
</div>
|
||||||
|
<div class="menu-item">
|
||||||
|
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linejoin="round"><path d="M8 2 13 4v4c0 3-2 5-5 6-3-1-5-3-5-6V4Z"/></svg>
|
||||||
|
角色管理
|
||||||
|
</div>
|
||||||
|
<div class="menu-item">
|
||||||
|
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"><rect x="2.5" y="3.5" width="11" height="9" rx="1.5"/><circle cx="6" cy="7.5" r="1.2"/><path d="M4.5 11c.3-1 1-1.5 1.5-1.5s1.2.5 1.5 1.5M9.5 6.5h2M9.5 9h2"/></svg>
|
||||||
|
岗位角色
|
||||||
|
</div>
|
||||||
|
<div class="menu-item">
|
||||||
|
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"><path d="M3 5h10M3 11h10"/><circle cx="6" cy="5" r="1.6"/><circle cx="10" cy="11" r="1.6"/></svg>
|
||||||
|
计提参数
|
||||||
|
</div>
|
||||||
|
<div class="menu-item">
|
||||||
|
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"><path d="M4 4.5h8M4 8h8M4 11.5h5"/></svg>
|
||||||
|
操作日志
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="aside-foot">
|
||||||
|
<div class="waterline" style="width:22px;margin:0 0 8px;"></div>
|
||||||
|
<div class="aside-foot-text">内部系统 · v0.1</div>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
<div class="main">
|
||||||
|
<div class="topbar">
|
||||||
|
<div class="crumb"><b>仪表盘</b></div>
|
||||||
|
<div class="who"><span class="avatar">管</span><span>管理员</span><span class="logout">退出登录</span></div>
|
||||||
|
</div>
|
||||||
|
<div class="content">
|
||||||
|
<div class="page-head">
|
||||||
|
<div>
|
||||||
|
<h1>仪表盘</h1>
|
||||||
|
<div class="desc">数据截至今日 06:00 · 12 笔回款待核算</div>
|
||||||
|
<div class="waterline"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="bento">
|
||||||
|
<div class="bento-card b-2x2">
|
||||||
|
<div class="b-eyebrow">本月绩效总额</div>
|
||||||
|
<div class="b-big" style="color:var(--ys-indigo)">268,430.16<small>元</small></div>
|
||||||
|
<div class="b-sub">较上月 <span class="b-up">+12.4%</span> · 已核销 86 笔</div>
|
||||||
|
<div id="chartPerf" style="width:100%;height:40px;margin-top:12px"></div>
|
||||||
|
</div>
|
||||||
|
<div class="bento-card b-2x2">
|
||||||
|
<div class="b-eyebrow">近 6 个月回款</div>
|
||||||
|
<div class="b-sub" style="margin-top:2px">单位:万元</div>
|
||||||
|
<div id="chartRev" style="width:100%;height:78px;margin-top:auto"></div>
|
||||||
|
</div>
|
||||||
|
<div class="bento-card b-2x1">
|
||||||
|
<div class="b-eyebrow">待办</div>
|
||||||
|
<div style="margin-top:auto;display:flex;flex-direction:column;gap:4px">
|
||||||
|
<div class="b-todo"><span class="cnt">12 笔</span>回款待核算</div>
|
||||||
|
<div class="b-todo"><span class="cnt">3 笔</span>业务归属待确认</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="bento-card">
|
||||||
|
<div class="b-head"><div class="b-eyebrow">营销人员</div>
|
||||||
|
<svg class="b-icon" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"><circle cx="8" cy="5.5" r="2.5"/><path d="M3 13.5c0-2.5 2.2-4 5-4s5 1.5 5 4"/></svg>
|
||||||
|
</div>
|
||||||
|
<div class="b-num">36<small>人</small></div>
|
||||||
|
</div>
|
||||||
|
<div class="bento-card">
|
||||||
|
<div class="b-head"><div class="b-eyebrow">岗位角色</div>
|
||||||
|
<svg class="b-icon" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"><rect x="2.5" y="3.5" width="11" height="9" rx="1.5"/><circle cx="6" cy="7.5" r="1.2"/><path d="M4.5 11c.3-1 1-1.5 1.5-1.5s1.2.5 1.5 1.5M9.5 6.5h2M9.5 9h2"/></svg>
|
||||||
|
</div>
|
||||||
|
<div class="b-num">3<small>个</small></div>
|
||||||
|
</div>
|
||||||
|
<div class="bento-card b-2x1">
|
||||||
|
<div class="b-eyebrow">常用操作</div>
|
||||||
|
<div class="row" style="margin-top:auto">
|
||||||
|
<button class="btn btn-primary" style="height:30px;font-size:12px;padding:0 13px">录入回款</button>
|
||||||
|
<button class="btn btn-default" style="height:30px;font-size:12px;padding:0 13px">计提参数</button>
|
||||||
|
<button class="btn btn-default" style="height:30px;font-size:12px;padding:0 13px">岗位角色</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="bento-card">
|
||||||
|
<div class="b-head"><div class="b-eyebrow">计提参数</div>
|
||||||
|
<svg class="b-icon" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"><path d="M3 5h10M3 11h10"/><circle cx="6" cy="5" r="1.6"/><circle cx="10" cy="11" r="1.6"/></svg>
|
||||||
|
</div>
|
||||||
|
<div class="b-num">16<small>项</small></div>
|
||||||
|
<div class="b-sub">版本 P20260101-00</div>
|
||||||
|
</div>
|
||||||
|
<div class="bento-card">
|
||||||
|
<div class="b-head"><div class="b-eyebrow">T+ 同步</div>
|
||||||
|
<svg class="b-icon" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"><path d="M13 8a5 5 0 1 1-1.5-3.5"/><path d="M13 2.5V5h-2.5"/></svg>
|
||||||
|
</div>
|
||||||
|
<div class="b-num" style="color:var(--ys-ok)">正常</div>
|
||||||
|
<div class="b-sub">每周一 06:00</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="note">
|
||||||
|
<b>Bento Grid</b>:大小不一的卡片按重要性排布——绩效总额与回款趋势占 2×2 主角位,数字型指标 1×1 紧凑排列。
|
||||||
|
卡片入场为 <b>45ms 交错上浮(320ms)</b>,悬停抬升 2px、阴影加深——打开预览即可看到微动效。
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ============ 登录页 ============ -->
|
||||||
|
<section class="section" id="sec-login">
|
||||||
|
<h2>06 · 登录页(重做)</h2>
|
||||||
|
<div class="login-shot">
|
||||||
|
<div class="login-center">
|
||||||
|
<div class="ripple-bg"><i></i><i></i><i></i><i></i></div>
|
||||||
|
<div style="position:relative">
|
||||||
|
<div class="login-card">
|
||||||
|
<div class="lc-brand">
|
||||||
|
<div class="lc-brand-name">衍水集团</div>
|
||||||
|
<div class="lc-slogan">把每一笔回款,算成一份踏实。</div>
|
||||||
|
<div class="waterline" style="width:40px;margin:14px auto 0"></div>
|
||||||
|
</div>
|
||||||
|
<div class="lc-form-head" style="margin-top:28px">欢迎回来</div>
|
||||||
|
<div class="lc-form-hint">请输入账号密码进入系统</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>用户名</label>
|
||||||
|
<input class="input" placeholder="请输入用户名" />
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>密码</label>
|
||||||
|
<input class="input" type="password" placeholder="请输入密码" />
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>验证码</label>
|
||||||
|
<div class="captcha-row">
|
||||||
|
<input class="input" placeholder="请输入验证码" />
|
||||||
|
<div class="captcha-box">7K3P</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button class="btn btn-primary login-btn">登 录</button>
|
||||||
|
</div>
|
||||||
|
<div class="lc-foot">© 衍水集团 · 内部系统</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="note">
|
||||||
|
背景三层:<b>点阵网格</b>(秩序)+ <b>顶部靛蓝天光</b>(氛围)+ <b>右上涟漪同心圆</b>(「衍水」的品牌装饰);
|
||||||
|
卡片内品牌区承担门面——品牌字、标语、水线;表单以「欢迎回来」承接。装饰与文案都来自「衍水」本身。
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ============ 签名元素 ============ -->
|
||||||
|
<section class="section" id="sec-sign">
|
||||||
|
<h2>07 · 签名元素:水线</h2>
|
||||||
|
<div class="comp-card">
|
||||||
|
<div style="font-size:18px;font-weight:600;letter-spacing:-0.01em">计提参数</div>
|
||||||
|
<div class="waterline" style="width:56px"></div>
|
||||||
|
<div class="note" style="max-width:720px">
|
||||||
|
页面标题下的<b>渐隐靛蓝细线(56px,左实右虚)</b>——「衍水」之水的提纯,全站唯一的装饰性元素,
|
||||||
|
只在每个页面标题处出现一次。它与登录页的水纹层理同源:<b>品牌连续,而非推倒重来</b>。
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ============ 微动效与精致文案(全局规范) ============ -->
|
||||||
|
<section class="section" id="sec-motion">
|
||||||
|
<h2>08 · 微动效与精致文案(全局规范)</h2>
|
||||||
|
<div class="comp-grid">
|
||||||
|
<div class="comp-card">
|
||||||
|
<h3>微动效 · 全站统一</h3>
|
||||||
|
<table class="spec-table">
|
||||||
|
<tr><td>页面 / 卡片入场</td><td>淡入 + 上浮 6px,45ms 交错</td><td class="num">320ms</td></tr>
|
||||||
|
<tr><td>按钮悬停 / 按下</td><td>抬升 1px、影加深 / 回落内阴影</td><td class="num">150ms</td></tr>
|
||||||
|
<tr><td>卡片悬停</td><td>抬升 2px、阴影加深</td><td class="num">180ms</td></tr>
|
||||||
|
<tr><td>输入聚焦</td><td>靛蓝描边 + 3px 柔光外环</td><td class="num">150ms</td></tr>
|
||||||
|
<tr><td>弹层 / 抽屉</td><td>淡入 + 上移 4px</td><td class="num">160ms</td></tr>
|
||||||
|
<tr><td>表格行</td><td>底色渐显(无位移)</td><td class="num">120ms</td></tr>
|
||||||
|
<tr><td>数据刷新</td><td>数字不做滚动动画(核对场景,动即干扰)</td><td class="num">—</td></tr>
|
||||||
|
<tr><td>无障碍</td><td>prefers-reduced-motion 时全部关闭</td><td class="num">—</td></tr>
|
||||||
|
</table>
|
||||||
|
<div class="note">目标不是「有动画」,而是<b>顺滑可信</b>:每次操作都有轻微、及时、可预期的反馈。</div>
|
||||||
|
</div>
|
||||||
|
<div class="comp-card">
|
||||||
|
<h3>精致文案 · 全站统一</h3>
|
||||||
|
<table class="spec-table">
|
||||||
|
<tr><td>按钮</td><td class="bad">提交</td><td class="good">保存变更 · 录入回款</td></tr>
|
||||||
|
<tr><td>空态</td><td class="bad">暂无数据</td><td class="good">还没有岗位角色,点「新增岗位」创建第一个</td></tr>
|
||||||
|
<tr><td>错误</td><td class="bad">操作失败</td><td class="good">岗位编码已存在,请换一个</td></tr>
|
||||||
|
<tr><td>危险确认</td><td class="bad">确定删除吗?</td><td class="good">删除后无法恢复,该岗位下的人员需重新归属</td></tr>
|
||||||
|
<tr><td>成功提示</td><td class="bad">操作成功</td><td class="good">岗位已创建 · 参数 11-01 起生效</td></tr>
|
||||||
|
<tr><td>页面描述</td><td class="bad">本页面用于管理</td><td class="good">决定人员适用的计提公式模板与参数;新增岗位无需改代码</td></tr>
|
||||||
|
</table>
|
||||||
|
<div class="note">说清楚<b>会发生什么</b>;动词开头、句子短;不道歉、不卖萌;<b>空态是邀请,错误是路标</b>。</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
<script src="echarts.min.js"></script>
|
||||||
|
<script>
|
||||||
|
/* 仪表盘图表:ECharts 定制主题(圆角柱 / 无网格线 / 浅灰轴标签)——实施时前端引入 echarts 依赖 */
|
||||||
|
const chartFont = "'PingFang SC','Microsoft YaHei','Noto Sans SC',sans-serif";
|
||||||
|
const rev = echarts.init(document.getElementById('chartRev'), null, { renderer: 'svg' });
|
||||||
|
rev.setOption({
|
||||||
|
grid: { left: 0, right: 0, top: 6, bottom: 20, containLabel: true },
|
||||||
|
xAxis: {
|
||||||
|
type: 'category', data: ['5月', '6月', '7月', '8月', '9月', '10月'],
|
||||||
|
axisLine: { show: false }, axisTick: { show: false },
|
||||||
|
axisLabel: { color: '#9aa3b2', fontSize: 9, margin: 10, fontFamily: chartFont }
|
||||||
|
},
|
||||||
|
yAxis: { type: 'value', show: false },
|
||||||
|
series: [{
|
||||||
|
type: 'bar', barWidth: 22, data: [34, 42, 30, 48, 38, 56],
|
||||||
|
itemStyle: {
|
||||||
|
borderRadius: [3, 3, 0, 0],
|
||||||
|
color: (p) => p.dataIndex === 5
|
||||||
|
? new echarts.graphic.LinearGradient(0, 0, 0, 1, [{ offset: 0, color: '#3a58bc' }, { offset: 1, color: '#2e4ba8' }])
|
||||||
|
: new echarts.graphic.LinearGradient(0, 0, 0, 1, [{ offset: 0, color: '#d5def3' }, { offset: 1, color: '#e8edf8' }])
|
||||||
|
}
|
||||||
|
}]
|
||||||
|
});
|
||||||
|
const perf = echarts.init(document.getElementById('chartPerf'), null, { renderer: 'svg' });
|
||||||
|
perf.setOption({
|
||||||
|
grid: { left: 0, right: 0, top: 6, bottom: 2 },
|
||||||
|
xAxis: { type: 'category', show: false, boundaryGap: false, data: [1, 2, 3, 4, 5, 6, 7] },
|
||||||
|
yAxis: { type: 'value', show: false, min: 0 },
|
||||||
|
series: [{
|
||||||
|
type: 'line', data: [8, 13, 10, 19, 23, 29, 33], smooth: 0.4, symbol: 'none',
|
||||||
|
lineStyle: { color: '#2e4ba8', width: 1.6 },
|
||||||
|
areaStyle: {
|
||||||
|
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
|
||||||
|
{ offset: 0, color: 'rgba(46,75,168,.10)' }, { offset: 1, color: 'rgba(46,75,168,0)' }
|
||||||
|
])
|
||||||
|
}
|
||||||
|
}]
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,268 @@
|
|||||||
|
# M1 开发计划:系统设置与参数体系(#28)
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
《剩余开发计划》已定稿(`docs/superpowers/plans/2026-10-12-剩余开发计划.md`),M1 是首个工作块。本文档即 M1 的正式开发计划(2026-10-12 批准):涵盖设计(四表/参数/模板/接口)、任务分解(T1–T8 步骤级)、执行安排与验收标准;开工后按第二章逐任务执行。
|
||||||
|
|
||||||
|
**为什么先做 M1**:岗位角色被基础数据的人员管理引用(外键上游);参数快照与公式模板是绩效核算的输入契约。先立地基可避免后续模块加表、加外键、改表单的连锁返工;计算预览还可作为业务对账工具先行验收公式口径。
|
||||||
|
|
||||||
|
**#28 需求原文要点**:① 人员岗位角色(营销员/营销经理/信息员等)支持增删,新增岗位时在系统设置中绑定计提公式与参数,**无需改代码**;② 全部计提比例、系数、阈值、分成比例、起付比例统一在系统设置维护,**支持版本管理与留痕**。
|
||||||
|
|
||||||
|
## 现状(开工前已核查)
|
||||||
|
|
||||||
|
| 项 | 状态 |
|
||||||
|
| --- | --- |
|
||||||
|
| 分支 | `feat/system-params`(基于 `feature/system-management` HEAD;后者领先 main 5 提交、未合并——是否先合并 main 由用户决定,不阻塞开发) |
|
||||||
|
| 容器 | Docker Desktop 位于 `D:\work\app\Docker\Docker Desktop.exe`(非默认路径) |
|
||||||
|
| 工作区 | docs/ 下有用户侧整理改动(业务清单删除、v2 新增、superpowers 目录),与 M1 无关,不动 |
|
||||||
|
| 基线 | 301 用例全绿(系统管理交付时);开工先复跑确认 |
|
||||||
|
|
||||||
|
## 范围
|
||||||
|
|
||||||
|
**做**:四张新表与种子(V3 迁移双轨);岗位角色 CRUD + 模板绑定;参数目录/版本化存储/按日期取值/变更留痕/对比/导出;公式纯函数与模板注册表;计算预览;前端两个页面(岗位角色、计提参数)。
|
||||||
|
|
||||||
|
**不做**:核算引擎与台账(M3);基础数据与业务数据(M2);参数 Redis 缓存(一期不做,保留键位);规则引擎(既有决策)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 一、技术设计
|
||||||
|
|
||||||
|
### 1.1 数据模型(四张新表,V3 迁移)
|
||||||
|
|
||||||
|
**`business_roles` 业务岗位角色表**(与系统登录角色 `roles` 严格区分,表注释写明)
|
||||||
|
|
||||||
|
| 列 | 类型 | 约束/说明 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| id | BIGINT UNSIGNED | PK AUTO_INCREMENT |
|
||||||
|
| role_code | VARCHAR(32) | NOT NULL,uk;创建后不可改 |
|
||||||
|
| role_name | VARCHAR(64) | NOT NULL |
|
||||||
|
| calc_template | VARCHAR(32) | NOT NULL,公式模板编码(以代码注册表为准) |
|
||||||
|
| description | VARCHAR(255) | NULL |
|
||||||
|
| sort_order | INT | NOT NULL DEFAULT 0 |
|
||||||
|
| status | TINYINT | NOT NULL DEFAULT 1(1 启用 0 禁用) |
|
||||||
|
| is_builtin | TINYINT | NOT NULL DEFAULT 0(1 内置禁删) |
|
||||||
|
| create_time / update_time | DATETIME | TimestampMixin(update 用 `text("CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP")`) |
|
||||||
|
|
||||||
|
**`param_definitions` 参数定义表**:`param_code` VARCHAR(64) uk;`param_name` VARCHAR(64);`param_group` VARCHAR(32);`value_type` VARCHAR(16)(DECIMAL/INT/BOOL/ENUM/TEXT);`unit` VARCHAR(16)(‰/%/元/天);`dimension_type` VARCHAR(32) DEFAULT 'NONE'(NONE/PRODUCT_CATEGORY/BUSINESS_ROLE);`options_text` VARCHAR(255)(枚举竖线分隔);`default_value`/`value_min`/`value_max` VARCHAR(64)(Fraction 可解析字符串);`description` VARCHAR(255);`sort_order` INT;`status` TINYINT;TimestampMixin。
|
||||||
|
|
||||||
|
**`param_change_batches` 参数变更批次表**:`version_no` VARCHAR(32) uk(`P{YYYYMMDD}-{当日序号}`);`effective_date` DATE NOT NULL idx;`change_type` TINYINT(1 立即 2 指定日期);`remark` VARCHAR(255);`operator_id` FK→users ON DELETE SET NULL + `operator_name` VARCHAR(64) 冗余固化;`item_count` INT DEFAULT 0;仅 `create_time`(append-only,不继承 TimestampMixin)。
|
||||||
|
|
||||||
|
**`param_values` 参数版本条目表(append-only)**:`param_id` FK→param_definitions RESTRICT idx;`dimension_value` VARCHAR(32) NOT NULL DEFAULT `''`(用空串不用 NULL);`effective_date` DATE NOT NULL idx;`value_text` VARCHAR(64) NOT NULL;`old_value_text` VARCHAR(64);`batch_id` FK→param_change_batches RESTRICT idx;仅 `create_time`。热路径索引 `idx_param_values_param_id_dimension_value_effective_date`。已生效条目禁改禁删;未生效批次可整批撤回。
|
||||||
|
|
||||||
|
**种子**(迁移 + init-dev 双写):3 个内置岗位(`salesperson 营销员`/TPL_SALESPERSON_DECAY、`sales_manager 营销经理`/TPL_MANAGER_SHARE、`info_officer 信息员`/TPL_INFO_SALES_FIXED)+ 16 项参数定义 + 版本条目 + 一条初始批次(`P20260101-00`)。
|
||||||
|
|
||||||
|
### 1.2 参数清单(16 项)
|
||||||
|
|
||||||
|
| param_code | 名称 | 类型/单位 | 维度 | 默认值 | 生效日期 |
|
||||||
|
| --- | --- | --- | --- | --- | --- |
|
||||||
|
| salesperson.rate.start | 业务员起始计提比例 | DECIMAL ‰ | BUSINESS_ROLE | 10 | 2026-02-17 |
|
||||||
|
| salesperson.rate.slope | 业务员日递减斜率 | DECIMAL ‰/天 | BUSINESS_ROLE | **4/360** | 2026-02-17 |
|
||||||
|
| salesperson.rate.floor | 业务员计提下限 | DECIMAL ‰ | BUSINESS_ROLE | 4 | 2026-02-17 |
|
||||||
|
| salesperson.days.boundary | 线性递减分界天数 | INT 天 | BUSINESS_ROLE | 360 | 2026-02-17 |
|
||||||
|
| manager.share.team | 经理本团队分成 | DECIMAL % | BUSINESS_ROLE | 12.5 | 2026-08-13 |
|
||||||
|
| manager.share.company | 经理全公司分成 | DECIMAL % | BUSINESS_ROLE | 5 | 2026-08-13 |
|
||||||
|
| enterprise.rate | 企业户计提比例 | DECIMAL ‰ | PRODUCT_CATEGORY | **2(02-17 起)/ 3(08-13 起)** | 双行 |
|
||||||
|
| info.rate | 信息员计提比例 | DECIMAL ‰ | BUSINESS_ROLE | 1 | 2026-08-13 |
|
||||||
|
| prepay.rate | 预付款计提比例 | DECIMAL ‰ | NONE | 10 | 2026-01-01 |
|
||||||
|
| barter.discount | 以物抵顶折算比例 | DECIMAL % | NONE | 80 | 2026-01-01 |
|
||||||
|
| payment.start.ratio | 支付起付比例 | DECIMAL % | NONE | 60 | 2026-01-01 |
|
||||||
|
| payment.weight.mode | 支付权重应用方式 | ENUM FULL\|BALANCE | NONE | FULL | 2026-01-01 |
|
||||||
|
| product.threshold | 折算毛利润额阈值 | DECIMAL % | PRODUCT_CATEGORY | 市政水利 30 / 轨交 20 | 2026-02-17 |
|
||||||
|
| salary.basic.mode | 月工资基本工资口径 | ENUM INCLUDE\|DEDUCT\|EXCLUDE | BUSINESS_ROLE | 业务员 INCLUDE / 经理 DEDUCT | 2026-01-01 |
|
||||||
|
| probation.annual.threshold | 试用期年累计结算阈值 | DECIMAL 元 | NONE | 18000 | 2026-01-01 |
|
||||||
|
| probation.salary.basic | 试用期基本工资标准 | DECIMAL 元 | NONE | 3000 | 2026-01-01 |
|
||||||
|
|
||||||
|
**不设参数**(作废条款落地):经理个人户 8‰ 起始/斜率;协助 25% 分成;含运费口径开关。
|
||||||
|
|
||||||
|
**值校验规则**(`validate_and_normalize`):DECIMAL 用 `Fraction(text)` 解析(拒绝 `1/0`、非数字),并按 `value_min`/`value_max` 校验;INT 必须整数;BOOL 仅 0/1;ENUM 必须在 `options_text` 内;规范化去首尾空白、分数式原样存储。
|
||||||
|
|
||||||
|
### 1.3 公式模板与消费契约
|
||||||
|
|
||||||
|
**模板注册表**(`app/services/calc_templates.py`,代码内有限集合,一期不引入规则引擎的落地方式):
|
||||||
|
|
||||||
|
| 模板编码 | 形态 | 声明参数 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| TPL_SALESPERSON_DECAY | 分段:≤分界 = 起始 − 天数×斜率;> 分界 = 下限 | salesperson.rate.* / days.boundary |
|
||||||
|
| TPL_MANAGER_SHARE | 聚合:∑本团队×12.5% + ∑全公司×5% | manager.share.* |
|
||||||
|
| TPL_ENTERPRISE_FIXED | 乘法:折算毛利润额 × 比例 | enterprise.rate |
|
||||||
|
| TPL_INFO_SALES_FIXED | 乘法:本人销售额 × 比例 | info.rate |
|
||||||
|
| TPL_PREPAY_FIXED | 乘法:回款额 × 比例 | prepay.rate |
|
||||||
|
|
||||||
|
修饰器(不绑角色,参数驱动):以物抵顶 ×80%、支付权重 R×R、起付 60%、试用期缓发。**绑定模型**:`business_roles.calc_template` → 模板;角色级覆盖经 `dimension_type=BUSINESS_ROLE` 条目;新增岗位 = 新增行 + 选模板(零代码)。
|
||||||
|
|
||||||
|
**消费接口契约**(M3 核算与预览共用):
|
||||||
|
|
||||||
|
```python
|
||||||
|
# app/services/param_service.py
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class ParamSnapshot:
|
||||||
|
biz_date: date
|
||||||
|
def get(self, code, *, role_code=None, product_category=None) -> str: ... # 覆盖链:角色 > 产品类别 > 默认
|
||||||
|
def fraction(self, code, **dims) -> Fraction: ...
|
||||||
|
def integer(self, code, **dims) -> int: ...
|
||||||
|
def boolean(self, code, **dims) -> bool: ...
|
||||||
|
def text(self, code, **dims) -> str: ...
|
||||||
|
|
||||||
|
def resolve_snapshot(session, biz_date: date) -> ParamSnapshot
|
||||||
|
def submit_change(session, payload, *, operator, meta=None) -> ParamChangeBatchOut
|
||||||
|
def compare(session, *, from_date: date, to_date: date) -> list[ParamDiffOut]
|
||||||
|
|
||||||
|
# app/services/formula.py(纯函数,禁 float)
|
||||||
|
def payment_days(ship_date, receipt_date) -> int # 发货日首日=1
|
||||||
|
def salesperson_rate(days, *, start, slope, floor, boundary_days) -> Fraction
|
||||||
|
def profit_rate(profit: Decimal, revenue: Decimal) -> Fraction # 不含运费;revenue=0 防除零
|
||||||
|
def converted_profit(revenue, rate, threshold) -> Decimal # 化简式:销售额 ×(毛利润率−阈值)
|
||||||
|
def performance_amount(base, rate) -> Decimal # 乘法方向(对照表 #3)
|
||||||
|
def apply_barter(amount, discount) -> Decimal
|
||||||
|
def payment_weight(r: int) -> Fraction # r×r
|
||||||
|
def start_payment_ok(received, revenue, ratio) -> bool
|
||||||
|
```
|
||||||
|
|
||||||
|
### 1.4 API 设计(/api/v1/system/*,AdminUser)
|
||||||
|
|
||||||
|
**岗位角色**:`GET/POST /business-roles`、`GET/PUT/DELETE /business-roles/{id}`、`PUT /business-roles/{id}/status`、`GET /business-roles/meta/templates`(模板下拉,含声明参数说明)。
|
||||||
|
|
||||||
|
**计提参数**:`GET /params`(目录+当前快照,支持 `asOf`)、`GET /params/{param_code}/history`、`POST /params/changes`(提交变更批次)、`DELETE /params/changes/{batch_id}`(撤回未生效)、`GET /params/changes`(记录分页)、`GET /params/changes/{batch_id}`(明细)、`GET /params/compare`(两日期对比)、`GET /params/export`(Excel,`StreamingResponse` 非统一信封)、`POST /params/preview`(计算预览)。
|
||||||
|
|
||||||
|
**关键示例 1:提交变更**
|
||||||
|
|
||||||
|
```json
|
||||||
|
// POST /api/v1/system/params/changes
|
||||||
|
{
|
||||||
|
"changeType": 2,
|
||||||
|
"effectiveDate": "2026-11-01",
|
||||||
|
"remark": "四季度系数调整",
|
||||||
|
"items": [
|
||||||
|
{ "paramCode": "enterprise.rate", "dimensionValue": "", "value": "3" },
|
||||||
|
{ "paramCode": "product.threshold", "dimensionValue": "rail_transit", "value": "22" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
// data:
|
||||||
|
{ "batchId": 12, "versionNo": "P20261012-01", "effectiveDate": "2026-11-01",
|
||||||
|
"changeType": 2, "itemCount": 2, "operatorName": "admin", "createTime": "..." }
|
||||||
|
```
|
||||||
|
|
||||||
|
**关键示例 2:计算预览**(示意值已验算)
|
||||||
|
|
||||||
|
```json
|
||||||
|
// POST /api/v1/system/params/preview
|
||||||
|
{ "businessRole": "salesperson", "customerType": "personal",
|
||||||
|
"productCategory": "municipal_water", "shipDate": "2025-12-15",
|
||||||
|
"receiptDate": "2026-01-10", "revenue": "100000", "profit": "45000",
|
||||||
|
"receivedAmount": "60000", "barter": false, "probation": false }
|
||||||
|
// data:
|
||||||
|
{ "paymentDays": 27, "rate": "97/10000", "rateText": "9.7‰(10‰ − 27 天 × 4/360‰)",
|
||||||
|
"profitRate": "9/20", "convertedProfit": "15000", "performanceAmount": "145.5",
|
||||||
|
"startPaymentOk": true, "paymentWeightR": 60, "paymentWeight": "9/25",
|
||||||
|
"payableAmount": "52.38", "paramVersion": "P20260101-00", "paramEffectiveDate": "2026-02-17" }
|
||||||
|
```
|
||||||
|
|
||||||
|
### 1.5 前端设计
|
||||||
|
|
||||||
|
**岗位角色页**(`views/system/business-roles/index.vue`):列表(复用 `usePagedList`/`AppPagination`/`StatusTag`)+ 新增/编辑弹窗(模板下拉,选中后展示模板声明的参数清单与当前值)+ 启停/删除(内置岗位按钮禁用并 title 提示原因)。路由 meta `{ title: '岗位角色', group: '系统管理', requiresAdmin: true, order: 5 }`。
|
||||||
|
|
||||||
|
**计提参数页**(`views/system/params/index.vue`):按 `param_group` 分组展示(名称/当前值+单位/生效日期/口径说明);编辑后进入「待提交」区,顶部选生效方式(立即/指定日期)+ 备注;「提交变更」二次确认(列出每项 旧值→新值);版本管理抽屉(变更记录分页 + 批次明细 + 两日期对比 + Excel 导出下载);预览抽屉(表单 → 结果卡,注明「斜率精确值 4/360」)。路由 meta order 6。导出接口走 axios 实例取 blob(非统一信封,api 文件内注明)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 二、任务分解(T1–T8)
|
||||||
|
|
||||||
|
任务依赖:T1 → T2 → T3 → (T4 ∥ T5) → (T6 ∥ T7) → T8。每任务遵循「写测试 → 确认失败 → 实现 → 确认通过 → commit」。
|
||||||
|
|
||||||
|
### T1 迁移与模型(四表 + 种子)
|
||||||
|
|
||||||
|
**文件**:`app/models/business_role.py`、`app/models/param.py`(创建);`app/models/__init__.py`(改);`alembic/versions/xxxx_*.py`(autogenerate+种子);`db/migration/V3__add_business_roles_and_params.sql`、`db/init/init-dev.sql`(改)、`db/queries/计提参数.sql`(创建)。
|
||||||
|
|
||||||
|
- [ ] 写两个模型文件(参照 `app/models/role.py` 写法;`ParamChangeBatch`/`ParamValue` 不继承 TimestampMixin)
|
||||||
|
- [ ] 注册进 `models/__init__.py`(漏注册会让 autogenerate 生成删表迁移)
|
||||||
|
- [ ] `uv run alembic revision --autogenerate -m "新增岗位角色与计提参数四表"` → 人工检查命名/注释/ON UPDATE → 补种子(3 岗位/16 定义/版本条目/初始批次)
|
||||||
|
- [ ] `uv run alembic upgrade head` → `uv run alembic upgrade <V2_rev>:head --sql > db/migration/V3__*.sql`
|
||||||
|
- [ ] 同步 `db/init/init-dev.sql`(建表 + 种子 + alembic_version 更新)
|
||||||
|
- [ ] 写 `db/queries/计提参数.sql`(时点化解析与对比查询留档)
|
||||||
|
- [ ] 验证:`uv run pytest tests/integration/test_schema_sync.py tests/unit/test_models.py -q` 全绿
|
||||||
|
- [ ] Commit:`feat(system): 新增岗位角色与计提参数四表及 V3 迁移`
|
||||||
|
|
||||||
|
### T2 参数注册表 + 公式纯函数
|
||||||
|
|
||||||
|
**文件**:`app/services/param_registry.py`、`calc_templates.py`、`formula.py`(创建);`app/core/constants.py`(改);`tests/unit/test_formula.py`、`test_param_registry.py`。
|
||||||
|
|
||||||
|
- [ ] 写失败测试(关键断言):`salesperson_rate(360,...) == Fraction(6,1000)`;`361 天 == Fraction(4,1000)`;`第 1 天 == Fraction(10) - Fraction(1,90)`;`payment_days(2025-12-15, 2026-01-10) == 27`;同日回款 == 1;`converted_profit(0,...) == 0`(防除零);负绩效不归零;`payment_weight(60) == Fraction(36,100)`;`parse_fraction("4/360") == Fraction(1,90)`;注册表 16 项且模板声明参数均存在
|
||||||
|
- [ ] 运行确认失败 → 实现三个文件 → 运行 `uv run pytest tests/unit -q` 全绿
|
||||||
|
- [ ] Commit:`feat(system): 计提公式模板注册表与精确分数计算函数`
|
||||||
|
|
||||||
|
### T3 参数仓储 + 取值服务(时点化解析)
|
||||||
|
|
||||||
|
**文件**:`app/repositories/param_repository.py`、`app/services/param_service.py`(解析部分);`tests/unit/test_param_snapshot.py`。
|
||||||
|
|
||||||
|
- [ ] 写失败测试:覆盖链(角色 > 产品类别 > 默认);未定义参数按注册表默认值兜底;同日多次变更取 id 最大
|
||||||
|
- [ ] 实现 `resolve_values(session, biz_date)`(一条 SQL 取全量快照)+ `ParamSnapshot`/`resolve_snapshot` → `uv run pytest tests/unit -q` 全绿
|
||||||
|
- [ ] Commit:`feat(system): 计提参数按业务日期时点化取值服务`
|
||||||
|
|
||||||
|
### T4 岗位角色 CRUD 后端(含审计与保护规则)
|
||||||
|
|
||||||
|
**文件**:`app/repositories/business_role_repository.py`、`app/schemas/business_role.py`、`app/services/business_role_service.py`、`app/api/v1/system/business_roles.py`(创建);`app/api/router.py`、`app/core/constants.py`(改)。整体参照 `roles` 模块模式。
|
||||||
|
|
||||||
|
- [ ] 实现(保护规则:内置禁删禁停、code 不可改、模板必须存在于注册表、人员引用计数钩子预留;写操作包 `audit_service.audit()`)
|
||||||
|
- [ ] 冒烟:新增自定义岗位 ✓、删除内置 → 403、非法模板 → 400
|
||||||
|
- [ ] Commit:`feat(system): 业务岗位角色管理接口与保护规则`
|
||||||
|
|
||||||
|
### T5 参数变更/历史/对比/导出/预览后端
|
||||||
|
|
||||||
|
**文件**:`app/schemas/param.py`、`app/api/v1/system/params.py`(创建);`param_service.py` 扩展、`app/api/router.py`、`constants.py`(改);`pyproject.toml`(`uv add openpyxl`)。
|
||||||
|
|
||||||
|
- [ ] 实现 9 个端点(提交/撤回/历史/分页/明细/对比/导出/预览/目录快照);撤回已生效批次 → 400;导出列含变更人/时间/项/旧值/新值/生效日期
|
||||||
|
- [ ] 冒烟链路:提交(指定未来日期)→ 当前快照不变 → 立即生效提交 → 快照变化 → 撤回未生效 → 回退 → 预览 360/361 → 导出可打开
|
||||||
|
- [ ] Commit:`feat(system): 计提参数版本管理、变更留痕与计算预览接口`
|
||||||
|
|
||||||
|
### T6 前端岗位角色页
|
||||||
|
|
||||||
|
**文件**:`src/api/businessRoles.ts`、`src/views/system/business-roles/index.vue`(创建);`router/index.ts`、`utils/constants.ts`(改)。
|
||||||
|
|
||||||
|
- [ ] 实现页面与路由 → `pnpm type-check && pnpm build` 通过
|
||||||
|
- [ ] Commit:`feat(system): 前端岗位角色管理页面`
|
||||||
|
|
||||||
|
### T7 前端参数配置页(版本管理 + 预览)
|
||||||
|
|
||||||
|
**文件**:`src/api/params.ts`、`src/views/system/params/index.vue`(创建);`router/index.ts`、`utils/constants.ts`(改)。
|
||||||
|
|
||||||
|
- [ ] 实现页面(分组表/生效方式/二次确认/版本抽屉/预览抽屉)→ `pnpm type-check` 通过
|
||||||
|
- [ ] e2e 链路:改参数 → 变更记录出现 → 预览值变化 → 导出可下载
|
||||||
|
- [ ] Commit:`feat(system): 前端计提参数配置与版本管理页面`
|
||||||
|
|
||||||
|
### T8 模块测试统一编写与文档
|
||||||
|
|
||||||
|
**文件**:`tests/unit/test_param_service.py`、`tests/integration/test_business_roles.py`、`test_param_management.py`(创建);`backend/tests/README.md`、`frontend/README.md`、`backend/README.md`、`frontend/e2e/*`(改)。
|
||||||
|
|
||||||
|
- [ ] 集成用例:参数精度全链路(注册表→种子→解析→预览一致);未来生效不泄漏;撤回回退;同日多次变更;审计留痕;权限 403;岗位保护规则
|
||||||
|
- [ ] 全量回归 `uv run pytest -q`(301 基线 + 新增全绿);e2e;三份 README 更新
|
||||||
|
- [ ] Commit:`test(system): 参数体系模块用例与测试记录`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 三、执行安排
|
||||||
|
|
||||||
|
- **分支**:`feat/system-params`(已创建);`feature/system-management` 是否先合并 main 由用户决定,不阻塞
|
||||||
|
- **环境**:启动 `D:\work\app\Docker\Docker Desktop.exe` → `docker compose -f docker/docker-compose.dev.yml up -d`;T2 纯函数开发不依赖容器
|
||||||
|
- **方式**:原生执行(当前会话逐任务推进);每任务后跑验证命令;T8 全量回归;收尾一轮 code review
|
||||||
|
- **注意**:Windows uvicorn `--reload` 偶发卡死——验证 reload 以「新 worker 的 startup complete」为准;开放问题 7 项(见下)按默认处理,不阻塞
|
||||||
|
|
||||||
|
## 四、验收标准(M1 完成的定义)
|
||||||
|
|
||||||
|
1. 管理员可新增岗位并绑定公式模板(无需改代码);内置岗位禁删禁停
|
||||||
|
2. 任一参数变更产生含变更人/时间/项/旧值/新值/生效日期的记录(批次 + 操作日志双保险)
|
||||||
|
3. 任意业务日期可解析出当时快照(08-12 企业户=2‰、08-13=3‰ 回溯正确)
|
||||||
|
4. 计算预览精确复现:360 天=6‰、361 天=4‰、12-15→01-10=27 天
|
||||||
|
5. `uv run pytest -q` 301 基线 + 新增全绿;`pnpm type-check && pnpm build` 通过;e2e 链路通过
|
||||||
|
6. `db/migration/V3__*.sql` 与 `db/init/init-dev.sql` 双轨同步(`test_schema_sync.py` 把关)
|
||||||
|
|
||||||
|
## 五、风险与开放问题
|
||||||
|
|
||||||
|
**风险(已定对策)**:参数缓存一期不做(保留 `ys:param.ver:*` 键位,未来按批次版本号精确失效);同日多次变更 UI 明示「按最新一条生效」;`4/360` 展示注明精确值;模板扩展摩擦一期接受(受控下拉);预览出参与需规 2.4.1 明细列同名(减少 M3 映射);openpyxl 在 T5 增补依赖。
|
||||||
|
|
||||||
|
**开放问题(不阻塞,按默认处理)**:① 第 1 天 10‰ vs 9.9889‰ → 按公式实现并参数化;② 企业户 2‰ 历史种子 → 已双种子;③ BALANCE 模式语义 → M3 前确认;④ `probation.salary.basic` 归属 → M2 时复核;⑤ 参数查询权限 → 暂 AdminUser;⑥ 导出列集合 → 以需规 2.3.1(四) 为准;⑦ 经理个人户业绩进入 12.5%+5% 的衔接 → **M3 开工前必须确认**。
|
||||||
|
|
||||||
|
## 六、验证方式
|
||||||
|
|
||||||
|
1. 按 T1–T8 的验证节点逐任务执行(见第二章各任务);T8 后全量回归 + e2e;
|
||||||
|
2. 验收标准 6 条逐条核对(见第四章);
|
||||||
|
3. 与 `ai-aux/AI_CONTEXT.md` 需求裁决节交叉核对口径无冲突。
|
||||||
@@ -0,0 +1,607 @@
|
|||||||
|
# 剩余开发计划(路线图 + 首工作块实施计划)
|
||||||
|
|
||||||
|
> **面向 AI 代理的工作者:** 首工作块(M1)实施时使用 subagent-driven-development(推荐)或 executing-plans 逐任务实现本计划第二章;步骤使用复选框(`- [ ]`)语法跟踪进度。第一章路线图供全局导航。
|
||||||
|
|
||||||
|
**目标:** 在需求裁决全部落定(2026-10-12)后,为剩余 5 大模块与 T+ 同步给出推进路线;并交付首个工作块「系统设置与参数体系(#28)」的可执行实施计划(任务级,含测试与验证)。
|
||||||
|
|
||||||
|
**架构:** 沿用现有六层(api → services → repositories → models,schemas 承载请求/响应,core 横切);新增「公式模板注册表(代码内有限集合)+ 参数按生效日期时点化存储 + 计算预览」作为核算模块的输入契约,不引入规则引擎。
|
||||||
|
|
||||||
|
**技术栈:** 后端 Python 3.12 + FastAPI + SQLAlchemy 2.0(同步)+ MySQL 8.0 + Redis 7;前端 Vue 3.5 + TS 5.9 + Vite 8 + Element Plus 2.14 + Pinia 4。
|
||||||
|
|
||||||
|
**规格:** `ai-aux/AI_CONTEXT.md`「需求裁决」节(权威口径);`docs/问题与解决方案对照表_v2.xlsx`(28 项裁决);`docs/2026.9.8修改方案.docx`(公式与生效日期)。需规(`docs/衍水绩效管理系统功能需规.docx`)中 4 类作废条款不再引用:26 天示例、含运费公式、协助 25%、经理个人户 8‰。
|
||||||
|
|
||||||
|
## 全局约束
|
||||||
|
|
||||||
|
- 表:小写蛇形复数、列小写蛇形、每列中文 COMMENT、主键 `id`、`uk_`/`idx_`/`fk_` 前缀;金额类 DECIMAL;时间列用 `TimestampMixin`(`ON UPDATE CURRENT_TIMESTAMP` 须 `text()` 显式写);append-only 表不继承 TimestampMixin(与 `login_logs` 先例一致)
|
||||||
|
- 迁移双轨:Alembic 生成迁移 → 导出 SQL 到 `db/migration/V{n}__*.sql`(按序编号)→ 同步 `db/init/init-dev.sql`;`tests/integration/test_schema_sync.py` 拦截漂移
|
||||||
|
- 六层红线:api 只参数绑定(写 `def` 非 `async def`)→ services 唯一事务边界 → repositories 只 `flush()`;统一响应 `{code,message,data}`,分页 `pageNum/pageSize` → `list/total/pageNum/pageSize`
|
||||||
|
- 写操作接入 `audit_service.audit()`;审计脱敏在格式化后执行(顺序不可倒)
|
||||||
|
- 金额全程 `Decimal`/`Fraction`,**禁 float**;`4/360` 按精确分数存储(`"4/360"`,禁 `0.011111`)
|
||||||
|
- Redis 键前缀 `ys:`,TTL 按业务语义;禁 `KEYS *` / `FLUSHALL` / `FLUSHDB`
|
||||||
|
- 前端:TS 锁 `~5.9.3`(不可 7.x);复用 `usePagedList`/`useConfirmAction`/`AppPagination`/`StatusTag`;视觉沿用 `--ys-*` token;新页面必须写 `meta.title`
|
||||||
|
- 测试:业务模块整体功能完成时统一编写用例;记录写入 `backend/tests/README.md` / `frontend/README.md`
|
||||||
|
- 提交:`feat(scope): 中文说明`;不直推 main/develop
|
||||||
|
|
||||||
|
## 审查重点(Review Focus)
|
||||||
|
|
||||||
|
以下输入/失败模式最可能伤到使用者,对应测试已钉进第二章各任务:
|
||||||
|
|
||||||
|
1. 畸形参数值(`"1/0"` 除零分数、非数字、超取值域)→ 保存必须 400 拒绝且不落库(T2/T5)
|
||||||
|
2. 指定未来生效的变更泄漏进当前快照 → 快照解析必须不含(T3/T5)
|
||||||
|
3. 撤回未生效批次后解析未回退 → 必须回退到撤回前值(T5)
|
||||||
|
4. 回款天数边界:同日回款 = 1 天;12-15 → 01-10 = 27 天;跨年连续(T2)
|
||||||
|
5. 计提比例边界:360 天 = 6‰、361 天 = 4‰、第 1 天 = 10 − 4/360 ≈ 9.9889‰(精确分数断言)(T2)
|
||||||
|
6. 毛利润额 = 0 → 折算额 0、绩效 0 不除零;负绩效按公式正常计算不归零(T2)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 第一章 模块级路线图
|
||||||
|
|
||||||
|
### 1.1 推荐顺序与依赖
|
||||||
|
|
||||||
|
```text
|
||||||
|
① 系统设置与参数体系(#28) ← 地基:岗位角色 + 参数 + 公式模板
|
||||||
|
② 基础数据管理 ← 人员引用 ① 岗位角色(外键上游)
|
||||||
|
③ 业务数据管理 ← 引用 ② 人员/产品/客户
|
||||||
|
④ 绩效工资核算 ← 消费 ① 参数快照(按回款到账日)+ ②③ 数据
|
||||||
|
⑤ 报表统计(∥ ⑥ T+ 同步) ← ⑤ 只读消费 ③④;⑥ 写入 ②③ 目标表,受 IT 字段映射制约
|
||||||
|
```
|
||||||
|
|
||||||
|
### 1.2 顺序论证(要点)
|
||||||
|
|
||||||
|
**① 先于基础数据:** 外键方向在上游(人员→岗位,先建避免迁移返工);参数取值契约是 ④ 的输入面,先定型让后续模块按契约编码;计算预览(需规 2.3.1 四)首块即可交给业务对账「360 天=6‰、361 天=4‰」,验收前移;参数清单提炼本身 = 28 项裁决逐条复核。阈值参数归 ① 有明文依据(#28「全部阈值统一在系统设置维护」)。
|
||||||
|
|
||||||
|
**⑥ 放最后:** 字段映射未定(对照表 #26 ⏳IT),放前部会把外部不确定性引入关键路径;目标表由 ②③ 定稿后同步才有意义;一期可手工录入闭环。**但预留前移**:②③ 表设计必须含外部唯一键列(`source_no`/`t_plus_id`)、同步批次时间列,并约定「同步不覆盖人工字段(指导价、归属、岗位)」。
|
||||||
|
|
||||||
|
**⑤ 可后置:** 纯消费端无下游,口径依赖真实核算数据校准;唯一约束是 ④ 设计台账时对照需规 2.4.1 明细列(合同编号/发货日/回款日/回款天数/产品类别/客户类型/人员岗位/计提比例/支付权重R/绩效金额)一次性建全。
|
||||||
|
|
||||||
|
### 1.3 各块范围与验收标准
|
||||||
|
|
||||||
|
| 块 | 范围界定 | 验收标准(一句话) |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| ① 系统设置与参数体系 | 岗位角色 CRUD + 模板绑定;参数目录/版本化存储/按日期取值/变更留痕/对比/导出;公式纯函数;计算预览 | 管理员可新增岗位并绑定模板;任一参数变更产生含变更人/时间/项/旧值/新值/生效日期的记录;任意业务日期可解析当时快照;预览精确复现 360 天=6‰、361 天=4‰;301 基线不回归 |
|
||||||
|
| ② 基础数据管理 | 人员(岗位/工资标准/团队/辐射区域/拓展计划)、产品类型与目录、业务(客户)与归属变更 | 三类数据可维护并留痕;人员岗位引用 ① 岗位表;表含 T+ 预留列;客户分类(个人户/经理个人户/企业户)与归属修改不回溯历史 |
|
||||||
|
| ③ 业务数据管理 | 业务明细+指导价、发货(批次/运费/发货日)、回款(批次号/到账日/以物抵顶/手动指定发货批次)、毛利润(不含运费)、客户评级计算器 | 一笔业务可多产品多批次发货、多笔回款;回款天数按「发货日首日=1」自动算;折算走精确分数;评级只出等级不影响金额 |
|
||||||
|
| ④ 绩效工资核算 | 核算引擎(按回款到账日取参数)、单笔台账+核销防重、经理 12.5%+5%、试用期/年累计 18000、起付 60% 与 R×R、预付款/以物抵顶、月工资(分岗口径)、重算授权留痕 | 任一笔回款可复算到分(零误差);同笔回款不重复核算;08-13 前后企业户 2‰/3‰ 回溯正确;月工资表可生成 |
|
||||||
|
| ⑤ 报表统计 | 个人绩效明细、部门汇总、业务统计、回款区间统计、Excel 导出与打印 | 四表口径与台账一致且可下钻单笔;导出精度与页面一致 |
|
||||||
|
| ⑥ T+ 数据同步 | 五类数据抓取/清洗/入库、唯一键去重、失败重试、同步日志、周定时+手动触发 | 手动同步幂等(重跑不重复入库);失败有日志与重试;不覆盖人工字段 |
|
||||||
|
|
||||||
|
### 1.4 里程碑(交付批次)
|
||||||
|
|
||||||
|
| 批次 | 内容 | 说明 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| **M1** | ① 系统设置与参数体系 | 独立可交付;预览工具可作业务对账入口先行验收 |
|
||||||
|
| **M2** | ② + ③ | 合并交付:一次表结构评审把 T+ 预留列与报表字段清单同时定稿 |
|
||||||
|
| **M3** | ④ 绩效工资核算 | 核心钱袋子;测试重点在精度与参数回溯 |
|
||||||
|
| **M4** | ⑤ 报表 + ⑥ T+ 同步(并行) | ⑥ 受 IT 制约,谁先就绪谁先交 |
|
||||||
|
|
||||||
|
### 1.5 需求依据对照
|
||||||
|
|
||||||
|
| 块 | 依据 | 关联作废条款(不引用) |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| ① | 需规 2.3.1(四)/2.5.2/2.1.2;对照表 #28/#5/#8/#20–#24 | 经理个人户 8‰(不设参数);含运费口径;协助 25%(不设参数) |
|
||||||
|
| ② | 需规 2.1.1–2.1.3;对照表 #9/#19/#26 | 信息部负责人条款(信息员独立角色) |
|
||||||
|
| ③ | 需规 2.2.1–2.2.5;对照表 #1/#2/#12/#13/#14/#18/#25 | 26 天示例;含运费公式(2.2.4/2.3.3 整段) |
|
||||||
|
| ④ | 需规 2.3.1–2.3.3;对照表 #3/#4/#6/#7/#11/#15/#16/#17/#20–#24 | 协助 25%;经理 8‰;26 天示例;含运费公式 |
|
||||||
|
| ⑤ | 需规 2.4.1–2.4.3;对照表 #22(明细展示 R) | — |
|
||||||
|
| ⑥ | 需规各「同步 T+」条;对照表 #10/#26/#27 | — |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 第二章 首工作块「系统设置与参数体系(#28)」实施计划
|
||||||
|
|
||||||
|
### 2.1 关键设计取舍(已定)
|
||||||
|
|
||||||
|
1. **参数存储 = 参数定义 + 逐项版本条目(时点化 append-only)+ 变更批次表承载「版本」**;否决全量快照(改一项复制全量、单参数历史需遍历)与单行 JSON(无回溯、无字段校验);
|
||||||
|
2. 数值一律以 **Fraction 可解析字符串**存储(`"4/360"`,禁 `0.011111`),解析走 `Fraction(text)` 不经 float;
|
||||||
|
3. **不设日期唯一键**:同日多次修正是合法留痕,解析按 `effective_date DESC, id DESC` 取最新;
|
||||||
|
4. **一期不做参数 Redis 缓存**(算钱系统缓存失效即错钱;20 行级查询成本可忽略),保留 `ys:param:ver:*` 键位待实测后启用;
|
||||||
|
5. 维度覆盖链:**角色维度 > 产品类别维度 > 默认行**;`dimension_value` 用空串不用 NULL(MySQL 组合索引对 NULL 不去重);
|
||||||
|
6. append-only 表(`param_values`/`param_change_batches`)**不继承 TimestampMixin**(无 update_time,与 `login_logs` 先例一致)。
|
||||||
|
|
||||||
|
### 2.2 表设计(V3 迁移,四张新表)
|
||||||
|
|
||||||
|
**`business_roles` 业务岗位角色表**(与系统登录角色 `roles` 严格区分,表注释写明;这是 #28「人员岗位角色」的落点)
|
||||||
|
|
||||||
|
| 列 | 类型 | 约束/说明 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| id | BIGINT UNSIGNED | PK AUTO_INCREMENT |
|
||||||
|
| role_code | VARCHAR(32) | NOT NULL,uk;创建后不可改(同 roles 保护) |
|
||||||
|
| role_name | VARCHAR(64) | NOT NULL |
|
||||||
|
| calc_template | VARCHAR(32) | NOT NULL,计提公式模板编码(以代码注册表为准) |
|
||||||
|
| description | VARCHAR(255) | NULL |
|
||||||
|
| sort_order | INT | NOT NULL DEFAULT 0 |
|
||||||
|
| status | TINYINT | NOT NULL DEFAULT 1(1 启用 0 禁用) |
|
||||||
|
| is_builtin | TINYINT | NOT NULL DEFAULT 0(1 内置禁删) |
|
||||||
|
| create_time / update_time | DATETIME | TimestampMixin |
|
||||||
|
|
||||||
|
种子(迁移 + init-dev 双写):`salesperson 营销员`(TPL_SALESPERSON_DECAY)、`sales_manager 营销经理`(TPL_MANAGER_SHARE)、`info_officer 信息员`(TPL_INFO_SALES_FIXED),均内置。
|
||||||
|
|
||||||
|
**`param_definitions` 计提参数定义表**:`param_code` VARCHAR(64) uk;`param_name` VARCHAR(64);`param_group` VARCHAR(32);`value_type` VARCHAR(16)(DECIMAL/INT/BOOL/ENUM/TEXT);`unit` VARCHAR(16)(‰/%/元/天,可 NULL);`dimension_type` VARCHAR(32) NOT NULL DEFAULT 'NONE'(NONE/PRODUCT_CATEGORY/BUSINESS_ROLE);`options_text` VARCHAR(255)(枚举竖线分隔);`default_value` / `value_min` / `value_max` VARCHAR(64)(Fraction 可解析字符串);`description` VARCHAR(255);`sort_order` INT;`status` TINYINT;TimestampMixin。
|
||||||
|
|
||||||
|
**`param_change_batches` 参数变更批次表**:`version_no` VARCHAR(32) uk(如 `P20261012-01`);`effective_date` DATE NOT NULL idx;`change_type` TINYINT(1 立即 2 指定日期);`remark` VARCHAR(255);`operator_id` BIGINT UNSIGNED FK→users ON DELETE SET NULL + `operator_name` VARCHAR(64) 冗余固化;`item_count` INT NOT NULL DEFAULT 0;仅 `create_time`。
|
||||||
|
|
||||||
|
**`param_values` 参数版本条目表(append-only)**:`param_id` FK→param_definitions RESTRICT idx;`dimension_value` VARCHAR(32) NOT NULL DEFAULT `''`;`effective_date` DATE NOT NULL idx;`value_text` VARCHAR(64) NOT NULL;`old_value_text` VARCHAR(64) NULL;`batch_id` FK→param_change_batches RESTRICT idx;仅 `create_time`。索引 `idx_param_values_param_id_dimension_value_effective_date`(解析热路径)。已生效条目禁改禁删;未生效批次可整批撤回(删批次+条目)。解析 SQL 留存 `db/queries/计提参数.sql`。
|
||||||
|
|
||||||
|
### 2.3 参数清单(16 项,迁移种子)
|
||||||
|
|
||||||
|
| param_code | 名称 | 类型/单位 | 维度 | 默认值 | 依据 |
|
||||||
|
| --- | --- | --- | --- | --- | --- |
|
||||||
|
| salesperson.rate.start | 业务员起始计提比例 | DECIMAL ‰ | BUSINESS_ROLE | 10 | 需规 2.3.1(一)1 |
|
||||||
|
| salesperson.rate.slope | 业务员日递减斜率 | DECIMAL ‰/天 | BUSINESS_ROLE | **4/360** | 对照表 #4 |
|
||||||
|
| salesperson.rate.floor | 业务员计提下限 | DECIMAL ‰ | BUSINESS_ROLE | 4 | 对照表 #4 |
|
||||||
|
| salesperson.days.boundary | 线性递减分界天数 | INT 天 | BUSINESS_ROLE | 360 | 对照表 #4 |
|
||||||
|
| manager.share.team | 经理本团队分成 | DECIMAL % | BUSINESS_ROLE | 12.5 | #6/#15/#16 |
|
||||||
|
| manager.share.company | 经理全公司分成 | DECIMAL % | BUSINESS_ROLE | 5 | 同上 |
|
||||||
|
| enterprise.rate | 企业户计提比例 | DECIMAL ‰ | PRODUCT_CATEGORY | 3 | #20 |
|
||||||
|
| info.rate | 信息员计提比例 | DECIMAL ‰ | BUSINESS_ROLE | 1 | #9 |
|
||||||
|
| prepay.rate | 预付款计提比例 | DECIMAL ‰ | NONE | 10 | #23 |
|
||||||
|
| barter.discount | 以物抵顶折算比例 | DECIMAL % | NONE | 80 | #24 |
|
||||||
|
| payment.start.ratio | 支付起付比例 | DECIMAL % | NONE | 60 | #21 |
|
||||||
|
| payment.weight.mode | 支付权重应用方式 | ENUM FULL\|BALANCE | NONE | FULL | #22 |
|
||||||
|
| product.threshold | 折算毛利润额阈值 | DECIMAL % | PRODUCT_CATEGORY | 市政水利 30 / 轨交 20 | #12、需规 2.1.2 |
|
||||||
|
| salary.basic.mode | 月工资基本工资口径 | ENUM INCLUDE\|DEDUCT\|EXCLUDE | BUSINESS_ROLE | 业务员 INCLUDE / 经理 DEDUCT | #8 |
|
||||||
|
| probation.annual.threshold | 试用期年累计结算阈值 | DECIMAL 元 | NONE | 18000 | #17 |
|
||||||
|
| probation.salary.basic | 试用期基本工资标准 | DECIMAL 元 | NONE | 3000 | 需规(归人员字段则撤,见开放问题) |
|
||||||
|
|
||||||
|
**不设参数**(作废条款的落地):经理个人户 8‰ 起始/斜率;协助 25% 分成;含运费口径开关。
|
||||||
|
|
||||||
|
**种子生效日期**(迁移与 init-dev 双写):`product.threshold`/`salesperson.*` = 2026-02-17;`enterprise.rate` = **2026-02-17 起 2‰ + 2026-08-13 起 3‰ 两行**;`manager.share.*`/`info.rate` = 2026-08-13;支付环节/预付款/以物抵顶/月工资口径 = 2026-01-01。
|
||||||
|
|
||||||
|
### 2.4 公式模板与角色绑定
|
||||||
|
|
||||||
|
**模板注册表**(`app/services/calc_templates.py`,代码内有限集合):
|
||||||
|
|
||||||
|
| 模板编码 | 名称 | 形态 | 声明参数 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| TPL_SALESPERSON_DECAY | 业务员个人户·天数线性递减 | 分段:≤分界 = 起始 − 天数×斜率;> 分界 = 下限 | salesperson.rate.* / days.boundary |
|
||||||
|
| TPL_MANAGER_SHARE | 经理·团队与全公司分成 | 聚合:∑本团队×12.5% + ∑全公司×5% | manager.share.* |
|
||||||
|
| TPL_ENTERPRISE_FIXED | 企业户·固定计提 | 乘法:折算毛利润额 × 比例 | enterprise.rate |
|
||||||
|
| TPL_INFO_SALES_FIXED | 信息员·销售额固定比例 | 乘法:本人销售额 × 比例 | info.rate |
|
||||||
|
| TPL_PREPAY_FIXED | 预付款·固定计提 | 乘法:回款额 × 比例 | prepay.rate |
|
||||||
|
|
||||||
|
修饰器(不绑角色,参数驱动):以物抵顶 ×80%、支付权重 R×R、起付 60%、试用期缓发。
|
||||||
|
|
||||||
|
**绑定模型**:`business_roles.calc_template` → 模板;角色级覆盖经 `dimension_type=BUSINESS_ROLE` 条目。新增岗位 = 新增行 + 选模板(零代码,满足 #28 ①)。
|
||||||
|
|
||||||
|
**消费接口契约**(④ 与预览共用,函数签名级):
|
||||||
|
|
||||||
|
```python
|
||||||
|
# app/services/param_service.py
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class ParamSnapshot:
|
||||||
|
biz_date: date
|
||||||
|
def get(self, code, *, role_code=None, product_category=None) -> str: ... # 覆盖链解析后原始值
|
||||||
|
def fraction(self, code, **dims) -> Fraction: ...
|
||||||
|
def integer(self, code, **dims) -> int: ...
|
||||||
|
def boolean(self, code, **dims) -> bool: ...
|
||||||
|
def text(self, code, **dims) -> str: ...
|
||||||
|
|
||||||
|
def resolve_snapshot(session, biz_date: date) -> ParamSnapshot
|
||||||
|
def submit_change(session, payload, *, operator, meta=None) -> ParamChangeBatchOut
|
||||||
|
def compare(session, *, from_date: date, to_date: date) -> list[ParamDiffOut]
|
||||||
|
|
||||||
|
# app/services/formula.py(纯函数,预览与 ④ 共用;禁 float)
|
||||||
|
def payment_days(ship_date, receipt_date) -> int # 发货日首日=1
|
||||||
|
def salesperson_rate(days, *, start, slope, floor, boundary_days) -> Fraction
|
||||||
|
def profit_rate(profit: Decimal, revenue: Decimal) -> Fraction # 不含运费;revenue=0 防除零
|
||||||
|
def converted_profit(revenue, rate, threshold) -> Decimal # 化简式:销售额 ×(毛利润率−阈值)
|
||||||
|
def performance_amount(base, rate) -> Decimal # 乘法方向(对照表 #3)
|
||||||
|
def apply_barter(amount, discount) -> Decimal
|
||||||
|
def payment_weight(r: int) -> Fraction # r×r
|
||||||
|
def start_payment_ok(received, revenue, ratio) -> bool
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.5 文件清单
|
||||||
|
|
||||||
|
#### 后端
|
||||||
|
|
||||||
|
| 层 | 文件 | 内容 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| models | `app/models/business_role.py`、`app/models/param.py`、`app/models/__init__.py`(改) | 四张表;**必须注册进 `__init__`**(漏注册会让 autogenerate 生成删表迁移) |
|
||||||
|
| repositories | `app/repositories/business_role_repository.py`、`param_repository.py` | 只 flush;`resolve_values(session, biz_date)` 一条 SQL 取全量快照;批次撤回用 `for_update` 锁行 |
|
||||||
|
| schemas | `app/schemas/business_role.py`、`param.py` | Out/CreateIn/UpdateIn/StatusIn/Query、TemplateOptionOut;ParamDefinitionOut/SnapshotOut/ChangeIn/Out/BatchOut/BatchDetailOut/DiffOut/PreviewIn/Out |
|
||||||
|
| services | `app/services/param_registry.py`、`calc_templates.py`、`formula.py`、`business_role_service.py`、`param_service.py` | 参数目录注册表(种子唯一来源 + 运行期校验);模板注册表;纯函数;岗位 CRUD+保护规则;参数提交/历史/对比/预览/导出 |
|
||||||
|
| api | `app/api/v1/system/business_roles.py`、`params.py`、`app/api/router.py`(改) | 挂载 `/api/v1/system/business-roles`、`/api/v1/system/params`(AdminUser) |
|
||||||
|
| core | `app/core/constants.py`(改) | `MODULE_BUSINESS_ROLE`/`MODULE_PARAM`、内置岗位编码、产品类别码 |
|
||||||
|
| 迁移 | `alembic/versions/xxxx_*.py`、`db/migration/V3__add_business_roles_and_params.sql`、`db/init/init-dev.sql`(改)、`db/queries/计提参数.sql` | 四表 + 种子;双轨同步 |
|
||||||
|
| 依赖 | `backend/pyproject.toml`(改) | 增补 `openpyxl`(参数导出用;`uv add openpyxl`) |
|
||||||
|
|
||||||
|
**接口集合**:business-roles 列表/新增/详情/编辑/删除/启停 + `meta/templates` 模板下拉;params 目录快照(`asOf`)、单参数历史、变更批次提交/撤回/分页/明细、双日期对比、Excel 导出(`StreamingResponse`,非统一信封)、计算预览。写操作全走 `audit_service.audit()`;变更批次表与操作日志互为双保险。
|
||||||
|
|
||||||
|
#### 前端
|
||||||
|
|
||||||
|
| 类型 | 文件 | 说明 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| api | `src/api/businessRoles.ts`、`src/api/params.ts` | params 的导出接口直接用 axios 实例(响应为 blob,非统一信封,文件内注明例外) |
|
||||||
|
| 页面 | `src/views/system/business-roles/index.vue` | 列表+表单+启停+删除(保护提示)+模板选择(展示模板声明参数) |
|
||||||
|
| 页面 | `src/views/system/params/index.vue` | 按 param_group 分组;生效方式(立即/指定日期)+备注;保存二次确认;版本管理抽屉(记录/明细/对比/导出);预览抽屉 |
|
||||||
|
| 路由 | `src/router/index.ts`(改) | `{ title: '岗位角色', group: '系统管理', requiresAdmin: true, order: 5 }`、`{ title: '计提参数', …, order: 6 }` |
|
||||||
|
| 常量 | `src/utils/constants.ts`(改) | MODULE_OPTIONS 增补;参数分组/单位展示映射 |
|
||||||
|
| 复用 | `usePagedList`/`useConfirmAction`/`AppPagination`/`StatusTag`/`--ys-*` | 不新增组件 |
|
||||||
|
|
||||||
|
### 2.6 任务序列(步骤级)
|
||||||
|
|
||||||
|
任务依赖:T1 → T2 → T3 → (T4 ∥ T5) → (T6 ∥ T7) → T8。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### 任务 T1:迁移与模型(四张新表 + 种子)
|
||||||
|
|
||||||
|
**文件:**
|
||||||
|
|
||||||
|
- 创建:`backend/app/models/business_role.py`、`backend/app/models/param.py`
|
||||||
|
- 修改:`backend/app/models/__init__.py`
|
||||||
|
- 创建:`backend/alembic/versions/xxxx_add_business_roles_and_params.py`(autogenerate 后人工补种子)
|
||||||
|
- 创建:`db/migration/V3__add_business_roles_and_params.sql`
|
||||||
|
- 修改:`db/init/init-dev.sql`
|
||||||
|
- 创建:`db/queries/计提参数.sql`
|
||||||
|
|
||||||
|
- [ ] **步骤 1:写两个模型文件**
|
||||||
|
|
||||||
|
按 2.2 字段设计实现 `BusinessRole`、`ParamDefinition`、`ParamChangeBatch`、`ParamValue`。写法参照 `backend/app/models/role.py`(`Mapped`/`mapped_column`、`mysql_table_args("中文表注释")`、`BIGINT_UNSIGNED`、`TINYINT_TYPE`);`ParamChangeBatch`/`ParamValue` 不继承 `TimestampMixin`,仅手写 `create_time`(`server_default=text("CURRENT_TIMESTAMP")`);外键目标类型必须与 `users.id` 一致(`BIGINT_UNSIGNED`)。
|
||||||
|
|
||||||
|
- [ ] **步骤 2:注册进 `models/__init__.py`**
|
||||||
|
|
||||||
|
四个类加入导入与 `__all__`(漏注册会让 autogenerate 把新表当成待删除)。
|
||||||
|
|
||||||
|
- [ ] **步骤 3:生成迁移并补种子**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd backend && uv run alembic revision --autogenerate -m "新增岗位角色与计提参数四表"
|
||||||
|
```
|
||||||
|
|
||||||
|
人工检查:表/列中文 COMMENT、`uk_`/`idx_`/`fk_` 命名、`ON UPDATE CURRENT_TIMESTAMP` 子句;然后按 2.2/2.3 用 `op.bulk_insert` 或 `op.execute` 补种子:3 个内置岗位、16 项参数定义、版本条目(`enterprise.rate` 两行:2026-02-17=2、2026-08-13=3;其余按 2.3 生效日期表;维度行展开——`product.threshold` 两行市政/轨交、`salary.basic.mode` 两行业务员/经理)。种子条目的 `batch_id` 需要一条「初始版本」批次(`version_no` 如 `P20260101-00`、`remark` 初始版本)。
|
||||||
|
|
||||||
|
- [ ] **步骤 4:应用迁移并导出 SQL**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd backend && uv run alembic upgrade head
|
||||||
|
uv run alembic upgrade <V2_revision>:head --sql > ../db/migration/V3__add_business_roles_and_params.sql
|
||||||
|
```
|
||||||
|
|
||||||
|
(V2 revision 从 `alembic/versions/` 中操作日志迁移文件读取。)
|
||||||
|
|
||||||
|
- [ ] **步骤 5:同步 `db/init/init-dev.sql`**
|
||||||
|
|
||||||
|
追加四表 DDL(与迁移产出一致)+ 种子 INSERT + 更新末尾 `alembic_version` 为 V3 revision id。
|
||||||
|
|
||||||
|
- [ ] **步骤 6:写 `db/queries/计提参数.sql`**
|
||||||
|
|
||||||
|
留存解析查询(时点化取值:`WHERE effective_date <= :biz_date ORDER BY effective_date DESC, id DESC` 取每组最新)与对比查询。
|
||||||
|
|
||||||
|
- [ ] **步骤 7:验证**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd backend && uv run pytest tests/integration/test_schema_sync.py tests/unit/test_models.py -q
|
||||||
|
```
|
||||||
|
|
||||||
|
预期:全部 PASS(schema 双轨无漂移、新表规范断言通过)。
|
||||||
|
|
||||||
|
- [ ] **步骤 8:Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add backend/app/models backend/alembic db/init/init-dev.sql db/migration db/queries
|
||||||
|
git commit -m "feat(system): 新增岗位角色与计提参数四表及 V3 迁移"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### 任务 T2:参数注册表 + 公式纯函数
|
||||||
|
|
||||||
|
**文件:**
|
||||||
|
|
||||||
|
- 创建:`backend/app/services/param_registry.py`、`backend/app/services/calc_templates.py`、`backend/app/services/formula.py`
|
||||||
|
- 修改:`backend/app/core/constants.py`(内置岗位编码、产品类别码常量)
|
||||||
|
- 测试:`backend/tests/unit/test_formula.py`、`backend/tests/unit/test_param_registry.py`
|
||||||
|
|
||||||
|
- [ ] **步骤 1:编写失败的测试(公式,算钱核心)**
|
||||||
|
|
||||||
|
代表性用例(全部精确分数断言,禁近似):
|
||||||
|
|
||||||
|
```python
|
||||||
|
def test_业务员360天_比例恰为6‰():
|
||||||
|
rate = salesperson_rate(360, start=Fraction(10), slope=Fraction(4, 360),
|
||||||
|
floor=Fraction(4), boundary_days=360)
|
||||||
|
assert rate == Fraction(6, 1000)
|
||||||
|
|
||||||
|
def test_业务员361天_固定下限4‰_分界线():
|
||||||
|
rate = salesperson_rate(361, start=Fraction(10), slope=Fraction(4, 360),
|
||||||
|
floor=Fraction(4), boundary_days=360)
|
||||||
|
assert rate == Fraction(4, 1000)
|
||||||
|
|
||||||
|
def test_业务员第1天_按公式为899比90000_精确分数():
|
||||||
|
rate = salesperson_rate(1, start=Fraction(10), slope=Fraction(4, 360),
|
||||||
|
floor=Fraction(4), boundary_days=360)
|
||||||
|
assert rate == Fraction(10) - Fraction(1, 90) # 899/90 ‰
|
||||||
|
|
||||||
|
def test_回款天数_发货日首日为第1天_12月15日到1月10日为27天():
|
||||||
|
assert payment_days(date(2025, 12, 15), date(2026, 1, 10)) == 27
|
||||||
|
|
||||||
|
def test_回款天数_同日回款为1天():
|
||||||
|
d = date(2026, 1, 1)
|
||||||
|
assert payment_days(d, d) == 1
|
||||||
|
|
||||||
|
def test_毛利润额为零_折算额与绩效均为零_不除零():
|
||||||
|
assert converted_profit(Decimal("0"), Fraction(0), Fraction(3, 10)) == Decimal("0")
|
||||||
|
|
||||||
|
def test_毛利润率低于阈值_折算为负_绩效负值不归零():
|
||||||
|
# 销售额 100、毛利润率 10%、阈值 30% → 折算 -20
|
||||||
|
assert converted_profit(Decimal("100"), Fraction(1, 10), Fraction(3, 10)) == Decimal("-20")
|
||||||
|
assert performance_amount(Decimal("-20"), Fraction(6, 1000)) == Decimal("-0.12")
|
||||||
|
|
||||||
|
def test_支付权重_R为60时权重0点36():
|
||||||
|
assert payment_weight(60) == Fraction(36, 100)
|
||||||
|
|
||||||
|
def test_注册表_16项_编码唯一_必填字段非空():
|
||||||
|
assert len(PARAM_REGISTRY) == 16
|
||||||
|
assert len({s.code for s in PARAM_REGISTRY.values()}) == 16
|
||||||
|
# 每项 name/group/value_type 非空
|
||||||
|
|
||||||
|
def test_解析分数_4除以360_按Fraction不丢精度():
|
||||||
|
assert parse_fraction("4/360") == Fraction(1, 90)
|
||||||
|
|
||||||
|
def test_模板注册表_五个模板_声明参数均在注册表内():
|
||||||
|
# TEMPLATES 的 params 引用必须全部存在于 PARAM_REGISTRY(跨表一致性)
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **步骤 2:运行确认失败**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd backend && uv run pytest tests/unit/test_formula.py tests/unit/test_param_registry.py -q
|
||||||
|
```
|
||||||
|
|
||||||
|
预期:FAIL(`ModuleNotFoundError` / `NameError`)。
|
||||||
|
|
||||||
|
- [ ] **步骤 3:实现三个 service 文件**
|
||||||
|
|
||||||
|
`formula.py`:8 个纯函数(签名见 2.4 契约)。要点:`salesperson_rate` 中 `days <= boundary_days` 用线性式、否则取 `floor`;`converted_profit` 用化简式 `revenue × (rate − threshold)`;`performance_amount` 为乘法;`payment_days` 为 `(receipt_date - ship_date).days + 1`。
|
||||||
|
|
||||||
|
`param_registry.py`:`ParamSpec` 冻结 dataclass + `PARAM_REGISTRY`(16 项,值与 2.3 种子一致)+ `get_spec`(未注册编码抛 500 级异常,属代码 bug)+ `parse_fraction`(`Fraction(text)`,捕获 `ZeroDivisionError`/`ValueError` → 400)+ `validate_and_normalize`(类型/取值域/枚举校验,返回规范串)。
|
||||||
|
|
||||||
|
`calc_templates.py`:`TemplateSpec` + `TEMPLATES`(5 个模板,见 2.4)+ `get_template`/`required_params`。
|
||||||
|
|
||||||
|
- [ ] **步骤 4:运行确认通过**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd backend && uv run pytest tests/unit -q
|
||||||
|
```
|
||||||
|
|
||||||
|
预期:全部 PASS(含既有单元基线)。
|
||||||
|
|
||||||
|
- [ ] **步骤 5:Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add backend/app/services/formula.py backend/app/services/param_registry.py backend/app/services/calc_templates.py backend/app/core/constants.py backend/tests/unit/test_formula.py backend/tests/unit/test_param_registry.py
|
||||||
|
git commit -m "feat(system): 计提公式模板注册表与精确分数计算函数"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### 任务 T3:参数仓储 + 取值服务(时点化解析)
|
||||||
|
|
||||||
|
**文件:**
|
||||||
|
|
||||||
|
- 创建:`backend/app/repositories/param_repository.py`
|
||||||
|
- 创建:`backend/app/services/param_service.py`(先实现 `ParamSnapshot`/`resolve_snapshot` 与覆盖链选择)
|
||||||
|
- 测试:`backend/tests/unit/test_param_snapshot.py`(覆盖链纯逻辑;真实 DB 解析在 T8 统一集成测试)
|
||||||
|
|
||||||
|
- [ ] **步骤 1:编写失败的测试(覆盖链与兜底)**
|
||||||
|
|
||||||
|
```python
|
||||||
|
def test_覆盖链_角色维度优先于产品类别与默认():
|
||||||
|
# rows: (code, dimension) 含 ('enterprise.rate', ''), ('enterprise.rate', 'municipal_water')
|
||||||
|
# 取 role_code=None, product_category='municipal_water' → 返回产品类别行
|
||||||
|
|
||||||
|
def test_未定义参数_按注册表默认值兜底():
|
||||||
|
# 某参数无任何版本条目 → snapshot.fraction(code) == 注册表 default_value 的 Fraction
|
||||||
|
|
||||||
|
def test_同日多次变更_取id最大的一条():
|
||||||
|
# 同日两行 → 取 id 大者
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **步骤 2:运行确认失败**(`uv run pytest tests/unit/test_param_snapshot.py -q`,预期 FAIL)
|
||||||
|
|
||||||
|
- [ ] **步骤 3:实现**
|
||||||
|
|
||||||
|
`param_repository.py`:`resolve_values(session, biz_date)` 一条 SQL 取全量快照(按 `(param_id, dimension_value)` 分组取 `effective_date DESC, id DESC` 首行;可用窗口函数或子查询,SQL 留存 `db/queries/计提参数.sql`);批次 CRUD 与 `for_update` 加锁函数(签名参照 2.5 与设计报告)。
|
||||||
|
|
||||||
|
`param_service.py`:`ParamSnapshot`(frozen dataclass,持有解析后 `dict[(code, dim)] → value` 与 `biz_date`)+ `resolve_snapshot(session, biz_date)`(读定义 + 读条目 + 合并注册表默认值);`get/fraction/integer/boolean/text` 五类取值(覆盖链:role_code 维度 > product_category 维度 > 默认行;`fraction` 走 `parse_fraction`)。
|
||||||
|
|
||||||
|
- [ ] **步骤 4:运行确认通过**(`uv run pytest tests/unit -q`,预期 PASS)
|
||||||
|
|
||||||
|
- [ ] **步骤 5:Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add backend/app/repositories/param_repository.py backend/app/services/param_service.py backend/tests/unit/test_param_snapshot.py
|
||||||
|
git commit -m "feat(system): 计提参数按业务日期时点化取值服务"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### 任务 T4:岗位角色 CRUD 后端(含审计与保护规则)
|
||||||
|
|
||||||
|
**文件:**
|
||||||
|
|
||||||
|
- 创建:`backend/app/repositories/business_role_repository.py`、`backend/app/schemas/business_role.py`、`backend/app/services/business_role_service.py`、`backend/app/api/v1/system/business_roles.py`
|
||||||
|
- 修改:`backend/app/api/router.py`、`backend/app/core/constants.py`(MODULE_BUSINESS_ROLE)
|
||||||
|
|
||||||
|
- [ ] **步骤 1:实现四层文件**
|
||||||
|
|
||||||
|
整体参照 `roles` 模块(`role_repository.py`/`role_service.py`/`api/v1/system/roles.py`)的写法与保护规则模式。接口:列表(分页+关键字+状态)、新增、详情、编辑、删除、启停、`GET /meta/templates` 模板下拉。保护规则:`is_builtin` 禁删禁停;`role_code` 创建后不可改;`calc_template` 必须存在于 `TEMPLATES`(否则 400);删除前检查人员引用(② 落地前以引用计数钩子预留,接入后生效);写操作全部包 `audit_service.audit()`。
|
||||||
|
|
||||||
|
- [ ] **步骤 2:冒烟验证(容器 + 后端在跑)**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd backend && uv run uvicorn app.main:app --reload --port 8000
|
||||||
|
# 另一终端:curl 登录取 token 后依次打 7 个端点;重点验证:
|
||||||
|
# - 新增自定义岗位(绑 TPL_ENTERPRISE_FIXED)→ 201/200
|
||||||
|
# - 删除内置 salesperson → 403;删除自定义岗位 → 成功
|
||||||
|
# - calc_template 传不存在的编码 → 400
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **步骤 3:Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add backend/app/repositories/business_role_repository.py backend/app/schemas/business_role.py backend/app/services/business_role_service.py backend/app/api/v1/system/business_roles.py backend/app/api/router.py backend/app/core/constants.py
|
||||||
|
git commit -m "feat(system): 业务岗位角色管理接口与保护规则"
|
||||||
|
```
|
||||||
|
|
||||||
|
(模块级正式用例按项目约定在 T8 统一编写。)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### 任务 T5:参数变更/历史/对比/导出/预览后端
|
||||||
|
|
||||||
|
**文件:**
|
||||||
|
|
||||||
|
- 创建:`backend/app/schemas/param.py`、`backend/app/api/v1/system/params.py`
|
||||||
|
- 修改:`backend/app/services/param_service.py`(提交/撤回/历史/对比/预览/导出)、`backend/app/api/router.py`、`backend/app/core/constants.py`(MODULE_PARAM)、`backend/pyproject.toml`(`uv add openpyxl`)
|
||||||
|
|
||||||
|
- [ ] **步骤 1:加依赖**:`cd backend && uv add openpyxl`(参数导出用)
|
||||||
|
|
||||||
|
- [ ] **步骤 2:实现 schemas 与 service 扩展**
|
||||||
|
|
||||||
|
`submit_change`:事务内校验全部 items(`validate_and_normalize`)→ 建批次(`version_no` 规则 `P{YYYYMMDD}-{当日序号}`)→ 逐项写 `param_values`(`old_value_text` 取当前生效值)→ `item_count` → commit。`withdraw_batch`:已生效(`effective_date <= today`)禁撤(400);`for_update` 锁批次行后删条目与批次。`compare`:两日期各取快照后逐项 diff。`preview`:入参(产品类别/客户类型/岗位/发货日/回款日/回款金额/销售额/毛利润额/是否以物抵顶/是否试用期)→ 输出(回款天数/计提比例/折算毛利润额/单笔绩效/起付判定/R×R 结果/所用参数生效版本)。`export_changes`:openpyxl 流式写变更记录(列:变更人/时间/参数/旧值/新值/生效日期/批次号)。
|
||||||
|
|
||||||
|
- [ ] **步骤 3:实现 api 层**
|
||||||
|
|
||||||
|
9 个端点(目录快照/历史/提交/撤回/批次分页/批次明细/对比/导出/预览);写操作包审计;导出返回 `StreamingResponse`(非统一信封,前端 api 层注明)。
|
||||||
|
|
||||||
|
- [ ] **步骤 4:冒烟验证**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 提交一次变更(3 项参数,指定未来日期)→ 当前快照不变
|
||||||
|
# 到该日期(或提交立即生效)→ 快照变化、历史出现记录
|
||||||
|
# 撤回未生效批次 → 快照回退;撤回已生效批次 → 400
|
||||||
|
# 预览:360 天/361 天边界、12-15→01-10=27 天精确复现
|
||||||
|
# 导出 → xlsx 可打开且列齐全
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **步骤 5:Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add backend/app/schemas/param.py backend/app/services/param_service.py backend/app/api/v1/system/params.py backend/app/api/router.py backend/app/core/constants.py backend/pyproject.toml backend/uv.lock
|
||||||
|
git commit -m "feat(system): 计提参数版本管理、变更留痕与计算预览接口"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### 任务 T6:前端岗位角色页
|
||||||
|
|
||||||
|
**文件:**
|
||||||
|
|
||||||
|
- 创建:`frontend/src/api/businessRoles.ts`、`frontend/src/views/system/business-roles/index.vue`
|
||||||
|
- 修改:`frontend/src/router/index.ts`、`frontend/src/utils/constants.ts`
|
||||||
|
|
||||||
|
- [ ] **步骤 1:api 封装 + 页面**
|
||||||
|
|
||||||
|
页面整体复用用户管理页模式(`usePagedList` + `useConfirmAction` + `AppPagination` + `StatusTag`);表单含模板下拉(选中后展示模板声明的参数清单与当前值);内置岗位删除/停用按钮禁用并给 title 提示;路由 meta `{ title: '岗位角色', group: '系统管理', requiresAdmin: true, order: 5 }`。
|
||||||
|
|
||||||
|
- [ ] **步骤 2:验证**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd frontend && pnpm type-check && pnpm build
|
||||||
|
```
|
||||||
|
|
||||||
|
预期:类型检查与构建通过。
|
||||||
|
|
||||||
|
- [ ] **步骤 3:Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add frontend/src/api/businessRoles.ts frontend/src/views/system/business-roles frontend/src/router/index.ts frontend/src/utils/constants.ts
|
||||||
|
git commit -m "feat(system): 前端岗位角色管理页面"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### 任务 T7:前端参数配置页(含版本管理抽屉与预览)
|
||||||
|
|
||||||
|
**文件:**
|
||||||
|
|
||||||
|
- 创建:`frontend/src/api/params.ts`、`frontend/src/views/system/params/index.vue`
|
||||||
|
- 修改:`frontend/src/router/index.ts`、`frontend/src/utils/constants.ts`
|
||||||
|
|
||||||
|
- [ ] **步骤 1:api 封装**(导出接口用 axios 实例取 blob,注明非统一信封例外)
|
||||||
|
|
||||||
|
- [ ] **步骤 2:页面实现**
|
||||||
|
|
||||||
|
按 `param_group` 分组展示参数(名称/当前值+单位/生效日期/口径说明);顶部「生效方式(立即/指定日期)+ 生效日期 + 备注」;保存前二次确认(列出改动项与旧值→新值);版本管理抽屉(变更记录分页 + 批次明细 + 两日期对比 + 导出下载);预览抽屉(表单 → 结果卡,注明「斜率精确值 4/360」);路由 meta order 6。
|
||||||
|
|
||||||
|
- [ ] **步骤 3:验证**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd frontend && pnpm type-check
|
||||||
|
# e2e(需容器+前后端在跑):改一个参数 → 变更记录出现 → 预览值变化 → 导出可下载
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **步骤 4:Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add frontend/src/api/params.ts frontend/src/views/system/params frontend/src/router/index.ts frontend/src/utils/constants.ts
|
||||||
|
git commit -m "feat(system): 前端计提参数配置与版本管理页面"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### 任务 T8:模块测试统一编写与文档更新
|
||||||
|
|
||||||
|
**文件:**
|
||||||
|
|
||||||
|
- 创建:`backend/tests/unit/test_param_service.py`(可 fakeredis 化的部分)、`backend/tests/integration/test_business_roles.py`、`backend/tests/integration/test_param_management.py`
|
||||||
|
- 修改:`backend/tests/README.md`、`frontend/README.md`、`backend/README.md`(接口清单)、`frontend/e2e/system_management.py` 或新增 `frontend/e2e/param_management.py`
|
||||||
|
|
||||||
|
- [ ] **步骤 1:编写集成用例**(覆盖审查重点 1/2/3)
|
||||||
|
|
||||||
|
参数精度专项(Fraction/1/90 全链路:注册表 → 种子 → 解析 → 预览一致);未来生效不泄漏;撤回回退;同日多次变更取最新;审计留痕(成功与失败);权限(普通用户 403);岗位保护规则(内置禁删、模板校验)。
|
||||||
|
|
||||||
|
- [ ] **步骤 2:全量回归**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd backend && uv run pytest -q
|
||||||
|
```
|
||||||
|
|
||||||
|
预期:301 基线 + 新增用例全绿。
|
||||||
|
|
||||||
|
- [ ] **步骤 3:e2e 与文档**
|
||||||
|
|
||||||
|
前端 e2e 步骤(菜单出现两个新页、改参数→记录→预览链路);更新三份 README(用例清单/运行记录/发现缺陷/待补用例、接口清单)。
|
||||||
|
|
||||||
|
- [ ] **步骤 4:Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add backend/tests frontend/e2e backend/README.md frontend/README.md
|
||||||
|
git commit -m "test(system): 参数体系模块用例与测试记录"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2.7 风险与开放问题
|
||||||
|
|
||||||
|
#### 风险(已定对策)
|
||||||
|
|
||||||
|
1. 参数缓存:一期不做(见 2.1-4);若未来启用必须按批次版本号精确失效,禁通配删除;
|
||||||
|
2. 同日多次变更:UI 明示「同日多次变更按最新一条生效」;
|
||||||
|
3. `4/360` 展示:存储分数、展示格式化(预览页注明「精确值 4/360,非截断 0.011111」);
|
||||||
|
4. 模板扩展摩擦:新公式形态仍需加模板(一期接受,与「不引入规则引擎」一致);岗位表单把模板做成带说明的受控下拉;
|
||||||
|
5. 报表字段前移:预览出参尽量与需规 2.4.1 明细列同名,减少 ④ 映射工作;
|
||||||
|
6. 参数导出用 openpyxl:已在 T5 增补依赖(AI_CONTEXT 已认可方向)。
|
||||||
|
|
||||||
|
#### 开放问题(不阻塞首块开工,逐项有默认处理)
|
||||||
|
|
||||||
|
| # | 问题 | 默认处理 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 1 | 第 1 天计提:需规图示「第 1 天 10‰」vs 公式 `10 − 4/360 ≈ 9.9889‰` | 按公式实现(上限为参考);起始值参数化,业务如需 10‰ 可在系统设置调整 |
|
||||||
|
| 2 | 企业户 02-17 前历史(2‰ 是否回溯) | 已按双种子(02-17=2‰、08-13=3‰)实现;如无需回溯可撤首行 |
|
||||||
|
| 3 | `payment.weight.mode` BALANCE 模式语义(余额口径) | 首块只存枚举;语义在 ④ 开工前确认 |
|
||||||
|
| 4 | `probation.salary.basic`(3000 元)是参数还是人员工资标准字段 | 暂列参数;② 开工时如归人员字段则移除 |
|
||||||
|
| 5 | 参数查询权限(普通用户可否只读快照) | 建议「查看可、修改仅管理员」,实现为 AdminUser 时可放宽——待确认 |
|
||||||
|
| 6 | 导出格式与列集合(是否含批次说明/操作人) | 以需规 2.3.1(四) 字段为准,导出前与业务对一次列 |
|
||||||
|
| 7 | 经理个人户业绩如何进入 12.5%+5%(裁决 #7 的具体衔接:该业绩是否计入「本人带领业务员」基数) | ④ 开工前必须确认(影响台账归属字段设计) |
|
||||||
|
|
||||||
|
## 验证方式
|
||||||
|
|
||||||
|
1. 计划内容与 `ai-aux/AI_CONTEXT.md` 需求裁决节逐条核对(口径无冲突、无遗漏作废条款);
|
||||||
|
2. 计划中的命令与项目现有命令体系一致(uv / pnpm / docker compose);
|
||||||
|
3. 首块每个任务有独立可测交付物;测试要点符合「模块整体完成时统一编写」约定(纯函数与 core 级随实现即时测);
|
||||||
|
4. 执行阶段验证:T1 后 `test_schema_sync.py` 通过;T2/T3 后纯单元全绿;T5 后冒烟链路完整;T8 后全量 301+ 用例与 e2e 全绿。
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
+37
-9
@@ -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` 传管理员密码。
|
||||||
@@ -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
@@ -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:
|
||||||
|
|||||||
@@ -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())
|
||||||
@@ -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())
|
||||||
@@ -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' })
|
||||||
|
}
|
||||||
@@ -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 })
|
||||||
|
}
|
||||||
@@ -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 })
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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' })
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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' })
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
@@ -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 }
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
/** 异常处理工具。 */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 显式声明「异常已按约定处理」。
|
||||||
|
*
|
||||||
|
* 本项目请求失败的提示统一由 axios 拦截器完成,页面 catch 块只需阻止
|
||||||
|
* Promise 继续传播;但**空 catch 会掩盖非预期异常**(如本地 TypeError),
|
||||||
|
* 也违反「禁空 catch」规范,故以本函数显式标注意图并留痕。
|
||||||
|
* 用 debug 级别:既不污染 e2e 收集的 error 级控制台信息,也便于排查时开启。
|
||||||
|
*/
|
||||||
|
export function handledError(error: unknown): void {
|
||||||
|
console.debug('[handled]', error)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
@@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Loaded 100 of 105 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user