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