feat(api-key): add independent quota and expiration support

This feature allows API Keys to have their own quota limits and expiration
times, independent of the user's balance.

Backend:
- Add quota, quota_used, expires_at fields to api_key schema
- Implement IsExpired() and IsQuotaExhausted() checks in middleware
- Add ResetQuota and ClearExpiration API endpoints
- Integrate quota billing in gateway handlers (OpenAI, Anthropic, Gemini)
- Include quota/expiration fields in auth cache for performance
- Expiration check returns 403, quota exhausted returns 429

Frontend:
- Add quota and expiration inputs to key create/edit dialog
- Add quick-select buttons for expiration (+7, +30, +90 days)
- Add reset quota confirmation dialog
- Add expires_at column to keys list
- Add i18n translations for new features (en/zh)

Migration:
- Add 045_add_api_key_quota.sql for new columns
This commit is contained in:
bayma888
2026-02-03 19:01:49 +08:00
parent bb3df5785a
commit 6146be1474
32 changed files with 1804 additions and 172 deletions

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)
}