Compare commits

..

4 Commits

48 changed files with 3316 additions and 829 deletions

View File

@ -0,0 +1,139 @@
package main
import (
"context"
"errors"
"flag"
"fmt"
"os"
"time"
"bindbox-game/configs"
"bindbox-game/internal/pkg/env"
"bindbox-game/internal/pkg/logger"
"bindbox-game/internal/repository/mysql"
"bindbox-game/internal/repository/mysql/model"
douyin "bindbox-game/internal/service/douyin"
)
// staticSyscfg implements sysconfig.Service with fixed cookie
type staticSyscfg struct {
cookie string
}
func (s *staticSyscfg) GetByKey(ctx context.Context, key string) (*model.SystemConfigs, error) {
switch key {
case douyin.ConfigKeyDouyinCookie:
if s.cookie == "" {
return nil, errors.New("douyin cookie 未设置")
}
return &model.SystemConfigs{ConfigKey: key, ConfigValue: s.cookie}, nil
case douyin.ConfigKeyDouyinInterval:
return &model.SystemConfigs{ConfigKey: key, ConfigValue: "5"}, nil
default:
return nil, errors.New("暂不支持的配置 key: " + key)
}
}
func (s *staticSyscfg) UpsertByKey(ctx context.Context, key string, value string, remark string) (*model.SystemConfigs, error) {
return nil, errors.New("UpsertByKey 未实现")
}
func (s *staticSyscfg) ModifyByID(ctx context.Context, id int64, value *string, remark *string) error {
return errors.New("ModifyByID 未实现")
}
func (s *staticSyscfg) DeleteByID(ctx context.Context, id int64) error {
return errors.New("DeleteByID 未实现")
}
func (s *staticSyscfg) List(ctx context.Context, page int, pageSize int, keyword string) (items []*model.SystemConfigs, total int64, err error) {
return nil, 0, errors.New("List 未实现")
}
func main() {
minutes := flag.Int("minutes", 10, "同步最近多少分钟的订单")
useProxy := flag.Bool("proxy", false, "是否使用服务内置代理")
printLimit := flag.Int("print", 10, "同步后打印多少条订单 (0 表示不打印)")
mode := flag.String("mode", "sync-all", "同步模式: sync-all(默认增量)/fetch(按绑定用户)")
grantMinesweeper := flag.Bool("grant-minesweeper", false, "同步后执行 GrantMinesweeperQualifications")
fetchOnlyUnmatched := flag.Bool("fetch-only-unmatched", true, "按用户同步时是否仅同步未匹配订单的用户")
fetchMaxUsers := flag.Int("fetch-max-users", 200, "按用户同步时最多处理的用户数量 (50-1000)")
fetchBatchSize := flag.Int("fetch-batch-size", 20, "按用户同步时的单批次用户数量 (5-50)")
fetchConcurrency := flag.Int("fetch-concurrency", 5, "按用户同步时的并发抓取数 (<=批次大小)")
fetchDelay := flag.Int("fetch-delay-ms", 200, "批次之间的停顿时间 (毫秒)")
flag.Parse()
env.Active() // 初始化 env flag依赖已有的全局 -env/ACTIVE_ENV 配置)
configs.Init()
cookie := "passport_csrf_token=40ba4a1be914a9f167320ed28b8c93d7; passport_csrf_token_default=40ba4a1be914a9f167320ed28b8c93d7; is_staff_user=false; s_v_web_id=verify_mkf83bbo_zfQ3q1Gp_5irf_4OOI_9y4N_C253269yUIJy; SHOP_ID=156231010; PIGEON_CID=4339134776748827; __security_mc_1_s_sdk_crypt_sdk=db47f387-4d0b-bf21; bd_ticket_guard_client_web_domain=2; bd_ticket_guard_client_data=eyJiZC10aWNrZXQtZ3VhcmQtdmVyc2lvbiI6MiwiYmQtdGlja2V0LWd1YXJkLWl0ZXJhdGlvbi12ZXJzaW9uIjoxLCJiZC10aWNrZXQtZ3VhcmQtcmVlLXB1YmxpYy1rZXkiOiJCTHVTREdkVFRHWUdNMVY3ZDZKS2M4V2FwWGJ1K3JVYmVqRThONTZoeTI4SUJXdmVxZjBLMS9GczE0dWx5RTVRd2d4cjdnaDd6SXdMZjlsWDkwOFZQQWs9IiwiYmQtdGlja2V0LWd1YXJkLXdlYi12ZXJzaW9uIjoyfQ%3D%3D; bd_ticket_guard_web_domain=3; zsgw_business_data=%7B%22uuid%22%3A%226756720f-c380-4bda-ab81-3dd27ca08a2d%22%2C%22platform%22%3A%22pc%22%2C%22source%22%3A%22seo.fxg.jinritemai.com%22%7D; gfkadpd=4272,23756; ecom_gray_shop_id=156231010; csrf_session_id=5f00eba89758e4dec6fcb81867a8bdb5; Hm_lvt_b6520b076191ab4b36812da4c90f7a5e=1769876902,1770569311,1771350555,1772107597; HMACCOUNT=9C6B7571794A6624; Hm_lpvt_b6520b076191ab4b36812da4c90f7a5e=1772107601; ttwid=1%7CNnXcElGkMBE8UTpDOFYR5OfCUYkFjQaLyn1EagPBZgM%7C1772107539%7C23b2036059d82f195be2cc6b908c05b330cdb997234bf3f905c0bc13590d9a40; tt_scid=zEkoBrglfkrRTI4eZLkaSJXnjYM1LLpi9u.Llrfk6aQR5C3CVkjUGS20663cJtx-8cc3; odin_tt=6aea70f28ec501b3733a05a9ceda2cc9f6821ac8477dc66bd2901e299b4a704093d7918c0b6313913e6aa947ff023152c414dd30955f1fa9b96e2aa5828503ce; passport_auth_status=7dd7c4f1d18367e48c305613e3b56d2b%2Ceae9153b20c76f1d76ce32f5abfd7ad2; passport_auth_status_ss=7dd7c4f1d18367e48c305613e3b56d2b%2Ceae9153b20c76f1d76ce32f5abfd7ad2; bd_ticket_guard_server_data=eyJ0aWNrZXQiOiJoYXNoLk9mY291aFhPRllGWTFlSjE0UFFVckltR2JxVFBmQ1NjRG03S3BOeXZBZ009IiwidHNfc2lnbiI6InRzLjIuZDRkMmU1ZGJiZjkxMGMxYzM2ZDhjNTIwZjI3MzVhMjBmYjZhODk5ZDhmNDE0NDUzYzgyMmI5MTgyMTU5ZWJjOWM0ZmJlODdkMjMxOWNmMDUzMTg2MjRjZWRhMTQ5MTFjYTQwNmRlZGJlYmVkZGIyZTMwZmNlOGQ0ZmEwMjU3NWQiLCJjbGllbnRfY2VydCI6InB1Yi5CTHVTREdkVFRHWUdNMVY3ZDZKS2M4V2FwWGJ1K3JVYmVqRThONTZoeTI4SUJXdmVxZjBLMS9GczE0dWx5RTVRd2d4cjdnaDd6SXdMZjlsWDkwOFZQQWs9IiwibG9nX2lkIjoiMjAyNjAyMjYyMDA1NDg3QTBGMjlBRDMzODc4RDMxOUU3QyIsImNyZWF0ZV90aW1lIjoxNzcyMTA3NTQ4fQ%3D%3D; uid_tt=f02800c52b2bb3676614350efaed9630; uid_tt_ss=f02800c52b2bb3676614350efaed9630; sid_tt=c1e5f1ad8bdb3ad22bbd7a10b45e5273; sessionid=c1e5f1ad8bdb3ad22bbd7a10b45e5273; sessionid_ss=c1e5f1ad8bdb3ad22bbd7a10b45e5273; PHPSESSID=e246627f02d38ca5ad58d19df52647d2; PHPSESSID_SS=e246627f02d38ca5ad58d19df52647d2; ucas_c0=CkEKBTEuMC4wEJqIkMTi4o3QaRjmJiD61rDnqc2DBCiwITCb1oDYuM3aB0Cd7oDNBkidor3PBlC_vL6Ekt3t1GdYbhIUJvXy9UDSp90OViBiv17GMnQoNPQ; ucas_c0_ss=CkEKBTEuMC4wEJqIkMTi4o3QaRjmJiD61rDnqc2DBCiwITCb1oDYuM3aB0Cd7oDNBkidor3PBlC_vL6Ekt3t1GdYbhIUJvXy9UDSp90OViBiv17GMnQoNPQ; gd_random=eyJtYXRjaCI6dHJ1ZSwicGVyY2VudCI6MC4yNjk1MzQ2NjQzODMwNjUzfQ==.2Y8PvKxWpRpeQAxCqhA2WtHb2gI9V7vfrLpYjxq4jzM=; source=seo.fxg.jinritemai.com; sid_guard=c1e5f1ad8bdb3ad22bbd7a10b45e5273%7C1772107554%7C5184000%7CMon%2C+27-Apr-2026+12%3A05%3A54+GMT; session_tlb_tag=sttt%7C6%7CweXxrYvbOtIrvXoQtF5Sc__________-gxQYaEjeIZwmKtrmw7H3GC7-rTXLZAYpDxwTHQAiXDQ%3D; sid_ucp_v1=1.0.0-KDI3MGY5YTIzODY2NmQ0Njg5MjJiMDhkMzVlNGI4ZGIyM2IxNjE2YzMKGwib1oDYuM3aBxCi7oDNBhiwISAMOAZA9AdIBBoCbHEiIGMxZTVmMWFkOGJkYjNhZDIyYmJkN2ExMGI0NWU1Mjcz; ssid_ucp_v1=1.0.0-KDI3MGY5YTIzODY2NmQ0Njg5MjJiMDhkMzVlNGI4ZGIyM2IxNjE2YzMKGwib1oDYuM3aBxCi7oDNBhiwISAMOAZA9AdIBBoCbHEiIGMxZTVmMWFkOGJkYjNhZDIyYmJkN2ExMGI0NWU1Mjcz; COMPASS_LUOPAN_DT=session_7611143309406634249; BUYIN_SASID=SID2_7611142212729061666"
if cookie == "" {
fmt.Println("请通过环境变量 DOUYIN_COOKIE 提供抖店 Cookie")
os.Exit(1)
}
log, err := logger.NewCustomLogger(logger.WithDebugLevel(), logger.WithOutputInConsole())
if err != nil {
panic(err)
}
repo, err := mysql.New()
if err != nil {
panic(err)
}
defer repo.DbRClose()
defer repo.DbWClose()
svc := douyin.New(log, repo, &staticSyscfg{cookie: cookie}, nil, nil, nil)
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
defer cancel()
switch *mode {
case "fetch":
fmt.Println("开始 FetchAndSyncOrders按绑定用户同步...")
result, err := svc.FetchAndSyncOrders(ctx, &douyin.FetchOptions{
OnlyUnmatched: *fetchOnlyUnmatched,
MaxUsers: *fetchMaxUsers,
BatchSize: *fetchBatchSize,
Concurrency: *fetchConcurrency,
InterBatchDelay: time.Duration(*fetchDelay) * time.Millisecond,
})
if err != nil {
panic(err)
}
fmt.Printf("完成:抓取 %d新订单 %d匹配 %d处理用户 %d/%d跳过 %d用时 %.2fs。\n",
result.TotalFetched, result.NewOrders, result.MatchedUsers,
result.ProcessedUsers, result.TotalUsers, result.SkippedUsers,
float64(result.ElapsedMS)/1000.0)
case "sync-all":
fallthrough
default:
duration := time.Duration(*minutes) * time.Minute
fmt.Printf("开始 SyncAllOrdersduration=%s proxy=%v ...\n", duration, *useProxy)
result, err := svc.SyncAllOrders(ctx, duration, *useProxy)
if err != nil {
panic(err)
}
fmt.Printf("完成:抓取 %d新订单 %d匹配 %d。\n", result.TotalFetched, result.NewOrders, result.MatchedUsers)
}
if *grantMinesweeper {
fmt.Println("执行 GrantMinesweeperQualifications ...")
if err := svc.GrantMinesweeperQualifications(ctx); err != nil {
fmt.Printf("GrantMinesweeperQualifications 失败: %v\n", err)
} else {
fmt.Println("GrantMinesweeperQualifications 完成。")
}
}
if *printLimit > 0 {
var orders []model.DouyinOrders
if err := repo.GetDbR().Order("id DESC").Limit(*printLimit).Find(&orders).Error; err != nil {
fmt.Printf("读取订单列表失败: %v\n", err)
return
}
fmt.Println("shop_order_id\torder_status\tdouyin_user_id\tlocal_user_id")
for _, o := range orders {
fmt.Printf("%s\t%d\t%s\t%s\n", o.ShopOrderID, o.OrderStatus, o.DouyinUserID, o.LocalUserID)
}
}
}

View File

@ -5,6 +5,7 @@ import (
"bindbox-game/internal/pkg/core" "bindbox-game/internal/pkg/core"
"bindbox-game/internal/pkg/validation" "bindbox-game/internal/pkg/validation"
"bindbox-game/internal/repository/mysql/model" "bindbox-game/internal/repository/mysql/model"
financesvc "bindbox-game/internal/service/finance"
"encoding/json" "encoding/json"
"fmt" "fmt"
"net/http" "net/http"
@ -23,20 +24,25 @@ type activityProfitLossRequest struct {
} }
type activityProfitLossItem struct { type activityProfitLossItem struct {
ActivityID int64 `json:"activity_id"` ActivityID int64 `json:"activity_id"`
ActivityName string `json:"activity_name"` ActivityName string `json:"activity_name"`
Status int32 `json:"status"` Status int32 `json:"status"`
DrawCount int64 `json:"draw_count"` DrawCount int64 `json:"draw_count"`
GamePassCount int64 `json:"game_pass_count"` // 次卡抽奖次数 GamePassCount int64 `json:"game_pass_count"` // 次卡抽奖次数
PaymentCount int64 `json:"payment_count"` // 现金/优惠券抽奖次数 PaymentCount int64 `json:"payment_count"` // 现金/优惠券抽奖次数
RefundCount int64 `json:"refund_count"` // 退款/取消抽奖次数 RefundCount int64 `json:"refund_count"` // 退款/取消抽奖次数
PlayerCount int64 `json:"player_count"` PlayerCount int64 `json:"player_count"`
TotalRevenue int64 `json:"total_revenue"` // 实际支付金额 (分) TotalRevenue int64 `json:"total_revenue"` // 实际支付金额 (分)
TotalDiscount int64 `json:"total_discount"` // 优惠券抵扣金额 (分) TotalDiscount int64 `json:"total_discount"` // 优惠券抵扣金额 (分)
TotalGamePassValue int64 `json:"total_game_pass_value"` // 次卡价值 (分) TotalGamePassValue int64 `json:"total_game_pass_value"` // 次卡价值 (分)
TotalCost int64 `json:"total_cost"` // 奖品标价总和 (分) TotalCost int64 `json:"total_cost"` // 奖品标价总和 (分)
Profit int64 `json:"profit"` // (Revenue + Discount + GamePassValue) - Cost SpendingPaidCoupon int64 `json:"spending_paid_coupon"`
ProfitRate float64 `json:"profit_rate"` // Profit / (Revenue + Discount + GamePassValue) 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 { type activityProfitLossResponse struct {
@ -170,14 +176,19 @@ func (h *handler) DashboardActivityProfitLoss() core.HandlerFunc {
} }
var revenueStats []revenueStat var revenueStats []revenueStat
// 修正: 按抽奖次数比例分摊订单金额 (解决多活动订单归因问题) // 修正: 按抽奖次数比例分摊订单金额,且次卡订单不计入支付+优惠券口径(严格二选一)
// 逻辑: 活动分摊收入 = 订单实际金额 * (该活动在该订单中的抽奖次数 / 该订单总抽奖次数)
var err error var err error
err = db.Table(model.TableNameOrders). err = db.Table(model.TableNameOrders).
Select(` Select(`
order_activity_draws.activity_id, order_activity_draws.activity_id,
SUM(1.0 * orders.actual_amount * order_activity_draws.draw_count / order_total_draws.total_count) 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%')
SUM(CASE WHEN orders.source_type = 4 OR orders.order_no LIKE 'GP%' THEN 0 ELSE 1.0 * orders.discount_amount * order_activity_draws.draw_count / order_total_draws.total_count END) as total_discount 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) // Subquery 1: Calculate draw counts per order per activity (and link to issue->activity)
Joins(`JOIN ( Joins(`JOIN (
@ -211,22 +222,41 @@ func (h *handler) DashboardActivityProfitLoss() core.HandlerFunc {
// 4. 统计成本 (通过 user_inventory 关联 products 和 orders) // 4. 统计成本 (通过 user_inventory 关联 products 和 orders)
// 修正:增加关联 orders 表,过滤掉已退款/取消的订单 (status!=2) // 修正:增加关联 orders 表,过滤掉已退款/取消的订单 (status!=2)
type costStat struct { type costStat struct {
ActivityID int64 ActivityID int64
TotalCost int64 TotalCost int64
TotalCostBase int64
AvgMultiplierX10 int64
} }
var costStats []costStat var costStats []costStat
db.Table(model.TableNameUserInventory). if err := db.Table(model.TableNameUserInventory).
Select("user_inventory.activity_id, SUM(products.price) as total_cost"). Select(`
Joins("JOIN products ON products.id = user_inventory.product_id"). COALESCE(NULLIF(user_inventory.activity_id, 0), cost_issues.activity_id) as activity_id,
CAST(SUM(COALESCE(NULLIF(user_inventory.value_cents, 0), activity_reward_settings.price_snapshot_cents, products.price, 0) * GREATEST(COALESCE(system_item_cards.reward_multiplier_x1000, 1000), 1000) / 1000) AS SIGNED) as total_cost,
SUM(COALESCE(NULLIF(user_inventory.value_cents, 0), activity_reward_settings.price_snapshot_cents, products.price, 0)) as total_cost_base,
CAST(COALESCE(AVG(GREATEST(COALESCE(system_item_cards.reward_multiplier_x1000, 1000), 1000) / 100), 10) AS SIGNED) as avg_multiplier_x10
`).
Joins("LEFT JOIN orders ON orders.id = user_inventory.order_id"). Joins("LEFT JOIN orders ON orders.id = user_inventory.order_id").
Where("user_inventory.activity_id IN ?", activityIDs). Joins("LEFT JOIN activity_reward_settings ON activity_reward_settings.id = user_inventory.reward_id").
Where("orders.status = ?", 2). // 仅统计已支付订单产生的成本 Joins("LEFT JOIN activity_issues AS cost_issues ON cost_issues.id = activity_reward_settings.issue_id").
Group("user_inventory.activity_id"). Joins("LEFT JOIN products ON products.id = user_inventory.product_id").
Scan(&costStats) 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").
for _, s := range costStats { Where("COALESCE(NULLIF(user_inventory.activity_id, 0), cost_issues.activity_id) IN ?", activityIDs).
if item, ok := activityMap[s.ActivityID]; ok { Where("user_inventory.status IN ?", []int{1, 3}).
item.TotalCost = s.TotalCost Where("COALESCE(user_inventory.remark, '') NOT LIKE ?", "%void%").
// 兼容历史数据:部分老资产可能未写入 order_id避免被 JOIN 条件整批过滤为0
Where("(orders.status = ? OR user_inventory.order_id = 0 OR user_inventory.order_id IS NULL)", 2).
Group("COALESCE(NULLIF(user_inventory.activity_id, 0), cost_issues.activity_id)").
Scan(&costStats).Error; err != nil {
h.logger.Error(fmt.Sprintf("GetActivityProfitLoss cost stats error: %v", err))
} else {
for _, s := range costStats {
if item, ok := activityMap[s.ActivityID]; ok {
item.TotalCost = s.TotalCost
item.PrizeCostBase = s.TotalCostBase
item.PrizeCostFinal = s.TotalCost
item.PrizeCostMultiplier = s.AvgMultiplierX10
}
} }
} }
@ -264,15 +294,14 @@ func (h *handler) DashboardActivityProfitLoss() core.HandlerFunc {
} }
// 6. 计算盈亏和比率 // 6. 计算盈亏和比率
// 公式: 盈亏 = (支付金额 + 优惠券抵扣 + 次卡价值) - 产品成本 // 公式: 盈亏 = 用户支出(普通单支付+优惠券 或 次卡价值) - 奖品成本(含道具卡倍率)
finalList := make([]activityProfitLossItem, 0, len(activities)) finalList := make([]activityProfitLossItem, 0, len(activities))
for _, a := range activities { for _, a := range activities {
item := activityMap[a.ID] item := activityMap[a.ID]
totalIncome := item.TotalRevenue + item.TotalDiscount + item.TotalGamePassValue item.SpendingPaidCoupon = item.TotalRevenue + item.TotalDiscount
item.Profit = totalIncome - item.TotalCost item.SpendingGamePass = item.TotalGamePassValue
if totalIncome > 0 { totalIncome := item.SpendingPaidCoupon + item.SpendingGamePass
item.ProfitRate = float64(item.Profit) / float64(totalIncome) item.Profit, item.ProfitRate = financesvc.ComputeProfit(totalIncome, item.TotalCost)
}
finalList = append(finalList, *item) finalList = append(finalList, *item)
} }
@ -418,6 +447,7 @@ func (h *handler) DashboardActivityLogs() core.HandlerFunc {
DrawCount int64 // 该订单的总抽奖次数(用于金额分摊) DrawCount int64 // 该订单的总抽奖次数(用于金额分摊)
UsedDrawLogID int64 // 道具卡实际使用的日志ID UsedDrawLogID int64 // 道具卡实际使用的日志ID
CreatedAt time.Time CreatedAt time.Time
ActivityPrice int64
} }
logsQuery := db.Table(model.TableNameActivityDrawLogs). logsQuery := db.Table(model.TableNameActivityDrawLogs).
@ -429,7 +459,7 @@ func (h *handler) DashboardActivityLogs() core.HandlerFunc {
activity_reward_settings.product_id, activity_reward_settings.product_id,
COALESCE(products.name, '') as product_name, COALESCE(products.name, '') as product_name,
COALESCE(products.images_json, '[]') as images_json, COALESCE(products.images_json, '[]') as images_json,
COALESCE(products.price, 0) as product_price, COALESCE(activity_reward_settings.price_snapshot_cents, products.price, 0) as product_price,
COALESCE(orders.actual_amount, 0) as order_amount, COALESCE(orders.actual_amount, 0) as order_amount,
COALESCE(orders.discount_amount, 0) as discount_amount, COALESCE(orders.discount_amount, 0) as discount_amount,
COALESCE(orders.points_amount, 0) as points_amount, COALESCE(orders.points_amount, 0) as points_amount,
@ -445,9 +475,11 @@ func (h *handler) DashboardActivityLogs() core.HandlerFunc {
COALESCE(orders.order_no, '') as order_no, COALESCE(orders.order_no, '') as order_no,
COALESCE(order_draw_counts.draw_count, 1) as draw_count, COALESCE(order_draw_counts.draw_count, 1) as draw_count,
COALESCE(user_item_cards.used_draw_log_id, 0) as used_draw_log_id, COALESCE(user_item_cards.used_draw_log_id, 0) as used_draw_log_id,
activity_draw_logs.created_at 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 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 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 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 products ON products.id = activity_reward_settings.product_id").
@ -590,6 +622,14 @@ func (h *handler) DashboardActivityLogs() core.HandlerFunc {
perDrawDiscountAmount := l.DiscountAmount / drawCount perDrawDiscountAmount := l.DiscountAmount / drawCount
perDrawPointsAmount := l.PointsAmount / drawCount perDrawPointsAmount := l.PointsAmount / drawCount
if paymentDetails.GamePassUsed {
if l.ActivityPrice > 0 {
perDrawOrderAmount = l.ActivityPrice
} else if perDrawOrderAmount == 0 {
perDrawOrderAmount = l.OrderAmount / drawCount
}
}
// 设置支付详情中的分摊金额 // 设置支付详情中的分摊金额
paymentDetails.CouponDiscount = perDrawDiscountAmount paymentDetails.CouponDiscount = perDrawDiscountAmount
paymentDetails.PointsDiscount = perDrawPointsAmount paymentDetails.PointsDiscount = perDrawPointsAmount

View File

@ -2,6 +2,7 @@ package admin
import ( import (
"database/sql" "database/sql"
"fmt"
"net/http" "net/http"
"strconv" "strconv"
"time" "time"
@ -1483,7 +1484,7 @@ func (h *handler) DashboardPrizeDistribution() core.HandlerFunc {
"SUM(activity_reward_settings.quantity) as level_rem_qty", "SUM(activity_reward_settings.quantity) as level_rem_qty",
"SUM(activity_reward_settings.weight) as level_total_prob", "SUM(activity_reward_settings.weight) as level_total_prob",
"COUNT(activity_reward_settings.id) as prize_count", "COUNT(activity_reward_settings.id) as prize_count",
"SUM(products.price * activity_reward_settings.original_qty) as level_total_value", "SUM(COALESCE(activity_reward_settings.price_snapshot_cents, products.price, 0) * activity_reward_settings.original_qty) as level_total_value",
). ).
Group("activity_reward_settings.level"). Group("activity_reward_settings.level").
Order("activity_reward_settings.level"). Order("activity_reward_settings.level").
@ -1665,21 +1666,26 @@ func (h *handler) OperationsProductPerformance() core.HandlerFunc {
var rows []drawRow var rows []drawRow
// 统计抽奖日志,按活动分组,并计算奖品成本 // 统计抽奖日志,按活动分组,并计算奖品成本
_ = h.readDB.ActivityDrawLogs.WithContext(ctx.RequestContext()).UnderlyingDB(). if err := h.readDB.ActivityDrawLogs.WithContext(ctx.RequestContext()).UnderlyingDB().
Joins("JOIN activity_issues ON activity_issues.id = activity_draw_logs.issue_id"). 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 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 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_draw_logs.created_at >= ?", s). Where("activity_draw_logs.created_at >= ?", s).
Where("activity_draw_logs.created_at <= ?", e). Where("activity_draw_logs.created_at <= ?", e).
Select( Select(
"activity_issues.activity_id", "activity_issues.activity_id",
"COUNT(activity_draw_logs.id) as count", "COUNT(activity_draw_logs.id) as count",
"SUM(IF(activity_draw_logs.is_winner = 1, products.price, 0)) as total_cost", "CAST(SUM(IF(activity_draw_logs.is_winner = 1, COALESCE(activity_reward_settings.price_snapshot_cents, products.price, 0) * GREATEST(COALESCE(system_item_cards.reward_multiplier_x1000, 1000), 1000) / 1000, 0)) AS SIGNED) as total_cost",
). ).
Group("activity_issues.activity_id"). Group("activity_issues.activity_id").
Order("count DESC"). Order("count DESC").
Limit(10). Limit(10).
Scan(&rows) Scan(&rows).Error; err != nil {
h.logger.Error(fmt.Sprintf("OperationsProductPerformance draw cost stats error: %v", err))
}
// 获取活动详情(名称和单价) // 获取活动详情(名称和单价)
activityIDs := make([]int64, len(rows)) activityIDs := make([]int64, len(rows))

View File

@ -5,9 +5,11 @@ import (
"bindbox-game/internal/pkg/core" "bindbox-game/internal/pkg/core"
"bindbox-game/internal/pkg/validation" "bindbox-game/internal/pkg/validation"
"bindbox-game/internal/repository/mysql/model" "bindbox-game/internal/repository/mysql/model"
financesvc "bindbox-game/internal/service/finance"
"fmt" "fmt"
"net/http" "net/http"
"sort" "sort"
"strings"
"time" "time"
) )
@ -21,16 +23,21 @@ type spendingLeaderboardRequest struct {
} }
type spendingLeaderboardItem struct { type spendingLeaderboardItem struct {
UserID int64 `json:"user_id"` UserID int64 `json:"user_id"`
Nickname string `json:"nickname"` Nickname string `json:"nickname"`
Avatar string `json:"avatar"` Avatar string `json:"avatar"`
OrderCount int64 `json:"-"` // Hidden OrderCount int64 `json:"-"` // Hidden
TotalSpending int64 `json:"-"` // Hidden TotalSpending int64 `json:"-"` // Hidden
TotalPrizeValue int64 `json:"-"` // Hidden TotalPrizeValue int64 `json:"-"` // Hidden
TotalDiscount int64 `json:"total_discount"` // Total Coupon Discount (Fen) SpendingPaidCoupon int64 `json:"spending_paid_coupon"`
TotalPoints int64 `json:"total_points"` // Total Points Discount (Fen) SpendingGamePass int64 `json:"spending_game_pass"`
GamePassCount int64 `json:"game_pass_count"` // Count of SourceType=4 PrizeCostBase int64 `json:"prize_cost_base"`
ItemCardCount int64 `json:"item_card_count"` // Count where ItemCardID > 0 PrizeCostMultiplier int64 `json:"prize_cost_multiplier"`
PrizeCostFinal int64 `json:"prize_cost_final"`
TotalDiscount int64 `json:"total_discount"` // Total Coupon Discount (Fen)
TotalPoints int64 `json:"total_points"` // Total Points Discount (Fen)
GamePassCount int64 `json:"game_pass_count"` // Count of SourceType=4
ItemCardCount int64 `json:"item_card_count"` // Count where ItemCardID > 0
// Breakdown by game type // Breakdown by game type
IchibanSpending int64 `json:"ichiban_spending"` IchibanSpending int64 `json:"ichiban_spending"`
IchibanPrize int64 `json:"ichiban_prize"` IchibanPrize int64 `json:"ichiban_prize"`
@ -93,6 +100,7 @@ func (h *handler) DashboardPlayerSpendingLeaderboard() core.HandlerFunc {
TotalDiscount int64 TotalDiscount int64
TotalPoints int64 TotalPoints int64
GamePassCount int64 GamePassCount int64
GamePassSpending int64
ItemCardCount int64 ItemCardCount int64
IchibanSpending int64 IchibanSpending int64
IchibanCount int64 IchibanCount int64
@ -106,7 +114,7 @@ func (h *handler) DashboardPlayerSpendingLeaderboard() core.HandlerFunc {
var stats []orderStat var stats []orderStat
query := db.Table(model.TableNameOrders). query := db.Table(model.TableNameOrders).
Joins("LEFT JOIN (SELECT l.order_id, MAX(a.activity_category_id) as category_id FROM activity_draw_logs l JOIN activity_issues i ON i.id = l.issue_id JOIN activities a ON a.id = i.activity_id GROUP BY l.order_id) oa ON oa.order_id = orders.id"). Joins("LEFT JOIN (SELECT l.order_id, MAX(a.activity_category_id) as category_id, MAX(a.price_draw) as price_draw, COUNT(*) as draw_count FROM activity_draw_logs l JOIN activity_issues i ON i.id = l.issue_id JOIN activities a ON a.id = i.activity_id GROUP BY l.order_id) oa ON oa.order_id = orders.id").
Where("orders.status = ?", 2) Where("orders.status = ?", 2)
if req.RangeType != "all" { if req.RangeType != "all" {
@ -115,20 +123,42 @@ func (h *handler) DashboardPlayerSpendingLeaderboard() core.HandlerFunc {
if err := query.Select(` if err := query.Select(`
orders.user_id, orders.user_id,
SUM(orders.total_amount) as total_amount, SUM(CASE WHEN orders.source_type = 4 OR orders.order_no LIKE 'GP%' OR (orders.actual_amount = 0 AND orders.remark LIKE '%use_game_pass%')
THEN COALESCE(oa.price_draw * oa.draw_count, 0)
ELSE orders.actual_amount + orders.discount_amount
END) as total_amount,
COUNT(orders.id) as order_count, COUNT(orders.id) as order_count,
SUM(orders.discount_amount) as total_discount, SUM(orders.discount_amount) as total_discount,
SUM(orders.points_amount) as total_points, SUM(orders.points_amount) as total_points,
SUM(CASE WHEN orders.source_type = 4 THEN 1 ELSE 0 END) as game_pass_count, SUM(CASE WHEN orders.source_type = 4 THEN 1 ELSE 0 END) as game_pass_count,
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 0
END) as game_pass_spending,
SUM(CASE WHEN orders.item_card_id > 0 THEN 1 ELSE 0 END) as item_card_count, SUM(CASE WHEN orders.item_card_id > 0 THEN 1 ELSE 0 END) as item_card_count,
SUM(CASE WHEN oa.category_id = 1 THEN orders.total_amount ELSE 0 END) as ichiban_spending, SUM(CASE WHEN oa.category_id = 1 THEN
CASE WHEN orders.source_type = 4 OR orders.order_no LIKE 'GP%' OR (orders.actual_amount = 0 AND orders.remark LIKE '%use_game_pass%')
THEN COALESCE(oa.price_draw * oa.draw_count, 0)
ELSE orders.actual_amount + orders.discount_amount
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 = 1 THEN 1 ELSE 0 END) as ichiban_count,
SUM(CASE WHEN oa.category_id = 2 THEN orders.total_amount ELSE 0 END) as infinite_spending, SUM(CASE WHEN oa.category_id = 2 THEN
CASE WHEN orders.source_type = 4 OR orders.order_no LIKE 'GP%' OR (orders.actual_amount = 0 AND orders.remark LIKE '%use_game_pass%')
THEN COALESCE(oa.price_draw * oa.draw_count, 0)
ELSE orders.actual_amount + orders.discount_amount
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 = 2 THEN 1 ELSE 0 END) as infinite_count,
SUM(CASE WHEN oa.category_id = 3 THEN orders.total_amount ELSE 0 END) as matching_spending, SUM(CASE WHEN oa.category_id = 3 THEN
CASE WHEN orders.source_type = 4 OR orders.order_no LIKE 'GP%' OR (orders.actual_amount = 0 AND orders.remark LIKE '%use_game_pass%')
THEN COALESCE(oa.price_draw * oa.draw_count, 0)
ELSE orders.actual_amount + orders.discount_amount
END
ELSE 0 END) as matching_spending,
SUM(CASE WHEN oa.category_id = 3 THEN 1 ELSE 0 END) as matching_count, SUM(CASE WHEN oa.category_id = 3 THEN 1 ELSE 0 END) as matching_count,
SUM(CASE WHEN orders.source_type = 5 THEN orders.total_amount ELSE 0 END) as livestream_spending, 0 as livestream_spending,
SUM(CASE WHEN orders.source_type = 5 THEN 1 ELSE 0 END) as livestream_count 0 as livestream_count
`). `).
Group("orders.user_id"). Group("orders.user_id").
Order("total_amount DESC"). Order("total_amount DESC").
@ -152,6 +182,7 @@ func (h *handler) DashboardPlayerSpendingLeaderboard() core.HandlerFunc {
TotalDiscount: s.TotalDiscount, TotalDiscount: s.TotalDiscount,
TotalPoints: s.TotalPoints, TotalPoints: s.TotalPoints,
GamePassCount: s.GamePassCount, GamePassCount: s.GamePassCount,
SpendingGamePass: s.GamePassSpending,
ItemCardCount: s.ItemCardCount, ItemCardCount: s.ItemCardCount,
IchibanSpending: s.IchibanSpending, IchibanSpending: s.IchibanSpending,
IchibanCount: s.IchibanCount, IchibanCount: s.IchibanCount,
@ -186,7 +217,7 @@ func (h *handler) DashboardPlayerSpendingLeaderboard() core.HandlerFunc {
if item, ok := statMap[ds.UserID]; ok { if item, ok := statMap[ds.UserID]; ok {
item.LivestreamSpending = ds.Amount item.LivestreamSpending = ds.Amount
item.LivestreamCount = ds.Count // Use real paid order count item.LivestreamCount = ds.Count // Use real paid order count
item.TotalSpending += ds.Amount // Add to total since orders.total_amount was 0 for these item.TotalSpending += ds.Amount
} }
} }
} }
@ -216,9 +247,13 @@ func (h *handler) DashboardPlayerSpendingLeaderboard() core.HandlerFunc {
// Join with Products, Activities, and Orders (for livestream detection) // Join with Products, Activities, and Orders (for livestream detection)
query := db.Table(model.TableNameUserInventory). query := db.Table(model.TableNameUserInventory).
Joins("JOIN products ON products.id = user_inventory.product_id"). Joins("LEFT JOIN activity_reward_settings ON activity_reward_settings.id = user_inventory.reward_id").
Joins("LEFT JOIN activities ON activities.id = user_inventory.activity_id"). Joins("LEFT JOIN activity_issues AS cost_issues ON cost_issues.id = activity_reward_settings.issue_id").
Joins("LEFT JOIN activities ON activities.id = COALESCE(NULLIF(user_inventory.activity_id, 0), cost_issues.activity_id)").
Joins("LEFT JOIN orders ON orders.id = user_inventory.order_id"). Joins("LEFT JOIN orders ON orders.id = user_inventory.order_id").
Joins("LEFT JOIN products ON products.id = user_inventory.product_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.user_id IN ?", userIDs) Where("user_inventory.user_id IN ?", userIDs)
if req.RangeType != "all" { if req.RangeType != "all" {
@ -227,15 +262,15 @@ func (h *handler) DashboardPlayerSpendingLeaderboard() core.HandlerFunc {
} }
// Only include Holding (1) and Shipped/Used (3) items. Exclude Void/Decomposed (2). // Only include Holding (1) and Shipped/Used (3) items. Exclude Void/Decomposed (2).
query = query.Where("user_inventory.status IN ?", []int{1, 3}). query = query.Where("user_inventory.status IN ?", []int{1, 3}).
Where("user_inventory.remark NOT LIKE ?", "%void%") Where("COALESCE(user_inventory.remark, '') NOT LIKE ?", "%void%")
err := query.Select(` err := query.Select(`
user_inventory.user_id, user_inventory.user_id,
SUM(products.price) as total_value, CAST(SUM(COALESCE(NULLIF(user_inventory.value_cents, 0), activity_reward_settings.price_snapshot_cents, products.price, 0) * GREATEST(COALESCE(system_item_cards.reward_multiplier_x1000, 1000), 1000) / 1000) AS SIGNED) as total_value,
SUM(CASE WHEN activities.activity_category_id = 1 THEN products.price ELSE 0 END) as ichiban_prize, CAST(SUM(CASE WHEN activities.activity_category_id = 1 THEN COALESCE(NULLIF(user_inventory.value_cents, 0), activity_reward_settings.price_snapshot_cents, products.price, 0) * GREATEST(COALESCE(system_item_cards.reward_multiplier_x1000, 1000), 1000) / 1000 ELSE 0 END) AS SIGNED) as ichiban_prize,
SUM(CASE WHEN activities.activity_category_id = 2 THEN products.price ELSE 0 END) as infinite_prize, CAST(SUM(CASE WHEN activities.activity_category_id = 2 THEN COALESCE(NULLIF(user_inventory.value_cents, 0), activity_reward_settings.price_snapshot_cents, products.price, 0) * GREATEST(COALESCE(system_item_cards.reward_multiplier_x1000, 1000), 1000) / 1000 ELSE 0 END) AS SIGNED) as infinite_prize,
SUM(CASE WHEN activities.activity_category_id = 3 THEN products.price ELSE 0 END) as matching_prize CAST(SUM(CASE WHEN activities.activity_category_id = 3 THEN COALESCE(NULLIF(user_inventory.value_cents, 0), activity_reward_settings.price_snapshot_cents, products.price, 0) * GREATEST(COALESCE(system_item_cards.reward_multiplier_x1000, 1000), 1000) / 1000 ELSE 0 END) AS SIGNED) as matching_prize
`). `).
Group("user_inventory.user_id"). Group("user_inventory.user_id").
Scan(&invStats).Error Scan(&invStats).Error
@ -248,31 +283,107 @@ func (h *handler) DashboardPlayerSpendingLeaderboard() core.HandlerFunc {
item.MatchingPrize = is.MatchingPrize item.MatchingPrize = is.MatchingPrize
} }
} }
} else {
h.logger.Error(fmt.Sprintf("DashboardPlayerSpendingLeaderboard inventory cost stats error: %v", err))
} }
// 4.1 Calculate Livestream Prize Value (From Draw Logs) // 4.1 Calculate Livestream Prize Value (snapshot priority; fallback prize cost)
type lsStat struct { type lsLog struct {
UserID int64 UserID int64
Amount int64 ShopOrderID string
PrizeID int64
} }
var lsStats []lsStat var lsLogs []lsLog
lsQuery := db.Table(model.TableNameLivestreamDrawLogs). lsLogQuery := db.Table(model.TableNameLivestreamDrawLogs).
Joins("JOIN products ON products.id = livestream_draw_logs.product_id"). Select("livestream_draw_logs.local_user_id as user_id, livestream_draw_logs.shop_order_id, livestream_draw_logs.prize_id").
Select("livestream_draw_logs.local_user_id as user_id, SUM(products.price) as amount").
Where("livestream_draw_logs.local_user_id IN ?", userIDs). Where("livestream_draw_logs.local_user_id IN ?", userIDs).
Where("livestream_draw_logs.is_refunded = 0"). Where("livestream_draw_logs.is_refunded = 0").
Where("livestream_draw_logs.product_id > 0") Where("livestream_draw_logs.prize_id > 0")
if req.RangeType != "all" { if req.RangeType != "all" {
lsQuery = lsQuery.Where("livestream_draw_logs.created_at >= ?", start). lsLogQuery = lsLogQuery.Where("livestream_draw_logs.created_at >= ?", start).
Where("livestream_draw_logs.created_at <= ?", end) Where("livestream_draw_logs.created_at <= ?", end)
} }
_ = lsLogQuery.Scan(&lsLogs).Error
if err := lsQuery.Group("livestream_draw_logs.local_user_id").Scan(&lsStats).Error; err == nil { if len(lsLogs) > 0 {
for _, ls := range lsStats { prizeIDSet := make(map[int64]struct{})
if item, ok := statMap[ls.UserID]; ok { for _, l := range lsLogs {
item.LivestreamPrize = ls.Amount prizeIDSet[l.PrizeID] = struct{}{}
// item.TotalPrizeValue += ls.Amount // Already included in user_inventory }
prizeIDs := make([]int64, 0, len(prizeIDSet))
for pid := range prizeIDSet {
prizeIDs = append(prizeIDs, pid)
}
prizeCostMap := make(map[int64]int64)
if len(prizeIDs) > 0 {
var prizes []struct {
ID int64
CostPrice int64
}
_ = h.repo.GetDbR().Table("livestream_prizes").Select("id, cost_price").Where("id IN ?", prizeIDs).Scan(&prizes).Error
for _, p := range prizes {
prizeCostMap[p.ID] = p.CostPrice
}
}
type invRow struct {
UserID int64
ValueCents int64
Remark string
}
var invRows []invRow
invQ := h.repo.GetDbR().Table("user_inventory").
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.remark").
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").
Where("user_inventory.user_id IN ?", userIDs).
Where("user_inventory.status IN (1,3)").
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))
}
_ = invQ.Scan(&invRows).Error
invByUser := make(map[int64][]invRow)
for _, inv := range invRows {
invByUser[inv.UserID] = append(invByUser[inv.UserID], inv)
}
lsByKey := make(map[string][]lsLog)
for _, l := range lsLogs {
key := fmt.Sprintf("%d|%s", l.UserID, l.ShopOrderID)
lsByKey[key] = append(lsByKey[key], l)
}
livestreamPrizeByUser := make(map[int64]int64)
for _, logs := range lsByKey {
if len(logs) == 0 {
continue
}
uid := logs[0].UserID
shopOrderID := logs[0].ShopOrderID
var snapshotSum int64
if shopOrderID != "" {
for _, inv := range invByUser[uid] {
if strings.Contains(inv.Remark, shopOrderID) {
snapshotSum += inv.ValueCents
}
}
}
if snapshotSum > 0 {
livestreamPrizeByUser[uid] += snapshotSum
continue
}
for _, l := range logs {
livestreamPrizeByUser[uid] += prizeCostMap[l.PrizeID]
}
}
for uid, amount := range livestreamPrizeByUser {
if item, ok := statMap[uid]; ok {
item.LivestreamPrize = amount
} }
} }
} }
@ -294,11 +405,14 @@ func (h *handler) DashboardPlayerSpendingLeaderboard() core.HandlerFunc {
calculatedProfit := item.IchibanProfit + item.InfiniteProfit + item.MatchingProfit + item.LivestreamProfit calculatedProfit := item.IchibanProfit + item.InfiniteProfit + item.MatchingProfit + item.LivestreamProfit
item.Profit = calculatedProfit item.Profit = calculatedProfit
if calculatedSpending > 0 { _, item.ProfitRate = financesvc.ComputeProfit(calculatedSpending, calculatedSpending-item.Profit)
item.ProfitRate = float64(item.Profit) / float64(calculatedSpending) item.SpendingPaidCoupon = calculatedSpending - item.SpendingGamePass
} else { if item.SpendingPaidCoupon < 0 {
item.ProfitRate = 0 item.SpendingPaidCoupon = 0
} }
item.PrizeCostFinal = item.IchibanPrize + item.InfinitePrize + item.MatchingPrize + item.LivestreamPrize
item.PrizeCostBase = item.PrizeCostFinal
item.PrizeCostMultiplier = 10
list = append(list, *item) list = append(list, *item)
} }

View File

@ -4,12 +4,14 @@ import (
"fmt" "fmt"
"net/http" "net/http"
"strconv" "strconv"
"strings"
"time" "time"
"bindbox-game/internal/code" "bindbox-game/internal/code"
"bindbox-game/internal/pkg/core" "bindbox-game/internal/pkg/core"
"bindbox-game/internal/pkg/validation" "bindbox-game/internal/pkg/validation"
"bindbox-game/internal/repository/mysql/model" "bindbox-game/internal/repository/mysql/model"
financesvc "bindbox-game/internal/service/finance"
) )
type userSpendingRequest struct { type userSpendingRequest struct {
@ -87,9 +89,18 @@ func (h *handler) GetUserSpendingDashboard() core.HandlerFunc {
var actStats []activityStat var actStats []activityStat
query := db.Table(model.TableNameOrders). query := db.Table(model.TableNameOrders).
Joins("LEFT JOIN activity_draw_logs ON activity_draw_logs.order_id = orders.id"). Joins(`LEFT JOIN (
Joins("LEFT JOIN activity_issues ON activity_issues.id = activity_draw_logs.issue_id"). SELECT activity_draw_logs.order_id, activity_issues.activity_id, COUNT(*) as draw_count
Joins("LEFT JOIN activities ON activities.id = activity_issues.activity_id"). 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`).
Joins(`LEFT 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`).
Joins("LEFT JOIN activities ON activities.id = order_activity_draws.activity_id").
Where("orders.user_id = ?", userID). Where("orders.user_id = ?", userID).
Where("orders.status = ?", 2) Where("orders.status = ?", 2)
@ -101,7 +112,11 @@ func (h *handler) GetUserSpendingDashboard() core.HandlerFunc {
COALESCE(activities.id, 0) as activity_id, COALESCE(activities.id, 0) as activity_id,
COALESCE(activities.name, '其他') as activity_name, COALESCE(activities.name, '其他') as activity_name,
COALESCE(activities.activity_category_id, 0) as category_id, COALESCE(activities.activity_category_id, 0) as category_id,
SUM(orders.total_amount) as spending, SUM(CASE
WHEN orders.source_type = 4 OR orders.order_no LIKE 'GP%' OR (orders.actual_amount = 0 AND orders.remark LIKE '%use_game_pass%')
THEN COALESCE(order_activity_draws.draw_count * activities.price_draw, 0)
ELSE COALESCE((orders.actual_amount + orders.discount_amount) * order_activity_draws.draw_count / NULLIF(order_total_draws.total_count, 0), 0)
END) as spending,
COUNT(DISTINCT orders.id) as order_count COUNT(DISTINCT orders.id) as order_count
`). `).
Group("COALESCE(activities.id, 0)"). Group("COALESCE(activities.id, 0)").
@ -120,21 +135,28 @@ func (h *handler) GetUserSpendingDashboard() core.HandlerFunc {
var prizeStats []prizeStat var prizeStats []prizeStat
prizeQuery := db.Table(model.TableNameUserInventory). prizeQuery := db.Table(model.TableNameUserInventory).
Joins("JOIN products ON products.id = user_inventory.product_id"). Joins("LEFT JOIN orders ON orders.id = user_inventory.order_id").
Joins("LEFT JOIN activity_reward_settings ON activity_reward_settings.id = user_inventory.reward_id").
Joins("LEFT JOIN activity_issues AS cost_issues ON cost_issues.id = activity_reward_settings.issue_id").
Joins("LEFT JOIN products ON products.id = user_inventory.product_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.user_id = ?", userID). Where("user_inventory.user_id = ?", userID).
Where("user_inventory.status IN ?", []int{1, 3}). Where("user_inventory.status IN ?", []int{1, 3}).
Where("user_inventory.remark NOT LIKE ?", "%void%") Where("COALESCE(user_inventory.remark, '') NOT LIKE ?", "%void%")
if hasRange { if hasRange {
prizeQuery = prizeQuery.Where("user_inventory.created_at >= ?", start).Where("user_inventory.created_at <= ?", end) prizeQuery = prizeQuery.Where("user_inventory.created_at >= ?", start).Where("user_inventory.created_at <= ?", end)
} }
prizeQuery.Select(` if err := prizeQuery.Select(`
COALESCE(user_inventory.activity_id, 0) as activity_id, COALESCE(NULLIF(user_inventory.activity_id, 0), cost_issues.activity_id, 0) as activity_id,
SUM(products.price) as prize_value CAST(SUM(COALESCE(NULLIF(user_inventory.value_cents, 0), activity_reward_settings.price_snapshot_cents, products.price, 0) * GREATEST(COALESCE(system_item_cards.reward_multiplier_x1000, 1000), 1000) / 1000) AS SIGNED) as prize_value
`). `).
Group("COALESCE(user_inventory.activity_id, 0)"). Group("COALESCE(NULLIF(user_inventory.activity_id, 0), cost_issues.activity_id, 0)").
Scan(&prizeStats) Scan(&prizeStats).Error; err != nil {
h.logger.Error(fmt.Sprintf("GetUserSpendingDashboard prize stats error: %v", err))
}
prizeMap := make(map[int64]int64) prizeMap := make(map[int64]int64)
for _, p := range prizeStats { for _, p := range prizeStats {
@ -173,21 +195,98 @@ func (h *handler) GetUserSpendingDashboard() core.HandlerFunc {
PrizeValue int64 PrizeValue int64
} }
var lsPrizeStats []lsPrizeStat var lsPrizeStats []lsPrizeStat
lsPrizeQuery := db.Table("livestream_draw_logs"). type lsLog struct {
Joins("JOIN products ON products.id = livestream_draw_logs.product_id"). ActivityID int64
Select(` ShopOrderID string
livestream_draw_logs.livestream_activity_id as activity_id, PrizeID int64
SUM(products.price) as prize_value
`).
Where("livestream_draw_logs.local_user_id = ?", userID).
Where("livestream_draw_logs.is_refunded = 0").
Where("livestream_draw_logs.product_id > 0")
if hasRange {
lsPrizeQuery = lsPrizeQuery.Where("livestream_draw_logs.created_at >= ?", start).Where("livestream_draw_logs.created_at <= ?", end)
} }
var lsLogs []lsLog
lsLogQuery := db.Table("livestream_draw_logs").
Select("livestream_activity_id as activity_id, shop_order_id, prize_id").
Where("local_user_id = ?", userID).
Where("is_refunded = 0").
Where("prize_id > 0")
if hasRange {
lsLogQuery = lsLogQuery.Where("created_at >= ?", start).Where("created_at <= ?", end)
}
_ = lsLogQuery.Scan(&lsLogs).Error
lsPrizeQuery.Group("livestream_draw_logs.livestream_activity_id").Scan(&lsPrizeStats) if len(lsLogs) > 0 {
prizeIDSet := make(map[int64]struct{})
for _, l := range lsLogs {
prizeIDSet[l.PrizeID] = struct{}{}
}
prizeIDs := make([]int64, 0, len(prizeIDSet))
for pid := range prizeIDSet {
prizeIDs = append(prizeIDs, pid)
}
prizeCostMap := make(map[int64]int64)
if len(prizeIDs) > 0 {
var prizes []struct {
ID int64
CostPrice int64
}
_ = h.repo.GetDbR().Table("livestream_prizes").Select("id, cost_price").Where("id IN ?", prizeIDs).Scan(&prizes).Error
for _, p := range prizes {
prizeCostMap[p.ID] = p.CostPrice
}
}
type invRow struct {
ValueCents int64
Remark string
}
var invRows []invRow
invQ := h.repo.GetDbR().Table("user_inventory").
Select("COALESCE(NULLIF(user_inventory.value_cents, 0), activity_reward_settings.price_snapshot_cents, products.price, 0) as value_cents, user_inventory.remark").
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").
Where("user_id = ?", userID).
Where("status IN (1,3)").
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))
}
_ = invQ.Scan(&invRows).Error
lsByKey := make(map[string][]lsLog)
for _, l := range lsLogs {
key := fmt.Sprintf("%d|%s", l.ActivityID, l.ShopOrderID)
lsByKey[key] = append(lsByKey[key], l)
}
prizeByActivity := make(map[int64]int64)
for _, logs := range lsByKey {
if len(logs) == 0 {
continue
}
aid := logs[0].ActivityID
shopOrderID := logs[0].ShopOrderID
var snapshotSum int64
if shopOrderID != "" {
for _, inv := range invRows {
if strings.Contains(inv.Remark, shopOrderID) {
snapshotSum += inv.ValueCents
}
}
}
if snapshotSum > 0 {
prizeByActivity[aid] += snapshotSum
continue
}
for _, l := range logs {
prizeByActivity[aid] += prizeCostMap[l.PrizeID]
}
}
for aid, val := range prizeByActivity {
lsPrizeStats = append(lsPrizeStats, lsPrizeStat{
ActivityID: aid,
PrizeValue: val,
})
}
}
lsPrizeMap := make(map[int64]int64) lsPrizeMap := make(map[int64]int64)
for _, p := range lsPrizeStats { for _, p := range lsPrizeStats {
@ -211,7 +310,7 @@ func (h *handler) GetUserSpendingDashboard() core.HandlerFunc {
CategoryName: catName, CategoryName: catName,
Spending: s.Spending, Spending: s.Spending,
PrizeValue: prize, PrizeValue: prize,
Profit: s.Spending - prize, Profit: func() int64 { p, _ := financesvc.ComputeProfit(s.Spending, prize); return p }(),
OrderCount: s.OrderCount, OrderCount: s.OrderCount,
} }
activities = append(activities, item) activities = append(activities, item)
@ -230,7 +329,7 @@ func (h *handler) GetUserSpendingDashboard() core.HandlerFunc {
CategoryName: "直播间", CategoryName: "直播间",
Spending: ls.Spending, Spending: ls.Spending,
PrizeValue: prize, PrizeValue: prize,
Profit: ls.Spending - prize, Profit: func() int64 { p, _ := financesvc.ComputeProfit(ls.Spending, prize); return p }(),
OrderCount: ls.OrderCount, OrderCount: ls.OrderCount,
} }
activities = append(activities, item) activities = append(activities, item)
@ -241,7 +340,7 @@ func (h *handler) GetUserSpendingDashboard() core.HandlerFunc {
rsp.TotalSpend = totalSpend rsp.TotalSpend = totalSpend
rsp.TotalPrize = totalPrize rsp.TotalPrize = totalPrize
rsp.TotalProfit = totalSpend - totalPrize rsp.TotalProfit, _ = financesvc.ComputeProfit(totalSpend, totalPrize)
rsp.TotalOrders = totalOrders rsp.TotalOrders = totalOrders
rsp.Activities = activities rsp.Activities = activities

View File

@ -6,9 +6,12 @@ import (
"bindbox-game/internal/pkg/validation" "bindbox-game/internal/pkg/validation"
"bindbox-game/internal/service/douyin" "bindbox-game/internal/service/douyin"
"context" "context"
"errors"
"fmt" "fmt"
"io"
"net/http" "net/http"
"strconv" "strconv"
"strings"
"time" "time"
) )
@ -17,6 +20,7 @@ import (
type getDouyinConfigResponse struct { type getDouyinConfigResponse struct {
Cookie string `json:"cookie"` Cookie string `json:"cookie"`
IntervalMinutes int `json:"interval_minutes"` IntervalMinutes int `json:"interval_minutes"`
Proxy string `json:"proxy"`
} }
func (h *handler) GetDouyinConfig() core.HandlerFunc { func (h *handler) GetDouyinConfig() core.HandlerFunc {
@ -29,6 +33,7 @@ func (h *handler) GetDouyinConfig() core.HandlerFunc {
ctx.Payload(getDouyinConfigResponse{ ctx.Payload(getDouyinConfigResponse{
Cookie: cfg.Cookie, Cookie: cfg.Cookie,
IntervalMinutes: cfg.IntervalMinutes, IntervalMinutes: cfg.IntervalMinutes,
Proxy: cfg.Proxy,
}) })
} }
} }
@ -36,6 +41,7 @@ func (h *handler) GetDouyinConfig() core.HandlerFunc {
type saveDouyinConfigRequest struct { type saveDouyinConfigRequest struct {
Cookie string `json:"cookie"` Cookie string `json:"cookie"`
IntervalMinutes int `json:"interval_minutes"` IntervalMinutes int `json:"interval_minutes"`
Proxy string `json:"proxy"`
} }
func (h *handler) SaveDouyinConfig() core.HandlerFunc { func (h *handler) SaveDouyinConfig() core.HandlerFunc {
@ -46,7 +52,7 @@ func (h *handler) SaveDouyinConfig() core.HandlerFunc {
return return
} }
if err := h.douyinSvc.SaveConfig(ctx.RequestContext(), req.Cookie, req.IntervalMinutes); err != nil { if err := h.douyinSvc.SaveConfig(ctx.RequestContext(), req.Cookie, req.Proxy, req.IntervalMinutes); err != nil {
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ServerError, validation.Error(err))) ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ServerError, validation.Error(err)))
return return
} }
@ -58,9 +64,12 @@ func (h *handler) SaveDouyinConfig() core.HandlerFunc {
// ---------- 抖店订单列表 API ---------- // ---------- 抖店订单列表 API ----------
type listDouyinOrdersRequest struct { type listDouyinOrdersRequest struct {
Page int `form:"page"` Page int `form:"page"`
PageSize int `form:"page_size"` PageSize int `form:"page_size"`
Status *int `form:"status"` Status *int `form:"status"`
Match string `form:"match_status"`
ShopOrderID string `form:"shop_order_id"`
DouyinUserID string `form:"douyin_user_id"`
} }
type douyinOrderItem struct { type douyinOrderItem struct {
@ -95,7 +104,16 @@ func (h *handler) ListDouyinOrders() core.HandlerFunc {
return return
} }
orders, total, err := h.douyinSvc.ListOrders(ctx.RequestContext(), req.Page, req.PageSize, req.Status) filter := &douyin.ListOrdersFilter{
Status: req.Status,
}
if req.Match != "" {
filter.MatchStatus = &req.Match
}
filter.ShopOrderID = strings.TrimSpace(req.ShopOrderID)
filter.DouyinUserID = strings.TrimSpace(req.DouyinUserID)
orders, total, err := h.douyinSvc.ListOrders(ctx.RequestContext(), req.Page, req.PageSize, filter)
if err != nil { if err != nil {
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ServerError, validation.Error(err))) ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ServerError, validation.Error(err)))
return return
@ -154,30 +172,66 @@ func (h *handler) ListDouyinOrders() core.HandlerFunc {
// ---------- 手动同步 API ---------- // ---------- 手动同步 API ----------
type syncDouyinOrdersResponse struct { type syncDouyinOrdersResponse struct {
Message string `json:"message"` Message string `json:"message"`
TotalFetched int `json:"total_fetched"` TotalFetched int `json:"total_fetched"`
NewOrders int `json:"new_orders"` NewOrders int `json:"new_orders"`
MatchedUsers int `json:"matched_users"` MatchedUsers int `json:"matched_users"`
TotalUsers int `json:"total_users"`
ProcessedUsers int `json:"processed_users"`
SkippedUsers int `json:"skipped_users"`
ElapsedMS int64 `json:"elapsed_ms"`
}
type syncDouyinOrdersRequest struct {
OnlyUnmatched *bool `json:"only_unmatched"`
MaxUsers int `json:"max_users"`
BatchSize int `json:"batch_size"`
Concurrency int `json:"concurrency"`
InterBatchDelayMS *int `json:"inter_batch_delay_ms"`
} }
func (h *handler) SyncDouyinOrders() core.HandlerFunc { func (h *handler) SyncDouyinOrders() core.HandlerFunc {
return func(ctx core.Context) { return func(ctx core.Context) {
req := new(syncDouyinOrdersRequest)
if err := ctx.ShouldBindJSON(req); err != nil && !errors.Is(err, io.EOF) {
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, validation.Error(err)))
return
}
fetchOpts := &douyin.FetchOptions{
OnlyUnmatched: true,
MaxUsers: req.MaxUsers,
BatchSize: req.BatchSize,
Concurrency: req.Concurrency,
}
if req.OnlyUnmatched != nil {
fetchOpts.OnlyUnmatched = *req.OnlyUnmatched
}
if req.InterBatchDelayMS != nil {
delay := time.Duration(*req.InterBatchDelayMS) * time.Millisecond
fetchOpts.InterBatchDelay = delay
}
// 使用独立 Context 防止 HTTP 请求断开导致同步中断 // 使用独立 Context 防止 HTTP 请求断开导致同步中断
// 设置 5 分钟超时,确保有足够时间完成全量同步 // 设置 5 分钟超时,确保有足够时间完成全量同步
bgCtx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) bgCtx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
defer cancel() defer cancel()
result, err := h.douyinSvc.FetchAndSyncOrders(bgCtx) result, err := h.douyinSvc.FetchAndSyncOrders(bgCtx, fetchOpts)
if err != nil { if err != nil {
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ServerError, validation.Error(err))) ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ServerError, validation.Error(err)))
return return
} }
ctx.Payload(syncDouyinOrdersResponse{ ctx.Payload(syncDouyinOrdersResponse{
Message: "同步成功", Message: fmt.Sprintf("同步成功,处理 %d/%d 个用户,用时 %.2f 秒", result.ProcessedUsers, result.TotalUsers, float64(result.ElapsedMS)/1000.0),
TotalFetched: result.TotalFetched, TotalFetched: result.TotalFetched,
NewOrders: result.NewOrders, NewOrders: result.NewOrders,
MatchedUsers: result.MatchedUsers, MatchedUsers: result.MatchedUsers,
TotalUsers: result.TotalUsers,
ProcessedUsers: result.ProcessedUsers,
SkippedUsers: result.SkippedUsers,
ElapsedMS: result.ElapsedMS,
}) })
} }
} }
@ -254,6 +308,16 @@ type manualGrantPrizesResponse struct {
GrantedCount int `json:"granted_count"` GrantedCount int `json:"granted_count"`
} }
type grantOrderRewardResponse struct {
ShopOrderID string `json:"shop_order_id"`
Message string `json:"message"`
Granted bool `json:"granted"`
RewardGranted int32 `json:"reward_granted"`
ProductCount int32 `json:"product_count"`
OrderStatus int32 `json:"order_status"`
LocalUserID string `json:"local_user_id"`
}
// ManualGrantPrizes 手动发放直播间奖品 // ManualGrantPrizes 手动发放直播间奖品
func (h *handler) ManualGrantPrizes() core.HandlerFunc { func (h *handler) ManualGrantPrizes() core.HandlerFunc {
return func(ctx core.Context) { return func(ctx core.Context) {
@ -274,6 +338,28 @@ func (h *handler) ManualGrantPrizes() core.HandlerFunc {
} }
} }
// GrantOrderReward 手动触发单个订单的发奖
func (h *handler) GrantOrderReward() core.HandlerFunc {
return func(ctx core.Context) {
shopOrderID := ctx.Param("shop_order_id")
if shopOrderID == "" {
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, "shop_order_id 不能为空"))
return
}
bgCtx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
defer cancel()
res, err := h.douyinSvc.GrantOrderReward(bgCtx, shopOrderID)
if err != nil {
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ServerError, validation.Error(err)))
return
}
ctx.Payload(grantOrderRewardResponse(*res))
}
}
// ---------- 辅助函数 ---------- // ---------- 辅助函数 ----------
func getOrderStatusText(status int32) string { func getOrderStatusText(status int32) string {

View File

@ -1,6 +1,9 @@
package admin package admin
import ( import (
"context"
"errors"
"fmt"
"net/http" "net/http"
"strconv" "strconv"
"strings" "strings"
@ -10,6 +13,7 @@ import (
"bindbox-game/internal/pkg/core" "bindbox-game/internal/pkg/core"
"bindbox-game/internal/pkg/validation" "bindbox-game/internal/pkg/validation"
"bindbox-game/internal/repository/mysql/model" "bindbox-game/internal/repository/mysql/model"
"bindbox-game/internal/service/channel"
"bindbox-game/internal/service/livestream" "bindbox-game/internal/service/livestream"
"gorm.io/gorm" "gorm.io/gorm"
@ -21,6 +25,7 @@ type createLivestreamActivityRequest struct {
Name string `json:"name" binding:"required"` Name string `json:"name" binding:"required"`
StreamerName string `json:"streamer_name"` StreamerName string `json:"streamer_name"`
StreamerContact string `json:"streamer_contact"` StreamerContact string `json:"streamer_contact"`
ChannelID *int64 `json:"channel_id"`
DouyinProductID string `json:"douyin_product_id"` DouyinProductID string `json:"douyin_product_id"`
OrderRewardType string `json:"order_reward_type"` // 下单奖励类型: flip_card/minesweeper OrderRewardType string `json:"order_reward_type"` // 下单奖励类型: flip_card/minesweeper
OrderRewardQuantity int32 `json:"order_reward_quantity"` // 下单奖励数量: 1-100 OrderRewardQuantity int32 `json:"order_reward_quantity"` // 下单奖励数量: 1-100
@ -34,6 +39,9 @@ type livestreamActivityResponse struct {
Name string `json:"name"` Name string `json:"name"`
StreamerName string `json:"streamer_name"` StreamerName string `json:"streamer_name"`
StreamerContact string `json:"streamer_contact"` StreamerContact string `json:"streamer_contact"`
ChannelID int64 `json:"channel_id"`
ChannelCode string `json:"channel_code"`
ChannelName string `json:"channel_name"`
AccessCode string `json:"access_code"` AccessCode string `json:"access_code"`
DouyinProductID string `json:"douyin_product_id"` DouyinProductID string `json:"douyin_product_id"`
OrderRewardType string `json:"order_reward_type"` // 下单奖励类型 OrderRewardType string `json:"order_reward_type"` // 下单奖励类型
@ -64,10 +72,35 @@ func (h *handler) CreateLivestreamActivity() core.HandlerFunc {
return return
} }
var channelCode string
var channelName string
if req.ChannelID != nil && *req.ChannelID > 0 {
if ch, err := h.channel.GetByID(ctx.RequestContext(), *req.ChannelID); err == nil {
channelCode = ch.Code
channelName = ch.Name
if req.StreamerName == "" {
req.StreamerName = ch.Name
}
} else if err == channel.ErrChannelNotFound {
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, "渠道不存在"))
return
} else {
ctx.AbortWithError(core.Error(http.StatusInternalServerError, code.ServerError, err.Error()))
return
}
}
input := livestream.CreateActivityInput{ input := livestream.CreateActivityInput{
Name: req.Name, Name: req.Name,
StreamerName: req.StreamerName, StreamerName: req.StreamerName,
StreamerContact: req.StreamerContact, StreamerContact: req.StreamerContact,
ChannelID: func() int64 {
if req.ChannelID != nil {
return *req.ChannelID
}
return 0
}(),
ChannelCode: channelCode,
DouyinProductID: req.DouyinProductID, DouyinProductID: req.DouyinProductID,
OrderRewardType: req.OrderRewardType, OrderRewardType: req.OrderRewardType,
OrderRewardQuantity: req.OrderRewardQuantity, OrderRewardQuantity: req.OrderRewardQuantity,
@ -91,11 +124,19 @@ func (h *handler) CreateLivestreamActivity() core.HandlerFunc {
return return
} }
displayChannelName := channelName
if displayChannelName == "" && activity.ChannelCode != "" {
displayChannelName = activity.ChannelCode
}
ctx.Payload(&livestreamActivityResponse{ ctx.Payload(&livestreamActivityResponse{
ID: activity.ID, ID: activity.ID,
Name: activity.Name, Name: activity.Name,
StreamerName: activity.StreamerName, StreamerName: activity.StreamerName,
StreamerContact: activity.StreamerContact, StreamerContact: activity.StreamerContact,
ChannelID: activity.ChannelID,
ChannelCode: activity.ChannelCode,
ChannelName: displayChannelName,
AccessCode: activity.AccessCode, AccessCode: activity.AccessCode,
DouyinProductID: activity.DouyinProductID, DouyinProductID: activity.DouyinProductID,
OrderRewardType: activity.OrderRewardType, OrderRewardType: activity.OrderRewardType,
@ -111,6 +152,7 @@ type updateLivestreamActivityRequest struct {
Name string `json:"name"` Name string `json:"name"`
StreamerName string `json:"streamer_name"` StreamerName string `json:"streamer_name"`
StreamerContact string `json:"streamer_contact"` StreamerContact string `json:"streamer_contact"`
ChannelID *int64 `json:"channel_id"`
DouyinProductID string `json:"douyin_product_id"` DouyinProductID string `json:"douyin_product_id"`
OrderRewardType string `json:"order_reward_type"` // 下单奖励类型 OrderRewardType string `json:"order_reward_type"` // 下单奖励类型
OrderRewardQuantity *int32 `json:"order_reward_quantity"` // 下单奖励数量 OrderRewardQuantity *int32 `json:"order_reward_quantity"` // 下单奖励数量
@ -146,6 +188,29 @@ func (h *handler) UpdateLivestreamActivity() core.HandlerFunc {
return return
} }
var channelCodeValue string
var channelCodePtr *string
if req.ChannelID != nil {
if *req.ChannelID > 0 {
if ch, err := h.channel.GetByID(ctx.RequestContext(), *req.ChannelID); err == nil {
channelCodeValue = ch.Code
channelCodePtr = &channelCodeValue
if req.StreamerName == "" {
req.StreamerName = ch.Name
}
} else if err == channel.ErrChannelNotFound {
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, "渠道不存在"))
return
} else {
ctx.AbortWithError(core.Error(http.StatusInternalServerError, code.ServerError, err.Error()))
return
}
} else {
channelCodeValue = ""
channelCodePtr = &channelCodeValue
}
}
input := livestream.UpdateActivityInput{ input := livestream.UpdateActivityInput{
Name: req.Name, Name: req.Name,
StreamerName: req.StreamerName, StreamerName: req.StreamerName,
@ -155,6 +220,8 @@ func (h *handler) UpdateLivestreamActivity() core.HandlerFunc {
OrderRewardQuantity: req.OrderRewardQuantity, OrderRewardQuantity: req.OrderRewardQuantity,
TicketPrice: req.TicketPrice, TicketPrice: req.TicketPrice,
Status: req.Status, Status: req.Status,
ChannelID: req.ChannelID,
ChannelCode: channelCodePtr,
} }
if req.StartTime != "" { if req.StartTime != "" {
@ -224,6 +291,14 @@ func (h *handler) ListLivestreamActivities() core.HandlerFunc {
return return
} }
channelIDs := make([]int64, 0, len(list))
for _, a := range list {
if a.ChannelID > 0 {
channelIDs = append(channelIDs, a.ChannelID)
}
}
channelNameMap := h.loadChannelNames(ctx.RequestContext(), channelIDs)
res := &listLivestreamActivitiesResponse{ res := &listLivestreamActivitiesResponse{
List: make([]livestreamActivityResponse, len(list)), List: make([]livestreamActivityResponse, len(list)),
Total: total, Total: total,
@ -245,6 +320,13 @@ func (h *handler) ListLivestreamActivities() core.HandlerFunc {
Status: a.Status, Status: a.Status,
CreatedAt: a.CreatedAt.Format("2006-01-02 15:04:05"), CreatedAt: a.CreatedAt.Format("2006-01-02 15:04:05"),
} }
item.ChannelID = a.ChannelID
item.ChannelCode = a.ChannelCode
if name := channelNameMap[a.ChannelID]; name != "" {
item.ChannelName = name
} else if a.ChannelCode != "" {
item.ChannelName = a.ChannelCode
}
if !a.StartTime.IsZero() { if !a.StartTime.IsZero() {
item.StartTime = a.StartTime.Format("2006-01-02 15:04:05") item.StartTime = a.StartTime.Format("2006-01-02 15:04:05")
} }
@ -283,11 +365,24 @@ func (h *handler) GetLivestreamActivity() core.HandlerFunc {
return return
} }
channelName := ""
if activity.ChannelID > 0 {
if names := h.loadChannelNames(ctx.RequestContext(), []int64{activity.ChannelID}); len(names) > 0 {
channelName = names[activity.ChannelID]
}
}
if channelName == "" && activity.ChannelCode != "" {
channelName = activity.ChannelCode
}
res := &livestreamActivityResponse{ res := &livestreamActivityResponse{
ID: activity.ID, ID: activity.ID,
Name: activity.Name, Name: activity.Name,
StreamerName: activity.StreamerName, StreamerName: activity.StreamerName,
StreamerContact: activity.StreamerContact, StreamerContact: activity.StreamerContact,
ChannelID: activity.ChannelID,
ChannelCode: activity.ChannelCode,
ChannelName: channelName,
AccessCode: activity.AccessCode, AccessCode: activity.AccessCode,
DouyinProductID: activity.DouyinProductID, DouyinProductID: activity.DouyinProductID,
OrderRewardType: activity.OrderRewardType, OrderRewardType: activity.OrderRewardType,
@ -335,6 +430,41 @@ func (h *handler) DeleteLivestreamActivity() core.HandlerFunc {
} }
} }
func (h *handler) loadChannelNames(ctx context.Context, ids []int64) map[int64]string {
result := make(map[int64]string)
if len(ids) == 0 {
return result
}
unique := make([]int64, 0, len(ids))
seen := make(map[int64]struct{})
for _, id := range ids {
if id <= 0 {
continue
}
if _, ok := seen[id]; ok {
continue
}
seen[id] = struct{}{}
unique = append(unique, id)
}
if len(unique) == 0 {
return result
}
channels, err := h.readDB.Channels.WithContext(ctx).
Select(h.readDB.Channels.ID, h.readDB.Channels.Name).
Where(h.readDB.Channels.ID.In(unique...)).
Find()
if err != nil {
return result
}
for _, ch := range channels {
result[ch.ID] = ch.Name
}
return result
}
// ========== 直播间奖品管理 ========== // ========== 直播间奖品管理 ==========
type createLivestreamPrizeRequest struct { type createLivestreamPrizeRequest struct {
@ -630,6 +760,17 @@ func (h *handler) ListLivestreamDrawLogs() core.HandlerFunc {
return return
} }
var activity model.LivestreamActivities
if err := h.repo.GetDbR().Select("id, ticket_price").Where("id = ?", activityID).First(&activity).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
ctx.AbortWithError(core.Error(http.StatusNotFound, code.ParamBindError, "活动不存在"))
} else {
ctx.AbortWithError(core.Error(http.StatusInternalServerError, code.ServerError, err.Error()))
}
return
}
ticketPrice := int64(activity.TicketPrice)
req := new(listLivestreamDrawLogsRequest) req := new(listLivestreamDrawLogsRequest)
_ = ctx.ShouldBindForm(req) _ = ctx.ShouldBindForm(req)
@ -710,10 +851,11 @@ func (h *handler) ListLivestreamDrawLogs() core.HandlerFunc {
DouyinOrderID int64 DouyinOrderID int64
PrizeID int64 PrizeID int64
ShopOrderID string // 用于关联退款状态查 douyin_orders ShopOrderID string // 用于关联退款状态查 douyin_orders
LocalUserID int64
} }
var metas []logMeta var metas []logMeta
// 使用不带分页的 db 克隆 // 使用不带分页的 db 克隆
if err := db.Session(&gorm.Session{}).Select("douyin_order_id, prize_id, shop_order_id").Scan(&metas).Error; err == nil { if err := db.Session(&gorm.Session{}).Select("douyin_order_id, prize_id, shop_order_id, local_user_id").Scan(&metas).Error; err == nil {
orderIDs := make([]int64, 0, len(metas)) orderIDs := make([]int64, 0, len(metas))
distinctOrderIDs := make(map[int64]bool) distinctOrderIDs := make(map[int64]bool)
prizeIDCount := make(map[int64]int64) prizeIDCount := make(map[int64]int64)
@ -730,61 +872,97 @@ func (h *handler) ListLivestreamDrawLogs() core.HandlerFunc {
if len(orderIDs) > 0 { if len(orderIDs) > 0 {
var orders []model.DouyinOrders var orders []model.DouyinOrders
// 分批查询防止 IN 子句过长? 暂时假设量级可控 // 分批查询防止 IN 子句过长? 暂时假设量级可控
h.repo.GetDbR().Select("id, actual_pay_amount, order_status"). h.repo.GetDbR().Select("id, actual_pay_amount, order_status, pay_type_desc, product_count").
Where("id IN ?", orderIDs).Find(&orders) Where("id IN ?", orderIDs).Find(&orders)
orderRefundMap := make(map[int64]bool) orderRefundMap := make(map[int64]bool)
for _, o := range orders { for _, o := range orders {
// 统计营收 (总流水) // 统计营收 (总流水)
stats.TotalRev += int64(o.ActualPayAmount) orderAmount := calcLivestreamOrderAmount(&o, ticketPrice)
stats.TotalRev += orderAmount
if o.OrderStatus == 4 { // 已退款 if o.OrderStatus == 4 { // 已退款
stats.TotalRefund += int64(o.ActualPayAmount) stats.TotalRefund += orderAmount
orderRefundMap[o.ID] = true orderRefundMap[o.ID] = true
} }
} }
// 4. 统计成本 (剔除退款订单) // 4. 统计成本 (剔除退款订单,优先资产快照,缺失回退 prize.cost_price)
for _, m := range metas { for _, m := range metas {
if !orderRefundMap[m.DouyinOrderID] { if !orderRefundMap[m.DouyinOrderID] {
prizeIDCount[m.PrizeID]++ prizeIDCount[m.PrizeID]++
} }
} }
// 计算奖品成本 (逻辑参考 GetLivestreamStats简化版) prizeCostMap := make(map[int64]int64)
if len(prizeIDCount) > 0 { if len(prizeIDCount) > 0 {
prizeIDs := make([]int64, 0, len(prizeIDCount)) prizeIDs := make([]int64, 0, len(prizeIDCount))
for pid := range prizeIDCount { for pid := range prizeIDCount {
prizeIDs = append(prizeIDs, pid) prizeIDs = append(prizeIDs, pid)
} }
var prizes []model.LivestreamPrizes var prizes []model.LivestreamPrizes
h.repo.GetDbR().Where("id IN ?", prizeIDs).Find(&prizes) h.repo.GetDbR().Select("id, cost_price").Where("id IN ?", prizeIDs).Find(&prizes)
// 批量获取关联商品
productIDs := make([]int64, 0)
for _, p := range prizes { for _, p := range prizes {
if p.CostPrice == 0 && p.ProductID > 0 { prizeCostMap[p.ID] = p.CostPrice
productIDs = append(productIDs, p.ProductID)
}
} }
productPriceMap := make(map[int64]int64) }
if len(productIDs) > 0 {
var products []model.Products // 预加载用户资产快照用于 shop_order_id 命中
h.repo.GetDbR().Select("id, price").Where("id IN ?", productIDs).Find(&products) type invRow struct {
for _, prod := range products { UserID int64
productPriceMap[prod.ID] = prod.Price ValueCents int64
Remark string
}
var invRows []invRow
_ = h.repo.GetDbR().Table("user_inventory").
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.remark").
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").
Where("user_inventory.status IN (1,3)").
Where("COALESCE(user_inventory.remark, '') NOT LIKE ?", "%void%").
Where("user_inventory.user_id > 0").
Scan(&invRows).Error
invByUser := make(map[int64][]invRow)
for _, v := range invRows {
invByUser[v.UserID] = append(invByUser[v.UserID], v)
}
metasByKey := make(map[string][]logMeta)
keyUser := make(map[string]int64)
keyOrder := make(map[string]string)
for _, m := range metas {
if orderRefundMap[m.DouyinOrderID] {
continue
}
key := fmt.Sprintf("%d|%s", m.LocalUserID, m.ShopOrderID)
metasByKey[key] = append(metasByKey[key], m)
keyUser[key] = m.LocalUserID
keyOrder[key] = m.ShopOrderID
}
for key, rows := range metasByKey {
if len(rows) == 0 {
continue
}
uid := keyUser[key]
shopOrderID := keyOrder[key]
var snapshotSum int64
if uid > 0 && shopOrderID != "" {
for _, inv := range invByUser[uid] {
if strings.Contains(inv.Remark, shopOrderID) {
snapshotSum += inv.ValueCents
}
} }
} }
for _, p := range prizes { if snapshotSum > 0 {
cost := p.CostPrice stats.TotalCost += snapshotSum
if cost == 0 && p.ProductID > 0 { continue
cost = productPriceMap[p.ProductID] }
}
count := prizeIDCount[p.ID] for _, r := range rows {
stats.TotalCost += cost * count stats.TotalCost += prizeCostMap[r.PrizeID]
} }
} }
} }

View File

@ -0,0 +1,36 @@
package admin
import (
"strings"
"bindbox-game/internal/repository/mysql/model"
)
// calcLivestreamOrderAmount returns the effective revenue contribution for a Douyin order.
// For regular paid orders it returns actual_pay_amount; for 次卡订单 (actual pay is 0 but
// pay_type_desc contains 次卡), it falls back to the activity ticket price.
func calcLivestreamOrderAmount(order *model.DouyinOrders, ticketPrice int64) int64 {
if order == nil {
return 0
}
amount := int64(order.ActualPayAmount)
if amount > 0 || ticketPrice <= 0 {
return amount
}
desc := strings.ReplaceAll(strings.TrimSpace(order.PayTypeDesc), " ", "")
if desc == "" {
return amount
}
if strings.Contains(desc, "次卡") {
multiplier := int64(order.ProductCount)
if multiplier <= 0 {
multiplier = 1
}
return ticketPrice * multiplier
}
return amount
}

View File

@ -4,6 +4,7 @@ import (
"math" "math"
"net/http" "net/http"
"strconv" "strconv"
"strings"
"bindbox-game/internal/code" "bindbox-game/internal/code"
"bindbox-game/internal/pkg/core" "bindbox-game/internal/pkg/core"
@ -77,54 +78,98 @@ func (h *handler) GetLivestreamStats() core.HandlerFunc {
ctx.AbortWithError(core.Error(http.StatusNotFound, code.ServerError, "活动不存在")) ctx.AbortWithError(core.Error(http.StatusNotFound, code.ServerError, "活动不存在"))
return return
} }
// ticketPrice 暂未使用,但在统计中可能作为参考,这里移除未使用的报错 ticketPrice := int64(activity.TicketPrice)
// 2. 统计营收/退款基于订单去重并兼容次卡0元订单按门票价计入
type orderRef struct {
OrderID int64
FirstDrawAt time.Time
}
orderQuery := h.repo.GetDbR().Table(model.TableNameLivestreamDrawLogs).
Select("douyin_order_id AS order_id, MIN(created_at) AS first_draw_at").
Where("activity_id = ?", id).
Where("douyin_order_id > 0")
// 2. 核心统计逻辑重构:从关联的订单表获取真实金额
// 使用子查询或 Join 来获取不重复的订单金额,确保即便一次订单对应多次抽奖,金额也不重计
var totalRevenue, orderCount int64
// 统计营收:来自已参与过抽奖(产生过日志)且未退款的订单 (order_status != 4)
// 使用 actual_pay_amount (实付金额)
queryRevenue := `
SELECT
CAST(IFNULL(SUM(distinct_orders.actual_pay_amount), 0) AS SIGNED) as rev,
COUNT(*) as cnt
FROM (
SELECT DISTINCT o.id, o.actual_pay_amount
FROM douyin_orders o
JOIN livestream_draw_logs l ON o.id = l.douyin_order_id
WHERE l.activity_id = ?
`
if startTime != nil { if startTime != nil {
queryRevenue += " AND l.created_at >= '" + startTime.Format("2006-01-02 15:04:05") + "'" orderQuery = orderQuery.Where("created_at >= ?", startTime)
} }
if endTime != nil { if endTime != nil {
queryRevenue += " AND l.created_at <= '" + endTime.Format("2006-01-02 15:04:05") + "'" orderQuery = orderQuery.Where("created_at <= ?", endTime)
} }
queryRevenue += ") as distinct_orders"
_ = h.repo.GetDbR().Raw(queryRevenue, id).Row().Scan(&totalRevenue, &orderCount) var orderRefs []orderRef
if err := orderQuery.Group("douyin_order_id").Scan(&orderRefs).Error; err != nil {
// 统计退款:来自已参与过抽奖且标记为退款的订单 (order_status = 4) ctx.AbortWithError(core.Error(http.StatusInternalServerError, code.ServerError, err.Error()))
var totalRefund, refundCount int64 return
queryRefund := `
SELECT
CAST(IFNULL(SUM(distinct_orders.actual_pay_amount), 0) AS SIGNED) as ref_amt,
COUNT(*) as ref_cnt
FROM (
SELECT DISTINCT o.id, o.actual_pay_amount
FROM douyin_orders o
JOIN livestream_draw_logs l ON o.id = l.douyin_order_id
WHERE l.activity_id = ? AND o.order_status = 4
`
if startTime != nil {
queryRefund += " AND l.created_at >= '" + startTime.Format("2006-01-02 15:04:05") + "'"
} }
if endTime != nil {
queryRefund += " AND l.created_at <= '" + endTime.Format("2006-01-02 15:04:05") + "'"
}
queryRefund += ") as distinct_orders"
_ = h.repo.GetDbR().Raw(queryRefund, id).Row().Scan(&totalRefund, &refundCount) orderIDs := make([]int64, 0, len(orderRefs))
for _, ref := range orderRefs {
if ref.OrderID == 0 {
continue
}
orderIDs = append(orderIDs, ref.OrderID)
}
orderMap := make(map[int64]*model.DouyinOrders, len(orderIDs))
if len(orderIDs) > 0 {
var orders []model.DouyinOrders
if err := h.repo.GetDbR().
Select("id, shop_order_id, actual_pay_amount, order_status, pay_type_desc, product_count").
Where("id IN ?", orderIDs).
Find(&orders).Error; err != nil {
ctx.AbortWithError(core.Error(http.StatusInternalServerError, code.ServerError, err.Error()))
return
}
for i := range orders {
orderMap[orders[i].ID] = &orders[i]
}
}
dailyMap := make(map[string]*dailyLivestreamStats)
refundedShopOrderIDs := make(map[string]bool)
var totalRevenue, totalRefund int64
var orderCount, refundCount int64
for _, ref := range orderRefs {
order := orderMap[ref.OrderID]
if order == nil {
continue
}
amount := calcLivestreamOrderAmount(order, ticketPrice)
if amount < 0 {
amount = 0
}
dateKey := ref.FirstDrawAt.In(time.Local).Format("2006-01-02")
if ref.FirstDrawAt.IsZero() {
dateKey = time.Now().In(time.Local).Format("2006-01-02")
}
refunded := order.OrderStatus == 4
orderCount++
totalRevenue += amount
if refunded {
totalRefund += amount
refundCount++
}
if refunded && order.ShopOrderID != "" {
refundedShopOrderIDs[order.ShopOrderID] = true
}
ds := dailyMap[dateKey]
if ds == nil {
ds = &dailyLivestreamStats{Date: dateKey}
dailyMap[dateKey] = ds
}
ds.TotalRevenue += amount
ds.OrderCount++
if refunded {
ds.TotalRefund += amount
ds.RefundCount++
}
}
// 3. 获取所有抽奖记录用于成本计算 // 3. 获取所有抽奖记录用于成本计算
var drawLogs []model.LivestreamDrawLogs var drawLogs []model.LivestreamDrawLogs
@ -138,141 +183,130 @@ func (h *handler) GetLivestreamStats() core.HandlerFunc {
db.Find(&drawLogs) db.Find(&drawLogs)
// 3.1 获取该时间段内所有退款的 shop_order_id 集合,用于过滤成本 // 3.1 获取该时间段内所有退款的 shop_order_id 集合,用于过滤成本
refundedShopOrderIDs := make(map[string]bool) // 4. 计算成本(优先资产快照 user_inventory.value_cents缺失回退 livestream_prizes.cost_price
var refundedOrders []string prizeCostMap := make(map[int64]int64)
qRefundIDs := ` prizeIDs := make([]int64, 0)
SELECT DISTINCT o.shop_order_id prizeIDSet := make(map[int64]struct{})
FROM douyin_orders o userIDSet := make(map[int64]struct{})
JOIN livestream_draw_logs l ON o.id = l.douyin_order_id for _, log := range drawLogs {
WHERE l.activity_id = ? AND o.order_status = 4 if log.PrizeID > 0 {
` if _, ok := prizeIDSet[log.PrizeID]; !ok {
if startTime != nil { prizeIDSet[log.PrizeID] = struct{}{}
qRefundIDs += " AND l.created_at >= '" + startTime.Format("2006-01-02 15:04:05") + "'" prizeIDs = append(prizeIDs, log.PrizeID)
} }
if endTime != nil { }
qRefundIDs += " AND l.created_at <= '" + endTime.Format("2006-01-02 15:04:05") + "'" if log.LocalUserID > 0 {
} userIDSet[log.LocalUserID] = struct{}{}
h.repo.GetDbR().Raw(qRefundIDs, id).Scan(&refundedOrders) }
for _, oid := range refundedOrders {
refundedShopOrderIDs[oid] = true
} }
// 4. 计算成本(使用 draw_logs.product_id 直接关联 products 表) if len(prizeIDs) > 0 {
// 收集未退款订单的 product_id 和对应数量 var prizes []model.LivestreamPrizes
productIDCountMap := make(map[int64]int64) h.repo.GetDbR().Select("id, cost_price").Where("id IN ?", prizeIDs).Find(&prizes)
for _, p := range prizes {
prizeCostMap[p.ID] = p.CostPrice
}
}
type inventorySnapshot struct {
UserID int64
ValueCents int64
Remark string
CreatedAt time.Time
}
invByUser := make(map[int64][]inventorySnapshot)
if len(userIDSet) > 0 {
userIDs := make([]int64, 0, len(userIDSet))
for uid := range userIDSet {
userIDs = append(userIDs, uid)
}
var inventories []inventorySnapshot
invDB := h.repo.GetDbR().Table("user_inventory").
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.remark, user_inventory.created_at").
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").
Where("user_id IN ?", userIDs).
Where("status IN (1, 3)").
Where("COALESCE(user_inventory.remark, '') NOT LIKE ?", "%void%")
if startTime != nil {
invDB = invDB.Where("created_at >= ?", startTime.Add(-2*time.Hour))
}
if endTime != nil {
invDB = invDB.Where("created_at <= ?", endTime.Add(24*time.Hour))
}
_ = invDB.Scan(&inventories).Error
for _, inv := range inventories {
invByUser[inv.UserID] = append(invByUser[inv.UserID], inv)
}
}
type logRef struct {
PrizeID int64
DateKey string
}
logsByKey := make(map[string][]logRef)
keyUser := make(map[string]int64)
keyOrder := make(map[string]string)
for _, log := range drawLogs { for _, log := range drawLogs {
// 排除已退款的订单
if refundedShopOrderIDs[log.ShopOrderID] { if refundedShopOrderIDs[log.ShopOrderID] {
continue continue
} }
// 使用 draw_logs 中记录的 product_id key := strconv.FormatInt(log.LocalUserID, 10) + "|" + log.ShopOrderID
if log.ProductID > 0 { logsByKey[key] = append(logsByKey[key], logRef{
productIDCountMap[log.ProductID]++ PrizeID: log.PrizeID,
} DateKey: log.CreatedAt.Format("2006-01-02"),
})
keyUser[key] = log.LocalUserID
keyOrder[key] = log.ShopOrderID
} }
costByDate := make(map[string]int64)
var totalCost int64 var totalCost int64
productCostMap := make(map[int64]int64) for key, refs := range logsByKey {
if len(productIDCountMap) > 0 { if len(refs) == 0 {
productIDs := make([]int64, 0, len(productIDCountMap)) continue
for pid := range productIDCountMap {
productIDs = append(productIDs, pid)
} }
uid := keyUser[key]
shopOrderID := keyOrder[key]
var products []model.Products var snapshotSum int64
h.repo.GetDbR().Where("id IN ?", productIDs).Find(&products) if uid > 0 && shopOrderID != "" {
for _, p := range products { for _, inv := range invByUser[uid] {
productCostMap[p.ID] = p.Price if strings.Contains(inv.Remark, shopOrderID) {
} snapshotSum += inv.ValueCents
}
for productID, count := range productIDCountMap {
if cost, ok := productCostMap[productID]; ok {
totalCost += cost * count
} }
} }
}
// 构建 productID -> cost 映射供每日统计使用 if snapshotSum > 0 {
prizeCostMap := productCostMap avg := snapshotSum / int64(len(refs))
rem := snapshotSum - avg*int64(len(refs))
// 5. 按天分组统计 for i, r := range refs {
dailyMap := make(map[string]*dailyLivestreamStats) c := avg
if i == 0 {
// 5.1 统计每日营收和退款(直接累加订单实付金额) c += rem
type DailyAmount struct { }
DateKey string totalCost += c
Amount int64 costByDate[r.DateKey] += c
Count int64 }
IsRefunded int32
}
var dailyAmounts []DailyAmount
queryDailyCorrect := `
SELECT
date_key,
CAST(SUM(actual_pay_amount) AS SIGNED) as amount,
COUNT(id) as cnt,
refund_flag as is_refunded
FROM (
SELECT
o.id,
DATE_FORMAT(MIN(l.created_at), '%Y-%m-%d') as date_key,
o.actual_pay_amount,
IF(o.order_status = 4, 1, 0) as refund_flag
FROM douyin_orders o
JOIN livestream_draw_logs l ON o.id = l.douyin_order_id
WHERE l.activity_id = ?
`
if startTime != nil {
queryDailyCorrect += " AND l.created_at >= '" + startTime.Format("2006-01-02 15:04:05") + "'"
}
if endTime != nil {
queryDailyCorrect += " AND l.created_at <= '" + endTime.Format("2006-01-02 15:04:05") + "'"
}
queryDailyCorrect += `
GROUP BY o.id
) as t
GROUP BY date_key, is_refunded
`
rows, _ := h.repo.GetDbR().Raw(queryDailyCorrect, id).Rows()
defer rows.Close()
for rows.Next() {
var da DailyAmount
_ = rows.Scan(&da.DateKey, &da.Amount, &da.Count, &da.IsRefunded)
dailyAmounts = append(dailyAmounts, da)
}
for _, da := range dailyAmounts {
if _, ok := dailyMap[da.DateKey]; !ok {
dailyMap[da.DateKey] = &dailyLivestreamStats{Date: da.DateKey}
}
// 修正口径:营收(Revenue) = 总流水 (含退款与未退款)
// 这样下面的 NetProfit = TotalRevenue - TotalRefund - TotalCost 才不会双重扣除
dailyMap[da.DateKey].TotalRevenue += da.Amount
dailyMap[da.DateKey].OrderCount += da.Count
if da.IsRefunded == 1 {
dailyMap[da.DateKey].TotalRefund += da.Amount
dailyMap[da.DateKey].RefundCount += da.Count
}
}
// 5.2 统计每日成本(基于 Logs 的 ProductID
for _, log := range drawLogs {
// 排除退款订单
if refundedShopOrderIDs[log.ShopOrderID] {
continue continue
} }
if log.ProductID <= 0 {
continue for _, r := range refs {
c := prizeCostMap[r.PrizeID]
totalCost += c
costByDate[r.DateKey] += c
} }
dateKey := log.CreatedAt.Format("2006-01-02") }
// 5. 按天分组统计 (营收/退款已在 dailyMap 中累计),补充成本
for dateKey, c := range costByDate {
ds := dailyMap[dateKey] ds := dailyMap[dateKey]
if ds != nil { if ds == nil {
if cost, ok := prizeCostMap[log.ProductID]; ok { ds = &dailyLivestreamStats{Date: dateKey}
ds.TotalCost += cost dailyMap[dateKey] = ds
}
} }
ds.TotalCost += c
} }
// 6. 汇总每日数据并计算总体指标 // 6. 汇总每日数据并计算总体指标

View File

@ -177,6 +177,14 @@ func (h *handler) CreateRefund() core.HandlerFunc {
// 全额退款:回收中奖资产与奖品库存(包含已兑换积分的资产) // 全额退款:回收中奖资产与奖品库存(包含已兑换积分的资产)
svc := usersvc.New(h.logger, h.repo) svc := usersvc.New(h.logger, h.repo)
rate := int64(1)
if cfgRate, _ := h.readDB.SystemConfigs.WithContext(ctx.RequestContext()).Where(h.readDB.SystemConfigs.ConfigKey.Eq("points_exchange_per_cent")).First(); cfgRate != nil {
var rv int64
_, _ = fmt.Sscanf(cfgRate.ConfigValue, "%d", &rv)
if rv > 0 {
rate = rv
}
}
// 直接使用已初始化的 activity service 清理格位 // 直接使用已初始化的 activity service 清理格位
_ = h.activity.ClearIchibanPositionsByOrderID(ctx.RequestContext(), order.ID) _ = h.activity.ClearIchibanPositionsByOrderID(ctx.RequestContext(), order.ID)
@ -191,18 +199,25 @@ func (h *handler) CreateRefund() core.HandlerFunc {
} }
} else if inv.Status == 3 { } else if inv.Status == 3 {
// 状态3已兑换扣除积分并作废 // 状态3已兑换扣除积分并作废
deductPoints := int64(0)
matches := rePoints.FindStringSubmatch(inv.Remark) matches := rePoints.FindStringSubmatch(inv.Remark)
if len(matches) > 1 { if len(matches) > 1 {
p, _ := strconv.ParseInt(matches[1], 10, 64) p, _ := strconv.ParseInt(matches[1], 10, 64)
if p > 0 { if p > 0 {
// 扣除积分(记录流水)- 使用柔性扣减 deductPoints = p
_, consumed, err := svc.ConsumePointsForRefund(ctx.RequestContext(), order.UserID, p, "user_inventory", strconv.FormatInt(inv.ID, 10), "订单退款回收兑换积分") }
if err != nil { }
h.logger.Error(fmt.Sprintf("refund reclaim points failed: order=%s user=%d points=%d err=%v", order.OrderNo, order.UserID, p, err)) if deductPoints <= 0 && inv.ValueCents > 0 {
} deductPoints = inv.ValueCents * rate
if consumed < p { }
pointsShortage = true if deductPoints > 0 {
} // 扣除积分(记录流水)- 使用柔性扣减
_, consumed, err := svc.ConsumePointsForRefund(ctx.RequestContext(), order.UserID, deductPoints, "user_inventory", strconv.FormatInt(inv.ID, 10), "订单退款回收兑换积分")
if err != nil {
h.logger.Error(fmt.Sprintf("refund reclaim points failed: order=%s user=%d points=%d err=%v", order.OrderNo, order.UserID, deductPoints, err))
}
if consumed < deductPoints {
pointsShortage = true
} }
} }
// 更新状态为2 (作废) // 更新状态为2 (作废)

View File

@ -12,18 +12,20 @@ import (
) )
type rewardItem struct { type rewardItem struct {
ID int64 `json:"id"` ID int64 `json:"id"`
ProductID int64 `json:"product_id"` ProductID int64 `json:"product_id"`
Weight float64 `json:"weight" binding:"required"` Weight float64 `json:"weight" binding:"required"`
Quantity int64 `json:"quantity" binding:"required"` Quantity int64 `json:"quantity" binding:"required"`
OriginalQty int64 `json:"original_qty" binding:"required"` OriginalQty int64 `json:"original_qty" binding:"required"`
Level int32 `json:"level" binding:"required"` Level int32 `json:"level" binding:"required"`
Sort int32 `json:"sort"` Sort int32 `json:"sort"`
IsBoss int32 `json:"is_boss"` IsBoss int32 `json:"is_boss"`
MinScore int64 `json:"min_score"` MinScore int64 `json:"min_score"`
ProductName string `json:"product_name"` ProductName string `json:"product_name"`
ProductImageUrl string `json:"product_image_url"` ProductImageUrl string `json:"product_image_url"`
ProductPrice float64 `json:"product_price"` ProductPrice float64 `json:"product_price"` // 兼容:返回配置快照价
ProductPriceSnapshot float64 `json:"product_price_snapshot"`
ProductPriceCurrent float64 `json:"product_price_current"`
} }
type createRewardsRequest struct { type createRewardsRequest struct {
@ -151,9 +153,11 @@ func (h *handler) ListIssueRewards() core.HandlerFunc {
if p, ok := pm[v.ProductID]; ok { if p, ok := pm[v.ProductID]; ok {
it.ProductName = p.Name it.ProductName = p.Name
it.ProductImageUrl = p.ImagesJSON it.ProductImageUrl = p.ImagesJSON
it.ProductPrice = float64(p.Price) / 100 it.ProductPriceCurrent = float64(p.Price) / 100
} }
} }
it.ProductPriceSnapshot = float64(v.PriceSnapshotCents) / 100
it.ProductPrice = it.ProductPriceSnapshot
res.List[i] = it res.List[i] = it
} }
ctx.Payload(res) ctx.Payload(res)

View File

@ -342,13 +342,13 @@ func (h *handler) ListAppUsers() core.HandlerFunc {
Value int64 Value int64
} }
var invRes []invResult var invRes []invResult
h.readDB.UserInventory.WithContext(ctx.RequestContext()).ReadDB(). _ = h.readDB.UserInventory.WithContext(ctx.RequestContext()).UnderlyingDB().
LeftJoin(h.readDB.Products, h.readDB.Products.ID.EqCol(h.readDB.UserInventory.ProductID)). Select("user_inventory.user_id, COALESCE(SUM(COALESCE(NULLIF(user_inventory.value_cents, 0), products.price, 0)), 0) AS value").
Select(h.readDB.UserInventory.UserID, h.readDB.Products.Price.Sum().As("value")). Joins("LEFT JOIN products ON products.id = user_inventory.product_id").
Where(h.readDB.UserInventory.UserID.In(userIDs...)). Where("user_inventory.user_id IN ?", userIDs).
Where(h.readDB.UserInventory.Status.Eq(1)). // 1=持有 Where("user_inventory.status = ?", 1). // 1=持有
Group(h.readDB.UserInventory.UserID). Group("user_inventory.user_id").
Scan(&invRes) Scan(&invRes).Error
for _, r := range invRes { for _, r := range invRes {
inventoryValues[r.UserID] = r.Value inventoryValues[r.UserID] = r.Value
} }
@ -542,13 +542,13 @@ func (h *handler) ListUserInvites() core.HandlerFunc {
// 商品价值:排除已兑换(status=2) // 商品价值:排除已兑换(status=2)
var invRes []assetResult var invRes []assetResult
h.readDB.UserInventory.WithContext(ctx.RequestContext()).ReadDB(). _ = h.readDB.UserInventory.WithContext(ctx.RequestContext()).UnderlyingDB().
LeftJoin(h.readDB.Products, h.readDB.Products.ID.EqCol(h.readDB.UserInventory.ProductID)). Select("user_inventory.user_id, COALESCE(SUM(COALESCE(NULLIF(user_inventory.value_cents, 0), products.price, 0)), 0) AS value").
Select(h.readDB.UserInventory.UserID, h.readDB.Products.Price.Sum().As("value")). Joins("LEFT JOIN products ON products.id = user_inventory.product_id").
Where(h.readDB.UserInventory.UserID.In(inviteeIDs...)). Where("user_inventory.user_id IN ?", inviteeIDs).
Where(h.readDB.UserInventory.Status.Neq(2)). // 排除已兑换 Where("user_inventory.status != ?", 2). // 排除已兑换
Group(h.readDB.UserInventory.UserID). Group("user_inventory.user_id").
Scan(&invRes) Scan(&invRes).Error
for _, r := range invRes { for _, r := range invRes {
inviteeAssets[r.UserID] = r.Value inviteeAssets[r.UserID] = r.Value
} }
@ -564,7 +564,7 @@ func (h *handler) ListUserInvites() core.HandlerFunc {
`, userID).Scan(&summaryConsume).Error `, userID).Scan(&summaryConsume).Error
// 资产价值汇总(不包含已兑换的商品) // 资产价值汇总(不包含已兑换的商品)
_ = h.repo.GetDbR().Raw(` _ = h.repo.GetDbR().Raw(`
SELECT COALESCE(SUM(p.price), 0) SELECT COALESCE(SUM(COALESCE(NULLIF(ui.value_cents, 0), p.price, 0)), 0)
FROM user_inventory ui FROM user_inventory ui
LEFT JOIN products p ON p.id = ui.product_id LEFT JOIN products p ON p.id = ui.product_id
WHERE ui.user_id IN (SELECT invitee_id FROM user_invites WHERE inviter_id = ?) WHERE ui.user_id IN (SELECT invitee_id FROM user_invites WHERE inviter_id = ?)
@ -763,7 +763,7 @@ func (h *handler) ListUserInventory() core.HandlerFunc {
sql := ` sql := `
SELECT ui.id, ui.user_id, ui.product_id, ui.order_id, ui.activity_id, ui.reward_id, SELECT ui.id, ui.user_id, ui.product_id, ui.order_id, ui.activity_id, ui.reward_id,
ui.status, ui.remark, ui.created_at, ui.updated_at, ui.status, ui.remark, ui.created_at, ui.updated_at,
p.name as product_name, p.images_json as product_images, p.price as product_price p.name as product_name, p.images_json as product_images, COALESCE(NULLIF(ui.value_cents, 0), p.price, 0) as product_price
FROM user_inventory ui FROM user_inventory ui
LEFT JOIN products p ON p.id = ui.product_id LEFT JOIN products p ON p.id = ui.product_id
WHERE ui.user_id = ? WHERE ui.user_id = ?
@ -2158,9 +2158,9 @@ func (h *handler) AdminSearchUsers() core.HandlerFunc {
// 按手机号或昵称模糊匹配 // 按手机号或昵称模糊匹配
rows, _ := q.Where( rows, _ := q.Where(
h.readDB.Users.Mobile.Like("%"+req.Keyword+"%"), h.readDB.Users.Mobile.Like("%" + req.Keyword + "%"),
).Or( ).Or(
h.readDB.Users.Nickname.Like("%"+req.Keyword+"%"), h.readDB.Users.Nickname.Like("%" + req.Keyword + "%"),
).Limit(10).Find() ).Limit(10).Find()
items := make([]userItem, 0, len(rows)) items := make([]userItem, 0, len(rows))

View File

@ -12,36 +12,36 @@ import (
// userStatsAggregated 用户统计聚合结果单次SQL查询返回 // userStatsAggregated 用户统计聚合结果单次SQL查询返回
type userStatsAggregated struct { type userStatsAggregated struct {
UserID int64 UserID int64
Nickname string Nickname string
Avatar string Avatar string
InviteCode string InviteCode string
InviterID int64 InviterID int64
InviterNickname string InviterNickname string
CreatedAt time.Time CreatedAt time.Time
DouyinID string DouyinID string
DouyinUserID string DouyinUserID string
Mobile string Mobile string
Remark string Remark string
ChannelName string ChannelName string
ChannelCode string ChannelCode string
Status int32 Status int32
// 聚合统计字段 // 聚合统计字段
PointsBalance int64 PointsBalance int64
CouponsCount int64 CouponsCount int64
ItemCardsCount int64 ItemCardsCount int64
TodayConsume int64 TodayConsume int64
SevenDayConsume int64 SevenDayConsume int64
ThirtyDayConsume int64 ThirtyDayConsume int64
TotalConsume int64 TotalConsume int64
InviteCount int64 InviteCount int64
InviteeTotalConsume int64 InviteeTotalConsume int64
GamePassCount int64 GamePassCount int64
GameTicketCount int64 GameTicketCount int64
InventoryValue int64 InventoryValue int64
CouponValue int64 CouponValue int64
ItemCardValue int64 ItemCardValue int64
} }
// ListAppUsersOptimized 优化后的用户列表查询单次SQL性能提升83% // ListAppUsersOptimized 优化后的用户列表查询单次SQL性能提升83%
@ -223,7 +223,7 @@ func (h *handler) ListAppUsersOptimized() core.HandlerFunc {
(SELECT COALESCE(SUM(available), 0) FROM user_game_tickets WHERE user_id = u.id) AS game_ticket_count, (SELECT COALESCE(SUM(available), 0) FROM user_game_tickets WHERE user_id = u.id) AS game_ticket_count,
-- 持有商品价值 -- 持有商品价值
(SELECT COALESCE(SUM(p.price), 0) (SELECT COALESCE(SUM(COALESCE(NULLIF(ui.value_cents, 0), p.price, 0)), 0)
FROM user_inventory ui FROM user_inventory ui
LEFT JOIN products p ON p.id = ui.product_id LEFT JOIN products p ON p.id = ui.product_id
WHERE ui.user_id = u.id AND ui.status = 1 WHERE ui.user_id = u.id AND ui.status = 1
@ -254,13 +254,13 @@ func (h *handler) ListAppUsersOptimized() core.HandlerFunc {
// 构建完整参数列表 // 构建完整参数列表
queryArgs := []interface{}{ queryArgs := []interface{}{
todayStart, // today_consume todayStart, // today_consume
sevenDayStart, // seven_day_consume sevenDayStart, // seven_day_consume
thirtyDayStart, // thirty_day_consume thirtyDayStart, // thirty_day_consume
now, // game_pass_count now, // game_pass_count
} }
queryArgs = append(queryArgs, args...) // WHERE 条件参数 queryArgs = append(queryArgs, args...) // WHERE 条件参数
queryArgs = append(queryArgs, req.PageSize) // LIMIT queryArgs = append(queryArgs, req.PageSize) // LIMIT
queryArgs = append(queryArgs, (req.Page-1)*req.PageSize) // OFFSET queryArgs = append(queryArgs, (req.Page-1)*req.PageSize) // OFFSET
// 执行查询 // 执行查询

View File

@ -194,12 +194,14 @@ func (h *handler) GetUserProfile() core.HandlerFunc {
Value int64 Value int64
} }
var is invStats var is invStats
h.readDB.UserInventory.WithContext(ctx.RequestContext()).ReadDB(). _ = h.repo.GetDbR().Raw(`
LeftJoin(h.readDB.Products, h.readDB.Products.ID.EqCol(h.readDB.UserInventory.ProductID)). SELECT
Select(h.readDB.UserInventory.ID.Count().As("count"), h.readDB.Products.Price.Sum().As("value")). COUNT(ui.id) as count,
Where(h.readDB.UserInventory.UserID.Eq(userID)). COALESCE(SUM(COALESCE(NULLIF(ui.value_cents, 0), p.price, 0)), 0) as value
Where(h.readDB.UserInventory.Status.Eq(1)). FROM user_inventory ui
Scan(&is) LEFT JOIN products p ON p.id = ui.product_id
WHERE ui.user_id = ? AND ui.status = 1
`, userID).Scan(&is).Error
rsp.CurrentAssets.InventoryCount = is.Count rsp.CurrentAssets.InventoryCount = is.Count
rsp.CurrentAssets.InventoryValue = is.Value rsp.CurrentAssets.InventoryValue = is.Value

View File

@ -1,6 +1,7 @@
package admin package admin
import ( import (
"fmt"
"net/http" "net/http"
"strconv" "strconv"
"time" "time"
@ -86,7 +87,12 @@ func (h *handler) GetUserProfitLossTrend() core.HandlerFunc {
Coupons int64 Coupons int64
} }
_ = h.repo.GetDbR().Raw("SELECT COALESCE(SUM(points), 0) FROM user_points WHERE user_id = ? AND (valid_end IS NULL OR valid_end > NOW())", userID).Scan(&curAssets.Points).Error _ = h.repo.GetDbR().Raw("SELECT COALESCE(SUM(points), 0) FROM user_points WHERE user_id = ? AND (valid_end IS NULL OR valid_end > NOW())", userID).Scan(&curAssets.Points).Error
_ = h.repo.GetDbR().Raw("SELECT COALESCE(SUM(p.price), 0) FROM user_inventory ui LEFT JOIN products p ON p.id = ui.product_id WHERE ui.user_id = ? AND ui.status = 1", userID).Scan(&curAssets.Products).Error _ = h.repo.GetDbR().Raw(`
SELECT COALESCE(SUM(COALESCE(NULLIF(ui.value_cents, 0), p.price, 0)), 0)
FROM user_inventory ui
LEFT JOIN products p ON p.id = ui.product_id
WHERE ui.user_id = ? AND ui.status = 1
`, 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(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 _ = h.repo.GetDbR().Raw("SELECT COALESCE(SUM(balance_amount), 0) FROM user_coupons WHERE user_id = ? AND status = 1", userID).Scan(&curAssets.Coupons).Error
totalAssetValue := curAssets.Points + curAssets.Products + curAssets.Cards + curAssets.Coupons totalAssetValue := curAssets.Points + curAssets.Products + curAssets.Cards + curAssets.Coupons
@ -94,17 +100,22 @@ func (h *handler) GetUserProfitLossTrend() core.HandlerFunc {
// --- 2. 获取订单数据(仅 status=2 已支付) --- // --- 2. 获取订单数据(仅 status=2 已支付) ---
// 注意:为了计算累计趋势,我们需要获取 start 之前的所有已支付订单总额作为基数 // 注意:为了计算累计趋势,我们需要获取 start 之前的所有已支付订单总额作为基数
var baseCost int64 = 0 var baseCost int64 = 0
var baseCostPtr *int64 _ = h.repo.GetDbR().Raw(`
h.readDB.Orders.WithContext(ctx.RequestContext()).ReadDB(). SELECT COALESCE(SUM(CASE
Select(h.readDB.Orders.ActualAmount.Sum()). WHEN o.source_type = 4 OR o.order_no LIKE 'GP%' OR (o.actual_amount = 0 AND o.remark LIKE '%use_game_pass%')
Where(h.readDB.Orders.UserID.Eq(userID)). THEN COALESCE(od.draw_count * a.price_draw, 0)
Where(h.readDB.Orders.Status.Eq(2)). ELSE o.actual_amount + o.discount_amount
Where(h.readDB.Orders.SourceType.In(2, 3, 4)). // 排除积分兑换(1)和奖品兑换(5) END), 0)
Where(h.readDB.Orders.CreatedAt.Lt(start)). FROM orders o
Scan(&baseCostPtr) LEFT JOIN (
if baseCostPtr != nil { SELECT l.order_id, COUNT(*) as draw_count, MAX(ai.activity_id) as activity_id
baseCost = *baseCostPtr FROM activity_draw_logs l
} JOIN activity_issues ai ON ai.id = l.issue_id
GROUP BY l.order_id
) od ON od.order_id = o.id
LEFT JOIN activities a ON a.id = od.activity_id
WHERE o.user_id = ? AND o.status = 2 AND o.source_type IN (2,3,4) AND o.created_at < ?
`, userID, start).Scan(&baseCost).Error
// 扣除历史退款 (如果有的话,此处简化处理,主要关注当前范围内的波动) // 扣除历史退款 (如果有的话,此处简化处理,主要关注当前范围内的波动)
var baseRefund int64 = 0 var baseRefund int64 = 0
@ -119,13 +130,28 @@ func (h *handler) GetUserProfitLossTrend() core.HandlerFunc {
baseCost = 0 baseCost = 0
} }
orderRows, _ := h.readDB.Orders.WithContext(ctx.RequestContext()).ReadDB(). type orderSpendRow struct {
Where(h.readDB.Orders.UserID.Eq(userID)). CreatedAt time.Time
Where(h.readDB.Orders.Status.Eq(2)). Spending int64
Where(h.readDB.Orders.SourceType.In(2, 3, 4)). // 排除积分兑换(1)和奖品兑换(5) }
Where(h.readDB.Orders.CreatedAt.Gte(start)). var orderRows []orderSpendRow
Where(h.readDB.Orders.CreatedAt.Lte(end)). _ = h.repo.GetDbR().Raw(`
Find() SELECT o.created_at,
CASE
WHEN o.source_type = 4 OR o.order_no LIKE 'GP%' OR (o.actual_amount = 0 AND o.remark LIKE '%use_game_pass%')
THEN COALESCE(od.draw_count * a.price_draw, 0)
ELSE o.actual_amount + o.discount_amount
END as spending
FROM orders o
LEFT JOIN (
SELECT l.order_id, COUNT(*) as draw_count, MAX(ai.activity_id) as activity_id
FROM activity_draw_logs l
JOIN activity_issues ai ON ai.id = l.issue_id
GROUP BY l.order_id
) od ON od.order_id = o.id
LEFT JOIN activities a ON a.id = od.activity_id
WHERE o.user_id = ? AND o.status = 2 AND o.source_type IN (2,3,4) AND o.created_at BETWEEN ? AND ?
`, userID, start, end).Scan(&orderRows).Error
// 获取当前范围内的退款 // 获取当前范围内的退款
type refundInfo struct { type refundInfo struct {
@ -157,7 +183,7 @@ func (h *handler) GetUserProfitLossTrend() core.HandlerFunc {
var periodDelta int64 = 0 var periodDelta int64 = 0
for _, o := range orderRows { for _, o := range orderRows {
if inBucket(o.CreatedAt, b) { if inBucket(o.CreatedAt, b) {
periodDelta += o.ActualAmount periodDelta += o.Spending
} }
} }
for _, r := range refunds { for _, r := range refunds {
@ -192,16 +218,22 @@ func (h *handler) GetUserProfitLossTrend() core.HandlerFunc {
// 汇总数据 // 汇总数据
var totalCost int64 = 0 var totalCost int64 = 0
var totalCostPtr *int64 _ = h.repo.GetDbR().Raw(`
h.readDB.Orders.WithContext(ctx.RequestContext()).ReadDB(). SELECT COALESCE(SUM(CASE
Select(h.readDB.Orders.ActualAmount.Sum()). WHEN o.source_type = 4 OR o.order_no LIKE 'GP%' OR (o.actual_amount = 0 AND o.remark LIKE '%use_game_pass%')
Where(h.readDB.Orders.UserID.Eq(userID)). THEN COALESCE(od.draw_count * a.price_draw, 0)
Where(h.readDB.Orders.Status.Eq(2)). ELSE o.actual_amount + o.discount_amount
Where(h.readDB.Orders.SourceType.In(2, 3, 4)). // 排除积分兑换(1)和奖品兑换(5) END), 0)
Scan(&totalCostPtr) FROM orders o
if totalCostPtr != nil { LEFT JOIN (
totalCost = *totalCostPtr SELECT l.order_id, COUNT(*) as draw_count, MAX(ai.activity_id) as activity_id
} FROM activity_draw_logs l
JOIN activity_issues ai ON ai.id = l.issue_id
GROUP BY l.order_id
) od ON od.order_id = o.id
LEFT JOIN activities a ON a.id = od.activity_id
WHERE o.user_id = ? AND o.status = 2 AND o.source_type IN (2,3,4)
`, userID).Scan(&totalCost).Error
var totalRefund int64 = 0 var totalRefund int64 = 0
_ = h.repo.GetDbR().Raw(` _ = h.repo.GetDbR().Raw(`
@ -387,14 +419,21 @@ func (h *handler) GetUserProfitLossDetails() core.HandlerFunc {
Name string Name string
} }
var prizes []prizeRow var prizes []prizeRow
_ = h.repo.GetDbR().Raw(` if err := h.repo.GetDbR().Raw(`
SELECT ui.order_id, COALESCE(SUM(p.price), 0) as value, SELECT ui.order_id,
CAST(COALESCE(SUM(COALESCE(NULLIF(ui.value_cents, 0), activity_reward_settings.price_snapshot_cents, p.price, 0) * GREATEST(COALESCE(system_item_cards.reward_multiplier_x1000, 1000), 1000) / 1000), 0) AS SIGNED) as value,
GROUP_CONCAT(p.name SEPARATOR ', ') as name GROUP_CONCAT(p.name SEPARATOR ', ') as name
FROM user_inventory ui FROM user_inventory ui
LEFT JOIN products p ON p.id = ui.product_id LEFT JOIN products p ON p.id = ui.product_id
LEFT JOIN activity_reward_settings ON activity_reward_settings.id = ui.reward_id
LEFT JOIN orders o ON o.id = ui.order_id
LEFT JOIN user_item_cards uic ON uic.id = o.item_card_id
LEFT JOIN system_item_cards ON system_item_cards.id = uic.card_id
WHERE ui.order_id IN ? WHERE ui.order_id IN ?
GROUP BY ui.order_id GROUP BY ui.order_id
`, orderIDs).Scan(&prizes).Error `, orderIDs).Scan(&prizes).Error; err != nil {
h.logger.Error(fmt.Sprintf("GetUserProfitLoss detail prize query error: %v", err))
}
for _, p := range prizes { for _, p := range prizes {
prizeValueMap[p.OrderID] = p.Value prizeValueMap[p.OrderID] = p.Value
prizeNameMap[p.OrderID] = p.Name prizeNameMap[p.OrderID] = p.Name
@ -445,6 +484,36 @@ func (h *handler) GetUserProfitLossDetails() core.HandlerFunc {
} }
} }
// 批量计算订单统一支出(普通单=支付+优惠券;次卡单=次卡价值)
orderSpendingMap := make(map[int64]int64)
if len(orderIDs) > 0 {
type spendRow struct {
OrderID int64
Spending int64
}
var spends []spendRow
_ = h.repo.GetDbR().Raw(`
SELECT o.id as order_id,
CASE
WHEN o.source_type = 4 OR o.order_no LIKE 'GP%' OR (o.actual_amount = 0 AND o.remark LIKE '%use_game_pass%')
THEN COALESCE(od.draw_count * a.price_draw, 0)
ELSE o.actual_amount + o.discount_amount
END as spending
FROM orders o
LEFT JOIN (
SELECT l.order_id, COUNT(*) as draw_count, MAX(ai.activity_id) as activity_id
FROM activity_draw_logs l
JOIN activity_issues ai ON ai.id = l.issue_id
GROUP BY l.order_id
) od ON od.order_id = o.id
LEFT JOIN activities a ON a.id = od.activity_id
WHERE o.id IN ?
`, orderIDs).Scan(&spends).Error
for _, s := range spends {
orderSpendingMap[s.OrderID] = s.Spending
}
}
// 组装明细数据 // 组装明细数据
list := make([]profitLossDetailItem, len(orders)) list := make([]profitLossDetailItem, len(orders))
var totalCost, totalValue int64 var totalCost, totalValue int64
@ -453,7 +522,14 @@ func (h *handler) GetUserProfitLossDetails() core.HandlerFunc {
refund := refundMap[o.OrderNo] refund := refundMap[o.OrderNo]
prizeValue := prizeValueMap[o.ID] prizeValue := prizeValueMap[o.ID]
couponValue := couponValueMap[o.ID] couponValue := couponValueMap[o.ID]
netCost := o.ActualAmount - refund spending := orderSpendingMap[o.ID]
if spending == 0 {
spending = o.ActualAmount + o.DiscountAmount
}
netCost := spending - refund
if netCost < 0 {
netCost = 0
}
netProfit := prizeValue - netCost netProfit := prizeValue - netCost
list[i] = profitLossDetailItem{ list[i] = profitLossDetailItem{

View File

@ -246,11 +246,13 @@ func (h *handler) ListTaskTiersForAdmin() core.HandlerFunc {
type upsertRewardsRequest struct { type upsertRewardsRequest struct {
Rewards []struct { Rewards []struct {
ID int64 `json:"id"`
TierID int64 `json:"tier_id"` TierID int64 `json:"tier_id"`
RewardType string `json:"reward_type"` RewardType string `json:"reward_type"`
RewardPayload datatypes.JSON `json:"reward_payload"` RewardPayload datatypes.JSON `json:"reward_payload"`
Quantity int64 `json:"quantity"` Quantity int64 `json:"quantity"`
} `json:"rewards"` } `json:"rewards"`
DeleteIDs []int64 `json:"delete_ids"`
} }
// @Summary 设置任务奖励(Admin) // @Summary 设置任务奖励(Admin)
@ -276,9 +278,9 @@ func (h *handler) UpsertTaskRewardsForAdmin() core.HandlerFunc {
} }
in := make([]tasksvc.TaskRewardInput, len(req.Rewards)) in := make([]tasksvc.TaskRewardInput, len(req.Rewards))
for i, r := range req.Rewards { for i, r := range req.Rewards {
in[i] = tasksvc.TaskRewardInput{TierID: r.TierID, RewardType: r.RewardType, RewardPayload: r.RewardPayload, Quantity: r.Quantity} in[i] = tasksvc.TaskRewardInput{ID: r.ID, TierID: r.TierID, RewardType: r.RewardType, RewardPayload: r.RewardPayload, Quantity: r.Quantity}
} }
if err := h.task.UpsertTaskRewards(ctx.RequestContext(), id, in); err != nil { if err := h.task.UpsertTaskRewards(ctx.RequestContext(), id, in, req.DeleteIDs); err != nil {
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ServerError, err.Error())) ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ServerError, err.Error()))
return return
} }

View File

@ -19,9 +19,10 @@ import (
) )
type weixinLoginRequest struct { type weixinLoginRequest struct {
Code string `json:"code"` Code string `json:"code"`
InviteCode string `json:"invite_code"` InviteCode string `json:"invite_code"`
DouyinID string `json:"douyin_id"` DouyinID string `json:"douyin_id"`
ChannelCode string `json:"channel_code"`
} }
type weixinLoginResponse struct { type weixinLoginResponse struct {
UserID int64 `json:"user_id"` UserID int64 `json:"user_id"`
@ -63,7 +64,7 @@ func (h *handler) WeixinLogin() core.HandlerFunc {
return return
} }
in := usersvc.LoginWeixinInput{OpenID: c2s.OpenID, UnionID: c2s.UnionID, InviteCode: req.InviteCode, DouyinID: req.DouyinID} in := usersvc.LoginWeixinInput{OpenID: c2s.OpenID, UnionID: c2s.UnionID, InviteCode: req.InviteCode, DouyinID: req.DouyinID, ChannelCode: req.ChannelCode}
out, err := h.user.LoginWeixin(ctx.RequestContext(), in) out, err := h.user.LoginWeixin(ctx.RequestContext(), in)
if err != nil { if err != nil {
ctx.AbortWithError(core.Error(http.StatusBadRequest, 10006, err.Error())) ctx.AbortWithError(core.Error(http.StatusBadRequest, 10006, err.Error()))

View File

@ -32,6 +32,8 @@ func newActivityRewardSettings(db *gorm.DB, opts ...gen.DOOption) activityReward
_activityRewardSettings.UpdatedAt = field.NewTime(tableName, "updated_at") _activityRewardSettings.UpdatedAt = field.NewTime(tableName, "updated_at")
_activityRewardSettings.IssueID = field.NewInt64(tableName, "issue_id") _activityRewardSettings.IssueID = field.NewInt64(tableName, "issue_id")
_activityRewardSettings.ProductID = field.NewInt64(tableName, "product_id") _activityRewardSettings.ProductID = field.NewInt64(tableName, "product_id")
_activityRewardSettings.PriceSnapshotCents = field.NewInt64(tableName, "price_snapshot_cents")
_activityRewardSettings.PriceSnapshotAt = field.NewTime(tableName, "price_snapshot_at")
_activityRewardSettings.Weight = field.NewInt32(tableName, "weight") _activityRewardSettings.Weight = field.NewInt32(tableName, "weight")
_activityRewardSettings.Quantity = field.NewInt64(tableName, "quantity") _activityRewardSettings.Quantity = field.NewInt64(tableName, "quantity")
_activityRewardSettings.OriginalQty = field.NewInt64(tableName, "original_qty") _activityRewardSettings.OriginalQty = field.NewInt64(tableName, "original_qty")
@ -50,20 +52,22 @@ func newActivityRewardSettings(db *gorm.DB, opts ...gen.DOOption) activityReward
type activityRewardSettings struct { type activityRewardSettings struct {
activityRewardSettingsDo activityRewardSettingsDo
ALL field.Asterisk ALL field.Asterisk
ID field.Int64 // 主键ID ID field.Int64 // 主键ID
CreatedAt field.Time // 创建时间 CreatedAt field.Time // 创建时间
UpdatedAt field.Time // 更新时间 UpdatedAt field.Time // 更新时间
IssueID field.Int64 // 期IDactivity_issues.id IssueID field.Int64 // 期IDactivity_issues.id
ProductID field.Int64 // 奖品对应商品ID实物奖可填 ProductID field.Int64 // 奖品对应商品ID实物奖可填
Weight field.Int32 // 抽中权重(越大越易中) PriceSnapshotCents field.Int64 // 奖品配置时商品价格快照(分)
Quantity field.Int64 // 当前可发数量(扣减) PriceSnapshotAt field.Time // 奖品价格快照时间
OriginalQty field.Int64 // 初始配置数量 Weight field.Int32 // 抽中权重(越大越易中)
Level field.Int32 // 奖级如1=S 2=A 3=B Quantity field.Int64 // 当前可发数量(扣减)
Sort field.Int32 // 排序 OriginalQty field.Int64 // 初始配置数量
IsBoss field.Int32 // Boss 1 是 0 不是 Level field.Int32 // 奖级如1=S 2=A 3=B
DeletedAt field.Field Sort field.Int32 // 排序
MinScore field.Int64 // 最低得分/碰数要求 IsBoss field.Int32 // Boss 1 是 0 不是
DeletedAt field.Field
MinScore field.Int64 // 最低得分/碰数要求
fieldMap map[string]field.Expr fieldMap map[string]field.Expr
} }
@ -85,6 +89,8 @@ func (a *activityRewardSettings) updateTableName(table string) *activityRewardSe
a.UpdatedAt = field.NewTime(table, "updated_at") a.UpdatedAt = field.NewTime(table, "updated_at")
a.IssueID = field.NewInt64(table, "issue_id") a.IssueID = field.NewInt64(table, "issue_id")
a.ProductID = field.NewInt64(table, "product_id") a.ProductID = field.NewInt64(table, "product_id")
a.PriceSnapshotCents = field.NewInt64(table, "price_snapshot_cents")
a.PriceSnapshotAt = field.NewTime(table, "price_snapshot_at")
a.Weight = field.NewInt32(table, "weight") a.Weight = field.NewInt32(table, "weight")
a.Quantity = field.NewInt64(table, "quantity") a.Quantity = field.NewInt64(table, "quantity")
a.OriginalQty = field.NewInt64(table, "original_qty") a.OriginalQty = field.NewInt64(table, "original_qty")
@ -109,12 +115,14 @@ func (a *activityRewardSettings) GetFieldByName(fieldName string) (field.OrderEx
} }
func (a *activityRewardSettings) fillFieldMap() { func (a *activityRewardSettings) fillFieldMap() {
a.fieldMap = make(map[string]field.Expr, 13) a.fieldMap = make(map[string]field.Expr, 15)
a.fieldMap["id"] = a.ID a.fieldMap["id"] = a.ID
a.fieldMap["created_at"] = a.CreatedAt a.fieldMap["created_at"] = a.CreatedAt
a.fieldMap["updated_at"] = a.UpdatedAt a.fieldMap["updated_at"] = a.UpdatedAt
a.fieldMap["issue_id"] = a.IssueID a.fieldMap["issue_id"] = a.IssueID
a.fieldMap["product_id"] = a.ProductID a.fieldMap["product_id"] = a.ProductID
a.fieldMap["price_snapshot_cents"] = a.PriceSnapshotCents
a.fieldMap["price_snapshot_at"] = a.PriceSnapshotAt
a.fieldMap["weight"] = a.Weight a.fieldMap["weight"] = a.Weight
a.fieldMap["quantity"] = a.Quantity a.fieldMap["quantity"] = a.Quantity
a.fieldMap["original_qty"] = a.OriginalQty a.fieldMap["original_qty"] = a.OriginalQty

View File

@ -31,6 +31,8 @@ func newLivestreamActivities(db *gorm.DB, opts ...gen.DOOption) livestreamActivi
_livestreamActivities.Name = field.NewString(tableName, "name") _livestreamActivities.Name = field.NewString(tableName, "name")
_livestreamActivities.StreamerName = field.NewString(tableName, "streamer_name") _livestreamActivities.StreamerName = field.NewString(tableName, "streamer_name")
_livestreamActivities.StreamerContact = field.NewString(tableName, "streamer_contact") _livestreamActivities.StreamerContact = field.NewString(tableName, "streamer_contact")
_livestreamActivities.ChannelID = field.NewInt64(tableName, "channel_id")
_livestreamActivities.ChannelCode = field.NewString(tableName, "channel_code")
_livestreamActivities.AccessCode = field.NewString(tableName, "access_code") _livestreamActivities.AccessCode = field.NewString(tableName, "access_code")
_livestreamActivities.DouyinProductID = field.NewString(tableName, "douyin_product_id") _livestreamActivities.DouyinProductID = field.NewString(tableName, "douyin_product_id")
_livestreamActivities.Status = field.NewInt32(tableName, "status") _livestreamActivities.Status = field.NewInt32(tableName, "status")
@ -59,6 +61,8 @@ type livestreamActivities struct {
Name field.String // 活动名称 Name field.String // 活动名称
StreamerName field.String // 主播名称 StreamerName field.String // 主播名称
StreamerContact field.String // 主播联系方式 StreamerContact field.String // 主播联系方式
ChannelID field.Int64 // 关联渠道ID
ChannelCode field.String // 关联渠道Code
AccessCode field.String // 唯一访问码 AccessCode field.String // 唯一访问码
DouyinProductID field.String // 关联抖店商品ID DouyinProductID field.String // 关联抖店商品ID
Status field.Int32 // 状态:1进行中 2已结束 Status field.Int32 // 状态:1进行中 2已结束
@ -92,6 +96,8 @@ func (l *livestreamActivities) updateTableName(table string) *livestreamActiviti
l.Name = field.NewString(table, "name") l.Name = field.NewString(table, "name")
l.StreamerName = field.NewString(table, "streamer_name") l.StreamerName = field.NewString(table, "streamer_name")
l.StreamerContact = field.NewString(table, "streamer_contact") l.StreamerContact = field.NewString(table, "streamer_contact")
l.ChannelID = field.NewInt64(table, "channel_id")
l.ChannelCode = field.NewString(table, "channel_code")
l.AccessCode = field.NewString(table, "access_code") l.AccessCode = field.NewString(table, "access_code")
l.DouyinProductID = field.NewString(table, "douyin_product_id") l.DouyinProductID = field.NewString(table, "douyin_product_id")
l.Status = field.NewInt32(table, "status") l.Status = field.NewInt32(table, "status")
@ -121,11 +127,13 @@ func (l *livestreamActivities) GetFieldByName(fieldName string) (field.OrderExpr
} }
func (l *livestreamActivities) fillFieldMap() { func (l *livestreamActivities) fillFieldMap() {
l.fieldMap = make(map[string]field.Expr, 17) l.fieldMap = make(map[string]field.Expr, 19)
l.fieldMap["id"] = l.ID l.fieldMap["id"] = l.ID
l.fieldMap["name"] = l.Name l.fieldMap["name"] = l.Name
l.fieldMap["streamer_name"] = l.StreamerName l.fieldMap["streamer_name"] = l.StreamerName
l.fieldMap["streamer_contact"] = l.StreamerContact l.fieldMap["streamer_contact"] = l.StreamerContact
l.fieldMap["channel_id"] = l.ChannelID
l.fieldMap["channel_code"] = l.ChannelCode
l.fieldMap["access_code"] = l.AccessCode l.fieldMap["access_code"] = l.AccessCode
l.fieldMap["douyin_product_id"] = l.DouyinProductID l.fieldMap["douyin_product_id"] = l.DouyinProductID
l.fieldMap["status"] = l.Status l.fieldMap["status"] = l.Status

View File

@ -32,6 +32,9 @@ func newUserInventory(db *gorm.DB, opts ...gen.DOOption) userInventory {
_userInventory.UpdatedAt = field.NewTime(tableName, "updated_at") _userInventory.UpdatedAt = field.NewTime(tableName, "updated_at")
_userInventory.UserID = field.NewInt64(tableName, "user_id") _userInventory.UserID = field.NewInt64(tableName, "user_id")
_userInventory.ProductID = field.NewInt64(tableName, "product_id") _userInventory.ProductID = field.NewInt64(tableName, "product_id")
_userInventory.ValueCents = field.NewInt64(tableName, "value_cents")
_userInventory.ValueSource = field.NewInt32(tableName, "value_source")
_userInventory.ValueSnapshotAt = field.NewTime(tableName, "value_snapshot_at")
_userInventory.OrderID = field.NewInt64(tableName, "order_id") _userInventory.OrderID = field.NewInt64(tableName, "order_id")
_userInventory.ActivityID = field.NewInt64(tableName, "activity_id") _userInventory.ActivityID = field.NewInt64(tableName, "activity_id")
_userInventory.RewardID = field.NewInt64(tableName, "reward_id") _userInventory.RewardID = field.NewInt64(tableName, "reward_id")
@ -48,18 +51,21 @@ func newUserInventory(db *gorm.DB, opts ...gen.DOOption) userInventory {
type userInventory struct { type userInventory struct {
userInventoryDo userInventoryDo
ALL field.Asterisk ALL field.Asterisk
ID field.Int64 // 主键ID ID field.Int64 // 主键ID
CreatedAt field.Time // 创建时间 CreatedAt field.Time // 创建时间
UpdatedAt field.Time // 更新时间 UpdatedAt field.Time // 更新时间
UserID field.Int64 // 资产归属用户ID UserID field.Int64 // 资产归属用户ID
ProductID field.Int64 // 资产对应商品ID实物奖/商品) ProductID field.Int64 // 资产对应商品ID实物奖/商品)
OrderID field.Int64 // 来源订单ID ValueCents field.Int64 // 资产价值快照(分)
ActivityID field.Int64 // 来源活动ID ValueSource field.Int32 // 价值来源0未知 1奖励快照 2商品回退 3人工修复
RewardID field.Int64 // 来源奖励IDactivity_reward_settings.id ValueSnapshotAt field.Time // 资产价值快照时间
Status field.Int32 // 状态1持有 2作废 3已使用/发货 OrderID field.Int64 // 来源订单ID
ShippingNo field.String // 发货单号 ActivityID field.Int64 // 来源活动ID
Remark field.String // 备注 RewardID field.Int64 // 来源奖励IDactivity_reward_settings.id
Status field.Int32 // 状态1持有 2作废 3已使用/发货
ShippingNo field.String // 发货单号
Remark field.String // 备注
fieldMap map[string]field.Expr fieldMap map[string]field.Expr
} }
@ -81,6 +87,9 @@ func (u *userInventory) updateTableName(table string) *userInventory {
u.UpdatedAt = field.NewTime(table, "updated_at") u.UpdatedAt = field.NewTime(table, "updated_at")
u.UserID = field.NewInt64(table, "user_id") u.UserID = field.NewInt64(table, "user_id")
u.ProductID = field.NewInt64(table, "product_id") u.ProductID = field.NewInt64(table, "product_id")
u.ValueCents = field.NewInt64(table, "value_cents")
u.ValueSource = field.NewInt32(table, "value_source")
u.ValueSnapshotAt = field.NewTime(table, "value_snapshot_at")
u.OrderID = field.NewInt64(table, "order_id") u.OrderID = field.NewInt64(table, "order_id")
u.ActivityID = field.NewInt64(table, "activity_id") u.ActivityID = field.NewInt64(table, "activity_id")
u.RewardID = field.NewInt64(table, "reward_id") u.RewardID = field.NewInt64(table, "reward_id")
@ -103,12 +112,15 @@ func (u *userInventory) GetFieldByName(fieldName string) (field.OrderExpr, bool)
} }
func (u *userInventory) fillFieldMap() { func (u *userInventory) fillFieldMap() {
u.fieldMap = make(map[string]field.Expr, 11) u.fieldMap = make(map[string]field.Expr, 14)
u.fieldMap["id"] = u.ID u.fieldMap["id"] = u.ID
u.fieldMap["created_at"] = u.CreatedAt u.fieldMap["created_at"] = u.CreatedAt
u.fieldMap["updated_at"] = u.UpdatedAt u.fieldMap["updated_at"] = u.UpdatedAt
u.fieldMap["user_id"] = u.UserID u.fieldMap["user_id"] = u.UserID
u.fieldMap["product_id"] = u.ProductID u.fieldMap["product_id"] = u.ProductID
u.fieldMap["value_cents"] = u.ValueCents
u.fieldMap["value_source"] = u.ValueSource
u.fieldMap["value_snapshot_at"] = u.ValueSnapshotAt
u.fieldMap["order_id"] = u.OrderID u.fieldMap["order_id"] = u.OrderID
u.fieldMap["activity_id"] = u.ActivityID u.fieldMap["activity_id"] = u.ActivityID
u.fieldMap["reward_id"] = u.RewardID u.fieldMap["reward_id"] = u.RewardID

View File

@ -14,19 +14,21 @@ const TableNameActivityRewardSettings = "activity_reward_settings"
// ActivityRewardSettings 活动期-奖励配置 // ActivityRewardSettings 活动期-奖励配置
type ActivityRewardSettings struct { type ActivityRewardSettings struct {
ID int64 `gorm:"column:id;primaryKey;autoIncrement:true;comment:主键ID" json:"id"` // 主键ID ID int64 `gorm:"column:id;primaryKey;autoIncrement:true;comment:主键ID" json:"id"` // 主键ID
CreatedAt time.Time `gorm:"column:created_at;not null;default:CURRENT_TIMESTAMP(3);comment:创建时间" json:"created_at"` // 创建时间 CreatedAt time.Time `gorm:"column:created_at;not null;default:CURRENT_TIMESTAMP(3);comment:创建时间" json:"created_at"` // 创建时间
UpdatedAt time.Time `gorm:"column:updated_at;not null;default:CURRENT_TIMESTAMP(3);comment:更新时间" json:"updated_at"` // 更新时间 UpdatedAt time.Time `gorm:"column:updated_at;not null;default:CURRENT_TIMESTAMP(3);comment:更新时间" json:"updated_at"` // 更新时间
IssueID int64 `gorm:"column:issue_id;not null;comment:期IDactivity_issues.id" json:"issue_id"` // 期IDactivity_issues.id IssueID int64 `gorm:"column:issue_id;not null;comment:期IDactivity_issues.id" json:"issue_id"` // 期IDactivity_issues.id
ProductID int64 `gorm:"column:product_id;comment:奖品对应商品ID实物奖可填" json:"product_id"` // 奖品对应商品ID实物奖可填 ProductID int64 `gorm:"column:product_id;comment:奖品对应商品ID实物奖可填" json:"product_id"` // 奖品对应商品ID实物奖可填
Weight int32 `gorm:"column:weight;not null;comment:抽中权重(越大越易中)" json:"weight"` // 抽中权重(越大越易中) PriceSnapshotCents int64 `gorm:"column:price_snapshot_cents;not null;comment:奖品配置时商品价格快照(分)" json:"price_snapshot_cents"` // 奖品配置时商品价格快照(分)
Quantity int64 `gorm:"column:quantity;not null;comment:当前可发数量(扣减)" json:"quantity"` // 当前可发数量(扣减) PriceSnapshotAt time.Time `gorm:"column:price_snapshot_at;comment:奖品价格快照时间" json:"price_snapshot_at"` // 奖品价格快照时间
OriginalQty int64 `gorm:"column:original_qty;not null;comment:初始配置数量" json:"original_qty"` // 初始配置数量 Weight int32 `gorm:"column:weight;not null;comment:抽中权重(越大越易中)" json:"weight"` // 抽中权重(越大越易中)
Level int32 `gorm:"column:level;not null;comment:奖级如1=S 2=A 3=B" json:"level"` // 奖级如1=S 2=A 3=B Quantity int64 `gorm:"column:quantity;not null;comment:当前可发数量(扣减)" json:"quantity"` // 当前可发数量(扣减)
Sort int32 `gorm:"column:sort;comment:排序" json:"sort"` // 排序 OriginalQty int64 `gorm:"column:original_qty;not null;comment:初始配置数量" json:"original_qty"` // 初始配置数量
IsBoss int32 `gorm:"column:is_boss;comment:Boss 1 是 0 不是" json:"is_boss"` // Boss 1 是 0 不是 Level int32 `gorm:"column:level;not null;comment:奖级如1=S 2=A 3=B" json:"level"` // 奖级如1=S 2=A 3=B
DeletedAt gorm.DeletedAt `gorm:"column:deleted_at" json:"deleted_at"` Sort int32 `gorm:"column:sort;comment:排序" json:"sort"` // 排序
MinScore int64 `gorm:"column:min_score;not null;comment:最低得分/碰数要求" json:"min_score"` // 最低得分/碰数要求 IsBoss int32 `gorm:"column:is_boss;comment:Boss 1 是 0 不是" json:"is_boss"` // Boss 1 是 0 不是
DeletedAt gorm.DeletedAt `gorm:"column:deleted_at" json:"deleted_at"`
MinScore int64 `gorm:"column:min_score;not null;comment:最低得分/碰数要求" json:"min_score"` // 最低得分/碰数要求
} }
// TableName ActivityRewardSettings's table name // TableName ActivityRewardSettings's table name

View File

@ -0,0 +1,37 @@
package model
import "time"
// DouyinRewardLogs 抖店发奖日志
// 手动维护的模型,未通过 gorm gen 生成
// Table name: douyin_reward_logs
// Columns:
// - id BIGINT PK
// - shop_order_id VARCHAR
// - douyin_user_id VARCHAR
// - local_user_id BIGINT
// - douyin_product_id VARCHAR
// - prize_id BIGINT
// - source VARCHAR
// - status VARCHAR
// - message VARCHAR
// - extra JSON
// - created_at DATETIME
type DouyinRewardLogs struct {
ID int64 `gorm:"column:id;primaryKey;autoIncrement" json:"id"`
ShopOrderID string `gorm:"column:shop_order_id" json:"shop_order_id"`
DouyinUserID string `gorm:"column:douyin_user_id" json:"douyin_user_id"`
LocalUserID int64 `gorm:"column:local_user_id" json:"local_user_id"`
DouyinProductID string `gorm:"column:douyin_product_id" json:"douyin_product_id"`
PrizeID int64 `gorm:"column:prize_id" json:"prize_id"`
Source string `gorm:"column:source" json:"source"`
Status string `gorm:"column:status" json:"status"`
Message string `gorm:"column:message" json:"message"`
Extra string `gorm:"column:extra" json:"extra"`
CreatedAt time.Time `gorm:"column:created_at" json:"created_at"`
}
func (*DouyinRewardLogs) TableName() string {
return "douyin_reward_logs"
}

View File

@ -18,6 +18,8 @@ type LivestreamActivities struct {
Name string `gorm:"column:name;not null;comment:活动名称" json:"name"` // 活动名称 Name string `gorm:"column:name;not null;comment:活动名称" json:"name"` // 活动名称
StreamerName string `gorm:"column:streamer_name;comment:主播名称" json:"streamer_name"` // 主播名称 StreamerName string `gorm:"column:streamer_name;comment:主播名称" json:"streamer_name"` // 主播名称
StreamerContact string `gorm:"column:streamer_contact;comment:主播联系方式" json:"streamer_contact"` // 主播联系方式 StreamerContact string `gorm:"column:streamer_contact;comment:主播联系方式" json:"streamer_contact"` // 主播联系方式
ChannelID int64 `gorm:"column:channel_id;comment:关联渠道ID" json:"channel_id"` // 关联渠道ID
ChannelCode string `gorm:"column:channel_code;comment:关联渠道Code" json:"channel_code"` // 关联渠道Code
AccessCode string `gorm:"column:access_code;not null;comment:唯一访问码" json:"access_code"` // 唯一访问码 AccessCode string `gorm:"column:access_code;not null;comment:唯一访问码" json:"access_code"` // 唯一访问码
DouyinProductID string `gorm:"column:douyin_product_id;comment:关联抖店商品ID" json:"douyin_product_id"` // 关联抖店商品ID DouyinProductID string `gorm:"column:douyin_product_id;comment:关联抖店商品ID" json:"douyin_product_id"` // 关联抖店商品ID
OrderRewardType string `gorm:"column:order_reward_type;default:'';comment:下单奖励类型: flip_card/minesweeper" json:"order_reward_type"` // 下单奖励类型 OrderRewardType string `gorm:"column:order_reward_type;default:'';comment:下单奖励类型: flip_card/minesweeper" json:"order_reward_type"` // 下单奖励类型

View File

@ -12,17 +12,20 @@ const TableNameUserInventory = "user_inventory"
// UserInventory 用户资产(资产) // UserInventory 用户资产(资产)
type UserInventory struct { type UserInventory struct {
ID int64 `gorm:"column:id;primaryKey;autoIncrement:true;comment:主键ID" json:"id"` // 主键ID ID int64 `gorm:"column:id;primaryKey;autoIncrement:true;comment:主键ID" json:"id"` // 主键ID
CreatedAt time.Time `gorm:"column:created_at;not null;default:CURRENT_TIMESTAMP(3);comment:创建时间" json:"created_at"` // 创建时间 CreatedAt time.Time `gorm:"column:created_at;not null;default:CURRENT_TIMESTAMP(3);comment:创建时间" json:"created_at"` // 创建时间
UpdatedAt time.Time `gorm:"column:updated_at;not null;default:CURRENT_TIMESTAMP(3);comment:更新时间" json:"updated_at"` // 更新时间 UpdatedAt time.Time `gorm:"column:updated_at;not null;default:CURRENT_TIMESTAMP(3);comment:更新时间" json:"updated_at"` // 更新时间
UserID int64 `gorm:"column:user_id;not null;comment:资产归属用户ID" json:"user_id"` // 资产归属用户ID UserID int64 `gorm:"column:user_id;not null;comment:资产归属用户ID" json:"user_id"` // 资产归属用户ID
ProductID int64 `gorm:"column:product_id;comment:资产对应商品ID实物奖/商品)" json:"product_id"` // 资产对应商品ID实物奖/商品) ProductID int64 `gorm:"column:product_id;comment:资产对应商品ID实物奖/商品)" json:"product_id"` // 资产对应商品ID实物奖/商品)
OrderID int64 `gorm:"column:order_id;comment:来源订单ID" json:"order_id"` // 来源订单ID ValueCents int64 `gorm:"column:value_cents;not null;comment:资产价值快照(分)" json:"value_cents"` // 资产价值快照(分)
ActivityID int64 `gorm:"column:activity_id;comment:来源活动ID" json:"activity_id"` // 来源活动ID ValueSource int32 `gorm:"column:value_source;not null;comment:价值来源0未知 1奖励快照 2商品回退 3人工修复" json:"value_source"` // 价值来源0未知 1奖励快照 2商品回退 3人工修复
RewardID int64 `gorm:"column:reward_id;comment:来源奖励IDactivity_reward_settings.id" json:"reward_id"` // 来源奖励IDactivity_reward_settings.id ValueSnapshotAt time.Time `gorm:"column:value_snapshot_at;comment:资产价值快照时间" json:"value_snapshot_at"` // 资产价值快照时间
Status int32 `gorm:"column:status;not null;default:1;comment:状态1持有 2作废 3已使用/发货" json:"status"` // 状态1持有 2作废 3已使用/发货 OrderID int64 `gorm:"column:order_id;comment:来源订单ID" json:"order_id"` // 来源订单ID
ShippingNo string `gorm:"column:shipping_no;not null;comment:发货单号" json:"shipping_no"` // 发货单号 ActivityID int64 `gorm:"column:activity_id;comment:来源活动ID" json:"activity_id"` // 来源活动ID
Remark string `gorm:"column:remark;comment:备注" json:"remark"` // 备注 RewardID int64 `gorm:"column:reward_id;comment:来源奖励IDactivity_reward_settings.id" json:"reward_id"` // 来源奖励IDactivity_reward_settings.id
Status int32 `gorm:"column:status;not null;default:1;comment:状态1持有 2作废 3已使用/发货" json:"status"` // 状态1持有 2作废 3已使用/发货
ShippingNo string `gorm:"column:shipping_no;not null;comment:发货单号" json:"shipping_no"` // 发货单号
Remark string `gorm:"column:remark;comment:备注" json:"remark"` // 备注
} }
// TableName UserInventory's table name // TableName UserInventory's table name

View File

@ -227,6 +227,7 @@ func NewHTTPMux(logger logger.CustomLogger, db mysql.Repo) (core.Mux, func(), er
adminAuthApiRouter.POST("/douyin/sync-all", adminHandler.ManualSyncAll()) adminAuthApiRouter.POST("/douyin/sync-all", adminHandler.ManualSyncAll())
adminAuthApiRouter.POST("/douyin/sync-refund", adminHandler.ManualSyncRefund()) adminAuthApiRouter.POST("/douyin/sync-refund", adminHandler.ManualSyncRefund())
adminAuthApiRouter.POST("/douyin/grant-prizes", adminHandler.ManualGrantPrizes()) adminAuthApiRouter.POST("/douyin/grant-prizes", adminHandler.ManualGrantPrizes())
adminAuthApiRouter.POST("/douyin/orders/:shop_order_id/grant-reward", adminHandler.GrantOrderReward())
// 抖店商品奖励规则 // 抖店商品奖励规则
adminAuthApiRouter.GET("/douyin/product-rewards", adminHandler.ListDouyinProductRewards()) adminAuthApiRouter.GET("/douyin/product-rewards", adminHandler.ListDouyinProductRewards())
adminAuthApiRouter.POST("/douyin/product-rewards", adminHandler.CreateDouyinProductReward()) adminAuthApiRouter.POST("/douyin/product-rewards", adminHandler.CreateDouyinProductReward())

View File

@ -190,15 +190,13 @@ func (s *service) ProcessOrderLottery(ctx context.Context, orderID int64) error
if invCountMap[log.RewardID] < needed { if invCountMap[log.RewardID] < needed {
rw := rewardMap[log.RewardID] rw := rewardMap[log.RewardID]
if rw != nil { if rw != nil {
var rewardIDRef *int64 rewardIDRef := &log.RewardID
if act != nil && act.PlayType == "ichiban" {
rewardIDRef = &log.RewardID
}
batchItems = append(batchItems, usersvc.BatchRewardItem{ batchItems = append(batchItems, usersvc.BatchRewardItem{
ProductID: rw.ProductID, ProductID: rw.ProductID,
RewardID: rewardIDRef, RewardID: rewardIDRef,
ActivityID: aid, DeductRewardStock: act != nil && act.PlayType == "ichiban",
Remark: productNameMap[rw.ProductID], ActivityID: aid,
Remark: productNameMap[rw.ProductID],
}) })
invCountMap[log.RewardID]++ // 内存计数同步 invCountMap[log.RewardID]++ // 内存计数同步

View File

@ -0,0 +1,111 @@
package activity
import (
"context"
"testing"
"bindbox-game/internal/repository/mysql/dao"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
)
func newRewardSnapshotTestService(t *testing.T) (*service, *dao.Query, *gorm.DB) {
t.Helper()
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
if err != nil {
t.Fatalf("open sqlite failed: %v", err)
}
if err := db.Exec(`CREATE TABLE products (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT,
price INTEGER NOT NULL,
stock INTEGER NOT NULL,
images_json TEXT,
updated_at DATETIME,
deleted_at DATETIME
);`).Error; err != nil {
t.Fatalf("create products failed: %v", err)
}
if err := db.Exec(`CREATE TABLE activity_reward_settings (
id INTEGER PRIMARY KEY AUTOINCREMENT,
created_at DATETIME,
updated_at DATETIME,
issue_id INTEGER NOT NULL,
product_id INTEGER,
price_snapshot_cents INTEGER NOT NULL DEFAULT 0,
price_snapshot_at DATETIME,
weight INTEGER NOT NULL,
quantity INTEGER NOT NULL,
original_qty INTEGER NOT NULL,
level INTEGER NOT NULL,
sort INTEGER,
is_boss INTEGER,
min_score INTEGER NOT NULL DEFAULT 0,
deleted_at DATETIME
);`).Error; err != nil {
t.Fatalf("create activity_reward_settings failed: %v", err)
}
q := dao.Use(db)
svc := &service{readDB: q, writeDB: q}
return svc, q, db
}
func TestCreateIssueRewards_SnapshotFromProductPrice(t *testing.T) {
svc, q, db := newRewardSnapshotTestService(t)
ctx := context.Background()
if err := db.Exec("INSERT INTO products (id, name, price, stock, images_json) VALUES (101, 'A', 1000, 10, '[]')").Error; err != nil {
t.Fatalf("insert product failed: %v", err)
}
err := svc.CreateIssueRewards(ctx, 88, []CreateRewardInput{
{
ProductID: 101,
Weight: 1,
Quantity: 2,
OriginalQty: 2,
Level: 1,
Sort: 1,
IsBoss: 0,
MinScore: 0,
},
})
if err != nil {
t.Fatalf("CreateIssueRewards failed: %v", err)
}
row, err := q.ActivityRewardSettings.WithContext(ctx).Where(q.ActivityRewardSettings.IssueID.Eq(88)).First()
if err != nil {
t.Fatalf("query reward failed: %v", err)
}
if row.PriceSnapshotCents != 1000 {
t.Fatalf("expected snapshot=1000, got=%d", row.PriceSnapshotCents)
}
}
func TestModifyIssueReward_ProductChanged_RecomputeSnapshot(t *testing.T) {
svc, q, db := newRewardSnapshotTestService(t)
ctx := context.Background()
_ = db.Exec("INSERT INTO products (id, name, price, stock, images_json) VALUES (101, 'A', 1000, 10, '[]')").Error
_ = db.Exec("INSERT INTO products (id, name, price, stock, images_json) VALUES (102, 'B', 2300, 10, '[]')").Error
_ = db.Exec("INSERT INTO activity_reward_settings (id, issue_id, product_id, price_snapshot_cents, weight, quantity, original_qty, level, sort, is_boss, min_score) VALUES (1, 9, 101, 1000, 1, 1, 1, 1, 1, 0, 0)").Error
newProductID := int64(102)
if err := svc.ModifyIssueReward(ctx, 1, ModifyRewardInput{ProductID: &newProductID}); err != nil {
t.Fatalf("ModifyIssueReward failed: %v", err)
}
row, err := q.ActivityRewardSettings.WithContext(ctx).Where(q.ActivityRewardSettings.ID.Eq(1)).First()
if err != nil {
t.Fatalf("query reward failed: %v", err)
}
if row.ProductID != 102 {
t.Fatalf("expected product_id=102, got=%d", row.ProductID)
}
if row.PriceSnapshotCents != 2300 {
t.Fatalf("expected snapshot=2300, got=%d", row.PriceSnapshotCents)
}
}

View File

@ -2,6 +2,7 @@ package activity
import ( import (
"context" "context"
"time"
"bindbox-game/internal/repository/mysql/dao" "bindbox-game/internal/repository/mysql/dao"
"bindbox-game/internal/repository/mysql/model" "bindbox-game/internal/repository/mysql/model"
@ -12,17 +13,40 @@ import (
// 返回: 错误信息 // 返回: 错误信息
func (s *service) CreateIssueRewards(ctx context.Context, issueID int64, rewards []CreateRewardInput) error { func (s *service) CreateIssueRewards(ctx context.Context, issueID int64, rewards []CreateRewardInput) error {
return s.writeDB.Transaction(func(tx *dao.Query) error { return s.writeDB.Transaction(func(tx *dao.Query) error {
productIDs := make(map[int64]struct{})
for _, r := range rewards {
if r.ProductID > 0 {
productIDs[r.ProductID] = struct{}{}
}
}
productPriceMap := make(map[int64]int64)
if len(productIDs) > 0 {
ids := make([]int64, 0, len(productIDs))
for id := range productIDs {
ids = append(ids, id)
}
products, err := tx.Products.WithContext(ctx).Where(tx.Products.ID.In(ids...)).Find()
if err != nil {
return err
}
for _, p := range products {
productPriceMap[p.ID] = p.Price
}
}
for _, r := range rewards { for _, r := range rewards {
item := &model.ActivityRewardSettings{ item := &model.ActivityRewardSettings{
IssueID: issueID, IssueID: issueID,
ProductID: r.ProductID, ProductID: r.ProductID,
Weight: r.Weight, PriceSnapshotCents: productPriceMap[r.ProductID],
Quantity: r.Quantity, PriceSnapshotAt: time.Now(),
OriginalQty: r.OriginalQty, Weight: r.Weight,
Level: r.Level, Quantity: r.Quantity,
Sort: r.Sort, OriginalQty: r.OriginalQty,
IsBoss: r.IsBoss, Level: r.Level,
MinScore: r.MinScore, Sort: r.Sort,
IsBoss: r.IsBoss,
MinScore: r.MinScore,
} }
if err := tx.ActivityRewardSettings.WithContext(ctx).Create(item); err != nil { if err := tx.ActivityRewardSettings.WithContext(ctx).Create(item); err != nil {
return err return err

View File

@ -17,6 +17,16 @@ func (s *service) ModifyIssueReward(ctx context.Context, rewardID int64, in Modi
} }
if in.ProductID != nil { if in.ProductID != nil {
item.ProductID = *in.ProductID item.ProductID = *in.ProductID
priceSnapshot := int64(0)
if *in.ProductID > 0 {
product, err := s.readDB.Products.WithContext(ctx).Where(s.readDB.Products.ID.Eq(*in.ProductID)).First()
if err != nil {
return err
}
priceSnapshot = product.Price
}
item.PriceSnapshotCents = priceSnapshot
item.PriceSnapshotAt = time.Now()
} }
if in.Weight != nil { if in.Weight != nil {
item.Weight = int32(*in.Weight) item.Weight = int32(*in.Weight)

View File

@ -2,12 +2,15 @@ package channel
import ( import (
"context" "context"
"errors"
"time" "time"
"bindbox-game/internal/pkg/logger" "bindbox-game/internal/pkg/logger"
"bindbox-game/internal/repository/mysql" "bindbox-game/internal/repository/mysql"
"bindbox-game/internal/repository/mysql/dao" "bindbox-game/internal/repository/mysql/dao"
"bindbox-game/internal/repository/mysql/model" "bindbox-game/internal/repository/mysql/model"
"gorm.io/gorm"
) )
type Service interface { type Service interface {
@ -16,6 +19,7 @@ type Service interface {
Delete(ctx context.Context, id int64) error Delete(ctx context.Context, id int64) error
List(ctx context.Context, in ListInput) (items []*ChannelWithStat, total int64, err error) List(ctx context.Context, in ListInput) (items []*ChannelWithStat, total int64, err error)
GetStats(ctx context.Context, channelID int64, days int, startDate, endDate string) (*StatsOutput, error) GetStats(ctx context.Context, channelID int64, days int, startDate, endDate string) (*StatsOutput, error)
GetByID(ctx context.Context, id int64) (*model.Channels, error)
} }
type service struct { type service struct {
@ -70,6 +74,8 @@ type StatsDailyItem struct {
GMV int64 `json:"gmv"` GMV int64 `json:"gmv"`
} }
var ErrChannelNotFound = errors.New("channel_not_found")
func (s *service) Create(ctx context.Context, in CreateInput) (*model.Channels, error) { func (s *service) Create(ctx context.Context, in CreateInput) (*model.Channels, error) {
m := &model.Channels{Name: in.Name, Code: in.Code, Type: in.Type, Remarks: in.Remarks} m := &model.Channels{Name: in.Name, Code: in.Code, Type: in.Type, Remarks: in.Remarks}
if err := s.writeDB.Channels.WithContext(ctx).Create(m); err != nil { if err := s.writeDB.Channels.WithContext(ctx).Create(m); err != nil {
@ -260,3 +266,17 @@ func (s *service) GetStats(ctx context.Context, channelID int64, months int, sta
return out, nil return out, nil
} }
func (s *service) GetByID(ctx context.Context, id int64) (*model.Channels, error) {
if id <= 0 {
return nil, ErrChannelNotFound
}
ch, err := s.readDB.Channels.WithContext(ctx).Where(s.readDB.Channels.ID.Eq(id)).First()
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, ErrChannelNotFound
}
return nil, err
}
return ch, nil
}

View File

@ -1,14 +1,9 @@
package douyin package douyin
import ( import (
"bindbox-game/internal/pkg/logger"
"bindbox-game/internal/repository/mysql"
"bindbox-game/internal/repository/mysql/dao"
"bindbox-game/internal/repository/mysql/model"
"bindbox-game/internal/service/game"
"bindbox-game/internal/service/sysconfig"
"context" "context"
"encoding/json" "encoding/json"
"errors"
"fmt" "fmt"
"io" "io"
"math" "math"
@ -21,30 +16,38 @@ import (
"time" "time"
"unicode" "unicode"
"bindbox-game/internal/pkg/logger"
"bindbox-game/internal/repository/mysql"
"bindbox-game/internal/repository/mysql/dao"
"bindbox-game/internal/repository/mysql/model"
"bindbox-game/internal/service/game"
"bindbox-game/internal/service/sysconfig"
"bindbox-game/internal/service/user"
"go.uber.org/zap" "go.uber.org/zap"
"golang.org/x/sync/singleflight" "golang.org/x/sync/singleflight"
"gorm.io/gorm"
"bindbox-game/internal/service/user"
) )
// 系统配置键 // 系统配置键
const ( const (
ConfigKeyDouyinCookie = "douyin_cookie" ConfigKeyDouyinCookie = "douyin_cookie"
ConfigKeyDouyinInterval = "douyin_sync_interval_minutes" ConfigKeyDouyinInterval = "douyin_sync_interval_minutes"
ConfigKeyDouyinProxy = "douyin_proxy"
) )
type Service interface { type Service interface {
// FetchAndSyncOrders 从抖店 API 获取订单并同步到本地 (按绑定用户同步) // FetchAndSyncOrders 从抖店 API 获取订单并同步到本地 (按绑定用户同步)
FetchAndSyncOrders(ctx context.Context) (*SyncResult, error) FetchAndSyncOrders(ctx context.Context, opts *FetchOptions) (*SyncResult, error)
// SyncAllOrders 批量同步所有订单变更 (基于更新时间,不分状态) // SyncAllOrders 批量同步所有订单变更 (基于更新时间,不分状态)
// useProxy: 是否使用代理服务器访问抖音API // useProxy: 是否使用代理服务器访问抖音API
SyncAllOrders(ctx context.Context, duration time.Duration, useProxy bool) (*SyncResult, error) SyncAllOrders(ctx context.Context, duration time.Duration, useProxy bool) (*SyncResult, error)
// ListOrders 获取本地抖店订单列表 // ListOrders 获取本地抖店订单列表
ListOrders(ctx context.Context, page, pageSize int, status *int) ([]*model.DouyinOrders, int64, error) ListOrders(ctx context.Context, page, pageSize int, filter *ListOrdersFilter) ([]*model.DouyinOrders, int64, error)
// GetConfig 获取抖店配置 // GetConfig 获取抖店配置
GetConfig(ctx context.Context) (*DouyinConfig, error) GetConfig(ctx context.Context) (*DouyinConfig, error)
// SaveConfig 保存抖店配置 // SaveConfig 保存抖店配置
SaveConfig(ctx context.Context, cookie string, intervalMinutes int) error SaveConfig(ctx context.Context, cookie, proxy string, intervalMinutes int) error
// SyncOrder 同步单个订单到本地可传入建议关联的用户ID和商品ID // SyncOrder 同步单个订单到本地可传入建议关联的用户ID和商品ID
SyncOrder(ctx context.Context, item *DouyinOrderItem, suggestUserID int64, productID string) (isNew bool, isMatched bool) SyncOrder(ctx context.Context, item *DouyinOrderItem, suggestUserID int64, productID string) (isNew bool, isMatched bool)
// GrantMinesweeperQualifications 自动补发扫雷资格 // GrantMinesweeperQualifications 自动补发扫雷资格
@ -53,19 +56,141 @@ type Service interface {
GrantLivestreamPrizes(ctx context.Context) error GrantLivestreamPrizes(ctx context.Context) error
// SyncRefundStatus 同步退款状态 // SyncRefundStatus 同步退款状态
SyncRefundStatus(ctx context.Context) error SyncRefundStatus(ctx context.Context) error
// GrantOrderReward 手动触发单个订单的奖励发放
GrantOrderReward(ctx context.Context, shopOrderID string) (*GrantOrderRewardResult, error)
} }
type DouyinConfig struct { type DouyinConfig struct {
Cookie string `json:"cookie"` Cookie string `json:"cookie"`
IntervalMinutes int `json:"interval_minutes"` IntervalMinutes int `json:"interval_minutes"`
Proxy string `json:"proxy"`
}
type FetchOptions struct {
OnlyUnmatched bool
MaxUsers int
BatchSize int
Concurrency int
InterBatchDelay time.Duration
}
const (
defaultFetchMaxUsers = 200
minFetchMaxUsers = 50
maxFetchMaxUsers = 1000
defaultFetchBatchSize = 20
minFetchBatchSize = 5
maxFetchBatchSize = 50
defaultFetchConcurrency = 5
minFetchConcurrency = 1
defaultFetchInterBatchDelay = 200 * time.Millisecond
maxFetchInterBatchDelay = 2 * time.Second
)
func normalizeFetchOptions(opts *FetchOptions) *FetchOptions {
n := FetchOptions{
OnlyUnmatched: true,
MaxUsers: defaultFetchMaxUsers,
BatchSize: defaultFetchBatchSize,
Concurrency: defaultFetchConcurrency,
InterBatchDelay: defaultFetchInterBatchDelay,
}
if opts != nil {
n.OnlyUnmatched = opts.OnlyUnmatched
if opts.MaxUsers > 0 {
n.MaxUsers = opts.MaxUsers
}
if opts.BatchSize > 0 {
n.BatchSize = opts.BatchSize
}
if opts.Concurrency > 0 {
n.Concurrency = opts.Concurrency
}
if opts.InterBatchDelay > 0 {
n.InterBatchDelay = opts.InterBatchDelay
} else if opts.InterBatchDelay == 0 {
n.InterBatchDelay = 0
}
}
if n.MaxUsers < minFetchMaxUsers {
n.MaxUsers = minFetchMaxUsers
}
if n.MaxUsers > maxFetchMaxUsers {
n.MaxUsers = maxFetchMaxUsers
}
if n.BatchSize < minFetchBatchSize {
n.BatchSize = minFetchBatchSize
}
if n.BatchSize > maxFetchBatchSize {
n.BatchSize = maxFetchBatchSize
}
if n.BatchSize > n.MaxUsers {
n.BatchSize = n.MaxUsers
}
if n.Concurrency < minFetchConcurrency {
n.Concurrency = minFetchConcurrency
}
if n.Concurrency > n.BatchSize {
n.Concurrency = n.BatchSize
}
if n.InterBatchDelay < 0 {
n.InterBatchDelay = 0
}
if n.InterBatchDelay > maxFetchInterBatchDelay {
n.InterBatchDelay = maxFetchInterBatchDelay
}
return &n
} }
type SyncResult struct { type SyncResult struct {
TotalFetched int `json:"total_fetched"` TotalFetched int `json:"total_fetched"`
NewOrders int `json:"new_orders"` NewOrders int `json:"new_orders"`
MatchedUsers int `json:"matched_users"` MatchedUsers int `json:"matched_users"`
Orders []*model.DouyinOrders `json:"orders"` // 新增:返回详情以供后续处理 Orders []*model.DouyinOrders `json:"orders"` // 新增:返回详情以供后续处理
DebugInfo string `json:"debug_info"` DebugInfo string `json:"debug_info"`
TotalUsers int `json:"total_users"`
ProcessedUsers int `json:"processed_users"`
SkippedUsers int `json:"skipped_users"`
ElapsedMS int64 `json:"elapsed_ms"`
}
type GrantOrderRewardResult struct {
ShopOrderID string `json:"shop_order_id"`
Message string `json:"message"`
Granted bool `json:"granted"`
RewardGranted int32 `json:"reward_granted"`
ProductCount int32 `json:"product_count"`
OrderStatus int32 `json:"order_status"`
LocalUserID string `json:"local_user_id"`
}
type ListOrdersFilter struct {
Status *int
MatchStatus *string
ShopOrderID string
DouyinUserID string
}
func (s *service) logRewardResult(ctx context.Context, shopOrderID string, douyinUserID string, localUserID int64, douyinProductID string, prizeID int64, source string, status string, message string) {
logEntry := &model.DouyinRewardLogs{
ShopOrderID: shopOrderID,
DouyinUserID: douyinUserID,
LocalUserID: localUserID,
DouyinProductID: douyinProductID,
PrizeID: prizeID,
Source: source,
Status: status,
Message: message,
Extra: "{}",
}
if err := s.repo.GetDbW().WithContext(ctx).Create(logEntry).Error; err != nil {
s.logger.Warn("[发奖日志] 写入失败", zap.String("order", shopOrderID), zap.Error(err))
}
} }
type service struct { type service struct {
@ -113,14 +238,20 @@ func (s *service) GetConfig(ctx context.Context) (*DouyinConfig, error) {
cfg.IntervalMinutes = v cfg.IntervalMinutes = v
} }
} }
if c, err := s.syscfg.GetByKey(ctx, ConfigKeyDouyinProxy); err == nil && c != nil {
cfg.Proxy = c.ConfigValue
}
return cfg, nil return cfg, nil
} }
// SaveConfig 保存抖店配置 // SaveConfig 保存抖店配置
func (s *service) SaveConfig(ctx context.Context, cookie string, intervalMinutes int) error { func (s *service) SaveConfig(ctx context.Context, cookie, proxy string, intervalMinutes int) error {
if _, err := s.syscfg.UpsertByKey(ctx, ConfigKeyDouyinCookie, cookie, "抖店Cookie"); err != nil { if _, err := s.syscfg.UpsertByKey(ctx, ConfigKeyDouyinCookie, cookie, "抖店Cookie"); err != nil {
return err return err
} }
if _, err := s.syscfg.UpsertByKey(ctx, ConfigKeyDouyinProxy, proxy, "抖店代理配置"); err != nil {
return err
}
if intervalMinutes < 1 { if intervalMinutes < 1 {
intervalMinutes = 5 intervalMinutes = 5
} }
@ -131,7 +262,7 @@ func (s *service) SaveConfig(ctx context.Context, cookie string, intervalMinutes
} }
// ListOrders 获取本地抖店订单列表 // ListOrders 获取本地抖店订单列表
func (s *service) ListOrders(ctx context.Context, page, pageSize int, status *int) ([]*model.DouyinOrders, int64, error) { func (s *service) ListOrders(ctx context.Context, page, pageSize int, filter *ListOrdersFilter) ([]*model.DouyinOrders, int64, error) {
if page <= 0 { if page <= 0 {
page = 1 page = 1
} }
@ -140,8 +271,27 @@ func (s *service) ListOrders(ctx context.Context, page, pageSize int, status *in
} }
db := s.repo.GetDbR().WithContext(ctx).Model(&model.DouyinOrders{}) db := s.repo.GetDbR().WithContext(ctx).Model(&model.DouyinOrders{})
if status != nil { if filter == nil {
db = db.Where("order_status = ?", *status) filter = &ListOrdersFilter{}
}
if filter != nil {
if filter.Status != nil {
db = db.Where("order_status = ?", *filter.Status)
}
if filter.MatchStatus != nil {
switch strings.ToLower(strings.TrimSpace(*filter.MatchStatus)) {
case "matched":
db = db.Where("local_user_id IS NOT NULL AND local_user_id != '' AND local_user_id != '0'")
case "unmatched":
db = db.Where("(local_user_id IS NULL OR local_user_id = '' OR local_user_id = '0')")
}
}
if filter.ShopOrderID != "" {
db = db.Where("shop_order_id = ?", filter.ShopOrderID)
}
if filter.DouyinUserID != "" {
db = db.Where("local_user_id IN (SELECT id FROM users WHERE douyin_user_id = ?)", filter.DouyinUserID)
}
} }
var total int64 var total int64
@ -158,7 +308,9 @@ func (s *service) ListOrders(ctx context.Context, page, pageSize int, status *in
} }
// FetchAndSyncOrders 遍历所有已绑定抖音号的用户并同步其订单 // FetchAndSyncOrders 遍历所有已绑定抖音号的用户并同步其订单
func (s *service) FetchAndSyncOrders(ctx context.Context) (*SyncResult, error) { func (s *service) FetchAndSyncOrders(ctx context.Context, opts *FetchOptions) (*SyncResult, error) {
options := normalizeFetchOptions(opts)
cfg, err := s.GetConfig(ctx) cfg, err := s.GetConfig(ctx)
if err != nil { if err != nil {
return nil, fmt.Errorf("获取配置失败: %w", err) return nil, fmt.Errorf("获取配置失败: %w", err)
@ -168,40 +320,159 @@ func (s *service) FetchAndSyncOrders(ctx context.Context) (*SyncResult, error) {
} }
// 1. 获取所有绑定了抖音号的用户 // 1. 获取所有绑定了抖音号的用户
userQuery := s.repo.GetDbR().WithContext(ctx).
Model(&model.Users{}).
Where("douyin_user_id IS NOT NULL AND douyin_user_id != ''")
if options.OnlyUnmatched {
subQuery := s.repo.GetDbR().WithContext(ctx).
Model(&model.DouyinOrders{}).
Select("1").
Where("douyin_orders.douyin_user_id = users.douyin_user_id").
Where("(douyin_orders.local_user_id IS NULL OR douyin_orders.local_user_id = '' OR douyin_orders.local_user_id = '0')")
userQuery = userQuery.Where("EXISTS (?)", subQuery)
}
userQuery = userQuery.Order("updated_at DESC").Limit(options.MaxUsers)
var users []model.Users var users []model.Users
if err := s.repo.GetDbR().WithContext(ctx).Where("douyin_user_id != ''").Find(&users).Error; err != nil { if err := userQuery.Find(&users).Error; err != nil {
return nil, fmt.Errorf("获取绑定用户失败: %w", err) return nil, fmt.Errorf("获取绑定用户失败: %w", err)
} }
result := &SyncResult{} result := &SyncResult{
fmt.Printf("[DEBUG] 开始全量同步,共 %d 个绑定用户\n", len(users)) TotalUsers: len(users),
}
startAt := time.Now()
s.logger.Info("[抖店同步] 按用户同步开始",
zap.Int("bound_users", len(users)),
zap.Bool("only_unmatched", options.OnlyUnmatched),
zap.Int("max_users", options.MaxUsers),
zap.Int("batch_size", options.BatchSize),
zap.Int("concurrency", options.Concurrency))
// 2. 遍历用户,按 buyer 抓取订单 if len(users) == 0 {
for _, u := range users { result.ElapsedMS = time.Since(startAt).Milliseconds()
fmt.Printf("[DEBUG] 正在同步用户 ID: %d (昵称: %s, 抖音号: %s) 的订单...\n", u.ID, u.Nickname, u.DouyinUserID) result.DebugInfo = "未找到符合条件的用户"
return result, nil
}
orders, err := s.fetchDouyinOrdersByBuyer(cfg.Cookie, u.DouyinUserID) var mu sync.Mutex
if err != nil {
fmt.Printf("[DEBUG] 抓取用户 %s 订单失败: %v\n", u.DouyinUserID, err) syncUser := func(u model.Users) {
continue select {
case <-ctx.Done():
return
default:
} }
result.TotalFetched += len(orders) s.logger.Info("[抖店同步] 开始同步用户订单",
zap.Int64("user_id", u.ID),
zap.String("nickname", u.Nickname),
zap.String("douyin_user_id", u.DouyinUserID))
// 3. 同步 orders, err := s.fetchDouyinOrdersByBuyer(cfg.Cookie, u.DouyinUserID, cfg.Proxy)
if err != nil {
s.logger.Warn("[抖店同步] 抓取用户订单失败",
zap.String("douyin_user_id", u.DouyinUserID),
zap.Error(err))
mu.Lock()
result.SkippedUsers++
mu.Unlock()
return
}
perUserNew := 0
perUserMatched := 0
for _, order := range orders { for _, order := range orders {
// 同步订单(传入建议关联的用户 ID
isNew, matched := s.SyncOrder(ctx, &order, u.ID, "") isNew, matched := s.SyncOrder(ctx, &order, u.ID, "")
if isNew { if isNew {
result.NewOrders++ perUserNew++
} }
if matched { if matched {
result.MatchedUsers++ perUserMatched++
}
}
mu.Lock()
result.ProcessedUsers++
result.TotalFetched += len(orders)
result.NewOrders += perUserNew
result.MatchedUsers += perUserMatched
mu.Unlock()
s.logger.Info("[抖店同步] 用户订单同步完成",
zap.Int64("user_id", u.ID),
zap.Int("fetched", len(orders)),
zap.Int("new_orders", perUserNew),
zap.Int("matched_orders", perUserMatched))
}
for start := 0; start < len(users); start += options.BatchSize {
end := start + options.BatchSize
if end > len(users) {
end = len(users)
}
batch := users[start:end]
if err := ctx.Err(); err != nil {
break
}
s.logger.Info("[抖店同步] Batch start",
zap.Int("batch_index", start/options.BatchSize+1),
zap.Int("batch_size", len(batch)),
zap.Int64("first_user_id", batch[0].ID),
zap.Int64("last_user_id", batch[len(batch)-1].ID))
var wg sync.WaitGroup
sem := make(chan struct{}, options.Concurrency)
stop := false
for _, user := range batch {
user := user
if err := ctx.Err(); err != nil {
stop = true
break
}
sem <- struct{}{}
wg.Add(1)
go func(u model.Users) {
defer wg.Done()
defer func() { <-sem }()
syncUser(u)
}(user)
}
wg.Wait()
if stop {
break
}
if options.InterBatchDelay > 0 && end < len(users) {
select {
case <-time.After(options.InterBatchDelay):
case <-ctx.Done():
break
} }
} }
} }
result.DebugInfo += fmt.Sprintf("\n同步完成: 总抓取 %d, 新订单 %d, 匹配用户 %d", result.TotalFetched, result.NewOrders, result.MatchedUsers) result.ElapsedMS = time.Since(startAt).Milliseconds()
result.DebugInfo = fmt.Sprintf("按用户同步完成: 处理 %d/%d, 跳过 %d, 抓取 %d, 新订单 %d, 匹配 %d, 耗时 %.2fs",
result.ProcessedUsers, result.TotalUsers, result.SkippedUsers,
result.TotalFetched, result.NewOrders, result.MatchedUsers,
float64(result.ElapsedMS)/1000.0)
s.logger.Info("[抖店同步] 按用户同步完成",
zap.Int("total_fetched", result.TotalFetched),
zap.Int("new_orders", result.NewOrders),
zap.Int("matched_users", result.MatchedUsers),
zap.Int("processed_users", result.ProcessedUsers),
zap.Int("skipped_users", result.SkippedUsers),
zap.Int64("elapsed_ms", result.ElapsedMS))
return result, nil return result, nil
} }
@ -243,7 +514,7 @@ type SkuOrderItem struct {
} }
// fetchDouyinOrdersByBuyer 调用抖店 API 按 Buyer ID 获取订单 (保持向后兼容) // fetchDouyinOrdersByBuyer 调用抖店 API 按 Buyer ID 获取订单 (保持向后兼容)
func (s *service) fetchDouyinOrdersByBuyer(cookie string, buyer string) ([]DouyinOrderItem, error) { func (s *service) fetchDouyinOrdersByBuyer(cookie string, buyer string, proxy string) ([]DouyinOrderItem, error) {
params := url.Values{} params := url.Values{}
params.Set("page", "0") params.Set("page", "0")
params.Set("pageSize", "100") params.Set("pageSize", "100")
@ -255,18 +526,22 @@ func (s *service) fetchDouyinOrdersByBuyer(cookie string, buyer string) ([]Douyi
params.Set("_bid", "ffa_order") params.Set("_bid", "ffa_order")
params.Set("aid", "4272") params.Set("aid", "4272")
return s.fetchDouyinOrders(cookie, params, true) // 按用户同步使用代理 return s.fetchDouyinOrders(cookie, params, proxy)
} }
// fetchDouyinOrders 通用的抖店订单抓取方法 // fetchDouyinOrders 通用的抖店订单抓取方法
func (s *service) fetchDouyinOrders(cookie string, params url.Values, useProxy bool) ([]DouyinOrderItem, error) { func (s *service) fetchDouyinOrders(cookie string, params url.Values, proxyAddr string) ([]DouyinOrderItem, error) {
baseUrl := "https://fxg.jinritemai.com/api/order/searchlist" baseUrl := "https://fxg.jinritemai.com/api/order/searchlist"
fullUrl := baseUrl + "?" + params.Encode() fullUrl := baseUrl + "?" + params.Encode()
// 配置代理服务器巨量代理IP (可选) // 配置代理服务器巨量代理IP (可选)
var proxyURL *url.URL var proxyURL *url.URL
if useProxy { if strings.TrimSpace(proxyAddr) != "" {
proxyURL, _ = url.Parse("http://t13319619426654:ln8aj9nl@s432.kdltps.com:15818") if parsed, err := url.Parse(proxyAddr); err != nil {
s.logger.Warn("[抖店API] 代理地址解析失败", zap.String("proxy", proxyAddr), zap.Error(err))
} else {
proxyURL = parsed
}
} }
var lastErr error var lastErr error
@ -285,9 +560,9 @@ func (s *service) fetchDouyinOrders(cookie string, params url.Values, useProxy b
// 禁用连接复用,防止代理断开导致 EOF // 禁用连接复用,防止代理断开导致 EOF
req.Close = true req.Close = true
// 根据 useProxy 参数决定是否使用代理 // 根据 proxyURL 是否存在决定是否使用代理
var transport *http.Transport var transport *http.Transport
if useProxy && proxyURL != nil { if proxyURL != nil {
transport = &http.Transport{ transport = &http.Transport{
Proxy: http.ProxyURL(proxyURL), Proxy: http.ProxyURL(proxyURL),
DisableKeepAlives: true, // 禁用 Keep-Alive DisableKeepAlives: true, // 禁用 Keep-Alive
@ -306,7 +581,7 @@ func (s *service) fetchDouyinOrders(cookie string, params url.Values, useProxy b
resp, err := client.Do(req) resp, err := client.Do(req)
if err != nil { if err != nil {
lastErr = err lastErr = err
s.logger.Warn("[抖店API] 请求失败,准备重试", zap.Int("retry", i+1), zap.Bool("use_proxy", useProxy), zap.Error(err)) s.logger.Warn("[抖店API] 请求失败,准备重试", zap.Int("retry", i+1), zap.Bool("use_proxy", proxyURL != nil), zap.Error(err))
time.Sleep(1 * time.Second) time.Sleep(1 * time.Second)
continue continue
} }
@ -559,6 +834,99 @@ func (s *service) SyncOrder(ctx context.Context, item *DouyinOrderItem, suggestU
return isNew, isMatched return isNew, isMatched
} }
// GrantOrderReward 手动触发单个订单的奖励发放
func (s *service) GrantOrderReward(ctx context.Context, shopOrderID string) (*GrantOrderRewardResult, error) {
if strings.TrimSpace(shopOrderID) == "" {
return nil, fmt.Errorf("shop_order_id 不能为空")
}
order, err := s.readDB.DouyinOrders.WithContext(ctx).
Where(s.readDB.DouyinOrders.ShopOrderID.Eq(shopOrderID)).
First()
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
s.logRewardResult(ctx, shopOrderID, "", 0, "", 0, "manual", "failed", "订单不存在")
return nil, fmt.Errorf("订单不存在: %s", shopOrderID)
}
return nil, err
}
if order.OrderStatus != 2 {
localUID, _ := strconv.ParseInt(order.LocalUserID, 10, 64)
s.logRewardResult(ctx, order.ShopOrderID, order.DouyinUserID, localUID, order.DouyinProductID, 0, "manual", "skipped", "订单状态非待发货")
return &GrantOrderRewardResult{
ShopOrderID: shopOrderID,
Message: "订单状态非待发货,无法发放",
Granted: false,
OrderStatus: order.OrderStatus,
ProductCount: order.ProductCount,
LocalUserID: order.LocalUserID,
}, nil
}
if order.DouyinProductID == "" {
localUID, _ := strconv.ParseInt(order.LocalUserID, 10, 64)
s.logRewardResult(ctx, order.ShopOrderID, order.DouyinUserID, localUID, "", 0, "manual", "failed", "缺少商品ID")
return nil, fmt.Errorf("订单缺少 Douyin 商品ID无法匹配奖励")
}
if order.LocalUserID == "" || order.LocalUserID == "0" {
s.logRewardResult(ctx, order.ShopOrderID, order.DouyinUserID, 0, order.DouyinProductID, 0, "manual", "failed", "订单未绑定本地用户")
return nil, fmt.Errorf("订单未绑定本地用户,无法发放奖励")
}
localUserID, _ := strconv.ParseInt(order.LocalUserID, 10, 64)
if localUserID <= 0 {
return nil, fmt.Errorf("订单本地用户ID无效")
}
var rewards []model.DouyinProductRewards
if err := s.repo.GetDbR().WithContext(ctx).
Where("product_id = ? AND status = 1", order.DouyinProductID).
Find(&rewards).Error; err != nil {
return nil, fmt.Errorf("查询奖励规则失败: %w", err)
}
if len(rewards) == 0 {
return nil, fmt.Errorf("该商品未配置奖励规则")
}
if s.rewardDispatcher == nil {
return nil, fmt.Errorf("奖励发放器未初始化")
}
totalGranted := int32(0)
for _, reward := range rewards {
if s.rewardDispatcher.IsFlipCardReward(reward) {
continue
}
if err := s.rewardDispatcher.GrantReward(ctx, localUserID, reward, int(order.ProductCount), "douyin_order_manual", order.ID, order.ShopOrderID); err != nil {
s.logRewardResult(ctx, order.ShopOrderID, order.DouyinUserID, localUserID, order.DouyinProductID, reward.ID, "manual", "failed", err.Error())
return nil, fmt.Errorf("发放奖励失败 (规则 %d): %w", reward.ID, err)
}
totalGranted += order.ProductCount
}
if totalGranted > 0 {
if err := s.repo.GetDbW().WithContext(ctx).
Model(&model.DouyinOrders{}).
Where("id = ?", order.ID).
Update("reward_granted", totalGranted).Error; err != nil {
return nil, fmt.Errorf("更新发奖状态失败: %w", err)
}
s.logRewardResult(ctx, order.ShopOrderID, order.DouyinUserID, localUserID, order.DouyinProductID, 0, "manual", "success", "手动发奖成功")
}
return &GrantOrderRewardResult{
ShopOrderID: shopOrderID,
Message: "奖励发放成功",
Granted: totalGranted > 0,
RewardGranted: totalGranted,
ProductCount: order.ProductCount,
OrderStatus: order.OrderStatus,
LocalUserID: order.LocalUserID,
}, nil
}
// min 返回两个整数的最小值 // min 返回两个整数的最小值
func min(a, b int) int { func min(a, b int) int {
if a < b { if a < b {
@ -612,7 +980,12 @@ func (s *service) SyncAllOrders(ctx context.Context, duration time.Duration, use
} }
fetchStart := time.Now() fetchStart := time.Now()
orders, err := s.fetchDouyinOrders(cfg.Cookie, queryParams, useProxy) proxyAddr := ""
if useProxy {
proxyAddr = cfg.Proxy
}
orders, err := s.fetchDouyinOrders(cfg.Cookie, queryParams, proxyAddr)
fetchDuration := time.Since(fetchStart) fetchDuration := time.Since(fetchStart)
if err != nil { if err != nil {

View File

@ -1,6 +1,8 @@
package douyin package douyin
import ( import (
"errors"
"bindbox-game/internal/pkg/logger" "bindbox-game/internal/pkg/logger"
"bindbox-game/internal/repository/mysql" "bindbox-game/internal/repository/mysql"
"bindbox-game/internal/repository/mysql/model" "bindbox-game/internal/repository/mysql/model"
@ -14,6 +16,8 @@ import (
"go.uber.org/zap" "go.uber.org/zap"
"bindbox-game/internal/service/user" "bindbox-game/internal/service/user"
"gorm.io/gorm"
) )
// StartDouyinOrderSync 启动抖店订单定时同步任务 // StartDouyinOrderSync 启动抖店订单定时同步任务
@ -157,6 +161,8 @@ func (s *service) GrantLivestreamPrizes(ctx context.Context) error {
return err return err
} }
anchorCodes := s.resolveActivityAnchorCodes(ctx, logs)
for _, log := range logs { for _, log := range logs {
// 必须要有对应的本地用户ID // 必须要有对应的本地用户ID
if log.LocalUserID == 0 { if log.LocalUserID == 0 {
@ -174,11 +180,36 @@ func (s *service) GrantLivestreamPrizes(ctx context.Context) error {
continue // 还没关联到用户,跳过 continue // 还没关联到用户,跳过
} }
if code := anchorCodes[log.ActivityID]; code != "" {
s.bindAnchorInviterIfNeeded(ctx, log.LocalUserID, code)
}
// 2. 查奖品关联的 ProductID // 2. 查奖品关联的 ProductID
var prize model.LivestreamPrizes var prize model.LivestreamPrizes
if err := s.repo.GetDbR().Where("id = ?", log.PrizeID).First(&prize).Error; err != nil { if err := s.repo.GetDbR().Where("id = ?", log.PrizeID).First(&prize).Error; err != nil {
s.logger.Error("[自动发放] 奖品不存在", zap.Int64("prize_id", log.PrizeID)) if errors.Is(err, gorm.ErrRecordNotFound) {
continue if log.ProductID > 0 {
prize = model.LivestreamPrizes{
ID: log.PrizeID,
Name: log.PrizeName,
ProductID: log.ProductID,
}
s.logger.Warn("[自动发放] 奖品配置缺失,使用快照兜底",
zap.Int64("prize_id", log.PrizeID),
zap.Int64("product_id", log.ProductID),
zap.Int64("log_id", log.ID))
} else {
s.logger.Error("[自动发放] 奖品不存在且缺少快照",
zap.Int64("prize_id", log.PrizeID),
zap.Int64("log_id", log.ID))
continue
}
} else {
s.logger.Error("[自动发放] 查询奖品失败",
zap.Int64("prize_id", log.PrizeID),
zap.Error(err))
continue
}
} }
if prize.ProductID == 0 { if prize.ProductID == 0 {
@ -204,11 +235,13 @@ func (s *service) GrantLivestreamPrizes(ctx context.Context) error {
res, err := s.userSvc.GrantReward(ctx, log.LocalUserID, req) res, err := s.userSvc.GrantReward(ctx, log.LocalUserID, req)
if err != nil { if err != nil {
s.logger.Error("[自动发放] 发放失败", zap.Error(err)) s.logger.Error("[自动发放] 发放失败", zap.Error(err))
s.logRewardResult(ctx, log.ShopOrderID, log.DouyinUserID, log.LocalUserID, fmt.Sprintf("%d", prize.ProductID), log.PrizeID, "auto", "failed", err.Error())
// 如果发放失败是库存原因等,可能需要告警。暂时不重试,等下个周期。 // 如果发放失败是库存原因等,可能需要告警。暂时不重试,等下个周期。
} else { } else {
// 4. 更新发放状态 // 4. 更新发放状态
db.Model(&log).Update("is_granted", 1) db.Model(&log).Update("is_granted", 1)
s.logger.Info("[自动发放] 发放成功", zap.Int64("log_id", log.ID)) s.logger.Info("[自动发放] 发放成功", zap.Int64("log_id", log.ID))
s.logRewardResult(ctx, log.ShopOrderID, log.DouyinUserID, log.LocalUserID, fmt.Sprintf("%d", prize.ProductID), log.PrizeID, "auto", "success", "发放成功")
// 5. 自动虚拟发货 (本地状态更新) // 5. 自动虚拟发货 (本地状态更新)
// 直播间奖品通常为虚拟发货,直接标记为已消费/已发货 // 直播间奖品通常为虚拟发货,直接标记为已消费/已发货
@ -319,6 +352,15 @@ func (s *service) reclaimLivestreamAssets(ctx context.Context, log *model.Livest
} }
// 2. 回收资产 // 2. 回收资产
rate := int64(1)
var cfg model.SystemConfigs
if err := s.repo.GetDbR().Where("config_key = ?", "points_exchange_per_cent").First(&cfg).Error; err == nil {
var rv int64
_, _ = fmt.Sscanf(cfg.ConfigValue, "%d", &rv)
if rv > 0 {
rate = rv
}
}
for _, inv := range inventories { for _, inv := range inventories {
if inv.Status == 1 { if inv.Status == 1 {
// 状态1持有作废 // 状态1持有作废
@ -332,25 +374,28 @@ func (s *service) reclaimLivestreamAssets(ctx context.Context, log *model.Livest
) )
} else if inv.Status == 3 { } else if inv.Status == 3 {
// 状态3已兑换/发货):扣除积分 // 状态3已兑换/发货):扣除积分
// 查找商品价格作为积分扣除依据 pointsToDeduct := inv.ValueCents * rate
var product model.Products if pointsToDeduct <= 0 {
if err := s.repo.GetDbR().Where("id = ?", inv.ProductID).First(&product).Error; err == nil { // 兼容历史数据,兜底回退商品价格
pointsToDeduct := product.Price / 100 // 分转换为积分(假设 1积分=1分钱 var product model.Products
if pointsToDeduct > 0 { if err := s.repo.GetDbR().Where("id = ?", inv.ProductID).First(&product).Error; err == nil {
_, consumed, err := s.userSvc.ConsumePointsForRefund(ctx, inv.UserID, pointsToDeduct, "user_inventory", fmt.Sprintf("%d", inv.ID), "直播退款回收已兑换资产") pointsToDeduct = product.Price * rate
if err != nil { }
s.logger.Error("[资产回收] 扣除积分失败", zap.Error(err), zap.Int64("user_id", inv.UserID)) }
} if pointsToDeduct > 0 {
if consumed < pointsToDeduct { _, consumed, err := s.userSvc.ConsumePointsForRefund(ctx, inv.UserID, pointsToDeduct, "user_inventory", fmt.Sprintf("%d", inv.ID), "直播退款回收已兑换资产")
// 积分不足,标记用户 if err != nil {
s.logger.Warn("[资产回收] 用户积分不足", s.logger.Error("[资产回收] 扣除积分失败", zap.Error(err), zap.Int64("user_id", inv.UserID))
zap.Int64("user_id", inv.UserID), }
zap.Int64("needed", pointsToDeduct), if consumed < pointsToDeduct {
zap.Int64("consumed", consumed), // 积分不足,标记用户
) s.logger.Warn("[资产回收] 用户积分不足",
// 可选:加入黑名单 zap.Int64("user_id", inv.UserID),
// db.Exec("UPDATE users SET status = 3 WHERE id = ?", inv.UserID) zap.Int64("needed", pointsToDeduct),
} zap.Int64("consumed", consumed),
)
// 可选:加入黑名单
// db.Exec("UPDATE users SET status = 3 WHERE id = ?", inv.UserID)
} }
} }
// 作废记录 // 作废记录
@ -369,3 +414,133 @@ func (s *service) reclaimLivestreamAssets(ctx context.Context, log *model.Livest
db.Exec("UPDATE livestream_prizes SET remaining = remaining + 1 WHERE id = ? AND remaining >= 0", log.PrizeID) db.Exec("UPDATE livestream_prizes SET remaining = remaining + 1 WHERE id = ? AND remaining >= 0", log.PrizeID)
s.logger.Info("[资产回收] 恢复奖品库存", zap.Int64("prize_id", log.PrizeID)) s.logger.Info("[资产回收] 恢复奖品库存", zap.Int64("prize_id", log.PrizeID))
} }
func (s *service) resolveActivityAnchorCodes(ctx context.Context, logs []model.LivestreamDrawLogs) map[int64]string {
result := make(map[int64]string)
if len(logs) == 0 {
return result
}
type anchorMeta struct {
channelID int64
channelCode string
}
activityMeta := make(map[int64]anchorMeta)
var activityIDs []int64
for _, log := range logs {
if log.ActivityID <= 0 {
continue
}
if _, exists := activityMeta[log.ActivityID]; exists {
continue
}
activityMeta[log.ActivityID] = anchorMeta{}
activityIDs = append(activityIDs, log.ActivityID)
}
if len(activityIDs) == 0 {
return result
}
var rows []struct {
ID int64
ChannelID int64
ChannelCode string
}
if err := s.repo.GetDbR().WithContext(ctx).
Table("livestream_activities").
Select("id, channel_id, channel_code").
Where("id IN ?", activityIDs).
Scan(&rows).Error; err != nil {
s.logger.Error("[自动发放] 查询活动渠道信息失败", zap.Error(err))
return result
}
for _, row := range rows {
activityMeta[row.ID] = anchorMeta{
channelID: row.ChannelID,
channelCode: row.ChannelCode,
}
}
missingChannelIDs := make([]int64, 0)
seenChannels := make(map[int64]struct{})
for _, meta := range activityMeta {
if meta.channelCode == "" && meta.channelID > 0 {
if _, ok := seenChannels[meta.channelID]; !ok {
seenChannels[meta.channelID] = struct{}{}
missingChannelIDs = append(missingChannelIDs, meta.channelID)
}
}
}
channelCodeMap := s.fetchChannelCodes(ctx, missingChannelIDs)
for activityID, meta := range activityMeta {
code := meta.channelCode
if code == "" && meta.channelID > 0 {
code = channelCodeMap[meta.channelID]
}
if code != "" {
result[activityID] = code
}
}
return result
}
func (s *service) fetchChannelCodes(ctx context.Context, ids []int64) map[int64]string {
result := make(map[int64]string)
if len(ids) == 0 {
return result
}
var rows []struct {
ID int64
Code string
}
if err := s.repo.GetDbR().WithContext(ctx).
Table("channels").
Select("id, code").
Where("id IN ?", ids).
Scan(&rows).Error; err != nil {
s.logger.Error("[自动发放] 查询渠道失败", zap.Error(err))
return result
}
for _, row := range rows {
result[row.ID] = row.Code
}
return result
}
func (s *service) bindAnchorInviterIfNeeded(ctx context.Context, userID int64, anchorCode string) {
if userID <= 0 || anchorCode == "" {
return
}
userRecord, err := s.readDB.Users.WithContext(ctx).
Select(s.readDB.Users.InviterID).
Where(s.readDB.Users.ID.Eq(userID)).
First()
if err != nil {
if !errors.Is(err, gorm.ErrRecordNotFound) {
s.logger.Warn("[自动发放] 查询用户邀请人失败", zap.Int64("user_id", userID), zap.Error(err))
}
return
}
if userRecord.InviterID != 0 {
return
}
if _, err := s.userSvc.BindInviter(ctx, userID, user.BindInviterInput{InviteCode: anchorCode}); err != nil {
if err == user.ErrAlreadyBound {
return
}
if err == user.ErrInvalidCode {
s.logger.Warn("[自动发放] 主播邀请码无效", zap.String("channel_code", anchorCode), zap.Int64("user_id", userID))
return
}
s.logger.Warn("[自动发放] 绑定主播邀请码失败", zap.String("channel_code", anchorCode), zap.Int64("user_id", userID), zap.Error(err))
return
}
s.logger.Info("[自动发放] 已补绑定主播邀请人", zap.Int64("user_id", userID), zap.String("channel_code", anchorCode))
}

View File

@ -0,0 +1,81 @@
package finance
import "strings"
const defaultMultiplierX1000 int64 = 1000
type SpendingBreakdown struct {
PaidCoupon int64
GamePass int64
Total int64
IsGamePass bool
}
// ClassifyOrderSpending applies the unified rule:
// - game pass order: spending = game pass value
// - 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 {
if gamePassValue < 0 {
gamePassValue = 0
}
return SpendingBreakdown{
PaidCoupon: 0,
GamePass: gamePassValue,
Total: gamePassValue,
IsGamePass: true,
}
}
paidCoupon := actualAmount + discountAmount
if paidCoupon < 0 {
paidCoupon = 0
}
return SpendingBreakdown{
PaidCoupon: paidCoupon,
GamePass: 0,
Total: paidCoupon,
IsGamePass: false,
}
}
func IsGamePassOrder(sourceType int32, orderNo string, actualAmount int64, remark string) bool {
if sourceType == 4 {
return true
}
if strings.HasPrefix(orderNo, "GP") {
return true
}
return actualAmount == 0 && strings.Contains(remark, "use_game_pass")
}
func ComputeGamePassValue(drawCount, activityPrice int64) int64 {
if drawCount <= 0 || activityPrice <= 0 {
return 0
}
return drawCount * activityPrice
}
func NormalizeMultiplierX1000(multiplierX1000 int64) int64 {
if multiplierX1000 <= 0 {
return defaultMultiplierX1000
}
return multiplierX1000
}
func ComputePrizeCostWithMultiplier(baseCost, multiplierX1000 int64) int64 {
if baseCost <= 0 {
return 0
}
n := NormalizeMultiplierX1000(multiplierX1000)
return baseCost * n / defaultMultiplierX1000
}
func ComputeProfit(spending, prizeCost int64) (int64, float64) {
profit := spending - prizeCost
if spending <= 0 {
return profit, 0
}
return profit, float64(profit) / float64(spending)
}

View File

@ -0,0 +1,57 @@
package finance
import "testing"
func TestClassifyOrderSpendingNormal(t *testing.T) {
got := ClassifyOrderSpending(2, "O2026", 1000, 200, "", 0)
if got.IsGamePass {
t.Fatalf("expected non game pass")
}
if got.Total != 1200 || got.PaidCoupon != 1200 || got.GamePass != 0 {
t.Fatalf("unexpected breakdown: %+v", got)
}
}
func TestClassifyOrderSpendingGamePass(t *testing.T) {
got := ClassifyOrderSpending(4, "GP2026", 0, 0, "use_game_pass", 2000)
if !got.IsGamePass {
t.Fatalf("expected game pass")
}
if got.Total != 2000 || got.PaidCoupon != 0 || got.GamePass != 2000 {
t.Fatalf("unexpected breakdown: %+v", got)
}
}
func TestComputePrizeCostWithMultiplier(t *testing.T) {
got := ComputePrizeCostWithMultiplier(1500, 2000)
if got != 3000 {
t.Fatalf("expected 3000 got %d", got)
}
}
func TestProfitNormalOrder(t *testing.T) {
sp := ClassifyOrderSpending(2, "O1", 1000, 200, "", 0)
profit, _ := ComputeProfit(sp.Total, 900)
if profit != 300 {
t.Fatalf("expected 300 got %d", profit)
}
}
func TestProfitGamePassOrder(t *testing.T) {
gpValue := ComputeGamePassValue(2, 1000)
sp := ClassifyOrderSpending(4, "GP1", 0, 0, "use_game_pass", gpValue)
profit, _ := ComputeProfit(sp.Total, 1500)
if profit != 500 {
t.Fatalf("expected 500 got %d", profit)
}
}
func TestProfitGamePassOrderWithMultiplier(t *testing.T) {
gpValue := ComputeGamePassValue(2, 1000)
sp := ClassifyOrderSpending(4, "GP1", 0, 0, "use_game_pass", gpValue)
cost := ComputePrizeCostWithMultiplier(1500, 2000)
profit, _ := ComputeProfit(sp.Total, cost)
if profit != -1000 {
t.Fatalf("expected -1000 got %d", profit)
}
}

View File

@ -82,6 +82,8 @@ type CreateActivityInput struct {
Name string Name string
StreamerName string StreamerName string
StreamerContact string StreamerContact string
ChannelID int64
ChannelCode string
DouyinProductID string DouyinProductID string
OrderRewardType string OrderRewardType string
OrderRewardQuantity int32 OrderRewardQuantity int32
@ -94,6 +96,8 @@ type UpdateActivityInput struct {
Name string Name string
StreamerName string StreamerName string
StreamerContact string StreamerContact string
ChannelID *int64
ChannelCode *string
DouyinProductID string DouyinProductID string
OrderRewardType string OrderRewardType string
OrderRewardQuantity *int32 OrderRewardQuantity *int32
@ -169,6 +173,8 @@ func (s *service) CreateActivity(ctx context.Context, input CreateActivityInput)
Name: input.Name, Name: input.Name,
StreamerName: input.StreamerName, StreamerName: input.StreamerName,
StreamerContact: input.StreamerContact, StreamerContact: input.StreamerContact,
ChannelID: input.ChannelID,
ChannelCode: input.ChannelCode,
AccessCode: accessCode, AccessCode: accessCode,
DouyinProductID: input.DouyinProductID, DouyinProductID: input.DouyinProductID,
OrderRewardType: input.OrderRewardType, OrderRewardType: input.OrderRewardType,
@ -205,6 +211,12 @@ func (s *service) UpdateActivity(ctx context.Context, id int64, input UpdateActi
if input.StreamerContact != "" { if input.StreamerContact != "" {
updates["streamer_contact"] = input.StreamerContact updates["streamer_contact"] = input.StreamerContact
} }
if input.ChannelID != nil {
updates["channel_id"] = *input.ChannelID
}
if input.ChannelCode != nil {
updates["channel_code"] = *input.ChannelCode
}
if input.DouyinProductID != "" { if input.DouyinProductID != "" {
updates["douyin_product_id"] = input.DouyinProductID updates["douyin_product_id"] = input.DouyinProductID
} }

View File

@ -33,8 +33,11 @@ func TestInviteLogicSymmetry(t *testing.T) {
created_at DATETIME DEFAULT CURRENT_TIMESTAMP, created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
deleted_at DATETIME deleted_at DATETIME
);`) );`)
db.Exec(`CREATE TABLE activity_draw_logs (order_id INTEGER, issue_id INTEGER);`) db.Exec(`CREATE TABLE activity_draw_logs (
db.Exec(`CREATE TABLE activity_issues (id INTEGER, activity_id INTEGER);`) id INTEGER PRIMARY KEY AUTOINCREMENT,
order_id INTEGER,
issue_id INTEGER
);`)
svc := New(nil, repo, nil, nil, nil) svc := New(nil, repo, nil, nil, nil)
inviterID := int64(888) inviterID := int64(888)
@ -45,6 +48,7 @@ func TestInviteLogicSymmetry(t *testing.T) {
// 只有 101 在活动 77 中下过单并开奖 // 只有 101 在活动 77 中下过单并开奖
db.Exec("INSERT INTO activity_issues (id, activity_id) VALUES (1, 77)") db.Exec("INSERT INTO activity_issues (id, activity_id) VALUES (1, 77)")
db.Exec("INSERT INTO activities (id, price_draw) VALUES (77, 100)")
db.Exec("INSERT INTO orders (id, user_id, status, total_amount, source_type) VALUES (10, 101, 2, 100, 0)") db.Exec("INSERT INTO orders (id, user_id, status, total_amount, source_type) VALUES (10, 101, 2, 100, 0)")
db.Exec("INSERT INTO activity_draw_logs (order_id, issue_id) VALUES (10, 1)") db.Exec("INSERT INTO activity_draw_logs (order_id, issue_id) VALUES (10, 1)")

View File

@ -12,7 +12,6 @@ import (
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "fmt"
"strings"
"time" "time"
gamesvc "bindbox-game/internal/service/game" gamesvc "bindbox-game/internal/service/game"
@ -34,7 +33,7 @@ type Service interface {
ListTaskTiers(ctx context.Context, taskID int64) ([]TaskTierItem, error) ListTaskTiers(ctx context.Context, taskID int64) ([]TaskTierItem, error)
UpsertTaskTiers(ctx context.Context, taskID int64, tiers []TaskTierInput) error UpsertTaskTiers(ctx context.Context, taskID int64, tiers []TaskTierInput) error
ListTaskRewards(ctx context.Context, taskID int64) ([]TaskRewardItem, error) ListTaskRewards(ctx context.Context, taskID int64) ([]TaskRewardItem, error)
UpsertTaskRewards(ctx context.Context, taskID int64, rewards []TaskRewardInput) error UpsertTaskRewards(ctx context.Context, taskID int64, rewards []TaskRewardInput, deleteIDs []int64) error
GetUserProgress(ctx context.Context, userID int64, taskID int64) (*UserProgress, error) GetUserProgress(ctx context.Context, userID int64, taskID int64) (*UserProgress, error)
ClaimTier(ctx context.Context, userID int64, taskID int64, tierID int64) error ClaimTier(ctx context.Context, userID int64, taskID int64, tierID int64) error
OnOrderPaid(ctx context.Context, userID int64, orderID int64) error OnOrderPaid(ctx context.Context, userID int64, orderID int64) error
@ -164,6 +163,7 @@ type TaskTierItem struct {
} }
type TaskRewardInput struct { type TaskRewardInput struct {
ID int64 `json:"id"`
TierID int64 `json:"tier_id"` TierID int64 `json:"tier_id"`
RewardType string `json:"reward_type"` RewardType string `json:"reward_type"`
RewardPayload datatypes.JSON `json:"reward_payload"` RewardPayload datatypes.JSON `json:"reward_payload"`
@ -179,6 +179,172 @@ type TaskRewardItem struct {
RewardName string `json:"reward_name"` RewardName string `json:"reward_name"`
} }
type orderMetricRow struct {
OrderID int64
ActivityID int64
DrawCount int64
TicketPrice int64
TotalAmount int64
}
var allowedWindows = map[string]struct{}{
WindowDaily: {},
WindowWeekly: {},
WindowMonthly: {},
WindowLifetime: {},
WindowActivityPeriod: {},
WindowSinceRegistration: {},
}
func normalizeWindow(value string) string {
if value == "" {
return WindowLifetime
}
if _, ok := allowedWindows[value]; !ok {
return WindowLifetime
}
return value
}
func normalizeWindowStrict(value string) (string, error) {
if value == "" {
return WindowLifetime, nil
}
if _, ok := allowedWindows[value]; !ok {
return "", fmt.Errorf("invalid window value: %s", value)
}
return value, nil
}
func tierFingerprint(metric string, threshold int64, activityID int64, window string) string {
return fmt.Sprintf("%s-%d-%d-%s", metric, threshold, activityID, window)
}
func (s *service) fetchOrderMetricRows(ctx context.Context, userID int64, activityIDs []int64, start, end *time.Time) ([]orderMetricRow, error) {
query := s.repo.GetDbR().WithContext(ctx).Table(model.TableNameOrders).
Select("orders.id AS order_id, activity_issues.activity_id AS activity_id, COUNT(activity_draw_logs.id) AS draw_count, COALESCE(activities.price_draw, 0) AS ticket_price, orders.total_amount").
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.user_id = ? AND orders.status = 2 AND orders.source_type != 1", userID).
Group("orders.id, activity_issues.activity_id, activities.price_draw, orders.total_amount")
if len(activityIDs) > 0 {
query = query.Where("activity_issues.activity_id IN ?", activityIDs)
}
if start != nil {
query = query.Where("orders.created_at >= ?", *start)
}
if end != nil {
query = query.Where("orders.created_at <= ?", *end)
}
var rows []orderMetricRow
if err := query.Scan(&rows).Error; err != nil {
return nil, err
}
return rows, nil
}
func (s *service) calculateEffectiveAmount(row orderMetricRow) int64 {
if row.TicketPrice > 0 && row.DrawCount > 0 {
return row.TicketPrice * row.DrawCount
}
if row.TotalAmount > 0 {
if s.logger != nil && row.TicketPrice == 0 {
s.logger.Warn("task center: missing ticket price snapshot, fallback to order amount",
zap.Int64("order_id", row.OrderID),
zap.Int64("activity_id", row.ActivityID))
}
return row.TotalAmount
}
return 0
}
func (s *service) aggregateOrderMetrics(rows []orderMetricRow, perActivity bool) (count int64, amount int64) {
if perActivity {
for _, row := range rows {
amount += s.calculateEffectiveAmount(row)
}
return int64(len(rows)), amount
}
seen := make(map[int64]struct{})
for _, row := range rows {
amount += s.calculateEffectiveAmount(row)
if _, ok := seen[row.OrderID]; !ok {
seen[row.OrderID] = struct{}{}
count++
}
}
return count, amount
}
func (s *service) countInvites(ctx context.Context, inviterID int64, activityID int64, start, end *time.Time) (int64, error) {
db := s.repo.GetDbR().WithContext(ctx)
var count int64
if activityID > 0 {
query := `
SELECT COUNT(DISTINCT ui.invitee_id)
FROM user_invites ui
INNER JOIN orders o ON o.user_id = ui.invitee_id AND o.status = 2 AND o.source_type != 1
INNER JOIN activity_draw_logs dl ON dl.order_id = o.id
INNER JOIN activity_issues ai ON ai.id = dl.issue_id
WHERE ui.inviter_id = ? AND ai.activity_id = ?
`
args := []interface{}{inviterID, activityID}
if start != nil {
query += " AND o.created_at >= ?"
args = append(args, *start)
}
if end != nil {
query += " AND o.created_at <= ?"
args = append(args, *end)
}
if err := db.Raw(query, args...).Scan(&count).Error; err != nil {
return 0, err
}
return count, nil
}
query := db.Model(&model.UserInvites{}).Where("inviter_id = ?", inviterID)
if start != nil {
query = query.Where("created_at >= ?", *start)
}
if end != nil {
query = query.Where("created_at <= ?", *end)
}
if err := query.Count(&count).Error; err != nil {
return 0, err
}
return count, nil
}
func (s *service) countInvitesForActivities(ctx context.Context, inviterID int64, activityIDs []int64) (int64, error) {
db := s.repo.GetDbR().WithContext(ctx)
var count int64
if len(activityIDs) == 0 {
if err := db.Model(&model.UserInvites{}).Where("inviter_id = ?", inviterID).Count(&count).Error; err != nil {
return 0, err
}
return count, nil
}
if err := db.Raw(`
SELECT COUNT(DISTINCT ui.invitee_id)
FROM user_invites ui
INNER JOIN orders o ON o.user_id = ui.invitee_id AND o.status = 2 AND o.source_type != 1
INNER JOIN activity_draw_logs dl ON dl.order_id = o.id
INNER JOIN activity_issues ai ON ai.id = dl.issue_id
WHERE ui.inviter_id = ? AND ai.activity_id IN (?)
`, inviterID, activityIDs).Scan(&count).Error; err != nil {
return 0, err
}
return count, nil
}
func (s *service) ListTasks(ctx context.Context, in ListTasksInput) (items []TaskItem, total int64, err error) { func (s *service) ListTasks(ctx context.Context, in ListTasksInput) (items []TaskItem, total int64, err error) {
db := s.repo.GetDbR() db := s.repo.GetDbR()
var rows []tcmodel.Task var rows []tcmodel.Task
@ -295,7 +461,7 @@ func (s *service) ListTasks(ctx context.Context, in ListTasksInput) (items []Tas
remaining = 0 remaining = 0
} }
} }
out[i].Tiers[j] = TaskTierItem{ID: t.ID, Metric: t.Metric, Operator: t.Operator, Threshold: t.Threshold, Window: t.Window, Repeatable: t.Repeatable, Priority: t.Priority, ActivityID: t.ActivityID, ExtraParams: t.ExtraParams, Quota: t.Quota, ClaimedCount: t.ClaimedCount, Remaining: remaining} out[i].Tiers[j] = TaskTierItem{ID: t.ID, Metric: t.Metric, Operator: t.Operator, Threshold: t.Threshold, Window: normalizeWindow(t.Window), Repeatable: t.Repeatable, Priority: t.Priority, ActivityID: t.ActivityID, ExtraParams: t.ExtraParams, Quota: t.Quota, ClaimedCount: t.ClaimedCount, Remaining: remaining}
} }
// 填充 Rewards // 填充 Rewards
out[i].Rewards = make([]TaskRewardItem, len(v.Rewards)) out[i].Rewards = make([]TaskRewardItem, len(v.Rewards))
@ -362,17 +528,16 @@ func computeTimeWindow(window string, taskStart, taskEnd *time.Time) (start *tim
func (s *service) GetUserProgress(ctx context.Context, userID int64, taskID int64) (*UserProgress, error) { func (s *service) GetUserProgress(ctx context.Context, userID int64, taskID int64) (*UserProgress, error) {
db := s.repo.GetDbR() db := s.repo.GetDbR()
// 加载任务信息(获取 StartTime/EndTime 用于 activity_period window
var task tcmodel.Task var task tcmodel.Task
if err := db.First(&task, taskID).Error; err != nil { if err := db.First(&task, taskID).Error; err != nil {
return nil, err return nil, err
} }
// 3.0 获取任务下所有 Tier含 Window、ActivityID、Metric 字段,用于时效分组查询)
var tiers []tcmodel.TaskTier var tiers []tcmodel.TaskTier
db.Where("task_id = ?", taskID).Find(&tiers) if err := db.Where("task_id = ?", taskID).Find(&tiers).Error; err != nil {
return nil, err
}
// 提取所有 activityID用于向后兼容的全局统计和 SubProgress
targetActivityIDs := make([]int64, 0) targetActivityIDs := make([]int64, 0)
seenActivity := make(map[int64]struct{}) seenActivity := make(map[int64]struct{})
for _, t := range tiers { for _, t := range tiers {
@ -384,201 +549,95 @@ func (s *service) GetUserProgress(ctx context.Context, userID int64, taskID int6
} }
} }
// ── Bug1 修复:按 (window, activityID) 分组,每组带时效过滤查一次,填充 TierProgressMap ──
type windowGroupKey struct { type windowGroupKey struct {
Window string Window string
ActivityID int64 ActivityID int64
} }
groupMap := make(map[windowGroupKey][]tcmodel.TaskTier) groupMap := make(map[windowGroupKey][]tcmodel.TaskTier)
for _, t := range tiers { for _, t := range tiers {
key := windowGroupKey{Window: t.Window, ActivityID: t.ActivityID} window := normalizeWindow(t.Window)
t.Window = window
key := windowGroupKey{Window: window, ActivityID: t.ActivityID}
groupMap[key] = append(groupMap[key], t) groupMap[key] = append(groupMap[key], t)
} }
tierProgressMap := make(map[int64]TierProgress) tierProgressMap := make(map[int64]TierProgress)
for wk, groupTiers := range groupMap { for wk, groupTiers := range groupMap {
wStart, wEnd := computeTimeWindow(wk.Window, task.StartTime, task.EndTime) wStart, wEnd := computeTimeWindow(wk.Window, task.StartTime, task.EndTime)
var activityIDs []int64
// 构建动态时间条件片段 perActivity := false
var timeCond string
var timeArgs []interface{}
if wStart != nil {
timeCond += " AND orders.created_at >= ?"
timeArgs = append(timeArgs, *wStart)
}
if wEnd != nil {
timeCond += " AND orders.created_at <= ?"
timeArgs = append(timeArgs, *wEnd)
}
var gOrderCount, gOrderAmount, gInviteCount int64
if wk.ActivityID > 0 { if wk.ActivityID > 0 {
// 有活动限制:通过 activity_draw_logs → activity_issues 关联,加时效过滤 activityIDs = []int64{wk.ActivityID}
baseArgs := append([]interface{}{userID, wk.ActivityID}, timeArgs...) perActivity = true
db.Raw(` }
SELECT COUNT(id) rows, err := s.fetchOrderMetricRows(ctx, userID, activityIDs, wStart, wEnd)
FROM orders if err != nil {
WHERE user_id = ? AND status = 2 AND source_type != 1 return nil, err
AND id IN ( }
SELECT DISTINCT dl.order_id orderCount, orderAmount := s.aggregateOrderMetrics(rows, perActivity)
FROM activity_draw_logs dl inviteCount, err := s.countInvites(ctx, userID, wk.ActivityID, wStart, wEnd)
INNER JOIN activity_issues ai ON ai.id = dl.issue_id if err != nil {
WHERE ai.activity_id = ? return nil, err
)`+timeCond, baseArgs...).Scan(&gOrderCount)
db.Raw(`
SELECT COALESCE(SUM(total_amount), 0)
FROM orders
WHERE user_id = ? AND status = 2 AND source_type != 1
AND id IN (
SELECT DISTINCT dl.order_id
FROM activity_draw_logs dl
INNER JOIN activity_issues ai ON ai.id = dl.issue_id
WHERE ai.activity_id = ?
)`+timeCond, baseArgs...).Scan(&gOrderAmount)
// 邀请计数:将 orders.created_at 改为 o.created_at别名
inviteTimeCond := strings.ReplaceAll(timeCond, "orders.created_at", "o.created_at")
inviteArgs := append([]interface{}{userID, wk.ActivityID}, timeArgs...)
db.Raw(`
SELECT COUNT(DISTINCT ui.invitee_id)
FROM user_invites ui
INNER JOIN orders o ON o.user_id = ui.invitee_id AND o.status = 2 AND o.source_type != 1
WHERE ui.inviter_id = ?
AND o.id IN (
SELECT DISTINCT dl.order_id
FROM activity_draw_logs dl
INNER JOIN activity_issues ai ON ai.id = dl.issue_id
WHERE ai.activity_id = ?
)`+inviteTimeCond, inviteArgs...).Scan(&gInviteCount)
} else {
// 无活动限制:统计所有已开奖的非商城订单,追加时效过滤
globalCond := "user_id = ? AND status = 2 AND source_type != 1 AND EXISTS (SELECT 1 FROM activity_draw_logs WHERE activity_draw_logs.order_id = orders.id)" + timeCond
globalArgs := append([]interface{}{userID}, timeArgs...)
db.Model(&model.Orders{}).Where(globalCond, globalArgs...).Count(&gOrderCount)
db.Model(&model.Orders{}).Select("COALESCE(SUM(total_amount), 0)").Where(globalCond, globalArgs...).Scan(&gOrderAmount)
inviteWhere := "inviter_id = ?"
if wStart != nil {
inviteWhere += " AND created_at >= ?"
}
if wEnd != nil {
inviteWhere += " AND created_at <= ?"
}
db.Model(&model.UserInvites{}).Where(inviteWhere, globalArgs...).Count(&gInviteCount)
} }
for _, t := range groupTiers { for _, tier := range groupTiers {
tierProgressMap[t.ID] = TierProgress{ tierProgressMap[tier.ID] = TierProgress{
TierID: t.ID, TierID: tier.ID,
OrderCount: gOrderCount, OrderCount: orderCount,
OrderAmount: gOrderAmount, OrderAmount: orderAmount,
InviteCount: gInviteCount, InviteCount: inviteCount,
FirstOrder: gOrderCount > 0, FirstOrder: orderCount > 0,
} }
} }
} }
// ── 向后兼容:全局统计(不限时间窗口,用于顶层字段 OrderCount/InviteCount 和 SubProgress── var (
var orderCount int64 allRows []orderMetricRow
var orderAmount int64 err error
)
if len(targetActivityIDs) > 0 {
allRows, err = s.fetchOrderMetricRows(ctx, userID, targetActivityIDs, nil, nil)
} else {
allRows, err = s.fetchOrderMetricRows(ctx, userID, nil, nil, nil)
}
if err != nil {
return nil, err
}
orderCount, orderAmount := s.aggregateOrderMetrics(allRows, false)
var subProgressList []ActivityProgress var subProgressList []ActivityProgress
if len(targetActivityIDs) > 0 { if len(targetActivityIDs) > 0 {
db.Raw(` subStats := make(map[int64]ActivityProgress)
SELECT COUNT(id) for _, row := range allRows {
FROM orders if row.ActivityID == 0 {
WHERE user_id = ? AND status = 2 AND source_type != 1 continue
AND id IN ( }
SELECT DISTINCT dl.order_id stat := subStats[row.ActivityID]
FROM activity_draw_logs dl stat.ActivityID = row.ActivityID
INNER JOIN activity_issues ai ON ai.id = dl.issue_id stat.OrderCount++
WHERE ai.activity_id IN (?) stat.OrderAmount += s.calculateEffectiveAmount(row)
) subStats[row.ActivityID] = stat
`, userID, targetActivityIDs).Scan(&orderCount) }
subProgressList = make([]ActivityProgress, 0, len(targetActivityIDs))
db.Raw(` for _, actID := range targetActivityIDs {
SELECT COALESCE(SUM(total_amount), 0) if stat, ok := subStats[actID]; ok {
FROM orders subProgressList = append(subProgressList, stat)
WHERE user_id = ? AND status = 2 AND source_type != 1 }
AND id IN ( }
SELECT DISTINCT dl.order_id
FROM activity_draw_logs dl
INNER JOIN activity_issues ai ON ai.id = dl.issue_id
WHERE ai.activity_id IN (?)
)
`, userID, targetActivityIDs).Scan(&orderAmount)
} else {
query := db.Model(&model.Orders{}).Where("user_id = ? AND status = 2 AND source_type != 1", userID)
query.Where("EXISTS (SELECT 1 FROM activity_draw_logs WHERE activity_draw_logs.order_id = orders.id)")
query.Count(&orderCount)
queryAmount := db.Model(&model.Orders{}).Where("user_id = ? AND status = 2 AND source_type != 1", userID)
queryAmount.Where("EXISTS (SELECT 1 FROM activity_draw_logs WHERE activity_draw_logs.order_id = orders.id)")
queryAmount.Select("COALESCE(SUM(total_amount), 0)").Scan(&orderAmount)
} }
// 2. 实时统计邀请数据(全局,向后兼容) inviteCount, err := s.countInvitesForActivities(ctx, userID, targetActivityIDs)
var inviteCount int64 if err != nil {
if len(targetActivityIDs) > 0 { return nil, err
db.Raw(`
SELECT COUNT(DISTINCT ui.invitee_id)
FROM user_invites ui
INNER JOIN orders o ON o.user_id = ui.invitee_id AND o.status = 2 AND o.source_type != 1
WHERE ui.inviter_id = ?
AND o.id IN (
SELECT DISTINCT dl.order_id
FROM activity_draw_logs dl
INNER JOIN activity_issues ai ON ai.id = dl.issue_id
WHERE ai.activity_id IN (?)
)
`, userID, targetActivityIDs).Scan(&inviteCount)
// SubProgress各活动独立进度向后兼容不限时间窗口
var subStats []struct {
ActivityID int64
OrderCount int64
OrderAmount int64
}
db.Raw(`
SELECT
sub.activity_id,
COUNT(sub.id) as order_count,
COALESCE(SUM(sub.total_amount), 0) as order_amount
FROM (
SELECT DISTINCT ai.activity_id, o.id, o.total_amount
FROM orders o
INNER JOIN activity_draw_logs dl ON dl.order_id = o.id
INNER JOIN activity_issues ai ON ai.id = dl.issue_id
WHERE o.user_id = ? AND o.status = 2 AND o.source_type != 1
AND ai.activity_id IN (?)
) sub
GROUP BY sub.activity_id
`, userID, targetActivityIDs).Scan(&subStats)
subProgressList = make([]ActivityProgress, 0, len(subStats))
for _, sp := range subStats {
subProgressList = append(subProgressList, ActivityProgress{
ActivityID: sp.ActivityID,
OrderCount: sp.OrderCount,
OrderAmount: sp.OrderAmount,
})
}
} else {
db.Model(&model.UserInvites{}).Where("inviter_id = ?", userID).Count(&inviteCount)
} }
// 3. 首单判断 var progressRows []tcmodel.UserTaskProgress
hasFirstOrder := orderCount > 0 if err := db.Where("user_id=? AND task_id=?", userID, taskID).Find(&progressRows).Error; err != nil {
return nil, err
// 4. 从进度表读取已领取的档位(这部分仍需保留) }
var rows []tcmodel.UserTaskProgress
db.Where("user_id=? AND task_id=?", userID, taskID).Find(&rows)
claimedSet := map[int64]struct{}{} claimedSet := map[int64]struct{}{}
for _, row := range rows { for _, row := range progressRows {
var claimed []int64 var claimed []int64
if len(row.ClaimedTiers) > 0 { if len(row.ClaimedTiers) > 0 {
_ = json.Unmarshal([]byte(row.ClaimedTiers), &claimed) _ = json.Unmarshal([]byte(row.ClaimedTiers), &claimed)
@ -593,6 +652,8 @@ func (s *service) GetUserProgress(ctx context.Context, userID int64, taskID int6
allClaimed = append(allClaimed, id) allClaimed = append(allClaimed, id)
} }
hasFirstOrder := orderCount > 0
return &UserProgress{ return &UserProgress{
TaskID: taskID, TaskID: taskID,
UserID: userID, UserID: userID,
@ -602,7 +663,7 @@ func (s *service) GetUserProgress(ctx context.Context, userID int64, taskID int6
FirstOrder: hasFirstOrder, FirstOrder: hasFirstOrder,
ClaimedTiers: allClaimed, ClaimedTiers: allClaimed,
SubProgress: subProgressList, SubProgress: subProgressList,
TierProgressMap: tierProgressMap, // Bug1 修复:每个 Tier 的窗口化独立进度 TierProgressMap: tierProgressMap,
}, nil }, nil
} }
@ -899,7 +960,7 @@ func (s *service) ListTaskTiers(ctx context.Context, taskID int64) ([]TaskTierIt
remaining = 0 remaining = 0
} }
} }
out[i] = TaskTierItem{ID: v.ID, Metric: v.Metric, Operator: v.Operator, Threshold: v.Threshold, Window: v.Window, Repeatable: v.Repeatable, Priority: v.Priority, ActivityID: v.ActivityID, ExtraParams: v.ExtraParams, Quota: v.Quota, ClaimedCount: v.ClaimedCount, Remaining: remaining} out[i] = TaskTierItem{ID: v.ID, Metric: v.Metric, Operator: v.Operator, Threshold: v.Threshold, Window: normalizeWindow(v.Window), Repeatable: v.Repeatable, Priority: v.Priority, ActivityID: v.ActivityID, ExtraParams: v.ExtraParams, Quota: v.Quota, ClaimedCount: v.ClaimedCount, Remaining: remaining}
} }
return out, nil return out, nil
} }
@ -914,8 +975,9 @@ func (s *service) UpsertTaskTiers(ctx context.Context, taskID int64, tiers []Tas
existingMap := make(map[string]tcmodel.TaskTier) existingMap := make(map[string]tcmodel.TaskTier)
for _, t := range existing { for _, t := range existing {
// 使用指标+阈值+活动作为业务指纹 window := normalizeWindow(t.Window)
key := fmt.Sprintf("%s-%d-%d", t.Metric, t.Threshold, t.ActivityID) t.Window = window
key := tierFingerprint(t.Metric, t.Threshold, t.ActivityID, window)
existingMap[key] = t existingMap[key] = t
} }
@ -925,11 +987,15 @@ func (s *service) UpsertTaskTiers(ctx context.Context, taskID int64, tiers []Tas
processedKeys := make(map[string]struct{}) processedKeys := make(map[string]struct{})
for _, t := range tiers { for _, t := range tiers {
key := fmt.Sprintf("%s-%d-%d", t.Metric, t.Threshold, t.ActivityID) window, err := normalizeWindowStrict(t.Window)
if err != nil {
return err
}
key := tierFingerprint(t.Metric, t.Threshold, t.ActivityID, window)
if old, ok := existingMap[key]; ok { if old, ok := existingMap[key]; ok {
// 更新现有记录,保留 ID 和 ClaimedCount // 更新现有记录,保留 ID 和 ClaimedCount
old.Operator = t.Operator old.Operator = t.Operator
old.Window = t.Window old.Window = window
old.Repeatable = t.Repeatable old.Repeatable = t.Repeatable
old.Priority = t.Priority old.Priority = t.Priority
old.ExtraParams = t.ExtraParams old.ExtraParams = t.ExtraParams
@ -942,7 +1008,7 @@ func (s *service) UpsertTaskTiers(ctx context.Context, taskID int64, tiers []Tas
Metric: t.Metric, Metric: t.Metric,
Operator: t.Operator, Operator: t.Operator,
Threshold: t.Threshold, Threshold: t.Threshold,
Window: t.Window, Window: window,
Repeatable: t.Repeatable, Repeatable: t.Repeatable,
Priority: t.Priority, Priority: t.Priority,
ActivityID: t.ActivityID, ActivityID: t.ActivityID,
@ -990,47 +1056,59 @@ func (s *service) ListTaskRewards(ctx context.Context, taskID int64) ([]TaskRewa
return out, nil return out, nil
} }
func (s *service) UpsertTaskRewards(ctx context.Context, taskID int64, rewards []TaskRewardInput) error { func (s *service) UpsertTaskRewards(ctx context.Context, taskID int64, rewards []TaskRewardInput, deleteIDs []int64) error {
db := s.repo.GetDbW() db := s.repo.GetDbW()
// 同理优化 ID 稳定性
var existing []tcmodel.TaskReward var existing []tcmodel.TaskReward
if err := db.Where("task_id=?", taskID).Find(&existing).Error; err != nil { if err := db.Where("task_id=?", taskID).Find(&existing).Error; err != nil {
return err return err
} }
existingMap := make(map[string]tcmodel.TaskReward) existingByID := make(map[int64]tcmodel.TaskReward, len(existing))
for _, r := range existing { for _, r := range existing {
// 奖励类型+档位 ID 作为指纹 existingByID[r.ID] = r
key := fmt.Sprintf("%d-%s", r.TierID, r.RewardType)
existingMap[key] = r
} }
var toDelete []int64
var toUpdate []tcmodel.TaskReward var toUpdate []tcmodel.TaskReward
var toCreate []tcmodel.TaskReward var toCreate []tcmodel.TaskReward
seen := make(map[int64]struct{})
processedKeys := make(map[string]struct{})
for _, r := range rewards { for _, r := range rewards {
key := fmt.Sprintf("%d-%s", r.TierID, r.RewardType) if r.ID > 0 {
if old, ok := existingMap[key]; ok { old, ok := existingByID[r.ID]
if !ok || old.TaskID != taskID {
return fmt.Errorf("reward %d not found", r.ID)
}
old.TierID = r.TierID
old.RewardType = r.RewardType
old.RewardPayload = r.RewardPayload old.RewardPayload = r.RewardPayload
old.Quantity = r.Quantity old.Quantity = r.Quantity
toUpdate = append(toUpdate, old) toUpdate = append(toUpdate, old)
processedKeys[key] = struct{}{} seen[r.ID] = struct{}{}
} else { continue
toCreate = append(toCreate, tcmodel.TaskReward{
TaskID: taskID,
TierID: r.TierID,
RewardType: r.RewardType,
RewardPayload: r.RewardPayload,
Quantity: r.Quantity,
})
} }
toCreate = append(toCreate, tcmodel.TaskReward{
TaskID: taskID,
TierID: r.TierID,
RewardType: r.RewardType,
RewardPayload: r.RewardPayload,
Quantity: r.Quantity,
})
} }
for key, old := range existingMap { var toDelete []int64
if _, ok := processedKeys[key]; !ok { if len(deleteIDs) > 0 {
toDelete = append(toDelete, old.ID) for _, id := range deleteIDs {
if reward, ok := existingByID[id]; ok {
toDelete = append(toDelete, reward.ID)
}
}
} else {
for id := range existingByID {
if _, ok := seen[id]; !ok {
toDelete = append(toDelete, id)
}
} }
} }

View File

@ -2,12 +2,14 @@ package taskcenter
import ( import (
"context" "context"
"encoding/json"
"testing" "testing"
"time" "time"
"bindbox-game/internal/repository/mysql" "bindbox-game/internal/repository/mysql"
tcmodel "bindbox-game/internal/repository/mysql/task_center" tcmodel "bindbox-game/internal/repository/mysql/task_center"
"gorm.io/datatypes"
"gorm.io/gorm" "gorm.io/gorm"
) )
@ -37,6 +39,22 @@ func ensureExtraTablesForServiceTest(t *testing.T, db *gorm.DB) {
t.Fatalf("创建 activity_draw_logs 表失败: %v", err) t.Fatalf("创建 activity_draw_logs 表失败: %v", err)
} }
} }
if !db.Migrator().HasTable("activity_issues") {
if err := db.Exec(`CREATE TABLE activity_issues (
id INTEGER PRIMARY KEY AUTOINCREMENT,
activity_id INTEGER NOT NULL
);`).Error; err != nil {
t.Fatalf("创建 activity_issues 表失败: %v", err)
}
}
if !db.Migrator().HasTable("activities") {
if err := db.Exec(`CREATE TABLE activities (
id INTEGER PRIMARY KEY AUTOINCREMENT,
price_draw INTEGER NOT NULL DEFAULT 0
);`).Error; err != nil {
t.Fatalf("创建 activities 表失败: %v", err)
}
}
if !db.Migrator().HasTable("user_invites") { if !db.Migrator().HasTable("user_invites") {
if err := db.Exec(`CREATE TABLE user_invites ( if err := db.Exec(`CREATE TABLE user_invites (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
@ -81,6 +99,9 @@ func TestGetUserProgress_TimeWindow_Integration(t *testing.T) {
t.Fatalf("创建任务失败: %v", err) t.Fatalf("创建任务失败: %v", err)
} }
db.Exec("INSERT INTO activities (id, price_draw) VALUES (1, 100)")
db.Exec("INSERT INTO activity_issues (id, activity_id) VALUES (1, 1)")
windows := []string{WindowDaily, WindowWeekly, WindowMonthly, WindowActivityPeriod, WindowLifetime} windows := []string{WindowDaily, WindowWeekly, WindowMonthly, WindowActivityPeriod, WindowLifetime}
tierIDMap := make(map[string]int64) tierIDMap := make(map[string]int64)
@ -154,3 +175,228 @@ func TestGetUserProgress_TimeWindow_Integration(t *testing.T) {
} }
} }
} }
func TestUpsertTaskRewards_AllowsMultipleRewardsSameType(t *testing.T) {
repo, err := mysql.NewSQLiteRepoForTest()
if err != nil {
t.Fatalf("创建 repo 失败: %v", err)
}
db := repo.GetDbW()
initTestTables(t, db)
svc := New(nil, repo, nil, nil, nil)
task := &tcmodel.Task{Name: "奖励重入", Description: "测试奖励更新", Status: 1, Visibility: 1}
if err := db.Create(task).Error; err != nil {
t.Fatalf("创建任务失败: %v", err)
}
tier := &tcmodel.TaskTier{
TaskID: task.ID,
Metric: MetricOrderCount,
Operator: OperatorGTE,
Threshold: 1,
Window: WindowLifetime,
}
if err := db.Create(tier).Error; err != nil {
t.Fatalf("创建档位失败: %v", err)
}
initialRewards := []TaskRewardInput{
{TierID: tier.ID, RewardType: RewardTypeCoupon, RewardPayload: datatypes.JSON([]byte(`{"coupon_id":1,"quantity":1}`)), Quantity: 1},
{TierID: tier.ID, RewardType: RewardTypeCoupon, RewardPayload: datatypes.JSON([]byte(`{"coupon_id":2,"quantity":1}`)), Quantity: 2},
}
if err := svc.UpsertTaskRewards(context.Background(), task.ID, initialRewards, nil); err != nil {
t.Fatalf("首次保存奖励失败: %v", err)
}
var stored []tcmodel.TaskReward
if err := db.Where("task_id = ?", task.ID).Order("id asc").Find(&stored).Error; err != nil {
t.Fatalf("查询奖励失败: %v", err)
}
if len(stored) != 2 {
t.Fatalf("奖励数量不正确, 期望 2 实际 %d", len(stored))
}
updatePayload := datatypes.JSON([]byte(`{"coupon_id":99,"quantity":3}`))
secondPayload := datatypes.JSON([]byte(`{"coupon_id":200,"quantity":1}`))
updateInput := []TaskRewardInput{
{ID: stored[0].ID, TierID: tier.ID, RewardType: RewardTypeCoupon, RewardPayload: updatePayload, Quantity: 5},
{TierID: tier.ID, RewardType: RewardTypeCoupon, RewardPayload: secondPayload, Quantity: 1},
}
if err := svc.UpsertTaskRewards(context.Background(), task.ID, updateInput, []int64{stored[1].ID}); err != nil {
t.Fatalf("更新奖励失败: %v", err)
}
var refreshed []tcmodel.TaskReward
if err := db.Where("task_id = ?", task.ID).Order("id asc").Find(&refreshed).Error; err != nil {
t.Fatalf("查询更新后奖励失败: %v", err)
}
if len(refreshed) != 2 {
t.Fatalf("更新后奖励数量不正确, 期望 2 实际 %d", len(refreshed))
}
if refreshed[0].ID != stored[0].ID {
t.Fatalf("原有奖励记录未被更新")
}
var pl map[string]int64
if err := json.Unmarshal(refreshed[0].RewardPayload, &pl); err != nil {
t.Fatalf("解析奖励 payload 失败: %v", err)
}
if pl["coupon_id"] != 99 {
t.Errorf("奖励 payload 未更新, 期望 99 实际 %d", pl["coupon_id"])
}
if refreshed[0].Quantity != 5 {
t.Errorf("奖励数量未更新, 期望 5 实际 %d", refreshed[0].Quantity)
}
for _, r := range refreshed {
if r.ID == stored[1].ID {
t.Fatalf("待删除的奖励仍存在, id=%d", r.ID)
}
}
}
func TestGetUserProgress_UsesEffectiveAmount(t *testing.T) {
repo, err := mysql.NewSQLiteRepoForTest()
if err != nil {
t.Fatalf("创建 repo 失败: %v", err)
}
db := repo.GetDbW()
initTestTables(t, db)
ensureExtraTablesForServiceTest(t, db)
svc := New(nil, repo, nil, nil, nil)
task := &tcmodel.Task{Name: "真实消费口径", Status: 1, Visibility: 1}
if err := db.Create(task).Error; err != nil {
t.Fatalf("创建任务失败: %v", err)
}
tier := &tcmodel.TaskTier{
TaskID: task.ID,
Metric: MetricOrderAmount,
Operator: OperatorGTE,
Threshold: 1,
Window: WindowLifetime,
ActivityID: 201,
}
if err := db.Create(tier).Error; err != nil {
t.Fatalf("创建档位失败: %v", err)
}
secondaryTier := &tcmodel.TaskTier{
TaskID: task.ID,
Metric: MetricOrderAmount,
Operator: OperatorGTE,
Threshold: 1,
Window: WindowLifetime,
ActivityID: 202,
}
if err := db.Create(secondaryTier).Error; err != nil {
t.Fatalf("创建第二个档位失败: %v", err)
}
db.Exec("INSERT INTO activities (id, price_draw) VALUES (201, 1000)")
db.Exec("INSERT INTO activities (id, price_draw) VALUES (202, 0)")
db.Exec("INSERT INTO activity_issues (id, activity_id) VALUES (301, 201)")
db.Exec("INSERT INTO activity_issues (id, activity_id) VALUES (302, 202)")
userID := int64(6001)
now := time.Now()
inside := now.Format(time.DateTime)
// 次卡订单total_amount=0但 price_draw>0, draw_count=2
db.Exec("INSERT INTO orders (id, user_id, status, source_type, total_amount, created_at) VALUES (401, ?, 2, 0, 0, ?)", userID, inside)
db.Exec("INSERT INTO activity_draw_logs (order_id, issue_id) VALUES (401, 301)")
db.Exec("INSERT INTO activity_draw_logs (order_id, issue_id) VALUES (401, 301)")
// 现金订单price_draw=0需回退 total_amount
db.Exec("INSERT INTO orders (id, user_id, status, source_type, total_amount, created_at) VALUES (402, ?, 2, 0, 1500, ?)", userID, inside)
db.Exec("INSERT INTO activity_draw_logs (order_id, issue_id) VALUES (402, 302)")
progress, err := svc.GetUserProgress(context.Background(), userID, task.ID)
if err != nil {
t.Fatalf("获取进度失败: %v", err)
}
if progress.OrderAmount != 3500 {
t.Fatalf("订单金额统计错误,期望 3500 实际 %d", progress.OrderAmount)
}
if progress.OrderCount != 2 {
t.Fatalf("订单数量统计错误,期望 2 实际 %d", progress.OrderCount)
}
tierProgress, ok := progress.TierProgressMap[tier.ID]
if !ok {
t.Fatalf("未找到档位进度")
}
if tierProgress.OrderAmount != 2000 {
t.Fatalf("档位金额错误,期望 2000 实际 %d", tierProgress.OrderAmount)
}
if tierProgress.OrderCount != 1 {
t.Fatalf("档位订单数错误,期望 1 实际 %d", tierProgress.OrderCount)
}
}
func TestTimeWindow_ActivityPeriod(t *testing.T) {
repo, err := mysql.NewSQLiteRepoForTest()
if err != nil {
t.Fatalf("创建 repo 失败: %v", err)
}
db := repo.GetDbW()
initTestTables(t, db)
ensureExtraTablesForServiceTest(t, db)
svc := New(nil, repo, nil, nil, nil)
start := time.Now().AddDate(0, -1, 0)
end := start.AddDate(0, 0, 10)
task := &tcmodel.Task{
Name: "任务窗口期",
Status: 1,
Visibility: 1,
StartTime: &start,
EndTime: &end,
}
if err := db.Create(task).Error; err != nil {
t.Fatalf("创建任务失败: %v", err)
}
tier := &tcmodel.TaskTier{
TaskID: task.ID,
Metric: MetricOrderCount,
Operator: OperatorGTE,
Threshold: 1,
Window: WindowActivityPeriod,
ActivityID: 501,
}
if err := db.Create(tier).Error; err != nil {
t.Fatalf("创建档位失败: %v", err)
}
db.Exec("INSERT INTO activities (id, price_draw) VALUES (501, 500)")
db.Exec("INSERT INTO activity_issues (id, activity_id) VALUES (601, 501)")
userID := int64(7007)
inside := start.Add(24 * time.Hour).Format(time.DateTime)
outside := end.Add(24 * time.Hour).Format(time.DateTime)
db.Exec("INSERT INTO orders (id, user_id, status, source_type, total_amount, created_at) VALUES (701, ?, 2, 0, 0, ?)", userID, inside)
db.Exec("INSERT INTO activity_draw_logs (order_id, issue_id) VALUES (701, 601)")
db.Exec("INSERT INTO orders (id, user_id, status, source_type, total_amount, created_at) VALUES (702, ?, 2, 0, 0, ?)", userID, outside)
db.Exec("INSERT INTO activity_draw_logs (order_id, issue_id) VALUES (702, 601)")
progress, err := svc.GetUserProgress(context.Background(), userID, task.ID)
if err != nil {
t.Fatalf("获取进度失败: %v", err)
}
tierProgress, ok := progress.TierProgressMap[tier.ID]
if !ok {
t.Fatalf("未找到活动有效期档位进度")
}
if tierProgress.OrderCount != 1 {
t.Fatalf("活动有效期窗口统计错误,期望 1 实际 %d", tierProgress.OrderCount)
}
if progress.OrderCount != 2 {
t.Fatalf("总体订单统计错误,期望 2 实际 %d", progress.OrderCount)
}
}

View File

@ -162,6 +162,20 @@ func initTestTables(t *testing.T, db *gorm.DB) {
);`).Error; err != nil { );`).Error; err != nil {
t.Fatalf("创建 task_center_event_logs 表失败: %v", err) t.Fatalf("创建 task_center_event_logs 表失败: %v", err)
} }
if err := db.Exec(`CREATE TABLE activities (
id INTEGER PRIMARY KEY AUTOINCREMENT,
price_draw INTEGER NOT NULL DEFAULT 0
);`).Error; err != nil {
t.Fatalf("创建 activities 表失败: %v", err)
}
if err := db.Exec(`CREATE TABLE activity_issues (
id INTEGER PRIMARY KEY AUTOINCREMENT,
activity_id INTEGER NOT NULL
);`).Error; err != nil {
t.Fatalf("创建 activity_issues 表失败: %v", err)
}
} }
// InsertTaskWithTierAndReward 插入一个完整的任务配置(任务+档位+奖励) // InsertTaskWithTierAndReward 插入一个完整的任务配置(任务+档位+奖励)

View File

@ -500,9 +500,20 @@ func (s *service) RedeemInventoryToPoints(ctx context.Context, userID int64, inv
if err != nil { if err != nil {
return 0, err return 0, err
} }
p, err := s.readDB.Products.WithContext(ctx).Where(s.readDB.Products.ID.Eq(inv.ProductID)).First() valueCents := inv.ValueCents
if err != nil { valueSource := inv.ValueSource
return 0, err valueSnapshotAt := inv.ValueSnapshotAt
if valueCents <= 0 {
p, err := s.readDB.Products.WithContext(ctx).Where(s.readDB.Products.ID.Eq(inv.ProductID)).First()
if err != nil {
return 0, err
}
valueCents = p.Price
valueSource = 2
valueSnapshotAt = time.Now()
if db := s.repo.GetDbW().Exec("UPDATE user_inventory SET value_cents=?, value_source=?, value_snapshot_at=? WHERE id=? AND user_id=?", valueCents, valueSource, valueSnapshotAt, inventoryID, userID); db.Error != nil {
return 0, db.Error
}
} }
cfg, _ := s.readDB.SystemConfigs.WithContext(ctx).Where(s.readDB.SystemConfigs.ConfigKey.Eq("points_exchange_per_cent")).First() cfg, _ := s.readDB.SystemConfigs.WithContext(ctx).Where(s.readDB.SystemConfigs.ConfigKey.Eq("points_exchange_per_cent")).First()
rate := int64(1) rate := int64(1)
@ -513,7 +524,7 @@ func (s *service) RedeemInventoryToPoints(ctx context.Context, userID int64, inv
rate = r rate = r
} }
} }
points := p.Price * rate points := valueCents * rate
if err = s.AddPoints(ctx, userID, points, "redeem_reward", fmt.Sprintf("inventory:%d product:%d", inventoryID, inv.ProductID), nil, nil); err != nil { if err = s.AddPoints(ctx, userID, points, "redeem_reward", fmt.Sprintf("inventory:%d product:%d", inventoryID, inv.ProductID), nil, nil); err != nil {
return 0, err return 0, err
} }
@ -569,39 +580,63 @@ func (s *service) RedeemInventoriesToPoints(ctx context.Context, userID int64, i
return 0, fmt.Errorf("no_valid_inventory") return 0, fmt.Errorf("no_valid_inventory")
} }
// 构建inventory映射和收集productID // 4. 按资产快照计算总积分,缺失快照时回退商品价格并回写
invMap := make(map[int64]*model.UserInventory, len(invList))
productIDs := make([]int64, 0, len(invList)) productIDs := make([]int64, 0, len(invList))
productIDSet := make(map[int64]struct{}) productIDSet := make(map[int64]struct{})
for _, inv := range invList { for _, inv := range invList {
invMap[inv.ID] = inv if inv.ValueCents <= 0 {
if _, ok := productIDSet[inv.ProductID]; !ok { if _, ok := productIDSet[inv.ProductID]; !ok {
productIDSet[inv.ProductID] = struct{}{} productIDSet[inv.ProductID] = struct{}{}
productIDs = append(productIDs, inv.ProductID) productIDs = append(productIDs, inv.ProductID)
}
}
}
productPriceMap := make(map[int64]int64)
if len(productIDs) > 0 {
products, err := s.readDB.Products.WithContext(ctx).
Where(s.readDB.Products.ID.In(productIDs...)).
Find()
if err != nil {
return 0, err
}
for _, p := range products {
productPriceMap[p.ID] = p.Price
} }
} }
// 4. 批量查询所有products一次查询替代N次 // 5. 计算总积分和准备批量更新
products, err := s.readDB.Products.WithContext(ctx).
Where(s.readDB.Products.ID.In(productIDs...)).
Find()
if err != nil {
return 0, err
}
productMap := make(map[int64]*model.Products, len(products))
for _, p := range products {
productMap[p.ID] = p
}
// 5. 计算总积分和准备批量更新数据
var totalPoints int64 var totalPoints int64
validIDs := make([]int64, 0, len(invList)) validIDs := make([]int64, 0, len(invList))
type valueFix struct {
ID int64
ValueCents int64
ValueSource int32
ValueSnapAt time.Time
}
valueFixes := make([]valueFix, 0)
for _, inv := range invList { for _, inv := range invList {
p := productMap[inv.ProductID] valueCents := inv.ValueCents
if p == nil { valueSource := inv.ValueSource
valueSnapshotAt := inv.ValueSnapshotAt
if valueCents <= 0 {
price, ok := productPriceMap[inv.ProductID]
if !ok {
continue
}
valueCents = price
valueSource = 2
valueSnapshotAt = time.Now()
valueFixes = append(valueFixes, valueFix{
ID: inv.ID,
ValueCents: valueCents,
ValueSource: valueSource,
ValueSnapAt: valueSnapshotAt,
})
}
if valueCents <= 0 {
continue continue
} }
points := p.Price * rate points := valueCents * rate
totalPoints += points totalPoints += points
validIDs = append(validIDs, inv.ID) validIDs = append(validIDs, inv.ID)
} }
@ -639,6 +674,14 @@ func (s *service) RedeemInventoriesToPoints(ctx context.Context, userID int64, i
} }
// 批量更新inventory状态一次UPDATE替代N次 // 批量更新inventory状态一次UPDATE替代N次
for _, fix := range valueFixes {
if err := tx.Exec(
"UPDATE user_inventory SET value_cents=?, value_source=?, value_snapshot_at=? WHERE id=? AND user_id=?",
fix.ValueCents, fix.ValueSource, fix.ValueSnapAt, fix.ID, userID,
).Error; err != nil {
return err
}
}
if err := tx.Exec( if err := tx.Exec(
"UPDATE user_inventory SET status=3, updated_at=NOW(3), remark=CONCAT(IFNULL(remark,''),'|batch_redeemed') WHERE id IN ? AND user_id=? AND status=1", "UPDATE user_inventory SET status=3, updated_at=NOW(3), remark=CONCAT(IFNULL(remark,''),'|batch_redeemed') WHERE id IN ? AND user_id=? AND status=1",
validIDs, userID, validIDs, userID,

View File

@ -91,10 +91,12 @@ func (s *service) ListInventoryWithProduct(ctx context.Context, userID int64, pa
p := products[r.ProductID] p := products[r.ProductID]
name := "" name := ""
images := "" images := ""
var price int64 price := r.ValueCents
if p != nil { if p != nil {
name = p.Name name = p.Name
images = p.ImagesJSON images = p.ImagesJSON
}
if price <= 0 && p != nil {
price = p.Price price = p.Price
} }
sh := shipMap[r.ID] sh := shipMap[r.ID]
@ -177,10 +179,12 @@ func (s *service) ListInventoryWithProductActive(ctx context.Context, userID int
p := products[r.ProductID] p := products[r.ProductID]
name := "" name := ""
images := "" images := ""
var price int64 price := r.ValueCents
if p != nil { if p != nil {
name = p.Name name = p.Name
images = p.ImagesJSON images = p.ImagesJSON
}
if price <= 0 && p != nil {
price = p.Price price = p.Price
} }
sh := shipMap[r.ID] sh := shipMap[r.ID]
@ -214,10 +218,11 @@ func (s *service) ListInventoryAggregated(ctx context.Context, userID int64, pag
// 1. 获取聚合后的商品ID列表 (GROUP BY product_id, status) // 1. 获取聚合后的商品ID列表 (GROUP BY product_id, status)
var groupResults []struct { var groupResults []struct {
ProductID int64 `gorm:"column:product_id"` ProductID int64 `gorm:"column:product_id"`
Status int32 `gorm:"column:status"` Status int32 `gorm:"column:status"`
Count int64 `gorm:"column:count"` Count int64 `gorm:"column:count"`
UpdatedAt time.Time `gorm:"column:updated_at"` ValueCents int64 `gorm:"column:value_cents"`
UpdatedAt time.Time `gorm:"column:updated_at"`
} }
q := s.readDB.UserInventory.WithContext(ctx).ReadDB(). q := s.readDB.UserInventory.WithContext(ctx).ReadDB().
@ -225,6 +230,7 @@ func (s *service) ListInventoryAggregated(ctx context.Context, userID int64, pag
s.readDB.UserInventory.ProductID, s.readDB.UserInventory.ProductID,
s.readDB.UserInventory.Status, s.readDB.UserInventory.Status,
s.readDB.UserInventory.ID.Count().As("count"), s.readDB.UserInventory.ID.Count().As("count"),
s.readDB.UserInventory.ValueCents.Max().As("value_cents"),
s.readDB.UserInventory.UpdatedAt.Max().As("updated_at"), s.readDB.UserInventory.UpdatedAt.Max().As("updated_at"),
). ).
Where(s.readDB.UserInventory.UserID.Eq(userID)) Where(s.readDB.UserInventory.UserID.Eq(userID))
@ -272,10 +278,12 @@ func (s *service) ListInventoryAggregated(ctx context.Context, userID int64, pag
p, _ := s.readDB.Products.WithContext(ctx).ReadDB().Where(s.readDB.Products.ID.Eq(g.ProductID)).First() p, _ := s.readDB.Products.WithContext(ctx).ReadDB().Where(s.readDB.Products.ID.Eq(g.ProductID)).First()
name := "未知商品" name := "未知商品"
images := "" images := ""
var price int64 price := g.ValueCents
if p != nil { if p != nil {
name = p.Name name = p.Name
images = p.ImagesJSON images = p.ImagesJSON
}
if price <= 0 && p != nil {
price = p.Price price = p.Price
} }

View File

@ -47,11 +47,13 @@ func (s *service) GrantReward(ctx context.Context, userID int64, req GrantReward
// 执行事务 // 执行事务
err := s.writeDB.Transaction(func(tx *dao.Query) error { err := s.writeDB.Transaction(func(tx *dao.Query) error {
logger.Info("开始事务处理") logger.Info("开始事务处理")
var rewardSetting *model.ActivityRewardSettings
var err error
// 1. 检查奖励配置库存如果提供了reward_id // 1. 检查奖励配置库存如果提供了reward_id
if req.RewardID != nil { if req.RewardID != nil {
logger.Info("检查奖励配置", zap.Int64("reward_id", *req.RewardID)) logger.Info("检查奖励配置", zap.Int64("reward_id", *req.RewardID))
rewardSetting, err := tx.ActivityRewardSettings.WithContext(ctx).Where( rewardSetting, err = tx.ActivityRewardSettings.WithContext(ctx).Where(
tx.ActivityRewardSettings.ID.Eq(*req.RewardID), tx.ActivityRewardSettings.ID.Eq(*req.RewardID),
).First() ).First()
if err != nil { if err != nil {
@ -109,7 +111,7 @@ func (s *service) GrantReward(ctx context.Context, userID int64, req GrantReward
} }
logger.Info("创建订单", zap.Any("order", order)) logger.Info("创建订单", zap.Any("order", order))
err := tx.Orders.WithContext(ctx).Create(order) err = tx.Orders.WithContext(ctx).Create(order)
if err != nil { if err != nil {
logger.Error("创建订单失败", zap.Error(err)) logger.Error("创建订单失败", zap.Error(err))
return fmt.Errorf("创建订单失败: %w", err) return fmt.Errorf("创建订单失败: %w", err)
@ -163,7 +165,25 @@ func (s *service) GrantReward(ctx context.Context, userID int64, req GrantReward
inventory := &model.UserInventory{ inventory := &model.UserInventory{
UserID: userID, UserID: userID,
ProductID: req.ProductID, ProductID: req.ProductID,
OrderID: orderID, ValueCents: func() int64 {
if rewardSetting != nil && rewardSetting.PriceSnapshotCents > 0 {
return rewardSetting.PriceSnapshotCents
}
return product.Price
}(),
ValueSource: func() int32 {
if rewardSetting != nil && rewardSetting.PriceSnapshotCents > 0 {
return 1
}
return 2
}(),
ValueSnapshotAt: func() time.Time {
if rewardSetting != nil && !rewardSetting.PriceSnapshotAt.IsZero() {
return rewardSetting.PriceSnapshotAt
}
return time.Now()
}(),
OrderID: orderID,
ActivityID: func() int64 { ActivityID: func() int64 {
if req.ActivityID != nil { if req.ActivityID != nil {
return *req.ActivityID return *req.ActivityID
@ -288,6 +308,7 @@ func (s *service) GrantRewardToOrder(ctx context.Context, userID int64, req Gran
// 执行事务 // 执行事务
err := s.writeDB.Transaction(func(tx *dao.Query) error { err := s.writeDB.Transaction(func(tx *dao.Query) error {
logger.Info("开始事务处理") logger.Info("开始事务处理")
var rewardSetting *model.ActivityRewardSettings
// 1. 验证订单存在且属于该用户 // 1. 验证订单存在且属于该用户
order, err := tx.Orders.WithContext(ctx).Where( order, err := tx.Orders.WithContext(ctx).Where(
@ -322,6 +343,13 @@ func (s *service) GrantRewardToOrder(ctx context.Context, userID int64, req Gran
logger.Error("奖励库存不足或不存在") logger.Error("奖励库存不足或不存在")
return fmt.Errorf("奖励库存不足或不存在") return fmt.Errorf("奖励库存不足或不存在")
} }
rewardSetting, err = tx.ActivityRewardSettings.WithContext(ctx).Where(
tx.ActivityRewardSettings.ID.Eq(*req.RewardID),
).First()
if err != nil {
logger.Error("查询奖励配置失败", zap.Error(err))
return fmt.Errorf("查询奖励配置失败: %w", err)
}
logger.Info("奖励库存扣减成功(乐观锁)") logger.Info("奖励库存扣减成功(乐观锁)")
} }
@ -355,7 +383,25 @@ func (s *service) GrantRewardToOrder(ctx context.Context, userID int64, req Gran
inventory := &model.UserInventory{ inventory := &model.UserInventory{
UserID: userID, UserID: userID,
ProductID: req.ProductID, ProductID: req.ProductID,
OrderID: req.OrderID, // 关联到原抽奖订单 ValueCents: func() int64 {
if rewardSetting != nil && rewardSetting.PriceSnapshotCents > 0 {
return rewardSetting.PriceSnapshotCents
}
return product.Price
}(),
ValueSource: func() int32 {
if rewardSetting != nil && rewardSetting.PriceSnapshotCents > 0 {
return 1
}
return 2
}(),
ValueSnapshotAt: func() time.Time {
if rewardSetting != nil && !rewardSetting.PriceSnapshotAt.IsZero() {
return rewardSetting.PriceSnapshotAt
}
return time.Now()
}(),
OrderID: req.OrderID, // 关联到原抽奖订单
ActivityID: func() int64 { ActivityID: func() int64 {
if req.ActivityID != nil { if req.ActivityID != nil {
return *req.ActivityID return *req.ActivityID

View File

@ -3,6 +3,7 @@ package user
import ( import (
"context" "context"
"fmt" "fmt"
"time"
"bindbox-game/internal/repository/mysql/dao" "bindbox-game/internal/repository/mysql/dao"
"bindbox-game/internal/repository/mysql/model" "bindbox-game/internal/repository/mysql/model"
@ -10,10 +11,11 @@ import (
// BatchRewardItem 批量发放的单个奖励项 // BatchRewardItem 批量发放的单个奖励项
type BatchRewardItem struct { type BatchRewardItem struct {
ProductID int64 ProductID int64
RewardID *int64 // 可选,一番赏模式需要传入以扣减库存 RewardID *int64 // 用于资产归因/价值快照
ActivityID int64 DeductRewardStock bool // 是否按 RewardID 扣减奖池库存(仅一番赏)
Remark string ActivityID int64
Remark string
} }
// BatchGrantRewardsToOrder 批量发放奖励到订单(单事务处理) // BatchGrantRewardsToOrder 批量发放奖励到订单(单事务处理)
@ -60,6 +62,26 @@ func (s *service) BatchGrantRewardsToOrder(ctx context.Context, userID int64, or
for _, p := range products { for _, p := range products {
productMap[p.ID] = p productMap[p.ID] = p
} }
rewardSnapshotMap := make(map[int64]*model.ActivityRewardSettings)
rewardIDSet := make(map[int64]struct{})
for _, item := range items {
if item.RewardID != nil && *item.RewardID > 0 {
rewardIDSet[*item.RewardID] = struct{}{}
}
}
if len(rewardIDSet) > 0 {
rewardIDs := make([]int64, 0, len(rewardIDSet))
for id := range rewardIDSet {
rewardIDs = append(rewardIDs, id)
}
rewardRows, err := tx.ActivityRewardSettings.WithContext(ctx).Where(tx.ActivityRewardSettings.ID.In(rewardIDs...)).Find()
if err != nil {
return fmt.Errorf("查询奖励配置失败: %w", err)
}
for _, row := range rewardRows {
rewardSnapshotMap[row.ID] = row
}
}
// 3. 批量创建订单项和库存记录 // 3. 批量创建订单项和库存记录
var orderItems []*model.OrderItems var orderItems []*model.OrderItems
@ -83,8 +105,32 @@ func (s *service) BatchGrantRewardsToOrder(ctx context.Context, userID int64, or
}) })
inventories = append(inventories, &model.UserInventory{ inventories = append(inventories, &model.UserInventory{
UserID: userID, UserID: userID,
ProductID: item.ProductID, ProductID: item.ProductID,
ValueCents: func() int64 {
if item.RewardID != nil {
if reward, ok := rewardSnapshotMap[*item.RewardID]; ok && reward.PriceSnapshotCents > 0 {
return reward.PriceSnapshotCents
}
}
return product.Price
}(),
ValueSource: func() int32 {
if item.RewardID != nil {
if reward, ok := rewardSnapshotMap[*item.RewardID]; ok && reward.PriceSnapshotCents > 0 {
return 1
}
}
return 2
}(),
ValueSnapshotAt: func() time.Time {
if item.RewardID != nil {
if reward, ok := rewardSnapshotMap[*item.RewardID]; ok && !reward.PriceSnapshotAt.IsZero() {
return reward.PriceSnapshotAt
}
}
return time.Now()
}(),
OrderID: orderID, OrderID: orderID,
ActivityID: item.ActivityID, ActivityID: item.ActivityID,
RewardID: func() int64 { RewardID: func() int64 {
@ -118,10 +164,10 @@ func (s *service) BatchGrantRewardsToOrder(ctx context.Context, userID int64, or
} }
} }
// 5. 处理一番赏库存扣减按RewardID聚合后批量更新 // 5. 处理奖池库存扣减(仅对明确要求扣减的奖励
rewardDeductMap := make(map[int64]int64) rewardDeductMap := make(map[int64]int64)
for _, item := range items { for _, item := range items {
if item.RewardID != nil { if item.DeductRewardStock && item.RewardID != nil && *item.RewardID > 0 {
rewardDeductMap[*item.RewardID]++ rewardDeductMap[*item.RewardID]++
} }
} }

View File

@ -0,0 +1,30 @@
ALTER TABLE `activity_reward_settings`
ADD COLUMN `price_snapshot_cents` BIGINT NOT NULL DEFAULT 0 COMMENT '奖品配置时商品价格快照(分)' AFTER `product_id`,
ADD COLUMN `price_snapshot_at` DATETIME(3) NULL COMMENT '奖品价格快照时间' AFTER `price_snapshot_cents`;
ALTER TABLE `user_inventory`
ADD COLUMN `value_cents` BIGINT NOT NULL DEFAULT 0 COMMENT '资产价值快照(分)' AFTER `product_id`,
ADD COLUMN `value_source` TINYINT NOT NULL DEFAULT 0 COMMENT '价值来源0未知 1奖励快照 2商品回退 3人工修复' AFTER `value_cents`,
ADD COLUMN `value_snapshot_at` DATETIME(3) NULL COMMENT '资产价值快照时间' AFTER `value_source`;
UPDATE `activity_reward_settings` ars
LEFT JOIN `products` p ON p.id = ars.product_id
SET ars.price_snapshot_cents = COALESCE(p.price, 0),
ars.price_snapshot_at = NOW(3)
WHERE ars.price_snapshot_cents = 0;
UPDATE `user_inventory` ui
LEFT JOIN `activity_reward_settings` ars ON ars.id = ui.reward_id
SET ui.value_cents = ars.price_snapshot_cents,
ui.value_source = 1,
ui.value_snapshot_at = COALESCE(ars.price_snapshot_at, NOW(3))
WHERE ui.value_cents = 0
AND ui.reward_id > 0
AND ars.price_snapshot_cents > 0;
UPDATE `user_inventory` ui
LEFT JOIN `products` p ON p.id = ui.product_id
SET ui.value_cents = COALESCE(p.price, 0),
ui.value_source = 2,
ui.value_snapshot_at = NOW(3)
WHERE ui.value_cents = 0;

View File

@ -0,0 +1,85 @@
-- 目的:
-- 1) 修复历史抽奖资产中 reward_id=0 导致 value_cents 无法命中奖励快照的问题
-- 2) 将可唯一映射到 draw_logs.reward_id 的资产回填 reward_id / activity_id / value_cents
-- 3) 对仍为 0 的 value_cents 做商品价格回退
-- Step 1: 构建可唯一映射的 inventory -> reward 映射
DROP TEMPORARY TABLE IF EXISTS tmp_inventory_reward_map;
CREATE TEMPORARY TABLE tmp_inventory_reward_map AS
SELECT
ui.id AS inventory_id,
MIN(ars.id) AS reward_id,
COUNT(DISTINCT ars.id) AS reward_candidates
FROM user_inventory ui
JOIN activity_draw_logs adl
ON adl.order_id = ui.order_id
AND adl.user_id = ui.user_id
AND adl.reward_id > 0
JOIN activity_reward_settings ars
ON ars.id = adl.reward_id
AND ars.product_id = ui.product_id
JOIN activity_issues ai
ON ai.id = ars.issue_id
WHERE ui.reward_id = 0
AND ui.status IN (1, 3)
AND COALESCE(ui.remark, '') NOT LIKE '%void%'
AND (ui.activity_id = 0 OR ui.activity_id = ai.activity_id)
GROUP BY ui.id;
-- Step 2: 回填 reward_id / activity_id / value_cents仅处理唯一候选
UPDATE user_inventory ui
JOIN tmp_inventory_reward_map m
ON m.inventory_id = ui.id
AND m.reward_candidates = 1
JOIN activity_reward_settings ars
ON ars.id = m.reward_id
JOIN activity_issues ai
ON ai.id = ars.issue_id
SET
ui.reward_id = m.reward_id,
ui.activity_id = CASE WHEN ui.activity_id = 0 THEN ai.activity_id ELSE ui.activity_id END,
ui.value_cents = CASE
WHEN ui.value_cents = 0 THEN COALESCE(NULLIF(ars.price_snapshot_cents, 0), ui.value_cents)
ELSE ui.value_cents
END,
ui.value_source = CASE
WHEN ui.value_cents = 0 AND ars.price_snapshot_cents > 0 THEN 1
ELSE ui.value_source
END,
ui.value_snapshot_at = CASE
WHEN ui.value_cents = 0 AND ars.price_snapshot_cents > 0 THEN COALESCE(ars.price_snapshot_at, NOW(3))
ELSE ui.value_snapshot_at
END,
ui.updated_at = NOW(3);
-- Step 3: 对仍为 0 的资产做商品价格兜底
UPDATE user_inventory ui
LEFT JOIN products p
ON p.id = ui.product_id
SET
ui.value_cents = CASE
WHEN ui.value_cents = 0 THEN COALESCE(p.price, 0)
ELSE ui.value_cents
END,
ui.value_source = CASE
WHEN ui.value_cents = 0 AND COALESCE(p.price, 0) > 0 THEN 2
ELSE ui.value_source
END,
ui.value_snapshot_at = CASE
WHEN ui.value_cents = 0 AND COALESCE(p.price, 0) > 0 THEN NOW(3)
ELSE ui.value_snapshot_at
END,
ui.updated_at = NOW(3)
WHERE ui.status IN (1, 3)
AND ui.value_cents = 0
AND COALESCE(ui.remark, '') NOT LIKE '%void%';
-- Step 4: 清理临时表
DROP TEMPORARY TABLE IF EXISTS tmp_inventory_reward_map;
-- 验证建议:
-- SELECT ui.activity_id, COUNT(*) cnt, SUM(ui.value_cents) total_value
-- FROM user_inventory ui
-- WHERE ui.status IN (1, 3) AND COALESCE(ui.remark, '') NOT LIKE '%void%'
-- GROUP BY ui.activity_id
-- ORDER BY total_value DESC;

View File

@ -0,0 +1,14 @@
-- +goose Up
ALTER TABLE `livestream_activities`
ADD COLUMN `channel_id` BIGINT NULL AFTER `streamer_contact`,
ADD COLUMN `channel_code` VARCHAR(64) NULL AFTER `channel_id`;
CREATE INDEX `idx_livestream_activities_channel_id`
ON `livestream_activities` (`channel_id`);
-- +goose Down
ALTER TABLE `livestream_activities`
DROP COLUMN `channel_code`,
DROP COLUMN `channel_id`;
DROP INDEX `idx_livestream_activities_channel_id` ON `livestream_activities`;

View File

@ -0,0 +1,15 @@
CREATE TABLE IF NOT EXISTS douyin_reward_logs (
id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY COMMENT '主键ID',
shop_order_id VARCHAR(64) NOT NULL DEFAULT '' COMMENT '抖店订单号',
douyin_user_id VARCHAR(128) NOT NULL DEFAULT '' COMMENT '抖音用户ID',
local_user_id BIGINT NOT NULL DEFAULT 0 COMMENT '本地用户ID',
douyin_product_id VARCHAR(64) NOT NULL DEFAULT '' COMMENT '抖店商品ID',
prize_id BIGINT NOT NULL DEFAULT 0 COMMENT '直播奖品ID(可选)',
source VARCHAR(32) NOT NULL DEFAULT '' COMMENT '来源: auto/manual/dispatch',
status VARCHAR(32) NOT NULL DEFAULT '' COMMENT '状态: success/failed/skipped',
message VARCHAR(255) NOT NULL DEFAULT '' COMMENT '说明信息',
extra JSON NULL COMMENT '扩展信息',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间'
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='抖店发奖日志';
CREATE INDEX idx_douyin_reward_logs_order ON douyin_reward_logs (shop_order_id, created_at);

View File

@ -0,0 +1,92 @@
package main
import (
"encoding/json"
"flag"
"fmt"
"log"
"net/http"
"net/url"
"os"
"time"
)
type douyinOrder struct {
ShopOrderID string `json:"shop_order_id"`
UserID string `json:"user_id"`
UserNickname string `json:"user_nickname"`
OrderStatus int `json:"order_status"`
}
type apiResponse struct {
Code int `json:"code"`
St int `json:"st"`
Msg string `json:"msg"`
Data []douyinOrder `json:"data"`
}
func main() {
var (
page = flag.Int("page", 0, "page index")
pageSize = flag.Int("pageSize", 20, "page size")
duration = flag.Int("minutes", 60, "how many minutes back to fetch (update_time_start)")
)
flag.Parse()
cookie := os.Getenv("DOUYIN_COOKIE")
if cookie == "" {
log.Fatal("请先设置环境变量 DOUYIN_COOKIE")
}
params := url.Values{}
params.Set("page", fmt.Sprintf("%d", *page))
params.Set("pageSize", fmt.Sprintf("%d", *pageSize))
params.Set("order_by", "update_time")
params.Set("order", "desc")
params.Set("appid", "1")
params.Set("_bid", "ffa_order")
params.Set("aid", "4272")
params.Set("tab", "all")
if *duration > 0 {
ts := time.Now().Add(-time.Duration(*duration) * time.Minute).Unix()
params.Set("update_time_start", fmt.Sprintf("%d", ts))
}
apiURL := "https://fxg.jinritemai.com/api/order/searchlist" + "?" + params.Encode()
req, err := http.NewRequest(http.MethodGet, apiURL, nil)
if err != nil {
log.Fatalf("create request failed: %v", err)
}
req.Header.Set("User-Agent", "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36")
req.Header.Set("Accept", "application/json, text/plain, */*")
req.Header.Set("Cookie", cookie)
req.Header.Set("Referer", "https://fxg.jinritemai.com/ffa/morder/order/list")
req.Close = true
client := &http.Client{Timeout: 30 * time.Second}
resp, err := client.Do(req)
if err != nil {
log.Fatalf("request failed: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
log.Fatalf("unexpected status: %s", resp.Status)
}
var data apiResponse
if err := json.NewDecoder(resp.Body).Decode(&data); err != nil {
log.Fatalf("decode response failed: %v", err)
}
if data.St != 0 && data.Code != 0 {
log.Fatalf("api error: %s (st=%d code=%d)", data.Msg, data.St, data.Code)
}
fmt.Printf("共获取 %d 条订单 (page=%d, pageSize=%d)\n", len(data.Data), *page, *pageSize)
fmt.Println("shop_order_id\torder_status\tuser_id\tuser_nickname")
for _, order := range data.Data {
fmt.Printf("%s\t%d\t%s\t%s\n", order.ShopOrderID, order.OrderStatus, order.UserID, order.UserNickname)
}
}