Some checks failed
Build docker and publish / linux (1.24.5) (push) Failing after 39s
feat(抽奖动态): 修复抽奖动态未渲染问题并优化文案展示 fix(用户概览): 修复用户概览无数据显示问题 feat(新用户列表): 在新用户列表显示称号明细 refactor(待办事项): 移除代办模块并全宽展示实时动态 feat(批量操作): 限制为单用户操作并在批量时提醒 fix(称号分配): 防重复分配称号的改造计划 perf(接口性能): 优化新用户和抽奖动态接口性能 feat(订单漏斗): 优化订单转化漏斗指标计算 docs(测试计划): 完善盲盒运营API核查与闭环测试计划
861 lines
38 KiB
Go
861 lines
38 KiB
Go
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 }
|
||
|
||
rows, err := h.readDB.UserPoints.WithContext(ctx.RequestContext()).ReadDB().Find()
|
||
if err != nil { ctx.AbortWithError(core.Error(http.StatusBadRequest, 21007, err.Error())); return }
|
||
var tpCur int64
|
||
now := time.Now()
|
||
for _, r := range rows {
|
||
if !r.ValidEnd.IsZero() && r.ValidEnd.Before(now) {
|
||
continue
|
||
}
|
||
tpCur += r.Points
|
||
}
|
||
// 使用积分流水计算净变动用于环比
|
||
// 当前窗口净变动
|
||
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
|
||
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.Name.As("prize_name"),
|
||
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 }
|
||
list := make([]drawStreamItem, len(rows))
|
||
var maxID int64
|
||
for i, v := range rows {
|
||
iname := v.ActivityName
|
||
if iname == "" { iname = v.IssueName }
|
||
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: v.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 "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"
|
||
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
|
||
}
|
||
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"`
|
||
Probability float64 `json:"probability"`
|
||
ActualProbability float64 `json:"actualProbability"`
|
||
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"`
|
||
}
|
||
|
||
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 }
|
||
var aid int64
|
||
if v, err := strconv.ParseInt(aidS, 10, 64); err == nil { aid = v } else { ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, "活动ID格式错误")); return }
|
||
issues, _ := h.readDB.ActivityIssues.WithContext(ctx.RequestContext()).ReadDB().Where(h.readDB.ActivityIssues.ActivityID.Eq(aid)).Find()
|
||
ids := make([]int64, len(issues))
|
||
for i, v := range issues { ids[i] = v.ID }
|
||
rsAll, _ := h.readDB.ActivityRewardSettings.WithContext(ctx.RequestContext()).ReadDB().Where(h.readDB.ActivityRewardSettings.IssueID.In(ids...)).Find()
|
||
var sumWeight int64
|
||
for _, r := range rsAll { sumWeight += int64(r.Weight) }
|
||
prizes := make([]activityPrize, len(rsAll))
|
||
var totalCost int64
|
||
var winRates []float64
|
||
var totalDraws int64
|
||
var participants int64
|
||
for i, r := range rsAll {
|
||
dc, _ := h.readDB.ActivityDrawLogs.WithContext(ctx.RequestContext()).ReadDB().Where(h.readDB.ActivityDrawLogs.IssueID.Eq(r.IssueID)).Count()
|
||
wc, _ := h.readDB.ActivityDrawLogs.WithContext(ctx.RequestContext()).ReadDB().Where(h.readDB.ActivityDrawLogs.IssueID.Eq(r.IssueID), h.readDB.ActivityDrawLogs.RewardID.Eq(r.ID), h.readDB.ActivityDrawLogs.IsWinner.Eq(1)).Count()
|
||
var price int64
|
||
if r.ProductID != 0 {
|
||
p, _ := h.readDB.Products.WithContext(ctx.RequestContext()).ReadDB().Where(h.readDB.Products.ID.Eq(r.ProductID)).First()
|
||
if p != nil { price = p.Price }
|
||
}
|
||
issued := r.OriginalQty - r.Quantity
|
||
var prob float64
|
||
if sumWeight > 0 { prob = float64(r.Weight) / float64(sumWeight) * 100 }
|
||
var actual float64
|
||
if dc > 0 { actual = float64(wc) / float64(dc) * 100 }
|
||
prizes[i] = activityPrize{PrizeID: r.ID, PrizeName: r.Name, PrizeLevel: r.Level, PrizeType: func() string { if r.ProductID != 0 { return "实物奖品" } ; return "虚拟/道具" }(), PrizeValue: price, TotalQuantity: r.OriginalQty, IssuedQuantity: issued, DrawCount: dc, WinCount: wc, WinRate: func() float64 { if dc > 0 { return float64(wc) / float64(dc) * 100 } ; return 0 }(), Probability: prob, ActualProbability: actual, Cost: price }
|
||
totalCost += price * wc
|
||
winRates = append(winRates, prizes[i].WinRate)
|
||
totalDraws += dc
|
||
}
|
||
participants, _ = h.readDB.ActivityDrawLogs.WithContext(ctx.RequestContext()).ReadDB().Where(h.readDB.ActivityDrawLogs.IssueID.In(ids...)).Distinct(h.readDB.ActivityDrawLogs.UserID).Count()
|
||
act, _ := h.readDB.Activities.WithContext(ctx.RequestContext()).ReadDB().Where(h.readDB.Activities.ID.Eq(aid)).First()
|
||
status := "active"
|
||
if act != nil && act.EndTime.Before(time.Now()) { status = "ended" }
|
||
var avg float64
|
||
if len(winRates) > 0 { var sum float64; for _, w := range winRates { sum += w } ; avg = sum / float64(len(winRates)) }
|
||
var maxW, minW float64
|
||
if len(winRates) > 0 { maxW = winRates[0]; minW = winRates[0]; for _, w := range winRates { if w > maxW { maxW = w } ; if w < minW { minW = w } } }
|
||
out := activityPrizeAnalysis{}
|
||
if act != nil {
|
||
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: totalDraws, TotalParticipants: participants}
|
||
}
|
||
out.Prizes = prizes
|
||
out.Summary.TotalCost = totalCost
|
||
out.Summary.AvgWinRate = avg
|
||
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)
|
||
}
|
||
} |