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

@@ -0,0 +1,560 @@
/**
* MqttClient.uts
* ------------------------------------------------------------------
* uni-app x (uvue) 最小 MQTT 3.1.1 over WebSocket 客户端
*
* 依赖:uni.connectSocket / SocketTask 二进制收发(App 平台 4.61+ 支持 ArrayBuffer)
*
* 用法:
* import { MqttClient } from './MqttClient.uts'
*
* const client = new MqttClient({
* url: 'ws://192.168.1.10:8083', // 后台「服务设置」里 WebSocket 端口,默认 8083
* clientId: 'app-' + Date.now().toString(),
* username: '会员账号', // allow_member_login 开启时用框架会员账号;否则用后台「认证账号」
* password: '密码',
* autoReconnect: true
* })
* client.onConnAck = (code: number) => { console.log('连接结果 code=' + code) } // 0=成功
* client.onMessage = (topic: string, payload: string) => { console.log(topic, payload) }
* client.onSubAck = (topic: string, code: number) => { console.log('订阅结果', topic, code) } // 128=被拒
* client.connect()
* client.subscribe('/devices/+/state', 0)
* client.publish('/devices/DEV001/cmd', '{"on":true}', 0, false)
* client.disconnect()
* ------------------------------------------------------------------
*/
export interface MqttClientOptions {
/** WebSocket 地址,形如 ws://192.168.1.10:8083 */
url: string
/** 客户端 ID,需全局唯一,建议 app-时间戳-随机数 */
clientId: string
/** MQTT 账号:allow_member_login=1 时为框架会员账号,否则为后台「认证账号」;匿名连接可留空 */
username?: string | null
password?: string | null
/** 心跳间隔秒,默认 10(App 后台 JS 定时器会暂停,缩短心跳可让服务端更快判定掉线、回前台更快重连) */
keepalive?: number
/** 会话清除,默认 true */
cleanSession?: boolean
/** 断线自动重连,默认 true(指数退避 1s~30s) */
autoReconnect?: boolean
}
interface Packet {
cmd: number
length: number
body: Uint8Array
}
interface PublishInfo {
topic: string
payload: string
}
export class MqttClient {
private opt: MqttClientOptions
private task: SocketTask | null = null
private connected: boolean = false
private closedByUser: boolean = false
private pingTimer: number | null = null
private reconnectTimer: number | null = null
private retryMs: number = 1000
private rxBuf: Uint8Array = new Uint8Array(0)
private subscribedTopics: string[] = []
// SUBACK 只回 packetId,用两个平行数组记住「发出的订阅」以便把结果对回主题
private subPendMid: number[] = []
private subPendTopic: string[] = []
/** 连接结果回调:code=0 成功,1~5 见 MQTT CONNACK 返回码 */
onConnAck: ((code: number) => void) | null = null
/** 连接成功(CONNACK=0)后触发 */
onOpen: (() => void) | null = null
/** 收到消息回调 */
onMessage: ((topic: string, payload: string) => void) | null = null
/** 订阅结果回调:code=0/1/2 为授予的 QoS,128(0x80) 表示服务端拒绝(ACL/归属校验不通过) */
onSubAck: ((topic: string, code: number) => void) | null = null
/** 连接关闭回调 */
onClose: ((code: number) => void) | null = null
/** 错误回调 */
onError: ((msg: string) => void) | null = null
constructor(options: MqttClientOptions) {
this.opt = options
if (this.opt.keepalive == null || this.opt.keepalive <= 0) {
this.opt.keepalive = 60
}
if (this.opt.cleanSession == null) {
this.opt.cleanSession = true
}
if (this.opt.autoReconnect == null) {
this.opt.autoReconnect = true
}
this.rxBuf = new Uint8Array(0)
this.subscribedTopics = []
this.subPendMid = []
this.subPendTopic = []
this.pingTimer = null
this.reconnectTimer = null
this.retryMs = 1000
this.connected = false
this.closedByUser = false
}
get isConnected(): boolean {
return this.connected
}
connect(): void {
if (this.task != null) {
return
}
this.closedByUser = false
this.retryMs = 1000
this.consoleLog('connecting ' + this.opt.url)
const task = uni.connectSocket({
url: this.opt.url,
success: () => {
},
fail: (err) => {
this.emitError('connect fail: ' + JSON.stringify(err))
if (!this.closedByUser && this.opt.autoReconnect === true) {
this.scheduleReconnect()
}
}
})
this.task = task
task.onOpen(() => {
this.handleOpen()
})
task.onMessage((res) => {
this.handleMessage(res.data)
})
task.onError(() => {
this.emitError('socket error')
})
task.onClose((res) => {
this.handleClose(res.code)
})
}
/** 断开连接(不再自动重连) */
disconnect(): void {
this.closedByUser = true
this.connected = false
this.stopPing()
if (this.reconnectTimer != null) {
clearTimeout(this.reconnectTimer)
this.reconnectTimer = null
}
if (this.task != null) {
try {
// 发送 DISCONNECT(14)
this.send(this.buildPacket(14, new Uint8Array(0)))
} catch (e) {
}
this.task.close({ code: 1000, reason: 'bye' })
this.task = null
}
}
/** 立即重连(App 回前台时调用)。后台期间 JS 定时器暂停、心跳停发,连接可能已被服务端断开,
* 此时不等指数退避定时器,直接清理旧连接并重新连接 */
reconnectNow(): void {
this.closedByUser = false
this.retryMs = 1000
if (this.reconnectTimer != null) {
clearTimeout(this.reconnectTimer)
this.reconnectTimer = null
}
if (this.task != null) {
try {
this.task.close({ code: 1000, reason: 'reconnect' })
} catch (e) {
}
this.task = null
}
this.connected = false
this.stopPing()
this.connect()
}
/** 订阅主题,qos 0 或 1 */
subscribe(topic: string, qos: number = 0): void {
if (topic.length === 0) {
return
}
if (!this.connected) {
this.emitError('not connected, cannot subscribe ' + topic)
return
}
this.sendSubscribe(topic, qos)
if (this.subscribedTopics.indexOf(topic) < 0) {
this.subscribedTopics.push(topic)
}
}
/** 发布消息,retain 仅对设备命令慎用 */
publish(topic: string, payload: string, qos: number = 0, retain: boolean = false): void {
if (!this.connected) {
this.emitError('not connected, cannot publish')
return
}
const flags = ((qos & 0x03) << 1) | (retain ? 0x01 : 0x00)
const body = this.join([this.encodeStr(topic), this.utf8Encode(payload)])
const pkt = this.buildPacket(3, body)
pkt[0] = (pkt[0] & 0xf0) | flags
this.send(pkt)
}
// ------------------------------------------------------------------
// 私有:WebSocket 生命周期
// ------------------------------------------------------------------
private handleOpen(): void {
// 打开后立刻发送 MQTT CONNECT(1)
const body = this.buildConnect()
this.send(this.buildPacket(1, body))
}
private handleMessage(data: string | ArrayBuffer): void {
let u8: Uint8Array
if (data instanceof ArrayBuffer) {
u8 = new Uint8Array(data)
} else {
u8 = this.utf8Encode(data)
}
this.rxBuf = this.join([this.rxBuf, u8])
while (this.rxBuf.length > 0) {
const pkt = this.parsePacket(this.rxBuf)
if (pkt == null) {
break
}
// 移除已消费字节
const restLen = this.rxBuf.length - pkt.length
const rest = new Uint8Array(restLen)
for (let i = 0; i < restLen; i++) {
rest[i] = this.rxBuf[pkt.length + i]
}
this.rxBuf = rest
this.handleCmd(pkt.cmd, pkt.body)
}
}
private handleClose(code: number): void {
this.connected = false
this.stopPing()
this.task = null
if (this.onClose != null) {
this.onClose(code)
}
if (!this.closedByUser && this.opt.autoReconnect === true) {
this.scheduleReconnect()
}
}
private scheduleReconnect(): void {
if (this.reconnectTimer != null) {
return
}
this.consoleLog('reconnect in ' + this.retryMs + 'ms')
this.reconnectTimer = setTimeout(() => {
this.reconnectTimer = null
this.connect()
}, this.retryMs)
this.retryMs = Math.min(this.retryMs * 2, 30000)
}
private startPing(): void {
this.stopPing()
const ka = this.opt.keepalive != null ? this.opt.keepalive : 10
const period = Math.max(10, Math.floor(ka / 2)) * 1000
this.pingTimer = setInterval(() => {
if (this.connected) {
// PINGREQ(12)
this.send(this.buildPacket(12, new Uint8Array(0)))
}
}, period)
}
private stopPing(): void {
if (this.pingTimer != null) {
clearInterval(this.pingTimer)
this.pingTimer = null
}
}
// ------------------------------------------------------------------
// 私有:MQTT 报文
// ------------------------------------------------------------------
private buildConnect(): Uint8Array {
const vh: Uint8Array[] = []
// 协议名 "MQTT"
vh.push(new Uint8Array([0x00, 0x04]))
vh.push(this.utf8Encode('MQTT'))
// 协议级别 4(MQTT 3.1.1)
vh.push(new Uint8Array([0x04]))
// 连接标志
const user = this.opt.username != null ? this.opt.username : ''
const pass = this.opt.password != null ? this.opt.password : ''
let flags = this.opt.cleanSession === true ? 0x02 : 0x00
if (user.length > 0) {
flags |= 0x80
}
if (pass.length > 0) {
flags |= 0x40
}
const ka = this.opt.keepalive != null ? this.opt.keepalive : 10
vh.push(new Uint8Array([flags, (ka >> 8) & 0xff, ka & 0xff]))
// Payload:clientId + username + password
vh.push(this.encodeStr(this.opt.clientId))
if (user.length > 0) {
vh.push(this.encodeStr(user))
}
if (pass.length > 0) {
vh.push(this.encodeStr(pass))
}
return this.join(vh)
}
private sendSubscribe(topic: string, qos: number): void {
const mid = Math.floor(Math.random() * 65535) + 1
const body = this.join([
new Uint8Array([(mid >> 8) & 0xff, mid & 0xff]),
this.encodeStr(topic),
new Uint8Array([qos & 0x03])
])
const pkt = this.buildPacket(8, body)
this.subPendMid.push(mid)
this.subPendTopic.push(topic)
// SUBSCRIBE 固定头固定为 0x82
pkt[0] = 0x82
this.send(pkt)
}
private handleCmd(cmd: number, body: Uint8Array): void {
if (cmd === 2) {
// CONNACK
const code = body.length >= 2 ? body[1] : -1
if (code === 0) {
this.connected = true
this.retryMs = 1000
// 恢复订阅
for (let i = 0; i < this.subscribedTopics.length; i++) {
this.sendSubscribe(this.subscribedTopics[i], 0)
}
this.startPing()
if (this.onConnAck != null) {
this.onConnAck(0)
}
if (this.onOpen != null) {
this.onOpen()
}
} else {
this.connected = false
this.emitError('CONNACK reject code=' + code)
if (this.onConnAck != null) {
this.onConnAck(code)
}
this.disconnect()
}
} else if (cmd === 3) {
// PUBLISH
const p = this.parsePublish(body)
if (p != null && this.onMessage != null) {
this.onMessage(p.topic, p.payload)
}
} else if (cmd === 9) {
// SUBACK:body = [packetIdHi, packetIdLo, code...]
this.parseSubAck(body)
}
// PINGRESP(13) / PUBACK(4) 联调场景忽略
}
/** 解析 SUBACK,把 code 对回到主题:128(0x80)=服务端拒绝订阅(ACL 未放行 / 非本人设备) */
private parseSubAck(body: Uint8Array): void {
if (body.length < 3) {
return
}
const mid = (body[0] << 8) | body[1]
const code = body[2]
let idx = -1
for (let i = 0; i < this.subPendMid.length; i++) {
if (this.subPendMid[i] === mid) {
idx = i
break
}
}
if (idx < 0) {
return
}
const topic = this.subPendTopic[idx]
this.subPendMid.splice(idx, 1)
this.subPendTopic.splice(idx, 1)
// 注意:订阅被拒**不是**连接错误,不走 emitError,否则调用方会把连接标记为失败
if (this.onSubAck != null) {
this.onSubAck(topic, code)
}
}
private parsePublish(body: Uint8Array): PublishInfo | null {
if (body.length < 2) {
return null
}
const tlen = (body[0] << 8) | body[1]
if (body.length < 2 + tlen) {
return null
}
const topic = this.utf8Decode(this.sliceU8(body, 2, tlen))
const payload = this.utf8Decode(this.sliceU8(body, 2 + tlen, body.length - 2 - tlen))
return { topic: topic, payload: payload }
}
private parsePacket(buf: Uint8Array): Packet | null {
if (buf.length < 2) {
return null
}
const cmd = (buf[0] >> 4) & 0x0f
let multiplier = 1
let remLen = 0
let idx = 1
let more = true
while (more) {
if (idx >= buf.length) {
return null
}
const b = buf[idx]
remLen += (b & 0x7f) * multiplier
idx += 1
multiplier *= 128
more = (b & 0x80) !== 0
if (multiplier > 128 * 128 * 128 * 128) {
return null
}
}
const total = idx + remLen
if (buf.length < total) {
return null
}
const body = this.sliceU8(buf, idx, remLen)
return { cmd: cmd, length: total, body: body }
}
private buildPacket(cmd: number, body: Uint8Array): Uint8Array {
let len = body.length
const rem: number[] = []
do {
let b = len % 128
len = Math.floor(len / 128)
if (len > 0) {
b |= 0x80
}
rem.push(b)
} while (len > 0)
const header = new Uint8Array(rem.length + 1)
header[0] = (cmd << 4) & 0xff
for (let i = 0; i < rem.length; i++) {
header[i + 1] = rem[i]
}
return this.join([header, body])
}
private encodeStr(str: string): Uint8Array {
const u8 = this.utf8Encode(str)
const out = new Uint8Array(u8.length + 2)
out[0] = (u8.length >> 8) & 0xff
out[1] = u8.length & 0xff
out.set(u8, 2)
return out
}
private send(data: Uint8Array): void {
if (this.task == null) {
return
}
const ab = new ArrayBuffer(data.length)
const copy = new Uint8Array(ab)
copy.set(data, 0)
this.task.send({ data: ab })
}
// ------------------------------------------------------------------
// 私有:字节工具(不依赖 TextEncoder,保证 uni-app x 兼容)
// ------------------------------------------------------------------
private join(parts: Uint8Array[]): Uint8Array {
let total = 0
for (let i = 0; i < parts.length; i++) {
total += parts[i].length
}
const out = new Uint8Array(total)
let off = 0
for (let i = 0; i < parts.length; i++) {
out.set(parts[i], off)
off += parts[i].length
}
return out
}
private sliceU8(buf: Uint8Array, start: number, len: number): Uint8Array {
const out = new Uint8Array(len)
for (let i = 0; i < len; i++) {
out[i] = buf[start + i]
}
return out
}
private utf8Encode(str: string): Uint8Array {
const enc = encodeURIComponent(str)
const bytes: number[] = []
let i = 0
while (i < enc.length) {
const c = enc.charAt(i)
if (c === '%') {
bytes.push(parseInt(enc.substring(i + 1, i + 3), 16))
i += 3
} else {
bytes.push(c.charCodeAt(0))
i += 1
}
}
const out = new Uint8Array(bytes.length)
for (let j = 0; j < bytes.length; j++) {
out[j] = bytes[j]
}
return out
}
private utf8Decode(bytes: Uint8Array): string {
let s = ''
let i = 0
while (i < bytes.length) {
const b = bytes[i]
if (b < 0x80) {
s += String.fromCharCode(b)
i += 1
} else if (b >= 0xc0 && b < 0xe0 && i + 1 < bytes.length) {
s += String.fromCharCode(((b & 0x1f) << 6) | (bytes[i + 1] & 0x3f))
i += 2
} else if (b >= 0xe0 && b < 0xf0 && i + 2 < bytes.length) {
s += String.fromCharCode(((b & 0x0f) << 12) | ((bytes[i + 1] & 0x3f) << 6) | (bytes[i + 2] & 0x3f))
i += 3
} else {
i += 1
}
}
return s
}
private emitError(msg: string): void {
this.consoleLog('ERROR ' + msg)
if (this.onError != null) {
this.onError(msg)
}
}
private consoleLog(msg: string): void {
console.log('[Mqtt] ' + msg)
}
}
+565
View File
@@ -0,0 +1,565 @@
<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>