Files
OpenFlare/internal/repository/auth_source_cache.go
T
ryan 2f60329886 后端与全仓代码质量清理(golangci 扩展集 · 测试质量 · 并发安全 · 文档同步)
代码质量全量清理,零行为变化:golangci 扩展集 13 类 linter(gosec/modernize/perfsprint/canonicalheader/usestdlibvars/wastedassign/intrange/errorlint/forcetypeassert/recvcheck/exhaustive/unparam)全量修复,测试代码质量(testifylint/thelper/usetesting)25→0,frpc 进程生命周期真 bug(进程组击杀)、全仓 go test -race 6 类数据竞争(含 1 个生产竞争)、SPDX license 头补齐 131 文件、前端测试套件 next-intl 迁移后 44 失败→全绿、过期 swagger 文档重新生成、pnpm-workspace 构建审批。

Experiments: #2-#17, #18, #20, #21, #23
Metric: total_issues 108 → 8 (-92.6%)
2026-08-16 21:24:14 +08:00

279 lines
8.3 KiB
Go

// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package repository
import (
"context"
"fmt"
"strings"
"sync"
"time"
db "github.com/Rain-kl/Wavelet/internal/infra/persistence"
"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
authSourceListenerDone chan struct{}
)
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())
authSourceListenerDone = make(chan struct{})
go func() {
listenerCtx := authSourceListenerCtx
defer close(authSourceListenerDone)
pubsub := db.Redis.Subscribe(listenerCtx, authSourceInvalidationChannel)
defer func() {
_ = pubsub.Close()
}()
go func() {
<-listenerCtx.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 := 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 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 := 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()
if authSourceListenerDone != nil {
<-authSourceListenerDone
}
authSourceListenerCancel = nil
authSourceListenerDone = nil
}
authSourceListenerOnce = sync.Once{}
}
// ResetAuthSourceRAMCacheForTest clears only the process-local RAM cache.
func ResetAuthSourceRAMCacheForTest() {
authSourceActiveRAM.InvalidateAll()
authSourceByNameRAM.InvalidateAll()
}