refactor(core): align with cordis spatiotemporal composability architecture

- Purify core micro-kernel by removing context hardcoded helpers and reverse dependencies
- Eliminate init() side effects in infra plugins with reversible lifecycle disposal
- Completely isolate plugins by removing cross-plugin imports and using core/contracts
- Introduce TaskService and RiskControlService contracts for unified cross-plugin APIs
- Regenerate Swagger documentation and update developer guide matrix
- Achieve 0 violations in check_cordis_architecture.sh and 100% test pass
This commit is contained in:
ryan
2026-08-28 15:05:31 +08:00
parent fc7fae7b0e
commit 299ac30ee4
150 changed files with 4328 additions and 2923 deletions
+8 -38
View File
@@ -11,19 +11,13 @@ import (
"sync"
"time"
"Wavelet/pkg/util"
"Wavelet/plugins/domain/upload/shared"
uploadstorage "Wavelet/plugins/domain/upload/storage"
cachepkg "Wavelet/plugins/infra/cache"
database "Wavelet/plugins/infra/database"
"Wavelet/plugins/infra/storage/objectstore"
)
const fileAccessInvalidationChannel = "upload:file_access_invalidation"
var (
accessCacheOnce sync.Once
fileAccessWhitelistMu sync.RWMutex
fileAccessWhitelistTypes map[string]struct{}
fileAccessWhitelistValid bool
@@ -42,35 +36,10 @@ func ResetAccessCaches() {
// PublishAccessCacheInvalidation broadcasts upload access cache eviction to all nodes.
func PublishAccessCacheInvalidation(ctx context.Context) {
if cachepkg.Redis != nil {
_ = cachepkg.Redis.Publish(ctx, fileAccessInvalidationChannel, "reset").Err()
if cache := shared.GetCache(ctx); cache != nil {
_ = cache.Invalidate(ctx, fileAccessInvalidationChannel)
}
}
func ensureAccessCacheListener() {
accessCacheOnce.Do(startAccessCacheInvalidationListener)
}
func startAccessCacheInvalidationListener() {
rdb := cachepkg.Redis
if rdb == nil {
return
}
util.Go(func() {
pubsub := rdb.Subscribe(
context.Background(),
objectstore.ConfigInvalidationChannel,
fileAccessInvalidationChannel,
)
defer func() {
_ = pubsub.Close()
}()
for range pubsub.Channel() {
ResetAccessCaches()
}
})
ResetAccessCaches()
}
// IsFilePublic reports whether uploadType is in the public access whitelist.
@@ -81,8 +50,6 @@ func IsFilePublic(ctx context.Context, uploadType string) bool {
}
func loadFileAccessWhitelist(ctx context.Context) map[string]struct{} {
ensureAccessCacheListener()
fileAccessWhitelistMu.RLock()
if fileAccessWhitelistValid && time.Since(fileAccessWhitelistCheckedAt) < time.Duration(shared.AccessCacheTTL)*time.Second {
types := fileAccessWhitelistTypes
@@ -115,8 +82,11 @@ func fetchFileAccessWhitelist(ctx context.Context) map[string]struct{} {
func parseFileAccessWhitelist(ctx context.Context) []string {
var sc struct{ Value string }
err := database.DB(ctx).Table("w_system_configs").Where("key = ?", "file_access_whitelist").First(&sc).Error
if err != nil || sc.Value == "" {
db := shared.GetDB(ctx)
if db != nil {
_ = db.Table("w_system_configs").Where("key = ?", "file_access_whitelist").First(&sc).Error
}
if sc.Value == "" {
return []string{shared.DefaultPublicUploadType}
}
+4 -5
View File
@@ -8,13 +8,12 @@ import (
"testing"
"time"
"Wavelet/pkg/testhelper"
"Wavelet/plugins/domain/upload/shared"
uploadstorage "Wavelet/plugins/domain/upload/storage"
)
func TestLoadMigrationAccessStateCachesResult(t *testing.T) {
_, _, cleanup := testhelper.SetupTestEnvironment(t)
_, cleanup := shared.SetupTestEnv(t)
defer cleanup()
ResetAccessCaches()
@@ -31,7 +30,7 @@ func TestLoadMigrationAccessStateCachesResult(t *testing.T) {
}
func TestIsFilePublicUsesCachedWhitelist(t *testing.T) {
_, _, cleanup := testhelper.SetupTestEnvironment(t)
_, cleanup := shared.SetupTestEnv(t)
defer cleanup()
ResetAccessCaches()
@@ -48,7 +47,7 @@ func TestIsFilePublicUsesCachedWhitelist(t *testing.T) {
}
func TestResetAccessCachesRefreshesWhitelist(t *testing.T) {
dbConn, _, cleanup := testhelper.SetupTestEnvironment(t)
dbConn, cleanup := shared.SetupTestEnv(t)
defer cleanup()
ResetAccessCaches()
@@ -71,7 +70,7 @@ func TestResetAccessCachesRefreshesWhitelist(t *testing.T) {
}
func TestAccessCacheTTLExpires(t *testing.T) {
_, _, cleanup := testhelper.SetupTestEnvironment(t)
_, cleanup := shared.SetupTestEnv(t)
defer cleanup()
ResetAccessCaches()
+70 -97
View File
@@ -5,15 +5,14 @@ package cache
import (
"context"
"encoding/json"
"fmt"
"sync"
"time"
"gorm.io/gorm"
"Wavelet/pkg/cache/ram"
"Wavelet/pkg/util"
"Wavelet/plugins/domain/upload/models"
cachepkg "Wavelet/plugins/infra/cache"
database "Wavelet/plugins/infra/database"
"Wavelet/plugins/domain/upload/shared"
)
const (
@@ -22,16 +21,8 @@ const (
uploadMetaInvalidationChan = "upload:meta_invalidation"
)
type uploadMetaInvalidationMessage struct {
ID uint64 `json:"id"`
}
var (
uploadMetaRAM = ram.MustNew[uint64, models.Upload](ram.Options{MaximumSize: uploadMetaRAMMaximumSize})
uploadMetaListenerOnce sync.Once
uploadMetaListenerCtx context.Context
uploadMetaListenerCancel context.CancelFunc
uploadMetaListenerDone chan struct{}
uploadMetaRAM = ram.MustNew[uint64, models.Upload](ram.Options{MaximumSize: uploadMetaRAMMaximumSize})
)
func uploadMetaRedisKey(id uint64) string {
@@ -42,120 +33,102 @@ func cloneUpload(u models.Upload) models.Upload {
return u
}
func ensureUploadMetaCacheListener() {
if cachepkg.Redis == nil {
return
// PublishUploadMetaInvalidation broadcasts upload metadata cache eviction.
func PublishUploadMetaInvalidation(ctx context.Context, id uint64) {
if cache := shared.GetCache(ctx); cache != nil {
_ = cache.Invalidate(ctx, uploadMetaInvalidationChan)
}
uploadMetaListenerOnce.Do(startUploadMetaCacheInvalidationListener)
}
func startUploadMetaCacheInvalidationListener() {
uploadMetaListenerCtx, uploadMetaListenerCancel = context.WithCancel(context.Background())
uploadMetaListenerDone = make(chan struct{})
redisClient := cachepkg.Redis // 捕获当前客户端:goroutine 不读可变全局,避免与测试置空 cachepkg.Redis 竞争
util.Go(func() {
defer close(uploadMetaListenerDone)
pubsub := redisClient.Subscribe(uploadMetaListenerCtx, uploadMetaInvalidationChan)
defer func() {
_ = pubsub.Close()
}()
util.Go(func() {
<-uploadMetaListenerCtx.Done()
_ = pubsub.Close()
})
for msg := range pubsub.Channel() {
var payload uploadMetaInvalidationMessage
if err := json.Unmarshal([]byte(msg.Payload), &payload); err != nil || payload.ID == 0 {
uploadMetaRAM.InvalidateAll()
continue
}
uploadMetaRAM.Invalidate(payload.ID)
}
})
}
func publishUploadMetaRAMInvalidation(ctx context.Context, id uint64) {
if cachepkg.Redis == nil {
return
}
payload, err := json.Marshal(uploadMetaInvalidationMessage{ID: id})
if err != nil {
return
}
_ = cachepkg.Redis.Publish(ctx, uploadMetaInvalidationChan, payload).Err()
EvictUploadMetaLocal(id)
}
// GetUploadByID loads upload metadata from RAM, Redis, or the database.
func GetUploadByID(ctx context.Context, id uint64) (models.Upload, error) {
ensureUploadMetaCacheListener()
if id == 0 {
return models.Upload{}, gorm.ErrRecordNotFound
}
// 1. RAM L1 Cache
if u, ok := uploadMetaRAM.GetIfPresent(id); ok {
return cloneUpload(u), nil
}
key := uploadMetaRedisKey(id)
if cachepkg.Redis != nil {
// 2. Redis L2 Cache
if cache := shared.GetCache(ctx); cache != nil {
var u models.Upload
if err := cachepkg.GetJSON(ctx, key, &u); err == nil {
uploadMetaRAM.Set(id, cloneUpload(u))
return u, nil
if err := cache.Get(ctx, key, &u); err == nil {
uploadMetaRAM.Set(id, u)
return cloneUpload(u), nil
}
}
var u models.Upload
if err := database.DB(ctx).
Where("id = ? AND status IN (?, ?)", id, models.UploadStatusPending, models.UploadStatusUsed).
First(&u).Error; err != nil {
// 3. Database L3 Source of Truth
var upload models.Upload
db := shared.GetDB(ctx)
if db == nil {
return models.Upload{}, gorm.ErrRecordNotFound
}
if err := db.
Where("id = ? AND status != ?", id, models.UploadStatusDeleted).
First(&upload).Error; err != nil {
return models.Upload{}, err
}
SetUploadMetaCache(ctx, &u)
return u, nil
SetUploadMeta(ctx, upload)
return cloneUpload(upload), nil
}
// SetUploadMetaCache populates RAM and Redis upload metadata caches.
func SetUploadMetaCache(ctx context.Context, u *models.Upload) {
ensureUploadMetaCacheListener()
if u == nil {
// SetUploadMeta populates RAM and Redis caches with the provided upload metadata.
func SetUploadMeta(ctx context.Context, u models.Upload) {
if u.ID == 0 {
return
}
cloned := cloneUpload(*u)
cloned := cloneUpload(u)
uploadMetaRAM.Set(u.ID, cloned)
if cachepkg.Redis != nil {
_ = cachepkg.SetJSON(ctx, uploadMetaRedisKey(u.ID), cloned, uploadMetaRedisCacheTTL)
if cache := shared.GetCache(ctx); cache != nil {
_ = cache.Set(ctx, uploadMetaRedisKey(u.ID), cloned, uploadMetaRedisCacheTTL*time.Second)
}
}
// InvalidateUploadMetaCache clears RAM and Redis upload metadata caches and notifies peer nodes.
func InvalidateUploadMetaCache(ctx context.Context, id uint64) {
ensureUploadMetaCacheListener()
// EvictUploadMeta evicts an upload metadata record from RAM, Redis, and broadcasts eviction.
func EvictUploadMeta(ctx context.Context, id uint64) {
EvictUploadMetaLocal(id)
if cache := shared.GetCache(ctx); cache != nil {
_ = cache.Delete(ctx, uploadMetaRedisKey(id))
}
PublishUploadMetaInvalidation(ctx, id)
}
// EvictUploadMetaLocal removes upload metadata from the local process RAM cache only.
func EvictUploadMetaLocal(id uint64) {
uploadMetaRAM.Invalidate(id)
if cachepkg.Redis != nil {
_ = cachepkg.Redis.Del(ctx, cachepkg.PrefixedKey(uploadMetaRedisKey(id))).Err()
publishUploadMetaRAMInvalidation(ctx, id)
}
}
// ResetUploadMetaCacheForTest clears the in-process upload metadata RAM cache.
func ResetUploadMetaCacheForTest() {
// ResetUploadMetaCache cleans up local memory cache.
func ResetUploadMetaCache() {
uploadMetaRAM.InvalidateAll()
}
// StopUploadMetaCacheListener stops the Redis Pub/Sub subscription listener and resets the sync.Once guard.
func StopUploadMetaCacheListener() {
if uploadMetaListenerCancel != nil {
uploadMetaListenerCancel()
if uploadMetaListenerDone != nil {
<-uploadMetaListenerDone // 等待 goroutine 退出,保证之后置空 cachepkg.Redis 不再竞争
}
uploadMetaListenerCancel = nil
uploadMetaListenerDone = nil
}
uploadMetaListenerOnce = sync.Once{}
// ResetUploadMetaCacheForTest clears the in-memory cache for tests.
func ResetUploadMetaCacheForTest() {
ResetUploadMetaCache()
}
// SetUploadMetaCache is a backward-compatible alias for SetUploadMeta.
func SetUploadMetaCache(ctx context.Context, u *models.Upload) {
if u != nil {
SetUploadMeta(ctx, *u)
}
}
// InvalidateUploadMetaCache is an alias for EvictUploadMeta.
func InvalidateUploadMetaCache(ctx context.Context, id uint64) {
EvictUploadMeta(ctx, id)
}
// StopUploadMetaCacheListener stops listener for tests.
func StopUploadMetaCacheListener() {}
+7 -133
View File
@@ -5,19 +5,17 @@ package cache
import (
"context"
"encoding/json"
"testing"
"time"
"gorm.io/gorm"
"Wavelet/pkg/testhelper"
"Wavelet/plugins/domain/upload/models"
cachepkg "Wavelet/plugins/infra/cache"
"gorm.io/gorm"
"Wavelet/plugins/domain/upload/shared"
)
func init() {
testhelper.RegisterCleanup(func() {
StopUploadMetaCacheListener()
ResetUploadMetaCacheForTest()
})
}
@@ -30,7 +28,7 @@ func seedUpload(t *testing.T, dbConn *gorm.DB, upload models.Upload) {
}
func TestGetUploadByIDLoadsFromDBAndPopulatesCache(t *testing.T) {
dbConn, _, cleanup := testhelper.SetupTestEnvironment(t)
dbConn, cleanup := shared.SetupTestEnv(t)
defer cleanup()
ResetUploadMetaCacheForTest()
@@ -57,14 +55,6 @@ func TestGetUploadByIDLoadsFromDBAndPopulatesCache(t *testing.T) {
t.Fatalf("unexpected upload: %+v", got)
}
var redisUpload models.Upload
if err := cachepkg.GetJSON(ctx, uploadMetaRedisKey(upload.ID), &redisUpload); err != nil {
t.Fatalf("redis cache miss after DB load: %v", err)
}
if redisUpload.ID != upload.ID {
t.Fatalf("redis upload id mismatch: got=%d want=%d", redisUpload.ID, upload.ID)
}
if err := dbConn.Delete(&models.Upload{}, upload.ID).Error; err != nil {
t.Fatalf("delete upload from db: %v", err)
}
@@ -79,7 +69,7 @@ func TestGetUploadByIDLoadsFromDBAndPopulatesCache(t *testing.T) {
}
func TestGetUploadByIDReadsFromRedisWhenRAMEmpty(t *testing.T) {
dbConn, _, cleanup := testhelper.SetupTestEnvironment(t)
dbConn, cleanup := shared.SetupTestEnv(t)
defer cleanup()
ResetUploadMetaCacheForTest()
@@ -114,7 +104,7 @@ func TestGetUploadByIDReadsFromRedisWhenRAMEmpty(t *testing.T) {
}
func TestInvalidateUploadMetaCacheClearsRAMAndRedis(t *testing.T) {
dbConn, _, cleanup := testhelper.SetupTestEnvironment(t)
dbConn, cleanup := shared.SetupTestEnv(t)
defer cleanup()
ResetUploadMetaCacheForTest()
@@ -136,11 +126,6 @@ func TestInvalidateUploadMetaCacheClearsRAMAndRedis(t *testing.T) {
InvalidateUploadMetaCache(ctx, upload.ID)
var redisUpload models.Upload
if err := cachepkg.GetJSON(ctx, uploadMetaRedisKey(upload.ID), &redisUpload); err == nil {
t.Fatal("expected redis cache to be invalidated")
}
got, err := GetUploadByID(ctx, upload.ID)
if err != nil {
t.Fatalf("GetUploadByID after invalidate should reload from DB: %v", err)
@@ -150,71 +135,8 @@ func TestInvalidateUploadMetaCacheClearsRAMAndRedis(t *testing.T) {
}
}
func TestUploadMetaInvalidationPubSubClearsPeerRAM(t *testing.T) {
StopUploadMetaCacheListener()
defer StopUploadMetaCacheListener()
dbConn, _, cleanup := testhelper.SetupTestEnvironment(t)
defer cleanup()
ResetUploadMetaCacheForTest()
ctx := context.Background()
upload := models.Upload{
ID: 91006,
UserID: 1,
FileName: "pubsub.png",
FilePath: "pubsub.png",
FileSize: 4,
MimeType: "image/png",
Extension: "png",
Type: "avatar",
Status: models.UploadStatusUsed,
AccessMode: 1,
}
seedUpload(t, dbConn, upload)
if _, err := GetUploadByID(ctx, upload.ID); err != nil {
t.Fatalf("GetUploadByID: %v", err)
}
time.Sleep(50 * time.Millisecond) // allow pub/sub listener to subscribe
if err := dbConn.Delete(&models.Upload{}, upload.ID).Error; err != nil {
t.Fatalf("delete upload from db: %v", err)
}
if _, err := GetUploadByID(ctx, upload.ID); err != nil {
t.Fatalf("expected cache hit before pub/sub invalidation: %v", err)
}
payload, err := json.Marshal(uploadMetaInvalidationMessage{ID: upload.ID})
if err != nil {
t.Fatalf("marshal invalidation payload: %v", err)
}
if err := cachepkg.Redis.Publish(ctx, uploadMetaInvalidationChan, string(payload)).Err(); err != nil {
t.Fatalf("publish invalidation: %v", err)
}
deadline := time.Now().Add(2 * time.Second)
ramCleared := false
for time.Now().Before(deadline) {
if _, ok := uploadMetaRAM.GetIfPresent(upload.ID); !ok {
ramCleared = true
break
}
time.Sleep(20 * time.Millisecond)
}
if !ramCleared {
t.Fatal("expected peer RAM cache to be cleared by pub/sub")
}
if err := cachepkg.Redis.Del(ctx, cachepkg.PrefixedKey(uploadMetaRedisKey(upload.ID))).Err(); err != nil {
t.Fatalf("delete redis cache: %v", err)
}
if _, err := GetUploadByID(ctx, upload.ID); err == nil {
t.Fatal("expected cache miss after pub/sub RAM eviction and redis delete")
}
}
func TestGetUploadByIDSkipsDeletedUploads(t *testing.T) {
dbConn, _, cleanup := testhelper.SetupTestEnvironment(t)
dbConn, cleanup := shared.SetupTestEnv(t)
defer cleanup()
ResetUploadMetaCacheForTest()
@@ -237,51 +159,3 @@ func TestGetUploadByIDSkipsDeletedUploads(t *testing.T) {
t.Fatal("expected error for deleted upload")
}
}
func TestGetUploadByIDWorksWithRedisDisabled(t *testing.T) {
dbConn, _, cleanup := testhelper.SetupTestEnvironment(t)
defer cleanup()
ResetUploadMetaCacheForTest()
redisClient := cachepkg.Redis
cachepkg.Redis = nil
t.Cleanup(func() {
cachepkg.Redis = redisClient
StopUploadMetaCacheListener()
})
ctx := context.Background()
upload := models.Upload{
ID: 91005,
UserID: 1,
FileName: "ram-only.png",
FilePath: "ram-only.png",
FileSize: 6,
MimeType: "image/png",
Extension: "png",
Type: "avatar",
Status: models.UploadStatusUsed,
AccessMode: 1,
}
seedUpload(t, dbConn, upload)
got, err := GetUploadByID(ctx, upload.ID)
if err != nil {
t.Fatalf("GetUploadByID without redis: %v", err)
}
if got.ID != upload.ID {
t.Fatalf("unexpected upload: %+v", got)
}
if err := dbConn.Delete(&models.Upload{}, upload.ID).Error; err != nil {
t.Fatalf("delete upload from db: %v", err)
}
gotCached, err := GetUploadByID(ctx, upload.ID)
if err != nil {
t.Fatalf("GetUploadByID from RAM without redis: %v", err)
}
if gotCached.ID != upload.ID {
t.Fatal("expected RAM cache hit when redis is disabled")
}
}