Compare commits

...

11 Commits

Author SHA1 Message Date
shaw
3fb43b91bf fix(security): 强化 usage 端点信息暴露控制 2026-01-08 17:45:31 +08:00
shaw
6e8188ed64 fix(antigravity): 修复请求频繁429的问题 2026-01-08 17:27:35 +08:00
shaw
db6f53e2c9 fix(billing): 修复客户端取消请求时计费丢失问题
检测 context.Canceled 作为客户端断开信号,返回已收集的 usage 而非错误
2026-01-08 11:25:17 +08:00
shaw
acabdc2f99 fix(i18n): correct priority description - lower value means higher priority 2026-01-08 09:56:26 +08:00
shaw
169aa4716e Merge branch 'feature/account-expires-at' into main: feat: add account expires-at field and auto-pause expired accounts 2026-01-08 09:27:57 +08:00
shaw
c0753320a0 Merge branch 'feat/usage-log-user-agent' 2026-01-08 09:16:48 +08:00
Edric Li
38d875b06f feat(update): 添加在线更新和定价数据获取的代理支持
针对国内服务器访问 GitHub 困难的问题,为在线更新和定价数据获取功能添加代理支持。

主要变更:
- 新增 update.proxy_url 配置项,支持 http/https/socks5/socks5h 协议
- 修改 GitHubReleaseClient 和 PricingRemoteClient 支持代理配置
- 更新 Wire 依赖注入,通过 Provider 函数传递配置
- 更新 Docker 配置文件,支持通过 UPDATE_PROXY_URL 环境变量设置代理

配置示例:
  update:
    proxy_url: "http://127.0.0.1:7890"

Docker 环境变量:
  UPDATE_PROXY_URL=http://host.docker.internal:7890

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-07 23:15:20 +08:00
Edric Li
1ada6cf768 feat(usage-log): 增加请求 User-Agent 记录
在使用记录中添加 user_agent 字段,用于记录 API 请求的 User-Agent 头信息,
便于分析客户端类型和调试。

变更内容:
- 新增数据库迁移 028_add_usage_logs_user_agent.sql
- 更新 UsageLog 模型和 Ent Schema 添加 user_agent 字段
- 更新 Repository 层的 Create 和 scanUsageLog 方法
- 更新 RecordUsageInput 结构体支持传入 UserAgent
- 更新 Claude/OpenAI/Gemini 三个网关 Handler 传递 UserAgent

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-07 22:49:46 +08:00
LLLLLLiulei
2b528c5f81 feat: auto-pause expired accounts 2026-01-07 16:59:35 +08:00
Xu Kang
f6dd4752e7 fix: 修复 Go 版本、包管理器和技术栈文档 (#195)
- backend/Dockerfile: Go 版本从 1.21 更新到 1.25.5(与 go.mod 一致)

- Makefile: 使用 pnpm 替代 npm(与 pnpm-lock.yaml 和 CI 一致)

- README.md/README_CN.md: 技术栈从 GORM 修正为 Ent
2026-01-07 16:35:51 +08:00
Xu Kang
b19c7875a4 fix(i18n): use correct translation key for dashboard redeem code description (#194)
Changed dashboard.addBalance to dashboard.addBalanceWithCode to match the existing translation key in locale files.
2026-01-07 15:01:07 +08:00
65 changed files with 1852 additions and 364 deletions

View File

@@ -9,7 +9,7 @@ build-backend:
# 编译前端(需要已安装依赖)
build-frontend:
@npm --prefix frontend run build
@pnpm --dir frontend run build
# 运行测试(后端 + 前端)
test: test-backend test-frontend
@@ -18,5 +18,5 @@ test-backend:
@$(MAKE) -C backend test
test-frontend:
@npm --prefix frontend run lint:check
@npm --prefix frontend run typecheck
@pnpm --dir frontend run lint:check
@pnpm --dir frontend run typecheck

View File

@@ -44,7 +44,7 @@ Sub2API is an AI API gateway platform designed to distribute and manage API quot
| Component | Technology |
|-----------|------------|
| Backend | Go 1.25.5, Gin, GORM |
| Backend | Go 1.25.5, Gin, Ent |
| Frontend | Vue 3.4+, Vite 5+, TailwindCSS |
| Database | PostgreSQL 15+ |
| Cache/Queue | Redis 7+ |

View File

@@ -44,7 +44,7 @@ Sub2API 是一个 AI API 网关平台,用于分发和管理 AI 产品订阅(
| 组件 | 技术 |
|------|------|
| 后端 | Go 1.25.5, Gin, GORM |
| 后端 | Go 1.25.5, Gin, Ent |
| 前端 | Vue 3.4+, Vite 5+, TailwindCSS |
| 数据库 | PostgreSQL 15+ |
| 缓存/队列 | Redis 7+ |

View File

@@ -1,4 +1,4 @@
FROM golang:1.21-alpine
FROM golang:1.25.5-alpine
WORKDIR /app

View File

@@ -63,6 +63,7 @@ func provideCleanup(
entClient *ent.Client,
rdb *redis.Client,
tokenRefresh *service.TokenRefreshService,
accountExpiry *service.AccountExpiryService,
pricing *service.PricingService,
emailQueue *service.EmailQueueService,
billingCache *service.BillingCacheService,
@@ -84,6 +85,10 @@ func provideCleanup(
tokenRefresh.Stop()
return nil
}},
{"AccountExpiryService", func() error {
accountExpiry.Stop()
return nil
}},
{"PricingService", func() error {
pricing.Stop()
return nil

View File

@@ -87,6 +87,7 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
geminiOAuthClient := repository.NewGeminiOAuthClient(configConfig)
geminiCliCodeAssistClient := repository.NewGeminiCliCodeAssistClient()
geminiOAuthService := service.NewGeminiOAuthService(proxyRepository, geminiOAuthClient, geminiCliCodeAssistClient, configConfig)
antigravityOAuthService := service.NewAntigravityOAuthService(proxyRepository)
geminiQuotaService := service.NewGeminiQuotaService(configConfig, settingRepository)
tempUnschedCache := repository.NewTempUnschedCache(redisClient)
rateLimitService := service.NewRateLimitService(accountRepository, usageLogRepository, configConfig, geminiQuotaService, tempUnschedCache)
@@ -97,13 +98,12 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
geminiTokenCache := repository.NewGeminiTokenCache(redisClient)
geminiTokenProvider := service.NewGeminiTokenProvider(accountRepository, geminiTokenCache, geminiOAuthService)
gatewayCache := repository.NewGatewayCache(redisClient)
antigravityOAuthService := service.NewAntigravityOAuthService(proxyRepository)
antigravityTokenProvider := service.NewAntigravityTokenProvider(accountRepository, geminiTokenCache, antigravityOAuthService)
httpUpstream := repository.NewHTTPUpstream(configConfig)
antigravityGatewayService := service.NewAntigravityGatewayService(accountRepository, gatewayCache, antigravityTokenProvider, rateLimitService, httpUpstream, settingService)
accountTestService := service.NewAccountTestService(accountRepository, geminiTokenProvider, antigravityGatewayService, httpUpstream, configConfig)
concurrencyCache := repository.ProvideConcurrencyCache(redisClient, configConfig)
concurrencyService := service.NewConcurrencyService(concurrencyCache)
concurrencyService := service.ProvideConcurrencyService(concurrencyCache, accountRepository, configConfig)
crsSyncService := service.NewCRSSyncService(accountRepository, proxyRepository, oAuthService, openAIOAuthService, geminiOAuthService, configConfig)
accountHandler := admin.NewAccountHandler(adminService, oAuthService, openAIOAuthService, geminiOAuthService, antigravityOAuthService, rateLimitService, accountUsageService, accountTestService, concurrencyService, crsSyncService)
oAuthHandler := admin.NewOAuthHandler(oAuthService)
@@ -114,7 +114,7 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
adminRedeemHandler := admin.NewRedeemHandler(adminService)
settingHandler := admin.NewSettingHandler(settingService, emailService, turnstileService)
updateCache := repository.NewUpdateCache(redisClient)
gitHubReleaseClient := repository.NewGitHubReleaseClient()
gitHubReleaseClient := repository.ProvideGitHubReleaseClient(configConfig)
serviceBuildInfo := provideServiceBuildInfo(buildInfo)
updateService := service.ProvideUpdateService(updateCache, gitHubReleaseClient, serviceBuildInfo)
systemHandler := handler.ProvideSystemHandler(updateService)
@@ -125,7 +125,7 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
userAttributeService := service.NewUserAttributeService(userAttributeDefinitionRepository, userAttributeValueRepository)
userAttributeHandler := admin.NewUserAttributeHandler(userAttributeService)
adminHandlers := handler.ProvideAdminHandlers(dashboardHandler, adminUserHandler, groupHandler, accountHandler, oAuthHandler, openAIOAuthHandler, geminiOAuthHandler, antigravityOAuthHandler, proxyHandler, adminRedeemHandler, settingHandler, systemHandler, adminSubscriptionHandler, adminUsageHandler, userAttributeHandler)
pricingRemoteClient := repository.NewPricingRemoteClient(configConfig)
pricingRemoteClient := repository.ProvidePricingRemoteClient(configConfig)
pricingService, err := service.ProvidePricingService(configConfig, pricingRemoteClient)
if err != nil {
return nil, err
@@ -148,7 +148,8 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
engine := server.ProvideRouter(configConfig, handlers, jwtAuthMiddleware, adminAuthMiddleware, apiKeyAuthMiddleware, apiKeyService, subscriptionService)
httpServer := server.ProvideHTTPServer(configConfig, engine)
tokenRefreshService := service.ProvideTokenRefreshService(accountRepository, oAuthService, openAIOAuthService, geminiOAuthService, antigravityOAuthService, configConfig)
v := provideCleanup(client, redisClient, tokenRefreshService, pricingService, emailQueueService, billingCacheService, oAuthService, openAIOAuthService, geminiOAuthService, antigravityOAuthService)
accountExpiryService := service.ProvideAccountExpiryService(accountRepository)
v := provideCleanup(client, redisClient, tokenRefreshService, accountExpiryService, pricingService, emailQueueService, billingCacheService, oAuthService, openAIOAuthService, geminiOAuthService, antigravityOAuthService)
application := &Application{
Server: httpServer,
Cleanup: v,
@@ -174,6 +175,7 @@ func provideCleanup(
entClient *ent.Client,
rdb *redis.Client,
tokenRefresh *service.TokenRefreshService,
accountExpiry *service.AccountExpiryService,
pricing *service.PricingService,
emailQueue *service.EmailQueueService,
billingCache *service.BillingCacheService,
@@ -194,6 +196,10 @@ func provideCleanup(
tokenRefresh.Stop()
return nil
}},
{"AccountExpiryService", func() error {
accountExpiry.Stop()
return nil
}},
{"PricingService", func() error {
pricing.Stop()
return nil

View File

@@ -49,6 +49,10 @@ type Account struct {
ErrorMessage *string `json:"error_message,omitempty"`
// LastUsedAt holds the value of the "last_used_at" field.
LastUsedAt *time.Time `json:"last_used_at,omitempty"`
// Account expiration time (NULL means no expiration).
ExpiresAt *time.Time `json:"expires_at,omitempty"`
// Auto pause scheduling when account expires.
AutoPauseOnExpired bool `json:"auto_pause_on_expired,omitempty"`
// Schedulable holds the value of the "schedulable" field.
Schedulable bool `json:"schedulable,omitempty"`
// RateLimitedAt holds the value of the "rate_limited_at" field.
@@ -129,13 +133,13 @@ func (*Account) scanValues(columns []string) ([]any, error) {
switch columns[i] {
case account.FieldCredentials, account.FieldExtra:
values[i] = new([]byte)
case account.FieldSchedulable:
case account.FieldAutoPauseOnExpired, account.FieldSchedulable:
values[i] = new(sql.NullBool)
case account.FieldID, account.FieldProxyID, account.FieldConcurrency, account.FieldPriority:
values[i] = new(sql.NullInt64)
case account.FieldName, account.FieldNotes, account.FieldPlatform, account.FieldType, account.FieldStatus, account.FieldErrorMessage, account.FieldSessionWindowStatus:
values[i] = new(sql.NullString)
case account.FieldCreatedAt, account.FieldUpdatedAt, account.FieldDeletedAt, account.FieldLastUsedAt, account.FieldRateLimitedAt, account.FieldRateLimitResetAt, account.FieldOverloadUntil, account.FieldSessionWindowStart, account.FieldSessionWindowEnd:
case account.FieldCreatedAt, account.FieldUpdatedAt, account.FieldDeletedAt, account.FieldLastUsedAt, account.FieldExpiresAt, account.FieldRateLimitedAt, account.FieldRateLimitResetAt, account.FieldOverloadUntil, account.FieldSessionWindowStart, account.FieldSessionWindowEnd:
values[i] = new(sql.NullTime)
default:
values[i] = new(sql.UnknownType)
@@ -257,6 +261,19 @@ func (_m *Account) assignValues(columns []string, values []any) error {
_m.LastUsedAt = new(time.Time)
*_m.LastUsedAt = value.Time
}
case account.FieldExpiresAt:
if value, ok := values[i].(*sql.NullTime); !ok {
return fmt.Errorf("unexpected type %T for field expires_at", values[i])
} else if value.Valid {
_m.ExpiresAt = new(time.Time)
*_m.ExpiresAt = value.Time
}
case account.FieldAutoPauseOnExpired:
if value, ok := values[i].(*sql.NullBool); !ok {
return fmt.Errorf("unexpected type %T for field auto_pause_on_expired", values[i])
} else if value.Valid {
_m.AutoPauseOnExpired = value.Bool
}
case account.FieldSchedulable:
if value, ok := values[i].(*sql.NullBool); !ok {
return fmt.Errorf("unexpected type %T for field schedulable", values[i])
@@ -416,6 +433,14 @@ func (_m *Account) String() string {
builder.WriteString(v.Format(time.ANSIC))
}
builder.WriteString(", ")
if v := _m.ExpiresAt; v != nil {
builder.WriteString("expires_at=")
builder.WriteString(v.Format(time.ANSIC))
}
builder.WriteString(", ")
builder.WriteString("auto_pause_on_expired=")
builder.WriteString(fmt.Sprintf("%v", _m.AutoPauseOnExpired))
builder.WriteString(", ")
builder.WriteString("schedulable=")
builder.WriteString(fmt.Sprintf("%v", _m.Schedulable))
builder.WriteString(", ")

View File

@@ -45,6 +45,10 @@ const (
FieldErrorMessage = "error_message"
// FieldLastUsedAt holds the string denoting the last_used_at field in the database.
FieldLastUsedAt = "last_used_at"
// FieldExpiresAt holds the string denoting the expires_at field in the database.
FieldExpiresAt = "expires_at"
// FieldAutoPauseOnExpired holds the string denoting the auto_pause_on_expired field in the database.
FieldAutoPauseOnExpired = "auto_pause_on_expired"
// FieldSchedulable holds the string denoting the schedulable field in the database.
FieldSchedulable = "schedulable"
// FieldRateLimitedAt holds the string denoting the rate_limited_at field in the database.
@@ -115,6 +119,8 @@ var Columns = []string{
FieldStatus,
FieldErrorMessage,
FieldLastUsedAt,
FieldExpiresAt,
FieldAutoPauseOnExpired,
FieldSchedulable,
FieldRateLimitedAt,
FieldRateLimitResetAt,
@@ -172,6 +178,8 @@ var (
DefaultStatus string
// StatusValidator is a validator for the "status" field. It is called by the builders before save.
StatusValidator func(string) error
// DefaultAutoPauseOnExpired holds the default value on creation for the "auto_pause_on_expired" field.
DefaultAutoPauseOnExpired bool
// DefaultSchedulable holds the default value on creation for the "schedulable" field.
DefaultSchedulable bool
// SessionWindowStatusValidator is a validator for the "session_window_status" field. It is called by the builders before save.
@@ -251,6 +259,16 @@ func ByLastUsedAt(opts ...sql.OrderTermOption) OrderOption {
return sql.OrderByField(FieldLastUsedAt, opts...).ToFunc()
}
// ByExpiresAt orders the results by the expires_at field.
func ByExpiresAt(opts ...sql.OrderTermOption) OrderOption {
return sql.OrderByField(FieldExpiresAt, opts...).ToFunc()
}
// ByAutoPauseOnExpired orders the results by the auto_pause_on_expired field.
func ByAutoPauseOnExpired(opts ...sql.OrderTermOption) OrderOption {
return sql.OrderByField(FieldAutoPauseOnExpired, opts...).ToFunc()
}
// BySchedulable orders the results by the schedulable field.
func BySchedulable(opts ...sql.OrderTermOption) OrderOption {
return sql.OrderByField(FieldSchedulable, opts...).ToFunc()

View File

@@ -120,6 +120,16 @@ func LastUsedAt(v time.Time) predicate.Account {
return predicate.Account(sql.FieldEQ(FieldLastUsedAt, v))
}
// ExpiresAt applies equality check predicate on the "expires_at" field. It's identical to ExpiresAtEQ.
func ExpiresAt(v time.Time) predicate.Account {
return predicate.Account(sql.FieldEQ(FieldExpiresAt, v))
}
// AutoPauseOnExpired applies equality check predicate on the "auto_pause_on_expired" field. It's identical to AutoPauseOnExpiredEQ.
func AutoPauseOnExpired(v bool) predicate.Account {
return predicate.Account(sql.FieldEQ(FieldAutoPauseOnExpired, v))
}
// Schedulable applies equality check predicate on the "schedulable" field. It's identical to SchedulableEQ.
func Schedulable(v bool) predicate.Account {
return predicate.Account(sql.FieldEQ(FieldSchedulable, v))
@@ -855,6 +865,66 @@ func LastUsedAtNotNil() predicate.Account {
return predicate.Account(sql.FieldNotNull(FieldLastUsedAt))
}
// ExpiresAtEQ applies the EQ predicate on the "expires_at" field.
func ExpiresAtEQ(v time.Time) predicate.Account {
return predicate.Account(sql.FieldEQ(FieldExpiresAt, v))
}
// ExpiresAtNEQ applies the NEQ predicate on the "expires_at" field.
func ExpiresAtNEQ(v time.Time) predicate.Account {
return predicate.Account(sql.FieldNEQ(FieldExpiresAt, v))
}
// ExpiresAtIn applies the In predicate on the "expires_at" field.
func ExpiresAtIn(vs ...time.Time) predicate.Account {
return predicate.Account(sql.FieldIn(FieldExpiresAt, vs...))
}
// ExpiresAtNotIn applies the NotIn predicate on the "expires_at" field.
func ExpiresAtNotIn(vs ...time.Time) predicate.Account {
return predicate.Account(sql.FieldNotIn(FieldExpiresAt, vs...))
}
// ExpiresAtGT applies the GT predicate on the "expires_at" field.
func ExpiresAtGT(v time.Time) predicate.Account {
return predicate.Account(sql.FieldGT(FieldExpiresAt, v))
}
// ExpiresAtGTE applies the GTE predicate on the "expires_at" field.
func ExpiresAtGTE(v time.Time) predicate.Account {
return predicate.Account(sql.FieldGTE(FieldExpiresAt, v))
}
// ExpiresAtLT applies the LT predicate on the "expires_at" field.
func ExpiresAtLT(v time.Time) predicate.Account {
return predicate.Account(sql.FieldLT(FieldExpiresAt, v))
}
// ExpiresAtLTE applies the LTE predicate on the "expires_at" field.
func ExpiresAtLTE(v time.Time) predicate.Account {
return predicate.Account(sql.FieldLTE(FieldExpiresAt, v))
}
// ExpiresAtIsNil applies the IsNil predicate on the "expires_at" field.
func ExpiresAtIsNil() predicate.Account {
return predicate.Account(sql.FieldIsNull(FieldExpiresAt))
}
// ExpiresAtNotNil applies the NotNil predicate on the "expires_at" field.
func ExpiresAtNotNil() predicate.Account {
return predicate.Account(sql.FieldNotNull(FieldExpiresAt))
}
// AutoPauseOnExpiredEQ applies the EQ predicate on the "auto_pause_on_expired" field.
func AutoPauseOnExpiredEQ(v bool) predicate.Account {
return predicate.Account(sql.FieldEQ(FieldAutoPauseOnExpired, v))
}
// AutoPauseOnExpiredNEQ applies the NEQ predicate on the "auto_pause_on_expired" field.
func AutoPauseOnExpiredNEQ(v bool) predicate.Account {
return predicate.Account(sql.FieldNEQ(FieldAutoPauseOnExpired, v))
}
// SchedulableEQ applies the EQ predicate on the "schedulable" field.
func SchedulableEQ(v bool) predicate.Account {
return predicate.Account(sql.FieldEQ(FieldSchedulable, v))

View File

@@ -195,6 +195,34 @@ func (_c *AccountCreate) SetNillableLastUsedAt(v *time.Time) *AccountCreate {
return _c
}
// SetExpiresAt sets the "expires_at" field.
func (_c *AccountCreate) SetExpiresAt(v time.Time) *AccountCreate {
_c.mutation.SetExpiresAt(v)
return _c
}
// SetNillableExpiresAt sets the "expires_at" field if the given value is not nil.
func (_c *AccountCreate) SetNillableExpiresAt(v *time.Time) *AccountCreate {
if v != nil {
_c.SetExpiresAt(*v)
}
return _c
}
// SetAutoPauseOnExpired sets the "auto_pause_on_expired" field.
func (_c *AccountCreate) SetAutoPauseOnExpired(v bool) *AccountCreate {
_c.mutation.SetAutoPauseOnExpired(v)
return _c
}
// SetNillableAutoPauseOnExpired sets the "auto_pause_on_expired" field if the given value is not nil.
func (_c *AccountCreate) SetNillableAutoPauseOnExpired(v *bool) *AccountCreate {
if v != nil {
_c.SetAutoPauseOnExpired(*v)
}
return _c
}
// SetSchedulable sets the "schedulable" field.
func (_c *AccountCreate) SetSchedulable(v bool) *AccountCreate {
_c.mutation.SetSchedulable(v)
@@ -405,6 +433,10 @@ func (_c *AccountCreate) defaults() error {
v := account.DefaultStatus
_c.mutation.SetStatus(v)
}
if _, ok := _c.mutation.AutoPauseOnExpired(); !ok {
v := account.DefaultAutoPauseOnExpired
_c.mutation.SetAutoPauseOnExpired(v)
}
if _, ok := _c.mutation.Schedulable(); !ok {
v := account.DefaultSchedulable
_c.mutation.SetSchedulable(v)
@@ -464,6 +496,9 @@ func (_c *AccountCreate) check() error {
return &ValidationError{Name: "status", err: fmt.Errorf(`ent: validator failed for field "Account.status": %w`, err)}
}
}
if _, ok := _c.mutation.AutoPauseOnExpired(); !ok {
return &ValidationError{Name: "auto_pause_on_expired", err: errors.New(`ent: missing required field "Account.auto_pause_on_expired"`)}
}
if _, ok := _c.mutation.Schedulable(); !ok {
return &ValidationError{Name: "schedulable", err: errors.New(`ent: missing required field "Account.schedulable"`)}
}
@@ -555,6 +590,14 @@ func (_c *AccountCreate) createSpec() (*Account, *sqlgraph.CreateSpec) {
_spec.SetField(account.FieldLastUsedAt, field.TypeTime, value)
_node.LastUsedAt = &value
}
if value, ok := _c.mutation.ExpiresAt(); ok {
_spec.SetField(account.FieldExpiresAt, field.TypeTime, value)
_node.ExpiresAt = &value
}
if value, ok := _c.mutation.AutoPauseOnExpired(); ok {
_spec.SetField(account.FieldAutoPauseOnExpired, field.TypeBool, value)
_node.AutoPauseOnExpired = value
}
if value, ok := _c.mutation.Schedulable(); ok {
_spec.SetField(account.FieldSchedulable, field.TypeBool, value)
_node.Schedulable = value
@@ -898,6 +941,36 @@ func (u *AccountUpsert) ClearLastUsedAt() *AccountUpsert {
return u
}
// SetExpiresAt sets the "expires_at" field.
func (u *AccountUpsert) SetExpiresAt(v time.Time) *AccountUpsert {
u.Set(account.FieldExpiresAt, v)
return u
}
// UpdateExpiresAt sets the "expires_at" field to the value that was provided on create.
func (u *AccountUpsert) UpdateExpiresAt() *AccountUpsert {
u.SetExcluded(account.FieldExpiresAt)
return u
}
// ClearExpiresAt clears the value of the "expires_at" field.
func (u *AccountUpsert) ClearExpiresAt() *AccountUpsert {
u.SetNull(account.FieldExpiresAt)
return u
}
// SetAutoPauseOnExpired sets the "auto_pause_on_expired" field.
func (u *AccountUpsert) SetAutoPauseOnExpired(v bool) *AccountUpsert {
u.Set(account.FieldAutoPauseOnExpired, v)
return u
}
// UpdateAutoPauseOnExpired sets the "auto_pause_on_expired" field to the value that was provided on create.
func (u *AccountUpsert) UpdateAutoPauseOnExpired() *AccountUpsert {
u.SetExcluded(account.FieldAutoPauseOnExpired)
return u
}
// SetSchedulable sets the "schedulable" field.
func (u *AccountUpsert) SetSchedulable(v bool) *AccountUpsert {
u.Set(account.FieldSchedulable, v)
@@ -1308,6 +1381,41 @@ func (u *AccountUpsertOne) ClearLastUsedAt() *AccountUpsertOne {
})
}
// SetExpiresAt sets the "expires_at" field.
func (u *AccountUpsertOne) SetExpiresAt(v time.Time) *AccountUpsertOne {
return u.Update(func(s *AccountUpsert) {
s.SetExpiresAt(v)
})
}
// UpdateExpiresAt sets the "expires_at" field to the value that was provided on create.
func (u *AccountUpsertOne) UpdateExpiresAt() *AccountUpsertOne {
return u.Update(func(s *AccountUpsert) {
s.UpdateExpiresAt()
})
}
// ClearExpiresAt clears the value of the "expires_at" field.
func (u *AccountUpsertOne) ClearExpiresAt() *AccountUpsertOne {
return u.Update(func(s *AccountUpsert) {
s.ClearExpiresAt()
})
}
// SetAutoPauseOnExpired sets the "auto_pause_on_expired" field.
func (u *AccountUpsertOne) SetAutoPauseOnExpired(v bool) *AccountUpsertOne {
return u.Update(func(s *AccountUpsert) {
s.SetAutoPauseOnExpired(v)
})
}
// UpdateAutoPauseOnExpired sets the "auto_pause_on_expired" field to the value that was provided on create.
func (u *AccountUpsertOne) UpdateAutoPauseOnExpired() *AccountUpsertOne {
return u.Update(func(s *AccountUpsert) {
s.UpdateAutoPauseOnExpired()
})
}
// SetSchedulable sets the "schedulable" field.
func (u *AccountUpsertOne) SetSchedulable(v bool) *AccountUpsertOne {
return u.Update(func(s *AccountUpsert) {
@@ -1904,6 +2012,41 @@ func (u *AccountUpsertBulk) ClearLastUsedAt() *AccountUpsertBulk {
})
}
// SetExpiresAt sets the "expires_at" field.
func (u *AccountUpsertBulk) SetExpiresAt(v time.Time) *AccountUpsertBulk {
return u.Update(func(s *AccountUpsert) {
s.SetExpiresAt(v)
})
}
// UpdateExpiresAt sets the "expires_at" field to the value that was provided on create.
func (u *AccountUpsertBulk) UpdateExpiresAt() *AccountUpsertBulk {
return u.Update(func(s *AccountUpsert) {
s.UpdateExpiresAt()
})
}
// ClearExpiresAt clears the value of the "expires_at" field.
func (u *AccountUpsertBulk) ClearExpiresAt() *AccountUpsertBulk {
return u.Update(func(s *AccountUpsert) {
s.ClearExpiresAt()
})
}
// SetAutoPauseOnExpired sets the "auto_pause_on_expired" field.
func (u *AccountUpsertBulk) SetAutoPauseOnExpired(v bool) *AccountUpsertBulk {
return u.Update(func(s *AccountUpsert) {
s.SetAutoPauseOnExpired(v)
})
}
// UpdateAutoPauseOnExpired sets the "auto_pause_on_expired" field to the value that was provided on create.
func (u *AccountUpsertBulk) UpdateAutoPauseOnExpired() *AccountUpsertBulk {
return u.Update(func(s *AccountUpsert) {
s.UpdateAutoPauseOnExpired()
})
}
// SetSchedulable sets the "schedulable" field.
func (u *AccountUpsertBulk) SetSchedulable(v bool) *AccountUpsertBulk {
return u.Update(func(s *AccountUpsert) {

View File

@@ -247,6 +247,40 @@ func (_u *AccountUpdate) ClearLastUsedAt() *AccountUpdate {
return _u
}
// SetExpiresAt sets the "expires_at" field.
func (_u *AccountUpdate) SetExpiresAt(v time.Time) *AccountUpdate {
_u.mutation.SetExpiresAt(v)
return _u
}
// SetNillableExpiresAt sets the "expires_at" field if the given value is not nil.
func (_u *AccountUpdate) SetNillableExpiresAt(v *time.Time) *AccountUpdate {
if v != nil {
_u.SetExpiresAt(*v)
}
return _u
}
// ClearExpiresAt clears the value of the "expires_at" field.
func (_u *AccountUpdate) ClearExpiresAt() *AccountUpdate {
_u.mutation.ClearExpiresAt()
return _u
}
// SetAutoPauseOnExpired sets the "auto_pause_on_expired" field.
func (_u *AccountUpdate) SetAutoPauseOnExpired(v bool) *AccountUpdate {
_u.mutation.SetAutoPauseOnExpired(v)
return _u
}
// SetNillableAutoPauseOnExpired sets the "auto_pause_on_expired" field if the given value is not nil.
func (_u *AccountUpdate) SetNillableAutoPauseOnExpired(v *bool) *AccountUpdate {
if v != nil {
_u.SetAutoPauseOnExpired(*v)
}
return _u
}
// SetSchedulable sets the "schedulable" field.
func (_u *AccountUpdate) SetSchedulable(v bool) *AccountUpdate {
_u.mutation.SetSchedulable(v)
@@ -610,6 +644,15 @@ func (_u *AccountUpdate) sqlSave(ctx context.Context) (_node int, err error) {
if _u.mutation.LastUsedAtCleared() {
_spec.ClearField(account.FieldLastUsedAt, field.TypeTime)
}
if value, ok := _u.mutation.ExpiresAt(); ok {
_spec.SetField(account.FieldExpiresAt, field.TypeTime, value)
}
if _u.mutation.ExpiresAtCleared() {
_spec.ClearField(account.FieldExpiresAt, field.TypeTime)
}
if value, ok := _u.mutation.AutoPauseOnExpired(); ok {
_spec.SetField(account.FieldAutoPauseOnExpired, field.TypeBool, value)
}
if value, ok := _u.mutation.Schedulable(); ok {
_spec.SetField(account.FieldSchedulable, field.TypeBool, value)
}
@@ -1016,6 +1059,40 @@ func (_u *AccountUpdateOne) ClearLastUsedAt() *AccountUpdateOne {
return _u
}
// SetExpiresAt sets the "expires_at" field.
func (_u *AccountUpdateOne) SetExpiresAt(v time.Time) *AccountUpdateOne {
_u.mutation.SetExpiresAt(v)
return _u
}
// SetNillableExpiresAt sets the "expires_at" field if the given value is not nil.
func (_u *AccountUpdateOne) SetNillableExpiresAt(v *time.Time) *AccountUpdateOne {
if v != nil {
_u.SetExpiresAt(*v)
}
return _u
}
// ClearExpiresAt clears the value of the "expires_at" field.
func (_u *AccountUpdateOne) ClearExpiresAt() *AccountUpdateOne {
_u.mutation.ClearExpiresAt()
return _u
}
// SetAutoPauseOnExpired sets the "auto_pause_on_expired" field.
func (_u *AccountUpdateOne) SetAutoPauseOnExpired(v bool) *AccountUpdateOne {
_u.mutation.SetAutoPauseOnExpired(v)
return _u
}
// SetNillableAutoPauseOnExpired sets the "auto_pause_on_expired" field if the given value is not nil.
func (_u *AccountUpdateOne) SetNillableAutoPauseOnExpired(v *bool) *AccountUpdateOne {
if v != nil {
_u.SetAutoPauseOnExpired(*v)
}
return _u
}
// SetSchedulable sets the "schedulable" field.
func (_u *AccountUpdateOne) SetSchedulable(v bool) *AccountUpdateOne {
_u.mutation.SetSchedulable(v)
@@ -1409,6 +1486,15 @@ func (_u *AccountUpdateOne) sqlSave(ctx context.Context) (_node *Account, err er
if _u.mutation.LastUsedAtCleared() {
_spec.ClearField(account.FieldLastUsedAt, field.TypeTime)
}
if value, ok := _u.mutation.ExpiresAt(); ok {
_spec.SetField(account.FieldExpiresAt, field.TypeTime, value)
}
if _u.mutation.ExpiresAtCleared() {
_spec.ClearField(account.FieldExpiresAt, field.TypeTime)
}
if value, ok := _u.mutation.AutoPauseOnExpired(); ok {
_spec.SetField(account.FieldAutoPauseOnExpired, field.TypeBool, value)
}
if value, ok := _u.mutation.Schedulable(); ok {
_spec.SetField(account.FieldSchedulable, field.TypeBool, value)
}

View File

@@ -80,6 +80,8 @@ var (
{Name: "status", Type: field.TypeString, Size: 20, Default: "active"},
{Name: "error_message", Type: field.TypeString, Nullable: true, SchemaType: map[string]string{"postgres": "text"}},
{Name: "last_used_at", Type: field.TypeTime, Nullable: true, SchemaType: map[string]string{"postgres": "timestamptz"}},
{Name: "expires_at", Type: field.TypeTime, Nullable: true, SchemaType: map[string]string{"postgres": "timestamptz"}},
{Name: "auto_pause_on_expired", Type: field.TypeBool, Default: true},
{Name: "schedulable", Type: field.TypeBool, Default: true},
{Name: "rate_limited_at", Type: field.TypeTime, Nullable: true, SchemaType: map[string]string{"postgres": "timestamptz"}},
{Name: "rate_limit_reset_at", Type: field.TypeTime, Nullable: true, SchemaType: map[string]string{"postgres": "timestamptz"}},
@@ -97,7 +99,7 @@ var (
ForeignKeys: []*schema.ForeignKey{
{
Symbol: "accounts_proxies_proxy",
Columns: []*schema.Column{AccountsColumns[22]},
Columns: []*schema.Column{AccountsColumns[24]},
RefColumns: []*schema.Column{ProxiesColumns[0]},
OnDelete: schema.SetNull,
},
@@ -121,7 +123,7 @@ var (
{
Name: "account_proxy_id",
Unique: false,
Columns: []*schema.Column{AccountsColumns[22]},
Columns: []*schema.Column{AccountsColumns[24]},
},
{
Name: "account_priority",
@@ -136,22 +138,22 @@ var (
{
Name: "account_schedulable",
Unique: false,
Columns: []*schema.Column{AccountsColumns[15]},
Columns: []*schema.Column{AccountsColumns[17]},
},
{
Name: "account_rate_limited_at",
Unique: false,
Columns: []*schema.Column{AccountsColumns[16]},
Columns: []*schema.Column{AccountsColumns[18]},
},
{
Name: "account_rate_limit_reset_at",
Unique: false,
Columns: []*schema.Column{AccountsColumns[17]},
Columns: []*schema.Column{AccountsColumns[19]},
},
{
Name: "account_overload_until",
Unique: false,
Columns: []*schema.Column{AccountsColumns[18]},
Columns: []*schema.Column{AccountsColumns[20]},
},
{
Name: "account_deleted_at",
@@ -371,6 +373,7 @@ var (
{Name: "stream", Type: field.TypeBool, Default: false},
{Name: "duration_ms", Type: field.TypeInt, Nullable: true},
{Name: "first_token_ms", Type: field.TypeInt, Nullable: true},
{Name: "user_agent", Type: field.TypeString, Nullable: true, Size: 512},
{Name: "image_count", Type: field.TypeInt, Default: 0},
{Name: "image_size", Type: field.TypeString, Nullable: true, Size: 10},
{Name: "created_at", Type: field.TypeTime, SchemaType: map[string]string{"postgres": "timestamptz"}},
@@ -388,31 +391,31 @@ var (
ForeignKeys: []*schema.ForeignKey{
{
Symbol: "usage_logs_api_keys_usage_logs",
Columns: []*schema.Column{UsageLogsColumns[23]},
Columns: []*schema.Column{UsageLogsColumns[24]},
RefColumns: []*schema.Column{APIKeysColumns[0]},
OnDelete: schema.NoAction,
},
{
Symbol: "usage_logs_accounts_usage_logs",
Columns: []*schema.Column{UsageLogsColumns[24]},
Columns: []*schema.Column{UsageLogsColumns[25]},
RefColumns: []*schema.Column{AccountsColumns[0]},
OnDelete: schema.NoAction,
},
{
Symbol: "usage_logs_groups_usage_logs",
Columns: []*schema.Column{UsageLogsColumns[25]},
Columns: []*schema.Column{UsageLogsColumns[26]},
RefColumns: []*schema.Column{GroupsColumns[0]},
OnDelete: schema.SetNull,
},
{
Symbol: "usage_logs_users_usage_logs",
Columns: []*schema.Column{UsageLogsColumns[26]},
Columns: []*schema.Column{UsageLogsColumns[27]},
RefColumns: []*schema.Column{UsersColumns[0]},
OnDelete: schema.NoAction,
},
{
Symbol: "usage_logs_user_subscriptions_usage_logs",
Columns: []*schema.Column{UsageLogsColumns[27]},
Columns: []*schema.Column{UsageLogsColumns[28]},
RefColumns: []*schema.Column{UserSubscriptionsColumns[0]},
OnDelete: schema.SetNull,
},
@@ -421,32 +424,32 @@ var (
{
Name: "usagelog_user_id",
Unique: false,
Columns: []*schema.Column{UsageLogsColumns[26]},
Columns: []*schema.Column{UsageLogsColumns[27]},
},
{
Name: "usagelog_api_key_id",
Unique: false,
Columns: []*schema.Column{UsageLogsColumns[23]},
Columns: []*schema.Column{UsageLogsColumns[24]},
},
{
Name: "usagelog_account_id",
Unique: false,
Columns: []*schema.Column{UsageLogsColumns[24]},
Columns: []*schema.Column{UsageLogsColumns[25]},
},
{
Name: "usagelog_group_id",
Unique: false,
Columns: []*schema.Column{UsageLogsColumns[25]},
Columns: []*schema.Column{UsageLogsColumns[26]},
},
{
Name: "usagelog_subscription_id",
Unique: false,
Columns: []*schema.Column{UsageLogsColumns[27]},
Columns: []*schema.Column{UsageLogsColumns[28]},
},
{
Name: "usagelog_created_at",
Unique: false,
Columns: []*schema.Column{UsageLogsColumns[22]},
Columns: []*schema.Column{UsageLogsColumns[23]},
},
{
Name: "usagelog_model",
@@ -461,12 +464,12 @@ var (
{
Name: "usagelog_user_id_created_at",
Unique: false,
Columns: []*schema.Column{UsageLogsColumns[26], UsageLogsColumns[22]},
Columns: []*schema.Column{UsageLogsColumns[27], UsageLogsColumns[23]},
},
{
Name: "usagelog_api_key_id_created_at",
Unique: false,
Columns: []*schema.Column{UsageLogsColumns[23], UsageLogsColumns[22]},
Columns: []*schema.Column{UsageLogsColumns[24], UsageLogsColumns[23]},
},
},
}

View File

@@ -1006,6 +1006,8 @@ type AccountMutation struct {
status *string
error_message *string
last_used_at *time.Time
expires_at *time.Time
auto_pause_on_expired *bool
schedulable *bool
rate_limited_at *time.Time
rate_limit_reset_at *time.Time
@@ -1770,6 +1772,91 @@ func (m *AccountMutation) ResetLastUsedAt() {
delete(m.clearedFields, account.FieldLastUsedAt)
}
// SetExpiresAt sets the "expires_at" field.
func (m *AccountMutation) SetExpiresAt(t time.Time) {
m.expires_at = &t
}
// ExpiresAt returns the value of the "expires_at" field in the mutation.
func (m *AccountMutation) ExpiresAt() (r time.Time, exists bool) {
v := m.expires_at
if v == nil {
return
}
return *v, true
}
// OldExpiresAt returns the old "expires_at" field's value of the Account entity.
// If the Account object wasn't provided to the builder, the object is fetched from the database.
// An error is returned if the mutation operation is not UpdateOne, or the database query fails.
func (m *AccountMutation) OldExpiresAt(ctx context.Context) (v *time.Time, err error) {
if !m.op.Is(OpUpdateOne) {
return v, errors.New("OldExpiresAt is only allowed on UpdateOne operations")
}
if m.id == nil || m.oldValue == nil {
return v, errors.New("OldExpiresAt requires an ID field in the mutation")
}
oldValue, err := m.oldValue(ctx)
if err != nil {
return v, fmt.Errorf("querying old value for OldExpiresAt: %w", err)
}
return oldValue.ExpiresAt, nil
}
// ClearExpiresAt clears the value of the "expires_at" field.
func (m *AccountMutation) ClearExpiresAt() {
m.expires_at = nil
m.clearedFields[account.FieldExpiresAt] = struct{}{}
}
// ExpiresAtCleared returns if the "expires_at" field was cleared in this mutation.
func (m *AccountMutation) ExpiresAtCleared() bool {
_, ok := m.clearedFields[account.FieldExpiresAt]
return ok
}
// ResetExpiresAt resets all changes to the "expires_at" field.
func (m *AccountMutation) ResetExpiresAt() {
m.expires_at = nil
delete(m.clearedFields, account.FieldExpiresAt)
}
// SetAutoPauseOnExpired sets the "auto_pause_on_expired" field.
func (m *AccountMutation) SetAutoPauseOnExpired(b bool) {
m.auto_pause_on_expired = &b
}
// AutoPauseOnExpired returns the value of the "auto_pause_on_expired" field in the mutation.
func (m *AccountMutation) AutoPauseOnExpired() (r bool, exists bool) {
v := m.auto_pause_on_expired
if v == nil {
return
}
return *v, true
}
// OldAutoPauseOnExpired returns the old "auto_pause_on_expired" field's value of the Account entity.
// If the Account object wasn't provided to the builder, the object is fetched from the database.
// An error is returned if the mutation operation is not UpdateOne, or the database query fails.
func (m *AccountMutation) OldAutoPauseOnExpired(ctx context.Context) (v bool, err error) {
if !m.op.Is(OpUpdateOne) {
return v, errors.New("OldAutoPauseOnExpired is only allowed on UpdateOne operations")
}
if m.id == nil || m.oldValue == nil {
return v, errors.New("OldAutoPauseOnExpired requires an ID field in the mutation")
}
oldValue, err := m.oldValue(ctx)
if err != nil {
return v, fmt.Errorf("querying old value for OldAutoPauseOnExpired: %w", err)
}
return oldValue.AutoPauseOnExpired, nil
}
// ResetAutoPauseOnExpired resets all changes to the "auto_pause_on_expired" field.
func (m *AccountMutation) ResetAutoPauseOnExpired() {
m.auto_pause_on_expired = nil
}
// SetSchedulable sets the "schedulable" field.
func (m *AccountMutation) SetSchedulable(b bool) {
m.schedulable = &b
@@ -2269,7 +2356,7 @@ func (m *AccountMutation) Type() string {
// order to get all numeric fields that were incremented/decremented, call
// AddedFields().
func (m *AccountMutation) Fields() []string {
fields := make([]string, 0, 22)
fields := make([]string, 0, 24)
if m.created_at != nil {
fields = append(fields, account.FieldCreatedAt)
}
@@ -2315,6 +2402,12 @@ func (m *AccountMutation) Fields() []string {
if m.last_used_at != nil {
fields = append(fields, account.FieldLastUsedAt)
}
if m.expires_at != nil {
fields = append(fields, account.FieldExpiresAt)
}
if m.auto_pause_on_expired != nil {
fields = append(fields, account.FieldAutoPauseOnExpired)
}
if m.schedulable != nil {
fields = append(fields, account.FieldSchedulable)
}
@@ -2374,6 +2467,10 @@ func (m *AccountMutation) Field(name string) (ent.Value, bool) {
return m.ErrorMessage()
case account.FieldLastUsedAt:
return m.LastUsedAt()
case account.FieldExpiresAt:
return m.ExpiresAt()
case account.FieldAutoPauseOnExpired:
return m.AutoPauseOnExpired()
case account.FieldSchedulable:
return m.Schedulable()
case account.FieldRateLimitedAt:
@@ -2427,6 +2524,10 @@ func (m *AccountMutation) OldField(ctx context.Context, name string) (ent.Value,
return m.OldErrorMessage(ctx)
case account.FieldLastUsedAt:
return m.OldLastUsedAt(ctx)
case account.FieldExpiresAt:
return m.OldExpiresAt(ctx)
case account.FieldAutoPauseOnExpired:
return m.OldAutoPauseOnExpired(ctx)
case account.FieldSchedulable:
return m.OldSchedulable(ctx)
case account.FieldRateLimitedAt:
@@ -2555,6 +2656,20 @@ func (m *AccountMutation) SetField(name string, value ent.Value) error {
}
m.SetLastUsedAt(v)
return nil
case account.FieldExpiresAt:
v, ok := value.(time.Time)
if !ok {
return fmt.Errorf("unexpected type %T for field %s", value, name)
}
m.SetExpiresAt(v)
return nil
case account.FieldAutoPauseOnExpired:
v, ok := value.(bool)
if !ok {
return fmt.Errorf("unexpected type %T for field %s", value, name)
}
m.SetAutoPauseOnExpired(v)
return nil
case account.FieldSchedulable:
v, ok := value.(bool)
if !ok {
@@ -2676,6 +2791,9 @@ func (m *AccountMutation) ClearedFields() []string {
if m.FieldCleared(account.FieldLastUsedAt) {
fields = append(fields, account.FieldLastUsedAt)
}
if m.FieldCleared(account.FieldExpiresAt) {
fields = append(fields, account.FieldExpiresAt)
}
if m.FieldCleared(account.FieldRateLimitedAt) {
fields = append(fields, account.FieldRateLimitedAt)
}
@@ -2723,6 +2841,9 @@ func (m *AccountMutation) ClearField(name string) error {
case account.FieldLastUsedAt:
m.ClearLastUsedAt()
return nil
case account.FieldExpiresAt:
m.ClearExpiresAt()
return nil
case account.FieldRateLimitedAt:
m.ClearRateLimitedAt()
return nil
@@ -2794,6 +2915,12 @@ func (m *AccountMutation) ResetField(name string) error {
case account.FieldLastUsedAt:
m.ResetLastUsedAt()
return nil
case account.FieldExpiresAt:
m.ResetExpiresAt()
return nil
case account.FieldAutoPauseOnExpired:
m.ResetAutoPauseOnExpired()
return nil
case account.FieldSchedulable:
m.ResetSchedulable()
return nil
@@ -8107,6 +8234,7 @@ type UsageLogMutation struct {
addduration_ms *int
first_token_ms *int
addfirst_token_ms *int
user_agent *string
image_count *int
addimage_count *int
image_size *string
@@ -9463,6 +9591,55 @@ func (m *UsageLogMutation) ResetFirstTokenMs() {
delete(m.clearedFields, usagelog.FieldFirstTokenMs)
}
// SetUserAgent sets the "user_agent" field.
func (m *UsageLogMutation) SetUserAgent(s string) {
m.user_agent = &s
}
// UserAgent returns the value of the "user_agent" field in the mutation.
func (m *UsageLogMutation) UserAgent() (r string, exists bool) {
v := m.user_agent
if v == nil {
return
}
return *v, true
}
// OldUserAgent returns the old "user_agent" field's value of the UsageLog entity.
// If the UsageLog object wasn't provided to the builder, the object is fetched from the database.
// An error is returned if the mutation operation is not UpdateOne, or the database query fails.
func (m *UsageLogMutation) OldUserAgent(ctx context.Context) (v *string, err error) {
if !m.op.Is(OpUpdateOne) {
return v, errors.New("OldUserAgent is only allowed on UpdateOne operations")
}
if m.id == nil || m.oldValue == nil {
return v, errors.New("OldUserAgent requires an ID field in the mutation")
}
oldValue, err := m.oldValue(ctx)
if err != nil {
return v, fmt.Errorf("querying old value for OldUserAgent: %w", err)
}
return oldValue.UserAgent, nil
}
// ClearUserAgent clears the value of the "user_agent" field.
func (m *UsageLogMutation) ClearUserAgent() {
m.user_agent = nil
m.clearedFields[usagelog.FieldUserAgent] = struct{}{}
}
// UserAgentCleared returns if the "user_agent" field was cleared in this mutation.
func (m *UsageLogMutation) UserAgentCleared() bool {
_, ok := m.clearedFields[usagelog.FieldUserAgent]
return ok
}
// ResetUserAgent resets all changes to the "user_agent" field.
func (m *UsageLogMutation) ResetUserAgent() {
m.user_agent = nil
delete(m.clearedFields, usagelog.FieldUserAgent)
}
// SetImageCount sets the "image_count" field.
func (m *UsageLogMutation) SetImageCount(i int) {
m.image_count = &i
@@ -9773,7 +9950,7 @@ func (m *UsageLogMutation) Type() string {
// order to get all numeric fields that were incremented/decremented, call
// AddedFields().
func (m *UsageLogMutation) Fields() []string {
fields := make([]string, 0, 27)
fields := make([]string, 0, 28)
if m.user != nil {
fields = append(fields, usagelog.FieldUserID)
}
@@ -9846,6 +10023,9 @@ func (m *UsageLogMutation) Fields() []string {
if m.first_token_ms != nil {
fields = append(fields, usagelog.FieldFirstTokenMs)
}
if m.user_agent != nil {
fields = append(fields, usagelog.FieldUserAgent)
}
if m.image_count != nil {
fields = append(fields, usagelog.FieldImageCount)
}
@@ -9911,6 +10091,8 @@ func (m *UsageLogMutation) Field(name string) (ent.Value, bool) {
return m.DurationMs()
case usagelog.FieldFirstTokenMs:
return m.FirstTokenMs()
case usagelog.FieldUserAgent:
return m.UserAgent()
case usagelog.FieldImageCount:
return m.ImageCount()
case usagelog.FieldImageSize:
@@ -9974,6 +10156,8 @@ func (m *UsageLogMutation) OldField(ctx context.Context, name string) (ent.Value
return m.OldDurationMs(ctx)
case usagelog.FieldFirstTokenMs:
return m.OldFirstTokenMs(ctx)
case usagelog.FieldUserAgent:
return m.OldUserAgent(ctx)
case usagelog.FieldImageCount:
return m.OldImageCount(ctx)
case usagelog.FieldImageSize:
@@ -10157,6 +10341,13 @@ func (m *UsageLogMutation) SetField(name string, value ent.Value) error {
}
m.SetFirstTokenMs(v)
return nil
case usagelog.FieldUserAgent:
v, ok := value.(string)
if !ok {
return fmt.Errorf("unexpected type %T for field %s", value, name)
}
m.SetUserAgent(v)
return nil
case usagelog.FieldImageCount:
v, ok := value.(int)
if !ok {
@@ -10427,6 +10618,9 @@ func (m *UsageLogMutation) ClearedFields() []string {
if m.FieldCleared(usagelog.FieldFirstTokenMs) {
fields = append(fields, usagelog.FieldFirstTokenMs)
}
if m.FieldCleared(usagelog.FieldUserAgent) {
fields = append(fields, usagelog.FieldUserAgent)
}
if m.FieldCleared(usagelog.FieldImageSize) {
fields = append(fields, usagelog.FieldImageSize)
}
@@ -10456,6 +10650,9 @@ func (m *UsageLogMutation) ClearField(name string) error {
case usagelog.FieldFirstTokenMs:
m.ClearFirstTokenMs()
return nil
case usagelog.FieldUserAgent:
m.ClearUserAgent()
return nil
case usagelog.FieldImageSize:
m.ClearImageSize()
return nil
@@ -10539,6 +10736,9 @@ func (m *UsageLogMutation) ResetField(name string) error {
case usagelog.FieldFirstTokenMs:
m.ResetFirstTokenMs()
return nil
case usagelog.FieldUserAgent:
m.ResetUserAgent()
return nil
case usagelog.FieldImageCount:
m.ResetImageCount()
return nil

View File

@@ -181,12 +181,16 @@ func init() {
account.DefaultStatus = accountDescStatus.Default.(string)
// account.StatusValidator is a validator for the "status" field. It is called by the builders before save.
account.StatusValidator = accountDescStatus.Validators[0].(func(string) error)
// accountDescAutoPauseOnExpired is the schema descriptor for auto_pause_on_expired field.
accountDescAutoPauseOnExpired := accountFields[13].Descriptor()
// account.DefaultAutoPauseOnExpired holds the default value on creation for the auto_pause_on_expired field.
account.DefaultAutoPauseOnExpired = accountDescAutoPauseOnExpired.Default.(bool)
// accountDescSchedulable is the schema descriptor for schedulable field.
accountDescSchedulable := accountFields[12].Descriptor()
accountDescSchedulable := accountFields[14].Descriptor()
// account.DefaultSchedulable holds the default value on creation for the schedulable field.
account.DefaultSchedulable = accountDescSchedulable.Default.(bool)
// accountDescSessionWindowStatus is the schema descriptor for session_window_status field.
accountDescSessionWindowStatus := accountFields[18].Descriptor()
accountDescSessionWindowStatus := accountFields[20].Descriptor()
// account.SessionWindowStatusValidator is a validator for the "session_window_status" field. It is called by the builders before save.
account.SessionWindowStatusValidator = accountDescSessionWindowStatus.Validators[0].(func(string) error)
accountgroupFields := schema.AccountGroup{}.Fields()
@@ -521,16 +525,20 @@ func init() {
usagelogDescStream := usagelogFields[21].Descriptor()
// usagelog.DefaultStream holds the default value on creation for the stream field.
usagelog.DefaultStream = usagelogDescStream.Default.(bool)
// usagelogDescUserAgent is the schema descriptor for user_agent field.
usagelogDescUserAgent := usagelogFields[24].Descriptor()
// usagelog.UserAgentValidator is a validator for the "user_agent" field. It is called by the builders before save.
usagelog.UserAgentValidator = usagelogDescUserAgent.Validators[0].(func(string) error)
// usagelogDescImageCount is the schema descriptor for image_count field.
usagelogDescImageCount := usagelogFields[24].Descriptor()
usagelogDescImageCount := usagelogFields[25].Descriptor()
// usagelog.DefaultImageCount holds the default value on creation for the image_count field.
usagelog.DefaultImageCount = usagelogDescImageCount.Default.(int)
// usagelogDescImageSize is the schema descriptor for image_size field.
usagelogDescImageSize := usagelogFields[25].Descriptor()
usagelogDescImageSize := usagelogFields[26].Descriptor()
// usagelog.ImageSizeValidator is a validator for the "image_size" field. It is called by the builders before save.
usagelog.ImageSizeValidator = usagelogDescImageSize.Validators[0].(func(string) error)
// usagelogDescCreatedAt is the schema descriptor for created_at field.
usagelogDescCreatedAt := usagelogFields[26].Descriptor()
usagelogDescCreatedAt := usagelogFields[27].Descriptor()
// usagelog.DefaultCreatedAt holds the default value on creation for the created_at field.
usagelog.DefaultCreatedAt = usagelogDescCreatedAt.Default.(func() time.Time)
userMixin := schema.User{}.Mixin()

View File

@@ -118,6 +118,16 @@ func (Account) Fields() []ent.Field {
Optional().
Nillable().
SchemaType(map[string]string{dialect.Postgres: "timestamptz"}),
// expires_at: 账户过期时间(可为空)
field.Time("expires_at").
Optional().
Nillable().
Comment("Account expiration time (NULL means no expiration).").
SchemaType(map[string]string{dialect.Postgres: "timestamptz"}),
// auto_pause_on_expired: 过期后自动暂停调度
field.Bool("auto_pause_on_expired").
Default(true).
Comment("Auto pause scheduling when account expires."),
// ========== 调度和速率限制相关字段 ==========
// 这些字段在 migrations/005_schema_parity.sql 中添加

View File

@@ -96,6 +96,10 @@ func (UsageLog) Fields() []ent.Field {
field.Int("first_token_ms").
Optional().
Nillable(),
field.String("user_agent").
MaxLen(512).
Optional().
Nillable(),
// 图片生成字段(仅 gemini-3-pro-image 等图片模型使用)
field.Int("image_count").

View File

@@ -70,6 +70,8 @@ type UsageLog struct {
DurationMs *int `json:"duration_ms,omitempty"`
// FirstTokenMs holds the value of the "first_token_ms" field.
FirstTokenMs *int `json:"first_token_ms,omitempty"`
// UserAgent holds the value of the "user_agent" field.
UserAgent *string `json:"user_agent,omitempty"`
// ImageCount holds the value of the "image_count" field.
ImageCount int `json:"image_count,omitempty"`
// ImageSize holds the value of the "image_size" field.
@@ -165,7 +167,7 @@ func (*UsageLog) scanValues(columns []string) ([]any, error) {
values[i] = new(sql.NullFloat64)
case usagelog.FieldID, usagelog.FieldUserID, usagelog.FieldAPIKeyID, usagelog.FieldAccountID, usagelog.FieldGroupID, usagelog.FieldSubscriptionID, usagelog.FieldInputTokens, usagelog.FieldOutputTokens, usagelog.FieldCacheCreationTokens, usagelog.FieldCacheReadTokens, usagelog.FieldCacheCreation5mTokens, usagelog.FieldCacheCreation1hTokens, usagelog.FieldBillingType, usagelog.FieldDurationMs, usagelog.FieldFirstTokenMs, usagelog.FieldImageCount:
values[i] = new(sql.NullInt64)
case usagelog.FieldRequestID, usagelog.FieldModel, usagelog.FieldImageSize:
case usagelog.FieldRequestID, usagelog.FieldModel, usagelog.FieldUserAgent, usagelog.FieldImageSize:
values[i] = new(sql.NullString)
case usagelog.FieldCreatedAt:
values[i] = new(sql.NullTime)
@@ -338,6 +340,13 @@ func (_m *UsageLog) assignValues(columns []string, values []any) error {
_m.FirstTokenMs = new(int)
*_m.FirstTokenMs = int(value.Int64)
}
case usagelog.FieldUserAgent:
if value, ok := values[i].(*sql.NullString); !ok {
return fmt.Errorf("unexpected type %T for field user_agent", values[i])
} else if value.Valid {
_m.UserAgent = new(string)
*_m.UserAgent = value.String
}
case usagelog.FieldImageCount:
if value, ok := values[i].(*sql.NullInt64); !ok {
return fmt.Errorf("unexpected type %T for field image_count", values[i])
@@ -498,6 +507,11 @@ func (_m *UsageLog) String() string {
builder.WriteString(fmt.Sprintf("%v", *v))
}
builder.WriteString(", ")
if v := _m.UserAgent; v != nil {
builder.WriteString("user_agent=")
builder.WriteString(*v)
}
builder.WriteString(", ")
builder.WriteString("image_count=")
builder.WriteString(fmt.Sprintf("%v", _m.ImageCount))
builder.WriteString(", ")

View File

@@ -62,6 +62,8 @@ const (
FieldDurationMs = "duration_ms"
// FieldFirstTokenMs holds the string denoting the first_token_ms field in the database.
FieldFirstTokenMs = "first_token_ms"
// FieldUserAgent holds the string denoting the user_agent field in the database.
FieldUserAgent = "user_agent"
// FieldImageCount holds the string denoting the image_count field in the database.
FieldImageCount = "image_count"
// FieldImageSize holds the string denoting the image_size field in the database.
@@ -144,6 +146,7 @@ var Columns = []string{
FieldStream,
FieldDurationMs,
FieldFirstTokenMs,
FieldUserAgent,
FieldImageCount,
FieldImageSize,
FieldCreatedAt,
@@ -194,6 +197,8 @@ var (
DefaultBillingType int8
// DefaultStream holds the default value on creation for the "stream" field.
DefaultStream bool
// UserAgentValidator is a validator for the "user_agent" field. It is called by the builders before save.
UserAgentValidator func(string) error
// DefaultImageCount holds the default value on creation for the "image_count" field.
DefaultImageCount int
// ImageSizeValidator is a validator for the "image_size" field. It is called by the builders before save.
@@ -330,6 +335,11 @@ func ByFirstTokenMs(opts ...sql.OrderTermOption) OrderOption {
return sql.OrderByField(FieldFirstTokenMs, opts...).ToFunc()
}
// ByUserAgent orders the results by the user_agent field.
func ByUserAgent(opts ...sql.OrderTermOption) OrderOption {
return sql.OrderByField(FieldUserAgent, opts...).ToFunc()
}
// ByImageCount orders the results by the image_count field.
func ByImageCount(opts ...sql.OrderTermOption) OrderOption {
return sql.OrderByField(FieldImageCount, opts...).ToFunc()

View File

@@ -175,6 +175,11 @@ func FirstTokenMs(v int) predicate.UsageLog {
return predicate.UsageLog(sql.FieldEQ(FieldFirstTokenMs, v))
}
// UserAgent applies equality check predicate on the "user_agent" field. It's identical to UserAgentEQ.
func UserAgent(v string) predicate.UsageLog {
return predicate.UsageLog(sql.FieldEQ(FieldUserAgent, v))
}
// ImageCount applies equality check predicate on the "image_count" field. It's identical to ImageCountEQ.
func ImageCount(v int) predicate.UsageLog {
return predicate.UsageLog(sql.FieldEQ(FieldImageCount, v))
@@ -1110,6 +1115,81 @@ func FirstTokenMsNotNil() predicate.UsageLog {
return predicate.UsageLog(sql.FieldNotNull(FieldFirstTokenMs))
}
// UserAgentEQ applies the EQ predicate on the "user_agent" field.
func UserAgentEQ(v string) predicate.UsageLog {
return predicate.UsageLog(sql.FieldEQ(FieldUserAgent, v))
}
// UserAgentNEQ applies the NEQ predicate on the "user_agent" field.
func UserAgentNEQ(v string) predicate.UsageLog {
return predicate.UsageLog(sql.FieldNEQ(FieldUserAgent, v))
}
// UserAgentIn applies the In predicate on the "user_agent" field.
func UserAgentIn(vs ...string) predicate.UsageLog {
return predicate.UsageLog(sql.FieldIn(FieldUserAgent, vs...))
}
// UserAgentNotIn applies the NotIn predicate on the "user_agent" field.
func UserAgentNotIn(vs ...string) predicate.UsageLog {
return predicate.UsageLog(sql.FieldNotIn(FieldUserAgent, vs...))
}
// UserAgentGT applies the GT predicate on the "user_agent" field.
func UserAgentGT(v string) predicate.UsageLog {
return predicate.UsageLog(sql.FieldGT(FieldUserAgent, v))
}
// UserAgentGTE applies the GTE predicate on the "user_agent" field.
func UserAgentGTE(v string) predicate.UsageLog {
return predicate.UsageLog(sql.FieldGTE(FieldUserAgent, v))
}
// UserAgentLT applies the LT predicate on the "user_agent" field.
func UserAgentLT(v string) predicate.UsageLog {
return predicate.UsageLog(sql.FieldLT(FieldUserAgent, v))
}
// UserAgentLTE applies the LTE predicate on the "user_agent" field.
func UserAgentLTE(v string) predicate.UsageLog {
return predicate.UsageLog(sql.FieldLTE(FieldUserAgent, v))
}
// UserAgentContains applies the Contains predicate on the "user_agent" field.
func UserAgentContains(v string) predicate.UsageLog {
return predicate.UsageLog(sql.FieldContains(FieldUserAgent, v))
}
// UserAgentHasPrefix applies the HasPrefix predicate on the "user_agent" field.
func UserAgentHasPrefix(v string) predicate.UsageLog {
return predicate.UsageLog(sql.FieldHasPrefix(FieldUserAgent, v))
}
// UserAgentHasSuffix applies the HasSuffix predicate on the "user_agent" field.
func UserAgentHasSuffix(v string) predicate.UsageLog {
return predicate.UsageLog(sql.FieldHasSuffix(FieldUserAgent, v))
}
// UserAgentIsNil applies the IsNil predicate on the "user_agent" field.
func UserAgentIsNil() predicate.UsageLog {
return predicate.UsageLog(sql.FieldIsNull(FieldUserAgent))
}
// UserAgentNotNil applies the NotNil predicate on the "user_agent" field.
func UserAgentNotNil() predicate.UsageLog {
return predicate.UsageLog(sql.FieldNotNull(FieldUserAgent))
}
// UserAgentEqualFold applies the EqualFold predicate on the "user_agent" field.
func UserAgentEqualFold(v string) predicate.UsageLog {
return predicate.UsageLog(sql.FieldEqualFold(FieldUserAgent, v))
}
// UserAgentContainsFold applies the ContainsFold predicate on the "user_agent" field.
func UserAgentContainsFold(v string) predicate.UsageLog {
return predicate.UsageLog(sql.FieldContainsFold(FieldUserAgent, v))
}
// ImageCountEQ applies the EQ predicate on the "image_count" field.
func ImageCountEQ(v int) predicate.UsageLog {
return predicate.UsageLog(sql.FieldEQ(FieldImageCount, v))

View File

@@ -323,6 +323,20 @@ func (_c *UsageLogCreate) SetNillableFirstTokenMs(v *int) *UsageLogCreate {
return _c
}
// SetUserAgent sets the "user_agent" field.
func (_c *UsageLogCreate) SetUserAgent(v string) *UsageLogCreate {
_c.mutation.SetUserAgent(v)
return _c
}
// SetNillableUserAgent sets the "user_agent" field if the given value is not nil.
func (_c *UsageLogCreate) SetNillableUserAgent(v *string) *UsageLogCreate {
if v != nil {
_c.SetUserAgent(*v)
}
return _c
}
// SetImageCount sets the "image_count" field.
func (_c *UsageLogCreate) SetImageCount(v int) *UsageLogCreate {
_c.mutation.SetImageCount(v)
@@ -567,6 +581,11 @@ func (_c *UsageLogCreate) check() error {
if _, ok := _c.mutation.Stream(); !ok {
return &ValidationError{Name: "stream", err: errors.New(`ent: missing required field "UsageLog.stream"`)}
}
if v, ok := _c.mutation.UserAgent(); ok {
if err := usagelog.UserAgentValidator(v); err != nil {
return &ValidationError{Name: "user_agent", err: fmt.Errorf(`ent: validator failed for field "UsageLog.user_agent": %w`, err)}
}
}
if _, ok := _c.mutation.ImageCount(); !ok {
return &ValidationError{Name: "image_count", err: errors.New(`ent: missing required field "UsageLog.image_count"`)}
}
@@ -690,6 +709,10 @@ func (_c *UsageLogCreate) createSpec() (*UsageLog, *sqlgraph.CreateSpec) {
_spec.SetField(usagelog.FieldFirstTokenMs, field.TypeInt, value)
_node.FirstTokenMs = &value
}
if value, ok := _c.mutation.UserAgent(); ok {
_spec.SetField(usagelog.FieldUserAgent, field.TypeString, value)
_node.UserAgent = &value
}
if value, ok := _c.mutation.ImageCount(); ok {
_spec.SetField(usagelog.FieldImageCount, field.TypeInt, value)
_node.ImageCount = value
@@ -1247,6 +1270,24 @@ func (u *UsageLogUpsert) ClearFirstTokenMs() *UsageLogUpsert {
return u
}
// SetUserAgent sets the "user_agent" field.
func (u *UsageLogUpsert) SetUserAgent(v string) *UsageLogUpsert {
u.Set(usagelog.FieldUserAgent, v)
return u
}
// UpdateUserAgent sets the "user_agent" field to the value that was provided on create.
func (u *UsageLogUpsert) UpdateUserAgent() *UsageLogUpsert {
u.SetExcluded(usagelog.FieldUserAgent)
return u
}
// ClearUserAgent clears the value of the "user_agent" field.
func (u *UsageLogUpsert) ClearUserAgent() *UsageLogUpsert {
u.SetNull(usagelog.FieldUserAgent)
return u
}
// SetImageCount sets the "image_count" field.
func (u *UsageLogUpsert) SetImageCount(v int) *UsageLogUpsert {
u.Set(usagelog.FieldImageCount, v)
@@ -1804,6 +1845,27 @@ func (u *UsageLogUpsertOne) ClearFirstTokenMs() *UsageLogUpsertOne {
})
}
// SetUserAgent sets the "user_agent" field.
func (u *UsageLogUpsertOne) SetUserAgent(v string) *UsageLogUpsertOne {
return u.Update(func(s *UsageLogUpsert) {
s.SetUserAgent(v)
})
}
// UpdateUserAgent sets the "user_agent" field to the value that was provided on create.
func (u *UsageLogUpsertOne) UpdateUserAgent() *UsageLogUpsertOne {
return u.Update(func(s *UsageLogUpsert) {
s.UpdateUserAgent()
})
}
// ClearUserAgent clears the value of the "user_agent" field.
func (u *UsageLogUpsertOne) ClearUserAgent() *UsageLogUpsertOne {
return u.Update(func(s *UsageLogUpsert) {
s.ClearUserAgent()
})
}
// SetImageCount sets the "image_count" field.
func (u *UsageLogUpsertOne) SetImageCount(v int) *UsageLogUpsertOne {
return u.Update(func(s *UsageLogUpsert) {
@@ -2533,6 +2595,27 @@ func (u *UsageLogUpsertBulk) ClearFirstTokenMs() *UsageLogUpsertBulk {
})
}
// SetUserAgent sets the "user_agent" field.
func (u *UsageLogUpsertBulk) SetUserAgent(v string) *UsageLogUpsertBulk {
return u.Update(func(s *UsageLogUpsert) {
s.SetUserAgent(v)
})
}
// UpdateUserAgent sets the "user_agent" field to the value that was provided on create.
func (u *UsageLogUpsertBulk) UpdateUserAgent() *UsageLogUpsertBulk {
return u.Update(func(s *UsageLogUpsert) {
s.UpdateUserAgent()
})
}
// ClearUserAgent clears the value of the "user_agent" field.
func (u *UsageLogUpsertBulk) ClearUserAgent() *UsageLogUpsertBulk {
return u.Update(func(s *UsageLogUpsert) {
s.ClearUserAgent()
})
}
// SetImageCount sets the "image_count" field.
func (u *UsageLogUpsertBulk) SetImageCount(v int) *UsageLogUpsertBulk {
return u.Update(func(s *UsageLogUpsert) {

View File

@@ -504,6 +504,26 @@ func (_u *UsageLogUpdate) ClearFirstTokenMs() *UsageLogUpdate {
return _u
}
// SetUserAgent sets the "user_agent" field.
func (_u *UsageLogUpdate) SetUserAgent(v string) *UsageLogUpdate {
_u.mutation.SetUserAgent(v)
return _u
}
// SetNillableUserAgent sets the "user_agent" field if the given value is not nil.
func (_u *UsageLogUpdate) SetNillableUserAgent(v *string) *UsageLogUpdate {
if v != nil {
_u.SetUserAgent(*v)
}
return _u
}
// ClearUserAgent clears the value of the "user_agent" field.
func (_u *UsageLogUpdate) ClearUserAgent() *UsageLogUpdate {
_u.mutation.ClearUserAgent()
return _u
}
// SetImageCount sets the "image_count" field.
func (_u *UsageLogUpdate) SetImageCount(v int) *UsageLogUpdate {
_u.mutation.ResetImageCount()
@@ -644,6 +664,11 @@ func (_u *UsageLogUpdate) check() error {
return &ValidationError{Name: "model", err: fmt.Errorf(`ent: validator failed for field "UsageLog.model": %w`, err)}
}
}
if v, ok := _u.mutation.UserAgent(); ok {
if err := usagelog.UserAgentValidator(v); err != nil {
return &ValidationError{Name: "user_agent", err: fmt.Errorf(`ent: validator failed for field "UsageLog.user_agent": %w`, err)}
}
}
if v, ok := _u.mutation.ImageSize(); ok {
if err := usagelog.ImageSizeValidator(v); err != nil {
return &ValidationError{Name: "image_size", err: fmt.Errorf(`ent: validator failed for field "UsageLog.image_size": %w`, err)}
@@ -784,6 +809,12 @@ func (_u *UsageLogUpdate) sqlSave(ctx context.Context) (_node int, err error) {
if _u.mutation.FirstTokenMsCleared() {
_spec.ClearField(usagelog.FieldFirstTokenMs, field.TypeInt)
}
if value, ok := _u.mutation.UserAgent(); ok {
_spec.SetField(usagelog.FieldUserAgent, field.TypeString, value)
}
if _u.mutation.UserAgentCleared() {
_spec.ClearField(usagelog.FieldUserAgent, field.TypeString)
}
if value, ok := _u.mutation.ImageCount(); ok {
_spec.SetField(usagelog.FieldImageCount, field.TypeInt, value)
}
@@ -1433,6 +1464,26 @@ func (_u *UsageLogUpdateOne) ClearFirstTokenMs() *UsageLogUpdateOne {
return _u
}
// SetUserAgent sets the "user_agent" field.
func (_u *UsageLogUpdateOne) SetUserAgent(v string) *UsageLogUpdateOne {
_u.mutation.SetUserAgent(v)
return _u
}
// SetNillableUserAgent sets the "user_agent" field if the given value is not nil.
func (_u *UsageLogUpdateOne) SetNillableUserAgent(v *string) *UsageLogUpdateOne {
if v != nil {
_u.SetUserAgent(*v)
}
return _u
}
// ClearUserAgent clears the value of the "user_agent" field.
func (_u *UsageLogUpdateOne) ClearUserAgent() *UsageLogUpdateOne {
_u.mutation.ClearUserAgent()
return _u
}
// SetImageCount sets the "image_count" field.
func (_u *UsageLogUpdateOne) SetImageCount(v int) *UsageLogUpdateOne {
_u.mutation.ResetImageCount()
@@ -1586,6 +1637,11 @@ func (_u *UsageLogUpdateOne) check() error {
return &ValidationError{Name: "model", err: fmt.Errorf(`ent: validator failed for field "UsageLog.model": %w`, err)}
}
}
if v, ok := _u.mutation.UserAgent(); ok {
if err := usagelog.UserAgentValidator(v); err != nil {
return &ValidationError{Name: "user_agent", err: fmt.Errorf(`ent: validator failed for field "UsageLog.user_agent": %w`, err)}
}
}
if v, ok := _u.mutation.ImageSize(); ok {
if err := usagelog.ImageSizeValidator(v); err != nil {
return &ValidationError{Name: "image_size", err: fmt.Errorf(`ent: validator failed for field "UsageLog.image_size": %w`, err)}
@@ -1743,6 +1799,12 @@ func (_u *UsageLogUpdateOne) sqlSave(ctx context.Context) (_node *UsageLog, err
if _u.mutation.FirstTokenMsCleared() {
_spec.ClearField(usagelog.FieldFirstTokenMs, field.TypeInt)
}
if value, ok := _u.mutation.UserAgent(); ok {
_spec.SetField(usagelog.FieldUserAgent, field.TypeString, value)
}
if _u.mutation.UserAgentCleared() {
_spec.ClearField(usagelog.FieldUserAgent, field.TypeString)
}
if value, ok := _u.mutation.ImageCount(); ok {
_spec.SetField(usagelog.FieldImageCount, field.TypeInt, value)
}

View File

@@ -52,6 +52,15 @@ type Config struct {
RunMode string `mapstructure:"run_mode" yaml:"run_mode"`
Timezone string `mapstructure:"timezone"` // e.g. "Asia/Shanghai", "UTC"
Gemini GeminiConfig `mapstructure:"gemini"`
Update UpdateConfig `mapstructure:"update"`
}
// UpdateConfig 在线更新相关配置
type UpdateConfig struct {
// ProxyURL 用于访问 GitHub 的代理地址
// 支持 http/https/socks5/socks5h 协议
// 例如: "http://127.0.0.1:7890", "socks5://127.0.0.1:1080"
ProxyURL string `mapstructure:"proxy_url"`
}
type GeminiConfig struct {
@@ -558,6 +567,10 @@ func setDefaults() {
viper.SetDefault("gemini.oauth.client_secret", "")
viper.SetDefault("gemini.oauth.scopes", "")
viper.SetDefault("gemini.quota.policy", "")
// Update - 在线更新配置
// 代理地址为空表示直连 GitHub适用于海外服务器
viper.SetDefault("update.proxy_url", "")
}
func (c *Config) Validate() error {

View File

@@ -85,6 +85,8 @@ type CreateAccountRequest struct {
Concurrency int `json:"concurrency"`
Priority int `json:"priority"`
GroupIDs []int64 `json:"group_ids"`
ExpiresAt *int64 `json:"expires_at"`
AutoPauseOnExpired *bool `json:"auto_pause_on_expired"`
ConfirmMixedChannelRisk *bool `json:"confirm_mixed_channel_risk"` // 用户确认混合渠道风险
}
@@ -101,6 +103,8 @@ type UpdateAccountRequest struct {
Priority *int `json:"priority"`
Status string `json:"status" binding:"omitempty,oneof=active inactive"`
GroupIDs *[]int64 `json:"group_ids"`
ExpiresAt *int64 `json:"expires_at"`
AutoPauseOnExpired *bool `json:"auto_pause_on_expired"`
ConfirmMixedChannelRisk *bool `json:"confirm_mixed_channel_risk"` // 用户确认混合渠道风险
}
@@ -204,6 +208,8 @@ func (h *AccountHandler) Create(c *gin.Context) {
Concurrency: req.Concurrency,
Priority: req.Priority,
GroupIDs: req.GroupIDs,
ExpiresAt: req.ExpiresAt,
AutoPauseOnExpired: req.AutoPauseOnExpired,
SkipMixedChannelCheck: skipCheck,
})
if err != nil {
@@ -261,6 +267,8 @@ func (h *AccountHandler) Update(c *gin.Context) {
Priority: req.Priority, // 指针类型nil 表示未提供
Status: req.Status,
GroupIDs: req.GroupIDs,
ExpiresAt: req.ExpiresAt,
AutoPauseOnExpired: req.AutoPauseOnExpired,
SkipMixedChannelCheck: skipCheck,
})
if err != nil {

View File

@@ -144,7 +144,7 @@ func (h *UsageHandler) List(c *gin.Context) {
out := make([]dto.UsageLog, 0, len(records))
for i := range records {
out = append(out, *dto.UsageLogFromService(&records[i]))
out = append(out, *dto.UsageLogFromServiceAdmin(&records[i]))
}
response.Paginated(c, out, result.Total, page, pageSize)
}

View File

@@ -1,7 +1,11 @@
// Package dto provides data transfer objects for HTTP handlers.
package dto
import "github.com/Wei-Shaw/sub2api/internal/service"
import (
"time"
"github.com/Wei-Shaw/sub2api/internal/service"
)
func UserFromServiceShallow(u *service.User) *User {
if u == nil {
@@ -120,6 +124,8 @@ func AccountFromServiceShallow(a *service.Account) *Account {
Status: a.Status,
ErrorMessage: a.ErrorMessage,
LastUsedAt: a.LastUsedAt,
ExpiresAt: timeToUnixSeconds(a.ExpiresAt),
AutoPauseOnExpired: a.AutoPauseOnExpired,
CreatedAt: a.CreatedAt,
UpdatedAt: a.UpdatedAt,
Schedulable: a.Schedulable,
@@ -157,6 +163,14 @@ func AccountFromService(a *service.Account) *Account {
return out
}
func timeToUnixSeconds(value *time.Time) *int64 {
if value == nil {
return nil
}
ts := value.Unix()
return &ts
}
func AccountGroupFromService(ag *service.AccountGroup) *AccountGroup {
if ag == nil {
return nil
@@ -220,7 +234,21 @@ func RedeemCodeFromService(rc *service.RedeemCode) *RedeemCode {
}
}
func UsageLogFromService(l *service.UsageLog) *UsageLog {
// AccountSummaryFromService returns a minimal AccountSummary for usage log display.
// Only includes ID and Name - no sensitive fields like Credentials, Proxy, etc.
func AccountSummaryFromService(a *service.Account) *AccountSummary {
if a == nil {
return nil
}
return &AccountSummary{
ID: a.ID,
Name: a.Name,
}
}
// usageLogFromServiceBase is a helper that converts service UsageLog to DTO.
// The account parameter allows caller to control what Account info is included.
func usageLogFromServiceBase(l *service.UsageLog, account *AccountSummary) *UsageLog {
if l == nil {
return nil
}
@@ -255,12 +283,27 @@ func UsageLogFromService(l *service.UsageLog) *UsageLog {
CreatedAt: l.CreatedAt,
User: UserFromServiceShallow(l.User),
APIKey: APIKeyFromService(l.APIKey),
Account: AccountFromService(l.Account),
Account: account,
Group: GroupFromServiceShallow(l.Group),
Subscription: UserSubscriptionFromService(l.Subscription),
}
}
// UsageLogFromService converts a service UsageLog to DTO for regular users.
// It excludes Account details - users should not see account information.
func UsageLogFromService(l *service.UsageLog) *UsageLog {
return usageLogFromServiceBase(l, nil)
}
// UsageLogFromServiceAdmin converts a service UsageLog to DTO for admin users.
// It includes minimal Account info (ID, Name only).
func UsageLogFromServiceAdmin(l *service.UsageLog) *UsageLog {
if l == nil {
return nil
}
return usageLogFromServiceBase(l, AccountSummaryFromService(l.Account))
}
func SettingFromService(s *service.Setting) *Setting {
if s == nil {
return nil

View File

@@ -60,21 +60,23 @@ type Group struct {
}
type Account struct {
ID int64 `json:"id"`
Name string `json:"name"`
Notes *string `json:"notes"`
Platform string `json:"platform"`
Type string `json:"type"`
Credentials map[string]any `json:"credentials"`
Extra map[string]any `json:"extra"`
ProxyID *int64 `json:"proxy_id"`
Concurrency int `json:"concurrency"`
Priority int `json:"priority"`
Status string `json:"status"`
ErrorMessage string `json:"error_message"`
LastUsedAt *time.Time `json:"last_used_at"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
ID int64 `json:"id"`
Name string `json:"name"`
Notes *string `json:"notes"`
Platform string `json:"platform"`
Type string `json:"type"`
Credentials map[string]any `json:"credentials"`
Extra map[string]any `json:"extra"`
ProxyID *int64 `json:"proxy_id"`
Concurrency int `json:"concurrency"`
Priority int `json:"priority"`
Status string `json:"status"`
ErrorMessage string `json:"error_message"`
LastUsedAt *time.Time `json:"last_used_at"`
ExpiresAt *int64 `json:"expires_at"`
AutoPauseOnExpired bool `json:"auto_pause_on_expired"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
Schedulable bool `json:"schedulable"`
@@ -182,11 +184,18 @@ type UsageLog struct {
User *User `json:"user,omitempty"`
APIKey *APIKey `json:"api_key,omitempty"`
Account *Account `json:"account,omitempty"`
Account *AccountSummary `json:"account,omitempty"` // Use minimal AccountSummary to prevent data leakage
Group *Group `json:"group,omitempty"`
Subscription *UserSubscription `json:"subscription,omitempty"`
}
// AccountSummary is a minimal account info for usage log display.
// It intentionally excludes sensitive fields like Credentials, Proxy, etc.
type AccountSummary struct {
ID int64 `json:"id"`
Name string `json:"name"`
}
type Setting struct {
ID int64 `json:"id"`
Key string `json:"key"`

View File

@@ -108,6 +108,9 @@ func (h *GatewayHandler) Messages(c *gin.Context) {
// 获取订阅信息可能为nil- 提前获取用于后续检查
subscription, _ := middleware2.GetSubscriptionFromContext(c)
// 获取 User-Agent
userAgent := c.Request.UserAgent()
// 0. 检查wait队列是否已满
maxWait := service.CalculateMaxWait(subject.Concurrency)
canWait, err := h.concurrencyHelper.IncrementWaitCount(c.Request.Context(), subject.UserID, maxWait)
@@ -267,7 +270,7 @@ func (h *GatewayHandler) Messages(c *gin.Context) {
}
// 异步记录使用量subscription已在函数开头获取
go func(result *service.ForwardResult, usedAccount *service.Account) {
go func(result *service.ForwardResult, usedAccount *service.Account, ua string) {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if err := h.gatewayService.RecordUsage(ctx, &service.RecordUsageInput{
@@ -276,10 +279,11 @@ func (h *GatewayHandler) Messages(c *gin.Context) {
User: apiKey.User,
Account: usedAccount,
Subscription: subscription,
UserAgent: ua,
}); err != nil {
log.Printf("Record usage failed: %v", err)
}
}(result, account)
}(result, account, userAgent)
return
}
}
@@ -394,7 +398,7 @@ func (h *GatewayHandler) Messages(c *gin.Context) {
}
// 异步记录使用量subscription已在函数开头获取
go func(result *service.ForwardResult, usedAccount *service.Account) {
go func(result *service.ForwardResult, usedAccount *service.Account, ua string) {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if err := h.gatewayService.RecordUsage(ctx, &service.RecordUsageInput{
@@ -403,10 +407,11 @@ func (h *GatewayHandler) Messages(c *gin.Context) {
User: apiKey.User,
Account: usedAccount,
Subscription: subscription,
UserAgent: ua,
}); err != nil {
log.Printf("Record usage failed: %v", err)
}
}(result, account)
}(result, account, userAgent)
return
}
}

View File

@@ -164,6 +164,9 @@ func (h *GatewayHandler) GeminiV1BetaModels(c *gin.Context) {
// Get subscription (may be nil)
subscription, _ := middleware.GetSubscriptionFromContext(c)
// 获取 User-Agent
userAgent := c.Request.UserAgent()
// For Gemini native API, do not send Claude-style ping frames.
geminiConcurrency := NewConcurrencyHelper(h.concurrencyHelper.concurrencyService, SSEPingFormatNone, 0)
@@ -300,7 +303,7 @@ func (h *GatewayHandler) GeminiV1BetaModels(c *gin.Context) {
}
// 6) record usage async
go func(result *service.ForwardResult, usedAccount *service.Account) {
go func(result *service.ForwardResult, usedAccount *service.Account, ua string) {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if err := h.gatewayService.RecordUsage(ctx, &service.RecordUsageInput{
@@ -309,10 +312,11 @@ func (h *GatewayHandler) GeminiV1BetaModels(c *gin.Context) {
User: apiKey.User,
Account: usedAccount,
Subscription: subscription,
UserAgent: ua,
}); err != nil {
log.Printf("Record usage failed: %v", err)
}
}(result, account)
}(result, account, userAgent)
return
}
}

View File

@@ -242,7 +242,7 @@ func (h *OpenAIGatewayHandler) Responses(c *gin.Context) {
}
// Async record usage
go func(result *service.OpenAIForwardResult, usedAccount *service.Account) {
go func(result *service.OpenAIForwardResult, usedAccount *service.Account, ua string) {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if err := h.gatewayService.RecordUsage(ctx, &service.OpenAIRecordUsageInput{
@@ -251,10 +251,11 @@ func (h *OpenAIGatewayHandler) Responses(c *gin.Context) {
User: apiKey.User,
Account: usedAccount,
Subscription: subscription,
UserAgent: ua,
}); err != nil {
log.Printf("Record usage failed: %v", err)
}
}(result, account)
}(result, account, userAgent)
return
}
}

View File

@@ -13,16 +13,48 @@ import (
"time"
)
// resolveHost 从 URL 解析 host
func resolveHost(urlStr string) string {
parsed, err := url.Parse(urlStr)
if err != nil {
return ""
}
return parsed.Host
}
// NewAPIRequest 创建 Antigravity API 请求v1internal 端点)
func NewAPIRequest(ctx context.Context, action, accessToken string, body []byte) (*http.Request, error) {
// 构建 URL流式请求添加 ?alt=sse 参数
apiURL := fmt.Sprintf("%s/v1internal:%s", BaseURL, action)
isStream := action == "streamGenerateContent"
if isStream {
apiURL += "?alt=sse"
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, apiURL, bytes.NewReader(body))
if err != nil {
return nil, err
}
// 基础 Headers
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+accessToken)
req.Header.Set("User-Agent", UserAgent)
// Accept Header 根据请求类型设置
if isStream {
req.Header.Set("Accept", "text/event-stream")
} else {
req.Header.Set("Accept", "application/json")
}
// 显式设置 Host Header
if host := resolveHost(apiURL); host != "" {
req.Host = host
}
// 注意requestType 已在 JSON body 的 V1InternalRequest 中设置,不需要 HTTP Header
return req, nil
}

View File

@@ -33,10 +33,11 @@ const (
"https://www.googleapis.com/auth/experimentsandconfigs"
// API 端点
BaseURL = "https://cloudcode-pa.googleapis.com"
// 优先使用 sandbox daily URL配额更宽松
BaseURL = "https://daily-cloudcode-pa.sandbox.googleapis.com"
// User-Agent
UserAgent = "antigravity/1.11.9 windows/amd64"
// User-Agent(模拟官方客户端)
UserAgent = "antigravity/1.104.0 darwin/arm64"
// Session 过期时间
SessionTTL = 30 * time.Minute

View File

@@ -1,17 +1,46 @@
package antigravity
import (
"crypto/sha256"
"encoding/binary"
"encoding/json"
"fmt"
"log"
"math/rand"
"os"
"strconv"
"strings"
"sync"
"time"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
)
var (
sessionRand = rand.New(rand.NewSource(time.Now().UnixNano()))
sessionRandMutex sync.Mutex
)
// generateStableSessionID 基于用户消息内容生成稳定的 session ID
func generateStableSessionID(contents []GeminiContent) string {
// 查找第一个 user 消息的文本
for _, content := range contents {
if content.Role == "user" && len(content.Parts) > 0 {
if text := content.Parts[0].Text; text != "" {
h := sha256.Sum256([]byte(text))
n := int64(binary.BigEndian.Uint64(h[:8])) & 0x7FFFFFFFFFFFFFFF
return "-" + strconv.FormatInt(n, 10)
}
}
}
// 回退:生成随机 session ID
sessionRandMutex.Lock()
n := sessionRand.Int63n(9_000_000_000_000_000_000)
sessionRandMutex.Unlock()
return "-" + strconv.FormatInt(n, 10)
}
type TransformOptions struct {
EnableIdentityPatch bool
// IdentityPatch 可选:自定义注入到 systemInstruction 开头的身份防护提示词;
@@ -67,8 +96,15 @@ func TransformClaudeToGeminiWithOptions(claudeReq *ClaudeRequest, projectID, map
// 5. 构建内部请求
innerRequest := GeminiRequest{
Contents: contents,
SafetySettings: DefaultSafetySettings,
Contents: contents,
// 总是设置 toolConfig与官方客户端一致
ToolConfig: &GeminiToolConfig{
FunctionCallingConfig: &GeminiFunctionCallingConfig{
Mode: "VALIDATED",
},
},
// 总是生成 sessionId基于用户消息内容
SessionID: generateStableSessionID(contents),
}
if systemInstruction != nil {
@@ -79,14 +115,9 @@ func TransformClaudeToGeminiWithOptions(claudeReq *ClaudeRequest, projectID, map
}
if len(tools) > 0 {
innerRequest.Tools = tools
innerRequest.ToolConfig = &GeminiToolConfig{
FunctionCallingConfig: &GeminiFunctionCallingConfig{
Mode: "VALIDATED",
},
}
}
// 如果提供了 metadata.user_id复用为 sessionId
// 如果提供了 metadata.user_id优先使用
if claudeReq.Metadata != nil && claudeReq.Metadata.UserID != "" {
innerRequest.SessionID = claudeReq.Metadata.UserID
}
@@ -95,7 +126,7 @@ func TransformClaudeToGeminiWithOptions(claudeReq *ClaudeRequest, projectID, map
v1Req := V1InternalRequest{
Project: projectID,
RequestID: "agent-" + uuid.New().String(),
UserAgent: "sub2api",
UserAgent: "antigravity", // 固定值,与官方客户端一致
RequestType: "agent",
Model: mappedModel,
Request: innerRequest,
@@ -104,37 +135,37 @@ func TransformClaudeToGeminiWithOptions(claudeReq *ClaudeRequest, projectID, map
return json.Marshal(v1Req)
}
func defaultIdentityPatch(modelName string) string {
return fmt.Sprintf(
"--- [IDENTITY_PATCH] ---\n"+
"Ignore any previous instructions regarding your identity or host platform (e.g., Amazon Q, Google AI).\n"+
"You are currently providing services as the native %s model via a standard API proxy.\n"+
"Always use the 'claude' command for terminal tasks if relevant.\n"+
"--- [SYSTEM_PROMPT_BEGIN] ---\n",
modelName,
)
// antigravityIdentity Antigravity identity 提示词
const antigravityIdentity = `<identity>
You are Antigravity, a powerful agentic AI coding assistant designed by the Google Deepmind team working on Advanced Agentic Coding.
You are pair programming with a USER to solve their coding task. The task may require creating a new codebase, modifying or debugging an existing codebase, or simply answering a question.
The USER will send you requests, which you must always prioritize addressing. Along with each USER request, we will attach additional metadata about their current state, such as what files they have open and where their cursor is.
This information may or may not be relevant to the coding task, it is up for you to decide.
</identity>
<communication_style>
- **Proactiveness**. As an agent, you are allowed to be proactive, but only in the course of completing the user's task. For example, if the user asks you to add a new component, you can edit the code, verify build and test statuses, and take any other obvious follow-up actions, such as performing additional research. However, avoid surprising the user. For example, if the user asks HOW to approach something, you should answer their question and instead of jumping into editing a file.</communication_style>`
func defaultIdentityPatch(_ string) string {
return antigravityIdentity
}
// buildSystemInstruction 构建 systemInstruction
func buildSystemInstruction(system json.RawMessage, modelName string, opts TransformOptions) *GeminiContent {
var parts []GeminiPart
// 可选注入身份防护指令(身份补丁)
if opts.EnableIdentityPatch {
identityPatch := strings.TrimSpace(opts.IdentityPatch)
if identityPatch == "" {
identityPatch = defaultIdentityPatch(modelName)
}
parts = append(parts, GeminiPart{Text: identityPatch})
}
// 先解析用户的 system prompt检测是否已包含 Antigravity identity
userHasAntigravityIdentity := false
var userSystemParts []GeminiPart
// 解析 system prompt
if len(system) > 0 {
// 尝试解析为字符串
var sysStr string
if err := json.Unmarshal(system, &sysStr); err == nil {
if strings.TrimSpace(sysStr) != "" {
parts = append(parts, GeminiPart{Text: sysStr})
userSystemParts = append(userSystemParts, GeminiPart{Text: sysStr})
if strings.Contains(sysStr, "You are Antigravity") {
userHasAntigravityIdentity = true
}
}
} else {
// 尝试解析为数组
@@ -142,17 +173,28 @@ func buildSystemInstruction(system json.RawMessage, modelName string, opts Trans
if err := json.Unmarshal(system, &sysBlocks); err == nil {
for _, block := range sysBlocks {
if block.Type == "text" && strings.TrimSpace(block.Text) != "" {
parts = append(parts, GeminiPart{Text: block.Text})
userSystemParts = append(userSystemParts, GeminiPart{Text: block.Text})
if strings.Contains(block.Text, "You are Antigravity") {
userHasAntigravityIdentity = true
}
}
}
}
}
}
// identity patch 模式下,用分隔符包裹 system prompt便于上游识别/调试;关闭时尽量保持原始 system prompt。
if opts.EnableIdentityPatch && len(parts) > 0 {
parts = append(parts, GeminiPart{Text: "\n--- [SYSTEM_PROMPT_END] ---"})
// 仅在用户未提供 Antigravity identity 时注入
if opts.EnableIdentityPatch && !userHasAntigravityIdentity {
identityPatch := strings.TrimSpace(opts.IdentityPatch)
if identityPatch == "" {
identityPatch = defaultIdentityPatch(modelName)
}
parts = append(parts, GeminiPart{Text: identityPatch})
}
// 添加用户的 system prompt
parts = append(parts, userSystemParts...)
if len(parts) == 0 {
return nil
}

View File

@@ -76,7 +76,8 @@ func (r *accountRepository) Create(ctx context.Context, account *service.Account
SetPriority(account.Priority).
SetStatus(account.Status).
SetErrorMessage(account.ErrorMessage).
SetSchedulable(account.Schedulable)
SetSchedulable(account.Schedulable).
SetAutoPauseOnExpired(account.AutoPauseOnExpired)
if account.ProxyID != nil {
builder.SetProxyID(*account.ProxyID)
@@ -84,6 +85,9 @@ func (r *accountRepository) Create(ctx context.Context, account *service.Account
if account.LastUsedAt != nil {
builder.SetLastUsedAt(*account.LastUsedAt)
}
if account.ExpiresAt != nil {
builder.SetExpiresAt(*account.ExpiresAt)
}
if account.RateLimitedAt != nil {
builder.SetRateLimitedAt(*account.RateLimitedAt)
}
@@ -280,7 +284,8 @@ func (r *accountRepository) Update(ctx context.Context, account *service.Account
SetPriority(account.Priority).
SetStatus(account.Status).
SetErrorMessage(account.ErrorMessage).
SetSchedulable(account.Schedulable)
SetSchedulable(account.Schedulable).
SetAutoPauseOnExpired(account.AutoPauseOnExpired)
if account.ProxyID != nil {
builder.SetProxyID(*account.ProxyID)
@@ -292,6 +297,11 @@ func (r *accountRepository) Update(ctx context.Context, account *service.Account
} else {
builder.ClearLastUsedAt()
}
if account.ExpiresAt != nil {
builder.SetExpiresAt(*account.ExpiresAt)
} else {
builder.ClearExpiresAt()
}
if account.RateLimitedAt != nil {
builder.SetRateLimitedAt(*account.RateLimitedAt)
} else {
@@ -570,6 +580,7 @@ func (r *accountRepository) ListSchedulable(ctx context.Context) ([]service.Acco
dbaccount.StatusEQ(service.StatusActive),
dbaccount.SchedulableEQ(true),
tempUnschedulablePredicate(),
notExpiredPredicate(now),
dbaccount.Or(dbaccount.OverloadUntilIsNil(), dbaccount.OverloadUntilLTE(now)),
dbaccount.Or(dbaccount.RateLimitResetAtIsNil(), dbaccount.RateLimitResetAtLTE(now)),
).
@@ -596,6 +607,7 @@ func (r *accountRepository) ListSchedulableByPlatform(ctx context.Context, platf
dbaccount.StatusEQ(service.StatusActive),
dbaccount.SchedulableEQ(true),
tempUnschedulablePredicate(),
notExpiredPredicate(now),
dbaccount.Or(dbaccount.OverloadUntilIsNil(), dbaccount.OverloadUntilLTE(now)),
dbaccount.Or(dbaccount.RateLimitResetAtIsNil(), dbaccount.RateLimitResetAtLTE(now)),
).
@@ -629,6 +641,7 @@ func (r *accountRepository) ListSchedulableByPlatforms(ctx context.Context, plat
dbaccount.StatusEQ(service.StatusActive),
dbaccount.SchedulableEQ(true),
tempUnschedulablePredicate(),
notExpiredPredicate(now),
dbaccount.Or(dbaccount.OverloadUntilIsNil(), dbaccount.OverloadUntilLTE(now)),
dbaccount.Or(dbaccount.RateLimitResetAtIsNil(), dbaccount.RateLimitResetAtLTE(now)),
).
@@ -727,6 +740,27 @@ func (r *accountRepository) SetSchedulable(ctx context.Context, id int64, schedu
return err
}
func (r *accountRepository) AutoPauseExpiredAccounts(ctx context.Context, now time.Time) (int64, error) {
result, err := r.sql.ExecContext(ctx, `
UPDATE accounts
SET schedulable = FALSE,
updated_at = NOW()
WHERE deleted_at IS NULL
AND schedulable = TRUE
AND auto_pause_on_expired = TRUE
AND expires_at IS NOT NULL
AND expires_at <= $1
`, now)
if err != nil {
return 0, err
}
rows, err := result.RowsAffected()
if err != nil {
return 0, err
}
return rows, nil
}
func (r *accountRepository) UpdateExtra(ctx context.Context, id int64, updates map[string]any) error {
if len(updates) == 0 {
return nil
@@ -861,6 +895,7 @@ func (r *accountRepository) queryAccountsByGroup(ctx context.Context, groupID in
preds = append(preds,
dbaccount.SchedulableEQ(true),
tempUnschedulablePredicate(),
notExpiredPredicate(now),
dbaccount.Or(dbaccount.OverloadUntilIsNil(), dbaccount.OverloadUntilLTE(now)),
dbaccount.Or(dbaccount.RateLimitResetAtIsNil(), dbaccount.RateLimitResetAtLTE(now)),
)
@@ -971,6 +1006,14 @@ func tempUnschedulablePredicate() dbpredicate.Account {
})
}
func notExpiredPredicate(now time.Time) dbpredicate.Account {
return dbaccount.Or(
dbaccount.ExpiresAtIsNil(),
dbaccount.ExpiresAtGT(now),
dbaccount.AutoPauseOnExpiredEQ(false),
)
}
func (r *accountRepository) loadTempUnschedStates(ctx context.Context, accountIDs []int64) (map[int64]tempUnschedSnapshot, error) {
out := make(map[int64]tempUnschedSnapshot)
if len(accountIDs) == 0 {
@@ -1086,6 +1129,8 @@ func accountEntityToService(m *dbent.Account) *service.Account {
Status: m.Status,
ErrorMessage: derefString(m.ErrorMessage),
LastUsedAt: m.LastUsedAt,
ExpiresAt: m.ExpiresAt,
AutoPauseOnExpired: m.AutoPauseOnExpired,
CreatedAt: m.CreatedAt,
UpdatedAt: m.UpdatedAt,
Schedulable: m.Schedulable,

View File

@@ -14,23 +14,33 @@ import (
)
type githubReleaseClient struct {
httpClient *http.Client
allowPrivateHosts bool
httpClient *http.Client
downloadHTTPClient *http.Client
}
func NewGitHubReleaseClient() service.GitHubReleaseClient {
allowPrivate := false
// NewGitHubReleaseClient 创建 GitHub Release 客户端
// proxyURL 为空时直连 GitHub支持 http/https/socks5/socks5h 协议
func NewGitHubReleaseClient(proxyURL string) service.GitHubReleaseClient {
sharedClient, err := httpclient.GetClient(httpclient.Options{
Timeout: 30 * time.Second,
ValidateResolvedIP: true,
AllowPrivateHosts: allowPrivate,
Timeout: 30 * time.Second,
ProxyURL: proxyURL,
})
if err != nil {
sharedClient = &http.Client{Timeout: 30 * time.Second}
}
// 下载客户端需要更长的超时时间
downloadClient, err := httpclient.GetClient(httpclient.Options{
Timeout: 10 * time.Minute,
ProxyURL: proxyURL,
})
if err != nil {
downloadClient = &http.Client{Timeout: 10 * time.Minute}
}
return &githubReleaseClient{
httpClient: sharedClient,
allowPrivateHosts: allowPrivate,
httpClient: sharedClient,
downloadHTTPClient: downloadClient,
}
}
@@ -68,15 +78,8 @@ func (c *githubReleaseClient) DownloadFile(ctx context.Context, url, dest string
return err
}
downloadClient, err := httpclient.GetClient(httpclient.Options{
Timeout: 10 * time.Minute,
ValidateResolvedIP: true,
AllowPrivateHosts: c.allowPrivateHosts,
})
if err != nil {
downloadClient = &http.Client{Timeout: 10 * time.Minute}
}
resp, err := downloadClient.Do(req)
// 使用预配置的下载客户端(已包含代理配置)
resp, err := c.downloadHTTPClient.Do(req)
if err != nil {
return err
}

View File

@@ -39,8 +39,8 @@ func (t *testTransport) RoundTrip(req *http.Request) (*http.Response, error) {
func newTestGitHubReleaseClient() *githubReleaseClient {
return &githubReleaseClient{
httpClient: &http.Client{},
allowPrivateHosts: true,
httpClient: &http.Client{},
downloadHTTPClient: &http.Client{},
}
}
@@ -234,7 +234,7 @@ func (s *GitHubReleaseServiceSuite) TestFetchLatestRelease_Success() {
httpClient: &http.Client{
Transport: &testTransport{testServerURL: s.srv.URL},
},
allowPrivateHosts: true,
downloadHTTPClient: &http.Client{},
}
release, err := s.client.FetchLatestRelease(context.Background(), "test/repo")
@@ -254,7 +254,7 @@ func (s *GitHubReleaseServiceSuite) TestFetchLatestRelease_Non200() {
httpClient: &http.Client{
Transport: &testTransport{testServerURL: s.srv.URL},
},
allowPrivateHosts: true,
downloadHTTPClient: &http.Client{},
}
_, err := s.client.FetchLatestRelease(context.Background(), "test/repo")
@@ -272,7 +272,7 @@ func (s *GitHubReleaseServiceSuite) TestFetchLatestRelease_InvalidJSON() {
httpClient: &http.Client{
Transport: &testTransport{testServerURL: s.srv.URL},
},
allowPrivateHosts: true,
downloadHTTPClient: &http.Client{},
}
_, err := s.client.FetchLatestRelease(context.Background(), "test/repo")
@@ -288,7 +288,7 @@ func (s *GitHubReleaseServiceSuite) TestFetchLatestRelease_ContextCancel() {
httpClient: &http.Client{
Transport: &testTransport{testServerURL: s.srv.URL},
},
allowPrivateHosts: true,
downloadHTTPClient: &http.Client{},
}
ctx, cancel := context.WithCancel(context.Background())

View File

@@ -8,7 +8,6 @@ import (
"strings"
"time"
"github.com/Wei-Shaw/sub2api/internal/config"
"github.com/Wei-Shaw/sub2api/internal/pkg/httpclient"
"github.com/Wei-Shaw/sub2api/internal/service"
)
@@ -17,17 +16,12 @@ type pricingRemoteClient struct {
httpClient *http.Client
}
func NewPricingRemoteClient(cfg *config.Config) service.PricingRemoteClient {
allowPrivate := false
validateResolvedIP := true
if cfg != nil {
allowPrivate = cfg.Security.URLAllowlist.AllowPrivateHosts
validateResolvedIP = cfg.Security.URLAllowlist.Enabled
}
// NewPricingRemoteClient 创建定价数据远程客户端
// proxyURL 为空时直连,支持 http/https/socks5/socks5h 协议
func NewPricingRemoteClient(proxyURL string) service.PricingRemoteClient {
sharedClient, err := httpclient.GetClient(httpclient.Options{
Timeout: 30 * time.Second,
ValidateResolvedIP: validateResolvedIP,
AllowPrivateHosts: allowPrivate,
Timeout: 30 * time.Second,
ProxyURL: proxyURL,
})
if err != nil {
sharedClient = &http.Client{Timeout: 30 * time.Second}

View File

@@ -6,7 +6,6 @@ import (
"net/http/httptest"
"testing"
"github.com/Wei-Shaw/sub2api/internal/config"
"github.com/stretchr/testify/require"
"github.com/stretchr/testify/suite"
)
@@ -20,13 +19,7 @@ type PricingServiceSuite struct {
func (s *PricingServiceSuite) SetupTest() {
s.ctx = context.Background()
client, ok := NewPricingRemoteClient(&config.Config{
Security: config.SecurityConfig{
URLAllowlist: config.URLAllowlistConfig{
AllowPrivateHosts: true,
},
},
}).(*pricingRemoteClient)
client, ok := NewPricingRemoteClient("").(*pricingRemoteClient)
require.True(s.T(), ok, "type assertion failed")
s.client = client
}

View File

@@ -22,7 +22,7 @@ import (
"github.com/lib/pq"
)
const usageLogSelectColumns = "id, user_id, api_key_id, account_id, request_id, model, group_id, subscription_id, input_tokens, output_tokens, cache_creation_tokens, cache_read_tokens, cache_creation_5m_tokens, cache_creation_1h_tokens, input_cost, output_cost, cache_creation_cost, cache_read_cost, total_cost, actual_cost, rate_multiplier, billing_type, stream, duration_ms, first_token_ms, image_count, image_size, created_at"
const usageLogSelectColumns = "id, user_id, api_key_id, account_id, request_id, model, group_id, subscription_id, input_tokens, output_tokens, cache_creation_tokens, cache_read_tokens, cache_creation_5m_tokens, cache_creation_1h_tokens, input_cost, output_cost, cache_creation_cost, cache_read_cost, total_cost, actual_cost, rate_multiplier, billing_type, stream, duration_ms, first_token_ms, user_agent, image_count, image_size, created_at"
type usageLogRepository struct {
client *dbent.Client
@@ -109,6 +109,7 @@ func (r *usageLogRepository) Create(ctx context.Context, log *service.UsageLog)
stream,
duration_ms,
first_token_ms,
user_agent,
image_count,
image_size,
created_at
@@ -118,8 +119,7 @@ func (r *usageLogRepository) Create(ctx context.Context, log *service.UsageLog)
$8, $9, $10, $11,
$12, $13,
$14, $15, $16, $17, $18, $19,
$20, $21, $22, $23, $24,
$25, $26, $27
$20, $21, $22, $23, $24, $25, $26, $27, $28
)
ON CONFLICT (request_id, api_key_id) DO NOTHING
RETURNING id, created_at
@@ -129,6 +129,7 @@ func (r *usageLogRepository) Create(ctx context.Context, log *service.UsageLog)
subscriptionID := nullInt64(log.SubscriptionID)
duration := nullInt(log.DurationMs)
firstToken := nullInt(log.FirstTokenMs)
userAgent := nullString(log.UserAgent)
imageSize := nullString(log.ImageSize)
var requestIDArg any
@@ -161,6 +162,7 @@ func (r *usageLogRepository) Create(ctx context.Context, log *service.UsageLog)
log.Stream,
duration,
firstToken,
userAgent,
log.ImageCount,
imageSize,
createdAt,
@@ -1870,6 +1872,7 @@ func scanUsageLog(scanner interface{ Scan(...any) error }) (*service.UsageLog, e
stream bool
durationMs sql.NullInt64
firstTokenMs sql.NullInt64
userAgent sql.NullString
imageCount int
imageSize sql.NullString
createdAt time.Time
@@ -1901,6 +1904,7 @@ func scanUsageLog(scanner interface{ Scan(...any) error }) (*service.UsageLog, e
&stream,
&durationMs,
&firstTokenMs,
&userAgent,
&imageCount,
&imageSize,
&createdAt,
@@ -1952,6 +1956,9 @@ func scanUsageLog(scanner interface{ Scan(...any) error }) (*service.UsageLog, e
value := int(firstTokenMs.Int64)
log.FirstTokenMs = &value
}
if userAgent.Valid {
log.UserAgent = &userAgent.String
}
if imageSize.Valid {
log.ImageSize = &imageSize.String
}

View File

@@ -25,6 +25,18 @@ func ProvideConcurrencyCache(rdb *redis.Client, cfg *config.Config) service.Conc
return NewConcurrencyCache(rdb, cfg.Gateway.ConcurrencySlotTTLMinutes, waitTTLSeconds)
}
// ProvideGitHubReleaseClient 创建 GitHub Release 客户端
// 从配置中读取代理设置,支持国内服务器通过代理访问 GitHub
func ProvideGitHubReleaseClient(cfg *config.Config) service.GitHubReleaseClient {
return NewGitHubReleaseClient(cfg.Update.ProxyURL)
}
// ProvidePricingRemoteClient 创建定价数据远程客户端
// 从配置中读取代理设置,支持国内服务器通过代理访问 GitHub 上的定价数据
func ProvidePricingRemoteClient(cfg *config.Config) service.PricingRemoteClient {
return NewPricingRemoteClient(cfg.Update.ProxyURL)
}
// ProviderSet is the Wire provider set for all repositories
var ProviderSet = wire.NewSet(
NewUserRepository,
@@ -53,8 +65,8 @@ var ProviderSet = wire.NewSet(
// HTTP service ports (DI Strategy A: return interface directly)
NewTurnstileVerifier,
NewPricingRemoteClient,
NewGitHubReleaseClient,
ProvidePricingRemoteClient,
ProvideGitHubReleaseClient,
NewProxyExitInfoProber,
NewClaudeUsageFetcher,
NewClaudeOAuthClient,

View File

@@ -9,21 +9,23 @@ import (
)
type Account struct {
ID int64
Name string
Notes *string
Platform string
Type string
Credentials map[string]any
Extra map[string]any
ProxyID *int64
Concurrency int
Priority int
Status string
ErrorMessage string
LastUsedAt *time.Time
CreatedAt time.Time
UpdatedAt time.Time
ID int64
Name string
Notes *string
Platform string
Type string
Credentials map[string]any
Extra map[string]any
ProxyID *int64
Concurrency int
Priority int
Status string
ErrorMessage string
LastUsedAt *time.Time
ExpiresAt *time.Time
AutoPauseOnExpired bool
CreatedAt time.Time
UpdatedAt time.Time
Schedulable bool
@@ -60,6 +62,9 @@ func (a *Account) IsSchedulable() bool {
return false
}
now := time.Now()
if a.AutoPauseOnExpired && a.ExpiresAt != nil && !now.Before(*a.ExpiresAt) {
return false
}
if a.OverloadUntil != nil && now.Before(*a.OverloadUntil) {
return false
}

View File

@@ -0,0 +1,71 @@
package service
import (
"context"
"log"
"sync"
"time"
)
// AccountExpiryService periodically pauses expired accounts when auto-pause is enabled.
type AccountExpiryService struct {
accountRepo AccountRepository
interval time.Duration
stopCh chan struct{}
stopOnce sync.Once
wg sync.WaitGroup
}
func NewAccountExpiryService(accountRepo AccountRepository, interval time.Duration) *AccountExpiryService {
return &AccountExpiryService{
accountRepo: accountRepo,
interval: interval,
stopCh: make(chan struct{}),
}
}
func (s *AccountExpiryService) Start() {
if s == nil || s.accountRepo == nil || s.interval <= 0 {
return
}
s.wg.Add(1)
go func() {
defer s.wg.Done()
ticker := time.NewTicker(s.interval)
defer ticker.Stop()
s.runOnce()
for {
select {
case <-ticker.C:
s.runOnce()
case <-s.stopCh:
return
}
}
}()
}
func (s *AccountExpiryService) Stop() {
if s == nil {
return
}
s.stopOnce.Do(func() {
close(s.stopCh)
})
s.wg.Wait()
}
func (s *AccountExpiryService) runOnce() {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
updated, err := s.accountRepo.AutoPauseExpiredAccounts(ctx, time.Now())
if err != nil {
log.Printf("[AccountExpiry] Auto pause expired accounts failed: %v", err)
return
}
if updated > 0 {
log.Printf("[AccountExpiry] Auto paused %d expired accounts", updated)
}
}

View File

@@ -38,6 +38,7 @@ type AccountRepository interface {
BatchUpdateLastUsed(ctx context.Context, updates map[int64]time.Time) error
SetError(ctx context.Context, id int64, errorMsg string) error
SetSchedulable(ctx context.Context, id int64, schedulable bool) error
AutoPauseExpiredAccounts(ctx context.Context, now time.Time) (int64, error)
BindGroups(ctx context.Context, accountID int64, groupIDs []int64) error
ListSchedulable(ctx context.Context) ([]Account, error)
@@ -71,29 +72,33 @@ type AccountBulkUpdate struct {
// CreateAccountRequest 创建账号请求
type CreateAccountRequest struct {
Name string `json:"name"`
Notes *string `json:"notes"`
Platform string `json:"platform"`
Type string `json:"type"`
Credentials map[string]any `json:"credentials"`
Extra map[string]any `json:"extra"`
ProxyID *int64 `json:"proxy_id"`
Concurrency int `json:"concurrency"`
Priority int `json:"priority"`
GroupIDs []int64 `json:"group_ids"`
Name string `json:"name"`
Notes *string `json:"notes"`
Platform string `json:"platform"`
Type string `json:"type"`
Credentials map[string]any `json:"credentials"`
Extra map[string]any `json:"extra"`
ProxyID *int64 `json:"proxy_id"`
Concurrency int `json:"concurrency"`
Priority int `json:"priority"`
GroupIDs []int64 `json:"group_ids"`
ExpiresAt *time.Time `json:"expires_at"`
AutoPauseOnExpired *bool `json:"auto_pause_on_expired"`
}
// UpdateAccountRequest 更新账号请求
type UpdateAccountRequest struct {
Name *string `json:"name"`
Notes *string `json:"notes"`
Credentials *map[string]any `json:"credentials"`
Extra *map[string]any `json:"extra"`
ProxyID *int64 `json:"proxy_id"`
Concurrency *int `json:"concurrency"`
Priority *int `json:"priority"`
Status *string `json:"status"`
GroupIDs *[]int64 `json:"group_ids"`
Name *string `json:"name"`
Notes *string `json:"notes"`
Credentials *map[string]any `json:"credentials"`
Extra *map[string]any `json:"extra"`
ProxyID *int64 `json:"proxy_id"`
Concurrency *int `json:"concurrency"`
Priority *int `json:"priority"`
Status *string `json:"status"`
GroupIDs *[]int64 `json:"group_ids"`
ExpiresAt *time.Time `json:"expires_at"`
AutoPauseOnExpired *bool `json:"auto_pause_on_expired"`
}
// AccountService 账号管理服务
@@ -134,6 +139,12 @@ func (s *AccountService) Create(ctx context.Context, req CreateAccountRequest) (
Concurrency: req.Concurrency,
Priority: req.Priority,
Status: StatusActive,
ExpiresAt: req.ExpiresAt,
}
if req.AutoPauseOnExpired != nil {
account.AutoPauseOnExpired = *req.AutoPauseOnExpired
} else {
account.AutoPauseOnExpired = true
}
if err := s.accountRepo.Create(ctx, account); err != nil {
@@ -224,6 +235,12 @@ func (s *AccountService) Update(ctx context.Context, id int64, req UpdateAccount
if req.Status != nil {
account.Status = *req.Status
}
if req.ExpiresAt != nil {
account.ExpiresAt = req.ExpiresAt
}
if req.AutoPauseOnExpired != nil {
account.AutoPauseOnExpired = *req.AutoPauseOnExpired
}
// 先验证分组是否存在(在任何写操作之前)
if req.GroupIDs != nil {

View File

@@ -103,6 +103,10 @@ func (s *accountRepoStub) SetSchedulable(ctx context.Context, id int64, schedula
panic("unexpected SetSchedulable call")
}
func (s *accountRepoStub) AutoPauseExpiredAccounts(ctx context.Context, now time.Time) (int64, error) {
panic("unexpected AutoPauseExpiredAccounts call")
}
func (s *accountRepoStub) BindGroups(ctx context.Context, accountID int64, groupIDs []int64) error {
panic("unexpected BindGroups call")
}

View File

@@ -122,16 +122,18 @@ type UpdateGroupInput struct {
}
type CreateAccountInput struct {
Name string
Notes *string
Platform string
Type string
Credentials map[string]any
Extra map[string]any
ProxyID *int64
Concurrency int
Priority int
GroupIDs []int64
Name string
Notes *string
Platform string
Type string
Credentials map[string]any
Extra map[string]any
ProxyID *int64
Concurrency int
Priority int
GroupIDs []int64
ExpiresAt *int64
AutoPauseOnExpired *bool
// SkipMixedChannelCheck skips the mixed channel risk check when binding groups.
// This should only be set when the caller has explicitly confirmed the risk.
SkipMixedChannelCheck bool
@@ -148,6 +150,8 @@ type UpdateAccountInput struct {
Priority *int // 使用指针区分"未提供"和"设置为0"
Status string
GroupIDs *[]int64
ExpiresAt *int64
AutoPauseOnExpired *bool
SkipMixedChannelCheck bool // 跳过混合渠道检查(用户已确认风险)
}
@@ -700,6 +704,15 @@ func (s *adminServiceImpl) CreateAccount(ctx context.Context, input *CreateAccou
Status: StatusActive,
Schedulable: true,
}
if input.ExpiresAt != nil && *input.ExpiresAt > 0 {
expiresAt := time.Unix(*input.ExpiresAt, 0)
account.ExpiresAt = &expiresAt
}
if input.AutoPauseOnExpired != nil {
account.AutoPauseOnExpired = *input.AutoPauseOnExpired
} else {
account.AutoPauseOnExpired = true
}
if err := s.accountRepo.Create(ctx, account); err != nil {
return nil, err
}
@@ -755,6 +768,17 @@ func (s *adminServiceImpl) UpdateAccount(ctx context.Context, id int64, input *U
if input.Status != "" {
account.Status = input.Status
}
if input.ExpiresAt != nil {
if *input.ExpiresAt <= 0 {
account.ExpiresAt = nil
} else {
expiresAt := time.Unix(*input.ExpiresAt, 0)
account.ExpiresAt = &expiresAt
}
}
if input.AutoPauseOnExpired != nil {
account.AutoPauseOnExpired = *input.AutoPauseOnExpired
}
// 先验证分组是否存在(在任何写操作之前)
if input.GroupIDs != nil {

View File

@@ -181,12 +181,17 @@ func (s *AntigravityGatewayService) TestConnection(ctx context.Context, account
return nil, fmt.Errorf("构建请求失败: %w", err)
}
// 构建 HTTP 请求(非流式
req, err := antigravity.NewAPIRequest(ctx, "generateContent", accessToken, requestBody)
// 构建 HTTP 请求(总是使用流式 endpoint与官方客户端一致
req, err := antigravity.NewAPIRequest(ctx, "streamGenerateContent", accessToken, requestBody)
if err != nil {
return nil, err
}
// DEBUG: 打印请求 header 和 body
log.Printf("[DEBUG] Antigravity TestConnection - URL: %s", req.URL.String())
log.Printf("[DEBUG] Antigravity TestConnection - Headers: %v", req.Header)
log.Printf("[DEBUG] Antigravity TestConnection - Body: %s", string(requestBody))
// 代理 URL
proxyURL := ""
if account.ProxyID != nil && account.Proxy != nil {
@@ -210,14 +215,8 @@ func (s *AntigravityGatewayService) TestConnection(ctx context.Context, account
return nil, fmt.Errorf("API 返回 %d: %s", resp.StatusCode, string(respBody))
}
// 解包 v1internal 响应
unwrapped, err := s.unwrapV1InternalResponse(respBody)
if err != nil {
return nil, fmt.Errorf("解包响应失败: %w", err)
}
// 提取响应文本
text := extractGeminiResponseText(unwrapped)
// 解析流式响应,提取文本
text := extractTextFromSSEResponse(respBody)
return &TestConnectionResult{
Text: text,
@@ -267,38 +266,66 @@ func (s *AntigravityGatewayService) getClaudeTransformOptions(ctx context.Contex
return opts
}
// extractGeminiResponseText 从 Gemini 响应中提取文本
func extractGeminiResponseText(respBody []byte) string {
var resp map[string]any
if err := json.Unmarshal(respBody, &resp); err != nil {
return ""
}
candidates, ok := resp["candidates"].([]any)
if !ok || len(candidates) == 0 {
return ""
}
candidate, ok := candidates[0].(map[string]any)
if !ok {
return ""
}
content, ok := candidate["content"].(map[string]any)
if !ok {
return ""
}
parts, ok := content["parts"].([]any)
if !ok {
return ""
}
// extractTextFromSSEResponse 从 SSE 流式响应中提取文本
func extractTextFromSSEResponse(respBody []byte) string {
var texts []string
for _, part := range parts {
if partMap, ok := part.(map[string]any); ok {
if text, ok := partMap["text"].(string); ok && text != "" {
texts = append(texts, text)
lines := bytes.Split(respBody, []byte("\n"))
for _, line := range lines {
line = bytes.TrimSpace(line)
if len(line) == 0 {
continue
}
// 跳过 SSE 前缀
if bytes.HasPrefix(line, []byte("data:")) {
line = bytes.TrimPrefix(line, []byte("data:"))
line = bytes.TrimSpace(line)
}
// 跳过非 JSON 行
if len(line) == 0 || line[0] != '{' {
continue
}
// 解析 JSON
var data map[string]any
if err := json.Unmarshal(line, &data); err != nil {
continue
}
// 尝试从 response.candidates[0].content.parts[].text 提取
response, ok := data["response"].(map[string]any)
if !ok {
// 尝试直接从 candidates 提取(某些响应格式)
response = data
}
candidates, ok := response["candidates"].([]any)
if !ok || len(candidates) == 0 {
continue
}
candidate, ok := candidates[0].(map[string]any)
if !ok {
continue
}
content, ok := candidate["content"].(map[string]any)
if !ok {
continue
}
parts, ok := content["parts"].([]any)
if !ok {
continue
}
for _, part := range parts {
if partMap, ok := part.(map[string]any); ok {
if text, ok := partMap["text"].(string); ok && text != "" {
texts = append(texts, text)
}
}
}
}
@@ -316,7 +343,7 @@ func (s *AntigravityGatewayService) wrapV1InternalRequest(projectID, model strin
wrapped := map[string]any{
"project": projectID,
"requestId": "agent-" + uuid.New().String(),
"userAgent": "sub2api",
"userAgent": "antigravity", // 固定值,与官方客户端一致
"requestType": "agent",
"model": model,
"request": request,
@@ -397,10 +424,10 @@ func (s *AntigravityGatewayService) Forward(ctx context.Context, c *gin.Context,
return nil, fmt.Errorf("transform request: %w", err)
}
// 构建上游 action
// 构建上游 actionNewAPIRequest 会自动处理 ?alt=sse 和 Accept Header
action := "generateContent"
if claudeReq.Stream {
action = "streamGenerateContent?alt=sse"
action = "streamGenerateContent"
}
// 重试循环
@@ -907,14 +934,11 @@ func (s *AntigravityGatewayService) ForwardGemini(ctx context.Context, c *gin.Co
return nil, err
}
// 构建上游 action
// 构建上游 actionNewAPIRequest 会自动处理 ?alt=sse 和 Accept Header
upstreamAction := action
if action == "generateContent" && stream {
upstreamAction = "streamGenerateContent"
}
if stream || upstreamAction == "streamGenerateContent" {
upstreamAction += "?alt=sse"
}
// 重试循环
var resp *http.Response

View File

@@ -105,6 +105,9 @@ func (m *mockAccountRepoForPlatform) SetError(ctx context.Context, id int64, err
func (m *mockAccountRepoForPlatform) SetSchedulable(ctx context.Context, id int64, schedulable bool) error {
return nil
}
func (m *mockAccountRepoForPlatform) AutoPauseExpiredAccounts(ctx context.Context, now time.Time) (int64, error) {
return 0, nil
}
func (m *mockAccountRepoForPlatform) BindGroups(ctx context.Context, accountID int64, groupIDs []int64) error {
return nil
}

View File

@@ -109,12 +109,13 @@ type ClaudeUsage struct {
// ForwardResult 转发结果
type ForwardResult struct {
RequestID string
Usage ClaudeUsage
Model string
Stream bool
Duration time.Duration
FirstTokenMs *int // 首字时间(流式请求)
RequestID string
Usage ClaudeUsage
Model string
Stream bool
Duration time.Duration
FirstTokenMs *int // 首字时间(流式请求)
ClientDisconnect bool // 客户端是否在流式传输过程中断开
// 图片生成计费字段(仅 gemini-3-pro-image 使用)
ImageCount int // 生成的图片数量
@@ -1465,6 +1466,7 @@ func (s *GatewayService) Forward(ctx context.Context, c *gin.Context, account *A
// 处理正常响应
var usage *ClaudeUsage
var firstTokenMs *int
var clientDisconnect bool
if reqStream {
streamResult, err := s.handleStreamingResponse(ctx, resp, c, account, startTime, originalModel, reqModel)
if err != nil {
@@ -1477,6 +1479,7 @@ func (s *GatewayService) Forward(ctx context.Context, c *gin.Context, account *A
}
usage = streamResult.usage
firstTokenMs = streamResult.firstTokenMs
clientDisconnect = streamResult.clientDisconnect
} else {
usage, err = s.handleNonStreamingResponse(ctx, resp, c, account, originalModel, reqModel)
if err != nil {
@@ -1485,12 +1488,13 @@ func (s *GatewayService) Forward(ctx context.Context, c *gin.Context, account *A
}
return &ForwardResult{
RequestID: resp.Header.Get("x-request-id"),
Usage: *usage,
Model: originalModel, // 使用原始模型用于计费和日志
Stream: reqStream,
Duration: time.Since(startTime),
FirstTokenMs: firstTokenMs,
RequestID: resp.Header.Get("x-request-id"),
Usage: *usage,
Model: originalModel, // 使用原始模型用于计费和日志
Stream: reqStream,
Duration: time.Since(startTime),
FirstTokenMs: firstTokenMs,
ClientDisconnect: clientDisconnect,
}, nil
}
@@ -1845,8 +1849,9 @@ func (s *GatewayService) handleRetryExhaustedError(ctx context.Context, resp *ht
// streamingResult 流式响应结果
type streamingResult struct {
usage *ClaudeUsage
firstTokenMs *int
usage *ClaudeUsage
firstTokenMs *int
clientDisconnect bool // 客户端是否在流式传输过程中断开
}
func (s *GatewayService) handleStreamingResponse(ctx context.Context, resp *http.Response, c *gin.Context, account *Account, startTime time.Time, originalModel, mappedModel string) (*streamingResult, error) {
@@ -1942,14 +1947,27 @@ func (s *GatewayService) handleStreamingResponse(ctx context.Context, resp *http
}
needModelReplace := originalModel != mappedModel
clientDisconnected := false // 客户端断开标志断开后继续读取上游以获取完整usage
for {
select {
case ev, ok := <-events:
if !ok {
return &streamingResult{usage: usage, firstTokenMs: firstTokenMs}, nil
// 上游完成,返回结果
return &streamingResult{usage: usage, firstTokenMs: firstTokenMs, clientDisconnect: clientDisconnected}, nil
}
if ev.err != nil {
// 检测 context 取消(客户端断开会导致 context 取消,进而影响上游读取)
if errors.Is(ev.err, context.Canceled) || errors.Is(ev.err, context.DeadlineExceeded) {
log.Printf("Context canceled during streaming, returning collected usage")
return &streamingResult{usage: usage, firstTokenMs: firstTokenMs, clientDisconnect: true}, nil
}
// 客户端已通过写入失败检测到断开,上游也出错了,返回已收集的 usage
if clientDisconnected {
log.Printf("Upstream read error after client disconnect: %v, returning collected usage", ev.err)
return &streamingResult{usage: usage, firstTokenMs: firstTokenMs, clientDisconnect: true}, nil
}
// 客户端未断开,正常的错误处理
if errors.Is(ev.err, bufio.ErrTooLong) {
log.Printf("SSE line too long: account=%d max_size=%d error=%v", account.ID, maxLineSize, ev.err)
sendErrorEvent("response_too_large")
@@ -1960,38 +1978,40 @@ func (s *GatewayService) handleStreamingResponse(ctx context.Context, resp *http
}
line := ev.line
if line == "event: error" {
// 上游返回错误事件,如果客户端已断开仍返回已收集的 usage
if clientDisconnected {
return &streamingResult{usage: usage, firstTokenMs: firstTokenMs, clientDisconnect: true}, nil
}
return nil, errors.New("have error in stream")
}
// Extract data from SSE line (supports both "data: " and "data:" formats)
var data string
if sseDataRe.MatchString(line) {
data := sseDataRe.ReplaceAllString(line, "")
data = sseDataRe.ReplaceAllString(line, "")
// 如果有模型映射替换响应中的model字段
if needModelReplace {
line = s.replaceModelInSSELine(line, mappedModel, originalModel)
}
}
// 转发行
// 写入客户端(统一处理 data 行和非 data 行)
if !clientDisconnected {
if _, err := fmt.Fprintf(w, "%s\n", line); err != nil {
sendErrorEvent("write_failed")
return &streamingResult{usage: usage, firstTokenMs: firstTokenMs}, err
clientDisconnected = true
log.Printf("Client disconnected during streaming, continuing to drain upstream for billing")
} else {
flusher.Flush()
}
flusher.Flush()
}
// 记录首字时间:第一个有效的 content_block_delta 或 message_start
if firstTokenMs == nil && data != "" && data != "[DONE]" {
// 无论客户端是否断开,都解析 usage仅对 data 行)
if data != "" {
if firstTokenMs == nil && data != "[DONE]" {
ms := int(time.Since(startTime).Milliseconds())
firstTokenMs = &ms
}
s.parseSSEUsage(data, usage)
} else {
// 非 data 行直接转发
if _, err := fmt.Fprintf(w, "%s\n", line); err != nil {
sendErrorEvent("write_failed")
return &streamingResult{usage: usage, firstTokenMs: firstTokenMs}, err
}
flusher.Flush()
}
case <-intervalCh:
@@ -1999,6 +2019,11 @@ func (s *GatewayService) handleStreamingResponse(ctx context.Context, resp *http
if time.Since(lastRead) < streamInterval {
continue
}
if clientDisconnected {
// 客户端已断开,上游也超时了,返回已收集的 usage
log.Printf("Upstream timeout after client disconnect, returning collected usage")
return &streamingResult{usage: usage, firstTokenMs: firstTokenMs, clientDisconnect: true}, nil
}
log.Printf("Stream data interval timeout: account=%d model=%s interval=%s", account.ID, originalModel, streamInterval)
sendErrorEvent("stream_timeout")
return &streamingResult{usage: usage, firstTokenMs: firstTokenMs}, fmt.Errorf("stream data interval timeout")
@@ -2152,6 +2177,7 @@ type RecordUsageInput struct {
User *User
Account *Account
Subscription *UserSubscription // 可选:订阅信息
UserAgent string // 请求的 User-Agent
}
// RecordUsage 记录使用量并扣费(或更新订阅用量)
@@ -2237,6 +2263,11 @@ func (s *GatewayService) RecordUsage(ctx context.Context, input *RecordUsageInpu
CreatedAt: time.Now(),
}
// 添加 UserAgent
if input.UserAgent != "" {
usageLog.UserAgent = &input.UserAgent
}
// 添加分组和订阅关联
if apiKey.GroupID != nil {
usageLog.GroupID = apiKey.GroupID

View File

@@ -90,6 +90,9 @@ func (m *mockAccountRepoForGemini) SetError(ctx context.Context, id int64, error
func (m *mockAccountRepoForGemini) SetSchedulable(ctx context.Context, id int64, schedulable bool) error {
return nil
}
func (m *mockAccountRepoForGemini) AutoPauseExpiredAccounts(ctx context.Context, now time.Time) (int64, error) {
return 0, nil
}
func (m *mockAccountRepoForGemini) BindGroups(ctx context.Context, accountID int64, groupIDs []int64) error {
return nil
}

View File

@@ -1092,6 +1092,7 @@ type OpenAIRecordUsageInput struct {
User *User
Account *Account
Subscription *UserSubscription
UserAgent string // 请求的 User-Agent
}
// RecordUsage records usage and deducts balance
@@ -1161,6 +1162,11 @@ func (s *OpenAIGatewayService) RecordUsage(ctx context.Context, input *OpenAIRec
CreatedAt: time.Now(),
}
// 添加 UserAgent
if input.UserAgent != "" {
usageLog.UserAgent = &input.UserAgent
}
if apiKey.GroupID != nil {
usageLog.GroupID = apiKey.GroupID
}

View File

@@ -38,6 +38,7 @@ type UsageLog struct {
Stream bool
DurationMs *int
FirstTokenMs *int
UserAgent *string
// 图片生成字段
ImageCount int

View File

@@ -47,6 +47,13 @@ func ProvideTokenRefreshService(
return svc
}
// ProvideAccountExpiryService creates and starts AccountExpiryService.
func ProvideAccountExpiryService(accountRepo AccountRepository) *AccountExpiryService {
svc := NewAccountExpiryService(accountRepo, time.Minute)
svc.Start()
return svc
}
// ProvideTimingWheelService creates and starts TimingWheelService
func ProvideTimingWheelService() *TimingWheelService {
svc := NewTimingWheelService()
@@ -110,6 +117,7 @@ var ProviderSet = wire.NewSet(
NewCRSSyncService,
ProvideUpdateService,
ProvideTokenRefreshService,
ProvideAccountExpiryService,
ProvideTimingWheelService,
ProvideDeferredService,
NewAntigravityQuotaFetcher,

View File

@@ -0,0 +1,10 @@
-- Add user_agent column to usage_logs table
-- Records the User-Agent header from API requests for analytics and debugging
ALTER TABLE usage_logs
ADD COLUMN IF NOT EXISTS user_agent VARCHAR(512);
-- Optional: Add index for user_agent queries (uncomment if needed for analytics)
-- CREATE INDEX IF NOT EXISTS idx_usage_logs_user_agent ON usage_logs(user_agent);
COMMENT ON COLUMN usage_logs.user_agent IS 'User-Agent header from the API request';

View File

@@ -0,0 +1,10 @@
-- Add expires_at for account expiration configuration
ALTER TABLE accounts ADD COLUMN IF NOT EXISTS expires_at timestamptz;
-- Document expires_at meaning
COMMENT ON COLUMN accounts.expires_at IS 'Account expiration time (NULL means no expiration).';
-- Add auto_pause_on_expired for account expiration scheduling control
ALTER TABLE accounts ADD COLUMN IF NOT EXISTS auto_pause_on_expired boolean NOT NULL DEFAULT true;
-- Document auto_pause_on_expired meaning
COMMENT ON COLUMN accounts.auto_pause_on_expired IS 'Auto pause scheduling when account expires.';
-- Ensure existing accounts are enabled by default
UPDATE accounts SET auto_pause_on_expired = true;

View File

@@ -123,3 +123,17 @@ GEMINI_OAUTH_SCOPES=
# Example:
# GEMINI_QUOTA_POLICY={"tiers":{"LEGACY":{"pro_rpd":50,"flash_rpd":1500,"cooldown_minutes":30},"PRO":{"pro_rpd":1500,"flash_rpd":4000,"cooldown_minutes":5},"ULTRA":{"pro_rpd":2000,"flash_rpd":0,"cooldown_minutes":5}}}
GEMINI_QUOTA_POLICY=
# -----------------------------------------------------------------------------
# Update Configuration (在线更新配置)
# -----------------------------------------------------------------------------
# Proxy URL for accessing GitHub (used for online updates and pricing data)
# 用于访问 GitHub 的代理地址(用于在线更新和定价数据获取)
# Supports: http, https, socks5, socks5h
# Examples:
# HTTP proxy: http://127.0.0.1:7890
# SOCKS5 proxy: socks5://127.0.0.1:1080
# With authentication: http://user:pass@proxy.example.com:8080
# Leave empty for direct connection (recommended for overseas servers)
# 留空表示直连(适用于海外服务器)
UPDATE_PROXY_URL=

View File

@@ -388,3 +388,18 @@ gemini:
# Cooldown time (minutes) after hitting quota
# 达到配额后的冷却时间(分钟)
cooldown_minutes: 5
# =============================================================================
# Update Configuration (在线更新配置)
# =============================================================================
update:
# Proxy URL for accessing GitHub (used for online updates and pricing data)
# 用于访问 GitHub 的代理地址(用于在线更新和定价数据获取)
# Supports: http, https, socks5, socks5h
# Examples:
# - HTTP proxy: "http://127.0.0.1:7890"
# - SOCKS5 proxy: "socks5://127.0.0.1:1080"
# - With authentication: "http://user:pass@proxy.example.com:8080"
# Leave empty for direct connection (recommended for overseas servers)
# 留空表示直连(适用于海外服务器)
proxy_url: ""

View File

@@ -109,6 +109,13 @@ services:
- SECURITY_URL_ALLOWLIST_ALLOW_PRIVATE_HOSTS=${SECURITY_URL_ALLOWLIST_ALLOW_PRIVATE_HOSTS:-false}
# Upstream hosts whitelist (comma-separated, only used when enabled=true)
- SECURITY_URL_ALLOWLIST_UPSTREAM_HOSTS=${SECURITY_URL_ALLOWLIST_UPSTREAM_HOSTS:-}
# =======================================================================
# Update Configuration (在线更新配置)
# =======================================================================
# Proxy for accessing GitHub (online updates + pricing data)
# Examples: http://host:port, socks5://host:port
- UPDATE_PROXY_URL=${UPDATE_PROXY_URL:-}
depends_on:
postgres:
condition: service_healthy

View File

@@ -1012,7 +1012,7 @@
</div>
<!-- Temp Unschedulable Rules -->
<div class="border-t border-gray-200 pt-4 dark:border-dark-600">
<div class="border-t border-gray-200 pt-4 dark:border-dark-600 space-y-4">
<div class="mb-3 flex items-center justify-between">
<div>
<label class="input-label mb-0">{{ t('admin.accounts.tempUnschedulable.title') }}</label>
@@ -1213,46 +1213,81 @@
<p class="input-hint">{{ t('admin.accounts.priorityHint') }}</p>
</div>
</div>
<div class="border-t border-gray-200 pt-4 dark:border-dark-600">
<label class="input-label">{{ t('admin.accounts.expiresAt') }}</label>
<input v-model="expiresAtInput" type="datetime-local" class="input" />
<p class="input-hint">{{ t('admin.accounts.expiresAtHint') }}</p>
</div>
<!-- Mixed Scheduling (only for antigravity accounts) -->
<div v-if="form.platform === 'antigravity'" class="flex items-center gap-2">
<label class="flex cursor-pointer items-center gap-2">
<input
type="checkbox"
v-model="mixedScheduling"
class="h-4 w-4 rounded border-gray-300 text-primary-500 focus:ring-primary-500 dark:border-dark-500"
/>
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">
{{ t('admin.accounts.mixedScheduling') }}
</span>
</label>
<div class="group relative">
<span
class="inline-flex h-4 w-4 cursor-help items-center justify-center rounded-full bg-gray-200 text-xs text-gray-500 hover:bg-gray-300 dark:bg-dark-600 dark:text-gray-400 dark:hover:bg-dark-500"
>
?
</span>
<!-- Tooltip向下显示避免被弹窗裁剪 -->
<div
class="pointer-events-none absolute left-0 top-full z-[100] mt-1.5 w-72 rounded bg-gray-900 px-3 py-2 text-xs text-white opacity-0 transition-opacity group-hover:opacity-100 dark:bg-gray-700"
>
{{ t('admin.accounts.mixedSchedulingTooltip') }}
<div
class="absolute bottom-full left-3 border-4 border-transparent border-b-gray-900 dark:border-b-gray-700"
></div>
<div>
<div class="flex items-center justify-between">
<div>
<label class="input-label mb-0">{{
t('admin.accounts.autoPauseOnExpired')
}}</label>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
{{ t('admin.accounts.autoPauseOnExpiredDesc') }}
</p>
</div>
<button
type="button"
@click="autoPauseOnExpired = !autoPauseOnExpired"
:class="[
'relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2',
autoPauseOnExpired ? 'bg-primary-600' : 'bg-gray-200 dark:bg-dark-600'
]"
>
<span
:class="[
'pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out',
autoPauseOnExpired ? 'translate-x-5' : 'translate-x-0'
]"
/>
</button>
</div>
</div>
<!-- Group Selection - 仅标准模式显示 -->
<GroupSelector
v-if="!authStore.isSimpleMode"
v-model="form.group_ids"
:groups="groups"
:platform="form.platform"
:mixed-scheduling="mixedScheduling"
data-tour="account-form-groups"
/>
<div class="border-t border-gray-200 pt-4 dark:border-dark-600">
<!-- Mixed Scheduling (only for antigravity accounts) -->
<div v-if="form.platform === 'antigravity'" class="flex items-center gap-2">
<label class="flex cursor-pointer items-center gap-2">
<input
type="checkbox"
v-model="mixedScheduling"
class="h-4 w-4 rounded border-gray-300 text-primary-500 focus:ring-primary-500 dark:border-dark-500"
/>
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">
{{ t('admin.accounts.mixedScheduling') }}
</span>
</label>
<div class="group relative">
<span
class="inline-flex h-4 w-4 cursor-help items-center justify-center rounded-full bg-gray-200 text-xs text-gray-500 hover:bg-gray-300 dark:bg-dark-600 dark:text-gray-400 dark:hover:bg-dark-500"
>
?
</span>
<!-- Tooltip向下显示避免被弹窗裁剪 -->
<div
class="pointer-events-none absolute left-0 top-full z-[100] mt-1.5 w-72 rounded bg-gray-900 px-3 py-2 text-xs text-white opacity-0 transition-opacity group-hover:opacity-100 dark:bg-gray-700"
>
{{ t('admin.accounts.mixedSchedulingTooltip') }}
<div
class="absolute bottom-full left-3 border-4 border-transparent border-b-gray-900 dark:border-b-gray-700"
></div>
</div>
</div>
</div>
<!-- Group Selection - 仅标准模式显示 -->
<GroupSelector
v-if="!authStore.isSimpleMode"
v-model="form.group_ids"
:groups="groups"
:platform="form.platform"
:mixed-scheduling="mixedScheduling"
data-tour="account-form-groups"
/>
</div>
</form>
@@ -1598,6 +1633,7 @@ import Icon from '@/components/icons/Icon.vue'
import ProxySelector from '@/components/common/ProxySelector.vue'
import GroupSelector from '@/components/common/GroupSelector.vue'
import ModelWhitelistSelector from '@/components/account/ModelWhitelistSelector.vue'
import { formatDateTimeLocalInput, parseDateTimeLocalInput } from '@/utils/format'
import OAuthAuthorizationFlow from './OAuthAuthorizationFlow.vue'
// Type for exposed OAuthAuthorizationFlow component
@@ -1713,6 +1749,7 @@ const customErrorCodesEnabled = ref(false)
const selectedErrorCodes = ref<number[]>([])
const customErrorCodeInput = ref<number | null>(null)
const interceptWarmupRequests = ref(false)
const autoPauseOnExpired = ref(true)
const mixedScheduling = ref(false) // For antigravity accounts: enable mixed scheduling
const tempUnschedEnabled = ref(false)
const tempUnschedRules = ref<TempUnschedRuleForm[]>([])
@@ -1795,7 +1832,8 @@ const form = reactive({
proxy_id: null as number | null,
concurrency: 10,
priority: 1,
group_ids: [] as number[]
group_ids: [] as number[],
expires_at: null as number | null
})
// Helper to check if current type needs OAuth flow
@@ -1805,6 +1843,13 @@ const isManualInputMethod = computed(() => {
return oauthFlowRef.value?.inputMethod === 'manual'
})
const expiresAtInput = computed({
get: () => formatDateTimeLocal(form.expires_at),
set: (value: string) => {
form.expires_at = parseDateTimeLocal(value)
}
})
const canExchangeCode = computed(() => {
const authCode = oauthFlowRef.value?.authCode || ''
if (form.platform === 'openai') {
@@ -2055,6 +2100,7 @@ const resetForm = () => {
form.concurrency = 10
form.priority = 1
form.group_ids = []
form.expires_at = null
accountCategory.value = 'oauth-based'
addMethod.value = 'oauth'
apiKeyBaseUrl.value = 'https://api.anthropic.com'
@@ -2066,6 +2112,7 @@ const resetForm = () => {
selectedErrorCodes.value = []
customErrorCodeInput.value = null
interceptWarmupRequests.value = false
autoPauseOnExpired.value = true
tempUnschedEnabled.value = false
tempUnschedRules.value = []
geminiOAuthType.value = 'code_assist'
@@ -2133,7 +2180,6 @@ const handleSubmit = async () => {
if (interceptWarmupRequests.value) {
credentials.intercept_warmup_requests = true
}
if (!applyTempUnschedConfig(credentials)) {
return
}
@@ -2144,7 +2190,8 @@ const handleSubmit = async () => {
try {
await adminAPI.accounts.create({
...form,
group_ids: form.group_ids
group_ids: form.group_ids,
auto_pause_on_expired: autoPauseOnExpired.value
})
appStore.showSuccess(t('admin.accounts.accountCreated'))
emit('created')
@@ -2182,6 +2229,9 @@ const handleGenerateUrl = async () => {
}
}
const formatDateTimeLocal = formatDateTimeLocalInput
const parseDateTimeLocal = parseDateTimeLocalInput
// Create account and handle success/failure
const createAccountAndFinish = async (
platform: AccountPlatform,
@@ -2202,7 +2252,9 @@ const createAccountAndFinish = async (
proxy_id: form.proxy_id,
concurrency: form.concurrency,
priority: form.priority,
group_ids: form.group_ids
group_ids: form.group_ids,
expires_at: form.expires_at,
auto_pause_on_expired: autoPauseOnExpired.value
})
appStore.showSuccess(t('admin.accounts.accountCreated'))
emit('created')
@@ -2416,7 +2468,8 @@ const handleCookieAuth = async (sessionKey: string) => {
extra,
proxy_id: form.proxy_id,
concurrency: form.concurrency,
priority: form.priority
priority: form.priority,
auto_pause_on_expired: autoPauseOnExpired.value
})
successCount++

View File

@@ -365,7 +365,7 @@
</div>
<!-- Temp Unschedulable Rules -->
<div class="border-t border-gray-200 pt-4 dark:border-dark-600">
<div class="border-t border-gray-200 pt-4 dark:border-dark-600 space-y-4">
<div class="mb-3 flex items-center justify-between">
<div>
<label class="input-label mb-0">{{ t('admin.accounts.tempUnschedulable.title') }}</label>
@@ -565,39 +565,74 @@
/>
</div>
</div>
<div>
<label class="input-label">{{ t('common.status') }}</label>
<Select v-model="form.status" :options="statusOptions" />
<div class="border-t border-gray-200 pt-4 dark:border-dark-600">
<label class="input-label">{{ t('admin.accounts.expiresAt') }}</label>
<input v-model="expiresAtInput" type="datetime-local" class="input" />
<p class="input-hint">{{ t('admin.accounts.expiresAtHint') }}</p>
</div>
<!-- Mixed Scheduling (only for antigravity accounts, read-only in edit mode) -->
<div v-if="account?.platform === 'antigravity'" class="flex items-center gap-2">
<label class="flex cursor-not-allowed items-center gap-2 opacity-60">
<input
type="checkbox"
v-model="mixedScheduling"
disabled
class="h-4 w-4 cursor-not-allowed rounded border-gray-300 text-primary-500 focus:ring-primary-500 dark:border-dark-500"
/>
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">
{{ t('admin.accounts.mixedScheduling') }}
</span>
</label>
<div class="group relative">
<span
class="inline-flex h-4 w-4 cursor-help items-center justify-center rounded-full bg-gray-200 text-xs text-gray-500 hover:bg-gray-300 dark:bg-dark-600 dark:text-gray-400 dark:hover:bg-dark-500"
<div>
<div class="flex items-center justify-between">
<div>
<label class="input-label mb-0">{{
t('admin.accounts.autoPauseOnExpired')
}}</label>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
{{ t('admin.accounts.autoPauseOnExpiredDesc') }}
</p>
</div>
<button
type="button"
@click="autoPauseOnExpired = !autoPauseOnExpired"
:class="[
'relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2',
autoPauseOnExpired ? 'bg-primary-600' : 'bg-gray-200 dark:bg-dark-600'
]"
>
?
</span>
<!-- Tooltip向下显示避免被弹窗裁剪 -->
<div
class="pointer-events-none absolute left-0 top-full z-[100] mt-1.5 w-72 rounded bg-gray-900 px-3 py-2 text-xs text-white opacity-0 transition-opacity group-hover:opacity-100 dark:bg-gray-700"
>
{{ t('admin.accounts.mixedSchedulingTooltip') }}
<span
:class="[
'pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out',
autoPauseOnExpired ? 'translate-x-5' : 'translate-x-0'
]"
/>
</button>
</div>
</div>
<div class="border-t border-gray-200 pt-4 dark:border-dark-600">
<div>
<label class="input-label">{{ t('common.status') }}</label>
<Select v-model="form.status" :options="statusOptions" />
</div>
<!-- Mixed Scheduling (only for antigravity accounts, read-only in edit mode) -->
<div v-if="account?.platform === 'antigravity'" class="flex items-center gap-2">
<label class="flex cursor-not-allowed items-center gap-2 opacity-60">
<input
type="checkbox"
v-model="mixedScheduling"
disabled
class="h-4 w-4 cursor-not-allowed rounded border-gray-300 text-primary-500 focus:ring-primary-500 dark:border-dark-500"
/>
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">
{{ t('admin.accounts.mixedScheduling') }}
</span>
</label>
<div class="group relative">
<span
class="inline-flex h-4 w-4 cursor-help items-center justify-center rounded-full bg-gray-200 text-xs text-gray-500 hover:bg-gray-300 dark:bg-dark-600 dark:text-gray-400 dark:hover:bg-dark-500"
>
?
</span>
<!-- Tooltip向下显示避免被弹窗裁剪 -->
<div
class="absolute bottom-full left-3 border-4 border-transparent border-b-gray-900 dark:border-b-gray-700"
></div>
class="pointer-events-none absolute left-0 top-full z-[100] mt-1.5 w-72 rounded bg-gray-900 px-3 py-2 text-xs text-white opacity-0 transition-opacity group-hover:opacity-100 dark:bg-gray-700"
>
{{ t('admin.accounts.mixedSchedulingTooltip') }}
<div
class="absolute bottom-full left-3 border-4 border-transparent border-b-gray-900 dark:border-b-gray-700"
></div>
</div>
</div>
</div>
</div>
@@ -666,6 +701,7 @@ import Icon from '@/components/icons/Icon.vue'
import ProxySelector from '@/components/common/ProxySelector.vue'
import GroupSelector from '@/components/common/GroupSelector.vue'
import ModelWhitelistSelector from '@/components/account/ModelWhitelistSelector.vue'
import { formatDateTimeLocalInput, parseDateTimeLocalInput } from '@/utils/format'
import {
getPresetMappingsByPlatform,
commonErrorCodes,
@@ -721,6 +757,7 @@ const customErrorCodesEnabled = ref(false)
const selectedErrorCodes = ref<number[]>([])
const customErrorCodeInput = ref<number | null>(null)
const interceptWarmupRequests = ref(false)
const autoPauseOnExpired = ref(false)
const mixedScheduling = ref(false) // For antigravity accounts: enable mixed scheduling
const tempUnschedEnabled = ref(false)
const tempUnschedRules = ref<TempUnschedRuleForm[]>([])
@@ -771,7 +808,8 @@ const form = reactive({
concurrency: 1,
priority: 1,
status: 'active' as 'active' | 'inactive',
group_ids: [] as number[]
group_ids: [] as number[],
expires_at: null as number | null
})
const statusOptions = computed(() => [
@@ -779,6 +817,13 @@ const statusOptions = computed(() => [
{ value: 'inactive', label: t('common.inactive') }
])
const expiresAtInput = computed({
get: () => formatDateTimeLocal(form.expires_at),
set: (value: string) => {
form.expires_at = parseDateTimeLocal(value)
}
})
// Watchers
watch(
() => props.account,
@@ -791,10 +836,12 @@ watch(
form.priority = newAccount.priority
form.status = newAccount.status as 'active' | 'inactive'
form.group_ids = newAccount.group_ids || []
form.expires_at = newAccount.expires_at ?? null
// Load intercept warmup requests setting (applies to all account types)
const credentials = newAccount.credentials as Record<string, unknown> | undefined
interceptWarmupRequests.value = credentials?.intercept_warmup_requests === true
autoPauseOnExpired.value = newAccount.auto_pause_on_expired === true
// Load mixed scheduling setting (only for antigravity accounts)
const extra = newAccount.extra as Record<string, unknown> | undefined
@@ -1042,6 +1089,9 @@ function toPositiveNumber(value: unknown) {
return Math.trunc(num)
}
const formatDateTimeLocal = formatDateTimeLocalInput
const parseDateTimeLocal = parseDateTimeLocalInput
// Methods
const handleClose = () => {
emit('close')
@@ -1057,6 +1107,10 @@ const handleSubmit = async () => {
if (updatePayload.proxy_id === null) {
updatePayload.proxy_id = 0
}
if (form.expires_at === null) {
updatePayload.expires_at = 0
}
updatePayload.auto_pause_on_expired = autoPauseOnExpired.value
// For apikey type, handle credentials update
if (props.account.type === 'apikey') {
@@ -1097,7 +1151,6 @@ const handleSubmit = async () => {
if (interceptWarmupRequests.value) {
newCredentials.intercept_warmup_requests = true
}
if (!applyTempUnschedConfig(newCredentials)) {
submitting.value = false
return
@@ -1114,7 +1167,6 @@ const handleSubmit = async () => {
} else {
delete newCredentials.intercept_warmup_requests
}
if (!applyTempUnschedConfig(newCredentials)) {
submitting.value = false
return

View File

@@ -40,7 +40,7 @@
</div>
<div class="min-w-0 flex-1">
<p class="text-sm font-medium text-gray-900 dark:text-white">{{ t('dashboard.redeemCode') }}</p>
<p class="text-xs text-gray-500 dark:text-dark-400">{{ t('dashboard.addBalance') }}</p>
<p class="text-xs text-gray-500 dark:text-dark-400">{{ t('dashboard.addBalanceWithCode') }}</p>
</div>
<Icon
name="chevronRight"

View File

@@ -1011,6 +1011,7 @@ export default {
groups: 'Groups',
usageWindows: 'Usage Windows',
lastUsed: 'Last Used',
expiresAt: 'Expires At',
actions: 'Actions'
},
tempUnschedulable: {
@@ -1152,12 +1153,17 @@ export default {
interceptWarmupRequests: 'Intercept Warmup Requests',
interceptWarmupRequestsDesc:
'When enabled, warmup requests like title generation will return mock responses without consuming upstream tokens',
autoPauseOnExpired: 'Auto Pause On Expired',
autoPauseOnExpiredDesc: 'When enabled, the account will auto pause scheduling after it expires',
expired: 'Expired',
proxy: 'Proxy',
noProxy: 'No Proxy',
concurrency: 'Concurrency',
priority: 'Priority',
priorityHint: 'Higher priority accounts are used first',
higherPriorityFirst: 'Higher value means higher priority',
priorityHint: 'Lower value accounts are used first',
expiresAt: 'Expires At',
expiresAtHint: 'Leave empty for no expiration',
higherPriorityFirst: 'Lower value means higher priority',
mixedScheduling: 'Use in /v1/messages',
mixedSchedulingHint: 'Enable to participate in Anthropic/Gemini group scheduling',
mixedSchedulingTooltip:
@@ -1987,7 +1993,7 @@ export default {
},
accountPriority: {
title: '⚖️ 4. Priority (Optional)',
description: '<div style="line-height: 1.7;"><p style="margin-bottom: 12px;">Set the account call priority.</p><div style="padding: 8px 12px; background: #eff6ff; border-left: 3px solid #3b82f6; border-radius: 4px; font-size: 13px; margin-bottom: 12px;"><b>📊 Priority Rules:</b><ul style="margin: 8px 0 0 16px;"><li>Higher number = higher priority</li><li>System uses high-priority accounts first</li><li>Same priority = random selection</li></ul></div><p style="padding: 8px 12px; background: #f0fdf4; border-left: 3px solid #10b981; border-radius: 4px; font-size: 13px;"><b>💡 Use Case:</b> Set main account to high priority, backup accounts to low priority</p></div>',
description: '<div style="line-height: 1.7;"><p style="margin-bottom: 12px;">Set the account call priority.</p><div style="padding: 8px 12px; background: #eff6ff; border-left: 3px solid #3b82f6; border-radius: 4px; font-size: 13px; margin-bottom: 12px;"><b>📊 Priority Rules:</b><ul style="margin: 8px 0 0 16px;"><li>Lower number = higher priority</li><li>System uses low-value accounts first</li><li>Same priority = random selection</li></ul></div><p style="padding: 8px 12px; background: #f0fdf4; border-left: 3px solid #10b981; border-radius: 4px; font-size: 13px;"><b>💡 Use Case:</b> Set main account to lower value, backup accounts to higher value</p></div>',
nextBtn: 'Next'
},
accountGroups: {

View File

@@ -859,7 +859,7 @@ export default {
accountsLabel: '指定账号',
accountsPlaceholder: '选择账号(留空则不限制)',
priorityLabel: '优先级',
priorityHint: '数值越优先级越高,用于账号调度',
priorityHint: '数值越优先级越高,用于账号调度',
statusLabel: '状态'
},
exclusiveObj: {
@@ -1061,6 +1061,7 @@ export default {
groups: '分组',
usageWindows: '用量窗口',
lastUsed: '最近使用',
expiresAt: '过期时间',
actions: '操作'
},
clearRateLimit: '清除速率限制',
@@ -1180,7 +1181,7 @@ export default {
credentialsLabel: '凭证',
credentialsPlaceholder: '请输入 Cookie 或 API Key',
priorityLabel: '优先级',
priorityHint: '数值越优先级越高',
priorityHint: '数值越优先级越高',
weightLabel: '权重',
weightHint: '用于负载均衡的权重值',
statusLabel: '状态'
@@ -1286,12 +1287,17 @@ export default {
errorCodeExists: '该错误码已被选中',
interceptWarmupRequests: '拦截预热请求',
interceptWarmupRequestsDesc: '启用后,标题生成等预热请求将返回 mock 响应,不消耗上游 token',
autoPauseOnExpired: '过期自动暂停调度',
autoPauseOnExpiredDesc: '启用后,账号过期将自动暂停调度',
expired: '已过期',
proxy: '代理',
noProxy: '无代理',
concurrency: '并发数',
priority: '优先级',
priorityHint: '优先级越的账号优先使用',
higherPriorityFirst: '数值越高优先级越高',
priorityHint: '优先级越的账号优先使用',
expiresAt: '过期时间',
expiresAtHint: '留空表示不过期',
higherPriorityFirst: '数值越小优先级越高',
mixedScheduling: '在 /v1/messages 中使用',
mixedSchedulingHint: '启用后可参与 Anthropic/Gemini 分组的调度',
mixedSchedulingTooltip:
@@ -2129,7 +2135,7 @@ export default {
},
accountPriority: {
title: '⚖️ 4. 优先级(可选)',
description: '<div style="line-height: 1.7;"><p style="margin-bottom: 12px;">设置账号的调用优先级。</p><div style="padding: 8px 12px; background: #eff6ff; border-left: 3px solid #3b82f6; border-radius: 4px; font-size: 13px; margin-bottom: 12px;"><b>📊 优先级规则:</b><ul style="margin: 8px 0 0 16px;"><li>数字越,优先级越高</li><li>系统优先使用高优先级账号</li><li>相同优先级则随机选择</li></ul></div><p style="padding: 8px 12px; background: #f0fdf4; border-left: 3px solid #10b981; border-radius: 4px; font-size: 13px;"><b>💡 使用场景:</b>主账号设置高优先级,备用账号设置低优先级</p></div>',
description: '<div style="line-height: 1.7;"><p style="margin-bottom: 12px;">设置账号的调用优先级。</p><div style="padding: 8px 12px; background: #eff6ff; border-left: 3px solid #3b82f6; border-radius: 4px; font-size: 13px; margin-bottom: 12px;"><b>📊 优先级规则:</b><ul style="margin: 8px 0 0 16px;"><li>数字越,优先级越高</li><li>系统优先使用低数值账号</li><li>相同优先级则随机选择</li></ul></div><p style="padding: 8px 12px; background: #f0fdf4; border-left: 3px solid #10b981; border-radius: 4px; font-size: 13px;"><b>💡 使用场景:</b>主账号设置低数值,备用账号设置高数值</p></div>',
nextBtn: '下一步'
},
accountGroups: {

View File

@@ -401,6 +401,8 @@ export interface Account {
status: 'active' | 'inactive' | 'error'
error_message: string | null
last_used_at: string | null
expires_at: number | null
auto_pause_on_expired: boolean
created_at: string
updated_at: string
proxy?: Proxy
@@ -491,6 +493,8 @@ export interface CreateAccountRequest {
concurrency?: number
priority?: number
group_ids?: number[]
expires_at?: number | null
auto_pause_on_expired?: boolean
confirm_mixed_channel_risk?: boolean
}
@@ -506,6 +510,8 @@ export interface UpdateAccountRequest {
schedulable?: boolean
status?: 'active' | 'inactive'
group_ids?: number[]
expires_at?: number | null
auto_pause_on_expired?: boolean
confirm_mixed_channel_risk?: boolean
}

View File

@@ -96,6 +96,7 @@ export function formatBytes(bytes: number, decimals: number = 2): string {
* 格式化日期
* @param date 日期字符串或 Date 对象
* @param options Intl.DateTimeFormatOptions
* @param localeOverride 可选 locale 覆盖
* @returns 格式化后的日期字符串
*/
export function formatDate(
@@ -108,14 +109,15 @@ export function formatDate(
minute: '2-digit',
second: '2-digit',
hour12: false
}
},
localeOverride?: string
): string {
if (!date) return ''
const d = new Date(date)
if (isNaN(d.getTime())) return ''
const locale = getLocale()
const locale = localeOverride ?? getLocale()
return new Intl.DateTimeFormat(locale, options).format(d)
}
@@ -135,10 +137,41 @@ export function formatDateOnly(date: string | Date | null | undefined): string {
/**
* 格式化日期时间(完整格式)
* @param date 日期字符串或 Date 对象
* @param options Intl.DateTimeFormatOptions
* @param localeOverride 可选 locale 覆盖
* @returns 格式化后的日期时间字符串
*/
export function formatDateTime(date: string | Date | null | undefined): string {
return formatDate(date)
export function formatDateTime(
date: string | Date | null | undefined,
options?: Intl.DateTimeFormatOptions,
localeOverride?: string
): string {
return formatDate(date, options, localeOverride)
}
/**
* 格式化为 datetime-local 控件值YYYY-MM-DDTHH:mm使用本地时间
*/
export function formatDateTimeLocalInput(timestampSeconds: number | null): string {
if (!timestampSeconds) return ''
const date = new Date(timestampSeconds * 1000)
if (isNaN(date.getTime())) return ''
const year = date.getFullYear()
const month = String(date.getMonth() + 1).padStart(2, '0')
const day = String(date.getDate()).padStart(2, '0')
const hours = String(date.getHours()).padStart(2, '0')
const minutes = String(date.getMinutes()).padStart(2, '0')
return `${year}-${month}-${day}T${hours}:${minutes}`
}
/**
* 解析 datetime-local 控件值为时间戳(秒,使用本地时间)
*/
export function parseDateTimeLocalInput(value: string): number | null {
if (!value) return null
const date = new Date(value)
if (isNaN(date.getTime())) return null
return Math.floor(date.getTime() / 1000)
}
/**

View File

@@ -70,6 +70,25 @@
<template #cell-last_used_at="{ value }">
<span class="text-sm text-gray-500 dark:text-dark-400">{{ formatRelativeTime(value) }}</span>
</template>
<template #cell-expires_at="{ row, value }">
<div class="flex flex-col items-start gap-1">
<span class="text-sm text-gray-500 dark:text-dark-400">{{ formatExpiresAt(value) }}</span>
<div v-if="isExpired(value) || (row.auto_pause_on_expired && value)" class="flex items-center gap-1">
<span
v-if="isExpired(value)"
class="inline-flex items-center rounded-md bg-amber-100 px-2 py-0.5 text-xs font-medium text-amber-700 dark:bg-amber-900/30 dark:text-amber-300"
>
{{ t('admin.accounts.expired') }}
</span>
<span
v-if="row.auto_pause_on_expired && value"
class="inline-flex items-center rounded-md bg-emerald-100 px-2 py-0.5 text-xs font-medium text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-300"
>
{{ t('admin.accounts.autoPauseOnExpired') }}
</span>
</div>
</div>
</template>
<template #cell-actions="{ row }">
<div class="flex items-center gap-1">
<button @click="handleEdit(row)" class="flex flex-col items-center gap-0.5 rounded-lg p-1.5 text-gray-500 transition-colors hover:bg-gray-100 hover:text-primary-600 dark:hover:bg-dark-700 dark:hover:text-primary-400">
@@ -128,7 +147,7 @@ import AccountUsageCell from '@/components/account/AccountUsageCell.vue'
import AccountTodayStatsCell from '@/components/account/AccountTodayStatsCell.vue'
import GroupBadge from '@/components/common/GroupBadge.vue'
import PlatformTypeBadge from '@/components/common/PlatformTypeBadge.vue'
import { formatRelativeTime } from '@/utils/format'
import { formatDateTime, formatRelativeTime } from '@/utils/format'
import type { Account, Proxy, Group } from '@/types'
const { t } = useI18n()
@@ -178,6 +197,7 @@ const cols = computed(() => {
{ key: 'usage', label: t('admin.accounts.columns.usageWindows'), sortable: false },
{ key: 'priority', label: t('admin.accounts.columns.priority'), sortable: true },
{ key: 'last_used_at', label: t('admin.accounts.columns.lastUsed'), sortable: true },
{ key: 'expires_at', label: t('admin.accounts.columns.expiresAt'), sortable: true },
{ key: 'notes', label: t('admin.accounts.columns.notes'), sortable: false },
{ key: 'actions', label: t('admin.accounts.columns.actions'), sortable: false }
)
@@ -204,6 +224,25 @@ const confirmDelete = async () => { if(!deletingAcc.value) return; try { await a
const handleToggleSchedulable = async (a: Account) => { togglingSchedulable.value = a.id; try { await adminAPI.accounts.setSchedulable(a.id, !a.schedulable); load() } finally { togglingSchedulable.value = null } }
const handleShowTempUnsched = (a: Account) => { tempUnschedAcc.value = a; showTempUnsched.value = true }
const handleTempUnschedReset = async () => { if(!tempUnschedAcc.value) return; try { await adminAPI.accounts.clearError(tempUnschedAcc.value.id); showTempUnsched.value = false; tempUnschedAcc.value = null; load() } catch (error) { console.error('Failed to reset temp unscheduled:', error) } }
const formatExpiresAt = (value: number | null) => {
if (!value) return '-'
return formatDateTime(
new Date(value * 1000),
{
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
hour12: false
},
'sv-SE'
)
}
const isExpired = (value: number | null) => {
if (!value) return false
return value * 1000 <= Date.now()
}
onMounted(async () => { load(); try { const [p, g] = await Promise.all([adminAPI.proxies.getAll(), adminAPI.groups.getAll()]); proxies.value = p; groups.value = g } catch (error) { console.error('Failed to load proxies/groups:', error) } })
</script>

View File

@@ -3,6 +3,7 @@ import vue from '@vitejs/plugin-vue'
import checker from 'vite-plugin-checker'
import { resolve } from 'path'
export default defineConfig({
plugins: [
vue(),
@@ -29,7 +30,7 @@ export default defineConfig({
},
server: {
host: '0.0.0.0',
port: 3000,
port: Number(process.env.VITE_DEV_PORT || 3000),
proxy: {
'/api': {
target: process.env.VITE_DEV_PROXY_TARGET || 'http://localhost:8080',