414 lines
13 KiB
Go
414 lines
13 KiB
Go
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})
|
||
}
|
||
}
|