Files
ttstd-infinite-canvas/backend/internal/repository/user_purge.go

214 lines
8.4 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 repository
import (
"fmt"
"strings"
"infinite-canvas/backend/internal/database"
"infinite-canvas/backend/internal/model"
"gorm.io/gorm"
)
// UserPurgeResult 汇总彻底删除用户时清掉的行数(按表)。
type UserPurgeResult struct {
DeletedByTable map[string]int64 `json:"deletedByTable"`
DeletedRowsTotal int64 `json:"deletedRowsTotal"`
}
type purgeSchema struct {
model any
table string
dbNameOfGo map[string]string // Go 字段名 → 真实列名(尊重 gorm tag / 命名策略)
}
func (p purgeSchema) column(goName string) string {
return p.dbNameOfGo[goName]
}
// purgeFKTemplates 描述“指向用户子数据的列”如何回溯到用户。
// 统一以 user_id = ? 收口;模板中 %s 为列名占位,? 为用户 ID 参数。
// 由于所有模板都回溯到“带 user_id 的根表”,而根表行只在第二阶段删除,
// 第一阶段内部任意表间顺序都不会破坏子查询可见性。
var purgeFKTemplates = map[string]string{
"TaskID": "%s IN (SELECT id FROM tasks WHERE user_id = ?)",
"SessionID": "%s IN (SELECT id FROM sessions WHERE user_id = ?)",
"ProjectID": "%s IN (SELECT id FROM projects WHERE user_id = ?)",
"AssetID": "%s IN (SELECT id FROM assets WHERE user_id = ?)",
"CanvasID": "%s IN (SELECT id FROM canvas_projects WHERE user_id = ?)",
"ResourceID": "%s IN (SELECT id FROM resources WHERE user_id = ?)",
"StorageSettingID": "%s IN (SELECT id FROM user_oss_settings WHERE user_id = ?)",
"BillingOrderID": "%s IN (SELECT id FROM billing_orders WHERE user_id = ?)",
"PaymentOrderID": "%s IN (SELECT id FROM payment_orders WHERE user_id = ?)",
"AssetVersionID": "%s IN (SELECT id FROM asset_versions WHERE asset_id IN (SELECT id FROM assets WHERE user_id = ?))",
"ShotID": "%s IN (SELECT id FROM shots WHERE project_id IN (SELECT id FROM projects WHERE user_id = ?))",
"UnitID": "%s IN (SELECT id FROM project_units WHERE project_id IN (SELECT id FROM projects WHERE user_id = ?))",
"WorkflowInstanceID": "%s IN (SELECT id FROM workflow_instances WHERE project_id IN (SELECT id FROM projects WHERE user_id = ?))",
"WorkflowStepID": "%s IN (SELECT id FROM workflow_step_instances WHERE workflow_instance_id IN (SELECT id FROM workflow_instances WHERE project_id IN (SELECT id FROM projects WHERE user_id = ?)))",
"RevisionID": "%s IN (SELECT id FROM shot_revisions WHERE shot_id IN (SELECT id FROM shots WHERE project_id IN (SELECT id FROM projects WHERE user_id = ?)))",
"FolderID": "%s IN (SELECT id FROM project_asset_folders WHERE project_id IN (SELECT id FROM projects WHERE user_id = ?))",
"VoiceProfileID": "%s IN (SELECT id FROM voice_profiles WHERE user_id = ?)",
}
// purgeOwnColumns 第二阶段直接归属用户的字段(Go 字段名)。
var purgeOwnColumns = []string{"UserID", "OwnerID", "RedeemedBy"}
// PurgeUserData 在单个事务内彻底删除一个用户及其名下业务数据,并(可选)
// 在事务尾部原子写入一条管理员审计记录(如 user.purge)。
// 阶段一:删除所有能通过 FK 回溯到该用户的子数据(根表行仍在,子查询始终可见);
// 阶段二:删除直接归属该用户的行(user_id / owner_id / redeemed_by);
// 阶段三:审计记录(保留本次传入的审计)、邮箱验证码与用户主行。
// 用户主行最后删,保证事务内全程可回溯。
func (r *Repository) PurgeUserData(user *model.User, keepAudit *model.AdminAuditEvent) (*UserPurgeResult, error) {
userID := strings.TrimSpace(user.ID)
if userID == "" {
return nil, fmt.Errorf("缺少用户 ID")
}
email := strings.TrimSpace(user.Email)
result := &UserPurgeResult{DeletedByTable: map[string]int64{}}
keepAction := ""
if keepAudit != nil {
keepAction = strings.TrimSpace(keepAudit.Action)
}
err := r.db.Transaction(func(tx *gorm.DB) error {
schemas, err := parsePurgeSchemas(tx)
if err != nil {
return err
}
// 阶段一:子表级联(不含用户主行,也不含 user_id 直属行)
for _, schema := range schemas {
if _, isUser := schema.model.(*model.User); isUser {
continue
}
conds, args := buildFKPurgeConds(schema, userID)
if len(conds) == 0 {
continue
}
if err := deletePurgedRows(tx, schema, conds, args, result); err != nil {
return err
}
}
// 阶段二:直属行
for _, schema := range schemas {
if _, isUser := schema.model.(*model.User); isUser {
continue
}
conds := make([]string, 0, 1)
args := make([]any, 0, 1)
for _, goName := range purgeOwnColumns {
if column := schema.column(goName); column != "" {
conds = append(conds, column+" = ?")
args = append(args, userID)
}
}
if len(conds) == 0 {
continue
}
if err := deletePurgedRows(tx, schema, conds, args, result); err != nil {
return err
}
}
// 阶段三:审计(该用户作操作者/被操作对象)、邮箱验证码、用户主行
for _, schema := range schemas {
if _, ok := schema.model.(*model.AdminAuditEvent); ok {
if err := deleteAdminAuditRows(tx, schema, userID, keepAction, result); err != nil {
return err
}
}
if _, ok := schema.model.(*model.EmailVerificationCode); ok && email != "" {
if column := schema.column("Email"); column != "" {
if err := deletePurgedWhere(tx, schema, column+" = ?", []any{email}, result); err != nil {
return err
}
}
}
}
for _, schema := range schemas {
if _, isUser := schema.model.(*model.User); isUser {
if err := deletePurgedWhere(tx, schema, "id = ?", []any{userID}, result); err != nil {
return err
}
}
}
// 本次删除动作的审计记录在用户主行删除后写入,避免被上面的清理误删。
if keepAudit != nil {
if err := tx.Create(keepAudit).Error; err != nil {
return fmt.Errorf("写入管理员审计失败:%w", err)
}
}
return nil
})
if err != nil {
return nil, err
}
return result, nil
}
func parsePurgeSchemas(db *gorm.DB) ([]purgeSchema, error) {
models := database.Models()
schemas := make([]purgeSchema, 0, len(models))
for _, dst := range models {
stmt := &gorm.Statement{DB: db, Dest: dst}
if err := stmt.Parse(dst); err != nil {
return nil, fmt.Errorf("解析表结构失败(%T):%w", dst, err)
}
names := make(map[string]string, len(stmt.Schema.Fields))
for _, field := range stmt.Schema.Fields {
names[field.Name] = field.DBName
}
schemas = append(schemas, purgeSchema{model: dst, table: stmt.Schema.Table, dbNameOfGo: names})
}
return schemas, nil
}
// buildFKPurgeConds 汇总一张表中命中“用户子数据 FK”的删除条件。
func buildFKPurgeConds(schema purgeSchema, userID string) ([]string, []any) {
conds := make([]string, 0, 2)
args := make([]any, 0, 2)
for goName, template := range purgeFKTemplates {
column := schema.column(goName)
if column == "" {
continue
}
conds = append(conds, fmt.Sprintf(template, column))
args = append(args, userID)
}
return conds, args
}
func deletePurgedRows(tx *gorm.DB, schema purgeSchema, conds []string, args []any, result *UserPurgeResult) error {
combined := "(" + strings.Join(conds, " OR ") + ")"
return deletePurgedWhere(tx, schema, combined, args, result)
}
func deletePurgedWhere(tx *gorm.DB, schema purgeSchema, where string, args []any, result *UserPurgeResult) error {
db := tx.Table(schema.table).Where(where, args...).Delete(schema.model)
if db.Error != nil {
return fmt.Errorf("清理 %s 失败:%w", schema.table, db.Error)
}
if db.RowsAffected > 0 {
result.DeletedByTable[schema.table] += db.RowsAffected
result.DeletedRowsTotal += db.RowsAffected
}
return nil
}
func deleteAdminAuditRows(tx *gorm.DB, schema purgeSchema, userID string, keepAction string, result *UserPurgeResult) error {
actorColumn := schema.column("ActorUserID")
targetTypeColumn := schema.column("TargetType")
targetIDColumn := schema.column("TargetID")
actionColumn := schema.column("Action")
if actorColumn == "" || targetTypeColumn == "" || targetIDColumn == "" || actionColumn == "" {
return nil
}
where := "(" + actorColumn + " = ? OR (" + targetTypeColumn + " = 'user' AND " + targetIDColumn + " = ?))"
args := []any{userID, userID}
if keepAction != "" {
where += " AND " + actionColumn + " <> ?"
args = append(args, keepAction)
}
return deletePurgedWhere(tx, schema, where, args, result)
}