393 lines
11 KiB
Go
393 lines
11 KiB
Go
// 统一认证中心一次性数据迁移:
|
||
// 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("------------------------------------------")
|
||
}
|