fwh_es_mqtt.go 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485
  1. package fwh_es_mqtt
  2. import (
  3. "encoding/json"
  4. "errors"
  5. "fmt"
  6. "fwh-toolbox-server/domain"
  7. "time"
  8. )
  9. // 本文件按「界面需求.txt」实现两大块接口:看板(Dashboard) + 查询(Query)。
  10. // 需求中的“站点”即 equipNo(设备号)。
  11. //
  12. // 看板:
  13. // 报文总量统计:月总量 / 日总量
  14. // 站点月总量(分页降序) -> 下钻:本月站点每日总量
  15. // 站点日总量(分页降序) -> 下钻:站点每日命令类型总量(分页降序)
  16. // 查询:
  17. // 按过滤条件查询逐条原始报文,默认当天、按时间降序。
  18. //
  19. // 约定:
  20. // date 参数格式 "yyyy.MM.dd"(对应日索引 iotcore-stat-yyyy.MM.dd)。
  21. // 月度统计用通配索引 iotcore-stat-yyyy.MM.* 一次聚合。
  22. // 分页从 1 开始;聚合类分页用 ES bucket_sort(服务端分页)。
  23. //
  24. // 时区约定(重要):全部统一使用 UTC。
  25. // - 索引 iotcore-stat-yyyy.MM.dd 按 UTC 日期切分(实测某日索引覆盖该日 UTC 00:00:00~23:59:59);
  26. // - 记录 @timestamp 为 UTC 绝对时刻;
  27. // - 因此 date 参数、date_histogram 分桶、时间范围过滤、“当天”判断均按 UTC 处理,
  28. // 不做本地时区/北京时间换算,保证与索引切分对齐、首尾天不丢数据。
  29. // 前端展示与传参也统一按 UTC。
  30. const (
  31. // esTermsScanSize terms 聚合内部扫描桶数上限(需大于实际站点/命令类型总数)。
  32. esTermsScanSize = 60000
  33. // defaultPageSize 默认每页条数
  34. defaultPageSize = 20
  35. // MaxMessagePageWindow ES from+size 深分页窗口上限(index.max_result_window 默认 10000)。
  36. // 逐条查询 from+size 超过该值会被拒绝(ES 硬限制,调大会拖垮集群)。
  37. MaxMessagePageWindow = 10000
  38. )
  39. // ErrDeepPaging 逐条查询深分页超出 ES 窗口上限时返回,供上层识别并给出友好提示(400 而非 500)。
  40. var ErrDeepPaging = errors.New("deep paging beyond max result window")
  41. // normalizePaging 规整分页参数
  42. func normalizePaging(page, pageSize int) (int, int) {
  43. if page <= 0 {
  44. page = 1
  45. }
  46. if pageSize <= 0 {
  47. pageSize = defaultPageSize
  48. }
  49. return page, pageSize
  50. }
  51. /*********************** 看板:报文总量 ***********************/
  52. // GetDailyMessageTotal 日总量:某天索引的文档总数。
  53. // 对应:GET iotcore-stat-<date>/_count
  54. func GetDailyMessageTotal(date string) (*domain.MessageTotal, error) {
  55. index := esIndexName(date)
  56. respBody, _, err := esGet("/" + index + "/_count")
  57. if err != nil {
  58. return nil, err
  59. }
  60. var res domain.EsCountResponse
  61. if err := json.Unmarshal(respBody, &res); err != nil {
  62. return nil, fmt.Errorf("[GetDailyMessageTotal] 解析响应失败: %w", err)
  63. }
  64. return &domain.MessageTotal{Period: date, Total: res.Count}, nil
  65. }
  66. // GetMonthlyMessageTotal 月总量:跨该月所有日索引的文档总数。
  67. // 对应:GET iotcore-stat-<year>.<month>.*/_count
  68. func GetMonthlyMessageTotal(year, month int) (*domain.MessageTotal, error) {
  69. pattern := esMonthIndexPattern(year, month)
  70. respBody, _, err := esGet("/" + pattern + "/_count")
  71. if err != nil {
  72. return nil, err
  73. }
  74. var res domain.EsCountResponse
  75. if err := json.Unmarshal(respBody, &res); err != nil {
  76. return nil, fmt.Errorf("[GetMonthlyMessageTotal] 解析响应失败: %w", err)
  77. }
  78. period := fmt.Sprintf("%04d.%02d", year, month)
  79. return &domain.MessageTotal{Period: period, Total: res.Count}, nil
  80. }
  81. /*********************** 看板:站点总量(分页降序) ***********************/
  82. // buildSiteTotalsQuery 构造“各站点(equipNo)报文总量 + 分页降序 + 站点去重计数”的聚合查询。
  83. func buildSiteTotalsQuery(page, pageSize int) map[string]any {
  84. from := (page - 1) * pageSize
  85. return map[string]any{
  86. "size": 0,
  87. "aggs": map[string]any{
  88. // 站点总数(去重 equipNo),用于前端计算总页数
  89. "site_count": map[string]any{
  90. "cardinality": map[string]any{
  91. "field": "equipNo",
  92. },
  93. },
  94. // 各站点总量
  95. "by_site": map[string]any{
  96. "terms": map[string]any{
  97. "field": "equipNo",
  98. "size": esTermsScanSize,
  99. "shard_size": esTermsScanSize,
  100. "order": map[string]any{"_count": "desc"},
  101. },
  102. "aggs": map[string]any{
  103. // 服务端分页:先按量降序,再截取当前页
  104. "paged": map[string]any{
  105. "bucket_sort": map[string]any{
  106. "sort": []any{
  107. map[string]any{"_count": map[string]any{"order": "desc"}},
  108. },
  109. "from": from,
  110. "size": pageSize,
  111. },
  112. },
  113. },
  114. },
  115. },
  116. }
  117. }
  118. // siteTotalsAggResponse 解析站点总量聚合响应
  119. type siteTotalsAggResponse struct {
  120. Aggregations *struct {
  121. SiteCount *domain.EsCardinality `json:"site_count,omitempty"`
  122. BySite *domain.EsTermsAggregation `json:"by_site,omitempty"`
  123. } `json:"aggregations,omitempty"`
  124. }
  125. // parseSiteTotalPage 将聚合响应转为分页结果
  126. func parseSiteTotalPage(respBody []byte, page, pageSize int, fnName string) (*domain.SiteTotalPage, error) {
  127. var res siteTotalsAggResponse
  128. if err := json.Unmarshal(respBody, &res); err != nil {
  129. return nil, fmt.Errorf("[%s] 解析响应失败: %w", fnName, err)
  130. }
  131. if res.Aggregations == nil || res.Aggregations.BySite == nil {
  132. return nil, fmt.Errorf("[%s] 响应缺少 by_site 聚合", fnName)
  133. }
  134. items := make([]domain.SiteTotalItem, 0, len(res.Aggregations.BySite.Buckets))
  135. for _, b := range res.Aggregations.BySite.Buckets {
  136. items = append(items, domain.SiteTotalItem{EquipNo: b.Key, Total: b.DocCount})
  137. }
  138. var siteCount int64
  139. if res.Aggregations.SiteCount != nil {
  140. siteCount = res.Aggregations.SiteCount.Value
  141. }
  142. return &domain.SiteTotalPage{
  143. Page: page,
  144. PageSize: pageSize,
  145. SiteCount: siteCount,
  146. Items: items,
  147. }, nil
  148. }
  149. // GetSiteMonthlyTotals 本月各站点报文总量,分页、按量从大到小。
  150. //
  151. // ⚠️ 性能警告:此查询【非常慢、非常耗资源】,生产环境慎用!
  152. // - 它对整月通配索引 iotcore-stat-<year>.<month>.*(数十亿级文档、数十个分片)
  153. // 做 equipNo 的 terms 聚合(size=60000)+ cardinality 去重 + bucket_sort 分页,
  154. // 等于全月全量扫描并在协调节点合并海量桶,耗时可达数十秒甚至更久,
  155. // 且会给 ES 集群带来很大内存/CPU 压力,可能影响其它查询。
  156. // - 因该性能问题,前端“站点月总量列表”界面已下线,本函数当前无上层调用,
  157. // 仅作为库能力保留。若确需使用,务必:限制并发、加超时、错峰调用,
  158. // 或改用预聚合/离线统计(如按天预算后再累加),不要直接对整月实时聚合。
  159. //
  160. // 相比之下,按“单日索引”做同类聚合(GetSiteDailyTotals)代价可控,推荐优先使用。
  161. func GetSiteMonthlyTotals(year, month, page, pageSize int) (*domain.SiteTotalPage, error) {
  162. page, pageSize = normalizePaging(page, pageSize)
  163. pattern := esMonthIndexPattern(year, month)
  164. respBody, err := esSearch(pattern, buildSiteTotalsQuery(page, pageSize))
  165. if err != nil {
  166. return nil, err
  167. }
  168. return parseSiteTotalPage(respBody, page, pageSize, "GetSiteMonthlyTotals")
  169. }
  170. // GetSiteDailyTotals 某天各站点报文总量,分页、按量从大到小。
  171. func GetSiteDailyTotals(date string, page, pageSize int) (*domain.SiteTotalPage, error) {
  172. page, pageSize = normalizePaging(page, pageSize)
  173. index := esIndexName(date)
  174. respBody, err := esSearch(index, buildSiteTotalsQuery(page, pageSize))
  175. if err != nil {
  176. return nil, err
  177. }
  178. return parseSiteTotalPage(respBody, page, pageSize, "GetSiteDailyTotals")
  179. }
  180. /*********************** 下钻:站点本月每日总量 ***********************/
  181. // GetSiteDailyTotalsInMonth 某站点本月的每日报文总量(用于点击站点后的每日趋势)。
  182. // 用 date_histogram 按天(UTC)聚合,一次跨该月全部日索引。
  183. func GetSiteDailyTotalsInMonth(equipNo string, year, month int) ([]domain.DailyTotal, error) {
  184. pattern := esMonthIndexPattern(year, month)
  185. query := map[string]any{
  186. "size": 0,
  187. "query": map[string]any{
  188. "term": map[string]any{"equipNo": equipNo},
  189. },
  190. "aggs": map[string]any{
  191. "by_day": map[string]any{
  192. "date_histogram": map[string]any{
  193. "field": "@timestamp",
  194. "calendar_interval": "day",
  195. // 统一使用 UTC:不设置 time_zone,ES 默认按 UTC 切分“每一天”,
  196. // 与索引 iotcore-stat-yyyy.MM.dd 的 UTC 日期切分对齐。
  197. "format": "yyyy-MM-dd",
  198. "min_doc_count": 0,
  199. },
  200. },
  201. },
  202. }
  203. respBody, err := esSearch(pattern, query)
  204. if err != nil {
  205. return nil, err
  206. }
  207. var res struct {
  208. Aggregations *struct {
  209. ByDay *domain.EsDateHistogramAggregation `json:"by_day,omitempty"`
  210. } `json:"aggregations,omitempty"`
  211. }
  212. if err := json.Unmarshal(respBody, &res); err != nil {
  213. return nil, fmt.Errorf("[GetSiteDailyTotalsInMonth] 解析响应失败: %w", err)
  214. }
  215. if res.Aggregations == nil || res.Aggregations.ByDay == nil {
  216. return nil, fmt.Errorf("[GetSiteDailyTotalsInMonth] 响应缺少 by_day 聚合")
  217. }
  218. out := make([]domain.DailyTotal, 0, len(res.Aggregations.ByDay.Buckets))
  219. for _, b := range res.Aggregations.ByDay.Buckets {
  220. out = append(out, domain.DailyTotal{Date: b.KeyAsString, Total: b.DocCount})
  221. }
  222. return out, nil
  223. }
  224. // GetMonthlyDailyTrend 全站本月每日报文总量(不区分设备)。
  225. // 用 date_histogram 按天(UTC)对整月通配索引聚合,返回有数据的每日桶。
  226. //
  227. // 性能说明:仅按“天”聚合总量、不做 equipNo 分组,代价可控(本质接近 _count 分桶),
  228. // 与已下线的“站点月总量列表”那类 equipNo 全量聚合不同,可放心用于看板。
  229. // 注:ES date_histogram 的 min_doc_count:0 只会补齐“已存在数据的时间范围内”的空天,
  230. // 整月 1~月末的完整补零由上层(service)负责。
  231. func GetMonthlyDailyTrend(year, month int) ([]domain.DailyTotal, error) {
  232. pattern := esMonthIndexPattern(year, month)
  233. query := map[string]any{
  234. "size": 0,
  235. "aggs": map[string]any{
  236. "by_day": map[string]any{
  237. "date_histogram": map[string]any{
  238. "field": "@timestamp",
  239. "calendar_interval": "day",
  240. // 统一使用 UTC:不设置 time_zone,ES 默认按 UTC 切分“每一天”,
  241. // 与索引 iotcore-stat-yyyy.MM.dd 的 UTC 日期切分对齐。
  242. "format": "yyyy-MM-dd",
  243. "min_doc_count": 0,
  244. },
  245. },
  246. },
  247. }
  248. respBody, err := esSearch(pattern, query)
  249. if err != nil {
  250. return nil, err
  251. }
  252. var res struct {
  253. Aggregations *struct {
  254. ByDay *domain.EsDateHistogramAggregation `json:"by_day,omitempty"`
  255. } `json:"aggregations,omitempty"`
  256. }
  257. if err := json.Unmarshal(respBody, &res); err != nil {
  258. return nil, fmt.Errorf("[GetMonthlyDailyTrend] 解析响应失败: %w", err)
  259. }
  260. if res.Aggregations == nil || res.Aggregations.ByDay == nil {
  261. return nil, fmt.Errorf("[GetMonthlyDailyTrend] 响应缺少 by_day 聚合")
  262. }
  263. out := make([]domain.DailyTotal, 0, len(res.Aggregations.ByDay.Buckets))
  264. for _, b := range res.Aggregations.ByDay.Buckets {
  265. out = append(out, domain.DailyTotal{Date: b.KeyAsString, Total: b.DocCount})
  266. }
  267. return out, nil
  268. }
  269. /*********************** 下钻:站点当日各命令类型总量(分页降序) ***********************/
  270. // GetSiteCmdTypeTotals 某站点某天各命令类型(cmdType)的报文总量,分页、按量从大到小。
  271. func GetSiteCmdTypeTotals(equipNo, date string, page, pageSize int) (*domain.CmdTypeTotalPage, error) {
  272. page, pageSize = normalizePaging(page, pageSize)
  273. index := esIndexName(date)
  274. from := (page - 1) * pageSize
  275. query := map[string]any{
  276. "size": 0,
  277. "query": map[string]any{
  278. "term": map[string]any{"equipNo": equipNo},
  279. },
  280. "aggs": map[string]any{
  281. "cmd_count": map[string]any{
  282. "cardinality": map[string]any{"field": "cmdType"},
  283. },
  284. "by_cmd": map[string]any{
  285. "terms": map[string]any{
  286. "field": "cmdType",
  287. "size": esTermsScanSize,
  288. "shard_size": esTermsScanSize,
  289. "order": map[string]any{"_count": "desc"},
  290. },
  291. "aggs": map[string]any{
  292. "paged": map[string]any{
  293. "bucket_sort": map[string]any{
  294. "sort": []any{
  295. map[string]any{"_count": map[string]any{"order": "desc"}},
  296. },
  297. "from": from,
  298. "size": pageSize,
  299. },
  300. },
  301. },
  302. },
  303. },
  304. }
  305. respBody, err := esSearch(index, query)
  306. if err != nil {
  307. return nil, err
  308. }
  309. var res struct {
  310. Aggregations *struct {
  311. CmdCount *domain.EsCardinality `json:"cmd_count,omitempty"`
  312. ByCmd *domain.EsNumericTermsAggregation `json:"by_cmd,omitempty"`
  313. } `json:"aggregations,omitempty"`
  314. }
  315. if err := json.Unmarshal(respBody, &res); err != nil {
  316. return nil, fmt.Errorf("[GetSiteCmdTypeTotals] 解析响应失败: %w", err)
  317. }
  318. if res.Aggregations == nil || res.Aggregations.ByCmd == nil {
  319. return nil, fmt.Errorf("[GetSiteCmdTypeTotals] 响应缺少 by_cmd 聚合")
  320. }
  321. items := make([]domain.CmdTypeTotalItem, 0, len(res.Aggregations.ByCmd.Buckets))
  322. for _, b := range res.Aggregations.ByCmd.Buckets {
  323. items = append(items, domain.CmdTypeTotalItem{CmdType: b.Key, Total: b.DocCount})
  324. }
  325. var cmdTypeCount int64
  326. if res.Aggregations.CmdCount != nil {
  327. cmdTypeCount = res.Aggregations.CmdCount.Value
  328. }
  329. return &domain.CmdTypeTotalPage{
  330. Page: page,
  331. PageSize: pageSize,
  332. CmdTypeCount: cmdTypeCount,
  333. Items: items,
  334. }, nil
  335. }
  336. /*********************** 查询:逐条原始报文 ***********************/
  337. // QueryMessageParam 逐条报文查询的过滤条件。
  338. // 所有过滤字段可选;时间范围为空时默认查“当天”(UTC)。
  339. type QueryMessageParam struct {
  340. EquipNo *string // 站点(设备号)
  341. CmdType *int64 // 命令类型
  342. StartTime *time.Time // 起始时间(含)
  343. EndTime *time.Time // 结束时间(含)
  344. Page int // 页码,从 1 开始
  345. PageSize int // 每页条数
  346. }
  347. // QueryMessages 按过滤条件查询逐条原始报文。
  348. // 默认:当天数据,按 @timestamp 降序(最新在前)。
  349. //
  350. // 时区说明:统一使用 UTC。索引 iotcore-stat-yyyy.MM.dd 按 UTC 日期切分,
  351. // 记录 @timestamp 为 UTC 绝对时刻;因此“当天/时间范围”均以 UTC 计算,
  352. // 上层(controller)传入的 StartTime/EndTime 也应为 UTC 时刻。
  353. // 说明:ES from+size 深分页上限为 index.max_result_window(默认 10000),
  354. // 超出范围会返回错误提示,避免深分页拖垮集群。
  355. func QueryMessages(param QueryMessageParam) (*domain.MessagePage, error) {
  356. page, pageSize := normalizePaging(param.Page, param.PageSize)
  357. // 时间范围默认当天(UTC)
  358. var start, end time.Time
  359. switch {
  360. case param.StartTime != nil && param.EndTime != nil:
  361. start, end = *param.StartTime, *param.EndTime
  362. case param.StartTime != nil:
  363. start = *param.StartTime
  364. end = time.Now().UTC()
  365. case param.EndTime != nil:
  366. end = *param.EndTime
  367. start = time.Date(end.Year(), end.Month(), end.Day(), 0, 0, 0, 0, time.UTC)
  368. default:
  369. now := time.Now().UTC()
  370. start = time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.UTC)
  371. end = now
  372. }
  373. from := (page - 1) * pageSize
  374. if from+pageSize > MaxMessagePageWindow {
  375. return nil, fmt.Errorf("[QueryMessages] 分页超出上限: from(%d)+size(%d) > %d: %w",
  376. from, pageSize, MaxMessagePageWindow, ErrDeepPaging)
  377. }
  378. // 过滤条件
  379. filters := []any{
  380. map[string]any{
  381. "range": map[string]any{
  382. "@timestamp": map[string]any{
  383. "gte": start.UTC().Format(time.RFC3339),
  384. "lte": end.UTC().Format(time.RFC3339),
  385. },
  386. },
  387. },
  388. }
  389. if param.EquipNo != nil {
  390. filters = append(filters, map[string]any{"term": map[string]any{"equipNo": *param.EquipNo}})
  391. }
  392. if param.CmdType != nil {
  393. filters = append(filters, map[string]any{"term": map[string]any{"cmdType": *param.CmdType}})
  394. }
  395. query := map[string]any{
  396. "from": from,
  397. "size": pageSize,
  398. "track_total_hits": true,
  399. "query": map[string]any{
  400. "bool": map[string]any{"filter": filters},
  401. },
  402. "sort": []any{
  403. map[string]any{"@timestamp": map[string]any{"order": "desc"}},
  404. },
  405. }
  406. // 跨天查询用月/多日通配,这里以“当天所在日索引”为默认;
  407. // 若时间跨月,改用更大范围通配以覆盖。
  408. index := chooseIndexForRange(start, end)
  409. respBody, err := esSearch(index, query)
  410. if err != nil {
  411. return nil, err
  412. }
  413. var res domain.EsMessageSearchResponse
  414. if err := json.Unmarshal(respBody, &res); err != nil {
  415. return nil, fmt.Errorf("[QueryMessages] 解析响应失败: %w", err)
  416. }
  417. out := &domain.MessagePage{Page: page, PageSize: pageSize}
  418. if res.Hits != nil {
  419. out.Total = res.Hits.Total.Value
  420. out.Items = make([]domain.MqttMessageItem, 0, len(res.Hits.Hits))
  421. for _, h := range res.Hits.Hits {
  422. item := h.Source
  423. // _id 仅在单个索引内唯一,跨日索引查询时需带上索引名
  424. item.DocID = h.Index + "/" + h.ID
  425. out.Items = append(out.Items, item)
  426. }
  427. }
  428. return out, nil
  429. }
  430. // chooseIndexForRange 根据查询时间范围选择索引:
  431. // - 同一天:精确到日索引 iotcore-stat-yyyy.MM.dd
  432. // - 同一月跨多天:该月通配 iotcore-stat-yyyy.MM.*
  433. // - 跨月:年通配 iotcore-stat-yyyy.*
  434. //
  435. // 时间以 UTC 判断,与索引按 UTC 日期切分保持一致。
  436. func chooseIndexForRange(start, end time.Time) string {
  437. s := start.UTC()
  438. e := end.UTC()
  439. if s.Year() == e.Year() && s.Month() == e.Month() && s.Day() == e.Day() {
  440. return esIndexName(fmt.Sprintf("%04d.%02d.%02d", s.Year(), int(s.Month()), s.Day()))
  441. }
  442. if s.Year() == e.Year() && s.Month() == e.Month() {
  443. return esMonthIndexPattern(s.Year(), int(s.Month()))
  444. }
  445. if s.Year() == e.Year() {
  446. return fmt.Sprintf("%s-%04d.*", esIndexPrefix, s.Year())
  447. }
  448. return esIndexPrefix + "-*"
  449. }