From 0ff93aca7a585fcd877bbacfdc778f2db9ece799 Mon Sep 17 00:00:00 2001 From: DaydreamCoding <22166516+DaydreamCoding@users.noreply.github.com> Date: Wed, 1 Jul 2026 21:40:31 +0800 Subject: [PATCH] feat: add usage IP geolocation lookup --- .../src/components/admin/usage/UsageTable.vue | 53 +++- .../admin/usage/__tests__/UsageTable.spec.ts | 106 +++++++ frontend/src/components/common/IpGeoCell.vue | 101 ++++++ .../common/__tests__/IpGeoCell.spec.ts | 108 +++++++ .../src/i18n/__tests__/ipGeoLocales.spec.ts | 24 ++ frontend/src/i18n/locales/en.ts | 15 + frontend/src/i18n/locales/zh.ts | 15 + .../src/utils/__tests__/ipGeoLookup.spec.ts | 295 ++++++++++++++++++ frontend/src/utils/ipGeoLookup.ts | 214 +++++++++++++ frontend/src/views/admin/UsageView.vue | 5 + frontend/src/views/user/UsageView.vue | 5 + 11 files changed, 938 insertions(+), 3 deletions(-) create mode 100644 frontend/src/components/common/IpGeoCell.vue create mode 100644 frontend/src/components/common/__tests__/IpGeoCell.spec.ts create mode 100644 frontend/src/i18n/__tests__/ipGeoLocales.spec.ts create mode 100644 frontend/src/utils/__tests__/ipGeoLookup.spec.ts create mode 100644 frontend/src/utils/ipGeoLookup.ts diff --git a/frontend/src/components/admin/usage/UsageTable.vue b/frontend/src/components/admin/usage/UsageTable.vue index 780cdda52f..4f24913133 100644 --- a/frontend/src/components/admin/usage/UsageTable.vue +++ b/frontend/src/components/admin/usage/UsageTable.vue @@ -1,5 +1,21 @@ diff --git a/frontend/src/components/common/__tests__/IpGeoCell.spec.ts b/frontend/src/components/common/__tests__/IpGeoCell.spec.ts new file mode 100644 index 0000000000..7b64a9246c --- /dev/null +++ b/frontend/src/components/common/__tests__/IpGeoCell.spec.ts @@ -0,0 +1,108 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { mount } from '@vue/test-utils' + +const mocks = vi.hoisted(() => ({ + getEntry: vi.fn(), + fetchOne: vi.fn(), +})) + +vi.mock('@/utils/ipGeoLookup', () => ({ + getEntry: mocks.getEntry, + fetchOne: mocks.fetchOne, +})) + +vi.mock('vue-i18n', async () => { + const actual = await vi.importActual('vue-i18n') + return { + ...actual, + useI18n: () => ({ + t: (key: string) => { + const table: Record = { + 'usage.ipGeo.fetch': 'Fetch region', + 'usage.ipGeo.fetching': 'Fetching...', + 'usage.ipGeo.failed': 'Failed', + 'usage.ipGeo.private': 'Private address', + 'usage.ipGeo.refreshTitle': 'Refresh', + 'usage.ipGeo.detailOrg': 'ISP', + 'usage.ipGeo.detailTimezone': 'Timezone', + 'usage.ipGeo.detailAccuracy': 'Accuracy', + 'usage.ipGeo.detailCoordinates': 'Coordinates', + } + return table[key] ?? key + }, + }), + } +}) + +import IpGeoCell from '../IpGeoCell.vue' + +describe('IpGeoCell', () => { + beforeEach(() => { + mocks.getEntry.mockReset() + mocks.fetchOne.mockReset() + }) + + it('renders a clickable fetch link in idle state and triggers fetchOne on click', async () => { + mocks.getEntry.mockReturnValue({ status: 'idle' }) + const wrapper = mount(IpGeoCell, { props: { ip: '8.8.8.8' } }) + expect(wrapper.text()).toContain('Fetch region') + await wrapper.find('button').trigger('click') + expect(mocks.fetchOne).toHaveBeenCalledWith('8.8.8.8') + }) + + it('renders loading state', () => { + mocks.getEntry.mockReturnValue({ status: 'loading' }) + const wrapper = mount(IpGeoCell, { props: { ip: '8.8.8.8' } }) + expect(wrapper.text()).toContain('Fetching...') + }) + + it('renders success state with label, tooltip detail, and a refresh button', async () => { + mocks.getEntry.mockReturnValue({ + status: 'success', + label: 'CN · Guangdong · Shenzhen', + detail: { + organization: 'AS4134 Chinanet', + timezone: 'Asia/Shanghai', + accuracy: 10, + latitude: '22.5', + longitude: '114.0', + }, + }) + const wrapper = mount(IpGeoCell, { props: { ip: '121.35.47.43' } }) + expect(wrapper.text()).toContain('CN · Guangdong · Shenzhen') + const buttons = wrapper.findAll('button') + expect(buttons.length).toBe(2) + expect(buttons[0].attributes('title')).toContain('AS4134 Chinanet') + expect(buttons[0].attributes('title')).toContain('Asia/Shanghai') + await buttons[1].trigger('click') + expect(mocks.fetchOne).toHaveBeenCalledWith('121.35.47.43', true) + }) + + it('opens the external lookup page when the label is clicked', async () => { + mocks.getEntry.mockReturnValue({ status: 'success', label: 'US · California', detail: {} }) + const openSpy = vi.spyOn(window, 'open').mockImplementation(() => null) + const wrapper = mount(IpGeoCell, { props: { ip: '8.8.4.4' } }) + await wrapper.findAll('button')[0].trigger('click') + expect(openSpy).toHaveBeenCalledWith( + 'https://www.iplocation.net/ip-lookup?query=8.8.4.4', + '_blank', + 'noopener,noreferrer' + ) + openSpy.mockRestore() + }) + + it('renders failed state as a clickable retry', async () => { + mocks.getEntry.mockReturnValue({ status: 'error' }) + const wrapper = mount(IpGeoCell, { props: { ip: '8.8.8.8' } }) + expect(wrapper.text()).toContain('Failed') + await wrapper.find('button').trigger('click') + expect(mocks.fetchOne).toHaveBeenCalledWith('8.8.8.8') + }) + + it('renders private state as non-clickable text', () => { + mocks.getEntry.mockReturnValue({ status: 'private' }) + const wrapper = mount(IpGeoCell, { props: { ip: '192.168.1.1' } }) + expect(wrapper.text()).toContain('Private address') + expect(wrapper.find('button').exists()).toBe(false) + }) +}) diff --git a/frontend/src/i18n/__tests__/ipGeoLocales.spec.ts b/frontend/src/i18n/__tests__/ipGeoLocales.spec.ts new file mode 100644 index 0000000000..65091444c3 --- /dev/null +++ b/frontend/src/i18n/__tests__/ipGeoLocales.spec.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from 'vitest' + +import en from '../locales/en' +import zh from '../locales/zh' + +describe('usage ipGeo locale keys', () => { + it('contains zh labels for IP geolocation UI', () => { + expect(zh.usage.ipGeo.fetch).toBe('获取地区') + expect(zh.usage.ipGeo.fetching).toBe('获取中...') + expect(zh.usage.ipGeo.failed).toBe('获取失败') + expect(zh.usage.ipGeo.private).toBe('内网地址') + expect(zh.usage.ipGeo.batchFetch).toBe('批量获取地区') + expect(zh.usage.ipGeo.pending).toBe('{count} 个 IP 待获取地区') + }) + + it('contains en labels for IP geolocation UI', () => { + expect(en.usage.ipGeo.fetch).toBe('Fetch region') + expect(en.usage.ipGeo.fetching).toBe('Fetching...') + expect(en.usage.ipGeo.failed).toBe('Failed') + expect(en.usage.ipGeo.private).toBe('Private address') + expect(en.usage.ipGeo.batchFetch).toBe('Batch fetch regions') + expect(en.usage.ipGeo.pending).toBe('{count} IPs pending') + }) +}) diff --git a/frontend/src/i18n/locales/en.ts b/frontend/src/i18n/locales/en.ts index 3469d3fff2..8f75fbe58d 100644 --- a/frontend/src/i18n/locales/en.ts +++ b/frontend/src/i18n/locales/en.ts @@ -995,6 +995,21 @@ export default { exportExcelFailed: 'Failed to export usage data', imageUnit: ' images', userAgent: 'User-Agent', + ipGeo: { + fetch: 'Fetch region', + fetching: 'Fetching...', + failed: 'Failed', + private: 'Private address', + refreshTitle: 'Refresh region info', + batchFetch: 'Batch fetch regions', + batchFetching: 'Fetching...', + pending: '{count} IPs pending', + batchFailed: 'Failed to batch fetch IP regions', + detailOrg: 'ISP', + detailTimezone: 'Timezone', + detailAccuracy: 'Accuracy', + detailCoordinates: 'Coordinates', + }, tabs: { usage: 'Usage', errors: 'Error Requests' }, errors: { time: 'Time', model: 'Model', endpoint: 'Endpoint', status: 'Status', diff --git a/frontend/src/i18n/locales/zh.ts b/frontend/src/i18n/locales/zh.ts index 3fa1e1371d..9a1deb5e25 100644 --- a/frontend/src/i18n/locales/zh.ts +++ b/frontend/src/i18n/locales/zh.ts @@ -999,6 +999,21 @@ export default { exportExcelFailed: '使用数据导出失败', imageUnit: '张', userAgent: 'User-Agent', + ipGeo: { + fetch: '获取地区', + fetching: '获取中...', + failed: '获取失败', + private: '内网地址', + refreshTitle: '刷新地区信息', + batchFetch: '批量获取地区', + batchFetching: '获取中...', + pending: '{count} 个 IP 待获取地区', + batchFailed: '批量获取地区信息失败', + detailOrg: '运营商', + detailTimezone: '时区', + detailAccuracy: '定位精度', + detailCoordinates: '坐标', + }, tabs: { usage: '用量明细', errors: '错误请求' }, errors: { time: '时间', model: '模型', endpoint: '端点', status: '状态码', diff --git a/frontend/src/utils/__tests__/ipGeoLookup.spec.ts b/frontend/src/utils/__tests__/ipGeoLookup.spec.ts new file mode 100644 index 0000000000..ab1dd29e13 --- /dev/null +++ b/frontend/src/utils/__tests__/ipGeoLookup.spec.ts @@ -0,0 +1,295 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest' +import { isPrivateIp, getEntry, formatGeoLabel, fetchOne, fetchBatch } from '../ipGeoLookup' + +describe('isPrivateIp', () => { + it('identifies private/reserved IPv4 ranges', () => { + expect(isPrivateIp('10.0.0.1')).toBe(true) + expect(isPrivateIp('127.0.0.1')).toBe(true) + expect(isPrivateIp('192.168.1.1')).toBe(true) + expect(isPrivateIp('172.16.0.1')).toBe(true) + expect(isPrivateIp('172.31.255.255')).toBe(true) + expect(isPrivateIp('169.254.1.1')).toBe(true) + }) + + it('does not flag public IPv4 addresses', () => { + expect(isPrivateIp('8.8.8.8')).toBe(false) + expect(isPrivateIp('172.32.0.1')).toBe(false) + expect(isPrivateIp('121.35.47.43')).toBe(false) + }) + + it('identifies private/reserved IPv6 addresses', () => { + expect(isPrivateIp('::1')).toBe(true) + expect(isPrivateIp('fe80::1')).toBe(true) + expect(isPrivateIp('fe90::1')).toBe(true) + expect(isPrivateIp('febf::1')).toBe(true) + expect(isPrivateIp('fc00::1')).toBe(true) + expect(isPrivateIp('fd00::1')).toBe(true) + expect(isPrivateIp('fdff::1')).toBe(true) + }) + + it('does not overmatch public IPv6 addresses near private ranges', () => { + expect(isPrivateIp('fec0::1')).toBe(false) + expect(isPrivateIp('fbff::1')).toBe(false) + expect(isPrivateIp('fe7f::1')).toBe(false) + }) +}) + +describe('getEntry', () => { + it('returns an idle entry for an IP that has never been fetched', () => { + expect(getEntry('203.0.113.9')).toEqual({ status: 'idle' }) + }) +}) + +describe('formatGeoLabel', () => { + it('joins country/region/city with a separator', () => { + expect(formatGeoLabel({ countryCode: 'CN', region: 'Guangdong', city: 'Shenzhen' })).toBe('CN · Guangdong · Shenzhen') + }) + + it('skips missing fields', () => { + expect(formatGeoLabel({ countryCode: 'CN' })).toBe('CN') + expect(formatGeoLabel({ countryCode: 'US', region: 'Massachusetts' })).toBe('US · Massachusetts') + }) +}) + +describe('fetchOne', () => { + beforeEach(() => { + localStorage.clear() + global.fetch = vi.fn() + }) + + it('marks a private IP without making a network request', async () => { + await fetchOne('192.168.50.1') + expect(getEntry('192.168.50.1')).toEqual({ status: 'private' }) + expect(global.fetch).not.toHaveBeenCalled() + }) + + it('fetches and stores a successful geolocation result', async () => { + (global.fetch as any).mockResolvedValue({ + ok: true, + json: async () => ({ + ip: '121.35.47.43', + country_code: 'CN', + region: 'Guangdong', + city: 'Shenzhen', + organization: 'AS4134 Chinanet', + timezone: 'Asia/Shanghai', + accuracy: 10, + latitude: '22.5455', + longitude: '114.0683', + }), + }) + + await fetchOne('121.35.47.43') + + expect(global.fetch).toHaveBeenCalledWith('https://get.geojs.io/v1/ip/geo/121.35.47.43.json') + const entry = getEntry('121.35.47.43') + expect(entry.status).toBe('success') + expect(entry.label).toBe('CN · Guangdong · Shenzhen') + expect(entry.detail?.organization).toBe('AS4134 Chinanet') + }) + + it('marks the entry as error when the response has no country_code', async () => { + (global.fetch as any).mockResolvedValue({ + ok: true, + json: async () => ({ ip: '192.0.2.55', organization: 'AS64512 Unknown' }), + }) + + await fetchOne('192.0.2.55') + + expect(getEntry('192.0.2.55').status).toBe('error') + }) + + it('marks the entry as error when the request rejects', async () => { + (global.fetch as any).mockRejectedValue(new Error('network down')) + + await fetchOne('198.51.100.7') + + expect(getEntry('198.51.100.7').status).toBe('error') + }) + + it('does not re-fetch a cached successful IP unless forced', async () => { + (global.fetch as any).mockResolvedValue({ + ok: true, + json: async () => ({ ip: '8.8.8.8', country_code: 'US', region: 'California', city: 'Mountain View' }), + }) + + await fetchOne('8.8.8.8') + expect(global.fetch).toHaveBeenCalledTimes(1) + + await fetchOne('8.8.8.8') + expect(global.fetch).toHaveBeenCalledTimes(1) + + await fetchOne('8.8.8.8', true) + expect(global.fetch).toHaveBeenCalledTimes(2) + }) +}) + +describe('fetchBatch', () => { + beforeEach(() => { + localStorage.clear() + global.fetch = vi.fn() + }) + + it('deduplicates IPs and skips private addresses without a network call', async () => { + (global.fetch as any).mockResolvedValue({ + ok: true, + json: async () => [{ ip: '203.0.113.10', country_code: 'US', region: 'Texas', city: 'Dallas' }], + }) + + await fetchBatch(['203.0.113.10', '203.0.113.10', '10.0.0.5']) + + expect(global.fetch).toHaveBeenCalledTimes(1) + const calledUrl = (global.fetch as any).mock.calls[0][0] as string + expect(calledUrl).toContain('ip=203.0.113.10') + expect(calledUrl).not.toContain('203.0.113.10,203.0.113.10') + expect(getEntry('10.0.0.5').status).toBe('private') + expect(getEntry('203.0.113.10').status).toBe('success') + }) + + it('splits more than 50 IPs into multiple chunk requests', async () => { + const ips = Array.from({ length: 61 }, (_, i) => `203.0.${Math.floor(i / 250)}.${(i % 250) + 1}`) + ;(global.fetch as any).mockImplementation(async (url: string) => ({ + ok: true, + json: async () => { + const queried = new URL(url).searchParams.get('ip')!.split(',') + return queried.map((ip) => ({ ip, country_code: 'US' })) + }, + })) + + await fetchBatch(ips) + + expect(global.fetch).toHaveBeenCalledTimes(2) + const firstChunkIps = new URL((global.fetch as any).mock.calls[0][0]).searchParams.get('ip')!.split(',') + const secondChunkIps = new URL((global.fetch as any).mock.calls[1][0]).searchParams.get('ip')!.split(',') + expect(firstChunkIps.length).toBe(50) + expect(secondChunkIps.length).toBe(11) + }) + + it('marks individual IPs as error when they are missing from the batch response', async () => { + (global.fetch as any).mockResolvedValue({ + ok: true, + json: async () => [{ ip: '203.0.113.20', country_code: 'US' }], + }) + + const ok = await fetchBatch(['203.0.113.20', '203.0.113.21']) + + expect(getEntry('203.0.113.20').status).toBe('success') + expect(getEntry('203.0.113.21').status).toBe('error') + // 响应本身是 200,只是个别 IP 缺失/无法定位,属于业务级失败而非网络级失败 + expect(ok).toBe(true) + }) + + it('returns false when a chunk request fails at the network level', async () => { + (global.fetch as any).mockRejectedValue(new Error('network down')) + + const ok = await fetchBatch(['203.0.113.50', '203.0.113.51']) + + expect(ok).toBe(false) + expect(getEntry('203.0.113.50').status).toBe('error') + expect(getEntry('203.0.113.51').status).toBe('error') + }) + + it('skips IPs that already have a cached success entry', async () => { + (global.fetch as any).mockResolvedValueOnce({ + ok: true, + json: async () => [{ ip: '203.0.113.40', country_code: 'CN' }], + }) + await fetchBatch(['203.0.113.40']) + expect(global.fetch).toHaveBeenCalledTimes(1) + + ;(global.fetch as any).mockResolvedValueOnce({ + ok: true, + json: async () => [{ ip: '203.0.113.41', country_code: 'CN' }], + }) + await fetchBatch(['203.0.113.40', '203.0.113.41']) + expect(global.fetch).toHaveBeenCalledTimes(2) + const secondCallUrl = (global.fetch as any).mock.calls[1][0] as string + expect(secondCallUrl).toContain('203.0.113.41') + expect(secondCallUrl).not.toContain('203.0.113.40') + }) +}) + +describe('ipGeoLookup localStorage persistence', () => { + beforeEach(() => { + localStorage.clear() + vi.resetModules() + }) + + it('hydrates the in-memory cache from a non-expired localStorage entry on module load', async () => { + localStorage.setItem( + 'sub2api:ip-geo-cache:v1', + JSON.stringify({ + '121.35.47.43': { label: 'CN · Guangdong · Shenzhen', fetchedAt: Date.now() }, + }) + ) + + const mod = await import('../ipGeoLookup') + + expect(mod.getEntry('121.35.47.43')).toEqual( + expect.objectContaining({ status: 'success', label: 'CN · Guangdong · Shenzhen' }) + ) + }) + + it('ignores expired localStorage entries on module load', async () => { + const twentyFiveHoursAgo = Date.now() - 25 * 60 * 60 * 1000 + localStorage.setItem( + 'sub2api:ip-geo-cache:v1', + JSON.stringify({ + '8.8.8.8': { label: 'US · California', fetchedAt: twentyFiveHoursAgo }, + }) + ) + + const mod = await import('../ipGeoLookup') + + expect(mod.getEntry('8.8.8.8')).toEqual({ status: 'idle' }) + }) + + it('persists a successful fetch result to localStorage', async () => { + global.fetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ ip: '1.2.4.8', country_code: 'CN' }), + }) + const mod = await import('../ipGeoLookup') + + await mod.fetchOne('1.2.4.8') + + const stored = JSON.parse(localStorage.getItem('sub2api:ip-geo-cache:v1') || '{}') + expect(stored['1.2.4.8']).toEqual(expect.objectContaining({ label: 'CN' })) + }) + + it('expires a hydrated in-memory entry after the TTL elapses', async () => { + const now = new Date('2026-07-01T00:00:00Z') + vi.setSystemTime(now) + localStorage.setItem( + 'sub2api:ip-geo-cache:v1', + JSON.stringify({ + '8.8.4.4': { label: 'US · California', fetchedAt: now.getTime() }, + }) + ) + + const mod = await import('../ipGeoLookup') + expect(mod.getEntry('8.8.4.4')).toEqual(expect.objectContaining({ status: 'success' })) + + vi.setSystemTime(new Date(now.getTime() + 25 * 60 * 60 * 1000)) + + expect(mod.getEntry('8.8.4.4')).toEqual({ status: 'idle' }) + }) + + it('re-fetches a successful in-memory cache entry after the TTL elapses', async () => { + const now = new Date('2026-07-01T00:00:00Z') + vi.setSystemTime(now) + global.fetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ ip: '8.8.8.8', country_code: 'US' }), + }) + const mod = await import('../ipGeoLookup') + + await mod.fetchOne('8.8.8.8') + expect(global.fetch).toHaveBeenCalledTimes(1) + + vi.setSystemTime(new Date(now.getTime() + 25 * 60 * 60 * 1000)) + await mod.fetchOne('8.8.8.8') + + expect(global.fetch).toHaveBeenCalledTimes(2) + }) +}) diff --git a/frontend/src/utils/ipGeoLookup.ts b/frontend/src/utils/ipGeoLookup.ts new file mode 100644 index 0000000000..22d09b530b --- /dev/null +++ b/frontend/src/utils/ipGeoLookup.ts @@ -0,0 +1,214 @@ +import { reactive } from 'vue' + +export type IpGeoStatus = 'idle' | 'loading' | 'success' | 'error' | 'private' + +export interface IpGeoDetail { + countryCode?: string + region?: string + city?: string + organization?: string + timezone?: string + accuracy?: number + latitude?: string + longitude?: string +} + +export interface IpGeoEntry { + status: IpGeoStatus + label?: string + detail?: IpGeoDetail + fetchedAt?: number +} + +const IDLE_ENTRY: IpGeoEntry = { status: 'idle' } +const CACHE_STORAGE_KEY = 'sub2api:ip-geo-cache:v1' +const CACHE_TTL_MS = 24 * 60 * 60 * 1000 +const BATCH_CHUNK_SIZE = 50 +const GEO_SINGLE_URL = 'https://get.geojs.io/v1/ip/geo' +const GEO_BATCH_URL = 'https://get.geojs.io/v1/ip/geo.json' + +interface StoredEntry { + label: string + detail?: IpGeoDetail + fetchedAt: number +} + +const cache = reactive(new Map()) + +function isFreshSuccess(entry: IpGeoEntry | undefined): entry is IpGeoEntry & { status: 'success'; fetchedAt: number } { + return entry?.status === 'success' && typeof entry.fetchedAt === 'number' && Date.now() - entry.fetchedAt <= CACHE_TTL_MS +} + +function getFreshEntry(ip: string): IpGeoEntry | undefined { + const entry = cache.get(ip) + if (entry?.status === 'success' && !isFreshSuccess(entry)) { + cache.delete(ip) + persistToStorage() + return undefined + } + return entry +} + +export function isPrivateIp(ip: string): boolean { + const v4 = ip.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/) + if (v4) { + const a = Number(v4[1]) + const b = Number(v4[2]) + if (a === 10) return true + if (a === 127) return true + if (a === 169 && b === 254) return true + if (a === 172 && b >= 16 && b <= 31) return true + if (a === 192 && b === 168) return true + return false + } + const lower = ip.toLowerCase() + if (lower === '::1') return true + const firstSegment = lower.split(':', 1)[0] + if (/^fe[89ab][0-9a-f]$/.test(firstSegment)) return true + if (/^f[cd][0-9a-f]{2}$/.test(firstSegment)) return true + return false +} + +function loadFromStorage(): void { + try { + const raw = localStorage.getItem(CACHE_STORAGE_KEY) + if (!raw) return + const parsed = JSON.parse(raw) as Record + const now = Date.now() + for (const [ip, stored] of Object.entries(parsed)) { + if (!stored || typeof stored.fetchedAt !== 'number') continue + if (now - stored.fetchedAt > CACHE_TTL_MS) continue + cache.set(ip, { status: 'success', label: stored.label, detail: stored.detail, fetchedAt: stored.fetchedAt }) + } + } catch { + // 忽略损坏的本地缓存 + } +} + +function persistToStorage(): void { + try { + const toStore: Record = {} + for (const [ip, entry] of cache.entries()) { + if (entry.status === 'success' && entry.label && entry.fetchedAt) { + toStore[ip] = { label: entry.label, detail: entry.detail, fetchedAt: entry.fetchedAt } + } + } + localStorage.setItem(CACHE_STORAGE_KEY, JSON.stringify(toStore)) + } catch { + // 存储写入失败(如隐私模式禁用 localStorage)不影响功能 + } +} + +loadFromStorage() + +export function getEntry(ip: string): IpGeoEntry { + return getFreshEntry(ip) ?? IDLE_ENTRY +} + +export function formatGeoLabel(detail: IpGeoDetail): string { + const parts = [detail.countryCode, detail.region, detail.city].filter( + (part): part is string => Boolean(part && part.trim()) + ) + return parts.join(' · ') +} + +interface RawGeoResponse { + ip: string + country_code?: string + region?: string + city?: string + organization?: string + timezone?: string + accuracy?: number + latitude?: string + longitude?: string +} + +function toDetail(raw: RawGeoResponse): IpGeoDetail { + return { + countryCode: raw.country_code, + region: raw.region, + city: raw.city, + organization: raw.organization, + timezone: raw.timezone, + accuracy: raw.accuracy, + latitude: raw.latitude, + longitude: raw.longitude, + } +} + +function applyResult(ip: string, raw: RawGeoResponse | undefined): void { + if (!raw || !raw.country_code) { + cache.set(ip, { status: 'error' }) + return + } + const detail = toDetail(raw) + cache.set(ip, { + status: 'success', + label: formatGeoLabel(detail), + detail, + fetchedAt: Date.now(), + }) +} + +export async function fetchOne(ip: string, force = false): Promise { + if (isPrivateIp(ip)) { + cache.set(ip, { status: 'private' }) + return + } + const existing = getFreshEntry(ip) + if (!force && (isFreshSuccess(existing) || existing?.status === 'loading')) { + return + } + cache.set(ip, { status: 'loading' }) + try { + const response = await fetch(`${GEO_SINGLE_URL}/${encodeURIComponent(ip)}.json`) + if (!response.ok) { + cache.set(ip, { status: 'error' }) + return + } + const raw = (await response.json()) as RawGeoResponse + applyResult(ip, raw) + persistToStorage() + } catch { + cache.set(ip, { status: 'error' }) + } +} + +export async function fetchBatch(ips: string[]): Promise { + const unique = Array.from(new Set(ips)) + const targets: string[] = [] + for (const ip of unique) { + if (isPrivateIp(ip)) { + cache.set(ip, { status: 'private' }) + continue + } + const existing = cache.get(ip) + if (isFreshSuccess(existing) || existing?.status === 'loading') continue + targets.push(ip) + } + if (targets.length === 0) return true + + targets.forEach((ip) => cache.set(ip, { status: 'loading' })) + + let allChunksOk = true + for (let i = 0; i < targets.length; i += BATCH_CHUNK_SIZE) { + const chunk = targets.slice(i, i + BATCH_CHUNK_SIZE) + try { + const response = await fetch(`${GEO_BATCH_URL}?ip=${chunk.map(encodeURIComponent).join(',')}`) + if (!response.ok) { + chunk.forEach((ip) => cache.set(ip, { status: 'error' })) + allChunksOk = false + continue + } + const results = (await response.json()) as RawGeoResponse[] + const byIp = new Map(results.map((r) => [r.ip, r])) + chunk.forEach((ip) => applyResult(ip, byIp.get(ip))) + persistToStorage() + } catch { + chunk.forEach((ip) => cache.set(ip, { status: 'error' })) + allChunksOk = false + } + } + return allChunksOk +} diff --git a/frontend/src/views/admin/UsageView.vue b/frontend/src/views/admin/UsageView.vue index 3dc1b59a43..fb2588cb7f 100644 --- a/frontend/src/views/admin/UsageView.vue +++ b/frontend/src/views/admin/UsageView.vue @@ -118,6 +118,7 @@ :default-sort-order="'desc'" @sort="handleSort" @userClick="handleUserClick" + @ipGeoBatchFailed="handleIpGeoBatchFailed" /> @@ -486,6 +487,10 @@ const handleSort = (key: string, order: 'asc' | 'desc') => { pagination.page = 1 loadLogs() } + +const handleIpGeoBatchFailed = () => { + appStore.showError(t('usage.ipGeo.batchFailed')) +} const cancelExport = () => exportAbortController?.abort() const openCleanupDialog = () => { cleanupDialogVisible.value = true } const getRequestTypeLabel = (log: AdminUsageLog): string => { diff --git a/frontend/src/views/user/UsageView.vue b/frontend/src/views/user/UsageView.vue index 53574bc91e..b66fdaa336 100644 --- a/frontend/src/views/user/UsageView.vue +++ b/frontend/src/views/user/UsageView.vue @@ -155,6 +155,7 @@ default-sort-key="created_at" default-sort-order="desc" @sort="handleSort" + @ipGeoBatchFailed="handleIpGeoBatchFailed" /> { void loadLogs() } +const handleIpGeoBatchFailed = () => { + appStore.showError(t('usage.ipGeo.batchFailed')) +} + const getRequestTypeExportText = (log: UsageLog): string => { const requestType = resolveUsageRequestType(log) if (requestType === 'cyber') return 'Cyber'