feat(backend): schema v8 活动 code 唯一索引与用户数据彻底删除 - user_purge 按外键模板分阶段清理, 管理路由/服务守卫(仅管理员/禁删自己/保底管理员/账务拒绝), 审计留痕

This commit is contained in:
maomaochong-ai
2026-09-06 20:28:52 +08:00
parent 5c6612c5dd
commit d109dddde4
7 changed files with 450 additions and 7 deletions

View File

@@ -178,13 +178,6 @@ func migrateSchemaV6(tx *gorm.DB) error {
return nil
}
func migrateSchemaV7(tx *gorm.DB) error {
if err := tx.AutoMigrate(&model.Asset{}, &model.AssetFolder{}); err != nil {
return fmt.Errorf("创建个人素材分类并扩展素材目录字段:%w", err)
}
return nil
}
func migrateSchemaV8(tx *gorm.DB) error {
if !tx.Migrator().HasTable(&model.LogicalModel{}) {
return nil
@@ -198,6 +191,12 @@ func migrateSchemaV8(tx *gorm.DB) error {
return nil
}
func migrateSchemaV7(tx *gorm.DB) error {
if err := tx.AutoMigrate(&model.Asset{}, &model.AssetFolder{}); err != nil {
return fmt.Errorf("创建个人素材分类并扩展素材目录字段:%w", err)
}
return nil
}
func MigrateSchema(db *gorm.DB) error {
return db.Transaction(func(tx *gorm.DB) error {
if tx.Dialector.Name() == "postgres" {

View File

@@ -375,6 +375,18 @@ func RegisterAdminRoutes(r *gin.RouterGroup, svc *service.Service) {
}
ok(c, gin.H{"ok": true})
})
r.DELETE("/admin/users/:id/permanent", func(c *gin.Context) {
user, err := currentUser(c, svc)
if err != nil {
failService(c, err)
return
}
if err := svc.PurgeUser(user, c.Param("id")); err != nil {
failService(c, err)
return
}
ok(c, gin.H{"ok": true})
})
r.GET("/admin/channels", func(c *gin.Context) {
user, err := currentUser(c, svc)
if err != nil {

View File

@@ -285,6 +285,20 @@ func (r *Repository) DeleteUserAuthSessions(userID string) error {
return r.db.Delete(&model.AuthSession{}, "user_id = ?", userID).Error
}
// CountUserRowsByTable 统计目标用户在给定表(均须含 user_id 列)中的行数,供删除前守卫使用。
func (r *Repository) CountUserRowsByTable(userID string, tables []string) (map[string]int64, error) {
counts := make(map[string]int64, len(tables))
for _, table := range tables {
var n int64
if err := r.db.Table(table).Where("user_id = ?", userID).Count(&n).Error; err != nil {
return nil, err
}
if n > 0 {
counts[table] = n
}
}
return counts, nil
}
func (r *Repository) LatestEmailVerificationCode(email string, purpose string) (*model.EmailVerificationCode, error) {
var code model.EmailVerificationCode
if err := r.db.Where("email = ? AND purpose = ? AND used_at IS NULL", email, purpose).Order("created_at desc").First(&code).Error; err != nil {

View File

@@ -0,0 +1,213 @@
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)
}

View File

@@ -389,6 +389,47 @@ func (s *Service) DeleteUser(actor *model.User, userID string) error {
return s.appendAdminAudit(actor, "user.disable", "user", user.ID, "停用用户并清除登录态", nil)
}
// purgeFinanceTables 存在即拒绝彻底删除的账务数据:真实资金流水/订单必须长期保留。
var purgeFinanceTables = []string{"billing_orders", "payment_orders"}
// PurgeUser 彻底删除(硬删)用户:账号与全部关联数据在单个事务内级联物理移除,不可恢复。
// 守卫:仅管理员、不能删自己、管理员需保底一个;存在资金流水/账单等需保留的账务数据时拒绝,提示改用停用。
// 业务内容(任务/项目/素材等)由 repo.PurgeUserData 按 FK 链在事务内级联删除,删除动作以 user.purge 审计落库。
func (s *Service) PurgeUser(actor *model.User, userID string) error {
if err := s.RequireAdmin(actor); err != nil {
return err
}
if actor.ID == userID {
return BadAuthRequest("不能删除当前登录的管理员账号")
}
user, err := s.repo.User(userID)
if err != nil {
return err
}
if user.Role == model.UserRoleAdmin {
count, err := s.repo.ActiveAdminCountExcluding(user.ID)
if err != nil {
return err
}
if count == 0 {
return BadAuthRequest("至少需要保留一个管理员")
}
}
financeCounts, err := s.repo.CountUserRowsByTable(user.ID, purgeFinanceTables)
if err != nil {
return err
}
if len(financeCounts) > 0 {
return BadAuthRequest("该用户存在资金流水/账单数据,无法彻底删除,请改用停用")
}
audit, err := newAdminAuditEvent(actor, "user.purge", "user", user.ID, "彻底删除用户账号及全部关联数据", nil)
if err != nil {
return err
}
_, err = s.repo.PurgeUserData(user, audit)
return err
}
func (s *Service) BulkDisableUsers(actor *model.User, req BulkDisableUsersRequest) (*BulkDisableUsersResult, error) {
if err := s.RequireAdmin(actor); err != nil {
return nil, err