From b5560ca70256d89d899e8050fdce61ca7324ee01 Mon Sep 17 00:00:00 2001 From: eafonyang Date: Mon, 15 Jun 2026 20:02:43 +0800 Subject: [PATCH] =?UTF-8?q?=E5=88=86=E4=BA=AB=E7=A0=81=E6=9C=89=E6=95=88?= =?UTF-8?q?=E6=9C=9F=EF=BC=8C=E6=94=AF=E6=8C=81=E8=A1=A8=E8=BE=BE=E5=BC=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.example | 4 ++ .env.production.example | 3 ++ cmd/server/main.go | 5 ++- docker-compose.yml | 2 + internal/cache/share_code_cache.go | 7 ++- internal/config/config.go | 10 ++++- internal/config/share_code_ttl.go | 59 ++++++++++++++++++++++++++ internal/config/share_code_ttl_test.go | 47 ++++++++++++++++++++ internal/handler/share_code.go | 2 +- 9 files changed, 130 insertions(+), 9 deletions(-) create mode 100644 internal/config/share_code_ttl.go create mode 100644 internal/config/share_code_ttl_test.go diff --git a/.env.example b/.env.example index 8bac6fc..7b09eb8 100644 --- a/.env.example +++ b/.env.example @@ -24,6 +24,10 @@ REDIS_PORT=6379 REDIS_PASSWORD= REDIS_DATABASE=1 +# Share code TTL (bare number = minutes; suffix: m/h/d), e.g. 30, 30m, 24h, 30d +SHARE_CODE_TTL_MIN=30 +# SHARE_CODE_MAX_PER_MAC=1 + # S3 (local development only; production uses EC2 IAM role) # AWS_REGION=eu-central-1 # S3_BUCKET=luxsin-app-bucket diff --git a/.env.production.example b/.env.production.example index ccd1964..b7cf860 100644 --- a/.env.production.example +++ b/.env.production.example @@ -14,3 +14,6 @@ DATABASE_PASSWORD=your-production-password # S3 (uses EC2 IAM role; do not set access keys in production) AWS_REGION=eu-central-1 S3_BUCKET=luxsin-app-bucket + +# Share code TTL (bare number = minutes; suffix: m/h/d), e.g. 30, 30m, 24h, 30d +SHARE_CODE_TTL_MIN=30 diff --git a/cmd/server/main.go b/cmd/server/main.go index e8476b1..9d630ac 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -78,6 +78,7 @@ func main() { zap.Int("port", cfg.Redis.Port), zap.Int("db", cfg.Redis.Database), ) + log.Info("share code ttl configured", zap.Duration("ttl", cfg.ShareCodeTTL)) // 启动时预热缓存 warmUpCache(log, db, rdb) @@ -88,7 +89,7 @@ func main() { devicePersistTask := task.NewDevicePersistTask(rdb, deviceRepo, log) devicePersistTask.Start(5 * time.Minute) - shareCodeCache := cache.NewShareCodeCache(rdb, time.Duration(cfg.ShareCodeTTLMin)*time.Minute) + shareCodeCache := cache.NewShareCodeCache(rdb, cfg.ShareCodeTTL) shareCodeRepo := repository.NewShareCodeRepository(db) shareCodePersistTask := task.NewShareCodePersistTask(shareCodeCache, shareCodeRepo, log) shareCodePersistTask.Start(5 * time.Minute) @@ -105,7 +106,7 @@ func main() { zap.String("region", cfg.S3.Region), ) - engine := router.New(log, db, searchClient, rdb, cfg.Equalize, s3Storage, cfg.ShareCodeMaxPerMac, time.Duration(cfg.ShareCodeTTLMin)*time.Minute, cfg.Env) + engine := router.New(log, db, searchClient, rdb, cfg.Equalize, s3Storage, cfg.ShareCodeMaxPerMac, cfg.ShareCodeTTL, cfg.Env) srv := &http.Server{ Addr: cfg.Addr(), diff --git a/docker-compose.yml b/docker-compose.yml index 29222bc..4680c9e 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -17,6 +17,8 @@ services: - ENABLE_PERSIST_TASK=true - AWS_REGION=eu-central-1 - S3_BUCKET=luxsin-app-bucket + - SHARE_CODE_MAX_PER_MAC=${SHARE_CODE_MAX_PER_MAC:-1} + - SHARE_CODE_TTL_MIN=${SHARE_CODE_TTL_MIN:-30} healthcheck: test: ["CMD", "wget", "-q", "-O", "/dev/null", "http://localhost:8080/api/v1/health"] interval: 30s diff --git a/internal/cache/share_code_cache.go b/internal/cache/share_code_cache.go index 6013b85..7247cf3 100644 --- a/internal/cache/share_code_cache.go +++ b/internal/cache/share_code_cache.go @@ -2,14 +2,14 @@ // // # Redis 存储结构 // -// share:{code} Hash, TTL 30min — 分享码主数据 +// share:{code} Hash, TTL = SHARE_CODE_TTL_MIN — 分享码主数据 // mac_addr 创建者 MAC 地址 // ip_addr 创建者 IP // eq_data EQ 参数 JSON // expire_at 过期时间 (RFC3339) // persisted 是否已刷入 DB ("0"/"1") // -// share:mac:{mac} ZSET, TTL 1h (兜底) — MAC 二级索引 (查询时主动清理过期成员) +// share:mac:{mac} ZSET, TTL = SHARE_CODE_TTL_MIN — MAC 二级索引 (查询时主动清理过期成员) // member = share_code // score = expire_at unix timestamp // @@ -51,7 +51,6 @@ const ( shareImportFlushLockPref = "share:import:flush:lock:" shareMacIndexPrefix = "share:mac:" shareCodeLength = 5 - shareMacIndexTTL = 1 * time.Hour shareImportPendingTTL = 12 * time.Hour shareCodeCharset = "23456789ABCDEFGHJKLMNPQRSTUVWXYZ" shareCodeMaxRetries = 20 @@ -126,7 +125,7 @@ func (c *ShareCodeCache) Create(ctx context.Context, macAddr, ipAddr string, eqD key := shareCodeKey(code) macIdxKey := shareMacIndexKey(macAddr) ok, err := shareCreateScript.Run(ctx, c.rdb, []string{key, sharePendingSet, macIdxKey}, - macAddr, ipAddr, eqJSON, expireAt.Format(time.RFC3339), int(c.codeTTL.Seconds()), code, expireAt.Unix(), int(shareMacIndexTTL.Seconds()), + macAddr, ipAddr, eqJSON, expireAt.Format(time.RFC3339), int(c.codeTTL.Seconds()), code, expireAt.Unix(), int(c.codeTTL.Seconds()), ).Int() if err != nil { return nil, fmt.Errorf("create share code in redis: %w", err) diff --git a/internal/config/config.go b/internal/config/config.go index 774862f..46737f7 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -4,6 +4,7 @@ import ( "fmt" "os" "strconv" + "time" ) type Config struct { @@ -17,7 +18,7 @@ type Config struct { S3 S3Config EnablePersistTask bool ShareCodeMaxPerMac int - ShareCodeTTLMin int + ShareCodeTTL time.Duration } func Load() (*Config, error) { @@ -49,6 +50,11 @@ func Load() (*Config, error) { eq := loadEqualize(env) s3cfg := loadS3(env) + shareCodeTTL, err := parseShareCodeTTL(getEnv("SHARE_CODE_TTL_MIN", "30")) + if err != nil { + return nil, fmt.Errorf("invalid SHARE_CODE_TTL_MIN: %w", err) + } + return &Config{ Env: env, Host: getEnv("APP_HOST", "0.0.0.0"), @@ -60,7 +66,7 @@ func Load() (*Config, error) { S3: s3cfg, EnablePersistTask: getEnv("ENABLE_PERSIST_TASK", "false") == "true", ShareCodeMaxPerMac: getEnvInt("SHARE_CODE_MAX_PER_MAC", 1), - ShareCodeTTLMin: getEnvInt("SHARE_CODE_TTL_MIN", 30), + ShareCodeTTL: shareCodeTTL, }, nil } diff --git a/internal/config/share_code_ttl.go b/internal/config/share_code_ttl.go new file mode 100644 index 0000000..a143b60 --- /dev/null +++ b/internal/config/share_code_ttl.go @@ -0,0 +1,59 @@ +package config + +import ( + "fmt" + "strconv" + "strings" + "time" +) + +const shareCodeTTLMax = 365 * 24 * time.Hour + +// parseShareCodeTTL parses SHARE_CODE_TTL_MIN values: +// - bare number (e.g. "30") = minutes (backward compatible) +// - with suffix: m (minutes), h (hours), d (days), e.g. "30m", "24h", "30d" +func parseShareCodeTTL(raw string) (time.Duration, error) { + raw = strings.TrimSpace(raw) + if raw == "" { + return 30 * time.Minute, nil + } + + if n, err := strconv.Atoi(raw); err == nil { + if n <= 0 { + return 0, fmt.Errorf("must be positive") + } + d := time.Duration(n) * time.Minute + if d > shareCodeTTLMax { + return 0, fmt.Errorf("exceeds maximum of 365d") + } + return d, nil + } + + if len(raw) < 2 { + return 0, fmt.Errorf("invalid format %q", raw) + } + + unit := strings.ToLower(string(raw[len(raw)-1])) + numStr := raw[:len(raw)-1] + n, err := strconv.Atoi(numStr) + if err != nil || n <= 0 { + return 0, fmt.Errorf("invalid format %q", raw) + } + + var d time.Duration + switch unit { + case "m": + d = time.Duration(n) * time.Minute + case "h": + d = time.Duration(n) * time.Hour + case "d": + d = time.Duration(n) * 24 * time.Hour + default: + return 0, fmt.Errorf("unknown unit %q (use m, h, or d)", unit) + } + + if d > shareCodeTTLMax { + return 0, fmt.Errorf("exceeds maximum of 365d") + } + return d, nil +} diff --git a/internal/config/share_code_ttl_test.go b/internal/config/share_code_ttl_test.go new file mode 100644 index 0000000..8f918ff --- /dev/null +++ b/internal/config/share_code_ttl_test.go @@ -0,0 +1,47 @@ +package config + +import ( + "testing" + "time" +) + +func TestParseShareCodeTTL(t *testing.T) { + tests := []struct { + raw string + want time.Duration + wantErr bool + }{ + {"", 30 * time.Minute, false}, + {"30", 30 * time.Minute, false}, + {"30m", 30 * time.Minute, false}, + {"30M", 30 * time.Minute, false}, + {"24h", 24 * time.Hour, false}, + {"24H", 24 * time.Hour, false}, + {"30d", 30 * 24 * time.Hour, false}, + {"365d", 365 * 24 * time.Hour, false}, + {"0", 0, true}, + {"-1", 0, true}, + {"0m", 0, true}, + {"abc", 0, true}, + {"30x", 0, true}, + {"366d", 0, true}, + } + + for _, tt := range tests { + t.Run(tt.raw, func(t *testing.T) { + got, err := parseShareCodeTTL(tt.raw) + if tt.wantErr { + if err == nil { + t.Fatalf("expected error for %q", tt.raw) + } + return + } + if err != nil { + t.Fatalf("unexpected error for %q: %v", tt.raw, err) + } + if got != tt.want { + t.Fatalf("parseShareCodeTTL(%q) = %v, want %v", tt.raw, got, tt.want) + } + }) + } +} diff --git a/internal/handler/share_code.go b/internal/handler/share_code.go index e6def73..cc678bc 100644 --- a/internal/handler/share_code.go +++ b/internal/handler/share_code.go @@ -24,7 +24,7 @@ func NewShareCodeHandler(shareCache *cache.ShareCodeCache, log *zap.Logger, maxP // ExportShareCode 导出分享码 // // @Summary 创建 EQ 分享码 -// @Description 将用户的 EQ 数据生成一个 5 位分享码,有效期 30 分钟 +// @Description 将用户的 EQ 数据生成一个 5 位分享码,有效期由 SHARE_CODE_TTL_MIN 配置决定(支持 30、30m、24h、30d 等格式) // @Tags ShareCode // @Accept json // @Produce json