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}) } }