安全(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(市场 / 开发者中心 / 审核 / 分润)
313 lines
11 KiB
PHP
313 lines
11 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\docs\controller;
|
||
|
||
use addon\docs\model\DocsDoc;
|
||
use addon\docs\model\DocsProject;
|
||
use addon\docs\model\DocsVersion;
|
||
use addon\docs\service\DocTreeService;
|
||
use think\facade\Cache;
|
||
|
||
/**
|
||
* 文档阅读页(三栏布局)
|
||
*
|
||
* @author ywxapp <admin@ywxapp.cn>
|
||
*/
|
||
class Read extends DocsFrontend
|
||
{
|
||
/**
|
||
* 项目入口:跳转到该项目第一篇文档
|
||
*
|
||
* @param string $project 项目标识
|
||
*/
|
||
public function entry($project = '')
|
||
{
|
||
$projectModel = DocsProject::findByName((string) $project);
|
||
if (!$projectModel) {
|
||
return $this->notFound('文档项目不存在或已下线');
|
||
}
|
||
|
||
$version = DocsVersion::resolve((int) $projectModel->id, (string) $projectModel->default_version);
|
||
if (!$version) {
|
||
return $this->notFound('该项目尚未创建版本');
|
||
}
|
||
|
||
$first = DocTreeService::firstDoc((int) $projectModel->id, (int) $version->id);
|
||
if (!$first) {
|
||
return $this->notFound('该项目暂无可阅读的文档');
|
||
}
|
||
|
||
return redirect($this->docsBase() . '/' . $projectModel->name . '/' . $first['name']);
|
||
}
|
||
|
||
/**
|
||
* 文档正文
|
||
*
|
||
* @param string $project 项目标识
|
||
* @param string $name 文档标识
|
||
* @param string $version 版本标识,可为空
|
||
*/
|
||
public function index($project = '', $name = '', $version = '')
|
||
{
|
||
$projectModel = DocsProject::findByName((string) $project);
|
||
if (!$projectModel) {
|
||
return $this->notFound('文档项目不存在或已下线');
|
||
}
|
||
|
||
$versionModel = DocsVersion::resolve(
|
||
(int) $projectModel->id,
|
||
(string) ($version !== '' ? $version : $projectModel->default_version)
|
||
);
|
||
if (!$versionModel) {
|
||
return $this->notFound('该项目尚未创建版本');
|
||
}
|
||
|
||
$projectId = (int) $projectModel->id;
|
||
$versionId = (int) $versionModel->id;
|
||
|
||
$doc = DocsDoc::findByName($projectId, $versionId, (string) $name);
|
||
if (!$doc) {
|
||
return $this->notFound('文档不存在或已下线');
|
||
}
|
||
|
||
// 纯目录节点没有正文,跳到它下面第一篇可读文档
|
||
if ((int) $doc->is_dir === 1) {
|
||
$child = DocsDoc::where('project_id', $projectId)
|
||
->where('version_id', $versionId)
|
||
->where('pid', $doc->id)
|
||
->where('status', 1)
|
||
->where('is_dir', 0)
|
||
->order('sort', 'asc')->order('id', 'asc')
|
||
->find();
|
||
if ($child) {
|
||
return redirect($this->docUrl($projectModel, $versionModel, (string) $child->name));
|
||
}
|
||
}
|
||
|
||
// 阅读量去重:同一会话同一文档 5 分钟内不计,避免刷新/刷新/刷新刷虚高
|
||
$this->bumpViews((int) $doc->id);
|
||
|
||
$tree = DocTreeService::buildTree($projectId, $versionId);
|
||
$siblings = DocTreeService::siblings($projectId, $versionId, (int) $doc->id);
|
||
$crumbs = DocTreeService::breadcrumb($projectId, $versionId, (int) $doc->id);
|
||
|
||
// 给正文标题补 id 锚点,供右侧目录跳转
|
||
[$content, $toc] = $this->buildToc((string) $doc->content);
|
||
|
||
// 默认版本用短地址,非默认版本地址里带版本段
|
||
$baseUrl = $this->docsBase() . '/' . $projectModel->name;
|
||
if ((int) $versionModel->is_default !== 1) {
|
||
$baseUrl .= '/' . $versionModel->name;
|
||
}
|
||
|
||
$treeHtml = $this->renderTree(
|
||
$tree,
|
||
$baseUrl,
|
||
(int) $doc->id,
|
||
array_column($crumbs, 'id')
|
||
);
|
||
|
||
$updateAt = $doc->update_at;
|
||
$updateTs = !is_numeric($updateAt) ? strtotime((string) $updateAt) : (int) $updateAt;
|
||
$updateDate = date('Y-m-d', (int) $updateTs);
|
||
|
||
$this->assign([
|
||
'baseUrl' => $baseUrl,
|
||
'project' => $projectModel,
|
||
'version' => $versionModel,
|
||
'versionList' => DocsVersion::listByProject($projectId),
|
||
'doc' => $doc,
|
||
'content' => $content,
|
||
'toc' => $toc,
|
||
'treeHtml' => $treeHtml,
|
||
'updateDate' => $updateDate,
|
||
'crumbs' => $crumbs,
|
||
'prev' => $siblings['prev'],
|
||
'next' => $siblings['next'],
|
||
'activeId' => (int) $doc->id,
|
||
'openIds' => array_column($crumbs, 'id'),
|
||
'pageTitle' => ($doc->seo_title !== '' ? $doc->seo_title : $doc->title) . ' - ' . $projectModel->title,
|
||
'seoKeywords' => $doc->seo_keywords,
|
||
'seoDesc' => $doc->seo_desc !== '' ? $doc->seo_desc : mb_substr(strip_tags((string) $doc->content), 0, 150, 'UTF-8'),
|
||
]);
|
||
|
||
return $this->fetch('read/index');
|
||
}
|
||
|
||
/**
|
||
* 递归渲染侧边文档树为 HTML(避免模板 {php} 块内定义函数导致的编译错误)
|
||
*
|
||
* @param array $nodes 树节点
|
||
* @param string $baseUrl 文档基础 URL
|
||
* @param int $activeId 当前文档 ID
|
||
* @param array $openIds 需要展开的祖先节点 ID
|
||
* @return string
|
||
*/
|
||
protected function renderTree(array $nodes, string $baseUrl, int $activeId, array $openIds): string
|
||
{
|
||
if (empty($nodes)) {
|
||
return '';
|
||
}
|
||
|
||
$html = '<ul class="dc-tree">';
|
||
foreach ($nodes as $node) {
|
||
$id = (int) $node['id'];
|
||
$isDir = (int) ($node['is_dir'] ?? 0) === 1;
|
||
$hasChild = !empty($node['children']);
|
||
$isActive = $id === $activeId;
|
||
$isOpen = $isActive || in_array($id, $openIds, true);
|
||
$title = htmlspecialchars((string) $node['title'], ENT_QUOTES, 'UTF-8');
|
||
$url = $baseUrl . '/' . rawurlencode((string) $node['name']);
|
||
|
||
$liClass = [];
|
||
if ($hasChild) {
|
||
$liClass[] = 'has-child';
|
||
}
|
||
if ($isOpen) {
|
||
$liClass[] = 'is-open';
|
||
}
|
||
|
||
$html .= '<li class="' . implode(' ', $liClass) . '">';
|
||
$html .= '<div class="dc-tree-item' . ($isActive ? ' is-active' : '') . '">';
|
||
|
||
if ($hasChild) {
|
||
$html .= '<span class="dc-tree-toggle" aria-label="展开"></span>';
|
||
}
|
||
|
||
if ($isDir && $hasChild) {
|
||
$html .= '<span class="dc-tree-link is-dir">' . $title . '</span>';
|
||
} else {
|
||
$html .= '<a class="dc-tree-link" href="' . htmlspecialchars($url, ENT_QUOTES) . '">' . $title . '</a>';
|
||
}
|
||
|
||
$html .= '</div>';
|
||
|
||
if ($hasChild) {
|
||
$html .= $this->renderTree($node['children'], $baseUrl, $activeId, $openIds);
|
||
}
|
||
|
||
$html .= '</li>';
|
||
}
|
||
$html .= '</ul>';
|
||
|
||
return $html;
|
||
}
|
||
|
||
/**
|
||
* 为正文中的 h2 / h3 标题补充锚点并抽取目录
|
||
*
|
||
* @param string $html 正文 HTML
|
||
* @return array{0: string, 1: array}
|
||
*/
|
||
protected function buildToc(string $html): array
|
||
{
|
||
if ($html === '') {
|
||
return ['', []];
|
||
}
|
||
|
||
$toc = [];
|
||
$index = 0;
|
||
|
||
$result = preg_replace_callback(
|
||
'#<h([23])([^>]*)>(.*?)</h\1>#is',
|
||
function ($m) use (&$toc, &$index) {
|
||
$level = (int) $m[1];
|
||
$attrs = $m[2];
|
||
$inner = $m[3];
|
||
$text = trim(strip_tags($inner));
|
||
|
||
if ($text === '') {
|
||
return $m[0];
|
||
}
|
||
|
||
$index++;
|
||
$anchor = 'doc-h-' . $index;
|
||
|
||
// 已有 id 时沿用,避免破坏作者自定义锚点
|
||
if (preg_match('/\sid\s*=\s*["\']([^"\']+)["\']/i', $attrs, $idMatch)) {
|
||
$anchor = $idMatch[1];
|
||
} else {
|
||
$attrs .= ' id="' . $anchor . '"';
|
||
}
|
||
|
||
$toc[] = [
|
||
'level' => $level,
|
||
'text' => $text,
|
||
'anchor' => $anchor,
|
||
];
|
||
|
||
return '<h' . $level . $attrs . '>' . $inner . '</h' . $level . '>';
|
||
},
|
||
$html
|
||
);
|
||
|
||
return [$result ?? $html, $toc];
|
||
}
|
||
|
||
/**
|
||
* 生成文档访问地址
|
||
*
|
||
* @param DocsProject $project 项目
|
||
* @param DocsVersion $version 版本
|
||
* @param string $name 文档标识
|
||
* @return string
|
||
*/
|
||
protected function docUrl(DocsProject $project, DocsVersion $version, string $name): string
|
||
{
|
||
$base = $this->docsBase();
|
||
// 默认版本走短地址,非默认版本带上版本段
|
||
if ((int) $version->is_default === 1) {
|
||
return $base . '/' . $project->name . '/' . $name;
|
||
}
|
||
return $base . '/' . $project->name . '/' . $version->name . '/' . $name;
|
||
}
|
||
|
||
/**
|
||
* 渲染 404 提示页(同时返回 HTTP 404 状态码,避免被索引收录)
|
||
*
|
||
* @param string $message 提示语
|
||
*/
|
||
protected function notFound(string $message)
|
||
{
|
||
$this->assign('message', $message);
|
||
// 关键:使用 HTTP 404 而非 200,搜索引擎不会收录软性 404 页。
|
||
// 注意:$this->fetch() 返回的是渲染后的 HTML 字符串(不是 Response 对象),
|
||
// 因此不能链式调用 ->code(),必须用 response($html, 404) 包装成响应。
|
||
return response($this->fetch('read/notfound'), 404);
|
||
}
|
||
|
||
/**
|
||
* 阅读量计数(同一访客同一文档 5 分钟内只算 1 次)
|
||
*
|
||
* 采用缓存节流而非 Session:前台文档对游客开放,游客请求未必启动 Session
|
||
* ($this->request->session() 在本框架返回数组而非 Store 对象),
|
||
* 且阅读量属于统计类数据,不应强依赖会话。
|
||
*
|
||
* @param int $docId 文档ID
|
||
*/
|
||
protected function bumpViews(int $docId): void
|
||
{
|
||
if ($docId <= 0) {
|
||
return;
|
||
}
|
||
|
||
$fingerprint = md5($this->request->ip() . '|' . (string) $this->request->header('user-agent', ''));
|
||
$key = 'docs_view:' . $docId . ':' . $fingerprint;
|
||
|
||
if (Cache::get($key, 0)) {
|
||
return; // 5 分钟内已计过
|
||
}
|
||
|
||
DocsDoc::addViews($docId);
|
||
Cache::set($key, 1, 300);
|
||
}
|
||
}
|