简介:这套黑色简洁风格的PHP短网址短链接生成源码,面向PHP开发者、个人站长与建站学习者,可用于快速搭建自有短链服务,并兼顾后台运营与前端体验。前端提供常规短链、自定义短链、密码保护链接、链接统计、暗色主题、小书签以及复制分享等功能;后端支持网址删除、站点设置、广告添加与编辑、访问分析和自定义CSS,便于二次调整界面与运营策略。压缩包共103个文件,约681KB,包含34个php、13个scss、13个less、11个js、10个css以及字体、图片和2个sql文件,分别承担业务逻辑、样式预处理、交互脚本、页面样式、资源与数据库初始化等用途。已有214人学习下载,适合希望研究短链系统结构、后台管理实现或直接部署轻量短链站点的读者参考,也能从广告位、统计分析与主题定制等模块中了解完整运营型短链工具的组织方式。
1. 短链服务真正难的从来不是「生成」
很多人拿到一份 PHP 短网址源码,第一反应是改 CSS、换 Logo,改完发现生成的链接点开是 404。短链接生成这件事,把长链接塞进数据库、算出一个六位短码、拼成https://域名/abc123返回,这三步谁都会写;真正把一套 PHP 短链接生成源码跑上线、还要跑得稳,靠的是短码唯一性、伪静态重写、跳转状态码和一层最基本的防滥用。这份黑色简洁风格的短网址源码定位很清楚:一个自托管的轻量短链系统,前台一个输入框,后台一张链接表,带点击统计和有效期,适合个人博客外链、运营活动短链、内部系统跳转入口这类不想把数据交给第三方服务的场景。下面按目录结构、核心代码、部署排查、二次开发一路拆下去,能跑起来的部分我都给到可抄的代码。
2. 拆开目录看架构:一套原生 PHP 短链的骨架长什么样
短链系统看着小,其实五脏俱全:入口、算法、存储、跳转、后台、统计,缺一环都跑不通。这份源码走的是原生 PHP + MySQL 的路子,不依赖框架,解压就能往虚拟主机或者一台小 VPS 上扔。先看清文件怎么分工,再决定从哪里下手改。
2.1 目录结构与文件职责
解压后的目录大致是下面这个形状,不同版本文件名可能有一两个字差异,但职责划分基本一致:
shorturl/ ├── index.php # 首页,黑色主题的短链生成表单 ├── short.php # 表单提交处理,返回 JSON ├── go.php # 短码跳转入口,核心链路的出口 ├── config/ │ ├── database.php # PDO 连接参数 │ └── admin.php # 后台密码哈希、站点域名 ├── includes/ │ ├── db.php # PDO 单例,全局只连一次 │ ├── functions.php # base62 编解码、URL 校验、短码生成 │ └── auth.php # 后台 session 校验 ├── admin/ │ ├── login.php # 登录页 │ ├── index.php # 链接列表 + 分页 │ ├── stats.php # 单条链接的点击曲线 │ └── delete.php # 禁用 / 删除 ├── assets/ │ ├── css/black.css # 黑色主题样式 │ └── js/app.js # 表单无刷新提交 ├── install.sql # 建表脚本 └── .htaccess # Apache 伪静态规则这份目录里有三个文件是主心骨,改动前最好先读懂:includes/functions.php管算法,short.php管写入,go.php管读出和跳转。其余文件都是外围。前台和后台共用同一份includes/db.php,好处是连接参数只维护一处,坏处是后台如果单独部署子域名,得注意session的作用域,这个在后面排查章节会细说。
admin/目录是典型的「文件即路由」,没有前端框架那种单页路由,每个动作一个 PHP 文件,改起来直观但要注意权限校验不能漏。常见的做法是在admin/下每个文件顶部require '../includes/auth.php'然后调一次requireAdmin(),漏掉一个文件就等于开了个后门,这点在二开时尤其要盯住。
2.2 数据表设计:两张表撑起整条链路
短链系统最小可用的表结构就两张:一张存链接,一张存点击日志。install.sql里通常长这样:
CREATE TABLE `links` ( `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, `code` VARCHAR(10) CHARACTER SET ascii COLLATE ascii_bin DEFAULT NULL COMMENT '短码', `url` TEXT NOT NULL COMMENT '原始长链接', `ip` VARCHAR(45) NOT NULL DEFAULT '' COMMENT '创建者IP', `click_count` INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '累计点击', `status` TINYINT NOT NULL DEFAULT 1 COMMENT '1正常 0禁用', `expire_at` DATETIME DEFAULT NULL COMMENT '过期时间,NULL为永久', `created_at` DATETIME NOT NULL, PRIMARY KEY (`id`), UNIQUE KEY `uk_code` (`code`), KEY `idx_url` (`url`(191)) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE `click_log` ( `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, `link_id` BIGINT UNSIGNED NOT NULL, `ip` VARCHAR(45) NOT NULL, `ua` VARCHAR(255) NOT NULL DEFAULT '', `referer` VARCHAR(255) NOT NULL DEFAULT '', `click_date` DATE NOT NULL, `created_at` DATETIME NOT NULL, PRIMARY KEY (`id`), UNIQUE KEY `uk_link_ip_day` (`link_id`, `ip`, `click_date`), KEY `idx_link_date` (`link_id`, `click_date`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;几个字段的含义值得说清楚。code单独用ascii_bin排序规则,是因为短码里大小写字母都有意义,而 MySQL 默认的utf8mb4_general_ci不区分大小写,abc123和ABC123会被判成重复,这个坑后面单独讲。url用TEXT是因为长链接轻松超过 255 字符,但索引只能取前缀 191,够用了。uk_link_ip_day这个唯一索引是关键设计:同一个链接、同一个 IP、同一天只允许一条记录,点击统计天然去重,写日志时用INSERT IGNORE直接吃掉重复,不用先查后插那种两次往返。expire_at留空表示永久有效,过期判断放在跳转时做,不靠定时任务,实现简单也不会漏。
如果后续要做多用户,加一张users表,links里补一个user_id字段就行,不影响现有链路。这种「先做单用户、再扩多用户」的演进方式,在轻量工具类项目里是性价比最高的。
2.3 运行环境与部署前提
环境要求不高:PHP 7.4 以上(8.x 也能跑,注意str_split之类的边界差异)、MySQL 5.7 或 8.0、Apache 带mod_rewrite,或者 Nginx 自己写location。PDO 扩展必须开,pdo_mysql没开的话页面会直接白屏,这是新手最常见的第一个坎。
部署流程按顺序走:先把源码解压到站点根目录,浏览器访问install.sql对应的导入方式(多数是进 phpMyAdmin 或者命令行mysql -u root -p dbname < install.sql);然后改config/database.php填库名、用户名、密码;再改config/admin.php里的站点域名和后台密码哈希;最后确认伪静态规则生效,访问域名/abc123能命中go.php。
config/admin.php里的密码不能明文写,用password_hash()生成一次粘进去:
<?php // config/admin.php return [ 'domain' => 'https://s.example.com', // 短链拼接用的域名,结尾不要带斜杠 // 用 password_hash('你的密码', PASSWORD_DEFAULT) 生成后粘贴到这里 'pass_hash' => '$2y$10$e0NRl3qXq0mJ9pQ1vH8kJ.9xWc7oVQ2yF1sT6uYpZ3aB4cD5eF6gH', 'per_page' => 20, // 后台列表每页条数 ];domain这个配置决定了最终返回给用户的短链长什么样,拼错成http://而站点强制https://会导致浏览器混合内容告警,短链点开还会被拦一次。pass_hash换了密码就得重新生成,别想着把哈希倒推回去,那是白费功夫。
站点根目录最好固定在域名根下,如果放在子目录里(比如/s/),伪静态规则和go.php里的路径拼接都要跟着改,这属于能省则省的麻烦,建议一开始就部署在独立域名或子域名上。
3. 核心链路代码:从长链接到 6 位短码,再到 302 跳转
这条链路只有四步:校验输入、算短码、写库、跳转出去。每一步都能写出花,也能写得一塌糊涂。下面按请求流经的顺序拆。
3.1 入口校验:URL 合法性与协议白名单
用户提交什么都有可能,直接塞进数据库再跳转,等于给自己开了个开放重定向。校验函数放在includes/functions.php:
<?php // includes/functions.php /** * 校验长链接是否可接受 * @param string $url 用户提交的原始链接 * @return string 规范化后的链接 * @throws InvalidArgumentException 校验不通过时抛出 */ function normalizeUrl(string $url): string { $url = trim($url); if ($url === '' || mb_strlen($url) > 2000) { throw new InvalidArgumentException('链接为空或超长'); } // 没写协议的,默认补 https,很多用户直接粘 example.com/a if (!preg_match('#^[a-z][a-z0-9+.-]*://#i', $url)) { $url = 'https://' . $url; } if (!filter_var($url, FILTER_VALIDATE_URL)) { throw new InvalidArgumentException('链接格式不正确'); } // 协议白名单:只放行 http/https,挡掉 javascript:、data: 这类 $scheme = strtolower(parse_url($url, PHP_URL_SCHEME)); if (!in_array($scheme, ['http', 'https'], true)) { throw new InvalidArgumentException('只支持 http/https 链接'); } // 不允许自己套自己,避免形成跳转环 $host = strtolower(parse_url($url, PHP_URL_HOST) ?? ''); if ($host === strtolower(parse_url(SITE_DOMAIN, PHP_URL_HOST))) { throw new InvalidArgumentException('不能缩短本站链接'); } return $url; }filter_var配FILTER_VALIDATE_URL能挡掉大部分畸形输入,但它对javascript:这类伪协议是放行的,所以协议白名单必须单独写一层,这是血泪经验。长度上限设 2000 是给数据库和浏览器都留余量,TEXT字段能存 65535 字节,但真有人往里灌几万字符的链接,跳转时浏览器也未必接得住。拒绝「自己套自己」那一段是为了防止有人构造短链 → 短链 → 短链的循环,跳几次就死循环了。
SITE_DOMAIN这个常量可以在config/database.php里define,也可以从admin.php的配置数组里读,二选一,别两边都写。
3.2 短码算法:base62 编码与自增 ID 的配合
短码方案无非两类:自增 ID 转进制,或者随机串加唯一索引重试。这份源码用的是前者,短、有序、无碰撞,实现也最短:
<?php // includes/functions.php const BASE62 = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ'; /** * 自增 ID 转 base62 短码 * @param int $id links 表主键,必须大于 0 * @param int $minLen 最小长度,不足左侧补 0,保证短码定长 */ function idToCode(int $id, int $minLen = 6): string { if ($id <= 0) { return str_repeat('0', $minLen); } $code = ''; $base = strlen(BASE62); // 62 while ($id > 0) { $code = BASE62[$id % $base] . $code; $id = intdiv($id, $base); } return str_pad($code, $minLen, '0', STR_PAD_LEFT); } /** * 短码还原成 ID,后台排查「这个码对应哪条记录」时用 */ function codeToId(string $code): int { $id = 0; $base = strlen(BASE62); foreach (str_split($code) as $ch) { $pos = strpos(BASE62, $ch); if ($pos === false) { return 0; // 非法字符直接判 0 } $id = $id * $base + $pos; } return $id; }BASE62的字符顺序决定短码长相,0-9a-zA-Z是最常见的一种,好处是短码看起来像随机串,坏处是大小写敏感带来的排序规则问题。$minLen = 6让短码至少六位,62 的 6 次方约 568 亿,个人站点用一辈子也到不了。补0是为了定长好看,如果你不在乎长短,把minLen设成 1 也行,短码会从1、2、3开始递增,一眼就能被枚举,所以不建议。
idToCode的复杂度是 O(log₆₂ n),十万条记录也就三到四次循环,性能上完全不用担心。真正需要担心的是「可枚举」:短码连续意味着别人能从000001顺着爬完你所有链接。如果链接内容敏感,得在这个基础上加偏移量或者换随机方案,这个改造放到最后一章说。
3.3 落库与查重:一次提交两次写库
short.php是表单提交的接收端,也是整个系统唯一写链接的地方:
<?php // short.php require __DIR__ . '/includes/db.php'; require __DIR__ . '/includes/functions.php'; header('Content-Type: application/json; charset=utf-8'); try { $url = normalizeUrl($_POST['url'] ?? ''); // 1. 先查同样的长链接是否已经生成过,有就直接复用 $stmt = $pdo->prepare( 'SELECT code FROM links WHERE url = ? AND status = 1 AND (expire_at IS NULL OR expire_at > NOW()) LIMIT 1' ); $stmt->execute([$url]); if ($code = $stmt->fetchColumn()) { echo json_encode(['code' => 0, 'short' => SITE_DOMAIN . '/' . $code, 'reused' => true]); exit; } // 2. 插入长链,先占一个自增 ID $stmt = $pdo->prepare( 'INSERT INTO links (url, ip, expire_at, created_at) VALUES (?, ?, NULL, NOW())' ); $stmt->execute([$url, $_SERVER['REMOTE_ADDR'] ?? '']); $id = (int)$pdo->lastInsertId(); // 3. ID 转短码回写,这一步失败要回滚 $code = idToCode($id); $pdo->prepare('UPDATE links SET code = ? WHERE id = ?')->execute([$code, $id]); echo json_encode(['code' => 0, 'short' => SITE_DOMAIN . '/' . $code, 'reused' => false]); } catch (InvalidArgumentException $e) { echo json_encode(['code' => 1, 'msg' => $e->getMessage()]); } catch (Throwable $e) { error_log($e->getMessage()); // 真实原因进日志,不回显给前端 echo json_encode(['code' => 1, 'msg' => '服务繁忙,请稍后再试']); }查重那一步是很多人会忽略的体验点:同一个活动链接被十个人提交,不该产生十个短码,复用一个就够,还能让点击统计集中在一行上。status = 1和expire_at的判断不能省,否则复用一个已经被禁用的短码,用户点开还是空白页。插入和回写短码分两条 SQL,是因为短码依赖自增 ID,拿不到 ID 就没法算。这两步之间理论上存在「插入了但回写失败」的窗口,量小到可以忽略,真在意就套一层事务:
$pdo->beginTransaction(); try { $pdo->prepare('INSERT INTO links (url, ip, created_at) VALUES (?, ?, NOW())') ->execute([$url, $_SERVER['REMOTE_ADDR'] ?? '']); $id = (int)$pdo->lastInsertId(); $pdo->prepare('UPDATE links SET code = ? WHERE id = ?')->execute([idToCode($id), $id]); $pdo->commit(); } catch (Throwable $e) { $pdo->rollBack(); throw $e; }异常处理里那条$e->getMessage()只写日志不返回前端,是安全底线。把 PDO 的原始报错直接吐到 JSON 里,等于把库名、表名、SQL 片段全送人,踩过一次就知道疼。
3.4 跳转入口 go.php 与伪静态重写
用户拿到的是https://s.example.com/abc123,这个路径实际不存在,靠伪静态转到go.php?c=abc123。go.php本身很短:
<?php // go.php require __DIR__ . '/includes/db.php'; $code = $_GET['c'] ?? ''; // 短码只允许 base62 且长度受限,挡掉扫描器和注入尝试 if (!preg_match('/^[0-9a-zA-Z]{4,10}$/', $code)) { http_response_code(404); exit('Not Found'); } $stmt = $pdo->prepare( 'SELECT id, url, expire_at FROM links WHERE code = ? AND status = 1 LIMIT 1' ); $stmt->execute([$code]); $row = $stmt->fetch(PDO::FETCH_ASSOC); if (!$row || ($row['expire_at'] !== null && $row['expire_at'] < date('Y-m-d H:i:s'))) { http_response_code(404); exit('链接不存在或已过期'); } // 点击统计:同一 IP 同一天只留一条,INSERT IGNORE 吃掉重复 $log = $pdo->prepare( 'INSERT IGNORE INTO click_log (link_id, ip, ua, referer, click_date, created_at) VALUES (?, ?, ?, ?, CURDATE(), NOW())' ); $log->execute([ $row['id'], $_SERVER['REMOTE_ADDR'] ?? '', mb_substr($_SERVER['HTTP_USER_AGENT'] ?? '', 0, 255), mb_substr($_SERVER['HTTP_REFERER'] ?? '', 0, 255), ]); if ($log->rowCount() > 0) { // 真的插入成功才累加,去重才算准 $pdo->prepare('UPDATE links SET click_count = click_count + 1 WHERE id = ?') ->execute([$row['id']]); } // 302 临时跳转,别用 301 header('Location: ' . $row['url'], true, 302); exit;正则^[0-9a-zA-Z]{4,10}$是廉价但有效的第一道门,非 base62 的请求连库都不用查。expire_at判断放在 PHP 层而不是 SQL 里,是为了区分「不存在」和「已过期」两种提示,虽然前端都显示 404,但日志里能看出来。统计部分用rowCount()判断是否真的插入了新行,只有新 IP 才累加click_count,这样计数和明细表始终对得上,不会出现「明细 100 条、计数 300」的玄学偏差。
header('Location: ...', true, 302)第三个参数不能省。用 301 的话,浏览器会把跳转关系长期缓存,用户删了短链、改了目标地址,自己电脑上还是往老地址跳,清缓存都未必管用,这是最典型的一类「我改了呀怎么没生效」。
对应的伪静态,Apache 写在根目录.htaccess:
RewriteEngine On RewriteBase / # 已存在的文件和目录直接放过,别把静态资源也重写了 RewriteCond %{REQUEST_FILENAME} !-f RewriteCond %{REQUEST_FILENAME} !-d # 4-10 位字母数字才进 go.php RewriteRule ^([0-9a-zA-Z]{4,10})$ go.php?c=$1 [L,QSA]Nginx 则是:
location / { try_files $uri $uri/ @shorten; } location @shorten { rewrite ^/([0-9a-zA-Z]{4,10})$ /go.php?c=$1 last; }两条规则用意一样:先让真实文件走静态,剩下的才交给go.php。少了!-f那两行,black.css这类资源也会被当成短码吞掉,页面直接变成白底黑字。
4. 黑色主题前端与后台:表单、列表、点击统计怎么落地
前端这套黑色主题不算复杂,一个居中卡片、一个输入框、一个按钮,配色靠 CSS 变量收口。真正需要动手的是表单提交方式和后台的数据读取,前者影响体验,后者影响你日常维护的效率。
4.1 黑色主题首页与无刷新提交
index.php结构很轻,一个表单加一个结果区:
<!-- index.php 片段 --> <form id="shorten-form" autocomplete="off"> <input id="long-url" type="text" name="url" placeholder="粘贴长链接,回车即可生成" maxlength="2000" required> <button type="submit">生成短链</button> </form> <p id="result" class="result-tip"></p>app.js走fetch,页面不刷新,生成完直接把短链贴回结果区:
// assets/js/app.js document.querySelector('#shorten-form').addEventListener('submit', async (e) => { e.preventDefault(); const input = document.querySelector('#long-url'); const tip = document.querySelector('#result'); const btn = e.target.querySelector('button[type=submit]'); btn.disabled = true; // 短链生成是写库操作,防连点 tip.textContent = '生成中…'; try { const resp = await fetch('short.php', { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams({ url: input.value.trim() }) }); const data = await resp.json(); if (data.code !== 0) throw new Error(data.msg || '生成失败'); tip.innerHTML = `<a href="${data.short}" target="_blank" rel="noopener">${data.short}</a>`; input.value = ''; } catch (err) { tip.textContent = err.message; } finally { btn.disabled = false; // 无论成败都恢复按钮 } });btn.disabled的两处赋值看着啰嗦,但少了任何一处都会出问题:不置 true,用户狂点回车能在一秒内塞进十几条重复记录;不在finally里恢复,一次网络抖动之后按钮就永久灰掉了,用户以为站点挂了。rel="noopener"是给target="_blank"配的,防止新页面拿到window.opener做点手脚。
黑色主题的样式收在black.css里,用变量控制主色:
:root { --bg: #0e0e10; --card: #17171a; --text: #e8e8ea; --accent: #4f8cff; } body { background: var(--bg); color: var(--text); } .card { background: var(--card); border-radius: 12px; padding: 32px; }改主题时只动:root这几个值,别去正文里挨个替换十六进制色号,那是自找麻烦。
4.2 后台登录、列表分页与软删除
后台的鉴权用 PHP 原生 session 就够,别为了一个单管理员系统上 JWT。includes/auth.php:
<?php // includes/auth.php if (session_status() === PHP_SESSION_NONE) { session_start(); } function requireAdmin(): void { if (empty($_SESSION['admin_ok'])) { header('Location: login.php'); exit; } }login.php里校验密码,用的就是config/admin.php里那个哈希:
<?php // admin/login.php 片段 $cfg = require __DIR__ . '/../config/admin.php'; if ($_SERVER['REQUEST_METHOD'] === 'POST') { if (password_verify($_POST['password'] ?? '', $cfg['pass_hash'])) { session_regenerate_id(true); // 登录成功换 session id,防固定攻击 $_SESSION['admin_ok'] = true; header('Location: index.php'); exit; } $error = '密码不正确'; }session_regenerate_id(true)这行很多人抄代码时顺手删掉,觉得可有可无,实际上是防 session 固定的关键一步。列表页admin/index.php做分页,用LIMIT加总数查询:
<?php // admin/index.php 片段 require __DIR__ . '/../includes/auth.php'; requireAdmin(); $page = max(1, (int)($_GET['page'] ?? 1)); $perPage = 20; $offset = ($page - 1) * $perPage; $total = (int)$pdo->query('SELECT COUNT(*) FROM links')->fetchColumn(); $stmt = $pdo->prepare( 'SELECT id, code, url, click_count, status, expire_at, created_at FROM links ORDER BY id DESC LIMIT :limit OFFSET :offset' ); $stmt->bindValue(':limit', $perPage, PDO::PARAM_INT); $stmt->bindValue(':offset', $offset, PDO::PARAM_INT); $stmt->execute(); $rows = $stmt->fetchAll(PDO::FETCH_ASSOC);LIMIT和OFFSET必须用bindValue带PDO::PARAM_INT,直接拼字符串拼错一次就是注入。删除操作做成软删除更稳:UPDATE links SET status = 0 WHERE id = ?,短链立刻失效但数据还在,用户来问「我那条链接怎么没了」你有据可查。真删也不是不行,但删完统计表就成了孤儿数据,还得再写一层级联清理。
4.3 点击统计:去重表与日维度聚合
统计页要的就是一张 30 天的曲线,数据从click_log按天聚合:
<?php // admin/stats.php 片段 $id = (int)($_GET['id'] ?? 0); $stmt = $pdo->prepare( 'SELECT click_date, COUNT(*) AS pv FROM click_log WHERE link_id = :id AND click_date >= DATE_SUB(CURDATE(), INTERVAL 30 DAY) GROUP BY click_date ORDER BY click_date ASC' ); $stmt->execute([':id' => $id]); $series = $stmt->fetchAll(PDO::FETCH_KEY_PAIR); // ['2024-06-01' => 12, ...]FETCH_KEY_PAIR把结果直接变成日期 => 次数的关联数组,前端画图时不用再循环转换。因为click_log上有uk_link_ip_day唯一索引,COUNT(*)得到的天然是 UV 而不是 PV,同一个用户一天点十次只算一次。想要 PV 就得去掉那个唯一索引,但索引一去,恶意刷新的成本就没了,短链被灌几十万条日志的事我见过不止一次。取舍看场景:对外活动短链建议留 UV,内部跳转无所谓就直接上 PV。
如果链接量大,click_log会膨胀得很快,按月分表或者定期归档到历史表都是办法,最省事的是写个定时脚本删 90 天前的明细,click_count那个累计值不受影响。
5. 部署踩坑与常见问题排查:伪静态、大小写、301 缓存
前面代码都抄对了,还是跑不起来,八成栽在环境上。下面这几条是这套短链源码上线过程中出现频率最高的,按「现象 → 原因 → 解决」逐条过。
5.1 伪静态不生效导致的整站 404
现象:首页能打开,短链也能生成,但复制出来点开就是 404,go.php?c=xxx直接访问却是正常的。
原因:伪静态没生效。Apache 这边三个常见原因:mod_rewrite没启用、根目录.htaccess的AllowOverride是None、站点根路径配错导致RewriteBase对不上。Nginx 则多半是压根没写location,因为 Nginx 不读.htaccess,把 Apache 的规则原样搬过去是不起作用的。
解决:Apache 先确认模块,httpd -M | grep rewrite有输出才算开了;再检查站点配置里<Directory>段的AllowOverride All;最后看.htaccess有没有被上传工具改名成.htaccess.txt。Nginx 把上一章那段try_files贴进server块,nginx -t过了再reload。排查时可以先临时访问go.php?c=你的短码,通了说明是重写问题,不通才是 PHP 或数据库的问题,这一步能省一半时间。
5.2 短码大小写撞车与字符集陷阱
现象:库里有abc123,再生成一个ABC123的短码时报唯一键冲突;或者反过来,查AbC123居然把abc123那条查出来了。
原因:links.code字段的排序规则是utf8mb4_general_ci,ci就是不区分大小写,MySQL 认为abc123和ABC123是同一个值。base62 里大小写是两种字符,PHP 层算得没错,到库里被合并了。
解决:建表时就把code字段定成CHARACTER SET ascii COLLATE ascii_bin,bin是二进制比较,大小写严格区分,顺带还省空间。已经建好表的用ALTER TABLE links MODIFY code VARCHAR(10) CHARACTER SET ascii COLLATE ascii_bin NOT NULL;改过来,改之前先查一遍有没有现存的大小写重复数据,有的话先处理再改,否则加唯一索引会失败。同样的问题也会出现在go.php的查询上,字段排序规则统一了,查询自然就准了。
5.3 跳转状态码、缓存和开放重定向
现象一:删了一条短链,换了个目标地址重新生成,自己电脑上点开还是往老地址跳,同事电脑上却是新的。
原因:早期版本用了301永久跳转,浏览器把这个映射写进了本地缓存,服务端改了也没用。
解决:header('Location: ...', true, 302),302 是临时跳转,浏览器每次都回源。如果已经被 301 污染了,只能让用户清缓存,或者换一个短码。
现象二:站点被人拿去批量生成短链,全指向同一批垃圾地址,后台列表一天涨几千条。
原因:short.php没有任何频率限制,只要能 POST 就能写库。这是自建短链最容易被薅的地方,脚本跑起来几秒钟几百条。
解决:三层一起上。入口加频率限制,用ip + 日期在缓存或者一张计数表里记次数,超了就返回「今天生成次数已达上限」;normalizeUrl里加域名黑名单,命中直接拒;后台加一个「新链接默认待审核」的开关,status默认 0,人工点一下才生效,这个开关在活动期间尤其有用。别指望前端那点校验,绕过它只需要一行 curl。
现象三:后台登录成功,点进列表页又跳回登录页,反反复复。
原因:session保存路径不可写,或者config/下的session.cookie_path和站点实际路径不一致。也有一种情况是前台和后台部署在不同子域,cookie 作用域对不上。
解决:先看session.save_path指向的目录权限,php -i | grep session.save_path能看到实际值;再确认session_set_cookie_params里的path是/;跨子域的就把domain设成.example.com。这类问题没有玄学,全都是配置项没对上,逐项打印出来比猜快得多。
6. 二次开发:加自定义后缀、二维码和缓存的改造顺序
跑通之后想往上加东西,顺序有讲究:先做不影响主链路的,再做改主链路的,最后做性能层的。上来就动go.php属于给自己找事。
第一优先级是自定义后缀,运营同学最需要。改法是在short.php里读一个可选的alias参数,校验^[0-9a-zA-Z_-]{2,20}$,查一次库确认没被占用,直接写进links.code而不是走idToCode。注意这时候code字段长度要放宽到 20,go.php的正则同步改成{2,20},两处不改一致就会出现「生成成功但点开 404」的经典事故。别名还带来一个副作用:有人会抢注admin、login这类词,跟后台目录撞车,维护一个保留词数组挡掉就行。
第二优先级是二维码。不用引整个库,前端用现成的 JS 库在结果区渲染一张短链 → canvas的图就够,生成完顺手提供下载。要落到后端批量出图,再考虑服务端方案。这一步完全不碰 PHP 主链路,风险最低。
第三优先级是缓存,也是收益最大的一步。go.php每次跳转都要查一次库,热门链接一天几万次点击,MySQL 白扛。加一层缓存,把code → url的映射存起来:
<?php // includes/cache.php —— 以文件缓存为例,没装 Redis 也能用 function cacheGet(string $key): ?string { $file = __DIR__ . '/../runtime/cache/' . md5($key) . '.txt'; if (!is_file($file) || filemtime($file) < time() - 600) { // 10 分钟过期 return null; } return (string)file_get_contents($file); } function cacheSet(string $key, string $value): void { $dir = __DIR__ . '/../runtime/cache'; if (!is_dir($dir)) { mkdir($dir, 0755, true); } file_put_contents($dir . '/' . md5($key) . '.txt', $value, LOCK_EX); }改造点是go.php查库前先cacheGet($code),命中就直接跳;未命中查完库再cacheSet。这里有个必须处理的问题:链接被禁用、被删除、过期之后,缓存里的旧映射还在,最长要 10 分钟才失效。做法是在admin/delete.php里删除或禁用成功后顺手删掉对应的缓存文件,缓存和库的失效逻辑要成对出现,只写一半就是给自己埋雷。真上量了再换 Redis,接口形状不变,换实现不换调用方。
验证方式很简单:改完先用ab -n 1000 -c 50压一下go.php,看 QPS 和 MySQL 的Com_select变化;再用后台禁用一条链接,立刻点开确认返回 404 而不是跳到老地址。这两步都过,改造才算收工。
从那以后我每次改go.php或者动短码规则,都强制走一遍「生成 → 点开 → 禁用 → 再点开 → 清缓存 → 再点开」的完整链路,绝不只看首页那张表单能不能出结果。短链这东西的坑全在跳转那一步,前面看着都正常,最后一步 404 才最要命。希望帮到你。
本文还有配套的精品资源,点击获取