Files
YwxAppThink/ywxapp/middleware/SpiderStat.php
T
yangwx 18101cf11a feat: 上线前安全加固与发布工程化;appmall 由插件迁移为独立应用
安全(P0)
- 发布包签名:新增 scripts/package_keygen.php、scripts/package_verify.php、
  ywxapp\service\PackageVerifier;FrameworkService::apply() 解压前强制验签
  (ywxapp.framework_package_verify,默认开启)
- JWT 收紧:config/jwt.php 移除公开默认密钥,JwtService::assertStrongSecret()
  校验非空 / 长度≥32 / 已知弱值;.env.production.example 键名由 JWT_SECRET_KEY 修正为 JWT_SECRET
- /api/v1/Article:免登录白名单收窄为只读接口,写操作字段白名单 + assertOwner() 越权校验
- 开发者令牌:查找接口不再回显明文、统一失败文案 + 限流
- TemplateInstaller:文件名白名单 + realpath 归一化 + 目录内断言,修复 Zip Slip;补 use ZipArchive
- 后台登录:接入验证码、失败锁定与登录限流(config/ratelimit.php)
- 缓存 / Session 驱动可由 .env 覆盖(CACHE_DRIVER / SESSION_TYPE),集群部署改 redis
- paycenter 支付日志开关改为 PAY_LOG,不再跟随 APP_DEBUG

工程化 / 交付
- 新增 CHANGELOG.md 与 .env.production.example;composer.lock 入库锁定依赖版本
- 插件离线安装 CLI 增加 --uid,后台上传安装透传 uid,付费插件授权校验可通过
- 授权拒绝文案区分「未携带 uid」与「未找到有效授权」,便于排障
- .gitignore 放行 .env.production.example / .env.addon.example / .gitattributes / public/.htaccess

结构
- appmall 由 addon/appmall 迁移为独立应用 app/appmall(市场 / 开发者中心 / 审核 / 分润)
2026-10-22 00:53:39 +08:00

141 lines
6.3 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
// +----------------------------------------------------------------------
// | YwxApp [ WE CAN DO IT JUST THINK ]
// +----------------------------------------------------------------------
// | Copyright (c) 2026-2036 http://ywxapp.cn All rights reserved.
// +----------------------------------------------------------------------
// | Author: ywxapp<admin@ywxapp.cn>
// +----------------------------------------------------------------------
declare(strict_types=1);
namespace ywxapp\middleware;
use think\facade\Db;
use ywxapp\model\BaseModel;
use ywxapp\library\SpiderDetect;
use ywxapp\library\SpiderVerify;
/**
* 全站搜索蜘蛛统计中间件(全局注册于 app/middleware.php)
*
* 设计要点:
* - 仅命中蜘蛛 UA 才落库,普通用户请求零额外查询;
* - 落库在响应生成之后($next 之后),不阻塞蜘蛛抓取响应;
* - 明细写 spider_log,按日聚合 upsert 到 spider_stat(报表免全表扫描);
* - 任何数据库异常静默吞掉(统计绝不能影响业务),缺表时经 BaseModel 自愈引擎 自愈一次;
* - install.sql 为唯一事实源,此处 DDL 仅为老库运行时兜底。
*
* 真伪识别(v2 新增):
* - UA 命中后调用 SpiderVerify 做 rDNS 双向验证(PTR + 官方后缀 + A/AAAA 正查);
* - 结果写入 verified(0待验证 / 1真蜘蛛 / 2假蜘蛛)与 rdns(反查主机名);
* - 聚合表额外维护 fake_count,后台可直观看到「多少是伪造 UA 刷的」;
* - 校验结果按 IP 缓存 24h,DNS 查询有硬超时与熔断,不会拖慢站点。
*
* @author ywxapp <admin@ywxapp.cn>
*/
class SpiderStat
{
public function handle($request, \Closure $next)
{
$response = $next($request);
try {
$ua = (string) $request->header('user-agent', '');
$spider = SpiderDetect::detect($ua);
if ($spider !== null) {
$this->record($request, $response, $spider, $ua);
}
} catch (\Throwable $e) {
// 统计失败绝不影响正常响应
}
return $response;
}
/**
* 落库:明细 + 按日聚合(含真伪判定)
*/
protected function record($request, $response, string $spider, string $ua): void
{
$prefix = config('database.connections.mysql.prefix', 'wxapp_');
$logTable = $prefix . 'common_spider_log';
$statTable = $prefix . 'common_spider_stat';
$ip = mb_substr((string) $request->ip(), 0, 45);
// 真伪校验:命中缓存则零 DNS 开销;仅对配置了官方 rDNS 的蜘蛛生效
$verdict = SpiderVerify::verify($ip, $spider);
$data = [
'spider' => $spider,
'url' => mb_substr((string) $request->url(), 0, 500),
'ip' => $ip,
'app' => mb_substr((string) (app('http')->getName() ?: ''), 0, 20),
'user_agent' => mb_substr($ua, 0, 500),
'http_code' => method_exists($response, 'getCode') ? (int) $response->getCode() : 200,
'verified' => (int) $verdict['status'],
'rdns' => mb_substr((string) $verdict['rdns'], 0, 255),
'create_at' => time(),
];
try {
$this->insert($logTable, $statTable, $data, $spider);
} catch (\Throwable $e) {
// 表可能不存在或老库缺列:自愈一次后重试
$this->ensureTables($logTable, $statTable);
$this->insert($logTable, $statTable, $data, $spider);
}
}
/**
* 写明细 + 聚合 upsert
*/
protected function insert(string $logTable, string $statTable, array $data, string $spider): void
{
Db::table($logTable)->insert($data);
// 按日聚合:主键(stat_date, spider),存在则累加;fake_count 只累加假蜘蛛次数
Db::execute(
"INSERT INTO `{$statTable}` (`stat_date`, `spider`, `count`, `fake_count`) VALUES (?, ?, 1, ?) "
. 'ON DUPLICATE KEY UPDATE `count` = `count` + 1, `fake_count` = `fake_count` + VALUES(`fake_count`)',
[date('Y-m-d'), $spider, (int) ($data['verified'] ?? 0) === 2 ? 1 : 0]
);
}
/**
* 缺表/缺列自愈(DDL 与 public/install/install.sql 保持一致)
*/
protected function ensureTables(string $logTable, string $statTable): void
{
BaseModel::ensureTable($logTable, "CREATE TABLE IF NOT EXISTS `{$logTable}` (
`id` bigint unsigned NOT NULL AUTO_INCREMENT COMMENT '主键ID',
`spider` varchar(20) NOT NULL DEFAULT '' COMMENT '蜘蛛标识',
`url` varchar(500) NOT NULL DEFAULT '' COMMENT '抓取URL',
`ip` varchar(45) NOT NULL DEFAULT '' COMMENT '来源IP',
`app` varchar(20) NOT NULL DEFAULT '' COMMENT '应用名',
`user_agent` varchar(500) NOT NULL DEFAULT '' COMMENT 'User-Agent',
`http_code` smallint unsigned NOT NULL DEFAULT '200' COMMENT '响应状态码',
`verified` tinyint(1) NOT NULL DEFAULT '0' COMMENT '真伪:0待验证 1真蜘蛛 2假蜘蛛',
`rdns` varchar(255) NOT NULL DEFAULT '' COMMENT 'rDNS反查主机名',
`create_at` int NOT NULL DEFAULT '0' COMMENT '抓取时间',
PRIMARY KEY (`id`),
KEY `idx_spider_time` (`spider`,`create_at`),
KEY `idx_create_at` (`create_at`),
KEY `idx_verified` (`verified`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='搜索蜘蛛抓取日志'");
BaseModel::ensureTable($statTable, "CREATE TABLE IF NOT EXISTS `{$statTable}` (
`stat_date` date NOT NULL COMMENT '统计日期',
`spider` varchar(20) NOT NULL DEFAULT '' COMMENT '蜘蛛标识',
`count` int unsigned NOT NULL DEFAULT '0' COMMENT '抓取次数',
`fake_count` int unsigned NOT NULL DEFAULT '0' COMMENT '其中假蜘蛛次数',
PRIMARY KEY (`stat_date`,`spider`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='搜索蜘蛛按日统计'");
// 老库补列(表已存在但缺新字段)
BaseModel::ensureColumn($logTable, 'verified', "tinyint(1) NOT NULL DEFAULT '0' COMMENT '真伪:0待验证 1真蜘蛛 2假蜘蛛'");
BaseModel::ensureColumn($logTable, 'rdns', "varchar(255) NOT NULL DEFAULT '' COMMENT 'rDNS反查主机名'");
BaseModel::ensureColumn($statTable, 'fake_count', "int unsigned NOT NULL DEFAULT '0' COMMENT '其中假蜘蛛次数'");
}
}