diff --git a/internal/apps/admin/logs/routers.go b/internal/apps/admin/logs/routers.go index f3bdf71f..c2d4acb7 100644 --- a/internal/apps/admin/logs/routers.go +++ b/internal/apps/admin/logs/routers.go @@ -18,9 +18,9 @@ import ( "github.com/Rain-kl/Wavelet/internal/apps/admin" "github.com/Rain-kl/Wavelet/internal/config" "github.com/Rain-kl/Wavelet/internal/db" - "github.com/Rain-kl/Wavelet/internal/logger" "github.com/Rain-kl/Wavelet/internal/model" "github.com/Rain-kl/Wavelet/internal/util" + "github.com/Rain-kl/Wavelet/pkg/logger" "github.com/gin-gonic/gin" ) diff --git a/internal/apps/admin/middlewares.go b/internal/apps/admin/middlewares.go index b1154a26..06856557 100644 --- a/internal/apps/admin/middlewares.go +++ b/internal/apps/admin/middlewares.go @@ -7,10 +7,10 @@ package admin import ( "net/http" - "github.com/Rain-kl/Wavelet/internal/logger" "github.com/Rain-kl/Wavelet/internal/model" - "github.com/Rain-kl/Wavelet/internal/otel_trace" "github.com/Rain-kl/Wavelet/internal/util" + "github.com/Rain-kl/Wavelet/pkg/logger" + otel_trace "github.com/Rain-kl/Wavelet/pkg/trace" "github.com/Rain-kl/Wavelet/internal/apps/oauth" "github.com/gin-gonic/gin" diff --git a/internal/apps/admin/push/events.go b/internal/apps/admin/push/events.go index b9b95355..1fef316f 100644 --- a/internal/apps/admin/push/events.go +++ b/internal/apps/admin/push/events.go @@ -14,9 +14,9 @@ import ( "sync" "github.com/Rain-kl/Wavelet/internal/db" - "github.com/Rain-kl/Wavelet/internal/logger" "github.com/Rain-kl/Wavelet/internal/model" "github.com/Rain-kl/Wavelet/internal/task" + "github.com/Rain-kl/Wavelet/pkg/logger" pkgpush "github.com/Rain-kl/Wavelet/pkg/push" "gorm.io/gorm" ) diff --git a/internal/apps/admin/push/task_listener.go b/internal/apps/admin/push/task_listener.go index c6e6482f..39fbcd69 100644 --- a/internal/apps/admin/push/task_listener.go +++ b/internal/apps/admin/push/task_listener.go @@ -10,9 +10,9 @@ import ( "time" "github.com/Rain-kl/Wavelet/internal/db" - "github.com/Rain-kl/Wavelet/internal/logger" "github.com/Rain-kl/Wavelet/internal/model" "github.com/Rain-kl/Wavelet/internal/task" + "github.com/Rain-kl/Wavelet/pkg/logger" ) func init() { diff --git a/internal/apps/admin/system_config/routers.go b/internal/apps/admin/system_config/routers.go index 79927d57..12b8d500 100644 --- a/internal/apps/admin/system_config/routers.go +++ b/internal/apps/admin/system_config/routers.go @@ -13,11 +13,11 @@ import ( "time" "github.com/Rain-kl/Wavelet/internal/db" - "github.com/Rain-kl/Wavelet/internal/logger" "github.com/Rain-kl/Wavelet/internal/model" "github.com/Rain-kl/Wavelet/internal/storage" "github.com/Rain-kl/Wavelet/internal/util" - mail "github.com/Rain-kl/Wavelet/internal/util/mail" + "github.com/Rain-kl/Wavelet/pkg/logger" + mail "github.com/Rain-kl/Wavelet/pkg/mail" "github.com/gin-gonic/gin" "gorm.io/gorm" ) diff --git a/internal/apps/admin/task/routers.go b/internal/apps/admin/task/routers.go index a77ce730..76cf8ae0 100644 --- a/internal/apps/admin/task/routers.go +++ b/internal/apps/admin/task/routers.go @@ -12,12 +12,12 @@ import ( "time" "github.com/Rain-kl/Wavelet/internal/apps/admin" - "github.com/Rain-kl/Wavelet/internal/logger" "github.com/Rain-kl/Wavelet/internal/model" "github.com/Rain-kl/Wavelet/internal/task" taskhandlers "github.com/Rain-kl/Wavelet/internal/task/handlers" "github.com/Rain-kl/Wavelet/internal/task/scheduler" "github.com/Rain-kl/Wavelet/internal/util" + "github.com/Rain-kl/Wavelet/pkg/logger" "github.com/gin-gonic/gin" "github.com/robfig/cron/v3" ) diff --git a/internal/apps/admin/updater/logics.go b/internal/apps/admin/updater/logics.go index 88d79297..0dc97b92 100644 --- a/internal/apps/admin/updater/logics.go +++ b/internal/apps/admin/updater/logics.go @@ -22,8 +22,8 @@ import ( "time" "github.com/Rain-kl/Wavelet/internal/buildinfo" - "github.com/Rain-kl/Wavelet/internal/logger" "github.com/Rain-kl/Wavelet/internal/model" + "github.com/Rain-kl/Wavelet/pkg/logger" "golang.org/x/mod/semver" ) diff --git a/internal/apps/admin/updater/restart_unix.go b/internal/apps/admin/updater/restart_unix.go index a6d021da..24d94c6c 100644 --- a/internal/apps/admin/updater/restart_unix.go +++ b/internal/apps/admin/updater/restart_unix.go @@ -12,7 +12,7 @@ import ( "path/filepath" "syscall" - "github.com/Rain-kl/Wavelet/internal/logger" + "github.com/Rain-kl/Wavelet/pkg/logger" ) const installedBinaryMode = 0o755 diff --git a/internal/apps/admin/updater/routers.go b/internal/apps/admin/updater/routers.go index 59869ea8..f359d0af 100644 --- a/internal/apps/admin/updater/routers.go +++ b/internal/apps/admin/updater/routers.go @@ -8,8 +8,8 @@ import ( "net/http" "time" - "github.com/Rain-kl/Wavelet/internal/logger" "github.com/Rain-kl/Wavelet/internal/util" + "github.com/Rain-kl/Wavelet/pkg/logger" "github.com/gin-gonic/gin" ) diff --git a/internal/apps/cap/middleware.go b/internal/apps/cap/middleware.go index 527a4c1c..cf55d7af 100644 --- a/internal/apps/cap/middleware.go +++ b/internal/apps/cap/middleware.go @@ -7,7 +7,7 @@ import ( "net/http" "github.com/Rain-kl/Wavelet/internal/util" - caputil "github.com/Rain-kl/Wavelet/internal/util/cap" + caputil "github.com/Rain-kl/Wavelet/internal/service/cap" "github.com/gin-gonic/gin" ) diff --git a/internal/apps/cap/routers.go b/internal/apps/cap/routers.go index 5070975f..1ed80974 100644 --- a/internal/apps/cap/routers.go +++ b/internal/apps/cap/routers.go @@ -6,7 +6,7 @@ package cap import ( "net/http" - "github.com/Rain-kl/Wavelet/internal/util/cap" + capService "github.com/Rain-kl/Wavelet/internal/service/cap" "github.com/gin-gonic/gin" ) @@ -38,10 +38,10 @@ func Challenge(c *gin.Context) { req.Scope = "login" } - mgr := cap.GetDefaultManager() + mgr := capService.GetDefaultManager() resp, err := mgr.Generate(c.Request.Context(), req.Scope) if err != nil { - c.JSON(http.StatusInternalServerError, cap.RedeemResponse{ + c.JSON(http.StatusInternalServerError, capService.RedeemResponse{ Success: false, Error: err.Error(), }) @@ -65,7 +65,7 @@ func Challenge(c *gin.Context) { func Redeem(c *gin.Context) { var req redeemRequest if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, cap.RedeemResponse{ + c.JSON(http.StatusBadRequest, capService.RedeemResponse{ Success: false, Error: "无效的参数", }) @@ -76,10 +76,10 @@ func Redeem(c *gin.Context) { req.Scope = "login" } - mgr := cap.GetDefaultManager() + mgr := capService.GetDefaultManager() resp, err := mgr.Redeem(c.Request.Context(), req.Token, req.Solutions, req.Scope) if err != nil { - c.JSON(http.StatusInternalServerError, cap.RedeemResponse{ + c.JSON(http.StatusInternalServerError, capService.RedeemResponse{ Success: false, Error: err.Error(), }) diff --git a/internal/apps/cap/routers_test.go b/internal/apps/cap/routers_test.go index 07335a95..44f2b41e 100644 --- a/internal/apps/cap/routers_test.go +++ b/internal/apps/cap/routers_test.go @@ -15,7 +15,8 @@ import ( "github.com/Rain-kl/Wavelet/internal/model" "github.com/Rain-kl/Wavelet/internal/testhelper" "github.com/Rain-kl/Wavelet/internal/util" - capUtil "github.com/Rain-kl/Wavelet/internal/util/cap" + capUtil "github.com/Rain-kl/Wavelet/internal/service/cap" + pkgcap "github.com/Rain-kl/Wavelet/pkg/cap" "github.com/gin-gonic/gin" ) @@ -53,7 +54,7 @@ func TestCapEndpointsAndMiddleware(t *testing.T) { t.Fatalf("expected 200 OK, got %d. Body: %s", w.Code, w.Body.String()) } - var challengeResp capUtil.ChallengeResponse + var challengeResp pkgcap.ChallengeResponse if err := json.Unmarshal(w.Body.Bytes(), &challengeResp); err != nil { t.Fatalf("failed to unmarshal challenge response: %v", err) } @@ -89,7 +90,7 @@ func TestCapEndpointsAndMiddleware(t *testing.T) { } // 5. Solve the challenge - solutions := capUtil.Solve(challengeResp.Token, challengeResp.Challenge.C, challengeResp.Challenge.S, challengeResp.Challenge.D) + solutions := pkgcap.Solve(challengeResp.Token, challengeResp.Challenge.C, challengeResp.Challenge.S, challengeResp.Challenge.D) // 6. Redeem solutions redeemReqPayload := redeemRequest{ diff --git a/internal/apps/oauth/audit.go b/internal/apps/oauth/audit.go index 909f0c75..5f5fcede 100644 --- a/internal/apps/oauth/audit.go +++ b/internal/apps/oauth/audit.go @@ -9,8 +9,8 @@ import ( "context" "encoding/json" - "github.com/Rain-kl/Wavelet/internal/logger" "github.com/Rain-kl/Wavelet/internal/model" + "github.com/Rain-kl/Wavelet/pkg/logger" "github.com/gin-gonic/gin" ) diff --git a/internal/apps/oauth/middlewares.go b/internal/apps/oauth/middlewares.go index 7b2f496f..8f4a4f68 100644 --- a/internal/apps/oauth/middlewares.go +++ b/internal/apps/oauth/middlewares.go @@ -12,8 +12,8 @@ import ( "github.com/Rain-kl/Wavelet/internal/common" "github.com/Rain-kl/Wavelet/internal/db" "github.com/Rain-kl/Wavelet/internal/model" - "github.com/Rain-kl/Wavelet/internal/otel_trace" "github.com/Rain-kl/Wavelet/internal/util" + otel_trace "github.com/Rain-kl/Wavelet/pkg/trace" "github.com/gin-contrib/sessions" "github.com/gin-gonic/gin" ) diff --git a/internal/apps/oauth/routers.go b/internal/apps/oauth/routers.go index a0432b46..1de779cd 100644 --- a/internal/apps/oauth/routers.go +++ b/internal/apps/oauth/routers.go @@ -7,9 +7,9 @@ package oauth import ( "net/http" - "github.com/Rain-kl/Wavelet/internal/logger" "github.com/Rain-kl/Wavelet/internal/model" "github.com/Rain-kl/Wavelet/internal/util" + "github.com/Rain-kl/Wavelet/pkg/logger" "github.com/gin-contrib/sessions" "github.com/gin-gonic/gin" ) diff --git a/internal/apps/oauth/sources.go b/internal/apps/oauth/sources.go index 15086ea9..2f77c504 100644 --- a/internal/apps/oauth/sources.go +++ b/internal/apps/oauth/sources.go @@ -18,9 +18,9 @@ import ( "github.com/Rain-kl/Wavelet/internal/common" "github.com/Rain-kl/Wavelet/internal/config" "github.com/Rain-kl/Wavelet/internal/db" - "github.com/Rain-kl/Wavelet/internal/logger" "github.com/Rain-kl/Wavelet/internal/model" "github.com/Rain-kl/Wavelet/internal/util" + "github.com/Rain-kl/Wavelet/pkg/logger" "github.com/coreos/go-oidc/v3/oidc" "github.com/gin-contrib/sessions" "github.com/gin-gonic/gin" diff --git a/internal/apps/risk_control/logics.go b/internal/apps/risk_control/logics.go index ba6dbfa1..7e409d2d 100644 --- a/internal/apps/risk_control/logics.go +++ b/internal/apps/risk_control/logics.go @@ -9,7 +9,7 @@ import ( "github.com/Rain-kl/Wavelet/internal/config" "github.com/Rain-kl/Wavelet/internal/db" - "github.com/Rain-kl/Wavelet/internal/logger" + "github.com/Rain-kl/Wavelet/pkg/logger" ) var logChan chan *UserAccessLog diff --git a/internal/apps/upload/file_server.go b/internal/apps/upload/file_server.go index fed744bf..639573fe 100644 --- a/internal/apps/upload/file_server.go +++ b/internal/apps/upload/file_server.go @@ -19,9 +19,9 @@ import ( "github.com/Rain-kl/Wavelet/internal/common" "github.com/Rain-kl/Wavelet/internal/db" "github.com/Rain-kl/Wavelet/internal/diskcache" - "github.com/Rain-kl/Wavelet/internal/logger" "github.com/Rain-kl/Wavelet/internal/model" "github.com/Rain-kl/Wavelet/internal/util" + "github.com/Rain-kl/Wavelet/pkg/logger" "github.com/gin-gonic/gin" "gorm.io/gorm" ) diff --git a/internal/apps/upload/routers.go b/internal/apps/upload/routers.go index 2b9e8016..c0191bb3 100644 --- a/internal/apps/upload/routers.go +++ b/internal/apps/upload/routers.go @@ -26,10 +26,10 @@ import ( "github.com/Rain-kl/Wavelet/internal/common" "github.com/Rain-kl/Wavelet/internal/db" "github.com/Rain-kl/Wavelet/internal/db/idgen" - "github.com/Rain-kl/Wavelet/internal/logger" "github.com/Rain-kl/Wavelet/internal/model" "github.com/Rain-kl/Wavelet/internal/storage" "github.com/Rain-kl/Wavelet/internal/util" + "github.com/Rain-kl/Wavelet/pkg/logger" "github.com/gin-gonic/gin" "gorm.io/gorm" ) diff --git a/internal/apps/upload/storage_ops.go b/internal/apps/upload/storage_ops.go index 6e5f170a..041340ed 100644 --- a/internal/apps/upload/storage_ops.go +++ b/internal/apps/upload/storage_ops.go @@ -7,9 +7,9 @@ import ( "context" "fmt" - "github.com/Rain-kl/Wavelet/internal/logger" "github.com/Rain-kl/Wavelet/internal/model" "github.com/Rain-kl/Wavelet/internal/storage" + "github.com/Rain-kl/Wavelet/pkg/logger" ) // StorageReadOnly checks if the storage system is in read-only maintenance mode. diff --git a/internal/apps/user/logics.go b/internal/apps/user/logics.go index edb03a76..8a1f2c32 100644 --- a/internal/apps/user/logics.go +++ b/internal/apps/user/logics.go @@ -20,6 +20,7 @@ import ( "github.com/Rain-kl/Wavelet/internal/model" "github.com/Rain-kl/Wavelet/internal/task" "github.com/Rain-kl/Wavelet/internal/util" + pkgu "github.com/Rain-kl/Wavelet/pkg/util" "github.com/gin-gonic/gin" ) @@ -171,7 +172,7 @@ func handleLoginEmailVerification(ctx context.Context, c *gin.Context, req *logi } } - maskedEmail := util.MaskEmail(user.Email) + maskedEmail := pkgu.MaskEmail(user.Email) c.JSON(http.StatusOK, util.Err(errNeedEmailCodePrefix+maskedEmail)) return errors.New("handled") } diff --git a/internal/apps/user/routers.go b/internal/apps/user/routers.go index 8141269f..d0797efa 100644 --- a/internal/apps/user/routers.go +++ b/internal/apps/user/routers.go @@ -15,9 +15,9 @@ import ( "github.com/Rain-kl/Wavelet/internal/config" "github.com/Rain-kl/Wavelet/internal/db" "github.com/Rain-kl/Wavelet/internal/db/idgen" - "github.com/Rain-kl/Wavelet/internal/logger" "github.com/Rain-kl/Wavelet/internal/model" "github.com/Rain-kl/Wavelet/internal/util" + "github.com/Rain-kl/Wavelet/pkg/logger" "github.com/gin-contrib/sessions" "github.com/gin-gonic/gin" ) diff --git a/internal/apps/user/tasks.go b/internal/apps/user/tasks.go index 0c26a216..99aafdac 100644 --- a/internal/apps/user/tasks.go +++ b/internal/apps/user/tasks.go @@ -13,7 +13,7 @@ import ( "github.com/Rain-kl/Wavelet/internal/model" "github.com/Rain-kl/Wavelet/internal/task" - "github.com/Rain-kl/Wavelet/internal/util/mail" + "github.com/Rain-kl/Wavelet/pkg/mail" ) // 异步任务名称与管理类型定义 diff --git a/internal/cmd/root.go b/internal/cmd/root.go index d8d8ede1..60e10b12 100644 --- a/internal/cmd/root.go +++ b/internal/cmd/root.go @@ -8,12 +8,31 @@ import ( "log" "github.com/Rain-kl/Wavelet/internal/buildinfo" + "github.com/Rain-kl/Wavelet/internal/config" "github.com/Rain-kl/Wavelet/internal/db/migrator" + "github.com/Rain-kl/Wavelet/pkg/logger" + "github.com/Rain-kl/Wavelet/pkg/trace" "github.com/spf13/cobra" ) var rootCmd = &cobra.Command{ Use: "wavelet", + PersistentPreRun: func(_ *cobra.Command, _ []string) { + logger.Init(logger.Config{ + Level: config.Config.Log.Level, + Format: config.Config.Log.Format, + Output: config.Config.Log.Output, + FilePath: config.Config.Log.FilePath, + MaxSize: config.Config.Log.MaxSize, + MaxAge: config.Config.Log.MaxAge, + MaxBackups: config.Config.Log.MaxBackups, + Compress: config.Config.Log.Compress, + }) + trace.Init(trace.Config{ + AppName: config.Config.App.AppName, + SamplingRate: config.Config.Otel.SamplingRate, + }) + }, PreRun: func(_ *cobra.Command, _ []string) { migrator.Migrate() }, diff --git a/internal/db/postgres_logger.go b/internal/db/postgres_logger.go index c5314c26..eb7964d9 100644 --- a/internal/db/postgres_logger.go +++ b/internal/db/postgres_logger.go @@ -11,7 +11,7 @@ import ( "strings" "time" - "github.com/Rain-kl/Wavelet/internal/logger" + "github.com/Rain-kl/Wavelet/pkg/logger" "gorm.io/gorm" gormLogger "gorm.io/gorm/logger" ) diff --git a/internal/diskcache/cache.go b/internal/diskcache/cache.go index 8942053e..1ad50ac6 100644 --- a/internal/diskcache/cache.go +++ b/internal/diskcache/cache.go @@ -1,261 +1,68 @@ // Copyright 2026 Arctel.net // SPDX-License-Identifier: Apache-2.0 -// Package diskcache implements a platform-level disk-backed cache with size limit, TTL, and LRU eviction. +// Package diskcache wraps the generic pkg/diskcache to provide database configuration integration. package diskcache import ( - "container/list" "context" - "encoding/binary" - "errors" - "fmt" - "os" - "path/filepath" - "sort" "strconv" - "strings" "sync" "time" "github.com/Rain-kl/Wavelet/internal/db" "github.com/Rain-kl/Wavelet/internal/model" - "github.com/peterbourgon/diskv/v3" + pkgcache "github.com/Rain-kl/Wavelet/pkg/diskcache" ) -// ErrCacheMiss represents a cache miss. -var ErrCacheMiss = errors.New("cache miss") +// Status represents the runtime cache statistics. +type Status = pkgcache.Status -// Constants for disk cache configuration and sizing const ( defaultCacheDir = "uploads/diskcache" - headerSize = 8 // 8 bytes metadata prefix for expiration UnixNano timestamp defaultMaxSizeMB = 100 defaultTTLMinutes = 60 defaultCleanupInterval = 10 // DefaultExpiration applies the cache-wide default TTL. - DefaultExpiration time.Duration = 0 + DefaultExpiration = pkgcache.DefaultExpiration // NoExpiration stores the item without a TTL. Size limits and LRU eviction still apply. - NoExpiration time.Duration = -1 + NoExpiration = pkgcache.NoExpiration ) +// ErrCacheMiss represents a cache miss. +var ErrCacheMiss = pkgcache.ErrCacheMiss + +// DiskCache is a wrapper around the generic pkg/diskcache that integrates with the DB for configs. +type DiskCache struct { + *pkgcache.DiskCache +} + var ( globalCache *DiskCache globalCacheOnce sync.Once ) -// Status represents the runtime cache statistics. -type Status struct { - TotalSize int64 `json:"total_size"` - KeysCount int `json:"keys_count"` - MaxSizeMB int64 `json:"max_size_mb"` - TTLMinutes int64 `json:"ttl_minutes"` - LRUEnabled bool `json:"lru_enabled"` - BasePath string `json:"base_path"` -} - -// DiskCache implements the disk-backed cache with size limits, TTL, and LRU eviction. -type DiskCache struct { - mu sync.RWMutex - d *diskv.Diskv - basePath string - maxSize int64 // in bytes - defaultTTL time.Duration - lruEnabled bool - - // LRU and Size tracking - currentSize int64 - items map[string]*list.Element - evictList *list.List -} - -type cacheItem struct { - key string - size int64 - expiredAt time.Time -} - // GetGlobalCache returns the global singleton DiskCache instance. func GetGlobalCache() *DiskCache { globalCacheOnce.Do(func() { - globalCache = New(defaultCacheDir) + pureCache := pkgcache.New(defaultCacheDir) + globalCache = &DiskCache{pureCache} // Load initial configs from database globalCache.ReloadConfig(context.Background()) // Start background routine to clean expired items every 10 minutes - go globalCache.startCleanupWorker(defaultCleanupInterval * time.Minute) + go globalCache.StartCleanupWorker(defaultCleanupInterval * time.Minute) }) return globalCache } -// New creates a new DiskCache instance. +// New creates a new DiskCache wrapper. func New(basePath string) *DiskCache { - d := diskv.New(diskv.Options{ - BasePath: basePath, - Transform: func(_ string) []string { return []string{} }, // flat structure for easy walk - CacheSizeMax: 1024 * 1024, // 1MB in-memory cache size for diskv itself - }) - - c := &DiskCache{ - d: d, - basePath: basePath, - maxSize: defaultMaxSizeMB * 1024 * 1024, // 100MB default - defaultTTL: defaultTTLMinutes * time.Minute, // 60 minutes default - lruEnabled: true, - items: make(map[string]*list.Element), - evictList: list.New(), - } - - // Scan directory on startup to rebuild LRU and size tracking - _ = c.loadTracker() - return c -} - -// Set stores a key-value pair in the cache. -// Use DefaultExpiration for the configured default TTL, NoExpiration for no -// TTL, or a positive duration for a business-specific TTL. -func (c *DiskCache) Set(key string, value []byte, ttl time.Duration) error { - c.mu.Lock() - defer c.mu.Unlock() - - if ttl == DefaultExpiration { - ttl = c.defaultTTL - } - - var expiredAt time.Time - if ttl > 0 { - expiredAt = time.Now().Add(ttl) - } - - // Prepare data layout: 8 bytes expiration timestamp + raw payload - buf := make([]byte, headerSize+len(value)) - var expNano int64 - if !expiredAt.IsZero() { - expNano = expiredAt.UnixNano() - } - binary.BigEndian.PutUint64(buf[0:headerSize], uint64(expNano)) - copy(buf[headerSize:], value) - - // Write to diskv - if err := c.d.Write(key, buf); err != nil { - return fmt.Errorf("failed to write key to disk: %w", err) - } - - // Get file size on disk (approximate) - size := int64(len(buf)) - - // Update memory tracker - if elem, ok := c.items[key]; ok { - item := elem.Value.(*cacheItem) - c.currentSize += size - item.size - item.size = size - item.expiredAt = expiredAt - c.evictList.MoveToFront(elem) - } else { - item := &cacheItem{ - key: key, - size: size, - expiredAt: expiredAt, - } - elem := c.evictList.PushFront(item) - c.items[key] = elem - c.currentSize += size - } - - // Evict items if size limit exceeded and LRU is enabled - c.evict() - - return nil -} - -// Get retrieves a key's value from the cache. -func (c *DiskCache) Get(key string) ([]byte, error) { - c.mu.Lock() - defer c.mu.Unlock() - - elem, ok := c.items[key] - if !ok { - return nil, ErrCacheMiss - } - - item := elem.Value.(*cacheItem) - - // Check expiration - if !item.expiredAt.IsZero() && time.Now().After(item.expiredAt) { - // Lazily delete expired item - _ = c.deleteUnlocked(key) - return nil, ErrCacheMiss - } - - // Read from diskv - data, err := c.d.Read(key) - if err != nil { - // Key exists in memory but not on disk, sync state - _ = c.deleteUnlocked(key) - return nil, ErrCacheMiss - } - - if len(data) < headerSize { - _ = c.deleteUnlocked(key) - return nil, ErrCacheMiss - } - - // Update LRU access order - c.evictList.MoveToFront(elem) - - // Slice off the metadata header - return data[headerSize:], nil -} - -// Delete removes a key-value pair from the cache. -func (c *DiskCache) Delete(key string) error { - c.mu.Lock() - defer c.mu.Unlock() - return c.deleteUnlocked(key) -} - -func (c *DiskCache) deleteUnlocked(key string) error { - if elem, ok := c.items[key]; ok { - item := elem.Value.(*cacheItem) - c.currentSize -= item.size - c.evictList.Remove(elem) - delete(c.items, key) - } - return c.d.Erase(key) -} - -// Clear flushes all cached elements. -func (c *DiskCache) Clear() error { - c.mu.Lock() - defer c.mu.Unlock() - - c.currentSize = 0 - c.items = make(map[string]*list.Element) - c.evictList.Init() - - return c.d.EraseAll() -} - -// Status returns the cache status. -func (c *DiskCache) Status() Status { - c.mu.RLock() - defer c.mu.RUnlock() - - return Status{ - TotalSize: c.currentSize, - KeysCount: len(c.items), - MaxSizeMB: c.maxSize / (1024 * 1024), - TTLMinutes: int64(c.defaultTTL.Minutes()), - LRUEnabled: c.lruEnabled, - BasePath: c.basePath, - } + return &DiskCache{pkgcache.New(basePath)} } // ReloadConfig reloads policies from database configs dynamically. func (c *DiskCache) ReloadConfig(ctx context.Context) { - c.mu.Lock() - defer c.mu.Unlock() - // Ensure DB is initialized before querying if db.DB(ctx) == nil { return @@ -269,7 +76,6 @@ func (c *DiskCache) ReloadConfig(ctx context.Context) { maxSizeMB = val } } - c.maxSize = maxSizeMB * 1024 * 1024 // 2. Default TTL var scTTL model.SystemConfig @@ -279,7 +85,6 @@ func (c *DiskCache) ReloadConfig(ctx context.Context) { ttlMinutes = val } } - c.defaultTTL = time.Duration(ttlMinutes) * time.Minute // 3. LRU Enabled var scLRU model.SystemConfig @@ -289,151 +94,6 @@ func (c *DiskCache) ReloadConfig(ctx context.Context) { lruEnabled = val } } - c.lruEnabled = lruEnabled - // Apply eviction immediately under new configs - c.evict() -} - -// evict evicts oldest items if current size exceeds maxSize and LRU is enabled. -func (c *DiskCache) evict() { - if !c.lruEnabled { - return - } - - for c.currentSize > c.maxSize && c.evictList.Len() > 0 { - elem := c.evictList.Back() - if elem == nil { - break - } - item := elem.Value.(*cacheItem) - key := item.key - - // Remove from memory - c.currentSize -= item.size - c.evictList.Remove(elem) - delete(c.items, key) - - // Delete from disk - _ = c.d.Erase(key) - } -} - -// loadTracker walks the directory to rebuild the LRU and size tracking structures. -func (c *DiskCache) loadTracker() error { - c.mu.Lock() - defer c.mu.Unlock() - - c.currentSize = 0 - c.items = make(map[string]*list.Element) - c.evictList = list.New() - - files, err := os.ReadDir(c.basePath) - if err != nil { - if os.IsNotExist(err) { - return nil - } - return err - } - - type tempItem struct { - key string - size int64 - expiredAt time.Time - mtime time.Time - } - var loadedItems []tempItem - - for _, file := range files { - if file.IsDir() { - continue - } - name := file.Name() - // Skip temporary files - if strings.HasPrefix(name, ".") || strings.Contains(name, "temp") { - continue - } - - info, err := file.Info() - if err != nil { - continue - } - - filePath := filepath.Join(c.basePath, name) - // #nosec G304 - f, err := os.Open(filePath) - if err != nil { - continue - } - - var expiredAt time.Time - var expNano int64 - err = binary.Read(f, binary.BigEndian, &expNano) - _ = f.Close() - if err != nil { - // Corrupt metadata header: delete file - _ = os.Remove(filePath) - continue - } - - if expNano > 0 { - expiredAt = time.Unix(0, expNano) - // Expired: delete file - if time.Now().After(expiredAt) { - _ = os.Remove(filePath) - continue - } - } - - loadedItems = append(loadedItems, tempItem{ - key: name, - size: info.Size(), - expiredAt: expiredAt, - mtime: info.ModTime(), - }) - } - - // Sort loaded items by modification time ascending (oldest first) - sort.Slice(loadedItems, func(i, j int) bool { - return loadedItems[i].mtime.Before(loadedItems[j].mtime) - }) - - // Populate LRU (PushFront so that newest items are at the front, oldest at the back) - for _, item := range loadedItems { - entry := &cacheItem{ - key: item.key, - size: item.size, - expiredAt: item.expiredAt, - } - element := c.evictList.PushFront(entry) - c.items[item.key] = element - c.currentSize += item.size - } - - return nil -} - -// startCleanupWorker periodically cleans up expired cache items. -func (c *DiskCache) startCleanupWorker(interval time.Duration) { - ticker := time.NewTicker(interval) - for range ticker.C { - c.cleanExpired() - } -} - -// cleanExpired scans memory for expired items and removes them. -func (c *DiskCache) cleanExpired() { - c.mu.Lock() - defer c.mu.Unlock() - - now := time.Now() - for key, elem := range c.items { - item := elem.Value.(*cacheItem) - if !item.expiredAt.IsZero() && now.After(item.expiredAt) { - c.currentSize -= item.size - c.evictList.Remove(elem) - delete(c.items, key) - _ = c.d.Erase(key) - } - } + c.UpdatePolicy(maxSizeMB, ttlMinutes, lruEnabled) } diff --git a/internal/diskcache/cache_test.go b/internal/diskcache/cache_test.go index af46f260..4f5cce32 100644 --- a/internal/diskcache/cache_test.go +++ b/internal/diskcache/cache_test.go @@ -4,218 +4,15 @@ package diskcache import ( - "bytes" "context" "os" "testing" - "time" "github.com/Rain-kl/Wavelet/internal/db" "github.com/Rain-kl/Wavelet/internal/model" "github.com/Rain-kl/Wavelet/internal/testhelper" ) -func TestDiskCacheBasic(t *testing.T) { - testDir := "uploads/test_diskcache_basic" - defer func() { _ = os.RemoveAll(testDir) }() - _ = os.RemoveAll(testDir) - - c := New(testDir) - defer func() { _ = c.Clear() }() - - key := "key1" - val := []byte("value1") - - // Get non-existent - _, err := c.Get(key) - if err != ErrCacheMiss { - t.Fatalf("expected ErrCacheMiss, got %v", err) - } - - // Set & Get - err = c.Set(key, val, 10*time.Second) - if err != nil { - t.Fatalf("failed to set cache: %v", err) - } - - got, err := c.Get(key) - if err != nil { - t.Fatalf("failed to get cache: %v", err) - } - - if !bytes.Equal(got, val) { - t.Errorf("expected %s, got %s", val, got) - } - - // Delete - err = c.Delete(key) - if err != nil { - t.Fatalf("failed to delete: %v", err) - } - - _, err = c.Get(key) - if err != ErrCacheMiss { - t.Errorf("expected ErrCacheMiss after delete, got %v", err) - } -} - -func TestDiskCacheTTL(t *testing.T) { - testDir := "uploads/test_diskcache_ttl" - defer func() { _ = os.RemoveAll(testDir) }() - _ = os.RemoveAll(testDir) - - c := New(testDir) - defer func() { _ = c.Clear() }() - - key := "ttlkey" - val := []byte("ttlval") - - // Set with 200ms TTL - err := c.Set(key, val, 200*time.Millisecond) - if err != nil { - t.Fatalf("failed to set: %v", err) - } - - // Immediate Get should succeed - got, err := c.Get(key) - if err != nil { - t.Fatalf("failed to get: %v", err) - } - if !bytes.Equal(got, val) { - t.Errorf("expected %s, got %s", val, got) - } - - // Sleep 250ms to expire - time.Sleep(250 * time.Millisecond) - - // Get should fail with cache miss - _, err = c.Get(key) - if err != ErrCacheMiss { - t.Errorf("expected ErrCacheMiss after TTL expiration, got %v", err) - } -} - -func TestDiskCacheExpirationPolicies(t *testing.T) { - testDir := "uploads/test_diskcache_expiration_policies" - defer func() { _ = os.RemoveAll(testDir) }() - _ = os.RemoveAll(testDir) - - c := New(testDir) - defer func() { _ = c.Clear() }() - c.defaultTTL = 50 * time.Millisecond - - if err := c.Set("default", []byte("default"), DefaultExpiration); err != nil { - t.Fatalf("Set(default, DefaultExpiration) returned error: %v", err) - } - if err := c.Set("custom", []byte("custom"), 100*time.Millisecond); err != nil { - t.Fatalf("Set(custom, 100ms) returned error: %v", err) - } - if err := c.Set("permanent", []byte("permanent"), NoExpiration); err != nil { - t.Fatalf("Set(permanent, NoExpiration) returned error: %v", err) - } - - time.Sleep(75 * time.Millisecond) - - if _, err := c.Get("default"); err != ErrCacheMiss { - t.Errorf("Get(default) error = %v, want ErrCacheMiss", err) - } - if _, err := c.Get("custom"); err != nil { - t.Errorf("Get(custom) returned error before custom TTL elapsed: %v", err) - } - if _, err := c.Get("permanent"); err != nil { - t.Errorf("Get(permanent) returned error: %v", err) - } - - time.Sleep(50 * time.Millisecond) - - if _, err := c.Get("custom"); err != ErrCacheMiss { - t.Errorf("Get(custom) error = %v, want ErrCacheMiss", err) - } - if _, err := c.Get("permanent"); err != nil { - t.Errorf("Get(permanent) returned error after other entries expired: %v", err) - } -} - -func TestDiskCacheNoExpirationSurvivesReload(t *testing.T) { - testDir := "uploads/test_diskcache_no_expiration_reload" - defer func() { _ = os.RemoveAll(testDir) }() - _ = os.RemoveAll(testDir) - - c := New(testDir) - if err := c.Set("permanent", []byte("value"), NoExpiration); err != nil { - t.Fatalf("Set(permanent, NoExpiration) returned error: %v", err) - } - - reloaded := New(testDir) - defer func() { _ = reloaded.Clear() }() - - got, err := reloaded.Get("permanent") - if err != nil { - t.Fatalf("reloaded Get(permanent) returned error: %v", err) - } - if !bytes.Equal(got, []byte("value")) { - t.Errorf("reloaded Get(permanent) = %q, want %q", got, "value") - } -} - -func TestDiskCacheLRUEviction(t *testing.T) { - testDir := "uploads/test_diskcache_lru" - defer func() { _ = os.RemoveAll(testDir) }() - _ = os.RemoveAll(testDir) - - c := New(testDir) - defer func() { _ = c.Clear() }() - - // Force a very small max size of 20 bytes for testing (8 bytes header + payload) - // So 2 items of 2 bytes payload = 2 * (8 + 2) = 20 bytes max. - c.maxSize = 20 - c.lruEnabled = true - - // Write item 1: 8 + 2 = 10 bytes - err := c.Set("k1", []byte("v1"), DefaultExpiration) - if err != nil { - t.Fatalf("failed to set k1: %v", err) - } - - // Write item 2: 8 + 2 = 10 bytes - err = c.Set("k2", []byte("v2"), DefaultExpiration) - if err != nil { - t.Fatalf("failed to set k2: %v", err) - } - - // Both should exist - if _, err := c.Get("k1"); err != nil { - t.Errorf("k1 should exist: %v", err) - } - if _, err := c.Get("k2"); err != nil { - t.Errorf("k2 should exist: %v", err) - } - - // Write item 3: 8 + 2 = 10 bytes -> total size would be 30, exceeding 20. - // This should evict the oldest item. Since k1 was accessed, but then k2 was accessed, - // wait, let's access k1 again to make it the most recently used, so k2 becomes oldest! - _, _ = c.Get("k1") // k1 is now MRU, k2 is LRU - - err = c.Set("k3", []byte("v3"), DefaultExpiration) - if err != nil { - t.Fatalf("failed to set k3: %v", err) - } - - // k2 should be evicted, k1 and k3 should exist - _, err = c.Get("k2") - if err != ErrCacheMiss { - t.Errorf("expected k2 to be evicted, got error %v", err) - } - - if _, err := c.Get("k1"); err != nil { - t.Errorf("k1 should still exist: %v", err) - } - - if _, err := c.Get("k3"); err != nil { - t.Errorf("k3 should exist: %v", err) - } -} - func TestDiskCacheReloadConfig(t *testing.T) { dbConn, _, cleanup := testhelper.SetupTestEnvironment(t) defer cleanup() diff --git a/internal/model/users.go b/internal/model/users.go index cba39124..aa5b83a2 100644 --- a/internal/model/users.go +++ b/internal/model/users.go @@ -12,7 +12,7 @@ import ( "time" "github.com/Rain-kl/Wavelet/internal/common" - "github.com/Rain-kl/Wavelet/internal/util" + "github.com/Rain-kl/Wavelet/pkg/util" "gorm.io/gorm" ) diff --git a/internal/router/middlewares.go b/internal/router/middlewares.go index cb2122d4..1af08898 100644 --- a/internal/router/middlewares.go +++ b/internal/router/middlewares.go @@ -12,9 +12,9 @@ import ( "time" "github.com/Rain-kl/Wavelet/internal/config" - "github.com/Rain-kl/Wavelet/internal/logger" "github.com/Rain-kl/Wavelet/internal/model" - "github.com/Rain-kl/Wavelet/internal/otel_trace" + "github.com/Rain-kl/Wavelet/pkg/logger" + otel_trace "github.com/Rain-kl/Wavelet/pkg/trace" "github.com/gin-gonic/gin" "go.opentelemetry.io/otel/codes" "go.opentelemetry.io/otel/trace" diff --git a/internal/router/router.go b/internal/router/router.go index 389097ea..6a977570 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -34,7 +34,7 @@ import ( "github.com/Rain-kl/Wavelet/internal/apps/user" "github.com/Rain-kl/Wavelet/internal/model" "github.com/Rain-kl/Wavelet/internal/util" - capUtil "github.com/Rain-kl/Wavelet/internal/util/cap" + capUtil "github.com/Rain-kl/Wavelet/internal/service/cap" // Swagger 文档生成 _ "github.com/Rain-kl/Wavelet/docs" @@ -42,7 +42,7 @@ import ( "github.com/Rain-kl/Wavelet/internal/apps/admin/system_config" "github.com/Rain-kl/Wavelet/internal/apps/oauth" "github.com/Rain-kl/Wavelet/internal/config" - "github.com/Rain-kl/Wavelet/internal/otel_trace" + otel_trace "github.com/Rain-kl/Wavelet/pkg/trace" "github.com/gin-contrib/sessions" "github.com/gin-contrib/sessions/redis" "github.com/gin-gonic/gin" diff --git a/internal/util/cap/manager.go b/internal/service/cap/manager.go similarity index 87% rename from internal/util/cap/manager.go rename to internal/service/cap/manager.go index 6178c31d..6abbfabb 100644 --- a/internal/util/cap/manager.go +++ b/internal/service/cap/manager.go @@ -1,6 +1,7 @@ // Copyright 2026 Arctel.net // SPDX-License-Identifier: Apache-2.0 +// Package cap provides CAPTCHA and proof-of-work (PoW) verification services. package cap import ( @@ -15,6 +16,7 @@ import ( "github.com/Rain-kl/Wavelet/internal/config" "github.com/Rain-kl/Wavelet/internal/db" "github.com/Rain-kl/Wavelet/internal/model" + pkgcap "github.com/Rain-kl/Wavelet/pkg/cap" ) const ( @@ -42,11 +44,11 @@ type Config struct { // Manager orchestrates challenge generation and solution validation type Manager struct { conf Config - store Store + store pkgcap.Store } // NewManager creates a new CAPTCHA Manager -func NewManager(conf Config, store Store) *Manager { +func NewManager(conf Config, store pkgcap.Store) *Manager { if conf.ChallengeCount <= 0 { conf.ChallengeCount = managerDefaultChallengeCount } @@ -69,19 +71,27 @@ func NewManager(conf Config, store Store) *Manager { } // Generate creates a challenge response -func (m *Manager) Generate(ctx context.Context, scope string) (*ChallengeResponse, error) { - c := ChallengeConfig{ +func (m *Manager) Generate(ctx context.Context, scope string) (*pkgcap.ChallengeResponse, error) { + c := pkgcap.ChallengeConfig{ Count: m.getChallengeCount(ctx), Size: m.getChallengeSize(ctx), Difficulty: m.getChallengeDifficulty(ctx), Expires: m.getChallengeTTL(ctx), } - return GenerateChallenge(m.conf.Secret, c, scope) + return pkgcap.GenerateChallenge(m.conf.Secret, c, scope) +} + +// RedeemResponse is returned to the client on redeem +type RedeemResponse struct { + Success bool `json:"success"` + Token string `json:"token,omitempty"` + Expires int64 `json:"expires,omitempty"` + Error string `json:"error,omitempty"` } // Redeem verifies PoW solutions and returns a one-time redeem token func (m *Manager) Redeem(ctx context.Context, token string, solutions []int, scope string) (*RedeemResponse, error) { - sigHex := jwtSigHex(token) + sigHex := pkgcap.JwtSigHex(token) if sigHex == "" { return &RedeemResponse{Success: false, Error: "invalid_token"}, nil } @@ -89,10 +99,7 @@ func (m *Manager) Redeem(ctx context.Context, token string, solutions []int, sco nonceKey := "cap:nonce:" + sigHex // Atomically claim the nonce slot BEFORE verifying solutions. - // SetNX returns true only when the key did not previously exist, so two - // concurrent requests carrying the same JWT can never both succeed here. - // TTL is set to the challenge's remaining lifetime so the slot auto-expires. - payload, err := VerifyChallengeSolutions(token, solutions, m.conf.Secret, scope) + payload, err := pkgcap.VerifyChallengeSolutions(token, solutions, m.conf.Secret, scope) if err != nil { return &RedeemResponse{Success: false, Error: err.Error()}, nil //nolint:nilerr // expected behavior: validation error is returned as response, not system error } @@ -115,8 +122,8 @@ func (m *Manager) Redeem(ctx context.Context, token string, solutions []int, sco } // Generate a redeem token formatted as "id:verToken" - id := randomHex(redeemTokenIDLength) - verToken := randomHex(redeemVerTokenLength) + id := pkgcap.RandomHex(redeemTokenIDLength) + verToken := pkgcap.RandomHex(redeemVerTokenLength) verHashBytes := sha256.Sum256([]byte(verToken)) verHashHex := hex.EncodeToString(verHashBytes[:]) @@ -139,8 +146,6 @@ func (m *Manager) Redeem(ctx context.Context, token string, solutions []int, sco } // VerifyToken validates and consumes the redeem token (single-use). -// GetAndDelete is used so that retrieval and removal happen atomically: -// two concurrent requests carrying the same token can never both see a value. func (m *Manager) VerifyToken(ctx context.Context, token string, expectedScope string) (bool, error) { if token == "" { return false, nil @@ -157,8 +162,7 @@ func (m *Manager) VerifyToken(ctx context.Context, token string, expectedScope s tokenKey := "cap:token:" + id + ":" + verHashHex - // Atomically retrieve-and-delete: the first caller gets the value, any - // subsequent caller (even concurrent) receives (false, nil) immediately. + // Atomically retrieve-and-delete val, exists, err := sGetAndDelete(ctx, m.store, tokenKey) if err != nil { return false, err @@ -190,7 +194,7 @@ func (m *Manager) VerifyToken(ctx context.Context, token string, expectedScope s } // sGetAndDelete safely calls store.GetAndDelete, treating a nil store as a miss. -func sGetAndDelete(ctx context.Context, store Store, key string) (string, bool, error) { +func sGetAndDelete(ctx context.Context, store pkgcap.Store, key string) (string, bool, error) { if store == nil { return "", false, nil } @@ -258,11 +262,11 @@ func GetDefaultManager() *Manager { challengeTTL := defaultChallengeTTL tokenTTL := defaultTokenTTL - var store Store + var store pkgcap.Store if config.Config != nil && config.Config.Redis.Enabled && db.Redis != nil { - store = NewRedisStore(db.Redis) + store = pkgcap.NewRedisStore(db.Redis) } else { - store = NewMemoryStore(1 * time.Minute) + store = pkgcap.NewMemoryStore(1 * time.Minute) } defaultManager = NewManager(Config{ diff --git a/internal/util/cap/cap_test.go b/internal/service/cap/manager_test.go similarity index 89% rename from internal/util/cap/cap_test.go rename to internal/service/cap/manager_test.go index 0a55986f..1b2c20f7 100644 --- a/internal/util/cap/cap_test.go +++ b/internal/service/cap/manager_test.go @@ -9,11 +9,13 @@ import ( "sync/atomic" "testing" "time" + + pkgcap "github.com/Rain-kl/Wavelet/pkg/cap" ) func TestCapFullFlow(t *testing.T) { secret := []byte("a-very-long-secret-key-at-least-16-bytes") - store := NewMemoryStore(1 * time.Minute) + store := pkgcap.NewMemoryStore(1 * time.Minute) manager := NewManager(Config{ Secret: secret, @@ -36,7 +38,7 @@ func TestCapFullFlow(t *testing.T) { } // Solve the challenge (acting as client) - solutions := Solve(resp.Token, resp.Challenge.C, resp.Challenge.S, resp.Challenge.D) + solutions := pkgcap.Solve(resp.Token, resp.Challenge.C, resp.Challenge.S, resp.Challenge.D) // Redeem redeemResp, err := manager.Redeem(ctx, resp.Token, solutions, scope) @@ -76,7 +78,7 @@ func TestRedeemConcurrentRace(t *testing.T) { const goroutines = 50 secret := []byte("race-test-secret-key-at-least-16-bytes") - store := NewMemoryStore(1 * time.Minute) + store := pkgcap.NewMemoryStore(1 * time.Minute) manager := NewManager(Config{ Secret: secret, ChallengeCount: 1, @@ -91,7 +93,7 @@ func TestRedeemConcurrentRace(t *testing.T) { if err != nil { t.Fatalf("Generate failed: %v", err) } - solutions := Solve(resp.Token, resp.Challenge.C, resp.Challenge.S, resp.Challenge.D) + solutions := pkgcap.Solve(resp.Token, resp.Challenge.C, resp.Challenge.S, resp.Challenge.D) var ( wg sync.WaitGroup @@ -125,7 +127,7 @@ func TestVerifyTokenConcurrentRace(t *testing.T) { const goroutines = 50 secret := []byte("race-test-secret-key-at-least-16-bytes") - store := NewMemoryStore(1 * time.Minute) + store := pkgcap.NewMemoryStore(1 * time.Minute) manager := NewManager(Config{ Secret: secret, ChallengeCount: 1, @@ -137,7 +139,7 @@ func TestVerifyTokenConcurrentRace(t *testing.T) { ctx := context.Background() resp, _ := manager.Generate(ctx, "login") - solutions := Solve(resp.Token, resp.Challenge.C, resp.Challenge.S, resp.Challenge.D) + solutions := pkgcap.Solve(resp.Token, resp.Challenge.C, resp.Challenge.S, resp.Challenge.D) redeemResp, err := manager.Redeem(ctx, resp.Token, solutions, "login") if err != nil || !redeemResp.Success { t.Fatalf("Redeem failed: %v %+v", err, redeemResp) diff --git a/internal/service/cleanup.go b/internal/service/cleanup.go index 77de0397..11a5129c 100644 --- a/internal/service/cleanup.go +++ b/internal/service/cleanup.go @@ -11,10 +11,10 @@ import ( "time" "github.com/Rain-kl/Wavelet/internal/db" - "github.com/Rain-kl/Wavelet/internal/logger" "github.com/Rain-kl/Wavelet/internal/model" "github.com/Rain-kl/Wavelet/internal/storage" "github.com/Rain-kl/Wavelet/internal/task" + "github.com/Rain-kl/Wavelet/pkg/logger" "gorm.io/gorm" ) diff --git a/internal/storage/http.go b/internal/storage/http.go index d5226eef..f901ce7b 100644 --- a/internal/storage/http.go +++ b/internal/storage/http.go @@ -10,7 +10,7 @@ import ( "net/url" "time" - "github.com/Rain-kl/Wavelet/internal/httppool" + "github.com/Rain-kl/Wavelet/pkg/httppool" ) func getHTTPObject(ctx context.Context, baseURL, key string) (*Object, error) { diff --git a/internal/storage/webdav.go b/internal/storage/webdav.go index 32d757b3..15d0cd1d 100644 --- a/internal/storage/webdav.go +++ b/internal/storage/webdav.go @@ -10,7 +10,7 @@ import ( "path" "strings" - "github.com/Rain-kl/Wavelet/internal/httppool" + "github.com/Rain-kl/Wavelet/pkg/httppool" "github.com/studio-b12/gowebdav" ) @@ -36,7 +36,7 @@ func (b *webDAVBackend) Put(_ context.Context, key string, body io.Reader, size } } if err := b.client.WriteStreamWithLength(key, body, size, storageFilePerm); err != nil { - return PutResult{}, fmt.Errorf("put WebDAV object: %w", err) + return PutResult{}, fmt.Errorf("put WebDAV object: %w", err) } return PutResult{Key: key}, nil } diff --git a/internal/task/executor.go b/internal/task/executor.go index 3a8e99e2..3a2382db 100644 --- a/internal/task/executor.go +++ b/internal/task/executor.go @@ -11,9 +11,9 @@ import ( "time" "github.com/Rain-kl/Wavelet/internal/db/idgen" - "github.com/Rain-kl/Wavelet/internal/logger" "github.com/Rain-kl/Wavelet/internal/model" - "github.com/Rain-kl/Wavelet/internal/otel_trace" + "github.com/Rain-kl/Wavelet/pkg/logger" + otel_trace "github.com/Rain-kl/Wavelet/pkg/trace" "github.com/hibiken/asynq" "go.opentelemetry.io/otel/attribute" "go.opentelemetry.io/otel/codes" diff --git a/internal/task/scheduler/scheduler.go b/internal/task/scheduler/scheduler.go index fd5a2589..2d1a1718 100644 --- a/internal/task/scheduler/scheduler.go +++ b/internal/task/scheduler/scheduler.go @@ -11,9 +11,9 @@ import ( "syscall" "time" - "github.com/Rain-kl/Wavelet/internal/logger" "github.com/Rain-kl/Wavelet/internal/model" "github.com/Rain-kl/Wavelet/internal/task" + "github.com/Rain-kl/Wavelet/pkg/logger" "github.com/hibiken/asynq" ) diff --git a/internal/util/errs.go b/internal/util/errs.go index bff6c4f9..ed878b14 100644 --- a/internal/util/errs.go +++ b/internal/util/errs.go @@ -7,12 +7,4 @@ const ( errCreateHTTPRequestFailed = "创建HTTP请求失败: %w" errHTTPRequestFailed = "请求%s接口失败: %w" errInvalidCustomValue = "invalid value: %v" - errInvalidSignKey = "invalid sign key: %w" - errSignKeyLengthInvalid = "sign key must be 32 bytes (64 hex characters)" - errCreateCipherFailed = "failed to create cipher: %w" - errCreateGCMFailed = "failed to create GCM: %w" - errGenerateNonceFailed = "failed to generate nonce: %w" - errDecodeCiphertextFailed = "failed to decode ciphertext: %w" - errCiphertextTooShort = "ciphertext too short" - errDecryptFailed = "failed to decrypt: %w" ) diff --git a/internal/util/http_clients.go b/internal/util/http_clients.go index 637897b6..a370e405 100644 --- a/internal/util/http_clients.go +++ b/internal/util/http_clients.go @@ -12,7 +12,7 @@ import ( "net/url" "time" - "go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp" + "github.com/Rain-kl/Wavelet/pkg/httppool" ) // IsLocalhost 检查 URL 是否为 localhost @@ -35,12 +35,8 @@ const ( // 配置HTTP客户端 使用 otelhttp 自动注入 trace span var httpClient = &http.Client{ - Timeout: httpClientTimeout * time.Second, - Transport: otelhttp.NewTransport(&http.Transport{ - MaxIdleConns: httpMaxIdleConns, - MaxIdleConnsPerHost: httpMaxIdleConnsPerHost, - IdleConnTimeout: httpIdleConnTimeout * time.Second, - }), + Timeout: httpClientTimeout * time.Second, + Transport: httppool.DefaultTransport(), } // SetHTTPClient 替换全局 HTTP 客户端实例 diff --git a/internal/util/cap/cap.go b/pkg/cap/cap.go similarity index 95% rename from internal/util/cap/cap.go rename to pkg/cap/cap.go index 763046e3..0d3727f0 100644 --- a/internal/util/cap/cap.go +++ b/pkg/cap/cap.go @@ -79,7 +79,8 @@ func b64urlDecode(str string) ([]byte, error) { return base64.RawURLEncoding.DecodeString(str) } -func randomHex(byteLen int) string { +// RandomHex generates a cryptographically secure random hexadecimal string of the specified byte length. +func RandomHex(byteLen int) string { bytes := make([]byte, byteLen) if _, err := rand.Read(bytes); err != nil { panic(err) @@ -129,7 +130,8 @@ func jwtVerify(token string, secret []byte) ([]byte, error) { return payload, nil } -func jwtSigHex(token string) string { +// JwtSigHex extracts the signature part of a JWT token and returns it as a hexadecimal string. +func JwtSigHex(token string) string { parts := strings.Split(token, ".") if len(parts) != jwtPartsCount { return "" @@ -160,7 +162,7 @@ func GenerateChallenge(secret []byte, conf ChallengeConfig, scope string) (*Chal expires := now + int64(conf.Expires/time.Millisecond) payload := ChallengePayload{ - Nonce: randomHex(defaultNonceLength), + Nonce: RandomHex(defaultNonceLength), Count: conf.Count, Size: conf.Size, Difficulty: conf.Difficulty, diff --git a/internal/util/cap/errs.go b/pkg/cap/errs.go similarity index 100% rename from internal/util/cap/errs.go rename to pkg/cap/errs.go diff --git a/internal/util/cap/prng.go b/pkg/cap/prng.go similarity index 100% rename from internal/util/cap/prng.go rename to pkg/cap/prng.go diff --git a/internal/util/cap/store.go b/pkg/cap/store.go similarity index 100% rename from internal/util/cap/store.go rename to pkg/cap/store.go diff --git a/pkg/diskcache/cache.go b/pkg/diskcache/cache.go new file mode 100644 index 00000000..e273f41c --- /dev/null +++ b/pkg/diskcache/cache.go @@ -0,0 +1,350 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +// Package diskcache implements a platform-level disk-backed cache with size limit, TTL, and LRU eviction. +package diskcache + +import ( + "container/list" + "encoding/binary" + "errors" + "fmt" + "os" + "path/filepath" + "sort" + "sync" + "time" + + "github.com/peterbourgon/diskv/v3" +) + +// ErrCacheMiss represents a cache miss. +var ErrCacheMiss = errors.New("cache miss") + +// Constants for disk cache configuration and sizing +const ( + headerSize = 8 // 8 bytes metadata prefix for expiration UnixNano timestamp + defaultMaxSizeMB = 100 + defaultTTLMinutes = 60 + cacheDirPerm = 0750 + + // DefaultExpiration applies the cache-wide default TTL. + DefaultExpiration time.Duration = 0 + // NoExpiration stores the item without a TTL. Size limits and LRU eviction still apply. + NoExpiration time.Duration = -1 +) + +// Status represents the runtime cache statistics. +type Status struct { + TotalSize int64 `json:"total_size"` + KeysCount int `json:"keys_count"` + MaxSizeMB int64 `json:"max_size_mb"` + TTLMinutes int64 `json:"ttl_minutes"` + LRUEnabled bool `json:"lru_enabled"` + BasePath string `json:"base_path"` +} + +// DiskCache implements the disk-backed cache with size limits, TTL, and LRU eviction. +type DiskCache struct { + mu sync.RWMutex + d *diskv.Diskv + basePath string + maxSize int64 // in bytes + defaultTTL time.Duration + lruEnabled bool + + // LRU and Size tracking + currentSize int64 + items map[string]*list.Element + evictList *list.List +} + +type cacheItem struct { + key string + size int64 + expiredAt time.Time +} + +// New creates a new DiskCache instance. +func New(basePath string) *DiskCache { + d := diskv.New(diskv.Options{ + BasePath: basePath, + Transform: func(_ string) []string { return []string{} }, // flat structure for easy walk + CacheSizeMax: 1024 * 1024, // 1MB in-memory cache size for diskv itself + }) + + c := &DiskCache{ + d: d, + basePath: basePath, + maxSize: defaultMaxSizeMB * 1024 * 1024, // 100MB default + defaultTTL: defaultTTLMinutes * time.Minute, // 60 minutes default + lruEnabled: true, + items: make(map[string]*list.Element), + evictList: list.New(), + } + + // Scan directory on startup to rebuild LRU and size tracking + _ = c.loadTracker() + return c +} + +// Set stores a key-value pair in the cache. +// Use DefaultExpiration for the configured default TTL, NoExpiration for no +// TTL, or a positive duration for a business-specific TTL. +func (c *DiskCache) Set(key string, value []byte, ttl time.Duration) error { + c.mu.Lock() + defer c.mu.Unlock() + + if ttl == DefaultExpiration { + ttl = c.defaultTTL + } + + var expiredAt time.Time + if ttl > 0 { + expiredAt = time.Now().Add(ttl) + } + + // Prepare data layout: 8 bytes expiration timestamp + raw payload + buf := make([]byte, headerSize+len(value)) + var expNano int64 + if !expiredAt.IsZero() { + expNano = expiredAt.UnixNano() + } + binary.BigEndian.PutUint64(buf[0:headerSize], uint64(expNano)) + copy(buf[headerSize:], value) + + // Write to diskv + if err := c.d.Write(key, buf); err != nil { + return fmt.Errorf("failed to write key to disk: %w", err) + } + + // Get file size on disk (approximate) + size := int64(len(buf)) + + // Update memory tracker + if elem, ok := c.items[key]; ok { + item := elem.Value.(*cacheItem) + c.currentSize += size - item.size + item.size = size + item.expiredAt = expiredAt + c.evictList.MoveToFront(elem) + } else { + item := &cacheItem{ + key: key, + size: size, + expiredAt: expiredAt, + } + elem := c.evictList.PushFront(item) + c.items[key] = elem + c.currentSize += size + } + + // Evict items if size limit exceeded and LRU is enabled + c.evict() + + return nil +} + +// Get retrieves a key's value from the cache. +func (c *DiskCache) Get(key string) ([]byte, error) { + c.mu.Lock() + defer c.mu.Unlock() + + elem, ok := c.items[key] + if !ok { + return nil, ErrCacheMiss + } + + item := elem.Value.(*cacheItem) + + // Check expiration + if !item.expiredAt.IsZero() && time.Now().After(item.expiredAt) { + // Lazily delete expired item + _ = c.deleteUnlocked(key) + return nil, ErrCacheMiss + } + + // Read from diskv + data, err := c.d.Read(key) + if err != nil { + // Key exists in memory but not on disk, sync state + _ = c.deleteUnlocked(key) + return nil, ErrCacheMiss + } + + if len(data) < headerSize { + _ = c.deleteUnlocked(key) + return nil, ErrCacheMiss + } + + // Update LRU access order + c.evictList.MoveToFront(elem) + + // Slice off the metadata header + return data[headerSize:], nil +} + +// Delete removes a key-value pair from the cache. +func (c *DiskCache) Delete(key string) error { + c.mu.Lock() + defer c.mu.Unlock() + return c.deleteUnlocked(key) +} + +func (c *DiskCache) deleteUnlocked(key string) error { + if elem, ok := c.items[key]; ok { + item := elem.Value.(*cacheItem) + c.currentSize -= item.size + c.evictList.Remove(elem) + delete(c.items, key) + } + return c.d.Erase(key) +} + +// Clear flushes all cached elements. +func (c *DiskCache) Clear() error { + c.mu.Lock() + defer c.mu.Unlock() + + c.currentSize = 0 + c.items = make(map[string]*list.Element) + c.evictList.Init() + + return c.d.EraseAll() +} + +// Status returns the cache status. +func (c *DiskCache) Status() Status { + c.mu.RLock() + defer c.mu.RUnlock() + + return Status{ + TotalSize: c.currentSize, + KeysCount: len(c.items), + MaxSizeMB: c.maxSize / (1024 * 1024), + TTLMinutes: int64(c.defaultTTL.Minutes()), + LRUEnabled: c.lruEnabled, + BasePath: c.basePath, + } +} + +// UpdatePolicy dynamically updates policies. +func (c *DiskCache) UpdatePolicy(maxSizeMB int64, ttlMinutes int64, lruEnabled bool) { + c.mu.Lock() + defer c.mu.Unlock() + + c.maxSize = maxSizeMB * 1024 * 1024 + c.defaultTTL = time.Duration(ttlMinutes) * time.Minute + c.lruEnabled = lruEnabled + c.evict() +} + +// evict evicts oldest items if current size exceeds maxSize and LRU is enabled. +func (c *DiskCache) evict() { + if !c.lruEnabled { + return + } + + for c.currentSize > c.maxSize && c.evictList.Len() > 0 { + elem := c.evictList.Back() + item := elem.Value.(*cacheItem) + c.currentSize -= item.size + c.evictList.Remove(elem) + delete(c.items, item.key) + _ = c.d.Erase(item.key) + } +} + +// loadTracker scans the cache directory on startup to rebuild memory state. +func (c *DiskCache) loadTracker() error { + c.mu.Lock() + defer c.mu.Unlock() + + // Ensure directory exists + if err := os.MkdirAll(c.basePath, cacheDirPerm); err != nil { + return err + } + + type loadedItem struct { + key string + size int64 + expiredAt time.Time + modTime time.Time + } + var loadedItems []loadedItem + + // Walk keys through diskv + keysChan := c.d.Keys(nil) + for key := range keysChan { + // Read raw bytes to parse expiration prefix + data, err := c.d.Read(key) + if err != nil || len(data) < headerSize { + _ = c.d.Erase(key) // corrupted file, wipe + continue + } + + expNano := int64(binary.BigEndian.Uint64(data[0:headerSize])) //nolint:gosec // false positive: UnixNano fits within int64 + var expiredAt time.Time + if expNano > 0 { + expiredAt = time.Unix(0, expNano) + } + + // Check mod time for ordering + path := filepath.Join(c.basePath, key) + info, err := os.Stat(path) + if err != nil { + continue + } + + loadedItems = append(loadedItems, loadedItem{ + key: key, + size: int64(len(data)), + expiredAt: expiredAt, + modTime: info.ModTime(), + }) + } + + // Sort by ModTime ascending (oldest first) so we rebuild LRU correctly + sort.Slice(loadedItems, func(i, j int) bool { + return loadedItems[i].modTime.Before(loadedItems[j].modTime) + }) + + // Populate LRU (PushFront so that newest items are at the front, oldest at the back) + for _, item := range loadedItems { + entry := &cacheItem{ + key: item.key, + size: item.size, + expiredAt: item.expiredAt, + } + element := c.evictList.PushFront(entry) + c.items[item.key] = element + c.currentSize += item.size + } + + return nil +} + +// StartCleanupWorker periodically cleans up expired cache items. +func (c *DiskCache) StartCleanupWorker(interval time.Duration) { + ticker := time.NewTicker(interval) + for range ticker.C { + c.cleanExpired() + } +} + +// cleanExpired scans memory for expired items and removes them. +func (c *DiskCache) cleanExpired() { + c.mu.Lock() + defer c.mu.Unlock() + + now := time.Now() + for key, elem := range c.items { + item := elem.Value.(*cacheItem) + if !item.expiredAt.IsZero() && now.After(item.expiredAt) { + c.currentSize -= item.size + c.evictList.Remove(elem) + delete(c.items, key) + _ = c.d.Erase(key) + } + } +} diff --git a/pkg/diskcache/cache_test.go b/pkg/diskcache/cache_test.go new file mode 100644 index 00000000..78e1eed1 --- /dev/null +++ b/pkg/diskcache/cache_test.go @@ -0,0 +1,212 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package diskcache + +import ( + "bytes" + "os" + "testing" + "time" +) + +func TestDiskCacheBasic(t *testing.T) { + testDir := "uploads/test_diskcache_basic" + defer func() { _ = os.RemoveAll(testDir) }() + _ = os.RemoveAll(testDir) + + c := New(testDir) + defer func() { _ = c.Clear() }() + + key := "key1" + val := []byte("value1") + + // Get non-existent + _, err := c.Get(key) + if err != ErrCacheMiss { + t.Fatalf("expected ErrCacheMiss, got %v", err) + } + + // Set & Get + err = c.Set(key, val, 10*time.Second) + if err != nil { + t.Fatalf("failed to set cache: %v", err) + } + + got, err := c.Get(key) + if err != nil { + t.Fatalf("failed to get cache: %v", err) + } + + if !bytes.Equal(got, val) { + t.Errorf("expected %s, got %s", val, got) + } + + // Delete + err = c.Delete(key) + if err != nil { + t.Fatalf("failed to delete: %v", err) + } + + _, err = c.Get(key) + if err != ErrCacheMiss { + t.Errorf("expected ErrCacheMiss after delete, got %v", err) + } +} + +func TestDiskCacheTTL(t *testing.T) { + testDir := "uploads/test_diskcache_ttl" + defer func() { _ = os.RemoveAll(testDir) }() + _ = os.RemoveAll(testDir) + + c := New(testDir) + defer func() { _ = c.Clear() }() + + key := "ttlkey" + val := []byte("ttlval") + + // Set with 200ms TTL + err := c.Set(key, val, 200*time.Millisecond) + if err != nil { + t.Fatalf("failed to set: %v", err) + } + + // Immediate Get should succeed + got, err := c.Get(key) + if err != nil { + t.Fatalf("failed to get: %v", err) + } + if !bytes.Equal(got, val) { + t.Errorf("expected %s, got %s", val, got) + } + + // Sleep 250ms to expire + time.Sleep(250 * time.Millisecond) + + // Get should fail with cache miss + _, err = c.Get(key) + if err != ErrCacheMiss { + t.Errorf("expected ErrCacheMiss after TTL expiration, got %v", err) + } +} + +func TestDiskCacheExpirationPolicies(t *testing.T) { + testDir := "uploads/test_diskcache_expiration_policies" + defer func() { _ = os.RemoveAll(testDir) }() + _ = os.RemoveAll(testDir) + + c := New(testDir) + defer func() { _ = c.Clear() }() + c.defaultTTL = 50 * time.Millisecond + + if err := c.Set("default", []byte("default"), DefaultExpiration); err != nil { + t.Fatalf("Set(default, DefaultExpiration) returned error: %v", err) + } + if err := c.Set("custom", []byte("custom"), 100*time.Millisecond); err != nil { + t.Fatalf("Set(custom, 100ms) returned error: %v", err) + } + if err := c.Set("permanent", []byte("permanent"), NoExpiration); err != nil { + t.Fatalf("Set(permanent, NoExpiration) returned error: %v", err) + } + + time.Sleep(75 * time.Millisecond) + + if _, err := c.Get("default"); err != ErrCacheMiss { + t.Errorf("Get(default) error = %v, want ErrCacheMiss", err) + } + if _, err := c.Get("custom"); err != nil { + t.Errorf("Get(custom) returned error before custom TTL elapsed: %v", err) + } + if _, err := c.Get("permanent"); err != nil { + t.Errorf("Get(permanent) returned error: %v", err) + } + + time.Sleep(50 * time.Millisecond) + + if _, err := c.Get("custom"); err != ErrCacheMiss { + t.Errorf("Get(custom) error = %v, want ErrCacheMiss", err) + } + if _, err := c.Get("permanent"); err != nil { + t.Errorf("Get(permanent) returned error after other entries expired: %v", err) + } +} + +func TestDiskCacheNoExpirationSurvivesReload(t *testing.T) { + testDir := "uploads/test_diskcache_no_expiration_reload" + defer func() { _ = os.RemoveAll(testDir) }() + _ = os.RemoveAll(testDir) + + c := New(testDir) + if err := c.Set("permanent", []byte("value"), NoExpiration); err != nil { + t.Fatalf("Set(permanent, NoExpiration) returned error: %v", err) + } + + reloaded := New(testDir) + defer func() { _ = reloaded.Clear() }() + + got, err := reloaded.Get("permanent") + if err != nil { + t.Fatalf("reloaded Get(permanent) returned error: %v", err) + } + if !bytes.Equal(got, []byte("value")) { + t.Errorf("reloaded Get(permanent) = %q, want %q", got, "value") + } +} + +func TestDiskCacheLRUEviction(t *testing.T) { + testDir := "uploads/test_diskcache_lru" + defer func() { _ = os.RemoveAll(testDir) }() + _ = os.RemoveAll(testDir) + + c := New(testDir) + defer func() { _ = c.Clear() }() + + // Force a very small max size of 20 bytes for testing (8 bytes header + payload) + // So 2 items of 2 bytes payload = 2 * (8 + 2) = 20 bytes max. + c.maxSize = 20 + c.lruEnabled = true + + // Write item 1: 8 + 2 = 10 bytes + err := c.Set("k1", []byte("v1"), DefaultExpiration) + if err != nil { + t.Fatalf("failed to set k1: %v", err) + } + + // Write item 2: 8 + 2 = 10 bytes + err = c.Set("k2", []byte("v2"), DefaultExpiration) + if err != nil { + t.Fatalf("failed to set k2: %v", err) + } + + // Both should exist + if _, err := c.Get("k1"); err != nil { + t.Errorf("k1 should exist: %v", err) + } + if _, err := c.Get("k2"); err != nil { + t.Errorf("k2 should exist: %v", err) + } + + // Write item 3: 8 + 2 = 10 bytes -> total size would be 30, exceeding 20. + // This should evict the oldest item. Since k1 was accessed, but then k2 was accessed, + // wait, let's access k1 again to make it the most recently used, so k2 becomes oldest! + _, _ = c.Get("k1") // k1 is now MRU, k2 is LRU + + err = c.Set("k3", []byte("v3"), DefaultExpiration) + if err != nil { + t.Fatalf("failed to set k3: %v", err) + } + + // k2 should be evicted, k1 and k3 should exist + _, err = c.Get("k2") + if err != ErrCacheMiss { + t.Errorf("expected k2 to be evicted, got error %v", err) + } + + if _, err := c.Get("k1"); err != nil { + t.Errorf("k1 should still exist: %v", err) + } + + if _, err := c.Get("k3"); err != nil { + t.Errorf("k3 should exist: %v", err) + } +} diff --git a/internal/httppool/httppool.go b/pkg/httppool/httppool.go similarity index 100% rename from internal/httppool/httppool.go rename to pkg/httppool/httppool.go diff --git a/internal/httppool/httppool_test.go b/pkg/httppool/httppool_test.go similarity index 100% rename from internal/httppool/httppool_test.go rename to pkg/httppool/httppool_test.go diff --git a/internal/logger/errs.go b/pkg/logger/errs.go similarity index 100% rename from internal/logger/errs.go rename to pkg/logger/errs.go diff --git a/internal/logger/logger.go b/pkg/logger/logger.go similarity index 65% rename from internal/logger/logger.go rename to pkg/logger/logger.go index 21abda16..7f9ba192 100644 --- a/internal/logger/logger.go +++ b/pkg/logger/logger.go @@ -14,6 +14,18 @@ import ( "go.uber.org/zap/zapcore" ) +// Config represents the logging configuration. +type Config struct { + Level string + Format string + Output string + FilePath string + MaxSize int + MaxAge int + MaxBackups int + Compress bool +} + var logger *otelzap.Logger // ringBufferCapacity 环形缓冲区容量 @@ -22,14 +34,16 @@ const ringBufferCapacity = 5000 // GlobalRingBuffer 全局日志环形缓冲区,供 Admin 日志查询和 WebSocket 推送使用 var GlobalRingBuffer *LogRingBuffer -func init() { - logWriter, err := GetLogWriter() +func doInit(cfg Config) { + logWriter, err := getLogWriterForConfig(cfg) if err != nil { log.Fatalf("[Logger] get log writer err: %v\n", err) } - // 初始化 ring buffer(保留最近 5000 行日志) - GlobalRingBuffer = NewLogRingBuffer(ringBufferCapacity) + // 初始化 ring buffer(保留最近 5000 行日志),如果是多次调用 Init,不需要重复创建 GlobalRingBuffer + if GlobalRingBuffer == nil { + GlobalRingBuffer = NewLogRingBuffer(ringBufferCapacity) + } // 使用 multi writer 同时写入原始输出和 ring buffer multiWriter := zapcore.NewMultiWriteSyncer( @@ -38,7 +52,7 @@ func init() { ) zapLogger := zap.New( - zapcore.NewCore(getEncoder(), multiWriter, getLogLevel()), + zapcore.NewCore(getEncoderForConfig(cfg), multiWriter, getLogLevelForConfig(cfg)), zap.AddCaller(), zap.AddCallerSkip(1), ) @@ -46,8 +60,21 @@ func init() { zapLogger, otelzap.WithMinLevel(zapLogger.Level()), ) +} - fmt.Printf("[Logger] %s\n", logger.Level()) +func init() { + // 默认使用 console stdout INFO 日志输出,避免在 Init 前或测试中发生空指针崩溃 + defaultCfg := Config{ + Level: "info", + Format: "console", + Output: "stdout", + } + doInit(defaultCfg) +} + +// Init initializes the logger with a custom configuration. +func Init(cfg Config) { + doInit(cfg) } // DebugF 输出 Debug 级别日志 diff --git a/internal/logger/ringbuffer.go b/pkg/logger/ringbuffer.go similarity index 100% rename from internal/logger/ringbuffer.go rename to pkg/logger/ringbuffer.go diff --git a/internal/logger/ringbuffer_test.go b/pkg/logger/ringbuffer_test.go similarity index 100% rename from internal/logger/ringbuffer_test.go rename to pkg/logger/ringbuffer_test.go diff --git a/internal/logger/utils.go b/pkg/logger/utils.go similarity index 65% rename from internal/logger/utils.go rename to pkg/logger/utils.go index 7d5d0e9a..83aa2592 100644 --- a/internal/logger/utils.go +++ b/pkg/logger/utils.go @@ -10,39 +10,20 @@ import ( "log" "os" "path/filepath" - "sync" - "github.com/Rain-kl/Wavelet/internal/config" "go.opentelemetry.io/otel/trace" "go.uber.org/zap" "go.uber.org/zap/zapcore" "gopkg.in/natefinch/lumberjack.v2" ) -var ( - logWriter zapcore.WriteSyncer - initLogWriterOnce sync.Once - initLogWriterErr error -) - -// GetLogWriter 获取日志输出写入器 -func GetLogWriter() (zapcore.WriteSyncer, error) { - initLogWriterOnce.Do(func() { - logWriter, initLogWriterErr = initWriter() - }) - - return logWriter, initLogWriterErr -} - // logDirPerm 日志目录权限 const logDirPerm = 0750 -func initWriter() (zapcore.WriteSyncer, error) { - logConfig := config.Config.Log - - if logConfig.Output == "file" { +func getLogWriterForConfig(cfg Config) (zapcore.WriteSyncer, error) { + if cfg.Output == "file" { // 初始化日志目录 - logPath := logConfig.FilePath + logPath := cfg.FilePath logDir := filepath.Dir(logPath) if err := os.MkdirAll(logDir, logDirPerm); err != nil { return nil, fmt.Errorf(errCreateLogFileDirFailed, err) @@ -51,10 +32,10 @@ func initWriter() (zapcore.WriteSyncer, error) { // 配置日志轮转 logOutput := &lumberjack.Logger{ Filename: logPath, - MaxSize: logConfig.MaxSize, - MaxBackups: logConfig.MaxBackups, - MaxAge: logConfig.MaxAge, - Compress: logConfig.Compress, + MaxSize: cfg.MaxSize, + MaxBackups: cfg.MaxBackups, + MaxAge: cfg.MaxAge, + Compress: cfg.Compress, } return zapcore.AddSync(logOutput), nil @@ -63,8 +44,8 @@ func initWriter() (zapcore.WriteSyncer, error) { return zapcore.AddSync(os.Stdout), nil } -// getEncoder 获取日志编码器 -func getEncoder() zapcore.Encoder { +// getEncoderForConfig 获取日志编码器 +func getEncoderForConfig(cfg Config) zapcore.Encoder { // 编码器配置 encoderConfig := zapcore.EncoderConfig{ TimeKey: "time", @@ -80,15 +61,15 @@ func getEncoder() zapcore.Encoder { EncodeCaller: zapcore.ShortCallerEncoder, } - if config.Config.Log.Format == "json" { + if cfg.Format == "json" { return zapcore.NewJSONEncoder(encoderConfig) } return zapcore.NewConsoleEncoder(encoderConfig) } -// getLogLevel 获取日志级别 -func getLogLevel() zapcore.Level { - level := config.Config.Log.Level +// getLogLevelForConfig 获取日志级别 +func getLogLevelForConfig(cfg Config) zapcore.Level { + level := cfg.Level switch level { case "debug": @@ -100,7 +81,7 @@ func getLogLevel() zapcore.Level { case "error": return zapcore.ErrorLevel default: - log.Fatalf("[Logger] invalid log level: %s\n", level) + log.Printf("[Logger] invalid log level: %s, defaulting to info\n", level) return zapcore.InfoLevel } } diff --git a/internal/util/mail/errs.go b/pkg/mail/errs.go similarity index 100% rename from internal/util/mail/errs.go rename to pkg/mail/errs.go diff --git a/internal/util/mail/mail.go b/pkg/mail/mail.go similarity index 100% rename from internal/util/mail/mail.go rename to pkg/mail/mail.go diff --git a/internal/util/mail/mail_test.go b/pkg/mail/mail_test.go similarity index 100% rename from internal/util/mail/mail_test.go rename to pkg/mail/mail_test.go diff --git a/internal/otel_trace/propagator.go b/pkg/trace/propagator.go similarity index 77% rename from internal/otel_trace/propagator.go rename to pkg/trace/propagator.go index 27fa4d19..56283b91 100644 --- a/internal/otel_trace/propagator.go +++ b/pkg/trace/propagator.go @@ -2,8 +2,8 @@ // Copyright 2026 Arctel.net // SPDX-License-Identifier: Apache-2.0 -// Package otel_trace 提供 OpenTelemetry 链路追踪封装工具 -package otel_trace +// Package trace 提供 OpenTelemetry 链路追踪封装工具 +package trace import "go.opentelemetry.io/otel/propagation" diff --git a/internal/otel_trace/sampler.go b/pkg/trace/sampler.go similarity index 96% rename from internal/otel_trace/sampler.go rename to pkg/trace/sampler.go index b3912f1b..d3efbbc0 100644 --- a/internal/otel_trace/sampler.go +++ b/pkg/trace/sampler.go @@ -2,7 +2,7 @@ // Copyright 2026 Arctel.net // SPDX-License-Identifier: Apache-2.0 -package otel_trace +package trace import ( sdktrace "go.opentelemetry.io/otel/sdk/trace" diff --git a/internal/otel_trace/trace.go b/pkg/trace/trace.go similarity index 69% rename from internal/otel_trace/trace.go rename to pkg/trace/trace.go index 2139a8f1..3bdeedb7 100644 --- a/internal/otel_trace/trace.go +++ b/pkg/trace/trace.go @@ -2,7 +2,7 @@ // Copyright 2026 Arctel.net // SPDX-License-Identifier: Apache-2.0 -package otel_trace +package trace import ( "context" @@ -21,15 +21,26 @@ func init() { prop := newPropagator() otel.SetTextMapPropagator(prop) - // 初始化 Trace Provider - tracerProvider, err := newTracerProvider() + // 初始化 Tracer 实例为 No-op 默认以避免未初始化前或测试环境崩溃 + Tracer = otel.GetTracerProvider().Tracer("github.com/Rain-kl/Wavelet") +} + +// Config 链路追踪配置 +type Config struct { + AppName string + SamplingRate float64 +} + +// Init 初始化 Tracer Provider 并关联全局 Tracer 实例 +func Init(cfg Config) { + tracerProvider, err := newTracerProvider(cfg) if err != nil { log.Fatalf("[Trace] init trace provider failed: %v", err) } shutdownFuncs = append(shutdownFuncs, tracerProvider.Shutdown) otel.SetTracerProvider(tracerProvider) - // 初始化 Tracer + // 更新 Tracer Tracer = tracerProvider.Tracer("github.com/Rain-kl/Wavelet") } @@ -38,7 +49,6 @@ func Shutdown(ctx context.Context) { for _, fn := range shutdownFuncs { _ = fn(ctx) } - shutdownFuncs = nil } // Start 创建一个新的 Trace Span diff --git a/internal/otel_trace/trace_provider.go b/pkg/trace/trace_provider.go similarity index 80% rename from internal/otel_trace/trace_provider.go rename to pkg/trace/trace_provider.go index a373c5b2..8aeb2e34 100644 --- a/internal/otel_trace/trace_provider.go +++ b/pkg/trace/trace_provider.go @@ -2,20 +2,19 @@ // Copyright 2026 Arctel.net // SPDX-License-Identifier: Apache-2.0 -package otel_trace +package trace import ( "context" "os" - "github.com/Rain-kl/Wavelet/internal/config" "go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc" "go.opentelemetry.io/otel/sdk/resource" sdktrace "go.opentelemetry.io/otel/sdk/trace" semconv "go.opentelemetry.io/otel/semconv/v1.26.0" ) -func newTracerProvider() (*sdktrace.TracerProvider, error) { +func newTracerProvider(cfg Config) (*sdktrace.TracerProvider, error) { // 获取主机名和容器信息 hostname, err := os.Hostname() if err != nil { @@ -27,7 +26,7 @@ func newTracerProvider() (*sdktrace.TracerProvider, error) { resource.Default(), resource.NewWithAttributes( semconv.SchemaURL, - semconv.ServiceName(config.Config.App.AppName), + semconv.ServiceName(cfg.AppName), semconv.HostName(hostname), semconv.K8SNamespaceName(os.Getenv("KUBERNETES_NAMESPACE")), semconv.K8SPodName(os.Getenv("KUBERNETES_POD_NAME")), @@ -48,7 +47,7 @@ func newTracerProvider() (*sdktrace.TracerProvider, error) { tracerProvider := sdktrace.NewTracerProvider( sdktrace.WithBatcher(traceExporter), sdktrace.WithResource(r), - sdktrace.WithSampler(ParentBasedErrorAwareSampler(config.Config.Otel.SamplingRate)), + sdktrace.WithSampler(ParentBasedErrorAwareSampler(cfg.SamplingRate)), ) return tracerProvider, nil } diff --git a/internal/util/crypto.go b/pkg/util/crypto.go similarity index 87% rename from internal/util/crypto.go rename to pkg/util/crypto.go index d1904798..ce31763b 100644 --- a/internal/util/crypto.go +++ b/pkg/util/crypto.go @@ -2,6 +2,7 @@ // Copyright 2026 Arctel.net // SPDX-License-Identifier: Apache-2.0 +// Package util provides generic utility functions. package util import ( @@ -16,8 +17,18 @@ import ( "io" ) -// aesKeyLength AES-256 密钥字节长度 -const aesKeyLength = 32 +const ( + aesKeyLength = 32 + + errInvalidSignKey = "invalid sign key: %w" + errSignKeyLengthInvalid = "sign key must be 32 bytes (64 hex characters)" + errCreateCipherFailed = "failed to create cipher: %w" + errCreateGCMFailed = "failed to create GCM: %w" + errGenerateNonceFailed = "failed to generate nonce: %w" + errDecodeCiphertextFailed = "failed to decode ciphertext: %w" + errCiphertextTooShort = "ciphertext too short" + errDecryptFailed = "failed to decrypt: %w" +) // Encrypt 使用 SignKey 加密字符串数据 // signKey: 64 字符 hex 编码的密钥(对应 32 字节,用于 AES-256) diff --git a/internal/util/password.go b/pkg/util/password.go similarity index 100% rename from internal/util/password.go rename to pkg/util/password.go diff --git a/internal/util/strings.go b/pkg/util/strings.go similarity index 100% rename from internal/util/strings.go rename to pkg/util/strings.go diff --git a/internal/util/uuid.go b/pkg/util/uuid.go similarity index 100% rename from internal/util/uuid.go rename to pkg/util/uuid.go