做统一认证登录

This commit is contained in:
2026-09-19 21:44:04 +08:00
parent 1a4471e34d
commit fa281363fb
78 changed files with 8127 additions and 1442 deletions
+392
View File
@@ -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("------------------------------------------")
}