fix: 修复多个管理后台问题

- 分页接口 page_size 最大限制从 100 改为 1000
- 通过 Redis Pub/Sub 实现跨实例认证缓存失效
- 允许订阅类型分组编辑计费倍率
- 账号计费倍率支持 3 位小数
This commit is contained in:
shaw
2026-01-18 22:13:47 +08:00
parent f6360e0bf3
commit 2028cc29b7
8 changed files with 77 additions and 11 deletions

View File

@@ -162,11 +162,11 @@ func ParsePagination(c *gin.Context) (page, pageSize int) {
// 支持 page_size 和 limit 两种参数名 // 支持 page_size 和 limit 两种参数名
if ps := c.Query("page_size"); ps != "" { if ps := c.Query("page_size"); ps != "" {
if val, err := parseInt(ps); err == nil && val > 0 && val <= 100 { if val, err := parseInt(ps); err == nil && val > 0 && val <= 1000 {
pageSize = val pageSize = val
} }
} else if l := c.Query("limit"); l != "" { } else if l := c.Query("limit"); l != "" {
if val, err := parseInt(l); err == nil && val > 0 && val <= 100 { if val, err := parseInt(l); err == nil && val > 0 && val <= 1000 {
pageSize = val pageSize = val
} }
} }

View File

@@ -5,6 +5,7 @@ import (
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "fmt"
"log"
"time" "time"
"github.com/Wei-Shaw/sub2api/internal/service" "github.com/Wei-Shaw/sub2api/internal/service"
@@ -12,9 +13,10 @@ import (
) )
const ( const (
apiKeyRateLimitKeyPrefix = "apikey:ratelimit:" apiKeyRateLimitKeyPrefix = "apikey:ratelimit:"
apiKeyRateLimitDuration = 24 * time.Hour apiKeyRateLimitDuration = 24 * time.Hour
apiKeyAuthCachePrefix = "apikey:auth:" apiKeyAuthCachePrefix = "apikey:auth:"
authCacheInvalidateChannel = "auth:cache:invalidate"
) )
// apiKeyRateLimitKey generates the Redis key for API key creation rate limiting. // apiKeyRateLimitKey generates the Redis key for API key creation rate limiting.
@@ -91,3 +93,45 @@ func (c *apiKeyCache) SetAuthCache(ctx context.Context, key string, entry *servi
func (c *apiKeyCache) DeleteAuthCache(ctx context.Context, key string) error { func (c *apiKeyCache) DeleteAuthCache(ctx context.Context, key string) error {
return c.rdb.Del(ctx, apiKeyAuthCacheKey(key)).Err() return c.rdb.Del(ctx, apiKeyAuthCacheKey(key)).Err()
} }
// PublishAuthCacheInvalidation publishes a cache invalidation message to all instances
func (c *apiKeyCache) PublishAuthCacheInvalidation(ctx context.Context, cacheKey string) error {
return c.rdb.Publish(ctx, authCacheInvalidateChannel, cacheKey).Err()
}
// SubscribeAuthCacheInvalidation subscribes to cache invalidation messages
func (c *apiKeyCache) SubscribeAuthCacheInvalidation(ctx context.Context, handler func(cacheKey string)) error {
pubsub := c.rdb.Subscribe(ctx, authCacheInvalidateChannel)
// Verify subscription is working
_, err := pubsub.Receive(ctx)
if err != nil {
_ = pubsub.Close()
return fmt.Errorf("subscribe to auth cache invalidation: %w", err)
}
go func() {
defer func() {
if err := pubsub.Close(); err != nil {
log.Printf("Warning: failed to close auth cache invalidation pubsub: %v", err)
}
}()
ch := pubsub.Channel()
for {
select {
case <-ctx.Done():
return
case msg, ok := <-ch:
if !ok {
return
}
if msg != nil {
handler(msg.Payload)
}
}
}
}()
return nil
}

View File

@@ -94,6 +94,20 @@ func (s *APIKeyService) initAuthCache(cfg *config.Config) {
s.authCacheL1 = cache s.authCacheL1 = cache
} }
// StartAuthCacheInvalidationSubscriber starts the Pub/Sub subscriber for L1 cache invalidation.
// This should be called after the service is fully initialized.
func (s *APIKeyService) StartAuthCacheInvalidationSubscriber(ctx context.Context) {
if s.cache == nil || s.authCacheL1 == nil {
return
}
if err := s.cache.SubscribeAuthCacheInvalidation(ctx, func(cacheKey string) {
s.authCacheL1.Del(cacheKey)
}); err != nil {
// Log but don't fail - L1 cache will still work, just without cross-instance invalidation
println("[Service] Warning: failed to start auth cache invalidation subscriber:", err.Error())
}
}
func (s *APIKeyService) authCacheKey(key string) string { func (s *APIKeyService) authCacheKey(key string) string {
sum := sha256.Sum256([]byte(key)) sum := sha256.Sum256([]byte(key))
return hex.EncodeToString(sum[:]) return hex.EncodeToString(sum[:])
@@ -149,6 +163,8 @@ func (s *APIKeyService) deleteAuthCache(ctx context.Context, cacheKey string) {
return return
} }
_ = s.cache.DeleteAuthCache(ctx, cacheKey) _ = s.cache.DeleteAuthCache(ctx, cacheKey)
// Publish invalidation message to other instances
_ = s.cache.PublishAuthCacheInvalidation(ctx, cacheKey)
} }
func (s *APIKeyService) loadAuthCacheEntry(ctx context.Context, key, cacheKey string) (*APIKeyAuthCacheEntry, error) { func (s *APIKeyService) loadAuthCacheEntry(ctx context.Context, key, cacheKey string) (*APIKeyAuthCacheEntry, error) {

View File

@@ -65,6 +65,10 @@ type APIKeyCache interface {
GetAuthCache(ctx context.Context, key string) (*APIKeyAuthCacheEntry, error) GetAuthCache(ctx context.Context, key string) (*APIKeyAuthCacheEntry, error)
SetAuthCache(ctx context.Context, key string, entry *APIKeyAuthCacheEntry, ttl time.Duration) error SetAuthCache(ctx context.Context, key string, entry *APIKeyAuthCacheEntry, ttl time.Duration) error
DeleteAuthCache(ctx context.Context, key string) error DeleteAuthCache(ctx context.Context, key string) error
// Pub/Sub for L1 cache invalidation across instances
PublishAuthCacheInvalidation(ctx context.Context, cacheKey string) error
SubscribeAuthCacheInvalidation(ctx context.Context, handler func(cacheKey string)) error
} }
// APIKeyAuthCacheInvalidator 提供认证缓存失效能力 // APIKeyAuthCacheInvalidator 提供认证缓存失效能力

View File

@@ -1,6 +1,7 @@
package service package service
import ( import (
"context"
"database/sql" "database/sql"
"time" "time"
@@ -189,6 +190,8 @@ func ProvideOpsScheduledReportService(
// ProvideAPIKeyAuthCacheInvalidator 提供 API Key 认证缓存失效能力 // ProvideAPIKeyAuthCacheInvalidator 提供 API Key 认证缓存失效能力
func ProvideAPIKeyAuthCacheInvalidator(apiKeyService *APIKeyService) APIKeyAuthCacheInvalidator { func ProvideAPIKeyAuthCacheInvalidator(apiKeyService *APIKeyService) APIKeyAuthCacheInvalidator {
// Start Pub/Sub subscriber for L1 cache invalidation across instances
apiKeyService.StartAuthCacheInvalidationSubscriber(context.Background())
return apiKeyService return apiKeyService
} }

View File

@@ -1371,7 +1371,7 @@
</div> </div>
<div> <div>
<label class="input-label">{{ t('admin.accounts.billingRateMultiplier') }}</label> <label class="input-label">{{ t('admin.accounts.billingRateMultiplier') }}</label>
<input v-model.number="form.rate_multiplier" type="number" min="0" step="0.01" class="input" /> <input v-model.number="form.rate_multiplier" type="number" min="0" step="0.001" class="input" />
<p class="input-hint">{{ t('admin.accounts.billingRateMultiplierHint') }}</p> <p class="input-hint">{{ t('admin.accounts.billingRateMultiplierHint') }}</p>
</div> </div>
</div> </div>

View File

@@ -566,7 +566,7 @@
</div> </div>
<div> <div>
<label class="input-label">{{ t('admin.accounts.billingRateMultiplier') }}</label> <label class="input-label">{{ t('admin.accounts.billingRateMultiplier') }}</label>
<input v-model.number="form.rate_multiplier" type="number" min="0" step="0.01" class="input" /> <input v-model.number="form.rate_multiplier" type="number" min="0" step="0.001" class="input" />
<p class="input-hint">{{ t('admin.accounts.billingRateMultiplierHint') }}</p> <p class="input-hint">{{ t('admin.accounts.billingRateMultiplierHint') }}</p>
</div> </div>
</div> </div>

View File

@@ -243,7 +243,7 @@
/> />
<p class="input-hint">{{ t('admin.groups.platformHint') }}</p> <p class="input-hint">{{ t('admin.groups.platformHint') }}</p>
</div> </div>
<div v-if="createForm.subscription_type !== 'subscription'"> <div>
<label class="input-label">{{ t('admin.groups.form.rateMultiplier') }}</label> <label class="input-label">{{ t('admin.groups.form.rateMultiplier') }}</label>
<input <input
v-model.number="createForm.rate_multiplier" v-model.number="createForm.rate_multiplier"
@@ -680,7 +680,7 @@
/> />
<p class="input-hint">{{ t('admin.groups.platformNotEditable') }}</p> <p class="input-hint">{{ t('admin.groups.platformNotEditable') }}</p>
</div> </div>
<div v-if="editForm.subscription_type !== 'subscription'"> <div>
<label class="input-label">{{ t('admin.groups.form.rateMultiplier') }}</label> <label class="input-label">{{ t('admin.groups.form.rateMultiplier') }}</label>
<input <input
v-model.number="editForm.rate_multiplier" v-model.number="editForm.rate_multiplier"
@@ -1605,12 +1605,11 @@ const confirmDelete = async () => {
} }
} }
// 监听 subscription_type 变化,订阅模式时重置 rate_multiplier 为 1is_exclusive 为 true // 监听 subscription_type 变化,订阅模式时 is_exclusive 默认为 true
watch( watch(
() => createForm.subscription_type, () => createForm.subscription_type,
(newVal) => { (newVal) => {
if (newVal === 'subscription') { if (newVal === 'subscription') {
createForm.rate_multiplier = 1.0
createForm.is_exclusive = true createForm.is_exclusive = true
} }
} }