安全(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(市场 / 开发者中心 / 审核 / 分润)
566 lines
16 KiB
Plaintext
566 lines
16 KiB
Plaintext
<template>
|
||
<view class="page">
|
||
<!-- 华为式设备绑定 -->
|
||
<view class="card">
|
||
<view class="card-title">华为式设备绑定(配网→激活→认领)</view>
|
||
<view class="row">
|
||
<text class="label">API地址</text>
|
||
<input class="input" v-model="apiBase" placeholder="http://192.168.1.10" />
|
||
</view>
|
||
|
||
<view class="sub-title">1. 会员登录(认领需 JWT)</view>
|
||
<view class="row">
|
||
<text class="label">账号</text>
|
||
<input class="input" v-model="loginAccount" placeholder="会员账号" />
|
||
</view>
|
||
<view class="row">
|
||
<text class="label">密码</text>
|
||
<input class="input" v-model="loginPassword" placeholder="会员密码" />
|
||
</view>
|
||
<button class="btn btn-sm" @click="onLogin">登录获取凭证</button>
|
||
<view class="status" :class="loginStatusClass">{{ loginStatusText }}</view>
|
||
|
||
<view class="sub-title">2. 设备激活(固件侧:入云领凭证)</view>
|
||
<view class="row">
|
||
<text class="label">SN</text>
|
||
<input class="input" v-model="devSn" placeholder="设备序列号" />
|
||
</view>
|
||
<view class="row">
|
||
<text class="label">激活令牌</text>
|
||
<input class="input" v-model="activateToken" placeholder="出厂预置 activate_token" />
|
||
</view>
|
||
<button class="btn btn-sm" @click="onActivate">设备激活</button>
|
||
|
||
<view class="sub-title">3. 用户认领(App 输入配对码)</view>
|
||
<view class="row">
|
||
<text class="label">配对码</text>
|
||
<input class="input" v-model="pairCode" placeholder="设备机身贴纸 6 位码" />
|
||
</view>
|
||
<button class="btn btn-sm" @click="onBind">绑定设备</button>
|
||
<view class="tips">激活成功后 uuid/secret 自动填入下方连接表单,可直接连接设备 MQTT。</view>
|
||
</view>
|
||
|
||
<!-- 连接设置 -->
|
||
<view class="card">
|
||
<view class="card-title">MQTT WebSocket 联调</view>
|
||
<view class="row">
|
||
<text class="label">服务器</text>
|
||
<input class="input" v-model="serverUrl" placeholder="ws://192.168.1.10:8083" />
|
||
</view>
|
||
<view class="row">
|
||
<text class="label">账号</text>
|
||
<input class="input" v-model="username" placeholder="会员账号/认证账号,可留空" />
|
||
</view>
|
||
<view class="row">
|
||
<text class="label">密码</text>
|
||
<input class="input" v-model="password" placeholder="可留空" />
|
||
</view>
|
||
<view class="row">
|
||
<text class="label">ClientID</text>
|
||
<input class="input" v-model="clientId" placeholder="留空自动生成" />
|
||
</view>
|
||
<view class="status" :class="statusClass">{{ statusText }}</view>
|
||
<button class="btn" :class="connected ? 'btn-danger' : 'btn-primary'" @click="onConnect">
|
||
{{ connected ? '断开连接' : '连接服务器' }}
|
||
</button>
|
||
</view>
|
||
|
||
<!-- 订阅 -->
|
||
<view class="card">
|
||
<view class="card-title">订阅主题</view>
|
||
<view class="row">
|
||
<input class="input flex1" v-model="subTopic" placeholder="/devices/设备UUID/state" />
|
||
<button class="btn btn-sm" @click="onSubscribe">订阅</button>
|
||
</view>
|
||
<view class="tips">
|
||
设备状态:/devices/设备UUID/state(填自己的设备);告警推送:/notify/你的账号/#
|
||
</view>
|
||
<view class="tips">
|
||
安全提示:服务端对 /devices/... 做归属校验,只能订阅已绑定或已共享给自己的设备;
|
||
/devices/+/state 这类跨设备通配符默认拒绝,需管理员在「ACL规则」中显式放行。
|
||
</view>
|
||
</view>
|
||
|
||
<!-- 发布 -->
|
||
<view class="card">
|
||
<view class="card-title">发布消息</view>
|
||
<view class="row">
|
||
<input class="input flex1" v-model="pubTopic" placeholder="/devices/DEV001/cmd" />
|
||
</view>
|
||
<view class="row">
|
||
<input class="input flex1" v-model="pubPayload" placeholder='{"on":true}' />
|
||
<button class="btn btn-sm" @click="onPublish">发送</button>
|
||
</view>
|
||
</view>
|
||
|
||
<!-- 消息流 -->
|
||
<view class="card card-logs">
|
||
<view class="card-title">
|
||
<text>消息流</text>
|
||
<text class="link" @click="clearLogs">清空</text>
|
||
</view>
|
||
<scroll-view class="logs" scroll-y="true">
|
||
<view class="log" v-for="(item, idx) in logs" :key="idx">
|
||
<text class="log-time">{{ item.time }}</text>
|
||
<text class="log-topic">{{ item.topic }}</text>
|
||
<text class="log-payload">{{ item.payload }}</text>
|
||
</view>
|
||
<view class="empty" v-if="logs.length === 0">暂无消息,先连接并订阅</view>
|
||
</scroll-view>
|
||
</view>
|
||
</view>
|
||
</template>
|
||
|
||
<script setup lang="uts">
|
||
import { onLoad, onShow, onUnload } from '@dcloudio/uni-app'
|
||
import { MqttClient } from './MqttClient.uts'
|
||
|
||
interface LogItem {
|
||
time: string
|
||
topic: string
|
||
payload: string
|
||
}
|
||
|
||
// 模块级单例:避免被 uni-app x 响应式代理包裹,保证类内部字节缓冲安全
|
||
let mqtt: MqttClient | null = null
|
||
|
||
// ─── 响应式状态 ───────────────────────────────────
|
||
const serverUrl = ref('ws://192.168.1.10:8083')
|
||
const username = ref('')
|
||
const password = ref('')
|
||
const clientId = ref('')
|
||
const subTopic = ref('/devices/你的设备UUID/state')
|
||
const pubTopic = ref('/devices/你的设备UUID/cmd')
|
||
const pubPayload = ref('{"on":true}')
|
||
const connected = ref(false)
|
||
const statusText = ref('未连接')
|
||
const statusLevel = ref('idle')
|
||
const logs = ref<LogItem[]>([])
|
||
// 华为式绑定
|
||
const apiBase = ref('http://192.168.1.10')
|
||
const loginAccount = ref('')
|
||
const loginPassword = ref('')
|
||
const loginStatusText = ref('未登录')
|
||
const loginStatusLevel = ref('idle')
|
||
const accessToken = ref('')
|
||
const devSn = ref('')
|
||
const activateToken = ref('')
|
||
const pairCode = ref('')
|
||
|
||
// ─── 计算属性 ───────────────────────────────────
|
||
const loginStatusClass = computed<string>((): string => {
|
||
if (loginStatusLevel.value === 'ok') {
|
||
return 'status-ok'
|
||
} else if (loginStatusLevel.value === 'err') {
|
||
return 'status-err'
|
||
} else if (loginStatusLevel.value === 'busy') {
|
||
return 'status-busy'
|
||
}
|
||
return 'status-idle'
|
||
})
|
||
|
||
const statusClass = computed<string>((): string => {
|
||
if (statusLevel.value === 'ok') {
|
||
return 'status-ok'
|
||
} else if (statusLevel.value === 'err') {
|
||
return 'status-err'
|
||
} else if (statusLevel.value === 'busy') {
|
||
return 'status-busy'
|
||
}
|
||
return 'status-idle'
|
||
})
|
||
|
||
// ─── 生命周期 ───────────────────────────────────
|
||
onLoad(() => {
|
||
if (clientId.value.length === 0) {
|
||
clientId.value = 'app-' + Date.now().toString()
|
||
}
|
||
})
|
||
|
||
onShow(() => {
|
||
// App 后台期间 JS 定时器暂停、心跳停发,服务端可能已断开;回前台立即重连
|
||
if (mqtt != null && !mqtt.isConnected) {
|
||
statusText.value = '重新连接...'
|
||
statusLevel.value = 'busy'
|
||
mqtt.reconnectNow()
|
||
}
|
||
})
|
||
|
||
onUnload(() => {
|
||
teardownClient()
|
||
})
|
||
|
||
// ─── HTTP 请求 ───────────────────────────────────
|
||
function httpPost(path: string, body: UTSJSONObject, needToken: boolean, onOk: (resp: UTSJSONObject) => void, onFail: (msg: string) => void) {
|
||
const header: UTSJSONObject = {
|
||
'Content-Type': 'application/json'
|
||
}
|
||
if (needToken && accessToken.value.length > 0) {
|
||
header['Authorization'] = 'Bearer ' + accessToken.value
|
||
}
|
||
uni.request<UTSJSONObject>({
|
||
url: apiBase.value + path,
|
||
method: 'POST',
|
||
data: body,
|
||
header: header,
|
||
success: (res: RequestSuccess<UTSJSONObject>) => {
|
||
const resp = res.data
|
||
const code = resp['code'] as number
|
||
// 兼容插件 Result::success 的 message 键 与 框架 apiSuccess 的 msg 键
|
||
const msg = (resp['message'] as string) || (resp['msg'] as string) || ''
|
||
if (code === 0) {
|
||
onOk(resp)
|
||
} else {
|
||
onFail(msg)
|
||
}
|
||
},
|
||
fail: (err: RequestFail) => {
|
||
onFail(err.errMsg)
|
||
}
|
||
})
|
||
}
|
||
|
||
// ─── 动作 ───────────────────────────────────
|
||
function onLogin() {
|
||
if (loginAccount.value.length === 0 || loginPassword.value.length === 0) {
|
||
loginStatusText.value = '请输入账号与密码'
|
||
loginStatusLevel.value = 'err'
|
||
return
|
||
}
|
||
loginStatusText.value = '登录中...'
|
||
loginStatusLevel.value = 'busy'
|
||
const body: UTSJSONObject = {
|
||
'account': loginAccount.value,
|
||
'password': loginPassword.value
|
||
}
|
||
httpPost('/api/v1/user/login', body, false, (resp: UTSJSONObject) => {
|
||
accessToken.value = (resp['access_token'] as string) || ''
|
||
if (accessToken.value.length === 0) {
|
||
loginStatusText.value = '登录成功但未返回 token'
|
||
loginStatusLevel.value = 'err'
|
||
return
|
||
}
|
||
loginStatusText.value = '登录成功'
|
||
loginStatusLevel.value = 'ok'
|
||
logMsg('系统', '登录成功,access_token 已保存')
|
||
}, (msg: string) => {
|
||
loginStatusText.value = msg
|
||
loginStatusLevel.value = 'err'
|
||
})
|
||
}
|
||
|
||
function onActivate() {
|
||
if (devSn.value.length === 0 || activateToken.value.length === 0) {
|
||
statusText.value = '请填写 SN 与激活令牌'
|
||
statusLevel.value = 'err'
|
||
return
|
||
}
|
||
statusText.value = '设备激活中...'
|
||
statusLevel.value = 'busy'
|
||
const body: UTSJSONObject = {
|
||
'sn': devSn.value,
|
||
'token': activateToken.value
|
||
}
|
||
httpPost('/iot/api/deviceActivate', body, false, (resp: UTSJSONObject) => {
|
||
const data = resp['data'] as UTSJSONObject | null
|
||
if (data == null) {
|
||
statusText.value = '激活成功(无凭证返回)'
|
||
statusLevel.value = 'ok'
|
||
return
|
||
}
|
||
const uuid = (data['uuid'] as string) || ''
|
||
const secret = (data['secret'] as string) || ''
|
||
if (uuid.length > 0) {
|
||
username.value = uuid
|
||
password.value = secret
|
||
clientId.value = uuid
|
||
}
|
||
statusText.value = secret.length > 0 ? '激活成功,凭证已填入连接表单' : '设备已激活(重复激活不下发凭证)'
|
||
statusLevel.value = 'ok'
|
||
logMsg('系统', '激活成功 sn=' + devSn.value + ' uuid=' + uuid + ' secret=' + secret)
|
||
}, (msg: string) => {
|
||
statusText.value = msg
|
||
statusLevel.value = 'err'
|
||
})
|
||
}
|
||
|
||
function onBind() {
|
||
if (accessToken.value.length === 0) {
|
||
statusText.value = '请先登录会员'
|
||
statusLevel.value = 'err'
|
||
return
|
||
}
|
||
if (devSn.value.length === 0 || pairCode.value.length === 0) {
|
||
statusText.value = '请填写 SN 与配对码'
|
||
statusLevel.value = 'err'
|
||
return
|
||
}
|
||
statusText.value = '绑定中...'
|
||
statusLevel.value = 'busy'
|
||
const body: UTSJSONObject = {
|
||
'sn': devSn.value,
|
||
'pair_code': pairCode.value
|
||
}
|
||
httpPost('/iot/api/client/bindDevice', body, true, (resp: UTSJSONObject) => {
|
||
statusText.value = '绑定成功'
|
||
statusLevel.value = 'ok'
|
||
logMsg('系统', '绑定成功 sn=' + devSn.value)
|
||
}, (msg: string) => {
|
||
statusText.value = msg
|
||
statusLevel.value = 'err'
|
||
})
|
||
}
|
||
|
||
function onConnect() {
|
||
if (connected.value) {
|
||
teardownClient()
|
||
connected.value = false
|
||
statusText.value = '已断开'
|
||
statusLevel.value = 'idle'
|
||
return
|
||
}
|
||
if (clientId.value.length === 0) {
|
||
clientId.value = 'app-' + Date.now().toString()
|
||
}
|
||
statusText.value = '连接中...'
|
||
statusLevel.value = 'busy'
|
||
teardownClient()
|
||
mqtt = new MqttClient({
|
||
url: serverUrl.value,
|
||
clientId: clientId.value,
|
||
username: username.value,
|
||
password: password.value,
|
||
autoReconnect: true
|
||
})
|
||
mqtt.onConnAck = (code: number) => {
|
||
if (code === 0) {
|
||
connected.value = true
|
||
statusText.value = '已连接'
|
||
statusLevel.value = 'ok'
|
||
logMsg('系统', '连接成功')
|
||
} else {
|
||
statusText.value = '连接被拒 code=' + code
|
||
statusLevel.value = 'err'
|
||
logMsg('系统', '连接被拒 code=' + code)
|
||
}
|
||
}
|
||
mqtt.onMessage = (topic: string, payload: string) => {
|
||
logMsg(topic, payload)
|
||
}
|
||
mqtt.onClose = (code: number) => {
|
||
connected.value = false
|
||
statusText.value = '已断开 code=' + code
|
||
statusLevel.value = 'idle'
|
||
logMsg('系统', '连接关闭 code=' + code)
|
||
}
|
||
mqtt.onError = (msg: string) => {
|
||
statusText.value = msg
|
||
statusLevel.value = 'err'
|
||
logMsg('系统', msg)
|
||
}
|
||
// 订阅结果:0x80 被拒(归属校验未通过 / ACL 未放行),如实反馈而不是静默
|
||
mqtt.onSubAck = (topicName: string, code: number) => {
|
||
if (code >= 128) {
|
||
statusText.value = '订阅被拒绝(无权限)'
|
||
statusLevel.value = 'err'
|
||
logMsg('系统', '订阅被拒绝:' + topicName + '(设备未绑定到本账号 / 无共享 / ACL 未放行)')
|
||
} else {
|
||
logMsg('系统', '订阅成功:' + topicName)
|
||
}
|
||
}
|
||
mqtt.connect()
|
||
}
|
||
|
||
function onSubscribe() {
|
||
if (mqtt == null || !connected.value) {
|
||
statusText.value = '请先连接服务器'
|
||
statusLevel.value = 'err'
|
||
return
|
||
}
|
||
const topic = subTopic.value.trim()
|
||
if (topic.length === 0) {
|
||
return
|
||
}
|
||
mqtt.subscribe(topic, 0)
|
||
logMsg('系统', '已订阅 ' + topic)
|
||
}
|
||
|
||
function onPublish() {
|
||
if (mqtt == null || !connected.value) {
|
||
statusText.value = '请先连接服务器'
|
||
statusLevel.value = 'err'
|
||
return
|
||
}
|
||
const topic = pubTopic.value.trim()
|
||
if (topic.length === 0) {
|
||
return
|
||
}
|
||
mqtt.publish(topic, pubPayload.value, 0, false)
|
||
logMsg('发送', topic + ' : ' + pubPayload.value)
|
||
}
|
||
|
||
function clearLogs() {
|
||
logs.value = [] as LogItem[]
|
||
}
|
||
|
||
function logMsg(topic: string, payload: string) {
|
||
const item: LogItem = {
|
||
time: new Date().toLocaleTimeString(),
|
||
topic: topic,
|
||
payload: payload
|
||
}
|
||
logs.value.push(item)
|
||
if (logs.value.length > 200) {
|
||
logs.value.splice(0, logs.value.length - 200)
|
||
}
|
||
}
|
||
|
||
function teardownClient() {
|
||
if (mqtt != null) {
|
||
mqtt.onConnAck = null
|
||
mqtt.onMessage = null
|
||
mqtt.onClose = null
|
||
mqtt.onError = null
|
||
mqtt.onSubAck = null
|
||
mqtt.disconnect()
|
||
mqtt = null
|
||
}
|
||
}
|
||
</script>
|
||
|
||
<style>
|
||
.page {
|
||
padding: 20rpx;
|
||
background: #f5f6fa;
|
||
}
|
||
.card {
|
||
background: #ffffff;
|
||
border-radius: 16rpx;
|
||
padding: 24rpx;
|
||
margin-bottom: 20rpx;
|
||
box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.04);
|
||
}
|
||
.card-title {
|
||
font-size: 30rpx;
|
||
font-weight: bold;
|
||
margin-bottom: 16rpx;
|
||
}
|
||
.sub-title {
|
||
font-size: 26rpx;
|
||
color: #666666;
|
||
margin: 20rpx 0 12rpx;
|
||
}
|
||
.row {
|
||
display: flex;
|
||
align-items: center;
|
||
margin-bottom: 16rpx;
|
||
}
|
||
.label {
|
||
width: 180rpx;
|
||
font-size: 26rpx;
|
||
color: #333333;
|
||
}
|
||
.input {
|
||
flex: 1;
|
||
height: 72rpx;
|
||
border: 1rpx solid #dddddd;
|
||
border-radius: 8rpx;
|
||
padding: 0 16rpx;
|
||
font-size: 26rpx;
|
||
}
|
||
.flex1 {
|
||
flex: 1;
|
||
margin-right: 12rpx;
|
||
}
|
||
.btn {
|
||
height: 76rpx;
|
||
border-radius: 12rpx;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
font-size: 28rpx;
|
||
color: #ffffff;
|
||
margin-top: 8rpx;
|
||
}
|
||
.btn-sm {
|
||
height: 64rpx;
|
||
padding: 0 30rpx;
|
||
border-radius: 8rpx;
|
||
font-size: 26rpx;
|
||
margin-top: 0;
|
||
display: inline-flex;
|
||
}
|
||
.btn-primary {
|
||
background: #2d8cf0;
|
||
}
|
||
.btn-danger {
|
||
background: #ed4014;
|
||
}
|
||
.status {
|
||
font-size: 24rpx;
|
||
margin-top: 12rpx;
|
||
padding: 10rpx 16rpx;
|
||
border-radius: 8rpx;
|
||
}
|
||
.status-ok {
|
||
color: #19be6b;
|
||
background: #e8f8f0;
|
||
}
|
||
.status-err {
|
||
color: #ed4014;
|
||
background: #fdeeea;
|
||
}
|
||
.status-busy {
|
||
color: #2d8cf0;
|
||
background: #eaf3fd;
|
||
}
|
||
.status-idle {
|
||
color: #999999;
|
||
background: #f5f5f5;
|
||
}
|
||
.tips {
|
||
font-size: 22rpx;
|
||
color: #999999;
|
||
margin-top: 12rpx;
|
||
line-height: 1.6;
|
||
}
|
||
.link {
|
||
font-size: 24rpx;
|
||
color: #2d8cf0;
|
||
margin-left: auto;
|
||
}
|
||
.card-logs {
|
||
padding-bottom: 0;
|
||
}
|
||
.logs {
|
||
height: 400rpx;
|
||
}
|
||
.log {
|
||
display: flex;
|
||
flex-direction: column;
|
||
padding: 12rpx 0;
|
||
border-bottom: 1rpx solid #f0f0f0;
|
||
}
|
||
.log-time {
|
||
font-size: 20rpx;
|
||
color: #999999;
|
||
}
|
||
.log-topic {
|
||
font-size: 24rpx;
|
||
color: #2d8cf0;
|
||
margin-top: 4rpx;
|
||
}
|
||
.log-payload {
|
||
font-size: 24rpx;
|
||
color: #333333;
|
||
margin-top: 4rpx;
|
||
word-break: break-all;
|
||
}
|
||
.empty {
|
||
text-align: center;
|
||
color: #cccccc;
|
||
font-size: 24rpx;
|
||
padding: 40rpx 0;
|
||
}
|
||
</style>
|