fix:订单同步

This commit is contained in:
win 2026-02-27 00:08:02 +08:00
parent 9972427cea
commit 46a7253239
32 changed files with 2296 additions and 467 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -919,9 +919,9 @@ func (h *handler) ListLivestreamDrawLogs() core.HandlerFunc {
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("user_inventory.status IN (1,3)").
Where("COALESCE(user_inventory.remark, '') NOT LIKE ?", "%void%").
Where("user_id > 0").
Where("user_inventory.user_id > 0").
Scan(&invRows).Error
invByUser := make(map[int64][]invRow)
for _, v := range invRows {

View File

@ -342,13 +342,13 @@ func (h *handler) ListAppUsers() core.HandlerFunc {
Value int64
}
var invRes []invResult
h.readDB.UserInventory.WithContext(ctx.RequestContext()).ReadDB().
LeftJoin(h.readDB.Products, h.readDB.Products.ID.EqCol(h.readDB.UserInventory.ProductID)).
Select(h.readDB.UserInventory.UserID, h.readDB.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 = ?

View File

@ -223,7 +223,7 @@ func (h *handler) ListAppUsersOptimized() core.HandlerFunc {
(SELECT COALESCE(SUM(available), 0) FROM user_game_tickets WHERE user_id = u.id) AS game_ticket_count,
-- 持有商品价值
(SELECT COALESCE(SUM(p.price), 0)
(SELECT COALESCE(SUM(COALESCE(NULLIF(ui.value_cents, 0), p.price, 0)), 0)
FROM user_inventory ui
LEFT JOIN products p ON p.id = ui.product_id
WHERE ui.user_id = u.id AND ui.status = 1

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,6 +1,8 @@
package douyin
import (
"errors"
"bindbox-game/internal/pkg/logger"
"bindbox-game/internal/repository/mysql"
"bindbox-game/internal/repository/mysql/model"
@ -14,6 +16,8 @@ import (
"go.uber.org/zap"
"bindbox-game/internal/service/user"
"gorm.io/gorm"
)
// StartDouyinOrderSync 启动抖店订单定时同步任务
@ -157,6 +161,8 @@ func (s *service) GrantLivestreamPrizes(ctx context.Context) error {
return err
}
anchorCodes := s.resolveActivityAnchorCodes(ctx, logs)
for _, log := range logs {
// 必须要有对应的本地用户ID
if log.LocalUserID == 0 {
@ -174,12 +180,37 @@ func (s *service) GrantLivestreamPrizes(ctx context.Context) error {
continue // 还没关联到用户,跳过
}
if code := anchorCodes[log.ActivityID]; code != "" {
s.bindAnchorInviterIfNeeded(ctx, log.LocalUserID, code)
}
// 2. 查奖品关联的 ProductID
var prize model.LivestreamPrizes
if err := s.repo.GetDbR().Where("id = ?", log.PrizeID).First(&prize).Error; err != nil {
s.logger.Error("[自动发放] 奖品不存在", zap.Int64("prize_id", log.PrizeID))
if errors.Is(err, gorm.ErrRecordNotFound) {
if log.ProductID > 0 {
prize = model.LivestreamPrizes{
ID: log.PrizeID,
Name: log.PrizeName,
ProductID: log.ProductID,
}
s.logger.Warn("[自动发放] 奖品配置缺失,使用快照兜底",
zap.Int64("prize_id", log.PrizeID),
zap.Int64("product_id", log.ProductID),
zap.Int64("log_id", log.ID))
} else {
s.logger.Error("[自动发放] 奖品不存在且缺少快照",
zap.Int64("prize_id", log.PrizeID),
zap.Int64("log_id", log.ID))
continue
}
} else {
s.logger.Error("[自动发放] 查询奖品失败",
zap.Int64("prize_id", log.PrizeID),
zap.Error(err))
continue
}
}
if prize.ProductID == 0 {
s.logger.Warn("[自动发放] 奖品未关联商品ID跳过", zap.Int64("prize_id", log.PrizeID), zap.String("name", prize.Name))
@ -204,11 +235,13 @@ func (s *service) GrantLivestreamPrizes(ctx context.Context) error {
res, err := s.userSvc.GrantReward(ctx, log.LocalUserID, req)
if err != nil {
s.logger.Error("[自动发放] 发放失败", zap.Error(err))
s.logRewardResult(ctx, log.ShopOrderID, log.DouyinUserID, log.LocalUserID, fmt.Sprintf("%d", prize.ProductID), log.PrizeID, "auto", "failed", err.Error())
// 如果发放失败是库存原因等,可能需要告警。暂时不重试,等下个周期。
} else {
// 4. 更新发放状态
db.Model(&log).Update("is_granted", 1)
s.logger.Info("[自动发放] 发放成功", zap.Int64("log_id", log.ID))
s.logRewardResult(ctx, log.ShopOrderID, log.DouyinUserID, log.LocalUserID, fmt.Sprintf("%d", prize.ProductID), log.PrizeID, "auto", "success", "发放成功")
// 5. 自动虚拟发货 (本地状态更新)
// 直播间奖品通常为虚拟发货,直接标记为已消费/已发货
@ -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))
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -12,7 +12,8 @@ import (
// BatchRewardItem 批量发放的单个奖励项
type BatchRewardItem struct {
ProductID int64
RewardID *int64 // 可选,一番赏模式需要传入以扣减库存
RewardID *int64 // 用于资产归因/价值快照
DeductRewardStock bool // 是否按 RewardID 扣减奖池库存(仅一番赏)
ActivityID int64
Remark string
}
@ -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]++
}
}

View File

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

View File

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

View File

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

View File

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