bindbox-game/internal/api/admin/dashboard_admin.go

1193 lines
38 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters

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

package admin
import (
"net/http"
"strconv"
"time"
"bindbox-game/internal/code"
"bindbox-game/internal/pkg/core"
"bindbox-game/internal/pkg/validation"
)
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 int64 `json:"totalPoints"`
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.Eq(time.Time{})).
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
}
rsp.ItemCardSales = icCur
rsp.DrawCount = dlCur
rsp.NewUsers = nuCur
rsp.TotalPoints = tpCur
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"`
}
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) > 31*24*time.Hour {
et = st.Add(30 * 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"))
visitors, _ := h.readDB.LogRequest.WithContext(ctx.RequestContext()).ReadDB().
Where(h.readDB.LogRequest.CreatedAt.Gte(s)).
Where(h.readDB.LogRequest.CreatedAt.Lte(e)).
Where(h.readDB.LogRequest.Path.Like("/api/app/%")).
Count()
orders, _ := h.readDB.Orders.WithContext(ctx.RequestContext()).ReadDB().
Where(h.readDB.Orders.CreatedAt.Gte(s)).
Where(h.readDB.Orders.CreatedAt.Lte(e)).
Count()
payments, _ := h.readDB.Orders.WithContext(ctx.RequestContext()).ReadDB().
Where(h.readDB.Orders.Status.Eq(2)).
Where(h.readDB.Orders.PaidAt.Gte(s)).
Where(h.readDB.Orders.PaidAt.Lte(e)).
Count()
shipped, _ := h.readDB.ShippingRecords.WithContext(ctx.RequestContext()).ReadDB().
Where(h.readDB.ShippingRecords.Status.In(2, 3)).
Where(h.readDB.ShippingRecords.UpdatedAt.Gte(s)).
Where(h.readDB.ShippingRecords.UpdatedAt.Lte(e)).
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(s)).
Where(h.readDB.Orders.UpdatedAt.Lte(e)).
Count()
completions := shipped + consumed
stages := []struct {
name string
val int64
}{
{"访问用户", visitors}, {"下单用户", orders}, {"支付用户", payments}, {"完成订单", completions},
}
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().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().
Select(h.readDB.ActivityDrawLogs.IssueID.As("key"), h.readDB.ActivityDrawLogs.ID.Count().As("count")).
Where(h.readDB.ActivityDrawLogs.IssueID.In(issueIDs...)).
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().
Select(h.readDB.ActivityDrawLogs.RewardID.As("key"), h.readDB.ActivityDrawLogs.ID.Count().As("count")).
Where(h.readDB.ActivityDrawLogs.IssueID.In(issueIDs...)).
Where(h.readDB.ActivityDrawLogs.IsWinner.Eq(1)).
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().
Where(h.readDB.ActivityDrawLogs.IssueID.In(issueIDs...)).
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)
}
}