analytics.go 9.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335
  1. package handler
  2. import (
  3. "encoding/csv"
  4. "fmt"
  5. "time"
  6. "spider/internal/model"
  7. "spider/internal/store"
  8. "github.com/gin-gonic/gin"
  9. )
  10. // AnalyticsHandler handles analytics endpoints.
  11. type AnalyticsHandler struct {
  12. store *store.Store
  13. }
  14. // Funnel returns conversion funnel data.
  15. func (h *AnalyticsHandler) Funnel(c *gin.Context) {
  16. db := h.store.DB
  17. var rawTotal, cleanTotal, validTotal int64
  18. db.Model(&model.MerchantRaw{}).Count(&rawTotal)
  19. db.Model(&model.MerchantClean{}).Count(&cleanTotal)
  20. db.Model(&model.MerchantClean{}).Where("status = ?", "valid").Count(&validTotal)
  21. type kv struct {
  22. Key string `gorm:"column:key"`
  23. Count int64 `gorm:"column:count"`
  24. }
  25. var followRows []kv
  26. db.Model(&model.MerchantClean{}).
  27. Select("follow_status as `key`, count(*) as `count`").
  28. Where("status = ?", "valid").
  29. Group("follow_status").
  30. Find(&followRows)
  31. followMap := map[string]int64{}
  32. for _, r := range followRows {
  33. followMap[r.Key] = r.Count
  34. }
  35. contacted := followMap["contacted"] + followMap["cooperating"] + followMap["rejected"]
  36. cooperating := followMap["cooperating"]
  37. rejected := followMap["rejected"]
  38. safeDiv := func(a, b int64) float64 {
  39. if b == 0 {
  40. return 0
  41. }
  42. return float64(a) / float64(b)
  43. }
  44. OK(c, gin.H{
  45. "raw_total": rawTotal,
  46. "clean_total": cleanTotal,
  47. "valid_total": validTotal,
  48. "contacted": contacted,
  49. "cooperating": cooperating,
  50. "rejected": rejected,
  51. "conversion_rates": gin.H{
  52. "raw_to_clean": safeDiv(cleanTotal, rawTotal),
  53. "clean_to_valid": safeDiv(validTotal, cleanTotal),
  54. "valid_to_contacted": safeDiv(contacted, validTotal),
  55. "contacted_to_cooperating": safeDiv(cooperating, contacted),
  56. },
  57. })
  58. }
  59. // SourceEfficiency returns source-level performance metrics.
  60. func (h *AnalyticsHandler) SourceEfficiency(c *gin.Context) {
  61. db := h.store.DB
  62. // By source type
  63. type sourceMetric struct {
  64. SourceType string `gorm:"column:source_type" json:"source_type"`
  65. RawCount int64 `gorm:"column:raw_count" json:"raw_count"`
  66. }
  67. var rawBySource []sourceMetric
  68. db.Model(&model.MerchantRaw{}).
  69. Select("source_type, count(*) as raw_count").
  70. Group("source_type").
  71. Find(&rawBySource)
  72. // Count clean/hot merchants per source type in a single query using JSON LIKE
  73. type cleanBySource struct {
  74. SourceType string `gorm:"column:source_type"`
  75. CleanCount int64 `gorm:"column:clean_count"`
  76. HotCount int64 `gorm:"column:hot_count"`
  77. }
  78. // Build a single query: count all and count hot per source type
  79. sourceTypes := make([]string, 0, len(rawBySource))
  80. rawCountMap := map[string]int64{}
  81. for _, rs := range rawBySource {
  82. if rs.SourceType != "" {
  83. sourceTypes = append(sourceTypes, rs.SourceType)
  84. rawCountMap[rs.SourceType] = rs.RawCount
  85. }
  86. }
  87. // Single batch query for clean counts per source type
  88. cleanCounts := map[string]int64{}
  89. hotCounts := map[string]int64{}
  90. for _, st := range sourceTypes {
  91. pattern := fmt.Sprintf("%%\"%s\"%%", st)
  92. var cc int64
  93. db.Model(&model.MerchantClean{}).Where("all_sources LIKE ?", pattern).Count(&cc)
  94. cleanCounts[st] = cc
  95. var hc int64
  96. db.Model(&model.MerchantClean{}).Where("all_sources LIKE ? AND level = ?", pattern, "Hot").Count(&hc)
  97. hotCounts[st] = hc
  98. }
  99. type sourceEff struct {
  100. SourceType string `json:"source_type"`
  101. RawCount int64 `json:"raw_count"`
  102. CleanCount int64 `json:"clean_count"`
  103. HotCount int64 `json:"hot_count"`
  104. Efficiency float64 `json:"efficiency"`
  105. }
  106. results := make([]sourceEff, 0, len(sourceTypes))
  107. for _, st := range sourceTypes {
  108. eff := float64(0)
  109. if rawCountMap[st] > 0 {
  110. eff = float64(hotCounts[st]) / float64(rawCountMap[st])
  111. }
  112. results = append(results, sourceEff{
  113. SourceType: st,
  114. RawCount: rawCountMap[st],
  115. CleanCount: cleanCounts[st],
  116. HotCount: hotCounts[st],
  117. Efficiency: eff,
  118. })
  119. }
  120. // Top keywords
  121. type kwMetric struct {
  122. Keyword string `gorm:"column:keyword" json:"keyword"`
  123. MerchantsFound int64 `gorm:"column:merchants_found" json:"merchants_found"`
  124. }
  125. var topKeywords []kwMetric
  126. db.Model(&model.MerchantRaw{}).
  127. Select("source_name as keyword, count(*) as merchants_found").
  128. Where("source_type = ? AND source_name != ''", "web").
  129. Group("source_name").
  130. Order("merchants_found DESC").
  131. Limit(10).
  132. Find(&topKeywords)
  133. // Top groups
  134. type groupMetric struct {
  135. GroupUsername string `gorm:"column:group_username" json:"group_username"`
  136. MembersFound int64 `gorm:"column:members_found" json:"members_found"`
  137. }
  138. var topGroups []groupMetric
  139. db.Model(&model.GroupMember{}).
  140. Select("group_username, count(distinct member_username) as members_found").
  141. Group("group_username").
  142. Order("members_found DESC").
  143. Limit(10).
  144. Find(&topGroups)
  145. OK(c, gin.H{
  146. "by_source_type": results,
  147. "top_keywords": topKeywords,
  148. "top_groups": topGroups,
  149. })
  150. }
  151. // Trends returns time-series data for reporting.
  152. func (h *AnalyticsHandler) Trends(c *gin.Context) {
  153. db := h.store.DB
  154. period := c.DefaultQuery("period", "week")
  155. rangeStr := c.DefaultQuery("range", "90")
  156. rangeDays := parseInt(rangeStr, 90)
  157. startDate := time.Now().AddDate(0, 0, -rangeDays)
  158. var dateFormat, groupExpr string
  159. switch period {
  160. case "month":
  161. dateFormat = "%Y-%m"
  162. groupExpr = "DATE_FORMAT(created_at, '%Y-%m')"
  163. default: // week
  164. dateFormat = "%x-W%v"
  165. groupExpr = "DATE_FORMAT(created_at, '%x-W%v')"
  166. }
  167. _ = dateFormat // used implicitly via groupExpr
  168. type trendRow struct {
  169. PeriodLabel string `gorm:"column:period_label" json:"period_label"`
  170. Count int64 `gorm:"column:count" json:"count"`
  171. }
  172. // Raw added per period
  173. var rawTrend []trendRow
  174. db.Model(&model.MerchantRaw{}).
  175. Select(groupExpr+" as period_label, count(*) as `count`").
  176. Where("created_at >= ?", startDate).
  177. Group("period_label").
  178. Order("period_label ASC").
  179. Find(&rawTrend)
  180. // Clean added per period
  181. var cleanTrend []trendRow
  182. db.Model(&model.MerchantClean{}).
  183. Select(groupExpr+" as period_label, count(*) as `count`").
  184. Where("created_at >= ?", startDate).
  185. Group("period_label").
  186. Order("period_label ASC").
  187. Find(&cleanTrend)
  188. // Build merged result
  189. type periodData struct {
  190. PeriodLabel string `json:"period_label"`
  191. RawAdded int64 `json:"raw_added"`
  192. CleanAdded int64 `json:"clean_added"`
  193. }
  194. periodMap := map[string]*periodData{}
  195. for _, r := range rawTrend {
  196. if _, ok := periodMap[r.PeriodLabel]; !ok {
  197. periodMap[r.PeriodLabel] = &periodData{PeriodLabel: r.PeriodLabel}
  198. }
  199. periodMap[r.PeriodLabel].RawAdded = r.Count
  200. }
  201. for _, r := range cleanTrend {
  202. if _, ok := periodMap[r.PeriodLabel]; !ok {
  203. periodMap[r.PeriodLabel] = &periodData{PeriodLabel: r.PeriodLabel}
  204. }
  205. periodMap[r.PeriodLabel].CleanAdded = r.Count
  206. }
  207. // Sort by period
  208. data := make([]periodData, 0, len(periodMap))
  209. for _, v := range periodMap {
  210. data = append(data, *v)
  211. }
  212. // Simple sort
  213. for i := 0; i < len(data); i++ {
  214. for j := i + 1; j < len(data); j++ {
  215. if data[i].PeriodLabel > data[j].PeriodLabel {
  216. data[i], data[j] = data[j], data[i]
  217. }
  218. }
  219. }
  220. OK(c, gin.H{
  221. "period": period,
  222. "data": data,
  223. })
  224. }
  225. // ExportTrends exports trend data as CSV.
  226. func (h *AnalyticsHandler) ExportTrends(c *gin.Context) {
  227. db := h.store.DB
  228. period := c.DefaultQuery("period", "week")
  229. rangeStr := c.DefaultQuery("range", "90")
  230. rangeDays := parseInt(rangeStr, 90)
  231. startDate := time.Now().AddDate(0, 0, -rangeDays)
  232. var groupExpr string
  233. switch period {
  234. case "month":
  235. groupExpr = "DATE_FORMAT(created_at, '%Y-%m')"
  236. default:
  237. groupExpr = "DATE_FORMAT(created_at, '%x-W%v')"
  238. }
  239. type trendRow struct {
  240. PeriodLabel string `gorm:"column:period_label"`
  241. Count int64 `gorm:"column:count"`
  242. }
  243. var rawTrend []trendRow
  244. db.Model(&model.MerchantRaw{}).
  245. Select(groupExpr+" as period_label, count(*) as `count`").
  246. Where("created_at >= ?", startDate).Group("period_label").Order("period_label ASC").Find(&rawTrend)
  247. var cleanTrend []trendRow
  248. db.Model(&model.MerchantClean{}).
  249. Select(groupExpr+" as period_label, count(*) as `count`").
  250. Where("created_at >= ?", startDate).Group("period_label").Order("period_label ASC").Find(&cleanTrend)
  251. type periodData struct {
  252. PeriodLabel string
  253. RawAdded int64
  254. CleanAdded int64
  255. }
  256. periodMap := map[string]*periodData{}
  257. for _, r := range rawTrend {
  258. if _, ok := periodMap[r.PeriodLabel]; !ok {
  259. periodMap[r.PeriodLabel] = &periodData{PeriodLabel: r.PeriodLabel}
  260. }
  261. periodMap[r.PeriodLabel].RawAdded = r.Count
  262. }
  263. for _, r := range cleanTrend {
  264. if _, ok := periodMap[r.PeriodLabel]; !ok {
  265. periodMap[r.PeriodLabel] = &periodData{PeriodLabel: r.PeriodLabel}
  266. }
  267. periodMap[r.PeriodLabel].CleanAdded = r.Count
  268. }
  269. data := make([]periodData, 0, len(periodMap))
  270. for _, v := range periodMap {
  271. data = append(data, *v)
  272. }
  273. for i := 0; i < len(data); i++ {
  274. for j := i + 1; j < len(data); j++ {
  275. if data[i].PeriodLabel > data[j].PeriodLabel {
  276. data[i], data[j] = data[j], data[i]
  277. }
  278. }
  279. }
  280. c.Header("Content-Type", "text/csv; charset=utf-8")
  281. c.Header("Content-Disposition", "attachment; filename=trends.csv")
  282. c.Writer.Write([]byte{0xEF, 0xBB, 0xBF})
  283. w := csv.NewWriter(c.Writer)
  284. w.Write([]string{"时间段", "原始新增", "清洗新增"})
  285. for _, d := range data {
  286. w.Write([]string{d.PeriodLabel, fmt.Sprintf("%d", d.RawAdded), fmt.Sprintf("%d", d.CleanAdded)})
  287. }
  288. w.Flush()
  289. }