Files
soul-yongping/soul-api/internal/handler/admin_dashboard.go
卡若 80e397f7ac feat: 运营-用户功能四大需求完整实现
1. 客资中心:Dashboard 聚合 CKB 线索+提交记录,联表用户信息
2. @置顶:Person 三端(后端+管理端+小程序)置顶功能,首页优先展示
3. 存客宝场景:一键检查并自动启用所有场景获客计划
4. 去重增强:后端聚合 dupCount,管理端展示重复标记和统计
5. 首页文案:"最新更新"→"推荐","开始阅读"→"点击阅读"

Made-with: Cursor
2026-03-19 16:20:46 +08:00

424 lines
12 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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_recordsjoin/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_recordsjoin/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
}