Files
gpt-load/internal/subscription/passive_quota_source_flush_test.go
tbphp 4a99ab1496 fix(subscription): 修复被动额度契约缺失与来源误归属 (#575)
* fix(ui): 接受订阅额度窗口的 source_id 字段

修复 #574 新增 source_id 后前端严格投影拒绝成功响应的问题。补齐可选 DTO 字段、白名单及字符串投影,保持其他未知字段校验,不修改后端额度匹配或限流行为。

新增前后端额度字段契约回归测试,避免后端新增字段却遗漏前端白名单和 DTO。

* chore(ui): 移除额度字段修复中的跨语言契约测试

* fix(subscription): 按 Active-Limit 判定被动额度通用组的归属

上游的 X-Codex-Primary/Secondary-* 报告的是本次请求实际计费到的额度,
而不是固定的普通账号额度。请求 Spark 等专属额度时这一组装的是该额度的
副本,按 codex 收下会用它覆盖同周期的普通窗口,把用尽的 7d 刷成满额。

改为由 X-Codex-Active-Limit 决定该组归属:premium/codex 归普通额度,
指向专属额度时改绑到该来源,上游已单独报告该来源则让位避免歧义。
缺少该头且响应里已有别的来源时按副本丢弃。

* fix(subscription): 按窗口去重被动额度副本而非丢弃整组

Active-Limit 指向专属额度时,通用组只有与独立命名空间来源和周期都相同
的窗口才是重复报告。原先只要该来源存在任意窗口就丢掉整组,会漏掉另一
个周期的刷新。改为解析后按来源加周期逐窗口去重。

* fix(subscription): 限定被动额度去重只比较独立命名空间

通用组内部的 primary 与 secondary 是两份数据而非互为副本,周期相同时
原实现让它们互相判定为重复并双双丢弃。去重范围收窄为通用窗口与独立
命名空间窗口之间。
2026-09-06 11:55:18 +08:00

68 lines
2.8 KiB
Go

package subscription
import (
"encoding/json"
"testing"
"time"
"gpt-load/internal/storage/models"
"gpt-load/internal/subscription/providers/codex"
providerobservation "gpt-load/internal/subscription/providers/observation"
)
func TestFlushPassiveQuotaSourcesPreservesCardWindowsAndHealth(t *testing.T) {
manager, db, registry, _, row := newCredentialManagerFixture(t,
credentialJSON("access", "refresh", time.Now().Add(time.Hour)))
raw, err := codex.NormalizeQuota([]byte(codexSourceQuotaPayload), nil)
if err != nil {
t.Fatal(err)
}
initial := newFlushableCredentialObservation(t, manager, row.ID, models.CredentialObservationFresh, string(raw))
ref, ok := registry.CredentialRef(row.ID)
if !ok {
t.Fatal("credential ref is unavailable")
}
observedAt := time.UnixMilli(2000)
patches := codex.NormalizePassiveQuotaWindows(map[string]string{
"X-Codex-Active-Limit": "premium",
"X-Codex-Primary-Used-Percent": "0",
"X-Codex-Primary-Window-Minutes": "300",
"X-Codex-Secondary-Used-Percent": "100",
"X-Codex-Secondary-Window-Minutes": "10080",
"X-Codex-Secondary-Reset-At": "1800000000",
"X-Codex-Bengalfox-Primary-Used-Percent": "30",
"X-Codex-Bengalfox-Primary-Window-Minutes": "300",
}, observedAt)
manager.RecordPassiveQuotaObservation(row.ID, ref.IdentityGeneration, observedAt.UnixMilli(), patches)
pending, err := manager.FlushPassiveQuotaObservations(t.Context())
if err != nil || pending || len(manager.DirtyPassiveQuotaObservations(1)) != 0 {
t.Fatalf("flush pending=%v error=%v", pending, err)
}
var stored models.CredentialObservation
if err := db.Take(&stored, "credential_id = ?", row.ID).Error; err != nil {
t.Fatal(err)
}
if stored.ObservedAtMS == nil || *stored.ObservedAtMS != 2000 ||
stored.ObservationVersion != initial.ObservationVersion || stored.State != initial.State {
t.Fatalf("unexpected observation metadata: %#v", stored)
}
var snapshot providerobservation.Snapshot
if err := json.Unmarshal(stored.SnapshotJSON, &snapshot); err != nil {
t.Fatal(err)
}
if len(snapshot.QuotaWindows) != 2 {
t.Fatalf("passive update changed the card's window set: %s", stored.SnapshotJSON)
}
weekly, spark := snapshot.QuotaWindows[0], snapshot.QuotaWindows[1]
if weekly.ID != "primary" || weekly.SourceID != "codex" ||
weekly.WindowSeconds == nil || *weekly.WindowSeconds != 604800 ||
weekly.Used == nil || *weekly.Used != 100 || weekly.State != "exhausted" ||
spark.SourceID != "codex_bengalfox" || spark.Used == nil || *spark.Used != 30 {
t.Fatalf("quota sources were not refreshed independently: %s", stored.SnapshotJSON)
}
views := registry.Snapshot()
if len(views) != 1 || views[0].ObservedQuotaRemaining() == nil || *views[0].ObservedQuotaRemaining() != 0 {
t.Fatalf("Spark or the untracked 5h window polluted account health: %#v", views)
}
}