Compare commits
4 Commits
7e8a2ebb52
...
46a7253239
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
46a7253239 | ||
|
|
9972427cea | ||
|
|
8b7af03400 | ||
|
|
70e45b09ab |
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -5,6 +5,7 @@ import (
|
||||
"bindbox-game/internal/pkg/core"
|
||||
"bindbox-game/internal/pkg/validation"
|
||||
"bindbox-game/internal/repository/mysql/model"
|
||||
financesvc "bindbox-game/internal/service/finance"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
@ -35,6 +36,11 @@ type activityProfitLossItem struct {
|
||||
TotalDiscount int64 `json:"total_discount"` // 优惠券抵扣金额 (分)
|
||||
TotalGamePassValue int64 `json:"total_game_pass_value"` // 次卡价值 (分)
|
||||
TotalCost int64 `json:"total_cost"` // 奖品标价总和 (分)
|
||||
SpendingPaidCoupon int64 `json:"spending_paid_coupon"`
|
||||
SpendingGamePass int64 `json:"spending_game_pass"`
|
||||
PrizeCostBase int64 `json:"prize_cost_base"`
|
||||
PrizeCostMultiplier int64 `json:"prize_cost_multiplier"`
|
||||
PrizeCostFinal int64 `json:"prize_cost_final"`
|
||||
Profit int64 `json:"profit"` // (Revenue + Discount + GamePassValue) - Cost
|
||||
ProfitRate float64 `json:"profit_rate"` // Profit / (Revenue + Discount + GamePassValue)
|
||||
}
|
||||
@ -170,14 +176,19 @@ func (h *handler) DashboardActivityProfitLoss() core.HandlerFunc {
|
||||
}
|
||||
var revenueStats []revenueStat
|
||||
|
||||
// 修正: 按抽奖次数比例分摊订单金额 (解决多活动订单归因问题)
|
||||
// 逻辑: 活动分摊收入 = 订单实际金额 * (该活动在该订单中的抽奖次数 / 该订单总抽奖次数)
|
||||
// 修正: 按抽奖次数比例分摊订单金额,且次卡订单不计入支付+优惠券口径(严格二选一)
|
||||
var err error
|
||||
err = db.Table(model.TableNameOrders).
|
||||
Select(`
|
||||
order_activity_draws.activity_id,
|
||||
SUM(1.0 * orders.actual_amount * order_activity_draws.draw_count / order_total_draws.total_count) as total_revenue,
|
||||
SUM(CASE WHEN orders.source_type = 4 OR orders.order_no LIKE 'GP%' THEN 0 ELSE 1.0 * orders.discount_amount * order_activity_draws.draw_count / order_total_draws.total_count END) as total_discount
|
||||
SUM(CASE WHEN orders.source_type = 4 OR orders.order_no LIKE 'GP%' OR (orders.actual_amount = 0 AND orders.remark LIKE '%use_game_pass%')
|
||||
THEN 0
|
||||
ELSE 1.0 * orders.actual_amount * order_activity_draws.draw_count / order_total_draws.total_count
|
||||
END) as total_revenue,
|
||||
SUM(CASE WHEN orders.source_type = 4 OR orders.order_no LIKE 'GP%' OR (orders.actual_amount = 0 AND orders.remark LIKE '%use_game_pass%')
|
||||
THEN 0
|
||||
ELSE 1.0 * orders.discount_amount * order_activity_draws.draw_count / order_total_draws.total_count
|
||||
END) as total_discount
|
||||
`).
|
||||
// Subquery 1: Calculate draw counts per order per activity (and link to issue->activity)
|
||||
Joins(`JOIN (
|
||||
@ -213,20 +224,39 @@ func (h *handler) DashboardActivityProfitLoss() core.HandlerFunc {
|
||||
type costStat struct {
|
||||
ActivityID int64
|
||||
TotalCost int64
|
||||
TotalCostBase int64
|
||||
AvgMultiplierX10 int64
|
||||
}
|
||||
var costStats []costStat
|
||||
db.Table(model.TableNameUserInventory).
|
||||
Select("user_inventory.activity_id, SUM(products.price) as total_cost").
|
||||
Joins("JOIN products ON products.id = user_inventory.product_id").
|
||||
if err := db.Table(model.TableNameUserInventory).
|
||||
Select(`
|
||||
COALESCE(NULLIF(user_inventory.activity_id, 0), cost_issues.activity_id) as activity_id,
|
||||
CAST(SUM(COALESCE(NULLIF(user_inventory.value_cents, 0), activity_reward_settings.price_snapshot_cents, products.price, 0) * GREATEST(COALESCE(system_item_cards.reward_multiplier_x1000, 1000), 1000) / 1000) AS SIGNED) as total_cost,
|
||||
SUM(COALESCE(NULLIF(user_inventory.value_cents, 0), activity_reward_settings.price_snapshot_cents, products.price, 0)) as total_cost_base,
|
||||
CAST(COALESCE(AVG(GREATEST(COALESCE(system_item_cards.reward_multiplier_x1000, 1000), 1000) / 100), 10) AS SIGNED) as avg_multiplier_x10
|
||||
`).
|
||||
Joins("LEFT JOIN orders ON orders.id = user_inventory.order_id").
|
||||
Where("user_inventory.activity_id IN ?", activityIDs).
|
||||
Where("orders.status = ?", 2). // 仅统计已支付订单产生的成本
|
||||
Group("user_inventory.activity_id").
|
||||
Scan(&costStats)
|
||||
|
||||
Joins("LEFT JOIN activity_reward_settings ON activity_reward_settings.id = user_inventory.reward_id").
|
||||
Joins("LEFT JOIN activity_issues AS cost_issues ON cost_issues.id = activity_reward_settings.issue_id").
|
||||
Joins("LEFT JOIN products ON products.id = user_inventory.product_id").
|
||||
Joins("LEFT JOIN user_item_cards ON user_item_cards.id = orders.item_card_id").
|
||||
Joins("LEFT JOIN system_item_cards ON system_item_cards.id = user_item_cards.card_id").
|
||||
Where("COALESCE(NULLIF(user_inventory.activity_id, 0), cost_issues.activity_id) IN ?", activityIDs).
|
||||
Where("user_inventory.status IN ?", []int{1, 3}).
|
||||
Where("COALESCE(user_inventory.remark, '') NOT LIKE ?", "%void%").
|
||||
// 兼容历史数据:部分老资产可能未写入 order_id,避免被 JOIN 条件整批过滤为0
|
||||
Where("(orders.status = ? OR user_inventory.order_id = 0 OR user_inventory.order_id IS NULL)", 2).
|
||||
Group("COALESCE(NULLIF(user_inventory.activity_id, 0), cost_issues.activity_id)").
|
||||
Scan(&costStats).Error; err != nil {
|
||||
h.logger.Error(fmt.Sprintf("GetActivityProfitLoss cost stats error: %v", err))
|
||||
} else {
|
||||
for _, s := range costStats {
|
||||
if item, ok := activityMap[s.ActivityID]; ok {
|
||||
item.TotalCost = s.TotalCost
|
||||
item.PrizeCostBase = s.TotalCostBase
|
||||
item.PrizeCostFinal = s.TotalCost
|
||||
item.PrizeCostMultiplier = s.AvgMultiplierX10
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -264,15 +294,14 @@ func (h *handler) DashboardActivityProfitLoss() core.HandlerFunc {
|
||||
}
|
||||
|
||||
// 6. 计算盈亏和比率
|
||||
// 公式: 盈亏 = (支付金额 + 优惠券抵扣 + 次卡价值) - 产品成本
|
||||
// 公式: 盈亏 = 用户支出(普通单支付+优惠券 或 次卡价值) - 奖品成本(含道具卡倍率)
|
||||
finalList := make([]activityProfitLossItem, 0, len(activities))
|
||||
for _, a := range activities {
|
||||
item := activityMap[a.ID]
|
||||
totalIncome := item.TotalRevenue + item.TotalDiscount + item.TotalGamePassValue
|
||||
item.Profit = totalIncome - item.TotalCost
|
||||
if totalIncome > 0 {
|
||||
item.ProfitRate = float64(item.Profit) / float64(totalIncome)
|
||||
}
|
||||
item.SpendingPaidCoupon = item.TotalRevenue + item.TotalDiscount
|
||||
item.SpendingGamePass = item.TotalGamePassValue
|
||||
totalIncome := item.SpendingPaidCoupon + item.SpendingGamePass
|
||||
item.Profit, item.ProfitRate = financesvc.ComputeProfit(totalIncome, item.TotalCost)
|
||||
finalList = append(finalList, *item)
|
||||
}
|
||||
|
||||
@ -418,6 +447,7 @@ func (h *handler) DashboardActivityLogs() core.HandlerFunc {
|
||||
DrawCount int64 // 该订单的总抽奖次数(用于金额分摊)
|
||||
UsedDrawLogID int64 // 道具卡实际使用的日志ID
|
||||
CreatedAt time.Time
|
||||
ActivityPrice int64
|
||||
}
|
||||
|
||||
logsQuery := db.Table(model.TableNameActivityDrawLogs).
|
||||
@ -429,7 +459,7 @@ func (h *handler) DashboardActivityLogs() core.HandlerFunc {
|
||||
activity_reward_settings.product_id,
|
||||
COALESCE(products.name, '') as product_name,
|
||||
COALESCE(products.images_json, '[]') as images_json,
|
||||
COALESCE(products.price, 0) as product_price,
|
||||
COALESCE(activity_reward_settings.price_snapshot_cents, products.price, 0) as product_price,
|
||||
COALESCE(orders.actual_amount, 0) as order_amount,
|
||||
COALESCE(orders.discount_amount, 0) as discount_amount,
|
||||
COALESCE(orders.points_amount, 0) as points_amount,
|
||||
@ -445,9 +475,11 @@ func (h *handler) DashboardActivityLogs() core.HandlerFunc {
|
||||
COALESCE(orders.order_no, '') as order_no,
|
||||
COALESCE(order_draw_counts.draw_count, 1) as draw_count,
|
||||
COALESCE(user_item_cards.used_draw_log_id, 0) as used_draw_log_id,
|
||||
activity_draw_logs.created_at
|
||||
activity_draw_logs.created_at,
|
||||
COALESCE(activities.price_draw, 0) as activity_price
|
||||
`).
|
||||
Joins("JOIN activity_issues ON activity_issues.id = activity_draw_logs.issue_id").
|
||||
Joins("JOIN activities ON activities.id = activity_issues.activity_id").
|
||||
Joins("LEFT JOIN users ON users.id = activity_draw_logs.user_id").
|
||||
Joins("LEFT JOIN activity_reward_settings ON activity_reward_settings.id = activity_draw_logs.reward_id").
|
||||
Joins("LEFT JOIN products ON products.id = activity_reward_settings.product_id").
|
||||
@ -590,6 +622,14 @@ func (h *handler) DashboardActivityLogs() core.HandlerFunc {
|
||||
perDrawDiscountAmount := l.DiscountAmount / drawCount
|
||||
perDrawPointsAmount := l.PointsAmount / drawCount
|
||||
|
||||
if paymentDetails.GamePassUsed {
|
||||
if l.ActivityPrice > 0 {
|
||||
perDrawOrderAmount = l.ActivityPrice
|
||||
} else if perDrawOrderAmount == 0 {
|
||||
perDrawOrderAmount = l.OrderAmount / drawCount
|
||||
}
|
||||
}
|
||||
|
||||
// 设置支付详情中的分摊金额
|
||||
paymentDetails.CouponDiscount = perDrawDiscountAmount
|
||||
paymentDetails.PointsDiscount = perDrawPointsAmount
|
||||
|
||||
@ -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,9 +247,13 @@ func (h *handler) DashboardPlayerSpendingLeaderboard() core.HandlerFunc {
|
||||
|
||||
// Join with Products, Activities, and Orders (for livestream detection)
|
||||
query := db.Table(model.TableNameUserInventory).
|
||||
Joins("JOIN products ON products.id = user_inventory.product_id").
|
||||
Joins("LEFT JOIN activities ON activities.id = user_inventory.activity_id").
|
||||
Joins("LEFT JOIN activity_reward_settings ON activity_reward_settings.id = user_inventory.reward_id").
|
||||
Joins("LEFT JOIN activity_issues AS cost_issues ON cost_issues.id = activity_reward_settings.issue_id").
|
||||
Joins("LEFT JOIN activities ON activities.id = COALESCE(NULLIF(user_inventory.activity_id, 0), cost_issues.activity_id)").
|
||||
Joins("LEFT JOIN orders ON orders.id = user_inventory.order_id").
|
||||
Joins("LEFT JOIN products ON products.id = user_inventory.product_id").
|
||||
Joins("LEFT JOIN user_item_cards ON user_item_cards.id = orders.item_card_id").
|
||||
Joins("LEFT JOIN system_item_cards ON system_item_cards.id = user_item_cards.card_id").
|
||||
Where("user_inventory.user_id IN ?", userIDs)
|
||||
|
||||
if req.RangeType != "all" {
|
||||
@ -227,14 +262,14 @@ func (h *handler) DashboardPlayerSpendingLeaderboard() core.HandlerFunc {
|
||||
}
|
||||
// Only include Holding (1) and Shipped/Used (3) items. Exclude Void/Decomposed (2).
|
||||
query = query.Where("user_inventory.status IN ?", []int{1, 3}).
|
||||
Where("user_inventory.remark NOT LIKE ?", "%void%")
|
||||
Where("COALESCE(user_inventory.remark, '') NOT LIKE ?", "%void%")
|
||||
|
||||
err := query.Select(`
|
||||
user_inventory.user_id,
|
||||
SUM(products.price) as total_value,
|
||||
SUM(CASE WHEN activities.activity_category_id = 1 THEN products.price ELSE 0 END) as ichiban_prize,
|
||||
SUM(CASE WHEN activities.activity_category_id = 2 THEN products.price ELSE 0 END) as infinite_prize,
|
||||
SUM(CASE WHEN activities.activity_category_id = 3 THEN products.price ELSE 0 END) as matching_prize
|
||||
CAST(SUM(COALESCE(NULLIF(user_inventory.value_cents, 0), activity_reward_settings.price_snapshot_cents, products.price, 0) * GREATEST(COALESCE(system_item_cards.reward_multiplier_x1000, 1000), 1000) / 1000) AS SIGNED) as total_value,
|
||||
CAST(SUM(CASE WHEN activities.activity_category_id = 1 THEN COALESCE(NULLIF(user_inventory.value_cents, 0), activity_reward_settings.price_snapshot_cents, products.price, 0) * GREATEST(COALESCE(system_item_cards.reward_multiplier_x1000, 1000), 1000) / 1000 ELSE 0 END) AS SIGNED) as ichiban_prize,
|
||||
CAST(SUM(CASE WHEN activities.activity_category_id = 2 THEN COALESCE(NULLIF(user_inventory.value_cents, 0), activity_reward_settings.price_snapshot_cents, products.price, 0) * GREATEST(COALESCE(system_item_cards.reward_multiplier_x1000, 1000), 1000) / 1000 ELSE 0 END) AS SIGNED) as infinite_prize,
|
||||
CAST(SUM(CASE WHEN activities.activity_category_id = 3 THEN COALESCE(NULLIF(user_inventory.value_cents, 0), activity_reward_settings.price_snapshot_cents, products.price, 0) * GREATEST(COALESCE(system_item_cards.reward_multiplier_x1000, 1000), 1000) / 1000 ELSE 0 END) AS SIGNED) as matching_prize
|
||||
`).
|
||||
Group("user_inventory.user_id").
|
||||
Scan(&invStats).Error
|
||||
@ -248,31 +283,107 @@ func (h *handler) DashboardPlayerSpendingLeaderboard() core.HandlerFunc {
|
||||
item.MatchingPrize = is.MatchingPrize
|
||||
}
|
||||
}
|
||||
} else {
|
||||
h.logger.Error(fmt.Sprintf("DashboardPlayerSpendingLeaderboard inventory cost stats error: %v", err))
|
||||
}
|
||||
|
||||
// 4.1 Calculate Livestream Prize Value (From Draw Logs)
|
||||
type lsStat struct {
|
||||
// 4.1 Calculate Livestream Prize Value (snapshot priority; fallback prize cost)
|
||||
type lsLog struct {
|
||||
UserID int64
|
||||
Amount int64
|
||||
ShopOrderID string
|
||||
PrizeID int64
|
||||
}
|
||||
var lsStats []lsStat
|
||||
lsQuery := db.Table(model.TableNameLivestreamDrawLogs).
|
||||
Joins("JOIN products ON products.id = livestream_draw_logs.product_id").
|
||||
Select("livestream_draw_logs.local_user_id as user_id, SUM(products.price) as amount").
|
||||
var lsLogs []lsLog
|
||||
lsLogQuery := db.Table(model.TableNameLivestreamDrawLogs).
|
||||
Select("livestream_draw_logs.local_user_id as user_id, livestream_draw_logs.shop_order_id, livestream_draw_logs.prize_id").
|
||||
Where("livestream_draw_logs.local_user_id IN ?", userIDs).
|
||||
Where("livestream_draw_logs.is_refunded = 0").
|
||||
Where("livestream_draw_logs.product_id > 0")
|
||||
|
||||
Where("livestream_draw_logs.prize_id > 0")
|
||||
if req.RangeType != "all" {
|
||||
lsQuery = lsQuery.Where("livestream_draw_logs.created_at >= ?", start).
|
||||
lsLogQuery = lsLogQuery.Where("livestream_draw_logs.created_at >= ?", start).
|
||||
Where("livestream_draw_logs.created_at <= ?", end)
|
||||
}
|
||||
_ = lsLogQuery.Scan(&lsLogs).Error
|
||||
|
||||
if err := lsQuery.Group("livestream_draw_logs.local_user_id").Scan(&lsStats).Error; err == nil {
|
||||
for _, ls := range lsStats {
|
||||
if item, ok := statMap[ls.UserID]; ok {
|
||||
item.LivestreamPrize = ls.Amount
|
||||
// item.TotalPrizeValue += ls.Amount // Already included in user_inventory
|
||||
if len(lsLogs) > 0 {
|
||||
prizeIDSet := make(map[int64]struct{})
|
||||
for _, l := range lsLogs {
|
||||
prizeIDSet[l.PrizeID] = struct{}{}
|
||||
}
|
||||
prizeIDs := make([]int64, 0, len(prizeIDSet))
|
||||
for pid := range prizeIDSet {
|
||||
prizeIDs = append(prizeIDs, pid)
|
||||
}
|
||||
prizeCostMap := make(map[int64]int64)
|
||||
if len(prizeIDs) > 0 {
|
||||
var prizes []struct {
|
||||
ID int64
|
||||
CostPrice int64
|
||||
}
|
||||
_ = h.repo.GetDbR().Table("livestream_prizes").Select("id, cost_price").Where("id IN ?", prizeIDs).Scan(&prizes).Error
|
||||
for _, p := range prizes {
|
||||
prizeCostMap[p.ID] = p.CostPrice
|
||||
}
|
||||
}
|
||||
|
||||
type invRow struct {
|
||||
UserID int64
|
||||
ValueCents int64
|
||||
Remark string
|
||||
}
|
||||
var invRows []invRow
|
||||
invQ := h.repo.GetDbR().Table("user_inventory").
|
||||
Select("user_inventory.user_id, COALESCE(NULLIF(user_inventory.value_cents, 0), activity_reward_settings.price_snapshot_cents, products.price, 0) as value_cents, user_inventory.remark").
|
||||
Joins("LEFT JOIN activity_reward_settings ON activity_reward_settings.id = user_inventory.reward_id").
|
||||
Joins("LEFT JOIN products ON products.id = user_inventory.product_id").
|
||||
Where("user_inventory.user_id IN ?", userIDs).
|
||||
Where("user_inventory.status IN (1,3)").
|
||||
Where("COALESCE(user_inventory.remark, '') NOT LIKE ?", "%void%")
|
||||
if req.RangeType != "all" {
|
||||
invQ = invQ.Where("user_inventory.created_at >= ?", start.Add(-2*time.Hour)).
|
||||
Where("user_inventory.created_at <= ?", end.Add(24*time.Hour))
|
||||
}
|
||||
_ = invQ.Scan(&invRows).Error
|
||||
invByUser := make(map[int64][]invRow)
|
||||
for _, inv := range invRows {
|
||||
invByUser[inv.UserID] = append(invByUser[inv.UserID], inv)
|
||||
}
|
||||
|
||||
lsByKey := make(map[string][]lsLog)
|
||||
for _, l := range lsLogs {
|
||||
key := fmt.Sprintf("%d|%s", l.UserID, l.ShopOrderID)
|
||||
lsByKey[key] = append(lsByKey[key], l)
|
||||
}
|
||||
|
||||
livestreamPrizeByUser := make(map[int64]int64)
|
||||
for _, logs := range lsByKey {
|
||||
if len(logs) == 0 {
|
||||
continue
|
||||
}
|
||||
uid := logs[0].UserID
|
||||
shopOrderID := logs[0].ShopOrderID
|
||||
|
||||
var snapshotSum int64
|
||||
if shopOrderID != "" {
|
||||
for _, inv := range invByUser[uid] {
|
||||
if strings.Contains(inv.Remark, shopOrderID) {
|
||||
snapshotSum += inv.ValueCents
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if snapshotSum > 0 {
|
||||
livestreamPrizeByUser[uid] += snapshotSum
|
||||
continue
|
||||
}
|
||||
for _, l := range logs {
|
||||
livestreamPrizeByUser[uid] += prizeCostMap[l.PrizeID]
|
||||
}
|
||||
}
|
||||
|
||||
for uid, amount := range livestreamPrizeByUser {
|
||||
if item, ok := statMap[uid]; ok {
|
||||
item.LivestreamPrize = amount
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -294,11 +405,14 @@ func (h *handler) DashboardPlayerSpendingLeaderboard() core.HandlerFunc {
|
||||
calculatedProfit := item.IchibanProfit + item.InfiniteProfit + item.MatchingProfit + item.LivestreamProfit
|
||||
|
||||
item.Profit = calculatedProfit
|
||||
if calculatedSpending > 0 {
|
||||
item.ProfitRate = float64(item.Profit) / float64(calculatedSpending)
|
||||
} else {
|
||||
item.ProfitRate = 0
|
||||
_, item.ProfitRate = financesvc.ComputeProfit(calculatedSpending, calculatedSpending-item.Profit)
|
||||
item.SpendingPaidCoupon = calculatedSpending - item.SpendingGamePass
|
||||
if item.SpendingPaidCoupon < 0 {
|
||||
item.SpendingPaidCoupon = 0
|
||||
}
|
||||
item.PrizeCostFinal = item.IchibanPrize + item.InfinitePrize + item.MatchingPrize + item.LivestreamPrize
|
||||
item.PrizeCostBase = item.PrizeCostFinal
|
||||
item.PrizeCostMultiplier = 10
|
||||
list = append(list, *item)
|
||||
}
|
||||
|
||||
|
||||
@ -4,12 +4,14 @@ import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"bindbox-game/internal/code"
|
||||
"bindbox-game/internal/pkg/core"
|
||||
"bindbox-game/internal/pkg/validation"
|
||||
"bindbox-game/internal/repository/mysql/model"
|
||||
financesvc "bindbox-game/internal/service/finance"
|
||||
)
|
||||
|
||||
type userSpendingRequest struct {
|
||||
@ -87,9 +89,18 @@ func (h *handler) GetUserSpendingDashboard() core.HandlerFunc {
|
||||
var actStats []activityStat
|
||||
|
||||
query := db.Table(model.TableNameOrders).
|
||||
Joins("LEFT JOIN activity_draw_logs ON activity_draw_logs.order_id = orders.id").
|
||||
Joins("LEFT JOIN activity_issues ON activity_issues.id = activity_draw_logs.issue_id").
|
||||
Joins("LEFT JOIN activities ON activities.id = activity_issues.activity_id").
|
||||
Joins(`LEFT JOIN (
|
||||
SELECT activity_draw_logs.order_id, activity_issues.activity_id, COUNT(*) as draw_count
|
||||
FROM activity_draw_logs
|
||||
JOIN activity_issues ON activity_issues.id = activity_draw_logs.issue_id
|
||||
GROUP BY activity_draw_logs.order_id, activity_issues.activity_id
|
||||
) as order_activity_draws ON order_activity_draws.order_id = orders.id`).
|
||||
Joins(`LEFT JOIN (
|
||||
SELECT order_id, COUNT(*) as total_count
|
||||
FROM activity_draw_logs
|
||||
GROUP BY order_id
|
||||
) as order_total_draws ON order_total_draws.order_id = orders.id`).
|
||||
Joins("LEFT JOIN activities ON activities.id = order_activity_draws.activity_id").
|
||||
Where("orders.user_id = ?", userID).
|
||||
Where("orders.status = ?", 2)
|
||||
|
||||
@ -101,7 +112,11 @@ func (h *handler) GetUserSpendingDashboard() core.HandlerFunc {
|
||||
COALESCE(activities.id, 0) as activity_id,
|
||||
COALESCE(activities.name, '其他') as activity_name,
|
||||
COALESCE(activities.activity_category_id, 0) as category_id,
|
||||
SUM(orders.total_amount) as spending,
|
||||
SUM(CASE
|
||||
WHEN orders.source_type = 4 OR orders.order_no LIKE 'GP%' OR (orders.actual_amount = 0 AND orders.remark LIKE '%use_game_pass%')
|
||||
THEN COALESCE(order_activity_draws.draw_count * activities.price_draw, 0)
|
||||
ELSE COALESCE((orders.actual_amount + orders.discount_amount) * order_activity_draws.draw_count / NULLIF(order_total_draws.total_count, 0), 0)
|
||||
END) as spending,
|
||||
COUNT(DISTINCT orders.id) as order_count
|
||||
`).
|
||||
Group("COALESCE(activities.id, 0)").
|
||||
@ -120,21 +135,28 @@ func (h *handler) GetUserSpendingDashboard() core.HandlerFunc {
|
||||
var prizeStats []prizeStat
|
||||
|
||||
prizeQuery := db.Table(model.TableNameUserInventory).
|
||||
Joins("JOIN products ON products.id = user_inventory.product_id").
|
||||
Joins("LEFT JOIN orders ON orders.id = user_inventory.order_id").
|
||||
Joins("LEFT JOIN activity_reward_settings ON activity_reward_settings.id = user_inventory.reward_id").
|
||||
Joins("LEFT JOIN activity_issues AS cost_issues ON cost_issues.id = activity_reward_settings.issue_id").
|
||||
Joins("LEFT JOIN products ON products.id = user_inventory.product_id").
|
||||
Joins("LEFT JOIN user_item_cards ON user_item_cards.id = orders.item_card_id").
|
||||
Joins("LEFT JOIN system_item_cards ON system_item_cards.id = user_item_cards.card_id").
|
||||
Where("user_inventory.user_id = ?", userID).
|
||||
Where("user_inventory.status IN ?", []int{1, 3}).
|
||||
Where("user_inventory.remark NOT LIKE ?", "%void%")
|
||||
Where("COALESCE(user_inventory.remark, '') NOT LIKE ?", "%void%")
|
||||
|
||||
if hasRange {
|
||||
prizeQuery = prizeQuery.Where("user_inventory.created_at >= ?", start).Where("user_inventory.created_at <= ?", end)
|
||||
}
|
||||
|
||||
prizeQuery.Select(`
|
||||
COALESCE(user_inventory.activity_id, 0) as activity_id,
|
||||
SUM(products.price) as prize_value
|
||||
if err := prizeQuery.Select(`
|
||||
COALESCE(NULLIF(user_inventory.activity_id, 0), cost_issues.activity_id, 0) as activity_id,
|
||||
CAST(SUM(COALESCE(NULLIF(user_inventory.value_cents, 0), activity_reward_settings.price_snapshot_cents, products.price, 0) * GREATEST(COALESCE(system_item_cards.reward_multiplier_x1000, 1000), 1000) / 1000) AS SIGNED) as prize_value
|
||||
`).
|
||||
Group("COALESCE(user_inventory.activity_id, 0)").
|
||||
Scan(&prizeStats)
|
||||
Group("COALESCE(NULLIF(user_inventory.activity_id, 0), cost_issues.activity_id, 0)").
|
||||
Scan(&prizeStats).Error; err != nil {
|
||||
h.logger.Error(fmt.Sprintf("GetUserSpendingDashboard prize stats error: %v", err))
|
||||
}
|
||||
|
||||
prizeMap := make(map[int64]int64)
|
||||
for _, p := range prizeStats {
|
||||
@ -173,21 +195,98 @@ func (h *handler) GetUserSpendingDashboard() core.HandlerFunc {
|
||||
PrizeValue int64
|
||||
}
|
||||
var lsPrizeStats []lsPrizeStat
|
||||
lsPrizeQuery := db.Table("livestream_draw_logs").
|
||||
Joins("JOIN products ON products.id = livestream_draw_logs.product_id").
|
||||
Select(`
|
||||
livestream_draw_logs.livestream_activity_id as activity_id,
|
||||
SUM(products.price) as prize_value
|
||||
`).
|
||||
Where("livestream_draw_logs.local_user_id = ?", userID).
|
||||
Where("livestream_draw_logs.is_refunded = 0").
|
||||
Where("livestream_draw_logs.product_id > 0")
|
||||
|
||||
type lsLog struct {
|
||||
ActivityID int64
|
||||
ShopOrderID string
|
||||
PrizeID int64
|
||||
}
|
||||
var lsLogs []lsLog
|
||||
lsLogQuery := db.Table("livestream_draw_logs").
|
||||
Select("livestream_activity_id as activity_id, shop_order_id, prize_id").
|
||||
Where("local_user_id = ?", userID).
|
||||
Where("is_refunded = 0").
|
||||
Where("prize_id > 0")
|
||||
if hasRange {
|
||||
lsPrizeQuery = lsPrizeQuery.Where("livestream_draw_logs.created_at >= ?", start).Where("livestream_draw_logs.created_at <= ?", end)
|
||||
lsLogQuery = lsLogQuery.Where("created_at >= ?", start).Where("created_at <= ?", end)
|
||||
}
|
||||
_ = lsLogQuery.Scan(&lsLogs).Error
|
||||
|
||||
if len(lsLogs) > 0 {
|
||||
prizeIDSet := make(map[int64]struct{})
|
||||
for _, l := range lsLogs {
|
||||
prizeIDSet[l.PrizeID] = struct{}{}
|
||||
}
|
||||
prizeIDs := make([]int64, 0, len(prizeIDSet))
|
||||
for pid := range prizeIDSet {
|
||||
prizeIDs = append(prizeIDs, pid)
|
||||
}
|
||||
prizeCostMap := make(map[int64]int64)
|
||||
if len(prizeIDs) > 0 {
|
||||
var prizes []struct {
|
||||
ID int64
|
||||
CostPrice int64
|
||||
}
|
||||
_ = h.repo.GetDbR().Table("livestream_prizes").Select("id, cost_price").Where("id IN ?", prizeIDs).Scan(&prizes).Error
|
||||
for _, p := range prizes {
|
||||
prizeCostMap[p.ID] = p.CostPrice
|
||||
}
|
||||
}
|
||||
|
||||
lsPrizeQuery.Group("livestream_draw_logs.livestream_activity_id").Scan(&lsPrizeStats)
|
||||
type invRow struct {
|
||||
ValueCents int64
|
||||
Remark string
|
||||
}
|
||||
var invRows []invRow
|
||||
invQ := h.repo.GetDbR().Table("user_inventory").
|
||||
Select("COALESCE(NULLIF(user_inventory.value_cents, 0), activity_reward_settings.price_snapshot_cents, products.price, 0) as value_cents, user_inventory.remark").
|
||||
Joins("LEFT JOIN activity_reward_settings ON activity_reward_settings.id = user_inventory.reward_id").
|
||||
Joins("LEFT JOIN products ON products.id = user_inventory.product_id").
|
||||
Where("user_id = ?", userID).
|
||||
Where("status IN (1,3)").
|
||||
Where("COALESCE(user_inventory.remark, '') NOT LIKE ?", "%void%")
|
||||
if hasRange {
|
||||
invQ = invQ.Where("created_at >= ?", start.Add(-2*time.Hour)).Where("created_at <= ?", end.Add(24*time.Hour))
|
||||
}
|
||||
_ = invQ.Scan(&invRows).Error
|
||||
|
||||
lsByKey := make(map[string][]lsLog)
|
||||
for _, l := range lsLogs {
|
||||
key := fmt.Sprintf("%d|%s", l.ActivityID, l.ShopOrderID)
|
||||
lsByKey[key] = append(lsByKey[key], l)
|
||||
}
|
||||
|
||||
prizeByActivity := make(map[int64]int64)
|
||||
for _, logs := range lsByKey {
|
||||
if len(logs) == 0 {
|
||||
continue
|
||||
}
|
||||
aid := logs[0].ActivityID
|
||||
shopOrderID := logs[0].ShopOrderID
|
||||
|
||||
var snapshotSum int64
|
||||
if shopOrderID != "" {
|
||||
for _, inv := range invRows {
|
||||
if strings.Contains(inv.Remark, shopOrderID) {
|
||||
snapshotSum += inv.ValueCents
|
||||
}
|
||||
}
|
||||
}
|
||||
if snapshotSum > 0 {
|
||||
prizeByActivity[aid] += snapshotSum
|
||||
continue
|
||||
}
|
||||
for _, l := range logs {
|
||||
prizeByActivity[aid] += prizeCostMap[l.PrizeID]
|
||||
}
|
||||
}
|
||||
|
||||
for aid, val := range prizeByActivity {
|
||||
lsPrizeStats = append(lsPrizeStats, lsPrizeStat{
|
||||
ActivityID: aid,
|
||||
PrizeValue: val,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
lsPrizeMap := make(map[int64]int64)
|
||||
for _, p := range lsPrizeStats {
|
||||
@ -211,7 +310,7 @@ func (h *handler) GetUserSpendingDashboard() core.HandlerFunc {
|
||||
CategoryName: catName,
|
||||
Spending: s.Spending,
|
||||
PrizeValue: prize,
|
||||
Profit: s.Spending - prize,
|
||||
Profit: func() int64 { p, _ := financesvc.ComputeProfit(s.Spending, prize); return p }(),
|
||||
OrderCount: s.OrderCount,
|
||||
}
|
||||
activities = append(activities, item)
|
||||
@ -230,7 +329,7 @@ func (h *handler) GetUserSpendingDashboard() core.HandlerFunc {
|
||||
CategoryName: "直播间",
|
||||
Spending: ls.Spending,
|
||||
PrizeValue: prize,
|
||||
Profit: ls.Spending - prize,
|
||||
Profit: func() int64 { p, _ := financesvc.ComputeProfit(ls.Spending, prize); return p }(),
|
||||
OrderCount: ls.OrderCount,
|
||||
}
|
||||
activities = append(activities, item)
|
||||
@ -241,7 +340,7 @@ func (h *handler) GetUserSpendingDashboard() core.HandlerFunc {
|
||||
|
||||
rsp.TotalSpend = totalSpend
|
||||
rsp.TotalPrize = totalPrize
|
||||
rsp.TotalProfit = totalSpend - totalPrize
|
||||
rsp.TotalProfit, _ = financesvc.ComputeProfit(totalSpend, totalPrize)
|
||||
rsp.TotalOrders = totalOrders
|
||||
rsp.Activities = activities
|
||||
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -1,6 +1,9 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
@ -10,6 +13,7 @@ import (
|
||||
"bindbox-game/internal/pkg/core"
|
||||
"bindbox-game/internal/pkg/validation"
|
||||
"bindbox-game/internal/repository/mysql/model"
|
||||
"bindbox-game/internal/service/channel"
|
||||
"bindbox-game/internal/service/livestream"
|
||||
|
||||
"gorm.io/gorm"
|
||||
@ -21,6 +25,7 @@ type createLivestreamActivityRequest struct {
|
||||
Name string `json:"name" binding:"required"`
|
||||
StreamerName string `json:"streamer_name"`
|
||||
StreamerContact string `json:"streamer_contact"`
|
||||
ChannelID *int64 `json:"channel_id"`
|
||||
DouyinProductID string `json:"douyin_product_id"`
|
||||
OrderRewardType string `json:"order_reward_type"` // 下单奖励类型: flip_card/minesweeper
|
||||
OrderRewardQuantity int32 `json:"order_reward_quantity"` // 下单奖励数量: 1-100
|
||||
@ -34,6 +39,9 @@ type livestreamActivityResponse struct {
|
||||
Name string `json:"name"`
|
||||
StreamerName string `json:"streamer_name"`
|
||||
StreamerContact string `json:"streamer_contact"`
|
||||
ChannelID int64 `json:"channel_id"`
|
||||
ChannelCode string `json:"channel_code"`
|
||||
ChannelName string `json:"channel_name"`
|
||||
AccessCode string `json:"access_code"`
|
||||
DouyinProductID string `json:"douyin_product_id"`
|
||||
OrderRewardType string `json:"order_reward_type"` // 下单奖励类型
|
||||
@ -64,10 +72,35 @@ func (h *handler) CreateLivestreamActivity() core.HandlerFunc {
|
||||
return
|
||||
}
|
||||
|
||||
var channelCode string
|
||||
var channelName string
|
||||
if req.ChannelID != nil && *req.ChannelID > 0 {
|
||||
if ch, err := h.channel.GetByID(ctx.RequestContext(), *req.ChannelID); err == nil {
|
||||
channelCode = ch.Code
|
||||
channelName = ch.Name
|
||||
if req.StreamerName == "" {
|
||||
req.StreamerName = ch.Name
|
||||
}
|
||||
} else if err == channel.ErrChannelNotFound {
|
||||
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, "渠道不存在"))
|
||||
return
|
||||
} else {
|
||||
ctx.AbortWithError(core.Error(http.StatusInternalServerError, code.ServerError, err.Error()))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
input := livestream.CreateActivityInput{
|
||||
Name: req.Name,
|
||||
StreamerName: req.StreamerName,
|
||||
StreamerContact: req.StreamerContact,
|
||||
ChannelID: func() int64 {
|
||||
if req.ChannelID != nil {
|
||||
return *req.ChannelID
|
||||
}
|
||||
return 0
|
||||
}(),
|
||||
ChannelCode: channelCode,
|
||||
DouyinProductID: req.DouyinProductID,
|
||||
OrderRewardType: req.OrderRewardType,
|
||||
OrderRewardQuantity: req.OrderRewardQuantity,
|
||||
@ -91,11 +124,19 @@ func (h *handler) CreateLivestreamActivity() core.HandlerFunc {
|
||||
return
|
||||
}
|
||||
|
||||
displayChannelName := channelName
|
||||
if displayChannelName == "" && activity.ChannelCode != "" {
|
||||
displayChannelName = activity.ChannelCode
|
||||
}
|
||||
|
||||
ctx.Payload(&livestreamActivityResponse{
|
||||
ID: activity.ID,
|
||||
Name: activity.Name,
|
||||
StreamerName: activity.StreamerName,
|
||||
StreamerContact: activity.StreamerContact,
|
||||
ChannelID: activity.ChannelID,
|
||||
ChannelCode: activity.ChannelCode,
|
||||
ChannelName: displayChannelName,
|
||||
AccessCode: activity.AccessCode,
|
||||
DouyinProductID: activity.DouyinProductID,
|
||||
OrderRewardType: activity.OrderRewardType,
|
||||
@ -111,6 +152,7 @@ type updateLivestreamActivityRequest struct {
|
||||
Name string `json:"name"`
|
||||
StreamerName string `json:"streamer_name"`
|
||||
StreamerContact string `json:"streamer_contact"`
|
||||
ChannelID *int64 `json:"channel_id"`
|
||||
DouyinProductID string `json:"douyin_product_id"`
|
||||
OrderRewardType string `json:"order_reward_type"` // 下单奖励类型
|
||||
OrderRewardQuantity *int32 `json:"order_reward_quantity"` // 下单奖励数量
|
||||
@ -146,6 +188,29 @@ func (h *handler) UpdateLivestreamActivity() core.HandlerFunc {
|
||||
return
|
||||
}
|
||||
|
||||
var channelCodeValue string
|
||||
var channelCodePtr *string
|
||||
if req.ChannelID != nil {
|
||||
if *req.ChannelID > 0 {
|
||||
if ch, err := h.channel.GetByID(ctx.RequestContext(), *req.ChannelID); err == nil {
|
||||
channelCodeValue = ch.Code
|
||||
channelCodePtr = &channelCodeValue
|
||||
if req.StreamerName == "" {
|
||||
req.StreamerName = ch.Name
|
||||
}
|
||||
} else if err == channel.ErrChannelNotFound {
|
||||
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, "渠道不存在"))
|
||||
return
|
||||
} else {
|
||||
ctx.AbortWithError(core.Error(http.StatusInternalServerError, code.ServerError, err.Error()))
|
||||
return
|
||||
}
|
||||
} else {
|
||||
channelCodeValue = ""
|
||||
channelCodePtr = &channelCodeValue
|
||||
}
|
||||
}
|
||||
|
||||
input := livestream.UpdateActivityInput{
|
||||
Name: req.Name,
|
||||
StreamerName: req.StreamerName,
|
||||
@ -155,6 +220,8 @@ func (h *handler) UpdateLivestreamActivity() core.HandlerFunc {
|
||||
OrderRewardQuantity: req.OrderRewardQuantity,
|
||||
TicketPrice: req.TicketPrice,
|
||||
Status: req.Status,
|
||||
ChannelID: req.ChannelID,
|
||||
ChannelCode: channelCodePtr,
|
||||
}
|
||||
|
||||
if req.StartTime != "" {
|
||||
@ -224,6 +291,14 @@ func (h *handler) ListLivestreamActivities() core.HandlerFunc {
|
||||
return
|
||||
}
|
||||
|
||||
channelIDs := make([]int64, 0, len(list))
|
||||
for _, a := range list {
|
||||
if a.ChannelID > 0 {
|
||||
channelIDs = append(channelIDs, a.ChannelID)
|
||||
}
|
||||
}
|
||||
channelNameMap := h.loadChannelNames(ctx.RequestContext(), channelIDs)
|
||||
|
||||
res := &listLivestreamActivitiesResponse{
|
||||
List: make([]livestreamActivityResponse, len(list)),
|
||||
Total: total,
|
||||
@ -245,6 +320,13 @@ func (h *handler) ListLivestreamActivities() core.HandlerFunc {
|
||||
Status: a.Status,
|
||||
CreatedAt: a.CreatedAt.Format("2006-01-02 15:04:05"),
|
||||
}
|
||||
item.ChannelID = a.ChannelID
|
||||
item.ChannelCode = a.ChannelCode
|
||||
if name := channelNameMap[a.ChannelID]; name != "" {
|
||||
item.ChannelName = name
|
||||
} else if a.ChannelCode != "" {
|
||||
item.ChannelName = a.ChannelCode
|
||||
}
|
||||
if !a.StartTime.IsZero() {
|
||||
item.StartTime = a.StartTime.Format("2006-01-02 15:04:05")
|
||||
}
|
||||
@ -283,11 +365,24 @@ func (h *handler) GetLivestreamActivity() core.HandlerFunc {
|
||||
return
|
||||
}
|
||||
|
||||
channelName := ""
|
||||
if activity.ChannelID > 0 {
|
||||
if names := h.loadChannelNames(ctx.RequestContext(), []int64{activity.ChannelID}); len(names) > 0 {
|
||||
channelName = names[activity.ChannelID]
|
||||
}
|
||||
}
|
||||
if channelName == "" && activity.ChannelCode != "" {
|
||||
channelName = activity.ChannelCode
|
||||
}
|
||||
|
||||
res := &livestreamActivityResponse{
|
||||
ID: activity.ID,
|
||||
Name: activity.Name,
|
||||
StreamerName: activity.StreamerName,
|
||||
StreamerContact: activity.StreamerContact,
|
||||
ChannelID: activity.ChannelID,
|
||||
ChannelCode: activity.ChannelCode,
|
||||
ChannelName: channelName,
|
||||
AccessCode: activity.AccessCode,
|
||||
DouyinProductID: activity.DouyinProductID,
|
||||
OrderRewardType: activity.OrderRewardType,
|
||||
@ -335,6 +430,41 @@ func (h *handler) DeleteLivestreamActivity() core.HandlerFunc {
|
||||
}
|
||||
}
|
||||
|
||||
func (h *handler) loadChannelNames(ctx context.Context, ids []int64) map[int64]string {
|
||||
result := make(map[int64]string)
|
||||
if len(ids) == 0 {
|
||||
return result
|
||||
}
|
||||
|
||||
unique := make([]int64, 0, len(ids))
|
||||
seen := make(map[int64]struct{})
|
||||
for _, id := range ids {
|
||||
if id <= 0 {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[id]; ok {
|
||||
continue
|
||||
}
|
||||
seen[id] = struct{}{}
|
||||
unique = append(unique, id)
|
||||
}
|
||||
if len(unique) == 0 {
|
||||
return result
|
||||
}
|
||||
|
||||
channels, err := h.readDB.Channels.WithContext(ctx).
|
||||
Select(h.readDB.Channels.ID, h.readDB.Channels.Name).
|
||||
Where(h.readDB.Channels.ID.In(unique...)).
|
||||
Find()
|
||||
if err != nil {
|
||||
return result
|
||||
}
|
||||
for _, ch := range channels {
|
||||
result[ch.ID] = ch.Name
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// ========== 直播间奖品管理 ==========
|
||||
|
||||
type createLivestreamPrizeRequest struct {
|
||||
@ -630,6 +760,17 @@ func (h *handler) ListLivestreamDrawLogs() core.HandlerFunc {
|
||||
return
|
||||
}
|
||||
|
||||
var activity model.LivestreamActivities
|
||||
if err := h.repo.GetDbR().Select("id, ticket_price").Where("id = ?", activityID).First(&activity).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
ctx.AbortWithError(core.Error(http.StatusNotFound, code.ParamBindError, "活动不存在"))
|
||||
} else {
|
||||
ctx.AbortWithError(core.Error(http.StatusInternalServerError, code.ServerError, err.Error()))
|
||||
}
|
||||
return
|
||||
}
|
||||
ticketPrice := int64(activity.TicketPrice)
|
||||
|
||||
req := new(listLivestreamDrawLogsRequest)
|
||||
_ = ctx.ShouldBindForm(req)
|
||||
|
||||
@ -710,10 +851,11 @@ func (h *handler) ListLivestreamDrawLogs() core.HandlerFunc {
|
||||
DouyinOrderID int64
|
||||
PrizeID int64
|
||||
ShopOrderID string // 用于关联退款状态查 douyin_orders
|
||||
LocalUserID int64
|
||||
}
|
||||
var metas []logMeta
|
||||
// 使用不带分页的 db 克隆
|
||||
if err := db.Session(&gorm.Session{}).Select("douyin_order_id, prize_id, shop_order_id").Scan(&metas).Error; err == nil {
|
||||
if err := db.Session(&gorm.Session{}).Select("douyin_order_id, prize_id, shop_order_id, local_user_id").Scan(&metas).Error; err == nil {
|
||||
orderIDs := make([]int64, 0, len(metas))
|
||||
distinctOrderIDs := make(map[int64]bool)
|
||||
prizeIDCount := make(map[int64]int64)
|
||||
@ -730,61 +872,97 @@ func (h *handler) ListLivestreamDrawLogs() core.HandlerFunc {
|
||||
if len(orderIDs) > 0 {
|
||||
var orders []model.DouyinOrders
|
||||
// 分批查询防止 IN 子句过长? 暂时假设量级可控
|
||||
h.repo.GetDbR().Select("id, actual_pay_amount, order_status").
|
||||
h.repo.GetDbR().Select("id, actual_pay_amount, order_status, pay_type_desc, product_count").
|
||||
Where("id IN ?", orderIDs).Find(&orders)
|
||||
|
||||
orderRefundMap := make(map[int64]bool)
|
||||
|
||||
for _, o := range orders {
|
||||
// 统计营收 (总流水)
|
||||
stats.TotalRev += int64(o.ActualPayAmount)
|
||||
orderAmount := calcLivestreamOrderAmount(&o, ticketPrice)
|
||||
stats.TotalRev += orderAmount
|
||||
|
||||
if o.OrderStatus == 4 { // 已退款
|
||||
stats.TotalRefund += int64(o.ActualPayAmount)
|
||||
stats.TotalRefund += orderAmount
|
||||
orderRefundMap[o.ID] = true
|
||||
}
|
||||
}
|
||||
|
||||
// 4. 统计成本 (剔除退款订单)
|
||||
// 4. 统计成本 (剔除退款订单,优先资产快照,缺失回退 prize.cost_price)
|
||||
for _, m := range metas {
|
||||
if !orderRefundMap[m.DouyinOrderID] {
|
||||
prizeIDCount[m.PrizeID]++
|
||||
}
|
||||
}
|
||||
|
||||
// 计算奖品成本 (逻辑参考 GetLivestreamStats,简化版)
|
||||
prizeCostMap := make(map[int64]int64)
|
||||
if len(prizeIDCount) > 0 {
|
||||
prizeIDs := make([]int64, 0, len(prizeIDCount))
|
||||
for pid := range prizeIDCount {
|
||||
prizeIDs = append(prizeIDs, pid)
|
||||
}
|
||||
|
||||
var prizes []model.LivestreamPrizes
|
||||
h.repo.GetDbR().Where("id IN ?", prizeIDs).Find(&prizes)
|
||||
|
||||
// 批量获取关联商品
|
||||
productIDs := make([]int64, 0)
|
||||
h.repo.GetDbR().Select("id, cost_price").Where("id IN ?", prizeIDs).Find(&prizes)
|
||||
for _, p := range prizes {
|
||||
if p.CostPrice == 0 && p.ProductID > 0 {
|
||||
productIDs = append(productIDs, p.ProductID)
|
||||
}
|
||||
}
|
||||
productPriceMap := make(map[int64]int64)
|
||||
if len(productIDs) > 0 {
|
||||
var products []model.Products
|
||||
h.repo.GetDbR().Select("id, price").Where("id IN ?", productIDs).Find(&products)
|
||||
for _, prod := range products {
|
||||
productPriceMap[prod.ID] = prod.Price
|
||||
prizeCostMap[p.ID] = p.CostPrice
|
||||
}
|
||||
}
|
||||
|
||||
for _, p := range prizes {
|
||||
cost := p.CostPrice
|
||||
if cost == 0 && p.ProductID > 0 {
|
||||
cost = productPriceMap[p.ProductID]
|
||||
// 预加载用户资产快照用于 shop_order_id 命中
|
||||
type invRow struct {
|
||||
UserID int64
|
||||
ValueCents int64
|
||||
Remark string
|
||||
}
|
||||
count := prizeIDCount[p.ID]
|
||||
stats.TotalCost += cost * count
|
||||
var invRows []invRow
|
||||
_ = h.repo.GetDbR().Table("user_inventory").
|
||||
Select("user_inventory.user_id, COALESCE(NULLIF(user_inventory.value_cents, 0), activity_reward_settings.price_snapshot_cents, products.price, 0) as value_cents, user_inventory.remark").
|
||||
Joins("LEFT JOIN activity_reward_settings ON activity_reward_settings.id = user_inventory.reward_id").
|
||||
Joins("LEFT JOIN products ON products.id = user_inventory.product_id").
|
||||
Where("user_inventory.status IN (1,3)").
|
||||
Where("COALESCE(user_inventory.remark, '') NOT LIKE ?", "%void%").
|
||||
Where("user_inventory.user_id > 0").
|
||||
Scan(&invRows).Error
|
||||
invByUser := make(map[int64][]invRow)
|
||||
for _, v := range invRows {
|
||||
invByUser[v.UserID] = append(invByUser[v.UserID], v)
|
||||
}
|
||||
metasByKey := make(map[string][]logMeta)
|
||||
keyUser := make(map[string]int64)
|
||||
keyOrder := make(map[string]string)
|
||||
for _, m := range metas {
|
||||
if orderRefundMap[m.DouyinOrderID] {
|
||||
continue
|
||||
}
|
||||
key := fmt.Sprintf("%d|%s", m.LocalUserID, m.ShopOrderID)
|
||||
metasByKey[key] = append(metasByKey[key], m)
|
||||
keyUser[key] = m.LocalUserID
|
||||
keyOrder[key] = m.ShopOrderID
|
||||
}
|
||||
|
||||
for key, rows := range metasByKey {
|
||||
if len(rows) == 0 {
|
||||
continue
|
||||
}
|
||||
uid := keyUser[key]
|
||||
shopOrderID := keyOrder[key]
|
||||
|
||||
var snapshotSum int64
|
||||
if uid > 0 && shopOrderID != "" {
|
||||
for _, inv := range invByUser[uid] {
|
||||
if strings.Contains(inv.Remark, shopOrderID) {
|
||||
snapshotSum += inv.ValueCents
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if snapshotSum > 0 {
|
||||
stats.TotalCost += snapshotSum
|
||||
continue
|
||||
}
|
||||
|
||||
for _, r := range rows {
|
||||
stats.TotalCost += prizeCostMap[r.PrizeID]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
36
internal/api/admin/livestream_helpers.go
Normal file
36
internal/api/admin/livestream_helpers.go
Normal file
@ -0,0 +1,36 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"bindbox-game/internal/repository/mysql/model"
|
||||
)
|
||||
|
||||
// calcLivestreamOrderAmount returns the effective revenue contribution for a Douyin order.
|
||||
// For regular paid orders it returns actual_pay_amount; for 次卡订单 (actual pay is 0 but
|
||||
// pay_type_desc contains 次卡), it falls back to the activity ticket price.
|
||||
func calcLivestreamOrderAmount(order *model.DouyinOrders, ticketPrice int64) int64 {
|
||||
if order == nil {
|
||||
return 0
|
||||
}
|
||||
|
||||
amount := int64(order.ActualPayAmount)
|
||||
if amount > 0 || ticketPrice <= 0 {
|
||||
return amount
|
||||
}
|
||||
|
||||
desc := strings.ReplaceAll(strings.TrimSpace(order.PayTypeDesc), " ", "")
|
||||
if desc == "" {
|
||||
return amount
|
||||
}
|
||||
|
||||
if strings.Contains(desc, "次卡") {
|
||||
multiplier := int64(order.ProductCount)
|
||||
if multiplier <= 0 {
|
||||
multiplier = 1
|
||||
}
|
||||
return ticketPrice * multiplier
|
||||
}
|
||||
|
||||
return amount
|
||||
}
|
||||
@ -4,6 +4,7 @@ import (
|
||||
"math"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"bindbox-game/internal/code"
|
||||
"bindbox-game/internal/pkg/core"
|
||||
@ -77,54 +78,98 @@ func (h *handler) GetLivestreamStats() core.HandlerFunc {
|
||||
ctx.AbortWithError(core.Error(http.StatusNotFound, code.ServerError, "活动不存在"))
|
||||
return
|
||||
}
|
||||
// ticketPrice 暂未使用,但在统计中可能作为参考,这里移除未使用的报错
|
||||
ticketPrice := int64(activity.TicketPrice)
|
||||
|
||||
// 2. 统计营收/退款:基于订单去重并兼容次卡(0元订单按门票价计入)
|
||||
type orderRef struct {
|
||||
OrderID int64
|
||||
FirstDrawAt time.Time
|
||||
}
|
||||
|
||||
orderQuery := h.repo.GetDbR().Table(model.TableNameLivestreamDrawLogs).
|
||||
Select("douyin_order_id AS order_id, MIN(created_at) AS first_draw_at").
|
||||
Where("activity_id = ?", id).
|
||||
Where("douyin_order_id > 0")
|
||||
|
||||
// 2. 核心统计逻辑重构:从关联的订单表获取真实金额
|
||||
// 使用子查询或 Join 来获取不重复的订单金额,确保即便一次订单对应多次抽奖,金额也不重计
|
||||
var totalRevenue, orderCount int64
|
||||
// 统计营收:来自已参与过抽奖(产生过日志)且未退款的订单 (order_status != 4)
|
||||
// 使用 actual_pay_amount (实付金额)
|
||||
queryRevenue := `
|
||||
SELECT
|
||||
CAST(IFNULL(SUM(distinct_orders.actual_pay_amount), 0) AS SIGNED) as rev,
|
||||
COUNT(*) as cnt
|
||||
FROM (
|
||||
SELECT DISTINCT o.id, o.actual_pay_amount
|
||||
FROM douyin_orders o
|
||||
JOIN livestream_draw_logs l ON o.id = l.douyin_order_id
|
||||
WHERE l.activity_id = ?
|
||||
`
|
||||
if startTime != nil {
|
||||
queryRevenue += " AND l.created_at >= '" + startTime.Format("2006-01-02 15:04:05") + "'"
|
||||
orderQuery = orderQuery.Where("created_at >= ?", startTime)
|
||||
}
|
||||
if endTime != nil {
|
||||
queryRevenue += " AND l.created_at <= '" + endTime.Format("2006-01-02 15:04:05") + "'"
|
||||
orderQuery = orderQuery.Where("created_at <= ?", endTime)
|
||||
}
|
||||
queryRevenue += ") as distinct_orders"
|
||||
|
||||
_ = h.repo.GetDbR().Raw(queryRevenue, id).Row().Scan(&totalRevenue, &orderCount)
|
||||
|
||||
// 统计退款:来自已参与过抽奖且标记为退款的订单 (order_status = 4)
|
||||
var totalRefund, refundCount int64
|
||||
queryRefund := `
|
||||
SELECT
|
||||
CAST(IFNULL(SUM(distinct_orders.actual_pay_amount), 0) AS SIGNED) as ref_amt,
|
||||
COUNT(*) as ref_cnt
|
||||
FROM (
|
||||
SELECT DISTINCT o.id, o.actual_pay_amount
|
||||
FROM douyin_orders o
|
||||
JOIN livestream_draw_logs l ON o.id = l.douyin_order_id
|
||||
WHERE l.activity_id = ? AND o.order_status = 4
|
||||
`
|
||||
if startTime != nil {
|
||||
queryRefund += " AND l.created_at >= '" + startTime.Format("2006-01-02 15:04:05") + "'"
|
||||
var orderRefs []orderRef
|
||||
if err := orderQuery.Group("douyin_order_id").Scan(&orderRefs).Error; err != nil {
|
||||
ctx.AbortWithError(core.Error(http.StatusInternalServerError, code.ServerError, err.Error()))
|
||||
return
|
||||
}
|
||||
if endTime != nil {
|
||||
queryRefund += " AND l.created_at <= '" + endTime.Format("2006-01-02 15:04:05") + "'"
|
||||
}
|
||||
queryRefund += ") as distinct_orders"
|
||||
|
||||
_ = h.repo.GetDbR().Raw(queryRefund, id).Row().Scan(&totalRefund, &refundCount)
|
||||
orderIDs := make([]int64, 0, len(orderRefs))
|
||||
for _, ref := range orderRefs {
|
||||
if ref.OrderID == 0 {
|
||||
continue
|
||||
}
|
||||
orderIDs = append(orderIDs, ref.OrderID)
|
||||
}
|
||||
|
||||
orderMap := make(map[int64]*model.DouyinOrders, len(orderIDs))
|
||||
if len(orderIDs) > 0 {
|
||||
var orders []model.DouyinOrders
|
||||
if err := h.repo.GetDbR().
|
||||
Select("id, shop_order_id, actual_pay_amount, order_status, pay_type_desc, product_count").
|
||||
Where("id IN ?", orderIDs).
|
||||
Find(&orders).Error; err != nil {
|
||||
ctx.AbortWithError(core.Error(http.StatusInternalServerError, code.ServerError, err.Error()))
|
||||
return
|
||||
}
|
||||
for i := range orders {
|
||||
orderMap[orders[i].ID] = &orders[i]
|
||||
}
|
||||
}
|
||||
|
||||
dailyMap := make(map[string]*dailyLivestreamStats)
|
||||
refundedShopOrderIDs := make(map[string]bool)
|
||||
var totalRevenue, totalRefund int64
|
||||
var orderCount, refundCount int64
|
||||
|
||||
for _, ref := range orderRefs {
|
||||
order := orderMap[ref.OrderID]
|
||||
if order == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
amount := calcLivestreamOrderAmount(order, ticketPrice)
|
||||
if amount < 0 {
|
||||
amount = 0
|
||||
}
|
||||
dateKey := ref.FirstDrawAt.In(time.Local).Format("2006-01-02")
|
||||
if ref.FirstDrawAt.IsZero() {
|
||||
dateKey = time.Now().In(time.Local).Format("2006-01-02")
|
||||
}
|
||||
refunded := order.OrderStatus == 4
|
||||
|
||||
orderCount++
|
||||
totalRevenue += amount
|
||||
if refunded {
|
||||
totalRefund += amount
|
||||
refundCount++
|
||||
}
|
||||
if refunded && order.ShopOrderID != "" {
|
||||
refundedShopOrderIDs[order.ShopOrderID] = true
|
||||
}
|
||||
|
||||
ds := dailyMap[dateKey]
|
||||
if ds == nil {
|
||||
ds = &dailyLivestreamStats{Date: dateKey}
|
||||
dailyMap[dateKey] = ds
|
||||
}
|
||||
ds.TotalRevenue += amount
|
||||
ds.OrderCount++
|
||||
if refunded {
|
||||
ds.TotalRefund += amount
|
||||
ds.RefundCount++
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 获取所有抽奖记录用于成本计算
|
||||
var drawLogs []model.LivestreamDrawLogs
|
||||
@ -138,141 +183,130 @@ func (h *handler) GetLivestreamStats() core.HandlerFunc {
|
||||
db.Find(&drawLogs)
|
||||
|
||||
// 3.1 获取该时间段内所有退款的 shop_order_id 集合,用于过滤成本
|
||||
refundedShopOrderIDs := make(map[string]bool)
|
||||
var refundedOrders []string
|
||||
qRefundIDs := `
|
||||
SELECT DISTINCT o.shop_order_id
|
||||
FROM douyin_orders o
|
||||
JOIN livestream_draw_logs l ON o.id = l.douyin_order_id
|
||||
WHERE l.activity_id = ? AND o.order_status = 4
|
||||
`
|
||||
// 4. 计算成本(优先资产快照 user_inventory.value_cents,缺失回退 livestream_prizes.cost_price)
|
||||
prizeCostMap := make(map[int64]int64)
|
||||
prizeIDs := make([]int64, 0)
|
||||
prizeIDSet := make(map[int64]struct{})
|
||||
userIDSet := make(map[int64]struct{})
|
||||
for _, log := range drawLogs {
|
||||
if log.PrizeID > 0 {
|
||||
if _, ok := prizeIDSet[log.PrizeID]; !ok {
|
||||
prizeIDSet[log.PrizeID] = struct{}{}
|
||||
prizeIDs = append(prizeIDs, log.PrizeID)
|
||||
}
|
||||
}
|
||||
if log.LocalUserID > 0 {
|
||||
userIDSet[log.LocalUserID] = struct{}{}
|
||||
}
|
||||
}
|
||||
|
||||
if len(prizeIDs) > 0 {
|
||||
var prizes []model.LivestreamPrizes
|
||||
h.repo.GetDbR().Select("id, cost_price").Where("id IN ?", prizeIDs).Find(&prizes)
|
||||
for _, p := range prizes {
|
||||
prizeCostMap[p.ID] = p.CostPrice
|
||||
}
|
||||
}
|
||||
|
||||
type inventorySnapshot struct {
|
||||
UserID int64
|
||||
ValueCents int64
|
||||
Remark string
|
||||
CreatedAt time.Time
|
||||
}
|
||||
invByUser := make(map[int64][]inventorySnapshot)
|
||||
if len(userIDSet) > 0 {
|
||||
userIDs := make([]int64, 0, len(userIDSet))
|
||||
for uid := range userIDSet {
|
||||
userIDs = append(userIDs, uid)
|
||||
}
|
||||
var inventories []inventorySnapshot
|
||||
invDB := h.repo.GetDbR().Table("user_inventory").
|
||||
Select("user_inventory.user_id, COALESCE(NULLIF(user_inventory.value_cents, 0), activity_reward_settings.price_snapshot_cents, products.price, 0) as value_cents, user_inventory.remark, user_inventory.created_at").
|
||||
Joins("LEFT JOIN activity_reward_settings ON activity_reward_settings.id = user_inventory.reward_id").
|
||||
Joins("LEFT JOIN products ON products.id = user_inventory.product_id").
|
||||
Where("user_id IN ?", userIDs).
|
||||
Where("status IN (1, 3)").
|
||||
Where("COALESCE(user_inventory.remark, '') NOT LIKE ?", "%void%")
|
||||
if startTime != nil {
|
||||
qRefundIDs += " AND l.created_at >= '" + startTime.Format("2006-01-02 15:04:05") + "'"
|
||||
invDB = invDB.Where("created_at >= ?", startTime.Add(-2*time.Hour))
|
||||
}
|
||||
if endTime != nil {
|
||||
qRefundIDs += " AND l.created_at <= '" + endTime.Format("2006-01-02 15:04:05") + "'"
|
||||
invDB = invDB.Where("created_at <= ?", endTime.Add(24*time.Hour))
|
||||
}
|
||||
h.repo.GetDbR().Raw(qRefundIDs, id).Scan(&refundedOrders)
|
||||
for _, oid := range refundedOrders {
|
||||
refundedShopOrderIDs[oid] = true
|
||||
}
|
||||
|
||||
// 4. 计算成本(使用 draw_logs.product_id 直接关联 products 表)
|
||||
// 收集未退款订单的 product_id 和对应数量
|
||||
productIDCountMap := make(map[int64]int64)
|
||||
for _, log := range drawLogs {
|
||||
// 排除已退款的订单
|
||||
if refundedShopOrderIDs[log.ShopOrderID] {
|
||||
continue
|
||||
}
|
||||
// 使用 draw_logs 中记录的 product_id
|
||||
if log.ProductID > 0 {
|
||||
productIDCountMap[log.ProductID]++
|
||||
_ = invDB.Scan(&inventories).Error
|
||||
for _, inv := range inventories {
|
||||
invByUser[inv.UserID] = append(invByUser[inv.UserID], inv)
|
||||
}
|
||||
}
|
||||
|
||||
var totalCost int64
|
||||
productCostMap := make(map[int64]int64)
|
||||
if len(productIDCountMap) > 0 {
|
||||
productIDs := make([]int64, 0, len(productIDCountMap))
|
||||
for pid := range productIDCountMap {
|
||||
productIDs = append(productIDs, pid)
|
||||
}
|
||||
|
||||
var products []model.Products
|
||||
h.repo.GetDbR().Where("id IN ?", productIDs).Find(&products)
|
||||
for _, p := range products {
|
||||
productCostMap[p.ID] = p.Price
|
||||
}
|
||||
|
||||
for productID, count := range productIDCountMap {
|
||||
if cost, ok := productCostMap[productID]; ok {
|
||||
totalCost += cost * count
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 构建 productID -> cost 映射供每日统计使用
|
||||
prizeCostMap := productCostMap
|
||||
|
||||
// 5. 按天分组统计
|
||||
dailyMap := make(map[string]*dailyLivestreamStats)
|
||||
|
||||
// 5.1 统计每日营收和退款(直接累加订单实付金额)
|
||||
type DailyAmount struct {
|
||||
type logRef struct {
|
||||
PrizeID int64
|
||||
DateKey string
|
||||
Amount int64
|
||||
Count int64
|
||||
IsRefunded int32
|
||||
}
|
||||
var dailyAmounts []DailyAmount
|
||||
queryDailyCorrect := `
|
||||
SELECT
|
||||
date_key,
|
||||
CAST(SUM(actual_pay_amount) AS SIGNED) as amount,
|
||||
COUNT(id) as cnt,
|
||||
refund_flag as is_refunded
|
||||
FROM (
|
||||
SELECT
|
||||
o.id,
|
||||
DATE_FORMAT(MIN(l.created_at), '%Y-%m-%d') as date_key,
|
||||
o.actual_pay_amount,
|
||||
IF(o.order_status = 4, 1, 0) as refund_flag
|
||||
FROM douyin_orders o
|
||||
JOIN livestream_draw_logs l ON o.id = l.douyin_order_id
|
||||
WHERE l.activity_id = ?
|
||||
`
|
||||
if startTime != nil {
|
||||
queryDailyCorrect += " AND l.created_at >= '" + startTime.Format("2006-01-02 15:04:05") + "'"
|
||||
}
|
||||
if endTime != nil {
|
||||
queryDailyCorrect += " AND l.created_at <= '" + endTime.Format("2006-01-02 15:04:05") + "'"
|
||||
}
|
||||
queryDailyCorrect += `
|
||||
GROUP BY o.id
|
||||
) as t
|
||||
GROUP BY date_key, is_refunded
|
||||
`
|
||||
rows, _ := h.repo.GetDbR().Raw(queryDailyCorrect, id).Rows()
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var da DailyAmount
|
||||
_ = rows.Scan(&da.DateKey, &da.Amount, &da.Count, &da.IsRefunded)
|
||||
dailyAmounts = append(dailyAmounts, da)
|
||||
}
|
||||
|
||||
for _, da := range dailyAmounts {
|
||||
if _, ok := dailyMap[da.DateKey]; !ok {
|
||||
dailyMap[da.DateKey] = &dailyLivestreamStats{Date: da.DateKey}
|
||||
}
|
||||
|
||||
// 修正口径:营收(Revenue) = 总流水 (含退款与未退款)
|
||||
// 这样下面的 NetProfit = TotalRevenue - TotalRefund - TotalCost 才不会双重扣除
|
||||
dailyMap[da.DateKey].TotalRevenue += da.Amount
|
||||
dailyMap[da.DateKey].OrderCount += da.Count
|
||||
|
||||
if da.IsRefunded == 1 {
|
||||
dailyMap[da.DateKey].TotalRefund += da.Amount
|
||||
dailyMap[da.DateKey].RefundCount += da.Count
|
||||
}
|
||||
}
|
||||
|
||||
// 5.2 统计每日成本(基于 Logs 的 ProductID)
|
||||
logsByKey := make(map[string][]logRef)
|
||||
keyUser := make(map[string]int64)
|
||||
keyOrder := make(map[string]string)
|
||||
for _, log := range drawLogs {
|
||||
// 排除退款订单
|
||||
if refundedShopOrderIDs[log.ShopOrderID] {
|
||||
continue
|
||||
}
|
||||
if log.ProductID <= 0 {
|
||||
key := strconv.FormatInt(log.LocalUserID, 10) + "|" + log.ShopOrderID
|
||||
logsByKey[key] = append(logsByKey[key], logRef{
|
||||
PrizeID: log.PrizeID,
|
||||
DateKey: log.CreatedAt.Format("2006-01-02"),
|
||||
})
|
||||
keyUser[key] = log.LocalUserID
|
||||
keyOrder[key] = log.ShopOrderID
|
||||
}
|
||||
|
||||
costByDate := make(map[string]int64)
|
||||
var totalCost int64
|
||||
for key, refs := range logsByKey {
|
||||
if len(refs) == 0 {
|
||||
continue
|
||||
}
|
||||
dateKey := log.CreatedAt.Format("2006-01-02")
|
||||
uid := keyUser[key]
|
||||
shopOrderID := keyOrder[key]
|
||||
|
||||
var snapshotSum int64
|
||||
if uid > 0 && shopOrderID != "" {
|
||||
for _, inv := range invByUser[uid] {
|
||||
if strings.Contains(inv.Remark, shopOrderID) {
|
||||
snapshotSum += inv.ValueCents
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if snapshotSum > 0 {
|
||||
avg := snapshotSum / int64(len(refs))
|
||||
rem := snapshotSum - avg*int64(len(refs))
|
||||
for i, r := range refs {
|
||||
c := avg
|
||||
if i == 0 {
|
||||
c += rem
|
||||
}
|
||||
totalCost += c
|
||||
costByDate[r.DateKey] += c
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
for _, r := range refs {
|
||||
c := prizeCostMap[r.PrizeID]
|
||||
totalCost += c
|
||||
costByDate[r.DateKey] += c
|
||||
}
|
||||
}
|
||||
|
||||
// 5. 按天分组统计 (营收/退款已在 dailyMap 中累计),补充成本
|
||||
for dateKey, c := range costByDate {
|
||||
ds := dailyMap[dateKey]
|
||||
if ds != nil {
|
||||
if cost, ok := prizeCostMap[log.ProductID]; ok {
|
||||
ds.TotalCost += cost
|
||||
}
|
||||
if ds == nil {
|
||||
ds = &dailyLivestreamStats{Date: dateKey}
|
||||
dailyMap[dateKey] = ds
|
||||
}
|
||||
ds.TotalCost += c
|
||||
}
|
||||
|
||||
// 6. 汇总每日数据并计算总体指标
|
||||
|
||||
@ -177,6 +177,14 @@ func (h *handler) CreateRefund() core.HandlerFunc {
|
||||
|
||||
// 全额退款:回收中奖资产与奖品库存(包含已兑换积分的资产)
|
||||
svc := usersvc.New(h.logger, h.repo)
|
||||
rate := int64(1)
|
||||
if cfgRate, _ := h.readDB.SystemConfigs.WithContext(ctx.RequestContext()).Where(h.readDB.SystemConfigs.ConfigKey.Eq("points_exchange_per_cent")).First(); cfgRate != nil {
|
||||
var rv int64
|
||||
_, _ = fmt.Sscanf(cfgRate.ConfigValue, "%d", &rv)
|
||||
if rv > 0 {
|
||||
rate = rv
|
||||
}
|
||||
}
|
||||
// 直接使用已初始化的 activity service 清理格位
|
||||
_ = h.activity.ClearIchibanPositionsByOrderID(ctx.RequestContext(), order.ID)
|
||||
|
||||
@ -191,20 +199,27 @@ func (h *handler) CreateRefund() core.HandlerFunc {
|
||||
}
|
||||
} else if inv.Status == 3 {
|
||||
// 状态3(已兑换):扣除积分并作废
|
||||
deductPoints := int64(0)
|
||||
matches := rePoints.FindStringSubmatch(inv.Remark)
|
||||
if len(matches) > 1 {
|
||||
p, _ := strconv.ParseInt(matches[1], 10, 64)
|
||||
if p > 0 {
|
||||
deductPoints = p
|
||||
}
|
||||
}
|
||||
if deductPoints <= 0 && inv.ValueCents > 0 {
|
||||
deductPoints = inv.ValueCents * rate
|
||||
}
|
||||
if deductPoints > 0 {
|
||||
// 扣除积分(记录流水)- 使用柔性扣减
|
||||
_, consumed, err := svc.ConsumePointsForRefund(ctx.RequestContext(), order.UserID, p, "user_inventory", strconv.FormatInt(inv.ID, 10), "订单退款回收兑换积分")
|
||||
_, consumed, err := svc.ConsumePointsForRefund(ctx.RequestContext(), order.UserID, deductPoints, "user_inventory", strconv.FormatInt(inv.ID, 10), "订单退款回收兑换积分")
|
||||
if err != nil {
|
||||
h.logger.Error(fmt.Sprintf("refund reclaim points failed: order=%s user=%d points=%d err=%v", order.OrderNo, order.UserID, p, err))
|
||||
h.logger.Error(fmt.Sprintf("refund reclaim points failed: order=%s user=%d points=%d err=%v", order.OrderNo, order.UserID, deductPoints, err))
|
||||
}
|
||||
if consumed < p {
|
||||
if consumed < deductPoints {
|
||||
pointsShortage = true
|
||||
}
|
||||
}
|
||||
}
|
||||
// 更新状态为2 (作废)
|
||||
_ = h.repo.GetDbW().Exec("UPDATE user_inventory SET status=2, updated_at=NOW(3), remark=CONCAT(IFNULL(remark,''),'|refund_reclaimed') WHERE id=?", inv.ID).Error
|
||||
// 恢复奖品库存
|
||||
|
||||
@ -23,7 +23,9 @@ type rewardItem struct {
|
||||
MinScore int64 `json:"min_score"`
|
||||
ProductName string `json:"product_name"`
|
||||
ProductImageUrl string `json:"product_image_url"`
|
||||
ProductPrice float64 `json:"product_price"`
|
||||
ProductPrice float64 `json:"product_price"` // 兼容:返回配置快照价
|
||||
ProductPriceSnapshot float64 `json:"product_price_snapshot"`
|
||||
ProductPriceCurrent float64 `json:"product_price_current"`
|
||||
}
|
||||
|
||||
type createRewardsRequest struct {
|
||||
@ -151,9 +153,11 @@ func (h *handler) ListIssueRewards() core.HandlerFunc {
|
||||
if p, ok := pm[v.ProductID]; ok {
|
||||
it.ProductName = p.Name
|
||||
it.ProductImageUrl = p.ImagesJSON
|
||||
it.ProductPrice = float64(p.Price) / 100
|
||||
it.ProductPriceCurrent = float64(p.Price) / 100
|
||||
}
|
||||
}
|
||||
it.ProductPriceSnapshot = float64(v.PriceSnapshotCents) / 100
|
||||
it.ProductPrice = it.ProductPriceSnapshot
|
||||
res.List[i] = it
|
||||
}
|
||||
ctx.Payload(res)
|
||||
|
||||
@ -342,13 +342,13 @@ func (h *handler) ListAppUsers() core.HandlerFunc {
|
||||
Value int64
|
||||
}
|
||||
var invRes []invResult
|
||||
h.readDB.UserInventory.WithContext(ctx.RequestContext()).ReadDB().
|
||||
LeftJoin(h.readDB.Products, h.readDB.Products.ID.EqCol(h.readDB.UserInventory.ProductID)).
|
||||
Select(h.readDB.UserInventory.UserID, h.readDB.Products.Price.Sum().As("value")).
|
||||
Where(h.readDB.UserInventory.UserID.In(userIDs...)).
|
||||
Where(h.readDB.UserInventory.Status.Eq(1)). // 1=持有
|
||||
Group(h.readDB.UserInventory.UserID).
|
||||
Scan(&invRes)
|
||||
_ = h.readDB.UserInventory.WithContext(ctx.RequestContext()).UnderlyingDB().
|
||||
Select("user_inventory.user_id, COALESCE(SUM(COALESCE(NULLIF(user_inventory.value_cents, 0), products.price, 0)), 0) AS value").
|
||||
Joins("LEFT JOIN products ON products.id = user_inventory.product_id").
|
||||
Where("user_inventory.user_id IN ?", userIDs).
|
||||
Where("user_inventory.status = ?", 1). // 1=持有
|
||||
Group("user_inventory.user_id").
|
||||
Scan(&invRes).Error
|
||||
for _, r := range invRes {
|
||||
inventoryValues[r.UserID] = r.Value
|
||||
}
|
||||
@ -542,13 +542,13 @@ func (h *handler) ListUserInvites() core.HandlerFunc {
|
||||
|
||||
// 商品价值:排除已兑换(status=2)
|
||||
var invRes []assetResult
|
||||
h.readDB.UserInventory.WithContext(ctx.RequestContext()).ReadDB().
|
||||
LeftJoin(h.readDB.Products, h.readDB.Products.ID.EqCol(h.readDB.UserInventory.ProductID)).
|
||||
Select(h.readDB.UserInventory.UserID, h.readDB.Products.Price.Sum().As("value")).
|
||||
Where(h.readDB.UserInventory.UserID.In(inviteeIDs...)).
|
||||
Where(h.readDB.UserInventory.Status.Neq(2)). // 排除已兑换
|
||||
Group(h.readDB.UserInventory.UserID).
|
||||
Scan(&invRes)
|
||||
_ = h.readDB.UserInventory.WithContext(ctx.RequestContext()).UnderlyingDB().
|
||||
Select("user_inventory.user_id, COALESCE(SUM(COALESCE(NULLIF(user_inventory.value_cents, 0), products.price, 0)), 0) AS value").
|
||||
Joins("LEFT JOIN products ON products.id = user_inventory.product_id").
|
||||
Where("user_inventory.user_id IN ?", inviteeIDs).
|
||||
Where("user_inventory.status != ?", 2). // 排除已兑换
|
||||
Group("user_inventory.user_id").
|
||||
Scan(&invRes).Error
|
||||
for _, r := range invRes {
|
||||
inviteeAssets[r.UserID] = r.Value
|
||||
}
|
||||
@ -564,7 +564,7 @@ func (h *handler) ListUserInvites() core.HandlerFunc {
|
||||
`, userID).Scan(&summaryConsume).Error
|
||||
// 资产价值汇总(不包含已兑换的商品)
|
||||
_ = h.repo.GetDbR().Raw(`
|
||||
SELECT COALESCE(SUM(p.price), 0)
|
||||
SELECT COALESCE(SUM(COALESCE(NULLIF(ui.value_cents, 0), p.price, 0)), 0)
|
||||
FROM user_inventory ui
|
||||
LEFT JOIN products p ON p.id = ui.product_id
|
||||
WHERE ui.user_id IN (SELECT invitee_id FROM user_invites WHERE inviter_id = ?)
|
||||
@ -763,7 +763,7 @@ func (h *handler) ListUserInventory() core.HandlerFunc {
|
||||
sql := `
|
||||
SELECT ui.id, ui.user_id, ui.product_id, ui.order_id, ui.activity_id, ui.reward_id,
|
||||
ui.status, ui.remark, ui.created_at, ui.updated_at,
|
||||
p.name as product_name, p.images_json as product_images, p.price as product_price
|
||||
p.name as product_name, p.images_json as product_images, COALESCE(NULLIF(ui.value_cents, 0), p.price, 0) as product_price
|
||||
FROM user_inventory ui
|
||||
LEFT JOIN products p ON p.id = ui.product_id
|
||||
WHERE ui.user_id = ?
|
||||
|
||||
@ -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()))
|
||||
|
||||
@ -32,6 +32,8 @@ func newActivityRewardSettings(db *gorm.DB, opts ...gen.DOOption) activityReward
|
||||
_activityRewardSettings.UpdatedAt = field.NewTime(tableName, "updated_at")
|
||||
_activityRewardSettings.IssueID = field.NewInt64(tableName, "issue_id")
|
||||
_activityRewardSettings.ProductID = field.NewInt64(tableName, "product_id")
|
||||
_activityRewardSettings.PriceSnapshotCents = field.NewInt64(tableName, "price_snapshot_cents")
|
||||
_activityRewardSettings.PriceSnapshotAt = field.NewTime(tableName, "price_snapshot_at")
|
||||
_activityRewardSettings.Weight = field.NewInt32(tableName, "weight")
|
||||
_activityRewardSettings.Quantity = field.NewInt64(tableName, "quantity")
|
||||
_activityRewardSettings.OriginalQty = field.NewInt64(tableName, "original_qty")
|
||||
@ -56,6 +58,8 @@ type activityRewardSettings struct {
|
||||
UpdatedAt field.Time // 更新时间
|
||||
IssueID field.Int64 // 期ID(activity_issues.id)
|
||||
ProductID field.Int64 // 奖品对应商品ID(实物奖可填)
|
||||
PriceSnapshotCents field.Int64 // 奖品配置时商品价格快照(分)
|
||||
PriceSnapshotAt field.Time // 奖品价格快照时间
|
||||
Weight field.Int32 // 抽中权重(越大越易中)
|
||||
Quantity field.Int64 // 当前可发数量(扣减)
|
||||
OriginalQty field.Int64 // 初始配置数量
|
||||
@ -85,6 +89,8 @@ func (a *activityRewardSettings) updateTableName(table string) *activityRewardSe
|
||||
a.UpdatedAt = field.NewTime(table, "updated_at")
|
||||
a.IssueID = field.NewInt64(table, "issue_id")
|
||||
a.ProductID = field.NewInt64(table, "product_id")
|
||||
a.PriceSnapshotCents = field.NewInt64(table, "price_snapshot_cents")
|
||||
a.PriceSnapshotAt = field.NewTime(table, "price_snapshot_at")
|
||||
a.Weight = field.NewInt32(table, "weight")
|
||||
a.Quantity = field.NewInt64(table, "quantity")
|
||||
a.OriginalQty = field.NewInt64(table, "original_qty")
|
||||
@ -109,12 +115,14 @@ func (a *activityRewardSettings) GetFieldByName(fieldName string) (field.OrderEx
|
||||
}
|
||||
|
||||
func (a *activityRewardSettings) fillFieldMap() {
|
||||
a.fieldMap = make(map[string]field.Expr, 13)
|
||||
a.fieldMap = make(map[string]field.Expr, 15)
|
||||
a.fieldMap["id"] = a.ID
|
||||
a.fieldMap["created_at"] = a.CreatedAt
|
||||
a.fieldMap["updated_at"] = a.UpdatedAt
|
||||
a.fieldMap["issue_id"] = a.IssueID
|
||||
a.fieldMap["product_id"] = a.ProductID
|
||||
a.fieldMap["price_snapshot_cents"] = a.PriceSnapshotCents
|
||||
a.fieldMap["price_snapshot_at"] = a.PriceSnapshotAt
|
||||
a.fieldMap["weight"] = a.Weight
|
||||
a.fieldMap["quantity"] = a.Quantity
|
||||
a.fieldMap["original_qty"] = a.OriginalQty
|
||||
|
||||
@ -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
|
||||
|
||||
@ -32,6 +32,9 @@ func newUserInventory(db *gorm.DB, opts ...gen.DOOption) userInventory {
|
||||
_userInventory.UpdatedAt = field.NewTime(tableName, "updated_at")
|
||||
_userInventory.UserID = field.NewInt64(tableName, "user_id")
|
||||
_userInventory.ProductID = field.NewInt64(tableName, "product_id")
|
||||
_userInventory.ValueCents = field.NewInt64(tableName, "value_cents")
|
||||
_userInventory.ValueSource = field.NewInt32(tableName, "value_source")
|
||||
_userInventory.ValueSnapshotAt = field.NewTime(tableName, "value_snapshot_at")
|
||||
_userInventory.OrderID = field.NewInt64(tableName, "order_id")
|
||||
_userInventory.ActivityID = field.NewInt64(tableName, "activity_id")
|
||||
_userInventory.RewardID = field.NewInt64(tableName, "reward_id")
|
||||
@ -54,6 +57,9 @@ type userInventory struct {
|
||||
UpdatedAt field.Time // 更新时间
|
||||
UserID field.Int64 // 资产归属用户ID
|
||||
ProductID field.Int64 // 资产对应商品ID(实物奖/商品)
|
||||
ValueCents field.Int64 // 资产价值快照(分)
|
||||
ValueSource field.Int32 // 价值来源:0未知 1奖励快照 2商品回退 3人工修复
|
||||
ValueSnapshotAt field.Time // 资产价值快照时间
|
||||
OrderID field.Int64 // 来源订单ID
|
||||
ActivityID field.Int64 // 来源活动ID
|
||||
RewardID field.Int64 // 来源奖励ID(activity_reward_settings.id)
|
||||
@ -81,6 +87,9 @@ func (u *userInventory) updateTableName(table string) *userInventory {
|
||||
u.UpdatedAt = field.NewTime(table, "updated_at")
|
||||
u.UserID = field.NewInt64(table, "user_id")
|
||||
u.ProductID = field.NewInt64(table, "product_id")
|
||||
u.ValueCents = field.NewInt64(table, "value_cents")
|
||||
u.ValueSource = field.NewInt32(table, "value_source")
|
||||
u.ValueSnapshotAt = field.NewTime(table, "value_snapshot_at")
|
||||
u.OrderID = field.NewInt64(table, "order_id")
|
||||
u.ActivityID = field.NewInt64(table, "activity_id")
|
||||
u.RewardID = field.NewInt64(table, "reward_id")
|
||||
@ -103,12 +112,15 @@ func (u *userInventory) GetFieldByName(fieldName string) (field.OrderExpr, bool)
|
||||
}
|
||||
|
||||
func (u *userInventory) fillFieldMap() {
|
||||
u.fieldMap = make(map[string]field.Expr, 11)
|
||||
u.fieldMap = make(map[string]field.Expr, 14)
|
||||
u.fieldMap["id"] = u.ID
|
||||
u.fieldMap["created_at"] = u.CreatedAt
|
||||
u.fieldMap["updated_at"] = u.UpdatedAt
|
||||
u.fieldMap["user_id"] = u.UserID
|
||||
u.fieldMap["product_id"] = u.ProductID
|
||||
u.fieldMap["value_cents"] = u.ValueCents
|
||||
u.fieldMap["value_source"] = u.ValueSource
|
||||
u.fieldMap["value_snapshot_at"] = u.ValueSnapshotAt
|
||||
u.fieldMap["order_id"] = u.OrderID
|
||||
u.fieldMap["activity_id"] = u.ActivityID
|
||||
u.fieldMap["reward_id"] = u.RewardID
|
||||
|
||||
@ -19,6 +19,8 @@ type ActivityRewardSettings struct {
|
||||
UpdatedAt time.Time `gorm:"column:updated_at;not null;default:CURRENT_TIMESTAMP(3);comment:更新时间" json:"updated_at"` // 更新时间
|
||||
IssueID int64 `gorm:"column:issue_id;not null;comment:期ID(activity_issues.id)" json:"issue_id"` // 期ID(activity_issues.id)
|
||||
ProductID int64 `gorm:"column:product_id;comment:奖品对应商品ID(实物奖可填)" json:"product_id"` // 奖品对应商品ID(实物奖可填)
|
||||
PriceSnapshotCents int64 `gorm:"column:price_snapshot_cents;not null;comment:奖品配置时商品价格快照(分)" json:"price_snapshot_cents"` // 奖品配置时商品价格快照(分)
|
||||
PriceSnapshotAt time.Time `gorm:"column:price_snapshot_at;comment:奖品价格快照时间" json:"price_snapshot_at"` // 奖品价格快照时间
|
||||
Weight int32 `gorm:"column:weight;not null;comment:抽中权重(越大越易中)" json:"weight"` // 抽中权重(越大越易中)
|
||||
Quantity int64 `gorm:"column:quantity;not null;comment:当前可发数量(扣减)" json:"quantity"` // 当前可发数量(扣减)
|
||||
OriginalQty int64 `gorm:"column:original_qty;not null;comment:初始配置数量" json:"original_qty"` // 初始配置数量
|
||||
|
||||
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"` // 下单奖励类型
|
||||
|
||||
@ -17,6 +17,9 @@ type UserInventory struct {
|
||||
UpdatedAt time.Time `gorm:"column:updated_at;not null;default:CURRENT_TIMESTAMP(3);comment:更新时间" json:"updated_at"` // 更新时间
|
||||
UserID int64 `gorm:"column:user_id;not null;comment:资产归属用户ID" json:"user_id"` // 资产归属用户ID
|
||||
ProductID int64 `gorm:"column:product_id;comment:资产对应商品ID(实物奖/商品)" json:"product_id"` // 资产对应商品ID(实物奖/商品)
|
||||
ValueCents int64 `gorm:"column:value_cents;not null;comment:资产价值快照(分)" json:"value_cents"` // 资产价值快照(分)
|
||||
ValueSource int32 `gorm:"column:value_source;not null;comment:价值来源:0未知 1奖励快照 2商品回退 3人工修复" json:"value_source"` // 价值来源:0未知 1奖励快照 2商品回退 3人工修复
|
||||
ValueSnapshotAt time.Time `gorm:"column:value_snapshot_at;comment:资产价值快照时间" json:"value_snapshot_at"` // 资产价值快照时间
|
||||
OrderID int64 `gorm:"column:order_id;comment:来源订单ID" json:"order_id"` // 来源订单ID
|
||||
ActivityID int64 `gorm:"column:activity_id;comment:来源活动ID" json:"activity_id"` // 来源活动ID
|
||||
RewardID int64 `gorm:"column:reward_id;comment:来源奖励ID(activity_reward_settings.id)" json:"reward_id"` // 来源奖励ID(activity_reward_settings.id)
|
||||
|
||||
@ -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],
|
||||
})
|
||||
|
||||
111
internal/service/activity/reward_snapshot_test.go
Normal file
111
internal/service/activity/reward_snapshot_test.go
Normal file
@ -0,0 +1,111 @@
|
||||
package activity
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"bindbox-game/internal/repository/mysql/dao"
|
||||
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func newRewardSnapshotTestService(t *testing.T) (*service, *dao.Query, *gorm.DB) {
|
||||
t.Helper()
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatalf("open sqlite failed: %v", err)
|
||||
}
|
||||
if err := db.Exec(`CREATE TABLE products (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT,
|
||||
price INTEGER NOT NULL,
|
||||
stock INTEGER NOT NULL,
|
||||
images_json TEXT,
|
||||
updated_at DATETIME,
|
||||
deleted_at DATETIME
|
||||
);`).Error; err != nil {
|
||||
t.Fatalf("create products failed: %v", err)
|
||||
}
|
||||
if err := db.Exec(`CREATE TABLE activity_reward_settings (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
created_at DATETIME,
|
||||
updated_at DATETIME,
|
||||
issue_id INTEGER NOT NULL,
|
||||
product_id INTEGER,
|
||||
price_snapshot_cents INTEGER NOT NULL DEFAULT 0,
|
||||
price_snapshot_at DATETIME,
|
||||
weight INTEGER NOT NULL,
|
||||
quantity INTEGER NOT NULL,
|
||||
original_qty INTEGER NOT NULL,
|
||||
level INTEGER NOT NULL,
|
||||
sort INTEGER,
|
||||
is_boss INTEGER,
|
||||
min_score INTEGER NOT NULL DEFAULT 0,
|
||||
deleted_at DATETIME
|
||||
);`).Error; err != nil {
|
||||
t.Fatalf("create activity_reward_settings failed: %v", err)
|
||||
}
|
||||
|
||||
q := dao.Use(db)
|
||||
svc := &service{readDB: q, writeDB: q}
|
||||
return svc, q, db
|
||||
}
|
||||
|
||||
func TestCreateIssueRewards_SnapshotFromProductPrice(t *testing.T) {
|
||||
svc, q, db := newRewardSnapshotTestService(t)
|
||||
ctx := context.Background()
|
||||
|
||||
if err := db.Exec("INSERT INTO products (id, name, price, stock, images_json) VALUES (101, 'A', 1000, 10, '[]')").Error; err != nil {
|
||||
t.Fatalf("insert product failed: %v", err)
|
||||
}
|
||||
|
||||
err := svc.CreateIssueRewards(ctx, 88, []CreateRewardInput{
|
||||
{
|
||||
ProductID: 101,
|
||||
Weight: 1,
|
||||
Quantity: 2,
|
||||
OriginalQty: 2,
|
||||
Level: 1,
|
||||
Sort: 1,
|
||||
IsBoss: 0,
|
||||
MinScore: 0,
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("CreateIssueRewards failed: %v", err)
|
||||
}
|
||||
|
||||
row, err := q.ActivityRewardSettings.WithContext(ctx).Where(q.ActivityRewardSettings.IssueID.Eq(88)).First()
|
||||
if err != nil {
|
||||
t.Fatalf("query reward failed: %v", err)
|
||||
}
|
||||
if row.PriceSnapshotCents != 1000 {
|
||||
t.Fatalf("expected snapshot=1000, got=%d", row.PriceSnapshotCents)
|
||||
}
|
||||
}
|
||||
|
||||
func TestModifyIssueReward_ProductChanged_RecomputeSnapshot(t *testing.T) {
|
||||
svc, q, db := newRewardSnapshotTestService(t)
|
||||
ctx := context.Background()
|
||||
|
||||
_ = db.Exec("INSERT INTO products (id, name, price, stock, images_json) VALUES (101, 'A', 1000, 10, '[]')").Error
|
||||
_ = db.Exec("INSERT INTO products (id, name, price, stock, images_json) VALUES (102, 'B', 2300, 10, '[]')").Error
|
||||
_ = db.Exec("INSERT INTO activity_reward_settings (id, issue_id, product_id, price_snapshot_cents, weight, quantity, original_qty, level, sort, is_boss, min_score) VALUES (1, 9, 101, 1000, 1, 1, 1, 1, 1, 0, 0)").Error
|
||||
|
||||
newProductID := int64(102)
|
||||
if err := svc.ModifyIssueReward(ctx, 1, ModifyRewardInput{ProductID: &newProductID}); err != nil {
|
||||
t.Fatalf("ModifyIssueReward failed: %v", err)
|
||||
}
|
||||
|
||||
row, err := q.ActivityRewardSettings.WithContext(ctx).Where(q.ActivityRewardSettings.ID.Eq(1)).First()
|
||||
if err != nil {
|
||||
t.Fatalf("query reward failed: %v", err)
|
||||
}
|
||||
if row.ProductID != 102 {
|
||||
t.Fatalf("expected product_id=102, got=%d", row.ProductID)
|
||||
}
|
||||
if row.PriceSnapshotCents != 2300 {
|
||||
t.Fatalf("expected snapshot=2300, got=%d", row.PriceSnapshotCents)
|
||||
}
|
||||
}
|
||||
@ -2,6 +2,7 @@ package activity
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"bindbox-game/internal/repository/mysql/dao"
|
||||
"bindbox-game/internal/repository/mysql/model"
|
||||
@ -12,10 +13,33 @@ import (
|
||||
// 返回: 错误信息
|
||||
func (s *service) CreateIssueRewards(ctx context.Context, issueID int64, rewards []CreateRewardInput) error {
|
||||
return s.writeDB.Transaction(func(tx *dao.Query) error {
|
||||
productIDs := make(map[int64]struct{})
|
||||
for _, r := range rewards {
|
||||
if r.ProductID > 0 {
|
||||
productIDs[r.ProductID] = struct{}{}
|
||||
}
|
||||
}
|
||||
productPriceMap := make(map[int64]int64)
|
||||
if len(productIDs) > 0 {
|
||||
ids := make([]int64, 0, len(productIDs))
|
||||
for id := range productIDs {
|
||||
ids = append(ids, id)
|
||||
}
|
||||
products, err := tx.Products.WithContext(ctx).Where(tx.Products.ID.In(ids...)).Find()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, p := range products {
|
||||
productPriceMap[p.ID] = p.Price
|
||||
}
|
||||
}
|
||||
|
||||
for _, r := range rewards {
|
||||
item := &model.ActivityRewardSettings{
|
||||
IssueID: issueID,
|
||||
ProductID: r.ProductID,
|
||||
PriceSnapshotCents: productPriceMap[r.ProductID],
|
||||
PriceSnapshotAt: time.Now(),
|
||||
Weight: r.Weight,
|
||||
Quantity: r.Quantity,
|
||||
OriginalQty: r.OriginalQty,
|
||||
|
||||
@ -17,6 +17,16 @@ func (s *service) ModifyIssueReward(ctx context.Context, rewardID int64, in Modi
|
||||
}
|
||||
if in.ProductID != nil {
|
||||
item.ProductID = *in.ProductID
|
||||
priceSnapshot := int64(0)
|
||||
if *in.ProductID > 0 {
|
||||
product, err := s.readDB.Products.WithContext(ctx).Where(s.readDB.Products.ID.Eq(*in.ProductID)).First()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
priceSnapshot = product.Price
|
||||
}
|
||||
item.PriceSnapshotCents = priceSnapshot
|
||||
item.PriceSnapshotAt = time.Now()
|
||||
}
|
||||
if in.Weight != nil {
|
||||
item.Weight = int32(*in.Weight)
|
||||
|
||||
@ -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. 自动虚拟发货 (本地状态更新)
|
||||
// 直播间奖品通常为虚拟发货,直接标记为已消费/已发货
|
||||
@ -319,6 +352,15 @@ func (s *service) reclaimLivestreamAssets(ctx context.Context, log *model.Livest
|
||||
}
|
||||
|
||||
// 2. 回收资产
|
||||
rate := int64(1)
|
||||
var cfg model.SystemConfigs
|
||||
if err := s.repo.GetDbR().Where("config_key = ?", "points_exchange_per_cent").First(&cfg).Error; err == nil {
|
||||
var rv int64
|
||||
_, _ = fmt.Sscanf(cfg.ConfigValue, "%d", &rv)
|
||||
if rv > 0 {
|
||||
rate = rv
|
||||
}
|
||||
}
|
||||
for _, inv := range inventories {
|
||||
if inv.Status == 1 {
|
||||
// 状态1(持有):作废
|
||||
@ -332,10 +374,14 @@ func (s *service) reclaimLivestreamAssets(ctx context.Context, log *model.Livest
|
||||
)
|
||||
} else if inv.Status == 3 {
|
||||
// 状态3(已兑换/发货):扣除积分
|
||||
// 查找商品价格作为积分扣除依据
|
||||
pointsToDeduct := inv.ValueCents * rate
|
||||
if pointsToDeduct <= 0 {
|
||||
// 兼容历史数据,兜底回退商品价格
|
||||
var product model.Products
|
||||
if err := s.repo.GetDbR().Where("id = ?", inv.ProductID).First(&product).Error; err == nil {
|
||||
pointsToDeduct := product.Price / 100 // 分转换为积分(假设 1积分=1分钱)
|
||||
pointsToDeduct = product.Price * rate
|
||||
}
|
||||
}
|
||||
if pointsToDeduct > 0 {
|
||||
_, consumed, err := s.userSvc.ConsumePointsForRefund(ctx, inv.UserID, pointsToDeduct, "user_inventory", fmt.Sprintf("%d", inv.ID), "直播退款回收已兑换资产")
|
||||
if err != nil {
|
||||
@ -352,7 +398,6 @@ func (s *service) reclaimLivestreamAssets(ctx context.Context, log *model.Livest
|
||||
// db.Exec("UPDATE users SET status = 3 WHERE id = ?", inv.UserID)
|
||||
}
|
||||
}
|
||||
}
|
||||
// 作废记录
|
||||
db.Model(&inv).Updates(map[string]any{
|
||||
"status": 2,
|
||||
@ -369,3 +414,133 @@ func (s *service) reclaimLivestreamAssets(ctx context.Context, log *model.Livest
|
||||
db.Exec("UPDATE livestream_prizes SET remaining = remaining + 1 WHERE id = ? AND remaining >= 0", log.PrizeID)
|
||||
s.logger.Info("[资产回收] 恢复奖品库存", zap.Int64("prize_id", log.PrizeID))
|
||||
}
|
||||
|
||||
func (s *service) resolveActivityAnchorCodes(ctx context.Context, logs []model.LivestreamDrawLogs) map[int64]string {
|
||||
result := make(map[int64]string)
|
||||
if len(logs) == 0 {
|
||||
return result
|
||||
}
|
||||
|
||||
type anchorMeta struct {
|
||||
channelID int64
|
||||
channelCode string
|
||||
}
|
||||
|
||||
activityMeta := make(map[int64]anchorMeta)
|
||||
var activityIDs []int64
|
||||
for _, log := range logs {
|
||||
if log.ActivityID <= 0 {
|
||||
continue
|
||||
}
|
||||
if _, exists := activityMeta[log.ActivityID]; exists {
|
||||
continue
|
||||
}
|
||||
activityMeta[log.ActivityID] = anchorMeta{}
|
||||
activityIDs = append(activityIDs, log.ActivityID)
|
||||
}
|
||||
|
||||
if len(activityIDs) == 0 {
|
||||
return result
|
||||
}
|
||||
|
||||
var rows []struct {
|
||||
ID int64
|
||||
ChannelID int64
|
||||
ChannelCode string
|
||||
}
|
||||
if err := s.repo.GetDbR().WithContext(ctx).
|
||||
Table("livestream_activities").
|
||||
Select("id, channel_id, channel_code").
|
||||
Where("id IN ?", activityIDs).
|
||||
Scan(&rows).Error; err != nil {
|
||||
s.logger.Error("[自动发放] 查询活动渠道信息失败", zap.Error(err))
|
||||
return result
|
||||
}
|
||||
|
||||
for _, row := range rows {
|
||||
activityMeta[row.ID] = anchorMeta{
|
||||
channelID: row.ChannelID,
|
||||
channelCode: row.ChannelCode,
|
||||
}
|
||||
}
|
||||
|
||||
missingChannelIDs := make([]int64, 0)
|
||||
seenChannels := make(map[int64]struct{})
|
||||
for _, meta := range activityMeta {
|
||||
if meta.channelCode == "" && meta.channelID > 0 {
|
||||
if _, ok := seenChannels[meta.channelID]; !ok {
|
||||
seenChannels[meta.channelID] = struct{}{}
|
||||
missingChannelIDs = append(missingChannelIDs, meta.channelID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
channelCodeMap := s.fetchChannelCodes(ctx, missingChannelIDs)
|
||||
for activityID, meta := range activityMeta {
|
||||
code := meta.channelCode
|
||||
if code == "" && meta.channelID > 0 {
|
||||
code = channelCodeMap[meta.channelID]
|
||||
}
|
||||
if code != "" {
|
||||
result[activityID] = code
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func (s *service) fetchChannelCodes(ctx context.Context, ids []int64) map[int64]string {
|
||||
result := make(map[int64]string)
|
||||
if len(ids) == 0 {
|
||||
return result
|
||||
}
|
||||
|
||||
var rows []struct {
|
||||
ID int64
|
||||
Code string
|
||||
}
|
||||
if err := s.repo.GetDbR().WithContext(ctx).
|
||||
Table("channels").
|
||||
Select("id, code").
|
||||
Where("id IN ?", ids).
|
||||
Scan(&rows).Error; err != nil {
|
||||
s.logger.Error("[自动发放] 查询渠道失败", zap.Error(err))
|
||||
return result
|
||||
}
|
||||
for _, row := range rows {
|
||||
result[row.ID] = row.Code
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func (s *service) bindAnchorInviterIfNeeded(ctx context.Context, userID int64, anchorCode string) {
|
||||
if userID <= 0 || anchorCode == "" {
|
||||
return
|
||||
}
|
||||
|
||||
userRecord, err := s.readDB.Users.WithContext(ctx).
|
||||
Select(s.readDB.Users.InviterID).
|
||||
Where(s.readDB.Users.ID.Eq(userID)).
|
||||
First()
|
||||
if err != nil {
|
||||
if !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
s.logger.Warn("[自动发放] 查询用户邀请人失败", zap.Int64("user_id", userID), zap.Error(err))
|
||||
}
|
||||
return
|
||||
}
|
||||
if userRecord.InviterID != 0 {
|
||||
return
|
||||
}
|
||||
|
||||
if _, err := s.userSvc.BindInviter(ctx, userID, user.BindInviterInput{InviteCode: anchorCode}); err != nil {
|
||||
if err == user.ErrAlreadyBound {
|
||||
return
|
||||
}
|
||||
if err == user.ErrInvalidCode {
|
||||
s.logger.Warn("[自动发放] 主播邀请码无效", zap.String("channel_code", anchorCode), zap.Int64("user_id", userID))
|
||||
return
|
||||
}
|
||||
s.logger.Warn("[自动发放] 绑定主播邀请码失败", zap.String("channel_code", anchorCode), zap.Int64("user_id", userID), zap.Error(err))
|
||||
return
|
||||
}
|
||||
s.logger.Info("[自动发放] 已补绑定主播邀请人", zap.Int64("user_id", userID), zap.String("channel_code", anchorCode))
|
||||
}
|
||||
|
||||
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 插入一个完整的任务配置(任务+档位+奖励)
|
||||
|
||||
@ -500,10 +500,21 @@ func (s *service) RedeemInventoryToPoints(ctx context.Context, userID int64, inv
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
valueCents := inv.ValueCents
|
||||
valueSource := inv.ValueSource
|
||||
valueSnapshotAt := inv.ValueSnapshotAt
|
||||
if valueCents <= 0 {
|
||||
p, err := s.readDB.Products.WithContext(ctx).Where(s.readDB.Products.ID.Eq(inv.ProductID)).First()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
valueCents = p.Price
|
||||
valueSource = 2
|
||||
valueSnapshotAt = time.Now()
|
||||
if db := s.repo.GetDbW().Exec("UPDATE user_inventory SET value_cents=?, value_source=?, value_snapshot_at=? WHERE id=? AND user_id=?", valueCents, valueSource, valueSnapshotAt, inventoryID, userID); db.Error != nil {
|
||||
return 0, db.Error
|
||||
}
|
||||
}
|
||||
cfg, _ := s.readDB.SystemConfigs.WithContext(ctx).Where(s.readDB.SystemConfigs.ConfigKey.Eq("points_exchange_per_cent")).First()
|
||||
rate := int64(1)
|
||||
if cfg != nil {
|
||||
@ -513,7 +524,7 @@ func (s *service) RedeemInventoryToPoints(ctx context.Context, userID int64, inv
|
||||
rate = r
|
||||
}
|
||||
}
|
||||
points := p.Price * rate
|
||||
points := valueCents * rate
|
||||
if err = s.AddPoints(ctx, userID, points, "redeem_reward", fmt.Sprintf("inventory:%d product:%d", inventoryID, inv.ProductID), nil, nil); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
@ -569,39 +580,63 @@ func (s *service) RedeemInventoriesToPoints(ctx context.Context, userID int64, i
|
||||
return 0, fmt.Errorf("no_valid_inventory")
|
||||
}
|
||||
|
||||
// 构建inventory映射和收集productID
|
||||
invMap := make(map[int64]*model.UserInventory, len(invList))
|
||||
// 4. 按资产快照计算总积分,缺失快照时回退商品价格并回写
|
||||
productIDs := make([]int64, 0, len(invList))
|
||||
productIDSet := make(map[int64]struct{})
|
||||
for _, inv := range invList {
|
||||
invMap[inv.ID] = inv
|
||||
if inv.ValueCents <= 0 {
|
||||
if _, ok := productIDSet[inv.ProductID]; !ok {
|
||||
productIDSet[inv.ProductID] = struct{}{}
|
||||
productIDs = append(productIDs, inv.ProductID)
|
||||
}
|
||||
}
|
||||
|
||||
// 4. 批量查询所有products(一次查询替代N次)
|
||||
}
|
||||
productPriceMap := make(map[int64]int64)
|
||||
if len(productIDs) > 0 {
|
||||
products, err := s.readDB.Products.WithContext(ctx).
|
||||
Where(s.readDB.Products.ID.In(productIDs...)).
|
||||
Find()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
productMap := make(map[int64]*model.Products, len(products))
|
||||
for _, p := range products {
|
||||
productMap[p.ID] = p
|
||||
productPriceMap[p.ID] = p.Price
|
||||
}
|
||||
}
|
||||
|
||||
// 5. 计算总积分和准备批量更新数据
|
||||
// 5. 计算总积分和准备批量更新
|
||||
var totalPoints int64
|
||||
validIDs := make([]int64, 0, len(invList))
|
||||
type valueFix struct {
|
||||
ID int64
|
||||
ValueCents int64
|
||||
ValueSource int32
|
||||
ValueSnapAt time.Time
|
||||
}
|
||||
valueFixes := make([]valueFix, 0)
|
||||
for _, inv := range invList {
|
||||
p := productMap[inv.ProductID]
|
||||
if p == nil {
|
||||
valueCents := inv.ValueCents
|
||||
valueSource := inv.ValueSource
|
||||
valueSnapshotAt := inv.ValueSnapshotAt
|
||||
if valueCents <= 0 {
|
||||
price, ok := productPriceMap[inv.ProductID]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
points := p.Price * rate
|
||||
valueCents = price
|
||||
valueSource = 2
|
||||
valueSnapshotAt = time.Now()
|
||||
valueFixes = append(valueFixes, valueFix{
|
||||
ID: inv.ID,
|
||||
ValueCents: valueCents,
|
||||
ValueSource: valueSource,
|
||||
ValueSnapAt: valueSnapshotAt,
|
||||
})
|
||||
}
|
||||
if valueCents <= 0 {
|
||||
continue
|
||||
}
|
||||
points := valueCents * rate
|
||||
totalPoints += points
|
||||
validIDs = append(validIDs, inv.ID)
|
||||
}
|
||||
@ -639,6 +674,14 @@ func (s *service) RedeemInventoriesToPoints(ctx context.Context, userID int64, i
|
||||
}
|
||||
|
||||
// 批量更新inventory状态(一次UPDATE替代N次)
|
||||
for _, fix := range valueFixes {
|
||||
if err := tx.Exec(
|
||||
"UPDATE user_inventory SET value_cents=?, value_source=?, value_snapshot_at=? WHERE id=? AND user_id=?",
|
||||
fix.ValueCents, fix.ValueSource, fix.ValueSnapAt, fix.ID, userID,
|
||||
).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := tx.Exec(
|
||||
"UPDATE user_inventory SET status=3, updated_at=NOW(3), remark=CONCAT(IFNULL(remark,''),'|batch_redeemed') WHERE id IN ? AND user_id=? AND status=1",
|
||||
validIDs, userID,
|
||||
|
||||
@ -91,10 +91,12 @@ func (s *service) ListInventoryWithProduct(ctx context.Context, userID int64, pa
|
||||
p := products[r.ProductID]
|
||||
name := ""
|
||||
images := ""
|
||||
var price int64
|
||||
price := r.ValueCents
|
||||
if p != nil {
|
||||
name = p.Name
|
||||
images = p.ImagesJSON
|
||||
}
|
||||
if price <= 0 && p != nil {
|
||||
price = p.Price
|
||||
}
|
||||
sh := shipMap[r.ID]
|
||||
@ -177,10 +179,12 @@ func (s *service) ListInventoryWithProductActive(ctx context.Context, userID int
|
||||
p := products[r.ProductID]
|
||||
name := ""
|
||||
images := ""
|
||||
var price int64
|
||||
price := r.ValueCents
|
||||
if p != nil {
|
||||
name = p.Name
|
||||
images = p.ImagesJSON
|
||||
}
|
||||
if price <= 0 && p != nil {
|
||||
price = p.Price
|
||||
}
|
||||
sh := shipMap[r.ID]
|
||||
@ -217,6 +221,7 @@ func (s *service) ListInventoryAggregated(ctx context.Context, userID int64, pag
|
||||
ProductID int64 `gorm:"column:product_id"`
|
||||
Status int32 `gorm:"column:status"`
|
||||
Count int64 `gorm:"column:count"`
|
||||
ValueCents int64 `gorm:"column:value_cents"`
|
||||
UpdatedAt time.Time `gorm:"column:updated_at"`
|
||||
}
|
||||
|
||||
@ -225,6 +230,7 @@ func (s *service) ListInventoryAggregated(ctx context.Context, userID int64, pag
|
||||
s.readDB.UserInventory.ProductID,
|
||||
s.readDB.UserInventory.Status,
|
||||
s.readDB.UserInventory.ID.Count().As("count"),
|
||||
s.readDB.UserInventory.ValueCents.Max().As("value_cents"),
|
||||
s.readDB.UserInventory.UpdatedAt.Max().As("updated_at"),
|
||||
).
|
||||
Where(s.readDB.UserInventory.UserID.Eq(userID))
|
||||
@ -272,10 +278,12 @@ func (s *service) ListInventoryAggregated(ctx context.Context, userID int64, pag
|
||||
p, _ := s.readDB.Products.WithContext(ctx).ReadDB().Where(s.readDB.Products.ID.Eq(g.ProductID)).First()
|
||||
name := "未知商品"
|
||||
images := ""
|
||||
var price int64
|
||||
price := g.ValueCents
|
||||
if p != nil {
|
||||
name = p.Name
|
||||
images = p.ImagesJSON
|
||||
}
|
||||
if price <= 0 && p != nil {
|
||||
price = p.Price
|
||||
}
|
||||
|
||||
|
||||
@ -47,11 +47,13 @@ func (s *service) GrantReward(ctx context.Context, userID int64, req GrantReward
|
||||
// 执行事务
|
||||
err := s.writeDB.Transaction(func(tx *dao.Query) error {
|
||||
logger.Info("开始事务处理")
|
||||
var rewardSetting *model.ActivityRewardSettings
|
||||
var err error
|
||||
|
||||
// 1. 检查奖励配置库存(如果提供了reward_id)
|
||||
if req.RewardID != nil {
|
||||
logger.Info("检查奖励配置", zap.Int64("reward_id", *req.RewardID))
|
||||
rewardSetting, err := tx.ActivityRewardSettings.WithContext(ctx).Where(
|
||||
rewardSetting, err = tx.ActivityRewardSettings.WithContext(ctx).Where(
|
||||
tx.ActivityRewardSettings.ID.Eq(*req.RewardID),
|
||||
).First()
|
||||
if err != nil {
|
||||
@ -109,7 +111,7 @@ func (s *service) GrantReward(ctx context.Context, userID int64, req GrantReward
|
||||
}
|
||||
|
||||
logger.Info("创建订单", zap.Any("order", order))
|
||||
err := tx.Orders.WithContext(ctx).Create(order)
|
||||
err = tx.Orders.WithContext(ctx).Create(order)
|
||||
if err != nil {
|
||||
logger.Error("创建订单失败", zap.Error(err))
|
||||
return fmt.Errorf("创建订单失败: %w", err)
|
||||
@ -163,6 +165,24 @@ func (s *service) GrantReward(ctx context.Context, userID int64, req GrantReward
|
||||
inventory := &model.UserInventory{
|
||||
UserID: userID,
|
||||
ProductID: req.ProductID,
|
||||
ValueCents: func() int64 {
|
||||
if rewardSetting != nil && rewardSetting.PriceSnapshotCents > 0 {
|
||||
return rewardSetting.PriceSnapshotCents
|
||||
}
|
||||
return product.Price
|
||||
}(),
|
||||
ValueSource: func() int32 {
|
||||
if rewardSetting != nil && rewardSetting.PriceSnapshotCents > 0 {
|
||||
return 1
|
||||
}
|
||||
return 2
|
||||
}(),
|
||||
ValueSnapshotAt: func() time.Time {
|
||||
if rewardSetting != nil && !rewardSetting.PriceSnapshotAt.IsZero() {
|
||||
return rewardSetting.PriceSnapshotAt
|
||||
}
|
||||
return time.Now()
|
||||
}(),
|
||||
OrderID: orderID,
|
||||
ActivityID: func() int64 {
|
||||
if req.ActivityID != nil {
|
||||
@ -288,6 +308,7 @@ func (s *service) GrantRewardToOrder(ctx context.Context, userID int64, req Gran
|
||||
// 执行事务
|
||||
err := s.writeDB.Transaction(func(tx *dao.Query) error {
|
||||
logger.Info("开始事务处理")
|
||||
var rewardSetting *model.ActivityRewardSettings
|
||||
|
||||
// 1. 验证订单存在且属于该用户
|
||||
order, err := tx.Orders.WithContext(ctx).Where(
|
||||
@ -322,6 +343,13 @@ func (s *service) GrantRewardToOrder(ctx context.Context, userID int64, req Gran
|
||||
logger.Error("奖励库存不足或不存在")
|
||||
return fmt.Errorf("奖励库存不足或不存在")
|
||||
}
|
||||
rewardSetting, err = tx.ActivityRewardSettings.WithContext(ctx).Where(
|
||||
tx.ActivityRewardSettings.ID.Eq(*req.RewardID),
|
||||
).First()
|
||||
if err != nil {
|
||||
logger.Error("查询奖励配置失败", zap.Error(err))
|
||||
return fmt.Errorf("查询奖励配置失败: %w", err)
|
||||
}
|
||||
logger.Info("奖励库存扣减成功(乐观锁)")
|
||||
}
|
||||
|
||||
@ -355,6 +383,24 @@ func (s *service) GrantRewardToOrder(ctx context.Context, userID int64, req Gran
|
||||
inventory := &model.UserInventory{
|
||||
UserID: userID,
|
||||
ProductID: req.ProductID,
|
||||
ValueCents: func() int64 {
|
||||
if rewardSetting != nil && rewardSetting.PriceSnapshotCents > 0 {
|
||||
return rewardSetting.PriceSnapshotCents
|
||||
}
|
||||
return product.Price
|
||||
}(),
|
||||
ValueSource: func() int32 {
|
||||
if rewardSetting != nil && rewardSetting.PriceSnapshotCents > 0 {
|
||||
return 1
|
||||
}
|
||||
return 2
|
||||
}(),
|
||||
ValueSnapshotAt: func() time.Time {
|
||||
if rewardSetting != nil && !rewardSetting.PriceSnapshotAt.IsZero() {
|
||||
return rewardSetting.PriceSnapshotAt
|
||||
}
|
||||
return time.Now()
|
||||
}(),
|
||||
OrderID: req.OrderID, // 关联到原抽奖订单
|
||||
ActivityID: func() int64 {
|
||||
if req.ActivityID != nil {
|
||||
|
||||
@ -3,6 +3,7 @@ package user
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"bindbox-game/internal/repository/mysql/dao"
|
||||
"bindbox-game/internal/repository/mysql/model"
|
||||
@ -11,7 +12,8 @@ import (
|
||||
// BatchRewardItem 批量发放的单个奖励项
|
||||
type BatchRewardItem struct {
|
||||
ProductID int64
|
||||
RewardID *int64 // 可选,一番赏模式需要传入以扣减库存
|
||||
RewardID *int64 // 用于资产归因/价值快照
|
||||
DeductRewardStock bool // 是否按 RewardID 扣减奖池库存(仅一番赏)
|
||||
ActivityID int64
|
||||
Remark string
|
||||
}
|
||||
@ -60,6 +62,26 @@ func (s *service) BatchGrantRewardsToOrder(ctx context.Context, userID int64, or
|
||||
for _, p := range products {
|
||||
productMap[p.ID] = p
|
||||
}
|
||||
rewardSnapshotMap := make(map[int64]*model.ActivityRewardSettings)
|
||||
rewardIDSet := make(map[int64]struct{})
|
||||
for _, item := range items {
|
||||
if item.RewardID != nil && *item.RewardID > 0 {
|
||||
rewardIDSet[*item.RewardID] = struct{}{}
|
||||
}
|
||||
}
|
||||
if len(rewardIDSet) > 0 {
|
||||
rewardIDs := make([]int64, 0, len(rewardIDSet))
|
||||
for id := range rewardIDSet {
|
||||
rewardIDs = append(rewardIDs, id)
|
||||
}
|
||||
rewardRows, err := tx.ActivityRewardSettings.WithContext(ctx).Where(tx.ActivityRewardSettings.ID.In(rewardIDs...)).Find()
|
||||
if err != nil {
|
||||
return fmt.Errorf("查询奖励配置失败: %w", err)
|
||||
}
|
||||
for _, row := range rewardRows {
|
||||
rewardSnapshotMap[row.ID] = row
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 批量创建订单项和库存记录
|
||||
var orderItems []*model.OrderItems
|
||||
@ -85,6 +107,30 @@ func (s *service) BatchGrantRewardsToOrder(ctx context.Context, userID int64, or
|
||||
inventories = append(inventories, &model.UserInventory{
|
||||
UserID: userID,
|
||||
ProductID: item.ProductID,
|
||||
ValueCents: func() int64 {
|
||||
if item.RewardID != nil {
|
||||
if reward, ok := rewardSnapshotMap[*item.RewardID]; ok && reward.PriceSnapshotCents > 0 {
|
||||
return reward.PriceSnapshotCents
|
||||
}
|
||||
}
|
||||
return product.Price
|
||||
}(),
|
||||
ValueSource: func() int32 {
|
||||
if item.RewardID != nil {
|
||||
if reward, ok := rewardSnapshotMap[*item.RewardID]; ok && reward.PriceSnapshotCents > 0 {
|
||||
return 1
|
||||
}
|
||||
}
|
||||
return 2
|
||||
}(),
|
||||
ValueSnapshotAt: func() time.Time {
|
||||
if item.RewardID != nil {
|
||||
if reward, ok := rewardSnapshotMap[*item.RewardID]; ok && !reward.PriceSnapshotAt.IsZero() {
|
||||
return reward.PriceSnapshotAt
|
||||
}
|
||||
}
|
||||
return time.Now()
|
||||
}(),
|
||||
OrderID: orderID,
|
||||
ActivityID: item.ActivityID,
|
||||
RewardID: func() int64 {
|
||||
@ -118,10 +164,10 @@ func (s *service) BatchGrantRewardsToOrder(ctx context.Context, userID int64, or
|
||||
}
|
||||
}
|
||||
|
||||
// 5. 处理一番赏库存扣减(按RewardID聚合后批量更新)
|
||||
// 5. 处理奖池库存扣减(仅对明确要求扣减的奖励)
|
||||
rewardDeductMap := make(map[int64]int64)
|
||||
for _, item := range items {
|
||||
if item.RewardID != nil {
|
||||
if item.DeductRewardStock && item.RewardID != nil && *item.RewardID > 0 {
|
||||
rewardDeductMap[*item.RewardID]++
|
||||
}
|
||||
}
|
||||
|
||||
30
migrations/20260221_add_reward_inventory_value_snapshots.sql
Normal file
30
migrations/20260221_add_reward_inventory_value_snapshots.sql
Normal file
@ -0,0 +1,30 @@
|
||||
ALTER TABLE `activity_reward_settings`
|
||||
ADD COLUMN `price_snapshot_cents` BIGINT NOT NULL DEFAULT 0 COMMENT '奖品配置时商品价格快照(分)' AFTER `product_id`,
|
||||
ADD COLUMN `price_snapshot_at` DATETIME(3) NULL COMMENT '奖品价格快照时间' AFTER `price_snapshot_cents`;
|
||||
|
||||
ALTER TABLE `user_inventory`
|
||||
ADD COLUMN `value_cents` BIGINT NOT NULL DEFAULT 0 COMMENT '资产价值快照(分)' AFTER `product_id`,
|
||||
ADD COLUMN `value_source` TINYINT NOT NULL DEFAULT 0 COMMENT '价值来源:0未知 1奖励快照 2商品回退 3人工修复' AFTER `value_cents`,
|
||||
ADD COLUMN `value_snapshot_at` DATETIME(3) NULL COMMENT '资产价值快照时间' AFTER `value_source`;
|
||||
|
||||
UPDATE `activity_reward_settings` ars
|
||||
LEFT JOIN `products` p ON p.id = ars.product_id
|
||||
SET ars.price_snapshot_cents = COALESCE(p.price, 0),
|
||||
ars.price_snapshot_at = NOW(3)
|
||||
WHERE ars.price_snapshot_cents = 0;
|
||||
|
||||
UPDATE `user_inventory` ui
|
||||
LEFT JOIN `activity_reward_settings` ars ON ars.id = ui.reward_id
|
||||
SET ui.value_cents = ars.price_snapshot_cents,
|
||||
ui.value_source = 1,
|
||||
ui.value_snapshot_at = COALESCE(ars.price_snapshot_at, NOW(3))
|
||||
WHERE ui.value_cents = 0
|
||||
AND ui.reward_id > 0
|
||||
AND ars.price_snapshot_cents > 0;
|
||||
|
||||
UPDATE `user_inventory` ui
|
||||
LEFT JOIN `products` p ON p.id = ui.product_id
|
||||
SET ui.value_cents = COALESCE(p.price, 0),
|
||||
ui.value_source = 2,
|
||||
ui.value_snapshot_at = NOW(3)
|
||||
WHERE ui.value_cents = 0;
|
||||
@ -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