feat: 上线前安全加固与发布工程化;appmall 由插件迁移为独立应用

安全(P0)
- 发布包签名:新增 scripts/package_keygen.php、scripts/package_verify.php、
  ywxapp\service\PackageVerifier;FrameworkService::apply() 解压前强制验签
  (ywxapp.framework_package_verify,默认开启)
- JWT 收紧:config/jwt.php 移除公开默认密钥,JwtService::assertStrongSecret()
  校验非空 / 长度≥32 / 已知弱值;.env.production.example 键名由 JWT_SECRET_KEY 修正为 JWT_SECRET
- /api/v1/Article:免登录白名单收窄为只读接口,写操作字段白名单 + assertOwner() 越权校验
- 开发者令牌:查找接口不再回显明文、统一失败文案 + 限流
- TemplateInstaller:文件名白名单 + realpath 归一化 + 目录内断言,修复 Zip Slip;补 use ZipArchive
- 后台登录:接入验证码、失败锁定与登录限流(config/ratelimit.php)
- 缓存 / Session 驱动可由 .env 覆盖(CACHE_DRIVER / SESSION_TYPE),集群部署改 redis
- paycenter 支付日志开关改为 PAY_LOG,不再跟随 APP_DEBUG

工程化 / 交付
- 新增 CHANGELOG.md 与 .env.production.example;composer.lock 入库锁定依赖版本
- 插件离线安装 CLI 增加 --uid,后台上传安装透传 uid,付费插件授权校验可通过
- 授权拒绝文案区分「未携带 uid」与「未找到有效授权」,便于排障
- .gitignore 放行 .env.production.example / .env.addon.example / .gitattributes / public/.htaccess

结构
- appmall 由 addon/appmall 迁移为独立应用 app/appmall(市场 / 开发者中心 / 审核 / 分润)
This commit is contained in:
yangwx committed 2026-10-22 00:53:39 +08:00
1 parent 1d49e6f5ee
commit 18101cf11a
636 files changed
+58517 -9519

No files matched your search

+202
View File
@@ -0,0 +1,202 @@
<div class="layui-container">
<div class="admin-title">场景联动</div>
<div class="layui-text" style="margin-bottom:10px;color:#999;">
智慧生活自动化:设备状态(如湿度&gt;60)→ 动作(下发命令/HTTP回调/站内告警);支持定时(cron)与手动触发。
配置约 30s 后在 Broker 生效。
</div>
<div style="margin-bottom:10px;">
<button class="layui-btn layui-btn-sm" onclick="editScene()">+ 新建场景</button>
</div>
<table id="sceneTable" lay-filter="sceneTable"></table>
</div>
<!-- 行操作按钮模板 -->
<script type="text/html" id="barScene">
<a class="layui-btn layui-btn-xs" lay-event="edit">编辑</a>
<a class="layui-btn layui-btn-xs layui-btn-normal" lay-event="run">执行</a>
<a class="layui-btn layui-btn-xs layui-btn-primary" lay-event="log">日志</a>
{{# if(d.enabled){ }}
<a class="layui-btn layui-btn-xs layui-btn-warm" lay-event="toggle">停用</a>
{{# } else { }}
<a class="layui-btn layui-btn-xs layui-btn-warm" lay-event="toggle">启用</a>
{{# } }}
<a class="layui-btn layui-btn-xs layui-btn-danger" lay-event="del">删除</a>
</script>
<script>
layui.use(['layer', 'jquery', 'form', 'table', 'http', 'ywxapp'], function(){
var http = layui.http;
var $ = layui.jquery, layer = layui.layer, form = layui.form, table = layui.table;
var devices = {$devicesJson|raw};
var scenes = {$scenesJson|raw};
function esc(s){ return String(s==null?'':s).replace(/&/g,'&amp;').replace(/"/g,'&quot;').replace(/</g,'&lt;'); }
function fmtTime(ts){ if(!ts){return '—';} var d=new Date(Number(ts)*1000),p=function(n){return (n<10?'0':'')+n;}; return d.getFullYear()+'-'+p(d.getMonth()+1)+'-'+p(d.getDate())+' '+p(d.getHours())+':'+p(d.getMinutes()); }
function reload(){ table.reload('sceneTable'); }
function ok(res, then){ if(res.code===0){ then(); } else { layer.msg(res.message||'操作失败'); } }
// 触发器类型切换:schedule 显示 Cron,其余显示主题过滤
function renderTrgType(val){
if(val==='schedule'){ $('#trgCronWrap').show(); $('#trgStateWrap').hide(); }
else { $('#trgStateWrap').show(); $('#trgCronWrap').hide(); }
}
form.on('select(trgTypeSel)', function(obj){ renderTrgType(obj.value); });
// ================= 表格(本地数据渲染) =================
table.render({
elem: '#sceneTable',
data: scenes || [],
page: false,
limit: 100,
cols: [[
{ field: 'id', title: 'ID', width: 60, align: 'center' },
{ field: 'title', title: '场景名', minWidth: 140 },
{ field: 'icon', title: '图标', width: 80, align: 'center' },
{ field: 'trigger_count', title: '触发器', width: 90, align: 'center' },
{ field: 'action_count', title: '动作数', width: 90, align: 'center' },
{ field: 'enabled', title: '启用', width: 90, align: 'center', templet: function (d) {
return d.enabled ? '<span class="layui-badge layui-bg-green">启用</span>' : '<span class="layui-badge">停用</span>';
} },
{ field: 'create_at', title: '创建时间', width: 160, templet: function (d) { return fmtTime(d.create_at); } },
{ title: '操作', width: 300, align: 'center', toolbar: '#barScene' }
]]
});
table.on('tool(sceneTable)', function (obj) {
var d = obj.data;
if (obj.event === 'edit') {
http.get('/iot/backend/sceneDetail', {id:d.id}).then(function(res){
if(res.code===0){ editSceneForm(res.data); }
else { layer.msg(res.message||'加载失败'); }
});
} else if (obj.event === 'run') { runScene(d.id); }
else if (obj.event === 'log') { showLogs(d); }
else if (obj.event === 'toggle') { toggleScene(d.id, d.enabled ? 0 : 1); }
else if (obj.event === 'del') { delScene(d.id); }
});
// ================= 新建 / 编辑 =================
window.editScene = function(){ editSceneForm({}); };
function editSceneForm(row){
row = row || {};
var trig = (row.triggers&&row.triggers.length)?row.triggers[0]:{type:'device_state',topic:'/devices/+/state'};
var actions = row.actions||[{type:'send_cmd'}];
var actHtml = '';
actions.forEach(function(a,i){
actHtml += '<div class="layui-form-item">'
+ '<label class="layui-form-label">动作'+(i+1)+'</label>'
+ '<div class="layui-input-inline" style="width:110px;"><select name="act_type">'
+ '<option value="send_cmd" '+(a.type=='send_cmd'?'selected':'')+'>下发命令</option>'
+ '<option value="http" '+(a.type=='http'?'selected':'')+'>HTTP回调</option>'
+ '<option value="notify" '+(a.type=='notify'?'selected':'')+'>站内告警</option>'
+ '</select></div>'
+ '<div class="layui-input-inline" style="width:130px;"><select name="act_uuid"><option value="">选择设备</option>';
devices.forEach(function(dev){
actHtml += '<option value="'+esc(dev.uuid)+'" '+(a.device_uuid==dev.uuid?'selected':'')+'>'+esc(dev.title)+' ('+esc(dev.model)+')</option>';
});
actHtml += '</select></div>'
+ '<div class="layui-input-inline" style="width:150px;"><input name="act_payload" class="layui-input" value="'+esc(a.payload_json)+'" placeholder="命令JSON/HTTP体/告警内容"></div>'
+ '<div class="layui-input-inline" style="width:180px;"><input name="act_topic" class="layui-input" value="'+esc(a.topic)+'" placeholder="http回调URL/告警标题"></div>'
+ '<button type="button" class="layui-btn layui-btn-xs layui-btn-danger" onclick="$(this).closest(\'.layui-form-item\').remove()">删</button>'
+ '</div>';
});
var html = '<form class="layui-form" style="padding:20px;" lay-filter="sceneForm">'
+ '<input type="hidden" name="id" value="'+(row.id||'')+'">'
+ '<div class="layui-form-item"><label class="layui-form-label">场景名</label><div class="layui-input-inline"><input name="title" class="layui-input" value="'+esc(row.title)+'" required></div></div>'
+ '<div class="layui-form-item"><label class="layui-form-label">图标</label><div class="layui-input-inline"><input name="icon" class="layui-input" value="'+esc(row.icon)+'" placeholder="如:☀ / 🌙"></div></div>'
+ '<div class="layui-form-item"><label class="layui-form-label">触发器</label><div class="layui-input-inline" style="width:110px;"><select name="trg_type" lay-filter="trgTypeSel"><option value="device_state" '+(trig.type=='device_state'?'selected':'')+'>设备状态</option><option value="schedule" '+(trig.type=='schedule'?'selected':'')+'>定时</option><option value="manual" '+(trig.type=='manual'?'selected':'')+'>手动</option></select></div>'
+ '<div class="layui-input-inline" id="trgStateWrap"><label class="layui-form-label" style="width:auto;">主题</label><input name="trg_topic" class="layui-input" style="display:inline-block;width:200px;" value="'+esc(trig.topic)+'" placeholder="/devices/+/state"></div>'
+ '<div class="layui-input-inline" id="trgCronWrap" style="display:none;"><label class="layui-form-label" style="width:auto;">Cron</label><input name="trg_cron" class="layui-input" style="display:inline-block;width:180px;" value="'+esc(trig.cron)+'" placeholder="0 8 * * *"></div>'
+ '</div>'
+ '<div class="layui-form-item"><label class="layui-form-label">条件JSON</label><div class="layui-input-block"><textarea name="trg_condition" class="layui-textarea" rows="3" placeholder=\'[{"field":"temperature","op":"gt","value":60}]\'>'+esc(trig.condition_json)+'</textarea></div></div>'
+ actHtml
+ '<div class="layui-form-item"><div class="layui-input-block"><button type="button" class="layui-btn layui-btn-sm layui-btn-normal" onclick="addSceneAct(this)">+ 添加动作</button></div></div>'
+ '</form>';
layer.open({type:1, title:(row.id?'编辑':'新建')+'场景', area:['760px','auto'], content:html, btn:['保存','取消'],
success:function(){ layui.form.render(); renderTrgType($('[lay-filter=sceneForm] [name=trg_type]').val()); },
yes:function(idx){
var data = {};
$('[lay-filter=sceneForm]').serializeArray().forEach(function(i){ data[i.name]=i.value; });
if(!data.title){ layer.msg('请填写场景名'); return; }
var trg = {type:data.trg_type, topic:data.trg_topic, cron:data.trg_cron,
condition_json: data.trg_condition, enabled:1};
var acts = [];
$('[lay-filter=sceneForm] [name=act_type]').each(function(i){
acts.push({
type:$(this).val(),
device_uuid: $('[lay-filter=sceneForm] [name=act_uuid]').eq(i).val()||'',
payload_json: $('[lay-filter=sceneForm] [name=act_payload]').eq(i).val()||'',
topic: $('[lay-filter=sceneForm] [name=act_topic]').eq(i).val()||''
});
});
data.triggers = JSON.stringify([trg]);
data.actions = JSON.stringify(acts);
http.post('/iot/backend/saveScene', data).then(function(res){
layer.msg(res.message||res.msg||'ok');
ok(res, function(){ layer.close(idx); setTimeout(reload, 600); });
});
}
});
}
window.addSceneAct = function(btn){
var opts = '';
devices.forEach(function(dev){ opts += '<option value="'+esc(dev.uuid)+'">'+esc(dev.title)+' ('+esc(dev.model)+')</option>'; });
var $frag = $('<div class="layui-form-item">'
+ '<label class="layui-form-label">动作</label>'
+ '<div class="layui-input-inline" style="width:110px;"><select name="act_type"><option value="send_cmd" selected>下发命令</option><option value="http">HTTP回调</option><option value="notify">站内告警</option></select></div>'
+ '<div class="layui-input-inline" style="width:130px;"><select name="act_uuid"><option value="">选择设备</option>'+opts+'</select></div>'
+ '<div class="layui-input-inline" style="width:150px;"><input name="act_payload" class="layui-input" placeholder="命令JSON/HTTP体/告警内容"></div>'
+ '<div class="layui-input-inline" style="width:180px;"><input name="act_topic" class="layui-input" placeholder="http回调URL/告警标题"></div>'
+ '<button type="button" class="layui-btn layui-btn-xs layui-btn-danger" onclick="$(this).closest(\'.layui-form-item\').remove()">删</button>'
+ '</div>');
$(btn).closest('.layui-form').append($frag);
layui.form.render();
};
// ================= 立即执行 =================
function runScene(id){
layer.confirm('立即执行该场景(手动触发)?', function(idx){
http.post('/iot/backend/runScene', {id:id}).then(function(res){
layer.msg(res.message||res.msg||'已触发'); layer.close(idx);
});
});
}
// ================= 启停 =================
function toggleScene(id, enabled){
http.post('/iot/backend/toggleScene', {id:id, enabled:enabled}).then(function(res){
layer.msg(res.message||res.msg||'已更新');
setTimeout(reload, 600);
});
}
// ================= 日志 =================
function showLogs(row){
http.get('/iot/backend/sceneLogs', {id:row.id, page:1}).then(function(res){
var d = res.data||{list:[], total:0};
var rows = d.list||[];
var html = '';
rows.forEach(function(l){
html += '<tr><td>'+l.id+'</td><td>'+l.trigger_type+'</td>'
+ '<td>'+(l.matched?'<span class="layui-badge layui-bg-green">命中</span>':'<span class="layui-badge layui-bg-gray">未命中</span>')+'</td>'
+ '<td>'+esc(l.message)+'</td><td>'+fmtTime(l.create_at)+'</td></tr>';
});
if(!rows.length){ html = '<tr><td colspan="5" style="text-align:center;color:#aaa;">暂无日志</td></tr>'; }
layer.open({type:1, title:'场景执行日志:'+esc(row.title)+'(共 '+d.total+' 条,显示最近 '+rows.length+' 条)', area:['720px','420px'],
content:'<table class="layui-table" style="margin:0;"><thead><tr><th>ID</th><th>触发</th><th>结果</th><th>说明</th><th>时间</th></tr></thead><tbody>'+html+'</tbody></table>',
btn:['关闭']});
});
}
// ================= 删除 =================
function delScene(id){
layer.confirm('确认删除该场景及其触发器/动作?', function(idx){
http.post('/iot/backend/deleteScene', {id:id}).then(function(res){
layer.msg(res.message||res.msg||'已删除');
setTimeout(reload, 600);
});
layer.close(idx);
});
}
});
</script>