實作短連結追蹤系統:從建立短網址到分析的完整指南實作短連結追蹤系統:從建立短網址到分析的完整指南
拆解一個自架短連結服務的核心實作,涵蓋 slug 產生、302 轉址、點擊追蹤(裝置/地區/來源)、CRUD 管理與分析數據聚合。適合想在自己專案中加入短連結功能的開發者。
實作短連結追蹤系統:從建立短網址到點擊分析的完整指南
適用技術棧:Next.js 15 (App Router) / Prisma (PostgreSQL) / nanoid / ua-parser-js / geoip-lite
最近在研究怎麼自己做一個短連結服務,發現核心邏輯其實就是「產生短碼 → 302 轉址 → 記錄點擊」這三步。比較花心思的是點擊追蹤那塊,要從 HTTP 請求裡解析出裝置、地區、來源這些資訊。這篇把整個短連結功能的實作邏輯整理出來,不含登入認證的部分,純粹聚焦在短連結本身怎麼做。
1. 這個功能做什麼
概覽
一套短連結的建立、轉址、追蹤、管理功能。使用者可以建立短網址(自動或自訂 slug),每次短連結被點擊時記錄完整的訪問資訊,最後透過 API 查詢分析數據。
解決什麼問題
- 自己掌控所有點擊數據,不依賴第三方短連結平台
- 追蹤不同管道的連結成效(referrer、device、country)
- 自定義短連結 slug,搭配自己的網域使用
功能邊界(Scope)
- 包含:短網址建立(自動/自訂 slug)、302 轉址、點擊記錄(referrer / device / country / city)、連結列表查詢(含排序)、連結刪除、分析數據查詢(時間趨勢 / 來源 / 裝置 / 地區 / 小時分佈)、CSV 匯出、連結到期日與點擊上限、標籤分組
- 不包含:登入認證、管理後台 UI、QR Code 產生、自訂 404 頁面、i18n
2. 整體架構與資料流
資料流
[使用者點擊短網址]
|
v
GET /:slug (Route Handler)
|
├─ 查詢 DB → Link 不存在 → redirect /404
|
├─ Link 存在 → 非同步記錄 Click
| ├─ 解析 User-Agent → device / browser / os
| ├─ 取得地理位置 (CDN headers > geoip-lite > null)
| ├─ 寫入 Click record
| └─ 更新 Link.lastClickAt
|
└─ 302 redirect → targetUrl
[管理操作 (API)]
|
├─ GET /api/links → 列出所有連結 + 點擊數(支援排序)
├─ POST /api/links → 建立短連結(自動/自訂 slug)
├─ DELETE /api/links/:id → 刪除連結 + 關聯點擊記錄
├─ GET /api/links/:id/analytics → 分析數據(時間趨勢/來源/裝置/地區/小時)
└─ GET /api/links/:id/export → CSV 匯出所有點擊記錄
來源檔案參考(僅供對照,非必要)
以下路徑僅標記程式碼出處,閱讀此 PRD 不需要存取這些檔案。
src/app/[slug]/route.ts | 入口 | 短連結轉址 + 觸發點擊記錄 |
src/lib/short-url.ts | 核心邏輯 | slug 產生、點擊記錄、UA 解析、GeoIP 查詢 |
src/app/api/links/route.ts | API | 連結列表 (GET) / 建立 (POST) |
src/app/api/links/[id]/route.ts | API | 連結刪除 (DELETE) |
src/app/api/links/[id]/analytics/route.ts | API | 分析數據查詢 |
src/app/api/links/[id]/export/route.ts | API | CSV 匯出 |
src/lib/db.ts | 資料層 | Prisma Client singleton |
src/lib/redis.ts | 資料層 | Redis client(已引入但未實際用於核心流程) |
prisma/schema.prisma | 資料層 | DB Schema 定義 |
外部依賴
| 依賴 | 類型 | 用途 |
|---|
nanoid | npm | 產生隨機短碼(預設 6 碼) |
ua-parser-js | npm | 解析 User-Agent 取得裝置/瀏覽器/OS |
geoip-lite | npm(選用) | 從 IP 查詢國家/城市,需 MaxMind 資料庫 |
prisma / @prisma/client | npm | ORM,操作 PostgreSQL |
PostgreSQL | DB | 主要資料儲存 |
Redis 的使用狀況
原始專案有引入 Redis client(src/lib/redis.ts),但在短連結的核心流程中並沒有實際使用。所有的讀寫都是直接打 PostgreSQL。Redis 可能是為了未來擴展準備的(例如:快取熱門 slug 的查詢結果、rate limiting),但目前不是必要依賴。
// src/lib/redis.ts — 已引入但核心流程未使用
import Redis from 'redis'
const redis = Redis.createClient({
url: process.env.REDIS_URL || 'redis://localhost:6379',
})
redis.on('error', (err) => console.log('Redis Client Error', err))
export const connectRedis = async () => {
if (!redis.isOpen) {
await redis.connect()
}
return redis
}
環境變數 / 設定
DATABASE_URL=postgresql://user:password@host:5432/dbname # PostgreSQL 連線字串
ENABLE_GEOIP_LOOKUP=true # 是否啟用 GeoIP(預設 true)
MAXMIND_LICENSE_KEY=your-key # MaxMind GeoIP 資料庫更新用(選用)
REDIS_URL=redis://localhost:6379 # Redis 連線(選用,目前未使用)
3. 資料結構與型別定義
資料庫 Schema
-- 短連結主表
CREATE TABLE links (
id TEXT PRIMARY KEY DEFAULT cuid(),
slug TEXT UNIQUE NOT NULL, -- 短碼,如 "abc123"
target_url TEXT NOT NULL, -- 目標網址
title TEXT, -- 自訂標題(選用)
tag TEXT, -- 分組標籤(選用)
expires_at TIMESTAMP, -- 到期日(選用)
click_limit INTEGER, -- 點擊上限(選用)
last_click_at TIMESTAMP, -- 最近一次點擊時間
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW()
);
-- 點擊記錄表
CREATE TABLE clicks (
id TEXT PRIMARY KEY DEFAULT cuid(),
link_id TEXT NOT NULL REFERENCES links(id) ON DELETE CASCADE,
timestamp TIMESTAMP DEFAULT NOW(),
referrer TEXT, -- 來源網址
user_agent TEXT NOT NULL, -- 原始 UA 字串
country TEXT, -- 國家 ISO code(如 "TW")
city TEXT, -- 城市名稱
device TEXT -- 裝置類型(desktop/mobile/tablet)
);
Prisma Schema(等效定義)
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
model Link {
id String @id @default(cuid())
slug String @unique
targetUrl String @map("target_url")
title String?
tag String?
expiresAt DateTime? @map("expires_at")
clickLimit Int? @map("click_limit")
lastClickAt DateTime? @map("last_click_at")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
clicks Click[]
@@map("links")
}
model Click {
id String @id @default(cuid())
linkId String @map("link_id")
timestamp DateTime @default(now())
referrer String?
userAgent String @map("user_agent")
country String?
city String?
device String?
link Link @relation(fields: [linkId], references: [id], onDelete: Cascade)
@@map("clicks")
}
核心型別
// 建立短連結的輸入
interface CreateLinkInput {
targetUrl: string
customSlug?: string
title?: string
tag?: string
expiresAt?: string // ISO 8601 datetime
clickLimit?: number
}
// 連結列表的回傳格式
interface LinkWithStats {
id: string
slug: string
targetUrl: string
title: string | null
tag: string | null
expiresAt: string | null
clickLimit: number | null
lastClickAt: string | null
createdAt: string
clickCount: number
}
// 分析數據的回傳格式
interface AnalyticsResponse {
link: {
id: string
slug: string
targetUrl: string
createdAt: string
totalClicks: number
}
analytics: {
period: string // "7 days"
timeline: { date: string; clicks: number }[]
topReferrers: { domain: string; count: number }[]
deviceStats: { device: string; count: number }[]
countryStats: { country: string; count: number }[]
cityStats: { city: string; count: number }[]
hourlyStats: { hour: number; count: number }[] // 0-23
totalClicksInPeriod: number
}
}
4. 一步步實作
這部分是整個功能的核心,每個 Step 的程式碼都能獨立理解,建議照順序走一遍。
Step 1:Prisma Client Singleton
目的:在 Next.js 開發模式下避免 hot reload 建立多個 Prisma Client 連線。
// src/lib/db.ts
import { PrismaClient } from '@prisma/client'
const globalForPrisma = globalThis as unknown as {
prisma: PrismaClient | undefined
}
export const db =
globalForPrisma.prisma ??
new PrismaClient({ log: ['query'] })
if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = db
- 這是 Next.js + Prisma 的標準 pattern,幾乎每個專案都需要
- production 環境不快取到 globalThis,因為不會有 hot reload 問題
Step 2:Slug 產生與短連結建立
目的:產生短網址的隨機短碼,或驗證使用者自訂的 slug 是否可用,然後寫入資料庫。
import { nanoid } from 'nanoid'
import { db } from './db'
// 產生隨機 slug,預設 6 碼(約 568 億種組合)
export function generateSlug(length: number = 6): string {
return nanoid(length)
}
// 檢查 slug 是否已被使用
export async function isSlugAvailable(slug: string): Promise<boolean> {
const existing = await db.link.findUnique({
where: { slug }
})
return !existing
}
// 建立短連結
export async function createShortUrl(
targetUrl: string,
customSlug?: string,
options?: {
title?: string
tag?: string
expiresAt?: Date
clickLimit?: number
}
): Promise<{ slug: string; id: string }> {
let slug = customSlug
if (slug) {
// 自訂 slug:檢查是否可用
const isAvailable = await isSlugAvailable(slug)
if (!isAvailable) {
throw new Error('Slug already exists')
}
} else {
// 自動產生:迴圈產生直到找到可用的 slug
do {
slug = generateSlug()
} while (!(await isSlugAvailable(slug)))
}
const link = await db.link.create({
data: {
slug,
targetUrl,
title: options?.title,
tag: options?.tag,
expiresAt: options?.expiresAt,
clickLimit: options?.clickLimit
}
})
return { slug, id: link.id }
}
- nanoid 6 碼在連結量不大時碰撞機率極低,但 do-while 迴圈確保了安全性
- slug 欄位在 DB 上必須有 unique index,Prisma schema 裡的
@unique 會自動建立
- 如果需要更短或更長的 slug,調整
generateSlug 的 length 參數即可
Step 3:短連結轉址(302 Redirect)
目的:當使用者造訪 yourdomain.com/:slug 時,查詢對應的目標網址並 302 轉址,同時非同步記錄點擊。
// src/app/[slug]/route.ts
import { NextRequest, NextResponse } from 'next/server'
import { db } from '@/lib/db'
import { recordClick } from '@/lib/short-url'
export async function GET(
request: NextRequest,
context: { params: Promise<{ slug: string }> }
) {
try {
const { slug } = await context.params
const link = await db.link.findUnique({
where: { slug }
})
if (!link) {
const host = request.headers.get('host')
const protocol = request.headers.get('x-forwarded-proto') || 'https'
const baseUrl = host ? `${protocol}://${host}` : new URL(request.url).origin
return NextResponse.redirect(new URL('/404', baseUrl))
}
// 關鍵:非同步記錄點擊,不阻塞轉址回應
recordClick(link.id, request).catch(error => {
console.error('Failed to record click:', error)
})
// 302 暫時轉址
return NextResponse.redirect(link.targetUrl, { status: 302 })
} catch (error) {
console.error('Redirect error:', error)
const host = request.headers.get('host')
const protocol = request.headers.get('x-forwarded-proto') || 'https'
const baseUrl = host ? `${protocol}://${host}` : new URL(request.url).origin
return NextResponse.redirect(new URL('/404', baseUrl))
}
}
- 必須用 302 不是 301。301 會被瀏覽器快取,之後的點擊就不會再經過你的 server,點擊追蹤就失效了
recordClick 用 fire-and-forget 模式(呼叫後不 await),轉址回應不會被記錄邏輯拖慢
[slug] 是 Next.js App Router 的動態路由,會匹配所有非靜態路徑
Step 4:點擊追蹤(User-Agent 解析 + GeoIP)
目的:從每次點擊的 HTTP 請求中,解析出裝置類型、來源網址、地理位置,然後寫入 clicks 表。
import { UAParser } from 'ua-parser-js'
import { db } from './db'
// ---------- User-Agent 解析 ----------
export function parseUserAgent(userAgent: string) {
const parser = new UAParser(userAgent)
const result = parser.getResult()
let deviceType: string = result.device.type || 'unknown'
// ua-parser-js 對桌面裝置的 device.type 會回傳 undefined,需要用關鍵字補判斷
if (deviceType === 'unknown') {
const mobileKeywords = ['Mobile', 'Android', 'iPhone', 'iPod', 'BlackBerry', 'Opera Mini', 'Windows Phone']
const tabletKeywords = ['iPad', 'Tablet', 'Kindle']
const desktopKeywords = ['Macintosh', 'Windows NT', 'Linux', 'CrOS']
const isMobile = mobileKeywords.some(kw => userAgent.includes(kw))
const isTablet = tabletKeywords.some(kw => userAgent.includes(kw))
const isDesktop = desktopKeywords.some(kw => userAgent.includes(kw))
if (isTablet) deviceType = 'tablet'
else if (isMobile && !isDesktop) deviceType = 'mobile'
else deviceType = 'desktop' // 預設為 desktop(更保守的判斷)
}
return {
device: deviceType,
browser: result.browser.name,
os: result.os.name
}
}
// ---------- GeoIP 地理位置查詢(多層降級策略) ----------
// 條件式載入 geoip-lite(選用依賴)
let geoip: typeof import('geoip-lite') | null = null
const GEOIP_ENABLED = process.env.ENABLE_GEOIP_LOOKUP !== 'false'
if (GEOIP_ENABLED) {
try {
geoip = require('geoip-lite')
// 驗證 geoip 是否真的可用
if (geoip) {
const testResult = geoip.lookup('8.8.8.8')
if (!testResult) {
geoip = null // 資料檔缺失,降級處理
}
}
} catch {
geoip = null
}
}
// 從 CDN headers 取得地理位置(優先級最高)
function getGeoFromHeaders(request: Request): { country: string | null; city: string | null } {
// 各平台的 header 名稱不同,按你的部署環境調整
const country = request.headers.get('x-zeabur-ip-country') // Zeabur
|| request.headers.get('cf-ipcountry') // Cloudflare
|| request.headers.get('x-vercel-ip-country') // Vercel
if (country && country !== 'XX') {
return { country, city: null }
}
return { country: null, city: null }
}
// 從 IP 查詢地理位置(geoip-lite)
function getLocationFromIP(ip: string): { country: string | null; city: string | null } {
// 過濾本地 IP
if (ip === '127.0.0.1' || ip === '::1' || ip.startsWith('192.168.') || ip.startsWith('10.') || ip.startsWith('172.')) {
return { country: null, city: null }
}
if (!geoip) return { country: null, city: null }
const geo = geoip.lookup(ip)
return {
country: geo?.country || null,
city: geo?.city || null
}
}
// 整合:CDN headers > geoip-lite > null
export function getLocationFromRequest(request: Request): { country: string | null; city: string | null } {
// 優先級 1: CDN headers
const headerGeo = getGeoFromHeaders(request)
if (headerGeo.country) return headerGeo
// 優先級 2: geoip-lite 本地查詢
if (GEOIP_ENABLED && geoip) {
const forwarded = request.headers.get('x-forwarded-for')
const realIp = request.headers.get('x-real-ip')
const ip = forwarded?.split(',')[0] || realIp || '127.0.0.1'
return getLocationFromIP(ip)
}
// 優先級 3: 優雅降級
return { country: null, city: null }
}
// ---------- 記錄點擊 ----------
export async function recordClick(linkId: string, request: Request) {
const userAgent = request.headers.get('user-agent') || ''
const referrer = request.headers.get('referer') || null
const { device } = parseUserAgent(userAgent)
const { country, city } = getLocationFromRequest(request)
// 同時寫入 Click 記錄 + 更新連結的最近點擊時間
await Promise.all([
db.click.create({
data: {
linkId,
referrer,
userAgent,
device,
country,
city,
}
}),
db.link.update({
where: { id: linkId },
data: { lastClickAt: new Date() }
})
])
}
- ua-parser-js 對桌面瀏覽器會回傳
device.type = undefined,這是已知行為,所以需要自己用關鍵字補判斷
- GeoIP 整個是選用的。沒裝 geoip-lite 或沒有 MaxMind 授權,系統照跑,地理欄位就是 null
- CDN headers 的優先權最高,因為它們比 geoip-lite 本地資料庫更準確且即時
Promise.all 同時寫 click 和更新 lastClickAt,減少等待時間
Step 5:連結列表查詢(含排序)
目的:列出所有短連結,包含點擊數統計,支援多欄位排序。
// GET /api/links?sort=createdAt&order=desc
export async function GET(request: NextRequest) {
const { searchParams } = new URL(request.url)
const sortBy = searchParams.get('sort') || 'createdAt'
const order = searchParams.get('order') || 'desc'
// 定義可排序的欄位
const sortableFields: Record<string, string | undefined> = {
createdAt: 'createdAt',
lastClickAt: 'lastClickAt',
title: 'title',
tag: 'tag',
expiresAt: 'expiresAt',
clickCount: undefined // 特殊處理:透過關聯計數排序
}
let orderBy: Record<string, string | Record<string, string>> = { createdAt: 'desc' }
if (sortBy === 'clickCount') {
// Prisma 支援用關聯的 _count 排序
orderBy = { clicks: { _count: order as 'asc' | 'desc' } }
} else if (sortableFields[sortBy]) {
orderBy = { [sortableFields[sortBy]!]: order as 'asc' | 'desc' }
}
const links = await db.link.findMany({
orderBy,
include: {
_count: { select: { clicks: true } }
}
})
// 把 Prisma 的 _count 格式轉成乾淨的 clickCount
const linksWithStats = links.map(link => ({
id: link.id,
slug: link.slug,
targetUrl: link.targetUrl,
title: link.title,
tag: link.tag,
expiresAt: link.expiresAt,
clickLimit: link.clickLimit,
lastClickAt: link.lastClickAt,
createdAt: link.createdAt,
clickCount: link._count.clicks
}))
return NextResponse.json(linksWithStats)
}
- Prisma 的
_count 排序語法比較特殊,{ clicks: { _count: 'desc' } } 這個格式需要注意
- 回傳時把
_count.clicks 攤平成 clickCount,前端用起來比較方便
Step 6:建立短連結 API
目的:接收前端的建立請求,驗證 URL 格式,呼叫核心的 createShortUrl 函式。
// POST /api/links
export async function POST(request: NextRequest) {
const { targetUrl, customSlug, title, tag, expiresAt, clickLimit } = await request.json()
if (!targetUrl) {
return NextResponse.json({ error: 'Target URL is required' }, { status: 400 })
}
// URL 格式驗證
try {
new URL(targetUrl)
} catch {
return NextResponse.json({ error: 'Invalid URL format' }, { status: 400 })
}
try {
const result = await createShortUrl(targetUrl, customSlug, {
title,
tag,
expiresAt: expiresAt ? new Date(expiresAt) : undefined,
clickLimit: clickLimit ? parseInt(clickLimit) : undefined
})
return NextResponse.json({
id: result.id,
slug: result.slug,
shortUrl: `${getBaseUrl()}/${result.slug}`,
targetUrl,
title,
tag,
expiresAt,
clickLimit
})
} catch (error) {
if (error instanceof Error && error.message === 'Slug already exists') {
return NextResponse.json({ error: 'Custom slug already exists' }, { status: 400 })
}
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
}
Step 7:刪除短連結 API
// DELETE /api/links/:id
export async function DELETE(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params
// 檢查連結是否存在
const existingLink = await db.link.findUnique({
where: { id }
})
if (!existingLink) {
return NextResponse.json({ error: 'Link not found' }, { status: 404 })
}
// 手動刪除點擊記錄,再刪除連結
// (雖然 Prisma schema 設了 onDelete: Cascade,但手動刪更明確)
await db.click.deleteMany({ where: { linkId: id } })
await db.link.delete({ where: { id } })
return NextResponse.json({ success: true, message: 'Link deleted successfully' })
}
Step 8:分析數據 API
目的:根據時間範圍查詢某條連結的點擊數據,聚合出時間趨勢、來源、裝置、地區、小時分佈。
老實說這是整個功能裡程式碼最長的一段,但邏輯不複雜,就是把 clicks 表的原始資料用不同維度做 group by。
// GET /api/links/:id/analytics?days=7&timezone=Asia/Taipei
export async function GET(
request: NextRequest,
context: { params: Promise<{ id: string }> }
) {
const { id } = await context.params
// 取得連結基本資訊 + 總點擊數
const link = await db.link.findUnique({
where: { id },
include: { _count: { select: { clicks: true } } }
})
if (!link) {
return NextResponse.json({ error: 'Link not found' }, { status: 404 })
}
const { searchParams } = new URL(request.url)
const days = parseInt(searchParams.get('days') || '7')
const timezone = searchParams.get('timezone') || 'UTC'
const startDate = new Date()
startDate.setDate(startDate.getDate() - days)
// 取出時間範圍內的所有 clicks
const clicks = await db.click.findMany({
where: {
linkId: id,
timestamp: { gte: startDate }
},
orderBy: { timestamp: 'asc' }
})
// --- 1. 按日期分組(考慮使用者時區) ---
const clicksByDate: Record<string, number> = {}
clicks.forEach(click => {
// sv-SE locale 會輸出 YYYY-MM-DD 格式
const date = click.timestamp.toLocaleDateString('sv-SE', { timeZone: timezone })
clicksByDate[date] = (clicksByDate[date] || 0) + 1
})
// 補齊沒有點擊的日期為 0,讓前端畫圖表時不會斷線
const timeline = []
for (let i = days - 1; i >= 0; i--) {
const date = new Date()
date.setDate(date.getDate() - i)
const dateStr = date.toISOString().split('T')[0]
timeline.push({ date: dateStr, clicks: clicksByDate[dateStr] || 0 })
}
// --- 2. 來源網域統計(取 top 10) ---
const referrerCounts: Record<string, number> = {}
clicks.forEach(click => {
if (click.referrer) {
try {
const domain = new URL(click.referrer).hostname
referrerCounts[domain] = (referrerCounts[domain] || 0) + 1
} catch {
referrerCounts['Direct'] = (referrerCounts['Direct'] || 0) + 1
}
} else {
referrerCounts['Direct'] = (referrerCounts['Direct'] || 0) + 1
}
})
const topReferrers = Object.entries(referrerCounts)
.sort(([, a], [, b]) => b - a)
.slice(0, 10)
.map(([domain, count]) => ({ domain, count }))
// --- 3. 裝置類型統計 ---
const deviceCounts: Record<string, number> = {}
clicks.forEach(click => {
const device = click.device || 'unknown'
deviceCounts[device] = (deviceCounts[device] || 0) + 1
})
const deviceStats = Object.entries(deviceCounts)
.map(([device, count]) => ({ device, count }))
// --- 4. 國家統計(取 top 10) ---
const countryCounts: Record<string, number> = {}
clicks.forEach(click => {
const country = click.country || 'Unknown'
countryCounts[country] = (countryCounts[country] || 0) + 1
})
const countryStats = Object.entries(countryCounts)
.sort(([, a], [, b]) => b - a)
.slice(0, 10)
.map(([country, count]) => ({ country, count }))
// --- 5. 城市統計(取 top 10) ---
const cityCounts: Record<string, number> = {}
clicks.forEach(click => {
const city = click.city || 'Unknown'
cityCounts[city] = (cityCounts[city] || 0) + 1
})
const cityStats = Object.entries(cityCounts)
.sort(([, a], [, b]) => b - a)
.slice(0, 10)
.map(([city, count]) => ({ city, count }))
// --- 6. 小時分佈(0-23,考慮使用者時區) ---
const hourlyCounts: Record<number, number> = {}
clicks.forEach(click => {
const userTime = new Date(click.timestamp.toLocaleString('en-US', { timeZone: timezone }))
const hour = userTime.getHours()
hourlyCounts[hour] = (hourlyCounts[hour] || 0) + 1
})
const hourlyStats = Array.from({ length: 24 }, (_, hour) => ({
hour,
count: hourlyCounts[hour] || 0
}))
return NextResponse.json({
link: {
id: link.id,
slug: link.slug,
targetUrl: link.targetUrl,
createdAt: link.createdAt,
totalClicks: link._count.clicks
},
analytics: {
period: `${days} days`,
timeline,
topReferrers,
deviceStats,
countryStats,
cityStats,
hourlyStats,
totalClicksInPeriod: clicks.length
}
})
}
toLocaleDateString('sv-SE', { timeZone }) 是一個常用技巧,sv-SE locale 會輸出 YYYY-MM-DD 格式,省去手動格式化
- 所有統計都在 application layer 做(不是 SQL aggregation)。對資料量不大的場景足夠用,如果點擊量很大可以考慮改成 SQL 層的 GROUP BY
- 補齊日期為 0 這步很重要,前端畫折線圖時才不會出現斷點
Step 9:CSV 匯出
目的:將某條連結的所有點擊記錄匯出為 CSV 檔案下載。
// GET /api/links/:id/export
export async function GET(
request: NextRequest,
context: { params: Promise<{ id: string }> }
) {
const { id } = await context.params
const link = await db.link.findUnique({
where: { id },
include: {
clicks: { orderBy: { timestamp: 'desc' } }
}
})
if (!link) {
return NextResponse.json({ error: 'Link not found' }, { status: 404 })
}
// 組裝 CSV
const csvHeaders = ['Timestamp', 'Referrer', 'Device', 'Country', 'City', 'User Agent']
const csvRows = link.clicks.map(click => [
click.timestamp.toISOString(),
click.referrer || 'Direct',
click.device || 'Unknown',
click.country || 'Unknown',
click.city || 'Unknown',
click.userAgent.replace(/"/g, '""') // CSV 標準:雙引號用兩個雙引號跳脫
])
const csvContent = [
csvHeaders.join(','),
...csvRows.map(row => row.map(cell => `"${cell}"`).join(','))
].join('\n')
return new Response(csvContent, {
headers: {
'Content-Type': 'text/csv',
'Content-Disposition': `attachment; filename="${link.slug}-analytics.csv"`
}
})
}
5. 快速開始
前提條件
實作步驟
-
安裝依賴
pnpm add nanoid ua-parser-js @prisma/client
pnpm add -D prisma @types/ua-parser-js
# 選用:GeoIP 支援
pnpm add geoip-lite
pnpm add -D @types/geoip-lite
-
設定環境變數
DATABASE_URL=postgresql://user:password@localhost:5432/shortlinks
# 選用
ENABLE_GEOIP_LOOKUP=true
MAXMIND_LICENSE_KEY=your-maxmind-key
-
初始化資料庫
# 建立 prisma/schema.prisma(參照 Section 3 的 Prisma Schema)
npx prisma db push # 直接同步 schema 到 DB
npx prisma generate # 產生 Prisma Client
-
建立核心檔案
按以下順序:
src/lib/db.ts — Prisma Client singleton(Step 1)
src/lib/short-url.ts — slug 產生 + UA 解析 + GeoIP + 點擊記錄(Step 2 + 4)
src/app/[slug]/route.ts — 轉址 handler(Step 3)
src/app/api/links/route.ts — 連結列表 + 建立(Step 5 + 6)
src/app/api/links/[id]/route.ts — 連結刪除(Step 7)
src/app/api/links/[id]/analytics/route.ts — 分析數據(Step 8)
src/app/api/links/[id]/export/route.ts — CSV 匯出(Step 9)
-
整合到你的專案
- 轉址的關鍵是
[slug]/route.ts 這個動態路由,它會攔截所有匹配的路徑
- API 的認證保護需要你自己接上你專案的認證機制(這份 PRD 不包含認證部分)
- 前端管理介面自由設計,只要呼叫上面這些 API 即可
-
測試驗證
6. 不同技術棧的調整方式
如果你的技術棧跟上面不完全一樣,這邊整理了需要調整的部分。
高度通用的部分(直接用)
- slug 產生邏輯(nanoid + 唯一性迴圈)
- User-Agent 解析邏輯(ua-parser-js + 關鍵字補判斷)
- GeoIP 多層降級策略(CDN headers > 本地查詢 > null)
- 分析數據的聚合邏輯(按日期/來源/裝置/地區分組統計)
- CSV 匯出的格式處理
需要客製化的部分
| 部分 | 原實作 | 新專案需要調整的地方 |
|---|
| 轉址 handler | Next.js App Router [slug]/route.ts | 改成你框架的路由(Express: app.get('/:slug')) |
| DB 操作 | Prisma ORM (PostgreSQL) | 換成你用的 ORM(Drizzle、TypeORM、Knex、raw SQL) |
| API 路由 | Next.js Route Handlers | 換成你框架的 API 機制(Express routes、Fastify 等) |
| GeoIP CDN headers | 讀 x-zeabur-ip-country | 換成你部署平台的 header(Cloudflare: cf-ipcountry,Vercel: x-vercel-ip-country) |
技術棧替換建議
- 如果你用 Express 而非 Next.js:轉址用
app.get('/:slug', handler),API 用 app.post('/api/links', handler),其餘邏輯完全一樣
- 如果你用 MySQL 而非 PostgreSQL:Prisma schema 的
provider 改成 mysql,cuid 改成 UUID,其他不變
- 如果你用 Drizzle 而非 Prisma:Schema 定義語法不同,但資料表結構和查詢邏輯完全一樣
- 如果你不需要 GeoIP:直接拿掉
geoip-lite 依賴,recordClick 裡的 country/city 固定傳 null
- 如果你想加 Redis 快取:可以在
[slug]/route.ts 的查詢前加一層 Redis cache,key 用 link:${slug},設個 5 分鐘 TTL,cache miss 時再查 DB
結語
這套短連結功能的核心就三個動作:nanoid 產生短碼、Route Handler 做 302 轉址、每次轉址時非同步寫一筆 Click 記錄。分析的部分是在 application layer 做聚合,不需要複雜的 SQL。整體架構蠻輕量的,如果你只是要在現有專案裡加一個短連結功能,挑你需要的部分搬過去就好。
© 2026 GEEKAZ. All Rights Reserved.