fix(pages): 收紧部署包与 Agent 同步边界

完成 V2 Phase 0 安全与一致性前置:统一真实归档限额、流式拉取、候选裁剪、保留上传删除语义及 Pages 路由引用锁。
This commit is contained in:
deqiying
2026-07-19 16:42:45 +08:00
parent f386674464
commit 4e8ec23264
47 changed files with 4005 additions and 759 deletions
+3
View File
@@ -12,5 +12,8 @@ import (
// ErrForbidden indicates the caller is not allowed to mutate the upload record.
var ErrForbidden = errors.New("upload forbidden")
// ErrReservedUploadType indicates that a generic mutation targeted a domain-reserved upload type.
var ErrReservedUploadType = errors.New(shared.ErrReservedUploadType)
// ErrStorageReadOnly indicates the storage backend is in migration read-only mode.
var ErrStorageReadOnly = errors.New(shared.ErrStorageReadOnly)
+18 -9
View File
@@ -100,13 +100,10 @@ func storeObject(ctx context.Context, objectKey string, reader io.Reader, size i
return result.Key, nil
}
func persistUploadRecord(ctx context.Context, upload *model.Upload, objectKey string) error {
func persistUploadRecord(ctx context.Context, upload *model.Upload, objectKey string, storedByRequest bool) error {
if err := createUploadWithStats(ctx, upload); err != nil {
_, backend, backendErr := storage.Active(ctx)
if backendErr == nil {
if deleteErr := backend.Delete(ctx, objectKey); deleteErr != nil {
logger.WarnF(ctx, "清理未写入数据库的上传对象失败: %v", deleteErr)
}
if storedByRequest {
cleanupUnpersistedObject(ctx, objectKey)
}
return err
}
@@ -114,6 +111,16 @@ func persistUploadRecord(ctx context.Context, upload *model.Upload, objectKey st
return nil
}
func cleanupUnpersistedObject(ctx context.Context, objectKey string) {
_, backend, err := storage.Active(ctx)
if err != nil {
return
}
if err := backend.Delete(ctx, objectKey); err != nil {
logger.WarnF(ctx, "清理未写入数据库的上传对象失败: %v", err)
}
}
func createUploadWithStats(ctx context.Context, upload *model.Upload) error {
return db.DB(ctx).Transaction(func(tx *gorm.DB) error {
if err := repository.CreateUploadTx(tx, upload); err != nil {
@@ -125,6 +132,8 @@ func createUploadWithStats(ctx context.Context, upload *model.Upload) error {
func createDedupRecord(ctx context.Context, existing model.Upload, req Request) (Result, error) {
accessMode := resolveAccessMode(req.Type, req.AccessMode)
metadata := req.Metadata
metadata.Bucket = existing.Metadata.Bucket
newUpload := model.Upload{
ID: idgen.NextUint64ID(),
UserID: req.UserID,
@@ -137,9 +146,9 @@ func createDedupRecord(ctx context.Context, existing model.Upload, req Request)
Type: req.Type,
Status: req.Status,
AccessMode: accessMode,
Metadata: existing.Metadata,
Metadata: metadata,
}
if err := persistUploadRecord(ctx, &newUpload, existing.FilePath); err != nil {
if err := persistUploadRecord(ctx, &newUpload, existing.FilePath, false); err != nil {
return Result{}, err
}
logger.InfoF(ctx, "文件触发秒传成功! ID: %d, Path: %s", newUpload.ID, existing.FilePath)
@@ -186,7 +195,7 @@ func createNewUpload(ctx context.Context, req Request) (Result, error) {
AccessMode: accessMode,
Metadata: req.Metadata,
}
if err := persistUploadRecord(ctx, &upload, storedKey); err != nil {
if err := persistUploadRecord(ctx, &upload, storedKey, true); err != nil {
return Result{}, err
}
+233 -2
View File
@@ -8,15 +8,20 @@ import (
"context"
"crypto/sha256"
"encoding/hex"
"errors"
"io"
"os"
"sync"
"testing"
"time"
uploadcache "github.com/Rain-kl/Wavelet/internal/apps/upload/cache"
"github.com/Rain-kl/Wavelet/internal/apps/upload/shared"
"github.com/Rain-kl/Wavelet/internal/db"
"github.com/Rain-kl/Wavelet/internal/model"
"github.com/Rain-kl/Wavelet/internal/storage"
"github.com/Rain-kl/Wavelet/internal/testhelper"
"gorm.io/gorm"
)
func TestIngestPolicyCreateIncrementsStats(t *testing.T) {
@@ -141,7 +146,11 @@ func TestIngestPolicyDedupNewRecordCreatesSecondRecord(t *testing.T) {
Extension: "png",
Hash: hashStr,
Type: "avatar",
Policy: PolicyDedupNewRecord,
Metadata: model.UploadMetadata{
UserAgent: "first-agent",
Extra: map[string]any{"record": "first"},
},
Policy: PolicyDedupNewRecord,
})
if err != nil {
t.Fatalf("first Ingest returned error: %v", err)
@@ -149,6 +158,10 @@ func TestIngestPolicyDedupNewRecordCreatesSecondRecord(t *testing.T) {
if putCount != 1 {
t.Fatalf("putCount after first ingest = %d, want 1", putCount)
}
first.Upload.Metadata.Bucket = "shared-bucket"
if err := dbConn.Save(&first.Upload).Error; err != nil {
t.Fatalf("update first upload metadata failed: %v", err)
}
second, err := Ingest(ctx, Request{
UserID: 1002,
@@ -159,7 +172,12 @@ func TestIngestPolicyDedupNewRecordCreatesSecondRecord(t *testing.T) {
Extension: "png",
Hash: hashStr,
Type: "avatar",
Policy: PolicyDedupNewRecord,
Metadata: model.UploadMetadata{
UserAgent: "second-agent",
Bucket: "caller-bucket-must-not-survive",
Extra: map[string]any{"record": "second"},
},
Policy: PolicyDedupNewRecord,
})
if err != nil {
t.Fatalf("second Ingest returned error: %v", err)
@@ -173,6 +191,15 @@ func TestIngestPolicyDedupNewRecordCreatesSecondRecord(t *testing.T) {
if first.Upload.ID == second.Upload.ID {
t.Fatal("dedup records should have unique IDs")
}
if second.Upload.Metadata.Bucket != "shared-bucket" {
t.Fatalf("dedup bucket = %q, want inherited shared-bucket", second.Upload.Metadata.Bucket)
}
if second.Upload.Metadata.UserAgent != "second-agent" {
t.Fatalf("dedup user agent = %q, want caller metadata", second.Upload.Metadata.UserAgent)
}
if second.Upload.Metadata.Extra["record"] != "second" {
t.Fatalf("dedup extra metadata = %#v, want caller metadata", second.Upload.Metadata.Extra)
}
var count int64
if err := dbConn.Model(&model.Upload{}).Where("hash = ?", hashStr).Count(&count).Error; err != nil {
@@ -183,6 +210,77 @@ func TestIngestPolicyDedupNewRecordCreatesSecondRecord(t *testing.T) {
}
}
func TestDedupRecordFailureDoesNotDeleteSharedObject(t *testing.T) {
dbConn, _, cleanup := testhelper.SetupTestEnvironment(t)
defer cleanup()
ctx := context.Background()
content := []byte("\x89PNG\r\n\x1a\nshared-object")
hash := sha256.Sum256(content)
hashStr := hex.EncodeToString(hash[:])
deleteCount := 0
restoreStorage, disableStorage := setupMockStorageWithDeleteCount(t, nil, &deleteCount)
defer restoreStorage()
defer disableStorage()
first, err := Ingest(ctx, Request{
UserID: 1001,
Reader: bytes.NewReader(content),
Size: int64(len(content)),
FileName: "shared.png",
MimeType: "image/png",
Extension: "png",
Hash: hashStr,
Type: "avatar",
Policy: PolicyDedupNewRecord,
})
if err != nil {
t.Fatalf("first Ingest returned error: %v", err)
}
const callbackName = "test:reject_dedup_upload_record"
if err := dbConn.Callback().Create().Before("gorm:create").Register(callbackName, func(tx *gorm.DB) {
upload, ok := tx.Statement.Dest.(*model.Upload)
if ok && upload.FileName == "dedup-fail.png" {
tx.AddError(errors.New("injected upload create failure"))
}
}); err != nil {
t.Fatalf("register create failure callback: %v", err)
}
defer func() { _ = dbConn.Callback().Create().Remove(callbackName) }()
_, err = Ingest(ctx, Request{
UserID: 1002,
Reader: bytes.NewReader(content),
Size: int64(len(content)),
FileName: "dedup-fail.png",
MimeType: "image/png",
Extension: "png",
Hash: hashStr,
Type: "avatar",
Metadata: model.UploadMetadata{
Extra: map[string]any{"record": "dedup-failure"},
},
Policy: PolicyDedupNewRecord,
})
if err == nil {
t.Fatal("dedup Ingest expected injected persistence error")
}
if deleteCount != 0 {
t.Fatalf("shared object delete count = %d, want 0", deleteCount)
}
_, backend, err := storage.Active(ctx)
if err != nil {
t.Fatalf("load active storage: %v", err)
}
obj, err := backend.Get(ctx, first.Upload.FilePath)
if err != nil {
t.Fatalf("shared object became unreadable after dedup failure: %v", err)
}
_ = obj.Body.Close()
}
func TestCreateUploadWithStatsRollsBackOnCreateFailure(t *testing.T) {
dbConn, _, cleanup := testhelper.SetupTestEnvironment(t)
defer cleanup()
@@ -259,6 +357,18 @@ func TestRemoveDecrementsStats(t *testing.T) {
if _, err := Remove(ctx, result.Upload.ID); err != nil {
t.Fatalf("Remove(%d) returned error: %v", result.Upload.ID, err)
}
stale := result.Upload
uploadcache.SetUploadMetaCache(ctx, &stale)
removedAgain, err := Remove(ctx, result.Upload.ID)
if err != nil {
t.Fatalf("second Remove(%d) returned error: %v", result.Upload.ID, err)
}
if removedAgain.Status != model.UploadStatusDeleted {
t.Fatalf("second Remove status = %s, want deleted", removedAgain.Status)
}
if _, err := uploadcache.GetUploadByID(ctx, result.Upload.ID); !errors.Is(err, gorm.ErrRecordNotFound) {
t.Fatalf("cache lookup after idempotent Remove error = %v, want record not found", err)
}
stats, err := loadTotalStats(ctx)
if err != nil {
@@ -269,6 +379,120 @@ func TestRemoveDecrementsStats(t *testing.T) {
}
}
func TestConcurrentRemoveDecrementsStatsOnce(t *testing.T) {
_, _, cleanup := testhelper.SetupTestEnvironment(t)
defer cleanup()
ctx := context.Background()
content := []byte("\x89PNG\r\n\x1a\nconcurrent-remove")
hash := sha256.Sum256(content)
restoreStorage, disableStorage := setupMockStorage(t, nil)
defer restoreStorage()
defer disableStorage()
result, err := Ingest(ctx, Request{
UserID: 1001,
Reader: bytes.NewReader(content),
Size: int64(len(content)),
FileName: "concurrent.png",
MimeType: "image/png",
Extension: "png",
Hash: hex.EncodeToString(hash[:]),
Type: "generic",
Policy: PolicyCreate,
})
if err != nil {
t.Fatalf("Ingest returned error: %v", err)
}
const workers = 8
start := make(chan struct{})
errs := make(chan error, workers)
var wg sync.WaitGroup
for i := 0; i < workers; i++ {
wg.Add(1)
go func() {
defer wg.Done()
<-start
_, removeErr := Remove(ctx, result.Upload.ID)
errs <- removeErr
}()
}
close(start)
wg.Wait()
close(errs)
for removeErr := range errs {
if removeErr != nil {
t.Fatalf("concurrent Remove returned error: %v", removeErr)
}
}
stats, err := loadTotalStats(ctx)
if err != nil {
t.Fatalf("loadTotalStats returned error: %v", err)
}
if stats.TotalCount != 0 || stats.TotalSize != 0 {
t.Fatalf("stats after concurrent remove = count %d size %d, want zero", stats.TotalCount, stats.TotalSize)
}
}
func TestRemoveOwnedAndReservedTypeBoundaries(t *testing.T) {
dbConn, _, cleanup := testhelper.SetupTestEnvironment(t)
defer cleanup()
ctx := context.Background()
ordinary := model.Upload{
ID: 99101,
UserID: 1001,
FileName: "owned.txt",
FilePath: "uploads/owned.txt",
FileSize: 16,
MimeType: "text/plain",
Extension: "txt",
Hash: "owned-hash",
Type: "generic",
Status: model.UploadStatusUsed,
CreatedAt: time.Now(),
}
reserved := model.Upload{
ID: 99102,
UserID: 1001,
FileName: "pages.zip",
FilePath: "uploads/pages.zip",
FileSize: 32,
MimeType: "application/zip",
Extension: "zip",
Hash: "reserved-hash",
Type: shared.ReservedPagesDeploymentType,
Status: model.UploadStatusUsed,
CreatedAt: time.Now(),
}
if err := dbConn.Create(&ordinary).Error; err != nil {
t.Fatalf("seed ordinary upload: %v", err)
}
if err := dbConn.Create(&reserved).Error; err != nil {
t.Fatalf("seed reserved upload: %v", err)
}
if _, err := RemoveOwned(ctx, 2002, ordinary.ID); !errors.Is(err, ErrForbidden) {
t.Fatalf("RemoveOwned non-owner error = %v, want ErrForbidden", err)
}
if _, err := Remove(ctx, reserved.ID); !errors.Is(err, ErrReservedUploadType) {
t.Fatalf("Remove reserved error = %v, want ErrReservedUploadType", err)
}
if _, err := RemoveOwned(ctx, reserved.UserID, reserved.ID); !errors.Is(err, ErrReservedUploadType) {
t.Fatalf("RemoveOwned reserved error = %v, want ErrReservedUploadType", err)
}
var persisted model.Upload
if err := dbConn.First(&persisted, reserved.ID).Error; err != nil {
t.Fatalf("reload reserved upload: %v", err)
}
if persisted.Status != model.UploadStatusUsed {
t.Fatalf("reserved upload status = %s, want used", persisted.Status)
}
}
type totalStatsSnapshot struct {
TotalCount int64
TotalSize int64
@@ -289,6 +513,10 @@ func loadTotalStats(ctx context.Context) (totalStatsSnapshot, error) {
}
func setupMockStorage(t *testing.T, putCount *int) (restore func(), disable func()) {
return setupMockStorageWithDeleteCount(t, putCount, nil)
}
func setupMockStorageWithDeleteCount(t *testing.T, putCount, deleteCount *int) (restore func(), disable func()) {
t.Helper()
mockFiles := make(map[string][]byte)
restore = storage.MockStorage(
@@ -316,6 +544,9 @@ func setupMockStorage(t *testing.T, putCount *int) (restore func(), disable func
},
func(ctx context.Context, key string) error {
delete(mockFiles, key)
if deleteCount != nil {
*deleteCount++
}
return nil
},
)
+46 -22
View File
@@ -7,52 +7,76 @@ import (
"context"
uploadcache "github.com/Rain-kl/Wavelet/internal/apps/upload/cache"
"github.com/Rain-kl/Wavelet/internal/apps/upload/shared"
uploadstats "github.com/Rain-kl/Wavelet/internal/apps/upload/stats"
"github.com/Rain-kl/Wavelet/internal/db"
"github.com/Rain-kl/Wavelet/internal/model"
"github.com/Rain-kl/Wavelet/internal/repository"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
// Remove soft-deletes an upload and decrements incremental stats.
// Remove soft-deletes an ordinary upload and decrements incremental stats once.
func Remove(ctx context.Context, uploadID uint64) (model.Upload, error) {
upload, err := repository.GetActiveUploadByID(ctx, uploadID)
upload, err := remove(ctx, 0, uploadID, false)
if err != nil {
return model.Upload{}, err
}
if err := softDeleteUploadWithStats(ctx, &upload); err != nil {
return model.Upload{}, err
}
upload.Status = model.UploadStatusDeleted
return upload, nil
}
// RemoveOwned soft-deletes an upload owned by userID and decrements incremental stats.
// RemoveOwned soft-deletes an ordinary upload owned by userID and decrements incremental stats once.
func RemoveOwned(ctx context.Context, userID, uploadID uint64) (model.Upload, error) {
upload, err := repository.GetActiveUploadByID(ctx, uploadID)
upload, err := remove(ctx, userID, uploadID, true)
if err != nil {
return model.Upload{}, err
}
if upload.UserID != userID {
return model.Upload{}, ErrForbidden
}
if err := softDeleteUploadWithStats(ctx, &upload); err != nil {
return model.Upload{}, err
}
upload.Status = model.UploadStatusDeleted
return upload, nil
}
func softDeleteUploadWithStats(ctx context.Context, upload *model.Upload) error {
statsSnapshot := *upload
func remove(ctx context.Context, userID, uploadID uint64, owned bool) (model.Upload, error) {
var upload model.Upload
if err := db.DB(ctx).Transaction(func(tx *gorm.DB) error {
if err := repository.SoftDeleteUploadTx(tx, upload); err != nil {
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).
Where("id = ?", uploadID).
First(&upload).Error; err != nil {
return err
}
return uploadstats.ApplyUploadStatsDeltaTx(tx, &statsSnapshot, -1)
}); err != nil {
if owned && upload.UserID != userID {
return ErrForbidden
}
if upload.Type == shared.ReservedPagesDeploymentType {
return ErrReservedUploadType
}
_, err := RemoveLockedTx(tx, &upload)
return err
}); err != nil {
return model.Upload{}, err
}
uploadcache.InvalidateUploadMetaCache(ctx, upload.ID)
return nil
InvalidateUploadMetaCache(ctx, uploadID)
upload.Status = model.UploadStatusDeleted
return upload, nil
}
// RemoveLockedTx performs the idempotent active-to-deleted transition for a row
// that the caller has already locked in its surrounding transaction.
func RemoveLockedTx(tx *gorm.DB, upload *model.Upload) (bool, error) {
rowsAffected, err := repository.SoftDeleteUploadTx(tx, upload)
if err != nil {
return false, err
}
if rowsAffected == 0 {
return false, nil
}
if err := uploadstats.ApplyUploadStatsDeltaTx(tx, upload, -1); err != nil {
return false, err
}
upload.Status = model.UploadStatusDeleted
return true, nil
}
// InvalidateUploadMetaCache invalidates upload metadata after the caller commits its transaction.
func InvalidateUploadMetaCache(ctx context.Context, uploadID uint64) {
uploadcache.InvalidateUploadMetaCache(ctx, uploadID)
}