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:
1 parent
1d49e6f5ee
commit
18101cf11a
636 files changed
+58517
-9519
No files matched your search
@@ -0,0 +1,350 @@
|
||||
<?php
|
||||
/*
|
||||
* @Author: YwxApp <ywx@ywxapp.cn>
|
||||
* @Date: 2026-09-29
|
||||
* @Description: 模板钩子(Discuz 式扩展点):模板埋点 -> 事件收集 -> 插件输出 HTML 片段
|
||||
* @FilePath: \ywxapp_dev\ywxapp\service\HookService.php
|
||||
* @CustomString: Copyright (c) 2026 YwxApp
|
||||
*/
|
||||
declare(strict_types=1);
|
||||
namespace ywxapp\service;
|
||||
|
||||
use think\facade\Event;
|
||||
use think\facade\Log;
|
||||
use think\facade\View;
|
||||
|
||||
/**
|
||||
* 模板钩子服务
|
||||
*
|
||||
* 与 Discuz 的 <!--{hook/xxx}--> 同构,但输出改为运行期收集:
|
||||
* 模板 {:hook('backend.score.toolbar', ['row'=>$row])}
|
||||
* -> 已挂载的处理器(HookService::register/mount,可排序、可枚举)
|
||||
* -> 事件 HookRender 的监听返回值(多个插件拼接)
|
||||
* -> 拼成 HTML 片段返回
|
||||
*
|
||||
* 两类事件语义差别(重要):
|
||||
* SmsSend / ScoreBefore 是「接管型」:trigger($e, $p, true) 只取一个返回值做决策;
|
||||
* HookRender 是「收集型」:非 once 触发,所有监听的返回值都要,用于拼接输出。
|
||||
* 因此监听器不要返回 false —— think\Event::trigger() 遇到 false 会 break,
|
||||
* 后面插件的输出会全部丢失。不想输出就返回空字符串。
|
||||
*/
|
||||
class HookService
|
||||
{
|
||||
/** 收集型事件名(订阅器 $eventPrefix='Hook' + onRender 即命中) */
|
||||
const EVENT = 'HookRender';
|
||||
|
||||
/** 开发者模式 cookie 键(后台「模板钩子」页临时开关用) */
|
||||
const DEV_COOKIE = 'hook_dev';
|
||||
|
||||
/**
|
||||
* 已挂载处理器:hook 名 => [[weight, handler, addon], ...]
|
||||
* @var array<string, array<int, array>>
|
||||
*/
|
||||
protected static array $handlers = [];
|
||||
|
||||
/**
|
||||
* 挂载处理器
|
||||
*
|
||||
* @param string $name 钩子名
|
||||
* @param callable|string $handler 回调,或模板路径 'demo@hook/score-toolbar'
|
||||
* @param int $weight 越小越靠前
|
||||
* @param string $addon 归属插件(诊断页展示用)
|
||||
*/
|
||||
public static function register(string $name, $handler, int $weight = 10, string $addon = ''): void
|
||||
{
|
||||
if ($name === '' || $handler === null || $handler === '') {
|
||||
return;
|
||||
}
|
||||
// 幂等:同钩子 + 同处理器 + 同来源插件只注册一次。
|
||||
// 插件热重载 / 重复初始化会再次执行订阅器里的 mount(),不去重会导致同一片段输出多份。
|
||||
foreach (static::$handlers[$name] ?? [] as $item) {
|
||||
if ($item[1] === $handler && $item[2] === $addon) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
static::$handlers[$name][] = [$weight, $handler, $addon];
|
||||
}
|
||||
|
||||
/**
|
||||
* 声明式挂载插件模板片段(推荐写法:可在「模板钩子」诊断页枚举到)
|
||||
*
|
||||
* HookService::mount('backend.score.toolbar', 'demo@hook/score-toolbar');
|
||||
* 等价模板:addon/demo/view/hook/score-toolbar.html
|
||||
*/
|
||||
public static function mount(string $name, string $template, string $addon = '', int $weight = 10): void
|
||||
{
|
||||
if ($addon === '' && str_contains($template, '@')) {
|
||||
[$addon, $template] = explode('@', $template, 2);
|
||||
}
|
||||
$addon = trim($addon, '@/');
|
||||
$template = ltrim($template, '@/');
|
||||
if ($addon === '' || $template === '') {
|
||||
return;
|
||||
}
|
||||
static::register($name, ['template' => $addon . '@' . $template], $weight, $addon);
|
||||
}
|
||||
|
||||
/**
|
||||
* 渲染钩子点:挂载处理器(按权重)+ 事件监听返回值,拼接成 HTML
|
||||
*/
|
||||
public static function render(string $name, array $vars = []): string
|
||||
{
|
||||
if ($name === '') {
|
||||
return '';
|
||||
}
|
||||
|
||||
$html = [];
|
||||
|
||||
// 1) 静态挂载:可枚举、可排序
|
||||
$handlers = static::handlers($name);
|
||||
foreach ($handlers as $item) {
|
||||
$out = static::callHandler($item[1], $vars);
|
||||
if ($out !== '') {
|
||||
$html[] = $out;
|
||||
}
|
||||
}
|
||||
|
||||
// 2) 事件收集:无监听时短路,省一次事件派发
|
||||
if (Event::hasListener(static::EVENT)) {
|
||||
$responses = Event::trigger(static::EVENT, ['name' => $name, 'vars' => $vars]);
|
||||
foreach ((array) $responses as $resp) {
|
||||
if (is_string($resp) && trim($resp) !== '') {
|
||||
$html[] = $resp;
|
||||
} elseif (is_array($resp) && ! empty($resp['html'])) {
|
||||
$html[] = (string) $resp['html'];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3) 开发者模式:无论有无输出都把埋点位置标出来
|
||||
if (static::devMode()) {
|
||||
return static::markup($name, $html, count($handlers));
|
||||
}
|
||||
|
||||
if (empty($html)) {
|
||||
return '';
|
||||
}
|
||||
return '<!-- hook: ' . $name . ' -->' . "\n" . implode("\n", $html) . "\n" . '<!-- /hook: ' . $name . ' -->';
|
||||
}
|
||||
|
||||
/**
|
||||
* 钩点可视化是否开启(Discuz 式开发者模式)
|
||||
*
|
||||
* 两种开关:
|
||||
* 1. config('ywxapp.hook_developer'):全局模式,前台/会员中心也标注(建议仅开发环境)
|
||||
* 2. cookie hook_dev=1:后台「数据运维 → 模板钩子」页临时开关,**只在 backend 应用内生效**,
|
||||
* 避免把埋点位置信息随前台页面暴露给访客。
|
||||
*/
|
||||
public static function devMode(): bool
|
||||
{
|
||||
try {
|
||||
if (config('ywxapp.hook_developer', false)) {
|
||||
return true;
|
||||
}
|
||||
if (app()->runningInConsole()) {
|
||||
return false;
|
||||
}
|
||||
// cookie 临时开关只在后台生效
|
||||
$appName = '';
|
||||
try {
|
||||
$appName = (string) app('http')->getName();
|
||||
} catch (\Throwable $e) {
|
||||
$appName = '';
|
||||
}
|
||||
if ($appName !== '' && $appName !== 'backend') {
|
||||
return false;
|
||||
}
|
||||
return app('request')->cookie(static::DEV_COOKIE) === '1';
|
||||
} catch (\Throwable $e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 开发者模式标注外壳
|
||||
*
|
||||
* - `<head>` 内埋点不能用元素包裹(非法元素会被浏览器搬到 body),退化为注释标记
|
||||
* - 其余位置包一层内联虚线框元素,悬停显示「钩子名 / 挂载数 / 输出段数」
|
||||
* - 空埋点标成灰色并显示「(空)」——看得见所有可挂位置,正是本功能的价值所在
|
||||
*/
|
||||
protected static function markup(string $name, array $html, int $mountCount): string
|
||||
{
|
||||
$count = count($html);
|
||||
$body = implode("\n", $html);
|
||||
|
||||
// head 区域:注释形式(不产生可见元素)
|
||||
if (str_ends_with($name, '.head')) {
|
||||
return '<!-- hook-mark: ' . $name . '(挂载 ' . $mountCount . ' 项,输出 ' . $count . ' 段) -->'
|
||||
. ($body !== '' ? "\n" . $body : '');
|
||||
}
|
||||
|
||||
$safe = htmlspecialchars($name, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
|
||||
$tag = '<small style="display:inline-block;margin:2px 4px 2px 0;padding:1px 5px;'
|
||||
. 'font-size:11px;line-height:17px;color:#fff;border-radius:2px;cursor:help;'
|
||||
. 'background:' . ($count > 0 ? '#1E9FFF' : '#b2b2b2') . ';" '
|
||||
. 'title="钩子:' . $safe . '|挂载 ' . $mountCount . ' 项,输出 ' . $count . ' 段">'
|
||||
. '⟡ ' . $safe . ($count > 0 ? '' : '(空)') . '</small>';
|
||||
|
||||
return '<span class="ywxapp-hook-mark" data-hook="' . $safe . '" '
|
||||
. 'style="display:inline-block;outline:1px dashed #1E9FFF;outline-offset:2px;">'
|
||||
. $tag . ($body !== '' ? "\n" . $body : '') . '</span>';
|
||||
}
|
||||
|
||||
/**
|
||||
* 取某钩子点的挂载(按 weight 升序,同权重保持注册顺序)
|
||||
*/
|
||||
public static function handlers(string $name): array
|
||||
{
|
||||
$list = static::$handlers[$name] ?? [];
|
||||
if (count($list) < 2) {
|
||||
return $list;
|
||||
}
|
||||
// 稳定排序:同权重按注册顺序
|
||||
$index = 0;
|
||||
$ranked = array_map(function ($item) use (&$index) {
|
||||
return [$item[0], $index++, $item[1], $item[2]];
|
||||
}, $list);
|
||||
usort($ranked, function ($a, $b) {
|
||||
return $a[0] === $b[0] ? $a[1] <=> $b[1] : $a[0] <=> $b[0];
|
||||
});
|
||||
return array_map(function ($item) {
|
||||
return [$item[0], $item[2], $item[3]];
|
||||
}, $ranked);
|
||||
}
|
||||
|
||||
/**
|
||||
* 全部挂载(诊断页用):hook 名 => ['addon'=>..,'weight'=>..,'type'=>'template|callback']
|
||||
*/
|
||||
public static function mounted(): array
|
||||
{
|
||||
$out = [];
|
||||
foreach (static::$handlers as $name => $items) {
|
||||
foreach ($items as $item) {
|
||||
$out[] = [
|
||||
'name' => $name,
|
||||
'weight' => $item[0],
|
||||
'type' => is_array($item[1]) ? 'template' : (is_string($item[1]) ? 'template' : 'callback'),
|
||||
'target' => is_array($item[1]) ? ($item[1]['template'] ?? '') : (is_string($item[1]) ? $item[1] : 'Closure'),
|
||||
'addon' => $item[2],
|
||||
];
|
||||
}
|
||||
}
|
||||
return $out;
|
||||
}
|
||||
|
||||
/**
|
||||
* 渲染插件模板片段:'demo@hook/score-toolbar' => addon/demo/view/hook/score-toolbar.html
|
||||
*
|
||||
* 视图根目录是全局单例,渲染前临时切到插件 view/,渲染完必须还原,
|
||||
* 否则会污染同请求后续的模板解析(核心后台页会去找插件目录下的模板)。
|
||||
*/
|
||||
public static function renderTemplate(string $path, array $vars = []): string
|
||||
{
|
||||
if (! str_contains($path, '@')) {
|
||||
return '';
|
||||
}
|
||||
[$addon, $tpl] = explode('@', $path, 2);
|
||||
$addon = trim($addon, '/');
|
||||
$tpl = ltrim(trim($tpl), '/');
|
||||
if ($addon === '' || $tpl === '') {
|
||||
return '';
|
||||
}
|
||||
|
||||
$base = root_path() . 'addon' . DIRECTORY_SEPARATOR . $addon . DIRECTORY_SEPARATOR
|
||||
. 'view' . DIRECTORY_SEPARATOR;
|
||||
$file = $base . $tpl . '.html';
|
||||
if (! is_file($file)) {
|
||||
Log::warning('[HookService] 钩子模板不存在: ' . $file);
|
||||
return '';
|
||||
}
|
||||
|
||||
$restore = View::getConfig('view_path');
|
||||
View::config(['view_path' => $base]);
|
||||
try {
|
||||
return (string) View::fetch($tpl, $vars);
|
||||
} catch (\Throwable $e) {
|
||||
Log::error('[HookService] 渲染钩子模板失败 ' . $file . ' - ' . $e->getMessage());
|
||||
return '';
|
||||
} finally {
|
||||
if ($restore !== null) {
|
||||
View::config(['view_path' => $restore]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行单个处理器,异常不影响其它插件输出
|
||||
*/
|
||||
protected static function callHandler($handler, array $vars): string
|
||||
{
|
||||
try {
|
||||
if (is_array($handler) && isset($handler['template'])) {
|
||||
return trim(static::renderTemplate((string) $handler['template'], $vars));
|
||||
}
|
||||
if (is_string($handler) && str_contains($handler, '@')) {
|
||||
return trim(static::renderTemplate($handler, $vars));
|
||||
}
|
||||
if (is_callable($handler)) {
|
||||
return trim((string) call_user_func($handler, $vars));
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
Log::error('[HookService] 钩子处理器异常: ' . $e->getMessage());
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
/**
|
||||
* 扫描视图目录里的 hook('...') 埋点(诊断页用)
|
||||
*
|
||||
* @return array ['name' => ['files' => [...], 'count' => int]]
|
||||
*/
|
||||
public static function points(): array
|
||||
{
|
||||
// 按 root_path 拼,不依赖当前应用上下文(CLI / 其它应用下同样可用)
|
||||
$appRoot = root_path() . 'app' . DIRECTORY_SEPARATOR;
|
||||
$roots = [
|
||||
'后台' => $appRoot . 'backend' . DIRECTORY_SEPARATOR . 'view',
|
||||
'会员中心' => $appRoot . 'member' . DIRECTORY_SEPARATOR . 'view',
|
||||
'前台' => $appRoot . 'frontend' . DIRECTORY_SEPARATOR . 'view',
|
||||
];
|
||||
foreach (glob(root_path() . 'addon' . DIRECTORY_SEPARATOR . '*', GLOB_ONLYDIR) ?: [] as $dir) {
|
||||
$roots['插件:' . basename($dir)] = $dir . DIRECTORY_SEPARATOR . 'view';
|
||||
}
|
||||
|
||||
$points = [];
|
||||
foreach ($roots as $label => $dir) {
|
||||
if (! is_dir($dir)) {
|
||||
continue;
|
||||
}
|
||||
$it = new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($dir, \FilesystemIterator::SKIP_DOTS));
|
||||
foreach ($it as $file) {
|
||||
if (! $file->isFile() || strtolower($file->getExtension()) !== 'html') {
|
||||
continue;
|
||||
}
|
||||
$content = (string) file_get_contents($file->getPathname());
|
||||
if (preg_match_all("/hook\(\s*'([^']+)'/", $content, $m)) {
|
||||
foreach ($m[1] as $name) {
|
||||
$points[$name]['files'][] = $label . ' ' . str_replace(root_path(), '', $file->getPathname());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$out = [];
|
||||
foreach ($points as $name => $info) {
|
||||
$info['files'] = array_values(array_unique($info['files']));
|
||||
$info['count'] = count($info['files']);
|
||||
$out[$name] = $info;
|
||||
}
|
||||
ksort($out);
|
||||
return $out;
|
||||
}
|
||||
|
||||
/**
|
||||
* 是否存在收集型监听(诊断页展示)
|
||||
*/
|
||||
public static function hasListener(): bool
|
||||
{
|
||||
return Event::hasListener(static::EVENT);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user