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

+297
View File
@@ -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,'&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('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> &nbsp;|&nbsp; 配置版本:<b>v' + cfg.cfg_ver + '</b> &nbsp;|&nbsp; 同步状态:' + 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>