状态:设计决策已闭合,尚未按本文实施业务代码。本文已合并截至 2026-09-19 确认的独立新模块、全球站点时区、按日拆分、D+2 固化、PostgreSQL 仅保存当前结构化正文、数据读写互锁、按需 Renderer 与 3 天不可变派生文件缓存、单写入渠道、任务权限、取消/重试、MCP、空文档、异步 ZIP、ZIP 完成后保留 7 天、PackageJob 人工删除规则、不允许未来日期、600 天日期跨度、预览格式、MinIO Repository、MongoDB 官方 URI 解析和通用 Repository 初始化等决策。
用途:供后续会话实施。本文记录已经确认的边界、接口、数据结构、并发语义和现有代码问题;剩余重试次数、租约时长、轮询频率等均为部署或实现参数,不再作为产品待决策事项。
当前批量下载任务保存在 MongoDB BatchDownloadConfig 集合中,任务记录同时包含:
items;data。实际文件使用类似以下路径:
BatchDownloadData/{SN}/{Type}/{FileName}
当前处理链路:
前端创建任务
-> MongoDB BatchDownloadConfig
-> Asynq 主任务
-> 同步/异步获取数据并生成文件
-> MinIO(按站点 SN 分类)
-> 将 MinIO 路径写回任务的 data 字段
-> 下载时实时组装 ZIP
现有 BatchDownload 作为旧模块完整保留:
DataDownload。建议的新模块命名空间:
/api/dataDownload/*
DataDownloadTask
DataDownloadItem
DataDownloadTaskDocument
DataDownloadDocument
DataDownloadRenderSlot
DataDownloadRenderArtifact
DataDownloadPackageJob
data-download:request:{requestKey}
DataDownloadBinary/...
DataDownloadRenderCache/...
DataDownloadPackage/...
旧模块主要代码位置(仅供参考,本次不在这些位置改造旧功能):
service/domain/batchDownloadDomain.goservice/repositories/mongo/batchDownloadRepo.goservice/services/batchDownloadServices.goservice/jobs/saveBatchDownloadData.goservice/task/queue/fwhQueue/fwhTaskBatchDownload.goservice/task/queue/fwhQueue/fwhTaskCallFile.gosrc/views/batch-download/index.vue旧 BatchDownloadData 嵌套结构、ExistsBatchDownloadXxx/StatBatchDownloadXxx 路径检查和 Redis CallFile 仓储都不复用于 DataDownload。由于旧模块必须继续运行,本次也不删除其仍在使用的代码或数据;只有未来单独下线旧模块时,才能在调用确认后清理这些依赖。
任务由任务 ID 标识,但 MinIO 对象按站点、类型和日期命名,路径中没有任务 ID。多个任务可能:
新方案允许“未固化逻辑文档”在后续请求中被有控制地覆盖,因此旧任务再次下载未固化文档时可能看到刷新后的内容;这里的问题是现状缺少明确的文档身份、固化边界和统一写入协调,而不是要求所有历史任务都永久保留原始字节快照。
src/views/batch-download/index.vue 中的 itemDelete 仍为 TODO。DeleteBatchDownloadConfig 只删除 MongoDB 任务记录。多个 MongoDB 更新方法使用 SetUpsert(true),包括状态更新、文件路径写入和 items 初始化。若任务记录已删除而 worker 仍在执行,后续写入可能用原 _id 创建一条残缺任务。
旧 BatchDownload 的这一问题只作为新模块设计参考,本次不修改旧 worker。新 DataDownload worker 的任务状态、item 和文档绑定写入不得使用 upsert,并应检查匹配结果及任务状态。
items 保存工作单元状态;data 保存产物路径。两者分别写入,可能出现文件已上传但路径写库失败、item 成功但没有文件、文件存在但 item 失败等不一致状态。
EnqueueBatchDownloadTask 返回的错误。AlarmSnapshot。date 和 logType 参数顺序疑似颠倒。filePath,未通过稳定的文档 ID 定位。系统拆分为三个职责不同的模块:
Task Module
-> FileRequestService
-> DocumentService
任务模块负责:
DocumentID 的关系及 ZIP 内的逻辑路径;任务模块不负责:
建议任务侧保存:
type TaskDocument struct {
ItemKey string
DocumentID string
LogicalPath string
}
任务与文档的引用关系归任务模块管理,DocumentService 不感知具体任务,也不维护引用计数。
由于 DataDownload 不限制站点数量,items 和任务文档清单不得继续嵌入一个 MongoDB 主任务文档,应使用独立 collection:
DataDownloadTask
DataDownloadItem
DataDownloadTaskDocument
这是并发请求协调层,解决“多个任务同时需要刷新同一个文档,于是一起请求相同数据”的问题。
它负责:
singleflight。它不负责具体业务数据的获取和文件生成。取得租约的任务负责请求数据,并按文档类型生成文件或准备结构化数据。
已确认系统中的文档内容不支持人工编辑,且只有这一条受控写入链路。FileRequestService 是共享文档写入的统一入口。
当已有相同 RequestKey 的租约时,调用方不长期阻塞 worker,而是结束本次执行并通过 Asynq 延迟任务重新检查。建议采用 15、30、60 秒等退避间隔。租约续期由 FileRequestService 统一自动完成,不要求各数据类型手写续租循环。
为了区分“同一轮共享请求的延迟等待者”和“稍后到达的新任务”,每轮请求还必须有独立 RequestID:
AwaitRequestID 重试;该轮完成后直接复用其结果,不再重复请求;AwaitRequestID 的新任务仍按 Mutable 规则重新请求和覆盖;DocumentService 负责文档缓存、结构化数据存储和文件存储,但不负责从业务数据源获取数据。
它负责:
DocumentID 提供读取和元信息查询,不向调用者暴露 MinIO 路径。它明确不负责:
仍需保留用于正确性判断的文档元数据,例如 DataDate、GeneratedAt、FreezeAt、State、校验值和创建时间。这些是缓存状态,不是运行历史。
现阶段不实现自动清理和 GC,物理删除由人工管理。
重要约束:由于引用关系归任务模块管理,DocumentService 无法单独判断一个文档是否仍被任务使用。人工物理删除可能破坏历史任务。管理员不提供手工“数据失效”或“清除生成文件缓存”操作;缓存换代全部由正文更新流程自动执行。物理删除仍是高权限操作,删除前由管理工具查询任务文档清单。
管理员允许在警告后强制物理删除仍被任务引用的文档。管理员页面必须提供“检查关联任务”操作;强制删除后,引用该 DocumentID 的历史任务可能无法读取该文件。若对应 RequestKey 正存在有效写入租约,则暂时禁止物理删除,避免 worker 在删除后立即重新写回。
| 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。
设数据日期为 D,固化边界为业务时区中的 D + 2天 00:00:00。例如 9 月 10 日的数据,在 9 月 12 日开始进入固化期。
只有在固化边界之后重新查询并生成/保存的内容才能标记为固化。在 D+2 之前生成的缓存不能因为时间到了就直接改成固化状态,必须在 D+2 后再刷新一次,以包含冻结前可能补入的数据:
当前时间 < FreezeAt
-> 新任务请求时重新查询并覆盖当前缓存
当前时间 >= FreezeAt,现有内容生成于 FreezeAt 之前
-> 再查询和保存一次,然后标记 Frozen
当前时间 >= FreezeAt,已有 Frozen 内容
-> 永久复用,不再查询和覆盖
固化边界使用每个站点自己的 IANA ZoneInfo 计算,不使用 worker 所在服务器的本地时区。
UpdateLog 不使用缓存命中跳过查询。每个批量下载任务都必须重新查询上游全量数据并更新数据库中的当前内容。UpdateLog 是不断补全的全量增量日志,任务所选时间范围对它完全无效,历史任务通过稳定 DocumentID 读取当前最新内容。任务选择的日期范围只原样保存在 DataDownloadTask 请求配置中,不参与 UpdateLog 的校验、RequestKey、查询参数、任务拆分或文档身份;UpdateLog 只记录本轮远程读取完成时间 SourceFetchedAt。
为了同时满足“每个任务都查询”和“单写入渠道”,相同 RequestKey 的这类任务可以通过租约串行执行,但前一个任务完成后,后一个任务仍要取得租约并重新查询,不能直接复用前一个任务刚写入的结果。
Settings 已明确从 DataDownload 中删除,后续由独立模块管理。DataDownload 的前端选项、领域类型、任务规划、缓存策略和 Renderer 均不包含 Settings。
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 后最终确认并固化为空结果。
所有 D+2 文档类型统一采用空结果语义:
skipped,不视为失败;Empty=true 的逻辑文档记录;RequestKey、状态、更新时间、FreezeAt 等元数据;任务的开始、结束日期不直接构成一个大文档。范围请求应先拆成每日文档单元,再逐日判断是否存在和是否固化。例如任务 A 请求 09-10 至 09-15,任务 B 请求 09-13 至 09-18,两者可以逐日复用 09-13、09-14、09-15。
两个任务是否需要同一个逻辑文档,由规范化文档单元生成的 RequestKey 判断,而不是由任务 ID、文件名或 MinIO 路径判断。RequestKey 应包含:
DocumentKind;SubType,例如 MQTT cmdType、EMS 日志类型;DevType;RequestKey 不包含任务 ID、任务描述、创建用户和 ZIP 内逻辑路径。例如:
v1|mqtt-log|SN001|2026-09-10|201|America/Los_Angeles|xlsx-v1
可对规范化字符串计算 SHA-256 作为实际键。Redis 租约和文档索引都使用同一个 RequestKey。当前 BatchDownloadItem.Key 维度不足,只用于任务内部工作单元定位,不能直接作为 RequestKey。
DocumentID 优先表示一个稳定的逻辑文档单元。未固化阶段的新请求会覆盖这个单元的当前内容;固化后内容不再变化。
这意味着:历史任务如果引用的是尚未固化的 DocumentID,在之后下载时可能读到被新任务刷新后的内容。这是“未固化数据新请求时覆盖”带来的明确行为。若未来要求历史任务永久保持创建时内容,则需改为每次刷新创建新 DocumentID 并保留版本。
用户选择的 YYYY-MM-DD 表示每个站点各自的本地日历日期,而不是统一的 UTC 日期。现有站点信息包含 ZoneInfo(例如 America/Los_Angeles)和数值 TimeZone;上游 PowerFlow、EMS 等接口以本地日期查询,MQTT/EventAlarm 同时返回设备时间和 UTC 时间。
统一规则:
AddDate(0, 0, n) 计算本地下一天和 D+2,不能使用固定 24/48 小时,以正确处理夏令时;建议时间模型:
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
}
时区解析降级规则:
ZoneInfo 合法时使用 IANA 时区及其夏令时规则;TimeZone 数值有效时,使用 time.FixedZone 创建固定偏移,并记录 TimezoneSource=offset;禁止继承旧代码中将无时区的 YYYY-MM-DD 直接按 UTC 解析、固定使用 America/Los_Angeles 或强制转换 Asia/Shanghai 的做法。
已选择数据库存储方案 B:对 PowerFlow、MqttLog、EventAlarm、UpdateLog 在 PostgreSQL 中只保存当前实际结构化数据,不保留历史正文快照,也不把生成后的 Excel/HTML 二进制作为文档正文。当前四类接口都按完整文档单元读取;任务确认上游完整读取成功后,在分布式写锁和 PostgreSQL 事务内全量替换当前数据。增量 UPSERT 只保留为未来真正接入增量接口时的扩展能力,不用于当前四类数据;预览或下载时才根据当前 ContentGeneration 生成临时导出文件。
生成后的导出文件允许作为派生缓存保存 3 天,以便预览、单文件下载和 ZIP 打包重复使用。派生缓存不是文档事实来源,过期或丢失后可以完全根据 PostgreSQL 正文重新生成。任务、文档目录、当前修订指针和派生缓存目录仍由 MongoDB 管理;派生文件内容存入 MinIO 的独立 DataDownloadRenderCache/ 前缀。
要求:
DocumentService.Open 先尝试复用未过期的派生文件,没有命中时调用对应 Renderer 从 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 是结构化正文的权威值。
已确认新增 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;DataDownloadBinary/、DataDownloadRenderCache/、DataDownloadPackage/ 或空文档预先创建目录对象。文件型文档提交顺序:
生成新的 BinaryContentRevision
-> 上传完整对象
-> 校验大小和 Checksum
-> MongoDB 原子切换 Document.CurrentBinaryRevision
-> Commit 成功
MongoDB 指针切换失败时,新对象没有成为当前内容。系统应尽力删除本次上传的孤立对象;删除失败时由管理员工具后续检查。稳定 DocumentID 始终通过 MongoDB 当前修订定位物理对象。
已确认不在 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) 使用明确的静态列表逐个调用各模型初始化器;init() 注册,避免隐藏顺序且无法返回错误;CreateIndexes 使用固定名称和固定定义,以便多实例启动时执行相同的幂等操作;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。
结构化正文始终只写 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 当前状态修复目录镜像,不回滚已提交的正文。
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 字符串,避免被服务器默认时区提前转换。
供 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)
}
供任务模块使用:
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 内部自动完成,不暴露为任务模块必须调用的公共方法。
不提供给普通任务:
type DocumentAdminService interface {
List(
ctx context.Context,
filter DocumentFilter,
) ([]DocumentMeta, error)
Delete(
ctx context.Context,
documentID string,
) error
}
管理员接口不提供手工数据失效或手工清除 RenderArtifact。结构化正文更新、RenderSlot 断开、新文件生成和旧文件退休均由后台自动完成。
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)
}
任务提交时间范围
-> Plan 按日期拆分
-> 每日计算 RequestKey
-> 已有 Frozen:直接返回 DocumentID
-> 不存在或 Mutable:竞争租约
-> 获得者查询上游
-> 保存结构化数据或文件,覆盖当前内容
-> 若已到 D+2,则状态写为 Frozen
-> 同一 RequestID 的延迟等待者使用本次结果
-> 任务保存每日 DocumentID + LogicalPath
“等待者使用本次结果”只用于合并同时发生的同一 RequestID 刷新,避免并发重复请求。该刷新完成后到达的下一次未固化请求没有 AwaitRequestID,仍会创建新一轮请求并覆盖。
每个任务请求
-> 必须进入查询流程,不因已有数据跳过
-> 相同 RequestKey 必要时通过租约串行
-> 每个任务依次重新查询
-> 覆盖数据库当前结构化数据
-> 保存/复用逻辑 DocumentID
长耗时 EMS/FaultRecord 请求由协调层自动续租。若 Prepare 发现相同 RequestKey 已有有效租约,则返回“进行中”结果并通过延迟任务稍后重试,不占用 worker 阻塞等待。
系统已确认:文档内容不支持编辑,只有 FileRequestService -> DocumentService 一个受控写入渠道。因此当前方案不再为文档索引设计独立的 CAS Revision 或多版本覆盖仲裁。
正确性依赖以下约束:
FileRequestService,禁止绕过协调层直接 Put。Commit 必须原子校验 Redis 中的 Token 与提交 Token 一致;租约过期或已被替换时拒绝提交。Token 校验是租约归属校验,不再维护多版本文档索引 CAS。如果未来增加第二个写入渠道、人工编辑、异步回写或跨系统同步,应重新引入 FencingToken 或更强的写入仲裁。
这里的“无多版本正文”不等于允许读取半成品。PostgreSQL 写入必须在排他数据租约和数据库事务中完成,提交时递增当前 ContentGeneration;Renderer 在共享数据租约中读取当前数据。ContentGeneration 只用于检测变化及隔离派生缓存,不映射到历史数据副本。
现阶段不实现自动清理和 GC,物理删除由人工管理。任务与文档引用关系仍归 Task Module;DocumentService 不维护引用计数。
删除任务默认只删除任务及其文档清单,不自动删除共享文档。人工删除文档前应查询任务引用或遵守保留期。新 DataDownload worker 的任务状态和结果写入不得使用 upsert,避免任务删除后被后台 worker 重新创建;旧 BatchDownload 本次不改动。
DataDownload 使用独立新页面,不修改旧 BatchDownload。当前确认三个子页面:
DataDownload
├── 任务管理
├── 待下载数据
└── 文件管理(仅管理员可见)
任务管理页面承担创建任务、任务列表、详情、取消、重试、描述修改、目录树、单文件预览/下载、整包下载和批量操作。待下载数据页面管理异步 ZIP。文件管理页面管理 DocumentService 中的全部逻辑文档和对象文件。
新页面不要求实现右键菜单,相关操作使用明确的按钮或批量操作入口。
SiteValidation failed item,不再展开每日和类型工作单元;任务状态:
pending
running
cancelling
cancelled
complete
partial_success
failed
error
item 状态继续使用:
pending
success
failed
skipped
等待相同 FileRequest 时仍显示 pending,不增加 waiting 状态。聚合规则:
complete;partial_success;failed;error;cancelled。任务“完成”的定义是数据已经成功缓存。数据库型文档不要求任务阶段预先生成 Excel/HTML;Renderer 在预览或下载时执行,Renderer 失败不反向修改原任务状态。
failed,任务按全部 item 聚合为 partial_success 或 failed;failed 和 partial_success 支持一键重试全部失败项;cancelled 不允许恢复或重试;attemptCount、最后失败阶段、最后失败原因和最后尝试时间;cancelled;支持:
| 操作 | Owner | 管理员 | 其他用户 |
|---|---|---|---|
| 查看任务和文件 | 是 | 是 | 是 |
| 预览和下载 | 是 | 是 | 是 |
| 修改描述 | 是 | 是 | 否 |
| 取消 | 是 | 是 | 否 |
| 重试 | 是 | 是 | 否 |
| 删除 | 是 | 是 | 否 |
| 批量下载 | 是 | 是 | 是 |
| 批量删除 | 是 | 是 | 否 |
| 修改 owner | 否 | 否 | 否 |
| 管理共享文档 | 否 | 是 | 否 |
.html、.csv、.xls、.xlsx、.log、.txt、.json;.tar.gz 内部目录浏览、内部文件下载和预览;.dat 及未知二进制格式只下载、不预览;.csv/.xlsx/.log 预览,但当前 Blob 未传入预览组件;新模块必须实现真实内容预览,不能复用其未完成行为;结构化文档导出格式已确认:
| 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 等对象型文档保持原文件,不做格式转换,只根据扩展名和内容类型决定是否支持预览。
.tar.gz 本体或单个归档内部文件可以直接下载。整包下载和批量任务下载采用异步打包,不沿用旧模块直接流式输出:
用户请求打包
-> 创建 DataDownloadPackageJob
-> 弹框提示前往“待下载数据”页面及目标文件名
-> 后台读取 TaskDocument
-> 渲染结构化文档/读取对象文件
-> 生成并校验完整 ZIP
-> 上传 DataDownloadPackage
-> 用户在待下载数据页面下载
提示示例:
打包任务已创建,请前往「待下载数据」页面下载。
文件名:DataDownload_20260918_143052.zip
弹框提供“确定”和“前往待下载数据”。
单任务文件名建议:
DataDownload_{taskID}_{timestamp}.zip
批量总包建议:
DataDownload_Multiple_{timestamp}.zip
├── {taskID-1}/...
├── {taskID-2}/...
└── {taskID-3}/...
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 记录。管理员仍可在此之前手动删除。
PackageJob 人工删除规则已确认。这里删除的是“待下载数据”中的 ZIP 打包任务记录 DataDownloadPackageJob 及其 ZIP,不是 DataDownloadTask 数据下载任务:
DataDownloadPackageJob(complete、failed、cancelled 或 expired)时,同时删除仍存在的 ZIP 对象;pending 或 running PackageJob 前必须先请求取消,并等待 worker 退出进入 cancelled 后再删除;异步 ZIP 是派生产物,与共享 Document 的人工清理策略不同,固定保留 7 天并由独立清理任务处理。打包临时目录在任务结束后立即清理。
Mutable DocumentID 可能在异步打包期间被刷新,而 PostgreSQL 不保留旧数据快照。PackageJob 在构建输入清单时,按稳定顺序为每个结构化 Document 获取共享数据读取租约,读取当时的当前 ContentGeneration,并确保生成或取得对应的不可变 RenderArtifact;随后为这些 Artifact 持有读取租约,释放数据读取租约,再开始组装 ZIP。正文后续更新可以继续进行,但旧 Artifact 在 PackageJob 结束前不得删除。历史任务以后重新发起打包时重新读取当时的当前数据。
页面仅管理员可见,浏览的是新 DataDownload 的逻辑 Document Catalog,而不是旧 BatchDownload 或裸 MinIO bucket。
至少展示和支持:
.tar.gz 内部浏览;管理员强制删除共享文档后,引用该 DocumentID 的任务仍保留清单,但读取时会得到文档不存在错误。关联检查只覆盖新 DataDownload;新旧文件完全隔离,因此无需检查旧 BatchDownload。
MCP 只查询和创建新 DataDownload,不访问旧 BatchDownload。由于部分文档存数据库、部分存对象存储,MCP 查询的是逻辑 Document Catalog,而不是直接遍历 MinIO。
MCP 后端能力:
.tar.gz 并读取内部文件;“检查 AI 活跃任务 + 创建任务”必须通过规范化任务指纹或短期 Redis 锁实现幂等,避免多个 MCP 请求并发创建相同任务。
PackageJob 人工删除规则和“不允许未来日期”已经确认。PostgreSQL 结构采用“当前正文状态表 + 按文档类型划分的强类型当前数据表”,不保存历史正文快照,也不复用现有按动态表名保存原始 MQTT JSON 的 SiteOriginalData。
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 创建新分区或新副本。
当前上游 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 生成逻辑。
建议表 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。
建议表 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 的显示语义。
UpdateLog 是 SN 级全量当前内容,不按日期拆分,也不把任务日期写入正文表或 RequestKey。其远程读取时间记录在 data_download_content_state.source_fetched_at。
使用四张当前正文表:
data_download_update_order:以 (document_id, order_no) 为稳定业务键,保存系统升级订单。data_download_update_device_version:以 (document_id, order_no, scope, device_sn, name) 为稳定业务键,保存 Gate 和子设备版本行。data_download_ahub_update:以 (document_id, order_no, ahub_sn) 为稳定业务键保存 aHub 升级记录。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 按产品指定时区格式化。
空结构化文档仍保留一条 data_download_content_state,row_count=0 且正文表无行;MongoDB Document 的 Empty=true 与状态表相互校验。管理员物理删除结构化 Document 时,在排他数据租约内删除其当前正文和状态行,并按 6.3 自动退休派生文件。Renderer 只读取当前数据;PackageJob 通过先物化不可变 RenderArtifact、再释放数据租约的方式保持打包输入稳定,不依赖 PostgreSQL 历史快照。
DataDownload 不使用运行时 GORM AutoMigrate。新增版本化 SQL migration,建议使用 pressly/goose/v3 加 Go embed 管理,并由 repositories/pgsql.Init(ctx) 在 worker 启动前执行;多实例部署时在 PostgreSQL advisory lock 内运行迁移。迁移失败阻止服务启动。GORM model/query 代码可以在 migration 后生成或手写,但不能反向承担生产表结构变更。
当前 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 变体,也不能在数据库名缺失时尽早失败。
评估过的两种方案:
MONGO_DATABASE:连接地址与业务库名完全分离,运维含义最清晰,也允许 DSN 不带路径;代价是所有部署环境、MCP 配置和 Secret 都要新增并保持两项配置一致,需要定义 DSN 路径与显式值冲突时谁优先。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。
后端必须沿用项目现有的横向分层,不创建与当前工程不一致的 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 仓储能力,避免继续扩大单个聚合文件;repositories/api/fwh_pro 等实现,不复制 HTTP Client;dataDownloadQueue,不向旧 BatchDownload handler 添加新逻辑;队列类型和队列名必须使用 DataDownload 独立命名空间;dataDownloadQueue/client 是建议的小型子包,用于避免 services -> queue.Enqueue 与 queue handler -> services 形成 Go 循环依赖;具体落地前应结合 TaskCore 初始化方式验证;cmd/mcp/tools/service 中注册,只调用 DataDownload Service,不直接访问 MongoDB、Redis 或 MinIO;IsAdmin,构造 Actor 传入 Service;Owner、管理员和 AI 特殊账户权限必须由 Service 统一校验,不能只依赖 Controller。repositories.Init -> repositories/mongo.Init + repositories/pgsql.Init 通用初始化链,并在 Task Queue 启动前调用。repositories/minio/,实现文档修订和 Package ZIP 的对象存储封装。.tar.gz 内部浏览。新会话可直接使用以下说明继续:
请先完整阅读
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 生成不可变临时导出文件,保存到 MinIODataDownloadRenderCache/并最多复用 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。