mirror of
https://gitee.com/wanwujie/sub2api
synced 2026-04-04 15:32:13 +08:00
Compare commits
16 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b015a3bd8a | ||
|
|
3fb43b91bf | ||
|
|
6e8188ed64 | ||
|
|
db6f53e2c9 | ||
|
|
acabdc2f99 | ||
|
|
169aa4716e | ||
|
|
c0753320a0 | ||
|
|
38d875b06f | ||
|
|
1ada6cf768 | ||
|
|
2b528c5f81 | ||
|
|
f6dd4752e7 | ||
|
|
b19c7875a4 | ||
|
|
d99a3ef14b | ||
|
|
fc8fa83fcc | ||
|
|
6dcd99468b | ||
|
|
d5ba7b80d3 |
6
Makefile
6
Makefile
@@ -9,7 +9,7 @@ build-backend:
|
||||
|
||||
# 编译前端(需要已安装依赖)
|
||||
build-frontend:
|
||||
@npm --prefix frontend run build
|
||||
@pnpm --dir frontend run build
|
||||
|
||||
# 运行测试(后端 + 前端)
|
||||
test: test-backend test-frontend
|
||||
@@ -18,5 +18,5 @@ test-backend:
|
||||
@$(MAKE) -C backend test
|
||||
|
||||
test-frontend:
|
||||
@npm --prefix frontend run lint:check
|
||||
@npm --prefix frontend run typecheck
|
||||
@pnpm --dir frontend run lint:check
|
||||
@pnpm --dir frontend run typecheck
|
||||
|
||||
@@ -44,7 +44,7 @@ Sub2API is an AI API gateway platform designed to distribute and manage API quot
|
||||
|
||||
| Component | Technology |
|
||||
|-----------|------------|
|
||||
| Backend | Go 1.25.5, Gin, GORM |
|
||||
| Backend | Go 1.25.5, Gin, Ent |
|
||||
| Frontend | Vue 3.4+, Vite 5+, TailwindCSS |
|
||||
| Database | PostgreSQL 15+ |
|
||||
| Cache/Queue | Redis 7+ |
|
||||
|
||||
@@ -44,7 +44,7 @@ Sub2API 是一个 AI API 网关平台,用于分发和管理 AI 产品订阅(
|
||||
|
||||
| 组件 | 技术 |
|
||||
|------|------|
|
||||
| 后端 | Go 1.25.5, Gin, GORM |
|
||||
| 后端 | Go 1.25.5, Gin, Ent |
|
||||
| 前端 | Vue 3.4+, Vite 5+, TailwindCSS |
|
||||
| 数据库 | PostgreSQL 15+ |
|
||||
| 缓存/队列 | Redis 7+ |
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
FROM golang:1.21-alpine
|
||||
FROM golang:1.25.5-alpine
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
|
||||
@@ -63,6 +63,7 @@ func provideCleanup(
|
||||
entClient *ent.Client,
|
||||
rdb *redis.Client,
|
||||
tokenRefresh *service.TokenRefreshService,
|
||||
accountExpiry *service.AccountExpiryService,
|
||||
pricing *service.PricingService,
|
||||
emailQueue *service.EmailQueueService,
|
||||
billingCache *service.BillingCacheService,
|
||||
@@ -84,6 +85,10 @@ func provideCleanup(
|
||||
tokenRefresh.Stop()
|
||||
return nil
|
||||
}},
|
||||
{"AccountExpiryService", func() error {
|
||||
accountExpiry.Stop()
|
||||
return nil
|
||||
}},
|
||||
{"PricingService", func() error {
|
||||
pricing.Stop()
|
||||
return nil
|
||||
|
||||
@@ -87,6 +87,7 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
|
||||
geminiOAuthClient := repository.NewGeminiOAuthClient(configConfig)
|
||||
geminiCliCodeAssistClient := repository.NewGeminiCliCodeAssistClient()
|
||||
geminiOAuthService := service.NewGeminiOAuthService(proxyRepository, geminiOAuthClient, geminiCliCodeAssistClient, configConfig)
|
||||
antigravityOAuthService := service.NewAntigravityOAuthService(proxyRepository)
|
||||
geminiQuotaService := service.NewGeminiQuotaService(configConfig, settingRepository)
|
||||
tempUnschedCache := repository.NewTempUnschedCache(redisClient)
|
||||
rateLimitService := service.NewRateLimitService(accountRepository, usageLogRepository, configConfig, geminiQuotaService, tempUnschedCache)
|
||||
@@ -97,13 +98,12 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
|
||||
geminiTokenCache := repository.NewGeminiTokenCache(redisClient)
|
||||
geminiTokenProvider := service.NewGeminiTokenProvider(accountRepository, geminiTokenCache, geminiOAuthService)
|
||||
gatewayCache := repository.NewGatewayCache(redisClient)
|
||||
antigravityOAuthService := service.NewAntigravityOAuthService(proxyRepository)
|
||||
antigravityTokenProvider := service.NewAntigravityTokenProvider(accountRepository, geminiTokenCache, antigravityOAuthService)
|
||||
httpUpstream := repository.NewHTTPUpstream(configConfig)
|
||||
antigravityGatewayService := service.NewAntigravityGatewayService(accountRepository, gatewayCache, antigravityTokenProvider, rateLimitService, httpUpstream, settingService)
|
||||
accountTestService := service.NewAccountTestService(accountRepository, geminiTokenProvider, antigravityGatewayService, httpUpstream, configConfig)
|
||||
concurrencyCache := repository.ProvideConcurrencyCache(redisClient, configConfig)
|
||||
concurrencyService := service.NewConcurrencyService(concurrencyCache)
|
||||
concurrencyService := service.ProvideConcurrencyService(concurrencyCache, accountRepository, configConfig)
|
||||
crsSyncService := service.NewCRSSyncService(accountRepository, proxyRepository, oAuthService, openAIOAuthService, geminiOAuthService, configConfig)
|
||||
accountHandler := admin.NewAccountHandler(adminService, oAuthService, openAIOAuthService, geminiOAuthService, antigravityOAuthService, rateLimitService, accountUsageService, accountTestService, concurrencyService, crsSyncService)
|
||||
oAuthHandler := admin.NewOAuthHandler(oAuthService)
|
||||
@@ -114,7 +114,7 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
|
||||
adminRedeemHandler := admin.NewRedeemHandler(adminService)
|
||||
settingHandler := admin.NewSettingHandler(settingService, emailService, turnstileService)
|
||||
updateCache := repository.NewUpdateCache(redisClient)
|
||||
gitHubReleaseClient := repository.NewGitHubReleaseClient()
|
||||
gitHubReleaseClient := repository.ProvideGitHubReleaseClient(configConfig)
|
||||
serviceBuildInfo := provideServiceBuildInfo(buildInfo)
|
||||
updateService := service.ProvideUpdateService(updateCache, gitHubReleaseClient, serviceBuildInfo)
|
||||
systemHandler := handler.ProvideSystemHandler(updateService)
|
||||
@@ -125,7 +125,7 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
|
||||
userAttributeService := service.NewUserAttributeService(userAttributeDefinitionRepository, userAttributeValueRepository)
|
||||
userAttributeHandler := admin.NewUserAttributeHandler(userAttributeService)
|
||||
adminHandlers := handler.ProvideAdminHandlers(dashboardHandler, adminUserHandler, groupHandler, accountHandler, oAuthHandler, openAIOAuthHandler, geminiOAuthHandler, antigravityOAuthHandler, proxyHandler, adminRedeemHandler, settingHandler, systemHandler, adminSubscriptionHandler, adminUsageHandler, userAttributeHandler)
|
||||
pricingRemoteClient := repository.NewPricingRemoteClient(configConfig)
|
||||
pricingRemoteClient := repository.ProvidePricingRemoteClient(configConfig)
|
||||
pricingService, err := service.ProvidePricingService(configConfig, pricingRemoteClient)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -148,7 +148,8 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
|
||||
engine := server.ProvideRouter(configConfig, handlers, jwtAuthMiddleware, adminAuthMiddleware, apiKeyAuthMiddleware, apiKeyService, subscriptionService)
|
||||
httpServer := server.ProvideHTTPServer(configConfig, engine)
|
||||
tokenRefreshService := service.ProvideTokenRefreshService(accountRepository, oAuthService, openAIOAuthService, geminiOAuthService, antigravityOAuthService, configConfig)
|
||||
v := provideCleanup(client, redisClient, tokenRefreshService, pricingService, emailQueueService, billingCacheService, oAuthService, openAIOAuthService, geminiOAuthService, antigravityOAuthService)
|
||||
accountExpiryService := service.ProvideAccountExpiryService(accountRepository)
|
||||
v := provideCleanup(client, redisClient, tokenRefreshService, accountExpiryService, pricingService, emailQueueService, billingCacheService, oAuthService, openAIOAuthService, geminiOAuthService, antigravityOAuthService)
|
||||
application := &Application{
|
||||
Server: httpServer,
|
||||
Cleanup: v,
|
||||
@@ -174,6 +175,7 @@ func provideCleanup(
|
||||
entClient *ent.Client,
|
||||
rdb *redis.Client,
|
||||
tokenRefresh *service.TokenRefreshService,
|
||||
accountExpiry *service.AccountExpiryService,
|
||||
pricing *service.PricingService,
|
||||
emailQueue *service.EmailQueueService,
|
||||
billingCache *service.BillingCacheService,
|
||||
@@ -194,6 +196,10 @@ func provideCleanup(
|
||||
tokenRefresh.Stop()
|
||||
return nil
|
||||
}},
|
||||
{"AccountExpiryService", func() error {
|
||||
accountExpiry.Stop()
|
||||
return nil
|
||||
}},
|
||||
{"PricingService", func() error {
|
||||
pricing.Stop()
|
||||
return nil
|
||||
|
||||
@@ -49,6 +49,10 @@ type Account struct {
|
||||
ErrorMessage *string `json:"error_message,omitempty"`
|
||||
// LastUsedAt holds the value of the "last_used_at" field.
|
||||
LastUsedAt *time.Time `json:"last_used_at,omitempty"`
|
||||
// Account expiration time (NULL means no expiration).
|
||||
ExpiresAt *time.Time `json:"expires_at,omitempty"`
|
||||
// Auto pause scheduling when account expires.
|
||||
AutoPauseOnExpired bool `json:"auto_pause_on_expired,omitempty"`
|
||||
// Schedulable holds the value of the "schedulable" field.
|
||||
Schedulable bool `json:"schedulable,omitempty"`
|
||||
// RateLimitedAt holds the value of the "rate_limited_at" field.
|
||||
@@ -129,13 +133,13 @@ func (*Account) scanValues(columns []string) ([]any, error) {
|
||||
switch columns[i] {
|
||||
case account.FieldCredentials, account.FieldExtra:
|
||||
values[i] = new([]byte)
|
||||
case account.FieldSchedulable:
|
||||
case account.FieldAutoPauseOnExpired, account.FieldSchedulable:
|
||||
values[i] = new(sql.NullBool)
|
||||
case account.FieldID, account.FieldProxyID, account.FieldConcurrency, account.FieldPriority:
|
||||
values[i] = new(sql.NullInt64)
|
||||
case account.FieldName, account.FieldNotes, account.FieldPlatform, account.FieldType, account.FieldStatus, account.FieldErrorMessage, account.FieldSessionWindowStatus:
|
||||
values[i] = new(sql.NullString)
|
||||
case account.FieldCreatedAt, account.FieldUpdatedAt, account.FieldDeletedAt, account.FieldLastUsedAt, account.FieldRateLimitedAt, account.FieldRateLimitResetAt, account.FieldOverloadUntil, account.FieldSessionWindowStart, account.FieldSessionWindowEnd:
|
||||
case account.FieldCreatedAt, account.FieldUpdatedAt, account.FieldDeletedAt, account.FieldLastUsedAt, account.FieldExpiresAt, account.FieldRateLimitedAt, account.FieldRateLimitResetAt, account.FieldOverloadUntil, account.FieldSessionWindowStart, account.FieldSessionWindowEnd:
|
||||
values[i] = new(sql.NullTime)
|
||||
default:
|
||||
values[i] = new(sql.UnknownType)
|
||||
@@ -257,6 +261,19 @@ func (_m *Account) assignValues(columns []string, values []any) error {
|
||||
_m.LastUsedAt = new(time.Time)
|
||||
*_m.LastUsedAt = value.Time
|
||||
}
|
||||
case account.FieldExpiresAt:
|
||||
if value, ok := values[i].(*sql.NullTime); !ok {
|
||||
return fmt.Errorf("unexpected type %T for field expires_at", values[i])
|
||||
} else if value.Valid {
|
||||
_m.ExpiresAt = new(time.Time)
|
||||
*_m.ExpiresAt = value.Time
|
||||
}
|
||||
case account.FieldAutoPauseOnExpired:
|
||||
if value, ok := values[i].(*sql.NullBool); !ok {
|
||||
return fmt.Errorf("unexpected type %T for field auto_pause_on_expired", values[i])
|
||||
} else if value.Valid {
|
||||
_m.AutoPauseOnExpired = value.Bool
|
||||
}
|
||||
case account.FieldSchedulable:
|
||||
if value, ok := values[i].(*sql.NullBool); !ok {
|
||||
return fmt.Errorf("unexpected type %T for field schedulable", values[i])
|
||||
@@ -416,6 +433,14 @@ func (_m *Account) String() string {
|
||||
builder.WriteString(v.Format(time.ANSIC))
|
||||
}
|
||||
builder.WriteString(", ")
|
||||
if v := _m.ExpiresAt; v != nil {
|
||||
builder.WriteString("expires_at=")
|
||||
builder.WriteString(v.Format(time.ANSIC))
|
||||
}
|
||||
builder.WriteString(", ")
|
||||
builder.WriteString("auto_pause_on_expired=")
|
||||
builder.WriteString(fmt.Sprintf("%v", _m.AutoPauseOnExpired))
|
||||
builder.WriteString(", ")
|
||||
builder.WriteString("schedulable=")
|
||||
builder.WriteString(fmt.Sprintf("%v", _m.Schedulable))
|
||||
builder.WriteString(", ")
|
||||
|
||||
@@ -45,6 +45,10 @@ const (
|
||||
FieldErrorMessage = "error_message"
|
||||
// FieldLastUsedAt holds the string denoting the last_used_at field in the database.
|
||||
FieldLastUsedAt = "last_used_at"
|
||||
// FieldExpiresAt holds the string denoting the expires_at field in the database.
|
||||
FieldExpiresAt = "expires_at"
|
||||
// FieldAutoPauseOnExpired holds the string denoting the auto_pause_on_expired field in the database.
|
||||
FieldAutoPauseOnExpired = "auto_pause_on_expired"
|
||||
// FieldSchedulable holds the string denoting the schedulable field in the database.
|
||||
FieldSchedulable = "schedulable"
|
||||
// FieldRateLimitedAt holds the string denoting the rate_limited_at field in the database.
|
||||
@@ -115,6 +119,8 @@ var Columns = []string{
|
||||
FieldStatus,
|
||||
FieldErrorMessage,
|
||||
FieldLastUsedAt,
|
||||
FieldExpiresAt,
|
||||
FieldAutoPauseOnExpired,
|
||||
FieldSchedulable,
|
||||
FieldRateLimitedAt,
|
||||
FieldRateLimitResetAt,
|
||||
@@ -172,6 +178,8 @@ var (
|
||||
DefaultStatus string
|
||||
// StatusValidator is a validator for the "status" field. It is called by the builders before save.
|
||||
StatusValidator func(string) error
|
||||
// DefaultAutoPauseOnExpired holds the default value on creation for the "auto_pause_on_expired" field.
|
||||
DefaultAutoPauseOnExpired bool
|
||||
// DefaultSchedulable holds the default value on creation for the "schedulable" field.
|
||||
DefaultSchedulable bool
|
||||
// SessionWindowStatusValidator is a validator for the "session_window_status" field. It is called by the builders before save.
|
||||
@@ -251,6 +259,16 @@ func ByLastUsedAt(opts ...sql.OrderTermOption) OrderOption {
|
||||
return sql.OrderByField(FieldLastUsedAt, opts...).ToFunc()
|
||||
}
|
||||
|
||||
// ByExpiresAt orders the results by the expires_at field.
|
||||
func ByExpiresAt(opts ...sql.OrderTermOption) OrderOption {
|
||||
return sql.OrderByField(FieldExpiresAt, opts...).ToFunc()
|
||||
}
|
||||
|
||||
// ByAutoPauseOnExpired orders the results by the auto_pause_on_expired field.
|
||||
func ByAutoPauseOnExpired(opts ...sql.OrderTermOption) OrderOption {
|
||||
return sql.OrderByField(FieldAutoPauseOnExpired, opts...).ToFunc()
|
||||
}
|
||||
|
||||
// BySchedulable orders the results by the schedulable field.
|
||||
func BySchedulable(opts ...sql.OrderTermOption) OrderOption {
|
||||
return sql.OrderByField(FieldSchedulable, opts...).ToFunc()
|
||||
|
||||
@@ -120,6 +120,16 @@ func LastUsedAt(v time.Time) predicate.Account {
|
||||
return predicate.Account(sql.FieldEQ(FieldLastUsedAt, v))
|
||||
}
|
||||
|
||||
// ExpiresAt applies equality check predicate on the "expires_at" field. It's identical to ExpiresAtEQ.
|
||||
func ExpiresAt(v time.Time) predicate.Account {
|
||||
return predicate.Account(sql.FieldEQ(FieldExpiresAt, v))
|
||||
}
|
||||
|
||||
// AutoPauseOnExpired applies equality check predicate on the "auto_pause_on_expired" field. It's identical to AutoPauseOnExpiredEQ.
|
||||
func AutoPauseOnExpired(v bool) predicate.Account {
|
||||
return predicate.Account(sql.FieldEQ(FieldAutoPauseOnExpired, v))
|
||||
}
|
||||
|
||||
// Schedulable applies equality check predicate on the "schedulable" field. It's identical to SchedulableEQ.
|
||||
func Schedulable(v bool) predicate.Account {
|
||||
return predicate.Account(sql.FieldEQ(FieldSchedulable, v))
|
||||
@@ -855,6 +865,66 @@ func LastUsedAtNotNil() predicate.Account {
|
||||
return predicate.Account(sql.FieldNotNull(FieldLastUsedAt))
|
||||
}
|
||||
|
||||
// ExpiresAtEQ applies the EQ predicate on the "expires_at" field.
|
||||
func ExpiresAtEQ(v time.Time) predicate.Account {
|
||||
return predicate.Account(sql.FieldEQ(FieldExpiresAt, v))
|
||||
}
|
||||
|
||||
// ExpiresAtNEQ applies the NEQ predicate on the "expires_at" field.
|
||||
func ExpiresAtNEQ(v time.Time) predicate.Account {
|
||||
return predicate.Account(sql.FieldNEQ(FieldExpiresAt, v))
|
||||
}
|
||||
|
||||
// ExpiresAtIn applies the In predicate on the "expires_at" field.
|
||||
func ExpiresAtIn(vs ...time.Time) predicate.Account {
|
||||
return predicate.Account(sql.FieldIn(FieldExpiresAt, vs...))
|
||||
}
|
||||
|
||||
// ExpiresAtNotIn applies the NotIn predicate on the "expires_at" field.
|
||||
func ExpiresAtNotIn(vs ...time.Time) predicate.Account {
|
||||
return predicate.Account(sql.FieldNotIn(FieldExpiresAt, vs...))
|
||||
}
|
||||
|
||||
// ExpiresAtGT applies the GT predicate on the "expires_at" field.
|
||||
func ExpiresAtGT(v time.Time) predicate.Account {
|
||||
return predicate.Account(sql.FieldGT(FieldExpiresAt, v))
|
||||
}
|
||||
|
||||
// ExpiresAtGTE applies the GTE predicate on the "expires_at" field.
|
||||
func ExpiresAtGTE(v time.Time) predicate.Account {
|
||||
return predicate.Account(sql.FieldGTE(FieldExpiresAt, v))
|
||||
}
|
||||
|
||||
// ExpiresAtLT applies the LT predicate on the "expires_at" field.
|
||||
func ExpiresAtLT(v time.Time) predicate.Account {
|
||||
return predicate.Account(sql.FieldLT(FieldExpiresAt, v))
|
||||
}
|
||||
|
||||
// ExpiresAtLTE applies the LTE predicate on the "expires_at" field.
|
||||
func ExpiresAtLTE(v time.Time) predicate.Account {
|
||||
return predicate.Account(sql.FieldLTE(FieldExpiresAt, v))
|
||||
}
|
||||
|
||||
// ExpiresAtIsNil applies the IsNil predicate on the "expires_at" field.
|
||||
func ExpiresAtIsNil() predicate.Account {
|
||||
return predicate.Account(sql.FieldIsNull(FieldExpiresAt))
|
||||
}
|
||||
|
||||
// ExpiresAtNotNil applies the NotNil predicate on the "expires_at" field.
|
||||
func ExpiresAtNotNil() predicate.Account {
|
||||
return predicate.Account(sql.FieldNotNull(FieldExpiresAt))
|
||||
}
|
||||
|
||||
// AutoPauseOnExpiredEQ applies the EQ predicate on the "auto_pause_on_expired" field.
|
||||
func AutoPauseOnExpiredEQ(v bool) predicate.Account {
|
||||
return predicate.Account(sql.FieldEQ(FieldAutoPauseOnExpired, v))
|
||||
}
|
||||
|
||||
// AutoPauseOnExpiredNEQ applies the NEQ predicate on the "auto_pause_on_expired" field.
|
||||
func AutoPauseOnExpiredNEQ(v bool) predicate.Account {
|
||||
return predicate.Account(sql.FieldNEQ(FieldAutoPauseOnExpired, v))
|
||||
}
|
||||
|
||||
// SchedulableEQ applies the EQ predicate on the "schedulable" field.
|
||||
func SchedulableEQ(v bool) predicate.Account {
|
||||
return predicate.Account(sql.FieldEQ(FieldSchedulable, v))
|
||||
|
||||
@@ -195,6 +195,34 @@ func (_c *AccountCreate) SetNillableLastUsedAt(v *time.Time) *AccountCreate {
|
||||
return _c
|
||||
}
|
||||
|
||||
// SetExpiresAt sets the "expires_at" field.
|
||||
func (_c *AccountCreate) SetExpiresAt(v time.Time) *AccountCreate {
|
||||
_c.mutation.SetExpiresAt(v)
|
||||
return _c
|
||||
}
|
||||
|
||||
// SetNillableExpiresAt sets the "expires_at" field if the given value is not nil.
|
||||
func (_c *AccountCreate) SetNillableExpiresAt(v *time.Time) *AccountCreate {
|
||||
if v != nil {
|
||||
_c.SetExpiresAt(*v)
|
||||
}
|
||||
return _c
|
||||
}
|
||||
|
||||
// SetAutoPauseOnExpired sets the "auto_pause_on_expired" field.
|
||||
func (_c *AccountCreate) SetAutoPauseOnExpired(v bool) *AccountCreate {
|
||||
_c.mutation.SetAutoPauseOnExpired(v)
|
||||
return _c
|
||||
}
|
||||
|
||||
// SetNillableAutoPauseOnExpired sets the "auto_pause_on_expired" field if the given value is not nil.
|
||||
func (_c *AccountCreate) SetNillableAutoPauseOnExpired(v *bool) *AccountCreate {
|
||||
if v != nil {
|
||||
_c.SetAutoPauseOnExpired(*v)
|
||||
}
|
||||
return _c
|
||||
}
|
||||
|
||||
// SetSchedulable sets the "schedulable" field.
|
||||
func (_c *AccountCreate) SetSchedulable(v bool) *AccountCreate {
|
||||
_c.mutation.SetSchedulable(v)
|
||||
@@ -405,6 +433,10 @@ func (_c *AccountCreate) defaults() error {
|
||||
v := account.DefaultStatus
|
||||
_c.mutation.SetStatus(v)
|
||||
}
|
||||
if _, ok := _c.mutation.AutoPauseOnExpired(); !ok {
|
||||
v := account.DefaultAutoPauseOnExpired
|
||||
_c.mutation.SetAutoPauseOnExpired(v)
|
||||
}
|
||||
if _, ok := _c.mutation.Schedulable(); !ok {
|
||||
v := account.DefaultSchedulable
|
||||
_c.mutation.SetSchedulable(v)
|
||||
@@ -464,6 +496,9 @@ func (_c *AccountCreate) check() error {
|
||||
return &ValidationError{Name: "status", err: fmt.Errorf(`ent: validator failed for field "Account.status": %w`, err)}
|
||||
}
|
||||
}
|
||||
if _, ok := _c.mutation.AutoPauseOnExpired(); !ok {
|
||||
return &ValidationError{Name: "auto_pause_on_expired", err: errors.New(`ent: missing required field "Account.auto_pause_on_expired"`)}
|
||||
}
|
||||
if _, ok := _c.mutation.Schedulable(); !ok {
|
||||
return &ValidationError{Name: "schedulable", err: errors.New(`ent: missing required field "Account.schedulable"`)}
|
||||
}
|
||||
@@ -555,6 +590,14 @@ func (_c *AccountCreate) createSpec() (*Account, *sqlgraph.CreateSpec) {
|
||||
_spec.SetField(account.FieldLastUsedAt, field.TypeTime, value)
|
||||
_node.LastUsedAt = &value
|
||||
}
|
||||
if value, ok := _c.mutation.ExpiresAt(); ok {
|
||||
_spec.SetField(account.FieldExpiresAt, field.TypeTime, value)
|
||||
_node.ExpiresAt = &value
|
||||
}
|
||||
if value, ok := _c.mutation.AutoPauseOnExpired(); ok {
|
||||
_spec.SetField(account.FieldAutoPauseOnExpired, field.TypeBool, value)
|
||||
_node.AutoPauseOnExpired = value
|
||||
}
|
||||
if value, ok := _c.mutation.Schedulable(); ok {
|
||||
_spec.SetField(account.FieldSchedulable, field.TypeBool, value)
|
||||
_node.Schedulable = value
|
||||
@@ -898,6 +941,36 @@ func (u *AccountUpsert) ClearLastUsedAt() *AccountUpsert {
|
||||
return u
|
||||
}
|
||||
|
||||
// SetExpiresAt sets the "expires_at" field.
|
||||
func (u *AccountUpsert) SetExpiresAt(v time.Time) *AccountUpsert {
|
||||
u.Set(account.FieldExpiresAt, v)
|
||||
return u
|
||||
}
|
||||
|
||||
// UpdateExpiresAt sets the "expires_at" field to the value that was provided on create.
|
||||
func (u *AccountUpsert) UpdateExpiresAt() *AccountUpsert {
|
||||
u.SetExcluded(account.FieldExpiresAt)
|
||||
return u
|
||||
}
|
||||
|
||||
// ClearExpiresAt clears the value of the "expires_at" field.
|
||||
func (u *AccountUpsert) ClearExpiresAt() *AccountUpsert {
|
||||
u.SetNull(account.FieldExpiresAt)
|
||||
return u
|
||||
}
|
||||
|
||||
// SetAutoPauseOnExpired sets the "auto_pause_on_expired" field.
|
||||
func (u *AccountUpsert) SetAutoPauseOnExpired(v bool) *AccountUpsert {
|
||||
u.Set(account.FieldAutoPauseOnExpired, v)
|
||||
return u
|
||||
}
|
||||
|
||||
// UpdateAutoPauseOnExpired sets the "auto_pause_on_expired" field to the value that was provided on create.
|
||||
func (u *AccountUpsert) UpdateAutoPauseOnExpired() *AccountUpsert {
|
||||
u.SetExcluded(account.FieldAutoPauseOnExpired)
|
||||
return u
|
||||
}
|
||||
|
||||
// SetSchedulable sets the "schedulable" field.
|
||||
func (u *AccountUpsert) SetSchedulable(v bool) *AccountUpsert {
|
||||
u.Set(account.FieldSchedulable, v)
|
||||
@@ -1308,6 +1381,41 @@ func (u *AccountUpsertOne) ClearLastUsedAt() *AccountUpsertOne {
|
||||
})
|
||||
}
|
||||
|
||||
// SetExpiresAt sets the "expires_at" field.
|
||||
func (u *AccountUpsertOne) SetExpiresAt(v time.Time) *AccountUpsertOne {
|
||||
return u.Update(func(s *AccountUpsert) {
|
||||
s.SetExpiresAt(v)
|
||||
})
|
||||
}
|
||||
|
||||
// UpdateExpiresAt sets the "expires_at" field to the value that was provided on create.
|
||||
func (u *AccountUpsertOne) UpdateExpiresAt() *AccountUpsertOne {
|
||||
return u.Update(func(s *AccountUpsert) {
|
||||
s.UpdateExpiresAt()
|
||||
})
|
||||
}
|
||||
|
||||
// ClearExpiresAt clears the value of the "expires_at" field.
|
||||
func (u *AccountUpsertOne) ClearExpiresAt() *AccountUpsertOne {
|
||||
return u.Update(func(s *AccountUpsert) {
|
||||
s.ClearExpiresAt()
|
||||
})
|
||||
}
|
||||
|
||||
// SetAutoPauseOnExpired sets the "auto_pause_on_expired" field.
|
||||
func (u *AccountUpsertOne) SetAutoPauseOnExpired(v bool) *AccountUpsertOne {
|
||||
return u.Update(func(s *AccountUpsert) {
|
||||
s.SetAutoPauseOnExpired(v)
|
||||
})
|
||||
}
|
||||
|
||||
// UpdateAutoPauseOnExpired sets the "auto_pause_on_expired" field to the value that was provided on create.
|
||||
func (u *AccountUpsertOne) UpdateAutoPauseOnExpired() *AccountUpsertOne {
|
||||
return u.Update(func(s *AccountUpsert) {
|
||||
s.UpdateAutoPauseOnExpired()
|
||||
})
|
||||
}
|
||||
|
||||
// SetSchedulable sets the "schedulable" field.
|
||||
func (u *AccountUpsertOne) SetSchedulable(v bool) *AccountUpsertOne {
|
||||
return u.Update(func(s *AccountUpsert) {
|
||||
@@ -1904,6 +2012,41 @@ func (u *AccountUpsertBulk) ClearLastUsedAt() *AccountUpsertBulk {
|
||||
})
|
||||
}
|
||||
|
||||
// SetExpiresAt sets the "expires_at" field.
|
||||
func (u *AccountUpsertBulk) SetExpiresAt(v time.Time) *AccountUpsertBulk {
|
||||
return u.Update(func(s *AccountUpsert) {
|
||||
s.SetExpiresAt(v)
|
||||
})
|
||||
}
|
||||
|
||||
// UpdateExpiresAt sets the "expires_at" field to the value that was provided on create.
|
||||
func (u *AccountUpsertBulk) UpdateExpiresAt() *AccountUpsertBulk {
|
||||
return u.Update(func(s *AccountUpsert) {
|
||||
s.UpdateExpiresAt()
|
||||
})
|
||||
}
|
||||
|
||||
// ClearExpiresAt clears the value of the "expires_at" field.
|
||||
func (u *AccountUpsertBulk) ClearExpiresAt() *AccountUpsertBulk {
|
||||
return u.Update(func(s *AccountUpsert) {
|
||||
s.ClearExpiresAt()
|
||||
})
|
||||
}
|
||||
|
||||
// SetAutoPauseOnExpired sets the "auto_pause_on_expired" field.
|
||||
func (u *AccountUpsertBulk) SetAutoPauseOnExpired(v bool) *AccountUpsertBulk {
|
||||
return u.Update(func(s *AccountUpsert) {
|
||||
s.SetAutoPauseOnExpired(v)
|
||||
})
|
||||
}
|
||||
|
||||
// UpdateAutoPauseOnExpired sets the "auto_pause_on_expired" field to the value that was provided on create.
|
||||
func (u *AccountUpsertBulk) UpdateAutoPauseOnExpired() *AccountUpsertBulk {
|
||||
return u.Update(func(s *AccountUpsert) {
|
||||
s.UpdateAutoPauseOnExpired()
|
||||
})
|
||||
}
|
||||
|
||||
// SetSchedulable sets the "schedulable" field.
|
||||
func (u *AccountUpsertBulk) SetSchedulable(v bool) *AccountUpsertBulk {
|
||||
return u.Update(func(s *AccountUpsert) {
|
||||
|
||||
@@ -247,6 +247,40 @@ func (_u *AccountUpdate) ClearLastUsedAt() *AccountUpdate {
|
||||
return _u
|
||||
}
|
||||
|
||||
// SetExpiresAt sets the "expires_at" field.
|
||||
func (_u *AccountUpdate) SetExpiresAt(v time.Time) *AccountUpdate {
|
||||
_u.mutation.SetExpiresAt(v)
|
||||
return _u
|
||||
}
|
||||
|
||||
// SetNillableExpiresAt sets the "expires_at" field if the given value is not nil.
|
||||
func (_u *AccountUpdate) SetNillableExpiresAt(v *time.Time) *AccountUpdate {
|
||||
if v != nil {
|
||||
_u.SetExpiresAt(*v)
|
||||
}
|
||||
return _u
|
||||
}
|
||||
|
||||
// ClearExpiresAt clears the value of the "expires_at" field.
|
||||
func (_u *AccountUpdate) ClearExpiresAt() *AccountUpdate {
|
||||
_u.mutation.ClearExpiresAt()
|
||||
return _u
|
||||
}
|
||||
|
||||
// SetAutoPauseOnExpired sets the "auto_pause_on_expired" field.
|
||||
func (_u *AccountUpdate) SetAutoPauseOnExpired(v bool) *AccountUpdate {
|
||||
_u.mutation.SetAutoPauseOnExpired(v)
|
||||
return _u
|
||||
}
|
||||
|
||||
// SetNillableAutoPauseOnExpired sets the "auto_pause_on_expired" field if the given value is not nil.
|
||||
func (_u *AccountUpdate) SetNillableAutoPauseOnExpired(v *bool) *AccountUpdate {
|
||||
if v != nil {
|
||||
_u.SetAutoPauseOnExpired(*v)
|
||||
}
|
||||
return _u
|
||||
}
|
||||
|
||||
// SetSchedulable sets the "schedulable" field.
|
||||
func (_u *AccountUpdate) SetSchedulable(v bool) *AccountUpdate {
|
||||
_u.mutation.SetSchedulable(v)
|
||||
@@ -610,6 +644,15 @@ func (_u *AccountUpdate) sqlSave(ctx context.Context) (_node int, err error) {
|
||||
if _u.mutation.LastUsedAtCleared() {
|
||||
_spec.ClearField(account.FieldLastUsedAt, field.TypeTime)
|
||||
}
|
||||
if value, ok := _u.mutation.ExpiresAt(); ok {
|
||||
_spec.SetField(account.FieldExpiresAt, field.TypeTime, value)
|
||||
}
|
||||
if _u.mutation.ExpiresAtCleared() {
|
||||
_spec.ClearField(account.FieldExpiresAt, field.TypeTime)
|
||||
}
|
||||
if value, ok := _u.mutation.AutoPauseOnExpired(); ok {
|
||||
_spec.SetField(account.FieldAutoPauseOnExpired, field.TypeBool, value)
|
||||
}
|
||||
if value, ok := _u.mutation.Schedulable(); ok {
|
||||
_spec.SetField(account.FieldSchedulable, field.TypeBool, value)
|
||||
}
|
||||
@@ -1016,6 +1059,40 @@ func (_u *AccountUpdateOne) ClearLastUsedAt() *AccountUpdateOne {
|
||||
return _u
|
||||
}
|
||||
|
||||
// SetExpiresAt sets the "expires_at" field.
|
||||
func (_u *AccountUpdateOne) SetExpiresAt(v time.Time) *AccountUpdateOne {
|
||||
_u.mutation.SetExpiresAt(v)
|
||||
return _u
|
||||
}
|
||||
|
||||
// SetNillableExpiresAt sets the "expires_at" field if the given value is not nil.
|
||||
func (_u *AccountUpdateOne) SetNillableExpiresAt(v *time.Time) *AccountUpdateOne {
|
||||
if v != nil {
|
||||
_u.SetExpiresAt(*v)
|
||||
}
|
||||
return _u
|
||||
}
|
||||
|
||||
// ClearExpiresAt clears the value of the "expires_at" field.
|
||||
func (_u *AccountUpdateOne) ClearExpiresAt() *AccountUpdateOne {
|
||||
_u.mutation.ClearExpiresAt()
|
||||
return _u
|
||||
}
|
||||
|
||||
// SetAutoPauseOnExpired sets the "auto_pause_on_expired" field.
|
||||
func (_u *AccountUpdateOne) SetAutoPauseOnExpired(v bool) *AccountUpdateOne {
|
||||
_u.mutation.SetAutoPauseOnExpired(v)
|
||||
return _u
|
||||
}
|
||||
|
||||
// SetNillableAutoPauseOnExpired sets the "auto_pause_on_expired" field if the given value is not nil.
|
||||
func (_u *AccountUpdateOne) SetNillableAutoPauseOnExpired(v *bool) *AccountUpdateOne {
|
||||
if v != nil {
|
||||
_u.SetAutoPauseOnExpired(*v)
|
||||
}
|
||||
return _u
|
||||
}
|
||||
|
||||
// SetSchedulable sets the "schedulable" field.
|
||||
func (_u *AccountUpdateOne) SetSchedulable(v bool) *AccountUpdateOne {
|
||||
_u.mutation.SetSchedulable(v)
|
||||
@@ -1409,6 +1486,15 @@ func (_u *AccountUpdateOne) sqlSave(ctx context.Context) (_node *Account, err er
|
||||
if _u.mutation.LastUsedAtCleared() {
|
||||
_spec.ClearField(account.FieldLastUsedAt, field.TypeTime)
|
||||
}
|
||||
if value, ok := _u.mutation.ExpiresAt(); ok {
|
||||
_spec.SetField(account.FieldExpiresAt, field.TypeTime, value)
|
||||
}
|
||||
if _u.mutation.ExpiresAtCleared() {
|
||||
_spec.ClearField(account.FieldExpiresAt, field.TypeTime)
|
||||
}
|
||||
if value, ok := _u.mutation.AutoPauseOnExpired(); ok {
|
||||
_spec.SetField(account.FieldAutoPauseOnExpired, field.TypeBool, value)
|
||||
}
|
||||
if value, ok := _u.mutation.Schedulable(); ok {
|
||||
_spec.SetField(account.FieldSchedulable, field.TypeBool, value)
|
||||
}
|
||||
|
||||
@@ -80,6 +80,8 @@ var (
|
||||
{Name: "status", Type: field.TypeString, Size: 20, Default: "active"},
|
||||
{Name: "error_message", Type: field.TypeString, Nullable: true, SchemaType: map[string]string{"postgres": "text"}},
|
||||
{Name: "last_used_at", Type: field.TypeTime, Nullable: true, SchemaType: map[string]string{"postgres": "timestamptz"}},
|
||||
{Name: "expires_at", Type: field.TypeTime, Nullable: true, SchemaType: map[string]string{"postgres": "timestamptz"}},
|
||||
{Name: "auto_pause_on_expired", Type: field.TypeBool, Default: true},
|
||||
{Name: "schedulable", Type: field.TypeBool, Default: true},
|
||||
{Name: "rate_limited_at", Type: field.TypeTime, Nullable: true, SchemaType: map[string]string{"postgres": "timestamptz"}},
|
||||
{Name: "rate_limit_reset_at", Type: field.TypeTime, Nullable: true, SchemaType: map[string]string{"postgres": "timestamptz"}},
|
||||
@@ -97,7 +99,7 @@ var (
|
||||
ForeignKeys: []*schema.ForeignKey{
|
||||
{
|
||||
Symbol: "accounts_proxies_proxy",
|
||||
Columns: []*schema.Column{AccountsColumns[22]},
|
||||
Columns: []*schema.Column{AccountsColumns[24]},
|
||||
RefColumns: []*schema.Column{ProxiesColumns[0]},
|
||||
OnDelete: schema.SetNull,
|
||||
},
|
||||
@@ -121,7 +123,7 @@ var (
|
||||
{
|
||||
Name: "account_proxy_id",
|
||||
Unique: false,
|
||||
Columns: []*schema.Column{AccountsColumns[22]},
|
||||
Columns: []*schema.Column{AccountsColumns[24]},
|
||||
},
|
||||
{
|
||||
Name: "account_priority",
|
||||
@@ -136,22 +138,22 @@ var (
|
||||
{
|
||||
Name: "account_schedulable",
|
||||
Unique: false,
|
||||
Columns: []*schema.Column{AccountsColumns[15]},
|
||||
Columns: []*schema.Column{AccountsColumns[17]},
|
||||
},
|
||||
{
|
||||
Name: "account_rate_limited_at",
|
||||
Unique: false,
|
||||
Columns: []*schema.Column{AccountsColumns[16]},
|
||||
Columns: []*schema.Column{AccountsColumns[18]},
|
||||
},
|
||||
{
|
||||
Name: "account_rate_limit_reset_at",
|
||||
Unique: false,
|
||||
Columns: []*schema.Column{AccountsColumns[17]},
|
||||
Columns: []*schema.Column{AccountsColumns[19]},
|
||||
},
|
||||
{
|
||||
Name: "account_overload_until",
|
||||
Unique: false,
|
||||
Columns: []*schema.Column{AccountsColumns[18]},
|
||||
Columns: []*schema.Column{AccountsColumns[20]},
|
||||
},
|
||||
{
|
||||
Name: "account_deleted_at",
|
||||
@@ -371,6 +373,7 @@ var (
|
||||
{Name: "stream", Type: field.TypeBool, Default: false},
|
||||
{Name: "duration_ms", Type: field.TypeInt, Nullable: true},
|
||||
{Name: "first_token_ms", Type: field.TypeInt, Nullable: true},
|
||||
{Name: "user_agent", Type: field.TypeString, Nullable: true, Size: 512},
|
||||
{Name: "image_count", Type: field.TypeInt, Default: 0},
|
||||
{Name: "image_size", Type: field.TypeString, Nullable: true, Size: 10},
|
||||
{Name: "created_at", Type: field.TypeTime, SchemaType: map[string]string{"postgres": "timestamptz"}},
|
||||
@@ -388,31 +391,31 @@ var (
|
||||
ForeignKeys: []*schema.ForeignKey{
|
||||
{
|
||||
Symbol: "usage_logs_api_keys_usage_logs",
|
||||
Columns: []*schema.Column{UsageLogsColumns[23]},
|
||||
Columns: []*schema.Column{UsageLogsColumns[24]},
|
||||
RefColumns: []*schema.Column{APIKeysColumns[0]},
|
||||
OnDelete: schema.NoAction,
|
||||
},
|
||||
{
|
||||
Symbol: "usage_logs_accounts_usage_logs",
|
||||
Columns: []*schema.Column{UsageLogsColumns[24]},
|
||||
Columns: []*schema.Column{UsageLogsColumns[25]},
|
||||
RefColumns: []*schema.Column{AccountsColumns[0]},
|
||||
OnDelete: schema.NoAction,
|
||||
},
|
||||
{
|
||||
Symbol: "usage_logs_groups_usage_logs",
|
||||
Columns: []*schema.Column{UsageLogsColumns[25]},
|
||||
Columns: []*schema.Column{UsageLogsColumns[26]},
|
||||
RefColumns: []*schema.Column{GroupsColumns[0]},
|
||||
OnDelete: schema.SetNull,
|
||||
},
|
||||
{
|
||||
Symbol: "usage_logs_users_usage_logs",
|
||||
Columns: []*schema.Column{UsageLogsColumns[26]},
|
||||
Columns: []*schema.Column{UsageLogsColumns[27]},
|
||||
RefColumns: []*schema.Column{UsersColumns[0]},
|
||||
OnDelete: schema.NoAction,
|
||||
},
|
||||
{
|
||||
Symbol: "usage_logs_user_subscriptions_usage_logs",
|
||||
Columns: []*schema.Column{UsageLogsColumns[27]},
|
||||
Columns: []*schema.Column{UsageLogsColumns[28]},
|
||||
RefColumns: []*schema.Column{UserSubscriptionsColumns[0]},
|
||||
OnDelete: schema.SetNull,
|
||||
},
|
||||
@@ -421,32 +424,32 @@ var (
|
||||
{
|
||||
Name: "usagelog_user_id",
|
||||
Unique: false,
|
||||
Columns: []*schema.Column{UsageLogsColumns[26]},
|
||||
Columns: []*schema.Column{UsageLogsColumns[27]},
|
||||
},
|
||||
{
|
||||
Name: "usagelog_api_key_id",
|
||||
Unique: false,
|
||||
Columns: []*schema.Column{UsageLogsColumns[23]},
|
||||
Columns: []*schema.Column{UsageLogsColumns[24]},
|
||||
},
|
||||
{
|
||||
Name: "usagelog_account_id",
|
||||
Unique: false,
|
||||
Columns: []*schema.Column{UsageLogsColumns[24]},
|
||||
Columns: []*schema.Column{UsageLogsColumns[25]},
|
||||
},
|
||||
{
|
||||
Name: "usagelog_group_id",
|
||||
Unique: false,
|
||||
Columns: []*schema.Column{UsageLogsColumns[25]},
|
||||
Columns: []*schema.Column{UsageLogsColumns[26]},
|
||||
},
|
||||
{
|
||||
Name: "usagelog_subscription_id",
|
||||
Unique: false,
|
||||
Columns: []*schema.Column{UsageLogsColumns[27]},
|
||||
Columns: []*schema.Column{UsageLogsColumns[28]},
|
||||
},
|
||||
{
|
||||
Name: "usagelog_created_at",
|
||||
Unique: false,
|
||||
Columns: []*schema.Column{UsageLogsColumns[22]},
|
||||
Columns: []*schema.Column{UsageLogsColumns[23]},
|
||||
},
|
||||
{
|
||||
Name: "usagelog_model",
|
||||
@@ -461,12 +464,12 @@ var (
|
||||
{
|
||||
Name: "usagelog_user_id_created_at",
|
||||
Unique: false,
|
||||
Columns: []*schema.Column{UsageLogsColumns[26], UsageLogsColumns[22]},
|
||||
Columns: []*schema.Column{UsageLogsColumns[27], UsageLogsColumns[23]},
|
||||
},
|
||||
{
|
||||
Name: "usagelog_api_key_id_created_at",
|
||||
Unique: false,
|
||||
Columns: []*schema.Column{UsageLogsColumns[23], UsageLogsColumns[22]},
|
||||
Columns: []*schema.Column{UsageLogsColumns[24], UsageLogsColumns[23]},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -1006,6 +1006,8 @@ type AccountMutation struct {
|
||||
status *string
|
||||
error_message *string
|
||||
last_used_at *time.Time
|
||||
expires_at *time.Time
|
||||
auto_pause_on_expired *bool
|
||||
schedulable *bool
|
||||
rate_limited_at *time.Time
|
||||
rate_limit_reset_at *time.Time
|
||||
@@ -1770,6 +1772,91 @@ func (m *AccountMutation) ResetLastUsedAt() {
|
||||
delete(m.clearedFields, account.FieldLastUsedAt)
|
||||
}
|
||||
|
||||
// SetExpiresAt sets the "expires_at" field.
|
||||
func (m *AccountMutation) SetExpiresAt(t time.Time) {
|
||||
m.expires_at = &t
|
||||
}
|
||||
|
||||
// ExpiresAt returns the value of the "expires_at" field in the mutation.
|
||||
func (m *AccountMutation) ExpiresAt() (r time.Time, exists bool) {
|
||||
v := m.expires_at
|
||||
if v == nil {
|
||||
return
|
||||
}
|
||||
return *v, true
|
||||
}
|
||||
|
||||
// OldExpiresAt returns the old "expires_at" field's value of the Account entity.
|
||||
// If the Account object wasn't provided to the builder, the object is fetched from the database.
|
||||
// An error is returned if the mutation operation is not UpdateOne, or the database query fails.
|
||||
func (m *AccountMutation) OldExpiresAt(ctx context.Context) (v *time.Time, err error) {
|
||||
if !m.op.Is(OpUpdateOne) {
|
||||
return v, errors.New("OldExpiresAt is only allowed on UpdateOne operations")
|
||||
}
|
||||
if m.id == nil || m.oldValue == nil {
|
||||
return v, errors.New("OldExpiresAt requires an ID field in the mutation")
|
||||
}
|
||||
oldValue, err := m.oldValue(ctx)
|
||||
if err != nil {
|
||||
return v, fmt.Errorf("querying old value for OldExpiresAt: %w", err)
|
||||
}
|
||||
return oldValue.ExpiresAt, nil
|
||||
}
|
||||
|
||||
// ClearExpiresAt clears the value of the "expires_at" field.
|
||||
func (m *AccountMutation) ClearExpiresAt() {
|
||||
m.expires_at = nil
|
||||
m.clearedFields[account.FieldExpiresAt] = struct{}{}
|
||||
}
|
||||
|
||||
// ExpiresAtCleared returns if the "expires_at" field was cleared in this mutation.
|
||||
func (m *AccountMutation) ExpiresAtCleared() bool {
|
||||
_, ok := m.clearedFields[account.FieldExpiresAt]
|
||||
return ok
|
||||
}
|
||||
|
||||
// ResetExpiresAt resets all changes to the "expires_at" field.
|
||||
func (m *AccountMutation) ResetExpiresAt() {
|
||||
m.expires_at = nil
|
||||
delete(m.clearedFields, account.FieldExpiresAt)
|
||||
}
|
||||
|
||||
// SetAutoPauseOnExpired sets the "auto_pause_on_expired" field.
|
||||
func (m *AccountMutation) SetAutoPauseOnExpired(b bool) {
|
||||
m.auto_pause_on_expired = &b
|
||||
}
|
||||
|
||||
// AutoPauseOnExpired returns the value of the "auto_pause_on_expired" field in the mutation.
|
||||
func (m *AccountMutation) AutoPauseOnExpired() (r bool, exists bool) {
|
||||
v := m.auto_pause_on_expired
|
||||
if v == nil {
|
||||
return
|
||||
}
|
||||
return *v, true
|
||||
}
|
||||
|
||||
// OldAutoPauseOnExpired returns the old "auto_pause_on_expired" field's value of the Account entity.
|
||||
// If the Account object wasn't provided to the builder, the object is fetched from the database.
|
||||
// An error is returned if the mutation operation is not UpdateOne, or the database query fails.
|
||||
func (m *AccountMutation) OldAutoPauseOnExpired(ctx context.Context) (v bool, err error) {
|
||||
if !m.op.Is(OpUpdateOne) {
|
||||
return v, errors.New("OldAutoPauseOnExpired is only allowed on UpdateOne operations")
|
||||
}
|
||||
if m.id == nil || m.oldValue == nil {
|
||||
return v, errors.New("OldAutoPauseOnExpired requires an ID field in the mutation")
|
||||
}
|
||||
oldValue, err := m.oldValue(ctx)
|
||||
if err != nil {
|
||||
return v, fmt.Errorf("querying old value for OldAutoPauseOnExpired: %w", err)
|
||||
}
|
||||
return oldValue.AutoPauseOnExpired, nil
|
||||
}
|
||||
|
||||
// ResetAutoPauseOnExpired resets all changes to the "auto_pause_on_expired" field.
|
||||
func (m *AccountMutation) ResetAutoPauseOnExpired() {
|
||||
m.auto_pause_on_expired = nil
|
||||
}
|
||||
|
||||
// SetSchedulable sets the "schedulable" field.
|
||||
func (m *AccountMutation) SetSchedulable(b bool) {
|
||||
m.schedulable = &b
|
||||
@@ -2269,7 +2356,7 @@ func (m *AccountMutation) Type() string {
|
||||
// order to get all numeric fields that were incremented/decremented, call
|
||||
// AddedFields().
|
||||
func (m *AccountMutation) Fields() []string {
|
||||
fields := make([]string, 0, 22)
|
||||
fields := make([]string, 0, 24)
|
||||
if m.created_at != nil {
|
||||
fields = append(fields, account.FieldCreatedAt)
|
||||
}
|
||||
@@ -2315,6 +2402,12 @@ func (m *AccountMutation) Fields() []string {
|
||||
if m.last_used_at != nil {
|
||||
fields = append(fields, account.FieldLastUsedAt)
|
||||
}
|
||||
if m.expires_at != nil {
|
||||
fields = append(fields, account.FieldExpiresAt)
|
||||
}
|
||||
if m.auto_pause_on_expired != nil {
|
||||
fields = append(fields, account.FieldAutoPauseOnExpired)
|
||||
}
|
||||
if m.schedulable != nil {
|
||||
fields = append(fields, account.FieldSchedulable)
|
||||
}
|
||||
@@ -2374,6 +2467,10 @@ func (m *AccountMutation) Field(name string) (ent.Value, bool) {
|
||||
return m.ErrorMessage()
|
||||
case account.FieldLastUsedAt:
|
||||
return m.LastUsedAt()
|
||||
case account.FieldExpiresAt:
|
||||
return m.ExpiresAt()
|
||||
case account.FieldAutoPauseOnExpired:
|
||||
return m.AutoPauseOnExpired()
|
||||
case account.FieldSchedulable:
|
||||
return m.Schedulable()
|
||||
case account.FieldRateLimitedAt:
|
||||
@@ -2427,6 +2524,10 @@ func (m *AccountMutation) OldField(ctx context.Context, name string) (ent.Value,
|
||||
return m.OldErrorMessage(ctx)
|
||||
case account.FieldLastUsedAt:
|
||||
return m.OldLastUsedAt(ctx)
|
||||
case account.FieldExpiresAt:
|
||||
return m.OldExpiresAt(ctx)
|
||||
case account.FieldAutoPauseOnExpired:
|
||||
return m.OldAutoPauseOnExpired(ctx)
|
||||
case account.FieldSchedulable:
|
||||
return m.OldSchedulable(ctx)
|
||||
case account.FieldRateLimitedAt:
|
||||
@@ -2555,6 +2656,20 @@ func (m *AccountMutation) SetField(name string, value ent.Value) error {
|
||||
}
|
||||
m.SetLastUsedAt(v)
|
||||
return nil
|
||||
case account.FieldExpiresAt:
|
||||
v, ok := value.(time.Time)
|
||||
if !ok {
|
||||
return fmt.Errorf("unexpected type %T for field %s", value, name)
|
||||
}
|
||||
m.SetExpiresAt(v)
|
||||
return nil
|
||||
case account.FieldAutoPauseOnExpired:
|
||||
v, ok := value.(bool)
|
||||
if !ok {
|
||||
return fmt.Errorf("unexpected type %T for field %s", value, name)
|
||||
}
|
||||
m.SetAutoPauseOnExpired(v)
|
||||
return nil
|
||||
case account.FieldSchedulable:
|
||||
v, ok := value.(bool)
|
||||
if !ok {
|
||||
@@ -2676,6 +2791,9 @@ func (m *AccountMutation) ClearedFields() []string {
|
||||
if m.FieldCleared(account.FieldLastUsedAt) {
|
||||
fields = append(fields, account.FieldLastUsedAt)
|
||||
}
|
||||
if m.FieldCleared(account.FieldExpiresAt) {
|
||||
fields = append(fields, account.FieldExpiresAt)
|
||||
}
|
||||
if m.FieldCleared(account.FieldRateLimitedAt) {
|
||||
fields = append(fields, account.FieldRateLimitedAt)
|
||||
}
|
||||
@@ -2723,6 +2841,9 @@ func (m *AccountMutation) ClearField(name string) error {
|
||||
case account.FieldLastUsedAt:
|
||||
m.ClearLastUsedAt()
|
||||
return nil
|
||||
case account.FieldExpiresAt:
|
||||
m.ClearExpiresAt()
|
||||
return nil
|
||||
case account.FieldRateLimitedAt:
|
||||
m.ClearRateLimitedAt()
|
||||
return nil
|
||||
@@ -2794,6 +2915,12 @@ func (m *AccountMutation) ResetField(name string) error {
|
||||
case account.FieldLastUsedAt:
|
||||
m.ResetLastUsedAt()
|
||||
return nil
|
||||
case account.FieldExpiresAt:
|
||||
m.ResetExpiresAt()
|
||||
return nil
|
||||
case account.FieldAutoPauseOnExpired:
|
||||
m.ResetAutoPauseOnExpired()
|
||||
return nil
|
||||
case account.FieldSchedulable:
|
||||
m.ResetSchedulable()
|
||||
return nil
|
||||
@@ -8107,6 +8234,7 @@ type UsageLogMutation struct {
|
||||
addduration_ms *int
|
||||
first_token_ms *int
|
||||
addfirst_token_ms *int
|
||||
user_agent *string
|
||||
image_count *int
|
||||
addimage_count *int
|
||||
image_size *string
|
||||
@@ -9463,6 +9591,55 @@ func (m *UsageLogMutation) ResetFirstTokenMs() {
|
||||
delete(m.clearedFields, usagelog.FieldFirstTokenMs)
|
||||
}
|
||||
|
||||
// SetUserAgent sets the "user_agent" field.
|
||||
func (m *UsageLogMutation) SetUserAgent(s string) {
|
||||
m.user_agent = &s
|
||||
}
|
||||
|
||||
// UserAgent returns the value of the "user_agent" field in the mutation.
|
||||
func (m *UsageLogMutation) UserAgent() (r string, exists bool) {
|
||||
v := m.user_agent
|
||||
if v == nil {
|
||||
return
|
||||
}
|
||||
return *v, true
|
||||
}
|
||||
|
||||
// OldUserAgent returns the old "user_agent" field's value of the UsageLog entity.
|
||||
// If the UsageLog object wasn't provided to the builder, the object is fetched from the database.
|
||||
// An error is returned if the mutation operation is not UpdateOne, or the database query fails.
|
||||
func (m *UsageLogMutation) OldUserAgent(ctx context.Context) (v *string, err error) {
|
||||
if !m.op.Is(OpUpdateOne) {
|
||||
return v, errors.New("OldUserAgent is only allowed on UpdateOne operations")
|
||||
}
|
||||
if m.id == nil || m.oldValue == nil {
|
||||
return v, errors.New("OldUserAgent requires an ID field in the mutation")
|
||||
}
|
||||
oldValue, err := m.oldValue(ctx)
|
||||
if err != nil {
|
||||
return v, fmt.Errorf("querying old value for OldUserAgent: %w", err)
|
||||
}
|
||||
return oldValue.UserAgent, nil
|
||||
}
|
||||
|
||||
// ClearUserAgent clears the value of the "user_agent" field.
|
||||
func (m *UsageLogMutation) ClearUserAgent() {
|
||||
m.user_agent = nil
|
||||
m.clearedFields[usagelog.FieldUserAgent] = struct{}{}
|
||||
}
|
||||
|
||||
// UserAgentCleared returns if the "user_agent" field was cleared in this mutation.
|
||||
func (m *UsageLogMutation) UserAgentCleared() bool {
|
||||
_, ok := m.clearedFields[usagelog.FieldUserAgent]
|
||||
return ok
|
||||
}
|
||||
|
||||
// ResetUserAgent resets all changes to the "user_agent" field.
|
||||
func (m *UsageLogMutation) ResetUserAgent() {
|
||||
m.user_agent = nil
|
||||
delete(m.clearedFields, usagelog.FieldUserAgent)
|
||||
}
|
||||
|
||||
// SetImageCount sets the "image_count" field.
|
||||
func (m *UsageLogMutation) SetImageCount(i int) {
|
||||
m.image_count = &i
|
||||
@@ -9773,7 +9950,7 @@ func (m *UsageLogMutation) Type() string {
|
||||
// order to get all numeric fields that were incremented/decremented, call
|
||||
// AddedFields().
|
||||
func (m *UsageLogMutation) Fields() []string {
|
||||
fields := make([]string, 0, 27)
|
||||
fields := make([]string, 0, 28)
|
||||
if m.user != nil {
|
||||
fields = append(fields, usagelog.FieldUserID)
|
||||
}
|
||||
@@ -9846,6 +10023,9 @@ func (m *UsageLogMutation) Fields() []string {
|
||||
if m.first_token_ms != nil {
|
||||
fields = append(fields, usagelog.FieldFirstTokenMs)
|
||||
}
|
||||
if m.user_agent != nil {
|
||||
fields = append(fields, usagelog.FieldUserAgent)
|
||||
}
|
||||
if m.image_count != nil {
|
||||
fields = append(fields, usagelog.FieldImageCount)
|
||||
}
|
||||
@@ -9911,6 +10091,8 @@ func (m *UsageLogMutation) Field(name string) (ent.Value, bool) {
|
||||
return m.DurationMs()
|
||||
case usagelog.FieldFirstTokenMs:
|
||||
return m.FirstTokenMs()
|
||||
case usagelog.FieldUserAgent:
|
||||
return m.UserAgent()
|
||||
case usagelog.FieldImageCount:
|
||||
return m.ImageCount()
|
||||
case usagelog.FieldImageSize:
|
||||
@@ -9974,6 +10156,8 @@ func (m *UsageLogMutation) OldField(ctx context.Context, name string) (ent.Value
|
||||
return m.OldDurationMs(ctx)
|
||||
case usagelog.FieldFirstTokenMs:
|
||||
return m.OldFirstTokenMs(ctx)
|
||||
case usagelog.FieldUserAgent:
|
||||
return m.OldUserAgent(ctx)
|
||||
case usagelog.FieldImageCount:
|
||||
return m.OldImageCount(ctx)
|
||||
case usagelog.FieldImageSize:
|
||||
@@ -10157,6 +10341,13 @@ func (m *UsageLogMutation) SetField(name string, value ent.Value) error {
|
||||
}
|
||||
m.SetFirstTokenMs(v)
|
||||
return nil
|
||||
case usagelog.FieldUserAgent:
|
||||
v, ok := value.(string)
|
||||
if !ok {
|
||||
return fmt.Errorf("unexpected type %T for field %s", value, name)
|
||||
}
|
||||
m.SetUserAgent(v)
|
||||
return nil
|
||||
case usagelog.FieldImageCount:
|
||||
v, ok := value.(int)
|
||||
if !ok {
|
||||
@@ -10427,6 +10618,9 @@ func (m *UsageLogMutation) ClearedFields() []string {
|
||||
if m.FieldCleared(usagelog.FieldFirstTokenMs) {
|
||||
fields = append(fields, usagelog.FieldFirstTokenMs)
|
||||
}
|
||||
if m.FieldCleared(usagelog.FieldUserAgent) {
|
||||
fields = append(fields, usagelog.FieldUserAgent)
|
||||
}
|
||||
if m.FieldCleared(usagelog.FieldImageSize) {
|
||||
fields = append(fields, usagelog.FieldImageSize)
|
||||
}
|
||||
@@ -10456,6 +10650,9 @@ func (m *UsageLogMutation) ClearField(name string) error {
|
||||
case usagelog.FieldFirstTokenMs:
|
||||
m.ClearFirstTokenMs()
|
||||
return nil
|
||||
case usagelog.FieldUserAgent:
|
||||
m.ClearUserAgent()
|
||||
return nil
|
||||
case usagelog.FieldImageSize:
|
||||
m.ClearImageSize()
|
||||
return nil
|
||||
@@ -10539,6 +10736,9 @@ func (m *UsageLogMutation) ResetField(name string) error {
|
||||
case usagelog.FieldFirstTokenMs:
|
||||
m.ResetFirstTokenMs()
|
||||
return nil
|
||||
case usagelog.FieldUserAgent:
|
||||
m.ResetUserAgent()
|
||||
return nil
|
||||
case usagelog.FieldImageCount:
|
||||
m.ResetImageCount()
|
||||
return nil
|
||||
|
||||
@@ -181,12 +181,16 @@ func init() {
|
||||
account.DefaultStatus = accountDescStatus.Default.(string)
|
||||
// account.StatusValidator is a validator for the "status" field. It is called by the builders before save.
|
||||
account.StatusValidator = accountDescStatus.Validators[0].(func(string) error)
|
||||
// accountDescAutoPauseOnExpired is the schema descriptor for auto_pause_on_expired field.
|
||||
accountDescAutoPauseOnExpired := accountFields[13].Descriptor()
|
||||
// account.DefaultAutoPauseOnExpired holds the default value on creation for the auto_pause_on_expired field.
|
||||
account.DefaultAutoPauseOnExpired = accountDescAutoPauseOnExpired.Default.(bool)
|
||||
// accountDescSchedulable is the schema descriptor for schedulable field.
|
||||
accountDescSchedulable := accountFields[12].Descriptor()
|
||||
accountDescSchedulable := accountFields[14].Descriptor()
|
||||
// account.DefaultSchedulable holds the default value on creation for the schedulable field.
|
||||
account.DefaultSchedulable = accountDescSchedulable.Default.(bool)
|
||||
// accountDescSessionWindowStatus is the schema descriptor for session_window_status field.
|
||||
accountDescSessionWindowStatus := accountFields[18].Descriptor()
|
||||
accountDescSessionWindowStatus := accountFields[20].Descriptor()
|
||||
// account.SessionWindowStatusValidator is a validator for the "session_window_status" field. It is called by the builders before save.
|
||||
account.SessionWindowStatusValidator = accountDescSessionWindowStatus.Validators[0].(func(string) error)
|
||||
accountgroupFields := schema.AccountGroup{}.Fields()
|
||||
@@ -521,16 +525,20 @@ func init() {
|
||||
usagelogDescStream := usagelogFields[21].Descriptor()
|
||||
// usagelog.DefaultStream holds the default value on creation for the stream field.
|
||||
usagelog.DefaultStream = usagelogDescStream.Default.(bool)
|
||||
// usagelogDescUserAgent is the schema descriptor for user_agent field.
|
||||
usagelogDescUserAgent := usagelogFields[24].Descriptor()
|
||||
// usagelog.UserAgentValidator is a validator for the "user_agent" field. It is called by the builders before save.
|
||||
usagelog.UserAgentValidator = usagelogDescUserAgent.Validators[0].(func(string) error)
|
||||
// usagelogDescImageCount is the schema descriptor for image_count field.
|
||||
usagelogDescImageCount := usagelogFields[24].Descriptor()
|
||||
usagelogDescImageCount := usagelogFields[25].Descriptor()
|
||||
// usagelog.DefaultImageCount holds the default value on creation for the image_count field.
|
||||
usagelog.DefaultImageCount = usagelogDescImageCount.Default.(int)
|
||||
// usagelogDescImageSize is the schema descriptor for image_size field.
|
||||
usagelogDescImageSize := usagelogFields[25].Descriptor()
|
||||
usagelogDescImageSize := usagelogFields[26].Descriptor()
|
||||
// usagelog.ImageSizeValidator is a validator for the "image_size" field. It is called by the builders before save.
|
||||
usagelog.ImageSizeValidator = usagelogDescImageSize.Validators[0].(func(string) error)
|
||||
// usagelogDescCreatedAt is the schema descriptor for created_at field.
|
||||
usagelogDescCreatedAt := usagelogFields[26].Descriptor()
|
||||
usagelogDescCreatedAt := usagelogFields[27].Descriptor()
|
||||
// usagelog.DefaultCreatedAt holds the default value on creation for the created_at field.
|
||||
usagelog.DefaultCreatedAt = usagelogDescCreatedAt.Default.(func() time.Time)
|
||||
userMixin := schema.User{}.Mixin()
|
||||
|
||||
@@ -118,6 +118,16 @@ func (Account) Fields() []ent.Field {
|
||||
Optional().
|
||||
Nillable().
|
||||
SchemaType(map[string]string{dialect.Postgres: "timestamptz"}),
|
||||
// expires_at: 账户过期时间(可为空)
|
||||
field.Time("expires_at").
|
||||
Optional().
|
||||
Nillable().
|
||||
Comment("Account expiration time (NULL means no expiration).").
|
||||
SchemaType(map[string]string{dialect.Postgres: "timestamptz"}),
|
||||
// auto_pause_on_expired: 过期后自动暂停调度
|
||||
field.Bool("auto_pause_on_expired").
|
||||
Default(true).
|
||||
Comment("Auto pause scheduling when account expires."),
|
||||
|
||||
// ========== 调度和速率限制相关字段 ==========
|
||||
// 这些字段在 migrations/005_schema_parity.sql 中添加
|
||||
|
||||
@@ -96,6 +96,10 @@ func (UsageLog) Fields() []ent.Field {
|
||||
field.Int("first_token_ms").
|
||||
Optional().
|
||||
Nillable(),
|
||||
field.String("user_agent").
|
||||
MaxLen(512).
|
||||
Optional().
|
||||
Nillable(),
|
||||
|
||||
// 图片生成字段(仅 gemini-3-pro-image 等图片模型使用)
|
||||
field.Int("image_count").
|
||||
|
||||
@@ -70,6 +70,8 @@ type UsageLog struct {
|
||||
DurationMs *int `json:"duration_ms,omitempty"`
|
||||
// FirstTokenMs holds the value of the "first_token_ms" field.
|
||||
FirstTokenMs *int `json:"first_token_ms,omitempty"`
|
||||
// UserAgent holds the value of the "user_agent" field.
|
||||
UserAgent *string `json:"user_agent,omitempty"`
|
||||
// ImageCount holds the value of the "image_count" field.
|
||||
ImageCount int `json:"image_count,omitempty"`
|
||||
// ImageSize holds the value of the "image_size" field.
|
||||
@@ -165,7 +167,7 @@ func (*UsageLog) scanValues(columns []string) ([]any, error) {
|
||||
values[i] = new(sql.NullFloat64)
|
||||
case usagelog.FieldID, usagelog.FieldUserID, usagelog.FieldAPIKeyID, usagelog.FieldAccountID, usagelog.FieldGroupID, usagelog.FieldSubscriptionID, usagelog.FieldInputTokens, usagelog.FieldOutputTokens, usagelog.FieldCacheCreationTokens, usagelog.FieldCacheReadTokens, usagelog.FieldCacheCreation5mTokens, usagelog.FieldCacheCreation1hTokens, usagelog.FieldBillingType, usagelog.FieldDurationMs, usagelog.FieldFirstTokenMs, usagelog.FieldImageCount:
|
||||
values[i] = new(sql.NullInt64)
|
||||
case usagelog.FieldRequestID, usagelog.FieldModel, usagelog.FieldImageSize:
|
||||
case usagelog.FieldRequestID, usagelog.FieldModel, usagelog.FieldUserAgent, usagelog.FieldImageSize:
|
||||
values[i] = new(sql.NullString)
|
||||
case usagelog.FieldCreatedAt:
|
||||
values[i] = new(sql.NullTime)
|
||||
@@ -338,6 +340,13 @@ func (_m *UsageLog) assignValues(columns []string, values []any) error {
|
||||
_m.FirstTokenMs = new(int)
|
||||
*_m.FirstTokenMs = int(value.Int64)
|
||||
}
|
||||
case usagelog.FieldUserAgent:
|
||||
if value, ok := values[i].(*sql.NullString); !ok {
|
||||
return fmt.Errorf("unexpected type %T for field user_agent", values[i])
|
||||
} else if value.Valid {
|
||||
_m.UserAgent = new(string)
|
||||
*_m.UserAgent = value.String
|
||||
}
|
||||
case usagelog.FieldImageCount:
|
||||
if value, ok := values[i].(*sql.NullInt64); !ok {
|
||||
return fmt.Errorf("unexpected type %T for field image_count", values[i])
|
||||
@@ -498,6 +507,11 @@ func (_m *UsageLog) String() string {
|
||||
builder.WriteString(fmt.Sprintf("%v", *v))
|
||||
}
|
||||
builder.WriteString(", ")
|
||||
if v := _m.UserAgent; v != nil {
|
||||
builder.WriteString("user_agent=")
|
||||
builder.WriteString(*v)
|
||||
}
|
||||
builder.WriteString(", ")
|
||||
builder.WriteString("image_count=")
|
||||
builder.WriteString(fmt.Sprintf("%v", _m.ImageCount))
|
||||
builder.WriteString(", ")
|
||||
|
||||
@@ -62,6 +62,8 @@ const (
|
||||
FieldDurationMs = "duration_ms"
|
||||
// FieldFirstTokenMs holds the string denoting the first_token_ms field in the database.
|
||||
FieldFirstTokenMs = "first_token_ms"
|
||||
// FieldUserAgent holds the string denoting the user_agent field in the database.
|
||||
FieldUserAgent = "user_agent"
|
||||
// FieldImageCount holds the string denoting the image_count field in the database.
|
||||
FieldImageCount = "image_count"
|
||||
// FieldImageSize holds the string denoting the image_size field in the database.
|
||||
@@ -144,6 +146,7 @@ var Columns = []string{
|
||||
FieldStream,
|
||||
FieldDurationMs,
|
||||
FieldFirstTokenMs,
|
||||
FieldUserAgent,
|
||||
FieldImageCount,
|
||||
FieldImageSize,
|
||||
FieldCreatedAt,
|
||||
@@ -194,6 +197,8 @@ var (
|
||||
DefaultBillingType int8
|
||||
// DefaultStream holds the default value on creation for the "stream" field.
|
||||
DefaultStream bool
|
||||
// UserAgentValidator is a validator for the "user_agent" field. It is called by the builders before save.
|
||||
UserAgentValidator func(string) error
|
||||
// DefaultImageCount holds the default value on creation for the "image_count" field.
|
||||
DefaultImageCount int
|
||||
// ImageSizeValidator is a validator for the "image_size" field. It is called by the builders before save.
|
||||
@@ -330,6 +335,11 @@ func ByFirstTokenMs(opts ...sql.OrderTermOption) OrderOption {
|
||||
return sql.OrderByField(FieldFirstTokenMs, opts...).ToFunc()
|
||||
}
|
||||
|
||||
// ByUserAgent orders the results by the user_agent field.
|
||||
func ByUserAgent(opts ...sql.OrderTermOption) OrderOption {
|
||||
return sql.OrderByField(FieldUserAgent, opts...).ToFunc()
|
||||
}
|
||||
|
||||
// ByImageCount orders the results by the image_count field.
|
||||
func ByImageCount(opts ...sql.OrderTermOption) OrderOption {
|
||||
return sql.OrderByField(FieldImageCount, opts...).ToFunc()
|
||||
|
||||
@@ -175,6 +175,11 @@ func FirstTokenMs(v int) predicate.UsageLog {
|
||||
return predicate.UsageLog(sql.FieldEQ(FieldFirstTokenMs, v))
|
||||
}
|
||||
|
||||
// UserAgent applies equality check predicate on the "user_agent" field. It's identical to UserAgentEQ.
|
||||
func UserAgent(v string) predicate.UsageLog {
|
||||
return predicate.UsageLog(sql.FieldEQ(FieldUserAgent, v))
|
||||
}
|
||||
|
||||
// ImageCount applies equality check predicate on the "image_count" field. It's identical to ImageCountEQ.
|
||||
func ImageCount(v int) predicate.UsageLog {
|
||||
return predicate.UsageLog(sql.FieldEQ(FieldImageCount, v))
|
||||
@@ -1110,6 +1115,81 @@ func FirstTokenMsNotNil() predicate.UsageLog {
|
||||
return predicate.UsageLog(sql.FieldNotNull(FieldFirstTokenMs))
|
||||
}
|
||||
|
||||
// UserAgentEQ applies the EQ predicate on the "user_agent" field.
|
||||
func UserAgentEQ(v string) predicate.UsageLog {
|
||||
return predicate.UsageLog(sql.FieldEQ(FieldUserAgent, v))
|
||||
}
|
||||
|
||||
// UserAgentNEQ applies the NEQ predicate on the "user_agent" field.
|
||||
func UserAgentNEQ(v string) predicate.UsageLog {
|
||||
return predicate.UsageLog(sql.FieldNEQ(FieldUserAgent, v))
|
||||
}
|
||||
|
||||
// UserAgentIn applies the In predicate on the "user_agent" field.
|
||||
func UserAgentIn(vs ...string) predicate.UsageLog {
|
||||
return predicate.UsageLog(sql.FieldIn(FieldUserAgent, vs...))
|
||||
}
|
||||
|
||||
// UserAgentNotIn applies the NotIn predicate on the "user_agent" field.
|
||||
func UserAgentNotIn(vs ...string) predicate.UsageLog {
|
||||
return predicate.UsageLog(sql.FieldNotIn(FieldUserAgent, vs...))
|
||||
}
|
||||
|
||||
// UserAgentGT applies the GT predicate on the "user_agent" field.
|
||||
func UserAgentGT(v string) predicate.UsageLog {
|
||||
return predicate.UsageLog(sql.FieldGT(FieldUserAgent, v))
|
||||
}
|
||||
|
||||
// UserAgentGTE applies the GTE predicate on the "user_agent" field.
|
||||
func UserAgentGTE(v string) predicate.UsageLog {
|
||||
return predicate.UsageLog(sql.FieldGTE(FieldUserAgent, v))
|
||||
}
|
||||
|
||||
// UserAgentLT applies the LT predicate on the "user_agent" field.
|
||||
func UserAgentLT(v string) predicate.UsageLog {
|
||||
return predicate.UsageLog(sql.FieldLT(FieldUserAgent, v))
|
||||
}
|
||||
|
||||
// UserAgentLTE applies the LTE predicate on the "user_agent" field.
|
||||
func UserAgentLTE(v string) predicate.UsageLog {
|
||||
return predicate.UsageLog(sql.FieldLTE(FieldUserAgent, v))
|
||||
}
|
||||
|
||||
// UserAgentContains applies the Contains predicate on the "user_agent" field.
|
||||
func UserAgentContains(v string) predicate.UsageLog {
|
||||
return predicate.UsageLog(sql.FieldContains(FieldUserAgent, v))
|
||||
}
|
||||
|
||||
// UserAgentHasPrefix applies the HasPrefix predicate on the "user_agent" field.
|
||||
func UserAgentHasPrefix(v string) predicate.UsageLog {
|
||||
return predicate.UsageLog(sql.FieldHasPrefix(FieldUserAgent, v))
|
||||
}
|
||||
|
||||
// UserAgentHasSuffix applies the HasSuffix predicate on the "user_agent" field.
|
||||
func UserAgentHasSuffix(v string) predicate.UsageLog {
|
||||
return predicate.UsageLog(sql.FieldHasSuffix(FieldUserAgent, v))
|
||||
}
|
||||
|
||||
// UserAgentIsNil applies the IsNil predicate on the "user_agent" field.
|
||||
func UserAgentIsNil() predicate.UsageLog {
|
||||
return predicate.UsageLog(sql.FieldIsNull(FieldUserAgent))
|
||||
}
|
||||
|
||||
// UserAgentNotNil applies the NotNil predicate on the "user_agent" field.
|
||||
func UserAgentNotNil() predicate.UsageLog {
|
||||
return predicate.UsageLog(sql.FieldNotNull(FieldUserAgent))
|
||||
}
|
||||
|
||||
// UserAgentEqualFold applies the EqualFold predicate on the "user_agent" field.
|
||||
func UserAgentEqualFold(v string) predicate.UsageLog {
|
||||
return predicate.UsageLog(sql.FieldEqualFold(FieldUserAgent, v))
|
||||
}
|
||||
|
||||
// UserAgentContainsFold applies the ContainsFold predicate on the "user_agent" field.
|
||||
func UserAgentContainsFold(v string) predicate.UsageLog {
|
||||
return predicate.UsageLog(sql.FieldContainsFold(FieldUserAgent, v))
|
||||
}
|
||||
|
||||
// ImageCountEQ applies the EQ predicate on the "image_count" field.
|
||||
func ImageCountEQ(v int) predicate.UsageLog {
|
||||
return predicate.UsageLog(sql.FieldEQ(FieldImageCount, v))
|
||||
|
||||
@@ -323,6 +323,20 @@ func (_c *UsageLogCreate) SetNillableFirstTokenMs(v *int) *UsageLogCreate {
|
||||
return _c
|
||||
}
|
||||
|
||||
// SetUserAgent sets the "user_agent" field.
|
||||
func (_c *UsageLogCreate) SetUserAgent(v string) *UsageLogCreate {
|
||||
_c.mutation.SetUserAgent(v)
|
||||
return _c
|
||||
}
|
||||
|
||||
// SetNillableUserAgent sets the "user_agent" field if the given value is not nil.
|
||||
func (_c *UsageLogCreate) SetNillableUserAgent(v *string) *UsageLogCreate {
|
||||
if v != nil {
|
||||
_c.SetUserAgent(*v)
|
||||
}
|
||||
return _c
|
||||
}
|
||||
|
||||
// SetImageCount sets the "image_count" field.
|
||||
func (_c *UsageLogCreate) SetImageCount(v int) *UsageLogCreate {
|
||||
_c.mutation.SetImageCount(v)
|
||||
@@ -567,6 +581,11 @@ func (_c *UsageLogCreate) check() error {
|
||||
if _, ok := _c.mutation.Stream(); !ok {
|
||||
return &ValidationError{Name: "stream", err: errors.New(`ent: missing required field "UsageLog.stream"`)}
|
||||
}
|
||||
if v, ok := _c.mutation.UserAgent(); ok {
|
||||
if err := usagelog.UserAgentValidator(v); err != nil {
|
||||
return &ValidationError{Name: "user_agent", err: fmt.Errorf(`ent: validator failed for field "UsageLog.user_agent": %w`, err)}
|
||||
}
|
||||
}
|
||||
if _, ok := _c.mutation.ImageCount(); !ok {
|
||||
return &ValidationError{Name: "image_count", err: errors.New(`ent: missing required field "UsageLog.image_count"`)}
|
||||
}
|
||||
@@ -690,6 +709,10 @@ func (_c *UsageLogCreate) createSpec() (*UsageLog, *sqlgraph.CreateSpec) {
|
||||
_spec.SetField(usagelog.FieldFirstTokenMs, field.TypeInt, value)
|
||||
_node.FirstTokenMs = &value
|
||||
}
|
||||
if value, ok := _c.mutation.UserAgent(); ok {
|
||||
_spec.SetField(usagelog.FieldUserAgent, field.TypeString, value)
|
||||
_node.UserAgent = &value
|
||||
}
|
||||
if value, ok := _c.mutation.ImageCount(); ok {
|
||||
_spec.SetField(usagelog.FieldImageCount, field.TypeInt, value)
|
||||
_node.ImageCount = value
|
||||
@@ -1247,6 +1270,24 @@ func (u *UsageLogUpsert) ClearFirstTokenMs() *UsageLogUpsert {
|
||||
return u
|
||||
}
|
||||
|
||||
// SetUserAgent sets the "user_agent" field.
|
||||
func (u *UsageLogUpsert) SetUserAgent(v string) *UsageLogUpsert {
|
||||
u.Set(usagelog.FieldUserAgent, v)
|
||||
return u
|
||||
}
|
||||
|
||||
// UpdateUserAgent sets the "user_agent" field to the value that was provided on create.
|
||||
func (u *UsageLogUpsert) UpdateUserAgent() *UsageLogUpsert {
|
||||
u.SetExcluded(usagelog.FieldUserAgent)
|
||||
return u
|
||||
}
|
||||
|
||||
// ClearUserAgent clears the value of the "user_agent" field.
|
||||
func (u *UsageLogUpsert) ClearUserAgent() *UsageLogUpsert {
|
||||
u.SetNull(usagelog.FieldUserAgent)
|
||||
return u
|
||||
}
|
||||
|
||||
// SetImageCount sets the "image_count" field.
|
||||
func (u *UsageLogUpsert) SetImageCount(v int) *UsageLogUpsert {
|
||||
u.Set(usagelog.FieldImageCount, v)
|
||||
@@ -1804,6 +1845,27 @@ func (u *UsageLogUpsertOne) ClearFirstTokenMs() *UsageLogUpsertOne {
|
||||
})
|
||||
}
|
||||
|
||||
// SetUserAgent sets the "user_agent" field.
|
||||
func (u *UsageLogUpsertOne) SetUserAgent(v string) *UsageLogUpsertOne {
|
||||
return u.Update(func(s *UsageLogUpsert) {
|
||||
s.SetUserAgent(v)
|
||||
})
|
||||
}
|
||||
|
||||
// UpdateUserAgent sets the "user_agent" field to the value that was provided on create.
|
||||
func (u *UsageLogUpsertOne) UpdateUserAgent() *UsageLogUpsertOne {
|
||||
return u.Update(func(s *UsageLogUpsert) {
|
||||
s.UpdateUserAgent()
|
||||
})
|
||||
}
|
||||
|
||||
// ClearUserAgent clears the value of the "user_agent" field.
|
||||
func (u *UsageLogUpsertOne) ClearUserAgent() *UsageLogUpsertOne {
|
||||
return u.Update(func(s *UsageLogUpsert) {
|
||||
s.ClearUserAgent()
|
||||
})
|
||||
}
|
||||
|
||||
// SetImageCount sets the "image_count" field.
|
||||
func (u *UsageLogUpsertOne) SetImageCount(v int) *UsageLogUpsertOne {
|
||||
return u.Update(func(s *UsageLogUpsert) {
|
||||
@@ -2533,6 +2595,27 @@ func (u *UsageLogUpsertBulk) ClearFirstTokenMs() *UsageLogUpsertBulk {
|
||||
})
|
||||
}
|
||||
|
||||
// SetUserAgent sets the "user_agent" field.
|
||||
func (u *UsageLogUpsertBulk) SetUserAgent(v string) *UsageLogUpsertBulk {
|
||||
return u.Update(func(s *UsageLogUpsert) {
|
||||
s.SetUserAgent(v)
|
||||
})
|
||||
}
|
||||
|
||||
// UpdateUserAgent sets the "user_agent" field to the value that was provided on create.
|
||||
func (u *UsageLogUpsertBulk) UpdateUserAgent() *UsageLogUpsertBulk {
|
||||
return u.Update(func(s *UsageLogUpsert) {
|
||||
s.UpdateUserAgent()
|
||||
})
|
||||
}
|
||||
|
||||
// ClearUserAgent clears the value of the "user_agent" field.
|
||||
func (u *UsageLogUpsertBulk) ClearUserAgent() *UsageLogUpsertBulk {
|
||||
return u.Update(func(s *UsageLogUpsert) {
|
||||
s.ClearUserAgent()
|
||||
})
|
||||
}
|
||||
|
||||
// SetImageCount sets the "image_count" field.
|
||||
func (u *UsageLogUpsertBulk) SetImageCount(v int) *UsageLogUpsertBulk {
|
||||
return u.Update(func(s *UsageLogUpsert) {
|
||||
|
||||
@@ -504,6 +504,26 @@ func (_u *UsageLogUpdate) ClearFirstTokenMs() *UsageLogUpdate {
|
||||
return _u
|
||||
}
|
||||
|
||||
// SetUserAgent sets the "user_agent" field.
|
||||
func (_u *UsageLogUpdate) SetUserAgent(v string) *UsageLogUpdate {
|
||||
_u.mutation.SetUserAgent(v)
|
||||
return _u
|
||||
}
|
||||
|
||||
// SetNillableUserAgent sets the "user_agent" field if the given value is not nil.
|
||||
func (_u *UsageLogUpdate) SetNillableUserAgent(v *string) *UsageLogUpdate {
|
||||
if v != nil {
|
||||
_u.SetUserAgent(*v)
|
||||
}
|
||||
return _u
|
||||
}
|
||||
|
||||
// ClearUserAgent clears the value of the "user_agent" field.
|
||||
func (_u *UsageLogUpdate) ClearUserAgent() *UsageLogUpdate {
|
||||
_u.mutation.ClearUserAgent()
|
||||
return _u
|
||||
}
|
||||
|
||||
// SetImageCount sets the "image_count" field.
|
||||
func (_u *UsageLogUpdate) SetImageCount(v int) *UsageLogUpdate {
|
||||
_u.mutation.ResetImageCount()
|
||||
@@ -644,6 +664,11 @@ func (_u *UsageLogUpdate) check() error {
|
||||
return &ValidationError{Name: "model", err: fmt.Errorf(`ent: validator failed for field "UsageLog.model": %w`, err)}
|
||||
}
|
||||
}
|
||||
if v, ok := _u.mutation.UserAgent(); ok {
|
||||
if err := usagelog.UserAgentValidator(v); err != nil {
|
||||
return &ValidationError{Name: "user_agent", err: fmt.Errorf(`ent: validator failed for field "UsageLog.user_agent": %w`, err)}
|
||||
}
|
||||
}
|
||||
if v, ok := _u.mutation.ImageSize(); ok {
|
||||
if err := usagelog.ImageSizeValidator(v); err != nil {
|
||||
return &ValidationError{Name: "image_size", err: fmt.Errorf(`ent: validator failed for field "UsageLog.image_size": %w`, err)}
|
||||
@@ -784,6 +809,12 @@ func (_u *UsageLogUpdate) sqlSave(ctx context.Context) (_node int, err error) {
|
||||
if _u.mutation.FirstTokenMsCleared() {
|
||||
_spec.ClearField(usagelog.FieldFirstTokenMs, field.TypeInt)
|
||||
}
|
||||
if value, ok := _u.mutation.UserAgent(); ok {
|
||||
_spec.SetField(usagelog.FieldUserAgent, field.TypeString, value)
|
||||
}
|
||||
if _u.mutation.UserAgentCleared() {
|
||||
_spec.ClearField(usagelog.FieldUserAgent, field.TypeString)
|
||||
}
|
||||
if value, ok := _u.mutation.ImageCount(); ok {
|
||||
_spec.SetField(usagelog.FieldImageCount, field.TypeInt, value)
|
||||
}
|
||||
@@ -1433,6 +1464,26 @@ func (_u *UsageLogUpdateOne) ClearFirstTokenMs() *UsageLogUpdateOne {
|
||||
return _u
|
||||
}
|
||||
|
||||
// SetUserAgent sets the "user_agent" field.
|
||||
func (_u *UsageLogUpdateOne) SetUserAgent(v string) *UsageLogUpdateOne {
|
||||
_u.mutation.SetUserAgent(v)
|
||||
return _u
|
||||
}
|
||||
|
||||
// SetNillableUserAgent sets the "user_agent" field if the given value is not nil.
|
||||
func (_u *UsageLogUpdateOne) SetNillableUserAgent(v *string) *UsageLogUpdateOne {
|
||||
if v != nil {
|
||||
_u.SetUserAgent(*v)
|
||||
}
|
||||
return _u
|
||||
}
|
||||
|
||||
// ClearUserAgent clears the value of the "user_agent" field.
|
||||
func (_u *UsageLogUpdateOne) ClearUserAgent() *UsageLogUpdateOne {
|
||||
_u.mutation.ClearUserAgent()
|
||||
return _u
|
||||
}
|
||||
|
||||
// SetImageCount sets the "image_count" field.
|
||||
func (_u *UsageLogUpdateOne) SetImageCount(v int) *UsageLogUpdateOne {
|
||||
_u.mutation.ResetImageCount()
|
||||
@@ -1586,6 +1637,11 @@ func (_u *UsageLogUpdateOne) check() error {
|
||||
return &ValidationError{Name: "model", err: fmt.Errorf(`ent: validator failed for field "UsageLog.model": %w`, err)}
|
||||
}
|
||||
}
|
||||
if v, ok := _u.mutation.UserAgent(); ok {
|
||||
if err := usagelog.UserAgentValidator(v); err != nil {
|
||||
return &ValidationError{Name: "user_agent", err: fmt.Errorf(`ent: validator failed for field "UsageLog.user_agent": %w`, err)}
|
||||
}
|
||||
}
|
||||
if v, ok := _u.mutation.ImageSize(); ok {
|
||||
if err := usagelog.ImageSizeValidator(v); err != nil {
|
||||
return &ValidationError{Name: "image_size", err: fmt.Errorf(`ent: validator failed for field "UsageLog.image_size": %w`, err)}
|
||||
@@ -1743,6 +1799,12 @@ func (_u *UsageLogUpdateOne) sqlSave(ctx context.Context) (_node *UsageLog, err
|
||||
if _u.mutation.FirstTokenMsCleared() {
|
||||
_spec.ClearField(usagelog.FieldFirstTokenMs, field.TypeInt)
|
||||
}
|
||||
if value, ok := _u.mutation.UserAgent(); ok {
|
||||
_spec.SetField(usagelog.FieldUserAgent, field.TypeString, value)
|
||||
}
|
||||
if _u.mutation.UserAgentCleared() {
|
||||
_spec.ClearField(usagelog.FieldUserAgent, field.TypeString)
|
||||
}
|
||||
if value, ok := _u.mutation.ImageCount(); ok {
|
||||
_spec.SetField(usagelog.FieldImageCount, field.TypeInt, value)
|
||||
}
|
||||
|
||||
@@ -52,6 +52,15 @@ type Config struct {
|
||||
RunMode string `mapstructure:"run_mode" yaml:"run_mode"`
|
||||
Timezone string `mapstructure:"timezone"` // e.g. "Asia/Shanghai", "UTC"
|
||||
Gemini GeminiConfig `mapstructure:"gemini"`
|
||||
Update UpdateConfig `mapstructure:"update"`
|
||||
}
|
||||
|
||||
// UpdateConfig 在线更新相关配置
|
||||
type UpdateConfig struct {
|
||||
// ProxyURL 用于访问 GitHub 的代理地址
|
||||
// 支持 http/https/socks5/socks5h 协议
|
||||
// 例如: "http://127.0.0.1:7890", "socks5://127.0.0.1:1080"
|
||||
ProxyURL string `mapstructure:"proxy_url"`
|
||||
}
|
||||
|
||||
type GeminiConfig struct {
|
||||
@@ -558,6 +567,10 @@ func setDefaults() {
|
||||
viper.SetDefault("gemini.oauth.client_secret", "")
|
||||
viper.SetDefault("gemini.oauth.scopes", "")
|
||||
viper.SetDefault("gemini.quota.policy", "")
|
||||
|
||||
// Update - 在线更新配置
|
||||
// 代理地址为空表示直连 GitHub(适用于海外服务器)
|
||||
viper.SetDefault("update.proxy_url", "")
|
||||
}
|
||||
|
||||
func (c *Config) Validate() error {
|
||||
|
||||
@@ -85,6 +85,8 @@ type CreateAccountRequest struct {
|
||||
Concurrency int `json:"concurrency"`
|
||||
Priority int `json:"priority"`
|
||||
GroupIDs []int64 `json:"group_ids"`
|
||||
ExpiresAt *int64 `json:"expires_at"`
|
||||
AutoPauseOnExpired *bool `json:"auto_pause_on_expired"`
|
||||
ConfirmMixedChannelRisk *bool `json:"confirm_mixed_channel_risk"` // 用户确认混合渠道风险
|
||||
}
|
||||
|
||||
@@ -101,6 +103,8 @@ type UpdateAccountRequest struct {
|
||||
Priority *int `json:"priority"`
|
||||
Status string `json:"status" binding:"omitempty,oneof=active inactive"`
|
||||
GroupIDs *[]int64 `json:"group_ids"`
|
||||
ExpiresAt *int64 `json:"expires_at"`
|
||||
AutoPauseOnExpired *bool `json:"auto_pause_on_expired"`
|
||||
ConfirmMixedChannelRisk *bool `json:"confirm_mixed_channel_risk"` // 用户确认混合渠道风险
|
||||
}
|
||||
|
||||
@@ -204,6 +208,8 @@ func (h *AccountHandler) Create(c *gin.Context) {
|
||||
Concurrency: req.Concurrency,
|
||||
Priority: req.Priority,
|
||||
GroupIDs: req.GroupIDs,
|
||||
ExpiresAt: req.ExpiresAt,
|
||||
AutoPauseOnExpired: req.AutoPauseOnExpired,
|
||||
SkipMixedChannelCheck: skipCheck,
|
||||
})
|
||||
if err != nil {
|
||||
@@ -261,6 +267,8 @@ func (h *AccountHandler) Update(c *gin.Context) {
|
||||
Priority: req.Priority, // 指针类型,nil 表示未提供
|
||||
Status: req.Status,
|
||||
GroupIDs: req.GroupIDs,
|
||||
ExpiresAt: req.ExpiresAt,
|
||||
AutoPauseOnExpired: req.AutoPauseOnExpired,
|
||||
SkipMixedChannelCheck: skipCheck,
|
||||
})
|
||||
if err != nil {
|
||||
|
||||
@@ -144,7 +144,7 @@ func (h *UsageHandler) List(c *gin.Context) {
|
||||
|
||||
out := make([]dto.UsageLog, 0, len(records))
|
||||
for i := range records {
|
||||
out = append(out, *dto.UsageLogFromService(&records[i]))
|
||||
out = append(out, *dto.UsageLogFromServiceAdmin(&records[i]))
|
||||
}
|
||||
response.Paginated(c, out, result.Total, page, pageSize)
|
||||
}
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
// Package dto provides data transfer objects for HTTP handlers.
|
||||
package dto
|
||||
|
||||
import "github.com/Wei-Shaw/sub2api/internal/service"
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||
)
|
||||
|
||||
func UserFromServiceShallow(u *service.User) *User {
|
||||
if u == nil {
|
||||
@@ -120,6 +124,8 @@ func AccountFromServiceShallow(a *service.Account) *Account {
|
||||
Status: a.Status,
|
||||
ErrorMessage: a.ErrorMessage,
|
||||
LastUsedAt: a.LastUsedAt,
|
||||
ExpiresAt: timeToUnixSeconds(a.ExpiresAt),
|
||||
AutoPauseOnExpired: a.AutoPauseOnExpired,
|
||||
CreatedAt: a.CreatedAt,
|
||||
UpdatedAt: a.UpdatedAt,
|
||||
Schedulable: a.Schedulable,
|
||||
@@ -157,6 +163,14 @@ func AccountFromService(a *service.Account) *Account {
|
||||
return out
|
||||
}
|
||||
|
||||
func timeToUnixSeconds(value *time.Time) *int64 {
|
||||
if value == nil {
|
||||
return nil
|
||||
}
|
||||
ts := value.Unix()
|
||||
return &ts
|
||||
}
|
||||
|
||||
func AccountGroupFromService(ag *service.AccountGroup) *AccountGroup {
|
||||
if ag == nil {
|
||||
return nil
|
||||
@@ -220,7 +234,21 @@ func RedeemCodeFromService(rc *service.RedeemCode) *RedeemCode {
|
||||
}
|
||||
}
|
||||
|
||||
func UsageLogFromService(l *service.UsageLog) *UsageLog {
|
||||
// AccountSummaryFromService returns a minimal AccountSummary for usage log display.
|
||||
// Only includes ID and Name - no sensitive fields like Credentials, Proxy, etc.
|
||||
func AccountSummaryFromService(a *service.Account) *AccountSummary {
|
||||
if a == nil {
|
||||
return nil
|
||||
}
|
||||
return &AccountSummary{
|
||||
ID: a.ID,
|
||||
Name: a.Name,
|
||||
}
|
||||
}
|
||||
|
||||
// usageLogFromServiceBase is a helper that converts service UsageLog to DTO.
|
||||
// The account parameter allows caller to control what Account info is included.
|
||||
func usageLogFromServiceBase(l *service.UsageLog, account *AccountSummary) *UsageLog {
|
||||
if l == nil {
|
||||
return nil
|
||||
}
|
||||
@@ -255,12 +283,27 @@ func UsageLogFromService(l *service.UsageLog) *UsageLog {
|
||||
CreatedAt: l.CreatedAt,
|
||||
User: UserFromServiceShallow(l.User),
|
||||
APIKey: APIKeyFromService(l.APIKey),
|
||||
Account: AccountFromService(l.Account),
|
||||
Account: account,
|
||||
Group: GroupFromServiceShallow(l.Group),
|
||||
Subscription: UserSubscriptionFromService(l.Subscription),
|
||||
}
|
||||
}
|
||||
|
||||
// UsageLogFromService converts a service UsageLog to DTO for regular users.
|
||||
// It excludes Account details - users should not see account information.
|
||||
func UsageLogFromService(l *service.UsageLog) *UsageLog {
|
||||
return usageLogFromServiceBase(l, nil)
|
||||
}
|
||||
|
||||
// UsageLogFromServiceAdmin converts a service UsageLog to DTO for admin users.
|
||||
// It includes minimal Account info (ID, Name only).
|
||||
func UsageLogFromServiceAdmin(l *service.UsageLog) *UsageLog {
|
||||
if l == nil {
|
||||
return nil
|
||||
}
|
||||
return usageLogFromServiceBase(l, AccountSummaryFromService(l.Account))
|
||||
}
|
||||
|
||||
func SettingFromService(s *service.Setting) *Setting {
|
||||
if s == nil {
|
||||
return nil
|
||||
|
||||
@@ -60,21 +60,23 @@ type Group struct {
|
||||
}
|
||||
|
||||
type Account struct {
|
||||
ID int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Notes *string `json:"notes"`
|
||||
Platform string `json:"platform"`
|
||||
Type string `json:"type"`
|
||||
Credentials map[string]any `json:"credentials"`
|
||||
Extra map[string]any `json:"extra"`
|
||||
ProxyID *int64 `json:"proxy_id"`
|
||||
Concurrency int `json:"concurrency"`
|
||||
Priority int `json:"priority"`
|
||||
Status string `json:"status"`
|
||||
ErrorMessage string `json:"error_message"`
|
||||
LastUsedAt *time.Time `json:"last_used_at"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
ID int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Notes *string `json:"notes"`
|
||||
Platform string `json:"platform"`
|
||||
Type string `json:"type"`
|
||||
Credentials map[string]any `json:"credentials"`
|
||||
Extra map[string]any `json:"extra"`
|
||||
ProxyID *int64 `json:"proxy_id"`
|
||||
Concurrency int `json:"concurrency"`
|
||||
Priority int `json:"priority"`
|
||||
Status string `json:"status"`
|
||||
ErrorMessage string `json:"error_message"`
|
||||
LastUsedAt *time.Time `json:"last_used_at"`
|
||||
ExpiresAt *int64 `json:"expires_at"`
|
||||
AutoPauseOnExpired bool `json:"auto_pause_on_expired"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
|
||||
Schedulable bool `json:"schedulable"`
|
||||
|
||||
@@ -182,11 +184,18 @@ type UsageLog struct {
|
||||
|
||||
User *User `json:"user,omitempty"`
|
||||
APIKey *APIKey `json:"api_key,omitempty"`
|
||||
Account *Account `json:"account,omitempty"`
|
||||
Account *AccountSummary `json:"account,omitempty"` // Use minimal AccountSummary to prevent data leakage
|
||||
Group *Group `json:"group,omitempty"`
|
||||
Subscription *UserSubscription `json:"subscription,omitempty"`
|
||||
}
|
||||
|
||||
// AccountSummary is a minimal account info for usage log display.
|
||||
// It intentionally excludes sensitive fields like Credentials, Proxy, etc.
|
||||
type AccountSummary struct {
|
||||
ID int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
type Setting struct {
|
||||
ID int64 `json:"id"`
|
||||
Key string `json:"key"`
|
||||
|
||||
@@ -108,6 +108,9 @@ func (h *GatewayHandler) Messages(c *gin.Context) {
|
||||
// 获取订阅信息(可能为nil)- 提前获取用于后续检查
|
||||
subscription, _ := middleware2.GetSubscriptionFromContext(c)
|
||||
|
||||
// 获取 User-Agent
|
||||
userAgent := c.Request.UserAgent()
|
||||
|
||||
// 0. 检查wait队列是否已满
|
||||
maxWait := service.CalculateMaxWait(subject.Concurrency)
|
||||
canWait, err := h.concurrencyHelper.IncrementWaitCount(c.Request.Context(), subject.UserID, maxWait)
|
||||
@@ -267,7 +270,7 @@ func (h *GatewayHandler) Messages(c *gin.Context) {
|
||||
}
|
||||
|
||||
// 异步记录使用量(subscription已在函数开头获取)
|
||||
go func(result *service.ForwardResult, usedAccount *service.Account) {
|
||||
go func(result *service.ForwardResult, usedAccount *service.Account, ua string) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
if err := h.gatewayService.RecordUsage(ctx, &service.RecordUsageInput{
|
||||
@@ -276,10 +279,11 @@ func (h *GatewayHandler) Messages(c *gin.Context) {
|
||||
User: apiKey.User,
|
||||
Account: usedAccount,
|
||||
Subscription: subscription,
|
||||
UserAgent: ua,
|
||||
}); err != nil {
|
||||
log.Printf("Record usage failed: %v", err)
|
||||
}
|
||||
}(result, account)
|
||||
}(result, account, userAgent)
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -394,7 +398,7 @@ func (h *GatewayHandler) Messages(c *gin.Context) {
|
||||
}
|
||||
|
||||
// 异步记录使用量(subscription已在函数开头获取)
|
||||
go func(result *service.ForwardResult, usedAccount *service.Account) {
|
||||
go func(result *service.ForwardResult, usedAccount *service.Account, ua string) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
if err := h.gatewayService.RecordUsage(ctx, &service.RecordUsageInput{
|
||||
@@ -403,10 +407,11 @@ func (h *GatewayHandler) Messages(c *gin.Context) {
|
||||
User: apiKey.User,
|
||||
Account: usedAccount,
|
||||
Subscription: subscription,
|
||||
UserAgent: ua,
|
||||
}); err != nil {
|
||||
log.Printf("Record usage failed: %v", err)
|
||||
}
|
||||
}(result, account)
|
||||
}(result, account, userAgent)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
@@ -164,6 +164,9 @@ func (h *GatewayHandler) GeminiV1BetaModels(c *gin.Context) {
|
||||
// Get subscription (may be nil)
|
||||
subscription, _ := middleware.GetSubscriptionFromContext(c)
|
||||
|
||||
// 获取 User-Agent
|
||||
userAgent := c.Request.UserAgent()
|
||||
|
||||
// For Gemini native API, do not send Claude-style ping frames.
|
||||
geminiConcurrency := NewConcurrencyHelper(h.concurrencyHelper.concurrencyService, SSEPingFormatNone, 0)
|
||||
|
||||
@@ -300,7 +303,7 @@ func (h *GatewayHandler) GeminiV1BetaModels(c *gin.Context) {
|
||||
}
|
||||
|
||||
// 6) record usage async
|
||||
go func(result *service.ForwardResult, usedAccount *service.Account) {
|
||||
go func(result *service.ForwardResult, usedAccount *service.Account, ua string) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
if err := h.gatewayService.RecordUsage(ctx, &service.RecordUsageInput{
|
||||
@@ -309,10 +312,11 @@ func (h *GatewayHandler) GeminiV1BetaModels(c *gin.Context) {
|
||||
User: apiKey.User,
|
||||
Account: usedAccount,
|
||||
Subscription: subscription,
|
||||
UserAgent: ua,
|
||||
}); err != nil {
|
||||
log.Printf("Record usage failed: %v", err)
|
||||
}
|
||||
}(result, account)
|
||||
}(result, account, userAgent)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
@@ -242,7 +242,7 @@ func (h *OpenAIGatewayHandler) Responses(c *gin.Context) {
|
||||
}
|
||||
|
||||
// Async record usage
|
||||
go func(result *service.OpenAIForwardResult, usedAccount *service.Account) {
|
||||
go func(result *service.OpenAIForwardResult, usedAccount *service.Account, ua string) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
if err := h.gatewayService.RecordUsage(ctx, &service.OpenAIRecordUsageInput{
|
||||
@@ -251,10 +251,11 @@ func (h *OpenAIGatewayHandler) Responses(c *gin.Context) {
|
||||
User: apiKey.User,
|
||||
Account: usedAccount,
|
||||
Subscription: subscription,
|
||||
UserAgent: ua,
|
||||
}); err != nil {
|
||||
log.Printf("Record usage failed: %v", err)
|
||||
}
|
||||
}(result, account)
|
||||
}(result, account, userAgent)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,16 +13,48 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// resolveHost 从 URL 解析 host
|
||||
func resolveHost(urlStr string) string {
|
||||
parsed, err := url.Parse(urlStr)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return parsed.Host
|
||||
}
|
||||
|
||||
// NewAPIRequest 创建 Antigravity API 请求(v1internal 端点)
|
||||
func NewAPIRequest(ctx context.Context, action, accessToken string, body []byte) (*http.Request, error) {
|
||||
// 构建 URL,流式请求添加 ?alt=sse 参数
|
||||
apiURL := fmt.Sprintf("%s/v1internal:%s", BaseURL, action)
|
||||
isStream := action == "streamGenerateContent"
|
||||
if isStream {
|
||||
apiURL += "?alt=sse"
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, apiURL, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 基础 Headers
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Authorization", "Bearer "+accessToken)
|
||||
req.Header.Set("User-Agent", UserAgent)
|
||||
|
||||
// Accept Header 根据请求类型设置
|
||||
if isStream {
|
||||
req.Header.Set("Accept", "text/event-stream")
|
||||
} else {
|
||||
req.Header.Set("Accept", "application/json")
|
||||
}
|
||||
|
||||
// 显式设置 Host Header
|
||||
if host := resolveHost(apiURL); host != "" {
|
||||
req.Host = host
|
||||
}
|
||||
|
||||
// 注意:requestType 已在 JSON body 的 V1InternalRequest 中设置,不需要 HTTP Header
|
||||
|
||||
return req, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -33,10 +33,11 @@ const (
|
||||
"https://www.googleapis.com/auth/experimentsandconfigs"
|
||||
|
||||
// API 端点
|
||||
BaseURL = "https://cloudcode-pa.googleapis.com"
|
||||
// 优先使用 sandbox daily URL,配额更宽松
|
||||
BaseURL = "https://daily-cloudcode-pa.sandbox.googleapis.com"
|
||||
|
||||
// User-Agent
|
||||
UserAgent = "antigravity/1.11.9 windows/amd64"
|
||||
// User-Agent(模拟官方客户端)
|
||||
UserAgent = "antigravity/1.104.0 darwin/arm64"
|
||||
|
||||
// Session 过期时间
|
||||
SessionTTL = 30 * time.Minute
|
||||
|
||||
@@ -1,17 +1,46 @@
|
||||
package antigravity
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/binary"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"math/rand"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
var (
|
||||
sessionRand = rand.New(rand.NewSource(time.Now().UnixNano()))
|
||||
sessionRandMutex sync.Mutex
|
||||
)
|
||||
|
||||
// generateStableSessionID 基于用户消息内容生成稳定的 session ID
|
||||
func generateStableSessionID(contents []GeminiContent) string {
|
||||
// 查找第一个 user 消息的文本
|
||||
for _, content := range contents {
|
||||
if content.Role == "user" && len(content.Parts) > 0 {
|
||||
if text := content.Parts[0].Text; text != "" {
|
||||
h := sha256.Sum256([]byte(text))
|
||||
n := int64(binary.BigEndian.Uint64(h[:8])) & 0x7FFFFFFFFFFFFFFF
|
||||
return "-" + strconv.FormatInt(n, 10)
|
||||
}
|
||||
}
|
||||
}
|
||||
// 回退:生成随机 session ID
|
||||
sessionRandMutex.Lock()
|
||||
n := sessionRand.Int63n(9_000_000_000_000_000_000)
|
||||
sessionRandMutex.Unlock()
|
||||
return "-" + strconv.FormatInt(n, 10)
|
||||
}
|
||||
|
||||
type TransformOptions struct {
|
||||
EnableIdentityPatch bool
|
||||
// IdentityPatch 可选:自定义注入到 systemInstruction 开头的身份防护提示词;
|
||||
@@ -67,8 +96,15 @@ func TransformClaudeToGeminiWithOptions(claudeReq *ClaudeRequest, projectID, map
|
||||
|
||||
// 5. 构建内部请求
|
||||
innerRequest := GeminiRequest{
|
||||
Contents: contents,
|
||||
SafetySettings: DefaultSafetySettings,
|
||||
Contents: contents,
|
||||
// 总是设置 toolConfig,与官方客户端一致
|
||||
ToolConfig: &GeminiToolConfig{
|
||||
FunctionCallingConfig: &GeminiFunctionCallingConfig{
|
||||
Mode: "VALIDATED",
|
||||
},
|
||||
},
|
||||
// 总是生成 sessionId,基于用户消息内容
|
||||
SessionID: generateStableSessionID(contents),
|
||||
}
|
||||
|
||||
if systemInstruction != nil {
|
||||
@@ -79,14 +115,9 @@ func TransformClaudeToGeminiWithOptions(claudeReq *ClaudeRequest, projectID, map
|
||||
}
|
||||
if len(tools) > 0 {
|
||||
innerRequest.Tools = tools
|
||||
innerRequest.ToolConfig = &GeminiToolConfig{
|
||||
FunctionCallingConfig: &GeminiFunctionCallingConfig{
|
||||
Mode: "VALIDATED",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// 如果提供了 metadata.user_id,复用为 sessionId
|
||||
// 如果提供了 metadata.user_id,优先使用
|
||||
if claudeReq.Metadata != nil && claudeReq.Metadata.UserID != "" {
|
||||
innerRequest.SessionID = claudeReq.Metadata.UserID
|
||||
}
|
||||
@@ -95,7 +126,7 @@ func TransformClaudeToGeminiWithOptions(claudeReq *ClaudeRequest, projectID, map
|
||||
v1Req := V1InternalRequest{
|
||||
Project: projectID,
|
||||
RequestID: "agent-" + uuid.New().String(),
|
||||
UserAgent: "sub2api",
|
||||
UserAgent: "antigravity", // 固定值,与官方客户端一致
|
||||
RequestType: "agent",
|
||||
Model: mappedModel,
|
||||
Request: innerRequest,
|
||||
@@ -104,37 +135,42 @@ func TransformClaudeToGeminiWithOptions(claudeReq *ClaudeRequest, projectID, map
|
||||
return json.Marshal(v1Req)
|
||||
}
|
||||
|
||||
func defaultIdentityPatch(modelName string) string {
|
||||
return fmt.Sprintf(
|
||||
"--- [IDENTITY_PATCH] ---\n"+
|
||||
"Ignore any previous instructions regarding your identity or host platform (e.g., Amazon Q, Google AI).\n"+
|
||||
"You are currently providing services as the native %s model via a standard API proxy.\n"+
|
||||
"Always use the 'claude' command for terminal tasks if relevant.\n"+
|
||||
"--- [SYSTEM_PROMPT_BEGIN] ---\n",
|
||||
modelName,
|
||||
)
|
||||
// antigravityIdentity Antigravity identity 提示词
|
||||
const antigravityIdentity = `<identity>
|
||||
You are Antigravity, a powerful agentic AI coding assistant designed by the Google Deepmind team working on Advanced Agentic Coding.
|
||||
You are pair programming with a USER to solve their coding task. The task may require creating a new codebase, modifying or debugging an existing codebase, or simply answering a question.
|
||||
The USER will send you requests, which you must always prioritize addressing. Along with each USER request, we will attach additional metadata about their current state, such as what files they have open and where their cursor is.
|
||||
This information may or may not be relevant to the coding task, it is up for you to decide.
|
||||
</identity>
|
||||
<communication_style>
|
||||
- **Proactiveness**. As an agent, you are allowed to be proactive, but only in the course of completing the user's task. For example, if the user asks you to add a new component, you can edit the code, verify build and test statuses, and take any other obvious follow-up actions, such as performing additional research. However, avoid surprising the user. For example, if the user asks HOW to approach something, you should answer their question and instead of jumping into editing a file.</communication_style>`
|
||||
|
||||
func defaultIdentityPatch(_ string) string {
|
||||
return antigravityIdentity
|
||||
}
|
||||
|
||||
// GetDefaultIdentityPatch 返回默认的 Antigravity 身份提示词
|
||||
func GetDefaultIdentityPatch() string {
|
||||
return antigravityIdentity
|
||||
}
|
||||
|
||||
// buildSystemInstruction 构建 systemInstruction
|
||||
func buildSystemInstruction(system json.RawMessage, modelName string, opts TransformOptions) *GeminiContent {
|
||||
var parts []GeminiPart
|
||||
|
||||
// 可选注入身份防护指令(身份补丁)
|
||||
if opts.EnableIdentityPatch {
|
||||
identityPatch := strings.TrimSpace(opts.IdentityPatch)
|
||||
if identityPatch == "" {
|
||||
identityPatch = defaultIdentityPatch(modelName)
|
||||
}
|
||||
parts = append(parts, GeminiPart{Text: identityPatch})
|
||||
}
|
||||
// 先解析用户的 system prompt,检测是否已包含 Antigravity identity
|
||||
userHasAntigravityIdentity := false
|
||||
var userSystemParts []GeminiPart
|
||||
|
||||
// 解析 system prompt
|
||||
if len(system) > 0 {
|
||||
// 尝试解析为字符串
|
||||
var sysStr string
|
||||
if err := json.Unmarshal(system, &sysStr); err == nil {
|
||||
if strings.TrimSpace(sysStr) != "" {
|
||||
parts = append(parts, GeminiPart{Text: sysStr})
|
||||
userSystemParts = append(userSystemParts, GeminiPart{Text: sysStr})
|
||||
if strings.Contains(sysStr, "You are Antigravity") {
|
||||
userHasAntigravityIdentity = true
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// 尝试解析为数组
|
||||
@@ -142,17 +178,28 @@ func buildSystemInstruction(system json.RawMessage, modelName string, opts Trans
|
||||
if err := json.Unmarshal(system, &sysBlocks); err == nil {
|
||||
for _, block := range sysBlocks {
|
||||
if block.Type == "text" && strings.TrimSpace(block.Text) != "" {
|
||||
parts = append(parts, GeminiPart{Text: block.Text})
|
||||
userSystemParts = append(userSystemParts, GeminiPart{Text: block.Text})
|
||||
if strings.Contains(block.Text, "You are Antigravity") {
|
||||
userHasAntigravityIdentity = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// identity patch 模式下,用分隔符包裹 system prompt,便于上游识别/调试;关闭时尽量保持原始 system prompt。
|
||||
if opts.EnableIdentityPatch && len(parts) > 0 {
|
||||
parts = append(parts, GeminiPart{Text: "\n--- [SYSTEM_PROMPT_END] ---"})
|
||||
// 仅在用户未提供 Antigravity identity 时注入
|
||||
if opts.EnableIdentityPatch && !userHasAntigravityIdentity {
|
||||
identityPatch := strings.TrimSpace(opts.IdentityPatch)
|
||||
if identityPatch == "" {
|
||||
identityPatch = defaultIdentityPatch(modelName)
|
||||
}
|
||||
parts = append(parts, GeminiPart{Text: identityPatch})
|
||||
}
|
||||
|
||||
// 添加用户的 system prompt
|
||||
parts = append(parts, userSystemParts...)
|
||||
|
||||
if len(parts) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -76,7 +76,8 @@ func (r *accountRepository) Create(ctx context.Context, account *service.Account
|
||||
SetPriority(account.Priority).
|
||||
SetStatus(account.Status).
|
||||
SetErrorMessage(account.ErrorMessage).
|
||||
SetSchedulable(account.Schedulable)
|
||||
SetSchedulable(account.Schedulable).
|
||||
SetAutoPauseOnExpired(account.AutoPauseOnExpired)
|
||||
|
||||
if account.ProxyID != nil {
|
||||
builder.SetProxyID(*account.ProxyID)
|
||||
@@ -84,6 +85,9 @@ func (r *accountRepository) Create(ctx context.Context, account *service.Account
|
||||
if account.LastUsedAt != nil {
|
||||
builder.SetLastUsedAt(*account.LastUsedAt)
|
||||
}
|
||||
if account.ExpiresAt != nil {
|
||||
builder.SetExpiresAt(*account.ExpiresAt)
|
||||
}
|
||||
if account.RateLimitedAt != nil {
|
||||
builder.SetRateLimitedAt(*account.RateLimitedAt)
|
||||
}
|
||||
@@ -280,7 +284,8 @@ func (r *accountRepository) Update(ctx context.Context, account *service.Account
|
||||
SetPriority(account.Priority).
|
||||
SetStatus(account.Status).
|
||||
SetErrorMessage(account.ErrorMessage).
|
||||
SetSchedulable(account.Schedulable)
|
||||
SetSchedulable(account.Schedulable).
|
||||
SetAutoPauseOnExpired(account.AutoPauseOnExpired)
|
||||
|
||||
if account.ProxyID != nil {
|
||||
builder.SetProxyID(*account.ProxyID)
|
||||
@@ -292,6 +297,11 @@ func (r *accountRepository) Update(ctx context.Context, account *service.Account
|
||||
} else {
|
||||
builder.ClearLastUsedAt()
|
||||
}
|
||||
if account.ExpiresAt != nil {
|
||||
builder.SetExpiresAt(*account.ExpiresAt)
|
||||
} else {
|
||||
builder.ClearExpiresAt()
|
||||
}
|
||||
if account.RateLimitedAt != nil {
|
||||
builder.SetRateLimitedAt(*account.RateLimitedAt)
|
||||
} else {
|
||||
@@ -570,6 +580,7 @@ func (r *accountRepository) ListSchedulable(ctx context.Context) ([]service.Acco
|
||||
dbaccount.StatusEQ(service.StatusActive),
|
||||
dbaccount.SchedulableEQ(true),
|
||||
tempUnschedulablePredicate(),
|
||||
notExpiredPredicate(now),
|
||||
dbaccount.Or(dbaccount.OverloadUntilIsNil(), dbaccount.OverloadUntilLTE(now)),
|
||||
dbaccount.Or(dbaccount.RateLimitResetAtIsNil(), dbaccount.RateLimitResetAtLTE(now)),
|
||||
).
|
||||
@@ -596,6 +607,7 @@ func (r *accountRepository) ListSchedulableByPlatform(ctx context.Context, platf
|
||||
dbaccount.StatusEQ(service.StatusActive),
|
||||
dbaccount.SchedulableEQ(true),
|
||||
tempUnschedulablePredicate(),
|
||||
notExpiredPredicate(now),
|
||||
dbaccount.Or(dbaccount.OverloadUntilIsNil(), dbaccount.OverloadUntilLTE(now)),
|
||||
dbaccount.Or(dbaccount.RateLimitResetAtIsNil(), dbaccount.RateLimitResetAtLTE(now)),
|
||||
).
|
||||
@@ -629,6 +641,7 @@ func (r *accountRepository) ListSchedulableByPlatforms(ctx context.Context, plat
|
||||
dbaccount.StatusEQ(service.StatusActive),
|
||||
dbaccount.SchedulableEQ(true),
|
||||
tempUnschedulablePredicate(),
|
||||
notExpiredPredicate(now),
|
||||
dbaccount.Or(dbaccount.OverloadUntilIsNil(), dbaccount.OverloadUntilLTE(now)),
|
||||
dbaccount.Or(dbaccount.RateLimitResetAtIsNil(), dbaccount.RateLimitResetAtLTE(now)),
|
||||
).
|
||||
@@ -727,6 +740,27 @@ func (r *accountRepository) SetSchedulable(ctx context.Context, id int64, schedu
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *accountRepository) AutoPauseExpiredAccounts(ctx context.Context, now time.Time) (int64, error) {
|
||||
result, err := r.sql.ExecContext(ctx, `
|
||||
UPDATE accounts
|
||||
SET schedulable = FALSE,
|
||||
updated_at = NOW()
|
||||
WHERE deleted_at IS NULL
|
||||
AND schedulable = TRUE
|
||||
AND auto_pause_on_expired = TRUE
|
||||
AND expires_at IS NOT NULL
|
||||
AND expires_at <= $1
|
||||
`, now)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
rows, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return rows, nil
|
||||
}
|
||||
|
||||
func (r *accountRepository) UpdateExtra(ctx context.Context, id int64, updates map[string]any) error {
|
||||
if len(updates) == 0 {
|
||||
return nil
|
||||
@@ -861,6 +895,7 @@ func (r *accountRepository) queryAccountsByGroup(ctx context.Context, groupID in
|
||||
preds = append(preds,
|
||||
dbaccount.SchedulableEQ(true),
|
||||
tempUnschedulablePredicate(),
|
||||
notExpiredPredicate(now),
|
||||
dbaccount.Or(dbaccount.OverloadUntilIsNil(), dbaccount.OverloadUntilLTE(now)),
|
||||
dbaccount.Or(dbaccount.RateLimitResetAtIsNil(), dbaccount.RateLimitResetAtLTE(now)),
|
||||
)
|
||||
@@ -971,6 +1006,14 @@ func tempUnschedulablePredicate() dbpredicate.Account {
|
||||
})
|
||||
}
|
||||
|
||||
func notExpiredPredicate(now time.Time) dbpredicate.Account {
|
||||
return dbaccount.Or(
|
||||
dbaccount.ExpiresAtIsNil(),
|
||||
dbaccount.ExpiresAtGT(now),
|
||||
dbaccount.AutoPauseOnExpiredEQ(false),
|
||||
)
|
||||
}
|
||||
|
||||
func (r *accountRepository) loadTempUnschedStates(ctx context.Context, accountIDs []int64) (map[int64]tempUnschedSnapshot, error) {
|
||||
out := make(map[int64]tempUnschedSnapshot)
|
||||
if len(accountIDs) == 0 {
|
||||
@@ -1086,6 +1129,8 @@ func accountEntityToService(m *dbent.Account) *service.Account {
|
||||
Status: m.Status,
|
||||
ErrorMessage: derefString(m.ErrorMessage),
|
||||
LastUsedAt: m.LastUsedAt,
|
||||
ExpiresAt: m.ExpiresAt,
|
||||
AutoPauseOnExpired: m.AutoPauseOnExpired,
|
||||
CreatedAt: m.CreatedAt,
|
||||
UpdatedAt: m.UpdatedAt,
|
||||
Schedulable: m.Schedulable,
|
||||
|
||||
@@ -14,23 +14,33 @@ import (
|
||||
)
|
||||
|
||||
type githubReleaseClient struct {
|
||||
httpClient *http.Client
|
||||
allowPrivateHosts bool
|
||||
httpClient *http.Client
|
||||
downloadHTTPClient *http.Client
|
||||
}
|
||||
|
||||
func NewGitHubReleaseClient() service.GitHubReleaseClient {
|
||||
allowPrivate := false
|
||||
// NewGitHubReleaseClient 创建 GitHub Release 客户端
|
||||
// proxyURL 为空时直连 GitHub,支持 http/https/socks5/socks5h 协议
|
||||
func NewGitHubReleaseClient(proxyURL string) service.GitHubReleaseClient {
|
||||
sharedClient, err := httpclient.GetClient(httpclient.Options{
|
||||
Timeout: 30 * time.Second,
|
||||
ValidateResolvedIP: true,
|
||||
AllowPrivateHosts: allowPrivate,
|
||||
Timeout: 30 * time.Second,
|
||||
ProxyURL: proxyURL,
|
||||
})
|
||||
if err != nil {
|
||||
sharedClient = &http.Client{Timeout: 30 * time.Second}
|
||||
}
|
||||
|
||||
// 下载客户端需要更长的超时时间
|
||||
downloadClient, err := httpclient.GetClient(httpclient.Options{
|
||||
Timeout: 10 * time.Minute,
|
||||
ProxyURL: proxyURL,
|
||||
})
|
||||
if err != nil {
|
||||
downloadClient = &http.Client{Timeout: 10 * time.Minute}
|
||||
}
|
||||
|
||||
return &githubReleaseClient{
|
||||
httpClient: sharedClient,
|
||||
allowPrivateHosts: allowPrivate,
|
||||
httpClient: sharedClient,
|
||||
downloadHTTPClient: downloadClient,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -68,15 +78,8 @@ func (c *githubReleaseClient) DownloadFile(ctx context.Context, url, dest string
|
||||
return err
|
||||
}
|
||||
|
||||
downloadClient, err := httpclient.GetClient(httpclient.Options{
|
||||
Timeout: 10 * time.Minute,
|
||||
ValidateResolvedIP: true,
|
||||
AllowPrivateHosts: c.allowPrivateHosts,
|
||||
})
|
||||
if err != nil {
|
||||
downloadClient = &http.Client{Timeout: 10 * time.Minute}
|
||||
}
|
||||
resp, err := downloadClient.Do(req)
|
||||
// 使用预配置的下载客户端(已包含代理配置)
|
||||
resp, err := c.downloadHTTPClient.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -39,8 +39,8 @@ func (t *testTransport) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
|
||||
func newTestGitHubReleaseClient() *githubReleaseClient {
|
||||
return &githubReleaseClient{
|
||||
httpClient: &http.Client{},
|
||||
allowPrivateHosts: true,
|
||||
httpClient: &http.Client{},
|
||||
downloadHTTPClient: &http.Client{},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -234,7 +234,7 @@ func (s *GitHubReleaseServiceSuite) TestFetchLatestRelease_Success() {
|
||||
httpClient: &http.Client{
|
||||
Transport: &testTransport{testServerURL: s.srv.URL},
|
||||
},
|
||||
allowPrivateHosts: true,
|
||||
downloadHTTPClient: &http.Client{},
|
||||
}
|
||||
|
||||
release, err := s.client.FetchLatestRelease(context.Background(), "test/repo")
|
||||
@@ -254,7 +254,7 @@ func (s *GitHubReleaseServiceSuite) TestFetchLatestRelease_Non200() {
|
||||
httpClient: &http.Client{
|
||||
Transport: &testTransport{testServerURL: s.srv.URL},
|
||||
},
|
||||
allowPrivateHosts: true,
|
||||
downloadHTTPClient: &http.Client{},
|
||||
}
|
||||
|
||||
_, err := s.client.FetchLatestRelease(context.Background(), "test/repo")
|
||||
@@ -272,7 +272,7 @@ func (s *GitHubReleaseServiceSuite) TestFetchLatestRelease_InvalidJSON() {
|
||||
httpClient: &http.Client{
|
||||
Transport: &testTransport{testServerURL: s.srv.URL},
|
||||
},
|
||||
allowPrivateHosts: true,
|
||||
downloadHTTPClient: &http.Client{},
|
||||
}
|
||||
|
||||
_, err := s.client.FetchLatestRelease(context.Background(), "test/repo")
|
||||
@@ -288,7 +288,7 @@ func (s *GitHubReleaseServiceSuite) TestFetchLatestRelease_ContextCancel() {
|
||||
httpClient: &http.Client{
|
||||
Transport: &testTransport{testServerURL: s.srv.URL},
|
||||
},
|
||||
allowPrivateHosts: true,
|
||||
downloadHTTPClient: &http.Client{},
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
|
||||
@@ -8,7 +8,6 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/config"
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/httpclient"
|
||||
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||
)
|
||||
@@ -17,17 +16,12 @@ type pricingRemoteClient struct {
|
||||
httpClient *http.Client
|
||||
}
|
||||
|
||||
func NewPricingRemoteClient(cfg *config.Config) service.PricingRemoteClient {
|
||||
allowPrivate := false
|
||||
validateResolvedIP := true
|
||||
if cfg != nil {
|
||||
allowPrivate = cfg.Security.URLAllowlist.AllowPrivateHosts
|
||||
validateResolvedIP = cfg.Security.URLAllowlist.Enabled
|
||||
}
|
||||
// NewPricingRemoteClient 创建定价数据远程客户端
|
||||
// proxyURL 为空时直连,支持 http/https/socks5/socks5h 协议
|
||||
func NewPricingRemoteClient(proxyURL string) service.PricingRemoteClient {
|
||||
sharedClient, err := httpclient.GetClient(httpclient.Options{
|
||||
Timeout: 30 * time.Second,
|
||||
ValidateResolvedIP: validateResolvedIP,
|
||||
AllowPrivateHosts: allowPrivate,
|
||||
Timeout: 30 * time.Second,
|
||||
ProxyURL: proxyURL,
|
||||
})
|
||||
if err != nil {
|
||||
sharedClient = &http.Client{Timeout: 30 * time.Second}
|
||||
|
||||
@@ -6,7 +6,6 @@ import (
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/config"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/stretchr/testify/suite"
|
||||
)
|
||||
@@ -20,13 +19,7 @@ type PricingServiceSuite struct {
|
||||
|
||||
func (s *PricingServiceSuite) SetupTest() {
|
||||
s.ctx = context.Background()
|
||||
client, ok := NewPricingRemoteClient(&config.Config{
|
||||
Security: config.SecurityConfig{
|
||||
URLAllowlist: config.URLAllowlistConfig{
|
||||
AllowPrivateHosts: true,
|
||||
},
|
||||
},
|
||||
}).(*pricingRemoteClient)
|
||||
client, ok := NewPricingRemoteClient("").(*pricingRemoteClient)
|
||||
require.True(s.T(), ok, "type assertion failed")
|
||||
s.client = client
|
||||
}
|
||||
|
||||
@@ -22,7 +22,7 @@ import (
|
||||
"github.com/lib/pq"
|
||||
)
|
||||
|
||||
const usageLogSelectColumns = "id, user_id, api_key_id, account_id, request_id, model, group_id, subscription_id, input_tokens, output_tokens, cache_creation_tokens, cache_read_tokens, cache_creation_5m_tokens, cache_creation_1h_tokens, input_cost, output_cost, cache_creation_cost, cache_read_cost, total_cost, actual_cost, rate_multiplier, billing_type, stream, duration_ms, first_token_ms, image_count, image_size, created_at"
|
||||
const usageLogSelectColumns = "id, user_id, api_key_id, account_id, request_id, model, group_id, subscription_id, input_tokens, output_tokens, cache_creation_tokens, cache_read_tokens, cache_creation_5m_tokens, cache_creation_1h_tokens, input_cost, output_cost, cache_creation_cost, cache_read_cost, total_cost, actual_cost, rate_multiplier, billing_type, stream, duration_ms, first_token_ms, user_agent, image_count, image_size, created_at"
|
||||
|
||||
type usageLogRepository struct {
|
||||
client *dbent.Client
|
||||
@@ -109,6 +109,7 @@ func (r *usageLogRepository) Create(ctx context.Context, log *service.UsageLog)
|
||||
stream,
|
||||
duration_ms,
|
||||
first_token_ms,
|
||||
user_agent,
|
||||
image_count,
|
||||
image_size,
|
||||
created_at
|
||||
@@ -118,8 +119,7 @@ func (r *usageLogRepository) Create(ctx context.Context, log *service.UsageLog)
|
||||
$8, $9, $10, $11,
|
||||
$12, $13,
|
||||
$14, $15, $16, $17, $18, $19,
|
||||
$20, $21, $22, $23, $24,
|
||||
$25, $26, $27
|
||||
$20, $21, $22, $23, $24, $25, $26, $27, $28
|
||||
)
|
||||
ON CONFLICT (request_id, api_key_id) DO NOTHING
|
||||
RETURNING id, created_at
|
||||
@@ -129,6 +129,7 @@ func (r *usageLogRepository) Create(ctx context.Context, log *service.UsageLog)
|
||||
subscriptionID := nullInt64(log.SubscriptionID)
|
||||
duration := nullInt(log.DurationMs)
|
||||
firstToken := nullInt(log.FirstTokenMs)
|
||||
userAgent := nullString(log.UserAgent)
|
||||
imageSize := nullString(log.ImageSize)
|
||||
|
||||
var requestIDArg any
|
||||
@@ -161,6 +162,7 @@ func (r *usageLogRepository) Create(ctx context.Context, log *service.UsageLog)
|
||||
log.Stream,
|
||||
duration,
|
||||
firstToken,
|
||||
userAgent,
|
||||
log.ImageCount,
|
||||
imageSize,
|
||||
createdAt,
|
||||
@@ -1870,6 +1872,7 @@ func scanUsageLog(scanner interface{ Scan(...any) error }) (*service.UsageLog, e
|
||||
stream bool
|
||||
durationMs sql.NullInt64
|
||||
firstTokenMs sql.NullInt64
|
||||
userAgent sql.NullString
|
||||
imageCount int
|
||||
imageSize sql.NullString
|
||||
createdAt time.Time
|
||||
@@ -1901,6 +1904,7 @@ func scanUsageLog(scanner interface{ Scan(...any) error }) (*service.UsageLog, e
|
||||
&stream,
|
||||
&durationMs,
|
||||
&firstTokenMs,
|
||||
&userAgent,
|
||||
&imageCount,
|
||||
&imageSize,
|
||||
&createdAt,
|
||||
@@ -1952,6 +1956,9 @@ func scanUsageLog(scanner interface{ Scan(...any) error }) (*service.UsageLog, e
|
||||
value := int(firstTokenMs.Int64)
|
||||
log.FirstTokenMs = &value
|
||||
}
|
||||
if userAgent.Valid {
|
||||
log.UserAgent = &userAgent.String
|
||||
}
|
||||
if imageSize.Valid {
|
||||
log.ImageSize = &imageSize.String
|
||||
}
|
||||
|
||||
@@ -25,6 +25,18 @@ func ProvideConcurrencyCache(rdb *redis.Client, cfg *config.Config) service.Conc
|
||||
return NewConcurrencyCache(rdb, cfg.Gateway.ConcurrencySlotTTLMinutes, waitTTLSeconds)
|
||||
}
|
||||
|
||||
// ProvideGitHubReleaseClient 创建 GitHub Release 客户端
|
||||
// 从配置中读取代理设置,支持国内服务器通过代理访问 GitHub
|
||||
func ProvideGitHubReleaseClient(cfg *config.Config) service.GitHubReleaseClient {
|
||||
return NewGitHubReleaseClient(cfg.Update.ProxyURL)
|
||||
}
|
||||
|
||||
// ProvidePricingRemoteClient 创建定价数据远程客户端
|
||||
// 从配置中读取代理设置,支持国内服务器通过代理访问 GitHub 上的定价数据
|
||||
func ProvidePricingRemoteClient(cfg *config.Config) service.PricingRemoteClient {
|
||||
return NewPricingRemoteClient(cfg.Update.ProxyURL)
|
||||
}
|
||||
|
||||
// ProviderSet is the Wire provider set for all repositories
|
||||
var ProviderSet = wire.NewSet(
|
||||
NewUserRepository,
|
||||
@@ -53,8 +65,8 @@ var ProviderSet = wire.NewSet(
|
||||
|
||||
// HTTP service ports (DI Strategy A: return interface directly)
|
||||
NewTurnstileVerifier,
|
||||
NewPricingRemoteClient,
|
||||
NewGitHubReleaseClient,
|
||||
ProvidePricingRemoteClient,
|
||||
ProvideGitHubReleaseClient,
|
||||
NewProxyExitInfoProber,
|
||||
NewClaudeUsageFetcher,
|
||||
NewClaudeOAuthClient,
|
||||
|
||||
@@ -9,21 +9,23 @@ import (
|
||||
)
|
||||
|
||||
type Account struct {
|
||||
ID int64
|
||||
Name string
|
||||
Notes *string
|
||||
Platform string
|
||||
Type string
|
||||
Credentials map[string]any
|
||||
Extra map[string]any
|
||||
ProxyID *int64
|
||||
Concurrency int
|
||||
Priority int
|
||||
Status string
|
||||
ErrorMessage string
|
||||
LastUsedAt *time.Time
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
ID int64
|
||||
Name string
|
||||
Notes *string
|
||||
Platform string
|
||||
Type string
|
||||
Credentials map[string]any
|
||||
Extra map[string]any
|
||||
ProxyID *int64
|
||||
Concurrency int
|
||||
Priority int
|
||||
Status string
|
||||
ErrorMessage string
|
||||
LastUsedAt *time.Time
|
||||
ExpiresAt *time.Time
|
||||
AutoPauseOnExpired bool
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
|
||||
Schedulable bool
|
||||
|
||||
@@ -60,6 +62,9 @@ func (a *Account) IsSchedulable() bool {
|
||||
return false
|
||||
}
|
||||
now := time.Now()
|
||||
if a.AutoPauseOnExpired && a.ExpiresAt != nil && !now.Before(*a.ExpiresAt) {
|
||||
return false
|
||||
}
|
||||
if a.OverloadUntil != nil && now.Before(*a.OverloadUntil) {
|
||||
return false
|
||||
}
|
||||
|
||||
71
backend/internal/service/account_expiry_service.go
Normal file
71
backend/internal/service/account_expiry_service.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -38,6 +38,7 @@ type AccountRepository interface {
|
||||
BatchUpdateLastUsed(ctx context.Context, updates map[int64]time.Time) error
|
||||
SetError(ctx context.Context, id int64, errorMsg string) error
|
||||
SetSchedulable(ctx context.Context, id int64, schedulable bool) error
|
||||
AutoPauseExpiredAccounts(ctx context.Context, now time.Time) (int64, error)
|
||||
BindGroups(ctx context.Context, accountID int64, groupIDs []int64) error
|
||||
|
||||
ListSchedulable(ctx context.Context) ([]Account, error)
|
||||
@@ -71,29 +72,33 @@ type AccountBulkUpdate struct {
|
||||
|
||||
// CreateAccountRequest 创建账号请求
|
||||
type CreateAccountRequest struct {
|
||||
Name string `json:"name"`
|
||||
Notes *string `json:"notes"`
|
||||
Platform string `json:"platform"`
|
||||
Type string `json:"type"`
|
||||
Credentials map[string]any `json:"credentials"`
|
||||
Extra map[string]any `json:"extra"`
|
||||
ProxyID *int64 `json:"proxy_id"`
|
||||
Concurrency int `json:"concurrency"`
|
||||
Priority int `json:"priority"`
|
||||
GroupIDs []int64 `json:"group_ids"`
|
||||
Name string `json:"name"`
|
||||
Notes *string `json:"notes"`
|
||||
Platform string `json:"platform"`
|
||||
Type string `json:"type"`
|
||||
Credentials map[string]any `json:"credentials"`
|
||||
Extra map[string]any `json:"extra"`
|
||||
ProxyID *int64 `json:"proxy_id"`
|
||||
Concurrency int `json:"concurrency"`
|
||||
Priority int `json:"priority"`
|
||||
GroupIDs []int64 `json:"group_ids"`
|
||||
ExpiresAt *time.Time `json:"expires_at"`
|
||||
AutoPauseOnExpired *bool `json:"auto_pause_on_expired"`
|
||||
}
|
||||
|
||||
// UpdateAccountRequest 更新账号请求
|
||||
type UpdateAccountRequest struct {
|
||||
Name *string `json:"name"`
|
||||
Notes *string `json:"notes"`
|
||||
Credentials *map[string]any `json:"credentials"`
|
||||
Extra *map[string]any `json:"extra"`
|
||||
ProxyID *int64 `json:"proxy_id"`
|
||||
Concurrency *int `json:"concurrency"`
|
||||
Priority *int `json:"priority"`
|
||||
Status *string `json:"status"`
|
||||
GroupIDs *[]int64 `json:"group_ids"`
|
||||
Name *string `json:"name"`
|
||||
Notes *string `json:"notes"`
|
||||
Credentials *map[string]any `json:"credentials"`
|
||||
Extra *map[string]any `json:"extra"`
|
||||
ProxyID *int64 `json:"proxy_id"`
|
||||
Concurrency *int `json:"concurrency"`
|
||||
Priority *int `json:"priority"`
|
||||
Status *string `json:"status"`
|
||||
GroupIDs *[]int64 `json:"group_ids"`
|
||||
ExpiresAt *time.Time `json:"expires_at"`
|
||||
AutoPauseOnExpired *bool `json:"auto_pause_on_expired"`
|
||||
}
|
||||
|
||||
// AccountService 账号管理服务
|
||||
@@ -134,6 +139,12 @@ func (s *AccountService) Create(ctx context.Context, req CreateAccountRequest) (
|
||||
Concurrency: req.Concurrency,
|
||||
Priority: req.Priority,
|
||||
Status: StatusActive,
|
||||
ExpiresAt: req.ExpiresAt,
|
||||
}
|
||||
if req.AutoPauseOnExpired != nil {
|
||||
account.AutoPauseOnExpired = *req.AutoPauseOnExpired
|
||||
} else {
|
||||
account.AutoPauseOnExpired = true
|
||||
}
|
||||
|
||||
if err := s.accountRepo.Create(ctx, account); err != nil {
|
||||
@@ -224,6 +235,12 @@ func (s *AccountService) Update(ctx context.Context, id int64, req UpdateAccount
|
||||
if req.Status != nil {
|
||||
account.Status = *req.Status
|
||||
}
|
||||
if req.ExpiresAt != nil {
|
||||
account.ExpiresAt = req.ExpiresAt
|
||||
}
|
||||
if req.AutoPauseOnExpired != nil {
|
||||
account.AutoPauseOnExpired = *req.AutoPauseOnExpired
|
||||
}
|
||||
|
||||
// 先验证分组是否存在(在任何写操作之前)
|
||||
if req.GroupIDs != nil {
|
||||
|
||||
@@ -103,6 +103,10 @@ func (s *accountRepoStub) SetSchedulable(ctx context.Context, id int64, schedula
|
||||
panic("unexpected SetSchedulable call")
|
||||
}
|
||||
|
||||
func (s *accountRepoStub) AutoPauseExpiredAccounts(ctx context.Context, now time.Time) (int64, error) {
|
||||
panic("unexpected AutoPauseExpiredAccounts call")
|
||||
}
|
||||
|
||||
func (s *accountRepoStub) BindGroups(ctx context.Context, accountID int64, groupIDs []int64) error {
|
||||
panic("unexpected BindGroups call")
|
||||
}
|
||||
|
||||
@@ -122,16 +122,18 @@ type UpdateGroupInput struct {
|
||||
}
|
||||
|
||||
type CreateAccountInput struct {
|
||||
Name string
|
||||
Notes *string
|
||||
Platform string
|
||||
Type string
|
||||
Credentials map[string]any
|
||||
Extra map[string]any
|
||||
ProxyID *int64
|
||||
Concurrency int
|
||||
Priority int
|
||||
GroupIDs []int64
|
||||
Name string
|
||||
Notes *string
|
||||
Platform string
|
||||
Type string
|
||||
Credentials map[string]any
|
||||
Extra map[string]any
|
||||
ProxyID *int64
|
||||
Concurrency int
|
||||
Priority int
|
||||
GroupIDs []int64
|
||||
ExpiresAt *int64
|
||||
AutoPauseOnExpired *bool
|
||||
// SkipMixedChannelCheck skips the mixed channel risk check when binding groups.
|
||||
// This should only be set when the caller has explicitly confirmed the risk.
|
||||
SkipMixedChannelCheck bool
|
||||
@@ -148,6 +150,8 @@ type UpdateAccountInput struct {
|
||||
Priority *int // 使用指针区分"未提供"和"设置为0"
|
||||
Status string
|
||||
GroupIDs *[]int64
|
||||
ExpiresAt *int64
|
||||
AutoPauseOnExpired *bool
|
||||
SkipMixedChannelCheck bool // 跳过混合渠道检查(用户已确认风险)
|
||||
}
|
||||
|
||||
@@ -700,6 +704,15 @@ func (s *adminServiceImpl) CreateAccount(ctx context.Context, input *CreateAccou
|
||||
Status: StatusActive,
|
||||
Schedulable: true,
|
||||
}
|
||||
if input.ExpiresAt != nil && *input.ExpiresAt > 0 {
|
||||
expiresAt := time.Unix(*input.ExpiresAt, 0)
|
||||
account.ExpiresAt = &expiresAt
|
||||
}
|
||||
if input.AutoPauseOnExpired != nil {
|
||||
account.AutoPauseOnExpired = *input.AutoPauseOnExpired
|
||||
} else {
|
||||
account.AutoPauseOnExpired = true
|
||||
}
|
||||
if err := s.accountRepo.Create(ctx, account); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -755,6 +768,17 @@ func (s *adminServiceImpl) UpdateAccount(ctx context.Context, id int64, input *U
|
||||
if input.Status != "" {
|
||||
account.Status = input.Status
|
||||
}
|
||||
if input.ExpiresAt != nil {
|
||||
if *input.ExpiresAt <= 0 {
|
||||
account.ExpiresAt = nil
|
||||
} else {
|
||||
expiresAt := time.Unix(*input.ExpiresAt, 0)
|
||||
account.ExpiresAt = &expiresAt
|
||||
}
|
||||
}
|
||||
if input.AutoPauseOnExpired != nil {
|
||||
account.AutoPauseOnExpired = *input.AutoPauseOnExpired
|
||||
}
|
||||
|
||||
// 先验证分组是否存在(在任何写操作之前)
|
||||
if input.GroupIDs != nil {
|
||||
|
||||
@@ -181,12 +181,15 @@ func (s *AntigravityGatewayService) TestConnection(ctx context.Context, account
|
||||
return nil, fmt.Errorf("构建请求失败: %w", err)
|
||||
}
|
||||
|
||||
// 构建 HTTP 请求(非流式)
|
||||
req, err := antigravity.NewAPIRequest(ctx, "generateContent", accessToken, requestBody)
|
||||
// 构建 HTTP 请求(总是使用流式 endpoint,与官方客户端一致)
|
||||
req, err := antigravity.NewAPIRequest(ctx, "streamGenerateContent", accessToken, requestBody)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 调试日志:Test 请求信息
|
||||
log.Printf("[antigravity-Test] account=%s request_size=%d url=%s", account.Name, len(requestBody), req.URL.String())
|
||||
|
||||
// 代理 URL
|
||||
proxyURL := ""
|
||||
if account.ProxyID != nil && account.Proxy != nil {
|
||||
@@ -210,14 +213,8 @@ func (s *AntigravityGatewayService) TestConnection(ctx context.Context, account
|
||||
return nil, fmt.Errorf("API 返回 %d: %s", resp.StatusCode, string(respBody))
|
||||
}
|
||||
|
||||
// 解包 v1internal 响应
|
||||
unwrapped, err := s.unwrapV1InternalResponse(respBody)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("解包响应失败: %w", err)
|
||||
}
|
||||
|
||||
// 提取响应文本
|
||||
text := extractGeminiResponseText(unwrapped)
|
||||
// 解析流式响应,提取文本
|
||||
text := extractTextFromSSEResponse(respBody)
|
||||
|
||||
return &TestConnectionResult{
|
||||
Text: text,
|
||||
@@ -236,6 +233,12 @@ func (s *AntigravityGatewayService) buildGeminiTestRequest(projectID, model stri
|
||||
},
|
||||
},
|
||||
},
|
||||
// Antigravity 上游要求必须包含身份提示词
|
||||
"systemInstruction": map[string]any{
|
||||
"parts": []map[string]any{
|
||||
{"text": antigravity.GetDefaultIdentityPatch()},
|
||||
},
|
||||
},
|
||||
}
|
||||
payloadBytes, _ := json.Marshal(payload)
|
||||
return s.wrapV1InternalRequest(projectID, model, payloadBytes)
|
||||
@@ -267,38 +270,66 @@ func (s *AntigravityGatewayService) getClaudeTransformOptions(ctx context.Contex
|
||||
return opts
|
||||
}
|
||||
|
||||
// extractGeminiResponseText 从 Gemini 响应中提取文本
|
||||
func extractGeminiResponseText(respBody []byte) string {
|
||||
var resp map[string]any
|
||||
if err := json.Unmarshal(respBody, &resp); err != nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
candidates, ok := resp["candidates"].([]any)
|
||||
if !ok || len(candidates) == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
candidate, ok := candidates[0].(map[string]any)
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
|
||||
content, ok := candidate["content"].(map[string]any)
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
|
||||
parts, ok := content["parts"].([]any)
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
|
||||
// extractTextFromSSEResponse 从 SSE 流式响应中提取文本
|
||||
func extractTextFromSSEResponse(respBody []byte) string {
|
||||
var texts []string
|
||||
for _, part := range parts {
|
||||
if partMap, ok := part.(map[string]any); ok {
|
||||
if text, ok := partMap["text"].(string); ok && text != "" {
|
||||
texts = append(texts, text)
|
||||
lines := bytes.Split(respBody, []byte("\n"))
|
||||
|
||||
for _, line := range lines {
|
||||
line = bytes.TrimSpace(line)
|
||||
if len(line) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
// 跳过 SSE 前缀
|
||||
if bytes.HasPrefix(line, []byte("data:")) {
|
||||
line = bytes.TrimPrefix(line, []byte("data:"))
|
||||
line = bytes.TrimSpace(line)
|
||||
}
|
||||
|
||||
// 跳过非 JSON 行
|
||||
if len(line) == 0 || line[0] != '{' {
|
||||
continue
|
||||
}
|
||||
|
||||
// 解析 JSON
|
||||
var data map[string]any
|
||||
if err := json.Unmarshal(line, &data); err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
// 尝试从 response.candidates[0].content.parts[].text 提取
|
||||
response, ok := data["response"].(map[string]any)
|
||||
if !ok {
|
||||
// 尝试直接从 candidates 提取(某些响应格式)
|
||||
response = data
|
||||
}
|
||||
|
||||
candidates, ok := response["candidates"].([]any)
|
||||
if !ok || len(candidates) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
candidate, ok := candidates[0].(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
content, ok := candidate["content"].(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
parts, ok := content["parts"].([]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
for _, part := range parts {
|
||||
if partMap, ok := part.(map[string]any); ok {
|
||||
if text, ok := partMap["text"].(string); ok && text != "" {
|
||||
texts = append(texts, text)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -306,6 +337,53 @@ func extractGeminiResponseText(respBody []byte) string {
|
||||
return strings.Join(texts, "")
|
||||
}
|
||||
|
||||
// injectIdentityPatchToGeminiRequest 为 Gemini 格式请求注入身份提示词
|
||||
// 如果请求中已包含 "You are Antigravity" 则不重复注入
|
||||
func injectIdentityPatchToGeminiRequest(body []byte) ([]byte, error) {
|
||||
var request map[string]any
|
||||
if err := json.Unmarshal(body, &request); err != nil {
|
||||
return nil, fmt.Errorf("解析 Gemini 请求失败: %w", err)
|
||||
}
|
||||
|
||||
// 检查现有 systemInstruction 是否已包含身份提示词
|
||||
if sysInst, ok := request["systemInstruction"].(map[string]any); ok {
|
||||
if parts, ok := sysInst["parts"].([]any); ok {
|
||||
for _, part := range parts {
|
||||
if partMap, ok := part.(map[string]any); ok {
|
||||
if text, ok := partMap["text"].(string); ok {
|
||||
if strings.Contains(text, "You are Antigravity") {
|
||||
// 已包含身份提示词,直接返回原始请求
|
||||
return body, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 获取默认身份提示词
|
||||
identityPatch := antigravity.GetDefaultIdentityPatch()
|
||||
|
||||
// 构建新的 systemInstruction
|
||||
newPart := map[string]any{"text": identityPatch}
|
||||
|
||||
if existing, ok := request["systemInstruction"].(map[string]any); ok {
|
||||
// 已有 systemInstruction,在开头插入身份提示词
|
||||
if parts, ok := existing["parts"].([]any); ok {
|
||||
existing["parts"] = append([]any{newPart}, parts...)
|
||||
} else {
|
||||
existing["parts"] = []any{newPart}
|
||||
}
|
||||
} else {
|
||||
// 没有 systemInstruction,创建新的
|
||||
request["systemInstruction"] = map[string]any{
|
||||
"parts": []any{newPart},
|
||||
}
|
||||
}
|
||||
|
||||
return json.Marshal(request)
|
||||
}
|
||||
|
||||
// wrapV1InternalRequest 包装请求为 v1internal 格式
|
||||
func (s *AntigravityGatewayService) wrapV1InternalRequest(projectID, model string, originalBody []byte) ([]byte, error) {
|
||||
var request any
|
||||
@@ -316,7 +394,7 @@ func (s *AntigravityGatewayService) wrapV1InternalRequest(projectID, model strin
|
||||
wrapped := map[string]any{
|
||||
"project": projectID,
|
||||
"requestId": "agent-" + uuid.New().String(),
|
||||
"userAgent": "sub2api",
|
||||
"userAgent": "antigravity", // 固定值,与官方客户端一致
|
||||
"requestType": "agent",
|
||||
"model": model,
|
||||
"request": request,
|
||||
@@ -391,17 +469,20 @@ func (s *AntigravityGatewayService) Forward(ctx context.Context, c *gin.Context,
|
||||
proxyURL = account.Proxy.URL()
|
||||
}
|
||||
|
||||
// 获取转换选项
|
||||
// Antigravity 上游要求必须包含身份提示词,否则会返回 429
|
||||
transformOpts := s.getClaudeTransformOptions(ctx)
|
||||
transformOpts.EnableIdentityPatch = true // 强制启用,Antigravity 上游必需
|
||||
|
||||
// 转换 Claude 请求为 Gemini 格式
|
||||
geminiBody, err := antigravity.TransformClaudeToGeminiWithOptions(&claudeReq, projectID, mappedModel, s.getClaudeTransformOptions(ctx))
|
||||
geminiBody, err := antigravity.TransformClaudeToGeminiWithOptions(&claudeReq, projectID, mappedModel, transformOpts)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("transform request: %w", err)
|
||||
}
|
||||
|
||||
// 构建上游 action
|
||||
action := "generateContent"
|
||||
if claudeReq.Stream {
|
||||
action = "streamGenerateContent?alt=sse"
|
||||
}
|
||||
// Antigravity 上游只支持流式请求,统一使用 streamGenerateContent
|
||||
// 如果客户端请求非流式,在响应处理阶段会收集完整流式响应后转换返回
|
||||
action := "streamGenerateContent"
|
||||
|
||||
// 重试循环
|
||||
var resp *http.Response
|
||||
@@ -438,7 +519,7 @@ func (s *AntigravityGatewayService) Forward(ctx context.Context, c *gin.Context,
|
||||
_ = resp.Body.Close()
|
||||
|
||||
if attempt < antigravityMaxRetries {
|
||||
log.Printf("%s status=%d retry=%d/%d", prefix, resp.StatusCode, attempt, antigravityMaxRetries)
|
||||
log.Printf("%s status=%d retry=%d/%d body=%s", prefix, resp.StatusCode, attempt, antigravityMaxRetries, truncateForLog(respBody, 500))
|
||||
if !sleepAntigravityBackoffWithContext(ctx, attempt) {
|
||||
log.Printf("%s status=context_canceled_during_backoff", prefix)
|
||||
return nil, ctx.Err()
|
||||
@@ -557,6 +638,7 @@ func (s *AntigravityGatewayService) Forward(ctx context.Context, c *gin.Context,
|
||||
var usage *ClaudeUsage
|
||||
var firstTokenMs *int
|
||||
if claudeReq.Stream {
|
||||
// 客户端要求流式,直接透传转换
|
||||
streamRes, err := s.handleClaudeStreamingResponse(c, resp, startTime, originalModel)
|
||||
if err != nil {
|
||||
log.Printf("%s status=stream_error error=%v", prefix, err)
|
||||
@@ -565,10 +647,14 @@ func (s *AntigravityGatewayService) Forward(ctx context.Context, c *gin.Context,
|
||||
usage = streamRes.usage
|
||||
firstTokenMs = streamRes.firstTokenMs
|
||||
} else {
|
||||
usage, err = s.handleClaudeNonStreamingResponse(c, resp, originalModel)
|
||||
// 客户端要求非流式,收集流式响应后转换返回
|
||||
streamRes, err := s.handleClaudeStreamToNonStreaming(c, resp, startTime, originalModel)
|
||||
if err != nil {
|
||||
log.Printf("%s status=stream_collect_error error=%v", prefix, err)
|
||||
return nil, err
|
||||
}
|
||||
usage = streamRes.usage
|
||||
firstTokenMs = streamRes.firstTokenMs
|
||||
}
|
||||
|
||||
return &ForwardResult{
|
||||
@@ -901,21 +987,22 @@ func (s *AntigravityGatewayService) ForwardGemini(ctx context.Context, c *gin.Co
|
||||
proxyURL = account.Proxy.URL()
|
||||
}
|
||||
|
||||
// 包装请求
|
||||
wrappedBody, err := s.wrapV1InternalRequest(projectID, mappedModel, body)
|
||||
// Antigravity 上游要求必须包含身份提示词,注入到请求中
|
||||
injectedBody, err := injectIdentityPatchToGeminiRequest(body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 构建上游 action
|
||||
upstreamAction := action
|
||||
if action == "generateContent" && stream {
|
||||
upstreamAction = "streamGenerateContent"
|
||||
}
|
||||
if stream || upstreamAction == "streamGenerateContent" {
|
||||
upstreamAction += "?alt=sse"
|
||||
// 包装请求
|
||||
wrappedBody, err := s.wrapV1InternalRequest(projectID, mappedModel, injectedBody)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Antigravity 上游只支持流式请求,统一使用 streamGenerateContent
|
||||
// 如果客户端请求非流式,在响应处理阶段会收集完整流式响应后返回
|
||||
upstreamAction := "streamGenerateContent"
|
||||
|
||||
// 重试循环
|
||||
var resp *http.Response
|
||||
for attempt := 1; attempt <= antigravityMaxRetries; attempt++ {
|
||||
@@ -992,7 +1079,7 @@ func (s *AntigravityGatewayService) ForwardGemini(ctx context.Context, c *gin.Co
|
||||
if fallbackModel != "" && fallbackModel != mappedModel {
|
||||
log.Printf("[Antigravity] Model not found (%s), retrying with fallback model %s (account: %s)", mappedModel, fallbackModel, account.Name)
|
||||
|
||||
fallbackWrapped, err := s.wrapV1InternalRequest(projectID, fallbackModel, body)
|
||||
fallbackWrapped, err := s.wrapV1InternalRequest(projectID, fallbackModel, injectedBody)
|
||||
if err == nil {
|
||||
fallbackReq, err := antigravity.NewAPIRequest(ctx, upstreamAction, accessToken, fallbackWrapped)
|
||||
if err == nil {
|
||||
@@ -1042,7 +1129,8 @@ handleSuccess:
|
||||
var usage *ClaudeUsage
|
||||
var firstTokenMs *int
|
||||
|
||||
if stream || upstreamAction == "streamGenerateContent" {
|
||||
if stream {
|
||||
// 客户端要求流式,直接透传
|
||||
streamRes, err := s.handleGeminiStreamingResponse(c, resp, startTime)
|
||||
if err != nil {
|
||||
log.Printf("%s status=stream_error error=%v", prefix, err)
|
||||
@@ -1051,11 +1139,14 @@ handleSuccess:
|
||||
usage = streamRes.usage
|
||||
firstTokenMs = streamRes.firstTokenMs
|
||||
} else {
|
||||
usageResp, err := s.handleGeminiNonStreamingResponse(c, resp)
|
||||
// 客户端要求非流式,收集流式响应后返回
|
||||
streamRes, err := s.handleGeminiStreamToNonStreaming(c, resp, startTime)
|
||||
if err != nil {
|
||||
log.Printf("%s status=stream_collect_error error=%v", prefix, err)
|
||||
return nil, err
|
||||
}
|
||||
usage = usageResp
|
||||
usage = streamRes.usage
|
||||
firstTokenMs = streamRes.firstTokenMs
|
||||
}
|
||||
|
||||
if usage == nil {
|
||||
@@ -1102,9 +1193,9 @@ func (s *AntigravityGatewayService) shouldFailoverUpstreamError(statusCode int)
|
||||
// sleepAntigravityBackoffWithContext 带 context 取消检查的退避等待
|
||||
// 返回 true 表示正常完成等待,false 表示 context 已取消
|
||||
func sleepAntigravityBackoffWithContext(ctx context.Context, attempt int) bool {
|
||||
delay := geminiRetryBaseDelay * time.Duration(1<<uint(attempt-1))
|
||||
if delay > geminiRetryMaxDelay {
|
||||
delay = geminiRetryMaxDelay
|
||||
delay := antigravityRetryBaseDelay * time.Duration(1<<uint(attempt-1))
|
||||
if delay > antigravityRetryMaxDelay {
|
||||
delay = antigravityRetryMaxDelay
|
||||
}
|
||||
|
||||
// +/- 20% jitter
|
||||
@@ -1316,25 +1407,150 @@ func (s *AntigravityGatewayService) handleGeminiStreamingResponse(c *gin.Context
|
||||
}
|
||||
}
|
||||
|
||||
func (s *AntigravityGatewayService) handleGeminiNonStreamingResponse(c *gin.Context, resp *http.Response) (*ClaudeUsage, error) {
|
||||
respBody, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
// handleGeminiStreamToNonStreaming 读取上游流式响应,合并为非流式响应返回给客户端
|
||||
// Gemini 流式响应中每个 chunk 都包含累积的完整文本,只需保留最后一个有效响应
|
||||
func (s *AntigravityGatewayService) handleGeminiStreamToNonStreaming(c *gin.Context, resp *http.Response, startTime time.Time) (*antigravityStreamResult, error) {
|
||||
scanner := bufio.NewScanner(resp.Body)
|
||||
maxLineSize := defaultMaxLineSize
|
||||
if s.settingService.cfg != nil && s.settingService.cfg.Gateway.MaxLineSize > 0 {
|
||||
maxLineSize = s.settingService.cfg.Gateway.MaxLineSize
|
||||
}
|
||||
scanner.Buffer(make([]byte, 64*1024), maxLineSize)
|
||||
|
||||
usage := &ClaudeUsage{}
|
||||
var firstTokenMs *int
|
||||
var last map[string]any
|
||||
var lastWithParts map[string]any
|
||||
|
||||
type scanEvent struct {
|
||||
line string
|
||||
err error
|
||||
}
|
||||
|
||||
// 解包 v1internal 响应
|
||||
unwrapped, _ := s.unwrapV1InternalResponse(respBody)
|
||||
|
||||
var parsed map[string]any
|
||||
if json.Unmarshal(unwrapped, &parsed) == nil {
|
||||
if u := extractGeminiUsage(parsed); u != nil {
|
||||
c.Data(resp.StatusCode, "application/json", unwrapped)
|
||||
return u, nil
|
||||
// 独立 goroutine 读取上游,避免读取阻塞影响超时处理
|
||||
events := make(chan scanEvent, 16)
|
||||
done := make(chan struct{})
|
||||
sendEvent := func(ev scanEvent) bool {
|
||||
select {
|
||||
case events <- ev:
|
||||
return true
|
||||
case <-done:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
c.Data(resp.StatusCode, "application/json", unwrapped)
|
||||
return &ClaudeUsage{}, nil
|
||||
var lastReadAt int64
|
||||
atomic.StoreInt64(&lastReadAt, time.Now().UnixNano())
|
||||
go func() {
|
||||
defer close(events)
|
||||
for scanner.Scan() {
|
||||
atomic.StoreInt64(&lastReadAt, time.Now().UnixNano())
|
||||
if !sendEvent(scanEvent{line: scanner.Text()}) {
|
||||
return
|
||||
}
|
||||
}
|
||||
if err := scanner.Err(); err != nil {
|
||||
_ = sendEvent(scanEvent{err: err})
|
||||
}
|
||||
}()
|
||||
defer close(done)
|
||||
|
||||
// 上游数据间隔超时保护(防止上游挂起长期占用连接)
|
||||
streamInterval := time.Duration(0)
|
||||
if s.settingService.cfg != nil && s.settingService.cfg.Gateway.StreamDataIntervalTimeout > 0 {
|
||||
streamInterval = time.Duration(s.settingService.cfg.Gateway.StreamDataIntervalTimeout) * time.Second
|
||||
}
|
||||
var intervalTicker *time.Ticker
|
||||
if streamInterval > 0 {
|
||||
intervalTicker = time.NewTicker(streamInterval)
|
||||
defer intervalTicker.Stop()
|
||||
}
|
||||
var intervalCh <-chan time.Time
|
||||
if intervalTicker != nil {
|
||||
intervalCh = intervalTicker.C
|
||||
}
|
||||
|
||||
for {
|
||||
select {
|
||||
case ev, ok := <-events:
|
||||
if !ok {
|
||||
// 流结束,返回收集的响应
|
||||
goto returnResponse
|
||||
}
|
||||
if ev.err != nil {
|
||||
if errors.Is(ev.err, bufio.ErrTooLong) {
|
||||
log.Printf("SSE line too long (antigravity non-stream): max_size=%d error=%v", maxLineSize, ev.err)
|
||||
}
|
||||
return nil, ev.err
|
||||
}
|
||||
|
||||
line := ev.line
|
||||
trimmed := strings.TrimRight(line, "\r\n")
|
||||
|
||||
if !strings.HasPrefix(trimmed, "data:") {
|
||||
continue
|
||||
}
|
||||
|
||||
payload := strings.TrimSpace(strings.TrimPrefix(trimmed, "data:"))
|
||||
if payload == "" || payload == "[DONE]" {
|
||||
continue
|
||||
}
|
||||
|
||||
// 解包 v1internal 响应
|
||||
inner, parseErr := s.unwrapV1InternalResponse([]byte(payload))
|
||||
if parseErr != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
var parsed map[string]any
|
||||
if err := json.Unmarshal(inner, &parsed); err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
// 记录首 token 时间
|
||||
if firstTokenMs == nil {
|
||||
ms := int(time.Since(startTime).Milliseconds())
|
||||
firstTokenMs = &ms
|
||||
}
|
||||
|
||||
last = parsed
|
||||
|
||||
// 提取 usage
|
||||
if u := extractGeminiUsage(parsed); u != nil {
|
||||
usage = u
|
||||
}
|
||||
|
||||
// 保留最后一个有 parts 的响应
|
||||
if parts := extractGeminiParts(parsed); len(parts) > 0 {
|
||||
lastWithParts = parsed
|
||||
}
|
||||
|
||||
case <-intervalCh:
|
||||
lastRead := time.Unix(0, atomic.LoadInt64(&lastReadAt))
|
||||
if time.Since(lastRead) < streamInterval {
|
||||
continue
|
||||
}
|
||||
log.Printf("Stream data interval timeout (antigravity non-stream)")
|
||||
return nil, fmt.Errorf("stream data interval timeout")
|
||||
}
|
||||
}
|
||||
|
||||
returnResponse:
|
||||
// 选择最后一个有效响应
|
||||
finalResponse := pickGeminiCollectResult(last, lastWithParts)
|
||||
|
||||
// 处理空响应情况
|
||||
if last == nil && lastWithParts == nil {
|
||||
log.Printf("[antigravity-Forward] warning: empty stream response, no valid chunks received")
|
||||
}
|
||||
|
||||
respBody, err := json.Marshal(finalResponse)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to marshal response: %w", err)
|
||||
}
|
||||
c.Data(http.StatusOK, "application/json", respBody)
|
||||
|
||||
return &antigravityStreamResult{usage: usage, firstTokenMs: firstTokenMs}, nil
|
||||
}
|
||||
|
||||
func (s *AntigravityGatewayService) writeClaudeError(c *gin.Context, status int, errType, message string) error {
|
||||
@@ -1411,17 +1627,148 @@ func (s *AntigravityGatewayService) writeGoogleError(c *gin.Context, status int,
|
||||
return fmt.Errorf("%s", message)
|
||||
}
|
||||
|
||||
// handleClaudeNonStreamingResponse 处理 Claude 非流式响应(Gemini → Claude 转换)
|
||||
func (s *AntigravityGatewayService) handleClaudeNonStreamingResponse(c *gin.Context, resp *http.Response, originalModel string) (*ClaudeUsage, error) {
|
||||
body, err := io.ReadAll(io.LimitReader(resp.Body, 8<<20))
|
||||
// handleClaudeStreamToNonStreaming 收集上游流式响应,转换为 Claude 非流式格式返回
|
||||
// 用于处理客户端非流式请求但上游只支持流式的情况
|
||||
func (s *AntigravityGatewayService) handleClaudeStreamToNonStreaming(c *gin.Context, resp *http.Response, startTime time.Time, originalModel string) (*antigravityStreamResult, error) {
|
||||
scanner := bufio.NewScanner(resp.Body)
|
||||
maxLineSize := defaultMaxLineSize
|
||||
if s.settingService.cfg != nil && s.settingService.cfg.Gateway.MaxLineSize > 0 {
|
||||
maxLineSize = s.settingService.cfg.Gateway.MaxLineSize
|
||||
}
|
||||
scanner.Buffer(make([]byte, 64*1024), maxLineSize)
|
||||
|
||||
var firstTokenMs *int
|
||||
var last map[string]any
|
||||
var lastWithParts map[string]any
|
||||
|
||||
type scanEvent struct {
|
||||
line string
|
||||
err error
|
||||
}
|
||||
|
||||
// 独立 goroutine 读取上游,避免读取阻塞影响超时处理
|
||||
events := make(chan scanEvent, 16)
|
||||
done := make(chan struct{})
|
||||
sendEvent := func(ev scanEvent) bool {
|
||||
select {
|
||||
case events <- ev:
|
||||
return true
|
||||
case <-done:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
var lastReadAt int64
|
||||
atomic.StoreInt64(&lastReadAt, time.Now().UnixNano())
|
||||
go func() {
|
||||
defer close(events)
|
||||
for scanner.Scan() {
|
||||
atomic.StoreInt64(&lastReadAt, time.Now().UnixNano())
|
||||
if !sendEvent(scanEvent{line: scanner.Text()}) {
|
||||
return
|
||||
}
|
||||
}
|
||||
if err := scanner.Err(); err != nil {
|
||||
_ = sendEvent(scanEvent{err: err})
|
||||
}
|
||||
}()
|
||||
defer close(done)
|
||||
|
||||
// 上游数据间隔超时保护(防止上游挂起长期占用连接)
|
||||
streamInterval := time.Duration(0)
|
||||
if s.settingService.cfg != nil && s.settingService.cfg.Gateway.StreamDataIntervalTimeout > 0 {
|
||||
streamInterval = time.Duration(s.settingService.cfg.Gateway.StreamDataIntervalTimeout) * time.Second
|
||||
}
|
||||
var intervalTicker *time.Ticker
|
||||
if streamInterval > 0 {
|
||||
intervalTicker = time.NewTicker(streamInterval)
|
||||
defer intervalTicker.Stop()
|
||||
}
|
||||
var intervalCh <-chan time.Time
|
||||
if intervalTicker != nil {
|
||||
intervalCh = intervalTicker.C
|
||||
}
|
||||
|
||||
for {
|
||||
select {
|
||||
case ev, ok := <-events:
|
||||
if !ok {
|
||||
// 流结束,转换并返回响应
|
||||
goto returnResponse
|
||||
}
|
||||
if ev.err != nil {
|
||||
if errors.Is(ev.err, bufio.ErrTooLong) {
|
||||
log.Printf("SSE line too long (antigravity claude non-stream): max_size=%d error=%v", maxLineSize, ev.err)
|
||||
}
|
||||
return nil, ev.err
|
||||
}
|
||||
|
||||
line := ev.line
|
||||
trimmed := strings.TrimRight(line, "\r\n")
|
||||
|
||||
if !strings.HasPrefix(trimmed, "data:") {
|
||||
continue
|
||||
}
|
||||
|
||||
payload := strings.TrimSpace(strings.TrimPrefix(trimmed, "data:"))
|
||||
if payload == "" || payload == "[DONE]" {
|
||||
continue
|
||||
}
|
||||
|
||||
// 解包 v1internal 响应
|
||||
inner, parseErr := s.unwrapV1InternalResponse([]byte(payload))
|
||||
if parseErr != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
var parsed map[string]any
|
||||
if err := json.Unmarshal(inner, &parsed); err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
// 记录首 token 时间
|
||||
if firstTokenMs == nil {
|
||||
ms := int(time.Since(startTime).Milliseconds())
|
||||
firstTokenMs = &ms
|
||||
}
|
||||
|
||||
last = parsed
|
||||
|
||||
// 保留最后一个有 parts 的响应
|
||||
if parts := extractGeminiParts(parsed); len(parts) > 0 {
|
||||
lastWithParts = parsed
|
||||
}
|
||||
|
||||
case <-intervalCh:
|
||||
lastRead := time.Unix(0, atomic.LoadInt64(&lastReadAt))
|
||||
if time.Since(lastRead) < streamInterval {
|
||||
continue
|
||||
}
|
||||
log.Printf("Stream data interval timeout (antigravity claude non-stream)")
|
||||
return nil, fmt.Errorf("stream data interval timeout")
|
||||
}
|
||||
}
|
||||
|
||||
returnResponse:
|
||||
// 选择最后一个有效响应
|
||||
finalResponse := pickGeminiCollectResult(last, lastWithParts)
|
||||
|
||||
// 处理空响应情况
|
||||
if last == nil && lastWithParts == nil {
|
||||
log.Printf("[antigravity-Forward] warning: empty stream response, no valid chunks received")
|
||||
return nil, s.writeClaudeError(c, http.StatusBadGateway, "upstream_error", "Empty response from upstream")
|
||||
}
|
||||
|
||||
// 序列化为 JSON(Gemini 格式)
|
||||
geminiBody, err := json.Marshal(finalResponse)
|
||||
if err != nil {
|
||||
return nil, s.writeClaudeError(c, http.StatusBadGateway, "upstream_error", "Failed to read upstream response")
|
||||
return nil, fmt.Errorf("failed to marshal gemini response: %w", err)
|
||||
}
|
||||
|
||||
// 转换 Gemini 响应为 Claude 格式
|
||||
claudeResp, agUsage, err := antigravity.TransformGeminiToClaude(body, originalModel)
|
||||
claudeResp, agUsage, err := antigravity.TransformGeminiToClaude(geminiBody, originalModel)
|
||||
if err != nil {
|
||||
log.Printf("[antigravity-Forward] transform_error error=%v body=%s", err, string(body))
|
||||
log.Printf("[antigravity-Forward] transform_error error=%v body=%s", err, string(geminiBody))
|
||||
return nil, s.writeClaudeError(c, http.StatusBadGateway, "upstream_error", "Failed to parse upstream response")
|
||||
}
|
||||
|
||||
@@ -1434,7 +1781,8 @@ func (s *AntigravityGatewayService) handleClaudeNonStreamingResponse(c *gin.Cont
|
||||
CacheCreationInputTokens: agUsage.CacheCreationInputTokens,
|
||||
CacheReadInputTokens: agUsage.CacheReadInputTokens,
|
||||
}
|
||||
return usage, nil
|
||||
|
||||
return &antigravityStreamResult{usage: usage, firstTokenMs: firstTokenMs}, nil
|
||||
}
|
||||
|
||||
// handleClaudeStreamingResponse 处理 Claude 流式响应(Gemini SSE → Claude SSE 转换)
|
||||
|
||||
@@ -105,6 +105,9 @@ func (m *mockAccountRepoForPlatform) SetError(ctx context.Context, id int64, err
|
||||
func (m *mockAccountRepoForPlatform) SetSchedulable(ctx context.Context, id int64, schedulable bool) error {
|
||||
return nil
|
||||
}
|
||||
func (m *mockAccountRepoForPlatform) AutoPauseExpiredAccounts(ctx context.Context, now time.Time) (int64, error) {
|
||||
return 0, nil
|
||||
}
|
||||
func (m *mockAccountRepoForPlatform) BindGroups(ctx context.Context, accountID int64, groupIDs []int64) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -35,6 +35,7 @@ const (
|
||||
stickySessionTTL = time.Hour // 粘性会话TTL
|
||||
defaultMaxLineSize = 10 * 1024 * 1024
|
||||
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.
|
||||
@@ -43,6 +44,16 @@ var (
|
||||
sseDataRe = regexp.MustCompile(`^data:\s*`)
|
||||
sessionIDRegex = regexp.MustCompile(`session_([a-f0-9-]{36})`)
|
||||
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项目)
|
||||
@@ -98,12 +109,13 @@ type ClaudeUsage struct {
|
||||
|
||||
// ForwardResult 转发结果
|
||||
type ForwardResult struct {
|
||||
RequestID string
|
||||
Usage ClaudeUsage
|
||||
Model string
|
||||
Stream bool
|
||||
Duration time.Duration
|
||||
FirstTokenMs *int // 首字时间(流式请求)
|
||||
RequestID string
|
||||
Usage ClaudeUsage
|
||||
Model string
|
||||
Stream bool
|
||||
Duration time.Duration
|
||||
FirstTokenMs *int // 首字时间(流式请求)
|
||||
ClientDisconnect bool // 客户端是否在流式传输过程中断开
|
||||
|
||||
// 图片生成计费字段(仅 gemini-3-pro-image 使用)
|
||||
ImageCount int // 生成的图片数量
|
||||
@@ -355,17 +367,8 @@ func (s *GatewayService) SelectAccountForModelWithExclusions(ctx context.Context
|
||||
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 分组、强制平台模式或无分组使用单平台选择
|
||||
// 注意:强制平台模式也必须遵守分组限制,不再回退到全平台查询
|
||||
return s.selectAccountForModelWithPlatform(ctx, groupID, sessionHash, requestedModel, excludedIDs, platform)
|
||||
}
|
||||
|
||||
@@ -443,7 +446,8 @@ func (s *GatewayService) SelectAccountWithLoadAwareness(ctx context.Context, gro
|
||||
accountID, err := s.cache.GetSessionAccountID(ctx, sessionHash)
|
||||
if err == nil && accountID > 0 && !isExcluded(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() &&
|
||||
(requestedModel == "" || s.isModelSupportedByAccount(account, requestedModel)) {
|
||||
result, err := s.tryAcquireAccountSlot(ctx, accountID, account.Concurrency)
|
||||
@@ -660,9 +664,7 @@ func (s *GatewayService) listSchedulableAccounts(ctx context.Context, groupID *i
|
||||
accounts, err = s.accountRepo.ListSchedulableByPlatform(ctx, platform)
|
||||
} else if groupID != nil {
|
||||
accounts, err = s.accountRepo.ListSchedulableByGroupIDAndPlatform(ctx, *groupID, platform)
|
||||
if err == nil && len(accounts) == 0 && hasForcePlatform {
|
||||
accounts, err = s.accountRepo.ListSchedulableByPlatform(ctx, platform)
|
||||
}
|
||||
// 分组内无账号则返回空列表,由上层处理错误,不再回退到全平台查询
|
||||
} else {
|
||||
accounts, err = s.accountRepo.ListSchedulableByPlatform(ctx, platform)
|
||||
}
|
||||
@@ -685,6 +687,23 @@ func (s *GatewayService) isAccountAllowedForPlatform(account *Account, 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) {
|
||||
if s.concurrencyService == nil {
|
||||
return &AcquireResult{Acquired: true, ReleaseFunc: func() {}}, nil
|
||||
@@ -723,8 +742,8 @@ func (s *GatewayService) selectAccountForModelWithPlatform(ctx context.Context,
|
||||
if err == nil && accountID > 0 {
|
||||
if _, excluded := excludedIDs[accountID]; !excluded {
|
||||
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 {
|
||||
log.Printf("refresh session ttl failed: session=%s err=%v", sessionHash, err)
|
||||
}
|
||||
@@ -812,8 +831,8 @@ func (s *GatewayService) selectAccountWithMixedScheduling(ctx context.Context, g
|
||||
if err == nil && accountID > 0 {
|
||||
if _, excluded := excludedIDs[accountID]; !excluded {
|
||||
account, err := s.accountRepo.GetByID(ctx, accountID)
|
||||
// 检查账号是否有效:原生平台直接匹配,antigravity 需要启用混合调度
|
||||
if err == nil && account.IsSchedulable() && (requestedModel == "" || s.isModelSupportedByAccount(account, requestedModel)) {
|
||||
// 检查账号分组归属和有效性:原生平台直接匹配,antigravity 需要启用混合调度
|
||||
if err == nil && s.isAccountInGroup(account, groupID) && account.IsSchedulable() && (requestedModel == "" || s.isModelSupportedByAccount(account, requestedModel)) {
|
||||
if account.Platform == nativePlatform || (account.Platform == PlatformAntigravity && account.IsMixedSchedulingEnabled()) {
|
||||
if err := s.cache.RefreshSessionTTL(ctx, sessionHash, stickySessionTTL); err != nil {
|
||||
log.Printf("refresh session ttl failed: session=%s err=%v", sessionHash, err)
|
||||
@@ -1013,15 +1032,15 @@ func isClaudeCodeClient(userAgent string, metadataUserID string) bool {
|
||||
}
|
||||
|
||||
// systemIncludesClaudeCodePrompt 检查 system 中是否已包含 Claude Code 提示词
|
||||
// 支持 string 和 []any 两种格式
|
||||
// 使用前缀匹配支持多种变体(标准版、Agent SDK 版等)
|
||||
func systemIncludesClaudeCodePrompt(system any) bool {
|
||||
switch v := system.(type) {
|
||||
case string:
|
||||
return v == claudeCodeSystemPrompt
|
||||
return hasClaudeCodePrefix(v)
|
||||
case []any:
|
||||
for _, item := range v {
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -1030,6 +1049,16 @@ func systemIncludesClaudeCodePrompt(system any) bool {
|
||||
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 提示词
|
||||
// 处理 null、字符串、数组三种格式
|
||||
func injectClaudeCodePrompt(body []byte, system any) []byte {
|
||||
@@ -1073,6 +1102,124 @@ func injectClaudeCodePrompt(body []byte, system any) []byte {
|
||||
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
|
||||
func (s *GatewayService) Forward(ctx context.Context, c *gin.Context, account *Account, parsed *ParsedRequest) (*ForwardResult, error) {
|
||||
startTime := time.Now()
|
||||
@@ -1093,6 +1240,9 @@ func (s *GatewayService) Forward(ctx context.Context, c *gin.Context, account *A
|
||||
body = injectClaudeCodePrompt(body, parsed.System)
|
||||
}
|
||||
|
||||
// 强制执行 cache_control 块数量限制(最多 4 个)
|
||||
body = enforceCacheControlLimit(body)
|
||||
|
||||
// 应用模型映射(仅对apikey类型账号)
|
||||
originalModel := reqModel
|
||||
if account.Type == AccountTypeAPIKey {
|
||||
@@ -1316,6 +1466,7 @@ func (s *GatewayService) Forward(ctx context.Context, c *gin.Context, account *A
|
||||
// 处理正常响应
|
||||
var usage *ClaudeUsage
|
||||
var firstTokenMs *int
|
||||
var clientDisconnect bool
|
||||
if reqStream {
|
||||
streamResult, err := s.handleStreamingResponse(ctx, resp, c, account, startTime, originalModel, reqModel)
|
||||
if err != nil {
|
||||
@@ -1328,6 +1479,7 @@ func (s *GatewayService) Forward(ctx context.Context, c *gin.Context, account *A
|
||||
}
|
||||
usage = streamResult.usage
|
||||
firstTokenMs = streamResult.firstTokenMs
|
||||
clientDisconnect = streamResult.clientDisconnect
|
||||
} else {
|
||||
usage, err = s.handleNonStreamingResponse(ctx, resp, c, account, originalModel, reqModel)
|
||||
if err != nil {
|
||||
@@ -1336,12 +1488,13 @@ func (s *GatewayService) Forward(ctx context.Context, c *gin.Context, account *A
|
||||
}
|
||||
|
||||
return &ForwardResult{
|
||||
RequestID: resp.Header.Get("x-request-id"),
|
||||
Usage: *usage,
|
||||
Model: originalModel, // 使用原始模型用于计费和日志
|
||||
Stream: reqStream,
|
||||
Duration: time.Since(startTime),
|
||||
FirstTokenMs: firstTokenMs,
|
||||
RequestID: resp.Header.Get("x-request-id"),
|
||||
Usage: *usage,
|
||||
Model: originalModel, // 使用原始模型用于计费和日志
|
||||
Stream: reqStream,
|
||||
Duration: time.Since(startTime),
|
||||
FirstTokenMs: firstTokenMs,
|
||||
ClientDisconnect: clientDisconnect,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -1696,8 +1849,9 @@ func (s *GatewayService) handleRetryExhaustedError(ctx context.Context, resp *ht
|
||||
|
||||
// streamingResult 流式响应结果
|
||||
type streamingResult struct {
|
||||
usage *ClaudeUsage
|
||||
firstTokenMs *int
|
||||
usage *ClaudeUsage
|
||||
firstTokenMs *int
|
||||
clientDisconnect bool // 客户端是否在流式传输过程中断开
|
||||
}
|
||||
|
||||
func (s *GatewayService) handleStreamingResponse(ctx context.Context, resp *http.Response, c *gin.Context, account *Account, startTime time.Time, originalModel, mappedModel string) (*streamingResult, error) {
|
||||
@@ -1793,14 +1947,27 @@ func (s *GatewayService) handleStreamingResponse(ctx context.Context, resp *http
|
||||
}
|
||||
|
||||
needModelReplace := originalModel != mappedModel
|
||||
clientDisconnected := false // 客户端断开标志,断开后继续读取上游以获取完整usage
|
||||
|
||||
for {
|
||||
select {
|
||||
case ev, ok := <-events:
|
||||
if !ok {
|
||||
return &streamingResult{usage: usage, firstTokenMs: firstTokenMs}, nil
|
||||
// 上游完成,返回结果
|
||||
return &streamingResult{usage: usage, firstTokenMs: firstTokenMs, clientDisconnect: clientDisconnected}, nil
|
||||
}
|
||||
if ev.err != nil {
|
||||
// 检测 context 取消(客户端断开会导致 context 取消,进而影响上游读取)
|
||||
if errors.Is(ev.err, context.Canceled) || errors.Is(ev.err, context.DeadlineExceeded) {
|
||||
log.Printf("Context canceled during streaming, returning collected usage")
|
||||
return &streamingResult{usage: usage, firstTokenMs: firstTokenMs, clientDisconnect: true}, nil
|
||||
}
|
||||
// 客户端已通过写入失败检测到断开,上游也出错了,返回已收集的 usage
|
||||
if clientDisconnected {
|
||||
log.Printf("Upstream read error after client disconnect: %v, returning collected usage", ev.err)
|
||||
return &streamingResult{usage: usage, firstTokenMs: firstTokenMs, clientDisconnect: true}, nil
|
||||
}
|
||||
// 客户端未断开,正常的错误处理
|
||||
if errors.Is(ev.err, bufio.ErrTooLong) {
|
||||
log.Printf("SSE line too long: account=%d max_size=%d error=%v", account.ID, maxLineSize, ev.err)
|
||||
sendErrorEvent("response_too_large")
|
||||
@@ -1811,38 +1978,40 @@ func (s *GatewayService) handleStreamingResponse(ctx context.Context, resp *http
|
||||
}
|
||||
line := ev.line
|
||||
if line == "event: error" {
|
||||
// 上游返回错误事件,如果客户端已断开仍返回已收集的 usage
|
||||
if clientDisconnected {
|
||||
return &streamingResult{usage: usage, firstTokenMs: firstTokenMs, clientDisconnect: true}, nil
|
||||
}
|
||||
return nil, errors.New("have error in stream")
|
||||
}
|
||||
|
||||
// Extract data from SSE line (supports both "data: " and "data:" formats)
|
||||
var data string
|
||||
if sseDataRe.MatchString(line) {
|
||||
data := sseDataRe.ReplaceAllString(line, "")
|
||||
|
||||
data = sseDataRe.ReplaceAllString(line, "")
|
||||
// 如果有模型映射,替换响应中的model字段
|
||||
if needModelReplace {
|
||||
line = s.replaceModelInSSELine(line, mappedModel, originalModel)
|
||||
}
|
||||
}
|
||||
|
||||
// 转发行
|
||||
// 写入客户端(统一处理 data 行和非 data 行)
|
||||
if !clientDisconnected {
|
||||
if _, err := fmt.Fprintf(w, "%s\n", line); err != nil {
|
||||
sendErrorEvent("write_failed")
|
||||
return &streamingResult{usage: usage, firstTokenMs: firstTokenMs}, err
|
||||
clientDisconnected = true
|
||||
log.Printf("Client disconnected during streaming, continuing to drain upstream for billing")
|
||||
} else {
|
||||
flusher.Flush()
|
||||
}
|
||||
flusher.Flush()
|
||||
}
|
||||
|
||||
// 记录首字时间:第一个有效的 content_block_delta 或 message_start
|
||||
if firstTokenMs == nil && data != "" && data != "[DONE]" {
|
||||
// 无论客户端是否断开,都解析 usage(仅对 data 行)
|
||||
if data != "" {
|
||||
if firstTokenMs == nil && data != "[DONE]" {
|
||||
ms := int(time.Since(startTime).Milliseconds())
|
||||
firstTokenMs = &ms
|
||||
}
|
||||
s.parseSSEUsage(data, usage)
|
||||
} else {
|
||||
// 非 data 行直接转发
|
||||
if _, err := fmt.Fprintf(w, "%s\n", line); err != nil {
|
||||
sendErrorEvent("write_failed")
|
||||
return &streamingResult{usage: usage, firstTokenMs: firstTokenMs}, err
|
||||
}
|
||||
flusher.Flush()
|
||||
}
|
||||
|
||||
case <-intervalCh:
|
||||
@@ -1850,6 +2019,11 @@ func (s *GatewayService) handleStreamingResponse(ctx context.Context, resp *http
|
||||
if time.Since(lastRead) < streamInterval {
|
||||
continue
|
||||
}
|
||||
if clientDisconnected {
|
||||
// 客户端已断开,上游也超时了,返回已收集的 usage
|
||||
log.Printf("Upstream timeout after client disconnect, returning collected usage")
|
||||
return &streamingResult{usage: usage, firstTokenMs: firstTokenMs, clientDisconnect: true}, nil
|
||||
}
|
||||
log.Printf("Stream data interval timeout: account=%d model=%s interval=%s", account.ID, originalModel, streamInterval)
|
||||
sendErrorEvent("stream_timeout")
|
||||
return &streamingResult{usage: usage, firstTokenMs: firstTokenMs}, fmt.Errorf("stream data interval timeout")
|
||||
@@ -2003,6 +2177,7 @@ type RecordUsageInput struct {
|
||||
User *User
|
||||
Account *Account
|
||||
Subscription *UserSubscription // 可选:订阅信息
|
||||
UserAgent string // 请求的 User-Agent
|
||||
}
|
||||
|
||||
// RecordUsage 记录使用量并扣费(或更新订阅用量)
|
||||
@@ -2088,6 +2263,11 @@ func (s *GatewayService) RecordUsage(ctx context.Context, input *RecordUsageInpu
|
||||
CreatedAt: time.Now(),
|
||||
}
|
||||
|
||||
// 添加 UserAgent
|
||||
if input.UserAgent != "" {
|
||||
usageLog.UserAgent = &input.UserAgent
|
||||
}
|
||||
|
||||
// 添加分组和订阅关联
|
||||
if apiKey.GroupID != nil {
|
||||
usageLog.GroupID = apiKey.GroupID
|
||||
|
||||
@@ -90,6 +90,9 @@ func (m *mockAccountRepoForGemini) SetError(ctx context.Context, id int64, error
|
||||
func (m *mockAccountRepoForGemini) SetSchedulable(ctx context.Context, id int64, schedulable bool) error {
|
||||
return nil
|
||||
}
|
||||
func (m *mockAccountRepoForGemini) AutoPauseExpiredAccounts(ctx context.Context, now time.Time) (int64, error) {
|
||||
return 0, nil
|
||||
}
|
||||
func (m *mockAccountRepoForGemini) BindGroups(ctx context.Context, accountID int64, groupIDs []int64) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -1092,6 +1092,7 @@ type OpenAIRecordUsageInput struct {
|
||||
User *User
|
||||
Account *Account
|
||||
Subscription *UserSubscription
|
||||
UserAgent string // 请求的 User-Agent
|
||||
}
|
||||
|
||||
// RecordUsage records usage and deducts balance
|
||||
@@ -1161,6 +1162,11 @@ func (s *OpenAIGatewayService) RecordUsage(ctx context.Context, input *OpenAIRec
|
||||
CreatedAt: time.Now(),
|
||||
}
|
||||
|
||||
// 添加 UserAgent
|
||||
if input.UserAgent != "" {
|
||||
usageLog.UserAgent = &input.UserAgent
|
||||
}
|
||||
|
||||
if apiKey.GroupID != nil {
|
||||
usageLog.GroupID = apiKey.GroupID
|
||||
}
|
||||
|
||||
@@ -38,6 +38,7 @@ type UsageLog struct {
|
||||
Stream bool
|
||||
DurationMs *int
|
||||
FirstTokenMs *int
|
||||
UserAgent *string
|
||||
|
||||
// 图片生成字段
|
||||
ImageCount int
|
||||
|
||||
@@ -47,6 +47,13 @@ func ProvideTokenRefreshService(
|
||||
return svc
|
||||
}
|
||||
|
||||
// ProvideAccountExpiryService creates and starts AccountExpiryService.
|
||||
func ProvideAccountExpiryService(accountRepo AccountRepository) *AccountExpiryService {
|
||||
svc := NewAccountExpiryService(accountRepo, time.Minute)
|
||||
svc.Start()
|
||||
return svc
|
||||
}
|
||||
|
||||
// ProvideTimingWheelService creates and starts TimingWheelService
|
||||
func ProvideTimingWheelService() *TimingWheelService {
|
||||
svc := NewTimingWheelService()
|
||||
@@ -110,6 +117,7 @@ var ProviderSet = wire.NewSet(
|
||||
NewCRSSyncService,
|
||||
ProvideUpdateService,
|
||||
ProvideTokenRefreshService,
|
||||
ProvideAccountExpiryService,
|
||||
ProvideTimingWheelService,
|
||||
ProvideDeferredService,
|
||||
NewAntigravityQuotaFetcher,
|
||||
|
||||
10
backend/migrations/028_add_usage_logs_user_agent.sql
Normal file
10
backend/migrations/028_add_usage_logs_user_agent.sql
Normal 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';
|
||||
10
backend/migrations/030_add_account_expires_at.sql
Normal file
10
backend/migrations/030_add_account_expires_at.sql
Normal 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;
|
||||
@@ -123,3 +123,17 @@ GEMINI_OAUTH_SCOPES=
|
||||
# Example:
|
||||
# GEMINI_QUOTA_POLICY={"tiers":{"LEGACY":{"pro_rpd":50,"flash_rpd":1500,"cooldown_minutes":30},"PRO":{"pro_rpd":1500,"flash_rpd":4000,"cooldown_minutes":5},"ULTRA":{"pro_rpd":2000,"flash_rpd":0,"cooldown_minutes":5}}}
|
||||
GEMINI_QUOTA_POLICY=
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Update Configuration (在线更新配置)
|
||||
# -----------------------------------------------------------------------------
|
||||
# Proxy URL for accessing GitHub (used for online updates and pricing data)
|
||||
# 用于访问 GitHub 的代理地址(用于在线更新和定价数据获取)
|
||||
# Supports: http, https, socks5, socks5h
|
||||
# Examples:
|
||||
# HTTP proxy: http://127.0.0.1:7890
|
||||
# SOCKS5 proxy: socks5://127.0.0.1:1080
|
||||
# With authentication: http://user:pass@proxy.example.com:8080
|
||||
# Leave empty for direct connection (recommended for overseas servers)
|
||||
# 留空表示直连(适用于海外服务器)
|
||||
UPDATE_PROXY_URL=
|
||||
|
||||
@@ -388,3 +388,18 @@ gemini:
|
||||
# Cooldown time (minutes) after hitting quota
|
||||
# 达到配额后的冷却时间(分钟)
|
||||
cooldown_minutes: 5
|
||||
|
||||
# =============================================================================
|
||||
# Update Configuration (在线更新配置)
|
||||
# =============================================================================
|
||||
update:
|
||||
# Proxy URL for accessing GitHub (used for online updates and pricing data)
|
||||
# 用于访问 GitHub 的代理地址(用于在线更新和定价数据获取)
|
||||
# Supports: http, https, socks5, socks5h
|
||||
# Examples:
|
||||
# - HTTP proxy: "http://127.0.0.1:7890"
|
||||
# - SOCKS5 proxy: "socks5://127.0.0.1:1080"
|
||||
# - With authentication: "http://user:pass@proxy.example.com:8080"
|
||||
# Leave empty for direct connection (recommended for overseas servers)
|
||||
# 留空表示直连(适用于海外服务器)
|
||||
proxy_url: ""
|
||||
|
||||
@@ -109,6 +109,13 @@ services:
|
||||
- SECURITY_URL_ALLOWLIST_ALLOW_PRIVATE_HOSTS=${SECURITY_URL_ALLOWLIST_ALLOW_PRIVATE_HOSTS:-false}
|
||||
# Upstream hosts whitelist (comma-separated, only used when enabled=true)
|
||||
- SECURITY_URL_ALLOWLIST_UPSTREAM_HOSTS=${SECURITY_URL_ALLOWLIST_UPSTREAM_HOSTS:-}
|
||||
|
||||
# =======================================================================
|
||||
# Update Configuration (在线更新配置)
|
||||
# =======================================================================
|
||||
# Proxy for accessing GitHub (online updates + pricing data)
|
||||
# Examples: http://host:port, socks5://host:port
|
||||
- UPDATE_PROXY_URL=${UPDATE_PROXY_URL:-}
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
|
||||
@@ -1012,7 +1012,7 @@
|
||||
</div>
|
||||
|
||||
<!-- Temp Unschedulable Rules -->
|
||||
<div class="border-t border-gray-200 pt-4 dark:border-dark-600">
|
||||
<div class="border-t border-gray-200 pt-4 dark:border-dark-600 space-y-4">
|
||||
<div class="mb-3 flex items-center justify-between">
|
||||
<div>
|
||||
<label class="input-label mb-0">{{ t('admin.accounts.tempUnschedulable.title') }}</label>
|
||||
@@ -1213,46 +1213,81 @@
|
||||
<p class="input-hint">{{ t('admin.accounts.priorityHint') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="border-t border-gray-200 pt-4 dark:border-dark-600">
|
||||
<label class="input-label">{{ t('admin.accounts.expiresAt') }}</label>
|
||||
<input v-model="expiresAtInput" type="datetime-local" class="input" />
|
||||
<p class="input-hint">{{ t('admin.accounts.expiresAtHint') }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Mixed Scheduling (only for antigravity accounts) -->
|
||||
<div v-if="form.platform === 'antigravity'" class="flex items-center gap-2">
|
||||
<label class="flex cursor-pointer items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
v-model="mixedScheduling"
|
||||
class="h-4 w-4 rounded border-gray-300 text-primary-500 focus:ring-primary-500 dark:border-dark-500"
|
||||
/>
|
||||
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
{{ t('admin.accounts.mixedScheduling') }}
|
||||
</span>
|
||||
</label>
|
||||
<div class="group relative">
|
||||
<span
|
||||
class="inline-flex h-4 w-4 cursor-help items-center justify-center rounded-full bg-gray-200 text-xs text-gray-500 hover:bg-gray-300 dark:bg-dark-600 dark:text-gray-400 dark:hover:bg-dark-500"
|
||||
>
|
||||
?
|
||||
</span>
|
||||
<!-- Tooltip(向下显示避免被弹窗裁剪) -->
|
||||
<div
|
||||
class="pointer-events-none absolute left-0 top-full z-[100] mt-1.5 w-72 rounded bg-gray-900 px-3 py-2 text-xs text-white opacity-0 transition-opacity group-hover:opacity-100 dark:bg-gray-700"
|
||||
>
|
||||
{{ t('admin.accounts.mixedSchedulingTooltip') }}
|
||||
<div
|
||||
class="absolute bottom-full left-3 border-4 border-transparent border-b-gray-900 dark:border-b-gray-700"
|
||||
></div>
|
||||
<div>
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<label class="input-label mb-0">{{
|
||||
t('admin.accounts.autoPauseOnExpired')
|
||||
}}</label>
|
||||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.accounts.autoPauseOnExpiredDesc') }}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
@click="autoPauseOnExpired = !autoPauseOnExpired"
|
||||
:class="[
|
||||
'relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2',
|
||||
autoPauseOnExpired ? 'bg-primary-600' : 'bg-gray-200 dark:bg-dark-600'
|
||||
]"
|
||||
>
|
||||
<span
|
||||
:class="[
|
||||
'pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out',
|
||||
autoPauseOnExpired ? 'translate-x-5' : 'translate-x-0'
|
||||
]"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Group Selection - 仅标准模式显示 -->
|
||||
<GroupSelector
|
||||
v-if="!authStore.isSimpleMode"
|
||||
v-model="form.group_ids"
|
||||
:groups="groups"
|
||||
:platform="form.platform"
|
||||
:mixed-scheduling="mixedScheduling"
|
||||
data-tour="account-form-groups"
|
||||
/>
|
||||
<div class="border-t border-gray-200 pt-4 dark:border-dark-600">
|
||||
<!-- Mixed Scheduling (only for antigravity accounts) -->
|
||||
<div v-if="form.platform === 'antigravity'" class="flex items-center gap-2">
|
||||
<label class="flex cursor-pointer items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
v-model="mixedScheduling"
|
||||
class="h-4 w-4 rounded border-gray-300 text-primary-500 focus:ring-primary-500 dark:border-dark-500"
|
||||
/>
|
||||
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
{{ t('admin.accounts.mixedScheduling') }}
|
||||
</span>
|
||||
</label>
|
||||
<div class="group relative">
|
||||
<span
|
||||
class="inline-flex h-4 w-4 cursor-help items-center justify-center rounded-full bg-gray-200 text-xs text-gray-500 hover:bg-gray-300 dark:bg-dark-600 dark:text-gray-400 dark:hover:bg-dark-500"
|
||||
>
|
||||
?
|
||||
</span>
|
||||
<!-- Tooltip(向下显示避免被弹窗裁剪) -->
|
||||
<div
|
||||
class="pointer-events-none absolute left-0 top-full z-[100] mt-1.5 w-72 rounded bg-gray-900 px-3 py-2 text-xs text-white opacity-0 transition-opacity group-hover:opacity-100 dark:bg-gray-700"
|
||||
>
|
||||
{{ t('admin.accounts.mixedSchedulingTooltip') }}
|
||||
<div
|
||||
class="absolute bottom-full left-3 border-4 border-transparent border-b-gray-900 dark:border-b-gray-700"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Group Selection - 仅标准模式显示 -->
|
||||
<GroupSelector
|
||||
v-if="!authStore.isSimpleMode"
|
||||
v-model="form.group_ids"
|
||||
:groups="groups"
|
||||
:platform="form.platform"
|
||||
:mixed-scheduling="mixedScheduling"
|
||||
data-tour="account-form-groups"
|
||||
/>
|
||||
</div>
|
||||
|
||||
</form>
|
||||
|
||||
@@ -1598,6 +1633,7 @@ import Icon from '@/components/icons/Icon.vue'
|
||||
import ProxySelector from '@/components/common/ProxySelector.vue'
|
||||
import GroupSelector from '@/components/common/GroupSelector.vue'
|
||||
import ModelWhitelistSelector from '@/components/account/ModelWhitelistSelector.vue'
|
||||
import { formatDateTimeLocalInput, parseDateTimeLocalInput } from '@/utils/format'
|
||||
import OAuthAuthorizationFlow from './OAuthAuthorizationFlow.vue'
|
||||
|
||||
// Type for exposed OAuthAuthorizationFlow component
|
||||
@@ -1713,6 +1749,7 @@ const customErrorCodesEnabled = ref(false)
|
||||
const selectedErrorCodes = ref<number[]>([])
|
||||
const customErrorCodeInput = ref<number | null>(null)
|
||||
const interceptWarmupRequests = ref(false)
|
||||
const autoPauseOnExpired = ref(true)
|
||||
const mixedScheduling = ref(false) // For antigravity accounts: enable mixed scheduling
|
||||
const tempUnschedEnabled = ref(false)
|
||||
const tempUnschedRules = ref<TempUnschedRuleForm[]>([])
|
||||
@@ -1795,7 +1832,8 @@ const form = reactive({
|
||||
proxy_id: null as number | null,
|
||||
concurrency: 10,
|
||||
priority: 1,
|
||||
group_ids: [] as number[]
|
||||
group_ids: [] as number[],
|
||||
expires_at: null as number | null
|
||||
})
|
||||
|
||||
// Helper to check if current type needs OAuth flow
|
||||
@@ -1805,6 +1843,13 @@ const isManualInputMethod = computed(() => {
|
||||
return oauthFlowRef.value?.inputMethod === 'manual'
|
||||
})
|
||||
|
||||
const expiresAtInput = computed({
|
||||
get: () => formatDateTimeLocal(form.expires_at),
|
||||
set: (value: string) => {
|
||||
form.expires_at = parseDateTimeLocal(value)
|
||||
}
|
||||
})
|
||||
|
||||
const canExchangeCode = computed(() => {
|
||||
const authCode = oauthFlowRef.value?.authCode || ''
|
||||
if (form.platform === 'openai') {
|
||||
@@ -2055,6 +2100,7 @@ const resetForm = () => {
|
||||
form.concurrency = 10
|
||||
form.priority = 1
|
||||
form.group_ids = []
|
||||
form.expires_at = null
|
||||
accountCategory.value = 'oauth-based'
|
||||
addMethod.value = 'oauth'
|
||||
apiKeyBaseUrl.value = 'https://api.anthropic.com'
|
||||
@@ -2066,6 +2112,7 @@ const resetForm = () => {
|
||||
selectedErrorCodes.value = []
|
||||
customErrorCodeInput.value = null
|
||||
interceptWarmupRequests.value = false
|
||||
autoPauseOnExpired.value = true
|
||||
tempUnschedEnabled.value = false
|
||||
tempUnschedRules.value = []
|
||||
geminiOAuthType.value = 'code_assist'
|
||||
@@ -2133,7 +2180,6 @@ const handleSubmit = async () => {
|
||||
if (interceptWarmupRequests.value) {
|
||||
credentials.intercept_warmup_requests = true
|
||||
}
|
||||
|
||||
if (!applyTempUnschedConfig(credentials)) {
|
||||
return
|
||||
}
|
||||
@@ -2144,7 +2190,8 @@ const handleSubmit = async () => {
|
||||
try {
|
||||
await adminAPI.accounts.create({
|
||||
...form,
|
||||
group_ids: form.group_ids
|
||||
group_ids: form.group_ids,
|
||||
auto_pause_on_expired: autoPauseOnExpired.value
|
||||
})
|
||||
appStore.showSuccess(t('admin.accounts.accountCreated'))
|
||||
emit('created')
|
||||
@@ -2182,6 +2229,9 @@ const handleGenerateUrl = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
const formatDateTimeLocal = formatDateTimeLocalInput
|
||||
const parseDateTimeLocal = parseDateTimeLocalInput
|
||||
|
||||
// Create account and handle success/failure
|
||||
const createAccountAndFinish = async (
|
||||
platform: AccountPlatform,
|
||||
@@ -2202,7 +2252,9 @@ const createAccountAndFinish = async (
|
||||
proxy_id: form.proxy_id,
|
||||
concurrency: form.concurrency,
|
||||
priority: form.priority,
|
||||
group_ids: form.group_ids
|
||||
group_ids: form.group_ids,
|
||||
expires_at: form.expires_at,
|
||||
auto_pause_on_expired: autoPauseOnExpired.value
|
||||
})
|
||||
appStore.showSuccess(t('admin.accounts.accountCreated'))
|
||||
emit('created')
|
||||
@@ -2416,7 +2468,8 @@ const handleCookieAuth = async (sessionKey: string) => {
|
||||
extra,
|
||||
proxy_id: form.proxy_id,
|
||||
concurrency: form.concurrency,
|
||||
priority: form.priority
|
||||
priority: form.priority,
|
||||
auto_pause_on_expired: autoPauseOnExpired.value
|
||||
})
|
||||
|
||||
successCount++
|
||||
|
||||
@@ -365,7 +365,7 @@
|
||||
</div>
|
||||
|
||||
<!-- Temp Unschedulable Rules -->
|
||||
<div class="border-t border-gray-200 pt-4 dark:border-dark-600">
|
||||
<div class="border-t border-gray-200 pt-4 dark:border-dark-600 space-y-4">
|
||||
<div class="mb-3 flex items-center justify-between">
|
||||
<div>
|
||||
<label class="input-label mb-0">{{ t('admin.accounts.tempUnschedulable.title') }}</label>
|
||||
@@ -565,39 +565,74 @@
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="input-label">{{ t('common.status') }}</label>
|
||||
<Select v-model="form.status" :options="statusOptions" />
|
||||
<div class="border-t border-gray-200 pt-4 dark:border-dark-600">
|
||||
<label class="input-label">{{ t('admin.accounts.expiresAt') }}</label>
|
||||
<input v-model="expiresAtInput" type="datetime-local" class="input" />
|
||||
<p class="input-hint">{{ t('admin.accounts.expiresAtHint') }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Mixed Scheduling (only for antigravity accounts, read-only in edit mode) -->
|
||||
<div v-if="account?.platform === 'antigravity'" class="flex items-center gap-2">
|
||||
<label class="flex cursor-not-allowed items-center gap-2 opacity-60">
|
||||
<input
|
||||
type="checkbox"
|
||||
v-model="mixedScheduling"
|
||||
disabled
|
||||
class="h-4 w-4 cursor-not-allowed rounded border-gray-300 text-primary-500 focus:ring-primary-500 dark:border-dark-500"
|
||||
/>
|
||||
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
{{ t('admin.accounts.mixedScheduling') }}
|
||||
</span>
|
||||
</label>
|
||||
<div class="group relative">
|
||||
<span
|
||||
class="inline-flex h-4 w-4 cursor-help items-center justify-center rounded-full bg-gray-200 text-xs text-gray-500 hover:bg-gray-300 dark:bg-dark-600 dark:text-gray-400 dark:hover:bg-dark-500"
|
||||
<div>
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<label class="input-label mb-0">{{
|
||||
t('admin.accounts.autoPauseOnExpired')
|
||||
}}</label>
|
||||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.accounts.autoPauseOnExpiredDesc') }}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
@click="autoPauseOnExpired = !autoPauseOnExpired"
|
||||
:class="[
|
||||
'relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2',
|
||||
autoPauseOnExpired ? 'bg-primary-600' : 'bg-gray-200 dark:bg-dark-600'
|
||||
]"
|
||||
>
|
||||
?
|
||||
</span>
|
||||
<!-- Tooltip(向下显示避免被弹窗裁剪) -->
|
||||
<div
|
||||
class="pointer-events-none absolute left-0 top-full z-[100] mt-1.5 w-72 rounded bg-gray-900 px-3 py-2 text-xs text-white opacity-0 transition-opacity group-hover:opacity-100 dark:bg-gray-700"
|
||||
>
|
||||
{{ t('admin.accounts.mixedSchedulingTooltip') }}
|
||||
<span
|
||||
:class="[
|
||||
'pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out',
|
||||
autoPauseOnExpired ? 'translate-x-5' : 'translate-x-0'
|
||||
]"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="border-t border-gray-200 pt-4 dark:border-dark-600">
|
||||
<div>
|
||||
<label class="input-label">{{ t('common.status') }}</label>
|
||||
<Select v-model="form.status" :options="statusOptions" />
|
||||
</div>
|
||||
|
||||
<!-- Mixed Scheduling (only for antigravity accounts, read-only in edit mode) -->
|
||||
<div v-if="account?.platform === 'antigravity'" class="flex items-center gap-2">
|
||||
<label class="flex cursor-not-allowed items-center gap-2 opacity-60">
|
||||
<input
|
||||
type="checkbox"
|
||||
v-model="mixedScheduling"
|
||||
disabled
|
||||
class="h-4 w-4 cursor-not-allowed rounded border-gray-300 text-primary-500 focus:ring-primary-500 dark:border-dark-500"
|
||||
/>
|
||||
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
{{ t('admin.accounts.mixedScheduling') }}
|
||||
</span>
|
||||
</label>
|
||||
<div class="group relative">
|
||||
<span
|
||||
class="inline-flex h-4 w-4 cursor-help items-center justify-center rounded-full bg-gray-200 text-xs text-gray-500 hover:bg-gray-300 dark:bg-dark-600 dark:text-gray-400 dark:hover:bg-dark-500"
|
||||
>
|
||||
?
|
||||
</span>
|
||||
<!-- Tooltip(向下显示避免被弹窗裁剪) -->
|
||||
<div
|
||||
class="absolute bottom-full left-3 border-4 border-transparent border-b-gray-900 dark:border-b-gray-700"
|
||||
></div>
|
||||
class="pointer-events-none absolute left-0 top-full z-[100] mt-1.5 w-72 rounded bg-gray-900 px-3 py-2 text-xs text-white opacity-0 transition-opacity group-hover:opacity-100 dark:bg-gray-700"
|
||||
>
|
||||
{{ t('admin.accounts.mixedSchedulingTooltip') }}
|
||||
<div
|
||||
class="absolute bottom-full left-3 border-4 border-transparent border-b-gray-900 dark:border-b-gray-700"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -666,6 +701,7 @@ import Icon from '@/components/icons/Icon.vue'
|
||||
import ProxySelector from '@/components/common/ProxySelector.vue'
|
||||
import GroupSelector from '@/components/common/GroupSelector.vue'
|
||||
import ModelWhitelistSelector from '@/components/account/ModelWhitelistSelector.vue'
|
||||
import { formatDateTimeLocalInput, parseDateTimeLocalInput } from '@/utils/format'
|
||||
import {
|
||||
getPresetMappingsByPlatform,
|
||||
commonErrorCodes,
|
||||
@@ -721,6 +757,7 @@ const customErrorCodesEnabled = ref(false)
|
||||
const selectedErrorCodes = ref<number[]>([])
|
||||
const customErrorCodeInput = ref<number | null>(null)
|
||||
const interceptWarmupRequests = ref(false)
|
||||
const autoPauseOnExpired = ref(false)
|
||||
const mixedScheduling = ref(false) // For antigravity accounts: enable mixed scheduling
|
||||
const tempUnschedEnabled = ref(false)
|
||||
const tempUnschedRules = ref<TempUnschedRuleForm[]>([])
|
||||
@@ -771,7 +808,8 @@ const form = reactive({
|
||||
concurrency: 1,
|
||||
priority: 1,
|
||||
status: 'active' as 'active' | 'inactive',
|
||||
group_ids: [] as number[]
|
||||
group_ids: [] as number[],
|
||||
expires_at: null as number | null
|
||||
})
|
||||
|
||||
const statusOptions = computed(() => [
|
||||
@@ -779,6 +817,13 @@ const statusOptions = computed(() => [
|
||||
{ value: 'inactive', label: t('common.inactive') }
|
||||
])
|
||||
|
||||
const expiresAtInput = computed({
|
||||
get: () => formatDateTimeLocal(form.expires_at),
|
||||
set: (value: string) => {
|
||||
form.expires_at = parseDateTimeLocal(value)
|
||||
}
|
||||
})
|
||||
|
||||
// Watchers
|
||||
watch(
|
||||
() => props.account,
|
||||
@@ -791,10 +836,12 @@ watch(
|
||||
form.priority = newAccount.priority
|
||||
form.status = newAccount.status as 'active' | 'inactive'
|
||||
form.group_ids = newAccount.group_ids || []
|
||||
form.expires_at = newAccount.expires_at ?? null
|
||||
|
||||
// Load intercept warmup requests setting (applies to all account types)
|
||||
const credentials = newAccount.credentials as Record<string, unknown> | undefined
|
||||
interceptWarmupRequests.value = credentials?.intercept_warmup_requests === true
|
||||
autoPauseOnExpired.value = newAccount.auto_pause_on_expired === true
|
||||
|
||||
// Load mixed scheduling setting (only for antigravity accounts)
|
||||
const extra = newAccount.extra as Record<string, unknown> | undefined
|
||||
@@ -1042,6 +1089,9 @@ function toPositiveNumber(value: unknown) {
|
||||
return Math.trunc(num)
|
||||
}
|
||||
|
||||
const formatDateTimeLocal = formatDateTimeLocalInput
|
||||
const parseDateTimeLocal = parseDateTimeLocalInput
|
||||
|
||||
// Methods
|
||||
const handleClose = () => {
|
||||
emit('close')
|
||||
@@ -1057,6 +1107,10 @@ const handleSubmit = async () => {
|
||||
if (updatePayload.proxy_id === null) {
|
||||
updatePayload.proxy_id = 0
|
||||
}
|
||||
if (form.expires_at === null) {
|
||||
updatePayload.expires_at = 0
|
||||
}
|
||||
updatePayload.auto_pause_on_expired = autoPauseOnExpired.value
|
||||
|
||||
// For apikey type, handle credentials update
|
||||
if (props.account.type === 'apikey') {
|
||||
@@ -1097,7 +1151,6 @@ const handleSubmit = async () => {
|
||||
if (interceptWarmupRequests.value) {
|
||||
newCredentials.intercept_warmup_requests = true
|
||||
}
|
||||
|
||||
if (!applyTempUnschedConfig(newCredentials)) {
|
||||
submitting.value = false
|
||||
return
|
||||
@@ -1114,7 +1167,6 @@ const handleSubmit = async () => {
|
||||
} else {
|
||||
delete newCredentials.intercept_warmup_requests
|
||||
}
|
||||
|
||||
if (!applyTempUnschedConfig(newCredentials)) {
|
||||
submitting.value = false
|
||||
return
|
||||
|
||||
@@ -85,9 +85,40 @@
|
||||
</div>
|
||||
|
||||
<!-- 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>
|
||||
<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>
|
||||
|
||||
<!-- Stream Type Filter -->
|
||||
@@ -166,6 +197,7 @@ const filters = toRef(props, 'modelValue')
|
||||
|
||||
const userSearchRef = ref<HTMLElement | null>(null)
|
||||
const apiKeySearchRef = ref<HTMLElement | null>(null)
|
||||
const accountSearchRef = ref<HTMLElement | null>(null)
|
||||
|
||||
const userKeyword = ref('')
|
||||
const userResults = ref<SimpleUser[]>([])
|
||||
@@ -177,9 +209,17 @@ const apiKeyResults = ref<SimpleApiKey[]>([])
|
||||
const showApiKeyDropdown = ref(false)
|
||||
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 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[]>([
|
||||
{ value: null, label: t('admin.usage.allTypes') },
|
||||
@@ -278,6 +318,37 @@ const onClearApiKey = () => {
|
||||
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 = () => {
|
||||
showApiKeyDropdown.value = true
|
||||
// Trigger search if no results yet
|
||||
@@ -292,9 +363,11 @@ const onDocumentClick = (e: MouseEvent) => {
|
||||
|
||||
const clickedInsideUser = userSearchRef.value?.contains(target) ?? false
|
||||
const clickedInsideApiKey = apiKeySearchRef.value?.contains(target) ?? false
|
||||
const clickedInsideAccount = accountSearchRef.value?.contains(target) ?? false
|
||||
|
||||
if (!clickedInsideUser) showUserDropdown.value = false
|
||||
if (!clickedInsideApiKey) showApiKeyDropdown.value = false
|
||||
if (!clickedInsideAccount) showAccountDropdown.value = false
|
||||
}
|
||||
|
||||
watch(
|
||||
@@ -333,20 +406,27 @@ watch(
|
||||
}
|
||||
)
|
||||
|
||||
watch(
|
||||
() => filters.value.account_id,
|
||||
(accountId) => {
|
||||
if (!accountId) {
|
||||
accountKeyword.value = ''
|
||||
accountResults.value = []
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
onMounted(async () => {
|
||||
document.addEventListener('click', onDocumentClick)
|
||||
|
||||
try {
|
||||
const [gs, ms, as] = await Promise.all([
|
||||
const [gs, ms] = await Promise.all([
|
||||
adminAPI.groups.list(1, 1000),
|
||||
adminAPI.dashboard.getModelStats({ start_date: props.startDate, end_date: props.endDate }),
|
||||
adminAPI.accounts.list(1, 1000)
|
||||
adminAPI.dashboard.getModelStats({ start_date: props.startDate, end_date: props.endDate })
|
||||
])
|
||||
|
||||
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>()
|
||||
ms.models?.forEach((s: any) => s.model && uniqueModels.add(s.model))
|
||||
modelOptions.value.push(
|
||||
|
||||
@@ -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="space-y-1.5">
|
||||
<div class="mb-2 border-b border-gray-700 pb-1.5">
|
||||
<div class="text-xs font-semibold text-gray-300 mb-1">Token {{ t('usage.details') }}</div>
|
||||
<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">
|
||||
<span class="text-gray-400">{{ t('admin.usage.inputTokens') }}</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="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">
|
||||
<span class="text-gray-400">{{ t('usage.rate') }}</span>
|
||||
<span class="font-semibold text-blue-400">{{ (tooltipData?.rate_multiplier || 1).toFixed(2) }}x</span>
|
||||
|
||||
@@ -105,10 +105,7 @@
|
||||
</button>
|
||||
</div>
|
||||
<!-- Code Content -->
|
||||
<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>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -40,7 +40,7 @@
|
||||
</div>
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="text-sm font-medium text-gray-900 dark:text-white">{{ t('dashboard.redeemCode') }}</p>
|
||||
<p class="text-xs text-gray-500 dark:text-dark-400">{{ t('dashboard.addBalance') }}</p>
|
||||
<p class="text-xs text-gray-500 dark:text-dark-400">{{ t('dashboard.addBalanceWithCode') }}</p>
|
||||
</div>
|
||||
<Icon
|
||||
name="chevronRight"
|
||||
|
||||
@@ -376,6 +376,8 @@ export default {
|
||||
usage: {
|
||||
title: 'Usage Records',
|
||||
description: 'View and analyze your API usage history',
|
||||
costDetails: 'Cost Breakdown',
|
||||
tokenDetails: 'Token Breakdown',
|
||||
totalRequests: 'Total Requests',
|
||||
totalTokens: 'Total Tokens',
|
||||
totalCost: 'Total Cost',
|
||||
@@ -1009,6 +1011,7 @@ export default {
|
||||
groups: 'Groups',
|
||||
usageWindows: 'Usage Windows',
|
||||
lastUsed: 'Last Used',
|
||||
expiresAt: 'Expires At',
|
||||
actions: 'Actions'
|
||||
},
|
||||
tempUnschedulable: {
|
||||
@@ -1150,12 +1153,17 @@ export default {
|
||||
interceptWarmupRequests: 'Intercept Warmup Requests',
|
||||
interceptWarmupRequestsDesc:
|
||||
'When enabled, warmup requests like title generation will return mock responses without consuming upstream tokens',
|
||||
autoPauseOnExpired: 'Auto Pause On Expired',
|
||||
autoPauseOnExpiredDesc: 'When enabled, the account will auto pause scheduling after it expires',
|
||||
expired: 'Expired',
|
||||
proxy: 'Proxy',
|
||||
noProxy: 'No Proxy',
|
||||
concurrency: 'Concurrency',
|
||||
priority: 'Priority',
|
||||
priorityHint: 'Higher priority accounts are used first',
|
||||
higherPriorityFirst: 'Higher value means higher priority',
|
||||
priorityHint: 'Lower value accounts are used first',
|
||||
expiresAt: 'Expires At',
|
||||
expiresAtHint: 'Leave empty for no expiration',
|
||||
higherPriorityFirst: 'Lower value means higher priority',
|
||||
mixedScheduling: 'Use in /v1/messages',
|
||||
mixedSchedulingHint: 'Enable to participate in Anthropic/Gemini group scheduling',
|
||||
mixedSchedulingTooltip:
|
||||
@@ -1691,6 +1699,7 @@ export default {
|
||||
userFilter: 'User',
|
||||
searchUserPlaceholder: 'Search user by email...',
|
||||
searchApiKeyPlaceholder: 'Search API key by name...',
|
||||
searchAccountPlaceholder: 'Search account by name...',
|
||||
selectedUser: 'Selected',
|
||||
user: 'User',
|
||||
account: 'Account',
|
||||
@@ -1984,7 +1993,7 @@ export default {
|
||||
},
|
||||
accountPriority: {
|
||||
title: '⚖️ 4. Priority (Optional)',
|
||||
description: '<div style="line-height: 1.7;"><p style="margin-bottom: 12px;">Set the account call priority.</p><div style="padding: 8px 12px; background: #eff6ff; border-left: 3px solid #3b82f6; border-radius: 4px; font-size: 13px; margin-bottom: 12px;"><b>📊 Priority Rules:</b><ul style="margin: 8px 0 0 16px;"><li>Higher number = higher priority</li><li>System uses high-priority accounts first</li><li>Same priority = random selection</li></ul></div><p style="padding: 8px 12px; background: #f0fdf4; border-left: 3px solid #10b981; border-radius: 4px; font-size: 13px;"><b>💡 Use Case:</b> Set main account to high priority, backup accounts to low priority</p></div>',
|
||||
description: '<div style="line-height: 1.7;"><p style="margin-bottom: 12px;">Set the account call priority.</p><div style="padding: 8px 12px; background: #eff6ff; border-left: 3px solid #3b82f6; border-radius: 4px; font-size: 13px; margin-bottom: 12px;"><b>📊 Priority Rules:</b><ul style="margin: 8px 0 0 16px;"><li>Lower number = higher priority</li><li>System uses low-value accounts first</li><li>Same priority = random selection</li></ul></div><p style="padding: 8px 12px; background: #f0fdf4; border-left: 3px solid #10b981; border-radius: 4px; font-size: 13px;"><b>💡 Use Case:</b> Set main account to lower value, backup accounts to higher value</p></div>',
|
||||
nextBtn: 'Next'
|
||||
},
|
||||
accountGroups: {
|
||||
|
||||
@@ -373,6 +373,8 @@ export default {
|
||||
usage: {
|
||||
title: '使用记录',
|
||||
description: '查看和分析您的 API 使用历史',
|
||||
costDetails: '成本明细',
|
||||
tokenDetails: 'Token 明细',
|
||||
totalRequests: '总请求数',
|
||||
totalTokens: '总 Token',
|
||||
totalCost: '总消费',
|
||||
@@ -857,7 +859,7 @@ export default {
|
||||
accountsLabel: '指定账号',
|
||||
accountsPlaceholder: '选择账号(留空则不限制)',
|
||||
priorityLabel: '优先级',
|
||||
priorityHint: '数值越高优先级越高,用于账号调度',
|
||||
priorityHint: '数值越小优先级越高,用于账号调度',
|
||||
statusLabel: '状态'
|
||||
},
|
||||
exclusiveObj: {
|
||||
@@ -1059,6 +1061,7 @@ export default {
|
||||
groups: '分组',
|
||||
usageWindows: '用量窗口',
|
||||
lastUsed: '最近使用',
|
||||
expiresAt: '过期时间',
|
||||
actions: '操作'
|
||||
},
|
||||
clearRateLimit: '清除速率限制',
|
||||
@@ -1178,7 +1181,7 @@ export default {
|
||||
credentialsLabel: '凭证',
|
||||
credentialsPlaceholder: '请输入 Cookie 或 API Key',
|
||||
priorityLabel: '优先级',
|
||||
priorityHint: '数值越高优先级越高',
|
||||
priorityHint: '数值越小优先级越高',
|
||||
weightLabel: '权重',
|
||||
weightHint: '用于负载均衡的权重值',
|
||||
statusLabel: '状态'
|
||||
@@ -1284,12 +1287,17 @@ export default {
|
||||
errorCodeExists: '该错误码已被选中',
|
||||
interceptWarmupRequests: '拦截预热请求',
|
||||
interceptWarmupRequestsDesc: '启用后,标题生成等预热请求将返回 mock 响应,不消耗上游 token',
|
||||
autoPauseOnExpired: '过期自动暂停调度',
|
||||
autoPauseOnExpiredDesc: '启用后,账号过期将自动暂停调度',
|
||||
expired: '已过期',
|
||||
proxy: '代理',
|
||||
noProxy: '无代理',
|
||||
concurrency: '并发数',
|
||||
priority: '优先级',
|
||||
priorityHint: '优先级越高的账号优先使用',
|
||||
higherPriorityFirst: '数值越高优先级越高',
|
||||
priorityHint: '优先级越小的账号优先使用',
|
||||
expiresAt: '过期时间',
|
||||
expiresAtHint: '留空表示不过期',
|
||||
higherPriorityFirst: '数值越小优先级越高',
|
||||
mixedScheduling: '在 /v1/messages 中使用',
|
||||
mixedSchedulingHint: '启用后可参与 Anthropic/Gemini 分组的调度',
|
||||
mixedSchedulingTooltip:
|
||||
@@ -1836,6 +1844,7 @@ export default {
|
||||
userFilter: '用户',
|
||||
searchUserPlaceholder: '按邮箱搜索用户...',
|
||||
searchApiKeyPlaceholder: '按名称搜索 API 密钥...',
|
||||
searchAccountPlaceholder: '按名称搜索账号...',
|
||||
selectedUser: '已选择',
|
||||
user: '用户',
|
||||
account: '账户',
|
||||
@@ -2126,7 +2135,7 @@ export default {
|
||||
},
|
||||
accountPriority: {
|
||||
title: '⚖️ 4. 优先级(可选)',
|
||||
description: '<div style="line-height: 1.7;"><p style="margin-bottom: 12px;">设置账号的调用优先级。</p><div style="padding: 8px 12px; background: #eff6ff; border-left: 3px solid #3b82f6; border-radius: 4px; font-size: 13px; margin-bottom: 12px;"><b>📊 优先级规则:</b><ul style="margin: 8px 0 0 16px;"><li>数字越大,优先级越高</li><li>系统优先使用高优先级账号</li><li>相同优先级则随机选择</li></ul></div><p style="padding: 8px 12px; background: #f0fdf4; border-left: 3px solid #10b981; border-radius: 4px; font-size: 13px;"><b>💡 使用场景:</b>主账号设置高优先级,备用账号设置低优先级</p></div>',
|
||||
description: '<div style="line-height: 1.7;"><p style="margin-bottom: 12px;">设置账号的调用优先级。</p><div style="padding: 8px 12px; background: #eff6ff; border-left: 3px solid #3b82f6; border-radius: 4px; font-size: 13px; margin-bottom: 12px;"><b>📊 优先级规则:</b><ul style="margin: 8px 0 0 16px;"><li>数字越小,优先级越高</li><li>系统优先使用低数值账号</li><li>相同优先级则随机选择</li></ul></div><p style="padding: 8px 12px; background: #f0fdf4; border-left: 3px solid #10b981; border-radius: 4px; font-size: 13px;"><b>💡 使用场景:</b>主账号设置低数值,备用账号设置高数值</p></div>',
|
||||
nextBtn: '下一步'
|
||||
},
|
||||
accountGroups: {
|
||||
|
||||
@@ -401,6 +401,8 @@ export interface Account {
|
||||
status: 'active' | 'inactive' | 'error'
|
||||
error_message: string | null
|
||||
last_used_at: string | null
|
||||
expires_at: number | null
|
||||
auto_pause_on_expired: boolean
|
||||
created_at: string
|
||||
updated_at: string
|
||||
proxy?: Proxy
|
||||
@@ -491,6 +493,8 @@ export interface CreateAccountRequest {
|
||||
concurrency?: number
|
||||
priority?: number
|
||||
group_ids?: number[]
|
||||
expires_at?: number | null
|
||||
auto_pause_on_expired?: boolean
|
||||
confirm_mixed_channel_risk?: boolean
|
||||
}
|
||||
|
||||
@@ -506,6 +510,8 @@ export interface UpdateAccountRequest {
|
||||
schedulable?: boolean
|
||||
status?: 'active' | 'inactive'
|
||||
group_ids?: number[]
|
||||
expires_at?: number | null
|
||||
auto_pause_on_expired?: boolean
|
||||
confirm_mixed_channel_risk?: boolean
|
||||
}
|
||||
|
||||
|
||||
@@ -96,6 +96,7 @@ export function formatBytes(bytes: number, decimals: number = 2): string {
|
||||
* 格式化日期
|
||||
* @param date 日期字符串或 Date 对象
|
||||
* @param options Intl.DateTimeFormatOptions
|
||||
* @param localeOverride 可选 locale 覆盖
|
||||
* @returns 格式化后的日期字符串
|
||||
*/
|
||||
export function formatDate(
|
||||
@@ -108,14 +109,15 @@ export function formatDate(
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
hour12: false
|
||||
}
|
||||
},
|
||||
localeOverride?: string
|
||||
): string {
|
||||
if (!date) return ''
|
||||
|
||||
const d = new Date(date)
|
||||
if (isNaN(d.getTime())) return ''
|
||||
|
||||
const locale = getLocale()
|
||||
const locale = localeOverride ?? getLocale()
|
||||
return new Intl.DateTimeFormat(locale, options).format(d)
|
||||
}
|
||||
|
||||
@@ -135,10 +137,41 @@ export function formatDateOnly(date: string | Date | null | undefined): string {
|
||||
/**
|
||||
* 格式化日期时间(完整格式)
|
||||
* @param date 日期字符串或 Date 对象
|
||||
* @param options Intl.DateTimeFormatOptions
|
||||
* @param localeOverride 可选 locale 覆盖
|
||||
* @returns 格式化后的日期时间字符串
|
||||
*/
|
||||
export function formatDateTime(date: string | Date | null | undefined): string {
|
||||
return formatDate(date)
|
||||
export function formatDateTime(
|
||||
date: string | Date | null | undefined,
|
||||
options?: Intl.DateTimeFormatOptions,
|
||||
localeOverride?: string
|
||||
): string {
|
||||
return formatDate(date, options, localeOverride)
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化为 datetime-local 控件值(YYYY-MM-DDTHH:mm,使用本地时间)
|
||||
*/
|
||||
export function formatDateTimeLocalInput(timestampSeconds: number | null): string {
|
||||
if (!timestampSeconds) return ''
|
||||
const date = new Date(timestampSeconds * 1000)
|
||||
if (isNaN(date.getTime())) return ''
|
||||
const year = date.getFullYear()
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0')
|
||||
const day = String(date.getDate()).padStart(2, '0')
|
||||
const hours = String(date.getHours()).padStart(2, '0')
|
||||
const minutes = String(date.getMinutes()).padStart(2, '0')
|
||||
return `${year}-${month}-${day}T${hours}:${minutes}`
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析 datetime-local 控件值为时间戳(秒,使用本地时间)
|
||||
*/
|
||||
export function parseDateTimeLocalInput(value: string): number | null {
|
||||
if (!value) return null
|
||||
const date = new Date(value)
|
||||
if (isNaN(date.getTime())) return null
|
||||
return Math.floor(date.getTime() / 1000)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -70,6 +70,25 @@
|
||||
<template #cell-last_used_at="{ value }">
|
||||
<span class="text-sm text-gray-500 dark:text-dark-400">{{ formatRelativeTime(value) }}</span>
|
||||
</template>
|
||||
<template #cell-expires_at="{ row, value }">
|
||||
<div class="flex flex-col items-start gap-1">
|
||||
<span class="text-sm text-gray-500 dark:text-dark-400">{{ formatExpiresAt(value) }}</span>
|
||||
<div v-if="isExpired(value) || (row.auto_pause_on_expired && value)" class="flex items-center gap-1">
|
||||
<span
|
||||
v-if="isExpired(value)"
|
||||
class="inline-flex items-center rounded-md bg-amber-100 px-2 py-0.5 text-xs font-medium text-amber-700 dark:bg-amber-900/30 dark:text-amber-300"
|
||||
>
|
||||
{{ t('admin.accounts.expired') }}
|
||||
</span>
|
||||
<span
|
||||
v-if="row.auto_pause_on_expired && value"
|
||||
class="inline-flex items-center rounded-md bg-emerald-100 px-2 py-0.5 text-xs font-medium text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-300"
|
||||
>
|
||||
{{ t('admin.accounts.autoPauseOnExpired') }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template #cell-actions="{ row }">
|
||||
<div class="flex items-center gap-1">
|
||||
<button @click="handleEdit(row)" class="flex flex-col items-center gap-0.5 rounded-lg p-1.5 text-gray-500 transition-colors hover:bg-gray-100 hover:text-primary-600 dark:hover:bg-dark-700 dark:hover:text-primary-400">
|
||||
@@ -128,7 +147,7 @@ import AccountUsageCell from '@/components/account/AccountUsageCell.vue'
|
||||
import AccountTodayStatsCell from '@/components/account/AccountTodayStatsCell.vue'
|
||||
import GroupBadge from '@/components/common/GroupBadge.vue'
|
||||
import PlatformTypeBadge from '@/components/common/PlatformTypeBadge.vue'
|
||||
import { formatRelativeTime } from '@/utils/format'
|
||||
import { formatDateTime, formatRelativeTime } from '@/utils/format'
|
||||
import type { Account, Proxy, Group } from '@/types'
|
||||
|
||||
const { t } = useI18n()
|
||||
@@ -178,6 +197,7 @@ const cols = computed(() => {
|
||||
{ key: 'usage', label: t('admin.accounts.columns.usageWindows'), sortable: false },
|
||||
{ key: 'priority', label: t('admin.accounts.columns.priority'), sortable: true },
|
||||
{ key: 'last_used_at', label: t('admin.accounts.columns.lastUsed'), sortable: true },
|
||||
{ key: 'expires_at', label: t('admin.accounts.columns.expiresAt'), sortable: true },
|
||||
{ key: 'notes', label: t('admin.accounts.columns.notes'), sortable: false },
|
||||
{ key: 'actions', label: t('admin.accounts.columns.actions'), sortable: false }
|
||||
)
|
||||
@@ -204,6 +224,25 @@ const confirmDelete = async () => { if(!deletingAcc.value) return; try { await a
|
||||
const handleToggleSchedulable = async (a: Account) => { togglingSchedulable.value = a.id; try { await adminAPI.accounts.setSchedulable(a.id, !a.schedulable); load() } finally { togglingSchedulable.value = null } }
|
||||
const handleShowTempUnsched = (a: Account) => { tempUnschedAcc.value = a; showTempUnsched.value = true }
|
||||
const handleTempUnschedReset = async () => { if(!tempUnschedAcc.value) return; try { await adminAPI.accounts.clearError(tempUnschedAcc.value.id); showTempUnsched.value = false; tempUnschedAcc.value = null; load() } catch (error) { console.error('Failed to reset temp unscheduled:', error) } }
|
||||
const formatExpiresAt = (value: number | null) => {
|
||||
if (!value) return '-'
|
||||
return formatDateTime(
|
||||
new Date(value * 1000),
|
||||
{
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hour12: false
|
||||
},
|
||||
'sv-SE'
|
||||
)
|
||||
}
|
||||
const isExpired = (value: number | null) => {
|
||||
if (!value) return false
|
||||
return value * 1000 <= Date.now()
|
||||
}
|
||||
|
||||
onMounted(async () => { load(); try { const [p, g] = await Promise.all([adminAPI.proxies.getAll(), adminAPI.groups.getAll()]); proxies.value = p; groups.value = g } catch (error) { console.error('Failed to load proxies/groups:', error) } })
|
||||
</script>
|
||||
|
||||
@@ -85,11 +85,48 @@ const exportToExcel = async () => {
|
||||
if (all.length >= total || res.items.length < 100) break; p++
|
||||
}
|
||||
if(!c.signal.aborted) {
|
||||
// 动态加载 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')
|
||||
saveAs(new Blob([XLSX.write(wb, { bookType: 'xlsx', type: 'array' })], { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' }), `usage_${Date.now()}.xlsx`)
|
||||
appStore.showSuccess('Export Success')
|
||||
const headers = [
|
||||
t('usage.time'), t('admin.usage.user'), t('usage.apiKeyFilter'),
|
||||
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') }
|
||||
finally { if(exportAbortController === c) { exportAbortController = null; exporting.value = false; exportProgress.show = false } }
|
||||
|
||||
@@ -342,8 +342,8 @@
|
||||
>
|
||||
<div class="space-y-1.5">
|
||||
<!-- Token Breakdown -->
|
||||
<div class="mb-2 border-b border-gray-700 pb-1.5">
|
||||
<div class="text-xs font-semibold text-gray-300 mb-1">Token 明细</div>
|
||||
<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">
|
||||
<span class="text-gray-400">{{ t('admin.usage.inputTokens') }}</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"
|
||||
>
|
||||
<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">
|
||||
<span class="text-gray-400">{{ t('usage.rate') }}</span>
|
||||
<span class="font-semibold text-blue-400"
|
||||
|
||||
@@ -3,6 +3,7 @@ import vue from '@vitejs/plugin-vue'
|
||||
import checker from 'vite-plugin-checker'
|
||||
import { resolve } from 'path'
|
||||
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
vue(),
|
||||
@@ -29,7 +30,7 @@ export default defineConfig({
|
||||
},
|
||||
server: {
|
||||
host: '0.0.0.0',
|
||||
port: 3000,
|
||||
port: Number(process.env.VITE_DEV_PORT || 3000),
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: process.env.VITE_DEV_PROXY_TARGET || 'http://localhost:8080',
|
||||
|
||||
Reference in New Issue
Block a user