Files
ttstd-infinite-canvas/backend/internal/repository/repository_storage_resources.go

269 lines
10 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 对象存储配置、存储位置、上传配额与资源(Resource)记录。
//
// 资源的物理删除不在这里做:业务删除写入 resource_deletion_jobs(Outbox),
// 由后台 worker 幂等清理,见 resource_deletion.go。
package repository
import (
"time"
"gorm.io/gorm"
"gorm.io/gorm/clause"
"infinite-canvas/backend/internal/model"
)
func (r *Repository) LatestUserOSSSetting(userID string) (*model.UserOSSSetting, error) {
var setting model.UserOSSSetting
if err := r.db.Where("user_id = ?", userID).Order("created_at desc, id desc").First(&setting).Error; err != nil {
return nil, err
}
return &setting, nil
}
func (r *Repository) UserOSSSettingsForUser(userID string) ([]model.UserOSSSetting, error) {
var settings []model.UserOSSSetting
err := r.db.Where("user_id = ?", userID).Order("created_at desc, id desc").Find(&settings).Error
return settings, err
}
func (r *Repository) UserOSSSettingForUser(userID string, id string) (*model.UserOSSSetting, error) {
var setting model.UserOSSSetting
if err := r.db.First(&setting, "id = ? AND user_id = ?", id, userID).Error; err != nil {
return nil, err
}
return &setting, nil
}
func (r *Repository) CreateUserOSSSetting(setting *model.UserOSSSetting) error {
return r.db.Create(setting).Error
}
func (r *Repository) StorageLocation(id string) (*model.StorageLocation, error) {
var location model.StorageLocation
if err := r.db.First(&location, "id = ?", id).Error; err != nil {
return nil, err
}
return &location, nil
}
func (r *Repository) StorageLocationByDigest(scope string, ownerID string, provider string, digest string) (*model.StorageLocation, error) {
var location model.StorageLocation
if err := r.db.First(&location, "scope = ? AND owner_id = ? AND provider = ? AND location_digest = ?", scope, ownerID, provider, digest).Error; err != nil {
return nil, err
}
return &location, nil
}
func (r *Repository) StorageLocationHistoryCount(scope string, ownerID string) (int64, error) {
var count int64
err := r.db.Model(&model.StorageLocation{}).Where("scope = ? AND owner_id = ?", scope, ownerID).Count(&count).Error
return count, err
}
func (r *Repository) StorageLocationResourceCount(id string) (int64, error) {
var count int64
err := r.db.Model(&model.Resource{}).Where("storage_setting_id = ?", id).Count(&count).Error
return count, err
}
func (r *Repository) CreateStorageLocation(location *model.StorageLocation) error {
return r.db.Create(location).Error
}
func (r *Repository) SaveStorageLocation(location *model.StorageLocation) error {
return r.db.Save(location).Error
}
func (r *Repository) ActivateStorageLocation(scope string, ownerID string, id string, active bool) error {
return r.db.Transaction(func(tx *gorm.DB) error {
if err := tx.Model(&model.StorageLocation{}).Where("scope = ? AND owner_id = ? AND active = ?", scope, ownerID, true).Update("active", false).Error; err != nil {
return err
}
if !active {
return nil
}
result := tx.Model(&model.StorageLocation{}).Where("id = ? AND scope = ? AND owner_id = ?", id, scope, ownerID).Update("active", true)
if result.Error != nil {
return result.Error
}
if result.RowsAffected != 1 {
return gorm.ErrRecordNotFound
}
return nil
})
}
func (r *Repository) ReserveDailyUpload(userID string, day string, size int64, limit int64) error {
usage := model.UserDailyUploadUsage{ID: userID + ":" + day, UserID: userID, Day: day}
return r.db.Transaction(func(tx *gorm.DB) error {
if err := tx.Clauses(clause.OnConflict{DoNothing: true}).Create(&usage).Error; err != nil {
return err
}
result := tx.Model(&model.UserDailyUploadUsage{}).
Where("id = ? AND bytes + ? < ?", usage.ID, size, limit).
Updates(map[string]any{"bytes": gorm.Expr("bytes + ?", size), "updated_at": time.Now()})
if result.Error != nil {
return result.Error
}
if result.RowsAffected == 0 {
return ErrDailyUploadLimitExceeded
}
return nil
})
}
func (r *Repository) ReleaseDailyUpload(userID string, day string, size int64) error {
id := userID + ":" + day
return r.db.Model(&model.UserDailyUploadUsage{}).
Where("id = ?", id).
Updates(map[string]any{
"bytes": gorm.Expr("CASE WHEN bytes >= ? THEN bytes - ? ELSE 0 END", size, size),
"updated_at": time.Now(),
}).Error
}
func (r *Repository) UserStoredFileBytes(userID string) (int64, error) {
var total int64
err := r.db.Raw(`
SELECT
COALESCE((
SELECT SUM(physical_resources.size)
FROM (
SELECT MAX(size) AS size
FROM resources
WHERE user_id = ? AND status = ?
GROUP BY COALESCE(NULLIF(provider, ''), 'local'), endpoint, bucket, object_key
) AS physical_resources
), 0)
`, userID, model.ResourceStatusReady).Scan(&total).Error
return total, err
}
func (r *Repository) DailyUploadBytes(userID string, day string) (int64, error) {
var total int64
err := r.db.Model(&model.UserDailyUploadUsage{}).Select("COALESCE(bytes, 0)").Where("user_id = ? AND day = ?", userID, day).Scan(&total).Error
return total, err
}
func (r *Repository) CreateResource(resource *model.Resource) error {
return r.db.Create(resource).Error
}
func (r *Repository) SaveResource(resource *model.Resource) error {
return r.db.Save(resource).Error
}
// UpdateResourceThumbnail only updates a still-ready row and cannot resurrect a deleted resource.
func (r *Repository) UpdateResourceThumbnail(userID string, id string, values map[string]any) (bool, error) {
result := r.db.Model(&model.Resource{}).
Where("id = ? AND user_id = ? AND status = ?", id, userID, model.ResourceStatusReady).
Updates(values)
return result.RowsAffected == 1, result.Error
}
func (r *Repository) ResourceByUploadKey(userID string, uploadKey string) (*model.Resource, error) {
var resource model.Resource
if err := r.db.First(&resource, "user_id = ? AND upload_key = ?", userID, uploadKey).Error; err != nil {
return nil, err
}
return &resource, nil
}
func (r *Repository) ClaimFailedResourceUpload(userID string, id string) (bool, error) {
result := r.db.Model(&model.Resource{}).
Where("id = ? AND user_id = ? AND status = ?", id, userID, model.ResourceStatusFailed).
Updates(map[string]any{"status": model.ResourceStatusPending, "error": "", "updated_at": time.Now()})
return result.RowsAffected == 1, result.Error
}
func (r *Repository) DeleteResource(userID string, id string) error {
return r.db.Delete(&model.Resource{}, "id = ? AND user_id = ?", id, userID).Error
}
func (r *Repository) Resource(id string) (*model.Resource, error) {
var resource model.Resource
if err := r.db.First(&resource, "id = ?", id).Error; err != nil {
return nil, err
}
return &resource, nil
}
func (r *Repository) ResourceForUser(userID string, id string) (*model.Resource, error) {
var resource model.Resource
if err := r.db.First(&resource, "id = ? AND user_id = ?", id, userID).Error; err != nil {
return nil, err
}
return &resource, nil
}
func (r *Repository) Resources(userID string, limit int) ([]model.Resource, error) {
var resources []model.Resource
if limit <= 0 || limit > 500 {
limit = 200
}
err := r.db.Order("created_at desc").Limit(limit).Find(&resources, "user_id = ?", userID).Error
return resources, err
}
// PlaybackPendingVideos 返回本地存储、就绪但尚无播放副本判定结果的视频
// (H.264 需标记 none、H.265 需触发转码)。
func (r *Repository) PlaybackPendingVideos(limit int) ([]model.Resource, error) {
var resources []model.Resource
if limit <= 0 || limit > 100 {
limit = 20
}
err := r.db.Where("kind = ? AND status = ? AND provider = ? AND (playback_status = ? OR playback_status IS NULL)",
"video", model.ResourceStatusReady, "local", "").Order("created_at asc").Limit(limit).Find(&resources).Error
return resources, err
}
// PlaybackNoneVideos 返回存量本地视频中旧逻辑遗留、停在 none 的行
// (规则变更前 H.265/MPEG-4 Part 2 曾被误判为浏览器可播并落 none)。
// 服务启动回填时对它们重新按 codec 判定,让判定规则变更覆盖规则变更前已导入的文件。
func (r *Repository) PlaybackNoneVideos(limit int) ([]model.Resource, error) {
var resources []model.Resource
if limit <= 0 || limit > 100 {
limit = 20
}
err := r.db.Where("kind = ? AND status = ? AND provider = ? AND playback_status = ?",
"video", model.ResourceStatusReady, "local", model.PlaybackStatusNone).
Order("created_at asc").Limit(limit).Find(&resources).Error
return resources, err
}
// ClaimPlaybackTranscode 原子地把待判定(空/none)视频置为 processing,返回是否抢占成功。
// 多实例或多 goroutine 并发转同一资源时仅一个能成功置位,其余返回 false 直接放弃,
// 避免重复转码同一份文件。
func (r *Repository) ClaimPlaybackTranscode(id string) (bool, error) {
res := r.db.Model(&model.Resource{}).
Where("id = ? AND (playback_status = ? OR playback_status IS NULL OR playback_status = ?)",
id, "", model.PlaybackStatusNone).
Updates(map[string]any{"playback_status": model.PlaybackStatusProcessing, "playback_error": ""})
if res.Error != nil {
return false, res.Error
}
return res.RowsAffected > 0, nil
}
// ResetStuckPlaybackTranscodes 服务重启时把卡在 processing 的转码记录重置回待判定
// (进程崩溃后转码 goroutine 随进程消亡,状态永远停在 processing)。
func (r *Repository) ResetStuckPlaybackTranscodes() error {
return r.db.Model(&model.Resource{}).
Where("playback_status = ?", model.PlaybackStatusProcessing).
Updates(map[string]any{"playback_status": "", "playback_error": ""}).Error
}
func (r *Repository) ResourceCleanupCandidates(incompleteBefore time.Time, readyBefore time.Time, limit int) ([]model.Resource, error) {
var resources []model.Resource
if limit <= 0 || limit > 500 {
limit = 100
}
err := r.db.Where(
"(status IN ? AND updated_at <= ?) OR (status = ? AND created_at <= ?)",
[]model.ResourceStatus{model.ResourceStatusPending, model.ResourceStatusFailed}, incompleteBefore,
model.ResourceStatusReady, readyBefore,
).Order("created_at asc, id asc").Limit(limit).Find(&resources).Error
return resources, err
}