Files
YwxAppThink/public/static/backend/modules/addon.js
T
yangwx 18101cf11a 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(市场 / 开发者中心 / 审核 / 分润)
2026-10-22 00:53:39 +08:00

336 lines
16 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
layui.define(['http', 'setter', 'auth', 'upload', 'form', 'layer', 'table'], function (exports) {
var table = layui.table, form = layui.form, layer = layui.layer, $ = layui.$, upload = layui.upload, http = layui.http
, auth = layui.auth;
var _savedPerms = (layui.data('backend').permission) || [];
auth.init(_savedPerms);
auth.setController('addon');
var ADDON_DEV = window.ADDON_DEV || 0;
// 清空后台侧栏菜单的本地缓存(Layui data 存储于 setter.appName 下,键为 'menus')。
// 插件卸载/安装/启停会改变 admin_power 菜单,必须清掉缓存让侧栏重新向后端拉取,
// 否则侧栏会一直显示(或一直缺)已变更插件的菜单。
function clearMenuCache() {
var appName = (layui.setter && layui.setter.appName) || 'backend';
layui.data(appName, { key: 'menus', remove: true });
// 兼容旧键名(历史版本曾写入 'menu')
layui.data(appName, { key: 'menu', remove: true });
}
// 安装/升级失败的可读化:后端 AddonService::validateDependencies() 收集式校验后,
// 会在 data.problems 里带上完整清单(缺哪些依赖、该装还是该升),这里逐条展示并给
// 「去应用市场安装」入口,避免只弹出一长串难以扫读的文案。
function showDependencyProblems(res) {
var data = res && res.data;
var list = (data && data.problems) || [];
if (!list.length) { return false; }
var badgeText = { addon: '插件', extension: 'PHP 扩展', framework: '框架' };
var rows = list.map(function (it) {
var badge = badgeText[it.type] || '依赖';
return '<li style="padding:6px 0;border-bottom:1px dashed #eee;">'
+ '<span class="layui-badge layui-bg-cyan" style="margin-right:6px;">' + badge + '</span>'
+ $('<div>').text(it.text).html()
+ '</li>';
}).join('');
var missing = (data && data.missing_addons) || [];
var extra = missing.length
? '<div style="margin-top:12px;"><a class="layui-btn layui-btn-normal layui-btn-xs" href="market" target="_blank">'
+ '去应用市场安装缺失插件(' + $('<span>').text(missing.join('、')).html() + ')</a></div>'
: '';
layer.open({
type: 1,
title: '依赖检查未通过(' + list.length + ' 项)',
area: ['620px', 'auto'],
content: '<div style="padding:16px 18px;font-size:13px;">'
+ '<ul style="list-style:none;margin:0;padding:0;">' + rows + '</ul>' + extra + '</div>',
btn: ['知道了']
});
return true;
}
// ===== 渲染核心表格 =====
var { access_token, refresh_token } = layui.setter.getTokens();
table.render({
elem: '#dataTable'
, url: "index" // 此处应填写你真实的后端接口地址
, cols: [[
{ field: 'title', title: '插件名称', minWidth: 180 },
{ field: 'name', title: '标识', width: 120 },
{ field: 'author', title: '开发者', width: 120 }
, { field: 'version', title: '版本号', width: 170, align: 'center', templet: '#versionTpl' }
, { field: 'description', title: '功能描述', minWidth: 250 }
, { field: 'status', title: '运行状态', width: 110, align: 'center', templet: '#switchTpl' } // 绑定开关模板
, { fixed: 'right', title: '操作', toolbar: '#operateTpl', width: 300, align: 'right' } // 绑定操作栏模板
]]
, page: true // 开启分页
, limit: 10
, limits: [10, 20, 50]
, skin: 'line' // 设定表格风格为行边框风格
, response: {
statusName: 'code' // 状态码字段
, statusCode: 0 // 成功状态码
, msgName: 'message' // 提示信息字段(后端返回 message)
, countName: 'count' // 数据总数字段
, dataName: 'data' // 数据列表字段
}
, done: function (res, curr, count) {
var view = $('#dataTable').next('.layui-table-view');
auth.filterDom(view);
auth.filterDom('.layui-btn[data-perm]');
// 开发模式关闭时隐藏操作列的「设计」按钮
// if (!ADDON_DEV) { $('.dev-design').remove(); }
}
});
// ===== 事件绑定(提前到这里:即便下方 upload/form 初始化出错,行内按钮依然可用) =====
table.on('toolbar(dataTable)', function (elem) {
switch (elem.event) {
case 'dataCreate':
active.dataCreate({ pid: 0, type: 1 });
break;
case 'dataRecybin':
active.dataRecybin();
break;
}
});
table.on('tool(dataTable)', function (elem) {
var data = elem.data;
// 插件标识:优先用 name,缺失时回退到 id(后端 index 两种都返回)
var addonName = data.name || data.id || '';
switch (elem.event) {
case 'update':
active.dataEdit(data);
break;
case 'config':
if (!addonName) { layer.msg('缺少插件标识', { icon: 2 }); break; }
location.href = 'setting?addon=' + encodeURIComponent(addonName);
break;
case 'upgrade':
if (!addonName) { layer.msg('缺少插件标识', { icon: 2 }); break; }
active.upgrade(addonName, data.latest_version || '');
break;
case 'uninstall':
if (!addonName) { layer.msg('缺少插件标识', { icon: 2 }); break; }
active.uninstall(addonName);
break;
case 'more':
var that = this;
var dropdown = layui.dropdown;
// 水平对齐方式
dropdown.render({
elem: that,
align: 'center', // align 已配置在元素 `lay-options` 属性上
show: true,accordion : true,
data: [{
title: '设计',
id: 'design'
}, {
title: '打包',
id: 'pack'
}, {
title: '发布',
id: 'submitOfficial'
}],
click: function (data, othis) {
switch (data.id) {
case 'pack':
// 触发后端 addon::pack 下载该插件的 zip 包(addon/temp/<name>-<version>.zip)
if (!addonName) { layer.msg('缺少插件标识', { icon: 2 }); break; }
window.location.href = 'pack?name=' + encodeURIComponent(addonName);
break;
case 'design':
if (!addonName) { layer.msg('缺少插件标识', { icon: 2 }); break; }
location.href = 'design?addon=' + encodeURIComponent(addonName);
break;
case 'submitOfficial':
// 发布到官方市场:打包本机已装插件并 POST 到官方提交接口,进入待审核
if (!addonName) { layer.msg('缺少插件标识', { icon: 2 }); break; }
layer.confirm('确定将「' + (data.title || addonName) + '」发布到官方市场吗?\n提交后需官方审核通过才公开上架。', function (index) {
layer.close(index);
var loadIdx = layer.load(1, { shade: [0.3, '#fff'] });
http.post('submitOfficial', { name: addonName })
.then(function (res) {
layer.close(loadIdx);
if (res.code === 0) {
layer.msg(res.message || '已提交,等待官方审核', { icon: 1 });
} else {
layer.msg(res.message || res.msg || '提交失败', { icon: 2 });
}
})
.catch(function () {
layer.close(loadIdx);
layer.msg('请求失败', { icon: 2 });
});
});
break;
default:
break;
}
},
});
break;
case 'default':
console.warn('未处理的操作事件:', elem.event, data);
}
});
// 顶部功能按钮 + 行内操作共用
var active = {
// 一键升级到市场最新版(version 为空时由服务端取最新版)
upgrade: function (name, version) {
var loadIdx = layer.load();
// 升级含下载+备份+覆盖,放宽超时到 10 分钟,避免半途被中断
http.post('upgrade', { name: name, version: version }, { timeout: 600000 })
.then(function (res) {
layer.close(loadIdx);
if (res.code === 0) {
layer.msg(res.message || '升级成功', { icon: 1 });
clearMenuCache(); // 插件菜单可能变更
table.reload('dataTable');
} else if (!showDependencyProblems(res)) {
layer.msg(res.message || '升级失败', { icon: 2 });
}
})
.catch(function () {
layer.close(loadIdx);
layer.msg('请求失败', { icon: 2 });
});
},
uninstall: function (name) {
// 卸载选项:是否备份数据 / 是否删除数据表(对应 AddonService::uninstall() 的 options)
layer.open({
type: 1,
title: '卸载插件:' + name,
area: ['460px', 'auto'],
content: '<div style="padding:16px 18px;">'
+ '<label style="display:block;margin-bottom:10px;">'
+ '<input type="checkbox" id="unin_backup" checked> 卸载前备份数据(SQL 落到 runtime/addon/backup/)'
+ '</label>'
+ '<label style="display:block;">'
+ '<input type="checkbox" id="unin_delete" checked> 同时 DROP 插件数据表(不勾选则保留数据)'
+ '</label>'
+ '<div class="layui-word-aux" style="margin-top:10px;">'
+ '提示:删除数据表后数据不可恢复,建议保持「备份数据」勾选。</div>'
+ '</div>',
btn: ['下一步', '取消'],
yes: function (index) {
var opts = {
backup_data: document.getElementById('unin_backup').checked ? 1 : 0,
delete_data: document.getElementById('unin_delete').checked ? 1 : 0
};
layer.close(index);
layer.prompt({
formType: 1
, title: '敏感操作,请验证口令'
}, function (value, index2) {
layer.close(index2);
http.delete('uninstall', {
name: name,
backup_data: opts.backup_data,
delete_data: opts.delete_data
})
.then((res) => {
if (res.code === 0) {
clearMenuCache(); // 插件菜单已删除,清掉侧栏菜单缓存
location.reload(); // 重新加载整页,侧栏立即刷新
} else {
layer.msg(res.msg || '操作失败');
}
});
}
}
});
},
batchDel: function () { // 批量卸载
var checkStatus = table.checkStatus('dataTable');
var data = checkStatus.data;
if (data.length === 0) {
layer.msg('请至少选中一项进行卸载', { icon: 0 });
return;
}
layer.confirm('确定要批量卸载选中的 ' + data.length + ' 个插件吗?', function (index) {
layer.msg('正在批量卸载...', { icon: 1 });
table.reload('dataTable');
layer.close(index);
});
},
refresh: function () { // 刷新表格
table.reload('dataTable');
layer.msg('已刷新列表', { icon: 1 });
}
};
$('.layui-btn').on('click', function () {
var type = $(this).data('type');
active[type] && active[type].call(this);
});
console.log(layui.setter.getTokens());
var inst = upload.render({
elem: '#buttonInstall',
field: 'file',
url: "upload", // 上传接口地址
accept: 'file', // 只允许上传文件
// 注意:不要设置 acceptMime。该属性会让浏览器文件选择框按 MIME 过滤,
// 而 zip 在本机常被识别为 application/x-zip-compressed / application/octet-stream,
// 导致选择框中插件包被置灰、无法选中。仅用 exts 在选完后校验后缀即可。
exts: 'zip', // 仅允许 zip 插件包(后端 AddonService 只支持 zip)
headers: { 'Authorization': `Bearer ${access_token}` }, // 鉴权
done: function (res) {
// 上传成功的回调
if (res.code == 0) {
layer.msg('插件安装成功!', { icon: 1 });
clearMenuCache(); // 插件菜单已变化,清掉侧栏菜单缓存
location.reload(); // 重新加载整页,侧栏立即刷新
} else if (!showDependencyProblems(res)) {
layer.msg('安装失败: ' + (res.message || '未知错误'), { icon: 2 });
}
},
error: function () {
// 上传失败的回调
// 当上传失败时,可在此处生成「重新上传」按钮,并执行该方法重新触发上传提交
/*
$('#btn').on('click', function(){
inst.upload();
})
*/
}
});
// ===== 监听顶部搜索表单提交 =====
form.on('submit(searchBtn)', function (data) {
var field = data.field; // 获取搜索表单的值
// 重载表格,将搜索条件传给后端
table.reload('dataTable', {
where: field,
page: { curr: 1 } // 搜索后回到第一页
});
return false; // 阻止表单默认跳转
});
// ===== 监听状态开关(启用/停用)的切换 =====
form.on('switch(statusSwitch)', function (obj) {
var name = this.value; // value 绑定的是插件名 d.id
var status = obj.elem.checked ? 1 : 0; // 根据开关状态赋值
var action = status ? 'enable' : 'disable';
var that = obj.othis;
http.post('toggle', { name: name, action: action })
.then(function (res) {
if (res.code === 0) {
layer.msg(status ? '已启用' : '已停用', { icon: 1 });
clearMenuCache(); // 菜单可见性已变化,清掉侧栏菜单缓存(下次导航即刷新)
} else {
layer.msg(res.message || '操作失败', { icon: 2 });
that.find('input').prop('checked', !status);
form.render();
}
})
.catch(function () {
layer.msg('请求失败', { icon: 2 });
that.find('input').prop('checked', !status);
form.render();
});
});
exports('addon', {}); // 输出模块接口
});