安全(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(市场 / 开发者中心 / 审核 / 分润)
291 lines
12 KiB
PHP
291 lines
12 KiB
PHP
<?php
|
||
// +----------------------------------------------------------------------
|
||
// | YwxApp AppMarket [ 应用市场中心站插件 ]
|
||
// +----------------------------------------------------------------------
|
||
// | Copyright (c) 2026-2036 http://ywxapp.cn All rights reserved.
|
||
// +----------------------------------------------------------------------
|
||
// | Author: ywxapp<admin@ywxapp.cn>
|
||
// +----------------------------------------------------------------------
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace app\appmall\library;
|
||
|
||
use ywxapp\model\BackendPower;
|
||
use ywxapp\model\MemberRule;
|
||
|
||
/**
|
||
* 应用市场「后台/前台菜单兜底」。
|
||
*
|
||
* 背景:appmall 已从插件(addon/appmall)迁移为普通应用(app/appmall),
|
||
* AddonService::createMenu() 读的是 ADDON_PATH 下的 menu.json,install.sql
|
||
* 里也没有 appmall 菜单种子 —— 两头都断了,后台侧边栏永远不会出现
|
||
* 「应用市场」菜单。本类把菜单注入改为**内置清单 + 幂等探测式兜底**,
|
||
* 随请求自动补齐,不再依赖安装命令与外部 menu.json 文件。
|
||
*
|
||
* 设计要点(与 AppmallSchema 的表结构兜底同一套哲学):
|
||
* - 事实源内置:菜单清单见本类常量(迁移自 menu.json,该文件已删除,
|
||
* 改菜单直接改常量即可);
|
||
* - 幂等:按菜单 name 逐项探测(appmall:backend:<name> / appmall:<name>),
|
||
* 已存在的跳过,常量后续新增条目也会被自动补上;
|
||
* - 只在中心站注入:客户机(is_market_client())上 /appmall/* 整体 404,
|
||
* 注入中心运营菜单只会产生死链,故整组跳过;
|
||
* - 绝不阻断业务:DB 异常一律静默吞掉,由后台页面正常暴露错误;
|
||
* - 语义对齐 AddonService::saveMenus:后台菜单 name 前缀「appmall:backend:」
|
||
* 并写 addon='appmall'(侧边栏按 addon 判定走完整路径直出),前台/会员
|
||
* 菜单写 member_rule.module 区分;目录型节点路由置 '#',叶子节点按
|
||
* name 推导(冒号转斜线),显式 route 优先。
|
||
*
|
||
* 触发点:
|
||
* - app/appmall/middleware/AppmallSchemaMiddleware → 任何 /appmall/* 请求;
|
||
* - app/backend/middleware/BackendSchemaMiddleware → 任何 /admin/* 请求
|
||
* (管理员不看 /appmall 前台也能在侧边栏看到菜单)。
|
||
*
|
||
* @author ywxapp<admin@ywxapp.cn>
|
||
*/
|
||
class AppmallMenu
|
||
{
|
||
/** 应用标识(菜单归属列 addon / name 前缀) */
|
||
private const APP = 'appmall';
|
||
|
||
/**
|
||
* 后台菜单根分组(挂到「应用」(name=app) 目录下,不存在则自成一级)。
|
||
*/
|
||
private const BACKEND_ROOT = [
|
||
'title' => '应用市场',
|
||
'icon' => 'fa fa-shopping-cart',
|
||
'sort' => 5,
|
||
];
|
||
|
||
/**
|
||
* 后台菜单叶子项(挂在根分组下;支持 'child' 嵌套目录)。
|
||
* 路由须与 app/appmall/route/app.php 注册地址一一对应。
|
||
*/
|
||
private const BACKEND_ITEMS = [
|
||
['name' => 'addonreview', 'title' => '插件审核', 'route' => '/appmall/backend/addonreview/index'],
|
||
['name' => 'developer', 'title' => '开发者管理', 'route' => '/appmall/backend/developer/index'],
|
||
['name' => 'revenuelist', 'title' => '收益账本', 'route' => '/appmall/backend/revenue/index'],
|
||
['name' => 'withdrawallist', 'title' => '提现管理', 'route' => '/appmall/backend/revenue/withdrawals'],
|
||
['name' => 'frameworklist', 'title' => '框架管理', 'icon' => 'fa fa-cloud-download', 'route' => '/appmall/backend/framework/index'],
|
||
];
|
||
|
||
/** 会员中心菜单(name 前缀 appmall:,module=member) */
|
||
private const MEMBER_ITEMS = [];
|
||
|
||
/** 前台菜单(name 前缀 appmall:,module=frontend) */
|
||
private const FRONTEND_ITEMS = [
|
||
['name' => 'frameworkdisplay', 'title' => '框架下载', 'route' => '/appmall/frontend/framework/index'],
|
||
];
|
||
|
||
/** 进程内已完成兜底标记:每请求最多探测一轮,幂等且极轻 */
|
||
private static bool $done = false;
|
||
|
||
/**
|
||
* 确保应用市场菜单已注入(幂等)。
|
||
*/
|
||
public static function ensure(): void
|
||
{
|
||
if (self::$done) {
|
||
return;
|
||
}
|
||
self::$done = true; // 先置位防重入
|
||
|
||
try {
|
||
// 客户机:/appmall/* 整体 404,不注入任何 appmall 菜单
|
||
if (function_exists('is_market_client') && is_market_client()) {
|
||
return;
|
||
}
|
||
|
||
self::ensureBackend(self::BACKEND_ITEMS);
|
||
self::ensureRule(self::FRONTEND_ITEMS, 'frontend');
|
||
self::ensureRule(self::MEMBER_ITEMS, 'member');
|
||
} catch (\Throwable $e) {
|
||
// 菜单兜底失败不影响本次请求,由后台页面暴露
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 后台菜单 → backend_power(name 前缀 appmall:backend:,写 addon 列)。
|
||
* 顶级挂载策略与 AddonService::createMenu 默认一致:优先挂「应用」
|
||
* (name=app)顶级目录下,不存在则自成一级菜单。
|
||
*/
|
||
private static function ensureBackend(array $items): void
|
||
{
|
||
if (empty($items)) {
|
||
return;
|
||
}
|
||
|
||
// 根分组节点:appmall:backend(存在则复用,幂等)
|
||
$rootName = self::APP . ':backend';
|
||
$root = BackendPower::where('name', $rootName)->find();
|
||
if (!$root || empty($root->id)) {
|
||
$app = BackendPower::where('name', 'app')->find();
|
||
$appPid = ($app && !empty($app->id)) ? (int) $app->id : 0;
|
||
$root = BackendPower::create([
|
||
'pid' => $appPid,
|
||
'title' => self::BACKEND_ROOT['title'],
|
||
'name' => $rootName,
|
||
'icon' => (string) self::BACKEND_ROOT['icon'],
|
||
'status' => 1,
|
||
'type' => 1,
|
||
'sort' => (int) (self::BACKEND_ROOT['sort'] ?? 1),
|
||
'route' => '',
|
||
'addon' => self::APP,
|
||
]);
|
||
}
|
||
$pid = !empty($root->id) ? (int) $root->id : 0;
|
||
|
||
foreach ($items as $item) {
|
||
if (is_array($item)) {
|
||
self::saveBackendItem($item, $pid);
|
||
}
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 递归保存单条后台菜单(已存在同名节点则跳过整棵子树)。
|
||
*/
|
||
private static function saveBackendItem(array $item, int $pid): void
|
||
{
|
||
// centerOnly:仅中心站显示;ensure() 已在客户机整组拦截,这里再兜一层
|
||
if (!empty($item['centerOnly']) && function_exists('is_market_client') && is_market_client()) {
|
||
return;
|
||
}
|
||
$name = (string) ($item['name'] ?? '');
|
||
if ($name === '') {
|
||
return;
|
||
}
|
||
|
||
$fullName = self::APP . ':backend:' . $name;
|
||
$childList = $item['child'] ?? $item['sublist'] ?? [];
|
||
$hasChild = !empty($childList) && is_array($childList);
|
||
$type = (int) ($item['type'] ?? ($hasChild ? 1 : 2));
|
||
$route = (string) ($item['route'] ?? '');
|
||
if ($route === '') {
|
||
// 目录型节点是纯容器,统一置 '#';叶子菜单按 name 推导(冒号转斜线)
|
||
$route = $type === 1 ? '#' : str_replace(':', '/', $fullName);
|
||
}
|
||
|
||
$model = BackendPower::where('name', $fullName)->find();
|
||
if (!$model || empty($model->id)) {
|
||
$model = BackendPower::create([
|
||
'pid' => $pid,
|
||
'title' => (string) ($item['title'] ?? ''),
|
||
'name' => $fullName,
|
||
'icon' => (string) ($item['icon'] ?? ''),
|
||
'status' => (int) ($item['status'] ?? 1),
|
||
'type' => $type,
|
||
'sort' => (int) ($item['sort'] ?? 1),
|
||
'route' => $route,
|
||
'addon' => self::APP,
|
||
]);
|
||
}
|
||
$childPid = !empty($model->id) ? (int) $model->id : $pid;
|
||
|
||
if ($hasChild) {
|
||
foreach ($childList as $child) {
|
||
if (is_array($child)) {
|
||
self::saveBackendItem($child, $childPid);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 前台 / 会员菜单 → member_rule(name 前缀 appmall:,module 区分端)。
|
||
*/
|
||
private static function ensureRule(array $items, string $module): void
|
||
{
|
||
foreach ($items as $item) {
|
||
if (!is_array($item)) {
|
||
continue;
|
||
}
|
||
$name = (string) ($item['name'] ?? '');
|
||
if ($name === '') {
|
||
continue;
|
||
}
|
||
if (!empty($item['centerOnly']) && function_exists('is_market_client') && is_market_client()) {
|
||
continue;
|
||
}
|
||
|
||
$fullName = self::APP . ':' . $name;
|
||
$childList = $item['child'] ?? $item['sublist'] ?? [];
|
||
$hasChild = !empty($childList) && is_array($childList);
|
||
$type = (int) ($item['type'] ?? ($hasChild ? 1 : 2));
|
||
$route = (string) ($item['route'] ?? '');
|
||
if ($route === '') {
|
||
$route = $type === 1 ? '#' : str_replace(':', '/', $fullName);
|
||
}
|
||
|
||
$model = MemberRule::where('name', $fullName)->where('module', $module)->find();
|
||
if (!$model || empty($model->id)) {
|
||
$model = MemberRule::create([
|
||
'pid' => 0,
|
||
'title' => (string) ($item['title'] ?? ''),
|
||
'name' => $fullName,
|
||
'icon' => (string) ($item['icon'] ?? ''),
|
||
'status' => (int) ($item['status'] ?? 1),
|
||
'type' => $type,
|
||
'sort' => (int) ($item['sort'] ?? 1),
|
||
'route' => $route,
|
||
'module' => $module,
|
||
]);
|
||
}
|
||
$childPid = !empty($model->id) ? (int) $model->id : 0;
|
||
|
||
if ($hasChild) {
|
||
foreach ($childList as $child) {
|
||
if (is_array($child)) {
|
||
self::saveRuleItem($child, $childPid, $module);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 递归保存 member_rule 子菜单。
|
||
*/
|
||
private static function saveRuleItem(array $item, int $pid, string $module): void
|
||
{
|
||
$name = (string) ($item['name'] ?? '');
|
||
if ($name === '') {
|
||
return;
|
||
}
|
||
if (!empty($item['centerOnly']) && function_exists('is_market_client') && is_market_client()) {
|
||
return;
|
||
}
|
||
|
||
$fullName = self::APP . ':' . $name;
|
||
$childList = $item['child'] ?? $item['sublist'] ?? [];
|
||
$hasChild = !empty($childList) && is_array($childList);
|
||
$type = (int) ($item['type'] ?? ($hasChild ? 1 : 2));
|
||
$route = (string) ($item['route'] ?? '');
|
||
if ($route === '') {
|
||
$route = $type === 1 ? '#' : str_replace(':', '/', $fullName);
|
||
}
|
||
|
||
$model = MemberRule::where('name', $fullName)->where('module', $module)->find();
|
||
if (!$model || empty($model->id)) {
|
||
$model = MemberRule::create([
|
||
'pid' => $pid,
|
||
'title' => (string) ($item['title'] ?? ''),
|
||
'name' => $fullName,
|
||
'icon' => (string) ($item['icon'] ?? ''),
|
||
'status' => (int) ($item['status'] ?? 1),
|
||
'type' => $type,
|
||
'sort' => (int) ($item['sort'] ?? 1),
|
||
'route' => $route,
|
||
'module' => $module,
|
||
]);
|
||
}
|
||
if ($hasChild && !empty($model->id)) {
|
||
foreach ($childList as $child) {
|
||
if (is_array($child)) {
|
||
self::saveRuleItem($child, (int) $model->id, $module);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|