feat(cli): 添加命令行敏感操作门禁和数据库管理功能

- 添加 migrate、seed、reset-all 命令行子命令
- 实现管理员密码门禁机制保护敏感操作
- 添加数据库表结构迁移和种子数据初始化功能
- 实现清空并重建全部表的 reset-all 功能
- 重构种子数据只包含系统必需基础数据,移除演示数据
- 添加 RBAC 权限角色初始化的命令行支持
- 配置命令行密码验证和操作日志记录功能
This commit is contained in:
SunYF
2026-09-20 15:38:45 +08:00
parent 60eacc9c4f
commit 5f68e77d5f
7 changed files with 320 additions and 261 deletions
+126 -120
View File
@@ -115,140 +115,146 @@ var seedRoleMeta = []struct {
{"inspector", "质检员", "质量检验相关"},
}
// seedRbacHandler POST /api/rbac/seed 幂等初始化权限与角色(已存在按 code 跳过)
func seedRbacHandler(ctx *svc.ServiceContext) http.HandlerFunc {
return requireAdmin(func(w http.ResponseWriter, r *http.Request) {
createdP, createdR := 0, 0
for _, p := range seedPermissions {
exist, _ := ctx.EntClient.Permission.Query().
Where(permission.CodeEQ(p.Code)).Exist(ctx0())
if exist {
// 已存在:幂等补齐 parent_code(新增字段不影响已建角色权限)
_, _ = ctx.EntClient.Permission.Update().
Where(permission.CodeEQ(p.Code)).
SetParentCode(p.Parent).
Save(ctx0())
continue
}
_, err := ctx.EntClient.Permission.Create().
SetCode(p.Code).SetName(p.Name).SetType(p.Type).
// SeedRBAC 幂等初始化权限与角色(已存在按 code 跳过/补齐),返回(新增权限数, 新增角色数, 绑定历史用户数)。
// 接收最小 ServiceContext(命令行 reset-all/seed 仅需 EntClient + EventLog),与 HTTP seedRbacHandler 共用同一逻辑,
// 保证两条路径灌入完全一致的权限模型。operator 为操作人(HTTP 传登录名,命令行传 "system")。
func SeedRBAC(ctx *svc.ServiceContext, operator string) (createdP, createdR, bound int) {
for _, p := range seedPermissions {
exist, _ := ctx.EntClient.Permission.Query().
Where(permission.CodeEQ(p.Code)).Exist(ctx0())
if exist {
// 已存在:幂等补齐 parent_code(新增字段不影响已建角色权限)
_, _ = ctx.EntClient.Permission.Update().
Where(permission.CodeEQ(p.Code)).
SetParentCode(p.Parent).
SetPath(p.Path).SetIcon(p.Icon).SetSort(p.Sort).
Save(ctx0())
if err == nil {
createdP++
}
continue
}
codes := seedRoleCodes()
bound := 0
for _, m := range seedRoleMeta {
rl, err := ctx.EntClient.Role.Query().
Where(role.CodeEQ(m.Code)).Only(ctx0())
if err != nil {
// 内置角色不存在则创建
rl, err = ctx.EntClient.Role.Create().
SetCode(m.Code).SetName(m.Name).SetRemark(m.Remark).
SetPermissionCodes(codes[m.Code]).
Save(ctx0())
if err != nil {
continue
}
createdR++
}
// 把「role 字符串 == 该角色 code」的历史用户绑定到 role_id(幂等,仅绑定 role_id 为空者)
users, _ := ctx.EntClient.User.Query().
Where(user.RoleEQ(m.Code), user.RoleIDIsNil()).All(ctx0())
for _, u := range users {
if _, err := ctx.EntClient.User.UpdateOneID(u.ID).SetRoleID(rl.ID).Save(ctx0()); err == nil {
bound++
}
}
_, err := ctx.EntClient.Permission.Create().
SetCode(p.Code).SetName(p.Name).SetType(p.Type).
SetParentCode(p.Parent).
SetPath(p.Path).SetIcon(p.Icon).SetSort(p.Sort).
Save(ctx0())
if err == nil {
createdP++
}
// 迁移历史角色:把“可查看的菜单”自动补上对应导出码(精细化:原全局 *:export 等价于“可见菜单均可导出”)
// 同时清理可能残留的废弃 *:export。已有角色 seed 不会覆盖,故在此补齐导出能力。
menuExport := map[string]string{}
for _, p := range seedPermissions {
if p.Type == "BUTTON" && p.Parent != "" && p.Name == "导出" {
menuExport[p.Parent] = p.Code
}
}
allRoles, _ := ctx.EntClient.Role.Query().All(ctx0())
for _, rl := range allRoles {
codes := rl.PermissionCodes
changed := false
set := map[string]bool{}
for _, c := range codes {
if c == "*:export" {
changed = true // 移除废弃全局码
continue
}
set[c] = true
}
for c := range set {
if strings.HasSuffix(c, ":view") {
// c 本身就是菜单权限码(如 inbound:view),与 menuExport 的 key 一致
if exp, ok := menuExport[c]; ok && !set[exp] {
set[exp] = true
changed = true
}
}
}
if !changed {
continue
}
newCodes := make([]string, 0, len(set))
for c := range set {
newCodes = append(newCodes, c)
}
_, _ = ctx.EntClient.Role.UpdateOneID(rl.ID).SetPermissionCodes(newCodes).Save(ctx0())
}
// 新增菜单权限的自动补授:seed 新增了 MENU 权限码(如新上线「退库确认」),
// 但内置角色已存在、不会被上面覆盖 → 这里把内置角色(非 admin)按 seedRoleCodes
// 的期望集合做「并集补授」,保证新菜单对既有角色立即可见。仅对内置三角色生效,
// 不触碰自建角色(避免影响管理员的自定义授权)。
builtin := map[string]bool{"admin": true, "operator": true, "inspector": true}
for _, m := range seedRoleMeta {
if m.Code == "admin" || !builtin[m.Code] {
continue
}
rl, err := ctx.EntClient.Role.Query().Where(role.CodeEQ(m.Code)).Only(ctx0())
}
codes := seedRoleCodes()
for _, m := range seedRoleMeta {
rl, err := ctx.EntClient.Role.Query().
Where(role.CodeEQ(m.Code)).Only(ctx0())
if err != nil {
// 内置角色不存在则创建
rl, err = ctx.EntClient.Role.Create().
SetCode(m.Code).SetName(m.Name).SetRemark(m.Remark).
SetPermissionCodes(codes[m.Code]).
Save(ctx0())
if err != nil {
continue
}
want := codes[m.Code]
set := map[string]bool{}
for _, c := range rl.PermissionCodes {
set[c] = true
createdR++
}
// 把「role 字符串 == 该角色 code」的历史用户绑定到 role_id(幂等,仅绑定 role_id 为空者)
users, _ := ctx.EntClient.User.Query().
Where(user.RoleEQ(m.Code), user.RoleIDIsNil()).All(ctx0())
for _, u := range users {
if _, err := ctx.EntClient.User.UpdateOneID(u.ID).SetRoleID(rl.ID).Save(ctx0()); err == nil {
bound++
}
changed := false
for _, c := range want {
if !set[c] {
set[c] = true
}
}
// 迁移历史角色:把“可查看的菜单”自动补上对应导出码(精细化:原全局 *:export 等价于“可见菜单均可导出”)
// 同时清理可能残留的废弃 *:export。已有角色 seed 不会覆盖,故在此补齐导出能力。
menuExport := map[string]string{}
for _, p := range seedPermissions {
if p.Type == "BUTTON" && p.Parent != "" && p.Name == "导出" {
menuExport[p.Parent] = p.Code
}
}
allRoles, _ := ctx.EntClient.Role.Query().All(ctx0())
for _, rl := range allRoles {
codes := rl.PermissionCodes
changed := false
set := map[string]bool{}
for _, c := range codes {
if c == "*:export" {
changed = true // 移除废弃全局码
continue
}
set[c] = true
}
for c := range set {
if strings.HasSuffix(c, ":view") {
// c 本身就是菜单权限码(如 inbound:view),与 menuExport 的 key 一致
if exp, ok := menuExport[c]; ok && !set[exp] {
set[exp] = true
changed = true
}
}
if !changed {
continue
}
merged := make([]string, 0, len(set))
for c := range set {
merged = append(merged, c)
}
_, _ = ctx.EntClient.Role.UpdateOneID(rl.ID).SetPermissionCodes(merged).Save(ctx0())
}
// 强制管理员角色始终拥有全部权限(不允许被缩减),与“admin 拥有所有权限”一致
if adminRole, aerr := ctx.EntClient.Role.Query().Where(role.CodeEQ("admin")).Only(ctx0()); aerr == nil {
_ = ctx.EntClient.Role.UpdateOneID(adminRole.ID).SetPermissionCodes(codes["admin"]).Exec(ctx0())
if !changed {
continue
}
// 清理已废弃的全局 *:export 权限行(迁移后无角色引用,幂等)
_, _ = ctx.EntClient.Permission.Delete().Where(permission.CodeEQ("*:export")).Exec(ctx0())
newCodes := make([]string, 0, len(set))
for c := range set {
newCodes = append(newCodes, c)
}
_, _ = ctx.EntClient.Role.UpdateOneID(rl.ID).SetPermissionCodes(newCodes).Save(ctx0())
}
// 新增菜单权限的自动补授:seed 新增了 MENU 权限码(如新上线「退库确认」),
// 但内置角色已存在、不会被上面覆盖 → 这里把内置角色(非 admin)按 seedRoleCodes
// 的期望集合做「并集补授」,保证新菜单对既有角色立即可见。仅对内置三角色生效,
// 不触碰自建角色(避免影响管理员的自定义授权)。
builtin := map[string]bool{"admin": true, "operator": true, "inspector": true}
for _, m := range seedRoleMeta {
if m.Code == "admin" || !builtin[m.Code] {
continue
}
rl, err := ctx.EntClient.Role.Query().Where(role.CodeEQ(m.Code)).Only(ctx0())
if err != nil {
continue
}
want := codes[m.Code]
set := map[string]bool{}
for _, c := range rl.PermissionCodes {
set[c] = true
}
changed := false
for _, c := range want {
if !set[c] {
set[c] = true
changed = true
}
}
if !changed {
continue
}
merged := make([]string, 0, len(set))
for c := range set {
merged = append(merged, c)
}
_, _ = ctx.EntClient.Role.UpdateOneID(rl.ID).SetPermissionCodes(merged).Save(ctx0())
}
// 强制管理员角色始终拥有全部权限(不允许被缩减),与“admin 拥有所有权限”一致
if adminRole, aerr := ctx.EntClient.Role.Query().Where(role.CodeEQ("admin")).Only(ctx0()); aerr == nil {
_ = ctx.EntClient.Role.UpdateOneID(adminRole.ID).SetPermissionCodes(codes["admin"]).Exec(ctx0())
}
// 清理已废弃的全局 *:export 权限行(迁移后无角色引用,幂等)
_, _ = ctx.EntClient.Permission.Delete().Where(permission.CodeEQ("*:export")).Exec(ctx0())
// 幂等 seed 只在发生真实变更(新增权限/角色/绑定历史用户)时记录日志,避免每次页面加载刷屏
if createdP > 0 || createdR > 0 || bound > 0 {
ctx.EventLog.Write(ctx0(), "rbac.seed", r.Header.Get("X-Username"), "rbac", "",
"RBAC 种子初始化", map[string]any{"permissions": createdP, "roles": createdR, "bound": bound})
}
logx.Infof("rbac seed: 新增权限 %d 条、角色 %d 个、绑定历史用户 %d 个", createdP, createdR, bound)
// 幂等 seed 只在发生真实变更(新增权限/角色/绑定历史用户)时记录日志,避免每次页面加载刷屏
if createdP > 0 || createdR > 0 || bound > 0 {
ctx.EventLog.Write(ctx0(), "rbac.seed", operator, "rbac", "",
"RBAC 种子初始化", map[string]any{"permissions": createdP, "roles": createdR, "bound": bound})
}
logx.Infof("rbac seed: 新增权限 %d 条、角色 %d 个、绑定历史用户 %d 个", createdP, createdR, bound)
return
}
// seedRbacHandler POST /api/rbac/seed 幂等初始化权限与角色(复用 SeedRBAC,与命令行 reset-all/seed 同源)
func seedRbacHandler(ctx *svc.ServiceContext) http.HandlerFunc {
return requireAdmin(func(w http.ResponseWriter, r *http.Request) {
createdP, createdR, bound := SeedRBAC(ctx, r.Header.Get("X-Username"))
ok(w, map[string]any{"createdPermissions": createdP, "createdRoles": createdR, "boundUsers": bound})
})
}