// Package janitor 后台清理循环(安全审计 M5): // 回收过期容量预留、超时未完成的上传会话(含其分片对象)与过期预签名会话 // (direct 模式残留对象一并删除)。此前这些资源仅在同 token 复用/显式取消时 // 释放,恶意 init 可长期占用容量预留或累积垃圾数据。 package janitor import ( "context" "errors" "log" "time" "gorm.io/gorm" "filecodebox/internal/model" "filecodebox/internal/storage" ) // chunkSessionMaxAge 未完成分片会话的最大保留时长(预留 TTL 为 2h, // 会话保留 24h 以支持断点续传;超时后由本循环清理)。 const chunkSessionMaxAge = 24 * time.Hour // presignGrace 过期预签名会话的宽限时长(到点即删,避免与在途 confirm 竞争)。 const presignGrace = time.Hour // Start 启动周期清理循环;ctx 取消时退出。 func Start(ctx context.Context, db *gorm.DB, store *storage.Manager, interval time.Duration) { go func() { ticker := time.NewTicker(interval) defer ticker.Stop() for { select { case <-ctx.Done(): return case <-ticker.C: Run(ctx, db, store) } } }() } // Run 执行一轮清理;单项失败仅记日志,不影响其他项。 func Run(ctx context.Context, db *gorm.DB, store *storage.Manager) { now := time.Now() cleanExpiredReservations(ctx, db, now) cleanExpiredChunkSessions(ctx, db, store, now) cleanExpiredPresignSessions(ctx, db, store, now) } // cleanExpiredReservations 删除全部过期容量预留。 func cleanExpiredReservations(ctx context.Context, db *gorm.DB, now time.Time) { if err := db.WithContext(ctx). Where("expires_at <= ?", now). Delete(&model.StorageReservation{}).Error; err != nil { log.Printf("[janitor] 清理过期容量预留失败: %v", err) } } // engineFor 按归属引擎取回实例;空/未知引擎回落当前引擎(对齐 API 层 storeFor 语义)。 func engineFor(store *storage.Manager, name string) (storage.Storage, error) { if name != "" && storage.ValidEngine(name) { if s, err := store.EngineOf(name); err == nil { return s, nil } } return store.Current(), nil } // cleanExpiredChunkSessions 清理超时未完成的分片会话及其分片对象。 func cleanExpiredChunkSessions(ctx context.Context, db *gorm.DB, store *storage.Manager, now time.Time) { var sessions []model.UploadChunk if err := db.WithContext(ctx). Where("chunk_index = -1 AND created_at < ?", now.Add(-chunkSessionMaxAge)). Limit(200). Find(&sessions).Error; err != nil { log.Printf("[janitor] 查询过期分片会话失败: %v", err) return } for _, s := range sessions { engine, err := engineFor(store, s.Engine) if err == nil && s.SavePath != "" { if err := engine.CleanChunks(ctx, s.UploadID, s.SavePath); err != nil && !errors.Is(err, storage.ErrNotFound) && !errors.Is(err, storage.ErrInvalidPath) { log.Printf("[janitor] 清理分片对象失败 upload_id=%s: %v", s.UploadID, err) } } if err := db.WithContext(ctx). Where("upload_id = ?", s.UploadID). Delete(&model.UploadChunk{}).Error; err != nil { log.Printf("[janitor] 删除过期分片会话失败 upload_id=%s: %v", s.UploadID, err) continue } log.Printf("[janitor] 已清理超时分片会话 upload_id=%s file=%s", s.UploadID, s.FileName) } } // cleanExpiredPresignSessions 清理过期预签名会话;direct 模式残留对象一并删除。 func cleanExpiredPresignSessions(ctx context.Context, db *gorm.DB, store *storage.Manager, now time.Time) { var sessions []model.PresignUploadSession if err := db.WithContext(ctx). Where("expires_at < ?", now.Add(-presignGrace)). Limit(200). Find(&sessions).Error; err != nil { log.Printf("[janitor] 查询过期预签名会话失败: %v", err) return } for _, s := range sessions { if s.Mode == "direct" && s.SavePath != "" { if engine, err := engineFor(store, s.Engine); err == nil { if err := engine.DeleteFile(ctx, s.SavePath); err != nil && !errors.Is(err, storage.ErrNotFound) && !errors.Is(err, storage.ErrInvalidPath) { log.Printf("[janitor] 删除直传残留对象失败 upload_id=%s: %v", s.UploadID, err) } } } if err := db.WithContext(ctx). Where("upload_id = ?", s.UploadID). Delete(&model.PresignUploadSession{}).Error; err != nil { log.Printf("[janitor] 删除过期预签名会话失败 upload_id=%s: %v", s.UploadID, err) continue } log.Printf("[janitor] 已清理过期预签名会话 upload_id=%s mode=%s", s.UploadID, s.Mode) } }