chore: 重写初始提交(清空历史,整理后全量提交)
This commit is contained in:
@@ -0,0 +1,104 @@
|
||||
<?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 addon\haonav\model;
|
||||
|
||||
use think\facade\Cache;
|
||||
use ywxapp\model\BaseModel;
|
||||
|
||||
/**
|
||||
* 广告位模型
|
||||
* 支持多广告位(首页顶部/底部、详情页内联、侧边栏),可投放图片广告或自定义代码(联盟广告)。
|
||||
* 表在首次访问时由 BaseModel 自愈引擎建立,无需重跑 install.sql。
|
||||
*/
|
||||
class Ad extends BaseModel
|
||||
{
|
||||
// 广告位标识
|
||||
const SLOT_HOME_TOP = 'home_top';
|
||||
const SLOT_HOME_BOTTOM = 'home_bottom';
|
||||
const SLOT_DETAIL_INLINE = 'detail_inline';
|
||||
const SLOT_SIDEBAR = 'sidebar';
|
||||
|
||||
// 类型:1=图片广告 2=代码广告
|
||||
const TYPE_IMAGE = 1;
|
||||
const TYPE_CODE = 2;
|
||||
|
||||
/**
|
||||
* 广告位字典(标识 => 中文名)
|
||||
*/
|
||||
public static function slots(): array
|
||||
{
|
||||
return [
|
||||
self::SLOT_HOME_TOP => '首页顶部',
|
||||
self::SLOT_HOME_BOTTOM => '首页底部',
|
||||
self::SLOT_DETAIL_INLINE => '详情页内联',
|
||||
self::SLOT_SIDEBAR => '侧边栏',
|
||||
];
|
||||
}
|
||||
|
||||
protected function getOptions(): array
|
||||
{
|
||||
return [
|
||||
'strict' => false,
|
||||
'name' => 'haonav_ads',
|
||||
'autoRelation' => [],
|
||||
'createTime' => 'create_at',
|
||||
'updateTime' => 'update_at',
|
||||
'dateFormat' => 'Y-m-d H:i:s',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 取某广告位当前生效的广告列表(状态启用 + 在生效时间范围内)
|
||||
*/
|
||||
public static function getBySlot(string $slot): array
|
||||
{
|
||||
$now = time();
|
||||
return self::where('slot', $slot)
|
||||
->where('status', 1)
|
||||
->where(function ($q) use ($now) {
|
||||
$q->whereNull('start_at')->whereOr('start_at', '<=', $now);
|
||||
})
|
||||
->where(function ($q) use ($now) {
|
||||
$q->whereNull('end_at')->whereOr('end_at', '>=', $now);
|
||||
})
|
||||
->order('sort', 'desc')
|
||||
->order('id', 'desc')
|
||||
->select()
|
||||
->toArray();
|
||||
}
|
||||
|
||||
/**
|
||||
* 所有广告位当前生效广告(按 slot 分组),带 60s 缓存,供前台统一 assign
|
||||
*/
|
||||
public static function allActiveBySlot(): array
|
||||
{
|
||||
$cacheKey = 'haonav_ads_all';
|
||||
$out = Cache::get($cacheKey);
|
||||
if (is_array($out)) {
|
||||
return $out;
|
||||
}
|
||||
$out = [];
|
||||
foreach (self::slots() as $k => $v) {
|
||||
$out[$k] = self::getBySlot($k);
|
||||
}
|
||||
Cache::set($cacheKey, $out, 60);
|
||||
return $out;
|
||||
}
|
||||
|
||||
/**
|
||||
* 清除广告缓存(后台增删改时调用)
|
||||
*/
|
||||
public static function clearCache(): void
|
||||
{
|
||||
Cache::delete('haonav_ads_all');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
<?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 addon\haonav\model;
|
||||
|
||||
use ywxapp\model\BaseModel;
|
||||
|
||||
/**
|
||||
* 友链/广告 自助申请模型
|
||||
* 访客在前台提交收录或广告合作申请,后台审核;通过的友链可一键写入 Links。
|
||||
* 表在首次访问时由 BaseModel 自愈引擎 自愈建立,无需重跑 install.sql。
|
||||
*/
|
||||
class Apply extends BaseModel
|
||||
{
|
||||
// 申请类型
|
||||
const TYPE_LINK = 1; // 友链/收录申请
|
||||
const TYPE_AD = 2; // 广告合作申请
|
||||
|
||||
// 审核状态
|
||||
const STATUS_PENDING = 0; // 待审核
|
||||
const STATUS_APPROVED = 1; // 已通过
|
||||
const STATUS_REJECTED = 2; // 已拒绝
|
||||
|
||||
/**
|
||||
* 类型字典
|
||||
*/
|
||||
public static function types(): array
|
||||
{
|
||||
return [
|
||||
self::TYPE_LINK => '收录/友链',
|
||||
self::TYPE_AD => '广告合作',
|
||||
];
|
||||
}
|
||||
|
||||
protected function getOptions(): array
|
||||
{
|
||||
return [
|
||||
'strict' => false,
|
||||
'name' => 'haonav_apply',
|
||||
'autoRelation' => [],
|
||||
'createTime' => 'create_at',
|
||||
'updateTime' => 'update_at',
|
||||
'dateFormat' => 'Y-m-d H:i:s',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 同一 IP 限频:窗口期内最多 N 条(默认 1 小时 5 条)
|
||||
*/
|
||||
public static function ipOverLimit(string $ip, int $max = 5, int $window = 3600): bool
|
||||
{
|
||||
$count = self::where('ip', $ip)->where('create_at', '>=', time() - $window)->count();
|
||||
return $count >= $max;
|
||||
}
|
||||
|
||||
/**
|
||||
* 同 URL 去重:待审/已通过中已存在同地址申请则拒收
|
||||
*/
|
||||
public static function urlExists(string $url): bool
|
||||
{
|
||||
return self::where('url', $url)->whereIn('status', [self::STATUS_PENDING, self::STATUS_APPROVED])->count() > 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
<?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 addon\haonav\model;
|
||||
|
||||
use ywxapp\model\BaseModel;
|
||||
|
||||
|
||||
class Category extends BaseModel
|
||||
{
|
||||
|
||||
protected function getOptions(): array
|
||||
{
|
||||
// 所有的参数配置统一返回
|
||||
return [
|
||||
'strict' => false,
|
||||
'name' => 'haonav_category',
|
||||
// 'connection' => '',
|
||||
// 'query' => [],
|
||||
// 'type' => [],
|
||||
// 'hidden' => [],
|
||||
// 'visible' => [],
|
||||
// 'append' => [],
|
||||
'autoRelation' => [],
|
||||
'createTime' => 'create_at',
|
||||
'updateTime' => 'update_at',
|
||||
'dateFormat' => 'Y-m-d H:i:s',
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
public function links()
|
||||
{
|
||||
return $this->hasMany(Links::class, 'cid', 'id');
|
||||
}
|
||||
|
||||
/**
|
||||
* 子分类(二级及更深层次)
|
||||
*/
|
||||
public function children()
|
||||
{
|
||||
return $this->hasMany(self::class, 'pid', 'id');
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除前保护:存在子分类或下属链接时禁止删除,避免产生孤儿数据
|
||||
* @param \think\Model $model
|
||||
* @throws \think\Exception
|
||||
*/
|
||||
public static function onBeforeDelete($model)
|
||||
{
|
||||
$childCount = self::where('pid', $model->id)->count();
|
||||
if ($childCount > 0) {
|
||||
throw new \think\Exception('该分类下还存在 ' . $childCount . ' 个子分类,请先处理子分类');
|
||||
}
|
||||
$linkCount = \addon\haonav\model\Links::where('cid', $model->id)->count();
|
||||
if ($linkCount > 0) {
|
||||
throw new \think\Exception('该分类下还存在 ' . $linkCount . ' 个链接,请先移走或删除这些链接');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
<?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 addon\haonav\model;
|
||||
|
||||
use ywxapp\model\BaseModel;
|
||||
|
||||
|
||||
class Configure extends BaseModel
|
||||
{
|
||||
|
||||
protected function getOptions(): array
|
||||
{
|
||||
// 所有的参数配置统一返回
|
||||
return [
|
||||
'strict' => false,
|
||||
'name' => 'haonav_config',
|
||||
'autoRelation' => [],
|
||||
'createTime' => 'create_at',
|
||||
'updateTime' => 'update_at',
|
||||
'dateFormat' => 'Y-m-d H:i:s',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 读取单个配置值
|
||||
* @param string $name
|
||||
* @param mixed $default
|
||||
* @return mixed
|
||||
*/
|
||||
public static function getVal(string $name, $default = '')
|
||||
{
|
||||
static $cache = [];
|
||||
if (array_key_exists($name, $cache)) {
|
||||
return $cache[$name];
|
||||
}
|
||||
$val = self::where('name', $name)->value('value');
|
||||
$val = $val === null ? $default : $val;
|
||||
$cache[$name] = $val;
|
||||
return $val;
|
||||
}
|
||||
|
||||
/**
|
||||
* 读取全部配置为 name => value 数组
|
||||
* @return array
|
||||
*/
|
||||
public static function getAll(): array
|
||||
{
|
||||
return self::column('value', 'name');
|
||||
}
|
||||
|
||||
/**
|
||||
* 老库结构自愈:早期 install.sql 的 config 表 id 无 PRIMARY KEY/AUTO_INCREMENT,
|
||||
* create() 不带 id 会报 1364 Field 'id' doesn't have a default value,此处运行时修复。
|
||||
*/
|
||||
protected static function ensurePk(): void
|
||||
{
|
||||
static $done = false;
|
||||
if ($done) {
|
||||
return;
|
||||
}
|
||||
self::ensureAutoIncrementPk('wxapp_haonav_config');
|
||||
$done = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 返利配置自愈:老库安装时 install.sql 尚未含这 3 行,访问配置页时补种,
|
||||
* 保证后台「返利」分组始终可配置(INSERT 幂等,按 name 判重)。
|
||||
*/
|
||||
public static function ensureRebateConfig(): void
|
||||
{
|
||||
self::ensurePk();
|
||||
$defaults = [
|
||||
'enable_rebate' => ['返利', '开启返利PID', '开启后,命中适用域名的链接点击将自动拼接返利PID', 'radio', '0', '1,0'],
|
||||
'rebate_param' => ['返利', '返利参数名', '拼接在网址后的参数名,默认 pid', 'string', 'pid', ''],
|
||||
'rebate_domains' => ['返利', '返利适用域名', '仅这些域名(含子域名)会拼接返利PID,逗号或换行分隔,如 taobao.com,jd.com,pinduoduo.com', 'textarea', 'taobao.com,jd.com,pinduoduo.com', ''],
|
||||
];
|
||||
foreach ($defaults as $name => $cfg) {
|
||||
if (self::where('name', $name)->count() === 0) {
|
||||
self::create([
|
||||
'name' => $name,
|
||||
'group' => $cfg[0],
|
||||
'title' => $cfg[1],
|
||||
'tip' => $cfg[2],
|
||||
'type' => $cfg[3],
|
||||
'value' => $cfg[4],
|
||||
'content' => $cfg[5],
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 死链自动下线配置自愈:老库安装时 install.sql 尚未含这 3 行,访问配置页时补种,
|
||||
* 保证后台「导航配置」的 task 分组始终可配置(INSERT 幂等,按 name 判重)。
|
||||
*/
|
||||
public static function ensureDeadlinkConfig(): void
|
||||
{
|
||||
self::ensurePk();
|
||||
$defaults = [
|
||||
'deadlink_auto' => ['task', '死链自动下线', '连续检测失败达阈值后,自动将链接置为禁用(下线)', 'radio', '0', '1,0'],
|
||||
'deadlink_threshold' => ['task', '下线阈值(连续失败次数)', '达到该连续失败次数才下线,避免单次网络抖动误杀(建议2)', 'number', '2', ''],
|
||||
'deadlink_recover' => ['task', '死链恢复自动上线', '链接重新可达时自动恢复为启用(仅对检测器自动下线的链接生效)', 'radio', '0', '1,0'],
|
||||
];
|
||||
foreach ($defaults as $name => $cfg) {
|
||||
if (self::where('name', $name)->count() === 0) {
|
||||
self::create([
|
||||
'name' => $name,
|
||||
'group' => $cfg[0],
|
||||
'title' => $cfg[1],
|
||||
'tip' => $cfg[2],
|
||||
'type' => $cfg[3],
|
||||
'value' => $cfg[4],
|
||||
'content' => $cfg[5],
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
<?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 addon\haonav\model;
|
||||
|
||||
use ywxapp\model\BaseModel;
|
||||
|
||||
/**
|
||||
* 用户云端收藏
|
||||
* @mixin \think\Model
|
||||
*/
|
||||
class Favorite extends BaseModel
|
||||
{
|
||||
protected function getOptions(): array
|
||||
{
|
||||
return [
|
||||
'strict' => false,
|
||||
'name' => 'haonav_favorites',
|
||||
'autoRelation' => [],
|
||||
'createTime' => 'create_at',
|
||||
'updateTime' => false,
|
||||
'dateFormat' => 'Y-m-d H:i:s',
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
<?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 addon\haonav\model;
|
||||
|
||||
use ywxapp\model\BaseModel;
|
||||
|
||||
/**
|
||||
* 用户收藏夹分享配置(一个用户一条)
|
||||
*
|
||||
* 用户可把「我的导航」生成一个只读分享链接,他人凭 token 访问 shared 页浏览,
|
||||
* enabled=1 才对外可见;token 可重置(旧链接立即失效)。
|
||||
*
|
||||
* @mixin \think\Model
|
||||
*/
|
||||
class FavoriteShare extends BaseModel
|
||||
{
|
||||
protected function getOptions(): array
|
||||
{
|
||||
return [
|
||||
'strict' => false,
|
||||
'name' => 'haonav_favorite_shares',
|
||||
'autoRelation' => [],
|
||||
'createTime' => 'create_at',
|
||||
'updateTime' => 'update_at',
|
||||
'dateFormat' => 'Y-m-d H:i:s',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成一个随机分享令牌(16位十六进制)
|
||||
*/
|
||||
public static function genToken(): string
|
||||
{
|
||||
try {
|
||||
return bin2hex(random_bytes(8));
|
||||
} catch (\Throwable $e) {
|
||||
return substr(md5(uniqid((string)mt_rand(), true)), 0, 16);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 取当前用户的分享配置,无则创建一条(默认关闭、已生成 token)
|
||||
*/
|
||||
public static function forUser(int $uid): FavoriteShare
|
||||
{
|
||||
$row = self::where('user_id', $uid)->find();
|
||||
if (!$row) {
|
||||
$row = new self();
|
||||
$row->user_id = $uid;
|
||||
$row->token = self::genToken();
|
||||
$row->title = '我的导航收藏夹';
|
||||
$row->enabled = 0;
|
||||
$row->views = 0;
|
||||
$row->save();
|
||||
} elseif (empty($row->token)) {
|
||||
$row->token = self::genToken();
|
||||
$row->save();
|
||||
}
|
||||
return $row;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,449 @@
|
||||
<?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 addon\haonav\model;
|
||||
|
||||
use ywxapp\model\BaseModel;
|
||||
use addon\haonav\model\Configure as ConfigureModel;
|
||||
use addon\haonav\library\Pinyin;
|
||||
|
||||
|
||||
class Links extends BaseModel
|
||||
{
|
||||
/**
|
||||
* 序列化时附加的虚拟字段(列表/详情输出分类名等)。
|
||||
* 注意:getCateAttr 是虚拟字段获取器,ThinkPHP 默认 toArray 只输出真实表字段,
|
||||
* 必须在此声明 append 才会进入 JSON(控制器里用 withAttr('cate') 无效,会被链式查询丢弃)。
|
||||
* @var array
|
||||
*/
|
||||
protected $append = ['cate', 'outbound_url'];
|
||||
|
||||
|
||||
protected function getOptions(): array
|
||||
{
|
||||
return [
|
||||
'strict' => false,
|
||||
'name' => 'haonav_links',
|
||||
'autoRelation' => [],
|
||||
'createTime' => 'create_at',
|
||||
'updateTime' => 'update_at',
|
||||
'dateFormat' => 'Y-m-d H:i:s',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 展示用图标:优先 favicon,其次 icon,最后默认图
|
||||
*/
|
||||
public function getShowIconAttr($value, $data)
|
||||
{
|
||||
$custom = !empty($data['favicon']) ? $data['favicon'] : (!empty($data['icon']) ? $data['icon'] : '');
|
||||
// 自定义图标(本地上传或非 icon.horse 外链)直接返回,保留原图;
|
||||
// 自动生成的 icon.horse 地址改为走本地代理(带缓存与失败降级),提升可靠性与速度。
|
||||
if ($custom && stripos($custom, 'icon.horse') === false) {
|
||||
return $custom;
|
||||
}
|
||||
$url = $data['url'] ?? '';
|
||||
if ($url) {
|
||||
return '/haonav/favicon.html?url=' . urlencode($url);
|
||||
}
|
||||
return '/static/haonav/img/default.png';
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据 URL 自动生成 icon.horse 的 favicon 地址
|
||||
*/
|
||||
public static function faviconOf(string $url): string
|
||||
{
|
||||
$host = parse_url($url, PHP_URL_HOST);
|
||||
return $host ? 'https://icon.horse/icon/' . $host : '';
|
||||
}
|
||||
|
||||
/**
|
||||
* 返利外链:enable_rebate 开启、本链接 pid 非空、且域名命中 rebate_domains 时,
|
||||
* 自动拼接返利参数(如 ?pid=xxx)。用于详情页「立即跳转」与倒计时自动跳转,让导航站赚返利。
|
||||
*/
|
||||
public function getOutboundUrlAttr($value, $data)
|
||||
{
|
||||
return self::buildOutboundUrl((string)($data['url'] ?? ''), (string)($data['pid'] ?? ''));
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据原始 URL + 返利 PID 生成返利外链(纯函数,便于测试与复用)
|
||||
*/
|
||||
public static function buildOutboundUrl(string $url, string $pid): string
|
||||
{
|
||||
if ($url === '' || $pid === '') {
|
||||
return $url;
|
||||
}
|
||||
if ((int)ConfigureModel::getVal('enable_rebate', '0') !== 1) {
|
||||
return $url;
|
||||
}
|
||||
$domains = self::rebateDomains();
|
||||
if (empty($domains)) {
|
||||
return $url;
|
||||
}
|
||||
$host = parse_url($url, PHP_URL_HOST);
|
||||
if (!is_string($host) || $host === '') {
|
||||
return $url;
|
||||
}
|
||||
$host = strtolower($host);
|
||||
$matched = false;
|
||||
foreach ($domains as $d) {
|
||||
if ($host === $d || substr($host, -strlen($d) - 1) === '.' . $d) {
|
||||
$matched = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!$matched) {
|
||||
return $url;
|
||||
}
|
||||
$param = (string)ConfigureModel::getVal('rebate_param', 'pid');
|
||||
if ($param === '') {
|
||||
$param = 'pid';
|
||||
}
|
||||
$sep = strpos($url, '?') !== false ? '&' : '?';
|
||||
return $url . $sep . urlencode($param) . '=' . urlencode($pid);
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析返利适用域名后缀(配置为逗号/空格/换行分隔;支持子域名)
|
||||
*/
|
||||
protected static function rebateDomains(): array
|
||||
{
|
||||
$raw = (string)ConfigureModel::getVal('rebate_domains', '');
|
||||
if ($raw === '') {
|
||||
return [];
|
||||
}
|
||||
$list = preg_split('/[\s,;]+/', $raw, -1, PREG_SPLIT_NO_EMPTY);
|
||||
return array_values(array_unique(array_map('strtolower', $list)));
|
||||
}
|
||||
|
||||
/**
|
||||
* 插入前:自动补全 favicon / icon
|
||||
*/
|
||||
public static function onBeforeInsert($data)
|
||||
{
|
||||
if (empty($data->url)) {
|
||||
return true;
|
||||
}
|
||||
$favicon = self::faviconOf($data->url);
|
||||
if ($favicon && empty($data->favicon)) {
|
||||
$data->favicon = $favicon;
|
||||
}
|
||||
if (empty($data->icon) && !empty($data->favicon)) {
|
||||
$data->icon = $data->favicon;
|
||||
}
|
||||
if (!empty($data->title)) {
|
||||
|
||||
$data->pinyin = Pinyin::keywordsOf((string)$data->title);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新前:仅当网址变更时才抓取远端元信息,且失败不影响保存;并同步刷新 favicon
|
||||
*/
|
||||
public static function onBeforeUpdate($data)
|
||||
{
|
||||
if (empty($data->url)) {
|
||||
return true;
|
||||
}
|
||||
$changed = $data->getChangedData();
|
||||
if (array_key_exists('url', $changed)) {
|
||||
try {
|
||||
$meta = self::fetchMeta($data->url);
|
||||
} catch (\Throwable $e) {
|
||||
$meta = false;
|
||||
}
|
||||
if (!empty($meta)) {
|
||||
$data->keywords = $meta['keywords'] ?: $data->keywords;
|
||||
$string = $meta['description'] ?: $data->description;
|
||||
$position = strpos($string, '。');
|
||||
$data->description = $position !== false ? substr($string, 0, $position) : $string;
|
||||
}
|
||||
}
|
||||
// 始终保证 favicon/icon 存在
|
||||
if (empty($data->favicon)) {
|
||||
$favicon = self::faviconOf($data->url);
|
||||
if ($favicon) {
|
||||
$data->favicon = $favicon;
|
||||
}
|
||||
}
|
||||
if (empty($data->icon) && !empty($data->favicon)) {
|
||||
$data->icon = $data->favicon;
|
||||
}
|
||||
if (!empty($data->title) && (array_key_exists('title', $changed) || empty($data->pinyin))) {
|
||||
|
||||
$data->pinyin = Pinyin::keywordsOf((string)$data->title);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 拼音回填:为存量数据补 pinyin(每次最多 $limit 条,幂等)
|
||||
* @return int 本次回填条数
|
||||
*/
|
||||
public static function backfillPinyin(int $limit = 300): int
|
||||
{
|
||||
try {
|
||||
|
||||
$rows = self::where(function ($q) {
|
||||
$q->whereNull('pinyin')->whereOr('pinyin', '');
|
||||
})->limit($limit)->select();
|
||||
$n = 0;
|
||||
foreach ($rows as $row) {
|
||||
$row->pinyin = Pinyin::keywordsOf((string)$row->title);
|
||||
$row->save();
|
||||
$n++;
|
||||
}
|
||||
return $n;
|
||||
} catch (\Throwable $e) {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 关联分类
|
||||
*/
|
||||
public function category()
|
||||
{
|
||||
return $this->belongsTo(Category::class, 'cid', 'id');
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取分类名称
|
||||
*/
|
||||
public function getCateAttr($value, $data)
|
||||
{
|
||||
static $cache = [];
|
||||
$cid = $data['cid'] ?? 0;
|
||||
if (! array_key_exists($cid, $cache)) {
|
||||
$cache[$cid] = Category::where('id', $cid)->value('title');
|
||||
}
|
||||
return $cache[$cid];
|
||||
}
|
||||
|
||||
/**
|
||||
* 站内搜索(兼容 LIKE,避免 FULLTEXT 分词配置差异)
|
||||
*/
|
||||
public static function search(string $keyword, int $page = 1, int $limit = 20)
|
||||
{
|
||||
|
||||
$kw = $keyword;
|
||||
return self::where('status', 1)
|
||||
->where(function ($q) use ($kw) {
|
||||
$q->where('title', 'like', '%' . $kw . '%')
|
||||
->whereOr('description', 'like', '%' . $kw . '%')
|
||||
->whereOr('keywords', 'like', '%' . $kw . '%');
|
||||
// 纯字母关键字同时匹配拼音(首字母+全拼),实现拼音搜索
|
||||
if (preg_match('/^[a-zA-Z]+$/', $kw)) {
|
||||
$q->whereOr('pinyin', 'like', '%' . strtolower($kw) . '%');
|
||||
}
|
||||
})
|
||||
->order('click_count', 'desc')
|
||||
->paginate(['page' => $page, 'list_rows' => $limit]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 检测单个链接可达性,返回 HTTP 状态码;不可达返回 false
|
||||
* 先用 HEAD,若返回 >=400(很多站点不支持 HEAD,会误判 403/405)再用 GET 复核一次,降低误杀。
|
||||
*/
|
||||
public static function checkLink(string $url, int $timeout = 8)
|
||||
{
|
||||
if (!class_exists(\GuzzleHttp\Client::class)) {
|
||||
return false;
|
||||
}
|
||||
$opts = [
|
||||
'headers' => ['Member-Agent' => 'Mozilla/5.0 (compatible; HaonavBot/1.0)'],
|
||||
'allow_redirects' => ['max' => 5],
|
||||
'stream' => true, // 只取状态码,不下载响应体
|
||||
];
|
||||
// Client 构造不做网络请求,提前实例化以保证下方 catch 中 $client 已定义
|
||||
$client = new \GuzzleHttp\Client(['timeout' => $timeout, 'verify' => false]);
|
||||
try {
|
||||
$code = $client->request('HEAD', $url, $opts)->getStatusCode();
|
||||
if ($code >= 400) {
|
||||
// HEAD 被拒,改用 GET 复核(部分站点禁用 HEAD 返回 403/405/501)
|
||||
$code = $client->request('GET', $url, $opts)->getStatusCode();
|
||||
}
|
||||
return $code;
|
||||
} catch (\GuzzleHttp\Exception\RequestException $e) {
|
||||
if ($e->hasResponse()) {
|
||||
$code = $e->getResponse()->getStatusCode();
|
||||
if ($code >= 400) {
|
||||
try {
|
||||
return $client->request('GET', $url, $opts)->getStatusCode();
|
||||
} catch (\Throwable $e2) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return $code;
|
||||
}
|
||||
return false;
|
||||
} catch (\Throwable $e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量检测全部链接,更新 last_check_at / status_code
|
||||
* @return array [total, ok, dead]
|
||||
*/
|
||||
public static function checkAllLinks(int $timeout = 8): array
|
||||
{
|
||||
|
||||
return self::checkList(self::select(), $timeout);
|
||||
}
|
||||
|
||||
/**
|
||||
* 小批量巡检:优先检测「最久未检测」的链接(NULL 最先)
|
||||
* @return array [total, ok, dead]
|
||||
*/
|
||||
public static function checkBatch(int $limit = 20, int $timeout = 5): array
|
||||
{
|
||||
|
||||
$links = self::order('last_check_at', 'asc')->limit(max(1, $limit))->select();
|
||||
return self::checkList($links, $timeout);
|
||||
}
|
||||
|
||||
/**
|
||||
* 对给定链接集合执行检测并落库(含死链自动下线/恢复)
|
||||
* @return array [total, ok, dead, offlined, recovered]
|
||||
*/
|
||||
protected static function checkList($links, int $timeout): array
|
||||
{
|
||||
// 死链自动下线配置(一次检测只读一次),与「导航配置」中 task 分组保持一致
|
||||
$auto = (int)ConfigureModel::getVal('deadlink_auto', '0') === 1;
|
||||
$threshold = max(1, (int)ConfigureModel::getVal('deadlink_threshold', '2'));
|
||||
$recover = (int)ConfigureModel::getVal('deadlink_recover', '0') === 1;
|
||||
|
||||
$total = 0;
|
||||
$ok = 0;
|
||||
$dead = 0;
|
||||
$offlined = 0;
|
||||
$recovered = 0;
|
||||
|
||||
foreach ($links as $link) {
|
||||
$total++;
|
||||
$code = self::checkLink($link->url, $timeout);
|
||||
$link->last_check_at = time();
|
||||
$isDead = ($code === false) || ($code >= 400);
|
||||
|
||||
if ($isDead) {
|
||||
$link->status_code = ($code === false) ? 0 : $code;
|
||||
$dead++;
|
||||
// 仅对启用中的链接累计失败并标记死链,避免误动管理员手动禁用/待审核项
|
||||
if ((int)$link->status === 1) {
|
||||
$link->fail_count = (int)($link->fail_count ?? 0) + 1;
|
||||
$link->dead_at = time();
|
||||
if ($auto && (int)$link->fail_count >= $threshold) {
|
||||
$link->status = 0;
|
||||
$offlined++;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
$link->status_code = $code;
|
||||
// dead_at 仅检测器下线的链接会带时间戳,手动禁用的为 0
|
||||
$wasDead = (int)($link->dead_at ?? 0) > 0;
|
||||
$link->fail_count = 0;
|
||||
$link->dead_at = 0;
|
||||
$ok++;
|
||||
// 死链恢复:曾被检测器下线且开启自动恢复 -> 重新启用(手动禁用项 dead_at=0,不会误恢复)
|
||||
if ($recover && $wasDead && (int)$link->status === 0) {
|
||||
$link->status = 1;
|
||||
$recovered++;
|
||||
}
|
||||
}
|
||||
$link->save();
|
||||
}
|
||||
return ['total' => $total, 'ok' => $ok, 'dead' => $dead, 'offlined' => $offlined, 'recovered' => $recovered];
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取网页元信息
|
||||
*/
|
||||
public static function fetchMeta($url)
|
||||
{
|
||||
libxml_use_internal_errors(true);
|
||||
|
||||
if (!class_exists(\GuzzleHttp\Client::class)) {
|
||||
throw new \Exception('请先安装 Guzzle: composer require guzzlehttp/guzzle');
|
||||
}
|
||||
|
||||
try {
|
||||
$client = new \GuzzleHttp\Client(['timeout' => 10, 'verify' => false]);
|
||||
$response = $client->get($url, [
|
||||
'headers' => [
|
||||
'Member-Agent' => 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36'
|
||||
]
|
||||
]);
|
||||
|
||||
$html = (string)$response->getBody();
|
||||
$headers = $response->getHeader('Content-Type');
|
||||
} catch (\Exception $e) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$html = self::toUtf8($html, $headers);
|
||||
|
||||
$html = preg_replace('/<meta[^>]*charset=[^>]*>/is', '', $html);
|
||||
$html = preg_replace('/<head>/i', '<head><meta charset="UTF-8">', $html, 1);
|
||||
|
||||
$dom = new \DOMDocument();
|
||||
$dom->loadHTML($html, LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD);
|
||||
|
||||
$xpath = new \DOMXPath($dom);
|
||||
|
||||
$titleNode = $xpath->query('//title');
|
||||
$keywordsNode = $xpath->query('//meta[@name="keywords"]');
|
||||
$descNode = $xpath->query('//meta[@name="description"]');
|
||||
|
||||
return [
|
||||
'title' => $titleNode->length ? trim($titleNode->item(0)->nodeValue) : '',
|
||||
'keywords' => $keywordsNode->length ? trim($keywordsNode->item(0)->getAttribute('content')) : '',
|
||||
'description' => $descNode->length ? trim($descNode->item(0)->getAttribute('content')) : '',
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
public static function toUtf8($html, array $headers = [])
|
||||
{
|
||||
$charset = null;
|
||||
|
||||
if (!empty($headers)) {
|
||||
foreach ($headers as $header) {
|
||||
if (preg_match('/charset=([^\s;]+)/i', $header, $match)) {
|
||||
$charset = strtoupper(trim($match[1]));
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!$charset) {
|
||||
preg_match('/<meta[^>]*charset=["\']?\s*(gbk|gb2312|big5|iso-8859-1|utf-8)/i', $html, $match);
|
||||
$charset = $match[1] ?? null;
|
||||
}
|
||||
|
||||
if (!$charset) {
|
||||
$charset = mb_detect_encoding($html, ['UTF-8', 'GBK', 'GB2312', 'BIG5', 'ISO-8859-1']);
|
||||
}
|
||||
|
||||
if ($charset && strtoupper($charset) !== 'UTF-8') {
|
||||
if (strtoupper($charset) === 'ISO-8859-1') {
|
||||
$html = mb_convert_encoding($html, 'UTF-8', 'GBK');
|
||||
} else {
|
||||
$html = mb_convert_encoding($html, 'UTF-8', $charset);
|
||||
}
|
||||
}
|
||||
|
||||
return $html;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user