Files
PayrollManagementSystem/backend/alembic/versions/6165691326d4_init_rbac_and_login_logs.py
T
liuwenxinandClaude Code ad5fe4a411 feat(system): 新增操作日志表与 V2 迁移
需求 2.5.1 要求记录用户管理操作,先落表结构:

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

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

356 lines
13 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""初始化 RBAC 与登录日志表
Revision ID: 6165691326d4
Revises:
Create Date: 2026-10-09 10:58:04.316902
"""
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 = "6165691326d4"
down_revision: str | Sequence[str] | None = None
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
"""Upgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.create_table(
"permissions",
sa.Column(
"id", mysql.BIGINT(unsigned=True), autoincrement=True, nullable=False, comment="主键ID"
),
sa.Column(
"perm_code",
sa.String(length=128),
nullable=False,
comment="权限编码,如 system:user:create",
),
sa.Column("perm_name", sa.String(length=64), nullable=False, comment="权限名称"),
sa.Column(
"perm_type",
mysql.TINYINT(),
server_default="1",
nullable=False,
comment="类型:1菜单 2按钮 3接口",
),
sa.Column(
"parent_id",
mysql.BIGINT(unsigned=True),
nullable=True,
comment="父权限ID(permissions.id),顶级为空",
),
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.ForeignKeyConstraint(
["parent_id"],
["permissions.id"],
name=op.f("fk_permissions_parent_id"),
ondelete="CASCADE",
),
sa.PrimaryKeyConstraint("id", name=op.f("pk_permissions")),
sa.UniqueConstraint("perm_code", name=op.f("uk_permissions_perm_code")),
comment="权限表",
mysql_charset="utf8mb4",
mysql_collate="utf8mb4_general_ci",
mysql_engine="InnoDB",
)
op.create_table(
"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="角色编码:admin系统管理员 user普通用户",
),
sa.Column("role_name", sa.String(length=64), 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(
"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_roles")),
sa.UniqueConstraint("role_code", name=op.f("uk_roles_role_code")),
comment="角色表",
mysql_charset="utf8mb4",
mysql_collate="utf8mb4_general_ci",
mysql_engine="InnoDB",
)
op.create_table(
"users",
sa.Column(
"id", mysql.BIGINT(unsigned=True), autoincrement=True, nullable=False, comment="主键ID"
),
sa.Column("username", sa.String(length=64), nullable=False, comment="登录用户名"),
sa.Column(
"password",
sa.String(length=100),
nullable=False,
comment="密码哈希(BCrypt,固定60字符)",
),
sa.Column("real_name", sa.String(length=64), nullable=True, comment="真实姓名"),
sa.Column("phone", sa.String(length=20), nullable=True, comment="手机号"),
sa.Column("email", sa.String(length=128), nullable=True, comment="邮箱"),
sa.Column(
"status",
mysql.TINYINT(),
server_default="1",
nullable=False,
comment="状态:1启用 0禁用",
),
sa.Column(
"token_version",
sa.Integer(),
server_default="1",
nullable=False,
comment="令牌版本号:改密/禁用/强制下线时+1,使已签发Token立即失效",
),
sa.Column("last_login_time", sa.DateTime(), nullable=True, comment="最后登录时间"),
sa.Column("remark", sa.String(length=255), nullable=True, comment="备注"),
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_users")),
sa.UniqueConstraint("username", name=op.f("uk_users_username")),
comment="系统用户表",
mysql_charset="utf8mb4",
mysql_collate="utf8mb4_general_ci",
mysql_engine="InnoDB",
)
op.create_index(op.f("idx_users_phone"), "users", ["phone"], unique=False)
op.create_table(
"login_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("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(
"success",
mysql.TINYINT(),
server_default="0",
nullable=False,
comment="是否成功:1成功 0失败",
),
sa.Column(
"fail_reason",
sa.String(length=64),
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_login_logs_user_id"), ondelete="SET NULL"
),
sa.PrimaryKeyConstraint("id", name=op.f("pk_login_logs")),
comment="登录日志表",
mysql_charset="utf8mb4",
mysql_collate="utf8mb4_general_ci",
mysql_engine="InnoDB",
)
op.create_index("idx_login_logs_create_time", "login_logs", ["create_time"], unique=False)
op.create_index("idx_login_logs_username", "login_logs", ["username"], unique=False)
op.create_table(
"role_permissions",
sa.Column(
"id", mysql.BIGINT(unsigned=True), autoincrement=True, nullable=False, comment="主键ID"
),
sa.Column(
"role_id", mysql.BIGINT(unsigned=True), nullable=False, comment="角色ID(roles.id)"
),
sa.Column(
"permission_id",
mysql.BIGINT(unsigned=True),
nullable=False,
comment="权限ID(permissions.id)",
),
sa.Column(
"create_time",
sa.DateTime(),
server_default=sa.text("CURRENT_TIMESTAMP"),
nullable=False,
comment="创建时间",
),
sa.ForeignKeyConstraint(
["permission_id"],
["permissions.id"],
name=op.f("fk_role_permissions_permission_id"),
ondelete="CASCADE",
),
sa.ForeignKeyConstraint(
["role_id"], ["roles.id"], name=op.f("fk_role_permissions_role_id"), ondelete="CASCADE"
),
sa.PrimaryKeyConstraint("id", name=op.f("pk_role_permissions")),
sa.UniqueConstraint(
"role_id", "permission_id", name="uk_role_permissions_role_id_permission_id"
),
comment="角色权限关联表",
mysql_charset="utf8mb4",
mysql_collate="utf8mb4_general_ci",
mysql_engine="InnoDB",
)
op.create_index(
"idx_role_permissions_permission_id", "role_permissions", ["permission_id"], unique=False
)
op.create_table(
"user_roles",
sa.Column(
"id", mysql.BIGINT(unsigned=True), autoincrement=True, nullable=False, comment="主键ID"
),
sa.Column(
"user_id", mysql.BIGINT(unsigned=True), nullable=False, comment="用户ID(users.id)"
),
sa.Column(
"role_id", mysql.BIGINT(unsigned=True), nullable=False, comment="角色ID(roles.id)"
),
sa.Column(
"create_time",
sa.DateTime(),
server_default=sa.text("CURRENT_TIMESTAMP"),
nullable=False,
comment="创建时间",
),
sa.ForeignKeyConstraint(
["role_id"], ["roles.id"], name=op.f("fk_user_roles_role_id"), ondelete="CASCADE"
),
sa.ForeignKeyConstraint(
["user_id"], ["users.id"], name=op.f("fk_user_roles_user_id"), ondelete="CASCADE"
),
sa.PrimaryKeyConstraint("id", name=op.f("pk_user_roles")),
sa.UniqueConstraint("user_id", "role_id", name="uk_user_roles_user_id_role_id"),
comment="用户角色关联表",
mysql_charset="utf8mb4",
mysql_collate="utf8mb4_general_ci",
mysql_engine="InnoDB",
)
op.create_index("idx_user_roles_role_id", "user_roles", ["role_id"], unique=False)
# ### end Alembic commands ###
# 角色为字典数据,随结构初始化 —— 放在迁移而非 init-dev.sql,
# 保证生产环境同样具备这两个基础角色(规范要求的两个角色,见功能需规 2.5.1)
op.bulk_insert(
sa.table(
"roles",
sa.column("role_code", sa.String),
sa.column("role_name", sa.String),
sa.column("description", sa.String),
sa.column("sort_order", sa.Integer),
),
[
{
"role_code": "admin",
"role_name": "系统管理员",
"description": "全部权限,含计提参数与系数表配置",
"sort_order": 1,
},
{
"role_code": "user",
"role_name": "普通用户",
"description": "业务操作",
"sort_order": 2,
},
],
)
def downgrade() -> None:
"""回滚迁移。
只保留 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("role_permissions")
op.drop_table("login_logs")
op.drop_table("users")
op.drop_table("roles")
op.drop_table("permissions")