dataDownloadRepo.go 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294
  1. package pgsql
  2. import (
  3. "crypto/sha256"
  4. "encoding/hex"
  5. "encoding/json"
  6. "fmt"
  7. "math"
  8. "strconv"
  9. "strings"
  10. "time"
  11. "fwh-toolbox-server/database"
  12. "fwh-toolbox-server/domain"
  13. "fwh-toolbox-server/dto"
  14. "gorm.io/gorm"
  15. )
  16. type DataDownloadRow struct {
  17. OccurredAt *time.Time
  18. Subtype string
  19. DeviceSN string
  20. DeviceType string
  21. Payload any
  22. }
  23. type powerFlowPoint struct {
  24. Sequence int `json:"sequence"`
  25. // DeviceTimeLocal preserves the wall-clock value reported by the device;
  26. // DataDownloadRow.OccurredAt stores the same instant normalized to UTC.
  27. DeviceTimeLocal string `json:"deviceTimeLocal"`
  28. PowerFhp, PowerGen, PowerLoad, PowerSun, PowerUti, Soc, KwhTotal *float32
  29. RunStatus *int32 `json:"runStatus"`
  30. }
  31. type DataDownloadContentState struct {
  32. DocumentID string `gorm:"column:document_id" json:"documentId"`
  33. DocumentKind string `gorm:"column:document_kind" json:"documentKind"`
  34. ContentGeneration int64 `gorm:"column:content_generation" json:"contentGeneration"`
  35. SchemaVersion string `gorm:"column:schema_version" json:"schemaVersion"`
  36. RowCount int64 `gorm:"column:row_count" json:"rowCount"`
  37. Checksum string `gorm:"column:checksum" json:"checksum"`
  38. SourceFetchedAt time.Time `gorm:"column:source_fetched_at" json:"sourceFetchedAt"`
  39. UpdatedAt time.Time `gorm:"column:updated_at" json:"updatedAt"`
  40. }
  41. func ReplaceDataDownloadRows(documentID string, kind domain.DataDownloadKind, siteSN, localDate, timezone string, rows []DataDownloadRow, fetchedAt time.Time) (*DataDownloadContentState, error) {
  42. var state DataDownloadContentState
  43. reportedIndex := uint(0)
  44. if kind == domain.DataDownloadPowerFlow && len(rows) == 1 {
  45. if value, ok := rows[0].Payload.(*dto.SitePowerFlow); ok {
  46. reportedIndex = value.Index
  47. points, err := expandPowerFlow(value, timezone)
  48. if err != nil {
  49. return nil, err
  50. }
  51. rows = points
  52. }
  53. }
  54. err := database.PgSQL.Transaction(func(tx *gorm.DB) error {
  55. // The transaction-scoped advisory lock makes replacement and readers mutually exclusive.
  56. if err := tx.Exec("SELECT pg_advisory_xact_lock(hashtextextended(?, 0))", documentID).Error; err != nil {
  57. return err
  58. }
  59. var generation int64
  60. if err := tx.Raw("SELECT content_generation FROM data_download_content_state WHERE document_id = ? FOR UPDATE", documentID).Scan(&generation).Error; err != nil {
  61. return err
  62. }
  63. generation++
  64. payloadBytes, err := json.Marshal(rows)
  65. if err != nil {
  66. return err
  67. }
  68. sum := sha256.Sum256(payloadBytes)
  69. checksum := hex.EncodeToString(sum[:])
  70. if err := tx.Exec(`INSERT INTO data_download_content_state
  71. (document_id, document_kind, content_generation, schema_version, row_count, checksum, content_checksum, source_fetched_at, updated_at)
  72. VALUES (?, ?, ?, 'v1', ?, ?, ?, ?, now())
  73. ON CONFLICT (document_id) DO UPDATE SET document_kind=excluded.document_kind,
  74. content_generation=excluded.content_generation, schema_version=excluded.schema_version,
  75. row_count=excluded.row_count, checksum=excluded.checksum, content_checksum=excluded.content_checksum,
  76. source_fetched_at=excluded.source_fetched_at, updated_at=now()`,
  77. documentID, string(kind), generation, len(rows), checksum, checksum, fetchedAt).Error; err != nil {
  78. return err
  79. }
  80. table := ""
  81. switch kind {
  82. case domain.DataDownloadPowerFlow:
  83. table = "data_download_power_flow_point"
  84. if err := tx.Exec("DELETE FROM data_download_power_flow_meta WHERE document_id = ?", documentID).Error; err != nil {
  85. return err
  86. }
  87. if err := tx.Exec("INSERT INTO data_download_power_flow_meta(document_id, site_sn, local_date, timezone, reported_index) VALUES (?, ?, ?, ?, ?)", documentID, siteSN, localDate, timezone, reportedIndex).Error; err != nil {
  88. return err
  89. }
  90. case domain.DataDownloadMqttLog:
  91. table = "data_download_mqtt_log_entry"
  92. case domain.DataDownloadEventAlarm:
  93. table = "data_download_event_alarm_entry"
  94. case domain.DataDownloadUpdateLog:
  95. for _, name := range []string{"data_download_update_log_entry", "data_download_update_order", "data_download_update_device_version", "data_download_ahub_update", "data_download_ahub_update_detail"} {
  96. if err := tx.Exec("DELETE FROM "+name+" WHERE document_id = ?", documentID).Error; err != nil {
  97. return err
  98. }
  99. }
  100. default:
  101. return fmt.Errorf("%s is not a structured document kind", kind)
  102. }
  103. if table != "" {
  104. if err := tx.Exec("DELETE FROM "+table+" WHERE document_id = ?", documentID).Error; err != nil {
  105. return err
  106. }
  107. }
  108. for i, row := range rows {
  109. payload, err := json.Marshal(row.Payload)
  110. if err != nil {
  111. return err
  112. }
  113. switch kind {
  114. case domain.DataDownloadPowerFlow:
  115. point := row.Payload.(powerFlowPoint)
  116. err = tx.Exec(`INSERT INTO data_download_power_flow_point(document_id, point_index, seq, sampled_at, device_time_local, device_time_utc, power_fhp, power_gen, power_load, power_sun, power_uti, soc, kwh_total, run_status, payload)
  117. VALUES (?, ?, ?, ?, NULLIF(?, '')::timestamp, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?::jsonb)`, documentID, i, point.Sequence, row.OccurredAt, point.DeviceTimeLocal, row.OccurredAt, point.PowerFhp, point.PowerGen, point.PowerLoad, point.PowerSun, point.PowerUti, point.Soc, point.KwhTotal, point.RunStatus, string(payload)).Error
  118. case domain.DataDownloadMqttLog:
  119. value := row.Payload.(domain.MqttLogItem)
  120. var logJSON any = nil
  121. if json.Valid([]byte(value.LogData)) {
  122. logJSON = value.LogData
  123. }
  124. deviceLocal := parseLocalTime(value.LogTime)
  125. err = tx.Exec(`INSERT INTO data_download_mqtt_log_entry(document_id,row_index,occurred_at,subtype,cmd_type,cmd_name,gateway_id,gateway_type,gateway_status,active_status,device_time_raw,event_time_raw,device_time_local,event_time_utc,log_data_raw,log_data_json,payload)
  126. VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?::jsonb,?::jsonb)`, documentID, i, row.OccurredAt, row.Subtype, value.CmdType, value.CmdName, value.GatewayId, value.GatewayType, value.GatewayStatus, value.ActiveStatus, value.LogTime, value.Time, deviceLocal, row.OccurredAt, value.LogData, logJSON, string(payload)).Error
  127. case domain.DataDownloadEventAlarm:
  128. value := row.Payload.(domain.EventAlarmItem)
  129. var logJSON any = nil
  130. if json.Valid([]byte(value.LogData)) {
  131. logJSON = value.LogData
  132. }
  133. sourceID := ""
  134. if value.ID != nil {
  135. sourceID = *value.ID
  136. }
  137. endRaw := ""
  138. if value.EndTime != nil {
  139. endRaw = *value.EndTime
  140. }
  141. err = tx.Exec(`INSERT INTO data_download_event_alarm_entry(document_id,row_index,occurred_at,source_id,cmd_type,cmd_name,gateway_id,log_type,alarm_code,log_name,log_data_raw,log_data_json,device_time_raw,start_time_raw,end_time_raw,payload)
  142. VALUES (?,?,?,?,?,?,?,?,?,?,?,?::jsonb,?,?,?,?::jsonb)`, documentID, i, row.OccurredAt, sourceID, value.CmdType, value.CmdName, value.GatewayId, value.LogType, value.AlarmCode, value.LogName, value.LogData, logJSON, value.LogTime, value.StartTime, endRaw, string(payload)).Error
  143. case domain.DataDownloadUpdateLog:
  144. switch value := row.Payload.(type) {
  145. case domain.SystemUpdateRecordInfo:
  146. err = tx.Exec("INSERT INTO data_download_update_order(document_id,order_no,payload) VALUES (?,?,?::jsonb)", documentID, value.OrderNo, string(payload)).Error
  147. case domain.SystemUpdateDetailInfo:
  148. err = tx.Exec("INSERT INTO data_download_update_device_version(document_id,order_no,scope,device_sn,name,payload) VALUES (?,?,?,?,?,?::jsonb)", documentID, value.OrderNo, "gateway", value.GatewayId, strconv.FormatInt(value.Id, 10), string(payload)).Error
  149. case domain.AHubUpdateRecordInfo:
  150. err = tx.Exec("INSERT INTO data_download_ahub_update(document_id,order_no,ahub_sn,payload) VALUES (?,?,?,?::jsonb)", documentID, value.OrderNo, value.AHubSn, string(payload)).Error
  151. for _, detail := range value.DetailList {
  152. detailPayload, _ := json.Marshal(detail)
  153. if insertErr := tx.Exec("INSERT INTO data_download_ahub_update_detail(document_id,order_no,ahub_sn,file_order,file_name,payload) VALUES (?,?,?,?,?,?::jsonb)", documentID, value.OrderNo, value.AHubSn, detail.UpgradeFileOrder, detail.UpgradeFileName, string(detailPayload)).Error; insertErr != nil {
  154. return insertErr
  155. }
  156. }
  157. }
  158. }
  159. if err != nil {
  160. return err
  161. }
  162. }
  163. return tx.Raw("SELECT * FROM data_download_content_state WHERE document_id = ?", documentID).Scan(&state).Error
  164. })
  165. return &state, err
  166. }
  167. func expandPowerFlow(value *dto.SitePowerFlow, timezone string) ([]DataDownloadRow, error) {
  168. length := len(value.DeviceTimeArray)
  169. for name, size := range map[string]int{"powerFhp": len(value.PowerFhpArray), "powerGen": len(value.PowerGenArray), "powerLoad": len(value.PowerLoadArray), "powerSun": len(value.PowerSunArray), "powerUti": len(value.PowerUtiArray), "soc": len(value.SocArray), "runStatus": len(value.RunStatusArray), "kwhTotal": len(value.KwhTotalArray)} {
  170. if size > length {
  171. return nil, fmt.Errorf("PowerFlow %s has %d values but only %d timestamps", name, size, length)
  172. }
  173. }
  174. for _, status := range value.RunStatusArray {
  175. if uint64(status) > uint64(math.MaxInt32) {
  176. return nil, fmt.Errorf("PowerFlow runStatus %d exceeds PostgreSQL integer range", status)
  177. }
  178. }
  179. rows := make([]DataDownloadRow, 0, length)
  180. for i, raw := range value.DeviceTimeArray {
  181. point := powerFlowPoint{Sequence: i, DeviceTimeLocal: raw, PowerFhp: f32At(value.PowerFhpArray, i), PowerGen: f32At(value.PowerGenArray, i), PowerLoad: f32At(value.PowerLoadArray, i), PowerSun: f32At(value.PowerSunArray, i), PowerUti: f32At(value.PowerUtiArray, i), Soc: f32At(value.SocArray, i), KwhTotal: f32At(value.KwhTotalArray, i), RunStatus: int32At(value.RunStatusArray, i)}
  182. var occurred *time.Time
  183. loc := postgresDataDownloadLocation(timezone)
  184. if parsed, parseErr := time.ParseInLocation(time.DateTime, raw, loc); parseErr == nil {
  185. utc := parsed.UTC()
  186. occurred = &utc
  187. }
  188. rows = append(rows, DataDownloadRow{OccurredAt: occurred, Payload: point})
  189. }
  190. return rows, nil
  191. }
  192. func parseLocalTime(value string) *time.Time {
  193. parsed, err := time.Parse(time.DateTime, value)
  194. if err != nil {
  195. return nil
  196. }
  197. return &parsed
  198. }
  199. func postgresDataDownloadLocation(name string) *time.Location {
  200. if loc, err := time.LoadLocation(name); err == nil {
  201. return loc
  202. }
  203. if strings.HasPrefix(name, "UTC") {
  204. if hours, err := strconv.ParseFloat(strings.TrimPrefix(name, "UTC"), 64); err == nil {
  205. return time.FixedZone(name, int(math.Round(hours*3600)))
  206. }
  207. }
  208. return time.UTC
  209. }
  210. func f32At(values []float32, index int) *float32 {
  211. if index >= len(values) {
  212. return nil
  213. }
  214. value := values[index]
  215. return &value
  216. }
  217. func int32At(values []uint, index int) *int32 {
  218. if index >= len(values) {
  219. return nil
  220. }
  221. value := int32(values[index])
  222. return &value
  223. }
  224. func ReadDataDownloadRows(documentID string, kind domain.DataDownloadKind) (*DataDownloadContentState, []json.RawMessage, error) {
  225. var state DataDownloadContentState
  226. var rows []json.RawMessage
  227. err := database.PgSQL.Transaction(func(tx *gorm.DB) error {
  228. if err := tx.Exec("SELECT pg_advisory_xact_lock_shared(hashtextextended(?, 0))", documentID).Error; err != nil {
  229. return err
  230. }
  231. if err := tx.Raw("SELECT * FROM data_download_content_state WHERE document_id = ?", documentID).Scan(&state).Error; err != nil {
  232. return err
  233. }
  234. table := map[domain.DataDownloadKind]string{
  235. domain.DataDownloadPowerFlow: "data_download_power_flow_point",
  236. domain.DataDownloadMqttLog: "data_download_mqtt_log_entry",
  237. domain.DataDownloadEventAlarm: "data_download_event_alarm_entry",
  238. domain.DataDownloadUpdateLog: "",
  239. }[kind]
  240. if table == "" && kind != domain.DataDownloadUpdateLog {
  241. return fmt.Errorf("%s is not structured", kind)
  242. }
  243. type payloadRow struct {
  244. Payload []byte `gorm:"column:payload"`
  245. }
  246. var values []payloadRow
  247. if kind == domain.DataDownloadUpdateLog {
  248. if err := tx.Raw(`SELECT payload FROM (
  249. SELECT 1 AS section, order_no AS k1, '' AS k2, payload FROM data_download_update_order WHERE document_id = ?
  250. UNION ALL SELECT 2, order_no, device_sn, payload FROM data_download_update_device_version WHERE document_id = ?
  251. UNION ALL SELECT 3, order_no, ahub_sn, payload FROM data_download_ahub_update WHERE document_id = ?
  252. UNION ALL SELECT 4, order_no, ahub_sn || file_order || file_name, payload FROM data_download_ahub_update_detail WHERE document_id = ?
  253. ) current_update ORDER BY section, k1, k2`, documentID, documentID, documentID, documentID).Scan(&values).Error; err != nil {
  254. return err
  255. }
  256. for _, value := range values {
  257. rows = append(rows, json.RawMessage(value.Payload))
  258. }
  259. return nil
  260. }
  261. order := "row_index"
  262. if kind == domain.DataDownloadPowerFlow {
  263. order = "point_index"
  264. }
  265. if err := tx.Raw("SELECT payload FROM "+table+" WHERE document_id = ? ORDER BY "+order, documentID).Scan(&values).Error; err != nil {
  266. return err
  267. }
  268. for _, value := range values {
  269. rows = append(rows, json.RawMessage(value.Payload))
  270. }
  271. return nil
  272. })
  273. return &state, rows, err
  274. }
  275. func DeleteDataDownloadContent(documentID string) error {
  276. return database.PgSQL.Transaction(func(tx *gorm.DB) error {
  277. if err := tx.Exec("SELECT pg_advisory_xact_lock(hashtextextended(?, 0))", documentID).Error; err != nil {
  278. return err
  279. }
  280. return tx.Exec("DELETE FROM data_download_content_state WHERE document_id = ?", documentID).Error
  281. })
  282. }