分享码有效期,支持表达式
This commit is contained in:
@@ -24,6 +24,10 @@ REDIS_PORT=6379
|
|||||||
REDIS_PASSWORD=
|
REDIS_PASSWORD=
|
||||||
REDIS_DATABASE=1
|
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)
|
# S3 (local development only; production uses EC2 IAM role)
|
||||||
# AWS_REGION=eu-central-1
|
# AWS_REGION=eu-central-1
|
||||||
# S3_BUCKET=luxsin-app-bucket
|
# S3_BUCKET=luxsin-app-bucket
|
||||||
|
|||||||
@@ -14,3 +14,6 @@ DATABASE_PASSWORD=your-production-password
|
|||||||
# S3 (uses EC2 IAM role; do not set access keys in production)
|
# S3 (uses EC2 IAM role; do not set access keys in production)
|
||||||
AWS_REGION=eu-central-1
|
AWS_REGION=eu-central-1
|
||||||
S3_BUCKET=luxsin-app-bucket
|
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
|
||||||
|
|||||||
+3
-2
@@ -78,6 +78,7 @@ func main() {
|
|||||||
zap.Int("port", cfg.Redis.Port),
|
zap.Int("port", cfg.Redis.Port),
|
||||||
zap.Int("db", cfg.Redis.Database),
|
zap.Int("db", cfg.Redis.Database),
|
||||||
)
|
)
|
||||||
|
log.Info("share code ttl configured", zap.Duration("ttl", cfg.ShareCodeTTL))
|
||||||
|
|
||||||
// 启动时预热缓存
|
// 启动时预热缓存
|
||||||
warmUpCache(log, db, rdb)
|
warmUpCache(log, db, rdb)
|
||||||
@@ -88,7 +89,7 @@ func main() {
|
|||||||
devicePersistTask := task.NewDevicePersistTask(rdb, deviceRepo, log)
|
devicePersistTask := task.NewDevicePersistTask(rdb, deviceRepo, log)
|
||||||
devicePersistTask.Start(5 * time.Minute)
|
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)
|
shareCodeRepo := repository.NewShareCodeRepository(db)
|
||||||
shareCodePersistTask := task.NewShareCodePersistTask(shareCodeCache, shareCodeRepo, log)
|
shareCodePersistTask := task.NewShareCodePersistTask(shareCodeCache, shareCodeRepo, log)
|
||||||
shareCodePersistTask.Start(5 * time.Minute)
|
shareCodePersistTask.Start(5 * time.Minute)
|
||||||
@@ -105,7 +106,7 @@ func main() {
|
|||||||
zap.String("region", cfg.S3.Region),
|
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{
|
srv := &http.Server{
|
||||||
Addr: cfg.Addr(),
|
Addr: cfg.Addr(),
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ services:
|
|||||||
- ENABLE_PERSIST_TASK=true
|
- ENABLE_PERSIST_TASK=true
|
||||||
- AWS_REGION=eu-central-1
|
- AWS_REGION=eu-central-1
|
||||||
- S3_BUCKET=luxsin-app-bucket
|
- 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:
|
healthcheck:
|
||||||
test: ["CMD", "wget", "-q", "-O", "/dev/null", "http://localhost:8080/api/v1/health"]
|
test: ["CMD", "wget", "-q", "-O", "/dev/null", "http://localhost:8080/api/v1/health"]
|
||||||
interval: 30s
|
interval: 30s
|
||||||
|
|||||||
Vendored
+3
-4
@@ -2,14 +2,14 @@
|
|||||||
//
|
//
|
||||||
// # Redis 存储结构
|
// # Redis 存储结构
|
||||||
//
|
//
|
||||||
// share:{code} Hash, TTL 30min — 分享码主数据
|
// share:{code} Hash, TTL = SHARE_CODE_TTL_MIN — 分享码主数据
|
||||||
// mac_addr 创建者 MAC 地址
|
// mac_addr 创建者 MAC 地址
|
||||||
// ip_addr 创建者 IP
|
// ip_addr 创建者 IP
|
||||||
// eq_data EQ 参数 JSON
|
// eq_data EQ 参数 JSON
|
||||||
// expire_at 过期时间 (RFC3339)
|
// expire_at 过期时间 (RFC3339)
|
||||||
// persisted 是否已刷入 DB ("0"/"1")
|
// persisted 是否已刷入 DB ("0"/"1")
|
||||||
//
|
//
|
||||||
// share:mac:{mac} ZSET, TTL 1h (兜底) — MAC 二级索引 (查询时主动清理过期成员)
|
// share:mac:{mac} ZSET, TTL = SHARE_CODE_TTL_MIN — MAC 二级索引 (查询时主动清理过期成员)
|
||||||
// member = share_code
|
// member = share_code
|
||||||
// score = expire_at unix timestamp
|
// score = expire_at unix timestamp
|
||||||
//
|
//
|
||||||
@@ -51,7 +51,6 @@ const (
|
|||||||
shareImportFlushLockPref = "share:import:flush:lock:"
|
shareImportFlushLockPref = "share:import:flush:lock:"
|
||||||
shareMacIndexPrefix = "share:mac:"
|
shareMacIndexPrefix = "share:mac:"
|
||||||
shareCodeLength = 5
|
shareCodeLength = 5
|
||||||
shareMacIndexTTL = 1 * time.Hour
|
|
||||||
shareImportPendingTTL = 12 * time.Hour
|
shareImportPendingTTL = 12 * time.Hour
|
||||||
shareCodeCharset = "23456789ABCDEFGHJKLMNPQRSTUVWXYZ"
|
shareCodeCharset = "23456789ABCDEFGHJKLMNPQRSTUVWXYZ"
|
||||||
shareCodeMaxRetries = 20
|
shareCodeMaxRetries = 20
|
||||||
@@ -126,7 +125,7 @@ func (c *ShareCodeCache) Create(ctx context.Context, macAddr, ipAddr string, eqD
|
|||||||
key := shareCodeKey(code)
|
key := shareCodeKey(code)
|
||||||
macIdxKey := shareMacIndexKey(macAddr)
|
macIdxKey := shareMacIndexKey(macAddr)
|
||||||
ok, err := shareCreateScript.Run(ctx, c.rdb, []string{key, sharePendingSet, macIdxKey},
|
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()
|
).Int()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("create share code in redis: %w", err)
|
return nil, fmt.Errorf("create share code in redis: %w", err)
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Config struct {
|
type Config struct {
|
||||||
@@ -17,7 +18,7 @@ type Config struct {
|
|||||||
S3 S3Config
|
S3 S3Config
|
||||||
EnablePersistTask bool
|
EnablePersistTask bool
|
||||||
ShareCodeMaxPerMac int
|
ShareCodeMaxPerMac int
|
||||||
ShareCodeTTLMin int
|
ShareCodeTTL time.Duration
|
||||||
}
|
}
|
||||||
|
|
||||||
func Load() (*Config, error) {
|
func Load() (*Config, error) {
|
||||||
@@ -49,6 +50,11 @@ func Load() (*Config, error) {
|
|||||||
eq := loadEqualize(env)
|
eq := loadEqualize(env)
|
||||||
s3cfg := loadS3(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{
|
return &Config{
|
||||||
Env: env,
|
Env: env,
|
||||||
Host: getEnv("APP_HOST", "0.0.0.0"),
|
Host: getEnv("APP_HOST", "0.0.0.0"),
|
||||||
@@ -60,7 +66,7 @@ func Load() (*Config, error) {
|
|||||||
S3: s3cfg,
|
S3: s3cfg,
|
||||||
EnablePersistTask: getEnv("ENABLE_PERSIST_TASK", "false") == "true",
|
EnablePersistTask: getEnv("ENABLE_PERSIST_TASK", "false") == "true",
|
||||||
ShareCodeMaxPerMac: getEnvInt("SHARE_CODE_MAX_PER_MAC", 1),
|
ShareCodeMaxPerMac: getEnvInt("SHARE_CODE_MAX_PER_MAC", 1),
|
||||||
ShareCodeTTLMin: getEnvInt("SHARE_CODE_TTL_MIN", 30),
|
ShareCodeTTL: shareCodeTTL,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -24,7 +24,7 @@ func NewShareCodeHandler(shareCache *cache.ShareCodeCache, log *zap.Logger, maxP
|
|||||||
// ExportShareCode 导出分享码
|
// ExportShareCode 导出分享码
|
||||||
//
|
//
|
||||||
// @Summary 创建 EQ 分享码
|
// @Summary 创建 EQ 分享码
|
||||||
// @Description 将用户的 EQ 数据生成一个 5 位分享码,有效期 30 分钟
|
// @Description 将用户的 EQ 数据生成一个 5 位分享码,有效期由 SHARE_CODE_TTL_MIN 配置决定(支持 30、30m、24h、30d 等格式)
|
||||||
// @Tags ShareCode
|
// @Tags ShareCode
|
||||||
// @Accept json
|
// @Accept json
|
||||||
// @Produce json
|
// @Produce json
|
||||||
|
|||||||
Reference in New Issue
Block a user