# 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。