319 lines
9.2 KiB
Go
319 lines
9.2 KiB
Go
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
|
||
}
|
||
|
||
// 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
|
||
}
|
||
// byModule: module -> map[key] -> count, key = action + "|" + target
|
||
type item struct {
|
||
Action string `json:"action"`
|
||
Target string `json:"target"`
|
||
Module string `json:"module"`
|
||
Page string `json:"page"`
|
||
Count int `json:"count"`
|
||
}
|
||
byModule := make(map[string]map[string]*item)
|
||
total := 0
|
||
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, Count: 0}
|
||
}
|
||
byModule[module][key].Count++
|
||
}
|
||
// 转为前端期望格式:byModule[module] = [{action,target,module,page,count},...]
|
||
out := make(map[string][]gin.H)
|
||
for mod, m := range byModule {
|
||
list := make([]gin.H, 0, len(m))
|
||
for _, v := range m {
|
||
list = append(list, gin.H{
|
||
"action": v.Action, "target": v.Target, "module": v.Module, "page": v.Page, "count": v.Count,
|
||
})
|
||
}
|
||
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, // 单位:分
|
||
})
|
||
}
|
||
|
||
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
|
||
}
|