Files
YwxAppThink/addon/iot/view/backend/admin/device.html
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

298 lines
19 KiB
HTML
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.
<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>