Appearance
营销短链怎么按访客地区跳转到不同落地页?PHP 实现地域智能跳转
一次投放,北方和南方要推不同的活动页,港澳台用户还得看到另一个版本。运营不可能为每个地区单独做一张二维码——线下物料上挂着四个二维码,扫码率先掉一半。
第三方短链平台基本都有「地域智能跳转」的功能,后台点几下就能配。但如果规则要跟自己的预约系统联动、要防止同行刷点击、要自己拿一手数据,那就得自建一个。这篇文章讲清楚自建时真正会踩的坑,以及一份能直接跑的 PHP 实现。
一、先看清约束
一个营销短链的跳转链路是这样的:用户点开链接 → 服务端判断他的地区 → 匹配规则 → 302 跳到目标页 → 记一条点击。
看起来简单,实际约束有三条:
跳转必须够快。 用户点一个短链,超过几百毫秒就是明显的白屏感。而一次归属地查询本身就要两百多毫秒。这就决定了缓存不是优化项,是必需项。
突发明显。 一条短信或一篇公众号推文发出去,几百个点击可能集中在几分钟内。这时如果每个点击都去调一次归属地接口,免费版 60 次/分钟的量级立刻超限。
链接会被分享。 给南方用户生成的链接,被转到北方的群里,跳过去就是错的地区。这不是 bug,是必须提前留的兜底口子。
二、几个设计决定,比代码本身重要
用 302,别用 301。 这是最容易犯的错。301 是永久重定向,浏览器、CDN、甚至运营商缓存都可能把它记下来——A 地区用户访问过后缓存了,B 地区用户拿到的是同一个缓存,跳转规则直接失效。地域跳转必须用 302(或 307),并且显式加 Cache-Control: no-store。
爬虫和预取直接给默认页。 微信、QQ、浏览器都会提前拉取链接做预览,还有一些爬虫专门扫短链。这些请求去查归属地纯属浪费额度,还会把点击统计搞脏。判断方法很便宜:不是 GET 请求就当预取;UA 里带 bot/spider/crawler/python-requests 之类特征就跳过;Purpose、X-Purpose、Sec-Purpose 这些头里带 prefetch 也跳过。
缓存要分三种 TTL。 正常结果给 1 天(同一个 IP 一天内的归属地几乎不会变);「查得到但省市为空」的情况给 10 分钟,别让它占着缓存;接口报错或被限速,只给 30 秒负缓存,避免接口抖动时几千个请求一起打过去。
查不到归属地就跳默认页。 这是整个服务最重要的一条:归属地接口超时、限速、返回空,用户都必须能正常跳到默认落地页。任何情况下都不能因为「地区没查到」把用户卡在原地。
日志先跳后写。 跳转和记点击是两件事,记日志失败不能影响跳转。IP 这类信息别原样长期留着——存省份和城市用于统计就够了,需要按 IP 去重的话存一个哈希值。
留一个手动覆盖的口子。 链接带上 ?r=gd 这样的参数时,直接按参数指定的地区跳,无视自动判断。运营自查要用,用户跨地区分享时也能自救。
三、表结构
sql
-- 短链表
CREATE TABLE short_link (
code VARCHAR(16) PRIMARY KEY,
default_url VARCHAR(512) NOT NULL, -- 兜底落地页
status TINYINT NOT NULL DEFAULT 1,
created_at DATETIME NOT NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- 地域规则表:scope 支持 prov(省)/ big_area(华东、华南这类大区)/ country(国家地区)
CREATE TABLE link_region_rule (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
code VARCHAR(16) NOT NULL,
scope ENUM('prov','big_area','country') NOT NULL,
match_value VARCHAR(32) NOT NULL, -- 江苏 / 华东 / 中国香港
target_url VARCHAR(512) NOT NULL,
priority INT NOT NULL DEFAULT 0,-- 同 scope 下数值大的优先
KEY idx_code_scope (code, scope, priority)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- 点击日志:只留归属地和 IP 哈希,不留原始 IP
CREATE TABLE link_click (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
code VARCHAR(16) NOT NULL,
clicked_at DATETIME NOT NULL,
region_country VARCHAR(32) DEFAULT '',
region_prov VARCHAR(32) DEFAULT '',
region_city VARCHAR(32) DEFAULT '',
ip_hash CHAR(32) DEFAULT '', -- md5(ip + 每日盐),仅用于去重统计
KEY idx_code_time (code, clicked_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;四、PHP 实现
一个文件搞定:读短链、查归属地(缓存优先)、匹配规则、302 跳转、异步记日志。
php
<?php
declare(strict_types=1);
// ---------------- 配置 ----------------
const GEO_API = 'https://ip9.com.cn/get?ip=';
const GEO_TTL = 86400; // 归属地正常缓存 1 天
const GEO_EMPTY = 600; // 省市为空(内网/保留地址)缓存 10 分钟
const GEO_FAIL = 30; // 接口失败负缓存 30 秒
const GEO_UA = 'geo-redirect/1.0';
const DSN = 'mysql:host=127.0.0.1;dbname=shortlink;charset=utf8mb4';
const DB_USER = 'shortlink';
const DB_PASS = 'your_password';
const IP_SALT = 'change_me_daily_salt';
// ---------------- 入口 ----------------
$code = isset($_GET['c']) ? (string)$_GET['c'] : '';
if (!preg_match('/^[A-Za-z0-9]{4,16}$/', $code)) {
http_response_code(404);
exit('link not found');
}
$link = load_link($code);
if (!$link) { http_response_code(404); exit('link not found'); }
// 爬虫、预取、非 GET:直接给默认页,不查归属地
if (is_prefetch()) { redirect_to($link['default_url']); }
$region = geo_region(client_ip());
// 手动覆盖优先:?r=gd 或 ?r=江苏
$override = isset($_GET['r']) ? trim((string)$_GET['r']) : '';
$target = $override !== '' ? match_override($code, $override, $link['default_url'])
: pick_target($code, $region, $link['default_url']);
log_click_async($code, $region, client_ip());
redirect_to(with_utm($target, $code));
// ---------------- 客户端信息 ----------------
function client_ip(): string {
// Nginx 侧配好 set_real_ip_from + real_ip_header 后,REMOTE_ADDR 就是真实用户 IP
$ip = $_SERVER['REMOTE_ADDR'] ?? '';
if (!filter_var($ip, FILTER_VALIDATE_IP)) {
// 没配 real_ip 模块时的降级取法:只当反向代理在自己网段内时才可信
$xff = $_SERVER['HTTP_X_FORWARDED_FOR'] ?? '';
if ($xff !== '') {
$first = trim(explode(',', $xff)[0]);
if (filter_var($first, FILTER_VALIDATE_IP)) { $ip = $first; }
}
}
return filter_var($ip, FILTER_VALIDATE_IP) ? $ip : '';
}
function is_prefetch(): bool {
if (($_SERVER['REQUEST_METHOD'] ?? 'GET') !== 'GET') { return true; }
foreach (['HTTP_PURPOSE', 'HTTP_X_PURPOSE', 'HTTP_SEC_PURPOSE', 'HTTP_X_MOZ'] as $h) {
if (!empty($_SERVER[$h]) && stripos($_SERVER[$h], 'prefetch') !== false) { return true; }
}
$ua = $_SERVER['HTTP_USER_AGENT'] ?? '';
if ($ua === '') { return true; } // 无 UA 的多半是脚本
return (bool)preg_match('/bot|spider|crawler|slurp|curl|wget|python-requests|headless/i', $ua);
}
// ---------------- 归属地:缓存优先,失败不抛异常 ----------------
function cache_get(string $key): ?string {
if (class_exists('Redis')) {
$r = new Redis();
if (@$r->connect('127.0.0.1', 6379, 0.2)) {
$v = $r->get($key);
return $v === false ? null : (string)$v;
}
}
// 没装 Redis 时用文件缓存顶住;量大了一定要换 Redis
$f = sys_get_temp_dir() . '/geo_' . md5($key) . '.cache';
if (is_file($f) && filemtime($f) + 3600 > time()) {
$raw = (string)file_get_contents($f);
$pos = strpos($raw, "\n");
if ($pos !== false && (int)substr($raw, 0, $pos) > time()) {
return substr($raw, $pos + 1);
}
}
return null;
}
function cache_set(string $key, string $val, int $ttl): void {
if (class_exists('Redis')) {
$r = new Redis();
if (@$r->connect('127.0.0.1', 6379, 0.2)) { $r->setex($key, $ttl, $val); return; }
}
@file_put_contents(sys_get_temp_dir() . '/geo_' . md5($key) . '.cache',
(time() + $ttl) . "\n" . $val, LOCK_EX);
}
function http_get_json(string $url, int $timeoutMs = 2500): ?array {
if (function_exists('curl_init')) {
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT_MS => $timeoutMs,
CURLOPT_CONNECTTIMEOUT_MS => 800,
CURLOPT_USERAGENT => GEO_UA, // 显式设 UA,别用库的默认值
CURLOPT_FOLLOWLOCATION => false,
]);
$body = curl_exec($ch);
$code = (int)curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
curl_close($ch);
if ($body === false || $code !== 200) { return null; }
} else {
// 极简环境(没编译 curl 扩展)的降级写法
$ctx = stream_context_create(['http' => [
'method' => 'GET',
'header' => "User-Agent: " . GEO_UA . "\r\n",
'timeout' => $timeoutMs / 1000,
'ignore_errors' => true,
]]);
$body = @file_get_contents($url, false, $ctx);
if ($body === false || (int)explode(' ', $http_response_header[0])[1] !== 200) { return null; }
}
$json = json_decode((string)$body, true);
return is_array($json) ? $json : null;
}
/**
* 返回 ['country' => .., 'prov' => .., 'city' => .., 'big_area' => ..]
* 拿不到任何位置时返回空数组
*/
function geo_region(string $ip): array {
if ($ip === '') { return []; }
$key = 'geo:' . $ip;
$cached = cache_get($key);
if ($cached !== null) {
return $cached === '' ? [] : (array)json_decode($cached, true);
}
$json = http_get_json(GEO_API . urlencode($ip));
if ($json === null || (int)($json['ret'] ?? 0) !== 200) {
cache_set($key, '', GEO_FAIL); // 失败负缓存:30 秒内不再打接口
return [];
}
$d = $json['data'] ?? [];
$country = (string)($d['country'] ?? '');
// 内网、保留地址、回环:"查得到但没位置",country 会写成「保留」
// 海外 IP 则相反:prov 是空的,country 才是关键字段,不能一起丢掉
$is_foreign = ($country !== '' && !in_array($country, ['中国', '保留'], true));
if (empty($d['prov']) && !$is_foreign) {
cache_set($key, '', GEO_EMPTY);
return [];
}
$region = [
'country' => $country,
'prov' => (string)($d['prov'] ?? ''),
'city' => (string)($d['city'] ?? ''),
'big_area' => (string)($d['big_area'] ?? ''),
];
cache_set($key, json_encode($region, JSON_UNESCAPED_UNICODE), GEO_TTL);
return $region;
}
// ---------------- 规则匹配:省 > 大区 > 国家/地区 > 默认 ----------------
function load_link(string $code): ?array {
$pdo = new PDO(DSN, DB_USER, DB_PASS, [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION]);
$st = $pdo->prepare('SELECT code, default_url FROM short_link WHERE code = ? AND status = 1');
$st->execute([$code]);
$row = $st->fetch(PDO::FETCH_ASSOC);
return $row ?: null;
}
function rules_by_code(string $code): array {
$pdo = new PDO(DSN, DB_USER, DB_PASS, [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION]);
$st = $pdo->prepare('SELECT scope, match_value, target_url FROM link_region_rule
WHERE code = ? ORDER BY priority DESC');
$st->execute([$code]);
$rules = ['prov' => [], 'big_area' => [], 'country' => []];
foreach ($st->fetchAll(PDO::FETCH_ASSOC) as $r) {
$rules[$r['scope']][$r['match_value']] = $r['target_url'];
}
return $rules;
}
function pick_target(string $code, array $region, string $default): string {
if (!$region) { return $default; } // 归属地缺失一律走默认页
$rules = rules_by_code($code);
// 港澳台和海外单独处理:这些情况 prov 是空的,只能靠 country 匹配
if (($region['country'] ?? '中国') !== '中国') {
$c = $region['country'];
if (isset($rules['country'][$c])) { return $rules['country'][$c]; }
if (isset($rules['country']['海外'])) { return $rules['country']['海外']; }
return $default;
}
if (!empty($region['prov']) && isset($rules['prov'][$region['prov']])) {
return $rules['prov'][$region['prov']];
}
if (!empty($region['big_area']) && isset($rules['big_area'][$region['big_area']])) {
return $rules['big_area'][$region['big_area']];
}
return $default;
}
function match_override(string $code, string $value, string $default): string {
$rules = rules_by_code($code);
foreach (['prov', 'big_area', 'country'] as $scope) {
if (isset($rules[$scope][$value])) { return $rules[$scope][$value]; }
}
return $default;
}
// ---------------- 跳转与日志 ----------------
function redirect_to(string $url): void {
if (!preg_match('#^https?://#i', $url)) { $url = 'https://' . ltrim($url, '/'); }
header('Cache-Control: no-store, no-cache, must-revalidate'); // 302 也要避免被缓存
header('Location: ' . $url, true, 302);
exit;
}
function with_utm(string $url, string $code): string {
$sep = strpos($url, '?') === false ? '?' : '&';
return $url . $sep . 'utm_source=short&sl=' . rawurlencode($code);
}
function log_click_async(string $code, array $region, string $ip): void {
// 同一 IP 10 秒内只记一条,防止刷点击把日志和额度打满
$k = 'clk:' . md5($ip . $code);
if (cache_get($k) !== null) { return; }
cache_set($k, '1', 10);
try {
$pdo = new PDO(DSN, DB_USER, DB_PASS, [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION]);
$st = $pdo->prepare('INSERT INTO link_click
(code, clicked_at, region_country, region_prov, region_city, ip_hash)
VALUES (?, NOW(), ?, ?, ?, ?)');
$st->execute([
$code,
$region['country'] ?? '',
$region['prov'] ?? '',
$region['city'] ?? '',
substr(md5($ip . IP_SALT), 0, 32), // 只留哈希,不留原始 IP
]);
} catch (Throwable $e) {
error_log('log_click failed: ' . $e->getMessage()); // 记日志失败不影响跳转
}
}Nginx 那边加一条规则,让 REMOTE_ADDR 变成真实用户 IP(只信任自己链路里的代理地址):
nginx
set_real_ip_from 10.0.0.0/8;
real_ip_header X-Forwarded-For;
real_ip_recursive on;顺带说一句:load_link() 和 rules_by_code() 每个请求各查一次库,几十个 QPS 没问题。真到高并发,把短链和规则一起放进 Redis(改规则时删键即可),数据库只留着做持久化。
五、上线前会遇到的几件事
免费接口额度决定了你的缓存策略。 免费版是 60 次/分钟的量级。一个中等投放场景,每天几千个独立 IP,缓存 TTL 设 1 天,出站调用量完全在额度内。但如果是「同一批人反复点」的场景(比如线下活动的二维码),命中率会更高,开销更小。反过来说,别在压测时把负缓存关掉——接口抖动时那 30 秒的负缓存就是你的保命符。
测试时注意自己的 IP。 本机调接口查到的可能是内网地址或保留地址,prov 会是空字符串。这不是接口坏了,接口实测对 192.168.x.x、10.x.x.x 这类地址返回的是 ret=200 但省市字段为空,isp 写着「内网地址」。本地联调想看真实结果,用 ?ip= 显式指定一个公网 IP 更省事。
带端口的 IP 会被判非法。 如果你的网关或者前置代码把 REMOTE_ADDR 拼成了 1.2.3.4:5678,接口返回的是 HTTP 400 而不是猜一个结果。日志里突然出现一片 400,先去看是不是有人往 IP 字段里塞了端口。
港澳台和海外要分两种配法,实测很反直觉。 查香港的 IP(203.198.0.1)返回的是 country=中国、prov=香港、big_area=港澳台——它走的是省份和大区规则。而查 8.8.8.8 这类海外 IP,返回的是 country=美国,prov/city 全是空,只能靠 country 匹配。所以规则表里 scope=country 建议配一条「海外」兜底,港澳台则用 prov 或 big_area=港澳台 来配,别指望一套规则覆盖两种。
「保留」不是海外。 查内网和内网回环地址(192.168.x.x、10.x.x.x、127.0.0.1)时 ret=200,但 country 字段写的是「保留」,省市同样为空。如果不做区分,这些地址会被当成「海外用户」送进海外落地页。代码里那行 !in_array($country, ['中国', '保留']) 就是干这个的。
地域差异化内容要注意边界。 按地区给不同活动页是常规运营手段,但涉及价格、服务资格这类差异时,先过一遍合规——「按地区调整价格」在很多场景下是敏感操作,别把它做成技术上的既成事实。
总结
自建地域跳转短链,真正的工作量不在查归属地,而在:302 而不是 301、预取请求不查接口、三种 TTL 的缓存、查不到就跳默认页、日志异步且脱敏。这几条做对了,一个 PHP 文件加两张配置表就能跑起来。
IP 归属地这块用的是 IP9 的免费接口 https://ip9.com.cn/get?ip=<IP>,不传 ip 参数就返回调用方自己的归属地,返回字段里有省、市、运营商、大区(big_area,华东华南这种),免费版 60 次/分钟、IPv4/IPv6 都能查;要区县、IP 类型(ISP/企业/IDC 机房)、AS 号的话是 VIP 版能力,字段说明在官网 https://www.ip9.com.cn。