package logic import ( "context" "encoding/json" "fmt" "github.com/gogf/gf/v2/frame/g" "github.com/gogf/gf/v2/os/gtime" "tool-api/internal/consts" ) // ============================================================================ // 软删除(T18) // // 语义:软删除、可恢复;重复删除同一个已删 id 计成功(幂等,不塞进 failures)。 // // 唯一键坑与方案:软删后行仍在,唯一索引仍被占用 → 同一 openid / 用户名 / 手机号无法再录入。 // 故软删时把**唯一列**改写为墓碑值(由主键派生,必然唯一),并把原值存进 // `deleted_from`(JSON) 以便人工恢复。墓碑值分**短/长**两种:username 必须用长墓碑(>32 字符) // 以防被人抢注为合法用户名(详见 tombstoneForColumn / tombstoneLong 的说明), // phone / openid 用短墓碑(列宽或格式约束下无法被抢注)。 // // 为什么不用「含 deleted_at 的复合唯一索引」:MySQL 唯一索引把 NULL 视为互不相等, // 活跃行的 deleted_at 全为 NULL,会导致同一唯一值可存在多条活跃记录,唯一性失效。 // // ⚠️ tools 例外:**不**墓碑化 tool_key。原因有二—— // 1. tool_key 是与小程序端 src/config/tools.ts TOOL_REGISTRY 的双份契约,保持占用更安全; // 2. 种子 seedRows 按 tool_key「存在即跳过」,若墓碑化会让被删的种子工具在重启时复活。 // 故软删工具仅置 deleted_at;重新启用请编辑原行(同名会得到「工具标识已存在」的明确提示)。 // // 📌 不变量:GoFrame v2.10 对所有 **Model** 查询(select / count / update / delete)**自动追加** // `deleted_at IS NULL`(按 schema 判定:表里有 `deleted_at`/`delete_at` 列才拼,否则无影响; // 源码 gdb_model_select.go 的 formatCondition + gdb_model_soft_time.go。Insert 另自动补 // created_at/updated_at,并把 deleted_at 置 NULL)。**raw `g.DB().Exec/GetAll/GetCount` 不受影响**。 // 因此: // ① 需要「连已软删的行一起看」的地方**必须显式 `.Unscoped()`**——否则软删行被静默过滤。典型后果: // 批量删除的存在性判断(admin_batch.go existingIdSet)误判"不存在"→ 破坏 delete 幂等; // 种子存在性判断(seed.go seedRows)误判"不存在"→ 尝试重插 → 撞未墓碑化的 uk tool_key → 启动刷屏报错。 // 以上两处均已加 .Unscoped()。 // ② 落在 raw SQL 上的软删过滤**必须手写**(如看板 MemberCount/LevelDist、TodayActive), // 不能指望框架自动加。 // (这也正是 tools.tool_key 不墓碑化的根因。) // ============================================================================ // tombstonePrefix 墓碑前缀;墓碑值 = 前缀 + 主键,与真实值不冲突且必然唯一。 const tombstonePrefix = "#del" // tombstonePad 长墓碑补尾,把墓碑值撑到 32 字符以上。 // // 🔴 为什么必须撑长:users.username 的校验只有 `required|length:3,32`,**没有字符集限制**, // 因此短墓碑 "#del5" 是**可以被人抢注为合法用户名**的。一旦有人注册了 "#del5", // 之后软删 id=5 的用户就会把 username 改成 "#del5" → 撞 uk_username 重复键 // → AdminUserBatch 整体返回 error → 整个批量删除失败(可被定向瘫痪)。 // 撑长到 >32 后,该字符串在用户名空间里**不可能被注册**,抢注通道关闭。 const tombstonePad = "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" // 40 个 x // tombstoneShort 普通墓碑。仅用于下列两类列: // - phone:录入校验要求 6-20 位**纯数字**,"#" 开头天然不可能;且列宽仅 varchar(20),放不下长墓碑; // - openid:由微信下发、用户不可自选,无法抢注。 func tombstoneShort(id int64) string { return fmt.Sprintf("%s%d", tombstonePrefix, id) } // tombstoneLong 长墓碑:恒定 > 32 字符。用于可被用户自由抢注的列(当前仅 username)。 func tombstoneLong(id int64) string { return fmt.Sprintf("%s%d%s", tombstonePrefix, id, tombstonePad) } // tombstoneForColumn 按列名选择墓碑长度。**将来新增任何可抢注的唯一列,必须在这里登记为长墓碑。** func tombstoneForColumn(col string, id int64) string { if col == "username" { return tombstoneLong(id) } return tombstoneShort(id) } // alreadySoftDeleted 行是否已被软删(deleted_at 非 nil 且非零)。供幂等跳过使用。 func alreadySoftDeleted(deletedAt *gtime.Time) bool { return deletedAt != nil && !deletedAt.IsZero() } // deletedFromJSON 组装 deleted_from 列(保存被墓碑化前的唯一键原值,便于恢复)。 // 空 map 返回 nil(写入 NULL)。 func deletedFromJSON(orig map[string]string) interface{} { if len(orig) == 0 { return nil } b, err := json.Marshal(orig) if err != nil { return nil } return string(b) } // softDeleteSimple 批量软删除(不墓碑化唯一键,仅置 deleted_at)。 // // 幂等机制(实测;源码 gdb_model_update.go:51 与 select 用同一个 formatCondition): // GoFrame 对 Model **UPDATE 也会**自动追加 `deleted_at IS NULL`。重复软删时该条件不匹配 // 已删行 → UPDATE 影响 0 行且不报错 → **天然幂等**。下面的 `.WhereNull("deleted_at")` // 与框架自动条件**重复(冗余但无害)**,保留仅作显式兜底与可读性。 func softDeleteSimple(ctx context.Context, table string, ids []int64) error { if len(ids) == 0 { return nil } _, err := g.Model(table).WhereIn("id", ids).WhereNull("deleted_at"). Data(g.Map{"deleted_at": gtime.Now()}).Update() return err } // softDeleteUniqueRow 单行软删除:置 deleted_at + 墓碑化 tombstones 指定的列 + 记录原值。 // 幂等机制同 softDeleteSimple:框架对 UPDATE 自动追加 `deleted_at IS NULL`,重复调用影响 0 行。 // 这里的 `.WhereNull("deleted_at")` 与框架自动条件重复(冗余但无害),保留作显式兜底。 func softDeleteUniqueRow(ctx context.Context, table string, id int64, tombstones map[string]string, now *gtime.Time) error { data := g.Map{ "deleted_at": now, "deleted_from": deletedFromJSON(tombstones), } for col := range tombstones { data[col] = tombstoneForColumn(col, id) } _, err := g.Model(table).Where("id", id).WhereNull("deleted_at").Data(data).Update() return err } // softDeleteUsers 软删用户:墓碑化 openid(NOT NULL)与非空 username, // 保证同 openid / 用户名可重新注册;原值写入 deleted_from。 // // 幂等:下面的读取 `g.Model(...).All()` 被框架自动追加 `deleted_at IS NULL`, // 已删行根本不会返回 → 循环体天然跳过(内部 alreadySoftDeleted 是额外兜底,正常不命中)。 func softDeleteUsers(ctx context.Context, ids []int64) error { if len(ids) == 0 { return nil } now := gtime.Now() rows, err := g.Model(consts.TableUsers). Fields("id, openid, username, deleted_at").WhereIn("id", ids).All() if err != nil { return err } for _, r := range rows { if alreadySoftDeleted(r["deleted_at"].GTime()) { continue // 已删 → 幂等跳过 } id := r["id"].Int64() orig := map[string]string{} if op := r["openid"].String(); op != "" { orig["openid"] = op } if un := r["username"].String(); un != "" { orig["username"] = un } if err = softDeleteUniqueRow(ctx, consts.TableUsers, id, orig, now); err != nil { return err } } return nil } // softDeleteEmployees 软删员工:墓碑化 phone,保证同一企业下同手机号可重新录入; // 原值写入 deleted_from。幂等机制同 softDeleteUsers(读取被框架自动过滤,已删行不返回)。 func softDeleteEmployees(ctx context.Context, ids []int64) error { if len(ids) == 0 { return nil } now := gtime.Now() rows, err := g.Model(consts.TableEmployees). Fields("id, phone, deleted_at").WhereIn("id", ids).All() if err != nil { return err } for _, r := range rows { if alreadySoftDeleted(r["deleted_at"].GTime()) { continue } id := r["id"].Int64() orig := map[string]string{} if ph := r["phone"].String(); ph != "" { orig["phone"] = ph } if err = softDeleteUniqueRow(ctx, consts.TableEmployees, id, orig, now); err != nil { return err } } return nil }