diff --git a/internal/api/activity/matching_game_app.go b/internal/api/activity/matching_game_app.go index 900d055..dfda6a5 100755 --- a/internal/api/activity/matching_game_app.go +++ b/internal/api/activity/matching_game_app.go @@ -172,7 +172,7 @@ func (h *handler) PreOrderMatchingGame() core.HandlerFunc { SourceType: 3, // 对对碰 TotalAmount: activity.PriceDraw, ActualAmount: 0, // 次数卡抵扣,实付0元 - DiscountAmount: activity.PriceDraw, + DiscountAmount: 0, // 次数卡支付,无优惠券抵扣 Status: 2, // 已支付 Remark: func() string { r := fmt.Sprintf("activity:%d|game_pass:%d|matching_game:issue:%d", activity.ID, validPass.ID, req.IssueID) diff --git a/internal/api/admin/activity_rankings_admin.go b/internal/api/admin/activity_rankings_admin.go index d94686a..4c2c943 100644 --- a/internal/api/admin/activity_rankings_admin.go +++ b/internal/api/admin/activity_rankings_admin.go @@ -121,7 +121,7 @@ func (h *handler) GetActivityRankings() core.HandlerFunc { orders.user_id, COALESCE(users.nickname, '') AS nickname, COALESCE(users.avatar, '') AS avatar, - CAST(SUM(orders.actual_amount + orders.discount_amount) AS SIGNED) AS total_amount, + CAST(SUM(orders.actual_amount) AS SIGNED) AS total_amount, COUNT(DISTINCT orders.id) AS order_count `). Group("orders.user_id, users.nickname, users.avatar") diff --git a/internal/api/admin/dashboard_activity.go b/internal/api/admin/dashboard_activity.go index 8fbd6ae..a7fdea9 100755 --- a/internal/api/admin/dashboard_activity.go +++ b/internal/api/admin/dashboard_activity.go @@ -168,126 +168,38 @@ func (h *handler) DashboardActivityProfitLoss() core.HandlerFunc { } } - // 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. 从 finance.Service 获取成本(替换原有直接 SQL 成本查询) - // finance.Service 用 value_cents 作为单一真相源(D-09),无需 COALESCE fallback chain + // 3. 从 finance.Service 统一获取收入、成本和展示字段 + // Revenue = actual_amount (真实现金) + // Cost = inventory value × item card multiplier + // 展示字段: CouponDiscount, PointsDiscount, GamePassValue financeParams := financesvc.ActivityProfitLossParams{ ActivityIDs: activityIDs, } financeResult, financeErr := h.financeSvc.QueryActivityProfitLoss(ctx.RequestContext(), financeParams) if financeErr != nil { - h.logger.Error(fmt.Sprintf("GetActivityProfitLoss finance cost error: %v", financeErr)) + h.logger.Error(fmt.Sprintf("GetActivityProfitLoss finance error: %v", financeErr)) } - // 按 activity_id 建立 cost 索引 - financeCostMap := make(map[int64]int64) if financeResult != nil { for _, d := range financeResult.Details { - financeCostMap[d.ActivityID] = d.Cost - } - } - for actID, item := range activityMap { - if cost, ok := financeCostMap[actID]; ok { - item.TotalCost = cost - item.PrizeCostFinal = cost + if item, ok := activityMap[d.ActivityID]; ok { + item.TotalRevenue = d.Revenue + item.TotalCost = d.Cost + item.PrizeCostFinal = d.Cost + item.TotalDiscount = d.CouponDiscount + d.PointsDiscount + item.TotalGamePassValue = d.GamePassValue + } } } - // 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. 计算盈亏和比率 - // 成本来自 finance.Service(value_cents 单一真相源 + 道具卡倍率) - // 收入来自原有 scan(保留 total_discount / total_game_pass_value 拆分字段) + // 4. 计算盈亏和比率 + // Revenue = 现金到账, Cost = 奖品成本 + // Profit = Revenue - Cost (优惠券/积分/次卡不参与利润计算) finalList := make([]activityProfitLossItem, 0, len(activities)) for _, a := range activities { item := activityMap[a.ID] - item.SpendingPaidCoupon = item.TotalRevenue + item.TotalDiscount + item.SpendingPaidCoupon = item.TotalRevenue item.SpendingGamePass = item.TotalGamePassValue - totalIncome := item.SpendingPaidCoupon + item.SpendingGamePass - item.Profit, item.ProfitRate = financesvc.ComputeProfit(totalIncome, item.TotalCost) + item.Profit, item.ProfitRate = financesvc.ComputeProfit(item.TotalRevenue, item.TotalCost) finalList = append(finalList, *item) } @@ -609,11 +521,11 @@ func (h *handler) DashboardActivityLogs() core.HandlerFunc { if drawCount <= 0 { drawCount = 1 } - perDrawOrderAmount := l.OrderAmount / drawCount - perDrawDiscountAmount := l.DiscountAmount / drawCount - perDrawPointsAmount := l.PointsAmount / drawCount + perDrawOrderAmount := l.OrderAmount / drawCount // actual_amount 分摊(现金) + perDrawDiscountAmount := l.DiscountAmount / drawCount // 展示用 + perDrawPointsAmount := l.PointsAmount / drawCount // 展示用 - // 次卡单口径:仅记次卡价值,不再叠加 discount,避免“次卡+现金”双计 + // 次卡单口径:仅记次卡价值,不再叠加 discount,避免”次卡+现金”双计 if isGamePassOrder { if l.ActivityPrice > 0 { perDrawOrderAmount = l.ActivityPrice @@ -626,6 +538,9 @@ func (h *handler) DashboardActivityLogs() core.HandlerFunc { paymentDetails.CouponDiscount = perDrawDiscountAmount paymentDetails.PointsDiscount = perDrawPointsAmount + // 计算单次抽奖成本:使用 value_cents × 道具卡倍率(与 finance service 一致) + prizeCost := financesvc.ComputePrizeCostWithMultiplier(l.ProductPrice, int64(l.Multiplier)*1000) + list[i] = activityLogItem{ ID: l.ID, UserID: l.UserID, @@ -636,13 +551,13 @@ func (h *handler) DashboardActivityLogs() core.HandlerFunc { ProductImage: productImage, ProductPrice: l.ProductPrice, ProductQuantity: quantity, - OrderAmount: perDrawOrderAmount, // 单次抽奖分摊的支付金额 + OrderAmount: perDrawOrderAmount, // 单次抽奖分摊的现金金额 OrderNo: l.OrderNo, // 订单号 - DiscountAmount: perDrawDiscountAmount, // 单次抽奖分摊的优惠金额 + DiscountAmount: perDrawDiscountAmount, // 单次抽奖分摊的优惠金额(展示用) PayType: payType, UsedCard: usedCard, OrderStatus: l.OrderStatus, - Profit: perDrawOrderAmount + perDrawDiscountAmount - l.ProductPrice*quantity, // 单次盈亏 = 分摊收入 - 成本*数量 + Profit: perDrawOrderAmount - prizeCost, // 单次盈亏 = 现金收入 - 奖品成本(含倍率) CreatedAt: l.CreatedAt, PaymentDetails: paymentDetails, } diff --git a/internal/api/admin/dashboard_spending.go b/internal/api/admin/dashboard_spending.go index ae9883b..a76d834 100755 --- a/internal/api/admin/dashboard_spending.go +++ b/internal/api/admin/dashboard_spending.go @@ -123,11 +123,11 @@ func (h *handler) DashboardPlayerSpendingLeaderboard() core.HandlerFunc { } if err := query.Select(` - orders.user_id, + orders.user_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 COALESCE(oa.price_draw * oa.draw_count, 0) - ELSE orders.actual_amount + orders.discount_amount - END) as total_amount, + ELSE orders.actual_amount + END) as total_amount, COUNT(orders.id) as order_count, SUM(orders.discount_amount) as total_discount, SUM(orders.points_amount) as total_points, @@ -140,21 +140,21 @@ func (h *handler) DashboardPlayerSpendingLeaderboard() core.HandlerFunc { SUM(CASE WHEN oa.category_id = 1 THEN 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 COALESCE(oa.price_draw * oa.draw_count, 0) - ELSE orders.actual_amount + orders.discount_amount + ELSE orders.actual_amount END ELSE 0 END) as ichiban_spending, SUM(CASE WHEN oa.category_id = 1 THEN 1 ELSE 0 END) as ichiban_count, SUM(CASE WHEN oa.category_id = 2 THEN 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 COALESCE(oa.price_draw * oa.draw_count, 0) - ELSE orders.actual_amount + orders.discount_amount + ELSE orders.actual_amount END ELSE 0 END) as infinite_spending, SUM(CASE WHEN oa.category_id = 2 THEN 1 ELSE 0 END) as infinite_count, SUM(CASE WHEN oa.category_id = 3 THEN 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 COALESCE(oa.price_draw * oa.draw_count, 0) - ELSE orders.actual_amount + orders.discount_amount + ELSE orders.actual_amount END ELSE 0 END) as matching_spending, SUM(CASE WHEN oa.category_id = 3 THEN 1 ELSE 0 END) as matching_count, @@ -261,9 +261,10 @@ func (h *handler) DashboardPlayerSpendingLeaderboard() core.HandlerFunc { query = query.Where("user_inventory.created_at >= ?", start). Where("user_inventory.created_at <= ?", end) } - // Only include Holding (1) and Shipped/Used (3) items. Exclude Void/Decomposed (2). + // Only include Holding (1) and Shipped/Used (3) items. Exclude Void/Decomposed (2) and redeemed-to-points (3+redeemed). query = query.Where("user_inventory.status IN ?", []int{1, 3}). - Where("COALESCE(user_inventory.remark, '') NOT LIKE ?", "%void%") + Where("COALESCE(user_inventory.remark, '') NOT LIKE ?", "%void%"). + Where("NOT (user_inventory.status = 3 AND (COALESCE(user_inventory.remark, '') LIKE ? OR COALESCE(user_inventory.remark, '') LIKE ?))", "%redeemed_points%", "%batch_redeemed%") err := query.Select(` user_inventory.user_id, @@ -339,7 +340,8 @@ func (h *handler) DashboardPlayerSpendingLeaderboard() core.HandlerFunc { Joins("LEFT JOIN products ON products.id = user_inventory.product_id"). Where("user_inventory.user_id IN ?", userIDs). Where("user_inventory.status IN (1,3)"). - Where("COALESCE(user_inventory.remark, '') NOT LIKE ?", "%void%") + Where("COALESCE(user_inventory.remark, '') NOT LIKE ?", "%void%"). + Where("NOT (user_inventory.status = 3 AND (COALESCE(user_inventory.remark, '') LIKE ? OR COALESCE(user_inventory.remark, '') LIKE ?))", "%redeemed_points%", "%batch_redeemed%") if req.RangeType != "all" { invQ = invQ.Where("user_inventory.created_at >= ?", start.Add(-2*time.Hour)). Where("user_inventory.created_at <= ?", end.Add(24*time.Hour)) diff --git a/internal/api/admin/dashboard_user_spending.go b/internal/api/admin/dashboard_user_spending.go index e5ea9ec..f9bd16c 100755 --- a/internal/api/admin/dashboard_user_spending.go +++ b/internal/api/admin/dashboard_user_spending.go @@ -115,7 +115,7 @@ func (h *handler) GetUserSpendingDashboard() core.HandlerFunc { CAST(ROUND(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 COALESCE(order_activity_draws.draw_count * activities.price_draw, 0) - ELSE COALESCE((orders.actual_amount + orders.discount_amount) * order_activity_draws.draw_count / NULLIF(order_total_draws.total_count, 0), 0) + ELSE COALESCE(orders.actual_amount * order_activity_draws.draw_count / NULLIF(order_total_draws.total_count, 0), 0) END), 0) AS SIGNED) as spending, COUNT(DISTINCT orders.id) as order_count `). @@ -143,7 +143,9 @@ func (h *handler) GetUserSpendingDashboard() core.HandlerFunc { Joins("LEFT JOIN system_item_cards ON system_item_cards.id = user_item_cards.card_id"). Where("user_inventory.user_id = ?", userID). Where("user_inventory.status IN ?", []int{1, 3}). - Where("COALESCE(user_inventory.remark, '') NOT LIKE ?", "%void%") + Where("COALESCE(user_inventory.remark, '') NOT LIKE ?", "%void%"). + Where("NOT (user_inventory.status = 3 AND (COALESCE(user_inventory.remark, '') LIKE ? OR COALESCE(user_inventory.remark, '') LIKE ?))", "%redeemed_points%", "%batch_redeemed%"). + Where("COALESCE(NULLIF(user_inventory.activity_id, 0), cost_issues.activity_id, 0) > 0") if hasRange { prizeQuery = prizeQuery.Where("user_inventory.created_at >= ?", start).Where("user_inventory.created_at <= ?", end) @@ -243,7 +245,8 @@ func (h *handler) GetUserSpendingDashboard() core.HandlerFunc { Joins("LEFT JOIN products ON products.id = user_inventory.product_id"). Where("user_id = ?", userID). Where("status IN (1,3)"). - Where("COALESCE(user_inventory.remark, '') NOT LIKE ?", "%void%") + Where("COALESCE(user_inventory.remark, '') NOT LIKE ?", "%void%"). + Where("NOT (user_inventory.status = 3 AND (COALESCE(user_inventory.remark, '') LIKE ? OR COALESCE(user_inventory.remark, '') LIKE ?))", "%redeemed_points%", "%batch_redeemed%") if hasRange { invQ = invQ.Where("created_at >= ?", start.Add(-2*time.Hour)).Where("created_at <= ?", end.Add(24*time.Hour)) } diff --git a/internal/api/admin/users_profile.go b/internal/api/admin/users_profile.go index cecc471..5525a51 100755 --- a/internal/api/admin/users_profile.go +++ b/internal/api/admin/users_profile.go @@ -209,6 +209,7 @@ func (h *handler) GetUserProfile() core.HandlerFunc { LEFT JOIN system_item_cards sic ON sic.id = uic.card_id WHERE ui.user_id = ? AND ui.status IN (1, 3) AND COALESCE(ui.remark, '') NOT LIKE '%%void%%' + AND NOT (ui.status = 3 AND (COALESCE(ui.remark, '') LIKE '%%redeemed_points%%' OR COALESCE(ui.remark, '') LIKE '%%batch_redeemed%%')) `, userID).Scan(&is).Error rsp.CurrentAssets.InventoryCount = is.Count rsp.CurrentAssets.InventoryValue = is.Value diff --git a/internal/api/admin/users_profit_loss.go b/internal/api/admin/users_profit_loss.go index fafda9c..dd002f1 100755 --- a/internal/api/admin/users_profit_loss.go +++ b/internal/api/admin/users_profit_loss.go @@ -100,6 +100,7 @@ func (h *handler) GetUserProfitLossTrend() core.HandlerFunc { LEFT JOIN system_item_cards sic ON sic.id = uic.card_id WHERE ui.user_id = ? AND ui.status IN (1, 3) AND COALESCE(ui.remark, '') NOT LIKE '%%void%%' + AND NOT (ui.status = 3 AND (COALESCE(ui.remark, '') LIKE '%%redeemed_points%%' OR COALESCE(ui.remark, '') LIKE '%%batch_redeemed%%')) `, userID).Scan(&curAssets.Products).Error _ = h.repo.GetDbR().Raw("SELECT COALESCE(SUM(sc.price), 0) FROM user_item_cards uic LEFT JOIN system_item_cards sc ON sc.id = uic.card_id WHERE uic.user_id = ? AND uic.status = 1", userID).Scan(&curAssets.Cards).Error _ = h.repo.GetDbR().Raw("SELECT COALESCE(SUM(balance_amount), 0) FROM user_coupons WHERE user_id = ? AND status = 1", userID).Scan(&curAssets.Coupons).Error @@ -112,7 +113,7 @@ func (h *handler) GetUserProfitLossTrend() core.HandlerFunc { SELECT COALESCE(SUM(CASE WHEN o.source_type = 4 OR o.order_no LIKE 'GP%' OR (o.actual_amount = 0 AND o.remark LIKE '%use_game_pass%') THEN COALESCE(od.draw_count * a.price_draw, 0) - ELSE o.actual_amount + o.discount_amount + ELSE o.actual_amount END), 0) FROM orders o LEFT JOIN ( @@ -148,7 +149,7 @@ func (h *handler) GetUserProfitLossTrend() core.HandlerFunc { CASE WHEN o.source_type = 4 OR o.order_no LIKE 'GP%' OR (o.actual_amount = 0 AND o.remark LIKE '%use_game_pass%') THEN COALESCE(od.draw_count * a.price_draw, 0) - ELSE o.actual_amount + o.discount_amount + ELSE o.actual_amount END as spending FROM orders o LEFT JOIN ( @@ -230,7 +231,7 @@ func (h *handler) GetUserProfitLossTrend() core.HandlerFunc { SELECT COALESCE(SUM(CASE WHEN o.source_type = 4 OR o.order_no LIKE 'GP%' OR (o.actual_amount = 0 AND o.remark LIKE '%use_game_pass%') THEN COALESCE(od.draw_count * a.price_draw, 0) - ELSE o.actual_amount + o.discount_amount + ELSE o.actual_amount END), 0) FROM orders o LEFT JOIN ( @@ -505,7 +506,7 @@ func (h *handler) GetUserProfitLossDetails() core.HandlerFunc { CASE WHEN o.source_type = 4 OR o.order_no LIKE 'GP%' OR (o.actual_amount = 0 AND o.remark LIKE '%use_game_pass%') THEN COALESCE(od.draw_count * a.price_draw, 0) - ELSE o.actual_amount + o.discount_amount + ELSE o.actual_amount END as spending FROM orders o LEFT JOIN ( @@ -532,7 +533,7 @@ func (h *handler) GetUserProfitLossDetails() core.HandlerFunc { couponValue := couponValueMap[o.ID] spending := orderSpendingMap[o.ID] if spending == 0 { - spending = o.ActualAmount + o.DiscountAmount + spending = o.ActualAmount } netCost := spending - refund if netCost < 0 { diff --git a/internal/service/finance/profit_metrics.go b/internal/service/finance/profit_metrics.go index 10e52d2..796284e 100644 --- a/internal/service/finance/profit_metrics.go +++ b/internal/service/finance/profit_metrics.go @@ -13,7 +13,10 @@ type SpendingBreakdown struct { // ClassifyOrderSpending applies the unified rule: // - game pass order: spending = game pass value -// - normal order: spending = actual + discount +// - normal order: spending = actual_amount only (real cash received) +// +// Note: discount_amount (coupon) and points_amount are FREE marketing subsidies, +// NOT real revenue. They are tracked as display-only fields separately. func ClassifyOrderSpending(sourceType int32, orderNo string, actualAmount, discountAmount int64, remark string, gamePassValue int64) SpendingBreakdown { isGamePass := IsGamePassOrder(sourceType, orderNo, actualAmount, remark) if isGamePass { @@ -28,14 +31,14 @@ func ClassifyOrderSpending(sourceType int32, orderNo string, actualAmount, disco } } - paidCoupon := actualAmount + discountAmount - if paidCoupon < 0 { - paidCoupon = 0 + cashRevenue := actualAmount + if cashRevenue < 0 { + cashRevenue = 0 } return SpendingBreakdown{ - PaidCoupon: paidCoupon, + PaidCoupon: cashRevenue, GamePass: 0, - Total: paidCoupon, + Total: cashRevenue, IsGamePass: false, } } diff --git a/internal/service/finance/query_activity.go b/internal/service/finance/query_activity.go index 476e0b0..9d1f53d 100644 --- a/internal/service/finance/query_activity.go +++ b/internal/service/finance/query_activity.go @@ -4,13 +4,15 @@ import ( "context" "fmt" - "bindbox-game/internal/pkg/points" "bindbox-game/internal/repository/mysql/model" ) // queryActivity implements QueryActivityProfitLoss using fan-out + in-memory merge. -// Four independent Scan() calls gather revenue, inventory cost, points cost, -// and coupon cost attributed to activity dimension; results merged in Go. +// Two independent Scan() calls gather revenue and inventory cost +// attributed to activity dimension; results merged in Go. +// +// Revenue = actual_amount only (real cash). Coupons/points are FREE marketing subsidies. +// Cost = inventory value × item card multiplier only. func (s *service) queryActivity(ctx context.Context, params ActivityProfitLossParams) (*ProfitLossResult, error) { // Step 1: Revenue scan — per-order rows attributed to activity via draw logs type activityRevenueRow struct { @@ -19,6 +21,7 @@ func (s *service) queryActivity(ctx context.Context, params ActivityProfitLossPa OrderNo string ActualAmount int64 DiscountAmount int64 + PointsAmount int64 Remark string DrawCount int64 ActivityPrice int64 @@ -28,14 +31,14 @@ func (s *service) queryActivity(ctx context.Context, params ActivityProfitLossPa Table(model.TableNameOrders). Select(`activity_issues.activity_id, orders.source_type, orders.order_no, - orders.actual_amount, orders.discount_amount, orders.remark, + orders.actual_amount, orders.discount_amount, orders.points_amount, orders.remark, COUNT(activity_draw_logs.id) as draw_count, COALESCE(MAX(activities.price_draw), 0) as activity_price`). Joins("JOIN activity_draw_logs ON activity_draw_logs.order_id = orders.id"). Joins("JOIN activity_issues ON activity_issues.id = activity_draw_logs.issue_id"). Joins("LEFT JOIN activities ON activities.id = activity_issues.activity_id"). Where("orders.status = ?", 2). - Group("orders.id, activity_issues.activity_id, orders.source_type, orders.order_no, orders.actual_amount, orders.discount_amount, orders.remark") + Group("orders.id, activity_issues.activity_id, orders.source_type, orders.order_no, orders.actual_amount, orders.discount_amount, orders.points_amount, orders.remark") if len(params.ActivityIDs) > 0 { q = q.Where("activity_issues.activity_id IN ?", params.ActivityIDs) } @@ -56,7 +59,16 @@ func (s *service) queryActivity(ctx context.Context, params ActivityProfitLossPa if _, ok := resultMap[r.ActivityID]; !ok { resultMap[r.ActivityID] = &ProfitLossDetail{ActivityID: r.ActivityID} } - resultMap[r.ActivityID].Revenue += bd.Total + d := resultMap[r.ActivityID] + d.Revenue += bd.Total + + // Populate display-only fields + if bd.IsGamePass { + d.GamePassValue += bd.GamePass + } else { + d.CouponDiscount += r.DiscountAmount + d.PointsDiscount += r.PointsAmount + } } // Step 2: Inventory cost scan — grouped by activity_id, multiplier applied in Go @@ -68,13 +80,16 @@ func (s *service) queryActivity(ctx context.Context, params ActivityProfitLossPa iq := s.dbR.WithContext(ctx). Table(model.TableNameUserInventory). Select(`user_inventory.activity_id, - user_inventory.value_cents, + COALESCE(NULLIF(user_inventory.value_cents, 0), activity_reward_settings.price_snapshot_cents, products.price, 0) as value_cents, COALESCE(system_item_cards.reward_multiplier_x1000, 1000) as multiplier_x1000`). + Joins("LEFT JOIN activity_reward_settings ON activity_reward_settings.id = user_inventory.reward_id"). + Joins("LEFT JOIN products ON products.id = user_inventory.product_id"). Joins("LEFT JOIN orders ON orders.id = user_inventory.order_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("user_inventory.status IN ?", []int{1, 3}). Where("COALESCE(user_inventory.remark, '') NOT LIKE ?", "%void%"). + Where("NOT (user_inventory.status = 3 AND (COALESCE(user_inventory.remark, '') LIKE ? OR COALESCE(user_inventory.remark, '') LIKE ?))", "%redeemed_points%", "%batch_redeemed%"). Where("(orders.status = ? OR user_inventory.order_id = 0 OR user_inventory.order_id IS NULL)", 2) if len(params.ActivityIDs) > 0 { iq = iq.Where("user_inventory.activity_id IN ?", params.ActivityIDs) @@ -97,78 +112,7 @@ func (s *service) queryActivity(ctx context.Context, params ActivityProfitLossPa resultMap[r.ActivityID].Cost += cost } - // Step 3: Points cost scan — link via orders → draw_logs → activity - type activityPointsRow struct { - ActivityID int64 - TotalPoints int64 - } - pq := s.dbR.WithContext(ctx). - Table(model.TableNameUserPointsLedger). - Select("activity_issues.activity_id, SUM(-user_points_ledger.points) as total_points"). - Joins("JOIN orders ON orders.order_no = user_points_ledger.ref_id AND user_points_ledger.ref_table = 'orders'"). - Joins("JOIN activity_draw_logs ON activity_draw_logs.order_id = orders.id"). - Joins("JOIN activity_issues ON activity_issues.id = activity_draw_logs.issue_id"). - Where("user_points_ledger.action = ?", "order_deduct"). - Where("user_points_ledger.points < ?", 0). - Where("orders.status = ?", 2) - if len(params.ActivityIDs) > 0 { - pq = pq.Where("activity_issues.activity_id IN ?", params.ActivityIDs) - } - if params.StartTime != nil { - pq = pq.Where("user_points_ledger.created_at >= ?", *params.StartTime) - } - if params.EndTime != nil { - pq = pq.Where("user_points_ledger.created_at <= ?", *params.EndTime) - } - pq = pq.Group("activity_issues.activity_id") - var pointsRows []activityPointsRow - if err := pq.Scan(&pointsRows).Error; err != nil { - return nil, fmt.Errorf("QueryActivityProfitLoss points cost scan: %w", err) - } - rate := s.getPointsExchangeRate(ctx) - for _, r := range pointsRows { - costCents := points.PointsToCents(r.TotalPoints, float64(rate)) - if _, ok := resultMap[r.ActivityID]; !ok { - resultMap[r.ActivityID] = &ProfitLossDetail{ActivityID: r.ActivityID} - } - resultMap[r.ActivityID].Cost += costCents - } - - // Step 4: Coupon cost scan — link via orders → draw_logs → activity - type activityCouponRow struct { - ActivityID int64 - TotalCost int64 - } - cq := s.dbR.WithContext(ctx). - Table(model.TableNameUserCouponLedger). - Select("activity_issues.activity_id, SUM(-user_coupon_ledger.change_amount) as total_cost"). - Joins("JOIN orders ON orders.id = user_coupon_ledger.order_id"). - Joins("JOIN activity_draw_logs ON activity_draw_logs.order_id = orders.id"). - Joins("JOIN activity_issues ON activity_issues.id = activity_draw_logs.issue_id"). - Where("user_coupon_ledger.change_amount < ?", 0). - Where("orders.status = ?", 2) - if len(params.ActivityIDs) > 0 { - cq = cq.Where("activity_issues.activity_id IN ?", params.ActivityIDs) - } - if params.StartTime != nil { - cq = cq.Where("user_coupon_ledger.created_at >= ?", *params.StartTime) - } - if params.EndTime != nil { - cq = cq.Where("user_coupon_ledger.created_at <= ?", *params.EndTime) - } - cq = cq.Group("activity_issues.activity_id") - var couponRows []activityCouponRow - if err := cq.Scan(&couponRows).Error; err != nil { - return nil, fmt.Errorf("QueryActivityProfitLoss coupon cost scan: %w", err) - } - for _, r := range couponRows { - if _, ok := resultMap[r.ActivityID]; !ok { - resultMap[r.ActivityID] = &ProfitLossDetail{ActivityID: r.ActivityID} - } - resultMap[r.ActivityID].Cost += r.TotalCost - } - - // Step 5: Apply ComputeProfit per detail and aggregate totals + // Step 3: Apply ComputeProfit per detail and aggregate totals details := make([]ProfitLossDetail, 0, len(resultMap)) var totalRevenue, totalCost int64 for _, d := range resultMap { diff --git a/internal/service/finance/query_user.go b/internal/service/finance/query_user.go index 5321e18..5ecd8df 100644 --- a/internal/service/finance/query_user.go +++ b/internal/service/finance/query_user.go @@ -4,13 +4,15 @@ import ( "context" "fmt" - "bindbox-game/internal/pkg/points" "bindbox-game/internal/repository/mysql/model" ) // queryUser implements QueryUserProfitLoss using fan-out + in-memory merge pattern. -// Four independent Scan() calls gather revenue, inventory cost, points cost, -// and coupon cost; results are merged in Go via map[int64]*ProfitLossDetail. +// Two independent Scan() calls gather revenue and inventory cost; +// results are merged in Go via map[int64]*ProfitLossDetail. +// +// Revenue = actual_amount only (real cash). Coupons/points are FREE marketing subsidies. +// Cost = inventory value × item card multiplier only. func (s *service) queryUser(ctx context.Context, params UserProfitLossParams) (*ProfitLossResult, error) { // Step 1: Revenue scan — per-order rows classified in Go type userRevenueRow struct { @@ -19,6 +21,7 @@ func (s *service) queryUser(ctx context.Context, params UserProfitLossParams) (* OrderNo string ActualAmount int64 DiscountAmount int64 + PointsAmount int64 Remark string DrawCount int64 ActivityPrice int64 @@ -27,14 +30,14 @@ func (s *service) queryUser(ctx context.Context, params UserProfitLossParams) (* q := s.dbR.WithContext(ctx). Table(model.TableNameOrders). Select(`orders.user_id, orders.source_type, orders.order_no, - orders.actual_amount, orders.discount_amount, orders.remark, + orders.actual_amount, orders.discount_amount, orders.points_amount, orders.remark, COUNT(activity_draw_logs.id) as draw_count, COALESCE(MAX(activities.price_draw), 0) as activity_price`). Joins(`LEFT JOIN activity_draw_logs ON activity_draw_logs.order_id = orders.id`). Joins(`LEFT JOIN activity_issues ON activity_issues.id = activity_draw_logs.issue_id`). Joins(`LEFT JOIN activities ON activities.id = activity_issues.activity_id`). Where("orders.status = ?", 2). - Group("orders.id, orders.user_id, orders.source_type, orders.order_no, orders.actual_amount, orders.discount_amount, orders.remark") + Group("orders.id, orders.user_id, orders.source_type, orders.order_no, orders.actual_amount, orders.discount_amount, orders.points_amount, orders.remark") if len(params.UserIDs) > 0 { q = q.Where("orders.user_id IN ?", params.UserIDs) } @@ -55,7 +58,16 @@ func (s *service) queryUser(ctx context.Context, params UserProfitLossParams) (* if _, ok := resultMap[r.UserID]; !ok { resultMap[r.UserID] = &ProfitLossDetail{UserID: r.UserID} } - resultMap[r.UserID].Revenue += bd.Total + d := resultMap[r.UserID] + d.Revenue += bd.Total + + // Populate display-only fields + if bd.IsGamePass { + d.GamePassValue += bd.GamePass + } else { + d.CouponDiscount += r.DiscountAmount + d.PointsDiscount += r.PointsAmount + } } // Step 2: Inventory cost scan — apply multiplier in Go (not SQL, for SQLite compat) @@ -67,13 +79,16 @@ func (s *service) queryUser(ctx context.Context, params UserProfitLossParams) (* iq := s.dbR.WithContext(ctx). Table(model.TableNameUserInventory). Select(`user_inventory.user_id, - user_inventory.value_cents, + COALESCE(NULLIF(user_inventory.value_cents, 0), activity_reward_settings.price_snapshot_cents, products.price, 0) as value_cents, COALESCE(system_item_cards.reward_multiplier_x1000, 1000) as multiplier_x1000`). + Joins("LEFT JOIN activity_reward_settings ON activity_reward_settings.id = user_inventory.reward_id"). + Joins("LEFT JOIN products ON products.id = user_inventory.product_id"). Joins("LEFT JOIN orders ON orders.id = user_inventory.order_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("user_inventory.status IN ?", []int{1, 3}). Where("COALESCE(user_inventory.remark, '') NOT LIKE ?", "%void%"). + Where("NOT (user_inventory.status = 3 AND (COALESCE(user_inventory.remark, '') LIKE ? OR COALESCE(user_inventory.remark, '') LIKE ?))", "%redeemed_points%", "%batch_redeemed%"). Where("(orders.status = ? OR user_inventory.order_id = 0 OR user_inventory.order_id IS NULL)", 2) if len(params.UserIDs) > 0 { iq = iq.Where("user_inventory.user_id IN ?", params.UserIDs) @@ -96,72 +111,7 @@ func (s *service) queryUser(ctx context.Context, params UserProfitLossParams) (* resultMap[r.UserID].Cost += cost } - // Step 3: Points cost scan - type userPointsRow struct { - UserID int64 - TotalPoints int64 - } - pq := s.dbR.WithContext(ctx). - Table(model.TableNameUserPointsLedger). - Select("user_id, SUM(-points) as total_points"). - Where("action = ?", "order_deduct"). - Where("points < ?", 0) - if len(params.UserIDs) > 0 { - pq = pq.Where("user_id IN ?", params.UserIDs) - } - if params.StartTime != nil { - pq = pq.Where("created_at >= ?", *params.StartTime) - } - if params.EndTime != nil { - pq = pq.Where("created_at <= ?", *params.EndTime) - } - pq = pq.Group("user_id") - var pointsRows []userPointsRow - if err := pq.Scan(&pointsRows).Error; err != nil { - return nil, fmt.Errorf("QueryUserProfitLoss points cost scan: %w", err) - } - rate := s.getPointsExchangeRate(ctx) - for _, r := range pointsRows { - costCents := points.PointsToCents(r.TotalPoints, float64(rate)) - if _, ok := resultMap[r.UserID]; !ok { - resultMap[r.UserID] = &ProfitLossDetail{UserID: r.UserID} - } - resultMap[r.UserID].Cost += costCents - } - - // Step 4: Coupon cost scan — join to paid orders - type userCouponRow struct { - UserID int64 - TotalCost int64 - } - cq := s.dbR.WithContext(ctx). - Table(model.TableNameUserCouponLedger). - Select("user_coupon_ledger.user_id, SUM(-user_coupon_ledger.change_amount) as total_cost"). - Joins("LEFT JOIN orders ON orders.id = user_coupon_ledger.order_id"). - Where("user_coupon_ledger.change_amount < ?", 0). - Where("orders.status = ?", 2) - if len(params.UserIDs) > 0 { - cq = cq.Where("user_coupon_ledger.user_id IN ?", params.UserIDs) - } - if params.StartTime != nil { - cq = cq.Where("user_coupon_ledger.created_at >= ?", *params.StartTime) - } - if params.EndTime != nil { - cq = cq.Where("user_coupon_ledger.created_at <= ?", *params.EndTime) - } - cq = cq.Group("user_coupon_ledger.user_id") - var couponRows []userCouponRow - if err := cq.Scan(&couponRows).Error; err != nil { - return nil, fmt.Errorf("QueryUserProfitLoss coupon cost scan: %w", err) - } - for _, r := range couponRows { - if _, ok := resultMap[r.UserID]; !ok { - resultMap[r.UserID] = &ProfitLossDetail{UserID: r.UserID} - } - resultMap[r.UserID].Cost += r.TotalCost - } - - // Step 5: Apply ComputeProfit per detail and aggregate totals + // Step 3: Apply ComputeProfit per detail and aggregate totals details := make([]ProfitLossDetail, 0, len(resultMap)) var totalRevenue, totalCost int64 for _, d := range resultMap { @@ -180,22 +130,3 @@ func (s *service) queryUser(ctx context.Context, params UserProfitLossParams) (* Breakdown: []interface{}{}, }, nil } - -// getPointsExchangeRate reads system_configs for the points exchange rate. -// Falls back to 1 (1 yuan = 1 point) on any error. -func (s *service) getPointsExchangeRate(ctx context.Context) int64 { - var cfg struct{ ConfigValue string } - if err := s.dbR.WithContext(ctx). - Table("system_configs"). - Select("config_value"). - Where("config_key = ?", "points.exchange_rate"). - First(&cfg).Error; err != nil { - return 1 - } - var rate int64 - fmt.Sscanf(cfg.ConfigValue, "%d", &rate) - if rate <= 0 { - return 1 - } - return rate -} diff --git a/internal/service/finance/types.go b/internal/service/finance/types.go index cc667b3..015c5b4 100644 --- a/internal/service/finance/types.go +++ b/internal/service/finance/types.go @@ -34,10 +34,16 @@ type ActivityProfitLossParams struct { type ProfitLossDetail struct { UserID int64 // populated for user dimension queries ActivityID int64 // populated for activity dimension queries - Revenue int64 // fen (RET-03: int64 only, no float64 for monetary) - Cost int64 // fen + Revenue int64 // fen — actual_amount only (real cash received) + Cost int64 // fen — inventory value × item card multiplier Profit int64 // fen ProfitRate float64 // ratio; only float64 field for monetary concept + + // Display-only fields — NOT included in Revenue/Cost/Profit calculation. + // These are FREE marketing subsidies the platform gave away. + CouponDiscount int64 // fen — total coupon discount (orders.discount_amount) + PointsDiscount int64 // fen — total points discount (orders.points_amount) + GamePassValue int64 // fen — total game pass value (draw_count × price_draw) } // ProfitLossResult — aggregated P&L result (RET-01) diff --git a/migrations/20260325_fix_matching_gamepass_discount.sql b/migrations/20260325_fix_matching_gamepass_discount.sql new file mode 100644 index 0000000..e780107 --- /dev/null +++ b/migrations/20260325_fix_matching_gamepass_discount.sql @@ -0,0 +1,24 @@ +-- 修复对对碰次数卡订单的幽灵 discount_amount +-- 问题:matching_game_app.go 中次数卡下单时,DiscountAmount 被错误地设为活动全价 +-- 实际上次数卡支付不涉及优惠券,discount_amount 应该为 0 +-- 影响:所有 discount_amount 汇总统计会虚高 +-- 代码修复:matching_game_app.go L175 DiscountAmount: activity.PriceDraw → 0 + +-- Step 1: 先查看受影响的数据量(DRY RUN) +-- SELECT COUNT(*) as affected_count, +-- SUM(discount_amount) as total_phantom_discount_cents, +-- SUM(discount_amount) / 100.0 as total_phantom_discount_yuan +-- FROM orders +-- WHERE source_type = 3 +-- AND actual_amount = 0 +-- AND discount_amount > 0 +-- AND (order_no LIKE 'GP%' OR remark LIKE '%game_pass%'); + +-- Step 2: 执行修复 +UPDATE orders +SET discount_amount = 0, + updated_at = NOW(3) +WHERE source_type = 3 + AND actual_amount = 0 + AND discount_amount > 0 + AND (order_no LIKE 'GP%' OR remark LIKE '%game_pass%');