fix:订单同步
This commit is contained in:
parent
9972427cea
commit
46a7253239
139
cmd/douyin_sync_debug/main.go
Normal file
139
cmd/douyin_sync_debug/main.go
Normal 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("开始 SyncAllOrders,duration=%s proxy=%v ...\n", duration, *useProxy)
|
||||
result, err := svc.SyncAllOrders(ctx, duration, *useProxy)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
fmt.Printf("完成:抓取 %d,新订单 %d,匹配 %d。\n", result.TotalFetched, result.NewOrders, result.MatchedUsers)
|
||||
}
|
||||
|
||||
if *grantMinesweeper {
|
||||
fmt.Println("执行 GrantMinesweeperQualifications ...")
|
||||
if err := svc.GrantMinesweeperQualifications(ctx); err != nil {
|
||||
fmt.Printf("GrantMinesweeperQualifications 失败: %v\n", err)
|
||||
} else {
|
||||
fmt.Println("GrantMinesweeperQualifications 完成。")
|
||||
}
|
||||
}
|
||||
|
||||
if *printLimit > 0 {
|
||||
var orders []model.DouyinOrders
|
||||
if err := repo.GetDbR().Order("id DESC").Limit(*printLimit).Find(&orders).Error; err != nil {
|
||||
fmt.Printf("读取订单列表失败: %v\n", err)
|
||||
return
|
||||
}
|
||||
fmt.Println("shop_order_id\torder_status\tdouyin_user_id\tlocal_user_id")
|
||||
for _, o := range orders {
|
||||
fmt.Printf("%s\t%d\t%s\t%s\n", o.ShopOrderID, o.OrderStatus, o.DouyinUserID, o.LocalUserID)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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))
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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 = ?
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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{
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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()))
|
||||
|
||||
@ -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
|
||||
|
||||
37
internal/repository/mysql/model/douyin_reward_logs.gen.go
Normal file
37
internal/repository/mysql/model/douyin_reward_logs.gen.go
Normal 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"
|
||||
}
|
||||
@ -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"` // 下单奖励类型
|
||||
|
||||
@ -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())
|
||||
|
||||
@ -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],
|
||||
})
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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))
|
||||
}
|
||||
|
||||
81
internal/service/finance/profit_metrics.go
Normal file
81
internal/service/finance/profit_metrics.go
Normal 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)
|
||||
}
|
||||
57
internal/service/finance/profit_metrics_test.go
Normal file
57
internal/service/finance/profit_metrics_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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)")
|
||||
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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 插入一个完整的任务配置(任务+档位+奖励)
|
||||
|
||||
@ -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]++
|
||||
}
|
||||
}
|
||||
|
||||
@ -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;
|
||||
@ -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`;
|
||||
15
migrations/20260226_create_douyin_reward_logs.sql
Normal file
15
migrations/20260226_create_douyin_reward_logs.sql
Normal 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);
|
||||
92
scripts/douyin_dump_ids/main.go
Normal file
92
scripts/douyin_dump_ids/main.go
Normal 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)
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user