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:
yangwx committed 2026-10-22 00:53:39 +08:00
1 parent 1d49e6f5ee
commit 18101cf11a
636 files changed
+58517 -9519

No files matched your search

+350
View File
@@ -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);
}
}