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/validation"
"bindbox-game/internal/repository/mysql/model"
financesvc "bindbox-game/internal/service/finance"
"encoding/json"
"fmt"
"net/http"
@ -35,6 +36,11 @@ type activityProfitLossItem struct {
TotalDiscount int64 `json:"total_discount"` // 优惠券抵扣金额 (分)
TotalGamePassValue int64 `json:"total_game_pass_value"` // 次卡价值 (分)
TotalCost int64 `json:"total_cost"` // 奖品标价总和 (分)
SpendingPaidCoupon int64 `json:"spending_paid_coupon"`
SpendingGamePass int64 `json:"spending_game_pass"`
PrizeCostBase int64 `json:"prize_cost_base"`
PrizeCostMultiplier int64 `json:"prize_cost_multiplier"`
PrizeCostFinal int64 `json:"prize_cost_final"`
Profit int64 `json:"profit"` // (Revenue + Discount + GamePassValue) - Cost
ProfitRate float64 `json:"profit_rate"` // Profit / (Revenue + Discount + GamePassValue)
}
@ -170,14 +176,19 @@ func (h *handler) DashboardActivityProfitLoss() core.HandlerFunc {
}
var revenueStats []revenueStat
// 修正: 按抽奖次数比例分摊订单金额 (解决多活动订单归因问题)
// 逻辑: 活动分摊收入 = 订单实际金额 * (该活动在该订单中的抽奖次数 / 该订单总抽奖次数)
// 修正: 按抽奖次数比例分摊订单金额,且次卡订单不计入支付+优惠券口径(严格二选一)
var err error
err = db.Table(model.TableNameOrders).
Select(`
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%' THEN 0 ELSE 1.0 * orders.discount_amount * order_activity_draws.draw_count / order_total_draws.total_count END) as total_discount
SUM(CASE WHEN orders.source_type = 4 OR orders.order_no LIKE 'GP%' OR (orders.actual_amount = 0 AND orders.remark LIKE '%use_game_pass%')
THEN 0
ELSE 1.0 * orders.actual_amount * order_activity_draws.draw_count / order_total_draws.total_count
END) as total_revenue,
SUM(CASE WHEN orders.source_type = 4 OR orders.order_no LIKE 'GP%' OR (orders.actual_amount = 0 AND orders.remark LIKE '%use_game_pass%')
THEN 0
ELSE 1.0 * orders.discount_amount * order_activity_draws.draw_count / order_total_draws.total_count
END) as total_discount
`).
// Subquery 1: Calculate draw counts per order per activity (and link to issue->activity)
Joins(`JOIN (
@ -213,20 +224,39 @@ func (h *handler) DashboardActivityProfitLoss() core.HandlerFunc {
type costStat struct {
ActivityID int64
TotalCost int64
TotalCostBase int64
AvgMultiplierX10 int64
}
var costStats []costStat
db.Table(model.TableNameUserInventory).
Select("user_inventory.activity_id, SUM(products.price) as total_cost").
Joins("JOIN products ON products.id = user_inventory.product_id").
if err := db.Table(model.TableNameUserInventory).
Select(`
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").
Where("user_inventory.activity_id IN ?", activityIDs).
Where("orders.status = ?", 2). // 仅统计已支付订单产生的成本
Group("user_inventory.activity_id").
Scan(&costStats)
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("COALESCE(NULLIF(user_inventory.activity_id, 0), cost_issues.activity_id) IN ?", activityIDs).
Where("user_inventory.status IN ?", []int{1, 3}).
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. 计算盈亏和比率
// 公式: 盈亏 = (支付金额 + 优惠券抵扣 + 次卡价值) - 产品成本
// 公式: 盈亏 = 用户支出(普通单支付+优惠券 或 次卡价值) - 奖品成本(含道具卡倍率)
finalList := make([]activityProfitLossItem, 0, len(activities))
for _, a := range activities {
item := activityMap[a.ID]
totalIncome := item.TotalRevenue + item.TotalDiscount + item.TotalGamePassValue
item.Profit = totalIncome - item.TotalCost
if totalIncome > 0 {
item.ProfitRate = float64(item.Profit) / float64(totalIncome)
}
item.SpendingPaidCoupon = item.TotalRevenue + item.TotalDiscount
item.SpendingGamePass = item.TotalGamePassValue
totalIncome := item.SpendingPaidCoupon + item.SpendingGamePass
item.Profit, item.ProfitRate = financesvc.ComputeProfit(totalIncome, item.TotalCost)
finalList = append(finalList, *item)
}
@ -418,6 +447,7 @@ func (h *handler) DashboardActivityLogs() core.HandlerFunc {
DrawCount int64 // 该订单的总抽奖次数(用于金额分摊)
UsedDrawLogID int64 // 道具卡实际使用的日志ID
CreatedAt time.Time
ActivityPrice int64
}
logsQuery := db.Table(model.TableNameActivityDrawLogs).
@ -429,7 +459,7 @@ func (h *handler) DashboardActivityLogs() core.HandlerFunc {
activity_reward_settings.product_id,
COALESCE(products.name, '') as product_name,
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.discount_amount, 0) as discount_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(order_draw_counts.draw_count, 1) as draw_count,
COALESCE(user_item_cards.used_draw_log_id, 0) as used_draw_log_id,
activity_draw_logs.created_at
activity_draw_logs.created_at,
COALESCE(activities.price_draw, 0) as activity_price
`).
Joins("JOIN activity_issues ON activity_issues.id = activity_draw_logs.issue_id").
Joins("JOIN activities ON activities.id = activity_issues.activity_id").
Joins("LEFT JOIN users ON users.id = activity_draw_logs.user_id").
Joins("LEFT JOIN activity_reward_settings ON activity_reward_settings.id = activity_draw_logs.reward_id").
Joins("LEFT JOIN products ON products.id = activity_reward_settings.product_id").
@ -590,6 +622,14 @@ func (h *handler) DashboardActivityLogs() core.HandlerFunc {
perDrawDiscountAmount := l.DiscountAmount / 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.PointsDiscount = perDrawPointsAmount

View File

@ -2,6 +2,7 @@ package admin
import (
"database/sql"
"fmt"
"net/http"
"strconv"
"time"
@ -1483,7 +1484,7 @@ func (h *handler) DashboardPrizeDistribution() core.HandlerFunc {
"SUM(activity_reward_settings.quantity) as level_rem_qty",
"SUM(activity_reward_settings.weight) as level_total_prob",
"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").
Order("activity_reward_settings.level").
@ -1665,21 +1666,26 @@ func (h *handler) OperationsProductPerformance() core.HandlerFunc {
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("LEFT JOIN activity_reward_settings ON activity_reward_settings.id = activity_draw_logs.reward_id").
Joins("LEFT JOIN products ON products.id = activity_reward_settings.product_id").
Joins("LEFT JOIN orders ON orders.id = activity_draw_logs.order_id").
Joins("LEFT JOIN user_item_cards ON user_item_cards.id = orders.item_card_id").
Joins("LEFT JOIN system_item_cards ON system_item_cards.id = user_item_cards.card_id").
Where("activity_draw_logs.created_at >= ?", s).
Where("activity_draw_logs.created_at <= ?", e).
Select(
"activity_issues.activity_id",
"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").
Order("count DESC").
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))

View File

@ -5,9 +5,11 @@ import (
"bindbox-game/internal/pkg/core"
"bindbox-game/internal/pkg/validation"
"bindbox-game/internal/repository/mysql/model"
financesvc "bindbox-game/internal/service/finance"
"fmt"
"net/http"
"sort"
"strings"
"time"
)
@ -27,6 +29,11 @@ type spendingLeaderboardItem struct {
OrderCount int64 `json:"-"` // Hidden
TotalSpending int64 `json:"-"` // Hidden
TotalPrizeValue int64 `json:"-"` // Hidden
SpendingPaidCoupon int64 `json:"spending_paid_coupon"`
SpendingGamePass int64 `json:"spending_game_pass"`
PrizeCostBase int64 `json:"prize_cost_base"`
PrizeCostMultiplier int64 `json:"prize_cost_multiplier"`
PrizeCostFinal int64 `json:"prize_cost_final"`
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
@ -93,6 +100,7 @@ func (h *handler) DashboardPlayerSpendingLeaderboard() core.HandlerFunc {
TotalDiscount int64
TotalPoints int64
GamePassCount int64
GamePassSpending int64
ItemCardCount int64
IchibanSpending int64
IchibanCount int64
@ -106,7 +114,7 @@ func (h *handler) DashboardPlayerSpendingLeaderboard() core.HandlerFunc {
var stats []orderStat
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)
if req.RangeType != "all" {
@ -115,20 +123,42 @@ func (h *handler) DashboardPlayerSpendingLeaderboard() core.HandlerFunc {
if err := query.Select(`
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,
SUM(orders.discount_amount) as total_discount,
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 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 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 = 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 = 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 orders.source_type = 5 THEN orders.total_amount ELSE 0 END) as livestream_spending,
SUM(CASE WHEN orders.source_type = 5 THEN 1 ELSE 0 END) as livestream_count
0 as livestream_spending,
0 as livestream_count
`).
Group("orders.user_id").
Order("total_amount DESC").
@ -152,6 +182,7 @@ func (h *handler) DashboardPlayerSpendingLeaderboard() core.HandlerFunc {
TotalDiscount: s.TotalDiscount,
TotalPoints: s.TotalPoints,
GamePassCount: s.GamePassCount,
SpendingGamePass: s.GamePassSpending,
ItemCardCount: s.ItemCardCount,
IchibanSpending: s.IchibanSpending,
IchibanCount: s.IchibanCount,
@ -186,7 +217,7 @@ func (h *handler) DashboardPlayerSpendingLeaderboard() core.HandlerFunc {
if item, ok := statMap[ds.UserID]; ok {
item.LivestreamSpending = ds.Amount
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)
query := db.Table(model.TableNameUserInventory).
Joins("JOIN products ON products.id = user_inventory.product_id").
Joins("LEFT JOIN activities ON activities.id = user_inventory.activity_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 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 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)
if req.RangeType != "all" {
@ -227,14 +262,14 @@ func (h *handler) DashboardPlayerSpendingLeaderboard() core.HandlerFunc {
}
// Only include Holding (1) and Shipped/Used (3) items. Exclude Void/Decomposed (2).
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(`
user_inventory.user_id,
SUM(products.price) as total_value,
SUM(CASE WHEN activities.activity_category_id = 1 THEN products.price ELSE 0 END) as ichiban_prize,
SUM(CASE WHEN activities.activity_category_id = 2 THEN products.price ELSE 0 END) as infinite_prize,
SUM(CASE WHEN activities.activity_category_id = 3 THEN products.price ELSE 0 END) as matching_prize
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,
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,
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,
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").
Scan(&invStats).Error
@ -248,31 +283,107 @@ func (h *handler) DashboardPlayerSpendingLeaderboard() core.HandlerFunc {
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)
type lsStat struct {
// 4.1 Calculate Livestream Prize Value (snapshot priority; fallback prize cost)
type lsLog struct {
UserID int64
Amount int64
ShopOrderID string
PrizeID int64
}
var lsStats []lsStat
lsQuery := 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, SUM(products.price) as amount").
var lsLogs []lsLog
lsLogQuery := db.Table(model.TableNameLivestreamDrawLogs).
Select("livestream_draw_logs.local_user_id as user_id, livestream_draw_logs.shop_order_id, livestream_draw_logs.prize_id").
Where("livestream_draw_logs.local_user_id IN ?", userIDs).
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" {
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)
}
_ = lsLogQuery.Scan(&lsLogs).Error
if err := lsQuery.Group("livestream_draw_logs.local_user_id").Scan(&lsStats).Error; err == nil {
for _, ls := range lsStats {
if item, ok := statMap[ls.UserID]; ok {
item.LivestreamPrize = ls.Amount
// item.TotalPrizeValue += ls.Amount // Already included in user_inventory
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 {
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
item.Profit = calculatedProfit
if calculatedSpending > 0 {
item.ProfitRate = float64(item.Profit) / float64(calculatedSpending)
} else {
item.ProfitRate = 0
_, item.ProfitRate = financesvc.ComputeProfit(calculatedSpending, calculatedSpending-item.Profit)
item.SpendingPaidCoupon = calculatedSpending - item.SpendingGamePass
if item.SpendingPaidCoupon < 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)
}

View File

@ -4,12 +4,14 @@ import (
"fmt"
"net/http"
"strconv"
"strings"
"time"
"bindbox-game/internal/code"
"bindbox-game/internal/pkg/core"
"bindbox-game/internal/pkg/validation"
"bindbox-game/internal/repository/mysql/model"
financesvc "bindbox-game/internal/service/finance"
)
type userSpendingRequest struct {
@ -87,9 +89,18 @@ func (h *handler) GetUserSpendingDashboard() core.HandlerFunc {
var actStats []activityStat
query := db.Table(model.TableNameOrders).
Joins("LEFT JOIN activity_draw_logs ON activity_draw_logs.order_id = orders.id").
Joins("LEFT JOIN activity_issues ON activity_issues.id = activity_draw_logs.issue_id").
Joins("LEFT JOIN activities ON activities.id = activity_issues.activity_id").
Joins(`LEFT JOIN (
SELECT activity_draw_logs.order_id, activity_issues.activity_id, COUNT(*) as draw_count
FROM activity_draw_logs
JOIN activity_issues ON activity_issues.id = activity_draw_logs.issue_id
GROUP BY activity_draw_logs.order_id, activity_issues.activity_id
) as order_activity_draws ON order_activity_draws.order_id = orders.id`).
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.status = ?", 2)
@ -101,7 +112,11 @@ func (h *handler) GetUserSpendingDashboard() core.HandlerFunc {
COALESCE(activities.id, 0) as activity_id,
COALESCE(activities.name, '其他') as activity_name,
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
`).
Group("COALESCE(activities.id, 0)").
@ -120,21 +135,28 @@ func (h *handler) GetUserSpendingDashboard() core.HandlerFunc {
var prizeStats []prizeStat
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.status IN ?", []int{1, 3}).
Where("user_inventory.remark NOT LIKE ?", "%void%")
Where("COALESCE(user_inventory.remark, '') NOT LIKE ?", "%void%")
if hasRange {
prizeQuery = prizeQuery.Where("user_inventory.created_at >= ?", start).Where("user_inventory.created_at <= ?", end)
}
prizeQuery.Select(`
COALESCE(user_inventory.activity_id, 0) as activity_id,
SUM(products.price) as prize_value
if err := prizeQuery.Select(`
COALESCE(NULLIF(user_inventory.activity_id, 0), cost_issues.activity_id, 0) 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 prize_value
`).
Group("COALESCE(user_inventory.activity_id, 0)").
Scan(&prizeStats)
Group("COALESCE(NULLIF(user_inventory.activity_id, 0), cost_issues.activity_id, 0)").
Scan(&prizeStats).Error; err != nil {
h.logger.Error(fmt.Sprintf("GetUserSpendingDashboard prize stats error: %v", err))
}
prizeMap := make(map[int64]int64)
for _, p := range prizeStats {
@ -173,21 +195,98 @@ func (h *handler) GetUserSpendingDashboard() core.HandlerFunc {
PrizeValue int64
}
var lsPrizeStats []lsPrizeStat
lsPrizeQuery := db.Table("livestream_draw_logs").
Joins("JOIN products ON products.id = livestream_draw_logs.product_id").
Select(`
livestream_draw_logs.livestream_activity_id as activity_id,
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")
type lsLog struct {
ActivityID int64
ShopOrderID string
PrizeID int64
}
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 {
lsPrizeQuery = lsPrizeQuery.Where("livestream_draw_logs.created_at >= ?", start).Where("livestream_draw_logs.created_at <= ?", end)
lsLogQuery = lsLogQuery.Where("created_at >= ?", start).Where("created_at <= ?", end)
}
_ = lsLogQuery.Scan(&lsLogs).Error
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
}
}
lsPrizeQuery.Group("livestream_draw_logs.livestream_activity_id").Scan(&lsPrizeStats)
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)
for _, p := range lsPrizeStats {
@ -211,7 +310,7 @@ func (h *handler) GetUserSpendingDashboard() core.HandlerFunc {
CategoryName: catName,
Spending: s.Spending,
PrizeValue: prize,
Profit: s.Spending - prize,
Profit: func() int64 { p, _ := financesvc.ComputeProfit(s.Spending, prize); return p }(),
OrderCount: s.OrderCount,
}
activities = append(activities, item)
@ -230,7 +329,7 @@ func (h *handler) GetUserSpendingDashboard() core.HandlerFunc {
CategoryName: "直播间",
Spending: ls.Spending,
PrizeValue: prize,
Profit: ls.Spending - prize,
Profit: func() int64 { p, _ := financesvc.ComputeProfit(ls.Spending, prize); return p }(),
OrderCount: ls.OrderCount,
}
activities = append(activities, item)
@ -241,7 +340,7 @@ func (h *handler) GetUserSpendingDashboard() core.HandlerFunc {
rsp.TotalSpend = totalSpend
rsp.TotalPrize = totalPrize
rsp.TotalProfit = totalSpend - totalPrize
rsp.TotalProfit, _ = financesvc.ComputeProfit(totalSpend, totalPrize)
rsp.TotalOrders = totalOrders
rsp.Activities = activities

View File

@ -6,9 +6,12 @@ import (
"bindbox-game/internal/pkg/validation"
"bindbox-game/internal/service/douyin"
"context"
"errors"
"fmt"
"io"
"net/http"
"strconv"
"strings"
"time"
)
@ -17,6 +20,7 @@ import (
type getDouyinConfigResponse struct {
Cookie string `json:"cookie"`
IntervalMinutes int `json:"interval_minutes"`
Proxy string `json:"proxy"`
}
func (h *handler) GetDouyinConfig() core.HandlerFunc {
@ -29,6 +33,7 @@ func (h *handler) GetDouyinConfig() core.HandlerFunc {
ctx.Payload(getDouyinConfigResponse{
Cookie: cfg.Cookie,
IntervalMinutes: cfg.IntervalMinutes,
Proxy: cfg.Proxy,
})
}
}
@ -36,6 +41,7 @@ func (h *handler) GetDouyinConfig() core.HandlerFunc {
type saveDouyinConfigRequest struct {
Cookie string `json:"cookie"`
IntervalMinutes int `json:"interval_minutes"`
Proxy string `json:"proxy"`
}
func (h *handler) SaveDouyinConfig() core.HandlerFunc {
@ -46,7 +52,7 @@ func (h *handler) SaveDouyinConfig() core.HandlerFunc {
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)))
return
}
@ -61,6 +67,9 @@ type listDouyinOrdersRequest struct {
Page int `form:"page"`
PageSize int `form:"page_size"`
Status *int `form:"status"`
Match string `form:"match_status"`
ShopOrderID string `form:"shop_order_id"`
DouyinUserID string `form:"douyin_user_id"`
}
type douyinOrderItem struct {
@ -95,7 +104,16 @@ func (h *handler) ListDouyinOrders() core.HandlerFunc {
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 {
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ServerError, validation.Error(err)))
return
@ -158,26 +176,62 @@ type syncDouyinOrdersResponse struct {
TotalFetched int `json:"total_fetched"`
NewOrders int `json:"new_orders"`
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 {
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 请求断开导致同步中断
// 设置 5 分钟超时,确保有足够时间完成全量同步
bgCtx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
defer cancel()
result, err := h.douyinSvc.FetchAndSyncOrders(bgCtx)
result, err := h.douyinSvc.FetchAndSyncOrders(bgCtx, fetchOpts)
if err != nil {
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ServerError, validation.Error(err)))
return
}
ctx.Payload(syncDouyinOrdersResponse{
Message: "同步成功",
Message: fmt.Sprintf("同步成功,处理 %d/%d 个用户,用时 %.2f 秒", result.ProcessedUsers, result.TotalUsers, float64(result.ElapsedMS)/1000.0),
TotalFetched: result.TotalFetched,
NewOrders: result.NewOrders,
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"`
}
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 手动发放直播间奖品
func (h *handler) ManualGrantPrizes() core.HandlerFunc {
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 {

View File

@ -1,6 +1,9 @@
package admin
import (
"context"
"errors"
"fmt"
"net/http"
"strconv"
"strings"
@ -10,6 +13,7 @@ import (
"bindbox-game/internal/pkg/core"
"bindbox-game/internal/pkg/validation"
"bindbox-game/internal/repository/mysql/model"
"bindbox-game/internal/service/channel"
"bindbox-game/internal/service/livestream"
"gorm.io/gorm"
@ -21,6 +25,7 @@ type createLivestreamActivityRequest struct {
Name string `json:"name" binding:"required"`
StreamerName string `json:"streamer_name"`
StreamerContact string `json:"streamer_contact"`
ChannelID *int64 `json:"channel_id"`
DouyinProductID string `json:"douyin_product_id"`
OrderRewardType string `json:"order_reward_type"` // 下单奖励类型: flip_card/minesweeper
OrderRewardQuantity int32 `json:"order_reward_quantity"` // 下单奖励数量: 1-100
@ -34,6 +39,9 @@ type livestreamActivityResponse struct {
Name string `json:"name"`
StreamerName string `json:"streamer_name"`
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"`
DouyinProductID string `json:"douyin_product_id"`
OrderRewardType string `json:"order_reward_type"` // 下单奖励类型
@ -64,10 +72,35 @@ func (h *handler) CreateLivestreamActivity() core.HandlerFunc {
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{
Name: req.Name,
StreamerName: req.StreamerName,
StreamerContact: req.StreamerContact,
ChannelID: func() int64 {
if req.ChannelID != nil {
return *req.ChannelID
}
return 0
}(),
ChannelCode: channelCode,
DouyinProductID: req.DouyinProductID,
OrderRewardType: req.OrderRewardType,
OrderRewardQuantity: req.OrderRewardQuantity,
@ -91,11 +124,19 @@ func (h *handler) CreateLivestreamActivity() core.HandlerFunc {
return
}
displayChannelName := channelName
if displayChannelName == "" && activity.ChannelCode != "" {
displayChannelName = activity.ChannelCode
}
ctx.Payload(&livestreamActivityResponse{
ID: activity.ID,
Name: activity.Name,
StreamerName: activity.StreamerName,
StreamerContact: activity.StreamerContact,
ChannelID: activity.ChannelID,
ChannelCode: activity.ChannelCode,
ChannelName: displayChannelName,
AccessCode: activity.AccessCode,
DouyinProductID: activity.DouyinProductID,
OrderRewardType: activity.OrderRewardType,
@ -111,6 +152,7 @@ type updateLivestreamActivityRequest struct {
Name string `json:"name"`
StreamerName string `json:"streamer_name"`
StreamerContact string `json:"streamer_contact"`
ChannelID *int64 `json:"channel_id"`
DouyinProductID string `json:"douyin_product_id"`
OrderRewardType string `json:"order_reward_type"` // 下单奖励类型
OrderRewardQuantity *int32 `json:"order_reward_quantity"` // 下单奖励数量
@ -146,6 +188,29 @@ func (h *handler) UpdateLivestreamActivity() core.HandlerFunc {
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{
Name: req.Name,
StreamerName: req.StreamerName,
@ -155,6 +220,8 @@ func (h *handler) UpdateLivestreamActivity() core.HandlerFunc {
OrderRewardQuantity: req.OrderRewardQuantity,
TicketPrice: req.TicketPrice,
Status: req.Status,
ChannelID: req.ChannelID,
ChannelCode: channelCodePtr,
}
if req.StartTime != "" {
@ -224,6 +291,14 @@ func (h *handler) ListLivestreamActivities() core.HandlerFunc {
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{
List: make([]livestreamActivityResponse, len(list)),
Total: total,
@ -245,6 +320,13 @@ func (h *handler) ListLivestreamActivities() core.HandlerFunc {
Status: a.Status,
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() {
item.StartTime = a.StartTime.Format("2006-01-02 15:04:05")
}
@ -283,11 +365,24 @@ func (h *handler) GetLivestreamActivity() core.HandlerFunc {
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{
ID: activity.ID,
Name: activity.Name,
StreamerName: activity.StreamerName,
StreamerContact: activity.StreamerContact,
ChannelID: activity.ChannelID,
ChannelCode: activity.ChannelCode,
ChannelName: channelName,
AccessCode: activity.AccessCode,
DouyinProductID: activity.DouyinProductID,
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 {
@ -630,6 +760,17 @@ func (h *handler) ListLivestreamDrawLogs() core.HandlerFunc {
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)
_ = ctx.ShouldBindForm(req)
@ -710,10 +851,11 @@ func (h *handler) ListLivestreamDrawLogs() core.HandlerFunc {
DouyinOrderID int64
PrizeID int64
ShopOrderID string // 用于关联退款状态查 douyin_orders
LocalUserID int64
}
var metas []logMeta
// 使用不带分页的 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))
distinctOrderIDs := make(map[int64]bool)
prizeIDCount := make(map[int64]int64)
@ -730,61 +872,97 @@ func (h *handler) ListLivestreamDrawLogs() core.HandlerFunc {
if len(orderIDs) > 0 {
var orders []model.DouyinOrders
// 分批查询防止 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)
orderRefundMap := make(map[int64]bool)
for _, o := range orders {
// 统计营收 (总流水)
stats.TotalRev += int64(o.ActualPayAmount)
orderAmount := calcLivestreamOrderAmount(&o, ticketPrice)
stats.TotalRev += orderAmount
if o.OrderStatus == 4 { // 已退款
stats.TotalRefund += int64(o.ActualPayAmount)
stats.TotalRefund += orderAmount
orderRefundMap[o.ID] = true
}
}
// 4. 统计成本 (剔除退款订单)
// 4. 统计成本 (剔除退款订单,优先资产快照,缺失回退 prize.cost_price)
for _, m := range metas {
if !orderRefundMap[m.DouyinOrderID] {
prizeIDCount[m.PrizeID]++
}
}
// 计算奖品成本 (逻辑参考 GetLivestreamStats简化版)
prizeCostMap := make(map[int64]int64)
if len(prizeIDCount) > 0 {
prizeIDs := make([]int64, 0, len(prizeIDCount))
for pid := range prizeIDCount {
prizeIDs = append(prizeIDs, pid)
}
var prizes []model.LivestreamPrizes
h.repo.GetDbR().Where("id IN ?", prizeIDs).Find(&prizes)
// 批量获取关联商品
productIDs := make([]int64, 0)
h.repo.GetDbR().Select("id, cost_price").Where("id IN ?", prizeIDs).Find(&prizes)
for _, p := range prizes {
if p.CostPrice == 0 && p.ProductID > 0 {
productIDs = append(productIDs, p.ProductID)
}
}
productPriceMap := make(map[int64]int64)
if len(productIDs) > 0 {
var products []model.Products
h.repo.GetDbR().Select("id, price").Where("id IN ?", productIDs).Find(&products)
for _, prod := range products {
productPriceMap[prod.ID] = prod.Price
prizeCostMap[p.ID] = p.CostPrice
}
}
for _, p := range prizes {
cost := p.CostPrice
if cost == 0 && p.ProductID > 0 {
cost = productPriceMap[p.ProductID]
// 预加载用户资产快照用于 shop_order_id 命中
type invRow struct {
UserID int64
ValueCents int64
Remark string
}
count := prizeIDCount[p.ID]
stats.TotalCost += cost * count
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
}
}
}
if snapshotSum > 0 {
stats.TotalCost += snapshotSum
continue
}
for _, r := range rows {
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"
"net/http"
"strconv"
"strings"
"bindbox-game/internal/code"
"bindbox-game/internal/pkg/core"
@ -77,54 +78,98 @@ func (h *handler) GetLivestreamStats() core.HandlerFunc {
ctx.AbortWithError(core.Error(http.StatusNotFound, code.ServerError, "活动不存在"))
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 {
queryRevenue += " AND l.created_at >= '" + startTime.Format("2006-01-02 15:04:05") + "'"
orderQuery = orderQuery.Where("created_at >= ?", startTime)
}
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)
// 统计退款:来自已参与过抽奖且标记为退款的订单 (order_status = 4)
var totalRefund, refundCount int64
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") + "'"
var orderRefs []orderRef
if err := orderQuery.Group("douyin_order_id").Scan(&orderRefs).Error; err != nil {
ctx.AbortWithError(core.Error(http.StatusInternalServerError, code.ServerError, err.Error()))
return
}
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. 获取所有抽奖记录用于成本计算
var drawLogs []model.LivestreamDrawLogs
@ -138,141 +183,130 @@ func (h *handler) GetLivestreamStats() core.HandlerFunc {
db.Find(&drawLogs)
// 3.1 获取该时间段内所有退款的 shop_order_id 集合,用于过滤成本
refundedShopOrderIDs := make(map[string]bool)
var refundedOrders []string
qRefundIDs := `
SELECT DISTINCT o.shop_order_id
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
`
// 4. 计算成本(优先资产快照 user_inventory.value_cents缺失回退 livestream_prizes.cost_price
prizeCostMap := make(map[int64]int64)
prizeIDs := make([]int64, 0)
prizeIDSet := make(map[int64]struct{})
userIDSet := make(map[int64]struct{})
for _, log := range drawLogs {
if log.PrizeID > 0 {
if _, ok := prizeIDSet[log.PrizeID]; !ok {
prizeIDSet[log.PrizeID] = struct{}{}
prizeIDs = append(prizeIDs, log.PrizeID)
}
}
if log.LocalUserID > 0 {
userIDSet[log.LocalUserID] = struct{}{}
}
}
if len(prizeIDs) > 0 {
var prizes []model.LivestreamPrizes
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 {
qRefundIDs += " AND l.created_at >= '" + startTime.Format("2006-01-02 15:04:05") + "'"
invDB = invDB.Where("created_at >= ?", startTime.Add(-2*time.Hour))
}
if endTime != nil {
qRefundIDs += " AND l.created_at <= '" + endTime.Format("2006-01-02 15:04:05") + "'"
invDB = invDB.Where("created_at <= ?", endTime.Add(24*time.Hour))
}
h.repo.GetDbR().Raw(qRefundIDs, id).Scan(&refundedOrders)
for _, oid := range refundedOrders {
refundedShopOrderIDs[oid] = true
}
// 4. 计算成本(使用 draw_logs.product_id 直接关联 products 表)
// 收集未退款订单的 product_id 和对应数量
productIDCountMap := make(map[int64]int64)
for _, log := range drawLogs {
// 排除已退款的订单
if refundedShopOrderIDs[log.ShopOrderID] {
continue
}
// 使用 draw_logs 中记录的 product_id
if log.ProductID > 0 {
productIDCountMap[log.ProductID]++
_ = invDB.Scan(&inventories).Error
for _, inv := range inventories {
invByUser[inv.UserID] = append(invByUser[inv.UserID], inv)
}
}
var totalCost int64
productCostMap := make(map[int64]int64)
if len(productIDCountMap) > 0 {
productIDs := make([]int64, 0, len(productIDCountMap))
for pid := range productIDCountMap {
productIDs = append(productIDs, pid)
}
var products []model.Products
h.repo.GetDbR().Where("id IN ?", productIDs).Find(&products)
for _, p := range products {
productCostMap[p.ID] = p.Price
}
for productID, count := range productIDCountMap {
if cost, ok := productCostMap[productID]; ok {
totalCost += cost * count
}
}
}
// 构建 productID -> cost 映射供每日统计使用
prizeCostMap := productCostMap
// 5. 按天分组统计
dailyMap := make(map[string]*dailyLivestreamStats)
// 5.1 统计每日营收和退款(直接累加订单实付金额)
type DailyAmount struct {
type logRef struct {
PrizeID int64
DateKey string
Amount int64
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
logsByKey := make(map[string][]logRef)
keyUser := make(map[string]int64)
keyOrder := make(map[string]string)
for _, log := range drawLogs {
// 排除退款订单
if refundedShopOrderIDs[log.ShopOrderID] {
continue
}
if log.ProductID <= 0 {
key := strconv.FormatInt(log.LocalUserID, 10) + "|" + log.ShopOrderID
logsByKey[key] = append(logsByKey[key], logRef{
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
for key, refs := range logsByKey {
if len(refs) == 0 {
continue
}
dateKey := log.CreatedAt.Format("2006-01-02")
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
}
}
}
if snapshotSum > 0 {
avg := snapshotSum / int64(len(refs))
rem := snapshotSum - avg*int64(len(refs))
for i, r := range refs {
c := avg
if i == 0 {
c += rem
}
totalCost += c
costByDate[r.DateKey] += c
}
continue
}
for _, r := range refs {
c := prizeCostMap[r.PrizeID]
totalCost += c
costByDate[r.DateKey] += c
}
}
// 5. 按天分组统计 (营收/退款已在 dailyMap 中累计),补充成本
for dateKey, c := range costByDate {
ds := dailyMap[dateKey]
if ds != nil {
if cost, ok := prizeCostMap[log.ProductID]; ok {
ds.TotalCost += cost
}
if ds == nil {
ds = &dailyLivestreamStats{Date: dateKey}
dailyMap[dateKey] = ds
}
ds.TotalCost += c
}
// 6. 汇总每日数据并计算总体指标

View File

@ -177,6 +177,14 @@ func (h *handler) CreateRefund() core.HandlerFunc {
// 全额退款:回收中奖资产与奖品库存(包含已兑换积分的资产)
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 清理格位
_ = h.activity.ClearIchibanPositionsByOrderID(ctx.RequestContext(), order.ID)
@ -191,20 +199,27 @@ func (h *handler) CreateRefund() core.HandlerFunc {
}
} else if inv.Status == 3 {
// 状态3已兑换扣除积分并作废
deductPoints := int64(0)
matches := rePoints.FindStringSubmatch(inv.Remark)
if len(matches) > 1 {
p, _ := strconv.ParseInt(matches[1], 10, 64)
if p > 0 {
deductPoints = p
}
}
if deductPoints <= 0 && inv.ValueCents > 0 {
deductPoints = inv.ValueCents * rate
}
if deductPoints > 0 {
// 扣除积分(记录流水)- 使用柔性扣减
_, consumed, err := svc.ConsumePointsForRefund(ctx.RequestContext(), order.UserID, p, "user_inventory", strconv.FormatInt(inv.ID, 10), "订单退款回收兑换积分")
_, 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, p, err))
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 < p {
if consumed < deductPoints {
pointsShortage = true
}
}
}
// 更新状态为2 (作废)
_ = h.repo.GetDbW().Exec("UPDATE user_inventory SET status=2, updated_at=NOW(3), remark=CONCAT(IFNULL(remark,''),'|refund_reclaimed') WHERE id=?", inv.ID).Error
// 恢复奖品库存

View File

@ -23,7 +23,9 @@ type rewardItem struct {
MinScore int64 `json:"min_score"`
ProductName string `json:"product_name"`
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 {
@ -151,9 +153,11 @@ func (h *handler) ListIssueRewards() core.HandlerFunc {
if p, ok := pm[v.ProductID]; ok {
it.ProductName = p.Name
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
}
ctx.Payload(res)

View File

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

View File

@ -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(p.price), 0)
(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 = u.id AND ui.status = 1

View File

@ -194,12 +194,14 @@ func (h *handler) GetUserProfile() core.HandlerFunc {
Value int64
}
var is invStats
h.readDB.UserInventory.WithContext(ctx.RequestContext()).ReadDB().
LeftJoin(h.readDB.Products, h.readDB.Products.ID.EqCol(h.readDB.UserInventory.ProductID)).
Select(h.readDB.UserInventory.ID.Count().As("count"), h.readDB.Products.Price.Sum().As("value")).
Where(h.readDB.UserInventory.UserID.Eq(userID)).
Where(h.readDB.UserInventory.Status.Eq(1)).
Scan(&is)
_ = h.repo.GetDbR().Raw(`
SELECT
COUNT(ui.id) as count,
COALESCE(SUM(COALESCE(NULLIF(ui.value_cents, 0), p.price, 0)), 0) as value
FROM user_inventory ui
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.InventoryValue = is.Value

View File

@ -1,6 +1,7 @@
package admin
import (
"fmt"
"net/http"
"strconv"
"time"
@ -86,7 +87,12 @@ func (h *handler) GetUserProfitLossTrend() core.HandlerFunc {
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(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(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
@ -94,17 +100,22 @@ func (h *handler) GetUserProfitLossTrend() core.HandlerFunc {
// --- 2. 获取订单数据(仅 status=2 已支付) ---
// 注意:为了计算累计趋势,我们需要获取 start 之前的所有已支付订单总额作为基数
var baseCost int64 = 0
var baseCostPtr *int64
h.readDB.Orders.WithContext(ctx.RequestContext()).ReadDB().
Select(h.readDB.Orders.ActualAmount.Sum()).
Where(h.readDB.Orders.UserID.Eq(userID)).
Where(h.readDB.Orders.Status.Eq(2)).
Where(h.readDB.Orders.SourceType.In(2, 3, 4)). // 排除积分兑换(1)和奖品兑换(5)
Where(h.readDB.Orders.CreatedAt.Lt(start)).
Scan(&baseCostPtr)
if baseCostPtr != nil {
baseCost = *baseCostPtr
}
_ = h.repo.GetDbR().Raw(`
SELECT COALESCE(SUM(CASE
WHEN o.source_type = 4 OR o.order_no LIKE 'GP%' OR (o.actual_amount = 0 AND o.remark LIKE '%use_game_pass%')
THEN COALESCE(od.draw_count * a.price_draw, 0)
ELSE o.actual_amount + o.discount_amount
END), 0)
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 < ?
`, userID, start).Scan(&baseCost).Error
// 扣除历史退款 (如果有的话,此处简化处理,主要关注当前范围内的波动)
var baseRefund int64 = 0
@ -119,13 +130,28 @@ func (h *handler) GetUserProfitLossTrend() core.HandlerFunc {
baseCost = 0
}
orderRows, _ := h.readDB.Orders.WithContext(ctx.RequestContext()).ReadDB().
Where(h.readDB.Orders.UserID.Eq(userID)).
Where(h.readDB.Orders.Status.Eq(2)).
Where(h.readDB.Orders.SourceType.In(2, 3, 4)). // 排除积分兑换(1)和奖品兑换(5)
Where(h.readDB.Orders.CreatedAt.Gte(start)).
Where(h.readDB.Orders.CreatedAt.Lte(end)).
Find()
type orderSpendRow struct {
CreatedAt time.Time
Spending int64
}
var orderRows []orderSpendRow
_ = h.repo.GetDbR().Raw(`
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 {
@ -157,7 +183,7 @@ func (h *handler) GetUserProfitLossTrend() core.HandlerFunc {
var periodDelta int64 = 0
for _, o := range orderRows {
if inBucket(o.CreatedAt, b) {
periodDelta += o.ActualAmount
periodDelta += o.Spending
}
}
for _, r := range refunds {
@ -192,16 +218,22 @@ func (h *handler) GetUserProfitLossTrend() core.HandlerFunc {
// 汇总数据
var totalCost int64 = 0
var totalCostPtr *int64
h.readDB.Orders.WithContext(ctx.RequestContext()).ReadDB().
Select(h.readDB.Orders.ActualAmount.Sum()).
Where(h.readDB.Orders.UserID.Eq(userID)).
Where(h.readDB.Orders.Status.Eq(2)).
Where(h.readDB.Orders.SourceType.In(2, 3, 4)). // 排除积分兑换(1)和奖品兑换(5)
Scan(&totalCostPtr)
if totalCostPtr != nil {
totalCost = *totalCostPtr
}
_ = h.repo.GetDbR().Raw(`
SELECT COALESCE(SUM(CASE
WHEN o.source_type = 4 OR o.order_no LIKE 'GP%' OR (o.actual_amount = 0 AND o.remark LIKE '%use_game_pass%')
THEN COALESCE(od.draw_count * a.price_draw, 0)
ELSE o.actual_amount + o.discount_amount
END), 0)
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)
`, userID).Scan(&totalCost).Error
var totalRefund int64 = 0
_ = h.repo.GetDbR().Raw(`
@ -387,14 +419,21 @@ func (h *handler) GetUserProfitLossDetails() core.HandlerFunc {
Name string
}
var prizes []prizeRow
_ = h.repo.GetDbR().Raw(`
SELECT ui.order_id, COALESCE(SUM(p.price), 0) as value,
if err := h.repo.GetDbR().Raw(`
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
FROM user_inventory ui
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 ?
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 {
prizeValueMap[p.OrderID] = p.Value
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))
var totalCost, totalValue int64
@ -453,7 +522,14 @@ func (h *handler) GetUserProfitLossDetails() core.HandlerFunc {
refund := refundMap[o.OrderNo]
prizeValue := prizeValueMap[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
list[i] = profitLossDetailItem{

View File

@ -246,11 +246,13 @@ func (h *handler) ListTaskTiersForAdmin() core.HandlerFunc {
type upsertRewardsRequest struct {
Rewards []struct {
ID int64 `json:"id"`
TierID int64 `json:"tier_id"`
RewardType string `json:"reward_type"`
RewardPayload datatypes.JSON `json:"reward_payload"`
Quantity int64 `json:"quantity"`
} `json:"rewards"`
DeleteIDs []int64 `json:"delete_ids"`
}
// @Summary 设置任务奖励(Admin)
@ -276,9 +278,9 @@ func (h *handler) UpsertTaskRewardsForAdmin() core.HandlerFunc {
}
in := make([]tasksvc.TaskRewardInput, len(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()))
return
}

View File

@ -22,6 +22,7 @@ type weixinLoginRequest struct {
Code string `json:"code"`
InviteCode string `json:"invite_code"`
DouyinID string `json:"douyin_id"`
ChannelCode string `json:"channel_code"`
}
type weixinLoginResponse struct {
UserID int64 `json:"user_id"`
@ -63,7 +64,7 @@ func (h *handler) WeixinLogin() core.HandlerFunc {
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)
if err != nil {
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.IssueID = field.NewInt64(tableName, "issue_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.Quantity = field.NewInt64(tableName, "quantity")
_activityRewardSettings.OriginalQty = field.NewInt64(tableName, "original_qty")
@ -56,6 +58,8 @@ type activityRewardSettings struct {
UpdatedAt field.Time // 更新时间
IssueID field.Int64 // 期IDactivity_issues.id
ProductID field.Int64 // 奖品对应商品ID实物奖可填
PriceSnapshotCents field.Int64 // 奖品配置时商品价格快照(分)
PriceSnapshotAt field.Time // 奖品价格快照时间
Weight field.Int32 // 抽中权重(越大越易中)
Quantity field.Int64 // 当前可发数量(扣减)
OriginalQty field.Int64 // 初始配置数量
@ -85,6 +89,8 @@ func (a *activityRewardSettings) updateTableName(table string) *activityRewardSe
a.UpdatedAt = field.NewTime(table, "updated_at")
a.IssueID = field.NewInt64(table, "issue_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.Quantity = field.NewInt64(table, "quantity")
a.OriginalQty = field.NewInt64(table, "original_qty")
@ -109,12 +115,14 @@ func (a *activityRewardSettings) GetFieldByName(fieldName string) (field.OrderEx
}
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["created_at"] = a.CreatedAt
a.fieldMap["updated_at"] = a.UpdatedAt
a.fieldMap["issue_id"] = a.IssueID
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["quantity"] = a.Quantity
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.StreamerName = field.NewString(tableName, "streamer_name")
_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.DouyinProductID = field.NewString(tableName, "douyin_product_id")
_livestreamActivities.Status = field.NewInt32(tableName, "status")
@ -59,6 +61,8 @@ type livestreamActivities struct {
Name field.String // 活动名称
StreamerName field.String // 主播名称
StreamerContact field.String // 主播联系方式
ChannelID field.Int64 // 关联渠道ID
ChannelCode field.String // 关联渠道Code
AccessCode field.String // 唯一访问码
DouyinProductID field.String // 关联抖店商品ID
Status field.Int32 // 状态:1进行中 2已结束
@ -92,6 +96,8 @@ func (l *livestreamActivities) updateTableName(table string) *livestreamActiviti
l.Name = field.NewString(table, "name")
l.StreamerName = field.NewString(table, "streamer_name")
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.DouyinProductID = field.NewString(table, "douyin_product_id")
l.Status = field.NewInt32(table, "status")
@ -121,11 +127,13 @@ func (l *livestreamActivities) GetFieldByName(fieldName string) (field.OrderExpr
}
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["name"] = l.Name
l.fieldMap["streamer_name"] = l.StreamerName
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["douyin_product_id"] = l.DouyinProductID
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.UserID = field.NewInt64(tableName, "user_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.ActivityID = field.NewInt64(tableName, "activity_id")
_userInventory.RewardID = field.NewInt64(tableName, "reward_id")
@ -54,6 +57,9 @@ type userInventory struct {
UpdatedAt field.Time // 更新时间
UserID field.Int64 // 资产归属用户ID
ProductID field.Int64 // 资产对应商品ID实物奖/商品)
ValueCents field.Int64 // 资产价值快照(分)
ValueSource field.Int32 // 价值来源0未知 1奖励快照 2商品回退 3人工修复
ValueSnapshotAt field.Time // 资产价值快照时间
OrderID field.Int64 // 来源订单ID
ActivityID field.Int64 // 来源活动ID
RewardID field.Int64 // 来源奖励IDactivity_reward_settings.id
@ -81,6 +87,9 @@ func (u *userInventory) updateTableName(table string) *userInventory {
u.UpdatedAt = field.NewTime(table, "updated_at")
u.UserID = field.NewInt64(table, "user_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.ActivityID = field.NewInt64(table, "activity_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() {
u.fieldMap = make(map[string]field.Expr, 11)
u.fieldMap = make(map[string]field.Expr, 14)
u.fieldMap["id"] = u.ID
u.fieldMap["created_at"] = u.CreatedAt
u.fieldMap["updated_at"] = u.UpdatedAt
u.fieldMap["user_id"] = u.UserID
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["activity_id"] = u.ActivityID
u.fieldMap["reward_id"] = u.RewardID

View File

@ -19,6 +19,8 @@ type ActivityRewardSettings struct {
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
ProductID int64 `gorm:"column:product_id;comment:奖品对应商品ID实物奖可填" json:"product_id"` // 奖品对应商品ID实物奖可填
PriceSnapshotCents int64 `gorm:"column:price_snapshot_cents;not null;comment:奖品配置时商品价格快照(分)" json:"price_snapshot_cents"` // 奖品配置时商品价格快照(分)
PriceSnapshotAt time.Time `gorm:"column:price_snapshot_at;comment:奖品价格快照时间" json:"price_snapshot_at"` // 奖品价格快照时间
Weight int32 `gorm:"column:weight;not null;comment:抽中权重(越大越易中)" json:"weight"` // 抽中权重(越大越易中)
Quantity int64 `gorm:"column:quantity;not null;comment:当前可发数量(扣减)" json:"quantity"` // 当前可发数量(扣减)
OriginalQty int64 `gorm:"column:original_qty;not null;comment:初始配置数量" json:"original_qty"` // 初始配置数量

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"` // 活动名称
StreamerName string `gorm:"column:streamer_name;comment:主播名称" json:"streamer_name"` // 主播名称
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"` // 唯一访问码
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"` // 下单奖励类型

View File

@ -17,6 +17,9 @@ type UserInventory struct {
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
ProductID int64 `gorm:"column:product_id;comment:资产对应商品ID实物奖/商品)" json:"product_id"` // 资产对应商品ID实物奖/商品)
ValueCents int64 `gorm:"column:value_cents;not null;comment:资产价值快照(分)" json:"value_cents"` // 资产价值快照(分)
ValueSource int32 `gorm:"column:value_source;not null;comment:价值来源0未知 1奖励快照 2商品回退 3人工修复" json:"value_source"` // 价值来源0未知 1奖励快照 2商品回退 3人工修复
ValueSnapshotAt time.Time `gorm:"column:value_snapshot_at;comment:资产价值快照时间" json:"value_snapshot_at"` // 资产价值快照时间
OrderID int64 `gorm:"column:order_id;comment:来源订单ID" json:"order_id"` // 来源订单ID
ActivityID int64 `gorm:"column:activity_id;comment:来源活动ID" json:"activity_id"` // 来源活动ID
RewardID int64 `gorm:"column:reward_id;comment:来源奖励IDactivity_reward_settings.id" json:"reward_id"` // 来源奖励IDactivity_reward_settings.id

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-refund", adminHandler.ManualSyncRefund())
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.POST("/douyin/product-rewards", adminHandler.CreateDouyinProductReward())

View File

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

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 (
"context"
"time"
"bindbox-game/internal/repository/mysql/dao"
"bindbox-game/internal/repository/mysql/model"
@ -12,10 +13,33 @@ import (
// 返回: 错误信息
func (s *service) CreateIssueRewards(ctx context.Context, issueID int64, rewards []CreateRewardInput) 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 {
item := &model.ActivityRewardSettings{
IssueID: issueID,
ProductID: r.ProductID,
PriceSnapshotCents: productPriceMap[r.ProductID],
PriceSnapshotAt: time.Now(),
Weight: r.Weight,
Quantity: r.Quantity,
OriginalQty: r.OriginalQty,

View File

@ -17,6 +17,16 @@ func (s *service) ModifyIssueReward(ctx context.Context, rewardID int64, in Modi
}
if in.ProductID != nil {
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 {
item.Weight = int32(*in.Weight)

View File

@ -2,12 +2,15 @@ package channel
import (
"context"
"errors"
"time"
"bindbox-game/internal/pkg/logger"
"bindbox-game/internal/repository/mysql"
"bindbox-game/internal/repository/mysql/dao"
"bindbox-game/internal/repository/mysql/model"
"gorm.io/gorm"
)
type Service interface {
@ -16,6 +19,7 @@ type Service interface {
Delete(ctx context.Context, id int64) 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)
GetByID(ctx context.Context, id int64) (*model.Channels, error)
}
type service struct {
@ -70,6 +74,8 @@ type StatsDailyItem struct {
GMV int64 `json:"gmv"`
}
var ErrChannelNotFound = errors.New("channel_not_found")
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}
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
}
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
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"
"encoding/json"
"errors"
"fmt"
"io"
"math"
@ -21,30 +16,38 @@ import (
"time"
"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"
"golang.org/x/sync/singleflight"
"bindbox-game/internal/service/user"
"gorm.io/gorm"
)
// 系统配置键
const (
ConfigKeyDouyinCookie = "douyin_cookie"
ConfigKeyDouyinInterval = "douyin_sync_interval_minutes"
ConfigKeyDouyinProxy = "douyin_proxy"
)
type Service interface {
// FetchAndSyncOrders 从抖店 API 获取订单并同步到本地 (按绑定用户同步)
FetchAndSyncOrders(ctx context.Context) (*SyncResult, error)
FetchAndSyncOrders(ctx context.Context, opts *FetchOptions) (*SyncResult, error)
// SyncAllOrders 批量同步所有订单变更 (基于更新时间,不分状态)
// useProxy: 是否使用代理服务器访问抖音API
SyncAllOrders(ctx context.Context, duration time.Duration, useProxy bool) (*SyncResult, error)
// 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(ctx context.Context) (*DouyinConfig, error)
// SaveConfig 保存抖店配置
SaveConfig(ctx context.Context, cookie string, intervalMinutes int) error
SaveConfig(ctx context.Context, cookie, proxy string, intervalMinutes int) error
// SyncOrder 同步单个订单到本地可传入建议关联的用户ID和商品ID
SyncOrder(ctx context.Context, item *DouyinOrderItem, suggestUserID int64, productID string) (isNew bool, isMatched bool)
// GrantMinesweeperQualifications 自动补发扫雷资格
@ -53,11 +56,95 @@ type Service interface {
GrantLivestreamPrizes(ctx context.Context) error
// SyncRefundStatus 同步退款状态
SyncRefundStatus(ctx context.Context) error
// GrantOrderReward 手动触发单个订单的奖励发放
GrantOrderReward(ctx context.Context, shopOrderID string) (*GrantOrderRewardResult, error)
}
type DouyinConfig struct {
Cookie string `json:"cookie"`
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 {
@ -66,6 +153,44 @@ type SyncResult struct {
MatchedUsers int `json:"matched_users"`
Orders []*model.DouyinOrders `json:"orders"` // 新增:返回详情以供后续处理
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 {
@ -113,14 +238,20 @@ func (s *service) GetConfig(ctx context.Context) (*DouyinConfig, error) {
cfg.IntervalMinutes = v
}
}
if c, err := s.syscfg.GetByKey(ctx, ConfigKeyDouyinProxy); err == nil && c != nil {
cfg.Proxy = c.ConfigValue
}
return cfg, nil
}
// 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 {
return err
}
if _, err := s.syscfg.UpsertByKey(ctx, ConfigKeyDouyinProxy, proxy, "抖店代理配置"); err != nil {
return err
}
if intervalMinutes < 1 {
intervalMinutes = 5
}
@ -131,7 +262,7 @@ func (s *service) SaveConfig(ctx context.Context, cookie string, intervalMinutes
}
// 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 {
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{})
if status != nil {
db = db.Where("order_status = ?", *status)
if filter == nil {
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
@ -158,7 +308,9 @@ func (s *service) ListOrders(ctx context.Context, page, pageSize int, status *in
}
// 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)
if err != nil {
return nil, fmt.Errorf("获取配置失败: %w", err)
@ -168,40 +320,159 @@ func (s *service) FetchAndSyncOrders(ctx context.Context) (*SyncResult, error) {
}
// 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
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)
}
result := &SyncResult{}
fmt.Printf("[DEBUG] 开始全量同步,共 %d 个绑定用户\n", len(users))
result := &SyncResult{
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 抓取订单
for _, u := range users {
fmt.Printf("[DEBUG] 正在同步用户 ID: %d (昵称: %s, 抖音号: %s) 的订单...\n", u.ID, u.Nickname, u.DouyinUserID)
orders, err := s.fetchDouyinOrdersByBuyer(cfg.Cookie, u.DouyinUserID)
if err != nil {
fmt.Printf("[DEBUG] 抓取用户 %s 订单失败: %v\n", u.DouyinUserID, err)
continue
if len(users) == 0 {
result.ElapsedMS = time.Since(startAt).Milliseconds()
result.DebugInfo = "未找到符合条件的用户"
return result, nil
}
result.TotalFetched += len(orders)
var mu sync.Mutex
// 3. 同步
syncUser := func(u model.Users) {
select {
case <-ctx.Done():
return
default:
}
s.logger.Info("[抖店同步] 开始同步用户订单",
zap.Int64("user_id", u.ID),
zap.String("nickname", u.Nickname),
zap.String("douyin_user_id", u.DouyinUserID))
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 {
// 同步订单(传入建议关联的用户 ID
isNew, matched := s.SyncOrder(ctx, &order, u.ID, "")
if isNew {
result.NewOrders++
perUserNew++
}
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
}
@ -243,7 +514,7 @@ type SkuOrderItem struct {
}
// 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.Set("page", "0")
params.Set("pageSize", "100")
@ -255,18 +526,22 @@ func (s *service) fetchDouyinOrdersByBuyer(cookie string, buyer string) ([]Douyi
params.Set("_bid", "ffa_order")
params.Set("aid", "4272")
return s.fetchDouyinOrders(cookie, params, true) // 按用户同步使用代理
return s.fetchDouyinOrders(cookie, params, proxy)
}
// 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"
fullUrl := baseUrl + "?" + params.Encode()
// 配置代理服务器巨量代理IP (可选)
var proxyURL *url.URL
if useProxy {
proxyURL, _ = url.Parse("http://t13319619426654:ln8aj9nl@s432.kdltps.com:15818")
if strings.TrimSpace(proxyAddr) != "" {
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
@ -285,9 +560,9 @@ func (s *service) fetchDouyinOrders(cookie string, params url.Values, useProxy b
// 禁用连接复用,防止代理断开导致 EOF
req.Close = true
// 根据 useProxy 参数决定是否使用代理
// 根据 proxyURL 是否存在决定是否使用代理
var transport *http.Transport
if useProxy && proxyURL != nil {
if proxyURL != nil {
transport = &http.Transport{
Proxy: http.ProxyURL(proxyURL),
DisableKeepAlives: true, // 禁用 Keep-Alive
@ -306,7 +581,7 @@ func (s *service) fetchDouyinOrders(cookie string, params url.Values, useProxy b
resp, err := client.Do(req)
if err != nil {
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)
continue
}
@ -559,6 +834,99 @@ func (s *service) SyncOrder(ctx context.Context, item *DouyinOrderItem, suggestU
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 返回两个整数的最小值
func min(a, b int) int {
if a < b {
@ -612,7 +980,12 @@ func (s *service) SyncAllOrders(ctx context.Context, duration time.Duration, use
}
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)
if err != nil {

View File

@ -1,6 +1,8 @@
package douyin
import (
"errors"
"bindbox-game/internal/pkg/logger"
"bindbox-game/internal/repository/mysql"
"bindbox-game/internal/repository/mysql/model"
@ -14,6 +16,8 @@ import (
"go.uber.org/zap"
"bindbox-game/internal/service/user"
"gorm.io/gorm"
)
// StartDouyinOrderSync 启动抖店订单定时同步任务
@ -157,6 +161,8 @@ func (s *service) GrantLivestreamPrizes(ctx context.Context) error {
return err
}
anchorCodes := s.resolveActivityAnchorCodes(ctx, logs)
for _, log := range logs {
// 必须要有对应的本地用户ID
if log.LocalUserID == 0 {
@ -174,12 +180,37 @@ func (s *service) GrantLivestreamPrizes(ctx context.Context) error {
continue // 还没关联到用户,跳过
}
if code := anchorCodes[log.ActivityID]; code != "" {
s.bindAnchorInviterIfNeeded(ctx, log.LocalUserID, code)
}
// 2. 查奖品关联的 ProductID
var prize model.LivestreamPrizes
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) {
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 {
s.logger.Warn("[自动发放] 奖品未关联商品ID跳过", zap.Int64("prize_id", log.PrizeID), zap.String("name", prize.Name))
@ -204,11 +235,13 @@ func (s *service) GrantLivestreamPrizes(ctx context.Context) error {
res, err := s.userSvc.GrantReward(ctx, log.LocalUserID, req)
if err != nil {
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 {
// 4. 更新发放状态
db.Model(&log).Update("is_granted", 1)
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. 自动虚拟发货 (本地状态更新)
// 直播间奖品通常为虚拟发货,直接标记为已消费/已发货
@ -319,6 +352,15 @@ func (s *service) reclaimLivestreamAssets(ctx context.Context, log *model.Livest
}
// 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 {
if inv.Status == 1 {
// 状态1持有作废
@ -332,10 +374,14 @@ func (s *service) reclaimLivestreamAssets(ctx context.Context, log *model.Livest
)
} else if inv.Status == 3 {
// 状态3已兑换/发货):扣除积分
// 查找商品价格作为积分扣除依据
pointsToDeduct := inv.ValueCents * rate
if pointsToDeduct <= 0 {
// 兼容历史数据,兜底回退商品价格
var product model.Products
if err := s.repo.GetDbR().Where("id = ?", inv.ProductID).First(&product).Error; err == nil {
pointsToDeduct := product.Price / 100 // 分转换为积分(假设 1积分=1分钱
pointsToDeduct = product.Price * rate
}
}
if pointsToDeduct > 0 {
_, consumed, err := s.userSvc.ConsumePointsForRefund(ctx, inv.UserID, pointsToDeduct, "user_inventory", fmt.Sprintf("%d", inv.ID), "直播退款回收已兑换资产")
if err != nil {
@ -352,7 +398,6 @@ func (s *service) reclaimLivestreamAssets(ctx context.Context, log *model.Livest
// db.Exec("UPDATE users SET status = 3 WHERE id = ?", inv.UserID)
}
}
}
// 作废记录
db.Model(&inv).Updates(map[string]any{
"status": 2,
@ -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)
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
StreamerName string
StreamerContact string
ChannelID int64
ChannelCode string
DouyinProductID string
OrderRewardType string
OrderRewardQuantity int32
@ -94,6 +96,8 @@ type UpdateActivityInput struct {
Name string
StreamerName string
StreamerContact string
ChannelID *int64
ChannelCode *string
DouyinProductID string
OrderRewardType string
OrderRewardQuantity *int32
@ -169,6 +173,8 @@ func (s *service) CreateActivity(ctx context.Context, input CreateActivityInput)
Name: input.Name,
StreamerName: input.StreamerName,
StreamerContact: input.StreamerContact,
ChannelID: input.ChannelID,
ChannelCode: input.ChannelCode,
AccessCode: accessCode,
DouyinProductID: input.DouyinProductID,
OrderRewardType: input.OrderRewardType,
@ -205,6 +211,12 @@ func (s *service) UpdateActivity(ctx context.Context, id int64, input UpdateActi
if 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 != "" {
updates["douyin_product_id"] = input.DouyinProductID
}

View File

@ -33,8 +33,11 @@ func TestInviteLogicSymmetry(t *testing.T) {
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
deleted_at DATETIME
);`)
db.Exec(`CREATE TABLE activity_draw_logs (order_id INTEGER, issue_id INTEGER);`)
db.Exec(`CREATE TABLE activity_issues (id INTEGER, activity_id INTEGER);`)
db.Exec(`CREATE TABLE activity_draw_logs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
order_id INTEGER,
issue_id INTEGER
);`)
svc := New(nil, repo, nil, nil, nil)
inviterID := int64(888)
@ -45,6 +48,7 @@ func TestInviteLogicSymmetry(t *testing.T) {
// 只有 101 在活动 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 activity_draw_logs (order_id, issue_id) VALUES (10, 1)")

View File

@ -12,7 +12,6 @@ import (
"encoding/json"
"errors"
"fmt"
"strings"
"time"
gamesvc "bindbox-game/internal/service/game"
@ -34,7 +33,7 @@ type Service interface {
ListTaskTiers(ctx context.Context, taskID int64) ([]TaskTierItem, error)
UpsertTaskTiers(ctx context.Context, taskID int64, tiers []TaskTierInput) 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)
ClaimTier(ctx context.Context, userID int64, taskID int64, tierID int64) error
OnOrderPaid(ctx context.Context, userID int64, orderID int64) error
@ -164,6 +163,7 @@ type TaskTierItem struct {
}
type TaskRewardInput struct {
ID int64 `json:"id"`
TierID int64 `json:"tier_id"`
RewardType string `json:"reward_type"`
RewardPayload datatypes.JSON `json:"reward_payload"`
@ -179,6 +179,172 @@ type TaskRewardItem struct {
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) {
db := s.repo.GetDbR()
var rows []tcmodel.Task
@ -295,7 +461,7 @@ func (s *service) ListTasks(ctx context.Context, in ListTasksInput) (items []Tas
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
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) {
db := s.repo.GetDbR()
// 加载任务信息(获取 StartTime/EndTime 用于 activity_period window
var task tcmodel.Task
if err := db.First(&task, taskID).Error; err != nil {
return nil, err
}
// 3.0 获取任务下所有 Tier含 Window、ActivityID、Metric 字段,用于时效分组查询)
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)
seenActivity := make(map[int64]struct{})
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 {
Window string
ActivityID int64
}
groupMap := make(map[windowGroupKey][]tcmodel.TaskTier)
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)
}
tierProgressMap := make(map[int64]TierProgress)
for wk, groupTiers := range groupMap {
wStart, wEnd := computeTimeWindow(wk.Window, task.StartTime, task.EndTime)
// 构建动态时间条件片段
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
var activityIDs []int64
perActivity := false
if wk.ActivityID > 0 {
// 有活动限制:通过 activity_draw_logs → activity_issues 关联,加时效过滤
baseArgs := append([]interface{}{userID, wk.ActivityID}, timeArgs...)
db.Raw(`
SELECT COUNT(id)
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(&gOrderCount)
activityIDs = []int64{wk.ActivityID}
perActivity = true
}
rows, err := s.fetchOrderMetricRows(ctx, userID, activityIDs, wStart, wEnd)
if err != nil {
return nil, err
}
orderCount, orderAmount := s.aggregateOrderMetrics(rows, perActivity)
inviteCount, err := s.countInvites(ctx, userID, wk.ActivityID, wStart, wEnd)
if err != nil {
return nil, err
}
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)
for _, tier := range groupTiers {
tierProgressMap[tier.ID] = TierProgress{
TierID: tier.ID,
OrderCount: orderCount,
OrderAmount: orderAmount,
InviteCount: inviteCount,
FirstOrder: orderCount > 0,
}
}
}
// 邀请计数:将 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)
var (
allRows []orderMetricRow
err error
)
if len(targetActivityIDs) > 0 {
allRows, err = s.fetchOrderMetricRows(ctx, userID, targetActivityIDs, nil, nil)
} 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)
allRows, err = s.fetchOrderMetricRows(ctx, userID, nil, nil, nil)
}
if err != nil {
return nil, err
}
orderCount, orderAmount := s.aggregateOrderMetrics(allRows, false)
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 {
tierProgressMap[t.ID] = TierProgress{
TierID: t.ID,
OrderCount: gOrderCount,
OrderAmount: gOrderAmount,
InviteCount: gInviteCount,
FirstOrder: gOrderCount > 0,
}
}
}
// ── 向后兼容:全局统计(不限时间窗口,用于顶层字段 OrderCount/InviteCount 和 SubProgress──
var orderCount int64
var orderAmount int64
var subProgressList []ActivityProgress
if len(targetActivityIDs) > 0 {
db.Raw(`
SELECT COUNT(id)
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 IN (?)
)
`, userID, targetActivityIDs).Scan(&orderCount)
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 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)
subStats := make(map[int64]ActivityProgress)
for _, row := range allRows {
if row.ActivityID == 0 {
continue
}
stat := subStats[row.ActivityID]
stat.ActivityID = row.ActivityID
stat.OrderCount++
stat.OrderAmount += s.calculateEffectiveAmount(row)
subStats[row.ActivityID] = stat
}
subProgressList = make([]ActivityProgress, 0, len(targetActivityIDs))
for _, actID := range targetActivityIDs {
if stat, ok := subStats[actID]; ok {
subProgressList = append(subProgressList, stat)
}
}
}
// 2. 实时统计邀请数据(全局,向后兼容)
var inviteCount int64
if len(targetActivityIDs) > 0 {
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)
inviteCount, err := s.countInvitesForActivities(ctx, userID, targetActivityIDs)
if err != nil {
return nil, err
}
// 3. 首单判断
hasFirstOrder := orderCount > 0
// 4. 从进度表读取已领取的档位(这部分仍需保留)
var rows []tcmodel.UserTaskProgress
db.Where("user_id=? AND task_id=?", userID, taskID).Find(&rows)
var progressRows []tcmodel.UserTaskProgress
if err := db.Where("user_id=? AND task_id=?", userID, taskID).Find(&progressRows).Error; err != nil {
return nil, err
}
claimedSet := map[int64]struct{}{}
for _, row := range rows {
for _, row := range progressRows {
var claimed []int64
if len(row.ClaimedTiers) > 0 {
_ = 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)
}
hasFirstOrder := orderCount > 0
return &UserProgress{
TaskID: taskID,
UserID: userID,
@ -602,7 +663,7 @@ func (s *service) GetUserProgress(ctx context.Context, userID int64, taskID int6
FirstOrder: hasFirstOrder,
ClaimedTiers: allClaimed,
SubProgress: subProgressList,
TierProgressMap: tierProgressMap, // Bug1 修复:每个 Tier 的窗口化独立进度
TierProgressMap: tierProgressMap,
}, nil
}
@ -899,7 +960,7 @@ func (s *service) ListTaskTiers(ctx context.Context, taskID int64) ([]TaskTierIt
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
}
@ -914,8 +975,9 @@ func (s *service) UpsertTaskTiers(ctx context.Context, taskID int64, tiers []Tas
existingMap := make(map[string]tcmodel.TaskTier)
for _, t := range existing {
// 使用指标+阈值+活动作为业务指纹
key := fmt.Sprintf("%s-%d-%d", t.Metric, t.Threshold, t.ActivityID)
window := normalizeWindow(t.Window)
t.Window = window
key := tierFingerprint(t.Metric, t.Threshold, t.ActivityID, window)
existingMap[key] = t
}
@ -925,11 +987,15 @@ func (s *service) UpsertTaskTiers(ctx context.Context, taskID int64, tiers []Tas
processedKeys := make(map[string]struct{})
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 {
// 更新现有记录,保留 ID 和 ClaimedCount
old.Operator = t.Operator
old.Window = t.Window
old.Window = window
old.Repeatable = t.Repeatable
old.Priority = t.Priority
old.ExtraParams = t.ExtraParams
@ -942,7 +1008,7 @@ func (s *service) UpsertTaskTiers(ctx context.Context, taskID int64, tiers []Tas
Metric: t.Metric,
Operator: t.Operator,
Threshold: t.Threshold,
Window: t.Window,
Window: window,
Repeatable: t.Repeatable,
Priority: t.Priority,
ActivityID: t.ActivityID,
@ -990,34 +1056,38 @@ func (s *service) ListTaskRewards(ctx context.Context, taskID int64) ([]TaskRewa
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()
// 同理优化 ID 稳定性
var existing []tcmodel.TaskReward
if err := db.Where("task_id=?", taskID).Find(&existing).Error; err != nil {
return err
}
existingMap := make(map[string]tcmodel.TaskReward)
existingByID := make(map[int64]tcmodel.TaskReward, len(existing))
for _, r := range existing {
// 奖励类型+档位 ID 作为指纹
key := fmt.Sprintf("%d-%s", r.TierID, r.RewardType)
existingMap[key] = r
existingByID[r.ID] = r
}
var toDelete []int64
var toUpdate []tcmodel.TaskReward
var toCreate []tcmodel.TaskReward
seen := make(map[int64]struct{})
processedKeys := make(map[string]struct{})
for _, r := range rewards {
key := fmt.Sprintf("%d-%s", r.TierID, r.RewardType)
if old, ok := existingMap[key]; ok {
if r.ID > 0 {
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.Quantity = r.Quantity
toUpdate = append(toUpdate, old)
processedKeys[key] = struct{}{}
} else {
seen[r.ID] = struct{}{}
continue
}
toCreate = append(toCreate, tcmodel.TaskReward{
TaskID: taskID,
TierID: r.TierID,
@ -1026,11 +1096,19 @@ func (s *service) UpsertTaskRewards(ctx context.Context, taskID int64, rewards [
Quantity: r.Quantity,
})
}
}
for key, old := range existingMap {
if _, ok := processedKeys[key]; !ok {
toDelete = append(toDelete, old.ID)
var toDelete []int64
if len(deleteIDs) > 0 {
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 (
"context"
"encoding/json"
"testing"
"time"
"bindbox-game/internal/repository/mysql"
tcmodel "bindbox-game/internal/repository/mysql/task_center"
"gorm.io/datatypes"
"gorm.io/gorm"
)
@ -37,6 +39,22 @@ func ensureExtraTablesForServiceTest(t *testing.T, db *gorm.DB) {
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 err := db.Exec(`CREATE TABLE user_invites (
id INTEGER PRIMARY KEY AUTOINCREMENT,
@ -81,6 +99,9 @@ func TestGetUserProgress_TimeWindow_Integration(t *testing.T) {
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}
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 {
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 插入一个完整的任务配置(任务+档位+奖励)

View File

@ -500,10 +500,21 @@ func (s *service) RedeemInventoryToPoints(ctx context.Context, userID int64, inv
if err != nil {
return 0, err
}
valueCents := inv.ValueCents
valueSource := inv.ValueSource
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()
rate := int64(1)
if cfg != nil {
@ -513,7 +524,7 @@ func (s *service) RedeemInventoryToPoints(ctx context.Context, userID int64, inv
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 {
return 0, err
}
@ -569,39 +580,63 @@ func (s *service) RedeemInventoriesToPoints(ctx context.Context, userID int64, i
return 0, fmt.Errorf("no_valid_inventory")
}
// 构建inventory映射和收集productID
invMap := make(map[int64]*model.UserInventory, len(invList))
// 4. 按资产快照计算总积分,缺失快照时回退商品价格并回写
productIDs := make([]int64, 0, len(invList))
productIDSet := make(map[int64]struct{})
for _, inv := range invList {
invMap[inv.ID] = inv
if inv.ValueCents <= 0 {
if _, ok := productIDSet[inv.ProductID]; !ok {
productIDSet[inv.ProductID] = struct{}{}
productIDs = append(productIDs, inv.ProductID)
}
}
// 4. 批量查询所有products一次查询替代N次
}
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
}
productMap := make(map[int64]*model.Products, len(products))
for _, p := range products {
productMap[p.ID] = p
productPriceMap[p.ID] = p.Price
}
}
// 5. 计算总积分和准备批量更新数据
// 5. 计算总积分和准备批量更新
var totalPoints int64
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 {
p := productMap[inv.ProductID]
if p == nil {
valueCents := inv.ValueCents
valueSource := inv.ValueSource
valueSnapshotAt := inv.ValueSnapshotAt
if valueCents <= 0 {
price, ok := productPriceMap[inv.ProductID]
if !ok {
continue
}
points := p.Price * rate
valueCents = price
valueSource = 2
valueSnapshotAt = time.Now()
valueFixes = append(valueFixes, valueFix{
ID: inv.ID,
ValueCents: valueCents,
ValueSource: valueSource,
ValueSnapAt: valueSnapshotAt,
})
}
if valueCents <= 0 {
continue
}
points := valueCents * rate
totalPoints += points
validIDs = append(validIDs, inv.ID)
}
@ -639,6 +674,14 @@ func (s *service) RedeemInventoriesToPoints(ctx context.Context, userID int64, i
}
// 批量更新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(
"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,

View File

@ -91,10 +91,12 @@ func (s *service) ListInventoryWithProduct(ctx context.Context, userID int64, pa
p := products[r.ProductID]
name := ""
images := ""
var price int64
price := r.ValueCents
if p != nil {
name = p.Name
images = p.ImagesJSON
}
if price <= 0 && p != nil {
price = p.Price
}
sh := shipMap[r.ID]
@ -177,10 +179,12 @@ func (s *service) ListInventoryWithProductActive(ctx context.Context, userID int
p := products[r.ProductID]
name := ""
images := ""
var price int64
price := r.ValueCents
if p != nil {
name = p.Name
images = p.ImagesJSON
}
if price <= 0 && p != nil {
price = p.Price
}
sh := shipMap[r.ID]
@ -217,6 +221,7 @@ func (s *service) ListInventoryAggregated(ctx context.Context, userID int64, pag
ProductID int64 `gorm:"column:product_id"`
Status int32 `gorm:"column:status"`
Count int64 `gorm:"column:count"`
ValueCents int64 `gorm:"column:value_cents"`
UpdatedAt time.Time `gorm:"column:updated_at"`
}
@ -225,6 +230,7 @@ func (s *service) ListInventoryAggregated(ctx context.Context, userID int64, pag
s.readDB.UserInventory.ProductID,
s.readDB.UserInventory.Status,
s.readDB.UserInventory.ID.Count().As("count"),
s.readDB.UserInventory.ValueCents.Max().As("value_cents"),
s.readDB.UserInventory.UpdatedAt.Max().As("updated_at"),
).
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()
name := "未知商品"
images := ""
var price int64
price := g.ValueCents
if p != nil {
name = p.Name
images = p.ImagesJSON
}
if price <= 0 && p != nil {
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 {
logger.Info("开始事务处理")
var rewardSetting *model.ActivityRewardSettings
var err error
// 1. 检查奖励配置库存如果提供了reward_id
if req.RewardID != nil {
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),
).First()
if err != nil {
@ -109,7 +111,7 @@ func (s *service) GrantReward(ctx context.Context, userID int64, req GrantReward
}
logger.Info("创建订单", zap.Any("order", order))
err := tx.Orders.WithContext(ctx).Create(order)
err = tx.Orders.WithContext(ctx).Create(order)
if err != nil {
logger.Error("创建订单失败", zap.Error(err))
return fmt.Errorf("创建订单失败: %w", err)
@ -163,6 +165,24 @@ func (s *service) GrantReward(ctx context.Context, userID int64, req GrantReward
inventory := &model.UserInventory{
UserID: userID,
ProductID: req.ProductID,
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 {
if req.ActivityID != nil {
@ -288,6 +308,7 @@ func (s *service) GrantRewardToOrder(ctx context.Context, userID int64, req Gran
// 执行事务
err := s.writeDB.Transaction(func(tx *dao.Query) error {
logger.Info("开始事务处理")
var rewardSetting *model.ActivityRewardSettings
// 1. 验证订单存在且属于该用户
order, err := tx.Orders.WithContext(ctx).Where(
@ -322,6 +343,13 @@ func (s *service) GrantRewardToOrder(ctx context.Context, userID int64, req Gran
logger.Error("奖励库存不足或不存在")
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("奖励库存扣减成功(乐观锁)")
}
@ -355,6 +383,24 @@ func (s *service) GrantRewardToOrder(ctx context.Context, userID int64, req Gran
inventory := &model.UserInventory{
UserID: userID,
ProductID: req.ProductID,
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 {
if req.ActivityID != nil {

View File

@ -3,6 +3,7 @@ package user
import (
"context"
"fmt"
"time"
"bindbox-game/internal/repository/mysql/dao"
"bindbox-game/internal/repository/mysql/model"
@ -11,7 +12,8 @@ import (
// BatchRewardItem 批量发放的单个奖励项
type BatchRewardItem struct {
ProductID int64
RewardID *int64 // 可选,一番赏模式需要传入以扣减库存
RewardID *int64 // 用于资产归因/价值快照
DeductRewardStock bool // 是否按 RewardID 扣减奖池库存(仅一番赏)
ActivityID int64
Remark string
}
@ -60,6 +62,26 @@ func (s *service) BatchGrantRewardsToOrder(ctx context.Context, userID int64, or
for _, p := range products {
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. 批量创建订单项和库存记录
var orderItems []*model.OrderItems
@ -85,6 +107,30 @@ func (s *service) BatchGrantRewardsToOrder(ctx context.Context, userID int64, or
inventories = append(inventories, &model.UserInventory{
UserID: userID,
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,
ActivityID: item.ActivityID,
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)
for _, item := range items {
if item.RewardID != nil {
if item.DeductRewardStock && item.RewardID != nil && *item.RewardID > 0 {
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)
}
}