technical-design.md 12 KB

DataDownload 技术设计

文档类型:目标技术设计。产品语义见 requirements.md,当前代码差异见 implementation-status.md。

1. 模块边界

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。

DocumentID = SHA-256(RequestKey)

任务内部 ItemKey 仅用于幂等规划,不能代替 RequestKey。

4. 文档状态与 Frozen

文档状态为 Mutable、Frozen、Dynamic,另有 Completeness=complete|partial。

日期 D 的 Frozen 判定:

当前站点本地时间 >= 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)
获取 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 三者出现可命中的旧组合:

取得写租约
  -> 使旧 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 为输入,返回:

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。