bindbox-game/internal/api/admin/dashboard_activity.go
Zuncle dd1034dda8 fix(finance): correct activity profit-loss cost aggregation
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.
2026-04-02 22:27:45 +08:00

748 lines
27 KiB
Go
Executable File
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package admin
import (
"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})
}
}