2509 lines
79 KiB
Go
Executable File
2509 lines
79 KiB
Go
Executable File
package admin
|
||
|
||
import (
|
||
"database/sql"
|
||
"fmt"
|
||
"net/http"
|
||
"strconv"
|
||
"time"
|
||
|
||
"bindbox-game/internal/code"
|
||
"bindbox-game/internal/pkg/core"
|
||
"bindbox-game/internal/pkg/validation"
|
||
"bindbox-game/internal/repository/mysql/model"
|
||
|
||
"gorm.io/gorm"
|
||
)
|
||
|
||
type cardsRequest struct {
|
||
RangeType string `form:"rangeType"`
|
||
StartDate string `form:"start"`
|
||
EndDate string `form:"end"`
|
||
}
|
||
|
||
type cardStatResponse struct {
|
||
ItemCardSales int64 `json:"itemCardSales"`
|
||
DrawCount int64 `json:"drawCount"`
|
||
NewUsers int64 `json:"newUsers"`
|
||
TotalPoints float64 `json:"totalPoints"`
|
||
TotalInventory int64 `json:"totalInventory"` // 存量盒柜资产
|
||
TotalCoupons int64 `json:"totalCoupons"`
|
||
TotalItemCards int64 `json:"totalItemCards"`
|
||
TotalGamePasses int64 `json:"totalGamePasses"`
|
||
ItemCardChange string `json:"itemCardChange"`
|
||
DrawChange string `json:"drawChange"`
|
||
NewUserChange string `json:"newUserChange"`
|
||
PointsChange string `json:"pointsChange"`
|
||
}
|
||
|
||
func (h *handler) DashboardCards() core.HandlerFunc {
|
||
return func(ctx core.Context) {
|
||
req := new(cardsRequest)
|
||
rsp := new(cardStatResponse)
|
||
if err := ctx.ShouldBindForm(req); err != nil {
|
||
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, validation.Error(err)))
|
||
return
|
||
}
|
||
start, end := parseRange(req.RangeType, req.StartDate, req.EndDate)
|
||
prevStart, prevEnd := previousWindow(start, end)
|
||
|
||
icCur, err := h.readDB.UserItemCards.WithContext(ctx.RequestContext()).ReadDB().
|
||
Where(h.readDB.UserItemCards.CreatedAt.Gte(start)).
|
||
Where(h.readDB.UserItemCards.CreatedAt.Lte(end)).
|
||
Count()
|
||
if err != nil {
|
||
ctx.AbortWithError(core.Error(http.StatusBadRequest, 21001, err.Error()))
|
||
return
|
||
}
|
||
icPrev, err := h.readDB.UserItemCards.WithContext(ctx.RequestContext()).ReadDB().
|
||
Where(h.readDB.UserItemCards.CreatedAt.Gte(prevStart)).
|
||
Where(h.readDB.UserItemCards.CreatedAt.Lte(prevEnd)).
|
||
Count()
|
||
if err != nil {
|
||
ctx.AbortWithError(core.Error(http.StatusBadRequest, 21002, err.Error()))
|
||
return
|
||
}
|
||
|
||
dlCur, err := h.readDB.ActivityDrawLogs.WithContext(ctx.RequestContext()).ReadDB().
|
||
Where(h.readDB.ActivityDrawLogs.CreatedAt.Gte(start)).
|
||
Where(h.readDB.ActivityDrawLogs.CreatedAt.Lte(end)).
|
||
Count()
|
||
if err != nil {
|
||
ctx.AbortWithError(core.Error(http.StatusBadRequest, 21003, err.Error()))
|
||
return
|
||
}
|
||
dlPrev, err := h.readDB.ActivityDrawLogs.WithContext(ctx.RequestContext()).ReadDB().
|
||
Where(h.readDB.ActivityDrawLogs.CreatedAt.Gte(prevStart)).
|
||
Where(h.readDB.ActivityDrawLogs.CreatedAt.Lte(prevEnd)).
|
||
Count()
|
||
if err != nil {
|
||
ctx.AbortWithError(core.Error(http.StatusBadRequest, 21004, err.Error()))
|
||
return
|
||
}
|
||
|
||
nuCur, err := h.readDB.Users.WithContext(ctx.RequestContext()).ReadDB().
|
||
Where(h.readDB.Users.CreatedAt.Gte(start)).
|
||
Where(h.readDB.Users.CreatedAt.Lte(end)).
|
||
Count()
|
||
if err != nil {
|
||
ctx.AbortWithError(core.Error(http.StatusBadRequest, 21005, err.Error()))
|
||
return
|
||
}
|
||
nuPrev, err := h.readDB.Users.WithContext(ctx.RequestContext()).ReadDB().
|
||
Where(h.readDB.Users.CreatedAt.Gte(prevStart)).
|
||
Where(h.readDB.Users.CreatedAt.Lte(prevEnd)).
|
||
Count()
|
||
if err != nil {
|
||
ctx.AbortWithError(core.Error(http.StatusBadRequest, 21006, err.Error()))
|
||
return
|
||
}
|
||
|
||
var tpRows []struct{ Sum int64 }
|
||
if err := h.readDB.UserPoints.WithContext(ctx.RequestContext()).ReadDB().
|
||
Where(h.readDB.UserPoints.ValidEnd.Gt(time.Now())).
|
||
Or(h.readDB.UserPoints.ValidEnd.IsNull()).
|
||
Select(h.readDB.UserPoints.Points.Sum().As("sum")).
|
||
Scan(&tpRows); err != nil {
|
||
ctx.AbortWithError(core.Error(http.StatusBadRequest, 21007, err.Error()))
|
||
return
|
||
}
|
||
var tpCur int64
|
||
if len(tpRows) > 0 {
|
||
tpCur = tpRows[0].Sum
|
||
}
|
||
// 使用积分流水计算净变动用于环比
|
||
// 当前窗口净变动
|
||
var curDeltaRows []struct{ Sum int64 }
|
||
if err := h.readDB.UserPointsLedger.WithContext(ctx.RequestContext()).ReadDB().
|
||
Where(h.readDB.UserPointsLedger.CreatedAt.Gte(start)).
|
||
Where(h.readDB.UserPointsLedger.CreatedAt.Lte(end)).
|
||
Select(h.readDB.UserPointsLedger.Points.Sum().As("sum")).
|
||
Scan(&curDeltaRows); err != nil {
|
||
ctx.AbortWithError(core.Error(http.StatusBadRequest, 21008, err.Error()))
|
||
return
|
||
}
|
||
var curDelta int64
|
||
if len(curDeltaRows) > 0 {
|
||
curDelta = curDeltaRows[0].Sum
|
||
}
|
||
// 前一窗口净变动
|
||
var prevDeltaRows []struct{ Sum int64 }
|
||
if err := h.readDB.UserPointsLedger.WithContext(ctx.RequestContext()).ReadDB().
|
||
Where(h.readDB.UserPointsLedger.CreatedAt.Gte(prevStart)).
|
||
Where(h.readDB.UserPointsLedger.CreatedAt.Lte(prevEnd)).
|
||
Select(h.readDB.UserPointsLedger.Points.Sum().As("sum")).
|
||
Scan(&prevDeltaRows); err != nil {
|
||
ctx.AbortWithError(core.Error(http.StatusBadRequest, 21009, err.Error()))
|
||
return
|
||
}
|
||
var prevDelta int64
|
||
if len(prevDeltaRows) > 0 {
|
||
prevDelta = prevDeltaRows[0].Sum
|
||
}
|
||
|
||
// 批量:存量优惠券 (未使用)
|
||
tcCur, _ := h.readDB.UserCoupons.WithContext(ctx.RequestContext()).ReadDB().
|
||
Where(h.readDB.UserCoupons.Status.Eq(1)).
|
||
Count()
|
||
|
||
// 批量:存量道具卡 (有效)
|
||
ticCur, _ := h.readDB.UserItemCards.WithContext(ctx.RequestContext()).ReadDB().
|
||
Where(h.readDB.UserItemCards.Status.Eq(1)).
|
||
Count()
|
||
|
||
// 批量:存量盒柜资产 (持有中)
|
||
tinvCur, _ := h.readDB.UserInventory.WithContext(ctx.RequestContext()).ReadDB().
|
||
Where(h.readDB.UserInventory.Status.Eq(1)).
|
||
Count()
|
||
|
||
// 批量:存量次卡 (剩余次数)
|
||
var tgpRows []struct{ Sum int64 }
|
||
_ = h.readDB.UserGamePasses.WithContext(ctx.RequestContext()).ReadDB().
|
||
Where(h.readDB.UserGamePasses.ExpiredAt.Gt(time.Now())).
|
||
Or(h.readDB.UserGamePasses.ExpiredAt.Eq(time.Time{})).
|
||
Select(h.readDB.UserGamePasses.Remaining.Sum().As("sum")).
|
||
Scan(&tgpRows)
|
||
var tgpCur int64
|
||
if len(tgpRows) > 0 {
|
||
tgpCur = tgpRows[0].Sum
|
||
}
|
||
|
||
rsp.ItemCardSales = icCur
|
||
rsp.DrawCount = dlCur
|
||
rsp.NewUsers = nuCur
|
||
rsp.TotalPoints = h.userSvc.CentsToPointsFloat(ctx.RequestContext(), tpCur)
|
||
rsp.TotalInventory = tinvCur
|
||
rsp.TotalCoupons = tcCur
|
||
rsp.TotalItemCards = ticCur
|
||
rsp.TotalGamePasses = tgpCur
|
||
rsp.ItemCardChange = percentChange(icPrev, icCur)
|
||
rsp.DrawChange = percentChange(dlPrev, dlCur)
|
||
rsp.NewUserChange = percentChange(nuPrev, nuCur)
|
||
rsp.PointsChange = percentChange(prevDelta, curDelta)
|
||
ctx.Payload(rsp)
|
||
}
|
||
}
|
||
|
||
type trendRequest struct {
|
||
RangeType string `form:"rangeType"`
|
||
Granularity string `form:"granularity"`
|
||
}
|
||
|
||
type trendPoint struct {
|
||
Date string `json:"date"`
|
||
Value int64 `json:"value"`
|
||
Gmv int64 `json:"gmv"`
|
||
Orders int64 `json:"orders"`
|
||
NewUsers int64 `json:"newUsers"`
|
||
}
|
||
|
||
type salesDrawTrendResponse struct {
|
||
Granularity string `json:"granularity"`
|
||
List []trendPoint `json:"list"`
|
||
}
|
||
|
||
type userTrendResponse struct {
|
||
Granularity string `json:"granularity"`
|
||
List []trendPoint `json:"list"`
|
||
}
|
||
|
||
func (h *handler) DashboardUserTrend() core.HandlerFunc {
|
||
return func(ctx core.Context) {
|
||
req := new(trendRequest)
|
||
rsp := new(userTrendResponse)
|
||
if err := ctx.ShouldBindForm(req); err != nil {
|
||
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, validation.Error(err)))
|
||
return
|
||
}
|
||
start, end := parseRange(req.RangeType, "", "")
|
||
gran := normalizeGranularity(req.Granularity)
|
||
buckets := buildBuckets(start, end, gran)
|
||
list := make([]trendPoint, 0, len(buckets))
|
||
for _, b := range buckets {
|
||
c, err := h.readDB.Users.WithContext(ctx.RequestContext()).ReadDB().
|
||
Where(h.readDB.Users.CreatedAt.Gte(b.Start)).
|
||
Where(h.readDB.Users.CreatedAt.Lte(b.End)).
|
||
Count()
|
||
if err != nil {
|
||
ctx.AbortWithError(core.Error(http.StatusBadRequest, 21009, err.Error()))
|
||
return
|
||
}
|
||
list = append(list, trendPoint{Date: b.Label, Value: c})
|
||
}
|
||
rsp.Granularity = gran
|
||
rsp.List = list
|
||
ctx.Payload(rsp)
|
||
}
|
||
}
|
||
|
||
type drawTrendResponse struct {
|
||
Granularity string `json:"granularity"`
|
||
List []trendPoint `json:"list"`
|
||
}
|
||
|
||
func (h *handler) DashboardDrawTrend() core.HandlerFunc {
|
||
return func(ctx core.Context) {
|
||
req := new(trendRequest)
|
||
rsp := new(drawTrendResponse)
|
||
if err := ctx.ShouldBindForm(req); err != nil {
|
||
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, validation.Error(err)))
|
||
return
|
||
}
|
||
start, end := parseRange(req.RangeType, "", "")
|
||
gran := normalizeGranularity(req.Granularity)
|
||
buckets := buildBuckets(start, end, gran)
|
||
list := make([]trendPoint, 0, len(buckets))
|
||
for _, b := range buckets {
|
||
c, err := h.readDB.ActivityDrawLogs.WithContext(ctx.RequestContext()).ReadDB().
|
||
Where(h.readDB.ActivityDrawLogs.CreatedAt.Gte(b.Start)).
|
||
Where(h.readDB.ActivityDrawLogs.CreatedAt.Lte(b.End)).
|
||
Count()
|
||
if err != nil {
|
||
ctx.AbortWithError(core.Error(http.StatusBadRequest, 21010, err.Error()))
|
||
return
|
||
}
|
||
list = append(list, trendPoint{Date: b.Label, Value: c})
|
||
}
|
||
rsp.Granularity = gran
|
||
rsp.List = list
|
||
ctx.Payload(rsp)
|
||
}
|
||
}
|
||
|
||
type newUsersRequest struct {
|
||
Page int `form:"page"`
|
||
PageSize int `form:"page_size"`
|
||
Period string `form:"period"`
|
||
}
|
||
|
||
type newUserItem struct {
|
||
ID int64 `json:"id"`
|
||
Nickname string `json:"nickname"`
|
||
Avatar string `json:"avatar"`
|
||
CreatedAt string `json:"createdAt"`
|
||
PointsBalance int64 `json:"pointsBalance"`
|
||
InventoryCount int64 `json:"inventoryCount"`
|
||
ItemCardCount int64 `json:"itemCardCount"`
|
||
CouponCount int64 `json:"couponCount"`
|
||
TitleCount int64 `json:"titleCount"`
|
||
LastOnlineAt string `json:"lastOnlineAt"`
|
||
Titles []userTitleBrief `json:"titles"`
|
||
}
|
||
|
||
type userTitleBrief struct {
|
||
ID int64 `json:"id"`
|
||
Name string `json:"name"`
|
||
}
|
||
|
||
type newUsersResponse struct {
|
||
Page int `json:"page"`
|
||
PageSize int `json:"pageSize"`
|
||
Total int64 `json:"total"`
|
||
List []newUserItem `json:"list"`
|
||
}
|
||
|
||
func (h *handler) DashboardNewUsers() core.HandlerFunc {
|
||
return func(ctx core.Context) {
|
||
req := new(newUsersRequest)
|
||
rsp := new(newUsersResponse)
|
||
if err := ctx.ShouldBindForm(req); err != nil {
|
||
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, validation.Error(err)))
|
||
return
|
||
}
|
||
if req.Page <= 0 {
|
||
req.Page = 1
|
||
}
|
||
if req.PageSize <= 0 {
|
||
req.PageSize = 20
|
||
}
|
||
if req.PageSize > 100 {
|
||
req.PageSize = 100
|
||
}
|
||
base := h.readDB.Users.WithContext(ctx.RequestContext()).ReadDB()
|
||
if req.Period != "" {
|
||
var s, e time.Time
|
||
now := time.Now()
|
||
switch req.Period {
|
||
case "month":
|
||
s = time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, now.Location())
|
||
e = s.AddDate(0, 1, 0).Add(-time.Second)
|
||
case "last_month":
|
||
lm := now.AddDate(0, -1, 0)
|
||
s = time.Date(lm.Year(), lm.Month(), 1, 0, 0, 0, 0, lm.Location())
|
||
e = s.AddDate(0, 1, 0).Add(-time.Second)
|
||
case "year":
|
||
s = time.Date(now.Year(), 1, 1, 0, 0, 0, 0, now.Location())
|
||
e = time.Date(now.Year(), 12, 31, 23, 59, 59, 0, now.Location())
|
||
}
|
||
if !s.IsZero() && !e.IsZero() {
|
||
base = base.Where(h.readDB.Users.CreatedAt.Gte(s)).Where(h.readDB.Users.CreatedAt.Lte(e))
|
||
}
|
||
}
|
||
total, err := base.Count()
|
||
if err != nil {
|
||
ctx.AbortWithError(core.Error(http.StatusBadRequest, 21011, err.Error()))
|
||
return
|
||
}
|
||
rows, err := base.Order(h.readDB.Users.ID.Desc()).Offset((req.Page - 1) * req.PageSize).Limit(req.PageSize).Find()
|
||
if err != nil {
|
||
ctx.AbortWithError(core.Error(http.StatusBadRequest, 21012, err.Error()))
|
||
return
|
||
}
|
||
// 收集用户ID做批量聚合
|
||
ids := make([]int64, len(rows))
|
||
for i, u := range rows {
|
||
ids[i] = u.ID
|
||
}
|
||
|
||
// 批量:资产数
|
||
type kvCount struct {
|
||
UserID int64
|
||
Cnt int64
|
||
}
|
||
invCounts := map[int64]int64{}
|
||
var invRows []kvCount
|
||
_ = h.readDB.UserInventory.WithContext(ctx.RequestContext()).ReadDB().
|
||
Select(h.readDB.UserInventory.UserID, h.readDB.UserInventory.UserID.Count().As("cnt")).
|
||
Where(h.readDB.UserInventory.UserID.In(ids...)).
|
||
Group(h.readDB.UserInventory.UserID).
|
||
Scan(&invRows)
|
||
for _, r := range invRows {
|
||
invCounts[r.UserID] = r.Cnt
|
||
}
|
||
|
||
// 批量:道具卡(有效)
|
||
icCounts := map[int64]int64{}
|
||
var icRows []kvCount
|
||
_ = h.readDB.UserItemCards.WithContext(ctx.RequestContext()).ReadDB().
|
||
Select(h.readDB.UserItemCards.UserID, h.readDB.UserItemCards.UserID.Count().As("cnt")).
|
||
Where(h.readDB.UserItemCards.UserID.In(ids...)).
|
||
Where(h.readDB.UserItemCards.Status.Eq(1)).
|
||
Group(h.readDB.UserItemCards.UserID).
|
||
Scan(&icRows)
|
||
for _, r := range icRows {
|
||
icCounts[r.UserID] = r.Cnt
|
||
}
|
||
|
||
// 批量:优惠券
|
||
cpCounts := map[int64]int64{}
|
||
var cpRows []kvCount
|
||
_ = h.readDB.UserCoupons.WithContext(ctx.RequestContext()).ReadDB().
|
||
Select(h.readDB.UserCoupons.UserID, h.readDB.UserCoupons.UserID.Count().As("cnt")).
|
||
Where(h.readDB.UserCoupons.UserID.In(ids...)).
|
||
Group(h.readDB.UserCoupons.UserID).
|
||
Scan(&cpRows)
|
||
for _, r := range cpRows {
|
||
cpCounts[r.UserID] = r.Cnt
|
||
}
|
||
|
||
// 批量:称号列表
|
||
type titleRow struct {
|
||
UserID int64
|
||
ID int64
|
||
Name string
|
||
}
|
||
titleMap := map[int64][]userTitleBrief{}
|
||
var trows []titleRow
|
||
_ = h.readDB.UserTitles.WithContext(ctx.RequestContext()).ReadDB().
|
||
LeftJoin(h.readDB.SystemTitles, h.readDB.SystemTitles.ID.EqCol(h.readDB.UserTitles.TitleID)).
|
||
Select(h.readDB.UserTitles.UserID, h.readDB.UserTitles.TitleID.As("id"), h.readDB.SystemTitles.Name).
|
||
Where(h.readDB.UserTitles.UserID.In(ids...)).
|
||
Scan(&trows)
|
||
for _, tr := range trows {
|
||
titleMap[tr.UserID] = append(titleMap[tr.UserID], userTitleBrief{ID: tr.ID, Name: tr.Name})
|
||
}
|
||
|
||
// 批量:有效积分余额
|
||
pointsMap := map[int64]int64{}
|
||
now := time.Now()
|
||
pRows, _ := h.readDB.UserPoints.WithContext(ctx.RequestContext()).ReadDB().
|
||
Where(h.readDB.UserPoints.UserID.In(ids...)).
|
||
Find()
|
||
for _, r := range pRows {
|
||
if !r.ValidEnd.IsZero() && r.ValidEnd.Before(now) {
|
||
continue
|
||
}
|
||
pointsMap[r.UserID] += r.Points
|
||
}
|
||
|
||
// 批量:最后在线(各表 MAX)
|
||
type tsRow struct {
|
||
UserID int64
|
||
Ts time.Time
|
||
}
|
||
maxMap := map[int64]time.Time{}
|
||
mergeMax := func(rows []tsRow) {
|
||
for _, r := range rows {
|
||
if r.Ts.After(maxMap[r.UserID]) {
|
||
maxMap[r.UserID] = r.Ts
|
||
}
|
||
}
|
||
}
|
||
var dlMax, odMax, paidMax, plMax, icMax, ivMax []tsRow
|
||
_ = h.readDB.ActivityDrawLogs.WithContext(ctx.RequestContext()).ReadDB().
|
||
Select(h.readDB.ActivityDrawLogs.UserID, h.readDB.ActivityDrawLogs.CreatedAt.Max().As("ts")).
|
||
Where(h.readDB.ActivityDrawLogs.UserID.In(ids...)).
|
||
Group(h.readDB.ActivityDrawLogs.UserID).
|
||
Scan(&dlMax)
|
||
mergeMax(dlMax)
|
||
_ = h.readDB.Orders.WithContext(ctx.RequestContext()).ReadDB().
|
||
Select(h.readDB.Orders.UserID, h.readDB.Orders.UpdatedAt.Max().As("ts")).
|
||
Where(h.readDB.Orders.UserID.In(ids...)).
|
||
Group(h.readDB.Orders.UserID).
|
||
Scan(&odMax)
|
||
mergeMax(odMax)
|
||
_ = h.readDB.Orders.WithContext(ctx.RequestContext()).ReadDB().
|
||
Select(h.readDB.Orders.UserID, h.readDB.Orders.PaidAt.Max().As("ts")).
|
||
Where(h.readDB.Orders.UserID.In(ids...)).
|
||
Group(h.readDB.Orders.UserID).
|
||
Scan(&paidMax)
|
||
mergeMax(paidMax)
|
||
_ = h.readDB.UserPointsLedger.WithContext(ctx.RequestContext()).ReadDB().
|
||
Select(h.readDB.UserPointsLedger.UserID, h.readDB.UserPointsLedger.CreatedAt.Max().As("ts")).
|
||
Where(h.readDB.UserPointsLedger.UserID.In(ids...)).
|
||
Group(h.readDB.UserPointsLedger.UserID).
|
||
Scan(&plMax)
|
||
mergeMax(plMax)
|
||
_ = h.readDB.UserItemCards.WithContext(ctx.RequestContext()).ReadDB().
|
||
Select(h.readDB.UserItemCards.UserID, h.readDB.UserItemCards.UpdatedAt.Max().As("ts")).
|
||
Where(h.readDB.UserItemCards.UserID.In(ids...)).
|
||
Group(h.readDB.UserItemCards.UserID).
|
||
Scan(&icMax)
|
||
mergeMax(icMax)
|
||
_ = h.readDB.UserInventory.WithContext(ctx.RequestContext()).ReadDB().
|
||
Select(h.readDB.UserInventory.UserID, h.readDB.UserInventory.UpdatedAt.Max().As("ts")).
|
||
Where(h.readDB.UserInventory.UserID.In(ids...)).
|
||
Group(h.readDB.UserInventory.UserID).
|
||
Scan(&ivMax)
|
||
mergeMax(ivMax)
|
||
|
||
list := make([]newUserItem, len(rows))
|
||
for i, u := range rows {
|
||
list[i] = newUserItem{
|
||
ID: u.ID,
|
||
Nickname: u.Nickname,
|
||
Avatar: u.Avatar,
|
||
CreatedAt: u.CreatedAt.Format("2006-01-02T15:04:05Z07:00"),
|
||
PointsBalance: pointsMap[u.ID],
|
||
InventoryCount: invCounts[u.ID],
|
||
ItemCardCount: icCounts[u.ID],
|
||
CouponCount: cpCounts[u.ID],
|
||
TitleCount: int64(len(titleMap[u.ID])),
|
||
LastOnlineAt: func() string {
|
||
t := maxMap[u.ID]
|
||
if t.IsZero() {
|
||
return ""
|
||
}
|
||
return t.Format("2006-01-02T15:04:05Z07:00")
|
||
}(),
|
||
Titles: titleMap[u.ID],
|
||
}
|
||
}
|
||
rsp.Page = req.Page
|
||
rsp.PageSize = req.PageSize
|
||
rsp.Total = total
|
||
rsp.List = list
|
||
ctx.Payload(rsp)
|
||
}
|
||
}
|
||
|
||
type drawStreamRequest struct {
|
||
SinceID *int64 `form:"since_id"`
|
||
Limit int `form:"limit"`
|
||
}
|
||
|
||
type drawStreamItem struct {
|
||
ID int64 `json:"id"`
|
||
UserID int64 `json:"userId"`
|
||
Nickname string `json:"nickname"`
|
||
IssueID int64 `json:"issueId"`
|
||
IssueName string `json:"issueName"`
|
||
ActivityName string `json:"activityName"`
|
||
IssueNumber string `json:"issueNumber"`
|
||
PrizeName string `json:"prizeName"`
|
||
IsWinner int32 `json:"isWinner"`
|
||
CreatedAt string `json:"createdAt"`
|
||
}
|
||
|
||
type drawStreamResponse struct {
|
||
List []drawStreamItem `json:"list"`
|
||
SinceID *int64 `json:"sinceId,omitempty"`
|
||
}
|
||
|
||
func (h *handler) DashboardDrawStream() core.HandlerFunc {
|
||
return func(ctx core.Context) {
|
||
req := new(drawStreamRequest)
|
||
rsp := new(drawStreamResponse)
|
||
if err := ctx.ShouldBindForm(req); err != nil {
|
||
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, validation.Error(err)))
|
||
return
|
||
}
|
||
if req.Limit <= 0 {
|
||
req.Limit = 50
|
||
}
|
||
if req.Limit > 100 {
|
||
req.Limit = 100
|
||
}
|
||
q := h.readDB.ActivityDrawLogs.WithContext(ctx.RequestContext()).ReadDB()
|
||
if req.SinceID != nil {
|
||
q = q.Where(h.readDB.ActivityDrawLogs.ID.Gt(*req.SinceID))
|
||
}
|
||
type row struct {
|
||
ID int64
|
||
UserID int64
|
||
Nickname string
|
||
IssueID int64
|
||
IssueName string
|
||
ActivityName string
|
||
IssueNumber string
|
||
PrizeName string
|
||
ProductID int64
|
||
IsWinner int32
|
||
CreatedAt time.Time
|
||
}
|
||
var rows []row
|
||
err := q.
|
||
LeftJoin(h.readDB.Users, h.readDB.Users.ID.EqCol(h.readDB.ActivityDrawLogs.UserID)).
|
||
LeftJoin(h.readDB.ActivityIssues, h.readDB.ActivityIssues.ID.EqCol(h.readDB.ActivityDrawLogs.IssueID)).
|
||
LeftJoin(h.readDB.Activities, h.readDB.Activities.ID.EqCol(h.readDB.ActivityIssues.ActivityID)).
|
||
LeftJoin(h.readDB.ActivityRewardSettings, h.readDB.ActivityRewardSettings.ID.EqCol(h.readDB.ActivityDrawLogs.RewardID)).
|
||
Select(
|
||
h.readDB.ActivityDrawLogs.ID,
|
||
h.readDB.ActivityDrawLogs.UserID,
|
||
h.readDB.Users.Nickname,
|
||
h.readDB.ActivityDrawLogs.IssueID,
|
||
h.readDB.Activities.Name.As("activity_name"),
|
||
h.readDB.ActivityIssues.IssueNumber.As("issue_number"),
|
||
h.readDB.ActivityRewardSettings.ProductID.As("product_id"),
|
||
h.readDB.ActivityDrawLogs.IsWinner,
|
||
h.readDB.ActivityDrawLogs.CreatedAt,
|
||
).
|
||
Order(h.readDB.ActivityDrawLogs.ID.Desc()).
|
||
Limit(req.Limit).
|
||
Scan(&rows)
|
||
if err != nil {
|
||
ctx.AbortWithError(core.Error(http.StatusBadRequest, 21016, err.Error()))
|
||
return
|
||
}
|
||
// 批量获取商品名称,通过 ProductID 关联查询
|
||
productIDs := make([]int64, 0, len(rows))
|
||
for _, v := range rows {
|
||
if v.ProductID > 0 {
|
||
productIDs = append(productIDs, v.ProductID)
|
||
}
|
||
}
|
||
productNameMap := make(map[int64]string)
|
||
if len(productIDs) > 0 {
|
||
products, _ := h.readDB.Products.WithContext(ctx.RequestContext()).ReadDB().Where(h.readDB.Products.ID.In(productIDs...)).Find()
|
||
for _, p := range products {
|
||
productNameMap[p.ID] = p.Name
|
||
}
|
||
}
|
||
list := make([]drawStreamItem, len(rows))
|
||
var maxID int64
|
||
for i, v := range rows {
|
||
iname := v.ActivityName
|
||
if iname == "" {
|
||
iname = v.IssueName
|
||
}
|
||
// 只使用商品名称
|
||
prizeName := ""
|
||
if v.ProductID > 0 {
|
||
if pn, ok := productNameMap[v.ProductID]; ok {
|
||
prizeName = pn
|
||
}
|
||
}
|
||
list[i] = drawStreamItem{
|
||
ID: v.ID,
|
||
UserID: v.UserID,
|
||
Nickname: v.Nickname,
|
||
IssueID: v.IssueID,
|
||
IssueName: func() string {
|
||
if v.ActivityName != "" && v.IssueNumber != "" {
|
||
return v.ActivityName + "-" + v.IssueNumber
|
||
}
|
||
return iname
|
||
}(),
|
||
ActivityName: v.ActivityName,
|
||
IssueNumber: v.IssueNumber,
|
||
PrizeName: prizeName,
|
||
IsWinner: v.IsWinner,
|
||
CreatedAt: v.CreatedAt.Format("2006-01-02T15:04:05Z07:00"),
|
||
}
|
||
if v.ID > maxID {
|
||
maxID = v.ID
|
||
}
|
||
}
|
||
if len(rows) > 0 {
|
||
rsp.SinceID = &maxID
|
||
}
|
||
rsp.List = list
|
||
ctx.Payload(rsp)
|
||
}
|
||
}
|
||
|
||
type todosRequest struct {
|
||
Limit int `form:"limit"`
|
||
}
|
||
|
||
type todoItem struct {
|
||
UserID int64 `json:"userId"`
|
||
Nickname string `json:"nickname"`
|
||
Avatar string `json:"avatar"`
|
||
TaskType string `json:"taskType"`
|
||
TaskLabel string `json:"taskLabel"`
|
||
}
|
||
|
||
type todosResponse struct {
|
||
List []todoItem `json:"list"`
|
||
}
|
||
|
||
func (h *handler) DashboardTodos() core.HandlerFunc {
|
||
return func(ctx core.Context) {
|
||
req := new(todosRequest)
|
||
rsp := new(todosResponse)
|
||
if err := ctx.ShouldBindForm(req); err != nil {
|
||
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, validation.Error(err)))
|
||
return
|
||
}
|
||
if req.Limit <= 0 {
|
||
req.Limit = 50
|
||
}
|
||
if req.Limit > 100 {
|
||
req.Limit = 100
|
||
}
|
||
base := h.readDB.Users.WithContext(ctx.RequestContext()).ReadDB().Order(h.readDB.Users.ID.Desc())
|
||
rows, err := base.Limit(req.Limit).Find()
|
||
if err != nil {
|
||
ctx.AbortWithError(core.Error(http.StatusBadRequest, 21017, err.Error()))
|
||
return
|
||
}
|
||
out := make([]todoItem, 0, len(rows))
|
||
for _, u := range rows {
|
||
dlCount, _ := h.readDB.ActivityDrawLogs.WithContext(ctx.RequestContext()).ReadDB().Where(h.readDB.ActivityDrawLogs.UserID.Eq(u.ID)).Count()
|
||
if dlCount == 0 {
|
||
out = append(out, todoItem{UserID: u.ID, Nickname: u.Nickname, Avatar: u.Avatar, TaskType: "undrawn", TaskLabel: "从未参与抽奖"})
|
||
}
|
||
}
|
||
rsp.List = out
|
||
ctx.Payload(rsp)
|
||
}
|
||
}
|
||
|
||
func parseRange(rangeType, startS, endS string) (time.Time, time.Time) {
|
||
now := time.Now()
|
||
switch rangeType {
|
||
case "today":
|
||
s := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
|
||
e := s.Add(24 * time.Hour).Add(-time.Second)
|
||
return s, e
|
||
case "24h":
|
||
e := now
|
||
s := e.Add(-24 * time.Hour)
|
||
return s, e
|
||
case "30d":
|
||
e := now
|
||
s := e.Add(-30 * 24 * time.Hour)
|
||
return s, e
|
||
case "custom":
|
||
if startS != "" && endS != "" {
|
||
if st, err := time.Parse("2006-01-02", startS); err == nil {
|
||
if et, err := time.Parse("2006-01-02", endS); err == nil {
|
||
et = et.Add(24 * time.Hour).Add(-time.Second)
|
||
if et.Sub(st) > 365*24*time.Hour {
|
||
et = st.Add(365 * 24 * time.Hour).Add(-time.Second)
|
||
}
|
||
return st, et
|
||
}
|
||
}
|
||
}
|
||
fallthrough
|
||
default:
|
||
e := now
|
||
s := e.Add(-7 * 24 * time.Hour)
|
||
return s, e
|
||
}
|
||
}
|
||
|
||
func previousWindow(start, end time.Time) (time.Time, time.Time) {
|
||
dur := end.Sub(start) + time.Second
|
||
ps := start.Add(-dur)
|
||
pe := start.Add(-time.Second)
|
||
return ps, pe
|
||
}
|
||
|
||
func percentChange(prev, cur int64) string {
|
||
if prev <= 0 {
|
||
return "+0%"
|
||
}
|
||
diff := float64(cur-prev) / float64(prev) * 100
|
||
if diff >= 0 {
|
||
return "+" + strconv.Itoa(int(diff)) + "%"
|
||
}
|
||
return "-" + strconv.Itoa(int(-diff)) + "%"
|
||
}
|
||
|
||
func daysBetween(start, end time.Time) []time.Time {
|
||
s := time.Date(start.Year(), start.Month(), start.Day(), 0, 0, 0, 0, start.Location())
|
||
e := time.Date(end.Year(), end.Month(), end.Day(), 0, 0, 0, 0, end.Location())
|
||
var out []time.Time
|
||
for d := s; !d.After(e); d = d.Add(24 * time.Hour) {
|
||
out = append(out, d)
|
||
}
|
||
return out
|
||
}
|
||
|
||
type bucket struct {
|
||
Label string
|
||
Start time.Time
|
||
End time.Time
|
||
}
|
||
|
||
func normalizeGranularity(in string) string {
|
||
switch in {
|
||
case "week":
|
||
return "week"
|
||
case "month":
|
||
return "month"
|
||
case "hour":
|
||
return "hour"
|
||
case "minute":
|
||
return "minute"
|
||
default:
|
||
return "day"
|
||
}
|
||
}
|
||
|
||
func buildBuckets(start, end time.Time, gran string) []bucket {
|
||
if gran == "day" {
|
||
days := daysBetween(start, end)
|
||
out := make([]bucket, 0, len(days))
|
||
for _, d := range days {
|
||
ds := time.Date(d.Year(), d.Month(), d.Day(), 0, 0, 0, 0, d.Location())
|
||
de := ds.Add(24 * time.Hour).Add(-time.Second)
|
||
out = append(out, bucket{Label: ds.Format("2006-01-02"), Start: ds, End: de})
|
||
}
|
||
return out
|
||
}
|
||
if gran == "week" {
|
||
s := start
|
||
for s.Weekday() != time.Monday {
|
||
s = s.Add(-24 * time.Hour)
|
||
}
|
||
out := []bucket{}
|
||
for cur := s; cur.Before(end) || cur.Equal(end); cur = cur.Add(7 * 24 * time.Hour) {
|
||
ds := time.Date(cur.Year(), cur.Month(), cur.Day(), 0, 0, 0, 0, cur.Location())
|
||
de := ds.Add(7 * 24 * time.Hour).Add(-time.Second)
|
||
label := ds.Format("2006-01-02")
|
||
if de.After(end) {
|
||
de = end
|
||
}
|
||
out = append(out, bucket{Label: label, Start: ds, End: de})
|
||
if de.Equal(end) {
|
||
break
|
||
}
|
||
}
|
||
return out
|
||
}
|
||
if gran == "hour" {
|
||
out := []bucket{}
|
||
// 步长 1 小时
|
||
for cur := start; !cur.After(end); cur = cur.Add(time.Hour) {
|
||
s := time.Date(cur.Year(), cur.Month(), cur.Day(), cur.Hour(), 0, 0, 0, cur.Location())
|
||
de := s.Add(time.Hour).Add(-time.Second)
|
||
out = append(out, bucket{Label: s.Format("01-02 15h"), Start: s, End: de})
|
||
}
|
||
return out
|
||
}
|
||
if gran == "minute" {
|
||
out := []bucket{}
|
||
// 步长 1 分钟
|
||
for cur := start; !cur.After(end); cur = cur.Add(time.Minute) {
|
||
s := time.Date(cur.Year(), cur.Month(), cur.Day(), cur.Hour(), cur.Minute(), 0, 0, cur.Location())
|
||
de := s.Add(time.Minute).Add(-time.Second)
|
||
out = append(out, bucket{Label: s.Format("01-02 15:04"), Start: s, End: de})
|
||
}
|
||
return out
|
||
}
|
||
s := time.Date(start.Year(), start.Month(), 1, 0, 0, 0, 0, start.Location())
|
||
out := []bucket{}
|
||
for cur := s; cur.Before(end) || cur.Equal(end); cur = cur.AddDate(0, 1, 0) {
|
||
ds := time.Date(cur.Year(), cur.Month(), 1, 0, 0, 0, 0, cur.Location())
|
||
de := ds.AddDate(0, 1, 0).Add(-time.Second)
|
||
label := ds.Format("2006-01")
|
||
if de.After(end) {
|
||
de = end
|
||
}
|
||
out = append(out, bucket{Label: label, Start: ds, End: de})
|
||
if de.Equal(end) {
|
||
break
|
||
}
|
||
}
|
||
return out
|
||
}
|
||
|
||
type funnelItem struct {
|
||
Stage string `json:"stage"`
|
||
Count int64 `json:"count"`
|
||
Rate float64 `json:"rate"`
|
||
LostCount int64 `json:"lostCount"`
|
||
}
|
||
|
||
func (h *handler) DashboardOrderFunnel() core.HandlerFunc {
|
||
return func(ctx core.Context) {
|
||
s, e := parseRange(ctx.Request().URL.Query().Get("rangeType"), ctx.Request().URL.Query().Get("start"), ctx.Request().URL.Query().Get("end"))
|
||
|
||
// 1. 注册用户 (真实业务数据)
|
||
visitors, _ := h.readDB.Users.WithContext(ctx.RequestContext()).ReadDB().
|
||
Where(h.readDB.Users.CreatedAt.Gte(s)).
|
||
Where(h.readDB.Users.CreatedAt.Lte(e)).
|
||
Count()
|
||
|
||
// 获取周期内注册的用户 ID 列表,用于后续阶段的子集统计
|
||
var newUserIDs []int64
|
||
h.readDB.Users.WithContext(ctx.RequestContext()).ReadDB().
|
||
Where(h.readDB.Users.CreatedAt.Gte(s)).
|
||
Where(h.readDB.Users.CreatedAt.Lte(e)).
|
||
Pluck(h.readDB.Users.ID, &newUserIDs)
|
||
|
||
// 2. 下单人数 (仅统计新注册用户中的下单人数)
|
||
var orders int64
|
||
if len(newUserIDs) > 0 {
|
||
_ = h.readDB.Orders.WithContext(ctx.RequestContext()).UnderlyingDB().
|
||
Model(&model.Orders{}).
|
||
Where("user_id IN ?", newUserIDs).
|
||
Where("created_at >= ? AND created_at <= ?", s, e).
|
||
Select("COUNT(DISTINCT user_id)").
|
||
Scan(&orders)
|
||
}
|
||
|
||
// 3. 支付人数 (仅统计新注册用户中的支付人数)
|
||
var payments int64
|
||
if len(newUserIDs) > 0 {
|
||
_ = h.readDB.Orders.WithContext(ctx.RequestContext()).UnderlyingDB().
|
||
Model(&model.Orders{}).
|
||
Where("user_id IN ?", newUserIDs).
|
||
Where("status = ?", 2).
|
||
Where("paid_at >= ? AND paid_at <= ?", s, e).
|
||
Select("COUNT(DISTINCT user_id)").
|
||
Scan(&payments)
|
||
}
|
||
|
||
// 4. (已移除) 成功支付作为漏斗终点
|
||
|
||
stages := []struct {
|
||
name string
|
||
val int64
|
||
}{
|
||
{"新注册用户", visitors}, {"下单人数", orders}, {"成功支付", payments},
|
||
}
|
||
|
||
out := make([]funnelItem, 0, len(stages))
|
||
var prev int64
|
||
for i, st := range stages {
|
||
var rate float64
|
||
var lost int64
|
||
if i == 0 {
|
||
rate = 100
|
||
lost = 0
|
||
} else {
|
||
if prev > 0 {
|
||
rate = float64(st.val) / float64(prev) * 100
|
||
}
|
||
lost = prev - st.val
|
||
if lost < 0 {
|
||
lost = 0
|
||
}
|
||
}
|
||
out = append(out, funnelItem{
|
||
Stage: st.name,
|
||
Count: st.val,
|
||
Rate: float64(int(rate*10)) / 10.0,
|
||
LostCount: lost,
|
||
})
|
||
prev = st.val
|
||
}
|
||
ctx.Payload(out)
|
||
}
|
||
}
|
||
|
||
type activitiesItem struct {
|
||
ID int64 `json:"id"`
|
||
Name string `json:"name"`
|
||
Type string `json:"type"`
|
||
StartTime string `json:"startTime"`
|
||
EndTime string `json:"endTime"`
|
||
Status string `json:"status"`
|
||
TotalDraws int64 `json:"totalDraws"`
|
||
TotalParticipants int64 `json:"totalParticipants"`
|
||
}
|
||
|
||
func (h *handler) DashboardActivities() core.HandlerFunc {
|
||
return func(ctx core.Context) {
|
||
rows, _ := h.readDB.Activities.WithContext(ctx.RequestContext()).ReadDB().Where(h.readDB.Activities.DeletedAt.IsNull()).Find()
|
||
out := make([]activitiesItem, len(rows))
|
||
for i, a := range rows {
|
||
issues, _ := h.readDB.ActivityIssues.WithContext(ctx.RequestContext()).ReadDB().Where(h.readDB.ActivityIssues.ActivityID.Eq(a.ID)).Find()
|
||
var drawTotal int64
|
||
var participants int64
|
||
for _, iss := range issues {
|
||
dt, _ := h.readDB.ActivityDrawLogs.WithContext(ctx.RequestContext()).ReadDB().Where(h.readDB.ActivityDrawLogs.IssueID.Eq(iss.ID)).Count()
|
||
drawTotal += dt
|
||
pc, _ := h.readDB.ActivityDrawLogs.WithContext(ctx.RequestContext()).ReadDB().Where(h.readDB.ActivityDrawLogs.IssueID.Eq(iss.ID)).Distinct(h.readDB.ActivityDrawLogs.UserID).Count()
|
||
participants += pc
|
||
}
|
||
status := "active"
|
||
if a.EndTime.Before(time.Now()) {
|
||
status = "ended"
|
||
}
|
||
out[i] = activitiesItem{ID: a.ID, Name: a.Name, Type: "转盘抽奖", StartTime: a.StartTime.Format("2006-01-02 15:04:05"), EndTime: a.EndTime.Format("2006-01-02 15:04:05"), Status: status, TotalDraws: drawTotal, TotalParticipants: participants}
|
||
}
|
||
ctx.Payload(out)
|
||
}
|
||
}
|
||
|
||
type activityPrize struct {
|
||
PrizeID int64 `json:"prizeId"`
|
||
PrizeName string `json:"prizeName"`
|
||
PrizeLevel int32 `json:"prizeLevel"`
|
||
PrizeType string `json:"prizeType"`
|
||
PrizeValue int64 `json:"prizeValue"`
|
||
TotalQuantity int64 `json:"totalQuantity"`
|
||
IssuedQuantity int64 `json:"issuedQuantity"`
|
||
DrawCount int64 `json:"drawCount"` // 该奖品所属期数的总抽奖次数
|
||
WinCount int64 `json:"winCount"` // 该奖品的中奖次数
|
||
WinRate float64 `json:"winRate"` // 中奖率 (WinCount / DrawCount)
|
||
Probability float64 `json:"probability"` // 配置概率 (Weight / IssueTotalWeight)
|
||
ActualProbability float64 `json:"actualProbability"` // 实际概率 (同 WinRate)
|
||
Cost int64 `json:"cost"`
|
||
}
|
||
|
||
type activityInfo struct {
|
||
ID int64 `json:"id"`
|
||
Name string `json:"name"`
|
||
Type string `json:"type"`
|
||
StartTime string `json:"startTime"`
|
||
EndTime string `json:"endTime"`
|
||
Status string `json:"status"`
|
||
TotalDraws int64 `json:"totalDraws"`
|
||
TotalParticipants int64 `json:"totalParticipants"`
|
||
PriceDraw int64 `json:"priceDraw"`
|
||
}
|
||
|
||
type activityPrizeAnalysis struct {
|
||
Activity activityInfo `json:"activity"`
|
||
Prizes []activityPrize `json:"prizes"`
|
||
Summary struct {
|
||
TotalCost int64 `json:"totalCost"`
|
||
AvgWinRate float64 `json:"avgWinRate"`
|
||
MaxWinRate float64 `json:"maxWinRate"`
|
||
MinWinRate float64 `json:"minWinRate"`
|
||
} `json:"summary"`
|
||
}
|
||
|
||
func (h *handler) DashboardActivityPrizeAnalysis() core.HandlerFunc {
|
||
return func(ctx core.Context) {
|
||
aidS := ctx.Request().URL.Query().Get("activity_id")
|
||
if aidS == "" {
|
||
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, "缺少活动ID"))
|
||
return
|
||
}
|
||
aid, err := strconv.ParseInt(aidS, 10, 64)
|
||
if err != nil {
|
||
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, "活动ID格式错误"))
|
||
return
|
||
}
|
||
|
||
// 1. 获取活动信息
|
||
act, err := h.readDB.Activities.WithContext(ctx.RequestContext()).ReadDB().Where(h.readDB.Activities.ID.Eq(aid)).First()
|
||
if err != nil {
|
||
ctx.AbortWithError(core.Error(http.StatusBadRequest, 21020, "活动不存在"))
|
||
return
|
||
}
|
||
|
||
// 2. 获取该活动下的所有期数 (Issues)
|
||
issues, _ := h.readDB.ActivityIssues.WithContext(ctx.RequestContext()).ReadDB().Where(h.readDB.ActivityIssues.ActivityID.Eq(aid)).Find()
|
||
if len(issues) == 0 {
|
||
ctx.Payload(activityPrizeAnalysis{Activity: activityInfo{ID: act.ID, Name: act.Name}})
|
||
return
|
||
}
|
||
issueIDs := make([]int64, len(issues))
|
||
for i, v := range issues {
|
||
issueIDs[i] = v.ID
|
||
}
|
||
|
||
// 3. 批量获取所有奖品配置 (Rewards)
|
||
rsAll, _ := h.readDB.ActivityRewardSettings.WithContext(ctx.RequestContext()).ReadDB().Where(h.readDB.ActivityRewardSettings.IssueID.In(issueIDs...)).Find()
|
||
|
||
// 收集 Product IDs 以便批量查询价格
|
||
productIDs := make([]int64, 0)
|
||
// 计算每期的总权重 (IssueID -> TotalWeight)
|
||
issueTotalWeights := make(map[int64]int64)
|
||
for _, r := range rsAll {
|
||
if r.ProductID != 0 {
|
||
productIDs = append(productIDs, r.ProductID)
|
||
}
|
||
issueTotalWeights[r.IssueID] += int64(r.Weight)
|
||
}
|
||
|
||
// 4. 批量查询商品价格和名称 (Product Price and Name)
|
||
priceMap := make(map[int64]int64)
|
||
productNameMap := make(map[int64]string)
|
||
if len(productIDs) > 0 {
|
||
products, _ := h.readDB.Products.WithContext(ctx.RequestContext()).ReadDB().Where(h.readDB.Products.ID.In(productIDs...)).Find()
|
||
for _, p := range products {
|
||
priceMap[p.ID] = p.Price
|
||
productNameMap[p.ID] = p.Name
|
||
}
|
||
}
|
||
|
||
// 5. 批量聚合统计数据 (Draw Logs)
|
||
// 5.1 每期的总抽奖数 (IssueID -> Count)
|
||
type countResult struct {
|
||
Key int64
|
||
Count int64
|
||
}
|
||
drawCounts := make(map[int64]int64)
|
||
var dcRows []countResult
|
||
_ = h.readDB.ActivityDrawLogs.WithContext(ctx.RequestContext()).ReadDB().Debug().
|
||
Select(h.readDB.ActivityDrawLogs.IssueID.As("key"), h.readDB.ActivityDrawLogs.ID.Count().As("count")).
|
||
LeftJoin(h.readDB.Orders, h.readDB.Orders.ID.EqCol(h.readDB.ActivityDrawLogs.OrderID)).
|
||
Where(h.readDB.ActivityDrawLogs.IssueID.In(issueIDs...)).
|
||
Where(h.readDB.Orders.Status.Eq(2)).
|
||
Group(h.readDB.ActivityDrawLogs.IssueID).
|
||
Scan(&dcRows)
|
||
|
||
for _, r := range dcRows {
|
||
drawCounts[r.Key] = r.Count
|
||
}
|
||
|
||
// 5.2 每个奖品的中奖数 (RewardID -> Count)
|
||
winCounts := make(map[int64]int64)
|
||
var wcRows []countResult
|
||
_ = h.readDB.ActivityDrawLogs.WithContext(ctx.RequestContext()).ReadDB().Debug().
|
||
Select(h.readDB.ActivityDrawLogs.RewardID.As("key"), h.readDB.ActivityDrawLogs.ID.Count().As("count")).
|
||
LeftJoin(h.readDB.Orders, h.readDB.Orders.ID.EqCol(h.readDB.ActivityDrawLogs.OrderID)).
|
||
Where(h.readDB.ActivityDrawLogs.IssueID.In(issueIDs...)).
|
||
Where(h.readDB.ActivityDrawLogs.IsWinner.Eq(1)).
|
||
Where(h.readDB.Orders.Status.Eq(2)).
|
||
Group(h.readDB.ActivityDrawLogs.RewardID).
|
||
Scan(&wcRows)
|
||
for _, r := range wcRows {
|
||
winCounts[r.Key] = r.Count
|
||
}
|
||
|
||
// 5.3 活动总参与人数 (Distinct UserID)
|
||
|
||
participants, _ := h.readDB.ActivityDrawLogs.WithContext(ctx.RequestContext()).ReadDB().Debug().
|
||
LeftJoin(h.readDB.Orders, h.readDB.Orders.ID.EqCol(h.readDB.ActivityDrawLogs.OrderID)).
|
||
Where(h.readDB.ActivityDrawLogs.IssueID.In(issueIDs...)).
|
||
Where(h.readDB.Orders.Status.Eq(2)).
|
||
Distinct(h.readDB.ActivityDrawLogs.UserID).
|
||
Count()
|
||
|
||
// 5.4 活动总抽奖次数 (Sum of Issue Draw Counts)
|
||
var totalActivityDraws int64
|
||
for _, c := range drawCounts {
|
||
totalActivityDraws += c
|
||
}
|
||
|
||
// 6. 组装数据
|
||
prizes := make([]activityPrize, len(rsAll))
|
||
var totalCost int64
|
||
var winRates []float64
|
||
|
||
for i, r := range rsAll {
|
||
dc := drawCounts[r.IssueID] // 该期的总抽奖数
|
||
wc := winCounts[r.ID] // 该奖品的中奖数
|
||
price := priceMap[r.ProductID]
|
||
|
||
issued := r.OriginalQty - r.Quantity
|
||
|
||
// 计算配置概率:该奖品权重 / 该期总权重
|
||
var prob float64
|
||
if w := issueTotalWeights[r.IssueID]; w > 0 {
|
||
prob = float64(r.Weight) / float64(w) * 100
|
||
}
|
||
|
||
// 计算实际中奖率:中奖数 / 该期总抽奖数
|
||
var actual float64
|
||
if dc > 0 {
|
||
actual = float64(wc) / float64(dc) * 100
|
||
}
|
||
|
||
prizes[i] = activityPrize{
|
||
PrizeID: r.ID,
|
||
PrizeName: productNameMap[r.ProductID],
|
||
PrizeLevel: r.Level,
|
||
PrizeType: func() string {
|
||
if r.ProductID != 0 {
|
||
return "实物奖品"
|
||
}
|
||
return "虚拟/道具"
|
||
}(),
|
||
PrizeValue: price,
|
||
TotalQuantity: r.OriginalQty,
|
||
IssuedQuantity: issued,
|
||
DrawCount: dc,
|
||
WinCount: wc,
|
||
WinRate: actual,
|
||
Probability: prob,
|
||
ActualProbability: actual,
|
||
Cost: price,
|
||
}
|
||
|
||
totalCost += price * wc
|
||
winRates = append(winRates, actual)
|
||
}
|
||
|
||
// 7. 计算汇总指标
|
||
status := "active"
|
||
if act.EndTime.Before(time.Now()) {
|
||
status = "ended"
|
||
}
|
||
|
||
var avgW, maxW, minW float64
|
||
if len(winRates) > 0 {
|
||
var sum float64
|
||
maxW = winRates[0]
|
||
minW = winRates[0]
|
||
for _, w := range winRates {
|
||
sum += w
|
||
if w > maxW {
|
||
maxW = w
|
||
}
|
||
if w < minW {
|
||
minW = w
|
||
}
|
||
}
|
||
avgW = sum / float64(len(winRates))
|
||
}
|
||
|
||
out := activityPrizeAnalysis{}
|
||
out.Activity = activityInfo{
|
||
ID: act.ID,
|
||
Name: act.Name,
|
||
Type: "转盘抽奖",
|
||
StartTime: act.StartTime.Format("2006-01-02 15:04:05"),
|
||
EndTime: act.EndTime.Format("2006-01-02 15:04:05"),
|
||
Status: status,
|
||
TotalDraws: totalActivityDraws,
|
||
TotalParticipants: participants,
|
||
PriceDraw: act.PriceDraw,
|
||
}
|
||
out.Prizes = prizes
|
||
out.Summary.TotalCost = totalCost
|
||
out.Summary.AvgWinRate = avgW
|
||
out.Summary.MaxWinRate = maxW
|
||
out.Summary.MinWinRate = minW
|
||
|
||
ctx.Payload(out)
|
||
}
|
||
}
|
||
|
||
type userOverviewResponse struct {
|
||
Chart []struct {
|
||
Date string `json:"date"`
|
||
Value int64 `json:"value"`
|
||
} `json:"chart"`
|
||
Metrics struct {
|
||
TotalUsers int64 `json:"totalUsers"`
|
||
TotalVisits int64 `json:"totalVisits"`
|
||
DailyVisits int64 `json:"dailyVisits"`
|
||
WeeklyGrowth string `json:"weeklyGrowth"`
|
||
} `json:"metrics"`
|
||
}
|
||
|
||
func (h *handler) DashboardUserOverview() core.HandlerFunc {
|
||
return func(ctx core.Context) {
|
||
totalUsers, _ := h.readDB.Users.WithContext(ctx.RequestContext()).ReadDB().Count()
|
||
todayS, todayE := parseRange("today", "", "")
|
||
dailyVisits, _ := h.readDB.LogRequest.WithContext(ctx.RequestContext()).ReadDB().Where(h.readDB.LogRequest.CreatedAt.Gte(todayS)).Where(h.readDB.LogRequest.CreatedAt.Lte(todayE)).Where(h.readDB.LogRequest.Path.Like("/api/app/%")).Count()
|
||
s7, e7 := parseRange("7d", "", "")
|
||
lastS := s7.Add(-7 * 24 * time.Hour)
|
||
lastE := s7.Add(-time.Second)
|
||
curVisits, _ := h.readDB.LogRequest.WithContext(ctx.RequestContext()).ReadDB().Where(h.readDB.LogRequest.CreatedAt.Gte(s7)).Where(h.readDB.LogRequest.CreatedAt.Lte(e7)).Where(h.readDB.LogRequest.Path.Like("/api/app/%")).Count()
|
||
prevVisits, _ := h.readDB.LogRequest.WithContext(ctx.RequestContext()).ReadDB().Where(h.readDB.LogRequest.CreatedAt.Gte(lastS)).Where(h.readDB.LogRequest.CreatedAt.Lte(lastE)).Where(h.readDB.LogRequest.Path.Like("/api/app/%")).Count()
|
||
var wg string
|
||
if prevVisits > 0 {
|
||
diff := float64(curVisits-prevVisits) / float64(prevVisits) * 100
|
||
if diff >= 0 {
|
||
wg = "+" + strconv.Itoa(int(diff)) + "%"
|
||
} else {
|
||
wg = "-" + strconv.Itoa(int(-diff)) + "%"
|
||
}
|
||
} else {
|
||
wg = "+0%"
|
||
}
|
||
totalVisits, _ := h.readDB.LogRequest.WithContext(ctx.RequestContext()).ReadDB().Where(h.readDB.LogRequest.Path.Like("/api/app/%")).Count()
|
||
type chartPoint struct {
|
||
Date string `json:"date"`
|
||
Value int64 `json:"value"`
|
||
}
|
||
chart := []chartPoint{}
|
||
now := time.Now()
|
||
for i := 8; i >= 0; i-- {
|
||
dt := time.Date(now.Year(), now.Month()-time.Month(i), 1, 0, 0, 0, 0, now.Location())
|
||
dn := dt.AddDate(0, 1, 0).Add(-time.Second)
|
||
c, _ := h.readDB.Users.WithContext(ctx.RequestContext()).ReadDB().Where(h.readDB.Users.CreatedAt.Gte(dt)).Where(h.readDB.Users.CreatedAt.Lte(dn)).Count()
|
||
chart = append(chart, chartPoint{Date: dt.Format("2006-01"), Value: c})
|
||
}
|
||
out := userOverviewResponse{}
|
||
out.Chart = make([]struct {
|
||
Date string `json:"date"`
|
||
Value int64 `json:"value"`
|
||
}, len(chart))
|
||
for i := range chart {
|
||
out.Chart[i] = struct {
|
||
Date string `json:"date"`
|
||
Value int64 `json:"value"`
|
||
}{Date: chart[i].Date, Value: chart[i].Value}
|
||
}
|
||
out.Metrics.TotalUsers = totalUsers
|
||
out.Metrics.TotalVisits = totalVisits
|
||
out.Metrics.DailyVisits = dailyVisits
|
||
out.Metrics.WeeklyGrowth = wg
|
||
ctx.Payload(out)
|
||
}
|
||
}
|
||
|
||
type retentionCohort struct {
|
||
Date string `json:"date"`
|
||
Total int64 `json:"total"`
|
||
Retention []float64 `json:"retention"`
|
||
}
|
||
|
||
type retentionAnalyticsResponse struct {
|
||
List []retentionCohort `json:"list"`
|
||
}
|
||
|
||
func (h *handler) DashboardRetentionAnalytics() core.HandlerFunc {
|
||
return func(ctx core.Context) {
|
||
// 默认取 30 天
|
||
end := time.Now()
|
||
start := end.AddDate(0, 0, -30)
|
||
days := daysBetween(start, end)
|
||
|
||
out := make([]retentionCohort, 0, len(days))
|
||
|
||
for _, d := range days {
|
||
ds := time.Date(d.Year(), d.Month(), d.Day(), 0, 0, 0, 0, d.Location())
|
||
de := ds.AddDate(0, 0, 1).Add(-time.Second)
|
||
|
||
// 分群活跃用户:该日期注册的用户
|
||
var userIDs []int64
|
||
_ = h.readDB.Users.WithContext(ctx.RequestContext()).ReadDB().
|
||
Where(h.readDB.Users.CreatedAt.Gte(ds)).
|
||
Where(h.readDB.Users.CreatedAt.Lte(de)).
|
||
Pluck(h.readDB.Users.ID, &userIDs)
|
||
|
||
total := int64(len(userIDs))
|
||
if total == 0 {
|
||
continue
|
||
}
|
||
|
||
retention := make([]float64, 8)
|
||
retention[0] = 100
|
||
|
||
for i := 1; i <= 7; i++ {
|
||
rs := ds.AddDate(0, 0, i)
|
||
re := rs.AddDate(0, 0, 1).Add(-time.Second)
|
||
|
||
// 统计这批用户在 Day i 是否有下单行为作为活跃指标
|
||
activeCount, _ := h.readDB.Orders.WithContext(ctx.RequestContext()).ReadDB().
|
||
Where(h.readDB.Orders.UserID.In(userIDs...)).
|
||
Where(h.readDB.Orders.CreatedAt.Gte(rs)).
|
||
Where(h.readDB.Orders.CreatedAt.Lte(re)).
|
||
Distinct(h.readDB.Orders.UserID).
|
||
Count()
|
||
|
||
rate := float64(activeCount) / float64(total) * 100
|
||
retention[i] = float64(int(rate*10)) / 10.0
|
||
}
|
||
|
||
out = append(out, retentionCohort{
|
||
Date: ds.Format("2006-01-02"),
|
||
Total: total,
|
||
Retention: retention,
|
||
})
|
||
}
|
||
|
||
ctx.Payload(retentionAnalyticsResponse{List: out})
|
||
}
|
||
}
|
||
|
||
type userEconomicsResponse struct {
|
||
Arpu int64 `json:"arpu"`
|
||
ArpuTrend float64 `json:"arpuTrend"`
|
||
Cac int64 `json:"cac"`
|
||
CacTrend float64 `json:"cacTrend"`
|
||
Clv int64 `json:"clv"`
|
||
KFactor float64 `json:"kFactor"`
|
||
}
|
||
|
||
func (h *handler) DashboardUserEconomics() core.HandlerFunc {
|
||
return func(ctx core.Context) {
|
||
now := time.Now()
|
||
start30 := now.AddDate(0, 0, -30)
|
||
start60 := now.AddDate(0, 0, -60)
|
||
|
||
// 1. ARPU: (总抽奖价值) / 活跃人数
|
||
type gmvRow struct {
|
||
TotalVal int64
|
||
}
|
||
var curGmv gmvRow
|
||
h.readDB.ActivityDrawLogs.WithContext(ctx.RequestContext()).UnderlyingDB().
|
||
Joins("JOIN activity_issues ON activity_issues.id = activity_draw_logs.issue_id").
|
||
Joins("JOIN activities ON activities.id = activity_issues.activity_id").
|
||
Where("activity_draw_logs.created_at >= ?", start30).
|
||
Select("SUM(activities.price_draw) as total_val").
|
||
Scan(&curGmv)
|
||
|
||
var prevGmv gmvRow
|
||
h.readDB.ActivityDrawLogs.WithContext(ctx.RequestContext()).UnderlyingDB().
|
||
Joins("JOIN activity_issues ON activity_issues.id = activity_draw_logs.issue_id").
|
||
Joins("JOIN activities ON activities.id = activity_issues.activity_id").
|
||
Where("activity_draw_logs.created_at >= ? AND activity_draw_logs.created_at < ?", start60, start30).
|
||
Select("SUM(activities.price_draw) as total_val").
|
||
Scan(&prevGmv)
|
||
|
||
activeUsers, _ := h.readDB.ActivityDrawLogs.WithContext(ctx.RequestContext()).ReadDB().
|
||
Where(h.readDB.ActivityDrawLogs.CreatedAt.Gte(start30)).
|
||
Distinct(h.readDB.ActivityDrawLogs.UserID).
|
||
Count()
|
||
prevUsers, _ := h.readDB.ActivityDrawLogs.WithContext(ctx.RequestContext()).ReadDB().
|
||
Where(h.readDB.ActivityDrawLogs.CreatedAt.Gte(start60)).
|
||
Where(h.readDB.ActivityDrawLogs.CreatedAt.Lt(start30)).
|
||
Distinct(h.readDB.ActivityDrawLogs.UserID).
|
||
Count()
|
||
|
||
var arpu int64
|
||
var arpuTrend float64
|
||
if activeUsers > 0 {
|
||
arpu = curGmv.TotalVal / activeUsers
|
||
if prevUsers > 0 && prevGmv.TotalVal > 0 {
|
||
prevArpu := prevGmv.TotalVal / prevUsers
|
||
if prevArpu > 0 {
|
||
arpuTrend = float64(arpu-prevArpu) / float64(prevArpu) * 100
|
||
}
|
||
}
|
||
}
|
||
|
||
// 2. K-Factor
|
||
invites, _ := h.readDB.UserInvites.WithContext(ctx.RequestContext()).ReadDB().
|
||
Where(h.readDB.UserInvites.CreatedAt.Gte(start30)).
|
||
Count()
|
||
|
||
var kFactor float64
|
||
if activeUsers > 0 {
|
||
kFactor = float64(invites) / float64(activeUsers)
|
||
}
|
||
|
||
// 3. CAC
|
||
var marketingSpendNull sql.NullInt64
|
||
_ = h.readDB.UserCoupons.WithContext(ctx.RequestContext()).UnderlyingDB().
|
||
Joins("JOIN system_coupons ON system_coupons.id = user_coupons.coupon_id").
|
||
Where("user_coupons.created_at >= ?", start30).
|
||
Select("SUM(system_coupons.discount_value)").
|
||
Scan(&marketingSpendNull)
|
||
|
||
newUsers, _ := h.readDB.Users.WithContext(ctx.RequestContext()).ReadDB().
|
||
Where(h.readDB.Users.CreatedAt.Gte(start30)).
|
||
Count()
|
||
|
||
var cac int64
|
||
if newUsers > 0 {
|
||
cac = marketingSpendNull.Int64 / newUsers
|
||
}
|
||
|
||
clv := arpu * 5
|
||
|
||
ctx.Payload(userEconomicsResponse{
|
||
Arpu: arpu / 100,
|
||
ArpuTrend: float64(int(arpuTrend*10)) / 10.0,
|
||
Cac: cac / 100,
|
||
CacTrend: 0.0,
|
||
Clv: clv / 100,
|
||
KFactor: float64(int(kFactor*100)) / 100.0,
|
||
})
|
||
}
|
||
}
|
||
|
||
type prizeDistributionItem struct {
|
||
Level int32 `json:"level"`
|
||
LevelName string `json:"levelName"`
|
||
WinnerCount int64 `json:"winnerCount"`
|
||
PrizeCount int64 `json:"prizeCount"`
|
||
WinRate float64 `json:"winRate"`
|
||
Cost int64 `json:"cost"`
|
||
}
|
||
|
||
func (h *handler) DashboardPrizeDistribution() core.HandlerFunc {
|
||
return func(ctx core.Context) {
|
||
// 聚合所有在线期数的奖品级别概率与产出
|
||
var rows []struct {
|
||
Level int32
|
||
LevelTotalQty int64 // 该档位总库存 (SUM original_qty)
|
||
LevelRemQty int64 // 该档位剩余 (SUM quantity)
|
||
LevelTotalProb float64 // 该档位总权重 (SUM weight)
|
||
PrizeCount int64 // 该档位配置项数 (COUNT id)
|
||
LevelTotalValue int64 // 该档位总货值 (SUM price * original_qty)
|
||
}
|
||
|
||
activityIdStr := ctx.Request().URL.Query().Get("activity_id")
|
||
|
||
// 1. 构建基础查询条件(复用)
|
||
buildBaseDB := func() *gorm.DB {
|
||
base := h.readDB.ActivityRewardSettings.WithContext(ctx.RequestContext()).UnderlyingDB().
|
||
Joins("JOIN activity_issues ON activity_issues.id = activity_reward_settings.issue_id").
|
||
Joins("JOIN activities ON activities.id = activity_issues.activity_id")
|
||
|
||
if activityIdStr != "" {
|
||
base = base.Where("activities.id = ?", activityIdStr)
|
||
} else {
|
||
base = base.Where("activities.status = ?", 1)
|
||
}
|
||
return base
|
||
}
|
||
|
||
// 2. 计算活动总权重 (用于计算真实概率)
|
||
var totalActivityWeight float64
|
||
// 使用新的DB Session进行查询,避免GORM Scope污染
|
||
weightDB := buildBaseDB()
|
||
var weightResult sql.NullFloat64
|
||
weightDB.Select("SUM(activity_reward_settings.weight)").Scan(&weightResult)
|
||
totalActivityWeight = weightResult.Float64
|
||
|
||
// 3. 分组统计各项指标
|
||
// 注意: gorm GEN 模式下的 UnderlyingDB() 返回的是 *gorm.DB,可以链式调用
|
||
mainDB := buildBaseDB().
|
||
Joins("LEFT JOIN products ON products.id = activity_reward_settings.product_id").
|
||
Select(
|
||
"activity_reward_settings.level",
|
||
"SUM(activity_reward_settings.original_qty) as level_total_qty",
|
||
"SUM(activity_reward_settings.quantity) as level_rem_qty",
|
||
"SUM(activity_reward_settings.weight) as level_total_prob",
|
||
"COUNT(activity_reward_settings.id) as prize_count",
|
||
"SUM(COALESCE(activity_reward_settings.price_snapshot_cents, products.price, 0) * activity_reward_settings.original_qty) as level_total_value",
|
||
).
|
||
Group("activity_reward_settings.level").
|
||
Order("activity_reward_settings.level").
|
||
Scan(&rows)
|
||
|
||
if mainDB.Error != nil {
|
||
ctx.AbortWithError(core.Error(http.StatusBadRequest, 21030, mainDB.Error.Error()))
|
||
return
|
||
}
|
||
|
||
// 4. 从 activity_draw_logs 统计实际抽奖中奖数量(按 Level 分组)
|
||
// 这是为了解决无限赏等不扣减库存的情况
|
||
type drawLogStat struct {
|
||
Level int32
|
||
WinCount int64
|
||
}
|
||
var drawStats []drawLogStat
|
||
|
||
drawLogDB := h.readDB.ActivityDrawLogs.WithContext(ctx.RequestContext()).UnderlyingDB().
|
||
Joins("JOIN activity_reward_settings ON activity_reward_settings.id = activity_draw_logs.reward_id").
|
||
Joins("JOIN activity_issues ON activity_issues.id = activity_draw_logs.issue_id").
|
||
Joins("JOIN activities ON activities.id = activity_issues.activity_id").
|
||
Where("activity_draw_logs.is_winner = ?", 1)
|
||
|
||
if activityIdStr != "" {
|
||
drawLogDB = drawLogDB.Where("activities.id = ?", activityIdStr)
|
||
} else {
|
||
drawLogDB = drawLogDB.Where("activities.status = ?", 1)
|
||
}
|
||
|
||
drawLogDB.Select(
|
||
"activity_reward_settings.level",
|
||
"COUNT(activity_draw_logs.id) as win_count",
|
||
).
|
||
Group("activity_reward_settings.level").
|
||
Scan(&drawStats)
|
||
|
||
// 构建 level -> winCount 映射
|
||
drawLogWinMap := make(map[int32]int64)
|
||
for _, ds := range drawStats {
|
||
drawLogWinMap[ds.Level] = ds.WinCount
|
||
}
|
||
|
||
out := make([]prizeDistributionItem, len(rows))
|
||
levelNames := map[int32]string{1: "隐藏款", 2: "A赏", 3: "B赏", 4: "C赏", 5: "D赏", 6: "E赏", 7: "F赏", 8: "Last赏"}
|
||
|
||
for i, r := range rows {
|
||
// 优先使用 activity_draw_logs 统计的实际中奖数
|
||
// 如果 drawLog 有记录则用它,否则回退到库存差计算
|
||
winCount := drawLogWinMap[r.Level]
|
||
if winCount == 0 {
|
||
// 回退到库存差计算(一番赏等会扣库存的场景)
|
||
winCount = r.LevelTotalQty - r.LevelRemQty
|
||
if winCount < 0 {
|
||
winCount = 0
|
||
}
|
||
}
|
||
|
||
name := levelNames[r.Level]
|
||
if name == "" {
|
||
name = strconv.Itoa(int(r.Level)) + "等奖"
|
||
}
|
||
|
||
// 真实概率计算:该档位总权重 / 活动总权重
|
||
var winRate float64
|
||
if totalActivityWeight > 0 {
|
||
winRate = r.LevelTotalProb / totalActivityWeight
|
||
}
|
||
|
||
// 成本计算:(该档位总货值 / 该档位总数量) * 已发出数量
|
||
// 即:加权平均单价 * 发出数量
|
||
var cost int64
|
||
if r.LevelTotalQty > 0 {
|
||
avgPrice := float64(r.LevelTotalValue) / float64(r.LevelTotalQty)
|
||
cost = int64(avgPrice * float64(winCount))
|
||
}
|
||
|
||
out[i] = prizeDistributionItem{
|
||
Level: r.Level,
|
||
LevelName: name,
|
||
WinnerCount: winCount,
|
||
PrizeCount: r.LevelTotalQty, // 前端显示的总库存
|
||
WinRate: winRate,
|
||
Cost: cost / 100, // 分转元
|
||
}
|
||
}
|
||
|
||
ctx.Payload(out)
|
||
}
|
||
}
|
||
|
||
func (h *handler) DashboardSalesDrawTrend() core.HandlerFunc {
|
||
return func(ctx core.Context) {
|
||
req := new(trendRequest)
|
||
if err := ctx.ShouldBindForm(req); err != nil {
|
||
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, validation.Error(err)))
|
||
return
|
||
}
|
||
|
||
s, e := parseRange(req.RangeType, "", "")
|
||
gran := normalizeGranularity(req.Granularity)
|
||
buckets := buildBuckets(s, e, gran)
|
||
|
||
list := make([]trendPoint, len(buckets))
|
||
for i, b := range buckets {
|
||
// 抽奖数 (Value)
|
||
draws, _ := h.readDB.ActivityDrawLogs.WithContext(ctx.RequestContext()).ReadDB().
|
||
Where(h.readDB.ActivityDrawLogs.CreatedAt.Gte(b.Start)).
|
||
Where(h.readDB.ActivityDrawLogs.CreatedAt.Lte(b.End)).
|
||
Count()
|
||
|
||
// 总业务价值 (GMV) - 基于抽奖单价计算
|
||
var gmv struct {
|
||
Total int64
|
||
}
|
||
_ = h.readDB.ActivityDrawLogs.WithContext(ctx.RequestContext()).UnderlyingDB().
|
||
Joins("JOIN activity_issues ON activity_issues.id = activity_draw_logs.issue_id").
|
||
Joins("JOIN activities ON activities.id = activity_issues.activity_id").
|
||
Where("activity_draw_logs.created_at >= ?", b.Start).
|
||
Where("activity_draw_logs.created_at <= ?", b.End).
|
||
Select("SUM(activities.price_draw) as total").
|
||
Scan(&gmv)
|
||
|
||
// 订单数 (仅支付成功的订单)
|
||
orders, _ := h.readDB.Orders.WithContext(ctx.RequestContext()).ReadDB().
|
||
Where(h.readDB.Orders.Status.Eq(2)).
|
||
Where(h.readDB.Orders.PaidAt.Gte(b.Start)).
|
||
Where(h.readDB.Orders.PaidAt.Lte(b.End)).
|
||
Count()
|
||
|
||
// 新注册用户数
|
||
newUsers, _ := h.readDB.Users.WithContext(ctx.RequestContext()).ReadDB().
|
||
Where(h.readDB.Users.CreatedAt.Gte(b.Start)).
|
||
Where(h.readDB.Users.CreatedAt.Lte(b.End)).
|
||
Count()
|
||
|
||
list[i] = trendPoint{
|
||
Date: b.Label,
|
||
Value: draws,
|
||
Gmv: gmv.Total / 100, // 转为元
|
||
Orders: orders,
|
||
NewUsers: newUsers,
|
||
}
|
||
}
|
||
|
||
ctx.Payload(salesDrawTrendResponse{
|
||
Granularity: gran,
|
||
List: list,
|
||
})
|
||
}
|
||
}
|
||
|
||
// =====================================================================
|
||
// 运营分析接口 (Operations Analytics)
|
||
// =====================================================================
|
||
|
||
// 1. 产品动销排行
|
||
type productPerformanceItem struct {
|
||
ID int64 `json:"id"`
|
||
SeriesName string `json:"seriesName"`
|
||
SalesCount int64 `json:"salesCount"`
|
||
Amount int64 `json:"amount"`
|
||
Profit int64 `json:"profit"`
|
||
ProfitRate float64 `json:"profitRate"`
|
||
ContributionRate float64 `json:"contributionRate"`
|
||
InventoryTurnover float64 `json:"inventoryTurnover"`
|
||
}
|
||
|
||
func (h *handler) OperationsProductPerformance() core.HandlerFunc {
|
||
return func(ctx core.Context) {
|
||
s, e := parseRange(ctx.Request().URL.Query().Get("rangeType"), "", "")
|
||
|
||
// 按活动聚合抽奖数据
|
||
type drawRow struct {
|
||
ActivityID int64 `gorm:"column:activity_id"`
|
||
Count int64 `gorm:"column:count"`
|
||
TotalCost int64 `gorm:"column:total_cost"`
|
||
}
|
||
var rows []drawRow
|
||
|
||
// 统计抽奖日志,按活动分组,并计算奖品成本
|
||
if err := h.readDB.ActivityDrawLogs.WithContext(ctx.RequestContext()).UnderlyingDB().
|
||
Joins("JOIN activity_issues ON activity_issues.id = activity_draw_logs.issue_id").
|
||
Joins("LEFT JOIN activity_reward_settings ON activity_reward_settings.id = activity_draw_logs.reward_id").
|
||
Joins("LEFT JOIN products ON products.id = activity_reward_settings.product_id").
|
||
Joins("LEFT JOIN orders ON orders.id = activity_draw_logs.order_id").
|
||
Joins("LEFT JOIN user_item_cards ON user_item_cards.id = orders.item_card_id").
|
||
Joins("LEFT JOIN system_item_cards ON system_item_cards.id = user_item_cards.card_id").
|
||
Where("activity_draw_logs.created_at >= ?", s).
|
||
Where("activity_draw_logs.created_at <= ?", e).
|
||
Select(
|
||
"activity_issues.activity_id",
|
||
"COUNT(activity_draw_logs.id) as count",
|
||
"CAST(SUM(IF(activity_draw_logs.is_winner = 1, COALESCE(activity_reward_settings.price_snapshot_cents, products.price, 0) * GREATEST(COALESCE(system_item_cards.reward_multiplier_x1000, 1000), 1000) / 1000, 0)) AS SIGNED) as total_cost",
|
||
).
|
||
Group("activity_issues.activity_id").
|
||
Order("count DESC").
|
||
Limit(10).
|
||
Scan(&rows).Error; err != nil {
|
||
h.logger.Error(fmt.Sprintf("OperationsProductPerformance draw cost stats error: %v", err))
|
||
}
|
||
|
||
// 获取活动详情(名称和单价)
|
||
activityIDs := make([]int64, len(rows))
|
||
for i, r := range rows {
|
||
activityIDs[i] = r.ActivityID
|
||
}
|
||
|
||
type actInfo struct {
|
||
Name string
|
||
PriceDraw int64
|
||
}
|
||
actMap := make(map[int64]actInfo)
|
||
if len(activityIDs) > 0 {
|
||
acts, _ := h.readDB.Activities.WithContext(ctx.RequestContext()).ReadDB().
|
||
Where(h.readDB.Activities.ID.In(activityIDs...)).Find()
|
||
for _, a := range acts {
|
||
actMap[a.ID] = actInfo{Name: a.Name, PriceDraw: a.PriceDraw}
|
||
}
|
||
}
|
||
|
||
// 计算总数用于贡献率
|
||
var totalCount int64
|
||
for _, r := range rows {
|
||
totalCount += r.Count
|
||
}
|
||
|
||
out := make([]productPerformanceItem, len(rows))
|
||
for i, r := range rows {
|
||
info := actMap[r.ActivityID]
|
||
|
||
var contribution float64
|
||
if totalCount > 0 {
|
||
contribution = float64(r.Count) / float64(totalCount) * 100
|
||
}
|
||
|
||
// 周转率简化计算
|
||
days := e.Sub(s).Hours() / 24
|
||
if days < 1 {
|
||
days = 1
|
||
}
|
||
turnover := float64(r.Count) / days * 7
|
||
|
||
out[i] = productPerformanceItem{
|
||
ID: r.ActivityID,
|
||
SeriesName: info.Name,
|
||
SalesCount: r.Count,
|
||
Amount: (r.Count * info.PriceDraw) / 100, // 转换为元
|
||
Profit: (r.Count*info.PriceDraw - r.TotalCost) / 100,
|
||
ProfitRate: 0,
|
||
ContributionRate: float64(int(contribution*10)) / 10.0,
|
||
InventoryTurnover: float64(int(turnover*10)) / 10.0,
|
||
}
|
||
if r.Count > 0 && info.PriceDraw > 0 {
|
||
revenue := r.Count * info.PriceDraw
|
||
pr := float64(revenue-r.TotalCost) / float64(revenue) * 100
|
||
out[i].ProfitRate = float64(int(pr*10)) / 10.0
|
||
}
|
||
}
|
||
|
||
ctx.Payload(out)
|
||
}
|
||
}
|
||
|
||
// 2. 积分经济总览
|
||
type pointsEconomySummaryResponse struct {
|
||
TotalIssued int64 `json:"totalIssued"`
|
||
TotalConsumed int64 `json:"totalConsumed"`
|
||
NetChange int64 `json:"netChange"`
|
||
ActiveUsersWithPoints int64 `json:"activeUsersWithPoints"`
|
||
ConversionRate float64 `json:"conversionRate"`
|
||
}
|
||
|
||
func (h *handler) OperationsPointsEconomySummary() core.HandlerFunc {
|
||
return func(ctx core.Context) {
|
||
s, e := parseRange(ctx.Request().URL.Query().Get("rangeType"), "", "")
|
||
|
||
// 发行总额 (正数积分)
|
||
var issuedNull sql.NullInt64
|
||
_ = h.readDB.UserPointsLedger.WithContext(ctx.RequestContext()).ReadDB().
|
||
Where(h.readDB.UserPointsLedger.CreatedAt.Gte(s)).
|
||
Where(h.readDB.UserPointsLedger.CreatedAt.Lte(e)).
|
||
Where(h.readDB.UserPointsLedger.Points.Gt(0)).
|
||
Select(h.readDB.UserPointsLedger.Points.Sum()).
|
||
Scan(&issuedNull)
|
||
issued := issuedNull.Int64
|
||
|
||
// 消耗总额 (负数积分)
|
||
var consumedNull sql.NullInt64
|
||
_ = h.readDB.UserPointsLedger.WithContext(ctx.RequestContext()).ReadDB().
|
||
Where(h.readDB.UserPointsLedger.CreatedAt.Gte(s)).
|
||
Where(h.readDB.UserPointsLedger.CreatedAt.Lte(e)).
|
||
Where(h.readDB.UserPointsLedger.Points.Lt(0)).
|
||
Select(h.readDB.UserPointsLedger.Points.Sum()).
|
||
Scan(&consumedNull)
|
||
consumed := -consumedNull.Int64 // 转为正数
|
||
|
||
// 持分活跃用户数
|
||
activeUsers, _ := h.readDB.UserPoints.WithContext(ctx.RequestContext()).ReadDB().
|
||
Where(h.readDB.UserPoints.Points.Gt(0)).
|
||
Count()
|
||
|
||
// 活跃持仓率
|
||
totalUsers, _ := h.readDB.Users.WithContext(ctx.RequestContext()).ReadDB().Count()
|
||
var conversionRate float64
|
||
if totalUsers > 0 {
|
||
conversionRate = float64(activeUsers) / float64(totalUsers) * 100
|
||
}
|
||
|
||
// Convert from Store Units (Cents) to Display Units (Points)
|
||
// Assuming 100 StoreUnits = 1 Point (if Rate=1)
|
||
// Because Store is Cents. 1 Point = 1 Yuan = 100 Cents.
|
||
issuedPoints := h.userSvc.CentsToPointsFloat(ctx.RequestContext(), issued)
|
||
consumedPoints := h.userSvc.CentsToPointsFloat(ctx.RequestContext(), consumed)
|
||
netChangePoints := issuedPoints - consumedPoints
|
||
|
||
ctx.Payload(pointsEconomySummaryResponse{
|
||
TotalIssued: int64(issuedPoints),
|
||
TotalConsumed: int64(consumedPoints),
|
||
NetChange: int64(netChangePoints),
|
||
ActiveUsersWithPoints: activeUsers,
|
||
ConversionRate: float64(int(conversionRate*10)) / 10.0,
|
||
})
|
||
}
|
||
}
|
||
|
||
// 3. 积分趋势
|
||
type pointsTrendItem struct {
|
||
Date string `json:"date"`
|
||
Issued int64 `json:"issued"`
|
||
Consumed int64 `json:"consumed"`
|
||
Expired int64 `json:"expired"`
|
||
NetChange int64 `json:"netChange"`
|
||
Balance int64 `json:"balance"`
|
||
}
|
||
|
||
func (h *handler) OperationsPointsTrend() core.HandlerFunc {
|
||
return func(ctx core.Context) {
|
||
s, e := parseRange(ctx.Request().URL.Query().Get("rangeType"), "", "")
|
||
buckets := buildBuckets(s, e, "day")
|
||
|
||
out := make([]pointsTrendItem, len(buckets))
|
||
var runningBalance int64
|
||
|
||
// 获取初始余额
|
||
var initBalanceNull sql.NullInt64
|
||
_ = h.readDB.UserPointsLedger.WithContext(ctx.RequestContext()).ReadDB().
|
||
Where(h.readDB.UserPointsLedger.CreatedAt.Lt(s)).
|
||
Select(h.readDB.UserPointsLedger.Points.Sum()).
|
||
Scan(&initBalanceNull)
|
||
runningBalance = initBalanceNull.Int64
|
||
|
||
for i, b := range buckets {
|
||
// 当日发行
|
||
var issuedNull sql.NullInt64
|
||
_ = h.readDB.UserPointsLedger.WithContext(ctx.RequestContext()).ReadDB().
|
||
Where(h.readDB.UserPointsLedger.CreatedAt.Gte(b.Start)).
|
||
Where(h.readDB.UserPointsLedger.CreatedAt.Lte(b.End)).
|
||
Where(h.readDB.UserPointsLedger.Points.Gt(0)).
|
||
Select(h.readDB.UserPointsLedger.Points.Sum()).
|
||
Scan(&issuedNull)
|
||
issued := issuedNull.Int64
|
||
|
||
// 当日消耗
|
||
var consumedNull sql.NullInt64
|
||
_ = h.readDB.UserPointsLedger.WithContext(ctx.RequestContext()).ReadDB().
|
||
Where(h.readDB.UserPointsLedger.CreatedAt.Gte(b.Start)).
|
||
Where(h.readDB.UserPointsLedger.CreatedAt.Lte(b.End)).
|
||
Where(h.readDB.UserPointsLedger.Points.Lt(0)).
|
||
Select(h.readDB.UserPointsLedger.Points.Sum()).
|
||
Scan(&consumedNull)
|
||
consumed := -consumedNull.Int64
|
||
|
||
netChange := issued - consumed
|
||
runningBalance += netChange
|
||
|
||
out[i] = pointsTrendItem{
|
||
Date: b.Label,
|
||
Issued: int64(h.userSvc.CentsToPointsFloat(ctx.RequestContext(), issued)),
|
||
Consumed: int64(h.userSvc.CentsToPointsFloat(ctx.RequestContext(), consumed)),
|
||
Expired: 0,
|
||
NetChange: int64(h.userSvc.CentsToPointsFloat(ctx.RequestContext(), netChange)),
|
||
Balance: int64(h.userSvc.CentsToPointsFloat(ctx.RequestContext(), runningBalance)),
|
||
}
|
||
}
|
||
|
||
ctx.Payload(out)
|
||
}
|
||
}
|
||
|
||
// 4. 积分收支结构
|
||
type pointsStructureItem struct {
|
||
Category string `json:"category"`
|
||
Amount int64 `json:"amount"`
|
||
Percentage float64 `json:"percentage"`
|
||
Trend string `json:"trend"`
|
||
}
|
||
|
||
func (h *handler) OperationsPointsStructure() core.HandlerFunc {
|
||
return func(ctx core.Context) {
|
||
s, e := parseRange(ctx.Request().URL.Query().Get("rangeType"), "", "")
|
||
|
||
// 按Action分组统计
|
||
type actionRow struct {
|
||
Action string
|
||
Total int64
|
||
}
|
||
var rows []actionRow
|
||
|
||
_ = h.readDB.UserPointsLedger.WithContext(ctx.RequestContext()).ReadDB().
|
||
Where(h.readDB.UserPointsLedger.CreatedAt.Gte(s)).
|
||
Where(h.readDB.UserPointsLedger.CreatedAt.Lte(e)).
|
||
Select(
|
||
h.readDB.UserPointsLedger.Action,
|
||
h.readDB.UserPointsLedger.Points.Sum().As("total"),
|
||
).
|
||
Group(h.readDB.UserPointsLedger.Action).
|
||
Scan(&rows)
|
||
|
||
// 计算总量
|
||
var absTotal int64
|
||
for _, r := range rows {
|
||
if r.Total > 0 {
|
||
absTotal += r.Total
|
||
} else {
|
||
absTotal += -r.Total
|
||
}
|
||
}
|
||
|
||
// Action名称映射
|
||
actionNames := map[string]string{
|
||
"signin": "签到奖励",
|
||
"order_deduct": "订单消耗",
|
||
"refund_restore": "退款返还",
|
||
"manual": "手动调整",
|
||
"task_reward": "任务奖励",
|
||
"draw_win": "抽奖中奖",
|
||
}
|
||
|
||
out := make([]pointsStructureItem, 0, len(rows))
|
||
for _, r := range rows {
|
||
name := actionNames[r.Action]
|
||
if name == "" {
|
||
name = r.Action
|
||
}
|
||
var pct float64
|
||
if absTotal > 0 {
|
||
amt := r.Total
|
||
if amt < 0 {
|
||
amt = -amt
|
||
}
|
||
pct = float64(amt) / float64(absTotal) * 100
|
||
}
|
||
out = append(out, pointsStructureItem{
|
||
Category: name,
|
||
Amount: int64(h.userSvc.CentsToPointsFloat(ctx.RequestContext(), r.Total)),
|
||
Percentage: float64(int(pct*10)) / 10.0,
|
||
Trend: "+0%",
|
||
})
|
||
}
|
||
|
||
ctx.Payload(out)
|
||
}
|
||
}
|
||
|
||
// 5. 优惠券效能排行
|
||
type couponEffectivenessItem struct {
|
||
CouponID int64 `json:"couponId"`
|
||
CouponName string `json:"couponName"`
|
||
Type string `json:"type"`
|
||
IssuedCount int64 `json:"issuedCount"`
|
||
UsedCount int64 `json:"usedCount"`
|
||
UsedRate float64 `json:"usedRate"`
|
||
BroughtOrders int64 `json:"broughtOrders"`
|
||
BroughtAmount int64 `json:"broughtAmount"`
|
||
ROI float64 `json:"roi"`
|
||
}
|
||
|
||
func (h *handler) OperationsCouponEffectiveness() core.HandlerFunc {
|
||
return func(ctx core.Context) {
|
||
s, e := parseRange(ctx.Request().URL.Query().Get("rangeType"), "", "")
|
||
|
||
// 获取所有券模板
|
||
coupons, _ := h.readDB.SystemCoupons.WithContext(ctx.RequestContext()).ReadDB().Find()
|
||
|
||
out := make([]couponEffectivenessItem, 0, len(coupons))
|
||
for _, c := range coupons {
|
||
// 发放数量
|
||
issued, _ := h.readDB.UserCoupons.WithContext(ctx.RequestContext()).ReadDB().
|
||
Where(h.readDB.UserCoupons.CouponID.Eq(c.ID)).
|
||
Where(h.readDB.UserCoupons.CreatedAt.Gte(s)).
|
||
Where(h.readDB.UserCoupons.CreatedAt.Lte(e)).
|
||
Count()
|
||
|
||
// 使用数量
|
||
used, _ := h.readDB.UserCoupons.WithContext(ctx.RequestContext()).ReadDB().
|
||
Where(h.readDB.UserCoupons.CouponID.Eq(c.ID)).
|
||
Where(h.readDB.UserCoupons.Status.Eq(2)).
|
||
Where(h.readDB.UserCoupons.UsedAt.Gte(s)).
|
||
Where(h.readDB.UserCoupons.UsedAt.Lte(e)).
|
||
Count()
|
||
|
||
// 带动订单和总价值 (实收 + 实际产生的优惠金额)
|
||
var orderStats struct {
|
||
Orders int64
|
||
ActualSum int64
|
||
DiscountSum int64
|
||
}
|
||
_ = h.readDB.Orders.WithContext(ctx.RequestContext()).UnderlyingDB().
|
||
Joins("JOIN user_coupons ON user_coupons.id = orders.coupon_id").
|
||
Where("user_coupons.coupon_id = ?", c.ID).
|
||
Where("orders.status = ?", 2).
|
||
Where("orders.paid_at >= ?", s).
|
||
Where("orders.paid_at <= ?", e).
|
||
Select("COUNT(orders.id) as orders, SUM(orders.actual_amount) as actual_sum, SUM(orders.discount_amount) as discount_sum").
|
||
Scan(&orderStats)
|
||
|
||
broughtTotalValue := orderStats.ActualSum + orderStats.DiscountSum // 总业务价值(GMV口径)
|
||
|
||
var usedRate float64
|
||
if issued > 0 {
|
||
usedRate = float64(used) / float64(issued) * 100
|
||
}
|
||
|
||
// ROI计算: 带动总价值 / 实际优惠成本
|
||
var roi float64
|
||
if orderStats.DiscountSum > 0 {
|
||
roi = float64(broughtTotalValue) / float64(orderStats.DiscountSum)
|
||
}
|
||
|
||
typeStr := "直减券"
|
||
switch c.DiscountType {
|
||
case 2:
|
||
typeStr = "满减券"
|
||
case 3:
|
||
typeStr = "折扣券"
|
||
}
|
||
|
||
out = append(out, couponEffectivenessItem{
|
||
CouponID: c.ID,
|
||
CouponName: c.Name,
|
||
Type: typeStr,
|
||
IssuedCount: issued,
|
||
UsedCount: used,
|
||
UsedRate: float64(int(usedRate*10)) / 10.0,
|
||
BroughtOrders: orderStats.Orders,
|
||
BroughtAmount: broughtTotalValue,
|
||
ROI: float64(int(roi*10)) / 10.0,
|
||
})
|
||
}
|
||
|
||
ctx.Payload(out)
|
||
}
|
||
}
|
||
|
||
// 6. 库存预警列表
|
||
type inventoryAlertItem struct {
|
||
ID int64 `json:"id"`
|
||
Name string `json:"name"`
|
||
Type string `json:"type"`
|
||
Stock int64 `json:"stock"`
|
||
Threshold int64 `json:"threshold"`
|
||
SalesSpeed float64 `json:"salesSpeed"`
|
||
}
|
||
|
||
func (h *handler) OperationsInventoryAlerts() core.HandlerFunc {
|
||
return func(ctx core.Context) {
|
||
threshold := int64(20)
|
||
out := make([]inventoryAlertItem, 0)
|
||
|
||
// 1. 奖品库存预警 (活动奖品设置表)
|
||
type rewardRow struct {
|
||
ID int64
|
||
ProductID int64
|
||
ProductName string
|
||
ActivityName string
|
||
Level int32
|
||
Quantity int64
|
||
OriginalQty int64
|
||
}
|
||
var rows []rewardRow
|
||
|
||
_ = h.readDB.ActivityRewardSettings.WithContext(ctx.RequestContext()).ReadDB().
|
||
LeftJoin(h.readDB.Products, h.readDB.Products.ID.EqCol(h.readDB.ActivityRewardSettings.ProductID)).
|
||
LeftJoin(h.readDB.ActivityIssues, h.readDB.ActivityIssues.ID.EqCol(h.readDB.ActivityRewardSettings.IssueID)).
|
||
LeftJoin(h.readDB.Activities, h.readDB.Activities.ID.EqCol(h.readDB.ActivityIssues.ActivityID)).
|
||
Where(h.readDB.ActivityRewardSettings.Quantity.Lt(threshold)).
|
||
Where(h.readDB.ActivityRewardSettings.Quantity.Gt(0)).
|
||
Select(
|
||
h.readDB.ActivityRewardSettings.ID,
|
||
h.readDB.ActivityRewardSettings.ProductID,
|
||
h.readDB.Products.Name.As("product_name"),
|
||
h.readDB.Activities.Name.As("activity_name"),
|
||
h.readDB.ActivityRewardSettings.Level,
|
||
h.readDB.ActivityRewardSettings.Quantity,
|
||
h.readDB.ActivityRewardSettings.OriginalQty,
|
||
).
|
||
Order(h.readDB.ActivityRewardSettings.Quantity).
|
||
Limit(15).
|
||
Scan(&rows)
|
||
|
||
for _, r := range rows {
|
||
consumed := r.OriginalQty - r.Quantity
|
||
speed := float64(consumed) / 30.0
|
||
|
||
// 构建显示名称
|
||
displayName := r.ProductName
|
||
if displayName == "" {
|
||
displayName = r.ActivityName + " " + strconv.Itoa(int(r.Level)) + "等奖"
|
||
}
|
||
|
||
out = append(out, inventoryAlertItem{
|
||
ID: r.ID,
|
||
Name: displayName,
|
||
Type: "physical",
|
||
Stock: r.Quantity,
|
||
Threshold: threshold,
|
||
SalesSpeed: float64(int(speed*10)) / 10.0,
|
||
})
|
||
}
|
||
|
||
// 2. 道具卡库存预警 (库存较少的道具卡)
|
||
cardThreshold := int64(50)
|
||
type cardRow struct {
|
||
ID int64
|
||
CardID int64
|
||
CardName string
|
||
Stock int64
|
||
}
|
||
var cardRows []cardRow
|
||
|
||
_ = h.readDB.UserItemCards.WithContext(ctx.RequestContext()).ReadDB().
|
||
LeftJoin(h.readDB.SystemItemCards, h.readDB.SystemItemCards.ID.EqCol(h.readDB.UserItemCards.CardID)).
|
||
Where(h.readDB.UserItemCards.Status.Eq(1)). // 未使用
|
||
Select(
|
||
h.readDB.SystemItemCards.ID,
|
||
h.readDB.UserItemCards.CardID,
|
||
h.readDB.SystemItemCards.Name.As("card_name"),
|
||
h.readDB.UserItemCards.ID.Count().As("stock"),
|
||
).
|
||
Group(h.readDB.UserItemCards.CardID).
|
||
Having(h.readDB.UserItemCards.ID.Count().Lt(int(cardThreshold))).
|
||
Scan(&cardRows)
|
||
|
||
for _, r := range cardRows {
|
||
out = append(out, inventoryAlertItem{
|
||
ID: r.CardID,
|
||
Name: r.CardName,
|
||
Type: "virtual",
|
||
Stock: r.Stock,
|
||
Threshold: cardThreshold,
|
||
SalesSpeed: 2.0, // 简化
|
||
})
|
||
}
|
||
|
||
ctx.Payload(out)
|
||
}
|
||
}
|
||
|
||
// 7. 风险事件监控
|
||
type riskEventItem struct {
|
||
UserID int64 `json:"userId"`
|
||
Nickname string `json:"nickname"`
|
||
Avatar string `json:"avatar"`
|
||
Type string `json:"type"`
|
||
Description string `json:"description"`
|
||
RiskLevel string `json:"riskLevel"`
|
||
CreatedAt string `json:"createdAt"`
|
||
}
|
||
|
||
func (h *handler) OperationsRiskEvents() core.HandlerFunc {
|
||
return func(ctx core.Context) {
|
||
now := time.Now()
|
||
last24h := now.Add(-24 * time.Hour)
|
||
|
||
type winnerRow struct {
|
||
UserID int64
|
||
WinCount int64
|
||
}
|
||
var winners []winnerRow
|
||
|
||
_ = h.readDB.ActivityDrawLogs.WithContext(ctx.RequestContext()).ReadDB().
|
||
Where(h.readDB.ActivityDrawLogs.IsWinner.Eq(1)).
|
||
Where(h.readDB.ActivityDrawLogs.CreatedAt.Gte(last24h)).
|
||
Select(
|
||
h.readDB.ActivityDrawLogs.UserID,
|
||
h.readDB.ActivityDrawLogs.ID.Count().As("win_count"),
|
||
).
|
||
Group(h.readDB.ActivityDrawLogs.UserID).
|
||
Having(h.readDB.ActivityDrawLogs.ID.Count().Gte(3)).
|
||
Scan(&winners)
|
||
|
||
userIDs := make([]int64, len(winners))
|
||
for i, w := range winners {
|
||
userIDs[i] = w.UserID
|
||
}
|
||
|
||
userMap := make(map[int64]struct {
|
||
Nickname string
|
||
Avatar string
|
||
})
|
||
if len(userIDs) > 0 {
|
||
users, _ := h.readDB.Users.WithContext(ctx.RequestContext()).ReadDB().
|
||
Where(h.readDB.Users.ID.In(userIDs...)).Find()
|
||
for _, u := range users {
|
||
userMap[u.ID] = struct {
|
||
Nickname string
|
||
Avatar string
|
||
}{Nickname: u.Nickname, Avatar: u.Avatar}
|
||
}
|
||
}
|
||
|
||
out := make([]riskEventItem, 0, len(winners))
|
||
for _, w := range winners {
|
||
u := userMap[w.UserID]
|
||
level := "medium"
|
||
if w.WinCount >= 5 {
|
||
level = "high"
|
||
}
|
||
out = append(out, riskEventItem{
|
||
UserID: w.UserID,
|
||
Nickname: u.Nickname,
|
||
Avatar: u.Avatar,
|
||
Type: "frequent_win",
|
||
Description: "24小时内中奖" + strconv.FormatInt(w.WinCount, 10) + "次",
|
||
RiskLevel: level,
|
||
CreatedAt: now.Format("15:04"),
|
||
})
|
||
}
|
||
|
||
ctx.Payload(out)
|
||
}
|
||
}
|
||
|
||
// 8. 实时中奖播报
|
||
type liveWinnerItem struct {
|
||
ID int64 `json:"id"`
|
||
Nickname string `json:"nickname"`
|
||
Avatar string `json:"avatar"`
|
||
IssueName string `json:"issueName"`
|
||
PrizeName string `json:"prizeName"`
|
||
IsBigWin bool `json:"isBigWin"`
|
||
Time string `json:"time"`
|
||
}
|
||
|
||
type liveWinnersResponse struct {
|
||
List []liveWinnerItem `json:"list"`
|
||
Stats struct {
|
||
HourlyWinRate float64 `json:"hourlyWinRate"`
|
||
DrawsPerMinute int64 `json:"drawsPerMinute"`
|
||
} `json:"stats"`
|
||
}
|
||
|
||
func (h *handler) DashboardLiveWinners() core.HandlerFunc {
|
||
return func(ctx core.Context) {
|
||
limitS := ctx.Request().URL.Query().Get("limit")
|
||
limit := 20
|
||
if l, err := strconv.Atoi(limitS); err == nil && l > 0 && l <= 50 {
|
||
limit = l
|
||
}
|
||
|
||
type winRow struct {
|
||
ID int64
|
||
UserID int64
|
||
Nickname string
|
||
Avatar string
|
||
IssueName string
|
||
PrizeName string
|
||
Level int32
|
||
CreatedAt time.Time
|
||
}
|
||
var rows []winRow
|
||
|
||
_ = h.readDB.ActivityDrawLogs.WithContext(ctx.RequestContext()).ReadDB().
|
||
LeftJoin(h.readDB.Users, h.readDB.Users.ID.EqCol(h.readDB.ActivityDrawLogs.UserID)).
|
||
LeftJoin(h.readDB.ActivityIssues, h.readDB.ActivityIssues.ID.EqCol(h.readDB.ActivityDrawLogs.IssueID)).
|
||
LeftJoin(h.readDB.ActivityRewardSettings, h.readDB.ActivityRewardSettings.ID.EqCol(h.readDB.ActivityDrawLogs.RewardID)).
|
||
LeftJoin(h.readDB.Products, h.readDB.Products.ID.EqCol(h.readDB.ActivityRewardSettings.ProductID)).
|
||
Where(h.readDB.ActivityDrawLogs.IsWinner.Eq(1)).
|
||
Order(h.readDB.ActivityDrawLogs.ID.Desc()).
|
||
Limit(limit).
|
||
Select(
|
||
h.readDB.ActivityDrawLogs.ID,
|
||
h.readDB.ActivityDrawLogs.UserID,
|
||
h.readDB.Users.Nickname,
|
||
h.readDB.Users.Avatar,
|
||
h.readDB.ActivityIssues.IssueNumber.As("issue_name"),
|
||
h.readDB.Products.Name.As("prize_name"),
|
||
h.readDB.ActivityRewardSettings.Level,
|
||
h.readDB.ActivityDrawLogs.CreatedAt,
|
||
).
|
||
Scan(&rows)
|
||
|
||
now := time.Now()
|
||
out := make([]liveWinnerItem, len(rows))
|
||
for i, r := range rows {
|
||
diff := now.Sub(r.CreatedAt)
|
||
var timeStr string
|
||
if diff < time.Minute {
|
||
timeStr = "刚刚"
|
||
} else if diff < time.Hour {
|
||
timeStr = strconv.Itoa(int(diff.Minutes())) + "分钟前"
|
||
} else {
|
||
timeStr = strconv.Itoa(int(diff.Hours())) + "小时前"
|
||
}
|
||
|
||
out[i] = liveWinnerItem{
|
||
ID: r.ID,
|
||
Nickname: r.Nickname,
|
||
Avatar: r.Avatar,
|
||
IssueName: r.IssueName,
|
||
PrizeName: r.PrizeName,
|
||
IsBigWin: r.Level <= 2,
|
||
Time: timeStr,
|
||
}
|
||
}
|
||
|
||
lastHour := now.Add(-time.Hour)
|
||
hourDraws, _ := h.readDB.ActivityDrawLogs.WithContext(ctx.RequestContext()).ReadDB().
|
||
Where(h.readDB.ActivityDrawLogs.CreatedAt.Gte(lastHour)).
|
||
Count()
|
||
hourWins, _ := h.readDB.ActivityDrawLogs.WithContext(ctx.RequestContext()).ReadDB().
|
||
Where(h.readDB.ActivityDrawLogs.CreatedAt.Gte(lastHour)).
|
||
Where(h.readDB.ActivityDrawLogs.IsWinner.Eq(1)).
|
||
Count()
|
||
|
||
var winRate float64
|
||
if hourDraws > 0 {
|
||
winRate = float64(hourWins) / float64(hourDraws) * 100
|
||
}
|
||
|
||
rsp := liveWinnersResponse{}
|
||
rsp.List = out
|
||
rsp.Stats.HourlyWinRate = float64(int(winRate*10)) / 10.0
|
||
rsp.Stats.DrawsPerMinute = hourDraws / 60
|
||
|
||
ctx.Payload(rsp)
|
||
}
|
||
}
|
||
|
||
// =====================================================================
|
||
// 补充接口:订单趋势、活动统计、道具卡销售
|
||
// =====================================================================
|
||
|
||
// 订单转化趋势
|
||
type orderTrendItem struct {
|
||
Date string `json:"date"`
|
||
Visitors int64 `json:"visitors"`
|
||
Orders int64 `json:"orders"`
|
||
Payments int64 `json:"payments"`
|
||
Completions int64 `json:"completions"`
|
||
ConversionRate float64 `json:"conversionRate"`
|
||
}
|
||
|
||
func (h *handler) DashboardOrderTrend() core.HandlerFunc {
|
||
return func(ctx core.Context) {
|
||
s, e := parseRange(ctx.Request().URL.Query().Get("rangeType"), "", "")
|
||
buckets := buildBuckets(s, e, "day")
|
||
|
||
out := make([]orderTrendItem, len(buckets))
|
||
for i, b := range buckets {
|
||
// 访问数 (请求日志)
|
||
visitors, _ := h.readDB.LogRequest.WithContext(ctx.RequestContext()).ReadDB().
|
||
Where(h.readDB.LogRequest.CreatedAt.Gte(b.Start)).
|
||
Where(h.readDB.LogRequest.CreatedAt.Lte(b.End)).
|
||
Where(h.readDB.LogRequest.Path.Like("/api/app/%")).
|
||
Count()
|
||
|
||
// 下单数
|
||
orders, _ := h.readDB.Orders.WithContext(ctx.RequestContext()).ReadDB().
|
||
Where(h.readDB.Orders.CreatedAt.Gte(b.Start)).
|
||
Where(h.readDB.Orders.CreatedAt.Lte(b.End)).
|
||
Count()
|
||
|
||
// 支付数
|
||
payments, _ := h.readDB.Orders.WithContext(ctx.RequestContext()).ReadDB().
|
||
Where(h.readDB.Orders.Status.Eq(2)).
|
||
Where(h.readDB.Orders.PaidAt.Gte(b.Start)).
|
||
Where(h.readDB.Orders.PaidAt.Lte(b.End)).
|
||
Count()
|
||
|
||
// 完成数 (已消费 + 已发货)
|
||
consumed, _ := h.readDB.Orders.WithContext(ctx.RequestContext()).ReadDB().
|
||
Where(h.readDB.Orders.Status.Eq(2), h.readDB.Orders.IsConsumed.Eq(1)).
|
||
Where(h.readDB.Orders.UpdatedAt.Gte(b.Start)).
|
||
Where(h.readDB.Orders.UpdatedAt.Lte(b.End)).
|
||
Count()
|
||
|
||
var rate float64
|
||
if visitors > 0 {
|
||
rate = float64(consumed) / float64(visitors) * 100
|
||
}
|
||
|
||
out[i] = orderTrendItem{
|
||
Date: b.Label,
|
||
Visitors: visitors,
|
||
Orders: orders,
|
||
Payments: payments,
|
||
Completions: consumed,
|
||
ConversionRate: float64(int(rate*100)) / 100.0,
|
||
}
|
||
}
|
||
|
||
ctx.Payload(out)
|
||
}
|
||
}
|
||
|
||
// 活动抽奖统计
|
||
type activityStatsResponse struct {
|
||
TotalActivities int64 `json:"totalActivities"`
|
||
TotalParticipants int64 `json:"totalParticipants"`
|
||
TotalDraws int64 `json:"totalDraws"`
|
||
WinnerCount int64 `json:"winnerCount"`
|
||
OverallWinRate float64 `json:"overallWinRate"`
|
||
CostControl float64 `json:"costControl"`
|
||
}
|
||
|
||
func (h *handler) DashboardActivityStats() core.HandlerFunc {
|
||
return func(ctx core.Context) {
|
||
s, e := parseRange(ctx.Request().URL.Query().Get("rangeType"), "", "")
|
||
|
||
// 活动总数
|
||
totalActivities, _ := h.readDB.Activities.WithContext(ctx.RequestContext()).ReadDB().Count()
|
||
|
||
// 总抽奖次数
|
||
totalDraws, _ := h.readDB.ActivityDrawLogs.WithContext(ctx.RequestContext()).ReadDB().
|
||
Where(h.readDB.ActivityDrawLogs.CreatedAt.Gte(s)).
|
||
Where(h.readDB.ActivityDrawLogs.CreatedAt.Lte(e)).
|
||
Count()
|
||
|
||
// 中奖数
|
||
winnerCount, _ := h.readDB.ActivityDrawLogs.WithContext(ctx.RequestContext()).ReadDB().
|
||
Where(h.readDB.ActivityDrawLogs.CreatedAt.Gte(s)).
|
||
Where(h.readDB.ActivityDrawLogs.CreatedAt.Lte(e)).
|
||
Where(h.readDB.ActivityDrawLogs.IsWinner.Eq(1)).
|
||
Count()
|
||
|
||
// 参与人数
|
||
totalParticipants, _ := h.readDB.ActivityDrawLogs.WithContext(ctx.RequestContext()).ReadDB().
|
||
Where(h.readDB.ActivityDrawLogs.CreatedAt.Gte(s)).
|
||
Where(h.readDB.ActivityDrawLogs.CreatedAt.Lte(e)).
|
||
Distinct(h.readDB.ActivityDrawLogs.UserID).
|
||
Count()
|
||
|
||
var winRate float64
|
||
if totalDraws > 0 {
|
||
winRate = float64(winnerCount) / float64(totalDraws) * 100
|
||
}
|
||
|
||
ctx.Payload(activityStatsResponse{
|
||
TotalActivities: totalActivities,
|
||
TotalParticipants: totalParticipants,
|
||
TotalDraws: totalDraws,
|
||
WinnerCount: winnerCount,
|
||
OverallWinRate: float64(int(winRate*100)) / 100.0,
|
||
CostControl: 85.0, // 简化处理
|
||
})
|
||
}
|
||
}
|
||
|
||
// 道具卡销售数据
|
||
type itemCardSalesItem struct {
|
||
CardID int64 `json:"cardId"`
|
||
CardName string `json:"cardName"`
|
||
CardType string `json:"cardType"`
|
||
SalesCount int64 `json:"salesCount"`
|
||
SalesAmount int64 `json:"salesAmount"`
|
||
UsageRate float64 `json:"usageRate"`
|
||
AvgEffect float64 `json:"avgEffect"`
|
||
}
|
||
|
||
func (h *handler) DashboardItemCardSales() core.HandlerFunc {
|
||
return func(ctx core.Context) {
|
||
s, e := parseRange(ctx.Request().URL.Query().Get("rangeType"), "", "")
|
||
|
||
// 获取道具卡列表
|
||
cards, _ := h.readDB.SystemItemCards.WithContext(ctx.RequestContext()).ReadDB().Find()
|
||
|
||
out := make([]itemCardSalesItem, 0, len(cards))
|
||
for _, c := range cards {
|
||
// 发放数量
|
||
issued, _ := h.readDB.UserItemCards.WithContext(ctx.RequestContext()).ReadDB().
|
||
Where(h.readDB.UserItemCards.CardID.Eq(c.ID)).
|
||
Where(h.readDB.UserItemCards.CreatedAt.Gte(s)).
|
||
Where(h.readDB.UserItemCards.CreatedAt.Lte(e)).
|
||
Count()
|
||
|
||
// 使用数量 (status=2表示已使用)
|
||
used, _ := h.readDB.UserItemCards.WithContext(ctx.RequestContext()).ReadDB().
|
||
Where(h.readDB.UserItemCards.CardID.Eq(c.ID)).
|
||
Where(h.readDB.UserItemCards.Status.Eq(2)).
|
||
Where(h.readDB.UserItemCards.UsedAt.Gte(s)).
|
||
Where(h.readDB.UserItemCards.UsedAt.Lte(e)).
|
||
Count()
|
||
|
||
var usageRate float64
|
||
if issued > 0 {
|
||
usageRate = float64(used) / float64(issued) * 100
|
||
}
|
||
|
||
// 卡类型映射
|
||
cardTypeMap := map[int32]string{1: "双倍奖励", 2: "概率提升", 3: "保护卡"}
|
||
cardTypeName := cardTypeMap[c.CardType]
|
||
if cardTypeName == "" {
|
||
cardTypeName = "其他"
|
||
}
|
||
|
||
out = append(out, itemCardSalesItem{
|
||
CardID: c.ID,
|
||
CardName: c.Name,
|
||
CardType: cardTypeName,
|
||
SalesCount: issued,
|
||
SalesAmount: issued * c.Price / 100,
|
||
UsageRate: float64(int(usageRate*10)) / 10.0,
|
||
AvgEffect: 10.0,
|
||
})
|
||
}
|
||
|
||
ctx.Payload(out)
|
||
}
|
||
}
|