做统一认证登录
This commit is contained in:
@@ -0,0 +1,392 @@
|
||||
// 统一认证中心一次性数据迁移:
|
||||
// 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("------------------------------------------")
|
||||
}
|
||||
Reference in New Issue
Block a user