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