Files
ryan 3528323b50 GORM 实体搜索 LIKE 转义收尾:6 站点复用 EscapeLike + 显式 ESCAPE 子句,含 OAuth 用户名冲突误报修复
Result: {"status":"keep","total_issues":8,"eslint_errors":0,"eslint_problems":0,"eslint_warnings":0,"golint_canonicalheader":0,"golint_errname":0,"golint_errorlint":1,"golint_exhaustive":0,"golint_forcetypeassert":0,"golint_gosec":0,"golint_intrange":0,"golint_modernize":3,"golint_nilnil":3,"golint_perfsprint":0,"golint_prealloc":0,"golint_recvcheck":1,"golint_test_testifylint":0,"golint_test_thelper":0,"golint_test_total":0,"golint_test_usetesting":0,"golint_total":8,"golint_usestdlibvars":0,"golint_vetx_total":0,"golint_wastedassign":0,"measure_s":102,"tsc_errors":0,"vitest_failed":0,"vitest_total":126}
2026-08-26 10:49:15 +08:00

282 lines
9.7 KiB
Go

// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package repository
import (
"context"
"strings"
"time"
"gorm.io/gorm"
"gorm.io/gorm/clause"
db "github.com/Rain-kl/Wavelet/internal/infra/persistence"
"github.com/Rain-kl/Wavelet/internal/model"
"github.com/Rain-kl/Wavelet/pkg/util"
)
// UploadListFilter filters paginated upload queries.
type UploadListFilter struct {
UserID uint64
Keyword string
Type string
Extension string
Page int
PageSize int
}
// UploadStorageObject is a distinct active object path with aggregated metadata for migration.
type UploadStorageObject struct {
FilePath string `gorm:"column:file_path"`
FileSize int64 `gorm:"column:file_size"`
MimeType string `gorm:"column:mime_type"`
Hash string `gorm:"column:hash"`
}
// RunInTransaction executes fn inside a database transaction.
// Prefer domain-specific repository methods when the full operation can live in repository.
// Upload package multi-step flows (lock + soft-delete + stats) use this boundary so apps
// do not call db.DB directly.
func RunInTransaction(ctx context.Context, fn func(tx *gorm.DB) error) error {
return db.DB(ctx).Transaction(fn)
}
// ListUploads returns paginated upload records matching the filter.
func ListUploads(ctx context.Context, filter UploadListFilter) (int64, []model.Upload, error) {
query := db.DB(ctx).Model(&model.Upload{}).
Where("status != ?", model.UploadStatusDeleted)
if filter.UserID != 0 {
query = query.Where("user_id = ?", filter.UserID)
}
if filter.Keyword != "" {
query = query.Where("LOWER(file_name) LIKE ? ESCAPE '\\'", "%"+util.EscapeLike(strings.ToLower(filter.Keyword))+"%")
}
if filter.Type != "" {
query = query.Where("type = ?", filter.Type)
}
if filter.Extension != "" {
query = query.Where("extension = ?", strings.ToLower(filter.Extension))
}
var total int64
if err := query.Count(&total).Error; err != nil {
return 0, nil, err
}
var items []model.Upload
offset := (filter.Page - 1) * filter.PageSize
if err := query.Order("created_at DESC").Offset(offset).Limit(filter.PageSize).Find(&items).Error; err != nil {
return 0, nil, err
}
return total, items, nil
}
// GetActiveUploadByID loads a non-deleted upload by ID.
func GetActiveUploadByID(ctx context.Context, id uint64) (model.Upload, error) {
var upload model.Upload
if err := db.DB(ctx).Where("id = ? AND status != ?", id, model.UploadStatusDeleted).First(&upload).Error; err != nil {
return model.Upload{}, err
}
return upload, nil
}
// GetCacheableUploadByID loads a pending or used upload by ID (for metadata cache DB fallback).
func GetCacheableUploadByID(ctx context.Context, id uint64) (model.Upload, error) {
var upload model.Upload
if err := db.DB(ctx).
Where("id = ? AND status IN (?, ?)", id, model.UploadStatusPending, model.UploadStatusUsed).
First(&upload).Error; err != nil {
return model.Upload{}, err
}
return upload, nil
}
// GetUploadByIDForUpdateTx loads and row-locks an upload by ID within an existing transaction.
func GetUploadByIDForUpdateTx(tx *gorm.DB, id uint64) (model.Upload, error) {
var upload model.Upload
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).
Where("id = ?", id).
First(&upload).Error; err != nil {
return model.Upload{}, err
}
return upload, nil
}
// SoftDeleteUpload marks an active upload as deleted and reports whether the row transitioned.
// External modules must use upload.Remove or upload.RemoveOwned; only internal/apps/upload may call this.
func SoftDeleteUpload(ctx context.Context, upload *model.Upload) (int64, error) {
return SoftDeleteUploadTx(db.DB(ctx), upload)
}
// SoftDeleteUploadTx marks an active upload as deleted within an existing transaction.
// RowsAffected is one only for the single successful active-to-deleted transition.
func SoftDeleteUploadTx(tx *gorm.DB, upload *model.Upload) (int64, error) {
result := tx.Model(&model.Upload{}).
Where("id = ? AND status IN ?", upload.ID, []model.UploadStatus{
model.UploadStatusPending,
model.UploadStatusUsed,
}).
Update("status", model.UploadStatusDeleted)
return result.RowsAffected, result.Error
}
// UpdateUpload applies partial field updates to an upload record.
func UpdateUpload(ctx context.Context, upload *model.Upload, updates map[string]any) error {
if len(updates) == 0 {
return nil
}
return db.DB(ctx).Model(upload).Updates(updates).Error
}
// ListDistinctUploadTypes returns all distinct non-empty upload business types.
func ListDistinctUploadTypes(ctx context.Context) ([]string, error) {
var types []string
if err := db.DB(ctx).Model(&model.Upload{}).
Where("type IS NOT NULL AND type != ''").
Distinct().
Pluck("type", &types).Error; err != nil {
return nil, err
}
return types, nil
}
// FindReusableUploadByHash finds an existing upload with the same hash and size.
func FindReusableUploadByHash(ctx context.Context, hash string, size int64) (model.Upload, error) {
var existing model.Upload
err := db.DB(ctx).
Where("hash = ? AND file_size = ? AND status IN (?, ?)", hash, size, model.UploadStatusPending, model.UploadStatusUsed).
First(&existing).Error
return existing, err
}
// CreateUpload persists a new upload record.
// External modules must use upload.Ingest; only internal/apps/upload may call this.
func CreateUpload(ctx context.Context, upload *model.Upload) error {
return CreateUploadTx(db.DB(ctx), upload)
}
// CreateUploadTx persists a new upload record within an existing transaction.
func CreateUploadTx(tx *gorm.DB, upload *model.Upload) error {
return tx.Create(upload).Error
}
// ListUploadsByIDs returns active uploads matching the given IDs.
func ListUploadsByIDs(ctx context.Context, ids []uint64) ([]model.Upload, error) {
var uploads []model.Upload
if err := db.DB(ctx).
Where("id IN ? AND status IN (?, ?)", ids, model.UploadStatusPending, model.UploadStatusUsed).
Find(&uploads).Error; err != nil {
return nil, err
}
return uploads, nil
}
// CountActiveUploads returns the number of non-deleted upload records.
func CountActiveUploads(ctx context.Context) (int64, error) {
var count int64
err := db.DB(ctx).Model(&model.Upload{}).
Where("status != ?", model.UploadStatusDeleted).
Count(&count).Error
return count, err
}
// ListPendingUploadsOlderThan returns pending uploads created before olderThan, after lastID, ordered by id.
func ListPendingUploadsOlderThan(ctx context.Context, lastID uint64, olderThan time.Time, limit int) ([]model.Upload, error) {
var uploads []model.Upload
err := db.DB(ctx).
Where("id > ? AND status = ? AND created_at < ?", lastID, model.UploadStatusPending, olderThan).
Order("id ASC").
Limit(limit).
Find(&uploads).Error
return uploads, err
}
// ListActiveImageUploadsAfterID returns non-deleted image uploads with id greater than lastID.
func ListActiveImageUploadsAfterID(ctx context.Context, lastID uint64, limit int) ([]model.Upload, error) {
var uploads []model.Upload
err := db.DB(ctx).
Where("id > ? AND status != ? AND (LOWER(mime_type) LIKE ? OR LOWER(extension) IN ?)",
lastID,
model.UploadStatusDeleted,
"image/%",
[]string{"jpg", "jpeg", "png", "webp", "gif"},
).
Order("id ASC").
Limit(limit).
Find(&uploads).Error
return uploads, err
}
// CountDistinctActiveFilePaths returns the number of distinct non-deleted upload file paths.
func CountDistinctActiveFilePaths(ctx context.Context) (int64, error) {
var count int64
err := db.DB(ctx).Model(&model.Upload{}).
Where("status != ?", model.UploadStatusDeleted).
Distinct("file_path").
Count(&count).Error
return count, err
}
// ListDistinctActiveStorageObjects returns a page of distinct active file paths ordered by path.
// When afterFilePath is non-empty, only paths strictly greater than it are returned.
func ListDistinctActiveStorageObjects(ctx context.Context, afterFilePath string, limit int) ([]UploadStorageObject, error) {
var objects []UploadStorageObject
query := db.DB(ctx).Model(&model.Upload{}).
Select("file_path, MAX(file_size) AS file_size, MAX(mime_type) AS mime_type, MAX(hash) AS hash").
Where("status != ?", model.UploadStatusDeleted)
if afterFilePath != "" {
query = query.Where("file_path > ?", afterFilePath)
}
err := query.Group("file_path").
Order("file_path ASC").
Limit(limit).
Scan(&objects).Error
return objects, err
}
// UpdateActiveUploadsFilePath rewrites file_path for all non-deleted uploads matching oldPath.
func UpdateActiveUploadsFilePath(ctx context.Context, oldPath, newPath string) error {
return db.DB(ctx).Model(&model.Upload{}).
Where("file_path = ? AND status != ?", oldPath, model.UploadStatusDeleted).
Update("file_path", newPath).Error
}
// MarkActiveUploadsDeletedByFilePath marks all non-deleted uploads with the given path as deleted
// and returns the rows that transitioned (for stats adjustment).
func MarkActiveUploadsDeletedByFilePath(ctx context.Context, filePath string) ([]model.Upload, error) {
var affected []model.Upload
err := db.DB(ctx).Transaction(func(tx *gorm.DB) error {
if err := tx.
Where("file_path = ? AND status != ?", filePath, model.UploadStatusDeleted).
Find(&affected).Error; err != nil {
return err
}
if len(affected) == 0 {
return nil
}
return tx.Model(&model.Upload{}).
Where("file_path = ?", filePath).
Update("status", model.UploadStatusDeleted).Error
})
if err != nil {
return nil, err
}
return affected, nil
}
// ListActiveUploadsTx returns all non-deleted uploads within an existing transaction.
func ListActiveUploadsTx(tx *gorm.DB) ([]model.Upload, error) {
var uploads []model.Upload
if err := tx.Where("status != ?", model.UploadStatusDeleted).Find(&uploads).Error; err != nil {
return nil, err
}
return uploads, nil
}
// UploadQuery returns a scoped GORM query for uploads.
func UploadQuery(ctx context.Context) *gorm.DB {
return db.DB(ctx).Model(&model.Upload{})
}