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,297 @@
|
||||
<div class="layui-container">
|
||||
<div class="admin-title">智能设备</div>
|
||||
<div class="layui-text" style="margin-bottom:10px;color:#999;">
|
||||
华为式绑定流程:后台预登记 SN,自动生成<b>配对码</b>(机身贴纸,防抢绑)与<b>激活令牌</b>(出厂预置)→ 设备首次联网调
|
||||
<code>/iot/api/deviceActivate</code> 入云激活领取 uuid/secret → 会员 App 扫码 SN + 输入配对码绑定认领。
|
||||
已激活设备重新绑定时凭证不变;老设备(无配对码)仍兼容"扫码即绑"。连接约定:username=clientId=uuid,Topic:<code>/devices/{uuid}/state</code> 上报、
|
||||
<code>/devices/{uuid}/cmd</code> 接收命令。
|
||||
</div>
|
||||
<div style="margin-bottom:10px;">
|
||||
<form class="layui-form" lay-filter="devSearch" style="display:inline-block;">
|
||||
<div class="layui-input-inline" style="width:220px;">
|
||||
<input name="kw" class="layui-input" id="devKw" placeholder="搜索 SN / 名称 / UUID">
|
||||
</div>
|
||||
<button class="layui-btn layui-btn-sm" lay-submit lay-filter="devSearch">搜索</button>
|
||||
</form>
|
||||
<button class="layui-btn layui-btn-sm" style="margin-left:10px;" onclick="editDevice()">+ 预登记设备</button>
|
||||
</div>
|
||||
<table id="devTable" lay-filter="devTable"></table>
|
||||
</div>
|
||||
|
||||
<!-- 行操作按钮模板 -->
|
||||
<script type="text/html" id="barDev">
|
||||
<a class="layui-btn layui-btn-xs" lay-event="edit">编辑</a>
|
||||
{{# if(d.uuid){ }}
|
||||
<a class="layui-btn layui-btn-xs layui-btn-primary" lay-event="cfg">传感器</a>
|
||||
<a class="layui-btn layui-btn-xs layui-btn-warm" lay-event="resetSecret">重置密钥</a>
|
||||
{{# } else { }}
|
||||
<a class="layui-btn layui-btn-xs layui-btn-normal" lay-event="resetToken">重置激活令牌</a>
|
||||
{{# } }}
|
||||
{{# if(d.uuid){ }}
|
||||
<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 kw = '';
|
||||
function esc(s){ return String(s==null?'':s).replace(/&/g,'&').replace(/"/g,'"').replace(/</g,'<'); }
|
||||
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('devTable', { where: kw ? { kw: kw } : {} }); }
|
||||
|
||||
// ================= 搜索 =================
|
||||
form.on('submit(devSearch)', function(obj){
|
||||
kw = $.trim(obj.field.kw || '');
|
||||
reload();
|
||||
return false;
|
||||
});
|
||||
|
||||
// ================= 表格 =================
|
||||
table.render({
|
||||
elem: '#devTable',
|
||||
url: '/iot/backend/deviceTable',
|
||||
page: true,
|
||||
limit: 20,
|
||||
limits: [10, 20, 50, 100],
|
||||
parseData: function (res) {
|
||||
return {
|
||||
code: res.code,
|
||||
msg: res.message || res.msg || '',
|
||||
count: (res.data && res.data.count) || 0,
|
||||
data: (res.data && res.data.list) || []
|
||||
};
|
||||
},
|
||||
cols: [[
|
||||
{ field: 'id', title: 'ID', width: 60, align: 'center' },
|
||||
{ field: 'sn', title: '序列号', width: 110 },
|
||||
{ field: 'title', title: '名称', minWidth: 110 },
|
||||
{ field: 'uuid', title: 'UUID', width: 150, templet: function (d) {
|
||||
return d.uuid ? '<span style="word-break:break-all;">' + esc(d.uuid) + '</span>' : '<span style="color:#aaa;">未激活</span>';
|
||||
} },
|
||||
{ field: 'model', title: '型号/房间', width: 120, templet: function (d) {
|
||||
return esc(d.model) + (d.room ? ' / ' + esc(d.room) : '');
|
||||
} },
|
||||
{ field: 'pair_code', title: '配对码', width: 90, align: 'center', templet: function (d) {
|
||||
return d.pair_code ? '<code>' + esc(d.pair_code) + '</code>' : '<span style="color:#aaa;">—</span>';
|
||||
} },
|
||||
{ field: 'member_account', title: '绑定会员', width: 130, templet: function (d) {
|
||||
if (d.member_id) {
|
||||
return '<span class="layui-badge layui-bg-blue">M' + d.member_id + '</span> ' + (d.member_account ? esc(d.member_account) : '<span style="color:#aaa;">(账号缺失)</span>');
|
||||
}
|
||||
return '<span style="color:#aaa;">未绑定</span>';
|
||||
} },
|
||||
{ field: 'online', title: '在线', width: 80, align: 'center', templet: function (d) {
|
||||
return d.online ? '<span class="layui-badge layui-bg-green">在线</span>' : '<span class="layui-badge">离线</span>';
|
||||
} },
|
||||
{ field: 'status', title: '状态', width: 80, align: 'center', templet: function (d) {
|
||||
return d.status ? '<span class="layui-badge layui-bg-green">启用</span>' : '<span class="layui-badge">禁用</span>';
|
||||
} },
|
||||
{ field: 'bind_at', title: '绑定时间', width: 145, templet: function (d) { return fmtTime(d.bind_at); } },
|
||||
{ field: 'cfg_ver', title: '传感器配置', width: 120, align: 'center', templet: function (d) {
|
||||
if (!d.cfg_ver) { return '<span style="color:#aaa;">未配置</span>'; }
|
||||
return (Number(d.sync_ver) >= Number(d.cfg_ver))
|
||||
? '<span class="layui-badge layui-bg-green">v' + d.cfg_ver + ' 已生效</span>'
|
||||
: '<span class="layui-badge layui-bg-orange">v' + d.cfg_ver + ' 待生效</span>';
|
||||
} },
|
||||
{ title: '操作', width: 330, align: 'center', toolbar: '#barDev' }
|
||||
]]
|
||||
});
|
||||
|
||||
table.on('tool(devTable)', function (obj) {
|
||||
var d = obj.data;
|
||||
if (obj.event === 'edit') { editDevice(d); }
|
||||
else if (obj.event === 'cfg') { cfgDevice(d); }
|
||||
else if (obj.event === 'resetSecret') { resetSecret(d.uuid); }
|
||||
else if (obj.event === 'resetToken') { resetToken(d.sn); }
|
||||
else if (obj.event === 'del') { delDevice(d); }
|
||||
});
|
||||
|
||||
// ================= 预登记 / 编辑 =================
|
||||
window.editDevice = function(row){
|
||||
row = row || {};
|
||||
var tokenTip = (!row.uuid && row.activate_token)
|
||||
? '<div class="layui-form-item"><label class="layui-form-label">激活令牌</label><div class="layui-input-inline" style="width:300px;"><input class="layui-input" value="'+esc(row.activate_token)+'" readonly placeholder="设备入云激活用,一次性"></div></div>'
|
||||
: (row.uuid ? '<div class="layui-form-item"><label class="layui-form-label">激活状态</label><div class="layui-input-inline" style="width:300px;"><span style="line-height:38px;color:green;">已激活</span></div></div>' : '');
|
||||
var html = '<form class="layui-form" style="padding:20px;" lay-filter="devForm">'
|
||||
+ '<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="sn" class="layui-input" value="'+esc(row.sn)+'" '+(row.id?'readonly':'')+' placeholder="机身SN,会员扫码绑定凭证"></div></div>'
|
||||
+ '<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)+'"></div></div>'
|
||||
+ '<div class="layui-form-item"><label class="layui-form-label">型号</label><div class="layui-input-inline"><input name="model" class="layui-input" value="'+esc(row.model)+'"></div></div>'
|
||||
+ '<div class="layui-form-item"><label class="layui-form-label">房间</label><div class="layui-input-inline"><input name="room" class="layui-input" value="'+esc(row.room)+'"></div></div>'
|
||||
+ '<div class="layui-form-item"><label class="layui-form-label">配对码</label><div class="layui-input-inline"><input name="pair_code" class="layui-input" value="'+esc(row.pair_code)+'" placeholder="留空=新增时自动生成6位数字"></div></div>'
|
||||
+ tokenTip
|
||||
+ '<div class="layui-form-item"><label class="layui-form-label">状态</label><div class="layui-input-inline"><select name="status"><option value="1" '+(row.status!=0?'selected':'')+'>启用</option><option value="0" '+(row.status==0?'selected':'')+'>禁用</option></select></div></div>'
|
||||
+ '</form>';
|
||||
layer.open({type:1, title:(row.id?'编辑':'预登记')+'设备', area:['460px','auto'], content:html, btn:['保存','取消'],
|
||||
success:function(){ layui.form.render(); },
|
||||
yes:function(idx){
|
||||
var data = {};
|
||||
$('[lay-filter=devForm]').serializeArray().forEach(function(i){ data[i.name]=i.value; });
|
||||
if(!row.id && !data.sn){ layer.msg('请填写设备序列号'); return; }
|
||||
http.post('/iot/backend/saveDevice', data).then(function(res){
|
||||
if(res.code===0){
|
||||
layer.close(idx);
|
||||
// 新登记:展示配对码 + 激活令牌(一次性,请写入出厂配置/贴纸)
|
||||
if(!row.id && res.data && res.data.activate_token){
|
||||
layer.open({type:1, title:'预登记成功(请保存以下出厂信息)', area:['520px','auto'],
|
||||
content:'<div style="padding:20px;">'
|
||||
+ '<p style="margin-bottom:8px;">设备序列号:<code>'+esc(data.sn)+'</code></p>'
|
||||
+ '<p style="margin-bottom:8px;">配对码(机身贴纸/说明书):<code>'+esc(res.data.pair_code)+'</code></p>'
|
||||
+ '<p style="margin-bottom:8px;">激活令牌(出厂预置,设备首次联网调用 /iot/api/deviceActivate 领取 uuid/secret,一次性):</p>'
|
||||
+ '<p><code style="word-break:break-all;">'+esc(res.data.activate_token)+'</code></p>'
|
||||
+ '</div>', btn:['我已保存','关闭'], yes:function(i){ layer.close(i); reload(); } });
|
||||
return;
|
||||
}
|
||||
layer.msg(res.message||res.msg||'ok');
|
||||
setTimeout(reload, 600);
|
||||
} else {
|
||||
layer.msg(res.message||'操作失败');
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// ================= 重置激活令牌 =================
|
||||
window.resetToken = function(sn){
|
||||
layer.confirm('重新生成激活令牌后,旧令牌立即失效(已打印/预置的旧令牌将无法激活)。确认继续?', function(idx){
|
||||
http.post('/iot/backend/resetActivateToken', {sn:sn}).then(function(res){
|
||||
layer.close(idx);
|
||||
if(res.code===0){
|
||||
layer.open({type:1, title:'新激活令牌(仅显示一次)', area:['520px','auto'],
|
||||
content:'<div style="padding:20px;">'
|
||||
+ '<p style="margin-bottom:8px;">设备序列号:<code>'+esc(sn)+'</code></p>'
|
||||
+ '<p style="margin-bottom:8px;">新激活令牌(出厂预置,一次性):</p>'
|
||||
+ '<p><code style="word-break:break-all;">'+esc((res.data&&res.data.activate_token)||'')+'</code></p>'
|
||||
+ '</div>', btn:['我已保存','关闭'], yes:function(i){ layer.close(i); } });
|
||||
} else {
|
||||
layer.msg(res.message||'操作失败');
|
||||
}
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
// ================= 重置密钥 =================
|
||||
window.resetSecret = function(uuid){
|
||||
layer.confirm('重置后将作废当前密钥,设备需使用新密钥重新连接。确认继续?', function(idx){
|
||||
http.post('/iot/backend/resetDeviceSecret', {uuid:uuid}).then(function(res){
|
||||
layer.close(idx);
|
||||
if(res.code===0){
|
||||
var sec = (res.data&&res.data.secret)||'';
|
||||
layer.open({type:1, title:'新密钥(仅显示一次,请立即保存)', area:['460px','auto'],
|
||||
content:'<div style="padding:20px;">'
|
||||
+ '<p style="margin-bottom:10px;">设备连接参数:</p>'
|
||||
+ '<p style="margin-bottom:6px;">username / clientId:<code>'+esc(uuid)+'</code></p>'
|
||||
+ '<p>password:<code style="word-break:break-all;">'+esc(sec)+'</code></p>'
|
||||
+ '</div>', btn:['我已保存','关闭'], yes:function(i){ layer.close(i); } });
|
||||
} else {
|
||||
layer.msg(res.message||'操作失败');
|
||||
}
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
// ================= 删除 =================
|
||||
window.delDevice = function(row){
|
||||
layer.confirm('删除将同时清除该设备的 MQTT 账号、ACL、影子与订阅,且不可恢复。确认删除?', function(idx){
|
||||
http.post('/iot/backend/deleteDevice', {uuid:row.uuid}).then(function(res){
|
||||
layer.msg(res.message||res.msg||'已删除');
|
||||
setTimeout(reload, 600);
|
||||
});
|
||||
layer.close(idx);
|
||||
});
|
||||
};
|
||||
|
||||
// ================= 传感器配置(配置真源在服务端:保存即 retained 下发设备,App 读同一份) =================
|
||||
var cfgUuid = '';
|
||||
function cfgRowHtml(s){
|
||||
return '<tr data-i="' + s.index + '">'
|
||||
+ '<td style="text-align:center;">' + (Number(s.index) + 1) + '</td>'
|
||||
+ '<td><input class="layui-input cfg-alias" value="' + esc(s.alias) + '" placeholder="如 进水"></td>'
|
||||
+ '<td><input type="number" min="1" max="3" class="layui-input cfg-probes" value="' + s.probes + '"></td>'
|
||||
+ '<td><input type="number" step="0.1" class="layui-input cfg-upper" value="' + s.upper + '"></td>'
|
||||
+ '<td><input type="number" step="0.1" class="layui-input cfg-lower" value="' + s.lower + '"></td>'
|
||||
+ '<td><input type="number" step="0.1" min="0" class="layui-input cfg-gap" value="' + s.gap + '"></td>'
|
||||
+ '<td style="text-align:center;"><input type="checkbox" class="cfg-enabled" ' + (Number(s.enabled) !== 0 ? 'checked' : '') + '></td>'
|
||||
+ '<td style="text-align:center;"><a class="layui-btn layui-btn-xs layui-btn-danger cfg-del">删除</a></td>'
|
||||
+ '</tr>';
|
||||
}
|
||||
window.cfgAddPos = function(){
|
||||
var $tb = $('#cfgTbody');
|
||||
if ($tb.find('tr').length >= 3) { layer.msg('最多 3 个位置(对应固件 3 条 OneWire 总线)'); return; }
|
||||
var used = {};
|
||||
$tb.find('tr').each(function(){ used[Number($(this).data('i'))] = 1; });
|
||||
var free = 0;
|
||||
while (used[free]) { free++; }
|
||||
$tb.append(cfgRowHtml({ index: free, alias: '位置' + (free + 1), probes: 2, upper: 85, lower: 0, gap: 0, enabled: 1 }));
|
||||
};
|
||||
$(document).on('click', '.cfg-del', function(){
|
||||
if ($('#cfgTbody tr').length <= 1) { layer.msg('至少保留一个位置'); return; }
|
||||
$(this).closest('tr').remove();
|
||||
});
|
||||
|
||||
window.cfgDevice = function(row){
|
||||
if (!row.uuid) { layer.msg('设备未激活(无 uuid),无法配置'); return; }
|
||||
cfgUuid = row.uuid;
|
||||
http.get('/iot/backend/deviceConfig', { uuid: cfgUuid }).then(function(res){
|
||||
if (res.code !== 0) { layer.msg(res.message || '读取配置失败'); return; }
|
||||
var cfg = res.data || {};
|
||||
var list = (cfg.sensors && cfg.sensors.length) ? cfg.sensors
|
||||
: [{ index: 0, alias: '位置1', probes: 2, upper: 85, lower: 0, gap: 0, enabled: 1 }];
|
||||
var rows = '';
|
||||
list.forEach(function(s){ rows += cfgRowHtml(s); });
|
||||
var state = cfg.applied
|
||||
? '<span class="layui-badge layui-bg-green">已生效(设备 v' + cfg.sync_ver + ')</span>'
|
||||
: '<span class="layui-badge layui-bg-orange">待设备上线生效(服务端 v' + cfg.cfg_ver + ' / 设备 v' + cfg.sync_ver + ')</span>';
|
||||
var html = '<div style="padding:16px 18px;">'
|
||||
+ '<div style="margin-bottom:10px;color:#666;line-height:22px;">'
|
||||
+ '设备:<b>' + esc(row.title || row.sn) + '</b> | 配置版本:<b>v' + cfg.cfg_ver + '</b> | 同步状态:' + state
|
||||
+ '<div style="margin-top:6px;color:#999;">配置保存在服务端(唯一真源):保存后立即入 retained 队列,设备在线秒级生效、离线则上线订阅瞬间收到。App 读同一份配置渲染面板,state 报文只传数值不传配置。</div>'
|
||||
+ '</div>'
|
||||
+ '<table class="layui-table" style="margin:0;"><thead><tr>'
|
||||
+ '<th style="width:52px;text-align:center;">序号</th><th>位置名称</th><th style="width:80px;">探头数</th>'
|
||||
+ '<th style="width:90px;">超温上限</th><th style="width:90px;">低温下限</th><th style="width:90px;">温差阈值</th>'
|
||||
+ '<th style="width:60px;text-align:center;">启用</th><th style="width:64px;text-align:center;">操作</th>'
|
||||
+ '</tr></thead><tbody id="cfgTbody">' + rows + '</tbody></table>'
|
||||
+ '<div style="margin-top:10px;">'
|
||||
+ '<a class="layui-btn layui-btn-sm" onclick="cfgAddPos()">+ 添加位置</a>'
|
||||
+ '<span style="margin-left:10px;color:#999;">温差阈值 = 同一位置内各探头极差告警(0=关闭);上限/下限为逐颗探头比对</span>'
|
||||
+ '</div></div>';
|
||||
layer.open({ type:1, title:'传感器位置配置', area:['880px','auto'], content:html, btn:['保存并下发','关闭'],
|
||||
yes:function(idx){
|
||||
var sensors = [], bad = '';
|
||||
$('#cfgTbody tr').each(function(){
|
||||
var $t = $(this);
|
||||
var s = {
|
||||
index: Number($t.data('i')),
|
||||
alias: $.trim($t.find('.cfg-alias').val()),
|
||||
probes: Number($t.find('.cfg-probes').val()),
|
||||
upper: Number($t.find('.cfg-upper').val()),
|
||||
lower: Number($t.find('.cfg-lower').val()),
|
||||
gap: Number($t.find('.cfg-gap').val()),
|
||||
enabled: $t.find('.cfg-enabled').prop('checked') ? 1 : 0
|
||||
};
|
||||
if (!s.alias) { bad = '位置名称不能为空'; }
|
||||
if (!(s.probes >= 1 && s.probes <= 3)) { bad = '探头数须为 1~3'; }
|
||||
if (isNaN(s.upper) || isNaN(s.lower)) { bad = '上限/下限须为数字'; }
|
||||
if (isNaN(s.gap) || s.gap < 0) { bad = '温差阈值须为不小于 0 的数字'; }
|
||||
sensors.push(s);
|
||||
});
|
||||
if (bad) { layer.msg(bad); return; }
|
||||
http.post('/iot/backend/saveDeviceConfig', { uuid: cfgUuid, sensors: JSON.stringify(sensors) }).then(function(r2){
|
||||
if (r2.code === 0) {
|
||||
layer.msg(r2.message || '已保存并下发');
|
||||
layer.close(idx);
|
||||
setTimeout(reload, 600);
|
||||
} else {
|
||||
layer.msg(r2.message || '保存失败');
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
};
|
||||
});
|
||||
</script>
|
||||
Reference in new issue
Block a user