Compare commits

...

12 Commits

Author SHA1 Message Date
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
shaw
d99a3ef14b fix(gateway): 修复账号跨分组调度问题
问题:账号可能被调度到未分配的分组(如 simon 账号被调度到 claude_default)

根因:
- 强制平台模式下分组查询失败时回退到全平台查询
- listSchedulableAccounts 中分组为空时回退到无分组查询
- 粘性会话只检查平台匹配,未校验账号分组归属

修复:
- 移除强制平台模式的回退逻辑,分组内无账号时返回错误
- 移除 listSchedulableAccounts 的回退逻辑
- 新增 isAccountInGroup 方法用于分组校验
- 在三处粘性会话检查中增加分组归属验证
2026-01-07 10:56:52 +08:00
shaw
fc8fa83fcc fix(keys): 修复代码框第一行多余空格问题
pre 标签会原样保留内部空白字符,导致 code 标签前的模板缩进
被渲染为实际空格。将 pre/code 标签写在同一行消除此问题。
2026-01-07 10:26:24 +08:00
shaw
6dcd99468b fix(gateway): 修复 cache_control 块超限问题并优化 Claude Code 检测
问题:
- OAuth/SetupToken 账号注入 system prompt 后可能导致 cache_control
  块超过 Anthropic API 的 4 个限制
- Claude Code 检测使用精确匹配,无法识别 Agent SDK 等变体

修复:
- 新增 enforceCacheControlLimit 函数,强制执行 4 个块限制
- 优先从 messages 移除,再从 system 尾部移除(保护注入的 prompt)
- 改用前缀匹配检测 Claude Code 系统提示词,支持多种变体:
  - 标准版、Agent SDK 版、Explore Agent 版、Compact 版
2026-01-07 10:17:09 +08:00
shaw
d5ba7b80d3 fix(admin/usage): 恢复成本 Tooltip 明细并优化账号筛选
问题修复:
- 恢复 Cost Tooltip 的成本分项明细 (input_cost, output_cost, cache 成本)
- 修复 Token Tooltip 双分隔线显示问题
- 修复 Tooltip 翻译键缺失问题,新增 costDetails/tokenDetails
- 恢复 Excel 导出格式化 (aoa_to_sheet + 翻译列头)

功能优化:
- 账号筛选从前端搜索改为后端搜索,避免一次加载 1000 条数据
- 行为与用户/API Key 筛选保持一致 (debounce + 后端分页)
2026-01-07 09:35:21 +08:00
65 changed files with 1927 additions and 288 deletions

View File

@@ -9,7 +9,7 @@ build-backend:
# 编译前端(需要已安装依赖) # 编译前端(需要已安装依赖)
build-frontend: build-frontend:
@npm --prefix frontend run build @pnpm --dir frontend run build
# 运行测试(后端 + 前端) # 运行测试(后端 + 前端)
test: test-backend test-frontend test: test-backend test-frontend
@@ -18,5 +18,5 @@ test-backend:
@$(MAKE) -C backend test @$(MAKE) -C backend test
test-frontend: test-frontend:
@npm --prefix frontend run lint:check @pnpm --dir frontend run lint:check
@npm --prefix frontend run typecheck @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 | | Component | Technology |
|-----------|------------| |-----------|------------|
| Backend | Go 1.25.5, Gin, GORM | | Backend | Go 1.25.5, Gin, Ent |
| Frontend | Vue 3.4+, Vite 5+, TailwindCSS | | Frontend | Vue 3.4+, Vite 5+, TailwindCSS |
| Database | PostgreSQL 15+ | | Database | PostgreSQL 15+ |
| Cache/Queue | Redis 7+ | | 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 | | 前端 | Vue 3.4+, Vite 5+, TailwindCSS |
| 数据库 | PostgreSQL 15+ | | 数据库 | PostgreSQL 15+ |
| 缓存/队列 | Redis 7+ | | 缓存/队列 | Redis 7+ |

View File

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

View File

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

View File

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

View File

@@ -49,6 +49,10 @@ type Account struct {
ErrorMessage *string `json:"error_message,omitempty"` ErrorMessage *string `json:"error_message,omitempty"`
// LastUsedAt holds the value of the "last_used_at" field. // LastUsedAt holds the value of the "last_used_at" field.
LastUsedAt *time.Time `json:"last_used_at,omitempty"` 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 holds the value of the "schedulable" field.
Schedulable bool `json:"schedulable,omitempty"` Schedulable bool `json:"schedulable,omitempty"`
// RateLimitedAt holds the value of the "rate_limited_at" field. // RateLimitedAt holds the value of the "rate_limited_at" field.
@@ -129,13 +133,13 @@ func (*Account) scanValues(columns []string) ([]any, error) {
switch columns[i] { switch columns[i] {
case account.FieldCredentials, account.FieldExtra: case account.FieldCredentials, account.FieldExtra:
values[i] = new([]byte) values[i] = new([]byte)
case account.FieldSchedulable: case account.FieldAutoPauseOnExpired, account.FieldSchedulable:
values[i] = new(sql.NullBool) values[i] = new(sql.NullBool)
case account.FieldID, account.FieldProxyID, account.FieldConcurrency, account.FieldPriority: case account.FieldID, account.FieldProxyID, account.FieldConcurrency, account.FieldPriority:
values[i] = new(sql.NullInt64) values[i] = new(sql.NullInt64)
case account.FieldName, account.FieldNotes, account.FieldPlatform, account.FieldType, account.FieldStatus, account.FieldErrorMessage, account.FieldSessionWindowStatus: case account.FieldName, account.FieldNotes, account.FieldPlatform, account.FieldType, account.FieldStatus, account.FieldErrorMessage, account.FieldSessionWindowStatus:
values[i] = new(sql.NullString) 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) values[i] = new(sql.NullTime)
default: default:
values[i] = new(sql.UnknownType) 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 = new(time.Time)
*_m.LastUsedAt = value.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: case account.FieldSchedulable:
if value, ok := values[i].(*sql.NullBool); !ok { if value, ok := values[i].(*sql.NullBool); !ok {
return fmt.Errorf("unexpected type %T for field schedulable", values[i]) 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(v.Format(time.ANSIC))
} }
builder.WriteString(", ") 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("schedulable=")
builder.WriteString(fmt.Sprintf("%v", _m.Schedulable)) builder.WriteString(fmt.Sprintf("%v", _m.Schedulable))
builder.WriteString(", ") builder.WriteString(", ")

View File

@@ -45,6 +45,10 @@ const (
FieldErrorMessage = "error_message" FieldErrorMessage = "error_message"
// FieldLastUsedAt holds the string denoting the last_used_at field in the database. // FieldLastUsedAt holds the string denoting the last_used_at field in the database.
FieldLastUsedAt = "last_used_at" 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 holds the string denoting the schedulable field in the database.
FieldSchedulable = "schedulable" FieldSchedulable = "schedulable"
// FieldRateLimitedAt holds the string denoting the rate_limited_at field in the database. // FieldRateLimitedAt holds the string denoting the rate_limited_at field in the database.
@@ -115,6 +119,8 @@ var Columns = []string{
FieldStatus, FieldStatus,
FieldErrorMessage, FieldErrorMessage,
FieldLastUsedAt, FieldLastUsedAt,
FieldExpiresAt,
FieldAutoPauseOnExpired,
FieldSchedulable, FieldSchedulable,
FieldRateLimitedAt, FieldRateLimitedAt,
FieldRateLimitResetAt, FieldRateLimitResetAt,
@@ -172,6 +178,8 @@ var (
DefaultStatus string DefaultStatus string
// StatusValidator is a validator for the "status" field. It is called by the builders before save. // StatusValidator is a validator for the "status" field. It is called by the builders before save.
StatusValidator func(string) error 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 holds the default value on creation for the "schedulable" field.
DefaultSchedulable bool DefaultSchedulable bool
// SessionWindowStatusValidator is a validator for the "session_window_status" field. It is called by the builders before save. // 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() 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. // BySchedulable orders the results by the schedulable field.
func BySchedulable(opts ...sql.OrderTermOption) OrderOption { func BySchedulable(opts ...sql.OrderTermOption) OrderOption {
return sql.OrderByField(FieldSchedulable, opts...).ToFunc() 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)) 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. // Schedulable applies equality check predicate on the "schedulable" field. It's identical to SchedulableEQ.
func Schedulable(v bool) predicate.Account { func Schedulable(v bool) predicate.Account {
return predicate.Account(sql.FieldEQ(FieldSchedulable, v)) return predicate.Account(sql.FieldEQ(FieldSchedulable, v))
@@ -855,6 +865,66 @@ func LastUsedAtNotNil() predicate.Account {
return predicate.Account(sql.FieldNotNull(FieldLastUsedAt)) 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. // SchedulableEQ applies the EQ predicate on the "schedulable" field.
func SchedulableEQ(v bool) predicate.Account { func SchedulableEQ(v bool) predicate.Account {
return predicate.Account(sql.FieldEQ(FieldSchedulable, v)) return predicate.Account(sql.FieldEQ(FieldSchedulable, v))

View File

@@ -195,6 +195,34 @@ func (_c *AccountCreate) SetNillableLastUsedAt(v *time.Time) *AccountCreate {
return _c 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. // SetSchedulable sets the "schedulable" field.
func (_c *AccountCreate) SetSchedulable(v bool) *AccountCreate { func (_c *AccountCreate) SetSchedulable(v bool) *AccountCreate {
_c.mutation.SetSchedulable(v) _c.mutation.SetSchedulable(v)
@@ -405,6 +433,10 @@ func (_c *AccountCreate) defaults() error {
v := account.DefaultStatus v := account.DefaultStatus
_c.mutation.SetStatus(v) _c.mutation.SetStatus(v)
} }
if _, ok := _c.mutation.AutoPauseOnExpired(); !ok {
v := account.DefaultAutoPauseOnExpired
_c.mutation.SetAutoPauseOnExpired(v)
}
if _, ok := _c.mutation.Schedulable(); !ok { if _, ok := _c.mutation.Schedulable(); !ok {
v := account.DefaultSchedulable v := account.DefaultSchedulable
_c.mutation.SetSchedulable(v) _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)} 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 { if _, ok := _c.mutation.Schedulable(); !ok {
return &ValidationError{Name: "schedulable", err: errors.New(`ent: missing required field "Account.schedulable"`)} 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) _spec.SetField(account.FieldLastUsedAt, field.TypeTime, value)
_node.LastUsedAt = &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 { if value, ok := _c.mutation.Schedulable(); ok {
_spec.SetField(account.FieldSchedulable, field.TypeBool, value) _spec.SetField(account.FieldSchedulable, field.TypeBool, value)
_node.Schedulable = value _node.Schedulable = value
@@ -898,6 +941,36 @@ func (u *AccountUpsert) ClearLastUsedAt() *AccountUpsert {
return u 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. // SetSchedulable sets the "schedulable" field.
func (u *AccountUpsert) SetSchedulable(v bool) *AccountUpsert { func (u *AccountUpsert) SetSchedulable(v bool) *AccountUpsert {
u.Set(account.FieldSchedulable, v) 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. // SetSchedulable sets the "schedulable" field.
func (u *AccountUpsertOne) SetSchedulable(v bool) *AccountUpsertOne { func (u *AccountUpsertOne) SetSchedulable(v bool) *AccountUpsertOne {
return u.Update(func(s *AccountUpsert) { 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. // SetSchedulable sets the "schedulable" field.
func (u *AccountUpsertBulk) SetSchedulable(v bool) *AccountUpsertBulk { func (u *AccountUpsertBulk) SetSchedulable(v bool) *AccountUpsertBulk {
return u.Update(func(s *AccountUpsert) { return u.Update(func(s *AccountUpsert) {

View File

@@ -247,6 +247,40 @@ func (_u *AccountUpdate) ClearLastUsedAt() *AccountUpdate {
return _u 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. // SetSchedulable sets the "schedulable" field.
func (_u *AccountUpdate) SetSchedulable(v bool) *AccountUpdate { func (_u *AccountUpdate) SetSchedulable(v bool) *AccountUpdate {
_u.mutation.SetSchedulable(v) _u.mutation.SetSchedulable(v)
@@ -610,6 +644,15 @@ func (_u *AccountUpdate) sqlSave(ctx context.Context) (_node int, err error) {
if _u.mutation.LastUsedAtCleared() { if _u.mutation.LastUsedAtCleared() {
_spec.ClearField(account.FieldLastUsedAt, field.TypeTime) _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 { if value, ok := _u.mutation.Schedulable(); ok {
_spec.SetField(account.FieldSchedulable, field.TypeBool, value) _spec.SetField(account.FieldSchedulable, field.TypeBool, value)
} }
@@ -1016,6 +1059,40 @@ func (_u *AccountUpdateOne) ClearLastUsedAt() *AccountUpdateOne {
return _u 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. // SetSchedulable sets the "schedulable" field.
func (_u *AccountUpdateOne) SetSchedulable(v bool) *AccountUpdateOne { func (_u *AccountUpdateOne) SetSchedulable(v bool) *AccountUpdateOne {
_u.mutation.SetSchedulable(v) _u.mutation.SetSchedulable(v)
@@ -1409,6 +1486,15 @@ func (_u *AccountUpdateOne) sqlSave(ctx context.Context) (_node *Account, err er
if _u.mutation.LastUsedAtCleared() { if _u.mutation.LastUsedAtCleared() {
_spec.ClearField(account.FieldLastUsedAt, field.TypeTime) _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 { if value, ok := _u.mutation.Schedulable(); ok {
_spec.SetField(account.FieldSchedulable, field.TypeBool, value) _spec.SetField(account.FieldSchedulable, field.TypeBool, value)
} }

View File

@@ -80,6 +80,8 @@ var (
{Name: "status", Type: field.TypeString, Size: 20, Default: "active"}, {Name: "status", Type: field.TypeString, Size: 20, Default: "active"},
{Name: "error_message", Type: field.TypeString, Nullable: true, SchemaType: map[string]string{"postgres": "text"}}, {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: "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: "schedulable", Type: field.TypeBool, Default: true},
{Name: "rate_limited_at", Type: field.TypeTime, Nullable: true, SchemaType: map[string]string{"postgres": "timestamptz"}}, {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"}}, {Name: "rate_limit_reset_at", Type: field.TypeTime, Nullable: true, SchemaType: map[string]string{"postgres": "timestamptz"}},
@@ -97,7 +99,7 @@ var (
ForeignKeys: []*schema.ForeignKey{ ForeignKeys: []*schema.ForeignKey{
{ {
Symbol: "accounts_proxies_proxy", Symbol: "accounts_proxies_proxy",
Columns: []*schema.Column{AccountsColumns[22]}, Columns: []*schema.Column{AccountsColumns[24]},
RefColumns: []*schema.Column{ProxiesColumns[0]}, RefColumns: []*schema.Column{ProxiesColumns[0]},
OnDelete: schema.SetNull, OnDelete: schema.SetNull,
}, },
@@ -121,7 +123,7 @@ var (
{ {
Name: "account_proxy_id", Name: "account_proxy_id",
Unique: false, Unique: false,
Columns: []*schema.Column{AccountsColumns[22]}, Columns: []*schema.Column{AccountsColumns[24]},
}, },
{ {
Name: "account_priority", Name: "account_priority",
@@ -136,22 +138,22 @@ var (
{ {
Name: "account_schedulable", Name: "account_schedulable",
Unique: false, Unique: false,
Columns: []*schema.Column{AccountsColumns[15]}, Columns: []*schema.Column{AccountsColumns[17]},
}, },
{ {
Name: "account_rate_limited_at", Name: "account_rate_limited_at",
Unique: false, Unique: false,
Columns: []*schema.Column{AccountsColumns[16]}, Columns: []*schema.Column{AccountsColumns[18]},
}, },
{ {
Name: "account_rate_limit_reset_at", Name: "account_rate_limit_reset_at",
Unique: false, Unique: false,
Columns: []*schema.Column{AccountsColumns[17]}, Columns: []*schema.Column{AccountsColumns[19]},
}, },
{ {
Name: "account_overload_until", Name: "account_overload_until",
Unique: false, Unique: false,
Columns: []*schema.Column{AccountsColumns[18]}, Columns: []*schema.Column{AccountsColumns[20]},
}, },
{ {
Name: "account_deleted_at", Name: "account_deleted_at",
@@ -371,6 +373,7 @@ var (
{Name: "stream", Type: field.TypeBool, Default: false}, {Name: "stream", Type: field.TypeBool, Default: false},
{Name: "duration_ms", Type: field.TypeInt, Nullable: true}, {Name: "duration_ms", Type: field.TypeInt, Nullable: true},
{Name: "first_token_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_count", Type: field.TypeInt, Default: 0},
{Name: "image_size", Type: field.TypeString, Nullable: true, Size: 10}, {Name: "image_size", Type: field.TypeString, Nullable: true, Size: 10},
{Name: "created_at", Type: field.TypeTime, SchemaType: map[string]string{"postgres": "timestamptz"}}, {Name: "created_at", Type: field.TypeTime, SchemaType: map[string]string{"postgres": "timestamptz"}},
@@ -388,31 +391,31 @@ var (
ForeignKeys: []*schema.ForeignKey{ ForeignKeys: []*schema.ForeignKey{
{ {
Symbol: "usage_logs_api_keys_usage_logs", Symbol: "usage_logs_api_keys_usage_logs",
Columns: []*schema.Column{UsageLogsColumns[23]}, Columns: []*schema.Column{UsageLogsColumns[24]},
RefColumns: []*schema.Column{APIKeysColumns[0]}, RefColumns: []*schema.Column{APIKeysColumns[0]},
OnDelete: schema.NoAction, OnDelete: schema.NoAction,
}, },
{ {
Symbol: "usage_logs_accounts_usage_logs", Symbol: "usage_logs_accounts_usage_logs",
Columns: []*schema.Column{UsageLogsColumns[24]}, Columns: []*schema.Column{UsageLogsColumns[25]},
RefColumns: []*schema.Column{AccountsColumns[0]}, RefColumns: []*schema.Column{AccountsColumns[0]},
OnDelete: schema.NoAction, OnDelete: schema.NoAction,
}, },
{ {
Symbol: "usage_logs_groups_usage_logs", Symbol: "usage_logs_groups_usage_logs",
Columns: []*schema.Column{UsageLogsColumns[25]}, Columns: []*schema.Column{UsageLogsColumns[26]},
RefColumns: []*schema.Column{GroupsColumns[0]}, RefColumns: []*schema.Column{GroupsColumns[0]},
OnDelete: schema.SetNull, OnDelete: schema.SetNull,
}, },
{ {
Symbol: "usage_logs_users_usage_logs", Symbol: "usage_logs_users_usage_logs",
Columns: []*schema.Column{UsageLogsColumns[26]}, Columns: []*schema.Column{UsageLogsColumns[27]},
RefColumns: []*schema.Column{UsersColumns[0]}, RefColumns: []*schema.Column{UsersColumns[0]},
OnDelete: schema.NoAction, OnDelete: schema.NoAction,
}, },
{ {
Symbol: "usage_logs_user_subscriptions_usage_logs", Symbol: "usage_logs_user_subscriptions_usage_logs",
Columns: []*schema.Column{UsageLogsColumns[27]}, Columns: []*schema.Column{UsageLogsColumns[28]},
RefColumns: []*schema.Column{UserSubscriptionsColumns[0]}, RefColumns: []*schema.Column{UserSubscriptionsColumns[0]},
OnDelete: schema.SetNull, OnDelete: schema.SetNull,
}, },
@@ -421,32 +424,32 @@ var (
{ {
Name: "usagelog_user_id", Name: "usagelog_user_id",
Unique: false, Unique: false,
Columns: []*schema.Column{UsageLogsColumns[26]}, Columns: []*schema.Column{UsageLogsColumns[27]},
}, },
{ {
Name: "usagelog_api_key_id", Name: "usagelog_api_key_id",
Unique: false, Unique: false,
Columns: []*schema.Column{UsageLogsColumns[23]}, Columns: []*schema.Column{UsageLogsColumns[24]},
}, },
{ {
Name: "usagelog_account_id", Name: "usagelog_account_id",
Unique: false, Unique: false,
Columns: []*schema.Column{UsageLogsColumns[24]}, Columns: []*schema.Column{UsageLogsColumns[25]},
}, },
{ {
Name: "usagelog_group_id", Name: "usagelog_group_id",
Unique: false, Unique: false,
Columns: []*schema.Column{UsageLogsColumns[25]}, Columns: []*schema.Column{UsageLogsColumns[26]},
}, },
{ {
Name: "usagelog_subscription_id", Name: "usagelog_subscription_id",
Unique: false, Unique: false,
Columns: []*schema.Column{UsageLogsColumns[27]}, Columns: []*schema.Column{UsageLogsColumns[28]},
}, },
{ {
Name: "usagelog_created_at", Name: "usagelog_created_at",
Unique: false, Unique: false,
Columns: []*schema.Column{UsageLogsColumns[22]}, Columns: []*schema.Column{UsageLogsColumns[23]},
}, },
{ {
Name: "usagelog_model", Name: "usagelog_model",
@@ -461,12 +464,12 @@ var (
{ {
Name: "usagelog_user_id_created_at", Name: "usagelog_user_id_created_at",
Unique: false, Unique: false,
Columns: []*schema.Column{UsageLogsColumns[26], UsageLogsColumns[22]}, Columns: []*schema.Column{UsageLogsColumns[27], UsageLogsColumns[23]},
}, },
{ {
Name: "usagelog_api_key_id_created_at", Name: "usagelog_api_key_id_created_at",
Unique: false, 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 status *string
error_message *string error_message *string
last_used_at *time.Time last_used_at *time.Time
expires_at *time.Time
auto_pause_on_expired *bool
schedulable *bool schedulable *bool
rate_limited_at *time.Time rate_limited_at *time.Time
rate_limit_reset_at *time.Time rate_limit_reset_at *time.Time
@@ -1770,6 +1772,91 @@ func (m *AccountMutation) ResetLastUsedAt() {
delete(m.clearedFields, account.FieldLastUsedAt) 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. // SetSchedulable sets the "schedulable" field.
func (m *AccountMutation) SetSchedulable(b bool) { func (m *AccountMutation) SetSchedulable(b bool) {
m.schedulable = &b m.schedulable = &b
@@ -2269,7 +2356,7 @@ func (m *AccountMutation) Type() string {
// order to get all numeric fields that were incremented/decremented, call // order to get all numeric fields that were incremented/decremented, call
// AddedFields(). // AddedFields().
func (m *AccountMutation) Fields() []string { func (m *AccountMutation) Fields() []string {
fields := make([]string, 0, 22) fields := make([]string, 0, 24)
if m.created_at != nil { if m.created_at != nil {
fields = append(fields, account.FieldCreatedAt) fields = append(fields, account.FieldCreatedAt)
} }
@@ -2315,6 +2402,12 @@ func (m *AccountMutation) Fields() []string {
if m.last_used_at != nil { if m.last_used_at != nil {
fields = append(fields, account.FieldLastUsedAt) 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 { if m.schedulable != nil {
fields = append(fields, account.FieldSchedulable) fields = append(fields, account.FieldSchedulable)
} }
@@ -2374,6 +2467,10 @@ func (m *AccountMutation) Field(name string) (ent.Value, bool) {
return m.ErrorMessage() return m.ErrorMessage()
case account.FieldLastUsedAt: case account.FieldLastUsedAt:
return m.LastUsedAt() return m.LastUsedAt()
case account.FieldExpiresAt:
return m.ExpiresAt()
case account.FieldAutoPauseOnExpired:
return m.AutoPauseOnExpired()
case account.FieldSchedulable: case account.FieldSchedulable:
return m.Schedulable() return m.Schedulable()
case account.FieldRateLimitedAt: case account.FieldRateLimitedAt:
@@ -2427,6 +2524,10 @@ func (m *AccountMutation) OldField(ctx context.Context, name string) (ent.Value,
return m.OldErrorMessage(ctx) return m.OldErrorMessage(ctx)
case account.FieldLastUsedAt: case account.FieldLastUsedAt:
return m.OldLastUsedAt(ctx) return m.OldLastUsedAt(ctx)
case account.FieldExpiresAt:
return m.OldExpiresAt(ctx)
case account.FieldAutoPauseOnExpired:
return m.OldAutoPauseOnExpired(ctx)
case account.FieldSchedulable: case account.FieldSchedulable:
return m.OldSchedulable(ctx) return m.OldSchedulable(ctx)
case account.FieldRateLimitedAt: case account.FieldRateLimitedAt:
@@ -2555,6 +2656,20 @@ func (m *AccountMutation) SetField(name string, value ent.Value) error {
} }
m.SetLastUsedAt(v) m.SetLastUsedAt(v)
return nil 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: case account.FieldSchedulable:
v, ok := value.(bool) v, ok := value.(bool)
if !ok { if !ok {
@@ -2676,6 +2791,9 @@ func (m *AccountMutation) ClearedFields() []string {
if m.FieldCleared(account.FieldLastUsedAt) { if m.FieldCleared(account.FieldLastUsedAt) {
fields = append(fields, account.FieldLastUsedAt) fields = append(fields, account.FieldLastUsedAt)
} }
if m.FieldCleared(account.FieldExpiresAt) {
fields = append(fields, account.FieldExpiresAt)
}
if m.FieldCleared(account.FieldRateLimitedAt) { if m.FieldCleared(account.FieldRateLimitedAt) {
fields = append(fields, account.FieldRateLimitedAt) fields = append(fields, account.FieldRateLimitedAt)
} }
@@ -2723,6 +2841,9 @@ func (m *AccountMutation) ClearField(name string) error {
case account.FieldLastUsedAt: case account.FieldLastUsedAt:
m.ClearLastUsedAt() m.ClearLastUsedAt()
return nil return nil
case account.FieldExpiresAt:
m.ClearExpiresAt()
return nil
case account.FieldRateLimitedAt: case account.FieldRateLimitedAt:
m.ClearRateLimitedAt() m.ClearRateLimitedAt()
return nil return nil
@@ -2794,6 +2915,12 @@ func (m *AccountMutation) ResetField(name string) error {
case account.FieldLastUsedAt: case account.FieldLastUsedAt:
m.ResetLastUsedAt() m.ResetLastUsedAt()
return nil return nil
case account.FieldExpiresAt:
m.ResetExpiresAt()
return nil
case account.FieldAutoPauseOnExpired:
m.ResetAutoPauseOnExpired()
return nil
case account.FieldSchedulable: case account.FieldSchedulable:
m.ResetSchedulable() m.ResetSchedulable()
return nil return nil
@@ -8107,6 +8234,7 @@ type UsageLogMutation struct {
addduration_ms *int addduration_ms *int
first_token_ms *int first_token_ms *int
addfirst_token_ms *int addfirst_token_ms *int
user_agent *string
image_count *int image_count *int
addimage_count *int addimage_count *int
image_size *string image_size *string
@@ -9463,6 +9591,55 @@ func (m *UsageLogMutation) ResetFirstTokenMs() {
delete(m.clearedFields, usagelog.FieldFirstTokenMs) 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. // SetImageCount sets the "image_count" field.
func (m *UsageLogMutation) SetImageCount(i int) { func (m *UsageLogMutation) SetImageCount(i int) {
m.image_count = &i m.image_count = &i
@@ -9773,7 +9950,7 @@ func (m *UsageLogMutation) Type() string {
// order to get all numeric fields that were incremented/decremented, call // order to get all numeric fields that were incremented/decremented, call
// AddedFields(). // AddedFields().
func (m *UsageLogMutation) Fields() []string { func (m *UsageLogMutation) Fields() []string {
fields := make([]string, 0, 27) fields := make([]string, 0, 28)
if m.user != nil { if m.user != nil {
fields = append(fields, usagelog.FieldUserID) fields = append(fields, usagelog.FieldUserID)
} }
@@ -9846,6 +10023,9 @@ func (m *UsageLogMutation) Fields() []string {
if m.first_token_ms != nil { if m.first_token_ms != nil {
fields = append(fields, usagelog.FieldFirstTokenMs) fields = append(fields, usagelog.FieldFirstTokenMs)
} }
if m.user_agent != nil {
fields = append(fields, usagelog.FieldUserAgent)
}
if m.image_count != nil { if m.image_count != nil {
fields = append(fields, usagelog.FieldImageCount) fields = append(fields, usagelog.FieldImageCount)
} }
@@ -9911,6 +10091,8 @@ func (m *UsageLogMutation) Field(name string) (ent.Value, bool) {
return m.DurationMs() return m.DurationMs()
case usagelog.FieldFirstTokenMs: case usagelog.FieldFirstTokenMs:
return m.FirstTokenMs() return m.FirstTokenMs()
case usagelog.FieldUserAgent:
return m.UserAgent()
case usagelog.FieldImageCount: case usagelog.FieldImageCount:
return m.ImageCount() return m.ImageCount()
case usagelog.FieldImageSize: case usagelog.FieldImageSize:
@@ -9974,6 +10156,8 @@ func (m *UsageLogMutation) OldField(ctx context.Context, name string) (ent.Value
return m.OldDurationMs(ctx) return m.OldDurationMs(ctx)
case usagelog.FieldFirstTokenMs: case usagelog.FieldFirstTokenMs:
return m.OldFirstTokenMs(ctx) return m.OldFirstTokenMs(ctx)
case usagelog.FieldUserAgent:
return m.OldUserAgent(ctx)
case usagelog.FieldImageCount: case usagelog.FieldImageCount:
return m.OldImageCount(ctx) return m.OldImageCount(ctx)
case usagelog.FieldImageSize: case usagelog.FieldImageSize:
@@ -10157,6 +10341,13 @@ func (m *UsageLogMutation) SetField(name string, value ent.Value) error {
} }
m.SetFirstTokenMs(v) m.SetFirstTokenMs(v)
return nil 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: case usagelog.FieldImageCount:
v, ok := value.(int) v, ok := value.(int)
if !ok { if !ok {
@@ -10427,6 +10618,9 @@ func (m *UsageLogMutation) ClearedFields() []string {
if m.FieldCleared(usagelog.FieldFirstTokenMs) { if m.FieldCleared(usagelog.FieldFirstTokenMs) {
fields = append(fields, usagelog.FieldFirstTokenMs) fields = append(fields, usagelog.FieldFirstTokenMs)
} }
if m.FieldCleared(usagelog.FieldUserAgent) {
fields = append(fields, usagelog.FieldUserAgent)
}
if m.FieldCleared(usagelog.FieldImageSize) { if m.FieldCleared(usagelog.FieldImageSize) {
fields = append(fields, usagelog.FieldImageSize) fields = append(fields, usagelog.FieldImageSize)
} }
@@ -10456,6 +10650,9 @@ func (m *UsageLogMutation) ClearField(name string) error {
case usagelog.FieldFirstTokenMs: case usagelog.FieldFirstTokenMs:
m.ClearFirstTokenMs() m.ClearFirstTokenMs()
return nil return nil
case usagelog.FieldUserAgent:
m.ClearUserAgent()
return nil
case usagelog.FieldImageSize: case usagelog.FieldImageSize:
m.ClearImageSize() m.ClearImageSize()
return nil return nil
@@ -10539,6 +10736,9 @@ func (m *UsageLogMutation) ResetField(name string) error {
case usagelog.FieldFirstTokenMs: case usagelog.FieldFirstTokenMs:
m.ResetFirstTokenMs() m.ResetFirstTokenMs()
return nil return nil
case usagelog.FieldUserAgent:
m.ResetUserAgent()
return nil
case usagelog.FieldImageCount: case usagelog.FieldImageCount:
m.ResetImageCount() m.ResetImageCount()
return nil return nil

View File

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

View File

@@ -118,6 +118,16 @@ func (Account) Fields() []ent.Field {
Optional(). Optional().
Nillable(). Nillable().
SchemaType(map[string]string{dialect.Postgres: "timestamptz"}), 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 中添加 // 这些字段在 migrations/005_schema_parity.sql 中添加

View File

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

View File

@@ -70,6 +70,8 @@ type UsageLog struct {
DurationMs *int `json:"duration_ms,omitempty"` DurationMs *int `json:"duration_ms,omitempty"`
// FirstTokenMs holds the value of the "first_token_ms" field. // FirstTokenMs holds the value of the "first_token_ms" field.
FirstTokenMs *int `json:"first_token_ms,omitempty"` 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 holds the value of the "image_count" field.
ImageCount int `json:"image_count,omitempty"` ImageCount int `json:"image_count,omitempty"`
// ImageSize holds the value of the "image_size" field. // 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) 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: 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) 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) values[i] = new(sql.NullString)
case usagelog.FieldCreatedAt: case usagelog.FieldCreatedAt:
values[i] = new(sql.NullTime) values[i] = new(sql.NullTime)
@@ -338,6 +340,13 @@ func (_m *UsageLog) assignValues(columns []string, values []any) error {
_m.FirstTokenMs = new(int) _m.FirstTokenMs = new(int)
*_m.FirstTokenMs = int(value.Int64) *_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: case usagelog.FieldImageCount:
if value, ok := values[i].(*sql.NullInt64); !ok { if value, ok := values[i].(*sql.NullInt64); !ok {
return fmt.Errorf("unexpected type %T for field image_count", values[i]) 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(fmt.Sprintf("%v", *v))
} }
builder.WriteString(", ") builder.WriteString(", ")
if v := _m.UserAgent; v != nil {
builder.WriteString("user_agent=")
builder.WriteString(*v)
}
builder.WriteString(", ")
builder.WriteString("image_count=") builder.WriteString("image_count=")
builder.WriteString(fmt.Sprintf("%v", _m.ImageCount)) builder.WriteString(fmt.Sprintf("%v", _m.ImageCount))
builder.WriteString(", ") builder.WriteString(", ")

View File

@@ -62,6 +62,8 @@ const (
FieldDurationMs = "duration_ms" FieldDurationMs = "duration_ms"
// FieldFirstTokenMs holds the string denoting the first_token_ms field in the database. // FieldFirstTokenMs holds the string denoting the first_token_ms field in the database.
FieldFirstTokenMs = "first_token_ms" 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 holds the string denoting the image_count field in the database.
FieldImageCount = "image_count" FieldImageCount = "image_count"
// FieldImageSize holds the string denoting the image_size field in the database. // FieldImageSize holds the string denoting the image_size field in the database.
@@ -144,6 +146,7 @@ var Columns = []string{
FieldStream, FieldStream,
FieldDurationMs, FieldDurationMs,
FieldFirstTokenMs, FieldFirstTokenMs,
FieldUserAgent,
FieldImageCount, FieldImageCount,
FieldImageSize, FieldImageSize,
FieldCreatedAt, FieldCreatedAt,
@@ -194,6 +197,8 @@ var (
DefaultBillingType int8 DefaultBillingType int8
// DefaultStream holds the default value on creation for the "stream" field. // DefaultStream holds the default value on creation for the "stream" field.
DefaultStream bool 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 holds the default value on creation for the "image_count" field.
DefaultImageCount int DefaultImageCount int
// ImageSizeValidator is a validator for the "image_size" field. It is called by the builders before save. // 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() 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. // ByImageCount orders the results by the image_count field.
func ByImageCount(opts ...sql.OrderTermOption) OrderOption { func ByImageCount(opts ...sql.OrderTermOption) OrderOption {
return sql.OrderByField(FieldImageCount, opts...).ToFunc() 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)) 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. // ImageCount applies equality check predicate on the "image_count" field. It's identical to ImageCountEQ.
func ImageCount(v int) predicate.UsageLog { func ImageCount(v int) predicate.UsageLog {
return predicate.UsageLog(sql.FieldEQ(FieldImageCount, v)) return predicate.UsageLog(sql.FieldEQ(FieldImageCount, v))
@@ -1110,6 +1115,81 @@ func FirstTokenMsNotNil() predicate.UsageLog {
return predicate.UsageLog(sql.FieldNotNull(FieldFirstTokenMs)) 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. // ImageCountEQ applies the EQ predicate on the "image_count" field.
func ImageCountEQ(v int) predicate.UsageLog { func ImageCountEQ(v int) predicate.UsageLog {
return predicate.UsageLog(sql.FieldEQ(FieldImageCount, v)) return predicate.UsageLog(sql.FieldEQ(FieldImageCount, v))

View File

@@ -323,6 +323,20 @@ func (_c *UsageLogCreate) SetNillableFirstTokenMs(v *int) *UsageLogCreate {
return _c 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. // SetImageCount sets the "image_count" field.
func (_c *UsageLogCreate) SetImageCount(v int) *UsageLogCreate { func (_c *UsageLogCreate) SetImageCount(v int) *UsageLogCreate {
_c.mutation.SetImageCount(v) _c.mutation.SetImageCount(v)
@@ -567,6 +581,11 @@ func (_c *UsageLogCreate) check() error {
if _, ok := _c.mutation.Stream(); !ok { if _, ok := _c.mutation.Stream(); !ok {
return &ValidationError{Name: "stream", err: errors.New(`ent: missing required field "UsageLog.stream"`)} 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 { if _, ok := _c.mutation.ImageCount(); !ok {
return &ValidationError{Name: "image_count", err: errors.New(`ent: missing required field "UsageLog.image_count"`)} 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) _spec.SetField(usagelog.FieldFirstTokenMs, field.TypeInt, value)
_node.FirstTokenMs = &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 { if value, ok := _c.mutation.ImageCount(); ok {
_spec.SetField(usagelog.FieldImageCount, field.TypeInt, value) _spec.SetField(usagelog.FieldImageCount, field.TypeInt, value)
_node.ImageCount = value _node.ImageCount = value
@@ -1247,6 +1270,24 @@ func (u *UsageLogUpsert) ClearFirstTokenMs() *UsageLogUpsert {
return u 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. // SetImageCount sets the "image_count" field.
func (u *UsageLogUpsert) SetImageCount(v int) *UsageLogUpsert { func (u *UsageLogUpsert) SetImageCount(v int) *UsageLogUpsert {
u.Set(usagelog.FieldImageCount, v) 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. // SetImageCount sets the "image_count" field.
func (u *UsageLogUpsertOne) SetImageCount(v int) *UsageLogUpsertOne { func (u *UsageLogUpsertOne) SetImageCount(v int) *UsageLogUpsertOne {
return u.Update(func(s *UsageLogUpsert) { 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. // SetImageCount sets the "image_count" field.
func (u *UsageLogUpsertBulk) SetImageCount(v int) *UsageLogUpsertBulk { func (u *UsageLogUpsertBulk) SetImageCount(v int) *UsageLogUpsertBulk {
return u.Update(func(s *UsageLogUpsert) { return u.Update(func(s *UsageLogUpsert) {

View File

@@ -504,6 +504,26 @@ func (_u *UsageLogUpdate) ClearFirstTokenMs() *UsageLogUpdate {
return _u 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. // SetImageCount sets the "image_count" field.
func (_u *UsageLogUpdate) SetImageCount(v int) *UsageLogUpdate { func (_u *UsageLogUpdate) SetImageCount(v int) *UsageLogUpdate {
_u.mutation.ResetImageCount() _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)} 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 v, ok := _u.mutation.ImageSize(); ok {
if err := usagelog.ImageSizeValidator(v); err != nil { 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)} 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() { if _u.mutation.FirstTokenMsCleared() {
_spec.ClearField(usagelog.FieldFirstTokenMs, field.TypeInt) _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 { if value, ok := _u.mutation.ImageCount(); ok {
_spec.SetField(usagelog.FieldImageCount, field.TypeInt, value) _spec.SetField(usagelog.FieldImageCount, field.TypeInt, value)
} }
@@ -1433,6 +1464,26 @@ func (_u *UsageLogUpdateOne) ClearFirstTokenMs() *UsageLogUpdateOne {
return _u 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. // SetImageCount sets the "image_count" field.
func (_u *UsageLogUpdateOne) SetImageCount(v int) *UsageLogUpdateOne { func (_u *UsageLogUpdateOne) SetImageCount(v int) *UsageLogUpdateOne {
_u.mutation.ResetImageCount() _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)} 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 v, ok := _u.mutation.ImageSize(); ok {
if err := usagelog.ImageSizeValidator(v); err != nil { 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)} 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() { if _u.mutation.FirstTokenMsCleared() {
_spec.ClearField(usagelog.FieldFirstTokenMs, field.TypeInt) _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 { if value, ok := _u.mutation.ImageCount(); ok {
_spec.SetField(usagelog.FieldImageCount, field.TypeInt, value) _spec.SetField(usagelog.FieldImageCount, field.TypeInt, value)
} }

View File

@@ -52,6 +52,15 @@ type Config struct {
RunMode string `mapstructure:"run_mode" yaml:"run_mode"` RunMode string `mapstructure:"run_mode" yaml:"run_mode"`
Timezone string `mapstructure:"timezone"` // e.g. "Asia/Shanghai", "UTC" Timezone string `mapstructure:"timezone"` // e.g. "Asia/Shanghai", "UTC"
Gemini GeminiConfig `mapstructure:"gemini"` 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 { type GeminiConfig struct {
@@ -558,6 +567,10 @@ func setDefaults() {
viper.SetDefault("gemini.oauth.client_secret", "") viper.SetDefault("gemini.oauth.client_secret", "")
viper.SetDefault("gemini.oauth.scopes", "") viper.SetDefault("gemini.oauth.scopes", "")
viper.SetDefault("gemini.quota.policy", "") viper.SetDefault("gemini.quota.policy", "")
// Update - 在线更新配置
// 代理地址为空表示直连 GitHub适用于海外服务器
viper.SetDefault("update.proxy_url", "")
} }
func (c *Config) Validate() error { func (c *Config) Validate() error {

View File

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

View File

@@ -1,7 +1,11 @@
// Package dto provides data transfer objects for HTTP handlers. // Package dto provides data transfer objects for HTTP handlers.
package dto 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 { func UserFromServiceShallow(u *service.User) *User {
if u == nil { if u == nil {
@@ -120,6 +124,8 @@ func AccountFromServiceShallow(a *service.Account) *Account {
Status: a.Status, Status: a.Status,
ErrorMessage: a.ErrorMessage, ErrorMessage: a.ErrorMessage,
LastUsedAt: a.LastUsedAt, LastUsedAt: a.LastUsedAt,
ExpiresAt: timeToUnixSeconds(a.ExpiresAt),
AutoPauseOnExpired: a.AutoPauseOnExpired,
CreatedAt: a.CreatedAt, CreatedAt: a.CreatedAt,
UpdatedAt: a.UpdatedAt, UpdatedAt: a.UpdatedAt,
Schedulable: a.Schedulable, Schedulable: a.Schedulable,
@@ -157,6 +163,14 @@ func AccountFromService(a *service.Account) *Account {
return out return out
} }
func timeToUnixSeconds(value *time.Time) *int64 {
if value == nil {
return nil
}
ts := value.Unix()
return &ts
}
func AccountGroupFromService(ag *service.AccountGroup) *AccountGroup { func AccountGroupFromService(ag *service.AccountGroup) *AccountGroup {
if ag == nil { if ag == nil {
return nil return nil

View File

@@ -60,21 +60,23 @@ type Group struct {
} }
type Account struct { type Account struct {
ID int64 `json:"id"` ID int64 `json:"id"`
Name string `json:"name"` Name string `json:"name"`
Notes *string `json:"notes"` Notes *string `json:"notes"`
Platform string `json:"platform"` Platform string `json:"platform"`
Type string `json:"type"` Type string `json:"type"`
Credentials map[string]any `json:"credentials"` Credentials map[string]any `json:"credentials"`
Extra map[string]any `json:"extra"` Extra map[string]any `json:"extra"`
ProxyID *int64 `json:"proxy_id"` ProxyID *int64 `json:"proxy_id"`
Concurrency int `json:"concurrency"` Concurrency int `json:"concurrency"`
Priority int `json:"priority"` Priority int `json:"priority"`
Status string `json:"status"` Status string `json:"status"`
ErrorMessage string `json:"error_message"` ErrorMessage string `json:"error_message"`
LastUsedAt *time.Time `json:"last_used_at"` LastUsedAt *time.Time `json:"last_used_at"`
CreatedAt time.Time `json:"created_at"` ExpiresAt *int64 `json:"expires_at"`
UpdatedAt time.Time `json:"updated_at"` AutoPauseOnExpired bool `json:"auto_pause_on_expired"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
Schedulable bool `json:"schedulable"` Schedulable bool `json:"schedulable"`

View File

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

View File

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

View File

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

View File

@@ -76,7 +76,8 @@ func (r *accountRepository) Create(ctx context.Context, account *service.Account
SetPriority(account.Priority). SetPriority(account.Priority).
SetStatus(account.Status). SetStatus(account.Status).
SetErrorMessage(account.ErrorMessage). SetErrorMessage(account.ErrorMessage).
SetSchedulable(account.Schedulable) SetSchedulable(account.Schedulable).
SetAutoPauseOnExpired(account.AutoPauseOnExpired)
if account.ProxyID != nil { if account.ProxyID != nil {
builder.SetProxyID(*account.ProxyID) builder.SetProxyID(*account.ProxyID)
@@ -84,6 +85,9 @@ func (r *accountRepository) Create(ctx context.Context, account *service.Account
if account.LastUsedAt != nil { if account.LastUsedAt != nil {
builder.SetLastUsedAt(*account.LastUsedAt) builder.SetLastUsedAt(*account.LastUsedAt)
} }
if account.ExpiresAt != nil {
builder.SetExpiresAt(*account.ExpiresAt)
}
if account.RateLimitedAt != nil { if account.RateLimitedAt != nil {
builder.SetRateLimitedAt(*account.RateLimitedAt) builder.SetRateLimitedAt(*account.RateLimitedAt)
} }
@@ -280,7 +284,8 @@ func (r *accountRepository) Update(ctx context.Context, account *service.Account
SetPriority(account.Priority). SetPriority(account.Priority).
SetStatus(account.Status). SetStatus(account.Status).
SetErrorMessage(account.ErrorMessage). SetErrorMessage(account.ErrorMessage).
SetSchedulable(account.Schedulable) SetSchedulable(account.Schedulable).
SetAutoPauseOnExpired(account.AutoPauseOnExpired)
if account.ProxyID != nil { if account.ProxyID != nil {
builder.SetProxyID(*account.ProxyID) builder.SetProxyID(*account.ProxyID)
@@ -292,6 +297,11 @@ func (r *accountRepository) Update(ctx context.Context, account *service.Account
} else { } else {
builder.ClearLastUsedAt() builder.ClearLastUsedAt()
} }
if account.ExpiresAt != nil {
builder.SetExpiresAt(*account.ExpiresAt)
} else {
builder.ClearExpiresAt()
}
if account.RateLimitedAt != nil { if account.RateLimitedAt != nil {
builder.SetRateLimitedAt(*account.RateLimitedAt) builder.SetRateLimitedAt(*account.RateLimitedAt)
} else { } else {
@@ -570,6 +580,7 @@ func (r *accountRepository) ListSchedulable(ctx context.Context) ([]service.Acco
dbaccount.StatusEQ(service.StatusActive), dbaccount.StatusEQ(service.StatusActive),
dbaccount.SchedulableEQ(true), dbaccount.SchedulableEQ(true),
tempUnschedulablePredicate(), tempUnschedulablePredicate(),
notExpiredPredicate(now),
dbaccount.Or(dbaccount.OverloadUntilIsNil(), dbaccount.OverloadUntilLTE(now)), dbaccount.Or(dbaccount.OverloadUntilIsNil(), dbaccount.OverloadUntilLTE(now)),
dbaccount.Or(dbaccount.RateLimitResetAtIsNil(), dbaccount.RateLimitResetAtLTE(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.StatusEQ(service.StatusActive),
dbaccount.SchedulableEQ(true), dbaccount.SchedulableEQ(true),
tempUnschedulablePredicate(), tempUnschedulablePredicate(),
notExpiredPredicate(now),
dbaccount.Or(dbaccount.OverloadUntilIsNil(), dbaccount.OverloadUntilLTE(now)), dbaccount.Or(dbaccount.OverloadUntilIsNil(), dbaccount.OverloadUntilLTE(now)),
dbaccount.Or(dbaccount.RateLimitResetAtIsNil(), dbaccount.RateLimitResetAtLTE(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.StatusEQ(service.StatusActive),
dbaccount.SchedulableEQ(true), dbaccount.SchedulableEQ(true),
tempUnschedulablePredicate(), tempUnschedulablePredicate(),
notExpiredPredicate(now),
dbaccount.Or(dbaccount.OverloadUntilIsNil(), dbaccount.OverloadUntilLTE(now)), dbaccount.Or(dbaccount.OverloadUntilIsNil(), dbaccount.OverloadUntilLTE(now)),
dbaccount.Or(dbaccount.RateLimitResetAtIsNil(), dbaccount.RateLimitResetAtLTE(now)), dbaccount.Or(dbaccount.RateLimitResetAtIsNil(), dbaccount.RateLimitResetAtLTE(now)),
). ).
@@ -727,6 +740,27 @@ func (r *accountRepository) SetSchedulable(ctx context.Context, id int64, schedu
return err 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 { func (r *accountRepository) UpdateExtra(ctx context.Context, id int64, updates map[string]any) error {
if len(updates) == 0 { if len(updates) == 0 {
return nil return nil
@@ -861,6 +895,7 @@ func (r *accountRepository) queryAccountsByGroup(ctx context.Context, groupID in
preds = append(preds, preds = append(preds,
dbaccount.SchedulableEQ(true), dbaccount.SchedulableEQ(true),
tempUnschedulablePredicate(), tempUnschedulablePredicate(),
notExpiredPredicate(now),
dbaccount.Or(dbaccount.OverloadUntilIsNil(), dbaccount.OverloadUntilLTE(now)), dbaccount.Or(dbaccount.OverloadUntilIsNil(), dbaccount.OverloadUntilLTE(now)),
dbaccount.Or(dbaccount.RateLimitResetAtIsNil(), dbaccount.RateLimitResetAtLTE(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) { func (r *accountRepository) loadTempUnschedStates(ctx context.Context, accountIDs []int64) (map[int64]tempUnschedSnapshot, error) {
out := make(map[int64]tempUnschedSnapshot) out := make(map[int64]tempUnschedSnapshot)
if len(accountIDs) == 0 { if len(accountIDs) == 0 {
@@ -1086,6 +1129,8 @@ func accountEntityToService(m *dbent.Account) *service.Account {
Status: m.Status, Status: m.Status,
ErrorMessage: derefString(m.ErrorMessage), ErrorMessage: derefString(m.ErrorMessage),
LastUsedAt: m.LastUsedAt, LastUsedAt: m.LastUsedAt,
ExpiresAt: m.ExpiresAt,
AutoPauseOnExpired: m.AutoPauseOnExpired,
CreatedAt: m.CreatedAt, CreatedAt: m.CreatedAt,
UpdatedAt: m.UpdatedAt, UpdatedAt: m.UpdatedAt,
Schedulable: m.Schedulable, Schedulable: m.Schedulable,

View File

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

View File

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

View File

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

View File

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

View File

@@ -22,7 +22,7 @@ import (
"github.com/lib/pq" "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 { type usageLogRepository struct {
client *dbent.Client client *dbent.Client
@@ -109,6 +109,7 @@ func (r *usageLogRepository) Create(ctx context.Context, log *service.UsageLog)
stream, stream,
duration_ms, duration_ms,
first_token_ms, first_token_ms,
user_agent,
image_count, image_count,
image_size, image_size,
created_at created_at
@@ -118,8 +119,7 @@ func (r *usageLogRepository) Create(ctx context.Context, log *service.UsageLog)
$8, $9, $10, $11, $8, $9, $10, $11,
$12, $13, $12, $13,
$14, $15, $16, $17, $18, $19, $14, $15, $16, $17, $18, $19,
$20, $21, $22, $23, $24, $20, $21, $22, $23, $24, $25, $26, $27, $28
$25, $26, $27
) )
ON CONFLICT (request_id, api_key_id) DO NOTHING ON CONFLICT (request_id, api_key_id) DO NOTHING
RETURNING id, created_at RETURNING id, created_at
@@ -129,6 +129,7 @@ func (r *usageLogRepository) Create(ctx context.Context, log *service.UsageLog)
subscriptionID := nullInt64(log.SubscriptionID) subscriptionID := nullInt64(log.SubscriptionID)
duration := nullInt(log.DurationMs) duration := nullInt(log.DurationMs)
firstToken := nullInt(log.FirstTokenMs) firstToken := nullInt(log.FirstTokenMs)
userAgent := nullString(log.UserAgent)
imageSize := nullString(log.ImageSize) imageSize := nullString(log.ImageSize)
var requestIDArg any var requestIDArg any
@@ -161,6 +162,7 @@ func (r *usageLogRepository) Create(ctx context.Context, log *service.UsageLog)
log.Stream, log.Stream,
duration, duration,
firstToken, firstToken,
userAgent,
log.ImageCount, log.ImageCount,
imageSize, imageSize,
createdAt, createdAt,
@@ -1870,6 +1872,7 @@ func scanUsageLog(scanner interface{ Scan(...any) error }) (*service.UsageLog, e
stream bool stream bool
durationMs sql.NullInt64 durationMs sql.NullInt64
firstTokenMs sql.NullInt64 firstTokenMs sql.NullInt64
userAgent sql.NullString
imageCount int imageCount int
imageSize sql.NullString imageSize sql.NullString
createdAt time.Time createdAt time.Time
@@ -1901,6 +1904,7 @@ func scanUsageLog(scanner interface{ Scan(...any) error }) (*service.UsageLog, e
&stream, &stream,
&durationMs, &durationMs,
&firstTokenMs, &firstTokenMs,
&userAgent,
&imageCount, &imageCount,
&imageSize, &imageSize,
&createdAt, &createdAt,
@@ -1952,6 +1956,9 @@ func scanUsageLog(scanner interface{ Scan(...any) error }) (*service.UsageLog, e
value := int(firstTokenMs.Int64) value := int(firstTokenMs.Int64)
log.FirstTokenMs = &value log.FirstTokenMs = &value
} }
if userAgent.Valid {
log.UserAgent = &userAgent.String
}
if imageSize.Valid { if imageSize.Valid {
log.ImageSize = &imageSize.String 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) 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 // ProviderSet is the Wire provider set for all repositories
var ProviderSet = wire.NewSet( var ProviderSet = wire.NewSet(
NewUserRepository, NewUserRepository,
@@ -53,8 +65,8 @@ var ProviderSet = wire.NewSet(
// HTTP service ports (DI Strategy A: return interface directly) // HTTP service ports (DI Strategy A: return interface directly)
NewTurnstileVerifier, NewTurnstileVerifier,
NewPricingRemoteClient, ProvidePricingRemoteClient,
NewGitHubReleaseClient, ProvideGitHubReleaseClient,
NewProxyExitInfoProber, NewProxyExitInfoProber,
NewClaudeUsageFetcher, NewClaudeUsageFetcher,
NewClaudeOAuthClient, NewClaudeOAuthClient,

View File

@@ -9,21 +9,23 @@ import (
) )
type Account struct { type Account struct {
ID int64 ID int64
Name string Name string
Notes *string Notes *string
Platform string Platform string
Type string Type string
Credentials map[string]any Credentials map[string]any
Extra map[string]any Extra map[string]any
ProxyID *int64 ProxyID *int64
Concurrency int Concurrency int
Priority int Priority int
Status string Status string
ErrorMessage string ErrorMessage string
LastUsedAt *time.Time LastUsedAt *time.Time
CreatedAt time.Time ExpiresAt *time.Time
UpdatedAt time.Time AutoPauseOnExpired bool
CreatedAt time.Time
UpdatedAt time.Time
Schedulable bool Schedulable bool
@@ -60,6 +62,9 @@ func (a *Account) IsSchedulable() bool {
return false return false
} }
now := time.Now() now := time.Now()
if a.AutoPauseOnExpired && a.ExpiresAt != nil && !now.Before(*a.ExpiresAt) {
return false
}
if a.OverloadUntil != nil && now.Before(*a.OverloadUntil) { if a.OverloadUntil != nil && now.Before(*a.OverloadUntil) {
return false 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 BatchUpdateLastUsed(ctx context.Context, updates map[int64]time.Time) error
SetError(ctx context.Context, id int64, errorMsg string) error SetError(ctx context.Context, id int64, errorMsg string) error
SetSchedulable(ctx context.Context, id int64, schedulable bool) 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 BindGroups(ctx context.Context, accountID int64, groupIDs []int64) error
ListSchedulable(ctx context.Context) ([]Account, error) ListSchedulable(ctx context.Context) ([]Account, error)
@@ -71,29 +72,33 @@ type AccountBulkUpdate struct {
// CreateAccountRequest 创建账号请求 // CreateAccountRequest 创建账号请求
type CreateAccountRequest struct { type CreateAccountRequest struct {
Name string `json:"name"` Name string `json:"name"`
Notes *string `json:"notes"` Notes *string `json:"notes"`
Platform string `json:"platform"` Platform string `json:"platform"`
Type string `json:"type"` Type string `json:"type"`
Credentials map[string]any `json:"credentials"` Credentials map[string]any `json:"credentials"`
Extra map[string]any `json:"extra"` Extra map[string]any `json:"extra"`
ProxyID *int64 `json:"proxy_id"` ProxyID *int64 `json:"proxy_id"`
Concurrency int `json:"concurrency"` Concurrency int `json:"concurrency"`
Priority int `json:"priority"` Priority int `json:"priority"`
GroupIDs []int64 `json:"group_ids"` GroupIDs []int64 `json:"group_ids"`
ExpiresAt *time.Time `json:"expires_at"`
AutoPauseOnExpired *bool `json:"auto_pause_on_expired"`
} }
// UpdateAccountRequest 更新账号请求 // UpdateAccountRequest 更新账号请求
type UpdateAccountRequest struct { type UpdateAccountRequest struct {
Name *string `json:"name"` Name *string `json:"name"`
Notes *string `json:"notes"` Notes *string `json:"notes"`
Credentials *map[string]any `json:"credentials"` Credentials *map[string]any `json:"credentials"`
Extra *map[string]any `json:"extra"` Extra *map[string]any `json:"extra"`
ProxyID *int64 `json:"proxy_id"` ProxyID *int64 `json:"proxy_id"`
Concurrency *int `json:"concurrency"` Concurrency *int `json:"concurrency"`
Priority *int `json:"priority"` Priority *int `json:"priority"`
Status *string `json:"status"` Status *string `json:"status"`
GroupIDs *[]int64 `json:"group_ids"` GroupIDs *[]int64 `json:"group_ids"`
ExpiresAt *time.Time `json:"expires_at"`
AutoPauseOnExpired *bool `json:"auto_pause_on_expired"`
} }
// AccountService 账号管理服务 // AccountService 账号管理服务
@@ -134,6 +139,12 @@ func (s *AccountService) Create(ctx context.Context, req CreateAccountRequest) (
Concurrency: req.Concurrency, Concurrency: req.Concurrency,
Priority: req.Priority, Priority: req.Priority,
Status: StatusActive, 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 { 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 { if req.Status != nil {
account.Status = *req.Status account.Status = *req.Status
} }
if req.ExpiresAt != nil {
account.ExpiresAt = req.ExpiresAt
}
if req.AutoPauseOnExpired != nil {
account.AutoPauseOnExpired = *req.AutoPauseOnExpired
}
// 先验证分组是否存在(在任何写操作之前) // 先验证分组是否存在(在任何写操作之前)
if req.GroupIDs != nil { if req.GroupIDs != nil {

View File

@@ -103,6 +103,10 @@ func (s *accountRepoStub) SetSchedulable(ctx context.Context, id int64, schedula
panic("unexpected SetSchedulable call") 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 { func (s *accountRepoStub) BindGroups(ctx context.Context, accountID int64, groupIDs []int64) error {
panic("unexpected BindGroups call") panic("unexpected BindGroups call")
} }

View File

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

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 { func (m *mockAccountRepoForPlatform) SetSchedulable(ctx context.Context, id int64, schedulable bool) error {
return nil 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 { func (m *mockAccountRepoForPlatform) BindGroups(ctx context.Context, accountID int64, groupIDs []int64) error {
return nil return nil
} }

View File

@@ -35,6 +35,7 @@ const (
stickySessionTTL = time.Hour // 粘性会话TTL stickySessionTTL = time.Hour // 粘性会话TTL
defaultMaxLineSize = 10 * 1024 * 1024 defaultMaxLineSize = 10 * 1024 * 1024
claudeCodeSystemPrompt = "You are Claude Code, Anthropic's official CLI for Claude." claudeCodeSystemPrompt = "You are Claude Code, Anthropic's official CLI for Claude."
maxCacheControlBlocks = 4 // Anthropic API 允许的最大 cache_control 块数量
) )
// sseDataRe matches SSE data lines with optional whitespace after colon. // sseDataRe matches SSE data lines with optional whitespace after colon.
@@ -43,6 +44,16 @@ var (
sseDataRe = regexp.MustCompile(`^data:\s*`) sseDataRe = regexp.MustCompile(`^data:\s*`)
sessionIDRegex = regexp.MustCompile(`session_([a-f0-9-]{36})`) sessionIDRegex = regexp.MustCompile(`session_([a-f0-9-]{36})`)
claudeCliUserAgentRe = regexp.MustCompile(`^claude-cli/\d+\.\d+\.\d+`) claudeCliUserAgentRe = regexp.MustCompile(`^claude-cli/\d+\.\d+\.\d+`)
// claudeCodePromptPrefixes 用于检测 Claude Code 系统提示词的前缀列表
// 支持多种变体标准版、Agent SDK 版、Explore Agent 版、Compact 版等
// 注意:前缀之间不应存在包含关系,否则会导致冗余匹配
claudeCodePromptPrefixes = []string{
"You are Claude Code, Anthropic's official CLI for Claude", // 标准版 & Agent SDK 版(含 running within...
"You are a Claude agent, built on Anthropic's Claude Agent SDK", // Agent SDK 变体
"You are a file search specialist for Claude Code", // Explore Agent 版
"You are a helpful AI assistant tasked with summarizing conversations", // Compact 版
}
) )
// allowedHeaders 白名单headers参考CRS项目 // allowedHeaders 白名单headers参考CRS项目
@@ -355,17 +366,8 @@ func (s *GatewayService) SelectAccountForModelWithExclusions(ctx context.Context
return s.selectAccountWithMixedScheduling(ctx, groupID, sessionHash, requestedModel, excludedIDs, platform) return s.selectAccountWithMixedScheduling(ctx, groupID, sessionHash, requestedModel, excludedIDs, platform)
} }
// 强制平台模式:优先按分组查找,找不到再查全部该平台账户
if hasForcePlatform && groupID != nil {
account, err := s.selectAccountForModelWithPlatform(ctx, groupID, sessionHash, requestedModel, excludedIDs, platform)
if err == nil {
return account, nil
}
// 分组中找不到,回退查询全部该平台账户
groupID = nil
}
// antigravity 分组、强制平台模式或无分组使用单平台选择 // antigravity 分组、强制平台模式或无分组使用单平台选择
// 注意:强制平台模式也必须遵守分组限制,不再回退到全平台查询
return s.selectAccountForModelWithPlatform(ctx, groupID, sessionHash, requestedModel, excludedIDs, platform) return s.selectAccountForModelWithPlatform(ctx, groupID, sessionHash, requestedModel, excludedIDs, platform)
} }
@@ -443,7 +445,8 @@ func (s *GatewayService) SelectAccountWithLoadAwareness(ctx context.Context, gro
accountID, err := s.cache.GetSessionAccountID(ctx, sessionHash) accountID, err := s.cache.GetSessionAccountID(ctx, sessionHash)
if err == nil && accountID > 0 && !isExcluded(accountID) { if err == nil && accountID > 0 && !isExcluded(accountID) {
account, err := s.accountRepo.GetByID(ctx, accountID) account, err := s.accountRepo.GetByID(ctx, accountID)
if err == nil && s.isAccountAllowedForPlatform(account, platform, useMixed) && if err == nil && s.isAccountInGroup(account, groupID) &&
s.isAccountAllowedForPlatform(account, platform, useMixed) &&
account.IsSchedulable() && account.IsSchedulable() &&
(requestedModel == "" || s.isModelSupportedByAccount(account, requestedModel)) { (requestedModel == "" || s.isModelSupportedByAccount(account, requestedModel)) {
result, err := s.tryAcquireAccountSlot(ctx, accountID, account.Concurrency) result, err := s.tryAcquireAccountSlot(ctx, accountID, account.Concurrency)
@@ -660,9 +663,7 @@ func (s *GatewayService) listSchedulableAccounts(ctx context.Context, groupID *i
accounts, err = s.accountRepo.ListSchedulableByPlatform(ctx, platform) accounts, err = s.accountRepo.ListSchedulableByPlatform(ctx, platform)
} else if groupID != nil { } else if groupID != nil {
accounts, err = s.accountRepo.ListSchedulableByGroupIDAndPlatform(ctx, *groupID, platform) accounts, err = s.accountRepo.ListSchedulableByGroupIDAndPlatform(ctx, *groupID, platform)
if err == nil && len(accounts) == 0 && hasForcePlatform { // 分组内无账号则返回空列表,由上层处理错误,不再回退到全平台查询
accounts, err = s.accountRepo.ListSchedulableByPlatform(ctx, platform)
}
} else { } else {
accounts, err = s.accountRepo.ListSchedulableByPlatform(ctx, platform) accounts, err = s.accountRepo.ListSchedulableByPlatform(ctx, platform)
} }
@@ -685,6 +686,23 @@ func (s *GatewayService) isAccountAllowedForPlatform(account *Account, platform
return account.Platform == platform return account.Platform == platform
} }
// isAccountInGroup checks if the account belongs to the specified group.
// Returns true if groupID is nil (no group restriction) or account belongs to the group.
func (s *GatewayService) isAccountInGroup(account *Account, groupID *int64) bool {
if groupID == nil {
return true // 无分组限制
}
if account == nil {
return false
}
for _, ag := range account.AccountGroups {
if ag.GroupID == *groupID {
return true
}
}
return false
}
func (s *GatewayService) tryAcquireAccountSlot(ctx context.Context, accountID int64, maxConcurrency int) (*AcquireResult, error) { func (s *GatewayService) tryAcquireAccountSlot(ctx context.Context, accountID int64, maxConcurrency int) (*AcquireResult, error) {
if s.concurrencyService == nil { if s.concurrencyService == nil {
return &AcquireResult{Acquired: true, ReleaseFunc: func() {}}, nil return &AcquireResult{Acquired: true, ReleaseFunc: func() {}}, nil
@@ -723,8 +741,8 @@ func (s *GatewayService) selectAccountForModelWithPlatform(ctx context.Context,
if err == nil && accountID > 0 { if err == nil && accountID > 0 {
if _, excluded := excludedIDs[accountID]; !excluded { if _, excluded := excludedIDs[accountID]; !excluded {
account, err := s.accountRepo.GetByID(ctx, accountID) account, err := s.accountRepo.GetByID(ctx, accountID)
// 检查账号平台是否匹配(确保粘性会话不会跨平台) // 检查账号分组归属和平台匹配(确保粘性会话不会跨分组或跨平台)
if err == nil && account.Platform == platform && account.IsSchedulable() && (requestedModel == "" || s.isModelSupportedByAccount(account, requestedModel)) { if err == nil && s.isAccountInGroup(account, groupID) && account.Platform == platform && account.IsSchedulable() && (requestedModel == "" || s.isModelSupportedByAccount(account, requestedModel)) {
if err := s.cache.RefreshSessionTTL(ctx, sessionHash, stickySessionTTL); err != nil { if err := s.cache.RefreshSessionTTL(ctx, sessionHash, stickySessionTTL); err != nil {
log.Printf("refresh session ttl failed: session=%s err=%v", sessionHash, err) log.Printf("refresh session ttl failed: session=%s err=%v", sessionHash, err)
} }
@@ -812,8 +830,8 @@ func (s *GatewayService) selectAccountWithMixedScheduling(ctx context.Context, g
if err == nil && accountID > 0 { if err == nil && accountID > 0 {
if _, excluded := excludedIDs[accountID]; !excluded { if _, excluded := excludedIDs[accountID]; !excluded {
account, err := s.accountRepo.GetByID(ctx, accountID) account, err := s.accountRepo.GetByID(ctx, accountID)
// 检查账号是否有效原生平台直接匹配antigravity 需要启用混合调度 // 检查账号分组归属和有效原生平台直接匹配antigravity 需要启用混合调度
if err == nil && account.IsSchedulable() && (requestedModel == "" || s.isModelSupportedByAccount(account, requestedModel)) { if err == nil && s.isAccountInGroup(account, groupID) && account.IsSchedulable() && (requestedModel == "" || s.isModelSupportedByAccount(account, requestedModel)) {
if account.Platform == nativePlatform || (account.Platform == PlatformAntigravity && account.IsMixedSchedulingEnabled()) { if account.Platform == nativePlatform || (account.Platform == PlatformAntigravity && account.IsMixedSchedulingEnabled()) {
if err := s.cache.RefreshSessionTTL(ctx, sessionHash, stickySessionTTL); err != nil { if err := s.cache.RefreshSessionTTL(ctx, sessionHash, stickySessionTTL); err != nil {
log.Printf("refresh session ttl failed: session=%s err=%v", sessionHash, err) log.Printf("refresh session ttl failed: session=%s err=%v", sessionHash, err)
@@ -1013,15 +1031,15 @@ func isClaudeCodeClient(userAgent string, metadataUserID string) bool {
} }
// systemIncludesClaudeCodePrompt 检查 system 中是否已包含 Claude Code 提示词 // systemIncludesClaudeCodePrompt 检查 system 中是否已包含 Claude Code 提示词
// 支持 string 和 []any 两种格式 // 使用前缀匹配支持多种变体标准版、Agent SDK 版等)
func systemIncludesClaudeCodePrompt(system any) bool { func systemIncludesClaudeCodePrompt(system any) bool {
switch v := system.(type) { switch v := system.(type) {
case string: case string:
return v == claudeCodeSystemPrompt return hasClaudeCodePrefix(v)
case []any: case []any:
for _, item := range v { for _, item := range v {
if m, ok := item.(map[string]any); ok { if m, ok := item.(map[string]any); ok {
if text, ok := m["text"].(string); ok && text == claudeCodeSystemPrompt { if text, ok := m["text"].(string); ok && hasClaudeCodePrefix(text) {
return true return true
} }
} }
@@ -1030,6 +1048,16 @@ func systemIncludesClaudeCodePrompt(system any) bool {
return false return false
} }
// hasClaudeCodePrefix 检查文本是否以 Claude Code 提示词的特征前缀开头
func hasClaudeCodePrefix(text string) bool {
for _, prefix := range claudeCodePromptPrefixes {
if strings.HasPrefix(text, prefix) {
return true
}
}
return false
}
// injectClaudeCodePrompt 在 system 开头注入 Claude Code 提示词 // injectClaudeCodePrompt 在 system 开头注入 Claude Code 提示词
// 处理 null、字符串、数组三种格式 // 处理 null、字符串、数组三种格式
func injectClaudeCodePrompt(body []byte, system any) []byte { func injectClaudeCodePrompt(body []byte, system any) []byte {
@@ -1073,6 +1101,124 @@ func injectClaudeCodePrompt(body []byte, system any) []byte {
return result return result
} }
// enforceCacheControlLimit 强制执行 cache_control 块数量限制(最多 4 个)
// 超限时优先从 messages 中移除 cache_control保护 system 中的缓存控制
func enforceCacheControlLimit(body []byte) []byte {
var data map[string]any
if err := json.Unmarshal(body, &data); err != nil {
return body
}
// 计算当前 cache_control 块数量
count := countCacheControlBlocks(data)
if count <= maxCacheControlBlocks {
return body
}
// 超限:优先从 messages 中移除,再从 system 中移除
for count > maxCacheControlBlocks {
if removeCacheControlFromMessages(data) {
count--
continue
}
if removeCacheControlFromSystem(data) {
count--
continue
}
break
}
result, err := json.Marshal(data)
if err != nil {
return body
}
return result
}
// countCacheControlBlocks 统计 system 和 messages 中的 cache_control 块数量
func countCacheControlBlocks(data map[string]any) int {
count := 0
// 统计 system 中的块
if system, ok := data["system"].([]any); ok {
for _, item := range system {
if m, ok := item.(map[string]any); ok {
if _, has := m["cache_control"]; has {
count++
}
}
}
}
// 统计 messages 中的块
if messages, ok := data["messages"].([]any); ok {
for _, msg := range messages {
if msgMap, ok := msg.(map[string]any); ok {
if content, ok := msgMap["content"].([]any); ok {
for _, item := range content {
if m, ok := item.(map[string]any); ok {
if _, has := m["cache_control"]; has {
count++
}
}
}
}
}
}
}
return count
}
// removeCacheControlFromMessages 从 messages 中移除一个 cache_control从头开始
// 返回 true 表示成功移除false 表示没有可移除的
func removeCacheControlFromMessages(data map[string]any) bool {
messages, ok := data["messages"].([]any)
if !ok {
return false
}
for _, msg := range messages {
msgMap, ok := msg.(map[string]any)
if !ok {
continue
}
content, ok := msgMap["content"].([]any)
if !ok {
continue
}
for _, item := range content {
if m, ok := item.(map[string]any); ok {
if _, has := m["cache_control"]; has {
delete(m, "cache_control")
return true
}
}
}
}
return false
}
// removeCacheControlFromSystem 从 system 中移除一个 cache_control从尾部开始保护注入的 prompt
// 返回 true 表示成功移除false 表示没有可移除的
func removeCacheControlFromSystem(data map[string]any) bool {
system, ok := data["system"].([]any)
if !ok {
return false
}
// 从尾部开始移除,保护开头注入的 Claude Code prompt
for i := len(system) - 1; i >= 0; i-- {
if m, ok := system[i].(map[string]any); ok {
if _, has := m["cache_control"]; has {
delete(m, "cache_control")
return true
}
}
}
return false
}
// Forward 转发请求到Claude API // Forward 转发请求到Claude API
func (s *GatewayService) Forward(ctx context.Context, c *gin.Context, account *Account, parsed *ParsedRequest) (*ForwardResult, error) { func (s *GatewayService) Forward(ctx context.Context, c *gin.Context, account *Account, parsed *ParsedRequest) (*ForwardResult, error) {
startTime := time.Now() startTime := time.Now()
@@ -1093,6 +1239,9 @@ func (s *GatewayService) Forward(ctx context.Context, c *gin.Context, account *A
body = injectClaudeCodePrompt(body, parsed.System) body = injectClaudeCodePrompt(body, parsed.System)
} }
// 强制执行 cache_control 块数量限制(最多 4 个)
body = enforceCacheControlLimit(body)
// 应用模型映射仅对apikey类型账号 // 应用模型映射仅对apikey类型账号
originalModel := reqModel originalModel := reqModel
if account.Type == AccountTypeAPIKey { if account.Type == AccountTypeAPIKey {
@@ -2003,6 +2152,7 @@ type RecordUsageInput struct {
User *User User *User
Account *Account Account *Account
Subscription *UserSubscription // 可选:订阅信息 Subscription *UserSubscription // 可选:订阅信息
UserAgent string // 请求的 User-Agent
} }
// RecordUsage 记录使用量并扣费(或更新订阅用量) // RecordUsage 记录使用量并扣费(或更新订阅用量)
@@ -2088,6 +2238,11 @@ func (s *GatewayService) RecordUsage(ctx context.Context, input *RecordUsageInpu
CreatedAt: time.Now(), CreatedAt: time.Now(),
} }
// 添加 UserAgent
if input.UserAgent != "" {
usageLog.UserAgent = &input.UserAgent
}
// 添加分组和订阅关联 // 添加分组和订阅关联
if apiKey.GroupID != nil { if apiKey.GroupID != nil {
usageLog.GroupID = apiKey.GroupID 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 { func (m *mockAccountRepoForGemini) SetSchedulable(ctx context.Context, id int64, schedulable bool) error {
return nil 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 { func (m *mockAccountRepoForGemini) BindGroups(ctx context.Context, accountID int64, groupIDs []int64) error {
return nil return nil
} }

View File

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

View File

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

View File

@@ -47,6 +47,13 @@ func ProvideTokenRefreshService(
return svc 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 // ProvideTimingWheelService creates and starts TimingWheelService
func ProvideTimingWheelService() *TimingWheelService { func ProvideTimingWheelService() *TimingWheelService {
svc := NewTimingWheelService() svc := NewTimingWheelService()
@@ -110,6 +117,7 @@ var ProviderSet = wire.NewSet(
NewCRSSyncService, NewCRSSyncService,
ProvideUpdateService, ProvideUpdateService,
ProvideTokenRefreshService, ProvideTokenRefreshService,
ProvideAccountExpiryService,
ProvideTimingWheelService, ProvideTimingWheelService,
ProvideDeferredService, ProvideDeferredService,
NewAntigravityQuotaFetcher, 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: # 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={"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= 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 time (minutes) after hitting quota
# 达到配额后的冷却时间(分钟) # 达到配额后的冷却时间(分钟)
cooldown_minutes: 5 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} - SECURITY_URL_ALLOWLIST_ALLOW_PRIVATE_HOSTS=${SECURITY_URL_ALLOWLIST_ALLOW_PRIVATE_HOSTS:-false}
# Upstream hosts whitelist (comma-separated, only used when enabled=true) # Upstream hosts whitelist (comma-separated, only used when enabled=true)
- SECURITY_URL_ALLOWLIST_UPSTREAM_HOSTS=${SECURITY_URL_ALLOWLIST_UPSTREAM_HOSTS:-} - 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: depends_on:
postgres: postgres:
condition: service_healthy condition: service_healthy

View File

@@ -1012,7 +1012,7 @@
</div> </div>
<!-- Temp Unschedulable Rules --> <!-- 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 class="mb-3 flex items-center justify-between">
<div> <div>
<label class="input-label mb-0">{{ t('admin.accounts.tempUnschedulable.title') }}</label> <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> <p class="input-hint">{{ t('admin.accounts.priorityHint') }}</p>
</div> </div>
</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>
<div v-if="form.platform === 'antigravity'" class="flex items-center gap-2"> <div class="flex items-center justify-between">
<label class="flex cursor-pointer items-center gap-2"> <div>
<input <label class="input-label mb-0">{{
type="checkbox" t('admin.accounts.autoPauseOnExpired')
v-model="mixedScheduling" }}</label>
class="h-4 w-4 rounded border-gray-300 text-primary-500 focus:ring-primary-500 dark:border-dark-500" <p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
/> {{ t('admin.accounts.autoPauseOnExpiredDesc') }}
<span class="text-sm font-medium text-gray-700 dark:text-gray-300"> </p>
{{ 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>
<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>
</div> </div>
<!-- Group Selection - 仅标准模式显示 --> <div class="border-t border-gray-200 pt-4 dark:border-dark-600">
<GroupSelector <!-- Mixed Scheduling (only for antigravity accounts) -->
v-if="!authStore.isSimpleMode" <div v-if="form.platform === 'antigravity'" class="flex items-center gap-2">
v-model="form.group_ids" <label class="flex cursor-pointer items-center gap-2">
:groups="groups" <input
:platform="form.platform" type="checkbox"
:mixed-scheduling="mixedScheduling" v-model="mixedScheduling"
data-tour="account-form-groups" 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> </form>
@@ -1598,6 +1633,7 @@ import Icon from '@/components/icons/Icon.vue'
import ProxySelector from '@/components/common/ProxySelector.vue' import ProxySelector from '@/components/common/ProxySelector.vue'
import GroupSelector from '@/components/common/GroupSelector.vue' import GroupSelector from '@/components/common/GroupSelector.vue'
import ModelWhitelistSelector from '@/components/account/ModelWhitelistSelector.vue' import ModelWhitelistSelector from '@/components/account/ModelWhitelistSelector.vue'
import { formatDateTimeLocalInput, parseDateTimeLocalInput } from '@/utils/format'
import OAuthAuthorizationFlow from './OAuthAuthorizationFlow.vue' import OAuthAuthorizationFlow from './OAuthAuthorizationFlow.vue'
// Type for exposed OAuthAuthorizationFlow component // Type for exposed OAuthAuthorizationFlow component
@@ -1713,6 +1749,7 @@ const customErrorCodesEnabled = ref(false)
const selectedErrorCodes = ref<number[]>([]) const selectedErrorCodes = ref<number[]>([])
const customErrorCodeInput = ref<number | null>(null) const customErrorCodeInput = ref<number | null>(null)
const interceptWarmupRequests = ref(false) const interceptWarmupRequests = ref(false)
const autoPauseOnExpired = ref(true)
const mixedScheduling = ref(false) // For antigravity accounts: enable mixed scheduling const mixedScheduling = ref(false) // For antigravity accounts: enable mixed scheduling
const tempUnschedEnabled = ref(false) const tempUnschedEnabled = ref(false)
const tempUnschedRules = ref<TempUnschedRuleForm[]>([]) const tempUnschedRules = ref<TempUnschedRuleForm[]>([])
@@ -1795,7 +1832,8 @@ const form = reactive({
proxy_id: null as number | null, proxy_id: null as number | null,
concurrency: 10, concurrency: 10,
priority: 1, 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 // Helper to check if current type needs OAuth flow
@@ -1805,6 +1843,13 @@ const isManualInputMethod = computed(() => {
return oauthFlowRef.value?.inputMethod === 'manual' 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 canExchangeCode = computed(() => {
const authCode = oauthFlowRef.value?.authCode || '' const authCode = oauthFlowRef.value?.authCode || ''
if (form.platform === 'openai') { if (form.platform === 'openai') {
@@ -2055,6 +2100,7 @@ const resetForm = () => {
form.concurrency = 10 form.concurrency = 10
form.priority = 1 form.priority = 1
form.group_ids = [] form.group_ids = []
form.expires_at = null
accountCategory.value = 'oauth-based' accountCategory.value = 'oauth-based'
addMethod.value = 'oauth' addMethod.value = 'oauth'
apiKeyBaseUrl.value = 'https://api.anthropic.com' apiKeyBaseUrl.value = 'https://api.anthropic.com'
@@ -2066,6 +2112,7 @@ const resetForm = () => {
selectedErrorCodes.value = [] selectedErrorCodes.value = []
customErrorCodeInput.value = null customErrorCodeInput.value = null
interceptWarmupRequests.value = false interceptWarmupRequests.value = false
autoPauseOnExpired.value = true
tempUnschedEnabled.value = false tempUnschedEnabled.value = false
tempUnschedRules.value = [] tempUnschedRules.value = []
geminiOAuthType.value = 'code_assist' geminiOAuthType.value = 'code_assist'
@@ -2133,7 +2180,6 @@ const handleSubmit = async () => {
if (interceptWarmupRequests.value) { if (interceptWarmupRequests.value) {
credentials.intercept_warmup_requests = true credentials.intercept_warmup_requests = true
} }
if (!applyTempUnschedConfig(credentials)) { if (!applyTempUnschedConfig(credentials)) {
return return
} }
@@ -2144,7 +2190,8 @@ const handleSubmit = async () => {
try { try {
await adminAPI.accounts.create({ await adminAPI.accounts.create({
...form, ...form,
group_ids: form.group_ids group_ids: form.group_ids,
auto_pause_on_expired: autoPauseOnExpired.value
}) })
appStore.showSuccess(t('admin.accounts.accountCreated')) appStore.showSuccess(t('admin.accounts.accountCreated'))
emit('created') emit('created')
@@ -2182,6 +2229,9 @@ const handleGenerateUrl = async () => {
} }
} }
const formatDateTimeLocal = formatDateTimeLocalInput
const parseDateTimeLocal = parseDateTimeLocalInput
// Create account and handle success/failure // Create account and handle success/failure
const createAccountAndFinish = async ( const createAccountAndFinish = async (
platform: AccountPlatform, platform: AccountPlatform,
@@ -2202,7 +2252,9 @@ const createAccountAndFinish = async (
proxy_id: form.proxy_id, proxy_id: form.proxy_id,
concurrency: form.concurrency, concurrency: form.concurrency,
priority: form.priority, 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')) appStore.showSuccess(t('admin.accounts.accountCreated'))
emit('created') emit('created')
@@ -2416,7 +2468,8 @@ const handleCookieAuth = async (sessionKey: string) => {
extra, extra,
proxy_id: form.proxy_id, proxy_id: form.proxy_id,
concurrency: form.concurrency, concurrency: form.concurrency,
priority: form.priority priority: form.priority,
auto_pause_on_expired: autoPauseOnExpired.value
}) })
successCount++ successCount++

View File

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

View File

@@ -85,9 +85,40 @@
</div> </div>
<!-- Account Filter --> <!-- Account Filter -->
<div class="w-full sm:w-auto sm:min-w-[220px]"> <div ref="accountSearchRef" class="usage-filter-dropdown relative w-full sm:w-auto sm:min-w-[220px]">
<label class="input-label">{{ t('admin.usage.account') }}</label> <label class="input-label">{{ t('admin.usage.account') }}</label>
<Select v-model="filters.account_id" :options="accountOptions" searchable @change="emitChange" /> <input
v-model="accountKeyword"
type="text"
class="input pr-8"
:placeholder="t('admin.usage.searchAccountPlaceholder')"
@input="debounceAccountSearch"
@focus="showAccountDropdown = true"
/>
<button
v-if="filters.account_id"
type="button"
@click="clearAccount"
class="absolute right-2 top-9 text-gray-400"
aria-label="Clear account filter"
>
</button>
<div
v-if="showAccountDropdown && (accountResults.length > 0 || accountKeyword)"
class="absolute z-50 mt-1 max-h-60 w-full overflow-auto rounded-lg border bg-white shadow-lg dark:bg-gray-800"
>
<button
v-for="a in accountResults"
:key="a.id"
type="button"
@click="selectAccount(a)"
class="w-full px-4 py-2 text-left hover:bg-gray-100 dark:hover:bg-gray-700"
>
<span class="truncate">{{ a.name }}</span>
<span class="ml-2 text-xs text-gray-400">#{{ a.id }}</span>
</button>
</div>
</div> </div>
<!-- Stream Type Filter --> <!-- Stream Type Filter -->
@@ -166,6 +197,7 @@ const filters = toRef(props, 'modelValue')
const userSearchRef = ref<HTMLElement | null>(null) const userSearchRef = ref<HTMLElement | null>(null)
const apiKeySearchRef = ref<HTMLElement | null>(null) const apiKeySearchRef = ref<HTMLElement | null>(null)
const accountSearchRef = ref<HTMLElement | null>(null)
const userKeyword = ref('') const userKeyword = ref('')
const userResults = ref<SimpleUser[]>([]) const userResults = ref<SimpleUser[]>([])
@@ -177,9 +209,17 @@ const apiKeyResults = ref<SimpleApiKey[]>([])
const showApiKeyDropdown = ref(false) const showApiKeyDropdown = ref(false)
let apiKeySearchTimeout: ReturnType<typeof setTimeout> | null = null let apiKeySearchTimeout: ReturnType<typeof setTimeout> | null = null
interface SimpleAccount {
id: number
name: string
}
const accountKeyword = ref('')
const accountResults = ref<SimpleAccount[]>([])
const showAccountDropdown = ref(false)
let accountSearchTimeout: ReturnType<typeof setTimeout> | null = null
const modelOptions = ref<SelectOption[]>([{ value: null, label: t('admin.usage.allModels') }]) const modelOptions = ref<SelectOption[]>([{ value: null, label: t('admin.usage.allModels') }])
const groupOptions = ref<SelectOption[]>([{ value: null, label: t('admin.usage.allGroups') }]) const groupOptions = ref<SelectOption[]>([{ value: null, label: t('admin.usage.allGroups') }])
const accountOptions = ref<SelectOption[]>([{ value: null, label: t('admin.usage.allAccounts') }])
const streamTypeOptions = ref<SelectOption[]>([ const streamTypeOptions = ref<SelectOption[]>([
{ value: null, label: t('admin.usage.allTypes') }, { value: null, label: t('admin.usage.allTypes') },
@@ -278,6 +318,37 @@ const onClearApiKey = () => {
emitChange() emitChange()
} }
const debounceAccountSearch = () => {
if (accountSearchTimeout) clearTimeout(accountSearchTimeout)
accountSearchTimeout = setTimeout(async () => {
if (!accountKeyword.value) {
accountResults.value = []
return
}
try {
const res = await adminAPI.accounts.list(1, 20, { search: accountKeyword.value })
accountResults.value = res.items.map((a) => ({ id: a.id, name: a.name }))
} catch {
accountResults.value = []
}
}, 300)
}
const selectAccount = (a: SimpleAccount) => {
accountKeyword.value = a.name
showAccountDropdown.value = false
filters.value.account_id = a.id
emitChange()
}
const clearAccount = () => {
accountKeyword.value = ''
accountResults.value = []
showAccountDropdown.value = false
filters.value.account_id = undefined
emitChange()
}
const onApiKeyFocus = () => { const onApiKeyFocus = () => {
showApiKeyDropdown.value = true showApiKeyDropdown.value = true
// Trigger search if no results yet // Trigger search if no results yet
@@ -292,9 +363,11 @@ const onDocumentClick = (e: MouseEvent) => {
const clickedInsideUser = userSearchRef.value?.contains(target) ?? false const clickedInsideUser = userSearchRef.value?.contains(target) ?? false
const clickedInsideApiKey = apiKeySearchRef.value?.contains(target) ?? false const clickedInsideApiKey = apiKeySearchRef.value?.contains(target) ?? false
const clickedInsideAccount = accountSearchRef.value?.contains(target) ?? false
if (!clickedInsideUser) showUserDropdown.value = false if (!clickedInsideUser) showUserDropdown.value = false
if (!clickedInsideApiKey) showApiKeyDropdown.value = false if (!clickedInsideApiKey) showApiKeyDropdown.value = false
if (!clickedInsideAccount) showAccountDropdown.value = false
} }
watch( watch(
@@ -333,20 +406,27 @@ watch(
} }
) )
watch(
() => filters.value.account_id,
(accountId) => {
if (!accountId) {
accountKeyword.value = ''
accountResults.value = []
}
}
)
onMounted(async () => { onMounted(async () => {
document.addEventListener('click', onDocumentClick) document.addEventListener('click', onDocumentClick)
try { try {
const [gs, ms, as] = await Promise.all([ const [gs, ms] = await Promise.all([
adminAPI.groups.list(1, 1000), adminAPI.groups.list(1, 1000),
adminAPI.dashboard.getModelStats({ start_date: props.startDate, end_date: props.endDate }), adminAPI.dashboard.getModelStats({ start_date: props.startDate, end_date: props.endDate })
adminAPI.accounts.list(1, 1000)
]) ])
groupOptions.value.push(...gs.items.map((g: any) => ({ value: g.id, label: g.name }))) groupOptions.value.push(...gs.items.map((g: any) => ({ value: g.id, label: g.name })))
accountOptions.value.push(...as.items.map((a: any) => ({ value: a.id, label: a.name })))
const uniqueModels = new Set<string>() const uniqueModels = new Set<string>()
ms.models?.forEach((s: any) => s.model && uniqueModels.add(s.model)) ms.models?.forEach((s: any) => s.model && uniqueModels.add(s.model))
modelOptions.value.push( modelOptions.value.push(

View File

@@ -143,8 +143,8 @@
> >
<div class="whitespace-nowrap rounded-lg border border-gray-700 bg-gray-900 px-3 py-2.5 text-xs text-white shadow-xl dark:border-gray-600 dark:bg-gray-800"> <div class="whitespace-nowrap rounded-lg border border-gray-700 bg-gray-900 px-3 py-2.5 text-xs text-white shadow-xl dark:border-gray-600 dark:bg-gray-800">
<div class="space-y-1.5"> <div class="space-y-1.5">
<div class="mb-2 border-b border-gray-700 pb-1.5"> <div>
<div class="text-xs font-semibold text-gray-300 mb-1">Token {{ t('usage.details') }}</div> <div class="text-xs font-semibold text-gray-300 mb-1">{{ t('usage.tokenDetails') }}</div>
<div v-if="tokenTooltipData && tokenTooltipData.input_tokens > 0" class="flex items-center justify-between gap-4"> <div v-if="tokenTooltipData && tokenTooltipData.input_tokens > 0" class="flex items-center justify-between gap-4">
<span class="text-gray-400">{{ t('admin.usage.inputTokens') }}</span> <span class="text-gray-400">{{ t('admin.usage.inputTokens') }}</span>
<span class="font-medium text-white">{{ tokenTooltipData.input_tokens.toLocaleString() }}</span> <span class="font-medium text-white">{{ tokenTooltipData.input_tokens.toLocaleString() }}</span>
@@ -184,6 +184,27 @@
> >
<div class="whitespace-nowrap rounded-lg border border-gray-700 bg-gray-900 px-3 py-2.5 text-xs text-white shadow-xl dark:border-gray-600 dark:bg-gray-800"> <div class="whitespace-nowrap rounded-lg border border-gray-700 bg-gray-900 px-3 py-2.5 text-xs text-white shadow-xl dark:border-gray-600 dark:bg-gray-800">
<div class="space-y-1.5"> <div class="space-y-1.5">
<!-- Cost Breakdown -->
<div class="mb-2 border-b border-gray-700 pb-1.5">
<div class="text-xs font-semibold text-gray-300 mb-1">{{ t('usage.costDetails') }}</div>
<div v-if="tooltipData && tooltipData.input_cost > 0" class="flex items-center justify-between gap-4">
<span class="text-gray-400">{{ t('admin.usage.inputCost') }}</span>
<span class="font-medium text-white">${{ tooltipData.input_cost.toFixed(6) }}</span>
</div>
<div v-if="tooltipData && tooltipData.output_cost > 0" class="flex items-center justify-between gap-4">
<span class="text-gray-400">{{ t('admin.usage.outputCost') }}</span>
<span class="font-medium text-white">${{ tooltipData.output_cost.toFixed(6) }}</span>
</div>
<div v-if="tooltipData && tooltipData.cache_creation_cost > 0" class="flex items-center justify-between gap-4">
<span class="text-gray-400">{{ t('admin.usage.cacheCreationCost') }}</span>
<span class="font-medium text-white">${{ tooltipData.cache_creation_cost.toFixed(6) }}</span>
</div>
<div v-if="tooltipData && tooltipData.cache_read_cost > 0" class="flex items-center justify-between gap-4">
<span class="text-gray-400">{{ t('admin.usage.cacheReadCost') }}</span>
<span class="font-medium text-white">${{ tooltipData.cache_read_cost.toFixed(6) }}</span>
</div>
</div>
<!-- Rate and Summary -->
<div class="flex items-center justify-between gap-6"> <div class="flex items-center justify-between gap-6">
<span class="text-gray-400">{{ t('usage.rate') }}</span> <span class="text-gray-400">{{ t('usage.rate') }}</span>
<span class="font-semibold text-blue-400">{{ (tooltipData?.rate_multiplier || 1).toFixed(2) }}x</span> <span class="font-semibold text-blue-400">{{ (tooltipData?.rate_multiplier || 1).toFixed(2) }}x</span>

View File

@@ -105,10 +105,7 @@
</button> </button>
</div> </div>
<!-- Code Content --> <!-- Code Content -->
<pre class="p-4 text-sm font-mono text-gray-100 overflow-x-auto"> <pre class="p-4 text-sm font-mono text-gray-100 overflow-x-auto"><code v-if="file.highlighted" v-html="file.highlighted"></code><code v-else v-text="file.content"></code></pre>
<code v-if="file.highlighted" v-html="file.highlighted"></code>
<code v-else v-text="file.content"></code>
</pre>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -40,7 +40,7 @@
</div> </div>
<div class="min-w-0 flex-1"> <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-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> </div>
<Icon <Icon
name="chevronRight" name="chevronRight"

View File

@@ -376,6 +376,8 @@ export default {
usage: { usage: {
title: 'Usage Records', title: 'Usage Records',
description: 'View and analyze your API usage history', description: 'View and analyze your API usage history',
costDetails: 'Cost Breakdown',
tokenDetails: 'Token Breakdown',
totalRequests: 'Total Requests', totalRequests: 'Total Requests',
totalTokens: 'Total Tokens', totalTokens: 'Total Tokens',
totalCost: 'Total Cost', totalCost: 'Total Cost',
@@ -1009,6 +1011,7 @@ export default {
groups: 'Groups', groups: 'Groups',
usageWindows: 'Usage Windows', usageWindows: 'Usage Windows',
lastUsed: 'Last Used', lastUsed: 'Last Used',
expiresAt: 'Expires At',
actions: 'Actions' actions: 'Actions'
}, },
tempUnschedulable: { tempUnschedulable: {
@@ -1150,12 +1153,17 @@ export default {
interceptWarmupRequests: 'Intercept Warmup Requests', interceptWarmupRequests: 'Intercept Warmup Requests',
interceptWarmupRequestsDesc: interceptWarmupRequestsDesc:
'When enabled, warmup requests like title generation will return mock responses without consuming upstream tokens', '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', proxy: 'Proxy',
noProxy: 'No Proxy', noProxy: 'No Proxy',
concurrency: 'Concurrency', concurrency: 'Concurrency',
priority: 'Priority', priority: 'Priority',
priorityHint: 'Higher priority accounts are used first', priorityHint: 'Lower value accounts are used first',
higherPriorityFirst: 'Higher value means higher priority', expiresAt: 'Expires At',
expiresAtHint: 'Leave empty for no expiration',
higherPriorityFirst: 'Lower value means higher priority',
mixedScheduling: 'Use in /v1/messages', mixedScheduling: 'Use in /v1/messages',
mixedSchedulingHint: 'Enable to participate in Anthropic/Gemini group scheduling', mixedSchedulingHint: 'Enable to participate in Anthropic/Gemini group scheduling',
mixedSchedulingTooltip: mixedSchedulingTooltip:
@@ -1691,6 +1699,7 @@ export default {
userFilter: 'User', userFilter: 'User',
searchUserPlaceholder: 'Search user by email...', searchUserPlaceholder: 'Search user by email...',
searchApiKeyPlaceholder: 'Search API key by name...', searchApiKeyPlaceholder: 'Search API key by name...',
searchAccountPlaceholder: 'Search account by name...',
selectedUser: 'Selected', selectedUser: 'Selected',
user: 'User', user: 'User',
account: 'Account', account: 'Account',
@@ -1984,7 +1993,7 @@ export default {
}, },
accountPriority: { accountPriority: {
title: '⚖️ 4. Priority (Optional)', 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' nextBtn: 'Next'
}, },
accountGroups: { accountGroups: {

View File

@@ -373,6 +373,8 @@ export default {
usage: { usage: {
title: '使用记录', title: '使用记录',
description: '查看和分析您的 API 使用历史', description: '查看和分析您的 API 使用历史',
costDetails: '成本明细',
tokenDetails: 'Token 明细',
totalRequests: '总请求数', totalRequests: '总请求数',
totalTokens: '总 Token', totalTokens: '总 Token',
totalCost: '总消费', totalCost: '总消费',
@@ -857,7 +859,7 @@ export default {
accountsLabel: '指定账号', accountsLabel: '指定账号',
accountsPlaceholder: '选择账号(留空则不限制)', accountsPlaceholder: '选择账号(留空则不限制)',
priorityLabel: '优先级', priorityLabel: '优先级',
priorityHint: '数值越优先级越高,用于账号调度', priorityHint: '数值越优先级越高,用于账号调度',
statusLabel: '状态' statusLabel: '状态'
}, },
exclusiveObj: { exclusiveObj: {
@@ -1059,6 +1061,7 @@ export default {
groups: '分组', groups: '分组',
usageWindows: '用量窗口', usageWindows: '用量窗口',
lastUsed: '最近使用', lastUsed: '最近使用',
expiresAt: '过期时间',
actions: '操作' actions: '操作'
}, },
clearRateLimit: '清除速率限制', clearRateLimit: '清除速率限制',
@@ -1178,7 +1181,7 @@ export default {
credentialsLabel: '凭证', credentialsLabel: '凭证',
credentialsPlaceholder: '请输入 Cookie 或 API Key', credentialsPlaceholder: '请输入 Cookie 或 API Key',
priorityLabel: '优先级', priorityLabel: '优先级',
priorityHint: '数值越优先级越高', priorityHint: '数值越优先级越高',
weightLabel: '权重', weightLabel: '权重',
weightHint: '用于负载均衡的权重值', weightHint: '用于负载均衡的权重值',
statusLabel: '状态' statusLabel: '状态'
@@ -1284,12 +1287,17 @@ export default {
errorCodeExists: '该错误码已被选中', errorCodeExists: '该错误码已被选中',
interceptWarmupRequests: '拦截预热请求', interceptWarmupRequests: '拦截预热请求',
interceptWarmupRequestsDesc: '启用后,标题生成等预热请求将返回 mock 响应,不消耗上游 token', interceptWarmupRequestsDesc: '启用后,标题生成等预热请求将返回 mock 响应,不消耗上游 token',
autoPauseOnExpired: '过期自动暂停调度',
autoPauseOnExpiredDesc: '启用后,账号过期将自动暂停调度',
expired: '已过期',
proxy: '代理', proxy: '代理',
noProxy: '无代理', noProxy: '无代理',
concurrency: '并发数', concurrency: '并发数',
priority: '优先级', priority: '优先级',
priorityHint: '优先级越的账号优先使用', priorityHint: '优先级越的账号优先使用',
higherPriorityFirst: '数值越高优先级越高', expiresAt: '过期时间',
expiresAtHint: '留空表示不过期',
higherPriorityFirst: '数值越小优先级越高',
mixedScheduling: '在 /v1/messages 中使用', mixedScheduling: '在 /v1/messages 中使用',
mixedSchedulingHint: '启用后可参与 Anthropic/Gemini 分组的调度', mixedSchedulingHint: '启用后可参与 Anthropic/Gemini 分组的调度',
mixedSchedulingTooltip: mixedSchedulingTooltip:
@@ -1836,6 +1844,7 @@ export default {
userFilter: '用户', userFilter: '用户',
searchUserPlaceholder: '按邮箱搜索用户...', searchUserPlaceholder: '按邮箱搜索用户...',
searchApiKeyPlaceholder: '按名称搜索 API 密钥...', searchApiKeyPlaceholder: '按名称搜索 API 密钥...',
searchAccountPlaceholder: '按名称搜索账号...',
selectedUser: '已选择', selectedUser: '已选择',
user: '用户', user: '用户',
account: '账户', account: '账户',
@@ -2126,7 +2135,7 @@ export default {
}, },
accountPriority: { accountPriority: {
title: '⚖️ 4. 优先级(可选)', 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: '下一步' nextBtn: '下一步'
}, },
accountGroups: { accountGroups: {

View File

@@ -401,6 +401,8 @@ export interface Account {
status: 'active' | 'inactive' | 'error' status: 'active' | 'inactive' | 'error'
error_message: string | null error_message: string | null
last_used_at: string | null last_used_at: string | null
expires_at: number | null
auto_pause_on_expired: boolean
created_at: string created_at: string
updated_at: string updated_at: string
proxy?: Proxy proxy?: Proxy
@@ -491,6 +493,8 @@ export interface CreateAccountRequest {
concurrency?: number concurrency?: number
priority?: number priority?: number
group_ids?: number[] group_ids?: number[]
expires_at?: number | null
auto_pause_on_expired?: boolean
confirm_mixed_channel_risk?: boolean confirm_mixed_channel_risk?: boolean
} }
@@ -506,6 +510,8 @@ export interface UpdateAccountRequest {
schedulable?: boolean schedulable?: boolean
status?: 'active' | 'inactive' status?: 'active' | 'inactive'
group_ids?: number[] group_ids?: number[]
expires_at?: number | null
auto_pause_on_expired?: boolean
confirm_mixed_channel_risk?: 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 date 日期字符串或 Date 对象
* @param options Intl.DateTimeFormatOptions * @param options Intl.DateTimeFormatOptions
* @param localeOverride 可选 locale 覆盖
* @returns 格式化后的日期字符串 * @returns 格式化后的日期字符串
*/ */
export function formatDate( export function formatDate(
@@ -108,14 +109,15 @@ export function formatDate(
minute: '2-digit', minute: '2-digit',
second: '2-digit', second: '2-digit',
hour12: false hour12: false
} },
localeOverride?: string
): string { ): string {
if (!date) return '' if (!date) return ''
const d = new Date(date) const d = new Date(date)
if (isNaN(d.getTime())) return '' if (isNaN(d.getTime())) return ''
const locale = getLocale() const locale = localeOverride ?? getLocale()
return new Intl.DateTimeFormat(locale, options).format(d) 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 date 日期字符串或 Date 对象
* @param options Intl.DateTimeFormatOptions
* @param localeOverride 可选 locale 覆盖
* @returns 格式化后的日期时间字符串 * @returns 格式化后的日期时间字符串
*/ */
export function formatDateTime(date: string | Date | null | undefined): string { export function formatDateTime(
return formatDate(date) 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 }"> <template #cell-last_used_at="{ value }">
<span class="text-sm text-gray-500 dark:text-dark-400">{{ formatRelativeTime(value) }}</span> <span class="text-sm text-gray-500 dark:text-dark-400">{{ formatRelativeTime(value) }}</span>
</template> </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 }"> <template #cell-actions="{ row }">
<div class="flex items-center gap-1"> <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"> <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 AccountTodayStatsCell from '@/components/account/AccountTodayStatsCell.vue'
import GroupBadge from '@/components/common/GroupBadge.vue' import GroupBadge from '@/components/common/GroupBadge.vue'
import PlatformTypeBadge from '@/components/common/PlatformTypeBadge.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' import type { Account, Proxy, Group } from '@/types'
const { t } = useI18n() const { t } = useI18n()
@@ -178,6 +197,7 @@ const cols = computed(() => {
{ key: 'usage', label: t('admin.accounts.columns.usageWindows'), sortable: false }, { key: 'usage', label: t('admin.accounts.columns.usageWindows'), sortable: false },
{ key: 'priority', label: t('admin.accounts.columns.priority'), sortable: true }, { key: 'priority', label: t('admin.accounts.columns.priority'), sortable: true },
{ key: 'last_used_at', label: t('admin.accounts.columns.lastUsed'), 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: 'notes', label: t('admin.accounts.columns.notes'), sortable: false },
{ key: 'actions', label: t('admin.accounts.columns.actions'), 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 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 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 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) } }) 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> </script>

View File

@@ -85,11 +85,48 @@ const exportToExcel = async () => {
if (all.length >= total || res.items.length < 100) break; p++ if (all.length >= total || res.items.length < 100) break; p++
} }
if(!c.signal.aborted) { if(!c.signal.aborted) {
// 动态加载 xlsx降低首屏包体并减少高危依赖的常驻暴露面。
const XLSX = await import('xlsx') const XLSX = await import('xlsx')
const ws = XLSX.utils.json_to_sheet(all); const wb = XLSX.utils.book_new(); XLSX.utils.book_append_sheet(wb, ws, 'Usage') const headers = [
saveAs(new Blob([XLSX.write(wb, { bookType: 'xlsx', type: 'array' })], { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' }), `usage_${Date.now()}.xlsx`) t('usage.time'), t('admin.usage.user'), t('usage.apiKeyFilter'),
appStore.showSuccess('Export Success') t('admin.usage.account'), t('usage.model'), t('admin.usage.group'),
t('usage.type'),
t('admin.usage.inputTokens'), t('admin.usage.outputTokens'),
t('admin.usage.cacheReadTokens'), t('admin.usage.cacheCreationTokens'),
t('admin.usage.inputCost'), t('admin.usage.outputCost'),
t('admin.usage.cacheReadCost'), t('admin.usage.cacheCreationCost'),
t('usage.rate'), t('usage.original'), t('usage.billed'),
t('usage.billingType'), t('usage.firstToken'), t('usage.duration'),
t('admin.usage.requestId')
]
const rows = all.map(log => [
log.created_at,
log.user?.email || '',
log.api_key?.name || '',
log.account?.name || '',
log.model,
log.group?.name || '',
log.stream ? t('usage.stream') : t('usage.sync'),
log.input_tokens,
log.output_tokens,
log.cache_read_tokens,
log.cache_creation_tokens,
log.input_cost?.toFixed(6) || '0.000000',
log.output_cost?.toFixed(6) || '0.000000',
log.cache_read_cost?.toFixed(6) || '0.000000',
log.cache_creation_cost?.toFixed(6) || '0.000000',
log.rate_multiplier?.toFixed(2) || '1.00',
log.total_cost?.toFixed(6) || '0.000000',
log.actual_cost?.toFixed(6) || '0.000000',
log.billing_type === 1 ? t('usage.subscription') : t('usage.balance'),
log.first_token_ms ?? '',
log.duration_ms,
log.request_id || ''
])
const ws = XLSX.utils.aoa_to_sheet([headers, ...rows])
const wb = XLSX.utils.book_new()
XLSX.utils.book_append_sheet(wb, ws, 'Usage')
saveAs(new Blob([XLSX.write(wb, { bookType: 'xlsx', type: 'array' })], { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' }), `usage_${filters.value.start_date}_to_${filters.value.end_date}.xlsx`)
appStore.showSuccess(t('usage.exportSuccess'))
} }
} catch (error) { console.error('Failed to export:', error); appStore.showError('Export Failed') } } catch (error) { console.error('Failed to export:', error); appStore.showError('Export Failed') }
finally { if(exportAbortController === c) { exportAbortController = null; exporting.value = false; exportProgress.show = false } } finally { if(exportAbortController === c) { exportAbortController = null; exporting.value = false; exportProgress.show = false } }

View File

@@ -342,8 +342,8 @@
> >
<div class="space-y-1.5"> <div class="space-y-1.5">
<!-- Token Breakdown --> <!-- Token Breakdown -->
<div class="mb-2 border-b border-gray-700 pb-1.5"> <div>
<div class="text-xs font-semibold text-gray-300 mb-1">Token 明细</div> <div class="text-xs font-semibold text-gray-300 mb-1">{{ t('usage.tokenDetails') }}</div>
<div v-if="tokenTooltipData && tokenTooltipData.input_tokens > 0" class="flex items-center justify-between gap-4"> <div v-if="tokenTooltipData && tokenTooltipData.input_tokens > 0" class="flex items-center justify-between gap-4">
<span class="text-gray-400">{{ t('admin.usage.inputTokens') }}</span> <span class="text-gray-400">{{ t('admin.usage.inputTokens') }}</span>
<span class="font-medium text-white">{{ tokenTooltipData.input_tokens.toLocaleString() }}</span> <span class="font-medium text-white">{{ tokenTooltipData.input_tokens.toLocaleString() }}</span>
@@ -389,6 +389,27 @@
class="whitespace-nowrap rounded-lg border border-gray-700 bg-gray-900 px-3 py-2.5 text-xs text-white shadow-xl dark:border-gray-600 dark:bg-gray-800" class="whitespace-nowrap rounded-lg border border-gray-700 bg-gray-900 px-3 py-2.5 text-xs text-white shadow-xl dark:border-gray-600 dark:bg-gray-800"
> >
<div class="space-y-1.5"> <div class="space-y-1.5">
<!-- Cost Breakdown -->
<div class="mb-2 border-b border-gray-700 pb-1.5">
<div class="text-xs font-semibold text-gray-300 mb-1">{{ t('usage.costDetails') }}</div>
<div v-if="tooltipData && tooltipData.input_cost > 0" class="flex items-center justify-between gap-4">
<span class="text-gray-400">{{ t('admin.usage.inputCost') }}</span>
<span class="font-medium text-white">${{ tooltipData.input_cost.toFixed(6) }}</span>
</div>
<div v-if="tooltipData && tooltipData.output_cost > 0" class="flex items-center justify-between gap-4">
<span class="text-gray-400">{{ t('admin.usage.outputCost') }}</span>
<span class="font-medium text-white">${{ tooltipData.output_cost.toFixed(6) }}</span>
</div>
<div v-if="tooltipData && tooltipData.cache_creation_cost > 0" class="flex items-center justify-between gap-4">
<span class="text-gray-400">{{ t('admin.usage.cacheCreationCost') }}</span>
<span class="font-medium text-white">${{ tooltipData.cache_creation_cost.toFixed(6) }}</span>
</div>
<div v-if="tooltipData && tooltipData.cache_read_cost > 0" class="flex items-center justify-between gap-4">
<span class="text-gray-400">{{ t('admin.usage.cacheReadCost') }}</span>
<span class="font-medium text-white">${{ tooltipData.cache_read_cost.toFixed(6) }}</span>
</div>
</div>
<!-- Rate and Summary -->
<div class="flex items-center justify-between gap-6"> <div class="flex items-center justify-between gap-6">
<span class="text-gray-400">{{ t('usage.rate') }}</span> <span class="text-gray-400">{{ t('usage.rate') }}</span>
<span class="font-semibold text-blue-400" <span class="font-semibold text-blue-400"

View File

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