data-download-integrated-design-2026-09-21.md 91 KB

DataDownload 集成设计归档(截至 2026-09-21)

状态:历史归档,不再作为 AI 默认入口或当前事实的唯一来源。当前文档导航见 ../README.md。本文保留拆分前的完整上下文,供追溯原始决策和章节编号。

独立问题台账:docs/issues/data-download-open-issues.md。后续问题状态、优先级、验收条件和关闭记录统一在该文档维护。

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

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 不感知具体任务,也不维护引用计数。

共享文档与用户访问入口必须分离:底层 DataDownloadDocument 和对象文件可以被不同任务复用,但普通用户不能直接浏览全局文档目录,只能通过自己任务下的 DataDownloadTaskDocument 访问。用户删除任务后,对应 TaskDocument 一并删除,该用户即失去这些文件的访问入口;共享 Document 和对象文件不随任务删除,用户如再次需要,应重新创建任务并重新取得 TaskDocument 关联。管理员仍可通过文件管理页面浏览和管理全局 Document Catalog。

由于 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。

FaultRecord 同样适用 D+2。它的 Frozen 单元是一个 DeviceSN + DevType + DataDate 每日文档,不是整个任务、整个站点或单个物理文件;同一任务中不同设备/日期的文档可以分别进入 Frozen。

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
	Completeness string // complete / partial
	Empty      bool
	UpdatedAt  time.Time
	Entries    []FaultRecordEntry
}

type FaultRecordEntry struct {
	ID               primitive.ObjectID
	DocumentID       string
	SourceFileID     string // 由 faultID + subRecordID 等稳定上游标识规范化生成
	FaultID          int64
	SubRecordID      int64
	FileName         string
	ContentType      string
	Size             int64
	Checksum         string
	CurrentObjectKey string // 仅 Repository 内部使用
	LastSuccessfulAt time.Time
	UpdatedAt        time.Time
}

type DataDownloadFileAttempt struct {
	ID            primitive.ObjectID
	TaskID        primitive.ObjectID
	ItemID        primitive.ObjectID
	DocumentID    string
	SourceFileID  string
	FileName      string
	Status        string // pending / running / success / failed
	EntryID       *primitive.ObjectID
	AttemptCount  int
	FailureStage  string
	FailureReason string
	LastAttemptAt time.Time
}

同一文件以稳定上游标识而不是文件名判断;文件名允许重复。每次成功刷新先上传新的不可变对象,再切换 FaultRecordEntry.CurrentObjectKey。文件执行 Attempt 是任务历史,Entry 是团队复用的当前文件目录。

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

FaultRecord 文件按条目独立请求和发布。成功条目立即可用,失败条目保存任务级 DataDownloadFileAttempt,因此一个任务可以是 partial_success。刷新某个已有文件失败时,不切换该文件的当前对象,旧任务继续读取旧文件;发起本轮刷新的新任务必须记录请求失败,不能把旧文件计为本轮成功。上游后续完整查询未再次返回的历史文件继续保留,不因清单缺席自动删除;只有同一文件的新内容成功提交时才切换当前对象,或由管理员执行物理删除。

FaultRecord 的 DevType 使用可扩展整数,不把实现限制为当前 0-aPower、1-aGate、2-MPPT、3-aHub 四种。设备 SN 提取由 DevType 策略负责;未来出现尚无策略的新类型时创建明确的失败记录,不得错误回退为 SiteSN。

FaultRecord 每日文档只有在以下条件全部成立时才能 Frozen:已经到达 D+2;固化边界后成功完成一次完整清单查询;本轮清单要求的所有文件均请求成功;不存在待重试的失败条目;Completeness=complete。某个设备每日文档 Frozen 不等待其他设备或整个任务完成。

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。DataDownloadItem.ItemKey 只用于任务内部工作单元定位;当前实现已使用独立的、按数据类型区分的 RequestKey 构造器生成文档身份。

5.1 DocumentID 的当前语义

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

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

FaultRecord 的“覆盖”按稳定文件条目切换当前对象:新对象必须先完成下载、上传和 Size/Checksum 校验,再原子替换条目当前指针。新写入失败时旧对象继续作为当前版本;新任务保留本轮失败结果,历史任务不保存也不要求读取“当时那一版”对象。

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 当前修订定位物理对象。

FaultRecord 是多对象文件型文档,按每个稳定文件 Entry 分别执行上述不可变上传和指针切换。一个 Entry 失败不阻止其他成功 Entry 立即发布,但每日文档保持 Completeness=partial,本轮任务按文件结果聚合为 partial_success/failed。文件请求执行记录属于 Task Module;成功发布的 Entry 和当前对象指针属于共享 Document Catalog,二者不得混为同一事实来源。

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} 进度聚合和失败项重试
DataDownloadFileAttempt {taskID: 1, itemID: 1, sourceFileID: 1} unique 记录 FaultRecord 本轮每个文件的成功或失败,不把旧缓存误算为本轮成功
DataDownloadFileAttempt {taskID: 1, status: 1} 汇总部分成功并重试全部失败文件
DataDownloadFaultRecordEntry {documentID: 1, sourceFileID: 1} unique 一个设备每日文档下稳定文件身份及当前对象指针
DataDownloadFaultRecordEntry {documentID: 1, fileName: 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
partial
failed
skipped

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

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

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

FaultRecord 在 item 下增加文件级执行结果。一个 item 中部分文件成功、部分文件失败时,该 item 必须能够表达 partial 完整性并使任务聚合为 partial_success;成功文件立即建立可读取的 TaskDocument/文件入口,失败文件保留最后失败阶段、原因和尝试次数。旧共享文件即使仍可读取,也不能替代本轮失败结果计入新任务的成功数。

12.4 失败项重试

  • 单个 item 的上游请求失败时先由后台执行有上限的延迟自动重试;已经成功的 item 不受影响,也不重复执行;
  • 自动重试耗尽后该 item 才进入 failed,任务按全部 item 聚合为 partial_success 或 failed;
  • failed 和 partial_success 支持一键重试全部失败项;
  • FaultRecord 首版只提供“一键重试全部失败文件”,不提供单文件人工重试入口;内部仍按文件记录状态,重试只处理失败文件;
  • 已完成和 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;
  • 用户删除任务后即失去通过该任务访问文件的入口;后续重新创建相同请求时,可以重新关联仍存在的共享 Document;
  • 所有任务 worker 更新都禁止 upsert,并在过滤条件中校验任务仍允许写入,防止删除后复活。

12.6 任务列表和查询

支持:

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

12.7 Owner 和权限

  • 一个任务只有一个 owner;
  • 普通用户创建任务时 owner 为当前登录用户;
  • MCP 创建任务时 owner 固定为特殊普通用户 USER_AI;USER_AI 不是管理员;
  • AI 创建的任务不出现在普通用户任务列表中,仅管理员可在管理界面查看;MCP 只通过受限的数据可用性和自身请求状态接口访问,不取得管理员权限;
  • owner 创建后不可转移;普通 owner 和管理员均不能修改 owner;
  • 普通用户任务仅 owner 和管理员可见。底层文件虽然可被多个任务复用,但普通用户必须经自己的 TaskDocument 入口访问,不能直接浏览其他用户任务或全局 Document Catalog;
  • owner 可以修改自己的普通任务描述;管理员拥有所有任务权限和共享文档管理权限。
操作 Owner 管理员 其他用户
查看任务 是 是 否
经本任务预览和下载 是 是 否
修改描述 是 是 否
取消 是 是 否
重试 是 是 否
删除 是 是 否
批量下载本任务文件 是 是 否
批量删除 是 是 否
修改 owner 否 否 否
管理共享文档 否 是 否

USER_AI 的任务是后台系统任务:不向普通用户提供任务入口,只有管理员可以查看、取消、重试或删除。USER_AI 通过 MCP 专用 Service 能力查询共享数据是否可用、发起数据请求和读取允许的数据,但不能调用管理员 Document Delete,也不能查看普通用户的任务元数据。

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;
  • 面向普通用户的文件接口必须同时校验 TaskID 与 TaskDocument 关联,不能仅凭已知 DocumentID 绕过任务入口读取共享文件;管理员文件管理和受限 MCP 数据读取使用独立授权路径;
  • 当前任务入口 API 为 GET /dataDownload/tasks/:id/documents/:documentId/file;FaultRecord 原始文件使用 GET /dataDownload/tasks/:id/files/:entryId。两者均校验任务 owner/admin 和 TaskDocument 关联;GET /dataDownload/documents/:documentId/file 仅管理员可用;
  • 空文档不显示在普通目录树;
  • 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 批量操作

  • 实现批量下载和批量删除;
  • 只处理选中项中当前用户是 owner 或管理员且满足操作条件的任务;普通用户不能选择或下载其他用户的任务;
  • 不支持的选中项静默跳过,不弹逐项提示,也不要求自动取消选中;
  • 批量下载生成一个总 ZIP,并按 TaskID 分目录;
  • 用户在目录树选择部分文件时,用于绕开整包中某个文档渲染失败的问题;
  • 选择结果解析后只有一个实际文件时直接下载;包含两个或更多实际文件时创建只包含所选文件的异步 ZIP;
  • 单选目录如果展开后包含多个文件,仍按多文件异步 ZIP 处理;单选 .tar.gz 本体或单个归档内部文件可以直接下载。
  • partial_success 任务允许打包当前成功文件;ZIP 根目录必须附带 DataDownloadReport.json,明确记录任务状态、成功/失败数量以及失败文件的设备、日期、文件名、阶段和原因,禁止把部分 ZIP 表述为完整结果。

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
}

对 partial_success 任务,已知失败的 FileAttempt 不作为 ZIP 输入,PackageJob 打包当前成功文件并生成 DataDownloadReport.json,因此这类 ZIP 可以正常完成但必须明确是不完整结果。如果已经选入 ZIP 的可用文档/文件在实际读取时失败,或 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 采用“数据优先、任务内部化”模型。AI 不依赖上一会话记住 TaskID,而是把用户的数据需求重新规范化为 DocumentUnit,并查询每个单元的可用性。Task 只是缺失数据的后台请求、调度、失败重试和管理员审计载体,不是 AI 面向用户的主要查询对象。

MCP 后端能力:

  • 以特殊普通用户 USER_AI 身份工作,不拥有管理员权限;
  • 按用户提出的 Kind、SN/DeviceSN、日期、SubType 等条件查询所需逻辑文档,不向普通用户暴露全局管理目录;
  • 按 Kind、SN/DeviceSN、日期、SubType、状态查找文档;
  • 查询元信息、SourceFetchedAt、Empty、Completeness 和 Mutable/Frozen/Dynamic 状态;
  • 读取/下载文档内容;
  • 浏览 .tar.gz 并读取内部文件;
  • 判断目标文档是 missing、fetching、partial、available 还是 last_fetch_failed;
  • 目标文档 Frozen + complete 时直接使用,不创建任务;
  • 目标文档存在但尚未 Frozen 时,向用户说明数据日期和最后成功获取时间,由用户选择直接使用当前数据或发起刷新;不得把 Mutable 数据静默描述为最终固化数据;
  • 用户选择刷新而新请求失败时,旧文件继续可用,但本轮 USER_AI 任务记录失败。AI必须说明“刷新失败、旧版本仍存在”,再次由用户决定是否使用旧版本,不能把旧版本算作本轮请求成功;
  • 目标文档 partial 时可以读取已成功文件,同时明确列出缺失/失败数量,并允许发起补齐请求;
  • 目标文档缺失时查询 USER_AI 的活跃 DataDownload 请求是否已经覆盖所需 DocumentUnit;
  • 无可用文档且无覆盖请求时创建后台 DataDownload 任务;
  • 新任务 owner 固定为 USER_AI,只允许管理员在任务管理界面查看;
  • 创建后立即返回 taskID,不等待完成;
  • MCP 后续会话通过相同的数据条件再次查询 DocumentUnit 可用性,不要求记住 taskID;必要时可以查询 USER_AI 自身请求的失败摘要,但不得查看普通用户任务;
  • MCP 不允许直接 Put、覆盖或 Delete。

“检查 USER_AI 活跃请求 + 创建任务”必须通过规范化任务指纹或短期 Redis 锁实现幂等,避免多个 MCP 请求并发创建相同任务。任务指纹用于后台请求去重,不替代文档 RequestKey,也不赋予 USER_AI 管理员权限。

建议 MCP 面向 AI 返回数据可用性视图,而不是要求模型拼接任务、item 和文件记录:

type DataAvailability struct {
	RequestKey       string
	DocumentID       string
	Kind             DocumentKind
	SiteSN           string
	DeviceSN         string
	DevType          *int
	DataDate         string
	Availability     string // missing/fetching/partial/available/last_fetch_failed
	DocumentState    DocumentState
	Completeness     string // partial/complete
	FileCount        int
	FailedFileCount  int
	SourceFetchedAt  *time.Time
	LastRequestedAt  *time.Time
	LastFailureAt    *time.Time
	LastFailure      string
}

该视图可以引用 USER_AI 自身最近一次请求的汇总状态,但不得返回普通用户的 TaskID、描述、owner 或任务失败细节。普通用户通过自己的任务入口读取文件;USER_AI 通过受限 MCP Service 按数据条件读取,管理员通过 Document Catalog 管理全局共享内容。

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/README.md,再按任务加载需求、技术设计、实现状态和对应 DD 项;只有缺少历史依据时才读取本归档。

20. 当前实现基线、差异与已知问题(2026-09-21)

本节用于消除“目标设计已经全部实现”的歧义。当前代码是可编译的首版骨架,以下内容为实际代码审计结果;未在“已实现”中列出的目标不能视为已经完成。

20.1 已与设计一致的部分

  • DataDownload 使用独立路由、MongoDB collection、Asynq 队列和 MinIO 前缀,旧 BatchDownload 源码未修改。
  • MongoDB 数据库名通过官方 connstring.ParseAndValidate 解析;URI 缺少数据库名时启动失败,日志不输出原始 URI。
  • 启动采用显式 repositories.Init(ctx) -> mongo.Init(ctx) + pgsql.Init(ctx),并位于 scheduler/worker 启动之前。
  • 任务、item、TaskDocument、Document、RenderSlot/Artifact 和 PackageJob 使用独立 collection;item 和 TaskDocument 未嵌入任务主文档。
  • 日期范围包含首尾且最多 600 天;按站点时区判断未来日期;UpdateLog 不按日期拆分。
  • D+2 边界按站点本地 D+2 00:00 计算;UpdateLog 使用 Dynamic 状态。
  • PostgreSQL 只保留当前结构化正文,替换在事务内完成,并使用 advisory lock 保护正文读取/写入;每次提交递增 ContentGeneration。
  • 派生文件使用不可变 MinIO key,固定 72 小时过期;正文更新会断开 RenderSlot;文件读取、正文更新和过期清理使用同一 DocumentID Redis 互斥锁。
  • FileRequest 使用独立 RequestID/token 租约并自动续期;同轮 Mutable 等待者携带 AwaitRequestID 延迟重试,Dynamic 等待后重新竞争下一轮请求,不再长期阻塞 worker。
  • item 规划使用 (taskId,itemKey) upsert,重复规划会复用已有 _id;稳定 Asynq TaskID 冲突按已调度处理。
  • ZIP 成功后保留 7 天;终态 PackageJob 人工删除会删除 ZIP 和 PackageJob,不删除 DataDownloadTask;expired 记录会被后续清理。
  • Package、二进制文档和已有 RenderArtifact 的 HTTP 下载使用流式响应;下载和清理以相同锁协调。
  • 描述过滤使用转义后的、不区分大小写的字面量包含匹配。
  • ObjectKey 已从 HTTP/MCP JSON 输出中隐藏,Controller 只接受 DocumentID/TaskID。
  • 管理员页面不提供手工 Invalidate 或清除派生缓存操作。

20.2 与目标设计不一致或尚未实现

领域 当前代码事实 与目标设计的差异
数据读写锁 所有读写仍使用同一个排他 Redis content 锁;流式读取会续租,PostgreSQL 内另有 advisory shared/exclusive lock 没有分布式读写锁、writer-preference、活跃读取计数和短宽限期;普通提交/Renderer content 锁尚未统一续租
RequestKey 已使用按数据类型区分的规范化键,并与任务内部 ItemKey 分离;FaultRecord 使用 DeviceSN/DevType/DataDate 设备级身份 各类型内容语义变化时仍需独立提升 content_version
RenderSlot Slot 只有 DocumentID、format、ContentGeneration、ArtifactID 没有 Slot.Generation、RendererVersion、CacheKey、发布 Token/CAS 校验和 generating/retiring/deleting 状态
权威 Generation 校验 缓存命中比较 MongoDB Document 镜像和 Slot 没有每次命中都校验 PostgreSQL 权威 Generation;PG 提交后 Mongo 同步失败存在旧 Slot 可读窗口
PostgreSQL migration 自制 GORM SQL 语句数组和 migration 表 没有采用 goose/embed,也没有 migration 级 PostgreSQL advisory lock;多实例同时首次迁移可能冲突
PostgreSQL 表 已加入强类型列,但仍保留 payload jsonb 和早期通用表/列 UpdateLog device version 没有拆成完整 Gate/子设备版本行;EventAlarm 可解析时间列不完整;结构仍是过渡态
FaultRecord 已按 DeviceSN + DevType + 日期拆分每日文档,保存多个独立原始对象、文件级 Attempt、partial 状态和 D+2 Frozen;不生成每日 ZIP 当前上游下载仍以 []byte 暂存;设备类型出现新枚举时必须补充明确的 SN 提取策略
Renderer MQTT/EventAlarm 已输出固定列;PowerFlow HTML 是 JSON <pre>;UpdateLog XLSX 是单列 payload PowerFlow 尚未复用 ECharts;UpdateLog 尚未生成 SystemUpdate/aHubUpdate 两个 Sheet;JSON 尚无结构化 envelope
文件预览 前端当前只有下载按钮;后端可以输出部分格式并支持 tar.gz 列表/内部读取 API 没有真实 html/csv/xls/xlsx/log/txt/json 预览 UI,也没有 tar.gz 浏览 UI
批量操作 Package API 接受多个 TaskID,但任务页面只支持单任务整包 没有任务多选、批量删除、目录树部分文件选择、单实际文件直下/多文件异步 ZIP 选择逻辑
任务页面 有 scope/SN/status/描述过滤、详情、取消、重试、删除 创建时间过滤未接入 UI;描述修改 API 未接入 UI;其他用户仍会看到变更按钮,依赖后端拒绝
Package 页面 有分页、下载、取消、重试、删除和轮询 缺少状态及创建时间过滤
管理员文档页面 有分页、引用数、普通文档下载、FaultRecord 文件清单/单文件下载和强制删除 缺少 Kind/SN/日期/SubType/状态过滤、完整元信息、归档浏览和“活跃 FileRequest 租约”专门检查
MCP 提供任务列表、创建任务、文档分页列表 缺少文档过滤/详情/读取、tar.gz 浏览、存在性判断、活跃任务覆盖判断和“检查后创建”的幂等锁;当前创建总是新建任务
大数据处理 HTTP 下载已流式返回;Renderer 新生成内容、上游二进制获取和 ZIP 构建仍使用 []byte/bytes.Buffer 没有流式生成/上传和本地临时文件方案,大任务仍可能产生高内存占用
测试 有 URI parser、固定偏移、格式白名单和少量 Renderer 单元测试 缺少 D+2/DST、并发覆盖、崩溃恢复、取消竞态、Package 生命周期、权限、真实 PostgreSQL/Mongo/MinIO 集成测试

20.3 当前源码高风险问题

  1. 普通 content/render 锁仍可能过期:FileRequest 租约和流式读取锁已有 token 续租,但正文提交与 Renderer 的普通 content 锁仍是固定 TTL;Artifact 发布也没有完整 fencing/CAS。
  2. PG 与 Mongo/Slot 不是原子提交:结构化正文先提交 PostgreSQL,再更新 Mongo Document,最后断开 Slot。进程在中间崩溃时,Mongo Generation 和旧 Artifact 可能继续被命中。
  3. 内存放大:Renderer 新生成内容、上游二进制读取、构建 ZIP 和上传 ZIP 仍可能把完整内容放入内存;多任务打包可能导致 OOM。
  4. 迁移并发风险:自制 migration 没有全局 advisory lock;两个实例同时首次启动可能都判断版本不存在,最后一个实例因版本主键冲突启动失败。
  5. Artifact 发布缺少 fencing/CAS:Slot 发布仅按 (documentId,format) upsert,没有验证 Slot generation、Renderer token 和 PostgreSQL generation。
  6. 二进制上传孤儿对象:新对象上传成功而 Mongo Document 切换失败时,当前代码直接返回,没有尽力删除新对象或登记 orphan。
  7. 任务删除非事务化:TaskDocument、Item、Task 依次删除;中途失败会留下部分删除状态。运行状态检查与删除也不是一个原子条件更新。
  8. 归档读取缺少内容上限:单个 tar entry 使用声明的 header.Size 读取,没有产品级最大预览/下载限制;恶意或异常归档可能造成高内存占用。
  9. MinIO Repository 忽略 context:Repository 方法接受 context,但底层现有 MinIO helper 不接收/传播 context,取消请求不能及时中断对象操作。

20.4 当前仓库中与 DataDownload 无关、但会阻断全仓测试的问题

  • service/models/model_pgsql/siteOriginalData.go 存在 Go 语法错误。
  • service/cmd/gen/fwh_pro.go、service/cmd/gen/pgsql/alarm_snapshot.go 引用已经不存在的配置字段。
  • service/cmd/test_plane_v2、repositories/mqtt/fwh_mqtt_test/test 及多个 service/test/* 仍按旧签名调用 logger.Init。
  • service/test/getApowerSn 对 *[]domain.FwhSiteInfo 直接执行 range。
  • service/test/getSnapTest 调用已经不存在的 jobs.GetAlarmSnapshot。
  • service/test/temp 按旧参数数量调用 GetSiteAnalysisManageList。

20.5 后续修正优先级

  1. 先实现可续租且带 fencing token 的 FileRequest/文档锁,并修复 PG→Mongo→Slot 崩溃一致性。
  2. 让 plan handler 幂等,并为任务删除/状态切换增加条件更新或事务策略。
  3. 将 FaultRecord 改为设备级每日清单和独立原始对象。
  4. 改为流式或临时文件打包/上传,消除大对象 io.ReadAll。
  5. 完成 Renderer、真实预览、批量选择及 MCP 剩余能力。
  6. 补充并发、D+2/DST、权限、取消和外部存储集成测试。