376 lines
11 KiB
Go
376 lines
11 KiB
Go
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)
|
|
}
|
|
}
|