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:
1 parent
1d49e6f5ee
commit
18101cf11a
636 files changed
+58517
-9519
No files matched your search
@@ -0,0 +1,106 @@
|
||||
<?php
|
||||
// +----------------------------------------------------------------------
|
||||
// | YwxApp 框架文件清单
|
||||
// | 以数组方式列出框架发行包包含的所有文件(与 package_framework.php 口径一致)
|
||||
// | 用法:
|
||||
// | $files = require __DIR__ . '/framework_files.php'; // 返回相对路径数组
|
||||
// | print_r($files);
|
||||
// +----------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* 框架发行的目录范围(与 scripts/package_framework.php 的 includeDirs 一致)。
|
||||
* 注意:addon/ 属于插件体系,不在框架包内。
|
||||
*/
|
||||
const FRAMEWORK_DIRS = ['app', 'config', 'extend', 'ywxapp', 'public', 'route'];
|
||||
|
||||
/**
|
||||
* 排除规则(相对路径,基于项目根):
|
||||
* - public/uploads 上传目录
|
||||
* - public/static/<插件名> 插件静态资源(运行时发布,不入库)
|
||||
* - config/database.php 数据库私密配置
|
||||
* - config/ywxapp.php 框架配置(含版本号等,安装/升级时不应覆盖)
|
||||
*/
|
||||
const FRAMEWORK_EXCLUDES = [
|
||||
'public/uploads',
|
||||
'public/addons',
|
||||
'config/database.php',
|
||||
'config/ywxapp.php',
|
||||
];
|
||||
|
||||
/**
|
||||
* 递归收集框架文件相对路径。
|
||||
* @return string[] 形如 ['app/backend/controller/Admin.php', 'config/app.php', ...]
|
||||
*/
|
||||
function frameworkFiles(): array
|
||||
{
|
||||
$root = dirname(__DIR__); // scripts/ 的上一级 = 项目根
|
||||
$files = [];
|
||||
|
||||
// 自动发现插件静态资源(addon/ 下每个子目录对应 public/static/<插件名>/),从清单中剔除
|
||||
$addonStatic = [];
|
||||
$addonRoot = $root . DIRECTORY_SEPARATOR . 'addon';
|
||||
if (is_dir($addonRoot)) {
|
||||
foreach (array_diff(scandir($addonRoot), ['.', '..']) as $entry) {
|
||||
if (is_dir($addonRoot . DIRECTORY_SEPARATOR . $entry)) {
|
||||
$addonStatic[] = 'public/static/' . $entry . '/';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$isExcluded = static function (string $rel) use ($root, $addonStatic): bool {
|
||||
// 顶层排除项
|
||||
if (in_array($rel, FRAMEWORK_EXCLUDES, true)) {
|
||||
return true;
|
||||
}
|
||||
// addon/ 下的任何内容都不属于框架包
|
||||
if (strpos($rel, 'addon/') === 0) {
|
||||
return true;
|
||||
}
|
||||
// 上传目录整体排除
|
||||
if (strpos($rel, 'public/uploads/') === 0) {
|
||||
return true;
|
||||
}
|
||||
// 插件静态资源(public/static/<插件名>/)整体排除
|
||||
foreach ($addonStatic as $staticPrefix) {
|
||||
if (strpos($rel, $staticPrefix) === 0) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
// 跳过 .git / 运行时目录(防御性)
|
||||
if (preg_match('#(^|/)(\.git|runtime|vendor)(/|$)#', $rel)) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
$scan = static function (string $dir, string $prefix) use (&$scan, $root, &$files, $isExcluded): void {
|
||||
if (!is_dir($dir)) {
|
||||
return;
|
||||
}
|
||||
foreach (scandir($dir) as $name) {
|
||||
if ($name === '.' || $name === '..') {
|
||||
continue;
|
||||
}
|
||||
$rel = $prefix === '' ? $name : $prefix . '/' . $name;
|
||||
if ($isExcluded($rel)) {
|
||||
continue;
|
||||
}
|
||||
$abs = $root . DIRECTORY_SEPARATOR . $rel;
|
||||
if (is_dir($abs)) {
|
||||
$scan($abs, $rel);
|
||||
} elseif (is_file($abs)) {
|
||||
$files[] = $rel;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
foreach (FRAMEWORK_DIRS as $d) {
|
||||
$scan($root . DIRECTORY_SEPARATOR . $d, $d);
|
||||
}
|
||||
|
||||
sort($files);
|
||||
return $files;
|
||||
}
|
||||
|
||||
// 作为数组返回:直接 require 本文件即可得到框架所有文件的相对路径数组
|
||||
return frameworkFiles();
|
||||
@@ -0,0 +1,803 @@
|
||||
<?php
|
||||
// 框架文件静态清单(由 scripts/framework_files.php 生成,共 784 个)
|
||||
return [
|
||||
'app/.htaccess',
|
||||
'app/Request.php',
|
||||
'app/api/common.php',
|
||||
'app/api/controller/Index.php',
|
||||
'app/api/controller/v1/Addon.php',
|
||||
'app/api/controller/v1/Article.php',
|
||||
'app/api/controller/v1/Ems.php',
|
||||
'app/api/controller/v1/Example.php',
|
||||
'app/api/controller/v1/Index.php',
|
||||
'app/api/controller/v1/Sms.php',
|
||||
'app/api/controller/v1/User.php',
|
||||
'app/api/controller/v2/Example.php',
|
||||
'app/api/event.php',
|
||||
'app/api/middleware.php',
|
||||
'app/api/route/route.php',
|
||||
'app/api/validate/Login.php',
|
||||
'app/api/validate/Register.php',
|
||||
'app/backend/common.php',
|
||||
'app/backend/config/view.php',
|
||||
'app/backend/controller/Ad.php',
|
||||
'app/backend/controller/AddonMonitor.php',
|
||||
'app/backend/controller/Addons.php',
|
||||
'app/backend/controller/Admin.php',
|
||||
'app/backend/controller/Adminlog.php',
|
||||
'app/backend/controller/Ajax.php',
|
||||
'app/backend/controller/Attachment.php',
|
||||
'app/backend/controller/Card.php',
|
||||
'app/backend/controller/EnsuresPower.php',
|
||||
'app/backend/controller/Configure.php',
|
||||
'app/backend/controller/Console.php',
|
||||
'app/backend/controller/Database.php',
|
||||
'app/backend/controller/Group.php',
|
||||
'app/backend/controller/Help.php',
|
||||
'app/backend/controller/Index.php',
|
||||
'app/backend/controller/Links.php',
|
||||
'app/backend/controller/Login.php',
|
||||
'app/backend/controller/Medal.php',
|
||||
'app/backend/controller/Navbar.php',
|
||||
'app/backend/controller/Notice.php',
|
||||
'app/backend/controller/Other.php',
|
||||
'app/backend/controller/Power.php',
|
||||
'app/backend/controller/Profile.php',
|
||||
'app/backend/controller/Prop.php',
|
||||
'app/backend/controller/Role.php',
|
||||
'app/backend/controller/Rule.php',
|
||||
'app/backend/controller/Score.php',
|
||||
'app/backend/controller/Sms.php',
|
||||
'app/backend/controller/Spider.php',
|
||||
'app/backend/controller/Task.php',
|
||||
'app/backend/controller/Template.php',
|
||||
'app/backend/controller/Upgrade.php',
|
||||
'app/backend/controller/User.php',
|
||||
'app/backend/event.php',
|
||||
'app/backend/event/AdminLog.php',
|
||||
'app/backend/lang/en-us.php',
|
||||
'app/backend/lang/zh-cn.php',
|
||||
'app/backend/lang/zh-cn/addon.php',
|
||||
'app/backend/lang/zh-cn/admin.php',
|
||||
'app/backend/lang/zh-cn/ajax.php',
|
||||
'app/backend/lang/zh-cn/attachment.php',
|
||||
'app/backend/lang/zh-cn/category.php',
|
||||
'app/backend/lang/zh-cn/configure.php',
|
||||
'app/backend/lang/zh-cn/dashboard.php',
|
||||
'app/backend/lang/zh-cn/group.php',
|
||||
'app/backend/lang/zh-cn/index.php',
|
||||
'app/backend/lang/zh-cn/power.php',
|
||||
'app/backend/lang/zh-cn/profile.php',
|
||||
'app/backend/lang/zh-cn/role.php',
|
||||
'app/backend/lang/zh-cn/rule.php',
|
||||
'app/backend/lang/zh-cn/user.php',
|
||||
'app/backend/library/MenuRegistry.php',
|
||||
'app/backend/listener/AdminLog.php',
|
||||
'app/backend/middleware.php',
|
||||
'app/backend/model/NavMenu.php',
|
||||
'app/backend/route/app.php',
|
||||
'app/backend/service/PermissionService.php',
|
||||
'app/backend/validate/Ad.php',
|
||||
'app/backend/validate/Admin.php',
|
||||
'app/backend/validate/AdminPermission.php',
|
||||
'app/backend/validate/AdminProfile.php',
|
||||
'app/backend/validate/AdminRole.php',
|
||||
'app/backend/validate/Card.php',
|
||||
'app/backend/validate/Help.php',
|
||||
'app/backend/validate/Links.php',
|
||||
'app/backend/validate/Login.php',
|
||||
'app/backend/validate/Medal.php',
|
||||
'app/backend/validate/Notice.php',
|
||||
'app/backend/validate/Prop.php',
|
||||
'app/backend/validate/Role.php',
|
||||
'app/backend/validate/Score.php',
|
||||
'app/backend/validate/Sms.php',
|
||||
'app/backend/validate/Task.php',
|
||||
'app/backend/validate/User.php',
|
||||
'app/backend/validate/UserRule.php',
|
||||
'app/backend/view/ad/index.html',
|
||||
'app/backend/view/addon/design.html',
|
||||
'app/backend/view/addon/index.html',
|
||||
'app/backend/view/addon/market.html',
|
||||
'app/backend/view/addon/my.html',
|
||||
'app/backend/view/addon/setting.html',
|
||||
'app/backend/view/addon_monitor/index.html',
|
||||
'app/backend/view/admin/index.html',
|
||||
'app/backend/view/adminlog/index.html',
|
||||
'app/backend/view/attachment/index.html',
|
||||
'app/backend/view/card/index.html',
|
||||
'app/backend/view/common/layout.html',
|
||||
'app/backend/view/configure/index.html',
|
||||
'app/backend/view/console/index.html',
|
||||
'app/backend/view/database/index.html',
|
||||
'app/backend/view/group/index.html',
|
||||
'app/backend/view/help/index.html',
|
||||
'app/backend/view/index/index.html',
|
||||
'app/backend/view/links/index.html',
|
||||
'app/backend/view/login/index.html',
|
||||
'app/backend/view/medal/index.html',
|
||||
'app/backend/view/navbar/index.html',
|
||||
'app/backend/view/notice/index.html',
|
||||
'app/backend/view/other/about.html',
|
||||
'app/backend/view/other/search.html',
|
||||
'app/backend/view/power/index.html',
|
||||
'app/backend/view/profile/avatar.html',
|
||||
'app/backend/view/profile/index.html',
|
||||
'app/backend/view/profile/password.html',
|
||||
'app/backend/view/prop/index.html',
|
||||
'app/backend/view/role/index.html',
|
||||
'app/backend/view/rule/index.html',
|
||||
'app/backend/view/score/index.html',
|
||||
'app/backend/view/score/log.html',
|
||||
'app/backend/view/sms/index.html',
|
||||
'app/backend/view/spider/index.html',
|
||||
'app/backend/view/task/index.html',
|
||||
'app/backend/view/template/index.html',
|
||||
'app/backend/view/template/setting.html',
|
||||
'app/backend/view/upgrade/index.html',
|
||||
'app/backend/view/user/index.html',
|
||||
'app/common.php',
|
||||
'app/event.php',
|
||||
'app/frontend/common.php',
|
||||
'app/frontend/controller/About.php',
|
||||
'app/frontend/controller/Cases.php',
|
||||
'app/frontend/controller/Contact.php',
|
||||
'app/frontend/controller/Doc.php',
|
||||
'app/frontend/controller/Feature.php',
|
||||
'app/frontend/controller/Help.php',
|
||||
'app/frontend/controller/Index.php',
|
||||
'app/frontend/controller/News.php',
|
||||
'app/frontend/controller/Notice.php',
|
||||
'app/frontend/controller/Plugin.php',
|
||||
'app/frontend/controller/Product.php',
|
||||
'app/frontend/controller/Site.php',
|
||||
'app/frontend/event.php',
|
||||
'app/frontend/middleware.php',
|
||||
'app/frontend/route/route.php',
|
||||
'app/frontend/view/about/index.html',
|
||||
'app/frontend/view/case/index.html',
|
||||
'app/frontend/view/common/404.html',
|
||||
'app/frontend/view/common/adslot.html',
|
||||
'app/frontend/view/common/footer.html',
|
||||
'app/frontend/view/common/header.html',
|
||||
'app/frontend/view/common/layout.html',
|
||||
'app/frontend/view/contact/index.html',
|
||||
'app/frontend/view/doc/index.html',
|
||||
'app/frontend/view/feature/detail.html',
|
||||
'app/frontend/view/feature/index.html',
|
||||
'app/frontend/view/help/index.html',
|
||||
'app/frontend/view/help/read.html',
|
||||
'app/frontend/view/index/index.html',
|
||||
'app/frontend/view/news/detail.html',
|
||||
'app/frontend/view/news/index.html',
|
||||
'app/frontend/view/notice/index.html',
|
||||
'app/frontend/view/notice/read.html',
|
||||
'app/frontend/view/plugin/index.html',
|
||||
'app/frontend/view/product/detail.html',
|
||||
'app/frontend/view/product/index.html',
|
||||
'app/frontend/view/site/index.html',
|
||||
'app/member/common.php',
|
||||
'app/member/controller/Ajax.php',
|
||||
'app/member/controller/Article.php',
|
||||
'app/member/controller/Card.php',
|
||||
'app/member/controller/Category.php',
|
||||
'app/member/controller/Index.php',
|
||||
'app/member/controller/Login.php',
|
||||
'app/member/controller/Medal.php',
|
||||
'app/member/controller/Payment.php',
|
||||
'app/member/controller/Profile.php',
|
||||
'app/member/controller/Register.php',
|
||||
'app/member/controller/Settings.php',
|
||||
'app/member/controller/Task.php',
|
||||
'app/member/event.php',
|
||||
'app/member/lang/zh-cn.php',
|
||||
'app/member/lang/zh-cn/profile.php',
|
||||
'app/member/listener/UserLog.php',
|
||||
'app/member/listener/UserMemberActivate.php',
|
||||
'app/member/middleware.php',
|
||||
'app/member/validate/Card.php',
|
||||
'app/member/validate/Login.php',
|
||||
'app/member/validate/Payment.php',
|
||||
'app/member/validate/Profile.php',
|
||||
'app/member/validate/Register.php',
|
||||
'app/member/validate/Settings.php',
|
||||
'app/member/view/card/exchange.html',
|
||||
'app/member/view/card/records.html',
|
||||
'app/member/view/common/layout.html',
|
||||
'app/member/view/index/index.html',
|
||||
'app/member/view/login/index.html',
|
||||
'app/member/view/medal/index.html',
|
||||
'app/member/view/payment/index.html',
|
||||
'app/member/view/profile/avatar.html',
|
||||
'app/member/view/profile/index.html',
|
||||
'app/member/view/profile/password.html',
|
||||
'app/member/view/register/index.html',
|
||||
'app/member/view/settings/index.html',
|
||||
'app/member/view/task/index.html',
|
||||
'app/member/view/task/myprop.html',
|
||||
'app/middleware.php',
|
||||
'app/provider.php',
|
||||
'app/service.php',
|
||||
'config/addon.php',
|
||||
'config/app.php',
|
||||
'config/auth.php',
|
||||
'config/cache.php',
|
||||
'config/captcha.php',
|
||||
'config/console.php',
|
||||
'config/cookie.php',
|
||||
'config/cors.php',
|
||||
'config/filesystem.php',
|
||||
'config/frontend/view.php',
|
||||
'config/jwt.php',
|
||||
'config/lang.php',
|
||||
'config/log.php',
|
||||
'config/mail.php',
|
||||
'config/member.php',
|
||||
'config/middleware.php',
|
||||
'config/pay.php',
|
||||
'config/route.php',
|
||||
'config/session.php',
|
||||
'config/spider.php',
|
||||
'config/storage.php',
|
||||
'config/sync.php',
|
||||
'config/template.php',
|
||||
'config/trace.php',
|
||||
'config/ueditor-plus.php',
|
||||
'config/view.php',
|
||||
'config/worker.php',
|
||||
'extend/.gitignore',
|
||||
'public/.htaccess',
|
||||
'public/.well-known/acme-challenge/BoV6RCcbQ4jq2sZG6st1EhuUII9aDrFcVQvcgztl4Bc',
|
||||
'public/.well-known/acme-challenge/KxaRaudq6k3EcVt5bTN-yScFAVdsrn5mqR5FV9C81L4',
|
||||
'public/.well-known/acme-challenge/XT-55IS4GqOHMEL8qkGE4rO7FKqMU-YP-L4-LftQQ6o',
|
||||
'public/.well-known/acme-challenge/iHoLoujNOUNVGvogod1K0RyoYRtywrDHj2yl3Rv_Z4w',
|
||||
'public/.well-known/acme-challenge/jx9-epYJTQidKEXyRfgG2RwREGVt4xdnqYYzBb8yDuY',
|
||||
'public/assets/layui/css/layui.css',
|
||||
'public/assets/layui/font/iconfont.eot',
|
||||
'public/assets/layui/font/iconfont.svg',
|
||||
'public/assets/layui/font/iconfont.ttf',
|
||||
'public/assets/layui/font/iconfont.woff',
|
||||
'public/assets/layui/font/iconfont.woff2',
|
||||
'public/assets/layui/js/index.js',
|
||||
'public/assets/layui/layui.js',
|
||||
'public/assets/layui3/css/layui.css',
|
||||
'public/assets/layui3/layui.esm.js',
|
||||
'public/assets/layui3/layui.js',
|
||||
'public/assets/library/axios.min.js',
|
||||
'public/assets/library/ueditor/config.json',
|
||||
'public/assets/library/ueditor/dialogs/ai/ai.html',
|
||||
'public/assets/library/ueditor/dialogs/ai/ai.js',
|
||||
'public/assets/library/ueditor/dialogs/anchor/anchor.html',
|
||||
'public/assets/library/ueditor/dialogs/attachment/attachment.css',
|
||||
'public/assets/library/ueditor/dialogs/attachment/attachment.html',
|
||||
'public/assets/library/ueditor/dialogs/attachment/attachment.js',
|
||||
'public/assets/library/ueditor/dialogs/attachment/images/alignicon.gif',
|
||||
'public/assets/library/ueditor/dialogs/attachment/images/alignicon.png',
|
||||
'public/assets/library/ueditor/dialogs/attachment/images/bg.png',
|
||||
'public/assets/library/ueditor/dialogs/attachment/images/file-icons.gif',
|
||||
'public/assets/library/ueditor/dialogs/attachment/images/file-icons.png',
|
||||
'public/assets/library/ueditor/dialogs/attachment/images/icons.gif',
|
||||
'public/assets/library/ueditor/dialogs/attachment/images/icons.png',
|
||||
'public/assets/library/ueditor/dialogs/attachment/images/image.png',
|
||||
'public/assets/library/ueditor/dialogs/attachment/images/progress.png',
|
||||
'public/assets/library/ueditor/dialogs/attachment/images/success.gif',
|
||||
'public/assets/library/ueditor/dialogs/attachment/images/success.png',
|
||||
'public/assets/library/ueditor/dialogs/audio/audio.css',
|
||||
'public/assets/library/ueditor/dialogs/audio/audio.html',
|
||||
'public/assets/library/ueditor/dialogs/audio/audio.js',
|
||||
'public/assets/library/ueditor/dialogs/audio/images/bg.png',
|
||||
'public/assets/library/ueditor/dialogs/audio/images/center_focus.jpg',
|
||||
'public/assets/library/ueditor/dialogs/audio/images/file-icons.gif',
|
||||
'public/assets/library/ueditor/dialogs/audio/images/file-icons.png',
|
||||
'public/assets/library/ueditor/dialogs/audio/images/icons.gif',
|
||||
'public/assets/library/ueditor/dialogs/audio/images/icons.png',
|
||||
'public/assets/library/ueditor/dialogs/audio/images/image.png',
|
||||
'public/assets/library/ueditor/dialogs/audio/images/left_focus.jpg',
|
||||
'public/assets/library/ueditor/dialogs/audio/images/none_focus.jpg',
|
||||
'public/assets/library/ueditor/dialogs/audio/images/progress.png',
|
||||
'public/assets/library/ueditor/dialogs/audio/images/right_focus.jpg',
|
||||
'public/assets/library/ueditor/dialogs/audio/images/success.gif',
|
||||
'public/assets/library/ueditor/dialogs/audio/images/success.png',
|
||||
'public/assets/library/ueditor/dialogs/background/background.css',
|
||||
'public/assets/library/ueditor/dialogs/background/background.html',
|
||||
'public/assets/library/ueditor/dialogs/background/background.js',
|
||||
'public/assets/library/ueditor/dialogs/background/images/bg.png',
|
||||
'public/assets/library/ueditor/dialogs/background/images/success.png',
|
||||
'public/assets/library/ueditor/dialogs/contentimport/contentimport.html',
|
||||
'public/assets/library/ueditor/dialogs/contentimport/contentimport.js',
|
||||
'public/assets/library/ueditor/dialogs/contentimport/mammoth.browser.min.js',
|
||||
'public/assets/library/ueditor/dialogs/contentimport/showdown.min.js',
|
||||
'public/assets/library/ueditor/dialogs/emotion/emotion.css',
|
||||
'public/assets/library/ueditor/dialogs/emotion/emotion.html',
|
||||
'public/assets/library/ueditor/dialogs/emotion/emotion.js',
|
||||
'public/assets/library/ueditor/dialogs/emotion/images/0.gif',
|
||||
'public/assets/library/ueditor/dialogs/emotion/images/bface.gif',
|
||||
'public/assets/library/ueditor/dialogs/emotion/images/cface.gif',
|
||||
'public/assets/library/ueditor/dialogs/emotion/images/fface.gif',
|
||||
'public/assets/library/ueditor/dialogs/emotion/images/jxface2.gif',
|
||||
'public/assets/library/ueditor/dialogs/emotion/images/neweditor-tab-bg.png',
|
||||
'public/assets/library/ueditor/dialogs/emotion/images/tface.gif',
|
||||
'public/assets/library/ueditor/dialogs/emotion/images/wface.gif',
|
||||
'public/assets/library/ueditor/dialogs/emotion/images/yface.gif',
|
||||
'public/assets/library/ueditor/dialogs/formula/formula.html',
|
||||
'public/assets/library/ueditor/dialogs/formula/formula.js',
|
||||
'public/assets/library/ueditor/dialogs/help/help.css',
|
||||
'public/assets/library/ueditor/dialogs/help/help.html',
|
||||
'public/assets/library/ueditor/dialogs/help/help.js',
|
||||
'public/assets/library/ueditor/dialogs/image/image.css',
|
||||
'public/assets/library/ueditor/dialogs/image/image.html',
|
||||
'public/assets/library/ueditor/dialogs/image/image.js',
|
||||
'public/assets/library/ueditor/dialogs/image/images/alignicon.jpg',
|
||||
'public/assets/library/ueditor/dialogs/image/images/bg.png',
|
||||
'public/assets/library/ueditor/dialogs/image/images/icons.gif',
|
||||
'public/assets/library/ueditor/dialogs/image/images/icons.png',
|
||||
'public/assets/library/ueditor/dialogs/image/images/image.png',
|
||||
'public/assets/library/ueditor/dialogs/image/images/progress.png',
|
||||
'public/assets/library/ueditor/dialogs/image/images/success.gif',
|
||||
'public/assets/library/ueditor/dialogs/image/images/success.png',
|
||||
'public/assets/library/ueditor/dialogs/insertframe/insertframe.html',
|
||||
'public/assets/library/ueditor/dialogs/internal.js',
|
||||
'public/assets/library/ueditor/dialogs/link/link.html',
|
||||
'public/assets/library/ueditor/dialogs/preview/preview.html',
|
||||
'public/assets/library/ueditor/dialogs/scrawl/images/addimg.png',
|
||||
'public/assets/library/ueditor/dialogs/scrawl/images/brush.png',
|
||||
'public/assets/library/ueditor/dialogs/scrawl/images/delimg.png',
|
||||
'public/assets/library/ueditor/dialogs/scrawl/images/delimgH.png',
|
||||
'public/assets/library/ueditor/dialogs/scrawl/images/empty.png',
|
||||
'public/assets/library/ueditor/dialogs/scrawl/images/emptyH.png',
|
||||
'public/assets/library/ueditor/dialogs/scrawl/images/eraser.png',
|
||||
'public/assets/library/ueditor/dialogs/scrawl/images/redo.png',
|
||||
'public/assets/library/ueditor/dialogs/scrawl/images/redoH.png',
|
||||
'public/assets/library/ueditor/dialogs/scrawl/images/scale.png',
|
||||
'public/assets/library/ueditor/dialogs/scrawl/images/scaleH.png',
|
||||
'public/assets/library/ueditor/dialogs/scrawl/images/size.png',
|
||||
'public/assets/library/ueditor/dialogs/scrawl/images/undo.png',
|
||||
'public/assets/library/ueditor/dialogs/scrawl/images/undoH.png',
|
||||
'public/assets/library/ueditor/dialogs/scrawl/scrawl.css',
|
||||
'public/assets/library/ueditor/dialogs/scrawl/scrawl.html',
|
||||
'public/assets/library/ueditor/dialogs/scrawl/scrawl.js',
|
||||
'public/assets/library/ueditor/dialogs/searchreplace/searchreplace.html',
|
||||
'public/assets/library/ueditor/dialogs/searchreplace/searchreplace.js',
|
||||
'public/assets/library/ueditor/dialogs/spechars/spechars.html',
|
||||
'public/assets/library/ueditor/dialogs/spechars/spechars.js',
|
||||
'public/assets/library/ueditor/dialogs/table/dragicon.png',
|
||||
'public/assets/library/ueditor/dialogs/table/edittable.css',
|
||||
'public/assets/library/ueditor/dialogs/table/edittable.html',
|
||||
'public/assets/library/ueditor/dialogs/table/edittable.js',
|
||||
'public/assets/library/ueditor/dialogs/table/edittd.html',
|
||||
'public/assets/library/ueditor/dialogs/table/edittip.html',
|
||||
'public/assets/library/ueditor/dialogs/template/config.js',
|
||||
'public/assets/library/ueditor/dialogs/template/images/bg.gif',
|
||||
'public/assets/library/ueditor/dialogs/template/images/pre0.png',
|
||||
'public/assets/library/ueditor/dialogs/template/images/pre1.png',
|
||||
'public/assets/library/ueditor/dialogs/template/images/pre2.png',
|
||||
'public/assets/library/ueditor/dialogs/template/images/pre3.png',
|
||||
'public/assets/library/ueditor/dialogs/template/images/pre4.png',
|
||||
'public/assets/library/ueditor/dialogs/template/template.css',
|
||||
'public/assets/library/ueditor/dialogs/template/template.html',
|
||||
'public/assets/library/ueditor/dialogs/template/template.js',
|
||||
'public/assets/library/ueditor/dialogs/video/images/bg.png',
|
||||
'public/assets/library/ueditor/dialogs/video/images/center_focus.jpg',
|
||||
'public/assets/library/ueditor/dialogs/video/images/file-icons.gif',
|
||||
'public/assets/library/ueditor/dialogs/video/images/file-icons.png',
|
||||
'public/assets/library/ueditor/dialogs/video/images/icons.gif',
|
||||
'public/assets/library/ueditor/dialogs/video/images/icons.png',
|
||||
'public/assets/library/ueditor/dialogs/video/images/image.png',
|
||||
'public/assets/library/ueditor/dialogs/video/images/left_focus.jpg',
|
||||
'public/assets/library/ueditor/dialogs/video/images/none_focus.jpg',
|
||||
'public/assets/library/ueditor/dialogs/video/images/progress.png',
|
||||
'public/assets/library/ueditor/dialogs/video/images/right_focus.jpg',
|
||||
'public/assets/library/ueditor/dialogs/video/images/success.gif',
|
||||
'public/assets/library/ueditor/dialogs/video/images/success.png',
|
||||
'public/assets/library/ueditor/dialogs/video/video.css',
|
||||
'public/assets/library/ueditor/dialogs/video/video.html',
|
||||
'public/assets/library/ueditor/dialogs/video/video.js',
|
||||
'public/assets/library/ueditor/dialogs/wordimage/wordimage.html',
|
||||
'public/assets/library/ueditor/dialogs/wordimage/wordimage.js',
|
||||
'public/assets/library/ueditor/index.html',
|
||||
'public/assets/library/ueditor/lang/en/en.js',
|
||||
'public/assets/library/ueditor/lang/en/images/addimage.png',
|
||||
'public/assets/library/ueditor/lang/en/images/alldeletebtnhoverskin.png',
|
||||
'public/assets/library/ueditor/lang/en/images/alldeletebtnupskin.png',
|
||||
'public/assets/library/ueditor/lang/en/images/background.png',
|
||||
'public/assets/library/ueditor/lang/en/images/button.png',
|
||||
'public/assets/library/ueditor/lang/en/images/copy.png',
|
||||
'public/assets/library/ueditor/lang/en/images/deletedisable.png',
|
||||
'public/assets/library/ueditor/lang/en/images/deleteenable.png',
|
||||
'public/assets/library/ueditor/lang/en/images/listbackground.png',
|
||||
'public/assets/library/ueditor/lang/en/images/localimage.png',
|
||||
'public/assets/library/ueditor/lang/en/images/music.png',
|
||||
'public/assets/library/ueditor/lang/en/images/rotateleftdisable.png',
|
||||
'public/assets/library/ueditor/lang/en/images/rotateleftenable.png',
|
||||
'public/assets/library/ueditor/lang/en/images/rotaterightdisable.png',
|
||||
'public/assets/library/ueditor/lang/en/images/rotaterightenable.png',
|
||||
'public/assets/library/ueditor/lang/en/images/upload.png',
|
||||
'public/assets/library/ueditor/lang/zh-cn/images/copy.png',
|
||||
'public/assets/library/ueditor/lang/zh-cn/images/localimage.png',
|
||||
'public/assets/library/ueditor/lang/zh-cn/images/music.png',
|
||||
'public/assets/library/ueditor/lang/zh-cn/images/upload.png',
|
||||
'public/assets/library/ueditor/lang/zh-cn/zh-cn.js',
|
||||
'public/assets/library/ueditor/plugins/demo/demo.js',
|
||||
'public/assets/library/ueditor/themes/default/css/ueditor.css',
|
||||
'public/assets/library/ueditor/themes/default/dialog.css',
|
||||
'public/assets/library/ueditor/themes/default/dialogbase.css',
|
||||
'public/assets/library/ueditor/themes/default/exts/ai.svg',
|
||||
'public/assets/library/ueditor/themes/default/exts/apk.svg',
|
||||
'public/assets/library/ueditor/themes/default/exts/chm.svg',
|
||||
'public/assets/library/ueditor/themes/default/exts/css.svg',
|
||||
'public/assets/library/ueditor/themes/default/exts/doc.svg',
|
||||
'public/assets/library/ueditor/themes/default/exts/docx.svg',
|
||||
'public/assets/library/ueditor/themes/default/exts/dwg.svg',
|
||||
'public/assets/library/ueditor/themes/default/exts/folder.svg',
|
||||
'public/assets/library/ueditor/themes/default/exts/gif.svg',
|
||||
'public/assets/library/ueditor/themes/default/exts/html.svg',
|
||||
'public/assets/library/ueditor/themes/default/exts/jpeg.svg',
|
||||
'public/assets/library/ueditor/themes/default/exts/jpg.svg',
|
||||
'public/assets/library/ueditor/themes/default/exts/log.svg',
|
||||
'public/assets/library/ueditor/themes/default/exts/mp3.svg',
|
||||
'public/assets/library/ueditor/themes/default/exts/mp4.svg',
|
||||
'public/assets/library/ueditor/themes/default/exts/pdf.svg',
|
||||
'public/assets/library/ueditor/themes/default/exts/png.svg',
|
||||
'public/assets/library/ueditor/themes/default/exts/ppt.svg',
|
||||
'public/assets/library/ueditor/themes/default/exts/pptx.svg',
|
||||
'public/assets/library/ueditor/themes/default/exts/psd.svg',
|
||||
'public/assets/library/ueditor/themes/default/exts/rar.svg',
|
||||
'public/assets/library/ueditor/themes/default/exts/svg.svg',
|
||||
'public/assets/library/ueditor/themes/default/exts/torrent.svg',
|
||||
'public/assets/library/ueditor/themes/default/exts/txt.svg',
|
||||
'public/assets/library/ueditor/themes/default/exts/unknown.svg',
|
||||
'public/assets/library/ueditor/themes/default/exts/xls.svg',
|
||||
'public/assets/library/ueditor/themes/default/exts/xlsx.svg',
|
||||
'public/assets/library/ueditor/themes/default/exts/zip.svg',
|
||||
'public/assets/library/ueditor/themes/default/images/anchor.gif',
|
||||
'public/assets/library/ueditor/themes/default/images/arrow.png',
|
||||
'public/assets/library/ueditor/themes/default/images/arrow_down.png',
|
||||
'public/assets/library/ueditor/themes/default/images/arrow_up.png',
|
||||
'public/assets/library/ueditor/themes/default/images/button-bg.gif',
|
||||
'public/assets/library/ueditor/themes/default/images/cancelbutton.gif',
|
||||
'public/assets/library/ueditor/themes/default/images/charts.png',
|
||||
'public/assets/library/ueditor/themes/default/images/cursor_h.gif',
|
||||
'public/assets/library/ueditor/themes/default/images/cursor_h.png',
|
||||
'public/assets/library/ueditor/themes/default/images/cursor_v.gif',
|
||||
'public/assets/library/ueditor/themes/default/images/cursor_v.png',
|
||||
'public/assets/library/ueditor/themes/default/images/dialog-title-bg.png',
|
||||
'public/assets/library/ueditor/themes/default/images/filescan.png',
|
||||
'public/assets/library/ueditor/themes/default/images/highlighted.gif',
|
||||
'public/assets/library/ueditor/themes/default/images/icons-all.gif',
|
||||
'public/assets/library/ueditor/themes/default/images/icons.gif',
|
||||
'public/assets/library/ueditor/themes/default/images/icons.png',
|
||||
'public/assets/library/ueditor/themes/default/images/img-cracked.png',
|
||||
'public/assets/library/ueditor/themes/default/images/loaderror.png',
|
||||
'public/assets/library/ueditor/themes/default/images/loading.gif',
|
||||
'public/assets/library/ueditor/themes/default/images/lock.gif',
|
||||
'public/assets/library/ueditor/themes/default/images/neweditor-tab-bg.png',
|
||||
'public/assets/library/ueditor/themes/default/images/pagebreak.gif',
|
||||
'public/assets/library/ueditor/themes/default/images/scale.png',
|
||||
'public/assets/library/ueditor/themes/default/images/sortable.png',
|
||||
'public/assets/library/ueditor/themes/default/images/spacer.gif',
|
||||
'public/assets/library/ueditor/themes/default/images/sparator_v.png',
|
||||
'public/assets/library/ueditor/themes/default/images/table-cell-align.png',
|
||||
'public/assets/library/ueditor/themes/default/images/tangram-colorpicker.png',
|
||||
'public/assets/library/ueditor/themes/default/images/toolbar_bg.png',
|
||||
'public/assets/library/ueditor/themes/default/images/unhighlighted.gif',
|
||||
'public/assets/library/ueditor/themes/default/images/upload.png',
|
||||
'public/assets/library/ueditor/themes/default/images/videologo.gif',
|
||||
'public/assets/library/ueditor/themes/default/images/word.gif',
|
||||
'public/assets/library/ueditor/themes/default/images/wordpaste.png',
|
||||
'public/assets/library/ueditor/themes/iframe.css',
|
||||
'public/assets/library/ueditor/third-party/SyntaxHighlighter/shCore.js',
|
||||
'public/assets/library/ueditor/third-party/SyntaxHighlighter/shCoreDefault.css',
|
||||
'public/assets/library/ueditor/third-party/clipboard/clipboard.js',
|
||||
'public/assets/library/ueditor/third-party/clipboard/clipboard.swf',
|
||||
'public/assets/library/ueditor/third-party/codemirror/codemirror.css',
|
||||
'public/assets/library/ueditor/third-party/codemirror/codemirror.js',
|
||||
'public/assets/library/ueditor/third-party/jquery-1.10.2.js',
|
||||
'public/assets/library/ueditor/third-party/showdown.js',
|
||||
'public/assets/library/ueditor/third-party/vue.js',
|
||||
'public/assets/library/ueditor/third-party/webuploader/webuploader.css',
|
||||
'public/assets/library/ueditor/third-party/webuploader/webuploader.js',
|
||||
'public/assets/library/ueditor/third-party/zeroclipboard/ZeroClipboard.js',
|
||||
'public/assets/library/ueditor/third-party/zeroclipboard/ZeroClipboard.swf',
|
||||
'public/assets/library/ueditor/ueditor.all.js',
|
||||
'public/assets/library/ueditor/ueditor.config.js',
|
||||
'public/assets/library/ueditor/ueditor.parse.js',
|
||||
'public/assets/ywxapp/config.js',
|
||||
'public/assets/ywxapp/css/template.css',
|
||||
'public/assets/ywxapp/css/ywxapp.css',
|
||||
'public/assets/ywxapp/modules/JsonCrypto.js',
|
||||
'public/assets/ywxapp/modules/auth.js',
|
||||
'public/assets/ywxapp/modules/chat-client.js',
|
||||
'public/assets/ywxapp/modules/common.js',
|
||||
'public/assets/ywxapp/modules/cropper/cropper.css',
|
||||
'public/assets/ywxapp/modules/cropper/cropper.js',
|
||||
'public/assets/ywxapp/modules/cropper/croppers.js',
|
||||
'public/assets/ywxapp/modules/echarts.js',
|
||||
'public/assets/ywxapp/modules/echartsTheme.js',
|
||||
'public/assets/ywxapp/modules/http.js',
|
||||
'public/assets/ywxapp/modules/iconPicker.js',
|
||||
'public/assets/ywxapp/modules/layim/forum.js',
|
||||
'public/assets/ywxapp/modules/layim/layim.js',
|
||||
'public/assets/ywxapp/modules/layim/res/images/default.png',
|
||||
'public/assets/ywxapp/modules/layim/res/layim.css',
|
||||
'public/assets/ywxapp/modules/layim/res/voice/default.mp3',
|
||||
'public/assets/ywxapp/modules/route.js',
|
||||
'public/assets/ywxapp/modules/ueditor.js',
|
||||
'public/assets/ywxapp/modules/view.js',
|
||||
'public/assets/ywxapp/modules/wxedit.js',
|
||||
'public/assets/ywxapp/modules/ywxapp.js',
|
||||
'public/assets/ywxapp/ywxapp.js',
|
||||
'public/favicon.ico',
|
||||
'public/index.html',
|
||||
'public/index.php',
|
||||
'public/install/Installer.php',
|
||||
'public/install/index.html',
|
||||
'public/install/index.php',
|
||||
'public/install/install.sql',
|
||||
'public/robots.txt',
|
||||
'public/router.php',
|
||||
'public/static/backend/images/bg.jpg',
|
||||
'public/static/backend/images/captcha.jpg',
|
||||
'public/static/backend/images/favicon.ico',
|
||||
'public/static/backend/images/home.png',
|
||||
'public/static/backend/images/icon-login.png',
|
||||
'public/static/backend/images/loginbg.png',
|
||||
'public/static/backend/images/loginbgtk.png',
|
||||
'public/static/backend/images/logo.png',
|
||||
'public/static/backend/json/console/prograss.js',
|
||||
'public/static/backend/modules/ad.js',
|
||||
'public/static/backend/modules/addon.js',
|
||||
'public/static/backend/modules/admin.js',
|
||||
'public/static/backend/modules/adminlog.js',
|
||||
'public/static/backend/modules/appmarket.js',
|
||||
'public/static/backend/modules/attachment.js',
|
||||
'public/static/backend/modules/card.js',
|
||||
'public/static/backend/modules/category.js',
|
||||
'public/static/backend/modules/configure.js',
|
||||
'public/static/backend/modules/console.js',
|
||||
'public/static/backend/modules/database.js',
|
||||
'public/static/backend/modules/group.js',
|
||||
'public/static/backend/modules/help.js',
|
||||
'public/static/backend/modules/index.js',
|
||||
'public/static/backend/modules/links.js',
|
||||
'public/static/backend/modules/login.js',
|
||||
'public/static/backend/modules/medal.js',
|
||||
'public/static/backend/modules/navbar.js',
|
||||
'public/static/backend/modules/notice.js',
|
||||
'public/static/backend/modules/power.js',
|
||||
'public/static/backend/modules/prop.js',
|
||||
'public/static/backend/modules/role.js',
|
||||
'public/static/backend/modules/rule.js',
|
||||
'public/static/backend/modules/score.js',
|
||||
'public/static/backend/modules/sms.js',
|
||||
'public/static/backend/modules/spider.js',
|
||||
'public/static/backend/modules/task.js',
|
||||
'public/static/backend/modules/user.js',
|
||||
'public/static/common/images/avatar.jpg',
|
||||
'public/static/common/img/xixingwl.png',
|
||||
'public/static/frontend/css/common.css',
|
||||
'public/static/frontend/css/frontend.css',
|
||||
'public/static/frontend/css/index.css',
|
||||
'public/static/frontend/css/responsive.css',
|
||||
'public/static/frontend/css/style.css',
|
||||
'public/static/frontend/css/template.css',
|
||||
'public/static/frontend/img/Big_icon1.png',
|
||||
'public/static/frontend/img/Big_icon2.png',
|
||||
'public/static/frontend/img/Big_icon3.png',
|
||||
'public/static/frontend/img/Big_icon4.png',
|
||||
'public/static/frontend/img/Product_img1.jpg',
|
||||
'public/static/frontend/img/Product_img2.jpg',
|
||||
'public/static/frontend/img/Product_img3.jpg',
|
||||
'public/static/frontend/img/Product_img4.jpg',
|
||||
'public/static/frontend/img/banner1.jpg',
|
||||
'public/static/frontend/img/banner2.jpg',
|
||||
'public/static/frontend/img/case1.jpg',
|
||||
'public/static/frontend/img/case2.jpg',
|
||||
'public/static/frontend/img/case3.jpg',
|
||||
'public/static/frontend/img/case4.jpg',
|
||||
'public/static/frontend/img/case5.jpg',
|
||||
'public/static/frontend/img/case6.jpg',
|
||||
'public/static/frontend/img/case7.jpg',
|
||||
'public/static/frontend/img/case8.jpg',
|
||||
'public/static/frontend/img/case9.jpg',
|
||||
'public/static/frontend/img/erweima.jpg',
|
||||
'public/static/frontend/img/home_img1.jpg',
|
||||
'public/static/frontend/img/home_img2.jpg',
|
||||
'public/static/frontend/img/home_img3.jpg',
|
||||
'public/static/frontend/img/home_img4.jpg',
|
||||
'public/static/frontend/img/logo.png',
|
||||
'public/static/frontend/img/nav_img1.jpg',
|
||||
'public/static/frontend/img/nav_img2.jpg',
|
||||
'public/static/frontend/img/nav_img3.jpg',
|
||||
'public/static/frontend/img/nav_img4.jpg',
|
||||
'public/static/frontend/img/news_big.jpg',
|
||||
'public/static/frontend/img/news_img1.jpg',
|
||||
'public/static/frontend/img/us_img1.jpg',
|
||||
'public/static/frontend/img/us_img2.jpg',
|
||||
'public/static/frontend/img/us_img3.jpg',
|
||||
'public/static/frontend/img/us_img4.png',
|
||||
'public/static/frontend/img/us_img5.png',
|
||||
'public/static/frontend/img/us_img6.png',
|
||||
'public/static/frontend/img/us_img7.png',
|
||||
'public/static/frontend/img/us_img8.png',
|
||||
'public/static/frontend/js/firm.js',
|
||||
'public/static/frontend/js/main.js',
|
||||
'public/static/frontend/modules/index.js',
|
||||
'public/static/frontend/modules/login.js',
|
||||
'public/static/member/css/common.css',
|
||||
'public/static/member/css/login.css',
|
||||
'public/static/member/css/template.css',
|
||||
'public/static/member/imgs/template/character.jpg',
|
||||
'public/static/member/imgs/template/huge.jpg',
|
||||
'public/static/member/imgs/template/layui-logo.jpg',
|
||||
'public/static/member/imgs/template/portrait.png',
|
||||
'public/static/member/json/console/prograss.json',
|
||||
'public/static/member/json/console/top-card.json',
|
||||
'public/static/member/json/console/top-search.json',
|
||||
'public/static/member/json/content/comment.js',
|
||||
'public/static/member/json/content/list.js',
|
||||
'public/static/member/json/content/tags.js',
|
||||
'public/static/member/json/forum/list.js',
|
||||
'public/static/member/json/forum/replys.js',
|
||||
'public/static/member/json/layim/getList.json',
|
||||
'public/static/member/json/layim/getMembers.json',
|
||||
'public/static/member/json/message/all.js',
|
||||
'public/static/member/json/message/direct.js',
|
||||
'public/static/member/json/message/notice.js',
|
||||
'public/static/member/json/workorder/demo.js',
|
||||
'public/static/member/modules/index.js',
|
||||
'public/static/member/modules/login.js',
|
||||
'public/static/member/modules/register.js',
|
||||
'public/static/member/views/console/index.html',
|
||||
'public/static/templates/myblog/theme.css',
|
||||
'public/storage/avatar/20260716/200405_6a58c8b517e0e.jpg',
|
||||
'route/app.php',
|
||||
'ywxapp/AddonBase.php',
|
||||
'ywxapp/command/AddonCrud.php',
|
||||
'ywxapp/command/AddonHealth.php',
|
||||
'ywxapp/command/AttachmentCleanCommand.php',
|
||||
'ywxapp/command/DbBackupCommand.php',
|
||||
'ywxapp/command/AddonLicenseCheck.php',
|
||||
'ywxapp/command/AddonMake.php',
|
||||
'ywxapp/command/AddonManage.php',
|
||||
'ywxapp/command/AddonRepair.php',
|
||||
'ywxapp/command/AppCommand.php',
|
||||
'ywxapp/command/GatewayWorker.php',
|
||||
'ywxapp/command/Upgrade.php',
|
||||
'ywxapp/command/addon/.md',
|
||||
'ywxapp/command/addon/addon.stub',
|
||||
'ywxapp/command/addon/command.stub',
|
||||
'ywxapp/command/addon/controller.api.stub',
|
||||
'ywxapp/command/addon/controller.plain.stub',
|
||||
'ywxapp/command/addon/controller.stub',
|
||||
'ywxapp/command/addon/event.stub',
|
||||
'ywxapp/command/addon/info.stub',
|
||||
'ywxapp/command/addon/listener.stub',
|
||||
'ywxapp/command/addon/middleware.stub',
|
||||
'ywxapp/command/addon/model.stub',
|
||||
'ywxapp/command/addon/service.stub',
|
||||
'ywxapp/command/addon/subscribe.stub',
|
||||
'ywxapp/command/addon/validate.stub',
|
||||
'ywxapp/command/crud/controller.stub',
|
||||
'ywxapp/command/crud/js.stub',
|
||||
'ywxapp/command/crud/model.stub',
|
||||
'ywxapp/command/crud/view.index.stub',
|
||||
'ywxapp/command/app/addon.stub',
|
||||
'ywxapp/command/app/app.stub',
|
||||
'ywxapp/command/app/command.stub',
|
||||
'ywxapp/command/app/controller.api.stub',
|
||||
'ywxapp/command/app/controller.plain.stub',
|
||||
'ywxapp/command/app/controller.stub',
|
||||
'ywxapp/command/app/event.stub',
|
||||
'ywxapp/command/app/listener.stub',
|
||||
'ywxapp/command/app/middleware.stub',
|
||||
'ywxapp/command/app/model.stub',
|
||||
'ywxapp/command/app/service.stub',
|
||||
'ywxapp/command/app/subscribe.stub',
|
||||
'ywxapp/command/app/validate.stub',
|
||||
'ywxapp/command/fullchain.pem',
|
||||
'ywxapp/command/privkey.pem',
|
||||
'ywxapp/controller/Addon.php',
|
||||
'ywxapp/controller/ApiController.php',
|
||||
'ywxapp/controller/BackendBase.php',
|
||||
'ywxapp/controller/BaseController.php',
|
||||
'ywxapp/controller/FrontendBase.php',
|
||||
'ywxapp/controller/MemberBase.php',
|
||||
'ywxapp/controller/UeditorPlus.php',
|
||||
'ywxapp/controller/UploadController.php',
|
||||
'ywxapp/event/Email.php',
|
||||
'ywxapp/exception/AddonException.php',
|
||||
'ywxapp/exception/AdminException.php',
|
||||
'ywxapp/exception/ApiException.php',
|
||||
'ywxapp/exception/ApiStatusException.php',
|
||||
'ywxapp/exception/AuthException.php',
|
||||
'ywxapp/exception/CrudException.php',
|
||||
'ywxapp/exception/PayException.php',
|
||||
'ywxapp/handler/ExceptionHandle.php',
|
||||
'ywxapp/helper.php',
|
||||
'ywxapp/lang/en-us.php',
|
||||
'ywxapp/lang/zh-cn.php',
|
||||
'ywxapp/lang/zh-cn/addon.php',
|
||||
'ywxapp/library/AdminAuth.php',
|
||||
'ywxapp/library/AdminLogger.php',
|
||||
'ywxapp/library/Auth.php',
|
||||
'ywxapp/library/DbBackup.php',
|
||||
'ywxapp/library/Email.php',
|
||||
'ywxapp/library/Mailer.php',
|
||||
'ywxapp/library/Menu.php',
|
||||
'ywxapp/library/Result.php',
|
||||
'ywxapp/library/Search/EsSearch.php',
|
||||
'ywxapp/library/SkinOverlay.php',
|
||||
'ywxapp/library/SkinVariables.php',
|
||||
'ywxapp/library/Sms.php',
|
||||
'ywxapp/library/SpiderDetect.php',
|
||||
'ywxapp/library/SpiderVerify.php',
|
||||
'ywxapp/library/StreamZipResponse.php',
|
||||
'ywxapp/library/TemplateInstaller.php',
|
||||
'ywxapp/library/TemplateManager.php',
|
||||
'ywxapp/library/TemplateResolver.php',
|
||||
'ywxapp/middleware/AddonHotReloadMiddleware.php',
|
||||
'ywxapp/middleware/AddonPerformanceMiddleware.php',
|
||||
'ywxapp/middleware/AllowCrossDomain.php',
|
||||
'ywxapp/middleware/MultiApp.php',
|
||||
'ywxapp/middleware/SpiderStat.php',
|
||||
'ywxapp/model/Ad.php',
|
||||
'ywxapp/model/AddonModel.php',
|
||||
'ywxapp/model/Attachment.php',
|
||||
'ywxapp/model/BackendAdmin.php',
|
||||
'ywxapp/model/BackendLog.php',
|
||||
'ywxapp/model/BackendPower.php',
|
||||
'ywxapp/model/BackendRole.php',
|
||||
'ywxapp/model/BackendRoleAccess.php',
|
||||
'ywxapp/model/BackendRolePower.php',
|
||||
'ywxapp/model/BaseModel.php',
|
||||
'ywxapp/model/Card.php',
|
||||
'ywxapp/model/Chat.php',
|
||||
'ywxapp/model/ChatMessage.php',
|
||||
'ywxapp/model/Configure.php',
|
||||
'ywxapp/model/Email.php',
|
||||
'ywxapp/model/Help.php',
|
||||
'ywxapp/model/Links.php',
|
||||
'ywxapp/model/Medal.php',
|
||||
'ywxapp/model/MemberGroup.php',
|
||||
'ywxapp/model/MemberGroupAccess.php',
|
||||
'ywxapp/model/MemberGroupRule.php',
|
||||
'ywxapp/model/MemberLog.php',
|
||||
'ywxapp/model/MemberProfile.php',
|
||||
'ywxapp/model/MemberRule.php',
|
||||
'ywxapp/model/MemberUser.php',
|
||||
'ywxapp/model/Notice.php',
|
||||
'ywxapp/model/Prop.php',
|
||||
'ywxapp/model/Score.php',
|
||||
'ywxapp/model/ScoreLog.php',
|
||||
'ywxapp/model/Sms.php',
|
||||
'ywxapp/model/Task.php',
|
||||
'ywxapp/service/AddonDevService.php',
|
||||
'ywxapp/service/AddonHotReload.php',
|
||||
'ywxapp/service/AddonPerformanceMonitor.php',
|
||||
'ywxapp/service/AddonService.php',
|
||||
'ywxapp/service/AppService.php',
|
||||
'ywxapp/service/CaptchaService.php',
|
||||
'ywxapp/service/FileStorageService.php',
|
||||
'ywxapp/service/FrameworkService.php',
|
||||
'ywxapp/service/JwtService.php',
|
||||
'ywxapp/service/RemoteService.php',
|
||||
'ywxapp/service/UEditorService.php',
|
||||
'ywxapp/service/WalletService.php',
|
||||
'ywxapp/utils/AesUtil.php',
|
||||
'ywxapp/utils/Date.php',
|
||||
'ywxapp/utils/HttpClient.php',
|
||||
'ywxapp/utils/JsonCrypto.php',
|
||||
'ywxapp/utils/Pinyin.php',
|
||||
'ywxapp/utils/Random.php',
|
||||
'ywxapp/utils/Rsa.php',
|
||||
'ywxapp/utils/SignUtil.php',
|
||||
'ywxapp/utils/StatusCode.php',
|
||||
'ywxapp/utils/TreeBuilder.php',
|
||||
'ywxapp/utils/UrlBuild.php',
|
||||
'ywxapp/utils/storage/AliOssStorage.php',
|
||||
'ywxapp/utils/storage/LocalStorage.php',
|
||||
'ywxapp/utils/storage/StorageInterface.php',
|
||||
'ywxapp/view/401.html',
|
||||
'ywxapp/view/error.html',
|
||||
'ywxapp/view/success.html',
|
||||
];
|
||||
@@ -0,0 +1,97 @@
|
||||
<?php
|
||||
/**
|
||||
* 发布包签名公共库(供 scripts/ 下的打包脚本复用)
|
||||
*
|
||||
* 为什么单独抽一层:打包脚本是无框架的独立 CLI,而验签服务 ywxapp\service\PackageVerifier
|
||||
* 需要被运行期代码复用;两者算法的任何差异都会导致「打得出来、装不进去」,
|
||||
* 故由本库薄薄一层接过去,保证签名/验签永远同一份实现。
|
||||
*
|
||||
* 依赖:仅 openssl 扩展 + ZipArchive,不需要启动框架。
|
||||
*
|
||||
* @package scripts
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once dirname(__DIR__, 2) . DIRECTORY_SEPARATOR . 'ywxapp' . DIRECTORY_SEPARATOR
|
||||
. 'service' . DIRECTORY_SEPARATOR . 'PackageVerifier.php';
|
||||
|
||||
use ywxapp\service\PackageVerifier;
|
||||
|
||||
/**
|
||||
* 定位打包私钥(按优先级):
|
||||
* 1) 环境变量 PACKAGE_SIGN_KEY(私钥文件路径)
|
||||
* 2) runtime/package_private.pem
|
||||
* 3) build/package_private.pem
|
||||
*
|
||||
* 私钥永远不入库:.gitignore 已覆盖 /runtime 与 /build。
|
||||
*
|
||||
* @param string $root 项目根
|
||||
* @return string 私钥文件路径;不存在时返回 ''
|
||||
*/
|
||||
function pkResolvePrivateKey(string $root): string
|
||||
{
|
||||
$env = trim((string) (getenv('PACKAGE_SIGN_KEY') ?: ''));
|
||||
if ($env !== '' && is_file($env)) {
|
||||
return $env;
|
||||
}
|
||||
foreach ([
|
||||
$root . 'runtime' . DIRECTORY_SEPARATOR . 'package_private.pem',
|
||||
$root . 'build' . DIRECTORY_SEPARATOR . 'package_private.pem',
|
||||
] as $candidate) {
|
||||
if (is_file($candidate)) {
|
||||
return $candidate;
|
||||
}
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
/**
|
||||
* 对一个已打包完成的 zip 签名(追加 signature.json)
|
||||
*
|
||||
* @param string $zipPath zip 绝对路径
|
||||
* @param string $version 版本号(参与签名,防降级)
|
||||
* @param string $privateKey 私钥文件路径;为空则跳过并返回 null(仅本机自测可接受)
|
||||
* @return array|null 签名信息 ['version','sha256','files','signed_at','signature'] 或 null(未签名)
|
||||
*/
|
||||
function pkSignPackage(string $zipPath, string $version, string $privateKey): ?array
|
||||
{
|
||||
if (! is_file($zipPath)) {
|
||||
throw new RuntimeException("待签名包不存在:{$zipPath}");
|
||||
}
|
||||
if ($privateKey === '' || ! is_file($privateKey)) {
|
||||
fwrite(STDERR, "[签名] 未找到签名私钥,跳过:{$zipPath}\n"
|
||||
. " 上线发布前请执行 php scripts/package_keygen.php 生成密钥对。\n");
|
||||
return null;
|
||||
}
|
||||
$info = PackageVerifier::sign($zipPath, $privateKey, $version);
|
||||
return $info;
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验一个包的签名(用于发布前自检)
|
||||
*
|
||||
* @param string $zipPath
|
||||
* @param string $publicKey 公钥 PEM 内容或文件路径
|
||||
* @param string $version 期望版本;留空不校验版本
|
||||
* @return array 签名信息
|
||||
*/
|
||||
function pkVerifyPackage(string $zipPath, string $publicKey, string $version = ''): array
|
||||
{
|
||||
$pem = is_file($publicKey) ? (string) file_get_contents($publicKey) : $publicKey;
|
||||
return PackageVerifier::verifyFile($zipPath, $pem, $version);
|
||||
}
|
||||
|
||||
/**
|
||||
* 输出一行人类可读的签名状态(供打包脚本打印)
|
||||
*
|
||||
* @param array|null $sig pkSignPackage() 的返回值
|
||||
*/
|
||||
function pkSignLabel(?array $sig): string
|
||||
{
|
||||
if ($sig === null) {
|
||||
return '**未签名**(禁止对外发布;请先执行 php scripts/package_keygen.php)';
|
||||
}
|
||||
return '已签名 RSA-SHA256(' . ($sig['files'] ?? 0) . ' 个文件,'
|
||||
. date('Y-m-d H:i:s', (int) ($sig['signed_at'] ?? 0)) . ')';
|
||||
}
|
||||
@@ -0,0 +1,242 @@
|
||||
<?php
|
||||
/**
|
||||
* 一次性脚本:把「系统信息」页的权限节点改成控制台的清理权限。
|
||||
*
|
||||
* 背景:内置「系统信息」页(System 控制器 / view/system / modules/system.js / install.sql 的 system:index)
|
||||
* 已整体下线,清理能力迁到 Console 控制器:
|
||||
* - 页面节点 system:index ——> 删除(连同挂它下面的子权限与角色授权)
|
||||
* - 按钮节点 system:clean ——> 就地把 name/route 改成 console:clean,并挂到「控制台」菜单下
|
||||
*
|
||||
* 为什么要就地改名而不是新建:新节点的 id 是新的,角色授权表(backend_role_power)里没有它,
|
||||
* 非超管账号会因为拿不到这个按钮权限而被 auth.filterDom() 摘掉按钮。
|
||||
* 保留原 id 就等于保留全部既有授权,老库零感知。
|
||||
*
|
||||
* 用法:
|
||||
* php scripts/migrate_console_clean.php # 预演:只统计,不动库(默认)
|
||||
* php scripts/migrate_console_clean.php --force # 真正执行
|
||||
*
|
||||
* 幂等:跑第二次时 system:* 已不存在,只要 console:clean 还在「控制台」下就什么都不做。
|
||||
*/
|
||||
declare(strict_types=1);
|
||||
|
||||
$root = dirname(__DIR__) . DIRECTORY_SEPARATOR;
|
||||
$force = in_array('--force', $argv ?? [], true);
|
||||
|
||||
echo $force
|
||||
? "[模式] 执行:以下动作会真实落库\n"
|
||||
: "[模式] 预演(dry-run):只统计不改动,加 --force 才执行\n";
|
||||
|
||||
/* ------------------------- 读配置:极简 env() + include database.php ------------------------- */
|
||||
|
||||
$GLOBALS['__env'] = [];
|
||||
foreach ([$root . '.env', $root . '.env.local'] as $envFile) {
|
||||
if (! is_file($envFile)) {
|
||||
continue;
|
||||
}
|
||||
foreach (file($envFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) as $line) {
|
||||
$line = trim($line);
|
||||
if ($line === '' || $line[0] === '#') {
|
||||
continue;
|
||||
}
|
||||
$pos = strpos($line, '=');
|
||||
if ($pos === false) {
|
||||
continue;
|
||||
}
|
||||
$GLOBALS['__env'][trim(substr($line, 0, $pos))] = trim(trim(substr($line, $pos + 1)), "\"'");
|
||||
}
|
||||
}
|
||||
|
||||
if (! function_exists('env')) {
|
||||
function env(string $key = '', $default = null)
|
||||
{
|
||||
return $GLOBALS['__env'][$key] ?? $default;
|
||||
}
|
||||
}
|
||||
|
||||
$dbConfFile = $root . 'config' . DIRECTORY_SEPARATOR . 'database.php';
|
||||
if (! is_file($dbConfFile)) {
|
||||
fwrite(STDERR, "找不到配置文件: {$dbConfFile}\n");
|
||||
exit(1);
|
||||
}
|
||||
$dbConf = include $dbConfFile;
|
||||
$conn = $dbConf['connections'][$dbConf['default'] ?? 'mysql'] ?? [];
|
||||
|
||||
/* ------------------------- 连库 ------------------------- */
|
||||
|
||||
$dsn = sprintf(
|
||||
'mysql:host=%s;port=%d;dbname=%s;charset=%s',
|
||||
$conn['hostname'] ?? '127.0.0.1',
|
||||
(int) ($conn['hostport'] ?? 3306),
|
||||
$conn['database'] ?? '',
|
||||
$conn['charset'] ?: 'utf8mb4'
|
||||
);
|
||||
|
||||
try {
|
||||
$pdo = new PDO($dsn, $conn['username'] ?? 'root', $conn['password'] ?? '', [
|
||||
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
|
||||
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
|
||||
PDO::ATTR_EMULATE_PREPARES => false,
|
||||
]);
|
||||
} catch (Throwable $e) {
|
||||
fwrite(STDERR, '数据库连接失败: ' . $e->getMessage() . "\n");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
$prefix = (string) ($conn['prefix'] ?? '');
|
||||
$powerTable = $prefix . 'backend_power';
|
||||
$rolePowerTable = $prefix . 'backend_role_power';
|
||||
$roleTable = $prefix . 'backend_role';
|
||||
|
||||
echo '[连接] ' . ($conn['hostname'] ?? '') . ':' . ($conn['hostport'] ?? 3306)
|
||||
. ' / ' . ($conn['database'] ?? '') . " (表前缀 {$prefix})\n";
|
||||
|
||||
$tableExists = static function (string $table) use ($pdo): bool {
|
||||
return (bool) $pdo->query("SHOW TABLES LIKE '{$table}'")->fetchColumn();
|
||||
};
|
||||
|
||||
if (! $tableExists($powerTable)) {
|
||||
echo "[权限] {$powerTable} 不存在,没什么可迁移的\n";
|
||||
exit(0);
|
||||
}
|
||||
|
||||
/* ------------------------- 1. 「控制台」节点:新权限的挂载点 ------------------------- */
|
||||
|
||||
$consoleRow = $pdo->query(
|
||||
"SELECT `id`, `title` FROM `{$powerTable}` WHERE `name` = 'console:index' ORDER BY `id` LIMIT 1"
|
||||
)->fetch();
|
||||
$consoleId = $consoleRow ? (int) $consoleRow['id'] : 0;
|
||||
|
||||
echo $consoleId
|
||||
? "[挂载点] 控制台 #{$consoleId}({$consoleRow['title']})\n"
|
||||
: "[挂载点] 找不到 console:index 节点,新节点将挂到根节点(pid=0)\n";
|
||||
|
||||
/* ------------------------- 2. 系统信息页节点:删除 ------------------------- */
|
||||
|
||||
$menuRow = $pdo->query(
|
||||
"SELECT `id`, `name`, `title` FROM `{$powerTable}` WHERE `name` = 'system:index' ORDER BY `id` LIMIT 1"
|
||||
)->fetch();
|
||||
|
||||
if ($menuRow) {
|
||||
$menuId = (int) $menuRow['id'];
|
||||
echo "[下线] 找到「{$menuRow['title']}」#{$menuId}({$menuRow['name']})\n";
|
||||
|
||||
$children = $pdo->query(
|
||||
"SELECT `id`, `name`, `title` FROM `{$powerTable}` WHERE `pid` = {$menuId} ORDER BY `id`"
|
||||
)->fetchAll();
|
||||
foreach ($children as $row) {
|
||||
echo sprintf(" 子节点 #%d %s (%s)\n", (int) $row['id'], $row['name'], $row['title']);
|
||||
}
|
||||
|
||||
$ids = array_merge([$menuId], array_map('intval', array_column($children, 'id')));
|
||||
$in = implode(',', $ids);
|
||||
|
||||
$hasRolePower = $tableExists($rolePowerTable);
|
||||
$grantRows = $hasRolePower
|
||||
? (int) $pdo->query("SELECT COUNT(*) FROM `{$rolePowerTable}` WHERE `power_id` IN ({$in})")->fetchColumn()
|
||||
: 0;
|
||||
|
||||
echo "[下线] 待删权限节点 " . count($ids) . " 个、角色授权 {$grantRows} 条\n";
|
||||
|
||||
if ($force) {
|
||||
$pdo->beginTransaction();
|
||||
try {
|
||||
if ($hasRolePower) {
|
||||
$pdo->exec("DELETE FROM `{$rolePowerTable}` WHERE `power_id` IN ({$in})");
|
||||
}
|
||||
// system:clean 要在下一步改造成 console:clean,别在这里跟着父节点一起删了
|
||||
$pdo->exec(
|
||||
"DELETE FROM `{$powerTable}` WHERE `id` IN ({$in}) AND `name` NOT IN ('system:clean', 'console:clean')"
|
||||
);
|
||||
$pdo->commit();
|
||||
echo "[执行] 页面节点与其下无用子节点已删除(system:clean 保留待改造)\n";
|
||||
} catch (Throwable $e) {
|
||||
$pdo->rollBack();
|
||||
fwrite(STDERR, '[失败] 删除回滚: ' . $e->getMessage() . "\n");
|
||||
exit(1);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
echo "[下线] 没有 system:index 节点,跳过\n";
|
||||
}
|
||||
|
||||
/* ------------------------- 3. 清理按钮权限:system:clean → console:clean ------------------------- */
|
||||
|
||||
$oldClean = $pdo->query(
|
||||
"SELECT `id`, `name`, `title` FROM `{$powerTable}` WHERE `name` = 'system:clean' ORDER BY `id` LIMIT 1"
|
||||
)->fetch();
|
||||
$newClean = $pdo->query(
|
||||
"SELECT `id`, `name`, `title` FROM `{$powerTable}` WHERE `name` = 'console:clean' ORDER BY `id` LIMIT 1"
|
||||
)->fetch();
|
||||
|
||||
if ($oldClean && $newClean) {
|
||||
// 两边都有:把授权合并到 console:clean,删掉旧的那个
|
||||
$oldId = (int) $oldClean['id'];
|
||||
$newId = (int) $newClean['id'];
|
||||
echo "[合并] system:clean #{$oldId} 与 console:clean #{$newId} 并存,授权合并到后者并删除旧节点\n";
|
||||
|
||||
if ($force && $tableExists($rolePowerTable)) {
|
||||
$pdo->beginTransaction();
|
||||
try {
|
||||
// 目标节点已有授权的行会撞唯一键,先去重再迁移
|
||||
$owned = $pdo->query(
|
||||
"SELECT `role_id` FROM `{$rolePowerTable}` WHERE `power_id` = {$newId}"
|
||||
)->fetchAll(PDO::FETCH_COLUMN);
|
||||
$ownedIds = $owned ? implode(',', array_map('intval', $owned)) : '0';
|
||||
$pdo->exec(
|
||||
"DELETE FROM `{$rolePowerTable}` WHERE `power_id` = {$oldId} AND `role_id` IN ({$ownedIds})"
|
||||
);
|
||||
$pdo->exec("UPDATE `{$rolePowerTable}` SET `power_id` = {$newId} WHERE `power_id` = {$oldId}");
|
||||
$pdo->exec("DELETE FROM `{$powerTable}` WHERE `id` = {$oldId}");
|
||||
$pdo->commit();
|
||||
echo "[执行] 授权迁移完成,旧节点已删除\n";
|
||||
} catch (Throwable $e) {
|
||||
$pdo->rollBack();
|
||||
fwrite(STDERR, '[失败] 合并回滚: ' . $e->getMessage() . "\n");
|
||||
exit(1);
|
||||
}
|
||||
}
|
||||
} elseif ($oldClean) {
|
||||
$oldId = (int) $oldClean['id'];
|
||||
echo "[改名] system:clean #{$oldId} → console:clean,路由 system/clean → console/clean,挂到 #{$consoleId}\n";
|
||||
if ($force) {
|
||||
$pdo->prepare(
|
||||
"UPDATE `{$powerTable}`
|
||||
SET `name` = 'console:clean', `route` = 'console/clean', `pid` = ?, `update_at` = ?
|
||||
WHERE `id` = ?"
|
||||
)->execute([$consoleId, time(), $oldId]);
|
||||
echo "[执行] 已改名(id 不变 ⇒ 角色授权自动保留)\n";
|
||||
}
|
||||
} elseif ($newClean) {
|
||||
echo "[跳过] console:clean #" . (int) $newClean['id'] . " 已就位\n";
|
||||
} else {
|
||||
echo "[补建] 没有 system:clean,也没有 console:clean —— 补建一个并授权给所有角色\n";
|
||||
if ($force) {
|
||||
$now = time();
|
||||
$newId = (int) $pdo->query("SELECT IFNULL(MAX(`id`), 0) + 1 FROM `{$powerTable}`")->fetchColumn();
|
||||
$stmt = $pdo->prepare(
|
||||
"INSERT INTO `{$powerTable}`
|
||||
(`id`, `pid`, `title`, `name`, `type`, `route`, `icon`, `sort`, `addon`, `status`, `create_at`, `update_at`, `delete_at`)
|
||||
VALUES (?, ?, '清理缓存', 'console:clean', 3, 'console/clean', '', 1, NULL, 1, ?, ?, 0)"
|
||||
);
|
||||
$stmt->execute([$newId, $consoleId, $now, $now]);
|
||||
echo "[执行] 已补建 console:clean #{$newId}\n";
|
||||
|
||||
// 新节点不在任何角色的授权列表里 ⇒ 非超管看不见按钮,必须全角色补授权
|
||||
if ($tableExists($roleTable) && $tableExists($rolePowerTable)) {
|
||||
$roles = $pdo->query("SELECT `id` FROM `{$roleTable}`")->fetchAll(PDO::FETCH_COLUMN);
|
||||
$ins = $pdo->prepare(
|
||||
"INSERT INTO `{$rolePowerTable}` (`role_id`, `power_id`, `create_at`) VALUES (?, ?, ?)"
|
||||
);
|
||||
$n = 0;
|
||||
foreach ($roles as $roleId) {
|
||||
$ins->execute([(int) $roleId, $newId, $now]);
|
||||
$n++;
|
||||
}
|
||||
echo "[执行] 已授权给 {$n} 个角色\n";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
echo $force
|
||||
? "[完成] 权限迁移结束(控制台菜单与按钮权限每次请求读库,无需清缓存)\n"
|
||||
: "[提示] 以上为预演结果,未做任何改动。确认后执行: php scripts/migrate_console_clean.php --force\n";
|
||||
@@ -0,0 +1,205 @@
|
||||
<?php
|
||||
/**
|
||||
* 一次性脚本:把本地插件打包成「非 Web 目录」下的市场 zip,并生成 wxapp_appmall_addon_list 测试记录 SQL。
|
||||
* 产物(zip + sql)统一输出到 build/addons/ 与 build/sql/(非 Web 可访问目录)。
|
||||
*
|
||||
* 与 ywxapp\service\AddonService::package() 的打包结构保持一致:
|
||||
* - addon/<name>/ 目录内容 -> zip 根
|
||||
* - public/static/addon/<name>/ 静态资源 -> zip 的 static/ 前缀(缺失时回退历史 public/static/<name>/)
|
||||
* 客户端 download()/local() 解压时按此结构还原。
|
||||
*
|
||||
* 用法:
|
||||
* php scripts/package_addon.php <addon> [version | major|minor|patch] [title] [author] [description] [price]
|
||||
* - version 缺省时按 patch 自动递增 addons/<addon>/info.php 的版本号并写回;可显式传 x.y.z 或 major/minor/patch
|
||||
*
|
||||
* 例:
|
||||
* php scripts/package_addon.php appmall # 自动 1.0.0 -> 1.0.1
|
||||
* php scripts/package_addon.php appmall minor # 1.0.0 -> 1.1.0
|
||||
* php scripts/package_addon.php mqttbroker "MQTT代理" "" "基于 Workerman 自研 MQTT Broker" 0
|
||||
*/
|
||||
declare(strict_types=1);
|
||||
|
||||
$root = dirname(__DIR__) . DIRECTORY_SEPARATOR; // d:/wwwroot/ywxapp_dev/
|
||||
|
||||
$addon = $argv[1] ?? 'mqttbroker';
|
||||
|
||||
// 解析可选版本参数(位于 $argv[2]);若是版本格式则视为版本参数,其余左移一位
|
||||
$verArg = $argv[2] ?? '';
|
||||
$isVerArg = ($verArg !== '' && (preg_match('/^\d+\.\d+\.\d+$/', $verArg) || in_array($verArg, ['major', 'minor', 'patch'], true)));
|
||||
if ($isVerArg) {
|
||||
$argv[2] = $argv[3] ?? '';
|
||||
$argv[3] = $argv[4] ?? '';
|
||||
$argv[4] = $argv[5] ?? '';
|
||||
$argv[5] = $argv[6] ?? '';
|
||||
}
|
||||
|
||||
$price = $argv[5] ?? '0.00';
|
||||
|
||||
$addonDir = $root . 'addon' . DIRECTORY_SEPARATOR . $addon . DIRECTORY_SEPARATOR;
|
||||
if (!is_dir($addonDir)) {
|
||||
fwrite(STDERR, "插件目录不存在: {$addonDir}\n");
|
||||
exit(1);
|
||||
}
|
||||
$info = include $addonDir . 'info.php';
|
||||
$curVersion = $info['version'] ?? '';
|
||||
if (!$curVersion) {
|
||||
fwrite(STDERR, "info.php 缺少 version\n");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
// ===================== 确定新版本号(支持自动递增) =====================
|
||||
if ($verArg === '') {
|
||||
$newVersion = bumpVersion($curVersion, 'patch');
|
||||
echo "[版本] 未指定版本号,按 patch 自动递增: {$curVersion} -> {$newVersion}\n";
|
||||
} elseif (preg_match('/^\d+\.\d+\.\d+$/', $verArg)) {
|
||||
$newVersion = $verArg;
|
||||
} else {
|
||||
$newVersion = bumpVersion($curVersion, $verArg);
|
||||
echo "[版本] 按 {$verArg} 递增: {$curVersion} -> {$newVersion}\n";
|
||||
}
|
||||
|
||||
// 将新版本写回 info.php(保持插件元数据与产出一致,下次打包在此基础上再 +1)
|
||||
writeAddonVersion($addonDir . 'info.php', $newVersion);
|
||||
echo "[版本] 已写回 addon/{$addon}/info.php: {$newVersion}\n";
|
||||
$version = $newVersion;
|
||||
|
||||
// 标题/作者/简介:命令行参数优先,缺省则从 info.php 读取,保持与插件元数据一致
|
||||
$title = $argv[2] ?? ($info['title'] ?? '');
|
||||
$author = $argv[3] ?? ($info['author'] ?? '');
|
||||
$description = $argv[4] ?? ($info['intro'] ?? '');
|
||||
|
||||
// 输出到「非 Web 可访问」目录(build/ 不在站点根 public/ 下)
|
||||
$marketDir = $root . 'build' . DIRECTORY_SEPARATOR . 'addons' . DIRECTORY_SEPARATOR;
|
||||
if (!is_dir($marketDir)) {
|
||||
mkdir($marketDir, 0755, true);
|
||||
}
|
||||
$zipFile = $marketDir . $addon . '-' . $version . '.zip';
|
||||
|
||||
// 静态资源目录(可选):统一 public/static/addon/<name>/,回退历史 public/static/<name>/
|
||||
$staticDir = $root . 'public' . DIRECTORY_SEPARATOR . 'static' . DIRECTORY_SEPARATOR
|
||||
. 'addon' . DIRECTORY_SEPARATOR . $addon . DIRECTORY_SEPARATOR;
|
||||
$legacyStaticDir = $root . 'public' . DIRECTORY_SEPARATOR . 'static' . DIRECTORY_SEPARATOR
|
||||
. $addon . DIRECTORY_SEPARATOR;
|
||||
|
||||
if (!class_exists('ZipArchive')) {
|
||||
fwrite(STDERR, "ZipArchive 扩展不可用\n");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
$zip = new ZipArchive();
|
||||
if ($zip->open($zipFile, ZipArchive::CREATE | ZipArchive::OVERWRITE) !== true) {
|
||||
fwrite(STDERR, "无法创建 zip: {$zipFile}\n");
|
||||
exit(1);
|
||||
}
|
||||
addDirToZip($zip, $addonDir, '');
|
||||
if (is_dir($staticDir)) {
|
||||
addDirToZip($zip, $staticDir, 'static/');
|
||||
} elseif (is_dir($legacyStaticDir)) {
|
||||
fwrite(STDOUT, "提示:静态资源仍在历史路径 public/static/{$addon}/,已按新口径打包;"
|
||||
. "建议迁移到 public/static/addon/{$addon}/\n");
|
||||
addDirToZip($zip, $legacyStaticDir, 'static/');
|
||||
}
|
||||
$zip->close();
|
||||
|
||||
// 写入包指纹(与 AddonService::package() 一致):zip 注释 + 同名 .sha256 文件,
|
||||
// 供安装端 AddonService::verifyPackageIntegrity() 校验完整性与防篡改。
|
||||
$zip->open($zipFile);
|
||||
$zip->setArchiveComment('YWXAPP-SHA256:' . hash_file('sha256', $zipFile));
|
||||
$zip->close();
|
||||
@file_put_contents($zipFile . '.sha256', hash_file('sha256', $zipFile) . ' ' . basename($zipFile) . PHP_EOL);
|
||||
|
||||
$hash = hash_file('sha256', $zipFile);
|
||||
$size = filesize($zipFile);
|
||||
$absPath = realpath($zipFile);
|
||||
|
||||
// SQL 转义(生成供人工执行的 INSERT)
|
||||
$esc = static function (string $v): string {
|
||||
return str_replace(["\\", "'"], ["\\\\", "\\'"], $v);
|
||||
};
|
||||
|
||||
$sql = "INSERT INTO `wxapp_appmall_addon_list` "
|
||||
. "(`name`,`title`,`version`,`price`,`author`,`description`,`logo`,`file_path`,`file_hash`,`status`,`create_at`,`update_at`) VALUES ("
|
||||
. "'" . $esc($addon) . "',"
|
||||
. "'" . $esc($title) . "',"
|
||||
. "'" . $esc($version) . "',"
|
||||
. floatval($price) . ","
|
||||
. "'" . $esc($author) . "',"
|
||||
. "'" . $esc($description) . "',"
|
||||
. "'',"
|
||||
. "'" . $esc($absPath) . "',"
|
||||
. "'" . $hash . "',"
|
||||
. "1,UNIX_TIMESTAMP(),UNIX_TIMESTAMP());";
|
||||
|
||||
$sqlDir = $root . 'build' . DIRECTORY_SEPARATOR . 'sql' . DIRECTORY_SEPARATOR;
|
||||
if (!is_dir($sqlDir)) {
|
||||
mkdir($sqlDir, 0755, true);
|
||||
}
|
||||
$sqlFile = $sqlDir . $addon . '-' . $version . '-appmall_addon_list.sql';
|
||||
file_put_contents($sqlFile, "-- 插件市场测试记录(由 scripts/package_addon.php 生成)\n-- zip: {$absPath}\n-- sha256: {$hash}\n" . $sql . "\n");
|
||||
|
||||
echo "=== 打包完成 ===\n";
|
||||
echo "zip : {$absPath}\n";
|
||||
echo "size : {$size} bytes\n";
|
||||
echo "sha256: {$hash}\n\n";
|
||||
echo "=== 测试记录 SQL(已写入 {$sqlFile})===\n";
|
||||
echo $sql . "\n";
|
||||
|
||||
/**
|
||||
* 在 x.y.z 基础上递增指定段(major/minor/patch)
|
||||
*/
|
||||
function bumpVersion(string $ver, string $level = 'patch'): string
|
||||
{
|
||||
if (!preg_match('/^(\d+)\.(\d+)\.(\d+)$/', $ver, $m)) {
|
||||
return $ver;
|
||||
}
|
||||
$major = (int) $m[1];
|
||||
$minor = (int) $m[2];
|
||||
$patch = (int) $m[3];
|
||||
switch ($level) {
|
||||
case 'major':
|
||||
$major++; $minor = 0; $patch = 0; break;
|
||||
case 'minor':
|
||||
$minor++; $patch = 0; break;
|
||||
case 'patch':
|
||||
default:
|
||||
$patch++; break;
|
||||
}
|
||||
return $major . '.' . $minor . '.' . $patch;
|
||||
}
|
||||
|
||||
/**
|
||||
* 将新版本号写回插件 info.php
|
||||
*/
|
||||
function writeAddonVersion(string $infoFile, string $ver): void
|
||||
{
|
||||
if (!is_file($infoFile)) {
|
||||
return;
|
||||
}
|
||||
$c = file_get_contents($infoFile);
|
||||
$c = preg_replace(
|
||||
"/(['\"])version['\"]\s*=>\s*['\"][^'\"]*['\"]/",
|
||||
"'version' => '" . $ver . "'",
|
||||
$c
|
||||
);
|
||||
file_put_contents($infoFile, $c);
|
||||
}
|
||||
|
||||
/**
|
||||
* 将目录递归加入 zip(结构对齐 AddonService::addDirectoryToZip)
|
||||
*/
|
||||
function addDirToZip(ZipArchive $zip, string $dir, string $prefix): void
|
||||
{
|
||||
$files = new RecursiveIteratorIterator(
|
||||
new RecursiveDirectoryIterator($dir, RecursiveDirectoryIterator::SKIP_DOTS),
|
||||
RecursiveIteratorIterator::SELF_FIRST
|
||||
);
|
||||
foreach ($files as $file) {
|
||||
$filePath = $file->getRealPath();
|
||||
$rel = ltrim(str_replace(DIRECTORY_SEPARATOR, '/', substr($filePath, strlen($dir))), '/');
|
||||
if ($file->isDir()) {
|
||||
$zip->addEmptyDir($prefix . $rel);
|
||||
} else {
|
||||
$zip->addFile($filePath, $prefix . $rel);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
<?php
|
||||
/**
|
||||
* 批量打包所有本地插件为市场 zip(结构对齐 ywxapp\service\AddonService::package())。
|
||||
*
|
||||
* - addon/<name>/ 内容 -> zip 根
|
||||
* - public/static/<name>/ 静态资源 -> zip 的 static/ 前缀
|
||||
*
|
||||
* 每个插件自动从 addon/<name>/info.php 读取 title/author/intro/version,
|
||||
* 输出到 build/addons/,并生成 wxapp_appmall_addon_list 测试记录 SQL(build/sql/<name>-<ver>-appmall_addon_list.sql)。
|
||||
*
|
||||
* 用法:
|
||||
* php scripts/package_all_addons.php [bump] [skip:name1,name2,...]
|
||||
*
|
||||
* 例:
|
||||
* php scripts/package_all_addons.php # 按 info.php 当前版本打包(不改版本号)
|
||||
* php scripts/package_all_addons.php bump # 每个插件版本按 patch +1 并写回 info.php
|
||||
* php scripts/package_all_addons.php bump skip:market # 同上但跳过 market
|
||||
* php scripts/package_all_addons.php skip:market # 跳过 market,版本号不变
|
||||
*/
|
||||
declare(strict_types=1);
|
||||
|
||||
$root = dirname(__DIR__) . DIRECTORY_SEPARATOR; // d:/wwwroot/ywxapp_dev/
|
||||
|
||||
// 参数:[bump] [skip:name1,name2,...]
|
||||
// bump 可选:每个插件版本按 patch +1 并写回 addons/<name>/info.php
|
||||
// skip: 可选:跳过指定插件(逗号分隔)
|
||||
$bumpArg = $argv[1] ?? '';
|
||||
$bump = false;
|
||||
if ($bumpArg === 'bump') {
|
||||
$bump = true;
|
||||
$skipArg = $argv[2] ?? '';
|
||||
} else {
|
||||
$skipArg = $bumpArg;
|
||||
}
|
||||
$skip = [];
|
||||
if (str_starts_with($skipArg, 'skip:')) {
|
||||
$skip = array_filter(explode(',', substr($skipArg, 5)), static fn($s) => $s !== '');
|
||||
}
|
||||
|
||||
$addonsDir = $root . 'addon' . DIRECTORY_SEPARATOR;
|
||||
if (!is_dir($addonsDir)) {
|
||||
fwrite(STDERR, "addons 目录不存在: {$addonsDir}\n");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
if (!class_exists('ZipArchive')) {
|
||||
fwrite(STDERR, "ZipArchive 扩展不可用\n");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
$marketDir = $root . 'build' . DIRECTORY_SEPARATOR . 'addons' . DIRECTORY_SEPARATOR;
|
||||
if (!is_dir($marketDir)) {
|
||||
mkdir($marketDir, 0755, true);
|
||||
}
|
||||
|
||||
$rows = [];
|
||||
$ok = [];
|
||||
$fail = [];
|
||||
|
||||
foreach (array_diff(scandir($addonsDir), ['.', '..']) as $name) {
|
||||
$addonDir = $addonsDir . $name . DIRECTORY_SEPARATOR;
|
||||
if (!is_dir($addonDir)) {
|
||||
continue;
|
||||
}
|
||||
$infoFile = $addonDir . 'info.php';
|
||||
if (!is_file($infoFile)) {
|
||||
$fail[] = $name . ' (缺 info.php)';
|
||||
continue;
|
||||
}
|
||||
if (in_array($name, $skip, true)) {
|
||||
echo "[skip] {$name}\n";
|
||||
continue;
|
||||
}
|
||||
|
||||
$info = include $infoFile;
|
||||
$title = (string) ($info['title'] ?? $name);
|
||||
$author = (string) ($info['author'] ?? '');
|
||||
$intro = (string) ($info['intro'] ?? '');
|
||||
$version = (string) ($info['version'] ?? '');
|
||||
if (!$version) {
|
||||
$fail[] = $name . ' (info.php 缺 version)';
|
||||
continue;
|
||||
}
|
||||
// 自动递增版本号(bump 模式):先记录旧值,+1 后写回 info.php
|
||||
$oldVersion = $version;
|
||||
if ($bump) {
|
||||
$version = bumpVersion($oldVersion, 'patch');
|
||||
writeAddonVersion($infoFile, $version);
|
||||
}
|
||||
|
||||
$zipFile = $marketDir . $name . '-' . $version . '.zip';
|
||||
$staticDir = $root . 'public' . DIRECTORY_SEPARATOR . 'static' . DIRECTORY_SEPARATOR
|
||||
. $name . DIRECTORY_SEPARATOR;
|
||||
|
||||
$zip = new ZipArchive();
|
||||
if ($zip->open($zipFile, ZipArchive::CREATE | ZipArchive::OVERWRITE) !== true) {
|
||||
$fail[] = $name . ' (无法创建 zip)';
|
||||
continue;
|
||||
}
|
||||
addDirToZip($zip, $addonDir, '');
|
||||
if (is_dir($staticDir)) {
|
||||
addDirToZip($zip, $staticDir, 'static/');
|
||||
}
|
||||
$zip->close();
|
||||
|
||||
$hash = hash_file('sha256', $zipFile);
|
||||
$size = filesize($zipFile);
|
||||
$absPath = realpath($zipFile);
|
||||
|
||||
$esc = static function (string $v): string {
|
||||
return str_replace(["\\", "'"], ["\\\\", "\\'"], $v);
|
||||
};
|
||||
$sql = "INSERT INTO `wxapp_appmall_addon_list` "
|
||||
. "(`name`,`title`,`version`,`price`,`author`,`description`,`logo`,`file_path`,`file_hash`,`status`,`create_at`,`update_at`) VALUES ("
|
||||
. "'" . $esc($name) . "',"
|
||||
. "'" . $esc($title) . "',"
|
||||
. "'" . $esc($version) . "',"
|
||||
. "0.00,"
|
||||
. "'" . $esc($author) . "',"
|
||||
. "'" . $esc($intro) . "',"
|
||||
. "'',"
|
||||
. "'" . $esc($absPath) . "',"
|
||||
. "'" . $hash . "',"
|
||||
. "1,UNIX_TIMESTAMP(),UNIX_TIMESTAMP());";
|
||||
|
||||
$sqlDir = $root . 'build' . DIRECTORY_SEPARATOR . 'sql' . DIRECTORY_SEPARATOR;
|
||||
if (!is_dir($sqlDir)) {
|
||||
mkdir($sqlDir, 0755, true);
|
||||
}
|
||||
$sqlFile = $sqlDir . $name . '-' . $version . '-appmall_addon_list.sql';
|
||||
file_put_contents($sqlFile, "-- 插件市场测试记录(由 scripts/package_all_addons.php 生成)\n-- zip: {$absPath}\n-- sha256: {$hash}\n" . $sql . "\n");
|
||||
|
||||
$verLabel = $bump ? ($oldVersion . ' -> ' . $version) : $version;
|
||||
$rows[] = [$name, $version, $size, $absPath];
|
||||
$ok[] = $name;
|
||||
printf(" [ok] %-12s v%s %8d bytes\n", $name, $verLabel, $size);
|
||||
}
|
||||
|
||||
echo "\n=== 打包汇总 ===\n";
|
||||
echo "成功: " . count($ok) . " 失败: " . count($fail) . "\n";
|
||||
if ($fail) {
|
||||
echo "失败列表:\n - " . implode("\n - ", $fail) . "\n";
|
||||
}
|
||||
echo "输出目录: {$marketDir}\n";
|
||||
|
||||
/**
|
||||
* 在 x.y.z 基础上递增指定段(major/minor/patch)
|
||||
*/
|
||||
function bumpVersion(string $ver, string $level = 'patch'): string
|
||||
{
|
||||
if (!preg_match('/^(\d+)\.(\d+)\.(\d+)$/', $ver, $m)) {
|
||||
return $ver;
|
||||
}
|
||||
$major = (int) $m[1];
|
||||
$minor = (int) $m[2];
|
||||
$patch = (int) $m[3];
|
||||
switch ($level) {
|
||||
case 'major':
|
||||
$major++; $minor = 0; $patch = 0; break;
|
||||
case 'minor':
|
||||
$minor++; $patch = 0; break;
|
||||
case 'patch':
|
||||
default:
|
||||
$patch++; break;
|
||||
}
|
||||
return $major . '.' . $minor . '.' . $patch;
|
||||
}
|
||||
|
||||
/**
|
||||
* 将新版本号写回插件 info.php
|
||||
*/
|
||||
function writeAddonVersion(string $infoFile, string $ver): void
|
||||
{
|
||||
if (!is_file($infoFile)) {
|
||||
return;
|
||||
}
|
||||
$c = file_get_contents($infoFile);
|
||||
$c = preg_replace(
|
||||
"/(['\"])version['\"]\s*=>\s*['\"][^'\"]*['\"]/",
|
||||
"'version' => '" . $ver . "'",
|
||||
$c
|
||||
);
|
||||
file_put_contents($infoFile, $c);
|
||||
}
|
||||
|
||||
/**
|
||||
* 将目录递归加入 zip(结构对齐 AddonService::addDirectoryToZip)
|
||||
*/
|
||||
function addDirToZip(ZipArchive $zip, string $dir, string $prefix): void
|
||||
{
|
||||
$files = new RecursiveIteratorIterator(
|
||||
new RecursiveDirectoryIterator($dir, RecursiveDirectoryIterator::SKIP_DOTS),
|
||||
RecursiveIteratorIterator::SELF_FIRST
|
||||
);
|
||||
foreach ($files as $file) {
|
||||
$filePath = $file->getRealPath();
|
||||
$rel = ltrim(str_replace(DIRECTORY_SEPARATOR, '/', substr($filePath, strlen($dir))), '/');
|
||||
if ($file->isDir()) {
|
||||
$zip->addEmptyDir($prefix . $rel);
|
||||
} else {
|
||||
$zip->addFile($filePath, $prefix . $rel);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,518 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* 一键打包「主框架整站升级包」(整包 + 增量补丁包)以及「完整项目安装包」。
|
||||
*
|
||||
* 与 ywxapp\service\FrameworkService::apply() 的约定一致:
|
||||
* - 整包(full):zip 内含整站代码目录(app/config/extend/ywxapp/public/route 等),
|
||||
* 解压根即项目根目录,apply 会按包内顶层目录识别为「整站包」并整体覆盖。
|
||||
* - 补丁(patch):zip 内仅含相对基础版本的变更文件(路径相对项目根),
|
||||
* 并附 delete.list(路径相对项目根,列出待删除的下线文件)。
|
||||
*
|
||||
* 安全:打包时排除运行/数据/客户端配置目录——runtime/vendor/data/.git、
|
||||
* public/uploads(用户上传)、config/database.php、config/ywxapp.php(库与升级服务器配置)、
|
||||
* .env、install.lock,避免升级覆盖客户机本地配置与数据。
|
||||
* 另:本脚本只打包「框架/整站骨架」,**不包含插件、模板皮肤与中心站专用模块**:
|
||||
* - 插件代码位于 addon/(不在打包目录内);
|
||||
* - app/appmall/ 是「中心站专用」的应用市场服务端,只在总站部署;站长站(客户机)仅需
|
||||
* ywxapp/ 内的客户端服务 + config('appmall.*') 调中心站远程 API 购买/安装插件,
|
||||
* 故整包/补丁/安装包一律排除(见下方 $excludeAppRel)。
|
||||
* public/static/ 采用白名单制——只打包 backend/frontend/member/common 四个核心
|
||||
* 静态目录,其余(插件静态 public/static/<插件名>/ 如 haonav/download、模板皮肤
|
||||
* public/static/templates 等)一律从整包/补丁/安装包中自动剔除。
|
||||
* 插件请改用 scripts/package_addon.php、模板请改用 scripts/package_template.php
|
||||
* 等独立打包流程分发。
|
||||
*
|
||||
* 产物输出到 build/(非 Web 可访问目录):
|
||||
* - build/framework/ywxapp-<version>.zip 整包(整站,用于升级)
|
||||
* - build/framework/ywxapp-<version>-patch.zip 补丁(from_version = 基础版本,用于升级)
|
||||
* - build/framework/ywxapp-<version>-install.zip 完整项目安装包(白名单:app/config/extend/public/ywxapp/route
|
||||
* + 根文件;不含插件、不含真实 .env/database.php/ywxapp.php,供新站首次部署)
|
||||
* 一次运行同时产出上述三个文件;插件仍走 scripts/package_all_addons.php 独立分发;
|
||||
* appmall(中心站专用模块)由总站随仓库自行部署,不进任何框架产物。
|
||||
*
|
||||
* 数据库升级(DDL/DML 变更)怎么办:
|
||||
* - 若本次升级涉及数据库结构/数据变更,把 SQL 放到仓库的 ywxapp/upgrade.sql
|
||||
* (整站包含 ywxapp/ 目录,会自动打进包;解压后位于 项目根/ywxapp/upgrade.sql)。
|
||||
* - FrameworkService::apply() 解压完成后会自动检测并执行该 SQL(按语句拆分,
|
||||
* 忽略 -- 行注释与 /* *\/ 块注释),并用 build/framework/sql_applied.json
|
||||
* 记录已执行版本,幂等(重复升级不重复执行)。执行失败会抛异常由升级流程提示。
|
||||
* - 仅放一个 ywxapp/upgrade.sql 即可;如需按版本分别演进,可在每次发布前更新其内容为
|
||||
* 该版本相对上一版的增量 SQL。
|
||||
*
|
||||
* 用法:
|
||||
* php scripts/package_framework.php [newVersion | major|minor|patch] [baseVersion]
|
||||
* - newVersion 选填:
|
||||
* x.y.z 显式指定新版本(并写回 config/ywxapp.php)
|
||||
* major|minor|patch 在 config/ywxapp.php 当前版本基础上递增对应段
|
||||
* 留空 默认按 patch 自动递增(并写回 config/ywxapp.php)
|
||||
* - baseVersion 选填,默认读取 config/ywxapp.php 的当前 version(作为升级起点)
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
$root = dirname(__DIR__) . DIRECTORY_SEPARATOR;
|
||||
|
||||
$cfgFile = $root . 'config' . DIRECTORY_SEPARATOR . 'ywxapp.php';
|
||||
$curVersion = readFrameworkVersion($cfgFile);
|
||||
|
||||
$arg1 = $argv[1] ?? '';
|
||||
|
||||
// ===================== 确定新版本号(支持自动递增) =====================
|
||||
if ($arg1 === '') {
|
||||
// 留空:默认按 patch 自动 +1
|
||||
if ($curVersion === '') {
|
||||
fwrite(STDERR, "未指定版本号,且无法从 config/ywxapp.php 读取当前版本,请显式传入 x.y.z\n");
|
||||
exit(1);
|
||||
}
|
||||
$newVersion = bumpVersion($curVersion, 'patch');
|
||||
echo "[版本] 未指定版本号,按 patch 自动递增: {$curVersion} -> {$newVersion}\n";
|
||||
} elseif (preg_match('/^\d+\.\d+\.\d+$/', $arg1)) {
|
||||
// 显式 x.y.z
|
||||
$newVersion = $arg1;
|
||||
} elseif (in_array($arg1, ['major', 'minor', 'patch'], true)) {
|
||||
// 按段递增
|
||||
if ($curVersion === '') {
|
||||
fwrite(STDERR, "未指定版本号,且无法从 config/ywxapp.php 读取当前版本,请显式传入 x.y.z\n");
|
||||
exit(1);
|
||||
}
|
||||
$newVersion = bumpVersion($curVersion, $arg1);
|
||||
echo "[版本] 按 {$arg1} 递增: {$curVersion} -> {$newVersion}\n";
|
||||
} else {
|
||||
fwrite(STDERR, "用法: php scripts/package_framework.php [newVersion x.y.z | major|minor|patch] [baseVersion x.y.z]\n");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
// ===================== 回归校验:版本号写入不能写坏配置 =====================
|
||||
// 必须在打包前拦截:一旦 FrameworkService::updateConfigVersion 回归成旧的
|
||||
// '$1' . $version 写法,真实升级会把 'version' => '1.0.0' 写成 .0.1',损坏配置文件。
|
||||
regressionCheckConfigVersion($root, $newVersion);
|
||||
|
||||
// baseVersion 缺省取 config 当前版本(升级起点,即递增前的值)
|
||||
$baseVersion = $argv[2] ?? '';
|
||||
if ($baseVersion === '') {
|
||||
$baseVersion = $curVersion !== '' ? $curVersion : $newVersion;
|
||||
}
|
||||
|
||||
// 将新版本写回 config/ywxapp.php(保持仓库版本与产出一致,下次打包在此基础上再 +1)
|
||||
writeFrameworkVersion($cfgFile, $newVersion);
|
||||
echo "[版本] 已写回 config/ywxapp.php: {$newVersion}\n";
|
||||
|
||||
if (!class_exists('ZipArchive')) {
|
||||
fwrite(STDERR, "ZipArchive 扩展不可用\n");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
// 整站包含的目录与根文件(注意:addons/ 不在此列,插件代码本身不打进框架包)
|
||||
$includeDirs = ['app', 'config', 'extend', 'ywxapp', 'public', 'route'];
|
||||
|
||||
// appmall 是「中心站专用」模块(应用市场服务端):只在总站部署,绝不随框架分发给站长。
|
||||
// 站长站(客户机)只需 ywxapp/ 内的客户端服务 + config('appmall.*') 调用中心站远程 API
|
||||
// 购买/安装插件,不需要市场服务端代码。故整包、补丁包、安装包一律排除 app/appmall/。
|
||||
$excludeAppRel = ['appmall'];
|
||||
|
||||
// public/static/ 白名单:框架包只保留核心静态目录 backend/frontend/member/common,
|
||||
// 其余子目录(插件静态 public/static/<插件名>/ 如 haonav/download、模板皮肤
|
||||
// public/static/templates 等)一律排除,由 package_addon.php / package_template.php
|
||||
// 独立打包分发。动态扫描 static 目录得到排除列表,比按 addon/ 名扫描更彻底。
|
||||
$staticAllowedDirs = ['backend', 'frontend', 'member', 'common'];
|
||||
$excludeStaticRel = [];
|
||||
$staticRoot = $root . 'public' . DIRECTORY_SEPARATOR . 'static';
|
||||
if (is_dir($staticRoot)) {
|
||||
foreach (array_diff(scandir($staticRoot), ['.', '..']) as $entry) {
|
||||
if (is_dir($staticRoot . DIRECTORY_SEPARATOR . $entry) && !in_array($entry, $staticAllowedDirs, true)) {
|
||||
$excludeStaticRel[] = 'public/static/' . $entry;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 各目录打包时的排除项(保护客户机本地配置/数据,以及排除插件/模板静态资源)
|
||||
$dirExclude = [
|
||||
'public' => ['uploads', 'addons'],
|
||||
'config' => ['database.php', 'ywxapp.php'],
|
||||
];
|
||||
// 各目录内的相对路径前缀排除(如 app/appmall、public/static/<插件名>、public/static/templates)
|
||||
$dirExcludeRel = [
|
||||
'app' => $excludeAppRel,
|
||||
'public' => array_map(static function (string $r) {
|
||||
return substr($r, strlen('public/')); // 变为 'static/haonav'、'static/templates' 等
|
||||
}, $excludeStaticRel),
|
||||
];
|
||||
$includeFiles = ['think', 'composer.json', 'README.md', 'LICENSE.txt'];
|
||||
|
||||
// 受保护、绝不允许进包的路径(相对项目根),用于整包/补丁过滤
|
||||
$protectedRel = static function (string $rel) use ($excludeStaticRel): bool {
|
||||
$rel = ltrim($rel, '/');
|
||||
$segs = explode('/', $rel);
|
||||
$first = $segs[0] ?? '';
|
||||
// 插件/模板静态资源按「目录前缀」剔除(public/static/<非白名单目录>/...),
|
||||
// 与整包 addDirToZip 的 $excludeRel 前缀匹配保持一致;不能只用 in_array
|
||||
// 精确匹配,否则补丁里具体的文件路径(public/static/haonav/css/nav.css)
|
||||
// 匹配不上目录条目(public/static/haonav)而漏进包。
|
||||
$excludedStatic = false;
|
||||
foreach ($excludeStaticRel as $ex) {
|
||||
$ex = trim($ex, '/');
|
||||
if ($rel === $ex || strpos($rel . '/', $ex . '/') === 0) {
|
||||
$excludedStatic = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (
|
||||
in_array($first, ['runtime', 'vendor', 'data'], true)
|
||||
|| $rel === '.env' || $rel === 'install.lock'
|
||||
|| ($first === 'public' && ($segs[1] ?? '') === 'uploads')
|
||||
|| ($first === 'config' && in_array($segs[1] ?? '', ['database.php', 'ywxapp.php'], true))
|
||||
|| strpos($rel, 'addons/') === 0 // 插件代码(双保险,addons/ 本就不在 includeDirs)
|
||||
|| ($first === 'app' && ($segs[1] ?? '') === 'appmall') // appmall 中心站专用模块(不随框架分发)
|
||||
|| $excludedStatic
|
||||
) { // 插件静态资源 + 模板/皮肤静态
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
$outDir = $root . 'build' . DIRECTORY_SEPARATOR . 'framework' . DIRECTORY_SEPARATOR;
|
||||
if (!is_dir($outDir)) {
|
||||
mkdir($outDir, 0755, true);
|
||||
}
|
||||
|
||||
// ===================== 整包 =====================
|
||||
$fullZipPath = $outDir . 'ywxapp-' . $newVersion . '.zip';
|
||||
$full = new ZipArchive();
|
||||
if ($full->open($fullZipPath, ZipArchive::CREATE | ZipArchive::OVERWRITE) !== true) {
|
||||
fwrite(STDERR, "无法创建整包: {$fullZipPath}\n");
|
||||
exit(1);
|
||||
}
|
||||
foreach ($includeDirs as $d) {
|
||||
$p = $root . $d;
|
||||
if (is_dir($p)) {
|
||||
$excl = $dirExclude[$d] ?? [];
|
||||
$exclRel = $dirExcludeRel[$d] ?? [];
|
||||
addDirToZip($full, $p, $d . '/', $excl, $exclRel);
|
||||
}
|
||||
}
|
||||
foreach ($includeFiles as $f) {
|
||||
$p = $root . $f;
|
||||
if (is_file($p)) {
|
||||
$full->addFile($p, $f);
|
||||
}
|
||||
}
|
||||
$full->close();
|
||||
$fullHash = hash_file('sha256', $fullZipPath);
|
||||
$fullSize = filesize($fullZipPath);
|
||||
|
||||
// ===================== 补丁包 =====================
|
||||
// 基于 git 工作区相对 HEAD(=基础版本) 的变更(跨整站代码目录):
|
||||
// - 已跟踪文件的增/改/删 用 git diff --name-status HEAD
|
||||
// - 未跟踪的新文件 用 git ls-files --others
|
||||
$deleteList = [];
|
||||
$addedRel = [];
|
||||
|
||||
foreach ($includeDirs as $d) {
|
||||
$diff = (string) @shell_exec('git -C ' . escapeshellarg($root) . ' diff --name-status HEAD -- ' . escapeshellarg($d . '/'));
|
||||
foreach (explode("\n", $diff) as $line) {
|
||||
$line = trim($line);
|
||||
if ($line === '') {
|
||||
continue;
|
||||
}
|
||||
$parts = preg_split('/\t/', $line);
|
||||
$status = $parts[0];
|
||||
$path = $parts[count($parts) - 1]; // 重命名取新路径
|
||||
$path = str_replace('\\', '/', trim($path, '"'));
|
||||
if ($protectedRel($path)) {
|
||||
continue;
|
||||
}
|
||||
$code = $status[0];
|
||||
if ($code === 'D') {
|
||||
$deleteList[] = $path;
|
||||
} elseif ($code === 'A' || $code === 'M') {
|
||||
if (is_file($root . $path)) {
|
||||
$addedRel[] = $path;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$untracked = (string) @shell_exec('git -C ' . escapeshellarg($root) . ' ls-files --others --exclude-standard -- ' . escapeshellarg($d . '/'));
|
||||
foreach (explode("\n", $untracked) as $line) {
|
||||
$line = trim($line);
|
||||
if ($line === '') {
|
||||
continue;
|
||||
}
|
||||
$path = str_replace('\\', '/', trim($line, '"'));
|
||||
if ($protectedRel($path)) {
|
||||
continue;
|
||||
}
|
||||
if (is_file($root . $path) && !in_array($path, $addedRel, true)) {
|
||||
$addedRel[] = $path;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$patchZipPath = $outDir . 'ywxapp-' . $newVersion . '-patch.zip';
|
||||
$patch = new ZipArchive();
|
||||
if ($patch->open($patchZipPath, ZipArchive::CREATE | ZipArchive::OVERWRITE) !== true) {
|
||||
fwrite(STDERR, "无法创建补丁包: {$patchZipPath}\n");
|
||||
exit(1);
|
||||
}
|
||||
foreach ($addedRel as $rel) {
|
||||
$patch->addFile($root . $rel, $rel);
|
||||
}
|
||||
if ($deleteList) {
|
||||
$patch->addFromString('delete.list', implode("\n", $deleteList) . "\n");
|
||||
}
|
||||
// 补丁元信息(apply 仅消费 delete.list / upgrade.json)
|
||||
$patch->addFromString('upgrade.json', json_encode([
|
||||
'from_version' => $baseVersion,
|
||||
'version' => $newVersion,
|
||||
'delete' => $deleteList,
|
||||
], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . "\n");
|
||||
$patch->close();
|
||||
$patchHash = hash_file('sha256', $patchZipPath);
|
||||
$patchSize = filesize($patchZipPath);
|
||||
|
||||
// ===================== 完整项目安装包(白名单,供新站首次部署) =====================
|
||||
$installDir = $outDir; // 三个文件统一输出到 build/framework/
|
||||
if (!is_dir($installDir)) {
|
||||
mkdir($installDir, 0755, true);
|
||||
}
|
||||
$installZipPath = $installDir . 'ywxapp-' . $newVersion . '-install.zip';
|
||||
$installZip = new ZipArchive();
|
||||
if ($installZip->open($installZipPath, ZipArchive::CREATE | ZipArchive::OVERWRITE) !== true) {
|
||||
fwrite(STDERR, "无法创建安装包: {$installZipPath}\n");
|
||||
exit(1);
|
||||
}
|
||||
// 白名单:仅下列根级条目进包(不含插件 addons/、不含运行时/依赖/密钥)
|
||||
$installDirs = ['app', 'config', 'extend', 'public', 'ywxapp', 'route'];
|
||||
$installFiles = ['404.html', 'composer.json', 'think', '.env.production.example'];
|
||||
// 各目录额外排除(按 basename):config 排除本地库/升级服务器配置;public 排除用户上传
|
||||
$installDirExcludes = [
|
||||
'app' => ['appmall'], // 中心站专用模块,不进安装包(站长站无需市场服务端)
|
||||
'config' => ['database.php', 'ywxapp.php'],
|
||||
'public' => ['uploads', 'addons'],
|
||||
];
|
||||
$installGlobalExcludes = ['runtime', 'vendor', '.git', 'node_modules', 'cache', 'install.lock', '.env'];
|
||||
foreach ($installDirs as $d) {
|
||||
$exNames = array_merge($installGlobalExcludes, $installDirExcludes[$d] ?? []);
|
||||
// public 目录额外排除 static 白名单(backend/frontend/member/common)之外的目录,
|
||||
// 保证安装包不含任何插件/模板静态产物。
|
||||
// 注意 addDirToZip 的 $excludeRel 是「相对被打包目录」前缀(public 下即 static/xxx),需去掉 public/ 段
|
||||
$exRel = ($d === 'public') ? array_map(fn($p) => substr($p, strlen('public/')), $excludeStaticRel) : [];
|
||||
addDirToZip($installZip, $root . $d, $d . '/', $exNames, $exRel);
|
||||
}
|
||||
foreach ($installFiles as $f) {
|
||||
$abs = $root . $f;
|
||||
if (!file_exists($abs)) {
|
||||
echo " (安装包) 跳过不存在: {$f}\n";
|
||||
continue;
|
||||
}
|
||||
// .env.production.example 以 .env 之名打进包(占位模板,无真实密钥)
|
||||
if ($f === '.env.production.example') {
|
||||
$installZip->addFile($abs, '.env');
|
||||
} else {
|
||||
$installZip->addFile($abs, $f);
|
||||
}
|
||||
}
|
||||
$installZip->close();
|
||||
$installHash = hash_file('sha256', $installZipPath);
|
||||
$installSize = filesize($installZipPath);
|
||||
|
||||
// ===================== 发布包签名(防篡改 / 防伪造来源) =====================
|
||||
// apply() 会把包直接解压覆盖整站,因此发布包必须带我方私钥签名;
|
||||
// 客户机用随版本发布的公钥先验签再安装(见 ywxapp\service\PackageVerifier)。
|
||||
// 私钥缺失时只警告不中断:本机自测允许出未签名包,**对外发布前必须带签名**。
|
||||
require_once __DIR__ . DIRECTORY_SEPARATOR . 'lib' . DIRECTORY_SEPARATOR . 'package_signature.php';
|
||||
$signKey = pkResolvePrivateKey($root);
|
||||
$packages = [
|
||||
'整包' => ['path' => $fullZipPath, 'hash' => &$fullHash, 'size' => &$fullSize, 'sig' => null],
|
||||
'补丁' => ['path' => $patchZipPath, 'hash' => &$patchHash, 'size' => &$patchSize, 'sig' => null],
|
||||
'安装包' => ['path' => $installZipPath, 'hash' => &$installHash, 'size' => &$installSize, 'sig' => null],
|
||||
];
|
||||
foreach ($packages as $label => &$pkg) {
|
||||
try {
|
||||
$pkg['sig'] = pkSignPackage($pkg['path'], $newVersion, $signKey);
|
||||
} catch (Throwable $e) {
|
||||
fwrite(STDERR, "[签名失败] {$label}: " . $e->getMessage() . "\n");
|
||||
}
|
||||
// 签名会改写 zip(追加 signature.json),故哈希与体积以签名后为准
|
||||
$pkg['hash'] = hash_file('sha256', $pkg['path']);
|
||||
$pkg['size'] = filesize($pkg['path']);
|
||||
}
|
||||
unset($pkg);
|
||||
|
||||
// ===================== 输出 =====================
|
||||
echo "=== 打包完成 (base={$baseVersion} -> new={$newVersion}, 整站) ===\n\n";
|
||||
echo "[整包] {$fullZipPath}\n";
|
||||
echo " size : {$fullSize} bytes\n";
|
||||
echo " sha256: {$fullHash}\n";
|
||||
echo " sign : " . pkSignLabel($packages['整包']['sig']) . "\n\n";
|
||||
echo "[补丁] {$patchZipPath}\n";
|
||||
echo " size : {$patchSize} bytes\n";
|
||||
echo " sha256: {$patchHash}\n";
|
||||
echo " sign : " . pkSignLabel($packages['补丁']['sig']) . "\n";
|
||||
echo " changed files (" . count($addedRel) . "):\n";
|
||||
foreach ($addedRel as $r) {
|
||||
echo " + {$r}\n";
|
||||
}
|
||||
echo " deleted files (" . count($deleteList) . "):\n";
|
||||
foreach ($deleteList as $r) {
|
||||
echo " - {$r}\n";
|
||||
}
|
||||
echo "\n[安装包] {$installZipPath}\n";
|
||||
echo " size : {$installSize} bytes\n";
|
||||
echo " sha256: {$installHash}\n";
|
||||
echo " sign : " . pkSignLabel($packages['安装包']['sig']) . "\n";
|
||||
echo " 说明 : 新站首次部署用,不含插件/addons、不含 appmall(中心站专用模块)、不含真实 .env·database.php·ywxapp.php\n";
|
||||
|
||||
/**
|
||||
* 回归校验:updateConfigVersion 必须用 '${1}' 写法写入版本号。
|
||||
* 否则当版本号含数字(如 1.0.1)时,'$1' 后接数字会被 PHP 当成第 11 个捕获组,
|
||||
* 把 'version' => '1.0.0' 写成 .0.1',损坏配置文件导致应用无法启动。
|
||||
*/
|
||||
function regressionCheckConfigVersion(string $root, string $newVersion): void
|
||||
{
|
||||
$svcFile = $root . 'ywxapp' . DIRECTORY_SEPARATOR . 'service' . DIRECTORY_SEPARATOR . 'FrameworkService.php';
|
||||
if (!is_file($svcFile)) {
|
||||
echo "[回归校验] 跳过:未找到 FrameworkService.php\n";
|
||||
return;
|
||||
}
|
||||
$src = file_get_contents($svcFile);
|
||||
$bad = '\'$1\' . $version';
|
||||
$good = '\'${1}\'';
|
||||
|
||||
// 1) 静态检查:禁止遗留 bug 写法('$1' . $version 且未使用 '${1}')
|
||||
if (strpos($src, $bad) !== false && strpos($src, $good) === false) {
|
||||
fwrite(
|
||||
STDERR,
|
||||
"[回归校验] FAIL: FrameworkService::updateConfigVersion 仍使用 bug 写法 '$1' . \$version," .
|
||||
"会把版本号写坏(应改为 '\${1}' . \$version . '\${2}')。\n"
|
||||
);
|
||||
exit(1);
|
||||
}
|
||||
|
||||
// 2) 运行时模拟:用修复后的替换串写入,确认版本号正确落盘格式
|
||||
// 注意:真实配置行 'version' 与 => 之间有多余空格,替换后原样保留,
|
||||
// 故断言用宽松匹配:版本号 token 被正确写入且 'version' 键前缀未丢失。
|
||||
$sample = " 'version' => '1.0.0',\n";
|
||||
$fixed = preg_replace(
|
||||
"/(['\"]version['\"]\s*=>\s*['\"])[^'\"]*(['\"])/",
|
||||
'${1}' . $newVersion . '${2}',
|
||||
$sample
|
||||
);
|
||||
if (strpos($fixed, "'{$newVersion}'") === false || strpos($fixed, "'version'") === false) {
|
||||
fwrite(STDERR, "[回归校验] FAIL: 模拟写版本号结果异常: " . trim($fixed) . "\n");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
// 3) 反向确认:bug 写法确实会写坏(仅作证明,不中断流程)
|
||||
$buggy = preg_replace(
|
||||
"/(['\"]version['\"]\s*=>\s*['\"])[^'\"]*(['\"])/",
|
||||
'$1' . $newVersion . '$2',
|
||||
$sample
|
||||
);
|
||||
if (strpos($buggy, "'{$newVersion}'") === false) {
|
||||
echo "[回归校验] OK: 已确认修复写法正确(若回归旧写法会写成: " . trim($buggy) . ")\n";
|
||||
} else {
|
||||
echo "[回归校验] OK: 版本号写入校验通过 (new={$newVersion})\n";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 读取 config/ywxapp.php 中的框架版本号
|
||||
*/
|
||||
function readFrameworkVersion(string $cfgFile): string
|
||||
{
|
||||
if (!is_file($cfgFile)) {
|
||||
return '';
|
||||
}
|
||||
$c = file_get_contents($cfgFile);
|
||||
if (preg_match("/['\"]version['\"]\s*=>\s*['\"]([^'\"]+)['\"]/", $c, $m)) {
|
||||
return $m[1];
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
/**
|
||||
* 在 x.y.z 基础上递增指定段(major/minor/patch)
|
||||
*/
|
||||
function bumpVersion(string $ver, string $level = 'patch'): string
|
||||
{
|
||||
if (!preg_match('/^(\d+)\.(\d+)\.(\d+)$/', $ver, $m)) {
|
||||
return $ver;
|
||||
}
|
||||
$major = (int) $m[1];
|
||||
$minor = (int) $m[2];
|
||||
$patch = (int) $m[3];
|
||||
switch ($level) {
|
||||
case 'major':
|
||||
$major++;
|
||||
$minor = 0;
|
||||
$patch = 0;
|
||||
break;
|
||||
case 'minor':
|
||||
$minor++;
|
||||
$patch = 0;
|
||||
break;
|
||||
case 'patch':
|
||||
default:
|
||||
$patch++;
|
||||
break;
|
||||
}
|
||||
return $major . '.' . $minor . '.' . $patch;
|
||||
}
|
||||
|
||||
/**
|
||||
* 将新版本号写回 config/ywxapp.php(必须用 '${1}' 安全写法,避免重写坏配置)
|
||||
*/
|
||||
function writeFrameworkVersion(string $cfgFile, string $ver): void
|
||||
{
|
||||
if (!is_file($cfgFile)) {
|
||||
return;
|
||||
}
|
||||
$c = file_get_contents($cfgFile);
|
||||
$c = preg_replace(
|
||||
"/(['\"]version['\"]\s*=>\s*['\"])[^'\"]*(['\"])/",
|
||||
'${1}' . $ver . '${2}',
|
||||
$c
|
||||
);
|
||||
file_put_contents($cfgFile, $c);
|
||||
}
|
||||
|
||||
/**
|
||||
* 将目录递归加入 zip。
|
||||
* - $excludeNames:按 basename 排除(如 uploads)。
|
||||
* - $excludeRel:按相对路径前缀排除(相对被打包目录,如 public 传入 'static/haonav'),
|
||||
* 用于排除插件静态资源等子目录。
|
||||
*/
|
||||
function addDirToZip(ZipArchive $zip, string $dir, string $prefix, array $excludeNames = [], array $excludeRel = []): void
|
||||
{
|
||||
$dir = rtrim($dir, '/\\');
|
||||
$iter = new RecursiveIteratorIterator(
|
||||
new RecursiveCallbackFilterIterator(
|
||||
new RecursiveDirectoryIterator($dir, RecursiveDirectoryIterator::SKIP_DOTS),
|
||||
static function ($file) use ($dir, $excludeNames, $excludeRel) {
|
||||
$fp = $file->getRealPath();
|
||||
$rel = ltrim(str_replace(DIRECTORY_SEPARATOR, '/', substr($fp, strlen($dir))), '/');
|
||||
if (in_array($file->getFilename(), $excludeNames, true)) {
|
||||
return false;
|
||||
}
|
||||
foreach ($excludeRel as $ex) {
|
||||
$ex = trim($ex, '/');
|
||||
if ($rel === $ex || strpos($rel . '/', $ex . '/') === 0) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
),
|
||||
RecursiveIteratorIterator::SELF_FIRST
|
||||
);
|
||||
foreach ($iter as $file) {
|
||||
$filePath = $file->getRealPath();
|
||||
$rel = ltrim(str_replace(DIRECTORY_SEPARATOR, '/', substr($filePath, strlen($dir))), '/');
|
||||
if ($file->isDir()) {
|
||||
$zip->addEmptyDir($prefix . $rel);
|
||||
} else {
|
||||
$zip->addFile($filePath, $prefix . $rel);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
<?php
|
||||
/**
|
||||
* 生成「发布包签名」密钥对(一次)
|
||||
*
|
||||
* 产出:
|
||||
* - 私钥 runtime/package_private.pem **仅限打包机,绝不入库/进包**(.gitignore 已忽略 /runtime)
|
||||
* - 公钥 ywxapp/data/package_public.pem 随版本发布,客户机用它验签
|
||||
* - 同时把公钥写回 ywxapp\service\PackageVerifier::BUILTIN_PUBLIC_KEY,
|
||||
* 作为「公钥文件被误删/被篡改」时的兜底锚点(否则删掉文件就能绕过验签)。
|
||||
*
|
||||
* 用法:
|
||||
* php scripts/package_keygen.php # 生成 4096 位密钥对(已存在则要求 --force)
|
||||
* php scripts/package_keygen.php --force # 强制轮换(旧包将全部验签失败,务必先给客户推过渡版本)
|
||||
* php scripts/package_keygen.php --bits=2048
|
||||
*
|
||||
* 轮换策略(重要):
|
||||
* 直接换 key 会让所有客户机拒绝安装旧包。正确做法是先发布一个「同时内置新旧公钥」的过渡版本,
|
||||
* 待覆盖率达标后再用新私钥发新版;本脚本的 --force 只用于「私钥已泄漏」应急。
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once __DIR__ . DIRECTORY_SEPARATOR . 'lib' . DIRECTORY_SEPARATOR . 'package_signature.php';
|
||||
|
||||
$root = dirname(__DIR__) . DIRECTORY_SEPARATOR;
|
||||
$bits = 4096;
|
||||
$force = false;
|
||||
foreach (array_slice($argv, 1) as $arg) {
|
||||
if ($arg === '--force') {
|
||||
$force = true;
|
||||
} elseif (strpos($arg, '--bits=') === 0) {
|
||||
$bits = (int) substr($arg, 7);
|
||||
if ($bits < 2048) {
|
||||
fwrite(STDERR, "密钥长度至少 2048 位\n");
|
||||
exit(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$privFile = $root . 'runtime' . DIRECTORY_SEPARATOR . 'package_private.pem';
|
||||
$pubFile = $root . 'ywxapp' . DIRECTORY_SEPARATOR . 'data' . DIRECTORY_SEPARATOR . 'package_public.pem';
|
||||
$builtin = $root . 'ywxapp' . DIRECTORY_SEPARATOR . 'service' . DIRECTORY_SEPARATOR . 'PackageVerifier.php';
|
||||
|
||||
if (is_file($privFile) && ! $force) {
|
||||
fwrite(STDERR, "私钥已存在:{$privFile}\n如需轮换请加 --force(会让旧发布包全部验签失败,先确认发布策略)\n");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
if (! extension_loaded('openssl')) {
|
||||
fwrite(STDERR, "缺少 openssl 扩展\n");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
$res = openssl_pkey_new([
|
||||
'private_key_bits' => $bits,
|
||||
'private_key_type' => OPENSSL_KEYTYPE_RSA,
|
||||
]);
|
||||
if ($res === false) {
|
||||
fwrite(STDERR, "生成密钥失败:" . openssl_error_string() . "\n");
|
||||
exit(1);
|
||||
}
|
||||
if (! openssl_pkey_export($res, $privatePem)) {
|
||||
fwrite(STDERR, "导出私钥失败\n");
|
||||
exit(1);
|
||||
}
|
||||
$details = openssl_pkey_get_details($res);
|
||||
$publicPem = $details['key'] ?? '';
|
||||
if ($publicPem === '') {
|
||||
fwrite(STDERR, "导出公钥失败\n");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
if (! is_dir(dirname($privFile))) {
|
||||
mkdir(dirname($privFile), 0755, true);
|
||||
}
|
||||
if (! is_dir(dirname($pubFile))) {
|
||||
mkdir(dirname($pubFile), 0755, true);
|
||||
}
|
||||
file_put_contents($privFile, $privatePem);
|
||||
chmod($privFile, 0600);
|
||||
file_put_contents($pubFile, $publicPem);
|
||||
|
||||
// 写回内置公钥常量:文件可被整包升级覆盖,常量是最后的信任锚
|
||||
$builtinOk = false;
|
||||
if (is_file($builtin)) {
|
||||
$src = (string) file_get_contents($builtin);
|
||||
// 用正则而不是字符串定位:Windows 下仓库可能检出 CRLF,
|
||||
// 硬匹配 "\nPEM;\n" 会漏。$count 用于判断是否真的替换到了。
|
||||
$new = preg_replace_callback(
|
||||
"/(private const BUILTIN_PUBLIC_KEY\s*=\s*<<<'PEM'\R)(.*?)(\R\s*PEM;)/s",
|
||||
static function (array $m) use ($publicPem): string {
|
||||
$raw = trim($publicPem);
|
||||
// PEM 里含 - 与字母数字,作为 replacement 无需转义,但统一按行尾惯例 CRLF/LF 保持不变
|
||||
return $m[1] . $raw . $m[3];
|
||||
},
|
||||
$src,
|
||||
1,
|
||||
$count
|
||||
);
|
||||
if ($count > 0 && $new !== null) {
|
||||
file_put_contents($builtin, $new);
|
||||
$builtinOk = true;
|
||||
}
|
||||
}
|
||||
|
||||
echo "=== 发布包签名密钥已生成 ===\n";
|
||||
echo "[私钥] {$privFile} (仅打包机保留,切勿入库/进包)\n";
|
||||
echo "[公钥] {$pubFile} (随版本发布给客户机)\n";
|
||||
echo "[内置] {$builtin} → " . ($builtinOk ? '已写入 BUILTIN_PUBLIC_KEY' : '写入失败,请手工粘贴公钥到该常量') . "\n";
|
||||
echo "[位数] {$bits}\n\n";
|
||||
echo "下一步:\n";
|
||||
echo " 1) 备份私钥到离线介质(丢了无法签发新版本,只能换 key 并强制轮换)\n";
|
||||
echo " 2) php scripts/package_framework.php 1.1.2 # 打包时会自动给三个 zip 追加签名\n";
|
||||
echo " 3) php scripts/package_verify.php build/framework/ywxapp-1.1.2.zip 1.1.2 # 发布前自检\n";
|
||||
@@ -0,0 +1,195 @@
|
||||
<?php
|
||||
/**
|
||||
* 一次性脚本:把本地模板皮肤打包成「非 Web 目录」下的市场 zip,并生成测试记录 SQL。
|
||||
* 产物(zip + sql)统一输出到 build/templates/ 与 build/sql/(非 Web 可访问目录)。
|
||||
*
|
||||
* 模板包结构(与运行时约定保持一致,详见 ywxapp/library/TemplateInstaller.php):
|
||||
* templates/<name>/
|
||||
* ├── template.json # 元数据
|
||||
* ├── view/<插件>/<区>/*.html # 覆盖视图(与插件 view 同构)
|
||||
* ├── static/ # 皮肤专属静态资源(安装时部署到 public/static/templates/<name>/)
|
||||
* └── preview.png # 商店预览图
|
||||
*
|
||||
* zip 内部两段前缀:
|
||||
* - templates/<name>/ 放 template.json / view/ / preview.png
|
||||
* - static/ 放皮肤静态资源 → 安装时落到 public 侧
|
||||
* 客户端 TemplateInstaller::install() 按此前缀还原。
|
||||
*
|
||||
* 用法:
|
||||
* php scripts/package_template.php <name> [version | major|minor|patch]
|
||||
* - version 缺省时按 patch 自动递增 templates/<name>/template.json 的版本号并写回
|
||||
*
|
||||
* 例:
|
||||
* php scripts/package_template.php myblog
|
||||
* php scripts/package_template.php myblog minor
|
||||
* php scripts/package_template.php myblog 1.2.0
|
||||
*/
|
||||
declare(strict_types=1);
|
||||
|
||||
$root = dirname(__DIR__) . DIRECTORY_SEPARATOR; // d:/wwwroot/ywxapp_dev/
|
||||
|
||||
$name = $argv[1] ?? '';
|
||||
if ($name === '' || !preg_match('/^[a-zA-Z][a-zA-Z0-9_]*$/', $name)) {
|
||||
fwrite(STDERR, "用法: php scripts/package_template.php <name> [version|major|minor|patch]\n");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
$tplDir = $root . 'templates' . DIRECTORY_SEPARATOR . $name . DIRECTORY_SEPARATOR;
|
||||
if (!is_dir($tplDir)) {
|
||||
fwrite(STDERR, "模板目录不存在: {$tplDir}\n");
|
||||
exit(1);
|
||||
}
|
||||
$jsonFile = $tplDir . 'template.json';
|
||||
if (!is_file($jsonFile)) {
|
||||
fwrite(STDERR, "缺少 template.json: {$jsonFile}\n");
|
||||
exit(1);
|
||||
}
|
||||
$meta = json_decode(file_get_contents($jsonFile), true);
|
||||
if (!is_array($meta)) {
|
||||
fwrite(STDERR, "template.json 解析失败\n");
|
||||
exit(1);
|
||||
}
|
||||
$curVersion = $meta['version'] ?? '';
|
||||
if (!$curVersion) {
|
||||
fwrite(STDERR, "template.json 缺少 version\n");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
// ===================== 确定新版本号(支持自动递增) =====================
|
||||
$verArg = $argv[2] ?? '';
|
||||
if ($verArg === '') {
|
||||
$newVersion = bumpVersion($curVersion, 'patch');
|
||||
echo "[版本] 未指定版本号,按 patch 自动递增: {$curVersion} -> {$newVersion}\n";
|
||||
} elseif (preg_match('/^\d+\.\d+\.\d+$/', $verArg)) {
|
||||
$newVersion = $verArg;
|
||||
} elseif (in_array($verArg, ['major', 'minor', 'patch'], true)) {
|
||||
$newVersion = bumpVersion($curVersion, $verArg);
|
||||
echo "[版本] 按 {$verArg} 递增: {$curVersion} -> {$newVersion}\n";
|
||||
} else {
|
||||
fwrite(STDERR, "无效的版本参数: {$verArg}\n");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
// 写回 template.json(保持元数据与产出一致)
|
||||
$meta['version'] = $newVersion;
|
||||
file_put_contents($jsonFile, json_encode($meta, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) . "\n");
|
||||
echo "[版本] 已写回 templates/{$name}/template.json: {$newVersion}\n";
|
||||
|
||||
// ===================== 打包 =====================
|
||||
$outDir = $root . 'build' . DIRECTORY_SEPARATOR . 'templates' . DIRECTORY_SEPARATOR;
|
||||
if (!is_dir($outDir)) {
|
||||
mkdir($outDir, 0755, true);
|
||||
}
|
||||
$zipFile = $outDir . $name . '-' . $newVersion . '.zip';
|
||||
|
||||
if (!class_exists('ZipArchive')) {
|
||||
fwrite(STDERR, "ZipArchive 扩展不可用\n");
|
||||
exit(1);
|
||||
}
|
||||
$zip = new ZipArchive();
|
||||
if ($zip->open($zipFile, ZipArchive::CREATE | ZipArchive::OVERWRITE) !== true) {
|
||||
fwrite(STDERR, "无法创建 zip: {$zipFile}\n");
|
||||
exit(1);
|
||||
}
|
||||
// 模板代码(排除 static/,static 单独以 static/ 前缀打包)
|
||||
addDirToZip($zip, $tplDir, 'templates/' . $name . '/', ['static']);
|
||||
// 皮肤静态资源 → 安装时部署到 public/static/templates/<name>/
|
||||
$staticDir = $tplDir . 'static' . DIRECTORY_SEPARATOR;
|
||||
if (is_dir($staticDir)) {
|
||||
addDirToZip($zip, $staticDir, 'static/');
|
||||
}
|
||||
$zip->close();
|
||||
|
||||
$hash = hash_file('sha256', $zipFile);
|
||||
$size = filesize($zipFile);
|
||||
$absPath = realpath($zipFile);
|
||||
|
||||
// ===================== 测试记录 SQL(Phase 3 接入 appmall 时 type='template') =====================
|
||||
$esc = static function (string $v): string {
|
||||
return str_replace(["\\", "'"], ["\\\\", "\\'"], $v);
|
||||
};
|
||||
$title = $meta['title'] ?? $name;
|
||||
$author = $meta['author'] ?? '';
|
||||
$description = $meta['description'] ?? '';
|
||||
$price = 0.00;
|
||||
$sql = "INSERT INTO `wxapp_appmall_addon_list` "
|
||||
. "(`name`,`type`,`title`,`version`,`price`,`author`,`description`,`logo`,`file_path`,`file_hash`,`status`,`create_at`,`update_at`) VALUES ("
|
||||
. "'" . $esc($name) . "',"
|
||||
. "'template',"
|
||||
. "'" . $esc($title) . "',"
|
||||
. "'" . $esc($newVersion) . "',"
|
||||
. floatval($price) . ","
|
||||
. "'" . $esc($author) . "',"
|
||||
. "'" . $esc($description) . "',"
|
||||
. "'',"
|
||||
. "'" . $esc($absPath) . "',"
|
||||
. "'" . $hash . "',"
|
||||
. "1,UNIX_TIMESTAMP(),UNIX_TIMESTAMP());";
|
||||
|
||||
$sqlDir = $root . 'build' . DIRECTORY_SEPARATOR . 'sql' . DIRECTORY_SEPARATOR;
|
||||
if (!is_dir($sqlDir)) {
|
||||
mkdir($sqlDir, 0755, true);
|
||||
}
|
||||
$sqlFile = $sqlDir . $name . '-' . $newVersion . '-template.sql';
|
||||
file_put_contents($sqlFile,
|
||||
"-- 模板市场测试记录(由 scripts/package_template.php 生成,type='template')\n"
|
||||
. "-- zip: {$absPath}\n-- sha256: {$hash}\n" . $sql . "\n");
|
||||
|
||||
echo "=== 打包完成 ===\n";
|
||||
echo "name : {$name}\n";
|
||||
echo "title : {$title}\n";
|
||||
echo "zip : {$absPath}\n";
|
||||
echo "size : {$size} bytes\n";
|
||||
echo "sha256: {$hash}\n";
|
||||
echo "目标插件: " . implode(',', $meta['target_addons'] ?? []) . "\n\n";
|
||||
echo "=== 测试记录 SQL(已写入 {$sqlFile})===\n";
|
||||
echo $sql . "\n";
|
||||
|
||||
/**
|
||||
* 在 x.y.z 基础上递增指定段
|
||||
*/
|
||||
function bumpVersion(string $ver, string $level = 'patch'): string
|
||||
{
|
||||
if (!preg_match('/^(\d+)\.(\d+)\.(\d+)$/', $ver, $m)) {
|
||||
return $ver;
|
||||
}
|
||||
$major = (int) $m[1];
|
||||
$minor = (int) $m[2];
|
||||
$patch = (int) $m[3];
|
||||
switch ($level) {
|
||||
case 'major':
|
||||
$major++; $minor = 0; $patch = 0; break;
|
||||
case 'minor':
|
||||
$minor++; $patch = 0; break;
|
||||
case 'patch':
|
||||
default:
|
||||
$patch++; break;
|
||||
}
|
||||
return $major . '.' . $minor . '.' . $patch;
|
||||
}
|
||||
|
||||
/**
|
||||
* 将目录递归加入 zip(结构对齐 AddonService::addDirectoryToZip)
|
||||
* @param string[] $exclude 需排除的子目录名(相对 $dir 的一级目录)
|
||||
*/
|
||||
function addDirToZip(ZipArchive $zip, string $dir, string $prefix, array $exclude = []): void
|
||||
{
|
||||
$files = new RecursiveIteratorIterator(
|
||||
new RecursiveDirectoryIterator($dir, RecursiveDirectoryIterator::SKIP_DOTS),
|
||||
RecursiveIteratorIterator::SELF_FIRST
|
||||
);
|
||||
foreach ($files as $file) {
|
||||
$filePath = $file->getRealPath();
|
||||
$rel = ltrim(str_replace(DIRECTORY_SEPARATOR, '/', substr($filePath, strlen($dir))), '/');
|
||||
// 排除指定一级子目录(如 static)
|
||||
$top = explode('/', $rel)[0] ?? '';
|
||||
if ($top !== '' && in_array($top, $exclude, true)) {
|
||||
continue;
|
||||
}
|
||||
if ($file->isDir()) {
|
||||
$zip->addEmptyDir($prefix . $rel);
|
||||
} else {
|
||||
$zip->addFile($filePath, $prefix . $rel);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
<?php
|
||||
/**
|
||||
* 发布包验签自检工具(发布前必跑)
|
||||
*
|
||||
* 用「随版本发布的公钥」验一个本机打好的包,验证:
|
||||
* - 包内能取出 signature.json;
|
||||
* - 内容清单哈希与签名一致(排除被篡改 / 下载损坏);
|
||||
* - RSA 签名有效(确实是本机私钥签发);
|
||||
* - 版本号一致(防降级回滚到含漏洞的旧版)。
|
||||
*
|
||||
* 用法:
|
||||
* php scripts/package_verify.php build/framework/ywxapp-1.1.2.zip 1.1.2
|
||||
* php scripts/package_verify.php <zip路径> [期望版本]
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once __DIR__ . DIRECTORY_SEPARATOR . 'lib' . DIRECTORY_SEPARATOR . 'package_signature.php';
|
||||
|
||||
$root = dirname(__DIR__) . DIRECTORY_SEPARATOR;
|
||||
$zip = $argv[1] ?? '';
|
||||
$ver = $argv[2] ?? '';
|
||||
|
||||
if ($zip === '') {
|
||||
fwrite(STDERR, "用法:php scripts/package_verify.php <zip路径> [期望版本]\n");
|
||||
exit(1);
|
||||
}
|
||||
if (! is_file($zip)) {
|
||||
fwrite(STDERR, "包不存在:{$zip}\n");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
$pubFile = $root . 'ywxapp' . DIRECTORY_SEPARATOR . 'data' . DIRECTORY_SEPARATOR . 'package_public.pem';
|
||||
if (! is_file($pubFile)) {
|
||||
fwrite(STDERR, "公钥不存在:{$pubFile}\n请先执行 php scripts/package_keygen.php\n");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
try {
|
||||
$info = pkVerifyPackage($zip, (string) file_get_contents($pubFile), $ver);
|
||||
} catch (Throwable $e) {
|
||||
fwrite(STDERR, "[FAIL] " . $e->getMessage() . "\n");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
echo "[OK] 验签通过:{$zip}\n";
|
||||
echo " 版本 : {$info['version']}\n";
|
||||
echo " 条目数 : {$info['files']}\n";
|
||||
echo " 算法 : {$info['alg']}\n";
|
||||
echo " 签发时间 : " . date('Y-m-d H:i:s', $info['signed_at']) . "\n";
|
||||
echo " sha256 : " . hash_file('sha256', $zip) . "\n";
|
||||
@@ -0,0 +1,166 @@
|
||||
<?php
|
||||
/**
|
||||
* 一次性脚本:清理内置「电子商务」(Shop)模块在已装好的库里的残留数据。
|
||||
*
|
||||
* 背景:内置商城已整体下线(控制器 / 验证器 / 视图 / 静态 JS / CommonShop 模型全部删除,
|
||||
* install.sql 里的菜单种子与 common_shop 表定义也一并移除)。但已经装好的库不会被这些改动影响:
|
||||
* MenuRegistry::ensure() 只会「缺了就补」,从来不删,于是「交易营销 → 电子商务」会变成一个
|
||||
* 点了就报错的僵尸菜单;wxapp_common_shop 表也会一直挂着。
|
||||
*
|
||||
* 本脚本处理三样东西(幂等,找不到就什么都不做):
|
||||
* 1. backend_power 里 shop / shop:* 的权限节点(含挂在这些节点下的子按钮)
|
||||
* 2. backend_role_power 里指向它们的角色授权记录(不删会留下孤儿行)
|
||||
* 3. wxapp_common_shop 数据表
|
||||
*
|
||||
* 用法:
|
||||
* php scripts/purge_builtin_shop.php # 预演:只统计、不动库(默认)
|
||||
* php scripts/purge_builtin_shop.php --force # 真正执行
|
||||
*
|
||||
* 依赖:只需 PDO + mysql 扩展,不加载框架(里的 .env / config/database.php 自己读)。
|
||||
*/
|
||||
declare(strict_types=1);
|
||||
|
||||
$root = dirname(__DIR__) . DIRECTORY_SEPARATOR;
|
||||
$force = in_array('--force', $argv ?? [], true);
|
||||
|
||||
echo $force
|
||||
? "[模式] 执行:以下动作会真实落库\n"
|
||||
: "[模式] 预演(dry-run):只统计不改动,加 --force 才执行\n";
|
||||
|
||||
/* ------------------------- 1. 读配置:极简 env() + include database.php ------------------------- */
|
||||
|
||||
$GLOBALS['__env'] = [];
|
||||
foreach ([$root . '.env', $root . '.env.local'] as $envFile) {
|
||||
if (! is_file($envFile)) {
|
||||
continue;
|
||||
}
|
||||
foreach (file($envFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) as $line) {
|
||||
$line = trim($line);
|
||||
if ($line === '' || $line[0] === '#') {
|
||||
continue;
|
||||
}
|
||||
$pos = strpos($line, '=');
|
||||
if ($pos === false) {
|
||||
continue;
|
||||
}
|
||||
$GLOBALS['__env'][trim(substr($line, 0, $pos))] = trim(trim(substr($line, $pos + 1)), "\"'");
|
||||
}
|
||||
}
|
||||
|
||||
if (! function_exists('env')) {
|
||||
function env(string $key = '', $default = null)
|
||||
{
|
||||
return $GLOBALS['__env'][$key] ?? $default;
|
||||
}
|
||||
}
|
||||
|
||||
$dbConfFile = $root . 'config' . DIRECTORY_SEPARATOR . 'database.php';
|
||||
if (! is_file($dbConfFile)) {
|
||||
fwrite(STDERR, "找不到配置文件: {$dbConfFile}\n");
|
||||
exit(1);
|
||||
}
|
||||
$dbConf = include $dbConfFile;
|
||||
$conn = $dbConf['connections'][$dbConf['default'] ?? 'mysql'] ?? [];
|
||||
|
||||
/* ------------------------- 2. 连库 ------------------------- */
|
||||
|
||||
$dsn = sprintf(
|
||||
'mysql:host=%s;port=%d;dbname=%s;charset=%s',
|
||||
$conn['hostname'] ?? '127.0.0.1',
|
||||
(int) ($conn['hostport'] ?? 3306),
|
||||
$conn['database'] ?? '',
|
||||
$conn['charset'] ?: 'utf8mb4'
|
||||
);
|
||||
|
||||
try {
|
||||
$pdo = new PDO($dsn, $conn['username'] ?? 'root', $conn['password'] ?? '', [
|
||||
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
|
||||
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
|
||||
PDO::ATTR_EMULATE_PREPARES => false,
|
||||
]);
|
||||
} catch (Throwable $e) {
|
||||
fwrite(STDERR, '数据库连接失败: ' . $e->getMessage() . "\n");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
$prefix = (string) ($conn['prefix'] ?? '');
|
||||
$powerTable = $prefix . 'backend_power';
|
||||
$rolePowerTable = $prefix . 'backend_role_power';
|
||||
$shopTable = $prefix . 'common_shop';
|
||||
|
||||
echo "[连接] {$conn['hostname']}:" . ($conn['hostport'] ?? 3306)
|
||||
. " / {$conn['database']} (表前缀 {$prefix})\n";
|
||||
|
||||
/* ------------------------- 3. 菜单 / 权限节点 + 角色授权 ------------------------- */
|
||||
|
||||
$tableExists = static function (string $table) use ($pdo): bool {
|
||||
return (bool) $pdo->query("SHOW TABLES LIKE '{$table}'")->fetchColumn();
|
||||
};
|
||||
|
||||
if (! $tableExists($powerTable)) {
|
||||
echo "[权限] {$powerTable} 不存在,跳过\n";
|
||||
echo "[数据表] {$shopTable} 不存在,跳过\n";
|
||||
exit(0);
|
||||
}
|
||||
|
||||
$stmt = $pdo->prepare(
|
||||
"SELECT `id`, `name`, `title` FROM `{$powerTable}`
|
||||
WHERE `name` = 'shop' OR `name` LIKE 'shop:%'
|
||||
ORDER BY `id`"
|
||||
);
|
||||
$stmt->execute();
|
||||
$powers = $stmt->fetchAll();
|
||||
|
||||
if (! $powers) {
|
||||
echo "[权限] 没有 shop 相关权限节点,跳过\n";
|
||||
} else {
|
||||
echo '[权限] 命中 ' . count($powers) . " 个节点:\n";
|
||||
foreach ($powers as $row) {
|
||||
echo sprintf(" #%d %s (%s)\n", (int) $row['id'], $row['name'], $row['title']);
|
||||
}
|
||||
|
||||
$ids = array_map('intval', array_column($powers, 'id'));
|
||||
$in = implode(',', $ids);
|
||||
|
||||
$hasRolePower = $tableExists($rolePowerTable);
|
||||
$grantRows = $hasRolePower
|
||||
? (int) $pdo->query("SELECT COUNT(*) FROM `{$rolePowerTable}` WHERE `power_id` IN ({$in})")->fetchColumn()
|
||||
: 0;
|
||||
$powerRows = (int) $pdo->query("SELECT COUNT(*) FROM `{$powerTable}` WHERE `id` IN ({$in}) OR `pid` IN ({$in})")->fetchColumn();
|
||||
|
||||
echo "[授权] {$rolePowerTable} 待删 {$grantRows} 条\n";
|
||||
echo "[权限] {$powerTable} 待删 {$powerRows} 条(含挂在它们下面的子按钮)\n";
|
||||
|
||||
if ($force) {
|
||||
$pdo->beginTransaction();
|
||||
try {
|
||||
if ($hasRolePower) {
|
||||
$pdo->exec("DELETE FROM `{$rolePowerTable}` WHERE `power_id` IN ({$in})");
|
||||
}
|
||||
$affected = $pdo->exec("DELETE FROM `{$powerTable}` WHERE `id` IN ({$in}) OR `pid` IN ({$in})");
|
||||
$pdo->commit();
|
||||
echo "[执行] 已删授权 {$grantRows} 条、权限节点 {$affected} 条\n";
|
||||
} catch (Throwable $e) {
|
||||
$pdo->rollBack();
|
||||
fwrite(STDERR, '[失败] 权限清理回滚: ' . $e->getMessage() . "\n");
|
||||
exit(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* ------------------------- 4. 数据表 ------------------------- */
|
||||
|
||||
if ($pdo->query("SHOW TABLES LIKE '{$shopTable}'")->fetchColumn()) {
|
||||
$rows = (int) $pdo->query("SELECT COUNT(*) FROM `{$shopTable}`")->fetchColumn();
|
||||
echo "[数据表] {$shopTable} 存在,共 {$rows} 行\n";
|
||||
if ($force) {
|
||||
$pdo->exec("DROP TABLE `{$shopTable}`");
|
||||
echo "[执行] 已 DROP TABLE `{$shopTable}`\n";
|
||||
}
|
||||
} else {
|
||||
echo "[数据表] {$shopTable} 不存在,跳过\n";
|
||||
}
|
||||
|
||||
echo $force
|
||||
? "[完成] 内置商城残留已清理。后台菜单即时生效(MenuRegistry 每次请求读库,无缓存)\n"
|
||||
: "[提示] 以上为预演结果,未做任何改动。确认后执行: php scripts/purge_builtin_shop.php --force\n";
|
||||
@@ -0,0 +1,274 @@
|
||||
<?php
|
||||
/**
|
||||
* 一次性脚本:把 backend_power 的 id 重排成「1..N 连续、父节点编号小于子节点」。
|
||||
*
|
||||
* 背景:account 权限表的 id 是历史累积出来的 —— 内置商城下线挖掉了 #35、系统信息页下线挖掉了 #84,
|
||||
* 后补的节点又只能往后加到 100+,于是有了 100/101/104/105 这种看起来像乱码的编号,
|
||||
* 且 100+ 的节点在文件里排在中间位置,读 install.sql 时没法一眼看出父子结构。
|
||||
* 现在按「深度优先遍历」重编号:顶层目录按 sort 排,每个父节点的 children 紧跟其后,
|
||||
* 编号天然满足 id(parent) < id(child),对着行号就能看出菜单树。
|
||||
*
|
||||
* 两条必须同步的事:
|
||||
* 1. backend_role_power.power_id —— 不同步就会变成指向空 id 的孤儿行,角色权限直接失效
|
||||
* 2. 表的 AUTO_INCREMENT —— 不跟上会导致下一个新增节点 id 撞车
|
||||
*
|
||||
* 安全机制:
|
||||
* - 默认 dry-run,只打印映射表,加 --force 才动库
|
||||
* - 动库前整表备份到 {prefix}backend_power_reindex_bak(已存在则不覆盖,直接中止并提示)
|
||||
* - 全部 UPDATE 包在一个事务里,失败整体回滚
|
||||
* - 主键重编号走「先整体搬到高位偏移区间 → 再落回 1..N」两阶段,避开唯一键冲突。
|
||||
* 不能用负数做中间态:power.id / pid 都是 int unsigned,负值直接 1264 越界。
|
||||
*
|
||||
* 幂等:重排后再跑,newId 与 oldId 全等,提示无需处理。
|
||||
*
|
||||
* 用法:
|
||||
* php scripts/reindex_backend_power.php # 预演
|
||||
* php scripts/reindex_backend_power.php --force # 执行
|
||||
* php scripts/reindex_backend_power.php --force --drop-bak # 备份表已存在且确认无用,删掉它再执行
|
||||
*/
|
||||
declare(strict_types=1);
|
||||
|
||||
$root = dirname(__DIR__) . DIRECTORY_SEPARATOR;
|
||||
$force = in_array('--force', $argv ?? [], true);
|
||||
$dropBak = in_array('--drop-bak', $argv ?? [], true);
|
||||
|
||||
echo $force
|
||||
? "[模式] 执行:会改 backend_power 主键,请先确认备份可用\n"
|
||||
: "[模式] 预演(dry-run):只算映射不动库,加 --force 才执行\n";
|
||||
|
||||
/* ------------------------- 读配置:极简 env() + include database.php ------------------------- */
|
||||
|
||||
$GLOBALS['__env'] = [];
|
||||
foreach ([$root . '.env', $root . '.env.local'] as $envFile) {
|
||||
if (! is_file($envFile)) {
|
||||
continue;
|
||||
}
|
||||
foreach (file($envFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) as $line) {
|
||||
$line = trim($line);
|
||||
if ($line === '' || $line[0] === '#') {
|
||||
continue;
|
||||
}
|
||||
$pos = strpos($line, '=');
|
||||
if ($pos === false) {
|
||||
continue;
|
||||
}
|
||||
$GLOBALS['__env'][trim(substr($line, 0, $pos))] = trim(trim(substr($line, $pos + 1)), "\"'");
|
||||
}
|
||||
}
|
||||
|
||||
if (! function_exists('env')) {
|
||||
function env(string $key = '', $default = null)
|
||||
{
|
||||
return $GLOBALS['__env'][$key] ?? $default;
|
||||
}
|
||||
}
|
||||
|
||||
$dbConfFile = $root . 'config' . DIRECTORY_SEPARATOR . 'database.php';
|
||||
if (! is_file($dbConfFile)) {
|
||||
fwrite(STDERR, "找不到配置文件: {$dbConfFile}\n");
|
||||
exit(1);
|
||||
}
|
||||
$dbConf = include $dbConfFile;
|
||||
$conn = $dbConf['connections'][$dbConf['default'] ?? 'mysql'] ?? [];
|
||||
|
||||
/* ------------------------- 连库 ------------------------- */
|
||||
|
||||
$dsn = sprintf(
|
||||
'mysql:host=%s;port=%d;dbname=%s;charset=%s',
|
||||
$conn['hostname'] ?? '127.0.0.1',
|
||||
(int) ($conn['hostport'] ?? 3306),
|
||||
$conn['database'] ?? '',
|
||||
$conn['charset'] ?: 'utf8mb4'
|
||||
);
|
||||
|
||||
try {
|
||||
$pdo = new PDO($dsn, $conn['username'] ?? 'root', $conn['password'] ?? '', [
|
||||
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
|
||||
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
|
||||
PDO::ATTR_EMULATE_PREPARES => false,
|
||||
]);
|
||||
} catch (Throwable $e) {
|
||||
fwrite(STDERR, '数据库连接失败: ' . $e->getMessage() . "\n");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
$prefix = (string) ($conn['prefix'] ?? '');
|
||||
$powerTable = $prefix . 'backend_power';
|
||||
$rolePowerTable = $prefix . 'backend_role_power';
|
||||
$bakTable = $prefix . 'backend_power_reindex_bak';
|
||||
|
||||
echo '[连接] ' . ($conn['hostname'] ?? '') . ':' . ($conn['hostport'] ?? 3306)
|
||||
. ' / ' . ($conn['database'] ?? '') . " (表前缀 {$prefix})\n";
|
||||
|
||||
$tableExists = static function (string $table) use ($pdo): bool {
|
||||
return (bool) $pdo->query("SHOW TABLES LIKE '{$table}'")->fetchColumn();
|
||||
};
|
||||
|
||||
if (! $tableExists($powerTable)) {
|
||||
echo "[中止] {$powerTable} 不存在\n";
|
||||
exit(0);
|
||||
}
|
||||
|
||||
/* ------------------------- 1. 读全表,按 DFS 算新编号 ------------------------- */
|
||||
|
||||
$rows = $pdo->query(
|
||||
"SELECT `id`, `pid`, `sort`, `name`, `title` FROM `{$powerTable}` ORDER BY `pid`, `sort`, `id`"
|
||||
)->fetchAll();
|
||||
|
||||
if (! $rows) {
|
||||
echo "[中止] {$powerTable} 是空表\n";
|
||||
exit(0);
|
||||
}
|
||||
|
||||
$children = [];
|
||||
$exists = [];
|
||||
foreach ($rows as $r) {
|
||||
$exists[(int) $r['id']] = true;
|
||||
}
|
||||
|
||||
$orphans = [];
|
||||
foreach ($rows as $r) {
|
||||
$pid = (int) $r['pid'];
|
||||
// pid 指向不存在的行(历史脏数据)当根节点处理,避免整棵树漏掉这一支
|
||||
if ($pid !== 0 && ! isset($exists[$pid])) {
|
||||
$orphans[] = (int) $r['id'] . '(' . $r['name'] . ',原 pid=' . $pid . ')';
|
||||
$pid = 0;
|
||||
}
|
||||
$children[$pid][] = $r;
|
||||
}
|
||||
foreach ($children as &$list) {
|
||||
usort($list, static function (array $a, array $b): int {
|
||||
return ((int) $a['sort'] <=> (int) $b['sort']) ?: ((int) $a['id'] <=> (int) $b['id']);
|
||||
});
|
||||
}
|
||||
unset($list);
|
||||
|
||||
/**
|
||||
* 深度优先:父先分配编号,紧接着分配它的孩子——保证 id(parent) < id(child)
|
||||
*
|
||||
* @param int $pid
|
||||
* @param array $children
|
||||
* @param array $map oldId => newId
|
||||
* @param int $next
|
||||
* @return void
|
||||
*/
|
||||
$walk = function (int $pid, array &$children, array &$map, int &$next) use (&$walk): void {
|
||||
foreach ($children[$pid] ?? [] as $row) {
|
||||
$old = (int) $row['id'];
|
||||
$map[$old] = $next++;
|
||||
$walk($old, $children, $map, $next);
|
||||
}
|
||||
};
|
||||
|
||||
$map = [];
|
||||
$next = 1;
|
||||
$walk(0, $children, $map, $next);
|
||||
|
||||
if ($orphans) {
|
||||
echo '[提示] 发现 pid 悬空的行,已当根节点处理:' . implode('、', $orphans) . "\n";
|
||||
}
|
||||
|
||||
$total = count($rows);
|
||||
$changed = 0;
|
||||
$planLine = [];
|
||||
foreach ($rows as $r) {
|
||||
$old = (int) $r['id'];
|
||||
$new = $map[$old];
|
||||
if ($old !== $new) {
|
||||
$changed++;
|
||||
}
|
||||
$planLine[] = sprintf(' %3d → %3d %-24s %s', $old, $new, $r['name'], $r['title']);
|
||||
}
|
||||
|
||||
echo "[计划] 共 {$total} 个节点,编号变动 {$changed} 个\n";
|
||||
echo implode("\n", $planLine) . "\n";
|
||||
|
||||
if ($changed === 0) {
|
||||
echo "[无需处理] 编号已经是连续的 DFS 顺序\n";
|
||||
exit(0);
|
||||
}
|
||||
|
||||
$hasRolePower = $tableExists($rolePowerTable);
|
||||
$grantRows = $hasRolePower
|
||||
? (int) $pdo->query("SELECT COUNT(*) FROM `{$rolePowerTable}`")->fetchColumn()
|
||||
: 0;
|
||||
echo "[计划] 同步 {$rolePowerTable} 的 role_id/power_id 映射(共 {$grantRows} 条授权)\n";
|
||||
echo '[计划] AUTO_INCREMENT 置为 ' . ($total + 1) . "\n";
|
||||
|
||||
if (! $force) {
|
||||
echo "[提示] 以上是预演结果,未做任何改动。确认后执行: php scripts/reindex_backend_power.php --force\n";
|
||||
exit(0);
|
||||
}
|
||||
|
||||
/* ------------------------- 2. 备份 + 事务内重编号 ------------------------- */
|
||||
|
||||
if ($tableExists($bakTable)) {
|
||||
// 上一轮跑挂了(回滚后备份还在)或跑完忘了收尾,都可以靠 --drop-bak 直接重来
|
||||
if (! $dropBak) {
|
||||
fwrite(STDERR, "[中止] 备份表 {$bakTable} 已存在,先确认它是否可以删除后再跑,避免覆盖历史备份\n");
|
||||
fwrite(STDERR, "[提示] 确认无用后重跑: php scripts/reindex_backend_power.php --force --drop-bak\n");
|
||||
exit(1);
|
||||
}
|
||||
$pdo->exec("DROP TABLE IF EXISTS `{$bakTable}`");
|
||||
$pdo->exec("DROP TABLE IF EXISTS `{$bakTable}_role`");
|
||||
echo "[备份] 已按 --drop-bak 删除旧备份\n";
|
||||
}
|
||||
|
||||
$pdo->exec("CREATE TABLE `{$bakTable}` AS SELECT * FROM `{$powerTable}`");
|
||||
$pdo->exec("CREATE TABLE `{$bakTable}_role` AS SELECT * FROM `{$rolePowerTable}`");
|
||||
echo "[备份] {$bakTable} 与 {$bakTable}_role 已建立\n";
|
||||
|
||||
// 中间态偏移量:id/pid 是 int unsigned,最大 4294967295,这里加到 100 万量级足够安全
|
||||
$offset = 1000000;
|
||||
|
||||
$pdo->beginTransaction();
|
||||
try {
|
||||
// 阶段一:整表搬到 OFFSET 高位区间。id/pid 都是 unsigned,负数中间态不行,
|
||||
// 只能先整体抬到一个没人用的区间;自重编号可能互换(85→2、2→3),逐行改必然撞唯一键。
|
||||
$updPower = $pdo->prepare("UPDATE `{$powerTable}` SET `id` = ?, `pid` = ? WHERE `id` = ?");
|
||||
foreach ($map as $old => $new) {
|
||||
$row = null;
|
||||
foreach ($rows as $r) {
|
||||
if ((int) $r['id'] === $old) {
|
||||
$row = $r;
|
||||
break;
|
||||
}
|
||||
}
|
||||
$oldPid = (int) $row['pid'];
|
||||
$newPid = $map[$oldPid] ?? 0; // 父不在本表(悬空)则归零
|
||||
$updPower->execute([$new + $offset, $newPid === 0 ? 0 : $newPid + $offset, $old]);
|
||||
}
|
||||
|
||||
if ($hasRolePower) {
|
||||
$updGrant = $pdo->prepare("UPDATE `{$rolePowerTable}` SET `power_id` = ? WHERE `power_id` = ?");
|
||||
foreach ($map as $old => $new) {
|
||||
$updGrant->execute([$new + $offset, $old]);
|
||||
}
|
||||
}
|
||||
|
||||
// 阶段二:把偏移量减掉,落到最终的 1..N。
|
||||
// 必须带 WHERE 限定「只动搬上去的那些」:role_power 里可能早就有指向不存在节点的孤儿行
|
||||
// (历史遗留,不在 $map 里),无差别减会让它们变成负数。
|
||||
$pdo->exec("UPDATE `{$powerTable}` SET `id` = `id` - {$offset}"
|
||||
. ", `pid` = IF(`pid` = 0, 0, `pid` - {$offset}) WHERE `id` > {$offset}");
|
||||
if ($hasRolePower) {
|
||||
$pdo->exec("UPDATE `{$rolePowerTable}` SET `power_id` = `power_id` - {$offset}"
|
||||
. " WHERE `power_id` > {$offset}");
|
||||
}
|
||||
|
||||
$pdo->commit();
|
||||
echo "[执行] 主键重编号完成\n";
|
||||
} catch (Throwable $e) {
|
||||
$pdo->rollBack();
|
||||
fwrite(STDERR, '[失败] 已回滚:' . $e->getMessage() . "\n");
|
||||
fwrite(STDERR, "[提示] 如需还原: DROP TABLE `{$powerTable}`; RENAME TABLE `{$bakTable}` TO `{$powerTable}`;\n");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
$pdo->exec("ALTER TABLE `{$powerTable}` AUTO_INCREMENT = " . ($total + 1));
|
||||
echo '[执行] AUTO_INCREMENT 已置为 ' . ($total + 1) . "\n";
|
||||
|
||||
// 复核
|
||||
$bad = $pdo->query("SELECT COUNT(*) FROM `{$powerTable}` p LEFT JOIN `{$powerTable}` f ON p.`pid` = f.`id` WHERE p.`pid` <> 0 AND f.`id` IS NULL")->fetchColumn();
|
||||
echo "[复核] 悬空 pid 行数:{$bad}\n";
|
||||
echo "[完成] 建议顺手把 install.sql 的种子同步成同一套编号(本次种子改的就是这个)\n";
|
||||
@@ -0,0 +1,185 @@
|
||||
<?php
|
||||
/**
|
||||
* 一次性脚本:把本地模板 zip 直接提交到官方市场(type=template)。
|
||||
* 配合 scripts/package_template.php 使用:先打包出 runtime/templates/<name>-<ver>.zip,
|
||||
* 再本脚本 POST 到 /appmall/api/addon/submit(与开发者控制台「提交应用」同一条 submit 链路)。
|
||||
*
|
||||
* 用法:
|
||||
* php scripts/submit_template.php <zip> <token> [api_url] [--title=...] [--price=...] [--category=...]
|
||||
* [--author=...] [--tags=...] [--logo=...] [--description=...] [--changelog=...]
|
||||
* [--require_framework=...] [-k]
|
||||
* - api_url 缺省 https://www.ywxapp.cn(生产);本地可用 http://localhost:端口
|
||||
* - -k 不校验 SSL(本地自签证书调试用)
|
||||
* - 元数据优先取命令行覆盖项,否则从 zip 内 template.json 读取
|
||||
*
|
||||
* 例:
|
||||
* php scripts/submit_template.php runtime/templates/myblog-1.0.0.zip YOUR_TOKEN
|
||||
* php scripts/submit_template.php runtime/templates/myblog-1.0.0.zip YOUR_TOKEN http://localhost:8080 -k
|
||||
*
|
||||
* 提交接口返回:code=1 成功(读 msg),code=0 失败(读 msg/message)。
|
||||
*/
|
||||
declare(strict_types=1);
|
||||
|
||||
// ===================== 解析参数 =====================
|
||||
$insecure = false;
|
||||
$opts = [
|
||||
'title' => null,
|
||||
'price' => null,
|
||||
'category' => null,
|
||||
'author' => null,
|
||||
'tags' => null,
|
||||
'logo' => null,
|
||||
'description' => null,
|
||||
'changelog' => null,
|
||||
'require_framework' => null,
|
||||
];
|
||||
$pos = [];
|
||||
foreach (array_slice($argv, 1) as $a) {
|
||||
if ($a === '-k') {
|
||||
$insecure = true;
|
||||
continue;
|
||||
}
|
||||
if (strpos($a, '--') === 0 && strpos($a, '=') !== false) {
|
||||
[$k, $v] = explode('=', $a, 2);
|
||||
$k = substr($k, 2);
|
||||
if (array_key_exists($k, $opts)) {
|
||||
$opts[$k] = $v;
|
||||
} else {
|
||||
fwrite(STDERR, "未知选项: --{$k}\n");
|
||||
exit(1);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
$pos[] = $a;
|
||||
}
|
||||
|
||||
$zip = $pos[0] ?? '';
|
||||
$token = $pos[1] ?? '';
|
||||
$apiUrl = $pos[2] ?? 'https://www.ywxapp.cn';
|
||||
|
||||
if ($zip === '' || $token === '') {
|
||||
fwrite(STDERR, "用法: php scripts/submit_template.php <zip> <token> [api_url] [--title=...] ... [-k]\n");
|
||||
exit(1);
|
||||
}
|
||||
$zip = realpath($zip);
|
||||
if (!is_file($zip)) {
|
||||
fwrite(STDERR, "zip 不存在: {$pos[0]}\n");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
// ===================== 读取 zip 内 template.json =====================
|
||||
if (!class_exists('ZipArchive')) {
|
||||
fwrite(STDERR, "ZipArchive 扩展不可用\n");
|
||||
exit(1);
|
||||
}
|
||||
$z = new ZipArchive();
|
||||
if ($z->open($zip) !== true) {
|
||||
fwrite(STDERR, "无法打开 zip: {$zip}\n");
|
||||
exit(1);
|
||||
}
|
||||
$jsonName = null;
|
||||
for ($i = 0; $i < $z->numFiles; $i++) {
|
||||
$n = $z->statIndex($i)['name'];
|
||||
if ($n === 'template.json' || preg_match('#^templates/[^/]+/template\.json$#', $n)) {
|
||||
$jsonName = $n;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if ($jsonName === null) {
|
||||
fwrite(STDERR, "zip 内缺少 template.json(须为 templates/<名称>/template.json 结构)\n");
|
||||
exit(1);
|
||||
}
|
||||
$meta = json_decode((string) $z->getFromName($jsonName), true);
|
||||
$z->close();
|
||||
if (!is_array($meta)) {
|
||||
fwrite(STDERR, "template.json 解析失败\n");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
// 模板标识:根目录 template.json 取 meta.name;templates/<name>/template.json 取目录名
|
||||
if (preg_match('#^template\.json$#', $jsonName)) {
|
||||
$name = $meta['name'] ?? '';
|
||||
} else {
|
||||
$segs = explode('/', $jsonName);
|
||||
$name = $segs[1] ?? ($meta['name'] ?? '');
|
||||
}
|
||||
|
||||
// ===================== 元数据合并(命令行覆盖优先) =====================
|
||||
$version = $meta['version'] ?? '';
|
||||
$title = $opts['title'] ?? ($meta['title'] ?? $name);
|
||||
$author = $opts['author'] ?? ($meta['author'] ?? '');
|
||||
$category = $opts['category'] ?? ($meta['category'] ?? '');
|
||||
$tags = $opts['tags'] ?? ($meta['tags'] ?? '');
|
||||
$price = (string) ($opts['price'] ?? ($meta['price'] ?? 0));
|
||||
$logo = $opts['logo'] ?? ($meta['logo'] ?? '');
|
||||
$description = $opts['description'] ?? ($meta['description'] ?? '');
|
||||
$changelog = $opts['changelog'] ?? ($meta['changelog'] ?? '');
|
||||
$requireFramework = $opts['require_framework'] ?? ($meta['require_framework'] ?? '');
|
||||
|
||||
// 基础校验(与 api/Market::submit 一致)
|
||||
if (!preg_match('/^[a-zA-Z0-9_]+$/', $name)) {
|
||||
fwrite(STDERR, "标识格式不正确(仅字母数字下划线): {$name}\n");
|
||||
exit(1);
|
||||
}
|
||||
if (!preg_match('/^\d+\.\d+\.\d+$/', $version)) {
|
||||
fwrite(STDERR, "版本号格式不正确(需 x.y.z): {$version}\n");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
echo "[提交] name={$name} version={$version} title={$title}\n";
|
||||
echo "[提交] 目标: {$apiUrl}/appmall/api/addon/submit (type=template)\n";
|
||||
|
||||
// ===================== 发起 multipart 提交 =====================
|
||||
if (!function_exists('curl_init')) {
|
||||
fwrite(STDERR, "curl 扩展不可用\n");
|
||||
exit(1);
|
||||
}
|
||||
$post = [
|
||||
'token' => $token,
|
||||
'type' => 'template',
|
||||
'name' => $name,
|
||||
'version' => $version,
|
||||
'title' => $title,
|
||||
'author' => $author,
|
||||
'category' => $category,
|
||||
'tags' => $tags,
|
||||
'price' => $price,
|
||||
'logo' => $logo,
|
||||
'description' => $description,
|
||||
'changelog' => $changelog,
|
||||
'require_framework' => $requireFramework,
|
||||
'file' => new CURLFile($zip, 'application/zip', basename($zip)),
|
||||
];
|
||||
|
||||
$ch = curl_init($apiUrl . '/appmall/api/addon/submit');
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_POST => true,
|
||||
CURLOPT_POSTFIELDS => $post,
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_TIMEOUT => 3600,
|
||||
CURLOPT_CONNECTTIMEOUT => 30,
|
||||
CURLOPT_SSL_VERIFYPEER => !$insecure,
|
||||
CURLOPT_SSL_VERIFYHOST => $insecure ? 0 : 2,
|
||||
]);
|
||||
$resp = curl_exec($ch);
|
||||
if ($resp === false) {
|
||||
fwrite(STDERR, "curl 错误: " . curl_error($ch) . "\n");
|
||||
curl_close($ch);
|
||||
exit(1);
|
||||
}
|
||||
$httpCode = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
curl_close($ch);
|
||||
|
||||
$data = json_decode((string) $resp, true);
|
||||
if (!is_array($data)) {
|
||||
fwrite(STDERR, "非 JSON 响应 (HTTP {$httpCode}):\n{$resp}\n");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
// submit 成功 code=1
|
||||
if (($data['code'] ?? 0) === 1) {
|
||||
echo "提交成功: " . ($data['msg'] ?? '') . "\n";
|
||||
exit(0);
|
||||
}
|
||||
echo "提交失败: " . ($data['msg'] ?? ($data['message'] ?? "HTTP {$httpCode}")) . "\n";
|
||||
exit(1);
|
||||
Reference in new issue
Block a user