mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-30 08:22:34 +00:00
Merge pull request #3615 from DaydreamCoding/feat/usage-ip-geolocation
feat(usage): add IP geolocation lookup to usage tables
This commit is contained in:
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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<string, IpGeoEntry>())
|
||||
|
||||
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<string, StoredEntry>
|
||||
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<string, StoredEntry> = {}
|
||||
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<void> {
|
||||
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<boolean> {
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user