Selaa lähdekoodia

feat:全新故障录波功能开发,绕过云平台,直接与设备对接,仅使用云的S3服务器用于文件上传下载(自测OK)

Jerry Liang 2 viikkoa sitten
vanhempi
commit
87182c7b8f
73 muutettua tiedostoa jossa 7644 lisäystä ja 1101 poistoa
  1. 4 4
      deploy/config.env.example
  2. 1 1
      docs/fault-record/README.md
  3. 56 42
      docs/fault-record/implementation-status.md
  4. 5 1
      docs/fault-record/protocol-observations.md
  5. 7 6
      docs/fault-record/protocol-specification.md
  6. 11 4
      docs/fault-record/requirements.md
  7. 27 14
      docs/fault-record/technical-design.md
  8. 52 68
      docs/fault-record/ui-design.md
  9. 4 4
      service/.env
  10. 23 0
      service/cache/redisx/lockx.go
  11. 4 4
      service/config/config.go
  12. 106 8
      service/controllers/faultRecordControllers.go
  13. 1 1
      service/domain/esMqttDomain.go
  14. 157 0
      service/domain/faultRecordMqttDomain.go
  15. 2 2
      service/domain/fwhProDomain.go
  16. 93 11
      service/dto/faultRecordDTO.go
  17. 138 0
      service/function/FaultRecordFile/archive.go
  18. 138 0
      service/function/FaultRecordFile/csv.go
  19. 170 0
      service/function/FaultRecordFile/file_test.go
  20. 155 0
      service/function/FaultRecordFile/reader.go
  21. 91 0
      service/function/FaultRecordFile/realtime.go
  22. 143 0
      service/function/FaultRecordFile/record.go
  23. 160 0
      service/function/FaultRecordProtocol/dict.go
  24. 373 0
      service/function/FaultRecordProtocol/protocol.go
  25. 193 0
      service/function/FaultRecordProtocol/protocol_test.go
  26. 148 0
      service/function/FaultRecordProtocol/setval.go
  27. 4 1
      service/main.go
  28. 27 0
      service/repositories/api/fwh_pro/fwh_faultRecordUpload.go
  29. 27 0
      service/repositories/api/fwh_test/fwh_faultRecordUpload.go
  30. 39 0
      service/repositories/minio/faultRecordRepo.go
  31. 182 0
      service/repositories/mongo/faultRecordRepo.go
  32. 3 0
      service/repositories/mongo/init.go
  33. 263 162
      service/repositories/mqtt/fwh_mqtt/fwh_mqtt_client.go
  34. 182 9
      service/repositories/mqtt/fwh_mqtt/fwh_mqtt_client_test.go
  35. 0 49
      service/repositories/mqtt/fwh_mqtt/fwh_mqtt_debug.go
  36. 52 0
      service/repositories/mqtt/fwh_mqtt/fwh_mqtt_envelope.go
  37. 0 210
      service/repositories/mqtt/fwh_mqtt/fwh_mqtt_fault_record.go
  38. 0 73
      service/repositories/mqtt/fwh_mqtt/test/main.go
  39. 44 0
      service/repositories/mqtt/fwh_xlog/fwh_xlog.go
  40. 5 6
      service/repositories/repositories.go
  41. 17 4
      service/routes/faultRecordRoutes.go
  42. 2 2
      service/services/aiChatServices.go
  43. 410 5
      service/services/faultRecordServices.go
  44. 9 9
      service/services/feedbackServices.go
  45. 174 0
      service/task/queue/faultRecordQueue/deps.go
  46. 624 0
      service/task/queue/faultRecordQueue/executor.go
  47. 637 0
      service/task/queue/faultRecordQueue/executor_test.go
  48. 117 0
      service/task/queue/faultRecordQueue/queue.go
  49. 2 0
      service/task/queue/taskQueue.go
  50. 19 2
      service/utils/mqttClient/mqttClient.go
  51. 120 22
      src/router/record.ts
  52. 58 9
      src/service/faultRecord/index.ts
  53. 189 10
      src/service/faultRecord/interface.ts
  54. 0 2
      src/stores/initStores.ts
  55. 0 59
      src/stores/modules/views/faultRecordState.ts
  56. 10 2
      src/stores/user.ts
  57. 1 0
      src/typings/vue-router.d.ts
  58. 140 0
      src/views/fault-record/common/DeviceSelector.vue
  59. 36 0
      src/views/fault-record/common/ExportButtons.vue
  60. 131 0
      src/views/fault-record/common/OperationHistory.vue
  61. 232 0
      src/views/fault-record/common/OperationProgress.vue
  62. 99 0
      src/views/fault-record/common/RecordDetail.vue
  63. 73 0
      src/views/fault-record/common/ResultItems.vue
  64. 133 0
      src/views/fault-record/common/WaveformChart.vue
  65. 190 0
      src/views/fault-record/common/constants.ts
  66. 94 0
      src/views/fault-record/common/useFaultRecord.ts
  67. 153 0
      src/views/fault-record/device-info/index.vue
  68. 241 0
      src/views/fault-record/function/index.vue
  69. 22 0
      src/views/fault-record/param-write/ParamSection.vue
  70. 415 0
      src/views/fault-record/param-write/index.vue
  71. 0 3
      src/views/fault-record/read/index.vue
  72. 206 0
      src/views/fault-record/record/index.vue
  73. 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 相关项

+ 1 - 1
docs/fault-record/README.md

@@ -1,6 +1,6 @@
 # 故障录波文档导航
 
-本目录整理通过 MQTT 直接获取和设置子设备故障录波数据的需求与设计,覆盖生产服和测试服。当前状态:需求和整体设计已确认,待编码;双环境 MQTT 客户端已完成,其余未实现(见 [implementation-status.md](implementation-status.md))。文档中的待实测内容不代表设备已支持。
+本目录整理通过 MQTT 直接获取和设置子设备故障录波数据的需求与设计,覆盖生产服和测试服。当前状态:首版已编码并通过离线测试,待真实设备实测(见 [implementation-status.md](implementation-status.md))。文档中的待实测内容不代表设备已支持。
 
 建议阅读顺序:requirements → technical-design → ui-design;查协议细节再看 protocol-specification 和 FaultFilter 文档。
 

+ 56 - 42
docs/fault-record/implementation-status.md

@@ -1,47 +1,61 @@
 # 故障录波当前实现状态
 
-> 核对日期:2026-10-16。本文只描述当前仓库事实,不代表设计目标已经落地。目标见 [requirements.md](requirements.md),设计见 [technical-design.md](technical-design.md) 和 [ui-design.md](ui-design.md)。
+> 核对日期:2026-10-16。按 [technical-design.md](technical-design.md) 和 [ui-design.md](ui-design.md) 完成首版编码;**尚未用真实设备测试**,依赖实测的前提见 [technical-design.md](technical-design.md#9-待实测前提)。
 
-## 1. 能力现状
+## 1. 代码位置
 
-| 能力 | 当前状态 | 代码依据 |
+| 层 | 位置 | 内容 |
 | --- | --- | --- |
-| 生产服/测试服 MQTT | **已完成**(2026-10-16):按环境各建一个客户端实例;单个环境初始化失败只记日志、不影响服务启动;原复制包 `fwh_mqtt_test` 已删除 | `service/repositories/mqtt/fwh_mqtt/fwh_mqtt_client.go` 的 `Env`、`ParseEnv`、`InitFwhMqttClient(env, cfg)`;`service/main.go`;单元测试 `fwh_mqtt_client_test.go`。未用真实设备验证 |
-| MQTT 收发方式 | 只有一问一答(`sendMessageWait`)和只发送(`sendMessageOnce`);没有持续监听;空闲 5 分钟断开 | `fwh_mqtt_client.go` |
-| 参数设置 | 已有 MQTT 入口,等待 `378` 后返回;接口支持可选 `env`;没有观察 `379` 逐项结果;前端无环境选择、无确认弹窗 | `fwh_mqtt_fault_record.go`、`/faultRecord/paramSetting`、`src/views/fault-record/write/index.vue` |
-| 设备信息(`cmd=0`) | 未实现 | — |
-| 录波记录获取(`cmd=2` + `607~610` + 下载) | 未实现;`607~610` 只有日志类型常量 | `service/domain/fwhProMqttDomain.go` |
-| 参数读取(`cmd=3`) | 未实现 | `src/views/fault-record/read/index.vue` 为空页面 |
-| 取消(`cmd=4`) | 未实现 | — |
-| 协议编解码、文件解析、CSV | 未实现;`service/function/FaultRecordFile` 为空目录 | — |
-| 异步任务与恢复 | 未实现;可复用 asynq 任务系统 | `service/task`,参考 `task/queue/dataDownloadQueue` |
-| 操作记录与文件存储 | 未实现;可复用 MongoDB、MinIO | `service/repositories/mongo`、`service/repositories/minio`、`service/utils/minio` |
-| 站点设备列表 | 生产服、测试服的站点信息接口均已存在,未用于本功能 | `fwh_pro.QuerySiteInfoList`、`fwh_test.QuerySiteInfoList`(`fhpSn` 为 aPower SN 列表) |
-| 前端菜单 | 现为“故障录波配置 → 读取配置/写入配置”,未按新结构调整 | `src/router/record.ts` |
-
-## 2. 现有代码入口
-
-| 位置 | 当前作用 |
-| --- | --- |
-| `service/utils/mqttClient/mqttClient.go` | paho 封装:连接、订阅(断线重连后自动恢复)、发布 |
-| `service/repositories/mqtt/fwh_mqtt/fwh_mqtt_client.go` | 按环境的 FWH MQTT 客户端;发送经 worker 串行处理 |
-| `service/repositories/mqtt/fwh_mqtt/fwh_mqtt_fault_record.go` | `377/378` 常量、参数设置结构、`FaultRecordParamSetting`、调试用 `ChargeChannel` |
-| `service/repositories/mqtt/fwh_mqtt/fwh_mqtt_debug.go` | `EnableXlog`,固定使用生产服 |
-| `service/repositories/mqtt/fwh_mqtt/test/main.go` | 测试服手动调试脚本 |
-| `service/services/faultRecordServices.go`、`controllers/faultRecordControllers.go`、`routes/faultRecordRoutes.go` | 参数设置 HTTP 路径 `/faultRecord/paramSetting` |
-| `src/views/fault-record/write/index.vue`、`read/index.vue` | 旧参数设置页面、读取页占位 |
-| `service/repositories/api/fwh_pro/fwh_faultRecord.go` | 现有 FWH API 故障录波流程(DataDownload 使用,不改动) |
-
-## 3. 与设计的差距
-
-按 [technical-design.md](technical-design.md) 的分层,尚需实现:
-
-1. **L2 协议编解码与解析器**:`377` 构造;`378/379/607~610` 解析(含 `608` 重复键);解压;`hexData`、`failuredata`、`waveformdata` 解析;CSV 生成。
-2. **L1 客户端改造**:增加 `Subscribe`(多订阅者、引用计数、订阅期间不空闲断开)和通用信封构造;把业务代码移出客户端包。
-3. **下载**:按环境域名、无 token 下载 hes-upload 文件。
-4. **存储**:Mongo `fault_record_operations`、MinIO `fault-record/<env>/<gatewaySn>/<fileName>`。
-5. **L3 任务执行器**:`faultRecordQueue`、网关锁、状态机、取消、重启恢复。
-6. **L4/L5 接口**:创建操作、查询状态与历史、取消、导出、设备列表。
-7. **前端**:新菜单结构和四个功能页、录波详情、参数设置新页面。
-
-依赖实测才能最终确认的部分见 [technical-design.md](technical-design.md#9-待实测前提)。
+| 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 秒重试至总超时)。

+ 5 - 1
docs/fault-record/protocol-observations.md

@@ -104,7 +104,7 @@ CSV 倒序展示;下表按 `logTime` 正序排列。`logTime` 比同一行的
 | 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` 键重复) |
+| 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` |
@@ -194,6 +194,10 @@ CSV 倒序展示;下表按 `logTime` 正序排列。`logTime` 比同一行的
 
 各通道的物理量和换算系数需要 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`(波形数据),两者对应同一请求。

+ 7 - 6
docs/fault-record/protocol-specification.md

@@ -39,8 +39,8 @@
 | `378` | `fwh/c2s/mng/{gatewaySN}` | 现有实现的等待 topic;原 CSV 未包含 topic  |
 | `379` | 待补充                      | 不能假定与`378` 相同                     |
 | `380` | 待补充                      | 不能假定与`377` 相同;样本中由 FWH 平台发出 |
-| `607`/`610` | 待补充(推测 s2c 方向) | FWH 平台发给设备,本工具只监听 |
-| `608`/`609` | 待补充(推测 c2s 方向) | 设备发给 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-测试服与生产服)。
 
@@ -390,9 +390,10 @@ FaultFilter 文档定义,无样本,经 `377` 转发待实测。附加字段
 | `url` | `607` | 字符串 | 设备 HTTP 上传地址;样本为测试服 `http://` 地址,不用于推导下载地址 |
 | `logTime` | `607` | 字符串 | 日期 `YYYY-MM-DD`,用途待确认 |
 | `filename` | `608` | 字符串 | 回显 `607.fileName`;注意键名小写 |
-| `result` | `608` | 整数 | **同一对象内出现两次**:第一次 `0`,第二次回显 `faultRecordID`。Go `encoding/json` 只保留最后一个值;如需第一次的值,必须按原始字节逐键读取 |
+| `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` | 字符串 | 上传文件名 = `379.filename + ".tar.bz2"` |
+| `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 秒。
@@ -463,8 +464,8 @@ https://<环境域名>/hes-upload/v1/file/download/logger/<equipNo>/<609.fileNam
 | B06  | ~~`379(cmd=1)` 结构~~    | 已明确为`setResultList[{id, resultOne}]`(7.3)                                  | 成功、部分失败实测报文                   |
 | B07  | `379/380` topic 与关联   | 服务端可用`snno`,具体规则和旧样本不一致                                         | 明确赋值规则及设备接受新应答的实测       |
 | B08  | 上传应答语义和可靠性       | 成功判据、应答时限、重发和断线恢复未知;本工具不回复 `380`,此项影响降低           | 设备上传处理规范及异常时间线             |
-| B09  | `607~610` topic 与订阅权限 | 本工具需监听平台下发的 `607/610`,s2c 方向能否订阅未知                            | 两个环境下订阅 s2c 的实测               |
-| B10  | `608/609/610` 值域       | `608.result` 键重复;`609` 失败值未知;`610.result=1` 含义未知                    | 上传失败样本、平台说明                   |
+| 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 中这两段是填充,填充方式不限定,解析时跳过 | —                                        |

+ 11 - 4
docs/fault-record/requirements.md

@@ -38,7 +38,7 @@
 - 获取到的压缩包应解析出故障数据和波形数据供查看:基本信息、快照通道、多通道波形(原始值),见 [ui-design.md](ui-design.md#31-录波详情抽屉或独立标签页)。
 - 导出:用户可任选原始压缩包或 CSV。
 
-**已确定**:每个设备每次获取 1 条记录;导出原始压缩包或 CSV;导出不设权限;暂不清理。按 FaultFilter 文档,记录列表来自 `cmd=0, code=3` 目录,记录序号即目录项索引;每条记录生成 `failuredata` 与 `waveformdata` 两个文件,设备每次获取记录前会清空历史文件。文件由平台上传后,按 `https://<环境域名>/hes-upload/v1/file/download/logger/<equipNo>/<文件名>.tar.bz2` 下载。功能统一称为“故障录波”。
+**已确定**:可一次获取多条记录,设备打成一个包上传(2026-10-19 用户确认,最多 32 条);导出原始压缩包或 CSV;导出不设权限;暂不清理。按 FaultFilter 文档,记录列表来自 `cmd=0, code=3` 目录,记录序号即目录项索引;每条记录生成 `failuredata` 与 `waveformdata` 两个文件,设备每次获取记录前会清空历史文件。文件由平台上传后,按 `https://<环境域名>/hes-upload/v1/file/download/logger/<equipNo>/<文件名>.tar.bz2` 下载。功能统一称为“故障录波”。
 
 ### FR-03 查询录波参数
 
@@ -63,9 +63,9 @@
 
 ### FR-06 操作方式与记录(用户确认,2026-10-16)
 
-- **页面**:主菜单“故障录波”,子菜单分生产服和测试服,再按功能分页面(录波记录、设备信息、参数读取、参数设置)。页面设计见 [ui-design.md](ui-design.md),用户根据实现效果再提意见。
+- **页面**:主菜单“故障录波”,子菜单分生产服和测试服,再按功能分页面(录波记录、设备信息、功能操作、参数设置)。页面设计见 [ui-design.md](ui-design.md),用户根据实现效果再提意见。
 - **异步**:所有操作异步执行,前端查询进度;服务重启后能自动恢复未完成的操作。
-- **单条获取**:每个设备每次只获取 1 条录波记录。
+- **多条获取**:一次可获取多条录波记录,设备打成一个包(2026-10-19 用户确认,替换原“每次 1 条”)。
 - **设备忙**:本工具内对同一网关串行;设备因其他来源(如 FWH 平台网页)回复 `busy` 时,明确提示用户,不自动重试。
 - **取消**:进行中的操作可取消,必要时向设备发送 `cmd=4`(退出)结束当前处理。
 - **设备列表**:pe 子设备 SN 即 aPower SN,从站点信息中获取;生产服和测试服调用各自的接口。
@@ -76,6 +76,13 @@
 - **与 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,以及设备返回的原始结果,便于排查请求与上传不一致的问题。
@@ -91,6 +98,6 @@
 | 记录查询 | 查询条件(目录来源与 `index` 映射已明确) |
 | 文件导出 | 平台对本工具直连发出的 `cmd=2` 同样自动上传(下载认证、压缩包结构已确认) |
 | 双环境 | 测试服与生产服各完成一次完整的 `cmd=2` 获取与下载 |
-| 参数读取 | `cmd=3` 实报样本 |
+| 功能操作读取 | 4、7 的 `cmd=3` 实报样本 |
 | 参数设置 | `cmd=1` 完整成功及失败样本、最终成功条件 |
 | 通用收发 | `379/380` topic、消息关联、超时与重发行为 |

+ 27 - 14
docs/fault-record/technical-design.md

@@ -1,6 +1,6 @@
 # 故障录波 MQTT 整体设计
 
-> 状态:设计草案,2026-09-30;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)。
+> 状态:设计草案,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. 设计目标与原则
 
@@ -19,7 +19,7 @@ L5 Route / Controller        HTTP 入参校验、出参转换
 L4 Service                   对外用例:创建操作、查询状态与结果、取消、导出、查询设备列表
 L3 Task Executor (asynq)     操作执行器:按状态机推进、持久化每一步、重启恢复
 L2 Protocol / Parser         纯函数,不访问网络、数据库和文件系统
-     ├─ protocol:构造 377 dataArea,解析 378/379/607~610(含 608 重复键)
+     ├─ 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 封装
@@ -32,12 +32,12 @@ L0 utils/mqttClient          paho 封装
 | --- | --- | --- | --- |
 | L0 | `utils/mqttClient` | 连接、订阅、发布、重连 | FWH 报文格式 |
 | L1 | `repositories/mqtt/fwh_mqtt` | 按环境的连接实例;FWH 信封编解码;三种收发方法;订阅期间保持连接 | 任何 cmdType/cmd 的业务含义 |
-| L2 | `function/FaultRecordFile`(已有空目录,按现有 `function/SnapFile` 的模式放解析器);协议编解码放同级 `function/FaultRecordProtocol` | 字节和 JSON 的结构化转换 | IO |
+| 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` | 数据读写 | 业务 |
-| 下载 | `repositories/api/fwh_upload`(或在 `fwh_pro`/`fwh_test` 中各加一个无 token 下载函数) | 按环境域名下载 | 解压、解析 |
+| 下载 | `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。
 
@@ -51,7 +51,7 @@ L2 是纯函数,用仓库中的样本文件([CloudLog_2026-10-14_104644.csv]
 | `Publish(ctx, topic, msg) error` | 只请求 | 发送后立即返回 |
 | `Subscribe(topic, handler) (cancel func(), error)` | 持续监听 | 同一 topic 可有多个订阅者,各自收到每条消息;`cancel` 只移除自己 |
 
-- `handler` 收到 `{Topic, Envelope, Raw []byte, ReceivedAt}`。`Envelope` 为解析后的外层,`Raw` 为原始字节,供上层处理 `608` 的重复 `result` 键。
+- `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 只保留通用部分。
@@ -70,13 +70,23 @@ L2 是纯函数,用仓库中的样本文件([CloudLog_2026-10-14_104644.csv]
 | 操作 | `cmd` | 输入 | 结果 |
 | --- | --- | --- | --- |
 | `device_info` | 0 | `codeList ⊆ {1,2,3}` | `hexList` 原文 + 解析结果 |
-| `record_fetch` | 2 | **一个**设备、**一条**记录序号 | 原始文件(MinIO)+ 解析结果 |
-| `param_read` | 3 | `idList` | `idInfo` 原文 + 解析结果 |
+| `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)。如不支持:参数读取页、参数设置的“读取当前值”和“自动复读”下线,取消只停止本工具的等待。`record_fetch` 每次只取一条记录,避免多组 `4/5` 无法区分。
+`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 异步执行
 
@@ -87,13 +97,15 @@ asynq worker ──▶ Task Executor ──▶ 执行并逐步写回 Mongo
 ```
 
 - 队列:新建 `faultRecordQueue`,在 `task/queue/taskQueue.go` 注册,沿用 `dataDownloadQueue` 的写法(`asynq.TaskID` 保证幂等,`Retention` 保留记录)。
-- **关闭 asynq 自动重试**(`MaxRetry(0)`),重复执行由执行器根据 Mongo 状态自行判断,避免重复发送 `377(cmd=2)` 清空设备文件。
-- 任务超时:`record_fetch` 10 分钟,其他 3 分钟(暂定,实测后调整)。
+- **不依赖 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>`。
+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 解析,推进状态,并把原始报文追加到记录:
 
@@ -101,7 +113,7 @@ asynq worker ──▶ Task Executor ──▶ 执行并逐步写回 Mongo
 pending → sent → accepted(378) → ready(379) → uploading(607) → uploaded(609) → downloaded → done
 ```
 
-5. 收到 `609.result=0` 后,按环境域名下载,存入 MinIO,解析,写 `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`。
@@ -112,6 +124,7 @@ pending → sent → accepted(378) → ready(379) → uploading(607) → uploade
 
 - 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`)
@@ -156,7 +169,7 @@ asynq 会把服务重启时未完成的任务重新投递。执行器先读 Mong
 
 | 项目 | 生产服 | 测试服 |
 | --- | --- | --- |
-| MQTT broker/证书 | `FWH_MQTT_BROKER`、`FWH_MQTT_CA/CERT/KEY` | `FWH_TEST_MQTT_BROKER`、`FWH_TEST_MQTT_CA/CERT/KEY` |
+| 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` |
 | 下载凭据 | 不需要 | 不需要 |
@@ -216,7 +229,7 @@ CSV 现场生成,不缓存。
 ## 9. 待实测前提
 
 1. 本工具直接发出 `377(cmd=2)` 后,平台是否同样自动发 `607` 并上传。这是“只监听”方案成立的前提。
-2. 两个环境下能否订阅 s2c 方向(`607/380/610`);不能时只能依赖 `609` 推进状态。
+2. ~~订阅 s2c 方向~~:测试服已实测可订阅(收到 380);607~610 的 topic 已确认(debug),待实测确认能收到。
 3. `cmd=0/1/3` 的 `380` 是否也由平台回复。
 4. `pe` 场景:外层 `equipNo` 与 `info[].sn` 的关系、下载路径中的 `<equipNo>`。
 5. `cmd=1/3` 经外层的实际报文和失败样本;`busy` 经外层如何体现。

+ 52 - 68
docs/fault-record/ui-design.md

@@ -1,6 +1,6 @@
 # 故障录波前端页面设计
 
-> 状态:初稿,2026-10-16。用户将根据实现效果再提意见。功能目标见 [requirements.md](requirements.md),后端设计见 [technical-design.md](technical-design.md)。组件库沿用 naive-ui,波形图沿用 uPlot。
+> 状态:初稿,2026-10-16;同日按用户意见调整:“参数读取”页改为“功能操作”,参数设置只保留 5、6 并改用 cmd=0 读取。用户将根据实现效果再提意见。功能目标见 [requirements.md](requirements.md),后端设计见 [technical-design.md](technical-design.md)。组件库沿用 naive-ui,波形图沿用 uPlot。
 
 ## 1. 菜单结构
 
@@ -9,14 +9,25 @@
 ├── 生产服
 │   ├── 录波记录
 │   ├── 设备信息
-│   ├── 参数读取
-│   └── 参数设置
+│   ├── 功能操作(功能码 1、3、4、7)
+│   └── 参数设置(功能码 5、6)
 └── 测试服
     └── (同上)
 ```
 
-- 路由形如 `fault-record/prod/record`、`fault-record/test/param-write`。两套环境使用同一组页面组件,环境从路由参数获取。
+- 路由形如 `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. 公共组件
@@ -71,7 +82,8 @@
 
 - 目录按设备返回的顺序显示,不按序号推断时间先后;允许按时间列排序。
 - 触发源显示代码和名称(名称来自协议文档 F15 表,表外代码只显示代码)。
-- 每次只获取一条记录;获取进行中时,其他“获取”按钮禁用。
+- 可勾选多条记录后点“获取所选”,设备把所选记录打成一个包上传(最多 32 条,用户确认设备支持,2026-10-19);每行的“获取”按钮仍可单条获取。获取进行中时,其他获取按钮禁用。
+- 操作进度的“原始报文与下载记录”中,除 MQTT 报文外,还记录每次 HTTP 文件下载(路径、结果、大小或错误、耗时),并记录 609 之后短时间内到达的报文(如 610)。
 - 导出下拉:原始压缩包、CSV。
 
 ### 3.1 录波详情(抽屉或独立标签页)
@@ -102,83 +114,55 @@
 - 勾选 `code`:故障总体信息(1)、录波功能基本信息(2)、录波总目录(3),点击“读取”。
 - 每个 code 一张卡片,按协议字段列出原始值;可展开原始 `hexData`。
 
-## 5. 参数读取
+## 5. 功能操作
 
 ```text
 ┌ 设备选择条 ───────────────────────────────────────────────────────┐
-├ 功能码:☑2 采样通道配置 ☑4 设备时间 ☑5 采样频率 ☑6 可配采样通道 ☑7 录波开关  [读取] ┤
-├ 结果 ─────────────────────────────────────────────────────────────┤
-│ 功能码 │ 名称         │ 原始十六进制 │ 解读           │ 结果      │
-│ 5      │ 采样频率     │ 1194         │ 4500 Hz        │ ok        │
-│ 7      │ 录波开关     │ 01           │ 使能           │ ok        │
-│ 2      │ 采样通道配置 │ …            │ 16 个通道(展开)│ ok        │
+├ 设备对时 (4) ──────────────────┬ 录波功能开关 (7) ────────────────┤
+│ 设备当前时间:2026-10-16 12:00:00│ 当前状态:使能                    │
+│ [日期时间] [取当前时间] [对时]   │ [使能录波] [关闭录波]             │
+├ 动作 ───────────────────────────┼ 读取当前值(cmd=3)────────────────┤
+│ [手动触发故障录波 (3)]          │ ☑4 设备时间 ☑7 录波开关  [读取]    │
+│ [清除设备故障波形 (1)]          │                                   │
+├ 结果(进度 + 逐项结果)────────────────────────────────────────────┤
+├ 操作历史(读取与执行合并显示)──────────────────────────────────────┤
 └──────────────────────────────────────────────────────────────────┘
 ```
 
-- 功能码 1、3 是动作,没有可读的状态,不在读取列表中。
-- “解读”只针对协议已定义结构的功能码(2、4、5、6、7),解不出来时只显示原始值。
-- 结果可一键“带入参数设置”。
+- 每个按钮单独执行一个功能码,执行前弹窗确认;1、3、4 以及关闭录波(7=00)标红,需勾选“我已确认”。
+- 4、7 执行成功后自动复读该项。
+- 4、7 的读取依赖 `cmd=3`(待实测,B01);不支持时只能执行,不能读取。
 
 ## 6. 参数设置
 
-相对现有页面的改进:
-
-| 现有页面 | 新设计 |
-| --- | --- |
-| 手填功能码数字和十六进制值 | 按功能码提供结构化表单,自动生成 `setVal`,并实时显示十六进制预览 |
-| 不知道设备当前值 | 可先“读取当前值”预填表单,修改项高亮 |
-| 无提交前确认 | 统一确认弹窗,危险操作醒目标注 |
-| 只显示成功/失败 | 显示进度和逐项 `resultOne`,可选设置后自动复读 |
-| 一次可填多个子设备 | 一次操作只针对一个设备(与后端操作模型一致) |
+只包含功能码 5(采样频率)和 6(可配采样通道);功能码 2 设备端暂未实现,暂不开放。
 
 ```text
-┌ 设备选择条 ─────────────────────────────── [读取当前值] ──────────┐
-├ 功能项 ─────────────────────────────────┬ 待提交 ─────────────────┤
-│ ▸ 采样频率 (5)     [ 4500 ] Hz           │ 5  采样频率   1194   [×] │
-│ ▸ 录波开关 (7)     (●使能 ○关闭)         │ 7  录波开关   01     [×] │
-│ ▸ 设备对时 (4)     [日期时间] [取当前时间] │ 6  可配通道1  010100014990│
-│ ▸ 采样通道配置 (2) 通道列表编辑器          │                          │
-│ ▸ 可配采样通道 (6) 行编辑器:              │ ☐ 设置成功后自动复读      │
-│     索引 │ 方式 │ 地址类型 │ 地址/代码    │                          │
-│ ▸ 危险操作                               │        [提交设置]         │
-│     [清除故障波形 (1)] [手动触发录波 (3)]  │                          │
-├ 高级:原始模式(直接输入功能码 + 十六进制,保留旧页面的能力) ────────┤
-├ 进度与逐项结果 ───────────────────────────────────────────────────────┤
+┌ 设备选择条 ───────────────────────────────────────────────────────┐
+│ 读取当前值(cmd=0):☑5 采样频率 ☑6 可配采样通道  [读取当前值]       │
+├ 参数 ────────────────────────────────────┬ 待提交 ─────────────────┤
+│ 采样频率 (5)   [ 4500 ] Hz   设备当前值 4500 Hz │ 5  采样频率   1194  [×] │
+│ 可配采样通道 (6)                            │ 6  可配采样通道 01…  [×] │
+│   索引 │ 方式 │ 地址类型 │ 地址/代码          │ ☑ 设置成功后自动复读     │
+│   设备当前:索引1 Uint16@0x14990 … (点击带入)│        [提交设置]        │
+│ 高级:原始模式(仅 5、6)                     │                          │
+├ 设置结果 + 复读核对 ─────────────────────────────────────────────────┤
 ├ 操作历史 ────────────────────────────────────────────────────────────┤
 └──────────────────────────────────────────────────────────────────┘
 ```
 
-各功能码的表单:
-
-| id | 表单 | 校验 |
-| --- | --- | --- |
-| 2 | 通道含义代码列表(每项 4 位十六进制),可增删、拖动排序;通道数自动计算 | 每项 2 字节 |
-| 4 | 日期时间选择(按设备时间)+“取当前时间” | 年 0~65535 |
-| 5 | 数字输入,单位 Hz | 1~65535 |
-| 6 | 每行:索引号、设置方式(默认/代码)、地址类型(默认方式时)、地址或代码(十六进制);可多行,每行生成一项 `id=6` | 按方式校验长度 |
-| 7 | 单选:使能/关闭 | — |
-| 1、3 | 按钮加入待提交列表 | 无参数 |
-
-### 6.1 提交确认弹窗
-
-```text
-┌ 确认设置 ────────────────────────────────────┐
-│ 环境:[生产服]   网关:xxx   设备:pe 10050015…│
-│                                              │
-│ 5  采样频率      4500 Hz  (1194)              │
-│ 7  录波开关      关闭     (00)   ⚠ 将停止录波   │
-│ 1  清除故障波形           ⚠ 不可恢复           │
-│                                              │
-│ ☐ 我已确认以上操作                            │
-│                        [取消]  [确认提交]      │
-└──────────────────────────────────────────────┘
-```
-
-- 所有设置都弹窗确认;包含 id=1(清除波形)、id=3(手动触发)、id=4(对时)、id=7 关闭录波时,对应行标红并加说明,且必须勾选“我已确认”才能提交。
-- 生产服环境的确认按钮使用警示色。
+- **读取当前值**改用 `cmd=0`:5 取 code=2 的采样频率,6 取 code=1 的可配采样通道;可勾选读取哪些项,默认全选;切换设备时清空。
+- **可配采样通道**:索引 1 对应 code=1 可配采样通道列表的第 1 项。默认方式和代码方式按同一格式解析(类型低字节的高 4 位为设置方式、低 4 位为地址类型,数据 4 字节;代码方式取前 2 字节为代码),后续出问题再改。
+- **数据类型选项**统一首字母大写:Empty、Uint16、Int16、Float32、Uint32_HWord、Uint32_LWord、Uint32。
+- **提交**前弹窗确认;生产服的确认按钮用警示色。
+- **复读核对**同样用 `cmd=0`:5 比对采样频率;6 按索引取列表对应项,拼成 setVal 后比对。
 
-### 6.2 结果
+相对旧页面的改进:
 
-- 进度同 2.2,到 `379` 为止。
-- 逐项结果表:功能码、名称、设置值、`resultOne`(非 ok 时显示错误码含义,来自协议文档 10.5/10.6)。
-- 勾选“自动复读”时,成功后对涉及的功能码自动发起一次参数读取,并与设置值比对。
+| 旧页面 | 新设计 |
+| --- | --- |
+| 手填功能码数字和十六进制值 | 结构化表单,自动生成 `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 - 11
service/dto/faultRecordDTO.go

@@ -1,18 +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 {
-	Env  string                `json:"env"` // 'prod'(默认)or 'test'
-	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)
+}

+ 4 - 1
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"
@@ -101,7 +102,9 @@ func main() {
 	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()))
 	}
-	// repositories.FwhMqttFaultRecordChargeChannel(context.Background(), repositories.FwhMqttEnvProd, "10060005A03X25160385")
+
+	// 恢复服务重启前未完成的故障录波操作(依赖任务系统和 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)
 }

+ 263 - 162
service/repositories/mqtt/fwh_mqtt/fwh_mqtt_client.go

@@ -20,55 +20,92 @@ 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
+}
+
+// Message 收到的一条消息
+type Message struct {
+	Topic      string
+	Envelope   FwhMqttMsg // 外层;DataArea 为通用解码结果
+	Raw        []byte     // 原始字节,业务层按需重新解析(如重复键)
+	ReceivedAt time.Time
 }
 
-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
+// Handler 订阅回调;每个订阅者在独立 goroutine 中顺序调用,可以阻塞,不影响其他订阅者
+type Handler func(Message)
+
+type subscriber struct {
+	id      uint64
+	handler Handler
+	ch      chan Message
+	done    chan struct{}
 }
 
-type MqttMsg struct {
-	Topic      string // 发送 Topic
-	Msg        FwhMqttMsg
-	ResTopic   *string // 响应 Topic
-	ResCmdType *uint
-	ResultCh   chan FwhMqttResult
-	Ctx        context.Context
+type topicEntry struct {
+	subs  map[uint64]*subscriber
+	ready chan struct{} // broker 订阅完成后关闭
+	err   error
 }
 
-type ResultChan struct {
-	ch  chan FwhMqttMsg
-	ctx context.Context
+// 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
 }
 
 // Env FWH 服务器环境,每个环境使用独立的 broker、证书和客户端实例
 type Env string
 
 const (
-	EnvProd Env = "prod" // 正式服
+	EnvProd Env = "prod" // 生产服
 	EnvTest Env = "test" // 测试服
 )
 
-// ParseEnv 解析请求中的环境参数,空值默认为正式服
+// ParseEnv 解析请求中的环境参数,空值默认为生产服
 func ParseEnv(s string) (Env, error) {
 	switch Env(s) {
 	case "", EnvProd:
@@ -121,28 +158,22 @@ func InitFwhMqttClient(env Env, cfg *config.FwhMqttConfig) error {
 		return err
 	}
 
-	c := &FwhMqttClient{
-		Client: client,
-		TaskCh: make(chan MqttMsg, 1000),
-	}
+	return registerClient(env, client)
+}
 
+func registerClient(env Env, tr transport) error {
 	fwhMqttClientsMu.Lock()
+	defer fwhMqttClientsMu.Unlock()
 	if _, exists := fwhMqttClients[env]; exists {
-		fwhMqttClientsMu.Unlock()
 		return fmt.Errorf("FWH MQTT client already initialized (env=%s)", env)
 	}
-	fwhMqttClients[env] = c
-	fwhMqttClientsMu.Unlock()
-
+	fwhMqttClients[env] = newFwhMqttClient(env, tr)
 	zap.L().Info("Init FWH MQTT client success.", zap.String("env", string(env)))
-
-	go c.fwhMqttWorker()
-
 	return nil
 }
 
-// getFwhMqttClient 获取指定环境的 MQTT 客户端
-func getFwhMqttClient(env Env) (*FwhMqttClient, error) {
+// GetClient 获取指定环境的 MQTT 客户端
+func GetClient(env Env) (*FwhMqttClient, error) {
 	fwhMqttClientsMu.RLock()
 	defer fwhMqttClientsMu.RUnlock()
 
@@ -153,20 +184,15 @@ func getFwhMqttClient(env Env) (*FwhMqttClient, error) {
 	return c, nil
 }
 
-func (c *FwhMqttClient) fwhMqttWorker() {
+/************** 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:
@@ -174,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")
+	}
 }

+ 182 - 9
service/repositories/mqtt/fwh_mqtt/fwh_mqtt_client_test.go

@@ -2,11 +2,90 @@ package fwh_mqtt
 
 import (
 	"context"
+	"encoding/json"
 	"fwh-toolbox-server/config"
-	"fwh-toolbox-server/dto"
+	"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
@@ -27,18 +106,112 @@ func TestParseEnv(t *testing.T) {
 	}
 }
 
-func TestUninitializedEnvReturnsError(t *testing.T) {
-	// 未初始化的环境不应 panic,应返回错误
-	if err := FaultRecordParamSetting(context.Background(), EnvTest, dto.FaultRecordParamInfo{Sn: "X"}); err == nil {
-		t.Fatal("expected error for uninitialized env")
-	}
-}
-
 func TestInitWithoutBrokerFails(t *testing.T) {
 	if err := InitFwhMqttClient(EnvTest, &config.FwhMqttConfig{}); err == nil {
 		t.Fatal("expected error when broker not configured")
 	}
-	if _, err := getFwhMqttClient(EnvTest); err == nil {
+	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 - 49
service/repositories/mqtt/fwh_mqtt/fwh_mqtt_debug.go

@@ -1,49 +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
-	}
-
-	// Xlog 只在正式服使用
-	client, err := getFwhMqttClient(EnvProd)
-	if err != nil {
-		return "", err
-	}
-
-	res, err := client.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 - 210
service/repositories/mqtt/fwh_mqtt/fwh_mqtt_fault_record.go

@@ -1,210 +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, env Env, sn string) error {
-	client, err := getFwhMqttClient(env)
-	if err != nil {
-		return err
-	}
-
-	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 = client.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, env Env, data dto.FaultRecordParamInfo) error {
-	client, err := getFwhMqttClient(env)
-	if err != nil {
-		return err
-	}
-
-	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 := client.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 - 73
service/repositories/mqtt/fwh_mqtt/test/main.go

@@ -1,73 +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"
-	"time"
-)
-
-func main() {
-	err := logger.Init("dev", "debug")
-	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",
-	}
-	if err := fwh_mqtt.InitFwhMqttClient(fwh_mqtt.EnvTest, &mqttConfig); err != nil {
-		panic(err)
-	}
-
-	// fwh_mqtt.ChargeChannel(context.Background(), fwh_mqtt.EnvTest, "EamonAux")
-	// time.Sleep(2 * time.Minute)
-	// fwh_mqtt.ChargeChannel(context.Background(), fwh_mqtt.EnvTest, "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.FaultRecordParamSetting(context.Background(), fwh_mqtt.EnvTest, data)
-	if err != nil {
-		fmt.Println(err.Error())
-	}
-	time.Sleep(2 * time.Minute)
-	err = fwh_mqtt.FaultRecordParamSetting(context.Background(), fwh_mqtt.EnvTest, 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
+}

+ 5 - 6
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"
@@ -508,16 +509,14 @@ var DeleteAiChatSession = mongo.DeleteAiChatSession
 type FwhMqttEnv = fwh_mqtt.Env
 
 const (
-	FwhMqttEnvProd = fwh_mqtt.EnvProd // 正式服
+	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

+ 410 - 5
service/services/faultRecordServices.go

@@ -2,15 +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 {
-	env, err := repositories.FwhMqttParseEnv(para.Env)
+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 repositories.FwhMqttFaultRecordParamSetting(ctx, env, para)
+	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>

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

@@ -0,0 +1,415 @@
+<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 可配采样通道 -->
+          <ParamSection :id="6" :preview="cfgHex" :disabled="!cfgValid" @add="add(6, cfgHex)">
+            <div class="flex flex-wrap gap-2 items-center">
+              <NInputNumber v-model:value="cfg.index" :min="1" :max="255" :show-button="false" class="w-24">
+                <template #prefix>
+                  索引
+                </template>
+              </NInputNumber>
+              <NSelect v-model:value="cfg.mode" :options="modeOptions" class="w-40" />
+              <NSelect v-if="cfg.mode === 0" v-model:value="cfg.addrType" :options="ADDR_TYPES" class="w-36" />
+              <NInput
+                v-model:value="cfg.value"
+                class="w-40"
+                :placeholder="cfg.mode === 0 ? '地址 8 位 hex' : '代码 4 位 hex'"
+                :status="cfg.value && !cfgValid ? 'error' : undefined"
+              />
+            </div>
+          </ParamSection>
+          <div v-if="deviceCfgs" class="text-xs">
+            <div class="text-gray-500 mb-1">设备当前可配采样通道(点击带入表单):</div>
+            <div class="flex flex-wrap gap-2">
+              <NTag
+                v-for="(c, i) in deviceCfgs"
+                :key="i"
+                size="small"
+                :type="cfg.index === i + 1 ? 'info' : 'default'"
+                class="cursor-pointer"
+                @click="loadCfg(i + 1)"
+              >
+                索引 {{ i + 1 }}:{{ configDesc(c) }}
+              </NTag>
+              <span v-if="deviceCfgs.length === 0" class="text-gray-500">无</span>
+            </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 十六进制" class="w-64" :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: '代码方式' },
+]
+const cfg = reactive({ index: 1 as number | null, mode: 0, addrType: 1, value: '' })
+const cfgValid = computed(() => {
+  if (!cfg.index) return false
+  return cfg.mode === 0 ? HEX8_RE.test(cfg.value) : HEX4_RE.test(cfg.value)
+})
+const cfgHex = computed(() => {
+  if (!cfgValid.value || !cfg.index) return ''
+  const typeByte = (cfg.mode << 4) | (cfg.mode === 0 ? cfg.addrType : 0)
+  const data = cfg.mode === 0 ? cfg.value : `${cfg.value}0000`
+  return (hex(cfg.index, 1) + hex(typeByte, 1) + data).toUpperCase()
+})
+
+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 loadCfg(index: number) {
+  const c = deviceCfgs.value?.[index - 1]
+  cfg.index = index
+  if (!c) return
+  const { mode, addrType } = cfgParts(c)
+  cfg.mode = mode
+  if (mode === 1) {
+    cfg.value = hex(c.address >>> 16, 2)
+  } else {
+    cfg.addrType = addrType
+    cfg.value = hex(c.address, 4)
+  }
+}
+
+/** 从 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
+    loadCfg(cfg.index ?? 1)
+  }
+}
+
+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
+  },
+)
+
+/** ************ 提交与复读核对 **************/
+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>