package service import ( "context" "encoding/json" "fmt" "log" "strings" "gorm.io/gorm" "gorm.io/gorm/clause" "oci-portal/internal/model" ) type tenantDeleteResult struct { config model.OciConfig deletedTaskIDs []uint channelsGone bool } type tenantTaskAction struct { task model.Task deleteTask bool payload string } type tenancyCacheInvalidator interface { InvalidateTenancy(tenancyOCID string) } func (s *OciConfigService) deleteTenant(ctx context.Context, id uint) (*tenantDeleteResult, error) { result := &tenantDeleteResult{} err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { return s.deleteTenantInTx(tx, id, result) }) if err != nil { return nil, fmt.Errorf("delete oci config %d: %w", id, err) } return result, nil } func (s *OciConfigService) deleteTenantInTx(tx *gorm.DB, id uint, result *tenantDeleteResult) error { if err := lockTenant(tx, id, &result.config); err != nil { return err } if err := s.deleteTenantTasks(tx, id, result); err != nil { return err } if err := deleteTenantEvents(tx, id); err != nil { return err } if err := deleteTenantAI(tx, id, result); err != nil { return err } if err := deleteTenantSnapshots(tx, id); err != nil { return err } return deleteTenantConfig(tx, id) } func lockTenant(tx *gorm.DB, id uint, cfg *model.OciConfig) error { err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(cfg, id).Error if err != nil { return fmt.Errorf("load tenant: %w", err) } return nil } func (s *OciConfigService) deleteTenantTasks(tx *gorm.DB, id uint, result *tenantDeleteResult) error { var tasks []model.Task err := tx.Where("type IN ?", []string{ model.TaskTypeSnatch, model.TaskTypeHealthCheck, model.TaskTypeCost, }).Find(&tasks).Error if err != nil { return fmt.Errorf("load tenant tasks: %w", err) } for i := range tasks { action, ok, err := planTenantTask(tasks[i], id) if err != nil { return err } if !ok { continue } if err := applyTenantTask(tx, action, result); err != nil { return err } } return nil } func planTenantTask(task model.Task, id uint) (tenantTaskAction, bool, error) { switch task.Type { case model.TaskTypeSnatch: return planSnatchTask(task, id) case model.TaskTypeHealthCheck, model.TaskTypeCost: return planMultiTenantTask(task, id) default: return tenantTaskAction{}, false, nil } } func planSnatchTask(task model.Task, id uint) (tenantTaskAction, bool, error) { var payload snatchPayload if err := json.Unmarshal([]byte(task.Payload), &payload); err != nil { logSkippedTask(task, err) return tenantTaskAction{}, false, nil } if payload.OciConfigID != id { return tenantTaskAction{}, false, nil } return tenantTaskAction{task: task, deleteTask: true}, true, nil } // logSkippedTask 记录 payload 无法解析而被跳过的任务:坏数据无法归属租户, // fail-closed 会永久阻断删除且只能手工修库;改为保留原任务(不删不改)并放行删除。 func logSkippedTask(task model.Task, err error) { log.Printf("tenant delete: 任务 %d(%s)payload 无法解析,跳过处理: %v", task.ID, task.Type, err) } func planMultiTenantTask(task model.Task, id uint) (tenantTaskAction, bool, error) { payload, err := decodeConfigIDs(task) if err != nil { logSkippedTask(task, err) return tenantTaskAction{}, false, nil } if len(payload.OciConfigIDs) == 0 { return tenantTaskAction{task: task, payload: task.Payload}, true, nil } remaining, found := removeConfigID(payload.OciConfigIDs, id) if !found { return tenantTaskAction{}, false, nil } if len(remaining) == 0 { return tenantTaskAction{task: task, deleteTask: true}, true, nil } payload.OciConfigIDs = remaining raw, err := json.Marshal(payload) if err != nil { return tenantTaskAction{}, false, fmt.Errorf("encode task %d payload: %w", task.ID, err) } return tenantTaskAction{task: task, payload: string(raw)}, true, nil } func decodeConfigIDs(task model.Task) (healthCheckPayload, error) { var payload healthCheckPayload if strings.TrimSpace(task.Payload) == "" { return payload, nil } if err := json.Unmarshal([]byte(task.Payload), &payload); err != nil { return payload, fmt.Errorf("parse task %d payload: %w", task.ID, err) } return payload, nil } func removeConfigID(ids []uint, id uint) ([]uint, bool) { remaining := make([]uint, 0, len(ids)) found := false for _, candidate := range ids { if candidate == id { found = true continue } remaining = append(remaining, candidate) } return remaining, found } func applyTenantTask(tx *gorm.DB, action tenantTaskAction, result *tenantDeleteResult) error { if err := deleteTaskHistory(tx, action.task.ID); err != nil { return err } if action.deleteTask { if err := tx.Delete(&model.Task{}, action.task.ID).Error; err != nil { return fmt.Errorf("delete task %d: %w", action.task.ID, err) } result.deletedTaskIDs = append(result.deletedTaskIDs, action.task.ID) return nil } return resetTenantTask(tx, action) } func deleteTaskHistory(tx *gorm.DB, taskID uint) error { if err := tx.Where("task_id = ?", taskID).Delete(&model.TaskLog{}).Error; err != nil { return fmt.Errorf("delete task %d logs: %w", taskID, err) } key := deadAliasKey(taskID) if err := tx.Where("key = ?", key).Delete(&model.Setting{}).Error; err != nil { return fmt.Errorf("delete task %d state: %w", taskID, err) } return nil } func resetTenantTask(tx *gorm.DB, action tenantTaskAction) error { updates := map[string]any{ "payload": action.payload, "last_run_at": nil, "last_error": "", "run_count": 0, } err := tx.Model(&model.Task{}).Where("id = ?", action.task.ID).Updates(updates).Error if err != nil { return fmt.Errorf("reset task %d: %w", action.task.ID, err) } return nil } func deleteTenantEvents(tx *gorm.DB, id uint) error { if err := lockTenantEventRows(tx, id); err != nil { return fmt.Errorf("load tenant log events: %w", err) } if err := deleteWhere(tx, &model.LogEvent{}, "oci_config_id = ?", id); err != nil { return fmt.Errorf("delete tenant log events: %w", err) } return nil } // lockTenantEventRows 对租户全部日志事件行加 FOR UPDATE 锁(SQLite 忽略, // MySQL/PG 阻塞并发写入);事件可达数万,ID 不回传拼接 SQL,删除用条件语句, // 避免绑定变量上限。 func lockTenantEventRows(tx *gorm.DB, id uint) error { var rows []model.LogEvent return tx.Clauses(clause.Locking{Strength: "UPDATE"}).Select("id"). Where("oci_config_id = ?", id).Order("id").Find(&rows).Error } func deleteTenantAI(tx *gorm.DB, id uint, result *tenantDeleteResult) error { channelIDs, err := lockedAiChannelIDs(tx, id) if err != nil { return fmt.Errorf("load tenant AI channels: %w", err) } if len(channelIDs) == 0 { return nil } if err := lockAiCallRows(tx, channelIDs); err != nil { return fmt.Errorf("load tenant AI calls: %w", err) } if err := deleteTenantAIRows(tx, channelIDs); err != nil { return err } result.channelsGone = true return reconcileAiProbeRows(tx, result) } func lockedAiChannelIDs(tx *gorm.DB, id uint) ([]uint, error) { var rows []model.AiChannel err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Select("id"). Where("oci_config_id = ?", id).Order("id").Find(&rows).Error ids := make([]uint, 0, len(rows)) for _, row := range rows { ids = append(ids, row.ID) } return ids, err } // lockAiCallRows 对租户渠道的全部调用日志行加 FOR UPDATE 锁(SQLite 忽略, // MySQL/PG 阻塞并发写入);调用日志可达数万,ID 不回传拼接 SQL, // 内容日志删除用子查询,避免绑定变量上限。 func lockAiCallRows(tx *gorm.DB, channelIDs []uint) error { var rows []model.AiCallLog return tx.Clauses(clause.Locking{Strength: "UPDATE"}).Select("id"). Where("channel_id IN ?", channelIDs).Order("id").Find(&rows).Error } func deleteTenantAIRows(tx *gorm.DB, channelIDs []uint) error { callIDs := tx.Model(&model.AiCallLog{}).Select("id").Where("channel_id IN ?", channelIDs) if err := deleteWhere(tx, &model.AiContentLog{}, "call_log_id IN (?)", callIDs); err != nil { return fmt.Errorf("delete tenant AI content logs: %w", err) } steps := []struct { name string value any }{ {"AI call logs", &model.AiCallLog{}}, {"AI model cache", &model.AiModelCache{}}, {"AI channels", &model.AiChannel{}}, } for _, step := range steps { if err := deleteAIChannelRows(tx, step.value, channelIDs); err != nil { return fmt.Errorf("delete tenant %s: %w", step.name, err) } } return nil } func deleteAIChannelRows(tx *gorm.DB, value any, channelIDs []uint) error { column := "channel_id" if _, ok := value.(*model.AiChannel); ok { column = "id" } return deleteWhere(tx, value, column+" IN ?", channelIDs) } func reconcileAiProbeRows(tx *gorm.DB, result *tenantDeleteResult) error { var channelCount int64 if err := tx.Model(&model.AiChannel{}).Count(&channelCount).Error; err != nil { return fmt.Errorf("count remaining AI channels: %w", err) } var ids []uint err := tx.Model(&model.Task{}).Where("type = ?", model.TaskTypeAiProbe).Pluck("id", &ids).Error if err != nil { return fmt.Errorf("load AI probe task: %w", err) } for _, id := range ids { if err := deleteTaskHistory(tx, id); err != nil { return err } if channelCount == 0 { if err := tx.Delete(&model.Task{}, id).Error; err != nil { return fmt.Errorf("delete AI probe task %d: %w", id, err) } result.deletedTaskIDs = append(result.deletedTaskIDs, id) continue } if err := resetTaskHistoryFields(tx, id); err != nil { return err } } return nil } func resetTaskHistoryFields(tx *gorm.DB, id uint) error { updates := map[string]any{"last_run_at": nil, "last_error": "", "run_count": 0} if err := tx.Model(&model.Task{}).Where("id = ?", id).Updates(updates).Error; err != nil { return fmt.Errorf("reset task %d history: %w", id, err) } return nil } func deleteTenantSnapshots(tx *gorm.DB, id uint) error { steps := []struct { name string value any }{ {"check snapshots", &model.CheckSnapshot{}}, {"cost snapshots", &model.CostSnapshot{}}, {"region cache", &model.RegionCache{}}, {"compartment cache", &model.CompartmentCache{}}, } for _, step := range steps { if err := deleteWhere(tx, step.value, "oci_config_id = ?", id); err != nil { return fmt.Errorf("delete tenant %s: %w", step.name, err) } } if err := tx.Where("key = ?", secretKey(id)).Delete(&model.Setting{}).Error; err != nil { return fmt.Errorf("delete tenant webhook secret: %w", err) } return nil } func deleteTenantConfig(tx *gorm.DB, id uint) error { res := tx.Delete(&model.OciConfig{}, id) if res.Error != nil { return fmt.Errorf("delete tenant row: %w", res.Error) } if res.RowsAffected == 0 { return gorm.ErrRecordNotFound } return nil } func deleteWhere(tx *gorm.DB, value any, query string, args ...any) error { return tx.Where(query, args...).Delete(value).Error } func (s *OciConfigService) afterTenantDelete(ctx context.Context, result *tenantDeleteResult) { // 数据已提交,客户端断开不应中断必需的运行时对齐。 ctx = context.WithoutCancel(ctx) s.InvalidateAuditCache(result.config.ID) if client, ok := s.client.(tenancyCacheInvalidator); ok { client.InvalidateTenancy(result.config.TenancyOCID) } if s.cleanupTasks != nil { s.cleanupTasks.ApplyTenantCleanup(ctx, result.deletedTaskIDs, result.channelsGone) } }