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

+290
View File
@@ -0,0 +1,290 @@
<?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);
}
}
}
}
}