@microi.net/cli 4.9.3 → 4.9.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.codebuddy-plugin/marketplace.json +2 -2
- package/.codebuddy-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/.workbuddy-plugin/marketplace.json +2 -2
- package/.workbuddy-plugin/plugin.json +1 -1
- package/assets/build-meta.json +5 -5
- package/package.json +1 -1
- package/scripts/mcp-server.js +92 -92
- package/scripts/microi-cli.js +25 -25
- package/scripts/microi-skills.meta.json +157 -130
- package/skills/.microi-skills-version.json +2 -2
- package/skills/README.md +3 -1
- package/skills/app-store/SKILL.md +10 -2
- package/skills/microi-ai-application/SKILL.md +1 -1
- package/skills/microi-docs-coverage/references/capability-map.md +4 -1
- package/skills/microi-form-engine/SKILL.md +6 -3
- package/skills/microi-form-layout/SKILL.md +18 -5
- package/skills/microi-microservice/SKILL.md +1 -1
- package/skills/microi-system-delivery/SKILL.md +5 -2
- package/skills/print-engine/SKILL.md +5 -4
- package/skills/unity-integration/SKILL.md +151 -0
- package/skills/unity-integration/agents/openai.yaml +4 -0
- package/skills/unity-integration/references/ai-app-delivery.md +98 -0
- package/skills/unity-integration/references/sdk-api.md +82 -0
- package/skills/unity-integration/references/toolbox-migration.md +66 -0
- package/skills/unity-integration/references/webgl-hosting.md +57 -0
- package/skills/v8-frontend-events/SKILL.md +11 -7
- package/skills/v8-frontend-events/references/bluetooth-print-api.md +31 -3
- package/skills/v8-frontend-events/references/bluetooth-print.md +54 -12
- package/skills/v8-mq-mqtt/SKILL.md +140 -52
- package/skills/v8-mq-mqtt/references/mqtt-production.md +341 -0
- package/skills/v8-mq-mqtt/scripts/check-mqtt-skill-coverage.mjs +205 -0
- package/skills/v8-utilities/references/client-api-index.md +4 -2
- package/skills/workspace-conventions/SKILL.md +3 -2
|
@@ -0,0 +1,341 @@
|
|
|
1
|
+
# Microi MQTT 生产参考
|
|
2
|
+
|
|
3
|
+
本参考用于设计或审查 Microi MQTT 配置、事件接口引擎、设备路由、服务端下行、
|
|
4
|
+
安全边界和生产部署。优先以目标版本源码与实际租户元数据为准;旧部署可能尚未
|
|
5
|
+
具备本文列出的全部字段或行为。
|
|
6
|
+
|
|
7
|
+
## 目录
|
|
8
|
+
|
|
9
|
+
- [事实源与适用边界](#事实源与适用边界)
|
|
10
|
+
- [接入架构与协议边界](#接入架构与协议边界)
|
|
11
|
+
- [SaaS 配置矩阵](#saas-配置矩阵)
|
|
12
|
+
- [租户识别与连接认证](#租户识别与连接认证)
|
|
13
|
+
- [Topic ACL 与规范化](#topic-acl-与规范化)
|
|
14
|
+
- [事件与字段可用性](#事件与字段可用性)
|
|
15
|
+
- [V8 返回值和失败关闭](#v8-返回值和失败关闭)
|
|
16
|
+
- [设备级接口引擎](#设备级接口引擎)
|
|
17
|
+
- [安全的遥测处理模式](#安全的遥测处理模式)
|
|
18
|
+
- [服务端安全下行](#服务端安全下行)
|
|
19
|
+
- [数据分层与可观测性](#数据分层与可观测性)
|
|
20
|
+
- [单节点与多节点部署](#单节点与多节点部署)
|
|
21
|
+
- [上线验收清单](#上线验收清单)
|
|
22
|
+
- [自动覆盖检查的证据边界](#自动覆盖检查的证据边界)
|
|
23
|
+
|
|
24
|
+
## 事实源与适用边界
|
|
25
|
+
|
|
26
|
+
按以下顺序确认当前能力,不要仅凭示例或历史文档推断:
|
|
27
|
+
|
|
28
|
+
1. `Microi.Server/Microi.Core/Model/MqttParam.cs`:`V8.MQTT` 可用字段。
|
|
29
|
+
2. `Microi.Server/Microi.MQTT/MicroiMQTT.cs`:Broker、认证、Topic ACL、事件、
|
|
30
|
+
返回值、设备缓存和日志的真实行为。
|
|
31
|
+
3. `Microi.Server/Microi.Core/SaaSEngine/TenantConfigurationSecurity.cs`:共享监听
|
|
32
|
+
配置、租户凭据、Topic 规范化和敏感字段边界。
|
|
33
|
+
4. `Microi.Server/Microi.Core/Interface/IMicroiMQTT.cs`:
|
|
34
|
+
`IMicroiMQTT.PublishAsync(osClient, ...)` 可信后端发布和节点状态接口。
|
|
35
|
+
5. `Microi.Server/Microi.net.Api/Controllers/MqttController.cs`:平台管理员、当前节点
|
|
36
|
+
状态与示例下行入口的权限边界。
|
|
37
|
+
6. `microi.doc/docs/doc/system-engine/mqtt-engine.md`:面向用户的完整能力说明。
|
|
38
|
+
|
|
39
|
+
目标服务器可能落后于当前源码。编写事件代码前回读其 `sys_osclients` 字段和运行
|
|
40
|
+
版本;缺少字段时走官方应用包/版本升级,不要在 V8 中伪造配置。
|
|
41
|
+
|
|
42
|
+
## 接入架构与协议边界
|
|
43
|
+
|
|
44
|
+
Microi 当前直接处理 MQTT,不直接解析 RS-485、ZigBee、BLE 或 Modbus 帧:
|
|
45
|
+
|
|
46
|
+
```text
|
|
47
|
+
现场设备 -> 边缘网关/协议转换 -> MQTT TCP/TLS -> Microi Broker
|
|
48
|
+
-> 租户认证与 Topic ACL -> mci_mqtt_client / mci_mqtt_log
|
|
49
|
+
-> V8.EventName + V8.MQTT -> 业务表、MongoDB、HTTP、告警、工单
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
边缘网关负责现场总线、采样与协议解析;Microi 负责租户边界、实时业务规则、数据
|
|
53
|
+
治理和应用联动。浏览器 MQTT 需要独立的 WebSocket MQTT 网关;当前隐藏元数据
|
|
54
|
+
`MqttWsPort` 不代表内嵌 Broker 已启用 WebSocket。
|
|
55
|
+
|
|
56
|
+
## SaaS 配置矩阵
|
|
57
|
+
|
|
58
|
+
| 字段 | 作用域 | 当前行为与默认值 | 变更生效 |
|
|
59
|
+
| --- | --- | --- | --- |
|
|
60
|
+
| `MqttEnable` | 每租户 | 主租户为 `1` 才启动 Broker;子租户为 `1` 才允许其连接 | 启停监听需重启 MQTT 节点 |
|
|
61
|
+
| `MqttPort` | 主租户共享 | TCP 监听端口,默认 `1883` | 重启 MQTT 节点 |
|
|
62
|
+
| `MqttUseTls` | 主租户共享 | `1` 时尝试启用 TLS 端点 | 重启 MQTT 节点 |
|
|
63
|
+
| `MqttTlsPort` | 主租户共享 | TLS 端口,默认 `8883` | 重启 MQTT 节点 |
|
|
64
|
+
| `MqttCertPath` | 主租户共享 | 进程/容器内可读的 PFX 路径 | 挂载证书后重启 |
|
|
65
|
+
| `MqttCertPassword` | 主租户共享秘密 | PFX 密码,只允许可信后端读取 | 轮换后重启 |
|
|
66
|
+
| `MqttFallbackPort` | 主租户运行时 | Windows 主端口被拒绝时使用;无有效配置则 `21883` | 重启 MQTT 节点 |
|
|
67
|
+
| `MqttWsPort` | 保留元数据 | 当前未创建 WebSocket 监听 | 不得宣称已生效 |
|
|
68
|
+
| `MqttAccount` | 每租户凭据 | 子租户必须独立、完整,不能与其它租户账号重复 | 新连接使用新值 |
|
|
69
|
+
| `MqttPwd` | 每租户秘密 | 子租户必须独立、完整,不能与其它租户密码重复 | 新连接使用新值 |
|
|
70
|
+
| `MqttApiEngine` | 每租户 | 默认 MQTT 事件接口引擎;兼容历史 GUID/ULID Id 和当前 `ApiEngineKey` | 按接口引擎缓存规则 |
|
|
71
|
+
| `MqttAllowAnonymous` | 仅主租户兼容 | 只有主租户显式为 `1` 才可能匿名;生产不建议 | 新连接使用新值 |
|
|
72
|
+
| `MqttTopicIsolation` | 兼容元数据 | 不能关闭强制租户 Topic ACL,子租户设为 `0` 也不放宽 | 无放宽语义 |
|
|
73
|
+
|
|
74
|
+
把 PFX 以只读 Secret/持久卷挂载,不把证书密码、MQTT 密码写入代码、URL、日志、
|
|
75
|
+
前端或普通 V8。运行时读取了某字段不等于所有历史数据库都已经拥有该字段;部署前
|
|
76
|
+
必须回读目标表结构。
|
|
77
|
+
|
|
78
|
+
当前 `MqttUseTls=1` 是在默认明文 TCP 端点之外增加 TLS 1.2 端点,不是 TLS-only
|
|
79
|
+
模式;证书路径无效时会写诊断,但默认 TCP Broker 仍可能启动。要求强制加密时,
|
|
80
|
+
除验证 TLS 握手外,还要在防火墙/入口层关闭公网明文端口,不能只看 `IsRunning`。
|
|
81
|
+
|
|
82
|
+
## 租户识别与连接认证
|
|
83
|
+
|
|
84
|
+
连接验证按以下优先级解析租户:
|
|
85
|
+
|
|
86
|
+
1. MQTT v5 User Property `OsClient`;
|
|
87
|
+
2. Username 的 `<OsClient>:<MqttAccount>` 前缀;
|
|
88
|
+
3. ClientId 的 `<OsClient>:<设备Id>` 前缀;
|
|
89
|
+
4. 旧主租户客户端仅在 Username 精确等于主租户 `MqttAccount` 时兼容。
|
|
90
|
+
|
|
91
|
+
同时提供多个来源时必须全部指向同一租户。显式未知租户、未启用租户、空/非法
|
|
92
|
+
ClientId、错误密码和跨租户 ClientId 冲突均失败关闭,不回退主租户。
|
|
93
|
+
|
|
94
|
+
子租户还必须满足:
|
|
95
|
+
|
|
96
|
+
- 账号与密码都非空;
|
|
97
|
+
- 账号不能与任一其它租户账号相同;
|
|
98
|
+
- 密码不能与任一其它租户密码相同;
|
|
99
|
+
- 不能通过 `MqttAllowAnonymous=1` 或 `MqttTopicIsolation=0` 绕过边界。
|
|
100
|
+
|
|
101
|
+
凭据使用常量时间字符串比较。不要在 Payload 中传一个 `OsClient` 后自行切换租户;
|
|
102
|
+
业务代码只信任 `V8.MQTT.OsClient`。
|
|
103
|
+
|
|
104
|
+
同一 ClientId 快速重连时,每次有效连接持有独立会话令牌。旧连接的延迟
|
|
105
|
+
`Disconnected` 会被记录为 `StaleDisconnectIgnored`,不会删除替代连接的租户映射
|
|
106
|
+
或错误标记新会话下线。
|
|
107
|
+
|
|
108
|
+
## Topic ACL 与规范化
|
|
109
|
+
|
|
110
|
+
业务 Topic 会被收敛为:
|
|
111
|
+
|
|
112
|
+
```text
|
|
113
|
+
tenant/{lowerOsClient}/{businessTopic}
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
| 输入 | 结果 |
|
|
117
|
+
| --- | --- |
|
|
118
|
+
| `sensor/temperature` | 自动加当前租户前缀 |
|
|
119
|
+
| `tenant/<当前租户>/sensor/temperature` | 保留并规范化租户大小写 |
|
|
120
|
+
| `<当前租户>/sensor/temperature` | 兼容旧前缀并转为标准格式 |
|
|
121
|
+
| `tenant/<其它租户>/...` | 拒绝 |
|
|
122
|
+
| `$SYS/...`、`$share/...` | 拒绝 |
|
|
123
|
+
| 发布 Topic 包含 `+` 或 `#` | 拒绝 |
|
|
124
|
+
| 订阅 `sensor/+/state` 或 `sensor/#` | 允许合法完整段通配符并加租户前缀 |
|
|
125
|
+
| 包含控制字符、反斜杠、`//`、`.` 或 `..` 路径段 | 拒绝 |
|
|
126
|
+
|
|
127
|
+
发布、订阅、Retained Message、可信后端下行和 MQTT v5 `ResponseTopic` 都执行同一
|
|
128
|
+
租户边界。`#` 只能是订阅的最后一个完整段,`+` 只能作为完整段。
|
|
129
|
+
|
|
130
|
+
## 事件与字段可用性
|
|
131
|
+
|
|
132
|
+
| `V8.EventName` | 触发时机 | 主要字段 | 返回值影响 |
|
|
133
|
+
| --- | --- | --- | --- |
|
|
134
|
+
| `StartServer` | Broker 成功启动后,逐个启用且配置引擎的租户 | `OsClient` | 不改变启动结果 |
|
|
135
|
+
| `Connected` | 认证成功、设备表/日志更新后 | `ClientId`、`OsClient`、`UserName`、`UserProperties` | 不能否决连接 |
|
|
136
|
+
| `Disconnected` | 当前有效会话断开、设备表/日志更新后 | `ClientId`、`OsClient`、`UserProperties` | 不能否决断开 |
|
|
137
|
+
| `Subscribing` | Topic ACL 已通过、订阅日志写入后 | `ClientId`、`OsClient`、`Topic`、`UserProperties` | 不能否决订阅 |
|
|
138
|
+
| `MessageReceived` | 发布 Topic 通过 ACL、接收日志写入后 | `ClientId`、`OsClient`、`Topic`、`Payload`、`PayloadRaw`、`Qos`、`Retain`、`UserProperties` | `Code != 1` 阻止广播 |
|
|
139
|
+
| `MessageChanged` | Retained Message 变化并通过 ACL 后 | `ClientId`、`OsClient`、`Topic`、`Payload`、`PayloadRaw`、`Qos`、`Retain` | 返回值不改变结果 |
|
|
140
|
+
| `StopServer` | Broker 正常停止后,逐个启用且配置引擎的租户 | `OsClient` | 不改变停止结果 |
|
|
141
|
+
|
|
142
|
+
`V8.MQTT` 完整模型:
|
|
143
|
+
|
|
144
|
+
| 字段 | 类型 | 说明 |
|
|
145
|
+
| --- | --- | --- |
|
|
146
|
+
| `ClientId` | `string` | 设备/客户端 Id |
|
|
147
|
+
| `Payload` | `object / string` | JSON 自动反序列化;失败时为原始字符串 |
|
|
148
|
+
| `PayloadRaw` | `string` | 原始 UTF-8 文本,适合验签与审计 |
|
|
149
|
+
| `Topic` | `string` | 已规范化的完整 Topic |
|
|
150
|
+
| `OsClient` | `string` | 已校验租户 |
|
|
151
|
+
| `UserName` | `string` | 连接用户名,仅部分事件有值 |
|
|
152
|
+
| `Qos` | `number` | `0`、`1`、`2` |
|
|
153
|
+
| `Retain` | `boolean` | Retain 标记 |
|
|
154
|
+
| `UserProperties` | `object` | MQTT v5 用户属性;没有时为空 |
|
|
155
|
+
|
|
156
|
+
按事件读取字段,不要假设生命周期事件也有 ClientId/Payload,或连接事件已有 Topic。
|
|
157
|
+
|
|
158
|
+
## V8 返回值和失败关闭
|
|
159
|
+
|
|
160
|
+
只有 `MessageReceived` 把接口引擎返回值作为发布策略:
|
|
161
|
+
|
|
162
|
+
- `null`/无返回值:放行;
|
|
163
|
+
- 字符串、数字等普通历史返回值:放行;
|
|
164
|
+
- 没有 `Code` 的对象:放行;
|
|
165
|
+
- `Code: 1`:放行;
|
|
166
|
+
- 显式 `Code != 1`:阻止向订阅者广播并写系统诊断;
|
|
167
|
+
- 已配置的事件引擎执行异常:归一为 `Code: 0`,失败关闭。
|
|
168
|
+
|
|
169
|
+
没有配置 `MqttApiEngine` 时不存在 V8 策略闸门,Broker 仍只执行内置认证与 Topic
|
|
170
|
+
ACL。接收日志在 V8 执行前进入 `mci_mqtt_log`,因此规则拒绝的消息仍可审计。
|
|
171
|
+
|
|
172
|
+
`Connected`、`Disconnected`、`Subscribing`、`MessageChanged` 和生命周期事件是业务
|
|
173
|
+
观察/联动入口,当前返回值不会反向改变底层协议动作。不要误写“在 `Connected`
|
|
174
|
+
返回 `Code: 0` 即可拒绝连接”或“在 `Subscribing` 返回失败即可拒绝订阅”。
|
|
175
|
+
|
|
176
|
+
## 设备级接口引擎
|
|
177
|
+
|
|
178
|
+
`mci_mqtt_client.ApiEngineId` 可覆盖租户 `sys_osclients.MqttApiEngine`。连接时平台:
|
|
179
|
+
|
|
180
|
+
1. 新增或更新 `ClientId`、`LastConnectTime`、`IsOnline`;
|
|
181
|
+
2. 把已有设备 `ApiEngineId` 放进当前节点缓存;
|
|
182
|
+
3. 优先使用设备引擎处理当前连接期间的 MQTT 事件;
|
|
183
|
+
4. 缺少设备引擎时回退租户默认引擎。
|
|
184
|
+
|
|
185
|
+
历史 JoinForm 配置可能保存 `sys_apiengine.Id`(GUID/ULID);运行时会解析为真实
|
|
186
|
+
`ApiEngineKey`。新配置优先保存 Key。
|
|
187
|
+
|
|
188
|
+
设备级缓存是当前节点、当前连接的优化,不是共享事实。修改 `ApiEngineId` 后让设备
|
|
189
|
+
重新连接。当前源码在有效断开时先移除连接与设备引擎缓存,因此
|
|
190
|
+
`Disconnected` 事件会走租户默认引擎;需要设备专属离线业务时,在租户默认引擎
|
|
191
|
+
按 `ClientId` 查询设备配置,不要假设断开事件仍持有设备缓存。
|
|
192
|
+
|
|
193
|
+
## 安全的遥测处理模式
|
|
194
|
+
|
|
195
|
+
```javascript
|
|
196
|
+
var mqtt = V8.MQTT || {};
|
|
197
|
+
|
|
198
|
+
if (V8.EventName !== 'MessageReceived') {
|
|
199
|
+
return { Code: 1 };
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
var data = mqtt.Payload;
|
|
203
|
+
if (typeof data === 'string') {
|
|
204
|
+
try {
|
|
205
|
+
data = JSON.parse(data);
|
|
206
|
+
} catch (ex) {
|
|
207
|
+
return { Code: 0, Msg: 'Payload 必须是合法 JSON。' };
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
if (!data || !data.eventId) {
|
|
212
|
+
return { Code: 0, Msg: '缺少稳定的 eventId。' };
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
var temperature = Number(data.temperature);
|
|
216
|
+
if (isNaN(temperature) || temperature < -80 || temperature > 200) {
|
|
217
|
+
return { Code: 0, Msg: 'temperature 超出允许范围。' };
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// iot_telemetry_ingest 必须按 mqtt.OsClient + data.eventId 做唯一约束/inbox 去重。
|
|
221
|
+
var result = V8.ApiEngine.Run('iot_telemetry_ingest', {
|
|
222
|
+
eventId: data.eventId,
|
|
223
|
+
osClient: mqtt.OsClient,
|
|
224
|
+
clientId: mqtt.ClientId,
|
|
225
|
+
topic: mqtt.Topic,
|
|
226
|
+
temperature: temperature,
|
|
227
|
+
qos: mqtt.Qos,
|
|
228
|
+
retain: mqtt.Retain,
|
|
229
|
+
payloadRaw: mqtt.PayloadRaw
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
return result && result.Code === 1
|
|
233
|
+
? { Code: 1 }
|
|
234
|
+
: { Code: 0, Msg: (result && result.Msg) || '遥测处理失败。' };
|
|
235
|
+
```
|
|
236
|
+
|
|
237
|
+
不要在重试时用 `NewUlid()` 重新生成业务幂等键。稳定 `EventId` 应由设备/网关生成,
|
|
238
|
+
或由网关基于设备序列号、消息序号和采样时间确定性构造。消费端仍需数据库唯一约束、
|
|
239
|
+
inbox/outbox、状态机或条件更新,QoS 2 也不能代替业务幂等。
|
|
240
|
+
|
|
241
|
+
## 服务端安全下行
|
|
242
|
+
|
|
243
|
+
可信后端只调用带租户上下文的接口:
|
|
244
|
+
|
|
245
|
+
```csharp
|
|
246
|
+
await mqttService.PublishAsync(
|
|
247
|
+
osClient,
|
|
248
|
+
$"device/{deviceId}/command",
|
|
249
|
+
JsonConvert.SerializeObject(new { Action = "restart", EventId = eventId }),
|
|
250
|
+
qos: 1,
|
|
251
|
+
retain: false);
|
|
252
|
+
```
|
|
253
|
+
|
|
254
|
+
运行时会规范化 Topic、`ResponseTopic`,覆盖 User Property 中的 `OsClient`,并用内部
|
|
255
|
+
租户 SenderClientId 注入消息。缺少 `osClient` 的旧原生重载会直接抛错拒绝。
|
|
256
|
+
|
|
257
|
+
`V8.MQTT` 是只读事件上下文,不是 `Publish` API。若需要 V8 下行,先在 C# 提供
|
|
258
|
+
最小、租户隔离、不可覆盖基础设施秘密的原子能力,再由接口引擎做菜单/表/行权限、
|
|
259
|
+
状态机、稳定 EventId、审计和业务编排;不要开放匿名通用发布 Controller。
|
|
260
|
+
|
|
261
|
+
## 数据分层与可观测性
|
|
262
|
+
|
|
263
|
+
| 数据 | 推荐位置 | 原因 |
|
|
264
|
+
| --- | --- | --- |
|
|
265
|
+
| 设备档案、阈值、归属、工单、告警状态 | FormEngine/关系库 | 需要事务、权限和后台维护 |
|
|
266
|
+
| 高频遥测、采样序列 | MongoDB/专用时序存储 | 便于分区、保留和批量查询 |
|
|
267
|
+
| 图片、音频、固件、大文件 | 对象存储/文件服务 | MQTT 只传文件 Id、哈希和元数据 |
|
|
268
|
+
| Broker 运行审计 | `mci_mqtt_log` | 排障,不代替长期遥测仓 |
|
|
269
|
+
| 当前设备接入台账 | `mci_mqtt_client` | 最后连接、基础在线状态、设备引擎 |
|
|
270
|
+
|
|
271
|
+
系统日志 `Type=MQTT` 记录端口占用、TLS、认证拒绝、Topic ACL、V8 异常和旧连接
|
|
272
|
+
断开忽略等诊断。当前 `mci_mqtt_log` 的 `Receive` 审计会保存解析后的 Payload 与
|
|
273
|
+
`PayloadRaw`;不要在 MQTT Payload 携带密码、Token 等秘密,并为该表配置严格权限、
|
|
274
|
+
脱敏、保留、归档和容量策略。日志/设备表写入失败只记录告警,不会停止消息主流程,
|
|
275
|
+
因此它们不能单独作为“消息一定持久化”的证明。
|
|
276
|
+
|
|
277
|
+
`mci_mqtt_client.IsOnline` 只能反映运行时最后写入的基础状态。节点崩溃不会保证
|
|
278
|
+
产生 `Disconnected`;业务在线判断应结合共享数据库中的心跳、最后活动时间、超时
|
|
279
|
+
窗口和设备状态机。
|
|
280
|
+
|
|
281
|
+
## 单节点与多节点部署
|
|
282
|
+
|
|
283
|
+
### 单节点或独立 MQTT 节点
|
|
284
|
+
|
|
285
|
+
- 显式映射 `MqttPort` 和 `MqttTlsPort`,开放防火墙/负载入口。
|
|
286
|
+
- 把证书挂载为只读文件;轮换后重启 MQTT 节点。
|
|
287
|
+
- 把 MQTT TCP/TLS 流量稳定路由到该节点,不要求 HTTP 会话粘滞来保证业务正确。
|
|
288
|
+
- `GetConnectedClients(osClient)` 和管理状态接口只用于当前节点诊断。
|
|
289
|
+
|
|
290
|
+
### 多 API 节点
|
|
291
|
+
|
|
292
|
+
内嵌 Broker 的连接、会话、订阅、Retained Message 和内存缓存不会跨 API 节点共享。
|
|
293
|
+
不要让负载均衡后的每个 API 节点各启动一套 Broker,再宣称它们组成一个集群。
|
|
294
|
+
|
|
295
|
+
选择以下一种生产架构:
|
|
296
|
+
|
|
297
|
+
1. 仅在独立 MQTT 节点启用内嵌 Broker,TCP/TLS 入口固定路由到该节点;
|
|
298
|
+
2. 使用支持持久化与集群的外部 Broker,并通过租户感知网关/适配器进入 Microi
|
|
299
|
+
事件链;外部 Broker 本身不会自动触发当前进程内 `V8.MQTT`;
|
|
300
|
+
3. 所有业务副作用使用稳定 EventId、数据库唯一约束、inbox/outbox 和条件更新。
|
|
301
|
+
|
|
302
|
+
若要求掉电或强杀窗口内零丢失,必须在返回成功前获得外部 Broker 持久化、共享
|
|
303
|
+
outbox 或同步 WAL 的确认;内存状态与异步稍后写库不能覆盖该窗口。
|
|
304
|
+
|
|
305
|
+
## 上线验收清单
|
|
306
|
+
|
|
307
|
+
- [ ] 主租户启用后 TCP 端口真实监听;TLS 证书链、端口和客户端握手真实可用。
|
|
308
|
+
- [ ] 正确凭据连接成功;错误密码、未知/未启用租户、凭据碰撞均被拒绝。
|
|
309
|
+
- [ ] 多租户来源冲突、跨租户 ClientId、空或非法 ClientId 被拒绝。
|
|
310
|
+
- [ ] 普通 Topic 自动加租户前缀;其它租户、`$SYS`、`$share`、非法路径被拒绝。
|
|
311
|
+
- [ ] 合法订阅通配符、QoS 0/1/2、Retain、`ResponseTopic` 按预期工作。
|
|
312
|
+
- [ ] 七类事件按实际触发时机进入正确租户,字段可用性与本参考一致。
|
|
313
|
+
- [ ] `MessageReceived` 返回 `Code: 0` 或执行异常时订阅端收不到广播,审计仍存在。
|
|
314
|
+
- [ ] JSON 与 UTF-8 文本载荷都按策略处理,非法载荷不会写业务表。
|
|
315
|
+
- [ ] 设备级 `ApiEngineId` 重连后生效,历史 Id 能解析为 `ApiEngineKey`。
|
|
316
|
+
- [ ] 同一 ClientId 快速重连时,旧断开不会把新连接标记为离线。
|
|
317
|
+
- [ ] 可信后端下行被强制限制在当前租户 Topic,旧无租户重载被拒绝。
|
|
318
|
+
- [ ] 重复消息、接口超时、数据库短故障、节点重启后,业务副作用仍然至多一次。
|
|
319
|
+
- [ ] 多节点场景验证入口路由、故障转移与共享业务状态,不把节点快照当全局事实。
|
|
320
|
+
- [ ] 峰值压测记录 Broker、V8、关系库/MongoDB、日志的 CPU、内存、延迟与磁盘增长。
|
|
321
|
+
|
|
322
|
+
静态源码、自动测试、本地 Broker、生产网络和真实硬件属于不同证据层。未执行某一层
|
|
323
|
+
时必须明确说明,不能用另一层的成功替代。
|
|
324
|
+
|
|
325
|
+
## 自动覆盖检查的证据边界
|
|
326
|
+
|
|
327
|
+
运行:
|
|
328
|
+
|
|
329
|
+
```powershell
|
|
330
|
+
node microi.skills/v8-mq-mqtt/scripts/check-mqtt-skill-coverage.mjs
|
|
331
|
+
```
|
|
332
|
+
|
|
333
|
+
脚本验证当前源码提取到的 MQTT 事件与 `MqttParam` 属性都出现在主 Skill、生产参考、
|
|
334
|
+
官网 MQTT 文档和 V8 后端索引中,并检查配置、安全、设备路由、下行、节点状态与
|
|
335
|
+
部署关键字。它不能证明:
|
|
336
|
+
|
|
337
|
+
- 字段已升级到某台目标服务器;
|
|
338
|
+
- Broker 已监听或证书有效;
|
|
339
|
+
- 外部 Broker/网关适配器已实现;
|
|
340
|
+
- V8 示例在目标表结构上运行成功;
|
|
341
|
+
- 真实设备、网络抖动、吞吐、掉电和多节点故障转移已经实测。
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
4
|
+
|
|
5
|
+
const scriptDir = path.dirname(fileURLToPath(import.meta.url));
|
|
6
|
+
const workspaceRoot = path.resolve(scriptDir, '..', '..', '..');
|
|
7
|
+
|
|
8
|
+
const files = {
|
|
9
|
+
skill: 'microi.skills/v8-mq-mqtt/SKILL.md',
|
|
10
|
+
reference: 'microi.skills/v8-mq-mqtt/references/mqtt-production.md',
|
|
11
|
+
mqttDoc: 'microi.doc/docs/doc/system-engine/mqtt-engine.md',
|
|
12
|
+
v8Doc: 'microi.doc/docs/doc/v8-engine/v8-server.md',
|
|
13
|
+
runtime: 'Microi.Server/Microi.MQTT/MicroiMQTT.cs',
|
|
14
|
+
model: 'Microi.Server/Microi.Core/Model/MqttParam.cs',
|
|
15
|
+
mqttInterface: 'Microi.Server/Microi.Core/Interface/IMicroiMQTT.cs',
|
|
16
|
+
tenantSecurity: 'Microi.Server/Microi.Core/SaaSEngine/TenantConfigurationSecurity.cs',
|
|
17
|
+
controller: 'Microi.Server/Microi.net.Api/Controllers/MqttController.cs'
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
const content = {};
|
|
21
|
+
const failures = [];
|
|
22
|
+
|
|
23
|
+
for (const [name, relativePath] of Object.entries(files)) {
|
|
24
|
+
const absolutePath = path.join(workspaceRoot, relativePath);
|
|
25
|
+
if (!fs.existsSync(absolutePath)) {
|
|
26
|
+
failures.push(`${name}: 文件不存在 ${relativePath}`);
|
|
27
|
+
content[name] = '';
|
|
28
|
+
continue;
|
|
29
|
+
}
|
|
30
|
+
content[name] = fs.readFileSync(absolutePath, 'utf8');
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function collectMatches(text, regex) {
|
|
34
|
+
const values = [];
|
|
35
|
+
for (const match of text.matchAll(regex)) values.push(match[1]);
|
|
36
|
+
return values;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function requireTokens(targetName, tokens) {
|
|
40
|
+
const text = content[targetName] || '';
|
|
41
|
+
for (const token of tokens) {
|
|
42
|
+
if (!text.includes(token)) failures.push(`${targetName}: 缺少 ${token}`);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function requireAny(targetName, alternatives, label) {
|
|
47
|
+
const text = content[targetName] || '';
|
|
48
|
+
if (!alternatives.some((token) => text.includes(token))) {
|
|
49
|
+
failures.push(`${targetName}: 缺少 ${label}`);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const runtimeEvents = new Set([
|
|
54
|
+
...collectMatches(
|
|
55
|
+
content.runtime,
|
|
56
|
+
/RunMqttV8EngineAsync\([^,\r\n]+,\s*"([A-Za-z]+)"/g
|
|
57
|
+
),
|
|
58
|
+
...collectMatches(
|
|
59
|
+
content.runtime,
|
|
60
|
+
/FireV8EventForAllTenantsAsync\(\s*"([A-Za-z]+)"/g
|
|
61
|
+
)
|
|
62
|
+
]);
|
|
63
|
+
|
|
64
|
+
const expectedEvents = [
|
|
65
|
+
'StartServer',
|
|
66
|
+
'Connected',
|
|
67
|
+
'Disconnected',
|
|
68
|
+
'Subscribing',
|
|
69
|
+
'MessageReceived',
|
|
70
|
+
'MessageChanged',
|
|
71
|
+
'StopServer'
|
|
72
|
+
];
|
|
73
|
+
|
|
74
|
+
for (const eventName of expectedEvents) {
|
|
75
|
+
if (!runtimeEvents.has(eventName)) failures.push(`runtime: 未提取到事件 ${eventName}`);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const mqttProperties = new Set(collectMatches(
|
|
79
|
+
content.model,
|
|
80
|
+
/public\s+[A-Za-z0-9_<>,.\s]+\s+([A-Za-z0-9_]+)\s*\{\s*get;\s*set;\s*\}/g
|
|
81
|
+
));
|
|
82
|
+
|
|
83
|
+
const expectedProperties = [
|
|
84
|
+
'ClientId',
|
|
85
|
+
'Payload',
|
|
86
|
+
'PayloadRaw',
|
|
87
|
+
'Topic',
|
|
88
|
+
'OsClient',
|
|
89
|
+
'UserName',
|
|
90
|
+
'Qos',
|
|
91
|
+
'Retain',
|
|
92
|
+
'UserProperties'
|
|
93
|
+
];
|
|
94
|
+
|
|
95
|
+
for (const propertyName of expectedProperties) {
|
|
96
|
+
if (!mqttProperties.has(propertyName)) failures.push(`model: 未提取到属性 ${propertyName}`);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const sourceEvents = [...runtimeEvents].sort();
|
|
100
|
+
const sourceProperties = [...mqttProperties].sort();
|
|
101
|
+
for (const targetName of ['skill', 'reference', 'mqttDoc', 'v8Doc']) {
|
|
102
|
+
requireTokens(targetName, sourceEvents);
|
|
103
|
+
requireTokens(targetName, sourceProperties);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const configurationTokens = [
|
|
107
|
+
'MqttEnable',
|
|
108
|
+
'MqttPort',
|
|
109
|
+
'MqttUseTls',
|
|
110
|
+
'MqttTlsPort',
|
|
111
|
+
'MqttCertPath',
|
|
112
|
+
'MqttCertPassword',
|
|
113
|
+
'MqttFallbackPort',
|
|
114
|
+
'MqttWsPort',
|
|
115
|
+
'MqttAccount',
|
|
116
|
+
'MqttPwd',
|
|
117
|
+
'MqttApiEngine',
|
|
118
|
+
'MqttAllowAnonymous',
|
|
119
|
+
'MqttTopicIsolation'
|
|
120
|
+
];
|
|
121
|
+
|
|
122
|
+
const securityTokens = [
|
|
123
|
+
'tenant/{lowerOsClient}',
|
|
124
|
+
'$SYS',
|
|
125
|
+
'$share',
|
|
126
|
+
'ResponseTopic',
|
|
127
|
+
'Code != 1',
|
|
128
|
+
'StaleDisconnectIgnored'
|
|
129
|
+
];
|
|
130
|
+
|
|
131
|
+
const operationsTokens = [
|
|
132
|
+
'mci_mqtt_client',
|
|
133
|
+
'mci_mqtt_log',
|
|
134
|
+
'ApiEngineId',
|
|
135
|
+
'IMicroiMQTT.PublishAsync',
|
|
136
|
+
'ConnectedClients',
|
|
137
|
+
'GetConnectedClients',
|
|
138
|
+
'外部 Broker',
|
|
139
|
+
'独立 MQTT 节点'
|
|
140
|
+
];
|
|
141
|
+
|
|
142
|
+
requireTokens('skill', [
|
|
143
|
+
'MqttEnable',
|
|
144
|
+
'MqttPort',
|
|
145
|
+
'MqttWsPort',
|
|
146
|
+
'MqttAccount',
|
|
147
|
+
'MqttPwd',
|
|
148
|
+
'MqttApiEngine',
|
|
149
|
+
'MqttAllowAnonymous',
|
|
150
|
+
'MqttTopicIsolation'
|
|
151
|
+
]);
|
|
152
|
+
requireTokens('reference', configurationTokens);
|
|
153
|
+
|
|
154
|
+
for (const targetName of ['skill', 'reference']) {
|
|
155
|
+
requireTokens(targetName, securityTokens);
|
|
156
|
+
requireTokens(targetName, operationsTokens);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
requireTokens(
|
|
160
|
+
'mqttDoc',
|
|
161
|
+
configurationTokens.filter((token) => token !== 'MqttAllowAnonymous')
|
|
162
|
+
);
|
|
163
|
+
requireAny(
|
|
164
|
+
'mqttDoc',
|
|
165
|
+
['MqttAllowAnonymous', '匿名连接只可能由主租户显式开启'],
|
|
166
|
+
'主租户匿名连接边界'
|
|
167
|
+
);
|
|
168
|
+
requireTokens(
|
|
169
|
+
'mqttDoc',
|
|
170
|
+
securityTokens.filter(
|
|
171
|
+
(token) => token !== 'StaleDisconnectIgnored' && token !== 'tenant/{lowerOsClient}'
|
|
172
|
+
)
|
|
173
|
+
);
|
|
174
|
+
requireAny(
|
|
175
|
+
'mqttDoc',
|
|
176
|
+
['tenant/{lowerOsClient}', 'tenant/<lowerOsClient>'],
|
|
177
|
+
'标准租户 Topic 模板'
|
|
178
|
+
);
|
|
179
|
+
requireTokens('v8Doc', ['## V8.MQTT', 'IMicroiMQTT.PublishAsync', 'Code != 1']);
|
|
180
|
+
|
|
181
|
+
requireTokens('runtime', [
|
|
182
|
+
'NormalizeTopic',
|
|
183
|
+
'ResponseTopic',
|
|
184
|
+
'StaleDisconnectIgnored',
|
|
185
|
+
'$share/',
|
|
186
|
+
'$SYS/',
|
|
187
|
+
'mci_mqtt_client',
|
|
188
|
+
'mci_mqtt_log',
|
|
189
|
+
'PublishRejectedByV8'
|
|
190
|
+
]);
|
|
191
|
+
requireTokens('mqttInterface', ['PublishAsync(string osClient', 'GetConnectedClients(string osClient)']);
|
|
192
|
+
requireTokens('tenantSecurity', ['NormalizeMqttTopic', 'HasTenantServiceCredentialCollision']);
|
|
193
|
+
requireTokens('controller', ['[PlatformAdminOnly]', 'StatusScope = "CurrentNode"']);
|
|
194
|
+
|
|
195
|
+
if (failures.length > 0) {
|
|
196
|
+
console.error('MQTT Skill 覆盖检查失败:');
|
|
197
|
+
for (const failure of failures) console.error(`- ${failure}`);
|
|
198
|
+
process.exitCode = 1;
|
|
199
|
+
} else {
|
|
200
|
+
console.log(
|
|
201
|
+
`MQTT Skill 覆盖检查通过:${sourceEvents.length} 个事件、` +
|
|
202
|
+
`${sourceProperties.length} 个 V8.MQTT 字段、` +
|
|
203
|
+
`${configurationTokens.length} 个配置项及生产安全/部署契约均已覆盖。`
|
|
204
|
+
);
|
|
205
|
+
}
|
|
@@ -114,7 +114,7 @@
|
|
|
114
114
|
| `V8.AddSysLog(...)` | 前端发起系统日志记录;不得含秘密 |
|
|
115
115
|
| `V8.SendSystemMessage(...)` | 发送站内系统消息 |
|
|
116
116
|
| `await V8.Method.ScanCode()` | 扫码;成功值同时写入 `V8.ScanCodeRes` |
|
|
117
|
-
| `V8.Print.*` |
|
|
117
|
+
| `V8.Print.*` | TSPL/CPCL/ESC-POS 蓝牙标签与小票打印,兼容 GP-M322、CC4,见蓝牙打印参考 |
|
|
118
118
|
| `V8.Identity.GetCapabilities()` | 读取当前租户强身份能力和本人登记状态 |
|
|
119
119
|
| `V8.Identity.CreateActionHash(value)` | 为稳定业务命令生成 SHA-256 摘要 |
|
|
120
120
|
| `V8.Identity.RegisterPasskey(options?)` | 登记当前用户 Passkey |
|
|
@@ -124,11 +124,13 @@
|
|
|
124
124
|
`V8.Identity` 是强身份验证模块。`V8.Identity.Verify` 的成功结果不能直接授权业务;后端必须重读权威数据、重算摘要并调用 `V8.Method.ConsumeIdentityVerificationTicket`。
|
|
125
125
|
蓝牙打印完整 API 包含 `V8.Print.createNew`、`V8.Print.createNewESC`、
|
|
126
126
|
`V8.Print.OpenBluetoothPage`、`V8.Print.isConnected`、
|
|
127
|
+
`V8.Print.reconnect`、`V8.Print.getConnectionState`、`V8.Print.subscribeConnection`、
|
|
128
|
+
`V8.Print.getPrinterProfile`、`V8.Print.setPrinterProfile`、
|
|
127
129
|
`V8.Print.prepareSend`、`V8.Print.Send`、`V8.Print.setOneTimeData`、
|
|
128
130
|
`V8.Print.setPrinterNum`、`V8.Print.disconnect` 和
|
|
129
131
|
`V8.Print.BLEInformation`。其中 `Send` 依赖 `prepareSend` 设置的共享分包游标,
|
|
130
132
|
只作内部状态机入口;业务代码必须调用并 `await prepareSend`。当前挂载范围、连接
|
|
131
|
-
|
|
133
|
+
真实性、佳博原 TSPL 回归、CC4 CPCL 适配、Android SPP 与串行约束见
|
|
132
134
|
[`bluetooth-print.md`](../../v8-frontend-events/references/bluetooth-print.md),TSC/ESC
|
|
133
135
|
完整方法见
|
|
134
136
|
[`bluetooth-print-api.md`](../../v8-frontend-events/references/bluetooth-print-api.md)。
|
|
@@ -106,10 +106,11 @@ AI 在工作区任意任务中生成的**一次性临时脚本、诊断文件、
|
|
|
106
106
|
| 吾码 App 源码 | `microi.app/` |
|
|
107
107
|
| 吾码 UniApp 源码 | `microi.uniapp/` |
|
|
108
108
|
| 吾码官方网站 / 文档源码 | `microi.doc/` |
|
|
109
|
-
| 吾码 AI
|
|
110
|
-
| 吾码官方应用商城发行包源码 | `microi.apps/{packageKey}/` |
|
|
109
|
+
| 吾码 AI 应用及应用商城发行源码 | `Microi-V8-Engine/{系统名称} ({ApiBase域名})/{OsClient}.{OsClientType}.{OsClientNetwork}/AI应用/{appKey}/` |
|
|
111
110
|
|
|
112
111
|
以上路径只作为通用工作区相对路径规范,不写入具体本机盘符。跨仓库、空工作区或普通用户项目中,如果路径不存在,以插件生成的 `AGENTS.md`、MCP 配置和实际文件树为准。
|
|
112
|
+
|
|
113
|
+
每个 `AI应用/{appKey}` 必须是界面、微服务、Manifest、接口引擎、资源策略、测试、构建脚本与商城上传素材的唯一事实源。纯平台应用没有前端时仍使用该目录;禁止另建 `microi.apps/` 平行发行根。
|
|
113
114
|
|
|
114
115
|
## Skills 通用化原则
|
|
115
116
|
|