Merge pull request #2891 from DaydreamCoding/feat/platform-quota-conn-optimization

feat(quota): 收敛 user×platform 配额的 DB 连接占用(sentinel 回填 + 写聚合 flusher)
This commit is contained in:
Wesley Liddick 2026-05-29 20:17:08 +08:00 committed by GitHub
commit f18451e56f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
26 changed files with 1685 additions and 115 deletions

View File

@ -98,6 +98,7 @@ func provideCleanup(
backupSvc *service.BackupService,
paymentOrderExpiry *service.PaymentOrderExpiryService,
channelMonitorRunner *service.ChannelMonitorRunner,
quotaFlusher *service.UserPlatformQuotaUsageFlusher,
) func() {
return func() {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
@ -246,6 +247,12 @@ func provideCleanup(
}
return nil
}},
{"UserPlatformQuotaUsageFlusher", func() error {
if quotaFlusher != nil {
quotaFlusher.Stop()
}
return nil
}},
}
infraSteps := []cleanupStep{

View File

@ -269,7 +269,8 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
scheduledTestRunnerService := service.ProvideScheduledTestRunnerService(scheduledTestPlanRepository, scheduledTestService, accountTestService, rateLimitService, configConfig)
paymentOrderExpiryService := service.ProvidePaymentOrderExpiryService(paymentService)
channelMonitorRunner := service.ProvideChannelMonitorRunner(channelMonitorService, settingService)
v := provideCleanup(client, redisClient, opsMetricsCollector, opsAggregationService, opsAlertEvaluatorService, opsCleanupService, opsScheduledReportService, opsSystemLogSink, schedulerSnapshotService, tokenRefreshService, accountExpiryService, subscriptionExpiryService, usageCleanupService, idempotencyCleanupService, pricingService, emailQueueService, billingCacheService, usageRecordWorkerPool, subscriptionService, oAuthService, openAIOAuthService, geminiOAuthService, antigravityOAuthService, openAIGatewayService, scheduledTestRunnerService, backupService, paymentOrderExpiryService, channelMonitorRunner)
userPlatformQuotaUsageFlusher := service.ProvideUserPlatformQuotaUsageFlusher(configConfig, billingCache, serviceUserPlatformQuotaRepository, timingWheelService)
v := provideCleanup(client, redisClient, opsMetricsCollector, opsAggregationService, opsAlertEvaluatorService, opsCleanupService, opsScheduledReportService, opsSystemLogSink, schedulerSnapshotService, tokenRefreshService, accountExpiryService, subscriptionExpiryService, usageCleanupService, idempotencyCleanupService, pricingService, emailQueueService, billingCacheService, usageRecordWorkerPool, subscriptionService, oAuthService, openAIOAuthService, geminiOAuthService, antigravityOAuthService, openAIGatewayService, scheduledTestRunnerService, backupService, paymentOrderExpiryService, channelMonitorRunner, userPlatformQuotaUsageFlusher)
application := &Application{
Server: httpServer,
Cleanup: v,
@ -324,6 +325,7 @@ func provideCleanup(
backupSvc *service.BackupService,
paymentOrderExpiry *service.PaymentOrderExpiryService,
channelMonitorRunner *service.ChannelMonitorRunner,
quotaFlusher *service.UserPlatformQuotaUsageFlusher,
) func() {
return func() {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
@ -471,6 +473,12 @@ func provideCleanup(
}
return nil
}},
{"UserPlatformQuotaUsageFlusher", func() error {
if quotaFlusher != nil {
quotaFlusher.Stop()
}
return nil
}},
}
infraSteps := []cleanupStep{

View File

@ -77,6 +77,7 @@ func TestProvideCleanup_WithMinimalDependencies_NoPanic(t *testing.T) {
nil, // backupSvc
nil, // paymentOrderExpiry
nil, // channelMonitorRunner
nil, // quotaFlusher
)
require.NotPanics(t, func() {

View File

@ -649,6 +649,9 @@ type BillingConfig struct {
// - billing_cache_service.checkUserPlatformQuotaEligibility 首次缓存装载
// 读写两端必须共用同一 TTL避免缓存生命周期不一致导致 quota 计数漂移。
UserPlatformQuotaCacheTTLSeconds int `mapstructure:"user_platform_quota_cache_ttl_seconds"`
// UserPlatformQuotaSentinelTTLSeconds sentinel(无 limit 占位)entry 的 TTL,
// 显著短于 quota cache 默认 86400s 以控 Redis 内存;默认 3600=1h。
UserPlatformQuotaSentinelTTLSeconds int `mapstructure:"user_platform_quota_sentinel_ttl_seconds"`
}
type CircuitBreakerConfig struct {
@ -1091,6 +1094,13 @@ type DatabaseConfig struct {
ConnMaxLifetimeMinutes int `mapstructure:"conn_max_lifetime_minutes"`
// ConnMaxIdleTimeMinutes: 空闲连接最大存活时间,及时释放不活跃连接
ConnMaxIdleTimeMinutes int `mapstructure:"conn_max_idle_time_minutes"`
// UserPlatformQuotaFlusherEnabled: 是否启用 user×platform 配额写聚合 flusher
UserPlatformQuotaFlusherEnabled bool `mapstructure:"user_platform_quota_flusher_enabled"`
// UserPlatformQuotaFlushIntervalMs: flusher 刷写间隔(毫秒)
UserPlatformQuotaFlushIntervalMs int `mapstructure:"user_platform_quota_flush_interval_ms"`
// UserPlatformQuotaFlushBatchSize: flusher 单批最大条数
// 建议 ≤ 6000单条 UPSERT 原子上限)
UserPlatformQuotaFlushBatchSize int `mapstructure:"user_platform_quota_flush_batch_size"`
}
func (d *DatabaseConfig) DSN() string {
@ -1571,6 +1581,7 @@ func setDefaults() {
viper.SetDefault("billing.circuit_breaker.reset_timeout_seconds", 30)
viper.SetDefault("billing.circuit_breaker.half_open_requests", 3)
viper.SetDefault("billing.user_platform_quota_cache_ttl_seconds", 86400)
viper.SetDefault("billing.user_platform_quota_sentinel_ttl_seconds", 3600)
// Turnstile
viper.SetDefault("turnstile.required", false)
@ -1657,6 +1668,9 @@ func setDefaults() {
viper.SetDefault("database.max_idle_conns", 128)
viper.SetDefault("database.conn_max_lifetime_minutes", 30)
viper.SetDefault("database.conn_max_idle_time_minutes", 5)
viper.SetDefault("database.user_platform_quota_flusher_enabled", false)
viper.SetDefault("database.user_platform_quota_flush_interval_ms", 2000)
viper.SetDefault("database.user_platform_quota_flush_batch_size", 1000)
// Redis
viper.SetDefault("redis.host", "localhost")

View File

@ -743,7 +743,7 @@ func (h *UserHandler) UpdateUserPlatformQuotas(c *gin.Context) {
if h.billingCache != nil {
for _, p := range service.AllowedQuotaPlatforms {
if err := h.billingCache.DeleteUserPlatformQuotaCache(ctx, userID, p); err != nil {
slog.Warn("quota cache invalidation failed", "user_id", userID, "platform", p, "err", err)
slog.Error("ALERT: quota cache invalidation failed after UpsertForUser; limit 生效可能延迟至 sentinel TTL(最长 1h),需人工确认或重试失效", "user_id", userID, "platform", p, "err", err)
}
}
}
@ -827,7 +827,7 @@ func (h *UserHandler) ResetUserPlatformQuotaWindow(c *gin.Context) {
if h.billingCache != nil {
if err := h.billingCache.DeleteUserPlatformQuotaCache(ctx, userID, req.Platform); err != nil {
slog.Warn("quota cache invalidation failed", "user_id", userID, "platform", req.Platform, "err", err)
slog.Error("ALERT: quota cache invalidation failed after ResetExpiredWindow; 窗口重置可能延迟至 sentinel TTL(最长 1h)", "user_id", userID, "platform", req.Platform, "err", err)
}
}

View File

@ -7,6 +7,7 @@ import (
"log"
"math/rand/v2"
"strconv"
"strings"
"time"
"github.com/Wei-Shaw/sub2api/internal/service"
@ -338,38 +339,26 @@ func userPlatformQuotaCacheKey(userID int64, platform string) string {
return fmt.Sprintf("billing:user_platform_quota:%d:%s", userID, platform)
}
func (c *billingCache) GetUserPlatformQuotaCache(ctx context.Context, userID int64, platform string) (*service.UserPlatformQuotaCacheEntry, bool, error) {
key := userPlatformQuotaCacheKey(userID, platform)
fields := []string{
"daily_usage", "weekly_usage", "monthly_usage", "version", "schema_version",
"daily_limit", "weekly_limit", "monthly_limit",
"daily_window_start", "weekly_window_start", "monthly_window_start",
// parseUserPlatformQuotaHash 将 Redis HGETALL 返回的 map[string]string 反序列化为
// *service.UserPlatformQuotaCacheEntry。空 mapkey 不存在)返回 nil。
// GetUserPlatformQuotaCache 和 BatchGetUserPlatformQuotaCache 共用此函数,确保解析逻辑一致。
func parseUserPlatformQuotaHash(m map[string]string) *service.UserPlatformQuotaCacheEntry {
if len(m) == 0 {
return nil
}
vals, err := c.rdb.HMGet(ctx, key, fields...).Result()
if err != nil {
return nil, false, err
}
// 前4个全为nil → key 不存在
if vals[0] == nil && vals[1] == nil && vals[2] == nil && vals[3] == nil {
return nil, false, nil
}
parseFloat := func(v any) float64 {
if v == nil {
parseFloat := func(s string) float64 {
if s == "" {
return 0
}
s, ok := v.(string)
if !ok {
f, err := strconv.ParseFloat(s, 64)
if err != nil {
log.Printf("billing_cache: corrupt quota usage field %q (using 0): %v", s, err)
return 0
}
f, _ := strconv.ParseFloat(s, 64)
return f
}
parseFloatPtr := func(v any) *float64 {
if v == nil {
return nil
}
s, ok := v.(string)
if !ok || s == "" {
parseFloatPtr := func(s string) *float64 {
if s == "" {
return nil
}
f, err := strconv.ParseFloat(s, 64)
@ -378,12 +367,8 @@ func (c *billingCache) GetUserPlatformQuotaCache(ctx context.Context, userID int
}
return &f
}
parseTimePtr := func(v any) *time.Time {
if v == nil {
return nil
}
s, ok := v.(string)
if !ok || s == "" {
parseTimePtr := func(s string) *time.Time {
if s == "" {
return nil
}
n, err := strconv.ParseInt(s, 10, 64)
@ -393,30 +378,37 @@ func (c *billingCache) GetUserPlatformQuotaCache(ctx context.Context, userID int
t := time.Unix(n, 0).UTC()
return &t
}
parseInt64 := func(v any) int64 {
if v == nil {
return 0
}
s, ok := v.(string)
if !ok {
return 0
}
parseInt64 := func(s string) int64 {
n, _ := strconv.ParseInt(s, 10, 64)
return n
}
return &service.UserPlatformQuotaCacheEntry{
DailyUsageUSD: parseFloat(vals[0]),
WeeklyUsageUSD: parseFloat(vals[1]),
MonthlyUsageUSD: parseFloat(vals[2]),
Version: parseInt64(vals[3]),
SchemaVersion: parseInt64(vals[4]),
DailyLimitUSD: parseFloatPtr(vals[5]),
WeeklyLimitUSD: parseFloatPtr(vals[6]),
MonthlyLimitUSD: parseFloatPtr(vals[7]),
DailyWindowStart: parseTimePtr(vals[8]),
WeeklyWindowStart: parseTimePtr(vals[9]),
MonthlyWindowStart: parseTimePtr(vals[10]),
}, true, nil
DailyUsageUSD: parseFloat(m["daily_usage"]),
WeeklyUsageUSD: parseFloat(m["weekly_usage"]),
MonthlyUsageUSD: parseFloat(m["monthly_usage"]),
Version: parseInt64(m["version"]),
SchemaVersion: parseInt64(m["schema_version"]),
DailyLimitUSD: parseFloatPtr(m["daily_limit"]),
WeeklyLimitUSD: parseFloatPtr(m["weekly_limit"]),
MonthlyLimitUSD: parseFloatPtr(m["monthly_limit"]),
DailyWindowStart: parseTimePtr(m["daily_window_start"]),
WeeklyWindowStart: parseTimePtr(m["weekly_window_start"]),
MonthlyWindowStart: parseTimePtr(m["monthly_window_start"]),
}
}
func (c *billingCache) GetUserPlatformQuotaCache(ctx context.Context, userID int64, platform string) (*service.UserPlatformQuotaCacheEntry, bool, error) {
key := userPlatformQuotaCacheKey(userID, platform)
m, err := c.rdb.HGetAll(ctx, key).Result()
if err != nil {
return nil, false, err
}
entry := parseUserPlatformQuotaHash(m)
if entry == nil {
// 空 map → key 不存在 → MISS
return nil, false, nil
}
return entry, true, nil
}
func (c *billingCache) SetUserPlatformQuotaCache(ctx context.Context, userID int64, platform string, entry *service.UserPlatformQuotaCacheEntry, ttl time.Duration) error {
@ -468,9 +460,12 @@ func (c *billingCache) DeleteUserPlatformQuotaCache(ctx context.Context, userID
// SetCache 重建为新版 entry —— 若此处仍累加,上层覆盖时会丢失这部分增量,导致 Redis usage 比真实偏小。
// key 不存在同样跳过(由下次 SetCache 重建)。
// KEYS[1] = hash key
// KEYS[2] = 脏集 keydirty set
// ARGV[1] = cost (string float)
// ARGV[2] = ttl seconds
// ARGV[3] = expected schema_version (Go 侧 UserPlatformQuotaCacheSchemaV1)
// ARGV[4] = dirty set member空串则不 SADD
// ARGV[5] = 脏集兜底 TTL 秒
const updateUserPlatformQuotaUsageScript = `
if redis.call("EXISTS", KEYS[1]) == 0 then
return 0
@ -484,18 +479,125 @@ redis.call("HINCRBYFLOAT", KEYS[1], "weekly_usage", ARGV[1])
redis.call("HINCRBYFLOAT", KEYS[1], "monthly_usage", ARGV[1])
redis.call("HINCRBY", KEYS[1], "version", 1)
redis.call("EXPIRE", KEYS[1], ARGV[2])
if ARGV[4] ~= "" then
redis.call("SADD", KEYS[2], ARGV[4])
redis.call("EXPIRE", KEYS[2], ARGV[5])
end
return 1
`
func (c *billingCache) IncrUserPlatformQuotaUsageCache(ctx context.Context, userID int64, platform string, cost float64, ttl time.Duration) error {
key := userPlatformQuotaCacheKey(userID, platform)
_, err := c.rdb.Eval(ctx, updateUserPlatformQuotaUsageScript, []string{key},
// userPlatformQuotaDirtySetKey 返回脏集dirty set的 Redis key。
// 使用与 userPlatformQuotaCacheKey 相同的前缀 "billing:"。
func userPlatformQuotaDirtySetKey() string { return "billing:" + "upq:dirty" }
// userPlatformQuotaDirtyTTLSeconds 脏集兜底 TTL初始 SADDLua与 Readd 共用,
// 确保 flusher 长期停摆时脏集最终过期;正常运行因持续 SADD 不断续期。
const userPlatformQuotaDirtyTTLSeconds = 86400
// userPlatformQuotaDirtyMember 构造脏集成员字符串 "userID:platform"。
func userPlatformQuotaDirtyMember(userID int64, platform string) string {
return strconv.FormatInt(userID, 10) + ":" + platform
}
func (c *billingCache) IncrUserPlatformQuotaUsageCache(ctx context.Context, userID int64, platform string, cost float64, ttl time.Duration, markDirty bool) error {
member := ""
if markDirty {
member = userPlatformQuotaDirtyMember(userID, platform)
}
_, err := c.rdb.Eval(ctx, updateUserPlatformQuotaUsageScript,
[]string{userPlatformQuotaCacheKey(userID, platform), userPlatformQuotaDirtySetKey()},
strconv.FormatFloat(cost, 'f', -1, 64),
int(ttl.Seconds()),
service.UserPlatformQuotaCacheSchemaV1,
member,
userPlatformQuotaDirtyTTLSeconds,
).Result()
if err != nil && !errors.Is(err, redis.Nil) {
return err
}
return nil
}
// parseUserPlatformQuotaDirtyMember 将脏集成员字符串 "userID:platform" 解析为
// service.UserPlatformQuotaKey。解析失败返回 ok=false。
func parseUserPlatformQuotaDirtyMember(m string) (service.UserPlatformQuotaKey, bool) {
parts := strings.SplitN(m, ":", 2)
if len(parts) != 2 {
return service.UserPlatformQuotaKey{}, false
}
uid, err := strconv.ParseInt(parts[0], 10, 64)
if err != nil {
return service.UserPlatformQuotaKey{}, false
}
return service.UserPlatformQuotaKey{UserID: uid, Platform: parts[1]}, true
}
// PopDirtyUserPlatformQuotaKeys 从脏集随机弹出最多 n 个 key。
// 脏集为空时返回 (nil, nil)。
func (c *billingCache) PopDirtyUserPlatformQuotaKeys(ctx context.Context, n int) ([]service.UserPlatformQuotaKey, error) {
members, err := c.rdb.SPopN(ctx, userPlatformQuotaDirtySetKey(), int64(n)).Result()
if err != nil {
if errors.Is(err, redis.Nil) {
return nil, nil
}
return nil, err
}
keys := make([]service.UserPlatformQuotaKey, 0, len(members))
for _, m := range members {
k, ok := parseUserPlatformQuotaDirtyMember(m)
if !ok {
log.Printf("billing_cache: skipping invalid dirty member %q", m)
continue
}
keys = append(keys, k)
}
return keys, nil
}
// ReaddDirtyUserPlatformQuotaKeys 将 keys 重新加入脏集flush 失败时回填)。
// 通过 pipeline 同时执行 SAdd + Expire确保 Readd 后脏集具有兜底 TTL。
// 空切片时直接返回 nil。
func (c *billingCache) ReaddDirtyUserPlatformQuotaKeys(ctx context.Context, keys []service.UserPlatformQuotaKey) error {
if len(keys) == 0 {
return nil
}
dirtyKey := userPlatformQuotaDirtySetKey()
members := make([]any, len(keys))
for i, k := range keys {
members[i] = userPlatformQuotaDirtyMember(k.UserID, k.Platform)
}
pipe := c.rdb.Pipeline()
pipe.SAdd(ctx, dirtyKey, members...)
pipe.Expire(ctx, dirtyKey, userPlatformQuotaDirtyTTLSeconds*time.Second)
_, err := pipe.Exec(ctx)
return err
}
// BatchGetUserPlatformQuotaCache 通过 Pipeline 批量 HGETALL 获取多个 user×platform 的
// quota cache。返回切片与 keys 顺序、长度对齐MISS 或解析失败位置返回 nil。
func (c *billingCache) BatchGetUserPlatformQuotaCache(ctx context.Context, keys []service.UserPlatformQuotaKey) ([]*service.UserPlatformQuotaCacheEntry, error) {
if len(keys) == 0 {
return nil, nil
}
pipe := c.rdb.Pipeline()
cmds := make([]*redis.MapStringStringCmd, len(keys))
for i, k := range keys {
cmds[i] = pipe.HGetAll(ctx, userPlatformQuotaCacheKey(k.UserID, k.Platform))
}
if _, err := pipe.Exec(ctx); err != nil && !errors.Is(err, redis.Nil) {
return nil, err
}
results := make([]*service.UserPlatformQuotaCacheEntry, len(keys))
for i, cmd := range cmds {
m, err := cmd.Result()
if err != nil {
if !errors.Is(err, redis.Nil) {
log.Printf("billing_cache: BatchGet HGETALL cmd[%d] failed: %v (skip, self-heal)", i, err)
}
// 单个命令失败 → 对应位置 nil继续
continue
}
results[i] = parseUserPlatformQuotaHash(m)
}
return results, nil
}

View File

@ -88,7 +88,7 @@ func TestUserPlatformQuotaCache_NilLimitSetThenGet(t *testing.T) {
func TestUserPlatformQuotaCache_IncrMissIsNoop(t *testing.T) {
c, _ := newMiniRedisCache(t)
if err := c.IncrUserPlatformQuotaUsageCache(context.Background(), 1, "openai", 0.5, time.Minute); err != nil {
if err := c.IncrUserPlatformQuotaUsageCache(context.Background(), 1, "openai", 0.5, time.Minute, false); err != nil {
t.Fatal(err)
}
_, ok, _ := c.GetUserPlatformQuotaCache(context.Background(), 1, "openai")
@ -105,10 +105,10 @@ func TestUserPlatformQuotaCache_IncrHitAccumulates(t *testing.T) {
Version: 1,
SchemaVersion: service.UserPlatformQuotaCacheSchemaV1,
}, time.Minute)
if err := c.IncrUserPlatformQuotaUsageCache(ctx, 1, "openai", 0.5, time.Minute); err != nil {
if err := c.IncrUserPlatformQuotaUsageCache(ctx, 1, "openai", 0.5, time.Minute, false); err != nil {
t.Fatal(err)
}
if err := c.IncrUserPlatformQuotaUsageCache(ctx, 1, "openai", 0.25, time.Minute); err != nil {
if err := c.IncrUserPlatformQuotaUsageCache(ctx, 1, "openai", 0.25, time.Minute, false); err != nil {
t.Fatal(err)
}
got, _, _ := c.GetUserPlatformQuotaCache(ctx, 1, "openai")

View File

@ -38,6 +38,9 @@ func (f *fakeRepoForAdapter) UpsertForUser(_ context.Context, userID int64, reco
f.upsertCalledWith = records
return f.upsertErr
}
func (f *fakeRepoForAdapter) BatchSnapshotUsage(_ context.Context, _ []UserPlatformQuotaSnapshot, _ time.Time) error {
return nil
}
func TestGenericAdapter_UpsertForUser_ForwardsRecords(t *testing.T) {
fake := &fakeRepoForAdapter{}

View File

@ -2,6 +2,7 @@ package repository
import (
"context"
"errors"
"fmt"
"strings"
"time"
@ -9,6 +10,7 @@ import (
dbent "github.com/Wei-Shaw/sub2api/ent"
"github.com/Wei-Shaw/sub2api/ent/userplatformquota"
"github.com/Wei-Shaw/sub2api/internal/pkg/timezone"
"github.com/lib/pq"
)
// UserPlatformQuotaRecord 是 repository 层的传输结构体,
@ -30,6 +32,22 @@ type UserPlatformQuotaRecord struct {
// ErrUserPlatformQuotaNotFound 用于 ResetExpiredWindow 等需要"必须命中已有记录"的方法。
var ErrUserPlatformQuotaNotFound = fmt.Errorf("user platform quota record not found")
// ErrUserPlatformQuotaFKViolation 当批量 UPSERT 中存在 user_id 不在 users 表的记录时返回。
var ErrUserPlatformQuotaFKViolation = errors.New("user platform quota snapshot FK violation")
// UserPlatformQuotaSnapshot 是 BatchSnapshotUsage 的输入结构体,
// 表示 Redis 当前窗口快照(用于绝对值覆盖写入 DB
type UserPlatformQuotaSnapshot struct {
UserID int64
Platform string
DailyUsageUSD float64
WeeklyUsageUSD float64
MonthlyUsageUSD float64
DailyWindowStart time.Time
WeeklyWindowStart time.Time
MonthlyWindowStart time.Time
}
// UserPlatformQuotaRepository 定义用户平台配额的数据访问接口。
type UserPlatformQuotaRepository interface {
// BulkInsertInitial 幂等批量插入初始配额记录ON CONFLICT DO NOTHING
@ -44,6 +62,10 @@ type UserPlatformQuotaRepository interface {
ResetExpiredWindow(ctx context.Context, userID int64, platform string, window string, newStart time.Time) error
// UpsertForUser 全量替换该用户所有平台限额配置(详见 service.UserPlatformQuotaRepository.UpsertForUser
UpsertForUser(ctx context.Context, userID int64, records []UserPlatformQuotaRecord) error
// BatchSnapshotUsage 用一条多行 UPSERT 把整批 usage 以绝对值覆盖写入(非累加)。
// usage/window_start 直接取 EXCLUDED(Redis 当前窗口快照),无 CASE。整批共用 now 作 created/updated_at。
// 要求 snapshots 内 (user,platform) 不重复。FK 违反返回 ErrUserPlatformQuotaFKViolation。
BatchSnapshotUsage(ctx context.Context, snapshots []UserPlatformQuotaSnapshot, now time.Time) error
}
type userPlatformQuotaRepository struct {
@ -414,3 +436,73 @@ func insertLimitsRow(ctx context.Context, client *dbent.Client, userID int64, re
}
return nil
}
// batchRows 是 BatchSnapshotUsage 每批最大行数9 参/行 × 6000 ≈ 54000 参,低于 Postgres 65535 上限)。
const batchRows = 6000
// BatchSnapshotUsage 用一条多行 UPSERT 把整批 usage 以绝对值覆盖写入(非累加)。
// 每批最多 batchRows 行;$1=now 共用;每行 8 个 per-row 参user_id, platform, 3×usage, 3×window_start
// FK 违反user_id 不存在)返回 ErrUserPlatformQuotaFKViolation。
//
// 注意:snapshots 超过 batchRows 会分多条 SQL 执行且【非单事务】——若某子批 FK 失败,
// 先前子批已写入无法回滚。调用方(flusher)应保证单次 batchSize ≤ batchRows
// (默认 flush_batch_size=1000 < 6000,安全)。
// 另注:启用 flusher 后,本绝对值覆盖与 admin 直写 DB(ResetExpiredWindow/UpsertForUser)存在覆盖竞态,
// 详见 service/user_platform_quota_flusher.go 中 flushOneBatch 的"已知竞态"注释。
func (r *userPlatformQuotaRepository) BatchSnapshotUsage(ctx context.Context, snapshots []UserPlatformQuotaSnapshot, now time.Time) error {
if len(snapshots) == 0 {
return nil
}
client := clientFromContext(ctx, r.client)
for start := 0; start < len(snapshots); start += batchRows {
end := start + batchRows
if end > len(snapshots) {
end = len(snapshots)
}
batch := snapshots[start:end]
var sb strings.Builder
_, _ = sb.WriteString(
"INSERT INTO user_platform_quotas" +
" (user_id, platform, daily_usage_usd, weekly_usage_usd, monthly_usage_usd," +
" daily_window_start, weekly_window_start, monthly_window_start, created_at, updated_at)" +
" VALUES ")
// $1 = now共用每行 8 个 per-row 参,从 $2 起连续编号。
args := []any{now}
for i, s := range batch {
if i > 0 {
_, _ = sb.WriteString(",")
}
b := len(args) // 当前 per-row 第一个参数的 0-based 索引,实际占位符 = b+1
fmt.Fprintf(&sb, "($%d,$%d,$%d,$%d,$%d,$%d,$%d,$%d,$1,$1)",
b+1, b+2, b+3, b+4, b+5, b+6, b+7, b+8)
args = append(args,
s.UserID, s.Platform,
s.DailyUsageUSD, s.WeeklyUsageUSD, s.MonthlyUsageUSD,
s.DailyWindowStart, s.WeeklyWindowStart, s.MonthlyWindowStart,
)
}
_, _ = sb.WriteString(
" ON CONFLICT (user_id, platform) WHERE deleted_at IS NULL DO UPDATE SET" +
" daily_usage_usd = EXCLUDED.daily_usage_usd," +
" weekly_usage_usd = EXCLUDED.weekly_usage_usd," +
" monthly_usage_usd = EXCLUDED.monthly_usage_usd," +
" daily_window_start = EXCLUDED.daily_window_start," +
" weekly_window_start = EXCLUDED.weekly_window_start," +
" monthly_window_start = EXCLUDED.monthly_window_start," +
" updated_at = EXCLUDED.updated_at")
if _, err := client.ExecContext(ctx, sb.String(), args...); err != nil {
var pqErr *pq.Error
if errors.As(err, &pqErr) && pqErr.Code == "23503" {
return ErrUserPlatformQuotaFKViolation
}
return err
}
}
return nil
}

View File

@ -267,3 +267,101 @@ func TestUserPlatformQuotaRepository_ResetExpiredWindow_NotFoundReturnsSentinel(
require.True(t, errors.Is(err, ErrUserPlatformQuotaNotFound),
"expected ErrUserPlatformQuotaNotFound, got %v", err)
}
// TestBatchSnapshotUsage_InsertOverwriteMultiKey 验证 BatchSnapshotUsage 的绝对值覆盖语义:
// 1. 首批插入 2 条(不同 user验证 daily 等于首批值;
// 2. 对同一 key 传不同值,验证 daily 等于新值(绝对覆盖,非累加)。
func TestBatchSnapshotUsage_InsertOverwriteMultiKey(t *testing.T) {
ctx := context.Background()
// BatchSnapshotUsage 不开事务(直接写),使用独立 client 保证跨调用可见性。
client := testEntClient(t)
userID1 := mustCreateUserForQuota(t, client)
userID2 := mustCreateUserForQuota(t, client)
repo := NewUserPlatformQuotaRepository(client)
now := time.Date(2026, 5, 29, 12, 0, 0, 0, time.UTC)
dailyStart := time.Date(2026, 5, 29, 0, 0, 0, 0, time.UTC)
weeklyStart := time.Date(2026, 5, 25, 0, 0, 0, 0, time.UTC) // 当周一
monthlyStart := time.Date(2026, 5, 1, 0, 0, 0, 0, time.UTC)
// ── 第一批:插入 2 行 ──────────────────────────────────────────────────────
firstBatch := []UserPlatformQuotaSnapshot{
{
UserID: userID1,
Platform: "anthropic",
DailyUsageUSD: 1.0,
WeeklyUsageUSD: 3.0,
MonthlyUsageUSD: 5.0,
DailyWindowStart: dailyStart,
WeeklyWindowStart: weeklyStart,
MonthlyWindowStart: monthlyStart,
},
{
UserID: userID2,
Platform: "openai",
DailyUsageUSD: 2.0,
WeeklyUsageUSD: 4.0,
MonthlyUsageUSD: 6.0,
DailyWindowStart: dailyStart,
WeeklyWindowStart: weeklyStart,
MonthlyWindowStart: monthlyStart,
},
}
require.NoError(t, repo.BatchSnapshotUsage(ctx, firstBatch, now), "first batch upsert")
// 验证首批 daily 值
rec1, err := repo.GetByUserPlatform(ctx, userID1, "anthropic")
require.NoError(t, err)
require.NotNil(t, rec1, "user1/anthropic should exist after first batch")
require.InDelta(t, 1.0, rec1.DailyUsageUSD, 1e-9, "user1 daily after first batch")
require.InDelta(t, 3.0, rec1.WeeklyUsageUSD, 1e-9, "user1 weekly after first batch")
require.InDelta(t, 5.0, rec1.MonthlyUsageUSD, 1e-9, "user1 monthly after first batch")
rec2, err := repo.GetByUserPlatform(ctx, userID2, "openai")
require.NoError(t, err)
require.NotNil(t, rec2, "user2/openai should exist after first batch")
require.InDelta(t, 2.0, rec2.DailyUsageUSD, 1e-9, "user2 daily after first batch")
// ── 第二批:对同一 key 传不同值,验证绝对覆盖(非累加)──────────────────
now2 := now.Add(5 * time.Minute)
secondBatch := []UserPlatformQuotaSnapshot{
{
UserID: userID1,
Platform: "anthropic",
DailyUsageUSD: 9.9, // 新值,不是 1.0+9.9=10.9
WeeklyUsageUSD: 19.9, // 新值,不是 3.0+19.9=22.9
MonthlyUsageUSD: 29.9, // 新值
DailyWindowStart: dailyStart,
WeeklyWindowStart: weeklyStart,
MonthlyWindowStart: monthlyStart,
},
{
UserID: userID2,
Platform: "openai",
DailyUsageUSD: 8.8,
WeeklyUsageUSD: 18.8,
MonthlyUsageUSD: 28.8,
DailyWindowStart: dailyStart,
WeeklyWindowStart: weeklyStart,
MonthlyWindowStart: monthlyStart,
},
}
require.NoError(t, repo.BatchSnapshotUsage(ctx, secondBatch, now2), "second batch upsert")
// 验证第二批覆盖daily 应为新值,不是累加
rec1After, err := repo.GetByUserPlatform(ctx, userID1, "anthropic")
require.NoError(t, err)
require.NotNil(t, rec1After)
require.InDelta(t, 9.9, rec1After.DailyUsageUSD, 1e-9, "user1 daily must be overwritten to 9.9 (not accumulated)")
require.InDelta(t, 19.9, rec1After.WeeklyUsageUSD, 1e-9, "user1 weekly must be overwritten to 19.9")
require.InDelta(t, 29.9, rec1After.MonthlyUsageUSD, 1e-9, "user1 monthly must be overwritten to 29.9")
rec2After, err := repo.GetByUserPlatform(ctx, userID2, "openai")
require.NoError(t, err)
require.NotNil(t, rec2After)
require.InDelta(t, 8.8, rec2After.DailyUsageUSD, 1e-9, "user2 daily must be overwritten to 8.8 (not accumulated)")
require.InDelta(t, 18.8, rec2After.WeeklyUsageUSD, 1e-9, "user2 weekly must be overwritten to 18.8")
require.InDelta(t, 28.8, rec2After.MonthlyUsageUSD, 1e-9, "user2 monthly must be overwritten to 28.8")
}

View File

@ -94,6 +94,29 @@ func (a *userPlatformQuotaServiceAdapter) ResetExpiredWindow(ctx context.Context
return err
}
// BatchSnapshotUsage 转换 []service.UserPlatformQuotaSnapshot → []UserPlatformQuotaSnapshot
// 调底层 repo并将 repository FK sentinel 包装为 service sentinel。
func (a *userPlatformQuotaServiceAdapter) BatchSnapshotUsage(ctx context.Context, snapshots []service.UserPlatformQuotaSnapshot, now time.Time) error {
repoSnaps := make([]UserPlatformQuotaSnapshot, len(snapshots))
for i, s := range snapshots {
repoSnaps[i] = UserPlatformQuotaSnapshot{
UserID: s.UserID,
Platform: s.Platform,
DailyUsageUSD: s.DailyUsageUSD,
WeeklyUsageUSD: s.WeeklyUsageUSD,
MonthlyUsageUSD: s.MonthlyUsageUSD,
DailyWindowStart: s.DailyWindowStart,
WeeklyWindowStart: s.WeeklyWindowStart,
MonthlyWindowStart: s.MonthlyWindowStart,
}
}
err := a.inner.BatchSnapshotUsage(ctx, repoSnaps, now)
if errors.Is(err, ErrUserPlatformQuotaFKViolation) {
return fmt.Errorf("%w: %v", service.ErrUserPlatformQuotaFKViolation, err)
}
return err
}
// genericUserPlatformQuotaAdapter 通过通用接口适配(用于测试 fake 或非标准实现)。
type genericUserPlatformQuotaAdapter struct {
inner UserPlatformQuotaRepository
@ -167,6 +190,29 @@ func (a *genericUserPlatformQuotaAdapter) ResetExpiredWindow(ctx context.Context
return err
}
// BatchSnapshotUsage 转换 []service.UserPlatformQuotaSnapshot → []UserPlatformQuotaSnapshot通用 adapter
// 并将 repository FK sentinel 包装为 service sentinel。
func (a *genericUserPlatformQuotaAdapter) BatchSnapshotUsage(ctx context.Context, snapshots []service.UserPlatformQuotaSnapshot, now time.Time) error {
repoSnaps := make([]UserPlatformQuotaSnapshot, len(snapshots))
for i, s := range snapshots {
repoSnaps[i] = UserPlatformQuotaSnapshot{
UserID: s.UserID,
Platform: s.Platform,
DailyUsageUSD: s.DailyUsageUSD,
WeeklyUsageUSD: s.WeeklyUsageUSD,
MonthlyUsageUSD: s.MonthlyUsageUSD,
DailyWindowStart: s.DailyWindowStart,
WeeklyWindowStart: s.WeeklyWindowStart,
MonthlyWindowStart: s.MonthlyWindowStart,
}
}
err := a.inner.BatchSnapshotUsage(ctx, repoSnaps, now)
if errors.Is(err, ErrUserPlatformQuotaFKViolation) {
return fmt.Errorf("%w: %v", service.ErrUserPlatformQuotaFKViolation, err)
}
return err
}
// toServiceRecord 将 repository.UserPlatformQuotaRecord 转换为 service.UserPlatformQuotaRecord。
func toServiceRecord(rec *UserPlatformQuotaRecord) *service.UserPlatformQuotaRecord {
return &service.UserPlatformQuotaRecord{

View File

@ -471,10 +471,22 @@ func (s *billingCacheStub) DeleteUserPlatformQuotaCache(ctx context.Context, use
panic("unexpected DeleteUserPlatformQuotaCache call")
}
func (s *billingCacheStub) IncrUserPlatformQuotaUsageCache(ctx context.Context, userID int64, platform string, cost float64, ttl time.Duration) error {
func (s *billingCacheStub) IncrUserPlatformQuotaUsageCache(ctx context.Context, userID int64, platform string, cost float64, ttl time.Duration, markDirty bool) error {
panic("unexpected IncrUserPlatformQuotaUsageCache call")
}
func (s *billingCacheStub) PopDirtyUserPlatformQuotaKeys(ctx context.Context, n int) ([]UserPlatformQuotaKey, error) {
panic("unexpected PopDirtyUserPlatformQuotaKeys call")
}
func (s *billingCacheStub) ReaddDirtyUserPlatformQuotaKeys(ctx context.Context, keys []UserPlatformQuotaKey) error {
panic("unexpected ReaddDirtyUserPlatformQuotaKeys call")
}
func (s *billingCacheStub) BatchGetUserPlatformQuotaCache(ctx context.Context, keys []UserPlatformQuotaKey) ([]*UserPlatformQuotaCacheEntry, error) {
panic("unexpected BatchGetUserPlatformQuotaCache call")
}
func waitForInvalidations(t *testing.T, ch <-chan subscriptionInvalidateCall, expected int) []subscriptionInvalidateCall {
t.Helper()
calls := make([]subscriptionInvalidateCall, 0, expected)

View File

@ -43,6 +43,10 @@ func (f *fakeInsertRecorder) ResetExpiredWindow(_ context.Context, _ int64, _ st
return nil
}
func (f *fakeInsertRecorder) BatchSnapshotUsage(_ context.Context, _ []UserPlatformQuotaSnapshot, _ time.Time) error {
return nil
}
func TestSnapshotPlatformQuotaDefaults_PassesToRepoBulkInsert(t *testing.T) {
fakeRepo := &fakeInsertRecorder{}
s := &AuthService{userPlatformQuotaRepo: fakeRepo}

View File

@ -105,6 +105,10 @@ func (s *userPlatformQuotaRepoStub) ResetExpiredWindow(context.Context, int64, s
panic("unexpected ResetExpiredWindow call")
}
func (s *userPlatformQuotaRepoStub) BatchSnapshotUsage(_ context.Context, _ []UserPlatformQuotaSnapshot, _ time.Time) error {
return nil
}
func (s *defaultSubscriptionAssignerStub) AssignOrExtendSubscription(_ context.Context, input *AssignSubscriptionInput) (*UserSubscription, bool, error) {
if input != nil {
s.calls = append(s.calls, *input)

View File

@ -689,7 +689,8 @@ func (s *BillingCacheService) IncrementUserPlatformQuotaUsage(userID int64, plat
ctx, cancel := context.WithTimeout(context.Background(), cacheWriteTimeout)
defer cancel()
ttl := time.Duration(s.cfg.Billing.UserPlatformQuotaCacheTTLSeconds) * time.Second
if err := s.cache.IncrUserPlatformQuotaUsageCache(ctx, userID, platform, cost, ttl); err != nil {
markDirty := s.cfg.Database.UserPlatformQuotaFlusherEnabled
if err := s.cache.IncrUserPlatformQuotaUsageCache(ctx, userID, platform, cost, ttl, markDirty); err != nil {
logger.LegacyPrintf("service.billing_cache",
"ALERT: incr user platform quota cache failed user=%d platform=%s cost=%f: %v",
userID, platform, cost, err)
@ -1096,7 +1097,12 @@ func (s *BillingCacheService) checkUserPlatformQuotaEligibility(
// 超时 50ms:覆盖正常路径与可接受抖动;Redis 异常时 hot path 不阻塞超过此值。
// 用 context.Background()+短超时,避免请求 ctx 取消导致刷新丢失。
// 显式 setCancel()(而非 defer):缩短 context 生命周期,避免 defer 延迟到函数返回。
if windowExpired && s.cache != nil {
// isSentinel 判定「该 entry 无任何 limit」,涵盖两类,跨窗口命中时都跳过 refresh:
// 1) A3 回填的 sentinel(DB 无行,短 TTL):refresh 会把短 TTL 误升级为 86400s,有害;
// 2) DB 有行但三 limit 全未配置的用户(TTL 86400s):refresh 纯属无意义(TTL 升级本身无害)。
// 两类的 enforcement(下方 limit!=nil 比较)都因 limit 全 nil 永远放行,跳过 refresh 均正确。
isSentinel := entry.DailyLimitUSD == nil && entry.WeeklyLimitUSD == nil && entry.MonthlyLimitUSD == nil
if windowExpired && s.cache != nil && !isSentinel {
refreshed := &UserPlatformQuotaCacheEntry{
DailyUsageUSD: dailyUsage,
WeeklyUsageUSD: weeklyUsage,
@ -1159,6 +1165,33 @@ func (s *BillingCacheService) checkUserPlatformQuotaEligibility(
}
rec, _ := v.(*UserPlatformQuotaRecord)
if rec == nil {
// 仅在 cache 可用且本次 GET 未出错时回填 sentinel:Redis GET 故障(cacheErr!=nil)
// 时不回填,与下方 line ~1201 "Redis 故障时 fail-open:不回填" 保持一致,
// 避免在 Redis 异常期做一次注定失败的 SET。
if s.cache != nil && cacheErr == nil {
now := time.Now()
startOfDay := timezone.StartOfDay(now)
startOfWeek := timezone.StartOfWeek(now)
sentinel := &UserPlatformQuotaCacheEntry{
SchemaVersion: UserPlatformQuotaCacheSchemaV1,
DailyWindowStart: &startOfDay,
WeeklyWindowStart: &startOfWeek,
MonthlyWindowStart: &now,
// limits 全 nil, usage 全 0(零值)
}
sentinelTTL := time.Duration(s.cfg.Billing.UserPlatformQuotaSentinelTTLSeconds) * time.Second
if sentinelTTL <= 0 {
// 防御:TTL<=0 时 Redis EXPIRE 会立即删除整个 key(见 billing_cache.go 的 pipe.Expire),
// sentinel 不持久化 → 每请求击穿 DB。配置缺失/误配为 0 时 fallback 到 1h。
sentinelTTL = time.Hour
}
setCtx, setCancel := context.WithTimeout(context.Background(), 50*time.Millisecond)
if setErr := s.cache.SetUserPlatformQuotaCache(setCtx, userID, platform, sentinel, sentinelTTL); setErr != nil {
userPlatformQuotaSentinelSetCacheErrorTotal.Add(1)
logger.LegacyPrintf("service.billing_cache", "Warning: set sentinel quota cache failed user=%d platform=%s: %v", userID, platform, setErr)
}
setCancel()
}
return nil
}
@ -1278,3 +1311,20 @@ func monthlyQuotaWindowExpired(start *time.Time, now time.Time) bool {
}
return now.Sub(*start) >= 30*24*time.Hour
}
// HasUserPlatformQuotaLimit 判断该 user×platform 是否设了任一非 nil limit。
// 写入点守卫:无 limit 直接跳过 Redis 写 + 脏集标记,消除无谓写入。
// fail-safe:任何不确定(simple 模式除外)都返回 true 维持写入。
func (s *BillingCacheService) HasUserPlatformQuotaLimit(ctx context.Context, userID int64, platform string) bool {
if s.cfg.RunMode == config.RunModeSimple {
return false
}
if s.cache == nil {
return true
}
entry, ok, err := s.cache.GetUserPlatformQuotaCache(ctx, userID, platform)
if err != nil || !ok || entry == nil {
return true
}
return entry.DailyLimitUSD != nil || entry.WeeklyLimitUSD != nil || entry.MonthlyLimitUSD != nil
}

View File

@ -79,10 +79,22 @@ func (s *billingCacheMissStub) DeleteUserPlatformQuotaCache(ctx context.Context,
return nil
}
func (s *billingCacheMissStub) IncrUserPlatformQuotaUsageCache(ctx context.Context, userID int64, platform string, cost float64, ttl time.Duration) error {
func (s *billingCacheMissStub) IncrUserPlatformQuotaUsageCache(ctx context.Context, userID int64, platform string, cost float64, ttl time.Duration, markDirty bool) error {
return nil
}
func (s *billingCacheMissStub) PopDirtyUserPlatformQuotaKeys(ctx context.Context, n int) ([]UserPlatformQuotaKey, error) {
return nil, nil
}
func (s *billingCacheMissStub) ReaddDirtyUserPlatformQuotaKeys(ctx context.Context, keys []UserPlatformQuotaKey) error {
return nil
}
func (s *billingCacheMissStub) BatchGetUserPlatformQuotaCache(ctx context.Context, keys []UserPlatformQuotaKey) ([]*UserPlatformQuotaCacheEntry, error) {
return nil, nil
}
type balanceLoadUserRepoStub struct {
mockUserRepo
calls atomic.Int64

View File

@ -80,10 +80,22 @@ func (b *billingCacheWorkerStub) DeleteUserPlatformQuotaCache(ctx context.Contex
return nil
}
func (b *billingCacheWorkerStub) IncrUserPlatformQuotaUsageCache(ctx context.Context, userID int64, platform string, cost float64, ttl time.Duration) error {
func (b *billingCacheWorkerStub) IncrUserPlatformQuotaUsageCache(ctx context.Context, userID int64, platform string, cost float64, ttl time.Duration, markDirty bool) error {
return nil
}
func (b *billingCacheWorkerStub) PopDirtyUserPlatformQuotaKeys(ctx context.Context, n int) ([]UserPlatformQuotaKey, error) {
return nil, nil
}
func (b *billingCacheWorkerStub) ReaddDirtyUserPlatformQuotaKeys(ctx context.Context, keys []UserPlatformQuotaKey) error {
return nil
}
func (b *billingCacheWorkerStub) BatchGetUserPlatformQuotaCache(ctx context.Context, keys []UserPlatformQuotaKey) ([]*UserPlatformQuotaCacheEntry, error) {
return nil, nil
}
func TestBillingCacheServiceQueueHighLoad(t *testing.T) {
cache := &billingCacheWorkerStub{}
svc := NewBillingCacheService(cache, nil, nil, nil, nil, nil, &config.Config{}, nil)

View File

@ -20,14 +20,15 @@ type fakeIncrCache struct {
}
type incrCall struct {
userID int64
platform string
cost float64
ttl time.Duration
userID int64
platform string
cost float64
ttl time.Duration
markDirty bool
}
func (f *fakeIncrCache) IncrUserPlatformQuotaUsageCache(ctx context.Context, userID int64, platform string, cost float64, ttl time.Duration) error {
f.calls = append(f.calls, incrCall{userID, platform, cost, ttl})
func (f *fakeIncrCache) IncrUserPlatformQuotaUsageCache(ctx context.Context, userID int64, platform string, cost float64, ttl time.Duration, markDirty bool) error {
f.calls = append(f.calls, incrCall{userID, platform, cost, ttl, markDirty})
return nil
}
@ -49,10 +50,10 @@ func TestIncrementUserPlatformQuotaUsage_SyncCallsCache(t *testing.T) {
if len(fake.calls) != 2 {
t.Fatalf("expected 2 incr calls, got %d", len(fake.calls))
}
if fake.calls[0] != (incrCall{101, "anthropic", 0.25, 120 * time.Second}) {
if fake.calls[0] != (incrCall{userID: 101, platform: "anthropic", cost: 0.25, ttl: 120 * time.Second, markDirty: false}) {
t.Errorf("call[0] = %+v", fake.calls[0])
}
if fake.calls[1] != (incrCall{101, "openai", 0.50, 120 * time.Second}) {
if fake.calls[1] != (incrCall{userID: 101, platform: "openai", cost: 0.50, ttl: 120 * time.Second, markDirty: false}) {
t.Errorf("call[1] = %+v", fake.calls[1])
}
}
@ -88,13 +89,23 @@ func (f *fakeQuotaRepo) ResetExpiredWindow(_ context.Context, _ int64, _ string,
return nil
}
// fakeFullCache 同时支持 Get + Set + Incr + Delete。
func (f *fakeQuotaRepo) BatchSnapshotUsage(_ context.Context, _ []UserPlatformQuotaSnapshot, _ time.Time) error {
return nil
}
// fakeFullCache 同时支持 Get + Set + Incr + Delete + Pop/Readd/BatchGet脏集读写
// mu 保护 entry 和 deleteCalls防止异步 goroutine 与主 goroutine 之间的 data race。
type fakeFullCache struct {
BillingCache
mu sync.Mutex
entry *UserPlatformQuotaCacheEntry
deleteCalls int
setCalls int // SetUserPlatformQuotaCache 调用次数
lastSetTTL time.Duration // 最近一次 Set 的 ttl
getErr error // 非 nil 时 Get 先返回 (nil,false,getErr)
setErr error // 非 nil 时 Set 返回该 err(setCalls 仍+1)
// dirty 模拟脏集,供 flusher 测试使用。
dirty map[UserPlatformQuotaKey]struct{}
}
// getDeleteCalls 线程安全地读取 deleteCalls。
@ -111,19 +122,41 @@ func (f *fakeFullCache) getEntry() *UserPlatformQuotaCacheEntry {
return f.entry
}
// getSetCalls 线程安全地读取 setCalls。
func (f *fakeFullCache) getSetCalls() int {
f.mu.Lock()
defer f.mu.Unlock()
return f.setCalls
}
// getLastSetTTL 线程安全地读取 lastSetTTL。
func (f *fakeFullCache) getLastSetTTL() time.Duration {
f.mu.Lock()
defer f.mu.Unlock()
return f.lastSetTTL
}
func (f *fakeFullCache) GetUserPlatformQuotaCache(_ context.Context, _ int64, _ string) (*UserPlatformQuotaCacheEntry, bool, error) {
f.mu.Lock()
defer f.mu.Unlock()
if f.getErr != nil {
return nil, false, f.getErr
}
if f.entry == nil {
return nil, false, nil
}
return f.entry, true, nil
}
func (f *fakeFullCache) SetUserPlatformQuotaCache(_ context.Context, _ int64, _ string, e *UserPlatformQuotaCacheEntry, _ time.Duration) error {
func (f *fakeFullCache) SetUserPlatformQuotaCache(_ context.Context, _ int64, _ string, e *UserPlatformQuotaCacheEntry, ttl time.Duration) error {
f.mu.Lock()
defer f.mu.Unlock()
f.setCalls++
if f.setErr != nil {
return f.setErr
}
f.entry = e
f.lastSetTTL = ttl
return nil
}
@ -135,6 +168,48 @@ func (f *fakeFullCache) DeleteUserPlatformQuotaCache(_ context.Context, _ int64,
return nil
}
func (f *fakeFullCache) PopDirtyUserPlatformQuotaKeys(_ context.Context, n int) ([]UserPlatformQuotaKey, error) {
f.mu.Lock()
defer f.mu.Unlock()
if len(f.dirty) == 0 {
return nil, nil
}
keys := make([]UserPlatformQuotaKey, 0, n)
for k := range f.dirty {
if len(keys) >= n {
break
}
keys = append(keys, k)
delete(f.dirty, k)
}
return keys, nil
}
func (f *fakeFullCache) ReaddDirtyUserPlatformQuotaKeys(_ context.Context, keys []UserPlatformQuotaKey) error {
f.mu.Lock()
defer f.mu.Unlock()
if f.dirty == nil {
f.dirty = make(map[UserPlatformQuotaKey]struct{})
}
for _, k := range keys {
f.dirty[k] = struct{}{}
}
return nil
}
// BatchGetUserPlatformQuotaCache 对每个 key 返回 f.entryMISS → nil
// 保持与输入 keys 顺序/长度对齐。注意此处所有 key 共享同一个 entry
// 仅用于测试场景。
func (f *fakeFullCache) BatchGetUserPlatformQuotaCache(_ context.Context, keys []UserPlatformQuotaKey) ([]*UserPlatformQuotaCacheEntry, error) {
f.mu.Lock()
defer f.mu.Unlock()
results := make([]*UserPlatformQuotaCacheEntry, len(keys))
for i := range keys {
results[i] = f.entry
}
return results, nil
}
func newServiceForPreflight(t *testing.T, repo UserPlatformQuotaRepository, cache BillingCache) *BillingCacheService {
t.Helper()
cfg := &config.Config{}
@ -593,3 +668,165 @@ func TestMonthlyQuotaWindowExpired_BoundaryTable(t *testing.T) {
})
}
}
// TestCheckUserPlatformQuotaEligibility_NoRow_WritesSentinel 验证:
// cache MISS + DB 无行时,回填 sentinel entry(三 limit 全 nil,三 window_start 全 non-nil),
// TTL = UserPlatformQuotaSentinelTTLSeconds,函数返回 nil(fail-open)。
func TestCheckUserPlatformQuotaEligibility_NoRow_WritesSentinel(t *testing.T) {
repo := &fakeQuotaRepo{rec: nil} // DB 无行
cache := &fakeFullCache{} // entry=nil → Get 返回 MISS
svc := newServiceForPreflight(t, repo, cache)
svc.cfg.Billing.UserPlatformQuotaSentinelTTLSeconds = 3600
if err := svc.checkUserPlatformQuotaEligibility(context.Background(), 1, "anthropic"); err != nil {
t.Fatalf("expected nil (fail-open), got %v", err)
}
if cache.getSetCalls() != 1 {
t.Fatalf("expected 1 SetUserPlatformQuotaCache call for sentinel, got %d", cache.getSetCalls())
}
sentinel := cache.getEntry()
if sentinel == nil {
t.Fatal("expected sentinel entry backfilled")
}
if sentinel.DailyLimitUSD != nil || sentinel.WeeklyLimitUSD != nil || sentinel.MonthlyLimitUSD != nil {
t.Errorf("sentinel must have all-nil limits")
}
if sentinel.DailyWindowStart == nil || sentinel.WeeklyWindowStart == nil || sentinel.MonthlyWindowStart == nil {
t.Errorf("sentinel must have non-nil window_start to avoid refresh churn")
}
if sentinel.SchemaVersion != UserPlatformQuotaCacheSchemaV1 {
t.Errorf("sentinel schema = %d, want V1", sentinel.SchemaVersion)
}
if cache.getLastSetTTL() != 3600*time.Second {
t.Errorf("sentinel ttl = %v, want 3600s", cache.getLastSetTTL())
}
}
// TestCheckUserPlatformQuotaEligibility_RedisGetError_NoSentinelBackfill 验证:
// Redis GET 故障(cacheErr!=nil)+ DB 无行时,不应回填 sentinel(与 "Redis 故障时不回填" 一致),且 fail-open。
func TestCheckUserPlatformQuotaEligibility_RedisGetError_NoSentinelBackfill(t *testing.T) {
repo := &fakeQuotaRepo{rec: nil}
cache := &fakeFullCache{getErr: errors.New("redis get down")}
svc := newServiceForPreflight(t, repo, cache)
svc.cfg.Billing.UserPlatformQuotaSentinelTTLSeconds = 3600
if err := svc.checkUserPlatformQuotaEligibility(context.Background(), 1, "anthropic"); err != nil {
t.Fatalf("redis 故障应 fail-open, got %v", err)
}
if cache.getSetCalls() != 0 {
t.Errorf("redis-get-error 时不应回填 sentinel, got %d set calls", cache.getSetCalls())
}
}
// TestCheckUserPlatformQuotaEligibility_NoRow_SentinelSetFailsFailOpen 验证:
// sentinel SET 失败时 fail-open(返回 nil)且计 metric。
func TestCheckUserPlatformQuotaEligibility_NoRow_SentinelSetFailsFailOpen(t *testing.T) {
before := userPlatformQuotaSentinelSetCacheErrorTotal.Load()
repo := &fakeQuotaRepo{rec: nil}
cache := &fakeFullCache{setErr: errors.New("redis set timeout")}
svc := newServiceForPreflight(t, repo, cache)
svc.cfg.Billing.UserPlatformQuotaSentinelTTLSeconds = 3600
if err := svc.checkUserPlatformQuotaEligibility(context.Background(), 1, "anthropic"); err != nil {
t.Fatalf("sentinel set 失败应 fail-open, got %v", err)
}
if cache.getSetCalls() != 1 {
t.Errorf("应尝试 set sentinel 恰好一次, got %d", cache.getSetCalls())
}
if got := userPlatformQuotaSentinelSetCacheErrorTotal.Load() - before; got != 1 {
t.Errorf("set 失败应使 metric +1, got delta %d", got)
}
}
// TestCheckUserPlatformQuotaEligibility_SentinelCrossDay_NoRefresh 验证:
// 命中 sentinel(三 limit 全 nil)且跨窗口(daily/weekly 过期)时,不应触发 refresh SetCache
// (否则会把短 sentinel TTL 误升级为 quota cache 默认 86400s)。
func TestCheckUserPlatformQuotaEligibility_SentinelCrossDay_NoRefresh(t *testing.T) {
yesterday := timezone.StartOfDay(time.Now().AddDate(0, 0, -1))
lastWeek := timezone.StartOfWeek(time.Now().AddDate(0, 0, -7))
monthAgoOK := time.Now().AddDate(0, 0, -5) // <30d, monthly 不过期
sentinel := &UserPlatformQuotaCacheEntry{
SchemaVersion: UserPlatformQuotaCacheSchemaV1,
DailyWindowStart: &yesterday, // 跨日 → daily windowExpired = true
WeeklyWindowStart: &lastWeek, // 跨周 → weekly windowExpired = true
MonthlyWindowStart: &monthAgoOK,
// limits 全 nil → sentinel
}
cache := &fakeFullCache{entry: sentinel} // entry 非 nil → Get HIT
svc := newServiceForPreflight(t, &fakeQuotaRepo{}, cache)
if err := svc.checkUserPlatformQuotaEligibility(context.Background(), 1, "anthropic"); err != nil {
t.Fatalf("sentinel = no limit, expected nil, got %v", err)
}
if cache.getSetCalls() != 0 {
t.Errorf("sentinel cross-window must NOT trigger refresh SetCache, got %d calls", cache.getSetCalls())
}
}
// ── TestHasUserPlatformQuotaLimit ────────────────────────────────────────────
func TestHasUserPlatformQuotaLimit(t *testing.T) {
daily := 5.0
tests := []struct {
name string
setup func() *BillingCacheService
want bool
}{
{
name: "has_limit",
setup: func() *BillingCacheService {
entry := &UserPlatformQuotaCacheEntry{DailyLimitUSD: &daily}
svc := newServiceForPreflight(t, &fakeQuotaRepo{}, &fakeFullCache{entry: entry})
return svc
},
want: true,
},
{
name: "sentinel_no_limit",
setup: func() *BillingCacheService {
entry := &UserPlatformQuotaCacheEntry{} // 三个 limit 字段全 nil
svc := newServiceForPreflight(t, &fakeQuotaRepo{}, &fakeFullCache{entry: entry})
return svc
},
want: false,
},
{
name: "cache_miss",
setup: func() *BillingCacheService {
// entry==nil → GetUserPlatformQuotaCache 返回 (nil,false,nil)
svc := newServiceForPreflight(t, &fakeQuotaRepo{}, &fakeFullCache{})
return svc
},
want: true, // fail-safe
},
{
name: "redis_err",
setup: func() *BillingCacheService {
svc := newServiceForPreflight(t, &fakeQuotaRepo{}, &fakeFullCache{getErr: errors.New("redis down")})
return svc
},
want: true, // fail-safe
},
{
name: "simple_mode",
setup: func() *BillingCacheService {
entry := &UserPlatformQuotaCacheEntry{DailyLimitUSD: &daily}
svc := newServiceForPreflight(t, &fakeQuotaRepo{}, &fakeFullCache{entry: entry})
svc.cfg.RunMode = config.RunModeSimple
return svc
},
want: false, // simple 模式始终跳过
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
svc := tt.setup()
got := svc.HasUserPlatformQuotaLimit(context.Background(), 1, "anthropic")
if got != tt.want {
t.Errorf("HasUserPlatformQuotaLimit() = %v, want %v", got, tt.want)
}
})
}
}

View File

@ -21,6 +21,12 @@ type APIKeyRateLimitCacheData struct {
Window7d int64 `json:"window_7d"`
}
// UserPlatformQuotaKey 标识一个 user×platform用于脏集出入与批量读。
type UserPlatformQuotaKey struct {
UserID int64
Platform string
}
// UserPlatformQuotaCacheEntry Redis hash 反序列化结果。
//
// SchemaVersion 用于向后兼容:
@ -72,7 +78,13 @@ type BillingCache interface {
SetUserPlatformQuotaCache(ctx context.Context, userID int64, platform string, entry *UserPlatformQuotaCacheEntry, ttl time.Duration) error
DeleteUserPlatformQuotaCache(ctx context.Context, userID int64, platform string) error
// IncrUserPlatformQuotaUsageCache 在缓存命中时累加用量缓存未命中key 不存在)静默返回 nil。
IncrUserPlatformQuotaUsageCache(ctx context.Context, userID int64, platform string, cost float64, ttl time.Duration) error
// markDirty=true 时将该 key 的 member 写入 Redis 脏集,供 flusher 批量回写 DB。
IncrUserPlatformQuotaUsageCache(ctx context.Context, userID int64, platform string, cost float64, ttl time.Duration, markDirty bool) error
// 脏集读写,供 flusher 使用。
PopDirtyUserPlatformQuotaKeys(ctx context.Context, n int) ([]UserPlatformQuotaKey, error)
ReaddDirtyUserPlatformQuotaKeys(ctx context.Context, keys []UserPlatformQuotaKey) error
BatchGetUserPlatformQuotaCache(ctx context.Context, keys []UserPlatformQuotaKey) ([]*UserPlatformQuotaCacheEntry, error)
}
// ModelPricing 模型价格配置per-token价格与LiteLLM格式一致

View File

@ -96,15 +96,20 @@ var (
modelsListCacheMissTotal atomic.Int64
modelsListCacheStoreTotal atomic.Int64
// Deprecated: flusher_enabled=true 后不再增长(仅 flag=false 降级直写路径使用);新主路径见 FlusherMetrics。remove after 2026-09。
// userPlatformQuotaDBIncrErrorTotal 统计 finalizePostUsageBilling 异步 goroutine
// 中 IncrementUsageWithReset 失败次数。Redis 已成功累加 + DB 写失败意味着
// Redis cache TTL 过期或被清后该笔 cost 会丢失(与实际消费偏差)。
// oncall 通过 GatewayUserPlatformQuotaIncrStats() 暴露给 ops 面板做阈值告警。
userPlatformQuotaDBIncrErrorTotal atomic.Int64
// Deprecated: flusher_enabled=true 后不再增长(仅 flag=false 降级直写路径使用);新主路径见 FlusherMetrics。remove after 2026-09。
// userPlatformQuotaDBIncrLegacyErrorTotal 统计 legacy postUsageBilling
// applyUsageBilling 在 repo==nil 时 fallback路径下的失败次数
// 与 DB Incr 失败分开计数,便于区分"主路径暂时故障"vs"基础设施长期未配齐"。
userPlatformQuotaDBIncrLegacyErrorTotal atomic.Int64
// userPlatformQuotaSentinelSetCacheErrorTotal 统计 checkUserPlatformQuotaEligibility
// 在 DB 无行时回填 sentinel cache entry 写 Redis 失败的次数phase A
userPlatformQuotaSentinelSetCacheErrorTotal atomic.Int64
)
func GatewayWindowCostPrefetchStats() (cacheHit, cacheMiss, batchSQL, fallback, errCount int64) {
@ -127,13 +132,32 @@ func GatewayModelsListCacheStats() (cacheHit, cacheMiss, store int64) {
return modelsListCacheHitTotal.Load(), modelsListCacheMissTotal.Load(), modelsListCacheStoreTotal.Load()
}
// GatewayUserPlatformQuotaIncrStats 返回 (mainPathErr, legacyPathErr)。
// GatewayUserPlatformQuotaIncrStats 返回 (mainPathErr, legacyPathErr, sentinelSetErr)。
// mainPathErrfinalizePostUsageBilling 异步 goroutine 写 DB 失败累计次数;
// legacyPathErrpostUsageBilling fallback 路径写 DB 失败累计次数。
// legacyPathErrpostUsageBilling fallback 路径写 DB 失败累计次数;
// sentinelSetErrDB 无行时回填 sentinel cache entry 写 Redis 失败累计次数。
// ops 监控面板可以按"持续上升斜率"做告警阈值。
func GatewayUserPlatformQuotaIncrStats() (mainPathErr, legacyPathErr int64) {
func GatewayUserPlatformQuotaIncrStats() (mainPathErr, legacyPathErr, sentinelSetErr int64) {
return userPlatformQuotaDBIncrErrorTotal.Load(),
userPlatformQuotaDBIncrLegacyErrorTotal.Load()
userPlatformQuotaDBIncrLegacyErrorTotal.Load(),
userPlatformQuotaSentinelSetCacheErrorTotal.Load()
}
// GatewayUserPlatformQuotaFlusherStats 暴露 flusher 运行指标供 ops/health 面板查询。
func GatewayUserPlatformQuotaFlusherStats(f *UserPlatformQuotaUsageFlusher) map[string]int64 {
if f == nil || f.metrics == nil {
return nil
}
m := f.metrics
return map[string]int64{
"flush_success": m.FlushSuccessTotal.Load(),
"flush_error": m.FlushErrorTotal.Load(),
"flush_batch_size": m.FlushBatchSizeTotal.Load(),
"flush_latency_ms_max": m.FlushLatencyMsMax.Load(),
"dirty_readd": m.DirtyReaddTotal.Load(),
"dirty_lost": m.DirtyLostTotal.Load(),
"flush_fk_violation": m.FlushFKViolationTotal.Load(),
}
}
func openAIStreamEventIsTerminal(data string) bool {
@ -8229,18 +8253,23 @@ func postUsageBilling(ctx context.Context, p *postUsageBillingParams, deps *bill
}
}
// Platform quota DB-only 累加(与 finalizePostUsageBilling 行为对齐的兜底):
// - 仅对 standard余额模式生效订阅模式豁免
// - 直接走 DB不经 Redis Incr 队列legacy 路径在 repo==nil仓库未注入
// 时被触发,此时整套 billing repo 都不可用,没有"双队列"风险
// - 失败仅记 ALERT log + counter不阻断主扣费流程与正常路径一致
//
// 历史背景:原 legacy path 完全跳过此累加,导致部署中如果 repo 偶然为 nil
// 时用户消费可绕过 platform quota存在静默资金风险。
// Platform quota 累加legacy 兜底路径):仅对 standard余额模式生效订阅模式豁免仅对有 limit 的用户写
// - HasUserPlatformQuotaLimit 守卫:与正常路径对齐,无 limit 公司跳过
// - 新增 Redis 同步写:enforcement 走 Redislegacy 路径也必须同步写,否则 preflight 看不到消费
// - flusher_enabled=false降级:保留原有同步直写 DB
// - flusher_enabled=true:跳过直写 DB由 flusher 异步批量刷markDirty 在 IncrementUserPlatformQuotaUsage 内部完成)
// - 失败仅记 ALERT log + counter不阻断主扣费流程
if !p.IsSubscriptionBill && p.Platform != "" && cost.ActualCost > 0 && p.User != nil && deps.userPlatformQuotaRepo != nil {
if err := deps.userPlatformQuotaRepo.IncrementUsageWithReset(billingCtx, p.User.ID, p.Platform, cost.ActualCost, time.Now().UTC()); err != nil {
userPlatformQuotaDBIncrLegacyErrorTotal.Add(1)
logger.LegacyPrintf("service.gateway", "ALERT: legacy incr user platform quota DB failed user=%d platform=%s cost=%f: %v", p.User.ID, p.Platform, cost.ActualCost, err)
if deps.billingCacheService.HasUserPlatformQuotaLimit(billingCtx, p.User.ID, p.Platform) {
deps.billingCacheService.IncrementUserPlatformQuotaUsage(p.User.ID, p.Platform, cost.ActualCost)
if deps.cfg == nil || !deps.cfg.Database.UserPlatformQuotaFlusherEnabled {
// 降级路径:flusher 未启用时保留原有同步直写 DB
if err := deps.userPlatformQuotaRepo.IncrementUsageWithReset(billingCtx, p.User.ID, p.Platform, cost.ActualCost, time.Now().UTC()); err != nil {
userPlatformQuotaDBIncrLegacyErrorTotal.Add(1)
logger.LegacyPrintf("service.gateway", "ALERT: legacy incr user platform quota DB failed user=%d platform=%s cost=%f: %v", p.User.ID, p.Platform, cost.ActualCost, err)
}
}
// flusher_enabled=true:不直写 DBflusher 异步批量刷
}
}
@ -8390,30 +8419,38 @@ func finalizePostUsageBilling(ctx context.Context, p *postUsageBillingParams, de
deps.deferredService.ScheduleLastUsedUpdate(p.Account.ID)
// Platform quota 累加:仅在 standard余额模式生效订阅模式豁免
// Redis 同步写 + DB 异步持久化:
// Platform quota 累加:仅在 standard余额模式生效订阅模式豁免仅对有 limit 的用户写
// Redis 同步写 + DB 异步持久化flag=false 降级)或 flusher 异步刷flag=true:
// - HasUserPlatformQuotaLimit 守卫:无 limit 的公司跳过,避免无效写入 + 浪费 Redis 容量
// - Redis 同步:确保下次 preflight 立即看到最新 usage,把 TOCTOU 超支窗口
// 限制在并发 in-flight 请求数量内(旧实现的异步入队会让超支无限累积直到 worker 处理)
// - DB 异步:在独立 goroutine 中走 detached context,失败用 ALERT log 触发 oncall 对账
// - DB 异步(flusher_enabled=false):在独立 goroutine 中走 detached context,失败用 ALERT log 触发 oncall 对账
// - flusher_enabled=true:不直写 DB,由 flusher 异步批量刷markDirty 已在 IncrementUserPlatformQuotaUsage 内部完成)
if !p.IsSubscriptionBill && p.Platform != "" && p.Cost.ActualCost > 0 && p.User != nil && deps.userPlatformQuotaRepo != nil {
deps.billingCacheService.IncrementUserPlatformQuotaUsage(p.User.ID, p.Platform, p.Cost.ActualCost)
dbCtx, dbCancel := detachUpstreamContext(ctx)
userID, platform, cost := p.User.ID, p.Platform, p.Cost.ActualCost
go func() {
defer func() {
if r := recover(); r != nil {
logger.LegacyPrintf("service.gateway", "ALERT: panic in user platform quota incr goroutine user=%d platform=%s: %v", userID, platform, r)
}
}()
defer dbCancel()
if err := deps.userPlatformQuotaRepo.IncrementUsageWithReset(dbCtx, userID, platform, cost, time.Now().UTC()); err != nil {
// 失败计数器:暴露给 GatewayUserPlatformQuotaIncrStats(),由 ops 面板做斜率告警。
userPlatformQuotaDBIncrErrorTotal.Add(1)
// ALERT 级别:DB 持久化失败意味着 Redis cache 失效后该笔 cost 永久丢失,
// 用户配额视图与实际消费会偏差,oncall 需要据此对账或人工补录。
logger.LegacyPrintf("service.gateway", "ALERT: incr user platform quota DB failed user=%d platform=%s cost=%f: %v", userID, platform, cost, err)
if deps.billingCacheService.HasUserPlatformQuotaLimit(ctx, p.User.ID, p.Platform) {
deps.billingCacheService.IncrementUserPlatformQuotaUsage(p.User.ID, p.Platform, p.Cost.ActualCost)
if deps.cfg == nil || !deps.cfg.Database.UserPlatformQuotaFlusherEnabled {
// 降级路径:flusher 未启用时保留原有异步直写 DB
dbCtx, dbCancel := detachUpstreamContext(ctx)
userID, platform, cost := p.User.ID, p.Platform, p.Cost.ActualCost
go func() {
defer func() {
if r := recover(); r != nil {
logger.LegacyPrintf("service.gateway", "ALERT: panic in user platform quota incr goroutine user=%d platform=%s: %v", userID, platform, r)
}
}()
defer dbCancel()
if err := deps.userPlatformQuotaRepo.IncrementUsageWithReset(dbCtx, userID, platform, cost, time.Now().UTC()); err != nil {
// 失败计数器:暴露给 GatewayUserPlatformQuotaIncrStats(),由 ops 面板做斜率告警。
userPlatformQuotaDBIncrErrorTotal.Add(1)
// ALERT 级别:DB 持久化失败意味着 Redis cache 失效后该笔 cost 永久丢失,
// 用户配额视图与实际消费会偏差,oncall 需要据此对账或人工补录。
logger.LegacyPrintf("service.gateway", "ALERT: incr user platform quota DB failed user=%d platform=%s cost=%f: %v", userID, platform, cost, err)
}
}()
}
}()
// flusher_enabled=true:不直写 DB,flusher 异步批量刷
}
}
// Notification checks run async — all parameters are already captured,
@ -8528,6 +8565,7 @@ type billingDeps struct {
deferredService *DeferredService
balanceNotifyService *BalanceNotifyService
userPlatformQuotaRepo UserPlatformQuotaRepository
cfg *config.Config
}
func (s *GatewayService) billingDeps() *billingDeps {
@ -8539,6 +8577,7 @@ func (s *GatewayService) billingDeps() *billingDeps {
deferredService: s.deferredService,
balanceNotifyService: s.balanceNotifyService,
userPlatformQuotaRepo: s.userPlatformQuotaRepo,
cfg: s.cfg,
}
}

View File

@ -124,9 +124,9 @@ func TestDefaultPricingIncludesCodexAutoReview(t *testing.T) {
got := svc.GetModelPricing("codex-auto-review")
require.NotNil(t, got)
require.InDelta(t, 2.5e-6, got.InputCostPerToken, 1e-12)
require.InDelta(t, 1.5e-5, got.OutputCostPerToken, 1e-12)
require.InDelta(t, 2.5e-7, got.CacheReadInputTokenCost, 1e-12)
require.InDelta(t, 5e-6, got.InputCostPerToken, 1e-12)
require.InDelta(t, 3e-5, got.OutputCostPerToken, 1e-12)
require.InDelta(t, 5e-7, got.CacheReadInputTokenCost, 1e-12)
}
func TestGetModelPricing_Gpt54MiniUsesDedicatedStaticFallbackWhenRemoteMissing(t *testing.T) {

View File

@ -0,0 +1,267 @@
package service
import (
"context"
"errors"
"sync/atomic"
"time"
"github.com/Wei-Shaw/sub2api/internal/config"
"github.com/Wei-Shaw/sub2api/internal/pkg/logger"
)
// quotaDirtyCache 是 flusher 依赖的窄接口(来自 BillingCache
type quotaDirtyCache interface {
PopDirtyUserPlatformQuotaKeys(ctx context.Context, n int) ([]UserPlatformQuotaKey, error)
ReaddDirtyUserPlatformQuotaKeys(ctx context.Context, keys []UserPlatformQuotaKey) error
BatchGetUserPlatformQuotaCache(ctx context.Context, keys []UserPlatformQuotaKey) ([]*UserPlatformQuotaCacheEntry, error)
}
// quotaSnapshotWriter 是 flusher 依赖的 DB 写入窄接口。
// 使用 service 层的 UserPlatformQuotaSnapshot避免与 repository 包形成循环依赖;
// 实际实现由 repository adapter 在 B7 注入。
type quotaSnapshotWriter interface {
BatchSnapshotUsage(ctx context.Context, snapshots []UserPlatformQuotaSnapshot, now time.Time) error
}
// FlusherMetrics 记录 flusher 运行时指标(原子量,零值可用)。
type FlusherMetrics struct {
FlushSuccessTotal atomic.Int64
FlushErrorTotal atomic.Int64
FlushBatchSizeTotal atomic.Int64
FlushLatencyMsMax atomic.Int64
DirtyReaddTotal atomic.Int64
// DirtyLostTotalReadd 失败导致脏 key 丢失——已 SPOP+主操作失败+Readd 也失败;
// Redis 仍权威,活跃 key 下次 SADD 自愈。
DirtyLostTotal atomic.Int64
FlushFKViolationTotal atomic.Int64
}
// flusherMaxBatchesPerTick 单次 tick 最多消费的批数,防止 tick 执行时间过长。
const flusherMaxBatchesPerTick = 16
// maxFlushBatchSize 限制单批行数,必须 ≤ repository.BatchSnapshotUsage 的 batchRows(6000),
// 以保证单次 flush 的 snapshots 仅生成一条 UPSERT(单事务原子)。两处需手动保持一致。
const maxFlushBatchSize = 6000
// defaultFlushBatchSize 是配置 flush_batch_size 非法(≤0)时的回退值。
const defaultFlushBatchSize = 1000
// UserPlatformQuotaUsageFlusher 将 Redis 脏集快照定期批量写入 DB。
// 不维护任何 delta/in-process 状态;每批读取 Redis 当前绝对值覆盖写入。
type UserPlatformQuotaUsageFlusher struct {
cache quotaDirtyCache
quotaRepo quotaSnapshotWriter
timingWheel *TimingWheelService
// enabled 对应 flusher_enabled 配置false 时 Start() 不注册定时器。
enabled bool
interval time.Duration
batchSize int
flushTimeout time.Duration
metrics *FlusherMetrics
stopped atomic.Bool
}
// NewUserPlatformQuotaUsageFlusher 创建 UserPlatformQuotaUsageFlusher。
// cache(BillingCache) 隐式满足 quotaDirtyCachequotaRepo(UserPlatformQuotaRepository) 隐式满足 quotaSnapshotWriter。
func NewUserPlatformQuotaUsageFlusher(cfg *config.Config, cache BillingCache, quotaRepo UserPlatformQuotaRepository, tw *TimingWheelService) *UserPlatformQuotaUsageFlusher {
batchSize := cfg.Database.UserPlatformQuotaFlushBatchSize
if batchSize <= 0 {
batchSize = defaultFlushBatchSize
}
if batchSize > maxFlushBatchSize {
logger.LegacyPrintf("quota_flusher",
"[QuotaFlusher] flush_batch_size %d 超过上限 %d,已 clamp(避免 BatchSnapshotUsage 多子批非原子)",
cfg.Database.UserPlatformQuotaFlushBatchSize, maxFlushBatchSize)
batchSize = maxFlushBatchSize
}
interval := time.Duration(cfg.Database.UserPlatformQuotaFlushIntervalMs) * time.Millisecond
if interval <= 0 {
logger.LegacyPrintf("quota_flusher", "[QuotaFlusher] flush_interval_ms %d 非法,回退 2000ms", cfg.Database.UserPlatformQuotaFlushIntervalMs)
interval = 2 * time.Second
}
return &UserPlatformQuotaUsageFlusher{
cache: cache,
quotaRepo: quotaRepo,
timingWheel: tw,
enabled: cfg.Database.UserPlatformQuotaFlusherEnabled,
interval: interval,
batchSize: batchSize,
flushTimeout: 3 * time.Second,
metrics: &FlusherMetrics{},
}
}
// updateLatencyMax 用 CAS 单调更新最大延迟。
func (s *UserPlatformQuotaUsageFlusher) updateLatencyMax(ms int64) {
for {
old := s.metrics.FlushLatencyMsMax.Load()
if ms <= old {
return
}
if s.metrics.FlushLatencyMsMax.CompareAndSwap(old, ms) {
return
}
}
}
// readdOrCountLost 尝试把 keys 回填脏集:成功计 DirtyReaddTotal失败计 DirtyLostTotal 并 ALERT。
func (s *UserPlatformQuotaUsageFlusher) readdOrCountLost(ctx context.Context, keys []UserPlatformQuotaKey, stage string) {
if err := s.cache.ReaddDirtyUserPlatformQuotaKeys(ctx, keys); err != nil {
s.metrics.DirtyLostTotal.Add(int64(len(keys)))
logger.LegacyPrintf("quota_flusher", "[QuotaFlusher] ALERT: Readd after %s failed, %d keys 丢出脏集(DB 镜像缺这批,Redis 仍权威,活跃 key 下次 SADD 自愈): %v", stage, len(keys), err)
return
}
s.metrics.DirtyReaddTotal.Add(int64(len(keys)))
}
// flushOneBatch 处理单批Pop → BatchGet → 组装 snaps → BatchSnapshotUsage。
// 返回 (shouldContinue bool)false 表示本轮循环应停止(空集/错误/最后一批)。
// 每次调用独立创建带 timeout 的 ctx 并 defer cancel不会在循环中累积泄漏。
func (s *UserPlatformQuotaUsageFlusher) flushOneBatch(parentCtx context.Context) bool {
ctx, cancel := context.WithTimeout(parentCtx, s.flushTimeout)
defer cancel()
// 1. Pop 脏集
keys, err := s.cache.PopDirtyUserPlatformQuotaKeys(ctx, s.batchSize)
if err != nil {
s.metrics.FlushErrorTotal.Add(1)
logger.LegacyPrintf("quota_flusher", "[QuotaFlusher] PopDirty error: %v", err)
return false
}
if len(keys) == 0 {
// 脏集已空
return false
}
// 2. 批量读 Redis 快照
entries, err := s.cache.BatchGetUserPlatformQuotaCache(ctx, keys)
if err != nil {
s.metrics.FlushErrorTotal.Add(1)
s.readdOrCountLost(ctx, keys, "BatchGet")
logger.LegacyPrintf("quota_flusher", "[QuotaFlusher] BatchGet error: %v", err)
return false
}
// 3. 组装 snapshotsMISS 或任一 WindowStart==nil → 跳过)
snaps := make([]UserPlatformQuotaSnapshot, 0, len(keys))
for i, key := range keys {
e := entries[i]
if e == nil {
continue
}
if e.DailyWindowStart == nil || e.WeeklyWindowStart == nil || e.MonthlyWindowStart == nil {
continue
}
snaps = append(snaps, UserPlatformQuotaSnapshot{
UserID: key.UserID,
Platform: key.Platform,
DailyUsageUSD: e.DailyUsageUSD,
WeeklyUsageUSD: e.WeeklyUsageUSD,
MonthlyUsageUSD: e.MonthlyUsageUSD,
DailyWindowStart: *e.DailyWindowStart,
WeeklyWindowStart: *e.WeeklyWindowStart,
MonthlyWindowStart: *e.MonthlyWindowStart,
})
}
// 4. 全部 MISS/异常跳过时
if len(snaps) == 0 {
// 若 Pop 数量已不满一批,表示脏集将空,停止
if len(keys) < s.batchSize {
return false
}
// 否则继续下一批(可能还有更多脏 key
return true
}
// 已知竞态(admin 写 × flusher 刷,仅 flusher_enabled=true 时存在):
// admin ResetExpiredWindow/UpsertForUser 是"先写 DB 再 DeleteCache"。若本批已 SPOP + BatchGet
// 读到旧 usage 快照(此刻 member 已离开脏集),而 admin 随后写 DB、本行 UPSERT 又在 admin 写之后落库,
// 则旧快照会覆盖 admin 刚写入的值;DeleteCache 后 Redis MISS,下次 preflight 从 DB 重载被覆盖的旧值。
// 因 member 已被 SPOP,admin 侧 SREM/清脏标记无法拦截本批(故未做)。影响有限,暂列为已知取舍:
// - UpsertForUser 改 limit,而本 UPSERT 不写 limit 列 → limit 配置不受影响;
// - ResetExpiredWindow 改 usage,但 preflight windowExpired 会在窗口真正过期时自愈重置,
// 仅"强制重置未过期窗口"且与本批精确交错时短暂失效;
// - 低频 admin 操作 + 默认 flusher_enabled=false。彻底消除需 version OCC(DB 加 version 列条件 UPSERT),
// 成本高;启用 flusher 后如需强一致再评估。
// 5. 写入 DB
start := time.Now()
writeErr := s.quotaRepo.BatchSnapshotUsage(ctx, snaps, time.Now().UTC())
s.updateLatencyMax(time.Since(start).Milliseconds())
if writeErr != nil {
if errors.Is(writeErr, ErrUserPlatformQuotaFKViolation) {
// 注意:PG FK violation 是整条 INSERT 回滚 → 整批(含同批正常用户)均未写入 DB,
// 且这些 key 已被 SPOP 出脏集、此处不 Readd。活跃 key 会在下次请求重新 SADD,
// flusher 读 Redis 当前累计绝对值刷库即自愈;低活跃 key 这轮 DB usage 偏低
// (Redis 仍是 enforcement 权威,不受影响;DB 仅展示)。已删用户边角的接受取舍,不做逐行重试。
// FK 违反:用户已被删除,直接丢弃不 Readd
s.metrics.FlushFKViolationTotal.Add(1)
s.metrics.FlushErrorTotal.Add(1)
logger.LegacyPrintf("quota_flusher", "[QuotaFlusher] FK violation (dropped %d snaps): %v", len(snaps), writeErr)
} else {
// 其他错误:回填脏集,保留下次重试
s.metrics.FlushErrorTotal.Add(1)
s.readdOrCountLost(ctx, keys, "BatchSnapshotUsage")
logger.LegacyPrintf("quota_flusher", "[QuotaFlusher] BatchSnapshotUsage error: %v", writeErr)
}
return false
}
// 6. 成功
s.metrics.FlushSuccessTotal.Add(1)
s.metrics.FlushBatchSizeTotal.Add(int64(len(snaps)))
// 若 Pop 数量不满一批,脏集已空,停止
if len(keys) < s.batchSize {
return false
}
return true
}
// flush 执行一次完整的 flush循环消费至脏集空或达到 maxBatchesPerTick。
func (s *UserPlatformQuotaUsageFlusher) flush() {
if s == nil {
return
}
parentCtx := context.Background()
for b := 0; b < flusherMaxBatchesPerTick; b++ {
if !s.flushOneBatch(parentCtx) {
return
}
}
// 连续消费满 flusherMaxBatchesPerTick 批仍未取空脏集:本 tick 主动让出,剩余积压留待下一 tick。
// 记一条 log 便于 oncall 发现 distinct 活跃 key 远超 maxBatchesPerTick×batchSize(DB 镜像延迟上升);
// 可配合 Redis SCARD billing:upq:dirty 观察脏集存量。
logger.LegacyPrintf("quota_flusher",
"[QuotaFlusher] 单 tick 达到 max batches 上限(%d × batchSize=%d),脏集仍非空,积压顺延至下一 tick",
flusherMaxBatchesPerTick, s.batchSize)
}
// tick 是 TimingWheel 回调。若 flusher 已停止则直接返回。
func (s *UserPlatformQuotaUsageFlusher) tick() {
if s == nil || s.stopped.Load() {
return
}
s.flush()
}
// Start 注册定时 tick。flusher_enabled=false 时直接返回,不注册定时器。
func (s *UserPlatformQuotaUsageFlusher) Start() {
if s == nil || !s.enabled {
return
}
s.timingWheel.ScheduleRecurring("deferred:platform_quota", s.interval, s.tick)
}
// Stop 停止 flusher标记 stopped → Cancel 定时器 → 执行最后一次 flush。
func (s *UserPlatformQuotaUsageFlusher) Stop() {
if s == nil {
return
}
s.stopped.Store(true)
s.timingWheel.Cancel("deferred:platform_quota")
s.flush()
}

View File

@ -0,0 +1,511 @@
package service
import (
"context"
"errors"
"testing"
"time"
"github.com/Wei-Shaw/sub2api/internal/config"
)
// ---------------------------------------------------------------------------
// Mock: quotaDirtyCache
// ---------------------------------------------------------------------------
type mockQuotaDirtyCache struct {
// popSequence: 第 0 次 Pop 返回 popSequence[0],之后返回 nil空集
popSequence [][]UserPlatformQuotaKey
popCallIdx int
// getEntries: BatchGetUserPlatformQuotaCache 返回的 entries与 keys 对齐)
getEntries []*UserPlatformQuotaCacheEntry
getErr error
// readdCalled: 记录 Readd 收到的 keys累积所有次调用
readdCalled [][]UserPlatformQuotaKey
readdErr error
}
func (m *mockQuotaDirtyCache) PopDirtyUserPlatformQuotaKeys(_ context.Context, _ int) ([]UserPlatformQuotaKey, error) {
if m.popCallIdx < len(m.popSequence) {
keys := m.popSequence[m.popCallIdx]
m.popCallIdx++
return keys, nil
}
// 超出序列 → 空集(模拟脏集已清空)
return nil, nil
}
func (m *mockQuotaDirtyCache) ReaddDirtyUserPlatformQuotaKeys(_ context.Context, keys []UserPlatformQuotaKey) error {
m.readdCalled = append(m.readdCalled, keys)
return m.readdErr
}
func (m *mockQuotaDirtyCache) BatchGetUserPlatformQuotaCache(_ context.Context, _ []UserPlatformQuotaKey) ([]*UserPlatformQuotaCacheEntry, error) {
if m.getErr != nil {
return nil, m.getErr
}
return m.getEntries, nil
}
// ---------------------------------------------------------------------------
// Mock: quotaSnapshotWriter
// ---------------------------------------------------------------------------
type mockQuotaSnapshotWriter struct {
receivedSnaps []UserPlatformQuotaSnapshot
returnErr error
}
func (m *mockQuotaSnapshotWriter) BatchSnapshotUsage(_ context.Context, snaps []UserPlatformQuotaSnapshot, _ time.Time) error {
m.receivedSnaps = append(m.receivedSnaps, snaps...)
return m.returnErr
}
// ---------------------------------------------------------------------------
// Helper: 构造窗口起始时间(非 nil
// ---------------------------------------------------------------------------
func flusherPtrTime(t time.Time) *time.Time { return &t }
func makeEntry(daily, weekly, monthly float64) *UserPlatformQuotaCacheEntry {
now := time.Now().UTC()
return &UserPlatformQuotaCacheEntry{
DailyUsageUSD: daily,
WeeklyUsageUSD: weekly,
MonthlyUsageUSD: monthly,
DailyWindowStart: flusherPtrTime(now),
WeeklyWindowStart: flusherPtrTime(now),
MonthlyWindowStart: flusherPtrTime(now),
}
}
// ---------------------------------------------------------------------------
// newTestFlusher: 直接构造 struct跳过构造函数B7 才注入)
// ---------------------------------------------------------------------------
func newTestFlusher(cache quotaDirtyCache, writer quotaSnapshotWriter) *UserPlatformQuotaUsageFlusher {
return &UserPlatformQuotaUsageFlusher{
cache: cache,
quotaRepo: writer,
timingWheel: nil, // 单测不启动 TimingWheel
interval: 5 * time.Second,
batchSize: 100,
flushTimeout: 5 * time.Second,
metrics: &FlusherMetrics{},
}
}
// ---------------------------------------------------------------------------
// 场景 1: PopSnapshotUpsert — 2 key + 2 个含 window 的 entry → writer 收 2 行
// ---------------------------------------------------------------------------
func TestFlusher_PopSnapshotUpsert(t *testing.T) {
keys := []UserPlatformQuotaKey{
{UserID: 1, Platform: "anthropic"},
{UserID: 2, Platform: "openai"},
}
cache := &mockQuotaDirtyCache{
popSequence: [][]UserPlatformQuotaKey{keys}, // 第 1 次返回 keys之后空
getEntries: []*UserPlatformQuotaCacheEntry{
makeEntry(1.0, 2.0, 3.0),
makeEntry(4.0, 5.0, 6.0),
},
}
writer := &mockQuotaSnapshotWriter{}
f := newTestFlusher(cache, writer)
f.flush()
if len(writer.receivedSnaps) != 2 {
t.Fatalf("expected 2 snaps, got %d", len(writer.receivedSnaps))
}
if f.metrics.FlushBatchSizeTotal.Load() != 2 {
t.Errorf("FlushBatchSizeTotal = %d, want 2", f.metrics.FlushBatchSizeTotal.Load())
}
if f.metrics.FlushSuccessTotal.Load() != 1 {
t.Errorf("FlushSuccessTotal = %d, want 1", f.metrics.FlushSuccessTotal.Load())
}
if f.metrics.FlushErrorTotal.Load() != 0 {
t.Errorf("FlushErrorTotal = %d, want 0", f.metrics.FlushErrorTotal.Load())
}
}
// ---------------------------------------------------------------------------
// 场景 2: MissKeySkipped — 2 keyBatchGet 返回 [entry, nil] → 只刷 1 行nil 跳过,不 Readd
// ---------------------------------------------------------------------------
func TestFlusher_MissKeySkipped(t *testing.T) {
keys := []UserPlatformQuotaKey{
{UserID: 1, Platform: "anthropic"},
{UserID: 2, Platform: "openai"},
}
cache := &mockQuotaDirtyCache{
popSequence: [][]UserPlatformQuotaKey{keys},
getEntries: []*UserPlatformQuotaCacheEntry{
makeEntry(1.0, 2.0, 3.0),
nil, // MISS
},
}
writer := &mockQuotaSnapshotWriter{}
f := newTestFlusher(cache, writer)
f.flush()
if len(writer.receivedSnaps) != 1 {
t.Fatalf("expected 1 snap, got %d", len(writer.receivedSnaps))
}
if writer.receivedSnaps[0].UserID != 1 {
t.Errorf("expected snap for UserID=1, got %d", writer.receivedSnaps[0].UserID)
}
if len(cache.readdCalled) != 0 {
t.Errorf("Readd should NOT be called on MISS, got %d calls", len(cache.readdCalled))
}
if f.metrics.FlushSuccessTotal.Load() != 1 {
t.Errorf("FlushSuccessTotal = %d, want 1", f.metrics.FlushSuccessTotal.Load())
}
}
// ---------------------------------------------------------------------------
// 场景 3: UpsertFailReadds — writer 返普通 error → keys 被 ReaddFlushErrorTotal=1DirtyReaddTotal=len
// ---------------------------------------------------------------------------
func TestFlusher_UpsertFailReadds(t *testing.T) {
keys := []UserPlatformQuotaKey{
{UserID: 1, Platform: "anthropic"},
{UserID: 2, Platform: "openai"},
}
cache := &mockQuotaDirtyCache{
popSequence: [][]UserPlatformQuotaKey{keys},
getEntries: []*UserPlatformQuotaCacheEntry{
makeEntry(1.0, 2.0, 3.0),
makeEntry(4.0, 5.0, 6.0),
},
}
writeErr := errors.New("db connection timeout")
writer := &mockQuotaSnapshotWriter{returnErr: writeErr}
f := newTestFlusher(cache, writer)
f.flush()
if f.metrics.FlushErrorTotal.Load() != 1 {
t.Errorf("FlushErrorTotal = %d, want 1", f.metrics.FlushErrorTotal.Load())
}
if len(cache.readdCalled) == 0 {
t.Fatal("Readd should be called after write error")
}
totalReadd := 0
for _, rk := range cache.readdCalled {
totalReadd += len(rk)
}
if totalReadd != len(keys) {
t.Errorf("DirtyReaddTotal (from Readd calls) = %d, want %d", totalReadd, len(keys))
}
if f.metrics.DirtyReaddTotal.Load() != int64(len(keys)) {
t.Errorf("DirtyReaddTotal metric = %d, want %d", f.metrics.DirtyReaddTotal.Load(), len(keys))
}
if f.metrics.FlushSuccessTotal.Load() != 0 {
t.Errorf("FlushSuccessTotal = %d, want 0", f.metrics.FlushSuccessTotal.Load())
}
}
// ---------------------------------------------------------------------------
// 场景 4: FKViolationDropsNoReadd — writer 返 ErrUserPlatformQuotaFKViolation → 不 ReaddFlushFKViolationTotal=1
// ---------------------------------------------------------------------------
func TestFlusher_FKViolationDropsNoReadd(t *testing.T) {
keys := []UserPlatformQuotaKey{
{UserID: 999, Platform: "anthropic"},
}
cache := &mockQuotaDirtyCache{
popSequence: [][]UserPlatformQuotaKey{keys},
getEntries: []*UserPlatformQuotaCacheEntry{
makeEntry(1.0, 2.0, 3.0),
},
}
writer := &mockQuotaSnapshotWriter{returnErr: ErrUserPlatformQuotaFKViolation}
f := newTestFlusher(cache, writer)
f.flush()
if f.metrics.FlushFKViolationTotal.Load() != 1 {
t.Errorf("FlushFKViolationTotal = %d, want 1", f.metrics.FlushFKViolationTotal.Load())
}
if f.metrics.FlushErrorTotal.Load() != 1 {
t.Errorf("FlushErrorTotal = %d, want 1", f.metrics.FlushErrorTotal.Load())
}
if len(cache.readdCalled) != 0 {
t.Errorf("Readd should NOT be called for FK violation (drop), got %d calls", len(cache.readdCalled))
}
if f.metrics.DirtyReaddTotal.Load() != 0 {
t.Errorf("DirtyReaddTotal = %d, want 0 (FK violation drops)", f.metrics.DirtyReaddTotal.Load())
}
}
// ---------------------------------------------------------------------------
// 场景 5: NilSafe — var f *UserPlatformQuotaUsageFlusher; f.flush(); f.Stop() 不 panic
// ---------------------------------------------------------------------------
func TestFlusher_NilSafe(t *testing.T) {
var f *UserPlatformQuotaUsageFlusher
// 下面两行不应 panic
f.flush()
f.Stop()
}
// ---------------------------------------------------------------------------
// 场景 6: StopPreventsFlush — stopped=true 后 tick() 不调 flushwriter 没收到 snaps
// ---------------------------------------------------------------------------
func TestFlusher_StopPreventsFlush(t *testing.T) {
keys := []UserPlatformQuotaKey{
{UserID: 1, Platform: "anthropic"},
}
cache := &mockQuotaDirtyCache{
popSequence: [][]UserPlatformQuotaKey{keys},
getEntries: []*UserPlatformQuotaCacheEntry{
makeEntry(1.0, 2.0, 3.0),
},
}
writer := &mockQuotaSnapshotWriter{}
f := newTestFlusher(cache, writer)
// 标记为已停止
f.stopped.Store(true)
// tick 应该直接返回,不触发 flush
f.tick()
if len(writer.receivedSnaps) != 0 {
t.Errorf("expected 0 snaps after stop, got %d", len(writer.receivedSnaps))
}
if cache.popCallIdx != 0 {
t.Errorf("Pop should not be called after stop, popCallIdx = %d", cache.popCallIdx)
}
}
// ---------------------------------------------------------------------------
// 场景 B13-1: ZeroPercentCompany — 0% 公司脏集恒空flusher 空跑无 DB 写
//
// 模拟几乎没有用户配置 quota limit 的公司脏集始终为空popSequence 为空切片),
// Pop 每次返回空集。flush() 应早退,不写 DB、不计成功、不 Readd。
// ---------------------------------------------------------------------------
func TestScenario_ZeroPercentCompany(t *testing.T) {
cache := &mockQuotaDirtyCache{
// popSequence 为空 → Pop 超出序列 → 始终返回 nil空集
popSequence: [][]UserPlatformQuotaKey{},
}
writer := &mockQuotaSnapshotWriter{}
f := newTestFlusher(cache, writer)
f.flush()
if len(writer.receivedSnaps) != 0 {
t.Errorf("0%% company: expected 0 snaps, got %d", len(writer.receivedSnaps))
}
if f.metrics.FlushBatchSizeTotal.Load() != 0 {
t.Errorf("0%% company: FlushBatchSizeTotal = %d, want 0", f.metrics.FlushBatchSizeTotal.Load())
}
if f.metrics.FlushSuccessTotal.Load() != 0 {
t.Errorf("0%% company: FlushSuccessTotal = %d, want 0 (empty-set early return)", f.metrics.FlushSuccessTotal.Load())
}
if f.metrics.FlushErrorTotal.Load() != 0 {
t.Errorf("0%% company: FlushErrorTotal = %d, want 0", f.metrics.FlushErrorTotal.Load())
}
if len(cache.readdCalled) != 0 {
t.Errorf("0%% company: Readd should never be called, got %d calls", len(cache.readdCalled))
}
}
// ---------------------------------------------------------------------------
// P1: IntervalFallback — flush_interval_ms ≤0 时回退 2s正常值保留
// ---------------------------------------------------------------------------
func TestNewUserPlatformQuotaUsageFlusher_IntervalFallback(t *testing.T) {
cases := []struct {
name string
inMs int
wantDu time.Duration
}{
{"零值回退 2s", 0, 2 * time.Second},
{"负数回退 2s", -100, 2 * time.Second},
{"正常 2000ms 保留", 2000, 2 * time.Second},
{"正常 500ms 保留", 500, 500 * time.Millisecond},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
cfg := &config.Config{}
cfg.Database.UserPlatformQuotaFlushIntervalMs = tc.inMs
f := NewUserPlatformQuotaUsageFlusher(cfg, nil, nil, nil)
if f.interval != tc.wantDu {
t.Fatalf("interval = %v, want %v", f.interval, tc.wantDu)
}
})
}
}
// ---------------------------------------------------------------------------
// P1: EnabledField — flusher_enabled 配置正确写入 f.enabled
// ---------------------------------------------------------------------------
func TestNewUserPlatformQuotaUsageFlusher_EnabledField(t *testing.T) {
for _, enabled := range []bool{true, false} {
cfg := &config.Config{}
cfg.Database.UserPlatformQuotaFlusherEnabled = enabled
cfg.Database.UserPlatformQuotaFlushIntervalMs = 500
f := NewUserPlatformQuotaUsageFlusher(cfg, nil, nil, nil)
if f.enabled != enabled {
t.Errorf("enabled = %v, want %v", f.enabled, enabled)
}
}
}
// ---------------------------------------------------------------------------
// P2: ReaddFailCounts — BatchGet 失败 + Readd 失败 → DirtyLostTotal 增、DirtyReaddTotal 不变
// BatchGet 失败 + Readd 成功 → DirtyReaddTotal 增、DirtyLostTotal 不变
// ---------------------------------------------------------------------------
func TestFlusher_ReaddFailCounts(t *testing.T) {
keys := []UserPlatformQuotaKey{
{UserID: 10, Platform: "anthropic"},
{UserID: 11, Platform: "openai"},
}
t.Run("Readd 失败计 DirtyLostTotal", func(t *testing.T) {
cache := &mockQuotaDirtyCache{
popSequence: [][]UserPlatformQuotaKey{keys},
getErr: errors.New("redis timeout"), // 触发 BatchGet 失败路径
readdErr: errors.New("redis connection refused"), // Readd 也失败
}
f := newTestFlusher(cache, &mockQuotaSnapshotWriter{})
f.flush()
if f.metrics.DirtyLostTotal.Load() != int64(len(keys)) {
t.Errorf("DirtyLostTotal = %d, want %d", f.metrics.DirtyLostTotal.Load(), len(keys))
}
if f.metrics.DirtyReaddTotal.Load() != 0 {
t.Errorf("DirtyReaddTotal = %d, want 0 (Readd 失败不应计入)", f.metrics.DirtyReaddTotal.Load())
}
})
t.Run("Readd 成功计 DirtyReaddTotal", func(t *testing.T) {
cache := &mockQuotaDirtyCache{
popSequence: [][]UserPlatformQuotaKey{keys},
getErr: errors.New("redis timeout"), // 触发 BatchGet 失败路径
readdErr: nil, // Readd 成功
}
f := newTestFlusher(cache, &mockQuotaSnapshotWriter{})
f.flush()
if f.metrics.DirtyReaddTotal.Load() != int64(len(keys)) {
t.Errorf("DirtyReaddTotal = %d, want %d", f.metrics.DirtyReaddTotal.Load(), len(keys))
}
if f.metrics.DirtyLostTotal.Load() != 0 {
t.Errorf("DirtyLostTotal = %d, want 0 (Readd 成功不应计 lost)", f.metrics.DirtyLostTotal.Load())
}
})
}
// ---------------------------------------------------------------------------
// ClampsBatchSize — NewUserPlatformQuotaUsageFlusher 构造时按
// [defaultFlushBatchSize, maxFlushBatchSize] 区间 clamp batchSize
// ---------------------------------------------------------------------------
func TestNewUserPlatformQuotaUsageFlusher_ClampsBatchSize(t *testing.T) {
cases := []struct {
name string
in int
want int
}{
{"超上限被 clamp", 7000, maxFlushBatchSize},
{"恰好上限保留", maxFlushBatchSize, maxFlushBatchSize},
{"零回退默认", 0, defaultFlushBatchSize},
{"负数回退默认", -5, defaultFlushBatchSize},
{"正常值保留", 500, 500},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
cfg := &config.Config{}
cfg.Database.UserPlatformQuotaFlushBatchSize = tc.in
f := NewUserPlatformQuotaUsageFlusher(cfg, nil, nil, nil)
if f.batchSize != tc.want {
t.Fatalf("batchSize = %d, want %d", f.batchSize, tc.want)
}
})
}
}
// ---------------------------------------------------------------------------
// 场景 B13-2: NinetyPercentCompany — 90% 公司大量用户配 limit一批 5 key 批量刷库
//
// 模拟大量用户配置了 quota limit 的公司:脏集第一次 Pop 返回 5 个不同用户的 key
// 之后返回空集(避免 flush 循环。flush() 应构造 5 条 snapshot 写入 DB
// 断言绝对值语义snap 的 DailyUsageUSD 等于 entry 的值、metrics 正确、不 Readd。
// ---------------------------------------------------------------------------
func TestScenario_NinetyPercentCompany(t *testing.T) {
keys := []UserPlatformQuotaKey{
{UserID: 101, Platform: "anthropic"},
{UserID: 102, Platform: "anthropic"},
{UserID: 103, Platform: "openai"},
{UserID: 104, Platform: "openai"},
{UserID: 105, Platform: "anthropic"},
}
entries := []*UserPlatformQuotaCacheEntry{
makeEntry(1.1, 2.2, 3.3),
makeEntry(4.4, 5.5, 6.6),
makeEntry(7.7, 8.8, 9.9),
makeEntry(0.5, 1.0, 1.5),
makeEntry(10.0, 20.0, 30.0),
}
cache := &mockQuotaDirtyCache{
// 第 1 次 Pop 返回 5 keys之后返回空集防止 flush 无限循环)
popSequence: [][]UserPlatformQuotaKey{keys},
getEntries: entries,
}
writer := &mockQuotaSnapshotWriter{}
f := newTestFlusher(cache, writer)
f.flush()
// 应收到 5 条 snapshot
if len(writer.receivedSnaps) != 5 {
t.Fatalf("90%% company: expected 5 snaps, got %d", len(writer.receivedSnaps))
}
// 验证绝对值语义:第 1 条 snap 的各窗口 usage 应等于 entries[0] 的值
snap0 := writer.receivedSnaps[0]
entry0 := entries[0]
if snap0.DailyUsageUSD != entry0.DailyUsageUSD {
t.Errorf("snap[0].DailyUsageUSD = %v, want %v", snap0.DailyUsageUSD, entry0.DailyUsageUSD)
}
if snap0.WeeklyUsageUSD != entry0.WeeklyUsageUSD {
t.Errorf("snap[0].WeeklyUsageUSD = %v, want %v", snap0.WeeklyUsageUSD, entry0.WeeklyUsageUSD)
}
if snap0.MonthlyUsageUSD != entry0.MonthlyUsageUSD {
t.Errorf("snap[0].MonthlyUsageUSD = %v, want %v", snap0.MonthlyUsageUSD, entry0.MonthlyUsageUSD)
}
// FlushBatchSizeTotal 应为 5本批 keys 数量)
if f.metrics.FlushBatchSizeTotal.Load() != 5 {
t.Errorf("90%% company: FlushBatchSizeTotal = %d, want 5", f.metrics.FlushBatchSizeTotal.Load())
}
// FlushSuccessTotal 应为 11 个批次写成功)
if f.metrics.FlushSuccessTotal.Load() != 1 {
t.Errorf("90%% company: FlushSuccessTotal = %d, want 1", f.metrics.FlushSuccessTotal.Load())
}
// 无错误、无 Readd
if f.metrics.FlushErrorTotal.Load() != 0 {
t.Errorf("90%% company: FlushErrorTotal = %d, want 0", f.metrics.FlushErrorTotal.Load())
}
if f.metrics.DirtyReaddTotal.Load() != 0 {
t.Errorf("90%% company: DirtyReaddTotal = %d, want 0", f.metrics.DirtyReaddTotal.Load())
}
if len(cache.readdCalled) != 0 {
t.Errorf("90%% company: Readd should not be called, got %d calls", len(cache.readdCalled))
}
}

View File

@ -11,6 +11,23 @@ import (
// handler 只需引用 service 包,无需直接依赖 repository 包。
var ErrUserPlatformQuotaNotFound = errors.New("user platform quota not found")
// ErrUserPlatformQuotaFKViolation service 层 sentinel批量 snapshot UPSERT 时存在
// user_id 不在 users 表的记录外键违反。adapter 负责将 repository 层同名 sentinel 包装为此错误。
var ErrUserPlatformQuotaFKViolation = errors.New("user platform quota snapshot FK violation")
// UserPlatformQuotaSnapshot 是 service 层 flusher 向 DB 写入快照时使用的传输结构。
// 字段语义与 repository.UserPlatformQuotaSnapshot 完全对应,由 adapter 负责转换。
type UserPlatformQuotaSnapshot struct {
UserID int64
Platform string
DailyUsageUSD float64
WeeklyUsageUSD float64
MonthlyUsageUSD float64
DailyWindowStart time.Time
WeeklyWindowStart time.Time
MonthlyWindowStart time.Time
}
// UserPlatformQuotaRecord service 层传输结构体(与 repository 层解耦)。
type UserPlatformQuotaRecord struct {
UserID int64
@ -47,4 +64,6 @@ type UserPlatformQuotaRepository interface {
// ResetExpiredWindow 重置指定窗口("daily"|"weekly"|"monthly")的用量与起始时间。
// 未命中活跃记录时返回service-side wrapper of repository.ErrUserPlatformQuotaNotFound
ResetExpiredWindow(ctx context.Context, userID int64, platform string, window string, newStart time.Time) error
// BatchSnapshotUsage 绝对值覆盖写入整批 usage 快照。FK 违反返回 ErrUserPlatformQuotaFKViolation。
BatchSnapshotUsage(ctx context.Context, snapshots []UserPlatformQuotaSnapshot, now time.Time) error
}

View File

@ -327,10 +327,22 @@ func (m *mockBillingCache) DeleteUserPlatformQuotaCache(context.Context, int64,
return nil
}
func (m *mockBillingCache) IncrUserPlatformQuotaUsageCache(context.Context, int64, string, float64, time.Duration) error {
func (m *mockBillingCache) IncrUserPlatformQuotaUsageCache(context.Context, int64, string, float64, time.Duration, bool) error {
return nil
}
func (m *mockBillingCache) PopDirtyUserPlatformQuotaKeys(context.Context, int) ([]UserPlatformQuotaKey, error) {
return nil, nil
}
func (m *mockBillingCache) ReaddDirtyUserPlatformQuotaKeys(context.Context, []UserPlatformQuotaKey) error {
return nil
}
func (m *mockBillingCache) BatchGetUserPlatformQuotaCache(context.Context, []UserPlatformQuotaKey) ([]*UserPlatformQuotaCacheEntry, error) {
return nil, nil
}
// --- 测试 ---
func TestUpdateBalance_Success(t *testing.T) {

View File

@ -571,8 +571,16 @@ var ProviderSet = wire.NewSet(
ProvideChannelMonitorService,
ProvideChannelMonitorRunner,
NewChannelMonitorRequestTemplateService,
ProvideUserPlatformQuotaUsageFlusher,
)
// ProvideUserPlatformQuotaUsageFlusher 创建并启动 UserPlatformQuotaUsageFlusher。
func ProvideUserPlatformQuotaUsageFlusher(cfg *config.Config, cache BillingCache, quotaRepo UserPlatformQuotaRepository, tw *TimingWheelService) *UserPlatformQuotaUsageFlusher {
svc := NewUserPlatformQuotaUsageFlusher(cfg, cache, quotaRepo, tw)
svc.Start()
return svc
}
// ProvidePaymentConfigService wraps NewPaymentConfigService to accept the named
// payment.EncryptionKey type instead of raw []byte, avoiding Wire ambiguity.
func ProvidePaymentConfigService(entClient *dbent.Client, settingRepo SettingRepository, key payment.EncryptionKey) *PaymentConfigService {