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
index d63baba91a..a9ccf94524 100644
--- a/backend/internal/handler/dto/api_key_mapper_last_used_test.go
+++ b/backend/internal/handler/dto/api_key_mapper_last_used_test.go
@@ -10,6 +10,7 @@ import (
func TestAPIKeyFromService_MapsLastUsedAt(t *testing.T) {
lastUsed := time.Now().UTC().Truncate(time.Second)
+ lastUsedIP := "203.0.113.10"
src := &service.APIKey{
ID: 1,
UserID: 2,
@@ -17,6 +18,7 @@ func TestAPIKeyFromService_MapsLastUsedAt(t *testing.T) {
Name: "Mapper",
Status: service.StatusActive,
LastUsedAt: &lastUsed,
+ LastUsedIP: &lastUsedIP,
CurrentConcurrency: 3,
}
@@ -24,6 +26,8 @@ func TestAPIKeyFromService_MapsLastUsedAt(t *testing.T) {
require.NotNil(t, out)
require.NotNil(t, out.LastUsedAt)
require.WithinDuration(t, lastUsed, *out.LastUsedAt, time.Second)
+ require.NotNil(t, out.LastUsedIP)
+ require.Equal(t, lastUsedIP, *out.LastUsedIP)
require.Equal(t, 3, out.CurrentConcurrency)
}
@@ -39,4 +43,5 @@ func TestAPIKeyFromService_MapsNilLastUsedAt(t *testing.T) {
out := APIKeyFromService(src)
require.NotNil(t, out)
require.Nil(t, out.LastUsedAt)
+ require.Nil(t, out.LastUsedIP)
}
diff --git a/backend/internal/handler/dto/mappers.go b/backend/internal/handler/dto/mappers.go
index 03e4c97309..00e1ea829c 100644
--- a/backend/internal/handler/dto/mappers.go
+++ b/backend/internal/handler/dto/mappers.go
@@ -89,6 +89,7 @@ func APIKeyFromService(k *service.APIKey) *APIKey {
IPWhitelist: k.IPWhitelist,
IPBlacklist: k.IPBlacklist,
LastUsedAt: k.LastUsedAt,
+ LastUsedIP: k.LastUsedIP,
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 286d2d5459..3aa8890d62 100644
--- a/backend/internal/handler/dto/types.go
+++ b/backend/internal/handler/dto/types.go
@@ -59,6 +59,7 @@ type APIKey struct {
IPWhitelist []string `json:"ip_whitelist"`
IPBlacklist []string `json:"ip_blacklist"`
LastUsedAt *time.Time `json:"last_used_at"`
+ LastUsedIP *string `json:"last_used_ip"`
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 877fc90353..638eec52f1 100644
--- a/backend/internal/repository/api_key_repo.go
+++ b/backend/internal/repository/api_key_repo.go
@@ -14,9 +14,11 @@ import (
"github.com/Wei-Shaw/sub2api/ent/schema/mixins"
"github.com/Wei-Shaw/sub2api/ent/user"
"github.com/Wei-Shaw/sub2api/internal/service"
+ "github.com/lib/pq"
"github.com/Wei-Shaw/sub2api/internal/pkg/pagination"
+ "entgo.io/ent/dialect"
entsql "entgo.io/ent/dialect/sql"
)
@@ -431,10 +433,100 @@ func (r *apiKeyRepository) ListByUserID(ctx context.Context, userID int64, param
for i := range keys {
outKeys = append(outKeys, *apiKeyEntityToService(keys[i]))
}
+ if err := r.attachLastUsedIPs(ctx, outKeys); err != nil {
+ return nil, nil, err
+ }
return outKeys, paginationResultFromTotal(int64(total), params), nil
}
+func (r *apiKeyRepository) attachLastUsedIPs(ctx context.Context, keys []service.APIKey) error {
+ if len(keys) == 0 || r.sql == nil {
+ return nil
+ }
+
+ apiKeyIDs := make([]int64, 0, len(keys))
+ for i := range keys {
+ apiKeyIDs = append(apiKeyIDs, keys[i].ID)
+ }
+
+ lastUsedIPs, err := r.latestUsageLogIPs(ctx, apiKeyIDs)
+ if err != nil {
+ return err
+ }
+ for i := range keys {
+ if ip, ok := lastUsedIPs[keys[i].ID]; ok {
+ keys[i].LastUsedIP = &ip
+ }
+ }
+ return nil
+}
+
+func (r *apiKeyRepository) latestUsageLogIPs(ctx context.Context, apiKeyIDs []int64) (result map[int64]string, err error) {
+ if len(apiKeyIDs) == 0 || r.sql == nil {
+ return map[int64]string{}, nil
+ }
+
+ query, args := latestUsageLogIPsQuery(apiKeyIDs, r.client.Driver().Dialect())
+ rows, err := r.sql.QueryContext(ctx, query, args...)
+ if err != nil {
+ return nil, err
+ }
+ defer func() {
+ if closeErr := rows.Close(); closeErr != nil && err == nil {
+ err = closeErr
+ }
+ }()
+
+ out := make(map[int64]string, len(apiKeyIDs))
+ for rows.Next() {
+ var apiKeyID int64
+ var ipAddress string
+ if err := rows.Scan(&apiKeyID, &ipAddress); err != nil {
+ return nil, err
+ }
+ out[apiKeyID] = ipAddress
+ }
+ if err := rows.Err(); err != nil {
+ return nil, err
+ }
+ return out, nil
+}
+
+func latestUsageLogIPsQuery(apiKeyIDs []int64, dialectName string) (string, []any) {
+ if dialectName == dialect.Postgres {
+ return `
+ SELECT api_key_id, ip_address
+ FROM (
+ SELECT api_key_id, ip_address,
+ ROW_NUMBER() OVER (PARTITION BY api_key_id ORDER BY created_at DESC, id DESC) AS rn
+ FROM usage_logs
+ WHERE api_key_id = ANY($1::bigint[])
+ AND ip_address IS NOT NULL
+ AND ip_address <> ''
+ ) ranked
+ WHERE rn = 1`, []any{pq.Array(apiKeyIDs)}
+ }
+
+ placeholders := make([]string, len(apiKeyIDs))
+ args := make([]any, len(apiKeyIDs))
+ for i, id := range apiKeyIDs {
+ placeholders[i] = "?"
+ args[i] = id
+ }
+ return fmt.Sprintf(`
+ SELECT api_key_id, ip_address
+ FROM (
+ SELECT api_key_id, ip_address,
+ ROW_NUMBER() OVER (PARTITION BY api_key_id ORDER BY created_at DESC, id DESC) AS rn
+ FROM usage_logs
+ WHERE api_key_id IN (%s)
+ AND ip_address IS NOT NULL
+ AND ip_address <> ''
+ ) ranked
+ WHERE rn = 1`, strings.Join(placeholders, ", ")), args
+}
+
func (r *apiKeyRepository) VerifyOwnership(ctx context.Context, userID int64, apiKeyIDs []int64) ([]int64, error) {
if len(apiKeyIDs) == 0 {
return []int64{}, nil
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
index 7c6e2850e8..839eda7f75 100644
--- a/backend/internal/repository/api_key_repo_last_used_unit_test.go
+++ b/backend/internal/repository/api_key_repo_last_used_unit_test.go
@@ -8,6 +8,7 @@ import (
dbent "github.com/Wei-Shaw/sub2api/ent"
"github.com/Wei-Shaw/sub2api/ent/enttest"
+ "github.com/Wei-Shaw/sub2api/internal/pkg/pagination"
"github.com/Wei-Shaw/sub2api/internal/service"
"github.com/stretchr/testify/require"
@@ -30,7 +31,7 @@ func newAPIKeyRepoSQLite(t *testing.T) (*apiKeyRepository, *dbent.Client) {
client := enttest.NewClient(t, enttest.WithOptions(dbent.Driver(drv)))
t.Cleanup(func() { _ = client.Close() })
- return &apiKeyRepository{client: client}, client
+ return &apiKeyRepository{client: client, sql: db}, client
}
func mustCreateAPIKeyRepoUser(t *testing.T, ctx context.Context, client *dbent.Client, email string) *service.User {
@@ -45,6 +46,85 @@ func mustCreateAPIKeyRepoUser(t *testing.T, ctx context.Context, client *dbent.C
return userEntityToService(u)
}
+func mustCreateAPIKeyRepoAccount(t *testing.T, ctx context.Context, client *dbent.Client, name string) int64 {
+ t.Helper()
+ a, err := client.Account.Create().
+ SetName(name).
+ SetPlatform(service.PlatformOpenAI).
+ SetType(service.AccountTypeAPIKey).
+ SetStatus(service.StatusActive).
+ SetCredentials(map[string]any{"api_key": "sk-test"}).
+ Save(ctx)
+ require.NoError(t, err)
+ return a.ID
+}
+
+func mustCreateAPIKeyRepoUsageLog(t *testing.T, ctx context.Context, client *dbent.Client, userID, apiKeyID, accountID int64, requestID string, createdAt time.Time, ipAddress *string) {
+ t.Helper()
+ builder := client.UsageLog.Create().
+ SetUserID(userID).
+ SetAPIKeyID(apiKeyID).
+ SetAccountID(accountID).
+ SetRequestID(requestID).
+ SetModel("gpt-5").
+ SetCreatedAt(createdAt)
+ if ipAddress != nil {
+ builder.SetIPAddress(*ipAddress)
+ }
+ _, err := builder.Save(ctx)
+ require.NoError(t, err)
+}
+
+func TestAPIKeyRepositoryListByUserIDAttachesLastUsedIP(t *testing.T) {
+ repo, client := newAPIKeyRepoSQLite(t)
+ ctx := context.Background()
+ user := mustCreateAPIKeyRepoUser(t, ctx, client, "list-last-used-ip@test.com")
+ accountID := mustCreateAPIKeyRepoAccount(t, ctx, client, "acc-list-last-used-ip")
+
+ withLogs := &service.APIKey{
+ UserID: user.ID,
+ Key: "sk-list-last-used-ip-logs",
+ Name: "With Logs",
+ Status: service.StatusActive,
+ }
+ emptyOnly := &service.APIKey{
+ UserID: user.ID,
+ Key: "sk-list-last-used-ip-empty",
+ Name: "Empty Only",
+ Status: service.StatusActive,
+ }
+ noLogs := &service.APIKey{
+ UserID: user.ID,
+ Key: "sk-list-last-used-ip-none",
+ Name: "No Logs",
+ Status: service.StatusActive,
+ }
+ require.NoError(t, repo.Create(ctx, withLogs))
+ require.NoError(t, repo.Create(ctx, emptyOnly))
+ require.NoError(t, repo.Create(ctx, noLogs))
+
+ olderIP := "198.51.100.10"
+ newerEmptyIP := ""
+ newestIP := "203.0.113.20"
+ base := time.Now().UTC().Add(-3 * time.Hour).Truncate(time.Second)
+ mustCreateAPIKeyRepoUsageLog(t, ctx, client, user.ID, withLogs.ID, accountID, "req-last-ip-older", base, &olderIP)
+ mustCreateAPIKeyRepoUsageLog(t, ctx, client, user.ID, withLogs.ID, accountID, "req-last-ip-empty", base.Add(time.Hour), &newerEmptyIP)
+ mustCreateAPIKeyRepoUsageLog(t, ctx, client, user.ID, withLogs.ID, accountID, "req-last-ip-newest", base.Add(2*time.Hour), &newestIP)
+ mustCreateAPIKeyRepoUsageLog(t, ctx, client, user.ID, emptyOnly.ID, accountID, "req-empty-ip", base.Add(3*time.Hour), &newerEmptyIP)
+
+ keys, _, err := repo.ListByUserID(ctx, user.ID, pagination.PaginationParams{Page: 1, PageSize: 10}, service.APIKeyListFilters{})
+ require.NoError(t, err)
+
+ byID := make(map[int64]service.APIKey, len(keys))
+ for _, key := range keys {
+ byID[key.ID] = key
+ }
+ require.NotNil(t, byID[withLogs.ID].LastUsedIP)
+ require.Equal(t, newestIP, *byID[withLogs.ID].LastUsedIP)
+ require.Nil(t, byID[emptyOnly.ID].LastUsedIP)
+ require.Nil(t, byID[noLogs.ID].LastUsedIP)
+}
+
func TestAPIKeyRepository_CreateWithLastUsedAt(t *testing.T) {
repo, client := newAPIKeyRepoSQLite(t)
ctx := context.Background()
diff --git a/backend/internal/server/api_contract_test.go b/backend/internal/server/api_contract_test.go
index 278e654834..d15ccc9c2e 100644
--- a/backend/internal/server/api_contract_test.go
+++ b/backend/internal/server/api_contract_test.go
@@ -234,6 +234,7 @@ func TestAPIContracts(t *testing.T) {
"ip_whitelist": null,
"ip_blacklist": null,
"last_used_at": null,
+ "last_used_ip": null,
"current_concurrency": 0,
"quota": 0,
"quota_used": 0,
@@ -284,6 +285,7 @@ func TestAPIContracts(t *testing.T) {
"ip_whitelist": null,
"ip_blacklist": null,
"last_used_at": null,
+ "last_used_ip": null,
"current_concurrency": 0,
"quota": 0,
"quota_used": 0,
diff --git a/backend/internal/service/api_key.go b/backend/internal/service/api_key.go
index dfc3ec1c5a..b92a848184 100644
--- a/backend/internal/service/api_key.go
+++ b/backend/internal/service/api_key.go
@@ -40,6 +40,7 @@ type APIKey struct {
CompiledIPWhitelist *ip.CompiledIPRules `json:"-"`
CompiledIPBlacklist *ip.CompiledIPRules `json:"-"`
LastUsedAt *time.Time
+ LastUsedIP *string
CreatedAt time.Time
UpdatedAt time.Time
User *User
diff --git a/frontend/src/i18n/locales/en/dashboard.ts b/frontend/src/i18n/locales/en/dashboard.ts
index a9c7c750c8..046179cd94 100644
--- a/frontend/src/i18n/locales/en/dashboard.ts
+++ b/frontend/src/i18n/locales/en/dashboard.ts
@@ -124,6 +124,7 @@ export default {
total: 'Last 30d',
quota: 'Quota',
lastUsedAt: 'Last Used',
+ lastUsedIP: 'Last Used IP',
useKey: 'Use Key',
useKeyModal: {
title: 'Use API Key',
diff --git a/frontend/src/i18n/locales/zh/dashboard.ts b/frontend/src/i18n/locales/zh/dashboard.ts
index 2ce3eda5bb..104e111f3a 100644
--- a/frontend/src/i18n/locales/zh/dashboard.ts
+++ b/frontend/src/i18n/locales/zh/dashboard.ts
@@ -124,6 +124,7 @@ export default {
total: '近30天',
quota: '额度',
lastUsedAt: '上次使用时间',
+ lastUsedIP: '最近使用 IP',
useKey: '使用密钥',
useKeyModal: {
title: '使用 API 密钥',
diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts
index 8465332287..78779c75fc 100644
--- a/frontend/src/types/index.ts
+++ b/frontend/src/types/index.ts
@@ -585,6 +585,7 @@ export interface ApiKey {
ip_whitelist: string[]
ip_blacklist: string[]
last_used_at: string | null
+ last_used_ip: string | null
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)
diff --git a/frontend/src/views/user/KeysView.vue b/frontend/src/views/user/KeysView.vue
index 087e0e4175..f8ecf7d076 100644
--- a/frontend/src/views/user/KeysView.vue
+++ b/frontend/src/views/user/KeysView.vue
@@ -354,6 +354,13 @@
-
+
+
+ {{ value }}
+
+ -
+
+
{{ formatDateTime(value) }}
@@ -1174,15 +1181,19 @@ const allColumns = computed(() => [
{ 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: 'last_used_ip', label: t('keys.lastUsedIP'), sortable: false },
{ key: 'created_at', label: t('keys.created'), sortable: true },
{ key: 'actions', label: t('common.actions'), sortable: false }
])
const ALWAYS_VISIBLE_COLUMNS = new Set(['name', 'actions'])
-const DEFAULT_HIDDEN_COLUMNS = ['rate_limit', 'last_used_at']
+const DEFAULT_HIDDEN_COLUMNS = ['rate_limit', 'last_used_at', 'last_used_ip']
const HIDDEN_COLUMNS_KEY = 'api-key-hidden-columns'
const COLUMN_SETTINGS_VERSION_KEY = 'api-key-column-settings-version'
-const COLUMN_SETTINGS_VERSION = 1
+const COLUMN_SETTINGS_VERSION = 2
+const VERSION_NEW_HIDDEN_COLUMNS: Record = {
+ 2: ['last_used_ip']
+}
const toggleableColumns = computed(() =>
allColumns.value.filter((col) => !ALWAYS_VISIBLE_COLUMNS.has(col.key))
@@ -1213,10 +1224,23 @@ const loadSavedColumns = () => {
!ALWAYS_VISIBLE_COLUMNS.has(key)
)
.forEach((key) => hiddenColumns.add(key))
+ const storedVersion = Number(localStorage.getItem(COLUMN_SETTINGS_VERSION_KEY) ?? '1')
+ if (storedVersion < COLUMN_SETTINGS_VERSION) {
+ for (let v = storedVersion + 1; v <= COLUMN_SETTINGS_VERSION; v++) {
+ for (const key of VERSION_NEW_HIDDEN_COLUMNS[v] ?? []) {
+ if (validColumnKeys.has(key) && !ALWAYS_VISIBLE_COLUMNS.has(key)) {
+ hiddenColumns.add(key)
+ }
+ }
+ }
+ saveColumnsToStorage()
+ } else {
+ localStorage.setItem(COLUMN_SETTINGS_VERSION_KEY, String(COLUMN_SETTINGS_VERSION))
+ }
} else {
DEFAULT_HIDDEN_COLUMNS.forEach((key) => hiddenColumns.add(key))
+ localStorage.setItem(COLUMN_SETTINGS_VERSION_KEY, String(COLUMN_SETTINGS_VERSION))
}
- localStorage.setItem(COLUMN_SETTINGS_VERSION_KEY, String(COLUMN_SETTINGS_VERSION))
} catch (error) {
console.error('Failed to load API key table columns:', error)
DEFAULT_HIDDEN_COLUMNS.forEach((key) => hiddenColumns.add(key))
diff --git a/frontend/src/views/user/__tests__/KeysView.spec.ts b/frontend/src/views/user/__tests__/KeysView.spec.ts
index 2417cd9e5c..ec4086181b 100644
--- a/frontend/src/views/user/__tests__/KeysView.spec.ts
+++ b/frontend/src/views/user/__tests__/KeysView.spec.ts
@@ -44,6 +44,7 @@ const messages: Record = {
'keys.group': 'Group',
'keys.currentConcurrency': 'Current Concurrency',
'keys.lastUsedAt': 'Last Used',
+ 'keys.lastUsedIP': 'Last Used IP',
'keys.rateLimitColumn': 'Rate Limit',
'keys.searchPlaceholder': 'Search name or key...',
'keys.status.active': 'Active',
@@ -113,6 +114,7 @@ const createApiKey = (): ApiKey => ({
ip_whitelist: [],
ip_blacklist: [],
last_used_at: null,
+ last_used_ip: null,
quota: 0,
quota_used: 0,
expires_at: null,
@@ -159,6 +161,12 @@ const DataTableStub = {
+
+
+
@@ -265,6 +273,7 @@ describe('user KeysView column settings', () => {
])
expect(visibleColumnKeys(wrapper)).not.toContain('rate_limit')
expect(visibleColumnKeys(wrapper)).not.toContain('last_used_at')
+ expect(visibleColumnKeys(wrapper)).not.toContain('last_used_ip')
})
it('shows a hidden column when toggled and persists the preference', async () => {
@@ -275,8 +284,28 @@ describe('user KeysView column settings', () => {
await nextTick()
expect(visibleColumnKeys(wrapper)).toContain('rate_limit')
- expect(localStorage.getItem('api-key-hidden-columns')).toBe(JSON.stringify(['last_used_at']))
- expect(localStorage.getItem('api-key-column-settings-version')).toBe('1')
+ expect(localStorage.getItem('api-key-hidden-columns')).toBe(
+ JSON.stringify(['last_used_at', 'last_used_ip'])
+ )
+ expect(localStorage.getItem('api-key-column-settings-version')).toBe('2')
+ })
+
+ it('shows the last used IP column when toggled', async () => {
+ listKeys.mockResolvedValueOnce({
+ items: [{ ...createApiKey(), last_used_ip: '203.0.113.10' }],
+ total: 1,
+ page: 1,
+ page_size: 20,
+ pages: 1,
+ })
+ const wrapper = await mountView()
+
+ await wrapper.get('button[title="Column Settings"]').trigger('click')
+ await getButtonByText(wrapper, 'Last Used IP').trigger('click')
+ await nextTick()
+
+ expect(visibleColumnKeys(wrapper)).toContain('last_used_ip')
+ expect(wrapper.get('[data-test="last-used-ip"]').text()).toBe('203.0.113.10')
})
it('restores column preferences from localStorage on mount', async () => {
@@ -296,6 +325,10 @@ describe('user KeysView column settings', () => {
'last_used_at',
'actions',
])
+ expect(localStorage.getItem('api-key-hidden-columns')).toBe(
+ JSON.stringify(['group', 'created_at', 'last_used_ip'])
+ )
+ expect(localStorage.getItem('api-key-column-settings-version')).toBe('2')
})
it('does not include always-visible columns in the toggleable menu', async () => {
@@ -308,6 +341,7 @@ describe('user KeysView column settings', () => {
expect(columnMenuText).toContain('API Key')
expect(columnMenuText).toContain('Current Concurrency')
expect(columnMenuText).toContain('Rate Limit')
+ expect(columnMenuText).toContain('Last Used IP')
expect(columnMenuText).not.toContain('Name')
expect(columnMenuText).not.toContain('Actions')
})