1. Revenue 口径统一为 actual_amount(真实现金到账)
- 优惠券(discount_amount)和积分(points_amount)是平台免费发放的营销补贴,
不算收入,改为展示字段
- 涉及: profit_metrics.go, dashboard_spending.go, users_profit_loss.go,
dashboard_user_spending.go, activity_rankings_admin.go
2. Cost 口径统一为奖品库存价值
- 删除 finance service 中的积分成本扫描(Step 3)和优惠券成本扫描(Step 4)
- 之前优惠券同时算在收入和成本两侧,导致利润被人为压低
- 涉及: query_user.go, query_activity.go
3. 统一 value_cents fallback chain
- finance service 改为与排行榜一致的三级回退:
COALESCE(NULLIF(value_cents,0), price_snapshot_cents, products.price, 0)
- 涉及: query_user.go, query_activity.go
4. 活动盈亏收入统一到 finance service
- 删除 dashboard_activity.go 自有的 revenue SQL(含比例分摊逻辑)
- 收入和成本统一由 finance.Service.QueryActivityProfitLoss() 提供
- 修复日志明细 profit:道具卡倍率改用 ComputePrizeCostWithMultiplier
5. finance service 新增展示字段
- ProfitLossDetail 增加 CouponDiscount, PointsDiscount, GamePassValue
- 不参与 Revenue/Cost/Profit 计算,仅供前端展示营销补贴明细
6. 修复对对碰次卡订单 discount_amount 数据污染
- matching_game_app.go 次卡下单时 DiscountAmount 错误设为活动全价
- 改为 0(次卡支付不涉及优惠券)
- 附带历史数据修复 migration SQL
7. 排除已分解奖品的成本重复计算
- 用户可以把奖品分解成积分再兑换新商品,导致同一份价值被计算两次
- 所有库存查询增加排除条件: status=3 且 remark 含 redeemed_points 或 batch_redeemed
- 涉及 6 个文件的库存成本/资产查询
8. 排行榜详情抽屉限定活动范围
- prize 查询增加 activity_id > 0 过滤,排除积分兑换/转入/合成等非活动产出
- 使排行榜与其详情抽屉口径一致
修改文件(12个):
- internal/service/finance/profit_metrics.go
- internal/service/finance/query_user.go
- internal/service/finance/query_activity.go
- internal/service/finance/types.go
- internal/api/admin/dashboard_activity.go
- internal/api/admin/dashboard_spending.go
- internal/api/admin/dashboard_user_spending.go
- internal/api/admin/users_profit_loss.go
- internal/api/admin/users_profile.go
- internal/api/admin/activity_rankings_admin.go
- internal/api/activity/matching_game_app.go
- migrations/20260325_fix_matching_gamepass_discount.sql
670 lines
24 KiB
Go
Executable File
670 lines
24 KiB
Go
Executable File
package admin
|
||
|
||
import (
|
||
"encoding/json"
|
||
"fmt"
|
||
"net/http"
|
||
"sort"
|
||
"strconv"
|
||
"strings"
|
||
"time"
|
||
|
||
"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"
|
||
|
||
"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. 从 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 error: %v", financeErr))
|
||
}
|
||
if financeResult != nil {
|
||
for _, d := range financeResult.Details {
|
||
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
|
||
}
|
||
}
|
||
}
|
||
|
||
// 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.SpendingGamePass = item.TotalGamePassValue
|
||
item.Profit, item.ProfitRate = financesvc.ComputeProfit(item.TotalRevenue, 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 计算后设置
|
||
isGamePassOrder := financesvc.IsGamePassOrder(l.SourceType, l.OrderNo, l.OrderAmount, l.OrderRemark)
|
||
|
||
// 检查是否使用了优惠券
|
||
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/order_no/remark 三条件)
|
||
if isGamePassOrder {
|
||
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 // actual_amount 分摊(现金)
|
||
perDrawDiscountAmount := l.DiscountAmount / drawCount // 展示用
|
||
perDrawPointsAmount := l.PointsAmount / drawCount // 展示用
|
||
|
||
// 次卡单口径:仅记次卡价值,不再叠加 discount,避免”次卡+现金”双计
|
||
if isGamePassOrder {
|
||
if l.ActivityPrice > 0 {
|
||
perDrawOrderAmount = l.ActivityPrice
|
||
}
|
||
perDrawDiscountAmount = 0
|
||
perDrawPointsAmount = 0
|
||
}
|
||
|
||
// 设置支付详情中的分摊金额
|
||
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,
|
||
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 - prizeCost, // 单次盈亏 = 现金收入 - 奖品成本(含倍率)
|
||
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, like}
|
||
condition := "(products.name LIKE ? OR CAST(products.id AS CHAR) 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})
|
||
}
|
||
}
|