Files
YwxAppThink/addon/iot/client/uvue/mqtt-debug/MqttClient.uts
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

561 lines
16 KiB
Plaintext
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.
/**
* 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)
}
}