DaydreamCoding 6b39b344d8 feat(quota): 用户 × 平台 USD 配额
为用户在 anthropic/openai/gemini/antigravity 四个平台上提供日/周/月
三个窗口的 USD 配额管控。配额语义:未设置=不限制,0=禁用,>0=美元上限。

两层模型:
- 配置层:系统默认配额,以及 email/linuxdo/oidc/wechat/github/google/
  dingtalk 七个鉴权来源的默认配额,存于 settings,以嵌套 JSON 整体读写
  (系统 1 个 key + 每个来源 1 个 key),整体替换语义。
- 运行时层:user_platform_quota 表按用户记录实际配额,与配置层解耦。

后端:新增 ent schema 与 140_user_platform_quotas.sql 迁移、repository
与 service 端口、计费链路集成、管理端与用户端读写接口。
前端:管理端设置页配额编辑、用户配额管理 Modal、用户 Dashboard 展示、
中英文案。

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 10:49:20 +08:00

134 lines
4.9 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package quotaview
import (
"testing"
"time"
"github.com/Wei-Shaw/sub2api/internal/pkg/timezone"
"github.com/Wei-Shaw/sub2api/internal/service"
)
// TestNextMonthlyResetTimeFrom_FromStart 验证start 已知时返回 start+30d不随 now 漂移。
func TestNextMonthlyResetTimeFrom_FromStart(t *testing.T) {
t0 := time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC)
now := t0.Add(15 * 24 * time.Hour) // t0 + 15d
want := t0.Add(30 * 24 * time.Hour) // t0 + 30d
got := NextMonthlyResetTimeFrom(&t0, now)
if !got.Equal(want) {
t.Errorf("NextMonthlyResetTimeFrom: want %v, got %v", want, got)
}
}
// TestNextMonthlyResetTimeFrom_NilStart 验证start=nil 时退化为 now+30d不 panic
func TestNextMonthlyResetTimeFrom_NilStart(t *testing.T) {
now := time.Date(2024, 3, 15, 12, 0, 0, 0, time.UTC)
want := now.Add(30 * 24 * time.Hour)
got := NextMonthlyResetTimeFrom(nil, now)
if !got.Equal(want) {
t.Errorf("NextMonthlyResetTimeFrom(nil): want %v, got %v", want, got)
}
}
// TestLazyZeroQuotaForResponse_MonthlyResetsAt_NotDrifting 验证:
// 连续两次以不同 now 调用、但 MonthlyWindowStart 相同的 record
// monthly_window_resets_at 始终等于 windowStart+30d不随 now 漂移。
func TestLazyZeroQuotaForResponse_MonthlyResetsAt_NotDrifting(t *testing.T) {
windowStart := time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC)
wantResetsAt := windowStart.Add(30 * 24 * time.Hour).Format(time.RFC3339)
r := service.UserPlatformQuotaRecord{
Platform: "openai",
MonthlyUsageUSD: 5.0,
MonthlyWindowStart: &windowStart,
}
// 第一次调用now = windowStart + 5d
now1 := windowStart.Add(5 * 24 * time.Hour)
out1 := LazyZeroQuotaForResponse(r, now1, false)
resetsAt1, ok1 := out1["monthly_window_resets_at"]
if !ok1 || resetsAt1 == nil {
t.Fatal("first call: monthly_window_resets_at should be set for active window")
}
s1, ok := resetsAt1.(*string)
if !ok || s1 == nil {
t.Fatalf("first call: monthly_window_resets_at should be *string, got %T", resetsAt1)
}
if *s1 != wantResetsAt {
t.Errorf("first call: want %s, got %s", wantResetsAt, *s1)
}
// 第二次调用now = windowStart + 10d不同 now但 resetsAt 应不变)
now2 := windowStart.Add(10 * 24 * time.Hour)
out2 := LazyZeroQuotaForResponse(r, now2, false)
resetsAt2, ok2 := out2["monthly_window_resets_at"]
if !ok2 || resetsAt2 == nil {
t.Fatal("second call: monthly_window_resets_at should be set for active window")
}
s2, ok := resetsAt2.(*string)
if !ok || s2 == nil {
t.Fatalf("second call: monthly_window_resets_at should be *string, got %T", resetsAt2)
}
if *s2 != wantResetsAt {
t.Errorf("second call: want %s, got %s", wantResetsAt, *s2)
}
// 两次结果必须相等
if *s1 != *s2 {
t.Errorf("resetsAt drifted between calls: %s vs %s", *s1, *s2)
}
}
// TestNeedsDailyReset_FollowsServerTimezone 验证日窗口过期判断按全局时区(北京 0 点)而非 UTC。
func TestNeedsDailyReset_FollowsServerTimezone(t *testing.T) {
if err := timezone.Init("Asia/Shanghai"); err != nil {
t.Fatalf("Init: %v", err)
}
t.Cleanup(func() { _ = timezone.Init("UTC") })
// now = 2026-05-25 23:00 UTC = 2026-05-26 07:00 +08北京 5/26
now := time.Date(2026, 5, 25, 23, 0, 0, 0, time.UTC)
// start = 2026-05-25 10:00 UTC = 2026-05-25 18:00 +08北京 5/25→ 应判定为过期
startPrevBeijingDay := time.Date(2026, 5, 25, 10, 0, 0, 0, time.UTC)
if !NeedsDailyReset(&startPrevBeijingDay, now) {
t.Error("上一个北京日的窗口应判定为过期")
}
// start = 2026-05-25 20:00 UTC = 2026-05-26 04:00 +08北京 5/26 同日)→ 不应过期
startSameBeijingDay := time.Date(2026, 5, 25, 20, 0, 0, 0, time.UTC)
if NeedsDailyReset(&startSameBeijingDay, now) {
t.Error("同一北京日的窗口不应判定为过期")
}
}
// TestNextDailyResetTime_FollowsServerTimezone 验证下次日重置 = 次日北京 0 点。
func TestNextDailyResetTime_FollowsServerTimezone(t *testing.T) {
if err := timezone.Init("Asia/Shanghai"); err != nil {
t.Fatalf("Init: %v", err)
}
t.Cleanup(func() { _ = timezone.Init("UTC") })
now := time.Date(2026, 5, 25, 23, 0, 0, 0, time.UTC) // 北京 5/26 07:00
want := time.Date(2026, 5, 27, 0, 0, 0, 0, timezone.Location()) // 北京 5/27 00:00
if got := nextDailyResetTime(now); !got.Equal(want) {
t.Errorf("nextDailyResetTime = %v, want %v", got, want)
}
}
// TestNextWeeklyResetTime_FollowsServerTimezone 验证下次周重置 = 下周一北京 0 点。
func TestNextWeeklyResetTime_FollowsServerTimezone(t *testing.T) {
if err := timezone.Init("Asia/Shanghai"); err != nil {
t.Fatalf("Init: %v", err)
}
t.Cleanup(func() { _ = timezone.Init("UTC") })
// 北京 2026-05-26周二→ 下周一是 2026-06-01
now := time.Date(2026, 5, 25, 23, 0, 0, 0, time.UTC) // 北京 5/26 07:00 周二
want := time.Date(2026, 6, 1, 0, 0, 0, 0, timezone.Location())
if got := nextWeeklyResetTime(now); !got.Equal(want) {
t.Errorf("nextWeeklyResetTime = %v, want %v", got, want)
}
}