前言
在2025年11月,我写了这篇文章:个性化Typecho VisitorLoggerPro插件,二次元浓度拉满!,当时我在进行前端界面优化的时候,已经发现了这个插件已经是一坨狗屎了。迫于当时AI的能力不够强大,我编程水平也就那样,就暂时先丢着代码里面的屎山不管了——反正又不是不能用
直到前段时间,AI的技术力突飞猛进,我也成功用AI构建了我想要的博客主题。不得不说,造物的快感太上头了。于是,我觉得是时候把我博客上面的一些插件和功能进行优化——改善代码质量,提升运行效率,修复安全问题
那么,就先拿当年让我感觉最屎山的VisitorLoggerPro插件开刀吧!
实施计划
Codearts 启动
我打算用Codex直接优化代码,但是在优化代码之前,为了不浪费昂贵的 ChatGPT 的 Token ,我使用了华为云免费的 Codearts 代码智能体进行了代码的审计,重点分析性能问题和安全问题,然后写一份分析报告。
提示词大概就是这样的:
这是一个Typecho插件,代码写的一坨狗屎,而且有一个数据库里面的数据记录还是有问题的。更多的问题诸如前端的省份显示在国家里面挖省份,Log记录的数据表管理混乱等。请你核查代码,找出潜在的性能问题和安全漏洞,在项目目录撰写一份markdown格式的优化建议。
果不其然,一次扫描下来,发现了大量的问题,包括但不限于:
- SQL 注入漏洞
- XSS 漏洞
- API 无权限校验
- N+1 查询性能问题
- 全表加载到 PHP
- 省份数据存储在 country 字段的BUG
- API 绕过 Typecho 框架
- ECharts 加载逻辑重复
果然,看来以后要用什么插件,如果可以最好还是让AI来审计一下,把潜在的漏洞抓出来补一补会比较好,顺带可能还能提个 PR。
当然,除了插件自身代码层面的问题,在新版的 Typecho 1.3.0 上面,界面也是出问题的,会导致界面错乱。以及原版插件的兼容性比较差,不支持 SQLite 数据库,这个也是需要改进的地方。
茬找完了,接下来就是 Codex 启动了。
Codex 启动
为了方便 ChatGPT 进行开发和调试工作,我在本地启动了 Typecho 实例,并且把用户和密码都告诉了它,方便它进行调试。不然它会想其它路子测试,包括但不限于自己启动另一个 php 进程跑测试,启动一个 python 的 http 服务器验证 web 交互等。而我直接告诉它可以用开发环境进行测试,它就会直接用了,而不是另起炉灶,这也就说明虽然它知道你弄了测试环境,但是如果你不主动说的话,它是不会去用的。
不得不说,顶级模型就是厉害,ChatGPT-5.6-Sol 会自己调用 curl wget 甚至 chromium 来进行开发调试(其实 ChatGPT 5.5 都会了),而国内模型和平台的情况,至少我用的华为云的 Codearts 是完全追不上的,只会使用一些基础的工具,甚至都不能自主完成代码交付前的Debug工作,交付的代码甚至运行可能报错。虽然华为云的 Codearts 内部用的模型 GLM5.1 按理来说应该也不差才对,但是表现确实差太多了。不过嘛,都没要你钱,免费用,不限量,那还有什么好说的,恩情还不玩就是了。
但是因为众所周知的原因,ChatGPT很喜欢偷懒,总是倾向于实现那些容易达成、效果立竿见影的目标,而对于更深层次的目标,它可能就不去做了。这一点在我后续重度使用Codex进行全栈开发的时候深有体会,它总是只做一个最小可行方案,如果你不规划好框架,策略,架构,它就给你乱来。
这一次修复也不例外,我第一次让它帮忙修复,明明要求是把所有的问题都修复,但是它只挑了影响比较严重的问题进行修复,而其它的问题视而不见。
为了应对AI的这种“偷懒”行为,就需要请出 Codex 最严厉的父亲 Codearts ,让它来审查代码修复的落实情况充当“监工”,然后让 Codex 阅读 Codearts 的审查报告继续完善代码。虽然说新开一个 Codex 进行审查是完全可行的,但是费 Token 啊,还是华为云太恩情了!
这样一来一回,然后我在自己上手验证和测试代码的修复情况和实现情况,基本上就没什么太大的问题了,如果还有问题,为了防止模型的思维定视,直接开多一个新的 Codearts 进行代码审查,反正Token不花钱,华为云恩情还不玩。
拉扯完成后完成了下面的这些修复工作:
改动情况
经过这几轮「Codearts 挑刺 → Codex 动刀 → Codearts 核查 → 我亲自验货」的循环,最终给这个插件来了一次近乎重写的升级。v2.3.1 的改动量,比我预期的大得多,但也彻底把那座屎山铲平了。
架构重构:数据库与统计逻辑独立拆分
新增 Database.php — 数据库安装、迁移与维护统一管理
改进前:Plugin.php::activate() 中内联了约 60 行建表、列检测、TIMESTAMP→DATETIME 迁移逻辑,仅支持 MySQL,并且还有注释掉的 SQLite 替代方案。
// 旧代码(Plugin.php)
$sql = "CREATE TABLE IF NOT EXISTS `{$prefix}visitor_log` (
`id` INT(10) UNSIGNED AUTO_INCREMENT PRIMARY KEY,
`ip` VARCHAR(45) NOT NULL,
...
) ENGINE=InnoDB DEFAULT CHARSET=utf8;";
// ********如果提示UNSIGNED 或 AUTO_INCREMENT 或 ENGINE的相关错误,将上述代码替换成以下代码********
//$sql = "CREATE TABLE IF NOT EXISTS `{$prefix}visitor_log` (
// `id` INT(10) PRIMARY KEY, ...
//);";改进后:建表逻辑迁移到 VisitorLoggerPro_Database::install(),根据数据库适配器自动选择 MySQL 或 SQLite DDL,同时自动补列、建索引、回填 visitor_hash、迁移地点数据。
// 新代码(Database.php)
public static function install()
{
$db = Typecho_Db::get();
if (self::isSQLite($db)) {
$sql = "CREATE TABLE IF NOT EXISTS {$table} ("
. 'id INTEGER PRIMARY KEY AUTOINCREMENT,'
. 'ip VARCHAR(45) NOT NULL,'
// ... SQLite 语法
. ')';
} else {
$sql = "CREATE TABLE IF NOT EXISTS {$table} ("
. '`id` INT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,'
// ... MySQL 语法, utf8mb4
. ') ENGINE=InnoDB DEFAULT CHARSET=utf8mb4';
}
$db->query($sql, Typecho_Db::WRITE);
self::addMissingColumns($db, $prefix);
self::createIndexes($db, $prefix);
self::backfillVisitorHashes($db, $prefix);
self::migrateLocations($db, $prefix);
}改进后的效果:
- SQLite 部署开箱即用,不再需要手动修改建表语句
- 字符集从
utf8升级为utf8mb4,支持 4 字节 Unicode - 自动创建 6 个查询索引(
idx_vlp_time、idx_vlp_ip_time、idx_vlp_country、idx_vlp_region、idx_vlp_route、idx_vlp_visitor_hash),大幅提升统计查询速度
新增 Statistics.php — 统计查询逻辑集中封装
改进前:getTrendData.php(349 行)和 getVisitStatistic.php(378 行)各自独立实现完整的数据库连接、日期校验、SQL 拼装、会话计算,这里面有大量重复代码,而且直接拼接 SQL 存在注入风险。
// 旧代码(getTrendData.php)
$sql = "SELECT DATE(time) as date, COUNT(*) as pv_count,
COUNT(DISTINCT ip) as unique_ip_count,
COUNT(DISTINCT CONCAT(ip, IFNULL(user_agent, ''))) as unique_visitor_count
FROM {$prefix}visitor_log
WHERE time >= '{$startDate}' AND time <= '{$endDate}'
GROUP BY DATE(time) ORDER BY date ASC";
$results = $db->fetchAll($sql);改进后:统计逻辑封装到 VisitorLoggerPro_Statistics 类,使用 Typecho 查询构造器,支持 MySQL/SQLite 双引擎。
// 新代码(Statistics.php)
public static function trend($startDate, $endDate)
{
list($startDate, $endDate) = self::validateRange($startDate, $endDate, 3660);
$db = Typecho_Db::get();
$bucketExpression = self::bucketExpression($db, $isSingleDay);
$rows = $db->fetchAll(
$db->select(
array($bucketExpression => 'bucket'),
array('COUNT(id)' => 'pv_count'),
array('COUNT(DISTINCT ip)' => 'unique_ip_count'),
array('COUNT(DISTINCT visitor_hash)' => 'unique_visitor_count')
)->from('table.visitor_log')
->where('time >= ?', $startDate)
->where('time <= ?', $endDate)
->group('bucket')
->order('bucket', Typecho_Db::SORT_ASC)
);
// ...
}优化效果:
- 消除 700+ 行重复代码,两个旧文件各精简为 9 行的 410 废弃提示
- SQL 注入风险消除:所有参数通过 Typecho 参数绑定传入
- 会话计算从 N 次独立查询优化为 1 次有序查询 + PHP 端 30 分钟间隔识别
visitor_hash替代CONCAT(ip, user_agent)做唯一访客去重,性能更好
新增 Location.php — 地点解析逻辑独立封装
改进前:Plugin.php::getIpLocation() 中内联了 ip2region 结果的字符串拆分、重复子串检测、cz88 编码转换等逻辑,仅返回 country 字段,这导致必须后期从前端挖省份,很抽象。
// 旧代码(Plugin.php)
$subStrings = explode(' ', $region);
$repeatedSubstring = explode('|', $region)[3];
$newString = '';
foreach ($subStrings as $subString) {
if (strpos($newString, $subString) !== false) {
$repeatedSubstring = $subString;
break;
}
$newString .= $subString . ' ';
}改进后:VisitorLoggerPro_Location 类提供 fromIp2Region()、fromCz88()、parse() 三个入口,统一返回 {country, region, city} 结构,内置中国省级行政区正则匹配和常见国家名归一化。
// 新代码(Location.php)
public static function fromIp2Region($raw)
{
$parts = array_pad(explode('|', (string) $raw), 5, '');
return self::clean(array(
'country' => self::part($parts[0]),
'region' => self::part($parts[2]),
'city' => self::part($parts[3])
));
}优化效果:
- 国家、省份、城市三字段独立存储,面板可展示完整地理层级
- 自动迁移旧数据中缺失的
region/city(Database::migrateLocations()) - 消除旧代码中不可靠的"重复子串检测"逻辑
安全修复
统计接口改为 Typecho Action 路由
改进前:getVisitStatistic.php 和 getTrendData.php 作为独立 PHP 文件直接被前端 fetch() 调用,无任何身份验证。
// 旧代码(getVisitStatistic.php)
header('Access-Control-Allow-Origin: *'); // 允许任意跨域
// ... 无任何身份校验,直接查询数据库改进后:所有统计接口注册为 Typecho Action(VisitorLoggerPro_Action),非公开端点强制管理员身份验证。
// 新代码(Action.php)
private function requireAdministrator()
{
$user = $this->widget('Widget_User');
if (!$user->hasLogin() || !$user->pass('administrator', true)) {
$this->json(array('success' => false, 'error' => '无权限访问'), 403);
}
}旧文件保留为 410 Gone 废弃提示:
// 新代码(getTrendData.php / getVisitStatistic.php)
http_response_code(410);
echo json_encode(array(
'success' => false,
'error' => '此接口已迁移到受保护的 Typecho Action 路由。'
), JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);请注意:这点安全改进会破坏掉原本依赖插件的前台访客访问量展示功能,如果你有网站公开展示访客信息这个需求,请考虑重构合适的接口
CSRF 防护
改进前:面板的删除操作通过 POST 直接提交,无 CSRF Token。
改进后:所有表单添加 Typecho 安全令牌。
// 新代码(panel.php)
<input type="hidden" name="_" value="<?php echo htmlspecialchars(
$security->getToken($request->getRequestUrl()), ENT_QUOTES, 'UTF-8'
); ?>">XSS 防护
改进前:背景 URL、IP 查询值等直接输出到 HTML/JS,存在注入风险。
// 旧代码
background-image: url('<?php echo $backgroundUrl; ?>');
const ipQuery = '<?php echo $ip; ?>';改进后:所有输出经 htmlspecialchars() 或 json_encode() 转义。
// 新代码
background-image: url(<?php echo json_encode($backgroundUrl, JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT); ?>);
const ipQuery = <?php echo json_encode($ip, JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT); ?>;IP 获取逻辑加固
改进前:无条件信任 HTTP_X_FORWARDED_FOR 和 HTTP_CLIENT_IP,易被伪造。
// 旧代码
if (!empty($_SERVER['HTTP_CLIENT_IP'])) {
return $_SERVER['HTTP_CLIENT_IP'];
} elseif (!empty($_SERVER['HTTP_X_FORWARDED_FOR'])) {
return $_SERVER['HTTP_X_FORWARDED_FOR'];
}改进后:新增 trustProxy 配置项,仅在明确启用时读取 X-Forwarded-For,且逐段校验合法性。
// 新代码(Plugin.php)
public static function getIpAddress()
{
$pluginOptions = Helper::options()->plugin('VisitorLoggerPro');
$trustProxy = isset($pluginOptions->trustProxy) && (string) $pluginOptions->trustProxy === '1';
if ($trustProxy && !empty($_SERVER['HTTP_X_FORWARDED_FOR'])) {
foreach (explode(',', $_SERVER['HTTP_X_FORWARDED_FOR']) as $candidate) {
$candidate = trim($candidate);
if (filter_var($candidate, FILTER_VALIDATE_IP)) {
return $candidate;
}
}
}
$remote = trim((string) ($_SERVER['REMOTE_ADDR'] ?? ''));
return filter_var($remote, FILTER_VALIDATE_IP) ? $remote : null;
}性能优化
数据库索引自动创建
Database::createIndexes() 在激活时自动为 time、ip+time、country、region、route、visitor_hash 六组列创建索引,MySQL 端兼容 767 字节索引上限的旧版本。
visitor_hash 去重字段
改进前:唯一访客统计使用 COUNT(DISTINCT CONCAT(ip, IFNULL(user_agent, ''))),每次查询需实时拼接。
改进后:写入时预计算 visitor_hash = md5(ip + "\x1f" + userAgent),查询改为 COUNT(DISTINCT visitor_hash),配合索引查询效率显著提升。
// 新代码(Database.php)
public static function visitorHash($ip, $userAgent)
{
return md5((string) $ip . "\x1f" . (string) $userAgent);
}IP 地理定位缓存
改进前:每次请求均查询 ip2region/cz88 数据库。
改进后:请求级静态缓存 + APCu 进程级缓存(TTL 86400 秒)。
// 新代码(Plugin.php)
public static function getIpLocation($ip)
{
static $requestCache = array();
if (isset($requestCache[$ip])) {
return $requestCache[$ip];
}
$cacheKey = 'vlp_location_' . md5(__TYPECHO_ROOT_DIR__ . '|' . $ip);
if (function_exists('apcu_fetch')) {
$cached = apcu_fetch($cacheKey, $success);
if ($success && is_array($cached)) {
return $requestCache[$ip] = $cached;
}
}
// ... 查询后写入 APCu
apcu_store($cacheKey, $location, 86400);
}会话计算从 N 次查询优化为 1 次
改进前:每个时间桶(小时/天)各执行一次 calculateSessions() 查询,单日需 24 次查询。
改进后:Statistics::sessionCounts() 一次取出全部有序记录,PHP 端按 30 分钟间隔识别会话边界。
ip2region 向量索引复用
改进前:每次调用 XdbSearcher::newWithFileOnly(),未利用向量索引加速。
改进后:加载向量索引后复用 searcher 实例。
// 新代码(Plugin.php)
static $searcher = null;
if ($searcher === null) {
$vectorIndex = XdbSearcher::loadVectorIndexFromFile($xdb);
$searcher = XdbSearcher::newWithVectorIndex($xdb, $vectorIndex);
}
$location = VisitorLoggerPro_Location::fromIp2Region($searcher->search($ip));面板统计查询改为 SQL 聚合
改进前:panel.php 取出全量记录后在 PHP 端循环统计国家/路由。
// 旧代码
$allLogsForStats = $db->fetchAll($db->select('country, route')->from(...));
foreach ($allLogsForStats as $log) {
$countryStats[$country]['count']++;
$routeStats[$route]['count']++;
}改进后:直接使用 SQL GROUP BY 聚合。
// 新代码
$countryStats = $db->fetchAll(
$db->select('country', array('COUNT(id)' => 'count'))->from(...)
->group('country')->order('count', Typecho_Db::SORT_DESC)->limit(30)
);功能新增
自动清理历史数据
新增 autoCleanup 和 retentionDays 配置项,每天最多执行一次清理。
// Database.php
public static function maybeCleanup($force = false)
{
$days = max(1, min(3650, $days));
$today = self::siteDate('Y-m-d');
$lastRun = isset($options->visitorLoggerProLastCleanup) ? ... : '';
if (!$force && $lastRun === $today) { return 0; }
$deleted = $db->query(
$db->delete('table.visitor_log')->where('time < ?', self::siteDate('Y-m-d H:i:s', time() - $days * 86400))
);
self::setRuntimeOption($db, 'visitorLoggerProLastCleanup', $today);
return (int) $deleted;
}ECharts 加载方式可配置
新增 echartsSource 配置项,可选 CDN(失败回落本地)或直接本地。
// Plugin.php 配置
$echartsSource = new Typecho_Widget_Helper_Form_Element_Radio(
'echartsSource',
array(
'cdn' => _t('公共 CDN(失败时回落本地)'),
'local' => _t('直接使用本地资源')
),
'cdn', ...
);信任反向代理 IP 开关
新增 trustProxy 配置项,默认关闭,仅在站点位于可信 CDN/反向代理后时启用。
IP 过滤外部文件支持
新增 ip_filters.json 文件支持,可批量导入需忽略的 IP 列表。
// Plugin.php
$filterFile = __DIR__ . '/ip_filters.json';
if (is_file($filterFile)) {
$filtered = json_decode((string) file_get_contents($filterFile), true);
if (is_array($filtered) && in_array($ip, $filtered, true)) {
return true;
}
}兼容性改进
Typecho 1.3 命名空间适配
adapter.php 新增了 Typecho 1.3 重构后的类名别名:
// 新增别名
if (!interface_exists('Typecho_Plugin_Interface') && interface_exists('\\Typecho\\Plugin\\PluginInterface')) {
class_alias('\\Typecho\\Plugin\\PluginInterface', 'Typecho_Plugin_Interface');
}
if (!class_exists('Typecho_Widget') && class_exists('\\Typecho\\Widget')) {
class_alias('\\Typecho\\Widget', 'Typecho_Widget');
}
if (!interface_exists('Widget_Interface_Do') && interface_exists('\\Widget\\ActionInterface')) {
class_alias('\\Widget\\ActionInterface', 'Widget_Interface_Do');
}
if (!class_exists('Helper') && class_exists('\\Utils\\Helper')) {
class_alias('\\Utils\\Helper', 'Helper');
}时区感知日期
改进前:使用 date() 函数,依赖 PHP 全局时区设置。
改进后:Database::siteDate() 读取 Typecho 站点时区配置,确保统计时间边界与后台一致。
public static function siteDate($format = 'Y-m-d H:i:s', $timestamp = null)
{
$timezone = Helper::options()->timezone;
if (is_numeric($timezone)) {
return gmdate($format, $timestamp + (int) $timezone);
}
// ... DateTimeZone 处理
}前端改进
资源加载器独立为 js/resource-loader.js
改进前:panel.php 和 trend.php 各内联约 70 行相同的 loadScriptWithTimeout()、loadECharts()、loadFlatpickr() 函数。
改进后:统一封装为 VisitorLoggerProLoader 对象,由 PHP 端注入资源配置后调用。
// 新代码(js/resource-loader.js)
var VisitorLoggerProLoader = {
loadECharts: function (config) { /* ... */ },
loadFlatpickr: function (config) { /* ... */ }
};
// panel.php / trend.php
const visitorLoggerProResources = <?php echo json_encode($resourceConfig, JSON_UNESCAPED_SLASHES); ?>;
Promise.allSettled([
VisitorLoggerProLoader.loadECharts(visitorLoggerProResources),
VisitorLoggerProLoader.loadFlatpickr(visitorLoggerProResources)
]).then(() => { initializeApp(); });API 路径使用 Typecho 路由
改进前:硬编码相对路径 ../usr/plugins/VisitorLoggerPro/getVisitStatistic.php。
改进后:使用 Typecho 安全 URL 生成。
$aggregateApiUrl = $security->getIndex('/action/visitor-stats-api?do=aggregate');
// JS: fetch(<?php echo json_encode($aggregateApiUrl); ?>, { ... })日志表格显示完整地理信息
改进前:国家列只显示 country 字段。
改进后:合并显示 country / region / city,过滤掉 Unknown 值。
<td><?php echo htmlspecialchars(implode(' / ', array_filter(
array($log['country'], $log['region'], $log['city']),
function ($value) { return $value && $value !== 'Unknown'; }
))); ?></td>总结
经过这一次改进,终于是把我网站应用栈里面最屎山的一个插件给铲平了,让我的网站能更加健壮。更新后的代码已经同步发布到了GitHub,项目:VisitorLoggerPro-Enhanced。
建议使用的各位更新,更新之前建议清空所有访问记录,禁用插件进行替换。