diff --git a/backend/internal/handler/admin/user_handler.go b/backend/internal/handler/admin/user_handler.go index 24e4540f36..817bfa9117 100644 --- a/backend/internal/handler/admin/user_handler.go +++ b/backend/internal/handler/admin/user_handler.go @@ -790,15 +790,21 @@ func (h *UserHandler) UpdateUserPlatformQuotas(c *gin.Context) { } } + // 三档全空的输入不落库:user_platform_quotas 只保存至少配置了一档限额的记录, + // 不存在的行等价于不限额。未进入 records 的平台由 UpsertForUser 软删,审计里记为 removed。 records := make([]service.UserPlatformQuotaRecord, 0, len(req.Quotas)) for _, q := range req.Quotas { - records = append(records, service.UserPlatformQuotaRecord{ + rec := service.UserPlatformQuotaRecord{ UserID: userID, Platform: q.Platform, DailyLimitUSD: q.DailyLimitUSD, WeeklyLimitUSD: q.WeeklyLimitUSD, MonthlyLimitUSD: q.MonthlyLimitUSD, - }) + } + if !rec.HasAnyLimit() { + continue + } + records = append(records, rec) } ctx := c.Request.Context() @@ -860,6 +866,7 @@ func (h *UserHandler) UpdateUserPlatformQuotas(c *gin.Context) { slog.Info("admin.quota_updated", "actor_admin_id", getAdminIDFromContext(c), "target_user_id", userID, + "submitted_count", len(req.Quotas), "platform_count", len(records), "before_snapshot_available", beforeErr == nil, "changes", changes) diff --git a/backend/internal/handler/admin/user_platform_quota_admin_test.go b/backend/internal/handler/admin/user_platform_quota_admin_test.go index f1d1b5b605..cf512164e5 100644 --- a/backend/internal/handler/admin/user_platform_quota_admin_test.go +++ b/backend/internal/handler/admin/user_platform_quota_admin_test.go @@ -112,16 +112,61 @@ func TestUpdateUserPlatformQuotas_Success(t *testing.T) { if len(repo.upsertCalls) != 1 { t.Fatalf("UpsertForUser should be called once, got %d", len(repo.upsertCalls)) } - // upsert 记录数 = 请求体中给出的平台数(未给出的平台不落库)。 - if repo.upsertCalls[0].userID != 42 || len(repo.upsertCalls[0].records) != 5 { + // upsert 记录数 = 请求体中至少配置了一档限额的平台数;三档全空的平台不落库, + // 由 UpsertForUser 的"不在列表里就软删"分支处理。 + if repo.upsertCalls[0].userID != 42 || len(repo.upsertCalls[0].records) != 2 { t.Errorf("unexpected upsert call: %+v", repo.upsertCalls[0]) } + for _, r := range repo.upsertCalls[0].records { + if r.Platform != "anthropic" && r.Platform != "openai" { + t.Errorf("platform %q has no configured limit and must not be upserted", r.Platform) + } + } // 缓存失效:按全部允许平台统一失效(含 kimi/zhipu/deepseek)。 if len(cache.deleteCalls) != len(service.AllowedQuotaPlatforms) { t.Errorf("expected %d cache delete calls, got %d: %+v", len(service.AllowedQuotaPlatforms), len(cache.deleteCalls), cache.deleteCalls) } } +// TestUpdateUserPlatformQuotas_AllUnlimitedClearsRows 锁定:全部平台三档全空等价于清空, +// UpsertForUser 收到空列表(软删该用户所有活跃行),且 0 = 显式禁用仍算已配置。 +func TestUpdateUserPlatformQuotas_AllUnlimitedClearsRows(t *testing.T) { + repo := &upsertCapturingQuotaRepo{} + cache := &billingCacheStub{} + h := buildTestHandler(repo, cache) + + body := `{"quotas":[ + {"platform":"anthropic","daily_limit_usd":null,"weekly_limit_usd":null,"monthly_limit_usd":null}, + {"platform":"openai"} + ]}` + c, w := putReq(t, body) + h.UpdateUserPlatformQuotas(c) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) + } + if len(repo.upsertCalls) != 1 { + t.Fatalf("UpsertForUser should be called once, got %d", len(repo.upsertCalls)) + } + if len(repo.upsertCalls[0].records) != 0 { + t.Errorf("all-unlimited input must upsert zero records, got %+v", repo.upsertCalls[0].records) + } + + repo = &upsertCapturingQuotaRepo{} + h = buildTestHandler(repo, &billingCacheStub{}) + c, w = putReq(t, `{"quotas":[{"platform":"gemini","daily_limit_usd":0}]}`) + h.UpdateUserPlatformQuotas(c) + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) + } + if len(repo.upsertCalls) != 1 || len(repo.upsertCalls[0].records) != 1 { + t.Fatalf("zero limit is a configured limit and must be upserted: %+v", repo.upsertCalls) + } + if r := repo.upsertCalls[0].records[0]; r.Platform != "gemini" || r.DailyLimitUSD == nil || *r.DailyLimitUSD != 0 { + t.Errorf("unexpected record: %+v", r) + } +} + func TestUpdateUserPlatformQuotas_RejectsDuplicatePlatform(t *testing.T) { h := buildTestHandler(&upsertCapturingQuotaRepo{}, &billingCacheStub{}) body := `{"quotas":[ diff --git a/backend/internal/repository/user_platform_quota_repo.go b/backend/internal/repository/user_platform_quota_repo.go index ccba2330ce..2e085ba4bc 100644 --- a/backend/internal/repository/user_platform_quota_repo.go +++ b/backend/internal/repository/user_platform_quota_repo.go @@ -10,7 +10,6 @@ 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 层的传输结构体, @@ -29,10 +28,16 @@ type UserPlatformQuotaRecord struct { MonthlyWindowStart *time.Time } +// HasAnyLimit 报告该记录是否至少配置了一档限额(0 也算配置)。 +// 三档全空的记录视为未配置:不写入 user_platform_quotas,等价于不限额。 +func (r UserPlatformQuotaRecord) HasAnyLimit() bool { + return r.DailyLimitUSD != nil || r.WeeklyLimitUSD != nil || r.MonthlyLimitUSD != nil +} + // ErrUserPlatformQuotaNotFound 用于 ResetExpiredWindow 等需要"必须命中已有记录"的方法。 var ErrUserPlatformQuotaNotFound = fmt.Errorf("user platform quota record not found") -// ErrUserPlatformQuotaFKViolation 当批量 UPSERT 中存在 user_id 不在 users 表的记录时返回。 +// ErrUserPlatformQuotaFKViolation 表示批量快照写入命中了 user_id 不在 users 表的记录。 var ErrUserPlatformQuotaFKViolation = errors.New("user platform quota snapshot FK violation") // UserPlatformQuotaSnapshot 是 BatchSnapshotUsage 的输入结构体, @@ -50,21 +55,20 @@ type UserPlatformQuotaSnapshot struct { // UserPlatformQuotaRepository 定义用户平台配额的数据访问接口。 type UserPlatformQuotaRepository interface { - // BulkInsertInitial 幂等批量插入初始配额记录(ON CONFLICT DO NOTHING)。 + // BulkInsertInitial 幂等批量插入初始配额记录:冲突时只补既有行为 NULL 的档位;三档全空的记录跳过。 BulkInsertInitial(ctx context.Context, records []UserPlatformQuotaRecord) error // GetByUserPlatform 查询单条配额记录,未找到时返回 (nil, nil)。 GetByUserPlatform(ctx context.Context, userID int64, platform string) (*UserPlatformQuotaRecord, error) // ListByUser 查询用户的所有平台配额记录(排除软删除)。 ListByUser(ctx context.Context, userID int64) ([]UserPlatformQuotaRecord, error) - // IncrementUsageWithReset 原子地累加用量,若窗口已过期则先重置再累加。 + // IncrementUsageWithReset 原子地累加用量,若窗口已过期则先重置再累加;记录不存在时不建行、直接返回 nil。 IncrementUsageWithReset(ctx context.Context, userID int64, platform string, cost float64, now time.Time) error // ResetExpiredWindow 重置指定窗口(daily/weekly/monthly)的用量与起始时间。 ResetExpiredWindow(ctx context.Context, userID int64, platform string, window string, newStart time.Time) error - // UpsertForUser 全量替换该用户所有平台限额配置(详见 service.UserPlatformQuotaRepository.UpsertForUser)。 + // 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 把整批 usage 以绝对值覆盖写入既有活跃行(非累加);行不存在或已软删的 (user,platform) 跳过。 + // usage/window_start 直接取 Redis 当前窗口快照。整批共用 now 作 updated_at。要求 snapshots 内 (user,platform) 不重复。 BatchSnapshotUsage(ctx context.Context, snapshots []UserPlatformQuotaSnapshot, now time.Time) error } @@ -77,18 +81,17 @@ func NewUserPlatformQuotaRepository(client *dbent.Client) UserPlatformQuotaRepos return &userPlatformQuotaRepository{client: client} } -// BulkInsertInitial 用原生 SQL ON CONFLICT 实现幂等批量插入(带条件 limit 覆盖)。 +// BulkInsertInitial 用原生 SQL ON CONFLICT 实现幂等批量插入。 // 仅插入 limit_usd 与元数据,usage_usd 用 DB 默认 0,window_start 留 NULL。 +// 三档限额全空的记录视为未配置,跳过不插入。 // FK 约束要求 user_id 在 users 表中存在,调用方负责保证。 // -// 冲突策略:CASE WHEN existing.*_limit_usd IS NULL THEN EXCLUDED.*_limit_usd ELSE existing ... -// - 若 IncrementUsageWithReset 因时序问题已先建行(limit 全 NULL), -// 此处会把注册时的默认 limit 写入,避免该用户在该平台永久无限额。 -// - 若管理员已通过 UpsertForUser 设置了非 NULL 个性化 limit,**保留不动** -// —— 旧实现无条件 EXCLUDED 覆盖会丢失个性化配置。 +// 冲突策略:COALESCE(existing.*_limit_usd, EXCLUDED.*_limit_usd) +// - 既有行某档 limit 为 NULL 时写入本次的值,非 NULL 的既有 limit 保持不动。 // - 不会改 usage_usd / window_start,保留累计的用量。 // - 仅命中 deleted_at IS NULL 的活跃记录(partial unique index 作用域)。 func (r *userPlatformQuotaRepository) BulkInsertInitial(ctx context.Context, records []UserPlatformQuotaRecord) error { + records = configuredRecords(records) if len(records) == 0 { return nil } @@ -116,8 +119,6 @@ func (r *userPlatformQuotaRepository) BulkInsertInitial(ctx context.Context, rec } // 精确命中 partial unique index(deleted_at IS NULL),避免对软删记录的歧义冲突。 // 条件覆盖:仅在现有 limit 为 NULL 时才写入 EXCLUDED,否则保留现有非 NULL 值。 - // - 修复 IncrementUsageWithReset 已用 NULL limit 建行的场景(NULL → 注册默认) - // - 保护管理员通过 UpsertForUser 设置的个性化 limit 不被静默覆盖 _, _ = sb.WriteString(` ON CONFLICT (user_id, platform) WHERE deleted_at IS NULL DO UPDATE SET daily_limit_usd = COALESCE(user_platform_quotas.daily_limit_usd, EXCLUDED.daily_limit_usd), @@ -169,13 +170,10 @@ func (r *userPlatformQuotaRepository) ListByUser(ctx context.Context, userID int // IncrementUsageWithReset 原子累加 cost 到 (user, platform) 三个窗口的 *_usage_usd。 // 行为: -// - 若记录存在:在事务内 SELECT FOR UPDATE,按 (prev_window_start vs current_window_start) +// - 记录存在:在事务内 SELECT FOR UPDATE,按 (prev_window_start vs current_window_start) // 判断是否需要重置(不同 = 重置为 cost;相同 = 累加 cost) -// - 若记录不存在(fail-open create 分支):插入新记录,**limit 字段保留 nil(无限制)** -// —— 这是预期行为:billing 链路不能因 quota 表缺失而阻断请求,未注册路径 -// 的用户 quota 默认放行,由调度层指标观测 + 后台对账补建 limit -// -// 上层正常路径(注册时 BulkInsertInitial)保证 limit 在记录创建时就被写入。 +// - 记录不存在:直接返回 nil,不建行。限额只存在于 user_platform_quotas 的行中, +// 不存在的行等价于不限额,没有可累加的对象。 func (r *userPlatformQuotaRepository) IncrementUsageWithReset(ctx context.Context, userID int64, platform string, cost float64, now time.Time) error { return r.withTx(ctx, func(txCtx context.Context, txClient *dbent.Client) error { existing, err := txClient.UserPlatformQuota.Query(). @@ -187,25 +185,7 @@ func (r *userPlatformQuotaRepository) IncrementUsageWithReset(ctx context.Contex ForUpdate(). Only(txCtx) if dbent.IsNotFound(err) { - // fail-open 建行:limit_* 保留 NULL(无限额)。 - // 用 ON CONFLICT DO UPDATE 累加,而非裸 INSERT:并发下另一请求可能在本事务 - // SELECT FOR UPDATE 之后、INSERT 之前刚建行,裸 INSERT 会撞 partial unique index - // 致事务回滚、本次 cost 丢失;DO UPDATE 把 cost 累加到既有 usage 上。 - // 写法与本文件 insertLimitsRow / BulkInsertInitial 的 ON CONFLICT 一致。 - const insertSQL = `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, $2, $3, $3, $3, $4, $5, $6, $7, $7) - ON CONFLICT (user_id, platform) WHERE deleted_at IS NULL DO UPDATE SET - daily_usage_usd = user_platform_quotas.daily_usage_usd + EXCLUDED.daily_usage_usd, - weekly_usage_usd = user_platform_quotas.weekly_usage_usd + EXCLUDED.weekly_usage_usd, - monthly_usage_usd = user_platform_quotas.monthly_usage_usd + EXCLUDED.monthly_usage_usd, - updated_at = EXCLUDED.updated_at` - // $6 = now:30 天滚动月度窗口以当前时刻为起始 - _, e := txClient.ExecContext(txCtx, insertSQL, - userID, platform, cost, - timezone.StartOfDay(now), timezone.StartOfWeek(now), now, now) - return e + return nil } if err != nil { return err @@ -331,12 +311,13 @@ func monthlyMaybeReset(prevUsage float64, prevStart *time.Time, cost float64, no } // UpsertForUser 全量替换该用户的所有平台限额(事务内): -// 1. 软删除未在 records 中出现的所有 active 行 -// 2. 对每条 record 尝试 UPDATE(含 deleted_at = NULL 兼容重激活); -// UPDATE 行数为 0 时 INSERT 新行 +// 1. 软删除未在 records 中出现、或在 records 中但三档全空的所有 active 行 +// 2. 对每条至少配置了一档限额的 record 尝试 UPDATE active 行;UPDATE 行数为 0 时 INSERT 新行 // -// 仅改 *_limit_usd + deleted_at + updated_at,保留 *_usage_usd / *_window_start。 +// 仅改 *_limit_usd + updated_at,保留 *_usage_usd / *_window_start。 +// 清空某平台的限额即软删该行并放弃其累计用量;之后再次配置限额会新建行、从新窗口起算。 func (r *userPlatformQuotaRepository) UpsertForUser(ctx context.Context, userID int64, records []UserPlatformQuotaRecord) error { + records = configuredRecords(records) return r.withTx(ctx, func(txCtx context.Context, txClient *dbent.Client) error { platforms := make([]string, 0, len(records)) for _, rec := range records { @@ -391,13 +372,12 @@ func softDeleteMissingPlatforms(ctx context.Context, client *dbent.Client, userI } // updateLimitsRow 尝试 UPDATE active 行(deleted_at IS NULL),返回受影响行数。 -// 仅更新 active 行:若存在多条历史软删记录,加 deleted_at IS NULL 守卫可避免 -// 批量重激活导致的 partial unique index(userplatformquota_user_id_platform_uq)冲突。 -// affected=0 时由调用方 UpsertForUser 走 insertLimitsRow 路径创建新行。 +// 软删行不会被命中:历史软删记录可能有多条,重激活会撞 partial unique index +// (userplatformquota_user_id_platform_uq)。affected=0 时由调用方 UpsertForUser 走 +// insertLimitsRow 路径创建新行。 func updateLimitsRow(ctx context.Context, client *dbent.Client, userID int64, rec UserPlatformQuotaRecord, now time.Time) (int64, error) { const query = `UPDATE user_platform_quotas - SET daily_limit_usd = $1, weekly_limit_usd = $2, monthly_limit_usd = $3, - deleted_at = NULL, updated_at = $4 + SET daily_limit_usd = $1, weekly_limit_usd = $2, monthly_limit_usd = $3, updated_at = $4 WHERE user_id = $5 AND platform = $6 AND deleted_at IS NULL` res, err := client.ExecContext(ctx, query, rec.DailyLimitUSD, rec.WeeklyLimitUSD, rec.MonthlyLimitUSD, now, @@ -437,14 +417,14 @@ func insertLimitsRow(ctx context.Context, client *dbent.Client, userID int64, re return nil } -// batchRows 是 BatchSnapshotUsage 每批最大行数(9 参/行 × 6000 ≈ 54000 参,低于 Postgres 65535 上限)。 +// batchRows 是 BatchSnapshotUsage 每批最大行数(8 参/行 × 6000 + 1 ≈ 48001 参,低于 Postgres 65535 上限)。 const batchRows = 6000 -// BatchSnapshotUsage 用一条多行 UPSERT 把整批 usage 以绝对值覆盖写入(非累加)。 +// BatchSnapshotUsage 用一条多行 UPDATE ... FROM (VALUES ...) 把整批 usage 以绝对值覆盖写入既有活跃行(非累加)。 // 每批最多 batchRows 行;$1=now 共用;每行 8 个 per-row 参(user_id, platform, 3×usage, 3×window_start)。 -// FK 违反(user_id 不存在)返回 ErrUserPlatformQuotaFKViolation。 +// 只命中 deleted_at IS NULL 的既有行:行不存在或已软删的 (user, platform) 直接跳过,不建行。 // -// 注意:snapshots 超过 batchRows 会分多条 SQL 执行且【非单事务】——若某子批 FK 失败, +// 注意:snapshots 超过 batchRows 会分多条 SQL 执行且【非单事务】——若某子批失败, // 先前子批已写入无法回滚。调用方(flusher)应保证单次 batchSize ≤ batchRows // (默认 flush_batch_size=1000 < 6000,安全)。 // 另注:启用 flusher 后,本绝对值覆盖与 admin 直写 DB(ResetExpiredWindow/UpsertForUser)存在覆盖竞态, @@ -465,19 +445,25 @@ func (r *userPlatformQuotaRepository) BatchSnapshotUsage(ctx context.Context, sn 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 ") + "UPDATE user_platform_quotas AS q SET" + + " daily_usage_usd = v.daily_usage_usd," + + " weekly_usage_usd = v.weekly_usage_usd," + + " monthly_usage_usd = v.monthly_usage_usd," + + " daily_window_start = v.daily_window_start," + + " weekly_window_start = v.weekly_window_start," + + " monthly_window_start = v.monthly_window_start," + + " updated_at = $1" + + " FROM (VALUES ") // $1 = now(共用);每行 8 个 per-row 参,从 $2 起连续编号。 + // VALUES 里的占位符显式转型,避免 Postgres 对多行 VALUES 推断不出参数类型。 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)", + fmt.Fprintf(&sb, "($%d::bigint,$%d::varchar,$%d::numeric,$%d::numeric,$%d::numeric,$%d::timestamptz,$%d::timestamptz,$%d::timestamptz)", b+1, b+2, b+3, b+4, b+5, b+6, b+7, b+8) args = append(args, s.UserID, s.Platform, @@ -487,22 +473,24 @@ func (r *userPlatformQuotaRepository) BatchSnapshotUsage(ctx context.Context, sn } _, _ = 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") + ") AS v(user_id, platform, daily_usage_usd, weekly_usage_usd, monthly_usage_usd," + + " daily_window_start, weekly_window_start, monthly_window_start)" + + " WHERE q.user_id = v.user_id AND q.platform = v.platform AND q.deleted_at IS NULL") 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 } + +// configuredRecords 过滤出至少配置了一档限额的记录;三档全空的记录视为未配置。 +func configuredRecords(records []UserPlatformQuotaRecord) []UserPlatformQuotaRecord { + out := make([]UserPlatformQuotaRecord, 0, len(records)) + for _, rec := range records { + if rec.HasAnyLimit() { + out = append(out, rec) + } + } + return out +} diff --git a/backend/internal/repository/user_platform_quota_repo_integration_test.go b/backend/internal/repository/user_platform_quota_repo_integration_test.go index c9433adc87..6cfe952823 100644 --- a/backend/internal/repository/user_platform_quota_repo_integration_test.go +++ b/backend/internal/repository/user_platform_quota_repo_integration_test.go @@ -10,6 +10,8 @@ import ( "time" dbent "github.com/Wei-Shaw/sub2api/ent" + "github.com/Wei-Shaw/sub2api/ent/schema/mixins" + "github.com/Wei-Shaw/sub2api/ent/userplatformquota" "github.com/Wei-Shaw/sub2api/internal/service" "github.com/stretchr/testify/require" ) @@ -23,6 +25,16 @@ func mustCreateUserForQuota(t *testing.T, client *dbent.Client) int64 { return u.ID } +// mustSeedQuotaRow 为 (user, platform) 建一条带 daily limit 的活跃记录。 +// IncrementUsageWithReset 只累加已存在的记录,累加类测试先用它建行。 +func mustSeedQuotaRow(t *testing.T, ctx context.Context, repo UserPlatformQuotaRepository, userID int64, platform string) { + t.Helper() + limit := 1000.0 + require.NoError(t, repo.BulkInsertInitial(ctx, []UserPlatformQuotaRecord{ + {UserID: userID, Platform: platform, DailyLimitUSD: &limit}, + })) +} + func TestUserPlatformQuotaRepository_BulkInsertInitial_Idempotent(t *testing.T) { ctx := context.Background() tx := testEntTx(t) @@ -36,17 +48,17 @@ func TestUserPlatformQuotaRepository_BulkInsertInitial_Idempotent(t *testing.T) daily := 5.0 records := []UserPlatformQuotaRecord{ {UserID: userID, Platform: "anthropic", DailyLimitUSD: &daily}, - {UserID: userID, Platform: "openai"}, + {UserID: userID, Platform: "openai"}, // 三档全空 = 未配置,不建行 } // 第一次插入 require.NoError(t, repo.BulkInsertInitial(txCtx, records), "first insert") - // 第二次插入应为 no-op(ON CONFLICT DO NOTHING) + // 第二次插入应为 no-op(ON CONFLICT 只补 NULL 档位) require.NoError(t, repo.BulkInsertInitial(txCtx, records), "second insert (idempotent)") list, err := repo.ListByUser(txCtx, userID) require.NoError(t, err, "list") - require.Len(t, list, 2, "expected 2 records after idempotent insert") + require.Len(t, list, 1, "only the configured platform gets a row, and only once") // 校验 daily_limit_usd 保留 var anthropicRec *UserPlatformQuotaRecord @@ -114,9 +126,9 @@ func TestUserPlatformQuotaRepository_BulkInsertInitial_CNProvidersAllowed(t *tes daily := 12.0 records := []UserPlatformQuotaRecord{ {UserID: userID, Platform: "kimi", DailyLimitUSD: &daily}, - {UserID: userID, Platform: "zhipu"}, - {UserID: userID, Platform: "deepseek"}, - {UserID: userID, Platform: "minimax"}, + {UserID: userID, Platform: "zhipu", DailyLimitUSD: &daily}, + {UserID: userID, Platform: "deepseek", DailyLimitUSD: &daily}, + {UserID: userID, Platform: "minimax", DailyLimitUSD: &daily}, } require.NoError(t, repo.BulkInsertInitial(txCtx, records), "kimi/zhipu/deepseek/minimax 平台应可写入(CHECK 约束已含国产供应商)") @@ -168,8 +180,9 @@ func TestUserPlatformQuotaRepository_IncrementUsageWithReset_SameWindow(t *testi repo := NewUserPlatformQuotaRepository(client) now := time.Date(2026, 5, 22, 10, 0, 0, 0, time.UTC) // 周五 + mustSeedQuotaRow(t, ctx, repo, userID, "anthropic") - // 首次调用:应新建记录 + // 首次累加:window_start 为 NULL 视为窗口过期,usage 重置为 cost require.NoError(t, repo.IncrementUsageWithReset(ctx, userID, "anthropic", 1.5, now)) rec, err := repo.GetByUserPlatform(ctx, userID, "anthropic") @@ -195,6 +208,7 @@ func TestUserPlatformQuotaRepository_IncrementUsageWithReset_DailyReset(t *testi userID := mustCreateUserForQuota(t, client) repo := NewUserPlatformQuotaRepository(client) + mustSeedQuotaRow(t, ctx, repo, userID, "anthropic") day1 := time.Date(2026, 5, 22, 10, 0, 0, 0, time.UTC) // 周五(同一周、同一月) day2 := time.Date(2026, 5, 23, 10, 0, 0, 0, time.UTC) // 周六(同一周、同一月) @@ -215,6 +229,7 @@ func TestUserPlatformQuotaRepository_IncrementUsageWithReset_WeeklyReset(t *test userID := mustCreateUserForQuota(t, client) repo := NewUserPlatformQuotaRepository(client) + mustSeedQuotaRow(t, ctx, repo, userID, "openai") // 5月22日(周五)和 5月25日(下周一),不同周 fri := time.Date(2026, 5, 22, 10, 0, 0, 0, time.UTC) @@ -230,6 +245,26 @@ func TestUserPlatformQuotaRepository_IncrementUsageWithReset_WeeklyReset(t *test require.InDelta(t, 7.0, rec.MonthlyUsageUSD, 1e-9, "monthly accumulates (same month)") } +// TestUserPlatformQuotaRepository_IncrementUsageWithReset_NoRowIsNoop 锁定不变式: +// 没有配额记录的 (user, platform) 累加时不建行、不报错。 +func TestUserPlatformQuotaRepository_IncrementUsageWithReset_NoRowIsNoop(t *testing.T) { + ctx := context.Background() + client := testEntClient(t) + userID := mustCreateUserForQuota(t, client) + repo := NewUserPlatformQuotaRepository(client) + + now := time.Date(2026, 5, 22, 10, 0, 0, 0, time.UTC) + require.NoError(t, repo.IncrementUsageWithReset(ctx, userID, "anthropic", 1.5, now)) + + rec, err := repo.GetByUserPlatform(ctx, userID, "anthropic") + require.NoError(t, err) + require.Nil(t, rec, "increment on a missing row must not create one") + + list, err := repo.ListByUser(ctx, userID) + require.NoError(t, err) + require.Empty(t, list) +} + func TestUserPlatformQuotaRepository_ResetExpiredWindow(t *testing.T) { ctx := context.Background() tx := testEntTx(t) @@ -324,10 +359,10 @@ func TestUserPlatformQuotaRepository_ResetExpiredWindow_NotFoundReturnsSentinel( "expected ErrUserPlatformQuotaNotFound, got %v", err) } -// TestBatchSnapshotUsage_InsertOverwriteMultiKey 验证 BatchSnapshotUsage 的绝对值覆盖语义: -// 1. 首批插入 2 条(不同 user),验证 daily 等于首批值; +// TestBatchSnapshotUsage_OverwriteMultiKey 验证 BatchSnapshotUsage 的绝对值覆盖语义: +// 1. 首批写入 2 条既有行(不同 user),验证 daily 等于首批值; // 2. 对同一 key 传不同值,验证 daily 等于新值(绝对覆盖,非累加)。 -func TestBatchSnapshotUsage_InsertOverwriteMultiKey(t *testing.T) { +func TestBatchSnapshotUsage_OverwriteMultiKey(t *testing.T) { ctx := context.Background() // BatchSnapshotUsage 不开事务(直接写),使用独立 client 保证跨调用可见性。 client := testEntClient(t) @@ -336,13 +371,15 @@ func TestBatchSnapshotUsage_InsertOverwriteMultiKey(t *testing.T) { userID2 := mustCreateUserForQuota(t, client) repo := NewUserPlatformQuotaRepository(client) + mustSeedQuotaRow(t, ctx, repo, userID1, "anthropic") + mustSeedQuotaRow(t, ctx, repo, userID2, "openai") 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 行 ────────────────────────────────────────────────────── + // ── 第一批:覆盖 2 行 ────────────────────────────────────────────────────── firstBatch := []UserPlatformQuotaSnapshot{ { UserID: userID1, @@ -421,3 +458,48 @@ func TestBatchSnapshotUsage_InsertOverwriteMultiKey(t *testing.T) { 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") } + +// TestBatchSnapshotUsage_NoRowIsNoop 锁定不变式:快照只落到既有活跃行; +// 行不存在或已软删的 (user, platform) 既不建行也不复活。 +func TestBatchSnapshotUsage_NoRowIsNoop(t *testing.T) { + ctx := context.Background() + client := testEntClient(t) + userID := mustCreateUserForQuota(t, client) + repo := NewUserPlatformQuotaRepository(client) + + now := time.Date(2026, 5, 29, 12, 0, 0, 0, time.UTC) + snap := func(platform string) UserPlatformQuotaSnapshot { + return UserPlatformQuotaSnapshot{ + UserID: userID, + Platform: platform, + DailyUsageUSD: 1.0, + WeeklyUsageUSD: 2.0, + MonthlyUsageUSD: 3.0, + DailyWindowStart: time.Date(2026, 5, 29, 0, 0, 0, 0, time.UTC), + WeeklyWindowStart: time.Date(2026, 5, 25, 0, 0, 0, 0, time.UTC), + MonthlyWindowStart: time.Date(2026, 5, 1, 0, 0, 0, 0, time.UTC), + } + } + + // 从未有行 + require.NoError(t, repo.BatchSnapshotUsage(ctx, []UserPlatformQuotaSnapshot{snap("anthropic")}, now)) + rec, err := repo.GetByUserPlatform(ctx, userID, "anthropic") + require.NoError(t, err) + require.Nil(t, rec, "snapshot must not create a row") + + // 有过行但已被软删(管理员清空限额) + mustSeedQuotaRow(t, ctx, repo, userID, "openai") + require.NoError(t, repo.UpsertForUser(ctx, userID, nil)) + require.NoError(t, repo.BatchSnapshotUsage(ctx, []UserPlatformQuotaSnapshot{snap("openai")}, now)) + rec, err = repo.GetByUserPlatform(ctx, userID, "openai") + require.NoError(t, err) + require.Nil(t, rec, "snapshot must not revive or recreate a soft-deleted row") + + // 软删 mixin 默认隐藏已删行,计数全部行需显式跳过 + all, err := client.UserPlatformQuota.Query(). + Where(userplatformquota.UserIDEQ(userID)). + All(mixins.SkipSoftDelete(ctx)) + require.NoError(t, err) + require.Len(t, all, 1, "only the soft-deleted openai row may exist") + require.NotNil(t, all[0].DeletedAt) +} diff --git a/backend/internal/repository/user_platform_quota_upsert_test.go b/backend/internal/repository/user_platform_quota_upsert_test.go index db8e6f4788..e3bfa42936 100644 --- a/backend/internal/repository/user_platform_quota_upsert_test.go +++ b/backend/internal/repository/user_platform_quota_upsert_test.go @@ -7,6 +7,7 @@ import ( "testing" "time" + "github.com/Wei-Shaw/sub2api/ent/schema/mixins" "github.com/Wei-Shaw/sub2api/ent/userplatformquota" "github.com/stretchr/testify/require" ) @@ -146,3 +147,44 @@ func TestUpsertForUser_EmptyClearsAll(t *testing.T) { require.NoError(t, err) require.Empty(t, got) } + +// TestUpsertForUser_UnlimitedRecordSoftDeletesAndDiscardsUsage 锁定语义:三档全空的记录视为未配置, +// 等价于该平台不在列表里 → 软删既有行并放弃其累计用量;之后再配置限额会新建行、从新窗口起算。 +func TestUpsertForUser_UnlimitedRecordSoftDeletesAndDiscardsUsage(t *testing.T) { + ctx := context.Background() + client := testEntClient(t) + userID := mustCreateUserForQuota(t, client) + repo := NewUserPlatformQuotaRepository(client) + + d := 10.0 + require.NoError(t, repo.UpsertForUser(ctx, userID, []UserPlatformQuotaRecord{ + {UserID: userID, Platform: "anthropic", DailyLimitUSD: &d}, + })) + now := time.Date(2026, 5, 22, 10, 0, 0, 0, time.UTC) + require.NoError(t, repo.IncrementUsageWithReset(ctx, userID, "anthropic", 7.5, now)) + + // 清空限额:提交三档全空的记录 + require.NoError(t, repo.UpsertForUser(ctx, userID, []UserPlatformQuotaRecord{ + {UserID: userID, Platform: "anthropic"}, + })) + gone, err := repo.GetByUserPlatform(ctx, userID, "anthropic") + require.NoError(t, err) + require.Nil(t, gone, "an unlimited record must soft-delete the row instead of keeping it with NULL limits") + + // 重新配置限额:新行从零起算 + require.NoError(t, repo.UpsertForUser(ctx, userID, []UserPlatformQuotaRecord{ + {UserID: userID, Platform: "anthropic", DailyLimitUSD: &d}, + })) + back, err := repo.GetByUserPlatform(ctx, userID, "anthropic") + require.NoError(t, err) + require.NotNil(t, back) + require.InDelta(t, 0, back.DailyUsageUSD, 1e-9, "usage restarts from zero") + require.Nil(t, back.DailyWindowStart, "window restarts") + + // 软删 mixin 默认隐藏已删行,计数全部行需显式跳过 + all, err := client.UserPlatformQuota.Query(). + Where(userplatformquota.UserIDEQ(userID), userplatformquota.PlatformEQ("anthropic")). + All(mixins.SkipSoftDelete(ctx)) + require.NoError(t, err) + require.Len(t, all, 2, "one soft-deleted history row plus one active row") +} diff --git a/backend/internal/service/auth_email_oauth_auto_test.go b/backend/internal/service/auth_email_oauth_auto_test.go index d128e6bbcb..316614f534 100644 --- a/backend/internal/service/auth_email_oauth_auto_test.go +++ b/backend/internal/service/auth_email_oauth_auto_test.go @@ -75,6 +75,7 @@ func TestEmailOAuthAuto_SnapshotsPlatformQuotaDefaults(t *testing.T) { require.Len(t, quotaRepo.bulkInsertCalls, 1, "createEmailOAuthUser must snapshot platform quotas via BulkInsertInitial") records := quotaRepo.bulkInsertCalls[0] + require.Len(t, records, 1, "only platforms with a configured limit get a row") var geminiRecord *UserPlatformQuotaRecord for i := range records { if records[i].Platform == "gemini" { diff --git a/backend/internal/service/auth_oauth_email_flow_test.go b/backend/internal/service/auth_oauth_email_flow_test.go index 6e57e95016..e5904caeac 100644 --- a/backend/internal/service/auth_oauth_email_flow_test.go +++ b/backend/internal/service/auth_oauth_email_flow_test.go @@ -539,6 +539,7 @@ func TestFinalizeOAuthEmailAccount_SnapshotsPlatformQuotaDefaults(t *testing.T) require.Len(t, quotaRepo.bulkInsertCalls, 1, "snapshotPlatformQuotaDefaults must call BulkInsertInitial once on successful OAuth signup") records := quotaRepo.bulkInsertCalls[0] + require.Len(t, records, 1, "only platforms with a configured limit get a row") var anthropicRecord *UserPlatformQuotaRecord for i := range records { if records[i].Platform == "anthropic" { diff --git a/backend/internal/service/auth_service.go b/backend/internal/service/auth_service.go index f17cfbb4d7..f2a76ccb9d 100644 --- a/backend/internal/service/auth_service.go +++ b/backend/internal/service/auth_service.go @@ -1930,7 +1930,7 @@ func resolvedTokenVersion(user *User) int64 { return user.TokenVersion ^ fingerprint } -// snapshotPlatformQuotaDefaults 把 plan.PlatformQuotas(platform × 3 window)以 +// snapshotPlatformQuotaDefaults 把 plan.PlatformQuotas 中至少配置了一档限额的平台以 // BulkInsertInitial 形式写入 user_platform_quotas 表。失败 fail-open(仅 warn log)。 func (s *AuthService) snapshotPlatformQuotaDefaults(ctx context.Context, userID int64, plan *signupGrantPlan) error { if s.userPlatformQuotaRepo == nil || plan == nil || len(plan.PlatformQuotas) == 0 { @@ -1941,18 +1941,23 @@ func (s *AuthService) snapshotPlatformQuotaDefaults(ctx context.Context, userID // 整个调用方事务被 Postgres 标记 aborted,把"无关紧要的默认配额快照"放大成 // "整笔注册失败"(OAuth pending 路径曾因此 500 → 清 cookie → 404)。 ctx = dbent.WithoutTx(ctx) + // 仅为至少配置了一档限额的平台建行:user_platform_quotas 中不存在的行等价于不限额, + // 三档全空的记录不携带任何可执行的限额。 records := make([]UserPlatformQuotaRecord, 0, len(plan.PlatformQuotas)) for platform, q := range plan.PlatformQuotas { - rec := UserPlatformQuotaRecord{ - UserID: userID, - Platform: platform, + if !q.HasAnyLimit() { + continue } - if q != nil { - rec.DailyLimitUSD = q.DailyLimitUSD - rec.WeeklyLimitUSD = q.WeeklyLimitUSD - rec.MonthlyLimitUSD = q.MonthlyLimitUSD - } - records = append(records, rec) + records = append(records, UserPlatformQuotaRecord{ + UserID: userID, + Platform: platform, + DailyLimitUSD: q.DailyLimitUSD, + WeeklyLimitUSD: q.WeeklyLimitUSD, + MonthlyLimitUSD: q.MonthlyLimitUSD, + }) + } + if len(records) == 0 { + return nil } if err := s.userPlatformQuotaRepo.BulkInsertInitial(ctx, records); err != nil { logger.LegacyPrintf("service.auth", "[Auth] Warning: snapshot platform quota failed user=%d: %v (fail-open)", userID, err) diff --git a/backend/internal/service/auth_service_platform_quota_test.go b/backend/internal/service/auth_service_platform_quota_test.go index 31e2b21677..1895e6cb22 100644 --- a/backend/internal/service/auth_service_platform_quota_test.go +++ b/backend/internal/service/auth_service_platform_quota_test.go @@ -15,6 +15,7 @@ import ( type fakeInsertRecorder struct { records []UserPlatformQuotaRecord err error + calls int // BulkInsertInitial 被调用的次数 lastCtx context.Context // 捕获最后一次 BulkInsertInitial 收到的 ctx(用于断言事务隔离) } @@ -23,6 +24,7 @@ func (f *fakeInsertRecorder) GetByUserPlatform(_ context.Context, _ int64, _ str } func (f *fakeInsertRecorder) BulkInsertInitial(ctx context.Context, recs []UserPlatformQuotaRecord) error { + f.calls++ f.lastCtx = ctx if f.err != nil { return f.err @@ -51,33 +53,70 @@ func (f *fakeInsertRecorder) BatchSnapshotUsage(_ context.Context, _ []UserPlatf return nil } +// TestSnapshotPlatformQuotaDefaults_PassesToRepoBulkInsert 锁定不变式:只有至少配置了一档限额的 +// 平台才建行;三档全空(含 nil 条目)的平台不进入 BulkInsertInitial。 func TestSnapshotPlatformQuotaDefaults_PassesToRepoBulkInsert(t *testing.T) { fakeRepo := &fakeInsertRecorder{} s := &AuthService{userPlatformQuotaRepo: fakeRepo} five := 5.0 + zero := 0.0 plan := &signupGrantPlan{ PlatformQuotas: map[string]*DefaultPlatformQuotaSetting{ "anthropic": {DailyLimitUSD: &five}, + "grok": {MonthlyLimitUSD: &zero}, // 0 = 显式禁用,属于已配置 "openai": {}, "gemini": {}, - "antigravity": {}, + "antigravity": nil, }, } if err := s.snapshotPlatformQuotaDefaults(context.Background(), 999, plan); err != nil { t.Fatal(err) } - if len(fakeRepo.records) != 4 { - t.Errorf("expected 4 records, got %d", len(fakeRepo.records)) + if len(fakeRepo.records) != 2 { + t.Fatalf("expected 2 records (anthropic, grok), got %d: %+v", len(fakeRepo.records), fakeRepo.records) } - found := false + byPlatform := map[string]UserPlatformQuotaRecord{} for _, r := range fakeRepo.records { - if r.UserID == 999 && r.Platform == "anthropic" && r.DailyLimitUSD != nil && *r.DailyLimitUSD == 5 { - found = true + if r.UserID != 999 { + t.Errorf("unexpected user id %d", r.UserID) + } + byPlatform[r.Platform] = r + } + if r, ok := byPlatform["anthropic"]; !ok || r.DailyLimitUSD == nil || *r.DailyLimitUSD != 5 { + t.Error("anthropic daily = 5 not snapshotted") + } + if r, ok := byPlatform["grok"]; !ok || r.MonthlyLimitUSD == nil || *r.MonthlyLimitUSD != 0 { + t.Error("grok monthly = 0 (explicit disable) must be snapshotted") + } + for _, p := range []string{"openai", "gemini", "antigravity"} { + if _, ok := byPlatform[p]; ok { + t.Errorf("platform %q has no configured limit and must not get a row", p) } } - if !found { - t.Error("anthropic daily = 5 not snapshotted") +} + +// TestSnapshotPlatformQuotaDefaults_AllUnlimitedSkipsInsert 锁定:没有任何平台配置限额时, +// 不调用 BulkInsertInitial(不产生全空的占位行)。 +func TestSnapshotPlatformQuotaDefaults_AllUnlimitedSkipsInsert(t *testing.T) { + fakeRepo := &fakeInsertRecorder{} + s := &AuthService{userPlatformQuotaRepo: fakeRepo} + + plan := &signupGrantPlan{ + PlatformQuotas: map[string]*DefaultPlatformQuotaSetting{ + "anthropic": {}, + "openai": {}, + "gemini": nil, + }, + } + if err := s.snapshotPlatformQuotaDefaults(context.Background(), 999, plan); err != nil { + t.Fatal(err) + } + if fakeRepo.calls != 0 { + t.Fatalf("BulkInsertInitial must not be called when no platform has a configured limit, got %d calls", fakeRepo.calls) + } + if len(fakeRepo.records) != 0 { + t.Errorf("expected no records, got %d", len(fakeRepo.records)) } } diff --git a/backend/internal/service/auth_service_register_test.go b/backend/internal/service/auth_service_register_test.go index c29fdac0c3..aa7519f3d1 100644 --- a/backend/internal/service/auth_service_register_test.go +++ b/backend/internal/service/auth_service_register_test.go @@ -298,19 +298,29 @@ func TestAuthService_Register_SnapshotsPlatformQuotaDefaults(t *testing.T) { require.Len(t, quotaRepo.bulkInsertCalls, 1) records := quotaRepo.bulkInsertCalls[0] - var openaiRecord *UserPlatformQuotaRecord - for i := range records { - if records[i].Platform == "openai" { - openaiRecord = &records[i] - break - } - } - require.NotNil(t, openaiRecord, "expected openai platform record") + require.Len(t, records, 1, "only platforms with a configured limit get a row") + openaiRecord := records[0] + require.Equal(t, "openai", openaiRecord.Platform) require.Equal(t, int64(77), openaiRecord.UserID) require.NotNil(t, openaiRecord.WeeklyLimitUSD) require.InDelta(t, 12.34, *openaiRecord.WeeklyLimitUSD, 0.0001) } +func TestAuthService_Register_NoDefaultQuotasSkipsSnapshot(t *testing.T) { + repo := &userRepoStub{nextID: 78} + quotaRepo := &userPlatformQuotaRepoStub{} + + service := newAuthService(repo, map[string]string{ + SettingKeyRegistrationEnabled: "true", + }, nil, quotaRepo) + + _, user, err := service.Register(context.Background(), "newuser2@test.com", "password") + require.NoError(t, err) + require.NotNil(t, user) + + require.Empty(t, quotaRepo.bulkInsertCalls, "no configured default limit must not create quota rows") +} + func TestAuthService_Register_DoesNotSnapshotOnDisabled(t *testing.T) { repo := &userRepoStub{} quotaRepo := &userPlatformQuotaRepoStub{} diff --git a/backend/internal/service/billing_cache_service.go b/backend/internal/service/billing_cache_service.go index b1c6e0933e..6ed70a5ba1 100644 --- a/backend/internal/service/billing_cache_service.go +++ b/backend/internal/service/billing_cache_service.go @@ -1139,10 +1139,9 @@ func (s *BillingCacheService) checkUserPlatformQuotaEligibility( // 超时 50ms:覆盖正常路径与可接受抖动;Redis 异常时 hot path 不阻塞超过此值。 // 用 context.Background()+短超时,避免请求 ctx 取消导致刷新丢失。 // 显式 setCancel()(而非 defer):缩短 context 生命周期,避免 defer 延迟到函数返回。 - // 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 无任何 limit」(DB 无行时回填的短 TTL sentinel),跨窗口命中时跳过 refresh: + // refresh 会把短 TTL 误升级为 86400s;其 enforcement(下方 limit!=nil 比较)因 limit 全 nil 永远放行, + // 跳过 refresh 不改变结果。 isSentinel := entry.DailyLimitUSD == nil && entry.WeeklyLimitUSD == nil && entry.MonthlyLimitUSD == nil if windowExpired && s.cache != nil && !isSentinel { refreshed := &UserPlatformQuotaCacheEntry{ diff --git a/backend/internal/service/setting_service.go b/backend/internal/service/setting_service.go index 06a1b19f1a..369d255294 100644 --- a/backend/internal/service/setting_service.go +++ b/backend/internal/service/setting_service.go @@ -162,6 +162,11 @@ type DefaultPlatformQuotaSetting struct { MonthlyLimitUSD *float64 `json:"monthly"` } +// HasAnyLimit 报告是否至少配置了一档限额(0 也算配置)。nil receiver 视为未配置。 +func (q *DefaultPlatformQuotaSetting) HasAnyLimit() bool { + return q != nil && (q.DailyLimitUSD != nil || q.WeeklyLimitUSD != nil || q.MonthlyLimitUSD != nil) +} + type ProviderDefaultGrantSettings struct { Balance float64 Concurrency int diff --git a/backend/internal/service/user_platform_quota_flusher.go b/backend/internal/service/user_platform_quota_flusher.go index 3ee23d2c93..c26b91bc9c 100644 --- a/backend/internal/service/user_platform_quota_flusher.go +++ b/backend/internal/service/user_platform_quota_flusher.go @@ -180,7 +180,8 @@ func (s *UserPlatformQuotaUsageFlusher) flushOneBatch(parentCtx context.Context) // 读到旧 usage 快照(此刻 member 已离开脏集),而 admin 随后写 DB、本行 UPSERT 又在 admin 写之后落库, // 则旧快照会覆盖 admin 刚写入的值;DeleteCache 后 Redis MISS,下次 preflight 从 DB 重载被覆盖的旧值。 // 因 member 已被 SPOP,admin 侧 SREM/清脏标记无法拦截本批(故未做)。影响有限,暂列为已知取舍: - // - UpsertForUser 改 limit,而本 UPSERT 不写 limit 列 → limit 配置不受影响; + // - UpsertForUser 改 limit,而本 UPDATE 只写既有活跃行的 usage/window 列 → limit 配置不受影响, + // 被软删的行不会被本 UPDATE 复活或重建; // - ResetExpiredWindow 改 usage,但 preflight windowExpired 会在窗口真正过期时自愈重置, // 仅"强制重置未过期窗口"且与本批精确交错时短暂失效; // - 低频 admin 操作 + 默认 flusher_enabled=false。彻底消除需 version OCC(DB 加 version 列条件 UPSERT), @@ -193,11 +194,9 @@ func (s *UserPlatformQuotaUsageFlusher) flushOneBatch(parentCtx context.Context) 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 + // FK 违反表示对应用户已不存在,快照无处可落:整批丢弃、不 Readd。 + // 同批其它 key 会在下次请求重新 SADD,flusher 读 Redis 当前累计绝对值刷库即自愈 + // (Redis 仍是 enforcement 权威;DB 仅展示)。 s.metrics.FlushFKViolationTotal.Add(1) s.metrics.FlushErrorTotal.Add(1) logger.LegacyPrintf("quota_flusher", "[QuotaFlusher] FK violation (dropped %d snaps): %v", len(snaps), writeErr) diff --git a/backend/internal/service/user_platform_quota_port.go b/backend/internal/service/user_platform_quota_port.go index 0f88eda4c3..828e7a9636 100644 --- a/backend/internal/service/user_platform_quota_port.go +++ b/backend/internal/service/user_platform_quota_port.go @@ -11,8 +11,8 @@ import ( // handler 只需引用 service 包,无需直接依赖 repository 包。 var ErrUserPlatformQuotaNotFound = errors.New("user platform quota not found") -// ErrUserPlatformQuotaFKViolation service 层 sentinel:批量 snapshot UPSERT 时存在 -// user_id 不在 users 表的记录(外键违反)。adapter 负责将 repository 层同名 sentinel 包装为此错误。 +// ErrUserPlatformQuotaFKViolation service 层 sentinel:批量快照写入命中了 user_id 不在 users 表的记录。 +// adapter 负责将 repository 层同名 sentinel 包装为此错误。 var ErrUserPlatformQuotaFKViolation = errors.New("user platform quota snapshot FK violation") // UserPlatformQuotaSnapshot 是 service 层 flusher 向 DB 写入快照时使用的传输结构。 @@ -44,26 +44,33 @@ type UserPlatformQuotaRecord struct { MonthlyWindowStart *time.Time } +// HasAnyLimit 报告该记录是否至少配置了一档限额(0 也算配置)。 +// 三档全空的记录视为未配置:不写入 user_platform_quotas,等价于不限额。 +func (r UserPlatformQuotaRecord) HasAnyLimit() bool { + return r.DailyLimitUSD != nil || r.WeeklyLimitUSD != nil || r.MonthlyLimitUSD != nil +} + // UserPlatformQuotaRepository 定义 service 层所需的 user × platform quota 数据访问端口。 // repository 包的 userPlatformQuotaRepository 实现此接口。 type UserPlatformQuotaRepository interface { // GetByUserPlatform 查询单条配额记录,未找到时返回 (nil, nil)。 GetByUserPlatform(ctx context.Context, userID int64, platform string) (*UserPlatformQuotaRecord, error) - // BulkInsertInitial 幂等批量插入初始配额记录(ON CONFLICT DO NOTHING)。 + // BulkInsertInitial 幂等批量插入初始配额记录:冲突时只补既有行为 NULL 的档位;三档全空的记录跳过。 BulkInsertInitial(ctx context.Context, records []UserPlatformQuotaRecord) error - // IncrementUsageWithReset 原子地累加用量,若窗口已过期则先重置再累加。 + // IncrementUsageWithReset 原子地累加用量,若窗口已过期则先重置再累加;记录不存在时不建行、直接返回 nil。 IncrementUsageWithReset(ctx context.Context, userID int64, platform string, cost float64, now time.Time) error // ListByUser 查询用户的所有平台配额记录。 ListByUser(ctx context.Context, userID int64) ([]UserPlatformQuotaRecord, error) // UpsertForUser 全量替换该用户所有平台限额配置(事务内): - // 1. 软删除未在 records 中出现的所有 active 行 - // 2. 对 records 中每条:UPDATE 已存在的(含重新激活软删行);UPDATE 未命中时 INSERT - // 仅改 *_limit_usd + deleted_at + updated_at,保留 *_usage_usd / *_window_start。 + // 1. 软删除未在 records 中出现、或在 records 中但三档全空的所有 active 行 + // 2. 对每条至少配置了一档限额的 record:UPDATE 已存在的 active 行;未命中时 INSERT + // 仅改 *_limit_usd + updated_at,保留 *_usage_usd / *_window_start。 + // 清空某平台的限额即软删该行并放弃其累计用量;之后再次配置限额会新建行、从新窗口起算。 // records 为空时仅执行步骤 1。 UpsertForUser(ctx context.Context, userID int64, records []UserPlatformQuotaRecord) error // 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 绝对值覆盖写入整批 usage 快照到既有活跃行;行不存在或已软删的 (user,platform) 跳过。 BatchSnapshotUsage(ctx context.Context, snapshots []UserPlatformQuotaSnapshot, now time.Time) error } diff --git a/backend/migrations/238_purge_unlimited_user_platform_quotas.sql b/backend/migrations/238_purge_unlimited_user_platform_quotas.sql new file mode 100644 index 0000000000..34979b0de3 --- /dev/null +++ b/backend/migrations/238_purge_unlimited_user_platform_quotas.sql @@ -0,0 +1,10 @@ +-- user_platform_quotas 仅保存至少配置了一档限额的记录;不存在的行等价于不限额。 +-- 三档限额全为 NULL 的行不携带任何可执行的限额,也不参与任何读取,直接删除。 +-- 幂等:重复执行影响 0 行。 +-- 行数约为历史用户数 × 平台数,整条语句在一个事务内执行;超大表需按 +-- SETUP_MIGRATION_TIMEOUT_SECONDS 调高迁移超时,或在升级前预先分批清理。 + +DELETE FROM user_platform_quotas + WHERE daily_limit_usd IS NULL + AND weekly_limit_usd IS NULL + AND monthly_limit_usd IS NULL; diff --git a/backend/migrations/user_platform_quota_purge_unlimited_migration_test.go b/backend/migrations/user_platform_quota_purge_unlimited_migration_test.go new file mode 100644 index 0000000000..fdc5e65df8 --- /dev/null +++ b/backend/migrations/user_platform_quota_purge_unlimited_migration_test.go @@ -0,0 +1,27 @@ +package migrations + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/require" +) + +// TestUserPlatformQuotasPurgeUnlimitedMigration 校验 238 号迁移只删除三档限额全为 NULL 的行: +// 这类行等价于"不存在",任何一档非 NULL 的记录(含软删历史)都必须保留。 +func TestUserPlatformQuotasPurgeUnlimitedMigration(t *testing.T) { + content, err := FS.ReadFile("238_purge_unlimited_user_platform_quotas.sql") + require.NoError(t, err) + + // 去掉注释行后只允许这一条 DELETE。 + var stmts []string + for _, line := range strings.Split(string(content), "\n") { + trimmed := strings.TrimSpace(line) + if trimmed == "" || strings.HasPrefix(trimmed, "--") { + continue + } + stmts = append(stmts, trimmed) + } + sql := strings.Join(stmts, " ") + require.Equal(t, "DELETE FROM user_platform_quotas WHERE daily_limit_usd IS NULL AND weekly_limit_usd IS NULL AND monthly_limit_usd IS NULL;", sql) +} diff --git a/frontend/src/components/admin/user/UserPlatformQuotaModal.vue b/frontend/src/components/admin/user/UserPlatformQuotaModal.vue index 7121b8e02d..536f9a8424 100644 --- a/frontend/src/components/admin/user/UserPlatformQuotaModal.vue +++ b/frontend/src/components/admin/user/UserPlatformQuotaModal.vue @@ -43,8 +43,8 @@ @@ -62,8 +62,8 @@ @@ -81,8 +81,8 @@ @@ -148,6 +148,18 @@ const loading = ref(false) const submitting = ref(false) const resetting = reactive>({}) const quotas = ref([]) +// 已保存且至少配置了一档限额的平台。只有这些平台在后端有配额记录,重置用量窗口才有对象。 +const savedConfigured = ref>(new Set()) + +function configuredPlatforms(items: PlatformQuotaItem[]): Set { + const out = new Set() + for (const it of items) { + if (it.daily_limit_usd != null || it.weekly_limit_usd != null || it.monthly_limit_usd != null) { + out.add(it.platform) + } + } + return out +} function emptyRow(p: PlatformQuotaPlatform): QuotaRow { return { @@ -190,9 +202,11 @@ async function load() { try { const data = await adminAPI.users.getPlatformQuotas(props.user.id) quotas.value = normalize(data.platform_quotas || []) + savedConfigured.value = configuredPlatforms(data.platform_quotas || []) } catch { appStore.showError(t('admin.users.platformQuota.loadFailed')) quotas.value = PLATFORMS.map(emptyRow) + savedConfigured.value = new Set() } finally { loading.value = false } @@ -273,6 +287,7 @@ async function onReset(platform: PlatformQuotaPlatform, quotaWindow: PlatformQuo try { const data = await adminAPI.users.resetPlatformQuotaWindow(props.user.id, platform, quotaWindow) quotas.value = normalize(data.platform_quotas || []) + savedConfigured.value = configuredPlatforms(data.platform_quotas || []) appStore.showSuccess(t('admin.users.platformQuota.reset.success', { platform, window: windowLabel })) } catch (e: any) { appStore.showError(e?.response?.data?.message || t('admin.users.platformQuota.reset.failed')) diff --git a/frontend/src/components/admin/user/__tests__/UserPlatformQuotaModal.spec.ts b/frontend/src/components/admin/user/__tests__/UserPlatformQuotaModal.spec.ts index 4315293907..d582126294 100644 --- a/frontend/src/components/admin/user/__tests__/UserPlatformQuotaModal.spec.ts +++ b/frontend/src/components/admin/user/__tests__/UserPlatformQuotaModal.spec.ts @@ -168,7 +168,42 @@ describe('UserPlatformQuotaModal', () => { confirmSpy.mockRestore() }) + // 只有已保存限额的平台在后端有配额记录,重置按钮才可用 + const anthropicConfigured = { + platform_quotas: [ + { + platform: 'anthropic', + daily_limit_usd: 10, + weekly_limit_usd: null, + monthly_limit_usd: null, + daily_usage_usd: 0, + weekly_usage_usd: 0, + monthly_usage_usd: 0, + }, + ], + } + + it('未配置限额的平台重置按钮禁用并提示不可用', async () => { + const w = await mountAndOpen() + const resetBtns = w.findAll('button').filter((b) => b.text() === '↻') + expect(resetBtns.length).toBe(15) // 5 平台 × 3 窗口 + for (const b of resetBtns) { + expect((b.element as HTMLButtonElement).disabled).toBe(true) + expect(b.attributes('title')).toBe('admin.users.platformQuota.reset.unavailable') + } + }) + + it('已保存限额的平台重置按钮可用,其余仍禁用', async () => { + apiMocks.getPlatformQuotas.mockResolvedValue(anthropicConfigured) + const w = await mountAndOpen() + const resetBtns = w.findAll('button').filter((b) => b.text() === '↻') + const enabled = resetBtns.filter((b) => !(b.element as HTMLButtonElement).disabled) + expect(enabled.length).toBe(3) // anthropic 的 daily/weekly/monthly + expect(enabled[0].attributes('title')).toBe('admin.users.platformQuota.reset.button') + }) + it('重置按钮 confirm 取消则不调用 API', async () => { + apiMocks.getPlatformQuotas.mockResolvedValue(anthropicConfigured) const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(false) const w = await mountAndOpen() const resetBtns = w.findAll('button').filter((b) => b.text() === '↻') @@ -180,6 +215,7 @@ describe('UserPlatformQuotaModal', () => { }) it('重置按钮 confirm 确认则调用 API', async () => { + apiMocks.getPlatformQuotas.mockResolvedValue(anthropicConfigured) const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(true) const w = await mountAndOpen() const resetBtns = w.findAll('button').filter((b) => b.text() === '↻') diff --git a/frontend/src/components/user/dashboard/UserDashboardStats.vue b/frontend/src/components/user/dashboard/UserDashboardStats.vue index ad9fae1ff4..293172bf8f 100644 --- a/frontend/src/components/user/dashboard/UserDashboardStats.vue +++ b/frontend/src/components/user/dashboard/UserDashboardStats.vue @@ -137,13 +137,15 @@

{{ t('dashboard.platformBreakdown') }}

- {{ t('dashboard.platformCount', { count: sortedPlatforms.length }) }} + {{ t('dashboard.platformCount', { count: platformCount }) }}
(() => { // 建立 by_platform Map - const byPlat = new Map() + const byPlat = new Map() for (const item of props.stats?.by_platform ?? []) byPlat.set(item.platform, item) - // 建立 quota Map + // 建立 quota Map。三档全空的记录不产生卡片,挂到卡片上也不渲染配额区。 const byQuota = new Map() for (const q of props.platformQuotas ?? []) byQuota.set(q.platform, q) - // union 平台集合。后端 by_platform / quota 接口均不会返回 platform='__other__', + // 卡片集合 = 有用量的平台 ∪ 至少配置了一档限额的平台。 + // 三档全空的限额记录等价于不限额,不单独产生卡片。 + // 后端 by_platform / quota 接口均不会返回 platform='__other__', // 无需显式排除;__other__ 由下方差值补差逻辑单独追加。 - const platforms = new Set([...byPlat.keys(), ...byQuota.keys()]) + const platforms = new Set(byPlat.keys()) + for (const [platform, q] of byQuota) { + if (hasAnyLimit(q)) platforms.add(platform) + } const PLATFORM_ORDER = ['anthropic', 'openai', 'gemini', 'antigravity', 'grok'] const cards: FusedPlatformCard[] = [] @@ -330,6 +332,9 @@ const platformCards = computed(() => { return cards }) +// 标题右侧的平台计数 = 实际渲染的平台卡片数,不含"其他"差额卡。 +const platformCount = computed(() => platformCards.value.filter((c) => !c.isOther).length) + // Quota helpers type QuotaWindow = 'daily' | 'weekly' | 'monthly' diff --git a/frontend/src/components/user/dashboard/__tests__/UserDashboardStats.spec.ts b/frontend/src/components/user/dashboard/__tests__/UserDashboardStats.spec.ts new file mode 100644 index 0000000000..a2ab9ed5cf --- /dev/null +++ b/frontend/src/components/user/dashboard/__tests__/UserDashboardStats.spec.ts @@ -0,0 +1,154 @@ +import { describe, it, expect, vi } from 'vitest' +import { mount, type VueWrapper } from '@vue/test-utils' + +// t() 回显 key;带参数时附上 JSON,便于断言计数 +vi.mock('vue-i18n', async () => { + const actual = await vi.importActual('vue-i18n') + return { + ...actual, + useI18n: () => ({ + t: (key: string, params?: Record) => + params ? `${key}:${JSON.stringify(params)}` : key, + }), + } +}) + +import UserDashboardStats from '../UserDashboardStats.vue' +import type { UserDashboardStats as UserStatsType, PlatformDashboardStats } from '@/api/usage' +import type { PlatformQuotaItem } from '@/types' + +function makeStats(over: Partial = {}): UserStatsType { + return { + total_api_keys: 1, + active_api_keys: 1, + total_requests: 0, + total_input_tokens: 0, + total_output_tokens: 0, + total_cache_creation_tokens: 0, + total_cache_read_tokens: 0, + total_tokens: 0, + total_cost: 0, + total_actual_cost: 0, + today_requests: 0, + today_input_tokens: 0, + today_output_tokens: 0, + today_cache_creation_tokens: 0, + today_cache_read_tokens: 0, + today_tokens: 0, + today_cost: 0, + today_actual_cost: 0, + average_duration_ms: 0, + rpm: 0, + tpm: 0, + by_platform: [], + ...over, + } +} + +function usage(platform: string, cost: number): PlatformDashboardStats { + return { + platform, + total_requests: 1, + total_tokens: 10, + total_actual_cost: cost, + today_requests: 1, + today_tokens: 10, + today_actual_cost: cost, + } +} + +function quota(over: Partial & { platform: string }): PlatformQuotaItem { + return { + daily_limit_usd: null, + weekly_limit_usd: null, + monthly_limit_usd: null, + daily_usage_usd: 0, + weekly_usage_usd: 0, + monthly_usage_usd: 0, + ...over, + } as PlatformQuotaItem +} + +function mountStats(stats: UserStatsType, platformQuotas: PlatformQuotaItem[] | null = null, isSimple = false) { + return mount(UserDashboardStats, { + props: { stats, balance: 0, isSimple, platformQuotas }, + global: { stubs: { Icon: true } }, + }) +} + +/** 渲染出的平台卡片,按 DOM 顺序返回 data-platform */ +function cardPlatforms(w: VueWrapper): string[] { + return w.findAll('[data-testid="platform-card"]').map((c) => c.attributes('data-platform') ?? '') +} + +describe('UserDashboardStats 按平台拆分', () => { + it('只有用量的平台才产生卡片;三档全空的限额记录不产生卡片', () => { + const w = mountStats( + makeStats({ total_actual_cost: 0.03, today_actual_cost: 0.03, by_platform: [usage('grok', 0.03)] }), + [ + quota({ platform: 'anthropic' }), + quota({ platform: 'openai' }), + quota({ platform: 'gemini' }), + quota({ platform: 'grok' }), + ] + ) + expect(cardPlatforms(w)).toEqual(['grok']) + expect(w.text()).toContain('dashboard.platformCount:{"count":1}') + expect(w.html()).not.toContain('dashboard.platformQuota.title') + }) + + it('配置了限额但没有用量的平台也产生卡片,并渲染配额区', () => { + const w = mountStats( + makeStats({ total_actual_cost: 0.03, today_actual_cost: 0.03, by_platform: [usage('grok', 0.03)] }), + [quota({ platform: 'openai', daily_limit_usd: 10, daily_usage_usd: 2.5 }), quota({ platform: 'anthropic' })] + ) + // 固定顺序:openai 排在 grok 前 + expect(cardPlatforms(w)).toEqual(['openai', 'grok']) + expect(w.text()).toContain('dashboard.platformQuota.title') + expect(w.text()).toContain('dashboard.platformCount:{"count":2}') + }) + + it('同一平台既有用量又有限额只产生一张卡片', () => { + const w = mountStats( + makeStats({ total_actual_cost: 1, today_actual_cost: 1, by_platform: [usage('openai', 1)] }), + [quota({ platform: 'openai', daily_limit_usd: 10, daily_usage_usd: 1 })] + ) + expect(cardPlatforms(w)).toEqual(['openai']) + expect(w.text()).toContain('dashboard.platformQuota.title') + }) + + it('限额为 0 的平台视为已配置,渲染禁用态', () => { + const w = mountStats(makeStats(), [quota({ platform: 'gemini', weekly_limit_usd: 0 })]) + expect(cardPlatforms(w)).toEqual(['gemini']) + expect(w.text()).toContain('dashboard.platformQuota.disabled') + expect(w.text()).toContain('dashboard.platformCount:{"count":1}') + }) + + it('固定顺序之外的平台也产生卡片,并排在固定顺序之后', () => { + const w = mountStats( + makeStats({ total_actual_cost: 0.5, today_actual_cost: 0, by_platform: [usage('kimi', 0.3), usage('anthropic', 0.2)] }) + ) + expect(cardPlatforms(w)).toEqual(['anthropic', 'kimi']) + expect(w.text()).toContain('Kimi') + }) + + it('总值大于各平台之和时追加"其他"卡片,且不计入平台计数', () => { + const w = mountStats( + makeStats({ total_actual_cost: 1.0, today_actual_cost: 0, by_platform: [usage('anthropic', 0.4)] }) + ) + expect(cardPlatforms(w)).toEqual(['anthropic', '__other__']) + expect(w.text()).toContain('dashboard.platformOther') + expect(w.text()).toContain('dashboard.platformCount:{"count":1}') + }) + + it('没有任何用量也没有配置限额时不渲染整块', () => { + const w = mountStats(makeStats(), [quota({ platform: 'anthropic' }), quota({ platform: 'openai' })]) + expect(w.html()).not.toContain('dashboard.platformBreakdown') + expect(cardPlatforms(w)).toEqual([]) + }) + + it('简易模式不渲染整块', () => { + const w = mountStats(makeStats({ by_platform: [usage('openai', 1)] }), null, true) + expect(w.html()).not.toContain('dashboard.platformBreakdown') + }) +}) diff --git a/frontend/src/i18n/locales/en/admin/overview.ts b/frontend/src/i18n/locales/en/admin/overview.ts index 18e9318abe..f81b1ced93 100644 --- a/frontend/src/i18n/locales/en/admin/overview.ts +++ b/frontend/src/i18n/locales/en/admin/overview.ts @@ -758,6 +758,7 @@ export default { clearAllConfirm: 'Clear daily / weekly / monthly limits for ALL platforms? All platforms will become "unlimited" with no local undo — you must manually re-enter values before saving.', reset: { button: 'Reset window', + unavailable: 'No limit configured for this platform, so there is no usage window to reset', confirm: 'Reset the {window} usage for {platform} for this user? This is effective immediately.', success: 'Reset {platform} {window} usage', failed: 'Reset failed', diff --git a/frontend/src/i18n/locales/en/admin/settings.ts b/frontend/src/i18n/locales/en/admin/settings.ts index da509a8251..534f79d617 100644 --- a/frontend/src/i18n/locales/en/admin/settings.ts +++ b/frontend/src/i18n/locales/en/admin/settings.ts @@ -407,7 +407,7 @@ export default { subscriptionGroup: 'Subscription Group', subscriptionValidityDays: 'Validity (days)', defaultPlatformQuotas: 'Default Platform Quotas (on signup)', - defaultPlatformQuotasHint: 'Automatically assigned to new users on signup; existing users are not affected. Leave blank = unlimited.', + defaultPlatformQuotasHint: 'Applied to new users on signup; existing users are not affected. Leave blank = no limit for that platform and window.', platformQuotaNotice: 'Monthly quota uses a 30-day rolling window, not a calendar month.', }, platformQuota: { diff --git a/frontend/src/i18n/locales/zh/admin/overview.ts b/frontend/src/i18n/locales/zh/admin/overview.ts index 8eca58fe35..d7ba0942f6 100644 --- a/frontend/src/i18n/locales/zh/admin/overview.ts +++ b/frontend/src/i18n/locales/zh/admin/overview.ts @@ -755,6 +755,7 @@ export default { clearAllConfirm: '确认清空全部平台的日 / 周 / 月限额?所有平台将变为"无限额",本地无法撤销,需要在保存前手动重填。', reset: { button: '重置该窗口', + unavailable: '该平台未配置限额,没有可重置的用量窗口', confirm: '确认重置该用户 {platform} 平台的 {window} 用量?此操作立即生效。', success: '已重置 {platform} {window} 用量', failed: '重置失败', diff --git a/frontend/src/i18n/locales/zh/admin/settings.ts b/frontend/src/i18n/locales/zh/admin/settings.ts index 5bdedb0104..fd2530daba 100644 --- a/frontend/src/i18n/locales/zh/admin/settings.ts +++ b/frontend/src/i18n/locales/zh/admin/settings.ts @@ -402,7 +402,7 @@ export default { subscriptionGroup: '订阅分组', subscriptionValidityDays: '有效期(天)', defaultPlatformQuotas: '默认平台限额(注册时分配)', - defaultPlatformQuotasHint: '新用户注册时自动写入平台限额记录;已有用户不受影响。留空 = 该平台该窗口不限制。', + defaultPlatformQuotasHint: '新用户注册时自动获得这里配置的限额;已有用户不受影响。留空 = 该平台该窗口不限制。', platformQuotaNotice: '月限额为 30 天滚动窗口,非自然月', }, platformQuota: {