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(市场 / 开发者中心 / 审核 / 分润)
This commit is contained in:
yangwx committed 2026-10-22 00:53:39 +08:00
1 parent 1d49e6f5ee
commit 18101cf11a
636 files changed
+58517 -9519

No files matched your search

+6
View File
@@ -15,6 +15,7 @@ use ywxapp\controller\FrontendBase;
use addon\haonav\model\Favorite as FavoriteModel;
use addon\haonav\model\FavoriteShare as FavoriteShareModel;
use addon\haonav\model\Configure as ConfigureModel;
use addon\haonav\controller\trait\BaseTrait;
/**
* 用户云端收藏(登录后「我的导航」跨端同步)
@@ -26,11 +27,16 @@ use addon\haonav\model\Configure as ConfigureModel;
*/
class Favorite extends FrontendBase
{
/* 共享 helper(assignAppearance 外观背景注入等;uid 等同类方法以本类定义优先) */
use BaseTrait;
protected $noNeedLogin = ['*'];
protected $noNeedVerify = ['*'];
protected function initialize()
{
// 分享页同样注入外观背景配置
$this->assignAppearance();
}
/**
+59 -846
View File
@@ -12,877 +12,90 @@ declare(strict_types=1);
namespace addon\haonav\controller;
use ywxapp\controller\FrontendBase;
use think\Request;
use think\Response;
use think\exception\ValidateException;
use think\facade\Db;
use addon\haonav\model\Links as LinksModel;
use addon\haonav\model\Category as CategoryModel;
use addon\haonav\model\Configure as ConfigureModel;
use addon\haonav\model\Ad as AdModel;
use addon\haonav\model\Apply as ApplyModel;
use addon\haonav\controller\trait\BaseTrait;
use addon\haonav\controller\trait\HomeTrait;
use addon\haonav\controller\trait\SearchTrait;
use addon\haonav\controller\trait\SiteDetailTrait;
use addon\haonav\controller\trait\CategoryTrait;
use addon\haonav\controller\trait\ApplyTrait;
use addon\haonav\controller\trait\SitemapTrait;
use addon\haonav\controller\trait\ReadMetaTrait;
use addon\haonav\controller\trait\FaviconTrait;
use addon\haonav\controller\trait\SnapshotTrait;
use addon\haonav\controller\trait\SwTrait;
use addon\haonav\controller\trait\AdTrait;
/**
* Index 类
* Index 类(按职责分拆至 controller/trait/*,本类只保留骨架)
*
* 路由对照(addon/haonav/route/app.php):
* index/rank → HomeTrait
* search/suggest/rate→ SearchTrait
* site → SiteDetailTrait
* category → CategoryTrait
* submit/apply → ApplyTrait
* sitemap → SitemapTrait
* read → ReadMetaTrait
* favicon → FaviconTrait
* snapshot → SnapshotTrait
* sw → SwTrait
* ad/click → AdTrait
* test → 本类(调试用)
*
* 共享 helper 在 BaseTrait 中。
*
* @author ywxapp <admin@ywxapp.cn>
*/
class Index extends FrontendBase
{
/**
* Summary of needLogin
* @var array
* 前台放行所有(游客可访问)
*/
protected $noNeedLogin = ['*'];
/**
* Summary of needRight
* @var array
* 前台放行所有 user-group 校验
*/
protected $noNeedVerify = ['*'];
/* ====== 共享 + 12 个按职责拆分的 trait ====== */
use BaseTrait; // 配置 / SEO / 用户 / 广告位 / 点击统计 / 文件输出
use HomeTrait; // index + rank
use SearchTrait; // search + suggest + rate
use SiteDetailTrait; // site(详情页 JSON-LD)
use CategoryTrait; // category
use ApplyTrait; // submit + apply
use SitemapTrait; // sitemap
use ReadMetaTrait; // read
use FaviconTrait; // favicon
use SnapshotTrait; // snapshot
use SwTrait; // sw(PWA)
use AdTrait; // adClick
/**
* 调试路由:打印当前 ThinkPHP 路由表
*/
public function test()
{
$routes = \think\facade\Route::getRules();
dump($routes);
}
/**
* 控制器初始化
* @return void
*/
protected function initialize() {}
/**
* 读取导航相关配置
* 控制器初始化(基类已自动调用,本类无需扩展)
*/
protected function siteConfig(): array
protected function initialize()
{
return [
'default_engine' => ConfigureModel::getVal('default_engine', 'baidu'),
'enable_submit' => (int)ConfigureModel::getVal('enable_submit', '1'),
'enable_hot_api' => (int)ConfigureModel::getVal('enable_hot_api', '0'),
];
// 外观背景配置(背景图 / 遮罩 / 随机变换色)注入所有前台页面
$this->assignAppearance();
}
/**
* 读取 SEO 配置(后台「导航配置 > seo」分组可改,缺省用内置文案)
* 后台模板占位(前台不需要,但保留以兼容路由调起)
*/
protected function seoConfig(): array
{
return [
'title' => (string)ConfigureModel::getVal('seo_title', '网址导航 - 精选实用网站大全'),
'keywords' => (string)ConfigureModel::getVal('seo_keywords', '网址导航,常用网址,网站大全,上网导航'),
'description' => (string)ConfigureModel::getVal('seo_description', '简洁实用的网址导航,收录精选优质网站,支持分类浏览、站内搜索、热门排行与网址投稿。'),
];
}
/**
* 统一注入 SEO 相关模板变量(seo/siteUrl)
*/
protected function assignSeo(): array
{
$seo = $this->seoConfig();
$this->view->assign('seo', $seo);
$this->view->assign('siteUrl', $this->request->domain());
return $seo;
}
/**
* 首页
*/
public function index()
{
$data = CategoryModel::with([
'children' => function ($query) {
$query->with(['subLinks' => function ($q) {
$q->limit(12)->order('click_count', 'desc');
}]);
},
'links' => function ($query) {
// 首页不按二级分组,直接展示该一级分类下的全部链接(含已归属二级的)
$query->where('status', 1)->order('click_count', 'desc')->limit(30);
},
])->where('pid', 0)->where('status', 1)->order('sort', 'desc')->select();
$hotspot = LinksModel::where('is_hot', 1)->where('status', 1)->limit(18)->order('click_count', 'desc')->select();
$links = LinksModel::where('is_recommend', 1)->where('status', 1)->limit(10)->order('sort', 'desc')->select();
$this->view->assign('hotspot', $hotspot);
$this->view->assign('data', $data);
$this->view->assign('links', $links);
$this->view->assign('config', $this->siteConfig());
$seo = $this->assignSeo();
$domain = $this->request->domain();
// 首页 JSON-LD:WebSite + SearchAction(搜索引擎站内搜索直达框)
$this->view->assign('jsonld', json_encode([
'@context' => 'https://schema.org',
'@type' => 'WebSite',
'name' => $seo['title'],
'description' => $seo['description'],
'url' => $domain . '/haonav/index.html',
'potentialAction' => [
'@type' => 'SearchAction',
'target' => $domain . '/haonav/search.html?q={search_term_string}',
'query-input' => 'required name=search_term_string',
],
], JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE));
$this->assignAds();
return $this->view->fetch();
}
/**
* 站内搜索
* 普通请求渲染搜索结果页;ajax=1 返回 JSON(供前端无刷新搜索)
*/
public function search()
{
$q = $this->request->param('q', '');
$page = $this->request->param('page/d', 1);
$limit = 20;
if ($this->request->isAjax() || $this->request->param('ajax/d', 0)) {
$data = LinksModel::search($q, $page, $limit);
$this->result->setCount($data->total());
$this->result->success($data->items());
}
$results = [];
if ($q !== '') {
$results = LinksModel::search($q, 1, 50)->items();
}
$this->view->assign('q', $q);
$this->view->assign('results', $results);
$this->view->assign('config', $this->siteConfig());
$this->assignSeo();
return $this->view->fetch('search');
}
/**
* 搜索建议(自动补全)
*/
public function suggest()
{
$q = trim((string)$this->request->param('q', ''));
$list = [];
if ($q !== '') {
// 标题/关键词/网址/拼音(首字母+全拼)均可命中
$isAlpha = (bool)preg_match('/^[a-zA-Z]+$/', $q);
$list = LinksModel::where('status', 1)
->where(function ($query) use ($q, $isAlpha) {
$query->where('title', 'like', "%{$q}%")
->whereOr('keywords', 'like', "%{$q}%")
->whereOr('url', 'like', "%{$q}%");
if ($isAlpha) {
$query->whereOr('pinyin', 'like', '%' . strtolower($q) . '%');
}
})
->field('id,title,url')
->order('click_count', 'desc')
->limit(10)
->select();
}
return $this->result->success($list);
}
/**
* 网站评分(登录后 1-5 星,按用户去重可改分;聚合更新综合评分)
* POST /haonav/rate {id, score}
*/
public function rate()
{
if (!$this->request->isPost()) {
return $this->result->error('请求方式错误');
}
$uid = $this->uid();
if (!$uid) {
return $this->result->error('请先登录后再评分', 401);
}
$id = (int)$this->request->post('id/d', 0);
$score = (int)$this->request->post('score/d', 0);
if ($id <= 0 || $score < 1 || $score > 5) {
return $this->result->error('参数错误');
}
$link = LinksModel::find($id);
if (!$link || $link->status != 1) {
return $this->result->error('网站不存在或已下架');
}
// 评分记录表由安装/升级流程(install.sql + Addon::upgrade 钩子)保证存在
$now = time();
$exists = Db::name('haonav_ratings')->where('user_id', $uid)->where('link_id', $id)->find();
if ($exists) {
Db::name('haonav_ratings')->where('id', $exists['id'])->update([
'score' => $score,
'update_at' => $now,
]);
} else {
Db::name('haonav_ratings')->insert([
'user_id' => $uid,
'link_id' => $id,
'score' => $score,
'create_at' => $now,
'update_at' => $now,
]);
}
// 重新聚合该站评分,保证 rating / rating_count 精确
$agg = Db::name('haonav_ratings')
->where('link_id', $id)
->field('AVG(score) as avg_score, COUNT(*) as cnt')
->find();
$rating = ($agg && $agg['cnt']) ? round((float)$agg['avg_score'], 1) : 0.0;
$count = $agg ? (int)$agg['cnt'] : 0;
LinksModel::where('id', $id)->update([
'rating' => $rating,
'rating_count' => $count,
]);
return $this->result->success([
'rating' => $rating,
'rating_count' => $count,
'my_score' => $score,
], '评分成功');
}
/**
* 当前登录用户ID,未登录返回 0(与 Favorite 控制器一致)
*/
protected function uid(): int
{
if ($this->auth && $this->auth->isLogin) {
$info = $this->auth->info;
if (is_array($info)) {
return (int)($info['id'] ?? 0);
}
return (int)($info->id ?? 0);
}
return 0;
}
/**
* 热门排行榜(按点击量)
*/
public function rank()
{
$list = LinksModel::where('status', 1)
->order('click_count', 'desc')
->limit(50)
->select();
$this->view->assign('list', $list);
$this->view->assign('config', $this->siteConfig());
$this->assignSeo();
return $this->view->fetch('rank');
}
/**
* 网址投稿(公开,提交后进入待审核 status=2)
*/
public function submit()
{
if ($this->request->isPost()) {
$params = $this->request->post();
try {
validate(\addon\haonav\validate\Link::class)->check($params);
} catch (ValidateException $e) {
return $this->result->error('数据验证失败: ' . $e->getMessage());
}
$enable = (int)ConfigureModel::getVal('enable_submit', '1');
$model = new LinksModel();
$model->cid = $params['cid'];
$model->title = $params['title'];
$model->url = $params['url'];
$model->description = $params['description'] ?? '';
$model->keywords = $params['keywords'] ?? '';
$model->status = $enable ? 2 : 1; // 待审核 or 直接上架
$model->save();
return $this->result->success([], $enable ? '提交成功,等待管理员审核' : '提交成功');
}
$cates = CategoryModel::where('status', 1)->order('sort', 'desc')->select();
$this->view->assign('cates', $cates);
$this->view->assign('config', $this->siteConfig());
$this->assignSeo();
return $this->view->fetch('submit');
}
/**
* 友链/广告合作自助申请(公开)
* GET 渲染表单页;POST 提交入库(待审核),后台「申请审核」处理。
* 防滥用:蜜罐字段 + 同 IP 限频(1小时5条)+ 同 URL 去重。
*/
public function apply()
{
if ($this->request->isPost()) {
// 蜜罐:正常用户不可见不填写,机器人常会填
if ((string)$this->request->post('website', '') !== '') {
return $this->result->success([], '提交成功,等待管理员审核');
}
$type = (int)$this->request->post('type/d', ApplyModel::TYPE_LINK);
$title = trim((string)$this->request->post('title', ''));
$url = trim((string)$this->request->post('url', ''));
$desc = trim((string)$this->request->post('description', ''));
$contact = trim((string)$this->request->post('contact', ''));
$slot = trim((string)$this->request->post('slot', ''));
if (!in_array($type, [ApplyModel::TYPE_LINK, ApplyModel::TYPE_AD], true)) {
return $this->result->error('申请类型错误');
}
if ($title === '' || mb_strlen($title) > 100) {
return $this->result->error('请填写正确的名称(100字以内)');
}
if (!filter_var($url, FILTER_VALIDATE_URL) || !preg_match('#^https?://#i', $url)) {
return $this->result->error('请填写正确的网址(http/https 开头)');
}
if ($contact === '' || mb_strlen($contact) > 100) {
return $this->result->error('请留下联系方式,便于审核后联系您');
}
if ($type === ApplyModel::TYPE_AD && $slot !== '' && !array_key_exists($slot, AdModel::slots())) {
return $this->result->error('意向广告位不存在');
}
$ip = (string)$this->request->ip();
if (ApplyModel::ipOverLimit($ip)) {
return $this->result->error('提交过于频繁,请稍后再试');
}
if (ApplyModel::urlExists($url)) {
return $this->result->error('该网址已提交过申请,请勿重复提交');
}
$model = new ApplyModel();
$model->type = $type;
$model->title = $title;
$model->url = $url;
$model->description = mb_substr($desc, 0, 500);
$model->contact = $contact;
$model->slot = $type === ApplyModel::TYPE_AD ? $slot : '';
$model->ip = $ip;
$model->status = ApplyModel::STATUS_PENDING;
$model->save();
return $this->result->success([], '提交成功,管理员审核后会通过您留下的联系方式回复');
}
$this->view->assign('slots', AdModel::slots());
$this->view->assign('config', $this->siteConfig());
$this->assignSeo();
return $this->view->fetch('apply');
}
/**
* 跳转中间页(累加点击)
*/
public function site($id = 0)
{
$data = LinksModel::find($id);
if ($data) {
LinksModel::where('id', $id)
->inc('click_count')
->update(['last_click_at' => time()]);
$data = LinksModel::find($id);
$this->recordClickStat((int)$data->id, (int)$data->cid);
}
$this->view->assign('info', $data);
$this->assignAds();
$this->assignSeo();
// 详情页 JSON-LD:WebPage
if ($data) {
$this->view->assign('jsonld', json_encode([
'@context' => 'https://schema.org',
'@type' => 'WebPage',
'name' => (string)$data->title,
'description' => (string)($data->description ?: $data->title),
'url' => $this->request->domain() . '/haonav/site/' . $data->id . '.html',
], JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE));
}
return $this->view->fetch('detail');
}
/**
* 点击时段聚合(供后台「点击热力图」)。表结构首次访问自愈,写入失败静默不影响跳转。
* 仅按 (link_id, date, hour) 聚合,全站每天最多 N链接×24 行,规模可控。
*/
protected function recordClickStat(int $linkId, int $cid): void
{
$table = 'wxapp_haonav_click_stats';
try {
$ymd = date('Y-m-d');
$hr = (int)date('G');
\think\facade\Db::execute(
"INSERT INTO `$table` (`link_id`,`cid`,`date`,`hour`,`clicks`) VALUES (?,?,?,?,1) " .
"ON DUPLICATE KEY UPDATE `clicks` = `clicks` + 1",
[$linkId, $cid, $ymd, $hr]
);
} catch (\Throwable $e) {
// 统计写入失败不影响跳转
}
}
/**
* 统一注入各广告位(按 slot 分组),前台模板用 {volist name="ads.home_top"} 等渲染
*/
protected function assignAds()
{
$this->view->assign('ads', AdModel::allActiveBySlot());
}
/**
* 广告点击中转(仅图片广告有跳转,累加 click_count 后 302 到目标)
* /haonav/ad/click?id=广告ID
*/
public function adClick()
{
$id = (int)$this->request->param('id/d', 0);
$ad = $id ? AdModel::find($id) : null;
if (!$ad || (int)$ad->type !== 1 || !$ad->url) {
return redirect('/haonav/index.html');
}
AdModel::where('id', $id)->inc('click_count')->update();
return redirect($ad->url);
}
/**
* 分类页
*/
public function category($id = 0)
{
$info = CategoryModel::with([
'children' => function ($query) {
$query->with(['subLinks' => function ($q) {
$q->order('click_count', 'desc');
}]);
},
])->where('status', 1)->find($id);
if (!$info) {
return redirect('/haonav/index.html');
}
// 顶级分类且有子分类:按二级子分类分组展示
if ($info->pid == 0 && $info->children && $info->children->count() > 0) {
// 未细分二级的链接(sub_cid=0)归入「常用」分组,排在最后
$others = LinksModel::where('cid', $id)
->where('sub_cid', 0)
->where('status', 1)
->order('click_count', 'desc')
->select();
$this->view->assign('others', $others);
$this->view->assign('mode', 'group');
} else {
// 二级分类 / 叶子分类:直接列出该分类下的全部链接
$links = ($info->pid == 0)
? LinksModel::where('cid', $id)->where('status', 1)
: LinksModel::where('sub_cid', $id)->where('status', 1);
$links = $links->order('click_count', 'desc')->select();
$this->view->assign('links', $links);
$this->view->assign('mode', 'flat');
}
$this->view->assign('info', $info);
$this->assignSeo();
return $this->view->fetch();
}
/**
* 站点地图(SEO)
* /haonav/sitemap.xml 输出标准 sitemap 协议 XML:
* 首页/排行/投稿 + 全部启用分类页 + 全部启用链接详情页;缓存 1 小时。
*/
public function sitemap()
{
$domain = $this->request->domain();
$cacheKey = 'haonav_sitemap_' . md5($domain);
$xml = \think\facade\Cache::get($cacheKey);
if (! $xml) {
$urls = [
['loc' => $domain . '/haonav/index.html', 'priority' => '1.0', 'changefreq' => 'daily'],
['loc' => $domain . '/haonav/rank.html', 'priority' => '0.8', 'changefreq' => 'daily'],
['loc' => $domain . '/haonav/submit.html', 'priority' => '0.5', 'changefreq' => 'monthly'],
];
$cates = CategoryModel::where('status', 1)->field('id,update_at')->select();
foreach ($cates as $c) {
$urls[] = [
'loc' => $domain . '/haonav/category/' . $c->id . '.html',
'priority' => '0.8',
'changefreq' => 'weekly',
'lastmod' => $c->update_at ? date('Y-m-d', strtotime((string)$c->update_at)) : '',
];
}
$links = LinksModel::where('status', 1)->field('id,update_at')->order('id', 'asc')->limit(5000)->select();
foreach ($links as $l) {
$urls[] = [
'loc' => $domain . '/haonav/site/' . $l->id . '.html',
'priority' => '0.6',
'changefreq' => 'weekly',
'lastmod' => $l->update_at ? date('Y-m-d', strtotime((string)$l->update_at)) : '',
];
}
$xml = '<?xml version="1.0" encoding="UTF-8"?>' . "\n"
. '<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">' . "\n";
foreach ($urls as $u) {
$xml .= " <url>\n <loc>" . htmlspecialchars($u['loc']) . "</loc>\n";
if (! empty($u['lastmod'])) {
$xml .= ' <lastmod>' . $u['lastmod'] . "</lastmod>\n";
}
$xml .= ' <changefreq>' . $u['changefreq'] . "</changefreq>\n"
. ' <priority>' . $u['priority'] . "</priority>\n </url>\n";
}
$xml .= '</urlset>';
\think\facade\Cache::set($cacheKey, $xml, 3600);
}
return Response::create($xml)->header([
'Content-Type' => 'application/xml; charset=utf-8',
'Cache-Control' => 'public, max-age=3600',
]);
}
/**
* 远程抓取网页元信息(后台「提取信息」按钮使用)
*/
public function read()
{
$url = $this->request->param('url', '');
if (! $url) {
return $this->result->error('缺少 url 参数');
}
$meta = LinksModel::fetchMeta($url);
if ($meta) {
return $this->result->success($meta);
}
return $this->result->error('无法获取网页信息');
}
/**
* 本地 favicon 代理 + 缓存
* 浏览器统一请求 /haonav/favicon.html?url=xxx,由服务端抓取并缓存到
* public/static/haonav/favicons/,避免直接依赖第三方服务、提升加载速度。
*/
public function favicon()
{
$url = $this->request->param('url', '');
$host = $url ? parse_url($url, PHP_URL_HOST) : '';
if (! $host) {
return $this->serveDefault();
}
$root = app()->getRootPath() . 'public/static/haonav/favicons/';
if (! is_dir($root)) {
@mkdir($root, 0755, true);
}
$key = md5($host);
// 失败负缓存:24 小时内不再尝试远程抓取,直接返回默认图
$failFile = $root . $key . '.fail';
if (is_file($failFile) && (time() - filemtime($failFile)) < 86400) {
return $this->serveDefault();
}
// 已缓存则直接返回
foreach (['png', 'ico', 'jpg', 'jpeg', 'svg', 'webp', 'gif'] as $ext) {
if (is_file($root . $key . '.' . $ext)) {
return $this->serveFile($root . $key . '.' . $ext);
}
}
$img = $this->fetchFavicon($host);
if ($img) {
$path = $root . $key . '.' . $img['ext'];
@file_put_contents($path, $img['data']);
return $this->serveFile($path);
}
@touch($failFile);
return $this->serveDefault();
}
/**
* 网站截图缩略图代理 + 缓存
* 请求 /haonav/snapshot.html?id=链接ID(以 id 换 url,避免 SSRF),
* 服务端经 WordPress mshots 免费截图服务生成,缓存 7 天到
* public/static/haonav/snapshots/。生成中/失败时返回默认小图,
* 前端以 naturalWidth 判断是否为真实截图。
*/
public function snapshot()
{
$id = (int)$this->request->param('id/d', 0);
$link = $id > 0 ? LinksModel::find($id) : null;
$url = $link ? (string)$link->url : '';
if (!$url) {
return $this->serveDefault();
}
$root = app()->getRootPath() . 'public/static/haonav/snapshots/';
if (! is_dir($root)) {
@mkdir($root, 0755, true);
}
$key = md5($url);
$file = $root . $key . '.jpg';
if (is_file($file) && (time() - filemtime($file)) < 7 * 86400) {
return $this->serveFile($file);
}
// 失败/生成中负缓存 1 小时,避免每次 hover 都打远程
$failFile = $root . $key . '.fail';
if (is_file($failFile) && (time() - filemtime($failFile)) < 3600) {
return is_file($file) ? $this->serveFile($file) : $this->serveDefault();
}
$img = $this->fetchSnapshot($url);
if ($img) {
@file_put_contents($file, $img);
return $this->serveFile($file);
}
@touch($failFile);
return is_file($file) ? $this->serveFile($file) : $this->serveDefault();
}
/**
* 抓取 mshots 截图;生成中(返回 loading gif)或失败返回 false
*/
protected function fetchSnapshot(string $url)
{
if (! class_exists(\GuzzleHttp\Client::class)) {
return false;
}
$api = 'https://s0.wp.com/mshots/v1/' . urlencode($url) . '?w=480';
try {
$client = new \GuzzleHttp\Client(['timeout' => 8, 'verify' => false]);
$resp = $client->get($api, ['headers' => ['Member-Agent' => 'Mozilla/5.0']]);
$body = (string)$resp->getBody();
$ct = strtolower($resp->getHeaderLine('Content-Type'));
// mshots 首次请求会 307 到 loading gif,表示截图排队生成中,不缓存
if (stripos($ct, 'gif') !== false || strlen($body) < 2048) {
return false;
}
return $body;
} catch (\Throwable $e) {
return false;
}
}
/**
* Service Worker 出口(PWA)
* SW 的作用域由其 URL 目录决定,静态目录 /static/... 无法覆盖 /haonav/,
* 故经路由 /haonav/sw.html 输出 JS,使作用域为 /haonav/。
*/
public function sw()
{
$path = app()->getRootPath() . 'public/static/haonav/sw.js';
$js = is_file($path) ? (string)file_get_contents($path) : '';
return Response::create($js)->header([
'Content-Type' => 'application/javascript; charset=utf-8',
'Service-Worker-Allowed' => '/haonav/',
'Cache-Control' => 'no-cache',
]);
}
/**
* 远程抓取 favicon。
* 抓取顺序(可靠性从高到低、对网络环境最友好):
* 1) 目标站点根路径 /favicon.ico(绝大多数站点自带,不依赖第三方)
* 2) 抓取首页 HTML,解析 <link rel="icon/shortcut icon/apple-touch-icon"> 指示的图标
* 3) 第三方兜底(icon.horse / Google,国内常被墙/慢,仅作保底)
* 任一步成功即返回 ['ext'=>, 'data'=>],全部失败返回 false。
*/
protected function fetchFavicon(string $host)
{
if (! class_exists(\GuzzleHttp\Client::class)) {
return false;
}
$ua = ['Member-Agent' => 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36'];
$client = new \GuzzleHttp\Client(['timeout' => 4, 'verify' => false]);
// 1) 站点自有 /favicon.ico(先 https 后 http)
foreach (['https://' . $host, 'http://' . $host] as $base) {
try {
$resp = $client->get($base . '/favicon.ico', ['headers' => $ua]);
if ($resp->getStatusCode() === 200) {
$ct = strtolower($resp->getHeaderLine('Content-Type'));
$body = (string)$resp->getBody();
// 服务器可能把 404 页面当 html 返回,需排除
if (strlen($body) >= 32 && stripos($ct, 'html') === false) {
return ['ext' => $this->extFromCt($ct, 'ico'), 'data' => $body];
}
}
} catch (\Throwable $e) {
// 继续尝试
}
}
// 2) 抓首页 HTML,解析 <link rel="icon*"> 拿到真实图标地址
foreach (['https://' . $host, 'http://' . $host] as $base) {
try {
$resp = $client->get($base . '/', ['headers' => $ua]);
if ($resp->getStatusCode() !== 200) {
continue;
}
$html = (string)$resp->getBody();
$icon = $this->parseIconHref($html, $base);
if ($icon) {
$resp2 = $client->get($icon, ['headers' => $ua]);
if ($resp2->getStatusCode() === 200) {
$ct = strtolower($resp2->getHeaderLine('Content-Type'));
$body = (string)$resp2->getBody();
if (strlen($body) >= 32 && stripos($ct, 'html') === false) {
return ['ext' => $this->extFromCt($ct, 'ico'), 'data' => $body];
}
}
}
} catch (\Throwable $e) {
// 继续尝试
}
}
// 3) 第三方兜底(可能被墙/慢,仅保底)
$sources = [
'https://www.google.com/s2/favicons?domain=' . $host . '&sz=64',
'https://icon.horse/icon/' . $host,
];
foreach ($sources as $u) {
try {
$resp = $client->get($u, ['headers' => $ua]);
$body = (string)$resp->getBody();
if (strlen($body) < 32) {
continue;
}
$ct = strtolower($resp->getHeaderLine('Content-Type'));
$ext = 'png';
if (stripos($ct, 'svg') !== false) {
$ext = 'svg';
} elseif (stripos($ct, 'ico') !== false) {
$ext = 'ico';
} elseif (stripos($ct, 'jpeg') !== false) {
$ext = 'jpg';
} elseif (stripos($ct, 'webp') !== false) {
$ext = 'webp';
} elseif (stripos($ct, 'gif') !== false) {
$ext = 'gif';
}
return ['ext' => $ext, 'data' => $body];
} catch (\Throwable $e) {
continue;
}
}
return false;
}
/**
* 根据 Content-Type 推断图片扩展名
*/
protected function extFromCt(string $ct, string $default = 'ico'): string
{
if (stripos($ct, 'svg') !== false) {
return 'svg';
}
if (stripos($ct, 'png') !== false) {
return 'png';
}
if (stripos($ct, 'jpeg') !== false) {
return 'jpg';
}
if (stripos($ct, 'webp') !== false) {
return 'webp';
}
if (stripos($ct, 'gif') !== false) {
return 'gif';
}
if (stripos($ct, 'ico') !== false || stripos($ct, 'x-icon') !== false) {
return 'ico';
}
return $default;
}
/**
* 从首页 HTML 中解析图标地址,兼容相对/绝对/协议相对路径与 apple-touch-icon。
* 优先返回普通 icon(体积小),apple-touch-icon 作为兜底。
*/
protected function parseIconHref(string $html, string $base): string
{
if (! preg_match_all('/<link\b[^>]*rel=["\'][^"\']*icon[^"\']*["\'][^>]*>/is', $html, $matches)) {
return '';
}
$candidates = [];
foreach ($matches[0] as $tag) {
if (! preg_match('/href=["\']([^"\']+)["\']/i', $tag, $m)) {
continue;
}
$candidates[] = [
'href' => $m[1],
'apple' => stripos($tag, 'apple-touch-icon') !== false,
];
}
usort($candidates, fn($a, $b) => ($a['apple'] <=> $b['apple']));
$base = rtrim($base, '/');
foreach ($candidates as $c) {
$href = $c['href'];
if (preg_match('/^https?:\/\//i', $href)) {
return $href;
}
if (strpos($href, '//') === 0) {
return 'https:' . $href;
}
return $base . ($href[0] === '/' ? $href : '/' . $href);
}
return '';
}
/**
* 输出本地图片文件
*/
protected function serveFile(string $path)
{
$ext = strtolower(pathinfo($path, PATHINFO_EXTENSION));
$map = [
'png' => 'image/png',
'jpg' => 'image/jpeg',
'jpeg' => 'image/jpeg',
'gif' => 'image/gif',
'ico' => 'image/x-icon',
'svg' => 'image/svg+xml',
'webp' => 'image/webp',
];
$ct = $map[$ext] ?? 'image/png';
$data = file_get_contents($path);
return Response::create($data)->header([
'Content-Type' => $ct,
'Cache-Control' => 'public, max-age=86400',
'Expires' => gmdate('D, d M Y H:i:s', time() + 86400) . ' GMT',
]);
}
/**
* 输出默认图标(文件不存在时回退到 1x1 透明图)
*/
protected function serveDefault()
{
$path = app()->getRootPath() . 'public/static/haonav/img/default.png';
if (is_file($path)) {
return $this->serveFile($path);
}
$gif = base64_decode('R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7');
return Response::create($gif)->header([
'Content-Type' => 'image/gif',
'Cache-Control' => 'public, max-age=3600',
]);
}
public function edit($id = null)
{
//
}
public function update(Request $request, $id)
{
//
}
public function delete($id)
{
//
}
}
public function edit($id = null) {}
public function update(Request $request, $id) {}
public function delete($id) {}
}
+1 -1
View File
@@ -20,7 +20,7 @@ use addon\haonav\model\Ad as AdModel;
class Ad extends HaonavBackend
{
protected $noNeedVerify = ['*'];
protected $noNeedVerify = [];
protected function initialize()
{
+1 -1
View File
@@ -23,7 +23,7 @@ use addon\haonav\model\Category as CategoryModel;
class Apply extends HaonavBackend
{
protected $noNeedVerify = ['*'];
protected $noNeedVerify = [];
protected function initialize()
{
+5 -6
View File
@@ -34,7 +34,7 @@ class Category extends HaonavBackend
* Summary of needRight
* @var array
*/
protected $noNeedVerify = ['*'];
protected $noNeedVerify = [];
/**
* 控制器初始化 _initialize
@@ -57,11 +57,10 @@ class Category extends HaonavBackend
$page = $this->request->param('page/d', 1);
$limit = $this->request->param('limit/d', 20);
$data = $this->model->withAttr('cate')
->when($title, fn($q, $t) => $q->whereLike('title', "%{$t}%"))->select();
// ->paginate(['page' => $page, 'list_rows' => $limit]);
// $this->result->setCount($data->total());
// $this->result->success($data->items());
$this->result->success($data);
->when($title, fn($q, $t) => $q->whereLike('title', "%{$t}%"))
->paginate(['page' => $page, 'list_rows' => $limit]);
$this->result->setCount($data->total());
$this->result->success($data->items());
}
return $this->view->fetch('category/index');
}
+2 -1
View File
@@ -32,7 +32,7 @@ class Configs extends HaonavBackend
* Summary of needRight
* @var array
*/
protected $noNeedVerify = ['*'];
protected $noNeedVerify = [];
/**
* 控制器初始化 _initialize
@@ -54,6 +54,7 @@ class Configs extends HaonavBackend
{
ConfigureModel::ensureRebateConfig();
ConfigureModel::ensureDeadlinkConfig();
ConfigureModel::ensureAppearanceConfig();
$page = $this->request->param('page', 1);
$size = $this->request->param('size', 15);
@@ -24,7 +24,7 @@ use addon\haonav\model\Category as CategoryModel;
class Dashboard extends HaonavBackend
{
protected $noNeedVerify = ['*'];
protected $noNeedVerify = [];
protected function initialize()
{
@@ -31,5 +31,5 @@ class HaonavBackend extends BackendBase
* 跳过后台细粒度权限校验(权限规则未配置时避免锁死后台)。
* 登录要求仍由 AddonBackend::_initialize() 强制(noNeedLogin 默认空=需要登录)。
*/
protected $noNeedVerify = ['*'];
protected $noNeedVerify = [];
}
+1 -1
View File
@@ -35,7 +35,7 @@ class Links extends HaonavBackend
* Summary of needRight
* @var array
*/
protected $noNeedVerify = ['*'];
protected $noNeedVerify = [];
/**
* 控制器初始化
+27
View File
@@ -0,0 +1,27 @@
<?php
declare(strict_types=1);
namespace addon\haonav\controller\trait;
use addon\haonav\model\Ad as AdModel;
/**
* 广告点击中转(仅图片广告 type==1? 才有跳转;累加 click_count 后 302 到目标)
* /haonav/ad/click?id=广告ID
*/
trait AdTrait
{
/**
* 广告点击中转(仅图片广告有跳转,累加 click_count 后 302 到目标)
*/
public function adClick()
{
$id = (int)$this->request->param('id/d', 0);
$ad = $id ? AdModel::find($id) : null;
if (!$ad || (int)$ad->type !== 1 || !$ad->url) {
return redirect('/haonav/index.html');
}
AdModel::where('id', $id)->inc('click_count')->update();
return redirect($ad->url);
}
}
@@ -0,0 +1,110 @@
<?php
declare(strict_types=1);
namespace addon\haonav\controller\trait;
use think\exception\ValidateException;
use addon\haonav\model\Configure as ConfigureModel;
use addon\haonav\model\Links as LinksModel;
use addon\haonav\model\Ad as AdModel;
use addon\haonav\model\Apply as ApplyModel;
/**
* 投稿 + 自助申请(友链 / 广告合作)
* - submit:老投稿(POST 保留,向 301 apply 迁移)
* - apply:新自助申请页(蜜罐 + IP 限频 + URL 去重)
*/
trait ApplyTrait
{
/**
* 网址投稿(公开,提交后进入待审核 status=2)
* POST:保留以兼容外部老表单 / API
* GET:投稿已并入「申请收录 / 广告合作」,301 永久迁移
*/
public function submit()
{
if ($this->request->isPost()) {
$params = $this->request->post();
try {
validate(\addon\haonav\validate\Link::class)->check($params);
} catch (ValidateException $e) {
return $this->result->error('数据验证失败: ' . $e->getMessage());
}
$enable = (int)ConfigureModel::getVal('enable_submit', '1');
$model = new LinksModel();
$model->cid = $params['cid'];
$model->title = $params['title'];
$model->url = $params['url'];
$model->description = $params['description'] ?? '';
$model->keywords = $params['keywords'] ?? '';
$model->status = $enable ? 2 : 1; // 待审核 or 直接上架
$model->save();
return $this->result->success([], $enable ? '提交成功,等待管理员审核' : '提交成功');
}
$this->redirect(url('haonav/apply'), 301);
return;
}
/**
* 友链/广告合作自助申请(公开)
* GET 渲染表单页;POST 提交入库(待审核),后台「申请审核」处理。
* 防滥用:蜜罐字段 + 同 IP 限频(1小时5条)+ 同 URL 去重。
*/
public function apply()
{
if ($this->request->isPost()) {
// 蜜罐:正常用户不可见不填写,机器人常会填
if ((string)$this->request->post('website', '') !== '') {
return $this->result->success([], '提交成功,等待管理员审核');
}
$type = (int)$this->request->post('type/d', ApplyModel::TYPE_LINK);
$title = trim((string)$this->request->post('title', ''));
$url = trim((string)$this->request->post('url', ''));
$desc = trim((string)$this->request->post('description', ''));
$contact = trim((string)$this->request->post('contact', ''));
$slot = trim((string)$this->request->post('slot', ''));
if (!in_array($type, [ApplyModel::TYPE_LINK, ApplyModel::TYPE_AD], true)) {
return $this->result->error('申请类型错误');
}
if ($title === '' || mb_strlen($title) > 100) {
return $this->result->error('请填写正确的名称(100字以内)');
}
if (!filter_var($url, FILTER_VALIDATE_URL) || !preg_match('#^https?://#i', $url)) {
return $this->result->error('请填写正确的网址(http/https 开头)');
}
if ($contact === '' || mb_strlen($contact) > 100) {
return $this->result->error('请留下联系方式,便于审核后联系您');
}
if ($type === ApplyModel::TYPE_AD && $slot !== '' && !array_key_exists($slot, AdModel::slots())) {
return $this->result->error('意向广告位不存在');
}
$ip = (string)$this->request->ip();
if (ApplyModel::ipOverLimit($ip)) {
return $this->result->error('提交过于频繁,请稍后再试');
}
if (ApplyModel::urlExists($url)) {
return $this->result->error('该网址已提交过申请,请勿重复提交');
}
$model = new ApplyModel();
$model->type = $type;
$model->title = $title;
$model->url = $url;
$model->description = mb_substr($desc, 0, 500);
$model->contact = $contact;
$model->slot = $type === ApplyModel::TYPE_AD ? $slot : '';
$model->ip = $ip;
$model->status = ApplyModel::STATUS_PENDING;
$model->save();
return $this->result->success([], '提交成功,管理员审核后会通过您留下的联系方式回复');
}
$this->view->assign('slots', AdModel::slots());
$this->view->assign('config', $this->siteConfig());
$this->assignSeo();
return $this->view->fetch('apply');
}
}
+138
View File
@@ -0,0 +1,138 @@
<?php
declare(strict_types=1);
namespace addon\haonav\controller\trait;
use think\facade\Cache;
use think\facade\Db;
use think\Response;
use addon\haonav\model\Configure as ConfigureModel;
use addon\haonav\model\Ad as AdModel;
/**
* Index 控制器共享 helper
* - 配置 / SEO / 用户 / 广告位 helper 被多数 trait 调用,集中放此处
* - 静态资源输出(Favicon / Snapshot)也共用,统一缓存头
*/
trait BaseTrait
{
/* ===================== 配置 / SEO ===================== */
protected function siteConfig(): array
{
return [
'default_engine' => ConfigureModel::getVal('default_engine', 'baidu'),
'enable_submit' => (int)ConfigureModel::getVal('enable_submit', '1'),
'enable_hot_api' => (int)ConfigureModel::getVal('enable_hot_api', '0'),
];
}
/**
* 外观背景配置注入(背景图 / 遮罩 / 随机变换色),
* 供前端 nav-theme.js 读取:window.NavAppearance。
*/
protected function assignAppearance(): void
{
$this->view->assign('appearanceJson', json_encode([
'image' => (string)ConfigureModel::getVal('bg_image', ''),
'mask' => (int)ConfigureModel::getVal('bg_mask', 35),
'random' => (int)ConfigureModel::getVal('bg_random', 0),
'festival' => (int)ConfigureModel::getVal('festival_theme', 1),
], JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE));
}
protected function seoConfig(): array
{
return [
'title' => (string)ConfigureModel::getVal('seo_title', '网址导航 - 精选实用网站大全'),
'keywords' => (string)ConfigureModel::getVal('seo_keywords', '网址导航,常用网址,网站大全,上网导航'),
'description' => (string)ConfigureModel::getVal('seo_description', '简洁实用的网址导航,收录精选优质网站,支持分类浏览、站内搜索、热门排行与网址投稿。'),
];
}
protected function assignSeo(): array
{
$seo = $this->seoConfig();
$this->view->assign('seo', $seo);
$this->view->assign('siteUrl', $this->request->domain());
return $seo;
}
/* ===================== 用户 / 权限 ===================== */
protected function uid(): int
{
if ($this->auth && $this->auth->isLogin) {
$info = $this->auth->info;
if (is_array($info)) {
return (int)($info['id'] ?? 0);
}
return (int)($info->id ?? 0);
}
return 0;
}
/* ===================== 广告位 / 点击统计 ===================== */
protected function assignAds()
{
$this->view->assign('ads', AdModel::allActiveBySlot());
}
/**
* 点击时段聚合(供后台「点击热力图」)。表结构首次访问自愈,
* 写入失败静默不影响跳转。仅按 (link_id, date, hour) 聚合,
* 全站每天最多 N链接×24 行,规模可控。
*/
protected function recordClickStat(int $linkId, int $cid): void
{
$table = 'wxapp_haonav_click_stats';
try {
$ymd = date('Y-m-d');
$hr = (int)date('G');
Db::execute(
"INSERT INTO `$table` (`link_id`,`cid`,`date`,`hour`,`clicks`) VALUES (?,?,?,?,1) " .
"ON DUPLICATE KEY UPDATE `clicks` = `clicks` + 1",
[$linkId, $cid, $ymd, $hr]
);
} catch (\Throwable $e) {
// 统计写入失败不影响跳转
}
}
/* ===================== 静态资源输出(Favicon / Snapshot 共享) ===================== */
protected function serveFile(string $path)
{
$ext = strtolower(pathinfo($path, PATHINFO_EXTENSION));
$map = [
'png' => 'image/png',
'jpg' => 'image/jpeg',
'jpeg' => 'image/jpeg',
'gif' => 'image/gif',
'ico' => 'image/x-icon',
'svg' => 'image/svg+xml',
'webp' => 'image/webp',
];
$ct = $map[$ext] ?? 'image/png';
$data = file_get_contents($path);
return Response::create($data)->header([
'Content-Type' => $ct,
'Cache-Control' => 'public, max-age=86400',
'Expires' => gmdate('D, d M Y H:i:s', time() + 86400) . ' GMT',
]);
}
protected function serveDefault()
{
$path = app()->getRootPath() . 'public/static/haonav/img/default.png';
if (is_file($path)) {
return $this->serveFile($path);
}
$gif = base64_decode('R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7');
return Response::create($gif)->header([
'Content-Type' => 'image/gif',
'Cache-Control' => 'public, max-age=3600',
]);
}
}
@@ -0,0 +1,55 @@
<?php
declare(strict_types=1);
namespace addon\haonav\controller\trait;
use addon\haonav\model\Links as LinksModel;
use addon\haonav\model\Category as CategoryModel;
/**
* 分类页:顶级分类按二级子分类分组 / 叶子分类直接平铺
*/
trait CategoryTrait
{
/**
* 分类页
*/
public function category($id = 0)
{
$info = CategoryModel::with([
'children' => function ($query) {
$query->with(['subLinks' => function ($q) {
$q->order('click_count', 'desc');
}]);
},
])->where('status', 1)->find($id);
if (!$info) {
return redirect('/haonav/index.html');
}
// 顶级分类且有子分类:按二级子分类分组展示
if ($info->pid == 0 && $info->children && $info->children->count() > 0) {
// 未细分二级的链接(sub_cid=0)归入「常用」分组,排在最后
$others = LinksModel::where('cid', $id)
->where('sub_cid', 0)
->where('status', 1)
->order('click_count', 'desc')
->select();
$this->view->assign('others', $others);
$this->view->assign('mode', 'group');
} else {
// 二级分类 / 叶子分类:直接列出该分类下的全部链接
$links = ($info->pid == 0)
? LinksModel::where('cid', $id)->where('status', 1)
: LinksModel::where('sub_cid', $id)->where('status', 1);
$links = $links->order('click_count', 'desc')->select();
$this->view->assign('links', $links);
$this->view->assign('mode', 'flat');
}
$this->view->assign('info', $info);
$this->assignSeo();
return $this->view->fetch();
}
}
@@ -0,0 +1,205 @@
<?php
declare(strict_types=1);
namespace addon\haonav\controller\trait;
/**
* Favicon 本地缓存:站点自有 favicon.ico → 解析 HTML <link rel=icon> → 第三方兜底
* 抓取顺序(可靠性从高到低、对网络环境最友好):
* 1) 目标站点根路径 /favicon.ico(绝大多数站点自带,不依赖第三方)
* 2) 抓取首页 HTML,解析 <link rel="icon/shortcut icon/apple-touch-icon"> 指示的图标
* 3) 第三方兜底(icon.horse / Google,国内常被墙/慢,仅作保底)
* 任一步成功即返回 ['ext'=>, 'data'=>],全部失败返回 false。
* 输出由 BaseTrait::serveFile / serveDefault 统一处理。
*/
trait FaviconTrait
{
/**
* 本地 favicon 代理 + 缓存
* 浏览器统一请求 /haonav/favicon.html?url=xxx,由服务端抓取并缓存到
* public/static/haonav/favicons/,避免直接依赖第三方服务、提升加载速度。
*/
public function favicon()
{
$url = $this->request->param('url', '');
$host = $url ? parse_url($url, PHP_URL_HOST) : '';
if (! $host) {
return $this->serveDefault();
}
$root = app()->getRootPath() . 'public/static/haonav/favicons/';
if (! is_dir($root)) {
@mkdir($root, 0755, true);
}
$key = md5($host);
// 失败负缓存:24 小时内不再尝试远程抓取,直接返回默认图
$failFile = $root . $key . '.fail';
if (is_file($failFile) && (time() - filemtime($failFile)) < 86400) {
return $this->serveDefault();
}
// 已缓存则直接返回
foreach (['png', 'ico', 'jpg', 'jpeg', 'svg', 'webp', 'gif'] as $ext) {
if (is_file($root . $key . '.' . $ext)) {
return $this->serveFile($root . $key . '.' . $ext);
}
}
$img = $this->fetchFavicon($host);
if ($img) {
$path = $root . $key . '.' . $img['ext'];
@file_put_contents($path, $img['data']);
return $this->serveFile($path);
}
@touch($failFile);
return $this->serveDefault();
}
/**
* 远程抓取 favicon。
* 任一步成功即返回 ['ext'=>, 'data'=>],全部失败返回 false。
*/
protected function fetchFavicon(string $host)
{
if (! class_exists(\GuzzleHttp\Client::class)) {
return false;
}
$ua = ['Member-Agent' => 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36'];
$client = new \GuzzleHttp\Client(['timeout' => 4, 'verify' => false]);
// 1) 站点自有 /favicon.ico(先 https 后 http)
foreach (['https://' . $host, 'http://' . $host] as $base) {
try {
$resp = $client->get($base . '/favicon.ico', ['headers' => $ua]);
if ($resp->getStatusCode() === 200) {
$ct = strtolower($resp->getHeaderLine('Content-Type'));
$body = (string)$resp->getBody();
// 服务器可能把 404 页面当 html 返回,需排除
if (strlen($body) >= 32 && stripos($ct, 'html') === false) {
return ['ext' => $this->extFromCt($ct, 'ico'), 'data' => $body];
}
}
} catch (\Throwable $e) {
// 继续尝试
}
}
// 2) 抓首页 HTML,解析 <link rel="icon*"> 拿到真实图标地址
foreach (['https://' . $host, 'http://' . $host] as $base) {
try {
$resp = $client->get($base . '/', ['headers' => $ua]);
if ($resp->getStatusCode() !== 200) {
continue;
}
$html = (string)$resp->getBody();
$icon = $this->parseIconHref($html, $base);
if ($icon) {
$resp2 = $client->get($icon, ['headers' => $ua]);
if ($resp2->getStatusCode() === 200) {
$ct = strtolower($resp2->getHeaderLine('Content-Type'));
$body = (string)$resp2->getBody();
if (strlen($body) >= 32 && stripos($ct, 'html') === false) {
return ['ext' => $this->extFromCt($ct, 'ico'), 'data' => $body];
}
}
}
} catch (\Throwable $e) {
// 继续尝试
}
}
// 3) 第三方兜底(可能被墙/慢,仅保底)
$sources = [
'https://www.google.com/s2/favicons?domain=' . $host . '&sz=64',
'https://icon.horse/icon/' . $host,
];
foreach ($sources as $u) {
try {
$resp = $client->get($u, ['headers' => $ua]);
$body = (string)$resp->getBody();
if (strlen($body) < 32) {
continue;
}
$ct = strtolower($resp->getHeaderLine('Content-Type'));
$ext = 'png';
if (stripos($ct, 'svg') !== false) {
$ext = 'svg';
} elseif (stripos($ct, 'ico') !== false) {
$ext = 'ico';
} elseif (stripos($ct, 'jpeg') !== false) {
$ext = 'jpg';
} elseif (stripos($ct, 'webp') !== false) {
$ext = 'webp';
} elseif (stripos($ct, 'gif') !== false) {
$ext = 'gif';
}
return ['ext' => $ext, 'data' => $body];
} catch (\Throwable $e) {
continue;
}
}
return false;
}
/**
* 根据 Content-Type 推断图片扩展名
*/
protected function extFromCt(string $ct, string $default = 'ico'): string
{
if (stripos($ct, 'svg') !== false) {
return 'svg';
}
if (stripos($ct, 'png') !== false) {
return 'png';
}
if (stripos($ct, 'jpeg') !== false) {
return 'jpg';
}
if (stripos($ct, 'webp') !== false) {
return 'webp';
}
if (stripos($ct, 'gif') !== false) {
return 'gif';
}
if (stripos($ct, 'ico') !== false || stripos($ct, 'x-icon') !== false) {
return 'ico';
}
return $default;
}
/**
* 从首页 HTML 中解析图标地址,兼容相对/绝对/协议相对路径与 apple-touch-icon。
* 优先返回普通 icon(体积小),apple-touch-icon 作为兜底。
*/
protected function parseIconHref(string $html, string $base): string
{
if (! preg_match_all('/<link\b[^>]*rel=["\'][^"\']*icon[^"\']*["\'][^>]*>/is', $html, $matches)) {
return '';
}
$candidates = [];
foreach ($matches[0] as $tag) {
if (! preg_match('/href=["\']([^"\']+)["\']/i', $tag, $m)) {
continue;
}
$candidates[] = [
'href' => $m[1],
'apple' => stripos($tag, 'apple-touch-icon') !== false,
];
}
usort($candidates, fn($a, $b) => ($a['apple'] <=> $b['apple']));
$base = rtrim($base, '/');
foreach ($candidates as $c) {
$href = $c['href'];
if (preg_match('/^https?:\/\//i', $href)) {
return $href;
}
if (strpos($href, '//') === 0) {
return 'https:' . $href;
}
return $base . ($href[0] === '/' ? $href : '/' . $href);
}
return '';
}
}
@@ -0,0 +1,73 @@
<?php
declare(strict_types=1);
namespace addon\haonav\controller\trait;
use addon\haonav\model\Links as LinksModel;
use addon\haonav\model\Category as CategoryModel;
/**
* 首页 + 排行榜渲染(含 JSON-LD 结构化数据)
*/
trait HomeTrait
{
/**
* 首页:顶级分类聚合 + 子分类联合查询
*/
public function index()
{
$data = CategoryModel::with([
'children' => function ($query) {
$query->with(['subLinks' => function ($q) {
$q->limit(12)->order('click_count', 'desc');
}]);
},
'links' => function ($query) {
// 首页不按二级分组,直接展示该一级分类下的全部链接(含已归属二级的)
$query->where('status', 1)->order('click_count', 'desc')->limit(10);
},
])->where('pid', 0)->where('status', 1)->order('sort', 'desc')->select();
$hotspot = LinksModel::where('is_hot', 1)->where('status', 1)->limit(18)->order('click_count', 'desc')->select();
$links = LinksModel::where('is_recommend', 1)->where('status', 1)->limit(10)->order('sort', 'desc')->select();
$this->view->assign('hotspot', $hotspot);
$this->view->assign('data', $data);
$this->view->assign('links', $links);
$this->view->assign('config', $this->siteConfig());
$seo = $this->assignSeo();
$domain = $this->request->domain();
// 首页 JSON-LD:WebSite + SearchAction(搜索引擎站内搜索直达框)
$this->view->assign('jsonld', json_encode([
'@context' => 'https://schema.org',
'@type' => 'WebSite',
'name' => $seo['title'],
'description' => $seo['description'],
'url' => $domain . '/haonav/index.html',
'potentialAction' => [
'@type' => 'SearchAction',
'target' => $domain . '/haonav/search.html?q={search_term_string}',
'query-input' => 'required name=search_term_string',
],
], JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE));
$this->assignAds();
return $this->view->fetch();
}
/**
* 热门排行榜(按点击量)
*/
public function rank()
{
$list = LinksModel::where('status', 1)
->order('click_count', 'desc')
->limit(50)
->select();
$this->view->assign('list', $list);
$this->view->assign('config', $this->siteConfig());
$this->assignSeo();
return $this->view->fetch('rank');
}
}
@@ -0,0 +1,28 @@
<?php
declare(strict_types=1);
namespace addon\haonav\controller\trait;
use addon\haonav\model\Links as LinksModel;
/**
* 后台「提取信息」:远程抓取网页元信息(title / description / keywords)
*/
trait ReadMetaTrait
{
/**
* 远程抓取网页元信息(后台「提取信息」按钮使用)
*/
public function read()
{
$url = $this->request->param('url', '');
if (! $url) {
return $this->result->error('缺少 url 参数');
}
$meta = LinksModel::fetchMeta($url);
if ($meta) {
return $this->result->success($meta);
}
return $this->result->error('无法获取网页信息');
}
}
@@ -0,0 +1,127 @@
<?php
declare(strict_types=1);
namespace addon\haonav\controller\trait;
use think\facade\Db;
use addon\haonav\model\Links as LinksModel;
/**
* 搜索相关:站内搜索页 + 自动补全 + 用户评分
*/
trait SearchTrait
{
/**
* 站内搜索
* 普通请求渲染搜索结果页;ajax=1 返回 JSON(供前端无刷新搜索)
*/
public function search()
{
$q = $this->request->param('q', '');
$page = $this->request->param('page/d', 1);
$limit = 20;
if ($this->request->isAjax() || $this->request->param('ajax/d', 0)) {
$data = LinksModel::search($q, $page, $limit);
$this->result->setCount($data->total());
$this->result->success($data->items());
}
$results = [];
if ($q !== '') {
$results = LinksModel::search($q, 1, 50)->items();
}
$this->view->assign('q', $q);
$this->view->assign('results', $results);
$this->view->assign('config', $this->siteConfig());
$this->assignSeo();
return $this->view->fetch('search');
}
/**
* 搜索建议(自动补全)
* 标题/关键词/网址/拼音(首字母+全拼)均可命中
*/
public function suggest()
{
$q = trim((string)$this->request->param('q', ''));
$list = [];
if ($q !== '') {
$isAlpha = (bool)preg_match('/^[a-zA-Z]+$/', $q);
$list = LinksModel::where('status', 1)
->where(function ($query) use ($q, $isAlpha) {
$query->where('title', 'like', "%{$q}%")
->whereOr('keywords', 'like', "%{$q}%")
->whereOr('url', 'like', "%{$q}%");
if ($isAlpha) {
$query->whereOr('pinyin', 'like', '%' . strtolower($q) . '%');
}
})
->field('id,title,url')
->order('click_count', 'desc')
->limit(10)
->select();
}
return $this->result->success($list);
}
/**
* 网站评分(登录后 1-5 星,按用户去重可改分;聚合更新综合评分)
* POST /haonav/rate {id, score}
*/
public function rate()
{
if (!$this->request->isPost()) {
return $this->result->error('请求方式错误');
}
$uid = $this->uid();
if (!$uid) {
return $this->result->error('请先登录后再评分', 401);
}
$id = (int)$this->request->post('id/d', 0);
$score = (int)$this->request->post('score/d', 0);
if ($id <= 0 || $score < 1 || $score > 5) {
return $this->result->error('参数错误');
}
$link = LinksModel::find($id);
if (!$link || $link->status != 1) {
return $this->result->error('网站不存在或已下架');
}
// 评分记录表由安装/升级流程(install.sql + Addon::upgrade 钩子)保证存在
$now = time();
$exists = Db::name('haonav_ratings')->where('user_id', $uid)->where('link_id', $id)->find();
if ($exists) {
Db::name('haonav_ratings')->where('id', $exists['id'])->update([
'score' => $score,
'update_at' => $now,
]);
} else {
Db::name('haonav_ratings')->insert([
'user_id' => $uid,
'link_id' => $id,
'score' => $score,
'create_at' => $now,
'update_at' => $now,
]);
}
// 重新聚合该站评分,保证 rating / rating_count 精确
$agg = Db::name('haonav_ratings')
->where('link_id', $id)
->field('AVG(score) as avg_score, COUNT(*) as cnt')
->find();
$rating = ($agg && $agg['cnt']) ? round((float)$agg['avg_score'], 1) : 0.0;
$count = $agg ? (int)$agg['cnt'] : 0;
LinksModel::where('id', $id)->update([
'rating' => $rating,
'rating_count' => $count,
]);
return $this->result->success([
'rating' => $rating,
'rating_count' => $count,
'my_score' => $score,
], '评分成功');
}
}
@@ -0,0 +1,43 @@
<?php
declare(strict_types=1);
namespace addon\haonav\controller\trait;
use addon\haonav\model\Links as LinksModel;
/**
* 详情页跳转中间页(/site/:id → 计数+跳转 + JSON-LD WebPage)
*/
trait SiteDetailTrait
{
/**
* 跳转中间页(累加点击)
*/
public function site($id = 0)
{
$data = LinksModel::find($id);
if ($data) {
LinksModel::where('id', $id)
->inc('click_count')
->update(['last_click_at' => time()]);
$data = LinksModel::find($id);
$this->recordClickStat((int)$data->id, (int)$data->cid);
}
$this->view->assign('info', $data);
$this->assignAds();
$this->assignSeo();
// 详情页 JSON-LD:WebPage
if ($data) {
$this->view->assign('jsonld', json_encode([
'@context' => 'https://schema.org',
'@type' => 'WebPage',
'name' => (string)$data->title,
'description' => (string)($data->description ?: $data->title),
'url' => $this->request->domain() . '/haonav/site/' . $data->id . '.html',
], JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE));
}
return $this->view->fetch('detail');
}
}
@@ -0,0 +1,68 @@
<?php
declare(strict_types=1);
namespace addon\haonav\controller\trait;
use think\facade\Cache;
use think\Response;
use addon\haonav\model\Links as LinksModel;
use addon\haonav\model\Category as CategoryModel;
/**
* SEO 站点地图(缓存 1 小时)
*/
trait SitemapTrait
{
/**
* 站点地图(SEO)
* /haonav/sitemap.xml 输出标准 sitemap 协议 XML:
* 首页/排行/投稿 + 全部启用分类页 + 全部启用链接详情页;缓存 1 小时。
*/
public function sitemap()
{
$domain = $this->request->domain();
$cacheKey = 'haonav_sitemap_' . md5($domain);
$xml = Cache::get($cacheKey);
if (! $xml) {
$urls = [
['loc' => $domain . '/haonav/index.html', 'priority' => '1.0', 'changefreq' => 'daily'],
['loc' => $domain . '/haonav/rank.html', 'priority' => '0.8', 'changefreq' => 'daily'],
['loc' => $domain . '/haonav/apply.html', 'priority' => '0.5', 'changefreq' => 'monthly'],
];
$cates = CategoryModel::where('status', 1)->field('id,update_at')->select();
foreach ($cates as $c) {
$urls[] = [
'loc' => $domain . '/haonav/category/' . $c->id . '.html',
'priority' => '0.8',
'changefreq' => 'weekly',
'lastmod' => $c->update_at ? date('Y-m-d', strtotime((string)$c->update_at)) : '',
];
}
$links = LinksModel::where('status', 1)->field('id,update_at')->order('id', 'asc')->limit(5000)->select();
foreach ($links as $l) {
$urls[] = [
'loc' => $domain . '/haonav/site/' . $l->id . '.html',
'priority' => '0.6',
'changefreq' => 'weekly',
'lastmod' => $l->update_at ? date('Y-m-d', strtotime((string)$l->update_at)) : '',
];
}
$xml = '<?xml version="1.0" encoding="UTF-8"?>' . "\n"
. '<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">' . "\n";
foreach ($urls as $u) {
$xml .= " <url>\n <loc>" . htmlspecialchars($u['loc']) . "</loc>\n";
if (! empty($u['lastmod'])) {
$xml .= ' <lastmod>' . $u['lastmod'] . "</lastmod>\n";
}
$xml .= ' <changefreq>' . $u['changefreq'] . "</changefreq>\n"
. ' <priority>' . $u['priority'] . "</priority>\n </url>\n";
}
$xml .= '</urlset>';
Cache::set($cacheKey, $xml, 3600);
}
return Response::create($xml)->header([
'Content-Type' => 'application/xml; charset=utf-8',
'Cache-Control' => 'public, max-age=3600',
]);
}
}
@@ -0,0 +1,78 @@
<?php
declare(strict_types=1);
namespace addon\haonav\controller\trait;
use addon\haonav\model\Links as LinksModel;
/**
* 网站截图缩略图(mshots 代理 + 7 天缓存 + 负缓存)
* 输出由 BaseTrait::serveFile / serveDefault 统一处理
*/
trait SnapshotTrait
{
/**
* 网站截图缩略图代理 + 缓存
* 请求 /haonav/snapshot.html?id=链接ID(以 id 换 url,避免 SSRF),
* 服务端经 WordPress mshots 免费截图服务生成,缓存 7 天到
* public/static/haonav/snapshots/。生成中/失败时返回默认小图,
* 前端以 naturalWidth 判断是否为真实截图。
*/
public function snapshot()
{
$id = (int)$this->request->param('id/d', 0);
$link = $id > 0 ? LinksModel::find($id) : null;
$url = $link ? (string)$link->url : '';
if (!$url) {
return $this->serveDefault();
}
$root = app()->getRootPath() . 'public/static/haonav/snapshots/';
if (! is_dir($root)) {
@mkdir($root, 0755, true);
}
$key = md5($url);
$file = $root . $key . '.jpg';
if (is_file($file) && (time() - filemtime($file)) < 7 * 86400) {
return $this->serveFile($file);
}
// 失败/生成中负缓存 1 小时,避免每次 hover 都打远程
$failFile = $root . $key . '.fail';
if (is_file($failFile) && (time() - filemtime($failFile)) < 3600) {
return is_file($file) ? $this->serveFile($file) : $this->serveDefault();
}
$img = $this->fetchSnapshot($url);
if ($img) {
@file_put_contents($file, $img);
return $this->serveFile($file);
}
@touch($failFile);
return is_file($file) ? $this->serveFile($file) : $this->serveDefault();
}
/**
* 抓取 mshots 截图;生成中(返回 loading gif)或失败返回 false
*/
protected function fetchSnapshot(string $url)
{
if (! class_exists(\GuzzleHttp\Client::class)) {
return false;
}
$api = 'https://s0.wp.com/mshots/v1/' . urlencode($url) . '?w=480';
try {
$client = new \GuzzleHttp\Client(['timeout' => 8, 'verify' => false]);
$resp = $client->get($api, ['headers' => ['Member-Agent' => 'Mozilla/5.0']]);
$body = (string)$resp->getBody();
$ct = strtolower($resp->getHeaderLine('Content-Type'));
// mshots 首次请求会 307 到 loading gif,表示截图排队生成中,不缓存
if (stripos($ct, 'gif') !== false || strlen($body) < 2048) {
return false;
}
return $body;
} catch (\Throwable $e) {
return false;
}
}
}
+28
View File
@@ -0,0 +1,28 @@
<?php
declare(strict_types=1);
namespace addon\haonav\controller\trait;
use think\Response;
/**
* Service Worker 出口(PWA 作用域 /haonav/)
*/
trait SwTrait
{
/**
* Service Worker 出口(PWA)
* SW 的作用域由其 URL 目录决定,静态目录 /static/... 无法覆盖 /haonav/,
* 故经路由 /haonav/sw.html 输出 JS,使作用域为 /haonav/。
*/
public function sw()
{
$path = app()->getRootPath() . 'public/static/haonav/sw.js';
$js = is_file($path) ? (string)file_get_contents($path) : '';
return Response::create($js)->header([
'Content-Type' => 'application/javascript; charset=utf-8',
'Service-Worker-Allowed' => '/haonav/',
'Cache-Control' => 'no-cache',
]);
}
}