Compare commits

...
Author SHA1 Message Date
shaw 99b357083e fix(subscription): restore midnight daily quota reset
日额度窗口恢复日历日语义(配置时区每天 0 点刷新),修复 v0.1.170 引入的回归:
- 手动重置配额后日窗口锚点漂移到重置时刻,此后 0 点不再刷新
- 新建/续费订阅日窗口按购买或首用时刻滚动,而非 0 点刷新

日窗口三处锚点(激活/手动重置/续期)统一写入当天 0 点,自动重置改为跨日历日
边界触发;存量非 0 点锚点在下一个 0 点自愈,无需数据库迁移。周/月窗口保持
期限对齐滚动语义(含到期约束)不变,不回归 issue #5051 的月额度翻倍问题。
日卡一次性额度豁免逻辑保留。
2026-08-07 17:42:51 +08:00
Wesley Liddick 32e4de7942 Merge pull request #5231 from fengshao1227/fix/upstream-dial-timeout
fix(upstream): set explicit TCP dial timeout on upstream transports
2026-08-07 16:24:53 +08:00
Wesley Liddick 8f55e0a7cd Merge pull request #5267 from wucm667/fix/issue-5264-model-plaza-composite
fix(model-plaza): show Composite group models
2026-08-07 16:17:52 +08:00
Wesley Liddick 5ec11885d1 Merge pull request #5307 from lenzhang/fix/ws-prewarm-continuation
fix(ws): allow Codex prewarm continuation
2026-08-07 16:15:11 +08:00
Wesley Liddick c8af48d769 Merge pull request #5315 from wucm667/fix/issue-5312-ops-custom-error-range
fix(ops): preserve custom range in error lists
2026-08-07 16:15:00 +08:00
Wesley Liddick dce0f6b4e3 Merge pull request #5337 from wucm667/fix/issue-5331-oauth-count-tokens-fallback
fix: avoid cooling OAuth accounts for HTML count token errors
2026-08-07 16:14:48 +08:00
Wesley Liddick 22ef761c27 Merge pull request #5309 from wucm667/fix/issue-5308-antigravity-gemini36
fix(antigravity): support Gemini 3.6 Flash models
2026-08-07 16:14:37 +08:00
Wesley Liddick 865002fd2c Merge pull request #5317 from wucm667/fix/issue-5314-grok-video-task-id
fix(grok): bind video owners for task_id receipts
2026-08-07 16:14:25 +08:00
Wesley Liddick 93367b6db4 Merge pull request #5351 from Wei-Shaw/fix/codex-tui-default-identity
fix(openai): default OAuth identity to codex-tui
2026-08-07 10:17:00 +08:00
wucm667 e93f6b9953 fix: avoid cooling OAuth accounts for HTML count token errors 2026-08-06 20:31:56 +08:00
wucm667 5b7a64334f fix(grok): support task IDs for video owner binding 2026-08-06 04:30:10 +08:00
wucm667 82ccc187cd fix(ops): preserve custom range in error lists 2026-08-06 02:26:42 +08:00
wucm667 ce14983133 fix(antigravity): support Gemini 3.6 Flash models 2026-08-05 22:35:01 +08:00
必通 fc5a1b78d2 fix(ws): allow Codex prewarm continuation 2026-08-05 21:26:37 +08:00
wucm667 d3b5703b55 fix(model-plaza): show composite group models 2026-08-04 19:14:28 +08:00
li 66ad405dd6 fix(upstream): set explicit TCP dial timeout on upstream transports
http.Transport 未设置 DialContext 时使用零值 net.Dialer(Timeout=0),
DNS 解析与 TCP 握手没有任何上限,只能等内核 TCP 重传耗尽(Linux 约 130 秒)。
ResponseHeaderTimeout 只约束连接建立之后等待响应头的阶段,覆盖不到建连。

上游域名被解析到 443 不可达的 IP 时(DNS 污染 / 路由异常),单个账号就要卡满
内核超时;而多账号故障转移是串行的,一次请求会阻塞数分钟且不写中间错误,
客户端表现为"长时间无首字后直接结束"。

- buildUpstreamTransport 显式设置 DialContext(10s)与 TLSHandshakeTimeout(10s)
- proxyutil SOCKS5 分支的 forward dialer 由 proxy.Direct(零值 dialer,同样无超时)
  换成带 10s 超时的 net.Dialer;该分支会覆盖 Transport.DialContext,
  调用方设置的建连超时对它无效,必须在此补上

Fixes #5151
2026-08-03 19:59:58 +08:00
37 changed files with 756 additions and 85 deletions
+6
View File
@@ -117,6 +117,12 @@ var DefaultAntigravityModelMapping = map[string]string{
"gemini-3.1-flash-image": "gemini-3.1-flash-image",
// Gemini 3.1 image preview 映射
"gemini-3.1-flash-image-preview": "gemini-3.1-flash-image",
// Gemini 3.6 Flash tiered models
"gemini-3.6-flash": "gemini-3.6-flash",
"gemini-3.6-flash-high": "gemini-3.6-flash-high",
"gemini-3.6-flash-low": "gemini-3.6-flash-low",
"gemini-3.6-flash-medium": "gemini-3.6-flash-medium",
"gemini-3.6-flash-tiered": "gemini-3.6-flash-tiered",
// Gemini 3 image 兼容映射(向 3.1 image 迁移)
"gemini-3-pro-image": "gemini-3.1-flash-image",
"gemini-3-pro-image-preview": "gemini-3.1-flash-image",
@@ -65,6 +65,14 @@ func TestDefaultAntigravityModelMapping_Gemini31ProAliases(t *testing.T) {
}
}
func TestDefaultAntigravityModelMapping_Gemini36FlashModels(t *testing.T) {
for _, model := range []string{"gemini-3.6-flash", "gemini-3.6-flash-high", "gemini-3.6-flash-low", "gemini-3.6-flash-medium", "gemini-3.6-flash-tiered"} {
if got := DefaultAntigravityModelMapping[model]; got != model {
t.Fatalf("expected %s to map to itself, got %q", model, got)
}
}
}
func TestDefaultBedrockModelMapping_ContainsNewClaudeModels(t *testing.T) {
t.Parallel()
@@ -178,6 +178,11 @@ var geminiModels = []modelDef{
{ID: "gemini-3.1-pro-high", DisplayName: "Gemini 3.1 Pro High", CreatedAt: "2026-02-19T00:00:00Z", IsReasoning: true},
{ID: "gemini-3.1-flash-image", DisplayName: "Gemini 3.1 Flash Image", CreatedAt: "2026-02-19T00:00:00Z"},
{ID: "gemini-3.1-flash-image-preview", DisplayName: "Gemini 3.1 Flash Image Preview", CreatedAt: "2026-02-19T00:00:00Z"},
{ID: "gemini-3.6-flash", DisplayName: "Gemini 3.6 Flash", CreatedAt: "2026-07-21T00:00:00Z"},
{ID: "gemini-3.6-flash-high", DisplayName: "Gemini 3.6 Flash High", CreatedAt: "2026-07-21T00:00:00Z", IsReasoning: true},
{ID: "gemini-3.6-flash-low", DisplayName: "Gemini 3.6 Flash Low", CreatedAt: "2026-07-21T00:00:00Z", IsReasoning: true},
{ID: "gemini-3.6-flash-medium", DisplayName: "Gemini 3.6 Flash Medium", CreatedAt: "2026-07-21T00:00:00Z", IsReasoning: true},
{ID: "gemini-3.6-flash-tiered", DisplayName: "Gemini 3.6 Flash", CreatedAt: "2026-07-21T00:00:00Z", IsReasoning: true},
{ID: "gemini-3-pro-preview", DisplayName: "Gemini 3 Pro Preview", CreatedAt: "2025-06-01T00:00:00Z", IsReasoning: true},
{ID: "gemini-3-pro-image", DisplayName: "Gemini 3 Pro Image", CreatedAt: "2025-06-01T00:00:00Z"},
}
@@ -20,6 +20,11 @@ func TestDefaultModels_ContainsNewAndLegacyImageModels(t *testing.T) {
"gemini-3.1-flash-image",
"gemini-3.1-flash-image-preview",
"gemini-3-pro-image", // legacy compatibility
"gemini-3.6-flash",
"gemini-3.6-flash-high",
"gemini-3.6-flash-low",
"gemini-3.6-flash-medium",
"gemini-3.6-flash-tiered",
}
for _, id := range requiredIDs {
+20 -1
View File
@@ -16,10 +16,29 @@ import (
"net/http"
"net/url"
"strings"
"time"
"golang.org/x/net/proxy"
)
const (
// socks5DialTimeout 限制到 SOCKS5 代理自身的 TCP 建连耗时。
socks5DialTimeout = 10 * time.Second
// socks5DialKeepAlive 与 Go 默认 keepalive 探测间隔保持一致。
socks5DialKeepAlive = 30 * time.Second
)
// socks5ForwardDialer 是 SOCKS5 dialer 的底层拨号器。
//
// proxy.FromURL 的默认 forward dialer 是 proxy.Direct(零值 net.Dialer,无超时),
// 代理地址不可达时会一直卡到内核 TCP 重传耗尽(Linux 约 130 秒)。SOCKS5 分支会
// 覆盖 Transport.DialContext,因此调用方在 Transport 上设置的建连超时对这条路径
// 无效,必须在这里补上。
var socks5ForwardDialer = &net.Dialer{
Timeout: socks5DialTimeout,
KeepAlive: socks5DialKeepAlive,
}
// ConfigureTransportProxy 根据代理 URL 配置 Transport
//
// 支持的协议:
@@ -45,7 +64,7 @@ func ConfigureTransportProxy(transport *http.Transport, proxyURL *url.URL) error
return nil
case "socks5", "socks5h":
dialer, err := proxy.FromURL(proxyURL, proxy.Direct)
dialer, err := proxy.FromURL(proxyURL, socks5ForwardDialer)
if err != nil {
return fmt.Errorf("create socks5 dialer: %w", err)
}
@@ -0,0 +1,58 @@
package proxyutil
import (
"context"
"errors"
"net"
"net/http"
"net/url"
"testing"
"time"
"github.com/stretchr/testify/require"
)
var errStub = errors.New("stub dial")
// 回归:SOCKS5 分支覆盖了调用方在 Transport 上设置的 DialContext,
// 底层 forward dialer 必须自带建连超时。proxy.Direct 是零值 net.Dialer,
// 代理不可达时会一直卡到内核 TCP 重传耗尽(Linux 约 130 秒)。
func TestSOCKS5ForwardDialerHasBoundedTimeout(t *testing.T) {
require.Greater(t, socks5ForwardDialer.Timeout, time.Duration(0))
require.Equal(t, socks5DialTimeout, socks5ForwardDialer.Timeout)
require.Equal(t, socks5DialKeepAlive, socks5ForwardDialer.KeepAlive)
}
func TestConfigureTransportProxySOCKS5SetsDialContext(t *testing.T) {
for _, scheme := range []string{"socks5", "socks5h"} {
t.Run(scheme, func(t *testing.T) {
proxyURL, err := url.Parse(scheme + "://127.0.0.1:1080")
require.NoError(t, err)
transport := &http.Transport{}
require.NoError(t, ConfigureTransportProxy(transport, proxyURL))
require.NotNil(t, transport.DialContext)
require.Nil(t, transport.Proxy, "SOCKS5 不应设置 Transport.Proxy")
})
}
}
// HTTP 代理走 Transport.Proxy,不得覆盖调用方设置的 DialContext。
func TestConfigureTransportProxyHTTPPreservesDialContext(t *testing.T) {
proxyURL, err := url.Parse("http://127.0.0.1:8080")
require.NoError(t, err)
called := false
transport := &http.Transport{}
transport.DialContext = func(_ context.Context, _, _ string) (net.Conn, error) {
called = true
return nil, errStub
}
require.NoError(t, ConfigureTransportProxy(transport, proxyURL))
require.NotNil(t, transport.Proxy)
require.NotNil(t, transport.DialContext)
_, _ = transport.DialContext(context.Background(), "tcp", "127.0.0.1:1")
require.True(t, called, "HTTP 代理分支不应替换调用方的 DialContext")
}
@@ -55,6 +55,18 @@ const (
// defaultResponseHeaderTimeout: 默认等待响应头超时时间(5分钟)
// LLM 请求可能排队较久,需要较长超时
defaultResponseHeaderTimeout = 300 * time.Second
// defaultUpstreamDialTimeout: 默认 TCP/DNS 建连超时(10秒)
// Transport 不设置 DialContext 时会退化为零值 net.Dialer(无超时),建连阶段
// 只能依赖内核默认 TCP 重传(Linux 约 130 秒)。ResponseHeaderTimeout 只约束
// 连接建立之后等待响应头的阶段,覆盖不到 DNS 解析与 TCP 握手。
// 上游域名被解析到 443 不可达的 IP 时(DNS 污染/路由异常),单个账号就要卡满
// 内核超时;而多账号故障转移是串行的,一次请求会阻塞数分钟且不写中间错误。
defaultUpstreamDialTimeout = 10 * time.Second
// defaultUpstreamDialKeepAlive: TCP keepalive 探测间隔,与 Go 默认值保持一致
defaultUpstreamDialKeepAlive = 30 * time.Second
// defaultUpstreamTLSHandshakeTimeout: TLS 握手超时(10秒)
// 与建连超时同量级,避免 TCP 已连通但对端不推进握手时无限等待
defaultUpstreamTLSHandshakeTimeout = 10 * time.Second
// defaultMaxUpstreamClients: 默认最大客户端缓存数量
// 超出后会淘汰最久未使用的客户端
defaultMaxUpstreamClients = 5000
@@ -1246,6 +1258,17 @@ func defaultPoolSettings(cfg *config.Config) poolSettings {
}
}
// newUpstreamDialer 构建上游 Transport 的 TCP dialer。
//
// 必须显式提供:http.Transport 的 DialContext 为 nil 时使用零值 net.Dialer,
// 建连没有任何超时上限,只能等内核 TCP 重传耗尽(Linux 约 130 秒)。
func newUpstreamDialer() *net.Dialer {
return &net.Dialer{
Timeout: defaultUpstreamDialTimeout,
KeepAlive: defaultUpstreamDialKeepAlive,
}
}
// buildUpstreamTransport 构建上游请求的 Transport
// 使用配置文件中的连接池参数,支持生产环境调优
//
@@ -1258,6 +1281,8 @@ func defaultPoolSettings(cfg *config.Config) poolSettings {
// - error: 代理配置错误
//
// Transport 参数说明:
// - DialContext: DNS 解析 + TCP 建连超时(不设置则无上限,退化为内核默认重传)
// - TLSHandshakeTimeout: TLS 握手超时
// - MaxIdleConns: 所有主机的最大空闲连接总数
// - MaxIdleConnsPerHost: 每主机最大空闲连接数(影响连接复用率)
// - MaxConnsPerHost: 每主机最大连接数(达到后新请求等待)
@@ -1265,6 +1290,8 @@ func defaultPoolSettings(cfg *config.Config) poolSettings {
// - ResponseHeaderTimeout: 等待响应头超时(不影响流式传输)
func buildUpstreamTransport(settings poolSettings, proxyURL *url.URL, protocolMode string) (*http.Transport, error) {
transport := &http.Transport{
DialContext: newUpstreamDialer().DialContext,
TLSHandshakeTimeout: defaultUpstreamTLSHandshakeTimeout,
MaxIdleConns: settings.maxIdleConns,
MaxIdleConnsPerHost: settings.maxIdleConnsPerHost,
MaxConnsPerHost: settings.maxConnsPerHost,
@@ -0,0 +1,75 @@
package repository
import (
"context"
"net"
"net/url"
"testing"
"time"
"github.com/stretchr/testify/require"
)
// 回归:上游 Transport 必须显式配置建连超时。
//
// http.Transport.DialContext 为 nil 时 Go 使用零值 net.Dialer(Timeout=0),
// DNS 解析与 TCP 握手没有任何上限,只能等内核重传耗尽(Linux 约 130 秒)。
// ResponseHeaderTimeout 只覆盖连接建立之后的阶段,管不到建连。
// 上游域名被解析到不可达 IP 时,串行的多账号故障转移会把一次请求拖到数分钟。
func TestBuildUpstreamTransportSetsDialTimeout(t *testing.T) {
settings := defaultPoolSettings(nil)
transport, err := buildUpstreamTransport(settings, nil, upstreamProtocolModeDefault)
require.NoError(t, err)
require.NotNil(t, transport.DialContext, "DialContext 缺失会退化为无超时的零值 dialer")
require.Equal(t, defaultUpstreamTLSHandshakeTimeout, transport.TLSHandshakeTimeout)
}
func TestNewUpstreamDialerHasBoundedTimeout(t *testing.T) {
dialer := newUpstreamDialer()
require.Greater(t, dialer.Timeout, time.Duration(0), "建连超时必须有上限")
require.Equal(t, defaultUpstreamDialTimeout, dialer.Timeout)
require.Equal(t, defaultUpstreamDialKeepAlive, dialer.KeepAlive)
}
// 建连超时对 HTTP 代理同样生效:Transport.Proxy 走的仍是 DialContext,
// 代理地址不可达时必须快速失败而不是挂满内核超时。
func TestBuildUpstreamTransportKeepsDialTimeoutWithHTTPProxy(t *testing.T) {
proxyURL, err := url.Parse("http://127.0.0.1:1080")
require.NoError(t, err)
transport, err := buildUpstreamTransport(defaultPoolSettings(nil), proxyURL, upstreamProtocolModeDefault)
require.NoError(t, err)
require.NotNil(t, transport.Proxy)
require.NotNil(t, transport.DialContext)
}
// SOCKS5 分支会覆盖 Transport.DialContext,覆盖后仍必须是有超时的拨号器。
func TestBuildUpstreamTransportKeepsDialContextWithSOCKS5Proxy(t *testing.T) {
proxyURL, err := url.Parse("socks5h://127.0.0.1:1080")
require.NoError(t, err)
transport, err := buildUpstreamTransport(defaultPoolSettings(nil), proxyURL, upstreamProtocolModeDefault)
require.NoError(t, err)
require.NotNil(t, transport.DialContext)
}
// Timeout 字段确实被 net.Dialer 用于建连:拨一个已被 close 的本地监听端口,
// 断言 Dialer 走的是自己的超时路径而不是无限等待。
// (不依赖外网可达性,CI 中确定性执行。)
func TestUpstreamDialerRespectsContextCancellation(t *testing.T) {
listener, err := net.Listen("tcp", "127.0.0.1:0")
require.NoError(t, err)
addr := listener.Addr().String()
require.NoError(t, listener.Close())
ctx, cancel := context.WithCancel(context.Background())
cancel()
conn, err := newUpstreamDialer().DialContext(ctx, "tcp", addr)
if conn != nil {
_ = conn.Close()
}
require.Error(t, err, "已取消的 context 必须立即中止拨号")
}
@@ -368,7 +368,7 @@ func (r *userSubscriptionRepository) UpdateNotes(ctx context.Context, subscripti
return translatePersistenceError(err, service.ErrSubscriptionNotFound, nil)
}
func (r *userSubscriptionRepository) ActivateWindows(ctx context.Context, id int64, start time.Time) error {
func (r *userSubscriptionRepository) ActivateWindows(ctx context.Context, id int64, dailyStart, periodicStart time.Time) error {
client := clientFromContext(ctx, r.client)
n, err := client.UserSubscription.Update().
Where(
@@ -377,24 +377,24 @@ func (r *userSubscriptionRepository) ActivateWindows(ctx context.Context, id int
usersubscription.WeeklyWindowStartIsNil(),
usersubscription.MonthlyWindowStartIsNil(),
).
SetDailyWindowStart(start).
SetWeeklyWindowStart(start).
SetMonthlyWindowStart(start).
SetDailyWindowStart(dailyStart).
SetWeeklyWindowStart(periodicStart).
SetMonthlyWindowStart(periodicStart).
Save(ctx)
return r.translateConditionalWindowReset(ctx, client, id, n, err)
}
func (r *userSubscriptionRepository) ResetUsageWindows(ctx context.Context, id int64, resetDaily, resetWeekly, resetMonthly bool, newWindowStart time.Time) error {
func (r *userSubscriptionRepository) ResetUsageWindows(ctx context.Context, id int64, resetDaily, resetWeekly, resetMonthly bool, dailyStart, periodicStart time.Time) error {
client := clientFromContext(ctx, r.client)
update := client.UserSubscription.UpdateOneID(id)
if resetDaily {
update.SetDailyUsageUsd(0).SetDailyWindowStart(newWindowStart)
update.SetDailyUsageUsd(0).SetDailyWindowStart(dailyStart)
}
if resetWeekly {
update.SetWeeklyUsageUsd(0).SetWeeklyWindowStart(newWindowStart)
update.SetWeeklyUsageUsd(0).SetWeeklyWindowStart(periodicStart)
}
if resetMonthly {
update.SetMonthlyUsageUsd(0).SetMonthlyWindowStart(newWindowStart)
update.SetMonthlyUsageUsd(0).SetMonthlyWindowStart(periodicStart)
}
_, err := update.Save(ctx)
return translatePersistenceError(err, service.ErrSubscriptionNotFound, nil)
@@ -451,8 +451,9 @@ func (s *UserSubscriptionRepoSuite) TestActivateWindows() {
group := s.mustCreateGroup("g-activate")
sub := s.mustCreateSubscription(user.ID, group.ID, nil)
dailyStart := time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC)
activateAt := time.Date(2025, 1, 1, 12, 0, 0, 0, time.UTC)
err := s.repo.ActivateWindows(s.ctx, sub.ID, activateAt)
err := s.repo.ActivateWindows(s.ctx, sub.ID, dailyStart, activateAt)
s.Require().NoError(err, "ActivateWindows")
got, err := s.repo.GetByID(s.ctx, sub.ID)
@@ -460,7 +461,9 @@ func (s *UserSubscriptionRepoSuite) TestActivateWindows() {
s.Require().NotNil(got.DailyWindowStart)
s.Require().NotNil(got.WeeklyWindowStart)
s.Require().NotNil(got.MonthlyWindowStart)
s.Require().WithinDuration(activateAt, *got.DailyWindowStart, time.Microsecond)
s.Require().WithinDuration(dailyStart, *got.DailyWindowStart, time.Microsecond)
s.Require().WithinDuration(activateAt, *got.WeeklyWindowStart, time.Microsecond)
s.Require().WithinDuration(activateAt, *got.MonthlyWindowStart, time.Microsecond)
}
func (s *UserSubscriptionRepoSuite) TestActivateWindows_StaleActivationPreservesExistingWindows() {
@@ -469,15 +472,16 @@ func (s *UserSubscriptionRepoSuite) TestActivateWindows_StaleActivationPreserves
sub := s.mustCreateSubscription(user.ID, group.ID, nil)
activatedAt := time.Date(2025, 1, 1, 12, 0, 0, 0, time.UTC)
manualResetAt := activatedAt.Add(2 * time.Hour)
manualDailyStart := time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC)
s.Require().NoError(s.repo.ActivateWindows(s.ctx, sub.ID, activatedAt))
s.Require().NoError(s.repo.ResetUsageWindows(s.ctx, sub.ID, true, true, true, manualResetAt))
s.Require().NoError(s.repo.ActivateWindows(s.ctx, sub.ID, activatedAt, activatedAt))
s.Require().NoError(s.repo.ResetUsageWindows(s.ctx, sub.ID, true, true, true, manualDailyStart, manualResetAt))
// Simulate a concurrent request carrying the original unactivated snapshot.
s.Require().NoError(s.repo.ActivateWindows(s.ctx, sub.ID, activatedAt.Add(time.Hour)))
s.Require().NoError(s.repo.ActivateWindows(s.ctx, sub.ID, activatedAt.Add(time.Hour), activatedAt.Add(time.Hour)))
got, err := s.repo.GetByID(s.ctx, sub.ID)
s.Require().NoError(err)
s.Require().WithinDuration(manualResetAt, *got.DailyWindowStart, time.Microsecond)
s.Require().WithinDuration(manualDailyStart, *got.DailyWindowStart, time.Microsecond)
s.Require().WithinDuration(manualResetAt, *got.WeeklyWindowStart, time.Microsecond)
s.Require().WithinDuration(manualResetAt, *got.MonthlyWindowStart, time.Microsecond)
}
@@ -535,7 +539,7 @@ func (s *UserSubscriptionRepoSuite) TestResetUsageWindows_ClearsUsageAfterAutoma
newWindowStart := oldWindowStart.Add(24 * time.Hour)
s.Require().NoError(s.repo.ResetDailyUsage(s.ctx, sub.ID, &oldWindowStart, newWindowStart))
s.Require().NoError(s.repo.IncrementUsage(s.ctx, sub.ID, 3))
s.Require().NoError(s.repo.ResetUsageWindows(s.ctx, sub.ID, true, false, false, newWindowStart))
s.Require().NoError(s.repo.ResetUsageWindows(s.ctx, sub.ID, true, false, false, newWindowStart, newWindowStart))
got, err := s.repo.GetByID(s.ctx, sub.ID)
s.Require().NoError(err)
@@ -770,7 +774,7 @@ func (s *UserSubscriptionRepoSuite) TestActiveExpiredBoundaries_UsageAndReset_Ba
s.Require().Equal(active.ID, got.ID, "expected active subscription")
activateAt := time.Now().Add(-25 * time.Hour)
s.Require().NoError(s.repo.ActivateWindows(s.ctx, active.ID, activateAt), "ActivateWindows")
s.Require().NoError(s.repo.ActivateWindows(s.ctx, active.ID, activateAt, activateAt), "ActivateWindows")
s.Require().NoError(s.repo.IncrementUsage(s.ctx, active.ID, 1.25), "IncrementUsage")
after, err := s.repo.GetByID(s.ctx, active.ID)
+2 -2
View File
@@ -2247,10 +2247,10 @@ func (stubUserSubscriptionRepo) UpdateStatus(ctx context.Context, subscriptionID
func (stubUserSubscriptionRepo) UpdateNotes(ctx context.Context, subscriptionID int64, notes string) error {
return errors.New("not implemented")
}
func (stubUserSubscriptionRepo) ActivateWindows(ctx context.Context, id int64, start time.Time) error {
func (stubUserSubscriptionRepo) ActivateWindows(ctx context.Context, id int64, dailyStart, periodicStart time.Time) error {
return errors.New("not implemented")
}
func (stubUserSubscriptionRepo) ResetUsageWindows(ctx context.Context, id int64, resetDaily, resetWeekly, resetMonthly bool, newWindowStart time.Time) error {
func (stubUserSubscriptionRepo) ResetUsageWindows(ctx context.Context, id int64, resetDaily, resetWeekly, resetMonthly bool, dailyStart, periodicStart time.Time) error {
return errors.New("not implemented")
}
func (stubUserSubscriptionRepo) ResetDailyUsage(ctx context.Context, id int64, expectedWindowStart *time.Time, newWindowStart time.Time) error {
@@ -82,7 +82,7 @@ type fakeGoogleSubscriptionRepo struct {
getByID func(ctx context.Context, id int64) (*service.UserSubscription, error)
getActive func(ctx context.Context, userID, groupID int64) (*service.UserSubscription, error)
updateStatus func(ctx context.Context, subscriptionID int64, status string) error
activateWindow func(ctx context.Context, id int64, start time.Time) error
activateWindow func(ctx context.Context, id int64, dailyStart, periodicStart time.Time) error
resetDaily func(ctx context.Context, id int64, start time.Time) error
resetWeekly func(ctx context.Context, id int64, start time.Time) error
resetMonthly func(ctx context.Context, id int64, start time.Time) error
@@ -231,13 +231,13 @@ func (f fakeGoogleSubscriptionRepo) UpdateStatus(ctx context.Context, subscripti
func (f fakeGoogleSubscriptionRepo) UpdateNotes(ctx context.Context, subscriptionID int64, notes string) error {
return errors.New("not implemented")
}
func (f fakeGoogleSubscriptionRepo) ActivateWindows(ctx context.Context, id int64, start time.Time) error {
func (f fakeGoogleSubscriptionRepo) ActivateWindows(ctx context.Context, id int64, dailyStart, periodicStart time.Time) error {
if f.activateWindow != nil {
return f.activateWindow(ctx, id, start)
return f.activateWindow(ctx, id, dailyStart, periodicStart)
}
return errors.New("not implemented")
}
func (f fakeGoogleSubscriptionRepo) ResetUsageWindows(context.Context, int64, bool, bool, bool, time.Time) error {
func (f fakeGoogleSubscriptionRepo) ResetUsageWindows(context.Context, int64, bool, bool, bool, time.Time, time.Time) error {
return errors.New("not implemented")
}
func (f fakeGoogleSubscriptionRepo) ResetDailyUsage(ctx context.Context, id int64, _ *time.Time, start time.Time) error {
@@ -886,7 +886,7 @@ func TestApiKeyAuthWithSubscriptionGoogle_SubscriptionLimitExceededReturns429(t
return &clone, nil
},
updateStatus: func(ctx context.Context, subscriptionID int64, status string) error { return nil },
activateWindow: func(ctx context.Context, id int64, start time.Time) error { return nil },
activateWindow: func(ctx context.Context, id int64, dailyStart, periodicStart time.Time) error { return nil },
resetDaily: func(ctx context.Context, id int64, start time.Time) error { return nil },
resetWeekly: func(ctx context.Context, id int64, start time.Time) error { return nil },
resetMonthly: func(ctx context.Context, id int64, start time.Time) error { return nil },
@@ -119,7 +119,7 @@ func TestSimpleModeBypassesQuotaCheck(t *testing.T) {
return &clone, nil
},
updateStatus: func(ctx context.Context, subscriptionID int64, status string) error { return nil },
activateWindow: func(ctx context.Context, id int64, start time.Time) error { return nil },
activateWindow: func(ctx context.Context, id int64, dailyStart, periodicStart time.Time) error { return nil },
resetDaily: func(ctx context.Context, id int64, start time.Time) error {
sub.DailyWindowStart = &start
sub.DailyUsageUSD = 0
@@ -252,7 +252,7 @@ func TestSimpleModeBypassesQuotaCheck(t *testing.T) {
return &clone, nil
},
updateStatus: func(ctx context.Context, subscriptionID int64, status string) error { return nil },
activateWindow: func(ctx context.Context, id int64, start time.Time) error { return nil },
activateWindow: func(ctx context.Context, id int64, dailyStart, periodicStart time.Time) error { return nil },
resetDaily: func(ctx context.Context, id int64, start time.Time) error { return nil },
resetWeekly: func(ctx context.Context, id int64, start time.Time) error { return nil },
resetMonthly: func(ctx context.Context, id int64, start time.Time) error { return nil },
@@ -1631,7 +1631,7 @@ type stubUserSubscriptionRepo struct {
getByID func(ctx context.Context, id int64) (*service.UserSubscription, error)
getActive func(ctx context.Context, userID, groupID int64) (*service.UserSubscription, error)
updateStatus func(ctx context.Context, subscriptionID int64, status string) error
activateWindow func(ctx context.Context, id int64, start time.Time) error
activateWindow func(ctx context.Context, id int64, dailyStart, periodicStart time.Time) error
resetDaily func(ctx context.Context, id int64, start time.Time) error
resetWeekly func(ctx context.Context, id int64, start time.Time) error
resetMonthly func(ctx context.Context, id int64, start time.Time) error
@@ -1753,14 +1753,14 @@ func (r *stubUserSubscriptionRepo) UpdateNotes(ctx context.Context, subscription
return errors.New("not implemented")
}
func (r *stubUserSubscriptionRepo) ActivateWindows(ctx context.Context, id int64, start time.Time) error {
func (r *stubUserSubscriptionRepo) ActivateWindows(ctx context.Context, id int64, dailyStart, periodicStart time.Time) error {
if r.activateWindow != nil {
return r.activateWindow(ctx, id, start)
return r.activateWindow(ctx, id, dailyStart, periodicStart)
}
return errors.New("not implemented")
}
func (r *stubUserSubscriptionRepo) ResetUsageWindows(context.Context, int64, bool, bool, bool, time.Time) error {
func (r *stubUserSubscriptionRepo) ResetUsageWindows(context.Context, int64, bool, bool, bool, time.Time, time.Time) error {
return errors.New("not implemented")
}
+5
View File
@@ -620,6 +620,11 @@ func (a *Account) resolveModelMapping(rawMapping map[string]any) map[string]stri
"gemini-3-flash",
"gemini-3.1-pro-high",
"gemini-3.1-pro-low",
"gemini-3.6-flash",
"gemini-3.6-flash-high",
"gemini-3.6-flash-low",
"gemini-3.6-flash-medium",
"gemini-3.6-flash-tiered",
})
applyAntigravityGemini31ProAliases(result)
}
+23 -8
View File
@@ -28,7 +28,8 @@ type PlazaModel struct {
// PlazaGroup 模型广场中以分组为顶层的条目。
//
// 与 AvailableGroupRef 相比多了 Description 与 Models;Models 来自该分组关联渠道的
// 支持模型(按分组平台隔离,防跨平台泄漏),与「可用渠道」页口径一致。
// 支持模型(普通分组按分组平台隔离,Composite 分组展开关联渠道已配置的
// 具体平台),与「可用渠道」页口径一致。
type PlazaGroup struct {
ID int64
Name string
@@ -99,8 +100,12 @@ func (s *ChannelService) ListPlazaGroups(ctx context.Context) ([]PlazaGroup, err
order = append(order, g.ID)
}
// modelIdx[groupID][modelName] = index into byGroup[groupID].Models
modelIdx := make(map[int64]map[string]int, len(groups))
type modelKey struct {
platform string
name string
}
// modelIdx[groupID][platform+modelName] = index into byGroup[groupID].Models
modelIdx := make(map[int64]map[modelKey]int, len(groups))
for i := range channels {
ch := &channels[i]
if ch.Status != StatusActive {
@@ -117,23 +122,28 @@ func (s *ChannelService) ListPlazaGroups(ctx context.Context) ([]PlazaGroup, err
}
idx := modelIdx[gid]
if idx == nil {
idx = make(map[string]int, len(supported))
idx = make(map[modelKey]int, len(supported))
modelIdx[gid] = idx
}
for j := range supported {
m := supported[j]
if m.Platform != pg.Platform {
if pg.Platform == PlatformComposite {
if !isConcreteRequestPlatform(m.Platform) {
continue
}
} else if m.Platform != pg.Platform {
continue
}
pricing := plazaImageDisplayPricing(m.Pricing, groupEnt[gid])
if at, seen := idx[m.Name]; seen {
key := modelKey{platform: m.Platform, name: m.Name}
if at, seen := idx[key]; seen {
// 先见者胜;仅当已存条目无定价而新条目有定价时升级。
if pg.Models[at].Pricing == nil && pricing != nil {
pg.Models[at].Pricing = pricing
}
continue
}
idx[m.Name] = len(pg.Models)
idx[key] = len(pg.Models)
pg.Models = append(pg.Models, PlazaModel{
Name: m.Name,
Platform: m.Platform,
@@ -150,7 +160,12 @@ func (s *ChannelService) ListPlazaGroups(ctx context.Context) ([]PlazaGroup, err
if len(pg.Models) == 0 {
continue
}
sort.SliceStable(pg.Models, func(i, j int) bool { return pg.Models[i].Name < pg.Models[j].Name })
sort.SliceStable(pg.Models, func(i, j int) bool {
if pg.Models[i].Name != pg.Models[j].Name {
return pg.Models[i].Name < pg.Models[j].Name
}
return pg.Models[i].Platform < pg.Models[j].Platform
})
for j := range pg.Models {
pg.Models[j].OfficialPricing = s.lookupOfficialPricing(pg.Models[j].Name, officialMemo)
}
@@ -107,6 +107,68 @@ func TestListPlazaGroups_PlatformIsolation(t *testing.T) {
require.Equal(t, "gpt-5", byName["g-gpt"][0].Name)
}
func TestListPlazaGroups_CompositeIncludesConfiguredConcretePlatforms(t *testing.T) {
anthropicPrice := 3e-6
openAIPrice := 2e-6
ch := Channel{
ID: 1, Name: "multi", Status: StatusActive, GroupIDs: []int64{10},
ModelPricing: []ChannelModelPricing{
{Platform: PlatformAnthropic, Models: []string{"shared-model"}, InputPrice: &anthropicPrice},
{Platform: PlatformOpenAI, Models: []string{"shared-model"}, InputPrice: &openAIPrice},
{Platform: "", Models: []string{"empty-platform"}},
{Platform: PlatformComposite, Models: []string{"nested-composite"}},
{Platform: "unknown-platform", Models: []string{"unknown-platform"}},
},
}
groups := []Group{{ID: 10, Name: "composite", Platform: PlatformComposite, RateMultiplier: 1}}
out, err := newPlazaChannelService([]Channel{ch}, groups, nil).ListPlazaGroups(context.Background())
require.NoError(t, err)
require.Len(t, out, 1)
require.Len(t, out[0].Models, 2, "only concrete platforms are included and same-named models remain distinct")
require.Equal(t, PlatformAnthropic, out[0].Models[0].Platform)
require.Equal(t, PlatformOpenAI, out[0].Models[1].Platform)
require.InDelta(t, anthropicPrice, *out[0].Models[0].Pricing.InputPrice, 1e-12)
require.InDelta(t, openAIPrice, *out[0].Models[1].Pricing.InputPrice, 1e-12)
}
func TestListPlazaGroups_CompositeAndOrdinaryGroupsDoNotLeakPlatforms(t *testing.T) {
ch := Channel{
ID: 1, Name: "multi", Status: StatusActive, GroupIDs: []int64{10, 20},
ModelPricing: []ChannelModelPricing{
{Platform: PlatformAnthropic, Models: []string{"claude-sonnet"}, InputPrice: testPtrFloat64(3e-6)},
{Platform: PlatformOpenAI, Models: []string{"gpt-5"}, InputPrice: testPtrFloat64(2e-6)},
},
}
groups := []Group{
{ID: 10, Name: "anthropic-only", Platform: PlatformAnthropic, RateMultiplier: 1},
{ID: 20, Name: "composite", Platform: PlatformComposite, RateMultiplier: 1},
}
out, err := newPlazaChannelService([]Channel{ch}, groups, nil).ListPlazaGroups(context.Background())
require.NoError(t, err)
require.Len(t, out, 2)
byName := map[string]PlazaGroup{}
for _, group := range out {
byName[group.Name] = group
}
require.Len(t, byName["anthropic-only"].Models, 1)
require.Equal(t, []PlazaModel{{
Name: "claude-sonnet", Platform: PlatformAnthropic, Pricing: byName["anthropic-only"].Models[0].Pricing,
}}, byName["anthropic-only"].Models)
require.Len(t, byName["composite"].Models, 2)
require.Equal(t, []string{"claude-sonnet", "gpt-5"}, []string{
byName["composite"].Models[0].Name,
byName["composite"].Models[1].Name,
})
require.Equal(t, []string{PlatformAnthropic, PlatformOpenAI}, []string{
byName["composite"].Models[0].Platform,
byName["composite"].Models[1].Platform,
})
}
func TestListPlazaGroups_InactiveChannelSkipped(t *testing.T) {
inactive := plazaPricedChannel(1, "off", []int64{10}, "anthropic", "claude-sonnet")
inactive.Status = "inactive"
+1 -1
View File
@@ -816,7 +816,7 @@ func extractGrokMediaVideoRequestID(body []byte) string {
if len(body) == 0 || !gjson.ValidBytes(body) {
return ""
}
for _, path := range []string{"request_id", "id", "data.request_id", "data.id", "video.request_id", "video.id"} {
for _, path := range []string{"request_id", "id", "data.request_id", "data.id", "video.request_id", "video.id", "task_id", "data.task_id", "video.task_id"} {
if id := strings.TrimSpace(gjson.GetBytes(body, path).String()); id != "" {
return id
}
@@ -345,12 +345,26 @@ func isOpenAIOAuthInputTokensUnsupported(statusCode int, body []byte) bool {
return true
}
// OAuth's platform endpoint can be blocked by an upstream proxy before it
// reaches the API and return an HTML 403 page without a structured error.
// Treat that endpoint-level response like the other unsupported cases so
// count_tokens remains a local, non-health-affecting convenience request.
if statusCode == http.StatusForbidden && isHTMLResponse(body) {
return true
}
return strings.Contains(msg, "input_tokens") &&
(strings.Contains(msg, "not found") ||
strings.Contains(msg, "not supported") ||
strings.Contains(msg, "unsupported"))
}
func isHTMLResponse(body []byte) bool {
trimmed := strings.TrimSpace(strings.ToLower(string(body)))
return strings.HasPrefix(trimmed, "<!doctype html") ||
strings.HasPrefix(trimmed, "<html")
}
func estimateOpenAIInputTokens(req openAIInputTokensCountRequest) (int, error) {
codec, err := openAIInputTokensCodecForModel(req.Model)
if err != nil {
@@ -123,6 +123,11 @@ func TestOpenAIGatewayService_ForwardCountTokensAsAnthropic_OAuthFallsBackWhenPl
statusCode: http.StatusForbidden,
body: `{"error":{"type":"invalid_request_error","code":"missing_scope","message":"Missing scopes: api.responses.write"}}`,
},
{
name: "403_html_proxy_page",
statusCode: http.StatusForbidden,
body: "<!doctype html><html><body>Forbidden</body></html>",
},
{
name: "404_input_tokens_unsupported",
statusCode: http.StatusNotFound,
@@ -1216,6 +1216,51 @@ func TestForwardGrokMediaVideoGenerationReturnsUsageAndResponseID(t *testing.T)
require.Equal(t, 10, result.VideoDurationSeconds)
}
func TestForwardGrokMediaVideoGenerationReturnsTaskIDAsResponseID(t *testing.T) {
t.Setenv(xai.EnvAllowUnsafeURLOverrides, "true")
gin.SetMode(gin.TestMode)
recorder := httptest.NewRecorder()
c, _ := gin.CreateTestContext(recorder)
body := []byte(`{"model":"grok-imagine-video","prompt":"waves"}`)
c.Request = httptest.NewRequest(http.MethodPost, "/v1/videos/generations", bytes.NewReader(body))
c.Request.Header.Set("Content-Type", "application/json")
account := &Account{
ID: 63,
Name: "grok",
Platform: PlatformGrok,
Type: AccountTypeAPIKey,
Concurrency: 1,
Credentials: map[string]any{
"api_key": "api-key",
"base_url": "https://xai.test/v1",
},
}
upstream := &httpUpstreamRecorder{resp: &http.Response{
StatusCode: http.StatusOK,
Header: http.Header{"Content-Type": []string{"application/json"}},
Body: io.NopCloser(strings.NewReader(`{"task_id":"video-task-123"}`)),
}}
svc := &OpenAIGatewayService{httpUpstream: upstream}
result, err := svc.ForwardGrokMedia(context.Background(), c, account, GrokMediaEndpointVideosGenerations, "", body, "application/json")
require.NoError(t, err)
require.Equal(t, "video-task-123", result.ResponseID)
}
func TestExtractGrokMediaVideoRequestIDPreservesExistingPrecedence(t *testing.T) {
body := []byte(`{
"request_id":"request-id",
"id":"id",
"task_id":"task-id",
"data":{"request_id":"data-request-id","id":"data-id","task_id":"data-task-id"},
"video":{"request_id":"video-request-id","id":"video-id","task_id":"video-task-id"}
}`)
require.Equal(t, "request-id", extractGrokMediaVideoRequestID(body))
}
func TestForwardGrokMediaVideoGenerationPreservesImageToVideoModel(t *testing.T) {
t.Setenv(xai.EnvAllowUnsafeURLOverrides, "true")
gin.SetMode(gin.TestMode)
@@ -551,13 +551,22 @@ func TestNormalizeOpenAIWSPayloadWithoutInputAndPreviousResponseID(t *testing.T)
t.Parallel()
normalized, err := normalizeOpenAIWSPayloadWithoutInputAndPreviousResponseID(
[]byte(`{"model":"gpt-5.1","input":[1],"previous_response_id":"resp_x","metadata":{"b":2,"a":1}}`),
[]byte(`{"model":"gpt-5.1","input":[1],"previous_response_id":"resp_x","client_metadata":{"request_start_ms":"1"},"stream_options":{"include_usage":true},"generate":false,"metadata":{"b":2,"a":1}}`),
)
require.NoError(t, err)
require.False(t, gjson.GetBytes(normalized, "input").Exists())
require.False(t, gjson.GetBytes(normalized, "previous_response_id").Exists())
require.False(t, gjson.GetBytes(normalized, "client_metadata").Exists())
require.False(t, gjson.GetBytes(normalized, "stream_options").Exists())
require.False(t, gjson.GetBytes(normalized, "generate").Exists())
require.Equal(t, float64(1), gjson.GetBytes(normalized, "metadata.a").Float())
normalized, err = normalizeOpenAIWSPayloadWithoutInputAndPreviousResponseID(
[]byte(`{"model":"gpt-5.1","generate":true}`),
)
require.NoError(t, err)
require.True(t, gjson.GetBytes(normalized, "generate").Bool())
_, err = normalizeOpenAIWSPayloadWithoutInputAndPreviousResponseID(nil)
require.Error(t, err)
@@ -662,6 +671,36 @@ func TestShouldKeepIngressPreviousResponseID(t *testing.T) {
require.Equal(t, "strict_incremental_ok", reason)
})
t.Run("codex_prewarm_to_business_keep", func(t *testing.T) {
prewarmPayload := []byte(`{
"type":"response.create",
"model":"gpt-5.1",
"store":false,
"generate":false,
"client_metadata":{"x-codex-ws-stream-request-start-ms":"100"},
"stream_options":{"include_usage":true},
"input":[{"type":"input_text","text":"hello"}]
}`)
businessPayload := []byte(`{
"type":"response.create",
"model":"gpt-5.1",
"store":false,
"client_metadata":{"x-codex-ws-stream-request-start-ms":"200"},
"previous_response_id":"resp_prewarm",
"input":[{"type":"input_text","text":"hello"}]
}`)
keep, reason, err := shouldKeepIngressPreviousResponseID(
prewarmPayload,
businessPayload,
"resp_prewarm",
false,
)
require.NoError(t, err)
require.True(t, keep)
require.Equal(t, "strict_incremental_ok", reason)
})
t.Run("missing_previous_response_id", func(t *testing.T) {
payload := []byte(`{"type":"response.create","model":"gpt-5.1","input":[]}`)
keep, reason, err := shouldKeepIngressPreviousResponseID(previousPayload, payload, "resp_turn_1", false)
@@ -449,6 +449,17 @@ func normalizeOpenAIWSPayloadWithoutInputAndPreviousResponseID(payload []byte) (
}
delete(decoded, "input")
delete(decoded, "previous_response_id")
// Codex changes transport-only metadata for every response.create. These fields
// do not alter the context referenced by previous_response_id and are excluded
// from Codex's own websocket reuse comparison.
delete(decoded, "client_metadata")
delete(decoded, "stream_options")
// Official Codex prewarms a connection with generate=false, then omits the
// field on the business request that continues from the prewarm response.
// Only normalize false so a meaningful generate=true change remains visible.
if generate, ok := decoded["generate"].(bool); ok && !generate {
delete(decoded, "generate")
}
return json.Marshal(decoded)
}
@@ -9,6 +9,7 @@ import (
dbent "github.com/Wei-Shaw/sub2api/ent"
infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
"github.com/Wei-Shaw/sub2api/internal/pkg/pagination"
"github.com/Wei-Shaw/sub2api/internal/pkg/timezone"
"github.com/dgraph-io/ristretto"
"github.com/stretchr/testify/require"
)
@@ -157,10 +158,10 @@ func (userSubRepoNoop) UpdateStatus(context.Context, int64, string) error {
func (userSubRepoNoop) UpdateNotes(context.Context, int64, string) error {
panic("unexpected UpdateNotes call")
}
func (userSubRepoNoop) ActivateWindows(context.Context, int64, time.Time) error {
func (userSubRepoNoop) ActivateWindows(context.Context, int64, time.Time, time.Time) error {
panic("unexpected ActivateWindows call")
}
func (userSubRepoNoop) ResetUsageWindows(context.Context, int64, bool, bool, bool, time.Time) error {
func (userSubRepoNoop) ResetUsageWindows(context.Context, int64, bool, bool, bool, time.Time, time.Time) error {
panic("unexpected ResetUsageWindows call")
}
func (userSubRepoNoop) ResetDailyUsage(context.Context, int64, *time.Time, time.Time) error {
@@ -424,7 +425,7 @@ func TestAssignSubscriptionRenewsExpiredSemanticMatch(t *testing.T) {
require.False(t, sub.StartsAt.Before(before))
require.False(t, sub.StartsAt.After(after))
require.Equal(t, sub.StartsAt.AddDate(0, 0, 30), sub.ExpiresAt)
require.Equal(t, sub.StartsAt, *sub.DailyWindowStart)
require.Equal(t, timezone.StartOfDay(sub.StartsAt), *sub.DailyWindowStart, "续期后日窗口应锚定当天 0 点")
require.Equal(t, sub.StartsAt, *sub.WeeklyWindowStart)
require.Equal(t, sub.StartsAt, *sub.MonthlyWindowStart)
require.Zero(t, sub.DailyUsageUSD)
@@ -0,0 +1,162 @@
package service
import (
"context"
"testing"
"time"
"github.com/Wei-Shaw/sub2api/internal/pkg/timezone"
"github.com/stretchr/testify/require"
)
// dailyMidnightResetRepo 记录 ResetDailyUsage 收到的新窗口起点。
type dailyMidnightResetRepo struct {
userSubRepoNoop
resetCalled bool
newWindowStart time.Time
}
func (r *dailyMidnightResetRepo) ResetDailyUsage(_ context.Context, _ int64, _ *time.Time, newWindowStart time.Time) error {
r.resetCalled = true
r.newWindowStart = newWindowStart
return nil
}
// midnightTestBase 返回一个固定日期在配置时区的 0 点,测试用时刻均从它推导,
// 保证断言在任意本地时区下都与生产逻辑同构。
func midnightTestBase() time.Time {
return timezone.StartOfDay(time.Date(2026, 8, 6, 12, 0, 0, 0, timezone.Location()))
}
func newMidnightTestSub(dailyWindowStart time.Time, base time.Time) *UserSubscription {
start := dailyWindowStart
return &UserSubscription{
ID: 1,
UserID: 10,
GroupID: 20,
StartsAt: base.AddDate(0, 0, -3),
ExpiresAt: base.AddDate(0, 0, 30),
DailyUsageUSD: 43.34,
DailyWindowStart: &start,
}
}
// 手动重置(或任何原因)留下的非 0 点锚点,跨 0 点后必须重置,
// 且新窗口起点是当天 0 点,而不是锚点+24h 的滚动时刻。
func TestCheckAndResetWindows_DailyResetsAtMidnightNotRollingAnchor(t *testing.T) {
base := midnightTestBase()
manualResetAt := base.Add(16*time.Hour + 49*time.Minute) // 昨日 16:49 手动重置
now := base.AddDate(0, 0, 1).Add(5 * time.Minute) // 次日 00:05
repo := &dailyMidnightResetRepo{}
svc := NewSubscriptionService(groupRepoNoop{}, repo, nil, nil, nil)
svc.now = func() time.Time { return now }
sub := newMidnightTestSub(manualResetAt, base)
require.NoError(t, svc.CheckAndResetWindows(context.Background(), sub))
require.True(t, repo.resetCalled, "跨 0 点后日窗口必须重置")
require.Equal(t, base.AddDate(0, 0, 1), repo.newWindowStart, "新窗口起点应为当天 0 点")
require.Zero(t, sub.DailyUsageUSD)
require.Equal(t, base.AddDate(0, 0, 1), *sub.DailyWindowStart)
}
// 同一日历日内(即使已过锚点+若干小时)不得重置:手动重置当天剩余时间继续累计用量。
func TestCheckAndResetWindows_DailyNoResetWithinSameCalendarDay(t *testing.T) {
base := midnightTestBase()
manualResetAt := base.Add(16*time.Hour + 49*time.Minute)
now := base.Add(23*time.Hour + 59*time.Minute)
repo := &dailyMidnightResetRepo{}
svc := NewSubscriptionService(groupRepoNoop{}, repo, nil, nil, nil)
svc.now = func() time.Time { return now }
sub := newMidnightTestSub(manualResetAt, base)
require.NoError(t, svc.CheckAndResetWindows(context.Background(), sub))
require.False(t, repo.resetCalled, "同一日历日内不应重置日窗口")
require.Equal(t, 43.34, sub.DailyUsageUSD)
}
// 0.1.170/171 期间产生的滚动锚点(如多日前的 17:18)自愈:下一次维护即拉回今天 0 点。
func TestCheckAndResetWindows_LegacyRollingAnchorHealsToMidnight(t *testing.T) {
base := midnightTestBase()
staleAnchor := base.AddDate(0, 0, -3).Add(17*time.Hour + 18*time.Minute)
now := base.Add(10 * time.Hour)
repo := &dailyMidnightResetRepo{}
svc := NewSubscriptionService(groupRepoNoop{}, repo, nil, nil, nil)
svc.now = func() time.Time { return now }
sub := newMidnightTestSub(staleAnchor, base)
require.NoError(t, svc.CheckAndResetWindows(context.Background(), sub))
require.True(t, repo.resetCalled)
require.Equal(t, base, repo.newWindowStart, "滚动锚点应被拉回今天 0 点,而非按 17:18 步进")
}
// 手动重置写入的 0 点锚点不会改变刷新节奏:次日 0 点照常需要重置。
func TestNeedsDailyReset_MidnightScheduleSurvivesManualReset(t *testing.T) {
base := midnightTestBase()
sub := newMidnightTestSub(base, base) // 手动重置后锚点=当天 0 点
require.False(t, sub.NeedsDailyResetAt(base.Add(23*time.Hour+54*time.Minute)), "当天 23:54 不应重置")
require.True(t, sub.NeedsDailyResetAt(base.AddDate(0, 0, 1).Add(time.Minute)), "次日 00:01 应重置")
}
// 多日订阅的日窗口展示的下次刷新时间固定为次日 0 点(截图中「X 小时后重置」的数据源)。
func TestDailyResetTime_NextMidnightForMultiDaySubscription(t *testing.T) {
base := midnightTestBase()
// 0 点锚点 → 次日 0 点
sub := newMidnightTestSub(base, base)
resetAt := sub.DailyResetTime()
require.NotNil(t, resetAt)
require.Equal(t, base.AddDate(0, 0, 1), *resetAt)
// 非 0 点滚动锚点 → 仍是其所在日的次日 0 点,而非锚点+24h
rolling := newMidnightTestSub(base.Add(16*time.Hour+49*time.Minute), base)
resetAt = rolling.DailyResetTime()
require.NotNil(t, resetAt)
require.Equal(t, base.AddDate(0, 0, 1), *resetAt)
}
// 截图场景:跨 0 点后(后台尚未收到请求推进窗口)列表展示即应清零日用量。
func TestNormalizeExpiredWindows_DailyUsageClearsAfterMidnight(t *testing.T) {
base := midnightTestBase()
manualResetAt := base.Add(16*time.Hour + 49*time.Minute)
now := base.AddDate(0, 0, 1).Add(time.Minute) // 次日 00:01
subs := []UserSubscription{*newMidnightTestSub(manualResetAt, base)}
normalizeExpiredWindowsAt(subs, now)
require.Zero(t, subs[0].DailyUsageUSD, "跨 0 点后展示的日用量应清零")
require.Nil(t, subs[0].DailyWindowStart)
}
// 日卡(一次性日额度)不受 0 点语义影响:跨 0 点不重置。
func TestCheckAndResetWindows_OneTimeDailyCardStillExemptFromMidnightReset(t *testing.T) {
base := midnightTestBase()
startsAt := base.Add(17 * time.Hour)
anchor := base
now := base.AddDate(0, 0, 1).Add(2 * time.Hour)
repo := &dailyMidnightResetRepo{}
svc := NewSubscriptionService(groupRepoNoop{}, repo, nil, nil, nil)
svc.now = func() time.Time { return now }
sub := &UserSubscription{
ID: 1,
UserID: 10,
GroupID: 20,
StartsAt: startsAt,
ExpiresAt: startsAt.AddDate(0, 0, 1),
DailyUsageUSD: 10,
DailyWindowStart: &anchor,
}
require.NoError(t, svc.CheckAndResetWindows(context.Background(), sub))
require.False(t, repo.resetCalled, "日卡为一次性配额,跨 0 点不应重置")
require.Equal(t, 10.0, sub.DailyUsageUSD)
}
@@ -87,11 +87,11 @@ func (r *subscriptionExpiryRepoStub) UpdateNotes(context.Context, int64, string)
return nil
}
func (r *subscriptionExpiryRepoStub) ActivateWindows(context.Context, int64, time.Time) error {
func (r *subscriptionExpiryRepoStub) ActivateWindows(context.Context, int64, time.Time, time.Time) error {
return nil
}
func (r *subscriptionExpiryRepoStub) ResetUsageWindows(context.Context, int64, bool, bool, bool, time.Time) error {
func (r *subscriptionExpiryRepoStub) ResetUsageWindows(context.Context, int64, bool, bool, bool, time.Time, time.Time) error {
return nil
}
@@ -8,12 +8,14 @@ import (
"testing"
"time"
"github.com/Wei-Shaw/sub2api/internal/pkg/timezone"
"github.com/stretchr/testify/require"
)
type activateWindowUserSubRepo struct {
userSubRepoNoop
windowStart time.Time
dailyStart time.Time
periodicStart time.Time
}
type monthlyResetUserSubRepo struct {
@@ -28,8 +30,9 @@ func (r *monthlyResetUserSubRepo) ResetMonthlyUsage(_ context.Context, _ int64,
return nil
}
func (r *activateWindowUserSubRepo) ActivateWindows(_ context.Context, _ int64, start time.Time) error {
r.windowStart = start
func (r *activateWindowUserSubRepo) ActivateWindows(_ context.Context, _ int64, dailyStart, periodicStart time.Time) error {
r.dailyStart = dailyStart
r.periodicStart = periodicStart
return nil
}
@@ -47,8 +50,9 @@ func TestDelayedFirstUseAnchorsMonthlyWindowAtActivation(t *testing.T) {
require.NoError(t, svc.CheckAndActivateWindow(context.Background(), sub))
require.Equal(t, activatedAt, repo.windowStart)
monthlyWindowStart := repo.windowStart
require.Equal(t, activatedAt, repo.periodicStart)
require.Equal(t, timezone.StartOfDay(activatedAt), repo.dailyStart)
monthlyWindowStart := repo.periodicStart
resetAt, ok := sub.automaticWindowStartAt(&monthlyWindowStart, 30*24*time.Hour, activatedAt.Add(30*24*time.Hour))
require.True(t, ok)
require.Equal(t, activatedAt.Add(30*24*time.Hour), resetAt)
@@ -8,6 +8,7 @@ import (
"testing"
"time"
"github.com/Wei-Shaw/sub2api/internal/pkg/timezone"
"github.com/stretchr/testify/require"
)
@@ -24,7 +25,8 @@ type resetQuotaUserSubRepoStub struct {
resetDailyErr error
resetWeeklyErr error
resetMonthlyErr error
windowStart time.Time
dailyStart time.Time
periodicStart time.Time
}
func (r *resetQuotaUserSubRepoStub) GetByID(_ context.Context, id int64) (*UserSubscription, error) {
@@ -35,11 +37,12 @@ func (r *resetQuotaUserSubRepoStub) GetByID(_ context.Context, id int64) (*UserS
return &cp, nil
}
func (r *resetQuotaUserSubRepoStub) ResetUsageWindows(_ context.Context, _ int64, resetDaily, resetWeekly, resetMonthly bool, windowStart time.Time) error {
func (r *resetQuotaUserSubRepoStub) ResetUsageWindows(_ context.Context, _ int64, resetDaily, resetWeekly, resetMonthly bool, dailyStart, periodicStart time.Time) error {
r.resetDailyCalled = resetDaily
r.resetWeeklyCalled = resetWeekly
r.resetMonthlyCalled = resetMonthly
r.windowStart = windowStart
r.dailyStart = dailyStart
r.periodicStart = periodicStart
if resetDaily && r.resetDailyErr != nil {
return r.resetDailyErr
}
@@ -54,15 +57,15 @@ func (r *resetQuotaUserSubRepoStub) ResetUsageWindows(_ context.Context, _ int64
}
if resetDaily {
r.sub.DailyUsageUSD = 0
r.sub.DailyWindowStart = &windowStart
r.sub.DailyWindowStart = &dailyStart
}
if resetWeekly {
r.sub.WeeklyUsageUSD = 0
r.sub.WeeklyWindowStart = &windowStart
r.sub.WeeklyWindowStart = &periodicStart
}
if resetMonthly {
r.sub.MonthlyUsageUSD = 0
r.sub.MonthlyWindowStart = &windowStart
r.sub.MonthlyWindowStart = &periodicStart
}
return nil
}
@@ -105,8 +108,10 @@ func TestAdminResetQuota_ResetBoth(t *testing.T) {
require.True(t, stub.resetDailyCalled, "应调用 ResetDailyUsage")
require.True(t, stub.resetWeeklyCalled, "应调用 ResetWeeklyUsage")
require.False(t, stub.resetMonthlyCalled, "不应调用 ResetMonthlyUsage")
require.Equal(t, resetAt, stub.windowStart)
require.Equal(t, resetAt, *result.DailyWindowStart)
// 手动重置后日窗口锚定当天 0 点(保持 0 点刷新节奏),周窗口锚定重置时刻。
require.Equal(t, timezone.StartOfDay(resetAt), stub.dailyStart)
require.Equal(t, resetAt, stub.periodicStart)
require.Equal(t, timezone.StartOfDay(resetAt), *result.DailyWindowStart)
require.Equal(t, resetAt, *result.WeeklyWindowStart)
}
@@ -13,6 +13,7 @@ import (
"github.com/Wei-Shaw/sub2api/internal/config"
infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
"github.com/Wei-Shaw/sub2api/internal/pkg/pagination"
"github.com/Wei-Shaw/sub2api/internal/pkg/timezone"
"github.com/dgraph-io/ristretto"
"golang.org/x/sync/singleflight"
)
@@ -380,13 +381,15 @@ func (s *SubscriptionService) withSubscriptionUpdateTx(ctx context.Context, fn f
func renewedSubscriptionTerm(existingSub *UserSubscription, notes string, startsAt, expiresAt time.Time) *UserSubscription {
renewed := *existingSub
windowStart := startsAt
// 日窗口按日历日对齐(0 点刷新);周/月窗口按订阅期限对齐(锚点为新周期起点)。
dailyWindowStart := timezone.StartOfDay(startsAt)
periodicWindowStart := startsAt
renewed.StartsAt = startsAt
renewed.ExpiresAt = expiresAt
renewed.Status = SubscriptionStatusActive
renewed.DailyWindowStart = &windowStart
renewed.WeeklyWindowStart = &windowStart
renewed.MonthlyWindowStart = &windowStart
renewed.DailyWindowStart = &dailyWindowStart
renewed.WeeklyWindowStart = &periodicWindowStart
renewed.MonthlyWindowStart = &periodicWindowStart
renewed.DailyUsageUSD = 0
renewed.WeeklyUsageUSD = 0
renewed.MonthlyUsageUSD = 0
@@ -858,7 +861,9 @@ func (s *SubscriptionService) checkAndActivateWindowAt(ctx context.Context, sub
return nil
}
return s.userSubRepo.ActivateWindows(ctx, sub.ID, now)
// 日窗口锚定当天 0 点(日历日语义);周/月窗口锚定首次使用时刻(期限对齐语义,
// 锚点不得早于 StartsAt,否则最后一个不完整周期会重复发放额度,见 issue #5051)。
return s.userSubRepo.ActivateWindows(ctx, sub.ID, timezone.StartOfDay(now), now)
}
// AdminResetQuota manually resets the daily, weekly, and/or monthly usage windows.
@@ -870,8 +875,10 @@ func (s *SubscriptionService) AdminResetQuota(ctx context.Context, subscriptionI
if err != nil {
return nil, err
}
windowStart := s.now()
if err := s.userSubRepo.ResetUsageWindows(ctx, sub.ID, resetDaily, resetWeekly, resetMonthly, windowStart); err != nil {
now := s.now()
// 日窗口锚点取当天 0 点:手动重置只清空用量,不改变“每天 0 点刷新”的节奏。
// 周/月窗口保持锚定重置时刻(期限对齐滚动窗口语义)。
if err := s.userSubRepo.ResetUsageWindows(ctx, sub.ID, resetDaily, resetWeekly, resetMonthly, timezone.StartOfDay(now), now); err != nil {
return nil, err
}
// Invalidate L1 ristretto cache. Ristretto's Del() is asynchronous by design,
@@ -890,8 +897,8 @@ func (s *SubscriptionService) CheckAndResetWindows(ctx context.Context, sub *Use
now := s.now()
needsInvalidateCache := false
// 日窗口重置(24小时)
if windowStart, ok := sub.automaticWindowStartAt(sub.DailyWindowStart, 24*time.Hour, now); !sub.HasOneTimeDailyQuota() && ok {
// 日窗口重置(每天 0 点刷新,按日历日对齐)
if windowStart, ok := sub.automaticDailyWindowStartAt(now); ok {
expectedWindowStart := sub.DailyWindowStart
if err := s.userSubRepo.ResetDailyUsage(ctx, sub.ID, expectedWindowStart, windowStart); err != nil {
return err
+32 -11
View File
@@ -1,6 +1,10 @@
package service
import "time"
import (
"time"
"github.com/Wei-Shaw/sub2api/internal/pkg/timezone"
)
const subscriptionDayDuration = 24 * time.Hour
@@ -75,13 +79,8 @@ func (s *UserSubscription) NeedsDailyReset() bool {
}
func (s *UserSubscription) NeedsDailyResetAt(now time.Time) bool {
if s.DailyWindowStart == nil {
return false
}
if s.HasOneTimeDailyQuota() {
return false
}
return !now.Before(s.DailyWindowStart.Add(24 * time.Hour))
_, ok := s.automaticDailyWindowStartAt(now)
return ok
}
func (s *UserSubscription) NeedsWeeklyReset() bool {
@@ -107,8 +106,26 @@ func (s *UserSubscription) NeedsMonthlyResetAt(now time.Time) bool {
}
func (s *UserSubscription) canAutomaticallyResetDailyAt(now time.Time) bool {
_, ok := s.automaticWindowStartAt(s.DailyWindowStart, 24*time.Hour, now)
return !s.HasOneTimeDailyQuota() && ok
_, ok := s.automaticDailyWindowStartAt(now)
return ok
}
// automaticDailyWindowStartAt 计算日窗口按“配置时区日历日”对齐后的当前窗口起点。
// 日额度固定在每天 0 点刷新(与周/月的期限对齐滚动窗口语义不同),因此只要持久化
// 的窗口起点落在更早的日历日,就允许推进到今天 0 点。手动重置、激活等写入的任何
// 非 0 点锚点都会在下一个 0 点被拉回日历日边界,不会永久漂移刷新时刻。
func (s *UserSubscription) automaticDailyWindowStartAt(now time.Time) (time.Time, bool) {
if s.DailyWindowStart == nil {
return time.Time{}, false
}
if s.HasOneTimeDailyQuota() {
return time.Time{}, false
}
today := timezone.StartOfDay(now)
if !today.After(timezone.StartOfDay(*s.DailyWindowStart)) {
return time.Time{}, false
}
return today, true
}
func (s *UserSubscription) canAutomaticallyResetWeeklyAt(now time.Time) bool {
@@ -121,6 +138,9 @@ func (s *UserSubscription) canAutomaticallyResetMonthlyAt(now time.Time) bool {
return ok
}
// automaticWindowStartAt 计算周/月窗口(期限对齐滚动窗口)的当前窗口起点。
// 窗口从锚点按整数个 period 步进,且不越过订阅到期时间,避免最后一个不完整
// 周期重复发放额度(issue #5051)。日窗口不走此函数,见 automaticDailyWindowStartAt。
func (s *UserSubscription) automaticWindowStartAt(previous *time.Time, period time.Duration, now time.Time) (time.Time, bool) {
if previous == nil {
return time.Time{}, false
@@ -155,7 +175,8 @@ func (s *UserSubscription) DailyResetTime() *time.Time {
t := s.ExpiresAt
return &t
}
t := s.DailyWindowStart.Add(24 * time.Hour)
// 日窗口按日历日对齐:下次刷新固定在窗口起点所在日的次日 0 点。
t := timezone.StartOfDay(*s.DailyWindowStart).AddDate(0, 0, 1)
return &t
}
@@ -6,6 +6,7 @@ import (
"testing"
"time"
"github.com/Wei-Shaw/sub2api/internal/pkg/timezone"
"github.com/stretchr/testify/require"
)
@@ -58,7 +59,7 @@ func TestAssignOrExtendSubscription_ExpiredDailyCardStartsNewOneTimeQuota(t *tes
require.True(t, renewed.StartsAt.After(oldStart), "重新购买过期订阅时应重置当前周期 StartsAt")
require.False(t, renewed.ExpiresAt.After(renewed.StartsAt.AddDate(0, 0, 1)))
require.NotNil(t, renewed.DailyWindowStart)
require.Equal(t, renewed.StartsAt, *renewed.DailyWindowStart)
require.Equal(t, timezone.StartOfDay(renewed.StartsAt), *renewed.DailyWindowStart, "续期后日窗口应锚定当天 0 点")
require.Equal(t, 0.0, renewed.DailyUsageUSD)
require.Equal(t, 0.0, renewed.WeeklyUsageUSD)
require.Equal(t, 0.0, renewed.MonthlyUsageUSD)
@@ -29,8 +29,13 @@ type UserSubscriptionRepository interface {
UpdateStatus(ctx context.Context, subscriptionID int64, status string) error
UpdateNotes(ctx context.Context, subscriptionID int64, notes string) error
ActivateWindows(ctx context.Context, id int64, start time.Time) error
ResetUsageWindows(ctx context.Context, id int64, resetDaily, resetWeekly, resetMonthly bool, newWindowStart time.Time) error
// ActivateWindows 首次使用时激活用量窗口。日窗口按日历日对齐,锚点为当天 0 点
// (dailyStart);周/月窗口为期限对齐滚动窗口,锚点为激活时刻(periodicStart)。
// 仅当三个窗口均未激活时生效。
ActivateWindows(ctx context.Context, id int64, dailyStart, periodicStart time.Time) error
// ResetUsageWindows 手动重置所选窗口的用量。日窗口锚点写入 dailyStart(当天 0 点,
// 保持 0 点刷新节奏不漂移);周/月窗口锚点写入 periodicStart(重置时刻)。
ResetUsageWindows(ctx context.Context, id int64, resetDaily, resetWeekly, resetMonthly bool, dailyStart, periodicStart time.Time) error
ResetDailyUsage(ctx context.Context, id int64, expectedWindowStart *time.Time, newWindowStart time.Time) error
ResetWeeklyUsage(ctx context.Context, id int64, expectedWindowStart *time.Time, newWindowStart time.Time) error
ResetMonthlyUsage(ctx context.Context, id int64, expectedWindowStart *time.Time, newWindowStart time.Time) error
@@ -59,13 +59,22 @@
<tbody>
<tr
v-for="m in sortedModels"
:key="m.name"
:key="`${m.platform}:${m.name}`"
class="border-b border-gray-100 transition-colors last:border-b-0 hover:bg-gray-50/70 dark:border-dark-800 dark:hover:bg-dark-800/50"
>
<!-- 模型名 + 非 token 计费模式徽章 -->
<td class="border-r border-gray-100 py-2.5 pl-5 pr-4 align-middle dark:border-dark-700/60">
<div class="flex flex-wrap items-center gap-1.5">
<span class="font-medium text-gray-900 dark:text-white">{{ m.name }}</span>
<span
v-if="platform && m.platform !== platform"
:class="[
'inline-flex items-center rounded-md px-1.5 py-0.5 text-[10px] font-medium',
platformBadgeLightClass(m.platform)
]"
>
{{ platformLabel(m.platform) }}
</span>
<span
v-if="billingMode(m) !== BILLING_MODE_TOKEN"
class="rounded-md bg-gray-100 px-1.5 py-0.5 text-[10px] font-medium text-gray-500 dark:bg-dark-700/70 dark:text-dark-300"
@@ -203,7 +212,7 @@
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { formatScaled } from '@/utils/pricing'
import { platformAccentColor } from '@/utils/platformColors'
import { platformAccentColor, platformBadgeLightClass, platformLabel } from '@/utils/platformColors'
import {
BILLING_MODE_TOKEN,
BILLING_MODE_IMAGE,
@@ -383,4 +383,25 @@ describe('PlazaModelPricingTable', () => {
// 旧 bug:image_output_price × 0.1 = 0.000003 被当按次价
expect(text).not.toContain('$0.000003')
})
it('Composite 分组中相同模型名按具体平台分别展示徽章', () => {
const anthropic = tokenModel({ name: 'shared-model', platform: 'anthropic' })
const openai = tokenModel({ name: 'shared-model', platform: 'openai' })
const wrapper = mount(PlazaModelPricingTable, {
props: {
models: [anthropic, openai],
platform: 'composite',
rateMultiplier: 1
}
})
const rows = wrapper.findAll('tbody tr')
expect(rows).toHaveLength(2)
expect(rows.map((row) => row.find('td').text())).toEqual([
'shared-modelAnthropic',
'shared-modelOpenAI'
])
expect(wrapper.text()).toContain('Anthropic')
expect(wrapper.text()).toContain('OpenAI')
})
})
@@ -114,6 +114,8 @@
<OpsErrorDetailsModal
:show="showErrorDetails"
:time-range="timeRange"
:custom-start-time="customStartTime"
:custom-end-time="customEndTime"
:platform="platform"
:group-id="groupId"
:error-type="errorDetailsType"
@@ -5,10 +5,13 @@ import BaseDialog from '@/components/common/BaseDialog.vue'
import Select from '@/components/common/Select.vue'
import OpsErrorLogTable from './OpsErrorLogTable.vue'
import { opsAPI, type OpsErrorLog } from '@/api/admin/ops'
import { buildOpsErrorTimeParams } from '../utils/opsErrorParams'
interface Props {
show: boolean
timeRange: string
customStartTime?: string | null
customEndTime?: string | null
platform?: string
groupId?: number | null
errorType: 'request' | 'upstream'
@@ -103,11 +106,11 @@ async function fetchErrorLogs() {
const params: Record<string, any> = {
page: page.value,
page_size: pageSize.value,
time_range: props.timeRange,
view: viewMode.value,
sort_by: sortBy.value,
sort_order: sortOrder.value
}
Object.assign(params, buildOpsErrorTimeParams(props.timeRange, props.customStartTime, props.customEndTime))
const platform = String(props.platform || '').trim()
if (platform) params.platform = platform
@@ -160,7 +163,7 @@ watch(
)
watch(
() => [props.timeRange, props.platform, props.groupId] as const,
() => [props.timeRange, props.customStartTime, props.customEndTime, props.platform, props.groupId] as const,
() => {
if (!props.show) return
page.value = 1
@@ -0,0 +1,16 @@
import { describe, expect, it } from 'vitest'
import { buildOpsErrorTimeParams } from '../opsErrorParams'
describe('buildOpsErrorTimeParams', () => {
it('uses explicit timestamps for a complete custom range', () => {
expect(buildOpsErrorTimeParams('custom', '2026-08-01T00:00:00Z', '2026-08-02T00:00:00Z')).toEqual({
start_time: '2026-08-01T00:00:00Z',
end_time: '2026-08-02T00:00:00Z'
})
})
it('preserves predefined ranges and falls back for incomplete custom ranges', () => {
expect(buildOpsErrorTimeParams('24h')).toEqual({ time_range: '24h' })
expect(buildOpsErrorTimeParams('custom', null, null)).toEqual({ time_range: '1h' })
})
})
@@ -0,0 +1,11 @@
export function buildOpsErrorTimeParams(
timeRange: string,
customStartTime?: string | null,
customEndTime?: string | null
): Record<string, string> {
if (timeRange === 'custom' && customStartTime && customEndTime) {
return { start_time: customStartTime, end_time: customEndTime }
}
return { time_range: timeRange === 'custom' ? '1h' : timeRange }
}