package migrate import ( "fmt" "sort" "strings" ) // MigrateReport 一次迁移运行的完整报告(dry-run 与 run 共用,dry-run 的 *Inserted 恒 0)。 // // 字段分组: // - 机构/校区:Institutions / Campuses 处理量 + 冲突 // - 用户归一:UsersTotal / UsersInserted / UsersSkipped(重跑幂等跳过) // - 身份:各 type 写入量(Phone / Email / Username / Wechat) // - 微信锚点:WechatUnion // - 成员关系:Memberships // - 冲突/异常清单:PhoneConflicts / UsernameConflicts / PhoneInvalid / WechatOrphans // (P0-08 人工复核输入,不自动处理) type MigrateReport struct { DryRun bool // true=仅预演不写库 // 机构/校区 InstitutionsTotal int InstitutionsCreated int CampusesTotal int CampusesCreated int // 用户归一 UsersTotal int UsersCreated int // 新插入 t_user UsersSkipped int // 重跑幂等:canonical_user_id 已存在 UsersDisabled int // status 映射为 disabled(0/3) // 身份(t_identity) IdentitiesPhone int IdentitiesEmail int IdentitiesUsername int IdentitiesWechat int IdentityDuplicates int // 全局唯一键已存在而跳过的 identity(幂等) // 微信锚点(t_wechat_union) WechatUnionCreated int // 成员关系(t_membership) MembershipsCreated int // 遗留映射(t_legacy_mapping) LegacyMappingsCreated int // 冲突/异常清单(P0-08 人工复核输入,不自动合并/丢弃) PhoneConflicts []ConflictItem // phone 撞号(同归一 phone >1 legacy id) UsernameConflicts []ConflictItem // username 归一后全局重复 PhoneInvalid []ConflictItem // phone 归一失败(phone_invalid) WechatOrphans []ConflictItem // 微信身份 userid 不在 t_user(孤儿,丢弃) // 错误(非阻断性警告:单行失败记录,不中断整批) Warnings []string } // ConflictItem 冲突/异常清单的单项(进 P0-08 人工复核)。 type ConflictItem struct { LegacyID int32 // legacy t_user.id(或关联表主键) Detail string // 人类可读描述(phone 值 / username 值 / 孤儿 openid) } // String 渲染为可读报告文本(stdout 输出 / 留档)。 func (r *MigrateReport) String() string { var b strings.Builder mode := "RUN" if r.DryRun { mode = "DRY-RUN" } b.WriteString(fmt.Sprintf("=== 数据迁移报告(%s)===\n", mode)) b.WriteString(fmt.Sprintf("机构: %d 读取 / %d 新建\n", r.InstitutionsTotal, r.InstitutionsCreated)) b.WriteString(fmt.Sprintf("校区: %d 读取 / %d 新建\n", r.CampusesTotal, r.CampusesCreated)) b.WriteString(fmt.Sprintf("用户: %d 读取 / %d 新建 / %d 幂等跳过 / %d disabled\n", r.UsersTotal, r.UsersCreated, r.UsersSkipped, r.UsersDisabled)) b.WriteString(fmt.Sprintf("身份: phone=%d email=%d username=%d wechat=%d(幂等跳过 %d)\n", r.IdentitiesPhone, r.IdentitiesEmail, r.IdentitiesUsername, r.IdentitiesWechat, r.IdentityDuplicates)) b.WriteString(fmt.Sprintf("微信锚点(t_wechat_union): %d 新建\n", r.WechatUnionCreated)) b.WriteString(fmt.Sprintf("成员关系(t_membership): %d 新建\n", r.MembershipsCreated)) b.WriteString(fmt.Sprintf("遗留映射(t_legacy_mapping): %d 新建\n", r.LegacyMappingsCreated)) if len(r.PhoneConflicts) > 0 { b.WriteString(fmt.Sprintf("\n[冲突] phone 撞号 %d 项(转 P0-08 人工):\n", len(r.PhoneConflicts))) for _, c := range sortedConflicts(r.PhoneConflicts) { b.WriteString(fmt.Sprintf(" legacy_id=%d %s\n", c.LegacyID, c.Detail)) } } if len(r.UsernameConflicts) > 0 { b.WriteString(fmt.Sprintf("\n[冲突] username 归一后重复 %d 项(转 P0-08 人工):\n", len(r.UsernameConflicts))) for _, c := range sortedConflicts(r.UsernameConflicts) { b.WriteString(fmt.Sprintf(" legacy_id=%d %s\n", c.LegacyID, c.Detail)) } } if len(r.PhoneInvalid) > 0 { b.WriteString(fmt.Sprintf("\n[异常] phone 归一失败 %d 项(phone_invalid,原始值留 t_legacy_mapping):\n", len(r.PhoneInvalid))) for _, c := range sortedConflicts(r.PhoneInvalid) { b.WriteString(fmt.Sprintf(" legacy_id=%d %s\n", c.LegacyID, c.Detail)) } } if len(r.WechatOrphans) > 0 { b.WriteString(fmt.Sprintf("\n[丢弃] 微信孤儿 %d 项(userid 不在 t_user,openid 无法归属):\n", len(r.WechatOrphans))) for _, c := range sortedConflicts(r.WechatOrphans) { b.WriteString(fmt.Sprintf(" legacy_id=%d %s\n", c.LegacyID, c.Detail)) } } if len(r.Warnings) > 0 { b.WriteString(fmt.Sprintf("\n[警告] %d 条:\n", len(r.Warnings))) for _, w := range r.Warnings { b.WriteString(" " + w + "\n") } } return b.String() } // sortedConflicts 按 LegacyID 排序(报告稳定输出,防 map 遍历序漂移)。 func sortedConflicts(items []ConflictItem) []ConflictItem { out := make([]ConflictItem, len(items)) copy(out, items) sort.Slice(out, func(i, j int) bool { return out[i].LegacyID < out[j].LegacyID }) return out }