Files
2026-09-19 21:44:04 +08:00

393 lines
11 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 统一认证中心一次性数据迁移:
// 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("------------------------------------------")
}