Merge pull request #471 from bayma888/feature/api-key-quota-expiration

feat(api-key): 添加API密钥独立配额和过期时间功能
This commit is contained in:
Wesley Liddick
2026-02-03 21:11:17 +08:00
committed by GitHub
37 changed files with 1832 additions and 175 deletions

View File

@@ -1,4 +1,4 @@
FROM golang:1.25.5-alpine
FROM golang:1.25.6-alpine
WORKDIR /app
@@ -15,7 +15,7 @@ RUN go mod download
COPY . .
# 构建应用
RUN go build -o main cmd/server/main.go
RUN go build -o main ./cmd/server/
# 暴露端口
EXPOSE 8080

View File

@@ -173,8 +173,8 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
userAttributeService := service.NewUserAttributeService(userAttributeDefinitionRepository, userAttributeValueRepository)
userAttributeHandler := admin.NewUserAttributeHandler(userAttributeService)
adminHandlers := handler.ProvideAdminHandlers(dashboardHandler, adminUserHandler, groupHandler, accountHandler, adminAnnouncementHandler, oAuthHandler, openAIOAuthHandler, geminiOAuthHandler, antigravityOAuthHandler, proxyHandler, adminRedeemHandler, promoHandler, settingHandler, opsHandler, systemHandler, adminSubscriptionHandler, adminUsageHandler, userAttributeHandler)
gatewayHandler := handler.NewGatewayHandler(gatewayService, geminiMessagesCompatService, antigravityGatewayService, userService, concurrencyService, billingCacheService, usageService, configConfig)
openAIGatewayHandler := handler.NewOpenAIGatewayHandler(openAIGatewayService, concurrencyService, billingCacheService, configConfig)
gatewayHandler := handler.NewGatewayHandler(gatewayService, geminiMessagesCompatService, antigravityGatewayService, userService, concurrencyService, billingCacheService, usageService, apiKeyService, configConfig)
openAIGatewayHandler := handler.NewOpenAIGatewayHandler(openAIGatewayService, concurrencyService, billingCacheService, apiKeyService, configConfig)
handlerSettingHandler := handler.ProvideSettingHandler(settingService, buildInfo)
totpHandler := handler.NewTotpHandler(totpService)
handlers := handler.ProvideHandlers(authHandler, userHandler, apiKeyHandler, usageHandler, redeemHandler, subscriptionHandler, announcementHandler, adminHandlers, gatewayHandler, openAIGatewayHandler, handlerSettingHandler, totpHandler)

View File

@@ -40,6 +40,12 @@ type APIKey struct {
IPWhitelist []string `json:"ip_whitelist,omitempty"`
// Blocked IPs/CIDRs
IPBlacklist []string `json:"ip_blacklist,omitempty"`
// Quota limit in USD for this API key (0 = unlimited)
Quota float64 `json:"quota,omitempty"`
// Used quota amount in USD
QuotaUsed float64 `json:"quota_used,omitempty"`
// Expiration time for this API key (null = never expires)
ExpiresAt *time.Time `json:"expires_at,omitempty"`
// Edges holds the relations/edges for other nodes in the graph.
// The values are being populated by the APIKeyQuery when eager-loading is set.
Edges APIKeyEdges `json:"edges"`
@@ -97,11 +103,13 @@ func (*APIKey) scanValues(columns []string) ([]any, error) {
switch columns[i] {
case apikey.FieldIPWhitelist, apikey.FieldIPBlacklist:
values[i] = new([]byte)
case apikey.FieldQuota, apikey.FieldQuotaUsed:
values[i] = new(sql.NullFloat64)
case apikey.FieldID, apikey.FieldUserID, apikey.FieldGroupID:
values[i] = new(sql.NullInt64)
case apikey.FieldKey, apikey.FieldName, apikey.FieldStatus:
values[i] = new(sql.NullString)
case apikey.FieldCreatedAt, apikey.FieldUpdatedAt, apikey.FieldDeletedAt:
case apikey.FieldCreatedAt, apikey.FieldUpdatedAt, apikey.FieldDeletedAt, apikey.FieldExpiresAt:
values[i] = new(sql.NullTime)
default:
values[i] = new(sql.UnknownType)
@@ -190,6 +198,25 @@ func (_m *APIKey) assignValues(columns []string, values []any) error {
return fmt.Errorf("unmarshal field ip_blacklist: %w", err)
}
}
case apikey.FieldQuota:
if value, ok := values[i].(*sql.NullFloat64); !ok {
return fmt.Errorf("unexpected type %T for field quota", values[i])
} else if value.Valid {
_m.Quota = value.Float64
}
case apikey.FieldQuotaUsed:
if value, ok := values[i].(*sql.NullFloat64); !ok {
return fmt.Errorf("unexpected type %T for field quota_used", values[i])
} else if value.Valid {
_m.QuotaUsed = value.Float64
}
case apikey.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
}
default:
_m.selectValues.Set(columns[i], values[i])
}
@@ -274,6 +301,17 @@ func (_m *APIKey) String() string {
builder.WriteString(", ")
builder.WriteString("ip_blacklist=")
builder.WriteString(fmt.Sprintf("%v", _m.IPBlacklist))
builder.WriteString(", ")
builder.WriteString("quota=")
builder.WriteString(fmt.Sprintf("%v", _m.Quota))
builder.WriteString(", ")
builder.WriteString("quota_used=")
builder.WriteString(fmt.Sprintf("%v", _m.QuotaUsed))
builder.WriteString(", ")
if v := _m.ExpiresAt; v != nil {
builder.WriteString("expires_at=")
builder.WriteString(v.Format(time.ANSIC))
}
builder.WriteByte(')')
return builder.String()
}

View File

@@ -35,6 +35,12 @@ const (
FieldIPWhitelist = "ip_whitelist"
// FieldIPBlacklist holds the string denoting the ip_blacklist field in the database.
FieldIPBlacklist = "ip_blacklist"
// FieldQuota holds the string denoting the quota field in the database.
FieldQuota = "quota"
// FieldQuotaUsed holds the string denoting the quota_used field in the database.
FieldQuotaUsed = "quota_used"
// FieldExpiresAt holds the string denoting the expires_at field in the database.
FieldExpiresAt = "expires_at"
// EdgeUser holds the string denoting the user edge name in mutations.
EdgeUser = "user"
// EdgeGroup holds the string denoting the group edge name in mutations.
@@ -79,6 +85,9 @@ var Columns = []string{
FieldStatus,
FieldIPWhitelist,
FieldIPBlacklist,
FieldQuota,
FieldQuotaUsed,
FieldExpiresAt,
}
// ValidColumn reports if the column name is valid (part of the table columns).
@@ -113,6 +122,10 @@ var (
DefaultStatus string
// StatusValidator is a validator for the "status" field. It is called by the builders before save.
StatusValidator func(string) error
// DefaultQuota holds the default value on creation for the "quota" field.
DefaultQuota float64
// DefaultQuotaUsed holds the default value on creation for the "quota_used" field.
DefaultQuotaUsed float64
)
// OrderOption defines the ordering options for the APIKey queries.
@@ -163,6 +176,21 @@ func ByStatus(opts ...sql.OrderTermOption) OrderOption {
return sql.OrderByField(FieldStatus, opts...).ToFunc()
}
// ByQuota orders the results by the quota field.
func ByQuota(opts ...sql.OrderTermOption) OrderOption {
return sql.OrderByField(FieldQuota, opts...).ToFunc()
}
// ByQuotaUsed orders the results by the quota_used field.
func ByQuotaUsed(opts ...sql.OrderTermOption) OrderOption {
return sql.OrderByField(FieldQuotaUsed, opts...).ToFunc()
}
// ByExpiresAt orders the results by the expires_at field.
func ByExpiresAt(opts ...sql.OrderTermOption) OrderOption {
return sql.OrderByField(FieldExpiresAt, opts...).ToFunc()
}
// ByUserField orders the results by user field.
func ByUserField(field string, opts ...sql.OrderTermOption) OrderOption {
return func(s *sql.Selector) {

View File

@@ -95,6 +95,21 @@ func Status(v string) predicate.APIKey {
return predicate.APIKey(sql.FieldEQ(FieldStatus, v))
}
// Quota applies equality check predicate on the "quota" field. It's identical to QuotaEQ.
func Quota(v float64) predicate.APIKey {
return predicate.APIKey(sql.FieldEQ(FieldQuota, v))
}
// QuotaUsed applies equality check predicate on the "quota_used" field. It's identical to QuotaUsedEQ.
func QuotaUsed(v float64) predicate.APIKey {
return predicate.APIKey(sql.FieldEQ(FieldQuotaUsed, v))
}
// ExpiresAt applies equality check predicate on the "expires_at" field. It's identical to ExpiresAtEQ.
func ExpiresAt(v time.Time) predicate.APIKey {
return predicate.APIKey(sql.FieldEQ(FieldExpiresAt, v))
}
// CreatedAtEQ applies the EQ predicate on the "created_at" field.
func CreatedAtEQ(v time.Time) predicate.APIKey {
return predicate.APIKey(sql.FieldEQ(FieldCreatedAt, v))
@@ -490,6 +505,136 @@ func IPBlacklistNotNil() predicate.APIKey {
return predicate.APIKey(sql.FieldNotNull(FieldIPBlacklist))
}
// QuotaEQ applies the EQ predicate on the "quota" field.
func QuotaEQ(v float64) predicate.APIKey {
return predicate.APIKey(sql.FieldEQ(FieldQuota, v))
}
// QuotaNEQ applies the NEQ predicate on the "quota" field.
func QuotaNEQ(v float64) predicate.APIKey {
return predicate.APIKey(sql.FieldNEQ(FieldQuota, v))
}
// QuotaIn applies the In predicate on the "quota" field.
func QuotaIn(vs ...float64) predicate.APIKey {
return predicate.APIKey(sql.FieldIn(FieldQuota, vs...))
}
// QuotaNotIn applies the NotIn predicate on the "quota" field.
func QuotaNotIn(vs ...float64) predicate.APIKey {
return predicate.APIKey(sql.FieldNotIn(FieldQuota, vs...))
}
// QuotaGT applies the GT predicate on the "quota" field.
func QuotaGT(v float64) predicate.APIKey {
return predicate.APIKey(sql.FieldGT(FieldQuota, v))
}
// QuotaGTE applies the GTE predicate on the "quota" field.
func QuotaGTE(v float64) predicate.APIKey {
return predicate.APIKey(sql.FieldGTE(FieldQuota, v))
}
// QuotaLT applies the LT predicate on the "quota" field.
func QuotaLT(v float64) predicate.APIKey {
return predicate.APIKey(sql.FieldLT(FieldQuota, v))
}
// QuotaLTE applies the LTE predicate on the "quota" field.
func QuotaLTE(v float64) predicate.APIKey {
return predicate.APIKey(sql.FieldLTE(FieldQuota, v))
}
// QuotaUsedEQ applies the EQ predicate on the "quota_used" field.
func QuotaUsedEQ(v float64) predicate.APIKey {
return predicate.APIKey(sql.FieldEQ(FieldQuotaUsed, v))
}
// QuotaUsedNEQ applies the NEQ predicate on the "quota_used" field.
func QuotaUsedNEQ(v float64) predicate.APIKey {
return predicate.APIKey(sql.FieldNEQ(FieldQuotaUsed, v))
}
// QuotaUsedIn applies the In predicate on the "quota_used" field.
func QuotaUsedIn(vs ...float64) predicate.APIKey {
return predicate.APIKey(sql.FieldIn(FieldQuotaUsed, vs...))
}
// QuotaUsedNotIn applies the NotIn predicate on the "quota_used" field.
func QuotaUsedNotIn(vs ...float64) predicate.APIKey {
return predicate.APIKey(sql.FieldNotIn(FieldQuotaUsed, vs...))
}
// QuotaUsedGT applies the GT predicate on the "quota_used" field.
func QuotaUsedGT(v float64) predicate.APIKey {
return predicate.APIKey(sql.FieldGT(FieldQuotaUsed, v))
}
// QuotaUsedGTE applies the GTE predicate on the "quota_used" field.
func QuotaUsedGTE(v float64) predicate.APIKey {
return predicate.APIKey(sql.FieldGTE(FieldQuotaUsed, v))
}
// QuotaUsedLT applies the LT predicate on the "quota_used" field.
func QuotaUsedLT(v float64) predicate.APIKey {
return predicate.APIKey(sql.FieldLT(FieldQuotaUsed, v))
}
// QuotaUsedLTE applies the LTE predicate on the "quota_used" field.
func QuotaUsedLTE(v float64) predicate.APIKey {
return predicate.APIKey(sql.FieldLTE(FieldQuotaUsed, v))
}
// ExpiresAtEQ applies the EQ predicate on the "expires_at" field.
func ExpiresAtEQ(v time.Time) predicate.APIKey {
return predicate.APIKey(sql.FieldEQ(FieldExpiresAt, v))
}
// ExpiresAtNEQ applies the NEQ predicate on the "expires_at" field.
func ExpiresAtNEQ(v time.Time) predicate.APIKey {
return predicate.APIKey(sql.FieldNEQ(FieldExpiresAt, v))
}
// ExpiresAtIn applies the In predicate on the "expires_at" field.
func ExpiresAtIn(vs ...time.Time) predicate.APIKey {
return predicate.APIKey(sql.FieldIn(FieldExpiresAt, vs...))
}
// ExpiresAtNotIn applies the NotIn predicate on the "expires_at" field.
func ExpiresAtNotIn(vs ...time.Time) predicate.APIKey {
return predicate.APIKey(sql.FieldNotIn(FieldExpiresAt, vs...))
}
// ExpiresAtGT applies the GT predicate on the "expires_at" field.
func ExpiresAtGT(v time.Time) predicate.APIKey {
return predicate.APIKey(sql.FieldGT(FieldExpiresAt, v))
}
// ExpiresAtGTE applies the GTE predicate on the "expires_at" field.
func ExpiresAtGTE(v time.Time) predicate.APIKey {
return predicate.APIKey(sql.FieldGTE(FieldExpiresAt, v))
}
// ExpiresAtLT applies the LT predicate on the "expires_at" field.
func ExpiresAtLT(v time.Time) predicate.APIKey {
return predicate.APIKey(sql.FieldLT(FieldExpiresAt, v))
}
// ExpiresAtLTE applies the LTE predicate on the "expires_at" field.
func ExpiresAtLTE(v time.Time) predicate.APIKey {
return predicate.APIKey(sql.FieldLTE(FieldExpiresAt, v))
}
// ExpiresAtIsNil applies the IsNil predicate on the "expires_at" field.
func ExpiresAtIsNil() predicate.APIKey {
return predicate.APIKey(sql.FieldIsNull(FieldExpiresAt))
}
// ExpiresAtNotNil applies the NotNil predicate on the "expires_at" field.
func ExpiresAtNotNil() predicate.APIKey {
return predicate.APIKey(sql.FieldNotNull(FieldExpiresAt))
}
// HasUser applies the HasEdge predicate on the "user" edge.
func HasUser() predicate.APIKey {
return predicate.APIKey(func(s *sql.Selector) {

View File

@@ -125,6 +125,48 @@ func (_c *APIKeyCreate) SetIPBlacklist(v []string) *APIKeyCreate {
return _c
}
// SetQuota sets the "quota" field.
func (_c *APIKeyCreate) SetQuota(v float64) *APIKeyCreate {
_c.mutation.SetQuota(v)
return _c
}
// SetNillableQuota sets the "quota" field if the given value is not nil.
func (_c *APIKeyCreate) SetNillableQuota(v *float64) *APIKeyCreate {
if v != nil {
_c.SetQuota(*v)
}
return _c
}
// SetQuotaUsed sets the "quota_used" field.
func (_c *APIKeyCreate) SetQuotaUsed(v float64) *APIKeyCreate {
_c.mutation.SetQuotaUsed(v)
return _c
}
// SetNillableQuotaUsed sets the "quota_used" field if the given value is not nil.
func (_c *APIKeyCreate) SetNillableQuotaUsed(v *float64) *APIKeyCreate {
if v != nil {
_c.SetQuotaUsed(*v)
}
return _c
}
// SetExpiresAt sets the "expires_at" field.
func (_c *APIKeyCreate) SetExpiresAt(v time.Time) *APIKeyCreate {
_c.mutation.SetExpiresAt(v)
return _c
}
// SetNillableExpiresAt sets the "expires_at" field if the given value is not nil.
func (_c *APIKeyCreate) SetNillableExpiresAt(v *time.Time) *APIKeyCreate {
if v != nil {
_c.SetExpiresAt(*v)
}
return _c
}
// SetUser sets the "user" edge to the User entity.
func (_c *APIKeyCreate) SetUser(v *User) *APIKeyCreate {
return _c.SetUserID(v.ID)
@@ -205,6 +247,14 @@ func (_c *APIKeyCreate) defaults() error {
v := apikey.DefaultStatus
_c.mutation.SetStatus(v)
}
if _, ok := _c.mutation.Quota(); !ok {
v := apikey.DefaultQuota
_c.mutation.SetQuota(v)
}
if _, ok := _c.mutation.QuotaUsed(); !ok {
v := apikey.DefaultQuotaUsed
_c.mutation.SetQuotaUsed(v)
}
return nil
}
@@ -243,6 +293,12 @@ func (_c *APIKeyCreate) check() error {
return &ValidationError{Name: "status", err: fmt.Errorf(`ent: validator failed for field "APIKey.status": %w`, err)}
}
}
if _, ok := _c.mutation.Quota(); !ok {
return &ValidationError{Name: "quota", err: errors.New(`ent: missing required field "APIKey.quota"`)}
}
if _, ok := _c.mutation.QuotaUsed(); !ok {
return &ValidationError{Name: "quota_used", err: errors.New(`ent: missing required field "APIKey.quota_used"`)}
}
if len(_c.mutation.UserIDs()) == 0 {
return &ValidationError{Name: "user", err: errors.New(`ent: missing required edge "APIKey.user"`)}
}
@@ -305,6 +361,18 @@ func (_c *APIKeyCreate) createSpec() (*APIKey, *sqlgraph.CreateSpec) {
_spec.SetField(apikey.FieldIPBlacklist, field.TypeJSON, value)
_node.IPBlacklist = value
}
if value, ok := _c.mutation.Quota(); ok {
_spec.SetField(apikey.FieldQuota, field.TypeFloat64, value)
_node.Quota = value
}
if value, ok := _c.mutation.QuotaUsed(); ok {
_spec.SetField(apikey.FieldQuotaUsed, field.TypeFloat64, value)
_node.QuotaUsed = value
}
if value, ok := _c.mutation.ExpiresAt(); ok {
_spec.SetField(apikey.FieldExpiresAt, field.TypeTime, value)
_node.ExpiresAt = &value
}
if nodes := _c.mutation.UserIDs(); len(nodes) > 0 {
edge := &sqlgraph.EdgeSpec{
Rel: sqlgraph.M2O,
@@ -539,6 +607,60 @@ func (u *APIKeyUpsert) ClearIPBlacklist() *APIKeyUpsert {
return u
}
// SetQuota sets the "quota" field.
func (u *APIKeyUpsert) SetQuota(v float64) *APIKeyUpsert {
u.Set(apikey.FieldQuota, v)
return u
}
// UpdateQuota sets the "quota" field to the value that was provided on create.
func (u *APIKeyUpsert) UpdateQuota() *APIKeyUpsert {
u.SetExcluded(apikey.FieldQuota)
return u
}
// AddQuota adds v to the "quota" field.
func (u *APIKeyUpsert) AddQuota(v float64) *APIKeyUpsert {
u.Add(apikey.FieldQuota, v)
return u
}
// SetQuotaUsed sets the "quota_used" field.
func (u *APIKeyUpsert) SetQuotaUsed(v float64) *APIKeyUpsert {
u.Set(apikey.FieldQuotaUsed, v)
return u
}
// UpdateQuotaUsed sets the "quota_used" field to the value that was provided on create.
func (u *APIKeyUpsert) UpdateQuotaUsed() *APIKeyUpsert {
u.SetExcluded(apikey.FieldQuotaUsed)
return u
}
// AddQuotaUsed adds v to the "quota_used" field.
func (u *APIKeyUpsert) AddQuotaUsed(v float64) *APIKeyUpsert {
u.Add(apikey.FieldQuotaUsed, v)
return u
}
// SetExpiresAt sets the "expires_at" field.
func (u *APIKeyUpsert) SetExpiresAt(v time.Time) *APIKeyUpsert {
u.Set(apikey.FieldExpiresAt, v)
return u
}
// UpdateExpiresAt sets the "expires_at" field to the value that was provided on create.
func (u *APIKeyUpsert) UpdateExpiresAt() *APIKeyUpsert {
u.SetExcluded(apikey.FieldExpiresAt)
return u
}
// ClearExpiresAt clears the value of the "expires_at" field.
func (u *APIKeyUpsert) ClearExpiresAt() *APIKeyUpsert {
u.SetNull(apikey.FieldExpiresAt)
return u
}
// UpdateNewValues updates the mutable fields using the new values that were set on create.
// Using this option is equivalent to using:
//
@@ -738,6 +860,69 @@ func (u *APIKeyUpsertOne) ClearIPBlacklist() *APIKeyUpsertOne {
})
}
// SetQuota sets the "quota" field.
func (u *APIKeyUpsertOne) SetQuota(v float64) *APIKeyUpsertOne {
return u.Update(func(s *APIKeyUpsert) {
s.SetQuota(v)
})
}
// AddQuota adds v to the "quota" field.
func (u *APIKeyUpsertOne) AddQuota(v float64) *APIKeyUpsertOne {
return u.Update(func(s *APIKeyUpsert) {
s.AddQuota(v)
})
}
// UpdateQuota sets the "quota" field to the value that was provided on create.
func (u *APIKeyUpsertOne) UpdateQuota() *APIKeyUpsertOne {
return u.Update(func(s *APIKeyUpsert) {
s.UpdateQuota()
})
}
// SetQuotaUsed sets the "quota_used" field.
func (u *APIKeyUpsertOne) SetQuotaUsed(v float64) *APIKeyUpsertOne {
return u.Update(func(s *APIKeyUpsert) {
s.SetQuotaUsed(v)
})
}
// AddQuotaUsed adds v to the "quota_used" field.
func (u *APIKeyUpsertOne) AddQuotaUsed(v float64) *APIKeyUpsertOne {
return u.Update(func(s *APIKeyUpsert) {
s.AddQuotaUsed(v)
})
}
// UpdateQuotaUsed sets the "quota_used" field to the value that was provided on create.
func (u *APIKeyUpsertOne) UpdateQuotaUsed() *APIKeyUpsertOne {
return u.Update(func(s *APIKeyUpsert) {
s.UpdateQuotaUsed()
})
}
// SetExpiresAt sets the "expires_at" field.
func (u *APIKeyUpsertOne) SetExpiresAt(v time.Time) *APIKeyUpsertOne {
return u.Update(func(s *APIKeyUpsert) {
s.SetExpiresAt(v)
})
}
// UpdateExpiresAt sets the "expires_at" field to the value that was provided on create.
func (u *APIKeyUpsertOne) UpdateExpiresAt() *APIKeyUpsertOne {
return u.Update(func(s *APIKeyUpsert) {
s.UpdateExpiresAt()
})
}
// ClearExpiresAt clears the value of the "expires_at" field.
func (u *APIKeyUpsertOne) ClearExpiresAt() *APIKeyUpsertOne {
return u.Update(func(s *APIKeyUpsert) {
s.ClearExpiresAt()
})
}
// Exec executes the query.
func (u *APIKeyUpsertOne) Exec(ctx context.Context) error {
if len(u.create.conflict) == 0 {
@@ -1103,6 +1288,69 @@ func (u *APIKeyUpsertBulk) ClearIPBlacklist() *APIKeyUpsertBulk {
})
}
// SetQuota sets the "quota" field.
func (u *APIKeyUpsertBulk) SetQuota(v float64) *APIKeyUpsertBulk {
return u.Update(func(s *APIKeyUpsert) {
s.SetQuota(v)
})
}
// AddQuota adds v to the "quota" field.
func (u *APIKeyUpsertBulk) AddQuota(v float64) *APIKeyUpsertBulk {
return u.Update(func(s *APIKeyUpsert) {
s.AddQuota(v)
})
}
// UpdateQuota sets the "quota" field to the value that was provided on create.
func (u *APIKeyUpsertBulk) UpdateQuota() *APIKeyUpsertBulk {
return u.Update(func(s *APIKeyUpsert) {
s.UpdateQuota()
})
}
// SetQuotaUsed sets the "quota_used" field.
func (u *APIKeyUpsertBulk) SetQuotaUsed(v float64) *APIKeyUpsertBulk {
return u.Update(func(s *APIKeyUpsert) {
s.SetQuotaUsed(v)
})
}
// AddQuotaUsed adds v to the "quota_used" field.
func (u *APIKeyUpsertBulk) AddQuotaUsed(v float64) *APIKeyUpsertBulk {
return u.Update(func(s *APIKeyUpsert) {
s.AddQuotaUsed(v)
})
}
// UpdateQuotaUsed sets the "quota_used" field to the value that was provided on create.
func (u *APIKeyUpsertBulk) UpdateQuotaUsed() *APIKeyUpsertBulk {
return u.Update(func(s *APIKeyUpsert) {
s.UpdateQuotaUsed()
})
}
// SetExpiresAt sets the "expires_at" field.
func (u *APIKeyUpsertBulk) SetExpiresAt(v time.Time) *APIKeyUpsertBulk {
return u.Update(func(s *APIKeyUpsert) {
s.SetExpiresAt(v)
})
}
// UpdateExpiresAt sets the "expires_at" field to the value that was provided on create.
func (u *APIKeyUpsertBulk) UpdateExpiresAt() *APIKeyUpsertBulk {
return u.Update(func(s *APIKeyUpsert) {
s.UpdateExpiresAt()
})
}
// ClearExpiresAt clears the value of the "expires_at" field.
func (u *APIKeyUpsertBulk) ClearExpiresAt() *APIKeyUpsertBulk {
return u.Update(func(s *APIKeyUpsert) {
s.ClearExpiresAt()
})
}
// Exec executes the query.
func (u *APIKeyUpsertBulk) Exec(ctx context.Context) error {
if u.create.err != nil {

View File

@@ -170,6 +170,68 @@ func (_u *APIKeyUpdate) ClearIPBlacklist() *APIKeyUpdate {
return _u
}
// SetQuota sets the "quota" field.
func (_u *APIKeyUpdate) SetQuota(v float64) *APIKeyUpdate {
_u.mutation.ResetQuota()
_u.mutation.SetQuota(v)
return _u
}
// SetNillableQuota sets the "quota" field if the given value is not nil.
func (_u *APIKeyUpdate) SetNillableQuota(v *float64) *APIKeyUpdate {
if v != nil {
_u.SetQuota(*v)
}
return _u
}
// AddQuota adds value to the "quota" field.
func (_u *APIKeyUpdate) AddQuota(v float64) *APIKeyUpdate {
_u.mutation.AddQuota(v)
return _u
}
// SetQuotaUsed sets the "quota_used" field.
func (_u *APIKeyUpdate) SetQuotaUsed(v float64) *APIKeyUpdate {
_u.mutation.ResetQuotaUsed()
_u.mutation.SetQuotaUsed(v)
return _u
}
// SetNillableQuotaUsed sets the "quota_used" field if the given value is not nil.
func (_u *APIKeyUpdate) SetNillableQuotaUsed(v *float64) *APIKeyUpdate {
if v != nil {
_u.SetQuotaUsed(*v)
}
return _u
}
// AddQuotaUsed adds value to the "quota_used" field.
func (_u *APIKeyUpdate) AddQuotaUsed(v float64) *APIKeyUpdate {
_u.mutation.AddQuotaUsed(v)
return _u
}
// SetExpiresAt sets the "expires_at" field.
func (_u *APIKeyUpdate) SetExpiresAt(v time.Time) *APIKeyUpdate {
_u.mutation.SetExpiresAt(v)
return _u
}
// SetNillableExpiresAt sets the "expires_at" field if the given value is not nil.
func (_u *APIKeyUpdate) SetNillableExpiresAt(v *time.Time) *APIKeyUpdate {
if v != nil {
_u.SetExpiresAt(*v)
}
return _u
}
// ClearExpiresAt clears the value of the "expires_at" field.
func (_u *APIKeyUpdate) ClearExpiresAt() *APIKeyUpdate {
_u.mutation.ClearExpiresAt()
return _u
}
// SetUser sets the "user" edge to the User entity.
func (_u *APIKeyUpdate) SetUser(v *User) *APIKeyUpdate {
return _u.SetUserID(v.ID)
@@ -350,6 +412,24 @@ func (_u *APIKeyUpdate) sqlSave(ctx context.Context) (_node int, err error) {
if _u.mutation.IPBlacklistCleared() {
_spec.ClearField(apikey.FieldIPBlacklist, field.TypeJSON)
}
if value, ok := _u.mutation.Quota(); ok {
_spec.SetField(apikey.FieldQuota, field.TypeFloat64, value)
}
if value, ok := _u.mutation.AddedQuota(); ok {
_spec.AddField(apikey.FieldQuota, field.TypeFloat64, value)
}
if value, ok := _u.mutation.QuotaUsed(); ok {
_spec.SetField(apikey.FieldQuotaUsed, field.TypeFloat64, value)
}
if value, ok := _u.mutation.AddedQuotaUsed(); ok {
_spec.AddField(apikey.FieldQuotaUsed, field.TypeFloat64, value)
}
if value, ok := _u.mutation.ExpiresAt(); ok {
_spec.SetField(apikey.FieldExpiresAt, field.TypeTime, value)
}
if _u.mutation.ExpiresAtCleared() {
_spec.ClearField(apikey.FieldExpiresAt, field.TypeTime)
}
if _u.mutation.UserCleared() {
edge := &sqlgraph.EdgeSpec{
Rel: sqlgraph.M2O,
@@ -611,6 +691,68 @@ func (_u *APIKeyUpdateOne) ClearIPBlacklist() *APIKeyUpdateOne {
return _u
}
// SetQuota sets the "quota" field.
func (_u *APIKeyUpdateOne) SetQuota(v float64) *APIKeyUpdateOne {
_u.mutation.ResetQuota()
_u.mutation.SetQuota(v)
return _u
}
// SetNillableQuota sets the "quota" field if the given value is not nil.
func (_u *APIKeyUpdateOne) SetNillableQuota(v *float64) *APIKeyUpdateOne {
if v != nil {
_u.SetQuota(*v)
}
return _u
}
// AddQuota adds value to the "quota" field.
func (_u *APIKeyUpdateOne) AddQuota(v float64) *APIKeyUpdateOne {
_u.mutation.AddQuota(v)
return _u
}
// SetQuotaUsed sets the "quota_used" field.
func (_u *APIKeyUpdateOne) SetQuotaUsed(v float64) *APIKeyUpdateOne {
_u.mutation.ResetQuotaUsed()
_u.mutation.SetQuotaUsed(v)
return _u
}
// SetNillableQuotaUsed sets the "quota_used" field if the given value is not nil.
func (_u *APIKeyUpdateOne) SetNillableQuotaUsed(v *float64) *APIKeyUpdateOne {
if v != nil {
_u.SetQuotaUsed(*v)
}
return _u
}
// AddQuotaUsed adds value to the "quota_used" field.
func (_u *APIKeyUpdateOne) AddQuotaUsed(v float64) *APIKeyUpdateOne {
_u.mutation.AddQuotaUsed(v)
return _u
}
// SetExpiresAt sets the "expires_at" field.
func (_u *APIKeyUpdateOne) SetExpiresAt(v time.Time) *APIKeyUpdateOne {
_u.mutation.SetExpiresAt(v)
return _u
}
// SetNillableExpiresAt sets the "expires_at" field if the given value is not nil.
func (_u *APIKeyUpdateOne) SetNillableExpiresAt(v *time.Time) *APIKeyUpdateOne {
if v != nil {
_u.SetExpiresAt(*v)
}
return _u
}
// ClearExpiresAt clears the value of the "expires_at" field.
func (_u *APIKeyUpdateOne) ClearExpiresAt() *APIKeyUpdateOne {
_u.mutation.ClearExpiresAt()
return _u
}
// SetUser sets the "user" edge to the User entity.
func (_u *APIKeyUpdateOne) SetUser(v *User) *APIKeyUpdateOne {
return _u.SetUserID(v.ID)
@@ -821,6 +963,24 @@ func (_u *APIKeyUpdateOne) sqlSave(ctx context.Context) (_node *APIKey, err erro
if _u.mutation.IPBlacklistCleared() {
_spec.ClearField(apikey.FieldIPBlacklist, field.TypeJSON)
}
if value, ok := _u.mutation.Quota(); ok {
_spec.SetField(apikey.FieldQuota, field.TypeFloat64, value)
}
if value, ok := _u.mutation.AddedQuota(); ok {
_spec.AddField(apikey.FieldQuota, field.TypeFloat64, value)
}
if value, ok := _u.mutation.QuotaUsed(); ok {
_spec.SetField(apikey.FieldQuotaUsed, field.TypeFloat64, value)
}
if value, ok := _u.mutation.AddedQuotaUsed(); ok {
_spec.AddField(apikey.FieldQuotaUsed, field.TypeFloat64, value)
}
if value, ok := _u.mutation.ExpiresAt(); ok {
_spec.SetField(apikey.FieldExpiresAt, field.TypeTime, value)
}
if _u.mutation.ExpiresAtCleared() {
_spec.ClearField(apikey.FieldExpiresAt, field.TypeTime)
}
if _u.mutation.UserCleared() {
edge := &sqlgraph.EdgeSpec{
Rel: sqlgraph.M2O,

View File

@@ -20,6 +20,9 @@ var (
{Name: "status", Type: field.TypeString, Size: 20, Default: "active"},
{Name: "ip_whitelist", Type: field.TypeJSON, Nullable: true},
{Name: "ip_blacklist", Type: field.TypeJSON, Nullable: true},
{Name: "quota", Type: field.TypeFloat64, Default: 0, SchemaType: map[string]string{"postgres": "decimal(20,8)"}},
{Name: "quota_used", Type: field.TypeFloat64, Default: 0, SchemaType: map[string]string{"postgres": "decimal(20,8)"}},
{Name: "expires_at", Type: field.TypeTime, Nullable: true},
{Name: "group_id", Type: field.TypeInt64, Nullable: true},
{Name: "user_id", Type: field.TypeInt64},
}
@@ -31,13 +34,13 @@ var (
ForeignKeys: []*schema.ForeignKey{
{
Symbol: "api_keys_groups_api_keys",
Columns: []*schema.Column{APIKeysColumns[9]},
Columns: []*schema.Column{APIKeysColumns[12]},
RefColumns: []*schema.Column{GroupsColumns[0]},
OnDelete: schema.SetNull,
},
{
Symbol: "api_keys_users_api_keys",
Columns: []*schema.Column{APIKeysColumns[10]},
Columns: []*schema.Column{APIKeysColumns[13]},
RefColumns: []*schema.Column{UsersColumns[0]},
OnDelete: schema.NoAction,
},
@@ -46,12 +49,12 @@ var (
{
Name: "apikey_user_id",
Unique: false,
Columns: []*schema.Column{APIKeysColumns[10]},
Columns: []*schema.Column{APIKeysColumns[13]},
},
{
Name: "apikey_group_id",
Unique: false,
Columns: []*schema.Column{APIKeysColumns[9]},
Columns: []*schema.Column{APIKeysColumns[12]},
},
{
Name: "apikey_status",
@@ -63,6 +66,16 @@ var (
Unique: false,
Columns: []*schema.Column{APIKeysColumns[3]},
},
{
Name: "apikey_quota_quota_used",
Unique: false,
Columns: []*schema.Column{APIKeysColumns[9], APIKeysColumns[10]},
},
{
Name: "apikey_expires_at",
Unique: false,
Columns: []*schema.Column{APIKeysColumns[11]},
},
},
}
// AccountsColumns holds the columns for the "accounts" table.

View File

@@ -79,6 +79,11 @@ type APIKeyMutation struct {
appendip_whitelist []string
ip_blacklist *[]string
appendip_blacklist []string
quota *float64
addquota *float64
quota_used *float64
addquota_used *float64
expires_at *time.Time
clearedFields map[string]struct{}
user *int64
cleareduser bool
@@ -634,6 +639,167 @@ func (m *APIKeyMutation) ResetIPBlacklist() {
delete(m.clearedFields, apikey.FieldIPBlacklist)
}
// SetQuota sets the "quota" field.
func (m *APIKeyMutation) SetQuota(f float64) {
m.quota = &f
m.addquota = nil
}
// Quota returns the value of the "quota" field in the mutation.
func (m *APIKeyMutation) Quota() (r float64, exists bool) {
v := m.quota
if v == nil {
return
}
return *v, true
}
// OldQuota returns the old "quota" field's value of the APIKey entity.
// If the APIKey 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 *APIKeyMutation) OldQuota(ctx context.Context) (v float64, err error) {
if !m.op.Is(OpUpdateOne) {
return v, errors.New("OldQuota is only allowed on UpdateOne operations")
}
if m.id == nil || m.oldValue == nil {
return v, errors.New("OldQuota requires an ID field in the mutation")
}
oldValue, err := m.oldValue(ctx)
if err != nil {
return v, fmt.Errorf("querying old value for OldQuota: %w", err)
}
return oldValue.Quota, nil
}
// AddQuota adds f to the "quota" field.
func (m *APIKeyMutation) AddQuota(f float64) {
if m.addquota != nil {
*m.addquota += f
} else {
m.addquota = &f
}
}
// AddedQuota returns the value that was added to the "quota" field in this mutation.
func (m *APIKeyMutation) AddedQuota() (r float64, exists bool) {
v := m.addquota
if v == nil {
return
}
return *v, true
}
// ResetQuota resets all changes to the "quota" field.
func (m *APIKeyMutation) ResetQuota() {
m.quota = nil
m.addquota = nil
}
// SetQuotaUsed sets the "quota_used" field.
func (m *APIKeyMutation) SetQuotaUsed(f float64) {
m.quota_used = &f
m.addquota_used = nil
}
// QuotaUsed returns the value of the "quota_used" field in the mutation.
func (m *APIKeyMutation) QuotaUsed() (r float64, exists bool) {
v := m.quota_used
if v == nil {
return
}
return *v, true
}
// OldQuotaUsed returns the old "quota_used" field's value of the APIKey entity.
// If the APIKey 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 *APIKeyMutation) OldQuotaUsed(ctx context.Context) (v float64, err error) {
if !m.op.Is(OpUpdateOne) {
return v, errors.New("OldQuotaUsed is only allowed on UpdateOne operations")
}
if m.id == nil || m.oldValue == nil {
return v, errors.New("OldQuotaUsed requires an ID field in the mutation")
}
oldValue, err := m.oldValue(ctx)
if err != nil {
return v, fmt.Errorf("querying old value for OldQuotaUsed: %w", err)
}
return oldValue.QuotaUsed, nil
}
// AddQuotaUsed adds f to the "quota_used" field.
func (m *APIKeyMutation) AddQuotaUsed(f float64) {
if m.addquota_used != nil {
*m.addquota_used += f
} else {
m.addquota_used = &f
}
}
// AddedQuotaUsed returns the value that was added to the "quota_used" field in this mutation.
func (m *APIKeyMutation) AddedQuotaUsed() (r float64, exists bool) {
v := m.addquota_used
if v == nil {
return
}
return *v, true
}
// ResetQuotaUsed resets all changes to the "quota_used" field.
func (m *APIKeyMutation) ResetQuotaUsed() {
m.quota_used = nil
m.addquota_used = nil
}
// SetExpiresAt sets the "expires_at" field.
func (m *APIKeyMutation) SetExpiresAt(t time.Time) {
m.expires_at = &t
}
// ExpiresAt returns the value of the "expires_at" field in the mutation.
func (m *APIKeyMutation) 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 APIKey entity.
// If the APIKey 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 *APIKeyMutation) 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 *APIKeyMutation) ClearExpiresAt() {
m.expires_at = nil
m.clearedFields[apikey.FieldExpiresAt] = struct{}{}
}
// ExpiresAtCleared returns if the "expires_at" field was cleared in this mutation.
func (m *APIKeyMutation) ExpiresAtCleared() bool {
_, ok := m.clearedFields[apikey.FieldExpiresAt]
return ok
}
// ResetExpiresAt resets all changes to the "expires_at" field.
func (m *APIKeyMutation) ResetExpiresAt() {
m.expires_at = nil
delete(m.clearedFields, apikey.FieldExpiresAt)
}
// ClearUser clears the "user" edge to the User entity.
func (m *APIKeyMutation) ClearUser() {
m.cleareduser = true
@@ -776,7 +942,7 @@ func (m *APIKeyMutation) Type() string {
// order to get all numeric fields that were incremented/decremented, call
// AddedFields().
func (m *APIKeyMutation) Fields() []string {
fields := make([]string, 0, 10)
fields := make([]string, 0, 13)
if m.created_at != nil {
fields = append(fields, apikey.FieldCreatedAt)
}
@@ -807,6 +973,15 @@ func (m *APIKeyMutation) Fields() []string {
if m.ip_blacklist != nil {
fields = append(fields, apikey.FieldIPBlacklist)
}
if m.quota != nil {
fields = append(fields, apikey.FieldQuota)
}
if m.quota_used != nil {
fields = append(fields, apikey.FieldQuotaUsed)
}
if m.expires_at != nil {
fields = append(fields, apikey.FieldExpiresAt)
}
return fields
}
@@ -835,6 +1010,12 @@ func (m *APIKeyMutation) Field(name string) (ent.Value, bool) {
return m.IPWhitelist()
case apikey.FieldIPBlacklist:
return m.IPBlacklist()
case apikey.FieldQuota:
return m.Quota()
case apikey.FieldQuotaUsed:
return m.QuotaUsed()
case apikey.FieldExpiresAt:
return m.ExpiresAt()
}
return nil, false
}
@@ -864,6 +1045,12 @@ func (m *APIKeyMutation) OldField(ctx context.Context, name string) (ent.Value,
return m.OldIPWhitelist(ctx)
case apikey.FieldIPBlacklist:
return m.OldIPBlacklist(ctx)
case apikey.FieldQuota:
return m.OldQuota(ctx)
case apikey.FieldQuotaUsed:
return m.OldQuotaUsed(ctx)
case apikey.FieldExpiresAt:
return m.OldExpiresAt(ctx)
}
return nil, fmt.Errorf("unknown APIKey field %s", name)
}
@@ -943,6 +1130,27 @@ func (m *APIKeyMutation) SetField(name string, value ent.Value) error {
}
m.SetIPBlacklist(v)
return nil
case apikey.FieldQuota:
v, ok := value.(float64)
if !ok {
return fmt.Errorf("unexpected type %T for field %s", value, name)
}
m.SetQuota(v)
return nil
case apikey.FieldQuotaUsed:
v, ok := value.(float64)
if !ok {
return fmt.Errorf("unexpected type %T for field %s", value, name)
}
m.SetQuotaUsed(v)
return nil
case apikey.FieldExpiresAt:
v, ok := value.(time.Time)
if !ok {
return fmt.Errorf("unexpected type %T for field %s", value, name)
}
m.SetExpiresAt(v)
return nil
}
return fmt.Errorf("unknown APIKey field %s", name)
}
@@ -951,6 +1159,12 @@ func (m *APIKeyMutation) SetField(name string, value ent.Value) error {
// this mutation.
func (m *APIKeyMutation) AddedFields() []string {
var fields []string
if m.addquota != nil {
fields = append(fields, apikey.FieldQuota)
}
if m.addquota_used != nil {
fields = append(fields, apikey.FieldQuotaUsed)
}
return fields
}
@@ -959,6 +1173,10 @@ func (m *APIKeyMutation) AddedFields() []string {
// was not set, or was not defined in the schema.
func (m *APIKeyMutation) AddedField(name string) (ent.Value, bool) {
switch name {
case apikey.FieldQuota:
return m.AddedQuota()
case apikey.FieldQuotaUsed:
return m.AddedQuotaUsed()
}
return nil, false
}
@@ -968,6 +1186,20 @@ func (m *APIKeyMutation) AddedField(name string) (ent.Value, bool) {
// type.
func (m *APIKeyMutation) AddField(name string, value ent.Value) error {
switch name {
case apikey.FieldQuota:
v, ok := value.(float64)
if !ok {
return fmt.Errorf("unexpected type %T for field %s", value, name)
}
m.AddQuota(v)
return nil
case apikey.FieldQuotaUsed:
v, ok := value.(float64)
if !ok {
return fmt.Errorf("unexpected type %T for field %s", value, name)
}
m.AddQuotaUsed(v)
return nil
}
return fmt.Errorf("unknown APIKey numeric field %s", name)
}
@@ -988,6 +1220,9 @@ func (m *APIKeyMutation) ClearedFields() []string {
if m.FieldCleared(apikey.FieldIPBlacklist) {
fields = append(fields, apikey.FieldIPBlacklist)
}
if m.FieldCleared(apikey.FieldExpiresAt) {
fields = append(fields, apikey.FieldExpiresAt)
}
return fields
}
@@ -1014,6 +1249,9 @@ func (m *APIKeyMutation) ClearField(name string) error {
case apikey.FieldIPBlacklist:
m.ClearIPBlacklist()
return nil
case apikey.FieldExpiresAt:
m.ClearExpiresAt()
return nil
}
return fmt.Errorf("unknown APIKey nullable field %s", name)
}
@@ -1052,6 +1290,15 @@ func (m *APIKeyMutation) ResetField(name string) error {
case apikey.FieldIPBlacklist:
m.ResetIPBlacklist()
return nil
case apikey.FieldQuota:
m.ResetQuota()
return nil
case apikey.FieldQuotaUsed:
m.ResetQuotaUsed()
return nil
case apikey.FieldExpiresAt:
m.ResetExpiresAt()
return nil
}
return fmt.Errorf("unknown APIKey field %s", name)
}

View File

@@ -91,6 +91,14 @@ func init() {
apikey.DefaultStatus = apikeyDescStatus.Default.(string)
// apikey.StatusValidator is a validator for the "status" field. It is called by the builders before save.
apikey.StatusValidator = apikeyDescStatus.Validators[0].(func(string) error)
// apikeyDescQuota is the schema descriptor for quota field.
apikeyDescQuota := apikeyFields[7].Descriptor()
// apikey.DefaultQuota holds the default value on creation for the quota field.
apikey.DefaultQuota = apikeyDescQuota.Default.(float64)
// apikeyDescQuotaUsed is the schema descriptor for quota_used field.
apikeyDescQuotaUsed := apikeyFields[8].Descriptor()
// apikey.DefaultQuotaUsed holds the default value on creation for the quota_used field.
apikey.DefaultQuotaUsed = apikeyDescQuotaUsed.Default.(float64)
accountMixin := schema.Account{}.Mixin()
accountMixinHooks1 := accountMixin[1].Hooks()
account.Hooks[0] = accountMixinHooks1[0]

View File

@@ -5,6 +5,7 @@ import (
"github.com/Wei-Shaw/sub2api/internal/domain"
"entgo.io/ent"
"entgo.io/ent/dialect"
"entgo.io/ent/dialect/entsql"
"entgo.io/ent/schema"
"entgo.io/ent/schema/edge"
@@ -52,6 +53,23 @@ func (APIKey) Fields() []ent.Field {
field.JSON("ip_blacklist", []string{}).
Optional().
Comment("Blocked IPs/CIDRs"),
// ========== Quota fields ==========
// Quota limit in USD (0 = unlimited)
field.Float("quota").
SchemaType(map[string]string{dialect.Postgres: "decimal(20,8)"}).
Default(0).
Comment("Quota limit in USD for this API key (0 = unlimited)"),
// Used quota amount
field.Float("quota_used").
SchemaType(map[string]string{dialect.Postgres: "decimal(20,8)"}).
Default(0).
Comment("Used quota amount in USD"),
// Expiration time (nil = never expires)
field.Time("expires_at").
Optional().
Nillable().
Comment("Expiration time for this API key (null = never expires)"),
}
}
@@ -77,5 +95,8 @@ func (APIKey) Indexes() []ent.Index {
index.Fields("group_id"),
index.Fields("status"),
index.Fields("deleted_at"),
// Index for quota queries
index.Fields("quota", "quota_used"),
index.Fields("expires_at"),
}
}

View File

@@ -3,6 +3,7 @@ package handler
import (
"strconv"
"time"
"github.com/Wei-Shaw/sub2api/internal/handler/dto"
"github.com/Wei-Shaw/sub2api/internal/pkg/pagination"
@@ -27,11 +28,13 @@ func NewAPIKeyHandler(apiKeyService *service.APIKeyService) *APIKeyHandler {
// CreateAPIKeyRequest represents the create API key request payload
type CreateAPIKeyRequest struct {
Name string `json:"name" binding:"required"`
GroupID *int64 `json:"group_id"` // nullable
CustomKey *string `json:"custom_key"` // 可选的自定义key
IPWhitelist []string `json:"ip_whitelist"` // IP 白名单
IPBlacklist []string `json:"ip_blacklist"` // IP 黑名单
Name string `json:"name" binding:"required"`
GroupID *int64 `json:"group_id"` // nullable
CustomKey *string `json:"custom_key"` // 可选的自定义key
IPWhitelist []string `json:"ip_whitelist"` // IP 白名单
IPBlacklist []string `json:"ip_blacklist"` // IP 黑名单
Quota *float64 `json:"quota"` // 配额限制 (USD)
ExpiresInDays *int `json:"expires_in_days"` // 过期天数
}
// UpdateAPIKeyRequest represents the update API key request payload
@@ -41,6 +44,9 @@ type UpdateAPIKeyRequest struct {
Status string `json:"status" binding:"omitempty,oneof=active inactive"`
IPWhitelist []string `json:"ip_whitelist"` // IP 白名单
IPBlacklist []string `json:"ip_blacklist"` // IP 黑名单
Quota *float64 `json:"quota"` // 配额限制 (USD), 0=无限制
ExpiresAt *string `json:"expires_at"` // 过期时间 (ISO 8601)
ResetQuota *bool `json:"reset_quota"` // 重置已用配额
}
// List handles listing user's API keys with pagination
@@ -114,11 +120,15 @@ func (h *APIKeyHandler) Create(c *gin.Context) {
}
svcReq := service.CreateAPIKeyRequest{
Name: req.Name,
GroupID: req.GroupID,
CustomKey: req.CustomKey,
IPWhitelist: req.IPWhitelist,
IPBlacklist: req.IPBlacklist,
Name: req.Name,
GroupID: req.GroupID,
CustomKey: req.CustomKey,
IPWhitelist: req.IPWhitelist,
IPBlacklist: req.IPBlacklist,
ExpiresInDays: req.ExpiresInDays,
}
if req.Quota != nil {
svcReq.Quota = *req.Quota
}
key, err := h.apiKeyService.Create(c.Request.Context(), subject.UserID, svcReq)
if err != nil {
@@ -153,6 +163,8 @@ func (h *APIKeyHandler) Update(c *gin.Context) {
svcReq := service.UpdateAPIKeyRequest{
IPWhitelist: req.IPWhitelist,
IPBlacklist: req.IPBlacklist,
Quota: req.Quota,
ResetQuota: req.ResetQuota,
}
if req.Name != "" {
svcReq.Name = &req.Name
@@ -161,6 +173,21 @@ func (h *APIKeyHandler) Update(c *gin.Context) {
if req.Status != "" {
svcReq.Status = &req.Status
}
// Parse expires_at if provided
if req.ExpiresAt != nil {
if *req.ExpiresAt == "" {
// Empty string means clear expiration
svcReq.ExpiresAt = nil
svcReq.ClearExpiration = true
} else {
t, err := time.Parse(time.RFC3339, *req.ExpiresAt)
if err != nil {
response.BadRequest(c, "Invalid expires_at format: "+err.Error())
return
}
svcReq.ExpiresAt = &t
}
}
key, err := h.apiKeyService.Update(c.Request.Context(), keyID, subject.UserID, svcReq)
if err != nil {

View File

@@ -76,6 +76,9 @@ func APIKeyFromService(k *service.APIKey) *APIKey {
Status: k.Status,
IPWhitelist: k.IPWhitelist,
IPBlacklist: k.IPBlacklist,
Quota: k.Quota,
QuotaUsed: k.QuotaUsed,
ExpiresAt: k.ExpiresAt,
CreatedAt: k.CreatedAt,
UpdatedAt: k.UpdatedAt,
User: UserFromServiceShallow(k.User),

View File

@@ -32,16 +32,19 @@ type AdminUser struct {
}
type APIKey struct {
ID int64 `json:"id"`
UserID int64 `json:"user_id"`
Key string `json:"key"`
Name string `json:"name"`
GroupID *int64 `json:"group_id"`
Status string `json:"status"`
IPWhitelist []string `json:"ip_whitelist"`
IPBlacklist []string `json:"ip_blacklist"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
ID int64 `json:"id"`
UserID int64 `json:"user_id"`
Key string `json:"key"`
Name string `json:"name"`
GroupID *int64 `json:"group_id"`
Status string `json:"status"`
IPWhitelist []string `json:"ip_whitelist"`
IPBlacklist []string `json:"ip_blacklist"`
Quota float64 `json:"quota"` // Quota limit in USD (0 = unlimited)
QuotaUsed float64 `json:"quota_used"` // Used quota amount in USD
ExpiresAt *time.Time `json:"expires_at"` // Expiration time (nil = never expires)
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
User *User `json:"user,omitempty"`
Group *Group `json:"group,omitempty"`

View File

@@ -31,6 +31,7 @@ type GatewayHandler struct {
userService *service.UserService
billingCacheService *service.BillingCacheService
usageService *service.UsageService
apiKeyService *service.APIKeyService
concurrencyHelper *ConcurrencyHelper
maxAccountSwitches int
maxAccountSwitchesGemini int
@@ -45,6 +46,7 @@ func NewGatewayHandler(
concurrencyService *service.ConcurrencyService,
billingCacheService *service.BillingCacheService,
usageService *service.UsageService,
apiKeyService *service.APIKeyService,
cfg *config.Config,
) *GatewayHandler {
pingInterval := time.Duration(0)
@@ -66,6 +68,7 @@ func NewGatewayHandler(
userService: userService,
billingCacheService: billingCacheService,
usageService: usageService,
apiKeyService: apiKeyService,
concurrencyHelper: NewConcurrencyHelper(concurrencyService, SSEPingFormatClaude, pingInterval),
maxAccountSwitches: maxAccountSwitches,
maxAccountSwitchesGemini: maxAccountSwitchesGemini,
@@ -316,13 +319,14 @@ func (h *GatewayHandler) Messages(c *gin.Context) {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if err := h.gatewayService.RecordUsage(ctx, &service.RecordUsageInput{
Result: result,
APIKey: apiKey,
User: apiKey.User,
Account: usedAccount,
Subscription: subscription,
UserAgent: ua,
IPAddress: clientIP,
Result: result,
APIKey: apiKey,
User: apiKey.User,
Account: usedAccount,
Subscription: subscription,
UserAgent: ua,
IPAddress: clientIP,
APIKeyService: h.apiKeyService,
}); err != nil {
log.Printf("Record usage failed: %v", err)
}
@@ -452,13 +456,14 @@ func (h *GatewayHandler) Messages(c *gin.Context) {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if err := h.gatewayService.RecordUsage(ctx, &service.RecordUsageInput{
Result: result,
APIKey: apiKey,
User: apiKey.User,
Account: usedAccount,
Subscription: subscription,
UserAgent: ua,
IPAddress: clientIP,
Result: result,
APIKey: apiKey,
User: apiKey.User,
Account: usedAccount,
Subscription: subscription,
UserAgent: ua,
IPAddress: clientIP,
APIKeyService: h.apiKeyService,
}); err != nil {
log.Printf("Record usage failed: %v", err)
}

View File

@@ -381,6 +381,7 @@ func (h *GatewayHandler) GeminiV1BetaModels(c *gin.Context) {
IPAddress: ip,
LongContextThreshold: 200000, // Gemini 200K 阈值
LongContextMultiplier: 2.0, // 超出部分双倍计费
APIKeyService: h.apiKeyService,
}); err != nil {
log.Printf("Record usage failed: %v", err)
}

View File

@@ -24,6 +24,7 @@ import (
type OpenAIGatewayHandler struct {
gatewayService *service.OpenAIGatewayService
billingCacheService *service.BillingCacheService
apiKeyService *service.APIKeyService
concurrencyHelper *ConcurrencyHelper
maxAccountSwitches int
}
@@ -33,6 +34,7 @@ func NewOpenAIGatewayHandler(
gatewayService *service.OpenAIGatewayService,
concurrencyService *service.ConcurrencyService,
billingCacheService *service.BillingCacheService,
apiKeyService *service.APIKeyService,
cfg *config.Config,
) *OpenAIGatewayHandler {
pingInterval := time.Duration(0)
@@ -46,6 +48,7 @@ func NewOpenAIGatewayHandler(
return &OpenAIGatewayHandler{
gatewayService: gatewayService,
billingCacheService: billingCacheService,
apiKeyService: apiKeyService,
concurrencyHelper: NewConcurrencyHelper(concurrencyService, SSEPingFormatComment, pingInterval),
maxAccountSwitches: maxAccountSwitches,
}
@@ -299,13 +302,14 @@ func (h *OpenAIGatewayHandler) Responses(c *gin.Context) {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if err := h.gatewayService.RecordUsage(ctx, &service.OpenAIRecordUsageInput{
Result: result,
APIKey: apiKey,
User: apiKey.User,
Account: usedAccount,
Subscription: subscription,
UserAgent: ua,
IPAddress: ip,
Result: result,
APIKey: apiKey,
User: apiKey.User,
Account: usedAccount,
Subscription: subscription,
UserAgent: ua,
IPAddress: ip,
APIKeyService: h.apiKeyService,
}); err != nil {
log.Printf("Record usage failed: %v", err)
}

View File

@@ -33,7 +33,10 @@ func (r *apiKeyRepository) Create(ctx context.Context, key *service.APIKey) erro
SetKey(key.Key).
SetName(key.Name).
SetStatus(key.Status).
SetNillableGroupID(key.GroupID)
SetNillableGroupID(key.GroupID).
SetQuota(key.Quota).
SetQuotaUsed(key.QuotaUsed).
SetNillableExpiresAt(key.ExpiresAt)
if len(key.IPWhitelist) > 0 {
builder.SetIPWhitelist(key.IPWhitelist)
@@ -110,6 +113,9 @@ func (r *apiKeyRepository) GetByKeyForAuth(ctx context.Context, key string) (*se
apikey.FieldStatus,
apikey.FieldIPWhitelist,
apikey.FieldIPBlacklist,
apikey.FieldQuota,
apikey.FieldQuotaUsed,
apikey.FieldExpiresAt,
).
WithUser(func(q *dbent.UserQuery) {
q.Select(
@@ -161,6 +167,8 @@ func (r *apiKeyRepository) Update(ctx context.Context, key *service.APIKey) erro
Where(apikey.IDEQ(key.ID), apikey.DeletedAtIsNil()).
SetName(key.Name).
SetStatus(key.Status).
SetQuota(key.Quota).
SetQuotaUsed(key.QuotaUsed).
SetUpdatedAt(now)
if key.GroupID != nil {
builder.SetGroupID(*key.GroupID)
@@ -168,6 +176,13 @@ func (r *apiKeyRepository) Update(ctx context.Context, key *service.APIKey) erro
builder.ClearGroupID()
}
// Expiration time
if key.ExpiresAt != nil {
builder.SetExpiresAt(*key.ExpiresAt)
} else {
builder.ClearExpiresAt()
}
// IP 限制字段
if len(key.IPWhitelist) > 0 {
builder.SetIPWhitelist(key.IPWhitelist)
@@ -357,6 +372,38 @@ func (r *apiKeyRepository) ListKeysByGroupID(ctx context.Context, groupID int64)
return keys, nil
}
// IncrementQuotaUsed atomically increments the quota_used field and returns the new value
func (r *apiKeyRepository) IncrementQuotaUsed(ctx context.Context, id int64, amount float64) (float64, error) {
// Use raw SQL for atomic increment to avoid race conditions
// First get current value
m, err := r.activeQuery().
Where(apikey.IDEQ(id)).
Select(apikey.FieldQuotaUsed).
Only(ctx)
if err != nil {
if dbent.IsNotFound(err) {
return 0, service.ErrAPIKeyNotFound
}
return 0, err
}
newValue := m.QuotaUsed + amount
// Update with new value
affected, err := r.client.APIKey.Update().
Where(apikey.IDEQ(id), apikey.DeletedAtIsNil()).
SetQuotaUsed(newValue).
Save(ctx)
if err != nil {
return 0, err
}
if affected == 0 {
return 0, service.ErrAPIKeyNotFound
}
return newValue, nil
}
func apiKeyEntityToService(m *dbent.APIKey) *service.APIKey {
if m == nil {
return nil
@@ -372,6 +419,9 @@ func apiKeyEntityToService(m *dbent.APIKey) *service.APIKey {
CreatedAt: m.CreatedAt,
UpdatedAt: m.UpdatedAt,
GroupID: m.GroupID,
Quota: m.Quota,
QuotaUsed: m.QuotaUsed,
ExpiresAt: m.ExpiresAt,
}
if m.Edges.User != nil {
out.User = userEntityToService(m.Edges.User)

View File

@@ -83,6 +83,9 @@ func TestAPIContracts(t *testing.T) {
"status": "active",
"ip_whitelist": null,
"ip_blacklist": null,
"quota": 0,
"quota_used": 0,
"expires_at": null,
"created_at": "2025-01-02T03:04:05Z",
"updated_at": "2025-01-02T03:04:05Z"
}
@@ -119,6 +122,9 @@ func TestAPIContracts(t *testing.T) {
"status": "active",
"ip_whitelist": null,
"ip_blacklist": null,
"quota": 0,
"quota_used": 0,
"expires_at": null,
"created_at": "2025-01-02T03:04:05Z",
"updated_at": "2025-01-02T03:04:05Z"
}
@@ -1442,6 +1448,10 @@ func (r *stubApiKeyRepo) ListKeysByGroupID(ctx context.Context, groupID int64) (
return nil, errors.New("not implemented")
}
func (r *stubApiKeyRepo) IncrementQuotaUsed(ctx context.Context, id int64, amount float64) (float64, error) {
return 0, errors.New("not implemented")
}
type stubUsageLogRepo struct {
userLogs map[int64][]service.UsageLog
}

View File

@@ -70,7 +70,27 @@ func apiKeyAuthWithSubscription(apiKeyService *service.APIKeyService, subscripti
// 检查API key是否激活
if !apiKey.IsActive() {
AbortWithError(c, 401, "API_KEY_DISABLED", "API key is disabled")
// Provide more specific error message based on status
switch apiKey.Status {
case service.StatusAPIKeyQuotaExhausted:
AbortWithError(c, 429, "API_KEY_QUOTA_EXHAUSTED", "API key 额度已用完")
case service.StatusAPIKeyExpired:
AbortWithError(c, 403, "API_KEY_EXPIRED", "API key 已过期")
default:
AbortWithError(c, 401, "API_KEY_DISABLED", "API key is disabled")
}
return
}
// 检查API Key是否过期即使状态是active也要检查时间
if apiKey.IsExpired() {
AbortWithError(c, 403, "API_KEY_EXPIRED", "API key 已过期")
return
}
// 检查API Key配额是否耗尽
if apiKey.IsQuotaExhausted() {
AbortWithError(c, 429, "API_KEY_QUOTA_EXHAUSTED", "API key 额度已用完")
return
}

View File

@@ -75,6 +75,9 @@ func (f fakeAPIKeyRepo) ListKeysByUserID(ctx context.Context, userID int64) ([]s
func (f fakeAPIKeyRepo) ListKeysByGroupID(ctx context.Context, groupID int64) ([]string, error) {
return nil, errors.New("not implemented")
}
func (f fakeAPIKeyRepo) IncrementQuotaUsed(ctx context.Context, id int64, amount float64) (float64, error) {
return 0, errors.New("not implemented")
}
type googleErrorResponse struct {
Error struct {

View File

@@ -319,6 +319,10 @@ func (r *stubApiKeyRepo) ListKeysByGroupID(ctx context.Context, groupID int64) (
return nil, errors.New("not implemented")
}
func (r *stubApiKeyRepo) IncrementQuotaUsed(ctx context.Context, id int64, amount float64) (float64, error) {
return 0, errors.New("not implemented")
}
type stubUserSubscriptionRepo struct {
getActive func(ctx context.Context, userID, groupID int64) (*service.UserSubscription, error)
updateStatus func(ctx context.Context, subscriptionID int64, status string) error

View File

@@ -2,6 +2,14 @@ package service
import "time"
// API Key status constants
const (
StatusAPIKeyActive = "active"
StatusAPIKeyDisabled = "disabled"
StatusAPIKeyQuotaExhausted = "quota_exhausted"
StatusAPIKeyExpired = "expired"
)
type APIKey struct {
ID int64
UserID int64
@@ -15,8 +23,53 @@ type APIKey struct {
UpdatedAt time.Time
User *User
Group *Group
// Quota fields
Quota float64 // Quota limit in USD (0 = unlimited)
QuotaUsed float64 // Used quota amount
ExpiresAt *time.Time // Expiration time (nil = never expires)
}
func (k *APIKey) IsActive() bool {
return k.Status == StatusActive
}
// IsExpired checks if the API key has expired
func (k *APIKey) IsExpired() bool {
if k.ExpiresAt == nil {
return false
}
return time.Now().After(*k.ExpiresAt)
}
// IsQuotaExhausted checks if the API key quota is exhausted
func (k *APIKey) IsQuotaExhausted() bool {
if k.Quota <= 0 {
return false // unlimited
}
return k.QuotaUsed >= k.Quota
}
// GetQuotaRemaining returns remaining quota (-1 for unlimited)
func (k *APIKey) GetQuotaRemaining() float64 {
if k.Quota <= 0 {
return -1 // unlimited
}
remaining := k.Quota - k.QuotaUsed
if remaining < 0 {
return 0
}
return remaining
}
// GetDaysUntilExpiry returns days until expiry (-1 for never expires)
func (k *APIKey) GetDaysUntilExpiry() int {
if k.ExpiresAt == nil {
return -1 // never expires
}
duration := time.Until(*k.ExpiresAt)
if duration < 0 {
return 0
}
return int(duration.Hours() / 24)
}

View File

@@ -1,5 +1,7 @@
package service
import "time"
// APIKeyAuthSnapshot API Key 认证缓存快照(仅包含认证所需字段)
type APIKeyAuthSnapshot struct {
APIKeyID int64 `json:"api_key_id"`
@@ -10,6 +12,13 @@ type APIKeyAuthSnapshot struct {
IPBlacklist []string `json:"ip_blacklist,omitempty"`
User APIKeyAuthUserSnapshot `json:"user"`
Group *APIKeyAuthGroupSnapshot `json:"group,omitempty"`
// Quota fields for API Key independent quota feature
Quota float64 `json:"quota"` // Quota limit in USD (0 = unlimited)
QuotaUsed float64 `json:"quota_used"` // Used quota amount
// Expiration field for API Key expiration feature
ExpiresAt *time.Time `json:"expires_at,omitempty"` // Expiration time (nil = never expires)
}
// APIKeyAuthUserSnapshot 用户快照

View File

@@ -213,6 +213,9 @@ func (s *APIKeyService) snapshotFromAPIKey(apiKey *APIKey) *APIKeyAuthSnapshot {
Status: apiKey.Status,
IPWhitelist: apiKey.IPWhitelist,
IPBlacklist: apiKey.IPBlacklist,
Quota: apiKey.Quota,
QuotaUsed: apiKey.QuotaUsed,
ExpiresAt: apiKey.ExpiresAt,
User: APIKeyAuthUserSnapshot{
ID: apiKey.User.ID,
Status: apiKey.User.Status,
@@ -256,6 +259,9 @@ func (s *APIKeyService) snapshotToAPIKey(key string, snapshot *APIKeyAuthSnapsho
Status: snapshot.Status,
IPWhitelist: snapshot.IPWhitelist,
IPBlacklist: snapshot.IPBlacklist,
Quota: snapshot.Quota,
QuotaUsed: snapshot.QuotaUsed,
ExpiresAt: snapshot.ExpiresAt,
User: &User{
ID: snapshot.User.ID,
Status: snapshot.User.Status,

View File

@@ -24,6 +24,10 @@ var (
ErrAPIKeyInvalidChars = infraerrors.BadRequest("API_KEY_INVALID_CHARS", "api key can only contain letters, numbers, underscores, and hyphens")
ErrAPIKeyRateLimited = infraerrors.TooManyRequests("API_KEY_RATE_LIMITED", "too many failed attempts, please try again later")
ErrInvalidIPPattern = infraerrors.BadRequest("INVALID_IP_PATTERN", "invalid IP or CIDR pattern")
// ErrAPIKeyExpired = infraerrors.Forbidden("API_KEY_EXPIRED", "api key has expired")
ErrAPIKeyExpired = infraerrors.Forbidden("API_KEY_EXPIRED", "api key 已过期")
// ErrAPIKeyQuotaExhausted = infraerrors.TooManyRequests("API_KEY_QUOTA_EXHAUSTED", "api key quota exhausted")
ErrAPIKeyQuotaExhausted = infraerrors.TooManyRequests("API_KEY_QUOTA_EXHAUSTED", "api key 额度已用完")
)
const (
@@ -51,6 +55,9 @@ type APIKeyRepository interface {
CountByGroupID(ctx context.Context, groupID int64) (int64, error)
ListKeysByUserID(ctx context.Context, userID int64) ([]string, error)
ListKeysByGroupID(ctx context.Context, groupID int64) ([]string, error)
// Quota methods
IncrementQuotaUsed(ctx context.Context, id int64, amount float64) (float64, error)
}
// APIKeyCache defines cache operations for API key service
@@ -85,6 +92,10 @@ type CreateAPIKeyRequest struct {
CustomKey *string `json:"custom_key"` // 可选的自定义key
IPWhitelist []string `json:"ip_whitelist"` // IP 白名单
IPBlacklist []string `json:"ip_blacklist"` // IP 黑名单
// Quota fields
Quota float64 `json:"quota"` // Quota limit in USD (0 = unlimited)
ExpiresInDays *int `json:"expires_in_days"` // Days until expiry (nil = never expires)
}
// UpdateAPIKeyRequest 更新API Key请求
@@ -94,6 +105,12 @@ type UpdateAPIKeyRequest struct {
Status *string `json:"status"`
IPWhitelist []string `json:"ip_whitelist"` // IP 白名单(空数组清空)
IPBlacklist []string `json:"ip_blacklist"` // IP 黑名单(空数组清空)
// Quota fields
Quota *float64 `json:"quota"` // Quota limit in USD (nil = no change, 0 = unlimited)
ExpiresAt *time.Time `json:"expires_at"` // Expiration time (nil = no change)
ClearExpiration bool `json:"-"` // Clear expiration (internal use)
ResetQuota *bool `json:"reset_quota"` // Reset quota_used to 0
}
// APIKeyService API Key服务
@@ -289,6 +306,14 @@ func (s *APIKeyService) Create(ctx context.Context, userID int64, req CreateAPIK
Status: StatusActive,
IPWhitelist: req.IPWhitelist,
IPBlacklist: req.IPBlacklist,
Quota: req.Quota,
QuotaUsed: 0,
}
// Set expiration time if specified
if req.ExpiresInDays != nil && *req.ExpiresInDays > 0 {
expiresAt := time.Now().AddDate(0, 0, *req.ExpiresInDays)
apiKey.ExpiresAt = &expiresAt
}
if err := s.apiKeyRepo.Create(ctx, apiKey); err != nil {
@@ -436,6 +461,35 @@ func (s *APIKeyService) Update(ctx context.Context, id int64, userID int64, req
}
}
// Update quota fields
if req.Quota != nil {
apiKey.Quota = *req.Quota
// If quota is increased and status was quota_exhausted, reactivate
if apiKey.Status == StatusAPIKeyQuotaExhausted && *req.Quota > apiKey.QuotaUsed {
apiKey.Status = StatusActive
}
}
if req.ResetQuota != nil && *req.ResetQuota {
apiKey.QuotaUsed = 0
// If resetting quota and status was quota_exhausted, reactivate
if apiKey.Status == StatusAPIKeyQuotaExhausted {
apiKey.Status = StatusActive
}
}
if req.ClearExpiration {
apiKey.ExpiresAt = nil
// If clearing expiry and status was expired, reactivate
if apiKey.Status == StatusAPIKeyExpired {
apiKey.Status = StatusActive
}
} else if req.ExpiresAt != nil {
apiKey.ExpiresAt = req.ExpiresAt
// If extending expiry and status was expired, reactivate
if apiKey.Status == StatusAPIKeyExpired && time.Now().Before(*req.ExpiresAt) {
apiKey.Status = StatusActive
}
}
// 更新 IP 限制(空数组会清空设置)
apiKey.IPWhitelist = req.IPWhitelist
apiKey.IPBlacklist = req.IPBlacklist
@@ -572,3 +626,51 @@ func (s *APIKeyService) SearchAPIKeys(ctx context.Context, userID int64, keyword
}
return keys, nil
}
// CheckAPIKeyQuotaAndExpiry checks if the API key is valid for use (not expired, quota not exhausted)
// Returns nil if valid, error if invalid
func (s *APIKeyService) CheckAPIKeyQuotaAndExpiry(apiKey *APIKey) error {
// Check expiration
if apiKey.IsExpired() {
return ErrAPIKeyExpired
}
// Check quota
if apiKey.IsQuotaExhausted() {
return ErrAPIKeyQuotaExhausted
}
return nil
}
// UpdateQuotaUsed updates the quota_used field after a request
// Also checks if quota is exhausted and updates status accordingly
func (s *APIKeyService) UpdateQuotaUsed(ctx context.Context, apiKeyID int64, cost float64) error {
if cost <= 0 {
return nil
}
// Use repository to atomically increment quota_used
newQuotaUsed, err := s.apiKeyRepo.IncrementQuotaUsed(ctx, apiKeyID, cost)
if err != nil {
return fmt.Errorf("increment quota used: %w", err)
}
// Check if quota is now exhausted and update status if needed
apiKey, err := s.apiKeyRepo.GetByID(ctx, apiKeyID)
if err != nil {
return nil // Don't fail the request, just log
}
// If quota is set and now exhausted, update status
if apiKey.Quota > 0 && newQuotaUsed >= apiKey.Quota {
apiKey.Status = StatusAPIKeyQuotaExhausted
if err := s.apiKeyRepo.Update(ctx, apiKey); err != nil {
return nil // Don't fail the request
}
// Invalidate cache so next request sees the new status
s.InvalidateAuthCacheByKey(ctx, apiKey.Key)
}
return nil
}

View File

@@ -99,6 +99,10 @@ func (s *authRepoStub) ListKeysByGroupID(ctx context.Context, groupID int64) ([]
return s.listKeysByGroupID(ctx, groupID)
}
func (s *authRepoStub) IncrementQuotaUsed(ctx context.Context, id int64, amount float64) (float64, error) {
panic("unexpected IncrementQuotaUsed call")
}
type authCacheStub struct {
getAuthCache func(ctx context.Context, key string) (*APIKeyAuthCacheEntry, error)
setAuthKeys []string

View File

@@ -118,6 +118,10 @@ func (s *apiKeyRepoStub) ListKeysByGroupID(ctx context.Context, groupID int64) (
panic("unexpected ListKeysByGroupID call")
}
func (s *apiKeyRepoStub) IncrementQuotaUsed(ctx context.Context, id int64, amount float64) (float64, error) {
panic("unexpected IncrementQuotaUsed call")
}
// apiKeyCacheStub 是 APIKeyCache 接口的测试桩实现。
// 用于验证删除操作时缓存清理逻辑是否被正确调用。
//

View File

@@ -4489,13 +4489,19 @@ func (s *GatewayService) replaceToolNamesInResponseBody(body []byte, toolNameMap
// RecordUsageInput 记录使用量的输入参数
type RecordUsageInput struct {
Result *ForwardResult
APIKey *APIKey
User *User
Account *Account
Subscription *UserSubscription // 可选:订阅信息
UserAgent string // 请求的 User-Agent
IPAddress string // 请求的客户端 IP 地址
Result *ForwardResult
APIKey *APIKey
User *User
Account *Account
Subscription *UserSubscription // 可选:订阅信息
UserAgent string // 请求的 User-Agent
IPAddress string // 请求的客户端 IP 地址
APIKeyService APIKeyQuotaUpdater // 可选用于更新API Key配额
}
// APIKeyQuotaUpdater defines the interface for updating API Key quota
type APIKeyQuotaUpdater interface {
UpdateQuotaUsed(ctx context.Context, apiKeyID int64, cost float64) error
}
// RecordUsage 记录使用量并扣费(或更新订阅用量)
@@ -4635,6 +4641,13 @@ func (s *GatewayService) RecordUsage(ctx context.Context, input *RecordUsageInpu
}
}
// 更新 API Key 配额(如果设置了配额限制)
if shouldBill && cost.ActualCost > 0 && apiKey.Quota > 0 && input.APIKeyService != nil {
if err := input.APIKeyService.UpdateQuotaUsed(ctx, apiKey.ID, cost.ActualCost); err != nil {
log.Printf("Update API key quota failed: %v", err)
}
}
// Schedule batch update for account last_used_at
s.deferredService.ScheduleLastUsedUpdate(account.ID)
@@ -4652,6 +4665,7 @@ type RecordUsageLongContextInput struct {
IPAddress string // 请求的客户端 IP 地址
LongContextThreshold int // 长上下文阈值(如 200000
LongContextMultiplier float64 // 超出阈值部分的倍率(如 2.0
APIKeyService *APIKeyService // API Key 配额服务(可选)
}
// RecordUsageWithLongContext 记录使用量并扣费,支持长上下文双倍计费(用于 Gemini
@@ -4788,6 +4802,12 @@ func (s *GatewayService) RecordUsageWithLongContext(ctx context.Context, input *
}
// 异步更新余额缓存
s.billingCacheService.QueueDeductBalance(user.ID, cost.ActualCost)
// API Key 独立配额扣费
if input.APIKeyService != nil && apiKey.Quota > 0 {
if err := input.APIKeyService.UpdateQuotaUsed(ctx, apiKey.ID, cost.ActualCost); err != nil {
log.Printf("Add API key quota used failed: %v", err)
}
}
}
}

View File

@@ -1681,13 +1681,14 @@ func (s *OpenAIGatewayService) replaceModelInResponseBody(body []byte, fromModel
// OpenAIRecordUsageInput input for recording usage
type OpenAIRecordUsageInput struct {
Result *OpenAIForwardResult
APIKey *APIKey
User *User
Account *Account
Subscription *UserSubscription
UserAgent string // 请求的 User-Agent
IPAddress string // 请求的客户端 IP 地址
Result *OpenAIForwardResult
APIKey *APIKey
User *User
Account *Account
Subscription *UserSubscription
UserAgent string // 请求的 User-Agent
IPAddress string // 请求的客户端 IP 地址
APIKeyService APIKeyQuotaUpdater
}
// RecordUsage records usage and deducts balance
@@ -1799,6 +1800,13 @@ func (s *OpenAIGatewayService) RecordUsage(ctx context.Context, input *OpenAIRec
}
}
// Update API key quota if applicable (only for balance mode with quota set)
if shouldBill && cost.ActualCost > 0 && apiKey.Quota > 0 && input.APIKeyService != nil {
if err := input.APIKeyService.UpdateQuotaUsed(ctx, apiKey.ID, cost.ActualCost); err != nil {
log.Printf("Update API key quota failed: %v", err)
}
}
// Schedule batch update for account last_used_at
s.deferredService.ScheduleLastUsedUpdate(account.ID)

View File

@@ -0,0 +1,20 @@
-- Migration: Add quota fields to api_keys table
-- This migration adds independent quota and expiration support for API keys
-- Add quota limit field (0 = unlimited)
ALTER TABLE api_keys ADD COLUMN IF NOT EXISTS quota DECIMAL(20, 8) NOT NULL DEFAULT 0;
-- Add used quota amount field
ALTER TABLE api_keys ADD COLUMN IF NOT EXISTS quota_used DECIMAL(20, 8) NOT NULL DEFAULT 0;
-- Add expiration time field (NULL = never expires)
ALTER TABLE api_keys ADD COLUMN IF NOT EXISTS expires_at TIMESTAMPTZ;
-- Add indexes for efficient quota queries
CREATE INDEX IF NOT EXISTS idx_api_keys_quota_quota_used ON api_keys(quota, quota_used) WHERE deleted_at IS NULL;
CREATE INDEX IF NOT EXISTS idx_api_keys_expires_at ON api_keys(expires_at) WHERE deleted_at IS NULL;
-- Comment on columns for documentation
COMMENT ON COLUMN api_keys.quota IS 'Quota limit in USD for this API key (0 = unlimited)';
COMMENT ON COLUMN api_keys.quota_used IS 'Used quota amount in USD';
COMMENT ON COLUMN api_keys.expires_at IS 'Expiration time for this API key (null = never expires)';

View File

@@ -1,9 +0,0 @@
//go:build tools
// +build tools
package tools
import (
_ "entgo.io/ent/cmd/ent"
_ "github.com/google/wire/cmd/wire"
)

View File

@@ -44,6 +44,8 @@ export async function getById(id: number): Promise<ApiKey> {
* @param customKey - Optional custom key value
* @param ipWhitelist - Optional IP whitelist
* @param ipBlacklist - Optional IP blacklist
* @param quota - Optional quota limit in USD (0 = unlimited)
* @param expiresInDays - Optional days until expiry (undefined = never expires)
* @returns Created API key
*/
export async function create(
@@ -51,7 +53,9 @@ export async function create(
groupId?: number | null,
customKey?: string,
ipWhitelist?: string[],
ipBlacklist?: string[]
ipBlacklist?: string[],
quota?: number,
expiresInDays?: number
): Promise<ApiKey> {
const payload: CreateApiKeyRequest = { name }
if (groupId !== undefined) {
@@ -66,6 +70,12 @@ export async function create(
if (ipBlacklist && ipBlacklist.length > 0) {
payload.ip_blacklist = ipBlacklist
}
if (quota !== undefined && quota > 0) {
payload.quota = quota
}
if (expiresInDays !== undefined && expiresInDays > 0) {
payload.expires_in_days = expiresInDays
}
const { data } = await apiClient.post<ApiKey>('/keys', payload)
return data

View File

@@ -407,6 +407,7 @@ export default {
usage: 'Usage',
today: 'Today',
total: 'Total',
quota: 'Quota',
useKey: 'Use Key',
useKeyModal: {
title: 'Use API Key',
@@ -470,6 +471,33 @@ export default {
geminiCli: 'Gemini CLI',
geminiCliDesc: 'Import as Gemini CLI configuration',
},
// Quota and expiration
quotaLimit: 'Quota Limit',
quotaAmount: 'Quota Amount (USD)',
quotaAmountPlaceholder: 'Enter quota limit in USD',
quotaAmountHint: 'Set the maximum amount this key can spend. 0 = unlimited.',
quotaUsed: 'Quota Used',
reset: 'Reset',
resetQuotaUsed: 'Reset used quota to 0',
resetQuotaTitle: 'Confirm Reset Quota',
resetQuotaConfirmMessage: 'Are you sure you want to reset the used quota (${used}) for key "{name}" to 0? This action cannot be undone.',
quotaResetSuccess: 'Quota reset successfully',
failedToResetQuota: 'Failed to reset quota',
expiration: 'Expiration',
expiresInDays: '{days} days',
extendDays: '+{days} days',
customDate: 'Custom',
expirationDate: 'Expiration Date',
expirationDateHint: 'Select when this API key should expire.',
currentExpiration: 'Current expiration',
expiresAt: 'Expires',
noExpiration: 'Never',
status: {
active: 'Active',
inactive: 'Inactive',
quota_exhausted: 'Quota Exhausted',
expired: 'Expired',
},
},
// Usage

View File

@@ -291,7 +291,8 @@ export default {
sendingResetLink: '发送中...',
sendResetLinkFailed: '发送重置链接失败,请重试。',
resetEmailSent: '重置链接已发送',
resetEmailSentHint: '如果该邮箱已注册,您将很快收到密码重置链接。请检查您的收件箱和垃圾邮件文件夹。',
resetEmailSentHint:
'如果该邮箱已注册,您将很快收到密码重置链接。请检查您的收件箱和垃圾邮件文件夹。',
backToLogin: '返回登录',
rememberedPassword: '想起密码了?',
// 重置密码
@@ -404,6 +405,7 @@ export default {
usage: '用量',
today: '今日',
total: '累计',
quota: '额度',
useKey: '使用密钥',
useKeyModal: {
title: '使用 API 密钥',
@@ -412,36 +414,41 @@ export default {
copied: '已复制',
note: '这些环境变量将在当前终端会话中生效。如需永久配置,请将其添加到 ~/.bashrc、~/.zshrc 或相应的配置文件中。',
noGroupTitle: '请先分配分组',
noGroupDescription: '此 API 密钥尚未分配分组,请先在密钥列表中点击分组列进行分配,然后才能查看使用配置。',
noGroupDescription:
'此 API 密钥尚未分配分组,请先在密钥列表中点击分组列进行分配,然后才能查看使用配置。',
openai: {
description: '将以下配置文件添加到 Codex CLI 配置目录中。',
configTomlHint: '请确保以下内容位于 config.toml 文件的开头部分',
note: '请确保配置目录存在。macOS/Linux 用户可运行 mkdir -p ~/.codex 创建目录。',
noteWindows: '按 Win+R输入 %userprofile%\\.codex 打开配置目录。如目录不存在,请先手动创建。',
noteWindows:
'按 Win+R输入 %userprofile%\\.codex 打开配置目录。如目录不存在,请先手动创建。'
},
cliTabs: {
claudeCode: 'Claude Code',
geminiCli: 'Gemini CLI',
codexCli: 'Codex CLI',
opencode: 'OpenCode',
opencode: 'OpenCode'
},
antigravity: {
description: '为 Antigravity 分组配置 API 访问。请根据您使用的客户端选择对应的配置方式。',
claudeCode: 'Claude Code',
geminiCli: 'Gemini CLI',
claudeNote: '这些环境变量将在当前终端会话中生效。如需永久配置,请将其添加到 ~/.bashrc、~/.zshrc 或相应的配置文件中。',
geminiNote: '这些环境变量将在当前终端会话中生效。如需永久配置,请将其添加到 ~/.bashrc、~/.zshrc 或相应的配置文件中。',
claudeNote:
'这些环境变量将在当前终端会话中生效。如需永久配置,请将其添加到 ~/.bashrc、~/.zshrc 或相应的配置文件中。',
geminiNote:
'这些环境变量将在当前终端会话中生效。如需永久配置,请将其添加到 ~/.bashrc、~/.zshrc 或相应的配置文件中。'
},
gemini: {
description: '将以下环境变量添加到您的终端配置文件或直接在终端中运行,以配置 Gemini CLI 访问。',
description:
'将以下环境变量添加到您的终端配置文件或直接在终端中运行,以配置 Gemini CLI 访问。',
modelComment: '如果你有 Gemini 3 权限可以填gemini-3-pro-preview',
note: '这些环境变量将在当前终端会话中生效。如需永久配置,请将其添加到 ~/.bashrc、~/.zshrc 或相应的配置文件中。',
note: '这些环境变量将在当前终端会话中生效。如需永久配置,请将其添加到 ~/.bashrc、~/.zshrc 或相应的配置文件中。'
},
opencode: {
title: 'OpenCode 配置示例',
subtitle: 'opencode.json',
hint: '配置文件路径:~/.config/opencode/opencode.json或 opencode.jsonc不存在需手动创建。可使用默认 provideropenai/anthropic/google或自定义 provider_id。API Key 支持直接配置或通过客户端 /connect 命令配置。示例仅供参考,模型与选项可按需调整。',
},
hint: '配置文件路径:~/.config/opencode/opencode.json或 opencode.jsonc不存在需手动创建。可使用默认 provideropenai/anthropic/google或自定义 provider_id。API Key 支持直接配置或通过客户端 /connect 命令配置。示例仅供参考,模型与选项可按需调整。'
}
},
customKeyLabel: '自定义密钥',
customKeyPlaceholder: '输入自定义密钥至少16个字符',
@@ -457,15 +464,43 @@ export default {
ipBlacklistPlaceholder: '1.2.3.4\n5.6.0.0/16',
ipBlacklistHint: '每行一个 IP 或 CIDR这些 IP 将被禁止使用此密钥',
ipRestrictionEnabled: '已配置 IP 限制',
ccSwitchNotInstalled: 'CC-Switch 未安装或协议处理程序未注册。请先安装 CC-Switch 或手动复制 API 密钥。',
ccSwitchNotInstalled:
'CC-Switch 未安装或协议处理程序未注册。请先安装 CC-Switch 或手动复制 API 密钥。',
ccsClientSelect: {
title: '选择客户端',
description: '请选择您要导入到 CC-Switch 的客户端类型:',
claudeCode: 'Claude Code',
claudeCodeDesc: '导入为 Claude Code 配置',
geminiCli: 'Gemini CLI',
geminiCliDesc: '导入为 Gemini CLI 配置',
geminiCliDesc: '导入为 Gemini CLI 配置'
},
// 配额和有效期
quotaLimit: '额度限制',
quotaAmount: '额度金额 (USD)',
quotaAmountPlaceholder: '输入 USD 额度限制',
quotaAmountHint: '设置此密钥可消费的最大金额。0 = 无限制。',
quotaUsed: '已用额度',
reset: '重置',
resetQuotaUsed: '将已用额度重置为 0',
resetQuotaTitle: '确认重置额度',
resetQuotaConfirmMessage: '确定要将密钥 "{name}" 的已用额度(${used})重置为 0 吗?此操作不可撤销。',
quotaResetSuccess: '额度重置成功',
failedToResetQuota: '重置额度失败',
expiration: '密钥有效期',
expiresInDays: '{days} 天',
extendDays: '+{days} 天',
customDate: '自定义',
expirationDate: '过期时间',
expirationDateHint: '选择此 API 密钥的过期时间。',
currentExpiration: '当前过期时间',
expiresAt: '过期时间',
noExpiration: '永久有效',
status: {
active: '活跃',
inactive: '已停用',
quota_exhausted: '额度耗尽',
expired: '已过期'
}
},
// Usage
@@ -757,8 +792,8 @@ export default {
editUser: '编辑用户',
deleteUser: '删除用户',
deleteConfirmMessage: "确定要删除用户 '{email}' 吗?此操作无法撤销。",
searchPlaceholder: '搜索用户...',
searchUsers: '搜索用户...',
searchPlaceholder: '搜索用户邮箱或用户名、备注、支持模糊查询...',
searchUsers: '搜索用户邮箱或用户名、备注、支持模糊查询',
roleFilter: '角色筛选',
allRoles: '全部角色',
allStatus: '全部状态',
@@ -1028,9 +1063,11 @@ export default {
exclusiveHint: '专属分组,可以手动指定给特定用户',
exclusiveTooltip: {
title: '什么是专属分组?',
description: '开启后,用户在创建 API Key 时将无法看到此分组。只有管理员手动将用户分配到此分组后,用户才能使用。',
description:
'开启后,用户在创建 API Key 时将无法看到此分组。只有管理员手动将用户分配到此分组后,用户才能使用。',
example: '使用场景:',
exampleContent: '公开分组费率 0.8,您可以创建一个费率 0.7 的专属分组,手动分配给 VIP 用户,让他们享受更优惠的价格。'
exampleContent:
'公开分组费率 0.8,您可以创建一个费率 0.7 的专属分组,手动分配给 VIP 用户,让他们享受更优惠的价格。'
},
rateMultiplierHint: '1.0 = 标准费率0.5 = 半价2.0 = 双倍',
platforms: {
@@ -1094,7 +1131,8 @@ export default {
},
claudeCode: {
title: 'Claude Code 客户端限制',
tooltip: '启用后,此分组仅允许 Claude Code 官方客户端访问。非 Claude Code 请求将被拒绝或降级到指定分组。',
tooltip:
'启用后,此分组仅允许 Claude Code 官方客户端访问。非 Claude Code 请求将被拒绝或降级到指定分组。',
enabled: '仅限 Claude Code',
disabled: '允许所有客户端',
fallbackGroup: '降级分组',
@@ -1111,7 +1149,8 @@ export default {
},
modelRouting: {
title: '模型路由配置',
tooltip: '配置特定模型请求优先路由到指定账号。支持通配符匹配,如 claude-opus-* 匹配所有 opus 模型。',
tooltip:
'配置特定模型请求优先路由到指定账号。支持通配符匹配,如 claude-opus-* 匹配所有 opus 模型。',
enabled: '已启用',
disabled: '已禁用',
disabledHint: '启用后,配置的路由规则才会生效',
@@ -1614,8 +1653,7 @@ export default {
regenerate: '重新生成',
step2OpenUrl: '在浏览器中打开 URL 并完成授权',
openUrlDesc: '在新标签页中打开授权 URL登录您的 Claude 账号并授权。',
proxyWarning:
'注意:如果您配置了代理,请确保浏览器使用相同的代理访问授权页面。',
proxyWarning: '注意:如果您配置了代理,请确保浏览器使用相同的代理访问授权页面。',
step3EnterCode: '输入授权码',
authCodeDesc: '授权完成后,页面会显示一个授权码。复制并粘贴到下方:',
authCode: '授权码',
@@ -1647,45 +1685,50 @@ export default {
authCodeHint: '您可以直接复制整个链接或仅复制 code 参数值,系统会自动识别'
},
// Gemini specific
gemini: {
title: 'Gemini 账户授权',
followSteps: '请按照以下步骤完成 Gemini 账户的授权:',
step1GenerateUrl: '生成授权链接',
generateAuthUrl: '生成授权链接',
projectIdLabel: 'Project ID可选',
projectIdPlaceholder: '例如my-gcp-project 或 cloud-ai-companion-xxxxx',
projectIdHint: '留空则在兑换授权码后自动探测;若自动探测失败,可填写后重新生成授权链接再授权。',
howToGetProjectId: '如何获取',
step2OpenUrl: '在浏览器中打开链接并完成授权',
openUrlDesc: '请在新标签页中打开授权链接,登录您的 Google 账户并授权',
step3EnterCode: '输入回调链接或 Code',
authCodeDesc: '授权完成后,复制浏览器跳转后的回调链接(推荐)或仅复制 code粘贴到下方即可。',
authCode: '回调链接或 Code',
authCodePlaceholder: '方式1推荐粘贴回调链接\n方式2仅粘贴 code 参数的值',
authCodeHint: '系统会自动从链接中解析 code/state。',
gemini: {
title: 'Gemini 账户授权',
followSteps: '请按照以下步骤完成 Gemini 账户的授权:',
step1GenerateUrl: '生成授权链接',
generateAuthUrl: '生成授权链接',
projectIdLabel: 'Project ID可选',
projectIdPlaceholder: '例如my-gcp-project 或 cloud-ai-companion-xxxxx',
projectIdHint:
'留空则在兑换授权码后自动探测;若自动探测失败,可填写后重新生成授权链接再授权。',
howToGetProjectId: '如何获取',
step2OpenUrl: '在浏览器中打开链接并完成授权',
openUrlDesc: '请在新标签页中打开授权链接,登录您的 Google 账户并授权。',
step3EnterCode: '输入回调链接或 Code',
authCodeDesc:
'授权完成后,复制浏览器跳转后的回调链接(推荐)或仅复制 code粘贴到下方即可。',
authCode: '回调链接或 Code',
authCodePlaceholder: '方式1推荐粘贴回调链接\n方式2仅粘贴 code 参数的值',
authCodeHint: '系统会自动从链接中解析 code/state。',
redirectUri: 'Redirect URI',
redirectUriHint: '需要在 Google OAuth Client 中配置,且必须与此处完全一致。',
confirmRedirectUri: '我已在 Google OAuth Client 中配置了该 Redirect URI必须完全一致',
invalidRedirectUri: 'Redirect URI 必须是合法的 http(s) URL',
redirectUriNotConfirmed: '请确认 Redirect URI 已在 Google OAuth Client 中正确配置',
missingRedirectUri: '缺少 Redirect URI',
failedToGenerateUrl: '生成 Gemini 授权链接失败',
missingExchangeParams: '缺少 code / session_id / state',
failedToExchangeCode: 'Gemini 授权码兑换失败',
missingProjectId: 'GCP Project ID 获取失败:您的 Google 账号未关联有效的 GCP 项目。请前往 Google Cloud Console 激活 GCP 并绑定信用卡,或在授权时手动填写 Project ID。',
modelPassthrough: 'Gemini 直接转发模型',
modelPassthroughDesc: '所有模型请求将直接转发至 Gemini API不进行模型限制或映射。',
stateWarningTitle: '提示',
stateWarningDesc: '建议粘贴完整回调链接(包含 code 和 state',
oauthTypeLabel: 'OAuth 类型',
redirectUriNotConfirmed: '请确认 Redirect URI 已在 Google OAuth Client 中正确配置',
missingRedirectUri: '缺少 Redirect URI',
failedToGenerateUrl: '生成 Gemini 授权链接失败',
missingExchangeParams: '缺少 code / session_id / state',
failedToExchangeCode: 'Gemini 授权码兑换失败',
missingProjectId:
'GCP Project ID 获取失败:您的 Google 账号未关联有效的 GCP 项目。请前往 Google Cloud Console 激活 GCP 并绑定信用卡,或在授权时手动填写 Project ID。',
modelPassthrough: 'Gemini 直接转发模型',
modelPassthroughDesc: '所有模型请求将直接转发至 Gemini API不进行模型限制或映射。',
stateWarningTitle: '提示',
stateWarningDesc: '建议粘贴完整回调链接(包含 code 和 state',
oauthTypeLabel: 'OAuth 类型',
needsProjectId: '内置授权Code Assist',
needsProjectIdDesc: '需要 GCP 项目与 Project ID',
noProjectIdNeeded: '自定义授权AI Studio',
noProjectIdNeededDesc: '需管理员配置 OAuth Client',
aiStudioNotConfiguredShort: '未配置',
aiStudioNotConfiguredTip: 'AI Studio OAuth 未配置:请先设置 GEMINI_OAUTH_CLIENT_ID / GEMINI_OAUTH_CLIENT_SECRET并在 Google OAuth Client 添加 Redirect URIhttp://localhost:1455/auth/callbackConsent Screen scopes 需包含 https://www.googleapis.com/auth/generative-language.retriever',
aiStudioNotConfigured: 'AI Studio OAuth 未配置:请先设置 GEMINI_OAUTH_CLIENT_ID / GEMINI_OAUTH_CLIENT_SECRET并在 Google OAuth Client 添加 Redirect URIhttp://localhost:1455/auth/callback'
},
aiStudioNotConfiguredShort: '未配置',
aiStudioNotConfiguredTip:
'AI Studio OAuth 未配置:请先设置 GEMINI_OAUTH_CLIENT_ID / GEMINI_OAUTH_CLIENT_SECRET并在 Google OAuth Client 添加 Redirect URIhttp://localhost:1455/auth/callbackConsent Screen scopes 需包含 https://www.googleapis.com/auth/generative-language.retriever',
aiStudioNotConfigured:
'AI Studio OAuth 未配置:请先设置 GEMINI_OAUTH_CLIENT_ID / GEMINI_OAUTH_CLIENT_SECRET并在 Google OAuth Client 添加 Redirect URIhttp://localhost:1455/auth/callback'
},
// Antigravity specific
antigravity: {
title: 'Antigravity 账户授权',
@@ -1707,7 +1750,7 @@ export default {
missingExchangeParams: '缺少 code / session_id / state',
failedToExchangeCode: 'Antigravity 授权码兑换失败'
}
},
},
// Gemini specific (platform-wide)
gemini: {
helpButton: '使用帮助',
@@ -1722,7 +1765,8 @@ export default {
tier: {
label: '账号等级',
hint: '提示:系统会优先尝试自动识别账号等级;若自动识别不可用或失败,则使用你选择的等级作为回退(本地模拟配额)。',
aiStudioHint: 'AI Studio 的配额是按模型分别限流Pro/Flash 独立)。若已绑卡(按量付费),请选 Pay-as-you-go。',
aiStudioHint:
'AI Studio 的配额是按模型分别限流Pro/Flash 独立)。若已绑卡(按量付费),请选 Pay-as-you-go。',
googleOne: {
free: 'Google One Free',
pro: 'Google One Pro',
@@ -1866,9 +1910,9 @@ export default {
outputCopied: '输出已复制',
startingTestForAccount: '开始测试账号:{name}',
testAccountTypeLabel: '账号类型:{type}',
selectTestModel: '选择测试模型',
testModel: '测试模型',
testPrompt: '提示词:"hi"',
selectTestModel: '选择测试模型',
testModel: '测试模型',
testPrompt: '提示词:"hi"',
// Stats Modal
viewStats: '查看统计',
usageStatistics: '使用统计',
@@ -2547,7 +2591,7 @@ export default {
internal: '内部'
},
total: '总计:',
searchPlaceholder: '搜索 request_id / client_request_id / message',
searchPlaceholder: '搜索 request_id / client_request_id / message'
},
// Error Detail Modal
errorDetail: {
@@ -2978,7 +3022,8 @@ export default {
ignoreCountTokensErrors: '忽略 count_tokens 错误',
ignoreCountTokensErrorsHint: '启用后count_tokens 请求的错误将不会写入错误日志。',
ignoreContextCanceled: '忽略客户端断连错误',
ignoreContextCanceledHint: '启用后客户端主动断开连接context canceled的错误将不会写入错误日志。',
ignoreContextCanceledHint:
'启用后客户端主动断开连接context canceled的错误将不会写入错误日志。',
ignoreNoAvailableAccounts: '忽略无可用账号错误',
ignoreNoAvailableAccountsHint: '启用后,"No available accounts" 错误将不会写入错误日志(不推荐,这通常是配置问题)。',
ignoreInvalidApiKeyErrors: '忽略无效 API Key 错误',
@@ -3097,7 +3142,8 @@ export default {
siteKeyHint: '从 Cloudflare Dashboard 获取',
cloudflareDashboard: 'Cloudflare Dashboard',
secretKeyHint: '服务端验证密钥(请保密)',
secretKeyConfiguredHint: '密钥已配置,留空以保留当前值。' },
secretKeyConfiguredHint: '密钥已配置,留空以保留当前值。'
},
linuxdo: {
title: 'LinuxDo Connect 登录',
description: '配置 LinuxDo Connect OAuth用于 Sub2API 用户登录',
@@ -3151,9 +3197,12 @@ export default {
logoTypeError: '请选择图片文件',
logoReadError: '读取图片文件失败',
homeContent: '首页内容',
homeContentPlaceholder: '在此输入首页内容,支持 Markdown & HTML 代码。如果输入的是一个链接,则会使用该链接作为 iframe 的 src 属性。',
homeContentHint: '自定义首页内容,支持 Markdown/HTML。如果输入的是链接(以 http:// 或 https:// 开头),则会使用该链接作为 iframe 的 src 属性,这允许你设置任意网页作为首页。设置后首页的状态信息将不再显示。',
homeContentIframeWarning: '⚠️ iframe 模式提示:部分网站设置了 X-Frame-Options 或 CSP 安全策略,禁止被嵌入到 iframe 中。如果页面显示空白或报错,请确认目标网站允许被嵌入,或考虑使用 HTML 模式自行构建页面内容。',
homeContentPlaceholder:
'在此输入首页内容,支持 Markdown & HTML 代码。如果输入的是一个链接,则会使用该链接作为 iframe 的 src 属性。',
homeContentHint:
'自定义首页内容,支持 Markdown/HTML。如果输入的是链接以 http:// 或 https:// 开头),则会使用该链接作为 iframe 的 src 属性,这允许你设置任意网页作为首页。设置后首页的状态信息将不再显示。',
homeContentIframeWarning:
'⚠️ iframe 模式提示:部分网站设置了 X-Frame-Options 或 CSP 安全策略,禁止被嵌入到 iframe 中。如果页面显示空白或报错,请确认目标网站允许被嵌入,或考虑使用 HTML 模式自行构建页面内容。',
hideCcsImportButton: '隐藏 CCS 导入按钮',
hideCcsImportButtonHint: '启用后将在 API Keys 页面隐藏"导入 CCS"按钮'
},
@@ -3390,131 +3439,158 @@ export default {
admin: {
welcome: {
title: '👋 欢迎使用 Sub2API',
description: '<div style="line-height: 1.8;"><p style="margin-bottom: 16px;">Sub2API 是一个强大的 AI 服务中转平台,让您轻松管理和分发 AI 服务。</p><p style="margin-bottom: 12px;"><b>🎯 核心功能:</b></p><ul style="margin-left: 20px; margin-bottom: 16px;"><li>📦 <b>分组管理</b> - 创建不同的服务套餐VIP、免费试用等</li><li>🔗 <b>账号池</b> - 连接多个上游 AI 服务商账号</li><li>🔑 <b>密钥分发</b> - 为用户生成独立的 API Key</li><li>💰 <b>计费管理</b> - 灵活的费率和配额控制</li></ul><p style="color: #10b981; font-weight: 600;">接下来,我们将用 3 分钟带您完成首次配置 →</p></div>',
description:
'<div style="line-height: 1.8;"><p style="margin-bottom: 16px;">Sub2API 是一个强大的 AI 服务中转平台,让您轻松管理和分发 AI 服务。</p><p style="margin-bottom: 12px;"><b>🎯 核心功能:</b></p><ul style="margin-left: 20px; margin-bottom: 16px;"><li>📦 <b>分组管理</b> - 创建不同的服务套餐VIP、免费试用等</li><li>🔗 <b>账号池</b> - 连接多个上游 AI 服务商账号</li><li>🔑 <b>密钥分发</b> - 为用户生成独立的 API Key</li><li>💰 <b>计费管理</b> - 灵活的费率和配额控制</li></ul><p style="color: #10b981; font-weight: 600;">接下来,我们将用 3 分钟带您完成首次配置 →</p></div>',
nextBtn: '开始配置 🚀',
prevBtn: '跳过'
},
groupManage: {
title: '📦 第一步:分组管理',
description: '<div style="line-height: 1.7;"><p style="margin-bottom: 12px;"><b>什么是分组?</b></p><p style="margin-bottom: 12px;">分组是 Sub2API 的核心概念,它就像一个"服务套餐"</p><ul style="margin-left: 20px; margin-bottom: 12px; font-size: 13px;"><li>🎯 每个分组可以包含多个上游账号</li><li>💰 每个分组有独立的计费倍率</li><li>👥 可以设置为公开或专属分组</li></ul><p style="margin-top: 12px; padding: 8px 12px; background: #f0fdf4; border-left: 3px solid #10b981; border-radius: 4px; font-size: 13px;"><b>💡 示例:</b>您可以创建"VIP专线"(高倍率)和"免费试用"(低倍率)两个分组</p><p style="margin-top: 16px; color: #10b981; font-weight: 600;">👉 点击左侧的"分组管理"开始</p></div>'
description:
'<div style="line-height: 1.7;"><p style="margin-bottom: 12px;"><b>什么是分组?</b></p><p style="margin-bottom: 12px;">分组是 Sub2API 的核心概念,它就像一个"服务套餐"</p><ul style="margin-left: 20px; margin-bottom: 12px; font-size: 13px;"><li>🎯 每个分组可以包含多个上游账号</li><li>💰 每个分组有独立的计费倍率</li><li>👥 可以设置为公开或专属分组</li></ul><p style="margin-top: 12px; padding: 8px 12px; background: #f0fdf4; border-left: 3px solid #10b981; border-radius: 4px; font-size: 13px;"><b>💡 示例:</b>您可以创建"VIP专线"(高倍率)和"免费试用"(低倍率)两个分组</p><p style="margin-top: 16px; color: #10b981; font-weight: 600;">👉 点击左侧的"分组管理"开始</p></div>'
},
createGroup: {
title: ' 创建新分组',
description: '<div style="line-height: 1.7;"><p style="margin-bottom: 12px;">现在让我们创建第一个分组。</p><p style="padding: 8px 12px; background: #eff6ff; border-left: 3px solid #3b82f6; border-radius: 4px; font-size: 13px; margin-bottom: 12px;"><b>📝 提示:</b>建议先创建一个测试分组,熟悉流程后再创建正式分组</p><p style="color: #10b981; font-weight: 600;">👉 点击"创建分组"按钮</p></div>'
description:
'<div style="line-height: 1.7;"><p style="margin-bottom: 12px;">现在让我们创建第一个分组。</p><p style="padding: 8px 12px; background: #eff6ff; border-left: 3px solid #3b82f6; border-radius: 4px; font-size: 13px; margin-bottom: 12px;"><b>📝 提示:</b>建议先创建一个测试分组,熟悉流程后再创建正式分组</p><p style="color: #10b981; font-weight: 600;">👉 点击"创建分组"按钮</p></div>'
},
groupName: {
title: '✏️ 1. 分组名称',
description: '<div style="line-height: 1.7;"><p style="margin-bottom: 12px;">为您的分组起一个易于识别的名称。</p><div style="padding: 8px 12px; background: #f0fdf4; border-left: 3px solid #10b981; border-radius: 4px; font-size: 13px; margin-bottom: 12px;"><b>💡 命名建议:</b><ul style="margin: 8px 0 0 16px;"><li>"测试分组" - 用于测试</li><li>"VIP专线" - 高质量服务</li><li>"免费试用" - 体验版</li></ul></div><p style="font-size: 13px; color: #6b7280;">填写完成后点击"下一步"继续</p></div>',
description:
'<div style="line-height: 1.7;"><p style="margin-bottom: 12px;">为您的分组起一个易于识别的名称。</p><div style="padding: 8px 12px; background: #f0fdf4; border-left: 3px solid #10b981; border-radius: 4px; font-size: 13px; margin-bottom: 12px;"><b>💡 命名建议:</b><ul style="margin: 8px 0 0 16px;"><li>"测试分组" - 用于测试</li><li>"VIP专线" - 高质量服务</li><li>"免费试用" - 体验版</li></ul></div><p style="font-size: 13px; color: #6b7280;">填写完成后点击"下一步"继续</p></div>',
nextBtn: '下一步'
},
groupPlatform: {
title: '🤖 2. 选择平台',
description: '<div style="line-height: 1.7;"><p style="margin-bottom: 12px;">选择该分组支持的 AI 平台。</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><b>Anthropic</b> - Claude 系列模型</li><li><b>OpenAI</b> - GPT 系列模型</li><li><b>Google</b> - Gemini 系列模型</li></ul></div><p style="font-size: 13px; color: #6b7280;">一个分组只能选择一个平台</p></div>',
description:
'<div style="line-height: 1.7;"><p style="margin-bottom: 12px;">选择该分组支持的 AI 平台。</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><b>Anthropic</b> - Claude 系列模型</li><li><b>OpenAI</b> - GPT 系列模型</li><li><b>Google</b> - Gemini 系列模型</li></ul></div><p style="font-size: 13px; color: #6b7280;">一个分组只能选择一个平台</p></div>',
nextBtn: '下一步'
},
groupMultiplier: {
title: '💰 3. 费率倍数',
description: '<div style="line-height: 1.7;"><p style="margin-bottom: 12px;">设置该分组的计费倍率,控制用户的实际扣费。</p><div style="padding: 8px 12px; background: #fef3c7; border-left: 3px solid #f59e0b; border-radius: 4px; font-size: 13px; margin-bottom: 12px;"><b>⚙️ 计费规则:</b><ul style="margin: 8px 0 0 16px;"><li><b>1.0</b> - 原价计费(成本价)</li><li><b>1.5</b> - 用户消耗 $1扣除 $1.5</li><li><b>2.0</b> - 用户消耗 $1扣除 $2</li><li><b>0.8</b> - 补贴模式(亏本运营)</li></ul></div><p style="font-size: 13px; color: #6b7280;">建议测试分组设置为 1.0</p></div>',
description:
'<div style="line-height: 1.7;"><p style="margin-bottom: 12px;">设置该分组的计费倍率,控制用户的实际扣费。</p><div style="padding: 8px 12px; background: #fef3c7; border-left: 3px solid #f59e0b; border-radius: 4px; font-size: 13px; margin-bottom: 12px;"><b>⚙️ 计费规则:</b><ul style="margin: 8px 0 0 16px;"><li><b>1.0</b> - 原价计费(成本价)</li><li><b>1.5</b> - 用户消耗 $1扣除 $1.5</li><li><b>2.0</b> - 用户消耗 $1扣除 $2</li><li><b>0.8</b> - 补贴模式(亏本运营)</li></ul></div><p style="font-size: 13px; color: #6b7280;">建议测试分组设置为 1.0</p></div>',
nextBtn: '下一步'
},
groupExclusive: {
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><b>关闭</b> - 公开分组,所有用户可见</li><li><b>开启</b> - 专属分组,仅指定用户可见</li></ul></div><p style="padding: 8px 12px; background: #f0fdf4; border-left: 3px solid #10b981; border-radius: 4px; font-size: 13px;"><b>💡 使用场景:</b>VIP 用户专属、内部测试、特殊客户等</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><b>关闭</b> - 公开分组,所有用户可见</li><li><b>开启</b> - 专属分组,仅指定用户可见</li></ul></div><p style="padding: 8px 12px; background: #f0fdf4; border-left: 3px solid #10b981; border-radius: 4px; font-size: 13px;"><b>💡 使用场景:</b>VIP 用户专属、内部测试、特殊客户等</p></div>',
nextBtn: '下一步'
},
groupSubmit: {
title: '✅ 保存分组',
description: '<div style="line-height: 1.7;"><p style="margin-bottom: 12px;">确认信息无误后,点击创建按钮保存分组。</p><p style="padding: 8px 12px; background: #fef3c7; border-left: 3px solid #f59e0b; border-radius: 4px; font-size: 13px; margin-bottom: 12px;"><b>⚠️ 注意:</b>分组创建后,平台类型不可修改,其他信息可以随时编辑</p><p style="padding: 8px 12px; background: #f0fdf4; border-left: 3px solid #10b981; border-radius: 4px; font-size: 13px;"><b>📌 下一步:</b>创建成功后,我们将添加上游账号到这个分组</p><p style="margin-top: 12px; color: #10b981; font-weight: 600;">👉 点击"创建"按钮</p></div>'
description:
'<div style="line-height: 1.7;"><p style="margin-bottom: 12px;">确认信息无误后,点击创建按钮保存分组。</p><p style="padding: 8px 12px; background: #fef3c7; border-left: 3px solid #f59e0b; border-radius: 4px; font-size: 13px; margin-bottom: 12px;"><b>⚠️ 注意:</b>分组创建后,平台类型不可修改,其他信息可以随时编辑</p><p style="padding: 8px 12px; background: #f0fdf4; border-left: 3px solid #10b981; border-radius: 4px; font-size: 13px;"><b>📌 下一步:</b>创建成功后,我们将添加上游账号到这个分组</p><p style="margin-top: 12px; color: #10b981; font-weight: 600;">👉 点击"创建"按钮</p></div>'
},
accountManage: {
title: '🔗 第二步:添加账号',
description: '<div style="line-height: 1.7;"><p style="margin-bottom: 12px;"><b>太棒了!分组已创建成功 🎉</b></p><p style="margin-bottom: 12px;">现在需要添加上游 AI 服务商的账号,让分组能够实际提供服务。</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>连接到上游 AI 服务Claude、GPT 等)</li><li>一个分组可以包含多个账号(负载均衡)</li><li>支持 OAuth 和 Session Key 两种方式</li></ul></div><p style="margin-top: 16px; color: #10b981; font-weight: 600;">👉 点击左侧的"账号管理"</p></div>'
description:
'<div style="line-height: 1.7;"><p style="margin-bottom: 12px;"><b>太棒了!分组已创建成功 🎉</b></p><p style="margin-bottom: 12px;">现在需要添加上游 AI 服务商的账号,让分组能够实际提供服务。</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>连接到上游 AI 服务Claude、GPT 等)</li><li>一个分组可以包含多个账号(负载均衡)</li><li>支持 OAuth 和 Session Key 两种方式</li></ul></div><p style="margin-top: 16px; color: #10b981; font-weight: 600;">👉 点击左侧的"账号管理"</p></div>'
},
createAccount: {
title: ' 添加新账号',
description: '<div style="line-height: 1.7;"><p style="margin-bottom: 12px;">点击按钮开始添加您的第一个上游账号。</p><p style="padding: 8px 12px; background: #f0fdf4; border-left: 3px solid #10b981; border-radius: 4px; font-size: 13px;"><b>💡 提示:</b>建议使用 OAuth 方式,更安全且无需手动提取密钥</p><p style="margin-top: 12px; color: #10b981; font-weight: 600;">👉 点击"添加账号"按钮</p></div>'
description:
'<div style="line-height: 1.7;"><p style="margin-bottom: 12px;">点击按钮开始添加您的第一个上游账号。</p><p style="padding: 8px 12px; background: #f0fdf4; border-left: 3px solid #10b981; border-radius: 4px; font-size: 13px;"><b>💡 提示:</b>建议使用 OAuth 方式,更安全且无需手动提取密钥</p><p style="margin-top: 12px; color: #10b981; font-weight: 600;">👉 点击"添加账号"按钮</p></div>'
},
accountName: {
title: '✏️ 1. 账号名称',
description: '<div style="line-height: 1.7;"><p style="margin-bottom: 12px;">为账号设置一个便于识别的名称。</p><p style="padding: 8px 12px; background: #f0fdf4; border-left: 3px solid #10b981; border-radius: 4px; font-size: 13px;"><b>💡 命名建议:</b>"Claude主账号"、"GPT备用1"、"测试账号" 等</p></div>',
description:
'<div style="line-height: 1.7;"><p style="margin-bottom: 12px;">为账号设置一个便于识别的名称。</p><p style="padding: 8px 12px; background: #f0fdf4; border-left: 3px solid #10b981; border-radius: 4px; font-size: 13px;"><b>💡 命名建议:</b>"Claude主账号"、"GPT备用1"、"测试账号" 等</p></div>',
nextBtn: '下一步'
},
accountPlatform: {
title: '🤖 2. 选择平台',
description: '<div style="line-height: 1.7;"><p style="margin-bottom: 12px;">选择该账号对应的服务商平台。</p><p style="padding: 8px 12px; background: #fef3c7; border-left: 3px solid #f59e0b; border-radius: 4px; font-size: 13px;"><b>⚠️ 重要:</b>平台必须与刚才创建的分组平台一致</p></div>',
description:
'<div style="line-height: 1.7;"><p style="margin-bottom: 12px;">选择该账号对应的服务商平台。</p><p style="padding: 8px 12px; background: #fef3c7; border-left: 3px solid #f59e0b; border-radius: 4px; font-size: 13px;"><b>⚠️ 重要:</b>平台必须与刚才创建的分组平台一致</p></div>',
nextBtn: '下一步'
},
accountType: {
title: '🔐 3. 授权方式',
description: '<div style="line-height: 1.7;"><p style="margin-bottom: 12px;">选择账号的授权方式。</p><div style="padding: 8px 12px; background: #f0fdf4; border-left: 3px solid #10b981; border-radius: 4px; font-size: 13px; margin-bottom: 12px;"><b>✅ 推荐OAuth 方式</b><ul style="margin: 8px 0 0 16px;"><li>无需手动提取密钥</li><li>更安全,支持自动刷新</li><li>适用于 Claude Code、ChatGPT OAuth</li></ul></div><div style="padding: 8px 12px; background: #eff6ff; border-left: 3px solid #3b82f6; border-radius: 4px; font-size: 13px;"><b>📌 Session Key 方式</b><ul style="margin: 8px 0 0 16px;"><li>需要手动从浏览器提取</li><li>可能需要定期更新</li><li>适用于不支持 OAuth 的平台</li></ul></div></div>',
description:
'<div style="line-height: 1.7;"><p style="margin-bottom: 12px;">选择账号的授权方式。</p><div style="padding: 8px 12px; background: #f0fdf4; border-left: 3px solid #10b981; border-radius: 4px; font-size: 13px; margin-bottom: 12px;"><b>✅ 推荐OAuth 方式</b><ul style="margin: 8px 0 0 16px;"><li>无需手动提取密钥</li><li>更安全,支持自动刷新</li><li>适用于 Claude Code、ChatGPT OAuth</li></ul></div><div style="padding: 8px 12px; background: #eff6ff; border-left: 3px solid #3b82f6; border-radius: 4px; font-size: 13px;"><b>📌 Session Key 方式</b><ul style="margin: 8px 0 0 16px;"><li>需要手动从浏览器提取</li><li>可能需要定期更新</li><li>适用于不支持 OAuth 的平台</li></ul></div></div>',
nextBtn: '下一步'
},
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: {
title: '🎯 5. 分配分组',
description: '<div style="line-height: 1.7;"><p style="margin-bottom: 12px;"><b>关键步骤!</b>将账号分配到刚才创建的分组。</p><div style="padding: 8px 12px; background: #fee2e2; border-left: 3px solid #ef4444; 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;"><b>关键步骤!</b>将账号分配到刚才创建的分组。</p><div style="padding: 8px 12px; background: #fee2e2; border-left: 3px solid #ef4444; 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: '下一步'
},
accountSubmit: {
title: '✅ 保存账号',
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>📌 OAuth 授权流程:</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>账号添加成功后,我们将创建 API 密钥</p><p style="margin-top: 12px; color: #10b981; font-weight: 600;">👉 点击"保存"按钮</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>📌 OAuth 授权流程:</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>账号添加成功后,我们将创建 API 密钥</p><p style="margin-top: 12px; color: #10b981; font-weight: 600;">👉 点击"保存"按钮</p></div>'
},
keyManage: {
title: '🔑 第三步:生成密钥',
description: '<div style="line-height: 1.7;"><p style="margin-bottom: 12px;"><b>恭喜!账号配置完成 🎉</b></p><p style="margin-bottom: 12px;">最后一步,生成 API Key 来测试服务是否正常工作。</p><div style="padding: 8px 12px; background: #eff6ff; border-left: 3px solid #3b82f6; border-radius: 4px; font-size: 13px; margin-bottom: 12px;"><b>🔑 API Key 的作用:</b><ul style="margin: 8px 0 0 16px;"><li>用于调用 AI 服务的凭证</li><li>每个 Key 绑定一个分组</li><li>可以设置配额和有效期</li><li>支持独立的使用统计</li></ul></div><p style="margin-top: 16px; color: #10b981; font-weight: 600;">👉 点击左侧的"API 密钥"</p></div>'
description:
'<div style="line-height: 1.7;"><p style="margin-bottom: 12px;"><b>恭喜!账号配置完成 🎉</b></p><p style="margin-bottom: 12px;">最后一步,生成 API Key 来测试服务是否正常工作。</p><div style="padding: 8px 12px; background: #eff6ff; border-left: 3px solid #3b82f6; border-radius: 4px; font-size: 13px; margin-bottom: 12px;"><b>🔑 API Key 的作用:</b><ul style="margin: 8px 0 0 16px;"><li>用于调用 AI 服务的凭证</li><li>每个 Key 绑定一个分组</li><li>可以设置配额和有效期</li><li>支持独立的使用统计</li></ul></div><p style="margin-top: 16px; color: #10b981; font-weight: 600;">👉 点击左侧的"API 密钥"</p></div>'
},
createKey: {
title: ' 创建密钥',
description: '<div style="line-height: 1.7;"><p style="margin-bottom: 12px;">点击按钮创建您的第一个 API Key。</p><p style="padding: 8px 12px; background: #f0fdf4; border-left: 3px solid #10b981; border-radius: 4px; font-size: 13px;"><b>💡 提示:</b>创建后请立即复制保存,密钥只显示一次</p><p style="margin-top: 12px; color: #10b981; font-weight: 600;">👉 点击"创建密钥"按钮</p></div>'
description:
'<div style="line-height: 1.7;"><p style="margin-bottom: 12px;">点击按钮创建您的第一个 API Key。</p><p style="padding: 8px 12px; background: #f0fdf4; border-left: 3px solid #10b981; border-radius: 4px; font-size: 13px;"><b>💡 提示:</b>创建后请立即复制保存,密钥只显示一次</p><p style="margin-top: 12px; color: #10b981; font-weight: 600;">👉 点击"创建密钥"按钮</p></div>'
},
keyName: {
title: '✏️ 1. 密钥名称',
description: '<div style="line-height: 1.7;"><p style="margin-bottom: 12px;">为密钥设置一个便于管理的名称。</p><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><p style="padding: 8px 12px; background: #f0fdf4; border-left: 3px solid #10b981; border-radius: 4px; font-size: 13px;"><b>💡 命名建议:</b>"测试密钥"、"生产环境"、"移动端" 等</p></div>',
nextBtn: '下一步'
},
keyGroup: {
title: '🎯 2. 选择分组',
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: '下一步'
},
keySubmit: {
title: '🎉 生成并复制',
description: '<div style="line-height: 1.7;"><p style="margin-bottom: 12px;">点击创建后,系统会生成完整的 API Key。</p><div style="padding: 8px 12px; background: #fee2e2; border-left: 3px solid #ef4444; 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><div style="padding: 8px 12px; background: #f0fdf4; border-left: 3px solid #10b981; border-radius: 4px; font-size: 13px; margin-bottom: 12px;"><b>🚀 下一步:</b><ul style="margin: 8px 0 0 16px;"><li>复制生成的 sk-xxx 密钥</li><li>在支持 OpenAI 接口的客户端中使用</li><li>开始体验 AI 服务!</li></ul></div><p style="margin-top: 12px; color: #10b981; font-weight: 600;">👉 点击"创建"按钮</p></div>'
description:
'<div style="line-height: 1.7;"><p style="margin-bottom: 12px;">点击创建后,系统会生成完整的 API Key。</p><div style="padding: 8px 12px; background: #fee2e2; border-left: 3px solid #ef4444; 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><div style="padding: 8px 12px; background: #f0fdf4; border-left: 3px solid #10b981; border-radius: 4px; font-size: 13px; margin-bottom: 12px;"><b>🚀 下一步:</b><ul style="margin: 8px 0 0 16px;"><li>复制生成的 sk-xxx 密钥</li><li>在支持 OpenAI 接口的客户端中使用</li><li>开始体验 AI 服务!</li></ul></div><p style="margin-top: 12px; color: #10b981; font-weight: 600;">👉 点击"创建"按钮</p></div>'
}
},
// User tour steps
user: {
welcome: {
title: '👋 欢迎使用 Sub2API',
description: '<div style="line-height: 1.8;"><p style="margin-bottom: 16px;">您好!欢迎来到 Sub2API AI 服务平台。</p><p style="margin-bottom: 12px;"><b>🎯 快速开始:</b></p><ul style="margin-left: 20px; margin-bottom: 16px;"><li>🔑 创建 API 密钥</li><li>📋 复制密钥到您的应用</li><li>🚀 开始使用 AI 服务</li></ul><p style="color: #10b981; font-weight: 600;">只需 1 分钟,让我们开始吧 →</p></div>',
description:
'<div style="line-height: 1.8;"><p style="margin-bottom: 16px;">您好!欢迎来到 Sub2API AI 服务平台。</p><p style="margin-bottom: 12px;"><b>🎯 快速开始:</b></p><ul style="margin-left: 20px; margin-bottom: 16px;"><li>🔑 创建 API 密钥</li><li>📋 复制密钥到您的应用</li><li>🚀 开始使用 AI 服务</li></ul><p style="color: #10b981; font-weight: 600;">只需 1 分钟,让我们开始吧 →</p></div>',
nextBtn: '开始 🚀',
prevBtn: '跳过'
},
keyManage: {
title: '🔑 API 密钥管理',
description: '<div style="line-height: 1.7;"><p style="margin-bottom: 12px;">在这里管理您的所有 API 访问密钥。</p><p style="padding: 8px 12px; background: #eff6ff; border-left: 3px solid #3b82f6; border-radius: 4px; font-size: 13px;"><b>📌 什么是 API 密钥?</b><br/>API 密钥是您访问 AI 服务的凭证,就像一把钥匙,让您的应用能够调用 AI 能力。</p><p style="margin-top: 12px; color: #10b981; font-weight: 600;">👉 点击进入密钥页面</p></div>'
description:
'<div style="line-height: 1.7;"><p style="margin-bottom: 12px;">在这里管理您的所有 API 访问密钥。</p><p style="padding: 8px 12px; background: #eff6ff; border-left: 3px solid #3b82f6; border-radius: 4px; font-size: 13px;"><b>📌 什么是 API 密钥?</b><br/>API 密钥是您访问 AI 服务的凭证,就像一把钥匙,让您的应用能够调用 AI 能力。</p><p style="margin-top: 12px; color: #10b981; font-weight: 600;">👉 点击进入密钥页面</p></div>'
},
createKey: {
title: ' 创建新密钥',
description: '<div style="line-height: 1.7;"><p style="margin-bottom: 12px;">点击按钮创建您的第一个 API 密钥。</p><p style="padding: 8px 12px; background: #f0fdf4; border-left: 3px solid #10b981; border-radius: 4px; font-size: 13px;"><b>💡 提示:</b>创建后密钥只显示一次,请务必复制保存</p><p style="margin-top: 12px; color: #10b981; font-weight: 600;">👉 点击"创建密钥"</p></div>'
description:
'<div style="line-height: 1.7;"><p style="margin-bottom: 12px;">点击按钮创建您的第一个 API 密钥。</p><p style="padding: 8px 12px; background: #f0fdf4; border-left: 3px solid #10b981; border-radius: 4px; font-size: 13px;"><b>💡 提示:</b>创建后密钥只显示一次,请务必复制保存</p><p style="margin-top: 12px; color: #10b981; font-weight: 600;">👉 点击"创建密钥"</p></div>'
},
keyName: {
title: '✏️ 密钥名称',
description: '<div style="line-height: 1.7;"><p style="margin-bottom: 12px;">为密钥起一个便于识别的名称。</p><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><p style="padding: 8px 12px; background: #f0fdf4; border-left: 3px solid #10b981; border-radius: 4px; font-size: 13px;"><b>💡 示例:</b>"我的第一个密钥"、"测试用" 等</p></div>',
nextBtn: '下一步'
},
keyGroup: {
title: '🎯 选择分组',
description: '<div style="line-height: 1.7;"><p style="margin-bottom: 12px;">选择管理员为您分配的服务分组。</p><p style="padding: 8px 12px; background: #eff6ff; border-left: 3px solid #3b82f6; border-radius: 4px; font-size: 13px;"><b>📌 分组说明:</b><br/>不同分组可能有不同的服务质量和计费标准,请根据需要选择。</p></div>',
description:
'<div style="line-height: 1.7;"><p style="margin-bottom: 12px;">选择管理员为您分配的服务分组。</p><p style="padding: 8px 12px; background: #eff6ff; border-left: 3px solid #3b82f6; border-radius: 4px; font-size: 13px;"><b>📌 分组说明:</b><br/>不同分组可能有不同的服务质量和计费标准,请根据需要选择。</p></div>',
nextBtn: '下一步'
},
keySubmit: {
title: '🎉 完成创建',
description: '<div style="line-height: 1.7;"><p style="margin-bottom: 12px;">点击确认创建您的 API 密钥。</p><div style="padding: 8px 12px; background: #fee2e2; border-left: 3px solid #ef4444; border-radius: 4px; font-size: 13px; margin-bottom: 12px;"><b>⚠️ 重要:</b><ul style="margin: 8px 0 0 16px;"><li>创建后请立即复制密钥sk-xxx</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><br/>将密钥配置到支持 OpenAI 接口的任何客户端(如 ChatBox、OpenCat 等),即可开始使用!</p><p style="margin-top: 12px; color: #10b981; font-weight: 600;">👉 点击"创建"按钮</p></div>'
description:
'<div style="line-height: 1.7;"><p style="margin-bottom: 12px;">点击确认创建您的 API 密钥。</p><div style="padding: 8px 12px; background: #fee2e2; border-left: 3px solid #ef4444; border-radius: 4px; font-size: 13px; margin-bottom: 12px;"><b>⚠️ 重要:</b><ul style="margin: 8px 0 0 16px;"><li>创建后请立即复制密钥sk-xxx</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><br/>将密钥配置到支持 OpenAI 接口的任何客户端(如 ChatBox、OpenCat 等),即可开始使用!</p><p style="margin-top: 12px; color: #10b981; font-weight: 600;">👉 点击"创建"按钮</p></div>'
}
}
}

View File

@@ -374,9 +374,12 @@ export interface ApiKey {
key: string
name: string
group_id: number | null
status: 'active' | 'inactive'
status: 'active' | 'inactive' | 'quota_exhausted' | 'expired'
ip_whitelist: string[]
ip_blacklist: string[]
quota: number // Quota limit in USD (0 = unlimited)
quota_used: number // Used quota amount in USD
expires_at: string | null // Expiration time (null = never expires)
created_at: string
updated_at: string
group?: Group
@@ -388,6 +391,8 @@ export interface CreateApiKeyRequest {
custom_key?: string // Optional custom API Key
ip_whitelist?: string[]
ip_blacklist?: string[]
quota?: number // Quota limit in USD (0 = unlimited)
expires_in_days?: number // Days until expiry (null = never expires)
}
export interface UpdateApiKeyRequest {
@@ -396,6 +401,9 @@ export interface UpdateApiKeyRequest {
status?: 'active' | 'inactive'
ip_whitelist?: string[]
ip_blacklist?: string[]
quota?: number // Quota limit in USD (null = no change, 0 = unlimited)
expires_at?: string | null // Expiration time (null = no change)
reset_quota?: boolean // Reset quota_used to 0
}
export interface CreateGroupRequest {

View File

@@ -108,12 +108,53 @@
${{ (usageStats[row.id]?.total_actual_cost ?? 0).toFixed(4) }}
</span>
</div>
<!-- Quota progress (if quota is set) -->
<div v-if="row.quota > 0" class="mt-1.5">
<div class="flex items-center gap-1.5">
<span class="text-gray-500 dark:text-gray-400">{{ t('keys.quota') }}:</span>
<span :class="[
'font-medium',
row.quota_used >= row.quota ? 'text-red-500' :
row.quota_used >= row.quota * 0.8 ? 'text-yellow-500' :
'text-gray-900 dark:text-white'
]">
${{ row.quota_used?.toFixed(2) || '0.00' }} / ${{ row.quota?.toFixed(2) }}
</span>
</div>
<div class="mt-1 h-1.5 w-full overflow-hidden rounded-full bg-gray-200 dark:bg-dark-600">
<div
:class="[
'h-full rounded-full transition-all',
row.quota_used >= row.quota ? 'bg-red-500' :
row.quota_used >= row.quota * 0.8 ? 'bg-yellow-500' :
'bg-primary-500'
]"
:style="{ width: Math.min((row.quota_used / row.quota) * 100, 100) + '%' }"
/>
</div>
</div>
</div>
</template>
<template #cell-expires_at="{ value }">
<span v-if="value" :class="[
'text-sm',
new Date(value) < new Date() ? 'text-red-500 dark:text-red-400' : 'text-gray-500 dark:text-dark-400'
]">
{{ formatDateTime(value) }}
</span>
<span v-else class="text-sm text-gray-400 dark:text-dark-500">{{ t('keys.noExpiration') }}</span>
</template>
<template #cell-status="{ value }">
<span :class="['badge', value === 'active' ? 'badge-success' : 'badge-gray']">
{{ t('admin.accounts.status.' + value) }}
<span :class="[
'badge',
value === 'active' ? 'badge-success' :
value === 'quota_exhausted' ? 'badge-warning' :
value === 'expired' ? 'badge-danger' :
'badge-gray'
]">
{{ t('keys.status.' + value) }}
</span>
</template>
@@ -334,6 +375,145 @@
</div>
</div>
</div>
<!-- Quota Limit Section -->
<div class="space-y-3">
<label class="input-label">{{ t('keys.quotaLimit') }}</label>
<!-- Switch commented out - always show input, 0 = unlimited
<div class="flex items-center justify-between">
<label class="input-label mb-0">{{ t('keys.quotaLimit') }}</label>
<button
type="button"
@click="formData.enable_quota = !formData.enable_quota"
:class="[
'relative inline-flex h-5 w-9 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none',
formData.enable_quota ? 'bg-primary-600' : 'bg-gray-200 dark:bg-dark-600'
]"
>
<span
:class="[
'pointer-events-none inline-block h-4 w-4 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out',
formData.enable_quota ? 'translate-x-4' : 'translate-x-0'
]"
/>
</button>
</div>
-->
<div class="space-y-4">
<div>
<div class="relative">
<span class="absolute left-3 top-1/2 -translate-y-1/2 text-gray-500">$</span>
<input
v-model.number="formData.quota"
type="number"
step="0.01"
min="0"
class="input pl-7"
:placeholder="t('keys.quotaAmountPlaceholder')"
/>
</div>
<p class="input-hint">{{ t('keys.quotaAmountHint') }}</p>
</div>
<!-- Quota used display (only in edit mode) -->
<div v-if="showEditModal && selectedKey && selectedKey.quota > 0">
<label class="input-label">{{ t('keys.quotaUsed') }}</label>
<div class="flex items-center gap-2">
<div class="flex-1 rounded-lg bg-gray-100 px-3 py-2 dark:bg-dark-700">
<span class="font-medium text-gray-900 dark:text-white">
${{ selectedKey.quota_used?.toFixed(4) || '0.0000' }}
</span>
<span class="mx-2 text-gray-400">/</span>
<span class="text-gray-500 dark:text-gray-400">
${{ selectedKey.quota?.toFixed(2) || '0.00' }}
</span>
</div>
<button
type="button"
@click="confirmResetQuota"
class="btn btn-secondary text-sm"
:title="t('keys.resetQuotaUsed')"
>
{{ t('keys.reset') }}
</button>
</div>
</div>
</div>
</div>
<!-- Expiration Section -->
<div class="space-y-3">
<div class="flex items-center justify-between">
<label class="input-label mb-0">{{ t('keys.expiration') }}</label>
<button
type="button"
@click="formData.enable_expiration = !formData.enable_expiration"
:class="[
'relative inline-flex h-5 w-9 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none',
formData.enable_expiration ? 'bg-primary-600' : 'bg-gray-200 dark:bg-dark-600'
]"
>
<span
:class="[
'pointer-events-none inline-block h-4 w-4 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out',
formData.enable_expiration ? 'translate-x-4' : 'translate-x-0'
]"
/>
</button>
</div>
<div v-if="formData.enable_expiration" class="space-y-4 pt-2">
<!-- Quick select buttons (for both create and edit mode) -->
<div class="flex flex-wrap gap-2">
<button
v-for="days in ['7', '30', '90']"
:key="days"
type="button"
@click="setExpirationDays(parseInt(days))"
:class="[
'rounded-lg px-3 py-1.5 text-sm transition-colors',
formData.expiration_preset === days
? 'bg-primary-100 text-primary-700 dark:bg-primary-900/30 dark:text-primary-400'
: 'bg-gray-100 text-gray-600 hover:bg-gray-200 dark:bg-dark-700 dark:text-gray-400 dark:hover:bg-dark-600'
]"
>
{{ showEditModal ? t('keys.extendDays', { days }) : t('keys.expiresInDays', { days }) }}
</button>
<button
type="button"
@click="formData.expiration_preset = 'custom'"
:class="[
'rounded-lg px-3 py-1.5 text-sm transition-colors',
formData.expiration_preset === 'custom'
? 'bg-primary-100 text-primary-700 dark:bg-primary-900/30 dark:text-primary-400'
: 'bg-gray-100 text-gray-600 hover:bg-gray-200 dark:bg-dark-700 dark:text-gray-400 dark:hover:bg-dark-600'
]"
>
{{ t('keys.customDate') }}
</button>
</div>
<!-- Date picker (always show for precise adjustment) -->
<div>
<label class="input-label">{{ t('keys.expirationDate') }}</label>
<input
v-model="formData.expiration_date"
type="datetime-local"
class="input"
/>
<p class="input-hint">{{ t('keys.expirationDateHint') }}</p>
</div>
<!-- Current expiration display (only in edit mode) -->
<div v-if="showEditModal && selectedKey?.expires_at" class="text-sm">
<span class="text-gray-500 dark:text-gray-400">{{ t('keys.currentExpiration') }}: </span>
<span class="font-medium text-gray-900 dark:text-white">
{{ formatDateTime(selectedKey.expires_at) }}
</span>
</div>
</div>
</div>
</form>
<template #footer>
<div class="flex justify-end gap-3">
@@ -391,6 +571,18 @@
@cancel="showDeleteDialog = false"
/>
<!-- Reset Quota Confirmation Dialog -->
<ConfirmDialog
:show="showResetQuotaDialog"
:title="t('keys.resetQuotaTitle')"
:message="t('keys.resetQuotaConfirmMessage', { name: selectedKey?.name, used: selectedKey?.quota_used?.toFixed(4) })"
:confirm-text="t('keys.reset')"
:cancel-text="t('common.cancel')"
:danger="true"
@confirm="resetQuotaUsed"
@cancel="showResetQuotaDialog = false"
/>
<!-- Use Key Modal -->
<UseKeyModal
:show="showUseKeyModal"
@@ -514,6 +706,13 @@ import type { Column } from '@/components/common/types'
import type { BatchApiKeyUsageStats } from '@/api/usage'
import { formatDateTime } from '@/utils/format'
// Helper to format date for datetime-local input
const formatDateTimeLocal = (isoDate: string): string => {
const date = new Date(isoDate)
const pad = (n: number) => n.toString().padStart(2, '0')
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}`
}
interface GroupOption {
value: number
label: string
@@ -532,6 +731,7 @@ const columns = computed<Column[]>(() => [
{ key: 'key', label: t('keys.apiKey'), sortable: false },
{ key: 'group', label: t('keys.group'), sortable: false },
{ key: 'usage', label: t('keys.usage'), sortable: false },
{ key: 'expires_at', label: t('keys.expiresAt'), sortable: true },
{ key: 'status', label: t('common.status'), sortable: true },
{ key: 'created_at', label: t('keys.created'), sortable: true },
{ key: 'actions', label: t('common.actions'), sortable: false }
@@ -553,6 +753,7 @@ const pagination = ref({
const showCreateModal = ref(false)
const showEditModal = ref(false)
const showDeleteDialog = ref(false)
const showResetQuotaDialog = ref(false)
const showUseKeyModal = ref(false)
const showCcsClientSelect = ref(false)
const pendingCcsRow = ref<ApiKey | null>(null)
@@ -587,7 +788,13 @@ const formData = ref({
custom_key: '',
enable_ip_restriction: false,
ip_whitelist: '',
ip_blacklist: ''
ip_blacklist: '',
// Quota settings (empty = unlimited)
enable_quota: false,
quota: null as number | null,
enable_expiration: false,
expiration_preset: '30' as '7' | '30' | '90' | 'custom',
expiration_date: ''
})
// 自定义Key验证
@@ -724,15 +931,21 @@ const handlePageSizeChange = (pageSize: number) => {
const editKey = (key: ApiKey) => {
selectedKey.value = key
const hasIPRestriction = (key.ip_whitelist?.length > 0) || (key.ip_blacklist?.length > 0)
const hasExpiration = !!key.expires_at
formData.value = {
name: key.name,
group_id: key.group_id,
status: key.status,
status: key.status === 'quota_exhausted' || key.status === 'expired' ? 'inactive' : key.status,
use_custom_key: false,
custom_key: '',
enable_ip_restriction: hasIPRestriction,
ip_whitelist: (key.ip_whitelist || []).join('\n'),
ip_blacklist: (key.ip_blacklist || []).join('\n')
ip_blacklist: (key.ip_blacklist || []).join('\n'),
enable_quota: key.quota > 0,
quota: key.quota > 0 ? key.quota : null,
enable_expiration: hasExpiration,
expiration_preset: 'custom',
expiration_date: key.expires_at ? formatDateTimeLocal(key.expires_at) : ''
}
showEditModal.value = true
}
@@ -820,6 +1033,28 @@ const handleSubmit = async () => {
const ipWhitelist = formData.value.enable_ip_restriction ? parseIPList(formData.value.ip_whitelist) : []
const ipBlacklist = formData.value.enable_ip_restriction ? parseIPList(formData.value.ip_blacklist) : []
// Calculate quota value (null/empty/0 = unlimited, stored as 0)
const quota = formData.value.quota && formData.value.quota > 0 ? formData.value.quota : 0
// Calculate expiration
let expiresInDays: number | undefined
let expiresAt: string | null | undefined
if (formData.value.enable_expiration && formData.value.expiration_date) {
if (!showEditModal.value) {
// Create mode: calculate days from date
const expDate = new Date(formData.value.expiration_date)
const now = new Date()
const diffDays = Math.ceil((expDate.getTime() - now.getTime()) / (1000 * 60 * 60 * 24))
expiresInDays = diffDays > 0 ? diffDays : 1
} else {
// Edit mode: use custom date directly
expiresAt = new Date(formData.value.expiration_date).toISOString()
}
} else if (showEditModal.value) {
// Edit mode: if expiration disabled or date cleared, send empty string to clear
expiresAt = ''
}
submitting.value = true
try {
if (showEditModal.value && selectedKey.value) {
@@ -828,12 +1063,22 @@ const handleSubmit = async () => {
group_id: formData.value.group_id,
status: formData.value.status,
ip_whitelist: ipWhitelist,
ip_blacklist: ipBlacklist
ip_blacklist: ipBlacklist,
quota: quota,
expires_at: expiresAt
})
appStore.showSuccess(t('keys.keyUpdatedSuccess'))
} else {
const customKey = formData.value.use_custom_key ? formData.value.custom_key : undefined
await keysAPI.create(formData.value.name, formData.value.group_id, customKey, ipWhitelist, ipBlacklist)
await keysAPI.create(
formData.value.name,
formData.value.group_id,
customKey,
ipWhitelist,
ipBlacklist,
quota,
expiresInDays
)
appStore.showSuccess(t('keys.keyCreatedSuccess'))
// Only advance tour if active, on submit step, and creation succeeded
if (onboardingStore.isCurrentStep('[data-tour="key-form-submit"]')) {
@@ -883,7 +1128,42 @@ const closeModals = () => {
custom_key: '',
enable_ip_restriction: false,
ip_whitelist: '',
ip_blacklist: ''
ip_blacklist: '',
enable_quota: false,
quota: null,
enable_expiration: false,
expiration_preset: '30',
expiration_date: ''
}
}
// Show reset quota confirmation dialog
const confirmResetQuota = () => {
showResetQuotaDialog.value = true
}
// Set expiration date based on quick select days
const setExpirationDays = (days: number) => {
formData.value.expiration_preset = days.toString() as '7' | '30' | '90'
const expDate = new Date()
expDate.setDate(expDate.getDate() + days)
formData.value.expiration_date = formatDateTimeLocal(expDate.toISOString())
}
// Reset quota used for an API key
const resetQuotaUsed = async () => {
if (!selectedKey.value) return
showResetQuotaDialog.value = false
try {
await keysAPI.update(selectedKey.value.id, { reset_quota: true })
appStore.showSuccess(t('keys.quotaResetSuccess'))
// Update local state
if (selectedKey.value) {
selectedKey.value.quota_used = 0
}
} catch (error: any) {
const errorMsg = error.response?.data?.detail || t('keys.failedToResetQuota')
appStore.showError(errorMsg)
}
}