2
0

4 کامیت‌ها bc44195c26 ... 0ac8d537b7

نویسنده SHA1 پیام تاریخ
  Jerry Liang 0ac8d537b7 fix:调整故障录波参数设置通道参数设置显示方式,同时并排展示 2 هفته پیش
  Jerry Liang 87182c7b8f feat:全新故障录波功能开发,绕过云平台,直接与设备对接,仅使用云的S3服务器用于文件上传下载(自测OK) 2 هفته پیش
  Jerry Liang 1560bd2ba0 docs:故障录波设计文档阶段性定稿提交(编码前) 2 هفته پیش
  Jerry Liang b74a2ad503 feat:把fwh_mqtt封装成一个通用的操作模块,生产服和测试服同时使用相同的模块 2 هفته پیش
79فایلهای تغییر یافته به همراه9896 افزوده شده و 1485 حذف شده
  1. 4 4
      deploy/config.env.example
  2. BIN
      docs/fault-record/2026-10-14_05_46_20.tar.bz2
  3. 25 0
      docs/fault-record/CloudLog_2026-10-14_104644.csv
  4. 928 0
      docs/fault-record/FaultFilter_MQTT协议文档.md
  5. 35 0
      docs/fault-record/README.md
  6. 61 0
      docs/fault-record/implementation-status.md
  7. 221 0
      docs/fault-record/protocol-observations.md
  8. 475 0
      docs/fault-record/protocol-specification.md
  9. 103 0
      docs/fault-record/requirements.md
  10. 238 0
      docs/fault-record/technical-design.md
  11. 168 0
      docs/fault-record/ui-design.md
  12. 4 4
      service/.env
  13. 23 0
      service/cache/redisx/lockx.go
  14. 4 4
      service/config/config.go
  15. 106 8
      service/controllers/faultRecordControllers.go
  16. 1 1
      service/domain/esMqttDomain.go
  17. 157 0
      service/domain/faultRecordMqttDomain.go
  18. 2 2
      service/domain/fwhProDomain.go
  19. 93 10
      service/dto/faultRecordDTO.go
  20. 138 0
      service/function/FaultRecordFile/archive.go
  21. 138 0
      service/function/FaultRecordFile/csv.go
  22. 170 0
      service/function/FaultRecordFile/file_test.go
  23. 155 0
      service/function/FaultRecordFile/reader.go
  24. 91 0
      service/function/FaultRecordFile/realtime.go
  25. 143 0
      service/function/FaultRecordFile/record.go
  26. 160 0
      service/function/FaultRecordProtocol/dict.go
  27. 373 0
      service/function/FaultRecordProtocol/protocol.go
  28. 193 0
      service/function/FaultRecordProtocol/protocol_test.go
  29. 148 0
      service/function/FaultRecordProtocol/setval.go
  30. 11 3
      service/main.go
  31. 27 0
      service/repositories/api/fwh_pro/fwh_faultRecordUpload.go
  32. 27 0
      service/repositories/api/fwh_test/fwh_faultRecordUpload.go
  33. 39 0
      service/repositories/minio/faultRecordRepo.go
  34. 182 0
      service/repositories/mongo/faultRecordRepo.go
  35. 3 0
      service/repositories/mongo/init.go
  36. 312 158
      service/repositories/mqtt/fwh_mqtt/fwh_mqtt_client.go
  37. 217 0
      service/repositories/mqtt/fwh_mqtt/fwh_mqtt_client_test.go
  38. 0 43
      service/repositories/mqtt/fwh_mqtt/fwh_mqtt_debug.go
  39. 52 0
      service/repositories/mqtt/fwh_mqtt/fwh_mqtt_envelope.go
  40. 0 200
      service/repositories/mqtt/fwh_mqtt/fwh_mqtt_fault_record.go
  41. 0 307
      service/repositories/mqtt/fwh_mqtt_test/fwh_mqtt_client.go
  42. 0 43
      service/repositories/mqtt/fwh_mqtt_test/fwh_mqtt_debug.go
  43. 0 202
      service/repositories/mqtt/fwh_mqtt_test/fwh_mqtt_fault_record.go
  44. 0 71
      service/repositories/mqtt/fwh_mqtt_test/test/main.go
  45. 44 0
      service/repositories/mqtt/fwh_xlog/fwh_xlog.go
  46. 12 5
      service/repositories/repositories.go
  47. 17 4
      service/routes/faultRecordRoutes.go
  48. 2 2
      service/services/aiChatServices.go
  49. 413 4
      service/services/faultRecordServices.go
  50. 9 9
      service/services/feedbackServices.go
  51. 174 0
      service/task/queue/faultRecordQueue/deps.go
  52. 624 0
      service/task/queue/faultRecordQueue/executor.go
  53. 637 0
      service/task/queue/faultRecordQueue/executor_test.go
  54. 117 0
      service/task/queue/faultRecordQueue/queue.go
  55. 2 0
      service/task/queue/taskQueue.go
  56. 19 2
      service/utils/mqttClient/mqttClient.go
  57. 120 22
      src/router/record.ts
  58. 58 9
      src/service/faultRecord/index.ts
  59. 189 10
      src/service/faultRecord/interface.ts
  60. 0 2
      src/stores/initStores.ts
  61. 0 59
      src/stores/modules/views/faultRecordState.ts
  62. 10 2
      src/stores/user.ts
  63. 1 0
      src/typings/vue-router.d.ts
  64. 140 0
      src/views/fault-record/common/DeviceSelector.vue
  65. 36 0
      src/views/fault-record/common/ExportButtons.vue
  66. 131 0
      src/views/fault-record/common/OperationHistory.vue
  67. 232 0
      src/views/fault-record/common/OperationProgress.vue
  68. 99 0
      src/views/fault-record/common/RecordDetail.vue
  69. 73 0
      src/views/fault-record/common/ResultItems.vue
  70. 133 0
      src/views/fault-record/common/WaveformChart.vue
  71. 190 0
      src/views/fault-record/common/constants.ts
  72. 94 0
      src/views/fault-record/common/useFaultRecord.ts
  73. 153 0
      src/views/fault-record/device-info/index.vue
  74. 241 0
      src/views/fault-record/function/index.vue
  75. 22 0
      src/views/fault-record/param-write/ParamSection.vue
  76. 471 0
      src/views/fault-record/param-write/index.vue
  77. 0 3
      src/views/fault-record/read/index.vue
  78. 206 0
      src/views/fault-record/record/index.vue
  79. 0 292
      src/views/fault-record/write/index.vue

+ 4 - 4
deploy/config.env.example

@@ -77,10 +77,10 @@ FWH_MQTT_KEY="./cert/pro/deviceCert.key"
 
 # ---------- FWH MQTT(测试,可选)----------
 # TODO: [可选] 测试环境 MQTT,用到测试服才需要;证书放 server/cert/test/
-FWH_TEST_MQTT_BROKER="tls://a35sger20yyec4-ats.iot.us-west-2.amazonaws.com:8883"
-FWH_TEST_MQTT_CA="./cert/test/AmazonRootCA1.pem"
-FWH_TEST_MQTT_CERT="./cert/test/01.certificate.pem.crt"
-FWH_TEST_MQTT_KEY="./cert/test/01.private.pem.key"
+FWH_MQTT_TEST_BROKER="tls://a35sger20yyec4-ats.iot.us-west-2.amazonaws.com:8883"
+FWH_MQTT_TEST_CA="./cert/test/AmazonRootCA1.pem"
+FWH_MQTT_TEST_CERT="./cert/test/01.certificate.pem.crt"
+FWH_MQTT_TEST_KEY="./cert/test/01.private.pem.key"
 
 # ---------- Plane(项目管理,可选)----------
 # TODO: [可选] 仅在启用 Plane 反馈/任务同步功能时填写以下所有 Plane 相关项

BIN
docs/fault-record/2026-10-14_05_46_20.tar.bz2


+ 25 - 0
docs/fault-record/CloudLog_2026-10-14_104644.csv

@@ -0,0 +1,25 @@
+"cmdType","cmdName","gatewayId","gatewayName","gatewayType","gatewayStatus","activeStatus","logData","logTime","time"
+610,故障录波文件上传结果应答,AFCI_TEST_0713,-,-,1,1,"{""cmdType"":610,""crc"":""3070A9DC"",""dataArea"":{""result"":1},""equipNo"":""AFCI_TEST_0713"",""len"":12,""snno"":6061,""timeStamp"":1791971193,""type"":0}",2026-10-14 05:46:33,2026-10-14 09:46:33
+609,故障录波文件上传结果,AFCI_TEST_0713,-,-,1,1,"{""cmdType"":609,""equipNo"":""AFCI_TEST_0713"",""type"":0,""timeStamp"":1791971193,""snno"":29,""len"":53,""crc"":""07C1B7FA"",""dataArea"":{""result"":0,""fileName"":""2026-10-14_05_46_20.tar.bz2""}}",2026-10-14 05:46:33,2026-10-14 09:46:33
+380,故障录波信息上传应答,AFCI_TEST_0713,-,-,1,1,"{""cmdType"":380,""crc"":""FF9D0192"",""dataArea"":{""result"":0,""cmd"":2},""equipNo"":""AFCI_TEST_0713"",""len"":20,""snno"":6052,""timeStamp"":1791971182,""type"":0}",2026-10-14 05:46:22,2026-10-14 09:46:22
+607,故障录波召唤下行数据,AFCI_TEST_0713,-,-,1,1,"{""cmdType"":607,""crc"":""87BD67DE"",""dataArea"":{""faultRecordID"":5226,""fileName"":""2026-10-14_05_46_20"",""url"":""http://test.franklinwh.com/hes-upload/v1/file/upload"",""logTime"":""2026-10-14""},""equipNo"":""AFCI_TEST_0713"",""len"":139,""snno"":6049,""timeStamp"":1791971181,""type"":0}",2026-10-14 05:46:21,2026-10-14 09:46:21
+608,故障录波召唤应答上行数据,AFCI_TEST_0713,-,-,1,1,"{""cmdType"":608,""equipNo"":""AFCI_TEST_0713"",""type"":0,""timeStamp"":1791971181,""snno"":28,""len"":59,""crc"":""A4970F2B"",""dataArea"":{""result"":0,""filename"":""2026-10-14_05_46_20"",""result"":5226}}",2026-10-14 05:46:21,2026-10-14 09:46:22
+379,故障录波信息上传,AFCI_TEST_0713,-,-,1,1,"{""cmdType"":379,""equipNo"":""AFCI_TEST_0713"",""type"":0,""timeStamp"":1791971181,""snno"":27,""len"":187,""crc"":""E1808BA3"",""dataArea"":{""result"":""ok"",""list"":[{""type"":""aGate"",""info"":[{""sn"":""AFCI_TEST_0713"",""indexInfo"":[{""index"":4,""resultOne"":""ok""},{""index"":5,""resultOne"":""ok""}]}]}],""filename"":""2026-10-14_05_46_20"",""cmd"":2}}",2026-10-14 05:46:21,2026-10-14 09:46:21
+378,故障录波信息应答,AFCI_TEST_0713,-,-,1,1,"{""cmdType"":378,""equipNo"":""AFCI_TEST_0713"",""type"":0,""timeStamp"":1791971138,""snno"":25,""len"":20,""crc"":""05E9AF33"",""dataArea"":{""cmd"":2,""result"":0}}",2026-10-14 05:45:38,2026-10-14 09:45:38
+377,故障录波实时数据获取,AFCI_TEST_0713,-,-,1,1,"{""cmdType"":377,""crc"":""FB01C55A"",""dataArea"":{""data"":{""list"":[{""type"":""aGate"",""info"":[{""sn"":""AFCI_TEST_0713"",""index"":[7]}]}]},""cmd"":2},""equipNo"":""AFCI_TEST_0713"",""len"":89,""snno"":6014,""timeStamp"":1791971138,""type"":0}",2026-10-14 05:45:38,2026-10-14 09:45:38
+380,故障录波信息上传应答,AFCI_TEST_0713,-,-,1,1,"{""cmdType"":380,""crc"":""CDAB6310"",""dataArea"":{""result"":0,""cmd"":0},""equipNo"":""AFCI_TEST_0713"",""len"":20,""snno"":6011,""timeStamp"":1791971136,""type"":0}",2026-10-14 05:45:36,2026-10-14 09:45:37
+379,故障录波信息上传,AFCI_TEST_0713,-,-,1,1,"{""cmdType"":379,""equipNo"":""AFCI_TEST_0713"",""type"":0,""timeStamp"":1791971128,""snno"":20,""len"":557,""crc"":""918AD93E"",""dataArea"":{""result"":""ok"",""list"":[{""type"":""aGate"",""info"":[{""sn"":""AFCI_TEST_0713"",""hexList"":[{""code"":3,""hexData"":""0F0107E801010000220000000110020207EA070E0235230000000110010307EA080C0203050000000110010407EA080D1528280000000110010507EA080E0318100000000110010607EA0810033A050000000110010707EA08120303080000000110010807E90B18010B2E0000000110010907E90B1A152C060000000110010A07E705041601030000000110020B07E705041602210000000110020C07E705041603280000000110020D07E7050416042E0000000110020E07E70504161C0E0000000110020F07EA061D0F0723000000011001"",""resultOne"":""ok""}]}]}],""cmd"":0}}",2026-10-14 05:45:28,2026-10-14 09:45:29
+378,故障录波信息应答,AFCI_TEST_0713,-,-,1,1,"{""cmdType"":378,""equipNo"":""AFCI_TEST_0713"",""type"":0,""timeStamp"":1791971125,""snno"":18,""len"":20,""crc"":""65B51A78"",""dataArea"":{""cmd"":0,""result"":0}}",2026-10-14 05:45:25,2026-10-14 09:45:26
+377,故障录波实时数据获取,AFCI_TEST_0713,-,-,1,1,"{""cmdType"":377,""crc"":""5169219D"",""dataArea"":{""data"":{""list"":[{""type"":""aGate"",""info"":[{""sn"":""AFCI_TEST_0713"",""codeList"":[3]}]}]},""cmd"":0},""equipNo"":""AFCI_TEST_0713"",""len"":92,""snno"":6008,""timeStamp"":1791971125,""type"":0}",2026-10-14 05:45:25,2026-10-14 09:45:25
+610,故障录波文件上传结果应答,AFCI_TEST_0713,-,-,1,1,"{""cmdType"":610,""crc"":""3070A9DC"",""dataArea"":{""result"":1},""equipNo"":""AFCI_TEST_0713"",""len"":12,""snno"":5731,""timeStamp"":1791970664,""type"":0}",2026-10-14 05:37:44,2026-10-14 09:37:47
+609,故障录波文件上传结果,AFCI_TEST_0713,-,-,1,1,"{""cmdType"":609,""equipNo"":""AFCI_TEST_0713"",""type"":0,""timeStamp"":1791970664,""snno"":31,""len"":53,""crc"":""C206AC38"",""dataArea"":{""result"":0,""fileName"":""2026-10-14_05_37_32.tar.bz2""}}",2026-10-14 05:37:44,2026-10-14 09:37:44
+380,故障录波信息上传应答,AFCI_TEST_0713,-,-,1,1,"{""cmdType"":380,""crc"":""FF9D0192"",""dataArea"":{""result"":0,""cmd"":2},""equipNo"":""AFCI_TEST_0713"",""len"":20,""snno"":5726,""timeStamp"":1791970655,""type"":0}",2026-10-14 05:37:35,2026-10-14 09:37:36
+608,故障录波召唤应答上行数据,AFCI_TEST_0713,-,-,1,1,"{""cmdType"":608,""equipNo"":""AFCI_TEST_0713"",""type"":0,""timeStamp"":1791970654,""snno"":30,""len"":59,""crc"":""A14FF691"",""dataArea"":{""result"":0,""filename"":""2026-10-14_05_37_32"",""result"":5224}}",2026-10-14 05:37:34,2026-10-14 09:37:34
+607,故障录波召唤下行数据,AFCI_TEST_0713,-,-,1,1,"{""cmdType"":607,""crc"":""4AEDD288"",""dataArea"":{""faultRecordID"":5224,""fileName"":""2026-10-14_05_37_32"",""url"":""http://test.franklinwh.com/hes-upload/v1/file/upload"",""logTime"":""2026-10-14""},""equipNo"":""AFCI_TEST_0713"",""len"":139,""snno"":5722,""timeStamp"":1791970653,""type"":0}",2026-10-14 05:37:33,2026-10-14 09:37:34
+379,故障录波信息上传,AFCI_TEST_0713,-,-,1,1,"{""cmdType"":379,""equipNo"":""AFCI_TEST_0713"",""type"":0,""timeStamp"":1791970652,""snno"":29,""len"":187,""crc"":""24479061"",""dataArea"":{""result"":""ok"",""list"":[{""type"":""aGate"",""info"":[{""sn"":""AFCI_TEST_0713"",""indexInfo"":[{""index"":4,""resultOne"":""ok""},{""index"":5,""resultOne"":""ok""}]}]}],""filename"":""2026-10-14_05_37_32"",""cmd"":2}}",2026-10-14 05:37:32,2026-10-14 09:37:34
+378,故障录波信息应答,AFCI_TEST_0713,-,-,1,1,"{""cmdType"":378,""equipNo"":""AFCI_TEST_0713"",""type"":0,""timeStamp"":1791970610,""snno"":25,""len"":20,""crc"":""05E9AF33"",""dataArea"":{""cmd"":2,""result"":0}}",2026-10-14 05:36:50,2026-10-14 09:36:51
+377,故障录波实时数据获取,AFCI_TEST_0713,-,-,1,1,"{""cmdType"":377,""crc"":""FB01C55A"",""dataArea"":{""data"":{""list"":[{""type"":""aGate"",""info"":[{""sn"":""AFCI_TEST_0713"",""index"":[7]}]}]},""cmd"":2},""equipNo"":""AFCI_TEST_0713"",""len"":89,""snno"":5701,""timeStamp"":1791970610,""type"":0}",2026-10-14 05:36:50,2026-10-14 09:36:51
+380,故障录波信息上传应答,AFCI_TEST_0713,-,-,1,1,"{""cmdType"":380,""crc"":""CDAB6310"",""dataArea"":{""result"":0,""cmd"":0},""equipNo"":""AFCI_TEST_0713"",""len"":20,""snno"":5699,""timeStamp"":1791970608,""type"":0}",2026-10-14 05:36:48,2026-10-14 09:36:48
+379,故障录波信息上传,AFCI_TEST_0713,-,-,1,1,"{""cmdType"":379,""equipNo"":""AFCI_TEST_0713"",""type"":0,""timeStamp"":1791970585,""snno"":17,""len"":557,""crc"":""918AD93E"",""dataArea"":{""result"":""ok"",""list"":[{""type"":""aGate"",""info"":[{""sn"":""AFCI_TEST_0713"",""hexList"":[{""code"":3,""hexData"":""0F0107E801010000220000000110020207EA070E0235230000000110010307EA080C0203050000000110010407EA080D1528280000000110010507EA080E0318100000000110010607EA0810033A050000000110010707EA08120303080000000110010807E90B18010B2E0000000110010907E90B1A152C060000000110010A07E705041601030000000110020B07E705041602210000000110020C07E705041603280000000110020D07E7050416042E0000000110020E07E70504161C0E0000000110020F07EA061D0F0723000000011001"",""resultOne"":""ok""}]}]}],""cmd"":0}}",2026-10-14 05:36:25,2026-10-14 09:36:26
+378,故障录波信息应答,AFCI_TEST_0713,-,-,1,1,"{""cmdType"":378,""equipNo"":""AFCI_TEST_0713"",""type"":0,""timeStamp"":1791970583,""snno"":15,""len"":20,""crc"":""65B51A78"",""dataArea"":{""cmd"":0,""result"":0}}",2026-10-14 05:36:23,2026-10-14 09:36:26
+377,故障录波实时数据获取,AFCI_TEST_0713,-,-,1,1,"{""cmdType"":377,""crc"":""5169219D"",""dataArea"":{""data"":{""list"":[{""type"":""aGate"",""info"":[{""sn"":""AFCI_TEST_0713"",""codeList"":[3]}]}]},""cmd"":0},""equipNo"":""AFCI_TEST_0713"",""len"":92,""snno"":5682,""timeStamp"":1791970582,""type"":0}",2026-10-14 05:36:22,2026-10-14 09:36:26

+ 928 - 0
docs/fault-record/FaultFilter_MQTT协议文档.md

@@ -0,0 +1,928 @@
+# 故障滤波(FaultFilter)MQTT 接口协议
+
+本协议用于云端 / 上位机与故障滤波设备之间通过 MQTT 进行的数据采集、记录导出与参数读写交互。本文档面向对接方开发者,仅描述通信主题、消息流程与字段定义。
+
+---
+
+## 1. 通信主题(Topic)
+
+| 主题                        | 方向                     | QoS | 说明                       |
+| --------------------------- | ------------------------ | --- | -------------------------- |
+| `aws/faultrecord`         | 对端 → 设备(下行命令) | 2   | 下发采集 / 记录 / 参数命令 |
+| `faultfilter/faultrecord` | 设备 → 对端(上行回复) | 2   | 对下行命令的处理结果回复   |
+
+交互模型为「请求 / 应答」:对端在 `aws/faultrecord` 发布一条命令,设备处理后在 `faultfilter/faultrecord` 发布一条对应的回复。请求与回复通过 `cmd` 字段关联,回复的 `cmd` 与请求保持一致。
+
+> 消息体均为 UTF-8 编码的 JSON 字符串。字段顺序不固定,接收方应按字段名解析。
+
+---
+
+## 2. 消息总体结构
+
+所有下行命令与上行回复的顶层结构一致:
+
+```json
+{
+  "cmd": <命令码>,
+  "data": { ... }
+}
+```
+
+| 字段     | 类型 | 必填 | 说明                                      |
+| -------- | ---- | ---- | ----------------------------------------- |
+| `cmd`  | 整数 | 是   | 命令码,标识本次交互的业务类型,见第 3 节 |
+| `data` | 对象 | 是   | 命令数据体,内容随 `cmd` 不同而不同     |
+
+---
+
+## 3. 命令码(cmd)
+
+| 命令码 | 名称         | 方向        | 说明                               |
+| ------ | ------------ | ----------- | ---------------------------------- |
+| 0      | 获取实时数据 | 请求 / 回复 | 读取设备实时数据                   |
+| 1      | 设置参数     | 请求 / 回复 | 写入设备参数                       |
+| 2      | 获取记录数据 | 请求 / 回复 | 导出故障录波 / 波形记录            |
+| 3      | 查询参数     | 请求 / 回复 | 读取设备参数                       |
+| 4      | 退出         | 请求        | 通知设备结束当前处理               |
+| 90     | 测试成功     | 请求        | 连通性测试,设备回复 `ok`        |
+| 99     | 测试失败     | 请求        | 连通性测试,设备回复 `test fail` |
+
+> 处理约束:
+>
+> - 命令码按 8 位无符号整数处理,有效范围 0–255。
+> - 设备同一时刻只处理一条业务命令。收到命令后的判定顺序为:先校验 `cmd` 字段 → 再判忙碌 → 再校验 `data` 字段。
+> - 当设备处于忙碌状态时收到非「退出(4)」命令,直接返回错误 `busy`(此时不中断正在进行的处理);其余在校验阶段失败的命令,回复错误后会结束当前处理流程。
+
+---
+
+## 4. 设备分组与寻址
+
+命令数据体中的设备通过「类型 + 序列号」寻址,统一组织为 `list → info` 两级结构。
+
+### 4.1 设备类型(type)
+
+| type      | 含义            | 寻址方式                             |
+| --------- | --------------- | ------------------------------------ |
+| `pe`    | PE 功率单元     | 通过 `sn` 序列号定位(最多 15 台) |
+| `aGate` | 网关 / 从控单元 | 系统内唯一,固定寻址                 |
+
+### 4.2 分组结构
+
+```json
+"data": {
+  "list": [
+    {
+      "type": "pe",
+      "info": [
+        { "sn": "<设备序列号>", ... }
+      ]
+    },
+    {
+      "type": "aGate",
+      "info": [
+        { "sn": "<设备序列号>", ... }
+      ]
+    }
+  ]
+}
+```
+
+| 字段            | 类型   | 层级 | 说明                                                |
+| --------------- | ------ | ---- | --------------------------------------------------- |
+| `list`        | 数组   | data | 设备分组列表,按 `type` 分组                      |
+| `list[].type` | 字符串 | list | 设备类型:`pe` 或 `aGate`                       |
+| `list[].info` | 数组   | list | 该类型下的具体设备项                                |
+| `info[].sn`   | 字符串 | info | 设备序列号。`pe` 必填;`aGate` 可填但不用于寻址 |
+
+> 若 `pe` 的 `sn` 在设备侧无法匹配,整条命令返回错误 `pe sn not find`(见第 6 节)。
+
+### 4.3 回复中的整体结果(result)
+
+每条回复的 `data.result` 表示本次命令的整体处理结果:
+
+| 取值      | 含义                     |
+| --------- | ------------------------ |
+| `ok`    | 全部子项处理成功         |
+| `error` | 存在至少一个子项处理失败 |
+
+子项级别的结果由各自的 `resultOne` 字段描述。
+
+---
+
+## 5. 各命令详细定义
+
+### 5.1 获取实时数据(cmd = 0)
+
+#### 请求
+
+```json
+{
+  "cmd": 0,
+  "data": {
+    "list": [
+      {
+        "type": "aGate",
+        "info": [
+          {
+            "sn": "10060010A01X26310190",
+            "codeList": [3]
+          }
+        ]
+      }
+    ]
+  }
+}
+```
+
+> 示例取自 2026-09-29 实报:查询 aGate 的故障录波总目录。
+
+| 字段                | 类型     | 说明                                   |
+| ------------------- | -------- | -------------------------------------- |
+| `info[].codeList` | 整数数组 | 需要读取的数据项命令码集合,取值见下表 |
+
+`codeList` / `code` 取值定义:
+
+| code | 含义                     |
+| ---- | ------------------------ |
+| 1    | 故障总体信息             |
+| 2    | 设备故障录波功能基本信息 |
+| 3    | 故障录波总目录           |
+
+> 数组中的多个 `code` 由设备按子设备串行逐个读取(每个 `code` 对应一次独立的子设备读取事务),全部完成后在回复的 `hexList` 中汇总返回。
+> 说明:`hexData` 回传的是子设备原始字节的十六进制字符串,各 code 的字节结构见 5.1.1。
+>
+> **cmd=0 不能用于查询 code=4(单一故障信息)和 code=5(单一故障波形数据)。** 这两类数据属于某条具体记录,须通过获取记录数据(cmd=2,见 5.2)按记录序号导出:先用 cmd=0 + code=3 查询总目录,再以目录项的位置索引作为 cmd=2 的 `index`。
+
+#### 5.1.1 各 code 返回数据(hexData)的字节格式
+
+`hexData` 回传的是子设备返回的原始字节的十六进制字符串。`code=1/2/3` 的字节结构如下(多字节字段按紧凑排列、无填充),已由子设备方资料确认。
+
+**时间字段结构(固定 7 字节)**:
+
+| 字节 | 字段 | 类型                       | 说明                             |
+| ---- | ---- | -------------------------- | -------------------------------- |
+| 1~2  | year | Uint16(大端,高字节在前) | 年,如 `07 E6` = 0x07E6 = 2022 |
+| 3    | mon  | Uint8                      | 月                               |
+| 4    | day  | Uint8                      | 日                               |
+| 5    | hour | Uint8                      | 时                               |
+| 6    | min  | Uint8                      | 分                               |
+| 7    | sec  | Uint8                      | 秒                               |
+
+> 示例:`07 E6 01 01 01 01 01` 表示 2022-01-01 1:1:1。
+
+**code=1 故障总体信息**:
+
+| 顺序 | 字段                   | 字节数 | 含义                                    |
+| ---- | ---------------------- | ------ | --------------------------------------- |
+| 1    | 最大可存储故障记录总数 | 1      | —                                      |
+| 2    | 当前故障记录总数       | 1      | —                                      |
+| 3    | 最早故障发生时间       | 7      | 时间字段结构见本节上方                  |
+| 4    | 最新故障发生时间       | 7      | 时间字段结构见本节上方                  |
+| 5    | 快照通道总数           | 1      | 记为 a                                  |
+| 6    | 已使用快照通道个数     | 1      | —                                      |
+| 7    | 各快照通道含义         | 2 × a | 每通道 2 字节含义编号(见第 10 节 F14) |
+| 8    | 采样通道总数           | 1      | 记为 b                                  |
+| 9    | 已使用采样通道个数     | 1      | —                                      |
+| 10   | 各采样通道含义         | 2 × b | 每通道 2 字节含义编号(见第 10 节 F14) |
+| 11   | 最大可配快照通道总数   | 1      | 记为 c                                  |
+| 12   | 已配置可配快照通道个数 | 1      | —                                      |
+| 13   | 各可配快照通道         | 6 × c | 每项 = 类型(2B) + 地址(4B)              |
+| 14   | 最大可配采样通道总数   | 1      | 记为 d                                  |
+| 15   | 已配置可配采样通道个数 | 1      | —                                      |
+| 16   | 各可配采样通道         | 6 × d | 每项 = 类型(2B) + 地址(4B)              |
+
+> 关于 code=1 中的编号含义:
+>
+> - 「各快照通道含义」/「各采样通道含义」中每个 2 字节值为通道含义参数代码,用于标识该通道对应的物理量(故障字 / 控制字 / 状态量,如温度、SOC、母线电流等),具体编号与物理量的对应关系由产品的参数代码表定义(见第 10 节 F14)。
+> - 「各可配快照通道」/「各可配采样通道」中每项的类型(2B) 与 地址(4B) 用于标识可配通道所记录的变量,取值含义同属产品定义(见第 10 节 F14)。
+>
+> 与 code=4 的关系(可相互参考):code=1 从「快照通道」起的后半部分,与 code=4(见 5.2.2)的通道部分结构基本一致,均按「快照通道组 → 采样通道组 → 可配快照通道组 → 可配采样通道组」组织,每组均为「总数 + 已用/已配数 + 逐通道数据」。两者的通道含义编号(F14)与可配通道类型/地址共用同一套定义。**唯一区别**:code=1 的快照通道逐项**只有含义(2B)**;code=4 的快照通道逐项是**含义(2B) + 数值(2B)**(code=4 需记录故障发生时刻的瞬时值,code=1 仅描述通道配置)。
+
+**code=2 设备故障录波功能基本信息**(固定 16 字节):
+
+| 偏移 | 字段                   | 字节数 | 含义 |
+| ---- | ---------------------- | ------ | ---- |
+| 0    | CAN 通讯协议版本号     | 1      | —   |
+| 1    | 故障数据存储格式版本号 | 1      | —   |
+| 2    | 参数列表版本号         | 4      | —   |
+| 6    | 最大支持采样频率       | 2      | —   |
+| 8    | 采样频率               | 2      | —   |
+| 10   | 采样点总数             | 2      | —   |
+| 12   | 故障前采样点个数       | 2      | —   |
+| 14   | 故障后采样点个数       | 2      | —   |
+
+**code=3 故障录波总目录**:
+
+| 顺序 | 字段                 | 字节数  | 含义                   |
+| ---- | -------------------- | ------- | ---------------------- |
+| 1    | 总目录中故障记录条数 | 1       | 记为 N                 |
+| 2    | 记录项列表           | N × 14 | N 条记录,每条结构见下 |
+
+单条记录(固定 **14 字节**):
+
+| 顺序 | 字段           | 字节数 | 含义                             |
+| ---- | -------------- | ------ | -------------------------------- |
+| 1    | 位置索引       | 1      | —                               |
+| 2    | 故障发生时间   | 7      | 时间字段结构见本节上方,年份大端 |
+| 3    | 参数列表版本号 | 4      | —                               |
+| 4    | 事件触发源     | 2      | 代码表见第 10 节 F15             |
+
+> 单条长度 = 1 + 7 + 4 + 2 = **14 字节**(时间按 7 字节)。
+
+**code=4 单一故障信息 / code=5 单一故障波形数据**:
+不能通过 cmd=0 查询,由 cmd=2 按记录导出并保存为文件。文件内容的字节结构见 5.2.2(`failuredata`,code=4)与 5.2.3(`waveformdata`,code=5),解析导出文件时使用。
+
+#### 回复
+
+```json
+{
+  "cmd": 0,
+  "data": {
+    "result": "ok",
+    "list": [
+      {
+        "type": "aGate",
+        "info": [
+          {
+            "sn": "10060010A01X26310190",
+            "hexList": [
+              {
+                "code": 3,
+                "hexData": "<故障录波总目录原始数据>",
+                "resultOne": "ok"
+              }
+            ]
+          }
+        ]
+      }
+    ]
+  }
+}
+```
+
+> 示例取自 2026-09-29 实报(对上述请求的回复)。该次 `hexData` 原文未存档,此处以占位符表示,结构见 5.1.1 code=3。2026-10-14 实报的 `hexData` 原文见 `CloudLog_2026-10-14_104644.csv`,已按 5.1.1 验证。
+
+| 字段                    | 类型   | 说明                                       |
+| ----------------------- | ------ | ------------------------------------------ |
+| `info[].hexList`      | 数组   | 本设备各数据项的读取结果                   |
+| `hexList[].code`      | 整数   | 数据项命令码,对应请求 `codeList` 中的值 |
+| `hexList[].hexData`   | 字符串 | 读取到的原始数据,十六进制字符串形式       |
+| `hexList[].resultOne` | 字符串 | 该数据项的处理结果                         |
+
+---
+
+### 5.2 获取记录数据(cmd = 2)
+
+用于导出故障录波 / 波形记录文件。
+
+#### 请求
+
+```json
+{
+  "cmd": 2,
+  "data": {
+    "list": [
+      {
+        "type": "aGate",
+        "info": [
+          {
+            "sn": "10060010A01X26310190",
+            "index": [9]
+          }
+        ]
+      }
+    ]
+  }
+}
+```
+
+> 示例取自 2026-09-29 实报:导出目录中位置索引为 9 的记录。
+
+| 字段             | 类型     | 说明                   |
+| ---------------- | -------- | ---------------------- |
+| `info[].index` | 整数数组 | 需要导出的记录索引集合 |
+
+#### 回复
+
+```json
+{
+  "cmd": 2,
+  "data": {
+    "result": "ok",
+    "list": [
+      {
+        "type": "aGate",
+        "info": [
+          {
+            "sn": "10060010A01X26310190",
+            "indexInfo": [
+              {
+                "index": 4,
+                "resultOne": "ok"
+              },
+              {
+                "index": 5,
+                "resultOne": "ok"
+              }
+            ]
+          }
+        ]
+      }
+    ],
+    "filename": "2026-09-29_14_18_29"
+  }
+}
+```
+
+> 示例取自 2026-09-29 实报(对上述请求的回复):记录序号 9 展开为 `4`(故障数据)与 `5`(波形数据)两项。
+
+| 字段                      | 类型   | 说明                                                                                                               |
+| ------------------------- | ------ | ------------------------------------------------------------------------------------------------------------------ |
+| `info[].indexInfo`      | 数组   | 本设备各记录项的处理结果                                                                                           |
+| `indexInfo[].index`     | 整数   | 记录数据类型码(非请求的记录序号):`4`=故障数据,`5`=波形数据。请求的每个记录序号在回复中会展开为这两类各一项 |
+| `indexInfo[].resultOne` | 字符串 | 该记录项的处理结果                                                                                                 |
+| `data.filename`         | 字符串 | 本次导出记录对应的时间戳目录名,格式 `YYYY-MM-DD_HH_MM_SS`                                                       |
+
+> 实现细节:
+>
+> - 对请求中的每个记录序号,设备向子设备分别请求「故障数据」和「波形数据」两类,因此回复的 `indexInfo` 中每个记录序号对应两项,`index` 字段分别为 `4`(故障数据)与 `5`(波形数据)。回复项中**不直接回传请求的记录序号数值**。
+
+#### 5.2.1 记录文件保存格式(code=4 / code=5 的落盘内容)
+
+获取记录(cmd=2)读取的是 `code=4`(单一故障信息 / 故障数据)与 `code=5`(单一故障波形数据),这两类数据设备不解析其内部结构,而是直接保存为文件。保存规则如下:
+
+**保存根目录**:`/home/root/ExportFile/faultFilterRecode/`
+
+**完整文件路径**:
+
+```
+/home/root/ExportFile/faultFilterRecode/<filename>/<sn>/index_<记录序号>/<类型文件名>
+```
+
+| 路径段               | 来源                          | 说明                                                                       |
+| -------------------- | ----------------------------- | -------------------------------------------------------------------------- |
+| `<filename>`       | 本次导出批次时间戳            | 与回复 `data.filename` 相同,格式 `YYYY-MM-DD_HH_MM_SS`                |
+| `<sn>`             | 设备序列号                    | 该记录所属子设备的 SN                                                      |
+| `index_<记录序号>` | 请求 `index[]` 中的记录序号 | 记录序号在此目录名中体现(**不**在 `indexInfo[].index` 字段中)    |
+| `<类型文件名>`     | 数据类型                      | `failuredata`(故障数据,code=4)或 `waveformdata`(波形数据,code=5) |
+
+示例:按上述实报(记录序号 9,SN `10060010A01X26310190`,批次时间 `2026-09-29_14_18_29`),按本节规则应生成两个文件(路径为按规则推导,未在设备上核对):
+
+```
+/home/root/ExportFile/faultFilterRecode/2026-09-29_14_18_29/10060010A01X26310190/index_9/failuredata
+/home/root/ExportFile/faultFilterRecode/2026-09-29_14_18_29/10060010A01X26310190/index_9/waveformdata
+```
+
+**文件内容**:子设备返回的原始字节,经「每字节转 2 位大写十六进制字符」转换后的 **ASCII 文本字符串**(例如字节 `0x0A 0x1B` 存为文本 `0A1B`)。文件以覆盖方式写入。
+
+> 补充说明:
+>
+> - 文件为纯十六进制文本,不含文件头。
+> - 每次获取记录(cmd=2)开始前,设备会先清空保存根目录下的**全部**历史记录文件,再保存本次结果。
+> - 仅当该记录项处理成功时才落盘;失败项不生成文件,仅在 `indexInfo` 中以 `resultOne` 反映错误。
+
+#### 5.2.2 `failuredata` 文件内容格式(code=4 单一故障信息)
+
+文件内为十六进制文本,还原成字节后按下表顺序解析(紧凑排列,多字节字段除另有说明外按示例中的大端顺序)。
+
+| 字节数 | 字段                                                 | 数据类型 | 备注                                                                                                                                                                                                                           |
+| ------ | ---------------------------------------------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
+| 2      | 单一故障信息数据长度                                 | Uint16   | 后续数据的长度                                                                                                                                                                                                                 |
+| 16     | 故障录波功能基本信息                                 | —       | 结构同 code=2「基本信息」,共 16 字节(见 5.1.1)                                                                                                                                                                              |
+| 1      | 位置索引号                                           | Uint8    | 从 1 开始,最大不超过「最大可存储故障记录总数」                                                                                                                                                                                |
+| 7      | 故障发生时间                                         | Uint8[7] | 例:`07 E6 01 01 01 01 01` 表示 2022-01-01 1:1:1                                                                                                                                                                             |
+| 2      | 事件触发源                                           | Uint16   | 采用事件触发源代码表示,触发源在应用层配置                                                                                                                                                                                     |
+| 64     | 故障发生时 DCDC 软件版本号                           | char[64] | aGate 中表示从 DSP 软件版本号。例:`PE_DCDC:V10R03B32D00` 或 `EMS_SL:V10R03B32D00`                                                                                                                                         |
+| 64     | 故障发生时 INV 软件版本号                            | char[64] | aGate 中留空。例:`PE_INV:V10R03B32D07`                                                                                                                                                                                      |
+| 64     | 故障发生时 FPGA 软件版本号                           | char[64] | aGate 中留空。例:`PE_FPGA:V255R03B32D00`                                                                                                                                                                                    |
+| 1      | 快照通道总数                                         | Uint8    | 设计最大 32 通道;无论是否使用都上传该参数及后续快照参数                                                                                                                                                                       |
+| 1      | 已使用的快照通道个数                                 | Uint8    | ≤ 快照通道总数                                                                                                                                                                                                                |
+| 2      | 第 1 个快照通道含义                                  | Uint16   | 通道含义参数代码,标识该通道对应的物理量(故障字 / 控制字 / 状态量,如温度、SOC 等);「采样通道」与「快照通道」共用同一套参数代码(见第 10 节 F14)                                                                           |
+| 2      | 第 1 个快照通道数值                                  | Uint16   | 取参数代码对应变量的数值,如母线电流                                                                                                                                                                                           |
+| …     | …(第 2 … n 个快照通道的含义 + 数值,各 2+2 字节) | Uint16   | n = 快照通道总数;每通道按「含义(2B) + 数值(2B)」重复                                                                                                                                                                          |
+| 2      | 第 n 个快照通道含义                                  | Uint16   | 同上                                                                                                                                                                                                                           |
+| 2      | 第 n 个快照通道数值                                  | Uint16   | 同上                                                                                                                                                                                                                           |
+| 1      | 采样通道总数                                         | Uint8    | —                                                                                                                                                                                                                             |
+| 1      | 已使用的采样通道个数                                 | Uint8    | ≤ 采样通道总数                                                                                                                                                                                                                |
+| 2      | 第 1 个采样通道含义                                  | Uint16   | 含义同快照通道含义                                                                                                                                                                                                             |
+| …     | …(第 2 … m 个采样通道含义,各 2 字节)            | Uint16   | m = 采样通道总数;每通道仅「含义(2B)」                                                                                                                                                                                         |
+| 2      | 第 m 个采样通道含义                                  | Uint16   | 同上                                                                                                                                                                                                                           |
+| 1      | 最大可配快照通道总数                                 | Uint8    | —                                                                                                                                                                                                                             |
+| 1      | 已配置的可配快照通道个数                             | Uint8    | ≤ 最大可配快照通道总数                                                                                                                                                                                                        |
+| 2      | 第 1 个可配快照通道所记录变量的类型                  | Uint16   | 采用约定类型代码(同 5.4.1 的 id=6 功能);「可配采样通道」与「可配快照通道」共用同一套类型代码:`0h`=Empty;`1h`=Uint16;`2h`=int16;`3h`=float32;`4h`=Uint32_HWord;`5h`=Uint32_LWord;`6h`=Uint32;其他=无效 |
+| 4      | 当前第 1 个可配采样通道所记录变量的地址              | Uint32   | 与程序版本相关。例:`00 31 11 94` 表示地址 0x311194                                                                                                                                                                          |
+| …     | …(第 2 … x 个:类型(2B) + 地址(4B))              | —       | x = 最大可配快照通道总数;每项按「类型(2B) + 地址(4B)」重复                                                                                                                                                                    |
+| 2      | 第 x 个可配快照通道所记录变量的类型                  | Uint16   | 同上                                                                                                                                                                                                                           |
+| 4      | 当前第 x 个可配采样通道所记录变量的地址              | Uint32   | 同上                                                                                                                                                                                                                           |
+| 1      | 最大可配采样通道总数                                 | Uint8    | —                                                                                                                                                                                                                             |
+| 1      | 已配置的可配采样通道个数                             | Uint8    | ≤ 最大可配采样通道总数                                                                                                                                                                                                        |
+| 2      | 第 1 个可配采样通道所记录变量的类型                  | Uint16   | 类型代码同可配快照通道                                                                                                                                                                                                         |
+| 4      | 当前第 1 个可配采样通道所记录变量的地址              | Uint32   | 与程序版本相关,同可配快照通道                                                                                                                                                                                                 |
+| …     | …(第 2 … y 个:类型(2B) + 地址(4B))              | —       | y = 最大可配采样通道总数;每项按「类型(2B) + 地址(4B)」重复                                                                                                                                                                    |
+| 2      | 第 y 个可配采样通道所记录变量的类型                  | Uint16   | 同上                                                                                                                                                                                                                           |
+| 4      | 当前第 y 个可配采样通道所记录变量的地址              | Uint32   | 同上                                                                                                                                                                                                                           |
+
+> 注:上表「16 字节 故障录波功能基本信息」与 code=2 的基本信息结构一致(见 5.1.1,共 16 字节),两处相符。
+> 通道含义参数代码(快照 / 采样通道含义编号)的取值定义见第 10 节 F14;事件触发源代码表见 10.4(第 10 节 F15)。
+>
+> 与 code=1 的关系(可相互参考):本表从「快照通道总数」起的后半部分,与 code=1(见 5.1.1)的通道部分结构基本一致(快照 / 采样 / 可配快照 / 可配采样四组,每组「总数 + 已用/已配数 + 逐通道数据」),通道「含义」编号(F14)与可配通道 type/addr 共用同一套定义。**唯一区别**:此处 code=4 的快照通道逐项为**含义(2B) + 数值(2B)**(含故障时刻瞬时值),而 code=1 的快照通道逐项**只有含义(2B)**。
+
+#### 5.2.3 `waveformdata` 文件内容格式(code=5 单一故障波形数据)
+
+文件内为十六进制文本,还原成字节后为连续的 Uint16 采样点序列。排列方式为**按采样点外层、波形通道内层**:先排所有波形在第 1 个采样点的值,再排所有波形在第 2 个采样点的值,依此类推。
+
+| 字节数 | 字段                                  | 数据类型 | 备注                     |
+| ------ | ------------------------------------- | -------- | ------------------------ |
+| 2      | 第 1 个波形 第 1 个点的数值           | Uint16   | —                       |
+| 2      | 第 2 个波形 第 1 个点的数值           | Uint16   | —                       |
+| …     | …(第 1 个采样点,第 1 … n 个波形) | Uint16   | n = 波形通道数           |
+| 2      | 第 n 个波形 第 1 个点的数值           | Uint16   | —                       |
+| 2      | 第 1 个波形 第 2 个点的数值           | Uint16   | 第 2 个采样点开始        |
+| …     | …(第 2 个采样点,第 1 … n 个波形) | Uint16   | —                       |
+| 2      | 第 n 个波形 第 2 个点的数值           | Uint16   | —                       |
+| …     | …(依次到第 m 个采样点)             | Uint16   | m = 每个波形的采样点总数 |
+| 2      | 第 n 个波形 第 m 个点的数值           | Uint16   | 最后一个点               |
+
+> 说明:
+>
+> - 存储顺序为「采样点优先」:第 k 个采样点处所有 n 个波形的值连续排列,再进入第 k+1 个采样点。
+> - 波形通道数 n、采样点总数 m 等由 code=1 / code=2 中的通道配置与采样点参数决定(如 code=2 的「采样点总数 / 故障前采样点 / 故障后采样点」)。
+> - 每个波形各采样点代表哪个物理量,对应 code=4 中的「采样通道含义」编号。
+
+---
+
+### 5.3 查询参数(cmd = 3)
+
+> 与 cmd=1 相同,`id` 是**命令控制符(功能码)**(定义见 5.4.1)。查询时只提供功能码、不带输入参数;返回的 `idData` 为该功能码对应的当前配置 / 状态,其数据结构可参照 5.4.1 中该功能码的参数结构。
+
+#### 请求
+
+```json
+{
+  "cmd": 3,
+  "data": {
+    "list": [
+      {
+        "type": "pe",
+        "info": [
+          {
+            "sn": "10050015A00X25070634",
+            "idList": [5]
+          }
+        ]
+      }
+    ]
+  }
+}
+```
+
+> 暂无 cmd=3 实报,本例为按协议构造的示意:查询 PE 当前采样频率。
+
+| 字段              | 类型     | 说明                                             |
+| ----------------- | -------- | ------------------------------------------------ |
+| `info[].idList` | 整数数组 | 需要查询的命令控制符(功能码)集合,取值见 5.4.1 |
+
+#### 回复
+
+```json
+{
+  "cmd": 3,
+  "data": {
+    "result": "ok",
+    "list": [
+      {
+        "type": "pe",
+        "info": [
+          {
+            "sn": "10050015A00X25070634",
+            "idInfo": [
+              {
+                "id": 5,
+                "idData": "1194",
+                "resultOne": "ok"
+              }
+            ]
+          }
+        ]
+      }
+    ]
+  }
+}
+```
+
+> 构造示意:`idData = "1194"` 表示 4500 Hz,数值为假设值,非实测。
+
+| 字段                   | 类型   | 说明                                                                  |
+| ---------------------- | ------ | --------------------------------------------------------------------- |
+| `info[].idInfo`      | 数组   | 本设备各功能项的查询结果                                              |
+| `idInfo[].id`        | 整数   | 命令控制符(功能码),对应请求 `idList` 中的值                      |
+| `idInfo[].idData`    | 字符串 | 读取到的该功能码对应配置 / 状态值,十六进制字符串形式;结构参照 5.4.1 |
+| `idInfo[].resultOne` | 字符串 | 该功能项的处理结果                                                    |
+
+---
+
+### 5.4 设置参数(cmd = 1)
+
+> `id` 并非「某个数据参数的编号」,而是**命令控制符(功能码)**:每个 `id` 代表一种对子设备的操作,`setVal` 是该操作的「函数输入参数」,其内容结构随 `id` 不同而不同。功能码及其输入参数结构见 5.4.1。
+
+#### 请求
+
+```json
+{
+  "cmd": 1,
+  "data": {
+    "list": [
+      {
+        "type": "pe",
+        "info": [
+          {
+            "sn": "10050015A00X25070634",
+            "paraList": [
+              { "id": 6, "setVal": "010100015984" },
+              { "id": 6, "setVal": "020100015981" },
+              { "id": 6, "setVal": "030100015983" },
+              { "id": 6, "setVal": "04030003FD34" }
+            ]
+          }
+        ]
+      }
+    ]
+  }
+}
+```
+
+> 示例取自已验证代码 `ChargeChannel`(`service/repositories/mqtt/fwh_mqtt/fwh_mqtt_fault_record.go`):一次配置 4 个可配采样通道,同一功能码 `id=6` 重复出现,由 `setVal` 首字节的通道索引区分。按 5.4.1 解析:
+>
+> | setVal             | 索引号 | 数据类型          | 数据            |
+> | ------------------ | ------ | ----------------- | --------------- |
+> | `01 01 00015984` | 1      | 默认方式,Uint16  | 地址 0x00015984 |
+> | `02 01 00015981` | 2      | 默认方式,Uint16  | 地址 0x00015981 |
+> | `03 01 00015983` | 3      | 默认方式,Uint16  | 地址 0x00015983 |
+> | `04 03 0003FD34` | 4      | 默认方式,float32 | 地址 0x0003FD34 |
+
+| 字段                  | 类型   | 说明                                                         |
+| --------------------- | ------ | ------------------------------------------------------------ |
+| `info[].paraList`   | 数组   | 需要执行的功能项列表                                         |
+| `paraList[].id`     | 整数   | 命令控制符(功能码),取值见 5.4.1                           |
+| `paraList[].setVal` | 字符串 | 该功能码的输入参数(结构见 5.4.1);无输入参数的功能码可为空 |
+
+#### 回复
+
+```json
+{
+  "cmd": 1,
+  "data": {
+    "result": "ok",
+    "list": [
+      {
+        "type": "pe",
+        "info": [
+          {
+            "sn": "10050015A00X25070634",
+            "setResultList": [
+              { "id": 6, "resultOne": "ok" },
+              { "id": 6, "resultOne": "ok" },
+              { "id": 6, "resultOne": "ok" },
+              { "id": 6, "resultOne": "ok" }
+            ]
+          }
+        ]
+      }
+    ]
+  }
+}
+```
+
+> 暂无 cmd=1 回复实报,本例为按协议构造的示意。重复功能码的结果是否严格按请求顺序逐项返回,待实测确认。
+
+| 字段                          | 类型   | 说明                                             |
+| ----------------------------- | ------ | ------------------------------------------------ |
+| `info[].setResultList`      | 数组   | 本设备各功能项的执行结果                         |
+| `setResultList[].id`        | 整数   | 命令控制符(功能码),对应请求 `paraList[].id` |
+| `setResultList[].resultOne` | 字符串 | 该功能项的执行结果                               |
+
+#### 5.4.1 命令控制符(id / 功能码)定义
+
+`id` 为命令控制符(功能码),指明对设备执行的操作。设置(cmd=1)时 `setVal` 为该功能码的输入参数;查询(cmd=3)时无输入参数,查询返回的数据结构可参照对应功能码的参数结构。
+
+| id | 功能                 | 设置时的输入参数(setVal)结构                                                                                                                        |
+| -- | -------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- |
+| 1  | 清除设备故障波形     | 无输入参数                                                                                                                                            |
+| 2  | 采样通道配置功能     | 「最大可配置的采样通道总数」(1 字节) + 「第 1 个采样通道含义」(2 字节, 大端) + … + 「第 n 个采样通道含义」(2 字节, 大端)。通道含义编号见第 10 节 F14 |
+| 3  | 手动触发故障录波     | 无输入参数                                                                                                                                            |
+| 4  | 设备对时             | 7 字节,按「年(2 字节) 月 日 时 分 秒」顺序。例:`07 E6 01 01 01 01 01` 表示 2022-01-01 1:1:1(年为大端)                                           |
+| 5  | 设置故障录波采样频率 | 2 字节,单位 Hz。例:`11 94` 表示 4500 Hz(大端)                                                                                                   |
+| 6  | 配置可配采样通道功能 | 「可配采样通道的索引号」(1 字节) + 「数据类型」(1 字节) + 「数据」(4 字节),见下方说明                                                                |
+| 7  | 故障波形功能状态控制 | 「控制字节」(1 字节):`0h`=关闭故障录波;`1h`=使能故障录波;其他=无效                                                                             |
+
+**id=6「配置可配采样通道功能」的参数结构**:
+
+- 索引号(1 字节):被配置的可配采样通道索引。
+- 数据类型(1 字节),拆为高半字节 ByteH 与低半字节 ByteL:
+  - ByteH = 设置方式:`0h`=默认方式(需携带采样通道地址类型与地址信息);`1h`=代码方式(需携带采样通道参数代码);其他=无效。
+  - ByteL = 采样通道地址类型(仅默认方式 ByteH=0h 时有效):`0h`=Empty;`1h`=Uint16;`2h`=int16;`3h`=float32;`4h`=Uint32_HWord;`5h`=Uint32_LWord;`6h`=Uint32;其他=无效。
+- 数据(4 字节),按设置方式解析:
+  - ByteH=0h(默认方式):4 字节表示要设置的地址。例:`00 31 11 94` 表示地址 0x311194。
+  - ByteH=1h(代码方式):前 2 字节表示采样通道代码(参数代码见 F14),后 2 字节无效。例:`12 34 xx xx` 表示采样通道代码 0x1234。
+
+**`setVal` 的编码方式**:`setVal` 为**十六进制字符串**,每 2 个字符表示 1 个字节。例如 `setVal = "010100015984"` 表示字节序列 `0x01 0x01 0x00 0x01 0x59 0x84`。各功能码输入参数的字节内容按上表 / 上文结构填写后,整体以十六进制字符串形式放入 `setVal`。
+
+> 对时(id=4)示例:7 字节 `07 E6 01 01 01 01 01` → `setVal = "07E60101010101"`(14 个十六进制字符)。采样频率(id=5)设 4500Hz 示例:2 字节 `11 94` → `setVal = "1194"`。
+
+> 备注(设备端实现):`setVal` 的十六进制字符串由设备端负责转换为字节后发往子设备。此转换属设备端实现细节,待后续联调测试确认,不影响本协议对接。
+
+> 查询(cmd=3):请求 `idList` 中填功能码,无输入参数;返回的 `idData` 为对应功能码的当前配置 / 状态,其数据结构**参照上表中该功能码的参数结构**(例如查 id=2 返回采样通道配置、查 id=5 返回当前采样频率等)。
+
+---
+
+### 5.5 退出(cmd = 4)
+
+通知设备结束当前处理流程。
+
+```json
+{
+  "cmd": 4,
+  "data": {}
+}
+```
+
+该命令不产生业务数据回复。
+
+---
+
+### 5.6 连通性测试(cmd = 90 / 99)
+
+用于链路连通性验证。
+
+```json
+{
+  "cmd": 90,
+  "data": {}
+}
+```
+
+设备回复(错误回复结构,见第 6 节):
+
+- `cmd = 90` → `data.result = "ok"`
+- `cmd = 99` → `data.result = "test fail"`
+
+---
+
+## 6. 错误回复
+
+当命令无法正常进入业务处理(如格式错误、字段缺失、设备忙、序列号无效等)时,设备返回简化的错误回复:
+
+```json
+{
+  "cmd": <命令码>,
+  "data": {
+    "result": "<错误说明>"
+  }
+}
+```
+
+| `data.result`     | 触发原因                     |
+| ------------------- | ---------------------------- |
+| `json unpack err` | 消息体不是合法 JSON          |
+| `cmd unpack err`  | 缺少 `cmd` 字段            |
+| `data unpack err` | 缺少 `data` 字段           |
+| `busy`            | 设备正忙,收到非「退出」命令 |
+| `pe sn not find`  | `pe` 设备序列号无法匹配    |
+| `cmd no define`   | 不支持的命令码               |
+| `ok`              | 测试命令(cmd=90)的成功回复 |
+| `test fail`       | 测试命令(cmd=99)的回复     |
+
+> 当 `cmd` 字段无法解析时,错误回复中的 `cmd` 取值为 0。
+
+---
+
+## 7. 字段速查
+
+| 字段                   | 出现位置            | 类型     | 含义                                                       |
+| ---------------------- | ------------------- | -------- | ---------------------------------------------------------- |
+| `cmd`                | 顶层                | 整数     | 命令码                                                     |
+| `data`               | 顶层                | 对象     | 数据体                                                     |
+| `data.result`        | 回复                | 字符串   | 整体结果:`ok` / `error`(或错误说明)                 |
+| `data.filename`      | 获取记录回复        | 字符串   | 导出文件标识                                               |
+| `list`               | data                | 数组     | 设备分组列表                                               |
+| `type`               | list                | 字符串   | 设备类型:`pe` / `aGate`                               |
+| `info`               | list                | 数组     | 设备项列表                                                 |
+| `sn`                 | info                | 字符串   | 设备序列号                                                 |
+| `codeList`           | info(请求,cmd=0) | 整数数组 | 数据项命令码列表                                           |
+| `index`              | info(请求,cmd=2) | 整数数组 | 记录索引列表                                               |
+| `idList`             | info(请求,cmd=3) | 整数数组 | 命令控制符(功能码)列表                                   |
+| `paraList`           | info(请求,cmd=1) | 数组     | 功能项列表                                                 |
+| `paraList[].id`      | paraList            | 整数     | 命令控制符(功能码)                                       |
+| `paraList[].setVal`  | paraList            | 字符串   | 功能码的输入参数(十六进制字符串)                         |
+| `hexList`            | info(回复,cmd=0) | 数组     | 实时数据结果列表                                           |
+| `hexList[].code`     | hexList             | 整数     | 数据项命令码                                               |
+| `hexList[].hexData`  | hexList             | 字符串   | 数据值(十六进制字符串)                                   |
+| `indexInfo`          | info(回复,cmd=2) | 数组     | 记录结果列表                                               |
+| `indexInfo[].index`  | indexInfo           | 整数     | 记录数据类型码:4=故障数据,5=波形数据(非请求的记录序号) |
+| `idInfo`             | info(回复,cmd=3) | 数组     | 功能码查询结果列表                                         |
+| `idInfo[].id`        | idInfo              | 整数     | 命令控制符(功能码)                                       |
+| `idInfo[].idData`    | idInfo              | 字符串   | 功能码对应的配置 / 状态值(十六进制字符串)                |
+| `setResultList`      | info(回复,cmd=1) | 数组     | 功能项执行结果列表                                         |
+| `setResultList[].id` | setResultList       | 整数     | 命令控制符(功能码)                                       |
+| `resultOne`          | 各回复子项          | 字符串   | 子项处理结果                                               |
+
+---
+
+## 8. 编号字段速览(code / index / id)
+
+本协议中有三类编号字段,分别用于不同命令。下面汇总其含义与取值出处。
+
+### 8.1 `code`(获取实时数据,cmd=0)
+
+- 表示要读取的实时数据类别,取值 1~3,定义见 5.1。code=4/5 不能通过 cmd=0 查询,须用 cmd=2 按记录导出。
+- 读取结果以十六进制字符串放入回复的 `hexData`;各 code 的 `hexData` 字节结构见 5.1.1。
+
+### 8.2 `index`(获取记录,cmd=2)
+
+- 表示要导出的记录序号,取值来源于 code=3「故障录波总目录」中各记录的位置索引(见 5.1.1)。
+- 每条记录包含故障数据(failuredata)与波形数据(waveformdata)两类,分别保存为文件(见 5.2.1)。
+
+### 8.3 `id`(设置 / 查询,cmd=1 / cmd=3)
+
+- 为**命令控制符(功能码)**,表示对设备执行的一种操作,而非某个数据参数的编号。
+- 功能码取值、设置时的输入参数 `setVal` 结构、查询返回的数据结构,均见 5.4.1。
+
+---
+
+## 9. 子项处理结果(resultOne)取值
+
+读取 / 设置的每个子项(`hexList[]`、`indexInfo[]`、`idInfo[]`、`setResultList[]`)都带一个 `resultOne` 字符串,表示该子项的处理结果。取值如下:
+
+| `resultOne` 取值             | 含义                                                                             |
+| ------------------------------ | -------------------------------------------------------------------------------- |
+| `ok`                         | 该子项处理成功;对应的 `hexData` / `idData` 有效                             |
+| `cancomerr:suberrcode=<hex>` | 与子设备通信时,子设备返回了错误码;`<hex>` 为子设备返回的错误代码(十六进制) |
+| `cancomerr:time out`         | 与子设备通信超时                                                                 |
+| `cancomerr:index=<hex>`      | 其他通信类错误;`<hex>` 为设备内部的错误判定码(十六进制)                     |
+| `emserr:can p null`          | 未取到目标子设备(子设备不可达 / 不存在)                                        |
+| `emserr:can data null`       | 子设备返回数据为空                                                               |
+| `emserr:set cmd err`         | 下发命令失败                                                                     |
+| `emserr:other`               | 其他错误                                                                         |
+
+> 当 `resultOne` 不为 `ok` 时,对应子项的 `hexData` / `idData` 可能为空或无效。
+> 其中 `cancomerr:suberrcode=<hex>` 里的 `<hex>` 为子设备错误码(F12,代码表见 10.6);`cancomerr:index=<hex>` 里的 `<hex>` 为设备内部判定码(F13,数值表见 10.5)。
+
+---
+
+## 10. 编号 / 代码字段汇总与消歧
+
+本节汇总协议中各类编号 / 代码字段的含义与取值出处,并对同名字段做消歧。各字段的取值表或来源均已在本文档内给出或指明。
+
+> 为避免混淆:`code`、`id`、`index`、`resultOne` 等名字在「请求 / 回复」「不同命令」里重复出现,但含义不同。下表用**完整 JSON 路径**(从消息根对象 `{}` 开始,`data.list[].info[]` 为设备项层级)加上**命令号与方向**唯一标识每个字段,请以路径为准,不要只看字段末名。
+
+### 10.1 编号字段清单(按完整 JSON 路径定位)
+
+**状态总览**:
+
+| 字段            | 含义与取值出处                                                          |
+| --------------- | ----------------------------------------------------------------------- |
+| F1、F2          | 实时数据类别 `code`,取值 1~3,见 5.1                                 |
+| F3              | 各 code 的 `hexData` 字节结构,见 5.1.1                               |
+| F4              | 记录序号,取值即 code=3 目录中各记录的 `index`(见 5.1.1)            |
+| F5、F6、F8、F10 | 命令控制符(功能码),见 5.4.1                                          |
+| F7              | 查询返回值 `idData`,结构参照 5.4.1 对应功能码                        |
+| F9              | 设置输入参数 `setVal`,十六进制字符串,结构见 5.4.1                   |
+| F11             | 记录文件保存格式与字节结构,见 5.2.1~5.2.3                              |
+| F12             | 下级设备错误码表,见 10.6                                               |
+| F13             | 设备内部通信错误判定码,见 10.5                                         |
+| F14             | 通道含义参数代码(Uint16 编号 → 物理量),对照表为产品级《参数代码表》 |
+| F15             | 事件触发源代码表,见 10.4                                               |
+
+> 下表为完整清单,列出每个字段的完整 JSON 路径、含义与取值出处。
+
+| 编号 | 完整 JSON 路径                                                                                  | 命令 / 方向           | 类型             | 含义                                                                                                                                               | 取值出处                               |
+| ---- | ----------------------------------------------------------------------------------------------- | --------------------- | ---------------- | -------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------- |
+| F1   | `data.list[].info[].codeList[]`                                                               | cmd=0 请求            | 整数数组         | 实时数据类别编号:1-故障总体信息;2-设备故障录波功能基本信息;3-故障录波总目录(4/5 不能用于 cmd=0)                                               | 5.1                                    |
+| F2   | `data.list[].info[].hexList[].code`                                                           | cmd=0 回复            | 整数             | 回显请求 F1 的编号                                                                                                                                 | 5.1                                    |
+| F3   | `data.list[].info[].hexList[].hexData`                                                        | cmd=0 回复            | 字符串(hex)      | 各 code 返回数据的原始字节(十六进制字符串)                                                                                                       | 字节结构:5.1.1;内含编号见 F14 / F15  |
+| F4   | `data.list[].info[].index[]`                                                                  | cmd=2 请求            | 整数数组         | 记录序号,取值即 code=3 目录中各记录的位置索引 `index`(从 1 开始)。对端应先用 cmd=0 + code=3 查目录,再以目录中列出的 `index` 作为本字段取值 | 5.1.1(code=3)                        |
+| F5   | `data.list[].info[].idList[]`                                                                 | cmd=3 请求            | 整数数组         | 命令控制符(功能码)                                                                                                                               | 5.4.1                                  |
+| F6   | `data.list[].info[].idInfo[].id`                                                              | cmd=3 回复            | 整数             | 回显请求 F5 的功能码                                                                                                                               | 5.4.1                                  |
+| F7   | `data.list[].info[].idInfo[].idData`                                                          | cmd=3 回复            | 字符串(hex)      | 查询返回的功能码对应配置 / 状态字节(如 id=2 返回采样通道配置、id=5 返回采样频率)                                                                 | 5.4.1(对应功能码结构)                |
+| F8   | `data.list[].info[].paraList[].id`                                                            | cmd=1 请求            | 整数             | 命令控制符(功能码)                                                                                                                               | 5.4.1                                  |
+| F9   | `data.list[].info[].paraList[].setVal`                                                        | cmd=1 请求            | 字符串(hex)      | 功能码输入参数,十六进制字符串(每 2 字符 = 1 字节,如 `"010100015984"` = `01 01 00 01 59 84`)                                                | 5.4.1                                  |
+| F10  | `data.list[].info[].setResultList[].id`                                                       | cmd=1 回复            | 整数             | 回显请求 F8 的功能码                                                                                                                               | 5.4.1                                  |
+| F11  | 设备侧导出的记录文件(`failuredata`/`waveformdata`)内容                                    | cmd=2(非 MQTT 字段) | 文本文件         | 导出记录的落盘文件                                                                                                                                 | 保存规则 5.2.1;字节结构 5.2.2 / 5.2.3 |
+| F12  | `resultOne` = `cancomerr:suberrcode=<hex>` 中的 `<hex>`                                   | 回复子项              | 整数(hex)        | 子设备返回的错误码                                                                                                                                 | 10.6                                   |
+| F13  | `resultOne` = `cancomerr:index=<hex>` 中的 `<hex>`                                        | 回复子项              | 整数(hex)        | 设备内部的通信错误判定码                                                                                                                           | 10.5                                   |
+| F14  | 通道含义编号(参数代码),见 code=1 的 `hexData`(5.1.1)、code=4 的 `failuredata`(5.2.2) | cmd=0 / cmd=2         | 整数编号(Uint16) | 快照 / 采样通道的含义编号(快照与采样共用同一套参数代码),标识通道对应的物理量;code=1 的可配通道「类型 / 地址」标识可配通道所记录的变量          | 产品级《参数代码表》(编号 → 物理量) |
+| F15  | 事件触发源代码,见 code=3(5.1.1)、code=4(5.2.2)的「事件触发源」字段                         | cmd=0                 | 整数(Uint16)     | 事件触发源                                                                                                                                         | 10.4(表外代码为未定义)               |
+
+### 10.2 同名字段消歧(重点)
+
+以下几组字段末名相同但不是同一个东西,按路径区分:
+
+| 末名                        | 不同含义的字段                                                                                                                   | 区别                                                                                                                                                               |
+| --------------------------- | -------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
+| `index`                   | ① `data.list[].info[].index[]`(cmd=2 **请求**,F4)② `data.list[].info[].indexInfo[].index`(cmd=2 **回复**) | ① 是请求的**记录序号**,取值来源于 code=3 目录(见 5.1.1)。② 是**记录数据类型码**(固定 4=故障数据 / 5=波形数据,见 5.2)。两者含义不同,均已明确。 |
+| `code`                    | `data.list[].info[].hexList[].code`(cmd=0 回复,F2)                                                                          | 仅此一处;它回显请求 `codeList`(F1)的元素。                                                                                                                    |
+| `id`                      | `idList[]`(F5) / `idInfo[].id`(F6) / `paraList[].id`(F8) / `setResultList[].id`(F10)                                     | 四处均为**同一套命令控制符(功能码)**,分布在不同命令 / 方向;取值见 5.4.1。                                                                                |
+| `result` vs `resultOne` | `data.result`(整条命令结果)`...resultOne`(单个子项结果)                                                                  | `data.result` 取值 `ok`/`error`(见 4.3);`resultOne` 取值见第 9 节;其中内嵌的 `<hex>` 代码表见 10.5(F13)/ 10.6(F12)。                             |
+
+> 注意 F13:`cancomerr:index=<hex>` 里的 `index` 与记录序号、记录类型码都**无关**,它是设备内部的通信错误判定码,与子设备无关。其数值表见 10.5。
+
+### 10.3 各编号 / 代码表的取值出处汇总
+
+本协议所涉编号 / 代码字段的取值表或来源均已在本文档内给出或指明:
+
+| 字段                                                                 | 取值出处                                   |
+| -------------------------------------------------------------------- | ------------------------------------------ |
+| 实时数据类别 `code`(cmd=0)                                       | 5.1                                        |
+| `hexData` 字节结构(code=1/2/3)                                   | 5.1.1                                      |
+| 记录文件字节结构(code=4 `failuredata` / code=5 `waveformdata`) | 5.2.2 / 5.2.3                              |
+| 记录序号 `index`(cmd=2)                                          | 来源于 code=3 目录(5.1.1)                |
+| 命令控制符 `id` 及输入参数 `setVal`、查询返回结构(cmd=1/3)     | 5.4.1                                      |
+| 通道含义参数代码(F14)                                              | 产品级《参数代码表》(编号 → 物理量对照) |
+| 事件触发源代码(F15)                                                | 10.4                                       |
+| 设备内部通信错误判定码(F13)                                        | 10.5                                       |
+| 子设备错误码(F12)                                                  | 10.6                                       |
+
+### 10.4 事件触发源代码表(F15)
+
+以下为「事件触发源」字段(`triggerSource`,Uint16)的取值定义。该字段出现在 code=3(故障录波总目录,见 5.1.1)与 code=4(单一故障信息,见 5.2.2)中。
+
+| 代码       | 含义                     |
+| ---------- | ------------------------ |
+| `0xFF0`  | 手动触发 _Fix            |
+| `0xFF1`  | 模拟故障触发 _Auto       |
+| `0x1000` | SL_DSP 预留触发源 16_1   |
+| `0x1001` | 并网发生状态切换故障     |
+| `0x1002` | 离网发生 aPower 离运问题 |
+| `0x1003` | 油机发生状态切换故障     |
+| `0x5000` | PE_INV 预留触发源 16_1   |
+| `0x5001` | 运行态下逆变故障         |
+| `0xD000` | MPPT 预留触发源 16_1     |
+| `0xD001` | 运行态下 MPPT 故障       |
+| `0xD800` | APBOX2.0 预留触发源 16_1 |
+| `0xD801` | 运行态下 APBOX2.0 故障   |
+| `0xE000` | DIF 预留触发源 16_1      |
+| `0xE001` | 并网发生状态切换故障     |
+| `0xE002` | 离网发生 aPower 离运问题 |
+
+> 上表以外的代码为未定义,待后续补充。
+
+### 10.5 内部通信错误判定码(F13)
+
+`resultOne` 为 `cancomerr:index=<hex>` 时,`<hex>` 是**设备内部**在与子设备通信过程中判定的错误类型码(以十六进制输出)。此表为设备自身定义,与子设备无关。
+
+说明:这些码对应与子设备通信在不同阶段(数据读取、记录传输、参数读写)的异常,供排查参考。
+
+| `<hex>` | 含义                                                |
+| --------- | --------------------------------------------------- |
+| `3`     | 下发命令与子设备回复的命令不一致                    |
+| `4`     | 数据读取阶段:子设备回复错误                        |
+| `5`     | 数据读取阶段:回复的数据类别不符                    |
+| `6`     | 记录传输阶段:起始报文接收错误                      |
+| `7`     | 记录传输阶段:后续数据未接收完成                    |
+| `8`     | 记录传输阶段:数据块不连续                          |
+| `9`     | 记录传输阶段:数据块内序号不连续                    |
+| `a`     | 记录传输阶段:收到多余的后续数据                    |
+| `b`     | 记录传输阶段:首包数据过短                          |
+| `c`     | 记录传输结束阶段:后续数据未接收完成                |
+| `d`     | 数据读取阶段:接收超时                              |
+| `e`     | 记录传输阶段:接收超时                              |
+| `f`     | 记录传输结束阶段:接收超时                          |
+| `10`    | 其他命令:接收超时                                  |
+| `11`    | 记录传输阶段:后续数据接收超时                      |
+| `12`    | 参数读写阶段:读 / 写类型不一致(如请求读却回复写) |
+| `13`    | 参数读写阶段:回复的功能码不一致                    |
+| `14`    | 参数读写阶段:回复数据过短                          |
+| `15`    | 参数读写阶段:接收超时                              |
+| `16`    | 参数读写阶段:后续数据未接收完成                    |
+
+> 说明:
+>
+> - 另有「子设备回复错误」与「通用超时」两种情况,分别以 `cancomerr:suberrcode=<hex>`(F12)与 `cancomerr:time out` 形式返回,**不会**出现在 `cancomerr:index=` 中。故本表取值集中在 `0x3`~`0x16`。
+> - 这些是「设备侧判定」的通信错误;而 F12(`suberrcode`)是「子设备回复」的错误码,二者来源不同,不要混淆。
+
+### 10.6 子设备错误码表(F12 / suberrcode)
+
+`resultOne` 为 `cancomerr:suberrcode=<hex>` 时,`<hex>` 是**子设备**返回的错误码(否定响应码),以十六进制输出。取值定义如下。
+
+| 代码     | 名称                                     | 说明                                       |
+| -------- | ---------------------------------------- | ------------------------------------------ |
+| `0x10` | GeneralReject                            | 诊断服务被服务器(ECU)拒绝                  |
+| `0x11` | serviceNotSupported                      | ECU 不支持该诊断请求                       |
+| `0x12` | subFunctionNotSupported                  | ECU 不支持该诊断请求子功能                 |
+| `0x13` | incorrectMessageLengthOrInvalidFormat    | 请求报文长度或格式错误                     |
+| `0x14` | ResponseTooLong                          | 诊断响应数据长度超出服务器所支持的最大长度 |
+| `0x22` | conditionsNotCorrect                     | 条件错误                                   |
+| `0x23` | commTimeoutError                         | 自定义,通信超时                           |
+| `0x31` | requestOutOfRange                        | 请求超过范围                               |
+| `0x33` | securityAccessDenied                     | 安全访问拒绝该请求                         |
+| `0x35` | invalidKey                               | 反馈密钥错误                               |
+| `0x36` | exceedNumberOfAttempts                   | 安全访问尝试次数超限                       |
+| `0x37` | requiredTimeDelayNotExpired              | 安全访问延时未到期                         |
+| `0x70` | UploadDownloadNotAccepted                | 上传或下载数据失败                         |
+| `0x71` | TransferDataSuspended                    | 由于故障导致数据传输操作被中断             |
+| `0x72` | GeneralProgrammingFailure                | 擦除或写入 Flash 出现错误                  |
+| `0x73` | WrongBlockSequenceCounter                | 数据包序列编号错误                         |
+| `0x78` | requestCorrectlyReceived-ResponsePending | ECU 正忙,无法及时响应该请求               |
+| `0x7F` | serviceNotSupportedInActiveSession       | ECU 不支持当前诊断模式下进行该 SID 请求    |
+
+> 说明:此表为子设备(PE / aGate)定义的错误码,由子设备在负响应中返回、经设备原样透传到 `suberrcode`。请勿与 F13(10.5,设备内部判定码)混淆:F12 是「子设备回复的错误」,F13 是「本设备判定的错误」。

+ 35 - 0
docs/fault-record/README.md

@@ -0,0 +1,35 @@
+# 故障录波文档导航
+
+本目录整理通过 MQTT 直接获取和设置子设备故障录波数据的需求与设计,覆盖生产服和测试服。当前状态:首版已编码并通过离线测试,待真实设备实测(见 [implementation-status.md](implementation-status.md))。文档中的待实测内容不代表设备已支持。
+
+建议阅读顺序:requirements → technical-design → ui-design;查协议细节再看 protocol-specification 和 FaultFilter 文档。
+
+## 文档职责
+
+| 文档 | 内容 | 何时读取 |
+| --- | --- | --- |
+| [requirements.md](requirements.md) | 四项功能目标、预期行为、待确认的产品口径 | 确定要做什么、讨论验收范围时 |
+| [technical-design.md](technical-design.md) | 分层架构、MQTT 客户端、异步操作与恢复、存储、取消、双环境 | 讨论如何实现、评审后端设计时 |
+| [ui-design.md](ui-design.md) | 菜单结构、各功能页面布局和交互 | 实现或评审前端页面时 |
+| [FaultFilter_MQTT协议文档.md](FaultFilter_MQTT协议文档.md) | 设备侧故障录波服务的业务协议:命令码、字段、`hexData` 字节结构、功能码、错误码 | 业务层字段的权威依据;与其他文档冲突时以它为准 |
+| [protocol-specification.md](protocol-specification.md) | `377~380` 逐字段结构、示意报文、关联规则和编号补充清单 | 核对协议结构、逐项补充缺失信息时 |
+| [protocol-observations.md](protocol-observations.md) | `377~380` 报文、真实样本时间线、旧协议描述和存疑项 | 判断协议字段、核对新抓包时 |
+| [implementation-status.md](implementation-status.md) | 当前仓库已实现的能力、代码入口和差距 | 开始修改代码或核对当前行为时 |
+| [CloudLog_2026-10-14_104644.csv](CloudLog_2026-10-14_104644.csv) | 2026-10-14 测试服原始通讯日志,含 `607~610` 上传过程 | 核对样本原文时 |
+| [2026-10-14_05_46_20.tar.bz2](2026-10-14_05_46_20.tar.bz2) | 上述日志对应的录波文件(实为 gzip tar),记录序号 7 | 开发和测试文件解析时 |
+
+## 事实与决策的边界
+
+- **真实报文**用于确认样本覆盖的消息字段和顺序;单份日志不证明未覆盖场景的行为。
+- **已验证代码**用于确认现有参数设置请求结构;当前代码行为见实现状态文档。
+- **旧协议描述**保留为待验证线索。与真实报文冲突时,以真实报文为准。
+- **用户后续确认**记录在协议详细描述中;与旧样本不同的服务端约定单独标注,不改写历史样本。
+- **功能需求**定义目标;**技术设计**只记录当前可支持的方案与必须先验证的条件。两者不因现有代码或单份日志而自动改写。
+
+## 与 DataDownload 的关系
+
+[DataDownload](../data-download/README.md) 已有基于 FWH API 的故障录波召唤、下载和归档流程。本目录讨论新增的 MQTT 直连路径。MQTT 路径的文件同样由平台上传到 hes-upload,`607.faultRecordID` 是平台召唤记录 ID,可与 DataDownload 的记录互查。用户决定:本功能完整实现并测试通过后,再考虑开放给 DataDownload;在此之前 DataDownload 继续使用原 FWH API 方案。本阶段不把现有 DataDownload 行为视为 MQTT 协议事实。
+
+## 维护方式
+
+新增抓包先更新 `protocol-observations.md` 并标明证据;字段和协议规则确认后更新 `protocol-specification.md`;产品范围变化更新 `requirements.md`;确认设计决策后更新 `technical-design.md`;页面调整更新 `ui-design.md`;代码落地后再更新 `implementation-status.md`。避免把同一项存疑写成多处互相冲突的结论。

+ 61 - 0
docs/fault-record/implementation-status.md

@@ -0,0 +1,61 @@
+# 故障录波当前实现状态
+
+> 核对日期:2026-10-16。按 [technical-design.md](technical-design.md) 和 [ui-design.md](ui-design.md) 完成首版编码;**尚未用真实设备测试**,依赖实测的前提见 [technical-design.md](technical-design.md#9-待实测前提)。
+
+## 1. 代码位置
+
+| 层 | 位置 | 内容 |
+| --- | --- | --- |
+| L0 | `service/utils/mqttClient/mqttClient.go` | 补充订阅表加锁(`OnConnect` 回调与业务线程并发访问) |
+| L1 | `service/repositories/mqtt/fwh_mqtt/` | `fwh_mqtt_client.go`:按环境的客户端,`Request`/`Publish`/`Subscribe`,有订阅时不空闲断开;`fwh_mqtt_envelope.go`:信封构造与 topic 工具 |
+| — | `service/repositories/mqtt/fwh_xlog/` | Xlog 业务从客户端包移出,固定生产服 |
+| L2 | `service/function/FaultRecordProtocol/` | 377 构造、378/379/607~610 解析(兼容旧版本 608 将 id 误写为第二个 result)、setVal 校验与 idData 解读、触发源/错误码字典 |
+| L2 | `service/function/FaultRecordFile/` | hexData(code=1/2/3)、failuredata、waveformdata、压缩包(按文件头识别 gzip/bzip2)、CSV(zip) |
+| 存储 | `service/repositories/mongo/faultRecordRepo.go` | 集合 `FaultRecordOperation`,索引在 `mongo.Init` 注册 |
+| 存储 | `service/repositories/minio/faultRecordRepo.go` | 对象键 `fault-record/<env>/<gatewaySn>/<fileName>` |
+| 下载 | `service/repositories/api/fwh_pro|fwh_test/fwh_faultRecordUpload.go` | 按环境域名无 token 下载 |
+| 锁 | `service/cache/redisx/lockx.go` | 新增 `TryLockWithToken`:以操作 ID 为 token,重启后可接管自己的网关锁 |
+| L3 | `service/task/queue/faultRecordQueue/` | asynq 队列 `fault-record`;执行器状态机、取消、重启恢复;`RecoverActive` 在启动时重新提交未结束操作 |
+| L4 | `service/services/faultRecordServices.go` | 创建/取消/查询/详情/录波解析/导出/设备列表 |
+| L5 | `service/controllers/faultRecordControllers.go`、`service/routes/faultRecordRoutes.go` | 见下方接口表 |
+| 前端 | `src/views/fault-record/` | `record`(录波记录)、`device-info`(设备信息)、`function`(功能操作:1/3/4/7)、`param-write`(参数设置:5/6,读取走 cmd=0)四个页面,公共组件在 `common/`;功能码 2 暂不开放 |
+| 前端 | `src/service/faultRecord/`、`src/router/record.ts` | 接口封装;菜单“故障录波 → 生产服/测试服 → 功能” |
+
+已删除:旧参数设置接口 `/faultRecord/paramSetting` 及其页面 `fault-record/read|write`、`faultRecordState` store、客户端包中的 `FaultRecordParamSetting`/`ChargeChannel`、测试服手动脚本。
+
+## 2. HTTP 接口
+
+| 方法 | 路径 | 说明 |
+| --- | --- | --- |
+| GET | `/faultRecord/devices?env=&gatewaySn=` | 站点信息中的 aGate + aPower(pe)列表 |
+| POST | `/faultRecord/operations` | 创建操作,返回 `id`;`kind` 为 `device_info`/`record_fetch`/`param_read`/`param_write` |
+| GET | `/faultRecord/operations?env=&gatewaySn=&kind=&pageNum=&pageSize=` | 操作历史(共享) |
+| GET | `/faultRecord/operations/:id` | 详情:状态、阶段时间、逐项结果(含解析)、原始报文 |
+| POST | `/faultRecord/operations/:id/cancel` | 取消 |
+| POST | `/faultRecord/operations/:id/retry-download` | 上传已完成但下载失败时手动重试下载 |
+| GET | `/faultRecord/operations/:id/record` | 录波解析结果(基本信息、通道、波形) |
+| GET | `/faultRecord/operations/:id/export?format=raw|csv` | 导出原始压缩包或 CSV(zip) |
+
+## 3. 已完成的验证
+
+全部离线,未连接任何设备或服务器:
+
+- 协议与解析:用 [CloudLog_2026-10-14_104644.csv](CloudLog_2026-10-14_104644.csv) 全部报文和 [2026-10-14_05_46_20.tar.bz2](2026-10-14_05_46_20.tar.bz2) 做单元测试(目录 15 条、failuredata 字段、16×1536 波形、故障点对齐、CSV)。
+- MQTT 客户端:用内存假连接测试多订阅者分发、引用计数订阅、一问一答匹配与超时(`-race`)。
+- 执行器:用样本报文回放完整 `record_fetch`(sent → … → done,faultRecordID=5226)和 `device_info`;另测设备忙、过滤其他 cmd/网关、378 超时、取消发送 cmd=4、重启时 `accepted` → interrupted、`ready` 直接下载恢复、网关锁占用。
+- 后端 `go build`、`go vet` 通过;前端 `vue-tsc`、`vite build` 通过,新增代码无 eslint 错误。
+- **未做**:页面实际渲染效果未在浏览器中查看(需要登录环境)。
+
+## 4. 实测时需要关注
+
+建议按此顺序在测试服实测,每步保存操作详情中的原始报文:
+
+1. **设备信息**:读取 `code=1,2,3`,核对目录与设备一致;确认 MQTT 能订阅 s2c(详情中应能看到 380)。
+2. **录波获取**:先选一条记录获取,确认出现 607/609、文件下载成功、波形可查看、两种导出可用。**这一步验证“只监听平台上传”是否成立。** 再勾选多条一起获取,确认压缩包包含全部记录,以及 379 中多组 `4/5` 的顺序。
+3. **功能操作读取**:读取 4、7,确认 `cmd=3` 外层支持及 idData 解读是否正确。
+4. **参数设置**:设一个无害参数(如采样频率设为当前值)并勾选自动复读;确认 `setResultList` 顺序,以及 code=1 可配采样通道与索引的对应(索引 1 = 第 1 项)。
+5. **取消**:获取录波时在“设备已应答”阶段取消,观察设备对 `cmd=4` 的反应。
+6. **pe 设备**:对一个 aPower 做第 1、2 步,确认下载路径中的 `<equipNo>` 是否仍为网关 SN。
+7. **生产服**:确认配置后重复第 1、2 步。
+
+可调参数集中在 `service/task/queue/faultRecordQueue/deps.go` 的 `defaultTimeouts`(378 等待 30s、379 等待 2 分钟/录波 4 分钟、上传 5 分钟;收到 610 后 15 秒开始下载、失败每 15 秒重试至总超时)。

+ 221 - 0
docs/fault-record/protocol-observations.md

@@ -0,0 +1,221 @@
+# 故障录波 MQTT 协议观察
+
+> 整理日期:2026-09-29;最近修订:2026-09-30。本文只记录报文证据、协议描述和待验证项。功能目标见 [requirements.md](requirements.md),整体设计见 [technical-design.md](technical-design.md)。协议描述可能过时;发生冲突时以真实报文为准。
+
+> 2026-10-12 补充:逐字段结构和本轮用户确认见 [协议详细描述](protocol-specification.md)。用户已明确 `377/378` 不能依靠 `snno` 关联;服务端实现的 `379/380` 可使用 `snno` 关联,具体赋值规则仍待补充。下文保留原样本事实和当时的观察,不将新约定改写为旧抓包行为。
+
+> 2026-10-14 补充:业务层字段已由 [FaultFilter_MQTT协议文档](FaultFilter_MQTT协议文档.md) 定义。下文中与之冲突的旧描述已更正并标注;实报样本事实不变,且与该文档一致。
+
+> 2026-10-16 补充:新增 [2026-10-14 样本](#2026-10-14-样本),包含文件上传消息 `607~610` 和可解析的 `hexData`。用户确认下载地址规律,并决定本工具只监听平台发起的上传。
+
+## 证据与范围
+
+| 标记 | 含义 |
+| --- | --- |
+| 实报 | 用户提供的 `CloudLog_2026-09-29_063029.csv` 中的 8 条真实通讯记录(附件未纳入仓库);以及 [CloudLog_2026-10-14_104644.csv](CloudLog_2026-10-14_104644.csv) 中的 24 条记录 |
+| 已验证代码 | 用户确认 `service/repositories/mqtt/fwh_mqtt/fwh_mqtt_fault_record.go` 已实现的部分协议结构可信 |
+| FaultFilter 文档 | 设备侧故障录波服务的业务协议文档;定义 `dataArea` 内的业务字段,不覆盖 `377~380` 外层 |
+| 描述 | 用户提供的旧协议说明;与 FaultFilter 文档冲突的部分已作废,其余未被实报或已验证代码覆盖的细节仍待验证 |
+
+本工具只发送 `377`,其余消息(`378`、`379`、`380`、`607~610`)全部监听;`380`、`607`、`610` 由 FWH 平台发出(设计决策,2026-10-16,见 [technical-design.md](technical-design.md))。2026-09-29 日志只覆盖 `cmd=0` 和 `cmd=2`、设备类型 `aGate`、成功结果。`cmd=1`、`cmd=3`、`pe` 设备类型及失败结果没有实报样本。
+
+## 报文外层与消息方向
+
+实报中的四类消息均有 `cmdType`、`equipNo`、`type`、`timeStamp`、`snno`、`len`、`crc`、`dataArea` 字段。样本的 `equipNo` 均为 `10060010A01X26310190`,`type=0`。现有发送代码将 `len` 设为序列化后 `dataArea` 的字节长度,并计算其 CRC32 IEEE 大写十六进制值;接收报文是否始终采用同一计算方式,尚未单独验证。
+
+| `cmdType` | 方向 | 作用 | `dataArea` |
+| --- | --- | --- | --- |
+| `377` | 服务端 → 设备 | 故障录波操作指令 | `cmd`、`data` |
+| `378` | 设备 → 服务端 | 对 `377` 的应答 | `cmd`、`result` |
+| `379` | 设备 → 服务端 | 故障录波信息上传请求 | `cmd`、`result`、按命令出现的 `list` / `filename` |
+| `380` | 服务端 → 设备 | 对 `379` 的应答 | `cmd`、`result` |
+
+`378` 是对指令的应答,业务数据或文件处理结果出现在后续 `379`。不能只凭 `378.result=0` 判定录波数据已经取得。CSV 没有 MQTT topic;当前客户端代码发送到 `fwh/s2c/mng/{sn}`,等待 `fwh/c2s/mng/{sn}` 的应答,但 `379/380` 的实际 topic 仍需确认。
+
+`379.dataArea` 的 `result/list/filename` 与 FaultFilter 文档回复中 `data` 对象的内容一致,只是没有 `data` 包裹,推断 `379` 承载的就是 FaultFilter 回复。
+
+## `377`:服务端请求
+
+`dataArea` 的形状为 `{ "cmd": <整数>, "data": { "list": [...] } }`。`list` 和每项的 `info` 均为数组。以下字段按每个 `list[]` / `info[]` 项列出。
+
+| `cmd` | 作用 | `list[].type` | `list[].info[]` | 证据 |
+| --- | --- | --- | --- | --- |
+| `0` | 获取实时数据;`code=3` 即故障录波总目录 | 实报为 `aGate` | `sn` 字符串;`codeList` 整数数组(`1~3`;`4/5` 只能经 `cmd=2` 获取) | `aGate`、`codeList=[3]` 为实报;`code` 字典来自 FaultFilter 文档 |
+| `1` | 设置参数 | 已验证代码发送 `pe` 或 `aGate` | `sn` 字符串;`paraList` 为 `{id: 功能码, setVal: 十六进制字符串}` 数组 | 已验证代码;本次无实报 |
+| `2` | 获取记录 | 实报为 `aGate` | `sn` 字符串;`index` 整数数组,元素为目录中的记录序号 | `aGate`、`index=[9]` 为实报;旧描述的范围 `1~16` 未获 FaultFilter 文档支持,以目录为准 |
+| `3` | 查询参数 | — | `sn` 字符串;`idList` 功能码数组 | 仅 FaultFilter 文档,无实报 |
+| `4` / `90` / `99` | 退出/连通性测试 | — | `data` 为 `{}` | 仅 FaultFilter 文档,无实报 |
+
+旧描述将部分 `type` 或 `sn` 写为 `string[]`,而真实报文及已验证代码对应的是**单个字符串字段**。旧描述列出的 `agate`、`mppt`、`apbox` 不在 FaultFilter 文档中;业务层设备类型只有 `pe` 和 `aGate`。
+
+## `378`:设备对 `377` 的应答
+
+`dataArea` 包含 `cmd` 和整数 `result`。描述称 `result=0` 为成功,`result=-1` 为失败。实报覆盖 `cmd=0` 和 `cmd=2` 的 `result=0`;失败值及其后续行为待验证。FaultFilter 文档不定义 `378`。
+
+## `379`:设备上传
+
+`dataArea.result` 为总结果字符串:`"ok"` 全部子项成功,`"error"` 存在子项失败;命令无法进入处理时为错误说明(如 `busy`、`pe sn not find`)。实报中的 `cmd=0` 和 `cmd=2` 均为 `"ok"`。`dataArea.list` 是数组,每项包含 `type` 和 `info[]`。
+
+| `cmd` | `dataArea` 中的业务字段 | 证据 |
+| --- | --- | --- |
+| `0` | `list[].type`;`info[].sn`;`info[].hexList[]`,每项含 `code` 整数、`resultOne` 字符串、`hexData` 十六进制字符串 | 实报覆盖:`type="aGate"`、`code=3`、`resultOne="ok"`,且有 `hexData`。`hexData` 为子设备原始字节,`code=3` 的结构为 1 字节条数 + N × 14 字节目录项(FaultFilter 文档 5.1.1) |
+| `1` | `list[].type`、`info[].sn`、`info[].setResultList[{id, resultOne}]` | FaultFilter 文档。旧描述的 `resultList[{id, result}]`(`result` 为 `string[]`)有误;`resultOne` 为字符串 |
+| `2` | `filename` 字符串;`list[].type`;`info[].sn`;`info[].indexInfo[]`,每项含 `index` 整数及 `resultOne` 字符串 | 实报覆盖:`filename="2026-09-29_14_18_29"`,`type="aGate"`,`index` 为 `4` 和 `5`,`resultOne` 均为 `"ok"`。`index` 是数据类型码(`4`=故障数据,`5`=波形数据),不是记录序号 |
+| `3` | `list[].type`、`info[].sn`、`info[].idInfo[{id, idData, resultOne}]` | 仅 FaultFilter 文档 |
+
+旧描述称 `379` 的设备类型可为 `aPower`,FaultFilter 文档未定义该值,实报仅见 `aGate`。旧描述将 `cmd=2` 的 `sn`、`resultOne` 写为数组,实报中二者都是字符串。`cmd=2` 的 `379` 只显示文件名和逐项结果,**没有录波文件字节或下载地址**:文件保存在设备本地 `/home/root/ExportFile/faultFilterRecode/<filename>/<sn>/index_<记录序号>/`,随后由平台经 `607~610` 触发上传,见 [2026-10-14 样本](#2026-10-14-样本)。
+
+## `380`:服务端对 `379` 的应答
+
+`dataArea` 包含 `cmd` 和整数 `result`。描述称 `result=0` 成功、`result=-1` 失败。实报覆盖 `cmd=0` 和 `cmd=2` 的 `result=0`;`cmd=1` 及失败值待验证。FaultFilter 文档不定义 `380`。
+
+## 实报时间线
+
+CSV 倒序展示;下表按 `logTime` 正序排列。`logTime` 比同一行的 `time` 晚 8 小时,与本地时间和 UTC 的差值一致。
+
+| 本地时间 | 消息 | 关键字段 |
+| --- | --- | --- |
+| 14:17:35 | `377` | `cmd=0`,`aGate`,`codeList=[3]`,`snno=7529` |
+| 14:17:35 | `378` | `cmd=0`,`result=0`,`snno=13` |
+| 14:17:38 | `379` | `cmd=0`,`result="ok"`,`hexList` 中 `code=3`、`resultOne="ok"`、`hexData`,`snno=14` |
+| 14:17:46 | `380` | `cmd=0`,`result=0`,`snno=7536` |
+| 14:17:47 | `377` | `cmd=2`,`aGate`,`index=[9]`,`snno=7539` |
+| 14:17:47 | `378` | `cmd=2`,`result=0`,`snno=22` |
+| 14:18:31 | `379` | `cmd=2`,`result="ok"`,`filename="2026-09-29_14_18_29"`,`indexInfo` 为 `4/ok`、`5/ok`,`snno=26` |
+| 14:18:33 | `380` | `cmd=2`,`result=0`,`snno=7559` |
+
+请求 `cmd=0` 后约 3 秒出现 `379`;请求 `cmd=2` 后约 44 秒出现 `379`。这些是单次样本的观察值,不是超时规则。请求、应答和上传的 `snno` 不相同;尚不能用它直接关联消息。
+
+该样本没有记录 `607~610`;当时是否发生上传无法从该日志判断。
+
+## 2026-10-14 样本
+
+来源:[CloudLog_2026-10-14_104644.csv](CloudLog_2026-10-14_104644.csv),测试服,网关 `AFCI_TEST_0713`,`aGate`,外层 `equipNo` 与 `info[].sn` 相同。包含两轮完全相同的操作(05:36 和 05:45),每轮依次为 `cmd=0, codeList=[3]` 和 `cmd=2, index=[7]`。日志未标明 `377` 的发送方。
+
+第二轮时间线(`logTime`,UTC):
+
+| 时间 | 消息 | 关键字段 |
+| --- | --- | --- |
+| 05:45:25 | `377` | `cmd=0`,`codeList=[3]`,`snno=6008` |
+| 05:45:25 | `378` | `cmd=0`,`result=0` |
+| 05:45:28 | `379` | `cmd=0`,`result="ok"`,`hexList` 中 `code=3`、`resultOne="ok"`,`hexData` 见下 |
+| 05:45:36 | `380` | `cmd=0`,`result=0` |
+| 05:45:38 | `377` | `cmd=2`,`index=[7]` |
+| 05:45:38 | `378` | `cmd=2`,`result=0` |
+| 05:46:21 | `379` | `cmd=2`,`result="ok"`,`filename="2026-10-14_05_46_20"`,`indexInfo` 为 `4/ok`、`5/ok` |
+| 05:46:21 | `607` | `faultRecordID=5226`,`fileName="2026-10-14_05_46_20"`,`url="http://test.franklinwh.com/hes-upload/v1/file/upload"`,`logTime="2026-10-14"` |
+| 05:46:21 | `608` | `{"result":0,"filename":"2026-10-14_05_46_20","result":5226}`(第二个 `result` 实为误写的 `id`,用户确认,2026-10-16) |
+| 05:46:22 | `380` | `cmd=2`,`result=0` |
+| 05:46:33 | `609` | `result=0`,`fileName="2026-10-14_05_46_20.tar.bz2"` |
+| 05:46:33 | `610` | `result=1` |
+
+第一轮(05:36~05:37)顺序与字段结构相同,`faultRecordID=5224`、`filename="2026-10-14_05_37_32"`,`607→609` 约 11 秒。
+
+观察:
+
+- `607` 在 `379(cmd=2)` 同一秒发出,早于 `380`,带平台召唤记录 ID,推断平台收到 `379` 后自动发起上传。用户确认本工具只监听这一过程(2026-10-16)。
+- 下载地址由用户确认为 `https://test.franklinwh.com/hes-upload/v1/file/download/logger/AFCI_TEST_0713/2026-10-14_05_46_20.tar.bz2`,即 `logger/<equipNo>/<609.fileName>`;生产服替换域名。
+- `610.result=1`,而 `378/380` 成功为 `0`,不同消息的成功值不一致。
+- 两轮 `cmd=0` 的 `hexData` 完全相同。
+
+`code=3` 目录解析(211 字节 = 1 + 15 × 14,结构与 FaultFilter 文档 5.1.1 一致):
+
+| 序号 | 发生时间 | 参数列表版本 | 触发源 |
+| --- | --- | --- | --- |
+| 1 | 2024-01-01 00:00:34 | `00000001` | `0x1002` |
+| 2 | 2026-07-14 02:53:35 | `00000001` | `0x1001` |
+| 3 | 2026-08-12 02:03:05 | `00000001` | `0x1001` |
+| 4 | 2026-08-13 21:40:40 | `00000001` | `0x1001` |
+| 5 | 2026-08-14 03:24:16 | `00000001` | `0x1001` |
+| 6 | 2026-08-16 03:58:05 | `00000001` | `0x1001` |
+| 7 | 2026-08-18 03:03:08 | `00000001` | `0x1001` |
+| 8 | 2025-11-24 01:11:46 | `00000001` | `0x1001` |
+| 9 | 2025-11-26 21:44:06 | `00000001` | `0x1001` |
+| 10 | 2023-05-04 22:01:03 | `00000001` | `0x1002` |
+| 11 | 2023-05-04 22:02:33 | `00000001` | `0x1002` |
+| 12 | 2023-05-04 22:03:40 | `00000001` | `0x1002` |
+| 13 | 2023-05-04 22:04:46 | `00000001` | `0x1002` |
+| 14 | 2023-05-04 22:28:14 | `00000001` | `0x1002` |
+| 15 | 2026-06-29 15:07:35 | `00000001` | `0x1001` |
+
+目录不按时间排序,像是循环覆盖存储;第 1 条时间疑似设备未校时。
+
+## 2026-10-14 录波文件解析
+
+来源:[2026-10-14_05_46_20.tar.bz2](2026-10-14_05_46_20.tar.bz2),即上表 `609.fileName` 对应的文件,按 `logger/<equipNo>/<fileName>` 规律下载。用户确认下载**不需要 token**(2026-10-16)。
+
+### 压缩包
+
+- **扩展名是 `.tar.bz2`,实际是 gzip 压缩的 tar**(文件头 `1f 8b`),用 bzip2 解压会失败。解压时应按文件头判断格式,不能信任扩展名。
+- 目录结构与 FaultFilter 文档 5.2.1 一致,但没有 `faultFilterRecode` 根目录:
+
+```text
+2026-10-14_05_46_20/AFCI_TEST_0713/index_7/failuredata    1024 字符(512 字节)
+2026-10-14_05_46_20/AFCI_TEST_0713/index_7/waveformdata  98304 字符(49152 字节)
+```
+
+- 文件内容为大写十六进制 ASCII 文本,无换行,与文档一致。`<sn>` 为 `info[].sn`(本例与网关 SN 相同)。
+
+### `failuredata`(按 FaultFilter 5.2.2 解析)
+
+| 字段 | 值 | 说明 |
+| --- | --- | --- |
+| 单一故障信息数据长度 | `0x0200` = 512 | 等于整个文件的字节数(含本字段和末尾填充),**不是**有效数据长度 |
+| 基本信息 | CAN 协议版本 1;存储格式版本 1;参数列表版本 `00000001`;最大采样频率 4500;采样频率 4500;采样点总数 1536;故障前 1024;故障后 512 | 与 code=2 结构一致 |
+| 位置索引 | 7 | 与请求 `index=[7]` 一致 |
+| 故障发生时间 | 2026-08-18 03:03:08 | 与目录第 7 条一致 |
+| 事件触发源 | `0x1001` 并网发生状态切换故障 | 与目录一致 |
+| DCDC 版本 | `EMS_SL:V10R80B21D00`,NUL 填充 | aGate 中为从 DSP 版本 |
+| INV/FPGA 版本 | 字节 `01 02 03 … 40` | aGate 中为填充,填充方式不限定(用户确认,2026-10-16);解析时跳过 |
+| 快照通道 | 总数 6,已用 6:`0x10=2049`、`0x11=0`、`0x12=0`、`0x13=4096`、`0x05=1`、`0x04=0` | |
+| 采样通道 | 总数 16,已用 16:`0x10 0x11 0x12 0x13 0x3016 0x3017 0x3013 0x3014 0x3010 0x3011 0x3022 0x3023 0x01 0x03 0x03 0x01` | 最后 4 项与可配采样通道的类型码相同,见下 |
+| 可配快照通道 | 总数 0 | |
+| 可配采样通道 | 总数 4,已配 4:`Uint16@0x00014990`、`float32@0x00001510`、`float32@0x00001512`、`Uint16@0x0001476E` | |
+| 结构结束位置 | 第 308 字节 | 剩余 204 字节全部为 `0xFF` 填充 |
+
+### `waveformdata`(按 FaultFilter 5.2.3 解析)
+
+- 49152 字节 = 16 通道 × 1536 点 × 2 字节,**恰好整除**。通道数 n = `failuredata` 的“采样通道总数”(16),点数 m = 基本信息的“采样点总数”(1536)。
+- “采样点优先”排列得到确认:按此排列,相邻点平均变化约 18;若按通道优先排列,平均变化约 2388(无意义)。
+- 采样通道列表的最后 4 项对应 4 个可配采样通道,即**可配采样通道已包含在 16 个采样通道中**,不另外追加。其“含义”位置填的是类型码(`0x01`/`0x03`),不是 F14 参数代码。
+- 每个通道每点固定 2 字节,包括类型为 float32 的可配通道。第 13、14 通道(float32)的值范围为 -248~249,看起来像 16 位整数,float32 如何压成 16 位待确认。
+- 数值应按**有符号 int16** 解释:第 4~7 通道(`0x3016/0x3017/0x3013/0x3014`)是幅值约 ±2200 的正弦波,按 Uint16 解释会在过零处跳变。文档写的是 Uint16。
+- 采样频率得到验证:第 4、5 通道过零周期约 90 点,在 4500 Hz 下即 **50 Hz**。
+- 故障时刻对齐得到验证:第 0 通道(`0x10`)在**第 1024 点**从 `0x0000` 变为 `0x0801`,正好等于故障前采样点数。快照 `0x10=2049`(`0x801`)等于故障后的值,快照 `0x13=4096` 等于第 3 通道故障前的值 `0x1000`。
+
+| 通道 | 含义代码 | 特征 |
+| --- | --- | --- |
+| 0~3 | `0x10~0x13` | 状态/故障字,只有少数几个离散值 |
+| 4~7 | `0x3016 0x3017 0x3013 0x3014` | 50 Hz 正弦,±2200 左右 |
+| 8~11 | `0x3010 0x3011 0x3022 0x3023` | 小幅值噪声(-36~31) |
+| 12 | 可配 Uint16 `0x14990` | 3 个离散值(`0x3020`、`0x3900`、`0x1900`) |
+| 13~14 | 可配 float32 `0x1510`/`0x1512` | -248~249 |
+| 15 | 可配 Uint16 `0x1476E` | 4 或 5 |
+
+各通道的物理量和换算系数需要 F14《参数代码表》,目前只能展示原始值。
+
+## 2026-10-19 测试服实测:607~610 的 topic
+
+测试服 EamonAux 的 `record_fetch`(05:20 UTC):`mng/{SN}` 上收到 378、379、380,但 5 分钟内没有 607~610,最终靠超时后按规则直接下载完成,`faultRecordId` 为空。用户确认 607~610 走 debug topic:607、610 为 `fwh/s2c/debug/{SN}`,608、609 为 `fwh/c2s/debug/{SN}`。已改为录波获取时同时订阅这两个 topic。
+
+## 待验证事项
+
+1. ~~`cmd=2` 请求 `index=[9]`,随后上传的 `indexInfo` 却是索引 `4`、`5`。~~ 已由 FaultFilter 文档解释:每个记录序号在回复中展开为类型码 `4`(故障数据)和 `5`(波形数据),两者对应同一请求。
+2. ~~`cmd=0` 的 `hexData` 代表什么。~~ 已由 FaultFilter 文档定义,并已用 2026-10-14 样本验证 `code=3` 结构。`code=1/2` 仍无样本。
+3. ~~`cmd=2` 的录波文件从设备传到服务端的通道。~~ 已由 2026-10-14 样本确认为 `607~610` 触发的 HTTP 上传,下载地址规律已确认。下载不需要 token,压缩包结构已确认(见 [2026-10-14 录波文件解析](#2026-10-14-录波文件解析))。仍待确认:平台对本工具直连发送的 `377` 是否同样自动上传。
+7. 录波文件解析中与 FaultFilter 文档不一致或文档未说明的地方:压缩格式实为 gzip;`failuredata` 长度字段含义;采样值有无符号;float32 可配通道如何存成 16 位。(aGate 的 INV/FPGA 版本字段已确认为填充,解析时跳过。)见 [2026-10-14 录波文件解析](#2026-10-14-录波文件解析)。
+4. `379` 上传和 `380` 应答的实际 topic、应答时限、重发机制及消息关联规则。
+5. `cmd=1`/`cmd=3` 的 `379` 实报,以及各命令的失败报文在外层的表现。结构本身已由 FaultFilter 文档定义(`setResultList`/`idInfo`)。
+6. `pe` 设备的实际寻址(网关 SN 与子设备 SN 不同时的报文);批量记录序号时多组 `4/5` 的顺序。
+
+## 下一轮实测需要记录的内容
+
+| 优先级 | 实测场景 | 必须保留的证据 | 要解决的问题 |
+| --- | --- | --- | --- |
+| 1 | `pe` 子设备、单个 `code` 的完整 `cmd=0` 查询 | 操作时间、网关 SN、子设备类型/SN、双向 topic、`377~380` 原始报文和设备界面中的对应记录 | 按 FaultFilter 文档解析 `hexData` 验证;网关与子设备 SN 关系 |
+| 1 | 本工具直连发送 `377(cmd=2)`,测试服和生产服各一次 | 全部双向报文(含 `607~610`)、topic、按规律下载的 `tar.bz2` | 平台是否自动上传;s2c 能否订阅;下载认证与压缩包结构 |
+| 1 | `cmd=3` 读取一项已知功能码(如 `id=5` 采样频率) | 请求/返回报文、功能码和当前值 | `cmd=3` 经外层转发的实际表现 |
+| 2 | `cmd=1` 设置一项参数并用 `cmd=3` 复读确认 | `377~380` 全链路、设置前后值、单项结果 | `378` 与 `379` 各自的成功语义,`setResultList` 实际结构 |
+| 2 | 设备忙、SN 不存在、参数设置失败及超时 | 完整错误报文、重发次数与间隔 | `busy` 等错误字符串在外层的表现、`378` 失败值、超时和重试规则 |
+
+新增样本时,先保留原始报文、topic、时间和触发操作,再将“待验证”条目逐项升级为已确认事实。当前代码入口和实现边界见 [implementation-status.md](implementation-status.md)。

+ 475 - 0
docs/fault-record/protocol-specification.md

@@ -0,0 +1,475 @@
+# 故障录波 MQTT 协议详细描述
+
+> 状态:待补全的协议描述,2026-10-12;2026-10-14 按 [FaultFilter_MQTT协议文档](FaultFilter_MQTT协议文档.md) 更正业务层字段;2026-10-16 补充 `607~610` 文件上传与下载地址(第 8A 节)。本文按现有信息逐字段描述 `377~380`,供补充和确认使用;不是已经完成设备验证的最终规范。
+>
+> 业务层(`dataArea` 内的 `cmd/data`、设备回复的 `result/list`)以 FaultFilter 协议文档为准;外层(`cmdType`、`snno`、`crc`、topic 等)不在该文档范围内,仍按本文记录。原始证据与样本时间线见 [protocol-observations.md](protocol-observations.md)。功能目标见 [requirements.md](requirements.md),架构讨论见 [technical-design.md](technical-design.md)。本文中的字段类型不等于完整的必填性、值域或失败行为约束。
+
+## 1. 信息来源与阅读规则
+
+| 标记             | 来源与适用范围                                                                                                                        |
+| ---------------- | ------------------------------------------------------------------------------------------------------------------------------------- |
+| 样本记录         | 观察文档整理的 CSV 通讯记录,仅覆盖`cmd=0/2`、`aGate` 和成功结果;2026-09-29 附件未入库,2026-10-14 附件为 `CloudLog_2026-10-14_104644.csv`(含 `607~610`) |
+| 已验证代码       | 用户此前确认可信的现有参数设置请求结构;代码入口为`service/repositories/mqtt/fwh_mqtt/fwh_mqtt_fault_record.go`                      |
+| FaultFilter 文档 | [FaultFilter_MQTT协议文档](FaultFilter_MQTT协议文档.md):设备侧故障录波服务的 `{cmd, data}` 业务协议;业务字段以其为准,经 `377~380` 外层转发后的表现仍需实测 |
+| 现有实现         | 仓库当前行为,不能单独证明设备协议要求                                                                                                |
+| 用户确认         | 本轮讨论确认的协议行为或服务端约定                                                                                                    |
+| 旧描述           | 先前协议说明;与 FaultFilter 文档冲突时以后者为准,其余未被样本或用户确认覆盖的部分仍待验证                                         |
+| 待补充           | 缺少资料、语义不清或证据存在冲突                                                                                                      |
+
+本文 JSON 均为**结构示意**,不是完整原始抓包。占位字符串没有协议值域含义,`hexData` 占位内容不能发送给设备;不为示意报文编造 `crc/len`。
+
+**业务层映射**:FaultFilter 文档的请求为 `{cmd, data:{list}}`,与 `377.dataArea` 一致;其回复为 `{cmd, data:{result, list, filename}}`,而样本 `379.dataArea` 中 `result/list/filename` 直接位于 `dataArea` 顶层,没有 `data` 包裹。推断网关转发时展开了 `data`,`379` 承载的即 FaultFilter 回复;该映射在 `cmd=1/3` 和错误场景下尚未实测。FaultFilter 文档中的 topic(`aws/faultrecord` / `faultfilter/faultrecord`)是该服务自身的收发主题,与云端 `fwh/s2c|c2s/mng/{SN}` 的关系待确认,不能互相替代。
+
+## 2. 协议角色、方向与传输
+
+存在两套方向不同的请求应答:
+
+| 请求    | 请求方 | 应答    | 应答方 | 用途                             |
+| ------- | ------ | ------- | ------ | -------------------------------- |
+| `377` | 本工具 | `378` | 设备   | 发起故障录波操作并获得指令应答   |
+| `379` | 设备   | `380` | FWH 平台 | 上传数据或处理结果并获得上传应答;本工具只监听 |
+| `607` | FWH 平台 | `608` | 设备 | 平台通知设备上传录波文件;本工具只监听(见 8A) |
+| `609` | 设备   | `610` | FWH 平台 | 设备报告文件上传结果;本工具只监听 |
+
+`378` 与 `379` 不是同一种响应。现有样本在 `377/378` 后出现 `379/380`,但尚未确认每次请求是否必然产生上传、上传条数以及严格顺序。两套协议分别关联;`379/380` 的关联不能反向证明某次 `379` 属于某次 `377`。FaultFilter 文档规定请求与回复通过 `cmd` 关联、设备同一时刻只处理一条业务命令(见第 4.1 节),可作为 `377→379` 业务关联的依据,但外层是否保证一请求一上传仍待实测。
+
+| 消息    | MQTT topic                  | 来源/状态                                 |
+| ------- | --------------------------- | ------------------------------------------ |
+| `377` | `fwh/s2c/mng/{gatewaySN}` | 现有实现;所有设备和失败场景是否适用待确认 |
+| `378` | `fwh/c2s/mng/{gatewaySN}` | 现有实现的等待 topic;原 CSV 未包含 topic  |
+| `379` | 待补充                      | 不能假定与`378` 相同                     |
+| `380` | 待补充                      | 不能假定与`377` 相同;样本中由 FWH 平台发出 |
+| `607`/`610` | `fwh/s2c/debug/{gatewaySN}` | FWH 平台发给设备,本工具只监听(用户确认,2026-10-19) |
+| `608`/`609` | `fwh/c2s/debug/{gatewaySN}` | 设备发给 FWH 平台(用户确认,2026-10-19) |
+
+测试服与生产服使用不同的 broker、证书和平台域名,同一操作的 MQTT 与 HTTP 下载必须属于同一环境,见 [technical-design.md](technical-design.md#3-测试服与生产服)。
+
+现有通用客户端订阅 QoS 为 `1`,故障录波发布使用 QoS `0`、`retained=false`。这些是实现配置,设备要求的 QoS、会话和离线投递规则待补充。FaultFilter 文档中的 QoS `2` 针对其自身 topic,不直接适用于云端外层。
+
+## 3. 公共报文外层
+
+四种消息的样本记录均包含以下外层字段。`dataArea` 是 JSON 对象,不是 JSON 字符串。
+
+| 字段          | JSON 类型 | 含义/现有行为                                                   | 来源与待补充内容                                              |
+| ------------- | --------- | ---------------------------------------------------------------- | ------------------------------------------------------------- |
+| `cmdType`   | 整数      | 消息类型,分别为`377/378/379/380`                              | 样本记录、已验证代码                                          |
+| `equipNo`   | 字符串    | 现有`377` 填目标网关 SN                                        | 样本中均为同一网关;真正子设备场景及全部应答取值待确认        |
+| `type`      | 整数      | 外层设备类型;现有发送填`0`,代码注释为网关 IBG                | 样本为`0`;其他值待补充。不同于业务层字符串 `list[].type` |
+| `timeStamp` | 整数      | 现有发送使用 Unix 秒`time.Now().Unix()`                        | 接收端单位、生成时点和校时规则待确认                          |
+| `snno`      | 整数      | 消息序号;关联规则见第 4 节                                      | 取值范围、回绕及设备重发是否保留序号待补充                    |
+| `len`       | 整数      | 现有发送为序列化后`dataArea` 的字节长度                        | 接收规则与失败校验行为待确认                                  |
+| `crc`       | 字符串    | 现有发送为`dataArea` 序列化字节的 CRC32 IEEE,8 位大写十六进制 | 接收规则与校验失败行为待确认                                  |
+| `dataArea`  | 对象      | 命令业务内容,详见第 5~8 节                                     | 业务字段见 FaultFilter 文档;外层失败报文的形态待补充         |
+
+现有发送计算方式:先以 Go `json.Marshal(dataArea)` 得到字节 `B`,再取 `len(B)` 和 `CRC32_IEEE(B)`。发送外层中的 `dataArea` 仍为对象。协议尚未明确 JSON 字段顺序、空白、Unicode 转义和数值格式对接收端 CRC 校验的影响,不能直接规定“反序列化后重新序列化”就是正确的校验方式。FaultFilter 文档说明业务 JSON 字段顺序不固定、接收方应按字段名解析。
+
+外层字段完整必填规则、最大报文长度、未知字段处理及空值规则均待补充。
+
+## 4. 序号、关联和当前服务端约定
+
+### 4.1 `377/378`
+
+- **用户确认**:设备端没有严格按请求 `snno` 应答,请求方和应答方各自维护序号,不能用它关联 `377/378`。
+- **用户确认**:服务端发送 `377` 可使用固定 `snno=123` 或随机数;现有代码使用 `123`。
+- **用户确认**:当前采用串行发送并等待对应类型应答的方向;串行范围及释放时点尚待细化。
+- **FaultFilter 文档**:设备同一时刻只处理一条业务命令。处理期间收到非「退出(`cmd=4`)」命令时直接回复错误 `busy`,不中断当前处理;`cmd=4` 用于结束当前处理。判定顺序为先校验 `cmd`、再判忙、再校验 `data`。该 `busy` 回复经外层体现为 `378` 还是 `379` 待实测。
+- **服务端暂定**:响应等待超时为 **15 秒**。在当前讨论中针对 `377/378` 梳理;计时起点尚待确认,不是设备承诺的最长响应时间,也不作为 `379` 的超时。
+- **待设计**:建议核对 topic、`equipNo`、`cmdType=378`、`dataArea.cmd`,而非只匹配 `cmdType`;此过滤方案尚未落地。
+- **待补充**:迟到或重复 `378`、其他发送方请求产生的 `378` 如何处理;相同网关和 `cmd` 的消息没有唯一关联凭据。
+
+### 4.2 `379/380`
+
+- **用户确认**:`380` 由服务端实现,可以使用 `snno` 关联 `379/380`。
+- **待细化**:是否通过 `380.snno=379.snno` 实现关联,还是另有规则;尚未确认,本文不将回显写为既定要求。
+- **证据冲突**:旧样本记录的 `379/380` 序号不同(`14→7536`、`26→7559`)。保留其历史事实,不将它解释为新服务端必须继续采用的行为;新关联方式需补充设备接受条件和验证结果。
+- **待补充**:应答 topic、应答期限、重发规则、应答成功的语义及重复上传处理。
+
+## 5. `377`:服务端操作请求
+
+### 5.1 公共业务结构
+
+| 字段路径(相对`dataArea`) | 类型     | 含义                                                                                                    | 来源/约束                                                         |
+| ---------------------------- | -------- | ------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------ |
+| `cmd`                      | 整数     | `0` 获取实时数据;`1` 设置参数;`2` 获取记录;`3` 查询参数;`4` 退出;`90/99` 连通性测试 | FaultFilter 文档;样本覆盖`0/2`,已验证代码覆盖 `1`;`3/4/90/99` 经外层转发待实测 |
+| `data`                     | 对象     | 请求内容                                                                                                | `cmd=4/90/99` 为空对象`{}`                                       |
+| `data.list`                | 对象数组 | 按设备类型分组                                                                                          | 最小/最大数量待补充                                               |
+| `data.list[].type`         | 字符串   | 目标设备类型:`pe` 或 `aGate`                                                                       | 字典见第 9 节                                                      |
+| `data.list[].info`         | 对象数组 | 该类型下的目标设备和操作项                                                                              | 批量限制待补充                                                     |
+| `data.list[].info[].sn`    | 字符串   | 目标设备 SN;`pe` 必填并用于寻址(最多 15 台),`aGate` 可填但不用于寻址                              | FaultFilter 文档;`pe` 的 SN 无法匹配时整条命令回复 `pe sn not find` |
+
+### 5.2 `cmd=0`:获取实时数据
+
+附加字段 `data.list[].info[].codeList` 为整数数组,每个元素为数据项编号。设备对每个 `code` 串行读取子设备,汇总后在回复的 `hexList` 中返回。
+
+| `code` | 含义                     | `hexData` 结构(FaultFilter 文档)       |
+| ------ | ------------------------ | ---------------------------------------- |
+| `1`  | 故障总体信息             | 5.1.1,变长                              |
+| `2`  | 设备故障录波功能基本信息 | 5.1.1,固定 16 字节                      |
+| `3`  | 故障录波总目录           | 5.1.1,1 字节条数 + N × 14 字节目录项    |
+
+`cmd=0` 只支持 `code=1~3`(用户确认,2026-10-14)。单一故障信息(`4`)和单一故障波形数据(`5`)属于具体记录,必须用 `cmd=2` 按记录序号导出(见 5.4)。样本请求 `[3]` 即查询录波总目录,目录项中的位置索引 `index` 是 `cmd=2` 的请求取值。重复值及空数组的行为未定义。
+
+`dataArea` 结构示意:
+
+```json
+{
+  "cmd": 0,
+  "data": {
+    "list": [
+      { "type": "aGate", "info": [{ "sn": "<目标设备SN>", "codeList": [3] }] }
+    ]
+  }
+}
+```
+
+### 5.3 `cmd=1`:设置参数
+
+| 附加字段路径(相对`dataArea`)         | 类型     | 含义                                                                                     |
+| ---------------------------------------- | -------- | ---------------------------------------------------------------------------------------- |
+| `data.list[].info[].paraList`          | 对象数组 | 待执行的功能项列表                                                                       |
+| `data.list[].info[].paraList[].id`     | 整数     | **命令控制符(功能码)**,不是数据参数编号;取值 `1~7`,见下表                       |
+| `data.list[].info[].paraList[].setVal` | 字符串   | 该功能码的输入参数,十六进制字符串(每 2 字符 1 字节);无输入参数的功能码可为空字符串 |
+
+| `id` | 功能                     | `setVal` 字节结构                                                                                 |
+| ---- | ------------------------ | ------------------------------------------------------------------------------------------------- |
+| `1`  | 清除设备故障波形         | 无                                                                                                |
+| `2`  | 采样通道配置             | 通道数(1B) + n × 通道含义(2B,大端)                                                               |
+| `3`  | 手动触发故障录波         | 无                                                                                                |
+| `4`  | 设备对时                 | 年(2B,大端) 月 日 时 分 秒,共 7 字节                                                            |
+| `5`  | 设置采样频率             | 2 字节,Hz,大端                                                                                  |
+| `6`  | 配置可配采样通道         | 索引号(1B) + 数据类型(1B,高半字节=设置方式,低半字节=地址类型) + 数据(4B)                        |
+| `7`  | 故障录波功能状态控制     | 1 字节:`0`=关闭,`1`=使能                                                                        |
+
+完整定义见 FaultFilter 文档 5.4.1。
+
+`dataArea` 结构示意,值取自现有辅助函数,不代表通用默认配置:
+
+```json
+{
+  "cmd": 1,
+  "data": {
+    "list": [
+      {
+        "type": "pe",
+        "info": [
+          {
+            "sn": "<目标子设备SN>",
+            "paraList": [
+              { "id": 6, "setVal": "010100015984" },
+              { "id": 6, "setVal": "020100015981" }
+            ]
+          }
+        ]
+      }
+    ]
+  }
+}
+```
+
+两项 `id=6` 均为「配置可配采样通道」,由 `setVal` 首字节的通道索引区分:`01 01 00015984` 表示通道 1、默认方式、Uint16、地址 `0x00015984`;`02 01 00015981` 表示通道 2、地址 `0x00015981`。因此 `id` 不能作为条目唯一键;回复 `setResultList` 是否按请求顺序逐项对应待实测。前端目前只校验十六进制字符,未按功能码校验字节长度。HTTP DTO 使用 `val`,MQTT 使用 `setVal`,两者不可混淆。
+
+### 5.4 `cmd=2`:获取记录
+
+附加字段 `data.list[].info[].index` 为整数数组,字段名为单数 `index`,值为数组。每个元素是**记录序号**,即 `cmd=0, code=3` 目录中各记录的位置索引(从 1 开始,不超过 `code=1` 的「最大可存储故障记录总数」)。旧描述的范围 `1~16` 未在 FaultFilter 文档中出现,以目录实际返回为准。样本请求 `[9]`。
+
+设备对每个记录序号分别读取「故障数据」(`code=4`) 与「波形数据」(`code=5`),并保存为设备本地文件(见 7.4)。每次 `cmd=2` 开始前,设备会删除保存根目录下的**全部**历史记录文件。
+
+```json
+{
+  "cmd": 2,
+  "data": {
+    "list": [
+      { "type": "aGate", "info": [{ "sn": "<目标设备SN>", "index": [9] }] }
+    ]
+  }
+}
+```
+
+### 5.5 `cmd=3`:查询参数
+
+FaultFilter 文档定义,无样本,经 `377` 转发待实测。附加字段 `data.list[].info[].idList` 为整数数组,元素为第 5.3 节的功能码,不带输入参数。
+
+```json
+{
+  "cmd": 3,
+  "data": {
+    "list": [
+      { "type": "pe", "info": [{ "sn": "<目标子设备SN>", "idList": [2, 5, 7] }] }
+    ]
+  }
+}
+```
+
+### 5.6 `cmd=4`/`cmd=90`/`cmd=99`
+
+- `cmd=4` 退出:`data` 为 `{}`,结束设备当前处理,不产生业务数据回复。
+- `cmd=90`/`cmd=99` 连通性测试:`data` 为 `{}`,设备分别回复 `result="ok"`/`"test fail"`。
+
+以上均来自 FaultFilter 文档,经外层转发的表现待实测。
+
+## 6. `378`:设备操作应答
+
+| 字段路径(相对`dataArea`) | 类型 | 含义/状态                                                           |
+| ---------------------------- | ---- | -------------------------------------------------------------------- |
+| `cmd`                      | 整数 | 样本为`0/2`;现有代码按 `0/1/2` 定义。是否始终等于请求命令待确认 |
+| `result`                   | 整数 | 样本成功为`0`;旧描述失败为 `-1`;完整错误值域待补充             |
+
+```json
+{ "cmd": 0, "result": 0 }
+```
+
+`378` 属于外层应答,FaultFilter 文档未定义(其回复 `result` 为字符串,对应 `379`)。样本与现有代码没有显示这里包含逐设备、逐参数或文件字节。`result=0` 表示收到、接受、排队还是执行完成,尚未确认;不能仅凭它断言数据已取得、文件可下载或参数已生效。
+
+**待补充的应答行为**:一条请求对应一条还是多条应答;格式错误、设备忙和不支持命令时 `378` 是否仍为 `0`;重复及迟到应答规则;字段缺失规则;收到应答后能否立即发送下一条请求(FaultFilter 层忙时会回复 `busy`)。
+
+## 7. `379`:设备数据/处理结果上传请求
+
+### 7.1 公共业务字段
+
+| 字段路径(相对`dataArea`) | 类型     | 含义/状态                                                                       |
+| ---------------------------- | -------- | -------------------------------------------------------------------------------- |
+| `cmd`                      | 整数     | 回显请求命令码;样本为`0/2`                                                    |
+| `result`                   | 字符串   | 整体结果:`"ok"` 全部子项成功,`"error"` 至少一个子项失败;错误回复时为错误说明 |
+| `list`                     | 对象数组 | 上传设备列表,位于`dataArea.list`,不是 `dataArea.data.list`                 |
+| `list[].type`              | 字符串   | 设备类型:`pe` 或 `aGate`                                                    |
+| `list[].info`              | 对象数组 | 各设备结果                                                                       |
+| `list[].info[].sn`         | 字符串   | 设备 SN;样本类型为字符串,不是数组                                              |
+
+**错误回复**:命令无法进入业务处理时,FaultFilter 回复只含 `result`,没有 `list`:
+
+| `result`          | 原因                                    |
+| ----------------- | --------------------------------------- |
+| `json unpack err` | 消息体不是合法 JSON                     |
+| `cmd unpack err`  | 缺少 `cmd`(此时回复 `cmd` 为 `0`) |
+| `data unpack err` | 缺少 `data`                           |
+| `busy`            | 设备正忙,收到非退出命令                |
+| `pe sn not find`  | `pe` 的 SN 无法匹配                   |
+| `cmd no define`   | 不支持的命令码                          |
+
+**子项结果 `resultOne`**(所有 `hexList[]`/`indexInfo[]`/`idInfo[]`/`setResultList[]` 子项通用):
+
+| 取值                          | 含义                                                              |
+| ----------------------------- | ----------------------------------------------------------------- |
+| `ok`                          | 成功,对应数据有效                                                |
+| `cancomerr:suberrcode=<hex>`  | 子设备负响应错误码(NRC),见 FaultFilter 文档 10.6               |
+| `cancomerr:time out`          | 与子设备通信超时                                                  |
+| `cancomerr:index=<hex>`       | 设备内部通信错误判定码,见 FaultFilter 文档 10.5                  |
+| `emserr:can p null`           | 未取到目标子设备                                                  |
+| `emserr:can data null`        | 子设备返回数据为空                                                |
+| `emserr:set cmd err`          | 下发命令失败                                                      |
+| `emserr:other`                | 其他错误                                                          |
+
+`resultOne` 不为 `ok` 时,对应 `hexData`/`idData` 可能为空或无效。错误回复经外层后是否仍以 `379` 上传待实测。
+
+### 7.2 `cmd=0`:实时数据上传
+
+| 附加字段路径                          | 类型     | 含义                                                                  |
+| ------------------------------------- | -------- | --------------------------------------------------------------------- |
+| `list[].info[].hexList`             | 对象数组 | 各数据项结果                                                          |
+| `list[].info[].hexList[].code`      | 整数     | 回显请求的`code`;样本`3` 为故障录波总目录                          |
+| `list[].info[].hexList[].resultOne` | 字符串   | 子项结果,见 7.1                                                      |
+| `list[].info[].hexList[].hexData`   | 字符串   | 子设备原始字节的十六进制字符串;结构按 `code` 见第 5.2 节             |
+
+```json
+{
+  "cmd": 0,
+  "result": "ok",
+  "list": [
+    {
+      "type": "aGate",
+      "info": [{ "sn": "<目标设备SN>", "hexList": [{ "code": 3, "resultOne": "ok", "hexData": "<原始十六进制数据>" }] }]
+    }
+  ]
+}
+```
+
+`code=3` 目录项固定 14 字节:位置索引(1) + 发生时间(7,年份大端) + 参数列表版本(4) + 事件触发源(2)。2026-10-14 样本的 `hexData` 共 211 字节 = 1 + 15 × 14,15 条记录均按此结构解析出合理的时间与触发源(`0x1001`/`0x1002`),结构已验证;解析结果见 [protocol-observations.md](protocol-observations.md#2026-10-14-样本)。
+
+### 7.3 `cmd=1`:参数设置结果上传
+
+无样本,结构来自 FaultFilter 文档。旧描述的 `resultList[{id, result}]` 有误,正确字段为 `setResultList`:
+
+| 附加字段路径                                | 类型     | 含义                                  |
+| ------------------------------------------- | -------- | ------------------------------------- |
+| `list[].info[].setResultList`             | 对象数组 | 各功能项执行结果                      |
+| `list[].info[].setResultList[].id`        | 整数     | 回显请求 `paraList[].id`(功能码)  |
+| `list[].info[].setResultList[].resultOne` | 字符串   | 子项结果,见 7.1;为字符串,不是数组  |
+
+```json
+{
+  "cmd": 1,
+  "result": "ok",
+  "list": [
+    {
+      "type": "pe",
+      "info": [{ "sn": "<目标子设备SN>", "setResultList": [{ "id": 6, "resultOne": "ok" }, { "id": 6, "resultOne": "ok" }] }]
+    }
+  ]
+}
+```
+
+重复功能码(如两项 `id=6`)的结果是否严格按请求顺序返回,待实测。
+
+### 7.4 `cmd=2`:记录处理结果上传
+
+| 附加字段路径                            | 类型     | 含义                                                                                     |
+| --------------------------------------- | -------- | ---------------------------------------------------------------------------------------- |
+| `filename`                            | 字符串   | 本次导出批次的时间戳目录名,格式`YYYY-MM-DD_HH_MM_SS`;样本为 `2026-09-29_14_18_29`  |
+| `list[].info[].indexInfo`             | 对象数组 | 各记录项结果                                                                             |
+| `list[].info[].indexInfo[].index`     | 整数     | **记录数据类型码**:`4`=故障数据,`5`=波形数据;**不是**请求的记录序号            |
+| `list[].info[].indexInfo[].resultOne` | 字符串   | 子项结果,见 7.1;不是数组                                                               |
+
+```json
+{
+  "cmd": 2,
+  "result": "ok",
+  "filename": "2026-09-29_14_18_29",
+  "list": [
+    {
+      "type": "aGate",
+      "info": [{ "sn": "<目标设备SN>", "indexInfo": [{ "index": 4, "resultOne": "ok" }, { "index": 5, "resultOne": "ok" }] }]
+    }
+  ]
+}
+```
+
+请求的每个记录序号在回复中展开为 `4`、`5` 两项,回复不回传记录序号本身。因此样本请求 `[9]` 后上传 `4/5` 是正常对应,并非索引转换。请求多个记录序号时,回复中会出现多组 `4/5`,无法仅凭回复区分属于哪个记录序号,只能依赖请求顺序(待实测)。
+
+上传不含文件字节或下载地址。文件保存在设备本地:
+
+```
+/home/root/ExportFile/faultFilterRecode/<filename>/<sn>/index_<记录序号>/failuredata
+/home/root/ExportFile/faultFilterRecode/<filename>/<sn>/index_<记录序号>/waveformdata
+```
+
+文件内容为原始字节的大写十六进制 ASCII 文本,无文件头;仅成功项落盘。内部结构见 FaultFilter 文档 5.2.2/5.2.3。文件由 FWH 平台通过 `607~610` 触发上传到 hes-upload,见第 8A 节。
+
+### 7.5 `cmd=3`:参数查询结果上传
+
+无样本,结构来自 FaultFilter 文档。
+
+| 附加字段路径                         | 类型     | 含义                                                         |
+| ------------------------------------ | -------- | ------------------------------------------------------------ |
+| `list[].info[].idInfo`             | 对象数组 | 各功能项查询结果                                             |
+| `list[].info[].idInfo[].id`        | 整数     | 回显请求的功能码                                             |
+| `list[].info[].idInfo[].idData`    | 字符串   | 当前配置/状态的十六进制字符串,结构参照第 5.3 节对应功能码 |
+| `list[].info[].idInfo[].resultOne` | 字符串   | 子项结果,见 7.1                                             |
+
+## 8. `380`:服务端上传应答
+
+| 字段路径(相对`dataArea`) | 类型 | 含义/状态                                       |
+| ---------------------------- | ---- | ------------------------------------------------ |
+| `cmd`                      | 整数 | 样本为`0/2`;是否必须与 `379.cmd` 一致待明确 |
+| `result`                   | 整数 | 样本为`0`;旧描述失败为 `-1`                 |
+
+```json
+{ "cmd": 0, "result": 0 }
+```
+
+`380` 属于外层,FaultFilter 文档未定义。关联方向已由用户确认可使用 `snno`,具体赋值规则见第 4.2 节。`result=0` 是接收成功、校验通过还是保存成功尚待确认。业务项失败时能否仍回复上传接收成功,以及失败应答是否触发设备重发,也待补充。
+
+**设计决策(2026-10-16)**:样本中的 `380` 由 FWH 平台回复,本工具不回复 `380`,只监听,避免设备收到重复应答。`cmd=0/1/3` 的 `380` 是否同样由平台回复待实测。
+
+## 8A. `607~610`:录波文件上传
+
+来源:2026-10-14 样本记录(两轮 `cmd=2`,结构一致)。这一组消息由 FWH 平台在收到 `379(cmd=2)` 后自动发起,本工具只监听(用户确认,2026-10-16)。
+
+| `cmdType` | 日志名称 | 方向 | `dataArea` 样本 |
+| --------- | -------- | ---- | --------------- |
+| `607` | 故障录波召唤下行数据 | 平台 → 设备 | `{"faultRecordID":5226,"fileName":"2026-10-14_05_46_20","url":"http://test.franklinwh.com/hes-upload/v1/file/upload","logTime":"2026-10-14"}` |
+| `608` | 故障录波召唤应答上行数据 | 设备 → 平台 | `{"result":0,"filename":"2026-10-14_05_46_20","result":5226}` |
+| `609` | 故障录波文件上传结果 | 设备 → 平台 | `{"result":0,"fileName":"2026-10-14_05_46_20.tar.bz2"}` |
+| `610` | 故障录波文件上传结果应答 | 平台 → 设备 | `{"result":1}` |
+
+字段说明:
+
+| 字段 | 消息 | 类型 | 含义/状态 |
+| ---- | ---- | ---- | ---------- |
+| `faultRecordID` | `607` | 整数 | FWH 平台的召唤记录 ID;可与 DataDownload 使用的平台记录互查 |
+| `fileName` | `607` | 字符串 | 等于 `379.filename`,无扩展名 |
+| `url` | `607` | 字符串 | 设备 HTTP 上传地址;样本为测试服 `http://` 地址,不用于推导下载地址 |
+| `logTime` | `607` | 字符串 | 日期 `YYYY-MM-DD`,用途待确认 |
+| `filename` | `608` | 字符串 | 回显 `607.fileName`;注意键名小写 |
+| `result` | `608` | 整数 | 应答结果,样本为 `0` |
+| `id` | `608` | 整数 | 平台召唤记录 ID,等于 `607.faultRecordID`。**旧版本设备把字段名误写为 `result`**,同一对象出现两个 `result`,第二个即 `id`(用户确认,2026-10-16);后续版本修复,旧版本仍需兼容:解析前将第二个 `result` 键改名为 `id`,新旧格式按同一结构解析。10-19 测试服的 608 为 `{"result":0,"filename":…}`,既无重复 `result` 也无 `id`,此时平台记录 ID 只取自 607;608 与 607 到达顺序不固定 |
+| `result` | `609` | 整数 | 样本 `0`,推测为上传成功;失败值待补充 |
+| `fileName` | `609` | 字符串 | 上传文件名。**不同固件不一致**:10-14 样本为 `<批次>.tar.bz2`,10-19 测试服 EamonAux 为 `<批次>`(不带后缀)。实际下载文件都带 `.tar.bz2`,下载前统一补齐后缀 |
+| `result` | `610` | 整数 | 样本 `1`;与 `378/380` 的 `0=成功` 约定不同,不能统一按 `0` 判定成功 |
+
+时序(样本):`379` 与 `607/608` 在同一秒内,`380` 晚约 1 秒,`609/610` 在 `607` 后约 11~12 秒。
+
+**下载地址**(用户确认,2026-10-16):
+
+```text
+https://<环境域名>/hes-upload/v1/file/download/logger/<equipNo>/<609.fileName>
+```
+
+测试服 `<环境域名>` 为 `test.franklinwh.com`,生产服为 `FWH_ENERGY` 配置的域名。下载**不需要 token**(用户确认,2026-10-16)。文件保留期限待确认。
+
+**压缩包**(2026-10-14 样本验证):扩展名为 `.tar.bz2`,实际是 **gzip** 压缩的 tar,解压须按文件头(`1f 8b` 为 gzip,`42 5a 68` 为 bzip2)判断。内部结构为:
+
+```text
+<filename>/<sn>/index_<记录序号>/failuredata
+<filename>/<sn>/index_<记录序号>/waveformdata
+```
+
+**文件解析规则**(以 FaultFilter 文档 5.2.2/5.2.3 为基础,按样本补充,详见 [protocol-observations.md](protocol-observations.md#2026-10-14-录波文件解析)):
+
+| 项目 | 规则 | 依据 |
+| --- | --- | --- |
+| `failuredata` 长度字段 | 等于整个文件字节数(含填充),不能当有效数据长度用;按结构逐项解析 | 样本 512 字节,结构在第 308 字节结束 |
+| `failuredata` 末尾 | `0xFF` 填充,解析到可配采样通道组结束即停止 | 样本 |
+| 版本字符串 | 取第一个 NUL 之前的内容。aGate 的 INV/FPGA 两段为填充,填充方式不限定,**不解析**,只跳过 64 字节 | 用户确认(2026-10-16);样本填充为 `01 02 … 40` |
+| 波形通道数 n | = `failuredata` 的“采样通道总数”,已包含可配采样通道 | 样本 16 通道,文件长度恰好整除 |
+| 波形点数 m | = 基本信息“采样点总数”;故障时刻在第“故障前采样点数”点 | 样本 1536 点,第 1024 点故障字跳变 |
+| 波形排列 | 采样点优先(同一点的 n 个通道连续) | 样本验证 |
+| 采样值 | 每点每通道 2 字节、大端;按 **int16** 解释(文档写 Uint16) | 样本正弦通道按有符号解释才连续 |
+| float32 可配通道 | 在波形中同样只占 2 字节,换算方式待确认 | 样本 |
+
+## 9. 设备类型与数据字典
+
+| 位置/来源                       | 已出现的值                             | 说明                                                                       |
+| -------------------------------- | -------------------------------------- | -------------------------------------------------------------------------- |
+| FaultFilter 文档                 | `pe`、`aGate`                      | 业务层仅定义这两种;`pe` 按 SN 寻址(最多 15 台),`aGate` 系统内唯一 |
+| `377/379.list[].type` 样本     | `aGate`                              | `cmd=0/2`                                                                |
+| `377.list[].type` 已验证代码   | `pe`、`aGate`                      | 参数设置                                                                   |
+| `377.list[].type` 旧描述       | `pe`、`agate`、`mppt`、`apbox` | FaultFilter 文档未定义`agate`(小写)、`mppt`、`apbox`,不应使用  |
+| `379.list[].type` 旧描述       | `aGate`、`aPower`                  | FaultFilter 文档未定义`aPower`,回复类型与请求一致为 `pe`/`aGate`  |
+| HTTP/页面类型                   | `pe`、`agate`                      | 现有设置代码将 HTTP`agate` 转成 MQTT `aGate`;MQTT 层必须使用 `aGate` |
+
+已由 FaultFilter 文档补齐的字典:`code` 含义及 `hexData` 结构(5.2)、功能码及 `setVal` 结构(5.3)、`indexInfo[].index` 类型码(7.4)、`resultOne` 及错误字符串(7.1)、事件触发源与子设备错误码(见该文档第 10 节)。通道含义参数代码(F14)见产品级《参数代码表》,未随该文档提供。
+
+仍缺失:外层 `378/380.result` 值域、支持的设备型号与固件版本。
+
+## 10. 待补充清单
+
+以下编号用于逐项讨论;“待补充”不是已经确定的限制。优先补齐 `A` 类,再继续分析上传协议。
+
+| 编号 | 待补充信息                 | 当前已知/缺口                                                                     | 可补充的材料                             |
+| ---- | -------------------------- | ---------------------------------------------------------------------------------- | ---------------------------------------- |
+| A01  | `378.result` 的精确定义  | 只知道样本为`0`,成功阶段未知                                                    | 协议说明、设备处理流程或对照实测         |
+| A02  | `378` 应答条数和批量语义 | 未知是否一请求一应答;批量子项结果已知在`379` 中按 `resultOne` 表达            | 多子设备/多参数请求及完整应答           |
+| A03  | `378` 字段与错误值域     | 业务层错误字符串已知(7.1),其在外层的体现方式未知                              | 非法输入、忙、不支持等场景的完整报文     |
+| A04  | 连续请求的设备行为         | FaultFilter 层串行处理、忙时回复`busy`;`busy` 经外层如何体现未知              | 处理中发送下一条请求的结果               |
+| A05  | 迟到、重复和重发应答       | 无唯一关联标识,15 秒不保证设备停止回复                                            | 延迟/断线场景的完整时间线和最大时限说明 |
+| A06  | topic 与发送方隔离         | 当前实现有`mng/{SN}`;FaultFilter 自身 topic 与云端 topic 的关系未知             | 双向 topic、其他平台发送时的消息可见性   |
+| A07  | 外层字段和 CRC 规则        | 发送代码有算法,接收及错误规则未知                                                 | 原始 JSON 字节、校验规范、失败样本       |
+| A08  | `377` 输入约束           | `pe` 最多 15 台;其余批量上限未知                                                | 数量限制和输入校验说明                   |
+| A09  | 15 秒超时的计时口径        | 服务端暂定值,起点与超时后策略未确定                                               | 服务端决策;设备延迟证据供参考           |
+| B01  | `cmd=3`/`cmd=4` 外层支持  | FaultFilter 文档定义了 `cmd=3`(查询参数)和 `cmd=4`(退出),旧描述、现有代码和实报只有 `0/1/2`。用户判断可能支持(2026-10-16),待实测。不支持时参数读取、复读和设备侧取消不可用 | `cmd=3`、`cmd=4` 实测报文               |
+| B02  | ~~`code/hexData` 解释~~ | 已明确(5.2);`code=3` 已用 2026-10-14 样本验证(7.2),`code=1/2` 待样本       | `code=1/2` 原始十六进制样本              |
+| B03  | ~~参数 ID/值/重复 ID~~  | `id` 为功能码,`setVal` 结构及重复 `id=6` 的区分方式已明确(5.3)            | 重复功能码结果顺序的实测                 |
+| B04  | ~~文件传输链路~~           | 已明确:平台经 `607~610` 触发设备 HTTP 上传 `tar.bz2`,按规律下载(8A);平台是否对本工具直连发出的 `377` 同样自动上传待实测 | 本工具直连 `cmd=2` 的完整时间线         |
+| B05  | `379` 产生条件和批次     | 是否依赖成功`378`、是否主动/拆分上传未知                                        | 操作到全部上传的时间线                   |
+| B06  | ~~`379(cmd=1)` 结构~~    | 已明确为`setResultList[{id, resultOne}]`(7.3)                                  | 成功、部分失败实测报文                   |
+| B07  | `379/380` topic 与关联   | 服务端可用`snno`,具体规则和旧样本不一致                                         | 明确赋值规则及设备接受新应答的实测       |
+| B08  | 上传应答语义和可靠性       | 成功判据、应答时限、重发和断线恢复未知;本工具不回复 `380`,此项影响降低           | 设备上传处理规范及异常时间线             |
+| B09  | ~~`607~610` topic 与订阅权限~~ | 已明确:`607/610` 在 `fwh/s2c/debug/{SN}`,`608/609` 在 `fwh/c2s/debug/{SN}`;测试服实测 s2c 方向可订阅(收到 380) | 生产服订阅实测 |
+| B10  | `609/610` 值域           | `608` 第二个 `result` 已确认为误写的 `id`,已兼容(8A)。仍待确认:`609` 失败值;`610.result=1` 含义。**待用户统一找人确认** | 设备方或平台方说明、上传失败样本 |
+| B11  | ~~下载认证与压缩包结构~~   | 已明确:不需要 token;实为 gzip tar;结构见 8A。保留期限和生产服是否一致待确认    | 生产服下载一个真实文件                   |
+| B12  | float32 可配通道的 16 位表示 | 样本第 13、14 通道类型为 float32,波形中每点仅 2 字节,值在 -248~249;换算方式未知。**待用户统一找设备方确认** | 设备方说明                               |
+| B13  | ~~`failuredata` 中 aGate 的 INV/FPGA 版本字段~~ | 已明确(用户确认,2026-10-16):aGate 中这两段是填充,填充方式不限定,解析时跳过 | —                                        |
+| B14  | 采样值有无符号             | 文档写 Uint16,样本正弦通道按 int16 解释才连续;暂按 int16 处理               | 设备方确认                               |
+| B15  | F14 物理量与换算系数       | **决策(2026-10-16)**:解析器先只显示原始值和通道含义代码,是否按《参数代码表》换算后续再定 | 《参数代码表》                           |
+
+补充时建议记录:编号、确认结论、适用设备/固件、证据来源、原始请求/响应和 topic。先保留历史样本,再更新字段或规则,避免将实现选择写成设备协议事实。

+ 103 - 0
docs/fault-record/requirements.md

@@ -0,0 +1,103 @@
+# 故障录波功能需求
+
+> 状态:需求梳理,2026-09-30;2026-10-16 补充双环境需求、文件获取方式和 FR-06 操作方式。本文记录目标能力与待确认的产品口径,不将协议描述中的未验证字段写为已确认需求。协议证据见 [protocol-observations.md](protocol-observations.md),当前实现见 [implementation-status.md](implementation-status.md)。
+
+## 1. 目标与范围
+
+目标是在 `repositories/mqtt` 对接设备故障录波协议,使以下操作可以直接通过 MQTT 完成,减少对 FWH API 的依赖:
+
+1. 查询子设备故障录波实时数据;
+2. 查询和导出子设备故障录波记录;
+3. 查询子设备故障录波参数;
+4. 设置子设备故障录波参数。
+
+本工具负责向设备发送请求并监听设备上传;对 `379` 的应答和录波文件上传由 FWH 平台自动完成,本工具不重复应答。录波文件的触发和状态跟踪走 MQTT,文件本身仍从 FWH 平台的 hes-upload 下载。
+
+以上能力需同时支持**测试服**和**生产服**(见 FR-05)。这里的“子设备”至少需要明确所在网关、设备类型和设备 SN。现有样本只有 aGate,且网关 SN 与 `info.sn` 相同;真正子设备的寻址方式待实测。
+
+本需求描述独立的 MQTT 能力。现有 [DataDownload](../data-download/requirements.md) 的故障录波任务、日期固化和文件归档规则继续由其文档定义;是否接入新 MQTT 能力需单独设计。
+
+## 2. 功能需求
+
+### FR-01 查询实时数据
+
+- 用户能指定目标网关和子设备,发起故障录波实时数据查询。
+- 系统应呈现设备实际返回的数据、对应设备和单项结果;请求被应答与数据已返回应是可区分的状态。
+- 若设备没有返回目标数据、返回失败或超时,系统应给出可定位的结果,不把空数据解释为查询成功。
+
+**已确定**:开放 `code=1~3`,按字段显示原始值,可展开原始 `hexData`;页面见 [ui-design.md](ui-design.md#4-设备信息)。`code` 集合(`1~3`)及含义已由 [FaultFilter_MQTT协议文档](FaultFilter_MQTT协议文档.md) 定义;单条记录的故障数据和波形数据不属于实时数据查询,归入 FR-02。
+
+### FR-02 查询与导出录波记录
+
+- 用户能查询目标子设备可获取的录波记录,并选择记录发起获取。
+- 系统应区分“请求已被设备应答”“设备报告文件处理结果”和“原始文件已可读取”三个阶段。
+- 导出应交付实际录波文件;仅有文件名或逐索引成功标志不满足导出需求。
+- 同一次请求包含多个记录时,应能看到每个记录的结果,避免把部分成功显示为全部成功。
+
+- 状态应覆盖“设备已应答”“记录已就绪(`379`)”“平台开始上传(`607`)”“上传完成(`609`)”“已下载”,以及每个阶段的失败或超时。
+- 获取到的压缩包应解析出故障数据和波形数据供查看:基本信息、快照通道、多通道波形(原始值),见 [ui-design.md](ui-design.md#31-录波详情抽屉或独立标签页)。
+- 导出:用户可任选原始压缩包或 CSV。
+
+**已确定**:可一次获取多条记录,设备打成一个包上传(2026-10-19 用户确认,最多 32 条);导出原始压缩包或 CSV;导出不设权限;暂不清理。按 FaultFilter 文档,记录列表来自 `cmd=0, code=3` 目录,记录序号即目录项索引;每条记录生成 `failuredata` 与 `waveformdata` 两个文件,设备每次获取记录前会清空历史文件。文件由平台上传后,按 `https://<环境域名>/hes-upload/v1/file/download/logger/<equipNo>/<文件名>.tar.bz2` 下载。功能统一称为“故障录波”。
+
+### FR-03 查询录波参数
+
+- 用户能读取目标子设备的当前故障录波参数,并看到参数 ID、当前值和读取结果。
+- 读取结果应来自设备本次返回的数据;本地上次填写的设置值不能冒充设备当前值。
+
+**已确定**:开放功能码 2、4、5、6、7(1、3 为动作,无可读状态);显示原始十六进制,协议已定义结构的同时显示解读。按 FaultFilter 文档,查询指令为 `cmd=3`,`idList` 可一次填多个功能码(`1~7`),返回 `idInfo[].idData` 十六进制,结构参照对应功能码的设置参数;经外层转发的实际报文待实测。
+
+### FR-04 设置录波参数
+
+- 用户能为指定子设备提交一个或多个参数 ID 与设置值。
+- 系统应分别呈现指令应答与逐参数执行结果;仅收到接收应答时,不应声称每项参数已经生效。
+- 失败或未确认的参数应能被识别,避免整批请求被误报为全部成功。
+
+**已确定**:提交前弹窗确认,危险操作醒目标注;以 `379` 逐项 `resultOne` 判定结果,可选设置后自动复读。**仍待实测**:`378` 与 `379(cmd=1)` 的实际报文。按 FaultFilter 文档,参数 ID 实为功能码,各功能码的 `setVal` 字节结构已定义;逐项结果为 `setResultList[].resultOne`,整体 `result` 为 `ok`/`error`。
+
+### FR-05 支持测试服和生产服
+
+- 用户发起任一操作时能选择测试服或生产服。
+- 同一操作的 MQTT 收发、文件下载和登录凭据都使用所选环境,结果中标明环境。
+- 一个环境不可用(未配置、连接失败)时,不影响另一个环境的操作。
+
+### FR-06 操作方式与记录(用户确认,2026-10-16)
+
+- **页面**:主菜单“故障录波”,子菜单分生产服和测试服,再按功能分页面(录波记录、设备信息、功能操作、参数设置)。页面设计见 [ui-design.md](ui-design.md),用户根据实现效果再提意见。
+- **异步**:所有操作异步执行,前端查询进度;服务重启后能自动恢复未完成的操作。
+- **多条获取**:一次可获取多条录波记录,设备打成一个包(2026-10-19 用户确认,替换原“每次 1 条”)。
+- **设备忙**:本工具内对同一网关串行;设备因其他来源(如 FWH 平台网页)回复 `busy` 时,明确提示用户,不自动重试。
+- **取消**:进行中的操作可取消,必要时向设备发送 `cmd=4`(退出)结束当前处理。
+- **设备列表**:pe 子设备 SN 即 aPower SN,从站点信息中获取;生产服和测试服调用各自的接口。
+- **时间**:目录和录波中的时间暂按设备时间原样显示,测试有问题再改。
+- **记录**:操作过程记录在数据库中,原始文件存 MinIO;操作历史所有人共享,并记录操作人。暂不实现数据清理。
+- **权限**:功能全部开放;参数设置提交前必须弹窗确认,危险操作醒目标注。
+- **导出**:用户可任选导出原始压缩包或 CSV。
+- **与 DataDownload**:本功能完整实现并测试通过后,再考虑开放给 DataDownload;DataDownload 暂时继续使用原 FWH API 方案。
+- **数据展示**:录波数据只显示原始值和通道含义代码,不做物理量换算。
+
+### FR-07 功能码划分(用户确认,2026-10-16)
+
+- **功能操作**页:1 清除波形、3 手动触发、4 设备对时、7 录波开关。1、3 只执行;4、7 用 `cmd=3` 读取、`cmd=1` 设置。
+- **参数设置**页:5 采样频率、6 可配采样通道,读取和复读改用 `cmd=0`(code=2/code=1)。
+- **功能码 2**(采样通道配置)设备端暂未实现,暂不开放。
+- 原“参数读取”页取消,由上述两页各自附带读取。FR-03 的读取需求按此落实。
+
+## 3. 共同需求
+
+- 每次操作应保留目标网关、子设备类型/SN、所请求的 `code`/`index`/参数 ID,以及设备返回的原始结果,便于排查请求与上传不一致的问题。
+- 请求超时、设备拒绝、上传失败、单项失败、文件缺失和报文无法关联应分别呈现,不合并成笼统的“成功/失败”。
+- 设备类型的可选值及大小写应以真实协议验证;当前样本不能证明所有类型都能使用这四项能力。
+- 并发操作不能互相误认上传结果:本工具内同一网关串行,关联规则见 [technical-design.md](technical-design.md#44-关联规则)。
+
+## 4. 验收前需确定的事项
+
+| 功能 | 必须取得的证据或决策 |
+| --- | --- |
+| 实时数据 | `code=3` 已验证;`code=1/2` 实报;可见输出形式 |
+| 记录查询 | 查询条件(目录来源与 `index` 映射已明确) |
+| 文件导出 | 平台对本工具直连发出的 `cmd=2` 同样自动上传(下载认证、压缩包结构已确认) |
+| 双环境 | 测试服与生产服各完成一次完整的 `cmd=2` 获取与下载 |
+| 功能操作读取 | 4、7 的 `cmd=3` 实报样本 |
+| 参数设置 | `cmd=1` 完整成功及失败样本、最终成功条件 |
+| 通用收发 | `379/380` topic、消息关联、超时与重发行为 |

+ 238 - 0
docs/fault-record/technical-design.md

@@ -0,0 +1,238 @@
+# 故障录波 MQTT 整体设计
+
+> 状态:设计草案,2026-09-30;2026-10-16 首版已按本文实现,实现与代码位置见 [implementation-status.md](implementation-status.md);2026-10-14 按 [FaultFilter_MQTT协议文档](FaultFilter_MQTT协议文档.md) 更新业务协议;2026-10-16 补充文件上传链路、双环境、分层架构、异步任务、存储和取消等用户已确认的决策。功能目标见 [requirements.md](requirements.md),页面设计见 [ui-design.md](ui-design.md),报文依据见 [protocol-observations.md](protocol-observations.md) 和 [protocol-specification.md](protocol-specification.md)。
+
+## 1. 设计目标与原则
+
+建立直接经 MQTT 与设备交互的故障录波能力,覆盖设备信息(实时数据)、录波记录获取、参数读取和参数设置,同时支持**生产服**和**测试服**。
+
+- **只发起、不应答**:本工具只发送 `377`(以及取消用的 `cmd=4`),其余消息全部监听。`380`、`607`、`610` 由 FWH 平台发出,平台在收到 `379(cmd=2)` 后自动发起文件上传。
+- **分层清晰**:每层只负责自己的事,只依赖下层。MQTT 客户端只提供底层收发,业务判断全部在上层。
+- **复用现有模块**:asynq 任务系统、MongoDB、MinIO、Redis、`fwh_pro`/`fwh_test` 站点信息接口、uPlot。
+- **只显示原始值**:录波数据不做物理量换算(B15)。
+- **DataDownload 暂不接入**:本功能完整实现并测试通过后,再考虑开放给 DataDownload;DataDownload 继续使用原 FWH API 方案。
+
+## 2. 分层架构
+
+```text
+L5 Route / Controller        HTTP 入参校验、出参转换
+L4 Service                   对外用例:创建操作、查询状态与结果、取消、导出、查询设备列表
+L3 Task Executor (asynq)     操作执行器:按状态机推进、持久化每一步、重启恢复
+L2 Protocol / Parser         纯函数,不访问网络、数据库和文件系统
+     ├─ protocol:构造 377 dataArea,解析 378/379/607~610(兼容旧版本 608 把 `id` 误写为第二个 `result`)
+     └─ parser:解压、hexData(code=1/2/3)、failuredata、waveformdata、CSV 生成
+L1 FWH MQTT Client           按环境实例化;FWH 报文外层(len/crc/信封)+ Request/Publish/Subscribe
+L0 utils/mqttClient          paho 封装
+
+存储:MongoDB(操作记录) · MinIO(原始文件) · Redis(网关锁、asynq)
+外部:FWH 平台 HTTP(站点信息、hes-upload 下载)
+```
+
+| 层 | 建议位置 | 只负责 | 不负责 |
+| --- | --- | --- | --- |
+| L0 | `utils/mqttClient` | 连接、订阅、发布、重连 | FWH 报文格式 |
+| L1 | `repositories/mqtt/fwh_mqtt` | 按环境的连接实例;FWH 信封编解码;三种收发方法;订阅期间保持连接 | 任何 cmdType/cmd 的业务含义 |
+| L2 | `function/FaultRecordFile`(解析器)、`function/FaultRecordProtocol`(协议编解码) | 字节和 JSON 的结构化转换 | IO |
+| L3 | `task/queue/faultRecordQueue` | 一次操作的完整执行和恢复 | HTTP 入参、页面格式 |
+| L4 | `services/faultRecordServices.go` | 用例编排,调用 Repository 和入队 | 协议细节 |
+| L5 | `controllers/faultRecordControllers.go`、`routes/faultRecordRoutes.go` | HTTP | 业务 |
+| 存储 | `repositories/mongo/faultRecordRepo.go`、`repositories/minio/faultRecordRepo.go` | 数据读写 | 业务 |
+| 下载 | `fwh_pro`/`fwh_test` 中各一个无 token 下载函数 | 按环境域名下载 | 解压、解析 |
+
+L2 是纯函数,用仓库中的样本文件([CloudLog_2026-10-14_104644.csv](CloudLog_2026-10-14_104644.csv)、[2026-10-14_05_46_20.tar.bz2](2026-10-14_05_46_20.tar.bz2))做单元测试。以后 DataDownload 接入时,复用 L1 和 L2。
+
+## 3. L1 FWH MQTT 客户端
+
+### 3.1 对外方法
+
+| 方法 | 用途 | 行为 |
+| --- | --- | --- |
+| `Request(ctx, req) (*Msg, error)` | 一问一答(保留原方式) | 订阅应答 topic → 发送 → 等到第一条满足 `Match` 的消息或超时 → 取消订阅 |
+| `Publish(ctx, topic, msg) error` | 只请求 | 发送后立即返回 |
+| `Subscribe(topic, handler) (cancel func(), error)` | 持续监听 | 同一 topic 可有多个订阅者,各自收到每条消息;`cancel` 只移除自己 |
+
+- `handler` 收到 `{Topic, Envelope, Raw []byte, ReceivedAt}`。`Envelope` 为解析后的外层,`Raw` 为原始字节,供上层按需重新解析(如旧版本 608 的重复 `result` 键)。
+- `Request` 的匹配条件由调用方传入(如 `cmdType=378 && dataArea.cmd=1`),客户端不内置任何业务过滤。
+- 信封构造(`len`、`crc`、`timeStamp`、`snno`)属于 L1 的通用能力,以 `NewEnvelope(cmdType, equipNo, dataArea)` 提供。
+- 现有 `EnableXlog`、`FaultRecordParamSetting`、`ChargeChannel` 中的业务内容移到上层,L1 只保留通用部分。
+
+### 3.2 连接与订阅管理
+
+- 内部维护 `topic → 订阅者集合`。第一个订阅者加入时向 broker 订阅;最后一个退出时向 broker 取消订阅。
+- **有活动订阅时不做空闲断开**;没有订阅且空闲 5 分钟后才断开(保持现有省连接的行为)。
+- 断线自动重连后恢复全部 broker 订阅(L0 已有 `ResumeSubscribe`)。L1 需要记录断线区间,供上层判断“这段时间可能漏了消息”。
+- 发布仍经过一个 worker 串行发送;订阅回调不阻塞 paho 线程,按订阅者各自的缓冲 channel 分发,满了就丢弃并记日志。
+
+## 4. 操作模型
+
+### 4.1 操作类型
+
+| 操作 | `cmd` | 输入 | 结果 |
+| --- | --- | --- | --- |
+| `device_info` | 0 | `codeList ⊆ {1,2,3}` | `hexList` 原文 + 解析结果 |
+| `record_fetch` | 2 | **一个**设备、一条或多条记录序号(最多 32) | 原始文件(MinIO)+ 解析结果 |
+| `param_read` | 3 | `idList`(功能操作页只用于 4、7) | `idInfo` 原文 + 解析结果 |
+| `param_write` | 1 | `paraList` | `setResultList` |
+
+每次操作只针对一个网关下的一个设备(`aGate` 或某个 `pe`)。
+
+`cmd=3`(查询参数)和 `cmd=4`(退出)只在 FaultFilter 文档中定义,旧描述、现有代码和实报只有 `0/1/2`。用户判断外层可能支持,先按支持实现,实测后确认(B01)。如不支持:功能操作页的 4、7 只能设置不能读取,取消只停止本工具的等待。参数设置页(5、6)读取和复读走 `cmd=0`,不受影响。`record_fetch` 可一次获取多条记录(2026-10-19 用户确认设备支持):文件按压缩包中的 `index_<n>` 目录区分,不会混淆;379 中的多组 `4/5` 结果按“每条记录一组、顺序与请求一致”对应(待实测)。解析后核对压缩包是否包含全部请求的序号,缺少时在结果中提示。等待时间按记录数延长:379 每多一条加 1 分钟,609 每多一条加 30 秒。
+
+下载时机:收到 609 成功后推进到 `uploaded`,收到 610 后 15 秒开始下载,失败后每 15 秒重试,一直到总超时(从 379 起算 5 分钟,每多一条记录加 30 秒);仍失败记为“下载失败”并注明尝试次数(用户要求,2026-10-19)。连续相同的失败只保留最后一条下载记录,并记录累计失败次数和首次失败时间。610 没有文件名,按“收到 609 之后的下一条 610”对应,其 `result` 只记录不判断。未收到 609 或 610 时只在超时时按规则直接下载一次;服务重启恢复时先尝试下载一次。不做收到 379 后定期试探下载的兜底(用户决定,2026-10-19)。
+
+**手动重试下载**:上传已完成(收到 609)但下载失败或超时的操作,页面提供“重试下载”按钮:操作重新置为“上传完成”,立即下载,失败后每 15 秒重试,**最多尝试 3 次**(用户要求,2026-10-19);每次触发都在原始报文中追加一条 `action` 记录(操作人、第几次重试、重试前状态与错误);每次重试使用新的任务 ID,服务重启后按当前重试序号恢复。
+
+**文件名**:不同固件的 609.fileName 有的带 `.tar.bz2`、有的不带。**每次下载(含自动和手动重试)都由批次名重新拼接 `<批次>.tar.bz2` 生成下载链接**,不依赖已保存的文件名(10-19 测试服实测缺后缀导致下载为空);MinIO 对象键和原始包导出文件名同样使用补齐后缀的名称。
+
+**前端轮询**:开始后 10 秒内每 2 秒查询一次,之后每 5 秒一次;同一操作只保留一条轮询。
+
+文件下载是 HTTP 请求,不是 MQTT 报文;每次下载作为一条 `http` 记录写入操作的原始报文(路径、结果、大小或错误、耗时)。操作结束后再记录约 2 秒内到达的报文(如 609 之后的 610)。
+
+### 4.2 异步执行
+
+```text
+前端 ──POST 创建操作──▶ Service ──写 Mongo(pending) + 入队 asynq──▶ 返回 operationId
+前端 ──GET 轮询 operationId──▶ Service ──读 Mongo──▶ 状态、阶段时间、结果
+asynq worker ──▶ Task Executor ──▶ 执行并逐步写回 Mongo
+```
+
+- 队列:新建 `faultRecordQueue`,在 `task/queue/taskQueue.go` 注册,沿用 `dataDownloadQueue` 的写法(`asynq.TaskID` 保证幂等,`Retention` 保留记录)。
+- **不依赖 asynq 重试做业务重试**:重复执行由执行器根据 Mongo 状态自行判断,避免重复发送 `377(cmd=2)` 清空设备文件。
+- 任务超时 15 分钟;各阶段等待:378 为 30 秒,379 为 2 分钟(`record_fetch` 4 分钟),379 到 609 为 5 分钟(暂定,实测后调整,见 `faultRecordQueue/deps.go`)。
+- `MaxRetry` 设为 3 只用于进程崩溃后重新投递;业务失败由执行器写入状态并正常返回,不触发 asynq 重试。(asynq 对崩溃任务按重试计数处理,`MaxRetry(0)` 会直接归档、无法恢复。)
+- 收到 609 并下载完成后操作即结束,之后到达的 610 不再记录。
+
+### 4.3 执行器流程(以 `record_fetch` 为例)
+
+1. 取 Redis 网关锁 `fault-record:lock:<env>:<gatewaySN>`(带 TTL 并续租)。取不到就结束操作,状态为 `gateway_locked`(本工具内已有操作在进行)。
+2. 订阅 `fwh/c2s/mng/<sn>` 和 `fwh/s2c/mng/<sn>`(378/379/380);`record_fetch` 另订阅 `fwh/s2c/debug/<sn>` 和 `fwh/c2s/debug/<sn>`(607/610 与 608/609,用户确认,2026-10-19)。debug topic 与 Xlog 共用,客户端支持同一 topic 多个订阅者,互不影响。
+3. 用 `Publish` 发 `377`,写 `sent`。
+4. 收到报文就交给 L2 解析,推进状态,并把原始报文追加到记录:
+
+```text
+pending → sent → accepted(378) → ready(379) → uploading(607) → uploaded(609) → downloaded → done
+```
+
+5. 收到 `609.result=0` 后推进到 `uploaded`;收到 610 后 15 秒开始下载(失败每 15 秒重试至总超时),存入 MinIO,解析,写 `done`。
+6. 释放锁,取消订阅。
+
+终止状态分开记录:`busy`(设备回复 busy,明确提示,不重试)、`rejected`(378 失败)、`item_failed`(379 中 `resultOne` 不为 ok)、`timeout_<阶段>`、`upload_failed`(609 失败)、`download_failed`、`parse_failed`、`cancelled`、`interrupted`。
+
+其他操作按同一框架执行,只到 `ready(379)` 为止,再解析结果写 `done`。
+
+### 4.4 关联规则
+
+- 377 → 378/379:环境 + `equipNo` + `cmdType` + `dataArea.cmd`。网关锁保证本工具内同一网关同一时间只有一个操作。
+- 379 → 607/608/609:`equipNo` + 文件名(`609.fileName` = `379.filename + ".tar.bz2"`)。
+- 608 在设备上行方向:订阅不到 607 时,收到文件名匹配的 608 也推进到“平台上传中”,并从 `608.id` 取得平台记录 ID。
+- 发送的 `377` 也会出现在 s2c topic 上,按 `cmdType` 过滤掉。
+
+### 4.5 取消(`cmd=4`)
+
+`cmd=4` 是 FaultFilter 协议的“退出”命令:通知设备结束当前处理,`data` 为 `{}`,没有业务回复。它是设备忙时**唯一**会被接受的命令。
+
+| 取消时所处阶段 | 处理 |
+| --- | --- |
+| `pending`(还没发 377) | 直接取消,不发任何消息 |
+| `sent`/`accepted`(设备正在处理我们的命令) | 发 `377(cmd=4)`,标记 `cancelled`,释放锁 |
+| `ready` 及之后(设备已处理完,平台在上传) | 不发 `cmd=4`(设备已无处理中的命令,且无法终止平台上传),只停止等待,标记 `cancelled` |
+| 状态为 `busy` 等终止态 | 不可取消 |
+
+限制:
+
+- `cmd=4` 作用于设备,不区分发起方。只有在本操作的命令确实被设备接受(没有收到 `busy`)时才发送,避免中断别人发起的处理。
+- 设备是否真的停止、外层是否有回复,待实测;页面显示“已发送取消”,不承诺设备一定停止。
+
+### 4.6 重启恢复
+
+asynq 会把服务重启时未完成的任务重新投递。执行器先读 Mongo 状态,从断点继续:
+
+| 重启时状态 | 恢复方式 |
+| --- | --- |
+| `pending` | 正常执行 |
+| `sent`/`accepted` | 不能确定设备是否已回复,且重发会清空设备文件,标记 `interrupted`,由用户手动重试 |
+| `ready`/`uploading`(已知文件名) | 直接按下载地址尝试下载;成功则继续解析,失败则重新订阅等待 `609`,直到超时 |
+| `uploaded`/`downloaded` | 继续下载/解析 |
+
+服务启动时扫描 Mongo 中未终止、但 asynq 中已不存在的操作(例如 Redis 数据丢失),按上表处理。
+
+## 5. 设备与站点信息
+
+- 用户输入网关 SN,Service 按环境调用 `fwh_pro.QuerySiteInfoList` 或 `fwh_test.QuerySiteInfoList` 获取站点信息。
+- 可选设备为:`aGate`(网关 SN)+ `fhpSn` 中逗号分隔的每个 aPower SN。**aPower SN 就是 `pe` SN**(用户确认,2026-10-16)。
+- 生产服和测试服是两个不同的接口(`fwh_pro` 与 `fwh_test`),必须按操作所属环境调用,不能用生产服站点信息去操作测试服设备,反之亦然。
+- 只调用 Repository 层接口,不复用 `services.GetSiteInfoData`:后者会把结果写入站点信息表,测试服数据不能写入。
+
+## 6. 测试服与生产服
+
+每次操作绑定一个环境,MQTT、站点信息和文件下载都取自同一环境,不能混用。
+
+| 项目 | 生产服 | 测试服 |
+| --- | --- | --- |
+| MQTT broker/证书 | `FWH_MQTT_BROKER`、`FWH_MQTT_CA/CERT/KEY` | `FWH_MQTT_TEST_BROKER`、`FWH_MQTT_TEST_CA/CERT/KEY` |
+| FWH 平台域名 | `FWH_ENERGY`(默认 `energy.franklinwh.com`) | `FWH_TEST_ENERGY`(如 `test.franklinwh.com`) |
+| 站点信息 | `fwh_pro` | `fwh_test` |
+| 下载凭据 | 不需要 | 不需要 |
+
+文件下载地址(用户确认,2026-10-16):
+
+```text
+https://<环境域名>/hes-upload/v1/file/download/logger/<equipNo>/<609.fileName>
+```
+
+- `<equipNo>` 取 `609` 外层的 `equipNo`;`pe` 场景下是否仍为网关 SN 待实测。
+- 域名取自环境配置,不从 `607.url` 推导(`607.url` 是上传地址,样本为 `http://`)。
+- 文件扩展名是 `.tar.bz2`,实际是 gzip tar;解压按文件头判断,同时兼容 gzip 和 bzip2。
+
+## 7. 存储
+
+### 7.1 MongoDB:操作记录
+
+选择 MongoDB:每次操作的报文条数和结果结构不固定,适合文档存储;与 DataDownload 的任务记录方式一致,已有连接与 Repository 写法。不使用 PostgreSQL:波形约 49KB,查看和导出时现场解析即可,无需结构化建表。
+
+集合 `fault_record_operations`(字段草案):
+
+| 字段 | 说明 |
+| --- | --- |
+| `_id` | operationId |
+| `env` | `prod`/`test` |
+| `gatewaySn`、`deviceType`、`deviceSn` | 目标 |
+| `kind`、`cmd`、`request` | 操作类型与请求参数 |
+| `status`、`stage`、`stageTimes` | 当前状态和各阶段时间 |
+| `operator` | 操作人(取自登录用户),历史共享可见 |
+| `messages[]` | 原始报文:方向、topic、cmdType、接收时间、原文 |
+| `result` | 解析后的结果(目录、设备信息、参数等) |
+| `fileName`、`faultRecordId`、`minioObject` | `record_fetch` 专用 |
+| `error` | 失败原因 |
+| `createdAt`、`updatedAt` | |
+
+索引:`env + gatewaySn + createdAt`、`status`、`createdAt`。暂不实现数据清理。
+
+### 7.2 MinIO:原始文件
+
+- 对象键:`fault-record/<env>/<gatewaySn>/<fileName>`,保留下载到的原始字节和原始文件名。
+- 对象键不经 HTTP 暴露,由 Service 按 operationId 读取。
+
+### 7.3 导出
+
+| 格式 | 内容 |
+| --- | --- |
+| 原始压缩包 | 直接从 MinIO 读出返回 |
+| CSV(zip) | `waveform.csv`:每行一个采样点,列为点序号、相对故障时刻毫秒、各通道原始值,表头为通道序号和含义代码;`summary.csv`:基本信息、版本、触发源、快照通道、采样通道、可配通道 |
+
+CSV 现场生成,不缓存。
+
+## 8. 时间口径
+
+目录、`failuredata`、对时功能中的时间均按**设备时间**原样显示,不做时区换算(暂定,实测有问题再改)。操作记录中的时间(创建、阶段时间)用服务器时间存储,前端按本地时间显示。
+
+## 9. 待实测前提
+
+1. 本工具直接发出 `377(cmd=2)` 后,平台是否同样自动发 `607` 并上传。这是“只监听”方案成立的前提。
+2. ~~订阅 s2c 方向~~:测试服已实测可订阅(收到 380);607~610 的 topic 已确认(debug),待实测确认能收到。
+3. `cmd=0/1/3` 的 `380` 是否也由平台回复。
+4. `pe` 场景:外层 `equipNo` 与 `info[].sn` 的关系、下载路径中的 `<equipNo>`。
+5. `cmd=1/3` 经外层的实际报文和失败样本;`busy` 经外层如何体现。
+6. `cmd=4` 的外层表现和实际效果。
+
+协议遗留问题见 [protocol-specification.md](protocol-specification.md#10-待补充清单) B12、B14。

+ 168 - 0
docs/fault-record/ui-design.md

@@ -0,0 +1,168 @@
+# 故障录波前端页面设计
+
+> 状态:初稿,2026-10-16;同日按用户意见调整:“参数读取”页改为“功能操作”,参数设置只保留 5、6 并改用 cmd=0 读取。用户将根据实现效果再提意见。功能目标见 [requirements.md](requirements.md),后端设计见 [technical-design.md](technical-design.md)。组件库沿用 naive-ui,波形图沿用 uPlot。
+
+## 1. 菜单结构
+
+```text
+故障录波                     (替换现有“故障录波配置”)
+├── 生产服
+│   ├── 录波记录
+│   ├── 设备信息
+│   ├── 功能操作(功能码 1、3、4、7)
+│   └── 参数设置(功能码 5、6)
+└── 测试服
+    └── (同上)
+```
+
+- 路由形如 `fault-record/prod/record`、`fault-record/prod/function`、`fault-record/test/param-write`。两套环境使用同一组页面组件,环境从路由参数获取。
+- 现有 `fault-record/read`、`fault-record/write` 页面下线,参数设置按第 6 节重新设计。
+- 功能码按页面划分:
+
+| 功能码 | 名称 | 页面 | 读取 | 设置/执行 |
+| --- | --- | --- | --- | --- |
+| 1 | 清除设备故障波形 | 功能操作 | 无(动作) | cmd=1 |
+| 2 | 采样通道配置 | **暂不开放** | — | 设备端暂未实现 |
+| 3 | 手动触发故障录波 | 功能操作 | 无(动作) | cmd=1 |
+| 4 | 设备对时 | 功能操作 | cmd=3 | cmd=1 |
+| 5 | 采样频率 | 参数设置 | cmd=0 code=2 | cmd=1 |
+| 6 | 可配采样通道 | 参数设置 | cmd=0 code=1 | cmd=1 |
+| 7 | 录波功能开关 | 功能操作 | cmd=3 | cmd=1 |
+- 每个页面顶部固定显示环境标签:**生产服**用醒目的警示色,测试服用中性色,避免在错误的环境操作。
+
+## 2. 公共组件
+
+### 2.1 设备选择条
+
+```text
+┌──────────────────────────────────────────────────────────────────┐
+│ [生产服]  网关 SN [________________] [查询]   设备 [aGate ▼]        │
+│           站点:xxx  在线  aPower ×3                               │
+└──────────────────────────────────────────────────────────────────┘
+```
+
+- 输入网关 SN 后查询站点信息,设备下拉为 `aGate` + 各 aPower SN(`pe`)。
+- 显示站点在线状态,离线时给出提示但不禁止操作。
+- 最近使用的网关 SN 存在本地,方便再次选择。
+
+### 2.2 操作进度
+
+```text
+已发送 ─── 设备已应答 ─── 数据就绪 ─── 平台上传中 ─── 上传完成 ─── 已下载 ─── 完成
+  ✓            ✓            ●
+                                                         [取消]
+```
+
+- 每个阶段显示时间;失败时在对应阶段显示原因(设备忙、超时、单项失败等)。
+- “设备忙”单独提示:“设备正在处理其他命令(可能来自 FWH 平台),请稍后再试”。
+- 可取消的阶段显示“取消”按钮,规则见 [technical-design.md](technical-design.md#45-取消cmd4)。
+- 可展开“原始报文”,按时间列出收发的全部报文 JSON,便于排查。
+
+### 2.3 操作历史
+
+各功能页底部都有历史表,按当前环境和网关筛选,所有人共享:
+
+| 时间 | 操作人 | 网关 | 设备 | 操作 | 参数摘要 | 状态 | 操作 |
+| --- | --- | --- | --- | --- | --- | --- | --- |
+
+点击一行可回看当时的进度、原始报文和结果。
+
+## 3. 录波记录
+
+```text
+┌ 设备选择条 ───────────────────────────────────────────────────────┐
+├ 故障录波总目录                                   [刷新目录] ────────┤
+│ 序号 │ 发生时间(设备时间)│ 触发源                       │ 参数版本 │ 操作      │
+│  7   │ 2026-08-18 03:03:08 │ 0x1001 并网发生状态切换故障 │ 00000001 │ [获取]    │
+│  …                                                                 │
+├ 当前操作进度(见 2.2) ─────────────────────────────────────────────┤
+├ 操作历史(见 2.3),已完成的行有 [查看] [导出 ▼] ──────────────────────┤
+└──────────────────────────────────────────────────────────────────┘
+```
+
+- 目录按设备返回的顺序显示,不按序号推断时间先后;允许按时间列排序。
+- 触发源显示代码和名称(名称来自协议文档 F15 表,表外代码只显示代码)。
+- 可勾选多条记录后点“获取所选”,设备把所选记录打成一个包上传(最多 32 条,用户确认设备支持,2026-10-19);每行的“获取”按钮仍可单条获取。获取进行中时,其他获取按钮禁用。
+- 操作进度的“原始报文与下载记录”中,除 MQTT 报文外,还记录每次 HTTP 文件下载(路径、结果、大小或错误、耗时),并记录 609 之后短时间内到达的报文(如 610)。
+- 导出下拉:原始压缩包、CSV。
+
+### 3.1 录波详情(抽屉或独立标签页)
+
+```text
+┌ 基本信息 ───────────────────────────────────────────────────────┐
+│ 记录序号 7  发生时间 2026-08-18 03:03:08  触发源 0x1001           │
+│ 采样频率 4500 Hz  采样点 1536(故障前 1024 / 故障后 512)          │
+│ 存储格式 v1  CAN 协议 v1  参数版本 00000001  DCDC EMS_SL:V10R80B21D00 │
+├ 波形 ────────────────────────────────────────────────────────────┤
+│ 通道:☑0 0x0010 ☑1 0x0011 … ☑12 可配 Uint16@0x14990 …   [全选][反选] │
+│ ┌──────────────────────────────────────────────────────────────┐ │
+│ │  uPlot 多通道折线,竖线标出故障时刻(第 1024 点 / 0 ms)        │ │
+│ └──────────────────────────────────────────────────────────────┘ │
+│ X 轴:[采样点 | 相对故障时刻 ms]   Y 轴:原始值(int16)             │
+├ 快照通道 ──────────────────────────────────────────────────────────┤
+│ 序号 │ 含义代码 │ 原始值 │                                          │
+├ 可配通道 ──────────────────────────────────────────────────────────┤
+│ 序号 │ 类型 │ 地址 │                                                │
+└──────────────────────────────────────────────────────────────────┘
+```
+
+- 通道数值量级差异大,提供“分组显示”:每个勾选通道一个小图,共享 X 轴和游标,避免小信号被大信号压平。
+- 游标悬停显示所有勾选通道在该点的原始值。
+
+## 4. 设备信息
+
+- 勾选 `code`:故障总体信息(1)、录波功能基本信息(2)、录波总目录(3),点击“读取”。
+- 每个 code 一张卡片,按协议字段列出原始值;可展开原始 `hexData`。
+
+## 5. 功能操作
+
+```text
+┌ 设备选择条 ───────────────────────────────────────────────────────┐
+├ 设备对时 (4) ──────────────────┬ 录波功能开关 (7) ────────────────┤
+│ 设备当前时间:2026-10-16 12:00:00│ 当前状态:使能                    │
+│ [日期时间] [取当前时间] [对时]   │ [使能录波] [关闭录波]             │
+├ 动作 ───────────────────────────┼ 读取当前值(cmd=3)────────────────┤
+│ [手动触发故障录波 (3)]          │ ☑4 设备时间 ☑7 录波开关  [读取]    │
+│ [清除设备故障波形 (1)]          │                                   │
+├ 结果(进度 + 逐项结果)────────────────────────────────────────────┤
+├ 操作历史(读取与执行合并显示)──────────────────────────────────────┤
+└──────────────────────────────────────────────────────────────────┘
+```
+
+- 每个按钮单独执行一个功能码,执行前弹窗确认;1、3、4 以及关闭录波(7=00)标红,需勾选“我已确认”。
+- 4、7 执行成功后自动复读该项。
+- 4、7 的读取依赖 `cmd=3`(待实测,B01);不支持时只能执行,不能读取。
+
+## 6. 参数设置
+
+只包含功能码 5(采样频率)和 6(可配采样通道);功能码 2 设备端暂未实现,暂不开放。
+
+```text
+┌ 设备选择条 ───────────────────────────────────────────────────────┐
+│ 读取当前值(cmd=0):☑5 采样频率 ☑6 可配采样通道  [读取当前值]       │
+├ 参数 ────────────────────────────────────┬ 待提交 ─────────────────┤
+│ 采样频率 (5)   [ 4500 ] Hz   设备当前值 4500 Hz │ 5  采样频率   1194  [×] │
+│ 可配采样通道 (6)                            │ 6  可配采样通道 01…  [×] │
+│   索引 │ 方式 │ 地址类型 │ 地址/代码          │ ☑ 设置成功后自动复读     │
+│   设备当前:索引1 Uint16@0x14990 … (点击带入)│        [提交设置]        │
+│ 高级:原始模式(仅 5、6)                     │                          │
+├ 设置结果 + 复读核对 ─────────────────────────────────────────────────┤
+├ 操作历史 ────────────────────────────────────────────────────────────┤
+└──────────────────────────────────────────────────────────────────┘
+```
+
+- **读取当前值**改用 `cmd=0`:5 取 code=2 的采样频率,6 取 code=1 的可配采样通道;可勾选读取哪些项,默认全选;切换设备时清空。
+- **可配采样通道**:读取后设备有几个通道就并列显示几行编辑框(每行:索引、方式、地址类型、地址/代码、setVal 预览、设备当前值),修改过的行高亮并标“已修改”,索引重复的行标红;可逐行“加入”,也可“全部加入待提交”或“加入已修改”一键导入多行,无效行跳过并提示;可“新增一行”或删除行。索引 1 对应 code=1 可配采样通道列表的第 1 项。默认方式和代码方式按同一格式解析(类型低字节的高 4 位为设置方式、低 4 位为地址类型,数据 4 字节;代码方式取前 2 字节为代码),后续出问题再改。
+- **数据类型选项**统一首字母大写:Empty、Uint16、Int16、Float32、Uint32_HWord、Uint32_LWord、Uint32。
+- **提交**前弹窗确认;生产服的确认按钮用警示色。
+- **复读核对**同样用 `cmd=0`:5 比对采样频率;6 按索引取列表对应项,拼成 setVal 后比对。
+
+相对旧页面的改进:
+
+| 旧页面 | 新设计 |
+| --- | --- |
+| 手填功能码数字和十六进制值 | 结构化表单,自动生成 `setVal` 并实时预览;保留原始模式 |
+| 不知道设备当前值 | 可读取当前值预填表单,点击设备当前通道带入 |
+| 无提交前确认 | 统一确认弹窗 |
+| 只显示成功/失败 | 显示进度、逐项 `resultOne` 和复读核对 |
+| 一次可填多个子设备 | 一次操作只针对一个设备 |

+ 4 - 4
service/.env

@@ -33,10 +33,10 @@ FWH_MQTT_CA="./cert/test/AmazonRootCA1.pem"
 FWH_MQTT_CERT="./cert/test/01.certificate.pem.crt"
 FWH_MQTT_KEY="./cert/test/01.private.pem.key"
 # TEST server
-FWH_TEST_MQTT_BROKER="tls://a35sger20yyec4-ats.iot.us-west-2.amazonaws.com:8883"
-FWH_TEST_MQTT_CA="./cert/test/AmazonRootCA1.pem"
-FWH_TEST_MQTT_CERT="./cert/test/01.certificate.pem.crt"
-FWH_TEST_MQTT_KEY="./cert/test/01.private.pem.key"
+FWH_MQTT_TEST_BROKER="tls://a35sger20yyec4-ats.iot.us-west-2.amazonaws.com:8883"
+FWH_MQTT_TEST_CA="./cert/test/AmazonRootCA1.pem"
+FWH_MQTT_TEST_CERT="./cert/test/01.certificate.pem.crt"
+FWH_MQTT_TEST_KEY="./cert/test/01.private.pem.key"
 
 # FWH ES (iotcore-stat Elasticsearch)
 FWH_ES_HOST="alb-prod.franklinwh.click:5603"

+ 23 - 0
service/cache/redisx/lockx.go

@@ -53,6 +53,29 @@ func TryLock(ctx context.Context, key string, ttl time.Duration) (*RedisLock, bo
 	return &RedisLock{key: key, token: token, ttl: ttl, acquired: time.Now(), label: lockLabel(key)}, true, nil
 }
 
+// TryLockWithToken 使用调用方指定的 token 非阻塞加锁。
+// 若锁已被同一 token 持有(例如进程重启后同一任务接管自己留下的锁),视为加锁成功并续期。
+func TryLockWithToken(ctx context.Context, key, token string, ttl time.Duration) (*RedisLock, bool, error) {
+	const luaScript = `
+if redis.call("set", KEYS[1], ARGV[1], "NX", "PX", ARGV[2]) then
+	return 1
+end
+if redis.call("get", KEYS[1]) == ARGV[1] then
+	redis.call("pexpire", KEYS[1], ARGV[2])
+	return 1
+end
+return 0
+`
+	res, err := redis.NewScript(luaScript).Run(ctx, getClient(), []string{key}, token, ttl.Milliseconds()).Int()
+	if err != nil {
+		return nil, false, err
+	}
+	if res == 0 {
+		return nil, false, nil
+	}
+	return &RedisLock{key: key, token: token, ttl: ttl, acquired: time.Now(), label: lockLabel(key)}, true, nil
+}
+
 // Lock 阻塞加锁,可设置重试间隔和超时
 func Lock(ctx context.Context, key string, ttl, retryInterval, timeout time.Duration) (*RedisLock, error) {
 	start := time.Now()

+ 4 - 4
service/config/config.go

@@ -220,10 +220,10 @@ func LoadConfig(configPaths ...string) (*Config, error) {
 			FwhMqttKeyUrl:  v.GetString("FWH_MQTT_KEY"),
 		},
 		FwhTestMqtt: FwhMqttConfig{
-			FwhMqttBroker:  v.GetString("FWH_TEST_MQTT_BROKER"),
-			FwhMqttCaUrl:   v.GetString("FWH_TEST_MQTT_CA"),
-			FwhMqttCertUrl: v.GetString("FWH_TEST_MQTT_CERT"),
-			FwhMqttKeyUrl:  v.GetString("FWH_TEST_MQTT_KEY"),
+			FwhMqttBroker:  v.GetString("FWH_MQTT_TEST_BROKER"),
+			FwhMqttCaUrl:   v.GetString("FWH_MQTT_TEST_CA"),
+			FwhMqttCertUrl: v.GetString("FWH_MQTT_TEST_CERT"),
+			FwhMqttKeyUrl:  v.GetString("FWH_MQTT_TEST_KEY"),
 		},
 		FwhEs: FwhEsConfig{
 			EsHost:               v.GetString("FWH_ES_HOST"),

+ 106 - 8
service/controllers/faultRecordControllers.go

@@ -1,26 +1,124 @@
 package controllers
 
 import (
-	"context"
+	"errors"
+	"net/url"
+	"strconv"
+
 	"fwh-toolbox-server/dto"
+	"fwh-toolbox-server/middleware"
+	mongoRepo "fwh-toolbox-server/repositories/mongo"
 	"fwh-toolbox-server/services"
 
 	"github.com/gofiber/fiber/v2"
 	"go.uber.org/zap"
 )
 
-// 设置故障录波参数
-// /faultRecord/paramSetting
-func SetFaultRecordParam(c *fiber.Ctx) error {
-	var body dto.FaultRecordParamInfo
+// faultRecordOperator 当前操作人:优先显示名,没有时用登录名
+func faultRecordOperator(c *fiber.Ctx) string {
+	if name := middleware.AuthGetDisplayName(c); name != "" {
+		return name
+	}
+	return middleware.AuthGetUserName(c)
+}
+
+func faultRecordErr(c *fiber.Ctx, err error) error {
+	if errors.Is(err, services.ErrFaultRecordInvalid) || errors.Is(err, mongoRepo.ErrFaultRecordOpNotFound) {
+		return Fail(c, err.Error())
+	}
+	return Error(c, err.Error())
+}
+
+// 查询网关下可操作的设备
+// GET /faultRecord/devices?env=&gatewaySn=
+func GetFaultRecordDevices(c *fiber.Ctx) error {
+	data, err := services.GetFaultRecordSiteDevices(c.Query("env"), c.Query("gatewaySn"))
+	if err != nil {
+		return faultRecordErr(c, err)
+	}
+	return Success(c, data)
+}
+
+// 创建操作
+// POST /faultRecord/operations
+func CreateFaultRecordOperation(c *fiber.Ctx) error {
+	var body dto.FaultRecordCreateOpReq
 	if err := c.BodyParser(&body); err != nil {
 		zap.L().Warn("Invalid request body", zap.String("err", err.Error()))
 		return Fail(c, "Invalid request body")
 	}
+	id, err := services.CreateFaultRecordOperation(c.UserContext(), body, faultRecordOperator(c))
+	if err != nil {
+		return faultRecordErr(c, err)
+	}
+	return Success(c, fiber.Map{"id": id})
+}
+
+// 操作历史(所有人共享)
+// GET /faultRecord/operations?env=&gatewaySn=&kind=&pageNum=&pageSize=
+func ListFaultRecordOperations(c *fiber.Ctx) error {
+	page, _ := strconv.ParseInt(c.Query("pageNum", "1"), 10, 64)
+	size, _ := strconv.ParseInt(c.Query("pageSize", "20"), 10, 64)
+	if page < 1 || size < 1 || size > 200 {
+		return Fail(c, "invalid pagination")
+	}
+	data, err := services.ListFaultRecordOperations(c.UserContext(), mongoRepo.FaultRecordOpFilter{
+		Env:       c.Query("env"),
+		GatewaySn: c.Query("gatewaySn"),
+		Kind:      c.Query("kind"),
+	}, page, size)
+	if err != nil {
+		return faultRecordErr(c, err)
+	}
+	return Success(c, data)
+}
+
+// 操作详情(含原始报文和逐项结果)
+// GET /faultRecord/operations/:id
+func GetFaultRecordOperation(c *fiber.Ctx) error {
+	data, err := services.GetFaultRecordOperation(c.UserContext(), c.Params("id"))
+	if err != nil {
+		return faultRecordErr(c, err)
+	}
+	return Success(c, data)
+}
+
+// 取消操作
+// POST /faultRecord/operations/:id/cancel
+func CancelFaultRecordOperation(c *fiber.Ctx) error {
+	if err := services.CancelFaultRecordOperation(c.UserContext(), c.Params("id")); err != nil {
+		return faultRecordErr(c, err)
+	}
+	return Success[any](c, nil)
+}
+
+// 手动重试下载(上传已完成但下载失败时)
+// POST /faultRecord/operations/:id/retry-download
+func RetryFaultRecordDownload(c *fiber.Ctx) error {
+	if err := services.RetryFaultRecordDownload(c.UserContext(), c.Params("id"), faultRecordOperator(c)); err != nil {
+		return faultRecordErr(c, err)
+	}
+	return Success[any](c, nil)
+}
+
+// 录波详情(解析后的基本信息、通道和波形)
+// GET /faultRecord/operations/:id/record
+func GetFaultRecordRecord(c *fiber.Ctx) error {
+	data, err := services.GetFaultRecordRecord(c.UserContext(), c.Params("id"))
+	if err != nil {
+		return faultRecordErr(c, err)
+	}
+	return Success(c, data)
+}
 
-	err := services.SetFaultRecordParam(context.Background(), body)
+// 导出录波文件
+// GET /faultRecord/operations/:id/export?format=raw|csv
+func ExportFaultRecordFile(c *fiber.Ctx) error {
+	name, data, err := services.ExportFaultRecordFile(c.UserContext(), c.Params("id"), c.Query("format"))
 	if err != nil {
-		return Error(c, err.Error())
+		return faultRecordErr(c, err)
 	}
-	return Success[any](c, nil, "Success")
+	c.Set("Content-Disposition", "attachment; filename=\""+name+"\"; filename*=UTF-8''"+url.PathEscape(name))
+	c.Type("application/octet-stream")
+	return c.Send(data)
 }

+ 1 - 1
service/domain/esMqttDomain.go

@@ -93,7 +93,7 @@ type SiteTotalPage struct {
 
 // DailyTotal 某天的报文总量
 type DailyTotal struct {
-	Date  string `json:"date"`  // "2026-09-08"
+	Date  string `json:"date"` // "2026-09-08"
 	Total int64  `json:"total"`
 }
 

+ 157 - 0
service/domain/faultRecordMqttDomain.go

@@ -0,0 +1,157 @@
+package domain
+
+import (
+	"time"
+
+	proto "fwh-toolbox-server/function/FaultRecordProtocol"
+
+	"go.mongodb.org/mongo-driver/bson/primitive"
+)
+
+// FaultRecordOpKind 故障录波 MQTT 操作类型
+type FaultRecordOpKind string
+
+const (
+	FaultRecordOpDeviceInfo  FaultRecordOpKind = "device_info"  // cmd=0
+	FaultRecordOpRecordFetch FaultRecordOpKind = "record_fetch" // cmd=2
+	FaultRecordOpParamRead   FaultRecordOpKind = "param_read"   // cmd=3
+	FaultRecordOpParamWrite  FaultRecordOpKind = "param_write"  // cmd=1
+)
+
+// Cmd 操作对应的业务层 cmd
+func (k FaultRecordOpKind) Cmd() int {
+	switch k {
+	case FaultRecordOpDeviceInfo:
+		return proto.CmdRealtime
+	case FaultRecordOpRecordFetch:
+		return proto.CmdGetRecord
+	case FaultRecordOpParamRead:
+		return proto.CmdQueryParam
+	case FaultRecordOpParamWrite:
+		return proto.CmdSetParam
+	}
+	return -1
+}
+
+// FaultRecordOpStatus 操作状态
+type FaultRecordOpStatus string
+
+const (
+	// 进行中
+	FaultRecordStatusPending    FaultRecordOpStatus = "pending"    // 已创建,未发送
+	FaultRecordStatusSent       FaultRecordOpStatus = "sent"       // 已发送 377
+	FaultRecordStatusAccepted   FaultRecordOpStatus = "accepted"   // 收到 378
+	FaultRecordStatusReady      FaultRecordOpStatus = "ready"      // 收到 379(record_fetch 继续等待上传)
+	FaultRecordStatusUploading  FaultRecordOpStatus = "uploading"  // 收到 607
+	FaultRecordStatusUploaded   FaultRecordOpStatus = "uploaded"   // 收到 609 成功
+	FaultRecordStatusDownloaded FaultRecordOpStatus = "downloaded" // 文件已存 MinIO
+
+	// 终止
+	FaultRecordStatusDone           FaultRecordOpStatus = "done"
+	FaultRecordStatusItemFailed     FaultRecordOpStatus = "item_failed"     // 379 中有子项失败
+	FaultRecordStatusBusy           FaultRecordOpStatus = "busy"            // 设备正在处理其他命令
+	FaultRecordStatusRejected       FaultRecordOpStatus = "rejected"        // 378 失败或 379 错误回复
+	FaultRecordStatusTimeout        FaultRecordOpStatus = "timeout"         // 某阶段等待超时,见 TimeoutStage
+	FaultRecordStatusUploadFailed   FaultRecordOpStatus = "upload_failed"   // 609 失败
+	FaultRecordStatusDownloadFailed FaultRecordOpStatus = "download_failed" // 下载或存储失败
+	FaultRecordStatusParseFailed    FaultRecordOpStatus = "parse_failed"    // 文件已保存但解析失败
+	FaultRecordStatusCancelled      FaultRecordOpStatus = "cancelled"
+	FaultRecordStatusInterrupted    FaultRecordOpStatus = "interrupted"    // 服务重启时处于无法安全恢复的阶段
+	FaultRecordStatusGatewayLocked  FaultRecordOpStatus = "gateway_locked" // 本工具内该网关已有操作进行中
+	FaultRecordStatusError          FaultRecordOpStatus = "error"          // 其他错误(MQTT 不可用等)
+)
+
+var faultRecordTerminal = map[FaultRecordOpStatus]bool{
+	FaultRecordStatusDone: true, FaultRecordStatusItemFailed: true, FaultRecordStatusBusy: true,
+	FaultRecordStatusRejected: true, FaultRecordStatusTimeout: true, FaultRecordStatusUploadFailed: true,
+	FaultRecordStatusDownloadFailed: true, FaultRecordStatusParseFailed: true, FaultRecordStatusCancelled: true,
+	FaultRecordStatusInterrupted: true, FaultRecordStatusGatewayLocked: true, FaultRecordStatusError: true,
+}
+
+func (s FaultRecordOpStatus) Terminal() bool { return faultRecordTerminal[s] }
+
+// CanRetryDownload 上传已完成(收到 609)但下载失败或超时,且有文件名时,可手动重试下载
+func (op *FaultRecordOperation) CanRetryDownload() bool {
+	if op.Kind != FaultRecordOpRecordFetch || op.FileName == "" {
+		return false
+	}
+	if _, uploaded := op.StageTimes[string(FaultRecordStatusUploaded)]; !uploaded {
+		return false
+	}
+	return op.Status == FaultRecordStatusDownloadFailed || op.Status == FaultRecordStatusTimeout
+}
+
+// FaultRecordTerminalStatuses 全部终止状态
+func FaultRecordTerminalStatuses() []FaultRecordOpStatus {
+	list := make([]FaultRecordOpStatus, 0, len(faultRecordTerminal))
+	for s := range faultRecordTerminal {
+		list = append(list, s)
+	}
+	return list
+}
+
+// FaultRecordOpRequest 操作请求参数
+type FaultRecordOpRequest struct {
+	CodeList      []int            `bson:"codeList,omitempty" json:"codeList,omitempty"`           // device_info
+	RecordIndex   int              `bson:"recordIndex,omitempty" json:"recordIndex,omitempty"`     // record_fetch(旧记录,单条)
+	RecordIndexes []int            `bson:"recordIndexes,omitempty" json:"recordIndexes,omitempty"` // record_fetch,可多条
+	IdList        []int            `bson:"idList,omitempty" json:"idList,omitempty"`               // param_read
+	ParaList      []proto.ParaItem `bson:"paraList,omitempty" json:"paraList,omitempty"`           // param_write
+}
+
+// Indexes 本次获取的记录序号(兼容只有 RecordIndex 的旧记录)
+func (r FaultRecordOpRequest) Indexes() []int {
+	if len(r.RecordIndexes) > 0 {
+		return r.RecordIndexes
+	}
+	if r.RecordIndex > 0 {
+		return []int{r.RecordIndex}
+	}
+	return nil
+}
+
+// FaultRecordOpMessage 收发的原始报文
+type FaultRecordOpMessage struct {
+	Direction string    `bson:"direction" json:"direction"` // send / recv / http(文件下载)/ action(用户操作,如手动重试下载)
+	Topic     string    `bson:"topic" json:"topic"`
+	CmdType   uint      `bson:"cmdType" json:"cmdType"`
+	At        time.Time `bson:"at" json:"at"`
+	Raw       string    `bson:"raw" json:"raw"`
+}
+
+// FaultRecordOperation 一次故障录波 MQTT 操作(MongoDB 文档)
+type FaultRecordOperation struct {
+	ID         primitive.ObjectID   `bson:"_id" json:"id"`
+	Env        string               `bson:"env" json:"env"`
+	GatewaySn  string               `bson:"gatewaySn" json:"gatewaySn"`
+	DeviceType string               `bson:"deviceType" json:"deviceType"` // pe / aGate
+	DeviceSn   string               `bson:"deviceSn" json:"deviceSn"`
+	Kind       FaultRecordOpKind    `bson:"kind" json:"kind"`
+	Cmd        int                  `bson:"cmd" json:"cmd"`
+	Request    FaultRecordOpRequest `bson:"request" json:"request"`
+	Operator   string               `bson:"operator" json:"operator"`
+
+	Status       FaultRecordOpStatus  `bson:"status" json:"status"`
+	TimeoutStage FaultRecordOpStatus  `bson:"timeoutStage,omitempty" json:"timeoutStage,omitempty"` // 超时时所等待的阶段
+	Error        string               `bson:"error,omitempty" json:"error,omitempty"`
+	StageTimes   map[string]time.Time `bson:"stageTimes" json:"stageTimes"`
+	CancelReq    bool                 `bson:"cancelRequested" json:"cancelRequested"`
+	ExitSent     bool                 `bson:"exitSent" json:"exitSent"` // 取消时已向设备发送 cmd=4
+
+	Messages []FaultRecordOpMessage `bson:"messages" json:"messages,omitempty"`
+	Reply    *proto.Reply           `bson:"reply,omitempty" json:"reply,omitempty"` // 379 原文结构
+
+	// record_fetch
+	FileName      string `bson:"fileName,omitempty" json:"fileName,omitempty"` // 609.fileName
+	FaultRecordID int64  `bson:"faultRecordId,omitempty" json:"faultRecordId,omitempty"`
+	UploadEquipNo string `bson:"uploadEquipNo,omitempty" json:"-"` // 下载路径中的 equipNo
+	MinioObject   string `bson:"minioObject,omitempty" json:"-"`
+	FileSize      int64  `bson:"fileSize,omitempty" json:"fileSize,omitempty"`
+
+	// 手动重试下载:上传已完成(收到 609)但下载失败时,由用户触发重新下载
+	DownloadRetryAt    *time.Time `bson:"downloadRetryAt,omitempty" json:"downloadRetryAt,omitempty"`
+	DownloadRetryCount int        `bson:"downloadRetryCount,omitempty" json:"downloadRetryCount,omitempty"`
+
+	CreatedAt time.Time `bson:"createdAt" json:"createdAt"`
+	UpdatedAt time.Time `bson:"updatedAt" json:"updatedAt"`
+}

+ 2 - 2
service/domain/fwhProDomain.go

@@ -144,8 +144,8 @@ type FaultRecordSubInfo struct {
 
 type SystemUpdateRecordInfo struct {
 	Id                   int64  `json:"id"`
-	OrderNo              string `json:"orderNo"` // 升级单号
-	Name                 string `json:"name"`    //
+	OrderNo              string `json:"orderNo"`     // 升级单号
+	Name                 string `json:"name"`        //
 	UpgradeType          int    `json:"upgradeType"` // 升级类型标识(上游返回数值)
 	UpgradeTime          string `json:"upgradeTime"` // 升级开始时间 - UTC时间
 	UpgradeNum           int    `json:"upgradeNum"`  // 升级设备数量

+ 93 - 10
service/dto/faultRecordDTO.go

@@ -1,17 +1,100 @@
 package dto
 
-type FaultRecordSubParamItem struct {
-	Id  int    `json:"id"`
-	Val string `json:"val"`
+import (
+	"time"
+
+	faultRecordFile "fwh-toolbox-server/function/FaultRecordFile"
+	proto "fwh-toolbox-server/function/FaultRecordProtocol"
+)
+
+// FaultRecordCreateOpReq 创建故障录波操作
+type FaultRecordCreateOpReq struct {
+	Env           string           `json:"env"`        // prod / test
+	GatewaySn     string           `json:"gatewaySn"`  // aGate SN
+	DeviceType    string           `json:"deviceType"` // aGate / pe
+	DeviceSn      string           `json:"deviceSn"`   // aGate 时可为空,默认网关 SN
+	Kind          string           `json:"kind"`       // device_info / record_fetch / param_read / param_write
+	CodeList      []int            `json:"codeList,omitempty"`
+	RecordIndex   int              `json:"recordIndex,omitempty"`   // 单条(兼容)
+	RecordIndexes []int            `json:"recordIndexes,omitempty"` // 多条,设备打成一个包
+	IdList        []int            `json:"idList,omitempty"`
+	ParaList      []proto.ParaItem `json:"paraList,omitempty"`
+}
+
+// FaultRecordDevice 可操作的设备
+type FaultRecordDevice struct {
+	Type string `json:"type"` // aGate / pe
+	Sn   string `json:"sn"`
+}
+
+// FaultRecordSiteDevices 网关及其设备
+type FaultRecordSiteDevices struct {
+	Env        string              `json:"env"`
+	GatewaySn  string              `json:"gatewaySn"`
+	SiteName   string              `json:"siteName"`
+	Online     bool                `json:"online"`
+	OnlineTime string              `json:"onlineTime,omitempty"`
+	Devices    []FaultRecordDevice `json:"devices"`
+}
+
+// FaultRecordOpSummary 操作历史列表项
+type FaultRecordOpSummary struct {
+	ID               string               `json:"id"`
+	Env              string               `json:"env"`
+	GatewaySn        string               `json:"gatewaySn"`
+	DeviceType       string               `json:"deviceType"`
+	DeviceSn         string               `json:"deviceSn"`
+	Kind             string               `json:"kind"`
+	Summary          string               `json:"summary"` // 请求参数摘要
+	Operator         string               `json:"operator"`
+	Status           string               `json:"status"`
+	TimeoutStage     string               `json:"timeoutStage,omitempty"`
+	Error            string               `json:"error,omitempty"`
+	StageTimes       map[string]time.Time `json:"stageTimes"`
+	FileName         string               `json:"fileName,omitempty"`
+	FaultRecordID    int64                `json:"faultRecordId,omitempty"`
+	CanCancel        bool                 `json:"canCancel"`
+	CanRetryDownload bool                 `json:"canRetryDownload"` // 上传已完成但下载失败,可手动重试下载
+	HasFile          bool                 `json:"hasFile"`
+	CreatedAt        time.Time            `json:"createdAt"`
+	UpdatedAt        time.Time            `json:"updatedAt"`
+}
+
+// FaultRecordOpMessageView 原始报文
+type FaultRecordOpMessageView struct {
+	Direction string    `json:"direction"`
+	Topic     string    `json:"topic"`
+	CmdType   uint      `json:"cmdType"`
+	At        time.Time `json:"at"`
+	Raw       string    `json:"raw"`
+}
+
+// FaultRecordResultItem 解析后的逐项结果
+type FaultRecordResultItem struct {
+	Code       int    `json:"code,omitempty"`   // device_info:数据类别;record_fetch:数据类型码 4/5
+	Id         int    `json:"id,omitempty"`     // param_read / param_write:功能码
+	Name       string `json:"name"`             // 名称
+	SetVal     string `json:"setVal,omitempty"` // param_write:设置值
+	Raw        string `json:"raw,omitempty"`    // hexData / idData 原文
+	Decoded    any    `json:"decoded,omitempty"`
+	DecodeErr  string `json:"decodeError,omitempty"`
+	ResultOne  string `json:"resultOne"`
+	ResultDesc string `json:"resultDesc"`
+	Ok         bool   `json:"ok"`
 }
 
-type FaultRecordSubParam struct {
-	Type     string                    `json:"type"` // 'pe' or 'agate'
-	Sn       string                    `json:"sn"`   // aGate/aPower SN
-	ParaList []FaultRecordSubParamItem `json:"paraList"`
+// FaultRecordOpDetail 操作详情
+type FaultRecordOpDetail struct {
+	FaultRecordOpSummary
+	Request     any                        `json:"request"`
+	ReplyResult string                     `json:"replyResult,omitempty"`
+	ReplyDesc   string                     `json:"replyDesc,omitempty"`
+	Items       []FaultRecordResultItem    `json:"items"`
+	Messages    []FaultRecordOpMessageView `json:"messages"`
 }
 
-type FaultRecordParamInfo struct {
-	Sn   string                `json:"sn"` // aGate SN
-	List []FaultRecordSubParam `json:"list"`
+// FaultRecordRecordView 录波详情(record_fetch 完成后)
+type FaultRecordRecordView struct {
+	OperationID string                   `json:"operationId"`
+	Records     []faultRecordFile.Record `json:"records"`
 }

+ 138 - 0
service/function/FaultRecordFile/archive.go

@@ -0,0 +1,138 @@
+package faultRecordFile
+
+import (
+	"archive/tar"
+	"bytes"
+	"compress/bzip2"
+	"compress/gzip"
+	"errors"
+	"fmt"
+	"io"
+	"path"
+	"sort"
+	"strconv"
+	"strings"
+)
+
+const (
+	maxEntrySize   = 16 << 20 // 单个文件上限
+	maxArchiveRead = 64 << 20 // 解压总量上限
+	fileFailure    = "failuredata"
+	fileWaveform   = "waveformdata"
+	indexDirPrefix = "index_"
+)
+
+// RecordFiles 压缩包中一条记录的两个文件(十六进制文本原文)
+type RecordFiles struct {
+	Batch        string `json:"batch"`
+	Sn           string `json:"sn"`
+	Index        int    `json:"index"`
+	FailureData  []byte `json:"-"`
+	WaveformData []byte `json:"-"`
+}
+
+// openArchive 按文件头判断压缩格式;扩展名为 .tar.bz2 的文件实际可能是 gzip
+func openArchive(data []byte) (io.Reader, error) {
+	switch {
+	case len(data) >= 2 && data[0] == 0x1f && data[1] == 0x8b:
+		return gzip.NewReader(bytes.NewReader(data))
+	case len(data) >= 3 && string(data[:3]) == "BZh":
+		return bzip2.NewReader(bytes.NewReader(data)), nil
+	default:
+		return bytes.NewReader(data), nil // 未压缩的 tar
+	}
+}
+
+// ExtractArchive 解出 <batch>/<sn>/index_<n>/{failuredata,waveformdata},其他文件忽略
+func ExtractArchive(data []byte) ([]RecordFiles, error) {
+	zr, err := openArchive(data)
+	if err != nil {
+		return nil, fmt.Errorf("open archive: %w", err)
+	}
+	tr := tar.NewReader(io.LimitReader(zr, maxArchiveRead))
+
+	records := map[string]*RecordFiles{}
+	for {
+		h, err := tr.Next()
+		if errors.Is(err, io.EOF) {
+			break
+		}
+		if err != nil {
+			return nil, fmt.Errorf("read archive: %w", err)
+		}
+		if h.Typeflag != tar.TypeReg {
+			continue
+		}
+		parts := strings.Split(strings.Trim(path.Clean(h.Name), "/"), "/")
+		if len(parts) < 4 {
+			continue
+		}
+		name := parts[len(parts)-1]
+		if name != fileFailure && name != fileWaveform {
+			continue
+		}
+		idxStr, ok := strings.CutPrefix(parts[len(parts)-2], indexDirPrefix)
+		if !ok {
+			continue
+		}
+		idx, err := strconv.Atoi(idxStr)
+		if err != nil {
+			continue
+		}
+		if h.Size > maxEntrySize {
+			return nil, fmt.Errorf("%s too large: %d bytes", h.Name, h.Size)
+		}
+		content, err := io.ReadAll(io.LimitReader(tr, maxEntrySize))
+		if err != nil {
+			return nil, fmt.Errorf("read %s: %w", h.Name, err)
+		}
+
+		batch, sn := parts[len(parts)-4], parts[len(parts)-3]
+		key := batch + "/" + sn + "/" + idxStr
+		rf, ok := records[key]
+		if !ok {
+			rf = &RecordFiles{Batch: batch, Sn: sn, Index: idx}
+			records[key] = rf
+		}
+		if name == fileFailure {
+			rf.FailureData = bytes.TrimSpace(content)
+		} else {
+			rf.WaveformData = bytes.TrimSpace(content)
+		}
+	}
+
+	list := make([]RecordFiles, 0, len(records))
+	for _, rf := range records {
+		list = append(list, *rf)
+	}
+	sort.Slice(list, func(i, j int) bool {
+		if list[i].Sn != list[j].Sn {
+			return list[i].Sn < list[j].Sn
+		}
+		return list[i].Index < list[j].Index
+	})
+	return list, nil
+}
+
+// ParseArchive 解压并解析全部记录;缺少任一文件的记录返回错误
+func ParseArchive(data []byte, deviceType string) ([]Record, error) {
+	files, err := ExtractArchive(data)
+	if err != nil {
+		return nil, err
+	}
+	if len(files) == 0 {
+		return nil, fmt.Errorf("no record found in archive")
+	}
+	records := make([]Record, 0, len(files))
+	for _, f := range files {
+		if len(f.FailureData) == 0 || len(f.WaveformData) == 0 {
+			return nil, fmt.Errorf("record %s/%s/index_%d is incomplete", f.Batch, f.Sn, f.Index)
+		}
+		rec, err := ParseRecordFiles(f, deviceType)
+		if err != nil {
+			return nil, fmt.Errorf("record %s/%s/index_%d: %w", f.Batch, f.Sn, f.Index, err)
+		}
+		records = append(records, rec)
+	}
+	return records, nil
+}

+ 138 - 0
service/function/FaultRecordFile/csv.go

@@ -0,0 +1,138 @@
+package faultRecordFile
+
+import (
+	"archive/zip"
+	"bytes"
+	"encoding/csv"
+	"fmt"
+	"io"
+	"strconv"
+)
+
+var utf8Bom = []byte{0xEF, 0xBB, 0xBF} // 便于 Excel 识别中文
+
+// ChannelLabel 通道表头:序号 + 含义代码(可配通道附类型和地址)
+func ChannelLabel(ch WaveformChannel) string {
+	if ch.Configurable != nil {
+		return fmt.Sprintf("ch%d 可配%s", ch.Number, ConfigurableDesc(*ch.Configurable))
+	}
+	return fmt.Sprintf("ch%d 0x%04X", ch.Number, ch.Meaning)
+}
+
+// ConfigurableDesc 可配通道说明:默认方式为“类型@地址”,代码方式为“代码 0xXXXX”
+func ConfigurableDesc(c ConfigurableChannel) string {
+	if c.Mode == 1 {
+		return fmt.Sprintf("代码0x%04X", c.Address>>16)
+	}
+	return fmt.Sprintf("%s@0x%X", c.TypeName, c.Address)
+}
+
+// WriteWaveformCSV 每行一个采样点:点序号、相对故障时刻毫秒、各通道原始值
+func WriteWaveformCSV(w io.Writer, rec Record) error {
+	if _, err := w.Write(utf8Bom); err != nil {
+		return err
+	}
+	cw := csv.NewWriter(w)
+	wf := rec.Waveform
+	header := []string{"point", "time_ms"}
+	for _, ch := range wf.Channels {
+		header = append(header, ChannelLabel(ch))
+	}
+	if err := cw.Write(header); err != nil {
+		return err
+	}
+	row := make([]string, len(header))
+	for k := 0; k < wf.Points; k++ {
+		row[0] = strconv.Itoa(k)
+		row[1] = strconv.FormatFloat(wf.TimeOffsetMs(k), 'f', 3, 64)
+		for c, ch := range wf.Channels {
+			row[c+2] = strconv.Itoa(int(ch.Values[k]))
+		}
+		if err := cw.Write(row); err != nil {
+			return err
+		}
+	}
+	cw.Flush()
+	return cw.Error()
+}
+
+// WriteSummaryCSV 基本信息、版本、触发源及各组通道
+func WriteSummaryCSV(w io.Writer, rec Record) error {
+	if _, err := w.Write(utf8Bom); err != nil {
+		return err
+	}
+	cw := csv.NewWriter(w)
+	f := rec.Failure
+	rows := [][]string{
+		{"项目", "值"},
+		{"批次", rec.Batch},
+		{"设备 SN", rec.Sn},
+		{"记录序号", strconv.Itoa(rec.RecordIndex)},
+		{"发生时间(设备时间)", f.Time},
+		{"触发源", fmt.Sprintf("0x%04X %s", f.TriggerSource, f.TriggerSourceName)},
+		{"CAN 协议版本", strconv.Itoa(int(f.Basic.CanProtocolVersion))},
+		{"存储格式版本", strconv.Itoa(int(f.Basic.StorageFormat))},
+		{"参数列表版本", f.Basic.ParamListVersion},
+		{"最大采样频率 Hz", strconv.Itoa(int(f.Basic.MaxSampleFrequency))},
+		{"采样频率 Hz", strconv.Itoa(int(f.Basic.SampleFrequency))},
+		{"采样点总数", strconv.Itoa(int(f.Basic.TotalPoints))},
+		{"故障前采样点", strconv.Itoa(int(f.Basic.PrePoints))},
+		{"故障后采样点", strconv.Itoa(int(f.Basic.PostPoints))},
+		{"DCDC 版本", f.DcdcVersion},
+		{"INV 版本", f.InvVersion},
+		{"FPGA 版本", f.FpgaVersion},
+		{},
+		{"快照通道", "含义代码", "原始值"},
+	}
+	for i, s := range f.Snapshots {
+		v := ""
+		if s.Value != nil {
+			v = strconv.Itoa(int(*s.Value))
+		}
+		rows = append(rows, []string{strconv.Itoa(i), fmt.Sprintf("0x%04X", s.Meaning), v})
+	}
+	rows = append(rows, []string{}, []string{"采样通道", "含义代码"})
+	for i, m := range f.SampleMeanings {
+		rows = append(rows, []string{strconv.Itoa(i), fmt.Sprintf("0x%04X", m)})
+	}
+	rows = append(rows, []string{}, []string{"可配快照通道", "配置"})
+	for i, c := range f.ConfigSnapshots {
+		rows = append(rows, []string{strconv.Itoa(i), ConfigurableDesc(c)})
+	}
+	rows = append(rows, []string{}, []string{"可配采样通道", "配置"})
+	for i, c := range f.ConfigSamples {
+		rows = append(rows, []string{strconv.Itoa(i), ConfigurableDesc(c)})
+	}
+	if err := cw.WriteAll(rows); err != nil {
+		return err
+	}
+	return cw.Error()
+}
+
+// BuildCSVZip 每条记录生成 waveform 和 summary 两个 CSV,打包为 zip
+func BuildCSVZip(records []Record) ([]byte, error) {
+	var buf bytes.Buffer
+	zw := zip.NewWriter(&buf)
+	for _, rec := range records {
+		prefix := fmt.Sprintf("%s_%s_index%d", rec.Batch, rec.Sn, rec.RecordIndex)
+		for _, item := range []struct {
+			name  string
+			write func(io.Writer, Record) error
+		}{
+			{prefix + "_waveform.csv", WriteWaveformCSV},
+			{prefix + "_summary.csv", WriteSummaryCSV},
+		} {
+			fw, err := zw.Create(item.name)
+			if err != nil {
+				return nil, err
+			}
+			if err := item.write(fw, rec); err != nil {
+				return nil, err
+			}
+		}
+	}
+	if err := zw.Close(); err != nil {
+		return nil, err
+	}
+	return buf.Bytes(), nil
+}

+ 170 - 0
service/function/FaultRecordFile/file_test.go

@@ -0,0 +1,170 @@
+package faultRecordFile
+
+import (
+	"archive/zip"
+	"bytes"
+	"os"
+	"strings"
+	"testing"
+
+	proto "fwh-toolbox-server/function/FaultRecordProtocol"
+)
+
+const (
+	sampleArchive = "../../../docs/fault-record/2026-10-14_05_46_20.tar.bz2"
+	// 2026-10-14 样本 379(cmd=0, code=3) 的 hexData
+	sampleDirectoryHex = "0F0107E801010000220000000110020207EA070E0235230000000110010307EA080C0203050000000110010407EA080D1528280000000110010507EA080E0318100000000110010607EA0810033A050000000110010707EA08120303080000000110010807E90B18010B2E0000000110010907E90B1A152C060000000110010A07E705041601030000000110020B07E705041602210000000110020C07E705041603280000000110020D07E7050416042E0000000110020E07E70504161C0E0000000110020F07EA061D0F0723000000011001"
+)
+
+func TestParseDirectory(t *testing.T) {
+	v, err := ParseRealtime(3, sampleDirectoryHex)
+	if err != nil {
+		t.Fatal(err)
+	}
+	list := v.([]DirectoryEntry)
+	if len(list) != 15 {
+		t.Fatalf("entries = %d", len(list))
+	}
+	e := list[6]
+	if e.Index != 7 || e.Time != "2026-08-18 03:03:08" || e.TriggerSource != 0x1001 || e.TriggerSourceName == "" || e.ParamListVersion != "00000001" {
+		t.Errorf("entry 7: %+v", e)
+	}
+	if _, err := ParseRealtime(3, sampleDirectoryHex[:40]); err == nil {
+		t.Error("truncated directory should fail")
+	}
+}
+
+func TestParseBasicInfo(t *testing.T) {
+	b, err := ParseRealtime(2, "01010000000111941194060004000200")
+	if err != nil {
+		t.Fatal(err)
+	}
+	bi := b.(BasicInfo)
+	if bi.SampleFrequency != 4500 || bi.TotalPoints != 1536 || bi.PrePoints != 1024 || bi.PostPoints != 512 {
+		t.Errorf("basic info: %+v", bi)
+	}
+}
+
+func loadSampleArchive(t *testing.T) []byte {
+	t.Helper()
+	data, err := os.ReadFile(sampleArchive)
+	if err != nil {
+		t.Skipf("sample archive not found: %v", err)
+	}
+	return data
+}
+
+func TestParseSampleArchive(t *testing.T) {
+	records, err := ParseArchive(loadSampleArchive(t), proto.DeviceTypeAGate)
+	if err != nil {
+		t.Fatal(err)
+	}
+	if len(records) != 1 {
+		t.Fatalf("records = %d", len(records))
+	}
+	rec := records[0]
+	if rec.Batch != "2026-10-14_05_46_20" || rec.Sn != "AFCI_TEST_0713" || rec.RecordIndex != 7 {
+		t.Errorf("record identity: %s %s %d", rec.Batch, rec.Sn, rec.RecordIndex)
+	}
+
+	f := rec.Failure
+	if f.DeclaredLength != 512 || f.ParsedBytes != 308 {
+		t.Errorf("length: declared=%d parsed=%d", f.DeclaredLength, f.ParsedBytes)
+	}
+	if f.Index != 7 || f.Time != "2026-08-18 03:03:08" || f.TriggerSource != 0x1001 {
+		t.Errorf("header: %+v", f)
+	}
+	if f.Basic.SampleFrequency != 4500 || f.Basic.TotalPoints != 1536 || f.Basic.PrePoints != 1024 {
+		t.Errorf("basic: %+v", f.Basic)
+	}
+	if f.DcdcVersion != "EMS_SL:V10R80B21D00" || f.InvVersion != "" || f.FpgaVersion != "" {
+		t.Errorf("versions: %q %q %q", f.DcdcVersion, f.InvVersion, f.FpgaVersion)
+	}
+	if len(f.Snapshots) != 6 || *f.Snapshots[0].Value != 2049 || len(f.SampleMeanings) != 16 || len(f.ConfigSamples) != 4 {
+		t.Errorf("channels: snap=%d samp=%d cfg=%d", len(f.Snapshots), len(f.SampleMeanings), len(f.ConfigSamples))
+	}
+
+	w := rec.Waveform
+	if len(w.Channels) != 16 || w.Points != 1536 {
+		t.Fatalf("waveform shape: %d × %d", len(w.Channels), w.Points)
+	}
+	// 故障字通道在第 1024 点跳变
+	ch0 := w.Channels[0].Values
+	if ch0[1023] != 0 || ch0[1024] != 0x0801 {
+		t.Errorf("ch0 around fault: %d %d", ch0[1023], ch0[1024])
+	}
+	// 正弦通道按 int16 解释,含负值
+	if w.Channels[4].Values[0] != -396 {
+		t.Errorf("ch4[0] = %d", w.Channels[4].Values[0])
+	}
+	if w.Channels[12].Configurable == nil || w.Channels[12].Configurable.Address != 0x14990 || w.Channels[11].Configurable != nil {
+		t.Error("configurable channels should be the last 4")
+	}
+	if w.TimeOffsetMs(1024) != 0 {
+		t.Errorf("fault time offset: %v", w.TimeOffsetMs(1024))
+	}
+}
+
+func TestPEKeepsVersions(t *testing.T) {
+	files, err := ExtractArchive(loadSampleArchive(t))
+	if err != nil {
+		t.Fatal(err)
+	}
+	rec, err := ParseRecordFiles(files[0], proto.DeviceTypePE)
+	if err != nil {
+		t.Fatal(err)
+	}
+	if rec.Failure.InvVersion == "" {
+		t.Error("pe should parse INV version field")
+	}
+}
+
+func TestBuildCSVZip(t *testing.T) {
+	records, err := ParseArchive(loadSampleArchive(t), proto.DeviceTypeAGate)
+	if err != nil {
+		t.Fatal(err)
+	}
+	data, err := BuildCSVZip(records)
+	if err != nil {
+		t.Fatal(err)
+	}
+	zr, err := zip.NewReader(bytes.NewReader(data), int64(len(data)))
+	if err != nil {
+		t.Fatal(err)
+	}
+	if len(zr.File) != 2 {
+		t.Fatalf("zip files = %d", len(zr.File))
+	}
+	var wf bytes.Buffer
+	if err := WriteWaveformCSV(&wf, records[0]); err != nil {
+		t.Fatal(err)
+	}
+	lines := strings.Split(strings.TrimSpace(wf.String()), "\n")
+	if len(lines) != 1537 {
+		t.Errorf("waveform csv lines = %d", len(lines))
+	}
+	if !strings.HasPrefix(lines[1025], "1024,0.000,2049,") {
+		t.Errorf("fault row: %s", lines[1025])
+	}
+}
+
+func TestRejectBadArchive(t *testing.T) {
+	if _, err := ParseArchive([]byte("not an archive"), proto.DeviceTypeAGate); err == nil {
+		t.Error("garbage should fail")
+	}
+}
+
+func TestConfigurableChannelModes(t *testing.T) {
+	// code=1:最大/当前记录数、两个时间、快照/采样/可配快照为空,可配采样 2 项(默认方式 Uint16、代码方式 0x1234)
+	hexData := "1005" + "07EA0A100C0000" + "07EA0A100C0000" + "0000" + "0000" + "0000" +
+		"0202" + "0001" + "00014990" + "0010" + "12340000"
+	v, err := ParseRealtime(1, hexData)
+	if err != nil {
+		t.Fatal(err)
+	}
+	cs := v.(Overview).ConfigSamples
+	if len(cs) != 2 || cs[0].Mode != 0 || cs[0].TypeName != "Uint16" || cs[0].Address != 0x14990 ||
+		cs[1].Mode != 1 || ConfigurableDesc(cs[1]) != "代码0x1234" {
+		t.Errorf("configurable: %+v", cs)
+	}
+}

+ 155 - 0
service/function/FaultRecordFile/reader.go

@@ -0,0 +1,155 @@
+// Package faultRecordFile 故障录波数据解析(纯函数):cmd=0 的 hexData、cmd=2 导出的 failuredata/waveformdata 及压缩包。
+//
+// 字节结构依据 docs/fault-record/FaultFilter_MQTT协议文档.md 5.1.1、5.2,
+// 并按 docs/fault-record/protocol-specification.md 8A 的样本验证规则补充。
+package faultRecordFile
+
+import (
+	"bytes"
+	"encoding/binary"
+	"fmt"
+
+	proto "fwh-toolbox-server/function/FaultRecordProtocol"
+)
+
+// byteReader 顺序读取,越界时记录错误并返回零值
+type byteReader struct {
+	b   []byte
+	p   int
+	err error
+}
+
+func (r *byteReader) take(n int, field string) []byte {
+	if r.err != nil {
+		return make([]byte, n)
+	}
+	if r.p+n > len(r.b) {
+		r.err = fmt.Errorf("data too short at %s (offset %d, need %d, have %d)", field, r.p, n, len(r.b)-r.p)
+		return make([]byte, n)
+	}
+	v := r.b[r.p : r.p+n]
+	r.p += n
+	return v
+}
+
+func (r *byteReader) u8(field string) uint8   { return r.take(1, field)[0] }
+func (r *byteReader) u16(field string) uint16 { return binary.BigEndian.Uint16(r.take(2, field)) }
+func (r *byteReader) u32(field string) uint32 { return binary.BigEndian.Uint32(r.take(4, field)) }
+func (r *byteReader) time(field string) proto.DeviceTime {
+	return proto.ParseDeviceTime(r.take(7, field))
+}
+
+// cString 取第一个 NUL 之前的内容
+func cString(b []byte) string {
+	if i := bytes.IndexByte(b, 0); i >= 0 {
+		b = b[:i]
+	}
+	return string(b)
+}
+
+/************** 公共结构 **************/
+
+// BasicInfo 设备故障录波功能基本信息(code=2,16 字节)
+type BasicInfo struct {
+	CanProtocolVersion uint8  `json:"canProtocolVersion"`
+	StorageFormat      uint8  `json:"storageFormat"`
+	ParamListVersion   string `json:"paramListVersion"` // 4 字节十六进制
+	MaxSampleFrequency uint16 `json:"maxSampleFrequency"`
+	SampleFrequency    uint16 `json:"sampleFrequency"`
+	TotalPoints        uint16 `json:"totalPoints"`
+	PrePoints          uint16 `json:"prePoints"`
+	PostPoints         uint16 `json:"postPoints"`
+}
+
+func readBasicInfo(r *byteReader) BasicInfo {
+	return BasicInfo{
+		CanProtocolVersion: r.u8("canProtocolVersion"),
+		StorageFormat:      r.u8("storageFormat"),
+		ParamListVersion:   fmt.Sprintf("%08X", r.u32("paramListVersion")),
+		MaxSampleFrequency: r.u16("maxSampleFrequency"),
+		SampleFrequency:    r.u16("sampleFrequency"),
+		TotalPoints:        r.u16("totalPoints"),
+		PrePoints:          r.u16("prePoints"),
+		PostPoints:         r.u16("postPoints"),
+	}
+}
+
+// SnapshotChannel 快照通道:含义代码(F14),code=4 中带故障时刻的原始值
+type SnapshotChannel struct {
+	Meaning uint16  `json:"meaning"`
+	Value   *uint16 `json:"value,omitempty"`
+}
+
+// ConfigurableChannel 可配通道:类型 2B + 数据 4B。
+// 类型低字节与设置 id=6 的类型字节格式一致:高 4 位为设置方式(0=默认方式 1=代码方式),低 4 位为地址类型;
+// 两种方式的数据都按 4 字节原样保留在 Address(代码方式时前 2 字节为参数代码)。
+type ConfigurableChannel struct {
+	Type     uint16 `json:"type"`
+	Mode     uint8  `json:"mode"`
+	AddrType uint8  `json:"addrType"`
+	TypeName string `json:"typeName"`
+	Address  uint32 `json:"address"`
+}
+
+// ChannelGroups 快照 / 采样 / 可配快照 / 可配采样 四组通道(code=1 与 failuredata 共用)
+type ChannelGroups struct {
+	SnapshotTotal     uint8                 `json:"snapshotTotal"`
+	SnapshotUsed      uint8                 `json:"snapshotUsed"`
+	Snapshots         []SnapshotChannel     `json:"snapshots"`
+	SampleTotal       uint8                 `json:"sampleTotal"`
+	SampleUsed        uint8                 `json:"sampleUsed"`
+	SampleMeanings    []uint16              `json:"sampleMeanings"`
+	ConfigSnapTotal   uint8                 `json:"configSnapTotal"`
+	ConfigSnapUsed    uint8                 `json:"configSnapUsed"`
+	ConfigSnapshots   []ConfigurableChannel `json:"configSnapshots"`
+	ConfigSampleTotal uint8                 `json:"configSampleTotal"`
+	ConfigSampleUsed  uint8                 `json:"configSampleUsed"`
+	ConfigSamples     []ConfigurableChannel `json:"configSamples"`
+}
+
+func readChannelGroups(r *byteReader, snapshotWithValue bool) ChannelGroups {
+	var g ChannelGroups
+	g.SnapshotTotal = r.u8("snapshotTotal")
+	g.SnapshotUsed = r.u8("snapshotUsed")
+	g.Snapshots = make([]SnapshotChannel, 0, g.SnapshotTotal)
+	for i := 0; i < int(g.SnapshotTotal); i++ {
+		c := SnapshotChannel{Meaning: r.u16("snapshotMeaning")}
+		if snapshotWithValue {
+			v := r.u16("snapshotValue")
+			c.Value = &v
+		}
+		g.Snapshots = append(g.Snapshots, c)
+	}
+
+	g.SampleTotal = r.u8("sampleTotal")
+	g.SampleUsed = r.u8("sampleUsed")
+	g.SampleMeanings = make([]uint16, 0, g.SampleTotal)
+	for i := 0; i < int(g.SampleTotal); i++ {
+		g.SampleMeanings = append(g.SampleMeanings, r.u16("sampleMeaning"))
+	}
+
+	g.ConfigSnapTotal = r.u8("configSnapTotal")
+	g.ConfigSnapUsed = r.u8("configSnapUsed")
+	g.ConfigSnapshots = readConfigurable(r, int(g.ConfigSnapTotal))
+
+	g.ConfigSampleTotal = r.u8("configSampleTotal")
+	g.ConfigSampleUsed = r.u8("configSampleUsed")
+	g.ConfigSamples = readConfigurable(r, int(g.ConfigSampleTotal))
+	return g
+}
+
+func readConfigurable(r *byteReader, n int) []ConfigurableChannel {
+	list := make([]ConfigurableChannel, 0, n)
+	for i := 0; i < n; i++ {
+		t := r.u16("configType")
+		mode, addrType := uint8(t)>>4, uint8(t)&0x0F
+		list = append(list, ConfigurableChannel{
+			Type:     t,
+			Mode:     mode,
+			AddrType: addrType,
+			TypeName: proto.ChannelAddrTypeNames[addrType],
+			Address:  r.u32("configAddress"),
+		})
+	}
+	return list
+}

+ 91 - 0
service/function/FaultRecordFile/realtime.go

@@ -0,0 +1,91 @@
+package faultRecordFile
+
+import (
+	"fmt"
+
+	proto "fwh-toolbox-server/function/FaultRecordProtocol"
+)
+
+// Overview code=1 故障总体信息
+type Overview struct {
+	MaxRecords     uint8  `json:"maxRecords"`
+	CurrentRecords uint8  `json:"currentRecords"`
+	EarliestTime   string `json:"earliestTime"` // 设备时间
+	LatestTime     string `json:"latestTime"`
+	ChannelGroups
+}
+
+// DirectoryEntry code=3 目录项(14 字节)
+type DirectoryEntry struct {
+	Index             uint8  `json:"index"`
+	Time              string `json:"time"` // 设备时间
+	ParamListVersion  string `json:"paramListVersion"`
+	TriggerSource     uint16 `json:"triggerSource"`
+	TriggerSourceName string `json:"triggerSourceName,omitempty"`
+}
+
+const directoryEntrySize = 14
+
+// ParseOverview 解析 code=1
+func ParseOverview(b []byte) (Overview, error) {
+	r := &byteReader{b: b}
+	o := Overview{
+		MaxRecords:     r.u8("maxRecords"),
+		CurrentRecords: r.u8("currentRecords"),
+		EarliestTime:   r.time("earliestTime").String(),
+		LatestTime:     r.time("latestTime").String(),
+	}
+	o.ChannelGroups = readChannelGroups(r, false)
+	return o, r.err
+}
+
+// ParseBasicInfo 解析 code=2
+func ParseBasicInfo(b []byte) (BasicInfo, error) {
+	if len(b) != 16 {
+		return BasicInfo{}, fmt.Errorf("basic info must be 16 bytes, got %d", len(b))
+	}
+	r := &byteReader{b: b}
+	return readBasicInfo(r), r.err
+}
+
+// ParseDirectory 解析 code=3:1 字节条数 + N × 14 字节
+func ParseDirectory(b []byte) ([]DirectoryEntry, error) {
+	if len(b) < 1 {
+		return nil, fmt.Errorf("directory is empty")
+	}
+	n := int(b[0])
+	if len(b) != 1+n*directoryEntrySize {
+		return nil, fmt.Errorf("directory length %d does not match %d entries", len(b), n)
+	}
+	r := &byteReader{b: b[1:]}
+	list := make([]DirectoryEntry, 0, n)
+	for i := 0; i < n; i++ {
+		e := DirectoryEntry{
+			Index:            r.u8("index"),
+			Time:             r.time("time").String(),
+			ParamListVersion: fmt.Sprintf("%08X", r.u32("paramListVersion")),
+			TriggerSource:    r.u16("triggerSource"),
+		}
+		e.TriggerSourceName = proto.TriggerSourceName(e.TriggerSource)
+		list = append(list, e)
+	}
+	return list, r.err
+}
+
+// ParseRealtime 按 code 解析 hexData,返回对应结构
+func ParseRealtime(code int, hexData string) (any, error) {
+	b, err := proto.DecodeHex(hexData)
+	if err != nil {
+		return nil, err
+	}
+	switch code {
+	case 1:
+		return ParseOverview(b)
+	case 2:
+		return ParseBasicInfo(b)
+	case 3:
+		return ParseDirectory(b)
+	default:
+		return nil, fmt.Errorf("unsupported code %d", code)
+	}
+}

+ 143 - 0
service/function/FaultRecordFile/record.go

@@ -0,0 +1,143 @@
+package faultRecordFile
+
+import (
+	"encoding/binary"
+	"fmt"
+
+	proto "fwh-toolbox-server/function/FaultRecordProtocol"
+)
+
+const versionFieldSize = 64
+
+// FailureData failuredata(code=4 单一故障信息)
+type FailureData struct {
+	DeclaredLength    uint16    `json:"declaredLength"` // 样本中等于整个文件字节数(含填充),不作为有效长度使用
+	Basic             BasicInfo `json:"basic"`
+	Index             uint8     `json:"index"`
+	Time              string    `json:"time"` // 设备时间
+	TriggerSource     uint16    `json:"triggerSource"`
+	TriggerSourceName string    `json:"triggerSourceName,omitempty"`
+	DcdcVersion       string    `json:"dcdcVersion"`
+	InvVersion        string    `json:"invVersion,omitempty"`  // aGate 中为填充,不解析
+	FpgaVersion       string    `json:"fpgaVersion,omitempty"` // aGate 中为填充,不解析
+	ChannelGroups
+	ParsedBytes int `json:"parsedBytes"` // 结构结束位置,之后为填充
+}
+
+// ParseFailureData 按结构逐项解析,结构结束后的内容(0xFF 填充)忽略
+func ParseFailureData(b []byte, deviceType string) (FailureData, error) {
+	r := &byteReader{b: b}
+	f := FailureData{
+		DeclaredLength: r.u16("length"),
+		Basic:          readBasicInfo(r),
+		Index:          r.u8("index"),
+		Time:           r.time("time").String(),
+		TriggerSource:  r.u16("triggerSource"),
+	}
+	f.TriggerSourceName = proto.TriggerSourceName(f.TriggerSource)
+	f.DcdcVersion = cString(r.take(versionFieldSize, "dcdcVersion"))
+	inv := r.take(versionFieldSize, "invVersion")
+	fpga := r.take(versionFieldSize, "fpgaVersion")
+	if deviceType != proto.DeviceTypeAGate {
+		f.InvVersion = cString(inv)
+		f.FpgaVersion = cString(fpga)
+	}
+	f.ChannelGroups = readChannelGroups(r, true)
+	f.ParsedBytes = r.p
+	return f, r.err
+}
+
+// WaveformChannel 一个采样通道的全部点(原始 int16 值)
+type WaveformChannel struct {
+	Number  int    `json:"number"`  // 通道序号,从 0 开始
+	Meaning uint16 `json:"meaning"` // 采样通道含义(可配通道处为类型码)
+	// Configurable 采样通道列表末尾的通道对应可配采样通道(按样本推断)
+	Configurable *ConfigurableChannel `json:"configurable,omitempty"`
+	Values       []int16              `json:"values"`
+}
+
+// Waveform waveformdata(code=5 单一故障波形数据)
+type Waveform struct {
+	Points          int               `json:"points"`
+	PrePoints       int               `json:"prePoints"`
+	SampleFrequency int               `json:"sampleFrequency"`
+	Channels        []WaveformChannel `json:"channels"`
+}
+
+// ParseWaveform 采样点优先排列:先排第 1 点的 n 个通道,再排第 2 点,依此类推。
+// 通道数取 failuredata 的“采样通道总数”,点数取基本信息的“采样点总数”;每点每通道 2 字节、大端、按 int16 解释。
+func ParseWaveform(b []byte, fd FailureData) (Waveform, error) {
+	n := int(fd.SampleTotal)
+	m := int(fd.Basic.TotalPoints)
+	if n == 0 || m == 0 {
+		return Waveform{}, fmt.Errorf("invalid waveform shape: %d channels × %d points", n, m)
+	}
+	if len(b) != n*m*2 {
+		return Waveform{}, fmt.Errorf("waveform length %d does not match %d channels × %d points × 2", len(b), n, m)
+	}
+
+	w := Waveform{
+		Points:          m,
+		PrePoints:       int(fd.Basic.PrePoints),
+		SampleFrequency: int(fd.Basic.SampleFrequency),
+		Channels:        make([]WaveformChannel, n),
+	}
+	cfgStart := n - int(fd.ConfigSampleTotal)
+	for c := 0; c < n; c++ {
+		ch := WaveformChannel{Number: c, Values: make([]int16, m)}
+		if c < len(fd.SampleMeanings) {
+			ch.Meaning = fd.SampleMeanings[c]
+		}
+		if cfgStart >= 0 && c >= cfgStart && c-cfgStart < len(fd.ConfigSamples) {
+			cfg := fd.ConfigSamples[c-cfgStart]
+			ch.Configurable = &cfg
+		}
+		w.Channels[c] = ch
+	}
+	for k := 0; k < m; k++ {
+		for c := 0; c < n; c++ {
+			off := (k*n + c) * 2
+			w.Channels[c].Values[k] = int16(binary.BigEndian.Uint16(b[off:]))
+		}
+	}
+	return w, nil
+}
+
+// TimeOffsetMs 第 k 个采样点相对故障时刻的毫秒数
+func (w Waveform) TimeOffsetMs(k int) float64 {
+	if w.SampleFrequency == 0 {
+		return 0
+	}
+	return float64(k-w.PrePoints) * 1000 / float64(w.SampleFrequency)
+}
+
+// Record 一条已解析的录波记录
+type Record struct {
+	Batch       string      `json:"batch"`
+	Sn          string      `json:"sn"`
+	RecordIndex int         `json:"recordIndex"`
+	Failure     FailureData `json:"failure"`
+	Waveform    Waveform    `json:"waveform"`
+}
+
+// ParseRecordFiles 解析一条记录的两个文件(内容为十六进制文本)
+func ParseRecordFiles(f RecordFiles, deviceType string) (Record, error) {
+	rec := Record{Batch: f.Batch, Sn: f.Sn, RecordIndex: f.Index}
+
+	fdBytes, err := proto.DecodeHex(string(f.FailureData))
+	if err != nil {
+		return rec, fmt.Errorf("failuredata: %w", err)
+	}
+	if rec.Failure, err = ParseFailureData(fdBytes, deviceType); err != nil {
+		return rec, fmt.Errorf("failuredata: %w", err)
+	}
+
+	wfBytes, err := proto.DecodeHex(string(f.WaveformData))
+	if err != nil {
+		return rec, fmt.Errorf("waveformdata: %w", err)
+	}
+	if rec.Waveform, err = ParseWaveform(wfBytes, rec.Failure); err != nil {
+		return rec, fmt.Errorf("waveformdata: %w", err)
+	}
+	return rec, nil
+}

+ 160 - 0
service/function/FaultRecordProtocol/dict.go

@@ -0,0 +1,160 @@
+package faultRecordProtocol
+
+import (
+	"fmt"
+	"strconv"
+	"strings"
+)
+
+// FunctionCodeNames 命令控制符(功能码),FaultFilter 文档 5.4.1
+var FunctionCodeNames = map[int]string{
+	1: "清除设备故障波形",
+	2: "采样通道配置",
+	3: "手动触发故障录波",
+	4: "设备对时",
+	5: "故障录波采样频率",
+	6: "配置可配采样通道",
+	7: "故障录波功能状态控制",
+}
+
+// DangerousFunctionCodes 设置前需要醒目确认的功能码
+var DangerousFunctionCodes = map[int]string{
+	1: "清除设备上的故障波形,不可恢复",
+	3: "立即触发一次故障录波",
+	4: "修改设备时间",
+}
+
+// RealtimeCodeNames 实时数据类别,FaultFilter 文档 5.1
+var RealtimeCodeNames = map[int]string{
+	1: "故障总体信息",
+	2: "设备故障录波功能基本信息",
+	3: "故障录波总目录",
+}
+
+// TriggerSourceNames 事件触发源(F15),FaultFilter 文档 10.4
+var TriggerSourceNames = map[uint16]string{
+	0x0FF0: "手动触发 _Fix",
+	0x0FF1: "模拟故障触发 _Auto",
+	0x1000: "SL_DSP 预留触发源 16_1",
+	0x1001: "并网发生状态切换故障",
+	0x1002: "离网发生 aPower 离运问题",
+	0x1003: "油机发生状态切换故障",
+	0x5000: "PE_INV 预留触发源 16_1",
+	0x5001: "运行态下逆变故障",
+	0xD000: "MPPT 预留触发源 16_1",
+	0xD001: "运行态下 MPPT 故障",
+	0xD800: "APBOX2.0 预留触发源 16_1",
+	0xD801: "运行态下 APBOX2.0 故障",
+	0xE000: "DIF 预留触发源 16_1",
+	0xE001: "并网发生状态切换故障",
+	0xE002: "离网发生 aPower 离运问题",
+}
+
+// internalErrorCodes 设备内部通信错误判定码(F13),FaultFilter 文档 10.5
+var internalErrorCodes = map[uint64]string{
+	0x3:  "下发命令与子设备回复的命令不一致",
+	0x4:  "数据读取阶段:子设备回复错误",
+	0x5:  "数据读取阶段:回复的数据类别不符",
+	0x6:  "记录传输阶段:起始报文接收错误",
+	0x7:  "记录传输阶段:后续数据未接收完成",
+	0x8:  "记录传输阶段:数据块不连续",
+	0x9:  "记录传输阶段:数据块内序号不连续",
+	0xa:  "记录传输阶段:收到多余的后续数据",
+	0xb:  "记录传输阶段:首包数据过短",
+	0xc:  "记录传输结束阶段:后续数据未接收完成",
+	0xd:  "数据读取阶段:接收超时",
+	0xe:  "记录传输阶段:接收超时",
+	0xf:  "记录传输结束阶段:接收超时",
+	0x10: "其他命令:接收超时",
+	0x11: "记录传输阶段:后续数据接收超时",
+	0x12: "参数读写阶段:读/写类型不一致",
+	0x13: "参数读写阶段:回复的功能码不一致",
+	0x14: "参数读写阶段:回复数据过短",
+	0x15: "参数读写阶段:接收超时",
+	0x16: "参数读写阶段:后续数据未接收完成",
+}
+
+// subDeviceErrorCodes 子设备错误码(F12),FaultFilter 文档 10.6
+var subDeviceErrorCodes = map[uint64]string{
+	0x10: "GeneralReject 诊断服务被拒绝",
+	0x11: "serviceNotSupported 不支持该诊断请求",
+	0x12: "subFunctionNotSupported 不支持该子功能",
+	0x13: "incorrectMessageLengthOrInvalidFormat 报文长度或格式错误",
+	0x14: "ResponseTooLong 响应过长",
+	0x22: "conditionsNotCorrect 条件错误",
+	0x23: "commTimeoutError 通信超时",
+	0x31: "requestOutOfRange 请求超过范围",
+	0x33: "securityAccessDenied 安全访问拒绝",
+	0x35: "invalidKey 密钥错误",
+	0x36: "exceedNumberOfAttempts 尝试次数超限",
+	0x37: "requiredTimeDelayNotExpired 延时未到期",
+	0x70: "UploadDownloadNotAccepted 上传或下载失败",
+	0x71: "TransferDataSuspended 数据传输中断",
+	0x72: "GeneralProgrammingFailure 擦写 Flash 错误",
+	0x73: "WrongBlockSequenceCounter 数据包序号错误",
+	0x78: "ResponsePending 子设备正忙",
+	0x7F: "serviceNotSupportedInActiveSession 当前模式不支持",
+}
+
+var fixedResultOne = map[string]string{
+	"ok":                   "成功",
+	"cancomerr:time out":   "与子设备通信超时",
+	"emserr:can p null":    "未取到目标子设备",
+	"emserr:can data null": "子设备返回数据为空",
+	"emserr:set cmd err":   "下发命令失败",
+	"emserr:other":         "其他错误",
+}
+
+var errorReplyResults = map[string]string{
+	"json unpack err": "消息体不是合法 JSON",
+	"cmd unpack err":  "缺少 cmd 字段",
+	"data unpack err": "缺少 data 字段",
+	"busy":            "设备正在处理其他命令",
+	"pe sn not find":  "pe 设备序列号无法匹配",
+	"cmd no define":   "不支持的命令码",
+	"test fail":       "连通性测试失败",
+}
+
+// DescribeResultOne 子项结果的中文说明,未知值原样返回
+func DescribeResultOne(s string) string {
+	if d, ok := fixedResultOne[s]; ok {
+		return d
+	}
+	if v, ok := strings.CutPrefix(s, "cancomerr:suberrcode="); ok {
+		return describeCode("子设备错误码", v, subDeviceErrorCodes)
+	}
+	if v, ok := strings.CutPrefix(s, "cancomerr:index="); ok {
+		return describeCode("设备内部通信错误", v, internalErrorCodes)
+	}
+	return s
+}
+
+// DescribeReplyResult 379 整体结果的中文说明
+func DescribeReplyResult(s string) string {
+	switch s {
+	case ResultOk:
+		return "全部成功"
+	case ResultError:
+		return "存在失败的子项"
+	}
+	if d, ok := errorReplyResults[s]; ok {
+		return d
+	}
+	return s
+}
+
+func describeCode(prefix, hexStr string, table map[uint64]string) string {
+	code, err := strconv.ParseUint(strings.TrimPrefix(strings.ToLower(hexStr), "0x"), 16, 32)
+	if err != nil {
+		return fmt.Sprintf("%s %s", prefix, hexStr)
+	}
+	if d, ok := table[code]; ok {
+		return fmt.Sprintf("%s 0x%X:%s", prefix, code, d)
+	}
+	return fmt.Sprintf("%s 0x%X", prefix, code)
+}
+
+// TriggerSourceName 触发源名称,表外代码返回空字符串
+func TriggerSourceName(code uint16) string {
+	return TriggerSourceNames[code]
+}

+ 373 - 0
service/function/FaultRecordProtocol/protocol.go

@@ -0,0 +1,373 @@
+// Package faultRecordProtocol 故障录波 MQTT 业务协议编解码(纯函数,不涉及网络、数据库和文件)。
+//
+// 协议依据:docs/fault-record/FaultFilter_MQTT协议文档.md、docs/fault-record/protocol-specification.md
+package faultRecordProtocol
+
+import (
+	"bytes"
+	"encoding/json"
+	"fmt"
+	"strings"
+)
+
+// 外层 cmdType
+const (
+	CmdTypeRequest     uint = 377 // 本工具 → 设备:故障录波操作指令
+	CmdTypeAck         uint = 378 // 设备 → 本工具:指令应答
+	CmdTypeReply       uint = 379 // 设备 → 平台:信息上传(业务回复)
+	CmdTypeReplyAck    uint = 380 // 平台 → 设备:信息上传应答
+	CmdTypeUploadCall  uint = 607 // 平台 → 设备:录波文件上传指令
+	CmdTypeUploadAck   uint = 608 // 设备 → 平台:上传指令应答
+	CmdTypeUploadDone  uint = 609 // 设备 → 平台:文件上传结果
+	CmdTypeUploadDoneA uint = 610 // 平台 → 设备:上传结果应答
+)
+
+// 业务层 cmd
+const (
+	CmdRealtime    = 0 // 获取实时数据
+	CmdSetParam    = 1 // 设置参数
+	CmdGetRecord   = 2 // 获取记录数据
+	CmdQueryParam  = 3 // 查询参数
+	CmdExit        = 4 // 退出
+	CmdTestSuccess = 90
+	CmdTestFail    = 99
+)
+
+// 业务层设备类型
+const (
+	DeviceTypePE    = "pe"
+	DeviceTypeAGate = "aGate"
+)
+
+// 整体结果
+const (
+	ResultOk    = "ok"
+	ResultError = "error"
+	ResultBusy  = "busy"
+)
+
+// NormalizeDeviceType 将页面/HTTP 中的设备类型转换为协议值,不区分大小写
+func NormalizeDeviceType(t string) (string, error) {
+	switch strings.ToLower(t) {
+	case "pe":
+		return DeviceTypePE, nil
+	case "agate":
+		return DeviceTypeAGate, nil
+	default:
+		return "", fmt.Errorf("unknown device type: %s", t)
+	}
+}
+
+/************** 377 请求 **************/
+
+type ParaItem struct {
+	Id     int    `json:"id"`
+	SetVal string `json:"setVal"`
+}
+
+type RequestInfo struct {
+	Sn       string     `json:"sn"`
+	CodeList []int      `json:"codeList,omitempty"` // cmd=0
+	ParaList []ParaItem `json:"paraList,omitempty"` // cmd=1
+	Index    []int      `json:"index,omitempty"`    // cmd=2
+	IdList   []int      `json:"idList,omitempty"`   // cmd=3
+}
+
+type RequestGroup struct {
+	Type string        `json:"type"`
+	Info []RequestInfo `json:"info"`
+}
+
+type RequestData struct {
+	List []RequestGroup `json:"list"`
+}
+
+// Request 377 的 dataArea
+type Request struct {
+	Cmd  int `json:"cmd"`
+	Data any `json:"data"`
+}
+
+func newDeviceRequest(cmd int, deviceType, sn string, info RequestInfo) (Request, error) {
+	t, err := NormalizeDeviceType(deviceType)
+	if err != nil {
+		return Request{}, err
+	}
+	if sn == "" {
+		return Request{}, fmt.Errorf("device sn is empty")
+	}
+	info.Sn = sn
+	return Request{
+		Cmd:  cmd,
+		Data: RequestData{List: []RequestGroup{{Type: t, Info: []RequestInfo{info}}}},
+	}, nil
+}
+
+// NewRealtimeRequest cmd=0,codeList 取值 1~3
+func NewRealtimeRequest(deviceType, sn string, codeList []int) (Request, error) {
+	if len(codeList) == 0 {
+		return Request{}, fmt.Errorf("codeList is empty")
+	}
+	for _, c := range codeList {
+		if c < 1 || c > 3 {
+			return Request{}, fmt.Errorf("invalid code %d, cmd=0 only supports 1~3", c)
+		}
+	}
+	return newDeviceRequest(CmdRealtime, deviceType, sn, RequestInfo{CodeList: codeList})
+}
+
+// NewSetParamRequest cmd=1,setVal 按功能码校验
+func NewSetParamRequest(deviceType, sn string, paraList []ParaItem) (Request, error) {
+	if len(paraList) == 0 {
+		return Request{}, fmt.Errorf("paraList is empty")
+	}
+	for i, p := range paraList {
+		if err := ValidateSetVal(p.Id, p.SetVal); err != nil {
+			return Request{}, fmt.Errorf("paraList[%d]: %w", i, err)
+		}
+		paraList[i].SetVal = strings.ToUpper(p.SetVal)
+	}
+	return newDeviceRequest(CmdSetParam, deviceType, sn, RequestInfo{ParaList: paraList})
+}
+
+// MaxRecordsPerRequest 一次 cmd=2 最多获取的记录数
+const MaxRecordsPerRequest = 32
+
+// NewGetRecordRequest cmd=2,可一次获取多条记录,设备打成一个包上传
+func NewGetRecordRequest(deviceType, sn string, indexes []int) (Request, error) {
+	if len(indexes) == 0 {
+		return Request{}, fmt.Errorf("record index is empty")
+	}
+	if len(indexes) > MaxRecordsPerRequest {
+		return Request{}, fmt.Errorf("too many records: %d (max %d)", len(indexes), MaxRecordsPerRequest)
+	}
+	seen := map[int]bool{}
+	for _, i := range indexes {
+		if i < 1 || i > 255 {
+			return Request{}, fmt.Errorf("invalid record index %d", i)
+		}
+		if seen[i] {
+			return Request{}, fmt.Errorf("duplicate record index %d", i)
+		}
+		seen[i] = true
+	}
+	return newDeviceRequest(CmdGetRecord, deviceType, sn, RequestInfo{Index: indexes})
+}
+
+// NewQueryParamRequest cmd=3
+func NewQueryParamRequest(deviceType, sn string, idList []int) (Request, error) {
+	if len(idList) == 0 {
+		return Request{}, fmt.Errorf("idList is empty")
+	}
+	for _, id := range idList {
+		if _, ok := FunctionCodeNames[id]; !ok {
+			return Request{}, fmt.Errorf("invalid function code %d", id)
+		}
+	}
+	return newDeviceRequest(CmdQueryParam, deviceType, sn, RequestInfo{IdList: idList})
+}
+
+// NewExitRequest cmd=4,结束设备当前处理
+func NewExitRequest() Request {
+	return Request{Cmd: CmdExit, Data: struct{}{}}
+}
+
+/************** 外层解析辅助 **************/
+
+// envelopeHead 只取路由需要的外层字段,dataArea 保留原始字节
+type envelopeHead struct {
+	CmdType  uint            `json:"cmdType"`
+	EquipNo  string          `json:"equipNo"`
+	DataArea json.RawMessage `json:"dataArea"`
+}
+
+// SplitEnvelope 从完整报文中取出 cmdType、equipNo 和原始 dataArea
+func SplitEnvelope(raw []byte) (cmdType uint, equipNo string, dataArea json.RawMessage, err error) {
+	var h envelopeHead
+	if err = json.Unmarshal(raw, &h); err != nil {
+		return 0, "", nil, err
+	}
+	return h.CmdType, h.EquipNo, h.DataArea, nil
+}
+
+/************** 378 指令应答 **************/
+
+type Ack struct {
+	Cmd    int `json:"cmd"`
+	Result int `json:"result"` // 样本成功为 0
+}
+
+func ParseAck(dataArea []byte) (Ack, error) {
+	var a Ack
+	err := json.Unmarshal(dataArea, &a)
+	return a, err
+}
+
+func (a Ack) Ok() bool { return a.Result == 0 }
+
+/************** 379 业务回复 **************/
+
+type HexItem struct {
+	Code      int    `json:"code"`
+	HexData   string `json:"hexData"`
+	ResultOne string `json:"resultOne"`
+}
+
+type IndexItem struct {
+	Index     int    `json:"index"` // 数据类型码:4=故障数据,5=波形数据
+	ResultOne string `json:"resultOne"`
+}
+
+type IdItem struct {
+	Id        int    `json:"id"`
+	IdData    string `json:"idData"`
+	ResultOne string `json:"resultOne"`
+}
+
+type SetResultItem struct {
+	Id        int    `json:"id"`
+	ResultOne string `json:"resultOne"`
+}
+
+type ReplyInfo struct {
+	Sn            string          `json:"sn"`
+	HexList       []HexItem       `json:"hexList,omitempty"`
+	IndexInfo     []IndexItem     `json:"indexInfo,omitempty"`
+	IdInfo        []IdItem        `json:"idInfo,omitempty"`
+	SetResultList []SetResultItem `json:"setResultList,omitempty"`
+}
+
+type ReplyGroup struct {
+	Type string      `json:"type"`
+	Info []ReplyInfo `json:"info"`
+}
+
+// Reply 379 的 dataArea(FaultFilter 回复的 data 被展开到 dataArea 顶层)
+type Reply struct {
+	Cmd      int          `json:"cmd"`
+	Result   string       `json:"result"`
+	Filename string       `json:"filename,omitempty"`
+	List     []ReplyGroup `json:"list,omitempty"`
+}
+
+func ParseReply(dataArea []byte) (Reply, error) {
+	var r Reply
+	err := json.Unmarshal(dataArea, &r)
+	return r, err
+}
+
+// Ok 全部子项成功
+func (r Reply) Ok() bool { return r.Result == ResultOk }
+
+// Busy 设备正在处理其他命令
+func (r Reply) Busy() bool { return r.Result == ResultBusy }
+
+// IsErrorReply 命令未进入业务处理(busy、格式错误、sn 不存在等),此时没有 list
+func (r Reply) IsErrorReply() bool { return r.Result != ResultOk && r.Result != ResultError }
+
+// FindInfo 按设备类型和 SN 查找回复项;aGate 系统内唯一,不比较 SN
+func (r Reply) FindInfo(deviceType, sn string) (*ReplyInfo, bool) {
+	for gi := range r.List {
+		g := &r.List[gi]
+		if g.Type != deviceType {
+			continue
+		}
+		for ii := range g.Info {
+			if deviceType == DeviceTypeAGate || g.Info[ii].Sn == sn {
+				return &g.Info[ii], true
+			}
+		}
+	}
+	return nil, false
+}
+
+/************** 607~610 文件上传 **************/
+
+type UploadCall struct {
+	FaultRecordID int64  `json:"faultRecordID"`
+	FileName      string `json:"fileName"`
+	Url           string `json:"url"`
+	LogTime       string `json:"logTime"`
+}
+
+func ParseUploadCall(dataArea []byte) (UploadCall, error) {
+	var u UploadCall
+	err := json.Unmarshal(dataArea, &u)
+	return u, err
+}
+
+// UploadAck 608 上传指令应答
+type UploadAck struct {
+	Result   int    `json:"result"`
+	Id       int64  `json:"id"` // 平台召唤记录 ID(即 607.faultRecordID)
+	Filename string `json:"filename"`
+}
+
+// ParseUploadAck 解析 608。
+// 旧版本设备把 id 字段名误写为 result(同一对象出现两个 result,第二个为 id),后续版本会修复;
+// 解析前先把第二个 result 键改名为 id,新旧版本都能按同一结构解析。
+func ParseUploadAck(dataArea []byte) (UploadAck, error) {
+	var a UploadAck
+	fixed, err := renameDuplicateKey(dataArea, "result", "id")
+	if err != nil {
+		return a, fmt.Errorf("608 dataArea: %w", err)
+	}
+	err = json.Unmarshal(fixed, &a)
+	return a, err
+}
+
+// renameDuplicateKey 将对象顶层第二次出现的 key 改名为 newKey;没有重复时原样返回
+func renameDuplicateKey(obj []byte, key, newKey string) ([]byte, error) {
+	dec := json.NewDecoder(bytes.NewReader(obj))
+	if t, err := dec.Token(); err != nil || t != json.Delim('{') {
+		return nil, fmt.Errorf("not a JSON object")
+	}
+	quoted := []byte(`"` + key + `"`)
+	seen := false
+	for dec.More() {
+		kt, err := dec.Token()
+		if err != nil {
+			return nil, err
+		}
+		end := int(dec.InputOffset())
+		if k, _ := kt.(string); k == key {
+			start := end - len(quoted)
+			if seen && start >= 0 && bytes.Equal(obj[start:end], quoted) {
+				out := make([]byte, 0, len(obj))
+				out = append(out, obj[:start]...)
+				out = append(out, `"`+newKey+`"`...)
+				return append(out, obj[end:]...), nil
+			}
+			seen = true
+		}
+		var skip json.RawMessage
+		if err := dec.Decode(&skip); err != nil {
+			return nil, err
+		}
+	}
+	return obj, nil
+}
+
+// UploadDone 609
+type UploadDone struct {
+	Result   int    `json:"result"` // 样本成功为 0
+	FileName string `json:"fileName"`
+}
+
+func ParseUploadDone(dataArea []byte) (UploadDone, error) {
+	var u UploadDone
+	err := json.Unmarshal(dataArea, &u)
+	return u, err
+}
+
+func (u UploadDone) Ok() bool { return u.Result == 0 }
+
+// UploadFileName 由 379.filename 推得上传文件名
+func UploadFileName(batch string) string {
+	return BatchFromUploadFileName(batch) + ".tar.bz2"
+}
+
+// BatchFromUploadFileName 由 609.fileName 去掉扩展名得到批次名
+func BatchFromUploadFileName(fileName string) string {
+	return strings.TrimSuffix(fileName, ".tar.bz2")
+}

+ 193 - 0
service/function/FaultRecordProtocol/protocol_test.go

@@ -0,0 +1,193 @@
+package faultRecordProtocol
+
+import (
+	"encoding/csv"
+	"encoding/json"
+	"os"
+	"strings"
+	"testing"
+)
+
+const sampleLog = "../../../docs/fault-record/CloudLog_2026-10-14_104644.csv"
+
+// loadSampleMessages 读取 2026-10-14 样本日志,按 cmdType 分组返回原始报文
+func loadSampleMessages(t *testing.T) map[uint][][]byte {
+	t.Helper()
+	f, err := os.Open(sampleLog)
+	if err != nil {
+		t.Skipf("sample log not found: %v", err)
+	}
+	defer f.Close()
+	rows, err := csv.NewReader(f).ReadAll()
+	if err != nil {
+		t.Fatal(err)
+	}
+	msgs := map[uint][][]byte{}
+	for _, row := range rows[1:] {
+		raw := []byte(row[7]) // logData
+		cmdType, _, _, err := SplitEnvelope(raw)
+		if err != nil {
+			t.Fatalf("split envelope: %v", err)
+		}
+		msgs[cmdType] = append(msgs[cmdType], raw)
+	}
+	return msgs
+}
+
+func TestSampleMessages(t *testing.T) {
+	msgs := loadSampleMessages(t)
+
+	for _, raw := range msgs[CmdTypeAck] {
+		_, _, da, _ := SplitEnvelope(raw)
+		ack, err := ParseAck(da)
+		if err != nil || !ack.Ok() {
+			t.Errorf("378 parse: %+v %v", ack, err)
+		}
+	}
+
+	var gotDir, gotRecord bool
+	for _, raw := range msgs[CmdTypeReply] {
+		_, equipNo, da, _ := SplitEnvelope(raw)
+		r, err := ParseReply(da)
+		if err != nil || !r.Ok() {
+			t.Fatalf("379 parse: %+v %v", r, err)
+		}
+		info, ok := r.FindInfo(DeviceTypeAGate, equipNo)
+		if !ok {
+			t.Fatal("379 info not found")
+		}
+		switch r.Cmd {
+		case CmdRealtime:
+			gotDir = len(info.HexList) == 1 && info.HexList[0].Code == 3 && len(info.HexList[0].HexData) == 422
+		case CmdGetRecord:
+			gotRecord = r.Filename != "" && len(info.IndexInfo) == 2 && info.IndexInfo[0].Index == 4 && info.IndexInfo[1].Index == 5
+		}
+	}
+	if !gotDir || !gotRecord {
+		t.Errorf("379 content: dir=%v record=%v", gotDir, gotRecord)
+	}
+
+	_, _, da, _ := SplitEnvelope(msgs[CmdTypeUploadCall][0])
+	call, err := ParseUploadCall(da)
+	if err != nil || call.FaultRecordID == 0 || call.FileName == "" {
+		t.Errorf("607 parse: %+v %v", call, err)
+	}
+
+	// 旧版本 608 的第二个 result 实为 id,应等于同批次 607.faultRecordID
+	ids := map[string]int64{}
+	for _, raw := range msgs[CmdTypeUploadCall] {
+		_, _, da, _ := SplitEnvelope(raw)
+		c, _ := ParseUploadCall(da)
+		ids[c.FileName] = c.FaultRecordID
+	}
+	for _, raw := range msgs[CmdTypeUploadAck] {
+		_, _, da, _ := SplitEnvelope(raw)
+		ack, err := ParseUploadAck(da)
+		if err != nil || ack.Result != 0 || ack.Filename == "" || ack.Id == 0 || ack.Id != ids[ack.Filename] {
+			t.Errorf("608 parse: %+v %v", ack, err)
+		}
+	}
+
+	for _, raw := range msgs[CmdTypeUploadDone] {
+		_, _, da, _ := SplitEnvelope(raw)
+		done, err := ParseUploadDone(da)
+		if err != nil || !done.Ok() || BatchFromUploadFileName(done.FileName) == done.FileName {
+			t.Errorf("609 parse: %+v %v", done, err)
+		}
+	}
+}
+
+func TestBuildRequests(t *testing.T) {
+	req, err := NewGetRecordRequest("agate", "AFCI_TEST_0713", []int{7})
+	if err != nil {
+		t.Fatal(err)
+	}
+	b, _ := json.Marshal(req)
+	want := `{"cmd":2,"data":{"list":[{"type":"aGate","info":[{"sn":"AFCI_TEST_0713","index":[7]}]}]}}`
+	if string(b) != want {
+		t.Errorf("cmd=2 request:\n got %s\nwant %s", b, want)
+	}
+
+	b, _ = json.Marshal(NewExitRequest())
+	if string(b) != `{"cmd":4,"data":{}}` {
+		t.Errorf("cmd=4 request: %s", b)
+	}
+
+	if _, err := NewRealtimeRequest("pe", "X", []int{4}); err == nil {
+		t.Error("code=4 should be rejected for cmd=0")
+	}
+	if _, err := NewGetRecordRequest("mppt", "X", []int{1}); err == nil {
+		t.Error("unknown device type should be rejected")
+	}
+	req, _ = NewGetRecordRequest("aGate", "X", []int{7, 9, 3})
+	if b, _ = json.Marshal(req); !strings.Contains(string(b), `"index":[7,9,3]`) {
+		t.Errorf("multi-record request: %s", b)
+	}
+	for _, bad := range [][]int{{}, {7, 7}, {0}, {256}} {
+		if _, err := NewGetRecordRequest("aGate", "X", bad); err == nil {
+			t.Errorf("indexes %v should be rejected", bad)
+		}
+	}
+}
+
+func TestSetVal(t *testing.T) {
+	ok := []ParaItem{{1, ""}, {2, "021001100A"}, {4, "07EA0A100C0000"}, {5, "1194"}, {6, "010100015984"}, {7, "00"}}
+	for _, p := range ok {
+		if err := ValidateSetVal(p.Id, p.SetVal); err != nil {
+			t.Errorf("id=%d %q: %v", p.Id, p.SetVal, err)
+		}
+	}
+	bad := []ParaItem{{1, "00"}, {2, "0210"}, {5, "11"}, {6, "0101"}, {7, "02"}, {8, ""}, {5, "XYZ1"}}
+	for _, p := range bad {
+		if err := ValidateSetVal(p.Id, p.SetVal); err == nil {
+			t.Errorf("id=%d %q should be invalid", p.Id, p.SetVal)
+		}
+	}
+
+	if v := DecodeIdData(5, "1194").(map[string]any)["frequencyHz"]; v != uint16(4500) {
+		t.Errorf("id=5 decode: %v", v)
+	}
+	c := DecodeIdData(6, "04030003FD34").(ConfigChannel)
+	if c.Index != 4 || c.Mode != 0 || c.TypeName != "Float32" || c.Address != 0x3FD34 {
+		t.Errorf("id=6 decode: %+v", c)
+	}
+}
+
+func TestDescribeResultOne(t *testing.T) {
+	cases := map[string]string{
+		"ok":                      "成功",
+		"cancomerr:suberrcode=31": "子设备错误码 0x31:requestOutOfRange 请求超过范围",
+		"cancomerr:index=e":       "设备内部通信错误 0xE:记录传输阶段:接收超时",
+		"something new":           "something new",
+		"cancomerr:suberrcode=zz": "子设备错误码 zz",
+		"emserr:can p null":       "未取到目标子设备",
+	}
+	for in, want := range cases {
+		if got := DescribeResultOne(in); got != want {
+			t.Errorf("DescribeResultOne(%q) = %q, want %q", in, got, want)
+		}
+	}
+}
+
+func TestParseUploadAckVersions(t *testing.T) {
+	cases := []struct {
+		name string
+		in   string
+		want UploadAck
+	}{
+		{"旧版本:第二个 result 为 id", `{"result":0,"filename":"B","result":5226}`, UploadAck{Result: 0, Id: 5226, Filename: "B"}},
+		{"旧版本:带空白", `{ "result" : 1 , "filename":"B", "result" : 7 }`, UploadAck{Result: 1, Id: 7, Filename: "B"}},
+		{"修复后版本", `{"result":0,"filename":"B","id":5226}`, UploadAck{Result: 0, Id: 5226, Filename: "B"}},
+		{"值中含 result 字样不受影响", `{"result":0,"filename":"result","result":9}`, UploadAck{Result: 0, Id: 9, Filename: "result"}},
+		{"只有一个 result", `{"result":2,"filename":"B"}`, UploadAck{Result: 2, Filename: "B"}},
+	}
+	for _, c := range cases {
+		got, err := ParseUploadAck([]byte(c.in))
+		if err != nil || got != c.want {
+			t.Errorf("%s: got %+v %v, want %+v", c.name, got, err, c.want)
+		}
+	}
+	if _, err := ParseUploadAck([]byte(`[1]`)); err == nil {
+		t.Error("non-object should fail")
+	}
+}

+ 148 - 0
service/function/FaultRecordProtocol/setval.go

@@ -0,0 +1,148 @@
+package faultRecordProtocol
+
+import (
+	"encoding/binary"
+	"encoding/hex"
+	"fmt"
+	"strings"
+)
+
+// 可配采样通道地址类型,FaultFilter 文档 5.4.1 id=6;名称统一首字母大写
+var ChannelAddrTypeNames = map[uint8]string{
+	0x0: "Empty",
+	0x1: "Uint16",
+	0x2: "Int16",
+	0x3: "Float32",
+	0x4: "Uint32_HWord",
+	0x5: "Uint32_LWord",
+	0x6: "Uint32",
+}
+
+// DecodeHex 解码十六进制字符串(允许空格)
+func DecodeHex(s string) ([]byte, error) {
+	s = strings.ReplaceAll(strings.TrimSpace(s), " ", "")
+	if len(s)%2 != 0 {
+		return nil, fmt.Errorf("hex length must be even")
+	}
+	return hex.DecodeString(s)
+}
+
+// ValidateSetVal 按功能码校验 setVal 的字节长度和取值
+func ValidateSetVal(id int, setVal string) error {
+	b, err := DecodeHex(setVal)
+	if err != nil {
+		return fmt.Errorf("id=%d setVal invalid: %w", id, err)
+	}
+	switch id {
+	case 1, 3:
+		if len(b) != 0 {
+			return fmt.Errorf("id=%d takes no parameter", id)
+		}
+	case 2:
+		if len(b) < 1 || (len(b)-1)%2 != 0 {
+			return fmt.Errorf("id=2 setVal must be 1 byte count + n × 2 bytes")
+		}
+	case 4:
+		if len(b) != 7 {
+			return fmt.Errorf("id=4 setVal must be 7 bytes")
+		}
+	case 5:
+		if len(b) != 2 {
+			return fmt.Errorf("id=5 setVal must be 2 bytes")
+		}
+	case 6:
+		if len(b) != 6 {
+			return fmt.Errorf("id=6 setVal must be 6 bytes")
+		}
+	case 7:
+		if len(b) != 1 || b[0] > 1 {
+			return fmt.Errorf("id=7 setVal must be 00 or 01")
+		}
+	default:
+		return fmt.Errorf("invalid function code %d", id)
+	}
+	return nil
+}
+
+// ConfigChannel id=6 可配采样通道配置
+type ConfigChannel struct {
+	Index    uint8  `json:"index"`
+	Mode     uint8  `json:"mode"`     // 0=默认方式(地址),1=代码方式
+	AddrType uint8  `json:"addrType"` // 默认方式下的地址类型
+	TypeName string `json:"typeName,omitempty"`
+	Address  uint32 `json:"address,omitempty"` // 默认方式
+	Code     uint16 `json:"code,omitempty"`    // 代码方式
+}
+
+// DecodeIdData 解读 cmd=3 返回的 idData(结构参照对应功能码的 setVal)。
+// 无法解读时返回 nil,调用方只显示原始值。
+func DecodeIdData(id int, idData string) any {
+	b, err := DecodeHex(idData)
+	if err != nil {
+		return nil
+	}
+	switch id {
+	case 2:
+		if len(b) < 1 || (len(b)-1)%2 != 0 {
+			return nil
+		}
+		channels := make([]string, 0, (len(b)-1)/2)
+		for i := 1; i+1 < len(b); i += 2 {
+			channels = append(channels, fmt.Sprintf("0x%04X", binary.BigEndian.Uint16(b[i:])))
+		}
+		return map[string]any{"count": b[0], "channels": channels}
+	case 4:
+		if len(b) != 7 {
+			return nil
+		}
+		return map[string]any{"deviceTime": ParseDeviceTime(b).String()}
+	case 5:
+		if len(b) != 2 {
+			return nil
+		}
+		return map[string]any{"frequencyHz": binary.BigEndian.Uint16(b)}
+	case 6:
+		if len(b) != 6 {
+			return nil
+		}
+		c := ConfigChannel{Index: b[0], Mode: b[1] >> 4, AddrType: b[1] & 0x0F}
+		if c.Mode == 0 {
+			c.TypeName = ChannelAddrTypeNames[c.AddrType]
+			c.Address = binary.BigEndian.Uint32(b[2:])
+		} else {
+			c.Code = binary.BigEndian.Uint16(b[2:])
+		}
+		return c
+	case 7:
+		if len(b) != 1 {
+			return nil
+		}
+		return map[string]any{"enabled": b[0] == 1}
+	}
+	return nil
+}
+
+// DeviceTime 设备时间(7 字节:年 2B 大端、月、日、时、分、秒),按设备时间原样表示,不做时区换算
+type DeviceTime struct {
+	Year  uint16 `json:"year"`
+	Month uint8  `json:"month"`
+	Day   uint8  `json:"day"`
+	Hour  uint8  `json:"hour"`
+	Min   uint8  `json:"min"`
+	Sec   uint8  `json:"sec"`
+}
+
+func ParseDeviceTime(b []byte) DeviceTime {
+	return DeviceTime{
+		Year:  binary.BigEndian.Uint16(b[0:2]),
+		Month: b[2],
+		Day:   b[3],
+		Hour:  b[4],
+		Min:   b[5],
+		Sec:   b[6],
+	}
+}
+
+func (t DeviceTime) String() string {
+	return fmt.Sprintf("%04d-%02d-%02d %02d:%02d:%02d", t.Year, t.Month, t.Day, t.Hour, t.Min, t.Sec)
+}

+ 11 - 3
service/main.go

@@ -10,6 +10,7 @@ import (
 	"fwh-toolbox-server/repositories/api/fwh_pro"
 	"fwh-toolbox-server/repositories/api/fwh_test"
 	"fwh-toolbox-server/task"
+	"fwh-toolbox-server/task/queue/faultRecordQueue"
 	"fwh-toolbox-server/utils/casdoor"
 	"fwh-toolbox-server/utils/minio"
 	"log"
@@ -94,9 +95,16 @@ func main() {
 	// 初始化Casdoor
 	casdoor.InitAuthConfig()
 
-	// 初始化FWH MQTT客户端
-	repositories.FwhMqttInit(&cfg.FwhMqtt)
-	// repositories.FwhMqttFaultRecordChargeChannel(context.Background(), "10060005A03X25160385")
+	// 初始化FWH MQTT客户端(正式服、测试服各一个实例,单个环境失败不影响服务启动)
+	if err := repositories.FwhMqttInit(repositories.FwhMqttEnvProd, &cfg.FwhMqtt); err != nil {
+		zap.L().Error("FWH MQTT init failed.", zap.String("env", "prod"), zap.String("err", err.Error()))
+	}
+	if err := repositories.FwhMqttInit(repositories.FwhMqttEnvTest, &cfg.FwhTestMqtt); err != nil {
+		zap.L().Error("FWH MQTT init failed.", zap.String("env", "test"), zap.String("err", err.Error()))
+	}
+
+	// 恢复服务重启前未完成的故障录波操作(依赖任务系统和 MQTT 已初始化)
+	faultRecordQueue.RecoverActive(context.Background())
 
 	app := fiber.New()
 	app.Use(func(c *fiber.Ctx) error {

+ 27 - 0
service/repositories/api/fwh_pro/fwh_faultRecordUpload.go

@@ -0,0 +1,27 @@
+package fwh_pro
+
+import (
+	"fmt"
+	"net/url"
+)
+
+// FaultRecordUploadFileURL 上传文件的完整下载地址(不需要 token,可直接在浏览器打开)
+func FaultRecordUploadFileURL(equipNo, fileName string) string {
+	return getFullEnergyUrl("/hes-upload/v1/file/download/logger/" + url.PathEscape(equipNo) + "/" + url.PathEscape(fileName))
+}
+
+// DownloadFaultRecordUploadFile 下载设备经 607~610 上传的故障录波文件(不需要 token)。
+// 地址:https://<域名>/hes-upload/v1/file/download/logger/<equipNo>/<fileName>
+func DownloadFaultRecordUploadFile(equipNo, fileName string) ([]byte, error) {
+	res, err := FwhHttpGetWithoutToken(FaultRecordUploadFileURL(equipNo, fileName), url.Values{})
+	if err != nil {
+		return nil, err
+	}
+	if res.StatusCode != 200 {
+		return nil, fmt.Errorf("download fault record file failed: %s", res.Status)
+	}
+	if len(res.Body) == 0 {
+		return nil, fmt.Errorf("download fault record file failed: empty body")
+	}
+	return res.Body, nil
+}

+ 27 - 0
service/repositories/api/fwh_test/fwh_faultRecordUpload.go

@@ -0,0 +1,27 @@
+package fwh_test
+
+import (
+	"fmt"
+	"net/url"
+)
+
+// FaultRecordUploadFileURL 上传文件的完整下载地址(不需要 token,可直接在浏览器打开)
+func FaultRecordUploadFileURL(equipNo, fileName string) string {
+	return getFullEnergyUrl("/hes-upload/v1/file/download/logger/" + url.PathEscape(equipNo) + "/" + url.PathEscape(fileName))
+}
+
+// DownloadFaultRecordUploadFile 下载设备经 607~610 上传的故障录波文件(不需要 token)。
+// 地址:https://<域名>/hes-upload/v1/file/download/logger/<equipNo>/<fileName>
+func DownloadFaultRecordUploadFile(equipNo, fileName string) ([]byte, error) {
+	res, err := FwhHttpGetWithoutToken(FaultRecordUploadFileURL(equipNo, fileName), url.Values{})
+	if err != nil {
+		return nil, err
+	}
+	if res.StatusCode != 200 {
+		return nil, fmt.Errorf("download fault record file failed: %s", res.Status)
+	}
+	if len(res.Body) == 0 {
+		return nil, fmt.Errorf("download fault record file failed: empty body")
+	}
+	return res.Body, nil
+}

+ 39 - 0
service/repositories/minio/faultRecordRepo.go

@@ -0,0 +1,39 @@
+package minio
+
+import (
+	"bytes"
+	"context"
+	"fmt"
+	"io"
+	"path"
+	"strings"
+
+	minioUtil "fwh-toolbox-server/utils/minio"
+)
+
+const faultRecordPrefix = "fault-record/"
+
+// FaultRecordObjectKey fault-record/<env>/<gatewaySn>/<fileName>
+func FaultRecordObjectKey(env, gatewaySn, fileName string) (string, error) {
+	for _, s := range []string{env, gatewaySn, fileName} {
+		if s == "" || strings.ContainsAny(s, "/\\") || s == "." || s == ".." {
+			return "", fmt.Errorf("invalid object key segment %q", s)
+		}
+	}
+	return faultRecordPrefix + path.Join(env, gatewaySn, fileName), nil
+}
+
+// PutFaultRecordFile 保存下载到的原始录波压缩包
+func PutFaultRecordFile(ctx context.Context, key string, data []byte) error {
+	return minioUtil.UploadFromReader(bytes.NewReader(data), int64(len(data)), key, "application/octet-stream")
+}
+
+// ReadFaultRecordFile 读取原始录波压缩包
+func ReadFaultRecordFile(ctx context.Context, key string) ([]byte, error) {
+	obj, err := minioUtil.GetObject(key)
+	if err != nil {
+		return nil, err
+	}
+	defer obj.Close()
+	return io.ReadAll(obj)
+}

+ 182 - 0
service/repositories/mongo/faultRecordRepo.go

@@ -0,0 +1,182 @@
+package mongo
+
+import (
+	"context"
+	"errors"
+	"strings"
+	"time"
+
+	"fwh-toolbox-server/database"
+	"fwh-toolbox-server/domain"
+
+	"go.mongodb.org/mongo-driver/bson"
+	"go.mongodb.org/mongo-driver/bson/primitive"
+	"go.mongodb.org/mongo-driver/mongo"
+	"go.mongodb.org/mongo-driver/mongo/options"
+)
+
+const CollectionName_FaultRecordOperation = "FaultRecordOperation"
+
+var ErrFaultRecordOpNotFound = errors.New("fault record operation not found")
+
+func faultRecordOps() *mongo.Collection {
+	return database.GetCollection(CollectionName_FaultRecordOperation)
+}
+
+func InitFaultRecordIndexes(ctx context.Context) error {
+	_, err := faultRecordOps().Indexes().CreateMany(ctx, []mongo.IndexModel{
+		{Keys: bson.D{{Key: "env", Value: 1}, {Key: "gatewaySn", Value: 1}, {Key: "createdAt", Value: -1}}},
+		{Keys: bson.D{{Key: "status", Value: 1}}},
+		{Keys: bson.D{{Key: "createdAt", Value: -1}}},
+	})
+	return err
+}
+
+func InsertFaultRecordOp(ctx context.Context, op *domain.FaultRecordOperation) error {
+	now := time.Now()
+	if op.ID.IsZero() {
+		op.ID = primitive.NewObjectID()
+	}
+	op.CreatedAt, op.UpdatedAt = now, now
+	if op.StageTimes == nil {
+		op.StageTimes = map[string]time.Time{}
+	}
+	op.StageTimes[string(op.Status)] = now
+	if op.Messages == nil {
+		op.Messages = []domain.FaultRecordOpMessage{}
+	}
+	_, err := faultRecordOps().InsertOne(ctx, op)
+	return err
+}
+
+func GetFaultRecordOp(ctx context.Context, id primitive.ObjectID) (*domain.FaultRecordOperation, error) {
+	var op domain.FaultRecordOperation
+	err := faultRecordOps().FindOne(ctx, bson.M{"_id": id}).Decode(&op)
+	if errors.Is(err, mongo.ErrNoDocuments) {
+		return nil, ErrFaultRecordOpNotFound
+	}
+	if err != nil {
+		return nil, err
+	}
+	return &op, nil
+}
+
+// UpdateFaultRecordOpStatus 更新状态并记录阶段时间;extra 为同时写入的其他字段。
+// 只更新尚未终止的操作,返回是否更新成功(已终止时返回 false)。
+func UpdateFaultRecordOpStatus(ctx context.Context, id primitive.ObjectID, status domain.FaultRecordOpStatus, extra bson.M) (bool, error) {
+	now := time.Now()
+	set := bson.M{"status": status, "updatedAt": now, "stageTimes." + string(status): now}
+	for k, v := range extra {
+		set[k] = v
+	}
+	res, err := faultRecordOps().UpdateOne(ctx,
+		bson.M{"_id": id, "status": bson.M{"$nin": domain.FaultRecordTerminalStatuses()}},
+		bson.M{"$set": set})
+	if err != nil {
+		return false, err
+	}
+	return res.ModifiedCount > 0, nil
+}
+
+// SetFaultRecordOpFields 更新字段,不改变状态
+func SetFaultRecordOpFields(ctx context.Context, id primitive.ObjectID, fields bson.M) error {
+	fields["updatedAt"] = time.Now()
+	_, err := faultRecordOps().UpdateOne(ctx, bson.M{"_id": id}, bson.M{"$set": fields})
+	return err
+}
+
+func AppendFaultRecordOpMessage(ctx context.Context, id primitive.ObjectID, msg domain.FaultRecordOpMessage) error {
+	_, err := faultRecordOps().UpdateOne(ctx, bson.M{"_id": id},
+		bson.M{"$push": bson.M{"messages": msg}, "$set": bson.M{"updatedAt": time.Now()}})
+	return err
+}
+
+// RequestFaultRecordOpCancel 标记取消请求,仅对未终止的操作生效
+func RequestFaultRecordOpCancel(ctx context.Context, id primitive.ObjectID) (bool, error) {
+	res, err := faultRecordOps().UpdateOne(ctx,
+		bson.M{"_id": id, "status": bson.M{"$nin": domain.FaultRecordTerminalStatuses()}},
+		bson.M{"$set": bson.M{"cancelRequested": true, "updatedAt": time.Now()}})
+	if err != nil {
+		return false, err
+	}
+	return res.MatchedCount > 0, nil
+}
+
+// ReopenFaultRecordOpForDownloadRetry 将“上传已完成但下载失败/超时”的操作重新置为 uploaded,供重试下载。
+// 条件在同一次更新中校验,避免并发重复触发;返回是否成功重开。
+func ReopenFaultRecordOpForDownloadRetry(ctx context.Context, id primitive.ObjectID) (bool, error) {
+	now := time.Now()
+	res, err := faultRecordOps().UpdateOne(ctx,
+		bson.M{
+			"_id":                 id,
+			"kind":                domain.FaultRecordOpRecordFetch,
+			"status":              bson.M{"$in": []domain.FaultRecordOpStatus{domain.FaultRecordStatusDownloadFailed, domain.FaultRecordStatusTimeout}},
+			"stageTimes.uploaded": bson.M{"$exists": true},
+			"fileName":            bson.M{"$nin": []any{"", nil}},
+		},
+		bson.M{
+			"$set":   bson.M{"status": domain.FaultRecordStatusUploaded, "downloadRetryAt": now, "cancelRequested": false, "updatedAt": now},
+			"$unset": bson.M{"error": "", "timeoutStage": ""},
+			"$inc":   bson.M{"downloadRetryCount": 1},
+		})
+	if err != nil {
+		return false, err
+	}
+	return res.ModifiedCount > 0, nil
+}
+
+// FaultRecordOpFilter 操作历史查询条件
+type FaultRecordOpFilter struct {
+	Env       string
+	GatewaySn string
+	Kind      string // 多个用逗号分隔
+}
+
+// ListFaultRecordOps 按创建时间倒序分页,不返回报文原文
+func ListFaultRecordOps(ctx context.Context, f FaultRecordOpFilter, page, size int64) ([]domain.FaultRecordOperation, int64, error) {
+	filter := bson.M{}
+	if f.Env != "" {
+		filter["env"] = f.Env
+	}
+	if f.GatewaySn != "" {
+		filter["gatewaySn"] = f.GatewaySn
+	}
+	if f.Kind != "" {
+		if kinds := strings.Split(f.Kind, ","); len(kinds) > 1 {
+			filter["kind"] = bson.M{"$in": kinds}
+		} else {
+			filter["kind"] = f.Kind
+		}
+	}
+	total, err := faultRecordOps().CountDocuments(ctx, filter)
+	if err != nil {
+		return nil, 0, err
+	}
+	opts := options.Find().
+		SetSort(bson.D{{Key: "createdAt", Value: -1}}).
+		SetSkip((page - 1) * size).
+		SetLimit(size).
+		SetProjection(bson.M{"messages": 0})
+	cur, err := faultRecordOps().Find(ctx, filter, opts)
+	if err != nil {
+		return nil, 0, err
+	}
+	var list []domain.FaultRecordOperation
+	if err := cur.All(ctx, &list); err != nil {
+		return nil, 0, err
+	}
+	return list, total, nil
+}
+
+// ListActiveFaultRecordOps 全部未终止的操作,用于启动时恢复
+func ListActiveFaultRecordOps(ctx context.Context) ([]domain.FaultRecordOperation, error) {
+	cur, err := faultRecordOps().Find(ctx,
+		bson.M{"status": bson.M{"$nin": domain.FaultRecordTerminalStatuses()}},
+		options.Find().SetProjection(bson.M{"messages": 0}))
+	if err != nil {
+		return nil, err
+	}
+	var list []domain.FaultRecordOperation
+	err = cur.All(ctx, &list)
+	return list, err
+}

+ 3 - 0
service/repositories/mongo/init.go

@@ -7,5 +7,8 @@ func Init(ctx context.Context) error {
 	if err := InitDataDownloadIndexes(ctx); err != nil {
 		return err
 	}
+	if err := InitFaultRecordIndexes(ctx); err != nil {
+		return err
+	}
 	return InitSimCardStatisticsIndexes(ctx)
 }

+ 312 - 158
service/repositories/mqtt/fwh_mqtt/fwh_mqtt_client.go

@@ -10,6 +10,7 @@ import (
 	"fwh-toolbox-server/utils/mqttClient"
 	"os"
 	"strconv"
+	"sync"
 	"time"
 
 	"go.uber.org/zap"
@@ -19,56 +20,120 @@ const (
 	fwhClientIdPrefix  = "fwh_xlog_mqtt_client_"
 	sleepTimeout       = 5 * time.Minute
 	sendMessageTimeout = 1 * time.Minute
-
-	topicPrefixS2C = "fwh/s2c/"
-	topicPrefixC2S = "fwh/c2s/"
+	subscriberBuffer   = 64
 )
 
-type FwhMqttResult struct {
-	Err  error
-	Data *FwhMqttMsg
+// transport 底层 MQTT 连接(utils/mqttClient),抽象出来便于测试
+type transport interface {
+	Connect() error
+	IsConnected() bool
+	Disconnect()
+	SubscribeMessage(topic string, callback func(payload []byte)) error
+	UnSubscribeMessage(topic string) error
+	SendMessageOnce(topic string, msg []byte) error
+	GetClientStatus() string
 }
 
-type FwhMqttMsg struct {
-	CmdType   uint        `json:"cmdType"`   // 业务类型标识
-	Crc       string      `json:"crc"`       // dataArea 数据字符串的CRC
-	DataArea  interface{} `json:"dataArea"`  // 数据域
-	EquipNo   string      `json:"equipNo"`   // 设备编号 SN
-	Len       int         `json:"len"`       // dataArea 数据长度
-	Snno      uint        `json:"snno"`      // 序号
-	TimeStamp int64       `json:"timeStamp"` // 时间戳
-	Type      uint        `json:"type"`      // 设备类型 0:设备网关IBG
+// Message 收到的一条消息
+type Message struct {
+	Topic      string
+	Envelope   FwhMqttMsg // 外层;DataArea 为通用解码结果
+	Raw        []byte     // 原始字节,业务层按需重新解析(如重复键)
+	ReceivedAt time.Time
 }
 
-type MqttMsg struct {
-	Topic      string // 发送 Topic
-	Msg        FwhMqttMsg
-	ResTopic   *string // 响应 Topic
-	ResCmdType *uint
-	ResultCh   chan FwhMqttResult
-	Ctx        context.Context
+// Handler 订阅回调;每个订阅者在独立 goroutine 中顺序调用,可以阻塞,不影响其他订阅者
+type Handler func(Message)
+
+type subscriber struct {
+	id      uint64
+	handler Handler
+	ch      chan Message
+	done    chan struct{}
 }
 
-type ResultChan struct {
-	ch  chan FwhMqttMsg
-	ctx context.Context
+type topicEntry struct {
+	subs  map[uint64]*subscriber
+	ready chan struct{} // broker 订阅完成后关闭
+	err   error
 }
 
+// opKind worker 串行执行的底层操作
+type opKind int
+
+const (
+	opPublish opKind = iota
+	opSubscribe
+	opUnsubscribe
+)
+
+type op struct {
+	kind    opKind
+	topic   string
+	payload []byte
+	result  chan error
+}
+
+// FwhMqttClient 一个环境的 FWH MQTT 客户端。
+// 只提供底层收发:Request(一问一答)、Publish(只请求)、Subscribe(持续监听),不包含任何业务判断。
 type FwhMqttClient struct {
-	Client *mqttClient.MqttClient
-	TaskCh chan MqttMsg
+	env Env
+	tr  transport
+	ops chan op
+
+	mu     sync.Mutex
+	topics map[string]*topicEntry
+	nextID uint64
+}
+
+func newFwhMqttClient(env Env, tr transport) *FwhMqttClient {
+	c := &FwhMqttClient{
+		env:    env,
+		tr:     tr,
+		ops:    make(chan op, 1000),
+		topics: map[string]*topicEntry{},
+	}
+	go c.worker()
+	return c
 }
 
-var fwhMqttClient = FwhMqttClient{
-	Client: nil,
-	TaskCh: make(chan MqttMsg, 1000),
+// Env FWH 服务器环境,每个环境使用独立的 broker、证书和客户端实例
+type Env string
+
+const (
+	EnvProd Env = "prod" // 生产服
+	EnvTest Env = "test" // 测试服
+)
+
+// ParseEnv 解析请求中的环境参数,空值默认为生产服
+func ParseEnv(s string) (Env, error) {
+	switch Env(s) {
+	case "", EnvProd:
+		return EnvProd, nil
+	case EnvTest:
+		return EnvTest, nil
+	default:
+		return "", fmt.Errorf("unknown FWH MQTT env: %s", s)
+	}
 }
 
-func InitFwhMqttClient(cfg *config.FwhMqttConfig) error {
+var (
+	fwhMqttClients   = map[Env]*FwhMqttClient{}
+	fwhMqttClientsMu sync.RWMutex
+)
+
+// InitFwhMqttClient 初始化指定环境的 MQTT 客户端;失败时该环境不可用,不影响其他环境
+func InitFwhMqttClient(env Env, cfg *config.FwhMqttConfig) error {
+	if cfg.FwhMqttBroker == "" {
+		zap.L().Warn("FWH MQTT broker not configured, skip init.", zap.String("env", string(env)))
+		return fmt.Errorf("FWH MQTT broker not configured (env=%s)", env)
+	}
+
 	// 证书配置
 	caCert, err := os.ReadFile(cfg.FwhMqttCaUrl)
 	if err != nil {
-		panic(err)
+		zap.L().Error("Read FWH MQTT CA failed.", zap.String("env", string(env)), zap.String("err", err.Error()))
+		return err
 	}
 
 	caCertPool := x509.NewCertPool()
@@ -76,7 +141,8 @@ func InitFwhMqttClient(cfg *config.FwhMqttConfig) error {
 
 	cert, err := tls.LoadX509KeyPair(cfg.FwhMqttCertUrl, cfg.FwhMqttKeyUrl)
 	if err != nil {
-		panic(err)
+		zap.L().Error("Load FWH MQTT cert failed.", zap.String("env", string(env)), zap.String("err", err.Error()))
+		return err
 	}
 
 	client, err := mqttClient.CreateMqttClient(mqttClient.MqttClientOpt{
@@ -88,32 +154,45 @@ func InitFwhMqttClient(cfg *config.FwhMqttConfig) error {
 		},
 	})
 	if err != nil {
-		zap.L().Error("Init FWH MQTT client failed.")
+		zap.L().Error("Init FWH MQTT client failed.", zap.String("env", string(env)))
 		return err
 	}
-	fwhMqttClient.Client = client
 
-	zap.L().Info("Init FWH MQTT client success.")
-
-	go fwhMqttClient.fwhMqttWorker()
+	return registerClient(env, client)
+}
 
+func registerClient(env Env, tr transport) error {
+	fwhMqttClientsMu.Lock()
+	defer fwhMqttClientsMu.Unlock()
+	if _, exists := fwhMqttClients[env]; exists {
+		return fmt.Errorf("FWH MQTT client already initialized (env=%s)", env)
+	}
+	fwhMqttClients[env] = newFwhMqttClient(env, tr)
+	zap.L().Info("Init FWH MQTT client success.", zap.String("env", string(env)))
 	return nil
 }
 
-func (c *FwhMqttClient) fwhMqttWorker() {
+// GetClient 获取指定环境的 MQTT 客户端
+func GetClient(env Env) (*FwhMqttClient, error) {
+	fwhMqttClientsMu.RLock()
+	defer fwhMqttClientsMu.RUnlock()
+
+	c, ok := fwhMqttClients[env]
+	if !ok {
+		return nil, fmt.Errorf("Fwh Mqtt Client Uninitialized (env=%s).", env)
+	}
+	return c, nil
+}
+
+/************** worker:独占底层连接,串行执行 **************/
+
+func (c *FwhMqttClient) worker() {
 	idleTimer := time.NewTimer(sleepTimeout)
 	defer idleTimer.Stop()
 
 	for {
 		select {
-		case task, ok := <-c.TaskCh:
-			if !ok {
-				zap.L().Error("Channel closed, worker exiting, recreate.")
-				c.TaskCh = make(chan MqttMsg, 1000)
-				continue
-			}
-
-			// 重置空闲计时器
+		case o := <-c.ops:
 			if !idleTimer.Stop() {
 				select {
 				case <-idleTimer.C:
@@ -121,154 +200,229 @@ func (c *FwhMqttClient) fwhMqttWorker() {
 				}
 			}
 			idleTimer.Reset(sleepTimeout)
-
-			// 处理任务
-			c.handleTask(task)
+			o.result <- c.execute(o)
 
 		case <-idleTimer.C:
-			if c.Client.IsConnected() {
-				zap.L().Info("FWH MQTT no task received within timeout, disconnect.")
-				c.Client.Disconnect()
+			// 有活动订阅时保持连接,否则空闲断开以节省连接
+			if c.tr.IsConnected() && !c.hasTopics() {
+				zap.L().Info("FWH MQTT no task received within timeout, disconnect.", zap.String("env", string(c.env)))
+				c.tr.Disconnect()
 			}
 			idleTimer.Reset(sleepTimeout)
 		}
 	}
 }
 
-func (c *FwhMqttClient) handleTask(task MqttMsg) {
-	// 1. 建立连接
-	if err := c.Client.Connect(); err != nil {
+func (c *FwhMqttClient) execute(o op) error {
+	if err := c.tr.Connect(); err != nil {
 		zap.L().Error("FWH MQTT connect failed.",
-			zap.String("status", c.Client.GetClientStatus()),
+			zap.String("env", string(c.env)),
+			zap.String("status", c.tr.GetClientStatus()),
 			zap.String("err", err.Error()),
 		)
-		task.ResultCh <- FwhMqttResult{Err: fmt.Errorf("FWH MQTT connect failed: %w", err)}
-		return
+		return fmt.Errorf("FWH MQTT connect failed: %w", err)
 	}
 
-	// 2. 如果需要响应,订阅响应 topic
-	var resultCh chan FwhMqttResult
-	if task.ResTopic != nil {
-		resultCh = make(chan FwhMqttResult, 1)
-		if err := c.Client.SubscribeMessage(*task.ResTopic, func(payload []byte) {
-			var fwhMqttMsg FwhMqttMsg
-			if err := json.Unmarshal(payload, &fwhMqttMsg); err != nil {
-				zap.L().Error("Result Unmarshal failed.(FwhMqttMsg)",
-					zap.String("topic", task.Topic),
-					zap.String("payload", string(payload)),
-					zap.String("err", err.Error()),
-				)
-				return
-			}
-
-			// 过滤 CmdType
-			if task.ResCmdType != nil && fwhMqttMsg.CmdType != *task.ResCmdType {
-				return
-			}
+	switch o.kind {
+	case opPublish:
+		return c.tr.SendMessageOnce(o.topic, o.payload)
+	case opSubscribe:
+		topic := o.topic
+		return c.tr.SubscribeMessage(topic, func(payload []byte) { c.dispatch(topic, payload) })
+	case opUnsubscribe:
+		return c.tr.UnSubscribeMessage(o.topic)
+	}
+	return fmt.Errorf("unknown op %d", o.kind)
+}
 
-			// 非阻塞写入,防止回调卡死
-			select {
-			case resultCh <- FwhMqttResult{Err: nil, Data: &fwhMqttMsg}:
-			default:
-				zap.L().Warn("[MQTT] resultCh full, dropping duplicate response")
-			}
-		}); err != nil {
-			zap.L().Error("FWH MQTT subscribe failed.", zap.String("err", err.Error()), zap.String("topic", *task.ResTopic))
-			task.ResultCh <- FwhMqttResult{Err: fmt.Errorf("subscribe failed: %w", err)}
-			return
-		}
+// enqueue 提交操作,返回结果 channel
+func (c *FwhMqttClient) enqueue(kind opKind, topic string, payload []byte) chan error {
+	o := op{kind: kind, topic: topic, payload: payload, result: make(chan error, 1)}
+	c.ops <- o
+	return o.result
+}
 
-		// 确保退出时取消订阅
-		defer func() {
-			if err := c.Client.UnSubscribeMessage(*task.ResTopic); err != nil {
-				zap.L().Error("FWH MQTT unsubscribe failed.", zap.String("err", err.Error()), zap.String("topic", *task.ResTopic))
-			}
-		}()
+func waitResult(ctx context.Context, ch chan error) error {
+	select {
+	case err := <-ch:
+		return err
+	case <-ctx.Done():
+		return ctx.Err()
 	}
+}
 
-	// 3. 发送消息
-	msgByte, err := json.Marshal(task.Msg)
+func (c *FwhMqttClient) hasTopics() bool {
+	c.mu.Lock()
+	defer c.mu.Unlock()
+	return len(c.topics) > 0
+}
+
+/************** 对外方法 **************/
+
+// Publish 只请求:发送后立即返回
+func (c *FwhMqttClient) Publish(ctx context.Context, topic string, msg FwhMqttMsg) error {
+	b, err := json.Marshal(msg)
 	if err != nil {
-		zap.L().Error("FWH MQTT message marshal failed.", zap.String("err", err.Error()))
-		task.ResultCh <- FwhMqttResult{Err: err}
-		return
+		return err
 	}
+	ctx, cancel := context.WithTimeout(ctx, sendMessageTimeout)
+	defer cancel()
+	if err := waitResult(ctx, c.enqueue(opPublish, topic, b)); err != nil {
+		zap.L().Error("FWH MQTT publish failed.", zap.String("env", string(c.env)), zap.String("topic", topic), zap.String("err", err.Error()))
+		return err
+	}
+	return nil
+}
 
-	if err := c.Client.SendMessageOnce(task.Topic, msgByte); err != nil {
-		zap.L().Error("FWH MQTT send message failed.", zap.String("err", err.Error()))
-		task.ResultCh <- FwhMqttResult{Err: err}
-		return
+// Subscribe 持续监听:同一 topic 可有多个订阅者,各自收到每条消息;返回的 cancel 只移除自己。
+// 首个订阅者加入时向 broker 订阅,最后一个退出时取消订阅;有订阅时连接不会空闲断开。
+func (c *FwhMqttClient) Subscribe(ctx context.Context, topic string, handler Handler) (cancel func(), err error) {
+	c.mu.Lock()
+	c.nextID++
+	sub := &subscriber{
+		id:      c.nextID,
+		handler: handler,
+		ch:      make(chan Message, subscriberBuffer),
+		done:    make(chan struct{}),
+	}
+	entry, exists := c.topics[topic]
+	var result chan error
+	if !exists {
+		entry = &topicEntry{subs: map[uint64]*subscriber{}, ready: make(chan struct{})}
+		c.topics[topic] = entry
+		result = c.enqueue(opSubscribe, topic, nil) // 持锁入队,保证与取消订阅的先后顺序
+	}
+	entry.subs[sub.id] = sub
+	c.mu.Unlock()
+
+	if !exists {
+		err := waitResult(ctx, result)
+		c.mu.Lock()
+		entry.err = err
+		close(entry.ready)
+		if err != nil && c.topics[topic] == entry {
+			delete(c.topics, topic)
+			// 等待超时时 broker 订阅可能稍后成功,补发取消订阅避免残留
+			c.enqueue(opUnsubscribe, topic, nil)
+		}
+		c.mu.Unlock()
+	} else {
+		select {
+		case <-entry.ready:
+		case <-ctx.Done():
+			c.removeSubscriber(topic, sub)
+			return nil, ctx.Err()
+		}
+	}
+	if entry.err != nil {
+		zap.L().Error("FWH MQTT subscribe failed.", zap.String("env", string(c.env)), zap.String("topic", topic), zap.String("err", entry.err.Error()))
+		return nil, fmt.Errorf("subscribe failed: %w", entry.err)
 	}
 
-	// 4. 等待响应 或 直接返回
-	if resultCh == nil {
-		// 只发送,不等响应
-		task.ResultCh <- FwhMqttResult{Err: nil, Data: nil}
+	go func() {
+		for {
+			select {
+			case m := <-sub.ch:
+				sub.handler(m)
+			case <-sub.done:
+				return
+			}
+		}
+	}()
+
+	var once sync.Once
+	return func() { once.Do(func() { c.removeSubscriber(topic, sub) }) }, nil
+}
+
+func (c *FwhMqttClient) removeSubscriber(topic string, sub *subscriber) {
+	c.mu.Lock()
+	defer c.mu.Unlock()
+	close(sub.done)
+	entry, ok := c.topics[topic]
+	if !ok {
 		return
 	}
-
-	// 一问一答:等待响应或超时
-	select {
-	case res := <-resultCh:
-		task.ResultCh <- res
-	case <-task.Ctx.Done():
-		task.ResultCh <- FwhMqttResult{Err: fmt.Errorf("FWH MQTT received message timeout")}
+	delete(entry.subs, sub.id)
+	if len(entry.subs) == 0 {
+		delete(c.topics, topic)
+		result := c.enqueue(opUnsubscribe, topic, nil)
+		go func() {
+			if err := <-result; err != nil {
+				zap.L().Warn("FWH MQTT unsubscribe failed.", zap.String("env", string(c.env)), zap.String("topic", topic), zap.String("err", err.Error()))
+			}
+		}()
 	}
 }
 
-func (c *FwhMqttClient) sendMessage(ctx context.Context, topicName string, msg FwhMqttMsg, resTopicName *string, resCmdType *uint) (*FwhMqttMsg, error) {
-	if c.Client == nil {
-		return nil, fmt.Errorf("Fwh Mqtt Client Uninitialized.")
+// dispatch 在 paho 回调线程中执行:解析一次外层,非阻塞分发给各订阅者
+func (c *FwhMqttClient) dispatch(topic string, payload []byte) {
+	m := Message{Topic: topic, Raw: payload, ReceivedAt: time.Now()}
+	if err := json.Unmarshal(payload, &m.Envelope); err != nil {
+		zap.L().Warn("FWH MQTT message unmarshal failed.", zap.String("topic", topic), zap.String("payload", string(payload)), zap.String("err", err.Error()))
+		return
 	}
 
-	ctxSend, cancel := context.WithTimeout(ctx, sendMessageTimeout)
-	defer cancel()
-
-	topic := topicPrefixS2C + topicName
-	var resTopic *string
-	if resTopicName != nil {
-		resTopicStr := topicPrefixC2S + *resTopicName
-		resTopic = &resTopicStr
+	c.mu.Lock()
+	entry := c.topics[topic]
+	var subs []*subscriber
+	if entry != nil {
+		for _, s := range entry.subs {
+			subs = append(subs, s)
+		}
 	}
+	c.mu.Unlock()
 
-	taskMsg := MqttMsg{
-		Topic:      topic,
-		Msg:        msg,
-		ResTopic:   resTopic,
-		ResCmdType: resCmdType,
-		ResultCh:   make(chan FwhMqttResult, 1),
-		Ctx:        ctxSend,
+	for _, s := range subs {
+		select {
+		case <-s.done:
+		case s.ch <- m:
+		default:
+			zap.L().Warn("FWH MQTT subscriber buffer full, dropping message.", zap.String("topic", topic), zap.Uint("cmdType", m.Envelope.CmdType))
+		}
 	}
-	c.TaskCh <- taskMsg
+}
 
-	for {
-		select {
-		case result, ok := <-taskMsg.ResultCh:
-			if !ok {
-				zap.L().Error("Result channel closed, worker exiting, recreate.", zap.String("topic", topic), zap.String("msg", fmt.Sprintf("%+v", msg)))
-				return nil, fmt.Errorf("Result channel closed, worker exiting, recreate.")
-			}
+// RequestOpt 一问一答参数
+type RequestOpt struct {
+	Topic    string             // 发送 topic
+	Msg      FwhMqttMsg         // 发送内容
+	ResTopic string             // 应答 topic
+	Match    func(Message) bool // 应答匹配条件,由调用方定义;为 nil 时取第一条消息
+	Timeout  time.Duration      // 等待应答超时,默认 sendMessageTimeout
+}
 
-			if result.Err != nil {
-				return nil, result.Err
-			}
+// Request 一问一答:先订阅应答 topic,再发送,返回第一条满足 Match 的消息
+func (c *FwhMqttClient) Request(ctx context.Context, opt RequestOpt) (*Message, error) {
+	timeout := opt.Timeout
+	if timeout <= 0 {
+		timeout = sendMessageTimeout
+	}
+	ctx, cancelCtx := context.WithTimeout(ctx, timeout)
+	defer cancelCtx()
 
-			return result.Data, nil
-		case <-ctxSend.Done():
-			zap.L().Info("Result received timeout.", zap.String("topic", topic), zap.String("msg", fmt.Sprintf("%+v", msg)))
-			return nil, fmt.Errorf("Result received timeout.")
+	resCh := make(chan Message, 1)
+	cancelSub, err := c.Subscribe(ctx, opt.ResTopic, func(m Message) {
+		if opt.Match != nil && !opt.Match(m) {
+			return
 		}
+		select {
+		case resCh <- m:
+		default:
+		}
+	})
+	if err != nil {
+		return nil, err
 	}
-}
+	defer cancelSub()
 
-// 仅发送不等待响应结果
-func (c *FwhMqttClient) sendMessageOnce(ctx context.Context, topicName string, msg FwhMqttMsg) error {
-	_, err := c.sendMessage(ctx, topicName, msg, nil, nil)
-	return err
-}
+	if err := c.Publish(ctx, opt.Topic, opt.Msg); err != nil {
+		return nil, err
+	}
 
-// 发送消息后,等待响应结果
-func (c *FwhMqttClient) sendMessageWait(ctx context.Context, topicName string, msg FwhMqttMsg, resTopicName string, resCmdType uint) (*FwhMqttMsg, error) {
-	res, err := c.sendMessage(ctx, topicName, msg, &resTopicName, &resCmdType)
-	return res, err
+	select {
+	case m := <-resCh:
+		return &m, nil
+	case <-ctx.Done():
+		return nil, fmt.Errorf("FWH MQTT received message timeout")
+	}
 }

+ 217 - 0
service/repositories/mqtt/fwh_mqtt/fwh_mqtt_client_test.go

@@ -0,0 +1,217 @@
+package fwh_mqtt
+
+import (
+	"context"
+	"encoding/json"
+	"fwh-toolbox-server/config"
+	"sync"
+	"testing"
+	"time"
+)
+
+// fakeTransport 内存实现,记录订阅和发送,并可模拟 broker 推送
+type fakeTransport struct {
+	mu          sync.Mutex
+	connected   bool
+	subs        map[string]func([]byte)
+	published   []string
+	subCalls    int
+	unsubCalls  int
+	disconnects int
+}
+
+func newFake() *fakeTransport { return &fakeTransport{subs: map[string]func([]byte){}} }
+
+func (f *fakeTransport) Connect() error { f.mu.Lock(); f.connected = true; f.mu.Unlock(); return nil }
+func (f *fakeTransport) IsConnected() bool {
+	f.mu.Lock()
+	defer f.mu.Unlock()
+	return f.connected
+}
+func (f *fakeTransport) Disconnect() {
+	f.mu.Lock()
+	f.connected = false
+	f.disconnects++
+	f.mu.Unlock()
+}
+func (f *fakeTransport) SubscribeMessage(topic string, cb func([]byte)) error {
+	f.mu.Lock()
+	f.subs[topic] = cb
+	f.subCalls++
+	f.mu.Unlock()
+	return nil
+}
+func (f *fakeTransport) UnSubscribeMessage(topic string) error {
+	f.mu.Lock()
+	delete(f.subs, topic)
+	f.unsubCalls++
+	f.mu.Unlock()
+	return nil
+}
+func (f *fakeTransport) SendMessageOnce(topic string, msg []byte) error {
+	f.mu.Lock()
+	f.published = append(f.published, topic)
+	f.mu.Unlock()
+	return nil
+}
+func (f *fakeTransport) GetClientStatus() string { return "fake" }
+
+// push 模拟 broker 推送
+func (f *fakeTransport) push(topic string, cmdType uint) bool {
+	f.mu.Lock()
+	cb := f.subs[topic]
+	f.mu.Unlock()
+	if cb == nil {
+		return false
+	}
+	b, _ := json.Marshal(FwhMqttMsg{CmdType: cmdType, EquipNo: "SN", DataArea: map[string]int{"cmd": 2}})
+	cb(b)
+	return true
+}
+
+func (f *fakeTransport) counts() (sub, unsub int) {
+	f.mu.Lock()
+	defer f.mu.Unlock()
+	return f.subCalls, f.unsubCalls
+}
+
+func waitFor(t *testing.T, cond func() bool) {
+	t.Helper()
+	deadline := time.Now().Add(2 * time.Second)
+	for !cond() {
+		if time.Now().After(deadline) {
+			t.Fatal("condition not met in time")
+		}
+		time.Sleep(5 * time.Millisecond)
+	}
+}
+
+func TestParseEnv(t *testing.T) {
+	cases := []struct {
+		in      string
+		want    Env
+		wantErr bool
+	}{
+		{"", EnvProd, false},
+		{"prod", EnvProd, false},
+		{"test", EnvTest, false},
+		{"TEST", "", true},
+		{"dev", "", true},
+	}
+	for _, c := range cases {
+		got, err := ParseEnv(c.in)
+		if (err != nil) != c.wantErr || got != c.want {
+			t.Errorf("ParseEnv(%q) = %q, %v; want %q, err=%v", c.in, got, err, c.want, c.wantErr)
+		}
+	}
+}
+
+func TestInitWithoutBrokerFails(t *testing.T) {
+	if err := InitFwhMqttClient(EnvTest, &config.FwhMqttConfig{}); err == nil {
+		t.Fatal("expected error when broker not configured")
+	}
+	if _, err := GetClient(EnvTest); err == nil {
+		t.Fatal("client should not be registered after failed init")
+	}
+}
+
+func TestSubscribeFanOutAndRefCount(t *testing.T) {
+	f := newFake()
+	c := newFwhMqttClient(EnvTest, f)
+	ctx := context.Background()
+
+	var mu sync.Mutex
+	got := map[string]int{}
+	h := func(name string) Handler {
+		return func(m Message) {
+			mu.Lock()
+			got[name]++
+			mu.Unlock()
+		}
+	}
+
+	cancelA, err := c.Subscribe(ctx, "t", h("a"))
+	if err != nil {
+		t.Fatal(err)
+	}
+	cancelB, err := c.Subscribe(ctx, "t", h("b"))
+	if err != nil {
+		t.Fatal(err)
+	}
+	if sub, _ := f.counts(); sub != 1 {
+		t.Fatalf("broker subscribe calls = %d, want 1", sub)
+	}
+
+	f.push("t", 379)
+	waitFor(t, func() bool { mu.Lock(); defer mu.Unlock(); return got["a"] == 1 && got["b"] == 1 })
+
+	cancelA()
+	cancelA() // 重复取消无副作用
+	f.push("t", 379)
+	waitFor(t, func() bool { mu.Lock(); defer mu.Unlock(); return got["b"] == 2 })
+	mu.Lock()
+	if got["a"] != 1 {
+		t.Errorf("cancelled subscriber received message")
+	}
+	mu.Unlock()
+	if _, unsub := f.counts(); unsub != 0 {
+		t.Errorf("should not unsubscribe while subscribers remain")
+	}
+
+	cancelB()
+	waitFor(t, func() bool { _, unsub := f.counts(); return unsub == 1 })
+	if c.hasTopics() {
+		t.Error("topics should be empty")
+	}
+}
+
+func TestRequestMatchesResponse(t *testing.T) {
+	f := newFake()
+	c := newFwhMqttClient(EnvTest, f)
+
+	go func() {
+		waitFor(t, func() bool {
+			f.mu.Lock()
+			defer f.mu.Unlock()
+			return len(f.published) == 1
+		})
+		f.push(C2STopic("mng/SN"), 379) // 不匹配,应被忽略
+		f.push(C2STopic("mng/SN"), 378)
+	}()
+
+	res, err := c.Request(context.Background(), RequestOpt{
+		Topic:    S2CTopic("mng/SN"),
+		Msg:      FwhMqttMsg{CmdType: 377},
+		ResTopic: C2STopic("mng/SN"),
+		Match:    func(m Message) bool { return m.Envelope.CmdType == 378 },
+		Timeout:  2 * time.Second,
+	})
+	if err != nil {
+		t.Fatal(err)
+	}
+	if res.Envelope.CmdType != 378 || len(res.Raw) == 0 {
+		t.Errorf("unexpected response: %+v", res.Envelope)
+	}
+	waitFor(t, func() bool { _, unsub := f.counts(); return unsub == 1 })
+}
+
+func TestRequestTimeout(t *testing.T) {
+	c := newFwhMqttClient(EnvTest, newFake())
+	_, err := c.Request(context.Background(), RequestOpt{
+		Topic: "a", ResTopic: "b", Timeout: 50 * time.Millisecond,
+	})
+	if err == nil {
+		t.Fatal("expected timeout")
+	}
+}
+
+func TestNewEnvelope(t *testing.T) {
+	m, err := NewEnvelope(377, "SN", map[string]any{"cmd": 4, "data": map[string]any{}})
+	if err != nil {
+		t.Fatal(err)
+	}
+	// {"cmd":4,"data":{}} 共 19 字节
+	if m.Len != 19 || len(m.Crc) != 8 || m.EquipNo != "SN" || m.CmdType != 377 {
+		t.Errorf("envelope: %+v", m)
+	}
+}

+ 0 - 43
service/repositories/mqtt/fwh_mqtt/fwh_mqtt_debug.go

@@ -1,43 +0,0 @@
-// debug 设备调试数据
-package fwh_mqtt
-
-import (
-	"context"
-	"encoding/json"
-	"fmt"
-	"hash/crc32"
-	"strings"
-	"time"
-)
-
-func EnableXlog(ctx context.Context, sn string) (string, error) {
-	dataArea := map[string]string{
-		"xlogaddr": "18.188.130.67:9081",
-	}
-
-	dataStr, err := json.Marshal(dataArea)
-	if err != nil {
-		return "", err
-	}
-
-	res, err := fwhMqttClient.sendMessageWait(ctx, "debug/"+sn, FwhMqttMsg{
-		CmdType:   2000, // xlog请求
-		Type:      0,    // IBG
-		Snno:      123,
-		TimeStamp: time.Now().Unix(),
-		EquipNo:   sn,
-		DataArea:  dataArea,
-		Len:       len(dataStr),
-		Crc:       strings.ToUpper(fmt.Sprintf("%08X", crc32.ChecksumIEEE(dataStr))),
-	}, "debug/"+sn, 2001)
-	if err != nil {
-		return "", err
-	}
-
-	b, err := json.Marshal(res.DataArea)
-	if err != nil {
-		return "", err
-	}
-
-	return string(b), nil
-}

+ 52 - 0
service/repositories/mqtt/fwh_mqtt/fwh_mqtt_envelope.go

@@ -0,0 +1,52 @@
+package fwh_mqtt
+
+import (
+	"encoding/json"
+	"fmt"
+	"hash/crc32"
+	"time"
+)
+
+const (
+	topicPrefixS2C = "fwh/s2c/"
+	topicPrefixC2S = "fwh/c2s/"
+
+	defaultSnno      = 123 // 设备不按 snno 应答,固定值即可
+	equipTypeGateway = 0   // 外层设备类型:网关 IBG
+)
+
+// FwhMqttMsg FWH MQTT 报文外层
+type FwhMqttMsg struct {
+	CmdType   uint        `json:"cmdType"`   // 业务类型标识
+	Crc       string      `json:"crc"`       // dataArea 数据字符串的CRC
+	DataArea  interface{} `json:"dataArea"`  // 数据域
+	EquipNo   string      `json:"equipNo"`   // 设备编号 SN
+	Len       int         `json:"len"`       // dataArea 数据长度
+	Snno      uint        `json:"snno"`      // 序号
+	TimeStamp int64       `json:"timeStamp"` // 时间戳
+	Type      uint        `json:"type"`      // 设备类型 0:设备网关IBG
+}
+
+// NewEnvelope 构造外层:len 为 dataArea 序列化字节长度,crc 为其 CRC32 IEEE 大写十六进制
+func NewEnvelope(cmdType uint, equipNo string, dataArea any) (FwhMqttMsg, error) {
+	b, err := json.Marshal(dataArea)
+	if err != nil {
+		return FwhMqttMsg{}, err
+	}
+	return FwhMqttMsg{
+		CmdType:   cmdType,
+		Type:      equipTypeGateway,
+		Snno:      defaultSnno,
+		TimeStamp: time.Now().Unix(),
+		EquipNo:   equipNo,
+		DataArea:  dataArea,
+		Len:       len(b),
+		Crc:       fmt.Sprintf("%08X", crc32.ChecksumIEEE(b)),
+	}, nil
+}
+
+// S2CTopic 服务端 → 设备方向的 topic,如 S2CTopic("mng/" + sn)
+func S2CTopic(name string) string { return topicPrefixS2C + name }
+
+// C2STopic 设备 → 服务端方向的 topic
+func C2STopic(name string) string { return topicPrefixC2S + name }

+ 0 - 200
service/repositories/mqtt/fwh_mqtt/fwh_mqtt_fault_record.go

@@ -1,200 +0,0 @@
-// fault record 故障录波请求
-package fwh_mqtt
-
-import (
-	"context"
-	"encoding/json"
-	"fmt"
-	"fwh-toolbox-server/dto"
-	"hash/crc32"
-	"strings"
-	"time"
-
-	"go.uber.org/zap"
-)
-
-const (
-	FaultRecordReqCmdType = 377 // 请求协议类型
-	FaultRecordResCmdType = 378 // 应答协议类型
-)
-
-type ParamSettingParaListItem struct {
-	Id     int    `json:"id"`
-	SetVal string `json:"setVal"`
-}
-
-type ParamSettingInfoItem struct {
-	Sn       string                     `json:"sn"` // aGate/aPower SN
-	ParaList []ParamSettingParaListItem `json:"paraList"`
-}
-
-type ParamSettingListItem struct {
-	Type string                 `json:"type"` // 'pe' or 'aGate' or 'mppt'
-	Info []ParamSettingInfoItem `json:"info"`
-}
-
-type ParamSettingData struct {
-	List []ParamSettingListItem `json:"list"`
-}
-
-type FaultRecordDataArea struct {
-	Cmd  int         `json:"cmd"` // 0:实时数据获取;1:参数设置;2:文件获取
-	Data interface{} `json:"data"`
-}
-
-type FaultRecordResponse struct {
-	Cmd    int `json:"cmd"`    // 0:实时数据获取;1:参数设置;2:文件获取
-	Result int `json:"result"` // 0:成功;-1:失败
-}
-
-func ChargeChannel(ctx context.Context, sn string) error {
-	dataArea := map[string]interface{}{
-		"cmd": 1,
-		"data": map[string]interface{}{
-			"list": []interface{}{
-				map[string]interface{}{
-					"type": "pe",
-					"info": []interface{}{
-						map[string]interface{}{
-							"sn": "10050015A00X25070634",
-							"paraList": []interface{}{
-								map[string]interface{}{
-									"id":     6,
-									"setVal": "010100015984",
-								},
-								map[string]interface{}{
-									"id":     6,
-									"setVal": "020100015981",
-								},
-								map[string]interface{}{
-									"id":     6,
-									"setVal": "030100015983",
-								},
-								map[string]interface{}{
-									"id":     6,
-									"setVal": "04030003FD34",
-								},
-							},
-						},
-					},
-				},
-			},
-		},
-	}
-
-	dataStr, err := json.Marshal(dataArea)
-	if err != nil {
-		return err
-	}
-
-	err = fwhMqttClient.sendMessageOnce(ctx, "mng/"+sn, FwhMqttMsg{
-		CmdType:   377, // 故障录波请求
-		Type:      0,   // IBG
-		Snno:      123,
-		TimeStamp: time.Now().Unix(),
-		EquipNo:   sn,
-		DataArea:  dataArea,
-		Len:       len(dataStr),
-		Crc:       strings.ToUpper(fmt.Sprintf("%08X", crc32.ChecksumIEEE(dataStr))),
-	})
-	if err != nil {
-		return err
-	}
-
-	return nil
-}
-
-func FaultRecordParamSetting(ctx context.Context, data dto.FaultRecordParamInfo) error {
-	siteSn := data.Sn
-
-	peParaList := []ParamSettingInfoItem{}
-	agateParaList := []ParamSettingInfoItem{}
-
-	for _, value := range data.List {
-		var paraSettingList []ParamSettingParaListItem
-		for _, v := range value.ParaList {
-			paraSettingList = append(paraSettingList, ParamSettingParaListItem{
-				Id:     v.Id,
-				SetVal: v.Val,
-			})
-		}
-
-		switch value.Type {
-		case "pe":
-			peParaList = append(peParaList, ParamSettingInfoItem{
-				Sn:       value.Sn,
-				ParaList: paraSettingList,
-			})
-		case "agate":
-			agateParaList = append(agateParaList, ParamSettingInfoItem{
-				Sn:       value.Sn,
-				ParaList: paraSettingList,
-			})
-		}
-	}
-
-	paraData := ParamSettingData{
-		List: []ParamSettingListItem{},
-	}
-
-	if len(agateParaList) > 0 {
-		paraData.List = append(paraData.List, ParamSettingListItem{
-			Type: "aGate",
-			Info: agateParaList,
-		})
-	}
-
-	if len(peParaList) > 0 {
-		paraData.List = append(paraData.List, ParamSettingListItem{
-			Type: "pe",
-			Info: peParaList,
-		})
-	}
-
-	dataArea := FaultRecordDataArea{
-		Cmd:  1, // 参数设置
-		Data: paraData,
-	}
-
-	dataStr, err := json.Marshal(dataArea)
-	if err != nil {
-		return err
-	}
-
-	fmt.Println(string(dataStr))
-
-	msg, err := fwhMqttClient.sendMessageWait(ctx, "mng/"+siteSn, FwhMqttMsg{
-		CmdType:   FaultRecordReqCmdType, // 故障录波请求
-		Type:      0,                     // IBG
-		Snno:      123,
-		TimeStamp: time.Now().Unix(),
-		EquipNo:   siteSn,
-		DataArea:  dataArea,
-		Len:       len(dataStr),
-		Crc:       strings.ToUpper(fmt.Sprintf("%08X", crc32.ChecksumIEEE(dataStr))),
-	}, "mng/"+siteSn, FaultRecordResCmdType)
-	if err != nil {
-		return err
-	}
-
-	if msg == nil {
-		return fmt.Errorf("FaultRecordParamSetting response data is empty.")
-	}
-
-	var res FaultRecordResponse
-	b, err := json.Marshal(msg.DataArea)
-	if err != nil {
-		return err
-	}
-
-	if err := json.Unmarshal(b, &res); err != nil {
-		zap.L().Error("FaultRecordParamSetting response data type unmarshal error.", zap.String("DataArea", string(b)), zap.String("err", err.Error()))
-		return err
-	}
-
-	if res.Result != 0 {
-		return fmt.Errorf("FaultRecord param setting failed.")
-	}
-
-	return nil
-}

+ 0 - 307
service/repositories/mqtt/fwh_mqtt_test/fwh_mqtt_client.go

@@ -1,307 +0,0 @@
-package fwh_mqtt_test
-
-import (
-	"context"
-	"crypto/tls"
-	"crypto/x509"
-	"encoding/json"
-	"fmt"
-	"fwh-toolbox-server/config"
-	"fwh-toolbox-server/utils/mqttClient"
-	"os"
-	"strconv"
-	"time"
-
-	"go.uber.org/zap"
-)
-
-const (
-	fwhClientIdPrefix  = "fwh_xlog_mqtt_client_"
-	sleepTimeout       = 1 * time.Minute
-	sendMessageTimeout = 1 * time.Minute
-
-	topicPrefixS2C = "fwh/s2c/"
-	topicPrefixC2S = "fwh/c2s/"
-)
-
-type FwhMqttResult struct {
-	Err  error
-	Data *FwhMqttMsg
-}
-
-type FwhMqttMsg struct {
-	CmdType   uint        `json:"cmdType"`   // 业务类型标识
-	Crc       string      `json:"crc"`       // dataArea 数据字符串的CRC
-	DataArea  interface{} `json:"dataArea"`  // 数据域
-	EquipNo   string      `json:"equipNo"`   // 设备编号 SN
-	Len       int         `json:"len"`       // dataArea 数据长度
-	Snno      uint        `json:"snno"`      // 序号
-	TimeStamp int64       `json:"timeStamp"` // 时间戳
-	Type      uint        `json:"type"`      // 设备类型 0:设备网关IBG
-}
-
-type MqttMsg struct {
-	Topic      string // 发送 Topic
-	Msg        FwhMqttMsg
-	ResTopic   *string // 响应 Topic
-	ResCmdType *uint
-	ResultCh   chan FwhMqttResult
-	Ctx        context.Context
-}
-
-type ResultChan struct {
-	ch  chan FwhMqttMsg
-	ctx context.Context
-}
-
-type FwhMqttClient struct {
-	Client *mqttClient.MqttClient
-	TaskCh chan MqttMsg
-}
-
-var fwhMqttClient = FwhMqttClient{
-	Client: nil,
-	TaskCh: make(chan MqttMsg, 1000),
-}
-
-func InitFwhMqttClient(cfg *config.FwhMqttConfig) error {
-	// 证书配置
-	caCert, err := os.ReadFile(cfg.FwhMqttCaUrl)
-	if err != nil {
-		panic(err)
-	}
-
-	caCertPool := x509.NewCertPool()
-	caCertPool.AppendCertsFromPEM(caCert)
-
-	cert, err := tls.LoadX509KeyPair(cfg.FwhMqttCertUrl, cfg.FwhMqttKeyUrl)
-	if err != nil {
-		panic(err)
-	}
-
-	client, err := mqttClient.CreateMqttClient(mqttClient.MqttClientOpt{
-		Broker:   cfg.FwhMqttBroker,
-		ClientID: fwhClientIdPrefix + strconv.FormatInt(time.Now().Unix(), 10),
-		TLS: &tls.Config{
-			Certificates: []tls.Certificate{cert},
-			RootCAs:      caCertPool,
-		},
-	})
-	if err != nil {
-		zap.L().Error("Init FWH MQTT client failed.")
-		return err
-	}
-	fwhMqttClient.Client = client
-
-	zap.L().Info("Init FWH MQTT client success.")
-
-	go fwhMqttClient.fwhMqttWorker()
-
-	return nil
-}
-
-func (c *FwhMqttClient) fwhMqttWorker() {
-	for {
-		select {
-		case task, ok := <-c.TaskCh:
-			if !ok {
-				zap.L().Error("Channel closed, worker exiting, recreate.")
-				c.TaskCh = make(chan MqttMsg, 1000)
-				break
-			}
-
-			if err := c.Client.Connect(); err != nil {
-				zap.L().Error("FWH MQTT connect failed.", zap.String("msg", fmt.Sprintf("%+v", task)), zap.String("err", err.Error()))
-				task.ResultCh <- FwhMqttResult{
-					Err:  fmt.Errorf("FWH MQTT connect failed."),
-					Data: nil,
-				}
-				break
-			}
-
-			// 订阅响应消息 - 请求响应无法一一对应,不支持并发请求,改成简单的队列请求
-			// if _, ok := resultChListMap[task.subTopic]; !ok {
-			// 	resultChListMap[task.subTopic] = []*ResultChan{}
-			// 	resultChListMap[task.subTopic] = append(resultChListMap[task.subTopic], &ResultChan{ch: task.resultCh, ctx: task.ctx})
-			// 	if err := client.SubscribeMessage(task.subTopic, func(payload []byte) {
-			// 		var newList []*ResultChan
-			// 		for _, ch := range resultChListMap[task.subTopic] {
-			// 			if err := ch.ctx.Err(); err != nil {
-			// 				switch err {
-			// 				case context.DeadlineExceeded:
-			// 					zap.L().Warn("Waiting for FWH MQTT message timeout.", zap.String("topic", task.topic))
-			// 				case context.Canceled:
-			// 					zap.L().Warn("Waiting for FWH MQTT message cancel.", zap.String("topic", task.topic))
-			// 				}
-			// 			} else {
-			// 				var fwhMqttMsg FwhMqttMsg
-			// 				err := json.Unmarshal(payload, &fwhMqttMsg)
-			// 				if err != nil {
-			// 					zap.L().Error("Result Unmarshal failed.(FwhMqttMsg)", zap.String("topic", task.topic), zap.String("payload", string(payload)), zap.String("err", err.Error()))
-			// 					newList = append(newList, ch)
-			// 					continue
-			// 				}
-
-			// 				if task.msg.Snno != fwhMqttMsg.Snno {
-			// 					newList = append(newList, ch)
-			// 					continue
-			// 				}
-
-			// 				ch.ch <- fwhMqttMsg
-			// 			}
-			// 		}
-			// 		resultChListMap[task.subTopic] = newList
-			// 	}); err != nil {
-			// 		zap.L().Error("FWH MQTT subscribe message failed.", zap.String("msg", fmt.Sprintf("%+v", task)))
-			// 		break
-			// 	}
-			// } else {
-			// 	resultChListMap[task.subTopic] = append(resultChListMap[task.subTopic], &ResultChan{ch: task.resultCh, ctx: task.ctx})
-			// }
-
-			var resultCh chan FwhMqttResult
-			if task.ResTopic != nil {
-				resultCh = make(chan FwhMqttResult)
-				if err := c.Client.SubscribeMessage(*task.ResTopic, func(payload []byte) {
-					var fwhMqttMsg FwhMqttMsg
-					if err := json.Unmarshal(payload, &fwhMqttMsg); err != nil {
-						zap.L().Error("Result Unmarshal failed.(FwhMqttMsg)", zap.String("topic", task.Topic), zap.String("payload", string(payload)), zap.String("err", err.Error()))
-						// resultCh <- FwhMqttResult{
-						// 	Err:  err,
-						// 	Data: nil,
-						// }
-						return
-					}
-
-					if task.ResCmdType != nil {
-						if fwhMqttMsg.CmdType == *task.ResCmdType {
-							resultCh <- FwhMqttResult{
-								Err:  nil,
-								Data: &fwhMqttMsg,
-							}
-						}
-					} else {
-						resultCh <- FwhMqttResult{
-							Err:  nil,
-							Data: &fwhMqttMsg,
-						}
-					}
-				}); err != nil {
-					zap.L().Error("FWH MQTT subscribe message failed.", zap.String("msg", fmt.Sprintf("%+v", task)), zap.String("err", err.Error()))
-					task.ResultCh <- FwhMqttResult{
-						Err:  err,
-						Data: nil,
-					}
-					break
-				}
-			}
-
-			msgByte, err := json.Marshal(task.Msg)
-			if err != nil {
-				zap.L().Error("FWH MQTT message marshal failed.", zap.String("msg", fmt.Sprintf("%+v", task)), zap.String("err", err.Error()))
-				task.ResultCh <- FwhMqttResult{
-					Err:  err,
-					Data: nil,
-				}
-				break
-			}
-
-			if err := c.Client.SendMessageOnce(task.Topic, msgByte); err != nil {
-				zap.L().Error("FWH MQTT send message failed.", zap.String("msg", fmt.Sprintf("%+v", task)), zap.String("err", err.Error()))
-				task.ResultCh <- FwhMqttResult{
-					Err:  err,
-					Data: nil,
-				}
-				break
-			}
-
-			if resultCh != nil {
-				select {
-				case res, ok := <-resultCh:
-					if !ok {
-						task.ResultCh <- FwhMqttResult{
-							Err:  fmt.Errorf("FWH MQTT received message channel closed."),
-							Data: nil,
-						}
-						break
-					}
-
-					task.ResultCh <- res
-				case <-task.Ctx.Done():
-					task.ResultCh <- FwhMqttResult{
-						Err:  fmt.Errorf("FWH MQTT received message timeout."),
-						Data: nil,
-					}
-				}
-			} else {
-				task.ResultCh <- FwhMqttResult{
-					Err:  nil,
-					Data: nil,
-				}
-			}
-
-		case <-time.After(sleepTimeout):
-			if c.Client.IsConnected() {
-				zap.L().Info("FWH MQTT no task received within timeout, disconnect.")
-				c.Client.Disconnect()
-			}
-		}
-	}
-}
-
-func (c *FwhMqttClient) sendMessage(ctx context.Context, topicName string, msg FwhMqttMsg, resTopicName *string, resCmdType *uint) (*FwhMqttMsg, error) {
-	if c.Client == nil {
-		return nil, fmt.Errorf("Fwh Mqtt Client Uninitialized.")
-	}
-
-	ctxSend, cancel := context.WithTimeout(ctx, sendMessageTimeout)
-	defer cancel()
-
-	topic := topicPrefixS2C + topicName
-	var resTopic *string
-	if resTopicName != nil {
-		resTopicStr := topicPrefixC2S + *resTopicName
-		resTopic = &resTopicStr
-	}
-
-	taskMsg := MqttMsg{
-		Topic:      topic,
-		Msg:        msg,
-		ResTopic:   resTopic,
-		ResCmdType: resCmdType,
-		ResultCh:   make(chan FwhMqttResult, 1),
-		Ctx:        ctxSend,
-	}
-	c.TaskCh <- taskMsg
-
-	for {
-		select {
-		case result, ok := <-taskMsg.ResultCh:
-			if !ok {
-				zap.L().Error("Result channel closed, worker exiting, recreate.", zap.String("topic", topic), zap.String("msg", fmt.Sprintf("%+v", msg)))
-				return nil, fmt.Errorf("Result channel closed, worker exiting, recreate.")
-			}
-
-			if result.Err != nil {
-				return nil, result.Err
-			}
-
-			return result.Data, nil
-		case <-ctxSend.Done():
-			zap.L().Info("Result received timeout.", zap.String("topic", topic), zap.String("msg", fmt.Sprintf("%+v", msg)))
-			return nil, fmt.Errorf("Result received timeout.")
-		}
-	}
-}
-
-// 仅发送不等待响应结果
-func (c *FwhMqttClient) sendMessageOnce(ctx context.Context, topicName string, msg FwhMqttMsg) error {
-	_, err := c.sendMessage(ctx, topicName, msg, nil, nil)
-	return err
-}
-
-// 发送消息后,等待响应结果
-func (c *FwhMqttClient) sendMessageWait(ctx context.Context, topicName string, msg FwhMqttMsg, resTopicName string, resCmdType uint) (*FwhMqttMsg, error) {
-	res, err := c.sendMessage(ctx, topicName, msg, &resTopicName, &resCmdType)
-	return res, err
-}

+ 0 - 43
service/repositories/mqtt/fwh_mqtt_test/fwh_mqtt_debug.go

@@ -1,43 +0,0 @@
-// debug 设备调试数据
-package fwh_mqtt_test
-
-import (
-	"context"
-	"encoding/json"
-	"fmt"
-	"hash/crc32"
-	"strings"
-	"time"
-)
-
-func EnableXlog(ctx context.Context, sn string) (string, error) {
-	dataArea := map[string]string{
-		"xlogaddr": "18.188.130.67:9081",
-	}
-
-	dataStr, err := json.Marshal(dataArea)
-	if err != nil {
-		return "", err
-	}
-
-	res, err := fwhMqttClient.sendMessageWait(ctx, "debug/"+sn, FwhMqttMsg{
-		CmdType:   2000, // xlog请求
-		Type:      0,    // IBG
-		Snno:      123,
-		TimeStamp: time.Now().Unix(),
-		EquipNo:   sn,
-		DataArea:  dataArea,
-		Len:       len(dataStr),
-		Crc:       strings.ToUpper(fmt.Sprintf("%08X", crc32.ChecksumIEEE(dataStr))),
-	}, "debug/"+sn, 2001)
-	if err != nil {
-		return "", err
-	}
-
-	b, err := json.Marshal(res.DataArea)
-	if err != nil {
-		return "", err
-	}
-
-	return string(b), nil
-}

+ 0 - 202
service/repositories/mqtt/fwh_mqtt_test/fwh_mqtt_fault_record.go

@@ -1,202 +0,0 @@
-// fault record 故障录波请求
-package fwh_mqtt_test
-
-import (
-	"context"
-	"encoding/json"
-	"fmt"
-	"fwh-toolbox-server/dto"
-	"hash/crc32"
-	"strings"
-	"time"
-
-	"go.uber.org/zap"
-)
-
-const (
-	FaultRecordReqCmdType = 377 // 请求协议类型
-	FaultRecordResCmdType = 378 // 应答协议类型
-)
-
-type ParamSettingParaListItem struct {
-	Id     int    `json:"id"`
-	SetVal string `json:"setVal"`
-}
-
-type ParamSettingInfoItem struct {
-	Sn       string                     `json:"sn"` // aGate/aPower SN
-	ParaList []ParamSettingParaListItem `json:"paraList"`
-}
-
-type ParamSettingListItem struct {
-	Type string                 `json:"type"` // 'pe' or 'aGate' or 'mppt'
-	Info []ParamSettingInfoItem `json:"info"`
-}
-
-type ParamSettingData struct {
-	List []ParamSettingListItem `json:"list"`
-}
-
-type FaultRecordDataArea struct {
-	Cmd  int         `json:"cmd"` // 0:实时数据获取;1:参数设置;2:文件获取
-	Data interface{} `json:"data"`
-}
-
-type FaultRecordResponse struct {
-	Cmd    int `json:"cmd"`    // 0:实时数据获取;1:参数设置;2:文件获取
-	Result int `json:"result"` // 0:成功;-1:失败
-}
-
-func ChargeChannel(ctx context.Context, sn string) error {
-	dataArea := map[string]interface{}{
-		"cmd": 1,
-		"data": map[string]interface{}{
-			"list": []interface{}{
-				map[string]interface{}{
-					"type": "pe",
-					"info": []interface{}{
-						map[string]interface{}{
-							"sn": "10070025A05X25310280",
-							"paraList": []interface{}{
-								map[string]interface{}{
-									"id":     6,
-									"setVal": "010100015984",
-								},
-								map[string]interface{}{
-									"id":     6,
-									"setVal": "020100015981",
-								},
-								map[string]interface{}{
-									"id":     6,
-									"setVal": "030100015983",
-								},
-								map[string]interface{}{
-									"id":     6,
-									"setVal": "04030003FD34",
-								},
-							},
-						},
-					},
-				},
-			},
-		},
-	}
-
-	dataStr, err := json.Marshal(dataArea)
-	if err != nil {
-		return err
-	}
-
-	fmt.Println(string(dataStr))
-
-	err = fwhMqttClient.sendMessageOnce(ctx, "mng/"+sn, FwhMqttMsg{
-		CmdType:   377, // 故障录波请求
-		Type:      0,   // IBG
-		Snno:      123,
-		TimeStamp: time.Now().Unix(),
-		EquipNo:   sn,
-		DataArea:  dataArea,
-		Len:       len(dataStr),
-		Crc:       strings.ToUpper(fmt.Sprintf("%08X", crc32.ChecksumIEEE(dataStr))),
-	})
-	if err != nil {
-		return err
-	}
-
-	return nil
-}
-
-func FaultRecordParamSetting(ctx context.Context, data dto.FaultRecordParamInfo) error {
-	siteSn := data.Sn
-
-	peParaList := []ParamSettingInfoItem{}
-	agateParaList := []ParamSettingInfoItem{}
-
-	for _, value := range data.List {
-		var paraSettingList []ParamSettingParaListItem
-		for _, v := range value.ParaList {
-			paraSettingList = append(paraSettingList, ParamSettingParaListItem{
-				Id:     v.Id,
-				SetVal: v.Val,
-			})
-		}
-
-		switch value.Type {
-		case "pe":
-			peParaList = append(peParaList, ParamSettingInfoItem{
-				Sn:       value.Sn,
-				ParaList: paraSettingList,
-			})
-		case "agate":
-			agateParaList = append(agateParaList, ParamSettingInfoItem{
-				Sn:       value.Sn,
-				ParaList: paraSettingList,
-			})
-		}
-	}
-
-	paraData := ParamSettingData{
-		List: []ParamSettingListItem{},
-	}
-
-	if len(agateParaList) > 0 {
-		paraData.List = append(paraData.List, ParamSettingListItem{
-			Type: "aGate",
-			Info: agateParaList,
-		})
-	}
-
-	if len(peParaList) > 0 {
-		paraData.List = append(paraData.List, ParamSettingListItem{
-			Type: "pe",
-			Info: peParaList,
-		})
-	}
-
-	dataArea := FaultRecordDataArea{
-		Cmd:  1, // 参数设置
-		Data: paraData,
-	}
-
-	dataStr, err := json.Marshal(dataArea)
-	if err != nil {
-		return err
-	}
-
-	fmt.Println(string(dataStr))
-
-	msg, err := fwhMqttClient.sendMessageWait(ctx, "mng/"+siteSn, FwhMqttMsg{
-		CmdType:   FaultRecordReqCmdType, // 故障录波请求
-		Type:      0,                     // IBG
-		Snno:      123,
-		TimeStamp: time.Now().Unix(),
-		EquipNo:   siteSn,
-		DataArea:  dataArea,
-		Len:       len(dataStr),
-		Crc:       strings.ToUpper(fmt.Sprintf("%08X", crc32.ChecksumIEEE(dataStr))),
-	}, "mng/"+siteSn, FaultRecordResCmdType)
-	if err != nil {
-		return err
-	}
-
-	if msg == nil {
-		return fmt.Errorf("FaultRecordParamSetting response data is empty.")
-	}
-
-	var res FaultRecordResponse
-	b, err := json.Marshal(msg.DataArea)
-	if err != nil {
-		return err
-	}
-
-	if err := json.Unmarshal(b, &res); err != nil {
-		zap.L().Error("FaultRecordParamSetting response data type unmarshal error.", zap.String("DataArea", string(b)), zap.String("err", err.Error()))
-		return err
-	}
-
-	if res.Result != 0 {
-		return fmt.Errorf("FaultRecord param setting failed.")
-	}
-
-	return nil
-}

+ 0 - 71
service/repositories/mqtt/fwh_mqtt_test/test/main.go

@@ -1,71 +0,0 @@
-package main
-
-import (
-	"context"
-	"fmt"
-	"fwh-toolbox-server/config"
-	"fwh-toolbox-server/dto"
-	"fwh-toolbox-server/logger"
-	"fwh-toolbox-server/repositories/mqtt/fwh_mqtt_test"
-	"time"
-)
-
-func main() {
-	err := logger.Init("dev")
-	if err != nil {
-		panic(err)
-	}
-	defer logger.Log.Sync() // 退出前同步zap缓冲区
-
-	mqttConfig := config.FwhMqttConfig{
-		FwhMqttBroker:  "tls://a35sger20yyec4-ats.iot.us-west-2.amazonaws.com:8883",
-		FwhMqttCaUrl:   "/var/www/fwh-toolbox/server/cert/test/AmazonRootCA1.pem",
-		FwhMqttCertUrl: "/var/www/fwh-toolbox/server/cert/test/01.certificate.pem.crt",
-		FwhMqttKeyUrl:  "/var/www/fwh-toolbox/server/cert/test/01.private.pem.key",
-	}
-	fwh_mqtt_test.InitFwhMqttClient(&mqttConfig)
-
-	// fwh_mqtt_test.ChargeChannel(context.Background(), "EamonAux")
-	// time.Sleep(2 * time.Minute)
-	// fwh_mqtt_test.ChargeChannel(context.Background(), "EamonAux")
-	// time.Sleep(2 * time.Minute)
-
-	data := dto.FaultRecordParamInfo{
-		Sn: "EamonAux",
-		List: []dto.FaultRecordSubParam{
-			{
-				Type: "pe",
-				Sn:   "10070025A05X25310280",
-				ParaList: []dto.FaultRecordSubParamItem{
-					{
-						Id:  6,
-						Val: "010100015984",
-					},
-					{
-						Id:  6,
-						Val: "020100015981",
-					},
-					{
-						Id:  6,
-						Val: "030100015983",
-					},
-					{
-						Id:  6,
-						Val: "04030003FD34",
-					},
-				},
-			},
-		},
-	}
-
-	err = fwh_mqtt_test.FaultRecordParamSetting(context.Background(), data)
-	if err != nil {
-		fmt.Println(err.Error())
-	}
-	time.Sleep(2 * time.Minute)
-	err = fwh_mqtt_test.FaultRecordParamSetting(context.Background(), data)
-	if err != nil {
-		fmt.Println(err.Error())
-	}
-	time.Sleep(2 * time.Minute)
-}

+ 44 - 0
service/repositories/mqtt/fwh_xlog/fwh_xlog.go

@@ -0,0 +1,44 @@
+// Package fwh_xlog 设备 Xlog 调试开关(基于 fwh_mqtt 一问一答)
+package fwh_xlog
+
+import (
+	"context"
+	"encoding/json"
+
+	"fwh-toolbox-server/repositories/mqtt/fwh_mqtt"
+)
+
+const (
+	cmdTypeXlogReq = 2000 // xlog请求
+	cmdTypeXlogRes = 2001 // xlog应答
+	xlogAddr       = "18.188.130.67:9081"
+)
+
+// EnableXlog 使能站点 xlog,只在生产服使用
+func EnableXlog(ctx context.Context, sn string) (string, error) {
+	client, err := fwh_mqtt.GetClient(fwh_mqtt.EnvProd)
+	if err != nil {
+		return "", err
+	}
+
+	msg, err := fwh_mqtt.NewEnvelope(cmdTypeXlogReq, sn, map[string]string{"xlogaddr": xlogAddr})
+	if err != nil {
+		return "", err
+	}
+
+	res, err := client.Request(ctx, fwh_mqtt.RequestOpt{
+		Topic:    fwh_mqtt.S2CTopic("debug/" + sn),
+		Msg:      msg,
+		ResTopic: fwh_mqtt.C2STopic("debug/" + sn),
+		Match:    func(m fwh_mqtt.Message) bool { return m.Envelope.CmdType == cmdTypeXlogRes },
+	})
+	if err != nil {
+		return "", err
+	}
+
+	b, err := json.Marshal(res.Envelope.DataArea)
+	if err != nil {
+		return "", err
+	}
+	return string(b), nil
+}

+ 12 - 5
service/repositories/repositories.go

@@ -13,6 +13,7 @@ import (
 	"fwh-toolbox-server/repositories/events"
 	"fwh-toolbox-server/repositories/mongo"
 	"fwh-toolbox-server/repositories/mqtt/fwh_mqtt"
+	"fwh-toolbox-server/repositories/mqtt/fwh_xlog"
 	pgsql_alarmSnapshot "fwh-toolbox-server/repositories/pgsql/alarmSnapshot"
 	pgsql_original "fwh-toolbox-server/repositories/pgsql/original"
 	"fwh-toolbox-server/repositories/redis"
@@ -505,11 +506,17 @@ var DeleteAiChatSession = mongo.DeleteAiChatSession
 
 /*************** MQTT ***************/
 // FwhMqtt
+type FwhMqttEnv = fwh_mqtt.Env
+
+const (
+	FwhMqttEnvProd = fwh_mqtt.EnvProd // 生产服
+	FwhMqttEnvTest = fwh_mqtt.EnvTest // 测试服
+)
+
+var FwhMqttParseEnv = fwh_mqtt.ParseEnv
 var FwhMqttInit = fwh_mqtt.InitFwhMqttClient
 
-// FwhMqtt - Xlog
-var FwhMqttEnableXlog = fwh_mqtt.EnableXlog
+var FwhMqttGetClient = fwh_mqtt.GetClient
 
-// FwhMqtt - Fault Record
-var FwhMqttFaultRecordChargeChannel = fwh_mqtt.ChargeChannel
-var FwhMqttFaultRecordParamSetting = fwh_mqtt.FaultRecordParamSetting
+// FwhMqtt - Xlog
+var FwhMqttEnableXlog = fwh_xlog.EnableXlog

+ 17 - 4
service/routes/faultRecordRoutes.go

@@ -1,6 +1,13 @@
 /*
- * Fault Record API List
- * /faultRecord/paramSetting - POST 设置故障录波参数
+ * Fault Record API List(MQTT 直连,支持 env=prod|test)
+ * /faultRecord/devices                    - GET  查询网关下可操作的设备
+ * /faultRecord/operations                 - POST 创建操作(异步)
+ * /faultRecord/operations                 - GET  操作历史
+ * /faultRecord/operations/:id             - GET  操作详情
+ * /faultRecord/operations/:id/cancel      - POST 取消操作
+ * /faultRecord/operations/:id/retry-download - POST 手动重试下载
+ * /faultRecord/operations/:id/record      - GET  录波详情
+ * /faultRecord/operations/:id/export      - GET  导出(format=raw|csv)
  */
 package routes
 
@@ -11,6 +18,12 @@ import (
 )
 
 func FaultRecordSetup(api fiber.Router) {
-	// 设置故障录波参数
-	api.Post("/faultRecord/paramSetting", controllers.SetFaultRecordParam)
+	api.Get("/faultRecord/devices", controllers.GetFaultRecordDevices)
+	api.Post("/faultRecord/operations", controllers.CreateFaultRecordOperation)
+	api.Get("/faultRecord/operations", controllers.ListFaultRecordOperations)
+	api.Get("/faultRecord/operations/:id", controllers.GetFaultRecordOperation)
+	api.Post("/faultRecord/operations/:id/cancel", controllers.CancelFaultRecordOperation)
+	api.Post("/faultRecord/operations/:id/retry-download", controllers.RetryFaultRecordDownload)
+	api.Get("/faultRecord/operations/:id/record", controllers.GetFaultRecordRecord)
+	api.Get("/faultRecord/operations/:id/export", controllers.ExportFaultRecordFile)
 }

+ 2 - 2
service/services/aiChatServices.go

@@ -32,8 +32,8 @@ type ACPSession struct {
 	SessionId      string
 	Title          string
 	WelcomeMessage string
-	AgentId        string // 实际生效的 agent(currentModeId)
-	ModelId        string // 实际生效的 model(currentModelId)
+	AgentId        string              // 实际生效的 agent(currentModeId)
+	ModelId        string              // 实际生效的 model(currentModelId)
 	ReplayMessages []acp.ReplayMessage // session/load 回放的历史消息
 	Client         *acp.Client
 	Mutex          sync.Mutex

+ 413 - 4
service/services/faultRecordServices.go

@@ -2,11 +2,420 @@ package services
 
 import (
 	"context"
+	"encoding/json"
+	"errors"
+	"fmt"
+	"strings"
+	"time"
+
+	"fwh-toolbox-server/domain"
 	"fwh-toolbox-server/dto"
-	"fwh-toolbox-server/repositories"
+	faultRecordFile "fwh-toolbox-server/function/FaultRecordFile"
+	proto "fwh-toolbox-server/function/FaultRecordProtocol"
+	"fwh-toolbox-server/repositories/api/fwh_pro"
+	"fwh-toolbox-server/repositories/api/fwh_test"
+	minioRepo "fwh-toolbox-server/repositories/minio"
+	mongoRepo "fwh-toolbox-server/repositories/mongo"
+	"fwh-toolbox-server/repositories/mqtt/fwh_mqtt"
+	"fwh-toolbox-server/task/queue/faultRecordQueue"
+
+	"go.mongodb.org/mongo-driver/bson/primitive"
+	"go.uber.org/zap"
 )
 
-// 设置故障录波参数
-func SetFaultRecordParam(ctx context.Context, para dto.FaultRecordParamInfo) error {
-	return repositories.FwhMqttFaultRecordParamSetting(ctx, para)
+var ErrFaultRecordInvalid = errors.New("invalid request")
+
+func faultRecordInvalid(format string, a ...any) error {
+	return fmt.Errorf("%w: %s", ErrFaultRecordInvalid, fmt.Sprintf(format, a...))
+}
+
+/************** 设备列表 **************/
+
+// GetFaultRecordSiteDevices 按环境查询站点信息,返回 aGate 及各 aPower(pe)。
+// 只调用 Repository,不写站点信息表(测试服数据不能写入)。
+func GetFaultRecordSiteDevices(envStr, gatewaySn string) (*dto.FaultRecordSiteDevices, error) {
+	env, err := fwh_mqtt.ParseEnv(envStr)
+	if err != nil {
+		return nil, faultRecordInvalid("%v", err)
+	}
+	gatewaySn = strings.TrimSpace(gatewaySn)
+	if gatewaySn == "" {
+		return nil, faultRecordInvalid("gatewaySn is empty")
+	}
+
+	para := dto.SiteInfoPara{SN: &gatewaySn, PageSize: 10, PageNum: 1}
+	var res *domain.FwhPageResponse[[]domain.FwhSiteInfo]
+	if env == fwh_mqtt.EnvTest {
+		res, err = fwh_test.QuerySiteInfoList(para)
+	} else {
+		res, err = fwh_pro.QuerySiteInfoList(para)
+	}
+	if err != nil {
+		return nil, err
+	}
+	if res.Result == nil || len(*res.Result) == 0 {
+		return nil, fmt.Errorf("站点不存在(%s)", gatewaySn)
+	}
+	var site *domain.FwhSiteInfo
+	for i := range *res.Result {
+		if (*res.Result)[i].Id == gatewaySn {
+			site = &(*res.Result)[i]
+		}
+	}
+	if site == nil {
+		return nil, fmt.Errorf("站点不存在(%s)", gatewaySn)
+	}
+
+	out := &dto.FaultRecordSiteDevices{
+		Env:       string(env),
+		GatewaySn: site.Id,
+		Online:    site.Status == 1,
+		Devices:   []dto.FaultRecordDevice{{Type: proto.DeviceTypeAGate, Sn: site.Id}},
+	}
+	if site.Name != nil {
+		out.SiteName = *site.Name
+	}
+	if site.OnLineTime != nil {
+		out.OnlineTime = *site.OnLineTime
+	}
+	for _, sn := range strings.Split(site.FhpSn, ",") {
+		if sn = strings.TrimSpace(sn); sn != "" {
+			out.Devices = append(out.Devices, dto.FaultRecordDevice{Type: proto.DeviceTypePE, Sn: sn}) // aPower SN 即 pe SN
+		}
+	}
+	return out, nil
+}
+
+/************** 创建/取消 **************/
+
+// CreateFaultRecordOperation 校验并创建操作,提交异步执行
+func CreateFaultRecordOperation(ctx context.Context, req dto.FaultRecordCreateOpReq, operator string) (string, error) {
+	env, err := fwh_mqtt.ParseEnv(req.Env)
+	if err != nil {
+		return "", faultRecordInvalid("%v", err)
+	}
+	if _, err := fwh_mqtt.GetClient(env); err != nil {
+		return "", fmt.Errorf("%s 环境 MQTT 不可用:%w", env, err)
+	}
+	gatewaySn := strings.TrimSpace(req.GatewaySn)
+	if gatewaySn == "" {
+		return "", faultRecordInvalid("gatewaySn is empty")
+	}
+	deviceType, err := proto.NormalizeDeviceType(req.DeviceType)
+	if err != nil {
+		return "", faultRecordInvalid("%v", err)
+	}
+	deviceSn := strings.TrimSpace(req.DeviceSn)
+	if deviceType == proto.DeviceTypeAGate && deviceSn == "" {
+		deviceSn = gatewaySn
+	}
+
+	kind := domain.FaultRecordOpKind(req.Kind)
+	op := &domain.FaultRecordOperation{
+		Env:        string(env),
+		GatewaySn:  gatewaySn,
+		DeviceType: deviceType,
+		DeviceSn:   deviceSn,
+		Kind:       kind,
+		Cmd:        kind.Cmd(),
+		Operator:   operator,
+		Status:     domain.FaultRecordStatusPending,
+		Request: domain.FaultRecordOpRequest{
+			CodeList:      req.CodeList,
+			RecordIndexes: recordIndexesOf(req),
+			IdList:        req.IdList,
+			ParaList:      req.ParaList,
+		},
+	}
+	if op.Cmd < 0 {
+		return "", faultRecordInvalid("unknown kind %s", req.Kind)
+	}
+	// 与执行器使用同一构造函数校验参数,尽早返回错误
+	if _, err := faultRecordQueue.BuildRequest(op); err != nil {
+		return "", faultRecordInvalid("%v", err)
+	}
+
+	if err := mongoRepo.InsertFaultRecordOp(ctx, op); err != nil {
+		return "", err
+	}
+	if err := faultRecordQueue.Enqueue(op.ID); err != nil {
+		_, _ = mongoRepo.UpdateFaultRecordOpStatus(ctx, op.ID, domain.FaultRecordStatusError, map[string]any{"error": "提交任务失败:" + err.Error()})
+		return "", err
+	}
+	return op.ID.Hex(), nil
+}
+
+// CancelFaultRecordOperation 请求取消;执行器在 1 秒内响应
+func CancelFaultRecordOperation(ctx context.Context, idStr string) error {
+	id, err := primitive.ObjectIDFromHex(idStr)
+	if err != nil {
+		return faultRecordInvalid("invalid id")
+	}
+	ok, err := mongoRepo.RequestFaultRecordOpCancel(ctx, id)
+	if err != nil {
+		return err
+	}
+	if !ok {
+		return faultRecordInvalid("操作已结束,无法取消")
+	}
+	return nil
+}
+
+// RetryFaultRecordDownload 上传已完成但下载失败时,手动重新下载(按 15 秒间隔重试至总超时)
+func RetryFaultRecordDownload(ctx context.Context, idStr, operator string) error {
+	op, err := getFaultRecordOp(ctx, idStr)
+	if err != nil {
+		return err
+	}
+	if !op.CanRetryDownload() {
+		return faultRecordInvalid("当前状态不能重试下载(需上传已完成且下载失败或超时)")
+	}
+	ok, err := mongoRepo.ReopenFaultRecordOpForDownloadRetry(ctx, op.ID)
+	if err != nil {
+		return err
+	}
+	if !ok {
+		return faultRecordInvalid("操作状态已变化,请刷新后再试")
+	}
+	// 在原始报文中记录本次手动重试(先于提交任务,保证执行器读到的报文条数包含它)
+	attempt := op.DownloadRetryCount + 1
+	raw, _ := json.Marshal(map[string]any{
+		"action":     "retry_download",
+		"operator":   operator,
+		"attempt":    attempt,
+		"prevStatus": op.Status,
+		"prevError":  op.Error,
+	})
+	if err := mongoRepo.AppendFaultRecordOpMessage(ctx, op.ID, domain.FaultRecordOpMessage{
+		Direction: "action", Topic: "手动重试下载", At: time.Now(), Raw: string(raw),
+	}); err != nil {
+		zap.L().Warn("fault record append retry action failed", zap.String("id", op.ID.Hex()), zap.Error(err))
+	}
+	if err := faultRecordQueue.EnqueueDownloadRetry(op.ID, attempt); err != nil {
+		_, _ = mongoRepo.UpdateFaultRecordOpStatus(ctx, op.ID, domain.FaultRecordStatusDownloadFailed, map[string]any{"error": "提交重试下载失败:" + err.Error()})
+		return err
+	}
+	return nil
+}
+
+/************** 查询 **************/
+
+func ListFaultRecordOperations(ctx context.Context, f mongoRepo.FaultRecordOpFilter, page, size int64) (*dto.ResPage[dto.FaultRecordOpSummary], error) {
+	list, total, err := mongoRepo.ListFaultRecordOps(ctx, f, page, size)
+	if err != nil {
+		return nil, err
+	}
+	out := &dto.ResPage[dto.FaultRecordOpSummary]{
+		Data:     make([]dto.FaultRecordOpSummary, 0, len(list)),
+		PageNum:  uint(page),
+		PageSize: uint(size),
+		Total:    uint(total),
+	}
+	for i := range list {
+		out.Data = append(out.Data, toFaultRecordSummary(&list[i]))
+	}
+	return out, nil
+}
+
+func GetFaultRecordOperation(ctx context.Context, idStr string) (*dto.FaultRecordOpDetail, error) {
+	op, err := getFaultRecordOp(ctx, idStr)
+	if err != nil {
+		return nil, err
+	}
+	d := &dto.FaultRecordOpDetail{
+		FaultRecordOpSummary: toFaultRecordSummary(op),
+		Request:              op.Request,
+		Items:                []dto.FaultRecordResultItem{},
+		Messages:             make([]dto.FaultRecordOpMessageView, 0, len(op.Messages)),
+	}
+	for _, m := range op.Messages {
+		d.Messages = append(d.Messages, dto.FaultRecordOpMessageView(m))
+	}
+	if op.Reply != nil {
+		d.ReplyResult = op.Reply.Result
+		d.ReplyDesc = proto.DescribeReplyResult(op.Reply.Result)
+		if info, ok := op.Reply.FindInfo(op.DeviceType, op.DeviceSn); ok {
+			d.Items = faultRecordResultItems(op, info)
+		}
+	}
+	return d, nil
+}
+
+// GetFaultRecordRecord 读取并解析已保存的录波文件
+func GetFaultRecordRecord(ctx context.Context, idStr string) (*dto.FaultRecordRecordView, error) {
+	op, data, err := readFaultRecordFile(ctx, idStr)
+	if err != nil {
+		return nil, err
+	}
+	records, err := faultRecordFile.ParseArchive(data, op.DeviceType)
+	if err != nil {
+		return nil, fmt.Errorf("解析录波文件失败:%w", err)
+	}
+	return &dto.FaultRecordRecordView{OperationID: op.ID.Hex(), Records: records}, nil
+}
+
+// ExportFaultRecordFile 导出原始压缩包或 CSV(zip),返回文件名和内容
+func ExportFaultRecordFile(ctx context.Context, idStr, format string) (string, []byte, error) {
+	op, data, err := readFaultRecordFile(ctx, idStr)
+	if err != nil {
+		return "", nil, err
+	}
+	switch format {
+	case "", "raw":
+		return proto.UploadFileName(op.FileName), data, nil
+	case "csv":
+		records, err := faultRecordFile.ParseArchive(data, op.DeviceType)
+		if err != nil {
+			return "", nil, fmt.Errorf("解析录波文件失败:%w", err)
+		}
+		zipData, err := faultRecordFile.BuildCSVZip(records)
+		if err != nil {
+			return "", nil, err
+		}
+		return proto.BatchFromUploadFileName(op.FileName) + "_csv.zip", zipData, nil
+	}
+	return "", nil, faultRecordInvalid("unknown format %s", format)
+}
+
+func recordIndexesOf(req dto.FaultRecordCreateOpReq) []int {
+	if len(req.RecordIndexes) > 0 {
+		return req.RecordIndexes
+	}
+	if req.RecordIndex > 0 {
+		return []int{req.RecordIndex}
+	}
+	return nil
+}
+
+func getFaultRecordOp(ctx context.Context, idStr string) (*domain.FaultRecordOperation, error) {
+	id, err := primitive.ObjectIDFromHex(idStr)
+	if err != nil {
+		return nil, faultRecordInvalid("invalid id")
+	}
+	return mongoRepo.GetFaultRecordOp(ctx, id)
+}
+
+func readFaultRecordFile(ctx context.Context, idStr string) (*domain.FaultRecordOperation, []byte, error) {
+	op, err := getFaultRecordOp(ctx, idStr)
+	if err != nil {
+		return nil, nil, err
+	}
+	if op.MinioObject == "" {
+		return nil, nil, faultRecordInvalid("该操作没有已保存的录波文件")
+	}
+	data, err := minioRepo.ReadFaultRecordFile(ctx, op.MinioObject)
+	if err != nil {
+		return nil, nil, err
+	}
+	return op, data, nil
+}
+
+/************** 视图转换 **************/
+
+func toFaultRecordSummary(op *domain.FaultRecordOperation) dto.FaultRecordOpSummary {
+	s := dto.FaultRecordOpSummary{
+		ID:               op.ID.Hex(),
+		Env:              op.Env,
+		GatewaySn:        op.GatewaySn,
+		DeviceType:       op.DeviceType,
+		DeviceSn:         op.DeviceSn,
+		Kind:             string(op.Kind),
+		Summary:          faultRecordRequestSummary(op),
+		Operator:         op.Operator,
+		Status:           string(op.Status),
+		TimeoutStage:     string(op.TimeoutStage),
+		Error:            op.Error,
+		StageTimes:       op.StageTimes,
+		FileName:         op.FileName,
+		FaultRecordID:    op.FaultRecordID,
+		CanCancel:        !op.Status.Terminal() && !op.CancelReq,
+		CanRetryDownload: op.CanRetryDownload(),
+		HasFile:          op.MinioObject != "",
+		CreatedAt:        op.CreatedAt,
+		UpdatedAt:        op.UpdatedAt,
+	}
+	return s
+}
+
+func joinInts(list []int) string {
+	parts := make([]string, len(list))
+	for i, v := range list {
+		parts[i] = fmt.Sprint(v)
+	}
+	return strings.Join(parts, ",")
+}
+
+func faultRecordRequestSummary(op *domain.FaultRecordOperation) string {
+	r := op.Request
+	switch op.Kind {
+	case domain.FaultRecordOpDeviceInfo:
+		return "code=" + joinInts(r.CodeList)
+	case domain.FaultRecordOpRecordFetch:
+		return "记录序号 " + joinInts(r.Indexes())
+	case domain.FaultRecordOpParamRead:
+		return "功能码 " + joinInts(r.IdList)
+	case domain.FaultRecordOpParamWrite:
+		parts := make([]string, len(r.ParaList))
+		for i, p := range r.ParaList {
+			parts[i] = fmt.Sprintf("%d=%s", p.Id, p.SetVal)
+		}
+		return strings.Join(parts, "; ")
+	}
+	return ""
+}
+
+func faultRecordResultItems(op *domain.FaultRecordOperation, info *proto.ReplyInfo) []dto.FaultRecordResultItem {
+	items := []dto.FaultRecordResultItem{}
+	result := func(it *dto.FaultRecordResultItem, resultOne string) {
+		it.ResultOne = resultOne
+		it.ResultDesc = proto.DescribeResultOne(resultOne)
+		it.Ok = resultOne == proto.ResultOk
+	}
+	switch op.Kind {
+	case domain.FaultRecordOpDeviceInfo:
+		for _, h := range info.HexList {
+			it := dto.FaultRecordResultItem{Code: h.Code, Name: proto.RealtimeCodeNames[h.Code], Raw: h.HexData}
+			result(&it, h.ResultOne)
+			if it.Ok && h.HexData != "" {
+				if v, err := faultRecordFile.ParseRealtime(h.Code, h.HexData); err != nil {
+					it.DecodeErr = err.Error()
+				} else {
+					it.Decoded = v
+				}
+			}
+			items = append(items, it)
+		}
+	case domain.FaultRecordOpRecordFetch:
+		// 每条记录展开为 4(故障数据)、5(波形数据)两项,按请求顺序对应(待实测确认)
+		indexes := op.Request.Indexes()
+		for i, x := range info.IndexInfo {
+			name := map[int]string{4: "故障数据", 5: "波形数据"}[x.Index]
+			if len(indexes) > 1 && len(info.IndexInfo) == 2*len(indexes) {
+				name = fmt.Sprintf("记录 %d %s", indexes[i/2], name)
+			}
+			it := dto.FaultRecordResultItem{Code: x.Index, Name: name}
+			result(&it, x.ResultOne)
+			items = append(items, it)
+		}
+	case domain.FaultRecordOpParamRead:
+		for _, x := range info.IdInfo {
+			it := dto.FaultRecordResultItem{Id: x.Id, Name: proto.FunctionCodeNames[x.Id], Raw: x.IdData}
+			result(&it, x.ResultOne)
+			if it.Ok {
+				it.Decoded = proto.DecodeIdData(x.Id, x.IdData)
+			}
+			items = append(items, it)
+		}
+	case domain.FaultRecordOpParamWrite:
+		// 同一功能码可重复(如多个 id=6),按请求顺序对应(待实测确认)
+		for i, x := range info.SetResultList {
+			it := dto.FaultRecordResultItem{Id: x.Id, Name: proto.FunctionCodeNames[x.Id]}
+			if i < len(op.Request.ParaList) && op.Request.ParaList[i].Id == x.Id {
+				it.SetVal = op.Request.ParaList[i].SetVal
+			}
+			result(&it, x.ResultOne)
+			items = append(items, it)
+		}
+	}
+	return items
 }

+ 9 - 9
service/services/feedbackServices.go

@@ -19,8 +19,8 @@ import (
 type FeedbackHistoryItem struct {
 	Id        string `json:"id"`
 	Title     string `json:"title"`
-	Type      string `json:"type"`      // bug / feature
-	Status    int    `json:"status"`    // -2 Pending, -1 Rejected, 0 Snoozed, 1 Accepted, 2 Duplicate
+	Type      string `json:"type"`   // bug / feature
+	Status    int    `json:"status"` // -2 Pending, -1 Rejected, 0 Snoozed, 1 Accepted, 2 Duplicate
 	Priority  string `json:"priority"`
 	CreatedAt string `json:"createdAt"`
 	CreatedBy string `json:"createdBy"` // 提交人用户名
@@ -259,13 +259,13 @@ func containsLabel(labels []string, target string) bool {
 
 // FeedbackDetailResult 反馈详情
 type FeedbackDetailResult struct {
-	Id              string                `json:"id"`
-	Title           string                `json:"title"`
-	DescriptionHTML string                `json:"descriptionHtml"`
-	Priority        string                `json:"priority"`
-	Status          int                   `json:"status"`
-	CreatedAt       string                `json:"createdAt"`
-	Attachments     []FeedbackAttachment  `json:"attachments"`
+	Id              string               `json:"id"`
+	Title           string               `json:"title"`
+	DescriptionHTML string               `json:"descriptionHtml"`
+	Priority        string               `json:"priority"`
+	Status          int                  `json:"status"`
+	CreatedAt       string               `json:"createdAt"`
+	Attachments     []FeedbackAttachment `json:"attachments"`
 }
 
 // FeedbackAttachment 附件信息

+ 174 - 0
service/task/queue/faultRecordQueue/deps.go

@@ -0,0 +1,174 @@
+package faultRecordQueue
+
+import (
+	"context"
+	"fmt"
+	"time"
+
+	"fwh-toolbox-server/cache/redisx"
+	"fwh-toolbox-server/domain"
+	"fwh-toolbox-server/repositories/api/fwh_pro"
+	"fwh-toolbox-server/repositories/api/fwh_test"
+	minioRepo "fwh-toolbox-server/repositories/minio"
+	mongoRepo "fwh-toolbox-server/repositories/mongo"
+	"fwh-toolbox-server/repositories/mqtt/fwh_mqtt"
+
+	"go.mongodb.org/mongo-driver/bson"
+	"go.mongodb.org/mongo-driver/bson/primitive"
+	"go.uber.org/zap"
+)
+
+// 执行器依赖的外部能力,抽象为接口便于测试
+
+type mqttBus interface {
+	Publish(ctx context.Context, topic string, msg fwh_mqtt.FwhMqttMsg) error
+	Subscribe(ctx context.Context, topic string, h fwh_mqtt.Handler) (func(), error)
+}
+
+type opStore interface {
+	Get(ctx context.Context, id primitive.ObjectID) (*domain.FaultRecordOperation, error)
+	UpdateStatus(ctx context.Context, id primitive.ObjectID, status domain.FaultRecordOpStatus, extra bson.M) (bool, error)
+	SetFields(ctx context.Context, id primitive.ObjectID, fields bson.M) error
+	AppendMessage(ctx context.Context, id primitive.ObjectID, msg domain.FaultRecordOpMessage) error
+}
+
+type fileStore interface {
+	Put(ctx context.Context, key string, data []byte) error
+	Get(ctx context.Context, key string) ([]byte, error)
+}
+
+type locker interface {
+	// Acquire 以 token 加锁并自动续期;返回的 release 停止续期并释放
+	Acquire(ctx context.Context, key, token string, ttl time.Duration) (release func(), ok bool, err error)
+}
+
+type deps struct {
+	bus         func(env string) (mqttBus, error)
+	store       opStore
+	files       fileStore
+	download    func(env, equipNo, fileName string) ([]byte, error)
+	downloadURL func(env, equipNo, fileName string) string
+	lock        locker
+	timeouts    timeouts
+}
+
+type timeouts struct {
+	ack                 time.Duration // 发送 → 378
+	reply               time.Duration // 378 → 379(cmd=0/1/3)
+	replyRecord         time.Duration // 378 → 379(cmd=2,设备需读取子设备记录)
+	replyPerRecord      time.Duration // cmd=2 每多一条记录增加的等待
+	upload              time.Duration // 379 → 609
+	uploadPerRecord     time.Duration // 每多一条记录增加的上传等待
+	cancelPoll          time.Duration
+	lockTTL             time.Duration
+	drain               time.Duration // 操作结束后继续记录报文的时间
+	afterUploadAck      time.Duration // 收到 610 后等待多久开始下载
+	downloadInterval    time.Duration // 下载失败后的重试间隔,一直重试至总超时
+	manualRetryAttempts int           // 手动重试下载的最多尝试次数
+}
+
+var defaultTimeouts = timeouts{
+	ack:                 30 * time.Second,
+	reply:               2 * time.Minute,
+	replyRecord:         4 * time.Minute,
+	replyPerRecord:      time.Minute, // 样本单条约 43 秒
+	upload:              5 * time.Minute,
+	uploadPerRecord:     30 * time.Second,
+	cancelPoll:          time.Second,
+	lockTTL:             2 * time.Minute,
+	drain:               2 * time.Second,
+	afterUploadAck:      15 * time.Second, // 用户要求:收到 610 后 15 秒开始下载
+	downloadInterval:    15 * time.Second, // 用户要求:失败后每 15 秒重试
+	manualRetryAttempts: 3,                // 用户要求:手动重试只尝试 3 次
+}
+
+/************** 默认实现 **************/
+
+type mongoStore struct{}
+
+func (mongoStore) Get(ctx context.Context, id primitive.ObjectID) (*domain.FaultRecordOperation, error) {
+	return mongoRepo.GetFaultRecordOp(ctx, id)
+}
+func (mongoStore) UpdateStatus(ctx context.Context, id primitive.ObjectID, s domain.FaultRecordOpStatus, extra bson.M) (bool, error) {
+	return mongoRepo.UpdateFaultRecordOpStatus(ctx, id, s, extra)
+}
+func (mongoStore) SetFields(ctx context.Context, id primitive.ObjectID, f bson.M) error {
+	return mongoRepo.SetFaultRecordOpFields(ctx, id, f)
+}
+func (mongoStore) AppendMessage(ctx context.Context, id primitive.ObjectID, m domain.FaultRecordOpMessage) error {
+	return mongoRepo.AppendFaultRecordOpMessage(ctx, id, m)
+}
+
+type minioStore struct{}
+
+func (minioStore) Put(ctx context.Context, key string, data []byte) error {
+	return minioRepo.PutFaultRecordFile(ctx, key, data)
+}
+func (minioStore) Get(ctx context.Context, key string) ([]byte, error) {
+	return minioRepo.ReadFaultRecordFile(ctx, key)
+}
+
+func mqttBusFor(env string) (mqttBus, error) {
+	e, err := fwh_mqtt.ParseEnv(env)
+	if err != nil {
+		return nil, err
+	}
+	return fwh_mqtt.GetClient(e)
+}
+
+func downloadURLFor(env, equipNo, fileName string) string {
+	if env == string(fwh_mqtt.EnvTest) {
+		return fwh_test.FaultRecordUploadFileURL(equipNo, fileName)
+	}
+	return fwh_pro.FaultRecordUploadFileURL(equipNo, fileName)
+}
+
+func downloadFor(env, equipNo, fileName string) ([]byte, error) {
+	switch env {
+	case string(fwh_mqtt.EnvProd):
+		return fwh_pro.DownloadFaultRecordUploadFile(equipNo, fileName)
+	case string(fwh_mqtt.EnvTest):
+		return fwh_test.DownloadFaultRecordUploadFile(equipNo, fileName)
+	}
+	return nil, fmt.Errorf("unknown env %s", env)
+}
+
+type redisLocker struct{}
+
+func (redisLocker) Acquire(ctx context.Context, key, token string, ttl time.Duration) (func(), bool, error) {
+	lock, ok, err := redisx.TryLockWithToken(ctx, key, token, ttl)
+	if err != nil || !ok {
+		return nil, ok, err
+	}
+	stop := make(chan struct{})
+	go func() {
+		t := time.NewTicker(ttl / 3)
+		defer t.Stop()
+		for {
+			select {
+			case <-stop:
+				return
+			case <-t.C:
+				if err := lock.Extend(context.Background(), ttl); err != nil {
+					zap.L().Warn("fault record gateway lock extend failed", zap.String("key", key), zap.Error(err))
+				}
+			}
+		}
+	}()
+	return func() {
+		close(stop)
+		lock.SafeRelease(context.Background())
+	}, true, nil
+}
+
+func defaultDeps() deps {
+	return deps{
+		bus:         mqttBusFor,
+		store:       mongoStore{},
+		files:       minioStore{},
+		download:    downloadFor,
+		downloadURL: downloadURLFor,
+		lock:        redisLocker{},
+		timeouts:    defaultTimeouts,
+	}
+}

+ 624 - 0
service/task/queue/faultRecordQueue/executor.go

@@ -0,0 +1,624 @@
+package faultRecordQueue
+
+import (
+	"context"
+	"encoding/json"
+	"fmt"
+	"strconv"
+	"strings"
+	"time"
+
+	"fwh-toolbox-server/domain"
+	faultRecordFile "fwh-toolbox-server/function/FaultRecordFile"
+	proto "fwh-toolbox-server/function/FaultRecordProtocol"
+	minioRepo "fwh-toolbox-server/repositories/minio"
+	"fwh-toolbox-server/repositories/mqtt/fwh_mqtt"
+
+	"go.mongodb.org/mongo-driver/bson"
+	"go.mongodb.org/mongo-driver/bson/primitive"
+	"go.uber.org/zap"
+)
+
+// BuildRequest 按操作构造 377 的 dataArea
+func BuildRequest(op *domain.FaultRecordOperation) (proto.Request, error) {
+	switch op.Kind {
+	case domain.FaultRecordOpDeviceInfo:
+		return proto.NewRealtimeRequest(op.DeviceType, op.DeviceSn, op.Request.CodeList)
+	case domain.FaultRecordOpRecordFetch:
+		return proto.NewGetRecordRequest(op.DeviceType, op.DeviceSn, op.Request.Indexes())
+	case domain.FaultRecordOpParamRead:
+		return proto.NewQueryParamRequest(op.DeviceType, op.DeviceSn, op.Request.IdList)
+	case domain.FaultRecordOpParamWrite:
+		return proto.NewSetParamRequest(op.DeviceType, op.DeviceSn, op.Request.ParaList)
+	}
+	return proto.Request{}, fmt.Errorf("unknown operation kind %s", op.Kind)
+}
+
+func lockKey(op *domain.FaultRecordOperation) string {
+	return fmt.Sprintf("fault-record:gateway:%s:%s", op.Env, op.GatewaySn)
+}
+
+// 需要记录到操作中的接收报文类型
+var recordedCmdTypes = map[uint]bool{
+	proto.CmdTypeAck: true, proto.CmdTypeReply: true, proto.CmdTypeReplyAck: true,
+	proto.CmdTypeUploadCall: true, proto.CmdTypeUploadAck: true,
+	proto.CmdTypeUploadDone: true, proto.CmdTypeUploadDoneA: true,
+}
+
+// execution 一次操作的执行过程
+type execution struct {
+	d        deps
+	op       *domain.FaultRecordOperation
+	bus      mqttBus
+	events   chan fwh_mqtt.Message
+	batch    string // 379.filename
+	msgCount int    // 已记录的报文条数(本执行器是该操作报文的唯一写入方)
+	lastFail *downloadFailure
+}
+
+// downloadFailure 最近一条下载失败记录;连续相同的失败只更新这一条并累计次数
+type downloadFailure struct {
+	index   int
+	key     string
+	count   int
+	firstAt time.Time
+}
+
+func (e *execution) s2cTopic() string { return fwh_mqtt.S2CTopic("mng/" + e.op.GatewaySn) }
+func (e *execution) c2sTopic() string { return fwh_mqtt.C2STopic("mng/" + e.op.GatewaySn) }
+
+// 文件上传消息 607~610 走 debug topic:607/610 为 s2c,608/609 为 c2s(用户确认,2026-10-19)
+func (e *execution) uploadTopics() []string {
+	return []string{fwh_mqtt.S2CTopic("debug/" + e.op.GatewaySn), fwh_mqtt.C2STopic("debug/" + e.op.GatewaySn)}
+}
+
+// run 执行或恢复一个操作。业务失败写入操作状态并返回 nil;只有存储故障或 ctx 取消才返回错误,交给 asynq 重试/重新投递。
+func (d deps) run(ctx context.Context, id primitive.ObjectID) error {
+	op, err := d.store.Get(ctx, id)
+	if err != nil {
+		return err
+	}
+	if op.Status.Terminal() {
+		return nil
+	}
+	e := &execution{d: d, op: op, batch: proto.BatchFromUploadFileName(op.FileName), msgCount: len(op.Messages)}
+
+	if op.CancelReq && op.Status == domain.FaultRecordStatusPending {
+		return e.finish(ctx, domain.FaultRecordStatusCancelled, "", nil)
+	}
+	switch op.Status {
+	case domain.FaultRecordStatusSent, domain.FaultRecordStatusAccepted:
+		// 重发会清空设备导出文件,也无法确认设备当前状态,交由用户重新发起
+		return e.finish(ctx, domain.FaultRecordStatusInterrupted, "服务重启时命令已发出但未收到结果,无法确认设备状态,请重新发起", nil)
+	}
+
+	release, ok, err := d.lock.Acquire(ctx, lockKey(op), op.ID.Hex(), d.timeouts.lockTTL)
+	if err != nil {
+		return err
+	}
+	if !ok {
+		return e.finish(ctx, domain.FaultRecordStatusGatewayLocked, "该网关在本工具内有其他操作正在进行,请稍后再试", nil)
+	}
+	defer release()
+
+	switch op.Status {
+	case domain.FaultRecordStatusUploaded:
+		if op.DownloadRetryAt != nil {
+			// 手动重试下载:最多尝试 manualRetryAttempts 次
+			return e.downloadUntilTimeout(ctx, 0, d.timeouts.manualRetryAttempts)
+		}
+		return e.downloadUntilTimeout(ctx, 0, 0)
+	case domain.FaultRecordStatusDownloaded:
+		data, err := d.files.Get(ctx, op.MinioObject)
+		if err != nil {
+			return e.finish(ctx, domain.FaultRecordStatusDownloadFailed, "读取已保存的文件失败:"+err.Error(), nil)
+		}
+		return e.parse(ctx, data)
+	}
+
+	if e.bus, err = d.bus(op.Env); err != nil {
+		return e.finish(ctx, domain.FaultRecordStatusError, "MQTT 不可用:"+err.Error(), nil)
+	}
+	e.events = make(chan fwh_mqtt.Message, 128)
+	handler := func(m fwh_mqtt.Message) {
+		select {
+		case e.events <- m:
+		case <-ctx.Done():
+		}
+	}
+	cancelC2S, err := e.bus.Subscribe(ctx, e.c2sTopic(), handler)
+	if err != nil {
+		if ctx.Err() != nil {
+			return ctx.Err()
+		}
+		return e.finish(ctx, domain.FaultRecordStatusError, "订阅设备上行 topic 失败:"+err.Error(), nil)
+	}
+	defer cancelC2S()
+	// 平台下发的 607/380/610 在 s2c 方向;订阅失败不影响主流程,只是看不到这些消息
+	if cancelS2C, err := e.bus.Subscribe(ctx, e.s2cTopic(), handler); err == nil {
+		defer cancelS2C()
+	} else {
+		zap.L().Warn("fault record subscribe s2c failed", zap.String("topic", e.s2cTopic()), zap.Error(err))
+	}
+	if op.Kind == domain.FaultRecordOpRecordFetch {
+		for _, topic := range e.uploadTopics() {
+			cancelUpload, err := e.bus.Subscribe(ctx, topic, handler)
+			if err != nil {
+				// 订阅失败时收不到 607~610,只能在超时后按规则直接下载
+				zap.L().Warn("fault record subscribe upload topic failed", zap.String("topic", topic), zap.Error(err))
+				continue
+			}
+			defer cancelUpload()
+		}
+	}
+	// 结束前把已到达和稍后到达的报文(如 609 之后的 610)记录下来;先于取消订阅执行
+	defer e.drain(ctx)
+
+	switch op.Status {
+	case domain.FaultRecordStatusPending:
+		if done, err := e.send(ctx); done || err != nil {
+			return err
+		}
+	case domain.FaultRecordStatusReady, domain.FaultRecordStatusUploading:
+		// 恢复:文件名已知,先直接尝试下载,下载不到再继续等待 609
+		if data, err := e.download(ctx); err == nil {
+			return e.storeAndParse(ctx, data)
+		}
+	}
+	return e.loop(ctx)
+}
+
+// send 发送 377;返回 done=true 表示操作已结束
+func (e *execution) send(ctx context.Context) (bool, error) {
+	req, err := BuildRequest(e.op)
+	if err != nil {
+		return true, e.finish(ctx, domain.FaultRecordStatusError, "构造请求失败:"+err.Error(), nil)
+	}
+	if err := e.publish(ctx, req); err != nil {
+		if ctx.Err() != nil {
+			return true, ctx.Err()
+		}
+		return true, e.finish(ctx, domain.FaultRecordStatusError, "发送失败:"+err.Error(), nil)
+	}
+	return false, e.advance(ctx, domain.FaultRecordStatusSent, nil)
+}
+
+func (e *execution) publish(ctx context.Context, req proto.Request) error {
+	msg, err := fwh_mqtt.NewEnvelope(proto.CmdTypeRequest, e.op.GatewaySn, req)
+	if err != nil {
+		return err
+	}
+	if err := e.bus.Publish(ctx, e.s2cTopic(), msg); err != nil {
+		return err
+	}
+	raw, _ := json.Marshal(msg)
+	e.record(ctx, "send", e.s2cTopic(), proto.CmdTypeRequest, raw, time.Now())
+	return nil
+}
+
+func (e *execution) loop(ctx context.Context) error {
+	ticker := time.NewTicker(e.d.timeouts.cancelPoll)
+	defer ticker.Stop()
+	for {
+		timer := time.NewTimer(time.Until(e.deadline()))
+		select {
+		case <-ctx.Done():
+			timer.Stop()
+			return ctx.Err()
+		case m := <-e.events:
+			timer.Stop()
+			if done, err := e.handle(ctx, m); done || err != nil {
+				return err
+			}
+		case <-ticker.C:
+			timer.Stop()
+			if cur, err := e.d.store.Get(ctx, e.op.ID); err == nil && cur.CancelReq {
+				return e.cancel(ctx)
+			}
+		case <-timer.C:
+			return e.onTimeout(ctx)
+		}
+	}
+}
+
+func (e *execution) stageTime(s domain.FaultRecordOpStatus) time.Time {
+	if t, ok := e.op.StageTimes[string(s)]; ok {
+		return t
+	}
+	return time.Now()
+}
+
+func (e *execution) deadline() time.Time {
+	t := e.d.timeouts
+	switch e.op.Status {
+	case domain.FaultRecordStatusSent:
+		return e.stageTime(domain.FaultRecordStatusSent).Add(t.ack)
+	case domain.FaultRecordStatusAccepted:
+		if e.op.Kind == domain.FaultRecordOpRecordFetch {
+			return e.stageTime(domain.FaultRecordStatusAccepted).Add(t.replyRecord + time.Duration(e.extraRecords())*t.replyPerRecord)
+		}
+		return e.stageTime(domain.FaultRecordStatusAccepted).Add(t.reply)
+	default: // ready / uploading / uploaded
+		start := e.stageTime(domain.FaultRecordStatusReady)
+		if e.op.DownloadRetryAt != nil && e.op.Status == domain.FaultRecordStatusUploaded {
+			start = *e.op.DownloadRetryAt // 手动重试下载:从重试时刻起重新计算总超时
+		}
+		return start.Add(t.upload + time.Duration(e.extraRecords())*t.uploadPerRecord)
+	}
+}
+
+// extraRecords 第一条之外的记录数,用于按记录数延长等待
+func (e *execution) extraRecords() int {
+	if n := len(e.op.Request.Indexes()); n > 1 {
+		return n - 1
+	}
+	return 0
+}
+
+func (e *execution) onTimeout(ctx context.Context) error {
+	switch e.op.Status {
+	case domain.FaultRecordStatusSent:
+		return e.finishTimeout(ctx, domain.FaultRecordStatusAccepted, "设备未应答指令(378)")
+	case domain.FaultRecordStatusAccepted:
+		return e.finishTimeout(ctx, domain.FaultRecordStatusReady, "设备未上传结果(379)")
+	default:
+		// 609 可能漏收,最后再尝试一次下载
+		if data, err := e.download(ctx); err == nil {
+			return e.storeAndParse(ctx, data)
+		}
+		return e.finishTimeout(ctx, domain.FaultRecordStatusUploaded, "未收到文件上传结果(609),且文件无法下载")
+	}
+}
+
+// handle 处理一条收到的消息;返回 done=true 表示操作已结束
+func (e *execution) handle(ctx context.Context, m fwh_mqtt.Message) (bool, error) {
+	cmdType, equipNo, da, err := proto.SplitEnvelope(m.Raw)
+	if err != nil || (equipNo != "" && equipNo != e.op.GatewaySn) || !recordedCmdTypes[cmdType] {
+		return false, nil
+	}
+	e.record(ctx, "recv", m.Topic, cmdType, m.Raw, m.ReceivedAt)
+
+	switch cmdType {
+	case proto.CmdTypeAck:
+		ack, err := proto.ParseAck(da)
+		if err != nil || ack.Cmd != e.op.Cmd || e.op.Status != domain.FaultRecordStatusSent {
+			return false, nil
+		}
+		if !ack.Ok() {
+			return true, e.finish(ctx, domain.FaultRecordStatusRejected, fmt.Sprintf("设备拒绝指令(378.result=%d)", ack.Result), nil)
+		}
+		return false, e.advance(ctx, domain.FaultRecordStatusAccepted, nil)
+
+	case proto.CmdTypeReply:
+		r, err := proto.ParseReply(da)
+		if err != nil || r.Cmd != e.op.Cmd {
+			return false, nil
+		}
+		if e.op.Status != domain.FaultRecordStatusSent && e.op.Status != domain.FaultRecordStatusAccepted {
+			return false, nil
+		}
+		return e.handleReply(ctx, r)
+
+	case proto.CmdTypeUploadCall:
+		call, err := proto.ParseUploadCall(da)
+		if err != nil || proto.BatchFromUploadFileName(call.FileName) != e.batch {
+			return false, nil
+		}
+		switch e.op.Status {
+		case domain.FaultRecordStatusReady:
+			return false, e.advance(ctx, domain.FaultRecordStatusUploading, bson.M{"faultRecordId": call.FaultRecordID})
+		case domain.FaultRecordStatusUploading, domain.FaultRecordStatusUploaded:
+			// 608 可能先于 607 到达并已推进状态,此时只补记平台记录 ID
+			return false, e.d.store.SetFields(ctx, e.op.ID, bson.M{"faultRecordId": call.FaultRecordID})
+		}
+		return false, nil
+
+	case proto.CmdTypeUploadAck:
+		// 608 在设备上行方向;订阅不到 607 时,靠它确认上传已开始并取得平台记录 ID
+		ack, err := proto.ParseUploadAck(da)
+		if err != nil || ack.Filename != e.batch || e.op.Status != domain.FaultRecordStatusReady {
+			return false, nil
+		}
+		// 修复后的固件 608 不再带 id,此时不覆盖 607 给出的平台记录 ID
+		extra := bson.M{}
+		if ack.Id != 0 {
+			extra["faultRecordId"] = ack.Id
+		}
+		return false, e.advance(ctx, domain.FaultRecordStatusUploading, extra)
+
+	case proto.CmdTypeUploadDone:
+		done, err := proto.ParseUploadDone(da)
+		if err != nil || proto.BatchFromUploadFileName(done.FileName) != e.batch {
+			return false, nil
+		}
+		if e.op.Status != domain.FaultRecordStatusReady && e.op.Status != domain.FaultRecordStatusUploading {
+			return false, nil
+		}
+		if !done.Ok() {
+			return true, e.finish(ctx, domain.FaultRecordStatusUploadFailed, fmt.Sprintf("设备上传文件失败(609.result=%d)", done.Result), nil)
+		}
+		if equipNo == "" {
+			equipNo = e.op.GatewaySn
+		}
+		// 609 成功只推进状态,等平台应答 610 后再下载
+		// 609.fileName 有的固件带 .tar.bz2、有的不带;统一补齐后缀,避免下载地址缺少后缀
+		return false, e.advance(ctx, domain.FaultRecordStatusUploaded, bson.M{"fileName": proto.UploadFileName(done.FileName), "uploadEquipNo": equipNo})
+
+	case proto.CmdTypeUploadDoneA:
+		// 610 没有文件名,按“本操作收到 609 之后的下一条 610”对应;result 含义未确认,只记录不判断
+		if e.op.Status != domain.FaultRecordStatusUploaded {
+			return false, nil
+		}
+		return true, e.downloadUntilTimeout(ctx, e.d.timeouts.afterUploadAck, 0)
+	}
+	return false, nil
+}
+
+func (e *execution) handleReply(ctx context.Context, r proto.Reply) (bool, error) {
+	if r.Busy() {
+		return true, e.finish(ctx, domain.FaultRecordStatusBusy, "设备正在处理其他命令(可能来自 FWH 平台),请稍后再试", bson.M{"reply": r})
+	}
+	if r.IsErrorReply() {
+		return true, e.finish(ctx, domain.FaultRecordStatusRejected, "设备拒绝命令:"+proto.DescribeReplyResult(r.Result), bson.M{"reply": r})
+	}
+	info, ok := r.FindInfo(e.op.DeviceType, e.op.DeviceSn)
+	if !ok {
+		return true, e.finish(ctx, domain.FaultRecordStatusRejected, "设备回复中没有目标设备", bson.M{"reply": r})
+	}
+
+	if e.op.Kind != domain.FaultRecordOpRecordFetch {
+		status := domain.FaultRecordStatusDone
+		if !r.Ok() {
+			status = domain.FaultRecordStatusItemFailed
+		}
+		return true, e.finish(ctx, status, "", bson.M{"reply": r})
+	}
+
+	allOk := r.Ok() && len(info.IndexInfo) > 0
+	for _, it := range info.IndexInfo {
+		if it.ResultOne != proto.ResultOk {
+			allOk = false
+		}
+	}
+	if !allOk {
+		return true, e.finish(ctx, domain.FaultRecordStatusItemFailed, "设备读取录波记录失败", bson.M{"reply": r})
+	}
+	if r.Filename == "" {
+		return true, e.finish(ctx, domain.FaultRecordStatusRejected, "设备回复中没有文件名", bson.M{"reply": r})
+	}
+	e.batch = r.Filename
+	return false, e.advance(ctx, domain.FaultRecordStatusReady, bson.M{"reply": r, "fileName": proto.UploadFileName(r.Filename)})
+}
+
+// uploadFileName 上传文件名:由批次名统一拼接 .tar.bz2
+func (e *execution) uploadFileName() string {
+	return proto.UploadFileName(e.batch)
+}
+
+func (e *execution) uploadEquipNo() string {
+	if e.op.UploadEquipNo != "" {
+		return e.op.UploadEquipNo
+	}
+	return e.op.GatewaySn
+}
+
+// downloadUntilTimeout 等待 firstWait 后开始下载,失败后每隔 downloadInterval 再试,直到总超时;
+// 等待期间响应取消请求
+// maxAttempts 为 0 时不限次数(一直重试至总超时)
+func (e *execution) downloadUntilTimeout(ctx context.Context, firstWait time.Duration, maxAttempts int) error {
+	deadline := e.deadline()
+	wait := firstWait
+	attempts := 0
+	for {
+		if cancelled, err := e.sleepCancellable(ctx, wait); err != nil {
+			return err
+		} else if cancelled {
+			return e.cancel(ctx)
+		}
+		data, err := e.download(ctx)
+		attempts++
+		if err == nil {
+			return e.storeAndParse(ctx, data)
+		}
+		if maxAttempts > 0 && attempts >= maxAttempts {
+			return e.finish(ctx, domain.FaultRecordStatusDownloadFailed,
+				fmt.Sprintf("重试下载失败,已尝试 %d 次:%s", attempts, err.Error()), nil)
+		}
+		remaining := time.Until(deadline)
+		if remaining <= 0 {
+			return e.finish(ctx, domain.FaultRecordStatusDownloadFailed,
+				fmt.Sprintf("下载文件失败,已尝试 %d 次直至总超时:%s", attempts, err.Error()), nil)
+		}
+		wait = min(e.d.timeouts.downloadInterval, remaining)
+	}
+}
+
+// sleepCancellable 等待 d;期间检查取消请求。返回 cancelled=true 表示用户请求取消
+func (e *execution) sleepCancellable(ctx context.Context, d time.Duration) (bool, error) {
+	if d <= 0 {
+		return false, nil
+	}
+	timer := time.NewTimer(d)
+	defer timer.Stop()
+	ticker := time.NewTicker(e.d.timeouts.cancelPoll)
+	defer ticker.Stop()
+	for {
+		select {
+		case <-ctx.Done():
+			return false, ctx.Err()
+		case <-timer.C:
+			return false, nil
+		case <-ticker.C:
+			if cur, err := e.d.store.Get(ctx, e.op.ID); err == nil && cur.CancelReq {
+				return true, nil
+			}
+		}
+	}
+}
+
+func (e *execution) storeAndParse(ctx context.Context, data []byte) error {
+	key, err := minioRepo.FaultRecordObjectKey(e.op.Env, e.op.GatewaySn, e.uploadFileName())
+	if err != nil {
+		return e.finish(ctx, domain.FaultRecordStatusDownloadFailed, err.Error(), nil)
+	}
+	if err := e.d.files.Put(ctx, key, data); err != nil {
+		return e.finish(ctx, domain.FaultRecordStatusDownloadFailed, "保存文件失败:"+err.Error(), nil)
+	}
+	if err := e.advance(ctx, domain.FaultRecordStatusDownloaded, bson.M{"minioObject": key, "fileSize": len(data)}); err != nil {
+		return err
+	}
+	return e.parse(ctx, data)
+}
+
+func (e *execution) parse(ctx context.Context, data []byte) error {
+	records, err := faultRecordFile.ParseArchive(data, e.op.DeviceType)
+	if err != nil {
+		return e.finish(ctx, domain.FaultRecordStatusParseFailed, "文件已保存,但解析失败:"+err.Error(), nil)
+	}
+	// 核对压缩包中是否包含全部请求的记录
+	got := map[int]bool{}
+	for _, r := range records {
+		got[r.RecordIndex] = true
+	}
+	var missing []string
+	for _, i := range e.op.Request.Indexes() {
+		if !got[i] {
+			missing = append(missing, strconv.Itoa(i))
+		}
+	}
+	msg := ""
+	if len(missing) > 0 {
+		msg = "压缩包中缺少记录序号:" + strings.Join(missing, ",")
+	}
+	return e.finish(ctx, domain.FaultRecordStatusDone, msg, nil)
+}
+
+// download 下载上传的文件,并把每次下载作为一条 http 记录写入原始报文
+func (e *execution) download(ctx context.Context) ([]byte, error) {
+	equipNo := e.uploadEquipNo()
+	// 每次下载都按规则重新拼接文件名(批次名 + .tar.bz2),不依赖已保存的文件名是否带后缀
+	fileName := e.uploadFileName()
+	entry := map[string]any{
+		"env":  e.op.Env,
+		"path": "/hes-upload/v1/file/download/logger/" + equipNo + "/" + fileName,
+	}
+	if e.d.downloadURL != nil {
+		entry["url"] = e.d.downloadURL(e.op.Env, equipNo, fileName)
+	}
+	start := time.Now()
+	data, err := e.d.download(e.op.Env, equipNo, fileName)
+	now := time.Now()
+	entry["durationMs"] = now.Sub(start).Milliseconds()
+	topic := "GET " + entry["path"].(string)
+	if err == nil {
+		entry["result"] = "ok"
+		entry["size"] = len(data)
+		raw, _ := json.Marshal(entry)
+		e.record(ctx, "http", topic, 0, raw, now)
+		e.lastFail = nil
+		return data, nil
+	}
+
+	entry["result"] = "failed"
+	entry["error"] = err.Error()
+	key := topic + "|" + err.Error()
+	// 与上一条记录是同一种失败:只保留最后一条,累计次数
+	if f := e.lastFail; f != nil && f.key == key && f.index == e.msgCount-1 {
+		f.count++
+		entry["count"] = f.count
+		entry["firstAt"] = f.firstAt
+		raw, _ := json.Marshal(entry)
+		e.replaceMessage(ctx, f.index, domain.FaultRecordOpMessage{Direction: "http", Topic: topic, At: now, Raw: string(raw)})
+		return nil, err
+	}
+	entry["count"] = 1
+	entry["firstAt"] = now
+	raw, _ := json.Marshal(entry)
+	e.record(ctx, "http", topic, 0, raw, now)
+	e.lastFail = &downloadFailure{index: e.msgCount - 1, key: key, count: 1, firstAt: now}
+	return nil, err
+}
+
+// drain 记录结束时缓冲区中的报文,并在短时间内继续记录随后到达的报文
+func (e *execution) drain(ctx context.Context) {
+	if e.events == nil || ctx.Err() != nil {
+		return
+	}
+	timer := time.NewTimer(e.d.timeouts.drain)
+	defer timer.Stop()
+	for {
+		select {
+		case m := <-e.events:
+			if cmdType, equipNo, _, err := proto.SplitEnvelope(m.Raw); err == nil && recordedCmdTypes[cmdType] && (equipNo == "" || equipNo == e.op.GatewaySn) {
+				e.record(ctx, "recv", m.Topic, cmdType, m.Raw, m.ReceivedAt)
+			}
+		case <-timer.C:
+			return
+		case <-ctx.Done():
+			return
+		}
+	}
+}
+
+// cancel 设备正在处理本操作的命令时发送 cmd=4,其余阶段只停止等待
+func (e *execution) cancel(ctx context.Context) error {
+	extra := bson.M{}
+	msg := ""
+	if e.op.Status == domain.FaultRecordStatusSent || e.op.Status == domain.FaultRecordStatusAccepted {
+		if err := e.publish(ctx, proto.NewExitRequest()); err != nil {
+			msg = "发送退出命令失败:" + err.Error()
+		} else {
+			extra["exitSent"] = true
+		}
+	}
+	return e.finish(ctx, domain.FaultRecordStatusCancelled, msg, extra)
+}
+
+func (e *execution) record(ctx context.Context, dir, topic string, cmdType uint, raw []byte, at time.Time) {
+	err := e.d.store.AppendMessage(ctx, e.op.ID, domain.FaultRecordOpMessage{
+		Direction: dir, Topic: topic, CmdType: cmdType, At: at, Raw: string(raw),
+	})
+	if err != nil {
+		zap.L().Warn("fault record append message failed", zap.String("id", e.op.ID.Hex()), zap.Error(err))
+		return
+	}
+	e.msgCount++
+}
+
+func (e *execution) replaceMessage(ctx context.Context, index int, msg domain.FaultRecordOpMessage) {
+	if err := e.d.store.SetFields(ctx, e.op.ID, bson.M{fmt.Sprintf("messages.%d", index): msg}); err != nil {
+		zap.L().Warn("fault record replace message failed", zap.String("id", e.op.ID.Hex()), zap.Error(err))
+	}
+}
+
+func (e *execution) advance(ctx context.Context, status domain.FaultRecordOpStatus, extra bson.M) error {
+	if _, err := e.d.store.UpdateStatus(ctx, e.op.ID, status, extra); err != nil {
+		return err
+	}
+	e.op.Status = status
+	if e.op.StageTimes == nil {
+		e.op.StageTimes = map[string]time.Time{}
+	}
+	e.op.StageTimes[string(status)] = time.Now()
+	if v, ok := extra["fileName"].(string); ok {
+		e.op.FileName = v
+	}
+	if v, ok := extra["uploadEquipNo"].(string); ok {
+		e.op.UploadEquipNo = v
+	}
+	return nil
+}
+
+func (e *execution) finish(ctx context.Context, status domain.FaultRecordOpStatus, msg string, extra bson.M) error {
+	if extra == nil {
+		extra = bson.M{}
+	}
+	if msg != "" {
+		extra["error"] = msg
+	}
+	return e.advance(ctx, status, extra)
+}
+
+func (e *execution) finishTimeout(ctx context.Context, waiting domain.FaultRecordOpStatus, msg string) error {
+	return e.finish(ctx, domain.FaultRecordStatusTimeout, msg, bson.M{"timeoutStage": waiting})
+}

+ 637 - 0
service/task/queue/faultRecordQueue/executor_test.go

@@ -0,0 +1,637 @@
+package faultRecordQueue
+
+import (
+	"context"
+	"encoding/csv"
+	"encoding/json"
+	"fmt"
+	"os"
+	"strings"
+	"sync"
+	"testing"
+	"time"
+
+	"fwh-toolbox-server/domain"
+	proto "fwh-toolbox-server/function/FaultRecordProtocol"
+	"fwh-toolbox-server/repositories/mqtt/fwh_mqtt"
+
+	"go.mongodb.org/mongo-driver/bson"
+	"go.mongodb.org/mongo-driver/bson/primitive"
+)
+
+const (
+	sampleLog     = "../../../../docs/fault-record/CloudLog_2026-10-14_104644.csv"
+	sampleArchive = "../../../../docs/fault-record/2026-10-14_05_46_20.tar.bz2"
+	sampleGateway = "AFCI_TEST_0713"
+	sampleBatch   = "2026-10-14_05_46_20"
+)
+
+/************** fakes **************/
+
+type memStore struct {
+	mu  sync.Mutex
+	ops map[primitive.ObjectID]*domain.FaultRecordOperation
+}
+
+func (s *memStore) Get(_ context.Context, id primitive.ObjectID) (*domain.FaultRecordOperation, error) {
+	s.mu.Lock()
+	defer s.mu.Unlock()
+	cp := *s.ops[id]
+	cp.StageTimes = map[string]time.Time{}
+	for k, v := range s.ops[id].StageTimes {
+		cp.StageTimes[k] = v
+	}
+	return &cp, nil
+}
+
+func (s *memStore) UpdateStatus(_ context.Context, id primitive.ObjectID, st domain.FaultRecordOpStatus, extra bson.M) (bool, error) {
+	s.mu.Lock()
+	defer s.mu.Unlock()
+	op := s.ops[id]
+	if op.Status.Terminal() {
+		return false, nil
+	}
+	op.Status = st
+	op.StageTimes[string(st)] = time.Now()
+	applyFields(op, extra)
+	return true, nil
+}
+
+func (s *memStore) SetFields(_ context.Context, id primitive.ObjectID, f bson.M) error {
+	s.mu.Lock()
+	defer s.mu.Unlock()
+	applyFields(s.ops[id], f)
+	return nil
+}
+
+func (s *memStore) AppendMessage(_ context.Context, id primitive.ObjectID, m domain.FaultRecordOpMessage) error {
+	s.mu.Lock()
+	defer s.mu.Unlock()
+	s.ops[id].Messages = append(s.ops[id].Messages, m)
+	return nil
+}
+
+func applyFields(op *domain.FaultRecordOperation, f bson.M) {
+	for k, v := range f {
+		var idx int
+		if n, _ := fmt.Sscanf(k, "messages.%d", &idx); n == 1 {
+			op.Messages[idx] = v.(domain.FaultRecordOpMessage)
+			continue
+		}
+		switch k {
+		case "error":
+			op.Error = v.(string)
+		case "reply":
+			r := v.(proto.Reply)
+			op.Reply = &r
+		case "fileName":
+			op.FileName = v.(string)
+		case "faultRecordId":
+			op.FaultRecordID = v.(int64)
+		case "uploadEquipNo":
+			op.UploadEquipNo = v.(string)
+		case "minioObject":
+			op.MinioObject = v.(string)
+		case "fileSize":
+			op.FileSize = int64(v.(int))
+		case "exitSent":
+			op.ExitSent = v.(bool)
+		case "timeoutStage":
+			op.TimeoutStage = v.(domain.FaultRecordOpStatus)
+		case "cancelRequested":
+			op.CancelReq = v.(bool)
+		}
+	}
+}
+
+// fakeBus 收到 377 后按脚本回放消息
+type fakeBus struct {
+	mu        sync.Mutex
+	handlers  map[string][]fwh_mqtt.Handler
+	published []fwh_mqtt.FwhMqttMsg
+	script    func(b *fakeBus, msg fwh_mqtt.FwhMqttMsg)
+}
+
+func (b *fakeBus) Subscribe(_ context.Context, topic string, h fwh_mqtt.Handler) (func(), error) {
+	b.mu.Lock()
+	defer b.mu.Unlock()
+	if b.handlers == nil {
+		b.handlers = map[string][]fwh_mqtt.Handler{}
+	}
+	b.handlers[topic] = append(b.handlers[topic], h)
+	return func() {}, nil
+}
+
+func (b *fakeBus) Publish(_ context.Context, topic string, msg fwh_mqtt.FwhMqttMsg) error {
+	b.mu.Lock()
+	b.published = append(b.published, msg)
+	script := b.script
+	b.mu.Unlock()
+	if script != nil {
+		go script(b, msg)
+	}
+	return nil
+}
+
+func (b *fakeBus) push(raw string) {
+	cmdType, _, _, _ := proto.SplitEnvelope([]byte(raw))
+	// 与实际一致:378/379 走 c2s/mng,380 走 s2c/mng;607/610 走 s2c/debug,608/609 走 c2s/debug
+	topic := fwh_mqtt.C2STopic("mng/" + sampleGateway)
+	switch cmdType {
+	case proto.CmdTypeReplyAck:
+		topic = fwh_mqtt.S2CTopic("mng/" + sampleGateway)
+	case proto.CmdTypeUploadCall, proto.CmdTypeUploadDoneA:
+		topic = fwh_mqtt.S2CTopic("debug/" + sampleGateway)
+	case proto.CmdTypeUploadAck, proto.CmdTypeUploadDone:
+		topic = fwh_mqtt.C2STopic("debug/" + sampleGateway)
+	}
+	b.mu.Lock()
+	hs := append([]fwh_mqtt.Handler(nil), b.handlers[topic]...)
+	b.mu.Unlock()
+	for _, h := range hs {
+		h(fwh_mqtt.Message{Topic: topic, Raw: []byte(raw), ReceivedAt: time.Now()})
+	}
+}
+
+type memFiles struct {
+	mu   sync.Mutex
+	data map[string][]byte
+}
+
+func (f *memFiles) Put(_ context.Context, key string, d []byte) error {
+	f.mu.Lock()
+	defer f.mu.Unlock()
+	f.data[key] = d
+	return nil
+}
+func (f *memFiles) Get(_ context.Context, key string) ([]byte, error) {
+	f.mu.Lock()
+	defer f.mu.Unlock()
+	d, ok := f.data[key]
+	if !ok {
+		return nil, fmt.Errorf("not found")
+	}
+	return d, nil
+}
+
+type fakeLock struct{ locked bool }
+
+func (l fakeLock) Acquire(context.Context, string, string, time.Duration) (func(), bool, error) {
+	return func() {}, !l.locked, nil
+}
+
+/************** helpers **************/
+
+// sampleRound 读取样本日志第二轮(05:45~05:46)按时间正序的报文
+func sampleRound(t *testing.T) []string {
+	t.Helper()
+	f, err := os.Open(sampleLog)
+	if err != nil {
+		t.Skipf("sample log not found: %v", err)
+	}
+	defer f.Close()
+	rows, err := csv.NewReader(f).ReadAll()
+	if err != nil {
+		t.Fatal(err)
+	}
+	var list []string
+	for i := len(rows) - 1; i >= 1; i-- { // CSV 为倒序
+		if strings.HasPrefix(rows[i][8], "2026-10-14 05:4") && rows[i][8] >= "2026-10-14 05:45" {
+			list = append(list, rows[i][7])
+		}
+	}
+	return list
+}
+
+// pickCmd 从样本中取出与 cmd 相关的报文(378/379/380 按 dataArea.cmd 过滤),不含 377
+func pickCmd(msgs []string, cmd int) []string {
+	var out []string
+	for _, raw := range msgs {
+		cmdType, _, da, _ := proto.SplitEnvelope([]byte(raw))
+		if cmdType == proto.CmdTypeRequest {
+			continue
+		}
+		if cmdType >= proto.CmdTypeAck && cmdType <= proto.CmdTypeReplyAck {
+			var v struct {
+				Cmd int `json:"cmd"`
+			}
+			json.Unmarshal(da, &v)
+			if v.Cmd != cmd {
+				continue
+			}
+		} else if cmd != proto.CmdGetRecord {
+			continue
+		}
+		out = append(out, raw)
+	}
+	return out
+}
+
+type harness struct {
+	d     deps
+	store *memStore
+	bus   *fakeBus
+	files *memFiles
+	op    *domain.FaultRecordOperation
+}
+
+func newHarness(t *testing.T, kind domain.FaultRecordOpKind, req domain.FaultRecordOpRequest) *harness {
+	t.Helper()
+	op := &domain.FaultRecordOperation{
+		ID: primitive.NewObjectID(), Env: "test", GatewaySn: sampleGateway,
+		DeviceType: proto.DeviceTypeAGate, DeviceSn: sampleGateway,
+		Kind: kind, Cmd: kind.Cmd(), Request: req,
+		Status: domain.FaultRecordStatusPending, StageTimes: map[string]time.Time{"pending": time.Now()},
+	}
+	h := &harness{
+		store: &memStore{ops: map[primitive.ObjectID]*domain.FaultRecordOperation{op.ID: op}},
+		bus:   &fakeBus{},
+		files: &memFiles{data: map[string][]byte{}},
+		op:    op,
+	}
+	archive, _ := os.ReadFile(sampleArchive)
+	to := defaultTimeouts
+	to.downloadInterval = 10 * time.Millisecond
+	to.cancelPoll = 10 * time.Millisecond
+	to.drain = 50 * time.Millisecond
+	to.afterUploadAck = 10 * time.Millisecond
+	h.d = deps{
+		bus:   func(string) (mqttBus, error) { return h.bus, nil },
+		store: h.store,
+		files: h.files,
+		download: func(env, equipNo, fileName string) ([]byte, error) {
+			if equipNo != sampleGateway || fileName != sampleBatch+".tar.bz2" || archive == nil {
+				return nil, fmt.Errorf("not found")
+			}
+			return archive, nil
+		},
+		lock: fakeLock{},
+		downloadURL: func(env, equipNo, fileName string) string {
+			return "https://" + env + ".example/hes-upload/v1/file/download/logger/" + equipNo + "/" + fileName
+		},
+		timeouts: to,
+	}
+	return h
+}
+
+func (h *harness) run(t *testing.T) *domain.FaultRecordOperation {
+	t.Helper()
+	ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
+	defer cancel()
+	if err := h.d.run(ctx, h.op.ID); err != nil {
+		t.Fatalf("run: %v", err)
+	}
+	op, _ := h.store.Get(ctx, h.op.ID)
+	return op
+}
+
+// replay 收到 377 后依次推送消息
+func replay(msgs []string) func(*fakeBus, fwh_mqtt.FwhMqttMsg) {
+	return func(b *fakeBus, m fwh_mqtt.FwhMqttMsg) {
+		if m.CmdType != proto.CmdTypeRequest {
+			return
+		}
+		for _, raw := range msgs {
+			b.push(raw)
+		}
+	}
+}
+
+/************** tests **************/
+
+func TestRecordFetchFullFlow(t *testing.T) {
+	msgs := pickCmd(sampleRound(t), proto.CmdGetRecord)
+	h := newHarness(t, domain.FaultRecordOpRecordFetch, domain.FaultRecordOpRequest{RecordIndex: 7})
+	h.bus.script = replay(msgs)
+
+	op := h.run(t)
+	if op.Status != domain.FaultRecordStatusDone {
+		t.Fatalf("status = %s, error = %s", op.Status, op.Error)
+	}
+	if op.FileName != sampleBatch+".tar.bz2" || op.FaultRecordID != 5226 || op.MinioObject == "" {
+		t.Errorf("record fields: %+v", op)
+	}
+	for _, s := range []string{"sent", "accepted", "ready", "uploading", "uploaded", "downloaded", "done"} {
+		if _, ok := op.StageTimes[s]; !ok {
+			t.Errorf("missing stage %s", s)
+		}
+	}
+	// 发送的 377 + 样本全部报文(含 609 之后的 610)+ 1 条 HTTP 下载记录
+	var hasHTTP bool
+	for _, m := range op.Messages {
+		if m.Direction == "http" && strings.Contains(m.Raw, `"result":"ok"`) {
+			hasHTTP = true
+		}
+	}
+	// 顺序:…609 → 610 → HTTP 下载(收到 610 后才下载)
+	first, last := op.Messages[0], op.Messages[len(op.Messages)-1]
+	prev := op.Messages[len(op.Messages)-2]
+	if first.CmdType != proto.CmdTypeRequest || last.Direction != "http" || prev.CmdType != proto.CmdTypeUploadDoneA || !hasHTTP || len(op.Messages) != len(msgs)+2 {
+		t.Errorf("messages = %d (first %d, prev %d, last %s, http %v)", len(op.Messages), first.CmdType, prev.CmdType, last.Direction, hasHTTP)
+	}
+	b, _ := json.Marshal(h.bus.published[0].DataArea)
+	if string(b) != `{"cmd":2,"data":{"list":[{"type":"aGate","info":[{"sn":"AFCI_TEST_0713","index":[7]}]}]}}` {
+		t.Errorf("request = %s", b)
+	}
+}
+
+func TestDeviceInfoFlow(t *testing.T) {
+	msgs := pickCmd(sampleRound(t), proto.CmdRealtime)
+	h := newHarness(t, domain.FaultRecordOpDeviceInfo, domain.FaultRecordOpRequest{CodeList: []int{3}})
+	h.bus.script = replay(msgs)
+
+	op := h.run(t)
+	if op.Status != domain.FaultRecordStatusDone || op.Reply == nil || len(op.Reply.List) != 1 {
+		t.Fatalf("status = %s reply = %+v", op.Status, op.Reply)
+	}
+}
+
+func TestBusyReply(t *testing.T) {
+	h := newHarness(t, domain.FaultRecordOpParamRead, domain.FaultRecordOpRequest{IdList: []int{5}})
+	h.bus.script = replay([]string{
+		`{"cmdType":378,"equipNo":"AFCI_TEST_0713","dataArea":{"cmd":3,"result":0}}`,
+		`{"cmdType":379,"equipNo":"AFCI_TEST_0713","dataArea":{"cmd":3,"result":"busy"}}`,
+	})
+	if op := h.run(t); op.Status != domain.FaultRecordStatusBusy {
+		t.Fatalf("status = %s", op.Status)
+	}
+}
+
+func TestIgnoresOtherCmdAndOtherGateway(t *testing.T) {
+	h := newHarness(t, domain.FaultRecordOpParamRead, domain.FaultRecordOpRequest{IdList: []int{5}})
+	h.bus.script = replay([]string{
+		`{"cmdType":378,"equipNo":"AFCI_TEST_0713","dataArea":{"cmd":3,"result":0}}`,
+		`{"cmdType":379,"equipNo":"AFCI_TEST_0713","dataArea":{"cmd":0,"result":"ok"}}`, // 其他 cmd
+		`{"cmdType":379,"equipNo":"OTHER","dataArea":{"cmd":3,"result":"busy"}}`,        // 其他网关
+		`{"cmdType":379,"equipNo":"AFCI_TEST_0713","dataArea":{"cmd":3,"result":"ok","list":[{"type":"aGate","info":[{"sn":"AFCI_TEST_0713","idInfo":[{"id":5,"idData":"1194","resultOne":"ok"}]}]}]}}`,
+	})
+	op := h.run(t)
+	if op.Status != domain.FaultRecordStatusDone || op.Reply.List[0].Info[0].IdInfo[0].IdData != "1194" {
+		t.Fatalf("status = %s reply = %+v", op.Status, op.Reply)
+	}
+}
+
+func TestAckTimeout(t *testing.T) {
+	h := newHarness(t, domain.FaultRecordOpParamRead, domain.FaultRecordOpRequest{IdList: []int{5}})
+	h.d.timeouts.ack = 50 * time.Millisecond
+	op := h.run(t)
+	if op.Status != domain.FaultRecordStatusTimeout || op.TimeoutStage != domain.FaultRecordStatusAccepted {
+		t.Fatalf("status = %s stage = %s", op.Status, op.TimeoutStage)
+	}
+}
+
+func TestCancelSendsExit(t *testing.T) {
+	h := newHarness(t, domain.FaultRecordOpRecordFetch, domain.FaultRecordOpRequest{RecordIndex: 7})
+	h.bus.script = func(b *fakeBus, m fwh_mqtt.FwhMqttMsg) {
+		if m.CmdType != proto.CmdTypeRequest {
+			return
+		}
+		if da, _ := json.Marshal(m.DataArea); strings.Contains(string(da), `"cmd":2`) {
+			b.push(`{"cmdType":378,"equipNo":"AFCI_TEST_0713","dataArea":{"cmd":2,"result":0}}`)
+			time.Sleep(30 * time.Millisecond)
+			h.store.SetFields(context.Background(), h.op.ID, bson.M{"cancelRequested": true})
+		}
+	}
+	op := h.run(t)
+	if op.Status != domain.FaultRecordStatusCancelled || !op.ExitSent {
+		t.Fatalf("status = %s exitSent = %v", op.Status, op.ExitSent)
+	}
+	last, _ := json.Marshal(h.bus.published[len(h.bus.published)-1].DataArea)
+	if string(last) != `{"cmd":4,"data":{}}` {
+		t.Errorf("exit request = %s", last)
+	}
+}
+
+func TestRecoverSentIsInterrupted(t *testing.T) {
+	h := newHarness(t, domain.FaultRecordOpRecordFetch, domain.FaultRecordOpRequest{RecordIndex: 7})
+	h.op.Status = domain.FaultRecordStatusAccepted
+	if op := h.run(t); op.Status != domain.FaultRecordStatusInterrupted || len(h.bus.published) != 0 {
+		t.Fatalf("status = %s published = %d", op.Status, len(h.bus.published))
+	}
+}
+
+func TestRecoverReadyDownloadsDirectly(t *testing.T) {
+	if _, err := os.Stat(sampleArchive); err != nil {
+		t.Skip("sample archive not found")
+	}
+	h := newHarness(t, domain.FaultRecordOpRecordFetch, domain.FaultRecordOpRequest{RecordIndex: 7})
+	h.op.Status = domain.FaultRecordStatusReady
+	h.op.FileName = sampleBatch + ".tar.bz2"
+	h.op.StageTimes["ready"] = time.Now()
+	if op := h.run(t); op.Status != domain.FaultRecordStatusDone || len(h.bus.published) != 0 {
+		t.Fatalf("status = %s error = %s", op.Status, op.Error)
+	}
+}
+
+func TestGatewayLocked(t *testing.T) {
+	h := newHarness(t, domain.FaultRecordOpParamRead, domain.FaultRecordOpRequest{IdList: []int{5}})
+	h.d.lock = fakeLock{locked: true}
+	if op := h.run(t); op.Status != domain.FaultRecordStatusGatewayLocked {
+		t.Fatalf("status = %s", op.Status)
+	}
+}
+
+// 订阅不到平台下发方向(无 607/380/610)时:靠 608 取得平台记录 ID;没有 610 不会提前下载,超时时按规则直接下载一次
+func TestRecordFetchWithoutS2C(t *testing.T) {
+	var c2sOnly []string
+	for _, raw := range pickCmd(sampleRound(t), proto.CmdGetRecord) {
+		cmdType, _, _, _ := proto.SplitEnvelope([]byte(raw))
+		if cmdType != proto.CmdTypeUploadCall && cmdType != proto.CmdTypeReplyAck && cmdType != proto.CmdTypeUploadDoneA {
+			c2sOnly = append(c2sOnly, raw)
+		}
+	}
+	h := newHarness(t, domain.FaultRecordOpRecordFetch, domain.FaultRecordOpRequest{RecordIndex: 7})
+	h.d.timeouts.upload = 300 * time.Millisecond
+	h.bus.script = replay(c2sOnly)
+
+	op := h.run(t)
+	if op.Status != domain.FaultRecordStatusDone || op.FaultRecordID != 5226 {
+		t.Fatalf("status = %s faultRecordId = %d error = %s", op.Status, op.FaultRecordID, op.Error)
+	}
+	for _, s := range []string{"uploading", "uploaded", "downloaded"} {
+		if _, ok := op.StageTimes[s]; !ok {
+			t.Errorf("missing stage %s", s)
+		}
+	}
+	// 下载发生在超时时,而不是收到 609 时
+	if op.StageTimes["downloaded"].Sub(op.StageTimes["uploaded"]) < 200*time.Millisecond {
+		t.Error("should not download before 610 or timeout")
+	}
+}
+
+// 一次获取多条记录:请求包含全部序号;压缩包中缺少的序号在结果中提示
+func TestRecordFetchMultiple(t *testing.T) {
+	msgs := pickCmd(sampleRound(t), proto.CmdGetRecord)
+	h := newHarness(t, domain.FaultRecordOpRecordFetch, domain.FaultRecordOpRequest{RecordIndexes: []int{7, 9}})
+	h.bus.script = replay(msgs)
+
+	op := h.run(t)
+	b, _ := json.Marshal(h.bus.published[0].DataArea)
+	if !strings.Contains(string(b), `"index":[7,9]`) {
+		t.Errorf("request = %s", b)
+	}
+	// 样本压缩包只有记录 7
+	if op.Status != domain.FaultRecordStatusDone || !strings.Contains(op.Error, "9") {
+		t.Fatalf("status = %s error = %q", op.Status, op.Error)
+	}
+}
+
+// 收到 610 后等待 afterUploadAck 再下载
+func TestDownloadAfter610Delay(t *testing.T) {
+	msgs := pickCmd(sampleRound(t), proto.CmdGetRecord)
+	h := newHarness(t, domain.FaultRecordOpRecordFetch, domain.FaultRecordOpRequest{RecordIndex: 7})
+	h.d.timeouts.afterUploadAck = 150 * time.Millisecond
+	h.bus.script = replay(msgs)
+
+	op := h.run(t)
+	if op.Status != domain.FaultRecordStatusDone {
+		t.Fatalf("status = %s error = %s", op.Status, op.Error)
+	}
+	var at610 time.Time
+	for _, m := range op.Messages {
+		if m.CmdType == proto.CmdTypeUploadDoneA {
+			at610 = m.At
+		}
+	}
+	if at610.IsZero() || op.StageTimes["downloaded"].Sub(at610) < 150*time.Millisecond {
+		t.Errorf("download should start ≥150ms after 610 (610 at %v, downloaded at %v)", at610, op.StageTimes["downloaded"])
+	}
+}
+
+// 收到 610 后开始下载;连续相同的失败只保留一条并累计次数,成功后另记一条
+func TestDownloadRetryDedupesFailures(t *testing.T) {
+	msgs := pickCmd(sampleRound(t), proto.CmdGetRecord)
+	h := newHarness(t, domain.FaultRecordOpRecordFetch, domain.FaultRecordOpRequest{RecordIndex: 7})
+	h.bus.script = replay(msgs)
+	real := h.d.download
+	var mu sync.Mutex
+	calls := 0
+	h.d.download = func(env, equipNo, fileName string) ([]byte, error) {
+		mu.Lock()
+		calls++
+		n := calls
+		mu.Unlock()
+		if n <= 3 {
+			return nil, fmt.Errorf("404 Not Found")
+		}
+		return real(env, equipNo, fileName)
+	}
+
+	op := h.run(t)
+	if op.Status != domain.FaultRecordStatusDone {
+		t.Fatalf("status = %s error = %s", op.Status, op.Error)
+	}
+	var httpMsgs []domain.FaultRecordOpMessage
+	for _, m := range op.Messages {
+		if m.Direction == "http" {
+			httpMsgs = append(httpMsgs, m)
+		}
+	}
+	if len(httpMsgs) != 2 || !strings.Contains(httpMsgs[0].Raw, `"url":"https://test.example/hes-upload/v1/file/download/logger/AFCI_TEST_0713/2026-10-14_05_46_20.tar.bz2"`) || !strings.Contains(httpMsgs[0].Raw, `"count":3`) || !strings.Contains(httpMsgs[1].Raw, `"result":"ok"`) {
+		t.Fatalf("http records = %+v", httpMsgs)
+	}
+}
+
+// 一直失败时重试到总超时,记为下载失败并注明尝试次数
+func TestDownloadRetryUntilTimeout(t *testing.T) {
+	msgs := pickCmd(sampleRound(t), proto.CmdGetRecord)
+	h := newHarness(t, domain.FaultRecordOpRecordFetch, domain.FaultRecordOpRequest{RecordIndex: 7})
+	h.d.timeouts.upload = 200 * time.Millisecond
+	h.d.timeouts.downloadInterval = 30 * time.Millisecond
+	h.bus.script = replay(msgs)
+	h.d.download = func(string, string, string) ([]byte, error) { return nil, fmt.Errorf("404 Not Found") }
+
+	op := h.run(t)
+	if op.Status != domain.FaultRecordStatusDownloadFailed || !strings.Contains(op.Error, "已尝试") {
+		t.Fatalf("status = %s error = %s", op.Status, op.Error)
+	}
+	n := 0
+	for _, m := range op.Messages {
+		if m.Direction == "http" {
+			n++
+		}
+	}
+	if n != 1 {
+		t.Errorf("same failures should collapse into 1 record, got %d", n)
+	}
+}
+
+// 新固件:609.fileName 不带 .tar.bz2、608 不带 id;下载地址仍须带后缀,平台记录 ID 取自 607
+func TestNewFirmware609WithoutSuffix(t *testing.T) {
+	var msgs []string
+	for _, raw := range pickCmd(sampleRound(t), proto.CmdGetRecord) {
+		raw = strings.Replace(raw, `"fileName":"2026-10-14_05_46_20.tar.bz2"`, `"fileName":"2026-10-14_05_46_20"`, 1)
+		raw = strings.Replace(raw, `"result":0,"filename":"2026-10-14_05_46_20","result":5226`, `"result":0,"filename":"2026-10-14_05_46_20"`, 1)
+		msgs = append(msgs, raw)
+	}
+	h := newHarness(t, domain.FaultRecordOpRecordFetch, domain.FaultRecordOpRequest{RecordIndex: 7})
+	h.bus.script = replay(msgs)
+
+	op := h.run(t)
+	if op.Status != domain.FaultRecordStatusDone || op.FileName != sampleBatch+".tar.bz2" || op.FaultRecordID != 5226 {
+		t.Fatalf("status = %s file = %s frid = %d error = %s", op.Status, op.FileName, op.FaultRecordID, op.Error)
+	}
+}
+
+// 手动重试下载:状态为 uploaded 且有 DownloadRetryAt 时,从重试时刻重新计算总超时并直接下载
+func TestManualDownloadRetry(t *testing.T) {
+	if _, err := os.Stat(sampleArchive); err != nil {
+		t.Skip("sample archive not found")
+	}
+	h := newHarness(t, domain.FaultRecordOpRecordFetch, domain.FaultRecordOpRequest{RecordIndex: 7})
+	now := time.Now()
+	h.op.Status = domain.FaultRecordStatusUploaded
+	h.op.FileName = sampleBatch + ".tar.bz2"
+	h.op.StageTimes["ready"] = now.Add(-time.Hour) // 原总超时早已过去
+	h.op.StageTimes["uploaded"] = now.Add(-time.Hour)
+	h.op.DownloadRetryAt = &now
+	calls := 0
+	real := h.d.download
+	h.d.download = func(env, equipNo, fileName string) ([]byte, error) {
+		calls++
+		if calls == 1 {
+			return nil, fmt.Errorf("404 Not Found")
+		}
+		return real(env, equipNo, fileName)
+	}
+
+	op := h.run(t)
+	if op.Status != domain.FaultRecordStatusDone || calls != 2 || len(h.bus.published) != 0 {
+		t.Fatalf("status = %s calls = %d published = %d error = %s", op.Status, calls, len(h.bus.published), op.Error)
+	}
+}
+
+// 手动重试只尝试 3 次;已保存的文件名不带后缀(旧记录)时,下载链接仍按规则补齐 .tar.bz2
+func TestManualRetryThreeAttemptsAndSuffix(t *testing.T) {
+	h := newHarness(t, domain.FaultRecordOpRecordFetch, domain.FaultRecordOpRequest{RecordIndex: 7})
+	now := time.Now()
+	h.op.Status = domain.FaultRecordStatusUploaded
+	h.op.FileName = sampleBatch // 旧记录:不带后缀
+	h.op.StageTimes["ready"] = now
+	h.op.StageTimes["uploaded"] = now
+	h.op.DownloadRetryAt = &now
+	var names []string
+	h.d.download = func(env, equipNo, fileName string) ([]byte, error) {
+		names = append(names, fileName)
+		return nil, fmt.Errorf("404 Not Found")
+	}
+
+	op := h.run(t)
+	if op.Status != domain.FaultRecordStatusDownloadFailed || len(names) != 3 || !strings.Contains(op.Error, "已尝试 3 次") {
+		t.Fatalf("status = %s attempts = %d error = %s", op.Status, len(names), op.Error)
+	}
+	for _, n := range names {
+		if n != sampleBatch+".tar.bz2" {
+			t.Errorf("download file name = %s", n)
+		}
+	}
+	var url string
+	for _, m := range op.Messages {
+		if m.Direction == "http" {
+			url = m.Raw
+		}
+	}
+	if !strings.Contains(url, sampleBatch+".tar.bz2") || !strings.Contains(url, `"count":3`) {
+		t.Errorf("http record = %s", url)
+	}
+}

+ 117 - 0
service/task/queue/faultRecordQueue/queue.go

@@ -0,0 +1,117 @@
+// Package faultRecordQueue 故障录波 MQTT 操作的异步执行器(asynq)
+package faultRecordQueue
+
+import (
+	"context"
+	"encoding/json"
+	"errors"
+	"fmt"
+	"time"
+
+	mongoRepo "fwh-toolbox-server/repositories/mongo"
+	task_core "fwh-toolbox-server/task/core"
+
+	"github.com/hibiken/asynq"
+	"go.mongodb.org/mongo-driver/bson/primitive"
+	"go.uber.org/zap"
+)
+
+const (
+	TaskTypeRun = "fault-record:run"
+	QueueName   = "fault-record"
+)
+
+var client *asynq.Client
+
+type runPayload struct {
+	OperationID string `json:"operationId"`
+}
+
+func Init(tc *task_core.TaskCore) {
+	mux := asynq.NewServeMux()
+	mux.HandleFunc(TaskTypeRun, handleRun)
+	client = tc.CreateTask(task_core.TaskConfig{
+		Name: "FaultRecord",
+		SvrCfg: asynq.Config{
+			Concurrency: 10, // 不同网关的操作可并行;同一网关由网关锁串行
+			Queues:      map[string]int{QueueName: 1},
+			ErrorHandler: asynq.ErrorHandlerFunc(func(_ context.Context, task *asynq.Task, err error) {
+				zap.L().Error("fault-record task failed", zap.String("type", task.Type()), zap.String("err", err.Error()))
+			}),
+		},
+		Mux: mux,
+	})
+}
+
+// Enqueue 提交操作。TaskID 固定为操作 ID,重复提交(如启动恢复)不会重复执行。
+// 业务失败由执行器写入操作状态,不依赖 asynq 重试;MaxRetry 只用于进程崩溃后重新投递。
+func Enqueue(id primitive.ObjectID) error {
+	if client == nil {
+		return fmt.Errorf("fault record queue not initialized")
+	}
+	payload, _ := json.Marshal(runPayload{OperationID: id.Hex()})
+	_, err := client.Enqueue(asynq.NewTask(TaskTypeRun, payload),
+		asynq.Queue(QueueName),
+		asynq.MaxRetry(3),
+		asynq.Timeout(15*time.Minute),
+		asynq.TaskID("fault-record-"+id.Hex()),
+		asynq.Retention(24*time.Hour),
+	)
+	if errors.Is(err, asynq.ErrTaskIDConflict) {
+		return nil
+	}
+	return err
+}
+
+// EnqueueDownloadRetry 提交手动重试下载;每次重试使用新的 TaskID(原任务的 TaskID 仍在保留期内)
+func EnqueueDownloadRetry(id primitive.ObjectID, attempt int) error {
+	if client == nil {
+		return fmt.Errorf("fault record queue not initialized")
+	}
+	payload, _ := json.Marshal(runPayload{OperationID: id.Hex()})
+	_, err := client.Enqueue(asynq.NewTask(TaskTypeRun, payload),
+		asynq.Queue(QueueName),
+		asynq.MaxRetry(3),
+		asynq.Timeout(15*time.Minute),
+		asynq.TaskID(fmt.Sprintf("fault-record-%s-retry-%d", id.Hex(), attempt)),
+		asynq.Retention(24*time.Hour),
+	)
+	if errors.Is(err, asynq.ErrTaskIDConflict) {
+		return nil
+	}
+	return err
+}
+
+func handleRun(ctx context.Context, t *asynq.Task) error {
+	var p runPayload
+	if err := json.Unmarshal(t.Payload(), &p); err != nil {
+		return fmt.Errorf("%w: %v", asynq.SkipRetry, err)
+	}
+	id, err := primitive.ObjectIDFromHex(p.OperationID)
+	if err != nil {
+		return fmt.Errorf("%w: %v", asynq.SkipRetry, err)
+	}
+	return defaultDeps().run(ctx, id)
+}
+
+// RecoverActive 启动时把未结束的操作重新提交;仍在队列中的会因 TaskID 冲突被忽略
+func RecoverActive(ctx context.Context) {
+	list, err := mongoRepo.ListActiveFaultRecordOps(ctx)
+	if err != nil {
+		zap.L().Error("fault record recover: list active failed", zap.Error(err))
+		return
+	}
+	for _, op := range list {
+		enqueue := func() error { return Enqueue(op.ID) }
+		if op.DownloadRetryCount > 0 {
+			// 手动重试下载使用独立的 TaskID,按当前重试序号重新提交
+			enqueue = func() error { return EnqueueDownloadRetry(op.ID, op.DownloadRetryCount) }
+		}
+		if err := enqueue(); err != nil {
+			zap.L().Error("fault record recover: enqueue failed", zap.String("id", op.ID.Hex()), zap.Error(err))
+		}
+	}
+	if len(list) > 0 {
+		zap.L().Info("fault record recover: resubmitted active operations", zap.Int("count", len(list)))
+	}
+}

+ 2 - 0
service/task/queue/taskQueue.go

@@ -4,6 +4,7 @@ import (
 	"fwh-toolbox-server/config"
 	task_core "fwh-toolbox-server/task/core"
 	"fwh-toolbox-server/task/queue/dataDownloadQueue"
+	"fwh-toolbox-server/task/queue/faultRecordQueue"
 	"fwh-toolbox-server/task/queue/fwhQueue"
 	"fwh-toolbox-server/task/queue/simCardQueue"
 )
@@ -15,5 +16,6 @@ func InitTaskQueue(tc *task_core.TaskCore, appConfig *config.AppConfig) {
 
 	fwhQueue.InitFwhTaskQueue(tc)
 	dataDownloadQueue.Init(tc)
+	faultRecordQueue.Init(tc)
 	simCardQueue.InitSimCardTaskQueue(tc, appConfig)
 }

+ 19 - 2
service/utils/mqttClient/mqttClient.go

@@ -8,6 +8,7 @@ import (
 	"net/url"
 	"os"
 	"reflect"
+	"sync"
 	"time"
 	"unsafe"
 
@@ -25,7 +26,8 @@ type MqttClient struct {
 	client        mqtt.Client
 	clientOptions *mqtt.ClientOptions
 	subscribeMap  map[string]func(payload []byte)
-	clientID      string // 客户端名称
+	subscribeMu   sync.Mutex // 保护 subscribeMap(OnConnect 回调与调用方在不同 goroutine)
+	clientID      string     // 客户端名称
 }
 
 const (
@@ -199,13 +201,24 @@ func (c *MqttClient) IsConnected() bool {
 
 // 恢复订阅消息
 func (c *MqttClient) ResumeSubscribe() {
+	c.subscribeMu.Lock()
+	subs := make(map[string]func(payload []byte), len(c.subscribeMap))
 	for k, v := range c.subscribeMap {
-		c.subscribeMessage(k, v)
+		subs[k] = v
+	}
+	c.subscribeMu.Unlock()
+
+	for k, v := range subs {
+		if err := c.subscribeMessage(k, v); err != nil {
+			zap.L().Error("[MQTT] resume subscribe failed", zap.String("topic", k), zap.String("err", err.Error()))
+		}
 	}
 }
 
 // 清空所有订阅
 func (c *MqttClient) CleanSubscribe() {
+	c.subscribeMu.Lock()
+	defer c.subscribeMu.Unlock()
 	for k := range c.subscribeMap {
 		delete(c.subscribeMap, k)
 	}
@@ -240,7 +253,9 @@ func (c *MqttClient) SubscribeMessage(topic string, callback func(payload []byte
 	}
 
 	// 备份subscribe
+	c.subscribeMu.Lock()
 	c.subscribeMap[topic] = callback
+	c.subscribeMu.Unlock()
 
 	return nil
 }
@@ -261,7 +276,9 @@ func (c *MqttClient) UnSubscribeMessage(topic string) error {
 		return err
 	}
 
+	c.subscribeMu.Lock()
 	delete(c.subscribeMap, topic)
+	c.subscribeMu.Unlock()
 
 	return nil
 }

+ 120 - 22
src/router/record.ts

@@ -127,7 +127,7 @@ export const routeRecordRaw: MergeMenuMixedOptions[] = [
       showTab: true,
       enableMultiTab: true,
       renderTabLabel({ name }) {
-        return `站点分析${name ? '-' + name : ''}`
+        return `站点分析${name ? `-${  name}` : ''}`
       },
     },
     component: 'sites-analysis/index',
@@ -529,31 +529,129 @@ export const routeRecordRaw: MergeMenuMixedOptions[] = [
     key: 'fault-record',
     name: 'faultRecord',
     icon: 'i-ph:castle-turret-duotone',
-    label: '故障录波配置',
+    label: '故障录波',
     children: [
       {
-        path: 'fault-record-read',
-        key: 'fault-record-read',
-        name: 'faultRecordRead',
-        icon: 'i-mdi:account-eye-outline',
-        label: '读取配置',
-        component: 'fault-record/read/index',
-        meta: {
-          showTab: true,
-          enableMultiTab: true,
-        },
+        path: 'prod',
+        key: 'fault-record-prod',
+        name: 'faultRecordProd',
+        icon: 'i-ph:factory-duotone',
+        label: '生产服',
+        children: [
+          {
+            path: 'record',
+            key: 'fault-record-prod-record',
+            name: 'faultRecordProdRecord',
+            icon: 'i-ph:wave-sine-duotone',
+            label: '录波记录',
+            component: 'fault-record/record/index',
+            meta: {
+              env: 'prod',
+              showTab: true,
+              label: '录波记录(生产服)',
+            },
+          },
+          {
+            path: 'device-info',
+            key: 'fault-record-prod-device-info',
+            name: 'faultRecordProdDeviceInfo',
+            icon: 'i-ph:info-duotone',
+            label: '设备信息',
+            component: 'fault-record/device-info/index',
+            meta: {
+              env: 'prod',
+              showTab: true,
+              label: '设备信息(生产服)',
+            },
+          },
+          {
+            path: 'function',
+            key: 'fault-record-prod-function',
+            name: 'faultRecordProdFunction',
+            icon: 'i-ph:lightning-duotone',
+            label: '功能操作',
+            component: 'fault-record/function/index',
+            meta: {
+              env: 'prod',
+              showTab: true,
+              label: '功能操作(生产服)',
+            },
+          },
+          {
+            path: 'param-write',
+            key: 'fault-record-prod-param-write',
+            name: 'faultRecordProdParamWrite',
+            icon: 'i-mdi:account-edit-outline',
+            label: '参数设置',
+            component: 'fault-record/param-write/index',
+            meta: {
+              env: 'prod',
+              showTab: true,
+              label: '参数设置(生产服)',
+            },
+          },
+        ],
       },
       {
-        path: 'fault-record-write',
-        key: 'fault-record-write',
-        name: 'faultRecordWrite',
-        icon: 'i-mdi:account-edit-outline',
-        label: '写入配置',
-        component: 'fault-record/write/index',
-        meta: {
-          showTab: true,
-          enableMultiTab: true,
-        },
+        path: 'test',
+        key: 'fault-record-test',
+        name: 'faultRecordTest',
+        icon: 'i-ph:flask-duotone',
+        label: '测试服',
+        children: [
+          {
+            path: 'record',
+            key: 'fault-record-test-record',
+            name: 'faultRecordTestRecord',
+            icon: 'i-ph:wave-sine-duotone',
+            label: '录波记录',
+            component: 'fault-record/record/index',
+            meta: {
+              env: 'test',
+              showTab: true,
+              label: '录波记录(测试服)',
+            },
+          },
+          {
+            path: 'device-info',
+            key: 'fault-record-test-device-info',
+            name: 'faultRecordTestDeviceInfo',
+            icon: 'i-ph:info-duotone',
+            label: '设备信息',
+            component: 'fault-record/device-info/index',
+            meta: {
+              env: 'test',
+              showTab: true,
+              label: '设备信息(测试服)',
+            },
+          },
+          {
+            path: 'function',
+            key: 'fault-record-test-function',
+            name: 'faultRecordTestFunction',
+            icon: 'i-ph:lightning-duotone',
+            label: '功能操作',
+            component: 'fault-record/function/index',
+            meta: {
+              env: 'test',
+              showTab: true,
+              label: '功能操作(测试服)',
+            },
+          },
+          {
+            path: 'param-write',
+            key: 'fault-record-test-param-write',
+            name: 'faultRecordTestParamWrite',
+            icon: 'i-mdi:account-edit-outline',
+            label: '参数设置',
+            component: 'fault-record/param-write/index',
+            meta: {
+              env: 'test',
+              showTab: true,
+              label: '参数设置(测试服)',
+            },
+          },
+        ],
       },
     ],
   },

+ 58 - 9
src/service/faultRecord/index.ts

@@ -1,15 +1,64 @@
-// API List
-// /faultRecord/paramSetting - POST 设置故障录波参数
+// API List(MQTT 直连故障录波,env=prod|test)
+// /faultRecord/devices                - GET  查询网关下可操作的设备
+// /faultRecord/operations             - POST 创建操作(异步)
+// /faultRecord/operations             - GET  操作历史
+// /faultRecord/operations/:id         - GET  操作详情
+// /faultRecord/operations/:id/cancel  - POST 取消操作
+// /faultRecord/operations/:id/retry-download - POST 手动重试下载
+// /faultRecord/operations/:id/record  - GET  录波详情
+// /faultRecord/operations/:id/export  - GET  导出(format=raw|csv)
 
 import http from '@/service/serverReq'
-import type { FaultRecordParamInfo } from './interface'
+import type { ResPage } from '@/service/interface'
+import type {
+  FaultRecordCreateReq,
+  FaultRecordEnv,
+  FaultRecordOpDetail,
+  FaultRecordOpSummary,
+  FaultRecordRecordView,
+  FaultRecordSiteDevices,
+} from './interface'
 
-const API_List = {
-  faultRecordParamSetting: '/faultRecord/paramSetting',
+const root = '/faultRecord/operations'
+const op = (id: string) => `${root}/${encodeURIComponent(id)}`
+
+export function getFaultRecordDevices(env: FaultRecordEnv, gatewaySn: string) {
+  return http.get<FaultRecordSiteDevices>('/faultRecord/devices', { params: { env, gatewaySn } })
+}
+
+export function createFaultRecordOperation(req: FaultRecordCreateReq) {
+  return http.post<{ id: string }>(root, req)
+}
+
+export function getFaultRecordOperations(
+  env: FaultRecordEnv,
+  pageNum: number,
+  pageSize: number,
+  gatewaySn = '',
+  kind = '', // 可多个,逗号分隔
+) {
+  return http.get<ResPage<FaultRecordOpSummary[]>>(root, {
+    params: { env, pageNum, pageSize, gatewaySn, kind },
+  })
+}
+
+export function getFaultRecordOperation(id: string) {
+  return http.get<FaultRecordOpDetail>(op(id))
+}
+
+export function cancelFaultRecordOperation(id: string) {
+  return http.post(`${op(id)}/cancel`)
+}
+
+// 上传已完成但下载失败时,手动重试下载
+export function retryFaultRecordDownload(id: string) {
+  return http.post(`${op(id)}/retry-download`)
+}
+
+export function getFaultRecordRecord(id: string) {
+  return http.get<FaultRecordRecordView>(`${op(id)}/record`)
 }
 
-// POST 设置故障录波参数
-export const setFaultRecordParam = async (config: FaultRecordParamInfo) => {
-  const url = API_List.faultRecordParamSetting
-  const res = await http.post<FaultRecordParamInfo>(url, config)
+export function exportFaultRecordFile(id: string, format: 'raw' | 'csv') {
+  return http.download(`${op(id)}/export`, undefined, { params: { format } })
 }

+ 189 - 10
src/service/faultRecord/interface.ts

@@ -1,11 +1,190 @@
-export interface FaultRecordParamInfo {
-  sn: string // aGate SN
-  list: {
-    type: 'pe' | 'agate'
-    sn: string // aGate SN(DSP) / aPower SN
-    paraList: {
-      id: number
-      val: string
-    }[]
-  }[]
+export type FaultRecordEnv = 'prod' | 'test'
+export type FaultRecordKind = 'device_info' | 'record_fetch' | 'param_read' | 'param_write'
+export type FaultRecordDeviceType = 'aGate' | 'pe'
+
+export type FaultRecordStatus =
+  | 'pending'
+  | 'sent'
+  | 'accepted'
+  | 'ready'
+  | 'uploading'
+  | 'uploaded'
+  | 'downloaded'
+  | 'done'
+  | 'item_failed'
+  | 'busy'
+  | 'rejected'
+  | 'timeout'
+  | 'upload_failed'
+  | 'download_failed'
+  | 'parse_failed'
+  | 'cancelled'
+  | 'interrupted'
+  | 'gateway_locked'
+  | 'error'
+
+export interface FaultRecordParaItem {
+  id: number
+  setVal: string
+}
+
+export interface FaultRecordCreateReq {
+  env: FaultRecordEnv
+  gatewaySn: string
+  deviceType: FaultRecordDeviceType
+  deviceSn: string
+  kind: FaultRecordKind
+  codeList?: number[]
+  recordIndex?: number // 单条(兼容)
+  recordIndexes?: number[] // 多条,设备打成一个包
+  idList?: number[]
+  paraList?: FaultRecordParaItem[]
+}
+
+export interface FaultRecordDevice {
+  type: FaultRecordDeviceType
+  sn: string
+}
+
+export interface FaultRecordSiteDevices {
+  env: FaultRecordEnv
+  gatewaySn: string
+  siteName: string
+  online: boolean
+  onlineTime?: string
+  devices: FaultRecordDevice[]
+}
+
+export interface FaultRecordOpSummary {
+  id: string
+  env: FaultRecordEnv
+  gatewaySn: string
+  deviceType: FaultRecordDeviceType
+  deviceSn: string
+  kind: FaultRecordKind
+  summary: string
+  operator: string
+  status: FaultRecordStatus
+  timeoutStage?: FaultRecordStatus
+  error?: string
+  stageTimes: Record<string, string>
+  fileName?: string
+  faultRecordId?: number
+  canCancel: boolean
+  canRetryDownload: boolean // 上传已完成但下载失败,可手动重试下载
+  downloadRetryCount?: number
+  hasFile: boolean
+  createdAt: string
+  updatedAt: string
+}
+
+export interface FaultRecordResultItem {
+  code?: number
+  id?: number
+  name: string
+  setVal?: string
+  raw?: string
+  decoded?: any
+  decodeError?: string
+  resultOne: string
+  resultDesc: string
+  ok: boolean
+}
+
+export interface FaultRecordOpMessage {
+  direction: 'send' | 'recv' | 'http' | 'action' // http 为文件下载记录,action 为用户操作(如手动重试下载)
+  topic: string
+  cmdType: number
+  at: string
+  raw: string
+}
+
+export interface FaultRecordOpDetail extends FaultRecordOpSummary {
+  request: Record<string, any>
+  replyResult?: string
+  replyDesc?: string
+  items: FaultRecordResultItem[]
+  messages: FaultRecordOpMessage[]
+}
+
+/** ************ 录波文件解析结果 */
+
+export interface FaultRecordBasicInfo {
+  canProtocolVersion: number
+  storageFormat: number
+  paramListVersion: string
+  maxSampleFrequency: number
+  sampleFrequency: number
+  totalPoints: number
+  prePoints: number
+  postPoints: number
+}
+
+export interface FaultRecordConfigurableChannel {
+  type: number
+  mode?: number // 0=默认方式 1=代码方式(前端以 type 为准,见 cfgParts)
+  addrType?: number
+  typeName: string
+  address: number
+}
+
+export interface FaultRecordChannelGroups {
+  snapshotTotal: number
+  snapshotUsed: number
+  snapshots: { meaning: number; value?: number }[]
+  sampleTotal: number
+  sampleUsed: number
+  sampleMeanings: number[]
+  configSnapTotal: number
+  configSnapUsed: number
+  configSnapshots: FaultRecordConfigurableChannel[]
+  configSampleTotal: number
+  configSampleUsed: number
+  configSamples: FaultRecordConfigurableChannel[]
+}
+
+export interface FaultRecordFailureData extends FaultRecordChannelGroups {
+  declaredLength: number
+  basic: FaultRecordBasicInfo
+  index: number
+  time: string
+  triggerSource: number
+  triggerSourceName?: string
+  dcdcVersion: string
+  invVersion?: string
+  fpgaVersion?: string
+  parsedBytes: number
+}
+
+export interface FaultRecordWaveformChannel {
+  number: number
+  meaning: number
+  configurable?: FaultRecordConfigurableChannel
+  values: number[]
+}
+
+export interface FaultRecordRecord {
+  batch: string
+  sn: string
+  recordIndex: number
+  failure: FaultRecordFailureData
+  waveform: {
+    points: number
+    prePoints: number
+    sampleFrequency: number
+    channels: FaultRecordWaveformChannel[]
+  }
+}
+
+export interface FaultRecordRecordView {
+  operationId: string
+  records: FaultRecordRecord[]
+}
+
+export interface FaultRecordDirectoryEntry {
+  index: number
+  time: string
+  paramListVersion: string
+  triggerSource: number
+  triggerSourceName?: string
 }

+ 0 - 2
src/stores/initStores.ts

@@ -24,7 +24,6 @@ import { useSitesMonitorConfigStore } from './modules/views/sitesMonitorConfig'
 import { useSiteRuntimeChartConfigStore } from './modules/views/siteRuntimeChartConfig'
 import { useXlogViewsStateStore } from './modules/views/xlogViewsState'
 import { useSiteInfoManageStateStore } from './modules/views/siteInfoManageState'
-import { useFaultRecordStateStore } from './modules/views/faultRecordState'
 import { useSimCardManageStateStore } from './modules/views/simCardManageState'
 
 // 全局注册表
@@ -47,7 +46,6 @@ export const InitStores = () => {
   InitOneStore(useSiteRuntimeChartConfigStore())
   InitOneStore(useXlogViewsStateStore())
   InitOneStore(useSiteInfoManageStateStore())
-  InitOneStore(useFaultRecordStateStore())
   InitOneStore(useSimCardManageStateStore())
 
   // 监听登录事件,重置Store

+ 0 - 59
src/stores/modules/views/faultRecordState.ts

@@ -1,59 +0,0 @@
-import { defineSetupStore } from '@/stores/public/template'
-
-const STORES_KEY = 'FaultRecordStateStore'
-const CURRENT_VERSION = '0.0.0'
-
-export interface FaultRecordWriteParamItem {
-  key: string
-  id: number
-  val: string
-}
-
-export interface FaultRecordWriteParam {
-  sn: string // aGate SN
-  list: {
-    key: string
-    type: 'pe' | 'agate'
-    sn: string // aGate SN(DSP) / aPower SN
-    paraList: FaultRecordWriteParamItem[]
-  }[]
-}
-
-export const useFaultRecordStateStore = defineSetupStore(
-  STORES_KEY,
-  CURRENT_VERSION,
-  () => {
-    const faultRecordWriteParam = ref<FaultRecordWriteParam>({
-      sn: '',
-      list: [],
-    })
-
-    function $reset() {
-      faultRecordWriteParam.value = {
-        sn: '',
-        list: [],
-      }
-    }
-
-    return {
-      // normal state
-      faultRecordWriteParam,
-
-      // reactive state - ref()
-
-      // reactive getters - computed()
-
-      // normal getters - function()
-
-      // actions - function()
-      $reset,
-    }
-  },
-  {
-    persist: {
-      key: STORES_KEY, // 存储 key
-      storage: localStorage, // 默认是 localStorage,sessionStorage可选
-      debug: true,
-    },
-  },
-)

+ 10 - 2
src/stores/user.ts

@@ -62,8 +62,16 @@ const autoUpdateMenuNames: string[] = [
 
 const faultRecordMenuNames: string[] = [
   'faultRecord', // 故障录波主菜单
-  'faultRecordRead', // 故障录波参数读界面
-  'faultRecordWrite', // 故障录波参数写界面
+  'faultRecordProd',
+  'faultRecordProdRecord',
+  'faultRecordProdDeviceInfo',
+  'faultRecordProdFunction',
+  'faultRecordProdParamWrite',
+  'faultRecordTest',
+  'faultRecordTestRecord',
+  'faultRecordTestDeviceInfo',
+  'faultRecordTestFunction',
+  'faultRecordTestParamWrite',
 ] as const
 
 const parsePermissions = (payload: JwtPayload): User => {

+ 1 - 0
src/typings/vue-router.d.ts

@@ -9,5 +9,6 @@ declare module 'vue-router' {
     showTab?: boolean
     enableMultiTab?: boolean
     renderTabLabel?: (params: RouteParamsGeneric) => string
+    env?: 'prod' | 'test' // 故障录波页面所属环境
   }
 }

+ 140 - 0
src/views/fault-record/common/DeviceSelector.vue

@@ -0,0 +1,140 @@
+<template>
+  <div class="flex flex-wrap gap-3 items-end">
+    <NTag :type="env === 'prod' ? 'error' : 'info'" size="large" :bordered="false" class="font-bold">
+      {{ ENV_LABEL[env] }}
+    </NTag>
+    <div class="w-64">
+      <div class="text-xs text-gray-500 mb-1">网关 SN</div>
+      <NAutoComplete
+        v-model:value="gatewayInput"
+        :options="recentOptions"
+        placeholder="输入 aGate SN"
+        clearable
+        @keyup.enter="query"
+        @select="onSelect"
+      />
+    </div>
+    <NButton type="primary" :loading="loading" @click="query">查询</NButton>
+    <div class="w-72">
+      <div class="text-xs text-gray-500 mb-1">设备</div>
+      <NSelect
+        :value="deviceKey"
+        :options="deviceOptions"
+        :disabled="!site"
+        placeholder="先查询网关"
+        @update:value="selectDevice"
+      />
+    </div>
+    <div v-if="site" class="text-sm text-gray-500 pb-1 flex gap-2 items-center">
+      <span>{{ site.siteName || '-' }}</span>
+      <NTag :type="site.online ? 'success' : 'warning'" size="small">
+        {{ site.online ? '在线' : '离线' }}
+      </NTag>
+      <span v-if="!site.online && site.onlineTime">最后上线 {{ site.onlineTime }}</span>
+      <span>aPower × {{ site.devices.length - 1 }}</span>
+    </div>
+  </div>
+</template>
+
+<script setup lang="ts">
+import { computed, onMounted, ref, watch } from 'vue'
+import { NAutoComplete, NButton, NSelect, NTag, useMessage } from 'naive-ui'
+
+import { getFaultRecordDevices } from '@/service/faultRecord'
+import type {
+  FaultRecordDevice,
+  FaultRecordEnv,
+  FaultRecordSiteDevices,
+} from '@/service/faultRecord/interface'
+
+import { ENV_LABEL, errText } from './constants'
+
+const props = defineProps<{ env: FaultRecordEnv }>()
+const gatewaySn = defineModel<string>('gatewaySn', { default: '' })
+const device = defineModel<FaultRecordDevice | undefined>('device')
+
+const message = useMessage()
+const loading = ref(false)
+const site = ref<FaultRecordSiteDevices>()
+const gatewayInput = ref(gatewaySn.value)
+
+/** ************ 最近使用的网关(仅本机,失败不影响功能) **************/
+const RECENT_KEY = `fault-record-recent-${props.env}`
+const recent = ref<string[]>(loadRecent())
+function loadRecent(): string[] {
+  try {
+    return JSON.parse(localStorage.getItem(RECENT_KEY) || '[]')
+  } catch {
+    return []
+  }
+}
+function saveRecent(sn: string) {
+  recent.value = [sn, ...recent.value.filter((s) => s !== sn)].slice(0, 10)
+  try {
+    localStorage.setItem(RECENT_KEY, JSON.stringify(recent.value))
+  } catch {
+    // ignore
+  }
+}
+const recentOptions = computed(() =>
+  recent.value.filter((s) => !gatewayInput.value || s.includes(gatewayInput.value.trim())),
+)
+
+/** ************ 设备 **************/
+const deviceKey = computed(() => (device.value ? `${device.value.type}:${device.value.sn}` : null))
+const deviceOptions = computed(() =>
+  (site.value?.devices ?? []).map((d) => ({
+    label: d.type === 'aGate' ? `aGate  ${d.sn}` : `pe(aPower)  ${d.sn}`,
+    value: `${d.type}:${d.sn}`,
+  })),
+)
+function selectDevice(key: string) {
+  device.value = site.value?.devices.find((d) => `${d.type}:${d.sn}` === key)
+}
+
+/** 选择历史项时,select 事件早于输入框值的更新,需直接使用选中值 */
+function onSelect(value: string | number) {
+  gatewayInput.value = String(value)
+  query()
+}
+
+async function query() {
+  // 在下拉中按回车选择时会同时触发 select 和 keyup.enter,避免重复查询
+  if (loading.value) return
+  const sn = gatewayInput.value?.trim()
+  if (!sn) {
+    message.warning('请输入网关 SN')
+    return
+  }
+  loading.value = true
+  try {
+    site.value = await getFaultRecordDevices(props.env, sn)
+    gatewaySn.value = site.value.gatewaySn
+    // 保留已选设备(如从参数读取页带入),否则默认 aGate
+    const keep = site.value.devices.find((d) => d.type === device.value?.type && d.sn === device.value?.sn)
+    device.value = keep ?? site.value.devices[0]
+    saveRecent(site.value.gatewaySn)
+  } catch (e) {
+    site.value = undefined
+    device.value = undefined
+    gatewaySn.value = ''
+    message.error(`查询站点失败:${errText(e)}`)
+  } finally {
+    loading.value = false
+  }
+}
+
+// 外部设置网关(如带入参数)时自动查询
+watch(gatewaySn, (v) => {
+  if (v && v !== site.value?.gatewaySn) {
+    gatewayInput.value = v
+    query()
+  }
+})
+
+onMounted(() => {
+  if (gatewaySn.value) query()
+})
+
+defineExpose({ query })
+</script>

+ 36 - 0
src/views/fault-record/common/ExportButtons.vue

@@ -0,0 +1,36 @@
+<template>
+  <NDropdown trigger="click" :options="options" @select="onSelect">
+    <NButton :size="size" :loading="loading">导出</NButton>
+  </NDropdown>
+</template>
+
+<script setup lang="ts">
+import { ref } from 'vue'
+import { NButton, NDropdown, useMessage } from 'naive-ui'
+
+import { exportFaultRecordFile } from '@/service/faultRecord'
+
+import { errText } from './constants'
+
+const props = withDefaults(defineProps<{ id: string; size?: 'tiny' | 'small' | 'medium' }>(), {
+  size: 'small',
+})
+
+const message = useMessage()
+const loading = ref(false)
+const options = [
+  { label: '原始压缩包', key: 'raw' },
+  { label: 'CSV(zip)', key: 'csv' },
+]
+
+async function onSelect(format: 'raw' | 'csv') {
+  loading.value = true
+  try {
+    await exportFaultRecordFile(props.id, format)
+  } catch (e) {
+    message.error(`导出失败:${errText(e)}`)
+  } finally {
+    loading.value = false
+  }
+}
+</script>

+ 131 - 0
src/views/fault-record/common/OperationHistory.vue

@@ -0,0 +1,131 @@
+<template>
+  <div class="flex flex-col gap-2">
+    <div class="flex gap-2 items-center">
+      <span class="font-bold">操作历史</span>
+      <span class="text-xs text-gray-500">(所有人共享{{ gatewaySn ? `,网关 ${gatewaySn}` : '' }})</span>
+      <div class="flex-1" />
+      <NButton size="small" quaternary :loading="loading" @click="load">刷新</NButton>
+    </div>
+    <NDataTable
+      size="small"
+      remote
+      :columns="columns"
+      :data="rows"
+      :loading="loading"
+      :pagination="pagination"
+      :row-key="(r: FaultRecordOpSummary) => r.id"
+      :row-props="rowProps"
+    />
+  </div>
+</template>
+
+<script setup lang="ts">
+import { h, reactive, ref, watch } from 'vue'
+import { NButton, NDataTable, NTag, NTooltip, useMessage } from 'naive-ui'
+import type { DataTableColumns } from 'naive-ui'
+
+import { getFaultRecordOperations } from '@/service/faultRecord'
+import type {
+  FaultRecordEnv,
+  FaultRecordKind,
+  FaultRecordOpSummary,
+} from '@/service/faultRecord/interface'
+
+import { KIND_LABEL, STATUS_META, errText, fmtTime } from './constants'
+
+const props = defineProps<{
+  env: FaultRecordEnv
+  kind: FaultRecordKind | string // 可多个,逗号分隔
+  gatewaySn?: string
+  refreshKey?: number
+}>()
+const emit = defineEmits<{ select: [row: FaultRecordOpSummary] }>()
+const slots = defineSlots<{ actions?: (p: { row: FaultRecordOpSummary }) => any }>()
+
+const message = useMessage()
+const loading = ref(false)
+const rows = ref<FaultRecordOpSummary[]>([])
+
+const pagination = reactive({
+  page: 1,
+  pageSize: 10,
+  itemCount: 0,
+  showSizePicker: true,
+  pageSizes: [10, 20, 50],
+  onChange: (p: number) => {
+    pagination.page = p
+    load()
+  },
+  onUpdatePageSize: (s: number) => {
+    pagination.pageSize = s
+    pagination.page = 1
+    load()
+  },
+})
+
+const columns: DataTableColumns<FaultRecordOpSummary> = [
+  { title: '时间', key: 'createdAt', width: 160, render: (r) => fmtTime(r.createdAt) },
+  { title: '操作人', key: 'operator', width: 110, ellipsis: { tooltip: true } },
+  { title: '网关', key: 'gatewaySn', width: 180, ellipsis: { tooltip: true } },
+  { title: '设备', key: 'deviceSn', width: 200, render: (r) => `${r.deviceType} ${r.deviceSn}` },
+  { title: '操作', key: 'kind', width: 90, render: (r) => KIND_LABEL[r.kind] },
+  { title: '参数', key: 'summary', ellipsis: { tooltip: true } },
+  {
+    title: '状态',
+    key: 'status',
+    width: 110,
+    render: (r) => {
+      const meta = STATUS_META[r.status]
+      const tag = h(NTag, { type: meta.type, size: 'small' }, { default: () => meta.label })
+      return r.error
+        ? h(NTooltip, null, { trigger: () => tag, default: () => r.error })
+        : tag
+    },
+  },
+  {
+    title: '',
+    key: 'actions',
+    width: 200,
+    render: (r) =>
+      h('div', { class: 'flex gap-1', onClick: (e: Event) => e.stopPropagation() }, [
+        h(NButton, { size: 'tiny', onClick: () => emit('select', r) }, { default: () => '查看' }),
+        slots.actions?.({ row: r }),
+      ]),
+  },
+]
+
+const rowProps = (r: FaultRecordOpSummary) => ({
+  style: 'cursor: pointer',
+  onClick: () => emit('select', r),
+})
+
+async function load() {
+  loading.value = true
+  try {
+    const res = await getFaultRecordOperations(
+      props.env,
+      pagination.page,
+      pagination.pageSize,
+      props.gatewaySn ?? '',
+      props.kind,
+    )
+    rows.value = res.data ?? []
+    pagination.itemCount = res.total
+  } catch (e) {
+    message.error(`获取操作历史失败:${errText(e)}`)
+  } finally {
+    loading.value = false
+  }
+}
+
+watch(
+  () => [props.env, props.gatewaySn, props.kind, props.refreshKey],
+  () => {
+    pagination.page = 1
+    load()
+  },
+  { immediate: true },
+)
+
+defineExpose({ load })
+</script>

+ 232 - 0
src/views/fault-record/common/OperationProgress.vue

@@ -0,0 +1,232 @@
+<template>
+  <div v-if="detail" class="flex flex-col gap-3">
+    <div class="flex flex-wrap gap-2 items-center">
+      <NTag :type="STATUS_META[detail.status].type">{{ STATUS_META[detail.status].label }}</NTag>
+      <span class="text-sm text-gray-500">
+        {{ KIND_LABEL[detail.kind] }} · {{ detail.deviceType }} {{ detail.deviceSn }} · {{ detail.summary }}
+        · {{ detail.operator }} · {{ fmtTime(detail.createdAt) }}
+      </span>
+      <div class="flex-1" />
+      <NPopconfirm v-if="detail.canCancel && cancel" @positive-click="onCancel">
+        <template #trigger>
+          <NButton size="small" type="warning" :loading="cancelling">取消</NButton>
+        </template>
+        {{ cancelHint }}
+      </NPopconfirm>
+      <NButton
+        v-if="detail.canRetryDownload && retryDownload"
+        size="small"
+        type="primary"
+        :loading="retrying"
+        @click="onRetryDownload"
+      >
+        重试下载
+      </NButton>
+    </div>
+
+    <NSteps size="small" :current="currentStep" :status="stepStatus">
+      <NStep
+        v-for="s in stages"
+        :key="s.status"
+        :title="s.label"
+        :description="fmtClock(detail.stageTimes[s.status])"
+      />
+    </NSteps>
+
+    <NAlert v-if="alertText" :type="alertType" :show-icon="true">{{ alertText }}</NAlert>
+
+    <NCollapse>
+      <NCollapseItem :title="`原始报文与下载记录(${detail.messages?.length ?? 0})`" name="messages">
+        <div class="flex flex-col gap-2 max-h-96 overflow-auto">
+          <div v-for="(m, i) in detail.messages" :key="i" class="text-xs">
+            <div class="text-gray-500">
+              {{ fmtTime(m.at) }}
+              <template v-if="m.direction === 'action'">
+                <NTag size="small" type="info">{{ m.topic }}</NTag>
+                <span class="ml-1">{{ actionText(m) }}</span>
+              </template>
+              <template v-else-if="m.direction === 'http'">
+                HTTP 文件下载 <span class="opacity-60">{{ m.topic }}</span>
+                <NTag v-if="httpInfo(m).failed" size="small" type="warning" class="ml-1">
+                  失败{{ httpInfo(m).count > 1 ? ` ${httpInfo(m).count} 次` : '' }}
+                </NTag>
+                <NTag v-else size="small" type="success" class="ml-1">成功</NTag>
+                <div v-if="httpInfo(m).url" class="mt-1">
+                  下载链接:
+                  <a
+                    :href="httpInfo(m).url"
+                    target="_blank"
+                    rel="noopener noreferrer"
+                    download
+                    class="text-blue-500 underline break-all"
+                  >{{ httpInfo(m).url }}</a>
+                </div>
+              </template>
+              <template v-else>
+                {{ m.direction === 'send' ? '发送 →' : '← 接收' }} {{ m.cmdType }}
+                {{ CMD_TYPE_NAME[m.cmdType] ?? '' }} <span class="opacity-60">{{ m.topic }}</span>
+              </template>
+            </div>
+            <pre class="m-0 p-2 rounded bg-gray-500/10 whitespace-pre-wrap break-all">{{ pretty(m.raw) }}</pre>
+          </div>
+        </div>
+      </NCollapseItem>
+    </NCollapse>
+  </div>
+</template>
+
+<script setup lang="ts">
+import { computed, ref } from 'vue'
+import {
+  NAlert,
+  NButton,
+  NCollapse,
+  NCollapseItem,
+  NPopconfirm,
+  NStep,
+  NSteps,
+  NTag,
+  useMessage,
+} from 'naive-ui'
+
+import type { FaultRecordOpDetail } from '@/service/faultRecord/interface'
+
+import { KIND_LABEL, KIND_STAGES, STATUS_META, errText, fmtTime } from './constants'
+
+const props = defineProps<{
+  detail?: FaultRecordOpDetail
+  cancel?: () => Promise<void>
+  /** 上传已完成但下载失败时,手动重试下载 */
+  retryDownload?: () => Promise<void>
+}>()
+
+const message = useMessage()
+const cancelling = ref(false)
+
+const CMD_TYPE_NAME: Record<number, string> = {
+  377: '指令',
+  378: '指令应答',
+  379: '结果上传',
+  380: '上传应答(平台)',
+  607: '上传指令(平台)',
+  608: '上传指令应答',
+  609: '文件上传结果',
+  610: '上传结果应答(平台)',
+}
+
+const stages = computed(() => (props.detail ? KIND_STAGES[props.detail.kind] : []))
+
+// 已到达的最后一个阶段
+const reachedIndex = computed(() => {
+  const d = props.detail
+  if (!d) return -1
+  let idx = -1
+  stages.value.forEach((s, i) => {
+    if (d.stageTimes[s.status]) idx = i
+  })
+  return idx
+})
+
+const currentStep = computed(() => {
+  const d = props.detail
+  if (!d) return 1
+  if (d.status === 'done') return stages.value.length
+  // 失败停在下一个未到达的阶段
+  return Math.min(reachedIndex.value + 2, stages.value.length)
+})
+
+const stepStatus = computed(() => {
+  const s = props.detail?.status
+  if (!s) return 'process'
+  if (s === 'done') return 'finish'
+  if (STATUS_META[s].terminal) return STATUS_META[s].type === 'error' ? 'error' : 'wait'
+  return 'process'
+})
+
+const alertType = computed(() => {
+  const t = props.detail ? STATUS_META[props.detail.status].type : 'info'
+  return t === 'default' ? 'info' : t
+})
+
+const alertText = computed(() => {
+  const d = props.detail
+  if (!d) return ''
+  if (d.status === 'busy') return '设备正在处理其他命令(可能来自 FWH 平台),请稍后再试。'
+  if (d.status === 'cancelled') return d.error || '操作已取消。'
+  if (d.status === 'item_failed') return d.error || '设备返回了失败的子项,详见结果。'
+  return d.error ?? ''
+})
+
+const cancelHint = computed(() => {
+  const s = props.detail?.status
+  if (s === 'sent' || s === 'accepted') return '将向设备发送退出命令(cmd=4)结束当前处理,确认取消?'
+  if (s === 'pending') return '操作尚未发送,确认取消?'
+  return '设备已处理完成,取消只停止本工具的等待,平台上传不受影响。确认取消?'
+})
+
+const retrying = ref(false)
+async function onRetryDownload() {
+  retrying.value = true
+  try {
+    await props.retryDownload?.()
+    message.info('已重新开始下载')
+  } catch (e) {
+    message.error(`重试下载失败:${errText(e)}`)
+  } finally {
+    retrying.value = false
+  }
+}
+
+async function onCancel() {
+  cancelling.value = true
+  try {
+    await props.cancel?.()
+    message.info('已请求取消')
+  } catch (e) {
+    message.error(errText(e))
+  } finally {
+    cancelling.value = false
+  }
+}
+
+function fmtClock(t?: string) {
+  return t ? fmtTime(t).slice(11) : ''
+}
+
+const WHITESPACE_RE = /\s/g
+
+/** 用户操作记录的说明,如“操作人 张三,第 2 次重试,重试前:下载失败” */
+function actionText(m: { raw: string }) {
+  try {
+    const v = JSON.parse(m.raw)
+    const parts = [`操作人 ${v.operator || '-'}`]
+    if (v.attempt) parts.push(`第 ${v.attempt} 次重试`)
+    if (v.prevStatus) parts.push(`重试前:${STATUS_META[v.prevStatus as keyof typeof STATUS_META]?.label ?? v.prevStatus}`)
+    return parts.join(',')
+  } catch {
+    return ''
+  }
+}
+
+/** 下载记录的结果与相同失败的累计次数 */
+function httpInfo(m: { raw: string }) {
+  try {
+    const v = JSON.parse(m.raw)
+    const url = typeof v.url === 'string' && v.url.startsWith('https://') ? v.url : ''
+    return { failed: v.result !== 'ok', count: Number(v.count ?? 1), url }
+  } catch {
+    return { failed: false, count: 1, url: '' }
+  }
+}
+
+/** 格式化报文;重新序列化会丢内容时(如 608 的重复 result 键,JSON.parse 只保留最后一个)原样显示 */
+function pretty(raw: string) {
+  try {
+    const parsed = JSON.parse(raw)
+    if (JSON.stringify(parsed).length !== raw.replace(WHITESPACE_RE, '').length) return raw
+    return JSON.stringify(parsed, null, 2)
+  } catch {
+    return raw
+  }
+}
+</script>

+ 99 - 0
src/views/fault-record/common/RecordDetail.vue

@@ -0,0 +1,99 @@
+<template>
+  <NSpin :show="loading">
+    <NAlert v-if="error" type="error" class="mb-3">{{ error }}</NAlert>
+    <div v-for="rec in records" :key="`${rec.sn}-${rec.recordIndex}`" class="flex flex-col gap-4">
+      <div class="flex gap-2 items-center">
+        <span class="font-bold">记录 {{ rec.recordIndex }} · {{ rec.sn }} · 批次 {{ rec.batch }}</span>
+        <div class="flex-1" />
+        <ExportButtons :id="operationId" />
+      </div>
+
+      <NDescriptions bordered size="small" :column="3" label-placement="left">
+        <NDescriptionsItem label="发生时间(设备时间)">{{ rec.failure.time }}</NDescriptionsItem>
+        <NDescriptionsItem label="触发源">
+          {{ fmtHex(rec.failure.triggerSource) }} {{ rec.failure.triggerSourceName ?? '' }}
+        </NDescriptionsItem>
+        <NDescriptionsItem label="位置索引">{{ rec.failure.index }}</NDescriptionsItem>
+        <NDescriptionsItem label="采样频率">{{ rec.failure.basic.sampleFrequency }} Hz(最大 {{ rec.failure.basic.maxSampleFrequency }})</NDescriptionsItem>
+        <NDescriptionsItem label="采样点">
+          {{ rec.failure.basic.totalPoints }}(故障前 {{ rec.failure.basic.prePoints }} / 故障后 {{ rec.failure.basic.postPoints }})
+        </NDescriptionsItem>
+        <NDescriptionsItem label="参数列表版本">{{ rec.failure.basic.paramListVersion }}</NDescriptionsItem>
+        <NDescriptionsItem label="CAN 协议 / 存储格式">
+          v{{ rec.failure.basic.canProtocolVersion }} / v{{ rec.failure.basic.storageFormat }}
+        </NDescriptionsItem>
+        <NDescriptionsItem label="DCDC 版本">{{ rec.failure.dcdcVersion || '-' }}</NDescriptionsItem>
+        <NDescriptionsItem v-if="rec.failure.invVersion || rec.failure.fpgaVersion" label="INV / FPGA 版本">
+          {{ rec.failure.invVersion || '-' }} / {{ rec.failure.fpgaVersion || '-' }}
+        </NDescriptionsItem>
+      </NDescriptions>
+
+      <NCard size="small" title="波形">
+        <WaveformChart :record="rec" />
+      </NCard>
+
+      <div class="gap-4 grid grid-cols-1 lg:grid-cols-2">
+        <NCard size="small" :title="`快照通道(${rec.failure.snapshotUsed}/${rec.failure.snapshotTotal})`">
+          <NDataTable
+            size="small"
+            :columns="snapshotColumns"
+            :data="rec.failure.snapshots.map((s, i) => ({ ...s, i }))"
+          />
+        </NCard>
+        <NCard size="small" :title="`可配采样通道(${rec.failure.configSampleUsed}/${rec.failure.configSampleTotal})`">
+          <NDataTable size="small" :columns="configColumns" :data="rec.failure.configSamples.map((s, i) => ({ ...s, i }))" />
+          <div v-if="rec.failure.configSnapTotal > 0" class="mt-3">
+            <div class="text-sm mb-1">可配快照通道({{ rec.failure.configSnapUsed }}/{{ rec.failure.configSnapTotal }})</div>
+            <NDataTable size="small" :columns="configColumns" :data="rec.failure.configSnapshots.map((s, i) => ({ ...s, i }))" />
+          </div>
+        </NCard>
+      </div>
+    </div>
+  </NSpin>
+</template>
+
+<script setup lang="ts">
+import { ref, watch } from 'vue'
+import { NAlert, NCard, NDataTable, NDescriptions, NDescriptionsItem, NSpin } from 'naive-ui'
+import type { DataTableColumns } from 'naive-ui'
+
+import { getFaultRecordRecord } from '@/service/faultRecord'
+import type { FaultRecordConfigurableChannel, FaultRecordRecord } from '@/service/faultRecord/interface'
+
+import ExportButtons from './ExportButtons.vue'
+import WaveformChart from './WaveformChart.vue'
+import { cfgParts, configDesc, errText, fmtHex } from './constants'
+
+const props = defineProps<{ operationId: string }>()
+
+const loading = ref(false)
+const error = ref('')
+const records = ref<FaultRecordRecord[]>([])
+
+const snapshotColumns: DataTableColumns<{ i: number; meaning: number; value?: number }> = [
+  { title: '序号', key: 'i', width: 60 },
+  { title: '含义代码', key: 'meaning', render: (r) => fmtHex(r.meaning) },
+  { title: '原始值', key: 'value', render: (r) => (r.value == null ? '-' : `${r.value}(${fmtHex(r.value)})`) },
+]
+
+const configColumns: DataTableColumns<FaultRecordConfigurableChannel & { i: number }> = [
+  { title: '序号', key: 'i', width: 60 },
+  { title: '方式', key: 'mode', width: 90, render: (r) => (cfgParts(r).mode === 1 ? '代码方式' : '默认方式') },
+  { title: '配置', key: 'address', render: (r) => configDesc(r) },
+]
+
+async function load() {
+  loading.value = true
+  error.value = ''
+  try {
+    records.value = (await getFaultRecordRecord(props.operationId)).records
+  } catch (e) {
+    records.value = []
+    error.value = `读取录波文件失败:${errText(e)}`
+  } finally {
+    loading.value = false
+  }
+}
+
+watch(() => props.operationId, load, { immediate: true })
+</script>

+ 73 - 0
src/views/fault-record/common/ResultItems.vue

@@ -0,0 +1,73 @@
+<template>
+  <div class="flex flex-col gap-2">
+    <div v-if="detail.replyResult" class="text-sm">
+      整体结果:<NTag size="small" :type="detail.replyResult === 'ok' ? 'success' : 'warning'">{{ detail.replyDesc }}</NTag>
+    </div>
+    <NDataTable size="small" :columns="columns" :data="detail.items" />
+  </div>
+</template>
+
+<script setup lang="ts">
+import { computed, h } from 'vue'
+import { NDataTable, NTag } from 'naive-ui'
+import type { DataTableColumns } from 'naive-ui'
+
+import type { FaultRecordOpDetail, FaultRecordResultItem } from '@/service/faultRecord/interface'
+
+const props = defineProps<{
+  detail: FaultRecordOpDetail
+  /** 自定义“解读”列的渲染;不传时显示 JSON */
+  renderDecoded?: (item: FaultRecordResultItem) => any
+}>()
+
+const resultCol = {
+  title: '结果',
+  key: 'resultOne',
+  width: 220,
+  render: (r: FaultRecordResultItem) =>
+    h(NTag, { size: 'small', type: r.ok ? 'success' : 'error' }, { default: () => r.resultDesc }),
+}
+
+const decodedCol = {
+  title: '解读',
+  key: 'decoded',
+  render: (r: FaultRecordResultItem) => {
+    if (r.decodeError) return h('span', { class: 'text-red-500' }, r.decodeError)
+    if (r.decoded == null) return '-'
+    return props.renderDecoded ? props.renderDecoded(r) : JSON.stringify(r.decoded)
+  },
+}
+
+const columns = computed<DataTableColumns<FaultRecordResultItem>>(() => {
+  switch (props.detail.kind) {
+    case 'param_write':
+      return [
+        { title: '功能码', key: 'id', width: 80 },
+        { title: '名称', key: 'name', width: 160 },
+        { title: '设置值', key: 'setVal', render: (r) => r.setVal || '-' },
+        resultCol,
+      ]
+    case 'param_read':
+      return [
+        { title: '功能码', key: 'id', width: 80 },
+        { title: '名称', key: 'name', width: 160 },
+        { title: '原始十六进制', key: 'raw', ellipsis: { tooltip: true } },
+        decodedCol,
+        resultCol,
+      ]
+    case 'record_fetch':
+      return [
+        { title: '类型码', key: 'code', width: 80 },
+        { title: '名称', key: 'name' },
+        resultCol,
+      ]
+    default:
+      return [
+        { title: 'code', key: 'code', width: 70 },
+        { title: '名称', key: 'name', width: 160 },
+        { title: '原始 hexData', key: 'raw', ellipsis: { tooltip: true } },
+        resultCol,
+      ]
+  }
+})
+</script>

+ 133 - 0
src/views/fault-record/common/WaveformChart.vue

@@ -0,0 +1,133 @@
+<template>
+  <div class="flex flex-col gap-2">
+    <div class="flex flex-wrap gap-2 items-center">
+      <span class="text-sm">X 轴</span>
+      <NRadioGroup v-model:value="xMode" size="small">
+        <NRadioButton value="ms">相对故障时刻 (ms)</NRadioButton>
+        <NRadioButton value="point">采样点</NRadioButton>
+      </NRadioGroup>
+      <div class="flex-1" />
+      <NButton size="small" @click="selected = channels.map((c) => c.number)">全选</NButton>
+      <NButton size="small" @click="selected = []">清空</NButton>
+    </div>
+    <NCheckboxGroup v-model:value="selected">
+      <div class="flex flex-wrap gap-x-4 gap-y-1">
+        <NCheckbox v-for="c in channels" :key="c.number" :value="c.number" :label="channelLabel(c)" />
+      </div>
+    </NCheckboxGroup>
+    <div class="text-xs text-gray-500">
+      每个通道一个小图,共用 X 轴与游标;纵轴为原始值(int16),未做物理量换算。虚线为故障时刻。
+    </div>
+    <div ref="container" class="flex flex-col w-full">
+      <div v-for="c in visibleChannels" :key="`${c.number}-${xMode}-${isDark}`">
+        <UplotVue :data="plotData(c)" :options="plotOptions(c)" />
+      </div>
+    </div>
+  </div>
+</template>
+
+<script setup lang="ts">
+import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
+import type uPlot from 'uplot'
+import UplotVue from 'uplot-vue'
+import 'uplot/dist/uPlot.min.css'
+import { NButton, NCheckbox, NCheckboxGroup, NRadioButton, NRadioGroup } from 'naive-ui'
+
+import { usePersonalization } from '@/composable/usePersonalization'
+import type { FaultRecordRecord, FaultRecordWaveformChannel } from '@/service/faultRecord/interface'
+
+import { configDesc, fmtHex } from './constants'
+
+const props = defineProps<{ record: FaultRecordRecord }>()
+
+const { isDark } = usePersonalization()
+
+// 单系列小图:标题已标明通道,所有图用同一系列色(参考调色板 slot 1,按明暗分别取值)
+const colors = computed(() =>
+  isDark.value
+    ? { series: '#3987e5', axis: '#c3c2b7', grid: 'rgba(240,239,236,0.08)', marker: '#c3c2b7' }
+    : { series: '#2a78d6', axis: '#52514e', grid: 'rgba(11,11,11,0.06)', marker: '#52514e' },
+)
+
+const channels = computed(() => props.record.waveform.channels)
+const selected = ref<number[]>(channels.value.slice(0, 8).map((c) => c.number))
+const visibleChannels = computed(() => channels.value.filter((c) => selected.value.includes(c.number)))
+const xMode = ref<'ms' | 'point'>('ms')
+
+function channelLabel(c: FaultRecordWaveformChannel) {
+  if (c.configurable) {
+    return `ch${c.number} 可配 ${configDesc(c.configurable)}`
+  }
+  return `ch${c.number} ${fmtHex(c.meaning)}`
+}
+
+const xValues = computed(() => {
+  const w = props.record.waveform
+  return Array.from({ length: w.points }, (_, k) =>
+    xMode.value === 'ms' ? ((k - w.prePoints) * 1000) / (w.sampleFrequency || 1) : k,  )
+})
+const faultX = computed(() => (xMode.value === 'ms' ? 0 : props.record.waveform.prePoints))
+
+function plotData(c: FaultRecordWaveformChannel): uPlot.AlignedData {
+  return [xValues.value, c.values]
+}
+
+/** ************ 宽度跟随容器 **************/
+const container = ref<HTMLElement>()
+const width = ref(800)
+let ro: ResizeObserver | undefined
+onMounted(() => {
+  if (!container.value) return
+  width.value = container.value.clientWidth || 800
+  ro = new ResizeObserver(([entry]) => {
+    const w = Math.floor(entry.contentRect.width)
+    if (w > 0 && Math.abs(w - width.value) > 8) width.value = w
+  })
+  ro.observe(container.value)
+})
+onBeforeUnmount(() => ro?.disconnect())
+
+const syncKey = `fault-record-wave-${Math.random().toString(36).slice(2)}`
+
+function plotOptions(c: FaultRecordWaveformChannel): uPlot.Options {
+  const col = colors.value
+  const axis = { stroke: col.axis, grid: { stroke: col.grid, width: 1 }, ticks: { show: false } }
+  return {
+    title: channelLabel(c),
+    width: width.value,
+    height: 140,
+    cursor: { sync: { key: syncKey }, points: { size: 8 } },
+    scales: { x: { time: false } },
+    legend: { show: true, live: true },
+    series: [
+      {
+        label: xMode.value === 'ms' ? 'ms' : '点',
+        value: (_u, v) => (v == null ? '-' : xMode.value === 'ms' ? v.toFixed(3) : String(v)),
+      },
+      { label: '原始值', stroke: col.series, width: 2, points: { show: false } },
+    ],
+    axes: [
+      { ...axis, size: 30 },
+      { ...axis, size: 60 },
+    ],
+    hooks: {
+      draw: [
+        (u) => {
+          // 故障时刻参考线
+          const x = Math.round(u.valToPos(faultX.value, 'x', true))
+          const ctx = u.ctx
+          ctx.save()
+          ctx.strokeStyle = col.marker
+          ctx.lineWidth = 1
+          ctx.setLineDash([4, 4])
+          ctx.beginPath()
+          ctx.moveTo(x, u.bbox.top)
+          ctx.lineTo(x, u.bbox.top + u.bbox.height)
+          ctx.stroke()
+          ctx.restore()
+        },
+      ],
+    },
+  }
+}
+</script>

+ 190 - 0
src/views/fault-record/common/constants.ts

@@ -0,0 +1,190 @@
+import type {
+  FaultRecordEnv,
+  FaultRecordKind,
+  FaultRecordStatus,
+} from '@/service/faultRecord/interface'
+
+type TagType = 'default' | 'info' | 'success' | 'warning' | 'error'
+
+export const ENV_LABEL: Record<FaultRecordEnv, string> = {
+  prod: '生产服',
+  test: '测试服',
+}
+
+export const KIND_LABEL: Record<FaultRecordKind, string> = {
+  device_info: '设备信息',
+  record_fetch: '录波获取',
+  param_read: '参数读取',
+  param_write: '参数设置',
+}
+
+export const STATUS_META: Record<FaultRecordStatus, { label: string; type: TagType; terminal: boolean }> = {
+  pending: { label: '等待执行', type: 'default', terminal: false },
+  sent: { label: '已发送', type: 'info', terminal: false },
+  accepted: { label: '设备已应答', type: 'info', terminal: false },
+  ready: { label: '数据就绪', type: 'info', terminal: false },
+  uploading: { label: '平台上传中', type: 'info', terminal: false },
+  uploaded: { label: '上传完成', type: 'info', terminal: false },
+  downloaded: { label: '已下载', type: 'info', terminal: false },
+  done: { label: '完成', type: 'success', terminal: true },
+  item_failed: { label: '部分失败', type: 'warning', terminal: true },
+  busy: { label: '设备忙', type: 'warning', terminal: true },
+  rejected: { label: '设备拒绝', type: 'error', terminal: true },
+  timeout: { label: '超时', type: 'error', terminal: true },
+  upload_failed: { label: '上传失败', type: 'error', terminal: true },
+  download_failed: { label: '下载失败', type: 'error', terminal: true },
+  parse_failed: { label: '解析失败', type: 'warning', terminal: true },
+  cancelled: { label: '已取消', type: 'default', terminal: true },
+  interrupted: { label: '已中断', type: 'warning', terminal: true },
+  gateway_locked: { label: '网关占用', type: 'warning', terminal: true },
+  error: { label: '错误', type: 'error', terminal: true },
+}
+
+export function isTerminal(s: FaultRecordStatus) {
+  return STATUS_META[s]?.terminal ?? true
+}
+
+/** 各操作经过的阶段(用于进度条) */
+export const KIND_STAGES: Record<FaultRecordKind, { status: FaultRecordStatus; label: string }[]> = {
+  device_info: [
+    { status: 'sent', label: '已发送' },
+    { status: 'accepted', label: '设备已应答' },
+    { status: 'done', label: '结果已返回' },
+  ],
+  param_read: [
+    { status: 'sent', label: '已发送' },
+    { status: 'accepted', label: '设备已应答' },
+    { status: 'done', label: '结果已返回' },
+  ],
+  param_write: [
+    { status: 'sent', label: '已发送' },
+    { status: 'accepted', label: '设备已应答' },
+    { status: 'done', label: '结果已返回' },
+  ],
+  record_fetch: [
+    { status: 'sent', label: '已发送' },
+    { status: 'accepted', label: '设备已应答' },
+    { status: 'ready', label: '数据就绪' },
+    { status: 'uploading', label: '平台上传中' },
+    { status: 'uploaded', label: '上传完成' },
+    { status: 'downloaded', label: '已下载' },
+    { status: 'done', label: '完成' },
+  ],
+}
+
+export const REALTIME_CODES = [
+  { code: 1, name: '故障总体信息' },
+  { code: 2, name: '录波功能基本信息' },
+  { code: 3, name: '故障录波总目录' },
+]
+
+export interface FunctionCodeDef {
+  id: number
+  name: string
+  /** 所在页面:function=功能操作(1/3/4/7),param=参数设置(5/6),reserved=暂未开放 */
+  page: 'function' | 'param' | 'reserved'
+  danger?: string // 设置时的风险说明
+}
+
+export const FUNCTION_CODES: FunctionCodeDef[] = [
+  { id: 1, name: '清除设备故障波形', page: 'function', danger: '清除设备上的故障波形,不可恢复' },
+  { id: 2, name: '采样通道配置', page: 'reserved' }, // 设备端暂未实现,暂不开放
+  { id: 3, name: '手动触发故障录波', page: 'function', danger: '立即触发一次故障录波' },
+  { id: 4, name: '设备对时', page: 'function', danger: '修改设备时间' },
+  { id: 5, name: '采样频率', page: 'param' },
+  { id: 6, name: '可配采样通道', page: 'param' },
+  { id: 7, name: '录波功能开关', page: 'function' },
+]
+
+export const functionName = (id: number) => FUNCTION_CODES.find((f) => f.id === id)?.name ?? `功能码 ${id}`
+
+/** 可配采样通道地址类型,名称统一首字母大写 */
+export const ADDR_TYPES = [
+  { value: 0, label: 'Empty' },
+  { value: 1, label: 'Uint16' },
+  { value: 2, label: 'Int16' },
+  { value: 3, label: 'Float32' },
+  { value: 4, label: 'Uint32_HWord' },
+  { value: 5, label: 'Uint32_LWord' },
+  { value: 6, label: 'Uint32' },
+]
+
+export const addrTypeName = (t: number) => ADDR_TYPES.find((a) => a.value === t)?.label ?? fmtHex(t, 1)
+
+/** 可配通道说明:默认方式“类型@地址”,代码方式“代码 0xXXXX”(两种方式数据格式一致,代码在前 2 字节) */
+export function configDesc(c: { type?: number; mode?: number; addrType?: number; address: number }) {
+  const { mode, addrType } = cfgParts(c)
+  if (mode === 1) return `代码${fmtHex(c.address >>> 16)}`
+  return `${addrTypeName(addrType)}@${fmtHex(c.address, 4)}`
+}
+
+/**
+ * 从可配通道的类型字段拆出设置方式和地址类型:类型低字节高 4 位为方式,低 4 位为地址类型。
+ * 有 type 时以 type 为准(不依赖后端是否返回 mode/addrType);没有时使用传入的 mode/addrType。
+ */
+export function cfgParts(c: { type?: number; mode?: number; addrType?: number }) {
+  if (c.type != null) {
+    const b = c.type & 0xFF
+    return { mode: b >> 4, addrType: b & 0x0F }
+  }
+  return { mode: c.mode ?? 0, addrType: c.addrType ?? 0 }
+}
+
+/** cmd=3 读取结果(功能码 4/7)的说明 */
+export function describeFunctionValue(item: { id?: number; decoded?: any }): string {
+  const d = item.decoded
+  if (d == null) return '-'
+  if (item.id === 4) return d.deviceTime
+  if (item.id === 7) return d.enabled ? '使能' : '关闭'
+  return JSON.stringify(d)
+}
+
+/** 设置项的风险说明;id=7 只有关闭时才算危险 */
+export function dangerOf(id: number, setVal: string): string | undefined {
+  if (id === 7) return setVal.toUpperCase() === '00' ? '关闭故障录波,设备将不再录波' : undefined
+  return FUNCTION_CODES.find((f) => f.id === id)?.danger
+}
+
+/** ************ 十六进制工具 */
+
+export const hex = (v: number, bytes: number) =>
+  (v >>> 0).toString(16).toUpperCase().padStart(bytes * 2, '0').slice(-bytes * 2)
+
+const HEX_BYTES_RE = /^(?:[0-9a-f]{2})*$/i
+export const isHex = (s: string) => HEX_BYTES_RE.test(s)
+
+export const fmtHex = (v: number, bytes = 2) => `0x${hex(v, bytes)}`
+
+/** 设备时间(年 2B 大端 + 月日时分秒) */
+export function encodeDeviceTime(d: Date) {
+  return (
+    hex(d.getFullYear(), 2) +
+    hex(d.getMonth() + 1, 1) +
+    hex(d.getDate(), 1) +
+    hex(d.getHours(), 1) +
+    hex(d.getMinutes(), 1) +
+    hex(d.getSeconds(), 1)
+  )
+}
+
+/** ************ 其他 */
+
+/** 从请求异常中取出后端返回的 message */
+export function errText(e: unknown): string {
+  const s = e instanceof Error ? e.message : String(e)
+  try {
+    const obj = JSON.parse(s)
+    if (obj && typeof obj.message === 'string') return obj.message
+  } catch {
+    // 非 JSON,原样返回
+  }
+  return s
+}
+
+export function fmtTime(t?: string) {
+  if (!t) return ''
+  const d = new Date(t)
+  if (Number.isNaN(d.getTime())) return t
+  const p = (n: number) => String(n).padStart(2, '0')
+  return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}`
+}

+ 94 - 0
src/views/fault-record/common/useFaultRecord.ts

@@ -0,0 +1,94 @@
+import { computed, onBeforeUnmount, ref } from 'vue'
+import { useRoute } from 'vue-router'
+
+import {
+  cancelFaultRecordOperation,
+  createFaultRecordOperation,
+  getFaultRecordOperation,
+} from '@/service/faultRecord'
+import type {
+  FaultRecordCreateReq,
+  FaultRecordEnv,
+  FaultRecordOpDetail,
+} from '@/service/faultRecord/interface'
+
+import { ENV_LABEL, errText, isTerminal } from './constants'
+
+/** 当前页面所属环境(来自路由 meta.env) */
+export function useFaultRecordEnv() {
+  const route = useRoute()
+  const env = computed<FaultRecordEnv>(() => (route.meta.env === 'test' ? 'test' : 'prod'))
+  const envLabel = computed(() => ENV_LABEL[env.value])
+  return { env, envLabel }
+}
+
+// 轮询间隔:开始后 10 秒内每 2 秒(及时看到设备应答),之后每 5 秒(等待 379/上传/下载,状态变化慢)
+const POLL_FAST_INTERVAL = 2000
+const POLL_FAST_PERIOD = 10_000
+const POLL_SLOW_INTERVAL = 5000
+
+/** 创建操作并轮询状态,直到结束 */
+export function useOperation(onFinished?: (d: FaultRecordOpDetail) => void) {
+  const detail = ref<FaultRecordOpDetail>()
+  const running = ref(false)
+  const error = ref('')
+  let timer: ReturnType<typeof setTimeout> | undefined
+  let watchingId = ''
+  // 每次开始轮询递增;只有最新一轮可以继续排下一次,避免请求在途时重复开始导致多条轮询并行
+  let generation = 0
+  let startedAt = 0
+
+  function stop() {
+    generation++
+    if (timer) clearTimeout(timer)
+    timer = undefined
+    running.value = false
+  }
+
+  async function poll(gen: number) {
+    const id = watchingId
+    try {
+      const d = await getFaultRecordOperation(id)
+      if (gen !== generation) return
+      detail.value = d
+      if (isTerminal(d.status)) {
+        stop()
+        onFinished?.(d)
+        return
+      }
+    } catch (e) {
+      if (gen !== generation) return
+      error.value = errText(e)
+    }
+    const interval = Date.now() - startedAt < POLL_FAST_PERIOD ? POLL_FAST_INTERVAL : POLL_SLOW_INTERVAL
+    timer = setTimeout(poll, interval, gen)
+  }
+
+  /** 查看某个已有操作(如从历史中选择),未结束时继续轮询;对正在轮询的同一操作重复调用不会重新开始 */
+  function watch(id: string) {
+    if (id === watchingId && running.value) return
+    stop()
+    watchingId = id
+    error.value = ''
+    running.value = true
+    startedAt = Date.now()
+    poll(generation)
+  }
+
+  async function start(req: FaultRecordCreateReq) {
+    error.value = ''
+    detail.value = undefined
+    const { id } = await createFaultRecordOperation(req)
+    watch(id)
+    return id
+  }
+
+  async function cancel() {
+    if (!detail.value) return
+    await cancelFaultRecordOperation(detail.value.id)
+  }
+
+  onBeforeUnmount(stop)
+
+  return { detail, running, error, start, watch, cancel, stop }
+}

+ 153 - 0
src/views/fault-record/device-info/index.vue

@@ -0,0 +1,153 @@
+<template>
+  <div class="p-4 flex flex-col gap-4">
+    <NCard size="small">
+      <DeviceSelector v-model:gateway-sn="gatewaySn" v-model:device="device" :env="env" />
+    </NCard>
+
+    <NCard size="small" title="读取设备信息(cmd=0)">
+      <div class="flex flex-wrap gap-4 items-center">
+        <NCheckboxGroup v-model:value="codes">
+          <NCheckbox v-for="c in REALTIME_CODES" :key="c.code" :value="c.code" :label="`${c.code} ${c.name}`" />
+        </NCheckboxGroup>
+        <NButton type="primary" :disabled="!device || codes.length === 0" :loading="op.running.value" @click="read">
+          读取
+        </NButton>
+      </div>
+    </NCard>
+
+    <NCard v-if="op.detail.value" size="small" title="结果">
+      <OperationProgress :detail="op.detail.value" :cancel="op.cancel" />
+      <div class="mt-3 flex flex-col gap-4">
+        <NCard v-for="item in op.detail.value.items" :key="item.code" size="small" embedded>
+          <template #header>
+            {{ item.code }} {{ item.name }}
+            <NTag size="small" :type="item.ok ? 'success' : 'error'" class="ml-2">{{ item.resultDesc }}</NTag>
+          </template>
+          <NAlert v-if="item.decodeError" type="error" class="mb-2">解析失败:{{ item.decodeError }}</NAlert>
+
+          <!-- code=2 基本信息 -->
+          <NDescriptions v-if="item.code === 2 && item.decoded" bordered size="small" :column="4" label-placement="left">
+            <NDescriptionsItem v-for="f in basicFields" :key="f.key" :label="f.label">{{ item.decoded[f.key] }}</NDescriptionsItem>
+          </NDescriptions>
+
+          <!-- code=1 故障总体信息 -->
+          <template v-else-if="item.code === 1 && item.decoded">
+            <NDescriptions bordered size="small" :column="4" label-placement="left" class="mb-3">
+              <NDescriptionsItem label="最大记录数">{{ item.decoded.maxRecords }}</NDescriptionsItem>
+              <NDescriptionsItem label="当前记录数">{{ item.decoded.currentRecords }}</NDescriptionsItem>
+              <NDescriptionsItem label="最早故障(设备时间)">{{ item.decoded.earliestTime }}</NDescriptionsItem>
+              <NDescriptionsItem label="最新故障(设备时间)">{{ item.decoded.latestTime }}</NDescriptionsItem>
+              <NDescriptionsItem label="快照通道">{{ item.decoded.snapshotUsed }}/{{ item.decoded.snapshotTotal }}</NDescriptionsItem>
+              <NDescriptionsItem label="采样通道">{{ item.decoded.sampleUsed }}/{{ item.decoded.sampleTotal }}</NDescriptionsItem>
+              <NDescriptionsItem label="可配快照通道">{{ item.decoded.configSnapUsed }}/{{ item.decoded.configSnapTotal }}</NDescriptionsItem>
+              <NDescriptionsItem label="可配采样通道">{{ item.decoded.configSampleUsed }}/{{ item.decoded.configSampleTotal }}</NDescriptionsItem>
+            </NDescriptions>
+            <div class="text-sm">快照通道含义:{{ hexList(item.decoded.snapshots.map((s: any) => s.meaning)) }}</div>
+            <div class="text-sm">采样通道含义:{{ hexList(item.decoded.sampleMeanings) }}</div>
+            <div class="text-sm">可配采样通道:{{ cfgList(item.decoded.configSamples) }}</div>
+            <div v-if="item.decoded.configSnapTotal" class="text-sm">可配快照通道:{{ cfgList(item.decoded.configSnapshots) }}</div>
+          </template>
+
+          <!-- code=3 目录 -->
+          <NDataTable
+            v-else-if="item.code === 3 && item.decoded"
+            size="small"
+            :columns="dirColumns"
+            :data="item.decoded"
+            :max-height="320"
+          />
+
+          <NCollapse class="mt-2">
+            <NCollapseItem title="原始 hexData" name="raw">
+              <pre class="text-xs m-0 whitespace-pre-wrap break-all">{{ item.raw || '-' }}</pre>
+            </NCollapseItem>
+          </NCollapse>
+        </NCard>
+      </div>
+    </NCard>
+
+    <NCard size="small">
+      <OperationHistory :env="env" kind="device_info" :gateway-sn="gatewaySn" :refresh-key="historyKey" @select="(r) => op.watch(r.id)" />
+    </NCard>
+  </div>
+</template>
+
+<script setup lang="ts">
+import { ref } from 'vue'
+import {
+  NAlert,
+  NButton,
+  NCard,
+  NCheckbox,
+  NCheckboxGroup,
+  NCollapse,
+  NCollapseItem,
+  NDataTable,
+  NDescriptions,
+  NDescriptionsItem,
+  NTag,
+  useMessage,
+} from 'naive-ui'
+import type { DataTableColumns } from 'naive-ui'
+
+import type {
+  FaultRecordConfigurableChannel,
+  FaultRecordDevice,
+  FaultRecordDirectoryEntry,
+} from '@/service/faultRecord/interface'
+
+import DeviceSelector from '../common/DeviceSelector.vue'
+import OperationHistory from '../common/OperationHistory.vue'
+import OperationProgress from '../common/OperationProgress.vue'
+import { REALTIME_CODES, configDesc, errText, fmtHex } from '../common/constants'
+import { useFaultRecordEnv, useOperation } from '../common/useFaultRecord'
+
+const { env } = useFaultRecordEnv()
+const message = useMessage()
+
+const gatewaySn = ref('')
+const device = ref<FaultRecordDevice>()
+const codes = ref<number[]>([1, 2])
+const historyKey = ref(0)
+
+const op = useOperation(() => historyKey.value++)
+
+async function read() {
+  if (!device.value) return
+  try {
+    await op.start({
+      env: env.value,
+      gatewaySn: gatewaySn.value,
+      deviceType: device.value.type,
+      deviceSn: device.value.sn,
+      kind: 'device_info',
+      codeList: [...codes.value].sort(),
+    })
+    historyKey.value++
+  } catch (e) {
+    message.error(`读取失败:${errText(e)}`)
+  }
+}
+
+const basicFields = [
+  { key: 'canProtocolVersion', label: 'CAN 协议版本' },
+  { key: 'storageFormat', label: '存储格式版本' },
+  { key: 'paramListVersion', label: '参数列表版本' },
+  { key: 'maxSampleFrequency', label: '最大采样频率 Hz' },
+  { key: 'sampleFrequency', label: '采样频率 Hz' },
+  { key: 'totalPoints', label: '采样点总数' },
+  { key: 'prePoints', label: '故障前采样点' },
+  { key: 'postPoints', label: '故障后采样点' },
+]
+
+const hexList = (list: number[]) => (list?.length ? list.map((v) => fmtHex(v)).join(' ') : '-')
+const cfgList = (list: FaultRecordConfigurableChannel[]) =>
+  list?.length ? list.map((c) => configDesc(c)).join(',') : '-'
+
+const dirColumns: DataTableColumns<FaultRecordDirectoryEntry> = [
+  { title: '序号', key: 'index', width: 70 },
+  { title: '发生时间(设备时间)', key: 'time', width: 190 },
+  { title: '触发源', key: 'triggerSource', render: (r) => `${fmtHex(r.triggerSource)} ${r.triggerSourceName ?? ''}` },
+  { title: '参数版本', key: 'paramListVersion', width: 110 },
+]
+</script>

+ 241 - 0
src/views/fault-record/function/index.vue

@@ -0,0 +1,241 @@
+<template>
+  <div class="p-4 flex flex-col gap-4">
+    <NCard size="small">
+      <DeviceSelector v-model:gateway-sn="gatewaySn" v-model:device="device" :env="env" />
+    </NCard>
+
+    <div class="gap-4 grid grid-cols-1 lg:grid-cols-2">
+      <!-- 4 设备对时 -->
+      <NCard size="small" title="设备对时 (4)">
+        <div class="flex flex-col gap-3">
+          <div class="text-sm">
+            设备当前时间:<span class="font-mono">{{ currentText(4) }}</span>
+          </div>
+          <div class="flex flex-wrap gap-2 items-center">
+            <NDatePicker v-model:value="timeTs" type="datetime" class="w-60" />
+            <NButton size="small" @click="timeTs = Date.now()">取当前时间</NButton>
+            <NButton type="warning" :disabled="!device || !timeTs || busy" @click="confirmRun(4, timeHex)">对时</NButton>
+          </div>
+          <div class="text-xs text-gray-500">按设备时间写入,不做时区换算。setVal = {{ timeHex || '-' }}</div>
+        </div>
+      </NCard>
+
+      <!-- 7 录波功能开关 -->
+      <NCard size="small" title="录波功能开关 (7)">
+        <div class="flex flex-col gap-3">
+          <div class="text-sm">
+            当前状态:<span class="font-mono">{{ currentText(7) }}</span>
+          </div>
+          <div class="flex gap-2 items-center">
+            <NButton type="primary" :disabled="!device || busy" @click="confirmRun(7, '01')">使能录波</NButton>
+            <NButton type="error" ghost :disabled="!device || busy" @click="confirmRun(7, '00')">关闭录波</NButton>
+          </div>
+        </div>
+      </NCard>
+
+      <!-- 1 / 3 动作 -->
+      <NCard size="small" title="动作">
+        <div class="flex flex-col gap-2">
+          <div class="flex gap-2 items-center">
+            <NButton type="error" ghost :disabled="!device || busy" @click="confirmRun(3, '')">手动触发故障录波 (3)</NButton>
+            <span class="text-xs text-gray-500">立即触发一次录波,可在“录波记录”中查看</span>
+          </div>
+          <div class="flex gap-2 items-center">
+            <NButton type="error" ghost :disabled="!device || busy" @click="confirmRun(1, '')">清除设备故障波形 (1)</NButton>
+            <span class="text-xs text-gray-500">清除设备上的全部故障波形,不可恢复</span>
+          </div>
+        </div>
+      </NCard>
+
+      <!-- 读取 -->
+      <NCard size="small" title="读取当前值(cmd=3)">
+        <div class="flex flex-wrap gap-3 items-center">
+          <NCheckboxGroup v-model:value="readIds">
+            <NCheckbox :value="4" label="4 设备时间" />
+            <NCheckbox :value="7" label="7 录波开关" />
+          </NCheckboxGroup>
+          <NButton :disabled="!device || readIds.length === 0 || busy" :loading="readOp.running.value" @click="read">
+            读取
+          </NButton>
+        </div>
+        <div class="text-xs text-gray-500 mt-2">功能码 1、3 是动作,没有可读取的状态。</div>
+      </NCard>
+    </div>
+
+    <NCard v-if="lastDetail" size="small" title="结果">
+      <OperationProgress :detail="lastDetail" :cancel="lastCancel" />
+      <ResultItems class="mt-3" :detail="lastDetail" :render-decoded="describeFunctionValue" />
+    </NCard>
+
+    <NCard size="small">
+      <OperationHistory
+        :env="env"
+        kind="param_read,param_write"
+        :gateway-sn="gatewaySn"
+        :refresh-key="historyKey"
+        @select="(r) => (r.kind === 'param_read' ? readOp.watch(r.id) : runOp.watch(r.id), (last = r.kind))"
+      />
+    </NCard>
+
+    <!-- 确认弹窗 -->
+    <NModal v-model:show="showConfirm" preset="card" title="确认执行" class="max-w-lg">
+      <div v-if="pendingRun" class="flex flex-col gap-2">
+        <div>
+          环境:<NTag :type="env === 'prod' ? 'error' : 'info'" size="small">{{ ENV_LABEL[env] }}</NTag>
+          网关:{{ gatewaySn }} 设备:{{ device?.type }} {{ device?.sn }}
+        </div>
+        <div :class="danger ? 'text-red-500' : ''">
+          {{ pendingRun.id }} {{ functionName(pendingRun.id) }} {{ pendingRunDesc }}
+          <span v-if="danger">⚠ {{ danger }}</span>
+        </div>
+        <NCheckbox v-if="danger" v-model:checked="dangerConfirmed">我已确认以上操作</NCheckbox>
+      </div>
+      <template #footer>
+        <div class="flex gap-2 justify-end">
+          <NButton @click="showConfirm = false">取消</NButton>
+          <NButton :type="env === 'prod' ? 'error' : 'primary'" :disabled="!!danger && !dangerConfirmed" @click="run">
+            确认执行
+          </NButton>
+        </div>
+      </template>
+    </NModal>
+  </div>
+</template>
+
+<script setup lang="ts">
+import { computed, ref, watch } from 'vue'
+import {
+  NButton,
+  NCard,
+  NCheckbox,
+  NCheckboxGroup,
+  NDatePicker,
+  NModal,
+  NTag,
+  useMessage,
+} from 'naive-ui'
+
+import type {
+  FaultRecordDevice,
+  FaultRecordKind,
+  FaultRecordResultItem,
+} from '@/service/faultRecord/interface'
+
+import DeviceSelector from '../common/DeviceSelector.vue'
+import OperationHistory from '../common/OperationHistory.vue'
+import OperationProgress from '../common/OperationProgress.vue'
+import ResultItems from '../common/ResultItems.vue'
+import {
+  ENV_LABEL,
+  dangerOf,
+  describeFunctionValue,
+  encodeDeviceTime,
+  errText,
+  functionName,
+} from '../common/constants'
+import { useFaultRecordEnv, useOperation } from '../common/useFaultRecord'
+
+const { env } = useFaultRecordEnv()
+const message = useMessage()
+
+const gatewaySn = ref('')
+const device = ref<FaultRecordDevice>()
+const historyKey = ref(0)
+const last = ref<FaultRecordKind>('param_read')
+
+/** ************ 读取(cmd=3,功能码 4/7) **************/
+const readIds = ref<number[]>([4, 7]) // 默认全选
+const current = ref<Record<number, FaultRecordResultItem>>({})
+const currentText = (id: number) => (current.value[id] ? describeFunctionValue(current.value[id]) : '未读取')
+
+const readOp = useOperation((d) => {
+  historyKey.value++
+  const map = { ...current.value }
+  for (const it of d.items) { if (it.ok && it.id != null) map[it.id] = it
+}
+  current.value = map
+  if (d.status !== 'done') message.warning(`读取未完全成功:${d.error ?? d.status}`)
+})
+
+async function read() {
+  if (!device.value) return
+  last.value = 'param_read'
+  try {
+    await readOp.start({
+      env: env.value,
+      gatewaySn: gatewaySn.value,
+      deviceType: device.value.type,
+      deviceSn: device.value.sn,
+      kind: 'param_read',
+      idList: [...readIds.value].sort(),
+    })
+    historyKey.value++
+  } catch (e) {
+    message.error(`读取失败:${errText(e)}`)
+  }
+}
+
+// 切换设备后清空已读到的值
+watch(
+  () => `${device.value?.type}:${device.value?.sn}`,
+  () => {
+    current.value = {}
+  },
+)
+
+/** ************ 执行(cmd=1,功能码 1/3/4/7) **************/
+const timeTs = ref<number | null>(Date.now())
+const timeHex = computed(() => (timeTs.value ? encodeDeviceTime(new Date(timeTs.value)) : ''))
+
+const pendingRun = ref<{ id: number; setVal: string }>()
+const showConfirm = ref(false)
+const dangerConfirmed = ref(false)
+const danger = computed(() => (pendingRun.value ? dangerOf(pendingRun.value.id, pendingRun.value.setVal) : undefined))
+const pendingRunDesc = computed(() => {
+  const p = pendingRun.value
+  if (!p) return ''
+  if (p.id === 4 && timeTs.value) return `→ ${new Date(timeTs.value).toLocaleString()}(${p.setVal})`
+  if (p.id === 7) return p.setVal === '01' ? '→ 使能' : '→ 关闭'
+  return ''
+})
+
+function confirmRun(id: number, setVal: string) {
+  pendingRun.value = { id, setVal }
+  dangerConfirmed.value = false
+  showConfirm.value = true
+}
+
+const runOp = useOperation((d) => {
+  historyKey.value++
+  // 4/7 执行成功后自动复读
+  const id = d.request.paraList?.[0]?.id
+  if (d.status === 'done' && (id === 4 || id === 7) && device.value) {
+    readIds.value = [id]
+    read()
+  }
+})
+
+async function run() {
+  const p = pendingRun.value
+  if (!p || !device.value) return
+  showConfirm.value = false
+  last.value = 'param_write'
+  try {
+    await runOp.start({
+      env: env.value,
+      gatewaySn: gatewaySn.value,
+      deviceType: device.value.type,
+      deviceSn: device.value.sn,
+      kind: 'param_write',
+      paraList: [{ id: p.id, setVal: p.setVal }],
+    })
+    historyKey.value++
+  } catch (e) {
+    message.error(`执行失败:${errText(e)}`)
+  }
+}
+
+const busy = computed(() => readOp.running.value || runOp.running.value)
+const lastDetail = computed(() => (last.value === 'param_read' ? readOp.detail.value : runOp.detail.value))
+const lastCancel = computed(() => (last.value === 'param_read' ? readOp.cancel : runOp.cancel))
+</script>

+ 22 - 0
src/views/fault-record/param-write/ParamSection.vue

@@ -0,0 +1,22 @@
+<template>
+  <div class="flex flex-col gap-1">
+    <div class="flex gap-2 items-center">
+      <span class="font-bold">{{ functionName(id) }} ({{ id }})</span>
+      <span v-if="current" class="text-xs text-gray-500">设备当前值:{{ current }}</span>
+    </div>
+    <div class="flex flex-wrap gap-2 items-center">
+      <slot />
+      <span class="text-xs text-gray-500 font-mono">setVal = {{ preview || '-' }}</span>
+      <NButton size="small" :disabled="disabled || !preview" @click="emit('add')">加入</NButton>
+    </div>
+  </div>
+</template>
+
+<script setup lang="ts">
+import { NButton } from 'naive-ui'
+
+import { functionName } from '../common/constants'
+
+defineProps<{ id: number; current?: string; preview: string; disabled?: boolean }>()
+const emit = defineEmits<{ add: [] }>()
+</script>

+ 471 - 0
src/views/fault-record/param-write/index.vue

@@ -0,0 +1,471 @@
+<template>
+  <div class="p-4 flex flex-col gap-4">
+    <NCard size="small">
+      <DeviceSelector v-model:gateway-sn="gatewaySn" v-model:device="device" :env="env" />
+      <div class="mt-3 flex flex-wrap gap-3 items-center">
+        <span class="text-sm">读取当前值(cmd=0):</span>
+        <NCheckboxGroup v-model:value="readIds">
+          <NCheckbox :value="5" label="5 采样频率" />
+          <NCheckbox :value="6" label="6 可配采样通道" />
+        </NCheckboxGroup>
+        <NButton :disabled="!device || readIds.length === 0 || busy" :loading="readOp.running.value" @click="readCurrent">
+          读取当前值
+        </NButton>
+      </div>
+      <div v-if="readOp.detail.value && readOp.running.value" class="mt-2">
+        <OperationProgress :detail="readOp.detail.value" :cancel="readOp.cancel" />
+      </div>
+    </NCard>
+
+    <div class="gap-4 grid grid-cols-1 xl:grid-cols-[1fr_420px]">
+      <!-- 功能项表单 -->
+      <NCard size="small" title="参数">
+        <div class="flex flex-col gap-5">
+          <!-- 5 采样频率 -->
+          <ParamSection :id="5" :current="deviceFreq != null ? `${deviceFreq} Hz` : ''" :preview="freqHex" :disabled="freq == null" @add="add(5, freqHex)">
+            <NInputNumber v-model:value="freq" :min="1" :max="65535" :show-button="false" class="w-48">
+              <template #suffix>
+                Hz
+              </template>
+            </NInputNumber>
+          </ParamSection>
+
+          <!-- 6 可配采样通道:读到几个通道就并列几行,可逐行加入或一键全部加入 -->
+          <div class="flex flex-col gap-2">
+            <div class="flex flex-wrap gap-2 items-center">
+              <span class="font-bold">{{ functionName(6) }} (6)</span>
+              <span class="text-xs text-gray-500">索引 1 对应设备列表第 1 项</span>
+              <div class="flex-1" />
+              <NButton size="small" @click="addCfgRow">新增一行</NButton>
+              <NButton size="small" :disabled="changedRows.length === 0" @click="addRows(changedRows)">
+                加入已修改({{ changedRows.length }})
+              </NButton>
+              <NButton size="small" type="primary" :disabled="validRows.length === 0" @click="addRows(cfgRows)">
+                全部加入待提交({{ validRows.length }})
+              </NButton>
+            </div>
+            <!-- 每个通道两行:上面一行为输入框(不换行,宽度不足时横向滚动),下面一行为 setVal 预览和设备当前值 -->
+            <div class="overflow-x-auto">
+              <div
+                v-for="(row, i) in cfgRows"
+                :key="row.key"
+                class="mb-1 p-2 rounded min-w-full w-max"
+                :class="rowState(row).changed ? 'bg-blue-500/10' : 'bg-gray-500/5'"
+              >
+                <div class="flex flex-nowrap gap-2 items-center">
+                  <NInputNumber v-model:value="row.index" :min="1" :max="255" :show-button="false" class="shrink-0 w-24">
+                    <template #prefix>
+                      索引
+                    </template>
+                  </NInputNumber>
+                  <NSelect v-model:value="row.mode" :options="modeOptions" class="shrink-0 w-40" />
+                  <NSelect v-if="row.mode === 0" v-model:value="row.addrType" :options="ADDR_TYPES" class="shrink-0 w-36" />
+                  <!-- 代码方式没有地址类型,占位保持各行列对齐 -->
+                  <div v-else class="shrink-0 w-36" />
+                  <NInput
+                    v-model:value="row.value"
+                    class="shrink-0"
+                    style="width: 9rem"
+                    :placeholder="row.mode === 0 ? '地址8位(16进制)' : '代码4位(16进制)'"
+                    :status="row.value && !rowState(row).valid ? 'error' : undefined"
+                  />
+                  <div class="shrink-0 w-16">
+                    <NTag v-if="rowState(row).duplicate" size="small" type="error">索引重复</NTag>
+                    <NTag v-else-if="row.original && rowState(row).changed" size="small" type="info">已修改</NTag>
+                  </div>
+                  <div class="flex-1" />
+                  <NButton size="small" class="shrink-0" :disabled="!rowState(row).valid" @click="addRows([row])">加入</NButton>
+                  <NButton size="small" class="shrink-0" quaternary @click="cfgRows.splice(i, 1)">删除</NButton>
+                </div>
+                <div class="text-xs text-gray-500 mt-1 flex flex-wrap gap-x-6">
+                  <span class="font-mono">setVal = {{ rowHex(row) || '-' }}</span>
+                  <span v-if="row.original">设备当前:{{ describeSetVal(6, row.original) }}</span>
+                </div>
+              </div>
+            </div>
+            <div v-if="cfgRows.length === 0" class="text-xs text-gray-500">无可配采样通道,可点“新增一行”</div>
+          </div>
+
+          <!-- 原始模式 -->
+          <NCollapse>
+            <NCollapseItem title="高级:原始模式(直接输入十六进制 setVal)" name="raw">
+              <div class="flex gap-2 items-center">
+                <NSelect v-model:value="raw.id" :options="codeOptions" class="w-48" />
+                <NInput v-model:value="raw.val" placeholder="setVal 十六进制" style="width: 16rem" :status="raw.val && !isHex(raw.val) ? 'error' : undefined" />
+                <NButton :disabled="!raw.val || !isHex(raw.val)" @click="add(raw.id, raw.val.toUpperCase())">加入</NButton>
+              </div>
+            </NCollapseItem>
+          </NCollapse>
+          <div class="text-xs text-gray-500">
+            功能码 1、3、4、7 在“功能操作”页;功能码 2(采样通道配置)设备端暂未实现,暂不开放。
+          </div>
+        </div>
+      </NCard>
+
+      <!-- 待提交 -->
+      <NCard size="small" title="待提交">
+        <div v-if="pending.length === 0" class="text-sm text-gray-500">从左侧加入要设置的参数</div>
+        <div v-for="(p, i) in pending" :key="p.key" class="py-1 border-b border-gray-500/10 flex gap-2 items-center">
+          <span class="text-gray-500 w-6">{{ p.id }}</span>
+          <div class="flex-1 min-w-0">
+            <div>{{ functionName(p.id) }}</div>
+            <div class="text-xs text-gray-500 break-all">{{ p.setVal }}</div>
+          </div>
+          <NButton size="tiny" quaternary @click="pending.splice(i, 1)">移除</NButton>
+        </div>
+        <div class="mt-3 flex flex-col gap-2">
+          <NCheckbox v-model:checked="autoReread">设置成功后自动复读核对(cmd=0)</NCheckbox>
+          <NButton
+            :type="env === 'prod' ? 'error' : 'primary'"
+            :disabled="!device || pending.length === 0 || busy"
+            :loading="writeOp.running.value"
+            @click="showConfirm = true"
+          >
+            提交设置
+          </NButton>
+        </div>
+      </NCard>
+    </div>
+
+    <NCard v-if="writeOp.detail.value" size="small" title="设置结果">
+      <OperationProgress :detail="writeOp.detail.value" :cancel="writeOp.cancel" />
+      <ResultItems class="mt-3" :detail="writeOp.detail.value" />
+      <div v-if="rereadRows.length" class="mt-3">
+        <div class="font-bold mb-1">复读核对</div>
+        <NDataTable size="small" :columns="rereadColumns" :data="rereadRows" />
+      </div>
+      <div v-else-if="rereadOp.running.value" class="text-sm text-gray-500 mt-3">正在复读…</div>
+    </NCard>
+
+    <NCard size="small">
+      <OperationHistory :env="env" kind="param_write" :gateway-sn="gatewaySn" :refresh-key="historyKey" @select="(r) => writeOp.watch(r.id)" />
+    </NCard>
+
+    <!-- 确认弹窗 -->
+    <NModal v-model:show="showConfirm" preset="card" title="确认设置" class="max-w-xl">
+      <div class="flex flex-col gap-2">
+        <div>
+          环境:<NTag :type="env === 'prod' ? 'error' : 'info'" size="small">{{ ENV_LABEL[env] }}</NTag>
+          网关:{{ gatewaySn }} 设备:{{ device?.type }} {{ device?.sn }}
+        </div>
+        <div v-for="p in pending" :key="p.key" class="flex gap-2">
+          <span class="w-6">{{ p.id }}</span>
+          <span class="w-32">{{ functionName(p.id) }}</span>
+          <span class="flex-1 break-all">{{ describeSetVal(p.id, p.setVal) }}({{ p.setVal }})</span>
+        </div>
+      </div>
+      <template #footer>
+        <div class="flex gap-2 justify-end">
+          <NButton @click="showConfirm = false">取消</NButton>
+          <NButton :type="env === 'prod' ? 'error' : 'primary'" @click="submit">确认提交</NButton>
+        </div>
+      </template>
+    </NModal>
+  </div>
+</template>
+
+<script setup lang="ts">
+import { computed, h, reactive, ref, watch } from 'vue'
+import {
+  NButton,
+  NCard,
+  NCheckbox,
+  NCheckboxGroup,
+  NCollapse,
+  NCollapseItem,
+  NDataTable,
+  NInput,
+  NInputNumber,
+  NModal,
+  NSelect,
+  NTag,
+  useMessage,
+} from 'naive-ui'
+import type { DataTableColumns } from 'naive-ui'
+
+import type {
+  FaultRecordConfigurableChannel,
+  FaultRecordDevice,
+  FaultRecordOpDetail,
+} from '@/service/faultRecord/interface'
+
+import DeviceSelector from '../common/DeviceSelector.vue'
+import OperationHistory from '../common/OperationHistory.vue'
+import OperationProgress from '../common/OperationProgress.vue'
+import ResultItems from '../common/ResultItems.vue'
+import {
+  ADDR_TYPES,
+  ENV_LABEL,
+  FUNCTION_CODES,
+  cfgParts,
+  configDesc,
+  errText,
+  functionName,
+  hex,
+  isHex,
+} from '../common/constants'
+import { useFaultRecordEnv, useOperation } from '../common/useFaultRecord'
+
+import ParamSection from './ParamSection.vue'
+
+const HEX4_RE = /^[0-9a-f]{4}$/i
+const HEX8_RE = /^[0-9a-f]{8}$/i
+
+const { env } = useFaultRecordEnv()
+const message = useMessage()
+
+const gatewaySn = ref('')
+const device = ref<FaultRecordDevice>()
+const historyKey = ref(0)
+
+/** ************ 表单 **************/
+const freq = ref<number | null>(4500)
+const freqHex = computed(() => (freq.value ? hex(freq.value, 2) : ''))
+
+const modeOptions = [
+  { value: 0, label: '默认方式(地址)' },
+  { value: 1, label: '代码方式' },
+]
+/** 可配采样通道编辑行;original 为设备当前值(setVal 格式),用于判断是否修改 */
+interface CfgRow {
+  key: number
+  index: number | null
+  mode: number
+  addrType: number
+  value: string
+  original?: string
+}
+let cfgRowKey = 0
+const cfgRows = ref<CfgRow[]>([{ key: ++cfgRowKey, index: 1, mode: 0, addrType: 1, value: '' }])
+
+function rowHex(row: CfgRow) {
+  const valid = row.index && (row.mode === 0 ? HEX8_RE.test(row.value) : HEX4_RE.test(row.value))
+  if (!valid || !row.index) return ''
+  const typeByte = (row.mode << 4) | (row.mode === 0 ? row.addrType : 0)
+  const data = row.mode === 0 ? row.value : `${row.value}0000`
+  return (hex(row.index, 1) + hex(typeByte, 1) + data).toUpperCase()
+}
+
+function rowState(row: CfgRow) {
+  const h = rowHex(row)
+  const duplicate = row.index != null && cfgRows.value.filter((r) => r.index === row.index).length > 1
+  return { valid: h !== '' && !duplicate, duplicate, changed: h !== '' && h !== row.original }
+}
+
+const validRows = computed(() => cfgRows.value.filter((r) => rowState(r).valid))
+const changedRows = computed(() => validRows.value.filter((r) => rowState(r).changed))
+
+function addCfgRow() {
+  const next = Math.max(0, ...cfgRows.value.map((r) => r.index ?? 0)) + 1
+  cfgRows.value.push({ key: ++cfgRowKey, index: Math.min(next, 255), mode: 0, addrType: 1, value: '' })
+}
+
+/** 把多行加入待提交;无效行跳过并提示 */
+function addRows(rows: CfgRow[]) {
+  let skipped = 0
+  for (const r of rows) {
+    if (rowState(r).valid) add(6, rowHex(r))
+    else skipped++
+  }
+  if (skipped > 0) message.warning(`${skipped} 行内容无效或索引重复,未加入`)
+}
+
+const codeOptions = FUNCTION_CODES.filter((f) => f.page === 'param').map((f) => ({ value: f.id, label: `${f.id} ${f.name}` }))
+const raw = reactive({ id: 5, val: '' })
+
+function describeSetVal(id: number, setVal: string) {
+  if (id === 5) return `${Number.parseInt(setVal, 16)} Hz`
+  if (id === 6 && setVal.length === 12) {
+    const typeByte = Number.parseInt(setVal.slice(2, 4), 16)
+    return `索引 ${Number.parseInt(setVal.slice(0, 2), 16)},${configDesc({
+      mode: typeByte >> 4,
+      addrType: typeByte & 0x0F,
+      address: Number.parseInt(setVal.slice(4), 16),
+    })}`
+  }
+  return ''
+}
+
+/** ************ 待提交 **************/
+interface PendingItem {
+  key: string
+  id: number
+  setVal: string
+}
+const pending = ref<PendingItem[]>([])
+
+/** 同一功能码只保留最后一次;id=6 按通道索引区分 */
+function add(id: number, setVal: string) {
+  const key = id === 6 ? `6-${setVal.slice(0, 2)}` : String(id)
+  const i = pending.value.findIndex((p) => p.key === key)
+  const item = { key, id, setVal }
+  if (i >= 0) pending.value.splice(i, 1, item)
+  else pending.value.push(item)
+}
+
+/** ************ 读取当前值(cmd=0:code=2 采样频率,code=1 可配采样通道) **************/
+const readIds = ref<number[]>([5, 6]) // 默认全选
+const deviceFreq = ref<number>()
+const deviceCfgs = ref<FaultRecordConfigurableChannel[]>()
+
+/** 设备通道 → 编辑行;索引 1 对应列表第 1 项 */
+function rowFromDevice(index: number, c: FaultRecordConfigurableChannel): CfgRow {
+  const { mode, addrType } = cfgParts(c)
+  const row: CfgRow = {
+    key: ++cfgRowKey,
+    index,
+    mode,
+    addrType: mode === 0 ? addrType : 1,
+    value: mode === 1 ? hex(c.address >>> 16, 2) : hex(c.address, 4),
+  }
+  row.original = rowHex(row)
+  return row
+}
+
+/** 从 cmd=0 结果中取出采样频率和可配采样通道 */
+function extractCurrent(d: FaultRecordOpDetail) {
+  const out: { freq?: number; cfgs?: FaultRecordConfigurableChannel[] } = {}
+  for (const it of d.items) {
+    if (!it.ok || !it.decoded) continue
+    if (it.code === 2) out.freq = it.decoded.sampleFrequency
+    if (it.code === 1) out.cfgs = it.decoded.configSamples ?? []
+  }
+  return out
+}
+
+function applyCurrent(d: FaultRecordOpDetail) {
+  const cur = extractCurrent(d)
+  if (cur.freq != null) {
+    deviceFreq.value = cur.freq
+    freq.value = cur.freq
+  }
+  if (cur.cfgs) {
+    deviceCfgs.value = cur.cfgs
+    // 读到几个通道就并列几行;没有通道时保留一行空白可手动填写
+    cfgRows.value = cur.cfgs.length > 0
+      ? cur.cfgs.map((c, i) => rowFromDevice(i + 1, c))
+      : [{ key: ++cfgRowKey, index: 1, mode: 0, addrType: 1, value: '' }]
+  }
+}
+
+const readOp = useOperation((d) => {
+  if (d.status === 'done' || d.status === 'item_failed') applyCurrent(d)
+  else message.warning(`读取当前值未成功:${d.error ?? d.status}`)
+})
+
+function codeListFor(ids: number[]) {
+  const codes = new Set<number>()
+  if (ids.includes(5)) codes.add(2)
+  if (ids.includes(6)) codes.add(1)
+  return [...codes].sort()
+}
+
+async function readCurrent() {
+  if (!device.value) return
+  try {
+    await readOp.start({
+      env: env.value,
+      gatewaySn: gatewaySn.value,
+      deviceType: device.value.type,
+      deviceSn: device.value.sn,
+      kind: 'device_info',
+      codeList: codeListFor(readIds.value),
+    })
+  } catch (e) {
+    message.error(`读取失败:${errText(e)}`)
+  }
+}
+
+// 切换设备后,之前读到的当前值不再适用
+watch(
+  () => `${device.value?.type}:${device.value?.sn}`,
+  () => {
+    deviceFreq.value = undefined
+    deviceCfgs.value = undefined
+    cfgRows.value = [{ key: ++cfgRowKey, index: 1, mode: 0, addrType: 1, value: '' }]
+  },
+)
+
+/** ************ 提交与复读核对 **************/
+const showConfirm = ref(false)
+const autoReread = ref(true)
+const rereadOp = useOperation()
+
+const writeOp = useOperation(async (d) => {
+  historyKey.value++
+  if (d.status !== 'done' || !autoReread.value) return
+  const ids: number[] = (d.request.paraList ?? []).map((p: { id: number }) => p.id)
+  try {
+    await rereadOp.start({
+      env: env.value,
+      gatewaySn: d.gatewaySn,
+      deviceType: d.deviceType,
+      deviceSn: d.deviceSn,
+      kind: 'device_info',
+      codeList: codeListFor(ids),
+    })
+  } catch (e) {
+    message.error(`复读失败:${errText(e)}`)
+  }
+})
+
+interface RereadRow {
+  id: number
+  name: string
+  sent: string
+  actual: string
+  match: boolean
+}
+
+const rereadRows = computed<RereadRow[]>(() => {
+  const d = rereadOp.detail.value
+  const w = writeOp.detail.value
+  if (!d || !w || d.status !== 'done') return []
+  const cur = extractCurrent(d)
+  return (w.request.paraList ?? []).map((p: { id: number; setVal: string }) => {
+    let actual = ''
+    if (p.id === 5 && cur.freq != null) actual = hex(cur.freq, 2)
+    if (p.id === 6 && cur.cfgs) {
+      const index = Number.parseInt(p.setVal.slice(0, 2), 16)
+      const c = cur.cfgs[index - 1]
+      if (c) actual = hex(index, 1) + hex(c.type & 0xFF, 1) + hex(c.address, 4)
+    }
+    return { id: p.id, name: functionName(p.id), sent: p.setVal, actual, match: actual !== '' && actual === p.setVal.toUpperCase() }
+  })
+})
+
+const rereadColumns: DataTableColumns<RereadRow> = [
+  { title: '功能码', key: 'id', width: 80 },
+  { title: '名称', key: 'name', width: 140 },
+  { title: '设置值', key: 'sent', render: (r) => `${r.sent}(${describeSetVal(r.id, r.sent)})` },
+  { title: '复读值', key: 'actual', render: (r) => (r.actual ? `${r.actual}(${describeSetVal(r.id, r.actual)})` : '未读到') },
+  {
+    title: '核对',
+    key: 'match',
+    width: 90,
+    render: (r) => h(NTag, { size: 'small', type: r.match ? 'success' : 'warning' }, { default: () => (r.match ? '一致' : '不一致') }),
+  },
+]
+
+const busy = computed(() => readOp.running.value || writeOp.running.value || rereadOp.running.value)
+
+async function submit() {
+  if (!device.value) return
+  showConfirm.value = false
+  rereadOp.stop()
+  rereadOp.detail.value = undefined
+  try {
+    await writeOp.start({
+      env: env.value,
+      gatewaySn: gatewaySn.value,
+      deviceType: device.value.type,
+      deviceSn: device.value.sn,
+      kind: 'param_write',
+      paraList: pending.value.map((p) => ({ id: p.id, setVal: p.setVal })),
+    })
+    historyKey.value++
+  } catch (e) {
+    message.error(`提交失败:${errText(e)}`)
+  }
+}
+</script>

+ 0 - 3
src/views/fault-record/read/index.vue

@@ -1,3 +0,0 @@
-<template></template>
-
-<script setup lang="ts"></script>

+ 206 - 0
src/views/fault-record/record/index.vue

@@ -0,0 +1,206 @@
+<template>
+  <div class="p-4 flex flex-col gap-4">
+    <NCard size="small">
+      <DeviceSelector v-model:gateway-sn="gatewaySn" v-model:device="device" :env="env" />
+    </NCard>
+
+    <NCard size="small" title="故障录波总目录">
+      <template #header-extra>
+        <div class="flex gap-2">
+          <NButton :disabled="!device || busy" :loading="dirOp.running.value" @click="refreshDirectory">
+            读取目录
+          </NButton>
+          <NButton
+            type="primary"
+            :disabled="!device || busy || checked.length === 0"
+            :loading="fetchOp.running.value"
+            @click="fetchRecords(checked)"
+          >
+            获取所选({{ checked.length }})
+          </NButton>
+        </div>
+      </template>
+      <div class="text-xs text-gray-500 mb-2">
+        时间为设备时间。目录按设备返回顺序显示(设备循环覆盖存储,序号不代表时间先后),可点击“发生时间”排序。勾选多条后点“获取所选”,设备会把所选记录打成一个包上传(最多 {{ MAX_RECORDS }} 条)。
+      </div>
+      <OperationProgress v-if="dirOp.running.value || dirFailed" :detail="dirOp.detail.value" :cancel="dirOp.cancel" class="mb-3" />
+      <NDataTable
+        v-model:checked-row-keys="checked"
+        size="small"
+        :columns="dirColumns"
+        :data="directory"
+        :row-key="(r: FaultRecordDirectoryEntry) => r.index"
+        :max-height="360"
+      />
+    </NCard>
+
+    <NCard v-if="fetchOp.detail.value" size="small" title="当前获取">
+      <OperationProgress
+        :detail="fetchOp.detail.value"
+        :cancel="fetchOp.cancel"
+        :retry-download="() => retryDownload(fetchOp.detail.value!.id)"
+      />
+      <div v-if="fetchOp.detail.value.hasFile" class="mt-2 flex gap-2">
+        <NButton type="primary" @click="openDetail(fetchOp.detail.value.id)">查看录波</NButton>
+        <ExportButtons :id="fetchOp.detail.value.id" />
+      </div>
+    </NCard>
+
+    <NCard size="small">
+      <OperationHistory :env="env" kind="record_fetch" :gateway-sn="gatewaySn" :refresh-key="historyKey" @select="(r) => fetchOp.watch(r.id)">
+        <template #actions="{ row }">
+          <template v-if="row.hasFile">
+            <NButton size="tiny" type="primary" @click="openDetail(row.id)">录波</NButton>
+            <ExportButtons :id="row.id" size="tiny" />
+          </template>
+          <NButton v-if="row.canRetryDownload" size="tiny" type="primary" @click="retryDownloadFromHistory(row.id)">
+            重试下载
+          </NButton>
+        </template>
+      </OperationHistory>
+    </NCard>
+
+    <NDrawer v-model:show="showDetail" width="85%" placement="right">
+      <NDrawerContent title="录波详情" closable>
+        <RecordDetail v-if="detailId" :operation-id="detailId" />
+      </NDrawerContent>
+    </NDrawer>
+  </div>
+</template>
+
+<script setup lang="ts">
+import { computed, h, ref, watch } from 'vue'
+import { NButton, NCard, NDataTable, NDrawer, NDrawerContent, useMessage } from 'naive-ui'
+import type { DataTableColumns } from 'naive-ui'
+
+import { retryFaultRecordDownload } from '@/service/faultRecord'
+import type {
+  FaultRecordDevice,
+  FaultRecordDirectoryEntry,
+} from '@/service/faultRecord/interface'
+
+import DeviceSelector from '../common/DeviceSelector.vue'
+import ExportButtons from '../common/ExportButtons.vue'
+import OperationHistory from '../common/OperationHistory.vue'
+import OperationProgress from '../common/OperationProgress.vue'
+import RecordDetail from '../common/RecordDetail.vue'
+import { errText, fmtHex } from '../common/constants'
+import { useFaultRecordEnv, useOperation } from '../common/useFaultRecord'
+
+const { env } = useFaultRecordEnv()
+const message = useMessage()
+
+const gatewaySn = ref('')
+const device = ref<FaultRecordDevice>()
+const directory = ref<FaultRecordDirectoryEntry[]>([])
+const historyKey = ref(0)
+
+/** ************ 目录 **************/
+const dirOp = useOperation((d) => {
+  const item = d.items.find((i) => i.code === 3)
+  if (d.status === 'done' && item?.decoded) {
+    directory.value = item.decoded as FaultRecordDirectoryEntry[]
+  }
+})
+const dirFailed = computed(() => {
+  const s = dirOp.detail.value?.status
+  return !!s && s !== 'done'
+})
+
+async function refreshDirectory() {
+  if (!device.value) return
+  directory.value = []
+  try {
+    await dirOp.start({
+      env: env.value,
+      gatewaySn: gatewaySn.value,
+      deviceType: device.value.type,
+      deviceSn: device.value.sn,
+      kind: 'device_info',
+      codeList: [3],
+    })
+  } catch (e) {
+    message.error(`读取目录失败:${errText(e)}`)
+  }
+}
+
+/** ************ 获取记录 **************/
+const fetchOp = useOperation((d) => {
+  historyKey.value++
+  if (d.status === 'done') message.success('录波获取完成')
+})
+/** 上传已完成但下载失败:重新下载(后端按 15 秒间隔重试至总超时),并继续查看进度 */
+async function retryDownload(id: string) {
+  await retryFaultRecordDownload(id)
+  historyKey.value++
+  fetchOp.watch(id)
+}
+
+function retryDownloadFromHistory(id: string) {
+  retryDownload(id)
+    .then(() => message.info('已重新开始下载'))
+    .catch((e) => message.error(`重试下载失败:${errText(e)}`))
+}
+const busy = computed(() => dirOp.running.value || fetchOp.running.value)
+
+const MAX_RECORDS = 32
+const checked = ref<number[]>([])
+
+async function fetchRecords(indexes: number[]) {
+  if (!device.value || indexes.length === 0) return
+  if (indexes.length > MAX_RECORDS) {
+    message.warning(`一次最多获取 ${MAX_RECORDS} 条`)
+    return
+  }
+  try {
+    await fetchOp.start({
+      env: env.value,
+      gatewaySn: gatewaySn.value,
+      deviceType: device.value.type,
+      deviceSn: device.value.sn,
+      kind: 'record_fetch',
+      recordIndexes: [...indexes].sort((a, b) => a - b),
+    })
+    historyKey.value++
+  } catch (e) {
+    message.error(`发起获取失败:${errText(e)}`)
+  }
+}
+
+const dirColumns: DataTableColumns<FaultRecordDirectoryEntry> = [
+  { type: 'selection', disabled: () => busy.value },
+  { title: '序号', key: 'index', width: 70 },
+  { title: '发生时间(设备时间)', key: 'time', width: 190, sorter: (a, b) => a.time.localeCompare(b.time) },
+  {
+    title: '触发源',
+    key: 'triggerSource',
+    render: (r) => `${fmtHex(r.triggerSource)} ${r.triggerSourceName ?? ''}`,
+  },
+  { title: '参数版本', key: 'paramListVersion', width: 110 },
+  {
+    title: '',
+    key: 'actions',
+    width: 90,
+    render: (r) =>
+      h(
+        NButton,
+        { size: 'small', type: 'primary', disabled: busy.value, onClick: () => fetchRecords([r.index]) },
+        { default: () => '获取' },
+      ),
+  },
+]
+
+/** ************ 详情 **************/
+const showDetail = ref(false)
+const detailId = ref('')
+function openDetail(id: string) {
+  detailId.value = id
+  showDetail.value = true
+}
+
+// 切换设备时清空目录
+watch(device, () => {
+  directory.value = []
+  checked.value = []
+})
+</script>

+ 0 - 292
src/views/fault-record/write/index.vue

@@ -1,292 +0,0 @@
-<template>
-  <NCard>
-    <NForm ref="formRef" :model="formValue" :rules="formRules">
-      <NFormItem label="aGate SN" path="sn">
-        <NInput v-model:value="formValue.sn" type="text" placeholder="aGate SN" />
-      </NFormItem>
-      <NSpin :show="spinSaveConfig">
-        <NButton type="info" :disabled="spinSaveConfig" @click="clickSaveConfig">
-          <template #icon>
-            <span class="i-material-symbols:save-outline" />
-          </template>
-          写入配置
-        </NButton>
-      </NSpin>
-      <NDivider />
-      <NFormItem content-class="flex flex-col gap-2" path="list" :show-label="false">
-        <div
-          v-for="(list, index) in formValue.list"
-          :key="list.key"
-          class="flex gap-2 w-full items-center"
-        >
-          <NButton
-            circle
-            @click="
-              () => {
-                clickDelSubModel(index)
-              }
-            "
-          >
-            <template #icon>
-              <span class="i-material-symbols:delete-outline" />
-            </template>
-          </NButton>
-          <NCard class="flex-1">
-            <NFormItem label="sub SN" :path="`list.${index}.sn`" :rule="createSubSnRule(index)">
-              <div class="flex gap-2 w-full">
-                <NSelect
-                  v-model:value="formValue.list[index].type"
-                  class="w-[120px]"
-                  :options="optionsSubSnType"
-                />
-                <NInput
-                  class="flex-1"
-                  :value="computedSubSn(index).value"
-                  type="text"
-                  placeholder="aGate SN(DSP) or aPower SN"
-                  :disabled="computedSubSnDisabled(index).value"
-                  @update:value="
-                    (value) => {
-                      updateSubSn(index, value)
-                    }
-                  "
-                />
-              </div>
-            </NFormItem>
-            <NFormItem
-              label="Param List"
-              :path="`list.${index}.paraList`"
-              :rule="createParamListRule()"
-            >
-              <NDynamicInput
-                v-model:value="formValue.list[index].paraList"
-                :on-create="onCreateParam"
-              >
-                <template #create-button-default> 新增参数 </template>
-                <template #default="{ value }">
-                  <div style="display: flex; align-items: center; width: 100%">
-                    <NInputNumber
-                      v-model:value="value.id"
-                      style="margin-right: 12px; width: 160px"
-                      :min="0"
-                      :show-button="false"
-                    />
-                    <NInput v-model:value="value.val" type="text" placeholder="Value" />
-                  </div>
-                </template>
-              </NDynamicInput>
-            </NFormItem>
-          </NCard>
-        </div>
-      </NFormItem>
-      <NButton @click="clickAddSubModel">
-        <template #icon>
-          <span class="i-material-symbols:add-rounded" />
-        </template>
-        新增子模块
-      </NButton>
-    </NForm>
-  </NCard>
-</template>
-
-<script setup lang="ts">
-import {
-  useMessage,
-  NCard,
-  NForm,
-  NFormItem,
-  NInput,
-  NInputNumber,
-  NDivider,
-  NButton,
-  NDynamicInput,
-  NSelect,
-  NSpin,
-} from 'naive-ui'
-import type { FormRules, FormItemRule } from 'naive-ui'
-import { storeToRefs } from 'pinia'
-import { setFaultRecordParam } from '@/service/faultRecord'
-import type { FaultRecordParamInfo } from '@/service/faultRecord/interface'
-import {
-  useFaultRecordStateStore,
-  type FaultRecordWriteParam as FaultRecordParam,
-  type FaultRecordWriteParamItem as FaultRecordParamItem,
-} from '@/stores/modules/views/faultRecordState'
-
-const faultRecordStateStore = useFaultRecordStateStore()
-
-const message = useMessage()
-
-const formRef = useTemplateRef<InstanceType<typeof NForm>>('formRef')
-
-const optionsSubSnType = [
-  { label: 'PE', value: 'pe' },
-  { label: 'aGate', value: 'agate' },
-]
-
-const formValue = storeToRefs(faultRecordStateStore).faultRecordWriteParam
-
-const formRules: FormRules = {
-  sn: {
-    required: true,
-    message: '请输入aGate SN',
-    trigger: ['blur', 'input'],
-  },
-  list: {
-    type: 'array',
-    required: true,
-    message: '至少添加一个模块',
-  },
-}
-
-const createSubSnRule = (index: number): FormItemRule => {
-  return {
-    required: true,
-    trigger: ['blur', 'input'],
-    validator: (rule: FormItemRule, value: string) => {
-      if (formValue.value.list[index].type === 'agate') return true
-      if (value.length > 0) return true
-      return new Error('请输入子设备SN')
-    },
-  }
-}
-
-const HEX_REGEX = /^[0-9a-f]+$/i
-const createParamListRule = (): FormItemRule | FormItemRule[] => {
-  return [
-    {
-      type: 'array',
-      required: true,
-      message: '至少添加一个参数',
-    },
-    {
-      required: true,
-      validator: (rule: FormItemRule, value: FaultRecordParamItem[]) => {
-        for (const item of value) {
-          if (item.val.length === 0) {
-            return new Error('请填写参数值')
-          }
-
-          const isValidHex = HEX_REGEX.test(item.val)
-          if (!isValidHex) {
-            return new Error('请填写正确参数值 (0-9,a-f,A-F)')
-          }
-        }
-        return true
-      },
-    },
-  ]
-}
-
-const clickAddSubModel = () => {
-  formValue.value.list.push({
-    key: crypto.randomUUID(),
-    type: 'pe',
-    sn: '',
-    paraList: [
-      {
-        key: crypto.randomUUID(),
-        id: 6,
-        val: '010300012D46',
-      },
-      {
-        key: crypto.randomUUID(),
-        id: 6,
-        val: '020300012DDE',
-      },
-      {
-        key: crypto.randomUUID(),
-        id: 6,
-        val: '030300012E12',
-      },
-      {
-        key: crypto.randomUUID(),
-        id: 6,
-        val: '040300012E14',
-      },
-    ],
-  })
-}
-
-const clickDelSubModel = (index: number) => {
-  formValue.value.list.splice(index, 1)
-}
-
-const onCreateParam = () => {
-  return {
-    key: crypto.randomUUID(),
-    id: 0,
-    val: '',
-  }
-}
-
-const computedSubSn = (index: number) => {
-  return computed(() => {
-    if (formValue.value.list[index].type === 'agate') {
-      return formValue.value.sn
-    } else {
-      return formValue.value.list[index].sn
-    }
-  })
-}
-
-const computedSubSnDisabled = (index: number) => {
-  return computed(() => {
-    if (formValue.value.list[index].type === 'agate') {
-      return true
-    } else {
-      return false
-    }
-  })
-}
-
-const updateSubSn = (index: number, value: string) => {
-  formValue.value.list[index].sn = value
-}
-
-const transformFaultRecordParam = (para: FaultRecordParam): FaultRecordParamInfo => {
-  return {
-    sn: para.sn,
-    list: para.list.map((s) => {
-      return {
-        type: s.type,
-        sn: s.type === 'agate' ? para.sn : s.sn,
-        paraList: s.paraList.map((p) => {
-          return {
-            id: p.id,
-            val: p.val,
-          }
-        }),
-      }
-    }),
-  }
-}
-
-const spinSaveConfig = ref<boolean>(false)
-
-const clickSaveConfig = () => {
-  formRef.value?.validate((errors) => {
-    if (errors) {
-      console.log(errors)
-      return
-    }
-
-    spinSaveConfig.value = true
-    const res = setFaultRecordParam(transformFaultRecordParam(formValue.value))
-    res
-      .then(() => {
-        message.success('写入成功')
-      })
-      .catch((err: unknown) => {
-        if (err instanceof Error) {
-          message.error(err.message)
-        } else {
-          message.error(String(err))
-        }
-      })
-      .finally(() => {
-        spinSaveConfig.value = false
-      })
-  })
-}
-</script>