Files
Mycontent/soul-api/internal/handler/admin_dashboard.go
2026-04-28 18:39:08 +08:00

570 lines
17 KiB
Go
Raw Permalink 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"
"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_recordsjoin/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
}