Pārlūkot izejas kodu

feat:重构批量数据下载模块(DataDownload)

Jerry Liang 1 mēnesi atpakaļ
vecāks
revīzija
2c25429226
70 mainītis faili ar 9428 papildinājumiem un 120 dzēšanām
  1. 8 0
      docs/data-download/AGENTS.md
  2. 45 0
      docs/data-download/README.md
  3. 116 19
      docs/data-download/archive/data-download-integrated-design-2026-09-21.md
  4. 102 0
      docs/data-download/data-download-code-review-2026-09-19.md
  5. 110 0
      docs/data-download/implementation-status.md
  6. 165 0
      docs/data-download/requirements.md
  7. 246 0
      docs/data-download/technical-design.md
  8. 86 0
      docs/issues/data-download-oom-2026-09-22.md
  9. 300 0
      docs/issues/data-download-open-issues.md
  10. 117 8
      service/cache/redisx/lockx.go
  11. 8 1
      service/cmd/mcp/main.go
  12. 1 1
      service/cmd/mcp/test/runner/cases.go
  13. 7 1
      service/cmd/mcp/tools/data/fwh_api.go
  14. 95 0
      service/cmd/mcp/tools/service/data_download.go
  15. 1 0
      service/cmd/mcp/tools/service/register.go
  16. 685 0
      service/controllers/dataDownloadControllers.go
  17. 155 0
      service/database/data_download_schema.go
  18. 20 6
      service/database/mongo.go
  19. 21 0
      service/database/mongo_test.go
  20. 214 0
      service/domain/dataDownloadDomain.go
  21. 72 10
      service/jobs/callFwhFile.go
  22. 88 0
      service/jobs/emsLogCallSlot.go
  23. 7 1
      service/main.go
  24. 67 4
      service/repositories/api/fwh_pro/fwhHttpClient.go
  25. 64 12
      service/repositories/api/fwh_pro/fwh_emsLog.go
  26. 27 0
      service/repositories/api/fwh_pro/fwh_emsLog_test.go
  27. 43 1
      service/repositories/api/fwh_pro/fwh_eventAlarmLog.go
  28. 96 23
      service/repositories/api/fwh_pro/fwh_faultRecord.go
  29. 41 1
      service/repositories/api/fwh_pro/fwh_mqttLog.go
  30. 5 0
      service/repositories/api/fwh_pro/fwh_powerFlow.go
  31. 5 3
      service/repositories/api/fwh_pro/fwh_update.go
  32. 5 3
      service/repositories/api/fwh_test/fwh_update.go
  33. 21 0
      service/repositories/init.go
  34. 51 0
      service/repositories/minio/dataDownloadRepo.go
  35. 658 0
      service/repositories/mongo/dataDownloadRepo.go
  36. 8 0
      service/repositories/mongo/init.go
  37. 294 0
      service/repositories/pgsql/dataDownloadRepo.go
  38. 39 0
      service/repositories/pgsql/dataDownloadRepo_test.go
  39. 14 0
      service/repositories/pgsql/init.go
  40. 30 21
      service/repositories/repositories.go
  41. 34 0
      service/routes/dataDownloadRoutes.go
  42. 1 0
      service/routes/routes.go
  43. 11 0
      service/scheduler/scheduler.go
  44. 66 0
      service/services/dataDownloadAdminService.go
  45. 112 0
      service/services/dataDownloadArchiveService.go
  46. 270 0
      service/services/dataDownloadBinaryService.go
  47. 68 0
      service/services/dataDownloadCleanupService.go
  48. 404 0
      service/services/dataDownloadFaultRecordCall.go
  49. 137 0
      service/services/dataDownloadFaultRecordCall_test.go
  50. 293 0
      service/services/dataDownloadFaultRecordService.go
  51. 195 0
      service/services/dataDownloadFileRequestService.go
  52. 245 0
      service/services/dataDownloadPackageService.go
  53. 302 0
      service/services/dataDownloadRenderService.go
  54. 128 0
      service/services/dataDownloadRequestKey.go
  55. 432 0
      service/services/dataDownloadService.go
  56. 271 0
      service/services/dataDownloadService_test.go
  57. 478 0
      service/task/queue/dataDownloadQueue/dataDownloadQueue.go
  58. 20 0
      service/task/queue/dataDownloadQueue/dataDownloadQueue_test.go
  59. 1 1
      service/task/queue/fwhQueue/fwhTaskBatchDownload.go
  60. 1 1
      service/task/queue/fwhQueueOld/fwhTaskBatchDownload.go
  61. 2 0
      service/task/queue/taskQueue.go
  62. 22 2
      service/utils/httpClient/httpClient.go
  63. 30 0
      service/utils/httpClient/httpClient_context_test.go
  64. 30 0
      src/router/record.ts
  65. 127 0
      src/service/dataDownload/index.ts
  66. 146 0
      src/service/dataDownload/interface.ts
  67. 11 1
      src/stores/user.ts
  68. 168 0
      src/views/data-download/documents.vue
  69. 1247 0
      src/views/data-download/index.vue
  70. 39 0
      src/views/data-download/packages.vue

+ 8 - 0
docs/data-download/AGENTS.md

@@ -0,0 +1,8 @@
+# DataDownload documentation instructions
+
+- Start with `README.md` and load only the documents required for the current task.
+- Do not read `archive/` by default. Use it only when canonical documents do not contain the needed historical rationale.
+- Treat `requirements.md` as product intent, `technical-design.md` as target implementation constraints, and `implementation-status.md` plus source code as current implementation facts.
+- Read only the relevant DD entry in `../issues/data-download-open-issues.md` when working on a numbered issue.
+- Update canonical documents instead of appending new decisions to the archived integrated design.
+- When code and requirements differ, record the discrepancy in the issue ledger rather than silently rewriting requirements to match code.

+ 45 - 0
docs/data-download/README.md

@@ -0,0 +1,45 @@
+# DataDownload 文档导航
+
+本目录采用“按关注点加载”的文档结构。AI 和开发者不应默认加载全部文档;先读本页,再根据任务选择最少的必要文件。
+
+目录内的 [`AGENTS.md`](AGENTS.md) 将同样的加载规则声明为 AI 工作约束,避免后续代理又默认读取完整归档。
+
+## 文档职责
+
+| 文档 | 内容 | 何时读取 |
+|---|---|---|
+| [requirements.md](requirements.md) | 产品需求、权限、状态语义、文件行为和验收标准 | 讨论业务行为、判断方案是否符合需求时 |
+| [technical-design.md](technical-design.md) | 领域模型、RequestKey、Frozen、存储、并发、FaultRecord 和接口设计 | 修改后端模型、服务或 API 时 |
+| [implementation-status.md](implementation-status.md) | 当前已实现能力、代码入口、已知差异和验证方式 | 开始编码、评审当前代码或交接时 |
+| [../issues/data-download-open-issues.md](../issues/data-download-open-issues.md) | DD 编号问题的状态、优先级和关闭记录 | 处理具体缺陷或规划下一项工作时 |
+| [archive/data-download-integrated-design-2026-09-21.md](archive/data-download-integrated-design-2026-09-21.md) | 拆分前的完整集成设计归档 | 仅在新文档缺少历史依据或需要追溯旧章节时 |
+| [data-download-code-review-2026-09-19.md](data-download-code-review-2026-09-19.md) | 2026-09-19 的一次性代码审查快照 | 追溯当时问题时 |
+
+## 推荐加载组合
+
+- 回答“系统应该怎样工作”:本页 + `requirements.md`。
+- 实施某项功能:本页 + `requirements.md` 的相关章节 + `technical-design.md` 的相关章节 + `implementation-status.md`。
+- 处理 `DD-xxx`:本页 + 问题台账中的该项;再按其链接读取相关需求或设计章节。
+- 核对当前代码:本页 + `implementation-status.md`,然后直接检查源码。
+- 追溯旧决策:最后才读取 `archive/`,不要把归档当作当前实现事实。
+
+## 事实优先级
+
+发生冲突时按以下顺序判断:
+
+1. 当前代码和自动化测试代表实际行为;
+2. `implementation-status.md` 解释当前实现边界;
+3. `requirements.md` 定义目标产品行为;
+4. `technical-design.md` 定义实现约束;
+5. 问题台账记录尚未解决的偏差;
+6. `archive/` 仅用于历史追溯。
+
+代码与需求不一致时,不应静默修改需求以迁就代码;应在问题台账新增或更新 DD 项,并明确选择修代码还是变更需求。
+
+## 维护规则
+
+- 产品行为变更只更新 `requirements.md`,并在技术设计中补充实现影响。
+- 数据模型、并发或存储方案变更只更新 `technical-design.md`,避免把代码细节写入需求文档。
+- 功能落地后更新 `implementation-status.md` 和对应 DD 状态。
+- 动态缺陷、风险和待办只放问题台账,不在多个文档复制一份。
+- 归档文件保持只读;新决策不得继续追加到归档。

Failā izmaiņas netiks attēlotas, jo tās ir par lielu
+ 116 - 19
docs/data-download/archive/data-download-integrated-design-2026-09-21.md


+ 102 - 0
docs/data-download/data-download-code-review-2026-09-19.md

@@ -0,0 +1,102 @@
+# DataDownload 代码审核复核与修复报告
+
+> 初审日期:2026-09-19  
+> 复核及修复日期:2026-09-19  
+> 审核范围:当前 git 未提交改动,并结合当时的集成设计归档进行权限、FileRequest、生命周期和存储约束复核。当前文档入口见 [README.md](README.md)。
+> 关联台账:[data-download-open-issues.md](../issues/data-download-open-issues.md)
+
+## 1. 结论
+
+原报告 11 条结论中:CR-02、CR-05、CR-06、CR-08 是实际问题;CR-04、CR-07、CR-10、CR-11 是低风险一致性或完整性问题;CR-01、CR-03、CR-09 不成立或没有足够证据。
+
+本轮已经修复确认成立且适合在当前范围内处理的问题,并补充修复复核时发现的任务计数错误。CR-08 的 HTTP 下载内存放大已经解决,但 ZIP 构建、Renderer 首次生成和上游二进制读取仍属于 DD-010,不能标记为整体解决。
+
+## 2. 逐条复核与处理状态
+
+| ID | 原严重度 | 复核结论 | 当前状态 | 处理结果 |
+|---|---|---|---|---|
+| CR-01 | 高 | 不成立 | rejected | 当前没有同 key 嵌套加锁;binary 与 rendered 分支互斥。不可重入只属于未来维护约束。 |
+| CR-02 | 高 | 成立 | resolved | 上游请求改用独立、自动续租的 FileRequest lease;content 锁仅覆盖提交阶段;等待者通过 Asynq 延迟重试。 |
+| CR-03 | 高 | 证据不足 | rejected | 短数组补 `nil` 与旧 PowerFlow 导出语义一致;新增测试固定该兼容行为。 |
+| CR-04 | 中 | 低风险类型不一致 | resolved | `run_status` 改用 `*int32`,超出 PostgreSQL integer 范围时显式报错。 |
+| CR-05 | 中 | 成立 | resolved | item 写入改为 `(taskId,itemKey)` upsert 并回填已有 `_id`;稳定队列 TaskID 冲突视为已调度。 |
+| CR-06 | 中 | 成立 | resolved | 清理时统一使用 `ExpiresAt + 30天`。 |
+| CR-07 | 中 | 字段未维护属实,影响被夸大 | resolved | Recount 统计空 TaskDocument,前端任务进度显示空文档数。 |
+| CR-08 | 中 | 成立 | partially resolved | Package、二进制文档、缓存 RenderArtifact 已流式响应;生成和打包阶段的内存问题继续由 DD-010 跟踪。 |
+| CR-09 | 中 | 不成立 | rejected | 设计明确允许其他登录用户查看、预览和下载;所有 `/api` 路由已有统一认证。 |
+| CR-10 | 低 | 防御性建议 | resolved | 删除弱类型 `mapToBSON`,改用显式 `bson.M`。 |
+| CR-11 | 低 | 文档建议 | resolved | 在 PowerFlow 持久化模型中明确本地 wall-clock 与 UTC instant 的语义。 |
+
+## 3. 关键修复说明
+
+### 3.1 FileRequest 协调与 EMS 长轮询
+
+- 新增 `data-download:request:{DocumentID}` 请求租约,RequestID 使用唯一 lease token。
+- 租约每 40 秒续期,TTL 为 2 分钟;续租失败后提交校验会拒绝继续发布。
+- Mutable 等待者携带 `AwaitRequestID`,按 15、30、60 秒退避重新入队;等待期间 item 保持 `pending`,不占用 worker。
+- 同轮 Mutable 请求完成后,等待者只复用对应 RequestID 的结果;之后创建的新任务仍会刷新 Mutable 文档。
+- Dynamic/UpdateLog 等待前一轮完成后重新竞争租约,不复用上一轮结果。
+- 上游 EMS 轮询和文件下载不再持有 content 锁;content 锁只在上传及元数据切换阶段持有。
+- 管理员删除文档时同时竞争 request lease,活跃请求期间禁止物理删除。
+
+这解决了 CR-02,并落实了设计文档 3.2、8.2、9.1、9.2 的主要行为。普通 content/Renderer 锁的完整 fencing/CAS 仍由 DD-001、DD-007 跟踪。
+
+### 3.2 规划幂等
+
+`InsertDataDownloadItems` 已由 `InsertMany` 改为无序 BulkWrite upsert。重复规划会取得已存在 item 的 `_id`,不会因唯一索引冲突失败;重复提交稳定 Asynq TaskID 时也不会把 item 错误标记为失败。该修复同时覆盖 plan handler 和 `site_validation` 重规划路径。
+
+### 3.3 任务计数
+
+除原报告指出的 `Empty` 未写入外,复核还发现两个遗漏:
+
+- 某种状态消失时,旧 `succeeded/failed` 值不会归零;
+- aggregate 将 `skipped` 视为成功,但 Recount 未计入 succeeded。
+
+当前 Recount 会先清零计数、将 `success + skipped` 汇总为 succeeded,并从 TaskDocument 统计空文档数。
+
+### 3.4 流式下载和 Package 生命周期
+
+- Package、二进制文档和 RenderArtifact 下载改为 `SendStream`。
+- 流式读取期间持有并续租对应锁,响应关闭后释放。
+- Package 下载、人工删除和定时清理使用同一个 Package 锁,避免流式下载期间删除对象。
+- Package 清理不再重算为 `清理时间+30天`,而是固定使用 `ExpiresAt+30天`。
+
+ZIP 仍完整构建在内存中,Renderer 首次生成仍产生完整 `[]byte`,因此 DD-010 保持 open。
+
+## 4. 被否决的原结论
+
+### CR-01
+
+`BuildDataDownloadPackage` 的 binary 分支自行取得 content 锁,structured 分支则直接进入 `ReadRenderedDataDownloadDocument`;两者不会在同一调用路径对同一 key 二次加锁。原报告描述的是假设未来修改后的风险,不是当前缺陷。
+
+### CR-03
+
+旧 PowerFlow 导出和前端转换均以时间轴为基准,并允许较短的并行数组以空值补齐。当前实现拒绝长于时间轴的数组、保留短数组中的“缺测值”语义,没有证据证明应强制全部等长。新增单元测试防止未来把兼容行为误改为报错。
+
+### CR-09
+
+设计文档 12.7 明确规定:其他用户可以查看任务和文件、预览、下载及批量下载,但不能修改、取消、重试或删除。归档读取属于文件查看/下载,且 `/api` 已由统一认证中间件保护,因此不能增加 owner/admin 限制。
+
+## 5. REPO-006 更正说明
+
+原报告建议把 REPO-006 更正为 `GetAlarmSnapshotData`,该建议不正确。
+
+REPO-006 描述的是旧测试 `service/test/getSnapTest/getAlarmSnapshotTest.go` 仍调用不存在的 `jobs.GetAlarmSnapshot`;DataDownload 新代码调用存在的 `jobs.GetAlarmSnapshotData`。两项事实同时成立,问题台账无需更正。
+
+## 6. 验证结果
+
+- `go test ./services ./controllers ./repositories/mongo ./repositories/pgsql ./repositories/minio ./task/queue/dataDownloadQueue ./cache/redisx ./database`:通过。
+- 新增 PowerFlow 回归测试:短并行数组补空;越界 `run_status` 被拒绝。
+- `pnpm type-check`:通过。
+- `pnpm exec eslint src/service/dataDownload src/views/data-download`:通过。
+- `git diff --check`:通过。
+- `go test ./...`:仍被 REPO-001~007 的既有源码问题阻断;DataDownload 相关包未出现新增失败。
+
+## 7. 仍需继续处理的风险
+
+- DD-001:普通 content/Renderer 锁尚未全部统一为带 fencing 的租约。
+- DD-002、DD-007、DD-008:PostgreSQL、Mongo Document、RenderSlot/Artifact 仍没有完整崩溃一致性和发布 CAS。
+- DD-009:二进制上传后 Mongo 切换失败仍可能产生孤儿对象。
+- DD-010:ZIP 构建、Renderer 首次生成和上游二进制获取仍有内存放大。
+- DD-015:归档内部条目读取仍缺少大小上限。
+- DD-023:缺少 Redis/Asynq/MongoDB/PostgreSQL/MinIO 跨进程与故障注入测试。

+ 110 - 0
docs/data-download/implementation-status.md

@@ -0,0 +1,110 @@
+# DataDownload 当前实现状态
+
+> 更新时间:2026-09-23。本文是代码导航和差异摘要,不替代源码。动态问题状态见 [问题台账](../issues/data-download-open-issues.md)。
+
+## 1. 已实现
+
+- 独立 DataDownload 路由、MongoDB collections、PostgreSQL 当前正文、Asynq 队列和 MinIO 前缀;
+- Task、Item、TaskDocument、Document、FileAttempt、FaultRecordEntry、RenderSlot、Artifact、PackageJob 分离;
+- 600 天日期范围、站点时区未来日期判断、D+2 和 Dynamic 状态;
+- 按数据类型构造 RequestKey,ItemKey 与 Document 身份分离;
+- Redis FileRequest token 租约、续租、deferred 等待和 completed marker;
+- PostgreSQL 事务替换当前正文并递增 Generation;
+- Renderer Artifact、72 小时过期、正文更新断开 Slot;
+- PackageJob 创建、下载、取消、重试、删除及 7 天有效期;
+- FaultRecord 设备级每日文档、独立文件对象、文件级 Attempt、partial、D+2 Frozen;
+- FaultRecord `v2` 先查可召唤目录、验证已召唤子记录,再召唤缺失项;召唤任务按字段与 UTC 时间窗口定位,按 ID 分页查询,每轮延迟约 60 秒重入队并释放 worker,最长 2 小时;
+- partial ZIP 成功文件及 `DataDownloadReport.json`;
+- owner/admin 任务隔离和 task-scoped 文件入口;
+- 管理员 FaultRecord 文件清单、单文件下载和物理删除;
+- MCP 固定非管理员 owner `USER_AI`,其任务查询限制为自身。
+
+## 2. 主要代码入口
+
+| 领域 | 文件 |
+|---|---|
+| Domain | `service/domain/dataDownloadDomain.go` |
+| Task planning / structured execution | `service/services/dataDownloadService.go` |
+| RequestKey | `service/services/dataDownloadRequestKey.go` |
+| Request lease | `service/services/dataDownloadFileRequestService.go` |
+| Binary execution | `service/services/dataDownloadBinaryService.go` |
+| FaultRecord execution | `service/services/dataDownloadFaultRecordService.go` |
+| Renderer | `service/services/dataDownloadRenderService.go` |
+| Package | `service/services/dataDownloadPackageService.go` |
+| Admin deletion | `service/services/dataDownloadAdminService.go` |
+| Mongo repository | `service/repositories/mongo/dataDownloadRepo.go` |
+| PostgreSQL repository | `service/repositories/pgsql/dataDownloadRepo.go` |
+| MinIO repository | `service/repositories/minio/dataDownloadRepo.go` |
+| Queue | `service/task/queue/dataDownloadQueue/dataDownloadQueue.go` |
+| HTTP controller/routes | `service/controllers/dataDownloadControllers.go`、`service/routes/dataDownloadRoutes.go` |
+| MCP | `service/cmd/mcp/tools/service/data_download.go` |
+| Frontend | `src/views/data-download/`、`src/service/dataDownload/` |
+
+## 3. 已知设计差异
+
+| 领域 | 当前实现 | 尚缺 |
+|---|---|---|
+| content lock | Redis 排他锁;流式读取可续租 | writer-preference 读写锁、统一 fencing |
+| Generation | Mongo Document/Slot 比较 | 每次命中校验 PostgreSQL 权威 Generation |
+| RenderSlot | DocumentID、format、Generation、ArtifactID | RendererVersion、CacheKey、发布 token/CAS、状态机 |
+| PostgreSQL migration | 自制 migration 表和 GORM SQL | goose/embed 或 migration advisory lock |
+| PostgreSQL schema | 强类型列仍保留 payload jsonb | 完整类型化及 UpdateLog 细分 |
+| Renderer | MQTT/EventAlarm 固定列,PowerFlow 基础 HTML | PowerFlow ECharts、UpdateLog 双 Sheet、JSON envelope |
+| 前端预览 | 下载为主 | HTML/CSV/XLSX/JSON/TXT 真实预览、tar 浏览 |
+| 批量操作 | 后端支持多 Task 打包,页面单任务整包 | 多选、部分文件选择、批量删除 |
+| MCP | 自身任务列表、创建、全局文档分页 | 条件过滤、详情/读取、存在性判断、覆盖检查与幂等创建 |
+| 大对象 | HTTP 读取流式;上游与 ZIP 多处 `[]byte` | 流式生成/上传或临时文件 |
+| FaultRecord 召唤轮询 | 每轮查询一次并延迟约 60 秒重入队,释放 worker;持久化任务信息和召唤占用,最长 2 小时 | 实际轮询间隔受队列调度影响 |
+| FaultRecord 设备发现 | 站点和设备基本信息只规划 aGate/aPower | MPPT/aHub 本阶段不处理;未来启用前确定设备 SN 来源 |
+
+## 4. 当前高风险项
+
+以问题台账为准,当前主要风险包括:
+
+- 普通 content/render 临界区的 TTL、续租和 fencing 不完整;
+- PostgreSQL、Mongo Document 和 RenderSlot 不是原子提交;
+- Artifact 发布缺少 generation/token CAS;
+- 上游大文件和 ZIP 构建存在内存放大;
+- migration 多实例并发不安全;
+- 二进制 Mongo 发布失败可能产生孤儿对象;
+- Task 跨 collection 删除不具备事务性;
+- MinIO 底层 helper 尚未真正传播 context。
+
+## 5. DD-005 / DD-006 落地状态
+
+### DD-005 RequestKey
+
+已解决。每个 Kind 只采用影响正文身份的字段;DocumentID、请求租约和 Mongo Document 使用同一规范化 RequestKey。各 Kind 通过独立 `content_version` 管理内容语义升级。
+
+### DD-006 FaultRecord
+
+已解决。规划按 DeviceSN/DevType/Date 拆分;执行保存多个 Entry 和每任务 FileAttempt;成功文件立即发布,失败不覆盖旧 current file;partial 可重试和打包;完整无失败且到达 D+2 才 Frozen。
+
+## 6. 验证
+
+当前相关验证命令:
+
+```bash
+cd service
+GOCACHE=/tmp/fwh-toolbox-go-cache go test ./services ./cmd/mcp/tools/service ./controllers ./routes ./task/queue/dataDownloadQueue ./repositories/mongo ./repositories/pgsql
+
+cd ..
+pnpm exec vue-tsc --build
+pnpm exec eslint src/service/dataDownload/index.ts src/service/dataDownload/interface.ts src/views/data-download/index.vue src/views/data-download/documents.vue
+```
+
+上述 DataDownload 相关测试与前端类型检查已通过。全仓 `go test ./...` 仍被与 DataDownload 无关的旧程序阻断,包括:
+
+- `models/model_pgsql/siteOriginalData.go` 语法错误;
+- 部分 `cmd/gen` 使用已不存在的配置字段;
+- 多个旧测试仍调用旧版 `logger.Init`;
+- 若干历史测试使用已经变化的函数签名。
+
+## 7. 接续开发读取顺序
+
+1. 先读 [README.md](README.md);
+2. 按任务读取 [requirements.md](requirements.md) 的相关需求;
+3. 读取 [technical-design.md](technical-design.md) 的相关设计;
+4. 阅读本文对应差异与源码入口;
+5. 只读取问题台账中的目标 DD 项;
+6. 仅在缺少决策依据时查阅 `archive/`。

+ 165 - 0
docs/data-download/requirements.md

@@ -0,0 +1,165 @@
+# DataDownload 产品需求
+
+> 文档类型:稳定需求。本文描述系统应该提供的产品行为,不描述某个函数或数据库的当前实现。当前落地情况见 [implementation-status.md](implementation-status.md)。
+
+## 1. 目标与边界
+
+DataDownload 用于按站点、设备、日期和数据类型请求、复用、查看及下载诊断数据。新模块独立于旧 BatchDownload;不修改、不迁移旧任务和旧文件。
+
+核心目标:
+
+- 用户创建数据请求任务后,可以持续查看进度、失败原因和已成功数据;
+- 相同逻辑数据可跨任务复用,不因任务删除而删除共享文件;
+- AI 在新会话中可按数据条件重新查询状态,不依赖记住旧 taskID;
+- Mutable 数据可刷新,Frozen 数据稳定复用;
+- 部分文件失败时,成功文件仍立即可用。
+
+## 2. 支持的数据类型
+
+| 类型 | 请求维度 | 固化策略 | 主要输出 |
+|---|---|---|---|
+| PowerFlow | SiteSN + 本地日期 + 时区 | D+2 | HTML/JSON |
+| MQTT Log | SiteSN + 本地日期 + SubType + 时区 | D+2 | XLSX/CSV/JSON |
+| EventAlarm | SiteSN + 本地日期 + 时区 | D+2 | XLSX/CSV/JSON |
+| AlarmSnapshot | SiteSN + 本地日期 + 时区 | D+2 | 原始 XLSX |
+| EMS Log | SiteSN + 日期 + SubType | D+2 | 原始文件 |
+| FaultRecord | DeviceSN + DevType + 本地日期 + 时区 | D+2 | 零到多个原始录波文件 |
+| UpdateLog | SiteSN | 永不固化 | XLSX/JSON |
+
+用户日期表示站点本地日历日期。任何依赖本地日期边界的类型都必须保留实际时区,不能只保存 UTC 偏移后丢失夏令时语义。
+
+## 3. 任务需求
+
+### 3.1 创建
+
+- 至少提供一个有效 SN 和一个数据类型;
+- SN 支持文本输入和 UTF-8 TXT 文件导入;
+- SN 去空、去重,但不静默改写其内容;
+- 日期型数据必须提供开始、结束日期,包含首尾;
+- 不允许请求站点当地的未来日期;
+- 单任务日期范围最多 600 天;
+- UpdateLog 单独请求时不要求日期范围;
+- 任务描述不参与数据身份和复用判断。
+
+### 3.2 状态
+
+任务状态包括:
+
+```text
+pending -> running -> complete | partial_success | failed | error | cancelled
+                   \-> cancelling -> cancelled
+```
+
+- 全部工作单元成功或为空:`complete`;
+- 至少有可用结果,同时有失败、取消或 partial item:`partial_success`;
+- 所有需要执行的工作单元均失败:`failed`;
+- 无法规划或启动:`error`;
+- 等待相同数据请求时仍表现为 pending,不占用 worker 长时间等待。
+
+任务完成表示所需数据已经成功缓存,不要求提前生成所有预览格式。
+
+### 3.3 重试、取消和删除
+
+- 后台对失败请求执行有上限的自动重试;
+- 人工“一键重试”处理全部失败 item;FaultRecord 内部只重试未成功文件;
+- 首版不提供 FaultRecord 单文件人工重试按钮;
+- 已成功 item 不重复执行;
+- cancelled 任务不能恢复;
+- active 任务必须先取消,才能删除;
+- 删除任务只删除 Task、Item、FileAttempt 和 TaskDocument,不删除共享 Document 或文件;
+- 任务记录不自动过期,管理员可手动删除。
+
+## 4. 文档复用与数据新鲜度
+
+### 4.1 Document 与 Task 的关系
+
+Task 是请求和执行历史;Document 是可复用的数据。多个任务可以引用同一 Document。
+
+- 普通用户只能通过自己的 TaskDocument 入口访问文件;
+- 删除任务后用户失去入口,但共享数据继续保留;
+- 用户再次请求同一数据时,可以重新关联已有 Document;
+- 历史任务不保证永久读取“任务创建时的旧版本”,Mutable 数据成功刷新后可切换为新内容。
+
+### 4.2 Mutable、Frozen、Dynamic
+
+- `Mutable`:尚未到固化边界,新请求可以刷新当前内容;
+- `Frozen`:固化边界后完成过一次成功的完整查询,后续直接复用;
+- `Dynamic`:永不固化,每个独立新请求都允许刷新;当前只有 UpdateLog。
+
+D 日数据的固化边界是业务时区 `D+2 00:00:00`。D+2 到达后不能只修改状态;必须在边界之后重新成功查询一次,才能 Frozen。
+
+空结果也是有效查询结果。D+2 后完整查询为空可以 Frozen,并保存 Empty 元数据,不创建虚假文件。
+
+## 5. FaultRecord 专项需求
+
+FaultRecord 的 Frozen 单元是 `DeviceSN + DevType + DataDate`,不是站点、整个任务或单个物理文件。
+
+当前站点任务只处理 `DevType=0`(aPower)和 `DevType=1`(aGate)。`DevType=2`(MPPT)和 `DevType=3`(aHub)暂不纳入规划;未来启用时需先确定其设备 SN 来源。
+
+获取流程先查询设备的可召唤目录,按 `failureTime` 选出目标日期的条目。查询已召唤列表后,必须读取子记录,以子记录的 `failureTime` 和故障码匹配目录条目;子记录数还必须等于列表中的 `faultRecordsCount`。仅对未匹配的目录条目发起召唤。新召唤任务按设备 SN、DevType、召唤数量、TriggerType 和 `cellTime`(请求 UTC 时间 ±1 分钟)定位,并记录任务 ID;每 60 秒查询一次状态,最多等待 2 小时。成功后按该 ID 查已召唤列表,验证子记录数量等于召唤数量,再下载所有有效新旧子文件。超时需保留最后一次观测的召唤状态。
+
+“`faultRecordingLst` 无记录”指接口成功返回 `result: []`,可继续召唤目录中的目标项;“`lstSubFaultWave` 子项为空”指某条已召唤记录的子列表为空,需要与该记录的 `faultRecordsCount` 比较。数量不一致时该记录无效,不得作为已完成召唤复用。
+
+- 当前已知 DevType 为 `0-aPower、1-aGate、2-MPPT、3-aHub`,未来允许新增;
+- 未知类型必须明确失败,不能错误回退为 SiteSN;
+- 每日文档关联零到多个独立原始对象,不生成每日 ZIP;
+- 稳定文件身份使用上游 FaultID/SubRecordID 等标识,不能依赖可能重复的文件名;
+- 一个文件失败不阻止其他文件发布;成功文件立即可用;
+- 文件级失败必须记录阶段、原因和尝试次数;
+- 刷新已有文件失败时继续保留旧 current file,不能用失败结果覆盖它;
+- 旧任务仍可读取旧 current file;新任务必须记录本轮失败,不能把旧文件算作本轮成功;
+- 上游后续未再返回某个历史文件时,不自动删除该文件;
+- 到达 D+2、完整清单查询成功、所有要求文件成功且无待重试失败时,文档才能 Frozen;
+- 某设备/日期 Frozen 不等待同任务的其他设备或日期。
+
+## 6. 权限与可见性
+
+| 操作 | Owner | 管理员 | 其他用户 |
+|---|---:|---:|---:|
+| 查看任务和任务文件 | 是 | 是 | 否 |
+| 修改描述、取消、重试、删除 | 是 | 是 | 否 |
+| 打包本任务文件 | 是 | 是 | 否 |
+| 浏览/删除全局共享文档 | 否 | 是 | 否 |
+| 修改 owner | 否 | 否 | 否 |
+
+- 普通用户即使传入全局 scope,也只能查询自己的任务;
+- 管理员可以查看所有任务和全局 Document Catalog;
+- 文件 API 必须校验 TaskID、owner/admin 和 TaskDocument 关联,不能凭 DocumentID 绕过入口;
+- 强制删除被引用的共享 Document 仅管理员可执行,并必须明确提示引用影响。
+
+## 7. 查看、下载与打包
+
+- 任务只要存在可读取的成功文件,就允许查看和下载,不受总体 partial/failed 状态阻断;
+- 空文档不出现在普通文件目录中,但管理员可查看其元数据;
+- 未知二进制和 `.dat` 允许下载,不要求预览;
+- FaultRecord 在任务目录中按设备、日期和原始文件展示;
+- 多任务 ZIP 按 TaskID 分目录;
+- `partial_success` 允许打包成功文件;ZIP 根目录包含 `DataDownloadReport.json`;
+- 报告至少包含任务状态、成功/失败数量,以及失败文件的设备、日期、文件名、阶段、原因和尝试次数;
+- 已选中的可用文件在实际读取时失败,则整个 PackageJob 失败,不提供半成品 ZIP;
+- ZIP 成功后保留 7 天;任务删除不自动删除已生成 ZIP,PackageJob 按自身生命周期管理。
+
+## 8. AI / MCP 需求
+
+MCP 使用固定的非管理员特殊用户 `USER_AI`。
+
+- AI 以数据条件为中心,不要求跨会话记住 taskID;
+- `Frozen + complete` 数据可直接使用;
+- Mutable 数据必须向用户说明数据日期和 `SourceFetchedAt`,由用户选择立即使用或刷新;
+- partial 数据可以使用成功部分,但必须说明失败/缺失数量;
+- 刷新失败而旧版本存在时,AI必须说明“刷新失败、旧版本仍存在”,再由用户确认是否使用;
+- 数据缺失时,先检查 `USER_AI` 是否已有覆盖该数据单元的活跃请求,避免重复创建;
+- 新任务 owner 固定为 `USER_AI`;普通用户看不到 AI 任务,只有管理员可管理;
+- MCP 不得查看普通用户任务元数据,也不得获得管理员文档删除权限;
+- MCP 不得直接 Put、覆盖或删除共享文件。
+
+## 9. 核心验收标准
+
+- 相同内容条件产生相同文档身份,任务字段不会破坏复用;
+- D+2、时区和空结果行为可测试;
+- FaultRecord 每个文件独立发布、失败和重试;
+- partial 任务的成功文件可立即下载及打包,并附准确报告;
+- 刷新失败不会破坏当前可用旧文件;
+- 删除任务立即移除用户入口,但共享文件仍存在;
+- 普通用户不能读取其他 owner 或全局 Document;
+- AI 新会话能按数据条件查询可用性或请求状态。

+ 246 - 0
docs/data-download/technical-design.md

@@ -0,0 +1,246 @@
+# DataDownload 技术设计
+
+> 文档类型:目标技术设计。产品语义见 [requirements.md](requirements.md),当前代码差异见 [implementation-status.md](implementation-status.md)。
+
+## 1. 模块边界
+
+```text
+Task Module
+  ├─ DataDownloadTask / Item / FileAttempt
+  ├─ TaskDocument(用户访问入口)
+  └─ Queue / Cancel / Retry / Aggregate
+
+FileRequestService
+  ├─ 规范化 RequestKey
+  ├─ DocumentID
+  ├─ 分布式请求租约与续租
+  └─ 相同请求合并
+
+DocumentService
+  ├─ Document Catalog
+  ├─ PostgreSQL 当前结构化正文
+  ├─ MinIO 原始对象及派生 Artifact
+  ├─ Mutable / Frozen / Dynamic
+  └─ Renderer / Package 输入
+```
+
+Task Module 管理“谁请求过、执行结果怎样”;DocumentService 管理“当前有哪些可复用数据”。DocumentService 不以 taskID 作为内容身份。
+
+## 2. 核心领域模型
+
+### 2.1 Task 侧
+
+- `DataDownloadTask`:owner、条件、总状态和汇总计数;
+- `DataDownloadItem`:一个可独立执行和重试的 DocumentUnit;
+- `DataDownloadTaskDocument`:Task 到 Document 的授权入口和逻辑路径;
+- `DataDownloadFileAttempt`:FaultRecord 某个物理文件在本任务中的执行历史;
+- `DataDownloadPackageJob`:异步 ZIP 生命周期。
+
+### 2.2 Document 侧
+
+- `DataDownloadDocument`:稳定逻辑数据单元及当前状态;
+- `DataDownloadFaultRecordEntry`:FaultRecord 文档下稳定文件身份和 current object 指针;
+- `DataDownloadRenderSlot`:文档格式到当前派生 Artifact 的指针;
+- `DataDownloadArtifact`:不可变派生对象。
+
+MongoDB collection 必须独立保存 Task、Item、TaskDocument、Document、Entry、Attempt、RenderSlot、Artifact 和 PackageJob,避免无限数组嵌入主任务。
+
+## 3. RequestKey 与 DocumentID
+
+RequestKey 只包含影响正文身份的字段,不包含 taskID、owner、描述、创建时间或输出文件名。
+
+当前规则:
+
+| Kind | RequestKey 字段 |
+|---|---|
+| PowerFlow | kind、content_version、SiteSN、Date、Timezone |
+| MQTT | kind、content_version、SiteSN、Date、SubType、Timezone |
+| EventAlarm | kind、content_version、SiteSN、Date、Timezone |
+| AlarmSnapshot | kind、content_version、SiteSN、Date、Timezone |
+| EMS | kind、content_version、SiteSN、Date、SubType |
+| FaultRecord | kind、content_version、DeviceSN、DevType、Date、Timezone |
+| UpdateLog | kind、content_version、SiteSN |
+
+字段值需要规范化并安全转义;每个 kind 独立维护 `content_version`。只有 query/parser/schema 的内容语义变化才提升版本,RendererVersion 不进入 RequestKey。
+
+```text
+DocumentID = SHA-256(RequestKey)
+```
+
+任务内部 ItemKey 仅用于幂等规划,不能代替 RequestKey。
+
+## 4. 文档状态与 Frozen
+
+文档状态为 Mutable、Frozen、Dynamic,另有 `Completeness=complete|partial`。
+
+日期 D 的 Frozen 判定:
+
+```text
+当前站点本地时间 >= D+2 00:00
+AND 本轮查询发生在该边界之后
+AND 完整上游查询成功
+AND 所有要求内容均成功发布
+AND 无待重试失败
+```
+
+边界前生成的 Mutable 文档不能由定时器直接翻转 Frozen。空结果在完整查询成功后也可以 Frozen。
+
+## 5. 存储设计
+
+### 5.1 PostgreSQL
+
+结构化数据只保存当前正文,不保存历史快照。`data_download_content_state` 保存 DocumentID、Kind、Generation、SchemaVersion、RowCount、Checksum、SourceFetchedAt。
+
+正文表按类型建模:PowerFlow 点数据、MQTT entries、EventAlarm entries、UpdateLog entries。更新使用事务内全量替换并递增 Generation;大批量写入可从 GORM batch 演进到 pgx CopyFrom。
+
+### 5.2 MongoDB
+
+MongoDB 保存任务状态、文档目录、对象指针、文件 Attempt、派生缓存 Slot 和 PackageJob。Document 的 Generation 是 PostgreSQL 权威代数的镜像,不应成为结构化正文的独立事实来源。
+
+关键唯一索引:
+
+- Item:`taskId + itemKey`;
+- TaskDocument:`taskId + itemKey + documentId`;
+- Document:`requestKey`;
+- FileAttempt:`taskId + itemId + sourceFileId`;
+- FaultRecordEntry:`documentId + sourceFileId`;
+- RenderSlot:`documentId + format`。
+
+### 5.3 MinIO
+
+原始二进制、FaultRecord 文件、RenderArtifact 和 Package ZIP 使用不同前缀。对象键不得通过 HTTP/MCP JSON 暴露。
+
+对象先以不可变 key 上传,校验成功后再切换 Mongo current pointer。指针切换失败时删除新孤儿对象;删除失败则需要可重试的 orphan/retiring 机制。
+
+## 6. FaultRecord 设计
+
+规划阶段从站点信息取得 aGate SN,从站点基本信息的 `FhpSn` 取得 aPower SN,按每个 `DeviceSN + DevType + DataDate` 创建 Item。当前只规划 DevType 0/1;规划阶段不查询 `faultRecordingLst`,因此执行 Item 时的第一项上游录波请求是 `getWaveRecordingCatalog`。保留的 SN 映射如下:
+
+| DevType | SN 来源 |
+|---:|---|
+| 0 aPower | `ApowerId` |
+| 1 aGate | `GatewayId` |
+| 2 MPPT | 暂不处理;历史记录中的字段为 `ApowerId` |
+| 3 aHub | 暂不处理;历史记录中的字段为 `PowerlinkId` |
+
+未知 DevType 返回明确错误,未来通过策略表扩展。
+
+执行流程:
+
+| 步骤 | 方法与上游路径 | 主要参数与判定 |
+|---|---|---|
+| 可召唤目录 | `GET /hes-gateway/manage/getWaveRecordingCatalog` | `deviceSerial`、`callType`;按 `failureTime` 过滤日期 |
+| 已召唤列表 | `GET /hes-gateway/manage/faultRecordingLst` | `deviseSerialNumber`、`earliestStartTime`、`earliestEndTime`;整个列表为空表示尚无已召唤记录 |
+| 子记录校验 | `GET /hes-gateway/manage/lstSubFaultWave` | `faultId`;数量须等于父记录的 `faultRecordsCount`,再比较子项 `failureTime` 与故障码 |
+| 发起召唤 | `POST /hes-gateway/manage/callFaultRecording` | `deviceId`、逗号分隔的 `index`、`callType`、`triggerType=2` |
+| 召唤状态 | `GET /hes-gateway/manage/lstFaultWaveCall` | `pageNum`、`pageSize`;初次按设备、类型、数量、TriggerType、`cellTime ±1 分钟` 定位 ID,此后按 ID 查找 |
+| 原始文件 | `GET /hes-gateway/manage/exportSubFaultWave` | `faultId`、`id`(子记录 ID) |
+
+```text
+获取 Document 请求租约
+  -> getWaveRecordingCatalog 按 failureTime 选目录条目
+  -> faultRecordingLst + lstSubFaultWave 校验已召唤内容与数量
+  -> 对缺失条目执行 callFaultRecording
+  -> lstFaultWaveCall 按设备、DevType、数量、TriggerType、cellTime 定位任务 ID
+  -> 每轮按 ID 查询一次状态,约 60 秒后延迟重入队;总计最多 2 小时,超时保存最后状态
+  -> 成功后 faultRecordingLst 按 ID 定位,lstSubFaultWave 再校验数量
+  -> 对未成功的 sourceFileID 分别下载
+  -> 上传不可变对象
+  -> 在 Document content lock 内切换 Entry current pointer
+  -> 记录本 Task 的 FileAttempt
+  -> 聚合 Entry 和 Attempt
+  -> 写入 Document completeness/state
+  -> 创建 TaskDocument 入口
+```
+
+`sourceFileID` 当前由 `faultID:subRecordID` 构成。文件名仅展示,允许重复。新查询没有返回旧 Entry 时不删除旧 Entry。
+
+本次召唤流程使用 FaultRecord `content_version=v2`,以免旧流程产生的 Frozen 文档被新请求直接复用。
+
+`faultRecordingLst` 成功返回 `result: []` 时,进入缺失项召唤分支。`lstSubFaultWave` 对某条记录返回空列表时,仍需与其 `faultRecordsCount` 比较;若数量不符,该记录无效。接口错误应作为错误处理,不应推断成无记录。
+
+轮询期间持久化召唤发起时间、数量、任务 ID 和最后状态,并用 Mongo 中按 DocumentID 保存的召唤占用记录约束同一文档只有一个 Item 发起召唤。每轮查询后将 Item 恢复为 pending,延迟约 60 秒重入队,立即释放 worker;重入队时重新取得短时 FileRequest 租约。召唤成功、远端明确失败或 2 小时超时后释放占用。任务取消或延迟入队失败时,如果远端召唤可能仍在运行,占用保留到终态或到期,以免其他 Item 重复召唤。占用记录在原始 2 小时窗口后附加 10 分钟到期,避免 worker 崩溃后无限占用。轮询间隔允许有队列调度误差。
+
+失败刷新不能切换 Entry;新任务 Attempt 仍失败。重试读取该 Task 的 Attempt,仅跳过已经 success 且 Entry 仍存在的文件。
+
+## 7. 请求合并与并发
+
+相同 DocumentID 使用 Redis 请求租约合并:
+
+- Frozen 文档直接返回;
+- 已有有效租约时返回 deferred,队列延迟重试,不阻塞 worker;
+- owner 使用 token 续租并在提交前验证所有权;
+- complete marker 只代表同轮成功完成;partial 不写完成标记;
+- Dynamic 等待者在前一轮结束后重新竞争,不能永久复用旧完成标记。
+
+Document 正文、Entry current pointer、Artifact 读取和物理删除使用同一 Document content 协调边界。目标方案应为 writer-preference 读写锁;当前实现仍使用排他 Redis lock,详见实现状态。
+
+## 8. 结构化正文与 Renderer
+
+写入顺序应避免 PostgreSQL、Mongo Document 和 RenderSlot 三者出现可命中的旧组合:
+
+```text
+取得写租约
+  -> 使旧 RenderSlot 不可命中
+  -> PostgreSQL 事务替换正文并递增 Generation
+  -> 回填 Mongo Document 镜像
+  -> 释放写租约
+```
+
+Renderer 按 `DocumentID + Format + ContentGeneration + RendererVersion` 生成不可变 Artifact。发布必须使用 generation/token CAS,缓存命中必须校验 PostgreSQL 权威 Generation。
+
+## 9. 任务聚合与重试
+
+Item 可为 pending、running、success、partial_success、failed、skipped、cancelled。FaultRecord 文件级结果先聚合成 Item,再聚合 Task。
+
+- partial item 计入任务可用结果,但使 Task 为 partial_success;
+- 自动重试耗尽后才落最终 failed/partial;
+- 人工重试将 failed/partial item 重新入队;
+- FaultRecord executor 根据已有 success Attempt 跳过成功文件;
+- worker 更新禁止 upsert,避免任务删除后复活。
+
+## 10. 权限与接口边界
+
+普通任务入口:
+
+- `GET /dataDownload/tasks/:id`
+- `GET /dataDownload/tasks/:id/documents/:documentId/file`
+- `GET /dataDownload/tasks/:id/files/:entryId`
+
+这些接口同时校验 owner/admin 和 TaskDocument 关联。
+
+管理员目录入口:
+
+- `GET /dataDownload/documents`
+- `GET /dataDownload/documents/:documentId/file`
+- `GET /dataDownload/documents/:documentId/files`
+- `GET /dataDownload/documents/:documentId/files/:entryId`
+- `DELETE /dataDownload/documents/:documentId`
+
+管理员强制删除需要先获取 request/content lock,删除正文、原始对象、Entry、Artifact 和目录元数据。
+
+## 11. PackageJob
+
+PackageJob 在后台解析 TaskDocument:结构化文档先渲染,普通二进制读取 current object,FaultRecord 枚举各 Entry。多任务时以 TaskID 为顶级目录。
+
+存在失败 FileAttempt 时,把成功文件和 `DataDownloadReport.json` 一起打包。输入文件读取失败或 Renderer 失败会使整个 Job 失败。
+
+ZIP 保存为不可变对象,成功后设置 `CompletedAt`、`ExpiresAt=+7d` 和后续 PurgeAt。下载期间与清理使用相同锁。
+
+## 12. MCP 数据优先模型
+
+MCP owner 固定为 `USER_AI`。逻辑查询以 DocumentUnit 为输入,返回:
+
+```text
+missing | fetching | partial | available | last_fetch_failed
+```
+
+结果至少包含 State、Completeness、Empty、SourceFetchedAt、FileCount、FailedFileCount。缺失时再检查 USER_AI 自身活跃任务覆盖并创建任务。任务指纹/短期锁用于“检查后创建”的幂等,不能替代 Document RequestKey。
+
+## 13. 清理与删除
+
+- Task 删除不级联共享 Document;
+- Document 删除需要处理 PostgreSQL 当前正文、MinIO current objects、FaultRecord Entries、RenderArtifacts 和 Mongo 元数据;
+- Artifact 与 Package 使用各自 TTL/过期状态;
+- 删除流程应可重入,跨 Mongo/PostgreSQL/MinIO 的失败通过 deleting/retiring/orphan 状态恢复;
+- 不允许宽泛扫描或根据用户输入直接删除任意 ObjectKey。

+ 86 - 0
docs/issues/data-download-oom-2026-09-22.md

@@ -0,0 +1,86 @@
+# DataDownload 内存 OOM 排查记录(2026-09-22)
+
+> 状态:根因已定位并实锤,修复进行中(未完成)。
+> 影响服务:`fwh-toolbox-server`(cgroup 内存上限 2GB)。
+> 测试站点:`10060001A00F22520081`(时区 America/Chicago,-0500/-0600 且有 DST)。
+
+## 一、现象
+
+- 创建 DataDownload 任务(尤其勾选「事件&告警」/「MQTT 日志」)后,后端进程内存在数秒内从 ~12MB 飙到 2GB,被 cgroup OOM killer 杀掉,随后 systemd 重启,反复循环(NRestarts 持续增长)。
+- 现象反常点:
+  1. OOM 前 3 秒内无任何「上游返回 / 分页请求」业务日志;
+  2. 数据量本身不大(PowerFlow 单日 ~20KB,正常);
+  3. 多个 item 并发时更快触发。
+
+## 二、根因(已实锤)
+
+**时间分片 periods 构建死循环 → periods 切片无限 append → OOM。**
+
+位置:
+- `service/repositories/api/fwh_pro/fwh_eventAlarmLog.go` 的 `QueryEventOrAlarmListOnTimeRange`
+- `service/repositories/api/fwh_pro/fwh_mqttLog.go` 的 `QueryMqttLogListOnTimeRange`
+
+两函数内「按天拆分时间段」的游标推进逻辑:
+```go
+currTime = dayEnd.Add(time.Second).Truncate(24 * time.Hour)          // Truncate 按 UTC 零点对齐
+currTime = time.Date(currTime.Year(), currTime.Month(), currTime.Day(), 0,0,0,0, currTime.Location())
+```
+
+当传入的 `startTime/endTime` 带**非 UTC 时区**(如 America/Chicago,-0500)时:
+- `dayEnd` = `2026-09-19T23:59:59-0500`,`.Add(1s)` = `2026-09-20T00:00:00-0500` = `2026-09-20T05:00:00 UTC`;
+- `.Truncate(24h)` 按 **UTC** 零点截断 → `2026-09-20T00:00:00 UTC` = `2026-09-19T19:00:00-0500`(**回退到 19 号晚上**);
+- 再 `time.Date(...,-0500)` → `2026-09-19T00:00:00-0500`;
+- `currTime` 从 09-19 变回 09-19 00:00,**未前进** → `currTime.Before(endTime)` 恒成立 → 死循环。
+
+验证日志实锤(每 500 个 period 打一次):
+```
+periods: 1443000  curr: 2026-09-19T00:00:00-0500  end: 2026-09-19T23:59:59-0500  ← curr 停滞不前
+(数秒内 periods 涨到 144 万+,仍在增长)
+```
+
+## 三、为什么 BatchDownload 没出错(关键对比)
+
+- `QueryEventOrAlarmListOnTimeRange` / `QueryMqttLogListOnTimeRange` 是 `fwh_pro` 共享函数,多个模块调用(fwhTaskSiteAnalysis、fwhTaskBatchDownload、dataDownloadService、mcp/fwh_api、fwhQueueOld)。
+- **BatchDownload** (`fwhTaskBatchDownload.go:55`) 用 `time.Parse(time.DateOnly, ...)` 解析日期 → 结果是 **UTC** 时间。函数内 `Truncate(24h)` 也按 UTC 对齐,两者一致,游标正常前进,不触发。
+- **DataDownload** (`dataDownloadService.go` `ExecuteStructuredDataDownloadItem`) 用 `time.ParseInLocation(time.DateOnly, item.Date, loc)` 解析,`loc` = 站点本地时区(Chicago)→ 带非 UTC 时区 → 触发 `Truncate(24h)` 回退死循环。
+
+**结论**:时间分片本不该带时区概念(就是「某段日期」的字面值)。BatchDownload 传 UTC 字面值恰好规避;DataDownload 传本地时区时间踩中共享函数里 `Truncate(24h)` 的 UTC 对齐陷阱。
+
+## 四、修复方案(遵循「共享函数非自身必要不改」原则)
+
+**方向**:不改被多模块共用的 `fwh_pro` 查询函数逻辑;在 **DataDownload 调用侧**把传给上游查询的时间剥离时区(转为同字面值的 UTC),与 BatchDownload 行为一致。
+
+理由:上游查询只把时间 `Format("2006-01-02 15:04:05")` 成字符串发给 FWH;UTC 与 Chicago 解析出的 `2026-09-14 00:00:00` 字面值相同,查询语义不变,但避开 `Truncate(24h)` 时区陷阱。
+
+**注意**:`ExecuteStructuredDataDownloadItem` 里的 `start/end`(本地时区)**仍用于 Frozen(D+2) 判断**(`!time.Now().In(loc).Before(start.AddDate(0,0,2))`),不能改动它们本身;只新增一对「字面值 UTC」时间专用于上游查询。
+
+## 五、修改进展
+
+已完成(`service/services/dataDownloadService.go` `ExecuteStructuredDataDownloadItem`):
+- 在 `start/end` 定义后新增 `startQuery := stripTimeLocation(start)` / `endQuery := stripTimeLocation(end)`(含说明注释);
+- mqtt / event 两个 case 的上游查询传参从 `start,end` 改为 `startQuery,endQuery`。
+
+**待完成(下次接续)**:
+1. **补上 `stripTimeLocation` 辅助函数**(当前编译会因未定义而失败)。建议实现:把时间按字面 y/m/d/h/m/s 重建为 UTC:
+   ```go
+   func stripTimeLocation(t time.Time) time.Time {
+       if t.IsZero() { return t }
+       return time.Date(t.Year(), t.Month(), t.Day(), t.Hour(), t.Minute(), t.Second(), t.Nanosecond(), time.UTC)
+   }
+   ```
+   放在 `parseRemoteTime`(约 422 行)附近。
+2. **在两个共享查询函数上补注释**(限制条件),避免再次踩坑:说明「startTime/endTime 必须为不含时区(UTC)的字面时间;内部按天拆分用 time.Truncate(24h) 按 UTC 对齐,传入非 UTC 时区会导致 periods 死循环 OOM」。
+3. 去掉临时验证日志(periods 持续增长 Warn),保留「periods 构建完成」Debug(可选)。
+4. `go build ./...`(相关包)+ `go test ./services` 验证。
+5. 清理毒任务(Mongo 活动态 item/task 置 cancelled + 清 asynq `data-download:*` 队列与 `data-download:*` 锁),重新部署 `fwh-toolbox-server`,重测 event&告警 + mqtt。
+
+## 六、附带发现的其他问题(本次已处理/待办)
+
+- **[已修] EMS 类型解析**:`dataDownloadBinaryService.go` 用 `strconv.Atoi("EmsRunLog")` 失败。已加 `resolveEmsLogType`(字符串枚举→数字映射,兼容数字),与 BatchDownload 的 `mapEmsLogType` 一致。
+- **[待办] UpdateLog detail 上游 500**:`selectIotFirmwareOrderDetailList` 对某 orderId 返回 500,导致整个 update_log item 失败重试。建议单个 order detail 失败时跳过并记 Warn,不整体失败。
+- **[遗留防护] mqtt 分页游标停滞**(同一秒 ≥1000 条):已加 Error 日志 + 返回,作为极端防护(非当前根因)。
+- **已加大量过程日志**:controller API 入口、plan/item/aggregate、请求租约获取/占用/丢失、各 kind 上游返回条数、二进制/FaultRecord 各步、HTTP 响应体字节数、分页请求前后。级别:Info=里程碑/API,Debug=规模细节,Warn=上游失败/重试,Error=最终失败。
+
+## 七、并发放大(次要因素,非根因)
+
+worker `Concurrency: 8`(`dataDownloadQueue.go`)。periods 死循环下,多个 item 并发只是让 OOM 更快;根因修复后并发不再是问题。是否降并发可后续评估,非必须。

+ 300 - 0
docs/issues/data-download-open-issues.md

@@ -0,0 +1,300 @@
+# DataDownload 待解决问题
+
+> 建立日期:2026-09-19  
+> 状态:持续维护  
+> 文档导航:[DataDownload README](../data-download/README.md);目标需求见 [requirements.md](../data-download/requirements.md);技术设计见 [technical-design.md](../data-download/technical-design.md);当前实现见 [implementation-status.md](../data-download/implementation-status.md)。
+> 范围:DataDownload 首版实现差异、可靠性风险,以及当前阻断全仓测试的既有源码问题。
+
+## 1. 使用规则
+
+- 状态取值:`open`、`in_progress`、`blocked`、`resolved`、`wont_fix`。
+- 优先级取值:
+  - `P0`:可能造成数据错误、并发覆盖、返回旧内容或任务永久异常,发布前必须解决。
+  - `P1`:可能造成资源泄漏、内存故障、重要功能缺失或运维风险。
+  - `P2`:产品能力、展示或工程质量未完整对齐设计。
+  - `P3`:仓库既有问题,不直接影响 DataDownload 主链路,但会阻断全仓验证。
+- 关闭问题时应填写解决提交、验证方式和关闭日期,不直接删除历史条目。
+- 产品目标以 `requirements.md` 为准,技术约束以 `technical-design.md` 为准,当前事实以源码、测试和 `implementation-status.md` 为准。
+
+## 2. 汇总
+
+| ID | 优先级 | 状态 | 问题 |
+|---|---|---|---|
+| DD-001 | P0 | open | Redis 锁没有续租和 fencing token(观测优先:第 1 层已落地,第 2/3 层待触发) |
+| DD-002 | P0 | resolved | PostgreSQL、Mongo Document、RenderSlot 提交不具备崩溃一致性(方向 A:先失效后提交;读侧竞态转 DD-008) |
+| DD-003 | P0 | resolved | FileRequestService 未按 RequestID/延迟等待模型实现 |
+| DD-004 | P0 | resolved | plan handler 重试不幂等 |
+| DD-005 | P0 | resolved | RequestKey 缺少影响正文身份的字段 |
+| DD-006 | P0 | resolved | FaultRecord 文档粒度和存储模型错误 |
+| DD-007 | P1 | open | Artifact 发布缺少 generation/token CAS 校验 |
+| DD-008 | P1 | open | 缓存命中未校验 PostgreSQL 权威 Generation |
+| DD-009 | P1 | open | 二进制切换失败会产生孤儿对象 |
+| DD-010 | P1 | open | 大文件读取与 ZIP 构建存在内存放大 |
+| DD-011 | P1 | open | PostgreSQL migration 多实例并发不安全 |
+| DD-012 | P1 | open | 任务删除不是事务或原子状态变更 |
+| DD-013 | P1 | resolved | PackageJob PurgeAt 计算基准错误 |
+| DD-014 | P1 | open | MinIO Repository 没有传播 context |
+| DD-015 | P1 | open | 归档内部文件读取没有大小上限 |
+| DD-016 | P2 | open | PostgreSQL 强类型结构仍处于过渡态 |
+| DD-017 | P2 | open | Renderer 输出未完整对齐设计 |
+| DD-018 | P2 | open | 前端真实预览与归档浏览未实现 |
+| DD-019 | P2 | open | 前端批量操作和部分文件选择未实现 |
+| DD-020 | P2 | open | 任务、Package 和管理员页面过滤/权限展示不完整 |
+| DD-021 | P2 | open | MCP 能力和幂等创建未完整实现 |
+| DD-022 | P2 | open | MongoDB 索引与设计索引表不完全一致 |
+| DD-023 | P2 | open | 自动化测试覆盖不足 |
+| REPO-001~007 | P3 | open | 仓库既有编译和旧测试签名问题 |
+
+## 3. P0:数据正确性与并发问题
+
+### DD-001 Redis 锁没有续租和 fencing token
+
+- 状态:`open`(当前采用「观测优先」策略:先补充可观测性,待真实样本再决定 CAS/TTL 方案)
+- 位置:`service/cache/redisx/lockx.go`、`service/services/dataDownloadService.go`、`dataDownloadBinaryService.go`
+- 当前行为:FileRequest 租约和流式读取锁已经支持 token 校验续租;普通 content 锁和 RenderArtifact 发布仍没有完整 fencing/CAS。
+- 影响:大型 Renderer 或正文提交超过 content lock TTL 后仍有租约过期窗口;Artifact 发布也仍可能被旧执行者覆盖。
+- 处理建议:把 content/render 锁统一为可续租租约,并在 Artifact/正文发布处加入 generation/token CAS。
+- 验收条件:覆盖锁续租、租约丢失、旧持有者晚提交、worker 崩溃后恢复的并发测试。
+
+#### 决策:观测优先,暂不实施 CAS
+
+该问题「理论存在、实际频率未知」,真实行数量级与 PG/MinIO 实际延迟均为估算。generation CAS 改造牵连 DD-002/007/008,投入较大。因此先补充观测日志,让问题从「悄悄写坏」变为「事后可判定、可定位」,攒到真实的耗时分布与 owner 丢失样本后,再决定是上 CAS 还是仅调 TTL / 改批量写入。本条在样本充分前保持 `open`,不因加了日志而视为已修复。
+
+#### 观测手段进度
+
+- **第 1 层(已落地,2026-09-20)**:`service/cache/redisx/lockx.go` 的 `RedisLock` 内建观测,一处覆盖全部锁(content / package / request lease,`cache.RedisLock` 为其类型别名,调用方透明):
+  - 新增 `acquired`(加锁时刻,单调计时)、`label`(从 key 前两段推断,如 `data-download:content`)。
+  - `SafeRelease` 分三档记录:正常释放但持锁 > TTL×70% 记 `Warn`;释放时发现 `not owner`(锁被抢走、可能已并发覆盖)记 `Error`(关键铁证,带 label/key/token/held/ttl);其他释放失败记 `Error`。
+  - 引入哨兵错误 `errLockNotOwner`,统一 `Release`/`LockRelease`/`Extend`/`SetIfOwner` 的 not-owner 返回值,供 `errors.Is` 精确判定。
+  - 验证:`go build ./cache/...`、`go test ./services ./cache/redisx ./database` 通过。
+
+- **第 2 层(待补,出问题后再加)**:写入临界区分段耗时 + 数据规模。
+  - 位置:`ExecuteStructuredDataDownloadItem` / `ExecuteBinaryDataDownloadItem` 拿锁之后。
+  - 记录字段:`documentId`、`taskId`、`kind`、`rowCount`(结构化)/`objectBytes`(二进制)、分段耗时(`replaceRows`/`minioPut`/`mongoWrite`)、`total`、`ttl`。
+  - 日志策略(定稿):正常路径打 `Debug`(生产默认 Warn 级别,不输出,零成本;需要时可临时调级看全量);`total ≥ TTL×50%` 打 `Warn` 带完整分段。默认零噪音,与第 1 层职责不重叠(第 1 层管「有没有出事/接近出事」,第 2 层管「出事时慢在哪一步」)。
+
+- **第 3 层(待补,视第 2 层结果再加)**:`ReplaceDataDownloadRows` 内部 `pg_advisory_xact_lock` 获取耗时。
+  - 目的:区分「逐行 insert 慢」与「advisory 锁等待慢(写-写争用,正是 DD-001 的并发场景)」。
+  - 触发条件:第 2 层日志显示 `replaceRows` 大但 `rowCount` 不高时,再补此层定位。
+  - 记录:advisory 锁获取耗时,> 若干秒打 `Warn`。
+
+### DD-002 PostgreSQL、Mongo Document、RenderSlot 崩溃一致性
+
+- 状态:`resolved`(方向 A,2026-09-20)
+- 位置:`service/services/dataDownloadService.go`(`ExecuteStructuredDataDownloadItem` 提交段)
+- 原行为:先提交 PostgreSQL,再更新 Mongo Document,最后断开 RenderSlot。「PG 已提到 N、Mongo Document 仍为 N-1」的窗口内,`currentArtifact` 因 `slot.ContentGeneration == document.ContentGeneration`(都为 N-1)判定命中,静默返回旧代缓存且不自愈。
+- 解决方式(方向 A:先失效、后提权威、再回填):将 `DetachDataDownloadRenderSlots` 从「PG + Upsert 之后」提前到 `ReplaceDataDownloadRows` **之前**。断开 RenderSlot 后 `currentArtifact` 会因 `slot.ArtifactID==nil` 判定 miss(`GetDataDownloadRenderSlot` 无 slot 时返回 `ErrNoDocuments`,调用方仅在 `err==nil` 时命中,其余一律重渲染),强制后续读者从 PG 当前 generation 重新渲染。
+  - 逐窗口验证(初始三者均为 N-1,目标 N):
+    - Detach 后 / PG 前崩溃:slot 断开、PG=N-1 → 读 miss 重渲染读 PG(N-1),正确。
+    - PG 后 / Upsert Document 前崩溃(原危险窗口):slot 断开、PG=N、Doc=N-1 → 读 miss 重渲染读 PG(**N**),正确,不再命中旧代缓存。
+    - 全部成功:slot 断开、PG=N、Doc=N → 读 miss 重渲染读 PG(N),正确。
+  - 反向风险:Detach 提前后若 PG 提交失败返回,仅表现为缓存被清、下次读重渲染同一代内容,无数据错误。
+- 影响范围:仅结构化路径(PowerFlow/MqttLog/EventAlarm/UpdateLog)。二进制路径(AlarmSnapshot/EmsLog/FaultRecord)走 `BinaryObjectKey` 直读、无 RenderSlot,不涉及本窗口。
+- 验证:`go build ./services/...`、`go vet ./services/`、`go test ./services ./repositories/pgsql ./database` 通过;崩溃注入的端到端恢复测试仍归 DD-023。
+- 残留(转由其他条目跟踪):
+  - **DD-008**:非崩溃的正常并发下,读取仍只比较 Mongo 侧 generation、未回读 PG 权威 generation。方向 A 消除了「崩溃后命中旧缓存」,但「读取信任 Mongo 副本而非 PG 权威」的读侧竞态需由 DD-008(缓存命中回读 PG generation)闭合。
+- 说明:本系统尚未上线、数据库无历史 DataDownload 数据,不存在旧提交顺序遗留的不一致文档,因此无需存量对账/修复任务。方向 A 自首条写入起即生效。
+
+### DD-003 FileRequestService 请求协调缺失
+
+- 状态:`resolved`
+- 解决日期:2026-09-19
+- 解决方式:新增独立 `data-download:request:{DocumentID}` 租约,RequestID 使用租约 token;等待者携带 `AwaitRequestID` 按 15/30/60 秒退避重新入队,保持 pending,不占用 worker。租约自动续期,提交前校验 owner token,完成标记通过 Lua 在仍持有租约时原子写入。Mutable 同轮等待者复用对应 RequestID 的结果;Dynamic 完成等待后重新竞争下一轮租约。
+- 验证:DataDownload 后端相关包编译和单元测试通过;跨进程 Redis/Asynq 集成场景仍归 DD-023。
+
+### DD-004 plan handler 重试不幂等
+
+- 状态:`resolved`
+- 位置:`service/task/queue/dataDownloadQueue/dataDownloadQueue.go:132-195`
+- 解决日期:2026-09-19
+- 解决方式:item 写入改为基于 `(taskId,itemKey)` 的无序 BulkWrite upsert,并回填已存在 item 的 `_id`;稳定 Asynq TaskID 冲突按“已经调度”处理。plan 和 `site_validation` 重规划均可重复执行。
+- 验证:相关包编译通过;MongoDB/Asynq 故障注入测试仍归 DD-023。
+
+### DD-005 RequestKey 维度不足
+
+- 状态:`resolved`
+- 位置:`service/services/dataDownloadService.go:168-170`
+- 解决日期:2026-09-21
+- 解决方式:新增独立的规范化 RequestKey 构造器,按数据类型选择实际影响正文身份的字段;`UpdateLog` 不带日期和时区,MQTT/EMS 按需包含 SubType,按本地日期查询或解析的类型包含实际时区,FaultRecord 使用 DeviceSN/DevType 设备级身份。每种类型使用独立 `content_version` 合并管理 query/parser/schema 语义版本,RendererVersion 不进入 RequestKey。
+- 链路:DocumentID、Redis 请求租约和 Mongo Document 均使用同一个规范化 RequestKey;任务内部 `ItemKey` 不再充当文档身份。
+- 验证:增加所有数据类型的确定性、字段隔离、转义和非法身份单元测试;DataDownload 服务、队列及 Mongo/PostgreSQL Repository 相关测试通过。
+
+### DD-006 FaultRecord 模型错误
+
+- 状态:`resolved`
+- 解决日期:2026-09-21
+- 解决方式:规划阶段按设备拆分 `DeviceSN + DevType + DataDate` item;执行阶段建立 `DataDownloadFaultRecordEntry` 多文件目录与任务级 `DataDownloadFileAttempt`,每个录波文件独立上传、发布和重试,不再创建每日 ZIP。TaskDocument 路径包含设备和日期,任务详情返回文件目录及 Attempt,并提供校验 task owner/admin 的任务入口单文件下载接口。
+- 新增决策:FaultRecord 同样执行 D+2,Frozen 作用于 `DeviceSN + DevType + DataDate` 文档单元;成功文件立即可用,文件级失败使 item/task 为 partial,旧当前文件在刷新失败时继续服务但不能计作新任务成功;上游后续未返回的历史文件继续保留;首版一键重试全部失败文件,不提供单文件人工重试。
+- 访问语义:底层 Document/File 可跨任务复用,普通用户只能通过自己的 TaskDocument 入口访问;删除任务即失去入口但不删除共享文件。MCP 使用非管理员特殊用户 `USER_AI`,AI 以数据可用性为中心查询,AI任务仅管理员可见。
+- 验收条件:支持一个日文档关联零到多个原始对象;空结果可固化;目录按设备和原始文件展示;保存文件级 Attempt;partial ZIP 包含成功文件及 `DataDownloadReport.json`;D+2 后完整查询且无失败条目才能 Frozen。
+- 验证:FaultRecord 空结果和完整结果按 D+2 规则写入 Mutable/Frozen;失败文件不切换旧 Entry,成功文件立即可读;一键重试只会再次处理未成功文件;PackageJob 收集独立对象,并在存在失败时生成含任务汇总、设备、日期、失败阶段和原因的 `DataDownloadReport.json`。相关 Service、Queue、Controller、Route、Mongo/PostgreSQL 包测试通过。
+
+## 4. P1:可靠性、资源和运维问题
+
+### DD-007 Artifact 发布缺少 CAS
+
+- 当前行为:`PublishDataDownloadArtifact` 直接按 `(documentId,format)` upsert Slot。
+- 处理建议:增加 RendererVersion、CacheKey、Slot.Generation、渲染 token 和 Artifact 状态;发布时原子校验 PG Generation 与 Slot generation。
+
+### DD-008 缓存命中未校验 PostgreSQL Generation
+
+- 当前行为:只比较 Mongo Document 和 Slot 的 Generation。
+- 影响:DD-002 的失败窗口中可能继续返回旧 Artifact。
+- 处理建议:在文档锁内读取 PG content_state,只有三者一致才允许命中。
+
+### DD-009 二进制上传孤儿对象
+
+- 位置:`service/services/dataDownloadBinaryService.go:129-160`
+- 当前行为:上传成功后 Mongo Upsert 失败会直接返回。
+- 处理建议:失败时尽力删除新对象;删除失败则登记 orphan/retiring 记录供清理任务重试。
+
+### DD-010 大对象内存放大
+
+- 位置:Renderer、BinaryService、PackageService、Controller。
+- 当前行为:HTTP 的 Package、二进制文档和缓存 RenderArtifact 已改为流式响应;Renderer 新生成内容、BinaryService 上游文件和 ZIP 构建仍使用 `[]byte`/`bytes.Buffer`。
+- 影响:多站点、600 天或大录波任务可能导致 OOM。
+- 处理建议:使用受控临时文件或流式管道;限制单条预览大小;MinIO 上传支持流和取消。
+
+### DD-011 migration 多实例并发不安全
+
+- 位置:`service/database/data_download_schema.go`
+- 当前行为:自制 migration table,没有 migration advisory lock,也没有 goose/embed。
+- 影响:两个实例首次启动可能同时判断版本缺失,其中一个因版本主键冲突启动失败。
+- 处理建议:采用 goose/embed,或至少在迁移事务前获取固定 PostgreSQL advisory lock。
+
+### DD-012 任务删除非事务化
+
+- 位置:`service/repositories/mongo/dataDownloadRepo.go` 的 `DeleteDataDownloadTask`。
+- 当前行为:依次删除 TaskDocument、Item、Task。
+- 影响:中途失败形成部分删除;状态检查与删除之间也存在竞态。
+- 处理建议:Mongo transaction,或使用 deleting 状态和可重入清理流程;删除主记录时带终态条件。
+
+### DD-013 PackageJob PurgeAt 计算错误
+
+- 状态:`resolved`
+- 位置:`service/services/dataDownloadCleanupService.go:27`
+- 解决日期:2026-09-19
+- 解决方式:清理时统一写入 `PurgeAt = ExpiresAt + 30天`,不再以清理实际执行时间为基准;下载与清理使用同一个 Package 锁,避免流式响应期间删除对象。
+
+### DD-014 MinIO context 未传播
+
+- 位置:`service/repositories/minio/dataDownloadRepo.go` 和现有 `utils/minio`。
+- 当前行为:Repository 接收 context,但底层 helper 不使用它。
+- 影响:用户取消、任务超时或服务关闭后,对象操作不能及时中断。
+
+### DD-015 归档读取缺少大小上限
+
+- 位置:`service/services/dataDownloadArchiveService.go`。
+- 当前行为:内部文件按 tar header.Size 读入内存。
+- 影响:异常或恶意归档可能消耗大量内存。
+- 处理建议:配置预览上限;超限只允许流式下载;验证 header.Size 和实际读取量。
+
+## 5. P2:功能和设计完整性问题
+
+### DD-016 PostgreSQL 结构仍是过渡态
+
+- 强类型列已经加入,但仍保留早期 `payload jsonb`、通用 UpdateLog 表和冗余字段。
+- UpdateLog device version 尚未展开为完整 Gate/子设备版本行。
+- EventAlarm 的可解析开始/结束时间列不完整。
+- 应制定新 migration,清理或明确保留兼容列,避免继续在同一个迁移版本追加语句。
+
+### DD-017 Renderer 未完整对齐
+
+- PowerFlow HTML 当前输出 JSON `<pre>`,未生成 ECharts。
+- UpdateLog XLSX 当前是单列 payload,未生成 `SystemUpdate`、`aHubUpdate` 两个 Sheet。
+- JSON 输出没有文档元信息和结构化 envelope。
+- MQTT/EventAlarm 固定列已经实现,但仍需使用真实数据回归旧导出格式。
+
+### DD-018 前端真实预览与归档浏览缺失
+
+- 当前任务和管理员页面只有下载按钮。
+- 尚无 html/csv/xls/xlsx/log/txt/json 真实预览。
+- 后端已有 tar.gz 列表/内部读取 API,但前端没有目录浏览和内部文件预览。
+
+### DD-019 批量操作和部分文件选择缺失
+
+- 后端 Package API 可以接受多个 TaskID,但页面没有任务多选。
+- 没有批量删除、目录树部分文件选择及权限不符项静默跳过。
+- 没有“解析后一个实际文件直接下载,两个以上创建异步 ZIP”的选择逻辑。
+
+### DD-020 页面过滤和权限展示不完整
+
+- 任务创建时间过滤后端已支持,但前端未接入。
+- 描述修改 API 已有,但前端未接入。
+- 查看“所有任务”时,非 owner 仍能看到取消/重试/删除按钮,只在后端拒绝。
+- Package 页面缺少状态和创建时间过滤。
+- 管理员页面缺少 Kind、SN、日期、SubType、状态过滤和完整对象清单。
+
+### DD-021 MCP 能力不完整
+
+- 当前只有任务列表、创建任务和文档分页列表。
+- 缺少文档过滤/详情/读取、tar.gz 浏览、存在性判断、AI 活跃任务覆盖判断。
+- “检查已有文档/任务后创建”没有规范化指纹或 Redis 锁,重复请求会创建重复任务。
+
+### DD-022 MongoDB 索引与设计不完全一致
+
+- 缺少 Document `{state,updatedAt}`。
+- Task 状态索引使用 `updatedAt`,设计为 `createdAt`。
+- TaskDocument 唯一键不是设计中的 `{taskId,logicalPath}`。
+- Artifact 没有唯一 CacheKey 索引。
+- Package 缺少 `{status,createdAt}` 和 `expiresAt` 索引。
+- 所有索引尚未设置固定名称,不利于显式迁移和审计。
+
+### DD-023 自动化测试不足
+
+至少需要增加:
+
+- IANA 时区、固定半小时偏移、DST 跨越和 D+2 边界测试;
+- Mutable 同轮合并、后续刷新、Frozen 复用、Dynamic 串行测试;
+- 锁续租、租约丢失、旧 worker 晚提交和崩溃恢复测试;
+- PostgreSQL 全量替换、Generation、空正文和回滚测试;
+- RenderSlot 失效/发布竞态与 3 天过期测试;
+- 取消、删除、人工重试、Package 7/30 天生命周期和权限测试;
+- MongoDB/PostgreSQL/Redis/MinIO 集成测试。
+
+## 6. P3:阻断全仓测试的既有源码问题
+
+这些问题不是 DataDownload 引入,但当前会使 `go test ./...` 失败。
+
+### REPO-001 PostgreSQL model 语法错误
+
+- 文件:`service/models/model_pgsql/siteOriginalData.go:54`
+- 问题:Go 语法错误,编译器报告 `unexpected keyword type`。
+
+### REPO-002 FWH 代码生成命令引用失效配置
+
+- 文件:`service/cmd/gen/fwh_pro.go`
+- 问题:引用不存在的 `cfg.MySQLDSN`。
+
+### REPO-003 PostgreSQL 代码生成命令引用失效配置
+
+- 文件:`service/cmd/gen/pgsql/alarm_snapshot.go`
+- 问题:引用不存在的 `cfg.PostgresDSN`。
+
+### REPO-004 多个旧命令/测试调用旧 logger.Init 签名
+
+- 范围:`service/cmd/test_plane_v2`、`service/repositories/mqtt/fwh_mqtt_test/test`、`service/test/shutdownStatistics`、`service/test/snapTest`、`service/test/getSnapTest`、`service/test/temp` 等。
+- 问题:仍只传一个参数;当前签名要求环境和日志级别两个参数。
+
+### REPO-005 getApowerSn 对指针切片直接 range
+
+- 文件:`service/test/getApowerSn/getApowerSn.go`
+- 问题:`res.Result` 为 `*[]domain.FwhSiteInfo`,需要先判空并解引用。
+
+### REPO-006 getSnapTest 调用已删除函数
+
+- 文件:`service/test/getSnapTest/getAlarmSnapshotTest.go`
+- 问题:调用不存在的 `jobs.GetAlarmSnapshot`。
+
+### REPO-007 temp 测试使用过期函数签名
+
+- 文件:`service/test/temp/runtimeSort.go`
+- 问题:`GetSiteAnalysisManageList` 的调用参数数量与当前签名不一致。
+
+## 7. 建议处理批次
+
+1. **并发正确性批次**:DD-001、DD-007、DD-008;DD-002、DD-003、DD-004、DD-005 已解决。
+2. **存储模型批次**:DD-006、DD-009、DD-011、DD-016、DD-022。
+3. **资源安全批次**:DD-010、DD-014、DD-015;DD-013 已解决。
+4. **产品补全批次**:DD-017~DD-021。
+5. **测试与仓库健康批次**:DD-023、REPO-001~REPO-007。

+ 117 - 8
service/cache/redisx/lockx.go

@@ -3,18 +3,41 @@ package redisx
 import (
 	"context"
 	"errors"
+	"fmt"
+	"strings"
 	"time"
 
 	"github.com/google/uuid"
 	"github.com/redis/go-redis/v9"
+	"go.uber.org/zap"
 )
 
 // 分布式锁
 
+// errLockNotOwner 表示当前实例已不再持有该锁(锁已过期或被他人抢占)。
+// 用哨兵错误以便 errors.Is 判定,供观测日志区分“锁被抢走”与其他失败。
+var errLockNotOwner = errors.New("lock not owner")
+
 type RedisLock struct {
-	key   string
-	token string
-	ttl   time.Duration
+	key      string
+	token    string
+	ttl      time.Duration
+	acquired time.Time // 加锁成功的时刻,用于统计持锁时长(配合 time.Since 使用,单调)
+	label    string    // 业务标签,从 key 推断,便于按锁类型聚合观测日志
+}
+
+// lockLabel 从 key 推断业务标签,便于日志聚合。
+// 约定 key 形如 "<domain>:<category>:<id>",取前两段,例如
+// "data-download:content:xxx" -> "data-download:content"。无法识别时返回 "unknown"。
+func lockLabel(key string) string {
+	parts := strings.SplitN(key, ":", 3)
+	if len(parts) >= 2 {
+		return parts[0] + ":" + parts[1]
+	}
+	if len(parts) == 1 && parts[0] != "" {
+		return parts[0]
+	}
+	return "unknown"
 }
 
 // TryLock 非阻塞尝试加锁
@@ -27,7 +50,7 @@ func TryLock(ctx context.Context, key string, ttl time.Duration) (*RedisLock, bo
 	if !ok {
 		return nil, false, nil
 	}
-	return &RedisLock{key: key, token: token, ttl: ttl}, true, nil
+	return &RedisLock{key: key, token: token, ttl: ttl, acquired: time.Now(), label: lockLabel(key)}, true, nil
 }
 
 // Lock 阻塞加锁,可设置重试间隔和超时
@@ -66,7 +89,7 @@ end
 		return err
 	}
 	if res == 0 {
-		return errors.New("unlock failed: not owner")
+		return fmt.Errorf("unlock failed: %w", errLockNotOwner)
 	}
 	return nil
 }
@@ -85,14 +108,100 @@ end
 		return err
 	}
 	if res == 0 {
-		return errors.New("unlock failed: not owner")
+		return fmt.Errorf("unlock failed: %w", errLockNotOwner)
 	}
 	return nil
 }
 
-// SafeRelease 带日志保护版本
+// SafeRelease 带日志保护版本。
+//
+// 除了尽力释放锁外,它还承担 DD-001 的观测职责:
+//   - 若释放时发现锁已不属于自己("not owner"),说明临界区耗时超过 TTL、
+//     锁在持有期间被别的持有者抢走,可能已发生并发覆盖,记为 Error(关键信号)。
+//   - 若正常释放但持锁时长已逼近 TTL(>70%),记为 Warn(风险预警)。
+//   - 其他释放错误记为 Error。
+// 该方法不改变控制流,也不返回错误,调用方无感知。
 func (l *RedisLock) SafeRelease(ctx context.Context) {
-	_ = l.Release(ctx)
+	held := time.Since(l.acquired)
+	err := l.Release(ctx)
+	switch {
+	case err == nil:
+		if l.ttl > 0 && held > l.ttl*7/10 {
+			zap.L().Warn("redis lock held close to ttl",
+				zap.String("label", l.label),
+				zap.String("key", l.key),
+				zap.Duration("held", held),
+				zap.Duration("ttl", l.ttl))
+		}
+	case errors.Is(err, errLockNotOwner):
+		// 关键信号:干完活时锁已不是自己的,临界区超时被抢,可能已并发覆盖。
+		zap.L().Error("redis lock lost before release (possible concurrent overwrite)",
+			zap.String("label", l.label),
+			zap.String("key", l.key),
+			zap.String("token", l.token),
+			zap.Duration("held", held),
+			zap.Duration("ttl", l.ttl))
+	default:
+		zap.L().Error("redis lock release failed",
+			zap.String("label", l.label),
+			zap.String("key", l.key),
+			zap.Duration("held", held),
+			zap.Error(err))
+	}
+}
+
+// Extend renews the lock only while this instance still owns it.
+func (l *RedisLock) Extend(ctx context.Context, ttl time.Duration) error {
+	const luaScript = `
+if redis.call("get", KEYS[1]) == ARGV[1] then
+	return redis.call("pexpire", KEYS[1], ARGV[2])
+else
+	return 0
+end
+`
+	res, err := redis.NewScript(luaScript).Run(ctx, getClient(), []string{l.key}, l.token, ttl.Milliseconds()).Int()
+	if err != nil {
+		return err
+	}
+	if res == 0 {
+		return fmt.Errorf("extend failed: %w", errLockNotOwner)
+	}
+	l.ttl = ttl
+	return nil
+}
+
+// IsOwner reports whether this instance still owns the lock.
+func (l *RedisLock) IsOwner(ctx context.Context) (bool, error) {
+	value, err := getClient().Get(ctx, l.key).Result()
+	if err == redis.Nil {
+		return false, nil
+	}
+	if err != nil {
+		return false, err
+	}
+	return value == l.token, nil
+}
+
+// SetIfOwner writes a completion marker only while this instance still owns
+// the lock. It is used by lease-based workflows to avoid publishing a stale
+// worker's result after its lease has expired.
+func (l *RedisLock) SetIfOwner(ctx context.Context, key, value string, ttl time.Duration) error {
+	const luaScript = `
+if redis.call("get", KEYS[1]) == ARGV[1] then
+	redis.call("set", KEYS[2], ARGV[2], "PX", ARGV[3])
+	return 1
+else
+	return 0
+end
+`
+	res, err := redis.NewScript(luaScript).Run(ctx, getClient(), []string{l.key, key}, l.token, value, ttl.Milliseconds()).Int()
+	if err != nil {
+		return err
+	}
+	if res == 0 {
+		return fmt.Errorf("commit failed: %w", errLockNotOwner)
+	}
+	return nil
 }
 
 func (l *RedisLock) GetKey() string {

+ 8 - 1
service/cmd/mcp/main.go

@@ -1,9 +1,11 @@
 package main
 
 import (
+	"context"
 	"fmt"
 	"os"
 
+	"github.com/hibiken/asynq"
 	"github.com/mark3labs/mcp-go/server"
 	"go.uber.org/zap"
 	"go.uber.org/zap/zapcore"
@@ -13,8 +15,9 @@ import (
 	"fwh-toolbox-server/database"
 	"fwh-toolbox-server/logger"
 	"fwh-toolbox-server/repositories"
-	minioUtil "fwh-toolbox-server/utils/minio"
+	"fwh-toolbox-server/task/queue/dataDownloadQueue"
 	"fwh-toolbox-server/utils/agentfiles"
+	minioUtil "fwh-toolbox-server/utils/minio"
 
 	"fwh-toolbox-server/cmd/mcp/tools"
 	"fwh-toolbox-server/cmd/mcp/tools/middleware"
@@ -44,6 +47,10 @@ func main() {
 	}
 	cache.Init(cfg.App.RedisAddr)
 	minioUtil.InitClient(&cfg.App)
+	if err := repositories.Init(context.Background()); err != nil {
+		zap.L().Fatal("Repository 初始化失败", zap.Error(err))
+	}
+	dataDownloadQueue.InitClient(asynq.RedisClientOpt{Addr: cfg.App.RedisAddr, DB: 12})
 
 	// 初始化 FWH Pro API 客户端(注入 host/账号,否则请求 URL 无 host)
 	repositories.FwhHttpInit(&cfg.FwhApi)

+ 1 - 1
service/cmd/mcp/test/runner/cases.go

@@ -411,7 +411,7 @@ func runCascadeCases(ctx context.Context, c *client.Client, byName map[string]mc
 		{"L2C-9", "data_sim_acceleronix_card_list", map[string]any{"page": 1, "size": 1}, "data.list[0].iccid", "data_sim_acceleronix_card_info", "iccid", nil},
 		{"L2C-10", "data_sim_acceleronix_data_pool_list", map[string]any{"page": 1, "size": 1}, "data.list[0].dataPoolId", "data_sim_acceleronix_data_pool_sim_info", "data_pool_id", nil},
 		// system_update_log(sample SN) → detail(order_id=id)
-		{"L2C-11", "data_fwh_query_system_update_log", map[string]any{"sn": sampleSN}, "[0].id", "data_fwh_query_system_update_log_detail", "order_id", nil},
+		{"L2C-11", "data_fwh_query_system_update_log", map[string]any{"sn": sampleSN}, "[0].id", "data_fwh_query_system_update_log_detail", "order_id", map[string]any{"sn": sampleSN}},
 		// minio: list_objects → read_object
 		{"L2C-12", "storage_minio_list_objects", map[string]any{"bucket": "fwh-toolbox", "limit": 20}, "objects[0].key", "storage_minio_read_object", "key", map[string]any{"bucket": "fwh-toolbox"}},
 		// plane issues: list → get_issue / sub_issues / activity(若 results 非空)

+ 7 - 1
service/cmd/mcp/tools/data/fwh_api.go

@@ -182,6 +182,7 @@ func registerFwhApiTools(s *server.MCPServer) {
 		mcp.NewTool("data_fwh_query_system_update_log_detail",
 			mcp.WithDescription("查询系统升级记录详细信息(FWH Pro API)"),
 			mcp.WithString("order_id", mcp.Required(), mcp.Description("升级单ID")),
+			mcp.WithString("sn", mcp.Required(), mcp.Description("站点SN")),
 		),
 		handleFwhQuerySystemUpdateLogDetail,
 	)
@@ -583,7 +584,12 @@ func handleFwhQuerySystemUpdateLogDetail(ctx context.Context, req mcp.CallToolRe
 		return helper.ParamInvalidError("order_id", "必须是数字"), nil
 	}
 
-	data, err := repositories.QuerySystemUpdateLogDetail(orderId)
+	sn, err := req.RequireString("sn")
+	if err != nil {
+		return helper.ParamError("sn"), nil
+	}
+
+	data, err := repositories.QuerySystemUpdateLogDetail(orderId, sn)
 	if err != nil {
 		return helper.ServiceError("查询升级详情", err), nil
 	}

+ 95 - 0
service/cmd/mcp/tools/service/data_download.go

@@ -0,0 +1,95 @@
+package service
+
+import (
+	"context"
+	"encoding/json"
+
+	"fwh-toolbox-server/cmd/mcp/tools/helper"
+	"fwh-toolbox-server/domain"
+	mongoRepo "fwh-toolbox-server/repositories/mongo"
+	"fwh-toolbox-server/services"
+	"fwh-toolbox-server/task/queue/dataDownloadQueue"
+
+	"github.com/mark3labs/mcp-go/mcp"
+	"github.com/mark3labs/mcp-go/server"
+)
+
+const dataDownloadMCPOwner = "USER_AI"
+
+func registerDataDownloadTools(s *server.MCPServer) {
+	s.AddTool(mcp.NewTool("service_data_download_list",
+		mcp.WithDescription("查询新 DataDownload 任务(不访问旧 BatchDownload)"),
+		mcp.WithNumber("page", mcp.Description("页码,默认1")),
+		mcp.WithNumber("size", mcp.Description("每页数量,默认20")),
+		mcp.WithString("description", mcp.Description("描述,不区分大小写字面量包含")),
+		mcp.WithString("sn", mcp.Description("站点 SN")),
+		mcp.WithString("status", mcp.Description("任务状态"))), handleDataDownloadList)
+	s.AddTool(mcp.NewTool("service_data_download_create",
+		mcp.WithDescription("创建新 DataDownload 任务 [写操作]"),
+		mcp.WithArray("sn", mcp.WithStringItems(), mcp.Description("站点 SN 列表")),
+		mcp.WithString("sn_file", mcp.Description("位于 Agent 目录中的 SN 文件")),
+		mcp.WithString("start_date", mcp.Description("开始日期 yyyy-MM-dd;仅 UpdateLog 时可省略")),
+		mcp.WithString("end_date", mcp.Description("结束日期 yyyy-MM-dd;仅 UpdateLog 时可省略")),
+		mcp.WithString("options", mcp.Required(), mcp.Description("DataDownloadOptions JSON")),
+		mcp.WithString("description", mcp.Description("任务描述"))), handleDataDownloadCreate)
+	s.AddTool(mcp.NewTool("service_data_download_documents",
+		mcp.WithDescription("查询 DataDownload 逻辑文档目录"),
+		mcp.WithNumber("page", mcp.Description("页码,默认1")),
+		mcp.WithNumber("size", mcp.Description("每页数量,默认20"))), handleDataDownloadDocuments)
+}
+
+func handleDataDownloadList(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
+	page, size := dataDownloadMCPPage(req)
+	tasks, total, err := mongoRepo.ListDataDownloadTasks(ctx, dataDownloadMCPOwner, true, req.GetString("description", ""), req.GetString("sn", ""), req.GetString("status", ""), nil, nil, page, size)
+	if err != nil {
+		return helper.ServiceError("查询 DataDownload", err), nil
+	}
+	return helper.PageResult(uint(total), uint(size), uint(page), tasks, "data_download_list"), nil
+}
+
+func handleDataDownloadCreate(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
+	resolved, errResult := helper.ResolveStringListOrFile(req, "sn", "sn_file")
+	if errResult != nil {
+		return errResult, nil
+	}
+	if len(resolved.Items) == 0 {
+		return helper.ParamInvalidError("sn", "至少提供一个 SN"), nil
+	}
+	optionsRaw, err := req.RequireString("options")
+	if err != nil {
+		return helper.ParamError("options"), nil
+	}
+	var options domain.DataDownloadOptions
+	if err := json.Unmarshal([]byte(optionsRaw), &options); err != nil {
+		return helper.ParamInvalidError("options", err.Error()), nil
+	}
+	task := &domain.DataDownloadTask{OwnerID: dataDownloadMCPOwner, OwnerName: "AI Assistant", SNs: resolved.Items, Description: req.GetString("description", ""), StartDate: req.GetString("start_date", ""), EndDate: req.GetString("end_date", ""), Options: options}
+	if err := services.CreateDataDownloadTask(ctx, task); err != nil {
+		return helper.ServiceError("创建 DataDownload", err), nil
+	}
+	if err := dataDownloadQueue.EnqueuePlan(task.ID); err != nil {
+		_ = mongoRepo.UpdateDataDownloadTaskStatus(context.Background(), task.ID, domain.DataDownloadError)
+		return helper.ServiceError("DataDownload 入队", err), nil
+	}
+	return helper.JSONResult(map[string]any{"created": true, "taskId": task.ID.Hex(), "owner": dataDownloadMCPOwner}), nil
+}
+
+func handleDataDownloadDocuments(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
+	page, size := dataDownloadMCPPage(req)
+	documents, total, err := mongoRepo.ListDataDownloadDocuments(ctx, page, size)
+	if err != nil {
+		return helper.ServiceError("查询 DataDownload 文档", err), nil
+	}
+	return helper.PageResult(uint(total), uint(size), uint(page), documents, "data_download_documents"), nil
+}
+
+func dataDownloadMCPPage(req mcp.CallToolRequest) (int64, int64) {
+	page, size := int64(req.GetFloat("page", 1)), int64(req.GetFloat("size", 20))
+	if page < 1 {
+		page = 1
+	}
+	if size < 1 || size > 200 {
+		size = 20
+	}
+	return page, size
+}

+ 1 - 0
service/cmd/mcp/tools/service/register.go

@@ -16,4 +16,5 @@ func RegisterServiceTools(s *server.MCPServer) {
 	registerAutoUpdateTools(s)
 	registerBatchDownloadTools(s)
 	registerSiteAnalysisTools(s)
+	registerDataDownloadTools(s)
 }

+ 685 - 0
service/controllers/dataDownloadControllers.go

@@ -0,0 +1,685 @@
+package controllers
+
+import (
+	"context"
+	"fmt"
+	"io"
+	"path"
+	"strconv"
+	"strings"
+	"time"
+
+	"fwh-toolbox-server/cache"
+	"fwh-toolbox-server/domain"
+	"fwh-toolbox-server/middleware"
+	minioRepo "fwh-toolbox-server/repositories/minio"
+	mongoRepo "fwh-toolbox-server/repositories/mongo"
+	"fwh-toolbox-server/services"
+	"fwh-toolbox-server/task/queue/dataDownloadQueue"
+
+	"github.com/gofiber/fiber/v2"
+	"go.mongodb.org/mongo-driver/bson"
+	"go.mongodb.org/mongo-driver/bson/primitive"
+	"go.uber.org/zap"
+)
+
+type dataDownloadLockedReader struct {
+	io.ReadCloser
+	lock   *cache.RedisLock
+	cancel context.CancelFunc
+	done   chan struct{}
+}
+
+func (r *dataDownloadLockedReader) Close() error {
+	r.cancel()
+	<-r.done
+	err := r.ReadCloser.Close()
+	r.lock.SafeRelease(context.Background())
+	return err
+}
+
+func newDataDownloadLockedReader(reader io.ReadCloser, lock *cache.RedisLock) *dataDownloadLockedReader {
+	ctx, cancel := context.WithCancel(context.Background())
+	result := &dataDownloadLockedReader{ReadCloser: reader, lock: lock, cancel: cancel, done: make(chan struct{})}
+	go func() {
+		defer close(result.done)
+		ticker := time.NewTicker(3 * time.Minute)
+		defer ticker.Stop()
+		for {
+			select {
+			case <-ctx.Done():
+				return
+			case <-ticker.C:
+				renewCtx, renewCancel := context.WithTimeout(context.Background(), 10*time.Second)
+				_ = lock.Extend(renewCtx, 10*time.Minute)
+				renewCancel()
+			}
+		}
+	}()
+	return result
+}
+
+func CreateDataDownload(c *fiber.Ctx) error {
+	var body struct {
+		SNs         []string                   `json:"sns"`
+		SNText      string                     `json:"snText"`
+		Description string                     `json:"description"`
+		StartDate   string                     `json:"startDate"`
+		EndDate     string                     `json:"endDate"`
+		Options     domain.DataDownloadOptions `json:"options"`
+	}
+	if err := c.BodyParser(&body); err != nil {
+		return Fail(c, "invalid request body")
+	}
+	if body.SNText != "" {
+		body.SNs = append(body.SNs, strings.FieldsFunc(body.SNText, func(r rune) bool { return r == ',' || r == '\n' || r == '\r' || r == '\t' || r == ' ' })...)
+	}
+	user := middleware.AuthGetUserInfo(c)
+	ownerID := user.Id
+	if ownerID == "" {
+		ownerID = user.Name
+	}
+	task := &domain.DataDownloadTask{OwnerID: ownerID, OwnerName: user.DisplayName, SNs: body.SNs, Description: body.Description, StartDate: body.StartDate, EndDate: body.EndDate, Options: body.Options}
+	if err := services.CreateDataDownloadTask(c.UserContext(), task); err != nil {
+		return Fail(c, err.Error())
+	}
+	if err := dataDownloadQueue.EnqueuePlan(task.ID); err != nil {
+		_ = mongoRepo.UpdateDataDownloadTaskStatus(context.Background(), task.ID, domain.DataDownloadError)
+		return Error(c, "task created but could not be queued: "+err.Error())
+	}
+	zap.L().Info("[DataDownload] API 创建任务",
+		zap.String("taskId", task.ID.Hex()),
+		zap.String("owner", ownerID),
+		zap.Int("snCount", len(task.SNs)),
+		zap.String("startDate", task.StartDate),
+		zap.String("endDate", task.EndDate),
+		zap.Any("options", task.Options),
+	)
+	return Success(c, task)
+}
+
+func GetDataDownloadDocumentFile(c *fiber.Ctx) error {
+	if !middleware.AuthIsAdmin(c) {
+		return Forbidden(c)
+	}
+	return sendDataDownloadDocumentFile(c, c.Params("documentId"))
+}
+
+func DownloadDataDownloadTaskDocument(c *fiber.Ctx) error {
+	taskID, err := primitive.ObjectIDFromHex(c.Params("id"))
+	if err != nil {
+		return Fail(c, "invalid task id")
+	}
+	task, err := mongoRepo.GetDataDownloadTask(c.UserContext(), taskID)
+	if err != nil {
+		return NotFound(c)
+	}
+	if !canAccessDataDownloadTask(c, task) {
+		return Forbidden(c)
+	}
+	documentID := c.Params("documentId")
+	linked, err := mongoRepo.HasDataDownloadTaskDocument(c.UserContext(), taskID, documentID)
+	if err != nil {
+		return Error(c, err.Error())
+	}
+	if !linked {
+		return NotFound(c)
+	}
+	return sendDataDownloadDocumentFile(c, documentID)
+}
+
+func sendDataDownloadDocumentFile(c *fiber.Ctx, documentID string) error {
+	document, err := mongoRepo.GetDataDownloadDocument(c.UserContext(), documentID)
+	if err != nil {
+		return NotFound(c)
+	}
+	if document.BinaryObjectKey != "" {
+		lock, err := cache.Lock(c.UserContext(), "data-download:content:"+document.ID, 10*time.Minute, 100*time.Millisecond, 10*time.Second)
+		if err != nil {
+			return Error(c, "document is being updated")
+		}
+		document, err = mongoRepo.GetDataDownloadDocument(c.UserContext(), document.ID)
+		if err != nil {
+			lock.SafeRelease(context.Background())
+			return NotFound(c)
+		}
+		reader, err := minioRepo.Open(c.UserContext(), document.BinaryObjectKey)
+		if err != nil {
+			lock.SafeRelease(context.Background())
+			return Error(c, err.Error())
+		}
+		c.Set("Content-Disposition", "attachment; filename=\""+path.Base(document.BinaryObjectKey)+"\"")
+		return c.SendStream(newDataDownloadLockedReader(reader, lock))
+	}
+	if document.Kind == domain.DataDownloadFaultRecord {
+		return Fail(c, "fault-record documents contain multiple files; select a file entry")
+	}
+	artifact, reader, err := services.OpenRenderedDataDownloadDocument(c.UserContext(), documentID, c.Query("format"))
+	if err != nil {
+		return Fail(c, err.Error())
+	}
+	c.Set("Content-Disposition", "attachment; filename=\""+artifact.Filename+"\"")
+	switch artifact.Format {
+	case "json":
+		c.Type("json")
+	case "html":
+		c.Type("html")
+	case "csv":
+		c.Type("csv")
+	case "xlsx":
+		c.Type("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")
+	}
+	return c.SendStream(reader)
+}
+
+func GetDataDownloadDocuments(c *fiber.Ctx) error {
+	if !middleware.AuthIsAdmin(c) {
+		return Forbidden(c)
+	}
+	page, _ := strconv.ParseInt(c.Query("pageNum", "1"), 10, 64)
+	pageSize, _ := strconv.ParseInt(c.Query("pageSize", "20"), 10, 64)
+	if page < 1 || pageSize < 1 || pageSize > 200 {
+		return Fail(c, "invalid pagination")
+	}
+	documents, total, err := mongoRepo.ListDataDownloadDocuments(c.UserContext(), page, pageSize)
+	if err != nil {
+		return Error(c, err.Error())
+	}
+	return Success(c, fiber.Map{"items": documents, "total": total, "pageNum": page, "pageSize": pageSize})
+}
+
+func GetDataDownloadDocumentReferences(c *fiber.Ctx) error {
+	if !middleware.AuthIsAdmin(c) {
+		return Forbidden(c)
+	}
+	count, err := mongoRepo.CountDataDownloadDocumentReferences(c.UserContext(), c.Params("documentId"))
+	if err != nil {
+		return Error(c, err.Error())
+	}
+	return Success(c, fiber.Map{"count": count})
+}
+
+func GetDataDownloadDocumentFiles(c *fiber.Ctx) error {
+	if !middleware.AuthIsAdmin(c) {
+		return Forbidden(c)
+	}
+	entries, err := mongoRepo.ListDataDownloadFaultRecordEntries(c.UserContext(), c.Params("documentId"))
+	if err != nil {
+		return Error(c, err.Error())
+	}
+	return Success(c, entries)
+}
+
+func DownloadDataDownloadDocumentEntry(c *fiber.Ctx) error {
+	if !middleware.AuthIsAdmin(c) {
+		return Forbidden(c)
+	}
+	entryID, err := primitive.ObjectIDFromHex(c.Params("entryId"))
+	if err != nil {
+		return Fail(c, "invalid file id")
+	}
+	entry, err := mongoRepo.GetDataDownloadFaultRecordEntryByID(c.UserContext(), entryID)
+	if err != nil || entry.DocumentID != c.Params("documentId") {
+		return NotFound(c)
+	}
+	return sendDataDownloadFaultRecordEntry(c, entry)
+}
+
+func DeleteDataDownloadDocument(c *fiber.Ctx) error {
+	if !middleware.AuthIsAdmin(c) {
+		return Forbidden(c)
+	}
+	if err := services.DeleteDataDownloadDocument(c.UserContext(), c.Params("documentId"), c.Query("force") == "true"); err != nil {
+		return Fail(c, err.Error())
+	}
+	return NoContent(c)
+}
+
+func GetDataDownloadArchiveEntries(c *fiber.Ctx) error {
+	if !middleware.AuthIsAdmin(c) {
+		return Forbidden(c)
+	}
+	entries, err := services.ListDataDownloadArchive(c.UserContext(), c.Params("documentId"))
+	if err != nil {
+		return Fail(c, err.Error())
+	}
+	return Success(c, entries)
+}
+
+func GetDataDownloadArchiveEntry(c *fiber.Ctx) error {
+	if !middleware.AuthIsAdmin(c) {
+		return Forbidden(c)
+	}
+	entry := c.Query("entry")
+	if entry == "" {
+		return Fail(c, "entry is required")
+	}
+	data, err := services.ReadDataDownloadArchiveEntry(c.UserContext(), c.Params("documentId"), entry)
+	if err != nil {
+		return Fail(c, err.Error())
+	}
+	c.Set("Content-Disposition", "attachment; filename=\""+path.Base(entry)+"\"")
+	return c.Send(data)
+}
+
+func GetDataDownloadTasks(c *fiber.Ctx) error {
+	page, _ := strconv.ParseInt(c.Query("pageNum", "1"), 10, 64)
+	pageSize, _ := strconv.ParseInt(c.Query("pageSize", "20"), 10, 64)
+	if page < 1 || pageSize < 1 || pageSize > 200 {
+		return Fail(c, "invalid pagination")
+	}
+	user := middleware.AuthGetUserInfo(c)
+	ownerID := user.Id
+	if ownerID == "" {
+		ownerID = user.Name
+	}
+	parseTime := func(value string) *time.Time {
+		if value == "" {
+			return nil
+		}
+		parsed, err := time.Parse(time.RFC3339, value)
+		if err != nil {
+			return nil
+		}
+		return &parsed
+	}
+	mineOnly := !user.IsAdmin || c.Query("scope", "mine") == "mine"
+	tasks, total, err := mongoRepo.ListDataDownloadTasks(c.UserContext(), ownerID, mineOnly, c.Query("description"), c.Query("sn"), c.Query("status"), parseTime(c.Query("createdFrom")), parseTime(c.Query("createdTo")), page, pageSize)
+	if err != nil {
+		return Error(c, err.Error())
+	}
+	return Success(c, fiber.Map{"items": tasks, "total": total, "pageNum": page, "pageSize": pageSize})
+}
+
+func GetDataDownloadTask(c *fiber.Ctx) error {
+	id, err := primitive.ObjectIDFromHex(c.Params("id"))
+	if err != nil {
+		return Fail(c, "invalid task id")
+	}
+	task, err := mongoRepo.GetDataDownloadTask(c.UserContext(), id)
+	if err != nil {
+		return NotFound(c)
+	}
+	if !canAccessDataDownloadTask(c, task) {
+		return Forbidden(c)
+	}
+	items, err := mongoRepo.ListDataDownloadItems(c.UserContext(), id)
+	if err != nil {
+		return Error(c, err.Error())
+	}
+	documents, err := mongoRepo.ListDataDownloadTaskDocuments(c.UserContext(), id)
+	if err != nil {
+		return Error(c, err.Error())
+	}
+	catalog := make([]*domain.DataDownloadDocument, 0, len(documents))
+	faultFiles := make(map[string][]domain.DataDownloadFaultRecordEntry)
+	for i := range documents {
+		if document, getErr := mongoRepo.GetDataDownloadDocument(c.UserContext(), documents[i].DocumentID); getErr == nil {
+			catalog = append(catalog, document)
+			if document.Kind == domain.DataDownloadFaultRecord {
+				if entries, listErr := mongoRepo.ListDataDownloadFaultRecordEntries(c.UserContext(), document.ID); listErr == nil {
+					faultFiles[document.ID] = entries
+				}
+			}
+		}
+	}
+	attempts, err := mongoRepo.ListDataDownloadFileAttempts(c.UserContext(), id)
+	if err != nil {
+		return Error(c, err.Error())
+	}
+	return Success(c, fiber.Map{"task": task, "items": items, "documents": documents, "catalog": catalog, "faultFiles": faultFiles, "fileAttempts": attempts})
+}
+
+func DownloadDataDownloadTaskFile(c *fiber.Ctx) error {
+	taskID, err := primitive.ObjectIDFromHex(c.Params("id"))
+	if err != nil {
+		return Fail(c, "invalid task id")
+	}
+	task, err := mongoRepo.GetDataDownloadTask(c.UserContext(), taskID)
+	if err != nil {
+		return NotFound(c)
+	}
+	if !canAccessDataDownloadTask(c, task) {
+		return Forbidden(c)
+	}
+	entryID, err := primitive.ObjectIDFromHex(c.Params("entryId"))
+	if err != nil {
+		return Fail(c, "invalid file id")
+	}
+	entry, err := mongoRepo.GetDataDownloadFaultRecordEntryByID(c.UserContext(), entryID)
+	if err != nil {
+		return NotFound(c)
+	}
+	linked, err := mongoRepo.HasDataDownloadTaskDocument(c.UserContext(), taskID, entry.DocumentID)
+	if err != nil {
+		return Error(c, err.Error())
+	}
+	if !linked {
+		return NotFound(c)
+	}
+	return sendDataDownloadFaultRecordEntry(c, entry)
+}
+
+func sendDataDownloadFaultRecordEntry(c *fiber.Ctx, entry *domain.DataDownloadFaultRecordEntry) error {
+	lock, err := cache.Lock(c.UserContext(), "data-download:content:"+entry.DocumentID, 10*time.Minute, 100*time.Millisecond, 10*time.Second)
+	if err != nil {
+		return Error(c, "document is being updated")
+	}
+	entry, err = mongoRepo.GetDataDownloadFaultRecordEntryByID(c.UserContext(), entry.ID)
+	if err != nil {
+		lock.SafeRelease(context.Background())
+		return NotFound(c)
+	}
+	reader, err := minioRepo.Open(c.UserContext(), entry.ObjectKey)
+	if err != nil {
+		lock.SafeRelease(context.Background())
+		return Error(c, err.Error())
+	}
+	c.Set("Content-Disposition", "attachment; filename=\""+path.Base(entry.FileName)+"\"")
+	if entry.ContentType != "" {
+		c.Set("Content-Type", entry.ContentType)
+	}
+	return c.SendStream(newDataDownloadLockedReader(reader, lock))
+}
+
+func UpdateDataDownloadDescription(c *fiber.Ctx) error {
+	id, task, ok := mutableDataDownloadTask(c)
+	if !ok {
+		return nil
+	}
+	var body struct {
+		Description string `json:"description"`
+	}
+	if err := c.BodyParser(&body); err != nil {
+		return Fail(c, "invalid request body")
+	}
+	_ = task
+	if err := mongoRepo.UpdateDataDownloadDescription(c.UserContext(), id, body.Description); err != nil {
+		return Error(c, err.Error())
+	}
+	return Success(c, body)
+}
+
+func CancelDataDownload(c *fiber.Ctx) error {
+	id, task, ok := mutableDataDownloadTask(c)
+	if !ok {
+		return nil
+	}
+	if task.Status != domain.DataDownloadPending && task.Status != domain.DataDownloadRunning {
+		return Fail(c, "only pending or running tasks can be cancelled")
+	}
+	if err := mongoRepo.UpdateDataDownloadTaskStatus(c.UserContext(), id, domain.DataDownloadCancelling); err != nil {
+		return Error(c, err.Error())
+	}
+	zap.L().Info("[DataDownload] API 取消任务", zap.String("taskId", id.Hex()))
+	return Success(c, fiber.Map{"id": id.Hex(), "status": domain.DataDownloadCancelling})
+}
+
+func RetryDataDownload(c *fiber.Ctx) error {
+	id, task, ok := mutableDataDownloadTask(c)
+	if !ok {
+		return nil
+	}
+	if task.Status == domain.DataDownloadCancelled {
+		return Fail(c, "cancelled tasks cannot be retried")
+	}
+	if task.Status != domain.DataDownloadFailed && task.Status != domain.DataDownloadPartial && task.Status != domain.DataDownloadError {
+		return Fail(c, "task has no retryable failures")
+	}
+	items, err := mongoRepo.ListDataDownloadItems(c.UserContext(), id)
+	if err != nil {
+		return Error(c, err.Error())
+	}
+	queued := 0
+	for i := range items {
+		if items[i].Status != domain.DataDownloadFailed && items[i].Status != domain.DataDownloadPartial {
+			continue
+		}
+		if items[i].Kind == domain.DataDownloadFaultRecord && items[i].CallRequestedAt != nil {
+			if err := mongoRepo.ResetDataDownloadFaultCall(c.UserContext(), items[i].ID); err != nil {
+				continue
+			}
+		}
+		if err := mongoRepo.UpdateDataDownloadItem(c.UserContext(), items[i].ID, domain.DataDownloadPending, "", ""); err == nil {
+			if err := dataDownloadQueue.EnqueueItem(items[i].ID); err == nil {
+				queued++
+			}
+		}
+	}
+	if queued == 0 {
+		return Fail(c, "no retryable item could be queued")
+	}
+	_ = mongoRepo.UpdateDataDownloadTaskStatus(c.UserContext(), id, domain.DataDownloadRunning)
+	zap.L().Info("[DataDownload] API 重试任务", zap.String("taskId", id.Hex()), zap.Int("queued", queued))
+	return Success(c, fiber.Map{"queued": queued})
+}
+
+func DeleteDataDownload(c *fiber.Ctx) error {
+	id, task, ok := mutableDataDownloadTask(c)
+	if !ok {
+		return nil
+	}
+	if task.Status == domain.DataDownloadPending || task.Status == domain.DataDownloadRunning || task.Status == domain.DataDownloadCancelling {
+		return Fail(c, "active task must be cancelled before deletion")
+	}
+	if err := mongoRepo.DeleteDataDownloadTask(c.UserContext(), id); err != nil {
+		return Error(c, err.Error())
+	}
+	zap.L().Info("[DataDownload] API 删除任务", zap.String("taskId", id.Hex()))
+	return NoContent(c)
+}
+
+func mutableDataDownloadTask(c *fiber.Ctx) (primitive.ObjectID, *domain.DataDownloadTask, bool) {
+	id, err := primitive.ObjectIDFromHex(c.Params("id"))
+	if err != nil {
+		_ = Fail(c, "invalid task id")
+		return id, nil, false
+	}
+	task, err := mongoRepo.GetDataDownloadTask(c.UserContext(), id)
+	if err != nil {
+		_ = NotFound(c)
+		return id, nil, false
+	}
+	user := middleware.AuthGetUserInfo(c)
+	ownerID := user.Id
+	if ownerID == "" {
+		ownerID = user.Name
+	}
+	if !user.IsAdmin && task.OwnerID != ownerID {
+		_ = Forbidden(c)
+		return id, nil, false
+	}
+	return id, task, true
+}
+
+func canAccessDataDownloadTask(c *fiber.Ctx, task *domain.DataDownloadTask) bool {
+	user := middleware.AuthGetUserInfo(c)
+	ownerID := user.Id
+	if ownerID == "" {
+		ownerID = user.Name
+	}
+	return user.IsAdmin || task.OwnerID == ownerID
+}
+
+func CreateDataDownloadPackage(c *fiber.Ctx) error {
+	var body struct {
+		TaskIDs []string `json:"taskIds"`
+	}
+	if err := c.BodyParser(&body); err != nil || len(body.TaskIDs) == 0 {
+		return Fail(c, "at least one taskId is required")
+	}
+	ids := make([]primitive.ObjectID, 0, len(body.TaskIDs))
+	for _, raw := range body.TaskIDs {
+		id, err := primitive.ObjectIDFromHex(raw)
+		if err != nil {
+			return Fail(c, "invalid task id")
+		}
+		task, err := mongoRepo.GetDataDownloadTask(c.UserContext(), id)
+		if err != nil {
+			return NotFound(c, "task not found")
+		}
+		if !canAccessDataDownloadTask(c, task) {
+			return Forbidden(c)
+		}
+		ids = append(ids, id)
+	}
+	user := middleware.AuthGetUserInfo(c)
+	ownerID := user.Id
+	if ownerID == "" {
+		ownerID = user.Name
+	}
+	now := time.Now().UTC()
+	prefix := "DataDownload_" + ids[0].Hex()
+	if len(ids) > 1 {
+		prefix = "DataDownload_Multiple"
+	}
+	job := &domain.DataDownloadPackageJob{TaskIDs: ids, OwnerID: ownerID, Status: domain.DataDownloadPending, Filename: fmt.Sprintf("%s_%s.zip", prefix, now.Format("20060102_150405")), CreatedAt: now, UpdatedAt: now}
+	if err := mongoRepo.InsertDataDownloadPackageJob(c.UserContext(), job); err != nil {
+		return Error(c, err.Error())
+	}
+	if err := dataDownloadQueue.EnqueuePackage(job.ID); err != nil {
+		_ = mongoRepo.UpdateDataDownloadPackageJob(context.Background(), job.ID, bson.M{"status": domain.DataDownloadFailed, "failureReason": err.Error()})
+		return Error(c, err.Error())
+	}
+	zap.L().Info("[DataDownload] API 创建打包任务",
+		zap.String("jobId", job.ID.Hex()),
+		zap.Int("taskCount", len(ids)),
+		zap.String("filename", job.Filename),
+	)
+	return Success(c, job)
+}
+
+func GetDataDownloadPackages(c *fiber.Ctx) error {
+	page, _ := strconv.ParseInt(c.Query("pageNum", "1"), 10, 64)
+	pageSize, _ := strconv.ParseInt(c.Query("pageSize", "20"), 10, 64)
+	if page < 1 || pageSize < 1 || pageSize > 200 {
+		return Fail(c, "invalid pagination")
+	}
+	user := middleware.AuthGetUserInfo(c)
+	ownerID := user.Id
+	if ownerID == "" {
+		ownerID = user.Name
+	}
+	jobs, total, err := mongoRepo.ListDataDownloadPackageJobs(c.UserContext(), ownerID, !user.IsAdmin, page, pageSize)
+	if err != nil {
+		return Error(c, err.Error())
+	}
+	return Success(c, fiber.Map{"items": jobs, "total": total, "pageNum": page, "pageSize": pageSize})
+}
+
+func DownloadDataDownloadPackage(c *fiber.Ctx) error {
+	id, job, ok := ownedDataDownloadPackage(c)
+	if !ok {
+		return nil
+	}
+	lock, err := cache.Lock(c.UserContext(), "data-download:package:"+id.Hex(), 10*time.Minute, 100*time.Millisecond, 10*time.Second)
+	if err != nil {
+		return Error(c, "package is being updated")
+	}
+	job, err = mongoRepo.GetDataDownloadPackageJob(c.UserContext(), id)
+	if err != nil {
+		lock.SafeRelease(context.Background())
+		return NotFound(c)
+	}
+	if job.Status != domain.DataDownloadSucceeded || job.ExpiresAt == nil || !job.ExpiresAt.After(time.Now()) {
+		lock.SafeRelease(context.Background())
+		return Fail(c, "package is not available")
+	}
+	reader, err := minioRepo.Open(c.UserContext(), job.ObjectKey)
+	if err != nil {
+		lock.SafeRelease(context.Background())
+		return Error(c, err.Error())
+	}
+	c.Type("zip")
+	c.Set("Content-Disposition", "attachment; filename=\""+job.Filename+"\"")
+	return c.SendStream(newDataDownloadLockedReader(reader, lock))
+}
+
+func CancelDataDownloadPackage(c *fiber.Ctx) error {
+	id, job, ok := ownedDataDownloadPackage(c)
+	if !ok {
+		return nil
+	}
+	if job.Status != domain.DataDownloadPending && job.Status != domain.DataDownloadRunning {
+		return Fail(c, "only pending or running packages can be cancelled")
+	}
+	status := domain.DataDownloadCancelling
+	if job.Status == domain.DataDownloadPending {
+		status = domain.DataDownloadCancelled
+	}
+	if err := mongoRepo.UpdateDataDownloadPackageJob(c.UserContext(), id, bson.M{"status": status}); err != nil {
+		return Error(c, err.Error())
+	}
+	return Success(c, fiber.Map{"status": status})
+}
+
+func DeleteDataDownloadPackage(c *fiber.Ctx) error {
+	id, job, ok := ownedDataDownloadPackage(c)
+	if !ok {
+		return nil
+	}
+	lock, err := cache.Lock(c.UserContext(), "data-download:package:"+id.Hex(), 5*time.Minute, 100*time.Millisecond, 10*time.Second)
+	if err != nil {
+		return Error(c, "package is being downloaded or updated")
+	}
+	defer lock.SafeRelease(context.Background())
+	job, err = mongoRepo.GetDataDownloadPackageJob(c.UserContext(), id)
+	if err != nil {
+		return NotFound(c)
+	}
+	if job.Status == domain.DataDownloadPending || job.Status == domain.DataDownloadRunning || job.Status == domain.DataDownloadCancelling {
+		return Fail(c, "active package must be cancelled first")
+	}
+	if job.ObjectKey != "" {
+		if err := minioRepo.Delete(c.UserContext(), job.ObjectKey); err != nil {
+			return Error(c, err.Error())
+		}
+	}
+	if err := mongoRepo.DeleteDataDownloadPackageJob(c.UserContext(), id); err != nil {
+		return Error(c, err.Error())
+	}
+	return NoContent(c)
+}
+
+func RetryDataDownloadPackage(c *fiber.Ctx) error {
+	_, original, ok := ownedDataDownloadPackage(c)
+	if !ok {
+		return nil
+	}
+	if original.Status != domain.DataDownloadFailed {
+		return Fail(c, "only failed packages can be retried")
+	}
+	now := time.Now().UTC()
+	job := &domain.DataDownloadPackageJob{TaskIDs: original.TaskIDs, OwnerID: original.OwnerID, Status: domain.DataDownloadPending, Filename: fmt.Sprintf("DataDownload_Retry_%s.zip", now.Format("20060102_150405")), CreatedAt: now, UpdatedAt: now}
+	if err := mongoRepo.InsertDataDownloadPackageJob(c.UserContext(), job); err != nil {
+		return Error(c, err.Error())
+	}
+	if err := dataDownloadQueue.EnqueuePackage(job.ID); err != nil {
+		_ = mongoRepo.UpdateDataDownloadPackageJob(context.Background(), job.ID, bson.M{"status": domain.DataDownloadFailed, "failureReason": err.Error()})
+		return Error(c, err.Error())
+	}
+	return Success(c, job)
+}
+
+func ownedDataDownloadPackage(c *fiber.Ctx) (primitive.ObjectID, *domain.DataDownloadPackageJob, bool) {
+	id, err := primitive.ObjectIDFromHex(c.Params("id"))
+	if err != nil {
+		_ = Fail(c, "invalid package id")
+		return id, nil, false
+	}
+	job, err := mongoRepo.GetDataDownloadPackageJob(c.UserContext(), id)
+	if err != nil {
+		_ = NotFound(c)
+		return id, nil, false
+	}
+	user := middleware.AuthGetUserInfo(c)
+	ownerID := user.Id
+	if ownerID == "" {
+		ownerID = user.Name
+	}
+	if !user.IsAdmin && job.OwnerID != ownerID {
+		_ = Forbidden(c)
+		return id, nil, false
+	}
+	return id, job, true
+}

+ 155 - 0
service/database/data_download_schema.go

@@ -0,0 +1,155 @@
+package database
+
+import (
+	"fmt"
+
+	"gorm.io/gorm"
+)
+
+const dataDownloadSchemaVersion int64 = 2
+
+var dataDownloadSchemaV1 = []string{
+	`CREATE TABLE IF NOT EXISTS data_download_content_state (
+        document_id varchar(64) PRIMARY KEY,
+        document_kind varchar(32) NOT NULL,
+        content_generation bigint NOT NULL DEFAULT 0,
+        schema_version varchar(32) NOT NULL DEFAULT 'v1',
+        row_count bigint NOT NULL DEFAULT 0,
+        checksum char(64) NOT NULL DEFAULT '',
+        source_fetched_at timestamptz NOT NULL,
+        updated_at timestamptz NOT NULL DEFAULT now()
+    )`,
+	`CREATE TABLE IF NOT EXISTS data_download_power_flow_meta (
+        document_id varchar(64) PRIMARY KEY REFERENCES data_download_content_state(document_id) ON DELETE CASCADE,
+        site_sn varchar(64) NOT NULL,
+        local_date date NOT NULL,
+        timezone varchar(80) NOT NULL
+    )`,
+	`CREATE TABLE IF NOT EXISTS data_download_power_flow_point (
+        document_id varchar(64) NOT NULL REFERENCES data_download_content_state(document_id) ON DELETE CASCADE,
+        point_index integer NOT NULL,
+        sampled_at timestamptz,
+        payload jsonb NOT NULL,
+        PRIMARY KEY(document_id, point_index)
+    )`,
+	`CREATE TABLE IF NOT EXISTS data_download_mqtt_log_entry (
+        document_id varchar(64) NOT NULL REFERENCES data_download_content_state(document_id) ON DELETE CASCADE,
+        row_index integer NOT NULL,
+        occurred_at timestamptz,
+        subtype varchar(80) NOT NULL DEFAULT '',
+        payload jsonb NOT NULL,
+        PRIMARY KEY(document_id, row_index)
+    )`,
+	`CREATE TABLE IF NOT EXISTS data_download_event_alarm_entry (
+        document_id varchar(64) NOT NULL REFERENCES data_download_content_state(document_id) ON DELETE CASCADE,
+        row_index integer NOT NULL,
+        occurred_at timestamptz,
+        payload jsonb NOT NULL,
+        PRIMARY KEY(document_id, row_index)
+    )`,
+	`CREATE TABLE IF NOT EXISTS data_download_update_log_entry (
+        document_id varchar(64) NOT NULL REFERENCES data_download_content_state(document_id) ON DELETE CASCADE,
+        row_index integer NOT NULL,
+        device_sn varchar(64) NOT NULL DEFAULT '',
+        device_type varchar(80) NOT NULL DEFAULT '',
+        occurred_at timestamptz,
+        payload jsonb NOT NULL,
+        PRIMARY KEY(document_id, row_index)
+    )`,
+	`CREATE INDEX IF NOT EXISTS idx_dd_pf_site_date ON data_download_power_flow_meta(site_sn, local_date)`,
+	`CREATE INDEX IF NOT EXISTS idx_dd_mqtt_occurred ON data_download_mqtt_log_entry(document_id, occurred_at)`,
+	`CREATE INDEX IF NOT EXISTS idx_dd_event_occurred ON data_download_event_alarm_entry(document_id, occurred_at)`,
+	`CREATE INDEX IF NOT EXISTS idx_dd_update_device ON data_download_update_log_entry(document_id, device_sn, device_type)`,
+	`ALTER TABLE data_download_content_state ADD COLUMN IF NOT EXISTS content_checksum char(64) NOT NULL DEFAULT ''`,
+	`ALTER TABLE data_download_content_state ALTER COLUMN schema_version DROP DEFAULT`,
+	`ALTER TABLE data_download_content_state ALTER COLUMN schema_version TYPE varchar(32) USING schema_version::text`,
+	`ALTER TABLE data_download_content_state ALTER COLUMN schema_version SET DEFAULT 'v1'`,
+	`DO $$ BEGIN IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'chk_dd_document_kind') THEN ALTER TABLE data_download_content_state ADD CONSTRAINT chk_dd_document_kind CHECK (document_kind IN ('power_flow','mqtt_log','event_alarm','update_log')); END IF; END $$`,
+	`ALTER TABLE data_download_power_flow_meta ADD COLUMN IF NOT EXISTS reported_index integer NOT NULL DEFAULT 0`,
+	`ALTER TABLE data_download_power_flow_point ADD COLUMN IF NOT EXISTS seq integer`,
+	`ALTER TABLE data_download_power_flow_point ADD COLUMN IF NOT EXISTS device_time_local timestamp without time zone`,
+	`ALTER TABLE data_download_power_flow_point ADD COLUMN IF NOT EXISTS device_time_utc timestamptz`,
+	`ALTER TABLE data_download_power_flow_point ADD COLUMN IF NOT EXISTS power_fhp real`,
+	`ALTER TABLE data_download_power_flow_point ADD COLUMN IF NOT EXISTS power_gen real`,
+	`ALTER TABLE data_download_power_flow_point ADD COLUMN IF NOT EXISTS power_load real`,
+	`ALTER TABLE data_download_power_flow_point ADD COLUMN IF NOT EXISTS power_sun real`,
+	`ALTER TABLE data_download_power_flow_point ADD COLUMN IF NOT EXISTS power_uti real`,
+	`ALTER TABLE data_download_power_flow_point ADD COLUMN IF NOT EXISTS soc real`,
+	`ALTER TABLE data_download_power_flow_point ADD COLUMN IF NOT EXISTS kwh_total real`,
+	`ALTER TABLE data_download_power_flow_point ADD COLUMN IF NOT EXISTS run_status integer`,
+	`ALTER TABLE data_download_mqtt_log_entry ADD COLUMN IF NOT EXISTS cmd_type varchar(32)`,
+	`ALTER TABLE data_download_mqtt_log_entry ADD COLUMN IF NOT EXISTS cmd_name varchar(255)`,
+	`ALTER TABLE data_download_mqtt_log_entry ADD COLUMN IF NOT EXISTS gateway_id varchar(64)`,
+	`ALTER TABLE data_download_mqtt_log_entry ADD COLUMN IF NOT EXISTS gateway_type varchar(80)`,
+	`ALTER TABLE data_download_mqtt_log_entry ADD COLUMN IF NOT EXISTS gateway_status varchar(80)`,
+	`ALTER TABLE data_download_mqtt_log_entry ADD COLUMN IF NOT EXISTS active_status varchar(80)`,
+	`ALTER TABLE data_download_mqtt_log_entry ADD COLUMN IF NOT EXISTS device_time_raw text`,
+	`ALTER TABLE data_download_mqtt_log_entry ADD COLUMN IF NOT EXISTS event_time_raw text`,
+	`ALTER TABLE data_download_mqtt_log_entry ADD COLUMN IF NOT EXISTS device_time_local timestamp without time zone`,
+	`ALTER TABLE data_download_mqtt_log_entry ADD COLUMN IF NOT EXISTS event_time_utc timestamptz`,
+	`ALTER TABLE data_download_mqtt_log_entry ADD COLUMN IF NOT EXISTS log_data_raw text`,
+	`ALTER TABLE data_download_mqtt_log_entry ADD COLUMN IF NOT EXISTS log_data_json jsonb`,
+	`ALTER TABLE data_download_event_alarm_entry ADD COLUMN IF NOT EXISTS source_id varchar(80)`,
+	`ALTER TABLE data_download_event_alarm_entry ADD COLUMN IF NOT EXISTS cmd_type varchar(80)`,
+	`ALTER TABLE data_download_event_alarm_entry ADD COLUMN IF NOT EXISTS cmd_name varchar(255)`,
+	`ALTER TABLE data_download_event_alarm_entry ADD COLUMN IF NOT EXISTS gateway_id varchar(64)`,
+	`ALTER TABLE data_download_event_alarm_entry ADD COLUMN IF NOT EXISTS log_type varchar(32)`,
+	`ALTER TABLE data_download_event_alarm_entry ADD COLUMN IF NOT EXISTS alarm_code varchar(80)`,
+	`ALTER TABLE data_download_event_alarm_entry ADD COLUMN IF NOT EXISTS log_name text`,
+	`ALTER TABLE data_download_event_alarm_entry ADD COLUMN IF NOT EXISTS log_data_raw text`,
+	`ALTER TABLE data_download_event_alarm_entry ADD COLUMN IF NOT EXISTS log_data_json jsonb`,
+	`ALTER TABLE data_download_event_alarm_entry ADD COLUMN IF NOT EXISTS device_time_raw text`,
+	`ALTER TABLE data_download_event_alarm_entry ADD COLUMN IF NOT EXISTS start_time_raw text`,
+	`ALTER TABLE data_download_event_alarm_entry ADD COLUMN IF NOT EXISTS end_time_raw text`,
+	`CREATE TABLE IF NOT EXISTS data_download_update_order (
+        document_id varchar(64) NOT NULL REFERENCES data_download_content_state(document_id) ON DELETE CASCADE,
+        order_no varchar(128) NOT NULL, payload jsonb NOT NULL,
+        PRIMARY KEY(document_id, order_no)
+    )`,
+	`CREATE TABLE IF NOT EXISTS data_download_update_device_version (
+        document_id varchar(64) NOT NULL REFERENCES data_download_content_state(document_id) ON DELETE CASCADE,
+        order_no varchar(128) NOT NULL, scope varchar(32) NOT NULL, device_sn varchar(64) NOT NULL,
+        name varchar(255) NOT NULL, payload jsonb NOT NULL,
+        PRIMARY KEY(document_id, order_no, scope, device_sn, name)
+    )`,
+	`CREATE TABLE IF NOT EXISTS data_download_ahub_update (
+        document_id varchar(64) NOT NULL REFERENCES data_download_content_state(document_id) ON DELETE CASCADE,
+        order_no varchar(128) NOT NULL, ahub_sn varchar(64) NOT NULL, payload jsonb NOT NULL,
+        PRIMARY KEY(document_id, order_no, ahub_sn)
+    )`,
+	`CREATE TABLE IF NOT EXISTS data_download_ahub_update_detail (
+        document_id varchar(64) NOT NULL REFERENCES data_download_content_state(document_id) ON DELETE CASCADE,
+        order_no varchar(128) NOT NULL, ahub_sn varchar(64) NOT NULL, file_order varchar(128) NOT NULL,
+        file_name varchar(255) NOT NULL, payload jsonb NOT NULL,
+        PRIMARY KEY(document_id, order_no, ahub_sn, file_order, file_name)
+    )`,
+	`CREATE INDEX IF NOT EXISTS idx_dd_mqtt_local ON data_download_mqtt_log_entry(document_id, device_time_local, row_index)`,
+	`CREATE INDEX IF NOT EXISTS idx_dd_event_alarm ON data_download_event_alarm_entry(document_id, log_type, alarm_code)`,
+}
+
+func MigrateDataDownloadSchema(db *gorm.DB) error {
+	if db == nil {
+		return fmt.Errorf("PostgreSQL is not initialized")
+	}
+	return db.Transaction(func(tx *gorm.DB) error {
+		if err := tx.Exec(`CREATE TABLE IF NOT EXISTS data_download_schema_migrations (
+            version bigint PRIMARY KEY,
+            applied_at timestamptz NOT NULL DEFAULT now()
+        )`).Error; err != nil {
+			return err
+		}
+		var count int64
+		if err := tx.Raw("SELECT count(*) FROM data_download_schema_migrations WHERE version = ?", dataDownloadSchemaVersion).Scan(&count).Error; err != nil {
+			return err
+		}
+		if count != 0 {
+			return nil
+		}
+		for _, statement := range dataDownloadSchemaV1 {
+			if err := tx.Exec(statement).Error; err != nil {
+				return fmt.Errorf("apply data-download schema v%d: %w", dataDownloadSchemaVersion, err)
+			}
+		}
+		return tx.Exec("INSERT INTO data_download_schema_migrations(version) VALUES (?)", dataDownloadSchemaVersion).Error
+	})
+}

+ 20 - 6
service/database/mongo.go

@@ -2,18 +2,24 @@ package database
 
 import (
 	"context"
+	"fmt"
 	zapLogger "fwh-toolbox-server/logger"
-	"strings"
 	"time"
 
 	"go.mongodb.org/mongo-driver/mongo"
 	"go.mongodb.org/mongo-driver/mongo/options"
+	"go.mongodb.org/mongo-driver/x/mongo/driver/connstring"
 )
 
 var MongoClient *mongo.Client
 var MongoDb *mongo.Database
 
 func InitMongo(uri string) (*mongo.Client, error) {
+	databaseName, err := parseMongoDatabaseName(uri)
+	if err != nil {
+		return nil, err
+	}
+
 	ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
 	defer cancel()
 
@@ -29,15 +35,23 @@ func InitMongo(uri string) (*mongo.Client, error) {
 	}
 
 	MongoClient = client
-	zapLogger.Sugar.Infoln("MongoDB connected: ", uri)
-
-	parts := strings.Split(uri, "/")
-	dbName := parts[len(parts)-1] // 最后一段就是数据库名
-	MongoDb = client.Database(dbName)
+	MongoDb = client.Database(databaseName)
+	zapLogger.Sugar.Infoln("MongoDB connected, database: ", databaseName)
 
 	return client, nil
 }
 
+func parseMongoDatabaseName(uri string) (string, error) {
+	cs, err := connstring.ParseAndValidate(uri)
+	if err != nil {
+		return "", fmt.Errorf("parse MongoDB URI: %w", err)
+	}
+	if cs.Database == "" {
+		return "", fmt.Errorf("MongoDB URI must contain a database name")
+	}
+	return cs.Database, nil
+}
+
 func GetCollection(collName string) *mongo.Collection {
 	return MongoDb.Collection(collName)
 }

+ 21 - 0
service/database/mongo_test.go

@@ -0,0 +1,21 @@
+package database
+
+import "testing"
+
+func TestParseMongoDatabaseName(t *testing.T) {
+	tests := []struct {
+		uri, want string
+		wantErr   bool
+	}{
+		{"mongodb://localhost:27017/toolbox?authSource=admin", "toolbox", false},
+		{"mongodb://user:pass@mongo-a:27017,mongo-b:27017/toolbox?replicaSet=rs0", "toolbox", false},
+		{"mongodb://localhost:27017/?authSource=admin", "", true},
+		{"not-a-uri", "", true},
+	}
+	for _, test := range tests {
+		got, err := parseMongoDatabaseName(test.uri)
+		if (err != nil) != test.wantErr || got != test.want {
+			t.Fatalf("parseMongoDatabaseName(%q) = %q, %v; want %q, err=%v", test.uri, got, err, test.want, test.wantErr)
+		}
+	}
+}

+ 214 - 0
service/domain/dataDownloadDomain.go

@@ -0,0 +1,214 @@
+package domain
+
+import (
+	"time"
+
+	"go.mongodb.org/mongo-driver/bson/primitive"
+)
+
+type DataDownloadKind string
+
+const (
+	DataDownloadPowerFlow     DataDownloadKind = "power_flow"
+	DataDownloadMqttLog       DataDownloadKind = "mqtt_log"
+	DataDownloadEventAlarm    DataDownloadKind = "event_alarm"
+	DataDownloadAlarmSnapshot DataDownloadKind = "alarm_snapshot"
+	DataDownloadEmsLog        DataDownloadKind = "ems_log"
+	DataDownloadFaultRecord   DataDownloadKind = "fault_record"
+	DataDownloadUpdateLog     DataDownloadKind = "update_log"
+)
+
+type DataDownloadState string
+
+const (
+	DataDownloadMutable DataDownloadState = "mutable"
+	DataDownloadFrozen  DataDownloadState = "frozen"
+	DataDownloadDynamic DataDownloadState = "dynamic"
+)
+
+type DataDownloadStatus string
+
+const (
+	DataDownloadPending       DataDownloadStatus = "pending"
+	DataDownloadRunning       DataDownloadStatus = "running"
+	DataDownloadCancelling    DataDownloadStatus = "cancelling"
+	DataDownloadSucceeded     DataDownloadStatus = "complete"
+	DataDownloadItemSucceeded DataDownloadStatus = "success"
+	DataDownloadSkipped       DataDownloadStatus = "skipped"
+	DataDownloadPartial       DataDownloadStatus = "partial_success"
+	DataDownloadFailed        DataDownloadStatus = "failed"
+	DataDownloadError         DataDownloadStatus = "error"
+	DataDownloadCancelled     DataDownloadStatus = "cancelled"
+	DataDownloadExpired       DataDownloadStatus = "expired"
+)
+
+type DataDownloadCompleteness string
+
+const (
+	DataDownloadCompletenessComplete DataDownloadCompleteness = "complete"
+	DataDownloadCompletenessPartial  DataDownloadCompleteness = "partial"
+)
+
+type DataDownloadOptions struct {
+	PowerFlow     bool     `bson:"powerFlow" json:"powerFlow"`
+	MqttLogs      []string `bson:"mqttLogs,omitempty" json:"mqttLogs,omitempty"`
+	EventAlarm    bool     `bson:"eventAlarm" json:"eventAlarm"`
+	AlarmSnapshot bool     `bson:"alarmSnapshot" json:"alarmSnapshot"`
+	EmsLogs       []string `bson:"emsLogs,omitempty" json:"emsLogs,omitempty"`
+	FaultRecord   bool     `bson:"faultRecord" json:"faultRecord"`
+	UpdateLog     bool     `bson:"updateLog" json:"updateLog"`
+}
+
+func (o DataDownloadOptions) Empty() bool {
+	return !o.PowerFlow && len(o.MqttLogs) == 0 && !o.EventAlarm && !o.AlarmSnapshot && len(o.EmsLogs) == 0 && !o.FaultRecord && !o.UpdateLog
+}
+
+type DataDownloadTask struct {
+	ID          primitive.ObjectID  `bson:"_id,omitempty" json:"id"`
+	OwnerID     string              `bson:"ownerId" json:"ownerId"`
+	OwnerName   string              `bson:"ownerName" json:"ownerName"`
+	SNs         []string            `bson:"sns" json:"sns"`
+	Description string              `bson:"description" json:"description"`
+	StartDate   string              `bson:"startDate,omitempty" json:"startDate,omitempty"`
+	EndDate     string              `bson:"endDate,omitempty" json:"endDate,omitempty"`
+	Options     DataDownloadOptions `bson:"options" json:"options"`
+	Status      DataDownloadStatus  `bson:"status" json:"status"`
+	Total       int64               `bson:"total" json:"total"`
+	Succeeded   int64               `bson:"succeeded" json:"succeeded"`
+	Failed      int64               `bson:"failed" json:"failed"`
+	Empty       int64               `bson:"empty" json:"empty"`
+	CreatedAt   time.Time           `bson:"createdAt" json:"createdAt"`
+	UpdatedAt   time.Time           `bson:"updatedAt" json:"updatedAt"`
+	FinishedAt  *time.Time          `bson:"finishedAt,omitempty" json:"finishedAt,omitempty"`
+}
+
+type DataDownloadItem struct {
+	ID                 primitive.ObjectID `bson:"_id,omitempty" json:"id"`
+	TaskID             primitive.ObjectID `bson:"taskId" json:"taskId"`
+	ItemKey            string             `bson:"itemKey" json:"itemKey"`
+	Kind               DataDownloadKind   `bson:"kind" json:"kind"`
+	SiteSN             string             `bson:"siteSn" json:"siteSn"`
+	DeviceSN           string             `bson:"deviceSn,omitempty" json:"deviceSn,omitempty"`
+	Date               string             `bson:"date,omitempty" json:"date,omitempty"`
+	Subtype            string             `bson:"subtype,omitempty" json:"subtype,omitempty"`
+	DeviceType         string             `bson:"deviceType,omitempty" json:"deviceType,omitempty"`
+	Timezone           string             `bson:"timezone" json:"timezone"`
+	Status             DataDownloadStatus `bson:"status" json:"status"`
+	Stage              string             `bson:"stage,omitempty" json:"stage,omitempty"`
+	FailureReason      string             `bson:"failureReason,omitempty" json:"failureReason,omitempty"`
+	Attempt            int                `bson:"attempt" json:"attempt"`
+	CallRequestedAt    *time.Time         `bson:"callRequestedAt,omitempty" json:"callRequestedAt,omitempty"`
+	CallRecordID       int64              `bson:"callRecordId,omitempty" json:"callRecordId,omitempty"`
+	CallRequestedCount int                `bson:"callRequestedCount,omitempty" json:"callRequestedCount,omitempty"`
+	CallRecordStatus   *int               `bson:"callRecordStatus,omitempty" json:"callRecordStatus,omitempty"`
+	CreatedAt          time.Time          `bson:"createdAt" json:"createdAt"`
+	UpdatedAt          time.Time          `bson:"updatedAt" json:"updatedAt"`
+}
+
+type DataDownloadDocument struct {
+	ID                string                   `bson:"_id" json:"id"`
+	RequestKey        string                   `bson:"requestKey" json:"requestKey"`
+	Kind              DataDownloadKind         `bson:"kind" json:"kind"`
+	SiteSN            string                   `bson:"siteSn" json:"siteSn"`
+	DeviceSN          string                   `bson:"deviceSn,omitempty" json:"deviceSn,omitempty"`
+	Date              string                   `bson:"date,omitempty" json:"date,omitempty"`
+	Subtype           string                   `bson:"subtype,omitempty" json:"subtype,omitempty"`
+	DeviceType        string                   `bson:"deviceType,omitempty" json:"deviceType,omitempty"`
+	Timezone          string                   `bson:"timezone" json:"timezone"`
+	State             DataDownloadState        `bson:"state" json:"state"`
+	Completeness      DataDownloadCompleteness `bson:"completeness,omitempty" json:"completeness,omitempty"`
+	Empty             bool                     `bson:"empty" json:"empty"`
+	FileCount         int64                    `bson:"fileCount,omitempty" json:"fileCount"`
+	FailedFileCount   int64                    `bson:"failedFileCount,omitempty" json:"failedFileCount"`
+	ContentGeneration int64                    `bson:"contentGeneration" json:"contentGeneration"`
+	BinaryObjectKey   string                   `bson:"binaryObjectKey,omitempty" json:"-"`
+	HasBinary         bool                     `bson:"-" json:"hasBinary"`
+	Checksum          string                   `bson:"checksum,omitempty" json:"checksum,omitempty"`
+	SourceFetchedAt   time.Time                `bson:"sourceFetchedAt" json:"sourceFetchedAt"`
+	FrozenAt          *time.Time               `bson:"frozenAt,omitempty" json:"frozenAt,omitempty"`
+	CreatedAt         time.Time                `bson:"createdAt" json:"createdAt"`
+	UpdatedAt         time.Time                `bson:"updatedAt" json:"updatedAt"`
+}
+
+type DataDownloadFaultRecordEntry struct {
+	ID               primitive.ObjectID `bson:"_id,omitempty" json:"id"`
+	DocumentID       string             `bson:"documentId" json:"documentId"`
+	SourceFileID     string             `bson:"sourceFileId" json:"sourceFileId"`
+	FaultID          int64              `bson:"faultId" json:"faultId"`
+	SubRecordID      int64              `bson:"subRecordId" json:"subRecordId"`
+	FileName         string             `bson:"fileName" json:"fileName"`
+	ContentType      string             `bson:"contentType" json:"contentType"`
+	Size             int64              `bson:"size" json:"size"`
+	Checksum         string             `bson:"checksum" json:"checksum"`
+	ObjectKey        string             `bson:"objectKey" json:"-"`
+	LastSuccessfulAt time.Time          `bson:"lastSuccessfulAt" json:"lastSuccessfulAt"`
+	CreatedAt        time.Time          `bson:"createdAt" json:"createdAt"`
+	UpdatedAt        time.Time          `bson:"updatedAt" json:"updatedAt"`
+}
+
+type DataDownloadFileAttempt struct {
+	ID            primitive.ObjectID  `bson:"_id,omitempty" json:"id"`
+	TaskID        primitive.ObjectID  `bson:"taskId" json:"taskId"`
+	ItemID        primitive.ObjectID  `bson:"itemId" json:"itemId"`
+	DocumentID    string              `bson:"documentId" json:"documentId"`
+	SourceFileID  string              `bson:"sourceFileId" json:"sourceFileId"`
+	FileName      string              `bson:"fileName,omitempty" json:"fileName,omitempty"`
+	Status        DataDownloadStatus  `bson:"status" json:"status"`
+	EntryID       *primitive.ObjectID `bson:"entryId,omitempty" json:"entryId,omitempty"`
+	AttemptCount  int                 `bson:"attemptCount" json:"attemptCount"`
+	FailureStage  string              `bson:"failureStage,omitempty" json:"failureStage,omitempty"`
+	FailureReason string              `bson:"failureReason,omitempty" json:"failureReason,omitempty"`
+	CreatedAt     time.Time           `bson:"createdAt" json:"createdAt"`
+	UpdatedAt     time.Time           `bson:"updatedAt" json:"updatedAt"`
+}
+
+type DataDownloadTaskDocument struct {
+	ID          primitive.ObjectID `bson:"_id,omitempty" json:"id"`
+	TaskID      primitive.ObjectID `bson:"taskId" json:"taskId"`
+	ItemKey     string             `bson:"itemKey" json:"itemKey"`
+	DocumentID  string             `bson:"documentId" json:"documentId"`
+	LogicalPath string             `bson:"logicalPath" json:"logicalPath"`
+	Empty       bool               `bson:"empty" json:"empty"`
+	CreatedAt   time.Time          `bson:"createdAt" json:"createdAt"`
+}
+
+type DataDownloadRenderSlot struct {
+	ID                primitive.ObjectID  `bson:"_id,omitempty" json:"id"`
+	DocumentID        string              `bson:"documentId" json:"documentId"`
+	Format            string              `bson:"format" json:"format"`
+	ContentGeneration int64               `bson:"contentGeneration" json:"contentGeneration"`
+	ArtifactID        *primitive.ObjectID `bson:"artifactId,omitempty" json:"artifactId,omitempty"`
+	UpdatedAt         time.Time           `bson:"updatedAt" json:"updatedAt"`
+}
+
+type DataDownloadArtifact struct {
+	ID                primitive.ObjectID `bson:"_id,omitempty" json:"id"`
+	DocumentID        string             `bson:"documentId" json:"documentId"`
+	Format            string             `bson:"format" json:"format"`
+	ContentGeneration int64              `bson:"contentGeneration" json:"contentGeneration"`
+	ObjectKey         string             `bson:"objectKey" json:"-"`
+	Filename          string             `bson:"filename" json:"filename"`
+	Size              int64              `bson:"size" json:"size"`
+	Checksum          string             `bson:"checksum" json:"checksum"`
+	ExpiresAt         time.Time          `bson:"expiresAt" json:"expiresAt"`
+	CreatedAt         time.Time          `bson:"createdAt" json:"createdAt"`
+}
+
+type DataDownloadPackageJob struct {
+	ID            primitive.ObjectID   `bson:"_id,omitempty" json:"id"`
+	TaskIDs       []primitive.ObjectID `bson:"taskIds" json:"taskIds"`
+	OwnerID       string               `bson:"ownerId" json:"ownerId"`
+	Status        DataDownloadStatus   `bson:"status" json:"status"`
+	ObjectKey     string               `bson:"objectKey,omitempty" json:"-"`
+	Filename      string               `bson:"filename,omitempty" json:"filename,omitempty"`
+	Size          int64                `bson:"size,omitempty" json:"size,omitempty"`
+	TotalCount    int64                `bson:"totalCount" json:"totalCount"`
+	DoneCount     int64                `bson:"doneCount" json:"doneCount"`
+	FailureReason string               `bson:"failureReason,omitempty" json:"failureReason,omitempty"`
+	StartedAt     *time.Time           `bson:"startedAt,omitempty" json:"startedAt,omitempty"`
+	CompletedAt   *time.Time           `bson:"completedAt,omitempty" json:"completedAt,omitempty"`
+	ExpiresAt     *time.Time           `bson:"expiresAt,omitempty" json:"expiresAt,omitempty"`
+	PurgeAt       *time.Time           `bson:"purgeAt,omitempty" json:"purgeAt,omitempty"`
+	CreatedAt     time.Time            `bson:"createdAt" json:"createdAt"`
+	UpdatedAt     time.Time            `bson:"updatedAt" json:"updatedAt"`
+}

+ 72 - 10
service/jobs/callFwhFile.go

@@ -149,22 +149,76 @@ func callFwhFile(mainTaskId primitive.ObjectID, taskId string, info domain.Batch
 		}
 	}()
 
+	var emsDeadline time.Time
+	var emsSlot *EmsLogCallSlot
+	var emsCtx context.Context
+	if info.FileType == FileTypeEmsLog {
+		slot, err := AcquireEmsLogCallSlot(callFwhFileCtx, info.Sn)
+		if err != nil {
+			failReason = fmt.Sprintf("等待 EMS 日志召唤名额失败: %s", err)
+			return
+		}
+		emsSlot = slot
+		defer slot.Release()
+		emsDeadline = time.Now().Add(EmsLogCallTimeout)
+		var cancel context.CancelFunc
+		emsCtx, cancel = context.WithDeadline(slot.Context(), emsDeadline)
+		defer cancel()
+	}
+
 	for count := 0; count < maxCount; count++ {
 		// 请求召唤
 		switch info.FileType {
 		case FileTypeEmsLog: // 召唤EMS日志
+			if emsSlot.Context().Err() != nil {
+				failReason = ErrEmsLogCallSlotLost.Error()
+				return
+			}
+			if time.Until(emsDeadline) <= 0 {
+				failReason = ErrEmsLogCallTimeout.Error()
+				return
+			}
+			logType, ok := mapEmsLogType[info.FileSupType]
+			if !ok {
+				failReason = fmt.Sprintf("未知 EMS 日志类型: %s", info.FileSupType)
+				return
+			}
+			baselineID, baselineErr := repositories.LatestEmsLogCallRecordIDContext(emsCtx, info.Sn, info.Date, logType)
+			if time.Until(emsDeadline) <= 0 {
+				failReason = ErrEmsLogCallTimeout.Error()
+				return
+			}
+			if baselineErr != nil {
+				failReason = fmt.Sprintf("查询 EMS 日志召唤基线失败: %s", baselineErr)
+				return
+			}
 			fmt.Printf("callEmsLog SN-%s Date-%s Type-%s\n", info.Sn, info.Date, info.FileSupType)
-			if err := callEmsLog(info.Sn, info.Date, info.FileSupType); err != nil {
+			err := callEmsLog(emsCtx, info.Sn, info.Date, info.FileSupType)
+			if time.Until(emsDeadline) <= 0 {
+				failReason = ErrEmsLogCallTimeout.Error()
+				return
+			}
+			if err != nil {
 				success = false
 				failReason = fmt.Sprintf("handle task CallEmsLog error - %s", err.Error())
 				return
 			}
+			if emsSlot.Context().Err() != nil {
+				failReason = ErrEmsLogCallSlotLost.Error()
+				return
+			}
 
-			ok, err := waitUntilDone(func() (bool, error) {
+			ok, err = waitUntilDone(func() (bool, error) {
 				// 查询召唤结果
 				for count := 0; count < maxCount; count++ {
+					if emsSlot.Context().Err() != nil {
+						return false, ErrEmsLogCallSlotLost
+					}
+					if time.Until(emsDeadline) <= 0 {
+						return false, ErrEmsLogCallTimeout
+					}
 					fmt.Printf("getEmsLogUrl SN-%s Date-%s Type-%s\n", info.Sn, info.Date, info.FileSupType)
-					recordInfo, err := getEmsLogUrl(info.Sn, info.Date, info.FileSupType)
+					recordInfo, err := getEmsLogUrlAfter(emsCtx, info.Sn, info.Date, info.FileSupType, baselineID)
 					if err != nil {
 						zap.L().Error("getEmsLogUrl读取EMS日志地址失败")
 						continue
@@ -176,7 +230,7 @@ func callFwhFile(mainTaskId primitive.ObjectID, taskId string, info domain.Batch
 						// 下载文件
 						var downloadCount int = 0
 						for downloadCount = 0; downloadCount < maxCount; downloadCount++ {
-							dataBytes, err := repositories.QueryDownloadEmsLogFile(recordInfo.FileUrl)
+							dataBytes, err := repositories.QueryDownloadEmsLogFileContext(emsCtx, recordInfo.FileUrl)
 							if err != nil {
 								zap.L().Error("EMS日志文件下载失败", zap.String("err", err.Error()))
 								continue
@@ -226,11 +280,19 @@ func callFwhFile(mainTaskId primitive.ObjectID, taskId string, info domain.Batch
 					return false, nil
 				}
 				return false, fmt.Errorf("读取EMS日志地址请求失败")
-			}, time.Minute, 30*time.Minute) // 每分钟查看,半小时超时
+			}, time.Minute, time.Until(emsDeadline)) // 单个文件共用 30 分钟期限
 			if err != nil {
 				success = false
 				failReason = err.Error() // 保存失败原因
 			}
+			if time.Until(emsDeadline) <= 0 {
+				failReason = ErrEmsLogCallTimeout.Error()
+				return // 超时后处理同一 SN 的下一个文件,不再召唤本文件
+			}
+			if emsSlot.Context().Err() != nil {
+				failReason = ErrEmsLogCallSlotLost.Error()
+				return
+			}
 			if ok {
 				// 召唤成功
 				success = true
@@ -534,13 +596,13 @@ func enqueueTask(sn string, taskInfo CallFileTaskInfo, payload domain.BatchDownl
 
 /************* 文件召唤&查询&下载接口 *************/
 // 召唤EMS日志
-func callEmsLog(sn string, date string, logType string) error {
+func callEmsLog(ctx context.Context, sn string, date string, logType string) error {
 	logTypeNum, ok := mapEmsLogType[logType]
 	if !ok {
 		zap.L().Error("logType不存在", zap.String("logType", logType))
 		return fmt.Errorf("logType不存在,logType=%s", logType)
 	}
-	err := repositories.QueryCallEmsLog(sn, date, logTypeNum)
+	err := repositories.QueryCallEmsLogContext(ctx, sn, date, logTypeNum)
 	if err != nil {
 		zap.L().Error("QueryCallEmsLog请求失败", zap.String("err", err.Error()))
 		return err
@@ -549,14 +611,14 @@ func callEmsLog(sn string, date string, logType string) error {
 	return nil
 }
 
-// 获取日志下载地址
-func getEmsLogUrl(sn string, date string, logType string) (*domain.EmsLogRecordInfo, error) {
+// 获取本次召唤的日志下载地址
+func getEmsLogUrlAfter(ctx context.Context, sn string, date string, logType string, afterID int64) (*domain.EmsLogRecordInfo, error) {
 	logTypeNum, ok := mapEmsLogType[logType]
 	if !ok {
 		zap.L().Error("logType不存在", zap.String("logType", logType))
 		return nil, fmt.Errorf("logType不存在,logType=%s", logType)
 	}
-	recordInfo, err := repositories.QueryEmsLogCallRecord(sn, date, logTypeNum)
+	recordInfo, err := repositories.QueryEmsLogCallRecordAfterContext(ctx, sn, date, logTypeNum, afterID)
 	if err != nil {
 		zap.L().Error("GetEmsLogUrl请求失败", zap.String("err", err.Error()))
 		return nil, err

+ 88 - 0
service/jobs/emsLogCallSlot.go

@@ -0,0 +1,88 @@
+package jobs
+
+import (
+	"context"
+	"errors"
+	"fmt"
+	"strings"
+	"time"
+
+	"fwh-toolbox-server/cache"
+
+	"go.uber.org/zap"
+)
+
+const (
+	emsLogCallSlotTTL   = 2 * time.Minute
+	emsLogCallSlotRetry = time.Second
+	EmsLogCallTimeout   = 30 * time.Minute
+)
+
+var ErrEmsLogCallTimeout = errors.New("EMS 日志单文件召唤超过 30 分钟")
+var ErrEmsLogCallSlotLost = errors.New("EMS 日志召唤名额已丢失")
+
+// EmsLogCallSlot serializes EMS calls for one SN across both download flows and instances.
+type EmsLogCallSlot struct {
+	lock   *cache.RedisLock
+	ctx    context.Context
+	cancel context.CancelFunc
+	done   chan struct{}
+}
+
+func TryAcquireEmsLogCallSlot(ctx context.Context, sn string) (*EmsLogCallSlot, bool, error) {
+	keySN := strings.ToUpper(strings.TrimSpace(sn))
+	if keySN == "" {
+		return nil, false, fmt.Errorf("EMS log SN is empty")
+	}
+	lock, ok, err := cache.TryLock(ctx, "ems-log:call:"+keySN, emsLogCallSlotTTL)
+	if err != nil || !ok {
+		return nil, ok, err
+	}
+	leaseCtx, cancel := context.WithCancel(ctx)
+	slot := &EmsLogCallSlot{lock: lock, ctx: leaseCtx, cancel: cancel, done: make(chan struct{})}
+	go slot.renew()
+	return slot, true, nil
+}
+
+func AcquireEmsLogCallSlot(ctx context.Context, sn string) (*EmsLogCallSlot, error) {
+	for {
+		slot, ok, err := TryAcquireEmsLogCallSlot(ctx, sn)
+		if err != nil || ok {
+			return slot, err
+		}
+		select {
+		case <-ctx.Done():
+			return nil, ctx.Err()
+		case <-time.After(emsLogCallSlotRetry):
+		}
+	}
+}
+
+func (s *EmsLogCallSlot) Context() context.Context { return s.ctx }
+
+func (s *EmsLogCallSlot) Release() {
+	s.cancel()
+	<-s.done
+	s.lock.SafeRelease(context.Background())
+}
+
+func (s *EmsLogCallSlot) renew() {
+	defer close(s.done)
+	ticker := time.NewTicker(emsLogCallSlotTTL / 3)
+	defer ticker.Stop()
+	for {
+		select {
+		case <-s.ctx.Done():
+			return
+		case <-ticker.C:
+			ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
+			err := s.lock.Extend(ctx, emsLogCallSlotTTL)
+			cancel()
+			if err != nil {
+				zap.L().Error("EMS 日志召唤名额续期失败", zap.Error(err))
+				s.cancel()
+				return
+			}
+		}
+	}
+}

+ 7 - 1
service/main.go

@@ -1,6 +1,7 @@
 package main
 
 import (
+	"context"
 	"fwh-toolbox-server/cache"
 	"fwh-toolbox-server/config"
 	"fwh-toolbox-server/controllers"
@@ -51,13 +52,18 @@ func main() {
 		zap.L().Error("database init failed.", zap.String("err", err.Error()))
 		panic(err)
 	}
-
 	// 初始化缓存
 	cache.Init(cfg.App.RedisAddr)
 
 	// 初始化Minio
 	minio.InitClient(&cfg.App)
 
+	// 初始化 Repository migration/index;必须在 scheduler/worker 之前完成。
+	if err = repositories.Init(context.Background()); err != nil {
+		zap.L().Error("repository init failed", zap.String("err", err.Error()))
+		panic(err)
+	}
+
 	// 初始化FwhApi
 	repositories.FwhHttpInit(&cfg.FwhApi)
 

+ 67 - 4
service/repositories/api/fwh_pro/fwhHttpClient.go

@@ -1,6 +1,7 @@
 package fwh_pro
 
 import (
+	"context"
 	"encoding/json"
 	"fwh-toolbox-server/config"
 	"fwh-toolbox-server/domain"
@@ -46,7 +47,7 @@ func getFullOssUrl(relativeURL string) string {
 
 // 仅用户登录或者不需要token的请求
 func FwhHttpGetWithoutToken(url string, params url.Values) (*httpClient.Response, error) {
-	t := timeout
+	t := timeout / time.Second
 	opt := httpClient.HttpClientOptions{
 		Timeout: &t,
 		Header: &http.Header{
@@ -57,7 +58,7 @@ func FwhHttpGetWithoutToken(url string, params url.Values) (*httpClient.Response
 }
 
 func FwhHttpGet(url string, params url.Values) (*httpClient.Response, error) {
-	t := timeout
+	t := timeout / time.Second
 	opt := httpClient.HttpClientOptions{
 		Timeout: &t,
 		Header: &http.Header{
@@ -68,9 +69,22 @@ func FwhHttpGet(url string, params url.Values) (*httpClient.Response, error) {
 	return fwhHttpClient.HttpGet(url, &params, &opt)
 }
 
+func FwhHttpGetContext(ctx context.Context, url string, params url.Values) (*httpClient.Response, error) {
+	t := timeout / time.Second
+	opt := httpClient.HttpClientOptions{
+		Timeout: &t,
+		Context: ctx,
+		Header: &http.Header{
+			"Logintoken": {getLoginToken()},
+			"Accept":     {"application/json", "text/plain", "*/*"},
+		},
+	}
+	return fwhHttpClient.HttpGet(url, &params, &opt)
+}
+
 // 仅用户登录或者不需要token的请求
 func FwhHttpPostWithoutToken(url string, body any) (*httpClient.Response, error) {
-	t := timeout
+	t := timeout / time.Second
 	opt := httpClient.HttpClientOptions{
 		Timeout: &t,
 		Header: &http.Header{
@@ -81,9 +95,22 @@ func FwhHttpPostWithoutToken(url string, body any) (*httpClient.Response, error)
 }
 
 func FwhHttpPost(url string, body any) (*httpClient.Response, error) {
-	t := timeout
+	t := timeout / time.Second
+	opt := httpClient.HttpClientOptions{
+		Timeout: &t,
+		Header: &http.Header{
+			"Logintoken": {getLoginToken()},
+			"Accept":     {"application/json", "text/plain", "*/*"},
+		},
+	}
+	return fwhHttpClient.HttpPost(url, body, &opt)
+}
+
+func FwhHttpPostContext(ctx context.Context, url string, body any) (*httpClient.Response, error) {
+	t := timeout / time.Second
 	opt := httpClient.HttpClientOptions{
 		Timeout: &t,
+		Context: ctx,
 		Header: &http.Header{
 			"Logintoken": {getLoginToken()},
 			"Accept":     {"application/json", "text/plain", "*/*"},
@@ -92,6 +119,42 @@ func FwhHttpPost(url string, body any) (*httpClient.Response, error) {
 	return fwhHttpClient.HttpPost(url, body, &opt)
 }
 
+func FwhHttpGetUnmarshalContext[T any](ctx context.Context, url string, params url.Values) (*domain.FwhResponse[T], error) {
+	for attempt := 0; attempt < 2; attempt++ {
+		res, err := FwhHttpGetContext(ctx, url, params)
+		if err != nil {
+			return nil, err
+		}
+		var data domain.FwhResponse[T]
+		if err := json.Unmarshal(res.Body, &data); err != nil {
+			return nil, err
+		}
+		if data.Code != 401 || attempt == 1 {
+			return &data, nil
+		}
+		reLogin()
+	}
+	return nil, ctx.Err()
+}
+
+func FwhHttpPostUnmarshalContext[T any](ctx context.Context, url string, body any) (*domain.FwhResponse[T], error) {
+	for attempt := 0; attempt < 2; attempt++ {
+		res, err := FwhHttpPostContext(ctx, url, body)
+		if err != nil {
+			return nil, err
+		}
+		var data domain.FwhResponse[T]
+		if err := json.Unmarshal(res.Body, &data); err != nil {
+			return nil, err
+		}
+		if data.Code != 401 || attempt == 1 {
+			return &data, nil
+		}
+		reLogin()
+	}
+	return nil, ctx.Err()
+}
+
 /************ 实现标准结构体解析 ************/
 
 func FwhHttpGetUnmarshal[T any](url string, params url.Values) (*domain.FwhResponse[T], error) {

+ 64 - 12
service/repositories/api/fwh_pro/fwh_emsLog.go

@@ -1,6 +1,7 @@
 package fwh_pro
 
 import (
+	"context"
 	"fmt"
 	"fwh-toolbox-server/domain"
 	"net/url"
@@ -8,7 +9,7 @@ import (
 )
 
 // 召唤EMS日志
-func queryCallEmsLog(sn string, dateStr string, logType int) (*domain.FwhResponse[bool], error) {
+func queryCallEmsLog(ctx context.Context, sn string, dateStr string, logType int) (*domain.FwhResponse[bool], error) {
 	// 要发送的表单数据
 	// form := url.Values{}
 	// form.Set("gatewayId", sn)                  // SN
@@ -27,7 +28,7 @@ func queryCallEmsLog(sn string, dateStr string, logType int) (*domain.FwhRespons
 		"lang":      "ZH_CN",
 	}
 
-	response, err := FwhHttpPostUnmarshal[bool](getFullEnergyUrl("/hes-gateway/manage/logFileCall"), form)
+	response, err := FwhHttpPostUnmarshalContext[bool](ctx, getFullEnergyUrl("/hes-gateway/manage/logFileCall"), form)
 	if err != nil {
 		return nil, err
 	}
@@ -36,8 +37,12 @@ func queryCallEmsLog(sn string, dateStr string, logType int) (*domain.FwhRespons
 
 // 召唤EMS日志
 func QueryCallEmsLog(sn string, dateStr string, logType int) error {
+	return QueryCallEmsLogContext(context.Background(), sn, dateStr, logType)
+}
+
+func QueryCallEmsLogContext(ctx context.Context, sn string, dateStr string, logType int) error {
 	// 查询告警快照数据
-	callRes, err := queryCallEmsLog(sn, dateStr, logType)
+	callRes, err := queryCallEmsLog(ctx, sn, dateStr, logType)
 	if err != nil {
 		return fmt.Errorf("[QueryCallEmsLog] %s", err.Error())
 	}
@@ -54,7 +59,7 @@ func QueryCallEmsLog(sn string, dateStr string, logType int) error {
 }
 
 // 查询EMS日志下载地址
-func queryEmsLogCallRecord(sn string) (*domain.FwhResponse[[]domain.EmsLogRecordInfo], error) {
+func queryEmsLogCallRecord(ctx context.Context, sn string) (*domain.FwhResponse[[]domain.EmsLogRecordInfo], error) {
 	// 构造 query 参数
 	params := url.Values{}
 	params.Add("linkId", sn) // SN
@@ -63,7 +68,7 @@ func queryEmsLogCallRecord(sn string) (*domain.FwhResponse[[]domain.EmsLogRecord
 	params.Add("pageNum", strconv.Itoa(1))
 	params.Add("lang", "ZH_CN")
 
-	response, err := FwhHttpGetUnmarshal[[]domain.EmsLogRecordInfo](getFullEnergyUrl("/hes-gateway/manage/iotDeviceFileRecords"), params)
+	response, err := FwhHttpGetUnmarshalContext[[]domain.EmsLogRecordInfo](ctx, getFullEnergyUrl("/hes-gateway/manage/iotDeviceFileRecords"), params)
 	if err != nil {
 		return nil, err
 	}
@@ -72,8 +77,39 @@ func queryEmsLogCallRecord(sn string) (*domain.FwhResponse[[]domain.EmsLogRecord
 
 // 查询EMS日志下载地址
 func QueryEmsLogCallRecord(sn string, dateStr string, logType int) (*domain.EmsLogRecordInfo, error) {
+	return QueryEmsLogCallRecordAfter(sn, dateStr, logType, -1)
+}
+
+// LatestEmsLogCallRecordID captures the existing record before a new call.
+// A later poll must ignore records at or below this ID.
+func LatestEmsLogCallRecordID(sn string, dateStr string, logType int) (int64, error) {
+	return LatestEmsLogCallRecordIDContext(context.Background(), sn, dateStr, logType)
+}
+
+func LatestEmsLogCallRecordIDContext(ctx context.Context, sn string, dateStr string, logType int) (int64, error) {
+	callRecordRes, err := queryEmsLogCallRecord(ctx, sn)
+	if err != nil {
+		return 0, fmt.Errorf("[LatestEmsLogCallRecordID] %w", err)
+	}
+	if callRecordRes.Code != 200 {
+		return 0, fmt.Errorf("[LatestEmsLogCallRecordID] 查询失败 code: %d, message: %s", callRecordRes.Code, callRecordRes.Message)
+	}
+	if callRecordRes.Result == nil {
+		return 0, nil
+	}
+	if latest := latestMatchingEmsLogRecord(*callRecordRes.Result, dateStr, logType, -1); latest != nil {
+		return latest.Id, nil
+	}
+	return 0, nil
+}
+
+func QueryEmsLogCallRecordAfter(sn string, dateStr string, logType int, afterID int64) (*domain.EmsLogRecordInfo, error) {
+	return QueryEmsLogCallRecordAfterContext(context.Background(), sn, dateStr, logType, afterID)
+}
+
+func QueryEmsLogCallRecordAfterContext(ctx context.Context, sn string, dateStr string, logType int, afterID int64) (*domain.EmsLogRecordInfo, error) {
 	// 查询告警快照数据
-	callRecordRes, err := queryEmsLogCallRecord(sn)
+	callRecordRes, err := queryEmsLogCallRecord(ctx, sn)
 	if err != nil {
 		return nil, fmt.Errorf("[QueryEmsLogCallRecord] %s", err.Error())
 	}
@@ -82,18 +118,34 @@ func QueryEmsLogCallRecord(sn string, dateStr string, logType int) (*domain.EmsL
 		return nil, fmt.Errorf("[QueryEmsLogCallRecord] 查询失败 code: %d, message: %s", callRecordRes.Code, callRecordRes.Message)
 	}
 
-	for _, record := range *callRecordRes.Result {
-		if record.LogTime == dateStr && record.CodeType == logType {
-			return &record, nil
-		}
+	if callRecordRes.Result == nil {
+		return nil, fmt.Errorf("[QueryEmsLogCallRecord] 结果为空")
+	}
+	latest := latestMatchingEmsLogRecord(*callRecordRes.Result, dateStr, logType, afterID)
+	if latest == nil {
+		return nil, fmt.Errorf("[QueryEmsLogCallRecord] 没有召唤日志")
 	}
+	return latest, nil
+}
 
-	return nil, fmt.Errorf("[QueryEmsLogCallRecord] 没有召唤日志")
+func latestMatchingEmsLogRecord(records []domain.EmsLogRecordInfo, dateStr string, logType int, afterID int64) *domain.EmsLogRecordInfo {
+	var latest *domain.EmsLogRecordInfo
+	for _, record := range records {
+		if record.LogTime == dateStr && record.CodeType == logType && record.Id > afterID && (latest == nil || record.Id > latest.Id) {
+			current := record
+			latest = &current
+		}
+	}
+	return latest
 }
 
 // 请求下载EMS日志文件
 func QueryDownloadEmsLogFile(filePath string) ([]byte, error) {
-	response, err := FwhHttpGet(getFullEnergyUrl("/hes-upload/v1/file/download/"+filePath), url.Values{})
+	return QueryDownloadEmsLogFileContext(context.Background(), filePath)
+}
+
+func QueryDownloadEmsLogFileContext(ctx context.Context, filePath string) ([]byte, error) {
+	response, err := FwhHttpGetContext(ctx, getFullEnergyUrl("/hes-upload/v1/file/download/"+filePath), url.Values{})
 	if err != nil {
 		return nil, err
 	}

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

@@ -0,0 +1,27 @@
+package fwh_pro
+
+import (
+	"testing"
+
+	"fwh-toolbox-server/domain"
+)
+
+func TestLatestMatchingEmsLogRecordIgnoresPreviousCalls(t *testing.T) {
+	records := []domain.EmsLogRecordInfo{
+		{Id: 12, LogTime: "2026-09-20", CodeType: 4},
+		{Id: 18, LogTime: "2026-09-21", CodeType: 4},
+		{Id: 15, LogTime: "2026-09-20", CodeType: 7},
+		{Id: 16, LogTime: "2026-09-20", CodeType: 4},
+	}
+	baseline := latestMatchingEmsLogRecord(records, "2026-09-20", 4, -1)
+	if baseline == nil || baseline.Id != 16 {
+		t.Fatalf("baseline = %+v, want ID 16", baseline)
+	}
+	if got := latestMatchingEmsLogRecord(records, "2026-09-20", 4, baseline.Id); got != nil {
+		t.Fatalf("previous call matched as new: %+v", got)
+	}
+	records = append(records, domain.EmsLogRecordInfo{Id: 19, LogTime: "2026-09-20", CodeType: 4})
+	if got := latestMatchingEmsLogRecord(records, "2026-09-20", 4, baseline.Id); got == nil || got.Id != 19 {
+		t.Fatalf("new call record = %+v, want ID 19", got)
+	}
+}

+ 43 - 1
service/repositories/api/fwh_pro/fwh_eventAlarmLog.go

@@ -130,6 +130,12 @@ func queryEventOrAlarmListOnePageExec(sn string, startTime, endTime time.Time, t
 }
 
 // 从FWH获取事件或告警log数据
+//
+// 限制条件(重要):startTime/endTime 必须为「不含时区」的字面时间(用 UTC 表示)。
+// 本函数按天拆分时间段时用 time.Truncate(24h) 推进游标,Truncate 按 UTC 零点对齐;
+// 若传入带非 UTC 时区(如 America/Chicago)的时间,游标会回退到前一天,
+// currTime.Before(endTime) 恒成立,periods 切片无限 append,导致内存 OOM。
+// 调用方(DataDownload)需先用 stripTimeLocation 剥离时区再传入。
 func QueryEventOrAlarmListOnTimeRange(sn string, startTime, endTime time.Time, t EventAlarmType) ([]domain.EventAlarmItem, error) {
 	// 保证 startTime <= endTime
 	if startTime.After(endTime) {
@@ -154,15 +160,27 @@ func QueryEventOrAlarmListOnTimeRange(sn string, startTime, endTime time.Time, t
 		currTime = dayEnd.Add(time.Second).Truncate(24 * time.Hour)
 		currTime = time.Date(currTime.Year(), currTime.Month(), currTime.Day(), 0, 0, 0, 0, currTime.Location())
 	}
+	zap.L().Debug("[fwh_pro] 事件/告警 periods 构建完成",
+		zap.String("sn", sn), zap.Int("logType", int(t)),
+		zap.Time("start", startTime), zap.Time("end", endTime),
+		zap.Int("periods", len(periods)),
+	)
 
 	// 按天查询
 	var eventLogList []domain.EventAlarmItem
 	for _, period := range periods {
 		var eventLogRes []domain.EventAlarmItem = nil
 		periodEndTime := period.End
+		pages := 0
 		// 检查数据是否超过1000(单次查询上限)
 		for {
 			var err error
+			pages++
+			zap.L().Debug("[fwh_pro] 事件/告警分页请求发起",
+				zap.String("sn", sn), zap.Int("logType", int(t)),
+				zap.Time("periodStart", period.Start), zap.Time("periodEnd", periodEndTime),
+				zap.Int("page", pages), zap.Int("accumulated", len(eventLogList)),
+			)
 			eventLogRes, err = queryEventOrAlarmListOnePage(sn, period.Start, periodEndTime, t)
 			if err != nil {
 				return nil, err
@@ -170,6 +188,15 @@ func QueryEventOrAlarmListOnTimeRange(sn string, startTime, endTime time.Time, t
 
 			eventLogList = append(eventLogList, eventLogRes...)
 
+			// 观测:累计规模,用于定位内存放大。正常一天一页即结束。
+			zap.L().Debug("[fwh_pro] 事件/告警分页请求返回",
+				zap.String("sn", sn),
+				zap.Int("logType", int(t)),
+				zap.Int("page", pages),
+				zap.Int("pageRows", len(eventLogRes)),
+				zap.Int("accumulated", len(eventLogList)),
+			)
+
 			// 判断是否未查完
 			if len(eventLogRes) < int(REQ_PAGE_SIZE) {
 				// 请求完成,退出
@@ -178,10 +205,25 @@ func QueryEventOrAlarmListOnTimeRange(sn string, startTime, endTime time.Time, t
 
 			// 跳转时间段,再次请求
 			// 提取最后一条数据的设备时间
-			periodEndTime, err = time.Parse(time.DateTime, eventLogRes[len(eventLogRes)-1].LogTime)
+			next, err := time.Parse(time.DateTime, eventLogRes[len(eventLogRes)-1].LogTime)
 			if err != nil {
 				return nil, err
 			}
+
+			// 游标未能前进:说明同一秒内 >= REQ_PAGE_SIZE(1000) 条数据,
+			// 单页游标无法翻过这一秒,继续循环会死循环并 OOM。现实中基本不会发生,
+			// 此处仅作防护:记录错误并返回,不再无限拉取。
+			if !next.Before(periodEndTime) {
+				zap.L().Error("[fwh_pro] 事件/告警日志分页游标停滞,疑似同一秒内数据超过单页上限",
+					zap.String("sn", sn),
+					zap.Int("logType", int(t)),
+					zap.Time("stuckAt", next),
+					zap.Int("pageSize", int(REQ_PAGE_SIZE)),
+					zap.Int("accumulated", len(eventLogList)),
+				)
+				return nil, fmt.Errorf("event/alarm log pagination stalled at %s: more than %d rows within one second", next.Format(time.DateTime), REQ_PAGE_SIZE)
+			}
+			periodEndTime = next
 		}
 	}
 

+ 96 - 23
service/repositories/api/fwh_pro/fwh_faultRecord.go

@@ -1,6 +1,7 @@
 package fwh_pro
 
 import (
+	"context"
 	"fmt"
 	"fwh-toolbox-server/domain"
 	"net/url"
@@ -13,13 +14,17 @@ import (
 
 // 查询故障录波日志
 func queryFaultRecordList(sn string, startTime string, endTime string) (*domain.FwhResponse[[]domain.FaultRecordInfo], error) {
+	return queryFaultRecordListPage(sn, startTime, endTime, 1)
+}
+
+func queryFaultRecordListPage(sn, startTime, endTime string, page int) (*domain.FwhResponse[[]domain.FaultRecordInfo], error) {
 	// 构造 query 参数
 	params := url.Values{}
 	params.Add("deviseSerialNumber", sn)       // SN
 	params.Add("earliestStartTime", startTime) // 时间范围 - 开始时间 format YYYY-MM-DD hh:mm:ss
 	params.Add("earliestEndTime", endTime)     // 时间范围 - 结束时间 format YYYY-MM-DD hh:mm:ss
 	params.Add("pageSize", strconv.FormatUint(REQ_PAGE_SIZE, 10))
-	params.Add("pageNum", strconv.Itoa(1))
+	params.Add("pageNum", strconv.Itoa(page))
 	params.Add("lang", "ZH_CN")
 	response, err := FwhHttpGetUnmarshal[[]domain.FaultRecordInfo](getFullEnergyUrl("/hes-gateway/manage/faultRecordingLst"), params)
 	if err != nil {
@@ -30,16 +35,24 @@ func queryFaultRecordList(sn string, startTime string, endTime string) (*domain.
 
 // 查询故障录波日志
 func QueryFaultRecordList(sn string, startTime string, endTime string) ([]domain.FaultRecordInfo, error) {
-	faultRecordResp, err := queryFaultRecordList(sn, startTime, endTime)
-	if err != nil {
-		return nil, fmt.Errorf("[QueryFaultRecordList] %w", err)
-	}
-
-	if faultRecordResp.Code != 200 {
-		return nil, fmt.Errorf("[QueryFaultRecordList] 查询失败 sn=%s, startTime=%s, endTime=%s, code: %d, message: %s", sn, startTime, endTime, faultRecordResp.Code, faultRecordResp.Message)
+	var records []domain.FaultRecordInfo
+	for page := 1; page <= 100; page++ {
+		response, err := queryFaultRecordListPage(sn, startTime, endTime, page)
+		if err != nil {
+			return nil, fmt.Errorf("[QueryFaultRecordList] page %d: %w", page, err)
+		}
+		if response.Code != 200 {
+			return nil, fmt.Errorf("[QueryFaultRecordList] 查询失败 sn=%s, page=%d, code=%d, message=%s", sn, page, response.Code, response.Message)
+		}
+		if response.Result == nil {
+			return records, nil
+		}
+		records = append(records, (*response.Result)...)
+		if len(*response.Result) < int(REQ_PAGE_SIZE) {
+			return records, nil
+		}
 	}
-
-	return *faultRecordResp.Result, nil
+	return nil, fmt.Errorf("[QueryFaultRecordList] exceeded 100 pages for sn=%s", sn)
 }
 
 // 查询设备可召唤数据列表 - 仅自动录播
@@ -100,6 +113,9 @@ func QueryFaultRecordCallableList(devType int, sn string) ([]domain.FaultRecordC
 		}
 		return nil, fmt.Errorf("[QueryFaultRecordCallableList] 查询失败 devType=%d, sn=%s, code: %d, message: %s", devType, sn, faultRecordCallableResp.Code, faultRecordCallableResp.Message)
 	}
+	if faultRecordCallableResp.Result == nil {
+		return []domain.FaultRecordCallableInfo{}, nil
+	}
 
 	return *faultRecordCallableResp.Result, nil
 }
@@ -121,7 +137,7 @@ func queryCellFaultRecord(devType int, sn string, indexList []int) (*domain.FwhR
 		"deviceId":    sn,                              // aGate or aPower or aHub SN
 		"index":       strings.Join(indexListStr, ","), // 要召唤数据的idx
 		"callType":    devType,
-		"triggerType": 2,    // 触发类型,目前仅支持2-自动
+		"triggerType": 2, // 触发类型,目前仅支持2-自动
 		"lang":        "ZH_CN",
 	}
 
@@ -148,10 +164,14 @@ func QueryCellFaultRecord(devType int, sn string, indexList []int) error {
 
 // 查询故障录波召唤记录
 func queryFaultRecordCallRecordList() (*domain.FwhResponse[[]domain.FaultRecordInfo], error) {
+	return queryFaultRecordCallRecordPage(1, 50)
+}
+
+func queryFaultRecordCallRecordPage(pageNum, pageSize int) (*domain.FwhResponse[[]domain.FaultRecordInfo], error) {
 	// 构造 query 参数
 	params := url.Values{}
-	params.Add("pageSize", strconv.Itoa(50))
-	params.Add("pageNum", strconv.Itoa(1))
+	params.Add("pageSize", strconv.Itoa(pageSize))
+	params.Add("pageNum", strconv.Itoa(pageNum))
 	params.Add("lang", "ZH_CN")
 
 	response, err := FwhHttpGetUnmarshal[[]domain.FaultRecordInfo](getFullEnergyUrl("/hes-gateway/manage/lstFaultWaveCall"), params)
@@ -161,6 +181,47 @@ func queryFaultRecordCallRecordList() (*domain.FwhResponse[[]domain.FaultRecordI
 	return response, nil
 }
 
+// QueryFaultRecordCallRecordPage reads a chosen page of call records. The
+// upstream response has no total count, so callers should continue until a
+// page has fewer than pageSize entries.
+func QueryFaultRecordCallRecordPage(pageNum, pageSize int) ([]domain.FaultRecordInfo, error) {
+	if pageNum < 1 || pageSize < 1 || pageSize > int(REQ_PAGE_SIZE) {
+		return nil, fmt.Errorf("invalid fault-record call page %d size %d", pageNum, pageSize)
+	}
+	response, err := queryFaultRecordCallRecordPage(pageNum, pageSize)
+	if err != nil {
+		return nil, err
+	}
+	if response.Code != 200 {
+		return nil, fmt.Errorf("[QueryFaultRecordCallRecordPage] code: %d, message: %s", response.Code, response.Message)
+	}
+	if response.Result == nil {
+		return nil, fmt.Errorf("[QueryFaultRecordCallRecordPage] missing result on page %d", pageNum)
+	}
+	return *response.Result, nil
+}
+
+func QueryFaultRecordCallRecordPageContext(ctx context.Context, pageNum, pageSize int) ([]domain.FaultRecordInfo, error) {
+	if pageNum < 1 || pageSize < 1 || pageSize > int(REQ_PAGE_SIZE) {
+		return nil, fmt.Errorf("invalid fault-record call page %d size %d", pageNum, pageSize)
+	}
+	params := url.Values{}
+	params.Set("pageSize", strconv.Itoa(pageSize))
+	params.Set("pageNum", strconv.Itoa(pageNum))
+	params.Set("lang", "ZH_CN")
+	response, err := FwhHttpGetUnmarshalContext[[]domain.FaultRecordInfo](ctx, getFullEnergyUrl("/hes-gateway/manage/lstFaultWaveCall"), params)
+	if err != nil {
+		return nil, err
+	}
+	if response.Code != 200 {
+		return nil, fmt.Errorf("[QueryFaultRecordCallRecordPageContext] page=%d code=%d message=%s", pageNum, response.Code, response.Message)
+	}
+	if response.Result == nil {
+		return []domain.FaultRecordInfo{}, nil
+	}
+	return *response.Result, nil
+}
+
 // 查询故障录波召唤记录
 func QueryFaultRecordCallRecordList() ([]domain.FaultRecordInfo, error) {
 	faultRecordCallRecordResp, err := queryFaultRecordCallRecordList()
@@ -177,11 +238,15 @@ func QueryFaultRecordCallRecordList() ([]domain.FaultRecordInfo, error) {
 
 // 查询故障录波子记录
 func queryFaultRecordSubList(id int64) (*domain.FwhResponse[[]domain.FaultRecordSubInfo], error) {
+	return queryFaultRecordSubListPage(id, 1)
+}
+
+func queryFaultRecordSubListPage(id int64, page int) (*domain.FwhResponse[[]domain.FaultRecordSubInfo], error) {
 	// 构造 query 参数
 	params := url.Values{}
 	params.Add("faultId", strconv.FormatInt(id, 10))
 	params.Add("pageSize", strconv.FormatUint(REQ_PAGE_SIZE, 10))
-	params.Add("pageNum", strconv.Itoa(1))
+	params.Add("pageNum", strconv.Itoa(page))
 	params.Add("lang", "ZH_CN")
 
 	response, err := FwhHttpGetUnmarshal[[]domain.FaultRecordSubInfo](getFullEnergyUrl("/hes-gateway/manage/lstSubFaultWave"), params)
@@ -193,16 +258,24 @@ func queryFaultRecordSubList(id int64) (*domain.FwhResponse[[]domain.FaultRecord
 
 // 查询故障录波子记录
 func QueryFaultRecordSubList(id int64) ([]domain.FaultRecordSubInfo, error) {
-	faultRecordSubResp, err := queryFaultRecordSubList(id)
-	if err != nil {
-		return nil, fmt.Errorf("[QueryFaultRecordSubList] %w", err)
-	}
-
-	if faultRecordSubResp.Code != 200 {
-		return nil, fmt.Errorf("[QueryFaultRecordSubList] 查询失败 faultId=%d, code: %d, message: %s", id, faultRecordSubResp.Code, faultRecordSubResp.Message)
+	var subs []domain.FaultRecordSubInfo
+	for page := 1; page <= 100; page++ {
+		response, err := queryFaultRecordSubListPage(id, page)
+		if err != nil {
+			return nil, fmt.Errorf("[QueryFaultRecordSubList] page %d: %w", page, err)
+		}
+		if response.Code != 200 {
+			return nil, fmt.Errorf("[QueryFaultRecordSubList] 查询失败 faultId=%d, page=%d, code=%d, message=%s", id, page, response.Code, response.Message)
+		}
+		if response.Result == nil {
+			return subs, nil
+		}
+		subs = append(subs, (*response.Result)...)
+		if len(*response.Result) < int(REQ_PAGE_SIZE) {
+			return subs, nil
+		}
 	}
-
-	return *faultRecordSubResp.Result, nil
+	return nil, fmt.Errorf("[QueryFaultRecordSubList] exceeded 100 pages for faultId=%d", id)
 }
 
 // 请求下载录波数据

+ 41 - 1
service/repositories/api/fwh_pro/fwh_mqttLog.go

@@ -8,6 +8,8 @@ import (
 	"strconv"
 	"strings"
 	"time"
+
+	"go.uber.org/zap"
 )
 
 type QueryMqttLogParam struct {
@@ -76,6 +78,12 @@ func queryMqttLogListOnePage(sn string, cmdType domain.MqttCmdType, startTime, e
 }
 
 // 从FWH获取MQTT log数据
+//
+// 限制条件(重要):startTime/endTime 必须为「不含时区」的字面时间(用 UTC 表示)。
+// 本函数按天拆分时间段时用 time.Truncate(24h) 推进游标,Truncate 按 UTC 零点对齐;
+// 若传入带非 UTC 时区(如 America/Chicago)的时间,游标会回退到前一天,
+// currTime.Before(endTime) 恒成立,periods 切片无限 append,导致内存 OOM。
+// 调用方(DataDownload)需先用 stripTimeLocation 剥离时区再传入。
 func QueryMqttLogListOnTimeRange(sn string, cmdType domain.MqttCmdType, startTime, endTime time.Time) ([]domain.MqttLogItem, error) {
 	// 保证 startTime <= endTime
 	if startTime.After(endTime) {
@@ -100,15 +108,27 @@ func QueryMqttLogListOnTimeRange(sn string, cmdType domain.MqttCmdType, startTim
 		currTime = dayEnd.Add(time.Second).Truncate(24 * time.Hour)
 		currTime = time.Date(currTime.Year(), currTime.Month(), currTime.Day(), 0, 0, 0, 0, currTime.Location())
 	}
+	zap.L().Debug("[fwh_pro] MQTT periods 构建完成",
+		zap.String("sn", sn), zap.Any("cmdType", cmdType),
+		zap.Time("start", startTime), zap.Time("end", endTime),
+		zap.Int("periods", len(periods)),
+	)
 
 	// 按天查询
 	var mqttLogList []domain.MqttLogItem
 	for _, period := range periods {
 		var mqttLogRes []domain.MqttLogItem = nil
 		periodEndTime := period.End
+		pages := 0
 		// 检查数据是否超过1000(单次查询上限)
 		for {
 			var err error
+			pages++
+			zap.L().Debug("[fwh_pro] MQTT 分页请求发起",
+				zap.String("sn", sn), zap.Any("cmdType", cmdType),
+				zap.Time("periodStart", period.Start), zap.Time("periodEnd", periodEndTime),
+				zap.Int("page", pages), zap.Int("accumulated", len(mqttLogList)),
+			)
 			mqttLogRes, err = queryMqttLogListOnePage(sn, cmdType, period.Start, periodEndTime)
 			if err != nil {
 				return nil, err
@@ -117,6 +137,11 @@ func QueryMqttLogListOnTimeRange(sn string, cmdType domain.MqttCmdType, startTim
 			time.Sleep(time.Second) // 延时1s
 
 			mqttLogList = append(mqttLogList, mqttLogRes...)
+			zap.L().Debug("[fwh_pro] MQTT 分页请求返回",
+				zap.String("sn", sn), zap.Any("cmdType", cmdType),
+				zap.Int("page", pages), zap.Int("pageRows", len(mqttLogRes)),
+				zap.Int("accumulated", len(mqttLogList)),
+			)
 
 			// 判断是否未查完
 			if len(mqttLogRes) < int(REQ_PAGE_SIZE) {
@@ -126,10 +151,25 @@ func QueryMqttLogListOnTimeRange(sn string, cmdType domain.MqttCmdType, startTim
 
 			// 跳转时间段,再次请求
 			// 提取最后一条数据的设备时间
-			periodEndTime, err = time.Parse(time.DateTime, mqttLogRes[len(mqttLogRes)-1].LogTime)
+			next, err := time.Parse(time.DateTime, mqttLogRes[len(mqttLogRes)-1].LogTime)
 			if err != nil {
 				return nil, err
 			}
+
+			// 游标未能前进:说明同一秒内 >= REQ_PAGE_SIZE(1000) 条数据,
+			// 单页游标(按最后一条 LogTime)无法翻过这一秒,继续循环会死循环并 OOM。
+			// 现实中基本不会发生,此处仅作防护:记录错误并返回,不再无限拉取。
+			if !next.Before(periodEndTime) {
+				zap.L().Error("[fwh_pro] MQTT 日志分页游标停滞,疑似同一秒内数据超过单页上限",
+					zap.String("sn", sn),
+					zap.Any("cmdType", cmdType),
+					zap.Time("stuckAt", next),
+					zap.Int("pageSize", int(REQ_PAGE_SIZE)),
+					zap.Int("accumulated", len(mqttLogList)),
+				)
+				return nil, fmt.Errorf("mqtt log pagination stalled at %s: more than %d rows within one second", next.Format(time.DateTime), REQ_PAGE_SIZE)
+			}
+			periodEndTime = next
 		}
 	}
 

+ 5 - 0
service/repositories/api/fwh_pro/fwh_powerFlow.go

@@ -23,6 +23,11 @@ func querySitePowerFlow(sn string, day time.Time) (*domain.FwhResponseA[dto.Site
 }
 
 // 从fwh_pro获取站点功率流
+//
+// 限制条件:day 仅取其字面「年月日」(内部 day.Format(time.DateOnly)),时分秒与时区被丢弃。
+// 调用方需保证 day 的字面日期就是目标日期;若传入的时间因时区换算会跨天(如本地 00:00 换算到
+// UTC 落到前一天),会查错日期。DataDownload 传本地时区当天 00:00,字面日期正确。
+// 本函数不做按天分片,无 QueryMqttLogListOnTimeRange 那类 Truncate 死循环风险。
 func QuerySitePowerFlow(site string, day time.Time) (*domain.FwhResponseA[dto.SitePowerFlow], error) {
 	sitePowerFlowRes, err := querySitePowerFlow(site, day)
 	if err != nil {

+ 5 - 3
service/repositories/api/fwh_pro/fwh_update.go

@@ -38,13 +38,15 @@ func QuerySystemUpdateLog(sn string) ([]domain.SystemUpdateRecordInfo, error) {
 }
 
 // 查询系统升级记录详细信息
-func querySystemUpdateLogDetail(orderId int64) (*domain.FwhResponse[[]domain.SystemUpdateDetailInfo], error) {
+func querySystemUpdateLogDetail(orderId int64, sn string) (*domain.FwhResponse[[]domain.SystemUpdateDetailInfo], error) {
 	// 要发送的表单数据
 	form := url.Values{}
 	form.Set("optType", "1")                            // 1-系统升级记录 2-下载记录(分步升级相关)
 	form.Set("orderId", strconv.FormatInt(orderId, 10)) // 升级单ID
+	form.Set("serials", sn)                             // SN
 	form.Set("pageSize", strconv.FormatUint(REQ_PAGE_SIZE, 10))
 	form.Set("pageNum", "1")
+	form.Set("lang", "ZH_CN")
 
 	response, err := FwhHttpPostUnmarshal[[]domain.SystemUpdateDetailInfo](getFullEnergyUrl("/hes-gateway/manage/selectIotFirmwareOrderDetailList"), form)
 	if err != nil {
@@ -54,8 +56,8 @@ func querySystemUpdateLogDetail(orderId int64) (*domain.FwhResponse[[]domain.Sys
 }
 
 // 查询系统升级记录详细信息
-func QuerySystemUpdateLogDetail(orderId int64) ([]domain.SystemUpdateDetailInfo, error) {
-	systemUpdateLogDetail, err := querySystemUpdateLogDetail(orderId)
+func QuerySystemUpdateLogDetail(orderId int64, sn string) ([]domain.SystemUpdateDetailInfo, error) {
+	systemUpdateLogDetail, err := querySystemUpdateLogDetail(orderId, sn)
 	if err != nil {
 		return nil, fmt.Errorf("[QuerySystemUpdateLogDetail] %s", err.Error())
 	}

+ 5 - 3
service/repositories/api/fwh_test/fwh_update.go

@@ -38,13 +38,15 @@ func QuerySystemUpdateLog(sn string) ([]domain.SystemUpdateRecordInfo, error) {
 }
 
 // 查询系统升级记录详细信息
-func querySystemUpdateLogDetail(orderId int64) (*domain.FwhResponse[[]domain.SystemUpdateDetailInfo], error) {
+func querySystemUpdateLogDetail(orderId int64, sn string) (*domain.FwhResponse[[]domain.SystemUpdateDetailInfo], error) {
 	// 要发送的表单数据
 	form := url.Values{}
 	form.Set("optType", "1")                            // 1-系统升级记录 2-下载记录(分步升级相关)
 	form.Set("orderId", strconv.FormatInt(orderId, 10)) // 升级单ID
+	form.Set("serials", sn)                             // SN
 	form.Set("pageSize", strconv.FormatUint(REQ_PAGE_SIZE, 10))
 	form.Set("pageNum", "1")
+	form.Set("lang", "ZH_CN")
 
 	response, err := FwhHttpPostUnmarshal[[]domain.SystemUpdateDetailInfo](getFullEnergyUrl("/hes-gateway/manage/selectIotFirmwareOrderDetailList"), form)
 	if err != nil {
@@ -54,8 +56,8 @@ func querySystemUpdateLogDetail(orderId int64) (*domain.FwhResponse[[]domain.Sys
 }
 
 // 查询系统升级记录详细信息
-func QuerySystemUpdateLogDetail(orderId int64) ([]domain.SystemUpdateDetailInfo, error) {
-	systemUpdateLogDetail, err := querySystemUpdateLogDetail(orderId)
+func QuerySystemUpdateLogDetail(orderId int64, sn string) ([]domain.SystemUpdateDetailInfo, error) {
+	systemUpdateLogDetail, err := querySystemUpdateLogDetail(orderId, sn)
 	if err != nil {
 		return nil, fmt.Errorf("[QuerySystemUpdateLogDetail] %s", err.Error())
 	}

+ 21 - 0
service/repositories/init.go

@@ -0,0 +1,21 @@
+package repositories
+
+import (
+	"context"
+	"fmt"
+
+	mongoRepo "fwh-toolbox-server/repositories/mongo"
+	pgsqlRepo "fwh-toolbox-server/repositories/pgsql"
+)
+
+// Init initializes repository-owned schemas and indexes after database and
+// object-store clients are connected, but before schedulers and workers start.
+func Init(ctx context.Context) error {
+	if err := mongoRepo.Init(ctx); err != nil {
+		return fmt.Errorf("initialize MongoDB repositories: %w", err)
+	}
+	if err := pgsqlRepo.Init(ctx); err != nil {
+		return fmt.Errorf("initialize PostgreSQL repositories: %w", err)
+	}
+	return nil
+}

+ 51 - 0
service/repositories/minio/dataDownloadRepo.go

@@ -0,0 +1,51 @@
+package minio
+
+import (
+	"bytes"
+	"context"
+	"fmt"
+	"io"
+	"path"
+	"strings"
+	"time"
+
+	minioUtil "fwh-toolbox-server/utils/minio"
+)
+
+const (
+	renderPrefix  = "DataDownloadRenderCache/"
+	packagePrefix = "DataDownloadPackage/"
+	binaryPrefix  = "DataDownloadDocument/"
+)
+
+func PutRenderArtifact(ctx context.Context, documentID string, generation int64, format string, data []byte, contentType string) (string, error) {
+	if strings.Contains(format, "/") || strings.Contains(format, "\\") {
+		return "", fmt.Errorf("invalid format")
+	}
+	key := fmt.Sprintf("%s%s/%d/%d.%s", renderPrefix, documentID, generation, time.Now().UnixNano(), format)
+	if err := minioUtil.UploadFromReader(bytes.NewReader(data), int64(len(data)), key, contentType); err != nil {
+		return "", err
+	}
+	return key, nil
+}
+
+func PutPackage(ctx context.Context, name string, data []byte) (string, error) {
+	key := fmt.Sprintf("%s%d-%s", packagePrefix, time.Now().UnixNano(), path.Base(name))
+	if err := minioUtil.UploadFromReader(bytes.NewReader(data), int64(len(data)), key, "application/zip"); err != nil {
+		return "", err
+	}
+	return key, nil
+}
+
+func PutBinaryDocument(ctx context.Context, documentID, filename string, reader io.Reader, size int64, contentType string) (string, error) {
+	key := fmt.Sprintf("%s%s/%d-%s", binaryPrefix, documentID, time.Now().UnixNano(), path.Base(filename))
+	if err := minioUtil.UploadFromReader(reader, size, key, contentType); err != nil {
+		return "", err
+	}
+	return key, nil
+}
+
+func Open(ctx context.Context, objectKey string) (io.ReadCloser, error) {
+	return minioUtil.GetObject(objectKey)
+}
+func Delete(ctx context.Context, objectKey string) error { return minioUtil.DeleteFile(objectKey) }

+ 658 - 0
service/repositories/mongo/dataDownloadRepo.go

@@ -0,0 +1,658 @@
+package mongo
+
+import (
+	"context"
+	"errors"
+	"fmt"
+	"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 (
+	ddTasks        = "data_download_tasks"
+	ddItems        = "data_download_items"
+	ddDocuments    = "data_download_documents"
+	ddTaskDocs     = "data_download_task_documents"
+	ddRenderSlots  = "data_download_render_slots"
+	ddArtifacts    = "data_download_artifacts"
+	ddPackages     = "data_download_package_jobs"
+	ddFaultEntries = "data_download_fault_record_entries"
+	ddFileAttempts = "data_download_file_attempts"
+	ddFaultCalls   = "data_download_fault_calls"
+)
+
+func InitDataDownloadIndexes(ctx context.Context) error {
+	indexes := map[string][]mongo.IndexModel{
+		ddTasks: {
+			{Keys: bson.D{{Key: "ownerId", Value: 1}, {Key: "createdAt", Value: -1}}},
+			{Keys: bson.D{{Key: "status", Value: 1}, {Key: "updatedAt", Value: 1}}},
+		},
+		ddItems: {
+			{Keys: bson.D{{Key: "taskId", Value: 1}, {Key: "itemKey", Value: 1}}, Options: options.Index().SetUnique(true)},
+			{Keys: bson.D{{Key: "taskId", Value: 1}, {Key: "status", Value: 1}}},
+		},
+		ddFileAttempts: {
+			{Keys: bson.D{{Key: "taskId", Value: 1}, {Key: "itemId", Value: 1}, {Key: "sourceFileId", Value: 1}}, Options: options.Index().SetUnique(true)},
+			{Keys: bson.D{{Key: "taskId", Value: 1}, {Key: "status", Value: 1}}},
+		},
+		ddFaultCalls: {
+			{Keys: bson.D{{Key: "expiresAt", Value: 1}}, Options: options.Index().SetExpireAfterSeconds(0)},
+		},
+		ddDocuments: {
+			{Keys: bson.D{{Key: "requestKey", Value: 1}}, Options: options.Index().SetUnique(true)},
+			{Keys: bson.D{{Key: "kind", Value: 1}, {Key: "siteSn", Value: 1}, {Key: "date", Value: 1}}},
+		},
+		ddFaultEntries: {
+			{Keys: bson.D{{Key: "documentId", Value: 1}, {Key: "sourceFileId", Value: 1}}, Options: options.Index().SetUnique(true)},
+			{Keys: bson.D{{Key: "documentId", Value: 1}, {Key: "fileName", Value: 1}}},
+		},
+		ddTaskDocs: {
+			{Keys: bson.D{{Key: "taskId", Value: 1}, {Key: "itemKey", Value: 1}, {Key: "documentId", Value: 1}}, Options: options.Index().SetUnique(true)},
+			{Keys: bson.D{{Key: "documentId", Value: 1}}},
+		},
+		ddRenderSlots: {
+			{Keys: bson.D{{Key: "documentId", Value: 1}, {Key: "format", Value: 1}}, Options: options.Index().SetUnique(true)},
+		},
+		ddArtifacts: {
+			{Keys: bson.D{{Key: "documentId", Value: 1}, {Key: "format", Value: 1}, {Key: "contentGeneration", Value: 1}}},
+			{Keys: bson.D{{Key: "expiresAt", Value: 1}}},
+		},
+		ddPackages: {
+			{Keys: bson.D{{Key: "ownerId", Value: 1}, {Key: "createdAt", Value: -1}}},
+			{Keys: bson.D{{Key: "taskIds", Value: 1}}},
+			{Keys: bson.D{{Key: "purgeAt", Value: 1}}},
+		},
+	}
+	for collection, models := range indexes {
+		if _, err := database.GetCollection(collection).Indexes().CreateMany(ctx, models); err != nil {
+			return fmt.Errorf("create %s indexes: %w", collection, err)
+		}
+	}
+	return nil
+}
+
+func InsertDataDownloadTask(ctx context.Context, task *domain.DataDownloadTask) error {
+	result, err := database.GetCollection(ddTasks).InsertOne(ctx, task)
+	if err == nil {
+		task.ID = result.InsertedID.(primitive.ObjectID)
+	}
+	return err
+}
+
+func GetDataDownloadTask(ctx context.Context, id primitive.ObjectID) (*domain.DataDownloadTask, error) {
+	var task domain.DataDownloadTask
+	err := database.GetCollection(ddTasks).FindOne(ctx, bson.M{"_id": id}).Decode(&task)
+	return &task, err
+}
+
+func ListDataDownloadTasks(ctx context.Context, ownerID string, mineOnly bool, description, sn, status string, createdFrom, createdTo *time.Time, page, pageSize int64) ([]domain.DataDownloadTask, int64, error) {
+	filter := bson.M{}
+	if mineOnly {
+		filter["ownerId"] = ownerID
+	}
+	if description != "" {
+		filter["description"] = primitive.Regex{Pattern: regexpQuote(description), Options: "i"}
+	}
+	if sn != "" {
+		filter["sns"] = sn
+	}
+	if status != "" {
+		filter["status"] = status
+	}
+	if createdFrom != nil || createdTo != nil {
+		rangeFilter := bson.M{}
+		if createdFrom != nil {
+			rangeFilter["$gte"] = *createdFrom
+		}
+		if createdTo != nil {
+			rangeFilter["$lte"] = *createdTo
+		}
+		filter["createdAt"] = rangeFilter
+	}
+	coll := database.GetCollection(ddTasks)
+	total, err := coll.CountDocuments(ctx, filter)
+	if err != nil {
+		return nil, 0, err
+	}
+	cursor, err := coll.Find(ctx, filter, options.Find().SetSort(bson.D{{Key: "createdAt", Value: -1}}).SetSkip((page-1)*pageSize).SetLimit(pageSize))
+	if err != nil {
+		return nil, 0, err
+	}
+	defer cursor.Close(ctx)
+	tasks := make([]domain.DataDownloadTask, 0)
+	err = cursor.All(ctx, &tasks)
+	return tasks, total, err
+}
+
+func regexpQuote(value string) string {
+	quoted := ""
+	for _, r := range value {
+		if r == '\\' || r == '.' || r == '+' || r == '*' || r == '?' || r == '(' || r == ')' || r == '[' || r == ']' || r == '{' || r == '}' || r == '^' || r == '$' || r == '|' {
+			quoted += "\\"
+		}
+		quoted += string(r)
+	}
+	return quoted
+}
+
+func UpdateDataDownloadTaskStatus(ctx context.Context, id primitive.ObjectID, status domain.DataDownloadStatus) error {
+	update := bson.M{"$set": bson.M{"status": status, "updatedAt": time.Now().UTC()}}
+	if status == domain.DataDownloadSucceeded || status == domain.DataDownloadPartial || status == domain.DataDownloadFailed || status == domain.DataDownloadError || status == domain.DataDownloadCancelled {
+		now := time.Now().UTC()
+		update["$set"].(bson.M)["finishedAt"] = now
+	}
+	result, err := database.GetCollection(ddTasks).UpdateByID(ctx, id, update)
+	if err == nil && result.MatchedCount != 1 {
+		return mongo.ErrNoDocuments
+	}
+	return err
+}
+
+func InsertDataDownloadItems(ctx context.Context, items []domain.DataDownloadItem) error {
+	if len(items) == 0 {
+		return nil
+	}
+	models := make([]mongo.WriteModel, len(items))
+	keys := make([]string, len(items))
+	for i := range items {
+		keys[i] = items[i].ItemKey
+		models[i] = mongo.NewUpdateOneModel().
+			SetFilter(bson.M{"taskId": items[i].TaskID, "itemKey": items[i].ItemKey}).
+			SetUpdate(bson.M{"$setOnInsert": items[i]}).
+			SetUpsert(true)
+	}
+	coll := database.GetCollection(ddItems)
+	if _, err := coll.BulkWrite(ctx, models, options.BulkWrite().SetOrdered(false)); err != nil {
+		return err
+	}
+	cursor, err := coll.Find(ctx, bson.M{"taskId": items[0].TaskID, "itemKey": bson.M{"$in": keys}})
+	if err != nil {
+		return err
+	}
+	defer cursor.Close(ctx)
+	var persisted []domain.DataDownloadItem
+	if err := cursor.All(ctx, &persisted); err != nil {
+		return err
+	}
+	byKey := make(map[string]primitive.ObjectID, len(persisted))
+	for i := range persisted {
+		byKey[persisted[i].ItemKey] = persisted[i].ID
+	}
+	for i := range items {
+		id, ok := byKey[items[i].ItemKey]
+		if !ok {
+			return fmt.Errorf("data-download item %q was not persisted", items[i].ItemKey)
+		}
+		items[i].ID = id
+	}
+	return nil
+}
+
+func GetDataDownloadItem(ctx context.Context, id primitive.ObjectID) (*domain.DataDownloadItem, error) {
+	var item domain.DataDownloadItem
+	err := database.GetCollection(ddItems).FindOne(ctx, bson.M{"_id": id}).Decode(&item)
+	return &item, err
+}
+
+func ListDataDownloadItems(ctx context.Context, taskID primitive.ObjectID) ([]domain.DataDownloadItem, error) {
+	cursor, err := database.GetCollection(ddItems).Find(ctx, bson.M{"taskId": taskID}, options.Find().SetSort(bson.D{{Key: "itemKey", Value: 1}}))
+	if err != nil {
+		return nil, err
+	}
+	defer cursor.Close(ctx)
+	items := make([]domain.DataDownloadItem, 0)
+	err = cursor.All(ctx, &items)
+	return items, err
+}
+
+func UpdateDataDownloadItem(ctx context.Context, id primitive.ObjectID, status domain.DataDownloadStatus, stage, reason string) error {
+	update := bson.M{"$set": bson.M{"status": status, "stage": stage, "failureReason": reason, "updatedAt": time.Now().UTC()}}
+	if status == domain.DataDownloadRunning {
+		update["$inc"] = bson.M{"attempt": 1}
+	}
+	result, err := database.GetCollection(ddItems).UpdateByID(ctx, id, update)
+	if err == nil && result.MatchedCount != 1 {
+		return mongo.ErrNoDocuments
+	}
+	return err
+}
+
+func UpdateDataDownloadFaultCall(ctx context.Context, id primitive.ObjectID, requestedAt *time.Time, requestedCount int, recordID int64, status *int) error {
+	set := bson.M{"updatedAt": time.Now().UTC()}
+	if requestedAt != nil {
+		set["callRequestedAt"] = *requestedAt
+	}
+	if requestedCount > 0 {
+		set["callRequestedCount"] = requestedCount
+	}
+	if recordID != 0 {
+		set["callRecordId"] = recordID
+	}
+	if status != nil {
+		set["callRecordStatus"] = *status
+	}
+	result, err := database.GetCollection(ddItems).UpdateByID(ctx, id, bson.M{"$set": set})
+	if err == nil && result.MatchedCount != 1 {
+		return mongo.ErrNoDocuments
+	}
+	return err
+}
+
+func ResetDataDownloadFaultCall(ctx context.Context, id primitive.ObjectID) error {
+	result, err := database.GetCollection(ddItems).UpdateByID(ctx, id, bson.M{
+		"$unset": bson.M{"callRequestedAt": "", "callRequestedCount": "", "callRecordId": "", "callRecordStatus": ""},
+		"$set":   bson.M{"updatedAt": time.Now().UTC()},
+	})
+	if err == nil && result.MatchedCount != 1 {
+		return mongo.ErrNoDocuments
+	}
+	return err
+}
+
+// TryOwnDataDownloadFaultCall keeps the same logical owner across delayed
+// queue executions. A different item cannot start another call for the same
+// document until the owner releases it or the original 2-hour window expires.
+func TryOwnDataDownloadFaultCall(ctx context.Context, documentID string, itemID primitive.ObjectID, requestedAt time.Time) (bool, error) {
+	now := time.Now().UTC()
+	expiresAt := requestedAt.Add(2*time.Hour + 10*time.Minute)
+	filter := bson.M{"_id": documentID, "$or": bson.A{bson.M{"itemId": itemID}, bson.M{"expiresAt": bson.M{"$lte": now}}}}
+	update := bson.M{"$set": bson.M{"itemId": itemID, "requestedAt": requestedAt, "expiresAt": expiresAt, "updatedAt": now}}
+	result, err := database.GetCollection(ddFaultCalls).UpdateOne(ctx, filter, update, options.Update().SetUpsert(true))
+	if mongo.IsDuplicateKeyError(err) {
+		return false, nil
+	}
+	if err != nil {
+		return false, err
+	}
+	return result.MatchedCount == 1 || result.UpsertedCount == 1, nil
+}
+
+func OtherDataDownloadFaultCallActive(ctx context.Context, documentID string, itemID primitive.ObjectID) (bool, error) {
+	var owner struct {
+		ItemID    primitive.ObjectID `bson:"itemId"`
+		ExpiresAt time.Time          `bson:"expiresAt"`
+	}
+	err := database.GetCollection(ddFaultCalls).FindOne(ctx, bson.M{"_id": documentID}).Decode(&owner)
+	if errors.Is(err, mongo.ErrNoDocuments) {
+		return false, nil
+	}
+	if err != nil {
+		return false, err
+	}
+	return owner.ItemID != itemID && owner.ExpiresAt.After(time.Now().UTC()), nil
+}
+
+func ReleaseDataDownloadFaultCall(ctx context.Context, documentID string, itemID primitive.ObjectID) error {
+	_, err := database.GetCollection(ddFaultCalls).DeleteOne(ctx, bson.M{"_id": documentID, "itemId": itemID})
+	return err
+}
+
+func DeferDataDownloadItem(ctx context.Context, id primitive.ObjectID) error {
+	result, err := database.GetCollection(ddItems).UpdateByID(ctx, id, bson.M{
+		"$set": bson.M{"status": domain.DataDownloadPending, "stage": "waiting", "failureReason": "", "updatedAt": time.Now().UTC()},
+		"$inc": bson.M{"attempt": -1},
+	})
+	if err == nil && result.MatchedCount != 1 {
+		return mongo.ErrNoDocuments
+	}
+	return err
+}
+
+func RecountDataDownloadTask(ctx context.Context, taskID primitive.ObjectID) error {
+	pipeline := mongo.Pipeline{
+		{{Key: "$match", Value: bson.M{"taskId": taskID}}},
+		{{Key: "$group", Value: bson.M{"_id": "$status", "count": bson.M{"$sum": 1}}}},
+	}
+	cursor, err := database.GetCollection(ddItems).Aggregate(ctx, pipeline)
+	if err != nil {
+		return err
+	}
+	defer cursor.Close(ctx)
+	type count struct {
+		ID    domain.DataDownloadStatus `bson:"_id"`
+		Count int64                     `bson:"count"`
+	}
+	var counts []count
+	if err := cursor.All(ctx, &counts); err != nil {
+		return err
+	}
+	set := bson.M{"updatedAt": time.Now().UTC(), "succeeded": int64(0), "failed": int64(0), "empty": int64(0)}
+	var total int64
+	for _, c := range counts {
+		total += c.Count
+		switch c.ID {
+		case domain.DataDownloadItemSucceeded, domain.DataDownloadSkipped, domain.DataDownloadPartial:
+			set["succeeded"] = set["succeeded"].(int64) + c.Count
+		case domain.DataDownloadFailed:
+			set["failed"] = c.Count
+		}
+	}
+	empty, err := database.GetCollection(ddTaskDocs).CountDocuments(ctx, bson.M{"taskId": taskID, "empty": true})
+	if err != nil {
+		return err
+	}
+	set["empty"] = empty
+	set["total"] = total
+	_, err = database.GetCollection(ddTasks).UpdateByID(ctx, taskID, bson.M{"$set": set})
+	return err
+}
+
+func UpsertDataDownloadDocument(ctx context.Context, document *domain.DataDownloadDocument) error {
+	_, err := database.GetCollection(ddDocuments).ReplaceOne(ctx, bson.M{"_id": document.ID}, document, options.Replace().SetUpsert(true))
+	return err
+}
+
+func GetDataDownloadDocument(ctx context.Context, id string) (*domain.DataDownloadDocument, error) {
+	var document domain.DataDownloadDocument
+	err := database.GetCollection(ddDocuments).FindOne(ctx, bson.M{"_id": id}).Decode(&document)
+	document.HasBinary = document.BinaryObjectKey != "" || document.FileCount > 0
+	return &document, err
+}
+
+func ListDataDownloadDocuments(ctx context.Context, page, pageSize int64) ([]domain.DataDownloadDocument, int64, error) {
+	coll := database.GetCollection(ddDocuments)
+	total, err := coll.CountDocuments(ctx, bson.M{})
+	if err != nil {
+		return nil, 0, err
+	}
+	cursor, err := coll.Find(ctx, bson.M{}, options.Find().SetSort(bson.D{{Key: "updatedAt", Value: -1}}).SetSkip((page-1)*pageSize).SetLimit(pageSize))
+	if err != nil {
+		return nil, 0, err
+	}
+	defer cursor.Close(ctx)
+	documents := make([]domain.DataDownloadDocument, 0)
+	err = cursor.All(ctx, &documents)
+	for i := range documents {
+		documents[i].HasBinary = documents[i].BinaryObjectKey != "" || documents[i].FileCount > 0
+	}
+	return documents, total, err
+}
+
+func CountDataDownloadDocumentReferences(ctx context.Context, documentID string) (int64, error) {
+	return database.GetCollection(ddTaskDocs).CountDocuments(ctx, bson.M{"documentId": documentID})
+}
+
+func ListDataDownloadArtifacts(ctx context.Context, documentID string) ([]domain.DataDownloadArtifact, error) {
+	cursor, err := database.GetCollection(ddArtifacts).Find(ctx, bson.M{"documentId": documentID})
+	if err != nil {
+		return nil, err
+	}
+	defer cursor.Close(ctx)
+	artifacts := make([]domain.DataDownloadArtifact, 0)
+	err = cursor.All(ctx, &artifacts)
+	return artifacts, err
+}
+
+func DeleteDataDownloadDocumentMetadata(ctx context.Context, documentID string) error {
+	if _, err := database.GetCollection(ddRenderSlots).DeleteMany(ctx, bson.M{"documentId": documentID}); err != nil {
+		return err
+	}
+	if _, err := database.GetCollection(ddArtifacts).DeleteMany(ctx, bson.M{"documentId": documentID}); err != nil {
+		return err
+	}
+	if _, err := database.GetCollection(ddFaultEntries).DeleteMany(ctx, bson.M{"documentId": documentID}); err != nil {
+		return err
+	}
+	result, err := database.GetCollection(ddDocuments).DeleteOne(ctx, bson.M{"_id": documentID})
+	if err == nil && result.DeletedCount != 1 {
+		return mongo.ErrNoDocuments
+	}
+	return err
+}
+
+func LinkDataDownloadDocument(ctx context.Context, link *domain.DataDownloadTaskDocument) error {
+	_, err := database.GetCollection(ddTaskDocs).InsertOne(ctx, link)
+	if mongo.IsDuplicateKeyError(err) {
+		return nil
+	}
+	return err
+}
+
+func ListDataDownloadTaskDocuments(ctx context.Context, taskID primitive.ObjectID) ([]domain.DataDownloadTaskDocument, error) {
+	cursor, err := database.GetCollection(ddTaskDocs).Find(ctx, bson.M{"taskId": taskID}, options.Find().SetSort(bson.D{{Key: "logicalPath", Value: 1}}))
+	if err != nil {
+		return nil, err
+	}
+	defer cursor.Close(ctx)
+	links := make([]domain.DataDownloadTaskDocument, 0)
+	err = cursor.All(ctx, &links)
+	return links, err
+}
+
+func DetachDataDownloadRenderSlots(ctx context.Context, documentID string) error {
+	_, err := database.GetCollection(ddRenderSlots).UpdateMany(ctx, bson.M{"documentId": documentID}, bson.M{"$unset": bson.M{"artifactId": ""}, "$set": bson.M{"updatedAt": time.Now().UTC()}})
+	return err
+}
+
+func GetDataDownloadRenderSlot(ctx context.Context, documentID, format string) (*domain.DataDownloadRenderSlot, error) {
+	var slot domain.DataDownloadRenderSlot
+	err := database.GetCollection(ddRenderSlots).FindOne(ctx, bson.M{"documentId": documentID, "format": format}).Decode(&slot)
+	return &slot, err
+}
+
+func GetDataDownloadArtifact(ctx context.Context, id primitive.ObjectID) (*domain.DataDownloadArtifact, error) {
+	var artifact domain.DataDownloadArtifact
+	err := database.GetCollection(ddArtifacts).FindOne(ctx, bson.M{"_id": id}).Decode(&artifact)
+	return &artifact, err
+}
+
+func PublishDataDownloadArtifact(ctx context.Context, artifact *domain.DataDownloadArtifact) error {
+	result, err := database.GetCollection(ddArtifacts).InsertOne(ctx, artifact)
+	if err != nil {
+		return err
+	}
+	artifact.ID = result.InsertedID.(primitive.ObjectID)
+	_, err = database.GetCollection(ddRenderSlots).UpdateOne(ctx,
+		bson.M{"documentId": artifact.DocumentID, "format": artifact.Format},
+		bson.M{"$set": bson.M{"contentGeneration": artifact.ContentGeneration, "artifactId": artifact.ID, "updatedAt": time.Now().UTC()}},
+		options.Update().SetUpsert(true))
+	return err
+}
+
+func UpdateDataDownloadDescription(ctx context.Context, id primitive.ObjectID, description string) error {
+	result, err := database.GetCollection(ddTasks).UpdateByID(ctx, id, bson.M{"$set": bson.M{"description": description, "updatedAt": time.Now().UTC()}})
+	if err == nil && result.MatchedCount != 1 {
+		return mongo.ErrNoDocuments
+	}
+	return err
+}
+
+func DeleteDataDownloadTask(ctx context.Context, id primitive.ObjectID) error {
+	if _, err := database.GetCollection(ddTaskDocs).DeleteMany(ctx, bson.M{"taskId": id}); err != nil {
+		return err
+	}
+	if _, err := database.GetCollection(ddItems).DeleteMany(ctx, bson.M{"taskId": id}); err != nil {
+		return err
+	}
+	if _, err := database.GetCollection(ddFileAttempts).DeleteMany(ctx, bson.M{"taskId": id}); err != nil {
+		return err
+	}
+	result, err := database.GetCollection(ddTasks).DeleteOne(ctx, bson.M{"_id": id})
+	if err == nil && result.DeletedCount != 1 {
+		return mongo.ErrNoDocuments
+	}
+	return err
+}
+
+func GetDataDownloadFileAttempt(ctx context.Context, taskID, itemID primitive.ObjectID, sourceFileID string) (*domain.DataDownloadFileAttempt, error) {
+	var attempt domain.DataDownloadFileAttempt
+	err := database.GetCollection(ddFileAttempts).FindOne(ctx, bson.M{"taskId": taskID, "itemId": itemID, "sourceFileId": sourceFileID}).Decode(&attempt)
+	return &attempt, err
+}
+
+func RecordDataDownloadFileAttempt(ctx context.Context, attempt *domain.DataDownloadFileAttempt) error {
+	now := time.Now().UTC()
+	set := bson.M{
+		"documentId": attempt.DocumentID, "fileName": attempt.FileName, "status": attempt.Status,
+		"entryId": attempt.EntryID, "failureStage": attempt.FailureStage,
+		"failureReason": attempt.FailureReason, "updatedAt": now,
+	}
+	_, err := database.GetCollection(ddFileAttempts).UpdateOne(ctx,
+		bson.M{"taskId": attempt.TaskID, "itemId": attempt.ItemID, "sourceFileId": attempt.SourceFileID},
+		bson.M{"$set": set, "$setOnInsert": bson.M{"createdAt": now}, "$inc": bson.M{"attemptCount": 1}},
+		options.Update().SetUpsert(true))
+	return err
+}
+
+func ListDataDownloadFileAttempts(ctx context.Context, taskID primitive.ObjectID) ([]domain.DataDownloadFileAttempt, error) {
+	cursor, err := database.GetCollection(ddFileAttempts).Find(ctx, bson.M{"taskId": taskID}, options.Find().SetSort(bson.D{{Key: "itemId", Value: 1}, {Key: "sourceFileId", Value: 1}}))
+	if err != nil {
+		return nil, err
+	}
+	defer cursor.Close(ctx)
+	attempts := make([]domain.DataDownloadFileAttempt, 0)
+	err = cursor.All(ctx, &attempts)
+	return attempts, err
+}
+
+func DeleteDataDownloadFileAttempt(ctx context.Context, taskID, itemID primitive.ObjectID, sourceFileID string) error {
+	_, err := database.GetCollection(ddFileAttempts).DeleteOne(ctx, bson.M{
+		"taskId": taskID, "itemId": itemID, "sourceFileId": sourceFileID,
+	})
+	return err
+}
+
+func HasDataDownloadTaskDocument(ctx context.Context, taskID primitive.ObjectID, documentID string) (bool, error) {
+	count, err := database.GetCollection(ddTaskDocs).CountDocuments(ctx, bson.M{"taskId": taskID, "documentId": documentID})
+	return count > 0, err
+}
+
+func UpsertDataDownloadFaultRecordEntry(ctx context.Context, entry *domain.DataDownloadFaultRecordEntry) (string, error) {
+	var existing domain.DataDownloadFaultRecordEntry
+	err := database.GetCollection(ddFaultEntries).FindOne(ctx, bson.M{"documentId": entry.DocumentID, "sourceFileId": entry.SourceFileID}).Decode(&existing)
+	oldObjectKey := ""
+	if err == nil {
+		entry.ID = existing.ID
+		entry.CreatedAt = existing.CreatedAt
+		oldObjectKey = existing.ObjectKey
+	} else if !errors.Is(err, mongo.ErrNoDocuments) {
+		return "", err
+	}
+	if entry.ID.IsZero() {
+		entry.ID = primitive.NewObjectID()
+	}
+	if entry.CreatedAt.IsZero() {
+		entry.CreatedAt = entry.UpdatedAt
+	}
+	_, err = database.GetCollection(ddFaultEntries).ReplaceOne(ctx,
+		bson.M{"documentId": entry.DocumentID, "sourceFileId": entry.SourceFileID}, entry, options.Replace().SetUpsert(true))
+	return oldObjectKey, err
+}
+
+func GetDataDownloadFaultRecordEntry(ctx context.Context, documentID, sourceFileID string) (*domain.DataDownloadFaultRecordEntry, error) {
+	var entry domain.DataDownloadFaultRecordEntry
+	err := database.GetCollection(ddFaultEntries).FindOne(ctx, bson.M{"documentId": documentID, "sourceFileId": sourceFileID}).Decode(&entry)
+	return &entry, err
+}
+
+func GetDataDownloadFaultRecordEntryByID(ctx context.Context, id primitive.ObjectID) (*domain.DataDownloadFaultRecordEntry, error) {
+	var entry domain.DataDownloadFaultRecordEntry
+	err := database.GetCollection(ddFaultEntries).FindOne(ctx, bson.M{"_id": id}).Decode(&entry)
+	return &entry, err
+}
+
+func ListDataDownloadFaultRecordEntries(ctx context.Context, documentID string) ([]domain.DataDownloadFaultRecordEntry, error) {
+	cursor, err := database.GetCollection(ddFaultEntries).Find(ctx, bson.M{"documentId": documentID}, options.Find().SetSort(bson.D{{Key: "fileName", Value: 1}, {Key: "sourceFileId", Value: 1}}))
+	if err != nil {
+		return nil, err
+	}
+	defer cursor.Close(ctx)
+	entries := make([]domain.DataDownloadFaultRecordEntry, 0)
+	err = cursor.All(ctx, &entries)
+	return entries, err
+}
+
+func InsertDataDownloadPackageJob(ctx context.Context, job *domain.DataDownloadPackageJob) error {
+	result, err := database.GetCollection(ddPackages).InsertOne(ctx, job)
+	if err == nil {
+		job.ID = result.InsertedID.(primitive.ObjectID)
+	}
+	return err
+}
+
+func GetDataDownloadPackageJob(ctx context.Context, id primitive.ObjectID) (*domain.DataDownloadPackageJob, error) {
+	var job domain.DataDownloadPackageJob
+	err := database.GetCollection(ddPackages).FindOne(ctx, bson.M{"_id": id}).Decode(&job)
+	return &job, err
+}
+
+func ListDataDownloadPackageJobs(ctx context.Context, ownerID string, mineOnly bool, page, pageSize int64) ([]domain.DataDownloadPackageJob, int64, error) {
+	filter := bson.M{}
+	if mineOnly {
+		filter["ownerId"] = ownerID
+	}
+	coll := database.GetCollection(ddPackages)
+	total, err := coll.CountDocuments(ctx, filter)
+	if err != nil {
+		return nil, 0, err
+	}
+	cursor, err := coll.Find(ctx, filter, options.Find().SetSort(bson.D{{Key: "createdAt", Value: -1}}).SetSkip((page-1)*pageSize).SetLimit(pageSize))
+	if err != nil {
+		return nil, 0, err
+	}
+	defer cursor.Close(ctx)
+	jobs := make([]domain.DataDownloadPackageJob, 0)
+	err = cursor.All(ctx, &jobs)
+	return jobs, total, err
+}
+
+func UpdateDataDownloadPackageJob(ctx context.Context, id primitive.ObjectID, set bson.M) error {
+	set["updatedAt"] = time.Now().UTC()
+	result, err := database.GetCollection(ddPackages).UpdateByID(ctx, id, bson.M{"$set": set})
+	if err == nil && result.MatchedCount != 1 {
+		return mongo.ErrNoDocuments
+	}
+	return err
+}
+
+func DeleteDataDownloadPackageJob(ctx context.Context, id primitive.ObjectID) error {
+	result, err := database.GetCollection(ddPackages).DeleteOne(ctx, bson.M{"_id": id})
+	if err == nil && result.DeletedCount != 1 {
+		return mongo.ErrNoDocuments
+	}
+	return err
+}
+
+func FindExpiredDataDownloadPackageJobs(ctx context.Context, now time.Time) ([]domain.DataDownloadPackageJob, error) {
+	cursor, err := database.GetCollection(ddPackages).Find(ctx, bson.M{"status": domain.DataDownloadSucceeded, "expiresAt": bson.M{"$lte": now}})
+	if err != nil {
+		return nil, err
+	}
+	defer cursor.Close(ctx)
+	jobs := make([]domain.DataDownloadPackageJob, 0)
+	err = cursor.All(ctx, &jobs)
+	return jobs, err
+}
+
+func PurgeExpiredDataDownloadPackageJobs(ctx context.Context, now time.Time) error {
+	_, err := database.GetCollection(ddPackages).DeleteMany(ctx, bson.M{"status": "expired", "purgeAt": bson.M{"$lte": now}})
+	return err
+}
+
+func FindExpiredDataDownloadArtifacts(ctx context.Context, now time.Time) ([]domain.DataDownloadArtifact, error) {
+	cursor, err := database.GetCollection(ddArtifacts).Find(ctx, bson.M{"expiresAt": bson.M{"$lte": now}})
+	if err != nil {
+		return nil, err
+	}
+	defer cursor.Close(ctx)
+	artifacts := make([]domain.DataDownloadArtifact, 0)
+	err = cursor.All(ctx, &artifacts)
+	return artifacts, err
+}
+
+func DetachDataDownloadArtifact(ctx context.Context, artifact domain.DataDownloadArtifact) error {
+	_, err := database.GetCollection(ddRenderSlots).UpdateMany(ctx, bson.M{"artifactId": artifact.ID}, bson.M{"$unset": bson.M{"artifactId": ""}, "$set": bson.M{"updatedAt": time.Now().UTC()}})
+	return err
+}
+
+func DeleteDataDownloadArtifact(ctx context.Context, artifact domain.DataDownloadArtifact) error {
+	_, err := database.GetCollection(ddArtifacts).DeleteOne(ctx, bson.M{"_id": artifact.ID})
+	return err
+}

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

@@ -0,0 +1,8 @@
+package mongo
+
+import "context"
+
+// Init is the explicit registry for MongoDB repository indexes.
+func Init(ctx context.Context) error {
+	return InitDataDownloadIndexes(ctx)
+}

+ 294 - 0
service/repositories/pgsql/dataDownloadRepo.go

@@ -0,0 +1,294 @@
+package pgsql
+
+import (
+	"crypto/sha256"
+	"encoding/hex"
+	"encoding/json"
+	"fmt"
+	"math"
+	"strconv"
+	"strings"
+	"time"
+
+	"fwh-toolbox-server/database"
+	"fwh-toolbox-server/domain"
+	"fwh-toolbox-server/dto"
+
+	"gorm.io/gorm"
+)
+
+type DataDownloadRow struct {
+	OccurredAt *time.Time
+	Subtype    string
+	DeviceSN   string
+	DeviceType string
+	Payload    any
+}
+
+type powerFlowPoint struct {
+	Sequence int `json:"sequence"`
+	// DeviceTimeLocal preserves the wall-clock value reported by the device;
+	// DataDownloadRow.OccurredAt stores the same instant normalized to UTC.
+	DeviceTimeLocal                                                  string `json:"deviceTimeLocal"`
+	PowerFhp, PowerGen, PowerLoad, PowerSun, PowerUti, Soc, KwhTotal *float32
+	RunStatus                                                        *int32 `json:"runStatus"`
+}
+
+type DataDownloadContentState struct {
+	DocumentID        string    `gorm:"column:document_id" json:"documentId"`
+	DocumentKind      string    `gorm:"column:document_kind" json:"documentKind"`
+	ContentGeneration int64     `gorm:"column:content_generation" json:"contentGeneration"`
+	SchemaVersion     string    `gorm:"column:schema_version" json:"schemaVersion"`
+	RowCount          int64     `gorm:"column:row_count" json:"rowCount"`
+	Checksum          string    `gorm:"column:checksum" json:"checksum"`
+	SourceFetchedAt   time.Time `gorm:"column:source_fetched_at" json:"sourceFetchedAt"`
+	UpdatedAt         time.Time `gorm:"column:updated_at" json:"updatedAt"`
+}
+
+func ReplaceDataDownloadRows(documentID string, kind domain.DataDownloadKind, siteSN, localDate, timezone string, rows []DataDownloadRow, fetchedAt time.Time) (*DataDownloadContentState, error) {
+	var state DataDownloadContentState
+	reportedIndex := uint(0)
+	if kind == domain.DataDownloadPowerFlow && len(rows) == 1 {
+		if value, ok := rows[0].Payload.(*dto.SitePowerFlow); ok {
+			reportedIndex = value.Index
+			points, err := expandPowerFlow(value, timezone)
+			if err != nil {
+				return nil, err
+			}
+			rows = points
+		}
+	}
+	err := database.PgSQL.Transaction(func(tx *gorm.DB) error {
+		// The transaction-scoped advisory lock makes replacement and readers mutually exclusive.
+		if err := tx.Exec("SELECT pg_advisory_xact_lock(hashtextextended(?, 0))", documentID).Error; err != nil {
+			return err
+		}
+		var generation int64
+		if err := tx.Raw("SELECT content_generation FROM data_download_content_state WHERE document_id = ? FOR UPDATE", documentID).Scan(&generation).Error; err != nil {
+			return err
+		}
+		generation++
+		payloadBytes, err := json.Marshal(rows)
+		if err != nil {
+			return err
+		}
+		sum := sha256.Sum256(payloadBytes)
+		checksum := hex.EncodeToString(sum[:])
+		if err := tx.Exec(`INSERT INTO data_download_content_state
+            (document_id, document_kind, content_generation, schema_version, row_count, checksum, content_checksum, source_fetched_at, updated_at)
+            VALUES (?, ?, ?, 'v1', ?, ?, ?, ?, now())
+            ON CONFLICT (document_id) DO UPDATE SET document_kind=excluded.document_kind,
+              content_generation=excluded.content_generation, schema_version=excluded.schema_version,
+              row_count=excluded.row_count, checksum=excluded.checksum, content_checksum=excluded.content_checksum,
+              source_fetched_at=excluded.source_fetched_at, updated_at=now()`,
+			documentID, string(kind), generation, len(rows), checksum, checksum, fetchedAt).Error; err != nil {
+			return err
+		}
+
+		table := ""
+		switch kind {
+		case domain.DataDownloadPowerFlow:
+			table = "data_download_power_flow_point"
+			if err := tx.Exec("DELETE FROM data_download_power_flow_meta WHERE document_id = ?", documentID).Error; err != nil {
+				return err
+			}
+			if err := tx.Exec("INSERT INTO data_download_power_flow_meta(document_id, site_sn, local_date, timezone, reported_index) VALUES (?, ?, ?, ?, ?)", documentID, siteSN, localDate, timezone, reportedIndex).Error; err != nil {
+				return err
+			}
+		case domain.DataDownloadMqttLog:
+			table = "data_download_mqtt_log_entry"
+		case domain.DataDownloadEventAlarm:
+			table = "data_download_event_alarm_entry"
+		case domain.DataDownloadUpdateLog:
+			for _, name := range []string{"data_download_update_log_entry", "data_download_update_order", "data_download_update_device_version", "data_download_ahub_update", "data_download_ahub_update_detail"} {
+				if err := tx.Exec("DELETE FROM "+name+" WHERE document_id = ?", documentID).Error; err != nil {
+					return err
+				}
+			}
+		default:
+			return fmt.Errorf("%s is not a structured document kind", kind)
+		}
+		if table != "" {
+			if err := tx.Exec("DELETE FROM "+table+" WHERE document_id = ?", documentID).Error; err != nil {
+				return err
+			}
+		}
+		for i, row := range rows {
+			payload, err := json.Marshal(row.Payload)
+			if err != nil {
+				return err
+			}
+			switch kind {
+			case domain.DataDownloadPowerFlow:
+				point := row.Payload.(powerFlowPoint)
+				err = tx.Exec(`INSERT INTO data_download_power_flow_point(document_id, point_index, seq, sampled_at, device_time_local, device_time_utc, power_fhp, power_gen, power_load, power_sun, power_uti, soc, kwh_total, run_status, payload)
+                    VALUES (?, ?, ?, ?, NULLIF(?, '')::timestamp, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?::jsonb)`, documentID, i, point.Sequence, row.OccurredAt, point.DeviceTimeLocal, row.OccurredAt, point.PowerFhp, point.PowerGen, point.PowerLoad, point.PowerSun, point.PowerUti, point.Soc, point.KwhTotal, point.RunStatus, string(payload)).Error
+			case domain.DataDownloadMqttLog:
+				value := row.Payload.(domain.MqttLogItem)
+				var logJSON any = nil
+				if json.Valid([]byte(value.LogData)) {
+					logJSON = value.LogData
+				}
+				deviceLocal := parseLocalTime(value.LogTime)
+				err = tx.Exec(`INSERT INTO data_download_mqtt_log_entry(document_id,row_index,occurred_at,subtype,cmd_type,cmd_name,gateway_id,gateway_type,gateway_status,active_status,device_time_raw,event_time_raw,device_time_local,event_time_utc,log_data_raw,log_data_json,payload)
+                    VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?::jsonb,?::jsonb)`, documentID, i, row.OccurredAt, row.Subtype, value.CmdType, value.CmdName, value.GatewayId, value.GatewayType, value.GatewayStatus, value.ActiveStatus, value.LogTime, value.Time, deviceLocal, row.OccurredAt, value.LogData, logJSON, string(payload)).Error
+			case domain.DataDownloadEventAlarm:
+				value := row.Payload.(domain.EventAlarmItem)
+				var logJSON any = nil
+				if json.Valid([]byte(value.LogData)) {
+					logJSON = value.LogData
+				}
+				sourceID := ""
+				if value.ID != nil {
+					sourceID = *value.ID
+				}
+				endRaw := ""
+				if value.EndTime != nil {
+					endRaw = *value.EndTime
+				}
+				err = tx.Exec(`INSERT INTO data_download_event_alarm_entry(document_id,row_index,occurred_at,source_id,cmd_type,cmd_name,gateway_id,log_type,alarm_code,log_name,log_data_raw,log_data_json,device_time_raw,start_time_raw,end_time_raw,payload)
+                    VALUES (?,?,?,?,?,?,?,?,?,?,?,?::jsonb,?,?,?,?::jsonb)`, documentID, i, row.OccurredAt, sourceID, value.CmdType, value.CmdName, value.GatewayId, value.LogType, value.AlarmCode, value.LogName, value.LogData, logJSON, value.LogTime, value.StartTime, endRaw, string(payload)).Error
+			case domain.DataDownloadUpdateLog:
+				switch value := row.Payload.(type) {
+				case domain.SystemUpdateRecordInfo:
+					err = tx.Exec("INSERT INTO data_download_update_order(document_id,order_no,payload) VALUES (?,?,?::jsonb)", documentID, value.OrderNo, string(payload)).Error
+				case domain.SystemUpdateDetailInfo:
+					err = tx.Exec("INSERT INTO data_download_update_device_version(document_id,order_no,scope,device_sn,name,payload) VALUES (?,?,?,?,?,?::jsonb)", documentID, value.OrderNo, "gateway", value.GatewayId, strconv.FormatInt(value.Id, 10), string(payload)).Error
+				case domain.AHubUpdateRecordInfo:
+					err = tx.Exec("INSERT INTO data_download_ahub_update(document_id,order_no,ahub_sn,payload) VALUES (?,?,?,?::jsonb)", documentID, value.OrderNo, value.AHubSn, string(payload)).Error
+					for _, detail := range value.DetailList {
+						detailPayload, _ := json.Marshal(detail)
+						if insertErr := tx.Exec("INSERT INTO data_download_ahub_update_detail(document_id,order_no,ahub_sn,file_order,file_name,payload) VALUES (?,?,?,?,?,?::jsonb)", documentID, value.OrderNo, value.AHubSn, detail.UpgradeFileOrder, detail.UpgradeFileName, string(detailPayload)).Error; insertErr != nil {
+							return insertErr
+						}
+					}
+				}
+			}
+			if err != nil {
+				return err
+			}
+		}
+		return tx.Raw("SELECT * FROM data_download_content_state WHERE document_id = ?", documentID).Scan(&state).Error
+	})
+	return &state, err
+}
+
+func expandPowerFlow(value *dto.SitePowerFlow, timezone string) ([]DataDownloadRow, error) {
+	length := len(value.DeviceTimeArray)
+	for name, size := range map[string]int{"powerFhp": len(value.PowerFhpArray), "powerGen": len(value.PowerGenArray), "powerLoad": len(value.PowerLoadArray), "powerSun": len(value.PowerSunArray), "powerUti": len(value.PowerUtiArray), "soc": len(value.SocArray), "runStatus": len(value.RunStatusArray), "kwhTotal": len(value.KwhTotalArray)} {
+		if size > length {
+			return nil, fmt.Errorf("PowerFlow %s has %d values but only %d timestamps", name, size, length)
+		}
+	}
+	for _, status := range value.RunStatusArray {
+		if uint64(status) > uint64(math.MaxInt32) {
+			return nil, fmt.Errorf("PowerFlow runStatus %d exceeds PostgreSQL integer range", status)
+		}
+	}
+	rows := make([]DataDownloadRow, 0, length)
+	for i, raw := range value.DeviceTimeArray {
+		point := powerFlowPoint{Sequence: i, DeviceTimeLocal: raw, PowerFhp: f32At(value.PowerFhpArray, i), PowerGen: f32At(value.PowerGenArray, i), PowerLoad: f32At(value.PowerLoadArray, i), PowerSun: f32At(value.PowerSunArray, i), PowerUti: f32At(value.PowerUtiArray, i), Soc: f32At(value.SocArray, i), KwhTotal: f32At(value.KwhTotalArray, i), RunStatus: int32At(value.RunStatusArray, i)}
+		var occurred *time.Time
+		loc := postgresDataDownloadLocation(timezone)
+		if parsed, parseErr := time.ParseInLocation(time.DateTime, raw, loc); parseErr == nil {
+			utc := parsed.UTC()
+			occurred = &utc
+		}
+		rows = append(rows, DataDownloadRow{OccurredAt: occurred, Payload: point})
+	}
+	return rows, nil
+}
+
+func parseLocalTime(value string) *time.Time {
+	parsed, err := time.Parse(time.DateTime, value)
+	if err != nil {
+		return nil
+	}
+	return &parsed
+}
+func postgresDataDownloadLocation(name string) *time.Location {
+	if loc, err := time.LoadLocation(name); err == nil {
+		return loc
+	}
+	if strings.HasPrefix(name, "UTC") {
+		if hours, err := strconv.ParseFloat(strings.TrimPrefix(name, "UTC"), 64); err == nil {
+			return time.FixedZone(name, int(math.Round(hours*3600)))
+		}
+	}
+	return time.UTC
+}
+func f32At(values []float32, index int) *float32 {
+	if index >= len(values) {
+		return nil
+	}
+	value := values[index]
+	return &value
+}
+func int32At(values []uint, index int) *int32 {
+	if index >= len(values) {
+		return nil
+	}
+	value := int32(values[index])
+	return &value
+}
+
+func ReadDataDownloadRows(documentID string, kind domain.DataDownloadKind) (*DataDownloadContentState, []json.RawMessage, error) {
+	var state DataDownloadContentState
+	var rows []json.RawMessage
+	err := database.PgSQL.Transaction(func(tx *gorm.DB) error {
+		if err := tx.Exec("SELECT pg_advisory_xact_lock_shared(hashtextextended(?, 0))", documentID).Error; err != nil {
+			return err
+		}
+		if err := tx.Raw("SELECT * FROM data_download_content_state WHERE document_id = ?", documentID).Scan(&state).Error; err != nil {
+			return err
+		}
+		table := map[domain.DataDownloadKind]string{
+			domain.DataDownloadPowerFlow:  "data_download_power_flow_point",
+			domain.DataDownloadMqttLog:    "data_download_mqtt_log_entry",
+			domain.DataDownloadEventAlarm: "data_download_event_alarm_entry",
+			domain.DataDownloadUpdateLog:  "",
+		}[kind]
+		if table == "" && kind != domain.DataDownloadUpdateLog {
+			return fmt.Errorf("%s is not structured", kind)
+		}
+		type payloadRow struct {
+			Payload []byte `gorm:"column:payload"`
+		}
+		var values []payloadRow
+		if kind == domain.DataDownloadUpdateLog {
+			if err := tx.Raw(`SELECT payload FROM (
+                    SELECT 1 AS section, order_no AS k1, '' AS k2, payload FROM data_download_update_order WHERE document_id = ?
+                    UNION ALL SELECT 2, order_no, device_sn, payload FROM data_download_update_device_version WHERE document_id = ?
+                    UNION ALL SELECT 3, order_no, ahub_sn, payload FROM data_download_ahub_update WHERE document_id = ?
+                    UNION ALL SELECT 4, order_no, ahub_sn || file_order || file_name, payload FROM data_download_ahub_update_detail WHERE document_id = ?
+                ) current_update ORDER BY section, k1, k2`, documentID, documentID, documentID, documentID).Scan(&values).Error; err != nil {
+				return err
+			}
+			for _, value := range values {
+				rows = append(rows, json.RawMessage(value.Payload))
+			}
+			return nil
+		}
+		order := "row_index"
+		if kind == domain.DataDownloadPowerFlow {
+			order = "point_index"
+		}
+		if err := tx.Raw("SELECT payload FROM "+table+" WHERE document_id = ? ORDER BY "+order, documentID).Scan(&values).Error; err != nil {
+			return err
+		}
+		for _, value := range values {
+			rows = append(rows, json.RawMessage(value.Payload))
+		}
+		return nil
+	})
+	return &state, rows, err
+}
+
+func DeleteDataDownloadContent(documentID string) error {
+	return database.PgSQL.Transaction(func(tx *gorm.DB) error {
+		if err := tx.Exec("SELECT pg_advisory_xact_lock(hashtextextended(?, 0))", documentID).Error; err != nil {
+			return err
+		}
+		return tx.Exec("DELETE FROM data_download_content_state WHERE document_id = ?", documentID).Error
+	})
+}

+ 39 - 0
service/repositories/pgsql/dataDownloadRepo_test.go

@@ -0,0 +1,39 @@
+package pgsql
+
+import (
+	"math"
+	"testing"
+
+	"fwh-toolbox-server/dto"
+)
+
+func TestExpandPowerFlowPreservesMissingParallelValuesAsNull(t *testing.T) {
+	value := &dto.SitePowerFlow{
+		DeviceTimeArray: []string{"2026-09-19 00:00:00", "2026-09-19 00:05:00"},
+		PowerFhpArray:   []float32{1.5},
+		RunStatusArray:  []uint{2},
+	}
+
+	rows, err := expandPowerFlow(value, "UTC")
+	if err != nil {
+		t.Fatalf("expandPowerFlow returned error: %v", err)
+	}
+	if len(rows) != 2 {
+		t.Fatalf("got %d rows, want 2", len(rows))
+	}
+	second := rows[1].Payload.(powerFlowPoint)
+	if second.PowerFhp != nil || second.RunStatus != nil {
+		t.Fatalf("missing parallel values must remain null: %#v", second)
+	}
+}
+
+func TestExpandPowerFlowRejectsRunStatusOutsidePostgresInteger(t *testing.T) {
+	value := &dto.SitePowerFlow{
+		DeviceTimeArray: []string{"2026-09-19 00:00:00"},
+		RunStatusArray:  []uint{uint(math.MaxInt32) + 1},
+	}
+
+	if _, err := expandPowerFlow(value, "UTC"); err == nil {
+		t.Fatal("expandPowerFlow accepted a runStatus outside PostgreSQL integer range")
+	}
+}

+ 14 - 0
service/repositories/pgsql/init.go

@@ -0,0 +1,14 @@
+package pgsql
+
+import (
+	"context"
+
+	"fwh-toolbox-server/database"
+)
+
+// Init applies versioned PostgreSQL repository migrations. The context is
+// reserved for future migration runners; the current GORM transaction owns its
+// own statement contexts.
+func Init(_ context.Context) error {
+	return database.MigrateDataDownloadSchema(database.PgSQL)
+}

+ 30 - 21
service/repositories/repositories.go

@@ -65,17 +65,25 @@ var QueryAlarmStatistics = fwh_pro.QueryAlarmStatistics
 var QueryAlarmStatisticsFromCode = fwh_pro.QueryAlarmStatisticsFromCode
 
 // fwh_emsLog
-var QueryCallEmsLog = fwh_pro.QueryCallEmsLog                 // 召唤EMS日志
-var QueryEmsLogCallRecord = fwh_pro.QueryEmsLogCallRecord     // 查询EMS日志下载地址
+var QueryCallEmsLog = fwh_pro.QueryCallEmsLog // 召唤EMS日志
+var QueryCallEmsLogContext = fwh_pro.QueryCallEmsLogContext
+var QueryEmsLogCallRecord = fwh_pro.QueryEmsLogCallRecord // 查询EMS日志下载地址
+var QueryEmsLogCallRecordAfter = fwh_pro.QueryEmsLogCallRecordAfter
+var QueryEmsLogCallRecordAfterContext = fwh_pro.QueryEmsLogCallRecordAfterContext
+var LatestEmsLogCallRecordID = fwh_pro.LatestEmsLogCallRecordID
+var LatestEmsLogCallRecordIDContext = fwh_pro.LatestEmsLogCallRecordIDContext
 var QueryDownloadEmsLogFile = fwh_pro.QueryDownloadEmsLogFile // 请求下载EMS日志文件
+var QueryDownloadEmsLogFileContext = fwh_pro.QueryDownloadEmsLogFileContext
 
 // fwh_faultRecord
 var QueryFaultRecordList = fwh_pro.QueryFaultRecordList                     // 查询故障录波日志
 var QueryFaultRecordCallableList = fwh_pro.QueryFaultRecordCallableList     // 查询设备可召唤数据列表 - 仅自动录播
 var QueryCellFaultRecord = fwh_pro.QueryCellFaultRecord                     // 召唤故障录波日志
 var QueryFaultRecordCallRecordList = fwh_pro.QueryFaultRecordCallRecordList // 查询故障录波召唤记录
-var QueryFaultRecordSubList = fwh_pro.QueryFaultRecordSubList               // 查询故障录波子记录
-var QueryDownloadFaultRecordData = fwh_pro.QueryDownloadFaultRecordData     // 请求下载录波数据
+var QueryFaultRecordCallRecordPage = fwh_pro.QueryFaultRecordCallRecordPage // 分页查询故障录波召唤记录
+var QueryFaultRecordCallRecordPageContext = fwh_pro.QueryFaultRecordCallRecordPageContext
+var QueryFaultRecordSubList = fwh_pro.QueryFaultRecordSubList           // 查询故障录波子记录
+var QueryDownloadFaultRecordData = fwh_pro.QueryDownloadFaultRecordData // 请求下载录波数据
 
 // fwh_update
 var QuerySystemUpdateLog = fwh_pro.QuerySystemUpdateLog             // 查询系统升级记录
@@ -163,11 +171,11 @@ var EsGetDailyMessageTotal = fwh_es_mqtt.GetDailyMessageTotal     // 日总量
 var EsGetMonthlyMessageTotal = fwh_es_mqtt.GetMonthlyMessageTotal // 月总量
 
 // 看板 - 站点总量(分页降序)
-var EsGetSiteMonthlyTotals = fwh_es_mqtt.GetSiteMonthlyTotals             // 站点月总量
-var EsGetSiteDailyTotals = fwh_es_mqtt.GetSiteDailyTotals                 // 站点日总量
-var EsGetSiteDailyTotalsInMonth = fwh_es_mqtt.GetSiteDailyTotalsInMonth   // 下钻:站点本月每日总量
-var EsGetMonthlyDailyTrend = fwh_es_mqtt.GetMonthlyDailyTrend             // 全站本月每日总量(看板月度趋势图)
-var EsGetSiteCmdTypeTotals = fwh_es_mqtt.GetSiteCmdTypeTotals             // 下钻:站点当日各命令类型总量
+var EsGetSiteMonthlyTotals = fwh_es_mqtt.GetSiteMonthlyTotals           // 站点月总量
+var EsGetSiteDailyTotals = fwh_es_mqtt.GetSiteDailyTotals               // 站点日总量
+var EsGetSiteDailyTotalsInMonth = fwh_es_mqtt.GetSiteDailyTotalsInMonth // 下钻:站点本月每日总量
+var EsGetMonthlyDailyTrend = fwh_es_mqtt.GetMonthlyDailyTrend           // 全站本月每日总量(看板月度趋势图)
+var EsGetSiteCmdTypeTotals = fwh_es_mqtt.GetSiteCmdTypeTotals           // 下钻:站点当日各命令类型总量
 
 // 查询 - 逐条原始报文
 var EsQueryMessages = fwh_es_mqtt.QueryMessages // 按过滤条件查询逐条报文(默认当天,按时间降序)
@@ -245,13 +253,13 @@ var PlaneListWorkItemRelations = plane_v1.ListWorkItemRelations   // 列出关
 var PlaneCreateWorkItemRelation = plane_v1.CreateWorkItemRelation // 创建关系
 
 // Plane_V1 - Intake
-var PlaneCreateIntakeIssue = plane_v1.CreateIntakeIssue         // 创建Intake Issue
-var PlaneGetIntakeIssueList = plane_v1.GetIntakeIssueList       // 查询Intake Issue列表
-var PlaneGetIntakeIssueDetail = plane_v1.GetIntakeIssueDetail   // 获取Intake Issue详情
-var PlaneUpdateIntakeIssue = plane_v1.UpdateIntakeIssue         // 更新Intake Issue
-var PlaneAcceptIntakeIssue = plane_v1.AcceptIntakeIssue         // 接受Intake Issue
-var PlaneRejectIntakeIssue = plane_v1.RejectIntakeIssue         // 拒绝Intake Issue
-var PlaneDeleteIntakeIssue = plane_v1.DeleteIntakeIssue         // 删除Intake Issue
+var PlaneCreateIntakeIssue = plane_v1.CreateIntakeIssue       // 创建Intake Issue
+var PlaneGetIntakeIssueList = plane_v1.GetIntakeIssueList     // 查询Intake Issue列表
+var PlaneGetIntakeIssueDetail = plane_v1.GetIntakeIssueDetail // 获取Intake Issue详情
+var PlaneUpdateIntakeIssue = plane_v1.UpdateIntakeIssue       // 更新Intake Issue
+var PlaneAcceptIntakeIssue = plane_v1.AcceptIntakeIssue       // 接受Intake Issue
+var PlaneRejectIntakeIssue = plane_v1.RejectIntakeIssue       // 拒绝Intake Issue
+var PlaneDeleteIntakeIssue = plane_v1.DeleteIntakeIssue       // 删除Intake Issue
 
 // Plane_V1 - Asset
 var PlaneUploadAsset = plane_v1.UploadAsset // 上传Asset文件
@@ -310,11 +318,11 @@ var PlaneUpdateInvitation = plane_v1.UpdateInvitation // 更新邀请
 var PlaneDeleteInvitation = plane_v1.DeleteInvitation // 撤销邀请
 
 // Plane_V1 - Sticky
-var PlaneListStickies = plane_v1.ListStickies   // 列出便签
-var PlaneCreateSticky = plane_v1.CreateSticky   // 创建便签
-var PlaneGetSticky = plane_v1.GetSticky         // 获取便签详情
-var PlaneUpdateSticky = plane_v1.UpdateSticky   // 更新便签
-var PlaneDeleteSticky = plane_v1.DeleteSticky   // 删除便签
+var PlaneListStickies = plane_v1.ListStickies // 列出便签
+var PlaneCreateSticky = plane_v1.CreateSticky // 创建便签
+var PlaneGetSticky = plane_v1.GetSticky       // 获取便签详情
+var PlaneUpdateSticky = plane_v1.UpdateSticky // 更新便签
+var PlaneDeleteSticky = plane_v1.DeleteSticky // 删除便签
 
 // Plane_V2 Web API (Session + CSRF 认证)
 
@@ -322,6 +330,7 @@ var PlaneDeleteSticky = plane_v1.DeleteSticky   // 删除便签
 type PlaneWebIntakeIssueRequest = plane_v2.WebIntakeIssueRequest
 type PlaneWebIntakeIssueResponse = plane_v2.WebIntakeIssueResponse
 type PlaneWebAssetUploadResult = plane_v2.WebAssetUploadResult
+
 const PlaneWebEntityIssueDescription = plane_v2.EntityIssueDescription
 
 /*************** EVENTS ***************/

+ 34 - 0
service/routes/dataDownloadRoutes.go

@@ -0,0 +1,34 @@
+package routes
+
+import (
+	"fwh-toolbox-server/controllers"
+
+	"github.com/gofiber/fiber/v2"
+)
+
+func DataDownloadSetup(api fiber.Router) {
+	group := api.Group("/dataDownload")
+	group.Get("/tasks", controllers.GetDataDownloadTasks)
+	group.Post("/tasks", controllers.CreateDataDownload)
+	group.Get("/tasks/:id", controllers.GetDataDownloadTask)
+	group.Get("/tasks/:id/documents/:documentId/file", controllers.DownloadDataDownloadTaskDocument)
+	group.Get("/tasks/:id/files/:entryId", controllers.DownloadDataDownloadTaskFile)
+	group.Put("/tasks/:id/description", controllers.UpdateDataDownloadDescription)
+	group.Post("/tasks/:id/cancel", controllers.CancelDataDownload)
+	group.Post("/tasks/:id/retry", controllers.RetryDataDownload)
+	group.Delete("/tasks/:id", controllers.DeleteDataDownload)
+	group.Get("/documents/:documentId/file", controllers.GetDataDownloadDocumentFile)
+	group.Get("/documents", controllers.GetDataDownloadDocuments)
+	group.Get("/documents/:documentId/references", controllers.GetDataDownloadDocumentReferences)
+	group.Get("/documents/:documentId/files", controllers.GetDataDownloadDocumentFiles)
+	group.Get("/documents/:documentId/files/:entryId", controllers.DownloadDataDownloadDocumentEntry)
+	group.Delete("/documents/:documentId", controllers.DeleteDataDownloadDocument)
+	group.Get("/documents/:documentId/archive", controllers.GetDataDownloadArchiveEntries)
+	group.Get("/documents/:documentId/archive/file", controllers.GetDataDownloadArchiveEntry)
+	group.Get("/packages", controllers.GetDataDownloadPackages)
+	group.Post("/packages", controllers.CreateDataDownloadPackage)
+	group.Get("/packages/:id/file", controllers.DownloadDataDownloadPackage)
+	group.Post("/packages/:id/cancel", controllers.CancelDataDownloadPackage)
+	group.Post("/packages/:id/retry", controllers.RetryDataDownloadPackage)
+	group.Delete("/packages/:id", controllers.DeleteDataDownloadPackage)
+}

+ 1 - 0
service/routes/routes.go

@@ -26,6 +26,7 @@ func Setup(app *fiber.App) {
 	SitesMonitorSetup(api)
 	SitesAnalysisSetup(api)
 	BatchDownloadSetup(api)
+	DataDownloadSetup(api)
 	BackgroundTaskSetup(api)
 	AutoUpdateConfigSetup(api)
 	XlogSetup(api)

+ 11 - 0
service/scheduler/scheduler.go

@@ -1,12 +1,14 @@
 package scheduler
 
 import (
+	"context"
 	"log"
 	"time"
 
 	"github.com/robfig/cron/v3"
 	"go.uber.org/zap"
 
+	"fwh-toolbox-server/services"
 	"fwh-toolbox-server/utils/agentfiles"
 )
 
@@ -80,6 +82,15 @@ func Init() *cron.Cron {
 		)
 	})
 
+	c.AddFunc("@every 1h", func() {
+		if err := services.CleanupDataDownloadPackages(context.Background()); err != nil {
+			zap.L().Error("DataDownload package cleanup failed", zap.String("err", err.Error()))
+		}
+		if err := services.CleanupDataDownloadArtifacts(context.Background()); err != nil {
+			zap.L().Error("DataDownload artifact cleanup failed", zap.String("err", err.Error()))
+		}
+	})
+
 	c.Start()
 	// 打印任务计划表
 	entries := c.Entries()

+ 66 - 0
service/services/dataDownloadAdminService.go

@@ -0,0 +1,66 @@
+package services
+
+import (
+	"context"
+	"fmt"
+	"time"
+
+	"fwh-toolbox-server/cache"
+	minioRepo "fwh-toolbox-server/repositories/minio"
+	mongoRepo "fwh-toolbox-server/repositories/mongo"
+	pgsqlRepo "fwh-toolbox-server/repositories/pgsql"
+)
+
+func DeleteDataDownloadDocument(ctx context.Context, documentID string, force bool) error {
+	references, err := mongoRepo.CountDataDownloadDocumentReferences(ctx, documentID)
+	if err != nil {
+		return err
+	}
+	if references > 0 && !force {
+		return fmt.Errorf("document is referenced by %d task(s); use force=true to delete", references)
+	}
+	requestLock, ok, err := cache.TryLock(ctx, dataDownloadRequestKey(documentID), 5*time.Minute)
+	if err != nil {
+		return err
+	}
+	if !ok {
+		return fmt.Errorf("document has an active file request")
+	}
+	defer requestLock.SafeRelease(context.Background())
+	lock, err := cache.Lock(ctx, "data-download:content:"+documentID, 5*time.Minute, 250*time.Millisecond, 5*time.Second)
+	if err != nil {
+		return fmt.Errorf("document has an active reader or writer: %w", err)
+	}
+	defer lock.SafeRelease(context.Background())
+	document, err := mongoRepo.GetDataDownloadDocument(ctx, documentID)
+	if err != nil {
+		return err
+	}
+	artifacts, err := mongoRepo.ListDataDownloadArtifacts(ctx, documentID)
+	if err != nil {
+		return err
+	}
+	entries, err := mongoRepo.ListDataDownloadFaultRecordEntries(ctx, documentID)
+	if err != nil {
+		return err
+	}
+	if document.BinaryObjectKey != "" {
+		if err := minioRepo.Delete(ctx, document.BinaryObjectKey); err != nil {
+			return err
+		}
+	}
+	for i := range artifacts {
+		if err := minioRepo.Delete(ctx, artifacts[i].ObjectKey); err != nil {
+			return err
+		}
+	}
+	for i := range entries {
+		if err := minioRepo.Delete(ctx, entries[i].ObjectKey); err != nil {
+			return err
+		}
+	}
+	if err := pgsqlRepo.DeleteDataDownloadContent(documentID); err != nil {
+		return err
+	}
+	return mongoRepo.DeleteDataDownloadDocumentMetadata(ctx, documentID)
+}

+ 112 - 0
service/services/dataDownloadArchiveService.go

@@ -0,0 +1,112 @@
+package services
+
+import (
+	"archive/tar"
+	"compress/gzip"
+	"context"
+	"fmt"
+	"io"
+	"path"
+	"strings"
+	"time"
+
+	"fwh-toolbox-server/cache"
+	minioRepo "fwh-toolbox-server/repositories/minio"
+	mongoRepo "fwh-toolbox-server/repositories/mongo"
+)
+
+type DataDownloadArchiveEntry struct {
+	Path      string `json:"path"`
+	Size      int64  `json:"size"`
+	Directory bool   `json:"directory"`
+}
+
+func ListDataDownloadArchive(ctx context.Context, documentID string) ([]DataDownloadArchiveEntry, error) {
+	lock, err := cache.Lock(ctx, "data-download:content:"+documentID, 10*time.Minute, 100*time.Millisecond, 10*time.Second)
+	if err != nil {
+		return nil, err
+	}
+	defer lock.SafeRelease(context.Background())
+	reader, compressed, archive, err := openDataDownloadTarGz(ctx, documentID)
+	if err != nil {
+		return nil, err
+	}
+	defer reader.Close()
+	defer compressed.Close()
+	var entries []DataDownloadArchiveEntry
+	for {
+		header, err := archive.Next()
+		if err == io.EOF {
+			break
+		}
+		if err != nil {
+			return nil, err
+		}
+		clean, err := safeArchiveEntry(header.Name)
+		if err != nil {
+			continue
+		}
+		entries = append(entries, DataDownloadArchiveEntry{Path: clean, Size: header.Size, Directory: header.FileInfo().IsDir()})
+	}
+	return entries, nil
+}
+
+func ReadDataDownloadArchiveEntry(ctx context.Context, documentID, entryPath string) ([]byte, error) {
+	lock, err := cache.Lock(ctx, "data-download:content:"+documentID, 10*time.Minute, 100*time.Millisecond, 10*time.Second)
+	if err != nil {
+		return nil, err
+	}
+	defer lock.SafeRelease(context.Background())
+	wanted, err := safeArchiveEntry(entryPath)
+	if err != nil {
+		return nil, err
+	}
+	reader, compressed, archive, err := openDataDownloadTarGz(ctx, documentID)
+	if err != nil {
+		return nil, err
+	}
+	defer reader.Close()
+	defer compressed.Close()
+	for {
+		header, err := archive.Next()
+		if err == io.EOF {
+			return nil, fmt.Errorf("archive entry not found")
+		}
+		if err != nil {
+			return nil, err
+		}
+		clean, cleanErr := safeArchiveEntry(header.Name)
+		if cleanErr != nil || clean != wanted || header.FileInfo().IsDir() {
+			continue
+		}
+		return io.ReadAll(io.LimitReader(archive, header.Size))
+	}
+}
+
+func openDataDownloadTarGz(ctx context.Context, documentID string) (io.ReadCloser, *gzip.Reader, *tar.Reader, error) {
+	document, err := mongoRepo.GetDataDownloadDocument(ctx, documentID)
+	if err != nil {
+		return nil, nil, nil, err
+	}
+	if document.BinaryObjectKey == "" || !(strings.HasSuffix(strings.ToLower(document.BinaryObjectKey), ".tar.gz") || strings.HasSuffix(strings.ToLower(document.BinaryObjectKey), ".tgz")) {
+		return nil, nil, nil, fmt.Errorf("document is not a tar.gz archive")
+	}
+	reader, err := minioRepo.Open(ctx, document.BinaryObjectKey)
+	if err != nil {
+		return nil, nil, nil, err
+	}
+	gz, err := gzip.NewReader(reader)
+	if err != nil {
+		_ = reader.Close()
+		return nil, nil, nil, err
+	}
+	return reader, gz, tar.NewReader(gz), nil
+}
+
+func safeArchiveEntry(value string) (string, error) {
+	clean := strings.TrimPrefix(path.Clean("/"+strings.ReplaceAll(value, "\\", "/")), "/")
+	if clean == "." || clean == "" || strings.HasPrefix(clean, "../") {
+		return "", fmt.Errorf("invalid archive entry")
+	}
+	return clean, nil
+}

+ 270 - 0
service/services/dataDownloadBinaryService.go

@@ -0,0 +1,270 @@
+package services
+
+import (
+	"bytes"
+	"context"
+	"crypto/sha256"
+	"encoding/hex"
+	"errors"
+	"fmt"
+	"path"
+	"strconv"
+	"time"
+
+	"fwh-toolbox-server/cache"
+	"fwh-toolbox-server/domain"
+	"fwh-toolbox-server/jobs"
+	"fwh-toolbox-server/repositories"
+	minioRepo "fwh-toolbox-server/repositories/minio"
+	mongoRepo "fwh-toolbox-server/repositories/mongo"
+
+	"github.com/xuri/excelize/v2"
+	"go.mongodb.org/mongo-driver/mongo"
+	"go.uber.org/zap"
+)
+
+func ExecuteBinaryDataDownloadItem(ctx context.Context, item *domain.DataDownloadItem, awaitRequestIDs ...string) error {
+	awaitRequestID := ""
+	if len(awaitRequestIDs) > 0 {
+		awaitRequestID = awaitRequestIDs[0]
+	}
+	if item.Kind == domain.DataDownloadFaultRecord {
+		return executeFaultRecordDataDownloadItem(ctx, item, awaitRequestID)
+	}
+	ready, lease, err := prepareDataDownloadRequest(ctx, item, awaitRequestID)
+	if err != nil {
+		return err
+	}
+	if ready != nil {
+		return linkDataDownloadItem(ctx, item, ready, ready.Empty)
+	}
+	defer lease.abort()
+	documentID := lease.documentID
+	fetchedAt := time.Now().UTC()
+	var data []byte
+	var filename, contentType string
+	switch item.Kind {
+	case domain.DataDownloadAlarmSnapshot:
+		values, err := jobs.GetAlarmSnapshotData(ctx, item.SiteSN, item.Date)
+		if err != nil {
+			zap.L().Warn("[DataDownload] AlarmSnapshot 上游查询失败", zap.String("sn", item.SiteSN), zap.String("date", item.Date), zap.String("err", err.Error()))
+			return err
+		}
+		zap.L().Debug("[DataDownload] AlarmSnapshot 上游返回", zap.String("sn", item.SiteSN), zap.String("date", item.Date), zap.Int("rows", len(values)))
+		if len(values) > 0 {
+			file := excelize.NewFile()
+			sheet := file.GetSheetName(0)
+			_ = file.SetSheetRow(sheet, "A1", &[]any{"ID", "Time", "Data"})
+			for i, value := range values {
+				_ = file.SetSheetRow(sheet, fmt.Sprintf("A%d", i+2), &[]any{value.ID, value.LogTime.Format(time.RFC3339), value.JSONData})
+			}
+			buffer, err := file.WriteToBuffer()
+			_ = file.Close()
+			if err != nil {
+				return err
+			}
+			data = buffer.Bytes()
+		}
+		filename, contentType = fmt.Sprintf("AlarmSnapshot_%s.xlsx", item.Date), "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
+	case domain.DataDownloadEmsLog:
+		logType, err := resolveEmsLogType(item.Subtype)
+		if err != nil {
+			return err
+		}
+		data, filename, err = fetchEmsLog(ctx, item, logType)
+		if err != nil {
+			return err
+		}
+		contentType = "application/octet-stream"
+	default:
+		return fmt.Errorf("%s is not a binary document kind", item.Kind)
+	}
+	contentLock, err := cache.Lock(ctx, "data-download:content:"+documentID, 10*time.Minute, 200*time.Millisecond, 30*time.Second)
+	if err != nil {
+		return err
+	}
+	defer contentLock.SafeRelease(context.Background())
+	if err := lease.validate(ctx); err != nil {
+		return err
+	}
+	objectKey := ""
+	if len(data) > 0 {
+		objectKey, err = minioRepo.PutBinaryDocument(ctx, documentID, filename, bytes.NewReader(data), int64(len(data)), contentType)
+		if err != nil {
+			return err
+		}
+		zap.L().Debug("[DataDownload] 二进制对象已上传 MinIO",
+			zap.String("documentId", documentID), zap.String("kind", string(item.Kind)),
+			zap.String("objectKey", objectKey), zap.Int("bytes", len(data)))
+	}
+	generation := int64(1)
+	createdAt := fetchedAt
+	oldObjectKey := ""
+	if existing, err := mongoRepo.GetDataDownloadDocument(ctx, documentID); err == nil {
+		generation = existing.ContentGeneration + 1
+		createdAt = existing.CreatedAt
+		oldObjectKey = existing.BinaryObjectKey
+	} else if err != mongo.ErrNoDocuments {
+		return err
+	}
+	sum := sha256.Sum256(data)
+	state := domain.DataDownloadMutable
+	var frozenAt *time.Time
+	loc := dataDownloadLocation(item.Timezone)
+	day, _ := time.ParseInLocation(time.DateOnly, item.Date, loc)
+	if !time.Now().In(loc).Before(day.AddDate(0, 0, 2)) {
+		now := time.Now().UTC()
+		state, frozenAt = domain.DataDownloadFrozen, &now
+	}
+	document := &domain.DataDownloadDocument{ID: documentID, RequestKey: lease.requestKey, Kind: item.Kind, SiteSN: item.SiteSN, DeviceSN: item.DeviceSN, Date: item.Date, Subtype: item.Subtype, DeviceType: item.DeviceType, Timezone: item.Timezone, State: state, Empty: len(data) == 0, ContentGeneration: generation, BinaryObjectKey: objectKey, Checksum: hex.EncodeToString(sum[:]), SourceFetchedAt: fetchedAt, FrozenAt: frozenAt, CreatedAt: createdAt, UpdatedAt: fetchedAt}
+	if err := mongoRepo.UpsertDataDownloadDocument(ctx, document); err != nil {
+		return err
+	}
+	if oldObjectKey != "" && oldObjectKey != objectKey {
+		_ = minioRepo.Delete(ctx, oldObjectKey)
+	}
+	if err := lease.complete(ctx); err != nil {
+		return err
+	}
+	return linkDataDownloadItem(ctx, item, document, document.Empty)
+}
+
+// DataDownloadSNDeferred keeps another file for this SN in the queue without
+// consuming an Asynq retry or another worker while the current call runs.
+type DataDownloadSNDeferred struct{ After time.Duration }
+
+func (e *DataDownloadSNDeferred) Error() string { return "EMS log call for SN is busy" }
+
+func fetchEmsLog(ctx context.Context, item *domain.DataDownloadItem, logType int) ([]byte, string, error) {
+	slot, ok, err := jobs.TryAcquireEmsLogCallSlot(ctx, item.SiteSN)
+	if err != nil {
+		return nil, "", err
+	}
+	if !ok {
+		return nil, "", &DataDownloadSNDeferred{After: 15 * time.Second}
+	}
+	defer slot.Release()
+	callCtx, cancel := context.WithTimeout(slot.Context(), jobs.EmsLogCallTimeout)
+	defer cancel()
+	baselineID, err := repositories.LatestEmsLogCallRecordIDContext(callCtx, item.SiteSN, item.Date, logType)
+	if callCtx.Err() != nil {
+		if errors.Is(callCtx.Err(), context.DeadlineExceeded) {
+			return nil, "", jobs.ErrEmsLogCallTimeout
+		}
+		return nil, "", jobs.ErrEmsLogCallSlotLost
+	}
+	if err != nil {
+		return nil, "", err
+	}
+	err = repositories.QueryCallEmsLogContext(callCtx, item.SiteSN, item.Date, logType)
+	if errors.Is(callCtx.Err(), context.DeadlineExceeded) {
+		return nil, "", jobs.ErrEmsLogCallTimeout
+	}
+	if callCtx.Err() != nil && ctx.Err() == nil {
+		return nil, "", jobs.ErrEmsLogCallSlotLost
+	}
+	if err != nil {
+		return nil, "", err
+	}
+	zap.L().Debug("[DataDownload] EmsLog 已召唤,等待生成",
+		zap.String("sn", item.SiteSN), zap.String("date", item.Date), zap.Int("logType", logType))
+	record, err := waitEmsLog(callCtx, item.SiteSN, item.Date, logType, baselineID)
+	if err != nil {
+		if errors.Is(err, context.DeadlineExceeded) {
+			return nil, "", jobs.ErrEmsLogCallTimeout
+		}
+		if errors.Is(err, context.Canceled) && ctx.Err() == nil {
+			return nil, "", jobs.ErrEmsLogCallSlotLost
+		}
+		return nil, "", err
+	}
+	data, err := repositories.QueryDownloadEmsLogFileContext(callCtx, record.FileUrl)
+	if errors.Is(callCtx.Err(), context.DeadlineExceeded) {
+		return nil, "", jobs.ErrEmsLogCallTimeout
+	}
+	if callCtx.Err() != nil && ctx.Err() == nil {
+		return nil, "", jobs.ErrEmsLogCallSlotLost
+	}
+	if err != nil {
+		return nil, "", err
+	}
+	zap.L().Debug("[DataDownload] EmsLog 文件已下载到内存",
+		zap.String("sn", item.SiteSN), zap.String("date", item.Date),
+		zap.Int("bytes", len(data)), zap.String("fileUrl", record.FileUrl))
+	filename := path.Base(record.FileUrl)
+	if filename == "." || filename == "" {
+		filename = fmt.Sprintf("EmsLog_%s_%s.tar.gz", item.Date, item.Subtype)
+	}
+	return data, filename, nil
+}
+
+func waitEmsLog(ctx context.Context, sn, date string, logType int, baselineID int64) (*domain.EmsLogRecordInfo, error) {
+	ticker := time.NewTicker(15 * time.Second)
+	defer ticker.Stop()
+	for {
+		select {
+		case <-ctx.Done():
+			return nil, ctx.Err()
+		case <-ticker.C:
+			if err := ctx.Err(); err != nil {
+				return nil, err
+			}
+			record, err := repositories.QueryEmsLogCallRecordAfterContext(ctx, sn, date, logType, baselineID)
+			if ctx.Err() != nil {
+				return nil, ctx.Err()
+			}
+			if err != nil {
+				continue
+			}
+			if record.Status < 0 {
+				return nil, fmt.Errorf("EMS log request failed")
+			}
+			if record.Status == 1 {
+				return record, nil
+			}
+		}
+	}
+}
+
+func stableDataDownloadDocumentID(requestKey string) string {
+	sum := sha256.Sum256([]byte(requestKey))
+	return hex.EncodeToString(sum[:])
+}
+
+func linkDataDownloadItem(ctx context.Context, item *domain.DataDownloadItem, document *domain.DataDownloadDocument, empty bool) error {
+	task, err := mongoRepo.GetDataDownloadTask(ctx, item.TaskID)
+	if err != nil {
+		return err
+	}
+	if task.Status == domain.DataDownloadCancelling || task.Status == domain.DataDownloadCancelled {
+		return nil
+	}
+	logicalPath := fmt.Sprintf("%s/%s/%s", item.SiteSN, item.Kind, item.Date)
+	if item.Kind == domain.DataDownloadFaultRecord {
+		logicalPath = fmt.Sprintf("%s/%s/%s-%s/%s", item.SiteSN, item.Kind, item.DeviceSN, item.DeviceType, item.Date)
+	}
+	return mongoRepo.LinkDataDownloadDocument(ctx, &domain.DataDownloadTaskDocument{TaskID: item.TaskID, ItemKey: item.ItemKey, DocumentID: document.ID, LogicalPath: logicalPath, Empty: empty, CreatedAt: time.Now().UTC()})
+}
+
+// dataDownloadEmsLogTypeMap 将 EMS 日志的字符串枚举映射为上游要求的数字 logType,
+// 与 BatchDownload(fwhTaskCallFile.go)的 mapEmsLogType 保持一致。
+var dataDownloadEmsLogTypeMap = map[string]int{
+	"EmsRunLog":     4,  // IBG运行日志
+	"EmsBattLog":    7,  // 电池日志
+	"EmsMonitorLog": 9,  // 设备系统资源监控日志
+	"Ems4gLog":      10, // 4G日志
+	"EmsAwsLog":     11, // AWS日志
+	"EmsMainLog":    12, // 主进程日志
+}
+
+// resolveEmsLogType 优先按字符串枚举映射为数字 logType;
+// 若传入的本身就是数字字符串(如 "4")则兼容解析,保证历史数据与测试可用。
+func resolveEmsLogType(subtype string) (int, error) {
+	if n, ok := dataDownloadEmsLogTypeMap[subtype]; ok {
+		return n, nil
+	}
+	if n, err := strconv.Atoi(subtype); err == nil {
+		return n, nil
+	}
+	return 0, fmt.Errorf("invalid EMS log type %q", subtype)
+}

+ 68 - 0
service/services/dataDownloadCleanupService.go

@@ -0,0 +1,68 @@
+package services
+
+import (
+	"context"
+	"time"
+
+	"fwh-toolbox-server/cache"
+	"fwh-toolbox-server/domain"
+	minioRepo "fwh-toolbox-server/repositories/minio"
+	mongoRepo "fwh-toolbox-server/repositories/mongo"
+
+	"go.mongodb.org/mongo-driver/bson"
+	"go.uber.org/zap"
+)
+
+func CleanupDataDownloadPackages(ctx context.Context) error {
+	now := time.Now().UTC()
+	jobs, err := mongoRepo.FindExpiredDataDownloadPackageJobs(ctx, now)
+	if err != nil {
+		return err
+	}
+	if len(jobs) > 0 {
+		zap.L().Info("[DataDownload] 清理过期 Package", zap.Int("count", len(jobs)))
+	}
+	for i := range jobs {
+		lock, ok, lockErr := cache.TryLock(ctx, "data-download:package:"+jobs[i].ID.Hex(), 5*time.Minute)
+		if lockErr != nil || !ok {
+			continue
+		}
+		if jobs[i].ObjectKey != "" {
+			if err := minioRepo.Delete(ctx, jobs[i].ObjectKey); err != nil {
+				lock.SafeRelease(context.Background())
+				continue
+			}
+		}
+		purge := jobs[i].ExpiresAt.Add(30 * 24 * time.Hour)
+		_ = mongoRepo.UpdateDataDownloadPackageJob(ctx, jobs[i].ID, bson.M{"status": domain.DataDownloadExpired, "objectKey": "", "purgeAt": purge})
+		lock.SafeRelease(context.Background())
+	}
+	return mongoRepo.PurgeExpiredDataDownloadPackageJobs(ctx, now)
+}
+
+func CleanupDataDownloadArtifacts(ctx context.Context) error {
+	artifacts, err := mongoRepo.FindExpiredDataDownloadArtifacts(ctx, time.Now().UTC())
+	if err != nil {
+		return err
+	}
+	if len(artifacts) > 0 {
+		zap.L().Info("[DataDownload] 清理过期渲染 Artifact", zap.Int("count", len(artifacts)))
+	}
+	for i := range artifacts {
+		lock, lockErr := cache.Lock(ctx, "data-download:content:"+artifacts[i].DocumentID, 10*time.Minute, 100*time.Millisecond, 10*time.Second)
+		if lockErr != nil {
+			continue
+		}
+		if err := mongoRepo.DetachDataDownloadArtifact(ctx, artifacts[i]); err != nil {
+			lock.SafeRelease(context.Background())
+			continue
+		}
+		if err := minioRepo.Delete(ctx, artifacts[i].ObjectKey); err != nil {
+			lock.SafeRelease(context.Background())
+			continue
+		}
+		_ = mongoRepo.DeleteDataDownloadArtifact(ctx, artifacts[i])
+		lock.SafeRelease(context.Background())
+	}
+	return nil
+}

+ 404 - 0
service/services/dataDownloadFaultRecordCall.go

@@ -0,0 +1,404 @@
+package services
+
+import (
+	"context"
+	"errors"
+	"fmt"
+	"strconv"
+	"strings"
+	"time"
+
+	"fwh-toolbox-server/domain"
+	"fwh-toolbox-server/repositories"
+	mongoRepo "fwh-toolbox-server/repositories/mongo"
+	"go.uber.org/zap"
+)
+
+const (
+	faultCallPageSize     = 50
+	faultCallPollInterval = time.Minute
+	faultCallTimeout      = 2 * time.Hour
+)
+
+type DataDownloadFaultCallDeferred struct{ After time.Duration }
+
+func (e *DataDownloadFaultCallDeferred) Error() string {
+	return "fault recording call is still running"
+}
+
+var errAmbiguousFaultCall = errors.New("ambiguous fault recording call")
+var ErrFaultRecordCallTimeout = errors.New("fault recording call timed out")
+var ErrFaultRecordCallFailed = errors.New("fault recording call failed")
+
+func faultCallTimeoutError(item *domain.DataDownloadItem, lastStatus string) error {
+	return fmt.Errorf("%w after 2h (recordId=%d, lastStatus=%s)", ErrFaultRecordCallTimeout, item.CallRecordID, lastStatus)
+}
+
+type faultRecordSource struct {
+	record domain.FaultRecordInfo
+	sub    domain.FaultRecordSubInfo
+}
+
+// resolveFaultRecordSources checks the callable catalog first. Only a subrecord
+// whose failure time and fault code match a catalog entry proves that an
+// existing remote record can be reused.
+func resolveFaultRecordSources(ctx context.Context, item *domain.DataDownloadItem, documentID string, deviceType int, start, end time.Time) ([]faultRecordSource, error) {
+	callable, err := repositories.QueryFaultRecordCallableList(deviceType, item.DeviceSN)
+	if err != nil {
+		return nil, fmt.Errorf("query callable fault records: %w", err)
+	}
+	selected := make([]domain.FaultRecordCallableInfo, 0)
+	for _, candidate := range callable {
+		failureTime, parseErr := time.ParseInLocation(time.DateTime, candidate.FailureTime, start.Location())
+		if parseErr != nil {
+			return nil, fmt.Errorf("invalid catalog failureTime %q: %w", candidate.FailureTime, parseErr)
+		}
+		if !failureTime.Before(start) && !failureTime.After(end) {
+			selected = append(selected, candidate)
+		}
+	}
+	if len(selected) == 0 {
+		if item.CallRequestedAt != nil {
+			return nil, fmt.Errorf("callable fault catalog became empty while call %d was active", item.CallRecordID)
+		}
+		return nil, nil
+	}
+	if err := ctx.Err(); err != nil {
+		return nil, err
+	}
+	records, err := repositories.QueryFaultRecordList(item.DeviceSN, start.Format(time.DateTime), end.Format(time.DateTime))
+	if err != nil {
+		return nil, fmt.Errorf("query existing fault records: %w", err)
+	}
+	matched := make([]bool, len(selected))
+	sources := make([]faultRecordSource, 0)
+	for _, record := range records {
+		if record.DeviceType != deviceType {
+			continue
+		}
+		sn, identityErr := dataDownloadFaultRecordDeviceSN(record)
+		if identityErr != nil {
+			return sources, identityErr
+		}
+		if sn != item.DeviceSN {
+			continue
+		}
+		if err := ctx.Err(); err != nil {
+			return sources, err
+		}
+		subs, subErr := repositories.QueryFaultRecordSubList(record.Id)
+		if subErr != nil {
+			return sources, fmt.Errorf("list existing fault record %d: %w", record.Id, subErr)
+		}
+		if len(subs) != record.FaultRecordsCount {
+			zap.L().Warn("[DataDownload] 已召唤录波子记录数量不一致,忽略该记录", zap.Int64("faultId", record.Id), zap.Int("expected", record.FaultRecordsCount), zap.Int("actual", len(subs)))
+		}
+		sources = append(sources, matchExistingFaultRecord(record, subs, selected, matched)...)
+	}
+	missing := make([]domain.FaultRecordCallableInfo, 0)
+	indices := make([]int, 0)
+	for i, candidate := range selected {
+		if !matched[i] {
+			missing = append(missing, candidate)
+			indices = append(indices, candidate.Idx)
+		}
+	}
+	if len(indices) == 0 {
+		if item.CallRequestedAt != nil {
+			if item.CallRecordStatus == nil || *item.CallRecordStatus != 2 {
+				if _, err := summonFaultRecords(ctx, item, documentID, deviceType, nil); err != nil {
+					return sources, err
+				}
+			}
+			found := false
+			for _, source := range sources {
+				if source.record.Id == item.CallRecordID {
+					found = true
+					break
+				}
+			}
+			if !found {
+				return sources, fmt.Errorf("summoned fault record %d is not in the verified subrecords", item.CallRecordID)
+			}
+		}
+		return sources, nil
+	}
+	called, err := summonFaultRecords(ctx, item, documentID, deviceType, indices)
+	if err != nil {
+		return sources, err
+	}
+	if err := ctx.Err(); err != nil {
+		return sources, err
+	}
+	newRecords, err := repositories.QueryFaultRecordList(item.DeviceSN, start.Format(time.DateTime), end.Format(time.DateTime))
+	if err != nil {
+		return sources, fmt.Errorf("query summoned fault record %d: %w", called.Id, err)
+	}
+	var created *domain.FaultRecordInfo
+	for i := range newRecords {
+		if newRecords[i].Id == called.Id {
+			created = &newRecords[i]
+			break
+		}
+	}
+	if created == nil {
+		return sources, fmt.Errorf("summoned fault record %d is missing from faultRecordingLst", called.Id)
+	}
+	expected := item.CallRequestedCount
+	if expected == 0 {
+		expected = len(missing)
+	}
+	if created.FaultRecordsCount != expected {
+		return sources, fmt.Errorf("summoned fault record %d count %d, expected %d", created.Id, created.FaultRecordsCount, expected)
+	}
+	subs, err := repositories.QueryFaultRecordSubList(created.Id)
+	if err != nil {
+		return sources, fmt.Errorf("list summoned fault record %d: %w", created.Id, err)
+	}
+	if len(subs) != expected {
+		return sources, fmt.Errorf("summoned fault record %d has %d subrecords, expected %d", created.Id, len(subs), expected)
+	}
+	used := make([]bool, len(selected))
+	for _, sub := range subs {
+		match := -1
+		for i, candidate := range selected {
+			if !used[i] && faultSubMatchesCallable(sub, candidate) {
+				match = i
+				break
+			}
+		}
+		if match < 0 {
+			return sources, fmt.Errorf("summoned fault record %d contains unexpected subrecord %d", created.Id, sub.Id)
+		}
+		used[match] = true
+		present := false
+		for _, source := range sources {
+			if source.record.Id == created.Id && source.sub.Id == sub.Id {
+				present = true
+				break
+			}
+		}
+		if !present {
+			sources = append(sources, faultRecordSource{record: *created, sub: sub})
+		}
+	}
+	return sources, nil
+}
+
+func matchExistingFaultRecord(record domain.FaultRecordInfo, subs []domain.FaultRecordSubInfo, selected []domain.FaultRecordCallableInfo, matched []bool) []faultRecordSource {
+	if len(subs) != record.FaultRecordsCount {
+		return nil
+	}
+	var sources []faultRecordSource
+	for _, sub := range subs {
+		for i, candidate := range selected {
+			if matched[i] || !faultSubMatchesCallable(sub, candidate) {
+				continue
+			}
+			matched[i] = true
+			sources = append(sources, faultRecordSource{record: record, sub: sub})
+			break
+		}
+	}
+	return sources
+}
+
+func faultSubMatchesCallable(sub domain.FaultRecordSubInfo, candidate domain.FaultRecordCallableInfo) bool {
+	return strings.TrimSpace(sub.FailureTime) == strings.TrimSpace(candidate.FailureTime) &&
+		strings.TrimSpace(sub.EventTriggerSource) == strings.TrimSpace(candidate.TriggerSourceCode)
+}
+
+func summonFaultRecords(ctx context.Context, item *domain.DataDownloadItem, documentID string, deviceType int, indices []int) (*domain.FaultRecordInfo, error) {
+	requestedAt := item.CallRequestedAt
+	if requestedAt != nil && !time.Now().Before(requestedAt.Add(faultCallTimeout)) {
+		return nil, faultCallTimeoutError(item, callStatusText(item))
+	}
+	if requestedAt == nil {
+		now := time.Now().UTC()
+		requestedAt = &now
+	}
+	owned, err := mongoRepo.TryOwnDataDownloadFaultCall(ctx, documentID, item.ID, *requestedAt)
+	if err != nil {
+		return nil, err
+	}
+	if !owned {
+		return nil, &DataDownloadFaultCallDeferred{After: faultCallPollInterval}
+	}
+	if item.CallRequestedAt == nil {
+		if err := mongoRepo.UpdateDataDownloadFaultCall(ctx, item.ID, requestedAt, len(indices), 0, nil); err != nil {
+			return nil, err
+		}
+		item.CallRequestedAt = requestedAt
+		item.CallRequestedCount = len(indices)
+		if err := ctx.Err(); err != nil {
+			return nil, err
+		}
+		if err := repositories.QueryCellFaultRecord(deviceType, item.DeviceSN, indices); err != nil {
+			return nil, fmt.Errorf("call fault recording: %w", err)
+		}
+	}
+	deadline := requestedAt.Add(faultCallTimeout)
+	pollCtx, cancel := context.WithDeadline(ctx, deadline)
+	defer cancel()
+	if err := pollCtx.Err(); err != nil {
+		return nil, faultCallTimeoutError(item, callStatusText(item))
+	}
+	task, err := mongoRepo.GetDataDownloadTask(pollCtx, item.TaskID)
+	if err != nil {
+		return nil, err
+	}
+	if task.Status == domain.DataDownloadCancelling || task.Status == domain.DataDownloadCancelled {
+		return nil, context.Canceled
+	}
+	var record *domain.FaultRecordInfo
+	if item.CallRecordID != 0 {
+		record, err = findFaultCallByID(pollCtx, item.CallRecordID)
+	} else {
+		record, err = findFaultCallByRequest(pollCtx, item.DeviceSN, deviceType, item.CallRequestedCount, *requestedAt)
+	}
+	if errors.Is(err, context.DeadlineExceeded) {
+		return nil, faultCallTimeoutError(item, callStatusText(item))
+	}
+	if errors.Is(err, errAmbiguousFaultCall) {
+		return nil, err
+	}
+	if err != nil {
+		zap.L().Warn("[DataDownload] 录波召唤状态查询失败,下一轮重试",
+			zap.String("itemId", item.ID.Hex()), zap.Int64("recordId", item.CallRecordID), zap.Error(err))
+	}
+	if record != nil {
+		item.CallRecordID = record.Id
+		item.CallRecordStatus = record.Status
+		if err := mongoRepo.UpdateDataDownloadFaultCall(ctx, item.ID, nil, 0, record.Id, record.Status); err != nil {
+			return nil, err
+		}
+		if record.Status != nil {
+			switch *record.Status {
+			case 2:
+				return record, nil
+			case -1:
+				return nil, fmt.Errorf("%w: recordId=%d reason=%s", ErrFaultRecordCallFailed, record.Id, faultReason(record))
+			}
+		}
+	}
+	remaining := time.Until(deadline)
+	if remaining <= 0 {
+		return nil, faultCallTimeoutError(item, callStatusText(item))
+	}
+	if remaining > faultCallPollInterval {
+		remaining = faultCallPollInterval
+	}
+	return nil, &DataDownloadFaultCallDeferred{After: remaining}
+}
+
+func callStatusText(item *domain.DataDownloadItem) string {
+	if item.CallRecordStatus == nil {
+		return "unknown"
+	}
+	return strconv.Itoa(*item.CallRecordStatus)
+}
+
+func faultReason(record *domain.FaultRecordInfo) string {
+	if record.FaultReason == nil {
+		return "upstream reported failure"
+	}
+	return *record.FaultReason
+}
+
+func findFaultCallByRequest(ctx context.Context, sn string, deviceType, count int, requestedAt time.Time) (*domain.FaultRecordInfo, error) {
+	var found *domain.FaultRecordInfo
+	lowerTime := requestedAt.Add(-time.Minute)
+	for page := 1; page <= 200; page++ {
+		if err := ctx.Err(); err != nil {
+			return nil, err
+		}
+		records, err := repositories.QueryFaultRecordCallRecordPageContext(ctx, page, faultCallPageSize)
+		if err != nil {
+			return nil, err
+		}
+		for i := range records {
+			record := &records[i]
+			if record.DeviceType != deviceType || record.FaultRecordsCount != count || record.TriggerType != 2 {
+				continue
+			}
+			deviceSN, snErr := dataDownloadFaultRecordDeviceSN(*record)
+			if snErr != nil || deviceSN != sn {
+				continue
+			}
+			callTime, parseErr := time.ParseInLocation(time.DateTime, record.CellTime, time.UTC)
+			if parseErr != nil || callTime.Before(requestedAt.Add(-time.Minute)) || callTime.After(requestedAt.Add(time.Minute)) {
+				continue
+			}
+			if found != nil && found.Id != record.Id {
+				return nil, fmt.Errorf("%w for %s at %s", errAmbiguousFaultCall, sn, requestedAt.Format(time.RFC3339))
+			}
+			copy := *record
+			found = &copy
+		}
+		// IDs are returned newest first. Once the oldest row is older than the
+		// match window, later pages cannot contain this request.
+		if len(records) > 0 {
+			oldest, parseErr := time.ParseInLocation(time.DateTime, records[len(records)-1].CellTime, time.UTC)
+			if parseErr == nil && oldest.Before(lowerTime) {
+				return found, nil
+			}
+		}
+		if len(records) < faultCallPageSize {
+			return found, nil
+		}
+	}
+	return nil, fmt.Errorf("fault recording call search exceeded 200 pages")
+}
+
+func findFaultCallByID(ctx context.Context, id int64) (*domain.FaultRecordInfo, error) {
+	probe := func(page int) (*domain.FaultRecordInfo, bool, error) {
+		if err := ctx.Err(); err != nil {
+			return nil, false, err
+		}
+		records, err := repositories.QueryFaultRecordCallRecordPageContext(ctx, page, faultCallPageSize)
+		if err != nil {
+			return nil, false, err
+		}
+		for i := range records {
+			if records[i].Id == id {
+				return &records[i], false, nil
+			}
+		}
+		return nil, len(records) > 0 && records[len(records)-1].Id > id, nil
+	}
+	// Exponential probing bounds the target page; binary search then locates
+	// it without scanning every newer page on each 60-second poll.
+	lower, upper := 0, 1
+	for upper <= 1024 {
+		record, newer, err := probe(upper)
+		if err != nil {
+			return nil, err
+		}
+		if record != nil {
+			return record, nil
+		}
+		if !newer {
+			break
+		}
+		lower, upper = upper, upper*2
+	}
+	if upper > 1024 {
+		return nil, fmt.Errorf("fault recording call %d is older than 1024 pages", id)
+	}
+	for lower+1 < upper {
+		middle := lower + (upper-lower)/2
+		record, newer, err := probe(middle)
+		if err != nil {
+			return nil, err
+		}
+		if record != nil {
+			return record, nil
+		}
+		if newer {
+			lower = middle
+		} else {
+			upper = middle
+		}
+	}
+	return nil, fmt.Errorf("fault recording call %d is absent from lstFaultWaveCall", id)
+}

+ 137 - 0
service/services/dataDownloadFaultRecordCall_test.go

@@ -0,0 +1,137 @@
+package services
+
+import (
+	"context"
+	"strings"
+	"testing"
+	"time"
+
+	"fwh-toolbox-server/domain"
+	"fwh-toolbox-server/repositories"
+)
+
+func TestFaultSubMatchesCallable(t *testing.T) {
+	candidate := domain.FaultRecordCallableInfo{FailureTime: "2026-09-22 12:30:00", TriggerSourceCode: "E42"}
+	sub := domain.FaultRecordSubInfo{FailureTime: "2026-09-22 12:30:00", EventTriggerSource: "E42"}
+	if !faultSubMatchesCallable(sub, candidate) {
+		t.Fatal("matching time and code should match")
+	}
+	sub.EventTriggerSource = "E43"
+	if faultSubMatchesCallable(sub, candidate) {
+		t.Fatal("different fault code must not match")
+	}
+}
+
+func TestExistingFaultRecordRequiresCompleteSubList(t *testing.T) {
+	candidates := []domain.FaultRecordCallableInfo{{FailureTime: "2026-09-22 12:30:00", TriggerSourceCode: "E42"}}
+	subs := []domain.FaultRecordSubInfo{{Id: 10, FailureTime: candidates[0].FailureTime, EventTriggerSource: candidates[0].TriggerSourceCode}}
+	matched := []bool{false}
+	record := domain.FaultRecordInfo{Id: 99, FaultRecordsCount: 2}
+	if got := matchExistingFaultRecord(record, subs, candidates, matched); len(got) != 0 || matched[0] {
+		t.Fatalf("incomplete record was reused: %+v", got)
+	}
+	record.FaultRecordsCount = 1
+	if got := matchExistingFaultRecord(record, subs, candidates, matched); len(got) != 1 || !matched[0] {
+		t.Fatalf("complete record not reused: %+v", got)
+	}
+}
+
+func TestResolveFaultRecordSourcesReusesVerifiedSubrecord(t *testing.T) {
+	originalCatalog := repositories.QueryFaultRecordCallableList
+	originalList := repositories.QueryFaultRecordList
+	originalSubs := repositories.QueryFaultRecordSubList
+	t.Cleanup(func() {
+		repositories.QueryFaultRecordCallableList = originalCatalog
+		repositories.QueryFaultRecordList = originalList
+		repositories.QueryFaultRecordSubList = originalSubs
+	})
+	repositories.QueryFaultRecordCallableList = func(_ int, _ string) ([]domain.FaultRecordCallableInfo, error) {
+		return []domain.FaultRecordCallableInfo{{Idx: 7, FailureTime: "2026-09-22 12:30:00", TriggerSourceCode: "E42"}}, nil
+	}
+	repositories.QueryFaultRecordList = func(_, _, _ string) ([]domain.FaultRecordInfo, error) {
+		return []domain.FaultRecordInfo{{Id: 99, GatewayId: "GATE-1", DeviceType: 1, FaultRecordsCount: 1}}, nil
+	}
+	repositories.QueryFaultRecordSubList = func(_ int64) ([]domain.FaultRecordSubInfo, error) {
+		return []domain.FaultRecordSubInfo{{Id: 10, FaultId: 99, GatewayId: "GATE-1", FailureTime: "2026-09-22 12:30:00", EventTriggerSource: "E42"}}, nil
+	}
+	start := time.Date(2026, 9, 22, 0, 0, 0, 0, time.UTC)
+	sources, err := resolveFaultRecordSources(context.Background(), &domain.DataDownloadItem{DeviceSN: "GATE-1"}, "document-1", 1, start, start.Add(24*time.Hour-time.Second))
+	if err != nil {
+		t.Fatal(err)
+	}
+	if len(sources) != 1 || sources[0].sub.Id != 10 {
+		t.Fatalf("sources = %+v", sources)
+	}
+}
+
+func TestFindFaultCallByRequestChecksAllIdentityFields(t *testing.T) {
+	original := repositories.QueryFaultRecordCallRecordPageContext
+	t.Cleanup(func() { repositories.QueryFaultRecordCallRecordPageContext = original })
+	sn := "GATE-1"
+	other := "GATE-2"
+	status := 0
+	calledAt := time.Date(2026, 9, 23, 12, 0, 0, 0, time.UTC)
+	repositories.QueryFaultRecordCallRecordPageContext = func(_ context.Context, page, size int) ([]domain.FaultRecordInfo, error) {
+		if page > 1 {
+			return nil, nil
+		}
+		return []domain.FaultRecordInfo{
+			{Id: 101, DeviceType: 1, GatewayId: other, FaultRecordsCount: 2, TriggerType: 2, CellTime: "2026-09-23 12:00:00", Status: &status},
+			{Id: 102, DeviceType: 1, GatewayId: sn, FaultRecordsCount: 1, TriggerType: 2, CellTime: "2026-09-23 12:00:00", Status: &status},
+			{Id: 103, DeviceType: 1, GatewayId: sn, FaultRecordsCount: 2, TriggerType: 1, CellTime: "2026-09-23 12:00:00", Status: &status},
+			{Id: 104, DeviceType: 1, GatewayId: sn, FaultRecordsCount: 2, TriggerType: 2, CellTime: "2026-09-23 12:02:00", Status: &status},
+			{Id: 105, DeviceType: 1, GatewayId: sn, FaultRecordsCount: 2, TriggerType: 2, CellTime: "2026-09-23 12:00:30", Status: &status},
+		}, nil
+	}
+	record, err := findFaultCallByRequest(context.Background(), sn, 1, 2, calledAt)
+	if err != nil {
+		t.Fatal(err)
+	}
+	if record == nil || record.Id != 105 {
+		t.Fatalf("got %+v, want ID 105", record)
+	}
+}
+
+func TestFindFaultCallByIDUsesPageSearch(t *testing.T) {
+	original := repositories.QueryFaultRecordCallRecordPageContext
+	t.Cleanup(func() { repositories.QueryFaultRecordCallRecordPageContext = original })
+	queries := 0
+	repositories.QueryFaultRecordCallRecordPageContext = func(_ context.Context, page, size int) ([]domain.FaultRecordInfo, error) {
+		queries++
+		if page > 128 {
+			return nil, nil
+		}
+		rows := make([]domain.FaultRecordInfo, size)
+		for i := range rows {
+			rows[i].Id = int64(6401 - (page-1)*size - i)
+		}
+		return rows, nil
+	}
+	record, err := findFaultCallByID(context.Background(), 1600)
+	if err != nil {
+		t.Fatal(err)
+	}
+	if record == nil || record.Id != 1600 {
+		t.Fatalf("got %+v, want ID 1600", record)
+	}
+	if queries > 16 {
+		t.Fatalf("used %d queries for a record on page 97", queries)
+	}
+}
+
+func TestSummonFaultRecordsHonorsElapsedTimeout(t *testing.T) {
+	started := time.Now().UTC().Add(-faultCallTimeout - time.Minute)
+	item := &domain.DataDownloadItem{CallRequestedAt: &started, DeviceSN: "GATE-1"}
+	_, err := summonFaultRecords(context.Background(), item, "document-1", 1, []int{1})
+	if err == nil || !strings.Contains(err.Error(), "timed out after 2h") {
+		t.Fatalf("got %v, want timeout", err)
+	}
+}
+
+func TestActiveFaultCallKeepsOwnershipAfterCancellation(t *testing.T) {
+	started := time.Now().UTC().Add(-time.Minute)
+	item := &domain.DataDownloadItem{Kind: domain.DataDownloadFaultRecord, CallRequestedAt: &started}
+	if err := ReleaseFaultCallOwnership(context.Background(), item); err != nil {
+		t.Fatal(err)
+	}
+}

+ 293 - 0
service/services/dataDownloadFaultRecordService.go

@@ -0,0 +1,293 @@
+package services
+
+import (
+	"bytes"
+	"context"
+	"crypto/sha256"
+	"encoding/hex"
+	"errors"
+	"fmt"
+	"mime"
+	"path/filepath"
+	"strconv"
+	"strings"
+	"time"
+
+	"fwh-toolbox-server/cache"
+	"fwh-toolbox-server/domain"
+	"fwh-toolbox-server/repositories"
+	minioRepo "fwh-toolbox-server/repositories/minio"
+	mongoRepo "fwh-toolbox-server/repositories/mongo"
+
+	"go.mongodb.org/mongo-driver/bson/primitive"
+	"go.mongodb.org/mongo-driver/mongo"
+	"go.uber.org/zap"
+)
+
+type DataDownloadPartialFailure struct {
+	Succeeded int
+	Failed    int
+	Cause     error
+}
+
+func (e *DataDownloadPartialFailure) Error() string {
+	return fmt.Sprintf("fault-record request partially completed: %d succeeded, %d failed", e.Succeeded, e.Failed)
+}
+
+func (e *DataDownloadPartialFailure) Unwrap() error { return e.Cause }
+
+func executeFaultRecordDataDownloadItem(ctx context.Context, item *domain.DataDownloadItem, awaitRequestID string) error {
+	ready, lease, err := prepareDataDownloadRequest(ctx, item, awaitRequestID)
+	if err != nil {
+		return err
+	}
+	if ready != nil {
+		_ = mongoRepo.ReleaseDataDownloadFaultCall(ctx, ready.ID, item.ID)
+		return linkDataDownloadItem(ctx, item, ready, ready.Empty)
+	}
+	defer lease.abort()
+	otherActive, err := mongoRepo.OtherDataDownloadFaultCallActive(ctx, lease.documentID, item.ID)
+	if err != nil {
+		return err
+	}
+	if otherActive {
+		return &DataDownloadFaultCallDeferred{After: time.Minute}
+	}
+
+	deviceType, err := strconv.Atoi(item.DeviceType)
+	if err != nil {
+		return fmt.Errorf("invalid fault-record device type %q", item.DeviceType)
+	}
+	loc := dataDownloadLocation(item.Timezone)
+	start, err := time.ParseInLocation(time.DateOnly, item.Date, loc)
+	if err != nil {
+		return err
+	}
+	end := start.AddDate(0, 0, 1).Add(-time.Second)
+	sources, sourceErr := resolveFaultRecordSources(ctx, item, lease.documentID, deviceType, start, end)
+	var callDeferred *DataDownloadFaultCallDeferred
+	pendingCall := errors.As(sourceErr, &callDeferred)
+	if errors.Is(sourceErr, context.Canceled) {
+		_ = ReleaseFaultCallOwnership(context.Background(), item)
+		return sourceErr
+	}
+	if sourceErr != nil && len(sources) == 0 {
+		if faultCallTerminal(item, sourceErr) {
+			_ = ReleaseFaultCallOwnership(context.Background(), item)
+		}
+		return sourceErr
+	}
+	fetchedAt := time.Now().UTC()
+	zap.L().Debug("[DataDownload] FaultRecord 已验证录波子记录",
+		zap.String("documentId", lease.documentID),
+		zap.String("deviceSn", item.DeviceSN),
+		zap.String("deviceType", item.DeviceType),
+		zap.String("date", item.Date),
+		zap.Int("sourceCount", len(sources)),
+	)
+
+	for _, source := range sources {
+		sourceID := fmt.Sprintf("%d:%d", source.record.Id, source.sub.Id)
+		if attempt, getErr := mongoRepo.GetDataDownloadFileAttempt(ctx, item.TaskID, item.ID, sourceID); getErr == nil && attempt.Status == domain.DataDownloadItemSucceeded {
+			if _, entryErr := mongoRepo.GetDataDownloadFaultRecordEntry(ctx, lease.documentID, sourceID); entryErr == nil {
+				continue
+			}
+		}
+
+		name, body, downloadErr := repositories.QueryDownloadFaultRecordData(source.record.Id, source.sub.Id)
+		if downloadErr != nil {
+			_ = recordFaultRecordAttempt(ctx, item, lease.documentID, sourceID, name, nil, "download", downloadErr)
+			continue
+		}
+		name = filepath.Base(name)
+		if name == "." || name == "" {
+			name = fmt.Sprintf("fault-%d-%d.bin", source.record.Id, source.sub.Id)
+		}
+		zap.L().Debug("[DataDownload] FaultRecord 文件已下载到内存",
+			zap.String("documentId", lease.documentID),
+			zap.String("sourceId", sourceID),
+			zap.String("fileName", name),
+			zap.Int("bytes", len(body)),
+		)
+		contentType := mime.TypeByExtension(strings.ToLower(filepath.Ext(name)))
+		if contentType == "" {
+			contentType = "application/octet-stream"
+		}
+		sum := sha256.Sum256(body)
+		objectKey, uploadErr := minioRepo.PutBinaryDocument(ctx, lease.documentID, name, bytes.NewReader(body), int64(len(body)), contentType)
+		if uploadErr != nil {
+			_ = recordFaultRecordAttempt(ctx, item, lease.documentID, sourceID, name, nil, "upload", uploadErr)
+			continue
+		}
+
+		contentLock, lockErr := cache.Lock(ctx, "data-download:content:"+lease.documentID, 10*time.Minute, 200*time.Millisecond, 30*time.Second)
+		if lockErr != nil {
+			_ = minioRepo.Delete(context.Background(), objectKey)
+			_ = recordFaultRecordAttempt(ctx, item, lease.documentID, sourceID, name, nil, "publish", lockErr)
+			continue
+		}
+		if validateErr := lease.validate(ctx); validateErr != nil {
+			contentLock.SafeRelease(context.Background())
+			_ = minioRepo.Delete(context.Background(), objectKey)
+			return validateErr
+		}
+		entry := &domain.DataDownloadFaultRecordEntry{
+			DocumentID: lease.documentID, SourceFileID: sourceID, FaultID: source.record.Id,
+			SubRecordID: source.sub.Id, FileName: name, ContentType: contentType,
+			Size: int64(len(body)), Checksum: hex.EncodeToString(sum[:]), ObjectKey: objectKey,
+			LastSuccessfulAt: fetchedAt, UpdatedAt: fetchedAt,
+		}
+		oldObjectKey, publishErr := mongoRepo.UpsertDataDownloadFaultRecordEntry(ctx, entry)
+		contentLock.SafeRelease(context.Background())
+		if publishErr != nil {
+			_ = minioRepo.Delete(context.Background(), objectKey)
+			_ = recordFaultRecordAttempt(ctx, item, lease.documentID, sourceID, name, nil, "publish", publishErr)
+			continue
+		}
+		if oldObjectKey != "" && oldObjectKey != objectKey {
+			_ = minioRepo.Delete(context.Background(), oldObjectKey)
+		}
+		if attemptErr := recordFaultRecordAttempt(ctx, item, lease.documentID, sourceID, name, &entry.ID, "", nil); attemptErr != nil {
+			return attemptErr
+		}
+	}
+	if sourceErr != nil && !pendingCall {
+		if err := recordFaultRecordAttempt(ctx, item, lease.documentID, "call:*", "", nil, "call", sourceErr); err != nil {
+			return err
+		}
+	} else {
+		_ = mongoRepo.DeleteDataDownloadFileAttempt(ctx, item.TaskID, item.ID, "call:*")
+	}
+
+	attempts, err := mongoRepo.ListDataDownloadFileAttempts(ctx, item.TaskID)
+	if err != nil {
+		return err
+	}
+	succeeded, failed := 0, 0
+	for i := range attempts {
+		if attempts[i].ItemID != item.ID {
+			continue
+		}
+		if attempts[i].Status == domain.DataDownloadItemSucceeded {
+			succeeded++
+		} else if attempts[i].Status == domain.DataDownloadFailed {
+			failed++
+		}
+	}
+	entries, err := mongoRepo.ListDataDownloadFaultRecordEntries(ctx, lease.documentID)
+	if err != nil {
+		return err
+	}
+	manifest := strings.Builder{}
+	for i := range entries {
+		manifest.WriteString(entries[i].SourceFileID)
+		manifest.WriteByte('|')
+		manifest.WriteString(entries[i].Checksum)
+		manifest.WriteByte('\n')
+	}
+	manifestSum := sha256.Sum256([]byte(manifest.String()))
+
+	contentLock, err := cache.Lock(ctx, "data-download:content:"+lease.documentID, 10*time.Minute, 200*time.Millisecond, 30*time.Second)
+	if err != nil {
+		return err
+	}
+	defer contentLock.SafeRelease(context.Background())
+	if err := lease.validate(ctx); err != nil {
+		return err
+	}
+	generation, createdAt := int64(1), fetchedAt
+	if existing, getErr := mongoRepo.GetDataDownloadDocument(ctx, lease.documentID); getErr == nil {
+		generation = existing.ContentGeneration + 1
+		createdAt = existing.CreatedAt
+	} else if !errors.Is(getErr, mongo.ErrNoDocuments) {
+		return getErr
+	}
+	completeness := domain.DataDownloadCompletenessComplete
+	if failed > 0 || pendingCall {
+		completeness = domain.DataDownloadCompletenessPartial
+	}
+	state, frozenAt := faultRecordDocumentState(time.Now(), loc, start, failed)
+	if pendingCall {
+		state, frozenAt = domain.DataDownloadMutable, nil
+	}
+	document := &domain.DataDownloadDocument{
+		ID: lease.documentID, RequestKey: lease.requestKey, Kind: item.Kind,
+		SiteSN: item.SiteSN, DeviceSN: item.DeviceSN, Date: item.Date, DeviceType: item.DeviceType,
+		Timezone: item.Timezone, State: state, Completeness: completeness,
+		Empty: !pendingCall && failed == 0 && len(entries) == 0, FileCount: int64(len(entries)), FailedFileCount: int64(failed),
+		ContentGeneration: generation, Checksum: hex.EncodeToString(manifestSum[:]), SourceFetchedAt: fetchedAt,
+		FrozenAt: frozenAt, CreatedAt: createdAt, UpdatedAt: fetchedAt,
+	}
+	if err := mongoRepo.UpsertDataDownloadDocument(ctx, document); err != nil {
+		return err
+	}
+	zap.L().Debug("[DataDownload] FaultRecord 内容已提交",
+		zap.String("documentId", lease.documentID),
+		zap.String("state", string(state)),
+		zap.String("completeness", string(completeness)),
+		zap.Int("fileCount", len(entries)),
+		zap.Int("succeeded", succeeded),
+		zap.Int("failed", failed),
+		zap.Int64("generation", generation),
+	)
+	if err := linkDataDownloadItem(ctx, item, document, document.Empty); err != nil {
+		return err
+	}
+	if pendingCall {
+		return callDeferred
+	}
+	if faultCallTerminal(item, sourceErr) {
+		_ = ReleaseFaultCallOwnership(context.Background(), item)
+	}
+	if failed > 0 {
+		return &DataDownloadPartialFailure{Succeeded: succeeded, Failed: failed, Cause: sourceErr}
+	}
+	if err := lease.complete(ctx); err != nil {
+		return err
+	}
+	return mongoRepo.ReleaseDataDownloadFaultCall(ctx, lease.documentID, item.ID)
+}
+
+func faultCallTerminal(item *domain.DataDownloadItem, err error) bool {
+	return item.CallRecordStatus != nil && *item.CallRecordStatus == 2 ||
+		errors.Is(err, ErrFaultRecordCallTimeout) || errors.Is(err, ErrFaultRecordCallFailed) || errors.Is(err, errAmbiguousFaultCall)
+}
+
+// ReleaseFaultCallOwnership clears an owner only when no remote call was sent,
+// the call is terminal, or its two-hour observation window has elapsed. A
+// cancelled item can otherwise leave a remote call running; its owner expires
+// automatically and must not be removed early by another queue execution.
+func ReleaseFaultCallOwnership(ctx context.Context, item *domain.DataDownloadItem) error {
+	if item == nil || item.Kind != domain.DataDownloadFaultRecord {
+		return nil
+	}
+	if item.CallRequestedAt != nil &&
+		(item.CallRecordStatus == nil || (*item.CallRecordStatus != 2 && *item.CallRecordStatus != -1)) &&
+		time.Now().Before(item.CallRequestedAt.Add(faultCallTimeout)) {
+		return nil
+	}
+	requestKey, err := buildDataDownloadRequestKey(item)
+	if err != nil {
+		return err
+	}
+	return mongoRepo.ReleaseDataDownloadFaultCall(ctx, stableDataDownloadDocumentID(requestKey), item.ID)
+}
+
+func faultRecordDocumentState(now time.Time, loc *time.Location, dataDay time.Time, failed int) (domain.DataDownloadState, *time.Time) {
+	if failed > 0 || now.In(loc).Before(dataDay.AddDate(0, 0, 2)) {
+		return domain.DataDownloadMutable, nil
+	}
+	frozenAt := now.UTC()
+	return domain.DataDownloadFrozen, &frozenAt
+}
+
+func recordFaultRecordAttempt(ctx context.Context, item *domain.DataDownloadItem, documentID, sourceID, fileName string, entryID *primitive.ObjectID, stage string, cause error) error {
+	status, reason := domain.DataDownloadItemSucceeded, ""
+	if cause != nil {
+		status, reason = domain.DataDownloadFailed, cause.Error()
+	}
+	return mongoRepo.RecordDataDownloadFileAttempt(ctx, &domain.DataDownloadFileAttempt{
+		TaskID: item.TaskID, ItemID: item.ID, DocumentID: documentID, SourceFileID: sourceID,
+		FileName: fileName, Status: status, EntryID: entryID, FailureStage: stage, FailureReason: reason,
+	})
+}

+ 195 - 0
service/services/dataDownloadFileRequestService.go

@@ -0,0 +1,195 @@
+package services
+
+import (
+	"context"
+	"errors"
+	"fmt"
+	"sync"
+	"time"
+
+	"fwh-toolbox-server/cache"
+	"fwh-toolbox-server/domain"
+	mongoRepo "fwh-toolbox-server/repositories/mongo"
+
+	"github.com/redis/go-redis/v9"
+	"go.mongodb.org/mongo-driver/mongo"
+	"go.uber.org/zap"
+)
+
+const (
+	dataDownloadRequestLeaseTTL = 2 * time.Minute
+	dataDownloadCompletedTTL    = time.Hour
+)
+
+// DataDownloadRequestDeferred tells the queue to retry an item later without
+// occupying a worker while another request for the same document is running.
+type DataDownloadRequestDeferred struct {
+	RequestID string
+	After     time.Duration
+}
+
+func (e *DataDownloadRequestDeferred) Error() string {
+	return fmt.Sprintf("data download request %s is still running", e.RequestID)
+}
+
+type dataDownloadRequestLease struct {
+	documentID string
+	requestKey string
+	requestID  string
+	lock       *cache.RedisLock
+	cancel     context.CancelFunc
+	done       chan struct{}
+	lost       chan struct{}
+	stopOnce   sync.Once
+}
+
+func dataDownloadRequestKey(documentID string) string {
+	return "data-download:request:" + documentID
+}
+
+func dataDownloadCompletedKey(documentID string) string {
+	return "data-download:completed:" + documentID
+}
+
+func prepareDataDownloadRequest(ctx context.Context, item *domain.DataDownloadItem, awaitRequestID string) (*domain.DataDownloadDocument, *dataDownloadRequestLease, error) {
+	requestKey, err := buildDataDownloadRequestKey(item)
+	if err != nil {
+		return nil, nil, err
+	}
+	documentID := stableDataDownloadDocumentID(requestKey)
+	if existing, err := mongoRepo.GetDataDownloadDocument(ctx, documentID); err == nil && existing.State == domain.DataDownloadFrozen {
+		return existing, nil, nil
+	} else if err != nil && !errors.Is(err, mongo.ErrNoDocuments) {
+		return nil, nil, err
+	}
+
+	if awaitRequestID != "" && item.Kind != domain.DataDownloadUpdateLog {
+		completed, err := cache.Get(ctx, dataDownloadCompletedKey(documentID))
+		if err == nil && completed == awaitRequestID {
+			document, getErr := mongoRepo.GetDataDownloadDocument(ctx, documentID)
+			if getErr == nil {
+				return document, nil, nil
+			}
+			if !errors.Is(getErr, mongo.ErrNoDocuments) {
+				return nil, nil, getErr
+			}
+		}
+		if err != nil && !errors.Is(err, redis.Nil) {
+			return nil, nil, err
+		}
+	}
+
+	lock, ok, err := cache.TryLock(ctx, dataDownloadRequestKey(documentID), dataDownloadRequestLeaseTTL)
+	if err != nil {
+		return nil, nil, err
+	}
+	if !ok {
+		requestID, getErr := cache.Get(ctx, dataDownloadRequestKey(documentID))
+		if getErr != nil && !errors.Is(getErr, redis.Nil) {
+			return nil, nil, getErr
+		}
+		if requestID == "" {
+			requestID = awaitRequestID
+		}
+		zap.L().Debug("[DataDownload] 请求租约被占用,本 item 延迟等待",
+			zap.String("itemId", item.ID.Hex()),
+			zap.String("documentId", documentID),
+			zap.String("kind", string(item.Kind)),
+			zap.String("sn", item.SiteSN),
+			zap.String("holderRequestId", requestID),
+		)
+		return nil, nil, &DataDownloadRequestDeferred{RequestID: requestID, After: 15 * time.Second}
+	}
+
+	// The document may have become frozen between the first lookup and lease acquisition.
+	if existing, getErr := mongoRepo.GetDataDownloadDocument(ctx, documentID); getErr == nil && existing.State == domain.DataDownloadFrozen {
+		lock.SafeRelease(context.Background())
+		return existing, nil, nil
+	} else if getErr != nil && !errors.Is(getErr, mongo.ErrNoDocuments) {
+		lock.SafeRelease(context.Background())
+		return nil, nil, getErr
+	}
+
+	renewCtx, cancel := context.WithCancel(context.Background())
+	lease := &dataDownloadRequestLease{
+		documentID: documentID,
+		requestKey: requestKey,
+		requestID:  lock.GetToken(),
+		lock:       lock,
+		cancel:     cancel,
+		done:       make(chan struct{}),
+		lost:       make(chan struct{}),
+	}
+	go lease.renew(renewCtx)
+	zap.L().Debug("[DataDownload] 已获取请求租约,进入执行",
+		zap.String("itemId", item.ID.Hex()),
+		zap.String("documentId", documentID),
+		zap.String("kind", string(item.Kind)),
+		zap.String("sn", item.SiteSN),
+		zap.String("subtype", item.Subtype),
+		zap.String("date", item.Date),
+	)
+	return nil, lease, nil
+}
+
+func (l *dataDownloadRequestLease) renew(ctx context.Context) {
+	defer close(l.done)
+	ticker := time.NewTicker(dataDownloadRequestLeaseTTL / 3)
+	defer ticker.Stop()
+	for {
+		select {
+		case <-ctx.Done():
+			return
+		case <-ticker.C:
+			renewCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
+			err := l.lock.Extend(renewCtx, dataDownloadRequestLeaseTTL)
+			cancel()
+			if err != nil {
+				zap.L().Warn("[DataDownload] 请求租约续期失败,租约丢失",
+					zap.String("documentId", l.documentID),
+					zap.String("requestId", l.requestID),
+					zap.String("err", err.Error()),
+				)
+				close(l.lost)
+				return
+			}
+		}
+	}
+}
+
+func (l *dataDownloadRequestLease) validate(ctx context.Context) error {
+	select {
+	case <-l.lost:
+		return fmt.Errorf("data download request lease was lost")
+	default:
+	}
+	ok, err := l.lock.IsOwner(ctx)
+	if err != nil {
+		return err
+	}
+	if !ok {
+		return fmt.Errorf("data download request lease was lost")
+	}
+	return nil
+}
+
+func (l *dataDownloadRequestLease) complete(ctx context.Context) error {
+	if err := l.lock.SetIfOwner(ctx, dataDownloadCompletedKey(l.documentID), l.requestID, dataDownloadCompletedTTL); err != nil {
+		l.stop()
+		return err
+	}
+	l.stop()
+	return nil
+}
+
+func (l *dataDownloadRequestLease) abort() {
+	l.stop()
+}
+
+func (l *dataDownloadRequestLease) stop() {
+	l.stopOnce.Do(func() {
+		l.cancel()
+		<-l.done
+		l.lock.SafeRelease(context.Background())
+	})
+}

+ 245 - 0
service/services/dataDownloadPackageService.go

@@ -0,0 +1,245 @@
+package services
+
+import (
+	"archive/zip"
+	"bytes"
+	"context"
+	"encoding/json"
+	"fmt"
+	"io"
+	"path"
+	"strings"
+	"time"
+
+	"fwh-toolbox-server/cache"
+	"fwh-toolbox-server/domain"
+	minioRepo "fwh-toolbox-server/repositories/minio"
+	mongoRepo "fwh-toolbox-server/repositories/mongo"
+
+	"go.uber.org/zap"
+)
+
+func BuildDataDownloadPackage(ctx context.Context, job *domain.DataDownloadPackageJob, progress func(done, total int64) error) ([]byte, int64, error) {
+	type input struct {
+		taskID, logicalPath, objectKey, filename string
+		data                                     []byte
+		document                                 *domain.DataDownloadDocument
+		artifact                                 *domain.DataDownloadArtifact
+	}
+	var inputs []input
+	type reportFailure struct {
+		TaskID        string `json:"taskId"`
+		ItemID        string `json:"itemId"`
+		DocumentID    string `json:"documentId"`
+		SourceFileID  string `json:"sourceFileId"`
+		FileName      string `json:"fileName,omitempty"`
+		FailureStage  string `json:"failureStage"`
+		FailureReason string `json:"failureReason"`
+		AttemptCount  int    `json:"attemptCount"`
+		DeviceSN      string `json:"deviceSn,omitempty"`
+		DeviceType    string `json:"deviceType,omitempty"`
+		Date          string `json:"date,omitempty"`
+	}
+	type reportTask struct {
+		TaskID    string                    `json:"taskId"`
+		Status    domain.DataDownloadStatus `json:"status"`
+		Succeeded int64                     `json:"succeeded"`
+		Failed    int64                     `json:"failed"`
+	}
+	var failures []reportFailure
+	var reportTasks []reportTask
+	for _, taskID := range job.TaskIDs {
+		task, err := mongoRepo.GetDataDownloadTask(ctx, taskID)
+		if err != nil {
+			return nil, 0, err
+		}
+		reportTasks = append(reportTasks, reportTask{TaskID: taskID.Hex(), Status: task.Status, Succeeded: task.Succeeded, Failed: task.Failed})
+		items, err := mongoRepo.ListDataDownloadItems(ctx, taskID)
+		if err != nil {
+			return nil, 0, err
+		}
+		itemByID := make(map[string]domain.DataDownloadItem, len(items))
+		for i := range items {
+			itemByID[items[i].ID.Hex()] = items[i]
+		}
+		attempts, err := mongoRepo.ListDataDownloadFileAttempts(ctx, taskID)
+		if err != nil {
+			return nil, 0, err
+		}
+		for i := range attempts {
+			if attempts[i].Status != domain.DataDownloadFailed {
+				continue
+			}
+			item := itemByID[attempts[i].ItemID.Hex()]
+			failures = append(failures, reportFailure{
+				TaskID: taskID.Hex(), ItemID: attempts[i].ItemID.Hex(), DocumentID: attempts[i].DocumentID,
+				SourceFileID: attempts[i].SourceFileID, FileName: attempts[i].FileName,
+				FailureStage: attempts[i].FailureStage, FailureReason: attempts[i].FailureReason,
+				AttemptCount: attempts[i].AttemptCount, DeviceSN: item.DeviceSN, DeviceType: item.DeviceType, Date: item.Date,
+			})
+		}
+		links, err := mongoRepo.ListDataDownloadTaskDocuments(ctx, taskID)
+		if err != nil {
+			return nil, 0, err
+		}
+		for _, link := range links {
+			if link.Empty {
+				continue
+			}
+			document, err := mongoRepo.GetDataDownloadDocument(ctx, link.DocumentID)
+			if err != nil {
+				return nil, 0, err
+			}
+			if document.Kind == domain.DataDownloadFaultRecord {
+				lock, err := cache.Lock(ctx, "data-download:content:"+document.ID, 10*time.Minute, 100*time.Millisecond, 30*time.Second)
+				if err != nil {
+					return nil, 0, err
+				}
+				entries, err := mongoRepo.ListDataDownloadFaultRecordEntries(ctx, document.ID)
+				if err != nil {
+					lock.SafeRelease(context.Background())
+					return nil, 0, err
+				}
+				for i := range entries {
+					reader, openErr := minioRepo.Open(ctx, entries[i].ObjectKey)
+					if openErr != nil {
+						lock.SafeRelease(context.Background())
+						return nil, 0, openErr
+					}
+					data, readErr := io.ReadAll(reader)
+					_ = reader.Close()
+					if readErr != nil {
+						lock.SafeRelease(context.Background())
+						return nil, 0, readErr
+					}
+					filename := fmt.Sprintf("%d-%d-%s", entries[i].FaultID, entries[i].SubRecordID, entries[i].FileName)
+					inputs = append(inputs, input{taskID: taskID.Hex(), logicalPath: link.LogicalPath, objectKey: entries[i].ObjectKey, filename: filename, data: data, document: document})
+				}
+				lock.SafeRelease(context.Background())
+				continue
+			}
+			entry := input{taskID: taskID.Hex(), logicalPath: link.LogicalPath, document: document}
+			if document.BinaryObjectKey != "" {
+				lock, err := cache.Lock(ctx, "data-download:content:"+document.ID, 10*time.Minute, 100*time.Millisecond, 30*time.Second)
+				if err != nil {
+					return nil, 0, err
+				}
+				current, err := mongoRepo.GetDataDownloadDocument(ctx, document.ID)
+				if err != nil {
+					lock.SafeRelease(context.Background())
+					return nil, 0, err
+				}
+				reader, err := minioRepo.Open(ctx, current.BinaryObjectKey)
+				if err != nil {
+					lock.SafeRelease(context.Background())
+					return nil, 0, err
+				}
+				entry.data, err = io.ReadAll(reader)
+				_ = reader.Close()
+				lock.SafeRelease(context.Background())
+				if err != nil {
+					return nil, 0, err
+				}
+				entry.objectKey = current.BinaryObjectKey
+			} else {
+				artifact, data, err := ReadRenderedDataDownloadDocument(ctx, document.ID, defaultDataDownloadFormat(document.Kind))
+				if err != nil {
+					return nil, 0, err
+				}
+				entry.data = data
+				entry.artifact, entry.objectKey = artifact, artifact.ObjectKey
+			}
+			inputs = append(inputs, entry)
+		}
+	}
+	if len(inputs) == 0 && len(failures) == 0 {
+		return nil, 0, fmt.Errorf("no readable non-empty documents")
+	}
+	if len(failures) > 0 {
+		report, err := json.MarshalIndent(struct {
+			GeneratedAt time.Time       `json:"generatedAt"`
+			Tasks       []reportTask    `json:"tasks"`
+			Failures    []reportFailure `json:"failures"`
+		}{GeneratedAt: time.Now().UTC(), Tasks: reportTasks, Failures: failures}, "", "  ")
+		if err != nil {
+			return nil, 0, err
+		}
+		inputs = append(inputs, input{logicalPath: "", filename: "DataDownloadReport.json", data: report})
+	}
+	if progress != nil {
+		if err := progress(0, int64(len(inputs))); err != nil {
+			return nil, 0, err
+		}
+	}
+	zap.L().Debug("[DataDownload] 打包收集完成,开始写 ZIP",
+		zap.Int("fileCount", len(inputs)),
+		zap.Int("failureCount", len(failures)),
+		zap.Int("taskCount", len(job.TaskIDs)),
+	)
+	var buffer bytes.Buffer
+	archive := zip.NewWriter(&buffer)
+	for i, entry := range inputs {
+		var reader io.ReadCloser
+		if entry.data != nil {
+			reader = io.NopCloser(bytes.NewReader(entry.data))
+		} else {
+			opened, err := minioRepo.Open(ctx, entry.objectKey)
+			if err != nil {
+				zap.L().Warn("[DataDownload] 打包读取对象失败", zap.String("objectKey", entry.objectKey), zap.Int("index", i), zap.String("err", err.Error()))
+				_ = archive.Close()
+				return nil, int64(i), err
+			}
+			reader = opened
+		}
+		filename := entry.filename
+		if filename == "" {
+			filename = path.Base(entry.objectKey)
+		}
+		if entry.artifact != nil {
+			filename = entry.artifact.Filename
+		}
+		logical := cleanZipPath(entry.logicalPath)
+		if len(job.TaskIDs) > 1 {
+			logical = path.Join(entry.taskID, logical)
+		}
+		writer, err := archive.Create(path.Join(logical, filename))
+		if err == nil {
+			_, err = io.Copy(writer, reader)
+		}
+		_ = reader.Close()
+		if err != nil {
+			_ = archive.Close()
+			return nil, int64(i), err
+		}
+		if progress != nil {
+			if err := progress(int64(i+1), int64(len(inputs))); err != nil {
+				_ = archive.Close()
+				return nil, int64(i + 1), err
+			}
+		}
+	}
+	if err := archive.Close(); err != nil {
+		return nil, 0, err
+	}
+	return buffer.Bytes(), int64(len(inputs)), nil
+}
+
+func defaultDataDownloadFormat(kind domain.DataDownloadKind) string {
+	switch kind {
+	case domain.DataDownloadPowerFlow:
+		return "html"
+	case domain.DataDownloadMqttLog, domain.DataDownloadEventAlarm, domain.DataDownloadUpdateLog:
+		return "xlsx"
+	default:
+		return ""
+	}
+}
+
+func cleanZipPath(value string) string {
+	value = strings.ReplaceAll(value, "\\", "/")
+	value = strings.TrimPrefix(path.Clean("/"+value), "/")
+	if value == "." {
+		return "documents"
+	}
+	return value
+}

+ 302 - 0
service/services/dataDownloadRenderService.go

@@ -0,0 +1,302 @@
+package services
+
+import (
+	"bytes"
+	"context"
+	"crypto/sha256"
+	"encoding/csv"
+	"encoding/hex"
+	"encoding/json"
+	"fmt"
+	"html"
+	"io"
+	"strings"
+	"time"
+
+	"fwh-toolbox-server/cache"
+	"fwh-toolbox-server/domain"
+	minioRepo "fwh-toolbox-server/repositories/minio"
+	mongoRepo "fwh-toolbox-server/repositories/mongo"
+	pgsqlRepo "fwh-toolbox-server/repositories/pgsql"
+
+	"github.com/xuri/excelize/v2"
+	"go.mongodb.org/mongo-driver/mongo"
+	"go.uber.org/zap"
+)
+
+const renderCacheTTL = 72 * time.Hour
+
+func ReadRenderedDataDownloadDocument(ctx context.Context, documentID, format string) (*domain.DataDownloadArtifact, []byte, error) {
+	document, format, lock, err := lockRenderedDataDownloadDocument(ctx, documentID, format)
+	if err != nil {
+		return nil, nil, err
+	}
+	defer lock.SafeRelease(context.Background())
+	artifact, data, err := resolveRenderedDataDownloadDocument(ctx, document, format)
+	if err != nil || data != nil {
+		return artifact, data, err
+	}
+	data, err = readDataDownloadArtifact(ctx, artifact)
+	return artifact, data, err
+}
+
+func OpenRenderedDataDownloadDocument(ctx context.Context, documentID, format string) (*domain.DataDownloadArtifact, io.ReadCloser, error) {
+	document, format, lock, err := lockRenderedDataDownloadDocument(ctx, documentID, format)
+	if err != nil {
+		return nil, nil, err
+	}
+	artifact, data, err := resolveRenderedDataDownloadDocument(ctx, document, format)
+	if err != nil {
+		lock.SafeRelease(context.Background())
+		return nil, nil, err
+	}
+	var reader io.ReadCloser
+	if data != nil {
+		reader = io.NopCloser(bytes.NewReader(data))
+	} else {
+		reader, err = minioRepo.Open(ctx, artifact.ObjectKey)
+		if err != nil {
+			lock.SafeRelease(context.Background())
+			return nil, nil, err
+		}
+	}
+	return artifact, newDataDownloadContentReader(reader, lock), nil
+}
+
+func lockRenderedDataDownloadDocument(ctx context.Context, documentID, format string) (*domain.DataDownloadDocument, string, *cache.RedisLock, error) {
+	document, err := mongoRepo.GetDataDownloadDocument(ctx, documentID)
+	if err != nil {
+		return nil, "", nil, err
+	}
+	format = strings.ToLower(strings.TrimPrefix(format, "."))
+	if !supportedRenderFormat(document.Kind, format) {
+		return nil, "", nil, fmt.Errorf("format %s is not supported for %s", format, document.Kind)
+	}
+
+	// This lock is shared by writers. It is deliberately exclusive at Redis level;
+	// PostgreSQL's advisory shared lock still permits safe concurrent database readers.
+	lock, err := cache.Lock(ctx, "data-download:content:"+documentID, 10*time.Minute, 200*time.Millisecond, 30*time.Second)
+	if err != nil {
+		return nil, "", nil, err
+	}
+	document, err = mongoRepo.GetDataDownloadDocument(ctx, documentID)
+	if err != nil {
+		lock.SafeRelease(context.Background())
+		return nil, "", nil, err
+	}
+	return document, format, lock, nil
+}
+
+func resolveRenderedDataDownloadDocument(ctx context.Context, document *domain.DataDownloadDocument, format string) (*domain.DataDownloadArtifact, []byte, error) {
+	if cached, err := currentArtifact(ctx, document, format); err == nil {
+		zap.L().Debug("[DataDownload] 渲染缓存命中",
+			zap.String("documentId", document.ID), zap.String("format", format),
+			zap.Int64("generation", cached.ContentGeneration))
+		return cached, nil, nil
+	}
+	state, rows, err := pgsqlRepo.ReadDataDownloadRows(document.ID, document.Kind)
+	if err != nil {
+		return nil, nil, err
+	}
+	data, contentType, err := encodeRenderedRows(document, rows, format)
+	if err != nil {
+		return nil, nil, err
+	}
+	objectKey, err := minioRepo.PutRenderArtifact(ctx, document.ID, state.ContentGeneration, format, data, contentType)
+	if err != nil {
+		return nil, nil, err
+	}
+	sum := sha256.Sum256(data)
+	now := time.Now().UTC()
+	artifact := &domain.DataDownloadArtifact{DocumentID: document.ID, Format: format, ContentGeneration: state.ContentGeneration, ObjectKey: objectKey, Filename: renderedFilename(document, format), Size: int64(len(data)), Checksum: hex.EncodeToString(sum[:]), ExpiresAt: now.Add(renderCacheTTL), CreatedAt: now}
+	if err := mongoRepo.PublishDataDownloadArtifact(ctx, artifact); err != nil {
+		_ = minioRepo.Delete(ctx, objectKey)
+		return nil, nil, err
+	}
+	zap.L().Debug("[DataDownload] 渲染完成并发布 artifact",
+		zap.String("documentId", document.ID), zap.String("format", format),
+		zap.Int64("generation", state.ContentGeneration), zap.Int("bytes", len(data)))
+	return artifact, data, nil
+}
+
+type dataDownloadContentReader struct {
+	io.ReadCloser
+	lock   *cache.RedisLock
+	cancel context.CancelFunc
+	done   chan struct{}
+}
+
+func newDataDownloadContentReader(reader io.ReadCloser, lock *cache.RedisLock) *dataDownloadContentReader {
+	ctx, cancel := context.WithCancel(context.Background())
+	result := &dataDownloadContentReader{ReadCloser: reader, lock: lock, cancel: cancel, done: make(chan struct{})}
+	go func() {
+		defer close(result.done)
+		ticker := time.NewTicker(3 * time.Minute)
+		defer ticker.Stop()
+		for {
+			select {
+			case <-ctx.Done():
+				return
+			case <-ticker.C:
+				renewCtx, renewCancel := context.WithTimeout(context.Background(), 10*time.Second)
+				_ = lock.Extend(renewCtx, 10*time.Minute)
+				renewCancel()
+			}
+		}
+	}()
+	return result
+}
+
+func (r *dataDownloadContentReader) Close() error {
+	r.cancel()
+	<-r.done
+	err := r.ReadCloser.Close()
+	r.lock.SafeRelease(context.Background())
+	return err
+}
+
+func readDataDownloadArtifact(ctx context.Context, artifact *domain.DataDownloadArtifact) ([]byte, error) {
+	reader, err := minioRepo.Open(ctx, artifact.ObjectKey)
+	if err != nil {
+		return nil, err
+	}
+	defer reader.Close()
+	return io.ReadAll(reader)
+}
+
+func currentArtifact(ctx context.Context, document *domain.DataDownloadDocument, format string) (*domain.DataDownloadArtifact, error) {
+	slot, err := mongoRepo.GetDataDownloadRenderSlot(ctx, document.ID, format)
+	if err != nil {
+		return nil, err
+	}
+	if slot.ArtifactID == nil || slot.ContentGeneration != document.ContentGeneration {
+		return nil, mongo.ErrNoDocuments
+	}
+	artifact, err := mongoRepo.GetDataDownloadArtifact(ctx, *slot.ArtifactID)
+	if err != nil {
+		return nil, err
+	}
+	if !artifact.ExpiresAt.After(time.Now()) {
+		return nil, mongo.ErrNoDocuments
+	}
+	return artifact, nil
+}
+
+func supportedRenderFormat(kind domain.DataDownloadKind, format string) bool {
+	switch kind {
+	case domain.DataDownloadPowerFlow:
+		return format == "html" || format == "json"
+	case domain.DataDownloadMqttLog, domain.DataDownloadEventAlarm:
+		return format == "xlsx" || format == "csv" || format == "json"
+	case domain.DataDownloadUpdateLog:
+		return format == "xlsx" || format == "json"
+	default:
+		return false
+	}
+}
+
+func encodeRenderedRows(document *domain.DataDownloadDocument, rows []json.RawMessage, format string) ([]byte, string, error) {
+	switch format {
+	case "json":
+		data, err := json.MarshalIndent(rows, "", "  ")
+		return data, "application/json", err
+	case "csv":
+		var out bytes.Buffer
+		writer := csv.NewWriter(&out)
+		headers, table := dataDownloadTable(document.Kind, rows)
+		_ = writer.Write(headers)
+		for _, row := range table {
+			_ = writer.Write(row)
+		}
+		writer.Flush()
+		return out.Bytes(), "text/csv; charset=utf-8", writer.Error()
+	case "html":
+		var out strings.Builder
+		out.WriteString("<!doctype html><html><head><meta charset=\"utf-8\"><title>PowerFlow</title></head><body><h1>PowerFlow ")
+		out.WriteString(html.EscapeString(document.SiteSN + " " + document.Date))
+		out.WriteString("</h1><pre>")
+		pretty, _ := json.MarshalIndent(rows, "", "  ")
+		out.WriteString(html.EscapeString(string(pretty)))
+		out.WriteString("</pre></body></html>")
+		return []byte(out.String()), "text/html; charset=utf-8", nil
+	case "xlsx":
+		file := excelize.NewFile()
+		sheet := file.GetSheetName(0)
+		headers, table := dataDownloadTable(document.Kind, rows)
+		for column, header := range headers {
+			cell, _ := excelize.CoordinatesToCellName(column+1, 1)
+			_ = file.SetCellValue(sheet, cell, header)
+		}
+		for rowIndex, row := range table {
+			for column, value := range row {
+				cell, _ := excelize.CoordinatesToCellName(column+1, rowIndex+2)
+				_ = file.SetCellValue(sheet, cell, value)
+			}
+		}
+		buffer, err := file.WriteToBuffer()
+		_ = file.Close()
+		if err != nil {
+			return nil, "", err
+		}
+		return buffer.Bytes(), "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", nil
+	default:
+		return nil, "", fmt.Errorf("unsupported format")
+	}
+}
+
+func dataDownloadTable(kind domain.DataDownloadKind, rows []json.RawMessage) ([]string, [][]string) {
+	fields := []string{"payload"}
+	headers := []string{"Payload"}
+	switch kind {
+	case domain.DataDownloadMqttLog:
+		fields, headers = []string{"cmdType", "logTime", "logData"}, []string{"Type", "Time", "Data"}
+	case domain.DataDownloadEventAlarm:
+		fields = []string{"id", "logTime", "logType", "alarmCode", "logName", "startTime", "endTime", "logData"}
+		headers = []string{"ID", "Time", "Type", "AlarmCode", "Name", "StartTime", "EndTime", "Data"}
+	}
+	table := make([][]string, 0, len(rows))
+	for index, raw := range rows {
+		if len(fields) == 1 {
+			table = append(table, []string{string(raw)})
+			continue
+		}
+		var object map[string]any
+		if err := json.Unmarshal(raw, &object); err != nil {
+			table = append(table, []string{string(raw)})
+			continue
+		}
+		row := make([]string, len(fields))
+		for column, field := range fields {
+			value := object[field]
+			if value == nil && kind == domain.DataDownloadEventAlarm && field == "id" {
+				value = index + 1
+			}
+			if value == nil && kind == domain.DataDownloadEventAlarm && field == "endTime" {
+				row[column] = "null"
+				continue
+			}
+			switch typed := value.(type) {
+			case string:
+				row[column] = typed
+			case nil:
+				row[column] = ""
+			default:
+				encoded, _ := json.Marshal(typed)
+				row[column] = string(encoded)
+			}
+		}
+		table = append(table, row)
+	}
+	return headers, table
+}
+
+func renderedFilename(document *domain.DataDownloadDocument, format string) string {
+	parts := []string{document.SiteSN, string(document.Kind)}
+	if document.Date != "" {
+		parts = append(parts, document.Date)
+	}
+	if document.Subtype != "" {
+		parts = append(parts, document.Subtype)
+	}
+	return strings.Join(parts, "_") + "." + format
+}

+ 128 - 0
service/services/dataDownloadRequestKey.go

@@ -0,0 +1,128 @@
+package services
+
+import (
+	"fmt"
+	"net/url"
+	"strings"
+	"time"
+
+	"fwh-toolbox-server/domain"
+)
+
+// Each document kind owns its content identity version. Bump only the affected
+// kind when its query, parsing, or stored content semantics change.
+var dataDownloadContentIdentityVersion = map[domain.DataDownloadKind]string{
+	domain.DataDownloadPowerFlow:     "v1",
+	domain.DataDownloadMqttLog:       "v1",
+	domain.DataDownloadEventAlarm:    "v1",
+	domain.DataDownloadAlarmSnapshot: "v1",
+	domain.DataDownloadEmsLog:        "v1",
+	domain.DataDownloadFaultRecord:   "v2",
+	domain.DataDownloadUpdateLog:     "v1",
+}
+
+type dataDownloadRequestKeyField struct {
+	name  string
+	value string
+}
+
+// buildDataDownloadRequestKey identifies the requested logical document. The
+// fields intentionally differ by kind so task-only or irrelevant parameters do
+// not reduce reuse between otherwise equivalent requests.
+func buildDataDownloadRequestKey(item *domain.DataDownloadItem) (string, error) {
+	if item == nil {
+		return "", fmt.Errorf("data download item is nil")
+	}
+	contentVersion, ok := dataDownloadContentIdentityVersion[item.Kind]
+	if !ok {
+		return "", fmt.Errorf("unsupported data download kind %q", item.Kind)
+	}
+	if item.SiteSN == "" {
+		return "", fmt.Errorf("site SN is required for %s", item.Kind)
+	}
+
+	fields := []dataDownloadRequestKeyField{
+		{name: "kind", value: string(item.Kind)},
+		{name: "content_version", value: contentVersion},
+	}
+	addSite := func() {
+		fields = append(fields, dataDownloadRequestKeyField{name: "site_sn", value: item.SiteSN})
+	}
+	addDate := func() error {
+		if _, err := time.Parse(time.DateOnly, item.Date); err != nil {
+			return fmt.Errorf("invalid data date %q for %s", item.Date, item.Kind)
+		}
+		fields = append(fields, dataDownloadRequestKeyField{name: "date", value: item.Date})
+		return nil
+	}
+	addSubtype := func() error {
+		if item.Subtype == "" {
+			return fmt.Errorf("subtype is required for %s", item.Kind)
+		}
+		fields = append(fields, dataDownloadRequestKeyField{name: "subtype", value: item.Subtype})
+		return nil
+	}
+	addTimezone := func() error {
+		if item.Timezone == "" {
+			return fmt.Errorf("timezone is required for %s", item.Kind)
+		}
+		fields = append(fields, dataDownloadRequestKeyField{name: "timezone", value: item.Timezone})
+		return nil
+	}
+
+	switch item.Kind {
+	case domain.DataDownloadPowerFlow, domain.DataDownloadEventAlarm, domain.DataDownloadAlarmSnapshot:
+		addSite()
+		if err := addDate(); err != nil {
+			return "", err
+		}
+		if err := addTimezone(); err != nil {
+			return "", err
+		}
+	case domain.DataDownloadMqttLog:
+		addSite()
+		if err := addDate(); err != nil {
+			return "", err
+		}
+		if err := addSubtype(); err != nil {
+			return "", err
+		}
+		if err := addTimezone(); err != nil {
+			return "", err
+		}
+	case domain.DataDownloadEmsLog:
+		addSite()
+		if err := addDate(); err != nil {
+			return "", err
+		}
+		if err := addSubtype(); err != nil {
+			return "", err
+		}
+	case domain.DataDownloadFaultRecord:
+		if item.DeviceSN == "" || item.DeviceType == "" {
+			return "", fmt.Errorf("device SN and device type are required for %s", item.Kind)
+		}
+		fields = append(fields,
+			dataDownloadRequestKeyField{name: "device_sn", value: item.DeviceSN},
+			dataDownloadRequestKeyField{name: "device_type", value: item.DeviceType},
+		)
+		if err := addDate(); err != nil {
+			return "", err
+		}
+		if err := addTimezone(); err != nil {
+			return "", err
+		}
+	case domain.DataDownloadUpdateLog:
+		addSite()
+	}
+
+	var key strings.Builder
+	key.WriteString("dd-request-v1")
+	for _, field := range fields {
+		key.WriteByte('|')
+		key.WriteString(field.name)
+		key.WriteByte('=')
+		key.WriteString(url.QueryEscape(field.value))
+	}
+	return key.String(), nil
+}

+ 432 - 0
service/services/dataDownloadService.go

@@ -0,0 +1,432 @@
+package services
+
+import (
+	"context"
+	"fmt"
+	"math"
+	"sort"
+	"strconv"
+	"strings"
+	"time"
+
+	"fwh-toolbox-server/cache"
+	"fwh-toolbox-server/domain"
+	"fwh-toolbox-server/dto"
+	"fwh-toolbox-server/repositories"
+	mongoRepo "fwh-toolbox-server/repositories/mongo"
+	pgsqlRepo "fwh-toolbox-server/repositories/pgsql"
+
+	"go.mongodb.org/mongo-driver/bson/primitive"
+	"go.uber.org/zap"
+)
+
+const dataDownloadMaxDays = 600
+
+func CreateDataDownloadTask(ctx context.Context, task *domain.DataDownloadTask) error {
+	if len(task.SNs) == 0 || task.Options.Empty() {
+		return fmt.Errorf("at least one SN and one data type are required")
+	}
+	seen := map[string]struct{}{}
+	normalized := make([]string, 0, len(task.SNs))
+	for _, raw := range task.SNs {
+		sn := strings.TrimSpace(raw)
+		if sn == "" {
+			continue
+		}
+		if _, exists := seen[sn]; !exists {
+			seen[sn] = struct{}{}
+			normalized = append(normalized, sn)
+		}
+	}
+	if len(normalized) == 0 {
+		return fmt.Errorf("at least one valid SN is required")
+	}
+	task.SNs = normalized
+	if hasDateBasedOption(task.Options) {
+		start, err := time.Parse(time.DateOnly, task.StartDate)
+		if err != nil {
+			return fmt.Errorf("invalid startDate")
+		}
+		end, err := time.Parse(time.DateOnly, task.EndDate)
+		if err != nil {
+			return fmt.Errorf("invalid endDate")
+		}
+		if end.Before(start) {
+			return fmt.Errorf("endDate must not be before startDate")
+		}
+		if int(end.Sub(start).Hours()/24)+1 > dataDownloadMaxDays {
+			return fmt.Errorf("date range must not exceed %d days", dataDownloadMaxDays)
+		}
+	}
+	now := time.Now().UTC()
+	task.ID = primitive.NewObjectID()
+	task.Status = domain.DataDownloadPending
+	task.CreatedAt, task.UpdatedAt = now, now
+	return mongoRepo.InsertDataDownloadTask(ctx, task)
+}
+
+func hasDateBasedOption(o domain.DataDownloadOptions) bool {
+	return o.PowerFlow || len(o.MqttLogs) > 0 || o.EventAlarm || o.AlarmSnapshot || len(o.EmsLogs) > 0 || o.FaultRecord
+}
+
+func PlanDataDownloadTask(ctx context.Context, task *domain.DataDownloadTask) ([]domain.DataDownloadItem, error) {
+	var start, end time.Time
+	var err error
+	if hasDateBasedOption(task.Options) {
+		start, err = time.Parse(time.DateOnly, task.StartDate)
+		if err != nil {
+			return nil, err
+		}
+		end, err = time.Parse(time.DateOnly, task.EndDate)
+		if err != nil {
+			return nil, err
+		}
+	}
+	now := time.Now().UTC()
+	items := []domain.DataDownloadItem{}
+	for _, sn := range task.SNs {
+		response, err := repositories.QuerySiteInfoList(dto.SiteInfoPara{SN: &sn, PageSize: 20, PageNum: 1})
+		if err != nil {
+			zap.L().Warn("[DataDownload] 规划:站点信息查询失败",
+				zap.String("taskId", task.ID.Hex()), zap.String("sn", sn), zap.String("err", err.Error()))
+			item := newDDItem(task.ID, "site_validation", sn, "", "", "", now)
+			item.Status, item.Stage, item.FailureReason = domain.DataDownloadFailed, "validation", err.Error()
+			items = append(items, item)
+			continue
+		}
+		var site *domain.FwhSiteInfo
+		if response != nil && response.Result != nil {
+			for i := range *response.Result {
+				if (*response.Result)[i].Id == sn {
+					site = &(*response.Result)[i]
+					break
+				}
+			}
+		}
+		if site == nil {
+			item := newDDItem(task.ID, "site_validation", sn, "", "", "", now)
+			item.Status, item.Stage, item.FailureReason = domain.DataDownloadFailed, "validation", "site does not exist"
+			items = append(items, item)
+			continue
+		}
+		zone, loc := siteLocation(site)
+		var faultDevices []dataDownloadFaultDevice
+		var faultDevicesErr error
+		if task.Options.FaultRecord {
+			faultDevices, faultDevicesErr = discoverDataDownloadFaultDevices(sn)
+			if faultDevicesErr != nil {
+				item := newDDItem(task.ID, domain.DataDownloadFaultRecord, sn, "", "device_discovery", zone, now)
+				item.Status, item.Stage, item.FailureReason = domain.DataDownloadFailed, "planning", faultDevicesErr.Error()
+				items = append(items, item)
+			}
+		}
+		if hasDateBasedOption(task.Options) {
+			localToday := now.In(loc).Format(time.DateOnly)
+			if task.EndDate > localToday {
+				return nil, fmt.Errorf("future date is not allowed for site %s (%s)", sn, zone)
+			}
+			for date := start; !date.After(end); date = date.AddDate(0, 0, 1) {
+				dateText := date.Format(time.DateOnly)
+				if task.Options.PowerFlow {
+					items = append(items, newDDItem(task.ID, domain.DataDownloadPowerFlow, sn, dateText, "", zone, now))
+				}
+				for _, subtype := range task.Options.MqttLogs {
+					items = append(items, newDDItem(task.ID, domain.DataDownloadMqttLog, sn, dateText, subtype, zone, now))
+				}
+				if task.Options.EventAlarm {
+					items = append(items, newDDItem(task.ID, domain.DataDownloadEventAlarm, sn, dateText, "", zone, now))
+				}
+				if task.Options.AlarmSnapshot {
+					items = append(items, newDDItem(task.ID, domain.DataDownloadAlarmSnapshot, sn, dateText, "", zone, now))
+				}
+				for _, subtype := range task.Options.EmsLogs {
+					items = append(items, newDDItem(task.ID, domain.DataDownloadEmsLog, sn, dateText, subtype, zone, now))
+				}
+				if task.Options.FaultRecord {
+					for _, device := range faultDevices {
+						items = append(items, newDDFaultRecordItem(task.ID, sn, device.SN, device.Type, dateText, zone, now))
+					}
+				}
+			}
+		}
+		if task.Options.UpdateLog {
+			items = append(items, newDDItem(task.ID, domain.DataDownloadUpdateLog, sn, "", "", zone, now))
+		}
+	}
+	return items, nil
+}
+
+type dataDownloadFaultDevice struct {
+	SN   string
+	Type int
+}
+
+func discoverDataDownloadFaultDevices(siteSN string) ([]dataDownloadFaultDevice, error) {
+	devices := map[string]dataDownloadFaultDevice{}
+	add := func(sn string, deviceType int) {
+		sn = strings.TrimSpace(sn)
+		if sn != "" {
+			devices[fmt.Sprintf("%d|%s", deviceType, sn)] = dataDownloadFaultDevice{SN: sn, Type: deviceType}
+		}
+	}
+	add(siteSN, 1)
+
+	base, err := repositories.QuerySiteBaseInfoList(dto.SiteBaseInfoPara{SiteList: []string{siteSN}, PageSize: 20, PageNum: 1})
+	if err != nil {
+		return sortedDataDownloadFaultDevices(devices), fmt.Errorf("discover fault-record devices for %s: %w", siteSN, err)
+	}
+	if base != nil && base.Result != nil && len(*base.Result) > 0 {
+		for _, sn := range strings.Split((*base.Result)[0].FhpSn, ",") {
+			add(sn, 0)
+		}
+	}
+
+	return sortedDataDownloadFaultDevices(devices), nil
+}
+
+func sortedDataDownloadFaultDevices(devices map[string]dataDownloadFaultDevice) []dataDownloadFaultDevice {
+	result := make([]dataDownloadFaultDevice, 0, len(devices))
+	for _, device := range devices {
+		result = append(result, device)
+	}
+	sort.Slice(result, func(i, j int) bool {
+		if result[i].Type == result[j].Type {
+			return result[i].SN < result[j].SN
+		}
+		return result[i].Type < result[j].Type
+	})
+	return result
+}
+
+func dataDownloadFaultRecordDeviceSN(record domain.FaultRecordInfo) (string, error) {
+	switch record.DeviceType {
+	case 0, 2:
+		if record.ApowerId != nil && strings.TrimSpace(*record.ApowerId) != "" {
+			return strings.TrimSpace(*record.ApowerId), nil
+		}
+	case 1:
+		if strings.TrimSpace(record.GatewayId) != "" {
+			return strings.TrimSpace(record.GatewayId), nil
+		}
+	case 3:
+		if record.PowerlinkId != nil && strings.TrimSpace(*record.PowerlinkId) != "" {
+			return strings.TrimSpace(*record.PowerlinkId), nil
+		}
+	default:
+		return "", fmt.Errorf("unsupported fault-record device type %d", record.DeviceType)
+	}
+	return "", fmt.Errorf("fault-record device SN is missing for type %d record %d", record.DeviceType, record.Id)
+}
+
+func siteLocation(site *domain.FwhSiteInfo) (string, *time.Location) {
+	if site.ZoneInfo != "" {
+		if loc, err := time.LoadLocation(site.ZoneInfo); err == nil {
+			return site.ZoneInfo, loc
+		}
+	}
+	offset := int(math.Round(float64(site.TimeZone) * 3600))
+	name := fmt.Sprintf("UTC%+.2f", site.TimeZone)
+	return name, time.FixedZone(name, offset)
+}
+
+func dataDownloadLocation(name string) *time.Location {
+	if loc, err := time.LoadLocation(name); err == nil {
+		return loc
+	}
+	if strings.HasPrefix(name, "UTC") {
+		if hours, err := strconv.ParseFloat(strings.TrimPrefix(name, "UTC"), 64); err == nil {
+			return time.FixedZone(name, int(math.Round(hours*3600)))
+		}
+	}
+	return time.UTC
+}
+
+func newDDItem(taskID primitive.ObjectID, kind domain.DataDownloadKind, sn, date, subtype, zone string, now time.Time) domain.DataDownloadItem {
+	key := strings.Join([]string{string(kind), sn, date, subtype}, "|")
+	return domain.DataDownloadItem{ID: primitive.NewObjectID(), TaskID: taskID, ItemKey: key, Kind: kind, SiteSN: sn, Date: date, Subtype: subtype, Timezone: zone, Status: domain.DataDownloadPending, CreatedAt: now, UpdatedAt: now}
+}
+
+func newDDFaultRecordItem(taskID primitive.ObjectID, siteSN, deviceSN string, deviceType int, date, zone string, now time.Time) domain.DataDownloadItem {
+	item := newDDItem(taskID, domain.DataDownloadFaultRecord, siteSN, date, "", zone, now)
+	item.DeviceSN = deviceSN
+	item.DeviceType = strconv.Itoa(deviceType)
+	item.ItemKey = strings.Join([]string{string(item.Kind), siteSN, deviceSN, item.DeviceType, date}, "|")
+	return item
+}
+
+func ExecuteStructuredDataDownloadItem(ctx context.Context, item *domain.DataDownloadItem, awaitRequestIDs ...string) error {
+	if item.Kind == domain.DataDownloadAlarmSnapshot || item.Kind == domain.DataDownloadEmsLog || item.Kind == domain.DataDownloadFaultRecord {
+		return ExecuteBinaryDataDownloadItem(ctx, item, awaitRequestIDs...)
+	}
+	awaitRequestID := ""
+	if len(awaitRequestIDs) > 0 {
+		awaitRequestID = awaitRequestIDs[0]
+	}
+	ready, lease, err := prepareDataDownloadRequest(ctx, item, awaitRequestID)
+	if err != nil {
+		return err
+	}
+	if ready != nil {
+		return linkDataDownloadItem(ctx, item, ready, ready.Empty)
+	}
+	defer lease.abort()
+	requestKey := lease.requestKey
+	documentID := lease.documentID
+
+	loc := dataDownloadLocation(item.Timezone)
+	var start, end time.Time
+	if item.Date != "" {
+		start, err = time.ParseInLocation(time.DateOnly, item.Date, loc)
+		if err != nil {
+			return err
+		}
+		end = start.AddDate(0, 0, 1).Add(-time.Nanosecond)
+	}
+	// 传给上游日志查询的时间必须是「不含时区」的字面时间(按 UTC 表示)。
+	// QueryMqttLogListOnTimeRange / QueryEventOrAlarmListOnTimeRange 内部按天拆分时使用
+	// time.Truncate(24h)(按 UTC 零点对齐),若传入带非 UTC 时区(如 America/Chicago)的时间,
+	// 游标推进会回退到前一天,导致 periods 切片无限增长并 OOM。这里剥离时区、只保留字面日期。
+	// 注意:start/end(本地时区)仍用于后面的 Frozen(D+2)判断,不能改动它们本身。
+	startQuery := stripTimeLocation(start)
+	endQuery := stripTimeLocation(end)
+	fetchedAt := time.Now().UTC()
+	rows := []pgsqlRepo.DataDownloadRow{}
+	switch item.Kind {
+	case domain.DataDownloadPowerFlow:
+		response, err := repositories.QuerySitePowerFlow(item.SiteSN, start)
+		if err != nil {
+			zap.L().Warn("[DataDownload] PowerFlow 上游查询失败", zap.String("sn", item.SiteSN), zap.String("date", item.Date), zap.String("err", err.Error()))
+			return err
+		}
+		if response.Result != nil {
+			rows = append(rows, pgsqlRepo.DataDownloadRow{Payload: response.Result})
+		}
+		zap.L().Debug("[DataDownload] PowerFlow 上游返回", zap.String("sn", item.SiteSN), zap.String("date", item.Date), zap.Bool("hasData", response.Result != nil))
+	case domain.DataDownloadMqttLog:
+		values, err := repositories.QueryMqttLogListOnTimeRange(item.SiteSN, domain.MqttCmdType(item.Subtype), startQuery, endQuery)
+		if err != nil {
+			zap.L().Warn("[DataDownload] MqttLog 上游查询失败", zap.String("sn", item.SiteSN), zap.String("subtype", item.Subtype), zap.String("date", item.Date), zap.String("err", err.Error()))
+			return err
+		}
+		for i := range values {
+			occurred := parseRemoteTime(values[i].Time)
+			rows = append(rows, pgsqlRepo.DataDownloadRow{OccurredAt: occurred, Subtype: item.Subtype, Payload: values[i]})
+		}
+		zap.L().Debug("[DataDownload] MqttLog 上游返回", zap.String("sn", item.SiteSN), zap.String("subtype", item.Subtype), zap.String("date", item.Date), zap.Int("rows", len(values)))
+	case domain.DataDownloadEventAlarm:
+		values, err := repositories.QueryEventOrAlarmListOnTimeRange(item.SiteSN, startQuery, endQuery, repositories.EventAndAlarm)
+		if err != nil {
+			zap.L().Warn("[DataDownload] EventAlarm 上游查询失败", zap.String("sn", item.SiteSN), zap.String("date", item.Date), zap.String("err", err.Error()))
+			return err
+		}
+		for i := range values {
+			occurred := parseRemoteTime(values[i].Time)
+			rows = append(rows, pgsqlRepo.DataDownloadRow{OccurredAt: occurred, Payload: values[i]})
+		}
+		zap.L().Debug("[DataDownload] EventAlarm 上游返回", zap.String("sn", item.SiteSN), zap.String("date", item.Date), zap.Int("rows", len(values)))
+	case domain.DataDownloadUpdateLog:
+		system, err := repositories.QuerySystemUpdateLog(item.SiteSN)
+		if err != nil {
+			zap.L().Warn("[DataDownload] UpdateLog(system) 上游查询失败", zap.String("sn", item.SiteSN), zap.String("err", err.Error()))
+			return err
+		}
+		for i := range system {
+			occurred := parseRemoteTime(system[i].UpgradeTime)
+			rows = append(rows, pgsqlRepo.DataDownloadRow{OccurredAt: occurred, DeviceSN: item.SiteSN, DeviceType: "gateway", Payload: system[i]})
+			details, detailErr := repositories.QuerySystemUpdateLogDetail(system[i].Id, item.SiteSN)
+			if detailErr != nil {
+				zap.L().Warn("[DataDownload] UpdateLog(detail) 上游查询失败", zap.String("sn", item.SiteSN), zap.Int64("orderId", system[i].Id), zap.String("err", detailErr.Error()))
+				return detailErr
+			}
+			for j := range details {
+				rows = append(rows, pgsqlRepo.DataDownloadRow{DeviceSN: details[j].GatewayId, DeviceType: "system_detail", Payload: details[j]})
+			}
+		}
+		ahub, err := repositories.QueryAHubUpdateLog(item.SiteSN)
+		if err != nil {
+			zap.L().Warn("[DataDownload] UpdateLog(ahub) 上游查询失败", zap.String("sn", item.SiteSN), zap.String("err", err.Error()))
+			return err
+		}
+		for i := range ahub {
+			rows = append(rows, pgsqlRepo.DataDownloadRow{DeviceSN: ahub[i].AHubSn, DeviceType: "ahub", Payload: ahub[i]})
+		}
+		zap.L().Debug("[DataDownload] UpdateLog 上游返回", zap.String("sn", item.SiteSN), zap.Int("systemOrders", len(system)), zap.Int("ahub", len(ahub)), zap.Int("totalRows", len(rows)))
+	default:
+		return fmt.Errorf("binary kind %s requires its dedicated collector", item.Kind)
+	}
+	contentLock, err := cache.Lock(ctx, "data-download:content:"+documentID, 10*time.Minute, 200*time.Millisecond, 30*time.Second)
+	if err != nil {
+		return err
+	}
+	zap.L().Debug("[DataDownload] 结构化 fetch 完成,准备提交",
+		zap.String("documentId", documentID),
+		zap.String("kind", string(item.Kind)),
+		zap.String("sn", item.SiteSN),
+		zap.String("date", item.Date),
+		zap.Int("rowCount", len(rows)),
+	)
+	defer contentLock.SafeRelease(context.Background())
+	if err := lease.validate(ctx); err != nil {
+		return err
+	}
+	// DD-002 方向 A:先失效旧渲染缓存,再提交 PG 权威内容,最后回填 Mongo Document。
+	// 断开 RenderSlot 后 currentArtifact 会因 slot.ArtifactID==nil 判定 miss,从而强制
+	// 后续读者从 PG 的最新 generation 重新渲染。这样即使在「PG 已提交、Document 尚未回填」
+	// 之间崩溃,读取也只会 miss 重渲染(读到 PG 的新代,正确),而不会命中指向旧代的缓存。
+	if err := mongoRepo.DetachDataDownloadRenderSlots(ctx, documentID); err != nil {
+		return err
+	}
+	state, err := pgsqlRepo.ReplaceDataDownloadRows(documentID, item.Kind, item.SiteSN, item.Date, item.Timezone, rows, fetchedAt)
+	if err != nil {
+		return err
+	}
+	documentState := domain.DataDownloadMutable
+	var frozenAt *time.Time
+	if item.Kind == domain.DataDownloadUpdateLog {
+		documentState = domain.DataDownloadDynamic
+	} else if !start.IsZero() && !time.Now().In(loc).Before(start.AddDate(0, 0, 2)) {
+		now := time.Now().UTC()
+		frozenAt = &now
+		documentState = domain.DataDownloadFrozen
+	}
+	createdAt := fetchedAt
+	if existing, err := mongoRepo.GetDataDownloadDocument(ctx, documentID); err == nil {
+		createdAt = existing.CreatedAt
+	}
+	document := &domain.DataDownloadDocument{ID: documentID, RequestKey: requestKey, Kind: item.Kind, SiteSN: item.SiteSN, DeviceSN: item.DeviceSN, Date: item.Date, Subtype: item.Subtype, DeviceType: item.DeviceType, Timezone: item.Timezone, State: documentState, Empty: len(rows) == 0, ContentGeneration: state.ContentGeneration, Checksum: state.Checksum, SourceFetchedAt: fetchedAt, FrozenAt: frozenAt, CreatedAt: createdAt, UpdatedAt: fetchedAt}
+	if err := mongoRepo.UpsertDataDownloadDocument(ctx, document); err != nil {
+		return err
+	}
+	zap.L().Debug("[DataDownload] 结构化内容已提交",
+		zap.String("documentId", documentID),
+		zap.String("kind", string(item.Kind)),
+		zap.String("state", string(documentState)),
+		zap.Int64("generation", state.ContentGeneration),
+		zap.Bool("empty", len(rows) == 0),
+	)
+	if err := lease.complete(ctx); err != nil {
+		return err
+	}
+	return linkDataDownloadItem(ctx, item, document, len(rows) == 0)
+}
+
+// stripTimeLocation 把时间按字面 年/月/日/时/分/秒/纳秒 原样重建为 UTC 时间。
+// 用途:传给上游日志查询(QueryMqttLogListOnTimeRange / QueryEventOrAlarmListOnTimeRange)前,
+// 剥离本地时区。上游按天拆分时用 time.Truncate(24h)(按 UTC 零点对齐),若传入带非 UTC 时区
+// 的时间,游标推进会回退到前一天导致 periods 无限增长并 OOM。剥离后与 BatchDownload 行为一致,
+// 而查询仅把时间 Format 成字符串,字面值不变、查询语义不变。
+func stripTimeLocation(t time.Time) time.Time {
+	if t.IsZero() {
+		return t
+	}
+	return time.Date(t.Year(), t.Month(), t.Day(), t.Hour(), t.Minute(), t.Second(), t.Nanosecond(), time.UTC)
+}
+
+func parseRemoteTime(value string) *time.Time {
+	for _, layout := range []string{time.RFC3339Nano, time.DateTime} {
+		if parsed, err := time.Parse(layout, value); err == nil {
+			return &parsed
+		}
+	}
+	return nil
+}

+ 271 - 0
service/services/dataDownloadService_test.go

@@ -0,0 +1,271 @@
+package services
+
+import (
+	"encoding/json"
+	"strings"
+	"testing"
+	"time"
+
+	"fwh-toolbox-server/domain"
+	"fwh-toolbox-server/dto"
+	"fwh-toolbox-server/repositories"
+
+	"go.mongodb.org/mongo-driver/bson/primitive"
+)
+
+func TestDiscoverFaultDevicesUsesCurrentScope(t *testing.T) {
+	original := repositories.QuerySiteBaseInfoList
+	t.Cleanup(func() { repositories.QuerySiteBaseInfoList = original })
+	repositories.QuerySiteBaseInfoList = func(_ dto.SiteBaseInfoPara) (*domain.FwhResponse[[]dto.SiteBaseInfo], error) {
+		devices := []dto.SiteBaseInfo{{Id: "GATE-1", FhpSn: "POWER-1,POWER-2"}}
+		return &domain.FwhResponse[[]dto.SiteBaseInfo]{Code: 200, Result: &devices}, nil
+	}
+	devices, err := discoverDataDownloadFaultDevices("GATE-1")
+	if err != nil {
+		t.Fatal(err)
+	}
+	if len(devices) != 3 || devices[0].Type != 0 || devices[1].Type != 0 || devices[2].Type != 1 {
+		t.Fatalf("unexpected fault devices: %+v", devices)
+	}
+}
+
+func TestDataDownloadLocationFixedOffset(t *testing.T) {
+	loc := dataDownloadLocation("UTC-07.50")
+	_, offset := time.Date(2026, 1, 1, 0, 0, 0, 0, loc).Zone()
+	if offset != -7*3600-30*60 {
+		t.Fatalf("offset = %d", offset)
+	}
+}
+
+func TestDataDownloadEventAlarmTable(t *testing.T) {
+	headers, rows := dataDownloadTable(domain.DataDownloadEventAlarm, []json.RawMessage{json.RawMessage(`{"id":null,"logTime":"2026-09-19 10:00:00","logType":"1","alarmCode":"A1","logName":"Alarm","startTime":"2026-09-19 09:59:00","endTime":null,"logData":"{}"}`)})
+	if len(headers) != 8 || headers[7] != "Data" {
+		t.Fatalf("unexpected headers: %#v", headers)
+	}
+	if rows[0][0] != "1" || rows[0][6] != "null" {
+		t.Fatalf("unexpected event row: %#v", rows[0])
+	}
+}
+
+func TestSupportedRenderFormats(t *testing.T) {
+	if !supportedRenderFormat(domain.DataDownloadPowerFlow, "html") {
+		t.Fatal("power flow HTML should be supported")
+	}
+	if supportedRenderFormat(domain.DataDownloadPowerFlow, "xlsx") {
+		t.Fatal("power flow XLSX should not be supported")
+	}
+	if !supportedRenderFormat(domain.DataDownloadMqttLog, "csv") {
+		t.Fatal("MQTT CSV should be supported")
+	}
+	if supportedRenderFormat(domain.DataDownloadUpdateLog, "csv") {
+		t.Fatal("update CSV should not be supported")
+	}
+}
+
+func TestCleanZipPath(t *testing.T) {
+	if got := cleanZipPath("../../outside"); got != "outside" {
+		t.Fatalf("unsafe path normalized to %q", got)
+	}
+}
+
+func TestBuildDataDownloadRequestKeyByKind(t *testing.T) {
+	tests := []struct {
+		name string
+		item domain.DataDownloadItem
+		want string
+	}{
+		{
+			name: "power flow includes timezone",
+			item: domain.DataDownloadItem{Kind: domain.DataDownloadPowerFlow, SiteSN: "SN001", Date: "2026-09-10", Timezone: "America/Los_Angeles"},
+			want: "dd-request-v1|kind=power_flow|content_version=v1|site_sn=SN001|date=2026-09-10|timezone=America%2FLos_Angeles",
+		},
+		{
+			name: "mqtt includes subtype and timezone",
+			item: domain.DataDownloadItem{Kind: domain.DataDownloadMqttLog, SiteSN: "SN001", Date: "2026-09-10", Subtype: "201", Timezone: "America/Los_Angeles"},
+			want: "dd-request-v1|kind=mqtt_log|content_version=v1|site_sn=SN001|date=2026-09-10|subtype=201|timezone=America%2FLos_Angeles",
+		},
+		{
+			name: "event alarm includes timezone",
+			item: domain.DataDownloadItem{Kind: domain.DataDownloadEventAlarm, SiteSN: "SN001", Date: "2026-09-10", Timezone: "America/Los_Angeles"},
+			want: "dd-request-v1|kind=event_alarm|content_version=v1|site_sn=SN001|date=2026-09-10|timezone=America%2FLos_Angeles",
+		},
+		{
+			name: "alarm snapshot includes timezone",
+			item: domain.DataDownloadItem{Kind: domain.DataDownloadAlarmSnapshot, SiteSN: "SN001", Date: "2026-09-10", Timezone: "America/Los_Angeles"},
+			want: "dd-request-v1|kind=alarm_snapshot|content_version=v1|site_sn=SN001|date=2026-09-10|timezone=America%2FLos_Angeles",
+		},
+		{
+			name: "EMS ignores timezone",
+			item: domain.DataDownloadItem{Kind: domain.DataDownloadEmsLog, SiteSN: "SN001", Date: "2026-09-10", Subtype: "2", Timezone: "America/Los_Angeles"},
+			want: "dd-request-v1|kind=ems_log|content_version=v1|site_sn=SN001|date=2026-09-10|subtype=2",
+		},
+		{
+			name: "device scoped fault record",
+			item: domain.DataDownloadItem{Kind: domain.DataDownloadFaultRecord, SiteSN: "SN001", DeviceSN: "DEV001", DeviceType: "aPower", Date: "2026-09-10", Timezone: "UTC-07.00"},
+			want: "dd-request-v1|kind=fault_record|content_version=v2|device_sn=DEV001|device_type=aPower|date=2026-09-10|timezone=UTC-07.00",
+		},
+		{
+			name: "update log ignores date subtype and timezone",
+			item: domain.DataDownloadItem{Kind: domain.DataDownloadUpdateLog, SiteSN: "SN001", Date: "2026-09-10", Subtype: "unused", Timezone: "America/Los_Angeles"},
+			want: "dd-request-v1|kind=update_log|content_version=v1|site_sn=SN001",
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			got, err := buildDataDownloadRequestKey(&tt.item)
+			if err != nil {
+				t.Fatal(err)
+			}
+			if got != tt.want {
+				t.Fatalf("request key = %q, want %q", got, tt.want)
+			}
+		})
+	}
+}
+
+func TestBuildDataDownloadRequestKeyIdentityStability(t *testing.T) {
+	base := domain.DataDownloadItem{
+		TaskID:   primitive.NewObjectID(),
+		ItemKey:  "task-item-a",
+		Kind:     domain.DataDownloadMqttLog,
+		SiteSN:   "SN|001",
+		Date:     "2026-09-10",
+		Subtype:  "cmd=201",
+		Timezone: "America/Los_Angeles",
+	}
+	want, err := buildDataDownloadRequestKey(&base)
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	changedTaskFields := base
+	changedTaskFields.TaskID = primitive.NewObjectID()
+	changedTaskFields.ItemKey = "task-item-b"
+	got, err := buildDataDownloadRequestKey(&changedTaskFields)
+	if err != nil {
+		t.Fatal(err)
+	}
+	if got != want {
+		t.Fatalf("task-only fields changed request identity: %q != %q", got, want)
+	}
+
+	changedContent := base
+	changedContent.Timezone = "Asia/Shanghai"
+	got, err = buildDataDownloadRequestKey(&changedContent)
+	if err != nil {
+		t.Fatal(err)
+	}
+	if got == want {
+		t.Fatal("content-affecting timezone did not change request identity")
+	}
+	if !strings.Contains(want, "site_sn=SN%7C001") || !strings.Contains(want, "subtype=cmd%3D201") {
+		t.Fatalf("request key fields were not safely escaped: %q", want)
+	}
+}
+
+func TestBuildDataDownloadRequestKeyRejectsInvalidIdentity(t *testing.T) {
+	tests := []domain.DataDownloadItem{
+		{Kind: "unknown", SiteSN: "SN001"},
+		{Kind: domain.DataDownloadMqttLog, SiteSN: "SN001", Date: "2026-09-10", Timezone: "UTC"},
+		{Kind: domain.DataDownloadPowerFlow, SiteSN: "SN001", Date: "not-a-date", Timezone: "UTC"},
+		{Kind: domain.DataDownloadFaultRecord, SiteSN: "SN001", DeviceSN: "DEV001", Date: "2026-09-10", Timezone: "UTC"},
+	}
+	for i := range tests {
+		if _, err := buildDataDownloadRequestKey(&tests[i]); err == nil {
+			t.Fatalf("case %d: expected invalid identity error", i)
+		}
+	}
+}
+
+func TestDataDownloadFaultRecordDeviceSN(t *testing.T) {
+	aPower, mppt, aHub := "AP-1", "MPPT-1", "HUB-1"
+	tests := []struct {
+		record domain.FaultRecordInfo
+		want   string
+	}{
+		{record: domain.FaultRecordInfo{DeviceType: 0, ApowerId: &aPower}, want: aPower},
+		{record: domain.FaultRecordInfo{DeviceType: 1, GatewayId: "GATE-1"}, want: "GATE-1"},
+		{record: domain.FaultRecordInfo{DeviceType: 2, ApowerId: &mppt}, want: mppt},
+		{record: domain.FaultRecordInfo{DeviceType: 3, PowerlinkId: &aHub}, want: aHub},
+	}
+	for _, tt := range tests {
+		got, err := dataDownloadFaultRecordDeviceSN(tt.record)
+		if err != nil {
+			t.Fatal(err)
+		}
+		if got != tt.want {
+			t.Fatalf("device SN = %q, want %q", got, tt.want)
+		}
+	}
+	if _, err := dataDownloadFaultRecordDeviceSN(domain.FaultRecordInfo{DeviceType: 4}); err == nil {
+		t.Fatal("future device types must require an explicit identity rule")
+	}
+}
+
+func TestFaultRecordDocumentStateDPlusTwo(t *testing.T) {
+	loc := time.FixedZone("UTC-7", -7*60*60)
+	day := time.Date(2026, 9, 10, 0, 0, 0, 0, loc)
+	before := time.Date(2026, 9, 11, 23, 59, 59, 0, loc)
+	atBoundary := time.Date(2026, 9, 12, 0, 0, 0, 0, loc)
+	if state, frozenAt := faultRecordDocumentState(before, loc, day, 0); state != domain.DataDownloadMutable || frozenAt != nil {
+		t.Fatalf("before D+2 = %s, %v", state, frozenAt)
+	}
+	if state, frozenAt := faultRecordDocumentState(atBoundary, loc, day, 0); state != domain.DataDownloadFrozen || frozenAt == nil {
+		t.Fatalf("at D+2 = %s, %v", state, frozenAt)
+	}
+	if state, frozenAt := faultRecordDocumentState(atBoundary, loc, day, 1); state != domain.DataDownloadMutable || frozenAt != nil {
+		t.Fatalf("failed D+2 request = %s, %v", state, frozenAt)
+	}
+}
+
+// TestStripTimeLocation 锁定 OOM 根因修复:把带时区的时间剥离为同字面值的 UTC。
+func TestStripTimeLocation(t *testing.T) {
+	chicago, err := time.LoadLocation("America/Chicago")
+	if err != nil {
+		t.Skipf("时区数据不可用: %v", err)
+	}
+
+	// 复现 DataDownload 的入参:本地时区解析出的当日起止。
+	start := time.Date(2026, 9, 19, 0, 0, 0, 0, chicago)
+	end := start.AddDate(0, 0, 1).Add(-time.Nanosecond)
+
+	startQuery := stripTimeLocation(start)
+	endQuery := stripTimeLocation(end)
+
+	// 剥离后必须为 UTC,且字面 年/月/日/时/分/秒 与原值一致。
+	if startQuery.Location() != time.UTC || endQuery.Location() != time.UTC {
+		t.Fatalf("剥离后应为 UTC: start=%v end=%v", startQuery.Location(), endQuery.Location())
+	}
+	if startQuery.Year() != 2026 || startQuery.Month() != 9 || startQuery.Day() != 19 ||
+		startQuery.Hour() != 0 || startQuery.Minute() != 0 || startQuery.Second() != 0 {
+		t.Fatalf("start 字面值被改变: %v", startQuery)
+	}
+
+	// 关键回归点:用剥离后的 UTC 起点走上游同款两步游标推进(Truncate 按 UTC 对齐 + time.Date 归零),
+	// 下一天游标必须严格前进到 09-20,而不是回退到当天导致死循环。
+	dayEnd := time.Date(startQuery.Year(), startQuery.Month(), startQuery.Day(), 23, 59, 59, 0, startQuery.Location())
+	truncated := dayEnd.Add(time.Second).Truncate(24 * time.Hour)
+	next := time.Date(truncated.Year(), truncated.Month(), truncated.Day(), 0, 0, 0, 0, truncated.Location())
+	if !next.After(startQuery) {
+		t.Fatalf("游标未前进(会死循环): start=%v next=%v", startQuery, next)
+	}
+	if next.Year() != 2026 || next.Month() != 9 || next.Day() != 20 {
+		t.Fatalf("游标应前进到 09-20,实际=%v", next)
+	}
+
+	// 反证:若不剥离时区(直接用 Chicago 时间),上游完整两步推进会把游标拉回当天 00:00 → 死循环。
+	// 第一步 Truncate(24h) 按 UTC 对齐后落在当天 19:00(-0500),第二步 time.Date(..,0,0,0,loc)
+	// 归零到当天 00:00,游标回退、未越过当天,currTime.Before(endTime) 恒成立。
+	badDayEnd := time.Date(start.Year(), start.Month(), start.Day(), 23, 59, 59, 0, start.Location())
+	badTruncated := badDayEnd.Add(time.Second).Truncate(24 * time.Hour)
+	badNext := time.Date(badTruncated.Year(), badTruncated.Month(), badTruncated.Day(), 0, 0, 0, 0, badTruncated.Location())
+	if badNext.After(start) {
+		t.Fatalf("预期未剥离时区会回退到当天起点,但游标前进了: start=%v badNext=%v", start, badNext)
+	}
+
+	// 零值需原样返回。
+	if got := stripTimeLocation(time.Time{}); !got.IsZero() {
+		t.Fatalf("零值应原样返回: %v", got)
+	}
+}

+ 478 - 0
service/task/queue/dataDownloadQueue/dataDownloadQueue.go

@@ -0,0 +1,478 @@
+package dataDownloadQueue
+
+import (
+	"context"
+	"encoding/json"
+	"errors"
+	"fmt"
+	"time"
+
+	"fwh-toolbox-server/domain"
+	"fwh-toolbox-server/jobs"
+	minioRepo "fwh-toolbox-server/repositories/minio"
+	mongoRepo "fwh-toolbox-server/repositories/mongo"
+	"fwh-toolbox-server/services"
+	task_core "fwh-toolbox-server/task/core"
+
+	"github.com/hibiken/asynq"
+	"go.mongodb.org/mongo-driver/bson"
+	"go.mongodb.org/mongo-driver/bson/primitive"
+	"go.uber.org/zap"
+)
+
+const (
+	TaskTypePlan    = "data-download:plan"
+	TaskTypeItem    = "data-download:item"
+	TaskTypePackage = "data-download:package"
+	QueuePlan       = "data-download:plan"
+	QueueItem       = "data-download:item"
+)
+
+var client *asynq.Client
+
+func InitClient(redisOpt asynq.RedisClientOpt) {
+	if client == nil {
+		client = asynq.NewClient(redisOpt)
+	}
+}
+
+type planPayload struct {
+	TaskID string `json:"taskId"`
+}
+type itemPayload struct {
+	ItemID         string `json:"itemId"`
+	AwaitRequestID string `json:"awaitRequestId,omitempty"`
+	DeferredCount  int    `json:"deferredCount,omitempty"`
+}
+type packagePayload struct {
+	JobID string `json:"jobId"`
+}
+
+func Init(tc *task_core.TaskCore) {
+	mux := asynq.NewServeMux()
+	mux.HandleFunc(TaskTypePlan, handlePlan)
+	mux.HandleFunc(TaskTypeItem, handleItem)
+	mux.HandleFunc(TaskTypePackage, handlePackage)
+	client = tc.CreateTask(task_core.TaskConfig{
+		Name: "DataDownload",
+		SvrCfg: asynq.Config{Concurrency: 8, Queues: map[string]int{QueueItem: 6, QueuePlan: 2}, ErrorHandler: asynq.ErrorHandlerFunc(func(_ context.Context, task *asynq.Task, err error) {
+			zap.L().Error("data-download task failed", zap.String("type", task.Type()), zap.String("err", err.Error()))
+		})},
+		Mux: mux,
+	})
+}
+
+func EnqueuePackage(jobID primitive.ObjectID) error {
+	payload, _ := json.Marshal(packagePayload{JobID: jobID.Hex()})
+	_, err := client.Enqueue(asynq.NewTask(TaskTypePackage, payload, asynq.Queue(QueueItem), asynq.MaxRetry(1), asynq.Timeout(2*time.Hour), asynq.TaskID("dd-package-"+jobID.Hex()), asynq.Retention(30*24*time.Hour)))
+	return err
+}
+
+func handlePackage(ctx context.Context, task *asynq.Task) error {
+	var payload packagePayload
+	if err := json.Unmarshal(task.Payload(), &payload); err != nil {
+		return err
+	}
+	id, err := primitive.ObjectIDFromHex(payload.JobID)
+	if err != nil {
+		return err
+	}
+	job, err := mongoRepo.GetDataDownloadPackageJob(ctx, id)
+	if err != nil {
+		return err
+	}
+	if job.Status == domain.DataDownloadCancelled || job.Status == domain.DataDownloadCancelling {
+		if job.Status == domain.DataDownloadCancelling {
+			_ = mongoRepo.UpdateDataDownloadPackageJob(ctx, id, bson.M{"status": domain.DataDownloadCancelled})
+		}
+		return nil
+	}
+	now := time.Now().UTC()
+	_ = mongoRepo.UpdateDataDownloadPackageJob(ctx, id, bson.M{"status": domain.DataDownloadRunning, "startedAt": now})
+	zap.L().Info("[DataDownload] 打包开始",
+		zap.String("jobId", id.Hex()),
+		zap.Int("taskCount", len(job.TaskIDs)),
+		zap.String("filename", job.Filename),
+	)
+	data, count, err := services.BuildDataDownloadPackage(ctx, job, func(done, total int64) error {
+		current, getErr := mongoRepo.GetDataDownloadPackageJob(ctx, id)
+		if getErr != nil {
+			return getErr
+		}
+		if current.Status == domain.DataDownloadCancelling || current.Status == domain.DataDownloadCancelled {
+			return context.Canceled
+		}
+		return mongoRepo.UpdateDataDownloadPackageJob(ctx, id, bson.M{"doneCount": done, "totalCount": total})
+	})
+	if err != nil {
+		status, reason := domain.DataDownloadFailed, err.Error()
+		if err == context.Canceled {
+			status, reason = domain.DataDownloadCancelled, ""
+		}
+		zap.L().Error("[DataDownload] 打包失败", zap.String("jobId", id.Hex()), zap.String("status", string(status)), zap.String("err", err.Error()))
+		_ = mongoRepo.UpdateDataDownloadPackageJob(ctx, id, bson.M{"status": status, "failureReason": reason})
+		return nil
+	}
+	objectKey, err := minioRepo.PutPackage(ctx, job.Filename, data)
+	if err != nil {
+		zap.L().Error("[DataDownload] 打包上传 MinIO 失败", zap.String("jobId", id.Hex()), zap.String("err", err.Error()))
+		_ = mongoRepo.UpdateDataDownloadPackageJob(ctx, id, bson.M{"status": domain.DataDownloadFailed, "failureReason": err.Error()})
+		return nil
+	}
+	completed := time.Now().UTC()
+	expires := completed.Add(7 * 24 * time.Hour)
+	purge := expires.Add(30 * 24 * time.Hour)
+	zap.L().Info("[DataDownload] 打包完成",
+		zap.String("jobId", id.Hex()),
+		zap.Int64("fileCount", count),
+		zap.Int("bytes", len(data)),
+		zap.String("objectKey", objectKey),
+	)
+	return mongoRepo.UpdateDataDownloadPackageJob(ctx, id, bson.M{"status": domain.DataDownloadSucceeded, "objectKey": objectKey, "size": len(data), "doneCount": count, "totalCount": count, "completedAt": completed, "expiresAt": expires, "purgeAt": purge, "failureReason": ""})
+}
+
+func EnqueuePlan(taskID primitive.ObjectID) error {
+	if client == nil {
+		return fmt.Errorf("data-download queue is not initialized")
+	}
+	payload, _ := json.Marshal(planPayload{TaskID: taskID.Hex()})
+	_, err := client.Enqueue(asynq.NewTask(TaskTypePlan, payload, asynq.Queue(QueuePlan), asynq.MaxRetry(3), asynq.Timeout(15*time.Minute), asynq.TaskID("dd-plan-"+taskID.Hex()), asynq.Retention(30*24*time.Hour)))
+	if err != nil {
+		zap.L().Error("[DataDownload] plan 入队失败", zap.String("taskId", taskID.Hex()), zap.String("err", err.Error()))
+	} else {
+		zap.L().Info("[DataDownload] plan 已入队", zap.String("taskId", taskID.Hex()))
+	}
+	return err
+}
+
+func enqueueItem(itemID primitive.ObjectID) error {
+	payload, _ := json.Marshal(itemPayload{ItemID: itemID.Hex()})
+	_, err := client.Enqueue(asynq.NewTask(TaskTypeItem, payload, asynq.Queue(QueueItem), asynq.MaxRetry(3), asynq.Timeout(40*time.Minute), asynq.TaskID("dd-item-"+itemID.Hex()), asynq.Retention(30*24*time.Hour)))
+	if errors.Is(err, asynq.ErrTaskIDConflict) {
+		return nil
+	}
+	return err
+}
+
+func enqueueDeferredItem(itemID primitive.ObjectID, requestID string, deferredCount int, after time.Duration) error {
+	payload, _ := json.Marshal(itemPayload{ItemID: itemID.Hex(), AwaitRequestID: requestID, DeferredCount: deferredCount})
+	_, err := client.Enqueue(asynq.NewTask(TaskTypeItem, payload,
+		asynq.Queue(QueueItem),
+		asynq.MaxRetry(3),
+		asynq.Timeout(40*time.Minute),
+		asynq.ProcessIn(after),
+		asynq.TaskID(fmt.Sprintf("dd-item-wait-%s-%d", itemID.Hex(), time.Now().UnixNano())),
+		asynq.Retention(30*24*time.Hour)))
+	return err
+}
+
+func handlePlan(ctx context.Context, job *asynq.Task) error {
+	var payload planPayload
+	if err := json.Unmarshal(job.Payload(), &payload); err != nil {
+		return err
+	}
+	id, err := primitive.ObjectIDFromHex(payload.TaskID)
+	if err != nil {
+		return err
+	}
+	task, err := mongoRepo.GetDataDownloadTask(ctx, id)
+	if err != nil {
+		return err
+	}
+	zap.L().Info("[DataDownload] plan 开始",
+		zap.String("taskId", id.Hex()),
+		zap.Int("snCount", len(task.SNs)),
+		zap.String("status", string(task.Status)),
+	)
+	if task.Status == domain.DataDownloadCancelled || task.Status == domain.DataDownloadCancelling {
+		if task.Status == domain.DataDownloadCancelling {
+			_ = mongoRepo.UpdateDataDownloadTaskStatus(ctx, id, domain.DataDownloadCancelled)
+		}
+		zap.L().Info("[DataDownload] plan 因任务取消而终止", zap.String("taskId", id.Hex()))
+		return nil
+	}
+	if err := mongoRepo.UpdateDataDownloadTaskStatus(ctx, id, domain.DataDownloadRunning); err != nil {
+		return err
+	}
+	totalItems := 0
+	// Plan and enqueue one site at a time so an unbounded SN list never expands
+	// into one large in-memory slice or MongoDB document.
+	for _, sn := range task.SNs {
+		latest, getErr := mongoRepo.GetDataDownloadTask(ctx, id)
+		if getErr != nil {
+			return getErr
+		}
+		if latest.Status == domain.DataDownloadCancelling || latest.Status == domain.DataDownloadCancelled {
+			_ = mongoRepo.UpdateDataDownloadTaskStatus(ctx, id, domain.DataDownloadCancelled)
+			break
+		}
+		siteTask := *task
+		siteTask.SNs = []string{sn}
+		items, planErr := services.PlanDataDownloadTask(ctx, &siteTask)
+		if planErr != nil {
+			zap.L().Error("[DataDownload] plan 站点规划失败",
+				zap.String("taskId", id.Hex()),
+				zap.String("sn", sn),
+				zap.String("err", planErr.Error()),
+			)
+			_ = mongoRepo.UpdateDataDownloadTaskStatus(ctx, id, domain.DataDownloadError)
+			return planErr
+		}
+		if err := mongoRepo.InsertDataDownloadItems(ctx, items); err != nil {
+			return err
+		}
+		totalItems += len(items)
+		zap.L().Debug("[DataDownload] plan 站点拆分完成",
+			zap.String("taskId", id.Hex()),
+			zap.String("sn", sn),
+			zap.Int("itemCount", len(items)),
+		)
+		for i := range items {
+			if items[i].Status == domain.DataDownloadFailed || items[i].Status == domain.DataDownloadSkipped {
+				continue
+			}
+			if err := enqueueItem(items[i].ID); err != nil {
+				zap.L().Error("[DataDownload] item 入队失败",
+					zap.String("taskId", id.Hex()),
+					zap.String("itemId", items[i].ID.Hex()),
+					zap.String("kind", string(items[i].Kind)),
+					zap.String("err", err.Error()),
+				)
+				_ = mongoRepo.UpdateDataDownloadItem(ctx, items[i].ID, domain.DataDownloadFailed, "enqueue", err.Error())
+			}
+		}
+		_ = mongoRepo.RecountDataDownloadTask(ctx, id)
+	}
+	if totalItems == 0 {
+		latest, getErr := mongoRepo.GetDataDownloadTask(ctx, id)
+		if getErr == nil && latest.Status == domain.DataDownloadCancelled {
+			return nil
+		}
+		zap.L().Info("[DataDownload] plan 完成,无有效 item,任务直接置为成功", zap.String("taskId", id.Hex()))
+		_ = mongoRepo.UpdateDataDownloadTaskStatus(ctx, id, domain.DataDownloadSucceeded)
+		return nil
+	}
+	zap.L().Info("[DataDownload] plan 完成,item 已全部入队",
+		zap.String("taskId", id.Hex()),
+		zap.Int("totalItems", totalItems),
+	)
+	return aggregate(ctx, id)
+}
+
+func EnqueueItem(itemID primitive.ObjectID) error {
+	payload, _ := json.Marshal(itemPayload{ItemID: itemID.Hex()})
+	_, err := client.Enqueue(asynq.NewTask(TaskTypeItem, payload, asynq.Queue(QueueItem), asynq.MaxRetry(3), asynq.Timeout(40*time.Minute), asynq.TaskID(fmt.Sprintf("dd-item-%s-%d", itemID.Hex(), time.Now().UnixNano())), asynq.Retention(30*24*time.Hour)))
+	return err
+}
+
+func handleItem(ctx context.Context, job *asynq.Task) error {
+	var payload itemPayload
+	if err := json.Unmarshal(job.Payload(), &payload); err != nil {
+		return err
+	}
+	id, err := primitive.ObjectIDFromHex(payload.ItemID)
+	if err != nil {
+		return err
+	}
+	item, err := mongoRepo.GetDataDownloadItem(ctx, id)
+	if err != nil {
+		return err
+	}
+	task, err := mongoRepo.GetDataDownloadTask(ctx, item.TaskID)
+	if err != nil {
+		return err
+	}
+	retried, _ := asynq.GetRetryCount(ctx)
+	zap.L().Debug("[DataDownload] item 开始处理",
+		zap.String("itemId", id.Hex()),
+		zap.String("taskId", item.TaskID.Hex()),
+		zap.String("kind", string(item.Kind)),
+		zap.String("sn", item.SiteSN),
+		zap.String("subtype", item.Subtype),
+		zap.String("date", item.Date),
+		zap.Int("attempt", retried),
+	)
+	if task.Status == domain.DataDownloadCancelled || task.Status == domain.DataDownloadCancelling {
+		zap.L().Info("[DataDownload] item 因任务取消而跳过", zap.String("itemId", id.Hex()), zap.String("taskId", item.TaskID.Hex()))
+		_ = services.ReleaseFaultCallOwnership(context.Background(), item)
+		_ = mongoRepo.UpdateDataDownloadItem(ctx, id, domain.DataDownloadCancelled, "cancelled", "")
+		return aggregate(ctx, item.TaskID)
+	}
+	if item.Kind == "site_validation" {
+		_ = mongoRepo.UpdateDataDownloadItem(ctx, id, domain.DataDownloadRunning, "validation", "")
+		retryTask := *task
+		retryTask.SNs = []string{item.SiteSN}
+		planned, planErr := services.PlanDataDownloadTask(ctx, &retryTask)
+		if planErr != nil || (len(planned) == 1 && planned[0].Kind == "site_validation") {
+			reason := "site does not exist"
+			if planErr != nil {
+				reason = planErr.Error()
+			} else if len(planned) == 1 {
+				reason = planned[0].FailureReason
+			}
+			_ = mongoRepo.UpdateDataDownloadItem(ctx, id, domain.DataDownloadFailed, "validation", reason)
+			return aggregate(ctx, item.TaskID)
+		}
+		if err := mongoRepo.InsertDataDownloadItems(ctx, planned); err != nil {
+			_ = mongoRepo.UpdateDataDownloadItem(ctx, id, domain.DataDownloadFailed, "validation", err.Error())
+			return aggregate(ctx, item.TaskID)
+		}
+		_ = mongoRepo.UpdateDataDownloadItem(ctx, id, domain.DataDownloadItemSucceeded, "validation", "")
+		for i := range planned {
+			if err := enqueueItem(planned[i].ID); err != nil {
+				_ = mongoRepo.UpdateDataDownloadItem(ctx, planned[i].ID, domain.DataDownloadFailed, "enqueue", err.Error())
+			}
+		}
+		return aggregate(ctx, item.TaskID)
+	}
+	_ = mongoRepo.UpdateDataDownloadItem(ctx, id, domain.DataDownloadRunning, "fetch", "")
+	err = services.ExecuteStructuredDataDownloadItem(ctx, item, payload.AwaitRequestID)
+	if err != nil {
+		var faultCallDeferred *services.DataDownloadFaultCallDeferred
+		if errors.As(err, &faultCallDeferred) {
+			_ = mongoRepo.DeferDataDownloadItem(ctx, id)
+			if enqueueErr := enqueueDeferredItem(id, payload.AwaitRequestID, payload.DeferredCount+1, faultCallDeferred.After); enqueueErr != nil {
+				_ = services.ReleaseFaultCallOwnership(context.Background(), item)
+				_ = mongoRepo.UpdateDataDownloadItem(ctx, id, domain.DataDownloadFailed, "enqueue", enqueueErr.Error())
+			}
+			return aggregate(ctx, item.TaskID)
+		}
+		var snDeferred *services.DataDownloadSNDeferred
+		if errors.As(err, &snDeferred) {
+			delay := snDeferred.After
+			zap.L().Debug("[DataDownload] EMS 召唤名额被占用,延迟处理",
+				zap.String("itemId", id.Hex()), zap.String("sn", item.SiteSN), zap.Duration("delay", delay))
+			_ = mongoRepo.DeferDataDownloadItem(ctx, id)
+			if enqueueErr := enqueueDeferredItem(id, payload.AwaitRequestID, payload.DeferredCount+1, delay); enqueueErr != nil {
+				_ = mongoRepo.UpdateDataDownloadItem(ctx, id, domain.DataDownloadFailed, "enqueue", enqueueErr.Error())
+			}
+			return aggregate(ctx, item.TaskID)
+		}
+		var deferred *services.DataDownloadRequestDeferred
+		if errors.As(err, &deferred) {
+			delay := deferred.After
+			switch payload.DeferredCount {
+			case 1:
+				delay = 30 * time.Second
+			case 2:
+				delay = time.Minute
+			default:
+				if payload.DeferredCount > 2 {
+					delay = time.Minute
+				}
+			}
+			zap.L().Debug("[DataDownload] item 等待请求租约,延迟重入队",
+				zap.String("itemId", id.Hex()),
+				zap.String("requestId", deferred.RequestID),
+				zap.Int("deferredCount", payload.DeferredCount+1),
+				zap.Duration("delay", delay),
+			)
+			_ = mongoRepo.DeferDataDownloadItem(ctx, id)
+			if enqueueErr := enqueueDeferredItem(id, deferred.RequestID, payload.DeferredCount+1, delay); enqueueErr != nil {
+				zap.L().Error("[DataDownload] item 延迟重入队失败", zap.String("itemId", id.Hex()), zap.String("err", enqueueErr.Error()))
+				_ = mongoRepo.UpdateDataDownloadItem(ctx, id, domain.DataDownloadFailed, "enqueue", enqueueErr.Error())
+				return aggregate(ctx, item.TaskID)
+			}
+			return aggregate(ctx, item.TaskID)
+		}
+		maxRetry, _ := asynq.GetMaxRetry(ctx)
+		if shouldRetryItem(err, retried, maxRetry) {
+			zap.L().Warn("[DataDownload] item 执行失败,将重试",
+				zap.String("itemId", id.Hex()),
+				zap.String("kind", string(item.Kind)),
+				zap.Int("attempt", retried),
+				zap.Int("maxRetry", maxRetry),
+				zap.String("err", err.Error()),
+			)
+			return err
+		}
+		var partial *services.DataDownloadPartialFailure
+		if errors.As(err, &partial) && partial.Succeeded > 0 {
+			zap.L().Warn("[DataDownload] item 部分成功(重试耗尽)",
+				zap.String("itemId", id.Hex()),
+				zap.String("kind", string(item.Kind)),
+				zap.Int("succeeded", partial.Succeeded),
+				zap.String("err", err.Error()),
+			)
+			_ = mongoRepo.UpdateDataDownloadItem(ctx, id, domain.DataDownloadPartial, "fetch", err.Error())
+		} else {
+			zap.L().Error("[DataDownload] item 最终失败(重试耗尽)",
+				zap.String("itemId", id.Hex()),
+				zap.String("taskId", item.TaskID.Hex()),
+				zap.String("kind", string(item.Kind)),
+				zap.String("sn", item.SiteSN),
+				zap.String("err", err.Error()),
+			)
+			_ = mongoRepo.UpdateDataDownloadItem(ctx, id, domain.DataDownloadFailed, "fetch", err.Error())
+		}
+		_ = services.ReleaseFaultCallOwnership(context.Background(), item)
+	} else {
+		latest, getErr := mongoRepo.GetDataDownloadTask(ctx, item.TaskID)
+		if getErr == nil && (latest.Status == domain.DataDownloadCancelling || latest.Status == domain.DataDownloadCancelled) {
+			_ = mongoRepo.UpdateDataDownloadItem(ctx, id, domain.DataDownloadCancelled, "cancelled", "")
+		} else {
+			zap.L().Info("[DataDownload] item 执行成功",
+				zap.String("itemId", id.Hex()),
+				zap.String("taskId", item.TaskID.Hex()),
+				zap.String("kind", string(item.Kind)),
+				zap.String("sn", item.SiteSN),
+			)
+			_ = mongoRepo.UpdateDataDownloadItem(ctx, id, domain.DataDownloadItemSucceeded, "complete", "")
+		}
+	}
+	return aggregate(ctx, item.TaskID)
+}
+
+func shouldRetryItem(err error, retried, maxRetry int) bool {
+	return retried < maxRetry && !errors.Is(err, jobs.ErrEmsLogCallTimeout) && !errors.Is(err, jobs.ErrEmsLogCallSlotLost) && !errors.Is(err, services.ErrFaultRecordCallTimeout) && !errors.Is(err, services.ErrFaultRecordCallFailed)
+}
+
+func aggregate(ctx context.Context, taskID primitive.ObjectID) error {
+	task, taskErr := mongoRepo.GetDataDownloadTask(ctx, taskID)
+	if taskErr != nil {
+		return taskErr
+	}
+	items, err := mongoRepo.ListDataDownloadItems(ctx, taskID)
+	if err != nil {
+		return err
+	}
+	var succeeded, partial, failed, cancelled int
+	for _, item := range items {
+		switch item.Status {
+		case domain.DataDownloadItemSucceeded, domain.DataDownloadSkipped:
+			succeeded++
+		case domain.DataDownloadPartial:
+			partial++
+		case domain.DataDownloadFailed:
+			failed++
+		case domain.DataDownloadCancelled:
+			cancelled++
+		default:
+			_ = mongoRepo.RecountDataDownloadTask(ctx, taskID)
+			return nil
+		}
+	}
+	status := domain.DataDownloadSucceeded
+	if task.Status == domain.DataDownloadCancelling || task.Status == domain.DataDownloadCancelled {
+		status = domain.DataDownloadCancelled
+	} else if cancelled == len(items) {
+		status = domain.DataDownloadCancelled
+	} else if failed == len(items) {
+		status = domain.DataDownloadFailed
+	} else if partial > 0 || failed > 0 || cancelled > 0 {
+		status = domain.DataDownloadPartial
+	}
+	if err := mongoRepo.RecountDataDownloadTask(ctx, taskID); err != nil {
+		return err
+	}
+	zap.L().Info("[DataDownload] 任务完成",
+		zap.String("taskId", taskID.Hex()),
+		zap.String("status", string(status)),
+		zap.Int("total", len(items)),
+		zap.Int("succeeded", succeeded),
+		zap.Int("partial", partial),
+		zap.Int("failed", failed),
+		zap.Int("cancelled", cancelled),
+	)
+	return mongoRepo.UpdateDataDownloadTaskStatus(ctx, taskID, status)
+}

+ 20 - 0
service/task/queue/dataDownloadQueue/dataDownloadQueue_test.go

@@ -0,0 +1,20 @@
+package dataDownloadQueue
+
+import (
+	"errors"
+	"testing"
+
+	"fwh-toolbox-server/jobs"
+)
+
+func TestShouldRetryItemSkipsEmsTimeout(t *testing.T) {
+	if shouldRetryItem(jobs.ErrEmsLogCallTimeout, 0, 3) {
+		t.Fatal("timed out EMS file should release the SN for the next file")
+	}
+	if shouldRetryItem(jobs.ErrEmsLogCallSlotLost, 0, 3) {
+		t.Fatal("lost EMS call slot should not immediately summon again")
+	}
+	if !shouldRetryItem(errors.New("temporary fetch error"), 0, 3) {
+		t.Fatal("unrelated transient failure should retain retries")
+	}
+}

+ 1 - 1
service/task/queue/fwhQueue/fwhTaskBatchDownload.go

@@ -544,7 +544,7 @@ func executeSyncUpdateLog(sn string, payload domain.TaskBatchDownload) error {
 			updateLogInfo.Result = fmt.Sprintf("unknown(%d)", logList.Result)
 		}
 
-		systemUpdateLogListDetail, err := repositories.QuerySystemUpdateLogDetail(logList.Id)
+		systemUpdateLogListDetail, err := repositories.QuerySystemUpdateLogDetail(logList.Id, sn)
 		if err != nil {
 			return err
 		}

+ 1 - 1
service/task/queue/fwhQueueOld/fwhTaskBatchDownload.go

@@ -321,7 +321,7 @@ var executeTaskBatchDownload = func(ctx context.Context, payload domain.TaskBatc
 					updateLogInfo.Result = fmt.Sprintf("unknown(%d)", logList.Result)
 				}
 
-				systemUpdateLogListDetail, err := repositories.QuerySystemUpdateLogDetail(logList.Id)
+				systemUpdateLogListDetail, err := repositories.QuerySystemUpdateLogDetail(logList.Id, sn)
 				if err != nil {
 					zap.L().Error("查询站点系统升级详细日志失败", zap.String("sn", sn), zap.String("err", err.Error()))
 					return nil, err

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

@@ -2,6 +2,7 @@ package taskQueue
 
 import (
 	task_core "fwh-toolbox-server/task/core"
+	"fwh-toolbox-server/task/queue/dataDownloadQueue"
 	"fwh-toolbox-server/task/queue/fwhQueue"
 	"fwh-toolbox-server/task/queue/simCardQueue"
 )
@@ -12,5 +13,6 @@ func InitTaskQueue(tc *task_core.TaskCore) {
 	}
 
 	fwhQueue.InitFwhTaskQueue(tc)
+	dataDownloadQueue.Init(tc)
 	simCardQueue.InitSimCardTaskQueue(tc)
 }

+ 22 - 2
service/utils/httpClient/httpClient.go

@@ -2,6 +2,7 @@ package httpClient
 
 import (
 	"bytes"
+	"context"
 	"encoding/json"
 	"errors"
 	"fmt"
@@ -25,6 +26,7 @@ type HttpClient struct {
 type HttpClientOptions struct {
 	Timeout *time.Duration // 单位:s
 	Header  *http.Header
+	Context context.Context
 }
 
 // PostBody 包含转换后的 Reader 和推荐的 Content-Type
@@ -110,7 +112,11 @@ func (c *HttpClient) HttpGet(url string, params *url.Values, opt *HttpClientOpti
 	zap.L().Debug("HttpGet", zap.String("url", finalURL))
 
 	// 创建请求
-	req, err := http.NewRequest(
+	requestCtx := context.Background()
+	if opt != nil && opt.Context != nil {
+		requestCtx = opt.Context
+	}
+	req, err := http.NewRequestWithContext(requestCtx,
 		http.MethodGet,
 		finalURL,
 		nil)
@@ -164,6 +170,16 @@ func (c *HttpClient) HttpGet(url string, params *url.Values, opt *HttpClientOpti
 			return nil, err
 		}
 
+		// 响应体大小观测:定位单次上游响应是否异常巨大(内存放大源)。
+		// 正常业务响应通常几十 KB 内;超过 5MB 视为异常,打 Warn。
+		if len(respBody) > 5*1024*1024 {
+			zap.L().Warn("[HttpGet] 响应体异常大",
+				zap.String("url", finalURL),
+				zap.Int("bytes", len(respBody)),
+				zap.Int64("contentLength", resp.ContentLength),
+			)
+		}
+
 		return &Response{
 			Status:        resp.Status,
 			StatusCode:    resp.StatusCode,
@@ -248,7 +264,11 @@ func (c *HttpClient) HttpPost(url string, body any, opt *HttpClientOptions) (*Re
 	zap.L().Debug("HttpPost", zap.String("url", url), zap.String("contentType", payload.ContentType))
 
 	// 创建请求
-	req, err := http.NewRequest(
+	requestCtx := context.Background()
+	if opt != nil && opt.Context != nil {
+		requestCtx = opt.Context
+	}
+	req, err := http.NewRequestWithContext(requestCtx,
 		http.MethodPost,
 		url,
 		payload.Reader)

+ 30 - 0
service/utils/httpClient/httpClient_context_test.go

@@ -0,0 +1,30 @@
+package httpClient
+
+import (
+	"context"
+	"errors"
+	"net/http"
+	"testing"
+	"time"
+)
+
+func TestRequestContextStopsCanceledCall(t *testing.T) {
+	client := &HttpClient{}
+	for _, method := range []string{http.MethodGet, http.MethodPost} {
+		t.Run(method, func(t *testing.T) {
+			ctx, cancel := context.WithCancel(context.Background())
+			cancel()
+			seconds := time.Duration(5)
+			options := &HttpClientOptions{Context: ctx, Timeout: &seconds}
+			var err error
+			if method == http.MethodGet {
+				_, err = client.HttpGet("http://example.invalid", nil, options)
+			} else {
+				_, err = client.HttpPost("http://example.invalid", "body", options)
+			}
+			if !errors.Is(err, context.Canceled) {
+				t.Fatalf("request error = %v, want context canceled", err)
+			}
+		})
+	}
+}

+ 30 - 0
src/router/record.ts

@@ -332,6 +332,36 @@ export const routeRecordRaw: MergeMenuMixedOptions[] = [
     },
     component: 'batch-download/index',
   },
+  {
+    path: 'data-download',
+    name: 'dataDownload',
+    icon: 'i-ph:download',
+    label: '批量数据下载',
+    redirect: 'data-download/tasks',
+    children: [
+      {
+        path: 'tasks',
+        name: 'dataDownloadTasks',
+        label: '任务管理',
+        meta: { componentName: 'DataDownloadTasks', showTab: true },
+        component: 'data-download/index',
+      },
+      {
+        path: 'packages',
+        name: 'dataDownloadPackages',
+        label: '待下载数据',
+        meta: { componentName: 'DataDownloadPackages', showTab: true },
+        component: 'data-download/packages',
+      },
+      {
+        path: 'documents',
+        name: 'dataDownloadDocuments',
+        label: '文件管理',
+        meta: { componentName: 'DataDownloadDocuments', showTab: true },
+        component: 'data-download/documents',
+      },
+    ],
+  },
   {
     path: 'background-task',
     name: 'backgroundTask',

+ 127 - 0
src/service/dataDownload/index.ts

@@ -0,0 +1,127 @@
+import http from '@/service/serverReq'
+import type {
+  DataDownloadPackagePage,
+  DataDownloadTaskDetail,
+  DataDownloadTaskInput,
+  DataDownloadTaskPage,
+} from './interface'
+
+const root = '/dataDownload/tasks'
+
+export function getDataDownloadTasks(
+  pageNum: number,
+  pageSize: number,
+  scope: 'mine' | 'all',
+  description = '',
+  sn = '',
+  status = '',
+) {
+  return http.get<DataDownloadTaskPage>(root, {
+    params: { pageNum, pageSize, scope, description, sn, status },
+  })
+}
+
+export function createDataDownloadPackage(taskIds: string[]) {
+  return http.post('/dataDownload/packages', { taskIds })
+}
+
+export function getDataDownloadPackages(pageNum: number, pageSize: number) {
+  return http.get<DataDownloadPackagePage>('/dataDownload/packages', {
+    params: { pageNum, pageSize },
+  })
+}
+
+export function downloadDataDownloadPackage(id: string) {
+  return http.download(`/dataDownload/packages/${encodeURIComponent(id)}/file`)
+}
+
+export function cancelDataDownloadPackage(id: string) {
+  return http.post(`/dataDownload/packages/${encodeURIComponent(id)}/cancel`)
+}
+
+export function retryDataDownloadPackage(id: string) {
+  return http.post(`/dataDownload/packages/${encodeURIComponent(id)}/retry`)
+}
+
+export function deleteDataDownloadPackage(id: string) {
+  return http.delete(`/dataDownload/packages/${encodeURIComponent(id)}`)
+}
+
+export function getDataDownloadDocuments(pageNum: number, pageSize: number) {
+  return http.get<{ items: import('./interface').DataDownloadDocument[]; total: number }>(
+    '/dataDownload/documents',
+    { params: { pageNum, pageSize } },
+  )
+}
+
+export function getDataDownloadDocumentReferences(documentId: string) {
+  return http.get<{ count: number }>(
+    `/dataDownload/documents/${encodeURIComponent(documentId)}/references`,
+  )
+}
+
+export function getDataDownloadDocumentFiles(documentId: string) {
+  return http.get<import('./interface').DataDownloadFaultRecordEntry[]>(
+    `/dataDownload/documents/${encodeURIComponent(documentId)}/files`,
+  )
+}
+
+export function downloadDataDownloadDocumentFile(documentId: string, entryId: string) {
+  return http.download(
+    `/dataDownload/documents/${encodeURIComponent(documentId)}/files/${encodeURIComponent(entryId)}`,
+  )
+}
+
+export function deleteDataDownloadDocument(documentId: string, force = false) {
+  return http.delete(`/dataDownload/documents/${encodeURIComponent(documentId)}`, {
+    params: { force },
+  })
+}
+
+export function downloadDataDownloadDocument(documentId: string, format?: string) {
+  return http.download(
+    `/dataDownload/documents/${encodeURIComponent(documentId)}/file`,
+    undefined,
+    { params: { format: format || '' } },
+  )
+}
+
+export function downloadDataDownloadTaskDocument(
+  taskId: string,
+  documentId: string,
+  format?: string,
+) {
+  return http.download(
+    `${root}/${encodeURIComponent(taskId)}/documents/${encodeURIComponent(documentId)}/file`,
+    undefined,
+    { params: { format: format || '' } },
+  )
+}
+
+export function downloadDataDownloadTaskFile(taskId: string, entryId: string) {
+  return http.download(`${root}/${encodeURIComponent(taskId)}/files/${encodeURIComponent(entryId)}`)
+}
+
+export function createDataDownload(input: DataDownloadTaskInput) {
+  return http.post(root, input)
+}
+
+export function getDataDownloadTask(id: string) {
+  return http.get<DataDownloadTaskDetail>(`${root}/${encodeURIComponent(id)}`)
+}
+
+export function cancelDataDownload(id: string) {
+  return http.post(`${root}/${encodeURIComponent(id)}/cancel`)
+}
+
+export function retryDataDownload(id: string) {
+  return http.post(`${root}/${encodeURIComponent(id)}/retry`)
+}
+
+export function deleteDataDownload(id: string) {
+  return http.delete(`${root}/${encodeURIComponent(id)}`)
+}
+
+export function updateDataDownloadDescription(id: string, description: string) {
+  return http.put(`${root}/${encodeURIComponent(id)}/description`, { description })
+}

+ 146 - 0
src/service/dataDownload/interface.ts

@@ -0,0 +1,146 @@
+export type DataDownloadStatus =
+  | 'pending'
+  | 'running'
+  | 'cancelling'
+  | 'cancelled'
+  | 'complete'
+  | 'partial_success'
+  | 'failed'
+  | 'error'
+
+export interface DataDownloadOptions {
+  powerFlow: boolean
+  mqttLogs: string[]
+  eventAlarm: boolean
+  alarmSnapshot: boolean
+  emsLogs: string[]
+  faultRecord: boolean
+  updateLog: boolean
+}
+
+export interface DataDownloadTask {
+  id: string
+  ownerName: string
+  sns: string[]
+  description: string
+  startDate?: string
+  endDate?: string
+  options: DataDownloadOptions
+  status: DataDownloadStatus
+  total: number
+  succeeded: number
+  failed: number
+  empty: number
+  createdAt: string
+  updatedAt: string
+}
+
+export interface DataDownloadTaskInput {
+  snText: string
+  description: string
+  startDate: string
+  endDate: string
+  options: DataDownloadOptions
+}
+
+export interface DataDownloadTaskPage {
+  items: DataDownloadTask[]
+  total: number
+  pageNum: number
+  pageSize: number
+}
+
+export interface DataDownloadPackageJob {
+  id: string
+  taskIds: string[]
+  status: DataDownloadStatus
+  filename: string
+  size: number
+  totalCount: number
+  doneCount: number
+  failureReason?: string
+  expiresAt?: string
+  createdAt: string
+}
+
+export interface DataDownloadPackagePage {
+  items: DataDownloadPackageJob[]
+  total: number
+}
+
+export interface DataDownloadDocument {
+  id: string
+  requestKey: string
+  kind: string
+  siteSn: string
+  deviceSn?: string
+  deviceType?: string
+  date?: string
+  subtype?: string
+  timezone: string
+  state: 'mutable' | 'frozen' | 'dynamic'
+  completeness?: 'complete' | 'partial'
+  fileCount: number
+  failedFileCount: number
+  empty: boolean
+  contentGeneration: number
+  hasBinary: boolean
+  checksum: string
+  sourceFetchedAt: string
+  updatedAt: string
+}
+
+export interface DataDownloadTaskDocument {
+  id: string
+  taskId: string
+  itemKey: string
+  documentId: string
+  logicalPath: string
+  empty: boolean
+}
+
+export interface DataDownloadTaskDetail {
+  task: DataDownloadTask
+  items: Array<{
+    id: string
+    itemKey: string
+    kind: string
+    siteSn: string
+    deviceSn?: string
+    deviceType?: string
+    date?: string
+    subtype?: string
+    status: string
+    stage?: string
+    attempt: number
+    updatedAt: string
+    failureReason?: string
+  }>
+  documents: DataDownloadTaskDocument[]
+  catalog: DataDownloadDocument[]
+  faultFiles: Record<string, DataDownloadFaultRecordEntry[]>
+  fileAttempts: DataDownloadFileAttempt[]
+}
+
+export interface DataDownloadFaultRecordEntry {
+  id: string
+  documentId: string
+  sourceFileId: string
+  fileName: string
+  contentType: string
+  size: number
+  checksum: string
+  lastSuccessfulAt: string
+}
+
+export interface DataDownloadFileAttempt {
+  id: string
+  itemId: string
+  documentId: string
+  sourceFileId: string
+  fileName?: string
+  status: string
+  attemptCount: number
+  failureStage?: string
+  failureReason?: string
+}

+ 11 - 1
src/stores/user.ts

@@ -44,6 +44,7 @@ const DEFAULT_USER: User = {
 // 特权菜单定义
 const adminMenuNames: string[] = [
   'backgroundTask', // 后台任务管理
+  'dataDownloadDocuments', // DataDownload 共享文档管理
 ] as const
 
 const xlogMenuNames: string[] = [
@@ -151,7 +152,16 @@ export const useUserStore = defineStore('userStore', () => {
           // resolve(filterRoutes(routeRecordRaw))
           const permissionsItems = user.value.permissions.items
           resolve(
-            routeRecordRaw.filter((route) => {
+            routeRecordRaw.map((route): MergeMenuMixedOptions => {
+              const nested = route as { children?: MergeMenuMixedOptions[] }
+              if (Array.isArray(nested.children)) {
+                return { ...route, children: nested.children.filter(child => {
+                  const name = (child as { name?: unknown }).name
+                  return !(typeof name === 'string' && adminMenuNames.includes(name))
+                }) } as MergeMenuMixedOptions
+              }
+              return route
+            }).filter((route) => {
               if (route.type === undefined && typeof route.name === 'string') {
                 // 清除admin权限页面
                 if (adminMenuNames.includes(route.name)) {

+ 168 - 0
src/views/data-download/documents.vue

@@ -0,0 +1,168 @@
+<template>
+  <div class="p-4">
+    <NCard title="DataDownload 文件管理">
+      <NAlert class="mb-4" type="info"
+        >仅管理员可用。正文更新与派生缓存失效由后台自动完成,不提供人工 Invalidate。</NAlert
+      >
+      <NDataTable
+        :columns="columns"
+        :data="documents"
+        :loading="loading"
+        :row-key="(row: DataDownloadDocument) => row.id"
+        :scroll-x="1250"
+      />
+      <div class="mt-4 flex justify-end">
+        <NPagination v-model:page="page" :item-count="total" @update:page="load" />
+      </div>
+    </NCard>
+    <NModal v-model:show="showFiles" preset="card" title="FaultRecord 文件" class="max-w-4xl">
+      <NDataTable
+        :columns="fileColumns"
+        :data="files"
+        :row-key="(row: DataDownloadFaultRecordEntry) => row.id"
+      />
+    </NModal>
+  </div>
+</template>
+
+<script setup lang="tsx">
+import { NButton, NTag, type DataTableColumns } from 'naive-ui'
+import dayjs from 'dayjs'
+import {
+  deleteDataDownloadDocument,
+  downloadDataDownloadDocument,
+  downloadDataDownloadDocumentFile,
+  getDataDownloadDocumentFiles,
+  getDataDownloadDocumentReferences,
+  getDataDownloadDocuments,
+} from '@/service/dataDownload'
+import type {
+  DataDownloadDocument,
+  DataDownloadFaultRecordEntry,
+} from '@/service/dataDownload/interface'
+
+defineOptions({ name: 'DataDownloadDocuments' })
+const message = useMessage()
+const dialog = useDialog()
+const documents = ref<DataDownloadDocument[]>([])
+const loading = ref(false)
+const page = ref(1)
+const total = ref(0)
+const showFiles = ref(false)
+const files = ref<DataDownloadFaultRecordEntry[]>([])
+const selectedDocumentId = ref('')
+const formatFor = (row: DataDownloadDocument) =>
+  row.hasBinary ? undefined : row.kind === 'power_flow' ? 'html' : 'xlsx'
+const columns: DataTableColumns<DataDownloadDocument> = [
+  { title: 'DocumentID', key: 'id', width: 220, ellipsis: { tooltip: true } },
+  { title: '类型', key: 'kind', width: 140 },
+  { title: 'Site SN', key: 'siteSn', width: 160 },
+  {
+    title: '日期/子类型',
+    key: 'date',
+    width: 180,
+    render: (row) => [row.date, row.subtype].filter(Boolean).join(' / ') || '-',
+  },
+  {
+    title: '状态',
+    key: 'state',
+    width: 110,
+    render: (row) => <NTag type={row.state === 'frozen' ? 'success' : 'warning'}>{row.state}</NTag>,
+  },
+  { title: 'Generation', key: 'contentGeneration', width: 110 },
+  { title: '空文档', key: 'empty', width: 90, render: (row) => (row.empty ? '是' : '否') },
+  {
+    title: '更新时间',
+    key: 'updatedAt',
+    width: 180,
+    render: (row) => dayjs(row.updatedAt).format('YYYY-MM-DD HH:mm:ss'),
+  },
+  {
+    title: '操作',
+    key: 'action',
+    width: 290,
+    fixed: 'right',
+    render: (row) => (
+      <div class="flex gap-2">
+        <NButton
+          size="small"
+          disabled={row.empty || row.kind === 'fault_record'}
+          onClick={() => downloadDataDownloadDocument(row.id, formatFor(row))}
+        >
+          下载
+        </NButton>
+        <NButton size="small" disabled={row.kind !== 'fault_record'} onClick={() => openFiles(row)}>
+          文件
+        </NButton>
+        <NButton size="small" onClick={() => refs(row)}>
+          关联任务
+        </NButton>
+        <NButton size="small" type="error" onClick={() => remove(row)}>
+          删除
+        </NButton>
+      </div>
+    ),
+  },
+]
+const fileColumns: DataTableColumns<DataDownloadFaultRecordEntry> = [
+  { title: '文件名', key: 'fileName', minWidth: 260 },
+  { title: '大小', key: 'size', width: 120 },
+  {
+    title: '最后成功时间',
+    key: 'lastSuccessfulAt',
+    width: 180,
+    render: (row) => dayjs(row.lastSuccessfulAt).format('YYYY-MM-DD HH:mm:ss'),
+  },
+  {
+    title: '操作',
+    key: 'action',
+    width: 100,
+    render: (row) => (
+      <NButton
+        size="small"
+        onClick={() => downloadDataDownloadDocumentFile(selectedDocumentId.value, row.id)}
+      >
+        下载
+      </NButton>
+    ),
+  },
+]
+async function load() {
+  loading.value = true
+  try {
+    const data = await getDataDownloadDocuments(page.value, 20)
+    documents.value = data.items || []
+    total.value = data.total || 0
+  } catch (error) {
+    message.error(String(error))
+  } finally {
+    loading.value = false
+  }
+}
+async function refs(row: DataDownloadDocument) {
+  const data = await getDataDownloadDocumentReferences(row.id)
+  message.info(`该文档被 ${data.count} 个任务引用`)
+}
+async function openFiles(row: DataDownloadDocument) {
+  selectedDocumentId.value = row.id
+  files.value = await getDataDownloadDocumentFiles(row.id)
+  showFiles.value = true
+}
+async function remove(row: DataDownloadDocument) {
+  const data = await getDataDownloadDocumentReferences(row.id)
+  dialog.warning({
+    title: '物理删除文档',
+    content: data.count
+      ? `文档被 ${data.count} 个任务引用。强制删除后这些任务的清单仍保留,但文件将无法读取。`
+      : '将物理删除正文、原始对象和派生缓存。',
+    positiveText: data.count ? '强制删除' : '删除',
+    negativeText: '取消',
+    onPositiveClick: async () => {
+      await deleteDataDownloadDocument(row.id, data.count > 0)
+      message.success('文档已删除')
+      await load()
+    },
+  })
+}
+onMounted(load)
+</script>

+ 1247 - 0
src/views/data-download/index.vue

@@ -0,0 +1,1247 @@
+<template>
+  <div class="p-4">
+    <NCard title="DataDownload 任务管理">
+      <div class="mb-4 flex flex-nowrap gap-3 w-full items-end">
+        <div class="shrink-0 w-28">
+          <div class="text-xs text-gray-500 mb-1">任务范围</div>
+          <NSelect v-model:value="scope" :options="scopeOptions" />
+        </div>
+        <div class="shrink-0 w-28">
+          <div class="text-xs text-gray-500 mb-1">任务状态</div>
+          <NSelect
+            v-model:value="statusFilter"
+            :options="statusOptions"
+            placeholder="任务状态"
+            clearable
+          />
+        </div>
+        <div class="flex-1 max-w-52 min-w-20">
+          <div class="text-xs text-gray-500 mb-1">SN</div>
+          <NInput
+            v-model:value="snFilter"
+            placeholder="按 SN 查询"
+            clearable
+            @keyup.enter="applyFilters"
+          />
+        </div>
+        <div class="flex-1 min-w-20">
+          <div class="text-xs text-gray-500 mb-1">描述</div>
+          <NInput
+            v-model:value="description"
+            placeholder="按描述查询"
+            clearable
+            @keyup.enter="applyFilters"
+          />
+        </div>
+        <NButton type="primary" :loading="loading" class="shrink-0" @click="applyFilters"
+          >查询</NButton
+        >
+        <NButton type="success" class="shrink-0" @click="showCreate = true">创建任务</NButton>
+      </div>
+      <NDataTable
+        :columns="columns"
+        :data="tasks"
+        :loading="loading"
+        :row-key="(row: DataDownloadTask) => row.id"
+        :scroll-x="1380"
+      />
+      <div class="mt-4 flex justify-end">
+        <NPagination
+          v-model:page="pageNum"
+          v-model:page-size="pageSize"
+          :item-count="total"
+          show-size-picker
+          :page-sizes="[20, 50, 100]"
+          @update:page="onPageChange"
+          @update:page-size="onPageSizeChange"
+        />
+      </div>
+    </NCard>
+
+    <NModal
+      :show="showDetail"
+      preset="card"
+      title="任务目录与明细"
+      :style="{ width: 'min(1080px, calc(100vw - 32px))' }"
+      @update:show="onDetailShowChange"
+    >
+      <div v-if="detail" class="task-detail">
+        <div class="task-detail-overview">
+          <div class="task-detail-heading">
+            <div class="task-detail-heading-main">
+              <div class="task-detail-eyebrow">任务概览</div>
+              <h3>{{ detail.task.description || '未命名任务' }}</h3>
+              <div class="task-detail-meta">
+                <span>{{ detail.task.ownerName || '未知创建人' }}</span>
+                <span>{{ formatDateRange(detail.task) }}</span>
+                <span>创建于 {{ formatDateTime(detail.task.createdAt) }}</span>
+              </div>
+            </div>
+            <NTag :type="statusTagType(detail.task.status)" size="small" round>
+              {{ statusText(detail.task.status) }}
+            </NTag>
+          </div>
+          <div class="task-detail-stats">
+            <div class="task-detail-stat">
+              <span>站点</span><strong>{{ detail.task.sns.length }}</strong>
+            </div>
+            <div class="task-detail-stat">
+              <span>已生成事项</span><strong>{{ progressCounts.total }}</strong>
+            </div>
+            <div class="task-detail-stat is-success">
+              <span>成功</span><strong>{{ detail.task.succeeded }}</strong>
+            </div>
+            <div class="task-detail-stat is-error">
+              <span>失败</span><strong>{{ detail.task.failed }}</strong>
+            </div>
+            <div class="task-detail-stat">
+              <span>无数据</span><strong>{{ detail.task.empty }}</strong>
+            </div>
+          </div>
+          <div class="task-detail-progress">
+            <div class="task-detail-progress-heading">
+              <strong
+                >已结束 {{ progressCounts.finished }} / 当前已生成
+                {{ progressCounts.total }} 项</strong
+              >
+              <span>{{ progressHint }}</span>
+            </div>
+            <div class="task-detail-progress-track" role="img" :aria-label="progressDescription">
+              <span
+                v-for="segment in progressSegments"
+                :key="segment.key"
+                :class="`progress-segment-${segment.key}`"
+                :style="{ width: `${segment.percent}%` }"
+              />
+            </div>
+            <div class="task-detail-progress-legend">
+              <span v-for="segment in progressLegend" :key="segment.key">
+                <i :class="`progress-dot-${segment.key}`" />{{ segment.label }} {{ segment.count }}
+              </span>
+            </div>
+          </div>
+        </div>
+
+        <NTabs v-model:value="detailTab" type="line" animated class="task-detail-tabs">
+          <NTabPane name="progress" :tab="`执行进度 (${progressCounts.total})`">
+            <div class="task-detail-section-heading task-detail-progress-title">
+              <div>
+                <h4>工作单元</h4>
+                <p>
+                  {{
+                    detailRefreshError
+                      ? '刷新失败,当前显示上次获取的状态。'
+                      : '查看当前已生成的全部事项;排队顺序不代表执行顺序。'
+                  }}
+                </p>
+              </div>
+              <NButton size="small" :loading="detailRefreshing" @click="refreshDetail(true)"
+                >刷新</NButton
+              >
+            </div>
+            <div class="task-detail-filters">
+              <NButton
+                v-for="filter in progressFilters"
+                :key="filter.value"
+                size="small"
+                :type="progressFilter === filter.value ? 'primary' : 'default'"
+                :secondary="progressFilter === filter.value"
+                :quaternary="progressFilter !== filter.value"
+                @click="progressFilter = filter.value"
+              >
+                {{ filter.label }} {{ filter.count }}
+              </NButton>
+            </div>
+            <NDataTable
+              :columns="progressColumns"
+              :data="visibleProgressItems"
+              :row-key="(row: DataDownloadTaskDetail['items'][number]) => row.id"
+              :scroll-x="1000"
+              :pagination="{
+                page: progressPage,
+                pageSize: progressPageSize,
+                showSizePicker: true,
+                pageSizes: [20, 50, 100],
+              }"
+              size="small"
+              striped
+              @update:page="progressPage = $event"
+              @update:page-size="setProgressPageSize"
+            />
+          </NTabPane>
+          <NTabPane name="documents" :tab="`任务目录 (${visibleDocumentCount})`">
+            <div class="task-detail-section-heading">
+              <div>
+                <h4>任务目录</h4>
+                <p>按站点、数据类型和日期查看已生成的文档。</p>
+              </div>
+            </div>
+            <NDataTable
+              :columns="documentColumns"
+              :data="documentTree"
+              :row-key="(row: DocumentTreeRow) => row.key"
+              :scroll-x="640"
+              default-expand-all
+              size="small"
+              striped
+            />
+          </NTabPane>
+          <NTabPane name="faultFiles" :tab="`录波文件 (${faultFileRows.length})`">
+            <div class="task-detail-section-heading">
+              <div>
+                <h4>FaultRecord 文件</h4>
+                <p>查看并下载已获取的故障录波原始文件。</p>
+              </div>
+            </div>
+            <NDataTable
+              :columns="faultFileColumns"
+              :data="faultFileRows"
+              :row-key="(row: FaultFileRow) => row.id"
+              :scroll-x="700"
+              size="small"
+              striped
+            />
+          </NTabPane>
+          <NTabPane name="failures" :tab="`失败项 (${failedItems.length})`">
+            <div class="task-detail-section-heading">
+              <div>
+                <h4>失败项</h4>
+                <p>查看执行失败或部分成功的工作单元及原因。</p>
+              </div>
+            </div>
+            <NDataTable
+              :columns="failureColumns"
+              :data="failedItems"
+              :scroll-x="600"
+              size="small"
+              striped
+            />
+          </NTabPane>
+          <NTabPane name="fileFailures" :tab="`文件请求失败 (${failedFileAttempts.length})`">
+            <div class="task-detail-section-heading">
+              <div>
+                <h4>文件请求失败</h4>
+                <p>查看失败阶段、尝试次数和具体原因。</p>
+              </div>
+            </div>
+            <NDataTable
+              :columns="fileFailureColumns"
+              :data="failedFileAttempts"
+              :scroll-x="780"
+              size="small"
+              striped
+            />
+          </NTabPane>
+        </NTabs>
+      </div>
+    </NModal>
+
+    <NModal
+      v-model:show="showCreate"
+      preset="card"
+      title="创建 DataDownload 任务"
+      class="max-w-3xl"
+    >
+      <NForm label-placement="left" label-width="110">
+        <NFormItem label="站点 SN">
+          <div class="w-full">
+            <NInput
+              v-model:value="form.snText"
+              type="textarea"
+              :rows="5"
+              placeholder="使用空格、逗号或换行分隔多个 SN"
+            /><input class="mt-2" type="file" accept=".txt,text/plain" @change="readSnFile" />
+            <div class="text-xs text-gray-500 mt-1">
+              UTF-8 TXT,每行一个 SN;允许 BOM,不支持表头和注释。
+            </div>
+          </div>
+        </NFormItem>
+        <NFormItem label="描述"><NInput v-model:value="form.description" /></NFormItem>
+        <NFormItem label="日期范围"
+          ><NDatePicker
+            v-model:formatted-value="dates"
+            type="daterange"
+            value-format="yyyy-MM-dd"
+            clearable
+        /></NFormItem>
+        <NFormItem label="数据类型">
+          <NSpace vertical>
+            <NSpace
+              ><NCheckbox v-model:checked="form.options.powerFlow">功率流图</NCheckbox
+              ><NCheckbox v-model:checked="form.options.eventAlarm">事件&告警</NCheckbox
+              ><NCheckbox v-model:checked="form.options.alarmSnapshot">告警快照</NCheckbox
+              ><NCheckbox v-model:checked="form.options.faultRecord">故障录波</NCheckbox></NSpace
+            >
+            <NSpace
+              ><NCheckbox v-model:checked="form.options.updateLog"
+                >升级记录(当前全量,不受日期范围限制)</NCheckbox
+              ></NSpace
+            >
+            <NSelect
+              v-model:value="form.options.mqttLogs"
+              multiple
+              filterable
+              clearable
+              :options="mqttLogOptions"
+              placeholder="MQTT 日志类型(可搜索多选)"
+            />
+            <NSelect
+              v-model:value="form.options.emsLogs"
+              multiple
+              filterable
+              clearable
+              :options="emsLogOptions"
+              placeholder="EMS 日志类型(可搜索多选)"
+            />
+          </NSpace>
+        </NFormItem>
+      </NForm>
+      <template #footer
+        ><div class="flex gap-2 justify-end">
+          <NButton @click="showCreate = false">取消</NButton
+          ><NButton type="primary" :loading="creating" @click="submit">创建</NButton>
+        </div></template
+      >
+    </NModal>
+  </div>
+</template>
+
+<script setup lang="tsx">
+import { NButton, NDropdown, NTag, type DataTableColumns, type SelectOption } from 'naive-ui'
+import dayjs from 'dayjs'
+import mqttLogNameList from '@/assets/data/mqttLogNameList.json'
+import { useUserStore } from '@/stores/user'
+import {
+  cancelDataDownload,
+  createDataDownload,
+  createDataDownloadPackage,
+  deleteDataDownload,
+  downloadDataDownloadTaskDocument,
+  downloadDataDownloadTaskFile,
+  getDataDownloadTask,
+  getDataDownloadTasks,
+  retryDataDownload,
+} from '@/service/dataDownload'
+import type {
+  DataDownloadFaultRecordEntry,
+  DataDownloadFileAttempt,
+  DataDownloadTask,
+  DataDownloadTaskDetail,
+  DataDownloadTaskInput,
+} from '@/service/dataDownload/interface'
+
+defineOptions({ name: 'DataDownloadTasks' })
+const BOM_PATTERN = /^\uFEFF/
+const LINE_BREAK_PATTERN = /\r?\n/
+const message = useMessage()
+const dialog = useDialog()
+const userStore = useUserStore()
+const loading = ref(false)
+const creating = ref(false)
+const showCreate = ref(false)
+const showDetail = ref(false)
+const detail = ref<DataDownloadTaskDetail>()
+const detailTab = ref('progress')
+type ProgressFilter = 'all' | 'running' | 'pending' | 'finished'
+const progressFilter = ref<ProgressFilter>('all')
+const progressPage = ref(1)
+const progressPageSize = ref(20)
+const detailRefreshing = ref(false)
+const detailRefreshError = ref(false)
+const scope = ref<'mine' | 'all'>('mine')
+const description = ref('')
+const snFilter = ref('')
+const statusFilter = ref<string | null>(null)
+const pageNum = ref(1)
+const pageSize = ref(20)
+const total = ref(0)
+const tasks = ref<DataDownloadTask[]>([])
+const dates = ref<[string, string] | null>([
+  dayjs().format('YYYY-MM-DD'),
+  dayjs().format('YYYY-MM-DD'),
+])
+const mqttLogOptions = Object.entries(mqttLogNameList.cloudLog.logType).map<SelectOption>(
+  ([key, value]) => ({ label: `${key} - ${value}`, value: key }),
+)
+const emsLogOptions: SelectOption[] = [
+  { label: 'IBG运行日志', value: 'EmsRunLog' },
+  { label: '电池日志', value: 'EmsBattLog' },
+  { label: '设备系统资源监控日志', value: 'EmsMonitorLog' },
+  { label: '4G日志', value: 'Ems4gLog' },
+  { label: 'AWS日志', value: 'EmsAwsLog' },
+  { label: '主进程日志', value: 'EmsMainLog' },
+]
+const scopeOptions = computed(() => [
+  { label: '我的任务', value: 'mine' },
+  ...(userStore.user.permissions.isAdmin ? [{ label: '所有任务', value: 'all' }] : []),
+])
+const statusLabels: Record<string, string> = {
+  pending: '等待中',
+  running: '执行中',
+  success: '已完成',
+  skipped: '已跳过',
+  cancelling: '取消中',
+  cancelled: '已取消',
+  complete: '已完成',
+  partial_success: '部分成功',
+  failed: '失败',
+  error: '错误',
+}
+const statusText = (status: string) => statusLabels[status] || status
+const statusTagType = (status: string) =>
+  status === 'complete' || status === 'success'
+    ? 'success'
+    : status === 'failed' || status === 'error'
+      ? 'error'
+      : status === 'cancelled'
+        ? 'default'
+        : 'warning'
+const formatDateTime = (value?: string) => (value ? dayjs(value).format('YYYY-MM-DD HH:mm') : '-')
+const formatDateRange = (task: DataDownloadTask) =>
+  `${task.startDate || '-'} ~ ${task.endDate || '-'}`
+const formatFileSize = (bytes: number) => {
+  if (!Number.isFinite(bytes) || bytes < 0) return '-'
+  if (bytes < 1024) return `${bytes} B`
+  const units = ['KB', 'MB', 'GB', 'TB']
+  const index = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)) - 1, units.length - 1)
+  return `${(bytes / 1024 ** (index + 1)).toFixed(1)} ${units[index]}`
+}
+// 任务筛选只使用任务状态;success 和 skipped 属于工作单元状态。
+const taskStatuses = [
+  'pending',
+  'running',
+  'cancelling',
+  'cancelled',
+  'complete',
+  'partial_success',
+  'failed',
+  'error',
+]
+const statusOptions = taskStatuses.map((value) => ({
+  label: statusLabels[value],
+  value,
+}))
+const form = reactive<DataDownloadTaskInput>({
+  snText: '',
+  description: '',
+  startDate: '',
+  endDate: '',
+  options: {
+    powerFlow: false,
+    mqttLogs: [],
+    eventAlarm: false,
+    alarmSnapshot: false,
+    emsLogs: [],
+    faultRecord: false,
+    updateLog: false,
+  },
+})
+
+const active = (status: string) => ['pending', 'running', 'cancelling'].includes(status)
+const terminal = (status: string) => !active(status)
+const columns: DataTableColumns<DataDownloadTask> = [
+  { title: '描述', key: 'description', minWidth: 160 },
+  { title: 'SN', key: 'sns', minWidth: 240, render: (row) => row.sns.join(', ') },
+  { title: 'Owner', key: 'ownerName', width: 130 },
+  {
+    title: '日期',
+    key: 'date',
+    width: 240,
+    render: (row) => `${row.startDate || '-'} ~ ${row.endDate || '-'}`,
+  },
+  {
+    title: '状态',
+    key: 'status',
+    width: 130,
+    render: (row) => (
+      <NTag
+        type={
+          row.status === 'complete'
+            ? 'success'
+            : row.status === 'failed' || row.status === 'error'
+              ? 'error'
+              : 'warning'
+        }
+      >
+        {statusText(row.status)}
+      </NTag>
+    ),
+  },
+  {
+    title: '进度',
+    key: 'progress',
+    width: 150,
+    render: (row) => `${row.succeeded}/${row.total}(空 ${row.empty})`,
+  },
+  {
+    title: '创建时间',
+    key: 'createdAt',
+    width: 180,
+    render: (row) => dayjs(row.createdAt).format('YYYY-MM-DD HH:mm:ss'),
+  },
+  {
+    title: '操作',
+    key: 'actions',
+    width: 150,
+    fixed: 'right',
+    render: (row) => (
+      <div class="flex gap-2">
+        <NButton size="small" onClick={() => openDetail(row)}>
+          详情
+        </NButton>
+        <NDropdown
+          trigger="click"
+          options={rowMenuOptions(row)}
+          onSelect={(key: string) => onRowMenuSelect(key, row)}
+        >
+          <NButton size="small">更多</NButton>
+        </NDropdown>
+      </div>
+    ),
+  },
+]
+
+function rowMenuOptions(row: DataDownloadTask) {
+  return [
+    { label: '打包', key: 'pack', disabled: row.succeeded < 1 },
+    { label: '取消', key: 'cancel', disabled: !active(row.status) },
+    {
+      label: '重试',
+      key: 'retry',
+      disabled: !['failed', 'partial_success', 'error'].includes(row.status),
+    },
+    { label: '删除', key: 'remove', disabled: !terminal(row.status) },
+  ]
+}
+function onRowMenuSelect(key: string, row: DataDownloadTask) {
+  if (key === 'pack') pack(row)
+  else if (key === 'cancel') cancel(row)
+  else if (key === 'retry') retry(row)
+  else if (key === 'remove') remove(row)
+}
+
+interface DetailRow {
+  documentId: string
+  logicalPath: string
+  empty: boolean
+  kind?: string
+  hasBinary?: boolean
+}
+// 文档目录树节点:分组节点(SN / 类型 / 设备)只有 name + children;
+// 叶子节点携带原始 DetailRow 信息(documentId 等),用于展示类型与下载。
+interface DocumentTreeRow {
+  key: string
+  name: string
+  leaf?: DetailRow
+  children?: DocumentTreeRow[]
+}
+const detailRows = computed<DetailRow[]>(() =>
+  (detail.value?.documents || []).map((link) => {
+    const document = detail.value?.catalog.find((item) => item.id === link.documentId)
+    return { ...link, ...(document || {}) }
+  }),
+)
+const visibleDocumentCount = computed(
+  () =>
+    detailRows.value.filter((row) => !row.empty && row.logicalPath?.split('/').some(Boolean))
+      .length,
+)
+const failedItems = computed(() =>
+  (detail.value?.items || []).filter(
+    (item) => item != null && ['failed', 'partial_success'].includes(item.status),
+  ),
+)
+const failedFileAttempts = computed(() =>
+  (detail.value?.fileAttempts || []).filter((item) => item != null && item.status === 'failed'),
+)
+type ProgressItem = DataDownloadTaskDetail['items'][number]
+const progressGroup = (status: string): Exclude<ProgressFilter, 'all'> | 'other' => {
+  if (status === 'pending') return 'pending'
+  if (status === 'running' || status === 'cancelling') return 'running'
+  if (
+    ['success', 'complete', 'skipped', 'partial_success', 'failed', 'error', 'cancelled'].includes(
+      status,
+    )
+  ) {
+    return 'finished'
+  }
+  return 'other'
+}
+const progressCounts = computed(() => {
+  const counts = {
+    total: 0,
+    pending: 0,
+    running: 0,
+    success: 0,
+    partial: 0,
+    failed: 0,
+    cancelled: 0,
+    other: 0,
+    finished: 0,
+  }
+  for (const item of detail.value?.items || []) {
+    if (!item) continue
+    counts.total++
+    if (item.status === 'pending') counts.pending++
+    else if (item.status === 'running' || item.status === 'cancelling') counts.running++
+    else if (['success', 'complete', 'skipped'].includes(item.status)) counts.success++
+    else if (item.status === 'partial_success') counts.partial++
+    else if (item.status === 'failed' || item.status === 'error') counts.failed++
+    else if (item.status === 'cancelled') counts.cancelled++
+    else counts.other++
+  }
+  counts.finished = counts.success + counts.partial + counts.failed + counts.cancelled
+  return counts
+})
+const progressSegments = computed(() => {
+  const counts = progressCounts.value
+  return [
+    { key: 'success', label: '完成', count: counts.success },
+    { key: 'partial', label: '部分成功', count: counts.partial },
+    { key: 'failed', label: '失败', count: counts.failed },
+    { key: 'running', label: '进行中', count: counts.running },
+    { key: 'pending', label: '排队中', count: counts.pending },
+    { key: 'cancelled', label: '已取消', count: counts.cancelled },
+    { key: 'other', label: '其他', count: counts.other },
+  ].map((segment) => ({
+    ...segment,
+    percent: counts.total ? (segment.count / counts.total) * 100 : 0,
+  }))
+})
+const progressLegend = computed(() =>
+  progressSegments.value.filter(
+    (segment) => segment.count > 0 || segment.key === 'running' || segment.key === 'pending',
+  ),
+)
+const progressHint = computed(() => {
+  const status = detail.value?.task.status
+  if (status === 'pending') return '等待生成任务清单'
+  if (status === 'cancelling') return '任务取消中'
+  if (status === 'running') {
+    return progressCounts.value.total === 0 ? '正在生成任务清单' : '各项并行执行,清单可能继续增加'
+  }
+  return '任务已结束'
+})
+const progressDescription = computed(
+  () =>
+    `当前已生成 ${progressCounts.value.total} 项,${progressSegments.value.map((segment) => `${segment.label} ${segment.count} 项`).join(',')}`,
+)
+const progressFilters = computed<Array<{ value: ProgressFilter; label: string; count: number }>>(
+  () => [
+    { value: 'all', label: '全部', count: progressCounts.value.total },
+    { value: 'running', label: '进行中', count: progressCounts.value.running },
+    { value: 'pending', label: '排队中', count: progressCounts.value.pending },
+    { value: 'finished', label: '已结束', count: progressCounts.value.finished },
+  ],
+)
+const visibleProgressItems = computed(() =>
+  (detail.value?.items || [])
+    .filter(
+      (item): item is ProgressItem =>
+        item != null &&
+        (progressFilter.value === 'all' || progressGroup(item.status) === progressFilter.value),
+    )
+    .slice()
+    .sort((a, b) => {
+      const order = { running: 0, pending: 1, other: 2, finished: 3 }
+      return order[progressGroup(a.status)] - order[progressGroup(b.status)]
+    }),
+)
+watch(progressFilter, () => {
+  progressPage.value = 1
+})
+watch(
+  () => visibleProgressItems.value.length,
+  (count) => {
+    progressPage.value = Math.min(
+      progressPage.value,
+      Math.max(1, Math.ceil(count / progressPageSize.value)),
+    )
+  },
+)
+function setProgressPageSize(value: number) {
+  progressPageSize.value = value
+  progressPage.value = 1
+}
+const kindLabels: Record<string, string> = {
+  power_flow: '功率流图',
+  mqtt_log: 'MQTT 日志',
+  event_alarm: '事件与告警',
+  alarm_snapshot: '告警快照',
+  ems_log: 'EMS 日志',
+  fault_record: '故障录波',
+  update_log: '升级记录',
+  site_validation: '站点校验',
+}
+const emsLogLabelByValue = Object.fromEntries(
+  emsLogOptions.map((option) => [String(option.value), String(option.label)]),
+)
+const progressItemSubtitle = (item: ProgressItem) => {
+  if (item.kind === 'fault_record' && item.deviceSn) {
+    return `设备 ${item.deviceSn}${item.deviceType ? ` · ${item.deviceType}` : ''}`
+  }
+  if (item.kind === 'ems_log' && item.subtype) {
+    return emsLogLabelByValue[item.subtype] || item.subtype
+  }
+  return item.subtype || ''
+}
+const stageLabels: Record<string, string> = {
+  waiting: '等待资源',
+  validation: '校验站点',
+  fetch: '获取数据',
+  planning: '规划中',
+  enqueue: '入队',
+  complete: '完成',
+  cancelled: '已取消',
+}
+const progressColumns: DataTableColumns<ProgressItem> = [
+  { title: '站点 SN', key: 'siteSn', width: 170, ellipsis: { tooltip: true } },
+  {
+    title: '事项',
+    key: 'kind',
+    minWidth: 190,
+    render: (row) => (
+      <div>
+        <div>{kindLabels[row.kind] || row.kind}</div>
+        {progressItemSubtitle(row) && (
+          <div class="progress-subtype">{progressItemSubtitle(row)}</div>
+        )}
+      </div>
+    ),
+  },
+  { title: '日期', key: 'date', width: 120, render: (row) => row.date || '—' },
+  {
+    title: '状态',
+    key: 'status',
+    width: 110,
+    render: (row) => (
+      <NTag size="small" type={statusTagType(row.status)}>
+        {statusText(row.status)}
+      </NTag>
+    ),
+  },
+  { title: '阶段', key: 'stage', width: 115, render: (row) => stageLabels[row.stage || ''] || '—' },
+  {
+    title: '最近更新',
+    key: 'updatedAt',
+    width: 160,
+    render: (row) => formatDateTime(row.updatedAt),
+  },
+  {
+    title: '原因',
+    key: 'failureReason',
+    minWidth: 180,
+    ellipsis: { tooltip: true },
+    render: (row) => row.failureReason || '—',
+  },
+]
+// 按 logicalPath 的天然层级(SN / 类型 / [设备] / 日期)聚合成树;过滤空文档。
+const documentTree = computed<DocumentTreeRow[]>(() => {
+  const roots: DocumentTreeRow[] = []
+  for (const row of detailRows.value) {
+    if (row.empty) continue // 空文档不在目录树中显示
+    const segments = (row.logicalPath || '').split('/').filter(Boolean)
+    if (segments.length === 0) continue
+    let level = roots
+    let keyPrefix = ''
+    segments.forEach((segment, index) => {
+      keyPrefix = keyPrefix ? `${keyPrefix}/${segment}` : segment
+      const isLeaf = index === segments.length - 1
+      let node = level.find((item) => item.key === keyPrefix)
+      if (!node) {
+        node = { key: keyPrefix, name: segment }
+        if (!isLeaf) node.children = []
+        level.push(node)
+      }
+      if (isLeaf) {
+        node.leaf = row
+      } else {
+        if (!node.children) node.children = []
+        level = node.children
+      }
+    })
+  }
+  return roots
+})
+const documentColumns: DataTableColumns<DocumentTreeRow> = [
+  {
+    title: '名称',
+    key: 'name',
+    minWidth: 360,
+    render: (row) => (
+      <span class={row.leaf ? undefined : 'tree-folder-name'} title={row.name}>
+        {row.name}
+      </span>
+    ),
+  },
+  { title: '类型', key: 'kind', width: 140, render: (row) => row.leaf?.kind ?? '—' },
+  {
+    title: '操作',
+    key: 'action',
+    width: 100,
+    render: (row) => {
+      const leaf = row.leaf
+      if (!leaf) return null
+      return (
+        <NButton
+          size="small"
+          disabled={leaf.empty || leaf.kind === 'fault_record'}
+          type="primary"
+          quaternary
+          onClick={() =>
+            detail.value &&
+            downloadDataDownloadTaskDocument(
+              detail.value.task.id,
+              leaf.documentId,
+              leaf.hasBinary ? undefined : leaf.kind === 'power_flow' ? 'html' : 'xlsx',
+            )
+          }
+        >
+          下载
+        </NButton>
+      )
+    },
+  },
+]
+const failureColumns: DataTableColumns<DataDownloadTaskDetail['items'][number]> = [
+  { title: '工作单元', key: 'itemKey' },
+  { title: '失败原因', key: 'failureReason' },
+]
+interface FaultFileRow {
+  id: string
+  documentId: string
+  fileName: string
+  size: number
+  lastSuccessfulAt: string
+}
+const faultFileRows = computed<FaultFileRow[]>(() =>
+  Object.values(detail.value?.faultFiles || {})
+    .filter((entries): entries is DataDownloadFaultRecordEntry[] => Array.isArray(entries))
+    .flat()
+    .filter((entry): entry is DataDownloadFaultRecordEntry => entry != null),
+)
+const faultFileColumns: DataTableColumns<FaultFileRow> = [
+  { title: '文件名', key: 'fileName', minWidth: 300, ellipsis: { tooltip: true } },
+  { title: '大小', key: 'size', width: 120, render: (row) => formatFileSize(row.size) },
+  {
+    title: '最后成功时间',
+    key: 'lastSuccessfulAt',
+    width: 180,
+    render: (row) => dayjs(row.lastSuccessfulAt).format('YYYY-MM-DD HH:mm:ss'),
+  },
+  {
+    title: '操作',
+    key: 'action',
+    width: 100,
+    render: (row) => (
+      <NButton
+        size="small"
+        type="primary"
+        quaternary
+        onClick={() => detail.value && downloadDataDownloadTaskFile(detail.value.task.id, row.id)}
+      >
+        下载
+      </NButton>
+    ),
+  },
+]
+const fileFailureColumns: DataTableColumns<DataDownloadFileAttempt> = [
+  {
+    title: '文件',
+    key: 'fileName',
+    minWidth: 180,
+    render: (row) => row.fileName || row.sourceFileId,
+  },
+  { title: '阶段', key: 'failureStage', width: 100 },
+  { title: '尝试次数', key: 'attemptCount', width: 100 },
+  { title: '失败原因', key: 'failureReason', minWidth: 260 },
+]
+
+// 刷新间隔(毫秒)。默认 30s;创建/重试任务后重置为 5s,之后每次刷新 +5s 递增,封顶 30s。
+const REFRESH_MIN = 5000
+const REFRESH_MAX = 30000
+const REFRESH_STEP = 5000
+let refreshInterval = REFRESH_MAX
+// 创建/重试任务时调用:重置刷新间隔,使轮询从 5s 开始递增。
+function resetRefreshInterval() {
+  refreshInterval = REFRESH_MIN
+}
+
+let latestLoadId = 0
+function applyFilters() {
+  pageNum.value = 1
+  void load()
+}
+function onPageChange(value: number) {
+  pageNum.value = value
+  void load()
+}
+function onPageSizeChange(value: number) {
+  pageSize.value = value
+  applyFilters()
+}
+watch([scope, statusFilter], applyFilters)
+
+async function load() {
+  const loadId = ++latestLoadId
+  loading.value = true
+  try {
+    const data = await getDataDownloadTasks(
+      pageNum.value,
+      pageSize.value,
+      scope.value,
+      description.value.trim(),
+      snFilter.value.trim(),
+      statusFilter.value || '',
+    )
+    if (loadId !== latestLoadId) return
+    tasks.value = (data.items || []).filter((item): item is DataDownloadTask => item != null)
+    total.value = data.total || 0
+  } finally {
+    if (loadId === latestLoadId) loading.value = false
+  }
+}
+async function submit() {
+  if (!form.snText.trim()) return message.warning('请输入至少一个 SN')
+  form.startDate = dates.value?.[0] || ''
+  form.endDate = dates.value?.[1] || ''
+  creating.value = true
+  try {
+    await createDataDownload(form)
+    message.success('任务已创建')
+    showCreate.value = false
+    resetRefreshInterval()
+    await load()
+  } catch (error) {
+    message.error(String(error))
+  } finally {
+    creating.value = false
+  }
+}
+async function readSnFile(event: Event) {
+  const input = event.target as HTMLInputElement
+  const file = input.files?.[0]
+  if (!file) return
+  const text = (await file.text()).replace(BOM_PATTERN, '')
+  const values = text
+    .split(LINE_BREAK_PATTERN)
+    .map((value) => value.trim())
+    .filter(Boolean)
+  form.snText = [form.snText, ...values].filter(Boolean).join('\n')
+  input.value = ''
+}
+async function cancel(row: DataDownloadTask) {
+  await cancelDataDownload(row.id)
+  message.success('已请求取消')
+  await load()
+}
+async function retry(row: DataDownloadTask) {
+  await retryDataDownload(row.id)
+  message.success('失败项已重新入队')
+  resetRefreshInterval()
+  await load()
+}
+async function pack(row: DataDownloadTask) {
+  const job = await createDataDownloadPackage([row.id])
+  message.success(`打包任务已创建:${job.filename}`)
+}
+async function openDetail(row: DataDownloadTask) {
+  try {
+    detail.value = await getDataDownloadTask(row.id)
+    detailTab.value = 'progress'
+    progressFilter.value = 'all'
+    progressPage.value = 1
+    detailRefreshError.value = false
+    showDetail.value = true
+    scheduleDetailRefresh()
+  } catch (error) {
+    message.error(`获取任务明细失败:${String(error)}`)
+  }
+}
+function onDetailShowChange(value: boolean) {
+  showDetail.value = value
+  if (!value) {
+    window.clearTimeout(detailRefreshTimer)
+    detail.value = undefined
+  }
+}
+let detailRefreshTimer: number | undefined
+function scheduleDetailRefresh() {
+  window.clearTimeout(detailRefreshTimer)
+  if (!showDetail.value || !detail.value || !active(detail.value.task.status)) return
+  detailRefreshTimer = window.setTimeout(() => void refreshDetail(), 30_000)
+}
+async function refreshDetail(manual = false) {
+  const taskID = detail.value?.task.id
+  if (!showDetail.value || !taskID || detailRefreshing.value) return
+  window.clearTimeout(detailRefreshTimer)
+  detailRefreshing.value = true
+  try {
+    const updated = await getDataDownloadTask(taskID)
+    if (showDetail.value && detail.value?.task.id === taskID) {
+      detail.value = updated
+      detailRefreshError.value = false
+    }
+  } catch (error) {
+    detailRefreshError.value = true
+    if (manual) message.error(`刷新任务明细失败:${String(error)}`)
+  } finally {
+    detailRefreshing.value = false
+    scheduleDetailRefresh()
+  }
+}
+function remove(row: DataDownloadTask) {
+  dialog.warning({
+    title: '确认删除',
+    content: `删除任务“${row.description || row.id}”?共享文档不会被删除。`,
+    positiveText: '删除',
+    negativeText: '取消',
+    onPositiveClick: async () => {
+      await deleteDataDownload(row.id)
+      message.success('已删除')
+      await load()
+    },
+  })
+}
+
+onMounted(load)
+let timer: number | undefined
+watch(
+  tasks,
+  (value) => {
+    window.clearTimeout(timer)
+    if (value.some((row) => active(row.status))) {
+      timer = window.setTimeout(load, refreshInterval)
+      // 按刷新次数递增,封顶 30s
+      refreshInterval = Math.min(refreshInterval + REFRESH_STEP, REFRESH_MAX)
+    } else {
+      // 无活跃任务,停止轮询并恢复默认间隔
+      refreshInterval = REFRESH_MAX
+    }
+  },
+  { deep: true },
+)
+onBeforeUnmount(() => {
+  window.clearTimeout(timer)
+  window.clearTimeout(detailRefreshTimer)
+})
+</script>
+
+<style scoped>
+.task-detail {
+  max-height: min(72vh, 780px);
+  overflow-y: auto;
+  padding-right: 4px;
+}
+
+.task-detail-overview {
+  padding: 20px 22px;
+  border: 1px solid color-mix(in srgb, currentColor 10%, transparent);
+  border-radius: 12px;
+  background: color-mix(in srgb, currentColor 3%, transparent);
+}
+
+.task-detail-heading {
+  display: flex;
+  align-items: flex-start;
+  justify-content: space-between;
+  gap: 20px;
+}
+
+.task-detail-heading-main {
+  min-width: 0;
+}
+
+.task-detail-eyebrow {
+  margin-bottom: 6px;
+  font-size: 12px;
+  color: #888;
+}
+
+.task-detail-heading h3 {
+  margin: 0;
+  font-size: 18px;
+  font-weight: 600;
+  line-height: 1.4;
+  overflow-wrap: anywhere;
+}
+
+.task-detail-meta {
+  display: flex;
+  flex-wrap: wrap;
+  gap: 4px 18px;
+  margin-top: 10px;
+  font-size: 12px;
+  color: #888;
+}
+
+.task-detail-stats {
+  display: grid;
+  grid-template-columns: repeat(5, minmax(0, 1fr));
+  gap: 10px;
+  margin-top: 18px;
+}
+
+.task-detail-stat {
+  display: flex;
+  flex-direction: column;
+  gap: 4px;
+  padding: 10px 12px;
+  border-radius: 8px;
+  background: color-mix(in srgb, currentColor 5%, transparent);
+}
+
+.task-detail-stat span {
+  font-size: 12px;
+  color: #888;
+}
+
+.task-detail-stat strong {
+  font-size: 20px;
+  font-weight: 600;
+  line-height: 1.2;
+}
+
+.task-detail-stat.is-success strong {
+  color: #18a058;
+}
+
+.task-detail-stat.is-error strong {
+  color: #d03050;
+}
+
+.task-detail-progress {
+  margin-top: 18px;
+}
+
+.task-detail-progress-heading,
+.task-detail-progress-title {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  gap: 12px;
+}
+
+.task-detail-progress-heading strong {
+  font-size: 13px;
+  font-weight: 600;
+}
+
+.task-detail-progress-heading > span {
+  font-size: 12px;
+  color: #888;
+}
+
+.task-detail-progress-track {
+  display: flex;
+  height: 10px;
+  margin-top: 10px;
+  overflow: hidden;
+  border-radius: 999px;
+  background: color-mix(in srgb, currentColor 10%, transparent);
+}
+
+.task-detail-progress-track span {
+  display: block;
+  height: 100%;
+  flex: none;
+}
+
+.task-detail-progress-legend {
+  display: flex;
+  flex-wrap: wrap;
+  gap: 6px 16px;
+  margin-top: 10px;
+  font-size: 12px;
+  color: #888;
+}
+
+.task-detail-progress-legend > span {
+  display: inline-flex;
+  align-items: center;
+  gap: 5px;
+}
+
+.task-detail-progress-legend i {
+  display: inline-block;
+  width: 7px;
+  height: 7px;
+  border-radius: 50%;
+}
+
+.progress-segment-success,
+.progress-dot-success {
+  background: #18a058;
+}
+.progress-segment-partial,
+.progress-dot-partial {
+  background: #f0a020;
+}
+.progress-segment-failed,
+.progress-dot-failed {
+  background: #d03050;
+}
+.progress-segment-running,
+.progress-dot-running {
+  background: #2080f0;
+}
+.progress-segment-pending,
+.progress-dot-pending {
+  background: #a0a4ad;
+}
+.progress-segment-cancelled,
+.progress-dot-cancelled {
+  background: #626670;
+}
+.progress-segment-other,
+.progress-dot-other {
+  background: #8a6fc4;
+}
+
+.task-detail-tabs {
+  margin-top: 18px;
+}
+
+.task-detail-section-heading {
+  margin: 6px 0 16px;
+}
+
+.task-detail-section-heading h4 {
+  margin: 0;
+  font-size: 15px;
+  font-weight: 600;
+}
+
+.task-detail-section-heading p {
+  margin: 4px 0 0;
+  font-size: 12px;
+  color: #888;
+}
+
+.task-detail-filters {
+  display: flex;
+  flex-wrap: wrap;
+  gap: 6px;
+  margin-bottom: 14px;
+}
+
+.progress-subtype {
+  margin-top: 2px;
+  font-size: 12px;
+  color: #888;
+}
+
+.tree-folder-name {
+  font-weight: 600;
+}
+
+@media (max-width: 640px) {
+  .task-detail-overview {
+    padding: 16px;
+  }
+
+  .task-detail-stats {
+    grid-template-columns: repeat(3, minmax(0, 1fr));
+  }
+}
+</style>

+ 39 - 0
src/views/data-download/packages.vue

@@ -0,0 +1,39 @@
+<template>
+  <div class="p-4">
+    <NCard title="待下载数据">
+      <NDataTable :columns="columns" :data="jobs" :loading="loading" :row-key="(row: DataDownloadPackageJob) => row.id" />
+      <div class="mt-4 flex justify-end"><NPagination v-model:page="page" :item-count="total" @update:page="load" /></div>
+    </NCard>
+  </div>
+</template>
+
+<script setup lang="tsx">
+import { NButton, NTag, type DataTableColumns } from 'naive-ui'
+import dayjs from 'dayjs'
+import { cancelDataDownloadPackage, deleteDataDownloadPackage, downloadDataDownloadPackage, getDataDownloadPackages, retryDataDownloadPackage } from '@/service/dataDownload'
+import type { DataDownloadPackageJob } from '@/service/dataDownload/interface'
+
+defineOptions({ name: 'DataDownloadPackages' })
+const message = useMessage()
+const jobs = ref<DataDownloadPackageJob[]>([])
+const loading = ref(false)
+const page = ref(1)
+const total = ref(0)
+const columns: DataTableColumns<DataDownloadPackageJob> = [
+  { title: '文件名', key: 'filename', minWidth: 260 },
+  { title: '状态', key: 'status', width: 130, render: row => <NTag type={row.status === 'complete' ? 'success' : row.status === 'failed' ? 'error' : 'warning'}>{row.status}</NTag> },
+  { title: '进度', key: 'progress', width: 100, render: row => `${row.doneCount}/${row.totalCount}` },
+  { title: '大小', key: 'size', width: 100, render: row => row.size ? `${(row.size / 1024 / 1024).toFixed(2)} MB` : '-' },
+  { title: '创建时间', key: 'createdAt', width: 180, render: row => dayjs(row.createdAt).format('YYYY-MM-DD HH:mm:ss') },
+  { title: '过期时间', key: 'expiresAt', width: 180, render: row => row.expiresAt ? dayjs(row.expiresAt).format('YYYY-MM-DD HH:mm:ss') : '-' },
+  { title: '操作', key: 'action', width: 250, render: row => <div class="flex gap-2"><NButton size="small" type="primary" disabled={row.status !== 'complete'} onClick={() => downloadDataDownloadPackage(row.id)}>下载</NButton><NButton size="small" disabled={row.status !== 'failed'} onClick={() => retry(row)}>重试</NButton><NButton size="small" disabled={!['pending', 'running'].includes(row.status)} onClick={() => cancel(row)}>取消</NButton><NButton size="small" type="error" disabled={['pending', 'running', 'cancelling'].includes(row.status)} onClick={() => remove(row)}>删除</NButton></div> },
+]
+async function load() { loading.value = true; try { const data = await getDataDownloadPackages(page.value, 20); jobs.value = data.items || []; total.value = data.total || 0 } finally { loading.value = false } }
+async function cancel(row: DataDownloadPackageJob) { await cancelDataDownloadPackage(row.id); message.success('已请求取消'); await load() }
+async function retry(row: DataDownloadPackageJob) { await retryDataDownloadPackage(row.id); message.success('已创建新的打包任务'); await load() }
+async function remove(row: DataDownloadPackageJob) { await deleteDataDownloadPackage(row.id); message.success('已删除 ZIP 及记录'); await load() }
+onMounted(load)
+let timer: number | undefined
+watch(jobs, value => { clearTimeout(timer); if (value.some(row => ['pending', 'running', 'cancelling'].includes(row.status))) timer = window.setTimeout(load, 5000) }, { deep: true })
+onBeforeUnmount(() => clearTimeout(timer))
+</script>

Daži faili netika attēloti, jo izmaiņu fails ir pārāk liels