refactor(backend): extract to pkg/cap

This commit is contained in:
ryan
2026-06-15 16:19:18 +08:00
parent 84ae4ec27e
commit b3ed94342c
63 changed files with 770 additions and 706 deletions
+1 -1
View File
@@ -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"
)
+2 -2
View File
@@ -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"
+1 -1
View File
@@ -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"
)
+1 -1
View File
@@ -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() {
+2 -2
View File
@@ -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"
)
+1 -1
View File
@@ -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"
)
+1 -1
View File
@@ -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"
)
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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"
)
+1 -1
View File
@@ -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"
)
+6 -6
View File
@@ -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(),
})
+4 -3
View File
@@ -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{
+1 -1
View File
@@ -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"
)
+1 -1
View File
@@ -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"
)
+1 -1
View File
@@ -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"
)
+1 -1
View File
@@ -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"
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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"
)
+1 -1
View File
@@ -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"
)
+1 -1
View File
@@ -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.
+2 -1
View File
@@ -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")
}
+1 -1
View File
@@ -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"
)
+1 -1
View File
@@ -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"
)
// 异步任务名称与管理类型定义
+19
View File
@@ -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()
},
+1 -1
View File
@@ -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"
)
+20 -360
View File
@@ -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)
}
-203
View File
@@ -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()
+1 -1
View File
@@ -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"
)
+2 -2
View File
@@ -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"
+2 -2
View File
@@ -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"
@@ -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{
@@ -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)
+1 -1
View File
@@ -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"
)
+1 -1
View File
@@ -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) {
+2 -2
View File
@@ -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
}
+2 -2
View File
@@ -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"
+1 -1
View File
@@ -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"
)
-8
View File
@@ -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"
)
+3 -7
View File
@@ -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 客户端实例
+5 -3
View File
@@ -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,
+350
View File
@@ -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)
}
}
}
+212
View File
@@ -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)
}
}
@@ -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 级别日志
@@ -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
}
}
@@ -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"
@@ -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"
@@ -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
@@ -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
}
+13 -2
View File
@@ -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)