dataDownloadBinaryService.go 9.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270
  1. package services
  2. import (
  3. "bytes"
  4. "context"
  5. "crypto/sha256"
  6. "encoding/hex"
  7. "errors"
  8. "fmt"
  9. "path"
  10. "strconv"
  11. "time"
  12. "fwh-toolbox-server/cache"
  13. "fwh-toolbox-server/domain"
  14. "fwh-toolbox-server/jobs"
  15. "fwh-toolbox-server/repositories"
  16. minioRepo "fwh-toolbox-server/repositories/minio"
  17. mongoRepo "fwh-toolbox-server/repositories/mongo"
  18. "github.com/xuri/excelize/v2"
  19. "go.mongodb.org/mongo-driver/mongo"
  20. "go.uber.org/zap"
  21. )
  22. func ExecuteBinaryDataDownloadItem(ctx context.Context, item *domain.DataDownloadItem, awaitRequestIDs ...string) error {
  23. awaitRequestID := ""
  24. if len(awaitRequestIDs) > 0 {
  25. awaitRequestID = awaitRequestIDs[0]
  26. }
  27. if item.Kind == domain.DataDownloadFaultRecord {
  28. return executeFaultRecordDataDownloadItem(ctx, item, awaitRequestID)
  29. }
  30. ready, lease, err := prepareDataDownloadRequest(ctx, item, awaitRequestID)
  31. if err != nil {
  32. return err
  33. }
  34. if ready != nil {
  35. return linkDataDownloadItem(ctx, item, ready, ready.Empty)
  36. }
  37. defer lease.abort()
  38. documentID := lease.documentID
  39. fetchedAt := time.Now().UTC()
  40. var data []byte
  41. var filename, contentType string
  42. switch item.Kind {
  43. case domain.DataDownloadAlarmSnapshot:
  44. values, err := jobs.GetAlarmSnapshotData(ctx, item.SiteSN, item.Date)
  45. if err != nil {
  46. zap.L().Warn("[DataDownload] AlarmSnapshot 上游查询失败", zap.String("sn", item.SiteSN), zap.String("date", item.Date), zap.String("err", err.Error()))
  47. return err
  48. }
  49. zap.L().Debug("[DataDownload] AlarmSnapshot 上游返回", zap.String("sn", item.SiteSN), zap.String("date", item.Date), zap.Int("rows", len(values)))
  50. if len(values) > 0 {
  51. file := excelize.NewFile()
  52. sheet := file.GetSheetName(0)
  53. _ = file.SetSheetRow(sheet, "A1", &[]any{"ID", "Time", "Data"})
  54. for i, value := range values {
  55. _ = file.SetSheetRow(sheet, fmt.Sprintf("A%d", i+2), &[]any{value.ID, value.LogTime.Format(time.RFC3339), value.JSONData})
  56. }
  57. buffer, err := file.WriteToBuffer()
  58. _ = file.Close()
  59. if err != nil {
  60. return err
  61. }
  62. data = buffer.Bytes()
  63. }
  64. filename, contentType = fmt.Sprintf("AlarmSnapshot_%s.xlsx", item.Date), "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
  65. case domain.DataDownloadEmsLog:
  66. logType, err := resolveEmsLogType(item.Subtype)
  67. if err != nil {
  68. return err
  69. }
  70. data, filename, err = fetchEmsLog(ctx, item, logType)
  71. if err != nil {
  72. return err
  73. }
  74. contentType = "application/octet-stream"
  75. default:
  76. return fmt.Errorf("%s is not a binary document kind", item.Kind)
  77. }
  78. contentLock, err := cache.Lock(ctx, "data-download:content:"+documentID, 10*time.Minute, 200*time.Millisecond, 30*time.Second)
  79. if err != nil {
  80. return err
  81. }
  82. defer contentLock.SafeRelease(context.Background())
  83. if err := lease.validate(ctx); err != nil {
  84. return err
  85. }
  86. objectKey := ""
  87. if len(data) > 0 {
  88. objectKey, err = minioRepo.PutBinaryDocument(ctx, documentID, filename, bytes.NewReader(data), int64(len(data)), contentType)
  89. if err != nil {
  90. return err
  91. }
  92. zap.L().Debug("[DataDownload] 二进制对象已上传 MinIO",
  93. zap.String("documentId", documentID), zap.String("kind", string(item.Kind)),
  94. zap.String("objectKey", objectKey), zap.Int("bytes", len(data)))
  95. }
  96. generation := int64(1)
  97. createdAt := fetchedAt
  98. oldObjectKey := ""
  99. if existing, err := mongoRepo.GetDataDownloadDocument(ctx, documentID); err == nil {
  100. generation = existing.ContentGeneration + 1
  101. createdAt = existing.CreatedAt
  102. oldObjectKey = existing.BinaryObjectKey
  103. } else if err != mongo.ErrNoDocuments {
  104. return err
  105. }
  106. sum := sha256.Sum256(data)
  107. state := domain.DataDownloadMutable
  108. var frozenAt *time.Time
  109. loc := dataDownloadLocation(item.Timezone)
  110. day, _ := time.ParseInLocation(time.DateOnly, item.Date, loc)
  111. if !time.Now().In(loc).Before(day.AddDate(0, 0, 2)) {
  112. now := time.Now().UTC()
  113. state, frozenAt = domain.DataDownloadFrozen, &now
  114. }
  115. document := &domain.DataDownloadDocument{ID: documentID, RequestKey: lease.requestKey, Kind: item.Kind, SiteSN: item.SiteSN, DeviceSN: item.DeviceSN, Date: item.Date, Subtype: item.Subtype, DeviceType: item.DeviceType, Timezone: item.Timezone, State: state, Empty: len(data) == 0, ContentGeneration: generation, BinaryObjectKey: objectKey, Checksum: hex.EncodeToString(sum[:]), SourceFetchedAt: fetchedAt, FrozenAt: frozenAt, CreatedAt: createdAt, UpdatedAt: fetchedAt}
  116. if err := mongoRepo.UpsertDataDownloadDocument(ctx, document); err != nil {
  117. return err
  118. }
  119. if oldObjectKey != "" && oldObjectKey != objectKey {
  120. _ = minioRepo.Delete(ctx, oldObjectKey)
  121. }
  122. if err := lease.complete(ctx); err != nil {
  123. return err
  124. }
  125. return linkDataDownloadItem(ctx, item, document, document.Empty)
  126. }
  127. // DataDownloadSNDeferred keeps another file for this SN in the queue without
  128. // consuming an Asynq retry or another worker while the current call runs.
  129. type DataDownloadSNDeferred struct{ After time.Duration }
  130. func (e *DataDownloadSNDeferred) Error() string { return "EMS log call for SN is busy" }
  131. func fetchEmsLog(ctx context.Context, item *domain.DataDownloadItem, logType int) ([]byte, string, error) {
  132. slot, ok, err := jobs.TryAcquireEmsLogCallSlot(ctx, item.SiteSN)
  133. if err != nil {
  134. return nil, "", err
  135. }
  136. if !ok {
  137. return nil, "", &DataDownloadSNDeferred{After: 15 * time.Second}
  138. }
  139. defer slot.Release()
  140. callCtx, cancel := context.WithTimeout(slot.Context(), jobs.EmsLogCallTimeout)
  141. defer cancel()
  142. baselineID, err := repositories.LatestEmsLogCallRecordIDContext(callCtx, item.SiteSN, item.Date, logType)
  143. if callCtx.Err() != nil {
  144. if errors.Is(callCtx.Err(), context.DeadlineExceeded) {
  145. return nil, "", jobs.ErrEmsLogCallTimeout
  146. }
  147. return nil, "", jobs.ErrEmsLogCallSlotLost
  148. }
  149. if err != nil {
  150. return nil, "", err
  151. }
  152. err = repositories.QueryCallEmsLogContext(callCtx, item.SiteSN, item.Date, logType)
  153. if errors.Is(callCtx.Err(), context.DeadlineExceeded) {
  154. return nil, "", jobs.ErrEmsLogCallTimeout
  155. }
  156. if callCtx.Err() != nil && ctx.Err() == nil {
  157. return nil, "", jobs.ErrEmsLogCallSlotLost
  158. }
  159. if err != nil {
  160. return nil, "", err
  161. }
  162. zap.L().Debug("[DataDownload] EmsLog 已召唤,等待生成",
  163. zap.String("sn", item.SiteSN), zap.String("date", item.Date), zap.Int("logType", logType))
  164. record, err := waitEmsLog(callCtx, item.SiteSN, item.Date, logType, baselineID)
  165. if err != nil {
  166. if errors.Is(err, context.DeadlineExceeded) {
  167. return nil, "", jobs.ErrEmsLogCallTimeout
  168. }
  169. if errors.Is(err, context.Canceled) && ctx.Err() == nil {
  170. return nil, "", jobs.ErrEmsLogCallSlotLost
  171. }
  172. return nil, "", err
  173. }
  174. data, err := repositories.QueryDownloadEmsLogFileContext(callCtx, record.FileUrl)
  175. if errors.Is(callCtx.Err(), context.DeadlineExceeded) {
  176. return nil, "", jobs.ErrEmsLogCallTimeout
  177. }
  178. if callCtx.Err() != nil && ctx.Err() == nil {
  179. return nil, "", jobs.ErrEmsLogCallSlotLost
  180. }
  181. if err != nil {
  182. return nil, "", err
  183. }
  184. zap.L().Debug("[DataDownload] EmsLog 文件已下载到内存",
  185. zap.String("sn", item.SiteSN), zap.String("date", item.Date),
  186. zap.Int("bytes", len(data)), zap.String("fileUrl", record.FileUrl))
  187. filename := path.Base(record.FileUrl)
  188. if filename == "." || filename == "" {
  189. filename = fmt.Sprintf("EmsLog_%s_%s.tar.gz", item.Date, item.Subtype)
  190. }
  191. return data, filename, nil
  192. }
  193. func waitEmsLog(ctx context.Context, sn, date string, logType int, baselineID int64) (*domain.EmsLogRecordInfo, error) {
  194. ticker := time.NewTicker(15 * time.Second)
  195. defer ticker.Stop()
  196. for {
  197. select {
  198. case <-ctx.Done():
  199. return nil, ctx.Err()
  200. case <-ticker.C:
  201. if err := ctx.Err(); err != nil {
  202. return nil, err
  203. }
  204. record, err := repositories.QueryEmsLogCallRecordAfterContext(ctx, sn, date, logType, baselineID)
  205. if ctx.Err() != nil {
  206. return nil, ctx.Err()
  207. }
  208. if err != nil {
  209. continue
  210. }
  211. if record.Status < 0 {
  212. return nil, fmt.Errorf("EMS log request failed")
  213. }
  214. if record.Status == 1 {
  215. return record, nil
  216. }
  217. }
  218. }
  219. }
  220. func stableDataDownloadDocumentID(requestKey string) string {
  221. sum := sha256.Sum256([]byte(requestKey))
  222. return hex.EncodeToString(sum[:])
  223. }
  224. func linkDataDownloadItem(ctx context.Context, item *domain.DataDownloadItem, document *domain.DataDownloadDocument, empty bool) error {
  225. task, err := mongoRepo.GetDataDownloadTask(ctx, item.TaskID)
  226. if err != nil {
  227. return err
  228. }
  229. if task.Status == domain.DataDownloadCancelling || task.Status == domain.DataDownloadCancelled {
  230. return nil
  231. }
  232. logicalPath := fmt.Sprintf("%s/%s/%s", item.SiteSN, item.Kind, item.Date)
  233. if item.Kind == domain.DataDownloadFaultRecord {
  234. logicalPath = fmt.Sprintf("%s/%s/%s-%s/%s", item.SiteSN, item.Kind, item.DeviceSN, item.DeviceType, item.Date)
  235. }
  236. return mongoRepo.LinkDataDownloadDocument(ctx, &domain.DataDownloadTaskDocument{TaskID: item.TaskID, ItemKey: item.ItemKey, DocumentID: document.ID, LogicalPath: logicalPath, Empty: empty, CreatedAt: time.Now().UTC()})
  237. }
  238. // dataDownloadEmsLogTypeMap 将 EMS 日志的字符串枚举映射为上游要求的数字 logType,
  239. // 与 BatchDownload(fwhTaskCallFile.go)的 mapEmsLogType 保持一致。
  240. var dataDownloadEmsLogTypeMap = map[string]int{
  241. "EmsRunLog": 4, // IBG运行日志
  242. "EmsBattLog": 7, // 电池日志
  243. "EmsMonitorLog": 9, // 设备系统资源监控日志
  244. "Ems4gLog": 10, // 4G日志
  245. "EmsAwsLog": 11, // AWS日志
  246. "EmsMainLog": 12, // 主进程日志
  247. }
  248. // resolveEmsLogType 优先按字符串枚举映射为数字 logType;
  249. // 若传入的本身就是数字字符串(如 "4")则兼容解析,保证历史数据与测试可用。
  250. func resolveEmsLogType(subtype string) (int, error) {
  251. if n, ok := dataDownloadEmsLogTypeMap[subtype]; ok {
  252. return n, nil
  253. }
  254. if n, err := strconv.Atoi(subtype); err == nil {
  255. return n, nil
  256. }
  257. return 0, fmt.Errorf("invalid EMS log type %q", subtype)
  258. }