package handler import ( "encoding/json" "net/http" "strconv" "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"} // 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"] = dashStr(u.Avatar) } else { m["userNickname"] = "" m["userAvatar"] = "" } out = append(out, m) } return 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, // 单位:分 }) } // AdminDashboardLeads GET /api/admin/dashboard/leads?limit=20 // 管理端-首页客资中心:聚合 ckb_lead_records(链接卡若留资)+ ckb_submit_records(join/match), // 联表 users 补齐头像/昵称,按时间倒序,每条包含联系方式(phone/wechatId)与来源。 func AdminDashboardLeads(c *gin.Context) { db := database.DB() limit := 20 if l := c.Query("limit"); l != "" { if n, err := strconv.Atoi(l); err == nil && n >= 1 && n <= 100 { limit = n } } search := c.Query("search") // 1. ckb_lead_records(链接卡若 / 文章@) var leads []model.CkbLeadRecord qLead := db.Model(&model.CkbLeadRecord{}).Order("created_at DESC") if search != "" { qLead = qLead.Where("nickname LIKE ? OR phone LIKE ? OR name LIKE ? OR wechat_id LIKE ?", "%"+search+"%", "%"+search+"%", "%"+search+"%", "%"+search+"%") } qLead.Limit(limit).Find(&leads) // 2. ckb_submit_records(join/match) var submits []model.CkbSubmitRecord qSub := db.Model(&model.CkbSubmitRecord{}).Order("created_at DESC") if search != "" { qSub = qSub.Where("nickname LIKE ? OR params LIKE ?", "%"+search+"%", "%"+search+"%") } qSub.Limit(limit).Find(&submits) // 收集所有 userID 关联用户信息 userIDs := make(map[string]bool) for _, l := range leads { if l.UserID != "" { userIDs[l.UserID] = true } } for _, s := range submits { if s.UserID != "" { userIDs[s.UserID] = true } } ids := make([]string, 0, len(userIDs)) for id := range userIDs { ids = append(ids, id) } var users []model.User if len(ids) > 0 { db.Select("id", "nickname", "avatar", "phone", "wechat_id", "is_vip", "tags", "ckb_tags").Where("id IN ?", ids).Find(&users) } userMap := make(map[string]*model.User) for i := range users { userMap[users[i].ID] = &users[i] } // 统计 var totalLeads, totalSubmits int64 db.Model(&model.CkbLeadRecord{}).Count(&totalLeads) db.Model(&model.CkbSubmitRecord{}).Count(&totalSubmits) var withPhone int64 db.Model(&model.CkbLeadRecord{}).Where("phone != '' AND phone IS NOT NULL").Count(&withPhone) // 去重统计:按 userId/phone/wechatId 聚合重复次数 dupCounts := make(map[string]int64) for _, l := range leads { key := l.UserID if key == "" { key = l.Phone } if key == "" { key = l.WechatID } if key != "" { if _, ok := dupCounts[key]; !ok { var cnt int64 q := db.Model(&model.CkbLeadRecord{}) if l.UserID != "" { q = q.Where("user_id = ?", l.UserID) } else if l.Phone != "" { q = q.Where("phone = ?", l.Phone) } else { q = q.Where("wechat_id = ?", l.WechatID) } q.Count(&cnt) dupCounts[key] = cnt } } } // 构造输出 type leadOut struct { SortTime time.Time Data gin.H } all := make([]leadOut, 0, len(leads)+len(submits)) for _, l := range leads { u := userMap[l.UserID] avatar := "" userNickname := l.Nickname if u != nil { avatar = dashStr(u.Avatar) if dashStr(u.Nickname) != "" { userNickname = dashStr(u.Nickname) } } sourceLabel := "链接卡若" if l.Source == "article_mention" { sourceLabel = "文章@" } else if l.Source == "index_link_button" { sourceLabel = "首页链接" } key := l.UserID if key == "" { key = l.Phone } if key == "" { key = l.WechatID } dupCount := dupCounts[key] if dupCount <= 1 { dupCount = 0 } all = append(all, leadOut{ SortTime: l.CreatedAt, Data: gin.H{ "id": l.ID, "type": "lead", "userId": l.UserID, "userNickname": userNickname, "userAvatar": avatar, "phone": l.Phone, "wechatId": l.WechatID, "name": l.Name, "source": l.Source, "sourceLabel": sourceLabel, "createdAt": l.CreatedAt, "dupCount": dupCount, }, }) } for _, s := range submits { u := userMap[s.UserID] avatar := "" userNickname := s.Nickname if u != nil { avatar = dashStr(u.Avatar) if dashStr(u.Nickname) != "" { userNickname = dashStr(u.Nickname) } } all = append(all, leadOut{ SortTime: s.CreatedAt, Data: gin.H{ "id": s.ID, "type": "submit", "userId": s.UserID, "userNickname": userNickname, "userAvatar": avatar, "matchType": s.Action, "source": s.Action, "sourceLabel": ckbSourceMap[s.Action], "createdAt": s.CreatedAt, }, }) } // 按时间倒序合并 for i := 0; i < len(all); i++ { for j := i + 1; j < len(all); j++ { if all[j].SortTime.After(all[i].SortTime) { all[i], all[j] = all[j], all[i] } } } if len(all) > limit { all = all[:limit] } out := make([]gin.H, 0, len(all)) for _, a := range all { out = append(out, a.Data) } c.JSON(http.StatusOK, gin.H{ "success": true, "leads": out, "totalLeads": totalLeads, "totalSubmits": totalSubmits, "withPhone": withPhone, "total": totalLeads + totalSubmits, }) } 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 }