dataDownloadRenderService.go 9.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302
  1. package services
  2. import (
  3. "bytes"
  4. "context"
  5. "crypto/sha256"
  6. "encoding/csv"
  7. "encoding/hex"
  8. "encoding/json"
  9. "fmt"
  10. "html"
  11. "io"
  12. "strings"
  13. "time"
  14. "fwh-toolbox-server/cache"
  15. "fwh-toolbox-server/domain"
  16. minioRepo "fwh-toolbox-server/repositories/minio"
  17. mongoRepo "fwh-toolbox-server/repositories/mongo"
  18. pgsqlRepo "fwh-toolbox-server/repositories/pgsql"
  19. "github.com/xuri/excelize/v2"
  20. "go.mongodb.org/mongo-driver/mongo"
  21. "go.uber.org/zap"
  22. )
  23. const renderCacheTTL = 72 * time.Hour
  24. func ReadRenderedDataDownloadDocument(ctx context.Context, documentID, format string) (*domain.DataDownloadArtifact, []byte, error) {
  25. document, format, lock, err := lockRenderedDataDownloadDocument(ctx, documentID, format)
  26. if err != nil {
  27. return nil, nil, err
  28. }
  29. defer lock.SafeRelease(context.Background())
  30. artifact, data, err := resolveRenderedDataDownloadDocument(ctx, document, format)
  31. if err != nil || data != nil {
  32. return artifact, data, err
  33. }
  34. data, err = readDataDownloadArtifact(ctx, artifact)
  35. return artifact, data, err
  36. }
  37. func OpenRenderedDataDownloadDocument(ctx context.Context, documentID, format string) (*domain.DataDownloadArtifact, io.ReadCloser, error) {
  38. document, format, lock, err := lockRenderedDataDownloadDocument(ctx, documentID, format)
  39. if err != nil {
  40. return nil, nil, err
  41. }
  42. artifact, data, err := resolveRenderedDataDownloadDocument(ctx, document, format)
  43. if err != nil {
  44. lock.SafeRelease(context.Background())
  45. return nil, nil, err
  46. }
  47. var reader io.ReadCloser
  48. if data != nil {
  49. reader = io.NopCloser(bytes.NewReader(data))
  50. } else {
  51. reader, err = minioRepo.Open(ctx, artifact.ObjectKey)
  52. if err != nil {
  53. lock.SafeRelease(context.Background())
  54. return nil, nil, err
  55. }
  56. }
  57. return artifact, newDataDownloadContentReader(reader, lock), nil
  58. }
  59. func lockRenderedDataDownloadDocument(ctx context.Context, documentID, format string) (*domain.DataDownloadDocument, string, *cache.RedisLock, error) {
  60. document, err := mongoRepo.GetDataDownloadDocument(ctx, documentID)
  61. if err != nil {
  62. return nil, "", nil, err
  63. }
  64. format = strings.ToLower(strings.TrimPrefix(format, "."))
  65. if !supportedRenderFormat(document.Kind, format) {
  66. return nil, "", nil, fmt.Errorf("format %s is not supported for %s", format, document.Kind)
  67. }
  68. // This lock is shared by writers. It is deliberately exclusive at Redis level;
  69. // PostgreSQL's advisory shared lock still permits safe concurrent database readers.
  70. lock, err := cache.Lock(ctx, "data-download:content:"+documentID, 10*time.Minute, 200*time.Millisecond, 30*time.Second)
  71. if err != nil {
  72. return nil, "", nil, err
  73. }
  74. document, err = mongoRepo.GetDataDownloadDocument(ctx, documentID)
  75. if err != nil {
  76. lock.SafeRelease(context.Background())
  77. return nil, "", nil, err
  78. }
  79. return document, format, lock, nil
  80. }
  81. func resolveRenderedDataDownloadDocument(ctx context.Context, document *domain.DataDownloadDocument, format string) (*domain.DataDownloadArtifact, []byte, error) {
  82. if cached, err := currentArtifact(ctx, document, format); err == nil {
  83. zap.L().Debug("[DataDownload] 渲染缓存命中",
  84. zap.String("documentId", document.ID), zap.String("format", format),
  85. zap.Int64("generation", cached.ContentGeneration))
  86. return cached, nil, nil
  87. }
  88. state, rows, err := pgsqlRepo.ReadDataDownloadRows(document.ID, document.Kind)
  89. if err != nil {
  90. return nil, nil, err
  91. }
  92. data, contentType, err := encodeRenderedRows(document, rows, format)
  93. if err != nil {
  94. return nil, nil, err
  95. }
  96. objectKey, err := minioRepo.PutRenderArtifact(ctx, document.ID, state.ContentGeneration, format, data, contentType)
  97. if err != nil {
  98. return nil, nil, err
  99. }
  100. sum := sha256.Sum256(data)
  101. now := time.Now().UTC()
  102. artifact := &domain.DataDownloadArtifact{DocumentID: document.ID, Format: format, ContentGeneration: state.ContentGeneration, ObjectKey: objectKey, Filename: renderedFilename(document, format), Size: int64(len(data)), Checksum: hex.EncodeToString(sum[:]), ExpiresAt: now.Add(renderCacheTTL), CreatedAt: now}
  103. if err := mongoRepo.PublishDataDownloadArtifact(ctx, artifact); err != nil {
  104. _ = minioRepo.Delete(ctx, objectKey)
  105. return nil, nil, err
  106. }
  107. zap.L().Debug("[DataDownload] 渲染完成并发布 artifact",
  108. zap.String("documentId", document.ID), zap.String("format", format),
  109. zap.Int64("generation", state.ContentGeneration), zap.Int("bytes", len(data)))
  110. return artifact, data, nil
  111. }
  112. type dataDownloadContentReader struct {
  113. io.ReadCloser
  114. lock *cache.RedisLock
  115. cancel context.CancelFunc
  116. done chan struct{}
  117. }
  118. func newDataDownloadContentReader(reader io.ReadCloser, lock *cache.RedisLock) *dataDownloadContentReader {
  119. ctx, cancel := context.WithCancel(context.Background())
  120. result := &dataDownloadContentReader{ReadCloser: reader, lock: lock, cancel: cancel, done: make(chan struct{})}
  121. go func() {
  122. defer close(result.done)
  123. ticker := time.NewTicker(3 * time.Minute)
  124. defer ticker.Stop()
  125. for {
  126. select {
  127. case <-ctx.Done():
  128. return
  129. case <-ticker.C:
  130. renewCtx, renewCancel := context.WithTimeout(context.Background(), 10*time.Second)
  131. _ = lock.Extend(renewCtx, 10*time.Minute)
  132. renewCancel()
  133. }
  134. }
  135. }()
  136. return result
  137. }
  138. func (r *dataDownloadContentReader) Close() error {
  139. r.cancel()
  140. <-r.done
  141. err := r.ReadCloser.Close()
  142. r.lock.SafeRelease(context.Background())
  143. return err
  144. }
  145. func readDataDownloadArtifact(ctx context.Context, artifact *domain.DataDownloadArtifact) ([]byte, error) {
  146. reader, err := minioRepo.Open(ctx, artifact.ObjectKey)
  147. if err != nil {
  148. return nil, err
  149. }
  150. defer reader.Close()
  151. return io.ReadAll(reader)
  152. }
  153. func currentArtifact(ctx context.Context, document *domain.DataDownloadDocument, format string) (*domain.DataDownloadArtifact, error) {
  154. slot, err := mongoRepo.GetDataDownloadRenderSlot(ctx, document.ID, format)
  155. if err != nil {
  156. return nil, err
  157. }
  158. if slot.ArtifactID == nil || slot.ContentGeneration != document.ContentGeneration {
  159. return nil, mongo.ErrNoDocuments
  160. }
  161. artifact, err := mongoRepo.GetDataDownloadArtifact(ctx, *slot.ArtifactID)
  162. if err != nil {
  163. return nil, err
  164. }
  165. if !artifact.ExpiresAt.After(time.Now()) {
  166. return nil, mongo.ErrNoDocuments
  167. }
  168. return artifact, nil
  169. }
  170. func supportedRenderFormat(kind domain.DataDownloadKind, format string) bool {
  171. switch kind {
  172. case domain.DataDownloadPowerFlow:
  173. return format == "html" || format == "json"
  174. case domain.DataDownloadMqttLog, domain.DataDownloadEventAlarm:
  175. return format == "xlsx" || format == "csv" || format == "json"
  176. case domain.DataDownloadUpdateLog:
  177. return format == "xlsx" || format == "json"
  178. default:
  179. return false
  180. }
  181. }
  182. func encodeRenderedRows(document *domain.DataDownloadDocument, rows []json.RawMessage, format string) ([]byte, string, error) {
  183. switch format {
  184. case "json":
  185. data, err := json.MarshalIndent(rows, "", " ")
  186. return data, "application/json", err
  187. case "csv":
  188. var out bytes.Buffer
  189. writer := csv.NewWriter(&out)
  190. headers, table := dataDownloadTable(document.Kind, rows)
  191. _ = writer.Write(headers)
  192. for _, row := range table {
  193. _ = writer.Write(row)
  194. }
  195. writer.Flush()
  196. return out.Bytes(), "text/csv; charset=utf-8", writer.Error()
  197. case "html":
  198. var out strings.Builder
  199. out.WriteString("<!doctype html><html><head><meta charset=\"utf-8\"><title>PowerFlow</title></head><body><h1>PowerFlow ")
  200. out.WriteString(html.EscapeString(document.SiteSN + " " + document.Date))
  201. out.WriteString("</h1><pre>")
  202. pretty, _ := json.MarshalIndent(rows, "", " ")
  203. out.WriteString(html.EscapeString(string(pretty)))
  204. out.WriteString("</pre></body></html>")
  205. return []byte(out.String()), "text/html; charset=utf-8", nil
  206. case "xlsx":
  207. file := excelize.NewFile()
  208. sheet := file.GetSheetName(0)
  209. headers, table := dataDownloadTable(document.Kind, rows)
  210. for column, header := range headers {
  211. cell, _ := excelize.CoordinatesToCellName(column+1, 1)
  212. _ = file.SetCellValue(sheet, cell, header)
  213. }
  214. for rowIndex, row := range table {
  215. for column, value := range row {
  216. cell, _ := excelize.CoordinatesToCellName(column+1, rowIndex+2)
  217. _ = file.SetCellValue(sheet, cell, value)
  218. }
  219. }
  220. buffer, err := file.WriteToBuffer()
  221. _ = file.Close()
  222. if err != nil {
  223. return nil, "", err
  224. }
  225. return buffer.Bytes(), "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", nil
  226. default:
  227. return nil, "", fmt.Errorf("unsupported format")
  228. }
  229. }
  230. func dataDownloadTable(kind domain.DataDownloadKind, rows []json.RawMessage) ([]string, [][]string) {
  231. fields := []string{"payload"}
  232. headers := []string{"Payload"}
  233. switch kind {
  234. case domain.DataDownloadMqttLog:
  235. fields, headers = []string{"cmdType", "logTime", "logData"}, []string{"Type", "Time", "Data"}
  236. case domain.DataDownloadEventAlarm:
  237. fields = []string{"id", "logTime", "logType", "alarmCode", "logName", "startTime", "endTime", "logData"}
  238. headers = []string{"ID", "Time", "Type", "AlarmCode", "Name", "StartTime", "EndTime", "Data"}
  239. }
  240. table := make([][]string, 0, len(rows))
  241. for index, raw := range rows {
  242. if len(fields) == 1 {
  243. table = append(table, []string{string(raw)})
  244. continue
  245. }
  246. var object map[string]any
  247. if err := json.Unmarshal(raw, &object); err != nil {
  248. table = append(table, []string{string(raw)})
  249. continue
  250. }
  251. row := make([]string, len(fields))
  252. for column, field := range fields {
  253. value := object[field]
  254. if value == nil && kind == domain.DataDownloadEventAlarm && field == "id" {
  255. value = index + 1
  256. }
  257. if value == nil && kind == domain.DataDownloadEventAlarm && field == "endTime" {
  258. row[column] = "null"
  259. continue
  260. }
  261. switch typed := value.(type) {
  262. case string:
  263. row[column] = typed
  264. case nil:
  265. row[column] = ""
  266. default:
  267. encoded, _ := json.Marshal(typed)
  268. row[column] = string(encoded)
  269. }
  270. }
  271. table = append(table, row)
  272. }
  273. return headers, table
  274. }
  275. func renderedFilename(document *domain.DataDownloadDocument, format string) string {
  276. parts := []string{document.SiteSN, string(document.Kind)}
  277. if document.Date != "" {
  278. parts = append(parts, document.Date)
  279. }
  280. if document.Subtype != "" {
  281. parts = append(parts, document.Subtype)
  282. }
  283. return strings.Join(parts, "_") + "." + format
  284. }