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
+130
View File
@@ -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/删库/种子等敏感操作一律显式触发 + 管理员密码门禁,
// 绝不随服务启动自动执行(对齐 MESmigrate / 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 路径走嵌入式静态资源
+4
View File
@@ -49,6 +49,10 @@ Agv:
Web:
OpenBrowser: true
# 命令行敏感操作门禁密码(migrate/reset-all/seed 需输入,独立于后台登录账号密码)
Cli:
AdminPassword: "Hardman_2026"
# 附件存储(年/月/日/文件类型/uuid.ext,库内只存相对路径,不存绝对路径)
# 生产环境改 RootDir: /data/bj_power/attachments
Attachment:
+12 -5
View File
@@ -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 段)。
// 落盘约定:<RootDir>/年/月/日/<文件类型大写>/<uuid>.<ext>,库内只存相对路径。
type AttachmentConfig struct {
@@ -73,6 +79,7 @@ type Config struct {
Agv AgvConfig
Web WebConfig
Attachment AttachmentConfig
Cli CliConfig
}
// RedisConf Redis 连接配置
+36
View File
@@ -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
}
+12 -119
View File
@@ -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))
}
+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})
})
}
-17
View File
@@ -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"