Files
qitongxue-api/internal/logic/softdelete.go
2026-09-29 10:57:01 +08:00

188 lines
8.2 KiB
Go
Raw 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.
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
}