diff --git a/backend/ent/apikey.go b/backend/ent/apikey.go
index 91d71964..760851c8 100644
--- a/backend/ent/apikey.go
+++ b/backend/ent/apikey.go
@@ -36,6 +36,8 @@ type APIKey struct {
GroupID *int64 `json:"group_id,omitempty"`
// Status holds the value of the "status" field.
Status string `json:"status,omitempty"`
+ // Last usage time of this API key
+ LastUsedAt *time.Time `json:"last_used_at,omitempty"`
// Allowed IPs/CIDRs, e.g. ["192.168.1.100", "10.0.0.0/8"]
IPWhitelist []string `json:"ip_whitelist,omitempty"`
// Blocked IPs/CIDRs
@@ -109,7 +111,7 @@ func (*APIKey) scanValues(columns []string) ([]any, error) {
values[i] = new(sql.NullInt64)
case apikey.FieldKey, apikey.FieldName, apikey.FieldStatus:
values[i] = new(sql.NullString)
- case apikey.FieldCreatedAt, apikey.FieldUpdatedAt, apikey.FieldDeletedAt, apikey.FieldExpiresAt:
+ case apikey.FieldCreatedAt, apikey.FieldUpdatedAt, apikey.FieldDeletedAt, apikey.FieldLastUsedAt, apikey.FieldExpiresAt:
values[i] = new(sql.NullTime)
default:
values[i] = new(sql.UnknownType)
@@ -182,6 +184,13 @@ func (_m *APIKey) assignValues(columns []string, values []any) error {
} else if value.Valid {
_m.Status = value.String
}
+ case apikey.FieldLastUsedAt:
+ if value, ok := values[i].(*sql.NullTime); !ok {
+ return fmt.Errorf("unexpected type %T for field last_used_at", values[i])
+ } else if value.Valid {
+ _m.LastUsedAt = new(time.Time)
+ *_m.LastUsedAt = value.Time
+ }
case apikey.FieldIPWhitelist:
if value, ok := values[i].(*[]byte); !ok {
return fmt.Errorf("unexpected type %T for field ip_whitelist", values[i])
@@ -296,6 +305,11 @@ func (_m *APIKey) String() string {
builder.WriteString("status=")
builder.WriteString(_m.Status)
builder.WriteString(", ")
+ if v := _m.LastUsedAt; v != nil {
+ builder.WriteString("last_used_at=")
+ builder.WriteString(v.Format(time.ANSIC))
+ }
+ builder.WriteString(", ")
builder.WriteString("ip_whitelist=")
builder.WriteString(fmt.Sprintf("%v", _m.IPWhitelist))
builder.WriteString(", ")
diff --git a/backend/ent/apikey/apikey.go b/backend/ent/apikey/apikey.go
index ac2a6008..6abea56b 100644
--- a/backend/ent/apikey/apikey.go
+++ b/backend/ent/apikey/apikey.go
@@ -31,6 +31,8 @@ const (
FieldGroupID = "group_id"
// FieldStatus holds the string denoting the status field in the database.
FieldStatus = "status"
+ // FieldLastUsedAt holds the string denoting the last_used_at field in the database.
+ FieldLastUsedAt = "last_used_at"
// FieldIPWhitelist holds the string denoting the ip_whitelist field in the database.
FieldIPWhitelist = "ip_whitelist"
// FieldIPBlacklist holds the string denoting the ip_blacklist field in the database.
@@ -83,6 +85,7 @@ var Columns = []string{
FieldName,
FieldGroupID,
FieldStatus,
+ FieldLastUsedAt,
FieldIPWhitelist,
FieldIPBlacklist,
FieldQuota,
@@ -176,6 +179,11 @@ func ByStatus(opts ...sql.OrderTermOption) OrderOption {
return sql.OrderByField(FieldStatus, opts...).ToFunc()
}
+// ByLastUsedAt orders the results by the last_used_at field.
+func ByLastUsedAt(opts ...sql.OrderTermOption) OrderOption {
+ return sql.OrderByField(FieldLastUsedAt, opts...).ToFunc()
+}
+
// ByQuota orders the results by the quota field.
func ByQuota(opts ...sql.OrderTermOption) OrderOption {
return sql.OrderByField(FieldQuota, opts...).ToFunc()
diff --git a/backend/ent/apikey/where.go b/backend/ent/apikey/where.go
index f54f44b7..c1900ee1 100644
--- a/backend/ent/apikey/where.go
+++ b/backend/ent/apikey/where.go
@@ -95,6 +95,11 @@ func Status(v string) predicate.APIKey {
return predicate.APIKey(sql.FieldEQ(FieldStatus, v))
}
+// LastUsedAt applies equality check predicate on the "last_used_at" field. It's identical to LastUsedAtEQ.
+func LastUsedAt(v time.Time) predicate.APIKey {
+ return predicate.APIKey(sql.FieldEQ(FieldLastUsedAt, 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))
@@ -485,6 +490,56 @@ func StatusContainsFold(v string) predicate.APIKey {
return predicate.APIKey(sql.FieldContainsFold(FieldStatus, v))
}
+// LastUsedAtEQ applies the EQ predicate on the "last_used_at" field.
+func LastUsedAtEQ(v time.Time) predicate.APIKey {
+ return predicate.APIKey(sql.FieldEQ(FieldLastUsedAt, v))
+}
+
+// LastUsedAtNEQ applies the NEQ predicate on the "last_used_at" field.
+func LastUsedAtNEQ(v time.Time) predicate.APIKey {
+ return predicate.APIKey(sql.FieldNEQ(FieldLastUsedAt, v))
+}
+
+// LastUsedAtIn applies the In predicate on the "last_used_at" field.
+func LastUsedAtIn(vs ...time.Time) predicate.APIKey {
+ return predicate.APIKey(sql.FieldIn(FieldLastUsedAt, vs...))
+}
+
+// LastUsedAtNotIn applies the NotIn predicate on the "last_used_at" field.
+func LastUsedAtNotIn(vs ...time.Time) predicate.APIKey {
+ return predicate.APIKey(sql.FieldNotIn(FieldLastUsedAt, vs...))
+}
+
+// LastUsedAtGT applies the GT predicate on the "last_used_at" field.
+func LastUsedAtGT(v time.Time) predicate.APIKey {
+ return predicate.APIKey(sql.FieldGT(FieldLastUsedAt, v))
+}
+
+// LastUsedAtGTE applies the GTE predicate on the "last_used_at" field.
+func LastUsedAtGTE(v time.Time) predicate.APIKey {
+ return predicate.APIKey(sql.FieldGTE(FieldLastUsedAt, v))
+}
+
+// LastUsedAtLT applies the LT predicate on the "last_used_at" field.
+func LastUsedAtLT(v time.Time) predicate.APIKey {
+ return predicate.APIKey(sql.FieldLT(FieldLastUsedAt, v))
+}
+
+// LastUsedAtLTE applies the LTE predicate on the "last_used_at" field.
+func LastUsedAtLTE(v time.Time) predicate.APIKey {
+ return predicate.APIKey(sql.FieldLTE(FieldLastUsedAt, v))
+}
+
+// LastUsedAtIsNil applies the IsNil predicate on the "last_used_at" field.
+func LastUsedAtIsNil() predicate.APIKey {
+ return predicate.APIKey(sql.FieldIsNull(FieldLastUsedAt))
+}
+
+// LastUsedAtNotNil applies the NotNil predicate on the "last_used_at" field.
+func LastUsedAtNotNil() predicate.APIKey {
+ return predicate.APIKey(sql.FieldNotNull(FieldLastUsedAt))
+}
+
// IPWhitelistIsNil applies the IsNil predicate on the "ip_whitelist" field.
func IPWhitelistIsNil() predicate.APIKey {
return predicate.APIKey(sql.FieldIsNull(FieldIPWhitelist))
diff --git a/backend/ent/apikey_create.go b/backend/ent/apikey_create.go
index 71540975..bc506585 100644
--- a/backend/ent/apikey_create.go
+++ b/backend/ent/apikey_create.go
@@ -113,6 +113,20 @@ func (_c *APIKeyCreate) SetNillableStatus(v *string) *APIKeyCreate {
return _c
}
+// SetLastUsedAt sets the "last_used_at" field.
+func (_c *APIKeyCreate) SetLastUsedAt(v time.Time) *APIKeyCreate {
+ _c.mutation.SetLastUsedAt(v)
+ return _c
+}
+
+// SetNillableLastUsedAt sets the "last_used_at" field if the given value is not nil.
+func (_c *APIKeyCreate) SetNillableLastUsedAt(v *time.Time) *APIKeyCreate {
+ if v != nil {
+ _c.SetLastUsedAt(*v)
+ }
+ return _c
+}
+
// SetIPWhitelist sets the "ip_whitelist" field.
func (_c *APIKeyCreate) SetIPWhitelist(v []string) *APIKeyCreate {
_c.mutation.SetIPWhitelist(v)
@@ -353,6 +367,10 @@ func (_c *APIKeyCreate) createSpec() (*APIKey, *sqlgraph.CreateSpec) {
_spec.SetField(apikey.FieldStatus, field.TypeString, value)
_node.Status = value
}
+ if value, ok := _c.mutation.LastUsedAt(); ok {
+ _spec.SetField(apikey.FieldLastUsedAt, field.TypeTime, value)
+ _node.LastUsedAt = &value
+ }
if value, ok := _c.mutation.IPWhitelist(); ok {
_spec.SetField(apikey.FieldIPWhitelist, field.TypeJSON, value)
_node.IPWhitelist = value
@@ -571,6 +589,24 @@ func (u *APIKeyUpsert) UpdateStatus() *APIKeyUpsert {
return u
}
+// SetLastUsedAt sets the "last_used_at" field.
+func (u *APIKeyUpsert) SetLastUsedAt(v time.Time) *APIKeyUpsert {
+ u.Set(apikey.FieldLastUsedAt, v)
+ return u
+}
+
+// UpdateLastUsedAt sets the "last_used_at" field to the value that was provided on create.
+func (u *APIKeyUpsert) UpdateLastUsedAt() *APIKeyUpsert {
+ u.SetExcluded(apikey.FieldLastUsedAt)
+ return u
+}
+
+// ClearLastUsedAt clears the value of the "last_used_at" field.
+func (u *APIKeyUpsert) ClearLastUsedAt() *APIKeyUpsert {
+ u.SetNull(apikey.FieldLastUsedAt)
+ return u
+}
+
// SetIPWhitelist sets the "ip_whitelist" field.
func (u *APIKeyUpsert) SetIPWhitelist(v []string) *APIKeyUpsert {
u.Set(apikey.FieldIPWhitelist, v)
@@ -818,6 +854,27 @@ func (u *APIKeyUpsertOne) UpdateStatus() *APIKeyUpsertOne {
})
}
+// SetLastUsedAt sets the "last_used_at" field.
+func (u *APIKeyUpsertOne) SetLastUsedAt(v time.Time) *APIKeyUpsertOne {
+ return u.Update(func(s *APIKeyUpsert) {
+ s.SetLastUsedAt(v)
+ })
+}
+
+// UpdateLastUsedAt sets the "last_used_at" field to the value that was provided on create.
+func (u *APIKeyUpsertOne) UpdateLastUsedAt() *APIKeyUpsertOne {
+ return u.Update(func(s *APIKeyUpsert) {
+ s.UpdateLastUsedAt()
+ })
+}
+
+// ClearLastUsedAt clears the value of the "last_used_at" field.
+func (u *APIKeyUpsertOne) ClearLastUsedAt() *APIKeyUpsertOne {
+ return u.Update(func(s *APIKeyUpsert) {
+ s.ClearLastUsedAt()
+ })
+}
+
// SetIPWhitelist sets the "ip_whitelist" field.
func (u *APIKeyUpsertOne) SetIPWhitelist(v []string) *APIKeyUpsertOne {
return u.Update(func(s *APIKeyUpsert) {
@@ -1246,6 +1303,27 @@ func (u *APIKeyUpsertBulk) UpdateStatus() *APIKeyUpsertBulk {
})
}
+// SetLastUsedAt sets the "last_used_at" field.
+func (u *APIKeyUpsertBulk) SetLastUsedAt(v time.Time) *APIKeyUpsertBulk {
+ return u.Update(func(s *APIKeyUpsert) {
+ s.SetLastUsedAt(v)
+ })
+}
+
+// UpdateLastUsedAt sets the "last_used_at" field to the value that was provided on create.
+func (u *APIKeyUpsertBulk) UpdateLastUsedAt() *APIKeyUpsertBulk {
+ return u.Update(func(s *APIKeyUpsert) {
+ s.UpdateLastUsedAt()
+ })
+}
+
+// ClearLastUsedAt clears the value of the "last_used_at" field.
+func (u *APIKeyUpsertBulk) ClearLastUsedAt() *APIKeyUpsertBulk {
+ return u.Update(func(s *APIKeyUpsert) {
+ s.ClearLastUsedAt()
+ })
+}
+
// SetIPWhitelist sets the "ip_whitelist" field.
func (u *APIKeyUpsertBulk) SetIPWhitelist(v []string) *APIKeyUpsertBulk {
return u.Update(func(s *APIKeyUpsert) {
diff --git a/backend/ent/apikey_update.go b/backend/ent/apikey_update.go
index b4ff230b..6ca01854 100644
--- a/backend/ent/apikey_update.go
+++ b/backend/ent/apikey_update.go
@@ -134,6 +134,26 @@ func (_u *APIKeyUpdate) SetNillableStatus(v *string) *APIKeyUpdate {
return _u
}
+// SetLastUsedAt sets the "last_used_at" field.
+func (_u *APIKeyUpdate) SetLastUsedAt(v time.Time) *APIKeyUpdate {
+ _u.mutation.SetLastUsedAt(v)
+ return _u
+}
+
+// SetNillableLastUsedAt sets the "last_used_at" field if the given value is not nil.
+func (_u *APIKeyUpdate) SetNillableLastUsedAt(v *time.Time) *APIKeyUpdate {
+ if v != nil {
+ _u.SetLastUsedAt(*v)
+ }
+ return _u
+}
+
+// ClearLastUsedAt clears the value of the "last_used_at" field.
+func (_u *APIKeyUpdate) ClearLastUsedAt() *APIKeyUpdate {
+ _u.mutation.ClearLastUsedAt()
+ return _u
+}
+
// SetIPWhitelist sets the "ip_whitelist" field.
func (_u *APIKeyUpdate) SetIPWhitelist(v []string) *APIKeyUpdate {
_u.mutation.SetIPWhitelist(v)
@@ -390,6 +410,12 @@ func (_u *APIKeyUpdate) sqlSave(ctx context.Context) (_node int, err error) {
if value, ok := _u.mutation.Status(); ok {
_spec.SetField(apikey.FieldStatus, field.TypeString, value)
}
+ if value, ok := _u.mutation.LastUsedAt(); ok {
+ _spec.SetField(apikey.FieldLastUsedAt, field.TypeTime, value)
+ }
+ if _u.mutation.LastUsedAtCleared() {
+ _spec.ClearField(apikey.FieldLastUsedAt, field.TypeTime)
+ }
if value, ok := _u.mutation.IPWhitelist(); ok {
_spec.SetField(apikey.FieldIPWhitelist, field.TypeJSON, value)
}
@@ -655,6 +681,26 @@ func (_u *APIKeyUpdateOne) SetNillableStatus(v *string) *APIKeyUpdateOne {
return _u
}
+// SetLastUsedAt sets the "last_used_at" field.
+func (_u *APIKeyUpdateOne) SetLastUsedAt(v time.Time) *APIKeyUpdateOne {
+ _u.mutation.SetLastUsedAt(v)
+ return _u
+}
+
+// SetNillableLastUsedAt sets the "last_used_at" field if the given value is not nil.
+func (_u *APIKeyUpdateOne) SetNillableLastUsedAt(v *time.Time) *APIKeyUpdateOne {
+ if v != nil {
+ _u.SetLastUsedAt(*v)
+ }
+ return _u
+}
+
+// ClearLastUsedAt clears the value of the "last_used_at" field.
+func (_u *APIKeyUpdateOne) ClearLastUsedAt() *APIKeyUpdateOne {
+ _u.mutation.ClearLastUsedAt()
+ return _u
+}
+
// SetIPWhitelist sets the "ip_whitelist" field.
func (_u *APIKeyUpdateOne) SetIPWhitelist(v []string) *APIKeyUpdateOne {
_u.mutation.SetIPWhitelist(v)
@@ -941,6 +987,12 @@ func (_u *APIKeyUpdateOne) sqlSave(ctx context.Context) (_node *APIKey, err erro
if value, ok := _u.mutation.Status(); ok {
_spec.SetField(apikey.FieldStatus, field.TypeString, value)
}
+ if value, ok := _u.mutation.LastUsedAt(); ok {
+ _spec.SetField(apikey.FieldLastUsedAt, field.TypeTime, value)
+ }
+ if _u.mutation.LastUsedAtCleared() {
+ _spec.ClearField(apikey.FieldLastUsedAt, field.TypeTime)
+ }
if value, ok := _u.mutation.IPWhitelist(); ok {
_spec.SetField(apikey.FieldIPWhitelist, field.TypeJSON, value)
}
diff --git a/backend/ent/migrate/schema.go b/backend/ent/migrate/schema.go
index 3c8d4870..aba00d4f 100644
--- a/backend/ent/migrate/schema.go
+++ b/backend/ent/migrate/schema.go
@@ -18,6 +18,7 @@ var (
{Name: "key", Type: field.TypeString, Unique: true, Size: 128},
{Name: "name", Type: field.TypeString, Size: 100},
{Name: "status", Type: field.TypeString, Size: 20, Default: "active"},
+ {Name: "last_used_at", Type: field.TypeTime, Nullable: true},
{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)"}},
@@ -34,13 +35,13 @@ var (
ForeignKeys: []*schema.ForeignKey{
{
Symbol: "api_keys_groups_api_keys",
- Columns: []*schema.Column{APIKeysColumns[12]},
+ Columns: []*schema.Column{APIKeysColumns[13]},
RefColumns: []*schema.Column{GroupsColumns[0]},
OnDelete: schema.SetNull,
},
{
Symbol: "api_keys_users_api_keys",
- Columns: []*schema.Column{APIKeysColumns[13]},
+ Columns: []*schema.Column{APIKeysColumns[14]},
RefColumns: []*schema.Column{UsersColumns[0]},
OnDelete: schema.NoAction,
},
@@ -49,12 +50,12 @@ var (
{
Name: "apikey_user_id",
Unique: false,
- Columns: []*schema.Column{APIKeysColumns[13]},
+ Columns: []*schema.Column{APIKeysColumns[14]},
},
{
Name: "apikey_group_id",
Unique: false,
- Columns: []*schema.Column{APIKeysColumns[12]},
+ Columns: []*schema.Column{APIKeysColumns[13]},
},
{
Name: "apikey_status",
@@ -66,15 +67,20 @@ var (
Unique: false,
Columns: []*schema.Column{APIKeysColumns[3]},
},
+ {
+ Name: "apikey_last_used_at",
+ Unique: false,
+ Columns: []*schema.Column{APIKeysColumns[7]},
+ },
{
Name: "apikey_quota_quota_used",
Unique: false,
- Columns: []*schema.Column{APIKeysColumns[9], APIKeysColumns[10]},
+ Columns: []*schema.Column{APIKeysColumns[10], APIKeysColumns[11]},
},
{
Name: "apikey_expires_at",
Unique: false,
- Columns: []*schema.Column{APIKeysColumns[11]},
+ Columns: []*schema.Column{APIKeysColumns[12]},
},
},
}
diff --git a/backend/ent/mutation.go b/backend/ent/mutation.go
index 678e98c4..7d5bf180 100644
--- a/backend/ent/mutation.go
+++ b/backend/ent/mutation.go
@@ -79,6 +79,7 @@ type APIKeyMutation struct {
key *string
name *string
status *string
+ last_used_at *time.Time
ip_whitelist *[]string
appendip_whitelist []string
ip_blacklist *[]string
@@ -513,6 +514,55 @@ func (m *APIKeyMutation) ResetStatus() {
m.status = nil
}
+// SetLastUsedAt sets the "last_used_at" field.
+func (m *APIKeyMutation) SetLastUsedAt(t time.Time) {
+ m.last_used_at = &t
+}
+
+// LastUsedAt returns the value of the "last_used_at" field in the mutation.
+func (m *APIKeyMutation) LastUsedAt() (r time.Time, exists bool) {
+ v := m.last_used_at
+ if v == nil {
+ return
+ }
+ return *v, true
+}
+
+// OldLastUsedAt returns the old "last_used_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) OldLastUsedAt(ctx context.Context) (v *time.Time, err error) {
+ if !m.op.Is(OpUpdateOne) {
+ return v, errors.New("OldLastUsedAt is only allowed on UpdateOne operations")
+ }
+ if m.id == nil || m.oldValue == nil {
+ return v, errors.New("OldLastUsedAt requires an ID field in the mutation")
+ }
+ oldValue, err := m.oldValue(ctx)
+ if err != nil {
+ return v, fmt.Errorf("querying old value for OldLastUsedAt: %w", err)
+ }
+ return oldValue.LastUsedAt, nil
+}
+
+// ClearLastUsedAt clears the value of the "last_used_at" field.
+func (m *APIKeyMutation) ClearLastUsedAt() {
+ m.last_used_at = nil
+ m.clearedFields[apikey.FieldLastUsedAt] = struct{}{}
+}
+
+// LastUsedAtCleared returns if the "last_used_at" field was cleared in this mutation.
+func (m *APIKeyMutation) LastUsedAtCleared() bool {
+ _, ok := m.clearedFields[apikey.FieldLastUsedAt]
+ return ok
+}
+
+// ResetLastUsedAt resets all changes to the "last_used_at" field.
+func (m *APIKeyMutation) ResetLastUsedAt() {
+ m.last_used_at = nil
+ delete(m.clearedFields, apikey.FieldLastUsedAt)
+}
+
// SetIPWhitelist sets the "ip_whitelist" field.
func (m *APIKeyMutation) SetIPWhitelist(s []string) {
m.ip_whitelist = &s
@@ -946,7 +996,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, 13)
+ fields := make([]string, 0, 14)
if m.created_at != nil {
fields = append(fields, apikey.FieldCreatedAt)
}
@@ -971,6 +1021,9 @@ func (m *APIKeyMutation) Fields() []string {
if m.status != nil {
fields = append(fields, apikey.FieldStatus)
}
+ if m.last_used_at != nil {
+ fields = append(fields, apikey.FieldLastUsedAt)
+ }
if m.ip_whitelist != nil {
fields = append(fields, apikey.FieldIPWhitelist)
}
@@ -1010,6 +1063,8 @@ func (m *APIKeyMutation) Field(name string) (ent.Value, bool) {
return m.GroupID()
case apikey.FieldStatus:
return m.Status()
+ case apikey.FieldLastUsedAt:
+ return m.LastUsedAt()
case apikey.FieldIPWhitelist:
return m.IPWhitelist()
case apikey.FieldIPBlacklist:
@@ -1045,6 +1100,8 @@ func (m *APIKeyMutation) OldField(ctx context.Context, name string) (ent.Value,
return m.OldGroupID(ctx)
case apikey.FieldStatus:
return m.OldStatus(ctx)
+ case apikey.FieldLastUsedAt:
+ return m.OldLastUsedAt(ctx)
case apikey.FieldIPWhitelist:
return m.OldIPWhitelist(ctx)
case apikey.FieldIPBlacklist:
@@ -1120,6 +1177,13 @@ func (m *APIKeyMutation) SetField(name string, value ent.Value) error {
}
m.SetStatus(v)
return nil
+ case apikey.FieldLastUsedAt:
+ v, ok := value.(time.Time)
+ if !ok {
+ return fmt.Errorf("unexpected type %T for field %s", value, name)
+ }
+ m.SetLastUsedAt(v)
+ return nil
case apikey.FieldIPWhitelist:
v, ok := value.([]string)
if !ok {
@@ -1218,6 +1282,9 @@ func (m *APIKeyMutation) ClearedFields() []string {
if m.FieldCleared(apikey.FieldGroupID) {
fields = append(fields, apikey.FieldGroupID)
}
+ if m.FieldCleared(apikey.FieldLastUsedAt) {
+ fields = append(fields, apikey.FieldLastUsedAt)
+ }
if m.FieldCleared(apikey.FieldIPWhitelist) {
fields = append(fields, apikey.FieldIPWhitelist)
}
@@ -1247,6 +1314,9 @@ func (m *APIKeyMutation) ClearField(name string) error {
case apikey.FieldGroupID:
m.ClearGroupID()
return nil
+ case apikey.FieldLastUsedAt:
+ m.ClearLastUsedAt()
+ return nil
case apikey.FieldIPWhitelist:
m.ClearIPWhitelist()
return nil
@@ -1288,6 +1358,9 @@ func (m *APIKeyMutation) ResetField(name string) error {
case apikey.FieldStatus:
m.ResetStatus()
return nil
+ case apikey.FieldLastUsedAt:
+ m.ResetLastUsedAt()
+ return nil
case apikey.FieldIPWhitelist:
m.ResetIPWhitelist()
return nil
diff --git a/backend/ent/runtime/runtime.go b/backend/ent/runtime/runtime.go
index 5e980be0..ff3f8f26 100644
--- a/backend/ent/runtime/runtime.go
+++ b/backend/ent/runtime/runtime.go
@@ -94,11 +94,11 @@ func init() {
// 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()
+ apikeyDescQuota := apikeyFields[8].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()
+ apikeyDescQuotaUsed := apikeyFields[9].Descriptor()
// apikey.DefaultQuotaUsed holds the default value on creation for the quota_used field.
apikey.DefaultQuotaUsed = apikeyDescQuotaUsed.Default.(float64)
accountMixin := schema.Account{}.Mixin()
diff --git a/backend/ent/schema/api_key.go b/backend/ent/schema/api_key.go
index 26d52cb0..c1ac7ac3 100644
--- a/backend/ent/schema/api_key.go
+++ b/backend/ent/schema/api_key.go
@@ -47,6 +47,10 @@ func (APIKey) Fields() []ent.Field {
field.String("status").
MaxLen(20).
Default(domain.StatusActive),
+ field.Time("last_used_at").
+ Optional().
+ Nillable().
+ Comment("Last usage time of this API key"),
field.JSON("ip_whitelist", []string{}).
Optional().
Comment("Allowed IPs/CIDRs, e.g. [\"192.168.1.100\", \"10.0.0.0/8\"]"),
@@ -95,6 +99,7 @@ func (APIKey) Indexes() []ent.Index {
index.Fields("group_id"),
index.Fields("status"),
index.Fields("deleted_at"),
+ index.Fields("last_used_at"),
// Index for quota queries
index.Fields("quota", "quota_used"),
index.Fields("expires_at"),
diff --git a/backend/go.sum b/backend/go.sum
index fa84988a..f044c3a8 100644
--- a/backend/go.sum
+++ b/backend/go.sum
@@ -176,6 +176,8 @@ github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovk
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
+github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U=
+github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/mattn/go-sqlite3 v1.14.17 h1:mCRHCLDUBXgpKAqIKsaAaAsrAlbkeomtRFKXh2L6YIM=
github.com/mattn/go-sqlite3 v1.14.17/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg=
github.com/mdelapenya/tlscert v0.2.0 h1:7H81W6Z/4weDvZBNOfQte5GpIMo0lGYEeWbkGp5LJHI=
@@ -209,6 +211,8 @@ github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A=
github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc=
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
+github.com/olekukonko/tablewriter v0.0.5 h1:P2Ga83D34wi1o9J6Wh1mRuqd4mF/x/lgBS7N7AbDhec=
+github.com/olekukonko/tablewriter v0.0.5/go.mod h1:hPp6KlRPjbx+hW8ykQs1w3UBbZlj6HuIJcUGPhkA7kY=
github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040=
@@ -238,6 +242,8 @@ github.com/refraction-networking/utls v1.8.1 h1:yNY1kapmQU8JeM1sSw2H2asfTIwWxIkr
github.com/refraction-networking/utls v1.8.1/go.mod h1:jkSOEkLqn+S/jtpEHPOsVv/4V4EVnelwbMQl4vCWXAM=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
+github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY=
+github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII=
@@ -260,6 +266,8 @@ github.com/spf13/afero v1.11.0 h1:WJQKhtpdm3v2IzqG8VMqrr6Rf3UYpEF239Jy9wNepM8=
github.com/spf13/afero v1.11.0/go.mod h1:GH9Y3pIexgf1MTIWtNGyogA5MwRIDXGUr+hbWNoBjkY=
github.com/spf13/cast v1.6.0 h1:GEiTHELF+vaR5dhz3VqZfFSzZjYbgeKDpBxQVS4GYJ0=
github.com/spf13/cast v1.6.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo=
+github.com/spf13/cobra v1.7.0 h1:hyqWnYt1ZQShIddO5kBpj3vu05/++x6tJ6dg8EC572I=
+github.com/spf13/cobra v1.7.0/go.mod h1:uLxZILRyS/50WlhOIKD7W6V5bgeIt+4sICxh6uRMrb0=
github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/viper v1.18.2 h1:LUXCnvUvSM6FXAsj6nnfc8Q2tp1dIgUfY9Kc8GsSOiQ=
diff --git a/backend/internal/handler/dto/api_key_mapper_last_used_test.go b/backend/internal/handler/dto/api_key_mapper_last_used_test.go
new file mode 100644
index 00000000..99644ced
--- /dev/null
+++ b/backend/internal/handler/dto/api_key_mapper_last_used_test.go
@@ -0,0 +1,40 @@
+package dto
+
+import (
+ "testing"
+ "time"
+
+ "github.com/Wei-Shaw/sub2api/internal/service"
+ "github.com/stretchr/testify/require"
+)
+
+func TestAPIKeyFromService_MapsLastUsedAt(t *testing.T) {
+ lastUsed := time.Now().UTC().Truncate(time.Second)
+ src := &service.APIKey{
+ ID: 1,
+ UserID: 2,
+ Key: "sk-map-last-used",
+ Name: "Mapper",
+ Status: service.StatusActive,
+ LastUsedAt: &lastUsed,
+ }
+
+ out := APIKeyFromService(src)
+ require.NotNil(t, out)
+ require.NotNil(t, out.LastUsedAt)
+ require.WithinDuration(t, lastUsed, *out.LastUsedAt, time.Second)
+}
+
+func TestAPIKeyFromService_MapsNilLastUsedAt(t *testing.T) {
+ src := &service.APIKey{
+ ID: 1,
+ UserID: 2,
+ Key: "sk-map-last-used-nil",
+ Name: "MapperNil",
+ Status: service.StatusActive,
+ }
+
+ out := APIKeyFromService(src)
+ require.NotNil(t, out)
+ require.Nil(t, out.LastUsedAt)
+}
diff --git a/backend/internal/handler/dto/mappers.go b/backend/internal/handler/dto/mappers.go
index dbc7a8bc..5e327022 100644
--- a/backend/internal/handler/dto/mappers.go
+++ b/backend/internal/handler/dto/mappers.go
@@ -77,6 +77,7 @@ func APIKeyFromService(k *service.APIKey) *APIKey {
Status: k.Status,
IPWhitelist: k.IPWhitelist,
IPBlacklist: k.IPBlacklist,
+ LastUsedAt: k.LastUsedAt,
Quota: k.Quota,
QuotaUsed: k.QuotaUsed,
ExpiresAt: k.ExpiresAt,
diff --git a/backend/internal/handler/dto/types.go b/backend/internal/handler/dto/types.go
index f2605ffc..70a8c792 100644
--- a/backend/internal/handler/dto/types.go
+++ b/backend/internal/handler/dto/types.go
@@ -38,6 +38,7 @@ type APIKey struct {
Status string `json:"status"`
IPWhitelist []string `json:"ip_whitelist"`
IPBlacklist []string `json:"ip_blacklist"`
+ LastUsedAt *time.Time `json:"last_used_at"`
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)
diff --git a/backend/internal/repository/api_key_repo.go b/backend/internal/repository/api_key_repo.go
index 9dcf0fe6..cdccd4fc 100644
--- a/backend/internal/repository/api_key_repo.go
+++ b/backend/internal/repository/api_key_repo.go
@@ -34,6 +34,7 @@ func (r *apiKeyRepository) Create(ctx context.Context, key *service.APIKey) erro
SetName(key.Name).
SetStatus(key.Status).
SetNillableGroupID(key.GroupID).
+ SetNillableLastUsedAt(key.LastUsedAt).
SetQuota(key.Quota).
SetQuotaUsed(key.QuotaUsed).
SetNillableExpiresAt(key.ExpiresAt)
@@ -48,6 +49,7 @@ func (r *apiKeyRepository) Create(ctx context.Context, key *service.APIKey) erro
created, err := builder.Save(ctx)
if err == nil {
key.ID = created.ID
+ key.LastUsedAt = created.LastUsedAt
key.CreatedAt = created.CreatedAt
key.UpdatedAt = created.UpdatedAt
}
@@ -394,6 +396,21 @@ func (r *apiKeyRepository) IncrementQuotaUsed(ctx context.Context, id int64, amo
return updated.QuotaUsed, nil
}
+func (r *apiKeyRepository) UpdateLastUsed(ctx context.Context, id int64, usedAt time.Time) error {
+ affected, err := r.client.APIKey.Update().
+ Where(apikey.IDEQ(id), apikey.DeletedAtIsNil()).
+ SetLastUsedAt(usedAt).
+ SetUpdatedAt(usedAt).
+ Save(ctx)
+ if err != nil {
+ return err
+ }
+ if affected == 0 {
+ return service.ErrAPIKeyNotFound
+ }
+ return nil
+}
+
func apiKeyEntityToService(m *dbent.APIKey) *service.APIKey {
if m == nil {
return nil
@@ -406,6 +423,7 @@ func apiKeyEntityToService(m *dbent.APIKey) *service.APIKey {
Status: m.Status,
IPWhitelist: m.IPWhitelist,
IPBlacklist: m.IPBlacklist,
+ LastUsedAt: m.LastUsedAt,
CreatedAt: m.CreatedAt,
UpdatedAt: m.UpdatedAt,
GroupID: m.GroupID,
diff --git a/backend/internal/repository/api_key_repo_last_used_unit_test.go b/backend/internal/repository/api_key_repo_last_used_unit_test.go
new file mode 100644
index 00000000..7c6e2850
--- /dev/null
+++ b/backend/internal/repository/api_key_repo_last_used_unit_test.go
@@ -0,0 +1,156 @@
+package repository
+
+import (
+ "context"
+ "database/sql"
+ "testing"
+ "time"
+
+ dbent "github.com/Wei-Shaw/sub2api/ent"
+ "github.com/Wei-Shaw/sub2api/ent/enttest"
+ "github.com/Wei-Shaw/sub2api/internal/service"
+ "github.com/stretchr/testify/require"
+
+ "entgo.io/ent/dialect"
+ entsql "entgo.io/ent/dialect/sql"
+ _ "modernc.org/sqlite"
+)
+
+func newAPIKeyRepoSQLite(t *testing.T) (*apiKeyRepository, *dbent.Client) {
+ t.Helper()
+
+ db, err := sql.Open("sqlite", "file:api_key_repo_last_used?mode=memory&cache=shared")
+ require.NoError(t, err)
+ t.Cleanup(func() { _ = db.Close() })
+
+ _, err = db.Exec("PRAGMA foreign_keys = ON")
+ require.NoError(t, err)
+
+ drv := entsql.OpenDB(dialect.SQLite, db)
+ client := enttest.NewClient(t, enttest.WithOptions(dbent.Driver(drv)))
+ t.Cleanup(func() { _ = client.Close() })
+
+ return &apiKeyRepository{client: client}, client
+}
+
+func mustCreateAPIKeyRepoUser(t *testing.T, ctx context.Context, client *dbent.Client, email string) *service.User {
+ t.Helper()
+ u, err := client.User.Create().
+ SetEmail(email).
+ SetPasswordHash("test-password-hash").
+ SetRole(service.RoleUser).
+ SetStatus(service.StatusActive).
+ Save(ctx)
+ require.NoError(t, err)
+ return userEntityToService(u)
+}
+
+func TestAPIKeyRepository_CreateWithLastUsedAt(t *testing.T) {
+ repo, client := newAPIKeyRepoSQLite(t)
+ ctx := context.Background()
+ user := mustCreateAPIKeyRepoUser(t, ctx, client, "create-last-used@test.com")
+
+ lastUsed := time.Now().UTC().Add(-time.Hour).Truncate(time.Second)
+ key := &service.APIKey{
+ UserID: user.ID,
+ Key: "sk-create-last-used",
+ Name: "CreateWithLastUsed",
+ Status: service.StatusActive,
+ LastUsedAt: &lastUsed,
+ }
+
+ require.NoError(t, repo.Create(ctx, key))
+ require.NotNil(t, key.LastUsedAt)
+ require.WithinDuration(t, lastUsed, *key.LastUsedAt, time.Second)
+
+ got, err := repo.GetByID(ctx, key.ID)
+ require.NoError(t, err)
+ require.NotNil(t, got.LastUsedAt)
+ require.WithinDuration(t, lastUsed, *got.LastUsedAt, time.Second)
+}
+
+func TestAPIKeyRepository_UpdateLastUsed(t *testing.T) {
+ repo, client := newAPIKeyRepoSQLite(t)
+ ctx := context.Background()
+ user := mustCreateAPIKeyRepoUser(t, ctx, client, "update-last-used@test.com")
+
+ key := &service.APIKey{
+ UserID: user.ID,
+ Key: "sk-update-last-used",
+ Name: "UpdateLastUsed",
+ Status: service.StatusActive,
+ }
+ require.NoError(t, repo.Create(ctx, key))
+
+ before, err := repo.GetByID(ctx, key.ID)
+ require.NoError(t, err)
+ require.Nil(t, before.LastUsedAt)
+
+ target := time.Now().UTC().Add(2 * time.Minute).Truncate(time.Second)
+ require.NoError(t, repo.UpdateLastUsed(ctx, key.ID, target))
+
+ after, err := repo.GetByID(ctx, key.ID)
+ require.NoError(t, err)
+ require.NotNil(t, after.LastUsedAt)
+ require.WithinDuration(t, target, *after.LastUsedAt, time.Second)
+ require.WithinDuration(t, target, after.UpdatedAt, time.Second)
+}
+
+func TestAPIKeyRepository_UpdateLastUsedDeletedKey(t *testing.T) {
+ repo, client := newAPIKeyRepoSQLite(t)
+ ctx := context.Background()
+ user := mustCreateAPIKeyRepoUser(t, ctx, client, "deleted-last-used@test.com")
+
+ key := &service.APIKey{
+ UserID: user.ID,
+ Key: "sk-update-last-used-deleted",
+ Name: "UpdateLastUsedDeleted",
+ Status: service.StatusActive,
+ }
+ require.NoError(t, repo.Create(ctx, key))
+ require.NoError(t, repo.Delete(ctx, key.ID))
+
+ err := repo.UpdateLastUsed(ctx, key.ID, time.Now().UTC())
+ require.ErrorIs(t, err, service.ErrAPIKeyNotFound)
+}
+
+func TestAPIKeyRepository_UpdateLastUsedDBError(t *testing.T) {
+ repo, client := newAPIKeyRepoSQLite(t)
+ ctx := context.Background()
+ user := mustCreateAPIKeyRepoUser(t, ctx, client, "db-error-last-used@test.com")
+
+ key := &service.APIKey{
+ UserID: user.ID,
+ Key: "sk-update-last-used-db-error",
+ Name: "UpdateLastUsedDBError",
+ Status: service.StatusActive,
+ }
+ require.NoError(t, repo.Create(ctx, key))
+
+ require.NoError(t, client.Close())
+ err := repo.UpdateLastUsed(ctx, key.ID, time.Now().UTC())
+ require.Error(t, err)
+}
+
+func TestAPIKeyRepository_CreateDuplicateKey(t *testing.T) {
+ repo, client := newAPIKeyRepoSQLite(t)
+ ctx := context.Background()
+ user := mustCreateAPIKeyRepoUser(t, ctx, client, "duplicate-key@test.com")
+
+ first := &service.APIKey{
+ UserID: user.ID,
+ Key: "sk-duplicate",
+ Name: "first",
+ Status: service.StatusActive,
+ }
+ second := &service.APIKey{
+ UserID: user.ID,
+ Key: "sk-duplicate",
+ Name: "second",
+ Status: service.StatusActive,
+ }
+
+ require.NoError(t, repo.Create(ctx, first))
+ err := repo.Create(ctx, second)
+ require.ErrorIs(t, err, service.ErrAPIKeyExists)
+}
diff --git a/backend/internal/server/api_contract_test.go b/backend/internal/server/api_contract_test.go
index d87d97b5..76897bc1 100644
--- a/backend/internal/server/api_contract_test.go
+++ b/backend/internal/server/api_contract_test.go
@@ -83,6 +83,7 @@ func TestAPIContracts(t *testing.T) {
"status": "active",
"ip_whitelist": null,
"ip_blacklist": null,
+ "last_used_at": null,
"quota": 0,
"quota_used": 0,
"expires_at": null,
@@ -122,6 +123,7 @@ func TestAPIContracts(t *testing.T) {
"status": "active",
"ip_whitelist": null,
"ip_blacklist": null,
+ "last_used_at": null,
"quota": 0,
"quota_used": 0,
"expires_at": null,
@@ -1471,6 +1473,20 @@ func (r *stubApiKeyRepo) IncrementQuotaUsed(ctx context.Context, id int64, amoun
return 0, errors.New("not implemented")
}
+func (r *stubApiKeyRepo) UpdateLastUsed(ctx context.Context, id int64, usedAt time.Time) error {
+ key, ok := r.byID[id]
+ if !ok {
+ return service.ErrAPIKeyNotFound
+ }
+ ts := usedAt
+ key.LastUsedAt = &ts
+ key.UpdatedAt = usedAt
+ clone := *key
+ r.byID[id] = &clone
+ r.byKey[clone.Key] = &clone
+ return nil
+}
+
type stubUsageLogRepo struct {
userLogs map[int64][]service.UsageLog
}
diff --git a/backend/internal/server/middleware/api_key_auth.go b/backend/internal/server/middleware/api_key_auth.go
index 7aad1699..8fa3517a 100644
--- a/backend/internal/server/middleware/api_key_auth.go
+++ b/backend/internal/server/middleware/api_key_auth.go
@@ -125,6 +125,7 @@ func apiKeyAuthWithSubscription(apiKeyService *service.APIKeyService, subscripti
})
c.Set(string(ContextKeyUserRole), apiKey.User.Role)
setGroupContext(c, apiKey.Group)
+ _ = apiKeyService.TouchLastUsed(c.Request.Context(), apiKey.ID)
c.Next()
return
}
@@ -184,6 +185,7 @@ func apiKeyAuthWithSubscription(apiKeyService *service.APIKeyService, subscripti
})
c.Set(string(ContextKeyUserRole), apiKey.User.Role)
setGroupContext(c, apiKey.Group)
+ _ = apiKeyService.TouchLastUsed(c.Request.Context(), apiKey.ID)
c.Next()
}
diff --git a/backend/internal/server/middleware/api_key_auth_google.go b/backend/internal/server/middleware/api_key_auth_google.go
index 38fbe38b..9da1b1c6 100644
--- a/backend/internal/server/middleware/api_key_auth_google.go
+++ b/backend/internal/server/middleware/api_key_auth_google.go
@@ -64,6 +64,7 @@ func APIKeyAuthWithSubscriptionGoogle(apiKeyService *service.APIKeyService, subs
})
c.Set(string(ContextKeyUserRole), apiKey.User.Role)
setGroupContext(c, apiKey.Group)
+ _ = apiKeyService.TouchLastUsed(c.Request.Context(), apiKey.ID)
c.Next()
return
}
@@ -104,6 +105,7 @@ func APIKeyAuthWithSubscriptionGoogle(apiKeyService *service.APIKeyService, subs
})
c.Set(string(ContextKeyUserRole), apiKey.User.Role)
setGroupContext(c, apiKey.Group)
+ _ = apiKeyService.TouchLastUsed(c.Request.Context(), apiKey.ID)
c.Next()
}
}
diff --git a/backend/internal/server/middleware/api_key_auth_google_test.go b/backend/internal/server/middleware/api_key_auth_google_test.go
index 38b93cb2..e4e0e253 100644
--- a/backend/internal/server/middleware/api_key_auth_google_test.go
+++ b/backend/internal/server/middleware/api_key_auth_google_test.go
@@ -7,6 +7,7 @@ import (
"net/http"
"net/http/httptest"
"testing"
+ "time"
"github.com/Wei-Shaw/sub2api/internal/config"
"github.com/Wei-Shaw/sub2api/internal/pkg/ctxkey"
@@ -18,7 +19,8 @@ import (
)
type fakeAPIKeyRepo struct {
- getByKey func(ctx context.Context, key string) (*service.APIKey, error)
+ getByKey func(ctx context.Context, key string) (*service.APIKey, error)
+ updateLastUsed func(ctx context.Context, id int64, usedAt time.Time) error
}
func (f fakeAPIKeyRepo) Create(ctx context.Context, key *service.APIKey) error {
@@ -78,6 +80,12 @@ func (f fakeAPIKeyRepo) ListKeysByGroupID(ctx context.Context, groupID int64) ([
func (f fakeAPIKeyRepo) IncrementQuotaUsed(ctx context.Context, id int64, amount float64) (float64, error) {
return 0, errors.New("not implemented")
}
+func (f fakeAPIKeyRepo) UpdateLastUsed(ctx context.Context, id int64, usedAt time.Time) error {
+ if f.updateLastUsed != nil {
+ return f.updateLastUsed(ctx, id, usedAt)
+ }
+ return nil
+}
type googleErrorResponse struct {
Error struct {
@@ -356,3 +364,144 @@ func TestApiKeyAuthWithSubscriptionGoogle_InsufficientBalance(t *testing.T) {
require.Equal(t, "Insufficient account balance", resp.Error.Message)
require.Equal(t, "PERMISSION_DENIED", resp.Error.Status)
}
+
+func TestApiKeyAuthWithSubscriptionGoogle_TouchesLastUsedOnSuccess(t *testing.T) {
+ gin.SetMode(gin.TestMode)
+
+ user := &service.User{
+ ID: 11,
+ Role: service.RoleUser,
+ Status: service.StatusActive,
+ Balance: 10,
+ Concurrency: 3,
+ }
+ apiKey := &service.APIKey{
+ ID: 201,
+ UserID: user.ID,
+ Key: "google-touch-ok",
+ Status: service.StatusActive,
+ User: user,
+ }
+
+ var touchedID int64
+ var touchedAt time.Time
+ r := gin.New()
+ apiKeyService := newTestAPIKeyService(fakeAPIKeyRepo{
+ getByKey: func(ctx context.Context, key string) (*service.APIKey, error) {
+ if key != apiKey.Key {
+ return nil, service.ErrAPIKeyNotFound
+ }
+ clone := *apiKey
+ return &clone, nil
+ },
+ updateLastUsed: func(ctx context.Context, id int64, usedAt time.Time) error {
+ touchedID = id
+ touchedAt = usedAt
+ return nil
+ },
+ })
+ cfg := &config.Config{RunMode: config.RunModeSimple}
+ r.Use(APIKeyAuthWithSubscriptionGoogle(apiKeyService, nil, cfg))
+ r.GET("/v1beta/test", func(c *gin.Context) { c.JSON(200, gin.H{"ok": true}) })
+
+ req := httptest.NewRequest(http.MethodGet, "/v1beta/test", nil)
+ req.Header.Set("x-goog-api-key", apiKey.Key)
+ rec := httptest.NewRecorder()
+ r.ServeHTTP(rec, req)
+
+ require.Equal(t, http.StatusOK, rec.Code)
+ require.Equal(t, apiKey.ID, touchedID)
+ require.False(t, touchedAt.IsZero())
+}
+
+func TestApiKeyAuthWithSubscriptionGoogle_TouchFailureDoesNotBlock(t *testing.T) {
+ gin.SetMode(gin.TestMode)
+
+ user := &service.User{
+ ID: 12,
+ Role: service.RoleUser,
+ Status: service.StatusActive,
+ Balance: 10,
+ Concurrency: 3,
+ }
+ apiKey := &service.APIKey{
+ ID: 202,
+ UserID: user.ID,
+ Key: "google-touch-fail",
+ Status: service.StatusActive,
+ User: user,
+ }
+
+ touchCalls := 0
+ r := gin.New()
+ apiKeyService := newTestAPIKeyService(fakeAPIKeyRepo{
+ getByKey: func(ctx context.Context, key string) (*service.APIKey, error) {
+ if key != apiKey.Key {
+ return nil, service.ErrAPIKeyNotFound
+ }
+ clone := *apiKey
+ return &clone, nil
+ },
+ updateLastUsed: func(ctx context.Context, id int64, usedAt time.Time) error {
+ touchCalls++
+ return errors.New("write failed")
+ },
+ })
+ cfg := &config.Config{RunMode: config.RunModeSimple}
+ r.Use(APIKeyAuthWithSubscriptionGoogle(apiKeyService, nil, cfg))
+ r.GET("/v1beta/test", func(c *gin.Context) { c.JSON(200, gin.H{"ok": true}) })
+
+ req := httptest.NewRequest(http.MethodGet, "/v1beta/test", nil)
+ req.Header.Set("x-goog-api-key", apiKey.Key)
+ rec := httptest.NewRecorder()
+ r.ServeHTTP(rec, req)
+
+ require.Equal(t, http.StatusOK, rec.Code)
+ require.Equal(t, 1, touchCalls)
+}
+
+func TestApiKeyAuthWithSubscriptionGoogle_TouchesLastUsedInStandardMode(t *testing.T) {
+ gin.SetMode(gin.TestMode)
+
+ user := &service.User{
+ ID: 13,
+ Role: service.RoleUser,
+ Status: service.StatusActive,
+ Balance: 10,
+ Concurrency: 3,
+ }
+ apiKey := &service.APIKey{
+ ID: 203,
+ UserID: user.ID,
+ Key: "google-touch-standard",
+ Status: service.StatusActive,
+ User: user,
+ }
+
+ touchCalls := 0
+ r := gin.New()
+ apiKeyService := newTestAPIKeyService(fakeAPIKeyRepo{
+ getByKey: func(ctx context.Context, key string) (*service.APIKey, error) {
+ if key != apiKey.Key {
+ return nil, service.ErrAPIKeyNotFound
+ }
+ clone := *apiKey
+ return &clone, nil
+ },
+ updateLastUsed: func(ctx context.Context, id int64, usedAt time.Time) error {
+ touchCalls++
+ return nil
+ },
+ })
+ cfg := &config.Config{RunMode: config.RunModeStandard}
+ r.Use(APIKeyAuthWithSubscriptionGoogle(apiKeyService, nil, cfg))
+ r.GET("/v1beta/test", func(c *gin.Context) { c.JSON(200, gin.H{"ok": true}) })
+
+ req := httptest.NewRequest(http.MethodGet, "/v1beta/test", nil)
+ req.Header.Set("Authorization", "Bearer "+apiKey.Key)
+ rec := httptest.NewRecorder()
+ r.ServeHTTP(rec, req)
+
+ require.Equal(t, http.StatusOK, rec.Code)
+ require.Equal(t, 1, touchCalls)
+}
diff --git a/backend/internal/server/middleware/api_key_auth_test.go b/backend/internal/server/middleware/api_key_auth_test.go
index f3a6f076..0d331761 100644
--- a/backend/internal/server/middleware/api_key_auth_test.go
+++ b/backend/internal/server/middleware/api_key_auth_test.go
@@ -351,6 +351,147 @@ func TestAPIKeyAuthIPRestrictionDoesNotTrustSpoofedForwardHeaders(t *testing.T)
require.Contains(t, w.Body.String(), "ACCESS_DENIED")
}
+func TestAPIKeyAuthTouchesLastUsedOnSuccess(t *testing.T) {
+ gin.SetMode(gin.TestMode)
+
+ user := &service.User{
+ ID: 7,
+ Role: service.RoleUser,
+ Status: service.StatusActive,
+ Balance: 10,
+ Concurrency: 3,
+ }
+ apiKey := &service.APIKey{
+ ID: 100,
+ UserID: user.ID,
+ Key: "touch-ok",
+ Status: service.StatusActive,
+ User: user,
+ }
+
+ var touchedID int64
+ var touchedAt time.Time
+ apiKeyRepo := &stubApiKeyRepo{
+ getByKey: func(ctx context.Context, key string) (*service.APIKey, error) {
+ if key != apiKey.Key {
+ return nil, service.ErrAPIKeyNotFound
+ }
+ clone := *apiKey
+ return &clone, nil
+ },
+ updateLastUsed: func(ctx context.Context, id int64, usedAt time.Time) error {
+ touchedID = id
+ touchedAt = usedAt
+ return nil
+ },
+ }
+
+ cfg := &config.Config{RunMode: config.RunModeSimple}
+ apiKeyService := service.NewAPIKeyService(apiKeyRepo, nil, nil, nil, nil, nil, cfg)
+ router := newAuthTestRouter(apiKeyService, nil, cfg)
+
+ w := httptest.NewRecorder()
+ req := httptest.NewRequest(http.MethodGet, "/t", nil)
+ req.Header.Set("x-api-key", apiKey.Key)
+ router.ServeHTTP(w, req)
+
+ require.Equal(t, http.StatusOK, w.Code)
+ require.Equal(t, apiKey.ID, touchedID)
+ require.False(t, touchedAt.IsZero(), "expected touch timestamp")
+}
+
+func TestAPIKeyAuthTouchLastUsedFailureDoesNotBlock(t *testing.T) {
+ gin.SetMode(gin.TestMode)
+
+ user := &service.User{
+ ID: 8,
+ Role: service.RoleUser,
+ Status: service.StatusActive,
+ Balance: 10,
+ Concurrency: 3,
+ }
+ apiKey := &service.APIKey{
+ ID: 101,
+ UserID: user.ID,
+ Key: "touch-fail",
+ Status: service.StatusActive,
+ User: user,
+ }
+
+ touchCalls := 0
+ apiKeyRepo := &stubApiKeyRepo{
+ getByKey: func(ctx context.Context, key string) (*service.APIKey, error) {
+ if key != apiKey.Key {
+ return nil, service.ErrAPIKeyNotFound
+ }
+ clone := *apiKey
+ return &clone, nil
+ },
+ updateLastUsed: func(ctx context.Context, id int64, usedAt time.Time) error {
+ touchCalls++
+ return errors.New("db unavailable")
+ },
+ }
+
+ cfg := &config.Config{RunMode: config.RunModeSimple}
+ apiKeyService := service.NewAPIKeyService(apiKeyRepo, nil, nil, nil, nil, nil, cfg)
+ router := newAuthTestRouter(apiKeyService, nil, cfg)
+
+ w := httptest.NewRecorder()
+ req := httptest.NewRequest(http.MethodGet, "/t", nil)
+ req.Header.Set("x-api-key", apiKey.Key)
+ router.ServeHTTP(w, req)
+
+ require.Equal(t, http.StatusOK, w.Code, "touch failure should not block request")
+ require.Equal(t, 1, touchCalls)
+}
+
+func TestAPIKeyAuthTouchesLastUsedInStandardMode(t *testing.T) {
+ gin.SetMode(gin.TestMode)
+
+ user := &service.User{
+ ID: 9,
+ Role: service.RoleUser,
+ Status: service.StatusActive,
+ Balance: 10,
+ Concurrency: 3,
+ }
+ apiKey := &service.APIKey{
+ ID: 102,
+ UserID: user.ID,
+ Key: "touch-standard",
+ Status: service.StatusActive,
+ User: user,
+ }
+
+ touchCalls := 0
+ apiKeyRepo := &stubApiKeyRepo{
+ getByKey: func(ctx context.Context, key string) (*service.APIKey, error) {
+ if key != apiKey.Key {
+ return nil, service.ErrAPIKeyNotFound
+ }
+ clone := *apiKey
+ return &clone, nil
+ },
+ updateLastUsed: func(ctx context.Context, id int64, usedAt time.Time) error {
+ touchCalls++
+ return nil
+ },
+ }
+
+ cfg := &config.Config{RunMode: config.RunModeStandard}
+ apiKeyService := service.NewAPIKeyService(apiKeyRepo, nil, nil, nil, nil, nil, cfg)
+ router := newAuthTestRouter(apiKeyService, nil, cfg)
+
+ w := httptest.NewRecorder()
+ req := httptest.NewRequest(http.MethodGet, "/t", nil)
+ req.Header.Set("x-api-key", apiKey.Key)
+ router.ServeHTTP(w, req)
+
+ require.Equal(t, http.StatusOK, w.Code)
+ require.Equal(t, 1, touchCalls)
+}
+
func newAuthTestRouter(apiKeyService *service.APIKeyService, subscriptionService *service.SubscriptionService, cfg *config.Config) *gin.Engine {
router := gin.New()
router.Use(gin.HandlerFunc(NewAPIKeyAuthMiddleware(apiKeyService, subscriptionService, cfg)))
@@ -361,7 +502,8 @@ func newAuthTestRouter(apiKeyService *service.APIKeyService, subscriptionService
}
type stubApiKeyRepo struct {
- getByKey func(ctx context.Context, key string) (*service.APIKey, error)
+ getByKey func(ctx context.Context, key string) (*service.APIKey, error)
+ updateLastUsed func(ctx context.Context, id int64, usedAt time.Time) error
}
func (r *stubApiKeyRepo) Create(ctx context.Context, key *service.APIKey) error {
@@ -439,6 +581,13 @@ func (r *stubApiKeyRepo) IncrementQuotaUsed(ctx context.Context, id int64, amoun
return 0, errors.New("not implemented")
}
+func (r *stubApiKeyRepo) UpdateLastUsed(ctx context.Context, id int64, usedAt time.Time) error {
+ if r.updateLastUsed != nil {
+ return r.updateLastUsed(ctx, id, usedAt)
+ }
+ return nil
+}
+
type stubUserSubscriptionRepo struct {
getActive func(ctx context.Context, userID, groupID int64) (*service.UserSubscription, error)
updateStatus func(ctx context.Context, subscriptionID int64, status string) error
diff --git a/backend/internal/service/api_key.go b/backend/internal/service/api_key.go
index d66059dd..fe1b3a5d 100644
--- a/backend/internal/service/api_key.go
+++ b/backend/internal/service/api_key.go
@@ -19,6 +19,7 @@ type APIKey struct {
Status string
IPWhitelist []string
IPBlacklist []string
+ LastUsedAt *time.Time
CreatedAt time.Time
UpdatedAt time.Time
User *User
diff --git a/backend/internal/service/api_key_service.go b/backend/internal/service/api_key_service.go
index cb1dd60a..c88906e3 100644
--- a/backend/internal/service/api_key_service.go
+++ b/backend/internal/service/api_key_service.go
@@ -5,6 +5,8 @@ import (
"crypto/rand"
"encoding/hex"
"fmt"
+ "strconv"
+ "sync"
"time"
"github.com/Wei-Shaw/sub2api/internal/config"
@@ -32,6 +34,7 @@ var (
const (
apiKeyMaxErrorsPerHour = 20
+ apiKeyLastUsedMinTouch = 30 * time.Second
)
type APIKeyRepository interface {
@@ -58,6 +61,7 @@ type APIKeyRepository interface {
// Quota methods
IncrementQuotaUsed(ctx context.Context, id int64, amount float64) (float64, error)
+ UpdateLastUsed(ctx context.Context, id int64, usedAt time.Time) error
}
// APIKeyCache defines cache operations for API key service
@@ -125,6 +129,8 @@ type APIKeyService struct {
authCacheL1 *ristretto.Cache
authCfg apiKeyAuthCacheConfig
authGroup singleflight.Group
+ lastUsedTouchL1 sync.Map // keyID -> time.Time
+ lastUsedTouchSF singleflight.Group
}
// NewAPIKeyService 创建API Key服务实例
@@ -527,6 +533,7 @@ func (s *APIKeyService) Delete(ctx context.Context, id int64, userID int64) erro
if err := s.apiKeyRepo.Delete(ctx, id); err != nil {
return fmt.Errorf("delete api key: %w", err)
}
+ s.lastUsedTouchL1.Delete(id)
return nil
}
@@ -558,6 +565,37 @@ func (s *APIKeyService) ValidateKey(ctx context.Context, key string) (*APIKey, *
return apiKey, user, nil
}
+// TouchLastUsed 通过防抖更新 api_keys.last_used_at,减少高频写放大。
+// 该操作为尽力而为,不应阻塞主请求链路。
+func (s *APIKeyService) TouchLastUsed(ctx context.Context, keyID int64) error {
+ if keyID <= 0 {
+ return nil
+ }
+
+ now := time.Now()
+ if v, ok := s.lastUsedTouchL1.Load(keyID); ok {
+ if last, ok := v.(time.Time); ok && now.Sub(last) < apiKeyLastUsedMinTouch {
+ return nil
+ }
+ }
+
+ _, err, _ := s.lastUsedTouchSF.Do(strconv.FormatInt(keyID, 10), func() (any, error) {
+ latest := time.Now()
+ if v, ok := s.lastUsedTouchL1.Load(keyID); ok {
+ if last, ok := v.(time.Time); ok && latest.Sub(last) < apiKeyLastUsedMinTouch {
+ return nil, nil
+ }
+ }
+
+ if err := s.apiKeyRepo.UpdateLastUsed(ctx, keyID, latest); err != nil {
+ return nil, fmt.Errorf("touch api key last used: %w", err)
+ }
+ s.lastUsedTouchL1.Store(keyID, latest)
+ return nil, nil
+ })
+ return err
+}
+
// IncrementUsage 增加API Key使用次数(可选:用于统计)
func (s *APIKeyService) IncrementUsage(ctx context.Context, keyID int64) error {
// 使用Redis计数器
diff --git a/backend/internal/service/api_key_service_cache_test.go b/backend/internal/service/api_key_service_cache_test.go
index 14ecbf39..2357813b 100644
--- a/backend/internal/service/api_key_service_cache_test.go
+++ b/backend/internal/service/api_key_service_cache_test.go
@@ -103,6 +103,10 @@ func (s *authRepoStub) IncrementQuotaUsed(ctx context.Context, id int64, amount
panic("unexpected IncrementQuotaUsed call")
}
+func (s *authRepoStub) UpdateLastUsed(ctx context.Context, id int64, usedAt time.Time) error {
+ panic("unexpected UpdateLastUsed call")
+}
+
type authCacheStub struct {
getAuthCache func(ctx context.Context, key string) (*APIKeyAuthCacheEntry, error)
setAuthKeys []string
diff --git a/backend/internal/service/api_key_service_delete_test.go b/backend/internal/service/api_key_service_delete_test.go
index d4d12144..79757808 100644
--- a/backend/internal/service/api_key_service_delete_test.go
+++ b/backend/internal/service/api_key_service_delete_test.go
@@ -24,10 +24,13 @@ import (
// - deleteErr: 模拟 Delete 返回的错误
// - deletedIDs: 记录被调用删除的 API Key ID,用于断言验证
type apiKeyRepoStub struct {
- apiKey *APIKey // GetKeyAndOwnerID 的返回值
- getByIDErr error // GetKeyAndOwnerID 的错误返回值
- deleteErr error // Delete 的错误返回值
- deletedIDs []int64 // 记录已删除的 API Key ID 列表
+ apiKey *APIKey // GetKeyAndOwnerID 的返回值
+ getByIDErr error // GetKeyAndOwnerID 的错误返回值
+ deleteErr error // Delete 的错误返回值
+ deletedIDs []int64 // 记录已删除的 API Key ID 列表
+ updateLastUsed func(ctx context.Context, id int64, usedAt time.Time) error
+ touchedIDs []int64
+ touchedUsedAts []time.Time
}
// 以下方法在本测试中不应被调用,使用 panic 确保测试失败时能快速定位问题
@@ -122,6 +125,15 @@ func (s *apiKeyRepoStub) IncrementQuotaUsed(ctx context.Context, id int64, amoun
panic("unexpected IncrementQuotaUsed call")
}
+func (s *apiKeyRepoStub) UpdateLastUsed(ctx context.Context, id int64, usedAt time.Time) error {
+ s.touchedIDs = append(s.touchedIDs, id)
+ s.touchedUsedAts = append(s.touchedUsedAts, usedAt)
+ if s.updateLastUsed != nil {
+ return s.updateLastUsed(ctx, id, usedAt)
+ }
+ return nil
+}
+
// apiKeyCacheStub 是 APIKeyCache 接口的测试桩实现。
// 用于验证删除操作时缓存清理逻辑是否被正确调用。
//
@@ -214,12 +226,15 @@ func TestApiKeyService_Delete_Success(t *testing.T) {
}
cache := &apiKeyCacheStub{}
svc := &APIKeyService{apiKeyRepo: repo, cache: cache}
+ svc.lastUsedTouchL1.Store(int64(42), time.Now())
err := svc.Delete(context.Background(), 42, 7) // API Key ID=42, 调用者 userID=7
require.NoError(t, err)
require.Equal(t, []int64{42}, repo.deletedIDs) // 验证正确的 API Key 被删除
require.Equal(t, []int64{7}, cache.invalidated) // 验证所有者的缓存被清除
require.Equal(t, []string{svc.authCacheKey("k")}, cache.deleteAuthKeys)
+ _, exists := svc.lastUsedTouchL1.Load(int64(42))
+ require.False(t, exists, "delete should clear touch debounce cache")
}
// TestApiKeyService_Delete_NotFound 测试删除不存在的 API Key 时返回正确的错误。
diff --git a/backend/internal/service/api_key_service_touch_last_used_test.go b/backend/internal/service/api_key_service_touch_last_used_test.go
new file mode 100644
index 00000000..5c750ec5
--- /dev/null
+++ b/backend/internal/service/api_key_service_touch_last_used_test.go
@@ -0,0 +1,141 @@
+//go:build unit
+
+package service
+
+import (
+ "context"
+ "errors"
+ "sync"
+ "testing"
+ "time"
+
+ "github.com/stretchr/testify/require"
+)
+
+func TestAPIKeyService_TouchLastUsed_InvalidKeyID(t *testing.T) {
+ repo := &apiKeyRepoStub{
+ updateLastUsed: func(ctx context.Context, id int64, usedAt time.Time) error {
+ return errors.New("should not be called")
+ },
+ }
+ svc := &APIKeyService{apiKeyRepo: repo}
+
+ require.NoError(t, svc.TouchLastUsed(context.Background(), 0))
+ require.NoError(t, svc.TouchLastUsed(context.Background(), -1))
+ require.Empty(t, repo.touchedIDs)
+}
+
+func TestAPIKeyService_TouchLastUsed_FirstTouchSucceeds(t *testing.T) {
+ repo := &apiKeyRepoStub{}
+ svc := &APIKeyService{apiKeyRepo: repo}
+
+ err := svc.TouchLastUsed(context.Background(), 123)
+ require.NoError(t, err)
+ require.Equal(t, []int64{123}, repo.touchedIDs)
+ require.Len(t, repo.touchedUsedAts, 1)
+ require.False(t, repo.touchedUsedAts[0].IsZero())
+
+ cached, ok := svc.lastUsedTouchL1.Load(int64(123))
+ require.True(t, ok, "successful touch should update debounce cache")
+ _, isTime := cached.(time.Time)
+ require.True(t, isTime)
+}
+
+func TestAPIKeyService_TouchLastUsed_DebouncedWithinWindow(t *testing.T) {
+ repo := &apiKeyRepoStub{}
+ svc := &APIKeyService{apiKeyRepo: repo}
+
+ require.NoError(t, svc.TouchLastUsed(context.Background(), 123))
+ require.NoError(t, svc.TouchLastUsed(context.Background(), 123))
+
+ require.Equal(t, []int64{123}, repo.touchedIDs, "second touch within debounce window should not hit repository")
+}
+
+func TestAPIKeyService_TouchLastUsed_ExpiredDebounceTouchesAgain(t *testing.T) {
+ repo := &apiKeyRepoStub{}
+ svc := &APIKeyService{apiKeyRepo: repo}
+
+ require.NoError(t, svc.TouchLastUsed(context.Background(), 123))
+
+ // 强制将 debounce 时间回拨到窗口之外,触发第二次写库。
+ svc.lastUsedTouchL1.Store(int64(123), time.Now().Add(-apiKeyLastUsedMinTouch-time.Second))
+
+ require.NoError(t, svc.TouchLastUsed(context.Background(), 123))
+ require.Len(t, repo.touchedIDs, 2)
+ require.Equal(t, int64(123), repo.touchedIDs[0])
+ require.Equal(t, int64(123), repo.touchedIDs[1])
+}
+
+func TestAPIKeyService_TouchLastUsed_RepoError(t *testing.T) {
+ repo := &apiKeyRepoStub{
+ updateLastUsed: func(ctx context.Context, id int64, usedAt time.Time) error {
+ return errors.New("db write failed")
+ },
+ }
+ svc := &APIKeyService{apiKeyRepo: repo}
+
+ err := svc.TouchLastUsed(context.Background(), 123)
+ require.Error(t, err)
+ require.ErrorContains(t, err, "touch api key last used")
+ require.Equal(t, []int64{123}, repo.touchedIDs)
+
+ _, ok := svc.lastUsedTouchL1.Load(int64(123))
+ require.False(t, ok, "failed touch should not update debounce cache")
+}
+
+type touchSingleflightRepo struct {
+ *apiKeyRepoStub
+ mu sync.Mutex
+ calls int
+ blockCh chan struct{}
+}
+
+func (r *touchSingleflightRepo) UpdateLastUsed(ctx context.Context, id int64, usedAt time.Time) error {
+ r.mu.Lock()
+ r.calls++
+ r.mu.Unlock()
+ <-r.blockCh
+ return nil
+}
+
+func TestAPIKeyService_TouchLastUsed_ConcurrentFirstTouchDeduplicated(t *testing.T) {
+ repo := &touchSingleflightRepo{
+ apiKeyRepoStub: &apiKeyRepoStub{},
+ blockCh: make(chan struct{}),
+ }
+ svc := &APIKeyService{apiKeyRepo: repo}
+
+ const workers = 20
+ startCh := make(chan struct{})
+ errCh := make(chan error, workers)
+ var wg sync.WaitGroup
+
+ for i := 0; i < workers; i++ {
+ wg.Add(1)
+ go func() {
+ defer wg.Done()
+ <-startCh
+ errCh <- svc.TouchLastUsed(context.Background(), 321)
+ }()
+ }
+
+ close(startCh)
+
+ require.Eventually(t, func() bool {
+ repo.mu.Lock()
+ defer repo.mu.Unlock()
+ return repo.calls >= 1
+ }, time.Second, 10*time.Millisecond)
+
+ close(repo.blockCh)
+ wg.Wait()
+ close(errCh)
+
+ for err := range errCh {
+ require.NoError(t, err)
+ }
+
+ repo.mu.Lock()
+ defer repo.mu.Unlock()
+ require.Equal(t, 1, repo.calls, "并发首次 touch 只应写库一次")
+}
diff --git a/backend/migrations/056_add_api_key_last_used_at.sql b/backend/migrations/056_add_api_key_last_used_at.sql
new file mode 100644
index 00000000..224d4193
--- /dev/null
+++ b/backend/migrations/056_add_api_key_last_used_at.sql
@@ -0,0 +1,9 @@
+-- 迁移:为 api_keys 增加 last_used_at 字段,用于记录 API Key 最近使用时间
+-- 幂等执行:可重复运行
+
+ALTER TABLE api_keys
+ADD COLUMN IF NOT EXISTS last_used_at TIMESTAMPTZ;
+
+CREATE INDEX IF NOT EXISTS idx_api_keys_last_used_at
+ON api_keys(last_used_at)
+WHERE deleted_at IS NULL;
diff --git a/frontend/src/i18n/locales/en.ts b/frontend/src/i18n/locales/en.ts
index 52de23f4..3c415989 100644
--- a/frontend/src/i18n/locales/en.ts
+++ b/frontend/src/i18n/locales/en.ts
@@ -478,6 +478,7 @@ export default {
today: 'Today',
total: 'Total',
quota: 'Quota',
+ lastUsedAt: 'Last Used',
useKey: 'Use Key',
useKeyModal: {
title: 'Use API Key',
diff --git a/frontend/src/i18n/locales/zh.ts b/frontend/src/i18n/locales/zh.ts
index 1e5be7ac..770f9ca9 100644
--- a/frontend/src/i18n/locales/zh.ts
+++ b/frontend/src/i18n/locales/zh.ts
@@ -479,6 +479,7 @@ export default {
today: '今日',
total: '累计',
quota: '额度',
+ lastUsedAt: '上次使用时间',
useKey: '使用密钥',
useKeyModal: {
title: '使用 API 密钥',
diff --git a/frontend/src/views/user/KeysView.vue b/frontend/src/views/user/KeysView.vue
index 80a64f2e..6beb993b 100644
--- a/frontend/src/views/user/KeysView.vue
+++ b/frontend/src/views/user/KeysView.vue
@@ -159,6 +159,13 @@
+
+
+ {{ formatDateTime(value) }}
+
+ -
+
+
{{ formatDateTime(value) }}
@@ -738,6 +745,7 @@ const columns = computed(() => [
{ 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: 'last_used_at', label: t('keys.lastUsedAt'), sortable: true },
{ key: 'created_at', label: t('keys.created'), sortable: true },
{ key: 'actions', label: t('common.actions'), sortable: false }
])