// 统一认证中心一次性数据迁移: // yz_system_tenant_user → yz_auth_identity(自然人)+ yz_auth_tenant_user(身份-企业绑定) // // 前置条件: // 1. 已人工执行 docs/sql/create_auth_tables.sql 建表; // 2. 在 go/ 目录下运行(需读取 conf/app.conf)。 // // 用法: // go run scripts/migrate_auth.go -check 预检:只输出统计与冲突报告,不写任何数据 // go run scripts/migrate_auth.go -apply 执行迁移(自动备份源表后再写入) // go run scripts/migrate_auth.go -apply -force 目标表已有数据时强制重跑 // // 迁移规则: // - 归并键优先级:手机号 > 邮箱 > 租户内账号;三者皆空时按 tid+原 uid 兜底; // - 同一归并键命中多条(多企业任职)→ 只建 1 个 identity,再建 N 条企业绑定; // - 同一归并键下密码不一致 → 记为冲突,取 is_default 优先、其次 id 最大的那条, // 并写入冲突报告供人工核对; // - 历史密码为 legacy(salt$sha256),无法离线转 argon2id,原样搬迁, // 用户登录成功时自动重新哈希升级。 package main import ( "encoding/csv" "flag" "fmt" "log" "os" "sort" "strings" "time" beego "github.com/beego/beego/v2/server/web" "github.com/google/uuid" "server/models" "server/pkg/passwordutil" ) // srcRow 源表行(yz_system_tenant_user) type srcRow struct { ID uint64 Tid uint64 Uid uint64 Account string Name string Phone string Email string Password string GroupID uint64 OrgID uint64 IsDefault int8 Status int8 } // group 同一归并键下的多条源记录 type group struct { key string rows []srcRow conflict bool // 同键下密码不一致 pwdValues int } func trimPtr(p *string) string { if p == nil { return "" } return strings.TrimSpace(*p) } // mergeKey 归并键:手机 > 邮箱 > 账号;都为空时返回空串(由调用方兜底) func mergeKey(r srcRow) string { if r.Phone != "" { return "m:" + r.Phone } if r.Email != "" { return "e:" + r.Email } if r.Account != "" { return "a:" + r.Account } return "" } func main() { check := flag.Bool("check", false, "预检模式,只输出报告不写数据") apply := flag.Bool("apply", false, "执行迁移") force := flag.Bool("force", false, "目标表已有数据时强制重跑") flag.Parse() if !*check && !*apply { fmt.Println("请指定 -check(预检)或 -apply(执行迁移)") os.Exit(1) } if err := beego.LoadAppConfig("ini", "conf/app.conf"); err != nil { log.Printf("加载 conf/app.conf 失败(若已自动加载可忽略): %v", err) } models.Init("") rows, err := loadSourceRows() if err != nil { log.Fatalf("读取源表失败: %v", err) } if len(rows) == 0 { log.Fatal("源表 yz_system_tenant_user 无有效数据,无需迁移") } log.Printf("源表有效记录: %d 条", len(rows)) groups := buildGroups(rows) log.Printf("归并后身份数: %d 个(其中冲突 %d 组)", len(groups), countConflict(groups)) // 冲突报告 if err := writeConflictReport(groups); err != nil { log.Printf("写入冲突报告失败: %v", err) } if *check { printSummary(rows, groups) log.Println("预检完成(未写入任何数据)。确认无误后执行: go run scripts/migrate_auth.go -apply") return } // ---- 执行迁移 ---- if err := ensureTargetWritable(*force); err != nil { log.Fatalf("目标表检查失败: %v", err) } if err := backupSourceTable(); err != nil { log.Fatalf("备份源表失败(已中止迁移): %v", err) } migratedBindings := 0 conflictHandled := 0 mapFile, err := os.Create(fmt.Sprintf("migrate_auth_map_%s.csv", time.Now().Format("20060102150405"))) if err != nil { log.Fatalf("创建映射文件失败: %v", err) } defer mapFile.Close() mapWriter := csv.NewWriter(mapFile) defer mapWriter.Flush() _ = mapWriter.Write([]string{"old_id", "old_uid", "tid", "identity_id", "tenant_user_id", "account", "merge_key", "conflict"}) for _, g := range groups { // 代表行:is_default 优先,其次 id 最大 rep := pickRepresentative(g) identityID, err := insertIdentity(rep, g.key) if err != nil { log.Fatalf("写入身份失败(key=%s): %v", g.key, err) } if g.conflict { conflictHandled++ } for _, r := range g.rows { tu := models.AuthTenantUser{ Tid: r.Tid, IdentityID: identityID, GroupID: r.GroupID, OrgID: r.OrgID, IsDefault: r.IsDefault, Status: r.Status, } if r.Account != "" { tu.Account = &r.Account } if r.Name != "" { tu.Name = &r.Name } if r.Phone != "" { tu.Phone = &r.Phone } if r.Email != "" { tu.Email = &r.Email } newID, err := models.Orm.Insert(&tu) if err != nil { log.Fatalf("写入企业绑定失败(tid=%d, identity=%d): %v", r.Tid, identityID, err) } migratedBindings++ _ = mapWriter.Write([]string{ fmt.Sprintf("%d", r.ID), fmt.Sprintf("%d", r.Uid), fmt.Sprintf("%d", r.Tid), fmt.Sprintf("%d", identityID), fmt.Sprintf("%d", newID), r.Account, g.key, fmt.Sprintf("%v", g.conflict), }) } } log.Printf("迁移完成:身份 %d 个,企业绑定 %d 条,处理冲突 %d 组", len(groups), migratedBindings, conflictHandled) log.Printf("映射文件已生成:%s(保存好,回滚或排查用)", mapFile.Name()) } // loadSourceRows 读取未软删的源记录 func loadSourceRows() ([]srcRow, error) { var list []models.SystemTenantUser _, err := models.Orm.QueryTable(new(models.SystemTenantUser)). Filter("delete_time__isnull", true). All(&list) if err != nil { return nil, err } rows := make([]srcRow, 0, len(list)) for _, m := range list { rows = append(rows, srcRow{ ID: m.ID, Tid: m.Tid, Uid: m.Uid, Account: trimPtr(m.Account), Name: trimPtr(m.Name), Phone: trimPtr(m.Phone), Email: trimPtr(m.Email), Password: trimPtr(m.Password), GroupID: m.GroupID, OrgID: m.OrgID, IsDefault: m.IsDefault, Status: m.Status, }) } return rows, nil } // buildGroups 按归并键分组,并标记密码冲突 func buildGroups(rows []srcRow) []*group { buckets := map[string]*group{} order := make([]string, 0) for _, r := range rows { key := mergeKey(r) if key == "" { // 手机/邮箱/账号全空:无法归并,按 tid+uid 兜底为独立身份 key = fmt.Sprintf("u:%d-%d", r.Tid, r.Uid) } g, ok := buckets[key] if !ok { g = &group{key: key} buckets[key] = g order = append(order, key) } g.rows = append(g.rows, r) } sort.Strings(order) out := make([]*group, 0, len(order)) for _, k := range order { g := buckets[k] pwdSet := map[string]bool{} for _, r := range g.rows { pwdSet[r.Password] = true } g.pwdValues = len(pwdSet) g.conflict = len(pwdSet) > 1 out = append(out, g) } return out } // pickRepresentative 选出代表行:is_default 优先,其次 id 最大 func pickRepresentative(g *group) srcRow { sorted := make([]srcRow, len(g.rows)) copy(sorted, g.rows) sort.SliceStable(sorted, func(i, j int) bool { if sorted[i].IsDefault != sorted[j].IsDefault { return sorted[i].IsDefault > sorted[j].IsDefault } return sorted[i].ID > sorted[j].ID }) return sorted[0] } func countConflict(groups []*group) int { n := 0 for _, g := range groups { if g.conflict { n++ } } return n } // insertIdentity 写入身份;密码算法按原值识别(legacy 保持原样,首登自动升级) func insertIdentity(rep srcRow, key string) (uint64, error) { algo := passwordutil.AlgoLegacy if passwordutil.AlgoOf(rep.Password) == passwordutil.AlgoArgon2id { algo = passwordutil.AlgoArgon2id } identity := models.AuthIdentity{ UnionID: strings.ReplaceAll(uuid.NewString(), "-", ""), PasswordAlgo: algo, Status: rep.Status, } if rep.Phone != "" { identity.Mobile = &rep.Phone } if rep.Email != "" { identity.Email = &rep.Email } if rep.Password != "" { identity.PasswordHash = &rep.Password } if rep.Name != "" { identity.Nickname = &rep.Name } id, err := models.Orm.Insert(&identity) return uint64(id), err } // ensureTargetWritable 目标表已有数据时需 -force 才继续,避免重复迁移 func ensureTargetWritable(force bool) error { cnt, err := models.Orm.QueryTable(new(models.AuthTenantUser)).Count() if err != nil { return err } if cnt > 0 && !force { return fmt.Errorf("yz_auth_tenant_user 已存在 %d 条数据,如确认要重跑请加 -force(建议先清空目标表)", cnt) } return nil } // backupSourceTable 备份源表 func backupSourceTable() error { name := fmt.Sprintf("yz_system_tenant_user_bak_%s", time.Now().Format("20060102150405")) _, err := models.Orm.Raw(fmt.Sprintf("CREATE TABLE `%s` AS SELECT * FROM `yz_system_tenant_user`", name)).Exec() if err != nil { return err } log.Printf("源表已备份为:%s", name) return nil } // writeConflictReport 输出冲突报告 CSV func writeConflictReport(groups []*group) error { conflicts := make([]*group, 0) for _, g := range groups { if g.conflict { conflicts = append(conflicts, g) } } // 无法归并(手机/邮箱/账号全空)的记录也提示 unkeyed := 0 for _, g := range groups { if strings.HasPrefix(g.key, "u:") { unkeyed += len(g.rows) } } filename := fmt.Sprintf("migrate_auth_conflict_%s.csv", time.Now().Format("20060102150405")) f, err := os.Create(filename) if err != nil { return err } defer f.Close() w := csv.NewWriter(f) defer w.Flush() _ = w.Write([]string{"merge_key", "row_count", "password_variants", "tids", "representative_id"}) for _, g := range conflicts { tids := make([]string, 0, len(g.rows)) for _, r := range g.rows { tids = append(tids, fmt.Sprintf("%d", r.Tid)) } rep := pickRepresentative(g) _ = w.Write([]string{ g.key, fmt.Sprintf("%d", len(g.rows)), fmt.Sprintf("%d", g.pwdValues), strings.Join(tids, "|"), fmt.Sprintf("%d", rep.ID), }) } if len(conflicts) > 0 { log.Printf("发现密码冲突 %d 组,详见 %s(迁移取 is_default 优先的那条,请人工核对)", len(conflicts), filename) } if unkeyed > 0 { log.Printf("警告:有 %d 条记录手机/邮箱/账号全空,已按 tid+uid 各自独立建身份,建议补齐手机号", unkeyed) } return nil } // printSummary 预检模式输出摘要 func printSummary(rows []srcRow, groups []*group) { tenantSet := map[uint64]bool{} for _, r := range rows { tenantSet[r.Tid] = true } multi := 0 for _, g := range groups { if len(g.rows) > 1 { multi++ } } log.Println("---------------- 预检摘要 ----------------") log.Printf("源记录数 : %d", len(rows)) log.Printf("涉及企业数 : %d", len(tenantSet)) log.Printf("归并后身份数 : %d", len(groups)) log.Printf("多企业任职身份 : %d", multi) log.Printf("密码冲突组数 : %d", countConflict(groups)) log.Println("------------------------------------------") }