bindbox-game/internal/api/admin/dashboard_activity.go
2026-02-27 16:07:12 +08:00

769 lines
29 KiB
Go
Executable File
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"
financesvc "bindbox-game/internal/service/finance"
"encoding/json"
"fmt"
"net/http"
"sort"
"strconv"
"strings"
"time"
"gorm.io/gorm"
)
type activityProfitLossRequest struct {
Page int `form:"page"`
PageSize int `form:"page_size"`
Name string `form:"name"`
Status int32 `form:"status"` // 1进行中 2下线
SortBy string `form:"sort_by"` // profit, profit_asc, profit_rate, draw_count
}
type activityProfitLossItem struct {
ActivityID int64 `json:"activity_id"`
ActivityName string `json:"activity_name"`
Status int32 `json:"status"`
DrawCount int64 `json:"draw_count"`
GamePassCount int64 `json:"game_pass_count"` // 次卡抽奖次数
PaymentCount int64 `json:"payment_count"` // 现金/优惠券抽奖次数
RefundCount int64 `json:"refund_count"` // 退款/取消抽奖次数
PlayerCount int64 `json:"player_count"`
TotalRevenue int64 `json:"total_revenue"` // 实际支付金额 (分)
TotalDiscount int64 `json:"total_discount"` // 优惠券抵扣金额 (分)
TotalGamePassValue int64 `json:"total_game_pass_value"` // 次卡价值 (分)
TotalCost int64 `json:"total_cost"` // 奖品标价总和 (分)
SpendingPaidCoupon int64 `json:"spending_paid_coupon"`
SpendingGamePass int64 `json:"spending_game_pass"`
PrizeCostBase int64 `json:"prize_cost_base"`
PrizeCostMultiplier int64 `json:"prize_cost_multiplier"`
PrizeCostFinal int64 `json:"prize_cost_final"`
Profit int64 `json:"profit"` // (Revenue + Discount + GamePassValue) - Cost
ProfitRate float64 `json:"profit_rate"` // Profit / (Revenue + Discount + GamePassValue)
}
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. 获取活动列表基础信息
// 1. 获取活动列表基础信息
var activities []model.Activities
// 仅查询有完整配置(Issue->RewardSettings)且未删除的活动
// 使用 Raw SQL 避免 GORM 自动注入 ambiguous 的 deleted_at
rawSubQuery := fmt.Sprintf(`
SELECT activity_issues.activity_id
FROM %s AS activity_issues
JOIN %s AS activity_reward_settings ON activity_reward_settings.issue_id = activity_issues.id
WHERE activity_issues.deleted_at IS NULL
AND activity_reward_settings.deleted_at IS NULL
`, model.TableNameActivityIssues, model.TableNameActivityRewardSettings)
query := db.Table(model.TableNameActivities).
Where("activities.deleted_at IS NULL").
Where(fmt.Sprintf("activities.id IN (%s)", rawSubQuery))
if req.Name != "" {
query = query.Where("activities.name LIKE ?", "%"+req.Name+"%")
}
if req.Status > 0 {
query = query.Where("activities.status = ?", req.Status)
}
var total int64
query.Count(&total)
// 如果有排序需求,先获取所有活动计算盈亏后排序,再分页
// 如果没有排序需求,直接数据库分页
needCustomSort := req.SortBy != ""
var limitQuery = query
if !needCustomSort {
limitQuery = query.Offset((req.Page - 1) * req.PageSize).Limit(req.PageSize)
}
if err := limitQuery.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 和 orders)
type drawStat struct {
ActivityID int64
TotalCount int64
GamePassCount int64
PaymentCount int64
RefundCount int64
PlayerCount int64
}
var drawStats []drawStat
db.Table(model.TableNameActivityDrawLogs).
Select(`
activity_issues.activity_id,
COUNT(activity_draw_logs.id) as total_count,
SUM(CASE WHEN orders.status = 2 AND (orders.source_type = 4 OR orders.order_no LIKE 'GP%') THEN 1 ELSE 0 END) as game_pass_count,
SUM(CASE WHEN orders.status = 2 AND NOT (orders.source_type = 4 OR orders.order_no LIKE 'GP%') THEN 1 ELSE 0 END) as payment_count,
SUM(CASE WHEN orders.status IN (3, 4) THEN 1 ELSE 0 END) as refund_count,
COUNT(DISTINCT CASE WHEN orders.status = 2 THEN activity_draw_logs.user_id END) as player_count
`).
Joins("JOIN activity_issues ON activity_issues.id = activity_draw_logs.issue_id").
Joins("LEFT JOIN orders ON orders.id = activity_draw_logs.order_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.GamePassCount + s.PaymentCount // 仅统计有效抽奖(次卡+支付)
item.GamePassCount = s.GamePassCount
item.PaymentCount = s.PaymentCount
item.RefundCount = s.RefundCount
item.PlayerCount = s.PlayerCount
}
}
// 3. 统计营收和优惠券抵扣 (通过 orders 关联 activity_draw_logs)
// 3. 统计营收和优惠券抵扣 (通过 orders 关联 activity_draw_logs)
// BUG修复排除已退款订单(status=4)。
// 注意: MySQL SUM()运算涉及除法时会返回Decimal类型需要Scan到float64
type revenueStat struct {
ActivityID int64
TotalRevenue float64
TotalDiscount float64
}
var revenueStats []revenueStat
// 修正: 按抽奖次数比例分摊订单金额,且次卡订单不计入支付+优惠券口径(严格二选一)
var err error
err = db.Table(model.TableNameOrders).
Select(`
order_activity_draws.activity_id,
SUM(CASE WHEN orders.source_type = 4 OR orders.order_no LIKE 'GP%' OR (orders.actual_amount = 0 AND orders.remark LIKE '%use_game_pass%')
THEN 0
ELSE 1.0 * orders.actual_amount * order_activity_draws.draw_count / order_total_draws.total_count
END) as total_revenue,
SUM(CASE WHEN orders.source_type = 4 OR orders.order_no LIKE 'GP%' OR (orders.actual_amount = 0 AND orders.remark LIKE '%use_game_pass%')
THEN 0
ELSE 1.0 * orders.discount_amount * order_activity_draws.draw_count / order_total_draws.total_count
END) as total_discount
`).
// Subquery 1: Calculate draw counts per order per activity (and link to issue->activity)
Joins(`JOIN (
SELECT activity_draw_logs.order_id, activity_issues.activity_id, COUNT(*) as draw_count
FROM activity_draw_logs
JOIN activity_issues ON activity_issues.id = activity_draw_logs.issue_id
GROUP BY activity_draw_logs.order_id, activity_issues.activity_id
) as order_activity_draws ON order_activity_draws.order_id = orders.id`).
// Subquery 2: Calculate total draw counts per order
Joins(`JOIN (
SELECT order_id, COUNT(*) as total_count
FROM activity_draw_logs
GROUP BY order_id
) as order_total_draws ON order_total_draws.order_id = orders.id`).
Where("orders.status = ?", 2). // 已支付(排除待支付、取消、退款状态)
Where("order_activity_draws.activity_id IN ?", activityIDs).
Group("order_activity_draws.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 = int64(s.TotalRevenue)
item.TotalDiscount = int64(s.TotalDiscount)
}
}
// 4. 统计成本 (通过 user_inventory 关联 products 和 orders)
// 修正:增加关联 orders 表,过滤掉已退款/取消的订单 (status!=2)
type costStat struct {
ActivityID int64
TotalCost int64
TotalCostBase int64
AvgMultiplierX10 int64
}
var costStats []costStat
if err := db.Table(model.TableNameUserInventory).
Select(`
COALESCE(NULLIF(user_inventory.activity_id, 0), cost_issues.activity_id) as activity_id,
CAST(SUM(COALESCE(NULLIF(user_inventory.value_cents, 0), activity_reward_settings.price_snapshot_cents, products.price, 0) * GREATEST(COALESCE(system_item_cards.reward_multiplier_x1000, 1000), 1000) / 1000) AS SIGNED) as total_cost,
SUM(COALESCE(NULLIF(user_inventory.value_cents, 0), activity_reward_settings.price_snapshot_cents, products.price, 0)) as total_cost_base,
CAST(COALESCE(AVG(GREATEST(COALESCE(system_item_cards.reward_multiplier_x1000, 1000), 1000) / 100), 10) AS SIGNED) as avg_multiplier_x10
`).
Joins("LEFT JOIN orders ON orders.id = user_inventory.order_id").
Joins("LEFT JOIN activity_reward_settings ON activity_reward_settings.id = user_inventory.reward_id").
Joins("LEFT JOIN activity_issues AS cost_issues ON cost_issues.id = activity_reward_settings.issue_id").
Joins("LEFT JOIN products ON products.id = user_inventory.product_id").
Joins("LEFT JOIN user_item_cards ON user_item_cards.id = orders.item_card_id").
Joins("LEFT JOIN system_item_cards ON system_item_cards.id = user_item_cards.card_id").
Where("COALESCE(NULLIF(user_inventory.activity_id, 0), cost_issues.activity_id) IN ?", activityIDs).
Where("user_inventory.status IN ?", []int{1, 3}).
Where("COALESCE(user_inventory.remark, '') NOT LIKE ?", "%void%").
// 兼容历史数据:部分老资产可能未写入 order_id避免被 JOIN 条件整批过滤为0
Where("(orders.status = ? OR user_inventory.order_id = 0 OR user_inventory.order_id IS NULL)", 2).
Group("COALESCE(NULLIF(user_inventory.activity_id, 0), cost_issues.activity_id)").
Scan(&costStats).Error; err != nil {
h.logger.Error(fmt.Sprintf("GetActivityProfitLoss cost stats error: %v", err))
} else {
for _, s := range costStats {
if item, ok := activityMap[s.ActivityID]; ok {
item.TotalCost = s.TotalCost
item.PrizeCostBase = s.TotalCostBase
item.PrizeCostFinal = s.TotalCost
item.PrizeCostMultiplier = s.AvgMultiplierX10
}
}
}
// 5. 统计次卡价值 (0元订单按活动单价计算)
// 先获取各活动的单价
activityPriceMap := make(map[int64]int64)
for _, a := range activities {
activityPriceMap[a.ID] = a.PriceDraw
}
// 统计每个活动的0元订单对应的抽奖次数 (次卡支付)
// BUG修复之前统计的是订单数量但一个订单可能包含多次抽奖
// 正确做法是统计抽奖次数,再乘以活动单价
type gamePassStat struct {
ActivityID int64
GamePassDraws int64 // 抽奖次数,非订单数
}
var gamePassStats []gamePassStat
db.Table(model.TableNameActivityDrawLogs).
Select("activity_issues.activity_id, COUNT(activity_draw_logs.id) as game_pass_draws").
Joins("JOIN activity_issues ON activity_issues.id = activity_draw_logs.issue_id").
Joins("JOIN orders ON orders.id = activity_draw_logs.order_id").
Where("orders.status = ? AND orders.status != ?", 2, 4). // 已支付且未退款
Where("orders.actual_amount = 0"). // 0元订单
Where("orders.source_type = 4 OR orders.order_no LIKE 'GP%'"). // 次数卡 (Lottery SourceType=4 OR Matching Game GP prefix)
Where("activity_issues.activity_id IN ?", activityIDs).
Group("activity_issues.activity_id").
Scan(&gamePassStats)
for _, s := range gamePassStats {
if item, ok := activityMap[s.ActivityID]; ok {
// 次卡价值 = 次卡抽奖次数 * 活动单价
item.TotalGamePassValue = s.GamePassDraws * activityPriceMap[s.ActivityID]
}
}
// 6. 计算盈亏和比率
// 公式: 盈亏 = 用户支出(普通单支付+优惠券 或 次卡价值) - 奖品成本(含道具卡倍率)
finalList := make([]activityProfitLossItem, 0, len(activities))
for _, a := range activities {
item := activityMap[a.ID]
item.SpendingPaidCoupon = item.TotalRevenue + item.TotalDiscount
item.SpendingGamePass = item.TotalGamePassValue
totalIncome := item.SpendingPaidCoupon + item.SpendingGamePass
item.Profit, item.ProfitRate = financesvc.ComputeProfit(totalIncome, item.TotalCost)
finalList = append(finalList, *item)
}
// 按请求的字段排序
if needCustomSort {
sort.Slice(finalList, func(i, j int) bool {
switch req.SortBy {
case "profit":
return finalList[i].Profit > finalList[j].Profit
case "profit_asc":
return finalList[i].Profit < finalList[j].Profit
case "profit_rate":
return finalList[i].ProfitRate > finalList[j].ProfitRate
case "draw_count":
return finalList[i].DrawCount > finalList[j].DrawCount
default:
return false // 保持原有顺序 (id DESC)
}
})
// 排序后再分页
start := (req.Page - 1) * req.PageSize
end := start + req.PageSize
if start > len(finalList) {
start = len(finalList)
}
if end > len(finalList) {
end = len(finalList)
}
finalList = finalList[start:end]
}
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"`
UserID int64 `form:"user_id"`
PlayerKeyword string `form:"player_keyword"`
PrizeKeyword string `form:"prize_keyword"`
}
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"`
ProductQuantity int64 `json:"product_quantity"` // 奖品数量
OrderAmount int64 `json:"order_amount"`
OrderNo string `json:"order_no"` // 订单号
DiscountAmount int64 `json:"discount_amount"` // 优惠金额(分)
PayType string `json:"pay_type"` // 支付方式/类型 (现金/道具卡/次数卡)
UsedCard string `json:"used_card"` // 使用的卡券名称(兼容旧字段)
OrderStatus int32 `json:"order_status"` // 订单状态: 1待支付 2已支付 3已取消 4已退款
Profit int64 `json:"profit"`
CreatedAt time.Time `json:"created_at"`
// 新增:详细支付信息
PaymentDetails PaymentDetails `json:"payment_details"`
}
// PaymentDetails 支付详细信息
type PaymentDetails struct {
CouponUsed bool `json:"coupon_used"` // 是否使用优惠券
CouponName string `json:"coupon_name"` // 优惠券名称
CouponDiscount int64 `json:"coupon_discount"` // 优惠券抵扣金额(分)
ItemCardUsed bool `json:"item_card_used"` // 是否使用道具卡
ItemCardName string `json:"item_card_name"` // 道具卡名称
GamePassUsed bool `json:"game_pass_used"` // 是否使用次数卡
GamePassInfo string `json:"game_pass_info"` // 次数卡使用信息
PointsUsed bool `json:"points_used"` // 是否使用积分
PointsDiscount int64 `json:"points_discount"` // 积分抵扣金额(分)
}
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
}
req.PlayerKeyword = strings.TrimSpace(req.PlayerKeyword)
req.PrizeKeyword = strings.TrimSpace(req.PrizeKeyword)
db := h.repo.GetDbR().WithContext(ctx.RequestContext())
var total int64
countQuery := db.Table(model.TableNameActivityDrawLogs).
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").
Where("activity_issues.activity_id = ?", activityID)
countQuery = applyActivityLogFilters(countQuery, req)
countQuery.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
PointsAmount int64 // 积分抵扣金额
OrderStatus int32 // 订单状态
SourceType int32
CouponID int64
CouponName string
ItemCardID int64
ItemCardName string
EffectType int32
Multiplier int32
OrderRemark string // BUG修复增加remark字段用于解析次数卡使用信息
OrderNo string // 订单号
DrawCount int64 // 该订单的总抽奖次数(用于金额分摊)
UsedDrawLogID int64 // 道具卡实际使用的日志ID
CreatedAt time.Time
ActivityPrice int64
}
logsQuery := 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(activity_reward_settings.price_snapshot_cents, products.price, 0) as product_price,
COALESCE(orders.actual_amount, 0) as order_amount,
COALESCE(orders.discount_amount, 0) as discount_amount,
COALESCE(orders.points_amount, 0) as points_amount,
COALESCE(orders.status, 0) as order_status,
orders.source_type,
COALESCE(orders.coupon_id, 0) as coupon_id,
COALESCE(system_coupons.name, '') as coupon_name,
COALESCE(orders.item_card_id, 0) as item_card_id,
COALESCE(system_item_cards.name, '') as item_card_name,
COALESCE(system_item_cards.effect_type, 0) as effect_type,
COALESCE(system_item_cards.reward_multiplier_x1000, 0) as multiplier,
COALESCE(orders.remark, '') as order_remark,
COALESCE(orders.order_no, '') as order_no,
COALESCE(order_draw_counts.draw_count, 1) as draw_count,
COALESCE(user_item_cards.used_draw_log_id, 0) as used_draw_log_id,
activity_draw_logs.created_at,
COALESCE(activities.price_draw, 0) as activity_price
`).
Joins("JOIN activity_issues ON activity_issues.id = activity_draw_logs.issue_id").
Joins("JOIN activities ON activities.id = activity_issues.activity_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 user_coupons ON user_coupons.id = orders.coupon_id").
Joins("LEFT JOIN system_coupons ON system_coupons.id = user_coupons.coupon_id").
Joins("LEFT JOIN user_item_cards ON user_item_cards.id = orders.item_card_id").
Joins("LEFT JOIN system_item_cards ON system_item_cards.id = user_item_cards.card_id").
Joins("LEFT JOIN (SELECT order_id, COUNT(*) as draw_count FROM activity_draw_logs GROUP BY order_id) as order_draw_counts ON order_draw_counts.order_id = activity_draw_logs.order_id").
Where("activity_issues.activity_id = ?", activityID)
logsQuery = applyActivityLogFilters(logsQuery, req)
err := logsQuery.
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]
}
// Default quantity is 1
quantity := int64(1)
// Determine PayType and UsedCard + PaymentDetails
payType := "现金支付"
usedCard := ""
paymentDetails := PaymentDetails{} // 金额将在 drawCount 计算后设置
// 检查是否使用了优惠券
if l.CouponID > 0 || l.CouponName != "" {
paymentDetails.CouponUsed = true
paymentDetails.CouponName = l.CouponName
if paymentDetails.CouponName == "" {
paymentDetails.CouponName = "优惠券"
}
usedCard = paymentDetails.CouponName
payType = "优惠券"
}
// 检查是否使用了道具卡
// BUG FIX: 仅当该条日志的 ID 等于 item_card 记录的 used_draw_log_id 时,才显示道具卡信息
// 防止一个订单下的所有抽奖记录都显示 "双倍快乐水"
isCardValidForThisLog := (l.UsedDrawLogID == 0) || (l.UsedDrawLogID == l.ID)
if (l.ItemCardID > 0 || l.ItemCardName != "") && isCardValidForThisLog {
paymentDetails.ItemCardUsed = true
paymentDetails.ItemCardName = l.ItemCardName
if paymentDetails.ItemCardName == "" {
paymentDetails.ItemCardName = "道具卡"
}
if usedCard != "" {
usedCard = usedCard + " + " + paymentDetails.ItemCardName
} else {
usedCard = paymentDetails.ItemCardName
}
payType = "道具卡"
// 计算双倍/多倍卡数量
if l.EffectType == 1 && l.Multiplier > 1000 {
quantity = quantity * int64(l.Multiplier) / 1000
}
}
// 检查是否使用了次数卡 (source_type=4 或 remark包含use_game_pass)
if l.SourceType == 4 || strings.Contains(l.OrderRemark, "use_game_pass") {
paymentDetails.GamePassUsed = true
// 解析 gp_use:ID:Count 格式获取次数卡使用信息
gamePassInfo := "次数卡"
if strings.Contains(l.OrderRemark, "gp_use:") {
// 从remark中提取次数卡信息格式: use_game_pass;gp_use:ID:Count;gp_use:ID:Count
parts := strings.Split(l.OrderRemark, ";")
var gpParts []string
for _, p := range parts {
if strings.HasPrefix(p, "gp_use:") {
gpParts = append(gpParts, p)
}
}
if len(gpParts) > 0 {
gamePassInfo = fmt.Sprintf("使用%d种次数卡", len(gpParts))
}
}
paymentDetails.GamePassInfo = gamePassInfo
if usedCard != "" {
usedCard = usedCard + " + " + gamePassInfo
} else {
usedCard = gamePassInfo
}
payType = "次数卡"
}
// 检查是否使用了积分
if l.PointsAmount > 0 {
paymentDetails.PointsUsed = true
}
// 如果同时使用了多种方式,标记为组合支付
usedCount := 0
if paymentDetails.CouponUsed {
usedCount++
}
if paymentDetails.ItemCardUsed {
usedCount++
}
if paymentDetails.GamePassUsed {
usedCount++
}
if usedCount > 1 {
payType = "组合支付"
} else if usedCount == 0 && l.OrderAmount > 0 {
payType = "现金支付"
} else if usedCount == 0 && l.OrderAmount == 0 {
// 0元支付默认视为次数卡使用实际业务中几乎不存在真正免费的情况
payType = "次数卡"
paymentDetails.GamePassUsed = true
if paymentDetails.GamePassInfo == "" {
paymentDetails.GamePassInfo = "次数卡"
}
}
// 计算单次抽奖的分摊金额(一个订单可能包含多次抽奖)
drawCount := l.DrawCount
if drawCount <= 0 {
drawCount = 1
}
perDrawOrderAmount := l.OrderAmount / drawCount
perDrawDiscountAmount := l.DiscountAmount / drawCount
perDrawPointsAmount := l.PointsAmount / drawCount
if paymentDetails.GamePassUsed {
if l.ActivityPrice > 0 {
perDrawOrderAmount = l.ActivityPrice
} else if perDrawOrderAmount == 0 {
perDrawOrderAmount = l.OrderAmount / drawCount
}
}
// 设置支付详情中的分摊金额
paymentDetails.CouponDiscount = perDrawDiscountAmount
paymentDetails.PointsDiscount = perDrawPointsAmount
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,
ProductQuantity: quantity,
OrderAmount: perDrawOrderAmount, // 单次抽奖分摊的支付金额
OrderNo: l.OrderNo, // 订单号
DiscountAmount: perDrawDiscountAmount, // 单次抽奖分摊的优惠金额
PayType: payType,
UsedCard: usedCard,
OrderStatus: l.OrderStatus,
Profit: perDrawOrderAmount + perDrawDiscountAmount - l.ProductPrice*quantity, // 单次盈亏 = 分摊收入 - 成本*数量
CreatedAt: l.CreatedAt,
PaymentDetails: paymentDetails,
}
}
ctx.Payload(&activityLogsResponse{
Page: req.Page,
PageSize: req.PageSize,
Total: total,
List: list,
})
}
}
func applyActivityLogFilters(q *gorm.DB, req *activityLogsRequest) *gorm.DB {
if req == nil {
return q
}
if req.UserID > 0 {
q = q.Where("activity_draw_logs.user_id = ?", req.UserID)
}
if kw := req.PlayerKeyword; kw != "" {
like := "%" + kw + "%"
var args []interface{}
condition := "(users.nickname LIKE ? OR users.mobile LIKE ? OR users.invite_code LIKE ?"
args = append(args, like, like, like)
if playerID, err := strconv.ParseInt(kw, 10, 64); err == nil {
condition += " OR users.id = ?"
args = append(args, playerID)
}
condition += ")"
q = q.Where(condition, args...)
}
if kw := req.PrizeKeyword; kw != "" {
like := "%" + kw + "%"
args := []interface{}{like}
condition := "(products.name LIKE ?"
if prizeID, err := strconv.ParseInt(kw, 10, 64); err == nil {
condition += " OR products.id = ?"
args = append(args, prizeID)
}
condition += ")"
q = q.Where(condition, args...)
}
return q
}
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})
}
}