* @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('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> */ 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 '' . "\n" . implode("\n", $html) . "\n" . ''; } /** * 钩点可视化是否开启(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; } } /** * 开发者模式标注外壳 * * - `` 内埋点不能用元素包裹(非法元素会被浏览器搬到 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 '' . ($body !== '' ? "\n" . $body : ''); } $safe = htmlspecialchars($name, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); $tag = '' . '⟡ ' . $safe . ($count > 0 ? '' : '(空)') . ''; return '' . $tag . ($body !== '' ? "\n" . $body : '') . ''; } /** * 取某钩子点的挂载(按 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); } }