merge: 合并 PR #22 Pages 部署源 V2 到 feat/pages-source-sync-v2

基于最新 main 合并 deqiying/feat/pages-source-sync-v2,
解决 docs/changelog/index.md 与限流相关条目的冲突。
This commit is contained in:
ryan
2026-07-19 19:36:48 +08:00
127 changed files with 24144 additions and 1933 deletions
+25 -18
View File
@@ -18,22 +18,23 @@ const (
// PagesProject OpenFlare Pages 静态托管项目。
type PagesProject struct {
ID uint `json:"id" gorm:"primaryKey;autoIncrement"`
Name string `json:"name" gorm:"size:255;not null"`
Slug string `json:"slug" gorm:"uniqueIndex;size:128;not null"`
Description string `json:"description" gorm:"type:text;not null;default:''"`
Enabled bool `json:"enabled" gorm:"not null;default:true"`
SPAFallbackEnabled bool `json:"spa_fallback_enabled" gorm:"not null;default:false"`
SPAFallbackPath string `json:"spa_fallback_path" gorm:"size:512;not null;default:'/index.html'"`
APIProxyEnabled bool `json:"api_proxy_enabled" gorm:"not null;default:false"`
APIProxyPath string `json:"api_proxy_path" gorm:"size:255;not null;default:''"`
APIProxyPass string `json:"api_proxy_pass" gorm:"size:2048;not null;default:''"`
APIProxyRewrite string `json:"api_proxy_rewrite" gorm:"size:255;not null;default:''"`
ActiveDeploymentID *uint `json:"active_deployment_id" gorm:"index"`
RootDir string `json:"root_dir" gorm:"size:512;not null;default:''"`
EntryFile string `json:"entry_file" gorm:"size:512;not null;default:'index.html'"`
CreatedAt time.Time `json:"created_at" gorm:"autoCreateTime"`
UpdatedAt time.Time `json:"updated_at" gorm:"autoUpdateTime"`
ID uint `json:"id" gorm:"primaryKey;autoIncrement"`
Name string `json:"name" gorm:"size:255;not null"`
Slug string `json:"slug" gorm:"uniqueIndex;size:128;not null"`
Description string `json:"description" gorm:"type:text;not null;default:''"`
Enabled bool `json:"enabled" gorm:"not null;default:true"`
SPAFallbackEnabled bool `json:"spa_fallback_enabled" gorm:"not null;default:false"`
SPAFallbackPath string `json:"spa_fallback_path" gorm:"size:512;not null;default:'/index.html'"`
APIProxyEnabled bool `json:"api_proxy_enabled" gorm:"not null;default:false"`
APIProxyPath string `json:"api_proxy_path" gorm:"size:255;not null;default:''"`
APIProxyPass string `json:"api_proxy_pass" gorm:"size:2048;not null;default:''"`
APIProxyRewrite string `json:"api_proxy_rewrite" gorm:"size:255;not null;default:''"`
ActiveDeploymentID *uint `json:"active_deployment_id" gorm:"index"`
RootDir string `json:"root_dir" gorm:"size:512;not null;default:''"`
EntryFile string `json:"entry_file" gorm:"size:512;not null;default:'index.html'"`
ContentConfigVersion int `json:"-" gorm:"not null;default:0"`
CreatedAt time.Time `json:"created_at" gorm:"autoCreateTime"`
UpdatedAt time.Time `json:"updated_at" gorm:"autoUpdateTime"`
}
// TableName 表名。
@@ -44,8 +45,8 @@ func (PagesProject) TableName() string {
// PagesDeployment OpenFlare Pages 不可变部署记录。
type PagesDeployment struct {
ID uint `json:"id" gorm:"primaryKey;autoIncrement"`
ProjectID uint `json:"project_id" gorm:"not null;index"`
DeploymentNumber int `json:"deployment_number" gorm:"not null"`
ProjectID uint `json:"project_id" gorm:"not null;index;uniqueIndex:idx_of_pages_deployments_project_number,priority:1;uniqueIndex:idx_of_pages_deployments_source_revision,priority:1,where:source_identity IS NOT NULL AND source_revision IS NOT NULL"`
DeploymentNumber int `json:"deployment_number" gorm:"not null;uniqueIndex:idx_of_pages_deployments_project_number,priority:2"`
Checksum string `json:"checksum" gorm:"size:64;not null;index"`
Status string `json:"status" gorm:"size:32;not null;default:'uploaded';index"`
UploadID uint64 `json:"upload_id,string" gorm:"not null;default:0;index"`
@@ -53,6 +54,12 @@ type PagesDeployment struct {
FileCount int `json:"file_count" gorm:"not null;default:0"`
TotalSize int64 `json:"total_size" gorm:"not null;default:0"`
CreatedBy string `json:"created_by" gorm:"size:64;not null;default:''"`
SourceType string `json:"source_type" gorm:"size:32;not null;default:''"`
SourceIdentity *string `json:"-" gorm:"type:char(64);uniqueIndex:idx_of_pages_deployments_source_revision,priority:2,where:source_identity IS NOT NULL AND source_revision IS NOT NULL"`
SourceRevision *string `json:"-" gorm:"type:char(64);uniqueIndex:idx_of_pages_deployments_source_revision,priority:3,where:source_identity IS NOT NULL AND source_revision IS NOT NULL"`
SourceLabel string `json:"source_label" gorm:"size:255;not null;default:''"`
SourceMeta string `json:"-" gorm:"type:text;not null;default:''"`
TriggerType string `json:"trigger_type" gorm:"size:32;not null;default:''"`
CreatedAt time.Time `json:"created_at" gorm:"autoCreateTime"`
ActivatedAt *time.Time `json:"activated_at"`
}
+75
View File
@@ -0,0 +1,75 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package model
import (
"context"
"errors"
"time"
"github.com/Rain-kl/Wavelet/internal/db"
)
const (
// PagesOrphanUploadCandidateLimit bounds one delayed Pages upload cleanup pass.
PagesOrphanUploadCandidateLimit = 100
pagesOrphanMarkerPredicatePostgres = "w_uploads.metadata #>> '{extra,pages_ingest_marker}' = ?"
pagesOrphanMarkerPredicateSQLite = "CASE WHEN json_valid(w_uploads.metadata) THEN json_extract(w_uploads.metadata, '$.extra.pages_ingest_marker') ELSE NULL END = ?"
)
// PagesOrphanUploadCandidateQuery describes the fail-closed SQL candidate set
// for delayed Pages upload compensation.
type PagesOrphanUploadCandidateQuery struct {
SystemUserID uint64
UploadType string
Marker string
CreatedBefore time.Time
}
// ListPagesOrphanUploadCandidates returns at most 100 unreferenced, isolated
// Pages V2 upload records. Callers must still lock and recheck every condition
// before deleting a candidate.
func ListPagesOrphanUploadCandidates(
ctx context.Context,
input PagesOrphanUploadCandidateQuery,
) ([]Upload, error) {
if input.SystemUserID == 0 || input.UploadType == "" || input.Marker == "" || input.CreatedBefore.IsZero() {
return nil, errors.New("invalid pages orphan upload candidate query")
}
markerPredicate, err := pagesOrphanMarkerPredicate(db.DB(ctx).Name())
if err != nil {
return nil, err
}
deploymentTable := (PagesDeployment{}).TableName()
uploadTable := (Upload{}).TableName()
var candidates []Upload
err = db.DB(ctx).
Model(&Upload{}).
Where(uploadTable+".status = ?", UploadStatusUsed).
Where(uploadTable+".user_id = ?", input.SystemUserID).
Where(uploadTable+".type = ?", input.UploadType).
Where(uploadTable+".created_at < ?", input.CreatedBefore).
Where(markerPredicate, input.Marker).
Where("NOT EXISTS (SELECT 1 FROM " + deploymentTable + " WHERE " + deploymentTable + ".upload_id = " + uploadTable + ".id)").
Order(uploadTable + ".id ASC").
Limit(PagesOrphanUploadCandidateLimit).
Find(&candidates).Error
if err != nil {
return nil, err
}
return candidates, nil
}
func pagesOrphanMarkerPredicate(dialect string) (string, error) {
switch dialect {
case "postgres":
return pagesOrphanMarkerPredicatePostgres, nil
case "sqlite":
return pagesOrphanMarkerPredicateSQLite, nil
default:
return "", errors.New("unsupported database dialect for Pages orphan cleanup")
}
}
@@ -0,0 +1,193 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package model
import (
"context"
"strings"
"testing"
"time"
"github.com/Rain-kl/Wavelet/internal/db"
"github.com/glebarez/sqlite"
"gorm.io/gorm"
)
func TestPagesOrphanMarkerPredicate(t *testing.T) {
tests := []struct {
name string
dialect string
want string
wantErr bool
}{
{
name: "postgres jsonb path",
dialect: "postgres",
want: "metadata #>> '{extra,pages_ingest_marker}'",
},
{
name: "sqlite guarded json extract",
dialect: "sqlite",
want: "CASE WHEN json_valid(w_uploads.metadata) THEN json_extract",
},
{
name: "unknown dialect rejected",
dialect: "mysql",
wantErr: true,
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
got, err := pagesOrphanMarkerPredicate(test.dialect)
if gotErr := err != nil; gotErr != test.wantErr {
t.Fatalf("pagesOrphanMarkerPredicate(%q) error = %v, want error presence = %t", test.dialect, err, test.wantErr)
}
if test.want != "" && !strings.Contains(got, test.want) {
t.Errorf("pagesOrphanMarkerPredicate(%q) = %q, want substring %q", test.dialect, got, test.want)
}
})
}
}
func TestListPagesOrphanUploadCandidatesFiltersAndLimits(t *testing.T) {
ctx := context.Background()
gormDB := setupPagesCleanupModelTestDB(t)
cutoff := time.Now().UTC().Add(-2 * time.Hour)
old := cutoff.Add(-time.Minute)
marker := UploadMetadata{Extra: map[string]any{
"pages_ingest_marker": "pages_deployment_v2",
"pages_project_id": "1",
}}
valid := make([]Upload, 0, PagesOrphanUploadCandidateLimit+1)
for index := 0; index < PagesOrphanUploadCandidateLimit+1; index++ {
valid = append(valid, pagesCleanupModelUpload(uint64(index+100), 999, "openflare_pages_deployment", UploadStatusUsed, old, marker))
}
if err := gormDB.Create(&valid).Error; err != nil {
t.Fatalf("create valid candidates error = %v, want nil", err)
}
referenced := pagesCleanupModelUpload(1, 999, "openflare_pages_deployment", UploadStatusUsed, old, marker)
wrongOwner := pagesCleanupModelUpload(2, 1000, "openflare_pages_deployment", UploadStatusUsed, old, marker)
wrongType := pagesCleanupModelUpload(3, 999, "generic", UploadStatusUsed, old, marker)
wrongStatus := pagesCleanupModelUpload(4, 999, "openflare_pages_deployment", UploadStatusPending, old, marker)
fresh := pagesCleanupModelUpload(5, 999, "openflare_pages_deployment", UploadStatusUsed, cutoff, marker)
wrongMarker := pagesCleanupModelUpload(6, 999, "openflare_pages_deployment", UploadStatusUsed, old, UploadMetadata{Extra: map[string]any{
"pages_ingest_marker": "pages_deployment_v1",
"pages_project_id": "1",
}})
for _, upload := range []Upload{referenced, wrongOwner, wrongType, wrongStatus, fresh, wrongMarker} {
if err := gormDB.Create(&upload).Error; err != nil {
t.Fatalf("create filtered upload %d error = %v, want nil", upload.ID, err)
}
}
if err := gormDB.Create(&PagesDeployment{
ProjectID: 1,
DeploymentNumber: 1,
Checksum: "referenced",
Status: PagesDeploymentStatusUploaded,
UploadID: referenced.ID,
}).Error; err != nil {
t.Fatalf("create referenced deployment error = %v, want nil", err)
}
invalidJSON := pagesCleanupModelUpload(7, 999, "openflare_pages_deployment", UploadStatusUsed, old, marker)
if err := gormDB.Create(&invalidJSON).Error; err != nil {
t.Fatalf("create invalid JSON upload error = %v, want nil", err)
}
if err := gormDB.Table((Upload{}).TableName()).Where("id = ?", invalidJSON.ID).
UpdateColumn("metadata", "{invalid").Error; err != nil {
t.Fatalf("corrupt upload metadata error = %v, want nil", err)
}
got, err := ListPagesOrphanUploadCandidates(ctx, PagesOrphanUploadCandidateQuery{
SystemUserID: 999,
UploadType: "openflare_pages_deployment",
Marker: "pages_deployment_v2",
CreatedBefore: cutoff,
})
if err != nil {
t.Fatalf("ListPagesOrphanUploadCandidates() error = %v, want nil", err)
}
if len(got) != PagesOrphanUploadCandidateLimit {
t.Fatalf("ListPagesOrphanUploadCandidates() count = %d, want %d", len(got), PagesOrphanUploadCandidateLimit)
}
for index, candidate := range got {
wantID := uint64(index + 100)
if candidate.ID != wantID {
t.Errorf("ListPagesOrphanUploadCandidates()[%d].ID = %d, want %d", index, candidate.ID, wantID)
}
}
}
func TestListPagesOrphanUploadCandidatesSkipsInvalidSQLiteJSON(t *testing.T) {
ctx := context.Background()
gormDB := setupPagesCleanupModelTestDB(t)
cutoff := time.Now().UTC().Add(-2 * time.Hour)
upload := pagesCleanupModelUpload(1, 999, "openflare_pages_deployment", UploadStatusUsed, cutoff.Add(-time.Minute), UploadMetadata{})
if err := gormDB.Create(&upload).Error; err != nil {
t.Fatalf("create invalid JSON candidate error = %v, want nil", err)
}
if err := gormDB.Table((Upload{}).TableName()).Where("id = ?", upload.ID).
UpdateColumn("metadata", "{invalid").Error; err != nil {
t.Fatalf("corrupt upload metadata error = %v, want nil", err)
}
got, err := ListPagesOrphanUploadCandidates(ctx, PagesOrphanUploadCandidateQuery{
SystemUserID: 999,
UploadType: "openflare_pages_deployment",
Marker: "pages_deployment_v2",
CreatedBefore: cutoff,
})
if err != nil {
t.Fatalf("ListPagesOrphanUploadCandidates(invalid JSON) error = %v, want nil", err)
}
if len(got) != 0 {
t.Errorf("ListPagesOrphanUploadCandidates(invalid JSON) count = %d, want 0", len(got))
}
}
func setupPagesCleanupModelTestDB(t *testing.T) *gorm.DB {
t.Helper()
gormDB, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{
DisableForeignKeyConstraintWhenMigrating: true,
})
if err != nil {
t.Fatalf("open Pages cleanup model test database error = %v, want nil", err)
}
if err := gormDB.AutoMigrate(&Upload{}, &PagesDeployment{}); err != nil {
t.Fatalf("migrate Pages cleanup model test database error = %v, want nil", err)
}
db.SetDB(gormDB)
t.Cleanup(func() { db.SetDB(nil) })
return gormDB
}
func pagesCleanupModelUpload(
id uint64,
userID uint64,
uploadType string,
status UploadStatus,
createdAt time.Time,
metadata UploadMetadata,
) Upload {
return Upload{
ID: id,
UserID: userID,
FileName: "site.zip",
FilePath: "pages/site.zip",
FileSize: 10,
MimeType: "application/zip",
Extension: "zip",
Hash: "checksum",
Type: uploadType,
Status: status,
AccessMode: 0,
Metadata: metadata,
CreatedAt: createdAt,
UpdatedAt: createdAt,
}
}
+59
View File
@@ -0,0 +1,59 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package model
import "time"
// PagesProjectSource 保存 Pages 项目的持久部署源配置。
//
// RemoteURL 可能包含签名参数,禁止直接序列化 model;对外接口必须映射到
// pages 包内的脱敏 source view。
type PagesProjectSource struct {
ID uint `json:"-" gorm:"primaryKey;autoIncrement"`
ProjectID uint `json:"-" gorm:"not null;uniqueIndex:idx_of_pages_project_sources_project_id"`
SourceType string `json:"-" gorm:"size:32;not null;default:''"`
RemoteURL string `json:"-" gorm:"type:text;not null;default:''"`
RemoteNetworkPolicy string `json:"-" gorm:"size:32;not null;default:''"`
GitHubRepository string `json:"-" gorm:"column:github_repository;size:255;not null;default:''"`
ReleaseSelector string `json:"-" gorm:"size:16;not null;default:''"`
ReleaseTag string `json:"-" gorm:"size:255;not null;default:''"`
AssetName string `json:"-" gorm:"size:255;not null;default:''"`
AutoUpdateEnabled bool `json:"-" gorm:"not null;default:false"`
CheckIntervalMinutes int `json:"-" gorm:"not null;default:0"`
ConfigVersion int `json:"-" gorm:"not null;default:0"`
SourceIdentity string `json:"-" gorm:"type:char(64);not null;default:''"`
CreatedAt time.Time `json:"-" gorm:"autoCreateTime"`
UpdatedAt time.Time `json:"-" gorm:"autoUpdateTime"`
}
// TableName 返回 Pages 项目部署源配置表名。
func (PagesProjectSource) TableName() string {
return "of_pages_project_sources"
}
// PagesProjectSourceRuntime 保存 Pages 项目部署源的可变运行态。
//
// Runtime 不冗余 project_id;调用方通过 SourceID 关联配置,并在最终提交时
// 同时校验 source config version 与 project content config version。
type PagesProjectSourceRuntime struct {
SourceID uint `json:"-" gorm:"primaryKey;autoIncrement:false"`
ETag string `json:"-" gorm:"column:etag;size:512;not null;default:''"`
LastSeenRevision string `json:"-" gorm:"type:char(64);not null;default:''"`
LastSeenDetail string `json:"-" gorm:"type:text;not null;default:''"`
LastAppliedRevision string `json:"-" gorm:"type:char(64);not null;default:''"`
LastAppliedDetail string `json:"-" gorm:"type:text;not null;default:''"`
SyncStatus string `json:"-" gorm:"size:32;not null;default:''"`
LastError string `json:"-" gorm:"type:text;not null;default:''"`
LastCheckedAt *time.Time `json:"-"`
LastSyncedAt *time.Time `json:"-"`
NextCheckAt *time.Time `json:"-" gorm:"index:idx_of_pages_project_source_runtime_next_check_at"`
LeaseExpiresAt *time.Time `json:"-"`
LeaseToken string `json:"-" gorm:"size:64;not null;default:''"`
UpdatedAt time.Time `json:"-" gorm:"autoUpdateTime"`
}
// TableName 返回 Pages 项目部署源运行态表名。
func (PagesProjectSourceRuntime) TableName() string {
return "of_pages_project_source_runtime"
}
@@ -0,0 +1,80 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package model
import (
"encoding/json"
"strings"
"testing"
"github.com/glebarez/sqlite"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gorm.io/gorm"
)
func TestPagesSourceModelsMatchMigrationSchema(t *testing.T) {
gormDB, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{
DisableForeignKeyConstraintWhenMigrating: true,
})
require.NoError(t, err)
require.NoError(t, gormDB.AutoMigrate(
&PagesProject{},
&PagesDeployment{},
&PagesProjectSource{},
&PagesProjectSourceRuntime{},
))
assert.Equal(t, "of_pages_project_sources", (PagesProjectSource{}).TableName())
assert.Equal(t, "of_pages_project_source_runtime", (PagesProjectSourceRuntime{}).TableName())
assert.True(t, gormDB.Migrator().HasColumn(&PagesProjectSource{}, "github_repository"))
assert.False(t, gormDB.Migrator().HasColumn(&PagesProjectSource{}, "git_hub_repository"))
assert.True(t, gormDB.Migrator().HasColumn(&PagesProjectSourceRuntime{}, "etag"))
assert.False(t, gormDB.Migrator().HasColumn(&PagesProjectSourceRuntime{}, "e_tag"))
var indexSQL string
require.NoError(t, gormDB.Raw(
"SELECT sql FROM sqlite_master WHERE type = 'index' AND name = ?",
"idx_of_pages_deployments_source_revision",
).Scan(&indexSQL).Error)
assert.Contains(t, strings.ToUpper(indexSQL), "WHERE SOURCE_IDENTITY IS NOT NULL AND SOURCE_REVISION IS NOT NULL")
}
func TestPagesSourceModelsDoNotSerializeSecretsOrFencingState(t *testing.T) {
sourceJSON, err := json.Marshal(PagesProjectSource{
ID: 1,
ProjectID: 2,
RemoteURL: "https://example.com/site.zip?token=secret",
ConfigVersion: 3,
SourceIdentity: strings.Repeat("a", 64),
})
require.NoError(t, err)
assert.JSONEq(t, `{}`, string(sourceJSON))
runtimeJSON, err := json.Marshal(PagesProjectSourceRuntime{
SourceID: 1,
ETag: `"secret-etag"`,
LeaseToken: "secret-lease",
})
require.NoError(t, err)
assert.JSONEq(t, `{}`, string(runtimeJSON))
identity := strings.Repeat("b", 64)
revision := strings.Repeat("c", 64)
deploymentJSON, err := json.Marshal(PagesDeployment{
SourceType: "remote_url",
SourceIdentity: &identity,
SourceRevision: &revision,
SourceLabel: "site.zip",
SourceMeta: `{"provider":"remote_url","private":"secret"}`,
TriggerType: "manual_sync",
})
require.NoError(t, err)
assert.NotContains(t, string(deploymentJSON), identity)
assert.NotContains(t, string(deploymentJSON), revision)
assert.NotContains(t, string(deploymentJSON), "private")
assert.Contains(t, string(deploymentJSON), `"source_type":"remote_url"`)
assert.Contains(t, string(deploymentJSON), `"source_label":"site.zip"`)
assert.Contains(t, string(deploymentJSON), `"trigger_type":"manual_sync"`)
}