Files
YwxAppThink/ywxapp/service/HookService.php
T
yangwx 18101cf11a 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(市场 / 开发者中心 / 审核 / 分润)
2026-10-22 00:53:39 +08:00

351 lines
13 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?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);
}
}