From 46a72532398358e3ec2b386ba821b961ef800117 Mon Sep 17 00:00:00 2001 From: win Date: Fri, 27 Feb 2026 00:08:02 +0800 Subject: [PATCH] =?UTF-8?q?fix:=E8=AE=A2=E5=8D=95=E5=90=8C=E6=AD=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- cmd/douyin_sync_debug/main.go | 139 +++++ internal/api/admin/dashboard_admin.go | 14 +- internal/api/admin/dashboard_spending.go | 207 ++++++-- internal/api/admin/dashboard_user_spending.go | 154 +++++- internal/api/admin/douyin_orders_admin.go | 114 +++- internal/api/admin/livestream_admin.go | 14 +- internal/api/admin/users_admin.go | 33 +- internal/api/admin/users_admin_optimized.go | 70 +-- internal/api/admin/users_profile.go | 14 +- internal/api/admin/users_profit_loss.go | 150 ++++-- internal/api/task_center/admin.go | 6 +- internal/api/user/login_app.go | 9 +- .../mysql/dao/livestream_activities.gen.go | 10 +- .../mysql/model/douyin_reward_logs.gen.go | 37 ++ .../mysql/model/livestream_activities.gen.go | 2 + internal/router/router.go | 1 + internal/service/activity/lottery_process.go | 14 +- internal/service/channel/channel.go | 20 + internal/service/douyin/order_sync.go | 465 +++++++++++++++-- internal/service/douyin/scheduler.go | 167 +++++- internal/service/finance/profit_metrics.go | 81 +++ .../service/finance/profit_metrics_test.go | 57 ++ internal/service/livestream/livestream.go | 12 + .../service/task_center/invite_logic_test.go | 8 +- internal/service/task_center/service.go | 486 ++++++++++-------- internal/service/task_center/service_test.go | 246 +++++++++ .../service/task_center/task_center_test.go | 14 + internal/service/user/reward_grant_batch.go | 13 +- ..._inventory_reward_value_from_draw_logs.sql | 85 +++ ...hannel_fields_to_livestream_activities.sql | 14 + .../20260226_create_douyin_reward_logs.sql | 15 + scripts/douyin_dump_ids/main.go | 92 ++++ 32 files changed, 2296 insertions(+), 467 deletions(-) create mode 100644 cmd/douyin_sync_debug/main.go create mode 100644 internal/repository/mysql/model/douyin_reward_logs.gen.go create mode 100644 internal/service/finance/profit_metrics.go create mode 100644 internal/service/finance/profit_metrics_test.go create mode 100644 migrations/20260221_backfill_inventory_reward_value_from_draw_logs.sql create mode 100644 migrations/20260223_add_channel_fields_to_livestream_activities.sql create mode 100644 migrations/20260226_create_douyin_reward_logs.sql create mode 100644 scripts/douyin_dump_ids/main.go diff --git a/cmd/douyin_sync_debug/main.go b/cmd/douyin_sync_debug/main.go new file mode 100644 index 0000000..708aee3 --- /dev/null +++ b/cmd/douyin_sync_debug/main.go @@ -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("开始 SyncAllOrders,duration=%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) + } + } +} diff --git a/internal/api/admin/dashboard_admin.go b/internal/api/admin/dashboard_admin.go index ac8958c..ab939e6 100755 --- a/internal/api/admin/dashboard_admin.go +++ b/internal/api/admin/dashboard_admin.go @@ -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)) diff --git a/internal/api/admin/dashboard_spending.go b/internal/api/admin/dashboard_spending.go index 9ccfe9b..eafac06 100755 --- a/internal/api/admin/dashboard_spending.go +++ b/internal/api/admin/dashboard_spending.go @@ -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" ) @@ -21,16 +23,21 @@ type spendingLeaderboardRequest struct { } type spendingLeaderboardItem struct { - UserID int64 `json:"user_id"` - Nickname string `json:"nickname"` - Avatar string `json:"avatar"` - OrderCount int64 `json:"-"` // Hidden - TotalSpending int64 `json:"-"` // Hidden - TotalPrizeValue int64 `json:"-"` // Hidden - TotalDiscount int64 `json:"total_discount"` // Total Coupon Discount (Fen) - TotalPoints int64 `json:"total_points"` // Total Points Discount (Fen) - GamePassCount int64 `json:"game_pass_count"` // Count of SourceType=4 - ItemCardCount int64 `json:"item_card_count"` // Count where ItemCardID > 0 + UserID int64 `json:"user_id"` + Nickname string `json:"nickname"` + Avatar string `json:"avatar"` + 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 + ItemCardCount int64 `json:"item_card_count"` // Count where ItemCardID > 0 // Breakdown by game type IchibanSpending int64 `json:"ichiban_spending"` IchibanPrize int64 `json:"ichiban_prize"` @@ -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,8 +247,13 @@ func (h *handler) DashboardPlayerSpendingLeaderboard() core.HandlerFunc { // Join with Products, Activities, and Orders (for livestream detection) query := db.Table(model.TableNameUserInventory). - 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" { @@ -226,15 +262,15 @@ 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(user_inventory.value_cents) as total_value, - SUM(CASE WHEN activities.activity_category_id = 1 THEN user_inventory.value_cents ELSE 0 END) as ichiban_prize, - SUM(CASE WHEN activities.activity_category_id = 2 THEN user_inventory.value_cents ELSE 0 END) as infinite_prize, - SUM(CASE WHEN activities.activity_category_id = 3 THEN user_inventory.value_cents ELSE 0 END) as matching_prize - `). + user_inventory.user_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_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 @@ -247,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 { - UserID int64 - Amount int64 + // 4.1 Calculate Livestream Prize Value (snapshot priority; fallback prize cost) + type lsLog struct { + UserID 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 } } } @@ -293,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) } diff --git a/internal/api/admin/dashboard_user_spending.go b/internal/api/admin/dashboard_user_spending.go index 0ec1a05..6c0f5d5 100755 --- a/internal/api/admin/dashboard_user_spending.go +++ b/internal/api/admin/dashboard_user_spending.go @@ -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,20 +135,28 @@ func (h *handler) GetUserSpendingDashboard() core.HandlerFunc { var prizeStats []prizeStat prizeQuery := db.Table(model.TableNameUserInventory). + 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(user_inventory.value_cents) as prize_value - `). - Group("COALESCE(user_inventory.activity_id, 0)"). - Scan(&prizeStats) + 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(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 { @@ -172,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") - - if hasRange { - lsPrizeQuery = lsPrizeQuery.Where("livestream_draw_logs.created_at >= ?", start).Where("livestream_draw_logs.created_at <= ?", end) + 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 { + lsLogQuery = lsLogQuery.Where("created_at >= ?", start).Where("created_at <= ?", end) + } + _ = lsLogQuery.Scan(&lsLogs).Error - lsPrizeQuery.Group("livestream_draw_logs.livestream_activity_id").Scan(&lsPrizeStats) + if len(lsLogs) > 0 { + prizeIDSet := make(map[int64]struct{}) + for _, l := range lsLogs { + prizeIDSet[l.PrizeID] = struct{}{} + } + prizeIDs := make([]int64, 0, len(prizeIDSet)) + for pid := range prizeIDSet { + prizeIDs = append(prizeIDs, pid) + } + prizeCostMap := make(map[int64]int64) + if len(prizeIDs) > 0 { + var prizes []struct { + ID int64 + CostPrice int64 + } + _ = h.repo.GetDbR().Table("livestream_prizes").Select("id, cost_price").Where("id IN ?", prizeIDs).Scan(&prizes).Error + for _, p := range prizes { + prizeCostMap[p.ID] = p.CostPrice + } + } + + type invRow struct { + ValueCents int64 + Remark string + } + var invRows []invRow + invQ := h.repo.GetDbR().Table("user_inventory"). + Select("COALESCE(NULLIF(user_inventory.value_cents, 0), activity_reward_settings.price_snapshot_cents, products.price, 0) as value_cents, user_inventory.remark"). + Joins("LEFT JOIN activity_reward_settings ON activity_reward_settings.id = user_inventory.reward_id"). + Joins("LEFT JOIN products ON products.id = user_inventory.product_id"). + Where("user_id = ?", userID). + Where("status IN (1,3)"). + Where("COALESCE(user_inventory.remark, '') NOT LIKE ?", "%void%") + if hasRange { + invQ = invQ.Where("created_at >= ?", start.Add(-2*time.Hour)).Where("created_at <= ?", end.Add(24*time.Hour)) + } + _ = invQ.Scan(&invRows).Error + + lsByKey := make(map[string][]lsLog) + for _, l := range lsLogs { + key := fmt.Sprintf("%d|%s", l.ActivityID, l.ShopOrderID) + lsByKey[key] = append(lsByKey[key], l) + } + + prizeByActivity := make(map[int64]int64) + for _, logs := range lsByKey { + if len(logs) == 0 { + continue + } + aid := logs[0].ActivityID + shopOrderID := logs[0].ShopOrderID + + var snapshotSum int64 + if shopOrderID != "" { + for _, inv := range invRows { + if strings.Contains(inv.Remark, shopOrderID) { + snapshotSum += inv.ValueCents + } + } + } + if snapshotSum > 0 { + prizeByActivity[aid] += snapshotSum + continue + } + for _, l := range logs { + prizeByActivity[aid] += prizeCostMap[l.PrizeID] + } + } + + for aid, val := range prizeByActivity { + lsPrizeStats = append(lsPrizeStats, lsPrizeStat{ + ActivityID: aid, + PrizeValue: val, + }) + } + } lsPrizeMap := make(map[int64]int64) for _, p := range lsPrizeStats { @@ -210,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) @@ -229,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) @@ -240,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 diff --git a/internal/api/admin/douyin_orders_admin.go b/internal/api/admin/douyin_orders_admin.go index 592e7a6..cb6d05b 100755 --- a/internal/api/admin/douyin_orders_admin.go +++ b/internal/api/admin/douyin_orders_admin.go @@ -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 } @@ -58,9 +64,12 @@ func (h *handler) SaveDouyinConfig() core.HandlerFunc { // ---------- 抖店订单列表 API ---------- type listDouyinOrdersRequest struct { - Page int `form:"page"` - PageSize int `form:"page_size"` - Status *int `form:"status"` + 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 @@ -154,30 +172,66 @@ func (h *handler) ListDouyinOrders() core.HandlerFunc { // ---------- 手动同步 API ---------- type syncDouyinOrdersResponse struct { - Message string `json:"message"` - TotalFetched int `json:"total_fetched"` - NewOrders int `json:"new_orders"` - MatchedUsers int `json:"matched_users"` + Message string `json:"message"` + 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: "同步成功", - TotalFetched: result.TotalFetched, - NewOrders: result.NewOrders, - MatchedUsers: result.MatchedUsers, + 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 { diff --git a/internal/api/admin/livestream_admin.go b/internal/api/admin/livestream_admin.go index 2b706d3..ef84eea 100755 --- a/internal/api/admin/livestream_admin.go +++ b/internal/api/admin/livestream_admin.go @@ -915,13 +915,13 @@ func (h *handler) ListLivestreamDrawLogs() core.HandlerFunc { Remark string } var invRows []invRow - _ = h.repo.GetDbR().Table("user_inventory"). - Select("user_inventory.user_id, COALESCE(NULLIF(user_inventory.value_cents, 0), activity_reward_settings.price_snapshot_cents, products.price, 0) as value_cents, user_inventory.remark"). - Joins("LEFT JOIN activity_reward_settings ON activity_reward_settings.id = user_inventory.reward_id"). - Joins("LEFT JOIN products ON products.id = user_inventory.product_id"). - Where("status IN (1,3)"). - Where("COALESCE(user_inventory.remark, '') NOT LIKE ?", "%void%"). - Where("user_id > 0"). + _ = 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 { diff --git a/internal/api/admin/users_admin.go b/internal/api/admin/users_admin.go index aaaaa94..774f6f3 100755 --- a/internal/api/admin/users_admin.go +++ b/internal/api/admin/users_admin.go @@ -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.UserInventory.ValueCents.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,8 +564,9 @@ func (h *handler) ListUserInvites() core.HandlerFunc { `, userID).Scan(&summaryConsume).Error // 资产价值汇总(不包含已兑换的商品) _ = h.repo.GetDbR().Raw(` - SELECT COALESCE(SUM(ui.value_cents), 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 = ?) AND ui.status != 2 `, userID).Scan(&summaryAsset).Error @@ -762,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, COALESCE(ui.value_cents, p.price, 0) 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 = ? diff --git a/internal/api/admin/users_admin_optimized.go b/internal/api/admin/users_admin_optimized.go index da86920..46a37e9 100755 --- a/internal/api/admin/users_admin_optimized.go +++ b/internal/api/admin/users_admin_optimized.go @@ -12,36 +12,36 @@ import ( // userStatsAggregated 用户统计聚合结果(单次SQL查询返回) type userStatsAggregated struct { - UserID int64 - Nickname string - Avatar string - InviteCode string - InviterID int64 - InviterNickname string - CreatedAt time.Time - DouyinID string - DouyinUserID string - Mobile string - Remark string - ChannelName string - ChannelCode string - Status int32 + UserID int64 + Nickname string + Avatar string + InviteCode string + InviterID int64 + InviterNickname string + CreatedAt time.Time + DouyinID string + DouyinUserID string + Mobile string + Remark string + ChannelName string + ChannelCode string + Status int32 // 聚合统计字段 - PointsBalance int64 - CouponsCount int64 - ItemCardsCount int64 - TodayConsume int64 - SevenDayConsume int64 - ThirtyDayConsume int64 - TotalConsume int64 - InviteCount int64 - InviteeTotalConsume int64 - GamePassCount int64 - GameTicketCount int64 - InventoryValue int64 - CouponValue int64 - ItemCardValue int64 + PointsBalance int64 + CouponsCount int64 + ItemCardsCount int64 + TodayConsume int64 + SevenDayConsume int64 + ThirtyDayConsume int64 + TotalConsume int64 + InviteCount int64 + InviteeTotalConsume int64 + GamePassCount int64 + GameTicketCount int64 + InventoryValue int64 + CouponValue int64 + ItemCardValue int64 } // ListAppUsersOptimized 优化后的用户列表查询(单次SQL,性能提升83%) @@ -223,7 +223,7 @@ func (h *handler) ListAppUsersOptimized() core.HandlerFunc { (SELECT COALESCE(SUM(available), 0) FROM user_game_tickets WHERE user_id = u.id) AS game_ticket_count, -- 持有商品价值 - (SELECT COALESCE(SUM(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 @@ -254,13 +254,13 @@ func (h *handler) ListAppUsersOptimized() core.HandlerFunc { // 构建完整参数列表 queryArgs := []interface{}{ - todayStart, // today_consume - sevenDayStart, // seven_day_consume - thirtyDayStart, // thirty_day_consume - now, // game_pass_count + todayStart, // today_consume + sevenDayStart, // seven_day_consume + thirtyDayStart, // thirty_day_consume + now, // game_pass_count } - queryArgs = append(queryArgs, args...) // WHERE 条件参数 - queryArgs = append(queryArgs, req.PageSize) // LIMIT + queryArgs = append(queryArgs, args...) // WHERE 条件参数 + queryArgs = append(queryArgs, req.PageSize) // LIMIT queryArgs = append(queryArgs, (req.Page-1)*req.PageSize) // OFFSET // 执行查询 diff --git a/internal/api/admin/users_profile.go b/internal/api/admin/users_profile.go index 67198d7..9eeb6ed 100755 --- a/internal/api/admin/users_profile.go +++ b/internal/api/admin/users_profile.go @@ -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 diff --git a/internal/api/admin/users_profit_loss.go b/internal/api/admin/users_profit_loss.go index ae72250..18ffa82 100755 --- a/internal/api/admin/users_profit_loss.go +++ b/internal/api/admin/users_profit_loss.go @@ -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 + 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 + GROUP BY ui.order_id + `, 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{ diff --git a/internal/api/task_center/admin.go b/internal/api/task_center/admin.go index 4990ac5..236d9b7 100755 --- a/internal/api/task_center/admin.go +++ b/internal/api/task_center/admin.go @@ -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 } diff --git a/internal/api/user/login_app.go b/internal/api/user/login_app.go index 2f9f155..225ee84 100755 --- a/internal/api/user/login_app.go +++ b/internal/api/user/login_app.go @@ -19,9 +19,10 @@ import ( ) type weixinLoginRequest struct { - Code string `json:"code"` - InviteCode string `json:"invite_code"` - DouyinID string `json:"douyin_id"` + 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())) diff --git a/internal/repository/mysql/dao/livestream_activities.gen.go b/internal/repository/mysql/dao/livestream_activities.gen.go index aa823e2..2c0b337 100755 --- a/internal/repository/mysql/dao/livestream_activities.gen.go +++ b/internal/repository/mysql/dao/livestream_activities.gen.go @@ -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 diff --git a/internal/repository/mysql/model/douyin_reward_logs.gen.go b/internal/repository/mysql/model/douyin_reward_logs.gen.go new file mode 100644 index 0000000..079e496 --- /dev/null +++ b/internal/repository/mysql/model/douyin_reward_logs.gen.go @@ -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" +} diff --git a/internal/repository/mysql/model/livestream_activities.gen.go b/internal/repository/mysql/model/livestream_activities.gen.go index 780936b..0d0da78 100755 --- a/internal/repository/mysql/model/livestream_activities.gen.go +++ b/internal/repository/mysql/model/livestream_activities.gen.go @@ -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"` // 下单奖励类型 diff --git a/internal/router/router.go b/internal/router/router.go index c319f9b..453d0ac 100755 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -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()) diff --git a/internal/service/activity/lottery_process.go b/internal/service/activity/lottery_process.go index 34d3920..31791ee 100755 --- a/internal/service/activity/lottery_process.go +++ b/internal/service/activity/lottery_process.go @@ -190,15 +190,13 @@ 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, - ActivityID: aid, - Remark: productNameMap[rw.ProductID], + ProductID: rw.ProductID, + RewardID: rewardIDRef, + DeductRewardStock: act != nil && act.PlayType == "ichiban", + ActivityID: aid, + Remark: productNameMap[rw.ProductID], }) invCountMap[log.RewardID]++ // 内存计数同步 diff --git a/internal/service/channel/channel.go b/internal/service/channel/channel.go index bc99707..b6ea1ac 100755 --- a/internal/service/channel/channel.go +++ b/internal/service/channel/channel.go @@ -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 +} diff --git a/internal/service/douyin/order_sync.go b/internal/service/douyin/order_sync.go index 20ec326..910e8ec 100755 --- a/internal/service/douyin/order_sync.go +++ b/internal/service/douyin/order_sync.go @@ -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,19 +56,141 @@ 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 { - TotalFetched int `json:"total_fetched"` - NewOrders int `json:"new_orders"` - MatchedUsers int `json:"matched_users"` - Orders []*model.DouyinOrders `json:"orders"` // 新增:返回详情以供后续处理 - DebugInfo string `json:"debug_info"` + TotalFetched int `json:"total_fetched"` + NewOrders int `json:"new_orders"` + 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) + if len(users) == 0 { + result.ElapsedMS = time.Since(startAt).Milliseconds() + result.DebugInfo = "未找到符合条件的用户" + return result, nil + } - orders, err := s.fetchDouyinOrdersByBuyer(cfg.Cookie, u.DouyinUserID) - if err != nil { - fmt.Printf("[DEBUG] 抓取用户 %s 订单失败: %v\n", u.DouyinUserID, err) - continue + var mu sync.Mutex + + syncUser := func(u model.Users) { + select { + case <-ctx.Done(): + return + default: } - result.TotalFetched += len(orders) + s.logger.Info("[抖店同步] 开始同步用户订单", + zap.Int64("user_id", u.ID), + zap.String("nickname", u.Nickname), + zap.String("douyin_user_id", u.DouyinUserID)) - // 3. 同步 + orders, err := s.fetchDouyinOrdersByBuyer(cfg.Cookie, u.DouyinUserID, cfg.Proxy) + if err != nil { + s.logger.Warn("[抖店同步] 抓取用户订单失败", + zap.String("douyin_user_id", u.DouyinUserID), + zap.Error(err)) + mu.Lock() + result.SkippedUsers++ + mu.Unlock() + return + } + + perUserNew := 0 + perUserMatched := 0 for _, order := range orders { - // 同步订单(传入建议关联的用户 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 { diff --git a/internal/service/douyin/scheduler.go b/internal/service/douyin/scheduler.go index 8e8ae19..7585acf 100755 --- a/internal/service/douyin/scheduler.go +++ b/internal/service/douyin/scheduler.go @@ -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,11 +180,36 @@ 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)) - continue + 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 { @@ -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. 自动虚拟发货 (本地状态更新) // 直播间奖品通常为虚拟发货,直接标记为已消费/已发货 @@ -381,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)) +} diff --git a/internal/service/finance/profit_metrics.go b/internal/service/finance/profit_metrics.go new file mode 100644 index 0000000..10e52d2 --- /dev/null +++ b/internal/service/finance/profit_metrics.go @@ -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) +} diff --git a/internal/service/finance/profit_metrics_test.go b/internal/service/finance/profit_metrics_test.go new file mode 100644 index 0000000..09e2d03 --- /dev/null +++ b/internal/service/finance/profit_metrics_test.go @@ -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) + } +} diff --git a/internal/service/livestream/livestream.go b/internal/service/livestream/livestream.go index 407d4c5..8ec1f2d 100755 --- a/internal/service/livestream/livestream.go +++ b/internal/service/livestream/livestream.go @@ -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 } diff --git a/internal/service/task_center/invite_logic_test.go b/internal/service/task_center/invite_logic_test.go index fd092be..5113db8 100755 --- a/internal/service/task_center/invite_logic_test.go +++ b/internal/service/task_center/invite_logic_test.go @@ -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)") diff --git a/internal/service/task_center/service.go b/internal/service/task_center/service.go index 37cac5c..bc1d0a9 100755 --- a/internal/service/task_center/service.go +++ b/internal/service/task_center/service.go @@ -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) - - db.Raw(` - SELECT COALESCE(SUM(total_amount), 0) - FROM orders - WHERE user_id = ? AND status = 2 AND source_type != 1 - AND id IN ( - SELECT DISTINCT dl.order_id - FROM activity_draw_logs dl - INNER JOIN activity_issues ai ON ai.id = dl.issue_id - WHERE ai.activity_id = ? - )`+timeCond, baseArgs...).Scan(&gOrderAmount) - - // 邀请计数:将 orders.created_at 改为 o.created_at(别名) - inviteTimeCond := strings.ReplaceAll(timeCond, "orders.created_at", "o.created_at") - inviteArgs := append([]interface{}{userID, wk.ActivityID}, timeArgs...) - db.Raw(` - SELECT COUNT(DISTINCT ui.invitee_id) - FROM user_invites ui - INNER JOIN orders o ON o.user_id = ui.invitee_id AND o.status = 2 AND o.source_type != 1 - WHERE ui.inviter_id = ? - AND o.id IN ( - SELECT DISTINCT dl.order_id - FROM activity_draw_logs dl - INNER JOIN activity_issues ai ON ai.id = dl.issue_id - WHERE ai.activity_id = ? - )`+inviteTimeCond, inviteArgs...).Scan(&gInviteCount) - } else { - // 无活动限制:统计所有已开奖的非商城订单,追加时效过滤 - globalCond := "user_id = ? AND status = 2 AND source_type != 1 AND EXISTS (SELECT 1 FROM activity_draw_logs WHERE activity_draw_logs.order_id = orders.id)" + timeCond - globalArgs := append([]interface{}{userID}, timeArgs...) - db.Model(&model.Orders{}).Where(globalCond, globalArgs...).Count(&gOrderCount) - db.Model(&model.Orders{}).Select("COALESCE(SUM(total_amount), 0)").Where(globalCond, globalArgs...).Scan(&gOrderAmount) - - inviteWhere := "inviter_id = ?" - if wStart != nil { - inviteWhere += " AND created_at >= ?" - } - if wEnd != nil { - inviteWhere += " AND created_at <= ?" - } - db.Model(&model.UserInvites{}).Where(inviteWhere, globalArgs...).Count(&gInviteCount) + 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 } - for _, t := range groupTiers { - tierProgressMap[t.ID] = TierProgress{ - TierID: t.ID, - OrderCount: gOrderCount, - OrderAmount: gOrderAmount, - InviteCount: gInviteCount, - FirstOrder: gOrderCount > 0, + for _, tier := range groupTiers { + tierProgressMap[tier.ID] = TierProgress{ + TierID: tier.ID, + OrderCount: orderCount, + OrderAmount: orderAmount, + InviteCount: inviteCount, + FirstOrder: orderCount > 0, } } } - // ── 向后兼容:全局统计(不限时间窗口,用于顶层字段 OrderCount/InviteCount 和 SubProgress)── - var orderCount int64 - var orderAmount int64 + var ( + allRows []orderMetricRow + err error + ) + if len(targetActivityIDs) > 0 { + allRows, err = s.fetchOrderMetricRows(ctx, userID, targetActivityIDs, nil, nil) + } else { + allRows, err = s.fetchOrderMetricRows(ctx, userID, nil, nil, nil) + } + if err != nil { + return nil, err + } + orderCount, orderAmount := s.aggregateOrderMetrics(allRows, false) + var subProgressList []ActivityProgress - 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,47 +1056,59 @@ 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 { - toCreate = append(toCreate, tcmodel.TaskReward{ - TaskID: taskID, - TierID: r.TierID, - RewardType: r.RewardType, - RewardPayload: r.RewardPayload, - Quantity: r.Quantity, - }) + seen[r.ID] = struct{}{} + continue } + + toCreate = append(toCreate, tcmodel.TaskReward{ + TaskID: taskID, + TierID: r.TierID, + RewardType: r.RewardType, + RewardPayload: r.RewardPayload, + 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) + } } } diff --git a/internal/service/task_center/service_test.go b/internal/service/task_center/service_test.go index 1ca94c4..b868582 100644 --- a/internal/service/task_center/service_test.go +++ b/internal/service/task_center/service_test.go @@ -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) + } +} diff --git a/internal/service/task_center/task_center_test.go b/internal/service/task_center/task_center_test.go index d9e5c91..9552634 100755 --- a/internal/service/task_center/task_center_test.go +++ b/internal/service/task_center/task_center_test.go @@ -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 插入一个完整的任务配置(任务+档位+奖励) diff --git a/internal/service/user/reward_grant_batch.go b/internal/service/user/reward_grant_batch.go index 0654f17..380280d 100755 --- a/internal/service/user/reward_grant_batch.go +++ b/internal/service/user/reward_grant_batch.go @@ -11,10 +11,11 @@ import ( // BatchRewardItem 批量发放的单个奖励项 type BatchRewardItem struct { - ProductID int64 - RewardID *int64 // 可选,一番赏模式需要传入以扣减库存 - ActivityID int64 - Remark string + ProductID int64 + RewardID *int64 // 用于资产归因/价值快照 + DeductRewardStock bool // 是否按 RewardID 扣减奖池库存(仅一番赏) + ActivityID int64 + Remark string } // BatchGrantRewardsToOrder 批量发放奖励到订单(单事务处理) @@ -163,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]++ } } diff --git a/migrations/20260221_backfill_inventory_reward_value_from_draw_logs.sql b/migrations/20260221_backfill_inventory_reward_value_from_draw_logs.sql new file mode 100644 index 0000000..dec6d6f --- /dev/null +++ b/migrations/20260221_backfill_inventory_reward_value_from_draw_logs.sql @@ -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; diff --git a/migrations/20260223_add_channel_fields_to_livestream_activities.sql b/migrations/20260223_add_channel_fields_to_livestream_activities.sql new file mode 100644 index 0000000..397caaf --- /dev/null +++ b/migrations/20260223_add_channel_fields_to_livestream_activities.sql @@ -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`; diff --git a/migrations/20260226_create_douyin_reward_logs.sql b/migrations/20260226_create_douyin_reward_logs.sql new file mode 100644 index 0000000..07bba1f --- /dev/null +++ b/migrations/20260226_create_douyin_reward_logs.sql @@ -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); diff --git a/scripts/douyin_dump_ids/main.go b/scripts/douyin_dump_ids/main.go new file mode 100644 index 0000000..3e9f6e3 --- /dev/null +++ b/scripts/douyin_dump_ids/main.go @@ -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) + } +}