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 @@ - + + @@ -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') })