Use handler-specific activity profit/loss rules for the dashboard endpoints, counting cash, coupons, and game pass value against product cost_price. Fix missing extra reward cost aggregation and align activity logs with the same profit calculation semantics.
748 lines
27 KiB
Go
Executable File
748 lines
27 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"
|
||
)
|
||
|
||
func computeActivityProfit(spending, cost int64) (int64, float64) {
|
||
profit := spending - cost
|
||
if spending <= 0 {
|
||
return profit, 0
|
||
}
|
||
return profit, float64(profit) / float64(spending)
|
||
}
|
||
|
||
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%'
|
||
OR (orders.actual_amount = 0 AND COALESCE(orders.remark, '') LIKE '%use_game_pass%')
|
||
) 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%'
|
||
OR (orders.actual_amount = 0 AND COALESCE(orders.remark, '') LIKE '%use_game_pass%')
|
||
) 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. 按活动汇总收入(现金/优惠券/次卡)
|
||
type activityRevenueStat struct {
|
||
ActivityID int64
|
||
SourceType int32
|
||
OrderNo string
|
||
OrderAmount int64
|
||
DiscountAmount int64
|
||
OrderRemark string
|
||
DrawCount int64
|
||
ActivityPrice int64
|
||
}
|
||
var revenueStats []activityRevenueStat
|
||
db.Table(model.TableNameOrders).
|
||
Select(`
|
||
activity_issues.activity_id,
|
||
orders.source_type,
|
||
orders.order_no,
|
||
COALESCE(orders.actual_amount, 0) as order_amount,
|
||
COALESCE(orders.discount_amount, 0) as discount_amount,
|
||
COALESCE(orders.remark, '') as order_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("JOIN activities ON activities.id = activity_issues.activity_id").
|
||
Where("orders.status = ?", 2).
|
||
Where("activity_issues.activity_id IN ?", activityIDs).
|
||
Group("orders.id, activity_issues.activity_id, orders.source_type, orders.order_no, orders.actual_amount, orders.discount_amount, orders.remark").
|
||
Scan(&revenueStats)
|
||
|
||
for _, stat := range revenueStats {
|
||
item, ok := activityMap[stat.ActivityID]
|
||
if !ok {
|
||
continue
|
||
}
|
||
if financesvc.IsGamePassOrder(stat.SourceType, stat.OrderNo, stat.OrderAmount, stat.OrderRemark) {
|
||
item.TotalGamePassValue += stat.DrawCount * stat.ActivityPrice
|
||
continue
|
||
}
|
||
item.TotalRevenue += stat.OrderAmount
|
||
item.TotalDiscount += stat.DiscountAmount
|
||
}
|
||
|
||
// 4. 按活动汇总产出成本,统一使用 products.cost_price
|
||
type activityCostStat struct {
|
||
ActivityID int64
|
||
TotalCost int64
|
||
}
|
||
var costStats []activityCostStat
|
||
db.Table(model.TableNameActivityDrawLogs).
|
||
Select(`
|
||
activity_issues.activity_id,
|
||
SUM(COALESCE(products.cost_price, 0) * (
|
||
COALESCE(NULLIF(activity_reward_settings.drop_quantity, 0), 1) +
|
||
CASE WHEN user_item_cards.used_draw_log_id = activity_draw_logs.id AND system_item_cards.effect_type = 1 AND system_item_cards.reward_multiplier_x1000 >= 2000 THEN 1 ELSE 0 END
|
||
)) as total_cost
|
||
`).
|
||
Joins("JOIN activity_issues ON activity_issues.id = activity_draw_logs.issue_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_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("activity_issues.activity_id IN ?", activityIDs).
|
||
Where("orders.status = ?", 2).
|
||
Group("activity_issues.activity_id").
|
||
Scan(&costStats)
|
||
|
||
for _, stat := range costStats {
|
||
if item, ok := activityMap[stat.ActivityID]; ok {
|
||
item.TotalCost = stat.TotalCost
|
||
item.PrizeCostBase = stat.TotalCost
|
||
item.PrizeCostMultiplier = 1000
|
||
item.PrizeCostFinal = stat.TotalCost
|
||
}
|
||
}
|
||
|
||
// 5. 计算盈亏和比率
|
||
finalList := make([]activityProfitLossItem, 0, len(activities))
|
||
for _, a := range activities {
|
||
item := activityMap[a.ID]
|
||
item.SpendingPaidCoupon = item.TotalRevenue + item.TotalDiscount
|
||
item.SpendingGamePass = item.TotalGamePassValue
|
||
spending := item.TotalRevenue + item.TotalDiscount + item.TotalGamePassValue
|
||
item.Profit, item.ProfitRate = computeActivityProfit(spending, 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
|
||
ProductCost int64
|
||
OrderAmount int64
|
||
DiscountAmount int64
|
||
PointsAmount int64 // 积分抵扣金额
|
||
OrderStatus int32 // 订单状态
|
||
SourceType int32
|
||
CouponID int64
|
||
CouponName string
|
||
ItemCardID int64
|
||
ItemCardName string
|
||
EffectType int32
|
||
Multiplier int32
|
||
DropQuantity int64
|
||
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(products.cost_price, 0) as product_cost,
|
||
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(NULLIF(activity_reward_settings.drop_quantity, 0), 1) as drop_quantity,
|
||
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]
|
||
}
|
||
|
||
quantity := l.DropQuantity
|
||
if quantity <= 0 {
|
||
quantity = 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 >= 2000 {
|
||
quantity++
|
||
}
|
||
}
|
||
|
||
// 检查是否使用了次数卡(统一口径: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 // 展示用
|
||
perDrawGamePassAmount := int64(0)
|
||
|
||
// 次卡单口径:仅记次卡价值,不再叠加 discount,避免”次卡+现金”双计
|
||
if isGamePassOrder {
|
||
perDrawOrderAmount = 0
|
||
perDrawDiscountAmount = 0
|
||
perDrawPointsAmount = 0
|
||
if l.ActivityPrice > 0 {
|
||
perDrawGamePassAmount = l.ActivityPrice
|
||
}
|
||
}
|
||
|
||
// 设置支付详情中的分摊金额
|
||
paymentDetails.CouponDiscount = perDrawDiscountAmount
|
||
paymentDetails.PointsDiscount = perDrawPointsAmount
|
||
|
||
prizeCost := l.ProductCost * quantity
|
||
profitSpending := perDrawOrderAmount + perDrawDiscountAmount + perDrawGamePassAmount
|
||
|
||
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.ProductCost,
|
||
ProductQuantity: quantity,
|
||
OrderAmount: perDrawOrderAmount + perDrawGamePassAmount,
|
||
OrderNo: l.OrderNo,
|
||
DiscountAmount: perDrawDiscountAmount,
|
||
PayType: payType,
|
||
UsedCard: usedCard,
|
||
OrderStatus: l.OrderStatus,
|
||
Profit: profitSpending - 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})
|
||
}
|
||
}
|