dataDownloadService.go 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432
  1. package services
  2. import (
  3. "context"
  4. "fmt"
  5. "math"
  6. "sort"
  7. "strconv"
  8. "strings"
  9. "time"
  10. "fwh-toolbox-server/cache"
  11. "fwh-toolbox-server/domain"
  12. "fwh-toolbox-server/dto"
  13. "fwh-toolbox-server/repositories"
  14. mongoRepo "fwh-toolbox-server/repositories/mongo"
  15. pgsqlRepo "fwh-toolbox-server/repositories/pgsql"
  16. "go.mongodb.org/mongo-driver/bson/primitive"
  17. "go.uber.org/zap"
  18. )
  19. const dataDownloadMaxDays = 600
  20. func CreateDataDownloadTask(ctx context.Context, task *domain.DataDownloadTask) error {
  21. if len(task.SNs) == 0 || task.Options.Empty() {
  22. return fmt.Errorf("at least one SN and one data type are required")
  23. }
  24. seen := map[string]struct{}{}
  25. normalized := make([]string, 0, len(task.SNs))
  26. for _, raw := range task.SNs {
  27. sn := strings.TrimSpace(raw)
  28. if sn == "" {
  29. continue
  30. }
  31. if _, exists := seen[sn]; !exists {
  32. seen[sn] = struct{}{}
  33. normalized = append(normalized, sn)
  34. }
  35. }
  36. if len(normalized) == 0 {
  37. return fmt.Errorf("at least one valid SN is required")
  38. }
  39. task.SNs = normalized
  40. if hasDateBasedOption(task.Options) {
  41. start, err := time.Parse(time.DateOnly, task.StartDate)
  42. if err != nil {
  43. return fmt.Errorf("invalid startDate")
  44. }
  45. end, err := time.Parse(time.DateOnly, task.EndDate)
  46. if err != nil {
  47. return fmt.Errorf("invalid endDate")
  48. }
  49. if end.Before(start) {
  50. return fmt.Errorf("endDate must not be before startDate")
  51. }
  52. if int(end.Sub(start).Hours()/24)+1 > dataDownloadMaxDays {
  53. return fmt.Errorf("date range must not exceed %d days", dataDownloadMaxDays)
  54. }
  55. }
  56. now := time.Now().UTC()
  57. task.ID = primitive.NewObjectID()
  58. task.Status = domain.DataDownloadPending
  59. task.CreatedAt, task.UpdatedAt = now, now
  60. return mongoRepo.InsertDataDownloadTask(ctx, task)
  61. }
  62. func hasDateBasedOption(o domain.DataDownloadOptions) bool {
  63. return o.PowerFlow || len(o.MqttLogs) > 0 || o.EventAlarm || o.AlarmSnapshot || len(o.EmsLogs) > 0 || o.FaultRecord
  64. }
  65. func PlanDataDownloadTask(ctx context.Context, task *domain.DataDownloadTask) ([]domain.DataDownloadItem, error) {
  66. var start, end time.Time
  67. var err error
  68. if hasDateBasedOption(task.Options) {
  69. start, err = time.Parse(time.DateOnly, task.StartDate)
  70. if err != nil {
  71. return nil, err
  72. }
  73. end, err = time.Parse(time.DateOnly, task.EndDate)
  74. if err != nil {
  75. return nil, err
  76. }
  77. }
  78. now := time.Now().UTC()
  79. items := []domain.DataDownloadItem{}
  80. for _, sn := range task.SNs {
  81. response, err := repositories.QuerySiteInfoList(dto.SiteInfoPara{SN: &sn, PageSize: 20, PageNum: 1})
  82. if err != nil {
  83. zap.L().Warn("[DataDownload] 规划:站点信息查询失败",
  84. zap.String("taskId", task.ID.Hex()), zap.String("sn", sn), zap.String("err", err.Error()))
  85. item := newDDItem(task.ID, "site_validation", sn, "", "", "", now)
  86. item.Status, item.Stage, item.FailureReason = domain.DataDownloadFailed, "validation", err.Error()
  87. items = append(items, item)
  88. continue
  89. }
  90. var site *domain.FwhSiteInfo
  91. if response != nil && response.Result != nil {
  92. for i := range *response.Result {
  93. if (*response.Result)[i].Id == sn {
  94. site = &(*response.Result)[i]
  95. break
  96. }
  97. }
  98. }
  99. if site == nil {
  100. item := newDDItem(task.ID, "site_validation", sn, "", "", "", now)
  101. item.Status, item.Stage, item.FailureReason = domain.DataDownloadFailed, "validation", "site does not exist"
  102. items = append(items, item)
  103. continue
  104. }
  105. zone, loc := siteLocation(site)
  106. var faultDevices []dataDownloadFaultDevice
  107. var faultDevicesErr error
  108. if task.Options.FaultRecord {
  109. faultDevices, faultDevicesErr = discoverDataDownloadFaultDevices(sn)
  110. if faultDevicesErr != nil {
  111. item := newDDItem(task.ID, domain.DataDownloadFaultRecord, sn, "", "device_discovery", zone, now)
  112. item.Status, item.Stage, item.FailureReason = domain.DataDownloadFailed, "planning", faultDevicesErr.Error()
  113. items = append(items, item)
  114. }
  115. }
  116. if hasDateBasedOption(task.Options) {
  117. localToday := now.In(loc).Format(time.DateOnly)
  118. if task.EndDate > localToday {
  119. return nil, fmt.Errorf("future date is not allowed for site %s (%s)", sn, zone)
  120. }
  121. for date := start; !date.After(end); date = date.AddDate(0, 0, 1) {
  122. dateText := date.Format(time.DateOnly)
  123. if task.Options.PowerFlow {
  124. items = append(items, newDDItem(task.ID, domain.DataDownloadPowerFlow, sn, dateText, "", zone, now))
  125. }
  126. for _, subtype := range task.Options.MqttLogs {
  127. items = append(items, newDDItem(task.ID, domain.DataDownloadMqttLog, sn, dateText, subtype, zone, now))
  128. }
  129. if task.Options.EventAlarm {
  130. items = append(items, newDDItem(task.ID, domain.DataDownloadEventAlarm, sn, dateText, "", zone, now))
  131. }
  132. if task.Options.AlarmSnapshot {
  133. items = append(items, newDDItem(task.ID, domain.DataDownloadAlarmSnapshot, sn, dateText, "", zone, now))
  134. }
  135. for _, subtype := range task.Options.EmsLogs {
  136. items = append(items, newDDItem(task.ID, domain.DataDownloadEmsLog, sn, dateText, subtype, zone, now))
  137. }
  138. if task.Options.FaultRecord {
  139. for _, device := range faultDevices {
  140. items = append(items, newDDFaultRecordItem(task.ID, sn, device.SN, device.Type, dateText, zone, now))
  141. }
  142. }
  143. }
  144. }
  145. if task.Options.UpdateLog {
  146. items = append(items, newDDItem(task.ID, domain.DataDownloadUpdateLog, sn, "", "", zone, now))
  147. }
  148. }
  149. return items, nil
  150. }
  151. type dataDownloadFaultDevice struct {
  152. SN string
  153. Type int
  154. }
  155. func discoverDataDownloadFaultDevices(siteSN string) ([]dataDownloadFaultDevice, error) {
  156. devices := map[string]dataDownloadFaultDevice{}
  157. add := func(sn string, deviceType int) {
  158. sn = strings.TrimSpace(sn)
  159. if sn != "" {
  160. devices[fmt.Sprintf("%d|%s", deviceType, sn)] = dataDownloadFaultDevice{SN: sn, Type: deviceType}
  161. }
  162. }
  163. add(siteSN, 1)
  164. base, err := repositories.QuerySiteBaseInfoList(dto.SiteBaseInfoPara{SiteList: []string{siteSN}, PageSize: 20, PageNum: 1})
  165. if err != nil {
  166. return sortedDataDownloadFaultDevices(devices), fmt.Errorf("discover fault-record devices for %s: %w", siteSN, err)
  167. }
  168. if base != nil && base.Result != nil && len(*base.Result) > 0 {
  169. for _, sn := range strings.Split((*base.Result)[0].FhpSn, ",") {
  170. add(sn, 0)
  171. }
  172. }
  173. return sortedDataDownloadFaultDevices(devices), nil
  174. }
  175. func sortedDataDownloadFaultDevices(devices map[string]dataDownloadFaultDevice) []dataDownloadFaultDevice {
  176. result := make([]dataDownloadFaultDevice, 0, len(devices))
  177. for _, device := range devices {
  178. result = append(result, device)
  179. }
  180. sort.Slice(result, func(i, j int) bool {
  181. if result[i].Type == result[j].Type {
  182. return result[i].SN < result[j].SN
  183. }
  184. return result[i].Type < result[j].Type
  185. })
  186. return result
  187. }
  188. func dataDownloadFaultRecordDeviceSN(record domain.FaultRecordInfo) (string, error) {
  189. switch record.DeviceType {
  190. case 0, 2:
  191. if record.ApowerId != nil && strings.TrimSpace(*record.ApowerId) != "" {
  192. return strings.TrimSpace(*record.ApowerId), nil
  193. }
  194. case 1:
  195. if strings.TrimSpace(record.GatewayId) != "" {
  196. return strings.TrimSpace(record.GatewayId), nil
  197. }
  198. case 3:
  199. if record.PowerlinkId != nil && strings.TrimSpace(*record.PowerlinkId) != "" {
  200. return strings.TrimSpace(*record.PowerlinkId), nil
  201. }
  202. default:
  203. return "", fmt.Errorf("unsupported fault-record device type %d", record.DeviceType)
  204. }
  205. return "", fmt.Errorf("fault-record device SN is missing for type %d record %d", record.DeviceType, record.Id)
  206. }
  207. func siteLocation(site *domain.FwhSiteInfo) (string, *time.Location) {
  208. if site.ZoneInfo != "" {
  209. if loc, err := time.LoadLocation(site.ZoneInfo); err == nil {
  210. return site.ZoneInfo, loc
  211. }
  212. }
  213. offset := int(math.Round(float64(site.TimeZone) * 3600))
  214. name := fmt.Sprintf("UTC%+.2f", site.TimeZone)
  215. return name, time.FixedZone(name, offset)
  216. }
  217. func dataDownloadLocation(name string) *time.Location {
  218. if loc, err := time.LoadLocation(name); err == nil {
  219. return loc
  220. }
  221. if strings.HasPrefix(name, "UTC") {
  222. if hours, err := strconv.ParseFloat(strings.TrimPrefix(name, "UTC"), 64); err == nil {
  223. return time.FixedZone(name, int(math.Round(hours*3600)))
  224. }
  225. }
  226. return time.UTC
  227. }
  228. func newDDItem(taskID primitive.ObjectID, kind domain.DataDownloadKind, sn, date, subtype, zone string, now time.Time) domain.DataDownloadItem {
  229. key := strings.Join([]string{string(kind), sn, date, subtype}, "|")
  230. return domain.DataDownloadItem{ID: primitive.NewObjectID(), TaskID: taskID, ItemKey: key, Kind: kind, SiteSN: sn, Date: date, Subtype: subtype, Timezone: zone, Status: domain.DataDownloadPending, CreatedAt: now, UpdatedAt: now}
  231. }
  232. func newDDFaultRecordItem(taskID primitive.ObjectID, siteSN, deviceSN string, deviceType int, date, zone string, now time.Time) domain.DataDownloadItem {
  233. item := newDDItem(taskID, domain.DataDownloadFaultRecord, siteSN, date, "", zone, now)
  234. item.DeviceSN = deviceSN
  235. item.DeviceType = strconv.Itoa(deviceType)
  236. item.ItemKey = strings.Join([]string{string(item.Kind), siteSN, deviceSN, item.DeviceType, date}, "|")
  237. return item
  238. }
  239. func ExecuteStructuredDataDownloadItem(ctx context.Context, item *domain.DataDownloadItem, awaitRequestIDs ...string) error {
  240. if item.Kind == domain.DataDownloadAlarmSnapshot || item.Kind == domain.DataDownloadEmsLog || item.Kind == domain.DataDownloadFaultRecord {
  241. return ExecuteBinaryDataDownloadItem(ctx, item, awaitRequestIDs...)
  242. }
  243. awaitRequestID := ""
  244. if len(awaitRequestIDs) > 0 {
  245. awaitRequestID = awaitRequestIDs[0]
  246. }
  247. ready, lease, err := prepareDataDownloadRequest(ctx, item, awaitRequestID)
  248. if err != nil {
  249. return err
  250. }
  251. if ready != nil {
  252. return linkDataDownloadItem(ctx, item, ready, ready.Empty)
  253. }
  254. defer lease.abort()
  255. requestKey := lease.requestKey
  256. documentID := lease.documentID
  257. loc := dataDownloadLocation(item.Timezone)
  258. var start, end time.Time
  259. if item.Date != "" {
  260. start, err = time.ParseInLocation(time.DateOnly, item.Date, loc)
  261. if err != nil {
  262. return err
  263. }
  264. end = start.AddDate(0, 0, 1).Add(-time.Nanosecond)
  265. }
  266. // 传给上游日志查询的时间必须是「不含时区」的字面时间(按 UTC 表示)。
  267. // QueryMqttLogListOnTimeRange / QueryEventOrAlarmListOnTimeRange 内部按天拆分时使用
  268. // time.Truncate(24h)(按 UTC 零点对齐),若传入带非 UTC 时区(如 America/Chicago)的时间,
  269. // 游标推进会回退到前一天,导致 periods 切片无限增长并 OOM。这里剥离时区、只保留字面日期。
  270. // 注意:start/end(本地时区)仍用于后面的 Frozen(D+2)判断,不能改动它们本身。
  271. startQuery := stripTimeLocation(start)
  272. endQuery := stripTimeLocation(end)
  273. fetchedAt := time.Now().UTC()
  274. rows := []pgsqlRepo.DataDownloadRow{}
  275. switch item.Kind {
  276. case domain.DataDownloadPowerFlow:
  277. response, err := repositories.QuerySitePowerFlow(item.SiteSN, start)
  278. if err != nil {
  279. zap.L().Warn("[DataDownload] PowerFlow 上游查询失败", zap.String("sn", item.SiteSN), zap.String("date", item.Date), zap.String("err", err.Error()))
  280. return err
  281. }
  282. if response.Result != nil {
  283. rows = append(rows, pgsqlRepo.DataDownloadRow{Payload: response.Result})
  284. }
  285. zap.L().Debug("[DataDownload] PowerFlow 上游返回", zap.String("sn", item.SiteSN), zap.String("date", item.Date), zap.Bool("hasData", response.Result != nil))
  286. case domain.DataDownloadMqttLog:
  287. values, err := repositories.QueryMqttLogListOnTimeRange(item.SiteSN, domain.MqttCmdType(item.Subtype), startQuery, endQuery)
  288. if err != nil {
  289. zap.L().Warn("[DataDownload] MqttLog 上游查询失败", zap.String("sn", item.SiteSN), zap.String("subtype", item.Subtype), zap.String("date", item.Date), zap.String("err", err.Error()))
  290. return err
  291. }
  292. for i := range values {
  293. occurred := parseRemoteTime(values[i].Time)
  294. rows = append(rows, pgsqlRepo.DataDownloadRow{OccurredAt: occurred, Subtype: item.Subtype, Payload: values[i]})
  295. }
  296. zap.L().Debug("[DataDownload] MqttLog 上游返回", zap.String("sn", item.SiteSN), zap.String("subtype", item.Subtype), zap.String("date", item.Date), zap.Int("rows", len(values)))
  297. case domain.DataDownloadEventAlarm:
  298. values, err := repositories.QueryEventOrAlarmListOnTimeRange(item.SiteSN, startQuery, endQuery, repositories.EventAndAlarm)
  299. if err != nil {
  300. zap.L().Warn("[DataDownload] EventAlarm 上游查询失败", zap.String("sn", item.SiteSN), zap.String("date", item.Date), zap.String("err", err.Error()))
  301. return err
  302. }
  303. for i := range values {
  304. occurred := parseRemoteTime(values[i].Time)
  305. rows = append(rows, pgsqlRepo.DataDownloadRow{OccurredAt: occurred, Payload: values[i]})
  306. }
  307. zap.L().Debug("[DataDownload] EventAlarm 上游返回", zap.String("sn", item.SiteSN), zap.String("date", item.Date), zap.Int("rows", len(values)))
  308. case domain.DataDownloadUpdateLog:
  309. system, err := repositories.QuerySystemUpdateLog(item.SiteSN)
  310. if err != nil {
  311. zap.L().Warn("[DataDownload] UpdateLog(system) 上游查询失败", zap.String("sn", item.SiteSN), zap.String("err", err.Error()))
  312. return err
  313. }
  314. for i := range system {
  315. occurred := parseRemoteTime(system[i].UpgradeTime)
  316. rows = append(rows, pgsqlRepo.DataDownloadRow{OccurredAt: occurred, DeviceSN: item.SiteSN, DeviceType: "gateway", Payload: system[i]})
  317. details, detailErr := repositories.QuerySystemUpdateLogDetail(system[i].Id, item.SiteSN)
  318. if detailErr != nil {
  319. zap.L().Warn("[DataDownload] UpdateLog(detail) 上游查询失败", zap.String("sn", item.SiteSN), zap.Int64("orderId", system[i].Id), zap.String("err", detailErr.Error()))
  320. return detailErr
  321. }
  322. for j := range details {
  323. rows = append(rows, pgsqlRepo.DataDownloadRow{DeviceSN: details[j].GatewayId, DeviceType: "system_detail", Payload: details[j]})
  324. }
  325. }
  326. ahub, err := repositories.QueryAHubUpdateLog(item.SiteSN)
  327. if err != nil {
  328. zap.L().Warn("[DataDownload] UpdateLog(ahub) 上游查询失败", zap.String("sn", item.SiteSN), zap.String("err", err.Error()))
  329. return err
  330. }
  331. for i := range ahub {
  332. rows = append(rows, pgsqlRepo.DataDownloadRow{DeviceSN: ahub[i].AHubSn, DeviceType: "ahub", Payload: ahub[i]})
  333. }
  334. zap.L().Debug("[DataDownload] UpdateLog 上游返回", zap.String("sn", item.SiteSN), zap.Int("systemOrders", len(system)), zap.Int("ahub", len(ahub)), zap.Int("totalRows", len(rows)))
  335. default:
  336. return fmt.Errorf("binary kind %s requires its dedicated collector", item.Kind)
  337. }
  338. contentLock, err := cache.Lock(ctx, "data-download:content:"+documentID, 10*time.Minute, 200*time.Millisecond, 30*time.Second)
  339. if err != nil {
  340. return err
  341. }
  342. zap.L().Debug("[DataDownload] 结构化 fetch 完成,准备提交",
  343. zap.String("documentId", documentID),
  344. zap.String("kind", string(item.Kind)),
  345. zap.String("sn", item.SiteSN),
  346. zap.String("date", item.Date),
  347. zap.Int("rowCount", len(rows)),
  348. )
  349. defer contentLock.SafeRelease(context.Background())
  350. if err := lease.validate(ctx); err != nil {
  351. return err
  352. }
  353. // DD-002 方向 A:先失效旧渲染缓存,再提交 PG 权威内容,最后回填 Mongo Document。
  354. // 断开 RenderSlot 后 currentArtifact 会因 slot.ArtifactID==nil 判定 miss,从而强制
  355. // 后续读者从 PG 的最新 generation 重新渲染。这样即使在「PG 已提交、Document 尚未回填」
  356. // 之间崩溃,读取也只会 miss 重渲染(读到 PG 的新代,正确),而不会命中指向旧代的缓存。
  357. if err := mongoRepo.DetachDataDownloadRenderSlots(ctx, documentID); err != nil {
  358. return err
  359. }
  360. state, err := pgsqlRepo.ReplaceDataDownloadRows(documentID, item.Kind, item.SiteSN, item.Date, item.Timezone, rows, fetchedAt)
  361. if err != nil {
  362. return err
  363. }
  364. documentState := domain.DataDownloadMutable
  365. var frozenAt *time.Time
  366. if item.Kind == domain.DataDownloadUpdateLog {
  367. documentState = domain.DataDownloadDynamic
  368. } else if !start.IsZero() && !time.Now().In(loc).Before(start.AddDate(0, 0, 2)) {
  369. now := time.Now().UTC()
  370. frozenAt = &now
  371. documentState = domain.DataDownloadFrozen
  372. }
  373. createdAt := fetchedAt
  374. if existing, err := mongoRepo.GetDataDownloadDocument(ctx, documentID); err == nil {
  375. createdAt = existing.CreatedAt
  376. }
  377. document := &domain.DataDownloadDocument{ID: documentID, RequestKey: requestKey, Kind: item.Kind, SiteSN: item.SiteSN, DeviceSN: item.DeviceSN, Date: item.Date, Subtype: item.Subtype, DeviceType: item.DeviceType, Timezone: item.Timezone, State: documentState, Empty: len(rows) == 0, ContentGeneration: state.ContentGeneration, Checksum: state.Checksum, SourceFetchedAt: fetchedAt, FrozenAt: frozenAt, CreatedAt: createdAt, UpdatedAt: fetchedAt}
  378. if err := mongoRepo.UpsertDataDownloadDocument(ctx, document); err != nil {
  379. return err
  380. }
  381. zap.L().Debug("[DataDownload] 结构化内容已提交",
  382. zap.String("documentId", documentID),
  383. zap.String("kind", string(item.Kind)),
  384. zap.String("state", string(documentState)),
  385. zap.Int64("generation", state.ContentGeneration),
  386. zap.Bool("empty", len(rows) == 0),
  387. )
  388. if err := lease.complete(ctx); err != nil {
  389. return err
  390. }
  391. return linkDataDownloadItem(ctx, item, document, len(rows) == 0)
  392. }
  393. // stripTimeLocation 把时间按字面 年/月/日/时/分/秒/纳秒 原样重建为 UTC 时间。
  394. // 用途:传给上游日志查询(QueryMqttLogListOnTimeRange / QueryEventOrAlarmListOnTimeRange)前,
  395. // 剥离本地时区。上游按天拆分时用 time.Truncate(24h)(按 UTC 零点对齐),若传入带非 UTC 时区
  396. // 的时间,游标推进会回退到前一天导致 periods 无限增长并 OOM。剥离后与 BatchDownload 行为一致,
  397. // 而查询仅把时间 Format 成字符串,字面值不变、查询语义不变。
  398. func stripTimeLocation(t time.Time) time.Time {
  399. if t.IsZero() {
  400. return t
  401. }
  402. return time.Date(t.Year(), t.Month(), t.Day(), t.Hour(), t.Minute(), t.Second(), t.Nanosecond(), time.UTC)
  403. }
  404. func parseRemoteTime(value string) *time.Time {
  405. for _, layout := range []string{time.RFC3339Nano, time.DateTime} {
  406. if parsed, err := time.Parse(layout, value); err == nil {
  407. return &parsed
  408. }
  409. }
  410. return nil
  411. }