From 5f68e77d5f3bb7cd1abed12302905e2cb541bf02 Mon Sep 17 00:00:00 2001 From: SunYF <123@hard_man.com> Date: Sun, 20 Sep 2026 15:38:45 +0800 Subject: [PATCH] =?UTF-8?q?feat(cli):=20=E6=B7=BB=E5=8A=A0=E5=91=BD?= =?UTF-8?q?=E4=BB=A4=E8=A1=8C=E6=95=8F=E6=84=9F=E6=93=8D=E4=BD=9C=E9=97=A8?= =?UTF-8?q?=E7=A6=81=E5=92=8C=E6=95=B0=E6=8D=AE=E5=BA=93=E7=AE=A1=E7=90=86?= =?UTF-8?q?=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 添加 migrate、seed、reset-all 命令行子命令 - 实现管理员密码门禁机制保护敏感操作 - 添加数据库表结构迁移和种子数据初始化功能 - 实现清空并重建全部表的 reset-all 功能 - 重构种子数据只包含系统必需基础数据,移除演示数据 - 添加 RBAC 权限角色初始化的命令行支持 - 配置命令行密码验证和操作日志记录功能 --- bj_power_wms/bj_power_wms.go | 130 +++++++++++ bj_power_wms/etc/bj_power_wms-api.yaml | 4 + bj_power_wms/internal/config/config.go | 17 +- bj_power_wms/internal/db/db.go | 36 +++ bj_power_wms/internal/db/seed.go | 131 +---------- bj_power_wms/internal/handler/rbac_admin.go | 246 ++++++++++---------- 小周.md | 17 -- 7 files changed, 320 insertions(+), 261 deletions(-) delete mode 100644 小周.md diff --git a/bj_power_wms/bj_power_wms.go b/bj_power_wms/bj_power_wms.go index 092aa69..74b3009 100644 --- a/bj_power_wms/bj_power_wms.go +++ b/bj_power_wms/bj_power_wms.go @@ -1,11 +1,14 @@ package main import ( + "bufio" + "crypto/subtle" "embed" "flag" "fmt" "io/fs" "net/http" + "os" "os/exec" "path" "runtime" @@ -13,6 +16,8 @@ import ( "time" "bj_power_wms/internal/config" + "bj_power_wms/internal/db" + "bj_power_wms/internal/eventlog" "bj_power_wms/internal/handler" "bj_power_wms/internal/logdaily" "bj_power_wms/internal/svc" @@ -69,6 +74,102 @@ func openBrowser(url string) { _ = cmd.Start() } +// promptPassword 从终端读取一行输入(不隐藏回显,用于运维终端操作) +func promptPassword(msg string) string { + fmt.Print(msg) + line, _ := bufio.NewReader(os.Stdin).ReadString('\n') + return strings.TrimRight(line, "\r\n") +} + +// adminGate 管理员密码门禁:敏感命令行操作(migrate/seed/reset-all)必须输入与配置 +// Cli.AdminPassword 一致的密码才放行;校验失败记录日志并终止,绝不继续执行(对齐 MES)。 +func adminGate(c config.Config) bool { + pwd := promptPassword("请输入管理员密码以执行 [" + flag.Arg(0) + "]: ") + if subtle.ConstantTimeCompare([]byte(pwd), []byte(c.Cli.AdminPassword)) != 1 { + logx.Errorf("敏感命令 [%s] 因管理员密码校验失败被终止", flag.Arg(0)) + fmt.Println("管理员密码不正确,已终止该操作。") + return false + } + logx.Infof("敏感命令 [%s] 已通过管理员密码校验", flag.Arg(0)) + return true +} + +// dbConf 从服务配置构造数据库连接配置 +func dbConf(c config.Config) db.DatabaseConf { + return db.DatabaseConf{ + Host: c.Postgres.Host, + Port: c.Postgres.Port, + User: c.Postgres.User, + Password: c.Postgres.Password, + Dbname: c.Postgres.DBName, + } +} + +// runMigrate 仅建表/补列(只增,不删任何数据) +func runMigrate(c config.Config) { + dbc := dbConf(c) + if err := db.EnsureDB(dbc); err != nil { + logx.Errorf("ensure database failed: %v", err) + os.Exit(1) + } + client := db.MustNewDB(dbc) + defer client.Close() + if err := db.AutoMigrate(client); err != nil { + logx.Errorf("migrate failed: %v", err) + os.Exit(1) + } + fmt.Println("migrate: 数据库结构迁移成功(只增/补列,未删除任何数据)。") +} + +// runSeed 对齐基础主数据 + 权限角色(不删数据;表不存在则先建) +func runSeed(c config.Config) { + dbc := dbConf(c) + if err := db.EnsureDB(dbc); err != nil { + logx.Errorf("ensure database failed: %v", err) + os.Exit(1) + } + client := db.MustNewDB(dbc) + defer client.Close() + if err := db.AutoMigrate(client); err != nil { + logx.Errorf("seed migrate failed: %v", err) + os.Exit(1) + } + if err := db.SeedIfEmpty(client); err != nil { + logx.Errorf("seed failed: %v", err) + os.Exit(1) + } + svcCtx := &svc.ServiceContext{EntClient: client, EventLog: eventlog.NewWriter(client)} + p, r, b := handler.SeedRBAC(svcCtx, "system") + fmt.Printf("seed: 基础数据与权限已对齐(新增权限 %d、角色 %d、绑定用户 %d)。\n", p, r, b) +} + +// runResetAll 清空并重建全部表 + 重灌全部种子(基础主数据 + 权限角色)。此操作不可恢复! +func runResetAll(c config.Config) { + dbc := dbConf(c) + if err := db.EnsureDB(dbc); err != nil { + logx.Errorf("ensure database failed: %v", err) + os.Exit(1) + } + if err := db.DropAllTables(dbc); err != nil { + logx.Errorf("reset-all drop failed: %v", err) + os.Exit(1) + } + client := db.MustNewDB(dbc) + defer client.Close() + if err := db.AutoMigrate(client); err != nil { + logx.Errorf("reset-all migrate failed: %v", err) + os.Exit(1) + } + if err := db.SeedIfEmpty(client); err != nil { + logx.Errorf("reset-all seed failed: %v", err) + os.Exit(1) + } + svcCtx := &svc.ServiceContext{EntClient: client, EventLog: eventlog.NewWriter(client)} + p, r, b := handler.SeedRBAC(svcCtx, "system") + logx.Infof("reset-all 完成:新增权限 %d、角色 %d、绑定用户 %d", p, r, b) + fmt.Println("reset-all: 已清空并重建全部表,写入基础主数据与权限(管理员 admin/123456)。") +} + func main() { flag.Parse() @@ -83,6 +184,35 @@ func main() { logx.SetWriter(logx.NewWriter(w)) } + // 命令行子命令:DDL/删库/种子等敏感操作一律显式触发 + 管理员密码门禁, + // 绝不随服务启动自动执行(对齐 MES:migrate / seed / reset-all / serve)。 + switch cmd := flag.Arg(0); cmd { + case "reset-all": + fmt.Println("reset-all: 即将清空并重建 WMS 全部表,此操作不可恢复!") + if !adminGate(c) { + os.Exit(1) + } + runResetAll(c) + return + case "seed": + if !adminGate(c) { + os.Exit(1) + } + runSeed(c) + return + case "migrate": + if !adminGate(c) { + os.Exit(1) + } + runMigrate(c) + return + case "", "serve": + // 正常启动服务:落到下方启动逻辑 + default: + fmt.Println("未知命令: " + cmd + ";可用命令: migrate / seed / reset-all / serve") + os.Exit(1) + } + staticHandler := newStaticHandler(webFS) // 兜底路由:已注册的 /api/* 走正常匹配;未注册的非 /api 路径走嵌入式静态资源 diff --git a/bj_power_wms/etc/bj_power_wms-api.yaml b/bj_power_wms/etc/bj_power_wms-api.yaml index b0cf006..b7045d6 100644 --- a/bj_power_wms/etc/bj_power_wms-api.yaml +++ b/bj_power_wms/etc/bj_power_wms-api.yaml @@ -49,6 +49,10 @@ Agv: Web: OpenBrowser: true +# 命令行敏感操作门禁密码(migrate/reset-all/seed 需输入,独立于后台登录账号密码) +Cli: + AdminPassword: "Hardman_2026" + # 附件存储(年/月/日/文件类型/uuid.ext,库内只存相对路径,不存绝对路径) # 生产环境改 RootDir: /data/bj_power/attachments Attachment: diff --git a/bj_power_wms/internal/config/config.go b/bj_power_wms/internal/config/config.go index 6b1adbe..8edf526 100644 --- a/bj_power_wms/internal/config/config.go +++ b/bj_power_wms/internal/config/config.go @@ -36,12 +36,12 @@ type MesConfig struct { // AgvConfig 海康 AGV(RCS-2000) 调度系统对接 // 未配置 appKey/appSecret/RCS 地址 或 EnableMock=true 时,使用 mock 客户端(模拟下发成功/到位),便于离线联调。 type AgvConfig struct { - EnableMock bool `json:",default=true"` // 是否模拟(未接真实 RCS 前保持 true) + EnableMock bool `json:",default=true"` // 是否模拟(未接真实 RCS 前保持 true) BaseURL string `json:",default=http://127.0.0.1:1010/rcs/rtas"` // RCS 服务前缀,如 http://10.10.10.10:1010/rcs/rtas - AppKey string `json:",default=unset"` // RCS 颁发给业务系统的应用标识 - AppSecret string `json:",default=unset"` // RCS 应用私钥 - CarrierCode string `json:",default=DEFAULT"` // 默认载具/托盘编号(选填) - PollInterval int `json:",default=10"` // 任务状态轮询间隔(秒) + AppKey string `json:",default=unset"` // RCS 颁发给业务系统的应用标识 + AppSecret string `json:",default=unset"` // RCS 应用私钥 + CarrierCode string `json:",default=DEFAULT"` // 默认载具/托盘编号(选填) + PollInterval int `json:",default=10"` // 任务状态轮询间隔(秒) } // WebConfig 前端静态资源托管配置(原库房客户端 8891 已合并进主服务) @@ -49,6 +49,12 @@ type WebConfig struct { OpenBrowser bool `json:",default=true"` // 本机开发时启动后自动打开浏览器;服务器部署设为 false } +// CliConfig 命令行敏感操作门禁密码(migrate/reset-all/seed 需输入,独立于后台登录账号密码)。 +// 与 MES 对齐:DDL/删库等高危操作一律显式触发 + 密码校验,绝不随服务启动自动执行。 +type CliConfig struct { + AdminPassword string `json:",default=Hardman_2026"` +} + // AttachmentConfig 附件存储 / 归档 / 磁盘监控配置(etc/*.yaml 的 Attachment 段)。 // 落盘约定:/年/月/日/<文件类型大写>/.,库内只存相对路径。 type AttachmentConfig struct { @@ -73,6 +79,7 @@ type Config struct { Agv AgvConfig Web WebConfig Attachment AttachmentConfig + Cli CliConfig } // RedisConf Redis 连接配置 diff --git a/bj_power_wms/internal/db/db.go b/bj_power_wms/internal/db/db.go index 4d9fdce..fcd325f 100644 --- a/bj_power_wms/internal/db/db.go +++ b/bj_power_wms/internal/db/db.go @@ -268,3 +268,39 @@ func applyColumnPatches(ctx context.Context) { } } } + +// DropAllTables 删除 public schema 下全部表(CASCADE 连带索引/序列/外键约束)。 +// 仅供命令行 reset-all 使用:清空后由 AutoMigrate 重建结构、SeedIfEmpty + handler.SeedRBAC 重灌种子。 +// 与 MES 的 db.DropAllTables 对齐(逐表 DROP 而非 DROP SCHEMA,保留 schema 权限归属)。 +func DropAllTables(c DatabaseConf) error { + dsn := fmt.Sprintf("postgresql://%s:%s@%s:%d/%s?sslmode=disable", + c.User, c.Password, c.Host, c.Port, c.Dbname) + sqlDB, err := sql.Open("pgx", dsn) + if err != nil { + return err + } + defer sqlDB.Close() + + rows, err := sqlDB.Query(`SELECT tablename FROM pg_tables WHERE schemaname='public'`) + if err != nil { + return err + } + var names []string + for rows.Next() { + var t string + if err := rows.Scan(&t); err != nil { + rows.Close() + return err + } + names = append(names, t) + } + rows.Close() + + for _, t := range names { + if _, err := sqlDB.Exec(`DROP TABLE IF EXISTS "` + t + `" CASCADE`); err != nil { + return err + } + } + slog.Info(fmt.Sprintf("已删除全部表 %d 张", len(names))) + return nil +} diff --git a/bj_power_wms/internal/db/seed.go b/bj_power_wms/internal/db/seed.go index c84f347..ae54155 100644 --- a/bj_power_wms/internal/db/seed.go +++ b/bj_power_wms/internal/db/seed.go @@ -4,14 +4,21 @@ import ( "context" "fmt" "log/slog" - "time" "bj_power_wms/ent" "golang.org/x/crypto/bcrypt" ) -// SeedIfEmpty 首次启动时预置基础数据(区域/账号/演示物料与库存) +// SeedIfEmpty 首次启动/重置后预置「系统运行必需的基础主数据」,不含任何演示业务数据。 +// +// 生产环境口径(用户裁决 2026-09-20):种子只允许写入系统跑起来所必需的固定主数据, +// 严禁预置演示物料/演示库存/演示账号等假业务数据。具体: +// - 区域:库房四大基础分区 Z01~Z04(库存落位的根节点,level=1); +// - 管理员账号:admin(初始密码 123456,登录后应立即修改);其余账号由管理员在「账号管理」按需创建、分配角色; +// - 接驳台:DOCK01~20(产线,DOCKn↔工位n)+ DOCK21(库房收货),系统预置只读主数据(dock.go 不提供增删改)。 +// +// 权限与角色不在此:由 handler.SeedRBAC 幂等初始化(reset-all/seed 命令与 POST /api/rbac/seed 共用同一函数)。 func SeedIfEmpty(client *ent.Client) error { ctx := context.Background() @@ -36,106 +43,20 @@ func SeedIfEmpty(client *ent.Client) error { slog.Info("seed: zones created") } - // 2. 账号(全部统一初始密码 123456) + // 2. 管理员账号(唯一预置账号;初始密码 123456,登录后应立即修改) users, _ := client.User.Query().Count(ctx) if users == 0 { adminHash, _ := bcrypt.GenerateFromPassword([]byte("123456"), bcrypt.DefaultCost) - opHash, _ := bcrypt.GenerateFromPassword([]byte("123456"), bcrypt.DefaultCost) client.User.Create(). SetUsername("admin"). SetPassword(string(adminHash)). SetRealName("系统管理员"). SetRole("admin"). SaveX(ctx) - client.User.Create(). - SetUsername("store1"). - SetPassword(string(opHash)). - SetRealName("一号库房保管员"). - SetRole("operator"). - SetDept("库房"). - SaveX(ctx) - client.User.Create(). - SetUsername("insp1"). - SetPassword(string(opHash)). - SetRealName("质检员一号"). - SetRole("inspector"). - SetDept("质检"). - SaveX(ctx) - slog.Info("seed: users created (admin/123456, store1/123456, insp1/123456)") + slog.Info("seed: admin user created (admin/123456)") } - // 3. 演示物料与库存 - materials, _ := client.Material.Query().Count(ctx) - if materials == 0 { - mats := []struct { - code, name, spec, unit string - mode int - isBatch, isSerial bool - }{ - {"GJ-BAN-001", "10mm钢板(结构件)", "2000x1000x10", "张", 1, true, false}, - {"GJ-GANGGUAN-002", "无缝钢管114x8", "114x8x6000", "根", 1, true, false}, - {"LJ-LUOSI-M16", "M16高强度螺栓", "M16x80 10.9级", "件", 1, true, false}, - {"JM-ZHOUCHENG-6020", "精密轴承6020", "6020-2RS", "套", 2, false, true}, - {"JM-DIANJI-750W", "伺服电机750W", "ECMA-C21307", "台", 2, false, true}, - {"BCP-JIEKOUTI", "接驳体半成品", "-", "件", 2, false, true}, - } - for _, m := range mats { - client.Material.Create(). - SetCode(m.code). - SetName(m.name). - SetNillableSpec(strPtrNil(m.spec)). - SetNillableUnit(strPtrNil(m.unit)). - SetManageMode(m.mode). - SetIsBatchManaged(m.isBatch). - SetIsSerialManaged(m.isSerial). - SaveX(ctx) - } - - // 演示库存(统一 inventory 表,manage_mode 区分结构件/电气件) - today := time.Now().Format("2006-01-02") - // 结构件批次(已检合格) - client.Inventory.Create(). - SetManageMode(1). - SetMaterialCode("GJ-BAN-001"). - SetNillableMaterialName(strPtrNil("10mm钢板(结构件)")). - SetBatchNo("B2026060100001"). - SetQuantity(120).SetLockedQty(0). - SetNillableProductionDate(strPtrNil("2026-06-01")). - SetNillableSupplier(strPtrNil("宝钢供应商")). - SetQualityStatus("合格"). - SetNillableZoneCode(strPtrNil("Z02")). - SetStatus("在库"). - SaveX(ctx) - client.Inventory.Create(). - SetManageMode(1). - SetMaterialCode("GJ-GANGGUAN-002"). - SetNillableMaterialName(strPtrNil("无缝钢管114x8")). - SetBatchNo("B2026060200034"). - SetQuantity(50).SetLockedQty(0). - SetNillableProductionDate(strPtrNil(today)). - SetNillableSupplier(strPtrNil("天津钢管")). - SetQualityStatus("未检"). - SetNillableZoneCode(strPtrNil("Z01")). - SetStatus("在库"). - SaveX(ctx) - - // 电气件 SN(在库,合格) - for i := 1; i <= 5; i++ { - client.Inventory.Create(). - SetManageMode(2). - SetMaterialCode("JM-ZHOUCHENG-6020"). - SetNillableMaterialName(strPtrNil("精密轴承6020")). - SetSnCode("SN20260601001" + itoaPad(i)). - SetQuantity(1).SetLockedQty(0). - SetQualityStatus("合格"). - SetNillableZoneCode(strPtrNil("Z02")). - SetStatus("在库"). - SaveX(ctx) - } - slog.Info("seed: demo materials & stock created") - } - - // 4. 接驳台主数据 DOCK01~20(产线, DOCKn↔工位n) + DOCK21(库房) + // 3. 接驳台主数据 DOCK01~20(产线, DOCKn↔工位n) + DOCK21(库房) docks, _ := client.Dock.Query().Count(ctx) if docks == 0 { for i := 1; i <= 20; i++ { @@ -162,31 +83,3 @@ func SeedIfEmpty(client *ent.Client) error { return nil } - -func strPtrNil(s string) *string { - if s == "" { - return nil - } - return &s -} - -func itoaPad(n int) string { - base := "000" - return base[:len(base)-lenOfInt(n)] + intToStr(n) -} - -func lenOfInt(n int) int { - c := 1 - for n >= 10 { - n /= 10 - c++ - } - return c -} - -func intToStr(n int) string { - if n < 10 { - return string(rune('0' + n)) - } - return intToStr(n/10) + string(rune('0'+n%10)) -} diff --git a/bj_power_wms/internal/handler/rbac_admin.go b/bj_power_wms/internal/handler/rbac_admin.go index 188af17..8f86db9 100644 --- a/bj_power_wms/internal/handler/rbac_admin.go +++ b/bj_power_wms/internal/handler/rbac_admin.go @@ -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}) }) } diff --git a/小周.md b/小周.md deleted file mode 100644 index 0b17e5e..0000000 --- a/小周.md +++ /dev/null @@ -1,17 +0,0 @@ -WMS系统 -1.仓储作业-入库管理 -物料和检测单号为必输项,但是前端显示规格不一致,物料未填写时,输入框会爆红,提示请选择物料,检测单号未填写时,输入框不会爆红,但是会有弹窗提示。建议如果必填项未填写的话,输入框既爆红,又会有弹窗提示。 -2.仓储作业-入库管理 -结构件入库,电气件 SN 入库,其他入库,这三个都可以添加一个附件上传的功能,就是入库记录页面的附件上传。入库记录页面的附件上传现在存在bug,上传附件后点击查看显示{"code":401,"message":"未登录或 token 缺失"} -3.区域维护设计的不合理,我说一下当前的展示效果,每次新建一个区域编码的时候,需要输入区域编码和区域名称,这时候问题就来了,一个区域不止一个货架号吧,但是当前页面只能新建一个货架号,然后选择第几层,选择位置号,一个货架中不止一层,每一次不止一个位置号,这样的话,现在这个区域维护页面需要显示好多重复的区域编码和区域名称,你需要思考一下这个问题,这个地方应该怎么进行优化。然后还存在一个联动问题,区域维护新建好,或者编辑一个区域之后,入库管理页面,在选择区域的时候不会显示刚刚新建的内容,需要刷新一下页面才能显示 -4.仓储作业-入库管理 -电气件SN入库把这个名称修改为电气件入库 -5.仓储作业-入库管理 -结构类入库:这部分现在存在问题,在选择物料的时候,会出现不止结构件类型的,还出现了,应该在其他入库类型的选项 -6.wms工单备料台账,存在问题,mes未实现把数据推送到wms工单备料台账,导致wms工单备料台账中没有数据,然后间接导致不能进行出库操作,不能闭环跑完整个流程 -7.仓储作业-出库管理 -出库区域选择的时候全是undefined,点击出库会有错误,参数错误: json: unknown field "outboundType" - - - -