mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-14 12:02:27 +08:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
99b357083e | ||
|
|
32e4de7942 | ||
|
|
8f55e0a7cd | ||
|
|
5ec11885d1 | ||
|
|
c8af48d769 | ||
|
|
dce0f6b4e3 | ||
|
|
22ef761c27 | ||
|
|
865002fd2c | ||
|
|
93367b6db4 | ||
|
|
e93f6b9953 | ||
|
|
5b7a64334f | ||
|
|
82ccc187cd | ||
|
|
ce14983133 | ||
|
|
fc5a1b78d2 | ||
|
|
d3b5703b55 | ||
|
|
66ad405dd6 |
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
Reference in New Issue
Block a user