Compare commits

..

No commits in common. "4353b0f053733452f8bdbe001d26aeaa72ec9af3" and "be245c14765c6a21fa6f70340088b5c42bf2ebf4" have entirely different histories.

17 changed files with 319 additions and 185 deletions

View File

@ -172,7 +172,7 @@ func (h *handler) PreOrderMatchingGame() core.HandlerFunc {
SourceType: 3, // 对对碰
TotalAmount: activity.PriceDraw,
ActualAmount: 0, // 次数卡抵扣实付0元
DiscountAmount: 0, // 次数卡支付,无优惠券抵扣
DiscountAmount: activity.PriceDraw,
Status: 2, // 已支付
Remark: func() string {
r := fmt.Sprintf("activity:%d|game_pass:%d|matching_game:issue:%d", activity.ID, validPass.ID, req.IssueID)

View File

@ -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) AS SIGNED) AS total_amount,
CAST(SUM(orders.actual_amount + orders.discount_amount) AS SIGNED) AS total_amount,
COUNT(DISTINCT orders.id) AS order_count
`).
Group("orders.user_id, users.nickname, users.avatar")

View File

@ -168,38 +168,126 @@ func (h *handler) DashboardActivityProfitLoss() core.HandlerFunc {
}
}
// 3. 从 finance.Service 统一获取收入、成本和展示字段
// Revenue = actual_amount (真实现金)
// Cost = inventory value × item card multiplier
// 展示字段: CouponDiscount, PointsDiscount, GamePassValue
// 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
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))
h.logger.Error(fmt.Sprintf("GetActivityProfitLoss finance cost error: %v", financeErr))
}
// 按 activity_id 建立 cost 索引
financeCostMap := make(map[int64]int64)
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
}
financeCostMap[d.ActivityID] = d.Cost
}
}
for actID, item := range activityMap {
if cost, ok := financeCostMap[actID]; ok {
item.TotalCost = cost
item.PrizeCostFinal = cost
}
}
// 4. 计算盈亏和比率
// Revenue = 现金到账, Cost = 奖品成本
// Profit = Revenue - Cost (优惠券/积分/次卡不参与利润计算)
// 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.Servicevalue_cents 单一真相源 + 道具卡倍率)
// 收入来自原有 scan保留 total_discount / total_game_pass_value 拆分字段)
finalList := make([]activityProfitLossItem, 0, len(activities))
for _, a := range activities {
item := activityMap[a.ID]
item.SpendingPaidCoupon = item.TotalRevenue
item.SpendingPaidCoupon = item.TotalRevenue + item.TotalDiscount
item.SpendingGamePass = item.TotalGamePassValue
item.Profit, item.ProfitRate = financesvc.ComputeProfit(item.TotalRevenue, item.TotalCost)
totalIncome := item.SpendingPaidCoupon + item.SpendingGamePass
item.Profit, item.ProfitRate = financesvc.ComputeProfit(totalIncome, item.TotalCost)
finalList = append(finalList, *item)
}
@ -521,11 +609,11 @@ func (h *handler) DashboardActivityLogs() core.HandlerFunc {
if drawCount <= 0 {
drawCount = 1
}
perDrawOrderAmount := l.OrderAmount / drawCount // actual_amount 分摊(现金)
perDrawDiscountAmount := l.DiscountAmount / drawCount // 展示用
perDrawPointsAmount := l.PointsAmount / drawCount // 展示用
perDrawOrderAmount := l.OrderAmount / drawCount
perDrawDiscountAmount := l.DiscountAmount / drawCount
perDrawPointsAmount := l.PointsAmount / drawCount
// 次卡单口径:仅记次卡价值,不再叠加 discount避免次卡+现金”双计
// 次卡单口径:仅记次卡价值,不再叠加 discount避免次卡+现金”双计
if isGamePassOrder {
if l.ActivityPrice > 0 {
perDrawOrderAmount = l.ActivityPrice
@ -538,9 +626,6 @@ 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,
@ -551,13 +636,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 - prizeCost, // 单次盈亏 = 现金收入 - 奖品成本(含倍率)
Profit: perDrawOrderAmount + perDrawDiscountAmount - l.ProductPrice*quantity, // 单次盈亏 = 分摊收入 - 成本*数量
CreatedAt: l.CreatedAt,
PaymentDetails: paymentDetails,
}

View File

@ -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
END) as total_amount,
ELSE orders.actual_amount + orders.discount_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
ELSE orders.actual_amount + orders.discount_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
ELSE orders.actual_amount + orders.discount_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
ELSE orders.actual_amount + orders.discount_amount
END
ELSE 0 END) as matching_spending,
SUM(CASE WHEN oa.category_id = 3 THEN 1 ELSE 0 END) as matching_count,
@ -261,10 +261,9 @@ 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) and redeemed-to-points (3+redeemed).
// Only include Holding (1) and Shipped/Used (3) items. Exclude Void/Decomposed (2).
query = query.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("COALESCE(user_inventory.remark, '') NOT LIKE ?", "%void%")
err := query.Select(`
user_inventory.user_id,
@ -340,8 +339,7 @@ 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("NOT (user_inventory.status = 3 AND (COALESCE(user_inventory.remark, '') LIKE ? OR COALESCE(user_inventory.remark, '') LIKE ?))", "%redeemed_points%", "%batch_redeemed%")
Where("COALESCE(user_inventory.remark, '') NOT LIKE ?", "%void%")
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))

View File

@ -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 * order_activity_draws.draw_count / NULLIF(order_total_draws.total_count, 0), 0)
ELSE COALESCE((orders.actual_amount + orders.discount_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,9 +143,7 @@ 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("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")
Where("COALESCE(user_inventory.remark, '') NOT LIKE ?", "%void%")
if hasRange {
prizeQuery = prizeQuery.Where("user_inventory.created_at >= ?", start).Where("user_inventory.created_at <= ?", end)
@ -245,8 +243,7 @@ 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("NOT (user_inventory.status = 3 AND (COALESCE(user_inventory.remark, '') LIKE ? OR COALESCE(user_inventory.remark, '') LIKE ?))", "%redeemed_points%", "%batch_redeemed%")
Where("COALESCE(user_inventory.remark, '') NOT LIKE ?", "%void%")
if hasRange {
invQ = invQ.Where("created_at >= ?", start.Add(-2*time.Hour)).Where("created_at <= ?", end.Add(24*time.Hour))
}

View File

@ -209,7 +209,6 @@ 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

View File

@ -100,7 +100,6 @@ 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
@ -113,7 +112,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
ELSE o.actual_amount + o.discount_amount
END), 0)
FROM orders o
LEFT JOIN (
@ -149,7 +148,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
ELSE o.actual_amount + o.discount_amount
END as spending
FROM orders o
LEFT JOIN (
@ -231,7 +230,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
ELSE o.actual_amount + o.discount_amount
END), 0)
FROM orders o
LEFT JOIN (
@ -506,7 +505,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
ELSE o.actual_amount + o.discount_amount
END as spending
FROM orders o
LEFT JOIN (
@ -533,7 +532,7 @@ func (h *handler) GetUserProfitLossDetails() core.HandlerFunc {
couponValue := couponValueMap[o.ID]
spending := orderSpendingMap[o.ID]
if spending == 0 {
spending = o.ActualAmount
spending = o.ActualAmount + o.DiscountAmount
}
netCost := spending - refund
if netCost < 0 {

View File

@ -121,11 +121,7 @@ type getAppProductDetailResponse struct {
func (h *productHandler) GetProductDetailForApp() core.HandlerFunc {
return func(ctx core.Context) {
idStr := ctx.Param("id")
id, err := strconv.ParseInt(idStr, 10, 64)
if err != nil || id <= 0 {
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, "无效的商品ID"))
return
}
id, _ := strconv.ParseInt(idStr, 10, 64)
d, err := h.product.GetDetailForApp(ctx.RequestContext(), id)
if err != nil {
if err.Error() == "PRODUCT_OFFSHELF" {
@ -136,7 +132,7 @@ func (h *productHandler) GetProductDetailForApp() core.HandlerFunc {
ctx.AbortWithError(core.Error(http.StatusOK, 20002, "商品缺货"))
return
}
ctx.AbortWithError(core.Error(http.StatusInternalServerError, code.ServerError, "商品信息获取失败"))
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ServerError, validation.Error(err)))
return
}
ptsDetail := h.user.CentsToPointsFloat(ctx.RequestContext(), d.Price)

View File

@ -17,7 +17,7 @@ type requestShippingResponse struct {
// RequestShipping 申请发货(使用默认地址)
// @Summary 申请发货
// @Description 用户已有默认地址时,申请对指定资产发货;单件发货需先支付运费;幂等校验已存在发货记录时返回已处理
// @Description 用户已有默认地址时,申请对指定资产发货;幂等校验已存在发货记录时返回已处理
// @Tags APP端.用户
// @Accept json
// @Produce json
@ -36,19 +36,6 @@ func (h *handler) RequestShipping() core.HandlerFunc {
return
}
userID := int64(ctx.SessionUserInfo().Id)
// 运费校验:单件发货一定 < 5 件,须已支付运费订单
paid, _ := h.readDB.Orders.WithContext(ctx.RequestContext()).
Where(
h.readDB.Orders.UserID.Eq(userID),
h.readDB.Orders.SourceType.Eq(shippingFeeSourceType),
h.readDB.Orders.Status.Eq(2),
).Count()
if paid == 0 {
ctx.AbortWithError(core.Error(http.StatusBadRequest, 150003, "不满5件需先支付运费"))
return
}
addrID, err := h.user.RequestShipping(ctx.RequestContext(), userID, req.InventoryID)
if err != nil {
ctx.AbortWithError(core.Error(http.StatusBadRequest, 10022, err.Error()))

View File

@ -22,7 +22,7 @@ type requestShippingBatchResponse struct {
// RequestShippingBatch 批量申请发货(使用默认地址或指定地址)
// @Summary 批量申请发货
// @Description 为多个资产申请发货,校验所有权与状态;不满 5 件需先支付运费;幂等:已申请的资产跳过
// @Description 为多个资产申请发货,校验所有权与状态;幂等:已申请的资产跳过
// @Tags APP端.用户
// @Accept json
// @Produce json
@ -45,21 +45,6 @@ func (h *handler) RequestShippingBatch() core.HandlerFunc {
return
}
userID := int64(ctx.SessionUserInfo().Id)
// 运费校验:不满 5 件须已支付运费订单
if len(req.InventoryIDs) < shippingFeeThreshold {
paid, _ := h.readDB.Orders.WithContext(ctx.RequestContext()).
Where(
h.readDB.Orders.UserID.Eq(userID),
h.readDB.Orders.SourceType.Eq(shippingFeeSourceType),
h.readDB.Orders.Status.Eq(2),
).Count()
if paid == 0 {
ctx.AbortWithError(core.Error(http.StatusBadRequest, 150003, "不满5件需先支付运费"))
return
}
}
addrID, batchNo, success, skipped, failed, err := h.user.RequestShippings(ctx.RequestContext(), userID, req.InventoryIDs, req.AddressID)
if err != nil {
ctx.AbortWithError(core.Error(http.StatusBadRequest, 10023, err.Error()))

View File

@ -1,7 +1,6 @@
package app
import (
"encoding/json"
"fmt"
"net/http"
"time"
@ -13,7 +12,7 @@ import (
)
const (
shippingFeeThreshold = 5 // 低于此件数收运费
shippingFeeThreshold = 5 // 超过此件数收运费
shippingFeeCents = 1000 // 运费金额10 元
shippingFeeSourceType = int32(5) // orders.source_type: 5 = 运费订单
)
@ -28,7 +27,7 @@ type shippingFeePreorderResponse struct {
// ShippingFeePreorder 创建运费订单
// @Summary 创建运费订单
// @Description 选中件数不满 5 件时,创建 10 元运费订单并返回 order_no前端再调用 /pay/wechat/jsapi/preorder 发起支付;满 5 件包邮无需调用
// @Description 选中件数超过 5 件时,创建 10 元运费订单并返回 order_no前端再调用 /pay/wechat/jsapi/preorder 发起支付
// @Tags APP端.用户
// @Accept json
// @Produce json
@ -50,15 +49,13 @@ func (h *handler) ShippingFeePreorder() core.HandlerFunc {
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, "inventory_ids 不能为空"))
return
}
if len(req.InventoryIDs) >= shippingFeeThreshold {
ctx.AbortWithError(core.Error(http.StatusBadRequest, 150001, fmt.Sprintf("件数 %d 件,无需支付运费", shippingFeeThreshold)))
if len(req.InventoryIDs) <= shippingFeeThreshold {
ctx.AbortWithError(core.Error(http.StatusBadRequest, 150001, fmt.Sprintf("件数不超过 %d 件,无需支付运费", shippingFeeThreshold)))
return
}
userID := int64(ctx.SessionUserInfo().Id)
remarkBytes, _ := json.Marshal(req.InventoryIDs)
// 生成运费订单号SF + 时间戳 + 用户ID后6位保证唯一
now := time.Now()
orderNo := fmt.Sprintf("SF%s%06d", now.Format("20060102150405"), userID%1000000)
@ -74,7 +71,6 @@ func (h *handler) ShippingFeePreorder() core.HandlerFunc {
ActualAmount: shippingFeeCents,
Status: 1,
IsConsumed: 0,
Remark: string(remarkBytes),
}
if err := h.writeDB.Orders.WithContext(ctx.RequestContext()).
Omit(h.writeDB.Orders.PaidAt, h.writeDB.Orders.CancelledAt).

View File

@ -462,7 +462,6 @@ func NewHTTPMux(logger logger.CustomLogger, db mysql.Repo) (core.Mux, func(), er
// 商城浏览(无需登录)
appPublicApiRouter.GET("/store/items", appapi.NewStore(logger, db, userSvc).ListStoreItemsForApp())
appPublicApiRouter.GET("/product_categories", appapi.NewProductCategory(logger, db).ListProductCategoriesForApp())
appPublicApiRouter.GET("/products/:id", appapi.NewProduct(logger, db, userSvc).GetProductDetailForApp())
}
// 公开接口路由组 (无需登录)
@ -515,6 +514,7 @@ func NewHTTPMux(logger logger.CustomLogger, db mysql.Repo) (core.Mux, func(), er
appAuthApiRouter.GET("/orders/:order_id", userHandler.GetOrderDetail())
appAuthApiRouter.POST("/orders/:order_id/cancel", userHandler.CancelOrder())
appAuthApiRouter.GET("/products", appapi.NewProduct(logger, db, userSvc).ListProductsForApp())
appAuthApiRouter.GET("/products/:id", appapi.NewProduct(logger, db, userSvc).GetProductDetailForApp())
appAuthApiRouter.GET("/lottery/result", activityHandler.LotteryResultByOrder())
// 需要黑名单检查的抽奖接口组

View File

@ -13,10 +13,7 @@ type SpendingBreakdown struct {
// ClassifyOrderSpending applies the unified rule:
// - game pass order: spending = game pass value
// - 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.
// - normal order: spending = actual + discount
func ClassifyOrderSpending(sourceType int32, orderNo string, actualAmount, discountAmount int64, remark string, gamePassValue int64) SpendingBreakdown {
isGamePass := IsGamePassOrder(sourceType, orderNo, actualAmount, remark)
if isGamePass {
@ -31,14 +28,14 @@ func ClassifyOrderSpending(sourceType int32, orderNo string, actualAmount, disco
}
}
cashRevenue := actualAmount
if cashRevenue < 0 {
cashRevenue = 0
paidCoupon := actualAmount + discountAmount
if paidCoupon < 0 {
paidCoupon = 0
}
return SpendingBreakdown{
PaidCoupon: cashRevenue,
PaidCoupon: paidCoupon,
GamePass: 0,
Total: cashRevenue,
Total: paidCoupon,
IsGamePass: false,
}
}

View File

@ -4,15 +4,13 @@ import (
"context"
"fmt"
"bindbox-game/internal/pkg/points"
"bindbox-game/internal/repository/mysql/model"
)
// queryActivity implements QueryActivityProfitLoss using fan-out + in-memory merge.
// 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.
// Four independent Scan() calls gather revenue, inventory cost, points cost,
// and coupon cost attributed to activity dimension; results merged in Go.
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 {
@ -21,7 +19,6 @@ func (s *service) queryActivity(ctx context.Context, params ActivityProfitLossPa
OrderNo string
ActualAmount int64
DiscountAmount int64
PointsAmount int64
Remark string
DrawCount int64
ActivityPrice int64
@ -31,14 +28,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.points_amount, orders.remark,
orders.actual_amount, orders.discount_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.points_amount, orders.remark")
Group("orders.id, activity_issues.activity_id, orders.source_type, orders.order_no, orders.actual_amount, orders.discount_amount, orders.remark")
if len(params.ActivityIDs) > 0 {
q = q.Where("activity_issues.activity_id IN ?", params.ActivityIDs)
}
@ -59,16 +56,7 @@ func (s *service) queryActivity(ctx context.Context, params ActivityProfitLossPa
if _, ok := resultMap[r.ActivityID]; !ok {
resultMap[r.ActivityID] = &ProfitLossDetail{ActivityID: r.ActivityID}
}
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
}
resultMap[r.ActivityID].Revenue += bd.Total
}
// Step 2: Inventory cost scan — grouped by activity_id, multiplier applied in Go
@ -80,16 +68,13 @@ func (s *service) queryActivity(ctx context.Context, params ActivityProfitLossPa
iq := s.dbR.WithContext(ctx).
Table(model.TableNameUserInventory).
Select(`user_inventory.activity_id,
COALESCE(NULLIF(user_inventory.value_cents, 0), activity_reward_settings.price_snapshot_cents, products.price, 0) as value_cents,
user_inventory.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)
@ -112,7 +97,78 @@ func (s *service) queryActivity(ctx context.Context, params ActivityProfitLossPa
resultMap[r.ActivityID].Cost += cost
}
// Step 3: Apply ComputeProfit per detail and aggregate totals
// 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
details := make([]ProfitLossDetail, 0, len(resultMap))
var totalRevenue, totalCost int64
for _, d := range resultMap {

View File

@ -4,15 +4,13 @@ import (
"context"
"fmt"
"bindbox-game/internal/pkg/points"
"bindbox-game/internal/repository/mysql/model"
)
// queryUser implements QueryUserProfitLoss using fan-out + in-memory merge pattern.
// 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.
// Four independent Scan() calls gather revenue, inventory cost, points cost,
// and coupon cost; results are merged in Go via map[int64]*ProfitLossDetail.
func (s *service) queryUser(ctx context.Context, params UserProfitLossParams) (*ProfitLossResult, error) {
// Step 1: Revenue scan — per-order rows classified in Go
type userRevenueRow struct {
@ -21,7 +19,6 @@ func (s *service) queryUser(ctx context.Context, params UserProfitLossParams) (*
OrderNo string
ActualAmount int64
DiscountAmount int64
PointsAmount int64
Remark string
DrawCount int64
ActivityPrice int64
@ -30,14 +27,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.points_amount, orders.remark,
orders.actual_amount, orders.discount_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.points_amount, orders.remark")
Group("orders.id, orders.user_id, orders.source_type, orders.order_no, orders.actual_amount, orders.discount_amount, orders.remark")
if len(params.UserIDs) > 0 {
q = q.Where("orders.user_id IN ?", params.UserIDs)
}
@ -58,16 +55,7 @@ func (s *service) queryUser(ctx context.Context, params UserProfitLossParams) (*
if _, ok := resultMap[r.UserID]; !ok {
resultMap[r.UserID] = &ProfitLossDetail{UserID: r.UserID}
}
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
}
resultMap[r.UserID].Revenue += bd.Total
}
// Step 2: Inventory cost scan — apply multiplier in Go (not SQL, for SQLite compat)
@ -79,16 +67,13 @@ func (s *service) queryUser(ctx context.Context, params UserProfitLossParams) (*
iq := s.dbR.WithContext(ctx).
Table(model.TableNameUserInventory).
Select(`user_inventory.user_id,
COALESCE(NULLIF(user_inventory.value_cents, 0), activity_reward_settings.price_snapshot_cents, products.price, 0) as value_cents,
user_inventory.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)
@ -111,7 +96,72 @@ func (s *service) queryUser(ctx context.Context, params UserProfitLossParams) (*
resultMap[r.UserID].Cost += cost
}
// Step 3: Apply ComputeProfit per detail and aggregate totals
// 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
details := make([]ProfitLossDetail, 0, len(resultMap))
var totalRevenue, totalCost int64
for _, d := range resultMap {
@ -130,3 +180,22 @@ 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
}

View File

@ -34,16 +34,10 @@ type ActivityProfitLossParams struct {
type ProfitLossDetail struct {
UserID int64 // populated for user dimension queries
ActivityID int64 // populated for activity dimension queries
Revenue int64 // fen — actual_amount only (real cash received)
Cost int64 // fen — inventory value × item card multiplier
Revenue int64 // fen (RET-03: int64 only, no float64 for monetary)
Cost int64 // fen
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)

View File

@ -1,24 +0,0 @@
-- 修复对对碰次数卡订单的幽灵 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%');