package handler import ( "encoding/json" "net/http" "strconv" "strings" "sync" "time" "soul-api/internal/database" "soul-api/internal/model" "soul-api/internal/wechat" "github.com/gin-gonic/gin" "gorm.io/gorm" ) var paidStatuses = []string{"paid", "completed", "success"} // trackStatsDisplayNickname 埋点看板用户名:注册用户走 DB 昵称;guest: 为访客设备;anonymous 为历史合并桶 func trackStatsDisplayNickname(uid string, nickFromDB string) string { if nickFromDB != "" { return nickFromDB } u := strings.TrimSpace(uid) if strings.HasPrefix(u, "guest:") { suf := strings.TrimPrefix(u, "guest:") if len(suf) > 16 { suf = suf[:4] + "…" + suf[len(suf)-8:] } return "访客·" + suf } if u == "anonymous" { return "未识别访客(旧)" } return u } // AdminDashboardStats GET /api/admin/dashboard/stats // 轻量聚合:总用户、付费订单数、付费用户数、总营收、转化率(无订单/用户明细) func AdminDashboardStats(c *gin.Context) { db := database.DB() var ( totalUsers int64 paidOrderCount int64 totalRevenue float64 paidUserCount int64 ) var wg sync.WaitGroup wg.Add(4) go func() { defer wg.Done(); db.Model(&model.User{}).Count(&totalUsers) }() go func() { defer wg.Done() db.Model(&model.Order{}).Where("status IN ?", paidStatuses).Count(&paidOrderCount) }() go func() { defer wg.Done() db.Model(&model.Order{}).Where("status IN ?", paidStatuses). Select("COALESCE(SUM(amount), 0)").Scan(&totalRevenue) }() go func() { defer wg.Done() db.Table("orders").Where("status IN ?", paidStatuses). Select("COUNT(DISTINCT user_id)").Scan(&paidUserCount) }() wg.Wait() conversionRate := 0.0 if totalUsers > 0 && paidUserCount > 0 { conversionRate = float64(paidUserCount) / float64(totalUsers) * 100 } c.JSON(http.StatusOK, gin.H{ "success": true, "totalUsers": totalUsers, "paidOrderCount": paidOrderCount, "paidUserCount": paidUserCount, "totalRevenue": totalRevenue, "conversionRate": conversionRate, }) } // AdminDashboardRecentOrders GET /api/admin/dashboard/recent-orders?limit=10 func AdminDashboardRecentOrders(c *gin.Context) { db := database.DB() limit := 5 if l := c.Query("limit"); l != "" { if n, err := strconv.Atoi(l); err == nil && n >= 1 && n <= 20 { limit = n } } var recentOrders []model.Order db.Where("status IN ?", paidStatuses).Order("created_at DESC").Limit(limit).Find(&recentOrders) c.JSON(http.StatusOK, gin.H{"success": true, "recentOrders": buildRecentOrdersOut(db, recentOrders)}) } // AdminDashboardNewUsers GET /api/admin/dashboard/new-users func AdminDashboardNewUsers(c *gin.Context) { db := database.DB() var newUsers []model.User db.Model(&model.User{}).Order("created_at DESC").Limit(10).Find(&newUsers) c.JSON(http.StatusOK, gin.H{"success": true, "newUsers": buildNewUsersOut(newUsers)}) } // AdminDashboardOverview GET /api/admin/dashboard/overview // 数据概览:总用户、付费订单数、付费用户数、总营收、转化率、最近订单、新用户 // 优化:6 组查询并行执行,减少总耗时 func AdminDashboardOverview(c *gin.Context) { db := database.DB() var ( totalUsers int64 paidOrderCount int64 totalRevenue float64 paidUserCount int64 recentOrders []model.Order newUsers []model.User ) var wg sync.WaitGroup wg.Add(6) go func() { defer wg.Done() db.Model(&model.User{}).Count(&totalUsers) }() go func() { defer wg.Done() db.Model(&model.Order{}).Where("status IN ?", paidStatuses).Count(&paidOrderCount) }() go func() { defer wg.Done() db.Model(&model.Order{}).Where("status IN ?", paidStatuses). Select("COALESCE(SUM(amount), 0)").Scan(&totalRevenue) }() go func() { defer wg.Done() db.Table("orders").Where("status IN ?", paidStatuses). Select("COUNT(DISTINCT user_id)").Scan(&paidUserCount) }() go func() { defer wg.Done() db.Where("status IN ?", paidStatuses). Order("created_at DESC").Limit(5).Find(&recentOrders) }() go func() { defer wg.Done() db.Model(&model.User{}).Order("created_at DESC").Limit(10).Find(&newUsers) }() wg.Wait() conversionRate := 0.0 if totalUsers > 0 && paidUserCount > 0 { conversionRate = float64(paidUserCount) / float64(totalUsers) * 100 } recentOut := buildRecentOrdersOut(db, recentOrders) newOut := buildNewUsersOut(newUsers) c.JSON(http.StatusOK, gin.H{ "success": true, "totalUsers": totalUsers, "paidOrderCount": paidOrderCount, "paidUserCount": paidUserCount, "totalRevenue": totalRevenue, "conversionRate": conversionRate, "recentOrders": recentOut, "newUsers": newOut, }) } func dashStr(s *string) string { if s == nil || *s == "" { return "" } return *s } func buildRecentOrdersOut(db *gorm.DB, recentOrders []model.Order) []gin.H { if len(recentOrders) == 0 { return nil } userIDs := make(map[string]bool) for _, o := range recentOrders { if o.UserID != "" { userIDs[o.UserID] = true } } ids := make([]string, 0, len(userIDs)) for id := range userIDs { ids = append(ids, id) } var users []model.User db.Where("id IN ?", ids).Find(&users) userMap := make(map[string]*model.User) for i := range users { userMap[users[i].ID] = &users[i] } out := make([]gin.H, 0, len(recentOrders)) for _, o := range recentOrders { b, _ := json.Marshal(o) var m map[string]interface{} _ = json.Unmarshal(b, &m) if u := userMap[o.UserID]; u != nil { m["userNickname"] = dashStr(u.Nickname) m["userAvatar"] = resolveAvatarURL(dashStr(u.Avatar)) } else { m["userNickname"] = "" m["userAvatar"] = "" } out = append(out, m) } return out } // AdminTrackStats GET /api/admin/track/stats?period=today|week|month|all // 埋点统计:按 extra_data->module 分组,按 action+target 聚合 count、独立用户数、用户列表 func AdminTrackStats(c *gin.Context) { period := c.DefaultQuery("period", "week") if period != "today" && period != "week" && period != "month" && period != "all" { period = "week" } now := time.Now() var start time.Time switch period { case "today": start = time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location()) case "week": weekday := int(now.Weekday()) if weekday == 0 { weekday = 7 } start = time.Date(now.Year(), now.Month(), now.Day()-weekday+1, 0, 0, 0, 0, now.Location()) case "month": start = time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, now.Location()) case "all": start = time.Time{} } db := database.DB() var tracks []model.UserTrack q := db.Model(&model.UserTrack{}) if !start.IsZero() { q = q.Where("created_at >= ?", start) } if err := q.Find(&tracks).Error; err != nil { c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()}) return } type item struct { Action string Target string Module string Page string Count int UserSet map[string]bool } byModule := make(map[string]map[string]*item) total := 0 allUserIDs := make(map[string]bool) for _, t := range tracks { total++ module := "other" page := "" if len(t.ExtraData) > 0 { var extra map[string]interface{} if err := json.Unmarshal(t.ExtraData, &extra); err == nil { if m, ok := extra["module"].(string); ok && m != "" { module = m } if p, ok := extra["page"].(string); ok { page = p } } } target := "" if t.Target != nil { target = *t.Target } key := t.Action + "|" + target if byModule[module] == nil { byModule[module] = make(map[string]*item) } if byModule[module][key] == nil { byModule[module][key] = &item{Action: t.Action, Target: target, Module: module, Page: page, UserSet: make(map[string]bool)} } byModule[module][key].Count++ if t.UserID != "" { byModule[module][key].UserSet[t.UserID] = true allUserIDs[t.UserID] = true } } // 批量查用户昵称 ids := make([]string, 0, len(allUserIDs)) for id := range allUserIDs { ids = append(ids, id) } userNickMap := make(map[string]string) if len(ids) > 0 { var users []model.User db.Where("id IN ?", ids).Select("id, nickname").Find(&users) for _, u := range users { userNickMap[u.ID] = dashStr(u.Nickname) } } out := make(map[string][]gin.H) for mod, m := range byModule { list := make([]gin.H, 0, len(m)) for _, v := range m { userIDs := make([]string, 0, len(v.UserSet)) for uid := range v.UserSet { userIDs = append(userIDs, uid) } users := make([]gin.H, 0, len(userIDs)) for _, uid := range userIDs { nick := trackStatsDisplayNickname(uid, userNickMap[uid]) users = append(users, gin.H{"userId": uid, "nickname": nick}) } list = append(list, gin.H{ "action": v.Action, "actionLabel": userTrackActionLabelCN(v.Action), "target": v.Target, "module": v.Module, "moduleLabel": userTrackModuleLabelCN(v.Module), "page": v.Page, "count": v.Count, "userCount": len(v.UserSet), "users": users, }) } out[mod] = list } c.JSON(http.StatusOK, gin.H{"success": true, "total": total, "byModule": out}) } // AdminBalanceSummary GET /api/admin/balance/summary // 汇总代付金额(product_type 为 gift_pay 或 gift_pay_batch 的已支付订单),用于 Dashboard 显示「含代付 ¥xx」 func AdminBalanceSummary(c *gin.Context) { db := database.DB() var totalGifted float64 db.Model(&model.Order{}).Where("product_type IN ? AND status IN ?", []string{"gift_pay", "gift_pay_batch"}, paidStatuses). Select("COALESCE(SUM(amount), 0)").Scan(&totalGifted) c.JSON(http.StatusOK, gin.H{"success": true, "data": gin.H{"totalGifted": totalGifted}}) } // AdminDashboardMerchantBalance GET /api/admin/dashboard/merchant-balance // 查询微信商户号实时余额(可用余额、待结算余额),用于看板展示 // 注意:普通商户可能需向微信申请开通权限,未开通时返回 error func AdminDashboardMerchantBalance(c *gin.Context) { bal, err := wechat.QueryMerchantBalance("BASIC") if err != nil { c.JSON(http.StatusOK, gin.H{ "success": false, "error": err.Error(), "message": "查询商户余额失败,可能未开通权限(请联系微信支付运营申请)", }) return } c.JSON(http.StatusOK, gin.H{ "success": true, "availableAmount": bal.AvailableAmount, // 单位:分 "pendingAmount": bal.PendingAmount, // 单位:分 }) } // AdminSuperIndividualStats GET /api/admin/super-individual/stats // 超级个体点击/获客统计:从 user_tracks 中筛选「点击头像」记录(target LIKE '链接头像_%'), // 按被点击的超级个体 ID 分组,统计点击次数、独立点击用户数 func AdminSuperIndividualStats(c *gin.Context) { db := database.DB() type row struct { TargetID string `json:"targetId"` Clicks int64 `json:"clicks"` UniqueClicks int64 `json:"uniqueClicks"` } var rows []row if err := db.Raw(` SELECT SUBSTRING(target, 6) AS target_id, COUNT(*) AS clicks, COUNT(DISTINCT user_id) AS unique_clicks FROM user_tracks WHERE action IN ('avatar_click', 'btn_click') AND target LIKE '链接头像\_%' GROUP BY target_id ORDER BY clicks DESC `).Scan(&rows).Error; err != nil { c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()}) return } if len(rows) == 0 { c.JSON(http.StatusOK, gin.H{"success": true, "data": []gin.H{}, "total": 0}) return } // 批量查用户信息 ids := make([]string, 0, len(rows)) for _, r := range rows { if r.TargetID != "" { ids = append(ids, r.TargetID) } } var users []model.User db.Where("id IN ?", ids).Find(&users) userMap := make(map[string]*model.User) for i := range users { userMap[users[i].ID] = &users[i] } // 获客(去重):该用户若绑定 persons.user_id,则统计指向其 person 的留资独立人数 type leadRow struct { UserID string `json:"userId"` Leads int64 `json:"leads"` } var leadRows []leadRow if len(ids) > 0 { _ = db.Raw(` SELECT p.user_id AS user_id, COUNT(DISTINCT l.user_id) AS leads FROM persons p INNER JOIN ckb_lead_records l ON l.target_person_id = p.person_id WHERE p.user_id IN ? GROUP BY p.user_id `, ids).Scan(&leadRows).Error } leadByUser := make(map[string]int64) for _, lr := range leadRows { if lr.UserID != "" { leadByUser[lr.UserID] = lr.Leads } } out := make([]gin.H, 0, len(rows)) for _, r := range rows { item := gin.H{ "userId": r.TargetID, "clicks": r.Clicks, "uniqueClicks": r.UniqueClicks, "leadCount": leadByUser[r.TargetID], } if u := userMap[r.TargetID]; u != nil { item["nickname"] = dashStr(u.Nickname) item["avatar"] = resolveAvatarURL(dashStr(u.Avatar)) item["phone"] = dashStr(u.Phone) } out = append(out, item) } c.JSON(http.StatusOK, gin.H{"success": true, "data": out, "total": len(out)}) } // AdminDashboardLeads GET /api/admin/dashboard/leads 管理端看板-存客宝线索/提交记录概览 func AdminDashboardLeads(c *gin.Context) { db := database.DB() var contactTotal, submitTotal, uniqueContactUsers int64 db.Model(&model.CkbLeadRecord{}).Count(&contactTotal) // 统一到 ckb_lead_records:join/match 也在同表,用 action!=lead 视作“已提交线索” db.Model(&model.CkbLeadRecord{}).Where("action IN ?", []string{"join", "match"}).Count(&submitTotal) db.Raw(`SELECT COUNT(DISTINCT user_id) FROM ckb_lead_records WHERE user_id IS NOT NULL AND user_id != ''`).Scan(&uniqueContactUsers) var todayContact, todaySubmit int64 db.Raw(`SELECT COUNT(*) FROM ckb_lead_records WHERE DATE(created_at) = CURDATE()`).Scan(&todayContact) db.Raw(`SELECT COUNT(*) FROM ckb_lead_records WHERE action IN ('join','match') AND DATE(created_at) = CURDATE()`).Scan(&todaySubmit) var sourceRows []struct { Source string `gorm:"column:source"` Count int64 `gorm:"column:cnt"` } db.Raw(`SELECT COALESCE(source, 'unknown') AS source, COUNT(*) AS cnt FROM ckb_lead_records GROUP BY COALESCE(source, 'unknown') ORDER BY cnt DESC`).Scan(&sourceRows) sources := make([]gin.H, 0, len(sourceRows)) for _, r := range sourceRows { sources = append(sources, gin.H{"source": r.Source, "count": r.Count}) } c.JSON(http.StatusOK, gin.H{ "success": true, "data": gin.H{ "contactLeadsTotal": contactTotal, "submitRecordsTotal": submitTotal, "uniqueContactUsers": uniqueContactUsers, "todayContactLeads": todayContact, "todaySubmitRecords": todaySubmit, "combinedTotal": contactTotal + submitTotal, "sourceDistribution": sources, }, }) } // AdminDashboardContentRanking GET /api/admin/dashboard/content-ranking // 从 user_tracks 聚合 action=view_chapter,按点击次数降序返回 Top N 章节 func AdminDashboardContentRanking(c *gin.Context) { db := database.DB() limit := 10 if n, err := strconv.Atoi(c.DefaultQuery("limit", "10")); err == nil && n >= 1 && n <= 50 { limit = n } type chapterClick struct { ChapterID string `gorm:"column:chapter_id"` ClickCount int `gorm:"column:click_count"` UniqueUsers int `gorm:"column:unique_users"` LastClick string `gorm:"column:last_click"` } var results []chapterClick if err := db.Raw(` SELECT chapter_id, COUNT(*) AS click_count, COUNT(DISTINCT user_id) AS unique_users, MAX(created_at) AS last_click FROM user_tracks WHERE action = 'view_chapter' AND chapter_id IS NOT NULL AND chapter_id != '' GROUP BY chapter_id ORDER BY click_count DESC LIMIT ? `, limit).Scan(&results).Error; err != nil { c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()}) return } chapterIDs := make([]string, 0, len(results)) for _, r := range results { if r.ChapterID != "" { chapterIDs = append(chapterIDs, r.ChapterID) } } titleMap := make(map[string]string) if len(chapterIDs) > 0 { var chapters []model.Chapter db.Where("id IN ?", chapterIDs).Select("id, section_title, chapter_title, part_title").Find(&chapters) for _, ch := range chapters { title := ch.SectionTitle if title == "" { title = ch.ChapterTitle } if title == "" { title = ch.PartTitle } titleMap[ch.ID] = title } } out := make([]gin.H, 0, len(results)) for i, r := range results { title := titleMap[r.ChapterID] if title == "" { title = "章节 " + r.ChapterID } out = append(out, gin.H{ "rank": i + 1, "chapterId": r.ChapterID, "title": title, "clickCount": r.ClickCount, "uniqueUsers": r.UniqueUsers, "lastClick": r.LastClick, }) } c.JSON(http.StatusOK, gin.H{"success": true, "data": out}) } func buildNewUsersOut(newUsers []model.User) []gin.H { out := make([]gin.H, 0, len(newUsers)) for _, u := range newUsers { out = append(out, gin.H{ "id": u.ID, "nickname": dashStr(u.Nickname), "phone": dashStr(u.Phone), "referralCode": dashStr(u.ReferralCode), "createdAt": u.CreatedAt, }) } return out }