data-download-document-service-design.md 76 KB

DataDownload 新模块与文档缓存设计:交接记录

状态:设计决策已闭合,尚未按本文实施业务代码。本文已合并截至 2026-09-19 确认的独立新模块、全球站点时区、按日拆分、D+2 固化、PostgreSQL 仅保存当前结构化正文、数据读写互锁、按需 Renderer 与 3 天不可变派生文件缓存、单写入渠道、任务权限、取消/重试、MCP、空文档、异步 ZIP、ZIP 完成后保留 7 天、PackageJob 人工删除规则、不允许未来日期、600 天日期跨度、预览格式、MinIO Repository、MongoDB 官方 URI 解析和通用 Repository 初始化等决策。

用途:供后续会话实施。本文记录已经确认的边界、接口、数据结构、并发语义和现有代码问题;剩余重试次数、租约时长、轮询频率等均为部署或实现参数,不再作为产品待决策事项。

1. 背景、现状与新模块边界

当前批量下载任务保存在 MongoDB BatchDownloadConfig 集合中,任务记录同时包含:

  • 请求配置;
  • 任务进度和工作单元 items;
  • 按 SN 组织的 MinIO 文件路径 data。

实际文件使用类似以下路径:

BatchDownloadData/{SN}/{Type}/{FileName}

当前处理链路:

前端创建任务
  -> MongoDB BatchDownloadConfig
  -> Asynq 主任务
  -> 同步/异步获取数据并生成文件
  -> MinIO(按站点 SN 分类)
  -> 将 MinIO 路径写回任务的 data 字段
  -> 下载时实时组装 ZIP

现有 BatchDownload 作为旧模块完整保留:

  • 旧代码、旧 API、旧页面和旧数据不修改;
  • 旧模块继续保持原有功能;
  • 不迁移旧任务和旧 MinIO 文件;
  • 新旧文件、MongoDB collection、Redis key、队列类型和对象存储前缀完全隔离;
  • MCP 只查询和创建新模块任务,不使用旧 BatchDownload;
  • 本文后续设计全部属于独立的新模块 DataDownload。

建议的新模块命名空间:

/api/dataDownload/*
DataDownloadTask
DataDownloadItem
DataDownloadTaskDocument
DataDownloadDocument
DataDownloadRenderSlot
DataDownloadRenderArtifact
DataDownloadPackageJob
data-download:request:{requestKey}
DataDownloadBinary/...
DataDownloadRenderCache/...
DataDownloadPackage/...

旧模块主要代码位置(仅供参考,本次不在这些位置改造旧功能):

  • service/domain/batchDownloadDomain.go
  • service/repositories/mongo/batchDownloadRepo.go
  • service/services/batchDownloadServices.go
  • service/jobs/saveBatchDownloadData.go
  • service/task/queue/fwhQueue/fwhTaskBatchDownload.go
  • service/task/queue/fwhQueue/fwhTaskCallFile.go
  • src/views/batch-download/index.vue

旧 BatchDownloadData 嵌套结构、ExistsBatchDownloadXxx/StatBatchDownloadXxx 路径检查和 Redis CallFile 仓储都不复用于 DataDownload。由于旧模块必须继续运行,本次也不删除其仍在使用的代码或数据;只有未来单独下线旧模块时,才能在调用确认后清理这些依赖。

2. 已识别的现有问题

2.1 文件生命周期与任务生命周期不一致

任务由任务 ID 标识,但 MinIO 对象按站点、类型和日期命名,路径中没有任务 ID。多个任务可能:

  • 引用同一个对象;
  • 并发覆盖同一个对象;
  • 后创建的任务改变旧任务下载到的内容;
  • 对带时间戳的文件产生内容相同但路径不同的副本;
  • 删除任务时无法安全判断能否删除实际文件。

新方案允许“未固化逻辑文档”在后续请求中被有控制地覆盖,因此旧任务再次下载未固化文档时可能看到刷新后的内容;这里的问题是现状缺少明确的文档身份、固化边界和统一写入协调,而不是要求所有历史任务都永久保留原始字节快照。

2.2 删除功能未完成

  • 前端 src/views/batch-download/index.vue 中的 itemDelete 仍为 TODO。
  • 后端 DeleteBatchDownloadConfig 只删除 MongoDB 任务记录。
  • MinIO 文件、队列任务及其他运行状态没有清理或取消。

2.3 删除后任务可能被 worker 重新创建

多个 MongoDB 更新方法使用 SetUpsert(true),包括状态更新、文件路径写入和 items 初始化。若任务记录已删除而 worker 仍在执行,后续写入可能用原 _id 创建一条残缺任务。

旧 BatchDownload 的这一问题只作为新模块设计参考,本次不修改旧 worker。新 DataDownload worker 的任务状态、item 和文档绑定写入不得使用 upsert,并应检查匹配结果及任务状态。

2.4 两套事实来源

  • items 保存工作单元状态;
  • data 保存产物路径。

两者分别写入,可能出现文件已上传但路径写库失败、item 成功但没有文件、文件存在但 item 失败等不一致状态。

2.5 其他已发现问题

  • 创建任务后没有处理 EnqueueBatchDownloadTask 返回的错误。
  • ZIP 打包逻辑遗漏 AlarmSnapshot。
  • MQTT 路径写入处的 date 和 logType 参数顺序疑似颠倒。
  • 单文件下载和压缩包浏览接口直接接受 MinIO filePath,未通过稳定的文档 ID 定位。
  • 尚未发现批量下载生命周期、并发覆盖和删除相关的自动化测试。

3. 已确认的设计决策

系统拆分为三个职责不同的模块:

Task Module
    -> FileRequestService
        -> DocumentService

3.1 Task Module

任务模块负责:

  • 创建、执行、取消和删除批量下载任务;
  • 描述需要的数据;
  • 在需要时向业务数据源请求数据;
  • 对文件型文档生成文件;对数据库型文档提交结构化数据;
  • 保存任务与 DocumentID 的关系及 ZIP 内的逻辑路径;
  • 根据任务自己的文档清单组装下载包;
  • 校验并去重 SN,记录无效 SN 对应的失败 item;
  • 管理任务 owner、描述、取消、失败项重试、权限和总体状态;
  • 发起异步 ZIP 打包任务,但不负责共享文档缓存策略。

任务模块不负责:

  • 判断文档是否可变;
  • 判断缓存是否过期;
  • 生成缓存键;
  • 管理 MinIO 对象路径;
  • 管理共享缓存的覆盖规则。

建议任务侧保存:

type TaskDocument struct {
	ItemKey     string
	DocumentID  string
	LogicalPath string
}

任务与文档的引用关系归任务模块管理,DocumentService 不感知具体任务,也不维护引用计数。

由于 DataDownload 不限制站点数量,items 和任务文档清单不得继续嵌入一个 MongoDB 主任务文档,应使用独立 collection:

DataDownloadTask
DataDownloadItem
DataDownloadTaskDocument

3.2 FileRequestService

这是并发请求协调层,解决“多个任务同时需要刷新同一个文档,于是一起请求相同数据”的问题。

它负责:

  • 合并相同文档请求;
  • 第一个调用者获得数据获取租约;
  • 后续调用者保持 item 为 pending,并通过延迟任务稍后重新检查;
  • 请求完成后让延迟重试者使用同一个文档,不长期占用 worker;
  • 请求失败后释放租约,允许重新竞争;
  • 使用租约 TTL/续租防止 worker 崩溃后永久锁死;
  • 支持跨进程协调,预期基于 Redis,而不是仅使用进程内 singleflight。

它不负责具体业务数据的获取和文件生成。取得租约的任务负责请求数据,并按文档类型生成文件或准备结构化数据。

已确认系统中的文档内容不支持人工编辑,且只有这一条受控写入链路。FileRequestService 是共享文档写入的统一入口。

当已有相同 RequestKey 的租约时,调用方不长期阻塞 worker,而是结束本次执行并通过 Asynq 延迟任务重新检查。建议采用 15、30、60 秒等退避间隔。租约续期由 FileRequestService 统一自动完成,不要求各数据类型手写续租循环。

为了区分“同一轮共享请求的延迟等待者”和“稍后到达的新任务”,每轮请求还必须有独立 RequestID:

  • Mutable 文档的等待者携带 AwaitRequestID 重试;该轮完成后直接复用其结果,不再重复请求;
  • 后续没有 AwaitRequestID 的新任务仍按 Mutable 规则重新请求和覆盖;
  • Dynamic(UpdateLog)只借助租约串行,不复用上一轮结果;前一轮结束后仍由当前任务创建新 RequestID 并查询;
  • 请求失败时记录该轮失败并释放租约,调用者按有上限的 Asynq 退避策略自动重试;自动重试耗尽后 item 才记 failed,具体次数和间隔作为部署配置。

3.3 DocumentService

DocumentService 负责文档缓存、结构化数据存储和文件存储,但不负责从业务数据源获取数据。

它负责:

  • 根据文档类型和查询条件计算稳定的文档身份;
  • 管理各文档类型的可变/不可变策略;
  • 将范围请求按文档策略拆成按日文档单元;
  • 判断现有文档是需要刷新还是已经固化;
  • 保存任务提交的结构化数据或已经生成好的文件;
  • 对数据库型文档,在下载时根据已保存的结构化数据生成导出文件;
  • 对未固化文档允许新请求覆盖当前缓存内容;
  • 对固化文档禁止后续覆盖;
  • 对查询成功但没有数据的文档保存空结果元数据,以表示该文档单元已经查询过;
  • 通过 DocumentID 提供读取和元信息查询,不向调用者暴露 MinIO 路径。

它明确不负责:

  • 调用上游业务数据获取接口;
  • 向业务数据源发请求;
  • 任务生命周期;
  • 任务与文档的引用关系;
  • 自动清理无引用文件;
  • 自动垃圾回收;
  • 记录缓存命中、生成失败等业务运行历史。

仍需保留用于正确性判断的文档元数据,例如 DataDate、GeneratedAt、FreezeAt、State、校验值和创建时间。这些是缓存状态,不是运行历史。

3.4 清理策略

现阶段不实现自动清理和 GC,物理删除由人工管理。

重要约束:由于引用关系归任务模块管理,DocumentService 无法单独判断一个文档是否仍被任务使用。人工物理删除可能破坏历史任务。管理员不提供手工“数据失效”或“清除生成文件缓存”操作;缓存换代全部由正文更新流程自动执行。物理删除仍是高权限操作,删除前由管理工具查询任务文档清单。

管理员允许在警告后强制物理删除仍被任务引用的文档。管理员页面必须提供“检查关联任务”操作;强制删除后,引用该 DocumentID 的历史任务可能无法读取该文件。若对应 RequestKey 正存在有效写入租约,则暂时禁止物理删除,避免 worker 在删除后立即重新写回。

4. 已确认的文档类型、固化和存储策略

4.1 类型策略表

DocumentKind 文档单元 刷新/固化规则 内容存储
power-flow SN + 数据日期 D+2 后生成的版本固化;此前新请求刷新并覆盖 PostgreSQL 结构化数据
mqtt-log SN + 数据日期 + cmdType D+2 后生成的版本固化;此前新请求刷新并覆盖 PostgreSQL 结构化数据
event-alarm SN + 数据日期 D+2 后生成的版本固化;此前新请求刷新并覆盖 PostgreSQL 结构化数据
alarm-snapshot SN + 数据日期 D+2 后生成的版本固化;此前新请求刷新并覆盖 文件/对象存储
ems-log SN + 数据日期 + EMS 日志类型 D+2 后生成的版本固化;此前新请求刷新并覆盖 文件/对象存储
fault-record 设备 SN + devType + 数据日期 按日期保存每日清单;D+2 后生成的版本固化;此前新请求刷新并覆盖 数据库清单 + 多个对象文件
update-log SN 永不固化;每个任务都重新查询全量信息并更新;任务日期范围对其无效 PostgreSQL 结构化数据

EMS 日志子类型为:EmsRunLog、EmsBattLog、EmsMonitorLog、Ems4gLog、EmsAwsLog、EmsMainLog。MQTT 的子类型是 cmdType。

4.2 D+2 固化的精确定义

设数据日期为 D,固化边界为业务时区中的 D + 2天 00:00:00。例如 9 月 10 日的数据,在 9 月 12 日开始进入固化期。

只有在固化边界之后重新查询并生成/保存的内容才能标记为固化。在 D+2 之前生成的缓存不能因为时间到了就直接改成固化状态,必须在 D+2 后再刷新一次,以包含冻结前可能补入的数据:

当前时间 < FreezeAt
  -> 新任务请求时重新查询并覆盖当前缓存

当前时间 >= FreezeAt,现有内容生成于 FreezeAt 之前
  -> 再查询和保存一次,然后标记 Frozen

当前时间 >= FreezeAt,已有 Frozen 内容
  -> 永久复用,不再查询和覆盖

固化边界使用每个站点自己的 IANA ZoneInfo 计算,不使用 worker 所在服务器的本地时区。

4.3 永不固化类型

UpdateLog 不使用缓存命中跳过查询。每个批量下载任务都必须重新查询上游全量数据并更新数据库中的当前内容。UpdateLog 是不断补全的全量增量日志,任务所选时间范围对它完全无效,历史任务通过稳定 DocumentID 读取当前最新内容。任务选择的日期范围只原样保存在 DataDownloadTask 请求配置中,不参与 UpdateLog 的校验、RequestKey、查询参数、任务拆分或文档身份;UpdateLog 只记录本轮远程读取完成时间 SourceFetchedAt。

为了同时满足“每个任务都查询”和“单写入渠道”,相同 RequestKey 的这类任务可以通过租约串行执行,但前一个任务完成后,后一个任务仍要取得租约并重新查询,不能直接复用前一个任务刚写入的结果。

Settings 已明确从 DataDownload 中删除,后续由独立模块管理。DataDownload 的前端选项、领域类型、任务规划、缓存策略和 Renderer 均不包含 Settings。

4.4 FaultRecord 每日清单

FaultRecord 不生成每日归档包。它按设备 SN、devType 和站点本地日期形成每日逻辑文档,一个日期下可以没有文件,也可以包含多个文件和多种录波类型:

FaultRecord/
└── DeviceSN/
    ├── 2026-09-10/
    │   ├── recording-a.xls
    │   ├── recording-b.xls
    │   └── recording-c.dat
    └── 2026-09-11/
        └── recording-d.xls

建议模型:

type FaultRecordDailyDocument struct {
	DocumentID string
	DeviceSN   string
	DevType    int
	DataDate   string
	ZoneInfo   string
	State      DocumentState
	Empty      bool
	UpdatedAt  time.Time
	Entries    []FaultRecordEntry
}

每日没有录波时保存空文档记录,不创建实际 MinIO 目录。空记录用于判断已查询和记录更新时间,普通任务目录树不展示,管理员文档页面可以查看。D+2 前仍会刷新;D+2 后最终确认并固化为空结果。

4.5 空结果文档

所有 D+2 文档类型统一采用空结果语义:

  • 上游查询成功但没有数据时,任务 item 为 skipped,不视为失败;
  • DocumentService 仍保存一个 Empty=true 的逻辑文档记录;
  • 空文档保存 RequestKey、状态、更新时间、FreezeAt 等元数据;
  • D+2 前的新任务仍会重新查询和更新空记录;
  • D+2 后必须再查询一次,确认仍为空后才能 Frozen;
  • TaskDocument 可以关联该空文档用于审计和复用判断;
  • 普通任务目录树不展示空文档或空目录;
  • 管理员文件管理页面可以查询空文档及其更新时间。

5. 请求范围拆分与“同一个文档”的判断

任务的开始、结束日期不直接构成一个大文档。范围请求应先拆成每日文档单元,再逐日判断是否存在和是否固化。例如任务 A 请求 09-10 至 09-15,任务 B 请求 09-13 至 09-18,两者可以逐日复用 09-13、09-14、09-15。

两个任务是否需要同一个逻辑文档,由规范化文档单元生成的 RequestKey 判断,而不是由任务 ID、文件名或 MinIO 路径判断。RequestKey 应包含:

  • DocumentKind;
  • 站点 SN 或实际设备 SN;
  • 数据日期;
  • SubType,例如 MQTT cmdType、EMS 日志类型;
  • DevType;
  • 其他会影响内容的规范化参数;
  • 业务时区;
  • 会影响缓存正文的查询、解析或结构版本。纯下载 Renderer 的输出格式/版本不进入 RequestKey,避免 Renderer 升级导致重新请求上游。

RequestKey 不包含任务 ID、任务描述、创建用户和 ZIP 内逻辑路径。例如:

v1|mqtt-log|SN001|2026-09-10|201|America/Los_Angeles|xlsx-v1

可对规范化字符串计算 SHA-256 作为实际键。Redis 租约和文档索引都使用同一个 RequestKey。当前 BatchDownloadItem.Key 维度不足,只用于任务内部工作单元定位,不能直接作为 RequestKey。

5.1 DocumentID 的当前语义

DocumentID 优先表示一个稳定的逻辑文档单元。未固化阶段的新请求会覆盖这个单元的当前内容;固化后内容不再变化。

这意味着:历史任务如果引用的是尚未固化的 DocumentID,在之后下载时可能读到被新任务刷新后的内容。这是“未固化数据新请求时覆盖”带来的明确行为。若未来要求历史任务永久保持创建时内容,则需改为每次刷新创建新 DocumentID 并保留版本。

5.2 全球站点时区模型

用户选择的 YYYY-MM-DD 表示每个站点各自的本地日历日期,而不是统一的 UTC 日期。现有站点信息包含 ZoneInfo(例如 America/Los_Angeles)和数值 TimeZone;上游 PowerFlow、EMS 等接口以本地日期查询,MQTT/EventAlarm 同时返回设备时间和 UTC 时间。

统一规则:

  • 文档身份和用户选择日期使用站点本地日历日期;
  • 上游设备时间查询使用站点本地时间;
  • 数据库绝对时间、CreatedAt、UpdatedAt、GeneratedAt 和 FreezeAt 统一存 UTC;
  • D+2 在站点时区中计算本地零点,再转换成 UTC 绝对时刻进行比较;
  • 使用 AddDate(0, 0, n) 计算本地下一天和 D+2,不能使用固定 24/48 小时,以正确处理夏令时;
  • 文档保存实际使用的 ZoneInfo 快照,RequestKey 也包含它。

建议时间模型:

type DocumentUnitTime struct {
	DataDate    string    // 站点本地 YYYY-MM-DD
	ZoneInfo    string    // IANA 名称或降级后的 UTC±HH:MM
	StartUTC    time.Time // 当日本地零点换算成 UTC
	EndUTC      time.Time // 下一本地零点换算成 UTC,右开区间
	FreezeAtUTC time.Time // D+2 本地零点换算成 UTC
	GeneratedAt time.Time // UTC
}

时区解析降级规则:

  1. ZoneInfo 合法时使用 IANA 时区及其夏令时规则;
  2. ZoneInfo 缺失或非法、但 TimeZone 数值有效时,使用 time.FixedZone 创建固定偏移,并记录 TimezoneSource=offset;
  3. 两者都不可用时,该 SN 创建时区解析失败 item,不请求该站点数据;
  4. 数值偏移支持半小时等非整数偏移;
  5. 固定偏移不包含夏令时,因此管理员界面应标识其降级来源。

禁止继承旧代码中将无时区的 YYYY-MM-DD 直接按 UTC 解析、固定使用 America/Los_Angeles 或强制转换 Asia/Shanghai 的做法。

6. 存储设计:数据库结构化数据 + 对象文件

已选择数据库存储方案 B:对 PowerFlow、MqttLog、EventAlarm、UpdateLog 在 PostgreSQL 中只保存当前实际结构化数据,不保留历史正文快照,也不把生成后的 Excel/HTML 二进制作为文档正文。当前四类接口都按完整文档单元读取;任务确认上游完整读取成功后,在分布式写锁和 PostgreSQL 事务内全量替换当前数据。增量 UPSERT 只保留为未来真正接入增量接口时的扩展能力,不用于当前四类数据;预览或下载时才根据当前 ContentGeneration 生成临时导出文件。

生成后的导出文件允许作为派生缓存保存 3 天,以便预览、单文件下载和 ZIP 打包重复使用。派生缓存不是文档事实来源,过期或丢失后可以完全根据 PostgreSQL 正文重新生成。任务、文档目录、当前修订指针和派生缓存目录仍由 MongoDB 管理;派生文件内容存入 MinIO 的独立 DataDownloadRenderCache/ 前缀。

要求:

  • 每个按日文档单元有独立数据库记录或可独立事务替换的数据集合;
  • 未固化时,新请求完整替换该文档单元的结构化数据,避免新旧数据混合;
  • 固化后拒绝覆盖;
  • 影响结构化正文的 schema/query/parser 版本必须进入 RequestKey;纯 Renderer 版本不改变逻辑文档身份;
  • 下载时生成文件不得再次请求上游业务数据;
  • DocumentService.Open 先尝试复用未过期的派生文件,没有命中时调用对应 Renderer 从 PostgreSQL 生成、上传并登记派生缓存;
  • 派生文件固定在成功生成后最多保留 3 天,重复使用不延长过期时间;对应 PostgreSQL 正文更新并递增 ContentGeneration 后,必须先在锁内断开旧缓存指针,使新读取无法再命中旧文件,再根据当前 Generation 生成和发布新文件;旧文件确认没有活跃读取后删除,清理失败不得影响已经成功提交的 PostgreSQL 正文。

AlarmSnapshot、EmsLog 保存实际文件;FaultRecord 保存每日结构化清单并关联多个实际文件。内部存储关系为:

DocumentService
  ├─ StructuredDocumentStore -> PostgreSQL
  ├─ BinaryDocumentStore     -> MinIO/Object Storage
  ├─ DocumentRenderer        -> HTML/XLSX/其他下载格式
  └─ RenderArtifactStore     -> MongoDB Catalog + MinIO 临时派生文件

FaultRecord 已确定采用“每日清单 + 多个实际录波文件”,不生成每日归档包;缓存身份和固化判断以设备、devType 与站点本地数据日期为基础。

PostgreSQL 结构化正文按稳定 DocumentID 保存唯一一份当前数据,并维护单调递增的 ContentGeneration。Generation 只用于并发校验、识别数据是否变化和派生文件缓存键,不代表一份可读取的历史正文。正文写入时获取 DocumentID 级排他写租约,在同一 PostgreSQL 事务内全量替换、校验并递增 Generation;生成文件时获取共享读取租约,并在同一 Generation 下读取完整数据。MongoDB 保存 Generation 镜像用于目录展示和协调,但 PostgreSQL 中的当前 Generation 是结构化正文的权威值。

6.1 DataDownload MinIO Repository

已确认新增 service/repositories/minio/,作为 DataDownload 与通用 utils/minio 客户端之间的业务存储层:

DocumentService / PackageService
  -> repositories/minio
      -> utils/minio
          -> MinIO Server

建议文件:

service/repositories/minio/
├── dataDownloadDocumentRepo.go
├── dataDownloadRenderArtifactRepo.go
└── dataDownloadPackageRepo.go

职责边界:

  • dataDownloadDocumentRepo.go 生成隔离的对象键,上传、打开、查询和删除文档内容修订,并保存 FaultRecord 每日清单关联的多个对象文件;
  • dataDownloadRenderArtifactRepo.go 保存、打开和删除结构化文档按需生成的临时导出文件;
  • dataDownloadPackageRepo.go 上传、打开、查询和删除异步 ZIP;
  • Repository 内部可以使用 Bucket、ObjectKey 和 MinIO SDK,Controller、DTO、任务接口及 MCP 不得暴露这些字段;
  • Repository 不判断 Mutable/Frozen、D+2、Owner、任务状态或 TaskDocument,这些仍由 Service 层负责;
  • MinIO 前缀是虚拟目录,不需要为 DataDownloadBinary/、DataDownloadRenderCache/、DataDownloadPackage/ 或空文档预先创建目录对象。

文件型文档提交顺序:

生成新的 BinaryContentRevision
  -> 上传完整对象
  -> 校验大小和 Checksum
  -> MongoDB 原子切换 Document.CurrentBinaryRevision
  -> Commit 成功

MongoDB 指针切换失败时,新对象没有成为当前内容。系统应尽力删除本次上传的孤立对象;删除失败时由管理员工具后续检查。稳定 DocumentID 始终通过 MongoDB 当前修订定位物理对象。

6.2 通用 Repository 初始化与 Mongo 索引

已确认不在 main.go 中直接调用某个业务模型的索引初始化。采用逐级、显式、可返回错误的通用初始化链:

main.go
  -> database.Init(...)
  -> cache.Init(...)
  -> utils/minio.InitClient(...)
  -> repositories.Init(ctx)
       -> repositories/mongo.Init(ctx)
            -> initDataDownloadIndexes(ctx)
            -> 未来其他模型的索引初始化
       -> repositories/pgsql.Init(ctx)
            -> 初始化或验证 DataDownload 结构化表及索引
       -> 未来 repositories/minio.Init(ctx)
       -> 未来 repositories/redis.Init(ctx)
  -> 外部 API 初始化
  -> Scheduler / Task Queue
  -> HTTP Server

建议新增:

service/repositories/init.go
service/repositories/mongo/init.go
service/repositories/mongo/dataDownloadIndexes.go

规则:

  • repositories.Init(ctx) 是 main 唯一感知的 Repository 初始化入口;
  • repositories/mongo.Init(ctx) 使用明确的静态列表逐个调用各模型初始化器;
  • 不使用 Go 自动 init() 注册,避免隐藏顺序且无法返回错误;
  • Repository 初始化必须在 Scheduler 和 Asynq worker 启动前完成;
  • 关键唯一索引创建失败时阻止服务启动,不能在缺少并发约束的情况下继续运行;
  • CreateIndexes 使用固定名称和固定定义,以便多实例启动时执行相同的幂等操作;
  • 修改已有索引字段或选项时采用显式迁移,不能在启动时自动删除未知索引;
  • 只初始化 DataDownload 新 collection,不修改旧 BatchDownload collection。

DataDownload 至少需要以下索引:

Collection 索引 用途
DataDownloadDocument {requestKey: 1} unique 保证一个 RequestKey 只有一个稳定逻辑文档
DataDownloadDocument {kind: 1, siteSN: 1, dataDate: 1} 文档目录和 MCP 查询
DataDownloadDocument {state: 1, updatedAt: -1} 管理员按状态浏览
DataDownloadItem {taskID: 1, itemKey: 1} unique 防止重试重复创建 item
DataDownloadItem {taskID: 1, status: 1} 进度聚合和失败项重试
DataDownloadTaskDocument {taskID: 1, logicalPath: 1} unique 防止任务目录出现重复路径
DataDownloadTaskDocument {documentID: 1, taskID: 1} 管理员检查关联任务
DataDownloadTask {ownerID: 1, createdAt: -1} “我的任务”和创建时间分页
DataDownloadTask {status: 1, createdAt: -1} 状态过滤和自动轮询
DataDownloadRenderSlot {slotKey: 1} unique 结构化文档当前可复用派生文件的原子指针
DataDownloadRenderArtifact {cacheKey: 1} unique 同一正文修订、格式和 Renderer 版本只有一个派生缓存
DataDownloadRenderArtifact {expiresAt: 1} 普通索引 查找应删除的 3 天临时派生文件
DataDownloadPackageJob {ownerID: 1, createdAt: -1} 待下载数据列表
DataDownloadPackageJob {status: 1, createdAt: -1} 查询待执行和执行中打包任务
DataDownloadPackageJob {expiresAt: 1} 普通索引 查找待清理 ZIP;暂不使用 TTL 自动删除
DataDownloadPackageJob {purgeAt: 1} 普通索引 ZIP 过期记录再保留 30 天后的数据库记录清理

描述查询已确定为不区分大小写的任意位置包含匹配。输入必须转义为字面量后再构造 MongoDB regex,禁止把用户输入直接当正则表达式执行。普通 B-tree 索引不能有效加速任意位置匹配;初期接受分页过滤查询,数据量明显增长后再引入专用搜索索引,不使用效果不确定的中文 Mongo text index 替代该语义。

RenderArtifact 和 PackageJob 都不使用 Mongo TTL 索引,因为 TTL 只删除数据库记录,不会同步删除 MinIO 对象。派生文件的 ExpiresAt = GeneratedAt + 3天,到期后先断开 Slot 指针,等待读取租约归零,再删除 MinIO 对象和 Artifact 记录。ZIP 的 ExpiresAt = CompletedAt + 7天;到期后先由清理任务删除 MinIO ZIP,再将任务标为 expired。expired PackageJob 元数据继续保留 30 天,然后由清理任务删除。PackageJob 的人工删除规则见 13.3。

6.3 结构化文档派生文件缓存

结构化正文始终只写 PostgreSQL。HTML、CSV、XLS/XLSX、JSON 等文件属于可重建的临时派生产物,不写回 PostgreSQL,也不改变 Document 状态或任务状态。

派生文件使用“不可变 Artifact + 可切换 Slot 指针”模型。已经发布的 MinIO 对象禁止原地覆盖;任何新正文或重新渲染都创建新的对象键。

建议 MongoDB 目录模型:

type DataDownloadRenderSlot struct {
	ID              primitive.ObjectID
	SlotKey         string // DocumentID | Representation | RendererVersion
	DocumentID      string
	Representation  string
	RendererVersion string
	ContentGeneration int64
	ArtifactID      *primitive.ObjectID // nil 表示当前没有可复用缓存
	Generation      int64               // 每次断开/发布指针递增
	UpdatedAt       time.Time
}

type DataDownloadRenderArtifact struct {
	ID              primitive.ObjectID
	CacheKey        string
	DocumentID      string
	ContentGeneration int64
	Representation  string
	RendererVersion string
	Status          string // generating, ready, retiring, failed, deleting
	FileName        string
	ContentType     string
	ObjectKey       string // 仅 Repository 内部使用,不对外暴露
	Size            int64
	Checksum        string
	Error           string
	GeneratedAt     time.Time
	ExpiresAt       time.Time
}

缓存键由以下字段规范化后计算 SHA-256:

DocumentID | ContentGeneration | Representation | RendererVersion

RendererVersion 必须进入派生缓存键,但不进入文档 RequestKey;Renderer 升级只导致重新生成临时文件,不重新请求上游。缓存有效期固定为:

ExpiresAt = GeneratedAt + 3 * 24h

命中缓存不会顺延 ExpiresAt。Document 当前数据更新并递增 ContentGeneration 后,即使新 Generation 会自然产生不同 CacheKey,也必须主动断开 DataDownloadRenderSlot.ArtifactID,不能仅依赖新 CacheKey 或等待 3 天自然过期。

正文更新与缓存换代顺序:

获取 DocumentID 排他数据写租约
  -> 按固定顺序获取该 Document 的 RenderSlot 分布式写租约
       -> 阻止新的 Artifact 共享读取租约进入
       -> 等待已经取得的 Artifact 读取租约结束或超时回收
  -> PostgreSQL 事务提交当前数据并递增 ContentGeneration
  -> 同步 MongoDB Document.CurrentGeneration 镜像
  -> 对每个 RenderSlot:
       -> 原子清空 ArtifactID,并递增 Generation
       -> 将旧 Artifact 标记为 retiring
  -> 释放 RenderSlot 写租约和数据写租约
  -> 后续 Open 看到空指针,根据新 Generation 生成新 Artifact
  -> 新 Artifact 上传并校验完成后,在写租约内原子发布到 Slot
  -> retiring Artifact 在无读取租约并经过短暂宽限期后删除

读取和指针更新通过 Redis 分布式读写租约协调。普通预览、下载和 PackageJob 在解析 Slot 指针前取得共享读取租约,并在整个对象读取期间自动续租;正文更新和新 Artifact 发布取得排他写租约。写租约采用 writer-preference:一旦有写入等待,阻止新的读取进入,避免持续下载导致更新饥饿。

如果旧派生文件正被预览、下载或 PackageJob 使用,已有读取可以在租约内读完不可变旧对象,但新读取不得再取得旧指针。旧 Artifact 在活跃读取租约归零后即可删除;为吸收网络流关闭、续租传播和进程调度竞态,默认再增加一个可配置的短宽限期。由于 PostgreSQL 不保留旧 Generation 数据,已经取得旧 Artifact 读取租约的在途操作继续使用该不可变文件;尚未物化输入的 PackageJob 在取得数据读取锁后使用当时的当前 Generation 生成 Artifact。删除失败不回滚正文更新,而是保留 retiring/deleting 状态并由清理任务重试。

断开指针必须同时使正在为旧 ContentGeneration 执行的 generating 结果失去发布资格。Renderer 发布前必须在写租约内原子校验渲染租约 Token、Slot.Generation、PostgreSQL 当前 ContentGeneration 和 Artifact.ContentGeneration;任一条件不匹配时不得写入 Slot,并把已上传对象转为 retiring,防止正文更新期间旧 Renderer 完成后把旧缓存重新挂回。

读取流程:

Open(DocumentID, representation)
  -> 获取 RenderSlot 共享读取租约
  -> 对照 PostgreSQL 权威 ContentGeneration 校验 Slot/Artifact
  -> Generation 一致、ready 且未过期:在租约内从 MinIO 打开并读取
  -> Slot 为空或 Artifact 已过期:释放读取租约并竞争渲染租约
       -> 获取 DocumentID 共享数据读取租约
       -> 在 PostgreSQL 事务中读取当前 Generation 的完整数据
       -> Renderer 生成本地临时文件或流
       -> 使用新的不可变 ObjectKey 上传 DataDownloadRenderCache
       -> 校验 Size/Checksum
       -> 在写租约内校验 ContentGeneration/Slot.Generation 后发布 Slot 指针
       -> 将 RenderArtifact 标为 ready,设置 3 天 ExpiresAt
       -> 删除本地临时文件
       -> 释放 DocumentID 共享数据读取租约
  -> 并发调用者复用同一个 ready artifact

渲染租约按 Render CacheKey 隔离,读写租约按 Render SlotKey 隔离;两者可复用 Redis 租约基础设施,但不得与上游 FileRequest 的 RequestKey 混用命名空间。普通到期或正文更新清理都必须先断开 Slot 指针,再等待读取租约归零和宽限期,之后删除 MinIO 对象及 Artifact 记录。对象已不存在视为幂等成功。上传成功但未发布到 Slot 的对象属于 retiring/orphan artifact,由清理任务处理。

PostgreSQL 的 content_generation 是权威值,MongoDB 的 CurrentGeneration 只是镜像。每次缓存命中都必须在 Slot 读取租约内校验权威 Generation;因此即使进程在 PostgreSQL 提交后、MongoDB 镜像或 Slot 更新前崩溃,也不能继续返回旧缓存。MongoDB 同步失败时保持 Slot 断开并记录待协调状态,后台根据 PostgreSQL 当前状态修复目录镜像,不回滚已提交的正文。

7. 建议的公共数据结构

type DocumentKind string

const (
	DocumentPowerFlow     DocumentKind = "power-flow"
	DocumentMqttLog       DocumentKind = "mqtt-log"
	DocumentEventAlarm    DocumentKind = "event-alarm"
	DocumentAlarmSnapshot DocumentKind = "alarm-snapshot"
	DocumentEmsLog        DocumentKind = "ems-log"
	DocumentFaultRecord   DocumentKind = "fault-record"
	DocumentUpdateLog     DocumentKind = "update-log"
)

type DocumentRangeRequest struct {
	Kind        DocumentKind
	SiteSN      string
	StartDate   string // 用户选择的 YYYY-MM-DD,按每个站点自己的本地日期解释
	EndDate     string // 用户选择的 YYYY-MM-DD,按每个站点自己的本地日期解释
	SubType     string
	DevType     *int
	Parameters  map[string]string
}

type DocumentUnitRequest struct {
	Kind          DocumentKind
	SiteSN        string
	DeviceSN      string
	DataDate      string // 站点本地 YYYY-MM-DD
	SubType       string
	DevType       *int
	Parameters    map[string]string
	Timezone      string
	TimezoneSource string
	ContentSchemaVersion string
}

type DocumentState string

const (
	DocumentMutable DocumentState = "mutable"
	DocumentFrozen  DocumentState = "frozen"
	DocumentDynamic DocumentState = "dynamic"
)

type DocumentWriteInput interface {
	documentWriteInput()
}

type BinaryDocumentInput struct {
	FileName    string
	ContentType string
	Size        int64
	Reader      io.Reader
}

// PowerFlowDocumentInput、MqttLogDocumentInput、EventAlarmDocumentInput、
// UpdateLogDocumentInput 和 FaultRecordDailyInput 分别定义强类型结构化 DTO,
// 并实现 DocumentWriteInput;不使用无约束的 any/map 传递正文。

type DocumentRef struct {
	DocumentID string
	Kind       DocumentKind
	DataDate   string
	State      DocumentState
	Empty      bool
	CreatedAt  time.Time
	UpdatedAt  time.Time
}

type DocumentMeta struct {
	DocumentRef
	RequestKey     string
	FreezeAt       *time.Time
	Checksum       string
	Timezone       string
	TimezoneSource string
}

type DocumentReader struct {
	Meta        DocumentMeta
	FileName    string
	ContentType string
	Reader      io.ReadCloser
}

type DocumentOpenRequest struct {
	DocumentID     string
	Representation string // raw、html、csv、xlsx 等受支持的输出形式
}

注意:DocumentRef 不应暴露 Bucket、MinIO ObjectKey、数据库表名、缓存键、TTL、引用数或租约信息。结构化输入不要长期使用无约束的 any,应按 DocumentKind 定义强类型 Put DTO 或仓储接口。日期字段在进入站点时区解析前保持 YYYY-MM-DD 字符串,避免被服务器默认时区提前转换。

8. 建议的对外接口

8.1 DocumentService

供 FileRequestService 和文档读取功能使用:

type DocumentService interface {
	// 按类型策略把范围请求规范化并拆成文档单元。
	Plan(
		ctx context.Context,
		request DocumentRangeRequest,
	) ([]DocumentUnitRequest, error)

	// 查询文档单元当前状态,不触发上游数据请求。
	Find(
		ctx context.Context,
		request DocumentUnitRequest,
	) (*DocumentRef, error)

	// 保存或覆盖结构化数据/文件;Frozen 文档必须拒绝覆盖。
	Put(
		ctx context.Context,
		request DocumentUnitRequest,
		input DocumentWriteInput,
	) (*DocumentRef, error)

	Open(
		ctx context.Context,
		request DocumentOpenRequest,
	) (*DocumentReader, error)

	Stat(
		ctx context.Context,
		documentID string,
	) (*DocumentMeta, error)
}

8.2 FileRequestService

供任务模块使用:

type FileRequestService interface {
	// Frozen 文档直接返回;Mutable/Dynamic 按策略进入刷新流程。
	Prepare(
		ctx context.Context,
		request FileRequestPrepareRequest,
	) (*PrepareResult, error)

	// 持有有效租约的任务获取数据后提交结构化数据或文件。
	Commit(
		ctx context.Context,
		lease FetchLease,
		input DocumentWriteInput,
	) (*DocumentRef, error)

	// 请求或生成失败时释放租约,使延迟重试调用者可以重新竞争。
	Abort(
		ctx context.Context,
		lease FetchLease,
		cause error,
	) error

}

type PrepareState string

const (
	PrepareReady    PrepareState = "ready"    // Frozen,或同一轮 Mutable 共享请求已完成
	PrepareFetch    PrepareState = "fetch"    // 当前调用者取得租约,应请求上游
	PrepareDeferred PrepareState = "deferred" // 已有请求执行中,应延迟重试
)

type FileRequestPrepareRequest struct {
	DocumentUnit  DocumentUnitRequest
	AwaitRequestID string // 首次为空;延迟重试时携带 PrepareResult.RequestID
}

type PrepareResult struct {
	State      PrepareState
	RequestID  string
	Document   *DocumentRef
	FetchLease *FetchLease
	RetryAfter time.Duration
}

type FetchLease struct {
	RequestKey string
	RequestID  string
	Token      string
	ExpiresAt  time.Time
}

约束:三种结果都返回本轮 RequestID。PrepareReady 必须返回 Document;PrepareFetch 必须返回 FetchLease;PrepareDeferred 不返回二者并提供 RetryAfter。租约续期由 FileRequestService 内部自动完成,不暴露为任务模块必须调用的公共方法。

8.3 人工管理接口

不提供给普通任务:

type DocumentAdminService interface {
	List(
		ctx context.Context,
		filter DocumentFilter,
	) ([]DocumentMeta, error)

	Delete(
		ctx context.Context,
		documentID string,
	) error
}

管理员接口不提供手工数据失效或手工清除 RenderArtifact。结构化正文更新、RenderSlot 断开、新文件生成和旧文件退休均由后台自动完成。

8.4 归档文件读取接口

EMS .tar.gz 内部浏览必须保留,并使用文档 ID 定位,而不是接受 MinIO 路径:

type ArchiveDocumentService interface {
	ListArchiveEntries(
		ctx context.Context,
		documentID string,
	) ([]ArchiveEntry, error)

	OpenArchiveEntry(
		ctx context.Context,
		documentID string,
		entryPath string,
	) (*DocumentReader, error)
}

9. 典型调用流程

9.1 D+2 类型

任务提交时间范围
  -> Plan 按日期拆分
  -> 每日计算 RequestKey
  -> 已有 Frozen:直接返回 DocumentID
  -> 不存在或 Mutable:竞争租约
       -> 获得者查询上游
       -> 保存结构化数据或文件,覆盖当前内容
       -> 若已到 D+2,则状态写为 Frozen
       -> 同一 RequestID 的延迟等待者使用本次结果
  -> 任务保存每日 DocumentID + LogicalPath

“等待者使用本次结果”只用于合并同时发生的同一 RequestID 刷新,避免并发重复请求。该刷新完成后到达的下一次未固化请求没有 AwaitRequestID,仍会创建新一轮请求并覆盖。

9.2 UpdateLog

每个任务请求
  -> 必须进入查询流程,不因已有数据跳过
  -> 相同 RequestKey 必要时通过租约串行
  -> 每个任务依次重新查询
  -> 覆盖数据库当前结构化数据
  -> 保存/复用逻辑 DocumentID

长耗时 EMS/FaultRecord 请求由协调层自动续租。若 Prepare 发现相同 RequestKey 已有有效租约,则返回“进行中”结果并通过延迟任务稍后重试,不占用 worker 阻塞等待。

10. 并发和覆盖语义(已取消索引 CAS 方案)

系统已确认:文档内容不支持编辑,只有 FileRequestService -> DocumentService 一个受控写入渠道。因此当前方案不再为文档索引设计独立的 CAS Revision 或多版本覆盖仲裁。

正确性依赖以下约束:

  1. 所有共享文档写入必须经过 FileRequestService,禁止绕过协调层直接 Put。
  2. 同一 RequestKey 同一时刻只允许一个有效 Redis 租约持有者写入。
  3. Commit 必须原子校验 Redis 中的 Token 与提交 Token 一致;租约过期或已被替换时拒绝提交。
  4. 长任务必须续租,避免正常执行中失去写入权。
  5. PostgreSQL 结构化正文按 DocumentID 使用分布式读写租约:当前四类写入持有排他租约并在一个事务内全量替换,Renderer 持有共享租约读取;不能让读取者看到半份数据。
  6. 覆盖文件型内容时先完成上传,再切换/替换当前对象,不能暴露半文件。
  7. Frozen 文档在 DocumentService 层强制拒绝覆盖。

Token 校验是租约归属校验,不再维护多版本文档索引 CAS。如果未来增加第二个写入渠道、人工编辑、异步回写或跨系统同步,应重新引入 FencingToken 或更强的写入仲裁。

这里的“无多版本正文”不等于允许读取半成品。PostgreSQL 写入必须在排他数据租约和数据库事务中完成,提交时递增当前 ContentGeneration;Renderer 在共享数据租约中读取当前数据。ContentGeneration 只用于检测变化及隔离派生缓存,不映射到历史数据副本。

11. 清理和删除

现阶段不实现自动清理和 GC,物理删除由人工管理。任务与文档引用关系仍归 Task Module;DocumentService 不维护引用计数。

删除任务默认只删除任务及其文档清单,不自动删除共享文档。人工删除文档前应查询任务引用或遵守保留期。新 DataDownload worker 的任务状态和结果写入不得使用 upsert,避免任务删除后被后台 worker 重新创建;旧 BatchDownload 本次不改动。

12. DataDownload 产品与任务需求

12.1 前端页面

DataDownload 使用独立新页面,不修改旧 BatchDownload。当前确认三个子页面:

DataDownload
├── 任务管理
├── 待下载数据
└── 文件管理(仅管理员可见)

任务管理页面承担创建任务、任务列表、详情、取消、重试、描述修改、目录树、单文件预览/下载、整包下载和批量操作。待下载数据页面管理异步 ZIP。文件管理页面管理 DocumentService 中的全部逻辑文档和对象文件。

新页面不要求实现右键菜单,相关操作使用明确的按钮或批量操作入口。

12.2 创建任务

  • SN 必填;
  • 支持文本框输入多个 SN,使用空格、逗号或换行分隔;
  • 支持上传 UTF-8 TXT(允许 UTF-8 BOM),每行一个 SN;
  • TXT 不支持表头和注释,忽略空行并去除首尾空格;
  • SN 大小写敏感;
  • 合并文本框和 TXT 后统一去重;
  • 不设置产品层面的最大站点数;实现必须分批解析、规划和入队,不能把全部 items 放进一个 MongoDB 文档;
  • SN 无效不阻止任务创建;每个无效 SN 只创建一个 SiteValidation failed item,不再展开每日和类型工作单元;
  • 无效 SN 重试时再次验证,再次无效则继续 failed,不额外管理;
  • 至少选择一种数据类型;
  • 保留任务描述和日期范围;
  • 日期范围的开始日和结束日都包含在请求内;开始日等于结束日时只生成一天的数据请求;
  • 日期跨度最多 600 天,按包含首尾日期后的日历天数计算;超过 600 天拒绝创建任务;
  • 对除 UpdateLog 外的类型不允许请求未来日期;以各站点本地日历日期判断,任一有效站点的结束日期晚于该站点“今天”时拒绝这些按日类型的任务规划;
  • 日期按每个站点的本地日历日期解释;UpdateLog 完全忽略日期,日期范围只保存在任务请求结构中,不参与其校验、RequestKey、上游查询或 item 拆分;
  • Settings 不出现在选项中;
  • UpdateLog 选项应说明“当前全量升级记录,不受所选日期范围限制”。

12.3 任务状态和聚合

任务状态:

pending
running
cancelling
cancelled
complete
partial_success
failed
error

item 状态继续使用:

pending
success
failed
skipped

等待相同 FileRequest 时仍显示 pending,不增加 waiting 状态。聚合规则:

  • 所有需要执行的 item 均 success/skipped:complete;
  • 同时存在成功和失败:partial_success;
  • 所有需要执行的 item 均失败:failed;
  • 任务无法规划或启动:error;
  • 用户取消且相关 worker 已退出:cancelled。

任务“完成”的定义是数据已经成功缓存。数据库型文档不要求任务阶段预先生成 Excel/HTML;Renderer 在预览或下载时执行,Renderer 失败不反向修改原任务状态。

12.4 失败项重试

  • 单个 item 的上游请求失败时先由后台执行有上限的延迟自动重试;已经成功的 item 不受影响,也不重复执行;
  • 自动重试耗尽后该 item 才进入 failed,任务按全部 item 聚合为 partial_success 或 failed;
  • failed 和 partial_success 支持一键重试全部失败项;
  • 已完成和 skipped item 不重复执行;
  • cancelled 不允许恢复或重试;
  • 人工重试次数不设上限;
  • item 记录每次实际执行尝试累计的 attemptCount、最后失败阶段、最后失败原因和最后尝试时间;
  • Missing 文档重新请求;Mutable 文档即使存在也重新请求;Frozen 文档直接复用;Dynamic 文档每次重新请求;
  • 无效 SN item 也参与统一重试,再次无效则继续失败;
  • Asynq 内部自动重试与用户发起的人工重试需要在计数和审计上区分。

12.5 取消和删除任务

  • pending/running 任务允许取消,不允许直接删除;
  • 取消后停止规划和调度后续 item;
  • 一旦 worker 已取得 FileRequest 租约并开始上游请求,就继续执行到 DocumentService Commit;
  • 已经取得的完整数据允许写入共享缓存;
  • 取消后不再向任务新增 TaskDocument 绑定;
  • FileRequestService 自动续租,避免长任务执行中失去写入权;
  • 相关 worker 全部确认退出后,任务才进入 cancelled;
  • cancelled、complete、partial_success、failed、error 等终态任务允许删除;
  • 取消前已成功绑定的文档继续保留在任务目录中,允许查看、下载和参与批量下载,直到任务被删除;
  • 删除任务只删除 Task、Item 和 TaskDocument,不删除共享 Document;
  • 所有任务 worker 更新都禁止 upsert,并在过滤条件中校验任务仍允许写入,防止删除后复活。

12.6 任务列表和查询

支持:

  • 分页和默认按创建时间倒序;
  • 按 SN、状态、创建时间范围、描述查询;
  • 切换“我的任务”和“所有任务”;
  • 展示 totalCount/doneCount、失败摘要和 item 明细;
  • 页面存在 pending/running/cancelling 任务时自动轮询;无活跃任务或页面进入后台时暂停或降低频率;
  • 轮询沿用当前分页和过滤条件。

12.7 Owner 和权限

  • 一个任务只有一个 owner;
  • 普通用户创建任务时 owner 为当前登录用户;
  • MCP 创建任务时 owner 为配置的特殊 AI 助手账户;
  • owner 创建后不可转移;普通 owner 和管理员均不能修改 owner;
  • owner 可以修改任务描述;管理员拥有所有任务权限;其他用户只能查看、预览和下载。
操作 Owner 管理员 其他用户
查看任务和文件 是 是 是
预览和下载 是 是 是
修改描述 是 是 否
取消 是 是 否
重试 是 是 否
删除 是 是 否
批量下载 是 是 是
批量删除 是 是 否
修改 owner 否 否 否
管理共享文档 否 是 否

12.8 目录、预览和下载

  • 任务只要至少存在一个可读取的非空 TaskDocument,就允许查看和下载,不再只按总体状态判断;
  • complete、partial_success、存在成功文档的 failed、存在成功文档的 cancelled 均可下载;
  • 保留目录树和单文件下载;
  • 预览格式确定为 .html、.csv、.xls、.xlsx、.log、.txt、.json;
  • 保留 .tar.gz 内部目录浏览、内部文件下载和预览;
  • 下载权限不受预览格式白名单限制:所有实际文件均允许下载;.dat 及未知二进制格式只下载、不预览;
  • 旧模块虽然声明支持 .csv/.xlsx/.log 预览,但当前 Blob 未传入预览组件;新模块必须实现真实内容预览,不能复用其未完成行为;
  • 所有文件接口使用 TaskID/DocumentID/ArchiveEntryPath,不接受任意 MinIO ObjectKey;
  • 空文档不显示在普通目录树;
  • Renderer 或文件读取错误应向用户返回明确错误。

结构化文档导出格式已确认:

DocumentKind 默认下载格式 支持的派生格式
PowerFlow HTML HTML、JSON
MqttLog XLSX XLSX、CSV、JSON
EventAlarm XLSX XLSX、CSV、JSON
UpdateLog XLSX XLSX、JSON

.xls 仅作为已有实际文件的预览格式,新 Renderer 不生成旧版 XLS。AlarmSnapshot、EmsLog、FaultRecord 等对象型文档保持原文件,不做格式转换,只根据扩展名和内容类型决定是否支持预览。

12.9 批量操作

  • 实现批量下载和批量删除;
  • 只处理选中项中当前用户有对应权限且满足操作条件的任务;其他用户可批量下载有权查看和下载的任务,但不能批量删除;
  • 不支持的选中项静默跳过,不弹逐项提示,也不要求自动取消选中;
  • 批量下载生成一个总 ZIP,并按 TaskID 分目录;
  • 用户在目录树选择部分文件时,用于绕开整包中某个文档渲染失败的问题;
  • 选择结果解析后只有一个实际文件时直接下载;包含两个或更多实际文件时创建只包含所选文件的异步 ZIP;
  • 单选目录如果展开后包含多个文件,仍按多文件异步 ZIP 处理;单选 .tar.gz 本体或单个归档内部文件可以直接下载。

13. 异步 ZIP 与待下载数据页面

13.1 打包流程

整包下载和批量任务下载采用异步打包,不沿用旧模块直接流式输出:

用户请求打包
  -> 创建 DataDownloadPackageJob
  -> 弹框提示前往“待下载数据”页面及目标文件名
  -> 后台读取 TaskDocument
  -> 渲染结构化文档/读取对象文件
  -> 生成并校验完整 ZIP
  -> 上传 DataDownloadPackage
  -> 用户在待下载数据页面下载

提示示例:

打包任务已创建,请前往「待下载数据」页面下载。
文件名:DataDownload_20260918_143052.zip

弹框提供“确定”和“前往待下载数据”。

单任务文件名建议:

DataDownload_{taskID}_{timestamp}.zip

批量总包建议:

DataDownload_Multiple_{timestamp}.zip
├── {taskID-1}/...
├── {taskID-2}/...
└── {taskID-3}/...

13.2 PackageJob

type PackageStatus string

const (
	PackagePending   PackageStatus = "pending"
	PackageRunning   PackageStatus = "running"
	PackageComplete  PackageStatus = "complete"
	PackageFailed    PackageStatus = "failed"
	PackageCancelled PackageStatus = "cancelled"
	PackageExpired   PackageStatus = "expired"
)

type DataDownloadPackageJob struct {
	ID          primitive.ObjectID
	OwnerID     string
	FileName    string
	TaskIDs     []primitive.ObjectID
	Status      PackageStatus
	TotalCount  int
	DoneCount   int
	ObjectKey   string
	Size        int64
	Error       string
	CreatedAt   time.Time
	StartedAt   *time.Time
	CompletedAt *time.Time
	ExpiresAt   *time.Time
	PurgeAt     *time.Time
}

任何一个文档读取失败或 Renderer 失败,整个 PackageJob 为 failed,不提供残缺 ZIP。需要部分文件时,用户回到任务目录树选择可用文件下载。该规则同样适用于批量总 ZIP。

ZIP 有效期固定为 7 天,从 CompletedAt 开始计算:

ExpiresAt = CompletedAt + 7 * 24h

打包排队和执行时间不占用用户的下载有效期。到达 ExpiresAt 后 ZIP 不再允许下载,清理任务先删除 MinIO 对象,再将 PackageJob 标为 expired。

过期记录保留策略已确认:

PurgeAt = ExpiresAt + 30 * 24h

PackageJob 进入 expired 后保留 30 天供查询和审计;到达 PurgeAt 后由清理任务自动删除 MongoDB PackageJob 记录。管理员仍可在此之前手动删除。

13.3 待下载数据页面

  • 普通用户查看自己的 PackageJob;管理员查看全部;
  • 按状态和创建时间过滤;
  • 对 pending/running 自动轮询;
  • 显示 doneCount/totalCount;
  • 下载 complete ZIP;
  • 查看 failed 原因;
  • 取消 pending/running;
  • 删除打包记录;
  • 对 failed 重新发起打包。

PackageJob 人工删除规则已确认。这里删除的是“待下载数据”中的 ZIP 打包任务记录 DataDownloadPackageJob 及其 ZIP,不是 DataDownloadTask 数据下载任务:

  • 删除终态的 DataDownloadPackageJob(complete、failed、cancelled 或 expired)时,同时删除仍存在的 ZIP 对象;
  • 删除 pending 或 running PackageJob 前必须先请求取消,并等待 worker 退出进入 cancelled 后再删除;
  • ZIP 删除成功后再删除 PackageJob 记录;ZIP 删除失败时保留记录并返回错误,避免产生不可追踪对象;
  • 已经自然过期且对象不存在时,删除记录应保持幂等并允许成功。

异步 ZIP 是派生产物,与共享 Document 的人工清理策略不同,固定保留 7 天并由独立清理任务处理。打包临时目录在任务结束后立即清理。

13.4 打包内容一致性

Mutable DocumentID 可能在异步打包期间被刷新,而 PostgreSQL 不保留旧数据快照。PackageJob 在构建输入清单时,按稳定顺序为每个结构化 Document 获取共享数据读取租约,读取当时的当前 ContentGeneration,并确保生成或取得对应的不可变 RenderArtifact;随后为这些 Artifact 持有读取租约,释放数据读取租约,再开始组装 ZIP。正文后续更新可以继续进行,但旧 Artifact 在 PackageJob 结束前不得删除。历史任务以后重新发起打包时重新读取当时的当前数据。

14. 管理员文件管理页面

页面仅管理员可见,浏览的是新 DataDownload 的逻辑 Document Catalog,而不是旧 BatchDownload 或裸 MinIO bucket。

至少展示和支持:

  • DocumentID、RequestKey、DocumentKind;
  • SiteSN/DeviceSN、DataDate、SubType、DevType;
  • Mutable/Frozen/Dynamic、Empty 状态;
  • ZoneInfo、TimezoneSource、UpdatedAt、FreezeAt;
  • 结构化数据库/对象文件存储类型;
  • 文件清单、大小、Checksum;
  • 普通文件预览/下载和 .tar.gz 内部浏览;
  • “检查关联任务”按钮,通过 DataDownloadTaskDocument 查询引用任务;
  • 物理 Delete;有关联任务时警告后允许管理员强制删除;
  • 有活跃 FileRequest 写入租约时暂时禁止物理删除。

管理员强制删除共享文档后,引用该 DocumentID 的任务仍保留清单,但读取时会得到文档不存在错误。关联检查只覆盖新 DataDownload;新旧文件完全隔离,因此无需检查旧 BatchDownload。

15. MCP 接口需求

MCP 只查询和创建新 DataDownload,不访问旧 BatchDownload。由于部分文档存数据库、部分存对象存储,MCP 查询的是逻辑 Document Catalog,而不是直接遍历 MinIO。

MCP 后端能力:

  • 查询整个逻辑目录结构;
  • 按 Kind、SN/DeviceSN、日期、SubType、状态查找文档;
  • 查询元信息和 Empty/Mutable/Frozen/Dynamic 状态;
  • 读取/下载文档内容;
  • 浏览 .tar.gz 并读取内部文件;
  • 判断目标文档是否存在且可用;
  • 缺失时查询 AI 助手账户的活跃 DataDownload 任务是否覆盖所需文档单元;
  • 无已有文档且无覆盖任务时创建普通 DataDownload 任务;
  • 新任务 owner 为特殊 AI 助手账户;
  • 创建后立即返回 taskID,不等待完成;
  • MCP 后续通过相同查询接口再次检查文档或任务覆盖情况;
  • MCP 不允许直接 Put、覆盖或 Delete。

“检查 AI 活跃任务 + 创建任务”必须通过规范化任务指纹或短期 Redis 锁实现幂等,避免多个 MCP 请求并发创建相同任务。

16. PostgreSQL 详细结构与已确认实施决策

PackageJob 人工删除规则和“不允许未来日期”已经确认。PostgreSQL 结构采用“当前正文状态表 + 按文档类型划分的强类型当前数据表”,不保存历史正文快照,也不复用现有按动态表名保存原始 MQTT JSON 的 SiteOriginalData。

16.1 当前正文状态表

PostgreSQL 不保存历史正文版本或快照。建议使用 data_download_content_state 保存每个结构化 Document 的当前状态:

字段 PostgreSQL 类型 说明
document_id varchar(64) PK MongoDB 稳定逻辑 DocumentID;不建立跨库外键
document_kind varchar(32) not null 四种结构化文档类型之一,使用 CHECK 约束
content_generation bigint not null 当前内容代数,每次成功写入后加 1,不代表历史快照
schema_version varchar(32) not null 正文结构/解析版本;与 RequestKey 中的版本一致
row_count bigint not null default 0 当前主记录数
content_checksum char(64) 当前规范化正文的 SHA-256
source_fetched_at timestamptz not null 本轮上游数据读取完成时间;UpdateLog 以此表示数据新鲜度
updated_at timestamptz not null 当前数据提交时间

写入流程:

取得 DocumentID 排他数据租约
  -> 按固定顺序取得该 Document 全部 RenderSlot 排他写租约并等待旧读取结束
  -> 开启 PostgreSQL 事务
  -> 对当前完整文档单元执行全量 DELETE + INSERT
  -> 校验 row_count/checksum
  -> content_generation = content_generation + 1
  -> 更新 source_fetched_at/updated_at
  -> 提交事务
  -> 自动断开对应 RenderSlot 指针
  -> 释放 RenderSlot 写租约和数据写租约

读取正文或生成文件必须取得同一 DocumentID 的共享数据租约,并在一个 PostgreSQL 读取事务内完成。写租约采用 writer-preference,等待已有读取结束并阻止新读取进入,因此写入期间不会修改 Renderer 正在提取的数据。PostgreSQL 事务仍是最终的原子性边界,Redis 租约用于跨进程协调和避免长时间生成期间发生更新。

正文批量写入初期使用 GORM CreateInBatches(..., 1000);MQTT 等大数据确认性能不足时,Repository 内部改用 pgx CopyFrom。正文按 DocumentID 保存当前数据,不按 Generation 创建新分区或新副本。

16.2 PowerFlow

当前上游 dto.SitePowerFlow 是多个并行数组,现有 Renderer 以 DeviceTimeArray 为主序列。建议拆成当前元数据和当前点数据:

data_download_power_flow_meta:document_id 主键、reported_index。

data_download_power_flow_point:document_id、seq、device_time_local、device_time_utc、power_fhp、power_gen、power_load、power_sun、power_uti、soc、kwh_total、run_status,主键 (document_id, seq)。

PowerFlow 使用全量替换。写入 DTO 按 DeviceTimeArray 形成点;较短的指标数组补 NULL,任何比时间数组更长的指标数组拒绝写入,避免静默丢数据。HTML Renderer 按 seq 读取,继续复用现有 ECharts 生成逻辑。

16.3 MQTT Log

建议表 data_download_mqtt_log_entry:

字段 类型
document_id、cmd_type、cmd_name、gateway_id varchar not null
seq integer not null
gateway_type、gateway_status、active_status varchar nullable
device_time_raw、event_time_raw text not null
device_time_local timestamp without time zone nullable
event_time_utc timestamptz nullable
log_data_raw text not null
log_data_json jsonb nullable

主键为 (document_id, seq),并建立 (document_id, device_time_local, seq) 索引。MQTT 每日文档使用全量替换。log_data_raw 保证与当前 Excel 输出兼容;能成功解析 JSON 时同时写入 log_data_json,解析失败不丢失原文。XLSX/CSV Renderer 默认读取 raw;JSON Renderer 返回结构化 envelope 和可用的 JSON payload。

16.4 EventAlarm

建议表 data_download_event_alarm_entry,以 (document_id, seq) 为主键,保存 source_id、命令/网关/告警固定字段、全部时间 raw 字段及其可解析时间列、log_data_raw 和可选 log_data_json。索引为 (document_id, device_time_local, seq) 和 (document_id, log_type, alarm_code)。

EventAlarm 每日文档使用全量替换。无法解析的上游时间保留对应 raw 值并将解析列置 NULL,不因单个格式异常丢弃整行。XLSX Renderer 保持当前 ID、Time、Type、AlarmCode、Name、StartTime、EndTime、Data 列顺序,并修复 nil EndTime 的显示语义。

16.5 UpdateLog

UpdateLog 是 SN 级全量当前内容,不按日期拆分,也不把任务日期写入正文表或 RequestKey。其远程读取时间记录在 data_download_content_state.source_fetched_at。

使用四张当前正文表:

  1. data_download_update_order:以 (document_id, order_no) 为稳定业务键,保存系统升级订单。
  2. data_download_update_device_version:以 (document_id, order_no, scope, device_sn, name) 为稳定业务键,保存 Gate 和子设备版本行。
  3. data_download_ahub_update:以 (document_id, order_no, ahub_sn) 为稳定业务键保存 aHub 升级记录。
  4. data_download_ahub_update_detail:以 (document_id, order_no, ahub_sn, file_order, file_name) 为稳定业务键保存 aHub 明细。

当前 UpdateLog 接口按完整集合处理,只有全部列表及其详情成功读取后,才在一个事务内全量替换四张当前正文表;任一上游分页或详情请求失败都回滚,不用不完整结果覆盖旧数据。这样可以正确反映上游删除或修正,也不产生历史 Generation 副本。未来若接入明确只返回增量的不同接口,再单独设计稳定业务键 UPSERT。XLSX Renderer 按现有 SystemUpdate、aHubUpdate 两个 Sheet 布局读取。时间写入时统一转换为 UTC,Renderer 按产品指定时区格式化。

16.6 空文档、删除与 Renderer

空结构化文档仍保留一条 data_download_content_state,row_count=0 且正文表无行;MongoDB Document 的 Empty=true 与状态表相互校验。管理员物理删除结构化 Document 时,在排他数据租约内删除其当前正文和状态行,并按 6.3 自动退休派生文件。Renderer 只读取当前数据;PackageJob 通过先物化不可变 RenderArtifact、再释放数据租约的方式保持打包输入稳定,不依赖 PostgreSQL 历史快照。

16.7 表迁移方式

DataDownload 不使用运行时 GORM AutoMigrate。新增版本化 SQL migration,建议使用 pressly/goose/v3 加 Go embed 管理,并由 repositories/pgsql.Init(ctx) 在 worker 启动前执行;多实例部署时在 PostgreSQL advisory lock 内运行迁移。迁移失败阻止服务启动。GORM model/query 代码可以在 migration 后生成或手写,但不能反向承担生产表结构变更。

16.8 MongoDB 数据库名配置(已确认)

当前 database.InitMongo 使用 strings.Split(uri, "/") 的最后一段作为数据库名。这只对 mongodb://host:port/DatabaseName 这种简单 URI 偶然正确:

  • mongodb://host/DatabaseName?replicaSet=rs0 会得到 DatabaseName?replicaSet=rs0;
  • mongodb://host/DatabaseName/ 会得到空字符串;
  • mongodb://host/?authSource=admin 没有业务数据库名,却可能把查询串误当数据库名;
  • 多主机、转义和 mongodb+srv 规则也不应由字符串拆分实现。

仓库当前 service/.env 使用简单的 mongodb://host:port/FwhToolboxDbDev 形式,因此当前配置本身可以工作;问题是初始化代码不能安全支持合法 URI 变体,也不能在数据库名缺失时尽早失败。

评估过的两种方案:

  1. 新增 MONGO_DATABASE:连接地址与业务库名完全分离,运维含义最清晰,也允许 DSN 不带路径;代价是所有部署环境、MCP 配置和 Secret 都要新增并保持两项配置一致,需要定义 DSN 路径与显式值冲突时谁优先。
  2. 使用 MongoDB 官方 connection-string parser:保持当前单一 MONGO_DSN 配置不变,正确处理查询参数、转义、多主机和 SRV;在解析结果的 Database 为空时阻止启动。代价是继续要求每个 DSN 都显式携带数据库路径。

已确认采用方案 2:使用 MongoDB 官方 connection-string parser,不新增 MONGO_DATABASE。项目使用 mongo-driver v1.17.4,使用其 connstring.ParseAndValidate 取得 Database,同时仍把原 URI 交给 options.Client().ApplyURI 建立连接。解析失败或 Database 为空时必须阻止启动;日志不得输出包含账号密码的原始 URI。

17. 后端源码目录约束

后端必须沿用项目现有的横向分层,不创建与当前工程不一致的 service/modules/datadownload 垂直模块。Task Module、DocumentService、FileRequestService 是逻辑职责,分别映射为现有目录中的多个同包文件。

建议布局:

service/
├── routes/
│   └── dataDownloadRoutes.go
├── controllers/
│   ├── dataDownloadTaskControllers.go
│   ├── dataDownloadPackageControllers.go
│   └── dataDownloadDocumentControllers.go
├── services/
│   ├── dataDownloadTaskServices.go
│   ├── dataDownloadExecutionServices.go
│   ├── dataDownloadDocumentServices.go
│   ├── dataDownloadFileRequestServices.go
│   ├── dataDownloadRenderArtifactServices.go
│   ├── dataDownloadPackageServices.go
│   └── dataDownloadPermissionServices.go
├── domain/
│   ├── dataDownloadTaskDomain.go
│   ├── dataDownloadDocumentDomain.go
│   ├── dataDownloadPackageDomain.go
│   └── dataDownloadQueueDomain.go
├── dto/
│   ├── dataDownloadTaskDTO.go
│   ├── dataDownloadDocumentDTO.go
│   └── dataDownloadPackageDTO.go
├── repositories/
│   ├── init.go
│   ├── dataDownloadRepo.go
│   ├── mongo/
│   │   ├── init.go
│   │   ├── dataDownloadIndexes.go
│   │   ├── dataDownloadTaskRepo.go
│   │   ├── dataDownloadItemRepo.go
│   │   ├── dataDownloadTaskDocumentRepo.go
│   │   ├── dataDownloadDocumentRepo.go
│   │   ├── dataDownloadRenderArtifactRepo.go
│   │   └── dataDownloadPackageRepo.go
│   ├── pgsql/
│   │   └── dataDownload/
│   │       ├── init.go
│   │       ├── powerFlowRepo.go
│   │       ├── mqttLogRepo.go
│   │       ├── eventAlarmRepo.go
│   │       └── updateLogRepo.go
│   ├── redis/
│   │   ├── dataDownloadFileRequestRepo.go
│   │   ├── dataDownloadRenderLeaseRepo.go
│   │   └── dataDownloadFingerprintRepo.go
│   └── minio/
│       ├── dataDownloadDocumentRepo.go
│       ├── dataDownloadRenderArtifactRepo.go
│       └── dataDownloadPackageRepo.go
├── models/model_pgsql/
│   ├── model_data_download/
│   └── query_data_download/
├── function/
│   └── DataDownloadFile/
│       ├── powerFlowRenderer.go
│       ├── mqttLogRenderer.go
│       ├── eventAlarmRenderer.go
│       ├── updateLogRenderer.go
│       └── archiveReader.go
├── jobs/
│   ├── dataDownloadPackage.go
│   ├── dataDownloadRenderArtifactCleanup.go
│   └── dataDownloadTempFile.go
├── task/queue/
│   └── dataDownloadQueue/
│       ├── dataDownloadTaskQueue.go
│       ├── dataDownloadTaskHandler.go
│       ├── dataDownloadItemHandler.go
│       ├── dataDownloadPackageHandler.go
│       └── client/
│           ├── client.go
│           └── enqueue.go
└── cmd/mcp/tools/service/
    └── data_download.go

约束和说明:

  • 多个 dataDownload*Services.go 仍属于现有 services package,只是按职责拆文件,避免产生超大单文件;
  • repositories/dataDownloadRepo.go 与现有 repositories.go 属于同一个 package,用于聚合 DataDownload 仓储能力,避免继续扩大单个聚合文件;
  • MongoDB Repository 保存任务、item、任务文档关系、逻辑文档目录、RenderSlot/Artifact 和 PackageJob;PostgreSQL Repository 只保存四种结构化文档的当前正文、ContentGeneration 和读取时间;
  • 上游 FWH API 客户端继续复用现有 repositories/api/fwh_pro 等实现,不复制 HTTP Client;
  • 新建独立 dataDownloadQueue,不向旧 BatchDownload handler 添加新逻辑;队列类型和队列名必须使用 DataDownload 独立命名空间;
  • dataDownloadQueue/client 是建议的小型子包,用于避免 services -> queue.Enqueue 与 queue handler -> services 形成 Go 循环依赖;具体落地前应结合 TaskCore 初始化方式验证;
  • MCP 按项目当前方式在 cmd/mcp/tools/service 中注册,只调用 DataDownload Service,不直接访问 MongoDB、Redis 或 MinIO;
  • Controller 从认证中间件取得用户名和 IsAdmin,构造 Actor 传入 Service;Owner、管理员和 AI 特殊账户权限必须由 Service 统一校验,不能只依赖 Controller。

18. 建议的实施顺序

  1. 在现有横向分层中创建独立 DataDownload 文件、路由、权限和 collection,不修改旧 BatchDownload。
  2. 增加 repositories.Init -> repositories/mongo.Init + repositories/pgsql.Init 通用初始化链,并在 Task Queue 启动前调用。
  3. 增加 repositories/minio/,实现文档修订和 Package ZIP 的对象存储封装。
  4. 定义 DataDownloadTask、DataDownloadItem、DataDownloadTaskDocument、Document、FaultRecordDailyDocument 和 PackageJob 模型。
  5. 实现站点本地日期、ZoneInfo/TimeZone 降级、UTC 边界和 D+2 计算,并覆盖 DST 测试。
  6. 定义范围请求、每日文档单元、RequestKey 和类型策略接口。
  7. 定义 PostgreSQL 中四种结构化文档的强类型当前数据表、ContentGeneration、事务内全量替换策略和 Renderer:PowerFlow、MqttLog、EventAlarm、UpdateLog。
  8. 实现 DocumentService 的 Plan、Find、Put、Open、Stat、空文档、结构化/文件存储路由及 Frozen 覆盖保护;Open 按 DocumentID、ContentGeneration、Representation 和 RendererVersion 复用 3 天派生缓存。
  9. 实现 Redis 租约 FileRequestService:Token 原子校验、自动续租、延迟重试、Abort/Commit。
  10. 先接入 PowerFlow 或 MQTT,验证站点日边界、Mutable 刷新、D+2 最终刷新、空结果和下载时渲染。
  11. 接入 EventAlarm、UpdateLog、AlarmSnapshot、EMSLog 和 FaultRecord 每日清单。
  12. 实现任务取消、partial_success、失败项人工重试、权限、自动轮询和批量操作。
  13. 实现目录树、所有预览/单文件下载、结构化文档 Renderer、3 天 RenderArtifact 缓存清理和 .tar.gz 内部浏览。
  14. 实现异步 PackageJob、待下载数据页面和 ZIP 生命周期。
  15. 实现管理员 Document Catalog 页面、关联检查和强制物理删除;不提供手工数据失效或手工清除生成文件缓存。
  16. 实现 MCP 逻辑目录、读取、存在性判断和 AI 账户任务创建接口。

19. 新会话快速提示

新会话可直接使用以下说明继续:

请先完整阅读 docs/data-download-document-service-design.md。新方案不是修改旧 BatchDownload,而是在现有 controller/service/domain/repository/route/task 横向分层中创建完全隔离的 DataDownload 模块,不建立 service/modules。Task Module 负责任务、上游查询、owner、取消/重试和 TaskDocument;FileRequestService 使用 Redis 租约、自动续租和延迟重试协调唯一写入渠道;DocumentService 负责按站点本地日期拆分、IANA ZoneInfo/固定偏移降级、RequestKey、D+2 固化、空文档、PostgreSQL 结构化正文和 MinIO 对象文件的存储与读取。PostgreSQL 按 DocumentID 只保存 PowerFlow、MqttLog、EventAlarm、UpdateLog 的当前结构化数据,不保存历史正文快照;当前四类接口完整读取成功后,在排他数据租约和事务内全量替换,提交时递增 ContentGeneration,任一分页或详情失败都不覆盖旧数据。生成文件时持有共享数据租约,避免提取过程中正文被修改。MongoDB 保存任务、逻辑文档目录、ContentGeneration 镜像、RenderSlot/RenderArtifact 和 PackageJob。预览或下载时根据当前 Generation 生成不可变临时导出文件,保存到 MinIO DataDownloadRenderCache/ 并最多复用 3 天;正文更新后在 RenderSlot 写租约内断开旧指针,新读取重新生成,旧文件在读取租约归零和短宽限期后删除。管理员不提供手工数据失效或手工清除生成文件缓存操作。PowerFlow 支持 HTML/JSON,MqttLog 与 EventAlarm 支持 XLSX/CSV/JSON,UpdateLog 支持 XLSX/JSON;新 Renderer 不生成 XLS。新增 repositories/minio/ 隐藏 Bucket/ObjectKey;启动采用 main -> repositories.Init -> mongo/pgsql 分层初始化;MongoDB 数据库名使用官方 URI parser,从 MONGO_DSN 取得,缺少数据库名时阻止启动。日期范围包含首尾、同一天只请求一天、最多包含首尾 600 天,对按日类型不允许未来日期;UpdateLog 完全忽略日期,日期范围只保存在任务结构中,并记录上游读取完成时间 SourceFetchedAt。预览支持 html/csv/xls/xlsx/log/txt/json,tar.gz 支持内部浏览,所有实际文件均可下载;一个实际文件直接下载,多个实际文件异步打包;ZIP 从 CompletedAt 起保留 7 天,expired PackageJob 再保留 30 天。删除终态 PackageJob 是删除 ZIP 包记录及其 ZIP,不是删除 DataDownloadTask;运行中 PackageJob 必须先取消。任务描述查询采用不区分大小写的字面量包含匹配。PowerFlow、MqttLog、EventAlarm、AlarmSnapshot、EmsLog、FaultRecord 在 D+2 后最终刷新并固化;UpdateLog 永不固化且每个任务查询全量信息;Settings 不属于本模块。MCP 只操作 DataDownload。设计决策已闭合,请按第 18 节顺序实施,不要修改或迁移旧 BatchDownload。