Files
ttstd-infinite-canvas/backend/internal/app/resource_deletion_worker.go
ddcat666 b59317415d feat(platform): 日志管理 - 统一日志出口并屏蔽查询与 Agent 流式事件日志
新增 internal/logging,slog、标准库 log、GORM 与 Gin 访问日志共用同一 Handler,由 CANVAS_LOG_LEVEL / CANVAS_LOG_FORMAT 控制。info 级别不再输出成功的查询类请求、成功 SQL、message_end 等 Agent 流式事件和常规画布自动保存;4xx/5xx、慢请求、慢 SQL、SQL 失败与 panic 仍会记录,SQL 不内联参数值。
2026-09-30 03:33:34 +08:00

201 lines
6.1 KiB
Go

package app
import (
"context"
"log"
"log/slog"
"math"
"strings"
"time"
"infinite-canvas/backend/internal/model"
)
const resourceDeletionLease = 2 * time.Minute
const incompleteResourceRetention = time.Hour
const detachedReadyResourceRetention = 24 * time.Hour
func (s *Service) startResourceDeletionWorker(ctx context.Context) {
s.runWorkerLoop(func(ctx context.Context) {
s.drainResourceDeletionJobs(32)
s.cleanupStaleAnnouncementImageDrafts()
s.cleanupExpiredArchivedAssets()
s.cleanupDetachedResources()
ticker := time.NewTicker(15 * time.Second)
defer ticker.Stop()
lastPeriodicCleanup := time.Now()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
s.drainResourceDeletionJobs(32)
if time.Since(lastPeriodicCleanup) >= time.Hour {
s.cleanupStaleAnnouncementImageDrafts()
s.cleanupExpiredArchivedAssets()
s.cleanupDetachedResources()
lastPeriodicCleanup = time.Now()
}
}
}
})
}
func (s *Service) cleanupDetachedResources() {
now := time.Now()
candidates, err := s.repo.ResourceCleanupCandidates(
now.Add(-incompleteResourceRetention),
now.Add(-detachedReadyResourceRetention),
500,
)
if err != nil {
log.Printf("detached resource cleanup query failed: %v", err)
return
}
byUser := map[string][]model.Resource{}
for _, resource := range candidates {
byUser[resource.UserID] = append(byUser[resource.UserID], resource)
}
for userID, resources := range byUser {
if err := s.cleanupDetachedUserResources(userID, resources); err != nil {
log.Printf("detached resource cleanup failed for user %s: %v", userID, err)
}
}
}
func (s *Service) cleanupDetachedUserResources(userID string, candidates []model.Resource) error {
s.storageMu.Lock()
defer s.storageMu.Unlock()
resourceIDs := make([]string, 0, len(candidates))
candidateSet := make(map[string]struct{}, len(candidates))
for _, resource := range candidates {
resourceIDs = append(resourceIDs, resource.ID)
candidateSet[resource.ID] = struct{}{}
}
snapshot, err := s.repo.ResourceReferenceSnapshot(userID, "", resourceIDs)
if err != nil {
return err
}
referenced := map[string]struct{}{}
// Site assets are global references and are not part of a user's canvas snapshot.
for resourceID := range s.appearanceResourceReferences(resourceIDs) {
referenced[resourceID] = struct{}{}
}
for _, reference := range snapshot.Direct {
if _, exists := candidateSet[reference.ResourceID]; exists {
referenced[reference.ResourceID] = struct{}{}
}
}
for _, document := range snapshot.Documents {
for resourceID := range documentReferencedResourceIDs(document.PrimaryJSON, candidateSet) {
referenced[resourceID] = struct{}{}
}
for resourceID := range documentReferencedResourceIDs(document.SecondaryJSON, candidateSet) {
referenced[resourceID] = struct{}{}
}
}
detached := make([]model.Resource, 0, len(candidates))
for _, resource := range candidates {
if _, exists := referenced[resource.ID]; !exists {
detached = append(detached, resource)
}
}
if len(detached) == 0 {
return nil
}
detachedIDs := make([]string, 0, len(detached))
for _, resource := range detached {
detachedIDs = append(detachedIDs, resource.ID)
}
physicalObjects := map[string]*model.Resource{}
for index := range detached {
resource := &detached[index]
if strings.TrimSpace(resource.ObjectKey) == "" {
continue
}
sharedCount, countErr := s.repo.ResourceStorageReferenceCount(resource, detachedIDs)
if countErr != nil {
return countErr
}
if sharedCount == 0 {
physicalObjects[resourceStorageIdentity(resource)] = resource
}
}
deletionJobs := resourceDeletionJobs(userID, physicalObjects)
if err := s.repo.DeleteDetachedResources(detached, deletionJobs); err != nil {
return err
}
slog.Info("detached resource cleanup", "removed", len(detached), "user", userID)
if len(deletionJobs) > 0 {
s.runWorkerTask(func() { s.drainResourceDeletionJobs(len(deletionJobs)) })
}
return nil
}
func (s *Service) cleanupExpiredArchivedAssets() {
policy, err := s.RuntimePolicy()
if err != nil {
return
}
retentionDays := policy.Resource.RecycleBinRetentionDays
if retentionDays <= 0 {
return
}
cutoff := time.Now().Add(-time.Duration(retentionDays) * 24 * time.Hour)
expired, err := s.repo.FindExpiredArchivedAssets(cutoff, 100)
if err != nil {
log.Printf("expired archived assets query failed: %v", err)
return
}
deleted := 0
for _, asset := range expired {
// 自动清理与用户手动删除走同一条强校验路径:先检查业务引用,
// 再以事务 + Outbox 删除资源记录和物理对象,不能从 repository 旁路。
if err := s.DeleteUserAsset(asset.UserID, asset.ID); err != nil {
log.Printf("expired archived asset delete failed for %s: %v", asset.ID, err)
continue
}
deleted++
}
if deleted > 0 {
slog.Info("recycle bin cleanup", "deleted", deleted, "retention_days", retentionDays)
}
}
func (s *Service) drainResourceDeletionJobs(limit int) {
owner := s.workerID
if owner == "" {
owner = newID()
}
for index := 0; index < limit; index++ {
job, err := s.repo.ClaimNextResourceDeletionJob(owner, resourceDeletionLease)
if err != nil {
log.Printf("resource deletion worker claim failed: %v", err)
return
}
if job == nil {
return
}
resource := &model.Resource{
ID: job.ResourceID, UserID: job.UserID, Provider: job.Provider,
Endpoint: job.Endpoint, Bucket: job.Bucket, StorageSettingID: job.StorageSettingID,
ObjectKey: job.ObjectKey,
}
if err := s.deleteStoredResourceObject(job.UserID, resource); err != nil {
delay := resourceDeletionRetryDelay(job.Attempts)
if retryErr := s.repo.RetryResourceDeletionJob(job.ID, owner, err.Error(), time.Now().Add(delay)); retryErr != nil {
log.Printf("resource deletion worker retry update failed for %s: %v", job.ID, retryErr)
}
continue
}
if err := s.repo.CompleteResourceDeletionJob(job.ID, owner); err != nil {
log.Printf("resource deletion worker completion failed for %s: %v", job.ID, err)
}
}
}
func resourceDeletionRetryDelay(attempts int) time.Duration {
exponent := math.Min(float64(max(attempts-1, 0)), 8)
return time.Duration(math.Pow(2, exponent)) * 15 * time.Second
}