feat: publish standalone worker
Separate worker packaging and service lifecycle from the control plane.
Этот коммит содержится в:
582
app/models/deletion.go
Обычный файл
582
app/models/deletion.go
Обычный файл
@@ -0,0 +1,582 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"log"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// OnUserCacheInvalidate is called when a user is mutated in a way that
|
||||
// invalidates the cached representation in auth/cache.go. It is wired up
|
||||
// from the auth package during init() to avoid an import cycle.
|
||||
var OnUserCacheInvalidate func(userID int64)
|
||||
|
||||
// DeletionGracePeriod is the time between a user requesting account deletion
|
||||
// and the scheduled hard-delete. During this period the user can cancel the
|
||||
// request and monitoring is paused for all of the user's accounts.
|
||||
const DeletionGracePeriod = 7 * 24 * time.Hour
|
||||
|
||||
// RequestUserDeletion sets the user's DeletionRequestedAt to now,
|
||||
// starting the 7-day grace period.
|
||||
func RequestUserDeletion(user *User) error {
|
||||
if user == nil {
|
||||
return errors.New("nil user")
|
||||
}
|
||||
if user.DeletionPending() {
|
||||
return nil
|
||||
}
|
||||
now := time.Now()
|
||||
user.DeletionRequestedAt = &now
|
||||
if err := DB().Save(user).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if OnUserCacheInvalidate != nil {
|
||||
OnUserCacheInvalidate(user.ID)
|
||||
}
|
||||
log.Printf("user %d requested account deletion (grace until %s)", user.ID, now.Add(DeletionGracePeriod))
|
||||
return nil
|
||||
}
|
||||
|
||||
// CancelUserDeletion clears the user's DeletionRequestedAt field,
|
||||
// canceling the pending account deletion.
|
||||
func CancelUserDeletion(user *User) error {
|
||||
if user == nil {
|
||||
return errors.New("nil user")
|
||||
}
|
||||
if !user.DeletionPending() {
|
||||
return nil
|
||||
}
|
||||
user.DeletionRequestedAt = nil
|
||||
if err := DB().Save(user).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if OnUserCacheInvalidate != nil {
|
||||
OnUserCacheInvalidate(user.ID)
|
||||
}
|
||||
log.Printf("user %d canceled account deletion", user.ID)
|
||||
return nil
|
||||
}
|
||||
|
||||
// HardDeleteAccount removes an account and all of its data (monitors, checks,
|
||||
// events, messages, contacts, groups, notifications, etc.) in a single
|
||||
// transaction. Accesses and invites pointing at the account are also cleaned
|
||||
// up. Users themselves are kept (they may belong to other accounts). This is
|
||||
// the admin "purge" path used to remove spam/error accounts immediately.
|
||||
func HardDeleteAccount(accountID int64) error {
|
||||
return DB().Transaction(func(tx *gorm.DB) error {
|
||||
// Remove accesses pointing at this account first (FK constraint).
|
||||
if err := tx.Where("account_id = ?", accountID).Delete(&Access{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
// Remove invites for this account.
|
||||
if err := tx.Where("account_id = ?", accountID).Delete(&Invite{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
// Delete all account data and the account row itself.
|
||||
return deleteAccountData(tx, accountID)
|
||||
})
|
||||
}
|
||||
|
||||
// HardDeleteUser removes the user and all of their owned data: accesses,
|
||||
// contacts, accounts, monitors, checks, events and messages. The deletion
|
||||
// is wrapped in a transaction to make sure partial failures don't leave
|
||||
// the database in a broken state.
|
||||
func HardDeleteUser(userID int64) error {
|
||||
return DB().Transaction(func(tx *gorm.DB) error {
|
||||
user := User{}
|
||||
if err := tx.First(&user, userID).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 1) Find all accounts the user has any access to.
|
||||
accesses := make([]Access, 0)
|
||||
if err := tx.Where("user_id = ?", userID).Find(&accesses).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
accountIDs := make([]int64, 0, len(accesses))
|
||||
for i := range accesses {
|
||||
accountIDs = append(accountIDs, accesses[i].AccountID)
|
||||
}
|
||||
|
||||
// 2) Find all contacts that belong to those accounts or directly to the user.
|
||||
if err := tx.Where("user_id = ?", userID).Delete(&Contact{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 3) For each account: delete the related data, then the account itself.
|
||||
for _, accountID := range accountIDs {
|
||||
if err := deleteAccountData(tx, accountID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// 4) Remove accesses.
|
||||
if err := tx.Where("user_id = ?", userID).Delete(&Access{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 5) Remove invites issued by or for this user.
|
||||
if err := tx.Exec("DELETE FROM invites WHERE invitee_id = ? OR account_id IN (?)",
|
||||
userID, accountIDs).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 6) Remove auth identities (password, social).
|
||||
if err := tx.Exec("DELETE FROM identities WHERE user_id = ?", userID).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 7) Remove api keys owned by the user.
|
||||
if err := tx.Where("user_id = ?", userID).Delete(&ApiKey{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 8) Remove sessions.
|
||||
if err := tx.Exec("DELETE FROM remember_tokens WHERE user_id = ?", userID).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 9) Finally remove the user row.
|
||||
if err := tx.Delete(&user).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
log.Printf("user %d hard-deleted (cascade accounts=%v)", userID, accountIDs)
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// deleteAccountData removes everything associated with a single account:
|
||||
// contacts, monitors, checks, events, messages, notifications, groups and
|
||||
// the account itself. The user accesses are removed separately.
|
||||
func deleteAccountData(tx *gorm.DB, accountID int64) error {
|
||||
// All contacts that reference this account (both account-scoped ones
|
||||
// with user_id IS NULL and per-user ones created in
|
||||
// CreateAccountForUser that set both account_id and user_id).
|
||||
// User-only contacts (account_id IS NULL) are independent of the
|
||||
// account and survive the deletion. contacts.account_id has a FK
|
||||
// to accounts(id) without ON DELETE CASCADE, so all referencing
|
||||
// rows must be removed before the account row goes away.
|
||||
contactIDs := make([]int64, 0)
|
||||
if err := tx.Model(&Contact{}).Where("account_id = ?", accountID).Pluck("id", &contactIDs).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
notificationIDs := make([]int64, 0)
|
||||
if err := tx.Model(&Notification{}).Where("account_id = ?", accountID).Pluck("id", ¬ificationIDs).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if len(contactIDs) > 0 {
|
||||
if err := tx.Exec("DELETE FROM notification_contacts WHERE contact_id IN (?)", contactIDs).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if len(notificationIDs) > 0 {
|
||||
if err := tx.Exec("DELETE FROM notification_contacts WHERE notification_id IN (?)", notificationIDs).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
messageQuery := tx.Model(&Message{})
|
||||
switch {
|
||||
case len(contactIDs) > 0 && len(notificationIDs) > 0:
|
||||
messageQuery = messageQuery.Where("contact_id IN (?) OR notification_id IN (?)", contactIDs, notificationIDs)
|
||||
case len(contactIDs) > 0:
|
||||
messageQuery = messageQuery.Where("contact_id IN (?)", contactIDs)
|
||||
case len(notificationIDs) > 0:
|
||||
messageQuery = messageQuery.Where("notification_id IN (?)", notificationIDs)
|
||||
default:
|
||||
messageQuery = nil
|
||||
}
|
||||
if messageQuery != nil {
|
||||
messageIDs := make([]int64, 0)
|
||||
if err := messageQuery.Pluck("id", &messageIDs).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if len(messageIDs) > 0 {
|
||||
if err := tx.Exec("DELETE FROM event_messages WHERE message_id IN (?)", messageIDs).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.Where("id IN (?)", messageIDs).Delete(&Message{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
if err := tx.Where("account_id = ?", accountID).Delete(&Contact{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Monitors (and their checks/events/messages via cascade below).
|
||||
monitors := make([]Monitor, 0)
|
||||
if err := tx.Joins("JOIN groups ON monitors.group_id = groups.id").
|
||||
Where("groups.account_id = ?", accountID).Find(&monitors).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
monitorIDs := make([]int64, 0, len(monitors))
|
||||
for i := range monitors {
|
||||
monitorIDs = append(monitorIDs, monitors[i].ID)
|
||||
}
|
||||
|
||||
if len(monitorIDs) > 0 {
|
||||
// Checks
|
||||
checks := make([]Check, 0)
|
||||
if err := tx.Where("monitor_id IN (?)", monitorIDs).Find(&checks).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
checkIDs := make([]int64, 0, len(checks))
|
||||
for i := range checks {
|
||||
checkIDs = append(checkIDs, checks[i].ID)
|
||||
}
|
||||
|
||||
// Events
|
||||
events := make([]Event, 0)
|
||||
if err := tx.Where("monitor_id IN (?)", monitorIDs).Find(&events).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
eventIDs := make([]int64, 0, len(events))
|
||||
for i := range events {
|
||||
eventIDs = append(eventIDs, events[i].ID)
|
||||
}
|
||||
|
||||
// Join tables first to avoid FK violations
|
||||
if len(checkIDs) > 0 {
|
||||
if err := tx.Exec("DELETE FROM event_checks WHERE check_id IN (?)", checkIDs).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
// check_region_results.check_id has a FK to checks(id) without
|
||||
// ON DELETE CASCADE, so it must be purged before checks go away.
|
||||
if err := tx.Exec("DELETE FROM check_region_results WHERE check_id IN (?)", checkIDs).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if len(eventIDs) > 0 {
|
||||
if err := tx.Exec("DELETE FROM event_messages WHERE event_id IN (?)", eventIDs).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if len(checkIDs) > 0 {
|
||||
if err := tx.Where("id IN (?)", checkIDs).Delete(&Check{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if len(eventIDs) > 0 {
|
||||
if err := tx.Where("id IN (?)", eventIDs).Delete(&Event{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
// DNS records
|
||||
if err := tx.Where("monitor_id IN (?)", monitorIDs).Delete(&DNSRecord{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Monitors themselves
|
||||
if err := tx.Where("id IN (?)", monitorIDs).Delete(&Monitor{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// Notification <-> group links
|
||||
if err := tx.Exec(
|
||||
"DELETE FROM notification_groups WHERE group_id IN (SELECT id FROM groups WHERE account_id = ?)",
|
||||
accountID,
|
||||
).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
// Notifications
|
||||
if err := tx.Where("account_id = ?", accountID).Delete(&Notification{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// LLMs scoped to this account. worker_llms.llm_id has a FK to llms(id)
|
||||
// without ON DELETE CASCADE, so the join rows must go first.
|
||||
if err := tx.Exec(
|
||||
"DELETE FROM worker_llms WHERE llm_id IN (SELECT id FROM llms WHERE account_id = ?)",
|
||||
accountID,
|
||||
).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.Where("account_id = ?", accountID).Delete(&LLM{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Groups
|
||||
if err := tx.Where("account_id = ?", accountID).Delete(&Group{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Inventory entities (docs/plans/inventory-management.md §6). All
|
||||
// four tables hold account_id FKs to accounts(id) without ON
|
||||
// DELETE CASCADE, so we wipe them in dependency order: deployments
|
||||
// and domains first (both FK into sites and servers), then
|
||||
// server_ips, then sites, then servers. Anything the account does
|
||||
// not own is left alone (e.g. shared infra servers are filtered
|
||||
// by account_id and survive).
|
||||
if err := tx.Where("account_id = ?", accountID).Delete(&Deployment{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.Where("account_id = ?", accountID).Delete(&Domain{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.Exec(
|
||||
"DELETE FROM server_ips WHERE server_id IN (SELECT id FROM servers WHERE account_id = ?)",
|
||||
accountID,
|
||||
).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.Where("account_id = ?", accountID).Delete(&Site{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.Where("account_id = ?", accountID).Delete(&Server{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Private workers scoped to this account (NULL account_id workers
|
||||
// are platform-operated and survive account deletion). The
|
||||
// worker_llms and check_region_results joins both FK to
|
||||
// worker_nodes(id) without ON DELETE CASCADE, so the join rows
|
||||
// must be cleared before the worker rows go away.
|
||||
if err := tx.Exec(
|
||||
"DELETE FROM worker_llms WHERE worker_node_id IN (SELECT id FROM worker_nodes WHERE account_id = ?)",
|
||||
accountID,
|
||||
).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.Exec(
|
||||
"DELETE FROM check_region_results WHERE worker_node_id IN (SELECT id FROM worker_nodes WHERE account_id = ?)",
|
||||
accountID,
|
||||
).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.Where("account_id = ?", accountID).Delete(&WorkerNode{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// The account itself
|
||||
return tx.Delete(&Account{}, accountID).Error
|
||||
}
|
||||
|
||||
// ProcessPendingDeletions hard-deletes users whose 7-day grace period has
|
||||
// elapsed. Designed to be called from a periodic scheduler.
|
||||
func ProcessPendingDeletions() (int, error) {
|
||||
cutoff := time.Now().Add(-DeletionGracePeriod)
|
||||
users := make([]User, 0)
|
||||
if err := DB().Where("deletion_requested_at IS NOT NULL AND deletion_requested_at < ?", cutoff).
|
||||
Find(&users).Error; err != nil {
|
||||
return 0, err
|
||||
}
|
||||
deleted := 0
|
||||
for i := range users {
|
||||
if err := HardDeleteUser(users[i].ID); err != nil {
|
||||
log.Printf("ProcessPendingDeletions: failed to delete user %d: %v", users[i].ID, err)
|
||||
continue
|
||||
}
|
||||
deleted++
|
||||
}
|
||||
if deleted > 0 {
|
||||
log.Printf("ProcessPendingDeletions: hard-deleted %d user(s)", deleted)
|
||||
}
|
||||
return deleted, nil
|
||||
}
|
||||
|
||||
// StaleAccountInactivity is the minimum inactivity window before a stale
|
||||
// account becomes eligible for admin cleanup. Picked at 3 months per the
|
||||
// /admin/accounts "Удалить старые" button.
|
||||
const StaleAccountInactivity = 90 * 24 * time.Hour
|
||||
|
||||
// StaleAccountCandidate describes an account that matched the
|
||||
// admin-cleanup eligibility filter but has not yet been deleted. The
|
||||
// snapshot is what the frontend shows in the confirmation dialog.
|
||||
type StaleAccountCandidate struct {
|
||||
AccountID int64 `json:"account_id"`
|
||||
AccountName string `json:"account_name"`
|
||||
UserID int64 `json:"user_id"`
|
||||
UserEmail *string `json:"user_email"`
|
||||
LastActiveAt *time.Time `json:"last_active_at"`
|
||||
}
|
||||
|
||||
// FindStaleAccounts returns the accounts that are eligible for the
|
||||
// admin "Удалить старые" cleanup:
|
||||
//
|
||||
// - the account has zero monitors configured (via group.account_id)
|
||||
// - every user with access to the account has exactly one account
|
||||
// membership total (so removing the account also orphans them)
|
||||
// - every such user's last_active_at is older than
|
||||
// StaleAccountInactivity (3 months). Users who have never logged in
|
||||
// (last_active_at IS NULL) are also eligible.
|
||||
func FindStaleAccounts() ([]StaleAccountCandidate, error) {
|
||||
cutoff := time.Now().Add(-StaleAccountInactivity)
|
||||
|
||||
// Step 1: account IDs that have zero monitors.
|
||||
accountsWithMonitors := make([]int64, 0)
|
||||
if err := DB().
|
||||
Table("monitors").
|
||||
Select("DISTINCT groups.account_id").
|
||||
Joins("JOIN groups ON groups.id = monitors.group_id").
|
||||
Scan(&accountsWithMonitors).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
accounts := make([]Account, 0)
|
||||
q := DB().Order("id ASC")
|
||||
if len(accountsWithMonitors) > 0 {
|
||||
q = q.Where("id NOT IN (?)", accountsWithMonitors)
|
||||
}
|
||||
if err := q.Find(&accounts).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
out := make([]StaleAccountCandidate, 0, len(accounts))
|
||||
for i := range accounts {
|
||||
acc := accounts[i]
|
||||
|
||||
// Step 2: every user with access to this account must have
|
||||
// exactly one account membership total.
|
||||
type userAccessCount struct {
|
||||
UserID int64
|
||||
Cnt int
|
||||
}
|
||||
counts := make([]userAccessCount, 0)
|
||||
err := DB().
|
||||
Table("accesses AS a1").
|
||||
Select("a1.user_id AS user_id, (SELECT COUNT(*) FROM accesses AS a2 WHERE a2.user_id = a1.user_id) AS cnt").
|
||||
Where("a1.account_id = ? AND a1.user_id IS NOT NULL", acc.ID).
|
||||
Group("a1.user_id").
|
||||
Scan(&counts).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(counts) == 0 {
|
||||
// An account with no user accesses is a config bug
|
||||
// (the owner access should always exist). Skip.
|
||||
continue
|
||||
}
|
||||
allSingle := true
|
||||
for _, c := range counts {
|
||||
if c.Cnt != 1 {
|
||||
allSingle = false
|
||||
break
|
||||
}
|
||||
}
|
||||
if !allSingle {
|
||||
continue
|
||||
}
|
||||
|
||||
// Step 3: every such user must be inactive beyond the cutoff.
|
||||
userIDs := make([]int64, 0, len(counts))
|
||||
for _, c := range counts {
|
||||
userIDs = append(userIDs, c.UserID)
|
||||
}
|
||||
users := make([]User, 0)
|
||||
if err := DB().Where("id IN (?)", userIDs).Find(&users).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
allStale := true
|
||||
for j := range users {
|
||||
u := users[j]
|
||||
if u.LastActiveAt != nil && u.LastActiveAt.After(cutoff) {
|
||||
allStale = false
|
||||
break
|
||||
}
|
||||
}
|
||||
if !allStale {
|
||||
continue
|
||||
}
|
||||
|
||||
// All gates passed — emit one candidate per user so the UI
|
||||
// can list which specific accounts would be removed.
|
||||
for j := range users {
|
||||
out = append(out, StaleAccountCandidate{
|
||||
AccountID: acc.ID,
|
||||
AccountName: acc.Name,
|
||||
UserID: users[j].ID,
|
||||
UserEmail: users[j].Email,
|
||||
LastActiveAt: users[j].LastActiveAt,
|
||||
})
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// CleanupStaleAccounts hard-deletes every account eligible for the
|
||||
// admin "Удалить старые" sweep. Returns the number of accounts that
|
||||
// were deleted. The matching users (each of whom only belonged to one
|
||||
// account) are deleted by HardDeleteAccount's cascading access cleanup
|
||||
// only if they no longer have any other account — that final teardown
|
||||
// is done here.
|
||||
func CleanupStaleAccounts() (int, error) {
|
||||
candidates, err := FindStaleAccounts()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
accountIDs := make([]int64, 0, len(candidates))
|
||||
seen := make(map[int64]bool, len(candidates))
|
||||
for _, c := range candidates {
|
||||
if !seen[c.AccountID] {
|
||||
seen[c.AccountID] = true
|
||||
accountIDs = append(accountIDs, c.AccountID)
|
||||
}
|
||||
}
|
||||
|
||||
deleted := 0
|
||||
for _, accountID := range accountIDs {
|
||||
if err := HardDeleteAccount(accountID); err != nil {
|
||||
log.Printf("CleanupStaleAccounts: failed to delete account %d: %v", accountID, err)
|
||||
continue
|
||||
}
|
||||
deleted++
|
||||
}
|
||||
|
||||
// Users that no longer have any accesses after the cascade are
|
||||
// clearly orphaned — purge them so the auth layer doesn't keep
|
||||
// dangling rows around. We bypass HardDeleteUser here because the
|
||||
// account-level data (monitors, checks, groups, etc.) was already
|
||||
// removed by the HardDeleteAccount loop above, so only the user
|
||||
// row and the dangling identities / contacts need cleanup.
|
||||
if deleted > 0 {
|
||||
orphans := make([]int64, 0)
|
||||
err := DB().
|
||||
Table("users").
|
||||
Select("users.id").
|
||||
Joins("LEFT JOIN accesses ON accesses.user_id = users.id").
|
||||
Where("accesses.id IS NULL").
|
||||
Pluck("users.id", &orphans).Error
|
||||
if err != nil {
|
||||
log.Printf("CleanupStaleAccounts: orphan user scan failed: %v", err)
|
||||
return deleted, nil
|
||||
}
|
||||
for _, userID := range orphans {
|
||||
tx := DB().Begin()
|
||||
if err := tx.Exec("DELETE FROM identities WHERE user_id = ?", userID).Error; err != nil {
|
||||
tx.Rollback()
|
||||
log.Printf("CleanupStaleAccounts: identities delete failed for user %d: %v", userID, err)
|
||||
continue
|
||||
}
|
||||
if err := tx.Where("user_id = ? AND account_id IS NULL", userID).Delete(&Contact{}).Error; err != nil {
|
||||
tx.Rollback()
|
||||
log.Printf("CleanupStaleAccounts: contacts delete failed for user %d: %v", userID, err)
|
||||
continue
|
||||
}
|
||||
if err := tx.Where("user_id = ?", userID).Delete(&ApiKey{}).Error; err != nil {
|
||||
tx.Rollback()
|
||||
log.Printf("CleanupStaleAccounts: api_keys delete failed for user %d: %v", userID, err)
|
||||
continue
|
||||
}
|
||||
if err := tx.Delete(&User{}, userID).Error; err != nil {
|
||||
tx.Rollback()
|
||||
log.Printf("CleanupStaleAccounts: user delete failed for %d: %v", userID, err)
|
||||
continue
|
||||
}
|
||||
if err := tx.Commit().Error; err != nil {
|
||||
log.Printf("CleanupStaleAccounts: commit failed for user %d: %v", userID, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if deleted > 0 {
|
||||
log.Printf("CleanupStaleAccounts: hard-deleted %d stale account(s)", deleted)
|
||||
}
|
||||
return deleted, nil
|
||||
}
|
||||
Ссылка в новой задаче
Block a user