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

414 lines
13 KiB
Go
Raw 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 (
"bindbox-game/internal/code"
"bindbox-game/internal/pkg/core"
"bindbox-game/internal/pkg/validation"
"bindbox-game/internal/repository/mysql/model"
"encoding/json"
"fmt"
"net/http"
"strconv"
"time"
)
type activityProfitLossRequest struct {
Page int `form:"page"`
PageSize int `form:"page_size"`
Name string `form:"name"`
Status int32 `form:"status"` // 1进行中 2下线
}
type activityProfitLossItem struct {
ActivityID int64 `json:"activity_id"`
ActivityName string `json:"activity_name"`
Status int32 `json:"status"`
DrawCount int64 `json:"draw_count"`
PlayerCount int64 `json:"player_count"`
TotalRevenue int64 `json:"total_revenue"` // 实际支付金额 (分)
TotalCost int64 `json:"total_cost"` // 奖品标价总和 (分)
Profit int64 `json:"profit"` // Revenue - Cost
ProfitRate float64 `json:"profit_rate"` // Profit / Revenue
}
type activityProfitLossResponse struct {
Page int `json:"page"`
PageSize int `json:"page_size"`
Total int64 `json:"total"`
List []activityProfitLossItem `json:"list"`
}
func (h *handler) DashboardActivityProfitLoss() core.HandlerFunc {
return func(ctx core.Context) {
req := new(activityProfitLossRequest)
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
}
db := h.repo.GetDbR().WithContext(ctx.RequestContext())
// 1. 获取活动列表基础信息
var activities []model.Activities
query := db.Table(model.TableNameActivities)
if req.Name != "" {
query = query.Where("name LIKE ?", "%"+req.Name+"%")
}
if req.Status > 0 {
query = query.Where("status = ?", req.Status)
}
var total int64
query.Count(&total)
if err := query.Offset((req.Page - 1) * req.PageSize).Limit(req.PageSize).Order("id DESC").Find(&activities).Error; err != nil {
h.logger.Error(fmt.Sprintf("GetActivityProfitLoss activities error: %v", err))
ctx.AbortWithError(core.Error(http.StatusInternalServerError, 21021, err.Error()))
return
}
if len(activities) == 0 {
ctx.Payload(&activityProfitLossResponse{
Page: req.Page,
PageSize: req.PageSize,
Total: total,
List: []activityProfitLossItem{},
})
return
}
activityIDs := make([]int64, len(activities))
activityMap := make(map[int64]*activityProfitLossItem)
for i, a := range activities {
activityIDs[i] = a.ID
activityMap[a.ID] = &activityProfitLossItem{
ActivityID: a.ID,
ActivityName: a.Name,
Status: a.Status,
}
}
// 2. 统计抽奖次数和人数 (通过 activity_draw_logs 关联 activity_issues)
type drawStat struct {
ActivityID int64
DrawCount int64
PlayerCount int64
}
var drawStats []drawStat
db.Table(model.TableNameActivityDrawLogs).
Select("activity_issues.activity_id, COUNT(activity_draw_logs.id) as draw_count, COUNT(DISTINCT activity_draw_logs.user_id) as player_count").
Joins("JOIN activity_issues ON activity_issues.id = activity_draw_logs.issue_id").
Where("activity_issues.activity_id IN ?", activityIDs).
Group("activity_issues.activity_id").
Scan(&drawStats)
for _, s := range drawStats {
if item, ok := activityMap[s.ActivityID]; ok {
item.DrawCount = s.DrawCount
item.PlayerCount = s.PlayerCount
}
}
// 3. 统计营收 (通过 orders 关联 activity_draw_logs)
type revenueStat struct {
ActivityID int64
TotalRevenue int64
}
var revenueStats []revenueStat
// 修正: 先找到每个订单对应的一个 activity_id (去重),再关联 orders 统计 actual_amount。
// 避免一个订单包含多个 draw logs 时导致 orders.actual_amount 被重复累加。
// 子查询: SELECT order_id, MAX(issue_id) as issue_id FROM activity_draw_logs GROUP BY order_id
// 然后通过 issue_id 关联 activity_issues 找到 activity_id
var err error
err = db.Table(model.TableNameOrders).
Select("activity_issues.activity_id, SUM(orders.actual_amount) as total_revenue").
Joins("JOIN (SELECT order_id, MAX(issue_id) as issue_id FROM activity_draw_logs GROUP BY order_id) dl ON dl.order_id = orders.id").
Joins("JOIN activity_issues ON activity_issues.id = dl.issue_id").
Where("orders.status = ?", 2). // 已支付
Where("activity_issues.activity_id IN ?", activityIDs).
Group("activity_issues.activity_id").
Scan(&revenueStats).Error
if err != nil {
h.logger.Error(fmt.Sprintf("GetActivityProfitLoss revenue stats error: %v", err))
}
for _, s := range revenueStats {
if item, ok := activityMap[s.ActivityID]; ok {
item.TotalRevenue = s.TotalRevenue
}
}
// 4. 统计成本 (通过 user_inventory 关联 products)
type costStat struct {
ActivityID int64
TotalCost int64
}
var costStats []costStat
db.Table(model.TableNameUserInventory).
Select("user_inventory.activity_id, SUM(products.price) as total_cost").
Joins("JOIN products ON products.id = user_inventory.product_id").
Where("user_inventory.activity_id IN ?", activityIDs).
Group("user_inventory.activity_id").
Scan(&costStats)
for _, s := range costStats {
if item, ok := activityMap[s.ActivityID]; ok {
item.TotalCost = s.TotalCost
}
}
// 5. 计算盈亏和比率
finalList := make([]activityProfitLossItem, 0, len(activities))
for _, a := range activities {
item := activityMap[a.ID]
item.Profit = item.TotalRevenue - item.TotalCost
if item.TotalRevenue > 0 {
item.ProfitRate = float64(item.Profit) / float64(item.TotalRevenue)
}
finalList = append(finalList, *item)
}
ctx.Payload(&activityProfitLossResponse{
Page: req.Page,
PageSize: req.PageSize,
Total: total,
List: finalList,
})
}
}
type activityLogsRequest struct {
Page int `form:"page"`
PageSize int `form:"page_size"`
}
type activityLogItem struct {
ID int64 `json:"id"`
UserID int64 `json:"user_id"`
Nickname string `json:"nickname"`
Avatar string `json:"avatar"`
ProductID int64 `json:"product_id"`
ProductName string `json:"product_name"`
ProductImage string `json:"product_image"`
ProductPrice int64 `json:"product_price"`
OrderAmount int64 `json:"order_amount"`
DiscountAmount int64 `json:"discount_amount"` // New: 优惠金额
PayType string `json:"pay_type"` // New: 支付方式/类型 (现金/道具卡/次数卡)
UsedCard string `json:"used_card"` // New: 使用的卡券名称
Profit int64 `json:"profit"`
CreatedAt time.Time `json:"created_at"`
}
type activityLogsResponse struct {
Page int `json:"page"`
PageSize int `json:"page_size"`
Total int64 `json:"total"`
List []activityLogItem `json:"list"`
}
func (h *handler) DashboardActivityLogs() core.HandlerFunc {
return func(ctx core.Context) {
activityID, _ := strconv.ParseInt(ctx.Param("activity_id"), 10, 64)
if activityID <= 0 {
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, "Invalid activity ID"))
return
}
req := new(activityLogsRequest)
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
}
db := h.repo.GetDbR().WithContext(ctx.RequestContext())
var total int64
db.Table(model.TableNameActivityDrawLogs).
Joins("JOIN activity_issues ON activity_issues.id = activity_draw_logs.issue_id").
Where("activity_issues.activity_id = ?", activityID).
Count(&total)
var logs []struct {
ID int64
UserID int64
Nickname string
Avatar string
ProductID int64
ProductName string
ImagesJSON string
ProductPrice int64
OrderAmount int64
DiscountAmount int64
SourceType int32
CouponName string
ItemCardName string
CreatedAt time.Time
}
err := db.Table(model.TableNameActivityDrawLogs).
Select(`
activity_draw_logs.id,
activity_draw_logs.user_id,
COALESCE(users.nickname, '') as nickname,
COALESCE(users.avatar, '') as avatar,
activity_reward_settings.product_id,
COALESCE(products.name, '') as product_name,
COALESCE(products.images_json, '[]') as images_json,
COALESCE(products.price, 0) as product_price,
COALESCE(orders.actual_amount, 0) as order_amount,
COALESCE(orders.discount_amount, 0) as discount_amount,
orders.source_type,
COALESCE(system_coupons.name, '') as coupon_name,
COALESCE(system_item_cards.name, '') as item_card_name,
activity_draw_logs.created_at
`).
Joins("JOIN activity_issues ON activity_issues.id = activity_draw_logs.issue_id").
Joins("LEFT JOIN users ON users.id = activity_draw_logs.user_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 system_coupons ON system_coupons.id = orders.coupon_id").
Joins("LEFT JOIN system_item_cards ON system_item_cards.id = orders.item_card_id").
Where("activity_issues.activity_id = ?", activityID).
Order("activity_draw_logs.id DESC").
Offset((req.Page - 1) * req.PageSize).
Limit(req.PageSize).
Scan(&logs).Error
if err != nil {
h.logger.Error(fmt.Sprintf("GetActivityLogs error: %v", err))
ctx.AbortWithError(core.Error(http.StatusInternalServerError, 21022, err.Error()))
return
}
list := make([]activityLogItem, len(logs))
for i, l := range logs {
var images []string
_ = json.Unmarshal([]byte(l.ImagesJSON), &images)
productImage := ""
if len(images) > 0 {
productImage = images[0]
}
// Determine PayType and UsedCard
payType := "现金支付"
usedCard := ""
if l.SourceType == 2 { // Order SourceType 2 = Ticket/Count Card
payType = "次数卡"
}
if l.ItemCardName != "" {
usedCard = l.ItemCardName
if payType == "现金支付" {
payType = "道具卡" // Override if item card is explicitly present
}
} else if l.CouponName != "" {
usedCard = l.CouponName
payType = "优惠券"
}
list[i] = activityLogItem{
ID: l.ID,
UserID: l.UserID,
Nickname: l.Nickname,
Avatar: l.Avatar,
ProductID: l.ProductID,
ProductName: l.ProductName,
ProductImage: productImage,
ProductPrice: l.ProductPrice,
OrderAmount: l.OrderAmount,
DiscountAmount: l.DiscountAmount,
PayType: payType,
UsedCard: usedCard,
Profit: l.OrderAmount - l.ProductPrice,
CreatedAt: l.CreatedAt,
}
}
ctx.Payload(&activityLogsResponse{
Page: req.Page,
PageSize: req.PageSize,
Total: total,
List: list,
})
}
}
type ensureActivityProfitLossMenuResponse struct {
Ensured bool `json:"ensured"`
Parent int64 `json:"parent_id"`
MenuID int64 `json:"menu_id"`
}
// EnsureActivityProfitLossMenu 确保运营分析下存在“活动盈亏”菜单
func (h *handler) EnsureActivityProfitLossMenu() core.HandlerFunc {
return func(ctx core.Context) {
// 1. 查找是否存在“控制台”或者“运营中心”类的父菜单
// 很多系统会将概览放在 Dashboard 下。根据 titles_seed.go运营是 Operations。
parent, _ := h.readDB.Menus.WithContext(ctx.RequestContext()).Where(h.readDB.Menus.Name.Eq("Operations")).First()
var parentID int64
if parent == nil {
// 如果没有 Operations尝试查找 Dashboard
parent, _ = h.readDB.Menus.WithContext(ctx.RequestContext()).Where(h.readDB.Menus.Name.Eq("Dashboard")).First()
}
if parent != nil {
parentID = parent.ID
}
// 2. 查找活动盈亏菜单
// 路径指向控制台并带上查参数
menuPath := "/dashboard/console?tab=activity-profit"
exists, _ := h.readDB.Menus.WithContext(ctx.RequestContext()).Where(h.readDB.Menus.Path.Eq(menuPath)).First()
if exists != nil {
ctx.Payload(&ensureActivityProfitLossMenuResponse{Ensured: true, Parent: parentID, MenuID: exists.ID})
return
}
// 3. 创建菜单
newMenu := &model.Menus{
ParentID: parentID,
Path: menuPath,
Name: "活动盈亏",
Component: "/dashboard/console/index",
Icon: "ri:pie-chart-2-fill",
Sort: 60, // 排序在称号之后
Status: true,
KeepAlive: true,
IsHide: false,
IsHideTab: false,
CreatedUser: "system",
UpdatedUser: "system",
}
if err := h.writeDB.Menus.WithContext(ctx.RequestContext()).Create(newMenu); err != nil {
ctx.AbortWithError(core.Error(http.StatusInternalServerError, 21023, "创建菜单失败: "+err.Error()))
return
}
// 读取新创建的 ID
created, _ := h.readDB.Menus.WithContext(ctx.RequestContext()).Where(h.readDB.Menus.Path.Eq(menuPath)).First()
menuID := int64(0)
if created != nil {
menuID = created.ID
}
ctx.Payload(&ensureActivityProfitLossMenuResponse{Ensured: true, Parent: parentID, MenuID: menuID})
}
}