mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-10-03 07:06:36 +08:00
perf(cache): 三层缓存框架补强
- 新增 cache-framework skill,规范 RAM→Redis→DB 读路径、失效与 pub/sub - 上传元数据 Otter+Redis 缓存与多节点失效;Auth Source 缓存与 pub/sub - ListSystemConfigsByKeys 补 Redis 层;上传统计单事务;登录/Token 缓存预热 - cleanup 任务补 upload meta 失效钩子
This commit is contained in:
@@ -0,0 +1,269 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/Rain-kl/Wavelet/internal/db"
|
||||
"github.com/Rain-kl/Wavelet/internal/model"
|
||||
"github.com/Rain-kl/Wavelet/pkg/cache/ram"
|
||||
)
|
||||
|
||||
const (
|
||||
authSourceActiveRedisKey = "oauth:auth_sources:active"
|
||||
authSourceByNameRedisKeyFmt = "oauth:auth_sources:by_name:%s"
|
||||
authSourceByNameRedisPattern = "oauth:auth_sources:by_name:*"
|
||||
authSourceActiveRAMKey = "active"
|
||||
authSourceCacheTTL = time.Hour
|
||||
authSourceRAMMaximumSize = 64
|
||||
authSourceInvalidationChannel = "oauth:auth_source_invalidation"
|
||||
)
|
||||
|
||||
// authSourceRedisRecord persists full auth source credentials in Redis.
|
||||
type authSourceRedisRecord struct {
|
||||
ID uint64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"`
|
||||
DisplayName string `json:"display_name"`
|
||||
IsActive bool `json:"is_active"`
|
||||
ClientID string `json:"client_id"`
|
||||
ClientSecret string `json:"client_secret"`
|
||||
OpenIDDiscoveryURL string `json:"openid_discovery_url"`
|
||||
Scopes string `json:"scopes"`
|
||||
IconURL string `json:"icon_url"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
ClientSecretConfigured bool `json:"client_secret_configured"`
|
||||
}
|
||||
|
||||
var (
|
||||
authSourceActiveRAM = ram.MustNew[string, []model.AuthSource](ram.Options{MaximumSize: authSourceRAMMaximumSize})
|
||||
authSourceByNameRAM = ram.MustNew[string, model.AuthSource](ram.Options{MaximumSize: authSourceRAMMaximumSize})
|
||||
authSourceListenerOnce sync.Once
|
||||
authSourceListenerCtx context.Context
|
||||
authSourceListenerCancel context.CancelFunc
|
||||
)
|
||||
|
||||
func cloneAuthSources(sources []model.AuthSource) []model.AuthSource {
|
||||
if len(sources) == 0 {
|
||||
return nil
|
||||
}
|
||||
cloned := make([]model.AuthSource, len(sources))
|
||||
copy(cloned, sources)
|
||||
return cloned
|
||||
}
|
||||
|
||||
func cloneAuthSource(source model.AuthSource) model.AuthSource {
|
||||
return source
|
||||
}
|
||||
|
||||
func normalizeAuthSourceName(name string) string {
|
||||
return strings.TrimSpace(strings.ToLower(name))
|
||||
}
|
||||
|
||||
func authSourceByNameRedisKey(name string) string {
|
||||
return fmt.Sprintf(authSourceByNameRedisKeyFmt, normalizeAuthSourceName(name))
|
||||
}
|
||||
|
||||
func authSourceToRedisRecord(source model.AuthSource) authSourceRedisRecord {
|
||||
return authSourceRedisRecord{
|
||||
ID: source.ID,
|
||||
Name: source.Name,
|
||||
Type: source.Type,
|
||||
DisplayName: source.DisplayName,
|
||||
IsActive: source.IsActive,
|
||||
ClientID: source.ClientID,
|
||||
ClientSecret: source.ClientSecret,
|
||||
OpenIDDiscoveryURL: source.OpenIDDiscoveryURL,
|
||||
Scopes: source.Scopes,
|
||||
IconURL: source.IconURL,
|
||||
CreatedAt: source.CreatedAt,
|
||||
UpdatedAt: source.UpdatedAt,
|
||||
ClientSecretConfigured: source.ClientSecretConfigured,
|
||||
}
|
||||
}
|
||||
|
||||
func redisRecordToAuthSource(record authSourceRedisRecord) model.AuthSource {
|
||||
return model.AuthSource{
|
||||
ID: record.ID,
|
||||
Name: record.Name,
|
||||
Type: record.Type,
|
||||
DisplayName: record.DisplayName,
|
||||
IsActive: record.IsActive,
|
||||
ClientID: record.ClientID,
|
||||
ClientSecret: record.ClientSecret,
|
||||
OpenIDDiscoveryURL: record.OpenIDDiscoveryURL,
|
||||
Scopes: record.Scopes,
|
||||
IconURL: record.IconURL,
|
||||
CreatedAt: record.CreatedAt,
|
||||
UpdatedAt: record.UpdatedAt,
|
||||
ClientSecretConfigured: record.ClientSecretConfigured,
|
||||
}
|
||||
}
|
||||
|
||||
func ensureAuthSourceCacheListener() {
|
||||
if db.Redis == nil {
|
||||
return
|
||||
}
|
||||
authSourceListenerOnce.Do(startAuthSourceCacheInvalidationListener)
|
||||
}
|
||||
|
||||
func startAuthSourceCacheInvalidationListener() {
|
||||
authSourceListenerCtx, authSourceListenerCancel = context.WithCancel(context.Background())
|
||||
|
||||
go func() {
|
||||
pubsub := db.Redis.Subscribe(authSourceListenerCtx, authSourceInvalidationChannel)
|
||||
defer func() {
|
||||
_ = pubsub.Close()
|
||||
}()
|
||||
|
||||
go func() {
|
||||
<-authSourceListenerCtx.Done()
|
||||
_ = pubsub.Close()
|
||||
}()
|
||||
|
||||
for range pubsub.Channel() {
|
||||
authSourceActiveRAM.InvalidateAll()
|
||||
authSourceByNameRAM.InvalidateAll()
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
func publishAuthSourceRAMInvalidation(ctx context.Context) {
|
||||
if db.Redis == nil {
|
||||
return
|
||||
}
|
||||
_ = db.Redis.Publish(ctx, authSourceInvalidationChannel, "reset").Err()
|
||||
}
|
||||
|
||||
func populateActiveAuthSourceCache(ctx context.Context, sources []model.AuthSource) {
|
||||
cloned := cloneAuthSources(sources)
|
||||
authSourceActiveRAM.Set(authSourceActiveRAMKey, cloned)
|
||||
if db.Redis != nil {
|
||||
_ = db.SetJSON(ctx, authSourceActiveRedisKey, cloned, authSourceCacheTTL)
|
||||
}
|
||||
}
|
||||
|
||||
func populateAuthSourceByNameCache(ctx context.Context, name string, source *model.AuthSource) {
|
||||
if source == nil {
|
||||
return
|
||||
}
|
||||
cloned := cloneAuthSource(*source)
|
||||
authSourceByNameRAM.Set(normalizeAuthSourceName(name), cloned)
|
||||
if db.Redis != nil {
|
||||
record := authSourceToRedisRecord(cloned)
|
||||
_ = db.SetJSON(ctx, authSourceByNameRedisKey(name), record, authSourceCacheTTL)
|
||||
}
|
||||
}
|
||||
|
||||
// GetActiveAuthSourcesCached returns active auth sources from RAM, Redis, or the database.
|
||||
func GetActiveAuthSourcesCached(ctx context.Context) ([]model.AuthSource, error) {
|
||||
ensureAuthSourceCacheListener()
|
||||
|
||||
if sources, ok := authSourceActiveRAM.GetIfPresent(authSourceActiveRAMKey); ok {
|
||||
return cloneAuthSources(sources), nil
|
||||
}
|
||||
|
||||
if db.Redis != nil {
|
||||
var sources []model.AuthSource
|
||||
if err := db.GetJSON(ctx, authSourceActiveRedisKey, &sources); err == nil {
|
||||
populateActiveAuthSourceCache(ctx, sources)
|
||||
return cloneAuthSources(sources), nil
|
||||
}
|
||||
}
|
||||
|
||||
sources, err := model.GetActiveAuthSources(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
populateActiveAuthSourceCache(ctx, sources)
|
||||
return cloneAuthSources(sources), nil
|
||||
}
|
||||
|
||||
// GetAuthSourceByNameCached returns an auth source by name from RAM, Redis, or the database.
|
||||
func GetAuthSourceByNameCached(ctx context.Context, name string) (*model.AuthSource, error) {
|
||||
ensureAuthSourceCacheListener()
|
||||
|
||||
normalized := normalizeAuthSourceName(name)
|
||||
if normalized == "" {
|
||||
return model.GetAuthSourceByName(ctx, name)
|
||||
}
|
||||
|
||||
if source, ok := authSourceByNameRAM.GetIfPresent(normalized); ok {
|
||||
cloned := cloneAuthSource(source)
|
||||
return &cloned, nil
|
||||
}
|
||||
|
||||
if db.Redis != nil {
|
||||
var record authSourceRedisRecord
|
||||
if err := db.GetJSON(ctx, authSourceByNameRedisKey(name), &record); err == nil {
|
||||
source := redisRecordToAuthSource(record)
|
||||
populateAuthSourceByNameCache(ctx, name, &source)
|
||||
cloned := cloneAuthSource(source)
|
||||
return &cloned, nil
|
||||
}
|
||||
}
|
||||
|
||||
source, err := model.GetAuthSourceByName(ctx, name)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
populateAuthSourceByNameCache(ctx, name, source)
|
||||
cloned := cloneAuthSource(*source)
|
||||
return &cloned, nil
|
||||
}
|
||||
|
||||
// InvalidateAuthSourceCache clears active and per-name auth source caches from RAM and Redis.
|
||||
func InvalidateAuthSourceCache(ctx context.Context) error {
|
||||
ensureAuthSourceCacheListener()
|
||||
|
||||
authSourceActiveRAM.InvalidateAll()
|
||||
authSourceByNameRAM.InvalidateAll()
|
||||
|
||||
if db.Redis == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := db.Redis.Del(ctx, db.PrefixedKey(authSourceActiveRedisKey)).Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
pattern := db.PrefixedKey(authSourceByNameRedisPattern)
|
||||
iter := db.Redis.Scan(ctx, 0, pattern, 0).Iterator()
|
||||
var keys []string
|
||||
for iter.Next(ctx) {
|
||||
keys = append(keys, iter.Val())
|
||||
}
|
||||
if err := iter.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
if len(keys) > 0 {
|
||||
if err := db.Redis.Del(ctx, keys...).Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
publishAuthSourceRAMInvalidation(ctx)
|
||||
return nil
|
||||
}
|
||||
|
||||
// StopAuthSourceCacheListener stops the Redis Pub/Sub subscription listener and resets the sync.Once guard.
|
||||
func StopAuthSourceCacheListener() {
|
||||
if authSourceListenerCancel != nil {
|
||||
authSourceListenerCancel()
|
||||
authSourceListenerCancel = nil
|
||||
}
|
||||
authSourceListenerOnce = sync.Once{}
|
||||
}
|
||||
|
||||
// ResetAuthSourceRAMCacheForTest clears only the process-local RAM cache.
|
||||
func ResetAuthSourceRAMCacheForTest() {
|
||||
authSourceActiveRAM.InvalidateAll()
|
||||
authSourceByNameRAM.InvalidateAll()
|
||||
}
|
||||
@@ -0,0 +1,240 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/alicebob/miniredis/v2"
|
||||
"github.com/glebarez/sqlite"
|
||||
"github.com/redis/go-redis/v9"
|
||||
"github.com/redis/go-redis/v9/maintnotifications"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/Rain-kl/Wavelet/internal/db"
|
||||
"github.com/Rain-kl/Wavelet/internal/model"
|
||||
)
|
||||
|
||||
func setupAuthSourceCacheTest(t *testing.T) (*gorm.DB, *miniredis.Miniredis, func()) {
|
||||
t.Helper()
|
||||
|
||||
sqliteDB, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{
|
||||
DisableForeignKeyConstraintWhenMigrating: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("failed to open in-memory SQLite db: %v", err)
|
||||
}
|
||||
if err := sqliteDB.AutoMigrate(&model.AuthSource{}); err != nil {
|
||||
t.Fatalf("failed to migrate auth sources: %v", err)
|
||||
}
|
||||
|
||||
miniRedis, err := miniredis.Run()
|
||||
if err != nil {
|
||||
t.Fatalf("failed to start miniredis: %v", err)
|
||||
}
|
||||
|
||||
db.SetDB(sqliteDB)
|
||||
db.Redis = redis.NewClient(&redis.Options{
|
||||
Addr: miniRedis.Addr(),
|
||||
MaintNotificationsConfig: &maintnotifications.Config{
|
||||
Mode: maintnotifications.ModeDisabled,
|
||||
},
|
||||
})
|
||||
|
||||
ResetAuthSourceRAMCacheForTest()
|
||||
|
||||
cleanup := func() {
|
||||
StopAuthSourceCacheListener()
|
||||
ResetAuthSourceRAMCacheForTest()
|
||||
db.Redis.Close()
|
||||
miniRedis.Close()
|
||||
db.Redis = nil
|
||||
}
|
||||
return sqliteDB, miniRedis, cleanup
|
||||
}
|
||||
|
||||
func TestGetActiveAuthSourcesCached_LoadsFromRedisBeforeDB(t *testing.T) {
|
||||
dbConn, _, cleanup := setupAuthSourceCacheTest(t)
|
||||
defer cleanup()
|
||||
ctx := context.Background()
|
||||
|
||||
if err := InvalidateAuthSourceCache(ctx); err != nil {
|
||||
t.Fatalf("InvalidateAuthSourceCache() error = %v", err)
|
||||
}
|
||||
|
||||
source := model.AuthSource{
|
||||
Name: "cached-source",
|
||||
Type: model.AuthSourceTypeOIDC,
|
||||
DisplayName: "Cached Source",
|
||||
IsActive: true,
|
||||
ClientID: "client-id",
|
||||
ClientSecret: "client-secret",
|
||||
OpenIDDiscoveryURL: "https://issuer.example.com",
|
||||
}
|
||||
if err := model.CreateAuthSource(ctx, &source); err != nil {
|
||||
t.Fatalf("CreateAuthSource() error = %v", err)
|
||||
}
|
||||
|
||||
warmed, err := GetActiveAuthSourcesCached(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("GetActiveAuthSourcesCached() warm error = %v", err)
|
||||
}
|
||||
if len(warmed) == 0 || warmed[0].Name != source.Name {
|
||||
t.Fatalf("GetActiveAuthSourcesCached() warm = %#v, want source %q", warmed, source.Name)
|
||||
}
|
||||
|
||||
if err := dbConn.Delete(&model.AuthSource{}, "id = ?", source.ID).Error; err != nil {
|
||||
t.Fatalf("Delete(auth source) error = %v", err)
|
||||
}
|
||||
|
||||
ResetAuthSourceRAMCacheForTest()
|
||||
|
||||
cached, err := GetActiveAuthSourcesCached(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("GetActiveAuthSourcesCached() cached error = %v", err)
|
||||
}
|
||||
if len(cached) == 0 || cached[0].Name != source.Name {
|
||||
t.Fatalf("GetActiveAuthSourcesCached() = %#v, want redis-backed source %q", cached, source.Name)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetAuthSourceByNameCached_LoadsFromRedisBeforeDB(t *testing.T) {
|
||||
dbConn, _, cleanup := setupAuthSourceCacheTest(t)
|
||||
defer cleanup()
|
||||
ctx := context.Background()
|
||||
|
||||
if err := InvalidateAuthSourceCache(ctx); err != nil {
|
||||
t.Fatalf("InvalidateAuthSourceCache() error = %v", err)
|
||||
}
|
||||
|
||||
source := model.AuthSource{
|
||||
Name: "by-name-source",
|
||||
Type: model.AuthSourceTypeOIDC,
|
||||
DisplayName: "By Name Source",
|
||||
IsActive: true,
|
||||
ClientID: "client-id",
|
||||
ClientSecret: "client-secret",
|
||||
OpenIDDiscoveryURL: "https://issuer.example.com",
|
||||
}
|
||||
if err := model.CreateAuthSource(ctx, &source); err != nil {
|
||||
t.Fatalf("CreateAuthSource() error = %v", err)
|
||||
}
|
||||
|
||||
warmed, err := GetAuthSourceByNameCached(ctx, source.Name)
|
||||
if err != nil {
|
||||
t.Fatalf("GetAuthSourceByNameCached() warm error = %v", err)
|
||||
}
|
||||
if warmed.Name != source.Name || warmed.ClientSecret != source.ClientSecret {
|
||||
t.Fatalf("GetAuthSourceByNameCached() warm = %#v, want %#v", warmed, source)
|
||||
}
|
||||
|
||||
if err := dbConn.Delete(&model.AuthSource{}, "id = ?", source.ID).Error; err != nil {
|
||||
t.Fatalf("Delete(auth source) error = %v", err)
|
||||
}
|
||||
|
||||
ResetAuthSourceRAMCacheForTest()
|
||||
|
||||
cached, err := GetAuthSourceByNameCached(ctx, source.Name)
|
||||
if err != nil {
|
||||
t.Fatalf("GetAuthSourceByNameCached() cached error = %v", err)
|
||||
}
|
||||
if cached.Name != source.Name || cached.ClientSecret != source.ClientSecret {
|
||||
t.Fatalf("GetAuthSourceByNameCached() = %#v, want redis-backed source %#v", cached, source)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInvalidateAuthSourceCache_ClearsRedisKeys(t *testing.T) {
|
||||
_, _, cleanup := setupAuthSourceCacheTest(t)
|
||||
defer cleanup()
|
||||
ctx := context.Background()
|
||||
|
||||
if err := InvalidateAuthSourceCache(ctx); err != nil {
|
||||
t.Fatalf("InvalidateAuthSourceCache() initial error = %v", err)
|
||||
}
|
||||
|
||||
source := model.AuthSource{
|
||||
Name: "invalidate-source",
|
||||
Type: model.AuthSourceTypeOIDC,
|
||||
DisplayName: "Invalidate Source",
|
||||
IsActive: true,
|
||||
ClientID: "client-id",
|
||||
ClientSecret: "client-secret",
|
||||
OpenIDDiscoveryURL: "https://issuer.example.com",
|
||||
}
|
||||
if err := model.CreateAuthSource(ctx, &source); err != nil {
|
||||
t.Fatalf("CreateAuthSource() error = %v", err)
|
||||
}
|
||||
if _, err := GetActiveAuthSourcesCached(ctx); err != nil {
|
||||
t.Fatalf("GetActiveAuthSourcesCached() warm error = %v", err)
|
||||
}
|
||||
if _, err := GetAuthSourceByNameCached(ctx, source.Name); err != nil {
|
||||
t.Fatalf("GetAuthSourceByNameCached() warm error = %v", err)
|
||||
}
|
||||
|
||||
if err := InvalidateAuthSourceCache(ctx); err != nil {
|
||||
t.Fatalf("InvalidateAuthSourceCache() error = %v", err)
|
||||
}
|
||||
|
||||
activeExists, err := db.Redis.Exists(ctx, db.PrefixedKey(authSourceActiveRedisKey)).Result()
|
||||
if err != nil {
|
||||
t.Fatalf("Exists(active key) error = %v", err)
|
||||
}
|
||||
if activeExists != 0 {
|
||||
t.Fatalf("active redis key still exists after invalidation")
|
||||
}
|
||||
|
||||
byNameExists, err := db.Redis.Exists(ctx, db.PrefixedKey(authSourceByNameRedisKey(source.Name))).Result()
|
||||
if err != nil {
|
||||
t.Fatalf("Exists(by-name key) error = %v", err)
|
||||
}
|
||||
if byNameExists != 0 {
|
||||
t.Fatalf("by-name redis key still exists after invalidation")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthSourceInvalidationPubSubClearsPeerRAM(t *testing.T) {
|
||||
dbConn, _, cleanup := setupAuthSourceCacheTest(t)
|
||||
defer cleanup()
|
||||
ctx := context.Background()
|
||||
|
||||
source := model.AuthSource{
|
||||
Name: "pubsub-source",
|
||||
Type: model.AuthSourceTypeOIDC,
|
||||
DisplayName: "PubSub Source",
|
||||
IsActive: true,
|
||||
ClientID: "client-id",
|
||||
ClientSecret: "client-secret",
|
||||
OpenIDDiscoveryURL: "https://issuer.example.com",
|
||||
}
|
||||
if err := model.CreateAuthSource(ctx, &source); err != nil {
|
||||
t.Fatalf("CreateAuthSource() error = %v", err)
|
||||
}
|
||||
|
||||
if _, err := GetActiveAuthSourcesCached(ctx); err != nil {
|
||||
t.Fatalf("GetActiveAuthSourcesCached() error = %v", err)
|
||||
}
|
||||
if err := dbConn.Delete(&model.AuthSource{}, "id = ?", source.ID).Error; err != nil {
|
||||
t.Fatalf("Delete(auth source) error = %v", err)
|
||||
}
|
||||
if _, err := GetActiveAuthSourcesCached(ctx); err != nil {
|
||||
t.Fatalf("expected RAM cache hit before pub/sub invalidation: %v", err)
|
||||
}
|
||||
|
||||
if err := db.Redis.Publish(ctx, authSourceInvalidationChannel, "reset").Err(); err != nil {
|
||||
t.Fatalf("publish invalidation: %v", err)
|
||||
}
|
||||
|
||||
deadline := time.Now().Add(500 * time.Millisecond)
|
||||
for time.Now().Before(deadline) {
|
||||
if _, ok := authSourceActiveRAM.GetIfPresent(authSourceActiveRAMKey); !ok {
|
||||
break
|
||||
}
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
if _, ok := authSourceActiveRAM.GetIfPresent(authSourceActiveRAMKey); ok {
|
||||
t.Fatal("expected peer RAM cache to be cleared by pub/sub")
|
||||
}
|
||||
}
|
||||
@@ -75,6 +75,22 @@ func ListSystemConfigsByKeys(ctx context.Context, keys []string) (map[string]mod
|
||||
missing = append(missing, key)
|
||||
}
|
||||
|
||||
if len(missing) > 0 && db.Redis != nil {
|
||||
stillMissing := make([]string, 0, len(missing))
|
||||
for _, key := range missing {
|
||||
var sc model.SystemConfig
|
||||
if err := db.HGetJSON(ctx, SystemConfigRedisHashKey, key, &sc); err == nil {
|
||||
systemConfigRAMCache.Set(key, cloneSystemConfig(sc))
|
||||
result[key] = sc
|
||||
continue
|
||||
} else if !errors.Is(err, redis.Nil) {
|
||||
return nil, err
|
||||
}
|
||||
stillMissing = append(stillMissing, key)
|
||||
}
|
||||
missing = stillMissing
|
||||
}
|
||||
|
||||
if len(missing) == 0 {
|
||||
return result, nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/Rain-kl/Wavelet/internal/db"
|
||||
"github.com/Rain-kl/Wavelet/internal/model"
|
||||
"github.com/alicebob/miniredis/v2"
|
||||
"github.com/glebarez/sqlite"
|
||||
"github.com/redis/go-redis/v9"
|
||||
"github.com/redis/go-redis/v9/maintnotifications"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func setupSystemConfigTest(t *testing.T) (*gorm.DB, func()) {
|
||||
t.Helper()
|
||||
|
||||
sqliteDB, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{
|
||||
DisableForeignKeyConstraintWhenMigrating: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("gorm.Open(sqlite) error = %v", err)
|
||||
}
|
||||
if err := sqliteDB.AutoMigrate(&model.SystemConfig{}); err != nil {
|
||||
t.Fatalf("AutoMigrate(SystemConfig) error = %v", err)
|
||||
}
|
||||
|
||||
siteConfig := model.SystemConfig{
|
||||
Key: model.ConfigKeySiteName,
|
||||
Value: "Wavelet",
|
||||
Type: "system",
|
||||
Description: "系统平台的展示名称",
|
||||
}
|
||||
if err := sqliteDB.Create(&siteConfig).Error; err != nil {
|
||||
t.Fatalf("Create(site_name) error = %v", err)
|
||||
}
|
||||
|
||||
mr, err := miniredis.Run()
|
||||
if err != nil {
|
||||
t.Fatalf("miniredis.Run() error = %v", err)
|
||||
}
|
||||
redisClient := redis.NewClient(&redis.Options{
|
||||
Addr: mr.Addr(),
|
||||
MaintNotificationsConfig: &maintnotifications.Config{
|
||||
Mode: maintnotifications.ModeDisabled,
|
||||
},
|
||||
})
|
||||
|
||||
previousRedis := db.Redis
|
||||
db.SetDB(sqliteDB)
|
||||
db.Redis = redisClient
|
||||
|
||||
cleanup := func() {
|
||||
StopSystemConfigCacheListener()
|
||||
ResetSystemConfigRAMCacheForTest()
|
||||
db.SetDB(nil)
|
||||
db.Redis = previousRedis
|
||||
_ = redisClient.Close()
|
||||
mr.Close()
|
||||
}
|
||||
|
||||
return sqliteDB, cleanup
|
||||
}
|
||||
|
||||
func TestListSystemConfigsByKeys_EmptyKeys(t *testing.T) {
|
||||
result, err := ListSystemConfigsByKeys(context.Background(), nil)
|
||||
if err != nil {
|
||||
t.Fatalf("ListSystemConfigsByKeys(nil) error = %v", err)
|
||||
}
|
||||
if len(result) != 0 {
|
||||
t.Fatalf("ListSystemConfigsByKeys(nil) = %#v, want empty map", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListSystemConfigsByKeys_LoadsFromRedisBeforeDB(t *testing.T) {
|
||||
dbConn, cleanup := setupSystemConfigTest(t)
|
||||
defer cleanup()
|
||||
ctx := context.Background()
|
||||
|
||||
ResetSystemConfigRAMCacheForTest()
|
||||
if err := InvalidateAllSystemConfigCaches(ctx); err != nil {
|
||||
t.Fatalf("InvalidateAllSystemConfigCaches() error = %v", err)
|
||||
}
|
||||
|
||||
warm, err := GetSystemConfigByKey(ctx, model.ConfigKeySiteName)
|
||||
if err != nil {
|
||||
t.Fatalf("GetSystemConfigByKey(site_name) warm error = %v", err)
|
||||
}
|
||||
if warm.Value != "Wavelet" {
|
||||
t.Fatalf("GetSystemConfigByKey(site_name).Value = %q, want %q", warm.Value, "Wavelet")
|
||||
}
|
||||
|
||||
if err := dbConn.Model(&model.SystemConfig{}).
|
||||
Where("key = ?", model.ConfigKeySiteName).
|
||||
Update("value", "db_only_value").Error; err != nil {
|
||||
t.Fatalf("Update(site_name) error = %v", err)
|
||||
}
|
||||
|
||||
ResetSystemConfigRAMCacheForTest()
|
||||
|
||||
configs, err := ListSystemConfigsByKeys(ctx, []string{model.ConfigKeySiteName})
|
||||
if err != nil {
|
||||
t.Fatalf("ListSystemConfigsByKeys(site_name) error = %v", err)
|
||||
}
|
||||
|
||||
sc, ok := configs[model.ConfigKeySiteName]
|
||||
if !ok {
|
||||
t.Fatal("ListSystemConfigsByKeys(site_name) missing site_name entry")
|
||||
}
|
||||
if sc.Value != "Wavelet" {
|
||||
t.Fatalf("ListSystemConfigsByKeys(site_name).Value = %q, want redis value %q", sc.Value, "Wavelet")
|
||||
}
|
||||
}
|
||||
|
||||
func TestListSystemConfigsByKeys_PopulatesRAMFromRedis(t *testing.T) {
|
||||
_, cleanup := setupSystemConfigTest(t)
|
||||
defer cleanup()
|
||||
ctx := context.Background()
|
||||
|
||||
ResetSystemConfigRAMCacheForTest()
|
||||
if err := InvalidateAllSystemConfigCaches(ctx); err != nil {
|
||||
t.Fatalf("InvalidateAllSystemConfigCaches() error = %v", err)
|
||||
}
|
||||
|
||||
if _, err := GetSystemConfigByKey(ctx, model.ConfigKeySiteName); err != nil {
|
||||
t.Fatalf("GetSystemConfigByKey(site_name) warm error = %v", err)
|
||||
}
|
||||
|
||||
ResetSystemConfigRAMCacheForTest()
|
||||
|
||||
if _, err := ListSystemConfigsByKeys(ctx, []string{model.ConfigKeySiteName}); err != nil {
|
||||
t.Fatalf("ListSystemConfigsByKeys(site_name) error = %v", err)
|
||||
}
|
||||
|
||||
cached, ok := systemConfigRAMCache.GetIfPresent(model.ConfigKeySiteName)
|
||||
if !ok {
|
||||
t.Fatal("expected RAM cache to be populated after redis hit")
|
||||
}
|
||||
if cached.Value != "Wavelet" {
|
||||
t.Fatalf("RAM cache value = %q, want %q", cached.Value, "Wavelet")
|
||||
}
|
||||
}
|
||||
@@ -65,7 +65,12 @@ func GetActiveUploadByID(ctx context.Context, id uint64) (model.Upload, error) {
|
||||
// SoftDeleteUpload marks an upload as deleted.
|
||||
// External modules must use upload.Remove or upload.RemoveOwned; only internal/apps/upload may call this.
|
||||
func SoftDeleteUpload(ctx context.Context, upload *model.Upload) error {
|
||||
return db.DB(ctx).Model(upload).Update("status", model.UploadStatusDeleted).Error
|
||||
return SoftDeleteUploadTx(db.DB(ctx), upload)
|
||||
}
|
||||
|
||||
// SoftDeleteUploadTx marks an upload as deleted within an existing transaction.
|
||||
func SoftDeleteUploadTx(tx *gorm.DB, upload *model.Upload) error {
|
||||
return tx.Model(upload).Update("status", model.UploadStatusDeleted).Error
|
||||
}
|
||||
|
||||
// UpdateUpload applies partial field updates to an upload record.
|
||||
@@ -100,7 +105,12 @@ func FindReusableUploadByHash(ctx context.Context, hash string, size int64) (mod
|
||||
// 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 db.DB(ctx).Create(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.
|
||||
|
||||
Reference in New Issue
Block a user