安全(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(市场 / 开发者中心 / 审核 / 分润)
146 lines
5.4 KiB
PHP
146 lines
5.4 KiB
PHP
<?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\controller\backend;
|
||
|
||
use think\facade\Db;
|
||
use ywxapp\model\BaseModel;
|
||
use addon\haonav\model\Links as LinksModel;
|
||
use addon\haonav\model\Category as CategoryModel;
|
||
|
||
/**
|
||
* 后台数据概览仪表盘
|
||
*
|
||
* @author ywxapp <admin@ywxapp.cn>
|
||
*/
|
||
class Dashboard extends HaonavBackend
|
||
{
|
||
|
||
protected $noNeedVerify = [];
|
||
|
||
protected function initialize()
|
||
{
|
||
}
|
||
|
||
public function index()
|
||
{
|
||
if ($this->request->isAjax()) {
|
||
return $this->stats();
|
||
}
|
||
// 顺带为存量链接回填拼音搜索串(每次最多 300 条,幂等、失败静默)
|
||
LinksModel::backfillPinyin(300);
|
||
$this->view->assign('title', '数据概览');
|
||
return $this->view->fetch('dashboard/index');
|
||
}
|
||
|
||
/**
|
||
* 汇总统计
|
||
*/
|
||
protected function stats()
|
||
{
|
||
// 基础计数
|
||
$summary = [
|
||
'links' => LinksModel::count(),
|
||
'online' => LinksModel::where('status', 1)->count(),
|
||
'pending' => LinksModel::where('status', 2)->count(),
|
||
'disabled' => LinksModel::where('status', 0)->count(),
|
||
'categories' => CategoryModel::count(),
|
||
'hot' => LinksModel::where('is_hot', 1)->count(),
|
||
'recommend' => LinksModel::where('is_recommend', 1)->count(),
|
||
'clicks' => (int)LinksModel::sum('click_count'),
|
||
];
|
||
|
||
// Top 分类(按链接数)
|
||
$cates = CategoryModel::field('id,title,icon')->select();
|
||
$catStat = [];
|
||
foreach ($cates as $c) {
|
||
$cnt = LinksModel::where('cid', $c->id)->count();
|
||
$catStat[] = ['title' => $c->title, 'icon' => $c->icon, 'count' => $cnt];
|
||
}
|
||
usort($catStat, function ($a, $b) {
|
||
return $b['count'] <=> $a['count'];
|
||
});
|
||
$topCategories = array_slice($catStat, 0, 10);
|
||
|
||
// Top 点击
|
||
$topClicks = LinksModel::field('id,title,url,click_count,cid')
|
||
->where('status', 1)
|
||
->order('click_count', 'desc')
|
||
->limit(10)
|
||
->select();
|
||
|
||
// 死链概览(status_code 已检测且非 2xx/3xx,或为 0)
|
||
$deadCount = LinksModel::whereNotNull('status_code')
|
||
->where(function ($q) {
|
||
$q->where('status_code', 0)->whereOr('status_code', '>=', 400);
|
||
})
|
||
->count();
|
||
$deadList = LinksModel::field('id,title,url,status_code,last_check_at')
|
||
->whereNotNull('status_code')
|
||
->where(function ($q) {
|
||
$q->where('status_code', 0)->whereOr('status_code', '>=', 400);
|
||
})
|
||
->limit(20)
|
||
->select();
|
||
$lastCheckAt = (int)LinksModel::max('last_check_at');
|
||
|
||
// 分类点击热度(基于 links.click_count 聚合,零额外表)
|
||
$catHeat = [];
|
||
$allCates = CategoryModel::field('id,title,icon')->select();
|
||
foreach ($allCates as $c) {
|
||
$catHeat[] = [
|
||
'cid' => $c->id,
|
||
'title' => $c->title,
|
||
'icon' => $c->icon,
|
||
'clicks' => (int)LinksModel::where('cid', $c->id)->sum('click_count'),
|
||
];
|
||
}
|
||
usort($catHeat, function ($a, $b) { return $b['clicks'] <=> $a['clicks']; });
|
||
|
||
// 访问时段热力图(星期×小时),取最近 30 天,按 date 归并星期;表不存在或查询失败均静默兜底
|
||
$timeHeat = [];
|
||
for ($d = 0; $d < 7; $d++) { $timeHeat[$d] = array_fill(0, 24, 0); }
|
||
$timeTotal = 0;
|
||
$timeRange = date('Y-m-d', strtotime('-30 days')) . ' ~ ' . date('Y-m-d');
|
||
try {
|
||
if (BaseModel::tableExists('wxapp_haonav_click_stats')) {
|
||
$rows = Db::name('haonav_click_stats')
|
||
->where('date', '>=', date('Y-m-d', strtotime('-30 days')))
|
||
->field('date,hour,clicks')
|
||
->select();
|
||
foreach ($rows as $r) {
|
||
$w = (int)date('w', strtotime($r['date'])); // 0=周日..6=周六
|
||
$h = (int)$r['hour'];
|
||
if ($w >= 0 && $w < 7 && $h >= 0 && $h < 24) {
|
||
$timeHeat[$w][$h] += (int)$r['clicks'];
|
||
$timeTotal += (int)$r['clicks'];
|
||
}
|
||
}
|
||
}
|
||
} catch (\Throwable $e) {
|
||
// 统计查询失败不阻断概览
|
||
}
|
||
|
||
return $this->result->success([
|
||
'summary' => $summary,
|
||
'topCategories' => $topCategories,
|
||
'topClicks' => $topClicks,
|
||
'dead' => ['count' => $deadCount, 'list' => $deadList, 'last_check_at' => $lastCheckAt ? date('Y-m-d H:i', $lastCheckAt) : ''],
|
||
'heatmap' => [
|
||
'category' => $catHeat,
|
||
'time' => $timeHeat,
|
||
'timeRange' => $timeRange,
|
||
'timeTotal' => $timeTotal,
|
||
],
|
||
]);
|
||
}
|
||
}
|