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/core"
|
||||||
"bindbox-game/internal/pkg/validation"
|
"bindbox-game/internal/pkg/validation"
|
||||||
"bindbox-game/internal/repository/mysql/model"
|
"bindbox-game/internal/repository/mysql/model"
|
||||||
|
financesvc "bindbox-game/internal/service/finance"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
@ -23,20 +24,25 @@ type activityProfitLossRequest struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type activityProfitLossItem struct {
|
type activityProfitLossItem struct {
|
||||||
ActivityID int64 `json:"activity_id"`
|
ActivityID int64 `json:"activity_id"`
|
||||||
ActivityName string `json:"activity_name"`
|
ActivityName string `json:"activity_name"`
|
||||||
Status int32 `json:"status"`
|
Status int32 `json:"status"`
|
||||||
DrawCount int64 `json:"draw_count"`
|
DrawCount int64 `json:"draw_count"`
|
||||||
GamePassCount int64 `json:"game_pass_count"` // 次卡抽奖次数
|
GamePassCount int64 `json:"game_pass_count"` // 次卡抽奖次数
|
||||||
PaymentCount int64 `json:"payment_count"` // 现金/优惠券抽奖次数
|
PaymentCount int64 `json:"payment_count"` // 现金/优惠券抽奖次数
|
||||||
RefundCount int64 `json:"refund_count"` // 退款/取消抽奖次数
|
RefundCount int64 `json:"refund_count"` // 退款/取消抽奖次数
|
||||||
PlayerCount int64 `json:"player_count"`
|
PlayerCount int64 `json:"player_count"`
|
||||||
TotalRevenue int64 `json:"total_revenue"` // 实际支付金额 (分)
|
TotalRevenue int64 `json:"total_revenue"` // 实际支付金额 (分)
|
||||||
TotalDiscount int64 `json:"total_discount"` // 优惠券抵扣金额 (分)
|
TotalDiscount int64 `json:"total_discount"` // 优惠券抵扣金额 (分)
|
||||||
TotalGamePassValue int64 `json:"total_game_pass_value"` // 次卡价值 (分)
|
TotalGamePassValue int64 `json:"total_game_pass_value"` // 次卡价值 (分)
|
||||||
TotalCost int64 `json:"total_cost"` // 奖品标价总和 (分)
|
TotalCost int64 `json:"total_cost"` // 奖品标价总和 (分)
|
||||||
Profit int64 `json:"profit"` // (Revenue + Discount + GamePassValue) - Cost
|
SpendingPaidCoupon int64 `json:"spending_paid_coupon"`
|
||||||
ProfitRate float64 `json:"profit_rate"` // Profit / (Revenue + Discount + GamePassValue)
|
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)
|
||||||
}
|
}
|
||||||
|
|
||||||
type activityProfitLossResponse struct {
|
type activityProfitLossResponse struct {
|
||||||
@ -170,14 +176,19 @@ func (h *handler) DashboardActivityProfitLoss() core.HandlerFunc {
|
|||||||
}
|
}
|
||||||
var revenueStats []revenueStat
|
var revenueStats []revenueStat
|
||||||
|
|
||||||
// 修正: 按抽奖次数比例分摊订单金额 (解决多活动订单归因问题)
|
// 修正: 按抽奖次数比例分摊订单金额,且次卡订单不计入支付+优惠券口径(严格二选一)
|
||||||
// 逻辑: 活动分摊收入 = 订单实际金额 * (该活动在该订单中的抽奖次数 / 该订单总抽奖次数)
|
|
||||||
var err error
|
var err error
|
||||||
err = db.Table(model.TableNameOrders).
|
err = db.Table(model.TableNameOrders).
|
||||||
Select(`
|
Select(`
|
||||||
order_activity_draws.activity_id,
|
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%' OR (orders.actual_amount = 0 AND orders.remark LIKE '%use_game_pass%')
|
||||||
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
|
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)
|
// Subquery 1: Calculate draw counts per order per activity (and link to issue->activity)
|
||||||
Joins(`JOIN (
|
Joins(`JOIN (
|
||||||
@ -211,22 +222,41 @@ func (h *handler) DashboardActivityProfitLoss() core.HandlerFunc {
|
|||||||
// 4. 统计成本 (通过 user_inventory 关联 products 和 orders)
|
// 4. 统计成本 (通过 user_inventory 关联 products 和 orders)
|
||||||
// 修正:增加关联 orders 表,过滤掉已退款/取消的订单 (status!=2)
|
// 修正:增加关联 orders 表,过滤掉已退款/取消的订单 (status!=2)
|
||||||
type costStat struct {
|
type costStat struct {
|
||||||
ActivityID int64
|
ActivityID int64
|
||||||
TotalCost int64
|
TotalCost int64
|
||||||
|
TotalCostBase int64
|
||||||
|
AvgMultiplierX10 int64
|
||||||
}
|
}
|
||||||
var costStats []costStat
|
var costStats []costStat
|
||||||
db.Table(model.TableNameUserInventory).
|
if err := db.Table(model.TableNameUserInventory).
|
||||||
Select("user_inventory.activity_id, SUM(products.price) as total_cost").
|
Select(`
|
||||||
Joins("JOIN products ON products.id = user_inventory.product_id").
|
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").
|
Joins("LEFT JOIN orders ON orders.id = user_inventory.order_id").
|
||||||
Where("user_inventory.activity_id IN ?", activityIDs).
|
Joins("LEFT JOIN activity_reward_settings ON activity_reward_settings.id = user_inventory.reward_id").
|
||||||
Where("orders.status = ?", 2). // 仅统计已支付订单产生的成本
|
Joins("LEFT JOIN activity_issues AS cost_issues ON cost_issues.id = activity_reward_settings.issue_id").
|
||||||
Group("user_inventory.activity_id").
|
Joins("LEFT JOIN products ON products.id = user_inventory.product_id").
|
||||||
Scan(&costStats)
|
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").
|
||||||
for _, s := range costStats {
|
Where("COALESCE(NULLIF(user_inventory.activity_id, 0), cost_issues.activity_id) IN ?", activityIDs).
|
||||||
if item, ok := activityMap[s.ActivityID]; ok {
|
Where("user_inventory.status IN ?", []int{1, 3}).
|
||||||
item.TotalCost = s.TotalCost
|
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. 计算盈亏和比率
|
// 6. 计算盈亏和比率
|
||||||
// 公式: 盈亏 = (支付金额 + 优惠券抵扣 + 次卡价值) - 产品成本
|
// 公式: 盈亏 = 用户支出(普通单支付+优惠券 或 次卡价值) - 奖品成本(含道具卡倍率)
|
||||||
finalList := make([]activityProfitLossItem, 0, len(activities))
|
finalList := make([]activityProfitLossItem, 0, len(activities))
|
||||||
for _, a := range activities {
|
for _, a := range activities {
|
||||||
item := activityMap[a.ID]
|
item := activityMap[a.ID]
|
||||||
totalIncome := item.TotalRevenue + item.TotalDiscount + item.TotalGamePassValue
|
item.SpendingPaidCoupon = item.TotalRevenue + item.TotalDiscount
|
||||||
item.Profit = totalIncome - item.TotalCost
|
item.SpendingGamePass = item.TotalGamePassValue
|
||||||
if totalIncome > 0 {
|
totalIncome := item.SpendingPaidCoupon + item.SpendingGamePass
|
||||||
item.ProfitRate = float64(item.Profit) / float64(totalIncome)
|
item.Profit, item.ProfitRate = financesvc.ComputeProfit(totalIncome, item.TotalCost)
|
||||||
}
|
|
||||||
finalList = append(finalList, *item)
|
finalList = append(finalList, *item)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -418,6 +447,7 @@ func (h *handler) DashboardActivityLogs() core.HandlerFunc {
|
|||||||
DrawCount int64 // 该订单的总抽奖次数(用于金额分摊)
|
DrawCount int64 // 该订单的总抽奖次数(用于金额分摊)
|
||||||
UsedDrawLogID int64 // 道具卡实际使用的日志ID
|
UsedDrawLogID int64 // 道具卡实际使用的日志ID
|
||||||
CreatedAt time.Time
|
CreatedAt time.Time
|
||||||
|
ActivityPrice int64
|
||||||
}
|
}
|
||||||
|
|
||||||
logsQuery := db.Table(model.TableNameActivityDrawLogs).
|
logsQuery := db.Table(model.TableNameActivityDrawLogs).
|
||||||
@ -429,7 +459,7 @@ func (h *handler) DashboardActivityLogs() core.HandlerFunc {
|
|||||||
activity_reward_settings.product_id,
|
activity_reward_settings.product_id,
|
||||||
COALESCE(products.name, '') as product_name,
|
COALESCE(products.name, '') as product_name,
|
||||||
COALESCE(products.images_json, '[]') as images_json,
|
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.actual_amount, 0) as order_amount,
|
||||||
COALESCE(orders.discount_amount, 0) as discount_amount,
|
COALESCE(orders.discount_amount, 0) as discount_amount,
|
||||||
COALESCE(orders.points_amount, 0) as points_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(orders.order_no, '') as order_no,
|
||||||
COALESCE(order_draw_counts.draw_count, 1) as draw_count,
|
COALESCE(order_draw_counts.draw_count, 1) as draw_count,
|
||||||
COALESCE(user_item_cards.used_draw_log_id, 0) as used_draw_log_id,
|
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 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 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 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 products ON products.id = activity_reward_settings.product_id").
|
||||||
@ -590,6 +622,14 @@ func (h *handler) DashboardActivityLogs() core.HandlerFunc {
|
|||||||
perDrawDiscountAmount := l.DiscountAmount / drawCount
|
perDrawDiscountAmount := l.DiscountAmount / drawCount
|
||||||
perDrawPointsAmount := l.PointsAmount / 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.CouponDiscount = perDrawDiscountAmount
|
||||||
paymentDetails.PointsDiscount = perDrawPointsAmount
|
paymentDetails.PointsDiscount = perDrawPointsAmount
|
||||||
|
|||||||
@ -2,6 +2,7 @@ package admin
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"database/sql"
|
"database/sql"
|
||||||
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
"time"
|
"time"
|
||||||
@ -1483,7 +1484,7 @@ func (h *handler) DashboardPrizeDistribution() core.HandlerFunc {
|
|||||||
"SUM(activity_reward_settings.quantity) as level_rem_qty",
|
"SUM(activity_reward_settings.quantity) as level_rem_qty",
|
||||||
"SUM(activity_reward_settings.weight) as level_total_prob",
|
"SUM(activity_reward_settings.weight) as level_total_prob",
|
||||||
"COUNT(activity_reward_settings.id) as prize_count",
|
"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").
|
Group("activity_reward_settings.level").
|
||||||
Order("activity_reward_settings.level").
|
Order("activity_reward_settings.level").
|
||||||
@ -1665,21 +1666,26 @@ func (h *handler) OperationsProductPerformance() core.HandlerFunc {
|
|||||||
var rows []drawRow
|
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("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 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 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 >= ?", s).
|
||||||
Where("activity_draw_logs.created_at <= ?", e).
|
Where("activity_draw_logs.created_at <= ?", e).
|
||||||
Select(
|
Select(
|
||||||
"activity_issues.activity_id",
|
"activity_issues.activity_id",
|
||||||
"COUNT(activity_draw_logs.id) as count",
|
"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").
|
Group("activity_issues.activity_id").
|
||||||
Order("count DESC").
|
Order("count DESC").
|
||||||
Limit(10).
|
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))
|
activityIDs := make([]int64, len(rows))
|
||||||
|
|||||||
@ -5,9 +5,11 @@ import (
|
|||||||
"bindbox-game/internal/pkg/core"
|
"bindbox-game/internal/pkg/core"
|
||||||
"bindbox-game/internal/pkg/validation"
|
"bindbox-game/internal/pkg/validation"
|
||||||
"bindbox-game/internal/repository/mysql/model"
|
"bindbox-game/internal/repository/mysql/model"
|
||||||
|
financesvc "bindbox-game/internal/service/finance"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"sort"
|
"sort"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -21,16 +23,21 @@ type spendingLeaderboardRequest struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type spendingLeaderboardItem struct {
|
type spendingLeaderboardItem struct {
|
||||||
UserID int64 `json:"user_id"`
|
UserID int64 `json:"user_id"`
|
||||||
Nickname string `json:"nickname"`
|
Nickname string `json:"nickname"`
|
||||||
Avatar string `json:"avatar"`
|
Avatar string `json:"avatar"`
|
||||||
OrderCount int64 `json:"-"` // Hidden
|
OrderCount int64 `json:"-"` // Hidden
|
||||||
TotalSpending int64 `json:"-"` // Hidden
|
TotalSpending int64 `json:"-"` // Hidden
|
||||||
TotalPrizeValue int64 `json:"-"` // Hidden
|
TotalPrizeValue int64 `json:"-"` // Hidden
|
||||||
TotalDiscount int64 `json:"total_discount"` // Total Coupon Discount (Fen)
|
SpendingPaidCoupon int64 `json:"spending_paid_coupon"`
|
||||||
TotalPoints int64 `json:"total_points"` // Total Points Discount (Fen)
|
SpendingGamePass int64 `json:"spending_game_pass"`
|
||||||
GamePassCount int64 `json:"game_pass_count"` // Count of SourceType=4
|
PrizeCostBase int64 `json:"prize_cost_base"`
|
||||||
ItemCardCount int64 `json:"item_card_count"` // Count where ItemCardID > 0
|
PrizeCostMultiplier int64 `json:"prize_cost_multiplier"`
|
||||||
|
PrizeCostFinal int64 `json:"prize_cost_final"`
|
||||||
|
TotalDiscount int64 `json:"total_discount"` // Total Coupon Discount (Fen)
|
||||||
|
TotalPoints int64 `json:"total_points"` // Total Points Discount (Fen)
|
||||||
|
GamePassCount int64 `json:"game_pass_count"` // Count of SourceType=4
|
||||||
|
ItemCardCount int64 `json:"item_card_count"` // Count where ItemCardID > 0
|
||||||
// Breakdown by game type
|
// Breakdown by game type
|
||||||
IchibanSpending int64 `json:"ichiban_spending"`
|
IchibanSpending int64 `json:"ichiban_spending"`
|
||||||
IchibanPrize int64 `json:"ichiban_prize"`
|
IchibanPrize int64 `json:"ichiban_prize"`
|
||||||
@ -93,6 +100,7 @@ func (h *handler) DashboardPlayerSpendingLeaderboard() core.HandlerFunc {
|
|||||||
TotalDiscount int64
|
TotalDiscount int64
|
||||||
TotalPoints int64
|
TotalPoints int64
|
||||||
GamePassCount int64
|
GamePassCount int64
|
||||||
|
GamePassSpending int64
|
||||||
ItemCardCount int64
|
ItemCardCount int64
|
||||||
IchibanSpending int64
|
IchibanSpending int64
|
||||||
IchibanCount int64
|
IchibanCount int64
|
||||||
@ -106,7 +114,7 @@ func (h *handler) DashboardPlayerSpendingLeaderboard() core.HandlerFunc {
|
|||||||
var stats []orderStat
|
var stats []orderStat
|
||||||
|
|
||||||
query := db.Table(model.TableNameOrders).
|
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)
|
Where("orders.status = ?", 2)
|
||||||
|
|
||||||
if req.RangeType != "all" {
|
if req.RangeType != "all" {
|
||||||
@ -115,20 +123,42 @@ func (h *handler) DashboardPlayerSpendingLeaderboard() core.HandlerFunc {
|
|||||||
|
|
||||||
if err := query.Select(`
|
if err := query.Select(`
|
||||||
orders.user_id,
|
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,
|
COUNT(orders.id) as order_count,
|
||||||
SUM(orders.discount_amount) as total_discount,
|
SUM(orders.discount_amount) as total_discount,
|
||||||
SUM(orders.points_amount) as total_points,
|
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 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 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 = 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 = 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 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,
|
0 as livestream_spending,
|
||||||
SUM(CASE WHEN orders.source_type = 5 THEN 1 ELSE 0 END) as livestream_count
|
0 as livestream_count
|
||||||
`).
|
`).
|
||||||
Group("orders.user_id").
|
Group("orders.user_id").
|
||||||
Order("total_amount DESC").
|
Order("total_amount DESC").
|
||||||
@ -152,6 +182,7 @@ func (h *handler) DashboardPlayerSpendingLeaderboard() core.HandlerFunc {
|
|||||||
TotalDiscount: s.TotalDiscount,
|
TotalDiscount: s.TotalDiscount,
|
||||||
TotalPoints: s.TotalPoints,
|
TotalPoints: s.TotalPoints,
|
||||||
GamePassCount: s.GamePassCount,
|
GamePassCount: s.GamePassCount,
|
||||||
|
SpendingGamePass: s.GamePassSpending,
|
||||||
ItemCardCount: s.ItemCardCount,
|
ItemCardCount: s.ItemCardCount,
|
||||||
IchibanSpending: s.IchibanSpending,
|
IchibanSpending: s.IchibanSpending,
|
||||||
IchibanCount: s.IchibanCount,
|
IchibanCount: s.IchibanCount,
|
||||||
@ -186,7 +217,7 @@ func (h *handler) DashboardPlayerSpendingLeaderboard() core.HandlerFunc {
|
|||||||
if item, ok := statMap[ds.UserID]; ok {
|
if item, ok := statMap[ds.UserID]; ok {
|
||||||
item.LivestreamSpending = ds.Amount
|
item.LivestreamSpending = ds.Amount
|
||||||
item.LivestreamCount = ds.Count // Use real paid order count
|
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)
|
// Join with Products, Activities, and Orders (for livestream detection)
|
||||||
query := db.Table(model.TableNameUserInventory).
|
query := db.Table(model.TableNameUserInventory).
|
||||||
Joins("JOIN products ON products.id = user_inventory.product_id").
|
Joins("LEFT JOIN activity_reward_settings ON activity_reward_settings.id = user_inventory.reward_id").
|
||||||
Joins("LEFT JOIN activities ON activities.id = user_inventory.activity_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 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)
|
Where("user_inventory.user_id IN ?", userIDs)
|
||||||
|
|
||||||
if req.RangeType != "all" {
|
if req.RangeType != "all" {
|
||||||
@ -227,15 +262,15 @@ func (h *handler) DashboardPlayerSpendingLeaderboard() core.HandlerFunc {
|
|||||||
}
|
}
|
||||||
// Only include Holding (1) and Shipped/Used (3) items. Exclude Void/Decomposed (2).
|
// Only include Holding (1) and Shipped/Used (3) items. Exclude Void/Decomposed (2).
|
||||||
query = query.Where("user_inventory.status IN ?", []int{1, 3}).
|
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(`
|
err := query.Select(`
|
||||||
user_inventory.user_id,
|
user_inventory.user_id,
|
||||||
SUM(products.price) as total_value,
|
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,
|
||||||
SUM(CASE WHEN activities.activity_category_id = 1 THEN products.price ELSE 0 END) as ichiban_prize,
|
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,
|
||||||
SUM(CASE WHEN activities.activity_category_id = 2 THEN products.price ELSE 0 END) as infinite_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,
|
||||||
SUM(CASE WHEN activities.activity_category_id = 3 THEN products.price ELSE 0 END) as matching_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").
|
Group("user_inventory.user_id").
|
||||||
Scan(&invStats).Error
|
Scan(&invStats).Error
|
||||||
|
|
||||||
@ -248,31 +283,107 @@ func (h *handler) DashboardPlayerSpendingLeaderboard() core.HandlerFunc {
|
|||||||
item.MatchingPrize = is.MatchingPrize
|
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)
|
// 4.1 Calculate Livestream Prize Value (snapshot priority; fallback prize cost)
|
||||||
type lsStat struct {
|
type lsLog struct {
|
||||||
UserID int64
|
UserID int64
|
||||||
Amount int64
|
ShopOrderID string
|
||||||
|
PrizeID int64
|
||||||
}
|
}
|
||||||
var lsStats []lsStat
|
var lsLogs []lsLog
|
||||||
lsQuery := db.Table(model.TableNameLivestreamDrawLogs).
|
lsLogQuery := 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, livestream_draw_logs.shop_order_id, livestream_draw_logs.prize_id").
|
||||||
Select("livestream_draw_logs.local_user_id as user_id, SUM(products.price) as amount").
|
|
||||||
Where("livestream_draw_logs.local_user_id IN ?", userIDs).
|
Where("livestream_draw_logs.local_user_id IN ?", userIDs).
|
||||||
Where("livestream_draw_logs.is_refunded = 0").
|
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" {
|
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)
|
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 {
|
if len(lsLogs) > 0 {
|
||||||
for _, ls := range lsStats {
|
prizeIDSet := make(map[int64]struct{})
|
||||||
if item, ok := statMap[ls.UserID]; ok {
|
for _, l := range lsLogs {
|
||||||
item.LivestreamPrize = ls.Amount
|
prizeIDSet[l.PrizeID] = struct{}{}
|
||||||
// item.TotalPrizeValue += ls.Amount // Already included in user_inventory
|
}
|
||||||
|
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
|
calculatedProfit := item.IchibanProfit + item.InfiniteProfit + item.MatchingProfit + item.LivestreamProfit
|
||||||
|
|
||||||
item.Profit = calculatedProfit
|
item.Profit = calculatedProfit
|
||||||
if calculatedSpending > 0 {
|
_, item.ProfitRate = financesvc.ComputeProfit(calculatedSpending, calculatedSpending-item.Profit)
|
||||||
item.ProfitRate = float64(item.Profit) / float64(calculatedSpending)
|
item.SpendingPaidCoupon = calculatedSpending - item.SpendingGamePass
|
||||||
} else {
|
if item.SpendingPaidCoupon < 0 {
|
||||||
item.ProfitRate = 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)
|
list = append(list, *item)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -4,12 +4,14 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"bindbox-game/internal/code"
|
"bindbox-game/internal/code"
|
||||||
"bindbox-game/internal/pkg/core"
|
"bindbox-game/internal/pkg/core"
|
||||||
"bindbox-game/internal/pkg/validation"
|
"bindbox-game/internal/pkg/validation"
|
||||||
"bindbox-game/internal/repository/mysql/model"
|
"bindbox-game/internal/repository/mysql/model"
|
||||||
|
financesvc "bindbox-game/internal/service/finance"
|
||||||
)
|
)
|
||||||
|
|
||||||
type userSpendingRequest struct {
|
type userSpendingRequest struct {
|
||||||
@ -87,9 +89,18 @@ func (h *handler) GetUserSpendingDashboard() core.HandlerFunc {
|
|||||||
var actStats []activityStat
|
var actStats []activityStat
|
||||||
|
|
||||||
query := db.Table(model.TableNameOrders).
|
query := db.Table(model.TableNameOrders).
|
||||||
Joins("LEFT JOIN activity_draw_logs ON activity_draw_logs.order_id = orders.id").
|
Joins(`LEFT JOIN (
|
||||||
Joins("LEFT JOIN activity_issues ON activity_issues.id = activity_draw_logs.issue_id").
|
SELECT activity_draw_logs.order_id, activity_issues.activity_id, COUNT(*) as draw_count
|
||||||
Joins("LEFT JOIN activities ON activities.id = activity_issues.activity_id").
|
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.user_id = ?", userID).
|
||||||
Where("orders.status = ?", 2)
|
Where("orders.status = ?", 2)
|
||||||
|
|
||||||
@ -101,7 +112,11 @@ func (h *handler) GetUserSpendingDashboard() core.HandlerFunc {
|
|||||||
COALESCE(activities.id, 0) as activity_id,
|
COALESCE(activities.id, 0) as activity_id,
|
||||||
COALESCE(activities.name, '其他') as activity_name,
|
COALESCE(activities.name, '其他') as activity_name,
|
||||||
COALESCE(activities.activity_category_id, 0) as category_id,
|
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
|
COUNT(DISTINCT orders.id) as order_count
|
||||||
`).
|
`).
|
||||||
Group("COALESCE(activities.id, 0)").
|
Group("COALESCE(activities.id, 0)").
|
||||||
@ -120,21 +135,28 @@ func (h *handler) GetUserSpendingDashboard() core.HandlerFunc {
|
|||||||
var prizeStats []prizeStat
|
var prizeStats []prizeStat
|
||||||
|
|
||||||
prizeQuery := db.Table(model.TableNameUserInventory).
|
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.user_id = ?", userID).
|
||||||
Where("user_inventory.status IN ?", []int{1, 3}).
|
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 {
|
if hasRange {
|
||||||
prizeQuery = prizeQuery.Where("user_inventory.created_at >= ?", start).Where("user_inventory.created_at <= ?", end)
|
prizeQuery = prizeQuery.Where("user_inventory.created_at >= ?", start).Where("user_inventory.created_at <= ?", end)
|
||||||
}
|
}
|
||||||
|
|
||||||
prizeQuery.Select(`
|
if err := prizeQuery.Select(`
|
||||||
COALESCE(user_inventory.activity_id, 0) as activity_id,
|
COALESCE(NULLIF(user_inventory.activity_id, 0), cost_issues.activity_id, 0) as activity_id,
|
||||||
SUM(products.price) as prize_value
|
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)").
|
Group("COALESCE(NULLIF(user_inventory.activity_id, 0), cost_issues.activity_id, 0)").
|
||||||
Scan(&prizeStats)
|
Scan(&prizeStats).Error; err != nil {
|
||||||
|
h.logger.Error(fmt.Sprintf("GetUserSpendingDashboard prize stats error: %v", err))
|
||||||
|
}
|
||||||
|
|
||||||
prizeMap := make(map[int64]int64)
|
prizeMap := make(map[int64]int64)
|
||||||
for _, p := range prizeStats {
|
for _, p := range prizeStats {
|
||||||
@ -173,21 +195,98 @@ func (h *handler) GetUserSpendingDashboard() core.HandlerFunc {
|
|||||||
PrizeValue int64
|
PrizeValue int64
|
||||||
}
|
}
|
||||||
var lsPrizeStats []lsPrizeStat
|
var lsPrizeStats []lsPrizeStat
|
||||||
lsPrizeQuery := db.Table("livestream_draw_logs").
|
type lsLog struct {
|
||||||
Joins("JOIN products ON products.id = livestream_draw_logs.product_id").
|
ActivityID int64
|
||||||
Select(`
|
ShopOrderID string
|
||||||
livestream_draw_logs.livestream_activity_id as activity_id,
|
PrizeID int64
|
||||||
SUM(products.price) as prize_value
|
|
||||||
`).
|
|
||||||
Where("livestream_draw_logs.local_user_id = ?", userID).
|
|
||||||
Where("livestream_draw_logs.is_refunded = 0").
|
|
||||||
Where("livestream_draw_logs.product_id > 0")
|
|
||||||
|
|
||||||
if hasRange {
|
|
||||||
lsPrizeQuery = lsPrizeQuery.Where("livestream_draw_logs.created_at >= ?", start).Where("livestream_draw_logs.created_at <= ?", end)
|
|
||||||
}
|
}
|
||||||
|
var lsLogs []lsLog
|
||||||
|
lsLogQuery := db.Table("livestream_draw_logs").
|
||||||
|
Select("livestream_activity_id as activity_id, shop_order_id, prize_id").
|
||||||
|
Where("local_user_id = ?", userID).
|
||||||
|
Where("is_refunded = 0").
|
||||||
|
Where("prize_id > 0")
|
||||||
|
if hasRange {
|
||||||
|
lsLogQuery = lsLogQuery.Where("created_at >= ?", start).Where("created_at <= ?", end)
|
||||||
|
}
|
||||||
|
_ = lsLogQuery.Scan(&lsLogs).Error
|
||||||
|
|
||||||
lsPrizeQuery.Group("livestream_draw_logs.livestream_activity_id").Scan(&lsPrizeStats)
|
if len(lsLogs) > 0 {
|
||||||
|
prizeIDSet := make(map[int64]struct{})
|
||||||
|
for _, l := range lsLogs {
|
||||||
|
prizeIDSet[l.PrizeID] = struct{}{}
|
||||||
|
}
|
||||||
|
prizeIDs := make([]int64, 0, len(prizeIDSet))
|
||||||
|
for pid := range prizeIDSet {
|
||||||
|
prizeIDs = append(prizeIDs, pid)
|
||||||
|
}
|
||||||
|
prizeCostMap := make(map[int64]int64)
|
||||||
|
if len(prizeIDs) > 0 {
|
||||||
|
var prizes []struct {
|
||||||
|
ID int64
|
||||||
|
CostPrice int64
|
||||||
|
}
|
||||||
|
_ = h.repo.GetDbR().Table("livestream_prizes").Select("id, cost_price").Where("id IN ?", prizeIDs).Scan(&prizes).Error
|
||||||
|
for _, p := range prizes {
|
||||||
|
prizeCostMap[p.ID] = p.CostPrice
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type invRow struct {
|
||||||
|
ValueCents int64
|
||||||
|
Remark string
|
||||||
|
}
|
||||||
|
var invRows []invRow
|
||||||
|
invQ := h.repo.GetDbR().Table("user_inventory").
|
||||||
|
Select("COALESCE(NULLIF(user_inventory.value_cents, 0), activity_reward_settings.price_snapshot_cents, products.price, 0) as value_cents, user_inventory.remark").
|
||||||
|
Joins("LEFT JOIN activity_reward_settings ON activity_reward_settings.id = user_inventory.reward_id").
|
||||||
|
Joins("LEFT JOIN products ON products.id = user_inventory.product_id").
|
||||||
|
Where("user_id = ?", userID).
|
||||||
|
Where("status IN (1,3)").
|
||||||
|
Where("COALESCE(user_inventory.remark, '') NOT LIKE ?", "%void%")
|
||||||
|
if hasRange {
|
||||||
|
invQ = invQ.Where("created_at >= ?", start.Add(-2*time.Hour)).Where("created_at <= ?", end.Add(24*time.Hour))
|
||||||
|
}
|
||||||
|
_ = invQ.Scan(&invRows).Error
|
||||||
|
|
||||||
|
lsByKey := make(map[string][]lsLog)
|
||||||
|
for _, l := range lsLogs {
|
||||||
|
key := fmt.Sprintf("%d|%s", l.ActivityID, l.ShopOrderID)
|
||||||
|
lsByKey[key] = append(lsByKey[key], l)
|
||||||
|
}
|
||||||
|
|
||||||
|
prizeByActivity := make(map[int64]int64)
|
||||||
|
for _, logs := range lsByKey {
|
||||||
|
if len(logs) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
aid := logs[0].ActivityID
|
||||||
|
shopOrderID := logs[0].ShopOrderID
|
||||||
|
|
||||||
|
var snapshotSum int64
|
||||||
|
if shopOrderID != "" {
|
||||||
|
for _, inv := range invRows {
|
||||||
|
if strings.Contains(inv.Remark, shopOrderID) {
|
||||||
|
snapshotSum += inv.ValueCents
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if snapshotSum > 0 {
|
||||||
|
prizeByActivity[aid] += snapshotSum
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, l := range logs {
|
||||||
|
prizeByActivity[aid] += prizeCostMap[l.PrizeID]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for aid, val := range prizeByActivity {
|
||||||
|
lsPrizeStats = append(lsPrizeStats, lsPrizeStat{
|
||||||
|
ActivityID: aid,
|
||||||
|
PrizeValue: val,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
lsPrizeMap := make(map[int64]int64)
|
lsPrizeMap := make(map[int64]int64)
|
||||||
for _, p := range lsPrizeStats {
|
for _, p := range lsPrizeStats {
|
||||||
@ -211,7 +310,7 @@ func (h *handler) GetUserSpendingDashboard() core.HandlerFunc {
|
|||||||
CategoryName: catName,
|
CategoryName: catName,
|
||||||
Spending: s.Spending,
|
Spending: s.Spending,
|
||||||
PrizeValue: prize,
|
PrizeValue: prize,
|
||||||
Profit: s.Spending - prize,
|
Profit: func() int64 { p, _ := financesvc.ComputeProfit(s.Spending, prize); return p }(),
|
||||||
OrderCount: s.OrderCount,
|
OrderCount: s.OrderCount,
|
||||||
}
|
}
|
||||||
activities = append(activities, item)
|
activities = append(activities, item)
|
||||||
@ -230,7 +329,7 @@ func (h *handler) GetUserSpendingDashboard() core.HandlerFunc {
|
|||||||
CategoryName: "直播间",
|
CategoryName: "直播间",
|
||||||
Spending: ls.Spending,
|
Spending: ls.Spending,
|
||||||
PrizeValue: prize,
|
PrizeValue: prize,
|
||||||
Profit: ls.Spending - prize,
|
Profit: func() int64 { p, _ := financesvc.ComputeProfit(ls.Spending, prize); return p }(),
|
||||||
OrderCount: ls.OrderCount,
|
OrderCount: ls.OrderCount,
|
||||||
}
|
}
|
||||||
activities = append(activities, item)
|
activities = append(activities, item)
|
||||||
@ -241,7 +340,7 @@ func (h *handler) GetUserSpendingDashboard() core.HandlerFunc {
|
|||||||
|
|
||||||
rsp.TotalSpend = totalSpend
|
rsp.TotalSpend = totalSpend
|
||||||
rsp.TotalPrize = totalPrize
|
rsp.TotalPrize = totalPrize
|
||||||
rsp.TotalProfit = totalSpend - totalPrize
|
rsp.TotalProfit, _ = financesvc.ComputeProfit(totalSpend, totalPrize)
|
||||||
rsp.TotalOrders = totalOrders
|
rsp.TotalOrders = totalOrders
|
||||||
rsp.Activities = activities
|
rsp.Activities = activities
|
||||||
|
|
||||||
|
|||||||
@ -6,9 +6,12 @@ import (
|
|||||||
"bindbox-game/internal/pkg/validation"
|
"bindbox-game/internal/pkg/validation"
|
||||||
"bindbox-game/internal/service/douyin"
|
"bindbox-game/internal/service/douyin"
|
||||||
"context"
|
"context"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -17,6 +20,7 @@ import (
|
|||||||
type getDouyinConfigResponse struct {
|
type getDouyinConfigResponse struct {
|
||||||
Cookie string `json:"cookie"`
|
Cookie string `json:"cookie"`
|
||||||
IntervalMinutes int `json:"interval_minutes"`
|
IntervalMinutes int `json:"interval_minutes"`
|
||||||
|
Proxy string `json:"proxy"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *handler) GetDouyinConfig() core.HandlerFunc {
|
func (h *handler) GetDouyinConfig() core.HandlerFunc {
|
||||||
@ -29,6 +33,7 @@ func (h *handler) GetDouyinConfig() core.HandlerFunc {
|
|||||||
ctx.Payload(getDouyinConfigResponse{
|
ctx.Payload(getDouyinConfigResponse{
|
||||||
Cookie: cfg.Cookie,
|
Cookie: cfg.Cookie,
|
||||||
IntervalMinutes: cfg.IntervalMinutes,
|
IntervalMinutes: cfg.IntervalMinutes,
|
||||||
|
Proxy: cfg.Proxy,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -36,6 +41,7 @@ func (h *handler) GetDouyinConfig() core.HandlerFunc {
|
|||||||
type saveDouyinConfigRequest struct {
|
type saveDouyinConfigRequest struct {
|
||||||
Cookie string `json:"cookie"`
|
Cookie string `json:"cookie"`
|
||||||
IntervalMinutes int `json:"interval_minutes"`
|
IntervalMinutes int `json:"interval_minutes"`
|
||||||
|
Proxy string `json:"proxy"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *handler) SaveDouyinConfig() core.HandlerFunc {
|
func (h *handler) SaveDouyinConfig() core.HandlerFunc {
|
||||||
@ -46,7 +52,7 @@ func (h *handler) SaveDouyinConfig() core.HandlerFunc {
|
|||||||
return
|
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)))
|
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ServerError, validation.Error(err)))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@ -58,9 +64,12 @@ func (h *handler) SaveDouyinConfig() core.HandlerFunc {
|
|||||||
// ---------- 抖店订单列表 API ----------
|
// ---------- 抖店订单列表 API ----------
|
||||||
|
|
||||||
type listDouyinOrdersRequest struct {
|
type listDouyinOrdersRequest struct {
|
||||||
Page int `form:"page"`
|
Page int `form:"page"`
|
||||||
PageSize int `form:"page_size"`
|
PageSize int `form:"page_size"`
|
||||||
Status *int `form:"status"`
|
Status *int `form:"status"`
|
||||||
|
Match string `form:"match_status"`
|
||||||
|
ShopOrderID string `form:"shop_order_id"`
|
||||||
|
DouyinUserID string `form:"douyin_user_id"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type douyinOrderItem struct {
|
type douyinOrderItem struct {
|
||||||
@ -95,7 +104,16 @@ func (h *handler) ListDouyinOrders() core.HandlerFunc {
|
|||||||
return
|
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 {
|
if err != nil {
|
||||||
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ServerError, validation.Error(err)))
|
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ServerError, validation.Error(err)))
|
||||||
return
|
return
|
||||||
@ -154,30 +172,66 @@ func (h *handler) ListDouyinOrders() core.HandlerFunc {
|
|||||||
// ---------- 手动同步 API ----------
|
// ---------- 手动同步 API ----------
|
||||||
|
|
||||||
type syncDouyinOrdersResponse struct {
|
type syncDouyinOrdersResponse struct {
|
||||||
Message string `json:"message"`
|
Message string `json:"message"`
|
||||||
TotalFetched int `json:"total_fetched"`
|
TotalFetched int `json:"total_fetched"`
|
||||||
NewOrders int `json:"new_orders"`
|
NewOrders int `json:"new_orders"`
|
||||||
MatchedUsers int `json:"matched_users"`
|
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 {
|
func (h *handler) SyncDouyinOrders() core.HandlerFunc {
|
||||||
return func(ctx core.Context) {
|
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 请求断开导致同步中断
|
// 使用独立 Context 防止 HTTP 请求断开导致同步中断
|
||||||
// 设置 5 分钟超时,确保有足够时间完成全量同步
|
// 设置 5 分钟超时,确保有足够时间完成全量同步
|
||||||
bgCtx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
|
bgCtx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
result, err := h.douyinSvc.FetchAndSyncOrders(bgCtx)
|
result, err := h.douyinSvc.FetchAndSyncOrders(bgCtx, fetchOpts)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ServerError, validation.Error(err)))
|
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ServerError, validation.Error(err)))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
ctx.Payload(syncDouyinOrdersResponse{
|
ctx.Payload(syncDouyinOrdersResponse{
|
||||||
Message: "同步成功",
|
Message: fmt.Sprintf("同步成功,处理 %d/%d 个用户,用时 %.2f 秒", result.ProcessedUsers, result.TotalUsers, float64(result.ElapsedMS)/1000.0),
|
||||||
TotalFetched: result.TotalFetched,
|
TotalFetched: result.TotalFetched,
|
||||||
NewOrders: result.NewOrders,
|
NewOrders: result.NewOrders,
|
||||||
MatchedUsers: result.MatchedUsers,
|
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"`
|
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 手动发放直播间奖品
|
// ManualGrantPrizes 手动发放直播间奖品
|
||||||
func (h *handler) ManualGrantPrizes() core.HandlerFunc {
|
func (h *handler) ManualGrantPrizes() core.HandlerFunc {
|
||||||
return func(ctx core.Context) {
|
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 {
|
func getOrderStatusText(status int32) string {
|
||||||
|
|||||||
@ -1,6 +1,9 @@
|
|||||||
package admin
|
package admin
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
@ -10,6 +13,7 @@ import (
|
|||||||
"bindbox-game/internal/pkg/core"
|
"bindbox-game/internal/pkg/core"
|
||||||
"bindbox-game/internal/pkg/validation"
|
"bindbox-game/internal/pkg/validation"
|
||||||
"bindbox-game/internal/repository/mysql/model"
|
"bindbox-game/internal/repository/mysql/model"
|
||||||
|
"bindbox-game/internal/service/channel"
|
||||||
"bindbox-game/internal/service/livestream"
|
"bindbox-game/internal/service/livestream"
|
||||||
|
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
@ -21,6 +25,7 @@ type createLivestreamActivityRequest struct {
|
|||||||
Name string `json:"name" binding:"required"`
|
Name string `json:"name" binding:"required"`
|
||||||
StreamerName string `json:"streamer_name"`
|
StreamerName string `json:"streamer_name"`
|
||||||
StreamerContact string `json:"streamer_contact"`
|
StreamerContact string `json:"streamer_contact"`
|
||||||
|
ChannelID *int64 `json:"channel_id"`
|
||||||
DouyinProductID string `json:"douyin_product_id"`
|
DouyinProductID string `json:"douyin_product_id"`
|
||||||
OrderRewardType string `json:"order_reward_type"` // 下单奖励类型: flip_card/minesweeper
|
OrderRewardType string `json:"order_reward_type"` // 下单奖励类型: flip_card/minesweeper
|
||||||
OrderRewardQuantity int32 `json:"order_reward_quantity"` // 下单奖励数量: 1-100
|
OrderRewardQuantity int32 `json:"order_reward_quantity"` // 下单奖励数量: 1-100
|
||||||
@ -34,6 +39,9 @@ type livestreamActivityResponse struct {
|
|||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
StreamerName string `json:"streamer_name"`
|
StreamerName string `json:"streamer_name"`
|
||||||
StreamerContact string `json:"streamer_contact"`
|
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"`
|
AccessCode string `json:"access_code"`
|
||||||
DouyinProductID string `json:"douyin_product_id"`
|
DouyinProductID string `json:"douyin_product_id"`
|
||||||
OrderRewardType string `json:"order_reward_type"` // 下单奖励类型
|
OrderRewardType string `json:"order_reward_type"` // 下单奖励类型
|
||||||
@ -64,10 +72,35 @@ func (h *handler) CreateLivestreamActivity() core.HandlerFunc {
|
|||||||
return
|
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{
|
input := livestream.CreateActivityInput{
|
||||||
Name: req.Name,
|
Name: req.Name,
|
||||||
StreamerName: req.StreamerName,
|
StreamerName: req.StreamerName,
|
||||||
StreamerContact: req.StreamerContact,
|
StreamerContact: req.StreamerContact,
|
||||||
|
ChannelID: func() int64 {
|
||||||
|
if req.ChannelID != nil {
|
||||||
|
return *req.ChannelID
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}(),
|
||||||
|
ChannelCode: channelCode,
|
||||||
DouyinProductID: req.DouyinProductID,
|
DouyinProductID: req.DouyinProductID,
|
||||||
OrderRewardType: req.OrderRewardType,
|
OrderRewardType: req.OrderRewardType,
|
||||||
OrderRewardQuantity: req.OrderRewardQuantity,
|
OrderRewardQuantity: req.OrderRewardQuantity,
|
||||||
@ -91,11 +124,19 @@ func (h *handler) CreateLivestreamActivity() core.HandlerFunc {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
displayChannelName := channelName
|
||||||
|
if displayChannelName == "" && activity.ChannelCode != "" {
|
||||||
|
displayChannelName = activity.ChannelCode
|
||||||
|
}
|
||||||
|
|
||||||
ctx.Payload(&livestreamActivityResponse{
|
ctx.Payload(&livestreamActivityResponse{
|
||||||
ID: activity.ID,
|
ID: activity.ID,
|
||||||
Name: activity.Name,
|
Name: activity.Name,
|
||||||
StreamerName: activity.StreamerName,
|
StreamerName: activity.StreamerName,
|
||||||
StreamerContact: activity.StreamerContact,
|
StreamerContact: activity.StreamerContact,
|
||||||
|
ChannelID: activity.ChannelID,
|
||||||
|
ChannelCode: activity.ChannelCode,
|
||||||
|
ChannelName: displayChannelName,
|
||||||
AccessCode: activity.AccessCode,
|
AccessCode: activity.AccessCode,
|
||||||
DouyinProductID: activity.DouyinProductID,
|
DouyinProductID: activity.DouyinProductID,
|
||||||
OrderRewardType: activity.OrderRewardType,
|
OrderRewardType: activity.OrderRewardType,
|
||||||
@ -111,6 +152,7 @@ type updateLivestreamActivityRequest struct {
|
|||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
StreamerName string `json:"streamer_name"`
|
StreamerName string `json:"streamer_name"`
|
||||||
StreamerContact string `json:"streamer_contact"`
|
StreamerContact string `json:"streamer_contact"`
|
||||||
|
ChannelID *int64 `json:"channel_id"`
|
||||||
DouyinProductID string `json:"douyin_product_id"`
|
DouyinProductID string `json:"douyin_product_id"`
|
||||||
OrderRewardType string `json:"order_reward_type"` // 下单奖励类型
|
OrderRewardType string `json:"order_reward_type"` // 下单奖励类型
|
||||||
OrderRewardQuantity *int32 `json:"order_reward_quantity"` // 下单奖励数量
|
OrderRewardQuantity *int32 `json:"order_reward_quantity"` // 下单奖励数量
|
||||||
@ -146,6 +188,29 @@ func (h *handler) UpdateLivestreamActivity() core.HandlerFunc {
|
|||||||
return
|
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{
|
input := livestream.UpdateActivityInput{
|
||||||
Name: req.Name,
|
Name: req.Name,
|
||||||
StreamerName: req.StreamerName,
|
StreamerName: req.StreamerName,
|
||||||
@ -155,6 +220,8 @@ func (h *handler) UpdateLivestreamActivity() core.HandlerFunc {
|
|||||||
OrderRewardQuantity: req.OrderRewardQuantity,
|
OrderRewardQuantity: req.OrderRewardQuantity,
|
||||||
TicketPrice: req.TicketPrice,
|
TicketPrice: req.TicketPrice,
|
||||||
Status: req.Status,
|
Status: req.Status,
|
||||||
|
ChannelID: req.ChannelID,
|
||||||
|
ChannelCode: channelCodePtr,
|
||||||
}
|
}
|
||||||
|
|
||||||
if req.StartTime != "" {
|
if req.StartTime != "" {
|
||||||
@ -224,6 +291,14 @@ func (h *handler) ListLivestreamActivities() core.HandlerFunc {
|
|||||||
return
|
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{
|
res := &listLivestreamActivitiesResponse{
|
||||||
List: make([]livestreamActivityResponse, len(list)),
|
List: make([]livestreamActivityResponse, len(list)),
|
||||||
Total: total,
|
Total: total,
|
||||||
@ -245,6 +320,13 @@ func (h *handler) ListLivestreamActivities() core.HandlerFunc {
|
|||||||
Status: a.Status,
|
Status: a.Status,
|
||||||
CreatedAt: a.CreatedAt.Format("2006-01-02 15:04:05"),
|
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() {
|
if !a.StartTime.IsZero() {
|
||||||
item.StartTime = a.StartTime.Format("2006-01-02 15:04:05")
|
item.StartTime = a.StartTime.Format("2006-01-02 15:04:05")
|
||||||
}
|
}
|
||||||
@ -283,11 +365,24 @@ func (h *handler) GetLivestreamActivity() core.HandlerFunc {
|
|||||||
return
|
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{
|
res := &livestreamActivityResponse{
|
||||||
ID: activity.ID,
|
ID: activity.ID,
|
||||||
Name: activity.Name,
|
Name: activity.Name,
|
||||||
StreamerName: activity.StreamerName,
|
StreamerName: activity.StreamerName,
|
||||||
StreamerContact: activity.StreamerContact,
|
StreamerContact: activity.StreamerContact,
|
||||||
|
ChannelID: activity.ChannelID,
|
||||||
|
ChannelCode: activity.ChannelCode,
|
||||||
|
ChannelName: channelName,
|
||||||
AccessCode: activity.AccessCode,
|
AccessCode: activity.AccessCode,
|
||||||
DouyinProductID: activity.DouyinProductID,
|
DouyinProductID: activity.DouyinProductID,
|
||||||
OrderRewardType: activity.OrderRewardType,
|
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 {
|
type createLivestreamPrizeRequest struct {
|
||||||
@ -630,6 +760,17 @@ func (h *handler) ListLivestreamDrawLogs() core.HandlerFunc {
|
|||||||
return
|
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)
|
req := new(listLivestreamDrawLogsRequest)
|
||||||
_ = ctx.ShouldBindForm(req)
|
_ = ctx.ShouldBindForm(req)
|
||||||
|
|
||||||
@ -710,10 +851,11 @@ func (h *handler) ListLivestreamDrawLogs() core.HandlerFunc {
|
|||||||
DouyinOrderID int64
|
DouyinOrderID int64
|
||||||
PrizeID int64
|
PrizeID int64
|
||||||
ShopOrderID string // 用于关联退款状态查 douyin_orders
|
ShopOrderID string // 用于关联退款状态查 douyin_orders
|
||||||
|
LocalUserID int64
|
||||||
}
|
}
|
||||||
var metas []logMeta
|
var metas []logMeta
|
||||||
// 使用不带分页的 db 克隆
|
// 使用不带分页的 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))
|
orderIDs := make([]int64, 0, len(metas))
|
||||||
distinctOrderIDs := make(map[int64]bool)
|
distinctOrderIDs := make(map[int64]bool)
|
||||||
prizeIDCount := make(map[int64]int64)
|
prizeIDCount := make(map[int64]int64)
|
||||||
@ -730,61 +872,97 @@ func (h *handler) ListLivestreamDrawLogs() core.HandlerFunc {
|
|||||||
if len(orderIDs) > 0 {
|
if len(orderIDs) > 0 {
|
||||||
var orders []model.DouyinOrders
|
var orders []model.DouyinOrders
|
||||||
// 分批查询防止 IN 子句过长? 暂时假设量级可控
|
// 分批查询防止 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)
|
Where("id IN ?", orderIDs).Find(&orders)
|
||||||
|
|
||||||
orderRefundMap := make(map[int64]bool)
|
orderRefundMap := make(map[int64]bool)
|
||||||
|
|
||||||
for _, o := range orders {
|
for _, o := range orders {
|
||||||
// 统计营收 (总流水)
|
// 统计营收 (总流水)
|
||||||
stats.TotalRev += int64(o.ActualPayAmount)
|
orderAmount := calcLivestreamOrderAmount(&o, ticketPrice)
|
||||||
|
stats.TotalRev += orderAmount
|
||||||
|
|
||||||
if o.OrderStatus == 4 { // 已退款
|
if o.OrderStatus == 4 { // 已退款
|
||||||
stats.TotalRefund += int64(o.ActualPayAmount)
|
stats.TotalRefund += orderAmount
|
||||||
orderRefundMap[o.ID] = true
|
orderRefundMap[o.ID] = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4. 统计成本 (剔除退款订单)
|
// 4. 统计成本 (剔除退款订单,优先资产快照,缺失回退 prize.cost_price)
|
||||||
for _, m := range metas {
|
for _, m := range metas {
|
||||||
if !orderRefundMap[m.DouyinOrderID] {
|
if !orderRefundMap[m.DouyinOrderID] {
|
||||||
prizeIDCount[m.PrizeID]++
|
prizeIDCount[m.PrizeID]++
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 计算奖品成本 (逻辑参考 GetLivestreamStats,简化版)
|
prizeCostMap := make(map[int64]int64)
|
||||||
if len(prizeIDCount) > 0 {
|
if len(prizeIDCount) > 0 {
|
||||||
prizeIDs := make([]int64, 0, len(prizeIDCount))
|
prizeIDs := make([]int64, 0, len(prizeIDCount))
|
||||||
for pid := range prizeIDCount {
|
for pid := range prizeIDCount {
|
||||||
prizeIDs = append(prizeIDs, pid)
|
prizeIDs = append(prizeIDs, pid)
|
||||||
}
|
}
|
||||||
|
|
||||||
var prizes []model.LivestreamPrizes
|
var prizes []model.LivestreamPrizes
|
||||||
h.repo.GetDbR().Where("id IN ?", prizeIDs).Find(&prizes)
|
h.repo.GetDbR().Select("id, cost_price").Where("id IN ?", prizeIDs).Find(&prizes)
|
||||||
|
|
||||||
// 批量获取关联商品
|
|
||||||
productIDs := make([]int64, 0)
|
|
||||||
for _, p := range prizes {
|
for _, p := range prizes {
|
||||||
if p.CostPrice == 0 && p.ProductID > 0 {
|
prizeCostMap[p.ID] = p.CostPrice
|
||||||
productIDs = append(productIDs, p.ProductID)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
productPriceMap := make(map[int64]int64)
|
}
|
||||||
if len(productIDs) > 0 {
|
|
||||||
var products []model.Products
|
// 预加载用户资产快照用于 shop_order_id 命中
|
||||||
h.repo.GetDbR().Select("id, price").Where("id IN ?", productIDs).Find(&products)
|
type invRow struct {
|
||||||
for _, prod := range products {
|
UserID int64
|
||||||
productPriceMap[prod.ID] = prod.Price
|
ValueCents int64
|
||||||
|
Remark string
|
||||||
|
}
|
||||||
|
var invRows []invRow
|
||||||
|
_ = h.repo.GetDbR().Table("user_inventory").
|
||||||
|
Select("user_inventory.user_id, COALESCE(NULLIF(user_inventory.value_cents, 0), activity_reward_settings.price_snapshot_cents, products.price, 0) as value_cents, user_inventory.remark").
|
||||||
|
Joins("LEFT JOIN activity_reward_settings ON activity_reward_settings.id = user_inventory.reward_id").
|
||||||
|
Joins("LEFT JOIN products ON products.id = user_inventory.product_id").
|
||||||
|
Where("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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, p := range prizes {
|
if snapshotSum > 0 {
|
||||||
cost := p.CostPrice
|
stats.TotalCost += snapshotSum
|
||||||
if cost == 0 && p.ProductID > 0 {
|
continue
|
||||||
cost = productPriceMap[p.ProductID]
|
}
|
||||||
}
|
|
||||||
count := prizeIDCount[p.ID]
|
for _, r := range rows {
|
||||||
stats.TotalCost += cost * count
|
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"
|
"math"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"bindbox-game/internal/code"
|
"bindbox-game/internal/code"
|
||||||
"bindbox-game/internal/pkg/core"
|
"bindbox-game/internal/pkg/core"
|
||||||
@ -77,54 +78,98 @@ func (h *handler) GetLivestreamStats() core.HandlerFunc {
|
|||||||
ctx.AbortWithError(core.Error(http.StatusNotFound, code.ServerError, "活动不存在"))
|
ctx.AbortWithError(core.Error(http.StatusNotFound, code.ServerError, "活动不存在"))
|
||||||
return
|
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 {
|
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 {
|
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)
|
var orderRefs []orderRef
|
||||||
|
if err := orderQuery.Group("douyin_order_id").Scan(&orderRefs).Error; err != nil {
|
||||||
// 统计退款:来自已参与过抽奖且标记为退款的订单 (order_status = 4)
|
ctx.AbortWithError(core.Error(http.StatusInternalServerError, code.ServerError, err.Error()))
|
||||||
var totalRefund, refundCount int64
|
return
|
||||||
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") + "'"
|
|
||||||
}
|
}
|
||||||
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. 获取所有抽奖记录用于成本计算
|
// 3. 获取所有抽奖记录用于成本计算
|
||||||
var drawLogs []model.LivestreamDrawLogs
|
var drawLogs []model.LivestreamDrawLogs
|
||||||
@ -138,141 +183,130 @@ func (h *handler) GetLivestreamStats() core.HandlerFunc {
|
|||||||
db.Find(&drawLogs)
|
db.Find(&drawLogs)
|
||||||
|
|
||||||
// 3.1 获取该时间段内所有退款的 shop_order_id 集合,用于过滤成本
|
// 3.1 获取该时间段内所有退款的 shop_order_id 集合,用于过滤成本
|
||||||
refundedShopOrderIDs := make(map[string]bool)
|
// 4. 计算成本(优先资产快照 user_inventory.value_cents,缺失回退 livestream_prizes.cost_price)
|
||||||
var refundedOrders []string
|
prizeCostMap := make(map[int64]int64)
|
||||||
qRefundIDs := `
|
prizeIDs := make([]int64, 0)
|
||||||
SELECT DISTINCT o.shop_order_id
|
prizeIDSet := make(map[int64]struct{})
|
||||||
FROM douyin_orders o
|
userIDSet := make(map[int64]struct{})
|
||||||
JOIN livestream_draw_logs l ON o.id = l.douyin_order_id
|
for _, log := range drawLogs {
|
||||||
WHERE l.activity_id = ? AND o.order_status = 4
|
if log.PrizeID > 0 {
|
||||||
`
|
if _, ok := prizeIDSet[log.PrizeID]; !ok {
|
||||||
if startTime != nil {
|
prizeIDSet[log.PrizeID] = struct{}{}
|
||||||
qRefundIDs += " AND l.created_at >= '" + startTime.Format("2006-01-02 15:04:05") + "'"
|
prizeIDs = append(prizeIDs, log.PrizeID)
|
||||||
}
|
}
|
||||||
if endTime != nil {
|
}
|
||||||
qRefundIDs += " AND l.created_at <= '" + endTime.Format("2006-01-02 15:04:05") + "'"
|
if log.LocalUserID > 0 {
|
||||||
}
|
userIDSet[log.LocalUserID] = struct{}{}
|
||||||
h.repo.GetDbR().Raw(qRefundIDs, id).Scan(&refundedOrders)
|
}
|
||||||
for _, oid := range refundedOrders {
|
|
||||||
refundedShopOrderIDs[oid] = true
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4. 计算成本(使用 draw_logs.product_id 直接关联 products 表)
|
if len(prizeIDs) > 0 {
|
||||||
// 收集未退款订单的 product_id 和对应数量
|
var prizes []model.LivestreamPrizes
|
||||||
productIDCountMap := make(map[int64]int64)
|
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 {
|
||||||
|
invDB = invDB.Where("created_at >= ?", startTime.Add(-2*time.Hour))
|
||||||
|
}
|
||||||
|
if endTime != nil {
|
||||||
|
invDB = invDB.Where("created_at <= ?", endTime.Add(24*time.Hour))
|
||||||
|
}
|
||||||
|
_ = invDB.Scan(&inventories).Error
|
||||||
|
for _, inv := range inventories {
|
||||||
|
invByUser[inv.UserID] = append(invByUser[inv.UserID], inv)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type logRef struct {
|
||||||
|
PrizeID int64
|
||||||
|
DateKey string
|
||||||
|
}
|
||||||
|
logsByKey := make(map[string][]logRef)
|
||||||
|
keyUser := make(map[string]int64)
|
||||||
|
keyOrder := make(map[string]string)
|
||||||
for _, log := range drawLogs {
|
for _, log := range drawLogs {
|
||||||
// 排除已退款的订单
|
|
||||||
if refundedShopOrderIDs[log.ShopOrderID] {
|
if refundedShopOrderIDs[log.ShopOrderID] {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
// 使用 draw_logs 中记录的 product_id
|
key := strconv.FormatInt(log.LocalUserID, 10) + "|" + log.ShopOrderID
|
||||||
if log.ProductID > 0 {
|
logsByKey[key] = append(logsByKey[key], logRef{
|
||||||
productIDCountMap[log.ProductID]++
|
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
|
var totalCost int64
|
||||||
productCostMap := make(map[int64]int64)
|
for key, refs := range logsByKey {
|
||||||
if len(productIDCountMap) > 0 {
|
if len(refs) == 0 {
|
||||||
productIDs := make([]int64, 0, len(productIDCountMap))
|
continue
|
||||||
for pid := range productIDCountMap {
|
|
||||||
productIDs = append(productIDs, pid)
|
|
||||||
}
|
}
|
||||||
|
uid := keyUser[key]
|
||||||
|
shopOrderID := keyOrder[key]
|
||||||
|
|
||||||
var products []model.Products
|
var snapshotSum int64
|
||||||
h.repo.GetDbR().Where("id IN ?", productIDs).Find(&products)
|
if uid > 0 && shopOrderID != "" {
|
||||||
for _, p := range products {
|
for _, inv := range invByUser[uid] {
|
||||||
productCostMap[p.ID] = p.Price
|
if strings.Contains(inv.Remark, shopOrderID) {
|
||||||
}
|
snapshotSum += inv.ValueCents
|
||||||
|
}
|
||||||
for productID, count := range productIDCountMap {
|
|
||||||
if cost, ok := productCostMap[productID]; ok {
|
|
||||||
totalCost += cost * count
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// 构建 productID -> cost 映射供每日统计使用
|
if snapshotSum > 0 {
|
||||||
prizeCostMap := productCostMap
|
avg := snapshotSum / int64(len(refs))
|
||||||
|
rem := snapshotSum - avg*int64(len(refs))
|
||||||
// 5. 按天分组统计
|
for i, r := range refs {
|
||||||
dailyMap := make(map[string]*dailyLivestreamStats)
|
c := avg
|
||||||
|
if i == 0 {
|
||||||
// 5.1 统计每日营收和退款(直接累加订单实付金额)
|
c += rem
|
||||||
type DailyAmount struct {
|
}
|
||||||
DateKey string
|
totalCost += c
|
||||||
Amount int64
|
costByDate[r.DateKey] += c
|
||||||
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)
|
|
||||||
for _, log := range drawLogs {
|
|
||||||
// 排除退款订单
|
|
||||||
if refundedShopOrderIDs[log.ShopOrderID] {
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if log.ProductID <= 0 {
|
|
||||||
continue
|
for _, r := range refs {
|
||||||
|
c := prizeCostMap[r.PrizeID]
|
||||||
|
totalCost += c
|
||||||
|
costByDate[r.DateKey] += c
|
||||||
}
|
}
|
||||||
dateKey := log.CreatedAt.Format("2006-01-02")
|
}
|
||||||
|
|
||||||
|
// 5. 按天分组统计 (营收/退款已在 dailyMap 中累计),补充成本
|
||||||
|
for dateKey, c := range costByDate {
|
||||||
ds := dailyMap[dateKey]
|
ds := dailyMap[dateKey]
|
||||||
if ds != nil {
|
if ds == nil {
|
||||||
if cost, ok := prizeCostMap[log.ProductID]; ok {
|
ds = &dailyLivestreamStats{Date: dateKey}
|
||||||
ds.TotalCost += cost
|
dailyMap[dateKey] = ds
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
ds.TotalCost += c
|
||||||
}
|
}
|
||||||
|
|
||||||
// 6. 汇总每日数据并计算总体指标
|
// 6. 汇总每日数据并计算总体指标
|
||||||
|
|||||||
@ -177,6 +177,14 @@ func (h *handler) CreateRefund() core.HandlerFunc {
|
|||||||
|
|
||||||
// 全额退款:回收中奖资产与奖品库存(包含已兑换积分的资产)
|
// 全额退款:回收中奖资产与奖品库存(包含已兑换积分的资产)
|
||||||
svc := usersvc.New(h.logger, h.repo)
|
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 清理格位
|
// 直接使用已初始化的 activity service 清理格位
|
||||||
_ = h.activity.ClearIchibanPositionsByOrderID(ctx.RequestContext(), order.ID)
|
_ = h.activity.ClearIchibanPositionsByOrderID(ctx.RequestContext(), order.ID)
|
||||||
|
|
||||||
@ -191,18 +199,25 @@ func (h *handler) CreateRefund() core.HandlerFunc {
|
|||||||
}
|
}
|
||||||
} else if inv.Status == 3 {
|
} else if inv.Status == 3 {
|
||||||
// 状态3(已兑换):扣除积分并作废
|
// 状态3(已兑换):扣除积分并作废
|
||||||
|
deductPoints := int64(0)
|
||||||
matches := rePoints.FindStringSubmatch(inv.Remark)
|
matches := rePoints.FindStringSubmatch(inv.Remark)
|
||||||
if len(matches) > 1 {
|
if len(matches) > 1 {
|
||||||
p, _ := strconv.ParseInt(matches[1], 10, 64)
|
p, _ := strconv.ParseInt(matches[1], 10, 64)
|
||||||
if p > 0 {
|
if p > 0 {
|
||||||
// 扣除积分(记录流水)- 使用柔性扣减
|
deductPoints = p
|
||||||
_, consumed, err := svc.ConsumePointsForRefund(ctx.RequestContext(), order.UserID, p, "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))
|
if deductPoints <= 0 && inv.ValueCents > 0 {
|
||||||
}
|
deductPoints = inv.ValueCents * rate
|
||||||
if consumed < p {
|
}
|
||||||
pointsShortage = true
|
if deductPoints > 0 {
|
||||||
}
|
// 扣除积分(记录流水)- 使用柔性扣减
|
||||||
|
_, 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, deductPoints, err))
|
||||||
|
}
|
||||||
|
if consumed < deductPoints {
|
||||||
|
pointsShortage = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// 更新状态为2 (作废)
|
// 更新状态为2 (作废)
|
||||||
|
|||||||
@ -12,18 +12,20 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type rewardItem struct {
|
type rewardItem struct {
|
||||||
ID int64 `json:"id"`
|
ID int64 `json:"id"`
|
||||||
ProductID int64 `json:"product_id"`
|
ProductID int64 `json:"product_id"`
|
||||||
Weight float64 `json:"weight" binding:"required"`
|
Weight float64 `json:"weight" binding:"required"`
|
||||||
Quantity int64 `json:"quantity" binding:"required"`
|
Quantity int64 `json:"quantity" binding:"required"`
|
||||||
OriginalQty int64 `json:"original_qty" binding:"required"`
|
OriginalQty int64 `json:"original_qty" binding:"required"`
|
||||||
Level int32 `json:"level" binding:"required"`
|
Level int32 `json:"level" binding:"required"`
|
||||||
Sort int32 `json:"sort"`
|
Sort int32 `json:"sort"`
|
||||||
IsBoss int32 `json:"is_boss"`
|
IsBoss int32 `json:"is_boss"`
|
||||||
MinScore int64 `json:"min_score"`
|
MinScore int64 `json:"min_score"`
|
||||||
ProductName string `json:"product_name"`
|
ProductName string `json:"product_name"`
|
||||||
ProductImageUrl string `json:"product_image_url"`
|
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 {
|
type createRewardsRequest struct {
|
||||||
@ -151,9 +153,11 @@ func (h *handler) ListIssueRewards() core.HandlerFunc {
|
|||||||
if p, ok := pm[v.ProductID]; ok {
|
if p, ok := pm[v.ProductID]; ok {
|
||||||
it.ProductName = p.Name
|
it.ProductName = p.Name
|
||||||
it.ProductImageUrl = p.ImagesJSON
|
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
|
res.List[i] = it
|
||||||
}
|
}
|
||||||
ctx.Payload(res)
|
ctx.Payload(res)
|
||||||
|
|||||||
@ -342,13 +342,13 @@ func (h *handler) ListAppUsers() core.HandlerFunc {
|
|||||||
Value int64
|
Value int64
|
||||||
}
|
}
|
||||||
var invRes []invResult
|
var invRes []invResult
|
||||||
h.readDB.UserInventory.WithContext(ctx.RequestContext()).ReadDB().
|
_ = h.readDB.UserInventory.WithContext(ctx.RequestContext()).UnderlyingDB().
|
||||||
LeftJoin(h.readDB.Products, h.readDB.Products.ID.EqCol(h.readDB.UserInventory.ProductID)).
|
Select("user_inventory.user_id, COALESCE(SUM(COALESCE(NULLIF(user_inventory.value_cents, 0), products.price, 0)), 0) AS value").
|
||||||
Select(h.readDB.UserInventory.UserID, h.readDB.Products.Price.Sum().As("value")).
|
Joins("LEFT JOIN products ON products.id = user_inventory.product_id").
|
||||||
Where(h.readDB.UserInventory.UserID.In(userIDs...)).
|
Where("user_inventory.user_id IN ?", userIDs).
|
||||||
Where(h.readDB.UserInventory.Status.Eq(1)). // 1=持有
|
Where("user_inventory.status = ?", 1). // 1=持有
|
||||||
Group(h.readDB.UserInventory.UserID).
|
Group("user_inventory.user_id").
|
||||||
Scan(&invRes)
|
Scan(&invRes).Error
|
||||||
for _, r := range invRes {
|
for _, r := range invRes {
|
||||||
inventoryValues[r.UserID] = r.Value
|
inventoryValues[r.UserID] = r.Value
|
||||||
}
|
}
|
||||||
@ -542,13 +542,13 @@ func (h *handler) ListUserInvites() core.HandlerFunc {
|
|||||||
|
|
||||||
// 商品价值:排除已兑换(status=2)
|
// 商品价值:排除已兑换(status=2)
|
||||||
var invRes []assetResult
|
var invRes []assetResult
|
||||||
h.readDB.UserInventory.WithContext(ctx.RequestContext()).ReadDB().
|
_ = h.readDB.UserInventory.WithContext(ctx.RequestContext()).UnderlyingDB().
|
||||||
LeftJoin(h.readDB.Products, h.readDB.Products.ID.EqCol(h.readDB.UserInventory.ProductID)).
|
Select("user_inventory.user_id, COALESCE(SUM(COALESCE(NULLIF(user_inventory.value_cents, 0), products.price, 0)), 0) AS value").
|
||||||
Select(h.readDB.UserInventory.UserID, h.readDB.Products.Price.Sum().As("value")).
|
Joins("LEFT JOIN products ON products.id = user_inventory.product_id").
|
||||||
Where(h.readDB.UserInventory.UserID.In(inviteeIDs...)).
|
Where("user_inventory.user_id IN ?", inviteeIDs).
|
||||||
Where(h.readDB.UserInventory.Status.Neq(2)). // 排除已兑换
|
Where("user_inventory.status != ?", 2). // 排除已兑换
|
||||||
Group(h.readDB.UserInventory.UserID).
|
Group("user_inventory.user_id").
|
||||||
Scan(&invRes)
|
Scan(&invRes).Error
|
||||||
for _, r := range invRes {
|
for _, r := range invRes {
|
||||||
inviteeAssets[r.UserID] = r.Value
|
inviteeAssets[r.UserID] = r.Value
|
||||||
}
|
}
|
||||||
@ -564,7 +564,7 @@ func (h *handler) ListUserInvites() core.HandlerFunc {
|
|||||||
`, userID).Scan(&summaryConsume).Error
|
`, userID).Scan(&summaryConsume).Error
|
||||||
// 资产价值汇总(不包含已兑换的商品)
|
// 资产价值汇总(不包含已兑换的商品)
|
||||||
_ = h.repo.GetDbR().Raw(`
|
_ = 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
|
FROM user_inventory ui
|
||||||
LEFT JOIN products p ON p.id = ui.product_id
|
LEFT JOIN products p ON p.id = ui.product_id
|
||||||
WHERE ui.user_id IN (SELECT invitee_id FROM user_invites WHERE inviter_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 := `
|
sql := `
|
||||||
SELECT ui.id, ui.user_id, ui.product_id, ui.order_id, ui.activity_id, ui.reward_id,
|
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,
|
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
|
FROM user_inventory ui
|
||||||
LEFT JOIN products p ON p.id = ui.product_id
|
LEFT JOIN products p ON p.id = ui.product_id
|
||||||
WHERE ui.user_id = ?
|
WHERE ui.user_id = ?
|
||||||
@ -2158,9 +2158,9 @@ func (h *handler) AdminSearchUsers() core.HandlerFunc {
|
|||||||
|
|
||||||
// 按手机号或昵称模糊匹配
|
// 按手机号或昵称模糊匹配
|
||||||
rows, _ := q.Where(
|
rows, _ := q.Where(
|
||||||
h.readDB.Users.Mobile.Like("%"+req.Keyword+"%"),
|
h.readDB.Users.Mobile.Like("%" + req.Keyword + "%"),
|
||||||
).Or(
|
).Or(
|
||||||
h.readDB.Users.Nickname.Like("%"+req.Keyword+"%"),
|
h.readDB.Users.Nickname.Like("%" + req.Keyword + "%"),
|
||||||
).Limit(10).Find()
|
).Limit(10).Find()
|
||||||
|
|
||||||
items := make([]userItem, 0, len(rows))
|
items := make([]userItem, 0, len(rows))
|
||||||
|
|||||||
@ -12,36 +12,36 @@ import (
|
|||||||
|
|
||||||
// userStatsAggregated 用户统计聚合结果(单次SQL查询返回)
|
// userStatsAggregated 用户统计聚合结果(单次SQL查询返回)
|
||||||
type userStatsAggregated struct {
|
type userStatsAggregated struct {
|
||||||
UserID int64
|
UserID int64
|
||||||
Nickname string
|
Nickname string
|
||||||
Avatar string
|
Avatar string
|
||||||
InviteCode string
|
InviteCode string
|
||||||
InviterID int64
|
InviterID int64
|
||||||
InviterNickname string
|
InviterNickname string
|
||||||
CreatedAt time.Time
|
CreatedAt time.Time
|
||||||
DouyinID string
|
DouyinID string
|
||||||
DouyinUserID string
|
DouyinUserID string
|
||||||
Mobile string
|
Mobile string
|
||||||
Remark string
|
Remark string
|
||||||
ChannelName string
|
ChannelName string
|
||||||
ChannelCode string
|
ChannelCode string
|
||||||
Status int32
|
Status int32
|
||||||
|
|
||||||
// 聚合统计字段
|
// 聚合统计字段
|
||||||
PointsBalance int64
|
PointsBalance int64
|
||||||
CouponsCount int64
|
CouponsCount int64
|
||||||
ItemCardsCount int64
|
ItemCardsCount int64
|
||||||
TodayConsume int64
|
TodayConsume int64
|
||||||
SevenDayConsume int64
|
SevenDayConsume int64
|
||||||
ThirtyDayConsume int64
|
ThirtyDayConsume int64
|
||||||
TotalConsume int64
|
TotalConsume int64
|
||||||
InviteCount int64
|
InviteCount int64
|
||||||
InviteeTotalConsume int64
|
InviteeTotalConsume int64
|
||||||
GamePassCount int64
|
GamePassCount int64
|
||||||
GameTicketCount int64
|
GameTicketCount int64
|
||||||
InventoryValue int64
|
InventoryValue int64
|
||||||
CouponValue int64
|
CouponValue int64
|
||||||
ItemCardValue int64
|
ItemCardValue int64
|
||||||
}
|
}
|
||||||
|
|
||||||
// ListAppUsersOptimized 优化后的用户列表查询(单次SQL,性能提升83%)
|
// ListAppUsersOptimized 优化后的用户列表查询(单次SQL,性能提升83%)
|
||||||
@ -223,7 +223,7 @@ func (h *handler) ListAppUsersOptimized() core.HandlerFunc {
|
|||||||
(SELECT COALESCE(SUM(available), 0) FROM user_game_tickets WHERE user_id = u.id) AS game_ticket_count,
|
(SELECT COALESCE(SUM(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
|
FROM user_inventory ui
|
||||||
LEFT JOIN products p ON p.id = ui.product_id
|
LEFT JOIN products p ON p.id = ui.product_id
|
||||||
WHERE ui.user_id = u.id AND ui.status = 1
|
WHERE ui.user_id = u.id AND ui.status = 1
|
||||||
@ -254,13 +254,13 @@ func (h *handler) ListAppUsersOptimized() core.HandlerFunc {
|
|||||||
|
|
||||||
// 构建完整参数列表
|
// 构建完整参数列表
|
||||||
queryArgs := []interface{}{
|
queryArgs := []interface{}{
|
||||||
todayStart, // today_consume
|
todayStart, // today_consume
|
||||||
sevenDayStart, // seven_day_consume
|
sevenDayStart, // seven_day_consume
|
||||||
thirtyDayStart, // thirty_day_consume
|
thirtyDayStart, // thirty_day_consume
|
||||||
now, // game_pass_count
|
now, // game_pass_count
|
||||||
}
|
}
|
||||||
queryArgs = append(queryArgs, args...) // WHERE 条件参数
|
queryArgs = append(queryArgs, args...) // WHERE 条件参数
|
||||||
queryArgs = append(queryArgs, req.PageSize) // LIMIT
|
queryArgs = append(queryArgs, req.PageSize) // LIMIT
|
||||||
queryArgs = append(queryArgs, (req.Page-1)*req.PageSize) // OFFSET
|
queryArgs = append(queryArgs, (req.Page-1)*req.PageSize) // OFFSET
|
||||||
|
|
||||||
// 执行查询
|
// 执行查询
|
||||||
|
|||||||
@ -194,12 +194,14 @@ func (h *handler) GetUserProfile() core.HandlerFunc {
|
|||||||
Value int64
|
Value int64
|
||||||
}
|
}
|
||||||
var is invStats
|
var is invStats
|
||||||
h.readDB.UserInventory.WithContext(ctx.RequestContext()).ReadDB().
|
_ = h.repo.GetDbR().Raw(`
|
||||||
LeftJoin(h.readDB.Products, h.readDB.Products.ID.EqCol(h.readDB.UserInventory.ProductID)).
|
SELECT
|
||||||
Select(h.readDB.UserInventory.ID.Count().As("count"), h.readDB.Products.Price.Sum().As("value")).
|
COUNT(ui.id) as count,
|
||||||
Where(h.readDB.UserInventory.UserID.Eq(userID)).
|
COALESCE(SUM(COALESCE(NULLIF(ui.value_cents, 0), p.price, 0)), 0) as value
|
||||||
Where(h.readDB.UserInventory.Status.Eq(1)).
|
FROM user_inventory ui
|
||||||
Scan(&is)
|
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.InventoryCount = is.Count
|
||||||
rsp.CurrentAssets.InventoryValue = is.Value
|
rsp.CurrentAssets.InventoryValue = is.Value
|
||||||
|
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
package admin
|
package admin
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
"time"
|
"time"
|
||||||
@ -86,7 +87,12 @@ func (h *handler) GetUserProfitLossTrend() core.HandlerFunc {
|
|||||||
Coupons int64
|
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(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(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
|
_ = 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
|
totalAssetValue := curAssets.Points + curAssets.Products + curAssets.Cards + curAssets.Coupons
|
||||||
@ -94,17 +100,22 @@ func (h *handler) GetUserProfitLossTrend() core.HandlerFunc {
|
|||||||
// --- 2. 获取订单数据(仅 status=2 已支付) ---
|
// --- 2. 获取订单数据(仅 status=2 已支付) ---
|
||||||
// 注意:为了计算累计趋势,我们需要获取 start 之前的所有已支付订单总额作为基数
|
// 注意:为了计算累计趋势,我们需要获取 start 之前的所有已支付订单总额作为基数
|
||||||
var baseCost int64 = 0
|
var baseCost int64 = 0
|
||||||
var baseCostPtr *int64
|
_ = h.repo.GetDbR().Raw(`
|
||||||
h.readDB.Orders.WithContext(ctx.RequestContext()).ReadDB().
|
SELECT COALESCE(SUM(CASE
|
||||||
Select(h.readDB.Orders.ActualAmount.Sum()).
|
WHEN o.source_type = 4 OR o.order_no LIKE 'GP%' OR (o.actual_amount = 0 AND o.remark LIKE '%use_game_pass%')
|
||||||
Where(h.readDB.Orders.UserID.Eq(userID)).
|
THEN COALESCE(od.draw_count * a.price_draw, 0)
|
||||||
Where(h.readDB.Orders.Status.Eq(2)).
|
ELSE o.actual_amount + o.discount_amount
|
||||||
Where(h.readDB.Orders.SourceType.In(2, 3, 4)). // 排除积分兑换(1)和奖品兑换(5)
|
END), 0)
|
||||||
Where(h.readDB.Orders.CreatedAt.Lt(start)).
|
FROM orders o
|
||||||
Scan(&baseCostPtr)
|
LEFT JOIN (
|
||||||
if baseCostPtr != nil {
|
SELECT l.order_id, COUNT(*) as draw_count, MAX(ai.activity_id) as activity_id
|
||||||
baseCost = *baseCostPtr
|
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
|
var baseRefund int64 = 0
|
||||||
@ -119,13 +130,28 @@ func (h *handler) GetUserProfitLossTrend() core.HandlerFunc {
|
|||||||
baseCost = 0
|
baseCost = 0
|
||||||
}
|
}
|
||||||
|
|
||||||
orderRows, _ := h.readDB.Orders.WithContext(ctx.RequestContext()).ReadDB().
|
type orderSpendRow struct {
|
||||||
Where(h.readDB.Orders.UserID.Eq(userID)).
|
CreatedAt time.Time
|
||||||
Where(h.readDB.Orders.Status.Eq(2)).
|
Spending int64
|
||||||
Where(h.readDB.Orders.SourceType.In(2, 3, 4)). // 排除积分兑换(1)和奖品兑换(5)
|
}
|
||||||
Where(h.readDB.Orders.CreatedAt.Gte(start)).
|
var orderRows []orderSpendRow
|
||||||
Where(h.readDB.Orders.CreatedAt.Lte(end)).
|
_ = h.repo.GetDbR().Raw(`
|
||||||
Find()
|
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 {
|
type refundInfo struct {
|
||||||
@ -157,7 +183,7 @@ func (h *handler) GetUserProfitLossTrend() core.HandlerFunc {
|
|||||||
var periodDelta int64 = 0
|
var periodDelta int64 = 0
|
||||||
for _, o := range orderRows {
|
for _, o := range orderRows {
|
||||||
if inBucket(o.CreatedAt, b) {
|
if inBucket(o.CreatedAt, b) {
|
||||||
periodDelta += o.ActualAmount
|
periodDelta += o.Spending
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for _, r := range refunds {
|
for _, r := range refunds {
|
||||||
@ -192,16 +218,22 @@ func (h *handler) GetUserProfitLossTrend() core.HandlerFunc {
|
|||||||
|
|
||||||
// 汇总数据
|
// 汇总数据
|
||||||
var totalCost int64 = 0
|
var totalCost int64 = 0
|
||||||
var totalCostPtr *int64
|
_ = h.repo.GetDbR().Raw(`
|
||||||
h.readDB.Orders.WithContext(ctx.RequestContext()).ReadDB().
|
SELECT COALESCE(SUM(CASE
|
||||||
Select(h.readDB.Orders.ActualAmount.Sum()).
|
WHEN o.source_type = 4 OR o.order_no LIKE 'GP%' OR (o.actual_amount = 0 AND o.remark LIKE '%use_game_pass%')
|
||||||
Where(h.readDB.Orders.UserID.Eq(userID)).
|
THEN COALESCE(od.draw_count * a.price_draw, 0)
|
||||||
Where(h.readDB.Orders.Status.Eq(2)).
|
ELSE o.actual_amount + o.discount_amount
|
||||||
Where(h.readDB.Orders.SourceType.In(2, 3, 4)). // 排除积分兑换(1)和奖品兑换(5)
|
END), 0)
|
||||||
Scan(&totalCostPtr)
|
FROM orders o
|
||||||
if totalCostPtr != nil {
|
LEFT JOIN (
|
||||||
totalCost = *totalCostPtr
|
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
|
var totalRefund int64 = 0
|
||||||
_ = h.repo.GetDbR().Raw(`
|
_ = h.repo.GetDbR().Raw(`
|
||||||
@ -387,14 +419,21 @@ func (h *handler) GetUserProfitLossDetails() core.HandlerFunc {
|
|||||||
Name string
|
Name string
|
||||||
}
|
}
|
||||||
var prizes []prizeRow
|
var prizes []prizeRow
|
||||||
_ = h.repo.GetDbR().Raw(`
|
if err := h.repo.GetDbR().Raw(`
|
||||||
SELECT ui.order_id, COALESCE(SUM(p.price), 0) as value,
|
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
|
GROUP_CONCAT(p.name SEPARATOR ', ') as name
|
||||||
FROM user_inventory ui
|
FROM user_inventory ui
|
||||||
LEFT JOIN products p ON p.id = ui.product_id
|
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 ?
|
WHERE ui.order_id IN ?
|
||||||
GROUP BY ui.order_id
|
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 {
|
for _, p := range prizes {
|
||||||
prizeValueMap[p.OrderID] = p.Value
|
prizeValueMap[p.OrderID] = p.Value
|
||||||
prizeNameMap[p.OrderID] = p.Name
|
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))
|
list := make([]profitLossDetailItem, len(orders))
|
||||||
var totalCost, totalValue int64
|
var totalCost, totalValue int64
|
||||||
@ -453,7 +522,14 @@ func (h *handler) GetUserProfitLossDetails() core.HandlerFunc {
|
|||||||
refund := refundMap[o.OrderNo]
|
refund := refundMap[o.OrderNo]
|
||||||
prizeValue := prizeValueMap[o.ID]
|
prizeValue := prizeValueMap[o.ID]
|
||||||
couponValue := couponValueMap[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
|
netProfit := prizeValue - netCost
|
||||||
|
|
||||||
list[i] = profitLossDetailItem{
|
list[i] = profitLossDetailItem{
|
||||||
|
|||||||
@ -246,11 +246,13 @@ func (h *handler) ListTaskTiersForAdmin() core.HandlerFunc {
|
|||||||
|
|
||||||
type upsertRewardsRequest struct {
|
type upsertRewardsRequest struct {
|
||||||
Rewards []struct {
|
Rewards []struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
TierID int64 `json:"tier_id"`
|
TierID int64 `json:"tier_id"`
|
||||||
RewardType string `json:"reward_type"`
|
RewardType string `json:"reward_type"`
|
||||||
RewardPayload datatypes.JSON `json:"reward_payload"`
|
RewardPayload datatypes.JSON `json:"reward_payload"`
|
||||||
Quantity int64 `json:"quantity"`
|
Quantity int64 `json:"quantity"`
|
||||||
} `json:"rewards"`
|
} `json:"rewards"`
|
||||||
|
DeleteIDs []int64 `json:"delete_ids"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// @Summary 设置任务奖励(Admin)
|
// @Summary 设置任务奖励(Admin)
|
||||||
@ -276,9 +278,9 @@ func (h *handler) UpsertTaskRewardsForAdmin() core.HandlerFunc {
|
|||||||
}
|
}
|
||||||
in := make([]tasksvc.TaskRewardInput, len(req.Rewards))
|
in := make([]tasksvc.TaskRewardInput, len(req.Rewards))
|
||||||
for i, r := range 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()))
|
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ServerError, err.Error()))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@ -19,9 +19,10 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type weixinLoginRequest struct {
|
type weixinLoginRequest struct {
|
||||||
Code string `json:"code"`
|
Code string `json:"code"`
|
||||||
InviteCode string `json:"invite_code"`
|
InviteCode string `json:"invite_code"`
|
||||||
DouyinID string `json:"douyin_id"`
|
DouyinID string `json:"douyin_id"`
|
||||||
|
ChannelCode string `json:"channel_code"`
|
||||||
}
|
}
|
||||||
type weixinLoginResponse struct {
|
type weixinLoginResponse struct {
|
||||||
UserID int64 `json:"user_id"`
|
UserID int64 `json:"user_id"`
|
||||||
@ -63,7 +64,7 @@ func (h *handler) WeixinLogin() core.HandlerFunc {
|
|||||||
return
|
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)
|
out, err := h.user.LoginWeixin(ctx.RequestContext(), in)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
ctx.AbortWithError(core.Error(http.StatusBadRequest, 10006, err.Error()))
|
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.UpdatedAt = field.NewTime(tableName, "updated_at")
|
||||||
_activityRewardSettings.IssueID = field.NewInt64(tableName, "issue_id")
|
_activityRewardSettings.IssueID = field.NewInt64(tableName, "issue_id")
|
||||||
_activityRewardSettings.ProductID = field.NewInt64(tableName, "product_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.Weight = field.NewInt32(tableName, "weight")
|
||||||
_activityRewardSettings.Quantity = field.NewInt64(tableName, "quantity")
|
_activityRewardSettings.Quantity = field.NewInt64(tableName, "quantity")
|
||||||
_activityRewardSettings.OriginalQty = field.NewInt64(tableName, "original_qty")
|
_activityRewardSettings.OriginalQty = field.NewInt64(tableName, "original_qty")
|
||||||
@ -50,20 +52,22 @@ func newActivityRewardSettings(db *gorm.DB, opts ...gen.DOOption) activityReward
|
|||||||
type activityRewardSettings struct {
|
type activityRewardSettings struct {
|
||||||
activityRewardSettingsDo
|
activityRewardSettingsDo
|
||||||
|
|
||||||
ALL field.Asterisk
|
ALL field.Asterisk
|
||||||
ID field.Int64 // 主键ID
|
ID field.Int64 // 主键ID
|
||||||
CreatedAt field.Time // 创建时间
|
CreatedAt field.Time // 创建时间
|
||||||
UpdatedAt field.Time // 更新时间
|
UpdatedAt field.Time // 更新时间
|
||||||
IssueID field.Int64 // 期ID(activity_issues.id)
|
IssueID field.Int64 // 期ID(activity_issues.id)
|
||||||
ProductID field.Int64 // 奖品对应商品ID(实物奖可填)
|
ProductID field.Int64 // 奖品对应商品ID(实物奖可填)
|
||||||
Weight field.Int32 // 抽中权重(越大越易中)
|
PriceSnapshotCents field.Int64 // 奖品配置时商品价格快照(分)
|
||||||
Quantity field.Int64 // 当前可发数量(扣减)
|
PriceSnapshotAt field.Time // 奖品价格快照时间
|
||||||
OriginalQty field.Int64 // 初始配置数量
|
Weight field.Int32 // 抽中权重(越大越易中)
|
||||||
Level field.Int32 // 奖级(如1=S 2=A 3=B)
|
Quantity field.Int64 // 当前可发数量(扣减)
|
||||||
Sort field.Int32 // 排序
|
OriginalQty field.Int64 // 初始配置数量
|
||||||
IsBoss field.Int32 // Boss 1 是 0 不是
|
Level field.Int32 // 奖级(如1=S 2=A 3=B)
|
||||||
DeletedAt field.Field
|
Sort field.Int32 // 排序
|
||||||
MinScore field.Int64 // 最低得分/碰数要求
|
IsBoss field.Int32 // Boss 1 是 0 不是
|
||||||
|
DeletedAt field.Field
|
||||||
|
MinScore field.Int64 // 最低得分/碰数要求
|
||||||
|
|
||||||
fieldMap map[string]field.Expr
|
fieldMap map[string]field.Expr
|
||||||
}
|
}
|
||||||
@ -85,6 +89,8 @@ func (a *activityRewardSettings) updateTableName(table string) *activityRewardSe
|
|||||||
a.UpdatedAt = field.NewTime(table, "updated_at")
|
a.UpdatedAt = field.NewTime(table, "updated_at")
|
||||||
a.IssueID = field.NewInt64(table, "issue_id")
|
a.IssueID = field.NewInt64(table, "issue_id")
|
||||||
a.ProductID = field.NewInt64(table, "product_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.Weight = field.NewInt32(table, "weight")
|
||||||
a.Quantity = field.NewInt64(table, "quantity")
|
a.Quantity = field.NewInt64(table, "quantity")
|
||||||
a.OriginalQty = field.NewInt64(table, "original_qty")
|
a.OriginalQty = field.NewInt64(table, "original_qty")
|
||||||
@ -109,12 +115,14 @@ func (a *activityRewardSettings) GetFieldByName(fieldName string) (field.OrderEx
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (a *activityRewardSettings) fillFieldMap() {
|
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["id"] = a.ID
|
||||||
a.fieldMap["created_at"] = a.CreatedAt
|
a.fieldMap["created_at"] = a.CreatedAt
|
||||||
a.fieldMap["updated_at"] = a.UpdatedAt
|
a.fieldMap["updated_at"] = a.UpdatedAt
|
||||||
a.fieldMap["issue_id"] = a.IssueID
|
a.fieldMap["issue_id"] = a.IssueID
|
||||||
a.fieldMap["product_id"] = a.ProductID
|
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["weight"] = a.Weight
|
||||||
a.fieldMap["quantity"] = a.Quantity
|
a.fieldMap["quantity"] = a.Quantity
|
||||||
a.fieldMap["original_qty"] = a.OriginalQty
|
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.Name = field.NewString(tableName, "name")
|
||||||
_livestreamActivities.StreamerName = field.NewString(tableName, "streamer_name")
|
_livestreamActivities.StreamerName = field.NewString(tableName, "streamer_name")
|
||||||
_livestreamActivities.StreamerContact = field.NewString(tableName, "streamer_contact")
|
_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.AccessCode = field.NewString(tableName, "access_code")
|
||||||
_livestreamActivities.DouyinProductID = field.NewString(tableName, "douyin_product_id")
|
_livestreamActivities.DouyinProductID = field.NewString(tableName, "douyin_product_id")
|
||||||
_livestreamActivities.Status = field.NewInt32(tableName, "status")
|
_livestreamActivities.Status = field.NewInt32(tableName, "status")
|
||||||
@ -59,6 +61,8 @@ type livestreamActivities struct {
|
|||||||
Name field.String // 活动名称
|
Name field.String // 活动名称
|
||||||
StreamerName field.String // 主播名称
|
StreamerName field.String // 主播名称
|
||||||
StreamerContact field.String // 主播联系方式
|
StreamerContact field.String // 主播联系方式
|
||||||
|
ChannelID field.Int64 // 关联渠道ID
|
||||||
|
ChannelCode field.String // 关联渠道Code
|
||||||
AccessCode field.String // 唯一访问码
|
AccessCode field.String // 唯一访问码
|
||||||
DouyinProductID field.String // 关联抖店商品ID
|
DouyinProductID field.String // 关联抖店商品ID
|
||||||
Status field.Int32 // 状态:1进行中 2已结束
|
Status field.Int32 // 状态:1进行中 2已结束
|
||||||
@ -92,6 +96,8 @@ func (l *livestreamActivities) updateTableName(table string) *livestreamActiviti
|
|||||||
l.Name = field.NewString(table, "name")
|
l.Name = field.NewString(table, "name")
|
||||||
l.StreamerName = field.NewString(table, "streamer_name")
|
l.StreamerName = field.NewString(table, "streamer_name")
|
||||||
l.StreamerContact = field.NewString(table, "streamer_contact")
|
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.AccessCode = field.NewString(table, "access_code")
|
||||||
l.DouyinProductID = field.NewString(table, "douyin_product_id")
|
l.DouyinProductID = field.NewString(table, "douyin_product_id")
|
||||||
l.Status = field.NewInt32(table, "status")
|
l.Status = field.NewInt32(table, "status")
|
||||||
@ -121,11 +127,13 @@ func (l *livestreamActivities) GetFieldByName(fieldName string) (field.OrderExpr
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (l *livestreamActivities) fillFieldMap() {
|
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["id"] = l.ID
|
||||||
l.fieldMap["name"] = l.Name
|
l.fieldMap["name"] = l.Name
|
||||||
l.fieldMap["streamer_name"] = l.StreamerName
|
l.fieldMap["streamer_name"] = l.StreamerName
|
||||||
l.fieldMap["streamer_contact"] = l.StreamerContact
|
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["access_code"] = l.AccessCode
|
||||||
l.fieldMap["douyin_product_id"] = l.DouyinProductID
|
l.fieldMap["douyin_product_id"] = l.DouyinProductID
|
||||||
l.fieldMap["status"] = l.Status
|
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.UpdatedAt = field.NewTime(tableName, "updated_at")
|
||||||
_userInventory.UserID = field.NewInt64(tableName, "user_id")
|
_userInventory.UserID = field.NewInt64(tableName, "user_id")
|
||||||
_userInventory.ProductID = field.NewInt64(tableName, "product_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.OrderID = field.NewInt64(tableName, "order_id")
|
||||||
_userInventory.ActivityID = field.NewInt64(tableName, "activity_id")
|
_userInventory.ActivityID = field.NewInt64(tableName, "activity_id")
|
||||||
_userInventory.RewardID = field.NewInt64(tableName, "reward_id")
|
_userInventory.RewardID = field.NewInt64(tableName, "reward_id")
|
||||||
@ -48,18 +51,21 @@ func newUserInventory(db *gorm.DB, opts ...gen.DOOption) userInventory {
|
|||||||
type userInventory struct {
|
type userInventory struct {
|
||||||
userInventoryDo
|
userInventoryDo
|
||||||
|
|
||||||
ALL field.Asterisk
|
ALL field.Asterisk
|
||||||
ID field.Int64 // 主键ID
|
ID field.Int64 // 主键ID
|
||||||
CreatedAt field.Time // 创建时间
|
CreatedAt field.Time // 创建时间
|
||||||
UpdatedAt field.Time // 更新时间
|
UpdatedAt field.Time // 更新时间
|
||||||
UserID field.Int64 // 资产归属用户ID
|
UserID field.Int64 // 资产归属用户ID
|
||||||
ProductID field.Int64 // 资产对应商品ID(实物奖/商品)
|
ProductID field.Int64 // 资产对应商品ID(实物奖/商品)
|
||||||
OrderID field.Int64 // 来源订单ID
|
ValueCents field.Int64 // 资产价值快照(分)
|
||||||
ActivityID field.Int64 // 来源活动ID
|
ValueSource field.Int32 // 价值来源:0未知 1奖励快照 2商品回退 3人工修复
|
||||||
RewardID field.Int64 // 来源奖励ID(activity_reward_settings.id)
|
ValueSnapshotAt field.Time // 资产价值快照时间
|
||||||
Status field.Int32 // 状态:1持有 2作废 3已使用/发货
|
OrderID field.Int64 // 来源订单ID
|
||||||
ShippingNo field.String // 发货单号
|
ActivityID field.Int64 // 来源活动ID
|
||||||
Remark field.String // 备注
|
RewardID field.Int64 // 来源奖励ID(activity_reward_settings.id)
|
||||||
|
Status field.Int32 // 状态:1持有 2作废 3已使用/发货
|
||||||
|
ShippingNo field.String // 发货单号
|
||||||
|
Remark field.String // 备注
|
||||||
|
|
||||||
fieldMap map[string]field.Expr
|
fieldMap map[string]field.Expr
|
||||||
}
|
}
|
||||||
@ -81,6 +87,9 @@ func (u *userInventory) updateTableName(table string) *userInventory {
|
|||||||
u.UpdatedAt = field.NewTime(table, "updated_at")
|
u.UpdatedAt = field.NewTime(table, "updated_at")
|
||||||
u.UserID = field.NewInt64(table, "user_id")
|
u.UserID = field.NewInt64(table, "user_id")
|
||||||
u.ProductID = field.NewInt64(table, "product_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.OrderID = field.NewInt64(table, "order_id")
|
||||||
u.ActivityID = field.NewInt64(table, "activity_id")
|
u.ActivityID = field.NewInt64(table, "activity_id")
|
||||||
u.RewardID = field.NewInt64(table, "reward_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() {
|
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["id"] = u.ID
|
||||||
u.fieldMap["created_at"] = u.CreatedAt
|
u.fieldMap["created_at"] = u.CreatedAt
|
||||||
u.fieldMap["updated_at"] = u.UpdatedAt
|
u.fieldMap["updated_at"] = u.UpdatedAt
|
||||||
u.fieldMap["user_id"] = u.UserID
|
u.fieldMap["user_id"] = u.UserID
|
||||||
u.fieldMap["product_id"] = u.ProductID
|
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["order_id"] = u.OrderID
|
||||||
u.fieldMap["activity_id"] = u.ActivityID
|
u.fieldMap["activity_id"] = u.ActivityID
|
||||||
u.fieldMap["reward_id"] = u.RewardID
|
u.fieldMap["reward_id"] = u.RewardID
|
||||||
|
|||||||
@ -14,19 +14,21 @@ const TableNameActivityRewardSettings = "activity_reward_settings"
|
|||||||
|
|
||||||
// ActivityRewardSettings 活动期-奖励配置
|
// ActivityRewardSettings 活动期-奖励配置
|
||||||
type ActivityRewardSettings struct {
|
type ActivityRewardSettings struct {
|
||||||
ID int64 `gorm:"column:id;primaryKey;autoIncrement:true;comment:主键ID" json:"id"` // 主键ID
|
ID int64 `gorm:"column:id;primaryKey;autoIncrement:true;comment:主键ID" json:"id"` // 主键ID
|
||||||
CreatedAt time.Time `gorm:"column:created_at;not null;default:CURRENT_TIMESTAMP(3);comment:创建时间" json:"created_at"` // 创建时间
|
CreatedAt time.Time `gorm:"column:created_at;not null;default:CURRENT_TIMESTAMP(3);comment:创建时间" json:"created_at"` // 创建时间
|
||||||
UpdatedAt time.Time `gorm:"column:updated_at;not null;default:CURRENT_TIMESTAMP(3);comment:更新时间" json:"updated_at"` // 更新时间
|
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)
|
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(实物奖可填)
|
ProductID int64 `gorm:"column:product_id;comment:奖品对应商品ID(实物奖可填)" json:"product_id"` // 奖品对应商品ID(实物奖可填)
|
||||||
Weight int32 `gorm:"column:weight;not null;comment:抽中权重(越大越易中)" json:"weight"` // 抽中权重(越大越易中)
|
PriceSnapshotCents int64 `gorm:"column:price_snapshot_cents;not null;comment:奖品配置时商品价格快照(分)" json:"price_snapshot_cents"` // 奖品配置时商品价格快照(分)
|
||||||
Quantity int64 `gorm:"column:quantity;not null;comment:当前可发数量(扣减)" json:"quantity"` // 当前可发数量(扣减)
|
PriceSnapshotAt time.Time `gorm:"column:price_snapshot_at;comment:奖品价格快照时间" json:"price_snapshot_at"` // 奖品价格快照时间
|
||||||
OriginalQty int64 `gorm:"column:original_qty;not null;comment:初始配置数量" json:"original_qty"` // 初始配置数量
|
Weight int32 `gorm:"column:weight;not null;comment:抽中权重(越大越易中)" json:"weight"` // 抽中权重(越大越易中)
|
||||||
Level int32 `gorm:"column:level;not null;comment:奖级(如1=S 2=A 3=B)" json:"level"` // 奖级(如1=S 2=A 3=B)
|
Quantity int64 `gorm:"column:quantity;not null;comment:当前可发数量(扣减)" json:"quantity"` // 当前可发数量(扣减)
|
||||||
Sort int32 `gorm:"column:sort;comment:排序" json:"sort"` // 排序
|
OriginalQty int64 `gorm:"column:original_qty;not null;comment:初始配置数量" json:"original_qty"` // 初始配置数量
|
||||||
IsBoss int32 `gorm:"column:is_boss;comment:Boss 1 是 0 不是" json:"is_boss"` // Boss 1 是 0 不是
|
Level int32 `gorm:"column:level;not null;comment:奖级(如1=S 2=A 3=B)" json:"level"` // 奖级(如1=S 2=A 3=B)
|
||||||
DeletedAt gorm.DeletedAt `gorm:"column:deleted_at" json:"deleted_at"`
|
Sort int32 `gorm:"column:sort;comment:排序" json:"sort"` // 排序
|
||||||
MinScore int64 `gorm:"column:min_score;not null;comment:最低得分/碰数要求" json:"min_score"` // 最低得分/碰数要求
|
IsBoss int32 `gorm:"column:is_boss;comment:Boss 1 是 0 不是" json:"is_boss"` // Boss 1 是 0 不是
|
||||||
|
DeletedAt gorm.DeletedAt `gorm:"column:deleted_at" json:"deleted_at"`
|
||||||
|
MinScore int64 `gorm:"column:min_score;not null;comment:最低得分/碰数要求" json:"min_score"` // 最低得分/碰数要求
|
||||||
}
|
}
|
||||||
|
|
||||||
// TableName ActivityRewardSettings's table name
|
// TableName ActivityRewardSettings's table name
|
||||||
|
|||||||
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"` // 活动名称
|
Name string `gorm:"column:name;not null;comment:活动名称" json:"name"` // 活动名称
|
||||||
StreamerName string `gorm:"column:streamer_name;comment:主播名称" json:"streamer_name"` // 主播名称
|
StreamerName string `gorm:"column:streamer_name;comment:主播名称" json:"streamer_name"` // 主播名称
|
||||||
StreamerContact string `gorm:"column:streamer_contact;comment:主播联系方式" json:"streamer_contact"` // 主播联系方式
|
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"` // 唯一访问码
|
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
|
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"` // 下单奖励类型
|
OrderRewardType string `gorm:"column:order_reward_type;default:'';comment:下单奖励类型: flip_card/minesweeper" json:"order_reward_type"` // 下单奖励类型
|
||||||
|
|||||||
@ -12,17 +12,20 @@ const TableNameUserInventory = "user_inventory"
|
|||||||
|
|
||||||
// UserInventory 用户资产(资产)
|
// UserInventory 用户资产(资产)
|
||||||
type UserInventory struct {
|
type UserInventory struct {
|
||||||
ID int64 `gorm:"column:id;primaryKey;autoIncrement:true;comment:主键ID" json:"id"` // 主键ID
|
ID int64 `gorm:"column:id;primaryKey;autoIncrement:true;comment:主键ID" json:"id"` // 主键ID
|
||||||
CreatedAt time.Time `gorm:"column:created_at;not null;default:CURRENT_TIMESTAMP(3);comment:创建时间" json:"created_at"` // 创建时间
|
CreatedAt time.Time `gorm:"column:created_at;not null;default:CURRENT_TIMESTAMP(3);comment:创建时间" json:"created_at"` // 创建时间
|
||||||
UpdatedAt time.Time `gorm:"column:updated_at;not null;default:CURRENT_TIMESTAMP(3);comment:更新时间" json:"updated_at"` // 更新时间
|
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
|
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(实物奖/商品)
|
ProductID int64 `gorm:"column:product_id;comment:资产对应商品ID(实物奖/商品)" json:"product_id"` // 资产对应商品ID(实物奖/商品)
|
||||||
OrderID int64 `gorm:"column:order_id;comment:来源订单ID" json:"order_id"` // 来源订单ID
|
ValueCents int64 `gorm:"column:value_cents;not null;comment:资产价值快照(分)" json:"value_cents"` // 资产价值快照(分)
|
||||||
ActivityID int64 `gorm:"column:activity_id;comment:来源活动ID" json:"activity_id"` // 来源活动ID
|
ValueSource int32 `gorm:"column:value_source;not null;comment:价值来源:0未知 1奖励快照 2商品回退 3人工修复" json:"value_source"` // 价值来源:0未知 1奖励快照 2商品回退 3人工修复
|
||||||
RewardID int64 `gorm:"column:reward_id;comment:来源奖励ID(activity_reward_settings.id)" json:"reward_id"` // 来源奖励ID(activity_reward_settings.id)
|
ValueSnapshotAt time.Time `gorm:"column:value_snapshot_at;comment:资产价值快照时间" json:"value_snapshot_at"` // 资产价值快照时间
|
||||||
Status int32 `gorm:"column:status;not null;default:1;comment:状态:1持有 2作废 3已使用/发货" json:"status"` // 状态:1持有 2作废 3已使用/发货
|
OrderID int64 `gorm:"column:order_id;comment:来源订单ID" json:"order_id"` // 来源订单ID
|
||||||
ShippingNo string `gorm:"column:shipping_no;not null;comment:发货单号" json:"shipping_no"` // 发货单号
|
ActivityID int64 `gorm:"column:activity_id;comment:来源活动ID" json:"activity_id"` // 来源活动ID
|
||||||
Remark string `gorm:"column:remark;comment:备注" json:"remark"` // 备注
|
RewardID int64 `gorm:"column:reward_id;comment:来源奖励ID(activity_reward_settings.id)" json:"reward_id"` // 来源奖励ID(activity_reward_settings.id)
|
||||||
|
Status int32 `gorm:"column:status;not null;default:1;comment:状态:1持有 2作废 3已使用/发货" json:"status"` // 状态:1持有 2作废 3已使用/发货
|
||||||
|
ShippingNo string `gorm:"column:shipping_no;not null;comment:发货单号" json:"shipping_no"` // 发货单号
|
||||||
|
Remark string `gorm:"column:remark;comment:备注" json:"remark"` // 备注
|
||||||
}
|
}
|
||||||
|
|
||||||
// TableName UserInventory's table name
|
// TableName UserInventory's table name
|
||||||
|
|||||||
@ -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-all", adminHandler.ManualSyncAll())
|
||||||
adminAuthApiRouter.POST("/douyin/sync-refund", adminHandler.ManualSyncRefund())
|
adminAuthApiRouter.POST("/douyin/sync-refund", adminHandler.ManualSyncRefund())
|
||||||
adminAuthApiRouter.POST("/douyin/grant-prizes", adminHandler.ManualGrantPrizes())
|
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.GET("/douyin/product-rewards", adminHandler.ListDouyinProductRewards())
|
||||||
adminAuthApiRouter.POST("/douyin/product-rewards", adminHandler.CreateDouyinProductReward())
|
adminAuthApiRouter.POST("/douyin/product-rewards", adminHandler.CreateDouyinProductReward())
|
||||||
|
|||||||
@ -190,15 +190,13 @@ func (s *service) ProcessOrderLottery(ctx context.Context, orderID int64) error
|
|||||||
if invCountMap[log.RewardID] < needed {
|
if invCountMap[log.RewardID] < needed {
|
||||||
rw := rewardMap[log.RewardID]
|
rw := rewardMap[log.RewardID]
|
||||||
if rw != nil {
|
if rw != nil {
|
||||||
var rewardIDRef *int64
|
rewardIDRef := &log.RewardID
|
||||||
if act != nil && act.PlayType == "ichiban" {
|
|
||||||
rewardIDRef = &log.RewardID
|
|
||||||
}
|
|
||||||
batchItems = append(batchItems, usersvc.BatchRewardItem{
|
batchItems = append(batchItems, usersvc.BatchRewardItem{
|
||||||
ProductID: rw.ProductID,
|
ProductID: rw.ProductID,
|
||||||
RewardID: rewardIDRef,
|
RewardID: rewardIDRef,
|
||||||
ActivityID: aid,
|
DeductRewardStock: act != nil && act.PlayType == "ichiban",
|
||||||
Remark: productNameMap[rw.ProductID],
|
ActivityID: aid,
|
||||||
|
Remark: productNameMap[rw.ProductID],
|
||||||
})
|
})
|
||||||
invCountMap[log.RewardID]++ // 内存计数同步
|
invCountMap[log.RewardID]++ // 内存计数同步
|
||||||
|
|
||||||
|
|||||||
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 (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"time"
|
||||||
|
|
||||||
"bindbox-game/internal/repository/mysql/dao"
|
"bindbox-game/internal/repository/mysql/dao"
|
||||||
"bindbox-game/internal/repository/mysql/model"
|
"bindbox-game/internal/repository/mysql/model"
|
||||||
@ -12,17 +13,40 @@ import (
|
|||||||
// 返回: 错误信息
|
// 返回: 错误信息
|
||||||
func (s *service) CreateIssueRewards(ctx context.Context, issueID int64, rewards []CreateRewardInput) error {
|
func (s *service) CreateIssueRewards(ctx context.Context, issueID int64, rewards []CreateRewardInput) error {
|
||||||
return s.writeDB.Transaction(func(tx *dao.Query) 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 {
|
for _, r := range rewards {
|
||||||
item := &model.ActivityRewardSettings{
|
item := &model.ActivityRewardSettings{
|
||||||
IssueID: issueID,
|
IssueID: issueID,
|
||||||
ProductID: r.ProductID,
|
ProductID: r.ProductID,
|
||||||
Weight: r.Weight,
|
PriceSnapshotCents: productPriceMap[r.ProductID],
|
||||||
Quantity: r.Quantity,
|
PriceSnapshotAt: time.Now(),
|
||||||
OriginalQty: r.OriginalQty,
|
Weight: r.Weight,
|
||||||
Level: r.Level,
|
Quantity: r.Quantity,
|
||||||
Sort: r.Sort,
|
OriginalQty: r.OriginalQty,
|
||||||
IsBoss: r.IsBoss,
|
Level: r.Level,
|
||||||
MinScore: r.MinScore,
|
Sort: r.Sort,
|
||||||
|
IsBoss: r.IsBoss,
|
||||||
|
MinScore: r.MinScore,
|
||||||
}
|
}
|
||||||
if err := tx.ActivityRewardSettings.WithContext(ctx).Create(item); err != nil {
|
if err := tx.ActivityRewardSettings.WithContext(ctx).Create(item); err != nil {
|
||||||
return err
|
return err
|
||||||
|
|||||||
@ -17,6 +17,16 @@ func (s *service) ModifyIssueReward(ctx context.Context, rewardID int64, in Modi
|
|||||||
}
|
}
|
||||||
if in.ProductID != nil {
|
if in.ProductID != nil {
|
||||||
item.ProductID = *in.ProductID
|
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 {
|
if in.Weight != nil {
|
||||||
item.Weight = int32(*in.Weight)
|
item.Weight = int32(*in.Weight)
|
||||||
|
|||||||
@ -2,12 +2,15 @@ package channel
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"errors"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"bindbox-game/internal/pkg/logger"
|
"bindbox-game/internal/pkg/logger"
|
||||||
"bindbox-game/internal/repository/mysql"
|
"bindbox-game/internal/repository/mysql"
|
||||||
"bindbox-game/internal/repository/mysql/dao"
|
"bindbox-game/internal/repository/mysql/dao"
|
||||||
"bindbox-game/internal/repository/mysql/model"
|
"bindbox-game/internal/repository/mysql/model"
|
||||||
|
|
||||||
|
"gorm.io/gorm"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Service interface {
|
type Service interface {
|
||||||
@ -16,6 +19,7 @@ type Service interface {
|
|||||||
Delete(ctx context.Context, id int64) error
|
Delete(ctx context.Context, id int64) error
|
||||||
List(ctx context.Context, in ListInput) (items []*ChannelWithStat, total int64, err 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)
|
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 {
|
type service struct {
|
||||||
@ -70,6 +74,8 @@ type StatsDailyItem struct {
|
|||||||
GMV int64 `json:"gmv"`
|
GMV int64 `json:"gmv"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var ErrChannelNotFound = errors.New("channel_not_found")
|
||||||
|
|
||||||
func (s *service) Create(ctx context.Context, in CreateInput) (*model.Channels, error) {
|
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}
|
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 {
|
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
|
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
|
package douyin
|
||||||
|
|
||||||
import (
|
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"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"math"
|
"math"
|
||||||
@ -21,30 +16,38 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
"unicode"
|
"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"
|
"go.uber.org/zap"
|
||||||
"golang.org/x/sync/singleflight"
|
"golang.org/x/sync/singleflight"
|
||||||
|
"gorm.io/gorm"
|
||||||
"bindbox-game/internal/service/user"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// 系统配置键
|
// 系统配置键
|
||||||
const (
|
const (
|
||||||
ConfigKeyDouyinCookie = "douyin_cookie"
|
ConfigKeyDouyinCookie = "douyin_cookie"
|
||||||
ConfigKeyDouyinInterval = "douyin_sync_interval_minutes"
|
ConfigKeyDouyinInterval = "douyin_sync_interval_minutes"
|
||||||
|
ConfigKeyDouyinProxy = "douyin_proxy"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Service interface {
|
type Service interface {
|
||||||
// FetchAndSyncOrders 从抖店 API 获取订单并同步到本地 (按绑定用户同步)
|
// FetchAndSyncOrders 从抖店 API 获取订单并同步到本地 (按绑定用户同步)
|
||||||
FetchAndSyncOrders(ctx context.Context) (*SyncResult, error)
|
FetchAndSyncOrders(ctx context.Context, opts *FetchOptions) (*SyncResult, error)
|
||||||
// SyncAllOrders 批量同步所有订单变更 (基于更新时间,不分状态)
|
// SyncAllOrders 批量同步所有订单变更 (基于更新时间,不分状态)
|
||||||
// useProxy: 是否使用代理服务器访问抖音API
|
// useProxy: 是否使用代理服务器访问抖音API
|
||||||
SyncAllOrders(ctx context.Context, duration time.Duration, useProxy bool) (*SyncResult, error)
|
SyncAllOrders(ctx context.Context, duration time.Duration, useProxy bool) (*SyncResult, error)
|
||||||
// ListOrders 获取本地抖店订单列表
|
// 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 获取抖店配置
|
||||||
GetConfig(ctx context.Context) (*DouyinConfig, error)
|
GetConfig(ctx context.Context) (*DouyinConfig, error)
|
||||||
// SaveConfig 保存抖店配置
|
// SaveConfig 保存抖店配置
|
||||||
SaveConfig(ctx context.Context, cookie string, intervalMinutes int) error
|
SaveConfig(ctx context.Context, cookie, proxy string, intervalMinutes int) error
|
||||||
// SyncOrder 同步单个订单到本地,可传入建议关联的用户ID和商品ID
|
// SyncOrder 同步单个订单到本地,可传入建议关联的用户ID和商品ID
|
||||||
SyncOrder(ctx context.Context, item *DouyinOrderItem, suggestUserID int64, productID string) (isNew bool, isMatched bool)
|
SyncOrder(ctx context.Context, item *DouyinOrderItem, suggestUserID int64, productID string) (isNew bool, isMatched bool)
|
||||||
// GrantMinesweeperQualifications 自动补发扫雷资格
|
// GrantMinesweeperQualifications 自动补发扫雷资格
|
||||||
@ -53,19 +56,141 @@ type Service interface {
|
|||||||
GrantLivestreamPrizes(ctx context.Context) error
|
GrantLivestreamPrizes(ctx context.Context) error
|
||||||
// SyncRefundStatus 同步退款状态
|
// SyncRefundStatus 同步退款状态
|
||||||
SyncRefundStatus(ctx context.Context) error
|
SyncRefundStatus(ctx context.Context) error
|
||||||
|
// GrantOrderReward 手动触发单个订单的奖励发放
|
||||||
|
GrantOrderReward(ctx context.Context, shopOrderID string) (*GrantOrderRewardResult, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
type DouyinConfig struct {
|
type DouyinConfig struct {
|
||||||
Cookie string `json:"cookie"`
|
Cookie string `json:"cookie"`
|
||||||
IntervalMinutes int `json:"interval_minutes"`
|
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 {
|
type SyncResult struct {
|
||||||
TotalFetched int `json:"total_fetched"`
|
TotalFetched int `json:"total_fetched"`
|
||||||
NewOrders int `json:"new_orders"`
|
NewOrders int `json:"new_orders"`
|
||||||
MatchedUsers int `json:"matched_users"`
|
MatchedUsers int `json:"matched_users"`
|
||||||
Orders []*model.DouyinOrders `json:"orders"` // 新增:返回详情以供后续处理
|
Orders []*model.DouyinOrders `json:"orders"` // 新增:返回详情以供后续处理
|
||||||
DebugInfo string `json:"debug_info"`
|
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 {
|
type service struct {
|
||||||
@ -113,14 +238,20 @@ func (s *service) GetConfig(ctx context.Context) (*DouyinConfig, error) {
|
|||||||
cfg.IntervalMinutes = v
|
cfg.IntervalMinutes = v
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if c, err := s.syscfg.GetByKey(ctx, ConfigKeyDouyinProxy); err == nil && c != nil {
|
||||||
|
cfg.Proxy = c.ConfigValue
|
||||||
|
}
|
||||||
return cfg, nil
|
return cfg, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// SaveConfig 保存抖店配置
|
// 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 {
|
if _, err := s.syscfg.UpsertByKey(ctx, ConfigKeyDouyinCookie, cookie, "抖店Cookie"); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
if _, err := s.syscfg.UpsertByKey(ctx, ConfigKeyDouyinProxy, proxy, "抖店代理配置"); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
if intervalMinutes < 1 {
|
if intervalMinutes < 1 {
|
||||||
intervalMinutes = 5
|
intervalMinutes = 5
|
||||||
}
|
}
|
||||||
@ -131,7 +262,7 @@ func (s *service) SaveConfig(ctx context.Context, cookie string, intervalMinutes
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ListOrders 获取本地抖店订单列表
|
// 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 {
|
if page <= 0 {
|
||||||
page = 1
|
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{})
|
db := s.repo.GetDbR().WithContext(ctx).Model(&model.DouyinOrders{})
|
||||||
if status != nil {
|
if filter == nil {
|
||||||
db = db.Where("order_status = ?", *status)
|
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
|
var total int64
|
||||||
@ -158,7 +308,9 @@ func (s *service) ListOrders(ctx context.Context, page, pageSize int, status *in
|
|||||||
}
|
}
|
||||||
|
|
||||||
// FetchAndSyncOrders 遍历所有已绑定抖音号的用户并同步其订单
|
// 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)
|
cfg, err := s.GetConfig(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("获取配置失败: %w", err)
|
return nil, fmt.Errorf("获取配置失败: %w", err)
|
||||||
@ -168,40 +320,159 @@ func (s *service) FetchAndSyncOrders(ctx context.Context) (*SyncResult, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 1. 获取所有绑定了抖音号的用户
|
// 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
|
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)
|
return nil, fmt.Errorf("获取绑定用户失败: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
result := &SyncResult{}
|
result := &SyncResult{
|
||||||
fmt.Printf("[DEBUG] 开始全量同步,共 %d 个绑定用户\n", len(users))
|
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 抓取订单
|
if len(users) == 0 {
|
||||||
for _, u := range users {
|
result.ElapsedMS = time.Since(startAt).Milliseconds()
|
||||||
fmt.Printf("[DEBUG] 正在同步用户 ID: %d (昵称: %s, 抖音号: %s) 的订单...\n", u.ID, u.Nickname, u.DouyinUserID)
|
result.DebugInfo = "未找到符合条件的用户"
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
orders, err := s.fetchDouyinOrdersByBuyer(cfg.Cookie, u.DouyinUserID)
|
var mu sync.Mutex
|
||||||
if err != nil {
|
|
||||||
fmt.Printf("[DEBUG] 抓取用户 %s 订单失败: %v\n", u.DouyinUserID, err)
|
syncUser := func(u model.Users) {
|
||||||
continue
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
default:
|
||||||
}
|
}
|
||||||
|
|
||||||
result.TotalFetched += len(orders)
|
s.logger.Info("[抖店同步] 开始同步用户订单",
|
||||||
|
zap.Int64("user_id", u.ID),
|
||||||
|
zap.String("nickname", u.Nickname),
|
||||||
|
zap.String("douyin_user_id", u.DouyinUserID))
|
||||||
|
|
||||||
// 3. 同步
|
orders, err := s.fetchDouyinOrdersByBuyer(cfg.Cookie, u.DouyinUserID, cfg.Proxy)
|
||||||
|
if err != nil {
|
||||||
|
s.logger.Warn("[抖店同步] 抓取用户订单失败",
|
||||||
|
zap.String("douyin_user_id", u.DouyinUserID),
|
||||||
|
zap.Error(err))
|
||||||
|
mu.Lock()
|
||||||
|
result.SkippedUsers++
|
||||||
|
mu.Unlock()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
perUserNew := 0
|
||||||
|
perUserMatched := 0
|
||||||
for _, order := range orders {
|
for _, order := range orders {
|
||||||
// 同步订单(传入建议关联的用户 ID)
|
|
||||||
isNew, matched := s.SyncOrder(ctx, &order, u.ID, "")
|
isNew, matched := s.SyncOrder(ctx, &order, u.ID, "")
|
||||||
if isNew {
|
if isNew {
|
||||||
result.NewOrders++
|
perUserNew++
|
||||||
}
|
}
|
||||||
if matched {
|
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
|
return result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -243,7 +514,7 @@ type SkuOrderItem struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// fetchDouyinOrdersByBuyer 调用抖店 API 按 Buyer ID 获取订单 (保持向后兼容)
|
// 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 := url.Values{}
|
||||||
params.Set("page", "0")
|
params.Set("page", "0")
|
||||||
params.Set("pageSize", "100")
|
params.Set("pageSize", "100")
|
||||||
@ -255,18 +526,22 @@ func (s *service) fetchDouyinOrdersByBuyer(cookie string, buyer string) ([]Douyi
|
|||||||
params.Set("_bid", "ffa_order")
|
params.Set("_bid", "ffa_order")
|
||||||
params.Set("aid", "4272")
|
params.Set("aid", "4272")
|
||||||
|
|
||||||
return s.fetchDouyinOrders(cookie, params, true) // 按用户同步使用代理
|
return s.fetchDouyinOrders(cookie, params, proxy)
|
||||||
}
|
}
|
||||||
|
|
||||||
// fetchDouyinOrders 通用的抖店订单抓取方法
|
// 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"
|
baseUrl := "https://fxg.jinritemai.com/api/order/searchlist"
|
||||||
fullUrl := baseUrl + "?" + params.Encode()
|
fullUrl := baseUrl + "?" + params.Encode()
|
||||||
|
|
||||||
// 配置代理服务器:巨量代理IP (可选)
|
// 配置代理服务器:巨量代理IP (可选)
|
||||||
var proxyURL *url.URL
|
var proxyURL *url.URL
|
||||||
if useProxy {
|
if strings.TrimSpace(proxyAddr) != "" {
|
||||||
proxyURL, _ = url.Parse("http://t13319619426654:ln8aj9nl@s432.kdltps.com:15818")
|
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
|
var lastErr error
|
||||||
@ -285,9 +560,9 @@ func (s *service) fetchDouyinOrders(cookie string, params url.Values, useProxy b
|
|||||||
// 禁用连接复用,防止代理断开导致 EOF
|
// 禁用连接复用,防止代理断开导致 EOF
|
||||||
req.Close = true
|
req.Close = true
|
||||||
|
|
||||||
// 根据 useProxy 参数决定是否使用代理
|
// 根据 proxyURL 是否存在决定是否使用代理
|
||||||
var transport *http.Transport
|
var transport *http.Transport
|
||||||
if useProxy && proxyURL != nil {
|
if proxyURL != nil {
|
||||||
transport = &http.Transport{
|
transport = &http.Transport{
|
||||||
Proxy: http.ProxyURL(proxyURL),
|
Proxy: http.ProxyURL(proxyURL),
|
||||||
DisableKeepAlives: true, // 禁用 Keep-Alive
|
DisableKeepAlives: true, // 禁用 Keep-Alive
|
||||||
@ -306,7 +581,7 @@ func (s *service) fetchDouyinOrders(cookie string, params url.Values, useProxy b
|
|||||||
resp, err := client.Do(req)
|
resp, err := client.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
lastErr = err
|
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)
|
time.Sleep(1 * time.Second)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@ -559,6 +834,99 @@ func (s *service) SyncOrder(ctx context.Context, item *DouyinOrderItem, suggestU
|
|||||||
return isNew, isMatched
|
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 返回两个整数的最小值
|
// min 返回两个整数的最小值
|
||||||
func min(a, b int) int {
|
func min(a, b int) int {
|
||||||
if a < b {
|
if a < b {
|
||||||
@ -612,7 +980,12 @@ func (s *service) SyncAllOrders(ctx context.Context, duration time.Duration, use
|
|||||||
}
|
}
|
||||||
|
|
||||||
fetchStart := time.Now()
|
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)
|
fetchDuration := time.Since(fetchStart)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@ -1,6 +1,8 @@
|
|||||||
package douyin
|
package douyin
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
|
|
||||||
"bindbox-game/internal/pkg/logger"
|
"bindbox-game/internal/pkg/logger"
|
||||||
"bindbox-game/internal/repository/mysql"
|
"bindbox-game/internal/repository/mysql"
|
||||||
"bindbox-game/internal/repository/mysql/model"
|
"bindbox-game/internal/repository/mysql/model"
|
||||||
@ -14,6 +16,8 @@ import (
|
|||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
|
|
||||||
"bindbox-game/internal/service/user"
|
"bindbox-game/internal/service/user"
|
||||||
|
|
||||||
|
"gorm.io/gorm"
|
||||||
)
|
)
|
||||||
|
|
||||||
// StartDouyinOrderSync 启动抖店订单定时同步任务
|
// StartDouyinOrderSync 启动抖店订单定时同步任务
|
||||||
@ -157,6 +161,8 @@ func (s *service) GrantLivestreamPrizes(ctx context.Context) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
anchorCodes := s.resolveActivityAnchorCodes(ctx, logs)
|
||||||
|
|
||||||
for _, log := range logs {
|
for _, log := range logs {
|
||||||
// 必须要有对应的本地用户ID
|
// 必须要有对应的本地用户ID
|
||||||
if log.LocalUserID == 0 {
|
if log.LocalUserID == 0 {
|
||||||
@ -174,11 +180,36 @@ func (s *service) GrantLivestreamPrizes(ctx context.Context) error {
|
|||||||
continue // 还没关联到用户,跳过
|
continue // 还没关联到用户,跳过
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if code := anchorCodes[log.ActivityID]; code != "" {
|
||||||
|
s.bindAnchorInviterIfNeeded(ctx, log.LocalUserID, code)
|
||||||
|
}
|
||||||
|
|
||||||
// 2. 查奖品关联的 ProductID
|
// 2. 查奖品关联的 ProductID
|
||||||
var prize model.LivestreamPrizes
|
var prize model.LivestreamPrizes
|
||||||
if err := s.repo.GetDbR().Where("id = ?", log.PrizeID).First(&prize).Error; err != nil {
|
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) {
|
||||||
continue
|
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 {
|
if prize.ProductID == 0 {
|
||||||
@ -204,11 +235,13 @@ func (s *service) GrantLivestreamPrizes(ctx context.Context) error {
|
|||||||
res, err := s.userSvc.GrantReward(ctx, log.LocalUserID, req)
|
res, err := s.userSvc.GrantReward(ctx, log.LocalUserID, req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.logger.Error("[自动发放] 发放失败", zap.Error(err))
|
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 {
|
} else {
|
||||||
// 4. 更新发放状态
|
// 4. 更新发放状态
|
||||||
db.Model(&log).Update("is_granted", 1)
|
db.Model(&log).Update("is_granted", 1)
|
||||||
s.logger.Info("[自动发放] 发放成功", zap.Int64("log_id", log.ID))
|
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. 自动虚拟发货 (本地状态更新)
|
// 5. 自动虚拟发货 (本地状态更新)
|
||||||
// 直播间奖品通常为虚拟发货,直接标记为已消费/已发货
|
// 直播间奖品通常为虚拟发货,直接标记为已消费/已发货
|
||||||
@ -319,6 +352,15 @@ func (s *service) reclaimLivestreamAssets(ctx context.Context, log *model.Livest
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 2. 回收资产
|
// 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 {
|
for _, inv := range inventories {
|
||||||
if inv.Status == 1 {
|
if inv.Status == 1 {
|
||||||
// 状态1(持有):作废
|
// 状态1(持有):作废
|
||||||
@ -332,25 +374,28 @@ func (s *service) reclaimLivestreamAssets(ctx context.Context, log *model.Livest
|
|||||||
)
|
)
|
||||||
} else if inv.Status == 3 {
|
} else if inv.Status == 3 {
|
||||||
// 状态3(已兑换/发货):扣除积分
|
// 状态3(已兑换/发货):扣除积分
|
||||||
// 查找商品价格作为积分扣除依据
|
pointsToDeduct := inv.ValueCents * rate
|
||||||
var product model.Products
|
if pointsToDeduct <= 0 {
|
||||||
if err := s.repo.GetDbR().Where("id = ?", inv.ProductID).First(&product).Error; err == nil {
|
// 兼容历史数据,兜底回退商品价格
|
||||||
pointsToDeduct := product.Price / 100 // 分转换为积分(假设 1积分=1分钱)
|
var product model.Products
|
||||||
if pointsToDeduct > 0 {
|
if err := s.repo.GetDbR().Where("id = ?", inv.ProductID).First(&product).Error; err == nil {
|
||||||
_, consumed, err := s.userSvc.ConsumePointsForRefund(ctx, inv.UserID, pointsToDeduct, "user_inventory", fmt.Sprintf("%d", inv.ID), "直播退款回收已兑换资产")
|
pointsToDeduct = product.Price * rate
|
||||||
if err != nil {
|
}
|
||||||
s.logger.Error("[资产回收] 扣除积分失败", zap.Error(err), zap.Int64("user_id", inv.UserID))
|
}
|
||||||
}
|
if pointsToDeduct > 0 {
|
||||||
if consumed < pointsToDeduct {
|
_, consumed, err := s.userSvc.ConsumePointsForRefund(ctx, inv.UserID, pointsToDeduct, "user_inventory", fmt.Sprintf("%d", inv.ID), "直播退款回收已兑换资产")
|
||||||
// 积分不足,标记用户
|
if err != nil {
|
||||||
s.logger.Warn("[资产回收] 用户积分不足",
|
s.logger.Error("[资产回收] 扣除积分失败", zap.Error(err), zap.Int64("user_id", inv.UserID))
|
||||||
zap.Int64("user_id", inv.UserID),
|
}
|
||||||
zap.Int64("needed", pointsToDeduct),
|
if consumed < pointsToDeduct {
|
||||||
zap.Int64("consumed", consumed),
|
// 积分不足,标记用户
|
||||||
)
|
s.logger.Warn("[资产回收] 用户积分不足",
|
||||||
// 可选:加入黑名单
|
zap.Int64("user_id", inv.UserID),
|
||||||
// db.Exec("UPDATE users SET status = 3 WHERE id = ?", inv.UserID)
|
zap.Int64("needed", pointsToDeduct),
|
||||||
}
|
zap.Int64("consumed", consumed),
|
||||||
|
)
|
||||||
|
// 可选:加入黑名单
|
||||||
|
// db.Exec("UPDATE users SET status = 3 WHERE id = ?", inv.UserID)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// 作废记录
|
// 作废记录
|
||||||
@ -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)
|
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))
|
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
|
Name string
|
||||||
StreamerName string
|
StreamerName string
|
||||||
StreamerContact string
|
StreamerContact string
|
||||||
|
ChannelID int64
|
||||||
|
ChannelCode string
|
||||||
DouyinProductID string
|
DouyinProductID string
|
||||||
OrderRewardType string
|
OrderRewardType string
|
||||||
OrderRewardQuantity int32
|
OrderRewardQuantity int32
|
||||||
@ -94,6 +96,8 @@ type UpdateActivityInput struct {
|
|||||||
Name string
|
Name string
|
||||||
StreamerName string
|
StreamerName string
|
||||||
StreamerContact string
|
StreamerContact string
|
||||||
|
ChannelID *int64
|
||||||
|
ChannelCode *string
|
||||||
DouyinProductID string
|
DouyinProductID string
|
||||||
OrderRewardType string
|
OrderRewardType string
|
||||||
OrderRewardQuantity *int32
|
OrderRewardQuantity *int32
|
||||||
@ -169,6 +173,8 @@ func (s *service) CreateActivity(ctx context.Context, input CreateActivityInput)
|
|||||||
Name: input.Name,
|
Name: input.Name,
|
||||||
StreamerName: input.StreamerName,
|
StreamerName: input.StreamerName,
|
||||||
StreamerContact: input.StreamerContact,
|
StreamerContact: input.StreamerContact,
|
||||||
|
ChannelID: input.ChannelID,
|
||||||
|
ChannelCode: input.ChannelCode,
|
||||||
AccessCode: accessCode,
|
AccessCode: accessCode,
|
||||||
DouyinProductID: input.DouyinProductID,
|
DouyinProductID: input.DouyinProductID,
|
||||||
OrderRewardType: input.OrderRewardType,
|
OrderRewardType: input.OrderRewardType,
|
||||||
@ -205,6 +211,12 @@ func (s *service) UpdateActivity(ctx context.Context, id int64, input UpdateActi
|
|||||||
if input.StreamerContact != "" {
|
if input.StreamerContact != "" {
|
||||||
updates["streamer_contact"] = 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 != "" {
|
if input.DouyinProductID != "" {
|
||||||
updates["douyin_product_id"] = input.DouyinProductID
|
updates["douyin_product_id"] = input.DouyinProductID
|
||||||
}
|
}
|
||||||
|
|||||||
@ -33,8 +33,11 @@ func TestInviteLogicSymmetry(t *testing.T) {
|
|||||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
deleted_at DATETIME
|
deleted_at DATETIME
|
||||||
);`)
|
);`)
|
||||||
db.Exec(`CREATE TABLE activity_draw_logs (order_id INTEGER, issue_id INTEGER);`)
|
db.Exec(`CREATE TABLE activity_draw_logs (
|
||||||
db.Exec(`CREATE TABLE activity_issues (id INTEGER, activity_id INTEGER);`)
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
order_id INTEGER,
|
||||||
|
issue_id INTEGER
|
||||||
|
);`)
|
||||||
|
|
||||||
svc := New(nil, repo, nil, nil, nil)
|
svc := New(nil, repo, nil, nil, nil)
|
||||||
inviterID := int64(888)
|
inviterID := int64(888)
|
||||||
@ -45,6 +48,7 @@ func TestInviteLogicSymmetry(t *testing.T) {
|
|||||||
|
|
||||||
// 只有 101 在活动 77 中下过单并开奖
|
// 只有 101 在活动 77 中下过单并开奖
|
||||||
db.Exec("INSERT INTO activity_issues (id, activity_id) VALUES (1, 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 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)")
|
db.Exec("INSERT INTO activity_draw_logs (order_id, issue_id) VALUES (10, 1)")
|
||||||
|
|
||||||
|
|||||||
@ -12,7 +12,6 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
gamesvc "bindbox-game/internal/service/game"
|
gamesvc "bindbox-game/internal/service/game"
|
||||||
@ -34,7 +33,7 @@ type Service interface {
|
|||||||
ListTaskTiers(ctx context.Context, taskID int64) ([]TaskTierItem, error)
|
ListTaskTiers(ctx context.Context, taskID int64) ([]TaskTierItem, error)
|
||||||
UpsertTaskTiers(ctx context.Context, taskID int64, tiers []TaskTierInput) error
|
UpsertTaskTiers(ctx context.Context, taskID int64, tiers []TaskTierInput) error
|
||||||
ListTaskRewards(ctx context.Context, taskID int64) ([]TaskRewardItem, 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)
|
GetUserProgress(ctx context.Context, userID int64, taskID int64) (*UserProgress, error)
|
||||||
ClaimTier(ctx context.Context, userID int64, taskID int64, tierID int64) error
|
ClaimTier(ctx context.Context, userID int64, taskID int64, tierID int64) error
|
||||||
OnOrderPaid(ctx context.Context, userID int64, orderID int64) error
|
OnOrderPaid(ctx context.Context, userID int64, orderID int64) error
|
||||||
@ -164,6 +163,7 @@ type TaskTierItem struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type TaskRewardInput struct {
|
type TaskRewardInput struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
TierID int64 `json:"tier_id"`
|
TierID int64 `json:"tier_id"`
|
||||||
RewardType string `json:"reward_type"`
|
RewardType string `json:"reward_type"`
|
||||||
RewardPayload datatypes.JSON `json:"reward_payload"`
|
RewardPayload datatypes.JSON `json:"reward_payload"`
|
||||||
@ -179,6 +179,172 @@ type TaskRewardItem struct {
|
|||||||
RewardName string `json:"reward_name"`
|
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) {
|
func (s *service) ListTasks(ctx context.Context, in ListTasksInput) (items []TaskItem, total int64, err error) {
|
||||||
db := s.repo.GetDbR()
|
db := s.repo.GetDbR()
|
||||||
var rows []tcmodel.Task
|
var rows []tcmodel.Task
|
||||||
@ -295,7 +461,7 @@ func (s *service) ListTasks(ctx context.Context, in ListTasksInput) (items []Tas
|
|||||||
remaining = 0
|
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
|
// 填充 Rewards
|
||||||
out[i].Rewards = make([]TaskRewardItem, len(v.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) {
|
func (s *service) GetUserProgress(ctx context.Context, userID int64, taskID int64) (*UserProgress, error) {
|
||||||
db := s.repo.GetDbR()
|
db := s.repo.GetDbR()
|
||||||
|
|
||||||
// 加载任务信息(获取 StartTime/EndTime 用于 activity_period window)
|
|
||||||
var task tcmodel.Task
|
var task tcmodel.Task
|
||||||
if err := db.First(&task, taskID).Error; err != nil {
|
if err := db.First(&task, taskID).Error; err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3.0 获取任务下所有 Tier(含 Window、ActivityID、Metric 字段,用于时效分组查询)
|
|
||||||
var tiers []tcmodel.TaskTier
|
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)
|
targetActivityIDs := make([]int64, 0)
|
||||||
seenActivity := make(map[int64]struct{})
|
seenActivity := make(map[int64]struct{})
|
||||||
for _, t := range tiers {
|
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 {
|
type windowGroupKey struct {
|
||||||
Window string
|
Window string
|
||||||
ActivityID int64
|
ActivityID int64
|
||||||
}
|
}
|
||||||
groupMap := make(map[windowGroupKey][]tcmodel.TaskTier)
|
groupMap := make(map[windowGroupKey][]tcmodel.TaskTier)
|
||||||
for _, t := range tiers {
|
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)
|
groupMap[key] = append(groupMap[key], t)
|
||||||
}
|
}
|
||||||
|
|
||||||
tierProgressMap := make(map[int64]TierProgress)
|
tierProgressMap := make(map[int64]TierProgress)
|
||||||
|
|
||||||
for wk, groupTiers := range groupMap {
|
for wk, groupTiers := range groupMap {
|
||||||
wStart, wEnd := computeTimeWindow(wk.Window, task.StartTime, task.EndTime)
|
wStart, wEnd := computeTimeWindow(wk.Window, task.StartTime, task.EndTime)
|
||||||
|
var activityIDs []int64
|
||||||
// 构建动态时间条件片段
|
perActivity := false
|
||||||
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
|
|
||||||
|
|
||||||
if wk.ActivityID > 0 {
|
if wk.ActivityID > 0 {
|
||||||
// 有活动限制:通过 activity_draw_logs → activity_issues 关联,加时效过滤
|
activityIDs = []int64{wk.ActivityID}
|
||||||
baseArgs := append([]interface{}{userID, wk.ActivityID}, timeArgs...)
|
perActivity = true
|
||||||
db.Raw(`
|
}
|
||||||
SELECT COUNT(id)
|
rows, err := s.fetchOrderMetricRows(ctx, userID, activityIDs, wStart, wEnd)
|
||||||
FROM orders
|
if err != nil {
|
||||||
WHERE user_id = ? AND status = 2 AND source_type != 1
|
return nil, err
|
||||||
AND id IN (
|
}
|
||||||
SELECT DISTINCT dl.order_id
|
orderCount, orderAmount := s.aggregateOrderMetrics(rows, perActivity)
|
||||||
FROM activity_draw_logs dl
|
inviteCount, err := s.countInvites(ctx, userID, wk.ActivityID, wStart, wEnd)
|
||||||
INNER JOIN activity_issues ai ON ai.id = dl.issue_id
|
if err != nil {
|
||||||
WHERE ai.activity_id = ?
|
return nil, err
|
||||||
)`+timeCond, baseArgs...).Scan(&gOrderCount)
|
|
||||||
|
|
||||||
db.Raw(`
|
|
||||||
SELECT COALESCE(SUM(total_amount), 0)
|
|
||||||
FROM orders
|
|
||||||
WHERE user_id = ? AND status = 2 AND source_type != 1
|
|
||||||
AND id IN (
|
|
||||||
SELECT DISTINCT dl.order_id
|
|
||||||
FROM activity_draw_logs dl
|
|
||||||
INNER JOIN activity_issues ai ON ai.id = dl.issue_id
|
|
||||||
WHERE ai.activity_id = ?
|
|
||||||
)`+timeCond, baseArgs...).Scan(&gOrderAmount)
|
|
||||||
|
|
||||||
// 邀请计数:将 orders.created_at 改为 o.created_at(别名)
|
|
||||||
inviteTimeCond := strings.ReplaceAll(timeCond, "orders.created_at", "o.created_at")
|
|
||||||
inviteArgs := append([]interface{}{userID, wk.ActivityID}, timeArgs...)
|
|
||||||
db.Raw(`
|
|
||||||
SELECT COUNT(DISTINCT ui.invitee_id)
|
|
||||||
FROM user_invites ui
|
|
||||||
INNER JOIN orders o ON o.user_id = ui.invitee_id AND o.status = 2 AND o.source_type != 1
|
|
||||||
WHERE ui.inviter_id = ?
|
|
||||||
AND o.id IN (
|
|
||||||
SELECT DISTINCT dl.order_id
|
|
||||||
FROM activity_draw_logs dl
|
|
||||||
INNER JOIN activity_issues ai ON ai.id = dl.issue_id
|
|
||||||
WHERE ai.activity_id = ?
|
|
||||||
)`+inviteTimeCond, inviteArgs...).Scan(&gInviteCount)
|
|
||||||
} else {
|
|
||||||
// 无活动限制:统计所有已开奖的非商城订单,追加时效过滤
|
|
||||||
globalCond := "user_id = ? AND status = 2 AND source_type != 1 AND EXISTS (SELECT 1 FROM activity_draw_logs WHERE activity_draw_logs.order_id = orders.id)" + timeCond
|
|
||||||
globalArgs := append([]interface{}{userID}, timeArgs...)
|
|
||||||
db.Model(&model.Orders{}).Where(globalCond, globalArgs...).Count(&gOrderCount)
|
|
||||||
db.Model(&model.Orders{}).Select("COALESCE(SUM(total_amount), 0)").Where(globalCond, globalArgs...).Scan(&gOrderAmount)
|
|
||||||
|
|
||||||
inviteWhere := "inviter_id = ?"
|
|
||||||
if wStart != nil {
|
|
||||||
inviteWhere += " AND created_at >= ?"
|
|
||||||
}
|
|
||||||
if wEnd != nil {
|
|
||||||
inviteWhere += " AND created_at <= ?"
|
|
||||||
}
|
|
||||||
db.Model(&model.UserInvites{}).Where(inviteWhere, globalArgs...).Count(&gInviteCount)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, t := range groupTiers {
|
for _, tier := range groupTiers {
|
||||||
tierProgressMap[t.ID] = TierProgress{
|
tierProgressMap[tier.ID] = TierProgress{
|
||||||
TierID: t.ID,
|
TierID: tier.ID,
|
||||||
OrderCount: gOrderCount,
|
OrderCount: orderCount,
|
||||||
OrderAmount: gOrderAmount,
|
OrderAmount: orderAmount,
|
||||||
InviteCount: gInviteCount,
|
InviteCount: inviteCount,
|
||||||
FirstOrder: gOrderCount > 0,
|
FirstOrder: orderCount > 0,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── 向后兼容:全局统计(不限时间窗口,用于顶层字段 OrderCount/InviteCount 和 SubProgress)──
|
var (
|
||||||
var orderCount int64
|
allRows []orderMetricRow
|
||||||
var orderAmount int64
|
err error
|
||||||
|
)
|
||||||
|
if len(targetActivityIDs) > 0 {
|
||||||
|
allRows, err = s.fetchOrderMetricRows(ctx, userID, targetActivityIDs, nil, nil)
|
||||||
|
} else {
|
||||||
|
allRows, err = s.fetchOrderMetricRows(ctx, userID, nil, nil, nil)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
orderCount, orderAmount := s.aggregateOrderMetrics(allRows, false)
|
||||||
|
|
||||||
var subProgressList []ActivityProgress
|
var subProgressList []ActivityProgress
|
||||||
|
|
||||||
if len(targetActivityIDs) > 0 {
|
if len(targetActivityIDs) > 0 {
|
||||||
db.Raw(`
|
subStats := make(map[int64]ActivityProgress)
|
||||||
SELECT COUNT(id)
|
for _, row := range allRows {
|
||||||
FROM orders
|
if row.ActivityID == 0 {
|
||||||
WHERE user_id = ? AND status = 2 AND source_type != 1
|
continue
|
||||||
AND id IN (
|
}
|
||||||
SELECT DISTINCT dl.order_id
|
stat := subStats[row.ActivityID]
|
||||||
FROM activity_draw_logs dl
|
stat.ActivityID = row.ActivityID
|
||||||
INNER JOIN activity_issues ai ON ai.id = dl.issue_id
|
stat.OrderCount++
|
||||||
WHERE ai.activity_id IN (?)
|
stat.OrderAmount += s.calculateEffectiveAmount(row)
|
||||||
)
|
subStats[row.ActivityID] = stat
|
||||||
`, userID, targetActivityIDs).Scan(&orderCount)
|
}
|
||||||
|
subProgressList = make([]ActivityProgress, 0, len(targetActivityIDs))
|
||||||
db.Raw(`
|
for _, actID := range targetActivityIDs {
|
||||||
SELECT COALESCE(SUM(total_amount), 0)
|
if stat, ok := subStats[actID]; ok {
|
||||||
FROM orders
|
subProgressList = append(subProgressList, stat)
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. 实时统计邀请数据(全局,向后兼容)
|
inviteCount, err := s.countInvitesForActivities(ctx, userID, targetActivityIDs)
|
||||||
var inviteCount int64
|
if err != nil {
|
||||||
if len(targetActivityIDs) > 0 {
|
return nil, 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
|
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. 首单判断
|
var progressRows []tcmodel.UserTaskProgress
|
||||||
hasFirstOrder := orderCount > 0
|
if err := db.Where("user_id=? AND task_id=?", userID, taskID).Find(&progressRows).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
// 4. 从进度表读取已领取的档位(这部分仍需保留)
|
}
|
||||||
var rows []tcmodel.UserTaskProgress
|
|
||||||
db.Where("user_id=? AND task_id=?", userID, taskID).Find(&rows)
|
|
||||||
|
|
||||||
claimedSet := map[int64]struct{}{}
|
claimedSet := map[int64]struct{}{}
|
||||||
for _, row := range rows {
|
for _, row := range progressRows {
|
||||||
var claimed []int64
|
var claimed []int64
|
||||||
if len(row.ClaimedTiers) > 0 {
|
if len(row.ClaimedTiers) > 0 {
|
||||||
_ = json.Unmarshal([]byte(row.ClaimedTiers), &claimed)
|
_ = 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)
|
allClaimed = append(allClaimed, id)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
hasFirstOrder := orderCount > 0
|
||||||
|
|
||||||
return &UserProgress{
|
return &UserProgress{
|
||||||
TaskID: taskID,
|
TaskID: taskID,
|
||||||
UserID: userID,
|
UserID: userID,
|
||||||
@ -602,7 +663,7 @@ func (s *service) GetUserProgress(ctx context.Context, userID int64, taskID int6
|
|||||||
FirstOrder: hasFirstOrder,
|
FirstOrder: hasFirstOrder,
|
||||||
ClaimedTiers: allClaimed,
|
ClaimedTiers: allClaimed,
|
||||||
SubProgress: subProgressList,
|
SubProgress: subProgressList,
|
||||||
TierProgressMap: tierProgressMap, // Bug1 修复:每个 Tier 的窗口化独立进度
|
TierProgressMap: tierProgressMap,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -899,7 +960,7 @@ func (s *service) ListTaskTiers(ctx context.Context, taskID int64) ([]TaskTierIt
|
|||||||
remaining = 0
|
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
|
return out, nil
|
||||||
}
|
}
|
||||||
@ -914,8 +975,9 @@ func (s *service) UpsertTaskTiers(ctx context.Context, taskID int64, tiers []Tas
|
|||||||
|
|
||||||
existingMap := make(map[string]tcmodel.TaskTier)
|
existingMap := make(map[string]tcmodel.TaskTier)
|
||||||
for _, t := range existing {
|
for _, t := range existing {
|
||||||
// 使用指标+阈值+活动作为业务指纹
|
window := normalizeWindow(t.Window)
|
||||||
key := fmt.Sprintf("%s-%d-%d", t.Metric, t.Threshold, t.ActivityID)
|
t.Window = window
|
||||||
|
key := tierFingerprint(t.Metric, t.Threshold, t.ActivityID, window)
|
||||||
existingMap[key] = t
|
existingMap[key] = t
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -925,11 +987,15 @@ func (s *service) UpsertTaskTiers(ctx context.Context, taskID int64, tiers []Tas
|
|||||||
|
|
||||||
processedKeys := make(map[string]struct{})
|
processedKeys := make(map[string]struct{})
|
||||||
for _, t := range tiers {
|
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 {
|
if old, ok := existingMap[key]; ok {
|
||||||
// 更新现有记录,保留 ID 和 ClaimedCount
|
// 更新现有记录,保留 ID 和 ClaimedCount
|
||||||
old.Operator = t.Operator
|
old.Operator = t.Operator
|
||||||
old.Window = t.Window
|
old.Window = window
|
||||||
old.Repeatable = t.Repeatable
|
old.Repeatable = t.Repeatable
|
||||||
old.Priority = t.Priority
|
old.Priority = t.Priority
|
||||||
old.ExtraParams = t.ExtraParams
|
old.ExtraParams = t.ExtraParams
|
||||||
@ -942,7 +1008,7 @@ func (s *service) UpsertTaskTiers(ctx context.Context, taskID int64, tiers []Tas
|
|||||||
Metric: t.Metric,
|
Metric: t.Metric,
|
||||||
Operator: t.Operator,
|
Operator: t.Operator,
|
||||||
Threshold: t.Threshold,
|
Threshold: t.Threshold,
|
||||||
Window: t.Window,
|
Window: window,
|
||||||
Repeatable: t.Repeatable,
|
Repeatable: t.Repeatable,
|
||||||
Priority: t.Priority,
|
Priority: t.Priority,
|
||||||
ActivityID: t.ActivityID,
|
ActivityID: t.ActivityID,
|
||||||
@ -990,47 +1056,59 @@ func (s *service) ListTaskRewards(ctx context.Context, taskID int64) ([]TaskRewa
|
|||||||
return out, nil
|
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()
|
db := s.repo.GetDbW()
|
||||||
// 同理优化 ID 稳定性
|
|
||||||
var existing []tcmodel.TaskReward
|
var existing []tcmodel.TaskReward
|
||||||
if err := db.Where("task_id=?", taskID).Find(&existing).Error; err != nil {
|
if err := db.Where("task_id=?", taskID).Find(&existing).Error; err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
existingMap := make(map[string]tcmodel.TaskReward)
|
existingByID := make(map[int64]tcmodel.TaskReward, len(existing))
|
||||||
for _, r := range existing {
|
for _, r := range existing {
|
||||||
// 奖励类型+档位 ID 作为指纹
|
existingByID[r.ID] = r
|
||||||
key := fmt.Sprintf("%d-%s", r.TierID, r.RewardType)
|
|
||||||
existingMap[key] = r
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var toDelete []int64
|
|
||||||
var toUpdate []tcmodel.TaskReward
|
var toUpdate []tcmodel.TaskReward
|
||||||
var toCreate []tcmodel.TaskReward
|
var toCreate []tcmodel.TaskReward
|
||||||
|
seen := make(map[int64]struct{})
|
||||||
|
|
||||||
processedKeys := make(map[string]struct{})
|
|
||||||
for _, r := range rewards {
|
for _, r := range rewards {
|
||||||
key := fmt.Sprintf("%d-%s", r.TierID, r.RewardType)
|
if r.ID > 0 {
|
||||||
if old, ok := existingMap[key]; ok {
|
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.RewardPayload = r.RewardPayload
|
||||||
old.Quantity = r.Quantity
|
old.Quantity = r.Quantity
|
||||||
toUpdate = append(toUpdate, old)
|
toUpdate = append(toUpdate, old)
|
||||||
processedKeys[key] = struct{}{}
|
seen[r.ID] = struct{}{}
|
||||||
} else {
|
continue
|
||||||
toCreate = append(toCreate, tcmodel.TaskReward{
|
|
||||||
TaskID: taskID,
|
|
||||||
TierID: r.TierID,
|
|
||||||
RewardType: r.RewardType,
|
|
||||||
RewardPayload: r.RewardPayload,
|
|
||||||
Quantity: r.Quantity,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
toCreate = append(toCreate, tcmodel.TaskReward{
|
||||||
|
TaskID: taskID,
|
||||||
|
TierID: r.TierID,
|
||||||
|
RewardType: r.RewardType,
|
||||||
|
RewardPayload: r.RewardPayload,
|
||||||
|
Quantity: r.Quantity,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
for key, old := range existingMap {
|
var toDelete []int64
|
||||||
if _, ok := processedKeys[key]; !ok {
|
if len(deleteIDs) > 0 {
|
||||||
toDelete = append(toDelete, old.ID)
|
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 (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/json"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"bindbox-game/internal/repository/mysql"
|
"bindbox-game/internal/repository/mysql"
|
||||||
tcmodel "bindbox-game/internal/repository/mysql/task_center"
|
tcmodel "bindbox-game/internal/repository/mysql/task_center"
|
||||||
|
|
||||||
|
"gorm.io/datatypes"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -37,6 +39,22 @@ func ensureExtraTablesForServiceTest(t *testing.T, db *gorm.DB) {
|
|||||||
t.Fatalf("创建 activity_draw_logs 表失败: %v", err)
|
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 !db.Migrator().HasTable("user_invites") {
|
||||||
if err := db.Exec(`CREATE TABLE user_invites (
|
if err := db.Exec(`CREATE TABLE user_invites (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
@ -81,6 +99,9 @@ func TestGetUserProgress_TimeWindow_Integration(t *testing.T) {
|
|||||||
t.Fatalf("创建任务失败: %v", err)
|
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}
|
windows := []string{WindowDaily, WindowWeekly, WindowMonthly, WindowActivityPeriod, WindowLifetime}
|
||||||
|
|
||||||
tierIDMap := make(map[string]int64)
|
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 {
|
);`).Error; err != nil {
|
||||||
t.Fatalf("创建 task_center_event_logs 表失败: %v", err)
|
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 插入一个完整的任务配置(任务+档位+奖励)
|
// InsertTaskWithTierAndReward 插入一个完整的任务配置(任务+档位+奖励)
|
||||||
|
|||||||
@ -500,9 +500,20 @@ func (s *service) RedeemInventoryToPoints(ctx context.Context, userID int64, inv
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, err
|
return 0, err
|
||||||
}
|
}
|
||||||
p, err := s.readDB.Products.WithContext(ctx).Where(s.readDB.Products.ID.Eq(inv.ProductID)).First()
|
valueCents := inv.ValueCents
|
||||||
if err != nil {
|
valueSource := inv.ValueSource
|
||||||
return 0, err
|
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()
|
cfg, _ := s.readDB.SystemConfigs.WithContext(ctx).Where(s.readDB.SystemConfigs.ConfigKey.Eq("points_exchange_per_cent")).First()
|
||||||
rate := int64(1)
|
rate := int64(1)
|
||||||
@ -513,7 +524,7 @@ func (s *service) RedeemInventoryToPoints(ctx context.Context, userID int64, inv
|
|||||||
rate = r
|
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 {
|
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
|
return 0, err
|
||||||
}
|
}
|
||||||
@ -569,39 +580,63 @@ func (s *service) RedeemInventoriesToPoints(ctx context.Context, userID int64, i
|
|||||||
return 0, fmt.Errorf("no_valid_inventory")
|
return 0, fmt.Errorf("no_valid_inventory")
|
||||||
}
|
}
|
||||||
|
|
||||||
// 构建inventory映射和收集productID
|
// 4. 按资产快照计算总积分,缺失快照时回退商品价格并回写
|
||||||
invMap := make(map[int64]*model.UserInventory, len(invList))
|
|
||||||
productIDs := make([]int64, 0, len(invList))
|
productIDs := make([]int64, 0, len(invList))
|
||||||
productIDSet := make(map[int64]struct{})
|
productIDSet := make(map[int64]struct{})
|
||||||
for _, inv := range invList {
|
for _, inv := range invList {
|
||||||
invMap[inv.ID] = inv
|
if inv.ValueCents <= 0 {
|
||||||
if _, ok := productIDSet[inv.ProductID]; !ok {
|
if _, ok := productIDSet[inv.ProductID]; !ok {
|
||||||
productIDSet[inv.ProductID] = struct{}{}
|
productIDSet[inv.ProductID] = struct{}{}
|
||||||
productIDs = append(productIDs, inv.ProductID)
|
productIDs = append(productIDs, inv.ProductID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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
|
||||||
|
}
|
||||||
|
for _, p := range products {
|
||||||
|
productPriceMap[p.ID] = p.Price
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4. 批量查询所有products(一次查询替代N次)
|
// 5. 计算总积分和准备批量更新
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
// 5. 计算总积分和准备批量更新数据
|
|
||||||
var totalPoints int64
|
var totalPoints int64
|
||||||
validIDs := make([]int64, 0, len(invList))
|
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 {
|
for _, inv := range invList {
|
||||||
p := productMap[inv.ProductID]
|
valueCents := inv.ValueCents
|
||||||
if p == nil {
|
valueSource := inv.ValueSource
|
||||||
|
valueSnapshotAt := inv.ValueSnapshotAt
|
||||||
|
if valueCents <= 0 {
|
||||||
|
price, ok := productPriceMap[inv.ProductID]
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
valueCents = price
|
||||||
|
valueSource = 2
|
||||||
|
valueSnapshotAt = time.Now()
|
||||||
|
valueFixes = append(valueFixes, valueFix{
|
||||||
|
ID: inv.ID,
|
||||||
|
ValueCents: valueCents,
|
||||||
|
ValueSource: valueSource,
|
||||||
|
ValueSnapAt: valueSnapshotAt,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if valueCents <= 0 {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
points := p.Price * rate
|
points := valueCents * rate
|
||||||
totalPoints += points
|
totalPoints += points
|
||||||
validIDs = append(validIDs, inv.ID)
|
validIDs = append(validIDs, inv.ID)
|
||||||
}
|
}
|
||||||
@ -639,6 +674,14 @@ func (s *service) RedeemInventoriesToPoints(ctx context.Context, userID int64, i
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 批量更新inventory状态(一次UPDATE替代N次)
|
// 批量更新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(
|
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",
|
"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,
|
validIDs, userID,
|
||||||
|
|||||||
@ -91,10 +91,12 @@ func (s *service) ListInventoryWithProduct(ctx context.Context, userID int64, pa
|
|||||||
p := products[r.ProductID]
|
p := products[r.ProductID]
|
||||||
name := ""
|
name := ""
|
||||||
images := ""
|
images := ""
|
||||||
var price int64
|
price := r.ValueCents
|
||||||
if p != nil {
|
if p != nil {
|
||||||
name = p.Name
|
name = p.Name
|
||||||
images = p.ImagesJSON
|
images = p.ImagesJSON
|
||||||
|
}
|
||||||
|
if price <= 0 && p != nil {
|
||||||
price = p.Price
|
price = p.Price
|
||||||
}
|
}
|
||||||
sh := shipMap[r.ID]
|
sh := shipMap[r.ID]
|
||||||
@ -177,10 +179,12 @@ func (s *service) ListInventoryWithProductActive(ctx context.Context, userID int
|
|||||||
p := products[r.ProductID]
|
p := products[r.ProductID]
|
||||||
name := ""
|
name := ""
|
||||||
images := ""
|
images := ""
|
||||||
var price int64
|
price := r.ValueCents
|
||||||
if p != nil {
|
if p != nil {
|
||||||
name = p.Name
|
name = p.Name
|
||||||
images = p.ImagesJSON
|
images = p.ImagesJSON
|
||||||
|
}
|
||||||
|
if price <= 0 && p != nil {
|
||||||
price = p.Price
|
price = p.Price
|
||||||
}
|
}
|
||||||
sh := shipMap[r.ID]
|
sh := shipMap[r.ID]
|
||||||
@ -214,10 +218,11 @@ func (s *service) ListInventoryAggregated(ctx context.Context, userID int64, pag
|
|||||||
|
|
||||||
// 1. 获取聚合后的商品ID列表 (GROUP BY product_id, status)
|
// 1. 获取聚合后的商品ID列表 (GROUP BY product_id, status)
|
||||||
var groupResults []struct {
|
var groupResults []struct {
|
||||||
ProductID int64 `gorm:"column:product_id"`
|
ProductID int64 `gorm:"column:product_id"`
|
||||||
Status int32 `gorm:"column:status"`
|
Status int32 `gorm:"column:status"`
|
||||||
Count int64 `gorm:"column:count"`
|
Count int64 `gorm:"column:count"`
|
||||||
UpdatedAt time.Time `gorm:"column:updated_at"`
|
ValueCents int64 `gorm:"column:value_cents"`
|
||||||
|
UpdatedAt time.Time `gorm:"column:updated_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
q := s.readDB.UserInventory.WithContext(ctx).ReadDB().
|
q := s.readDB.UserInventory.WithContext(ctx).ReadDB().
|
||||||
@ -225,6 +230,7 @@ func (s *service) ListInventoryAggregated(ctx context.Context, userID int64, pag
|
|||||||
s.readDB.UserInventory.ProductID,
|
s.readDB.UserInventory.ProductID,
|
||||||
s.readDB.UserInventory.Status,
|
s.readDB.UserInventory.Status,
|
||||||
s.readDB.UserInventory.ID.Count().As("count"),
|
s.readDB.UserInventory.ID.Count().As("count"),
|
||||||
|
s.readDB.UserInventory.ValueCents.Max().As("value_cents"),
|
||||||
s.readDB.UserInventory.UpdatedAt.Max().As("updated_at"),
|
s.readDB.UserInventory.UpdatedAt.Max().As("updated_at"),
|
||||||
).
|
).
|
||||||
Where(s.readDB.UserInventory.UserID.Eq(userID))
|
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()
|
p, _ := s.readDB.Products.WithContext(ctx).ReadDB().Where(s.readDB.Products.ID.Eq(g.ProductID)).First()
|
||||||
name := "未知商品"
|
name := "未知商品"
|
||||||
images := ""
|
images := ""
|
||||||
var price int64
|
price := g.ValueCents
|
||||||
if p != nil {
|
if p != nil {
|
||||||
name = p.Name
|
name = p.Name
|
||||||
images = p.ImagesJSON
|
images = p.ImagesJSON
|
||||||
|
}
|
||||||
|
if price <= 0 && p != nil {
|
||||||
price = p.Price
|
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 {
|
err := s.writeDB.Transaction(func(tx *dao.Query) error {
|
||||||
logger.Info("开始事务处理")
|
logger.Info("开始事务处理")
|
||||||
|
var rewardSetting *model.ActivityRewardSettings
|
||||||
|
var err error
|
||||||
|
|
||||||
// 1. 检查奖励配置库存(如果提供了reward_id)
|
// 1. 检查奖励配置库存(如果提供了reward_id)
|
||||||
if req.RewardID != nil {
|
if req.RewardID != nil {
|
||||||
logger.Info("检查奖励配置", zap.Int64("reward_id", *req.RewardID))
|
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),
|
tx.ActivityRewardSettings.ID.Eq(*req.RewardID),
|
||||||
).First()
|
).First()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@ -109,7 +111,7 @@ func (s *service) GrantReward(ctx context.Context, userID int64, req GrantReward
|
|||||||
}
|
}
|
||||||
|
|
||||||
logger.Info("创建订单", zap.Any("order", order))
|
logger.Info("创建订单", zap.Any("order", order))
|
||||||
err := tx.Orders.WithContext(ctx).Create(order)
|
err = tx.Orders.WithContext(ctx).Create(order)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Error("创建订单失败", zap.Error(err))
|
logger.Error("创建订单失败", zap.Error(err))
|
||||||
return fmt.Errorf("创建订单失败: %w", err)
|
return fmt.Errorf("创建订单失败: %w", err)
|
||||||
@ -163,7 +165,25 @@ func (s *service) GrantReward(ctx context.Context, userID int64, req GrantReward
|
|||||||
inventory := &model.UserInventory{
|
inventory := &model.UserInventory{
|
||||||
UserID: userID,
|
UserID: userID,
|
||||||
ProductID: req.ProductID,
|
ProductID: req.ProductID,
|
||||||
OrderID: orderID,
|
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 {
|
ActivityID: func() int64 {
|
||||||
if req.ActivityID != nil {
|
if req.ActivityID != nil {
|
||||||
return *req.ActivityID
|
return *req.ActivityID
|
||||||
@ -288,6 +308,7 @@ func (s *service) GrantRewardToOrder(ctx context.Context, userID int64, req Gran
|
|||||||
// 执行事务
|
// 执行事务
|
||||||
err := s.writeDB.Transaction(func(tx *dao.Query) error {
|
err := s.writeDB.Transaction(func(tx *dao.Query) error {
|
||||||
logger.Info("开始事务处理")
|
logger.Info("开始事务处理")
|
||||||
|
var rewardSetting *model.ActivityRewardSettings
|
||||||
|
|
||||||
// 1. 验证订单存在且属于该用户
|
// 1. 验证订单存在且属于该用户
|
||||||
order, err := tx.Orders.WithContext(ctx).Where(
|
order, err := tx.Orders.WithContext(ctx).Where(
|
||||||
@ -322,6 +343,13 @@ func (s *service) GrantRewardToOrder(ctx context.Context, userID int64, req Gran
|
|||||||
logger.Error("奖励库存不足或不存在")
|
logger.Error("奖励库存不足或不存在")
|
||||||
return fmt.Errorf("奖励库存不足或不存在")
|
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("奖励库存扣减成功(乐观锁)")
|
logger.Info("奖励库存扣减成功(乐观锁)")
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -355,7 +383,25 @@ func (s *service) GrantRewardToOrder(ctx context.Context, userID int64, req Gran
|
|||||||
inventory := &model.UserInventory{
|
inventory := &model.UserInventory{
|
||||||
UserID: userID,
|
UserID: userID,
|
||||||
ProductID: req.ProductID,
|
ProductID: req.ProductID,
|
||||||
OrderID: req.OrderID, // 关联到原抽奖订单
|
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 {
|
ActivityID: func() int64 {
|
||||||
if req.ActivityID != nil {
|
if req.ActivityID != nil {
|
||||||
return *req.ActivityID
|
return *req.ActivityID
|
||||||
|
|||||||
@ -3,6 +3,7 @@ package user
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
"bindbox-game/internal/repository/mysql/dao"
|
"bindbox-game/internal/repository/mysql/dao"
|
||||||
"bindbox-game/internal/repository/mysql/model"
|
"bindbox-game/internal/repository/mysql/model"
|
||||||
@ -10,10 +11,11 @@ import (
|
|||||||
|
|
||||||
// BatchRewardItem 批量发放的单个奖励项
|
// BatchRewardItem 批量发放的单个奖励项
|
||||||
type BatchRewardItem struct {
|
type BatchRewardItem struct {
|
||||||
ProductID int64
|
ProductID int64
|
||||||
RewardID *int64 // 可选,一番赏模式需要传入以扣减库存
|
RewardID *int64 // 用于资产归因/价值快照
|
||||||
ActivityID int64
|
DeductRewardStock bool // 是否按 RewardID 扣减奖池库存(仅一番赏)
|
||||||
Remark string
|
ActivityID int64
|
||||||
|
Remark string
|
||||||
}
|
}
|
||||||
|
|
||||||
// BatchGrantRewardsToOrder 批量发放奖励到订单(单事务处理)
|
// BatchGrantRewardsToOrder 批量发放奖励到订单(单事务处理)
|
||||||
@ -60,6 +62,26 @@ func (s *service) BatchGrantRewardsToOrder(ctx context.Context, userID int64, or
|
|||||||
for _, p := range products {
|
for _, p := range products {
|
||||||
productMap[p.ID] = p
|
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. 批量创建订单项和库存记录
|
// 3. 批量创建订单项和库存记录
|
||||||
var orderItems []*model.OrderItems
|
var orderItems []*model.OrderItems
|
||||||
@ -83,8 +105,32 @@ func (s *service) BatchGrantRewardsToOrder(ctx context.Context, userID int64, or
|
|||||||
})
|
})
|
||||||
|
|
||||||
inventories = append(inventories, &model.UserInventory{
|
inventories = append(inventories, &model.UserInventory{
|
||||||
UserID: userID,
|
UserID: userID,
|
||||||
ProductID: item.ProductID,
|
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,
|
OrderID: orderID,
|
||||||
ActivityID: item.ActivityID,
|
ActivityID: item.ActivityID,
|
||||||
RewardID: func() int64 {
|
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)
|
rewardDeductMap := make(map[int64]int64)
|
||||||
for _, item := range items {
|
for _, item := range items {
|
||||||
if item.RewardID != nil {
|
if item.DeductRewardStock && item.RewardID != nil && *item.RewardID > 0 {
|
||||||
rewardDeductMap[*item.RewardID]++
|
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