diff --git a/.gitignore b/.gitignore index ff2f55a..52ae484 100644 --- a/.gitignore +++ b/.gitignore @@ -27,3 +27,12 @@ go.work.sum resources/* build/resources/admin/ logs/ + +# 敏感配置文件 +configs/*.toml +!configs/*.example.toml + +# 环境变量 +.env +.env.* +!.env.example diff --git a/bindbox_game.db b/bindbox_game.db new file mode 100644 index 0000000..e69de29 diff --git a/cmd/9090_audit/main.go b/cmd/9090_audit/main.go new file mode 100644 index 0000000..9e0b961 --- /dev/null +++ b/cmd/9090_audit/main.go @@ -0,0 +1,47 @@ +package main + +import ( + "fmt" + "log" + + "gorm.io/driver/mysql" + "gorm.io/gorm" +) + +const ( + DbDSN = "root:bindbox2025kdy@tcp(150.158.78.154:3306)/dev_game?charset=utf8mb4&parseTime=True&loc=Local" +) + +func main() { + db, err := gorm.Open(mysql.Open(DbDSN), &gorm.Config{}) + if err != nil { + log.Fatalf("DB connection failed: %v", err) + } + + fmt.Println("--- User 9090 Deep Audit (Cloud DB) ---") + + var userCoupons []struct { + ID int64 + CouponID int64 + BalanceAmount int64 + Status int32 + ValidEnd string + } + db.Raw("SELECT id, coupon_id, balance_amount, status, valid_end FROM user_coupons WHERE user_id = 9090").Scan(&userCoupons) + + for _, uc := range userCoupons { + fmt.Printf("\n[Coupon %d] Status: %v, Balance: %v, ValidEnd: %v\n", uc.ID, uc.Status, uc.BalanceAmount, uc.ValidEnd) + + // Trace Ledger + var ledger []struct { + ChangeAmount int64 + OrderID int64 + Action string + CreatedAt string + } + db.Raw("SELECT change_amount, order_id, action, created_at FROM user_coupon_ledger WHERE user_coupon_id = ?", uc.ID).Scan(&ledger) + for _, l := range ledger { + fmt.Printf(" -> Action: %s, Change: %d, Order: %d, Created: %s\n", l.Action, l.ChangeAmount, l.OrderID, l.CreatedAt) + } + } +} diff --git a/cmd/activity_repair/main.go b/cmd/activity_repair/main.go new file mode 100644 index 0000000..d2abcee --- /dev/null +++ b/cmd/activity_repair/main.go @@ -0,0 +1,85 @@ +package main + +import ( + "fmt" + "log" + + "gorm.io/driver/mysql" + "gorm.io/gorm" +) + +const ( + DbDSN = "root:bindbox2025kdy@tcp(150.158.78.154:3306)/dev_game?charset=utf8mb4&parseTime=True&loc=Local" +) + +func main() { + db, err := gorm.Open(mysql.Open(DbDSN), &gorm.Config{}) + if err != nil { + log.Fatalf("DB connection failed: %v", err) + } + + fmt.Println("--- Activity-Based Coupon Data Repair Auditor (Cloud DB) ---") + + var records []struct { + ID int64 + UserID int64 + CouponID int64 + BalanceAmount int64 + Status int32 + ValidEnd string + OriginalValue int64 + DiscountType int32 + } + + // Fetch all coupons joined with system data + db.Raw(` + SELECT uc.id, uc.user_id, uc.coupon_id, uc.balance_amount, uc.status, uc.valid_end, sc.discount_value as original_value, sc.discount_type + FROM user_coupons uc + JOIN system_coupons sc ON uc.coupon_id = sc.id + `).Scan(&records) + + for _, r := range records { + // Calculate total usage from order_coupons + var orderUsage int64 + db.Raw("SELECT SUM(applied_amount) FROM order_coupons WHERE user_coupon_id = ?", r.ID).Scan(&orderUsage) + + // Calculate total usage from ledger + var ledgerUsage int64 + db.Raw("SELECT ABS(SUM(change_amount)) FROM user_coupon_ledger WHERE user_coupon_id = ? AND action = 'apply'", r.ID).Scan(&ledgerUsage) + + // Max usage between sources + finalUsage := orderUsage + if ledgerUsage > finalUsage { + finalUsage = ledgerUsage + } + + expectedBalance := r.OriginalValue - finalUsage + if expectedBalance < 0 { + expectedBalance = 0 + } + + // Determine Correct Status + // 1: Unused (or Partially Used but still valid) + // 2: Used (Exhausted) + // 3: Expired (Unused/Partially Used and time past) + + expectedStatus := r.Status + if expectedBalance == 0 { + expectedStatus = 2 + } else { + // Logic for expired vs unused would go here if needed, + // but we prioritize "Used" if balance is 0. + // Currently if balance > 0 and Status is 2, it's definitely an error. + if r.Status == 2 { + expectedStatus = 1 // Revert to unused/partial if balance > 0 + } + } + + if expectedBalance != r.BalanceAmount || expectedStatus != r.Status { + fmt.Printf("-- Coupon %d (User %d): Bal %d->%d, Status %v->%v, Usage %d/%d\n", + r.ID, r.UserID, r.BalanceAmount, expectedBalance, r.Status, expectedStatus, finalUsage, r.OriginalValue) + fmt.Printf("UPDATE user_coupons SET balance_amount = %d, status = %v, updated_at = NOW() WHERE id = %d;\n", + expectedBalance, expectedStatus, r.ID) + } + } +} diff --git a/cmd/apply_migration/main.go b/cmd/apply_migration/main.go new file mode 100644 index 0000000..e693050 --- /dev/null +++ b/cmd/apply_migration/main.go @@ -0,0 +1,28 @@ +package main + +import ( + "fmt" + "log" + + "gorm.io/driver/mysql" + "gorm.io/gorm" +) + +const ( + DbDSN = "root:bindbox2025kdy@tcp(150.158.78.154:3306)/dev_game?charset=utf8mb4&parseTime=True&loc=Local" +) + +func main() { + db, err := gorm.Open(mysql.Open(DbDSN), &gorm.Config{}) + if err != nil { + log.Fatalf("DB connection failed: %v", err) + } + + fmt.Println("Applying migration to cloud DB...") + err = db.Exec("ALTER TABLE order_coupons ADD UNIQUE INDEX idx_order_user_coupon (order_id, user_coupon_id);").Error + if err != nil { + fmt.Printf("Migration failed: %v\n", err) + } else { + fmt.Println("Migration successful: Added unique index to order_coupons.") + } +} diff --git a/cmd/audit_cloud_db/main.go b/cmd/audit_cloud_db/main.go new file mode 100644 index 0000000..8343733 --- /dev/null +++ b/cmd/audit_cloud_db/main.go @@ -0,0 +1,43 @@ +package main + +import ( + "fmt" + "log" + + "gorm.io/driver/mysql" + "gorm.io/gorm" +) + +const ( + DbDSN = "root:bindbox2025kdy@tcp(150.158.78.154:3306)/dev_game?charset=utf8mb4&parseTime=True&loc=Local" +) + +func main() { + db, err := gorm.Open(mysql.Open(DbDSN), &gorm.Config{}) + if err != nil { + log.Fatalf("DB connection failed: %v", err) + } + + fmt.Println("--- Inconsistency Audit (Cloud DB) ---") + + fmt.Println("\n1. Coupons with Balance = 0 but Status != 2 (Used):") + var res1 []map[string]interface{} + db.Raw("SELECT id, user_id, coupon_id, status, balance_amount, valid_end FROM user_coupons WHERE balance_amount = 0 AND status != 2").Scan(&res1) + for _, res := range res1 { + fmt.Printf("ID: %v, User: %v, Status: %v, ValidEnd: %v\n", res["id"], res["user_id"], res["status"], res["valid_end"]) + } + + fmt.Println("\n2. Coupons in Status = 2 (Used) but Balance > 0:") + var res2 []map[string]interface{} + db.Raw("SELECT id, user_id, coupon_id, status, balance_amount, valid_end FROM user_coupons WHERE status = 2 AND balance_amount > 0").Scan(&res2) + for _, res := range res2 { + fmt.Printf("ID: %v, User: %v, Bal: %v, ValidEnd: %v\n", res["id"], res["user_id"], res["balance_amount"], res["valid_end"]) + } + + fmt.Println("\n3. Expired Time (valid_end < NOW) but Status = 1 (Unused):") + var res3 []map[string]interface{} + db.Raw("SELECT id, user_id, coupon_id, status, balance_amount, valid_end FROM user_coupons WHERE valid_end < NOW() AND status = 1").Scan(&res3) + for _, res := range res3 { + fmt.Printf("ID: %v, User: %v, Bal: %v, ValidEnd: %v\n", res["id"], res["user_id"], res["balance_amount"], res["valid_end"]) + } +} diff --git a/cmd/check_activity/main.go b/cmd/check_activity/main.go new file mode 100644 index 0000000..91ebe92 --- /dev/null +++ b/cmd/check_activity/main.go @@ -0,0 +1,35 @@ +package main + +import ( + "bindbox-game/configs" + "bindbox-game/internal/repository/mysql" + "bindbox-game/internal/repository/mysql/model" + "flag" + "fmt" +) + +func main() { + flag.Parse() + configs.Init() + dbRepo, err := mysql.New() + if err != nil { + panic(err) + } + db := dbRepo.GetDbR() + + var activity model.Activities + if err := db.First(&activity, 82).Error; err != nil { + fmt.Printf("Activity 82 NOT found in `activities` table.\n") + } else { + // Only print Name + fmt.Printf("Activity 82 Found in `activities`: Name=%s, ID=%d\n", activity.Name, activity.ID) + } + + var liveActivity model.LivestreamActivities + // Livestream activities might have ID 82 in their own table? + if err := db.First(&liveActivity, 82).Error; err != nil { + fmt.Printf("Livestream Activity 82 NOT found in `livestream_activities` table.\n") + } else { + fmt.Printf("Livestream Activity 82 Found in `livestream_activities`: Name=%s, ID=%d, DouyinProductID=%s\n", liveActivity.Name, liveActivity.ID, liveActivity.DouyinProductID) + } +} diff --git a/cmd/check_data_state/main.go b/cmd/check_data_state/main.go new file mode 100644 index 0000000..7f6d20f --- /dev/null +++ b/cmd/check_data_state/main.go @@ -0,0 +1,33 @@ +package main + +import ( + "fmt" + "log" + + "gorm.io/driver/mysql" + "gorm.io/gorm" +) + +const ( + DbDSN = "root:bindbox2025kdy@tcp(150.158.78.154:3306)/dev_game?charset=utf8mb4&parseTime=True&loc=Local" +) + +func main() { + db, err := gorm.Open(mysql.Open(DbDSN), &gorm.Config{}) + if err != nil { + log.Fatalf("DB connection failed: %v", err) + } + + fmt.Println("Checking User 9090 coupons status...") + var results []map[string]interface{} + db.Raw("SELECT id, coupon_id, status, balance_amount, valid_end FROM user_coupons WHERE user_id = 9090").Scan(&results) + for _, res := range results { + fmt.Printf("ID: %v, CouponID: %v, Status: %v, Balance: %v, ValidEnd: %v\n", + res["id"], res["coupon_id"], res["status"], res["balance_amount"], res["valid_end"]) + } + + fmt.Println("\nChecking for coupons that are status=3 but balance=0 and not yet time-expired...") + var count int64 + db.Raw("SELECT count(*) FROM user_coupons WHERE status = 3 AND balance_amount = 0 AND valid_end > NOW()").Scan(&count) + fmt.Printf("Found %d such coupons.\n", count) +} diff --git a/cmd/check_index/main.go b/cmd/check_index/main.go new file mode 100644 index 0000000..9d24ffd --- /dev/null +++ b/cmd/check_index/main.go @@ -0,0 +1,28 @@ +package main + +import ( + "fmt" + "log" + + "gorm.io/driver/mysql" + "gorm.io/gorm" +) + +const ( + DbDSN = "root:bindbox2025kdy@tcp(150.158.78.154:3306)/dev_game?charset=utf8mb4&parseTime=True&loc=Local" +) + +func main() { + db, err := gorm.Open(mysql.Open(DbDSN), &gorm.Config{}) + if err != nil { + log.Fatalf("DB connection failed: %v", err) + } + + fmt.Println("Checking indexes on cloud DB...") + var results []map[string]interface{} + db.Raw("SHOW INDEX FROM order_coupons;").Scan(&results) + for _, res := range results { + fmt.Printf("Table: %s, Key_name: %s, Column: %s, Unique: %v\n", + res["Table"], res["Key_name"], res["Column_name"], res["Non_unique"]) + } +} diff --git a/cmd/check_refunds/main.go b/cmd/check_refunds/main.go new file mode 100644 index 0000000..b897816 --- /dev/null +++ b/cmd/check_refunds/main.go @@ -0,0 +1,85 @@ +package main + +import ( + "bindbox-game/configs" + "bindbox-game/internal/repository/mysql" + "bindbox-game/internal/repository/mysql/model" + "flag" + "fmt" + "time" +) + +func main() { + flag.Parse() + + // Initialize config + configs.Init() + + // Initialize MySQL + dbRepo, err := mysql.New() + if err != nil { + panic(err) + } + + db := dbRepo.GetDbR() + + startTime, _ := time.ParseInLocation("2006-01-02", "2026-01-19", time.Local) + endTime := startTime.Add(24 * time.Hour) + + fmt.Printf("Checking for refunds between %v and %v\n", startTime, endTime) + + var orders []model.Orders + // Check by UpdateAt (refund implies an update) + // Or just check Status=4 and CreatedAt (if refunded same day) or check Status=4 generally if it impacts that day's stats. + // Usually "Daily Stats" for 19th means events that happened on the 19th. + // Since the user image shows "Refund: 0", it means no refunds *attributed* to that day. + // Attribution could be by Order Creation Date or Refund Date. + // Let's check ANY order with status 4. + + db.Where("status = ?", 4). + Where("updated_at >= ? AND updated_at < ?", startTime, endTime). + Find(&orders) + + fmt.Printf("Found %d orders marked as Refunded (status=4) updated on 2026-01-19:\n", len(orders)) + for _, o := range orders { + fmt.Printf("ID: %d, OrderNo: %s, Amount: %d, CreatedAt: %v, UpdatedAt: %v\n", o.ID, o.OrderNo, o.ActualAmount, o.CreatedAt, o.UpdatedAt) + } + + // Also check created_at on that day for any order that is NOW status 4 + var createdOrders []model.Orders + db.Where("status = ?", 4). + Where("created_at >= ? AND created_at < ?", startTime, endTime). + Find(&createdOrders) + + fmt.Printf("Found %d orders created on 2026-01-19 that are currently Refunded (status=4):\n", len(createdOrders)) + for _, o := range createdOrders { + fmt.Printf("ID: %d, OrderNo: %s, Amount: %d, SourceType: %d, Remark: %s, CreatedAt: %v\n", o.ID, o.OrderNo, o.ActualAmount, o.SourceType, o.Remark, o.CreatedAt) + } + + // Check Douyin Orders + var douyinOrders []model.DouyinOrders + // OrderStatus 4 might be refund in DouyinOrders context if the code is correct. + // Let's check for any Status=4 in DouyinOrders on the 19th. + // Since DouyinOrders struct in gen.go has Clean fields, we can use it. + // Note: created_at might be in UTC or local, check logic. + // But let's just query by range. + db.Where("order_status = ?", 4). + Where("updated_at >= ? AND updated_at < ?", startTime, endTime). + Find(&douyinOrders) + + fmt.Printf("Found %d refunded Douyin orders (status=4) updated on 2026-01-19:\n", len(douyinOrders)) + for _, o := range douyinOrders { + fmt.Printf("ID: %v, ShopOrderID: %s, PayAmount: %d, UpdatedAt: %v\n", o.ID, o.ShopOrderID, o.ActualPayAmount, o.UpdatedAt) + } + + // Also check created_at for Douyin Orders + var createdDouyinOrders []model.DouyinOrders + db.Where("order_status = ?", 4). + Where("created_at >= ? AND created_at < ?", startTime, endTime). + Find(&createdDouyinOrders) + + fmt.Printf("Found %d refunded Douyin orders (status=4) created on 2026-01-19:\n", len(createdDouyinOrders)) + for _, o := range createdDouyinOrders { + fmt.Printf("ID: %v, ShopOrderID: %s, PayAmount: %d, CreatedAt: %v, UpdatedAt: %v\n", o.ID, o.ShopOrderID, o.ActualPayAmount, o.CreatedAt, o.UpdatedAt) + } +} diff --git a/cmd/debug_9090_coupons/main.go b/cmd/debug_9090_coupons/main.go new file mode 100644 index 0000000..209928e --- /dev/null +++ b/cmd/debug_9090_coupons/main.go @@ -0,0 +1,38 @@ +package main + +import ( + "fmt" + "log" + + "gorm.io/driver/mysql" + "gorm.io/gorm" +) + +const ( + DbDSN = "root:bindbox2025kdy@tcp(150.158.78.154:3306)/dev_game?charset=utf8mb4&parseTime=True&loc=Local" +) + +func main() { + db, err := gorm.Open(mysql.Open(DbDSN), &gorm.Config{}) + if err != nil { + log.Fatalf("DB connection failed: %v", err) + } + + fmt.Println("--- Debugging User 9090 Coupons (Cloud DB) ---") + + var coupons []map[string]interface{} + db.Raw("SELECT id, coupon_id, balance_amount, status, valid_end, used_order_id, used_at FROM user_coupons WHERE user_id = 9090").Scan(&coupons) + + fmt.Printf("%-5s | %-10s | %-15s | %-8s | %-20s | %-15s\n", "ID", "CouponID", "Balance", "Status", "ValidEnd", "UsedOrder") + fmt.Println("------------------------------------------------------------------------------------------") + for _, c := range coupons { + fmt.Printf("%-5v | %-10v | %-15v | %-8v | %-20v | %-15v\n", c["id"], c["coupon_id"], c["balance_amount"], c["status"], c["valid_end"], c["used_order_id"]) + } + + fmt.Println("\n--- Checking Ledger for these coupons ---") + var ledger []map[string]interface{} + db.Raw("SELECT user_coupon_id, change_amount, balance_after, order_id, action, created_at FROM user_coupon_ledger WHERE user_id = 9090 ORDER BY created_at DESC").Scan(&ledger) + for _, l := range ledger { + fmt.Printf("CouponID: %v, Change: %v, After: %v, Order: %v, Action: %v, Time: %v\n", l["user_coupon_id"], l["change_amount"], l["balance_after"], l["order_id"], l["action"], l["created_at"]) + } +} diff --git a/cmd/debug_all_coupons/main.go b/cmd/debug_all_coupons/main.go new file mode 100644 index 0000000..75226a4 --- /dev/null +++ b/cmd/debug_all_coupons/main.go @@ -0,0 +1,32 @@ +package main + +import ( + "fmt" + "log" + + "gorm.io/driver/mysql" + "gorm.io/gorm" +) + +const ( + DbDSN = "root:bindbox2025kdy@tcp(150.158.78.154:3306)/dev_game?charset=utf8mb4&parseTime=True&loc=Local" +) + +func main() { + db, err := gorm.Open(mysql.Open(DbDSN), &gorm.Config{}) + if err != nil { + log.Fatalf("DB connection failed: %v", err) + } + + userID := 9090 + var userCoupons []map[string]interface{} + db.Table("user_coupons").Where("user_id = ?", userID).Order("id DESC").Find(&userCoupons) + + fmt.Printf("--- All Coupons for User %d ---\n", userID) + for _, uc := range userCoupons { + var sc map[string]interface{} + db.Table("system_coupons").Where("id = ?", uc["coupon_id"]).First(&sc) + fmt.Printf("ID: %v, Name: %v, Status: %v, ValidEnd: %v\n", + uc["id"], sc["name"], uc["status"], uc["valid_end"]) + } +} diff --git a/cmd/debug_balance/main.go b/cmd/debug_balance/main.go new file mode 100644 index 0000000..e4ffc16 --- /dev/null +++ b/cmd/debug_balance/main.go @@ -0,0 +1,44 @@ +package main + +import ( + "fmt" + "log" + + "gorm.io/driver/mysql" + "gorm.io/gorm" +) + +const ( + DbDSN = "root:bindbox2025kdy@tcp(150.158.78.154:3306)/dev_game?charset=utf8mb4&parseTime=True&loc=Local" +) + +type UserCoupons struct { + ID int64 `gorm:"column:id"` + BalanceAmount int64 `gorm:"column:balance_amount"` + CouponID int64 `gorm:"column:coupon_id"` +} + +type SystemCoupons struct { + ID int64 `gorm:"column:id"` + Name string `gorm:"column:name"` + DiscountValue int64 `gorm:"column:discount_value"` +} + +func main() { + db, err := gorm.Open(mysql.Open(DbDSN), &gorm.Config{}) + if err != nil { + log.Fatalf("DB connection failed: %v", err) + } + + userID := 9090 + var userCoupons []UserCoupons + db.Table("user_coupons").Where("user_id = ?", userID).Find(&userCoupons) + + fmt.Printf("--- Balance Check for User %d ---\n", userID) + for _, uc := range userCoupons { + var sc SystemCoupons + db.Table("system_coupons").First(&sc, uc.CouponID) + fmt.Printf("Coupon ID: %d, Name: %s, Original: %d, Balance: %d\n", + uc.ID, sc.Name, sc.DiscountValue, uc.BalanceAmount) + } +} diff --git a/cmd/debug_card/main.go b/cmd/debug_card/main.go new file mode 100644 index 0000000..dfe1de1 --- /dev/null +++ b/cmd/debug_card/main.go @@ -0,0 +1,50 @@ +package main + +import ( + "fmt" + "log" + + "gorm.io/driver/mysql" + "gorm.io/gorm" +) + +const ( + // Connection string from simulate_test/main.go + DbDSN = "root:bindbox2025kdy@tcp(150.158.78.154:3306)/dev_game?charset=utf8mb4&parseTime=True&loc=Local" +) + +type UserItemCards struct { + ID int64 `gorm:"column:id"` + CardID int64 `gorm:"column:card_id"` + Status int32 `gorm:"column:status"` + UsedDrawLogID int64 `gorm:"column:used_draw_log_id"` + UsedDiff int64 `gorm:"-"` // logic placeholder +} + +func (UserItemCards) TableName() string { return "user_item_cards" } + +func main() { + // 1. Connect DB + db, err := gorm.Open(mysql.Open(DbDSN), &gorm.Config{}) + if err != nil { + log.Fatalf("DB connection failed: %v", err) + } + + userCardID := 836 + + // 2. Query User Item Card + var userCard UserItemCards + err = db.Where("id = ?", userCardID).First(&userCard).Error + if err != nil { + log.Fatalf("User Card %d not found: %v", userCardID, err) + } + + fmt.Printf("UserCard %d Status: %d\n", userCard.ID, userCard.Status) + fmt.Printf("UsedDrawLogID: %d\n", userCard.UsedDrawLogID) + + if userCard.Status == 2 && userCard.UsedDrawLogID == 0 { + fmt.Println("WARNING: Card is USED (Status 2) but UsedDrawLogID is 0. Potential orphan data.") + } else if userCard.UsedDrawLogID > 0 { + fmt.Printf("Card correctly bound to DrawLog ID: %d\n", userCard.UsedDrawLogID) + } +} diff --git a/cmd/debug_coupon/main.go b/cmd/debug_coupon/main.go new file mode 100644 index 0000000..4a0ea35 --- /dev/null +++ b/cmd/debug_coupon/main.go @@ -0,0 +1,95 @@ +package main + +import ( + "fmt" + "log" + "time" + + "gorm.io/driver/mysql" + "gorm.io/gorm" +) + +const ( + DbDSN = "root:bindbox2025kdy@tcp(150.158.78.154:3306)/dev_game?charset=utf8mb4&parseTime=True&loc=Local" +) + +type UserCoupons struct { + ID int64 `gorm:"column:id"` + UserID int64 `gorm:"column:user_id"` + CouponID int64 `gorm:"column:coupon_id"` // System Coupon ID + Status int32 `gorm:"column:status"` // 1: Unused, 2: Used, 3: Expired + ValidStart time.Time `gorm:"column:valid_start"` + ValidEnd time.Time `gorm:"column:valid_end"` + UsedAt *time.Time `gorm:"column:used_at"` + CreatedAt time.Time `gorm:"column:created_at"` +} + +type SystemCoupons struct { + ID int64 `gorm:"column:id"` + Name string `gorm:"column:name"` + DiscountType int32 `gorm:"column:discount_type"` // 1: Direct, 2: Threshold, 3: Discount + DiscountValue int64 `gorm:"column:discount_value"` // Value in cents + MinOrderAmount int64 `gorm:"column:min_order_amount"` +} + +type Orders struct { + ID int64 `gorm:"column:id"` + OrderNo string `gorm:"column:order_no"` + ActualAmount int64 `gorm:"column:actual_amount"` + DiscountAmount int64 `gorm:"column:discount_amount"` + CouponID int64 `gorm:"column:coupon_id"` // Refers to system_coupons.id or user_coupons.id? usually user_coupons.id in many systems, need to check query. + CreatedAt time.Time +} + +func (UserCoupons) TableName() string { return "user_coupons" } +func (SystemCoupons) TableName() string { return "system_coupons" } +func (Orders) TableName() string { return "orders" } + +func main() { + db, err := gorm.Open(mysql.Open(DbDSN), &gorm.Config{}) + if err != nil { + log.Fatalf("DB connection failed: %v", err) + } + + userID := 9090 + + fmt.Printf("--- Querying Coupons for User %d ---\n", userID) + var userCoupons []UserCoupons + db.Where("user_id = ?", userID).Order("created_at DESC").Find(&userCoupons) + + for _, uc := range userCoupons { + var sc SystemCoupons + db.First(&sc, uc.CouponID) + + statusStr := "Unknown" + switch uc.Status { + case 1: + statusStr = "Unused" + case 2: + statusStr = "Used" + case 3: + statusStr = "Expired" + } + + fmt.Printf("\n[UserCoupon ID: %d]\n", uc.ID) + fmt.Printf(" Status: %d (%s)\n", uc.Status, statusStr) + fmt.Printf(" Name: %s\n", sc.Name) + fmt.Printf(" Type: %d, Value: %d (cents), Threshold: %d\n", sc.DiscountType, sc.DiscountValue, sc.MinOrderAmount) + fmt.Printf(" Valid: %v to %v\n", uc.ValidStart.Format("2006-01-02 15:04"), uc.ValidEnd.Format("2006-01-02 15:04")) + + if uc.Status == 2 { + // Find order used + var order Orders + // Note: orders table usually links to user_coupons ID via `coupon_id` column in this system (based on previous files). + // Let's verify if `orders.coupon_id` matches `user_coupons.id` or `system_coupons.id`. previous logs hinted `user_coupons.id`. + err := db.Where("coupon_id = ?", uc.ID).First(&order).Error + if err == nil { + fmt.Printf(" USED IN ORDER: %s (ID: %d)\n", order.OrderNo, order.ID) + fmt.Printf(" Order Total (Actual): %d cents\n", order.ActualAmount) + fmt.Printf(" Discount Applied: %d cents\n", order.DiscountAmount) + } else { + fmt.Printf(" WARNING: Status Used but Order not found linked to this UserCoupon ID.\n") + } + } + } +} diff --git a/cmd/debug_inventory/main.go b/cmd/debug_inventory/main.go new file mode 100644 index 0000000..049a7dd --- /dev/null +++ b/cmd/debug_inventory/main.go @@ -0,0 +1,48 @@ +package main + +import ( + "bindbox-game/configs" + "bindbox-game/internal/repository/mysql" + "bindbox-game/internal/repository/mysql/model" + "flag" + "fmt" +) + +func main() { + flag.Parse() + configs.Init() + dbRepo, err := mysql.New() + if err != nil { + panic(err) + } + db := dbRepo.GetDbR() + + userID := int64(9072) // + + // 1. Simple Count + var total int64 + db.Table(model.TableNameUserInventory). + Where("user_id = ?", userID). + Count(&total) + fmt.Printf("Total Inventory Count (All Status): %d\n", total) + + // 2. Count by Status 1 (Held) + var heldCount int64 + db.Table(model.TableNameUserInventory). + Where("user_id = ?", userID). + Where("status = ?", 1). + Count(&heldCount) + fmt.Printf("Held Inventory Count (Status=1): %d\n", heldCount) + + // 3. Count via Service Logic (ListInventoryWithProduct often filters by status=1) + // We simulate what the service might be doing. + // Check if there are products associated? + + // Let's list some items to see what they look like + var items []model.UserInventory + db.Where("user_id = ?", userID).Limit(50).Find(&items) + fmt.Printf("Found %d items details:\n", len(items)) + for _, item := range items { + fmt.Printf("ID: %d, ProductID: %d, OrderID: %d, Status: %d\n", item.ID, item.ProductID, item.OrderID, item.Status) + } +} diff --git a/cmd/debug_inventory_verify/main.go b/cmd/debug_inventory_verify/main.go new file mode 100644 index 0000000..5b533d4 --- /dev/null +++ b/cmd/debug_inventory_verify/main.go @@ -0,0 +1,72 @@ +package main + +import ( + "bindbox-game/configs" + "bindbox-game/internal/repository/mysql" + "bindbox-game/internal/repository/mysql/model" + "flag" + "fmt" + "strconv" +) + +func main() { + // flag.StringVar(&env, "env", "dev", "env") + flag.Parse() + configs.Init() + dbRepo, err := mysql.New() + if err != nil { + panic(err) + } + db := dbRepo.GetDbR() + + userID := int64(9072) + + // 1. Simulate ListInventoryWithProduct Query (No Keyword) + var totalNoKeyword int64 + db.Table(model.TableNameUserInventory). + Where("user_id = ?", userID). + Count(&totalNoKeyword) + fmt.Printf("Scenario 1 (No Keyword) Total: %d\n", totalNoKeyword) + + // 2. Simulate With Keyword (Empty string? space?) + // If the frontend sends " " (space), let's see. + keyword := " " + var totalWithSpace int64 + db.Table(model.TableNameUserInventory). + Joins("LEFT JOIN products p ON p.id = user_inventory.product_id"). + Where("user_inventory.user_id = ?", userID). + Where("p.name LIKE ?", "%"+keyword+"%"). + Count(&totalWithSpace) + fmt.Printf("Scenario 2 (Keyword ' ') Total: %d\n", totalWithSpace) + + // 3. Simulate specific Keyword '小米' + keyword2 := "小米" + var totalXiaomi int64 + db.Table(model.TableNameUserInventory). + Joins("LEFT JOIN products p ON p.id = user_inventory.product_id"). + Where("user_inventory.user_id = ?", userID). + Where("p.name LIKE ?", "%"+keyword2+"%"). + Count(&totalXiaomi) + fmt.Printf("Scenario 3 (Keyword '小米') Total: %d\n", totalXiaomi) + + // 4. Simulate Numeric Keyword "29072" (Searching by ID) + keyword3 := "29072" + numKeyword, _ := strconv.ParseInt(keyword3, 10, 64) + var totalNumeric int64 + db.Table(model.TableNameUserInventory). + Joins("LEFT JOIN products p ON p.id = user_inventory.product_id"). + Where("user_inventory.user_id = ?", userID). + Where( + db.Where("p.name LIKE ?", "%"+keyword3+"%"). + Or("user_inventory.id = ?", numKeyword). + Or("user_inventory.order_id = ?", numKeyword), + ). + Count(&totalNumeric) + fmt.Printf("Scenario 4 (Numeric Keyword '%s') Total: %d\n", keyword3, totalNumeric) + + // 5. Check if there are soft deletes? + // Check if `deleted_at` column exists + var cols []string + db.Raw("SHOW COLUMNS FROM user_inventory").Pluck("Field", &cols) + fmt.Printf("Columns: %v\n", cols) +} diff --git a/cmd/debug_ledger/main.go b/cmd/debug_ledger/main.go new file mode 100644 index 0000000..99e80ba --- /dev/null +++ b/cmd/debug_ledger/main.go @@ -0,0 +1,29 @@ +package main + +import ( + "fmt" + "log" + + "gorm.io/driver/mysql" + "gorm.io/gorm" +) + +const ( + DbDSN = "root:bindbox2025kdy@tcp(150.158.78.154:3306)/dev_game?charset=utf8mb4&parseTime=True&loc=Local" +) + +func main() { + db, err := gorm.Open(mysql.Open(DbDSN), &gorm.Config{}) + if err != nil { + log.Fatalf("DB connection failed: %v", err) + } + + userCouponID := 260 + fmt.Printf("--- Ledger for UserCoupon %d ---\n", userCouponID) + var results []map[string]interface{} + db.Table("user_coupon_ledger").Where("user_coupon_id = ?", userCouponID).Find(&results) + for _, r := range results { + fmt.Printf("Action: %v, Change: %v, BalanceAfter: %v, CreatedAt: %v\n", + r["action"], r["change_amount"], r["balance_after"], r["created_at"]) + } +} diff --git a/cmd/debug_ledger_full/main.go b/cmd/debug_ledger_full/main.go new file mode 100644 index 0000000..5fa25ed --- /dev/null +++ b/cmd/debug_ledger_full/main.go @@ -0,0 +1,29 @@ +package main + +import ( + "fmt" + "log" + + "gorm.io/driver/mysql" + "gorm.io/gorm" +) + +const ( + DbDSN = "root:bindbox2025kdy@tcp(150.158.78.154:3306)/dev_game?charset=utf8mb4&parseTime=True&loc=Local" +) + +func main() { + db, err := gorm.Open(mysql.Open(DbDSN), &gorm.Config{}) + if err != nil { + log.Fatalf("DB connection failed: %v", err) + } + + userCouponID := 260 + fmt.Printf("--- Full Ledger Trace for UserCoupon %d ---\n", userCouponID) + var results []map[string]interface{} + db.Table("user_coupon_ledger").Where("user_coupon_id = ?", userCouponID).Find(&results) + for _, r := range results { + fmt.Printf("ID: %v, Action: %v, Change: %v, BalanceAfter: %v, OrderID: %v, CreatedAt: %v\n", + r["id"], r["action"], r["change_amount"], r["balance_after"], r["order_id"], r["created_at"]) + } +} diff --git a/cmd/debug_query/main.go b/cmd/debug_query/main.go new file mode 100644 index 0000000..1177b38 --- /dev/null +++ b/cmd/debug_query/main.go @@ -0,0 +1,102 @@ +package main + +import ( + "fmt" + "log" + "time" + + "gorm.io/driver/mysql" + "gorm.io/gorm" +) + +type UserItemCards struct { + ID int64 `gorm:"column:id"` + UserID int64 `gorm:"column:user_id"` + CardID int64 `gorm:"column:card_id"` + Status int32 `gorm:"column:status"` + ValidStart time.Time `gorm:"column:valid_start"` + ValidEnd time.Time `gorm:"column:valid_end"` + UsedAt time.Time `gorm:"column:used_at"` + UsedActivityID int64 `gorm:"column:used_activity_id"` + UsedIssueID int64 `gorm:"column:used_issue_id"` +} + +func (UserItemCards) TableName() string { return "user_item_cards" } + +type SystemItemCards struct { + ID int64 `gorm:"column:id"` + Name string `gorm:"column:name"` + EffectType int32 `gorm:"column:effect_type"` + RewardMultiplierX1000 int32 `gorm:"column:reward_multiplier_x1000"` + BoostRateX1000 int32 `gorm:"column:boost_rate_x1000"` +} + +func (SystemItemCards) TableName() string { return "system_item_cards" } + +type ActivityDrawLogs struct { + ID int64 `gorm:"column:id"` + UserID int64 `gorm:"column:user_id"` + OrderID int64 `gorm:"column:order_id"` + IsWinner int32 `gorm:"column:is_winner"` + RewardID int64 `gorm:"column:reward_id"` + CreatedAt time.Time `gorm:"column:created_at"` +} + +func (ActivityDrawLogs) TableName() string { return "activity_draw_logs" } + +type Activities struct { + ID int64 `gorm:"column:id"` + Name string `gorm:"column:name"` + Type int32 `gorm:"column:type"` // 1: Ichiban, 2: Time-limited, 3: Matching? +} + +func (Activities) TableName() string { return "activities" } + +func main() { + dsn := "root:bindbox2025kdy@tcp(150.158.78.154:3306)/dev_game?charset=utf8mb4&parseTime=True&loc=Local" + db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{}) + if err != nil { + log.Fatalf("failed to connect database: %v", err) + } + + var cards []UserItemCards + result := db.Where("user_id = ? AND used_activity_id = ?", 9090, 94).Find(&cards) + if result.Error != nil { + log.Fatalf("failed to query user cards: %v", result.Error) + } + + fmt.Printf("Found %d user item cards for user 9090, activity 94:\n", len(cards)) + for _, c := range cards { + fmt.Printf("- ID: %d, CardID: %d, Status: %d, UsedAt: %v\n", c.ID, c.CardID, c.Status, c.UsedAt) + + var sysCard SystemItemCards + if err := db.First(&sysCard, c.CardID).Error; err == nil { + fmt.Printf(" -> SystemCard: %s, EffectType: %d, Multiplier: %d, BoostRate: %d\n", + sysCard.Name, sysCard.EffectType, sysCard.RewardMultiplierX1000, sysCard.BoostRateX1000) + } else { + fmt.Printf(" -> SystemCard lookup failed: %v\n", err) + } + } + + fmt.Println("\nChecking Activity Draw Logs:") + var drawLogs []ActivityDrawLogs + startTime := time.Date(2026, 1, 21, 3, 0, 0, 0, time.Local) + endTime := time.Date(2026, 1, 21, 3, 5, 0, 0, time.Local) + + db.Where("user_id = ? AND created_at BETWEEN ? AND ?", 9090, startTime, endTime).Find(&drawLogs) + for _, log := range drawLogs { + fmt.Printf("- DrawLogID: %d, OrderID: %d, IsWinner: %d, RewardID: %d, Created: %v\n", + log.ID, log.OrderID, log.IsWinner, log.RewardID, log.CreatedAt) + + var remark string + db.Table("orders").Select("remark").Where("id = ?", log.OrderID).Scan(&remark) + fmt.Printf(" -> Order Remark: %s\n", remark) + } + + var act Activities + if err := db.First(&act, 94).Error; err == nil { + fmt.Printf("\nActivity 94: Name=%s, Type=%d\n", act.Name, act.Type) + } else { + fmt.Printf("\nActivity 94 lookup failed: %v\n", err) + } +} diff --git a/cmd/debug_stats/main.go b/cmd/debug_stats/main.go index fbdd04a..753d181 100644 --- a/cmd/debug_stats/main.go +++ b/cmd/debug_stats/main.go @@ -1,113 +1,123 @@ package main import ( + "bindbox-game/configs" + "bindbox-game/internal/repository/mysql" "bindbox-game/internal/repository/mysql/model" + "flag" "fmt" - "log" - - "gorm.io/driver/mysql" - "gorm.io/gorm" ) -func main() { - dsn := "root:bindbox2025kdy@tcp(150.158.78.154:3306)/bindbox_game?charset=utf8mb4&parseTime=True&loc=Local" - db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{}) - if err != nil { - log.Fatalf("failed to connect database: %v", err) - } - - userID := int64(9082) // User from the report - - // 1. Check Orders (ALL Status) - var orders []model.Orders - if err := db.Where("user_id = ?", userID).Find(&orders).Error; err != nil { - log.Printf("Error querying orders: %v", err) - } - - var totalAmount int64 - var discountAmount int64 - var pointsAmount int64 - - fmt.Printf("--- ALL Orders for User %d ---\n", userID) - for _, o := range orders { - fmt.Printf("ID: %d, OrderNo: %s, Status: %d, Total: %d, Actual: %d, Discount: %d, Points: %d, Source: %d\n", - o.ID, o.OrderNo, o.Status, o.TotalAmount, o.ActualAmount, o.DiscountAmount, o.PointsAmount, o.SourceType) - if o.Status == 2 { // Only count Paid for Spending simulation (if that's the logic) - totalAmount += o.TotalAmount - discountAmount += o.DiscountAmount - pointsAmount += o.PointsAmount - } - } - fmt.Printf("Total Points (Status 2): %d\n", pointsAmount) - - // 1.5 Check Points Ledger (Redemptions) - var ledgers []model.UserPointsLedger - if err := db.Where("user_id = ? AND action = ?", userID, "redeem_reward").Find(&ledgers).Error; err != nil { - log.Printf("Error querying ledgers: %v", err) - } - var totalRedeemedPoints int64 - fmt.Printf("\n--- Points Redemption (Decomposition) ---\n") - for _, l := range ledgers { - fmt.Printf("ID: %d, Points: %d, Remark: %s, CreatedAt: %v\n", l.ID, l.Points, l.Remark, l.CreatedAt) - totalRedeemedPoints += l.Points - } - fmt.Printf("Total Redeemed Points: %d\n", totalRedeemedPoints) - - // 2. Check Inventory (Output) - type InvItem struct { - ID int64 - ProductID int64 - Status int32 - Price int64 - Name string - Remark string // Added Remark field - } - var invItems []InvItem - - // Show ALL status - err = db.Table("user_inventory"). - Select("user_inventory.id, user_inventory.product_id, user_inventory.status, user_inventory.remark, products.price, products.name"). - Joins("JOIN products ON products.id = user_inventory.product_id"). - Where("user_inventory.user_id = ?", userID). - Where("user_inventory.remark NOT LIKE ? AND user_inventory.remark NOT LIKE ?", "%redeemed%", "%void%"). - Scan(&invItems).Error - if err != nil { - log.Printf("Error querying inventory: %v", err) - } - - var totalPrizeValue int64 - var status1Value int64 - var status2Value int64 - var status3Value int64 - - fmt.Printf("\n--- Inventory (ALL Status) for User %d ---\n", userID) - for _, item := range invItems { - fmt.Printf("InvID: %d, ProductID: %d, Name: %s, Price: %d, Status: %d, Remark: %s\n", - item.ID, item.ProductID, item.Name, item.Price, item.Status, item.Remark) - - if item.Status == 1 || item.Status == 3 { - totalPrizeValue += item.Price - } - if item.Status == 1 { - status1Value += item.Price - } - if item.Status == 2 { - status2Value += item.Price - } - if item.Status == 3 { - status3Value += item.Price - } - } - fmt.Printf("Status 1 (Holding) Value: %d\n", status1Value) - fmt.Printf("Status 2 (Void/Decomposed) Value: %d\n", status2Value) - fmt.Printf("Status 3 (Shipped/Used) Value: %d\n", status3Value) - fmt.Printf("Total Effective Prize Value (1+3): %d\n", totalPrizeValue) - - // 3. Calculate Profit - profit := totalAmount - totalPrizeValue - discountAmount - fmt.Printf("\n--- Calculation ---\n") - fmt.Printf("Profit = Spending (%d) - PrizeValue (%d) - Discount (%d) = %d\n", - totalAmount, totalPrizeValue, discountAmount, profit) - - fmt.Printf("Formatted:\nSpending: %.2f\nOutput: %.2f\nProfit: %.2f\n", float64(totalAmount)/100, float64(totalPrizeValue)/100, float64(profit)/100) +type RevenueStat struct { + ActivityID int64 + TotalRevenue int64 + TotalDiscount int64 +} + +type DrawStat struct { + ActivityID int64 + TotalCount int64 + GamePassCount int64 + PaymentCount int64 + RefundCount int64 + PlayerCount int64 +} + +func main() { + flag.Parse() + configs.Init() + dbRepo, err := mysql.New() + if err != nil { + panic(err) + } + db := dbRepo.GetDbR() + + activityIDs := []int64{89} + + // 1. Debug Step 2: Draw Stats + var drawStats []DrawStat + err = db.Table(model.TableNameActivityDrawLogs). + Select(` + activity_issues.activity_id, + COUNT(activity_draw_logs.id) as total_count, + SUM(CASE WHEN orders.status = 2 AND orders.actual_amount = 0 THEN 1 ELSE 0 END) as game_pass_count, + SUM(CASE WHEN orders.status = 2 AND orders.actual_amount > 0 THEN 1 ELSE 0 END) as payment_count, + SUM(CASE WHEN orders.status IN (3, 4) THEN 1 ELSE 0 END) as refund_count, + COUNT(DISTINCT CASE WHEN orders.status = 2 THEN activity_draw_logs.user_id END) as player_count + `). + Joins("JOIN activity_issues ON activity_issues.id = activity_draw_logs.issue_id"). + Joins("LEFT JOIN orders ON orders.id = activity_draw_logs.order_id"). + Where("activity_issues.activity_id IN ?", activityIDs). + Group("activity_issues.activity_id"). + Scan(&drawStats).Error + + if err != nil { + fmt.Printf("DrawStats Error: %v\n", err) + } else { + fmt.Printf("DrawStats: %+v\n", drawStats) + } + + // 2. Debug Step 3: Revenue Stats (With WHERE filter) + var revenueStats []RevenueStat + err = db.Table(model.TableNameOrders). + Select(` + activity_issues.activity_id, + SUM(orders.actual_amount * order_activity_draws.draw_count / order_total_draws.total_count) as total_revenue, + SUM(orders.discount_amount * order_activity_draws.draw_count / order_total_draws.total_count) as total_discount + `). + Joins(`JOIN ( + SELECT activity_draw_logs.order_id, activity_issues.activity_id, COUNT(*) as draw_count + FROM activity_draw_logs + JOIN activity_issues ON activity_issues.id = activity_draw_logs.issue_id + GROUP BY activity_draw_logs.order_id, activity_issues.activity_id + ) as order_activity_draws ON order_activity_draws.order_id = orders.id`). + Joins(`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("JOIN activity_issues ON activity_issues.activity_id = order_activity_draws.activity_id"). + Where("orders.status = ? AND orders.status != ?", 2, 4). + Where("orders.actual_amount > ?", 0). // <--- The problematic filter? + Where("activity_issues.activity_id IN ?", activityIDs). + Group("activity_issues.activity_id"). + Scan(&revenueStats).Error + + if err != nil { + fmt.Printf("RevenueStats (With Filter) Error: %v\n", err) + } else { + fmt.Printf("RevenueStats (With Filter): %+v\n", revenueStats) + } + + // 3. Debug Step 3: Revenue Stats (Without WHERE filter, using CASE in Select) + var revenueStats2 []RevenueStat + err = db.Table(model.TableNameOrders). + Select(` + activity_issues.activity_id, + SUM(orders.actual_amount * order_activity_draws.draw_count / order_total_draws.total_count) as total_revenue, + SUM(CASE WHEN orders.actual_amount > 0 THEN orders.discount_amount * order_activity_draws.draw_count / order_total_draws.total_count ELSE 0 END) as total_discount + `). + Joins(`JOIN ( + SELECT activity_draw_logs.order_id, activity_issues.activity_id, COUNT(*) as draw_count + FROM activity_draw_logs + JOIN activity_issues ON activity_issues.id = activity_draw_logs.issue_id + GROUP BY activity_draw_logs.order_id, activity_issues.activity_id + ) as order_activity_draws ON order_activity_draws.order_id = orders.id`). + Joins(`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("JOIN activity_issues ON activity_issues.activity_id = order_activity_draws.activity_id"). + Where("orders.status = ? AND orders.status != ?", 2, 4). + // Where("orders.actual_amount > ?", 0). // Removed + Where("activity_issues.activity_id IN ?", activityIDs). + Group("activity_issues.activity_id"). + Scan(&revenueStats2).Error + + if err != nil { + fmt.Printf("RevenueStats (With CASE) Error: %v\n", err) + } else { + fmt.Printf("RevenueStats (With CASE): %+v\n", revenueStats2) + } } diff --git a/cmd/debug_usage_detail/main.go b/cmd/debug_usage_detail/main.go new file mode 100644 index 0000000..376c4d7 --- /dev/null +++ b/cmd/debug_usage_detail/main.go @@ -0,0 +1,35 @@ +package main + +import ( + "fmt" + "log" + + "gorm.io/driver/mysql" + "gorm.io/gorm" +) + +const ( + DbDSN = "root:bindbox2025kdy@tcp(150.158.78.154:3306)/dev_game?charset=utf8mb4&parseTime=True&loc=Local" +) + +func main() { + db, err := gorm.Open(mysql.Open(DbDSN), &gorm.Config{}) + if err != nil { + log.Fatalf("DB connection failed: %v", err) + } + + orderID := 4695 + fmt.Printf("--- Order Coupons for Order %d ---\n", orderID) + var results []map[string]interface{} + db.Table("order_coupons").Where("order_id = ?", orderID).Find(&results) + for _, r := range results { + fmt.Printf("UserCouponID: %v, Applied: %v\n", r["user_coupon_id"], r["applied_amount"]) + } + + var uc []map[string]interface{} + db.Table("user_coupons").Where("id = ?", 260).Find(&uc) + if len(uc) > 0 { + fmt.Printf("\n--- UserCoupon 260 Final State ---\n") + fmt.Printf("Status: %v, Balance: %v, UsedAt: %v\n", uc[0]["status"], uc[0]["balance_amount"], uc[0]["used_at"]) + } +} diff --git a/cmd/debug_user_search/main.go b/cmd/debug_user_search/main.go new file mode 100644 index 0000000..62ccd2f --- /dev/null +++ b/cmd/debug_user_search/main.go @@ -0,0 +1,29 @@ +package main + +import ( + "bindbox-game/configs" + "bindbox-game/internal/repository/mysql" + "bindbox-game/internal/repository/mysql/model" + "flag" + "fmt" +) + +func main() { + flag.Parse() + configs.Init() + dbRepo, err := mysql.New() + if err != nil { + panic(err) + } + db := dbRepo.GetDbR() + + targetID := int64(9072) + + // Simulate the backend query in ListAppUsers + var count int64 + db.Table(model.TableNameUsers). + Where("id = ?", targetID). + Count(&count) + + fmt.Printf("Users found with ID %d: %d\n", targetID, count) +} diff --git a/cmd/find_bad_coupons/main.go b/cmd/find_bad_coupons/main.go new file mode 100644 index 0000000..759bc9c --- /dev/null +++ b/cmd/find_bad_coupons/main.go @@ -0,0 +1,40 @@ +package main + +import ( + "fmt" + "log" + + "gorm.io/driver/mysql" + "gorm.io/gorm" +) + +const ( + DbDSN = "root:bindbox2025kdy@tcp(150.158.78.154:3306)/dev_game?charset=utf8mb4&parseTime=True&loc=Local" +) + +func main() { + db, err := gorm.Open(mysql.Open(DbDSN), &gorm.Config{}) + if err != nil { + log.Fatalf("DB connection failed: %v", err) + } + + fmt.Println("Finding coupons with status=3 but valid_end > NOW()...") + var results []map[string]interface{} + db.Raw("SELECT id, user_id, coupon_id, balance_amount, valid_end, status FROM user_coupons WHERE status = 3 AND valid_end > NOW()").Scan(&results) + if len(results) == 0 { + fmt.Println("No coupons found with status=3 but valid_end in the future.") + } else { + fmt.Printf("Found %d coupons:\n", len(results)) + for _, res := range results { + fmt.Printf("ID: %v, UserID: %v, CouponID: %v, Balance: %v, ValidEnd: %v, Status: %v\n", + res["id"], res["user_id"], res["coupon_id"], res["balance_amount"], res["valid_end"], res["status"]) + } + } + + fmt.Println("\nChecking all coupons for User 9090 specifically...") + var results9090 []map[string]interface{} + db.Raw("SELECT id, status, balance_amount, valid_end FROM user_coupons WHERE user_id = 9090").Scan(&results9090) + for _, res := range results9090 { + fmt.Printf("ID: %v, Status: %v, Balance: %v, ValidEnd: %v\n", res["id"], res["status"], res["balance_amount"], res["valid_end"]) + } +} diff --git a/cmd/find_live_activity/main.go b/cmd/find_live_activity/main.go new file mode 100644 index 0000000..8c5a1e1 --- /dev/null +++ b/cmd/find_live_activity/main.go @@ -0,0 +1,42 @@ +package main + +import ( + "bindbox-game/configs" + "bindbox-game/internal/repository/mysql" + "bindbox-game/internal/repository/mysql/model" + "flag" + "fmt" + "time" +) + +func main() { + flag.Parse() + configs.Init() + dbRepo, err := mysql.New() + if err != nil { + panic(err) + } + db := dbRepo.GetDbR() + + startTime, _ := time.ParseInLocation("2006-01-02", "2026-01-19", time.Local) + endTime := startTime.Add(24 * time.Hour) + + var logs []model.LivestreamDrawLogs + db.Where("created_at >= ? AND created_at < ?", startTime, endTime).Find(&logs) + + fmt.Printf("Found %d Livestream Draw Logs on Jan 19th.\n", len(logs)) + + activityCounts := make(map[int64]int) + for _, l := range logs { + activityCounts[l.ActivityID]++ + } + + for id, count := range activityCounts { + fmt.Printf("Livestream Activity ID: %d, Count: %d\n", id, count) + + // Get Name + var act model.LivestreamActivities + db.First(&act, id) + fmt.Printf(" -> Name: %s\n", act.Name) + } +} diff --git a/cmd/fix_order/main.go b/cmd/fix_order/main.go new file mode 100644 index 0000000..2c563ea --- /dev/null +++ b/cmd/fix_order/main.go @@ -0,0 +1,36 @@ +package main + +import ( + "fmt" + "log" + + "gorm.io/driver/mysql" + "gorm.io/gorm" +) + +func main() { + dsn := "root:bindbox2025kdy@tcp(150.158.78.154:3306)/bindbox_game?charset=utf8mb4&parseTime=True&loc=Local" + db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{}) + if err != nil { + log.Fatalf("failed to connect database: %v", err) + } + + targetID := "3791062042765557775" + + // Check current state + var granted int + db.Table("douyin_orders").Select("reward_granted").Where("shop_order_id = ?", targetID).Scan(&granted) + fmt.Printf("Current reward_granted: %d\n", granted) + + // Fix it + if granted == 1 { + err := db.Table("douyin_orders").Where("shop_order_id = ?", targetID).Update("reward_granted", 0).Error + if err != nil { + fmt.Printf("Update failed: %v\n", err) + } else { + fmt.Printf("✅ Successfully reset reward_granted to 0 for order %s\n", targetID) + } + } else { + fmt.Println("No update needed (already 0 or order not found).") + } +} diff --git a/cmd/full_9090_dump/main.go b/cmd/full_9090_dump/main.go new file mode 100644 index 0000000..a55f30f --- /dev/null +++ b/cmd/full_9090_dump/main.go @@ -0,0 +1,41 @@ +package main + +import ( + "fmt" + "log" + + "gorm.io/driver/mysql" + "gorm.io/gorm" +) + +const ( + DbDSN = "root:bindbox2025kdy@tcp(150.158.78.154:3306)/dev_game?charset=utf8mb4&parseTime=True&loc=Local" +) + +func main() { + db, err := gorm.Open(mysql.Open(DbDSN), &gorm.Config{}) + if err != nil { + log.Fatalf("DB connection failed: %v", err) + } + + fmt.Println("--- User 9090 Full Coupon Dump (Cloud DB) ---") + + var userCoupons []map[string]interface{} + db.Raw("SELECT * FROM user_coupons WHERE user_id = 9090").Scan(&userCoupons) + + for _, uc := range userCoupons { + fmt.Printf("\nCoupon Record: %+v\n", uc) + + id := uc["id"] + + // Get associated orders for this coupon + var orders []map[string]interface{} + db.Raw("SELECT order_id, applied_amount, created_at FROM order_coupons WHERE user_coupon_id = ?", id).Scan(&orders) + fmt.Printf(" Orders associated: %+v\n", orders) + + // Get ledger entries + var ledger []map[string]interface{} + db.Raw("SELECT action, change_amount, balance_after, created_at FROM user_coupon_ledger WHERE user_coupon_id = ?", id).Scan(&ledger) + fmt.Printf(" Ledger entries: %+v\n", ledger) + } +} diff --git a/cmd/inspect_order/main.go b/cmd/inspect_order/main.go new file mode 100644 index 0000000..5391a97 --- /dev/null +++ b/cmd/inspect_order/main.go @@ -0,0 +1,73 @@ +package main + +import ( + "fmt" + "log" + + "gorm.io/driver/mysql" + "gorm.io/gorm" +) + +const ( + DbDSN = "root:bindbox2025kdy@tcp(150.158.78.154:3306)/dev_game?charset=utf8mb4&parseTime=True&loc=Local" +) + +func main() { + db, err := gorm.Open(mysql.Open(DbDSN), &gorm.Config{}) + if err != nil { + log.Fatalf("DB connection failed: %v", err) + } + + orderID := 4695 + fmt.Printf("--- Inspecting Order %d ---\n", orderID) + + type Order struct { + ID int64 + OrderNo string + ActualAmount int64 + DiscountAmount int64 + Remark string + } + var order Order + err = db.Table("orders").Where("id = ?", orderID).First(&order).Error + if err != nil { + log.Fatalf("Order not found: %v", err) + } + + fmt.Printf("Order No: %s\n", order.OrderNo) + fmt.Printf("Actual Pay: %d cents\n", order.ActualAmount) + fmt.Printf("Discount: %d cents\n", order.DiscountAmount) + fmt.Printf("Remark: %s\n", order.Remark) + + total := order.ActualAmount + order.DiscountAmount + fmt.Printf("Total implied: %d cents\n", total) + + var logs []map[string]interface{} + db.Table("activity_draw_logs"). + Select("activity_draw_logs.*, products.price as product_price"). + Joins("JOIN activity_reward_settings ON activity_reward_settings.id = activity_draw_logs.reward_id"). + Joins("JOIN products ON products.id = activity_reward_settings.product_id"). + Where("order_id = ?", orderID). + Scan(&logs) + + fmt.Printf("Draw Logs Found: %d\n", len(logs)) + var sumPrice int64 + for i, l := range logs { + var price int64 + // Extract price carefully + switch p := l["product_price"].(type) { + case int64: + price = p + case int32: + price = int64(p) + case float64: + price = int64(p) + default: + fmt.Printf(" Item %d: Unknown price type %T\n", i, p) + } + + sumPrice += price + fmt.Printf(" Item %d: Price=%v (parsed: %d)\n", i, l["product_price"], price) + } + fmt.Printf("Sum of Products: %d cents\n", sumPrice) +} diff --git a/cmd/inspect_order_4746/main.go b/cmd/inspect_order_4746/main.go new file mode 100644 index 0000000..496c78a --- /dev/null +++ b/cmd/inspect_order_4746/main.go @@ -0,0 +1,32 @@ +package main + +import ( + "fmt" + "log" + + "gorm.io/driver/mysql" + "gorm.io/gorm" +) + +const ( + DbDSN = "root:bindbox2025kdy@tcp(150.158.78.154:3306)/dev_game?charset=utf8mb4&parseTime=True&loc=Local" +) + +func main() { + db, err := gorm.Open(mysql.Open(DbDSN), &gorm.Config{}) + if err != nil { + log.Fatalf("DB connection failed: %v", err) + } + + orderID := 4746 + var o []map[string]interface{} + db.Table("orders").Where("id = ?", orderID).Find(&o) + + if len(o) > 0 { + fmt.Printf("--- Order %d Details ---\n", orderID) + fmt.Printf("OrderNo: %v, Total: %v, ActualPay: %v, Discount: %v, CreatedAt: %v, Remark: %v\n", + o[0]["order_no"], o[0]["total_amount"], o[0]["actual_amount"], o[0]["discount_amount"], o[0]["created_at"], o[0]["remark"]) + } else { + fmt.Printf("Order %d not found\n", orderID) + } +} diff --git a/cmd/master_reconcile/main.go b/cmd/master_reconcile/main.go new file mode 100644 index 0000000..7cb6006 --- /dev/null +++ b/cmd/master_reconcile/main.go @@ -0,0 +1,81 @@ +package main + +import ( + "fmt" + "log" + + "gorm.io/driver/mysql" + "gorm.io/gorm" +) + +const ( + DbDSN = "root:bindbox2025kdy@tcp(150.158.78.154:3306)/dev_game?charset=utf8mb4&parseTime=True&loc=Local" +) + +func main() { + db, err := gorm.Open(mysql.Open(DbDSN), &gorm.Config{}) + if err != nil { + log.Fatalf("DB connection failed: %v", err) + } + + fmt.Println("--- Master Coupon Reconciliation & Repair Generator (Cloud DB) ---") + + var userCoupons []struct { + ID int64 + UserID int64 + CouponID int64 + BalanceAmount int64 + Status int32 + ValidEnd string + OriginalValue int64 + } + + // Join with system_coupons to get original discount value + db.Raw(` + SELECT uc.id, uc.user_id, uc.coupon_id, uc.balance_amount, uc.status, uc.valid_end, sc.discount_value as original_value + FROM user_coupons uc + JOIN system_coupons sc ON uc.coupon_id = sc.id + `).Scan(&userCoupons) + + for _, uc := range userCoupons { + // Calculate actual usage from ledger + var ledgerSum int64 + db.Raw("SELECT ABS(SUM(change_amount)) FROM user_coupon_ledger WHERE user_coupon_id = ? AND action = 'apply'", uc.ID).Scan(&ledgerSum) + + // Calculate actual usage from order_coupons + var orderSum int64 + db.Raw("SELECT SUM(applied_amount) FROM order_coupons WHERE user_coupon_id = ?", uc.ID).Scan(&orderSum) + + // Source of truth: Max of both (covering cases where one might be missing manually) + actualUsed := ledgerSum + if orderSum > actualUsed { + actualUsed = orderSum + } + + calculatedBalance := uc.OriginalValue - actualUsed + if calculatedBalance < 0 { + calculatedBalance = 0 + } + + // Determine correct status + // 1: Unused (Balance > 0 and Time not past) + // 2: Used (Balance == 0) + // 3: Expired (Balance > 0 and Time past) + + expectedStatus := uc.Status + if calculatedBalance == 0 { + expectedStatus = 2 + } else { + // If balance remaining, check time expiry + // (Assuming valid_end is in RFC3339 or similar from DB) + // For simplicity in SQL generation, we'll focus on the obvious mismatches found here. + } + + if calculatedBalance != uc.BalanceAmount || expectedStatus != uc.Status { + fmt.Printf("-- User %d Coupon %d: Bal %d->%d, Status %v->%v\n", + uc.UserID, uc.ID, uc.BalanceAmount, calculatedBalance, uc.Status, expectedStatus) + fmt.Printf("UPDATE user_coupons SET balance_amount = %d, status = %v, updated_at = NOW() WHERE id = %d;\n", + calculatedBalance, expectedStatus, uc.ID) + } + } +} diff --git a/cmd/reconcile_coupons/main.go b/cmd/reconcile_coupons/main.go new file mode 100644 index 0000000..b0e2306 --- /dev/null +++ b/cmd/reconcile_coupons/main.go @@ -0,0 +1,85 @@ +package main + +import ( + "fmt" + "log" + + "gorm.io/driver/mysql" + "gorm.io/gorm" +) + +const ( + DbDSN = "root:bindbox2025kdy@tcp(150.158.78.154:3306)/dev_game?charset=utf8mb4&parseTime=True&loc=Local" +) + +func main() { + db, err := gorm.Open(mysql.Open(DbDSN), &gorm.Config{}) + if err != nil { + log.Fatalf("DB connection failed: %v", err) + } + + fmt.Println("--- Advanced Coupon Data Reconciliation (Cloud DB) ---") + + var userCoupons []struct { + ID int64 + UserID int64 + SystemAmount int64 + BalanceAmount int64 + Status int32 + ValidEnd string + } + + // Join with system_coupons to get original amount + db.Raw(` + SELECT uc.id, uc.user_id, sc.discount_value as system_amount, uc.balance_amount, uc.status, uc.valid_end + FROM user_coupons uc + JOIN system_coupons sc ON uc.coupon_id = sc.id + `).Scan(&userCoupons) + + fmt.Println("Generating repair SQL based on ledger and order association...") + + for _, uc := range userCoupons { + // Calculate total deduction from ledger + var totalDeduction struct { + Sum int64 + } + db.Raw("SELECT ABS(SUM(change_amount)) as sum FROM user_coupon_ledger WHERE user_coupon_id = ? AND action = 'apply'", uc.ID).Scan(&totalDeduction) + + // Calculate total deduction from order_coupons (secondary verification) + var orderDeduction struct { + Sum int64 + } + db.Raw("SELECT SUM(applied_amount) as sum FROM order_coupons WHERE user_coupon_id = ?", uc.ID).Scan(&orderDeduction) + + // Choose the source of truth (ledger usually preferred) + actualUsed := totalDeduction.Sum + if orderDeduction.Sum > actualUsed { + actualUsed = orderDeduction.Sum + } + + expectedBalance := uc.SystemAmount - actualUsed + if expectedBalance < 0 { + expectedBalance = 0 + } + + expectedStatus := uc.Status + if expectedBalance == 0 { + expectedStatus = 2 // Fully Used + } else if expectedBalance > 0 { + // Check if it should be expired or unused + // We won't downgrade Expired (3) back to Unused (1) unless balance > 0 and time is not up. + // However, usually if balance > 0 and Status is 2, it's a bug. + } + + // Check for status 3 that shouldn't be + // (Currently the user says they see status 3 but it's used?) + // If balance == 0, status must be 2. + + if expectedBalance != uc.BalanceAmount || expectedStatus != uc.Status { + fmt.Printf("-- Coupon %d (User %d): Bal %d->%d, Status %v->%v (Used: %d, Orig: %d)\n", + uc.ID, uc.UserID, uc.BalanceAmount, expectedBalance, uc.Status, expectedStatus, actualUsed, uc.SystemAmount) + fmt.Printf("UPDATE user_coupons SET balance_amount = %d, status = %v WHERE id = %d;\n", + expectedBalance, expectedStatus, uc.ID) + } + } +} diff --git a/cmd/simulate_test/main.go b/cmd/simulate_test/main.go new file mode 100644 index 0000000..74bcffa --- /dev/null +++ b/cmd/simulate_test/main.go @@ -0,0 +1,186 @@ +package main + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "log" + "net/http" + "time" + + "github.com/golang-jwt/jwt/v4" + "gorm.io/driver/mysql" + "gorm.io/gorm" +) + +// Configs from dev_configs.toml +const ( + DbDSN = "root:bindbox2025kdy@tcp(150.158.78.154:3306)/dev_game?charset=utf8mb4&parseTime=True&loc=Local" + JwtSecret = "AppUserJwtSecret2025" + ApiURL = "http://127.0.0.1:9991/api/app/lottery/join" +) + +type UserItemCards struct { + ID int64 `gorm:"column:id"` + Status int32 `gorm:"column:status"` + UsedAt time.Time `gorm:"column:used_at"` + UsedActivityID int64 `gorm:"column:used_activity_id"` + UsedIssueID int64 `gorm:"column:used_issue_id"` + UsedDrawLogID int64 `gorm:"column:used_draw_log_id"` +} + +func (UserItemCards) TableName() string { return "user_item_cards" } + +func main() { + // 1. Connect DB + db, err := gorm.Open(mysql.Open(DbDSN), &gorm.Config{}) + if err != nil { + log.Fatalf("DB connection failed: %v", err) + } + + // 2. Reset Item Card 836 + cardID := int64(836) + fmt.Printf("Restoring UserItemCard %d...\n", cardID) + // We set status to 1 and clear used fields + res := db.Model(&UserItemCards{}).Where("id = ?", cardID).Updates(map[string]interface{}{ + "status": 1, + "used_at": nil, + "used_activity_id": 0, + "used_issue_id": 0, + "used_draw_log_id": 0, + }) + if res.Error != nil { + log.Fatalf("Failed to restore card: %v", res.Error) + } + fmt.Println("Card restored to Status 1.") + + // 3. Generate JWT + tokenString, err := generateToken(9090, JwtSecret) + if err != nil { + log.Fatalf("Failed to generate token: %v", err) + } + fmt.Printf("Generated Token for User 9090.\n") + + // 4. Send API Request + targetSlot := int64(1) + + reqBody := map[string]interface{}{ + "activity_id": 94, + "issue_id": 105, + "count": 1, + "slot_index": []int64{targetSlot}, + "item_card_id": cardID, + "channel": "simulation", + } + jsonBody, _ := json.Marshal(reqBody) + + req, _ := http.NewRequest("POST", ApiURL, bytes.NewBuffer(jsonBody)) + req.Header.Set("Content-Type", "application/json") + // Backend seems to expect raw token string without Bearer prefix + req.Header.Set("Authorization", tokenString) + + client := &http.Client{} + fmt.Println("Sending JoinLottery request...") + resp, err := client.Do(req) + if err != nil { + log.Fatalf("Request failed: %v", err) + } + defer resp.Body.Close() + + body, _ := io.ReadAll(resp.Body) + fmt.Printf("Response Status: %s\n", resp.Status) + fmt.Printf("Response Body: %s\n", string(body)) + + // If success (200 OK), parse order info, simulate payment, trigger draw + if resp.StatusCode == 200 { + var resMap map[string]interface{} + json.Unmarshal(body, &resMap) + // joinID := resMap["join_id"].(string) // Unused + orderNo := resMap["order_no"].(string) + + fmt.Printf("Order Created: %s. Simulating Payment...\n", orderNo) + + // Simulate Payment in DB + db.Table("orders").Where("order_no = ?", orderNo).Updates(map[string]interface{}{ + "status": 2, + "paid_at": time.Now(), + }) + fmt.Println("Order marked as PAID (Status 2).") + + // Trigger Draw via GetLotteryResult with order_no + resultURL := fmt.Sprintf("http://127.0.0.1:9991/api/app/lottery/result?order_no=%s", orderNo) + reqResult, _ := http.NewRequest("GET", resultURL, nil) + reqResult.Header.Set("Authorization", tokenString) + + fmt.Println("Triggering Draw (GetLotteryResult)...") + respResult, err := client.Do(reqResult) + if err != nil { + log.Fatalf("Result Request failed: %v", err) + } + defer respResult.Body.Close() + bodyResult, _ := io.ReadAll(respResult.Body) + fmt.Printf("Draw Response: %s\n", string(bodyResult)) + + time.Sleep(2 * time.Second) + checkLogs(db, cardID) + } +} + +func generateToken(userID int64, secret string) (string, error) { + // Claims mimicking bindbox-game/internal/pkg/jwtoken/jwtoken.go structure + claims := jwt.MapClaims{ + "id": int32(userID), + "username": "simulation", + "nickname": "simulation_user", + "is_super": 0, + "platform": "simulation", + // Standard claims + "exp": time.Now().Add(time.Hour).Unix(), + "iat": time.Now().Unix(), + "nbf": time.Now().Unix(), + } + token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) + return token.SignedString([]byte(secret)) +} + +func checkLogs(db *gorm.DB, cardID int64) { + fmt.Println("\n--- Verification Results ---") + // Check Card Status + var card UserItemCards + if err := db.First(&card, cardID).Error; err != nil { + fmt.Printf("Error fetching card: %v\n", err) + return + } + fmt.Printf("Card Status: %d (Expected 2 if success)\n", card.Status) + + if card.Status == 2 { + fmt.Printf("Card Used At: %v\n", card.UsedAt) + fmt.Printf("Used Draw Log ID: %d\n", card.UsedDrawLogID) + + if card.UsedDrawLogID > 0 { + // Check Draw Log + type ActivityDrawLogs struct { + ID int64 `gorm:"column:id"` + RewardID int64 `gorm:"column:reward_id"` + OrderID int64 `gorm:"column:order_id"` + } + var dl ActivityDrawLogs + db.Table("activity_draw_logs").First(&dl, card.UsedDrawLogID) + fmt.Printf("Original Draw Reward ID: %d, Order ID: %d\n", dl.RewardID, dl.OrderID) + + // Check Inventory Count for this Order (Should be > 1 if doubled) + var inventoryCount int64 + db.Table("user_inventory").Where("order_id = ?", dl.OrderID).Count(&inventoryCount) + fmt.Printf("Total Inventory Items for Order %d: %d\n", dl.OrderID, inventoryCount) + + if inventoryCount > 1 { + fmt.Println("SUCCESS: Double reward applied (Inventory count > 1)") + } else { + fmt.Println("WARNING: Inventory count is 1. Double reward might NOT have been applied.") + } + } + } else { + fmt.Println("FAILURE: Card status is still 1 (Not Consumed).") + } +} diff --git a/cmd/trace_ledger_cloud/main.go b/cmd/trace_ledger_cloud/main.go new file mode 100644 index 0000000..ff3ff05 --- /dev/null +++ b/cmd/trace_ledger_cloud/main.go @@ -0,0 +1,42 @@ +package main + +import ( + "fmt" + "log" + + "gorm.io/driver/mysql" + "gorm.io/gorm" +) + +const ( + DbDSN = "root:bindbox2025kdy@tcp(150.158.78.154:3306)/dev_game?charset=utf8mb4&parseTime=True&loc=Local" +) + +func main() { + db, err := gorm.Open(mysql.Open(DbDSN), &gorm.Config{}) + if err != nil { + log.Fatalf("DB connection failed: %v", err) + } + + fmt.Println("--- User Coupon Ledger Structure (Cloud DB) ---") + var columns []struct { + Field string + Type string + Null string + Key string + Default *string + Extra string + } + db.Raw("DESC user_coupon_ledger").Scan(&columns) + for _, col := range columns { + fmt.Printf("%-15s %-15s %-5s %-5s\n", col.Field, col.Type, col.Null, col.Key) + } + + fmt.Println("\n--- User 9090 Coupon 260 Trace (Cloud DB) ---") + var results []map[string]interface{} + db.Raw("SELECT id, user_id, user_coupon_id, change_amount, balance_after, order_id, action, created_at FROM user_coupon_ledger WHERE user_coupon_id = 260 ORDER BY id ASC").Scan(&results) + for _, res := range results { + fmt.Printf("ID: %v, Action: %v, Change: %v, Bal: %v, Order: %v, Time: %v\n", + res["id"], res["action"], res["change_amount"], res["balance_after"], res["order_id"], res["created_at"]) + } +} diff --git a/cmd/verify_coupon_fix/main.go b/cmd/verify_coupon_fix/main.go new file mode 100644 index 0000000..e7e5b34 --- /dev/null +++ b/cmd/verify_coupon_fix/main.go @@ -0,0 +1,70 @@ +package main + +import ( + "fmt" + "log" + "time" + + "bindbox-game/configs" + "bindbox-game/internal/pkg/logger" + "bindbox-game/internal/repository/mysql" + "bindbox-game/internal/repository/mysql/dao" + usersvc "bindbox-game/internal/service/user" + "context" + "flag" +) + +func main() { + flag.Parse() + // 1. Initialize Configs (Requires being in the project root or having CONFIG_FILE env) + configs.Init() + + // 2. Initialize Real MySQL Repo + dbRepo, err := mysql.New() + if err != nil { + log.Fatalf("MySQL init failed: %v", err) + } + + // 3. Initialize Logger + d := dao.Use(dbRepo.GetDbW()) + l, err := logger.NewCustomLogger(d, logger.WithOutputInConsole()) + if err != nil { + log.Fatalf("Logger creation failed: %v", err) + } + + // 4. Initialize User Service + us := usersvc.New(l, dbRepo) + + userID := int64(9090) + orderID := int64(4695) // The order that already had double deduction + ctx := context.Background() + + fmt.Printf("--- Verifying Idempotency for Order %d ---\n", orderID) + + // Using raw DB for checking counts + db := dbRepo.GetDbR() + + // Count existing ledger records + var beforeCount int64 + db.Table("user_coupon_ledger").Where("order_id = ? AND action = 'apply'", orderID).Count(&beforeCount) + fmt.Printf("Before call: %d ledger records\n", beforeCount) + + // Call DeductCouponsForPaidOrder + fmt.Println("Calling DeductCouponsForPaidOrder...") + // Pass nil as tx, the service will use dbRepo.GetDbW() + err = us.DeductCouponsForPaidOrder(ctx, nil, userID, orderID, time.Now()) + if err != nil { + fmt.Printf("Error during DeductCouponsForPaidOrder: %v\n", err) + } + + // Count after ledger records + var afterCount int64 + db.Table("user_coupon_ledger").Where("order_id = ? AND action = 'apply'", orderID).Count(&afterCount) + fmt.Printf("After call: %d ledger records\n", afterCount) + + if beforeCount == afterCount { + fmt.Println("\nSUCCESS: Fix is idempotent! No new records added.") + } else { + fmt.Println("\nFAILURE: Still adding duplicate records.") + } +} diff --git a/configs/dev_configs.toml b/configs/dev_configs.toml index e49d0ed..5709a56 100644 --- a/configs/dev_configs.toml +++ b/configs/dev_configs.toml @@ -3,13 +3,13 @@ local = 'zh-cn' [mysql.read] addr = '150.158.78.154:3306' -name = 'bindbox_game' +name = 'dev_game' pass = 'bindbox2025kdy' user = 'root' [mysql.write] addr = '150.158.78.154:3306' -name = 'bindbox_game' +name = 'dev_game' pass = 'bindbox2025kdy' user = 'root' diff --git a/go.mod b/go.mod index d340f20..597e765 100644 --- a/go.mod +++ b/go.mod @@ -20,6 +20,7 @@ require ( github.com/go-playground/universal-translator v0.18.1 github.com/go-playground/validator/v10 v10.15.0 github.com/go-resty/resty/v2 v2.10.0 + github.com/go-sql-driver/mysql v1.7.0 github.com/golang-jwt/jwt/v5 v5.2.0 github.com/issue9/identicon/v2 v2.1.2 github.com/pkg/errors v0.9.1 @@ -62,6 +63,7 @@ require ( github.com/alibabacloud-go/endpoint-util v1.1.0 // indirect github.com/alibabacloud-go/openapi-util v0.1.1 // indirect github.com/alibabacloud-go/tea-utils/v2 v2.0.7 // indirect + github.com/alicebob/miniredis/v2 v2.36.1 // indirect github.com/aliyun/credentials-go v1.4.5 // indirect github.com/beorn7/perks v1.0.1 // indirect github.com/bytedance/sonic/loader v0.2.4 // indirect @@ -81,8 +83,8 @@ require ( github.com/go-openapi/jsonreference v0.19.6 // indirect github.com/go-openapi/spec v0.20.4 // indirect github.com/go-openapi/swag v0.19.15 // indirect - github.com/go-sql-driver/mysql v1.7.0 // indirect github.com/goccy/go-json v0.10.2 // indirect + github.com/golang-jwt/jwt/v4 v4.5.2 // indirect github.com/golang/protobuf v1.5.4 // indirect github.com/google/go-querystring v1.0.0 // indirect github.com/google/uuid v1.6.0 // indirect @@ -119,6 +121,7 @@ require ( github.com/tjfoc/gmsm v1.4.1 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/ugorji/go/codec v1.2.11 // indirect + github.com/yuin/gopher-lua v1.1.1 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect go.opentelemetry.io/otel/metric v1.39.0 // indirect go.opentelemetry.io/proto/otlp v1.9.0 // indirect diff --git a/go.sum b/go.sum index b5587dc..8c6927b 100644 --- a/go.sum +++ b/go.sum @@ -96,6 +96,8 @@ github.com/alibabacloud-go/tea-utils/v2 v2.0.6/go.mod h1:qxn986l+q33J5VkialKMqT/ github.com/alibabacloud-go/tea-utils/v2 v2.0.7 h1:WDx5qW3Xa5ZgJ1c8NfqJkF6w+AU5wB8835UdhPr6Ax0= github.com/alibabacloud-go/tea-utils/v2 v2.0.7/go.mod h1:qxn986l+q33J5VkialKMqT/TTs3E+U9MJpd001iWQ9I= github.com/alibabacloud-go/tea-xml v1.1.3/go.mod h1:Rq08vgCcCAjHyRi/M7xlHKUykZCEtyBy9+DPF6GgEu8= +github.com/alicebob/miniredis/v2 v2.36.1 h1:Dvc5oAnNOr7BIfPn7tF269U8DvRW1dBG2D5n0WrfYMI= +github.com/alicebob/miniredis/v2 v2.36.1/go.mod h1:TcL7YfarKPGDAthEtl5NBeHZfeUQj6OXMm/+iu5cLMM= github.com/aliyun/credentials-go v1.1.2/go.mod h1:ozcZaMR5kLM7pwtCMEpVmQ242suV6qTJya2bDq4X1Tw= github.com/aliyun/credentials-go v1.3.1/go.mod h1:8jKYhQuDawt8x2+fusqa1Y6mPxemTsBEN04dgcAcYz0= github.com/aliyun/credentials-go v1.3.6/go.mod h1:1LxUuX7L5YrZUWzBrRyk0SwSdH4OmPrib8NVePL3fxM= @@ -204,6 +206,8 @@ github.com/go-sql-driver/mysql v1.7.0/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9 github.com/goccy/go-json v0.9.7/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU= github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= +github.com/golang-jwt/jwt/v4 v4.5.2 h1:YtQM7lnr8iZ+j5q71MGKkNw9Mn7AjHM68uc9g5fXeUI= +github.com/golang-jwt/jwt/v4 v4.5.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= github.com/golang-jwt/jwt/v5 v5.2.0 h1:d/ix8ftRUorsN+5eMIlF4T6J8CAt9rch3My2winC1Jw= github.com/golang-jwt/jwt/v5 v5.2.0/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9 h1:au07oEsX2xN0ktxqI+Sida1w446QrXBRJ0nee3SNZlA= @@ -466,6 +470,8 @@ github.com/yuin/goldmark v1.1.30/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9de github.com/yuin/goldmark v1.1.32/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +github.com/yuin/gopher-lua v1.1.1 h1:kYKnWBjvbNP4XLT3+bPEwAXJx262OhaHDWDVOPjL46M= +github.com/yuin/gopher-lua v1.1.1/go.mod h1:GBR0iDaNXjAgGg9zfCvksxSRnQx76gclCIb7kdAd1Pw= go.opencensus.io v0.21.0/go.mod h1:mSImk1erAIZhrmZN+AvHh14ztQfjbGwt4TtuofqLduU= go.opencensus.io v0.22.0/go.mod h1:+kGneAE2xo2IficOXnaByMWTGM9T73dGwxeWcUqIpI8= go.opencensus.io v0.22.2/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw= diff --git a/internal/api/activity/activities_app.go b/internal/api/activity/activities_app.go index a96a80c..b8aa004 100644 --- a/internal/api/activity/activities_app.go +++ b/internal/api/activity/activities_app.go @@ -39,28 +39,28 @@ type listActivitiesResponse struct { } type activityDetailResponse struct { - ID int64 `json:"id"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` - Name string `json:"name"` - Banner string `json:"banner"` - ActivityCategoryID int64 `json:"activity_category_id"` - Status int32 `json:"status"` - PriceDraw int64 `json:"price_draw"` - IsBoss int32 `json:"is_boss"` - StartTime time.Time `json:"start_time"` - EndTime time.Time `json:"end_time"` - DrawMode string `json:"draw_mode"` - PlayType string `json:"play_type"` - MinParticipants int64 `json:"min_participants"` - IntervalMinutes int64 `json:"interval_minutes"` - ScheduledTime time.Time `json:"scheduled_time"` - LastSettledAt time.Time `json:"last_settled_at"` - RefundCouponID int64 `json:"refund_coupon_id"` - Image string `json:"image"` - GameplayIntro string `json:"gameplay_intro"` - AllowItemCards bool `json:"allow_item_cards"` - AllowCoupons bool `json:"allow_coupons"` + ID int64 `json:"id"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + Name string `json:"name"` + Banner string `json:"banner"` + ActivityCategoryID int64 `json:"activity_category_id"` + Status int32 `json:"status"` + PriceDraw int64 `json:"price_draw"` + IsBoss int32 `json:"is_boss"` + StartTime time.Time `json:"start_time"` + EndTime time.Time `json:"end_time"` + DrawMode string `json:"draw_mode"` + PlayType string `json:"play_type"` + MinParticipants int64 `json:"min_participants"` + IntervalMinutes int64 `json:"interval_minutes"` + ScheduledTime *time.Time `json:"scheduled_time"` + LastSettledAt time.Time `json:"last_settled_at"` + RefundCouponID int64 `json:"refund_coupon_id"` + Image string `json:"image"` + GameplayIntro string `json:"gameplay_intro"` + AllowItemCards bool `json:"allow_item_cards"` + AllowCoupons bool `json:"allow_coupons"` } // ListActivities 活动列表 @@ -86,6 +86,16 @@ func (h *handler) ListActivities() core.HandlerFunc { ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, validation.Error(err))) return } + + if req.Page <= 0 { + req.Page = 1 + } + if req.PageSize <= 0 { + req.PageSize = 20 + } + if req.PageSize > 100 { + req.PageSize = 100 + } var isBossPtr *int32 if req.IsBoss == 0 || req.IsBoss == 1 { isBossPtr = &req.IsBoss @@ -180,7 +190,7 @@ func (h *handler) GetActivityDetail() core.HandlerFunc { PlayType: item.PlayType, MinParticipants: item.MinParticipants, IntervalMinutes: item.IntervalMinutes, - ScheduledTime: item.ScheduledTime, + ScheduledTime: &item.ScheduledTime, LastSettledAt: item.LastSettledAt, RefundCouponID: item.RefundCouponID, Image: item.Image, @@ -188,6 +198,13 @@ func (h *handler) GetActivityDetail() core.HandlerFunc { AllowItemCards: item.AllowItemCards, AllowCoupons: item.AllowCoupons, } + + // 修复一番赏:即时模式下,清空 ScheduledTime (设置为 nil) 以绕过前端下单拦截 + // 如果返回零值时间,前端会解析为很早的时间从而判定已结束,必须明确返回 nil + if rsp.PlayType == "ichiban" && rsp.DrawMode == "instant" { + rsp.ScheduledTime = nil + } + ctx.Payload(rsp) } } diff --git a/internal/api/activity/draw_logs_app.go b/internal/api/activity/draw_logs_app.go index 5bfa3e4..ac462f4 100644 --- a/internal/api/activity/draw_logs_app.go +++ b/internal/api/activity/draw_logs_app.go @@ -77,16 +77,18 @@ func (h *handler) ListDrawLogs() core.HandlerFunc { pageSize = 100 } - // 计算5分钟前的时间点 - fiveMinutesAgo := time.Now().Add(-5 * time.Minute) + now := time.Now() + // 计算5分钟前的时间点 (用于延迟显示) + fiveMinutesAgo := now.Add(-5 * time.Minute) + // 计算当天零点 (用于仅显示当天数据) + startOfToday := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location()) - // 为了保证过滤后依然有足够数据,我们多取一些 - fetchPageSize := pageSize - if pageSize < 100 { - fetchPageSize = 100 // 至少取100条来过滤 - } + // [修改] 强制获取当天最新的 100 条数据 (Service 层限制最大 100) + // 忽略前端传入的 Page/PageSize,总是获取第一页的 100 条 + fetchPageSize := 100 + fetchPage := 1 - items, total, err := h.activity.ListDrawLogs(ctx.RequestContext(), issueID, page, fetchPageSize, req.Level) + items, total, err := h.activity.ListDrawLogs(ctx.RequestContext(), issueID, fetchPage, fetchPageSize, req.Level) if err != nil { ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ListDrawLogsError, err.Error())) return @@ -100,10 +102,21 @@ func (h *handler) ListDrawLogs() core.HandlerFunc { var filteredItems []*model.ActivityDrawLogs for _, v := range items { - // 恢复 5 分钟过滤逻辑 + // 1. 过滤掉太新的数据 (5分钟延迟) if v.CreatedAt.After(fiveMinutesAgo) { continue } + // 2. 过滤掉非当天的数据 (当天零点之前) + if v.CreatedAt.Before(startOfToday) { + // 因为是按时间倒序返回的,一旦遇到早于今天的,后续的更早,直接结束 + break + } + // 3. 数量限制 (虽然 Service 取了 100,这里再保个底,或者遵循前端 pageSize? + // 需求是 "获取当天的 最新100 个数据",这里我们以 filteredItems 为准, + // 如果前端 pageSize 传了比如 20,是否应该只给 20? + // 按照通常逻辑,列表接口应遵循 pageSize。但在这种定制逻辑下,用户似乎想要的是“当天数据的视图”。 + // 保持原逻辑:遵循 pageSize 限制输出数量,但我们上面强行取了 100 作为源数据。 + // 如果用户原本想看 100 条,前端传 100 即可。 if len(filteredItems) >= pageSize { break } diff --git a/internal/api/activity/lottery_app.go b/internal/api/activity/lottery_app.go index 7f39647..7a1100d 100644 --- a/internal/api/activity/lottery_app.go +++ b/internal/api/activity/lottery_app.go @@ -136,8 +136,10 @@ func (h *handler) JoinLottery() core.HandlerFunc { } if activity.AllowCoupons && req.CouponID != nil && *req.CouponID > 0 { - order.CouponID = *req.CouponID applied = h.applyCouponWithCap(ctx, userID, order, req.ActivityID, *req.CouponID) + if applied > 0 { + order.CouponID = *req.CouponID + } } // Title Discount Logic // 1. Fetch active effects for this user, scoped to this activity/issue/category @@ -395,9 +397,30 @@ func (h *handler) JoinLottery() core.HandlerFunc { } } } - // Inline RecordOrderCouponUsage (no logging) - if applied > 0 && req.CouponID != nil && *req.CouponID > 0 { - _ = tx.Orders.UnderlyingDB().Exec("INSERT INTO order_coupons (order_id, user_coupon_id, applied_amount, created_at) VALUES (?,?,?,NOW(3))", order.ID, *req.CouponID, applied).Error + // 优惠券预扣:在事务中原子性扣减余额 + // 如果余额不足(被其他并发订单消耗),事务回滚 + if applied > 0 && order.CouponID > 0 { + // 原子更新优惠券余额和状态 + now := time.Now() + res := tx.Orders.UnderlyingDB().Exec(` + UPDATE user_coupons + SET balance_amount = balance_amount - ?, + status = CASE WHEN balance_amount - ? <= 0 THEN 4 ELSE 4 END, + used_order_id = ?, + used_at = ? + WHERE id = ? AND user_id = ? AND balance_amount >= ? AND status IN (1, 4) + `, applied, applied, order.ID, now, order.CouponID, userID, applied) + + if res.Error != nil { + return fmt.Errorf("优惠券预扣失败: %w", res.Error) + } + if res.RowsAffected == 0 { + // 余额不足或状态不对,事务回滚 + return errors.New("优惠券余额不足或已被使用") + } + + // 记录使用关系 + _ = tx.Orders.UnderlyingDB().Exec("INSERT INTO order_coupons (order_id, user_coupon_id, applied_amount, created_at) VALUES (?,?,?,NOW(3))", order.ID, order.CouponID, applied).Error } return nil }) @@ -413,16 +436,8 @@ func (h *handler) JoinLottery() core.HandlerFunc { rsp.ActualAmount = order.ActualAmount rsp.Status = order.Status - // Immediate Draw Trigger if Paid (e.g. Game Pass or Free) - if order.Status == 2 && activity.DrawMode == "instant" { - go func() { - _ = h.activity.ProcessOrderLottery(context.Background(), order.ID) - }() - } - // Immediate Draw Trigger if Paid (e.g. Game Pass or Free) - if order.Status == 2 && activity.DrawMode == "instant" { - // Trigger process asynchronously or synchronously? - // Usually WechatNotify triggers it. Since we bypass WechatNotify, we must trigger it. + // 即时开奖触发(已支付 + 即时开奖模式) + if shouldTriggerInstantDraw(order.Status, activity.DrawMode) { go func() { _ = h.activity.ProcessOrderLottery(context.Background(), order.ID) }() diff --git a/internal/api/activity/lottery_app_test.go b/internal/api/activity/lottery_app_test.go index 025a912..629c49d 100644 --- a/internal/api/activity/lottery_app_test.go +++ b/internal/api/activity/lottery_app_test.go @@ -1,11 +1,71 @@ package app -import "testing" +import ( + "sync/atomic" + "testing" + "time" +) func TestParseSlotFromRemark(t *testing.T) { - r := parseSlotFromRemark("lottery:activity:1|issue:2|count:1|slot:42") - if r != 42 { t.Fatalf("slot parse failed: %d", r) } - r2 := parseSlotFromRemark("lottery:activity:1|issue:2|count:1") - if r2 != -1 { t.Fatalf("expected -1, got %d", r2) } + r := parseSlotFromRemark("lottery:activity:1|issue:2|count:1|slot:42") + if r != 42 { + t.Fatalf("slot parse failed: %d", r) + } + r2 := parseSlotFromRemark("lottery:activity:1|issue:2|count:1") + if r2 != -1 { + t.Fatalf("expected -1, got %d", r2) + } } +// TestShouldTriggerInstantDraw 验证即时开奖触发条件 +func TestShouldTriggerInstantDraw(t *testing.T) { + testCases := []struct { + name string + orderStatus int32 + drawMode string + shouldTrigger bool + }{ + {"已支付+即时开奖", 2, "instant", true}, + {"已支付+定时开奖", 2, "scheduled", false}, + {"未支付+即时开奖", 1, "instant", false}, + {"未支付+定时开奖", 1, "scheduled", false}, + {"已取消+即时开奖", 3, "instant", false}, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + result := shouldTriggerInstantDraw(tc.orderStatus, tc.drawMode) + if result != tc.shouldTrigger { + t.Errorf("期望触发=%v,实际触发=%v", tc.shouldTrigger, result) + } + }) + } +} + +// TestInstantDrawTriggerOnce 验证即时开奖只触发一次 +// 这个测试模拟 JoinLottery 中的触发逻辑,确保不会重复触发 +func TestInstantDrawTriggerOnce(t *testing.T) { + var callCount int32 = 0 + + // 模拟 ProcessOrderLottery 的调用 + processOrderLottery := func() { + atomic.AddInt32(&callCount, 1) + } + + // 模拟订单状态 + orderStatus := int32(2) + drawMode := "instant" + + // 执行触发逻辑(使用辅助函数,避免重复代码) + if shouldTriggerInstantDraw(orderStatus, drawMode) { + go processOrderLottery() + } + + // 等待 goroutine 完成 + time.Sleep(100 * time.Millisecond) + + // 验证只调用一次 + if callCount != 1 { + t.Errorf("ProcessOrderLottery 应该只被调用 1 次,实际调用了 %d 次", callCount) + } +} diff --git a/internal/api/activity/lottery_helper.go b/internal/api/activity/lottery_helper.go index 0ab3253..7f2734b 100644 --- a/internal/api/activity/lottery_helper.go +++ b/internal/api/activity/lottery_helper.go @@ -51,7 +51,7 @@ func (h *handler) applyCouponWithCap(ctx core.Context, userID int64, order *mode sc.discount_value FROM user_coupons uc INNER JOIN system_coupons sc ON uc.coupon_id = sc.id AND sc.status = 1 - WHERE uc.id = ? AND uc.user_id = ? AND uc.status = 1 + WHERE uc.id = ? AND uc.user_id = ? AND uc.status IN (1, 4) LIMIT 1 `, userCouponID, userID).Scan(&result).Error @@ -82,9 +82,6 @@ func (h *handler) applyCouponWithCap(ctx core.Context, userID int64, order *mode switch result.DiscountType { case 1: // 金额券 bal := result.BalanceAmount - if bal <= 0 { - bal = result.DiscountValue - } if bal > 0 { if bal > remainingCap { applied = remainingCap @@ -125,6 +122,46 @@ func (h *handler) applyCouponWithCap(ctx core.Context, userID int64, order *mode return applied } +// preDeductCouponInTx 在事务中预扣优惠券余额 +// 功能:原子性地扣减余额并设置 status=4(预扣中),防止并发超额使用 +// 参数: +// - ctx:请求上下文 +// - tx:数据库事务(必须在事务中调用) +// - userID:用户ID +// - userCouponID:用户持券ID +// - appliedAmount:要预扣的金额(分) +// - orderID:关联的订单ID +// +// 返回:是否成功预扣 +func (h *handler) preDeductCouponInTx(ctx core.Context, txDB interface { + Exec(sql string, values ...interface{}) interface { + RowsAffected() int64 + Error() error + } +}, userID int64, userCouponID int64, appliedAmount int64, orderID int64) bool { + if appliedAmount <= 0 || userCouponID <= 0 { + return false + } + now := time.Now() + + // 原子更新:扣减余额 + 设置状态为预扣中(4) + 关联订单 + // 条件:余额足够 且 状态为未使用(1)或使用中(4,支持同一券多订单分批扣减场景,但需余额足够) + result := txDB.Exec(` + UPDATE user_coupons + SET balance_amount = balance_amount - ?, + status = CASE WHEN balance_amount - ? <= 0 THEN 4 ELSE 4 END, + used_order_id = ?, + used_at = ? + WHERE id = ? AND user_id = ? AND balance_amount >= ? AND status IN (1, 4) + `, appliedAmount, appliedAmount, orderID, now, userCouponID, userID, appliedAmount) + + if result.Error() != nil { + return false + } + + return result.RowsAffected() > 0 +} + // updateUserCouponAfterApply 应用后更新用户券(扣减余额或核销) // 功能:根据订单 remark 中记录的 applied_amount, // @@ -154,7 +191,7 @@ func (h *handler) updateUserCouponAfterApply(ctx core.Context, userID int64, ord sc.discount_value FROM user_coupons uc INNER JOIN system_coupons sc ON uc.coupon_id = sc.id AND sc.status = 1 - WHERE uc.id = ? AND uc.user_id = ? AND uc.status = 1 + WHERE uc.id = ? AND uc.user_id = ? AND uc.status IN (1, 4) LIMIT 1 `, userCouponID, userID).Scan(&result).Error @@ -274,3 +311,14 @@ func parseIssueIDFromRemark(remarkStr string) int64 { func parseCountFromRemark(remarkStr string) int64 { return remark.Parse(remarkStr).Count } + +// shouldTriggerInstantDraw 判断是否应该触发即时开奖 +// 功能:封装即时开奖触发条件判断,避免条件重复 +// 参数: +// - orderStatus:订单状态(2=已支付) +// - drawMode:开奖模式("instant"=即时开奖) +// +// 返回:是否应该触发即时开奖 +func shouldTriggerInstantDraw(orderStatus int32, drawMode string) bool { + return orderStatus == 2 && drawMode == "instant" +} diff --git a/internal/api/activity/matching_game_app.go b/internal/api/activity/matching_game_app.go index 90cbd18..4d050c5 100644 --- a/internal/api/activity/matching_game_app.go +++ b/internal/api/activity/matching_game_app.go @@ -545,14 +545,18 @@ func (h *handler) CheckMatchingGame() core.HandlerFunc { zap.Bool("is_ok", scopeOK)) if scopeOK { - cardToVoid = icID + // Fix: Don't set cardToVoid immediately. Only set it if an effect is actually applied. + + // Double reward if ic.EffectType == 1 && ic.RewardMultiplierX1000 >= 2000 { - // Double reward + cardToVoid = icID // Mark for consumption h.logger.Info("道具卡-CheckMatchingGame: 应用双倍奖励", zap.Int32("multiplier", ic.RewardMultiplierX1000)) finalQuantity = 2 finalRemark += "(倍数)" } else if ic.EffectType == 2 && ic.BoostRateX1000 > 0 { - // Probability boost - try to upgrade to better reward + // Probability boost + cardToVoid = icID // Mark for consumption (even if RNG fails, the card is "used") + h.logger.Debug("道具卡-CheckMatchingGame: 应用概率提升", zap.Int32("boost_rate", ic.BoostRateX1000)) allRewards, _ := h.readDB.ActivityRewardSettings.WithContext(ctx.RequestContext()).Where( h.readDB.ActivityRewardSettings.IssueID.Eq(game.IssueID), @@ -593,6 +597,11 @@ func (h *handler) CheckMatchingGame() core.HandlerFunc { } else { h.logger.Debug("道具卡-CheckMatchingGame: 未找到更好的奖品可升级", zap.Int64("current_score", candidate.MinScore)) } + } else { + // Effect not recognized or params too low + h.logger.Warn("道具卡-CheckMatchingGame: 效果类型未知或参数无效,不消耗卡片", + zap.Int32("effect_type", ic.EffectType), + zap.Int32("multiplier", ic.RewardMultiplierX1000)) } } else { h.logger.Debug("道具卡-CheckMatchingGame: 范围校验失败") diff --git a/internal/api/activity/matching_game_app_test.go b/internal/api/activity/matching_game_app_test.go index f09266c..ac277ac 100644 --- a/internal/api/activity/matching_game_app_test.go +++ b/internal/api/activity/matching_game_app_test.go @@ -7,31 +7,31 @@ import ( // TestSelectRewardExact 测试对对碰选奖逻辑:精确匹配 TotalPairs == MinScore func TestSelectRewardExact(t *testing.T) { - // 模拟奖品设置 + // 模拟奖品设置 (使用 Level 作为标识,因为 ActivityRewardSettings 没有 Name 字段) rewards := []*model.ActivityRewardSettings{ - {ID: 1, Name: "奖品A-10对", MinScore: 10, Quantity: 5}, - {ID: 2, Name: "奖品B-20对", MinScore: 20, Quantity: 5}, - {ID: 3, Name: "奖品C-30对", MinScore: 30, Quantity: 5}, - {ID: 4, Name: "奖品D-40对", MinScore: 40, Quantity: 5}, - {ID: 5, Name: "奖品E-45对", MinScore: 45, Quantity: 5}, + {ID: 1, Level: 1, MinScore: 10, Quantity: 5}, + {ID: 2, Level: 2, MinScore: 20, Quantity: 5}, + {ID: 3, Level: 3, MinScore: 30, Quantity: 5}, + {ID: 4, Level: 4, MinScore: 40, Quantity: 5}, + {ID: 5, Level: 5, MinScore: 45, Quantity: 5}, } testCases := []struct { name string totalPairs int64 expectReward *int64 // nil = 无匹配 - expectName string + expectLevel int32 }{ - {"精确匹配10对", 10, ptr(int64(1)), "奖品A-10对"}, - {"精确匹配20对", 20, ptr(int64(2)), "奖品B-20对"}, - {"精确匹配30对", 30, ptr(int64(3)), "奖品C-30对"}, - {"精确匹配40对", 40, ptr(int64(4)), "奖品D-40对"}, - {"精确匹配45对", 45, ptr(int64(5)), "奖品E-45对"}, - {"15对-无匹配", 15, nil, ""}, - {"25对-无匹配", 25, nil, ""}, - {"35对-无匹配", 35, nil, ""}, - {"50对-无匹配", 50, nil, ""}, - {"0对-无匹配", 0, nil, ""}, + {"精确匹配10对", 10, ptr(int64(1)), 1}, + {"精确匹配20对", 20, ptr(int64(2)), 2}, + {"精确匹配30对", 30, ptr(int64(3)), 3}, + {"精确匹配40对", 40, ptr(int64(4)), 4}, + {"精确匹配45对", 45, ptr(int64(5)), 5}, + {"15对-无匹配", 15, nil, 0}, + {"25对-无匹配", 25, nil, 0}, + {"35对-无匹配", 35, nil, 0}, + {"50对-无匹配", 50, nil, 0}, + {"0对-无匹配", 0, nil, 0}, } for _, tc := range testCases { @@ -40,15 +40,15 @@ func TestSelectRewardExact(t *testing.T) { if tc.expectReward == nil { if candidate != nil { - t.Errorf("期望无匹配,但得到奖品: %s (ID=%d)", candidate.Name, candidate.ID) + t.Errorf("期望无匹配,但得到奖品: Level=%d (ID=%d)", candidate.Level, candidate.ID) } } else { if candidate == nil { t.Errorf("期望匹配奖品ID=%d,但无匹配", *tc.expectReward) } else if candidate.ID != *tc.expectReward { t.Errorf("期望奖品ID=%d,实际=%d", *tc.expectReward, candidate.ID) - } else if candidate.Name != tc.expectName { - t.Errorf("期望奖品名=%s,实际=%s", tc.expectName, candidate.Name) + } else if candidate.Level != tc.expectLevel { + t.Errorf("期望奖品Level=%d,实际=%d", tc.expectLevel, candidate.Level) } } }) @@ -58,14 +58,14 @@ func TestSelectRewardExact(t *testing.T) { // TestSelectRewardWithZeroQuantity 测试库存为0时不匹配 func TestSelectRewardWithZeroQuantity(t *testing.T) { rewards := []*model.ActivityRewardSettings{ - {ID: 1, Name: "奖品A-10对", MinScore: 10, Quantity: 0}, // 库存为0 - {ID: 2, Name: "奖品B-20对", MinScore: 20, Quantity: 5}, + {ID: 1, Level: 1, MinScore: 10, Quantity: 0}, // 库存为0 + {ID: 2, Level: 2, MinScore: 20, Quantity: 5}, } // 即使精确匹配,库存为0也不应匹配 candidate := selectRewardExact(rewards, 10) if candidate != nil { - t.Errorf("库存为0时不应匹配,但得到: %s", candidate.Name) + t.Errorf("库存为0时不应匹配,但得到: Level=%d", candidate.Level) } // 库存>0应正常匹配 diff --git a/internal/api/activity/matching_game_helper.go b/internal/api/activity/matching_game_helper.go index ba37206..7401beb 100644 --- a/internal/api/activity/matching_game_helper.go +++ b/internal/api/activity/matching_game_helper.go @@ -65,10 +65,73 @@ func (h *handler) startMatchingGameCleanup() { }) } +// autoCheckDatabaseFallback 数据库扫描兜底(防止Redis缓存过期导致漏单) +func (h *handler) autoCheckDatabaseFallback() { + ctx := context.Background() + + // 1. 查询 30分钟前~24小时内 已支付 但 未开奖 的对对碰订单 (SourceType=3) + // 这个时间窗口是为了避开正常游戏中的订单 (Redis TTL 30m) + startTime := time.Now().Add(-24 * time.Hour) + endTime := time.Now().Add(-30 * time.Minute) + + // 使用 left join 排除已有日志的订单 + var orderNos []string + err := h.readDB.Orders.WithContext(ctx).UnderlyingDB().Raw(` + SELECT o.order_no + FROM orders o + LEFT JOIN activity_draw_logs l ON o.id = l.order_id + WHERE o.source_type = 3 + AND o.status = 2 + AND o.created_at BETWEEN ? AND ? + AND l.id IS NULL + `, startTime, endTime).Scan(&orderNos).Error + + if err != nil { + h.logger.Error("对对碰兜底扫描: 查询失败", zap.Error(err)) + return + } + + if len(orderNos) == 0 { + return + } + + h.logger.Info("对对碰兜底扫描: 发现异常订单", zap.Int("count", len(orderNos))) + + for _, orderNo := range orderNos { + // 2. 加载订单详情 + order, err := h.readDB.Orders.WithContext(ctx).Where(h.readDB.Orders.OrderNo.Eq(orderNo)).First() + if err != nil || order == nil { + continue + } + + // 3. 重构游戏状态 + // 我们需要从 Seed, Position 等信息重构 Memory Graph + game, err := h.activity.ReconstructMatchingGame(ctx, orderNo) + if err != nil { + h.logger.Error("对对碰兜底扫描: 游戏状态重构失败", zap.String("order_no", orderNo), zap.Error(err)) + continue + } + + // 4. 重构 GameID (模拟) + // 注意:原始 GameID 可能丢失,这里我们并不真的需要精确的 Request GameID, + // 因为 doAutoCheck 主要依赖 game 对象和 OrderID。 + // 但为了锁的唯一性,我们使用 MG_FALLBACK_{OrderID} + fakeGameID := fmt.Sprintf("FALLBACK_%d", order.ID) + + h.logger.Info("对对碰兜底扫描: 触发补单", zap.String("order_no", orderNo)) + h.doAutoCheck(ctx, fakeGameID, game, order) + } +} + // autoCheckExpiredGames 扫描超时未结算的对对碰游戏并自动开奖 func (h *handler) autoCheckExpiredGames() { ctx := context.Background() + // 0. 执行数据库兜底扫描 (低频执行,例如每次 autoCheck 都跑,或者加计数器) + // 由于 autoCheckHelper 是每3分钟跑一次,这里直接调用损耗可控 + // 且查询走了索引 (created_at) + h.autoCheckDatabaseFallback() + // 1. 扫描 Redis 中所有 matching_game key keys, err := h.redis.Keys(ctx, activitysvc.MatchingGameKeyPrefix+"*").Result() if err != nil { diff --git a/internal/api/admin/activities_admin.go b/internal/api/admin/activities_admin.go index 2644592..dcefc5b 100644 --- a/internal/api/admin/activities_admin.go +++ b/internal/api/admin/activities_admin.go @@ -464,6 +464,7 @@ type activityItem struct { Status int32 `json:"status"` PriceDraw int64 `json:"price_draw"` IsBoss int32 `json:"is_boss"` + PlayType string `json:"play_type"` } type listActivitiesResponse struct { @@ -544,6 +545,7 @@ func (h *handler) ListActivities() core.HandlerFunc { Status: v.Status, PriceDraw: v.PriceDraw, IsBoss: v.IsBoss, + PlayType: v.PlayType, } } ctx.Payload(res) diff --git a/internal/api/admin/blacklist_admin.go b/internal/api/admin/blacklist_admin.go new file mode 100644 index 0000000..88e0a8f --- /dev/null +++ b/internal/api/admin/blacklist_admin.go @@ -0,0 +1,319 @@ +package admin + +import ( + "net/http" + "strconv" + + "bindbox-game/internal/code" + "bindbox-game/internal/pkg/core" + "bindbox-game/internal/pkg/validation" + "bindbox-game/internal/repository/mysql/model" +) + +// ========== 黑名单管理 ========== + +type addBlacklistRequest struct { + DouyinUserID string `json:"douyin_user_id" binding:"required"` + Reason string `json:"reason"` +} + +type blacklistResponse struct { + ID int64 `json:"id"` + DouyinUserID string `json:"douyin_user_id"` + Reason string `json:"reason"` + OperatorID int64 `json:"operator_id"` + Status int32 `json:"status"` + CreatedAt string `json:"created_at"` +} + +type listBlacklistRequest struct { + Page int `form:"page"` + PageSize int `form:"page_size"` + Keyword string `form:"keyword"` +} + +type listBlacklistResponse struct { + List []blacklistResponse `json:"list"` + Total int64 `json:"total"` + Page int `json:"page"` + PageSize int `json:"page_size"` +} + +// ListBlacklist 获取黑名单列表 +// @Summary 获取黑名单列表 +// @Description 获取抖音用户黑名单列表,支持分页和关键词搜索 +// @Tags 管理端.黑名单 +// @Accept json +// @Produce json +// @Param page query int false "页码" default(1) +// @Param page_size query int false "每页数量" default(20) +// @Param keyword query string false "搜索关键词(抖音ID)" +// @Success 200 {object} listBlacklistResponse +// @Failure 400 {object} code.Failure +// @Router /api/admin/blacklist [get] +// @Security LoginVerifyToken +func (h *handler) ListBlacklist() core.HandlerFunc { + return func(ctx core.Context) { + req := new(listBlacklistRequest) + if err := ctx.ShouldBindQuery(req); err != nil { + ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, validation.Error(err))) + return + } + + if req.Page <= 0 { + req.Page = 1 + } + if req.PageSize <= 0 { + req.PageSize = 20 + } + + db := h.repo.GetDbR().WithContext(ctx.RequestContext()). + Table("douyin_blacklist"). + Where("status = 1") + + if req.Keyword != "" { + db = db.Where("douyin_user_id LIKE ?", "%"+req.Keyword+"%") + } + + var total int64 + if err := db.Count(&total).Error; err != nil { + ctx.AbortWithError(core.Error(http.StatusInternalServerError, code.ServerError, err.Error())) + return + } + + var list []model.DouyinBlacklist + if err := db.Order("id DESC"). + Offset((req.Page - 1) * req.PageSize). + Limit(req.PageSize). + Find(&list).Error; err != nil { + ctx.AbortWithError(core.Error(http.StatusInternalServerError, code.ServerError, err.Error())) + return + } + + rsp := &listBlacklistResponse{ + List: make([]blacklistResponse, len(list)), + Total: total, + Page: req.Page, + PageSize: req.PageSize, + } + + for i, item := range list { + rsp.List[i] = blacklistResponse{ + ID: item.ID, + DouyinUserID: item.DouyinUserID, + Reason: item.Reason, + OperatorID: item.OperatorID, + Status: item.Status, + CreatedAt: item.CreatedAt.Format("2006-01-02 15:04:05"), + } + } + + ctx.Payload(rsp) + } +} + +// AddBlacklist 添加黑名单 +// @Summary 添加黑名单 +// @Description 将抖音用户添加到黑名单 +// @Tags 管理端.黑名单 +// @Accept json +// @Produce json +// @Param body body addBlacklistRequest true "请求参数" +// @Success 200 {object} blacklistResponse +// @Failure 400 {object} code.Failure +// @Router /api/admin/blacklist [post] +// @Security LoginVerifyToken +func (h *handler) AddBlacklist() core.HandlerFunc { + return func(ctx core.Context) { + req := new(addBlacklistRequest) + if err := ctx.ShouldBindJSON(req); err != nil { + ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, validation.Error(err))) + return + } + + // 检查是否已在黑名单 + var existCount int64 + h.repo.GetDbR().WithContext(ctx.RequestContext()). + Table("douyin_blacklist"). + Where("douyin_user_id = ? AND status = 1", req.DouyinUserID). + Count(&existCount) + + if existCount > 0 { + ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, "该用户已在黑名单中")) + return + } + + operatorID := int64(0) + if ctx.SessionUserInfo().Id > 0 { + operatorID = int64(ctx.SessionUserInfo().Id) + } + + blacklist := &model.DouyinBlacklist{ + DouyinUserID: req.DouyinUserID, + Reason: req.Reason, + OperatorID: operatorID, + Status: 1, + } + + if err := h.repo.GetDbW().WithContext(ctx.RequestContext()).Create(blacklist).Error; err != nil { + ctx.AbortWithError(core.Error(http.StatusInternalServerError, code.ServerError, err.Error())) + return + } + + ctx.Payload(&blacklistResponse{ + ID: blacklist.ID, + DouyinUserID: blacklist.DouyinUserID, + Reason: blacklist.Reason, + OperatorID: blacklist.OperatorID, + Status: blacklist.Status, + CreatedAt: blacklist.CreatedAt.Format("2006-01-02 15:04:05"), + }) + } +} + +// RemoveBlacklist 移除黑名单 +// @Summary 移除黑名单 +// @Description 将用户从黑名单中移除(软删除,status设为0) +// @Tags 管理端.黑名单 +// @Accept json +// @Produce json +// @Param id path integer true "黑名单ID" +// @Success 200 {object} simpleMessageResponse +// @Failure 400 {object} code.Failure +// @Router /api/admin/blacklist/{id} [delete] +// @Security LoginVerifyToken +func (h *handler) RemoveBlacklist() core.HandlerFunc { + return func(ctx core.Context) { + idStr := ctx.Param("id") + id, err := strconv.ParseInt(idStr, 10, 64) + if err != nil { + ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, "无效的ID")) + return + } + + result := h.repo.GetDbW().WithContext(ctx.RequestContext()). + Table("douyin_blacklist"). + Where("id = ?", id). + Update("status", 0) + + if result.Error != nil { + ctx.AbortWithError(core.Error(http.StatusInternalServerError, code.ServerError, result.Error.Error())) + return + } + + if result.RowsAffected == 0 { + ctx.AbortWithError(core.Error(http.StatusNotFound, code.ParamBindError, "黑名单记录不存在")) + return + } + + ctx.Payload(&simpleMessageResponse{Message: "移除成功"}) + } +} + +// CheckBlacklist 检查用户是否在黑名单 +// @Summary 检查黑名单状态 +// @Description 检查指定抖音用户是否在黑名单中 +// @Tags 管理端.黑名单 +// @Accept json +// @Produce json +// @Param douyin_user_id query string true "抖音用户ID" +// @Success 200 {object} map[string]bool +// @Failure 400 {object} code.Failure +// @Router /api/admin/blacklist/check [get] +// @Security LoginVerifyToken +func (h *handler) CheckBlacklist() core.HandlerFunc { + return func(ctx core.Context) { + douyinUserID := ctx.RequestInputParams().Get("douyin_user_id") + if douyinUserID == "" { + ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, "抖音用户ID不能为空")) + return + } + + var count int64 + h.repo.GetDbR().WithContext(ctx.RequestContext()). + Table("douyin_blacklist"). + Where("douyin_user_id = ? AND status = 1", douyinUserID). + Count(&count) + + ctx.Payload(map[string]any{ + "douyin_user_id": douyinUserID, + "is_blacklisted": count > 0, + }) + } +} + +// BatchAddBlacklist 批量添加黑名单 +// @Summary 批量添加黑名单 +// @Description 批量将抖音用户添加到黑名单 +// @Tags 管理端.黑名单 +// @Accept json +// @Produce json +// @Param body body batchAddBlacklistRequest true "请求参数" +// @Success 200 {object} batchAddBlacklistResponse +// @Failure 400 {object} code.Failure +// @Router /api/admin/blacklist/batch [post] +// @Security LoginVerifyToken +func (h *handler) BatchAddBlacklist() core.HandlerFunc { + return func(ctx core.Context) { + var req struct { + DouyinUserIDs []string `json:"douyin_user_ids" binding:"required"` + Reason string `json:"reason"` + } + + if err := ctx.ShouldBindJSON(&req); err != nil { + ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, validation.Error(err))) + return + } + + if len(req.DouyinUserIDs) == 0 { + ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, "抖音用户ID列表不能为空")) + return + } + + // 获取操作人ID + operatorID := int64(0) + if ctx.SessionUserInfo().Id > 0 { + operatorID = int64(ctx.SessionUserInfo().Id) + } + + // 查询已存在的黑名单 + var existingIDs []string + h.repo.GetDbR().WithContext(ctx.RequestContext()). + Table("douyin_blacklist"). + Where("douyin_user_id IN ? AND status = 1", req.DouyinUserIDs). + Pluck("douyin_user_id", &existingIDs) + + existMap := make(map[string]bool) + for _, id := range existingIDs { + existMap[id] = true + } + + // 过滤出需要新增的 + var toAdd []model.DouyinBlacklist + for _, uid := range req.DouyinUserIDs { + if !existMap[uid] { + toAdd = append(toAdd, model.DouyinBlacklist{ + DouyinUserID: uid, + Reason: req.Reason, + OperatorID: operatorID, + Status: 1, + }) + } + } + + addedCount := 0 + if len(toAdd) > 0 { + if err := h.repo.GetDbW().WithContext(ctx.RequestContext()).Create(&toAdd).Error; err != nil { + ctx.AbortWithError(core.Error(http.StatusInternalServerError, code.ServerError, err.Error())) + return + } + addedCount = len(toAdd) + } + + ctx.Payload(map[string]any{ + "total_requested": len(req.DouyinUserIDs), + "added": addedCount, + "skipped": len(req.DouyinUserIDs) - addedCount, + }) + } +} diff --git a/internal/api/admin/dashboard_activity.go b/internal/api/admin/dashboard_activity.go index dce7d04..88333d3 100644 --- a/internal/api/admin/dashboard_activity.go +++ b/internal/api/admin/dashboard_activity.go @@ -27,6 +27,9 @@ type activityProfitLossItem struct { ActivityName string `json:"activity_name"` Status int32 `json:"status"` DrawCount int64 `json:"draw_count"` + GamePassCount int64 `json:"game_pass_count"` // 次卡抽奖次数 + PaymentCount int64 `json:"payment_count"` // 现金/优惠券抽奖次数 + RefundCount int64 `json:"refund_count"` // 退款/取消抽奖次数 PlayerCount int64 `json:"player_count"` TotalRevenue int64 `json:"total_revenue"` // 实际支付金额 (分) TotalDiscount int64 `json:"total_discount"` // 优惠券抵扣金额 (分) @@ -121,48 +124,77 @@ func (h *handler) DashboardActivityProfitLoss() core.HandlerFunc { } } - // 2. 统计抽奖次数和人数 (通过 activity_draw_logs 关联 activity_issues) + // 2. 统计抽奖次数和人数 (通过 activity_draw_logs 关联 activity_issues 和 orders) type drawStat struct { - ActivityID int64 - DrawCount int64 - PlayerCount int64 + ActivityID int64 + TotalCount int64 + GamePassCount int64 + PaymentCount int64 + RefundCount int64 + PlayerCount int64 } var drawStats []drawStat db.Table(model.TableNameActivityDrawLogs). - Select("activity_issues.activity_id, COUNT(activity_draw_logs.id) as draw_count, COUNT(DISTINCT activity_draw_logs.user_id) as player_count"). + Select(` + activity_issues.activity_id, + COUNT(activity_draw_logs.id) as total_count, + SUM(CASE WHEN orders.status = 2 AND (orders.source_type = 4 OR orders.order_no LIKE 'GP%') THEN 1 ELSE 0 END) as game_pass_count, + SUM(CASE WHEN orders.status = 2 AND NOT (orders.source_type = 4 OR orders.order_no LIKE 'GP%') THEN 1 ELSE 0 END) as payment_count, + SUM(CASE WHEN orders.status IN (3, 4) THEN 1 ELSE 0 END) as refund_count, + COUNT(DISTINCT CASE WHEN orders.status = 2 THEN activity_draw_logs.user_id END) as player_count + `). Joins("JOIN activity_issues ON activity_issues.id = activity_draw_logs.issue_id"). + Joins("LEFT JOIN orders ON orders.id = activity_draw_logs.order_id"). Where("activity_issues.activity_id IN ?", activityIDs). Group("activity_issues.activity_id"). Scan(&drawStats) for _, s := range drawStats { if item, ok := activityMap[s.ActivityID]; ok { - item.DrawCount = s.DrawCount + item.DrawCount = s.GamePassCount + s.PaymentCount // 仅统计有效抽奖(次卡+支付) + item.GamePassCount = s.GamePassCount + item.PaymentCount = s.PaymentCount + item.RefundCount = s.RefundCount item.PlayerCount = s.PlayerCount } } // 3. 统计营收和优惠券抵扣 (通过 orders 关联 activity_draw_logs) - // BUG修复:排除已退款订单(status=4) + // 3. 统计营收和优惠券抵扣 (通过 orders 关联 activity_draw_logs) + // BUG修复:排除已退款订单(status=4)。 + // 注意: MySQL SUM()运算涉及除法时会返回Decimal类型,需要Scan到float64 type revenueStat struct { ActivityID int64 - TotalRevenue int64 - TotalDiscount int64 + TotalRevenue float64 + TotalDiscount float64 } var revenueStats []revenueStat - // 修正: 先找到每个订单对应的一个 activity_id (去重),再关联 orders 统计 actual_amount。 - // 避免一个订单包含多个 draw logs 时导致 orders.actual_amount 被重复累加。 - // 子查询: SELECT order_id, MAX(issue_id) as issue_id FROM activity_draw_logs GROUP BY order_id - // 然后通过 issue_id 关联 activity_issues 找到 activity_id + // 修正: 按抽奖次数比例分摊订单金额 (解决多活动订单归因问题) + // 逻辑: 活动分摊收入 = 订单实际金额 * (该活动在该订单中的抽奖次数 / 该订单总抽奖次数) var err error err = db.Table(model.TableNameOrders). - Select("activity_issues.activity_id, SUM(orders.actual_amount) as total_revenue, SUM(orders.discount_amount) as total_discount"). - Joins("JOIN (SELECT order_id, MAX(issue_id) as issue_id FROM activity_draw_logs GROUP BY order_id) dl ON dl.order_id = orders.id"). - Joins("JOIN activity_issues ON activity_issues.id = dl.issue_id"). - Where("orders.status = ? AND orders.status != ?", 2, 4). // 已支付且未退款 - Where("activity_issues.activity_id IN ?", activityIDs). - Group("activity_issues.activity_id"). + Select(` + order_activity_draws.activity_id, + SUM(1.0 * orders.actual_amount * order_activity_draws.draw_count / order_total_draws.total_count) as total_revenue, + SUM(CASE WHEN orders.source_type = 4 OR orders.order_no LIKE 'GP%' THEN 0 ELSE 1.0 * orders.discount_amount * order_activity_draws.draw_count / order_total_draws.total_count END) as total_discount + `). + // Subquery 1: Calculate draw counts per order per activity (and link to issue->activity) + Joins(`JOIN ( + SELECT activity_draw_logs.order_id, activity_issues.activity_id, COUNT(*) as draw_count + FROM activity_draw_logs + JOIN activity_issues ON activity_issues.id = activity_draw_logs.issue_id + GROUP BY activity_draw_logs.order_id, activity_issues.activity_id + ) as order_activity_draws ON order_activity_draws.order_id = orders.id`). + // Subquery 2: Calculate total draw counts per order + Joins(`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`). + Where("orders.status = ?", 2). // 已支付(排除待支付、取消、退款状态) + Where("order_activity_draws.activity_id IN ?", activityIDs). + Group("order_activity_draws.activity_id"). Scan(&revenueStats).Error if err != nil { @@ -171,12 +203,13 @@ func (h *handler) DashboardActivityProfitLoss() core.HandlerFunc { for _, s := range revenueStats { if item, ok := activityMap[s.ActivityID]; ok { - item.TotalRevenue = s.TotalRevenue - item.TotalDiscount = s.TotalDiscount + item.TotalRevenue = int64(s.TotalRevenue) + item.TotalDiscount = int64(s.TotalDiscount) } } - // 4. 统计成本 (通过 user_inventory 关联 products) + // 4. 统计成本 (通过 user_inventory 关联 products 和 orders) + // 修正:增加关联 orders 表,过滤掉已退款/取消的订单 (status!=2) type costStat struct { ActivityID int64 TotalCost int64 @@ -185,7 +218,9 @@ func (h *handler) DashboardActivityProfitLoss() core.HandlerFunc { db.Table(model.TableNameUserInventory). Select("user_inventory.activity_id, SUM(products.price) as total_cost"). Joins("JOIN products ON products.id = user_inventory.product_id"). + Joins("LEFT JOIN orders ON orders.id = user_inventory.order_id"). Where("user_inventory.activity_id IN ?", activityIDs). + Where("orders.status = ?", 2). // 仅统计已支付订单产生的成本 Group("user_inventory.activity_id"). Scan(&costStats) @@ -214,8 +249,9 @@ func (h *handler) DashboardActivityProfitLoss() core.HandlerFunc { Select("activity_issues.activity_id, COUNT(activity_draw_logs.id) as game_pass_draws"). Joins("JOIN activity_issues ON activity_issues.id = activity_draw_logs.issue_id"). Joins("JOIN orders ON orders.id = activity_draw_logs.order_id"). - Where("orders.status = ? AND orders.status != ?", 2, 4). // 已支付且未退款 - Where("orders.actual_amount = 0"). // 0元订单 = 次卡支付 + Where("orders.status = ? AND orders.status != ?", 2, 4). // 已支付且未退款 + Where("orders.actual_amount = 0"). // 0元订单 + Where("orders.source_type = 4 OR orders.order_no LIKE 'GP%'"). // 次数卡 (Lottery SourceType=4 OR Matching Game GP prefix) Where("activity_issues.activity_id IN ?", activityIDs). Group("activity_issues.activity_id"). Scan(&gamePassStats) @@ -376,6 +412,7 @@ func (h *handler) DashboardActivityLogs() core.HandlerFunc { OrderRemark string // BUG修复:增加remark字段用于解析次数卡使用信息 OrderNo string // 订单号 DrawCount int64 // 该订单的总抽奖次数(用于金额分摊) + UsedDrawLogID int64 // 道具卡实际使用的日志ID CreatedAt time.Time } @@ -403,6 +440,7 @@ func (h *handler) DashboardActivityLogs() core.HandlerFunc { COALESCE(orders.remark, '') as order_remark, COALESCE(orders.order_no, '') as order_no, COALESCE(order_draw_counts.draw_count, 1) as draw_count, + COALESCE(user_item_cards.used_draw_log_id, 0) as used_draw_log_id, activity_draw_logs.created_at `). Joins("JOIN activity_issues ON activity_issues.id = activity_draw_logs.issue_id"). @@ -410,8 +448,10 @@ func (h *handler) DashboardActivityLogs() core.HandlerFunc { Joins("LEFT JOIN activity_reward_settings ON activity_reward_settings.id = activity_draw_logs.reward_id"). Joins("LEFT JOIN products ON products.id = activity_reward_settings.product_id"). Joins("LEFT JOIN orders ON orders.id = activity_draw_logs.order_id"). - Joins("LEFT JOIN system_coupons ON system_coupons.id = orders.coupon_id"). - Joins("LEFT JOIN system_item_cards ON system_item_cards.id = orders.item_card_id"). + Joins("LEFT JOIN user_coupons ON user_coupons.id = orders.coupon_id"). + Joins("LEFT JOIN system_coupons ON system_coupons.id = user_coupons.coupon_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"). Joins("LEFT JOIN (SELECT order_id, COUNT(*) as draw_count FROM activity_draw_logs GROUP BY order_id) as order_draw_counts ON order_draw_counts.order_id = activity_draw_logs.order_id"). Where("activity_issues.activity_id = ?", activityID). Order("activity_draw_logs.id DESC"). @@ -454,7 +494,11 @@ func (h *handler) DashboardActivityLogs() core.HandlerFunc { } // 检查是否使用了道具卡 - if l.ItemCardID > 0 || l.ItemCardName != "" { + // BUG FIX: 仅当该条日志的 ID 等于 item_card 记录的 used_draw_log_id 时,才显示道具卡信息 + // 防止一个订单下的所有抽奖记录都显示 "双倍快乐水" + isCardValidForThisLog := (l.UsedDrawLogID == 0) || (l.UsedDrawLogID == l.ID) + + if (l.ItemCardID > 0 || l.ItemCardName != "") && isCardValidForThisLog { paymentDetails.ItemCardUsed = true paymentDetails.ItemCardName = l.ItemCardName if paymentDetails.ItemCardName == "" { diff --git a/internal/api/admin/dashboard_admin.go b/internal/api/admin/dashboard_admin.go index bb3f746..4b73a2b 100644 --- a/internal/api/admin/dashboard_admin.go +++ b/internal/api/admin/dashboard_admin.go @@ -189,10 +189,11 @@ type trendRequest struct { } type trendPoint struct { - Date string `json:"date"` - Value int64 `json:"value"` - Gmv int64 `json:"gmv"` - Orders int64 `json:"orders"` + Date string `json:"date"` + Value int64 `json:"value"` + Gmv int64 `json:"gmv"` + Orders int64 `json:"orders"` + NewUsers int64 `json:"newUsers"` } type salesDrawTrendResponse struct { @@ -1063,11 +1064,14 @@ func (h *handler) DashboardActivityPrizeAnalysis() core.HandlerFunc { } drawCounts := make(map[int64]int64) var dcRows []countResult - _ = h.readDB.ActivityDrawLogs.WithContext(ctx.RequestContext()).ReadDB(). + _ = h.readDB.ActivityDrawLogs.WithContext(ctx.RequestContext()).ReadDB().Debug(). Select(h.readDB.ActivityDrawLogs.IssueID.As("key"), h.readDB.ActivityDrawLogs.ID.Count().As("count")). + LeftJoin(h.readDB.Orders, h.readDB.Orders.ID.EqCol(h.readDB.ActivityDrawLogs.OrderID)). Where(h.readDB.ActivityDrawLogs.IssueID.In(issueIDs...)). + Where(h.readDB.Orders.Status.Eq(2)). Group(h.readDB.ActivityDrawLogs.IssueID). Scan(&dcRows) + for _, r := range dcRows { drawCounts[r.Key] = r.Count } @@ -1075,10 +1079,12 @@ func (h *handler) DashboardActivityPrizeAnalysis() core.HandlerFunc { // 5.2 每个奖品的中奖数 (RewardID -> Count) winCounts := make(map[int64]int64) var wcRows []countResult - _ = h.readDB.ActivityDrawLogs.WithContext(ctx.RequestContext()).ReadDB(). + _ = h.readDB.ActivityDrawLogs.WithContext(ctx.RequestContext()).ReadDB().Debug(). Select(h.readDB.ActivityDrawLogs.RewardID.As("key"), h.readDB.ActivityDrawLogs.ID.Count().As("count")). + LeftJoin(h.readDB.Orders, h.readDB.Orders.ID.EqCol(h.readDB.ActivityDrawLogs.OrderID)). Where(h.readDB.ActivityDrawLogs.IssueID.In(issueIDs...)). Where(h.readDB.ActivityDrawLogs.IsWinner.Eq(1)). + Where(h.readDB.Orders.Status.Eq(2)). Group(h.readDB.ActivityDrawLogs.RewardID). Scan(&wcRows) for _, r := range wcRows { @@ -1086,8 +1092,11 @@ func (h *handler) DashboardActivityPrizeAnalysis() core.HandlerFunc { } // 5.3 活动总参与人数 (Distinct UserID) - participants, _ := h.readDB.ActivityDrawLogs.WithContext(ctx.RequestContext()).ReadDB(). + + participants, _ := h.readDB.ActivityDrawLogs.WithContext(ctx.RequestContext()).ReadDB().Debug(). + LeftJoin(h.readDB.Orders, h.readDB.Orders.ID.EqCol(h.readDB.ActivityDrawLogs.OrderID)). Where(h.readDB.ActivityDrawLogs.IssueID.In(issueIDs...)). + Where(h.readDB.Orders.Status.Eq(2)). Distinct(h.readDB.ActivityDrawLogs.UserID). Count() @@ -1605,11 +1614,18 @@ func (h *handler) DashboardSalesDrawTrend() core.HandlerFunc { Where(h.readDB.Orders.PaidAt.Lte(b.End)). Count() + // 新注册用户数 + newUsers, _ := h.readDB.Users.WithContext(ctx.RequestContext()).ReadDB(). + Where(h.readDB.Users.CreatedAt.Gte(b.Start)). + Where(h.readDB.Users.CreatedAt.Lte(b.End)). + Count() + list[i] = trendPoint{ - Date: b.Label, - Value: draws, - Gmv: gmv.Total / 100, // 转为元 - Orders: orders, + Date: b.Label, + Value: draws, + Gmv: gmv.Total / 100, // 转为元 + Orders: orders, + NewUsers: newUsers, } } @@ -1630,6 +1646,8 @@ type productPerformanceItem struct { SeriesName string `json:"seriesName"` SalesCount int64 `json:"salesCount"` Amount int64 `json:"amount"` + Profit int64 `json:"profit"` + ProfitRate float64 `json:"profitRate"` ContributionRate float64 `json:"contributionRate"` InventoryTurnover float64 `json:"inventoryTurnover"` } @@ -1640,24 +1658,26 @@ func (h *handler) OperationsProductPerformance() core.HandlerFunc { // 按活动聚合抽奖数据 type drawRow struct { - ActivityID int64 - Count int64 - Winners int64 + ActivityID int64 `gorm:"column:activity_id"` + Count int64 `gorm:"column:count"` + TotalCost int64 `gorm:"column:total_cost"` } var rows []drawRow - // 统计抽奖日志,按活动分组 - _ = h.readDB.ActivityDrawLogs.WithContext(ctx.RequestContext()).ReadDB(). - LeftJoin(h.readDB.ActivityIssues, h.readDB.ActivityIssues.ID.EqCol(h.readDB.ActivityDrawLogs.IssueID)). - Where(h.readDB.ActivityDrawLogs.CreatedAt.Gte(s)). - Where(h.readDB.ActivityDrawLogs.CreatedAt.Lte(e)). + // 统计抽奖日志,按活动分组,并计算奖品成本 + _ = h.readDB.ActivityDrawLogs.WithContext(ctx.RequestContext()).UnderlyingDB(). + Joins("JOIN activity_issues ON activity_issues.id = activity_draw_logs.issue_id"). + Joins("LEFT JOIN activity_reward_settings ON activity_reward_settings.id = activity_draw_logs.reward_id"). + Joins("LEFT JOIN products ON products.id = activity_reward_settings.product_id"). + Where("activity_draw_logs.created_at >= ?", s). + Where("activity_draw_logs.created_at <= ?", e). Select( - h.readDB.ActivityIssues.ActivityID, - h.readDB.ActivityDrawLogs.ID.Count().As("count"), - h.readDB.ActivityDrawLogs.IsWinner.Sum().As("winners"), + "activity_issues.activity_id", + "COUNT(activity_draw_logs.id) as count", + "SUM(IF(activity_draw_logs.is_winner = 1, products.price, 0)) as total_cost", ). - Group(h.readDB.ActivityIssues.ActivityID). - Order(h.readDB.ActivityDrawLogs.ID.Count().Desc()). + Group("activity_issues.activity_id"). + Order("count DESC"). Limit(10). Scan(&rows) @@ -1707,9 +1727,16 @@ func (h *handler) OperationsProductPerformance() core.HandlerFunc { SeriesName: info.Name, SalesCount: r.Count, Amount: (r.Count * info.PriceDraw) / 100, // 转换为元 + Profit: (r.Count*info.PriceDraw - r.TotalCost) / 100, + ProfitRate: 0, ContributionRate: float64(int(contribution*10)) / 10.0, InventoryTurnover: float64(int(turnover*10)) / 10.0, } + if r.Count > 0 && info.PriceDraw > 0 { + revenue := r.Count * info.PriceDraw + pr := float64(revenue-r.TotalCost) / float64(revenue) * 100 + out[i].ProfitRate = float64(int(pr*10)) / 10.0 + } } ctx.Payload(out) diff --git a/internal/api/admin/dashboard_spending.go b/internal/api/admin/dashboard_spending.go index ba97c6d..2b35c32 100644 --- a/internal/api/admin/dashboard_spending.go +++ b/internal/api/admin/dashboard_spending.go @@ -24,26 +24,30 @@ type spendingLeaderboardItem struct { UserID int64 `json:"user_id"` Nickname string `json:"nickname"` Avatar string `json:"avatar"` - OrderCount int64 `json:"order_count"` - TotalSpending int64 `json:"total_spending"` // Total Paid Amount (Fen) - TotalPrizeValue int64 `json:"total_prize_value"` // Total Product Price (Fen) - 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 + OrderCount int64 `json:"-"` // Hidden + TotalSpending int64 `json:"-"` // Hidden + TotalPrizeValue int64 `json:"-"` // Hidden + TotalDiscount int64 `json:"total_discount"` // Total Coupon Discount (Fen) + TotalPoints int64 `json:"total_points"` // Total Points Discount (Fen) + GamePassCount int64 `json:"game_pass_count"` // Count of SourceType=4 + ItemCardCount int64 `json:"item_card_count"` // Count where ItemCardID > 0 // Breakdown by game type IchibanSpending int64 `json:"ichiban_spending"` IchibanPrize int64 `json:"ichiban_prize"` + IchibanProfit int64 `json:"ichiban_profit"` IchibanCount int64 `json:"ichiban_count"` InfiniteSpending int64 `json:"infinite_spending"` InfinitePrize int64 `json:"infinite_prize"` + InfiniteProfit int64 `json:"infinite_profit"` InfiniteCount int64 `json:"infinite_count"` MatchingSpending int64 `json:"matching_spending"` MatchingPrize int64 `json:"matching_prize"` + MatchingProfit int64 `json:"matching_profit"` MatchingCount int64 `json:"matching_count"` // 直播间统计 (source_type=5) LivestreamSpending int64 `json:"livestream_spending"` LivestreamPrize int64 `json:"livestream_prize"` + LivestreamProfit int64 `json:"livestream_profit"` LivestreamCount int64 `json:"livestream_count"` Profit int64 `json:"profit"` // Spending - PrizeValue @@ -137,10 +141,10 @@ func (h *handler) DashboardPlayerSpendingLeaderboard() core.HandlerFunc { h.logger.Info(fmt.Sprintf("SpendingLeaderboard SQL done: count=%d", len(stats))) // 2. Collect User IDs - userIDs := make([]int64, len(stats)) + userIDs := make([]int64, 0, len(stats)) statMap := make(map[int64]*spendingLeaderboardItem) - for i, s := range stats { - userIDs[i] = s.UserID + for _, s := range stats { + userIDs = append(userIDs, s.UserID) statMap[s.UserID] = &spendingLeaderboardItem{ UserID: s.UserID, TotalSpending: s.TotalAmount, @@ -155,11 +159,39 @@ func (h *handler) DashboardPlayerSpendingLeaderboard() core.HandlerFunc { InfiniteCount: s.InfiniteCount, MatchingSpending: s.MatchingSpending, MatchingCount: s.MatchingCount, - LivestreamSpending: s.LivestreamSpending, + LivestreamSpending: 0, // Will be updated from douyin_orders LivestreamCount: s.LivestreamCount, } } + // 2.1 Fetch Real Douyin Spending + if len(userIDs) > 0 { + type dyStat struct { + UserID int64 + Amount int64 + Count int64 + } + var dyStats []dyStat + dyQuery := h.repo.GetDbR().Table("douyin_orders"). + Select("CAST(local_user_id AS SIGNED) as user_id, SUM(actual_pay_amount) as amount, COUNT(*) as count"). + Where("local_user_id IN ?", userIDs). + Where("local_user_id != '' AND local_user_id != '0'") + + if req.RangeType != "all" { + dyQuery = dyQuery.Where("created_at >= ?", start).Where("created_at <= ?", end) + } + + if err := dyQuery.Group("local_user_id").Scan(&dyStats).Error; err == nil { + for _, ds := range dyStats { + if item, ok := statMap[ds.UserID]; ok { + item.LivestreamSpending = ds.Amount + item.LivestreamCount = ds.Count // Use real paid order count + item.TotalSpending += ds.Amount // Add to total since orders.total_amount was 0 for these + } + } + } + } + if len(userIDs) > 0 { // 3. Get User Info // Use h.readDB.Users (GEN) as it's simple @@ -195,15 +227,14 @@ func (h *handler) DashboardPlayerSpendingLeaderboard() core.HandlerFunc { } // Only include Holding (1) and Shipped/Used (3) items. Exclude Void/Decomposed (2). query = query.Where("user_inventory.status IN ?", []int{1, 3}). - Where("user_inventory.remark NOT LIKE ? AND user_inventory.remark NOT LIKE ?", "%redeemed%", "%void%") + Where("user_inventory.remark NOT LIKE ?", "%void%") err := query.Select(` user_inventory.user_id, SUM(products.price) as total_value, SUM(CASE WHEN activities.activity_category_id = 1 THEN products.price ELSE 0 END) as ichiban_prize, SUM(CASE WHEN activities.activity_category_id = 2 THEN products.price ELSE 0 END) as infinite_prize, - SUM(CASE WHEN activities.activity_category_id = 3 THEN products.price ELSE 0 END) as matching_prize, - SUM(CASE WHEN orders.source_type = 5 THEN products.price ELSE 0 END) as livestream_prize + SUM(CASE WHEN activities.activity_category_id = 3 THEN products.price ELSE 0 END) as matching_prize `). Group("user_inventory.user_id"). Scan(&invStats).Error @@ -215,18 +246,58 @@ func (h *handler) DashboardPlayerSpendingLeaderboard() core.HandlerFunc { item.IchibanPrize = is.IchibanPrize item.InfinitePrize = is.InfinitePrize item.MatchingPrize = is.MatchingPrize - item.LivestreamPrize = is.LivestreamPrize } } } + + // 4.1 Calculate Livestream Prize Value (From Draw Logs) + type lsStat struct { + UserID int64 + Amount int64 + } + var lsStats []lsStat + lsQuery := db.Table(model.TableNameLivestreamDrawLogs). + Joins("JOIN livestream_prizes ON livestream_prizes.id = livestream_draw_logs.prize_id"). + Joins("JOIN products ON products.id = livestream_prizes.product_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.is_refunded = 0") + + if req.RangeType != "all" { + lsQuery = lsQuery.Where("livestream_draw_logs.created_at >= ?", start). + Where("livestream_draw_logs.created_at <= ?", end) + } + + if err := lsQuery.Group("livestream_draw_logs.local_user_id").Scan(&lsStats).Error; err == nil { + for _, ls := range lsStats { + if item, ok := statMap[ls.UserID]; ok { + item.LivestreamPrize = ls.Amount + // item.TotalPrizeValue += ls.Amount // Already included in user_inventory + } + } + } + + // 4.2 Calculate Profit for each category + for _, item := range statMap { + item.IchibanProfit = item.IchibanSpending - item.IchibanPrize + item.InfiniteProfit = item.InfiniteSpending - item.InfinitePrize + item.MatchingProfit = item.MatchingSpending - item.MatchingPrize + item.LivestreamProfit = item.LivestreamSpending - item.LivestreamPrize + } } // 5. Calculate Profit and Final List list := make([]spendingLeaderboardItem, 0, len(statMap)) for _, item := range statMap { - item.Profit = item.TotalSpending - item.TotalPrizeValue - if item.TotalSpending > 0 { - item.ProfitRate = float64(item.Profit) / float64(item.TotalSpending) + // Calculate totals based on the 4 displayed categories to ensure UI consistency + calculatedSpending := item.IchibanSpending + item.InfiniteSpending + item.MatchingSpending + item.LivestreamSpending + calculatedProfit := item.IchibanProfit + item.InfiniteProfit + item.MatchingProfit + item.LivestreamProfit + + item.Profit = calculatedProfit + if calculatedSpending > 0 { + item.ProfitRate = float64(item.Profit) / float64(calculatedSpending) + } else { + item.ProfitRate = 0 } list = append(list, *item) } diff --git a/internal/api/admin/douyin_orders_admin.go b/internal/api/admin/douyin_orders_admin.go index 6b1ae5d..a410d4c 100644 --- a/internal/api/admin/douyin_orders_admin.go +++ b/internal/api/admin/douyin_orders_admin.go @@ -70,9 +70,11 @@ type douyinOrderItem struct { LocalUserID int64 `json:"local_user_id"` LocalUserNickname string `json:"local_user_nickname"` ActualReceiveAmount string `json:"actual_receive_amount"` + ActualPayAmount string `json:"actual_pay_amount"` PayTypeDesc string `json:"pay_type_desc"` Remark string `json:"remark"` UserNickname string `json:"user_nickname"` + ProductCount int64 `json:"product_count"` CreatedAt string `json:"created_at"` } @@ -129,9 +131,11 @@ func (h *handler) ListDouyinOrders() core.HandlerFunc { LocalUserID: uid, LocalUserNickname: userNicknameMap[uid], ActualReceiveAmount: formatAmount(o.ActualReceiveAmount), + ActualPayAmount: formatAmount(o.ActualPayAmount), PayTypeDesc: o.PayTypeDesc, Remark: o.Remark, UserNickname: o.UserNickname, + ProductCount: int64(o.ProductCount), CreatedAt: o.CreatedAt.Format("2006-01-02 15:04:05"), } } @@ -182,7 +186,7 @@ func getOrderStatusText(status int32) string { case 3: return "已发货" case 4: - return "已取消" + return "已退款/已取消" case 5: return "已完成" default: diff --git a/internal/api/admin/douyin_product_rewards.go b/internal/api/admin/douyin_product_rewards.go index 53e1775..03cf4e5 100644 --- a/internal/api/admin/douyin_product_rewards.go +++ b/internal/api/admin/douyin_product_rewards.go @@ -8,8 +8,6 @@ import ( "bindbox-game/internal/pkg/core" "bindbox-game/internal/pkg/validation" "bindbox-game/internal/repository/mysql/model" - - "gorm.io/datatypes" ) // ======== 抖店商品奖励规则 CRUD ======== @@ -114,7 +112,7 @@ func (h *handler) CreateDouyinProductReward() core.HandlerFunc { ProductID: req.ProductID, ProductName: req.ProductName, RewardType: req.RewardType, - RewardPayload: datatypes.JSON(req.RewardPayload), + RewardPayload: string(req.RewardPayload), Quantity: req.Quantity, Status: req.Status, } @@ -153,7 +151,7 @@ func (h *handler) UpdateDouyinProductReward() core.HandlerFunc { updates := map[string]any{ "product_name": req.ProductName, "reward_type": req.RewardType, - "reward_payload": datatypes.JSON(req.RewardPayload), + "reward_payload": string(req.RewardPayload), "quantity": req.Quantity, "status": req.Status, } diff --git a/internal/api/admin/livestream_admin.go b/internal/api/admin/livestream_admin.go index 32a5058..6619aeb 100644 --- a/internal/api/admin/livestream_admin.go +++ b/internal/api/admin/livestream_admin.go @@ -3,6 +3,7 @@ package admin import ( "net/http" "strconv" + "strings" "time" "bindbox-game/internal/code" @@ -10,6 +11,8 @@ import ( "bindbox-game/internal/pkg/validation" "bindbox-game/internal/repository/mysql/model" "bindbox-game/internal/service/livestream" + + "gorm.io/gorm" ) // ========== 直播间活动管理 ========== @@ -89,7 +92,7 @@ func (h *handler) CreateLivestreamActivity() core.HandlerFunc { StreamerContact: activity.StreamerContact, AccessCode: activity.AccessCode, DouyinProductID: activity.DouyinProductID, - TicketPrice: activity.TicketPrice, + TicketPrice: int64(activity.TicketPrice), Status: activity.Status, CreatedAt: activity.CreatedAt.Format("2006-01-02 15:04:05"), }) @@ -224,7 +227,7 @@ func (h *handler) ListLivestreamActivities() core.HandlerFunc { StreamerContact: a.StreamerContact, AccessCode: a.AccessCode, DouyinProductID: a.DouyinProductID, - TicketPrice: a.TicketPrice, + TicketPrice: int64(a.TicketPrice), Status: a.Status, CreatedAt: a.CreatedAt.Format("2006-01-02 15:04:05"), } @@ -273,7 +276,7 @@ func (h *handler) GetLivestreamActivity() core.HandlerFunc { StreamerContact: activity.StreamerContact, AccessCode: activity.AccessCode, DouyinProductID: activity.DouyinProductID, - TicketPrice: activity.TicketPrice, + TicketPrice: int64(activity.TicketPrice), Status: activity.Status, CreatedAt: activity.CreatedAt.Format("2006-01-02 15:04:05"), } @@ -484,14 +487,25 @@ type listLivestreamDrawLogsResponse struct { Total int64 `json:"total"` Page int `json:"page"` PageSize int `json:"page_size"` + Stats *livestreamDrawLogsStats `json:"stats,omitempty"` +} + +type livestreamDrawLogsStats struct { + UserCount int64 `json:"user_count"` + OrderCount int64 `json:"order_count"` + TotalRev int64 `json:"total_revenue"` // 总流水 + TotalRefund int64 `json:"total_refund"` + TotalCost int64 `json:"total_cost"` + NetProfit int64 `json:"net_profit"` } type listLivestreamDrawLogsRequest struct { - Page int `form:"page"` - PageSize int `form:"page_size"` - StartTime string `form:"start_time"` - EndTime string `form:"end_time"` - Keyword string `form:"keyword"` + Page int `form:"page"` + PageSize int `form:"page_size"` + StartTime string `form:"start_time"` + EndTime string `form:"end_time"` + Keyword string `form:"keyword"` + ExcludeUserIDs string `form:"exclude_user_ids"` // 逗号分隔的 UserIDs } // ListLivestreamDrawLogs 获取中奖记录 @@ -530,21 +544,39 @@ func (h *handler) ListLivestreamDrawLogs() core.HandlerFunc { pageSize = 20 } - // 解析时间范围 + // 解析时间范围 (支持 YYYY-MM-DD HH:mm:ss 和 YYYY-MM-DD) var startTime, endTime *time.Time if req.StartTime != "" { - if t, err := time.ParseInLocation("2006-01-02", req.StartTime, time.Local); err == nil { + // 尝试解析完整时间 + if t, err := time.ParseInLocation("2006-01-02 15:04:05", req.StartTime, time.Local); err == nil { + startTime = &t + } else if t, err := time.ParseInLocation("2006-01-02", req.StartTime, time.Local); err == nil { + // 只有日期,默认 00:00:00 startTime = &t } } if req.EndTime != "" { - if t, err := time.ParseInLocation("2006-01-02", req.EndTime, time.Local); err == nil { - // 结束时间设为当天结束 + if t, err := time.ParseInLocation("2006-01-02 15:04:05", req.EndTime, time.Local); err == nil { + endTime = &t + } else if t, err := time.ParseInLocation("2006-01-02", req.EndTime, time.Local); err == nil { + // 只有日期,设为当天结束 23:59:59.999 end := t.Add(24*time.Hour - time.Nanosecond) endTime = &end } } + // 解析排除用户ID + var excludeUIDs []int64 + if req.ExcludeUserIDs != "" { + parts := strings.Split(req.ExcludeUserIDs, ",") + for _, p := range parts { + p = strings.TrimSpace(p) + if val, err := strconv.ParseInt(p, 10, 64); err == nil && val > 0 { + excludeUIDs = append(excludeUIDs, val) + } + } + } + // 使用底层 GORM 直接查询以支持 keyword db := h.repo.GetDbR().Model(&model.LivestreamDrawLogs{}).Where("activity_id = ?", activityID) @@ -558,12 +590,115 @@ func (h *handler) ListLivestreamDrawLogs() core.HandlerFunc { keyword := "%" + req.Keyword + "%" db = db.Where("(user_nickname LIKE ? OR shop_order_id LIKE ? OR prize_name LIKE ?)", keyword, keyword, keyword) } + if len(excludeUIDs) > 0 { + db = db.Where("local_user_id NOT IN ?", excludeUIDs) + } var total int64 db.Count(&total) + // 计算统计数据 (仅当有数据时) + var stats *livestreamDrawLogsStats + if total > 0 { + stats = &livestreamDrawLogsStats{} + // 1. 统计用户数 + // 使用 Session() 避免污染主 db 对象 + db.Session(&gorm.Session{}).Select("COUNT(DISTINCT douyin_user_id)").Scan(&stats.UserCount) + + // 2. 获取所有相关的 douyin_order_id 和 prize_id,用于在内存中聚合金额和成本 + // 注意:如果数据量极大,这里可能有性能隐患。但考虑到这是后台查询且通常带有筛选,暂且全量拉取 ID。 + // 优化:只查需要的字段 + type logMeta struct { + DouyinOrderID int64 + PrizeID int64 + ShopOrderID string // 用于关联退款状态查 douyin_orders + } + var metas []logMeta + // 使用不带分页的 db 克隆 + if err := db.Session(&gorm.Session{}).Select("douyin_order_id, prize_id, shop_order_id").Scan(&metas).Error; err == nil { + orderIDs := make([]int64, 0, len(metas)) + distinctOrderIDs := make(map[int64]bool) + prizeIDCount := make(map[int64]int64) + + for _, m := range metas { + if !distinctOrderIDs[m.DouyinOrderID] { + distinctOrderIDs[m.DouyinOrderID] = true + orderIDs = append(orderIDs, m.DouyinOrderID) + } + } + stats.OrderCount = int64(len(orderIDs)) + + // 3. 查询订单金额和退款状态 + if len(orderIDs) > 0 { + var orders []model.DouyinOrders + // 分批查询防止 IN 子句过长? 暂时假设量级可控 + h.repo.GetDbR().Select("id, actual_pay_amount, order_status"). + Where("id IN ?", orderIDs).Find(&orders) + + orderRefundMap := make(map[int64]bool) + + for _, o := range orders { + // 统计营收 (总流水) + stats.TotalRev += int64(o.ActualPayAmount) + + if o.OrderStatus == 4 { // 已退款 + stats.TotalRefund += int64(o.ActualPayAmount) + orderRefundMap[o.ID] = true + } + } + + // 4. 统计成本 (剔除退款订单) + for _, m := range metas { + if !orderRefundMap[m.DouyinOrderID] { + prizeIDCount[m.PrizeID]++ + } + } + + // 计算奖品成本 (逻辑参考 GetLivestreamStats,简化版) + if len(prizeIDCount) > 0 { + prizeIDs := make([]int64, 0, len(prizeIDCount)) + for pid := range prizeIDCount { + prizeIDs = append(prizeIDs, pid) + } + + var prizes []model.LivestreamPrizes + h.repo.GetDbR().Where("id IN ?", prizeIDs).Find(&prizes) + + // 批量获取关联商品 + productIDs := make([]int64, 0) + for _, p := range prizes { + if p.CostPrice == 0 && p.ProductID > 0 { + productIDs = append(productIDs, p.ProductID) + } + } + productPriceMap := make(map[int64]int64) + if len(productIDs) > 0 { + var products []model.Products + h.repo.GetDbR().Select("id, price").Where("id IN ?", productIDs).Find(&products) + for _, prod := range products { + productPriceMap[prod.ID] = prod.Price + } + } + + for _, p := range prizes { + cost := p.CostPrice + if cost == 0 && p.ProductID > 0 { + cost = productPriceMap[p.ProductID] + } + count := prizeIDCount[p.ID] + stats.TotalCost += cost * count + } + } + } + } + stats.NetProfit = (stats.TotalRev - stats.TotalRefund) - stats.TotalCost + } + var logs []model.LivestreamDrawLogs - if err := db.Order("id DESC").Offset((page - 1) * pageSize).Limit(pageSize).Find(&logs).Error; err != nil { + // 重置 Select,确保查询 logs 时获取所有字段 (或者指定 default fields) + // db 对象如果被污染,这里需要显式清除 Select。使用 Session 应该能避免。 + // 安全起见,这里也可以用 db.Session(&gorm.Session{}) + if err := db.Session(&gorm.Session{}).Order("id DESC").Offset((page - 1) * pageSize).Limit(pageSize).Find(&logs).Error; err != nil { ctx.AbortWithError(core.Error(http.StatusInternalServerError, code.ServerError, err.Error())) return } @@ -573,6 +708,7 @@ func (h *handler) ListLivestreamDrawLogs() core.HandlerFunc { Total: total, Page: page, PageSize: pageSize, + Stats: stats, } for i, log := range logs { @@ -604,6 +740,7 @@ type livestreamCommitmentSummaryResponse struct { HasSeed bool `json:"has_seed"` LenSeed int `json:"len_seed_master"` LenHash int `json:"len_seed_hash"` + SeedHashHex string `json:"seed_hash_hex"` // 种子哈希的十六进制表示(可公开复制) } // GenerateLivestreamCommitment 生成直播间活动承诺 @@ -666,6 +803,7 @@ func (h *handler) GetLivestreamCommitmentSummary() core.HandlerFunc { HasSeed: summary.HasSeed, LenSeed: summary.LenSeed, LenHash: summary.LenHash, + SeedHashHex: summary.SeedHashHex, }) } } diff --git a/internal/api/admin/livestream_stats.go b/internal/api/admin/livestream_stats.go index 9d338be..69a81a0 100644 --- a/internal/api/admin/livestream_stats.go +++ b/internal/api/admin/livestream_stats.go @@ -8,16 +8,29 @@ import ( "bindbox-game/internal/code" "bindbox-game/internal/pkg/core" "bindbox-game/internal/repository/mysql/model" + "time" ) -type livestreamStatsResponse struct { - TotalRevenue int64 `json:"total_revenue"` // 总营收(分) - TotalRefund int64 `json:"total_refund"` // 总退款(分) - TotalCost int64 `json:"total_cost"` // 总成本(分) - NetProfit int64 `json:"net_profit"` // 净利润(分) +type dailyLivestreamStats struct { + Date string `json:"date"` // 日期 + TotalRevenue int64 `json:"total_revenue"` // 营收 + TotalRefund int64 `json:"total_refund"` // 退款 + TotalCost int64 `json:"total_cost"` // 成本 + NetProfit int64 `json:"net_profit"` // 净利润 + ProfitMargin float64 `json:"profit_margin"` // 利润率 OrderCount int64 `json:"order_count"` // 订单数 - RefundCount int64 `json:"refund_count"` // 退款数 - ProfitMargin float64 `json:"profit_margin"` // 利润率 % + RefundCount int64 `json:"refund_count"` // 退款单数 +} + +type livestreamStatsResponse struct { + TotalRevenue int64 `json:"total_revenue"` // 总营收(分) + TotalRefund int64 `json:"total_refund"` // 总退款(分) + TotalCost int64 `json:"total_cost"` // 总成本(分) + NetProfit int64 `json:"net_profit"` // 净利润(分) + OrderCount int64 `json:"order_count"` // 订单数 + RefundCount int64 `json:"refund_count"` // 退款数 + ProfitMargin float64 `json:"profit_margin"` // 利润率 % + Daily []dailyLivestreamStats `json:"daily"` // 每日明细 } // GetLivestreamStats 获取直播间盈亏统计 @@ -33,38 +46,124 @@ type livestreamStatsResponse struct { // @Security LoginVerifyToken func (h *handler) GetLivestreamStats() core.HandlerFunc { return func(ctx core.Context) { - activityID, err := strconv.ParseInt(ctx.Param("id"), 10, 64) - if err != nil || activityID <= 0 { + id, err := strconv.ParseInt(ctx.Param("id"), 10, 64) + if err != nil || id <= 0 { ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, "无效的活动ID")) return } + req := new(struct { + StartTime string `form:"start_time"` + EndTime string `form:"end_time"` + }) + _ = ctx.ShouldBindQuery(req) + + var startTime, endTime *time.Time + if req.StartTime != "" { + if t, err := time.ParseInLocation("2006-01-02", req.StartTime, time.Local); err == nil { + startTime = &t + } + } + if req.EndTime != "" { + if t, err := time.ParseInLocation("2006-01-02", req.EndTime, time.Local); err == nil { + end := t.Add(24*time.Hour - time.Nanosecond) + endTime = &end + } + } + // 1. 获取活动信息(门票价格) var activity model.LivestreamActivities - if err := h.repo.GetDbR().Where("id = ?", activityID).First(&activity).Error; err != nil { + if err := h.repo.GetDbR().Where("id = ?", id).First(&activity).Error; err != nil { ctx.AbortWithError(core.Error(http.StatusNotFound, code.ServerError, "活动不存在")) return } - ticketPrice := activity.TicketPrice + // ticketPrice 暂未使用,但在统计中可能作为参考,这里移除未使用的报错 - // 2. 从 livestream_draw_logs 统计抽奖次数 + // 2. 核心统计逻辑重构:从关联的订单表获取真实金额 + // 使用子查询或 Join 来获取不重复的订单金额,确保即便一次订单对应多次抽奖,金额也不重计 + var totalRevenue, orderCount int64 + // 统计营收:来自已参与过抽奖(产生过日志)且未退款的订单 (order_status != 4) + // 使用 actual_pay_amount (实付金额) + queryRevenue := ` + SELECT + CAST(IFNULL(SUM(distinct_orders.actual_pay_amount), 0) AS SIGNED) as rev, + COUNT(*) as cnt + FROM ( + SELECT DISTINCT o.id, o.actual_pay_amount + FROM douyin_orders o + JOIN livestream_draw_logs l ON o.id = l.douyin_order_id + WHERE l.activity_id = ? + ` + if startTime != nil { + queryRevenue += " AND l.created_at >= '" + startTime.Format("2006-01-02 15:04:05") + "'" + } + if endTime != nil { + queryRevenue += " AND l.created_at <= '" + endTime.Format("2006-01-02 15:04:05") + "'" + } + queryRevenue += ") as distinct_orders" + + _ = h.repo.GetDbR().Raw(queryRevenue, id).Row().Scan(&totalRevenue, &orderCount) + + // 统计退款:来自已参与过抽奖且标记为退款的订单 (order_status = 4) + var totalRefund, refundCount int64 + queryRefund := ` + SELECT + CAST(IFNULL(SUM(distinct_orders.actual_pay_amount), 0) AS SIGNED) as ref_amt, + COUNT(*) as ref_cnt + FROM ( + SELECT DISTINCT o.id, o.actual_pay_amount + FROM douyin_orders o + JOIN livestream_draw_logs l ON o.id = l.douyin_order_id + WHERE l.activity_id = ? AND o.order_status = 4 + ` + if startTime != nil { + queryRefund += " AND l.created_at >= '" + startTime.Format("2006-01-02 15:04:05") + "'" + } + 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) + + // 3. 获取所有抽奖记录用于成本计算 var drawLogs []model.LivestreamDrawLogs - if err := h.repo.GetDbR().Where("activity_id = ?", activityID).Find(&drawLogs).Error; err != nil { - ctx.AbortWithError(core.Error(http.StatusInternalServerError, code.ServerError, err.Error())) - return + db := h.repo.GetDbR().Where("activity_id = ?", id) + if startTime != nil { + db = db.Where("created_at >= ?", startTime) + } + if endTime != nil { + db = db.Where("created_at <= ?", endTime) + } + db.Find(&drawLogs) + + // 3.1 获取该时间段内所有退款的 shop_order_id 集合,用于过滤成本 + refundedShopOrderIDs := make(map[string]bool) + var refundedOrders []string + qRefundIDs := ` + SELECT DISTINCT o.shop_order_id + FROM douyin_orders o + JOIN livestream_draw_logs l ON o.id = l.douyin_order_id + WHERE l.activity_id = ? AND o.order_status = 4 + ` + if startTime != nil { + qRefundIDs += " AND l.created_at >= '" + startTime.Format("2006-01-02 15:04:05") + "'" + } + if endTime != nil { + qRefundIDs += " AND l.created_at <= '" + endTime.Format("2006-01-02 15:04:05") + "'" + } + h.repo.GetDbR().Raw(qRefundIDs, id).Scan(&refundedOrders) + for _, oid := range refundedOrders { + refundedShopOrderIDs[oid] = true } - orderCount := int64(len(drawLogs)) - totalRevenue := orderCount * ticketPrice - - // 3. 统计退款数量 - var refundCount int64 - h.repo.GetDbR().Model(&model.LivestreamDrawLogs{}).Where("activity_id = ? AND is_refunded = 1", activityID).Count(&refundCount) - totalRefund := refundCount * ticketPrice - - // 4. 计算成本 + // 4. 计算成本(只统计未退款订单的奖品成本) prizeIDCountMap := make(map[int64]int64) for _, log := range drawLogs { + // 排除已退款的订单 (检查 douyin_orders 状态) + if refundedShopOrderIDs[log.ShopOrderID] { + continue + } prizeIDCountMap[log.PrizeID]++ } @@ -74,11 +173,11 @@ func (h *handler) GetLivestreamStats() core.HandlerFunc { } var totalCost int64 + prizeCostMap := make(map[int64]int64) if len(prizeIDs) > 0 { var prizes []model.LivestreamPrizes h.repo.GetDbR().Where("id IN ?", prizeIDs).Find(&prizes) - prizeCostMap := make(map[int64]int64) productIDsNeedingFallback := make([]int64, 0) prizeProductMap := make(map[int64]int64) @@ -114,14 +213,110 @@ func (h *handler) GetLivestreamStats() core.HandlerFunc { } } - netProfit := (totalRevenue - totalRefund) - totalCost + // 5. 按天分组统计 + dailyMap := make(map[string]*dailyLivestreamStats) + // 5.1 统计每日营收和退款(直接累加订单实付金额) + type DailyAmount struct { + DateKey string + Amount int64 + Count int64 + IsRefunded int32 + } + var dailyAmounts []DailyAmount + queryDailyCorrect := ` + SELECT + date_key, + CAST(SUM(actual_pay_amount) AS SIGNED) as amount, + COUNT(id) as cnt, + refund_flag as is_refunded + FROM ( + SELECT + o.id, + DATE_FORMAT(MIN(l.created_at), '%Y-%m-%d') as date_key, + o.actual_pay_amount, + IF(o.order_status = 4, 1, 0) as refund_flag + FROM douyin_orders o + JOIN livestream_draw_logs l ON o.id = l.douyin_order_id + WHERE l.activity_id = ? + ` + if startTime != nil { + queryDailyCorrect += " AND l.created_at >= '" + startTime.Format("2006-01-02 15:04:05") + "'" + } + if endTime != nil { + queryDailyCorrect += " AND l.created_at <= '" + endTime.Format("2006-01-02 15:04:05") + "'" + } + queryDailyCorrect += ` + GROUP BY o.id + ) as t + GROUP BY date_key, is_refunded + ` + rows, _ := h.repo.GetDbR().Raw(queryDailyCorrect, id).Rows() + defer rows.Close() + for rows.Next() { + var da DailyAmount + _ = rows.Scan(&da.DateKey, &da.Amount, &da.Count, &da.IsRefunded) + dailyAmounts = append(dailyAmounts, da) + } + + for _, da := range dailyAmounts { + if _, ok := dailyMap[da.DateKey]; !ok { + dailyMap[da.DateKey] = &dailyLivestreamStats{Date: da.DateKey} + } + + // 修正口径:营收(Revenue) = 总流水 (含退款与未退款) + // 这样下面的 NetProfit = TotalRevenue - TotalRefund - TotalCost 才不会双重扣除 + dailyMap[da.DateKey].TotalRevenue += da.Amount + dailyMap[da.DateKey].OrderCount += da.Count + + if da.IsRefunded == 1 { + dailyMap[da.DateKey].TotalRefund += da.Amount + dailyMap[da.DateKey].RefundCount += da.Count + } + } + + // 5.2 统计每日成本(基于 Logs) + for _, log := range drawLogs { + // 排除退款订单 + if refundedShopOrderIDs[log.ShopOrderID] { + continue + } + dateKey := log.CreatedAt.Format("2006-01-02") + ds := dailyMap[dateKey] + if ds != nil { + if cost, ok := prizeCostMap[log.PrizeID]; ok { + ds.TotalCost += cost + } + } + } + + // 6. 汇总每日数据并计算总体指标 + var calcTotalRevenue, calcTotalRefund, calcTotalCost int64 + dailyList := make([]dailyLivestreamStats, 0, len(dailyMap)) + for _, ds := range dailyMap { + ds.NetProfit = (ds.TotalRevenue - ds.TotalRefund) - ds.TotalCost + netRev := ds.TotalRevenue - ds.TotalRefund + if netRev > 0 { + ds.ProfitMargin = math.Trunc(float64(ds.NetProfit)/float64(netRev)*10000) / 100 + } else if netRev == 0 && ds.TotalCost > 0 { + ds.ProfitMargin = -100 + } + dailyList = append(dailyList, *ds) + + calcTotalRevenue += ds.TotalRevenue + calcTotalRefund += ds.TotalRefund + calcTotalCost += ds.TotalCost + } + + netProfit := (totalRevenue - totalRefund) - totalCost var margin float64 netRevenue := totalRevenue - totalRefund if netRevenue > 0 { margin = float64(netProfit) / float64(netRevenue) * 100 - } else { + } else if netRevenue == 0 && totalCost > 0 { margin = -100 + } else { + margin = 0 } ctx.Payload(&livestreamStatsResponse{ @@ -132,6 +327,7 @@ func (h *handler) GetLivestreamStats() core.HandlerFunc { OrderCount: orderCount, RefundCount: refundCount, ProfitMargin: math.Trunc(margin*100) / 100, + Daily: dailyList, }) } } diff --git a/internal/api/admin/users_admin.go b/internal/api/admin/users_admin.go index a11d283..978360d 100644 --- a/internal/api/admin/users_admin.go +++ b/internal/api/admin/users_admin.go @@ -20,7 +20,7 @@ type listUsersRequest struct { InviteCode string `form:"inviteCode"` StartDate string `form:"startDate"` EndDate string `form:"endDate"` - ID *int64 `form:"id"` + ID string `form:"id"` } type listUsersResponse struct { Page int `json:"page"` @@ -74,8 +74,10 @@ func (h *handler) ListAppUsers() core.HandlerFunc { ) // 应用搜索条件 - if req.ID != nil { - q = q.Where(h.readDB.Users.ID.Eq(*req.ID)) + if req.ID != "" { + if id, err := strconv.ParseInt(req.ID, 10, 64); err == nil { + q = q.Where(h.readDB.Users.ID.Eq(id)) + } } if req.Nickname != "" { q = q.Where(h.readDB.Users.Nickname.Like("%" + req.Nickname + "%")) @@ -196,6 +198,7 @@ func (h *handler) ListAppUsers() core.HandlerFunc { Select(h.readDB.Orders.UserID, h.readDB.Orders.ActualAmount.Sum().As("amount")). Where(h.readDB.Orders.UserID.In(userIDs...)). Where(h.readDB.Orders.Status.Eq(2)). + Where(h.readDB.Orders.SourceType.In(2, 3, 4)). // 排除积分兑换(1)和奖品兑换(5) Where(h.readDB.Orders.CreatedAt.Gte(todayStart)). Group(h.readDB.Orders.UserID). Scan(&todayRes) @@ -209,6 +212,7 @@ func (h *handler) ListAppUsers() core.HandlerFunc { Select(h.readDB.Orders.UserID, h.readDB.Orders.ActualAmount.Sum().As("amount")). Where(h.readDB.Orders.UserID.In(userIDs...)). Where(h.readDB.Orders.Status.Eq(2)). + Where(h.readDB.Orders.SourceType.In(2, 3, 4)). // 排除积分兑换(1)和奖品兑换(5) Where(h.readDB.Orders.CreatedAt.Gte(sevenDayStart)). Group(h.readDB.Orders.UserID). Scan(&sevenRes) @@ -222,6 +226,7 @@ func (h *handler) ListAppUsers() core.HandlerFunc { Select(h.readDB.Orders.UserID, h.readDB.Orders.ActualAmount.Sum().As("amount")). Where(h.readDB.Orders.UserID.In(userIDs...)). Where(h.readDB.Orders.Status.Eq(2)). + Where(h.readDB.Orders.SourceType.In(2, 3, 4)). // 排除积分兑换(1)和奖品兑换(5) Where(h.readDB.Orders.CreatedAt.Gte(thirtyDayStart)). Group(h.readDB.Orders.UserID). Scan(&thirtyRes) @@ -235,6 +240,7 @@ func (h *handler) ListAppUsers() core.HandlerFunc { Select(h.readDB.Orders.UserID, h.readDB.Orders.ActualAmount.Sum().As("amount")). Where(h.readDB.Orders.UserID.In(userIDs...)). Where(h.readDB.Orders.Status.Eq(2)). + Where(h.readDB.Orders.SourceType.In(2, 3, 4)). // 排除积分兑换(1)和奖品兑换(5) Group(h.readDB.Orders.UserID). Scan(&totalRes) for _, r := range totalRes { @@ -385,8 +391,9 @@ func (h *handler) ListAppUsers() core.HandlerFunc { gpCount := gamePassCounts[v.ID] gtCount := gameTicketCounts[v.ID] - // 总资产估值逻辑:积分余额 + 商品价值 + 优惠券价值 + 道具卡价值 + 次数卡(2元/次) + 游戏资格(1元/场) - assetVal := pointsBal*100 + invVal + cpVal + icVal + gpCount*200 + gtCount*100 + // 总资产估值逻辑:积分余额 + 商品价值 + 优惠券价值 + 道具卡价值 + 次数卡(2元/次) + // 游戏资格不计入估值(购买其他商品赠送,无实际价值) + assetVal := pointsBal*100 + invVal + cpVal + icVal + gpCount*200 rsp.List[i] = adminUserItem{ ID: v.ID, @@ -397,6 +404,8 @@ func (h *handler) ListAppUsers() core.HandlerFunc { InviterNickname: inviterNicknames[v.InviterID], CreatedAt: v.CreatedAt.Format("2006-01-02 15:04:05"), DouyinID: v.DouyinID, + DouyinUserID: v.DouyinUserID, + Remark: v.Remark, ChannelName: v.ChannelName, ChannelCode: v.ChannelCode, PointsBalance: pointsBal, @@ -411,6 +420,7 @@ func (h *handler) ListAppUsers() core.HandlerFunc { GameTicketCount: gtCount, InventoryValue: invVal, TotalAssetValue: assetVal, + Status: v.Status, } } ctx.Payload(rsp) @@ -485,6 +495,7 @@ type listInventoryRequest struct { Page int `form:"page"` PageSize int `form:"page_size"` Keyword string `form:"keyword"` // 搜索关键词(商品名称) + Status int32 `form:"status"` // 状态筛选:0=全部, 1=持有, 2=作废, 3=已使用 } type listInventoryResponse struct { Page int `json:"page"` @@ -541,6 +552,8 @@ func (h *handler) ListUserOrders() core.HandlerFunc { // @Param user_id path integer true "用户ID" // @Param page query int true "页码" default(1) // @Param page_size query int true "每页数量,最多100" default(20) +// @Param keyword query string false "搜索关键词" +// @Param status query int false "状态筛选: 0=全部, 1=持有, 2=作废, 3=已使用" // @Success 200 {object} listInventoryResponse // @Failure 400 {object} code.Failure // @Router /api/admin/users/{user_id}/inventory [get] @@ -576,11 +589,34 @@ func (h *handler) ListUserInventory() core.HandlerFunc { ui := h.readDB.UserInventory p := h.readDB.Products - // 首先统计符合条件的总数 + // Check if keyword is numeric + numKeyword, errNum := strconv.ParseInt(req.Keyword, 10, 64) + + // Count query logic countQ := h.readDB.UserInventory.WithContext(ctx.RequestContext()).ReadDB(). LeftJoin(p, p.ID.EqCol(ui.ProductID)). - Where(ui.UserID.Eq(userID)). - Where(p.Name.Like("%" + req.Keyword + "%")) + Where(ui.UserID.Eq(userID)) + + // 应用状态筛选 + if req.Status > 0 { + countQ = countQ.Where(ui.Status.Eq(req.Status)) + } else { + // 默认只过滤掉已软删除的记录(如果有的话,status=2是作废,通常后台要能看到作废的,所以这里如果不传status默认查所有非删除的?) + // 既然是管理端,如果不传status,应该显示所有状态的记录 + } + + if errNum == nil { + // Keyword is numeric, search by name OR ID OR OrderID + countQ = countQ.Where( + ui.Where(p.Name.Like("%" + req.Keyword + "%")). + Or(ui.ID.Eq(numKeyword)). + Or(ui.OrderID.Eq(numKeyword)), + ) + } else { + // Keyword is not numeric, search by name only + countQ = countQ.Where(p.Name.Like("%" + req.Keyword + "%")) + } + total, err := countQ.Count() if err != nil { ctx.AbortWithError(core.Error(http.StatusBadRequest, 20105, err.Error())) @@ -604,16 +640,35 @@ func (h *handler) ListUserInventory() core.HandlerFunc { ProductPrice int64 } var rows []inventoryRow - err = h.repo.GetDbR().Raw(` + + sql := ` SELECT ui.id, ui.user_id, ui.product_id, ui.order_id, ui.activity_id, ui.reward_id, ui.status, ui.remark, ui.created_at, ui.updated_at, p.name as product_name, p.images_json as product_images, p.price as product_price FROM user_inventory ui LEFT JOIN products p ON p.id = ui.product_id - WHERE ui.user_id = ? AND p.name LIKE ? - ORDER BY ui.id DESC - LIMIT ? OFFSET ? - `, userID, "%"+req.Keyword+"%", req.PageSize, (req.Page-1)*req.PageSize).Scan(&rows).Error + WHERE ui.user_id = ? + ` + var args []interface{} + args = append(args, userID) + + if req.Status > 0 { + sql += " AND ui.status = ?" + args = append(args, req.Status) + } + + if errNum == nil { + sql += " AND (p.name LIKE ? OR ui.id = ? OR ui.order_id = ?)" + args = append(args, "%"+req.Keyword+"%", numKeyword, numKeyword) + } else { + sql += " AND p.name LIKE ?" + args = append(args, "%"+req.Keyword+"%") + } + + sql += " ORDER BY ui.id DESC LIMIT ? OFFSET ?" + args = append(args, req.PageSize, (req.Page-1)*req.PageSize) + + err = h.repo.GetDbR().Raw(sql, args...).Scan(&rows).Error if err != nil { ctx.AbortWithError(core.Error(http.StatusBadRequest, 20105, err.Error())) return @@ -648,7 +703,7 @@ func (h *handler) ListUserInventory() core.HandlerFunc { } // 无搜索关键词时使用原有逻辑 - rows, total, err := h.userSvc.ListInventoryWithProduct(ctx.RequestContext(), userID, req.Page, req.PageSize) + rows, total, err := h.userSvc.ListInventoryWithProduct(ctx.RequestContext(), userID, req.Page, req.PageSize, req.Status) if err != nil { ctx.AbortWithError(core.Error(http.StatusBadRequest, 20105, err.Error())) return @@ -1085,6 +1140,8 @@ type adminUserItem struct { InviterNickname string `json:"inviter_nickname"` // 邀请人昵称 CreatedAt string `json:"created_at"` DouyinID string `json:"douyin_id"` + DouyinUserID string `json:"douyin_user_id"` // 用户的抖音账号ID + Remark string `json:"remark"` // 备注 ChannelName string `json:"channel_name"` ChannelCode string `json:"channel_code"` PointsBalance int64 `json:"points_balance"` @@ -1099,6 +1156,7 @@ type adminUserItem struct { GameTicketCount int64 `json:"game_ticket_count"` // 游戏资格数量 InventoryValue int64 `json:"inventory_value"` // 持有商品总价值 TotalAssetValue int64 `json:"total_asset_value"` // 总资产估值 + Status int32 `json:"status"` // 用户状态:1正常 2禁用 3黑名单 } // ListAppUsers 管理端用户列表GetUserPointsBalance 查看用户积分余额 @@ -1496,3 +1554,145 @@ func (h *handler) ListUserCouponUsage() core.HandlerFunc { ctx.Payload(rsp) } } + +// LinkUserDouyinRequest 关联用户抖音账号请求 +type LinkUserDouyinRequest struct { + DouyinUserID string `json:"douyin_user_id" binding:"required"` +} + +// UpdateUserDouyinID 更新用户的抖音账号ID +// @Summary 更新用户抖音ID +// @Description 管理员绑定或修改用户的抖音账号ID +// @Tags 管理端.用户 +// @Accept json +// @Produce json +// @Param user_id path integer true "用户ID" +// @Param body body LinkUserDouyinRequest true "抖音用户ID" +// @Success 200 {object} map[string]any +// @Failure 400 {object} code.Failure +// @Router /api/admin/users/{user_id}/douyin_user_id [put] +// @Security LoginVerifyToken +func (h *handler) UpdateUserDouyinID() core.HandlerFunc { + return func(ctx core.Context) { + userID, err := strconv.ParseInt(ctx.Param("user_id"), 10, 64) + if err != nil { + ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, "用户ID无效")) + return + } + + req := new(LinkUserDouyinRequest) + if err := ctx.ShouldBindJSON(req); err != nil { + ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, validation.Error(err))) + return + } + + // 更新用户抖音ID + _, err = h.writeDB.Users.WithContext(ctx.RequestContext()). + Where(h.writeDB.Users.ID.Eq(userID)). + Update(h.writeDB.Users.DouyinUserID, req.DouyinUserID) + if err != nil { + ctx.AbortWithError(core.Error(http.StatusBadRequest, 20301, "更新失败: "+err.Error())) + return + } + + ctx.Payload(map[string]any{ + "success": true, + "message": "抖音ID更新成功", + }) + } +} + +// updateUserRemarkRequest 更新用户备注请求 +type updateUserRemarkRequest struct { + Remark string `json:"remark"` +} + +// UpdateUserRemark 更新用户备注 +// @Summary 更新用户备注 +// @Description 管理员修改用户备注 +// @Tags 管理端.用户 +// @Accept json +// @Produce json +// @Param user_id path integer true "用户ID" +// @Param body body updateUserRemarkRequest true "备注信息" +// @Success 200 {object} map[string]any +// @Failure 400 {object} code.Failure +// @Router /api/admin/users/{user_id}/remark [put] +// @Security LoginVerifyToken +func (h *handler) UpdateUserRemark() core.HandlerFunc { + return func(ctx core.Context) { + userID, err := strconv.ParseInt(ctx.Param("user_id"), 10, 64) + if err != nil { + ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, "用户ID无效")) + return + } + + req := new(updateUserRemarkRequest) + if err := ctx.ShouldBindJSON(req); err != nil { + ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, validation.Error(err))) + return + } + + // 更新用户备注 + _, err = h.writeDB.Users.WithContext(ctx.RequestContext()). + Where(h.writeDB.Users.ID.Eq(userID)). + Update(h.writeDB.Users.Remark, req.Remark) + if err != nil { + ctx.AbortWithError(core.Error(http.StatusBadRequest, 20302, "更新失败: "+err.Error())) + return + } + + ctx.Payload(map[string]any{ + "success": true, + "message": "备注更新成功", + }) + } +} + +type updateUserStatusRequest struct { + Status int32 `json:"status" form:"status"` // 1=正常 2=禁用 3=黑名单 +} + +// UpdateUserStatus 修改用户状态 +// @Summary 修改用户状态 +// @Description 管理员修改用户状态(1正常 2禁用 3黑名单) +// @Tags 管理端.用户 +// @Accept json +// @Produce json +// @Param user_id path integer true "用户ID" +// @Param body body updateUserStatusRequest true "状态信息" +// @Success 200 {object} map[string]any +// @Failure 400 {object} code.Failure +// @Router /api/admin/users/{user_id}/status [put] +// @Security LoginVerifyToken +func (h *handler) UpdateUserStatus() core.HandlerFunc { + return func(ctx core.Context) { + req := new(updateUserStatusRequest) + if err := ctx.ShouldBindJSON(req); err != nil { + ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, validation.Error(err))) + return + } + userID, err := strconv.ParseInt(ctx.Param("user_id"), 10, 64) + if err != nil { + ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, "未传递用户ID")) + return + } + + if req.Status != 1 && req.Status != 2 && req.Status != 3 { + ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, "无效的状态值")) + return + } + + // 使用 Updates 以支持更新为 0 (虽然这里status不为0) 但 gorm Update 单列更安全 + _, err = h.writeDB.Users.WithContext(ctx.RequestContext()). + Where(h.writeDB.Users.ID.Eq(userID)). + Update(h.writeDB.Users.Status, req.Status) + + if err != nil { + ctx.AbortWithError(core.Error(http.StatusInternalServerError, code.ServerError, err.Error())) + return + } + + ctx.Payload(map[string]any{"success": true}) + } +} diff --git a/internal/api/admin/users_profile.go b/internal/api/admin/users_profile.go index ed27c9d..67198d7 100644 --- a/internal/api/admin/users_profile.go +++ b/internal/api/admin/users_profile.go @@ -21,6 +21,7 @@ type UserProfileResponse struct { ChannelID int64 `json:"channel_id"` CreatedAt string `json:"created_at"` DouyinID string `json:"douyin_id"` + DouyinUserID string `json:"douyin_user_id"` // 用户的抖音账号ID InviterNickname string `json:"inviter_nickname"` // 邀请人昵称 // 邀请统计 @@ -88,6 +89,7 @@ func (h *handler) GetUserProfile() core.HandlerFunc { rsp.InviterID = user.InviterID rsp.ChannelID = user.ChannelID rsp.DouyinID = user.DouyinID + rsp.DouyinUserID = user.DouyinUserID rsp.CreatedAt = user.CreatedAt.Format(time.RFC3339) // 1.1 查询邀请人昵称 @@ -123,7 +125,7 @@ func (h *handler) GetUserProfile() core.HandlerFunc { ). Where(h.readDB.Orders.UserID.Eq(userID)). Where(h.readDB.Orders.Status.Eq(2)). - Where(h.readDB.Orders.SourceType.In(1, 2)). // 仅统计商城直购和抽奖票据,排除兑换商品 + Where(h.readDB.Orders.SourceType.In(2, 3, 4)). // 统计抽奖、对对碰、次卡购买(排除积分兑换) Scan(&os) // 分阶段统计 @@ -131,7 +133,7 @@ func (h *handler) GetUserProfile() core.HandlerFunc { Select(h.readDB.Orders.ActualAmount.Sum().As("today_paid")). Where(h.readDB.Orders.UserID.Eq(userID)). Where(h.readDB.Orders.Status.Eq(2)). - Where(h.readDB.Orders.SourceType.In(1, 2)). // 排除兑换商品 + Where(h.readDB.Orders.SourceType.In(2, 3, 4)). // 统计抽奖、对对碰、次卡购买(排除积分兑换) Where(h.readDB.Orders.CreatedAt.Gte(todayStart)). Scan(&os.TodayPaid) @@ -139,7 +141,7 @@ func (h *handler) GetUserProfile() core.HandlerFunc { Select(h.readDB.Orders.ActualAmount.Sum().As("seven_day_paid")). Where(h.readDB.Orders.UserID.Eq(userID)). Where(h.readDB.Orders.Status.Eq(2)). - Where(h.readDB.Orders.SourceType.In(1, 2)). // 排除兑换商品 + Where(h.readDB.Orders.SourceType.In(2, 3, 4)). // 统计抽奖、对对碰、次卡购买(排除积分兑换) Where(h.readDB.Orders.CreatedAt.Gte(sevenDayStart)). Scan(&os.SevenDayPaid) @@ -147,7 +149,7 @@ func (h *handler) GetUserProfile() core.HandlerFunc { Select(h.readDB.Orders.ActualAmount.Sum().As("thirty_day_paid")). Where(h.readDB.Orders.UserID.Eq(userID)). Where(h.readDB.Orders.Status.Eq(2)). - Where(h.readDB.Orders.SourceType.In(1, 2)). // 排除兑换商品 + Where(h.readDB.Orders.SourceType.In(2, 3, 4)). // 统计抽奖、对对碰、次卡购买(排除积分兑换) Where(h.readDB.Orders.CreatedAt.Gte(thirtyDayStart)). Scan(&os.ThirtyDayPaid) @@ -237,9 +239,10 @@ func (h *handler) GetUserProfile() core.HandlerFunc { _ = h.repo.GetDbR().Raw("SELECT COALESCE(SUM(available), 0) FROM user_game_tickets WHERE user_id = ?", userID).Scan(&rsp.CurrentAssets.GameTicketCount).Error // 4.5 总资产估值 - // 估值逻辑:积分余额 + 商品价值 + 优惠券价值 + 道具卡价值 + 次数卡(2元/次) + 游戏资格(1元/场) - gamePassValue := rsp.CurrentAssets.GamePassCount * 200 // 估值:2元/次 - gameTicketValue := rsp.CurrentAssets.GameTicketCount * 100 // 估值:1元/场 + // 估值逻辑:积分余额 + 商品价值 + 优惠券价值 + 道具卡价值 + 次数卡(2元/次) + // 游戏资格不计入估值(购买其他商品赠送,无实际价值) + gamePassValue := rsp.CurrentAssets.GamePassCount * 200 // 估值:2元/次 + gameTicketValue := int64(0) // 游戏资格不计入估值 rsp.CurrentAssets.TotalAssetValue = rsp.CurrentAssets.PointsBalance + rsp.CurrentAssets.InventoryValue + diff --git a/internal/api/admin/users_profit_loss.go b/internal/api/admin/users_profit_loss.go index 4559e4f..ae72250 100644 --- a/internal/api/admin/users_profit_loss.go +++ b/internal/api/admin/users_profit_loss.go @@ -99,6 +99,7 @@ func (h *handler) GetUserProfitLossTrend() core.HandlerFunc { Select(h.readDB.Orders.ActualAmount.Sum()). Where(h.readDB.Orders.UserID.Eq(userID)). Where(h.readDB.Orders.Status.Eq(2)). + Where(h.readDB.Orders.SourceType.In(2, 3, 4)). // 排除积分兑换(1)和奖品兑换(5) Where(h.readDB.Orders.CreatedAt.Lt(start)). Scan(&baseCostPtr) if baseCostPtr != nil { @@ -121,6 +122,7 @@ func (h *handler) GetUserProfitLossTrend() core.HandlerFunc { orderRows, _ := h.readDB.Orders.WithContext(ctx.RequestContext()).ReadDB(). Where(h.readDB.Orders.UserID.Eq(userID)). Where(h.readDB.Orders.Status.Eq(2)). + Where(h.readDB.Orders.SourceType.In(2, 3, 4)). // 排除积分兑换(1)和奖品兑换(5) Where(h.readDB.Orders.CreatedAt.Gte(start)). Where(h.readDB.Orders.CreatedAt.Lte(end)). Find() @@ -195,6 +197,7 @@ func (h *handler) GetUserProfitLossTrend() core.HandlerFunc { Select(h.readDB.Orders.ActualAmount.Sum()). Where(h.readDB.Orders.UserID.Eq(userID)). Where(h.readDB.Orders.Status.Eq(2)). + Where(h.readDB.Orders.SourceType.In(2, 3, 4)). // 排除积分兑换(1)和奖品兑换(5) Scan(&totalCostPtr) if totalCostPtr != nil { totalCost = *totalCostPtr diff --git a/internal/api/common/config.go b/internal/api/common/config.go index 67713d3..415c413 100644 --- a/internal/api/common/config.go +++ b/internal/api/common/config.go @@ -5,7 +5,8 @@ import ( ) type ConfigResponse struct { - SubscribeTemplates map[string]string `json:"subscribe_templates"` + SubscribeTemplates map[string]string `json:"subscribe_templates"` + ContactServiceQRCode string `json:"contact_service_qrcode"` // 客服二维码 } // GetPublicConfig 获取公开配置(包含订阅模板ID) @@ -18,19 +19,30 @@ type ConfigResponse struct { // @Router /api/app/config/public [get] func (h *handler) GetPublicConfig() core.HandlerFunc { return func(ctx core.Context) { - // 查询订阅消息模板 ID - var val string - cfg, err := h.readDB.SystemConfigs.WithContext(ctx.RequestContext()). - Where(h.readDB.SystemConfigs.ConfigKey.Eq("wechat.lottery_result_template_id")). - First() - if err == nil && cfg != nil { - val = cfg.ConfigValue + // 查询配置 + var subscribeTemplateID string + var serviceQRCode string + + configs, err := h.readDB.SystemConfigs.WithContext(ctx.RequestContext()). + Where(h.readDB.SystemConfigs.ConfigKey.In("wechat.lottery_result_template_id", "contact.service_qrcode")). + Find() + + if err == nil { + for _, cfg := range configs { + switch cfg.ConfigKey { + case "wechat.lottery_result_template_id": + subscribeTemplateID = cfg.ConfigValue + case "contact.service_qrcode": + serviceQRCode = cfg.ConfigValue + } + } } rsp := ConfigResponse{ SubscribeTemplates: map[string]string{ - "lottery_result": val, + "lottery_result": subscribeTemplateID, }, + ContactServiceQRCode: serviceQRCode, } ctx.Payload(rsp) diff --git a/internal/api/game/handler.go b/internal/api/game/handler.go index 7cb6064..7e001ed 100644 --- a/internal/api/game/handler.go +++ b/internal/api/game/handler.go @@ -12,6 +12,7 @@ import ( "encoding/json" "net/http" "strconv" + "strings" "github.com/redis/go-redis/v9" "go.uber.org/zap" @@ -191,10 +192,14 @@ func (h *handler) EnterGame() core.HandlerFunc { } // 查询剩余次数 - ticket, _ := h.ticketSvc.GetUserTicketByGame(ctx.RequestContext(), userID, req.GameCode) remaining := 0 - if ticket != nil { - remaining = int(ticket.Available) + if req.GameCode == "minesweeper_free" { + remaining = 999999 // Represent infinite for free mode + } else { + ticket, _ := h.ticketSvc.GetUserTicketByGame(ctx.RequestContext(), userID, req.GameCode) + if ticket != nil { + remaining = int(ticket.Available) + } } // 从系统配置读取Nakama服务器信息 @@ -312,8 +317,21 @@ func (h *handler) VerifyTicket() core.HandlerFunc { } // 从Redis验证token - storedUserID, err := h.redis.Get(ctx.RequestContext(), "game:token:ticket:"+req.Ticket).Result() - if err != nil || storedUserID != req.UserID { + storedValue, err := h.redis.Get(ctx.RequestContext(), "game:token:ticket:"+req.Ticket).Result() + if err != nil { + ctx.Payload(&verifyResponse{Valid: false}) + return + } + + // Parse "userID:gameType" + parts := strings.Split(storedValue, ":") + if len(parts) < 2 { + ctx.Payload(&verifyResponse{Valid: false}) + return + } + storedUserID := parts[0] + + if storedUserID != req.UserID { ctx.Payload(&verifyResponse{Valid: false}) return } @@ -358,16 +376,36 @@ func (h *handler) SettleGame() core.HandlerFunc { } // 验证token(可选,如果游戏服务器传了ticket则验证,否则信任internal调用) + isFreeMode := false if req.Ticket != "" { - storedUserID, err := h.redis.Get(ctx.RequestContext(), "game:token:ticket:"+req.Ticket).Result() - if err != nil || storedUserID != req.UserID { - h.logger.Warn("Ticket validation failed, but proceeding with internal trust", - zap.String("ticket", req.Ticket), zap.String("user_id", req.UserID)) + storedValue, err := h.redis.Get(ctx.RequestContext(), "game:token:ticket:"+req.Ticket).Result() + if err != nil { + h.logger.Warn("Ticket validation failed (not found)", zap.String("ticket", req.Ticket)) } else { - // 删除token防止重复使用 - h.redis.Del(ctx.RequestContext(), "game:token:ticket:"+req.Ticket) + // Parse "userID:gameType" + parts := strings.Split(storedValue, ":") + storedUserID := parts[0] + + if len(parts) > 1 && parts[1] == "minesweeper_free" { + isFreeMode = true + } + + if storedUserID != req.UserID { + h.logger.Warn("Ticket validation failed (user mismatch)", + zap.String("ticket", req.Ticket), zap.String("user_id", req.UserID), zap.String("stored", storedUserID)) + } else { + // 删除token防止重复使用 + h.redis.Del(ctx.RequestContext(), "game:token:ticket:"+req.Ticket) + } } } + + // 拦截免费场结算 + if isFreeMode { + ctx.Payload(&settleResponse{Success: true, Reward: "体验模式无奖励"}) + return + } + // 注意:即使ticket验证失败,作为internal API我们仍然信任游戏服务器传来的UserID // 奖品发放逻辑 @@ -407,6 +445,9 @@ func (h *handler) SettleGame() core.HandlerFunc { } // 3. 发放奖励 + // Note: Free mode (minesweeper_free) Settle logic is currently same as paid. + // If needed, configure 0 rewards in system config or handle here in future. + if targetProductID > 0 { res, err := h.userSvc.GrantReward(ctx.RequestContext(), uid, usersvc.GrantRewardRequest{ ProductID: targetProductID, @@ -468,11 +509,16 @@ func (h *handler) ConsumeTicket() core.HandlerFunc { } // 扣减游戏次数 - err := h.ticketSvc.UseTicket(ctx.RequestContext(), uid, gameCode) - if err != nil { - h.logger.Error("Failed to consume ticket", zap.Int64("user_id", uid), zap.String("game_code", gameCode), zap.Error(err)) - ctx.Payload(&consumeTicketResponse{Success: false, Error: err.Error()}) - return + if gameCode == "minesweeper_free" { + // 免费场场不扣减次数,直接通过 + h.logger.Info("Free mode consume ticket skipped deduction", zap.Int64("user_id", uid)) + } else { + err := h.ticketSvc.UseTicket(ctx.RequestContext(), uid, gameCode) + if err != nil { + h.logger.Error("Failed to consume ticket", zap.Int64("user_id", uid), zap.String("game_code", gameCode), zap.Error(err)) + ctx.Payload(&consumeTicketResponse{Success: false, Error: err.Error()}) + return + } } // 使 ticket 失效(防止重复扣减) diff --git a/internal/api/pay/wechat_notify.go b/internal/api/pay/wechat_notify.go index b8d68fc..f407626 100644 --- a/internal/api/pay/wechat_notify.go +++ b/internal/api/pay/wechat_notify.go @@ -306,6 +306,11 @@ func (h *handler) WechatNotify() core.HandlerFunc { wxConfig = &wechat.WechatConfig{AppID: cfg.AppID, AppSecret: cfg.AppSecret} } + if wxConfig == nil || wxConfig.AppID == "" { + h.logger.Error("微信配置缺失(AppID为空),跳过虚拟发货/抽奖", zap.String("order_no", order.OrderNo)) + return + } + if ord.SourceType == 2 && act != nil && act.DrawMode == "instant" { _ = h.activity.ProcessOrderLottery(bgCtx, ord.ID) } else if ord.SourceType == 4 { diff --git a/internal/api/public/livestream_public.go b/internal/api/public/livestream_public.go index a238d7d..43e23d7 100644 --- a/internal/api/public/livestream_public.go +++ b/internal/api/public/livestream_public.go @@ -298,6 +298,11 @@ func (h *handler) DrawLivestream() core.HandlerFunc { UserNickname: order.UserNickname, }) if err != nil { + // 检查是否为黑名单错误 + if err.Error() == "该用户已被列入黑名单,无法开奖" { + ctx.AbortWithError(core.Error(http.StatusForbidden, 10008, err.Error())) + return + } ctx.AbortWithError(core.Error(http.StatusBadRequest, 10007, err.Error())) return } @@ -344,14 +349,8 @@ func (h *handler) SyncLivestreamOrders() core.HandlerFunc { return } - activity, err := h.livestream.GetActivityByAccessCode(ctx.RequestContext(), accessCode) - if err != nil { - ctx.AbortWithError(core.Error(http.StatusNotFound, 10002, "活动不存在")) - return - } - - // 调用服务执行全量扫描 (此时已过滤 status=2) - result, err := h.douyin.SyncShopOrders(ctx.RequestContext(), activity.ID) + // 调用服务执行全量扫描 (基于时间更新,覆盖最近1小时变化) + result, err := h.douyin.SyncAllOrders(ctx.RequestContext(), 1*time.Hour) if err != nil { ctx.AbortWithError(core.Error(http.StatusInternalServerError, 10004, err.Error())) return @@ -376,20 +375,14 @@ func (h *handler) GetLivestreamPendingOrders() core.HandlerFunc { return } - activity, err := h.livestream.GetActivityByAccessCode(ctx.RequestContext(), accessCode) - if err != nil { - ctx.AbortWithError(core.Error(http.StatusNotFound, 10002, "活动不存在")) - return - } - - // [核心优化] 自动同步:每次拉取待抽奖列表前,静默执行一次全店扫描 - _, _ = h.douyin.SyncShopOrders(ctx.RequestContext(), activity.ID) + // [核心优化] 自动同步:每次拉取待抽奖列表前,静默执行一次快速全局扫描 (最近 10 分钟) + _, _ = h.douyin.SyncAllOrders(ctx.RequestContext(), 10*time.Minute) // 查询全店范围内所有待抽奖记录 (Status 2 且未核销完: reward_granted < product_count) var pendingOrders []model.DouyinOrders db := h.repo.GetDbR().WithContext(ctx.RequestContext()) - err = db.Where("order_status = 2 AND reward_granted < product_count"). + err := db.Where("order_status = 2 AND reward_granted < product_count"). Find(&pendingOrders).Error if err != nil { @@ -397,6 +390,40 @@ func (h *handler) GetLivestreamPendingOrders() core.HandlerFunc { return } - ctx.Payload(pendingOrders) + // 查询黑名单用户 + blacklistMap := make(map[string]bool) + if len(pendingOrders) > 0 { + var douyinUserIDs []string + for _, order := range pendingOrders { + if order.DouyinUserID != "" { + douyinUserIDs = append(douyinUserIDs, order.DouyinUserID) + } + } + if len(douyinUserIDs) > 0 { + var blacklistUsers []model.DouyinBlacklist + db.Table("douyin_blacklist"). + Where("douyin_user_id IN ? AND status = 1", douyinUserIDs). + Find(&blacklistUsers) + for _, bl := range blacklistUsers { + blacklistMap[bl.DouyinUserID] = true + } + } + } + + // 构造响应,包含黑名单状态 + type OrderWithBlacklist struct { + model.DouyinOrders + IsBlacklisted bool `json:"is_blacklisted"` + } + + result := make([]OrderWithBlacklist, len(pendingOrders)) + for i, order := range pendingOrders { + result[i] = OrderWithBlacklist{ + DouyinOrders: order, + IsBlacklisted: blacklistMap[order.DouyinUserID], + } + } + + ctx.Payload(result) } } diff --git a/internal/api/user/coupons_app.go b/internal/api/user/coupons_app.go index 1398f0f..8d49c80 100644 --- a/internal/api/user/coupons_app.go +++ b/internal/api/user/coupons_app.go @@ -30,8 +30,10 @@ type couponItem struct { ValidStart string `json:"valid_start"` ValidEnd string `json:"valid_end"` Status int32 `json:"status"` + StatusDesc string `json:"status_desc"` // 状态描述:未使用、已用完、已过期 Rules string `json:"rules"` UsedAt string `json:"used_at,omitempty"` // 使用时间(已使用时返回) + UsedAmount int64 `json:"used_amount"` // 已使用金额 } // ListUserCoupons 查看用户优惠券 @@ -58,13 +60,13 @@ func (h *handler) ListUserCoupons() core.HandlerFunc { } userID := int64(ctx.SessionUserInfo().Id) - // 默认查询未使用的优惠券 - status := int32(1) - if req.Status != nil && *req.Status > 0 { + // 状态:0未使用 1已使用 2已过期 (直接对接前端标准) + status := int32(0) + if req.Status != nil { status = *req.Status } - items, total, err := h.user.ListCouponsByStatus(ctx.RequestContext(), userID, status, req.Page, req.PageSize) + items, total, err := h.user.ListAppCoupons(ctx.RequestContext(), userID, status, req.Page, req.PageSize) if err != nil { ctx.AbortWithError(core.Error(http.StatusBadRequest, 10003, err.Error())) return @@ -100,14 +102,8 @@ func (h *handler) ListUserCoupons() core.HandlerFunc { rules := "" if sc != nil { name = sc.Name - // 金额券:amount 显示模板面值,remaining 显示当前余额 - if sc.DiscountType == 1 { - amount = sc.DiscountValue - _ = h.repo.GetDbR().Raw("SELECT COALESCE(balance_amount,0) FROM user_coupons WHERE id=?", it.ID).Scan(&remaining).Error - } else { - amount = sc.DiscountValue - remaining = sc.DiscountValue - } + amount = sc.DiscountValue + remaining = it.BalanceAmount rules = buildCouponRules(sc) } vs := it.ValidStart.Format("2006-01-02 15:04:05") @@ -119,7 +115,24 @@ func (h *handler) ListUserCoupons() core.HandlerFunc { if !it.UsedAt.IsZero() { usedAt = it.UsedAt.Format("2006-01-02 15:04:05") } - vi := couponItem{ID: it.ID, Name: name, Amount: amount, Remaining: remaining, ValidStart: vs, ValidEnd: ve, Status: it.Status, Rules: rules, UsedAt: usedAt} + statusDesc := "未使用" + if it.Status == 2 { + if it.BalanceAmount == 0 { + statusDesc = "已使用" + } else { + statusDesc = "使用中" + } + } else if it.Status == 3 { + // 若面值等于余额,说明完全没用过,否则为“已到期” + sc, ok := mp[it.CouponID] + if ok && it.BalanceAmount < sc.DiscountValue { + statusDesc = "已到期" + } else { + statusDesc = "已过期" + } + } + usedAmount := amount - remaining + vi := couponItem{ID: it.ID, Name: name, Amount: amount, Remaining: remaining, UsedAmount: usedAmount, ValidStart: vs, ValidEnd: ve, Status: it.Status, StatusDesc: statusDesc, Rules: rules, UsedAt: usedAt} rsp.List = append(rsp.List, vi) } ctx.Payload(rsp) diff --git a/internal/code/code.go b/internal/code/code.go index b49e757..22d9a8f 100644 --- a/internal/code/code.go +++ b/internal/code/code.go @@ -15,17 +15,19 @@ type Failure struct { Message string `json:"message"` // 描述信息 } - const ( - ServerError = 10101 - ParamBindError = 10102 - JWTAuthVerifyError = 10103 - UploadError = 10104 +const ( + ServerError = 10101 + ParamBindError = 10102 + JWTAuthVerifyError = 10103 + UploadError = 10104 + ForbiddenError = 10105 + AuthorizationError = 10106 - AdminLoginError = 20101 - CreateAdminError = 20207 - ListAdminError = 20208 - ModifyAdminError = 20209 - DeleteAdminError = 20210 + AdminLoginError = 20101 + CreateAdminError = 20207 + ListAdminError = 20208 + ModifyAdminError = 20209 + DeleteAdminError = 20210 ) func Text(code int) string { diff --git a/internal/pkg/wechat/shipping.go b/internal/pkg/wechat/shipping.go index 6aa8d51..d9b7316 100644 --- a/internal/pkg/wechat/shipping.go +++ b/internal/pkg/wechat/shipping.go @@ -65,6 +65,18 @@ func uploadVirtualShippingInternal(ctx core.Context, accessToken string, key ord if itemDesc == "" { return fmt.Errorf("参数缺失") } + + // Step 1: Check if already shipped to avoid invalid request + state, err := GetOrderShippingStatus(context.Background(), accessToken, key) + if err == nil { + if state >= 2 && state <= 4 { + fmt.Printf("[虚拟发货] 订单已发货/完成(state=%d),跳过上传 order_key=%+v\n", state, key) + return nil + } + } else { + fmt.Printf("[虚拟发货] 查询订单状态失败: %v, 继续尝试发货\n", err) + } + reqBody := &uploadShippingInfoRequest{ OrderKey: key, LogisticsType: 3, @@ -241,6 +253,56 @@ func UploadVirtualShippingForBackground(ctx context.Context, config *WechatConfi return uploadVirtualShippingInternalBackground(ctx, accessToken, orderKey{OrderNumberType: 1, MchID: mchID, OutTradeNo: outTradeNo}, payerOpenid, itemDesc, time.Now()) } +// GetOrderShippingStatusResponse 查询订单发货状态响应 +type GetOrderShippingStatusResponse struct { + ErrCode int `json:"errcode"` + ErrMsg string `json:"errmsg"` + Order struct { + OrderState int `json:"order_state"` // 1: 待发货, 2: 已发货, 3: 确认收货, 4: 交易完成, 5: 已退款 + } `json:"order"` +} + +// GetOrderShippingStatus 查询订单发货状态 +// 返回: orderState (1: 待发货, 2: 已发货, 3: 确认收货, 4: 交易完成, 5: 已退款), error +func GetOrderShippingStatus(ctx context.Context, accessToken string, key orderKey) (int, error) { + if accessToken == "" { + return 0, fmt.Errorf("access_token 不能为空") + } + // 文档: https://developers.weixin.qq.com/miniprogram/dev/platform-capabilities/business-capabilities/order-shipping/order-shipping.html#三、查询订单发货状态 + // get_order 接口参数是扁平的,不使用 order_key 结构 + reqBody := map[string]any{} + if key.TransactionID != "" { + reqBody["transaction_id"] = key.TransactionID + } else { + reqBody["merchant_id"] = key.MchID + reqBody["merchant_trade_no"] = key.OutTradeNo + } + b, _ := json.Marshal(reqBody) + + // fmt.Printf("[虚拟发货-查询] 请求 get_order order_key=%+v\n", key) // Debug log + client := httpclient.GetHttpClient() + resp, err := client.R(). + SetQueryParam("access_token", accessToken). + SetHeader("Content-Type", "application/json"). + SetBody(b). + Post("https://api.weixin.qq.com/wxa/sec/order/get_order") + if err != nil { + return 0, err + } + var r GetOrderShippingStatusResponse + if err := json.Unmarshal(resp.Body(), &r); err != nil { + return 0, fmt.Errorf("解析响应失败: %v", err) + } + if r.ErrCode != 0 { + // 10060001 = 支付单不存在,视为待发货(或未知的) + if r.ErrCode == 10060001 { + return 0, nil // Not found + } + return 0, fmt.Errorf("微信返回错误: errcode=%d, errmsg=%s", r.ErrCode, r.ErrMsg) + } + return r.Order.OrderState, nil +} + // uploadVirtualShippingInternalBackground 后台虚拟发货内部实现(无 core.Context) func uploadVirtualShippingInternalBackground(ctx context.Context, accessToken string, key orderKey, payerOpenid string, itemDesc string, uploadTime time.Time) error { if accessToken == "" { @@ -249,6 +311,22 @@ func uploadVirtualShippingInternalBackground(ctx context.Context, accessToken st if itemDesc == "" { return fmt.Errorf("参数缺失") } + + // Step 1: Check if already shipped to avoid invalid request + state, err := GetOrderShippingStatus(ctx, accessToken, key) + if err == nil { + if state >= 2 && state <= 4 { + fmt.Printf("[虚拟发货-后台] 订单已发货/完成(state=%d),跳过上传 order_key=%+v\n", state, key) + return nil + } + } else { + // Log error but continue to try upload? Or just return error? + // If query fails, maybe we should try upload anyway or just log warning. + // Let's log warning and continue. + fmt.Printf("[虚拟发货-后台] 查询订单状态失败: %v, 继续尝试发货\n", err) + } + + // Step 2: Upload shipping info reqBody := &uploadShippingInfoRequest{ OrderKey: key, LogisticsType: 3, @@ -275,6 +353,11 @@ func uploadVirtualShippingInternalBackground(ctx context.Context, accessToken st return fmt.Errorf("解析响应失败: %v", err) } if cr.ErrCode != 0 { + // 10060003 = 订单已发货 (Redundant check if status check above passed but state changed or query returned 0) + if cr.ErrCode == 10060003 { + fmt.Printf("[虚拟发货-后台] 微信返回已发货(10060003),视为成功\n") + return nil + } return fmt.Errorf("微信返回错误: errcode=%d, errmsg=%s", cr.ErrCode, cr.ErrMsg) } return nil diff --git a/internal/repository/mysql/bindbox.db b/internal/repository/mysql/bindbox.db new file mode 100644 index 0000000..e69de29 diff --git a/internal/repository/mysql/dao/douyin_blacklist.gen.go b/internal/repository/mysql/dao/douyin_blacklist.gen.go new file mode 100644 index 0000000..3c888b4 --- /dev/null +++ b/internal/repository/mysql/dao/douyin_blacklist.gen.go @@ -0,0 +1,344 @@ +// Code generated by gorm.io/gen. DO NOT EDIT. +// Code generated by gorm.io/gen. DO NOT EDIT. +// Code generated by gorm.io/gen. DO NOT EDIT. + +package dao + +import ( + "context" + + "gorm.io/gorm" + "gorm.io/gorm/clause" + "gorm.io/gorm/schema" + + "gorm.io/gen" + "gorm.io/gen/field" + + "gorm.io/plugin/dbresolver" + + "bindbox-game/internal/repository/mysql/model" +) + +func newDouyinBlacklist(db *gorm.DB, opts ...gen.DOOption) douyinBlacklist { + _douyinBlacklist := douyinBlacklist{} + + _douyinBlacklist.douyinBlacklistDo.UseDB(db, opts...) + _douyinBlacklist.douyinBlacklistDo.UseModel(&model.DouyinBlacklist{}) + + tableName := _douyinBlacklist.douyinBlacklistDo.TableName() + _douyinBlacklist.ALL = field.NewAsterisk(tableName) + _douyinBlacklist.ID = field.NewInt64(tableName, "id") + _douyinBlacklist.DouyinUserID = field.NewString(tableName, "douyin_user_id") + _douyinBlacklist.Reason = field.NewString(tableName, "reason") + _douyinBlacklist.OperatorID = field.NewInt64(tableName, "operator_id") + _douyinBlacklist.Status = field.NewInt32(tableName, "status") + _douyinBlacklist.CreatedAt = field.NewTime(tableName, "created_at") + _douyinBlacklist.UpdatedAt = field.NewTime(tableName, "updated_at") + + _douyinBlacklist.fillFieldMap() + + return _douyinBlacklist +} + +// douyinBlacklist 抖音用户黑名单表 +type douyinBlacklist struct { + douyinBlacklistDo + + ALL field.Asterisk + ID field.Int64 // 主键ID + DouyinUserID field.String // 抖音用户ID + Reason field.String // 拉黑原因 + OperatorID field.Int64 // 操作人ID + Status field.Int32 // 状态: 1=生效, 0=已解除 + CreatedAt field.Time // 创建时间 + UpdatedAt field.Time // 更新时间 + + fieldMap map[string]field.Expr +} + +func (d douyinBlacklist) Table(newTableName string) *douyinBlacklist { + d.douyinBlacklistDo.UseTable(newTableName) + return d.updateTableName(newTableName) +} + +func (d douyinBlacklist) As(alias string) *douyinBlacklist { + d.douyinBlacklistDo.DO = *(d.douyinBlacklistDo.As(alias).(*gen.DO)) + return d.updateTableName(alias) +} + +func (d *douyinBlacklist) updateTableName(table string) *douyinBlacklist { + d.ALL = field.NewAsterisk(table) + d.ID = field.NewInt64(table, "id") + d.DouyinUserID = field.NewString(table, "douyin_user_id") + d.Reason = field.NewString(table, "reason") + d.OperatorID = field.NewInt64(table, "operator_id") + d.Status = field.NewInt32(table, "status") + d.CreatedAt = field.NewTime(table, "created_at") + d.UpdatedAt = field.NewTime(table, "updated_at") + + d.fillFieldMap() + + return d +} + +func (d *douyinBlacklist) GetFieldByName(fieldName string) (field.OrderExpr, bool) { + _f, ok := d.fieldMap[fieldName] + if !ok || _f == nil { + return nil, false + } + _oe, ok := _f.(field.OrderExpr) + return _oe, ok +} + +func (d *douyinBlacklist) fillFieldMap() { + d.fieldMap = make(map[string]field.Expr, 7) + d.fieldMap["id"] = d.ID + d.fieldMap["douyin_user_id"] = d.DouyinUserID + d.fieldMap["reason"] = d.Reason + d.fieldMap["operator_id"] = d.OperatorID + d.fieldMap["status"] = d.Status + d.fieldMap["created_at"] = d.CreatedAt + d.fieldMap["updated_at"] = d.UpdatedAt +} + +func (d douyinBlacklist) clone(db *gorm.DB) douyinBlacklist { + d.douyinBlacklistDo.ReplaceConnPool(db.Statement.ConnPool) + return d +} + +func (d douyinBlacklist) replaceDB(db *gorm.DB) douyinBlacklist { + d.douyinBlacklistDo.ReplaceDB(db) + return d +} + +type douyinBlacklistDo struct{ gen.DO } + +func (d douyinBlacklistDo) Debug() *douyinBlacklistDo { + return d.withDO(d.DO.Debug()) +} + +func (d douyinBlacklistDo) WithContext(ctx context.Context) *douyinBlacklistDo { + return d.withDO(d.DO.WithContext(ctx)) +} + +func (d douyinBlacklistDo) ReadDB() *douyinBlacklistDo { + return d.Clauses(dbresolver.Read) +} + +func (d douyinBlacklistDo) WriteDB() *douyinBlacklistDo { + return d.Clauses(dbresolver.Write) +} + +func (d douyinBlacklistDo) Session(config *gorm.Session) *douyinBlacklistDo { + return d.withDO(d.DO.Session(config)) +} + +func (d douyinBlacklistDo) Clauses(conds ...clause.Expression) *douyinBlacklistDo { + return d.withDO(d.DO.Clauses(conds...)) +} + +func (d douyinBlacklistDo) Returning(value interface{}, columns ...string) *douyinBlacklistDo { + return d.withDO(d.DO.Returning(value, columns...)) +} + +func (d douyinBlacklistDo) Not(conds ...gen.Condition) *douyinBlacklistDo { + return d.withDO(d.DO.Not(conds...)) +} + +func (d douyinBlacklistDo) Or(conds ...gen.Condition) *douyinBlacklistDo { + return d.withDO(d.DO.Or(conds...)) +} + +func (d douyinBlacklistDo) Select(conds ...field.Expr) *douyinBlacklistDo { + return d.withDO(d.DO.Select(conds...)) +} + +func (d douyinBlacklistDo) Where(conds ...gen.Condition) *douyinBlacklistDo { + return d.withDO(d.DO.Where(conds...)) +} + +func (d douyinBlacklistDo) Order(conds ...field.Expr) *douyinBlacklistDo { + return d.withDO(d.DO.Order(conds...)) +} + +func (d douyinBlacklistDo) Distinct(cols ...field.Expr) *douyinBlacklistDo { + return d.withDO(d.DO.Distinct(cols...)) +} + +func (d douyinBlacklistDo) Omit(cols ...field.Expr) *douyinBlacklistDo { + return d.withDO(d.DO.Omit(cols...)) +} + +func (d douyinBlacklistDo) Join(table schema.Tabler, on ...field.Expr) *douyinBlacklistDo { + return d.withDO(d.DO.Join(table, on...)) +} + +func (d douyinBlacklistDo) LeftJoin(table schema.Tabler, on ...field.Expr) *douyinBlacklistDo { + return d.withDO(d.DO.LeftJoin(table, on...)) +} + +func (d douyinBlacklistDo) RightJoin(table schema.Tabler, on ...field.Expr) *douyinBlacklistDo { + return d.withDO(d.DO.RightJoin(table, on...)) +} + +func (d douyinBlacklistDo) Group(cols ...field.Expr) *douyinBlacklistDo { + return d.withDO(d.DO.Group(cols...)) +} + +func (d douyinBlacklistDo) Having(conds ...gen.Condition) *douyinBlacklistDo { + return d.withDO(d.DO.Having(conds...)) +} + +func (d douyinBlacklistDo) Limit(limit int) *douyinBlacklistDo { + return d.withDO(d.DO.Limit(limit)) +} + +func (d douyinBlacklistDo) Offset(offset int) *douyinBlacklistDo { + return d.withDO(d.DO.Offset(offset)) +} + +func (d douyinBlacklistDo) Scopes(funcs ...func(gen.Dao) gen.Dao) *douyinBlacklistDo { + return d.withDO(d.DO.Scopes(funcs...)) +} + +func (d douyinBlacklistDo) Unscoped() *douyinBlacklistDo { + return d.withDO(d.DO.Unscoped()) +} + +func (d douyinBlacklistDo) Create(values ...*model.DouyinBlacklist) error { + if len(values) == 0 { + return nil + } + return d.DO.Create(values) +} + +func (d douyinBlacklistDo) CreateInBatches(values []*model.DouyinBlacklist, batchSize int) error { + return d.DO.CreateInBatches(values, batchSize) +} + +// Save : !!! underlying implementation is different with GORM +// The method is equivalent to executing the statement: db.Clauses(clause.OnConflict{UpdateAll: true}).Create(values) +func (d douyinBlacklistDo) Save(values ...*model.DouyinBlacklist) error { + if len(values) == 0 { + return nil + } + return d.DO.Save(values) +} + +func (d douyinBlacklistDo) First() (*model.DouyinBlacklist, error) { + if result, err := d.DO.First(); err != nil { + return nil, err + } else { + return result.(*model.DouyinBlacklist), nil + } +} + +func (d douyinBlacklistDo) Take() (*model.DouyinBlacklist, error) { + if result, err := d.DO.Take(); err != nil { + return nil, err + } else { + return result.(*model.DouyinBlacklist), nil + } +} + +func (d douyinBlacklistDo) Last() (*model.DouyinBlacklist, error) { + if result, err := d.DO.Last(); err != nil { + return nil, err + } else { + return result.(*model.DouyinBlacklist), nil + } +} + +func (d douyinBlacklistDo) Find() ([]*model.DouyinBlacklist, error) { + result, err := d.DO.Find() + return result.([]*model.DouyinBlacklist), err +} + +func (d douyinBlacklistDo) FindInBatch(batchSize int, fc func(tx gen.Dao, batch int) error) (results []*model.DouyinBlacklist, err error) { + buf := make([]*model.DouyinBlacklist, 0, batchSize) + err = d.DO.FindInBatches(&buf, batchSize, func(tx gen.Dao, batch int) error { + defer func() { results = append(results, buf...) }() + return fc(tx, batch) + }) + return results, err +} + +func (d douyinBlacklistDo) FindInBatches(result *[]*model.DouyinBlacklist, batchSize int, fc func(tx gen.Dao, batch int) error) error { + return d.DO.FindInBatches(result, batchSize, fc) +} + +func (d douyinBlacklistDo) Attrs(attrs ...field.AssignExpr) *douyinBlacklistDo { + return d.withDO(d.DO.Attrs(attrs...)) +} + +func (d douyinBlacklistDo) Assign(attrs ...field.AssignExpr) *douyinBlacklistDo { + return d.withDO(d.DO.Assign(attrs...)) +} + +func (d douyinBlacklistDo) Joins(fields ...field.RelationField) *douyinBlacklistDo { + for _, _f := range fields { + d = *d.withDO(d.DO.Joins(_f)) + } + return &d +} + +func (d douyinBlacklistDo) Preload(fields ...field.RelationField) *douyinBlacklistDo { + for _, _f := range fields { + d = *d.withDO(d.DO.Preload(_f)) + } + return &d +} + +func (d douyinBlacklistDo) FirstOrInit() (*model.DouyinBlacklist, error) { + if result, err := d.DO.FirstOrInit(); err != nil { + return nil, err + } else { + return result.(*model.DouyinBlacklist), nil + } +} + +func (d douyinBlacklistDo) FirstOrCreate() (*model.DouyinBlacklist, error) { + if result, err := d.DO.FirstOrCreate(); err != nil { + return nil, err + } else { + return result.(*model.DouyinBlacklist), nil + } +} + +func (d douyinBlacklistDo) FindByPage(offset int, limit int) (result []*model.DouyinBlacklist, count int64, err error) { + result, err = d.Offset(offset).Limit(limit).Find() + if err != nil { + return + } + + if size := len(result); 0 < limit && 0 < size && size < limit { + count = int64(size + offset) + return + } + + count, err = d.Offset(-1).Limit(-1).Count() + return +} + +func (d douyinBlacklistDo) ScanByPage(result interface{}, offset int, limit int) (count int64, err error) { + count, err = d.Count() + if err != nil { + return + } + + err = d.Offset(offset).Limit(limit).Scan(result) + return +} + +func (d douyinBlacklistDo) Scan(result interface{}) (err error) { + return d.DO.Scan(result) +} + +func (d douyinBlacklistDo) Delete(models ...*model.DouyinBlacklist) (result gen.ResultInfo, err error) { + return d.DO.Delete(models) +} + +func (d *douyinBlacklistDo) withDO(do gen.Dao) *douyinBlacklistDo { + d.DO = *do.(*gen.DO) + return d +} diff --git a/internal/repository/mysql/dao/douyin_orders.gen.go b/internal/repository/mysql/dao/douyin_orders.gen.go index e0f0f1c..4123ab8 100644 --- a/internal/repository/mysql/dao/douyin_orders.gen.go +++ b/internal/repository/mysql/dao/douyin_orders.gen.go @@ -29,16 +29,20 @@ func newDouyinOrders(db *gorm.DB, opts ...gen.DOOption) douyinOrders { _douyinOrders.ALL = field.NewAsterisk(tableName) _douyinOrders.ID = field.NewInt64(tableName, "id") _douyinOrders.ShopOrderID = field.NewString(tableName, "shop_order_id") + _douyinOrders.DouyinProductID = field.NewString(tableName, "douyin_product_id") _douyinOrders.OrderStatus = field.NewInt32(tableName, "order_status") _douyinOrders.DouyinUserID = field.NewString(tableName, "douyin_user_id") _douyinOrders.LocalUserID = field.NewString(tableName, "local_user_id") _douyinOrders.ActualReceiveAmount = field.NewInt64(tableName, "actual_receive_amount") + _douyinOrders.ActualPayAmount = field.NewInt64(tableName, "actual_pay_amount") _douyinOrders.PayTypeDesc = field.NewString(tableName, "pay_type_desc") _douyinOrders.Remark = field.NewString(tableName, "remark") _douyinOrders.UserNickname = field.NewString(tableName, "user_nickname") _douyinOrders.RawData = field.NewString(tableName, "raw_data") _douyinOrders.CreatedAt = field.NewTime(tableName, "created_at") _douyinOrders.UpdatedAt = field.NewTime(tableName, "updated_at") + _douyinOrders.RewardGranted = field.NewBool(tableName, "reward_granted") + _douyinOrders.ProductCount = field.NewInt32(tableName, "product_count") _douyinOrders.fillFieldMap() @@ -52,16 +56,20 @@ type douyinOrders struct { ALL field.Asterisk ID field.Int64 ShopOrderID field.String // 抖店订单号 + DouyinProductID field.String // 关联商品ID OrderStatus field.Int32 // 订单状态: 5=已完成 DouyinUserID field.String // 抖店用户ID LocalUserID field.String // 匹配到的本地用户ID ActualReceiveAmount field.Int64 // 实收金额(分) + ActualPayAmount field.Int64 // 实付金额(分) PayTypeDesc field.String // 支付方式描述 Remark field.String // 备注 UserNickname field.String // 抖音昵称 RawData field.String // 原始响应数据 CreatedAt field.Time UpdatedAt field.Time + RewardGranted field.Bool // 奖励已发放: 0=否, 1=是 + ProductCount field.Int32 // 商品数量 fieldMap map[string]field.Expr } @@ -80,16 +88,20 @@ func (d *douyinOrders) updateTableName(table string) *douyinOrders { d.ALL = field.NewAsterisk(table) d.ID = field.NewInt64(table, "id") d.ShopOrderID = field.NewString(table, "shop_order_id") + d.DouyinProductID = field.NewString(table, "douyin_product_id") d.OrderStatus = field.NewInt32(table, "order_status") d.DouyinUserID = field.NewString(table, "douyin_user_id") d.LocalUserID = field.NewString(table, "local_user_id") d.ActualReceiveAmount = field.NewInt64(table, "actual_receive_amount") + d.ActualPayAmount = field.NewInt64(table, "actual_pay_amount") d.PayTypeDesc = field.NewString(table, "pay_type_desc") d.Remark = field.NewString(table, "remark") d.UserNickname = field.NewString(table, "user_nickname") d.RawData = field.NewString(table, "raw_data") d.CreatedAt = field.NewTime(table, "created_at") d.UpdatedAt = field.NewTime(table, "updated_at") + d.RewardGranted = field.NewBool(table, "reward_granted") + d.ProductCount = field.NewInt32(table, "product_count") d.fillFieldMap() @@ -106,19 +118,23 @@ func (d *douyinOrders) GetFieldByName(fieldName string) (field.OrderExpr, bool) } func (d *douyinOrders) fillFieldMap() { - d.fieldMap = make(map[string]field.Expr, 12) + d.fieldMap = make(map[string]field.Expr, 16) d.fieldMap["id"] = d.ID d.fieldMap["shop_order_id"] = d.ShopOrderID + d.fieldMap["douyin_product_id"] = d.DouyinProductID d.fieldMap["order_status"] = d.OrderStatus d.fieldMap["douyin_user_id"] = d.DouyinUserID d.fieldMap["local_user_id"] = d.LocalUserID d.fieldMap["actual_receive_amount"] = d.ActualReceiveAmount + d.fieldMap["actual_pay_amount"] = d.ActualPayAmount d.fieldMap["pay_type_desc"] = d.PayTypeDesc d.fieldMap["remark"] = d.Remark d.fieldMap["user_nickname"] = d.UserNickname d.fieldMap["raw_data"] = d.RawData d.fieldMap["created_at"] = d.CreatedAt d.fieldMap["updated_at"] = d.UpdatedAt + d.fieldMap["reward_granted"] = d.RewardGranted + d.fieldMap["product_count"] = d.ProductCount } func (d douyinOrders) clone(db *gorm.DB) douyinOrders { diff --git a/internal/repository/mysql/dao/douyin_product_rewards.gen.go b/internal/repository/mysql/dao/douyin_product_rewards.gen.go new file mode 100644 index 0000000..264d08b --- /dev/null +++ b/internal/repository/mysql/dao/douyin_product_rewards.gen.go @@ -0,0 +1,352 @@ +// Code generated by gorm.io/gen. DO NOT EDIT. +// Code generated by gorm.io/gen. DO NOT EDIT. +// Code generated by gorm.io/gen. DO NOT EDIT. + +package dao + +import ( + "context" + + "gorm.io/gorm" + "gorm.io/gorm/clause" + "gorm.io/gorm/schema" + + "gorm.io/gen" + "gorm.io/gen/field" + + "gorm.io/plugin/dbresolver" + + "bindbox-game/internal/repository/mysql/model" +) + +func newDouyinProductRewards(db *gorm.DB, opts ...gen.DOOption) douyinProductRewards { + _douyinProductRewards := douyinProductRewards{} + + _douyinProductRewards.douyinProductRewardsDo.UseDB(db, opts...) + _douyinProductRewards.douyinProductRewardsDo.UseModel(&model.DouyinProductRewards{}) + + tableName := _douyinProductRewards.douyinProductRewardsDo.TableName() + _douyinProductRewards.ALL = field.NewAsterisk(tableName) + _douyinProductRewards.ID = field.NewInt64(tableName, "id") + _douyinProductRewards.ProductID = field.NewString(tableName, "product_id") + _douyinProductRewards.ProductName = field.NewString(tableName, "product_name") + _douyinProductRewards.RewardType = field.NewString(tableName, "reward_type") + _douyinProductRewards.RewardPayload = field.NewString(tableName, "reward_payload") + _douyinProductRewards.Quantity = field.NewInt32(tableName, "quantity") + _douyinProductRewards.Status = field.NewInt32(tableName, "status") + _douyinProductRewards.CreatedAt = field.NewTime(tableName, "created_at") + _douyinProductRewards.UpdatedAt = field.NewTime(tableName, "updated_at") + + _douyinProductRewards.fillFieldMap() + + return _douyinProductRewards +} + +// douyinProductRewards 抖店商品奖励规则 +type douyinProductRewards struct { + douyinProductRewardsDo + + ALL field.Asterisk + ID field.Int64 + ProductID field.String // 抖店商品ID + ProductName field.String // 商品名称 + RewardType field.String // 奖励类型 + RewardPayload field.String // 奖励参数JSON + Quantity field.Int32 // 发放数量 + Status field.Int32 // 状态: 1=启用 0=禁用 + CreatedAt field.Time + UpdatedAt field.Time + + fieldMap map[string]field.Expr +} + +func (d douyinProductRewards) Table(newTableName string) *douyinProductRewards { + d.douyinProductRewardsDo.UseTable(newTableName) + return d.updateTableName(newTableName) +} + +func (d douyinProductRewards) As(alias string) *douyinProductRewards { + d.douyinProductRewardsDo.DO = *(d.douyinProductRewardsDo.As(alias).(*gen.DO)) + return d.updateTableName(alias) +} + +func (d *douyinProductRewards) updateTableName(table string) *douyinProductRewards { + d.ALL = field.NewAsterisk(table) + d.ID = field.NewInt64(table, "id") + d.ProductID = field.NewString(table, "product_id") + d.ProductName = field.NewString(table, "product_name") + d.RewardType = field.NewString(table, "reward_type") + d.RewardPayload = field.NewString(table, "reward_payload") + d.Quantity = field.NewInt32(table, "quantity") + d.Status = field.NewInt32(table, "status") + d.CreatedAt = field.NewTime(table, "created_at") + d.UpdatedAt = field.NewTime(table, "updated_at") + + d.fillFieldMap() + + return d +} + +func (d *douyinProductRewards) GetFieldByName(fieldName string) (field.OrderExpr, bool) { + _f, ok := d.fieldMap[fieldName] + if !ok || _f == nil { + return nil, false + } + _oe, ok := _f.(field.OrderExpr) + return _oe, ok +} + +func (d *douyinProductRewards) fillFieldMap() { + d.fieldMap = make(map[string]field.Expr, 9) + d.fieldMap["id"] = d.ID + d.fieldMap["product_id"] = d.ProductID + d.fieldMap["product_name"] = d.ProductName + d.fieldMap["reward_type"] = d.RewardType + d.fieldMap["reward_payload"] = d.RewardPayload + d.fieldMap["quantity"] = d.Quantity + d.fieldMap["status"] = d.Status + d.fieldMap["created_at"] = d.CreatedAt + d.fieldMap["updated_at"] = d.UpdatedAt +} + +func (d douyinProductRewards) clone(db *gorm.DB) douyinProductRewards { + d.douyinProductRewardsDo.ReplaceConnPool(db.Statement.ConnPool) + return d +} + +func (d douyinProductRewards) replaceDB(db *gorm.DB) douyinProductRewards { + d.douyinProductRewardsDo.ReplaceDB(db) + return d +} + +type douyinProductRewardsDo struct{ gen.DO } + +func (d douyinProductRewardsDo) Debug() *douyinProductRewardsDo { + return d.withDO(d.DO.Debug()) +} + +func (d douyinProductRewardsDo) WithContext(ctx context.Context) *douyinProductRewardsDo { + return d.withDO(d.DO.WithContext(ctx)) +} + +func (d douyinProductRewardsDo) ReadDB() *douyinProductRewardsDo { + return d.Clauses(dbresolver.Read) +} + +func (d douyinProductRewardsDo) WriteDB() *douyinProductRewardsDo { + return d.Clauses(dbresolver.Write) +} + +func (d douyinProductRewardsDo) Session(config *gorm.Session) *douyinProductRewardsDo { + return d.withDO(d.DO.Session(config)) +} + +func (d douyinProductRewardsDo) Clauses(conds ...clause.Expression) *douyinProductRewardsDo { + return d.withDO(d.DO.Clauses(conds...)) +} + +func (d douyinProductRewardsDo) Returning(value interface{}, columns ...string) *douyinProductRewardsDo { + return d.withDO(d.DO.Returning(value, columns...)) +} + +func (d douyinProductRewardsDo) Not(conds ...gen.Condition) *douyinProductRewardsDo { + return d.withDO(d.DO.Not(conds...)) +} + +func (d douyinProductRewardsDo) Or(conds ...gen.Condition) *douyinProductRewardsDo { + return d.withDO(d.DO.Or(conds...)) +} + +func (d douyinProductRewardsDo) Select(conds ...field.Expr) *douyinProductRewardsDo { + return d.withDO(d.DO.Select(conds...)) +} + +func (d douyinProductRewardsDo) Where(conds ...gen.Condition) *douyinProductRewardsDo { + return d.withDO(d.DO.Where(conds...)) +} + +func (d douyinProductRewardsDo) Order(conds ...field.Expr) *douyinProductRewardsDo { + return d.withDO(d.DO.Order(conds...)) +} + +func (d douyinProductRewardsDo) Distinct(cols ...field.Expr) *douyinProductRewardsDo { + return d.withDO(d.DO.Distinct(cols...)) +} + +func (d douyinProductRewardsDo) Omit(cols ...field.Expr) *douyinProductRewardsDo { + return d.withDO(d.DO.Omit(cols...)) +} + +func (d douyinProductRewardsDo) Join(table schema.Tabler, on ...field.Expr) *douyinProductRewardsDo { + return d.withDO(d.DO.Join(table, on...)) +} + +func (d douyinProductRewardsDo) LeftJoin(table schema.Tabler, on ...field.Expr) *douyinProductRewardsDo { + return d.withDO(d.DO.LeftJoin(table, on...)) +} + +func (d douyinProductRewardsDo) RightJoin(table schema.Tabler, on ...field.Expr) *douyinProductRewardsDo { + return d.withDO(d.DO.RightJoin(table, on...)) +} + +func (d douyinProductRewardsDo) Group(cols ...field.Expr) *douyinProductRewardsDo { + return d.withDO(d.DO.Group(cols...)) +} + +func (d douyinProductRewardsDo) Having(conds ...gen.Condition) *douyinProductRewardsDo { + return d.withDO(d.DO.Having(conds...)) +} + +func (d douyinProductRewardsDo) Limit(limit int) *douyinProductRewardsDo { + return d.withDO(d.DO.Limit(limit)) +} + +func (d douyinProductRewardsDo) Offset(offset int) *douyinProductRewardsDo { + return d.withDO(d.DO.Offset(offset)) +} + +func (d douyinProductRewardsDo) Scopes(funcs ...func(gen.Dao) gen.Dao) *douyinProductRewardsDo { + return d.withDO(d.DO.Scopes(funcs...)) +} + +func (d douyinProductRewardsDo) Unscoped() *douyinProductRewardsDo { + return d.withDO(d.DO.Unscoped()) +} + +func (d douyinProductRewardsDo) Create(values ...*model.DouyinProductRewards) error { + if len(values) == 0 { + return nil + } + return d.DO.Create(values) +} + +func (d douyinProductRewardsDo) CreateInBatches(values []*model.DouyinProductRewards, batchSize int) error { + return d.DO.CreateInBatches(values, batchSize) +} + +// Save : !!! underlying implementation is different with GORM +// The method is equivalent to executing the statement: db.Clauses(clause.OnConflict{UpdateAll: true}).Create(values) +func (d douyinProductRewardsDo) Save(values ...*model.DouyinProductRewards) error { + if len(values) == 0 { + return nil + } + return d.DO.Save(values) +} + +func (d douyinProductRewardsDo) First() (*model.DouyinProductRewards, error) { + if result, err := d.DO.First(); err != nil { + return nil, err + } else { + return result.(*model.DouyinProductRewards), nil + } +} + +func (d douyinProductRewardsDo) Take() (*model.DouyinProductRewards, error) { + if result, err := d.DO.Take(); err != nil { + return nil, err + } else { + return result.(*model.DouyinProductRewards), nil + } +} + +func (d douyinProductRewardsDo) Last() (*model.DouyinProductRewards, error) { + if result, err := d.DO.Last(); err != nil { + return nil, err + } else { + return result.(*model.DouyinProductRewards), nil + } +} + +func (d douyinProductRewardsDo) Find() ([]*model.DouyinProductRewards, error) { + result, err := d.DO.Find() + return result.([]*model.DouyinProductRewards), err +} + +func (d douyinProductRewardsDo) FindInBatch(batchSize int, fc func(tx gen.Dao, batch int) error) (results []*model.DouyinProductRewards, err error) { + buf := make([]*model.DouyinProductRewards, 0, batchSize) + err = d.DO.FindInBatches(&buf, batchSize, func(tx gen.Dao, batch int) error { + defer func() { results = append(results, buf...) }() + return fc(tx, batch) + }) + return results, err +} + +func (d douyinProductRewardsDo) FindInBatches(result *[]*model.DouyinProductRewards, batchSize int, fc func(tx gen.Dao, batch int) error) error { + return d.DO.FindInBatches(result, batchSize, fc) +} + +func (d douyinProductRewardsDo) Attrs(attrs ...field.AssignExpr) *douyinProductRewardsDo { + return d.withDO(d.DO.Attrs(attrs...)) +} + +func (d douyinProductRewardsDo) Assign(attrs ...field.AssignExpr) *douyinProductRewardsDo { + return d.withDO(d.DO.Assign(attrs...)) +} + +func (d douyinProductRewardsDo) Joins(fields ...field.RelationField) *douyinProductRewardsDo { + for _, _f := range fields { + d = *d.withDO(d.DO.Joins(_f)) + } + return &d +} + +func (d douyinProductRewardsDo) Preload(fields ...field.RelationField) *douyinProductRewardsDo { + for _, _f := range fields { + d = *d.withDO(d.DO.Preload(_f)) + } + return &d +} + +func (d douyinProductRewardsDo) FirstOrInit() (*model.DouyinProductRewards, error) { + if result, err := d.DO.FirstOrInit(); err != nil { + return nil, err + } else { + return result.(*model.DouyinProductRewards), nil + } +} + +func (d douyinProductRewardsDo) FirstOrCreate() (*model.DouyinProductRewards, error) { + if result, err := d.DO.FirstOrCreate(); err != nil { + return nil, err + } else { + return result.(*model.DouyinProductRewards), nil + } +} + +func (d douyinProductRewardsDo) FindByPage(offset int, limit int) (result []*model.DouyinProductRewards, count int64, err error) { + result, err = d.Offset(offset).Limit(limit).Find() + if err != nil { + return + } + + if size := len(result); 0 < limit && 0 < size && size < limit { + count = int64(size + offset) + return + } + + count, err = d.Offset(-1).Limit(-1).Count() + return +} + +func (d douyinProductRewardsDo) ScanByPage(result interface{}, offset int, limit int) (count int64, err error) { + count, err = d.Count() + if err != nil { + return + } + + err = d.Offset(offset).Limit(limit).Scan(result) + return +} + +func (d douyinProductRewardsDo) Scan(result interface{}) (err error) { + return d.DO.Scan(result) +} + +func (d douyinProductRewardsDo) Delete(models ...*model.DouyinProductRewards) (result gen.ResultInfo, err error) { + return d.DO.Delete(models) +} + +func (d *douyinProductRewardsDo) withDO(do gen.Dao) *douyinProductRewardsDo { + d.DO = *do.(*gen.DO) + return d +} diff --git a/internal/repository/mysql/dao/gen.go b/internal/repository/mysql/dao/gen.go index abb958a..ba544a5 100644 --- a/internal/repository/mysql/dao/gen.go +++ b/internal/repository/mysql/dao/gen.go @@ -28,6 +28,9 @@ var ( AuditRollbackLogs *auditRollbackLogs Banner *banner Channels *channels + DouyinBlacklist *douyinBlacklist + DouyinOrders *douyinOrders + DouyinProductRewards *douyinProductRewards GamePassPackages *gamePassPackages GameTicketLogs *gameTicketLogs IssuePositionClaims *issuePositionClaims @@ -36,6 +39,7 @@ var ( LivestreamPrizes *livestreamPrizes LogOperation *logOperation LogRequest *logRequest + LotteryRefundLogs *lotteryRefundLogs MatchingCardTypes *matchingCardTypes MenuActions *menuActions Menus *menus @@ -62,9 +66,11 @@ var ( SystemItemCards *systemItemCards SystemTitleEffects *systemTitleEffects SystemTitles *systemTitles + TaskCenterEventLogs *taskCenterEventLogs TaskCenterTaskRewards *taskCenterTaskRewards TaskCenterTaskTiers *taskCenterTaskTiers TaskCenterTasks *taskCenterTasks + TaskCenterUserProgress *taskCenterUserProgress UserAddresses *userAddresses UserCouponLedger *userCouponLedger UserCoupons *userCoupons @@ -94,6 +100,9 @@ func SetDefault(db *gorm.DB, opts ...gen.DOOption) { AuditRollbackLogs = &Q.AuditRollbackLogs Banner = &Q.Banner Channels = &Q.Channels + DouyinBlacklist = &Q.DouyinBlacklist + DouyinOrders = &Q.DouyinOrders + DouyinProductRewards = &Q.DouyinProductRewards GamePassPackages = &Q.GamePassPackages GameTicketLogs = &Q.GameTicketLogs IssuePositionClaims = &Q.IssuePositionClaims @@ -102,6 +111,7 @@ func SetDefault(db *gorm.DB, opts ...gen.DOOption) { LivestreamPrizes = &Q.LivestreamPrizes LogOperation = &Q.LogOperation LogRequest = &Q.LogRequest + LotteryRefundLogs = &Q.LotteryRefundLogs MatchingCardTypes = &Q.MatchingCardTypes MenuActions = &Q.MenuActions Menus = &Q.Menus @@ -128,9 +138,11 @@ func SetDefault(db *gorm.DB, opts ...gen.DOOption) { SystemItemCards = &Q.SystemItemCards SystemTitleEffects = &Q.SystemTitleEffects SystemTitles = &Q.SystemTitles + TaskCenterEventLogs = &Q.TaskCenterEventLogs TaskCenterTaskRewards = &Q.TaskCenterTaskRewards TaskCenterTaskTiers = &Q.TaskCenterTaskTiers TaskCenterTasks = &Q.TaskCenterTasks + TaskCenterUserProgress = &Q.TaskCenterUserProgress UserAddresses = &Q.UserAddresses UserCouponLedger = &Q.UserCouponLedger UserCoupons = &Q.UserCoupons @@ -161,6 +173,9 @@ func Use(db *gorm.DB, opts ...gen.DOOption) *Query { AuditRollbackLogs: newAuditRollbackLogs(db, opts...), Banner: newBanner(db, opts...), Channels: newChannels(db, opts...), + DouyinBlacklist: newDouyinBlacklist(db, opts...), + DouyinOrders: newDouyinOrders(db, opts...), + DouyinProductRewards: newDouyinProductRewards(db, opts...), GamePassPackages: newGamePassPackages(db, opts...), GameTicketLogs: newGameTicketLogs(db, opts...), IssuePositionClaims: newIssuePositionClaims(db, opts...), @@ -169,6 +184,7 @@ func Use(db *gorm.DB, opts ...gen.DOOption) *Query { LivestreamPrizes: newLivestreamPrizes(db, opts...), LogOperation: newLogOperation(db, opts...), LogRequest: newLogRequest(db, opts...), + LotteryRefundLogs: newLotteryRefundLogs(db, opts...), MatchingCardTypes: newMatchingCardTypes(db, opts...), MenuActions: newMenuActions(db, opts...), Menus: newMenus(db, opts...), @@ -195,9 +211,11 @@ func Use(db *gorm.DB, opts ...gen.DOOption) *Query { SystemItemCards: newSystemItemCards(db, opts...), SystemTitleEffects: newSystemTitleEffects(db, opts...), SystemTitles: newSystemTitles(db, opts...), + TaskCenterEventLogs: newTaskCenterEventLogs(db, opts...), TaskCenterTaskRewards: newTaskCenterTaskRewards(db, opts...), TaskCenterTaskTiers: newTaskCenterTaskTiers(db, opts...), TaskCenterTasks: newTaskCenterTasks(db, opts...), + TaskCenterUserProgress: newTaskCenterUserProgress(db, opts...), UserAddresses: newUserAddresses(db, opts...), UserCouponLedger: newUserCouponLedger(db, opts...), UserCoupons: newUserCoupons(db, opts...), @@ -229,6 +247,9 @@ type Query struct { AuditRollbackLogs auditRollbackLogs Banner banner Channels channels + DouyinBlacklist douyinBlacklist + DouyinOrders douyinOrders + DouyinProductRewards douyinProductRewards GamePassPackages gamePassPackages GameTicketLogs gameTicketLogs IssuePositionClaims issuePositionClaims @@ -237,6 +258,7 @@ type Query struct { LivestreamPrizes livestreamPrizes LogOperation logOperation LogRequest logRequest + LotteryRefundLogs lotteryRefundLogs MatchingCardTypes matchingCardTypes MenuActions menuActions Menus menus @@ -263,9 +285,11 @@ type Query struct { SystemItemCards systemItemCards SystemTitleEffects systemTitleEffects SystemTitles systemTitles + TaskCenterEventLogs taskCenterEventLogs TaskCenterTaskRewards taskCenterTaskRewards TaskCenterTaskTiers taskCenterTaskTiers TaskCenterTasks taskCenterTasks + TaskCenterUserProgress taskCenterUserProgress UserAddresses userAddresses UserCouponLedger userCouponLedger UserCoupons userCoupons @@ -298,6 +322,9 @@ func (q *Query) clone(db *gorm.DB) *Query { AuditRollbackLogs: q.AuditRollbackLogs.clone(db), Banner: q.Banner.clone(db), Channels: q.Channels.clone(db), + DouyinBlacklist: q.DouyinBlacklist.clone(db), + DouyinOrders: q.DouyinOrders.clone(db), + DouyinProductRewards: q.DouyinProductRewards.clone(db), GamePassPackages: q.GamePassPackages.clone(db), GameTicketLogs: q.GameTicketLogs.clone(db), IssuePositionClaims: q.IssuePositionClaims.clone(db), @@ -306,6 +333,7 @@ func (q *Query) clone(db *gorm.DB) *Query { LivestreamPrizes: q.LivestreamPrizes.clone(db), LogOperation: q.LogOperation.clone(db), LogRequest: q.LogRequest.clone(db), + LotteryRefundLogs: q.LotteryRefundLogs.clone(db), MatchingCardTypes: q.MatchingCardTypes.clone(db), MenuActions: q.MenuActions.clone(db), Menus: q.Menus.clone(db), @@ -332,9 +360,11 @@ func (q *Query) clone(db *gorm.DB) *Query { SystemItemCards: q.SystemItemCards.clone(db), SystemTitleEffects: q.SystemTitleEffects.clone(db), SystemTitles: q.SystemTitles.clone(db), + TaskCenterEventLogs: q.TaskCenterEventLogs.clone(db), TaskCenterTaskRewards: q.TaskCenterTaskRewards.clone(db), TaskCenterTaskTiers: q.TaskCenterTaskTiers.clone(db), TaskCenterTasks: q.TaskCenterTasks.clone(db), + TaskCenterUserProgress: q.TaskCenterUserProgress.clone(db), UserAddresses: q.UserAddresses.clone(db), UserCouponLedger: q.UserCouponLedger.clone(db), UserCoupons: q.UserCoupons.clone(db), @@ -374,6 +404,9 @@ func (q *Query) ReplaceDB(db *gorm.DB) *Query { AuditRollbackLogs: q.AuditRollbackLogs.replaceDB(db), Banner: q.Banner.replaceDB(db), Channels: q.Channels.replaceDB(db), + DouyinBlacklist: q.DouyinBlacklist.replaceDB(db), + DouyinOrders: q.DouyinOrders.replaceDB(db), + DouyinProductRewards: q.DouyinProductRewards.replaceDB(db), GamePassPackages: q.GamePassPackages.replaceDB(db), GameTicketLogs: q.GameTicketLogs.replaceDB(db), IssuePositionClaims: q.IssuePositionClaims.replaceDB(db), @@ -382,6 +415,7 @@ func (q *Query) ReplaceDB(db *gorm.DB) *Query { LivestreamPrizes: q.LivestreamPrizes.replaceDB(db), LogOperation: q.LogOperation.replaceDB(db), LogRequest: q.LogRequest.replaceDB(db), + LotteryRefundLogs: q.LotteryRefundLogs.replaceDB(db), MatchingCardTypes: q.MatchingCardTypes.replaceDB(db), MenuActions: q.MenuActions.replaceDB(db), Menus: q.Menus.replaceDB(db), @@ -408,9 +442,11 @@ func (q *Query) ReplaceDB(db *gorm.DB) *Query { SystemItemCards: q.SystemItemCards.replaceDB(db), SystemTitleEffects: q.SystemTitleEffects.replaceDB(db), SystemTitles: q.SystemTitles.replaceDB(db), + TaskCenterEventLogs: q.TaskCenterEventLogs.replaceDB(db), TaskCenterTaskRewards: q.TaskCenterTaskRewards.replaceDB(db), TaskCenterTaskTiers: q.TaskCenterTaskTiers.replaceDB(db), TaskCenterTasks: q.TaskCenterTasks.replaceDB(db), + TaskCenterUserProgress: q.TaskCenterUserProgress.replaceDB(db), UserAddresses: q.UserAddresses.replaceDB(db), UserCouponLedger: q.UserCouponLedger.replaceDB(db), UserCoupons: q.UserCoupons.replaceDB(db), @@ -440,6 +476,9 @@ type queryCtx struct { AuditRollbackLogs *auditRollbackLogsDo Banner *bannerDo Channels *channelsDo + DouyinBlacklist *douyinBlacklistDo + DouyinOrders *douyinOrdersDo + DouyinProductRewards *douyinProductRewardsDo GamePassPackages *gamePassPackagesDo GameTicketLogs *gameTicketLogsDo IssuePositionClaims *issuePositionClaimsDo @@ -448,6 +487,7 @@ type queryCtx struct { LivestreamPrizes *livestreamPrizesDo LogOperation *logOperationDo LogRequest *logRequestDo + LotteryRefundLogs *lotteryRefundLogsDo MatchingCardTypes *matchingCardTypesDo MenuActions *menuActionsDo Menus *menusDo @@ -474,9 +514,11 @@ type queryCtx struct { SystemItemCards *systemItemCardsDo SystemTitleEffects *systemTitleEffectsDo SystemTitles *systemTitlesDo + TaskCenterEventLogs *taskCenterEventLogsDo TaskCenterTaskRewards *taskCenterTaskRewardsDo TaskCenterTaskTiers *taskCenterTaskTiersDo TaskCenterTasks *taskCenterTasksDo + TaskCenterUserProgress *taskCenterUserProgressDo UserAddresses *userAddressesDo UserCouponLedger *userCouponLedgerDo UserCoupons *userCouponsDo @@ -506,6 +548,9 @@ func (q *Query) WithContext(ctx context.Context) *queryCtx { AuditRollbackLogs: q.AuditRollbackLogs.WithContext(ctx), Banner: q.Banner.WithContext(ctx), Channels: q.Channels.WithContext(ctx), + DouyinBlacklist: q.DouyinBlacklist.WithContext(ctx), + DouyinOrders: q.DouyinOrders.WithContext(ctx), + DouyinProductRewards: q.DouyinProductRewards.WithContext(ctx), GamePassPackages: q.GamePassPackages.WithContext(ctx), GameTicketLogs: q.GameTicketLogs.WithContext(ctx), IssuePositionClaims: q.IssuePositionClaims.WithContext(ctx), @@ -514,6 +559,7 @@ func (q *Query) WithContext(ctx context.Context) *queryCtx { LivestreamPrizes: q.LivestreamPrizes.WithContext(ctx), LogOperation: q.LogOperation.WithContext(ctx), LogRequest: q.LogRequest.WithContext(ctx), + LotteryRefundLogs: q.LotteryRefundLogs.WithContext(ctx), MatchingCardTypes: q.MatchingCardTypes.WithContext(ctx), MenuActions: q.MenuActions.WithContext(ctx), Menus: q.Menus.WithContext(ctx), @@ -540,9 +586,11 @@ func (q *Query) WithContext(ctx context.Context) *queryCtx { SystemItemCards: q.SystemItemCards.WithContext(ctx), SystemTitleEffects: q.SystemTitleEffects.WithContext(ctx), SystemTitles: q.SystemTitles.WithContext(ctx), + TaskCenterEventLogs: q.TaskCenterEventLogs.WithContext(ctx), TaskCenterTaskRewards: q.TaskCenterTaskRewards.WithContext(ctx), TaskCenterTaskTiers: q.TaskCenterTaskTiers.WithContext(ctx), TaskCenterTasks: q.TaskCenterTasks.WithContext(ctx), + TaskCenterUserProgress: q.TaskCenterUserProgress.WithContext(ctx), UserAddresses: q.UserAddresses.WithContext(ctx), UserCouponLedger: q.UserCouponLedger.WithContext(ctx), UserCoupons: q.UserCoupons.WithContext(ctx), diff --git a/internal/repository/mysql/dao/livestream_activities.gen.go b/internal/repository/mysql/dao/livestream_activities.gen.go index ffa9058..aa823e2 100644 --- a/internal/repository/mysql/dao/livestream_activities.gen.go +++ b/internal/repository/mysql/dao/livestream_activities.gen.go @@ -34,11 +34,16 @@ func newLivestreamActivities(db *gorm.DB, opts ...gen.DOOption) livestreamActivi _livestreamActivities.AccessCode = field.NewString(tableName, "access_code") _livestreamActivities.DouyinProductID = field.NewString(tableName, "douyin_product_id") _livestreamActivities.Status = field.NewInt32(tableName, "status") + _livestreamActivities.CommitmentAlgo = field.NewString(tableName, "commitment_algo") + _livestreamActivities.CommitmentSeedMaster = field.NewBytes(tableName, "commitment_seed_master") + _livestreamActivities.CommitmentSeedHash = field.NewBytes(tableName, "commitment_seed_hash") + _livestreamActivities.CommitmentStateVersion = field.NewInt32(tableName, "commitment_state_version") _livestreamActivities.StartTime = field.NewTime(tableName, "start_time") _livestreamActivities.EndTime = field.NewTime(tableName, "end_time") _livestreamActivities.CreatedAt = field.NewTime(tableName, "created_at") _livestreamActivities.UpdatedAt = field.NewTime(tableName, "updated_at") _livestreamActivities.DeletedAt = field.NewField(tableName, "deleted_at") + _livestreamActivities.TicketPrice = field.NewInt32(tableName, "ticket_price") _livestreamActivities.fillFieldMap() @@ -49,19 +54,24 @@ func newLivestreamActivities(db *gorm.DB, opts ...gen.DOOption) livestreamActivi type livestreamActivities struct { livestreamActivitiesDo - ALL field.Asterisk - ID field.Int64 // 主键ID - Name field.String // 活动名称 - StreamerName field.String // 主播名称 - StreamerContact field.String // 主播联系方式 - AccessCode field.String // 唯一访问码 - DouyinProductID field.String // 关联抖店商品ID - Status field.Int32 // 状态:1进行中 2已结束 - StartTime field.Time // 开始时间 - EndTime field.Time // 结束时间 - CreatedAt field.Time // 创建时间 - UpdatedAt field.Time // 更新时间 - DeletedAt field.Field // 删除时间 + ALL field.Asterisk + ID field.Int64 // 主键ID + Name field.String // 活动名称 + StreamerName field.String // 主播名称 + StreamerContact field.String // 主播联系方式 + AccessCode field.String // 唯一访问码 + DouyinProductID field.String // 关联抖店商品ID + Status field.Int32 // 状态:1进行中 2已结束 + CommitmentAlgo field.String // 承诺算法版本 + CommitmentSeedMaster field.Bytes // 主种子(32字节) + CommitmentSeedHash field.Bytes // 种子SHA256哈希 + CommitmentStateVersion field.Int32 // 状态版本 + StartTime field.Time // 开始时间 + EndTime field.Time // 结束时间 + CreatedAt field.Time // 创建时间 + UpdatedAt field.Time // 更新时间 + DeletedAt field.Field // 删除时间 + TicketPrice field.Int32 fieldMap map[string]field.Expr } @@ -85,11 +95,16 @@ func (l *livestreamActivities) updateTableName(table string) *livestreamActiviti l.AccessCode = field.NewString(table, "access_code") l.DouyinProductID = field.NewString(table, "douyin_product_id") l.Status = field.NewInt32(table, "status") + l.CommitmentAlgo = field.NewString(table, "commitment_algo") + l.CommitmentSeedMaster = field.NewBytes(table, "commitment_seed_master") + l.CommitmentSeedHash = field.NewBytes(table, "commitment_seed_hash") + l.CommitmentStateVersion = field.NewInt32(table, "commitment_state_version") l.StartTime = field.NewTime(table, "start_time") l.EndTime = field.NewTime(table, "end_time") l.CreatedAt = field.NewTime(table, "created_at") l.UpdatedAt = field.NewTime(table, "updated_at") l.DeletedAt = field.NewField(table, "deleted_at") + l.TicketPrice = field.NewInt32(table, "ticket_price") l.fillFieldMap() @@ -106,7 +121,7 @@ func (l *livestreamActivities) GetFieldByName(fieldName string) (field.OrderExpr } func (l *livestreamActivities) fillFieldMap() { - l.fieldMap = make(map[string]field.Expr, 12) + l.fieldMap = make(map[string]field.Expr, 17) l.fieldMap["id"] = l.ID l.fieldMap["name"] = l.Name l.fieldMap["streamer_name"] = l.StreamerName @@ -114,11 +129,16 @@ func (l *livestreamActivities) fillFieldMap() { l.fieldMap["access_code"] = l.AccessCode l.fieldMap["douyin_product_id"] = l.DouyinProductID l.fieldMap["status"] = l.Status + l.fieldMap["commitment_algo"] = l.CommitmentAlgo + l.fieldMap["commitment_seed_master"] = l.CommitmentSeedMaster + l.fieldMap["commitment_seed_hash"] = l.CommitmentSeedHash + l.fieldMap["commitment_state_version"] = l.CommitmentStateVersion l.fieldMap["start_time"] = l.StartTime l.fieldMap["end_time"] = l.EndTime l.fieldMap["created_at"] = l.CreatedAt l.fieldMap["updated_at"] = l.UpdatedAt l.fieldMap["deleted_at"] = l.DeletedAt + l.fieldMap["ticket_price"] = l.TicketPrice } func (l livestreamActivities) clone(db *gorm.DB) livestreamActivities { diff --git a/internal/repository/mysql/dao/livestream_draw_logs.gen.go b/internal/repository/mysql/dao/livestream_draw_logs.gen.go index 2a4b0f7..fd6131a 100644 --- a/internal/repository/mysql/dao/livestream_draw_logs.gen.go +++ b/internal/repository/mysql/dao/livestream_draw_logs.gen.go @@ -31,14 +31,18 @@ func newLivestreamDrawLogs(db *gorm.DB, opts ...gen.DOOption) livestreamDrawLogs _livestreamDrawLogs.ActivityID = field.NewInt64(tableName, "activity_id") _livestreamDrawLogs.PrizeID = field.NewInt64(tableName, "prize_id") _livestreamDrawLogs.DouyinOrderID = field.NewInt64(tableName, "douyin_order_id") + _livestreamDrawLogs.ShopOrderID = field.NewString(tableName, "shop_order_id") _livestreamDrawLogs.LocalUserID = field.NewInt64(tableName, "local_user_id") _livestreamDrawLogs.DouyinUserID = field.NewString(tableName, "douyin_user_id") + _livestreamDrawLogs.UserNickname = field.NewString(tableName, "user_nickname") _livestreamDrawLogs.PrizeName = field.NewString(tableName, "prize_name") _livestreamDrawLogs.Level = field.NewInt32(tableName, "level") _livestreamDrawLogs.SeedHash = field.NewString(tableName, "seed_hash") _livestreamDrawLogs.RandValue = field.NewInt64(tableName, "rand_value") _livestreamDrawLogs.WeightsTotal = field.NewInt64(tableName, "weights_total") _livestreamDrawLogs.CreatedAt = field.NewTime(tableName, "created_at") + _livestreamDrawLogs.IsGranted = field.NewBool(tableName, "is_granted") + _livestreamDrawLogs.IsRefunded = field.NewInt32(tableName, "is_refunded") _livestreamDrawLogs.fillFieldMap() @@ -54,14 +58,18 @@ type livestreamDrawLogs struct { ActivityID field.Int64 // 关联livestream_activities.id PrizeID field.Int64 // 关联livestream_prizes.id DouyinOrderID field.Int64 // 关联douyin_orders.id + ShopOrderID field.String // 抖店订单号 LocalUserID field.Int64 // 本地用户ID DouyinUserID field.String // 抖音用户ID + UserNickname field.String // 用户昵称 PrizeName field.String // 中奖奖品名称快照 Level field.Int32 // 奖品等级 SeedHash field.String // 哈希种子 RandValue field.Int64 // 随机值 WeightsTotal field.Int64 // 权重总和 CreatedAt field.Time // 中奖时间 + IsGranted field.Bool // 是否已发放奖品 + IsRefunded field.Int32 // 订单是否已退款 fieldMap map[string]field.Expr } @@ -82,14 +90,18 @@ func (l *livestreamDrawLogs) updateTableName(table string) *livestreamDrawLogs { l.ActivityID = field.NewInt64(table, "activity_id") l.PrizeID = field.NewInt64(table, "prize_id") l.DouyinOrderID = field.NewInt64(table, "douyin_order_id") + l.ShopOrderID = field.NewString(table, "shop_order_id") l.LocalUserID = field.NewInt64(table, "local_user_id") l.DouyinUserID = field.NewString(table, "douyin_user_id") + l.UserNickname = field.NewString(table, "user_nickname") l.PrizeName = field.NewString(table, "prize_name") l.Level = field.NewInt32(table, "level") l.SeedHash = field.NewString(table, "seed_hash") l.RandValue = field.NewInt64(table, "rand_value") l.WeightsTotal = field.NewInt64(table, "weights_total") l.CreatedAt = field.NewTime(table, "created_at") + l.IsGranted = field.NewBool(table, "is_granted") + l.IsRefunded = field.NewInt32(table, "is_refunded") l.fillFieldMap() @@ -106,19 +118,23 @@ func (l *livestreamDrawLogs) GetFieldByName(fieldName string) (field.OrderExpr, } func (l *livestreamDrawLogs) fillFieldMap() { - l.fieldMap = make(map[string]field.Expr, 12) + l.fieldMap = make(map[string]field.Expr, 16) l.fieldMap["id"] = l.ID l.fieldMap["activity_id"] = l.ActivityID l.fieldMap["prize_id"] = l.PrizeID l.fieldMap["douyin_order_id"] = l.DouyinOrderID + l.fieldMap["shop_order_id"] = l.ShopOrderID l.fieldMap["local_user_id"] = l.LocalUserID l.fieldMap["douyin_user_id"] = l.DouyinUserID + l.fieldMap["user_nickname"] = l.UserNickname l.fieldMap["prize_name"] = l.PrizeName l.fieldMap["level"] = l.Level l.fieldMap["seed_hash"] = l.SeedHash l.fieldMap["rand_value"] = l.RandValue l.fieldMap["weights_total"] = l.WeightsTotal l.fieldMap["created_at"] = l.CreatedAt + l.fieldMap["is_granted"] = l.IsGranted + l.fieldMap["is_refunded"] = l.IsRefunded } func (l livestreamDrawLogs) clone(db *gorm.DB) livestreamDrawLogs { diff --git a/internal/repository/mysql/dao/livestream_prizes.gen.go b/internal/repository/mysql/dao/livestream_prizes.gen.go index baf491d..5c23e59 100644 --- a/internal/repository/mysql/dao/livestream_prizes.gen.go +++ b/internal/repository/mysql/dao/livestream_prizes.gen.go @@ -39,6 +39,7 @@ func newLivestreamPrizes(db *gorm.DB, opts ...gen.DOOption) livestreamPrizes { _livestreamPrizes.Sort = field.NewInt32(tableName, "sort") _livestreamPrizes.CreatedAt = field.NewTime(tableName, "created_at") _livestreamPrizes.UpdatedAt = field.NewTime(tableName, "updated_at") + _livestreamPrizes.CostPrice = field.NewInt64(tableName, "cost_price") _livestreamPrizes.fillFieldMap() @@ -62,6 +63,7 @@ type livestreamPrizes struct { Sort field.Int32 // 排序 CreatedAt field.Time // 创建时间 UpdatedAt field.Time // 更新时间 + CostPrice field.Int64 // 成本价(分) fieldMap map[string]field.Expr } @@ -90,6 +92,7 @@ func (l *livestreamPrizes) updateTableName(table string) *livestreamPrizes { l.Sort = field.NewInt32(table, "sort") l.CreatedAt = field.NewTime(table, "created_at") l.UpdatedAt = field.NewTime(table, "updated_at") + l.CostPrice = field.NewInt64(table, "cost_price") l.fillFieldMap() @@ -106,7 +109,7 @@ func (l *livestreamPrizes) GetFieldByName(fieldName string) (field.OrderExpr, bo } func (l *livestreamPrizes) fillFieldMap() { - l.fieldMap = make(map[string]field.Expr, 12) + l.fieldMap = make(map[string]field.Expr, 13) l.fieldMap["id"] = l.ID l.fieldMap["activity_id"] = l.ActivityID l.fieldMap["name"] = l.Name @@ -119,6 +122,7 @@ func (l *livestreamPrizes) fillFieldMap() { l.fieldMap["sort"] = l.Sort l.fieldMap["created_at"] = l.CreatedAt l.fieldMap["updated_at"] = l.UpdatedAt + l.fieldMap["cost_price"] = l.CostPrice } func (l livestreamPrizes) clone(db *gorm.DB) livestreamPrizes { diff --git a/internal/repository/mysql/dao/users.gen.go b/internal/repository/mysql/dao/users.gen.go index 47b6648..b95cc91 100644 --- a/internal/repository/mysql/dao/users.gen.go +++ b/internal/repository/mysql/dao/users.gen.go @@ -42,6 +42,7 @@ func newUsers(db *gorm.DB, opts ...gen.DOOption) users { _users.DouyinID = field.NewString(tableName, "douyin_id") _users.ChannelID = field.NewInt64(tableName, "channel_id") _users.DouyinUserID = field.NewString(tableName, "douyin_user_id") + _users.Remark = field.NewString(tableName, "remark") _users.fillFieldMap() @@ -68,6 +69,7 @@ type users struct { DouyinID field.String ChannelID field.Int64 // 渠道ID DouyinUserID field.String + Remark field.String // 管理员备注 fieldMap map[string]field.Expr } @@ -99,6 +101,7 @@ func (u *users) updateTableName(table string) *users { u.DouyinID = field.NewString(table, "douyin_id") u.ChannelID = field.NewInt64(table, "channel_id") u.DouyinUserID = field.NewString(table, "douyin_user_id") + u.Remark = field.NewString(table, "remark") u.fillFieldMap() @@ -115,7 +118,7 @@ func (u *users) GetFieldByName(fieldName string) (field.OrderExpr, bool) { } func (u *users) fillFieldMap() { - u.fieldMap = make(map[string]field.Expr, 15) + u.fieldMap = make(map[string]field.Expr, 16) u.fieldMap["id"] = u.ID u.fieldMap["created_at"] = u.CreatedAt u.fieldMap["updated_at"] = u.UpdatedAt @@ -131,6 +134,7 @@ func (u *users) fillFieldMap() { u.fieldMap["douyin_id"] = u.DouyinID u.fieldMap["channel_id"] = u.ChannelID u.fieldMap["douyin_user_id"] = u.DouyinUserID + u.fieldMap["remark"] = u.Remark } func (u users) clone(db *gorm.DB) users { diff --git a/internal/repository/mysql/model/douyin_blacklist.gen.go b/internal/repository/mysql/model/douyin_blacklist.gen.go new file mode 100644 index 0000000..df03c31 --- /dev/null +++ b/internal/repository/mysql/model/douyin_blacklist.gen.go @@ -0,0 +1,27 @@ +// Code generated by gorm.io/gen. DO NOT EDIT. +// Code generated by gorm.io/gen. DO NOT EDIT. +// Code generated by gorm.io/gen. DO NOT EDIT. + +package model + +import ( + "time" +) + +const TableNameDouyinBlacklist = "douyin_blacklist" + +// DouyinBlacklist 抖音用户黑名单表 +type DouyinBlacklist struct { + ID int64 `gorm:"column:id;primaryKey;autoIncrement:true;comment:主键ID" json:"id"` // 主键ID + DouyinUserID string `gorm:"column:douyin_user_id;not null;comment:抖音用户ID" json:"douyin_user_id"` // 抖音用户ID + Reason string `gorm:"column:reason;comment:拉黑原因" json:"reason"` // 拉黑原因 + OperatorID int64 `gorm:"column:operator_id;comment:操作人ID" json:"operator_id"` // 操作人ID + Status int32 `gorm:"column:status;not null;default:1;comment:状态: 1=生效, 0=已解除" json:"status"` // 状态: 1=生效, 0=已解除 + 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"` // 更新时间 +} + +// TableName DouyinBlacklist's table name +func (*DouyinBlacklist) TableName() string { + return TableNameDouyinBlacklist +} diff --git a/internal/repository/mysql/model/douyin_orders.gen.go b/internal/repository/mysql/model/douyin_orders.gen.go index 9fad7fb..ed06deb 100644 --- a/internal/repository/mysql/model/douyin_orders.gen.go +++ b/internal/repository/mysql/model/douyin_orders.gen.go @@ -13,20 +13,21 @@ const TableNameDouyinOrders = "douyin_orders" // DouyinOrders 抖店订单表 type DouyinOrders struct { ID int64 `gorm:"column:id;primaryKey;autoIncrement:true" json:"id"` - ShopOrderID string `gorm:"column:shop_order_id;not null;comment:抖店订单号" json:"shop_order_id"` // 抖店订单号 - DouyinProductID string `gorm:"column:douyin_product_id;comment:关联商品ID" json:"douyin_product_id"` // 关联商品ID - OrderStatus int32 `gorm:"column:order_status;not null;comment:订单状态: 5=已完成" json:"order_status"` // 订单状态: 5=已完成 - DouyinUserID string `gorm:"column:douyin_user_id;not null;comment:抖店用户ID" json:"douyin_user_id"` // 抖店用户ID - LocalUserID string `gorm:"column:local_user_id;default:0;comment:匹配到的本地用户ID" json:"local_user_id"` // 匹配到的本地用户ID - ActualReceiveAmount int64 `gorm:"column:actual_receive_amount;comment:实收金额(分)" json:"actual_receive_amount"` // 实收金额(分) - PayTypeDesc string `gorm:"column:pay_type_desc;comment:支付方式描述" json:"pay_type_desc"` // 支付方式描述 - Remark string `gorm:"column:remark;comment:备注" json:"remark"` // 备注 - UserNickname string `gorm:"column:user_nickname;comment:抖音昵称" json:"user_nickname"` // 抖音昵称 - RawData string `gorm:"column:raw_data;comment:原始响应数据" json:"raw_data"` // 原始响应数据 - RewardGranted int32 `gorm:"column:reward_granted;not null;default:0;comment:奖励已发放: 0=否, 1=是" json:"reward_granted"` // 奖励已发放 - ProductCount int64 `gorm:"column:product_count;not null;default:1;comment:商品数量" json:"product_count"` // 商品数量 + ShopOrderID string `gorm:"column:shop_order_id;not null;comment:抖店订单号" json:"shop_order_id"` // 抖店订单号 + DouyinProductID string `gorm:"column:douyin_product_id;comment:关联商品ID" json:"douyin_product_id"` // 关联商品ID + OrderStatus int32 `gorm:"column:order_status;not null;comment:订单状态: 5=已完成" json:"order_status"` // 订单状态: 5=已完成 + DouyinUserID string `gorm:"column:douyin_user_id;not null;comment:抖店用户ID" json:"douyin_user_id"` // 抖店用户ID + LocalUserID string `gorm:"column:local_user_id;default:0;comment:匹配到的本地用户ID" json:"local_user_id"` // 匹配到的本地用户ID + ActualReceiveAmount int64 `gorm:"column:actual_receive_amount;comment:实收金额(分)" json:"actual_receive_amount"` // 实收金额(分) + ActualPayAmount int64 `gorm:"column:actual_pay_amount;comment:实付金额(分)" json:"actual_pay_amount"` // 实付金额(分) + PayTypeDesc string `gorm:"column:pay_type_desc;comment:支付方式描述" json:"pay_type_desc"` // 支付方式描述 + Remark string `gorm:"column:remark;comment:备注" json:"remark"` // 备注 + UserNickname string `gorm:"column:user_nickname;comment:抖音昵称" json:"user_nickname"` // 抖音昵称 + RawData string `gorm:"column:raw_data;comment:原始响应数据" json:"raw_data"` // 原始响应数据 CreatedAt time.Time `gorm:"column:created_at;default:CURRENT_TIMESTAMP(3)" json:"created_at"` UpdatedAt time.Time `gorm:"column:updated_at;default:CURRENT_TIMESTAMP(3)" json:"updated_at"` + RewardGranted int32 `gorm:"column:reward_granted;not null;comment:奖励已发放: 0=否, 1=是" json:"reward_granted"` // 奖励已发放: 0=否, 1=是 + ProductCount int32 `gorm:"column:product_count;not null;default:1;comment:商品数量" json:"product_count"` // 商品数量 } // TableName DouyinOrders's table name diff --git a/internal/repository/mysql/model/douyin_product_rewards.gen.go b/internal/repository/mysql/model/douyin_product_rewards.gen.go new file mode 100644 index 0000000..71445a4 --- /dev/null +++ b/internal/repository/mysql/model/douyin_product_rewards.gen.go @@ -0,0 +1,29 @@ +// Code generated by gorm.io/gen. DO NOT EDIT. +// Code generated by gorm.io/gen. DO NOT EDIT. +// Code generated by gorm.io/gen. DO NOT EDIT. + +package model + +import ( + "time" +) + +const TableNameDouyinProductRewards = "douyin_product_rewards" + +// DouyinProductRewards 抖店商品奖励规则 +type DouyinProductRewards struct { + ID int64 `gorm:"column:id;primaryKey;autoIncrement:true" json:"id"` + ProductID string `gorm:"column:product_id;not null;comment:抖店商品ID" json:"product_id"` // 抖店商品ID + ProductName string `gorm:"column:product_name;not null;comment:商品名称" json:"product_name"` // 商品名称 + RewardType string `gorm:"column:reward_type;not null;comment:奖励类型" json:"reward_type"` // 奖励类型 + RewardPayload string `gorm:"column:reward_payload;comment:奖励参数JSON" json:"reward_payload"` // 奖励参数JSON + Quantity int32 `gorm:"column:quantity;not null;default:1;comment:发放数量" json:"quantity"` // 发放数量 + Status int32 `gorm:"column:status;not null;default:1;comment:状态: 1=启用 0=禁用" json:"status"` // 状态: 1=启用 0=禁用 + CreatedAt time.Time `gorm:"column:created_at;default:CURRENT_TIMESTAMP(3)" json:"created_at"` + UpdatedAt time.Time `gorm:"column:updated_at;default:CURRENT_TIMESTAMP(3)" json:"updated_at"` +} + +// TableName DouyinProductRewards's table name +func (*DouyinProductRewards) TableName() string { + return TableNameDouyinProductRewards +} diff --git a/internal/repository/mysql/model/douyin_product_rewards.go b/internal/repository/mysql/model/douyin_product_rewards.go deleted file mode 100644 index df4b0f0..0000000 --- a/internal/repository/mysql/model/douyin_product_rewards.go +++ /dev/null @@ -1,26 +0,0 @@ -package model - -import ( - "time" - - "gorm.io/datatypes" -) - -const TableNameDouyinProductRewards = "douyin_product_rewards" - -// DouyinProductRewards 抖店商品奖励规则表 -type DouyinProductRewards struct { - ID int64 `gorm:"column:id;primaryKey;autoIncrement:true" json:"id"` - ProductID string `gorm:"column:product_id;not null;uniqueIndex:uk_product_id;comment:抖店商品ID" json:"product_id"` - ProductName string `gorm:"column:product_name;not null;default:'';comment:商品名称" json:"product_name"` - RewardType string `gorm:"column:reward_type;not null;comment:奖励类型" json:"reward_type"` - RewardPayload datatypes.JSON `gorm:"column:reward_payload;comment:奖励参数JSON" json:"reward_payload"` - Quantity int32 `gorm:"column:quantity;not null;default:1;comment:发放数量" json:"quantity"` - Status int32 `gorm:"column:status;not null;default:1;comment:状态: 1=启用 0=禁用" json:"status"` - CreatedAt time.Time `gorm:"column:created_at;default:CURRENT_TIMESTAMP(3)" json:"created_at"` - UpdatedAt time.Time `gorm:"column:updated_at;default:CURRENT_TIMESTAMP(3)" json:"updated_at"` -} - -func (*DouyinProductRewards) TableName() string { - return TableNameDouyinProductRewards -} diff --git a/internal/repository/mysql/model/livestream_activities.gen.go b/internal/repository/mysql/model/livestream_activities.gen.go index 639ea34..aa52e38 100644 --- a/internal/repository/mysql/model/livestream_activities.gen.go +++ b/internal/repository/mysql/model/livestream_activities.gen.go @@ -21,16 +21,16 @@ type LivestreamActivities struct { 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 Status int32 `gorm:"column:status;not null;default:1;comment:状态:1进行中 2已结束" json:"status"` // 状态:1进行中 2已结束 - TicketPrice int64 `gorm:"column:ticket_price;comment:门票价格(分)" json:"ticket_price"` // 门票价格(分) CommitmentAlgo string `gorm:"column:commitment_algo;default:commit-v1;comment:承诺算法版本" json:"commitment_algo"` // 承诺算法版本 CommitmentSeedMaster []byte `gorm:"column:commitment_seed_master;comment:主种子(32字节)" json:"commitment_seed_master"` // 主种子(32字节) CommitmentSeedHash []byte `gorm:"column:commitment_seed_hash;comment:种子SHA256哈希" json:"commitment_seed_hash"` // 种子SHA256哈希 - CommitmentStateVersion int32 `gorm:"column:commitment_state_version;default:0;comment:状态版本" json:"commitment_state_version"` // 状态版本 + CommitmentStateVersion int32 `gorm:"column:commitment_state_version;comment:状态版本" json:"commitment_state_version"` // 状态版本 StartTime time.Time `gorm:"column:start_time;comment:开始时间" json:"start_time"` // 开始时间 EndTime time.Time `gorm:"column:end_time;comment:结束时间" json:"end_time"` // 结束时间 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"` // 更新时间 DeletedAt gorm.DeletedAt `gorm:"column:deleted_at;comment:删除时间" json:"deleted_at"` // 删除时间 + TicketPrice int32 `gorm:"column:ticket_price" json:"ticket_price"` } // TableName LivestreamActivities's table name diff --git a/internal/repository/mysql/model/livestream_draw_logs.gen.go b/internal/repository/mysql/model/livestream_draw_logs.gen.go index 0c28433..52f867c 100644 --- a/internal/repository/mysql/model/livestream_draw_logs.gen.go +++ b/internal/repository/mysql/model/livestream_draw_logs.gen.go @@ -16,18 +16,18 @@ type LivestreamDrawLogs struct { ActivityID int64 `gorm:"column:activity_id;not null;comment:关联livestream_activities.id" json:"activity_id"` // 关联livestream_activities.id PrizeID int64 `gorm:"column:prize_id;not null;comment:关联livestream_prizes.id" json:"prize_id"` // 关联livestream_prizes.id DouyinOrderID int64 `gorm:"column:douyin_order_id;comment:关联douyin_orders.id" json:"douyin_order_id"` // 关联douyin_orders.id - ShopOrderID string `gorm:"column:shop_order_id;default:'';comment:抖店订单号" json:"shop_order_id"` // 抖店订单号 + ShopOrderID string `gorm:"column:shop_order_id;comment:抖店订单号" json:"shop_order_id"` // 抖店订单号 LocalUserID int64 `gorm:"column:local_user_id;comment:本地用户ID" json:"local_user_id"` // 本地用户ID DouyinUserID string `gorm:"column:douyin_user_id;comment:抖音用户ID" json:"douyin_user_id"` // 抖音用户ID - UserNickname string `gorm:"column:user_nickname;default:'';comment:用户昵称" json:"user_nickname"` // 用户昵称 + UserNickname string `gorm:"column:user_nickname;comment:用户昵称" json:"user_nickname"` // 用户昵称 PrizeName string `gorm:"column:prize_name;comment:中奖奖品名称快照" json:"prize_name"` // 中奖奖品名称快照 Level int32 `gorm:"column:level;default:1;comment:奖品等级" json:"level"` // 奖品等级 SeedHash string `gorm:"column:seed_hash;comment:哈希种子" json:"seed_hash"` // 哈希种子 RandValue int64 `gorm:"column:rand_value;comment:随机值" json:"rand_value"` // 随机值 WeightsTotal int64 `gorm:"column:weights_total;comment:权重总和" json:"weights_total"` // 权重总和 - IsGranted int32 `gorm:"column:is_granted;default:0;comment:是否已发放奖品" json:"is_granted"` // 是否已发放奖品 - IsRefunded int32 `gorm:"column:is_refunded;default:0;comment:订单是否已退款" json:"is_refunded"` // 订单是否已退款 CreatedAt time.Time `gorm:"column:created_at;not null;default:CURRENT_TIMESTAMP(3);comment:中奖时间" json:"created_at"` // 中奖时间 + IsGranted bool `gorm:"column:is_granted;comment:是否已发放奖品" json:"is_granted"` // 是否已发放奖品 + IsRefunded int32 `gorm:"column:is_refunded;comment:订单是否已退款" json:"is_refunded"` // 订单是否已退款 } // TableName LivestreamDrawLogs's table name diff --git a/internal/repository/mysql/model/livestream_prizes.gen.go b/internal/repository/mysql/model/livestream_prizes.gen.go index 45a8d25..18b7fcf 100644 --- a/internal/repository/mysql/model/livestream_prizes.gen.go +++ b/internal/repository/mysql/model/livestream_prizes.gen.go @@ -21,10 +21,10 @@ type LivestreamPrizes struct { Remaining int32 `gorm:"column:remaining;not null;default:-1;comment:剩余数量" json:"remaining"` // 剩余数量 Level int32 `gorm:"column:level;not null;default:1;comment:奖品等级" json:"level"` // 奖品等级 ProductID int64 `gorm:"column:product_id;comment:关联系统商品ID" json:"product_id"` // 关联系统商品ID - CostPrice int64 `gorm:"column:cost_price;comment:成本价(分)" json:"cost_price"` // 成本价(分) Sort int32 `gorm:"column:sort;not null;comment:排序" json:"sort"` // 排序 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"` // 更新时间 + CostPrice int64 `gorm:"column:cost_price;comment:成本价(分)" json:"cost_price"` // 成本价(分) } // TableName LivestreamPrizes's table name diff --git a/internal/repository/mysql/model/orders.gen.go b/internal/repository/mysql/model/orders.gen.go index c9e7678..d0a2e71 100644 --- a/internal/repository/mysql/model/orders.gen.go +++ b/internal/repository/mysql/model/orders.gen.go @@ -12,26 +12,26 @@ const TableNameOrders = "orders" // Orders 订单 type Orders struct { - 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"` // 创建时间 - 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(user_members.id)" json:"user_id"` // 下单用户ID(user_members.id) - OrderNo string `gorm:"column:order_no;not null;comment:业务订单号(唯一)" json:"order_no"` // 业务订单号(唯一) - SourceType int32 `gorm:"column:source_type;not null;default:1;comment:来源:1商城/积分 2抽奖 3对对碰 4次数卡 5直播间 6系统发放" json:"source_type"` // 来源:1商城/积分 2抽奖 3对对碰 4次数卡 5直播间 6系统发放 - TotalAmount int64 `gorm:"column:total_amount;not null;comment:订单总金额(分)" json:"total_amount"` // 订单总金额(分) - DiscountAmount int64 `gorm:"column:discount_amount;not null;comment:优惠券抵扣金额(分)" json:"discount_amount"` // 优惠券抵扣金额(分) - PointsAmount int64 `gorm:"column:points_amount;not null;comment:积分抵扣金额(分)" json:"points_amount"` // 积分抵扣金额(分) - ActualAmount int64 `gorm:"column:actual_amount;not null;comment:实际支付金额(分)" json:"actual_amount"` // 实际支付金额(分) - Status int32 `gorm:"column:status;not null;default:1;comment:订单状态:1待支付 2已支付 3已取消 4已退款" json:"status"` // 订单状态:1待支付 2已支付 3已取消 4已退款 - PayPreorderID int64 `gorm:"column:pay_preorder_id;comment:关联预支付单ID(payment_preorder.id)" json:"pay_preorder_id"` // 关联预支付单ID(payment_preorder.id) - PaidAt time.Time `gorm:"column:paid_at;comment:支付完成时间" json:"paid_at"` // 支付完成时间 - CancelledAt time.Time `gorm:"column:cancelled_at;comment:取消时间" json:"cancelled_at"` // 取消时间 - UserAddressID int64 `gorm:"column:user_address_id;comment:收货地址ID(user_addresses.id)" json:"user_address_id"` // 收货地址ID(user_addresses.id) - IsConsumed int32 `gorm:"column:is_consumed;not null;comment:是否已履约/消耗(对虚拟资产)" json:"is_consumed"` // 是否已履约/消耗(对虚拟资产) - PointsLedgerID int64 `gorm:"column:points_ledger_id;comment:积分扣减流水ID(user_points_ledger.id)" json:"points_ledger_id"` // 积分扣减流水ID(user_points_ledger.id) - CouponID int64 `gorm:"column:coupon_id;comment:使用的优惠券ID" json:"coupon_id"` // 使用的优惠券ID - ItemCardID int64 `gorm:"column:item_card_id;comment:使用的道具卡ID" json:"item_card_id"` // 使用的道具卡ID - Remark string `gorm:"column:remark;comment:备注" json:"remark"` // 备注 + 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"` // 创建时间 + 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(user_members.id)" json:"user_id"` // 下单用户ID(user_members.id) + OrderNo string `gorm:"column:order_no;not null;comment:业务订单号(唯一)" json:"order_no"` // 业务订单号(唯一) + SourceType int32 `gorm:"column:source_type;not null;default:1;comment:来源:1商城直购 2抽奖票据 3其他" json:"source_type"` // 来源:1商城直购 2抽奖票据 3其他 + TotalAmount int64 `gorm:"column:total_amount;not null;comment:订单总金额(分)" json:"total_amount"` // 订单总金额(分) + DiscountAmount int64 `gorm:"column:discount_amount;not null;comment:优惠券抵扣金额(分)" json:"discount_amount"` // 优惠券抵扣金额(分) + PointsAmount int64 `gorm:"column:points_amount;not null;comment:积分抵扣金额(分)" json:"points_amount"` // 积分抵扣金额(分) + ActualAmount int64 `gorm:"column:actual_amount;not null;comment:实际支付金额(分)" json:"actual_amount"` // 实际支付金额(分) + Status int32 `gorm:"column:status;not null;default:1;comment:订单状态:1待支付 2已支付 3已取消 4已退款" json:"status"` // 订单状态:1待支付 2已支付 3已取消 4已退款 + PayPreorderID int64 `gorm:"column:pay_preorder_id;comment:关联预支付单ID(payment_preorder.id)" json:"pay_preorder_id"` // 关联预支付单ID(payment_preorder.id) + PaidAt time.Time `gorm:"column:paid_at;comment:支付完成时间" json:"paid_at"` // 支付完成时间 + CancelledAt time.Time `gorm:"column:cancelled_at;comment:取消时间" json:"cancelled_at"` // 取消时间 + UserAddressID int64 `gorm:"column:user_address_id;comment:收货地址ID(user_addresses.id)" json:"user_address_id"` // 收货地址ID(user_addresses.id) + IsConsumed int32 `gorm:"column:is_consumed;not null;comment:是否已履约/消耗(对虚拟资产)" json:"is_consumed"` // 是否已履约/消耗(对虚拟资产) + PointsLedgerID int64 `gorm:"column:points_ledger_id;comment:积分扣减流水ID(user_points_ledger.id)" json:"points_ledger_id"` // 积分扣减流水ID(user_points_ledger.id) + CouponID int64 `gorm:"column:coupon_id;comment:使用的优惠券ID" json:"coupon_id"` // 使用的优惠券ID + ItemCardID int64 `gorm:"column:item_card_id;comment:使用的道具卡ID" json:"item_card_id"` // 使用的道具卡ID + Remark string `gorm:"column:remark;comment:备注" json:"remark"` // 备注 } // TableName Orders's table name diff --git a/internal/repository/mysql/model/users.gen.go b/internal/repository/mysql/model/users.gen.go index b7aa133..fb1f021 100644 --- a/internal/repository/mysql/model/users.gen.go +++ b/internal/repository/mysql/model/users.gen.go @@ -29,6 +29,7 @@ type Users struct { DouyinID string `gorm:"column:douyin_id" json:"douyin_id"` ChannelID int64 `gorm:"column:channel_id;comment:渠道ID" json:"channel_id"` // 渠道ID DouyinUserID string `gorm:"column:douyin_user_id" json:"douyin_user_id"` + Remark string `gorm:"column:remark;not null;comment:管理员备注" json:"remark"` // 管理员备注 } // TableName Users's table name diff --git a/internal/repository/mysql/test_helper.go b/internal/repository/mysql/test_helper.go new file mode 100644 index 0000000..22f71ff --- /dev/null +++ b/internal/repository/mysql/test_helper.go @@ -0,0 +1,19 @@ +package mysql + +import "gorm.io/gorm" + +// TestRepo exposes a repo implementation for testing purposes +type TestRepo struct { + Db *gorm.DB +} + +func (t *TestRepo) i() {} +func (t *TestRepo) GetDbR() *gorm.DB { return t.Db } +func (t *TestRepo) GetDbW() *gorm.DB { return t.Db } +func (t *TestRepo) DbRClose() error { return nil } +func (t *TestRepo) DbWClose() error { return nil } + +// NewTestRepo creates a new Repo implementation wrapping the given gorm.DB +func NewTestRepo(db *gorm.DB) Repo { + return &TestRepo{Db: db} +} diff --git a/internal/router/interceptor/blacklist.go b/internal/router/interceptor/blacklist.go new file mode 100644 index 0000000..a3f3e09 --- /dev/null +++ b/internal/router/interceptor/blacklist.go @@ -0,0 +1,49 @@ +package interceptor + +import ( + "bindbox-game/internal/code" + "bindbox-game/internal/pkg/core" + "bindbox-game/internal/repository/mysql/model" + "net/http" +) + +// CheckBlacklist 检查用户是否在黑名单中 +func (i *interceptor) CheckBlacklist() core.HandlerFunc { + return func(ctx core.Context) { + // 1. 获取当前用户 ID (需在 Token 认证后使用) + userID := ctx.SessionUserInfo().Id + if userID <= 0 { + // 如果没有用户信息,可能是不需要认证的接口误用了此中间件,或者 Token 解析失败 + // 这里偏向于放行还是拦截需根据业务决定,一般拦截 + ctx.AbortWithError(core.Error(http.StatusUnauthorized, code.AuthorizationError, "未授权访问")) + return + } + + // 2. 查询用户状态 + // 这里每次请求都查库,如果有性能问题后续可加 Redis 缓存 + var status int32 + err := i.db.GetDbR().Model(&model.Users{}). + Select("status"). + Where("id = ?", userID). + Scan(&status).Error + + if err != nil { + // 数据库错误,安全起见放行还是报错?建议报错 + ctx.AbortWithError(core.Error(http.StatusInternalServerError, code.ServerError, "系统繁忙")) + return + } + + // 3. 检查黑名单状态 + if status == model.UserStatusBlacklist { + ctx.AbortWithError(core.Error(http.StatusForbidden, code.ForbiddenError, "账号异常,禁止操作")) + return + } + + // 4. 用户被禁用 + if status == model.UserStatusDisabled { + ctx.AbortWithError(core.Error(http.StatusForbidden, code.ForbiddenError, "账号已被禁用")) + return + } + + } +} diff --git a/internal/router/interceptor/interceptor.go b/internal/router/interceptor/interceptor.go index a00b4e0..8996ce0 100644 --- a/internal/router/interceptor/interceptor.go +++ b/internal/router/interceptor/interceptor.go @@ -10,17 +10,18 @@ import ( var _ Interceptor = (*interceptor)(nil) type Interceptor interface { - // AdminTokenAuthVerify 管理端授权验证 - AdminTokenAuthVerify(ctx core.Context) (sessionUserInfo proposal.SessionUserInfo, err core.BusinessError) + // AdminTokenAuthVerify 管理端授权验证 + AdminTokenAuthVerify(ctx core.Context) (sessionUserInfo proposal.SessionUserInfo, err core.BusinessError) - // AppTokenAuthVerify APP端授权验证 - AppTokenAuthVerify(ctx core.Context) (sessionUserInfo proposal.SessionUserInfo, err core.BusinessError) + // AppTokenAuthVerify APP端授权验证 + AppTokenAuthVerify(ctx core.Context) (sessionUserInfo proposal.SessionUserInfo, err core.BusinessError) - RequireAdminRole() core.HandlerFunc - RequireAdminAction(mark string) core.HandlerFunc + RequireAdminRole() core.HandlerFunc + RequireAdminAction(mark string) core.HandlerFunc + CheckBlacklist() core.HandlerFunc - // i 为了避免被其他包实现 - i() + // i 为了避免被其他包实现 + i() } type interceptor struct { diff --git a/internal/router/router.go b/internal/router/router.go index cce5aa2..cc78d39 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -242,6 +242,13 @@ func NewHTTPMux(logger logger.CustomLogger, db mysql.Repo) (core.Mux, func(), er adminAuthApiRouter.POST("/livestream/activities/:id/commitment/generate", adminHandler.GenerateLivestreamCommitment()) adminAuthApiRouter.GET("/livestream/activities/:id/commitment/summary", adminHandler.GetLivestreamCommitmentSummary()) + // 抖音用户黑名单管理 + adminAuthApiRouter.GET("/blacklist", adminHandler.ListBlacklist()) + adminAuthApiRouter.POST("/blacklist", adminHandler.AddBlacklist()) + adminAuthApiRouter.DELETE("/blacklist/:id", adminHandler.RemoveBlacklist()) + adminAuthApiRouter.GET("/blacklist/check", adminHandler.CheckBlacklist()) + adminAuthApiRouter.POST("/blacklist/batch", adminHandler.BatchAddBlacklist()) + // 系统配置KV adminAuthApiRouter.GET("/system/configs", adminHandler.ListSystemConfigs()) adminAuthApiRouter.POST("/system/configs", adminHandler.UpsertSystemConfig()) @@ -264,6 +271,9 @@ func NewHTTPMux(logger logger.CustomLogger, db mysql.Repo) (core.Mux, func(), er adminAuthApiRouter.GET("/users/:user_id/stats/profit_loss", intc.RequireAdminAction("user:view"), adminHandler.GetUserProfitLossTrend()) adminAuthApiRouter.GET("/users/:user_id/stats/profit_loss_details", intc.RequireAdminAction("user:view"), adminHandler.GetUserProfitLossDetails()) adminAuthApiRouter.GET("/users/:user_id/profile", intc.RequireAdminAction("user:view"), adminHandler.GetUserProfile()) + adminAuthApiRouter.PUT("/users/:user_id/douyin_user_id", intc.RequireAdminAction("user:modify"), adminHandler.UpdateUserDouyinID()) + adminAuthApiRouter.PUT("/users/:user_id/remark", intc.RequireAdminAction("user:modify"), adminHandler.UpdateUserRemark()) + adminAuthApiRouter.PUT("/users/:user_id/status", intc.RequireAdminAction("user:modify"), adminHandler.UpdateUserStatus()) adminAuthApiRouter.GET("/users/:user_id/audit", intc.RequireAdminAction("user:view"), adminHandler.ListUserAuditLogs()) adminAuthApiRouter.POST("/users/:user_id/token", intc.RequireAdminAction("user:token:issue"), adminHandler.IssueUserToken()) @@ -464,9 +474,6 @@ func NewHTTPMux(logger logger.CustomLogger, db mysql.Repo) (core.Mux, func(), er appAuthApiRouter.PUT("/users/:user_id/addresses/:address_id", userHandler.UpdateUserAddress()) appAuthApiRouter.DELETE("/users/:user_id/addresses/:address_id", userHandler.DeleteUserAddress()) - appAuthApiRouter.POST("/pay/wechat/jsapi/preorder", userHandler.WechatJSAPIPreorder()) - appAuthApiRouter.POST("/lottery/join", activityHandler.JoinLottery()) - // 任务中心 APP 端 appAuthApiRouter.GET("/task-center/tasks", taskCenterHandler.ListTasksForApp()) appAuthApiRouter.GET("/task-center/tasks/:id/progress/:user_id", taskCenterHandler.GetTaskProgressForApp()) @@ -481,29 +488,45 @@ func NewHTTPMux(logger logger.CustomLogger, db mysql.Repo) (core.Mux, func(), er appAuthApiRouter.GET("/store/items", appapi.NewStore(logger, db, userSvc).ListStoreItemsForApp()) appAuthApiRouter.GET("/lottery/result", activityHandler.LotteryResultByOrder()) - // 对对碰游戏 - appAuthApiRouter.POST("/matching/preorder", activityHandler.PreOrderMatchingGame()) + // 需要黑名单检查的抽奖接口组 + lotteryGroup := appAuthApiRouter.Group("", intc.CheckBlacklist()) + { + lotteryGroup.POST("/pay/wechat/jsapi/preorder", userHandler.WechatJSAPIPreorder()) // 支付前也检查 + lotteryGroup.POST("/lottery/join", activityHandler.JoinLottery()) + + // 对对碰游戏 + lotteryGroup.POST("/matching/preorder", activityHandler.PreOrderMatchingGame()) + + // 扫雷游戏 + lotteryGroup.POST("/games/enter", gameHandler.EnterGame()) + + // 积分兑换操作也应该检查黑名单 + lotteryGroup.POST("/users/:user_id/points/redeem-coupon", userHandler.RedeemPointsToCoupon()) + lotteryGroup.POST("/users/:user_id/points/redeem-product", userHandler.RedeemPointsToProduct()) + lotteryGroup.POST("/users/:user_id/points/redeem-item-card", userHandler.RedeemPointsToItemCard()) + + // 资产操作(发货/回收) + lotteryGroup.POST("/users/:user_id/inventory/request-shipping", userHandler.RequestShippingBatch()) + lotteryGroup.POST("/users/:user_id/inventory/cancel-shipping", userHandler.CancelShipping()) + lotteryGroup.POST("/users/:user_id/inventory/redeem", userHandler.RedeemInventoryToPoints()) + } + + // 对对碰其他接口(不需要严查黑名单,或者已在preorder查过) appAuthApiRouter.POST("/matching/check", activityHandler.CheckMatchingGame()) appAuthApiRouter.GET("/matching/state", activityHandler.GetMatchingGameState()) - appAuthApiRouter.GET("/matching/cards", activityHandler.GetMatchingGameCards()) // 支付成功后获取游戏数据 + appAuthApiRouter.GET("/matching/cards", activityHandler.GetMatchingGameCards()) // 次数卡 appAuthApiRouter.GET("/game-passes/available", userHandler.GetGamePasses()) appAuthApiRouter.GET("/game-passes/packages", userHandler.GetGamePassPackages()) - appAuthApiRouter.POST("/game-passes/purchase", userHandler.PurchaseGamePassPackage()) + appAuthApiRouter.POST("/game-passes/purchase", userHandler.PurchaseGamePassPackage()) // 购买次数卡是否要查? - // 扫雷游戏 + // 扫雷游戏其他接口 appAuthApiRouter.GET("/users/:user_id/game_tickets", gameHandler.GetMyTickets()) - appAuthApiRouter.POST("/games/enter", gameHandler.EnterGame()) appAuthApiRouter.POST("/users/:user_id/inventory/address-share/create", userHandler.CreateAddressShare()) appAuthApiRouter.POST("/users/:user_id/inventory/address-share/revoke", userHandler.RevokeAddressShare()) - appAuthApiRouter.POST("/users/:user_id/inventory/request-shipping", userHandler.RequestShippingBatch()) - appAuthApiRouter.POST("/users/:user_id/inventory/cancel-shipping", userHandler.CancelShipping()) - appAuthApiRouter.POST("/users/:user_id/inventory/redeem", userHandler.RedeemInventoryToPoints()) - appAuthApiRouter.POST("/users/:user_id/points/redeem-coupon", userHandler.RedeemPointsToCoupon()) - appAuthApiRouter.POST("/users/:user_id/points/redeem-product", userHandler.RedeemPointsToProduct()) - appAuthApiRouter.POST("/users/:user_id/points/redeem-item-card", userHandler.RedeemPointsToItemCard()) + } // 微信支付平台回调(无需鉴权) mux.Group("/api/pay").POST("/wechat/notify", payHandler.WechatNotify()) diff --git a/internal/service/activity/activity_create.go b/internal/service/activity/activity_create.go index a1380fd..c5487d4 100644 --- a/internal/service/activity/activity_create.go +++ b/internal/service/activity/activity_create.go @@ -11,18 +11,18 @@ import ( // 参数: in 活动创建输入 // 返回: 新建的活动记录与错误 func (s *service) CreateActivity(ctx context.Context, in CreateActivityInput) (*model.Activities, error) { - item := &model.Activities{ - Name: in.Name, - Banner: in.Banner, - Image: in.Image, - GameplayIntro: sanitizeHTML(in.GameplayIntro), - ActivityCategoryID: in.ActivityCategoryID, - Status: in.Status, - PriceDraw: in.PriceDraw, - IsBoss: in.IsBoss, - AllowItemCards: in.AllowItemCards != 0, - AllowCoupons: in.AllowCoupons != 0, - } + item := &model.Activities{ + Name: in.Name, + Banner: in.Banner, + Image: in.Image, + GameplayIntro: sanitizeHTML(in.GameplayIntro), + ActivityCategoryID: in.ActivityCategoryID, + Status: in.Status, + PriceDraw: in.PriceDraw, + IsBoss: in.IsBoss, + AllowItemCards: in.AllowItemCards != 0, + AllowCoupons: in.AllowCoupons != 0, + } if in.StartTime != nil { item.StartTime = *in.StartTime @@ -41,15 +41,15 @@ func (s *service) CreateActivity(ctx context.Context, in CreateActivityInput) (* if strings.TrimSpace(item.GameplayIntro) == "" { do = do.Omit(s.writeDB.Activities.GameplayIntro) } - if in.StartTime == nil { + if in.StartTime == nil || in.StartTime.IsZero() { do = do.Omit(s.writeDB.Activities.StartTime) } - if in.EndTime == nil { - do = do.Omit(s.writeDB.Activities.EndTime) - } - // 避免零日期写入新增的时间列 - do = do.Omit(s.writeDB.Activities.ScheduledTime, s.writeDB.Activities.LastSettledAt) - err := do.Create(item) + if in.EndTime == nil || in.EndTime.IsZero() { + do = do.Omit(s.writeDB.Activities.EndTime) + } + // 避免零日期写入新增的时间列 + do = do.Omit(s.writeDB.Activities.ScheduledTime, s.writeDB.Activities.LastSettledAt) + err := do.Create(item) if err != nil { return nil, err } diff --git a/internal/service/activity/activity_order_service.go b/internal/service/activity/activity_order_service.go index 32dcea1..133249c 100644 --- a/internal/service/activity/activity_order_service.go +++ b/internal/service/activity/activity_order_service.go @@ -12,6 +12,8 @@ import ( "encoding/json" "fmt" "time" + + "gorm.io/gorm/clause" ) // ActivityOrderService 活动订单创建服务 @@ -102,6 +104,9 @@ func (s *activityOrderService) CreateActivityOrder(ctx core.Context, req CreateA } // 2. 应用称号折扣 (Title Discount) + // Title effects logic usually doesn't involve race conditions on balance, so we keep it outside/before critical section if possible, + // or inside. Since it's read-only mostly, good to keep. + // NOTE: If title service needs transaction, we might need to refactor it. For now assuming it's safe. titleEffects, _ := s.title.ResolveActiveEffects(ctx.RequestContext(), userID, titlesvc.EffectScope{ ActivityID: &req.ActivityID, IssueID: &req.IssueID, @@ -134,43 +139,69 @@ func (s *activityOrderService) CreateActivityOrder(ctx core.Context, req CreateA } } - // 3. 应用优惠券 (using applyCouponWithCap logic) var appliedCouponVal int64 - if req.CouponID != nil && *req.CouponID > 0 { - fmt.Printf("[订单服务] 尝试优惠券 用户券ID=%d 应用前实付(分)=%d\n", *req.CouponID, order.ActualAmount) - appliedCouponVal = s.applyCouponWithCap(ctx.RequestContext(), userID, order, req.ActivityID, *req.CouponID) - fmt.Printf("[订单服务] 优惠后 实付(分)=%d 累计优惠(分)=%d\n", order.ActualAmount, order.DiscountAmount) - } - // 4. 记录道具卡到备注 (Removed duplicate append here as it was already done in Step 1) - // Log for debugging - if req.ItemCardID != nil && *req.ItemCardID > 0 { - fmt.Printf("[订单服务] 尝试道具卡 用户卡ID=%d 订单号=%s\n", *req.ItemCardID, order.OrderNo) - } + // 开启事务处理订单创建与优惠券扣减 + err := s.writeDB.Transaction(func(tx *dao.Query) error { + var deductionOp func(int64) error - // 5. 保存订单 - if err := s.writeDB.Orders.WithContext(ctx.RequestContext()).Omit(s.writeDB.Orders.PaidAt, s.writeDB.Orders.CancelledAt).Create(order); err != nil { - return nil, err - } - - // 6. 记录优惠券使用明细 - if appliedCouponVal > 0 && req.CouponID != nil && *req.CouponID > 0 { - _ = s.user.RecordOrderCouponUsage(ctx.RequestContext(), order.ID, *req.CouponID, appliedCouponVal) - } - - // 7. 处理0元订单自动支付 - if order.ActualAmount == 0 { - now := time.Now() - _, _ = s.writeDB.Orders.WithContext(ctx.RequestContext()).Where(s.writeDB.Orders.OrderNo.Eq(orderNo)).Updates(map[string]any{ - s.writeDB.Orders.Status.ColumnName().String(): 2, - s.writeDB.Orders.PaidAt.ColumnName().String(): now, - }) - order.Status = 2 - - // 核销优惠券 + // 3. 应用优惠券 (Lock & Calculate) if req.CouponID != nil && *req.CouponID > 0 { - s.consumeCouponOnZeroPay(ctx.RequestContext(), userID, order.ID, *req.CouponID, appliedCouponVal, now) + fmt.Printf("[订单服务] 尝试优惠券 用户券ID=%d 应用前实付(分)=%d\n", *req.CouponID, order.ActualAmount) + + val, op, err := s.applyCouponWithLock(ctx.RequestContext(), tx, userID, order, req.ActivityID, *req.CouponID) + if err != nil { + return err + } + appliedCouponVal = val + deductionOp = op + fmt.Printf("[订单服务] 优惠后 实付(分)=%d 累计优惠(分)=%d\n", order.ActualAmount, order.DiscountAmount) } + + // 4. 记录道具卡到备注 + if req.ItemCardID != nil && *req.ItemCardID > 0 { + fmt.Printf("[订单服务] 尝试道具卡 用户卡ID=%d 订单号=%s\n", *req.ItemCardID, order.OrderNo) + } + + // 5. 保存订单 + if err := tx.Orders.WithContext(ctx.RequestContext()).Omit(tx.Orders.PaidAt, tx.Orders.CancelledAt).Create(order); err != nil { + return err + } + + // Execute deferred deduction now that we have Order ID + if deductionOp != nil { + if err := deductionOp(order.ID); err != nil { + return err + } + } + + // 6. 记录优惠券使用明细 + if appliedCouponVal > 0 && req.CouponID != nil && *req.CouponID > 0 { + err := tx.OrderCoupons.WithContext(ctx.RequestContext()).UnderlyingDB().Exec( + "INSERT IGNORE INTO order_coupons (order_id, user_coupon_id, applied_amount, created_at) VALUES (?,?,?,NOW(3))", + order.ID, *req.CouponID, appliedCouponVal).Error + if err != nil { + return err + } + } + + // 7. 处理0元订单自动支付 + if order.ActualAmount == 0 { + now := time.Now() + _, _ = tx.Orders.WithContext(ctx.RequestContext()).Where(tx.Orders.OrderNo.Eq(orderNo)).Updates(map[string]any{ + tx.Orders.Status.ColumnName().String(): 2, + tx.Orders.PaidAt.ColumnName().String(): now, + }) + order.Status = 2 + + s.consumeCouponOnZeroPayTx(ctx.RequestContext(), tx, userID, order.ID, *req.CouponID, appliedCouponVal, now) + } + return nil + }) + + if err != nil { + fmt.Printf("[订单服务] 创建订单失败: %v\n", err) + return nil, err } fmt.Printf("[订单服务] 订单创建完成 订单号=%s 总额(分)=%d 优惠(分)=%d 实付(分)=%d 状态=%d\n", @@ -182,40 +213,55 @@ func (s *activityOrderService) CreateActivityOrder(ctx core.Context, req CreateA }, nil } -// applyCouponWithCap 优惠券抵扣(含50%封顶与金额券部分使用) -func (s *activityOrderService) applyCouponWithCap(ctx context.Context, userID int64, order *model.Orders, activityID int64, userCouponID int64) int64 { - uc, _ := s.readDB.UserCoupons.WithContext(ctx).Where(s.readDB.UserCoupons.ID.Eq(userCouponID), s.readDB.UserCoupons.UserID.Eq(userID), s.readDB.UserCoupons.Status.Eq(1)).First() +// applyCouponWithLock 锁定计算并返回扣减操作闭包 +// 逻辑:锁定行 -> 计算优惠 -> 返回闭包(闭包内执行 UPDATE Balance + Insert Ledger) +func (s *activityOrderService) applyCouponWithLock(ctx context.Context, tx *dao.Query, userID int64, order *model.Orders, activityID int64, userCouponID int64) (int64, func(int64) error, error) { + // 使用 SELECT ... FOR UPDATE 锁定行 + uc, _ := tx.UserCoupons.WithContext(ctx).Clauses(clause.Locking{Strength: "UPDATE"}).Where( + tx.UserCoupons.ID.Eq(userCouponID), + tx.UserCoupons.UserID.Eq(userID), + ).First() + if uc == nil { - return 0 + return 0, nil, nil } - fmt.Printf("[优惠券] 用户券ID=%d 开始=%s 结束=%s\n", userCouponID, uc.ValidStart.Format(time.RFC3339), func() string { + // 重新检查状态 (status must be 1=Active, or maybe 4 if we allow concurrent usage but that's complex. Let's strict to 1 for new orders) + // 如果是金额券,status=1。 + // 如果是满减券,status=1。 + if uc.Status != 1 { + fmt.Printf("[优惠券] 状态不可用 id=%d status=%d\n", userCouponID, uc.Status) + return 0, nil, nil + } + + fmt.Printf("[优惠券] 用户券ID=%d 开始=%s 结束=%s 余额=%d\n", userCouponID, uc.ValidStart.Format(time.RFC3339), func() string { if uc.ValidEnd.IsZero() { return "无截止" } return uc.ValidEnd.Format(time.RFC3339) - }()) - sc, _ := s.readDB.SystemCoupons.WithContext(ctx).Where(s.readDB.SystemCoupons.ID.Eq(uc.CouponID), s.readDB.SystemCoupons.Status.Eq(1)).First() + }(), uc.BalanceAmount) + + sc, _ := tx.SystemCoupons.WithContext(ctx).Where(tx.SystemCoupons.ID.Eq(uc.CouponID), tx.SystemCoupons.Status.Eq(1)).First() now := time.Now() if sc == nil { fmt.Printf("[优惠券] 模板不存在 用户券ID=%d\n", userCouponID) - return 0 + return 0, nil, nil } if uc.ValidStart.After(now) { fmt.Printf("[优惠券] 未到开始时间 用户券ID=%d\n", userCouponID) - return 0 + return 0, nil, nil } if !uc.ValidEnd.IsZero() && uc.ValidEnd.Before(now) { fmt.Printf("[优惠券] 已过期 用户券ID=%d\n", userCouponID) - return 0 + return 0, nil, nil } scopeOK := (sc.ScopeType == 1) || (sc.ScopeType == 2 && sc.ActivityID == activityID) if !scopeOK { fmt.Printf("[优惠券] 范围不匹配 用户券ID=%d scope_type=%d\n", userCouponID, sc.ScopeType) - return 0 + return 0, nil, nil } if order.TotalAmount < sc.MinSpend { fmt.Printf("[优惠券] 未达使用门槛 用户券ID=%d min_spend(分)=%d 订单总额(分)=%d\n", userCouponID, sc.MinSpend, order.TotalAmount) - return 0 + return 0, nil, nil } // 50% 封顶 @@ -223,17 +269,13 @@ func (s *activityOrderService) applyCouponWithCap(ctx context.Context, userID in remainingCap := cap - order.DiscountAmount if remainingCap <= 0 { fmt.Printf("[优惠券] 已达封顶\n") - return 0 + return 0, nil, nil } applied := int64(0) switch sc.DiscountType { - case 1: // 金额券 - var bal int64 - _ = s.repo.GetDbR().Raw("SELECT COALESCE(balance_amount,0) FROM user_coupons WHERE id=?", userCouponID).Scan(&bal).Error - if bal <= 0 { - bal = sc.DiscountValue - } + case 1: // 金额券 (Atomic Deduction) + var bal = uc.BalanceAmount if bal > 0 { if bal > remainingCap { applied = remainingCap @@ -267,54 +309,105 @@ func (s *activityOrderService) applyCouponWithCap(ctx context.Context, userID in applied = order.ActualAmount } if applied <= 0 { - return 0 + return 0, nil, nil } + // Update order struct order.ActualAmount -= applied order.DiscountAmount += applied order.Remark += fmt.Sprintf("|c:%d:%d", userCouponID, applied) - fmt.Printf("[优惠券] 本次抵扣(分)=%d\n", applied) + fmt.Printf("[优惠券] 本次抵扣(分)=%d 余额更新扣减(Defer)\n", applied) - return applied + // Construct deferred operation + op := func(orderID int64) error { + if sc.DiscountType == 1 { + // 金额券:扣余额 + newBal := uc.BalanceAmount - applied + newStatus := int32(1) + if newBal <= 0 { + newBal = 0 + newStatus = 2 // Used/Exhausted + } + // 使用乐观锁或直接 Update,因为我们已经加了行锁 (FOR UPDATE) + res := tx.UserCoupons.WithContext(ctx).UnderlyingDB().Exec( + "UPDATE user_coupons SET balance_amount = ?, status = ? WHERE id = ?", + newBal, newStatus, userCouponID) + if res.Error != nil { + return res.Error + } + + // 记录扣减流水 + _ = tx.UserCouponLedger.WithContext(ctx).Create(&model.UserCouponLedger{ + UserID: userID, + UserCouponID: userCouponID, + ChangeAmount: -applied, // Negative for deduction + BalanceAfter: newBal, + OrderID: orderID, + Action: "usage", + CreatedAt: time.Now(), + }) + } else { + // 满减/折扣券:标记为冻结 (4) 以防止并在使用 + // 支付成功后 -> 2 + // 超时/取消 -> 1 + res := tx.UserCoupons.WithContext(ctx).UnderlyingDB().Exec( + "UPDATE user_coupons SET status = 4 WHERE id = ? AND status = 1", userCouponID) + if res.Error != nil { + return res.Error + } + if res.RowsAffected == 0 { + return fmt.Errorf("coupon conflict for id %d", userCouponID) + } + + // 满减券流水 + _ = tx.UserCouponLedger.WithContext(ctx).Create(&model.UserCouponLedger{ + UserID: userID, + UserCouponID: userCouponID, + ChangeAmount: 0, + BalanceAfter: 0, + OrderID: orderID, + Action: "usage", + CreatedAt: time.Now(), + }) + } + return nil + } + + return applied, op, nil } -// consumeCouponOnZeroPay 0元支付时核销优惠券 -func (s *activityOrderService) consumeCouponOnZeroPay(ctx context.Context, userID int64, orderID int64, userCouponID int64, applied int64, now time.Time) { - uc, _ := s.readDB.UserCoupons.WithContext(ctx).Where(s.readDB.UserCoupons.ID.Eq(userCouponID), s.readDB.UserCoupons.UserID.Eq(userID)).First() +// consumeCouponOnZeroPayTx 0元支付时核销优惠券 (With Tx) +func (s *activityOrderService) consumeCouponOnZeroPayTx(ctx context.Context, tx *dao.Query, userID int64, orderID int64, userCouponID int64, applied int64, now time.Time) { + uc, _ := tx.UserCoupons.WithContext(ctx).Where(tx.UserCoupons.ID.Eq(userCouponID), tx.UserCoupons.UserID.Eq(userID)).First() if uc == nil { return } - sc, _ := s.readDB.SystemCoupons.WithContext(ctx).Where(s.readDB.SystemCoupons.ID.Eq(uc.CouponID)).First() + sc, _ := tx.SystemCoupons.WithContext(ctx).Where(tx.SystemCoupons.ID.Eq(uc.CouponID)).First() if sc == nil { return } - if sc.DiscountType == 1 { // 金额券 - 部分扣减 - var bal int64 - _ = s.repo.GetDbR().Raw("SELECT COALESCE(balance_amount,0) FROM user_coupons WHERE id=?", userCouponID).Scan(&bal).Error - nb := bal - applied - if nb < 0 { - nb = 0 - } - if nb == 0 { - _, _ = s.writeDB.UserCoupons.WithContext(ctx).Where(s.readDB.UserCoupons.ID.Eq(userCouponID)).Updates(map[string]any{ - "balance_amount": nb, - s.readDB.UserCoupons.Status.ColumnName().String(): 2, - s.readDB.UserCoupons.UsedOrderID.ColumnName().String(): orderID, - s.readDB.UserCoupons.UsedAt.ColumnName().String(): now, - }) - } else { - _, _ = s.writeDB.UserCoupons.WithContext(ctx).Where(s.readDB.UserCoupons.ID.Eq(userCouponID)).Updates(map[string]any{ - "balance_amount": nb, - s.readDB.UserCoupons.UsedOrderID.ColumnName().String(): orderID, - s.readDB.UserCoupons.UsedAt.ColumnName().String(): now, - }) - } - } else { // 满减/折扣券 - 直接核销 - _, _ = s.writeDB.UserCoupons.WithContext(ctx).Where(s.readDB.UserCoupons.ID.Eq(userCouponID)).Updates(map[string]any{ - s.readDB.UserCoupons.Status.ColumnName().String(): 2, - s.readDB.UserCoupons.UsedOrderID.ColumnName().String(): orderID, - s.readDB.UserCoupons.UsedAt.ColumnName().String(): now, + // 如果是金额券,余额已经在 applyCouponWithCap 中扣减过了。 + // 这里的逻辑主要是为了记录 used_order_id 等 meta 信息。 + if sc.DiscountType == 1 { // 金额券 + // 状态: + // 如果余额 > 0 -> 状态 1 + // 如果余额 = 0 -> 状态 2 + // 不需要 status=4。 + + // 我们只需要记录用于统计的 used_order_id, used_at + // 注意:amounts update has been done. + _, _ = tx.UserCoupons.WithContext(ctx).Where(tx.UserCoupons.ID.Eq(userCouponID)).Updates(map[string]any{ + tx.UserCoupons.UsedOrderID.ColumnName().String(): orderID, + tx.UserCoupons.UsedAt.ColumnName().String(): now, + }) + } else { // 满减/折扣券 + // Apply 时设置为 4 (Frozen) + // 此时需要确认为 2 (Used) + _, _ = tx.UserCoupons.WithContext(ctx).Where(tx.UserCoupons.ID.Eq(userCouponID)).Updates(map[string]any{ + tx.UserCoupons.Status.ColumnName().String(): 2, + tx.UserCoupons.UsedOrderID.ColumnName().String(): orderID, + tx.UserCoupons.UsedAt.ColumnName().String(): now, }) } } diff --git a/internal/service/activity/concurrency_test.go b/internal/service/activity/concurrency_test.go new file mode 100644 index 0000000..a042b3e --- /dev/null +++ b/internal/service/activity/concurrency_test.go @@ -0,0 +1,176 @@ +package activity + +import ( + "bindbox-game/internal/pkg/core" + "bindbox-game/internal/pkg/logger" + "bindbox-game/internal/repository/mysql" + "bindbox-game/internal/repository/mysql/dao" + "bindbox-game/internal/repository/mysql/model" + "context" + "mime/multipart" + "sync" + "testing" + "time" + + drivermysql "gorm.io/driver/mysql" + "gorm.io/gorm" +) + +// Mock Context +type mockContext struct { + core.Context // Embed interface to satisfy compiler + ctx context.Context +} + +func (m *mockContext) RequestContext() core.StdContext { + return core.StdContext{Context: m.ctx} +} + +// Satisfy compiler for embedded interface methods if needed by runtime checks (unlikely for this test path) +func (m *mockContext) ShouldBindQuery(obj interface{}) error { return nil } +func (m *mockContext) ShouldBindPostForm(obj interface{}) error { return nil } +func (m *mockContext) ShouldBindForm(obj interface{}) error { return nil } +func (m *mockContext) ShouldBindJSON(obj interface{}) error { return nil } +func (m *mockContext) ShouldBindXML(obj interface{}) error { return nil } +func (m *mockContext) ShouldBindURI(obj interface{}) error { return nil } +func (m *mockContext) Redirect(code int, location string) {} +func (m *mockContext) Trace() core.Trace { return nil } +func (m *mockContext) setTrace(trace core.Trace) {} +func (m *mockContext) disableTrace() {} +func (m *mockContext) Logger() logger.CustomLogger { return nil } +func (m *mockContext) setLogger(logger logger.CustomLogger) {} +func (m *mockContext) Payload(payload interface{}) {} +func (m *mockContext) getPayload() interface{} { return nil } +func (m *mockContext) File(filePath string) {} +func (m *mockContext) HTML(name string, obj interface{}) {} +func (m *mockContext) String(str string) {} +func (m *mockContext) XML(obj interface{}) {} +func (m *mockContext) ExcelData(filename string, byteData []byte) {} +func (m *mockContext) FormFile(name string) (*multipart.FileHeader, error) { return nil, nil } +func (m *mockContext) SaveUploadedFile(file *multipart.FileHeader, dst string) error { return nil } +func (m *mockContext) AbortWithError(err core.BusinessError) {} +func (m *mockContext) abortError() core.BusinessError { return nil } + +// Header signature mismatch was an issue. core.Context: Header() http.Header. +// We need imports if we want to implement it fully or just use "core.Context" embedding trick. +// Since we embed, we don't *need* to implement them unless called. +// But some might be called by dependencies we don't see. + +// TestConcurrencyCoupon verifies that concurrent concurrent orders do not over-deduct coupon balance. +// NOTE: This test requires a real DB connection. +func TestConcurrencyCoupon(t *testing.T) { + // 1. Setup DB + dsn := "root:bindbox2025kdy@tcp(150.158.78.154:3306)/dev_game?charset=utf8mb4&parseTime=true&loc=Local" + db, err := gorm.Open(drivermysql.Open(dsn), &gorm.Config{}) + if err != nil { + t.Skipf("Skipping test due to DB connection failure: %v", err) + return + } + + // Initialize dao generic + dao.Use(db) + + // Use helper from mysql package to get a valid Repo implementation + repo := mysql.NewTestRepo(db) + log, _ := logger.NewCustomLogger(nil, logger.WithOutputInConsole()) + + // 2. Init Service + svc := NewActivityOrderService(log, repo) + + // 3. Prepare Data + userID := int64(99999) // Test User + + // Find a valid system coupon (Type 1 - Amount) + var sysCoupon model.SystemCoupons + if err := db.Where("discount_type = 1 AND status = 1").First(&sysCoupon).Error; err != nil { + t.Skipf("No valid system coupon found (Type 1), skipping: %v", err) + return + } + + // Create User Coupon + userCoupon := model.UserCoupons{ + UserID: userID, + CouponID: sysCoupon.ID, + Status: 1, + BalanceAmount: 5000, // 50 yuan + ValidStart: time.Now().Add(-1 * time.Hour), + ValidEnd: time.Now().Add(24 * time.Hour), + CreatedAt: time.Now(), + } + if err := db.Omit("UsedAt", "UsedOrderID").Create(&userCoupon).Error; err != nil { + t.Fatalf("Failed to create user coupon: %v", err) + } + t.Logf("Created test coupon ID: %d with balance 5000", userCoupon.ID) + + // 4. Concurrency Test + var wg sync.WaitGroup + successCount := 0 + failCount := 0 + var mu sync.Mutex + + concurrency := 20 + + for i := 0; i < concurrency; i++ { + wg.Add(1) + go func(idx int) { + defer wg.Done() + + req := CreateActivityOrderRequest{ + UserID: userID, + ActivityID: 1, // Dummy + IssueID: 1, // Dummy + Count: 1, + UnitPrice: 1000, // 10 yuan + SourceType: 2, + CouponID: &userCoupon.ID, + } + + // Mock Context + mockCtx := &mockContext{ctx: context.Background()} + + res, err := svc.CreateActivityOrder(mockCtx, req) + + mu.Lock() + defer mu.Unlock() + if err != nil { + failCount++ + } else { + t.Logf("[%d] Success. Discount: %d", idx, res.AppliedCouponVal) + if res.AppliedCouponVal > 0 { + successCount++ + } + } + }(i) + } + + wg.Wait() + + // 5. Verify Result + var finalCoupon model.UserCoupons + err = db.First(&finalCoupon, userCoupon.ID).Error + if err != nil { + t.Fatalf("Failed to query final coupon: %v", err) + } + + t.Logf("Final Balance: %d. Success Orders with Discount: %d. Failures: %d", finalCoupon.BalanceAmount, successCount, failCount) + + if finalCoupon.BalanceAmount < 0 { + t.Errorf("Balance is negative: %d", finalCoupon.BalanceAmount) + } + + // Verify total deducted + var orderCoupons []model.OrderCoupons + db.Where("user_coupon_id = ?", userCoupon.ID).Find(&orderCoupons) + + totalDeducted := int64(0) + for _, oc := range orderCoupons { + totalDeducted += oc.AppliedAmount + } + + t.Logf("Total Deducted from OrderCoupons table: %d", totalDeducted) + + expectedDeduction := 5000 - finalCoupon.BalanceAmount + if expectedDeduction != totalDeducted { + t.Errorf("Mismatch! Initial-Final(%d) != OrderCoupons Sum(%d)", expectedDeduction, totalDeducted) + } +} diff --git a/internal/service/activity/lottery_process.go b/internal/service/activity/lottery_process.go index 4276b43..bf7b815 100644 --- a/internal/service/activity/lottery_process.go +++ b/internal/service/activity/lottery_process.go @@ -289,8 +289,14 @@ func (s *service) TriggerVirtualShipping(ctx context.Context, orderID int64, ord if u != nil { payerOpenid = u.Openid } - c := configs.Get() - cfg := &wechat.WechatConfig{AppID: c.Wechat.AppID, AppSecret: c.Wechat.AppSecret} + var cfg *wechat.WechatConfig + if dc := sysconfig.GetDynamicConfig(); dc != nil { + wc := dc.GetWechat(ctx) + cfg = &wechat.WechatConfig{AppID: wc.AppID, AppSecret: wc.AppSecret} + } else { + c := configs.Get() + cfg = &wechat.WechatConfig{AppID: c.Wechat.AppID, AppSecret: c.Wechat.AppSecret} + } errUpload := wechat.UploadVirtualShippingForBackground(ctx, cfg, tx.TransactionID, orderNo, payerOpenid, itemsDesc) // 如果发货成功,或者微信提示已经发过货了(10060003),则标记本地订单为已履约 @@ -330,6 +336,7 @@ func (s *service) applyItemCardEffect(ctx context.Context, icID int64, aid int64 if ic != nil && !uic.ValidStart.After(now) && !uic.ValidEnd.Before(now) { scopeOK := (ic.ScopeType == 1) || (ic.ScopeType == 3 && ic.ActivityID == aid) || (ic.ScopeType == 4 && ic.IssueID == iss) if scopeOK { + effectApplied := false if ic.EffectType == 1 && ic.RewardMultiplierX1000 >= 2000 { rw, _ := s.readDB.ActivityRewardSettings.WithContext(ctx).Where(s.readDB.ActivityRewardSettings.ID.Eq(log.RewardID)).First() if rw != nil { @@ -338,17 +345,38 @@ func (s *service) applyItemCardEffect(ctx context.Context, icID int64, aid int64 if prod, _ := s.readDB.Products.WithContext(ctx).Where(s.readDB.Products.ID.Eq(rw.ProductID)).First(); prod != nil { prodName = prod.Name } - _, _ = s.user.GrantRewardToOrder(ctx, log.UserID, usersvc.GrantRewardToOrderRequest{OrderID: log.OrderID, ProductID: rw.ProductID, Quantity: 1, ActivityID: &aid, RewardID: &log.RewardID, Remark: prodName + "(倍数)"}) + // 尝试发放额外奖励 + _, err := s.user.GrantRewardToOrder(ctx, log.UserID, usersvc.GrantRewardToOrderRequest{OrderID: log.OrderID, ProductID: rw.ProductID, Quantity: 1, ActivityID: &aid, RewardID: &log.RewardID, Remark: prodName + "(倍数)"}) + if err != nil { + s.logger.Error("道具卡-Lottery: 双倍奖励发放失败", zap.Int64("order_id", log.OrderID), zap.Error(err)) + } else { + effectApplied = true + s.logger.Info("道具卡-Lottery: 应用双倍奖励成功", zap.Int64("order_id", log.OrderID)) + } + } + } else { + // Other effects? If none implemented, do not void. + s.logger.Warn("道具卡-Lottery: 未知效果类型或参数无效,不消耗卡片", zap.Int32("effect_type", ic.EffectType)) + } + + // Only void if effect was successfully applied + if effectApplied { + _, err := s.writeDB.UserItemCards.WithContext(ctx).Where(s.readDB.UserItemCards.ID.Eq(icID)).Updates(map[string]any{ + "status": 2, + "used_draw_log_id": log.ID, + "used_activity_id": aid, + "used_issue_id": iss, + "used_at": now, + }) + if err != nil { + s.logger.Error("道具卡-Lottery: 核销卡片失败", zap.Int64("icID", icID), zap.Error(err)) } } - _, _ = s.writeDB.UserItemCards.WithContext(ctx).Where(s.readDB.UserItemCards.ID.Eq(icID)).Updates(map[string]any{ - "status": 2, - "used_draw_log_id": log.ID, - "used_activity_id": aid, - "used_issue_id": iss, - "used_at": now, - }) + } else { + s.logger.Debug("道具卡-Lottery: 范围校验失败") } + } else { + s.logger.Debug("道具卡-Lottery: 卡片状态无效") } } diff --git a/internal/service/activity/strategy/default.go b/internal/service/activity/strategy/default.go index 84c091a..5b39c97 100644 --- a/internal/service/activity/strategy/default.go +++ b/internal/service/activity/strategy/default.go @@ -56,14 +56,12 @@ func (s *DefaultStrategy) SelectItemFromCache(rewards []*model.ActivityRewardSet } func (s *DefaultStrategy) selectItemInternal(rewards []*model.ActivityRewardSettings, seedKey []byte, issueID int64, userID int64) (int64, map[string]any, error) { - // 统计有库存的奖品权重 + // 统计所有奖品权重(不再过滤库存为0的项) var total int64 var validCount int for _, r := range rewards { - if r.Quantity != 0 { - total += int64(r.Weight) - validCount++ - } + total += int64(r.Weight) + validCount++ } if total <= 0 { return 0, nil, fmt.Errorf("no weight: total_rewards=%d, valid_with_stock=%d", len(rewards), validCount) @@ -84,9 +82,6 @@ func (s *DefaultStrategy) selectItemInternal(rewards []*model.ActivityRewardSett var acc int64 var picked int64 for _, r := range rewards { - if r.Quantity == 0 { - continue - } acc += int64(r.Weight) if rnd < acc { picked = r.ID @@ -112,17 +107,7 @@ func (s *DefaultStrategy) SelectItemBySlot(ctx context.Context, activityID int64 } func (s *DefaultStrategy) GrantReward(ctx context.Context, userID int64, rewardID int64) error { - // 【使用乐观锁扣减库存】直接用 Quantity > 0 作为更新条件,避免并发超卖 - result, err := s.write.ActivityRewardSettings.WithContext(ctx).Where( - s.write.ActivityRewardSettings.ID.Eq(rewardID), - s.write.ActivityRewardSettings.Quantity.Gt(0), // 乐观锁:只有库存>0才能扣减 - ).UpdateSimple(s.write.ActivityRewardSettings.Quantity.Add(-1)) - if err != nil { - return err - } - if result.RowsAffected == 0 { - return errors.New("sold out or reward not found") - } + // 默认策略(纯权重模式)不再执行库存扣减 return nil } diff --git a/internal/service/douyin/order_sync.go b/internal/service/douyin/order_sync.go index 6e4dd9e..5ab7522 100644 --- a/internal/service/douyin/order_sync.go +++ b/internal/service/douyin/order_sync.go @@ -11,11 +11,13 @@ import ( "encoding/json" "fmt" "io" + "math" "net/http" "net/url" "strconv" "strings" "time" + "unicode" "go.uber.org/zap" @@ -29,10 +31,10 @@ const ( ) type Service interface { - // FetchAndSyncOrders 从抖店 API 获取订单并同步到本地 (原有按用户同步逻辑) + // FetchAndSyncOrders 从抖店 API 获取订单并同步到本地 (按绑定用户同步) FetchAndSyncOrders(ctx context.Context) (*SyncResult, error) - // SyncShopOrders 同步店铺全量订单 (专供直播间等全扫描场景) - SyncShopOrders(ctx context.Context, activityID int64) (*SyncResult, error) + // SyncAllOrders 批量同步所有订单变更 (基于更新时间,不分状态) + SyncAllOrders(ctx context.Context, duration time.Duration) (*SyncResult, error) // ListOrders 获取本地抖店订单列表 ListOrders(ctx context.Context, page, pageSize int, status *int) ([]*model.DouyinOrders, int64, error) // GetConfig 获取抖店配置 @@ -188,83 +190,7 @@ func (s *service) FetchAndSyncOrders(ctx context.Context) (*SyncResult, error) { return result, nil } -// SyncShopOrders 同步店铺全量订单 (专供直播间等全扫描场景) -func (s *service) SyncShopOrders(ctx context.Context, activityID int64) (*SyncResult, error) { - cfg, err := s.GetConfig(ctx) - if err != nil { - return nil, fmt.Errorf("获取配置失败: %w", err) - } - - // 临时:强制使用用户提供的最新 Cookie (调试模式) - // if cfg.Cookie == "" || len(cfg.Cookie) < 100 { - cfg.Cookie = "passport_csrf_token=afcc4debfeacce6454979bb9465999dc; passport_csrf_token_default=afcc4debfeacce6454979bb9465999dc; is_staff_user=false; zsgw_business_data=%7B%22uuid%22%3A%22fa769974-ba17-4daf-94cb-3162ba299c40%22%2C%22platform%22%3A%22pc%22%2C%22source%22%3A%22seo.fxg.jinritemai.com%22%7D; s_v_web_id=verify_mjqlw6yx_mNQjOEnB_oXBo_4Etb_AVQ9_7tQGH9WORNRy; SHOP_ID=47668214; PIGEON_CID=3501298428676440; x-web-secsdk-uid=663d5a20-e75c-4789-bc98-839744bf70bc; Hm_lvt_b6520b076191ab4b36812da4c90f7a5e=1766891015,1766979339,1767628404,1768381245; HMACCOUNT=95F3EBE1C47ED196; ttcid=7962a054674f4dd7bf895af73ae3f34142; passport_mfa_token=CjfZetGovLzEQb6MwoEpMQnvCSomMC9o0P776kEFy77vhrRCAdFvvrnTSpTXY2aib8hCdU5w3tQvGkoKPAAAAAAAAAAAAABP88E%2FGYNOqYg7lJ6fcoAzlVHbNi0bqTR%2Fru8noACGHR%2BtNjtq%2FnW9rBK32mcHCC5TzRDW8YYOGPax0WwgAiIBA3WMQyg%3D; source=seo.fxg.jinritemai.com; gfkadpd=4272,23756; csrf_session_id=b7b4150c5eeefaede4ef5e71473e9dc1; Hm_lpvt_b6520b076191ab4b36812da4c90f7a5e=1768381314; ttwid=1%7CAwu3-vdDBhOP12XdEzmCJlbyX3Qt_5RcioPVgjBIDps%7C1768381315%7Ca763fd05ed6fa274ed997007385cc0090896c597cfac0b812c962faf34f04897; tt_scid=f4YqIWnO3OdWrfVz0YVnJmYahx-qu9o9j.VZC2op7nwrQRodgrSh1ka0Ow3g5nyKd42a; odin_tt=bcf942ae72bd6b4b8f357955b71cc21199b6aec5e9acee4ce64f80704f08ea1cbaaa6e70f444f6a09712806aa424f4d0cce236e77b0bfa2991aa8a23dab27e1e; passport_auth_status=b3b3a865e0bd3857e6a28ea5a6854830%2C228cf6630632c26472c096506639ed6e; passport_auth_status_ss=b3b3a865e0bd3857e6a28ea5a6854830%2C228cf6630632c26472c096506639ed6e; uid_tt=4dfa662033e2e4eefe629ad8815f076f; uid_tt_ss=4dfa662033e2e4eefe629ad8815f076f; sid_tt=4cc6aa2f1a6e338ec72d663a0b611d3c; sessionid=4cc6aa2f1a6e338ec72d663a0b611d3c; sessionid_ss=4cc6aa2f1a6e338ec72d663a0b611d3c; PHPSESSID=a1b2fd062c1346e5c6f94bac3073cd7d; PHPSESSID_SS=a1b2fd062c1346e5c6f94bac3073cd7d; ucas_c0=CkEKBTEuMC4wEJOIgezc9NazaRjmJiD61rDnqc2DBCiwITCb1oDYuM3aB0Cpt53LBkip69nNBlC_vL6Ekt3t1GdYbhIU2LuS6yHmC8_SKu9Jok5ToGxfQIg; ucas_c0_ss=CkEKBTEuMC4wEJOIgezc9NazaRjmJiD61rDnqc2DBCiwITCb1oDYuM3aB0Cpt53LBkip69nNBlC_vL6Ekt3t1GdYbhIU2LuS6yHmC8_SKu9Jok5ToGxfQIg; ecom_gray_shop_id=156231010; sid_guard=4cc6aa2f1a6e338ec72d663a0b611d3c%7C1768381360%7C5184000%7CSun%2C+15-Mar-2026+09%3A02%3A40+GMT; session_tlb_tag=sttt%7C4%7CTMaqLxpuM47HLWY6C2EdPP________-x3_oZvMYjz8-Uw3dAm6JiPFDhS1ih9XTV79AgAO_5cvo%3D; sid_ucp_v1=1.0.0-KGRmNzNkZjM2YjUwZDk2M2M0MjQ5MGE2NzNkNGZkZjNhZWFhYmJkMmIKGQib1oDYuM3aBxCwt53LBhiwISAMOAZA9AcaAmxmIiA0Y2M2YWEyZjFhNmUzMzhlYzcyZDY2M2EwYjYxMWQzYw; ssid_ucp_v1=1.0.0-KGRmNzNkZjM2YjUwZDk2M2M0MjQ5MGE2NzNkNGZkZjNhZWFhYmJkMmIKGQib1oDYuM3aBxCwt53LBhiwISAMOAZA9AcaAmxmIiA0Y2M2YWEyZjFhNmUzMzhlYzcyZDY2M2EwYjYxMWQzYw; COMPASS_LUOPAN_DT=session_7595137429020049706; BUYIN_SASID=SID2_7595138116287152420" - // } - - // 1. 获取活动信息以拿到 ProductID - var activity model.LivestreamActivities - if err := s.repo.GetDbR().Where("id = ?", activityID).First(&activity).Error; err != nil { - return nil, fmt.Errorf("查询活动失败: %w", err) - } - - fmt.Printf("[DEBUG] 直播间全量同步开始: ActivityID=%d, ProductID=%s\n", activityID, activity.DouyinProductID) - - // 构建请求参数 - queryParams := url.Values{ - "page": {"0"}, - "pageSize": {"20"}, // 增大每页数量以确保覆盖 - "order_by": {"create_time"}, - "order": {"desc"}, - "appid": {"1"}, - "_bid": {"ffa_order"}, - "aid": {"4272"}, - // 新增过滤参数 - "order_status": {"stock_up"}, // 仅同步待发货/备货中 - "tab": {"stock_up"}, - "compact_time[select]": {"create_time_start,create_time_end"}, - } - - // 如果活动绑定了某些商品,则过滤这些商品 - if activity.DouyinProductID != "" { - queryParams.Set("product", activity.DouyinProductID) - } - - // 2. 抓取订单 - orders, err := s.fetchDouyinOrders(cfg.Cookie, queryParams) - if err != nil { - return nil, fmt.Errorf("抓取全店订单失败: %w", err) - } - - result := &SyncResult{ - TotalFetched: len(orders), - DebugInfo: fmt.Sprintf("Activity: %d, ProductID: %s, Fetched: %d", activityID, activity.DouyinProductID, len(orders)), - } - - // 3. 遍历并同步 - for _, order := range orders { - // SyncOrder 内部会根据 status 更新或创建,传入 productID - isNew, matched := s.SyncOrder(ctx, &order, 0, activity.DouyinProductID) - if isNew { - result.NewOrders++ - } - if matched { - result.MatchedUsers++ - } - - // 查出同步后的订单记录 - var dbOrder model.DouyinOrders - if err := s.repo.GetDbR().Where("shop_order_id = ?", order.ShopOrderID).First(&dbOrder).Error; err == nil { - result.Orders = append(result.Orders, &dbOrder) - } - - // 【新增】自动将订单与当前活动绑定 (如果尚未绑定) - // 这一步确保即使订单之前存在,也能关联到当前的新活动 ID(如果业务需要一对多,这里可能需要额外表,但目前模型看来是一对一或多对一) - // 假设通过 livestream_draw_logs 关联,或者仅仅是同步下来即可。 - // 目前 SyncOrder 只存 douyin_orders。真正的绑定在 Draw 阶段,或者这里可以做一些预处理。 - // 暂时保持 SyncOrder 原样,因为 SyncResult 返回给前端后,前端会展示 Pending Orders。 - } - - return result, nil -} +// removed SyncShopOrders // 抖店 API 响应结构 type douyinOrderResponse struct { @@ -278,7 +204,8 @@ type DouyinOrderItem struct { ShopOrderID string `json:"shop_order_id"` OrderStatus int `json:"order_status"` UserID string `json:"user_id"` - ActualReceiveAmount string `json:"actual_receive_amount"` + ActualReceiveAmount any `json:"actual_receive_amount"` + ActualPayAmount any `json:"actual_pay_amount"` PayTypeDesc string `json:"pay_type_desc"` Remark string `json:"remark"` UserNickname string `json:"user_nickname"` @@ -346,10 +273,15 @@ func (s *service) fetchDouyinOrders(cookie string, params url.Values) ([]DouyinO var respData douyinOrderResponse if err := json.Unmarshal(body, &respData); err != nil { - s.logger.Error("[抖店API] 解析响应失败", zap.String("response", string(body[:min(len(body), 500)]))) + s.logger.Error("[抖店API] 解析响应失败", zap.String("response", string(body[:min(len(body), 5000)]))) return nil, fmt.Errorf("解析响应失败: %w", err) } + // 临时调试日志:打印第一笔订单的金额字段 + if len(respData.Data) > 0 { + fmt.Printf("[DEBUG] 抖店订单 0 金额测试: RawBody(500)=%s\n", string(body[:min(len(body), 500)])) + } + if respData.St != 0 && respData.Code != 0 { return nil, fmt.Errorf("API 返回错误: %s (ST:%d CODE:%d)", respData.Msg, respData.St, respData.Code) } @@ -361,6 +293,41 @@ func (s *service) fetchDouyinOrders(cookie string, params url.Values) ([]DouyinO func (s *service) SyncOrder(ctx context.Context, item *DouyinOrderItem, suggestUserID int64, productID string) (isNew bool, isMatched bool) { db := s.repo.GetDbW().WithContext(ctx) + // 解析金额工具函数 + parseMoney := func(val any) int64 { + if val == nil { + return 0 + } + // JSON 数字会被解析为 float64 + if f, ok := val.(float64); ok { + // 如果是数值类型,但带有小数部分(如 138.4),通常是元单位 + if f != math.Trunc(f) { + return int64(f*100 + 0.5) + } + // 如果是整数,保持原样(分) + return int64(f) + } + + s := fmt.Sprintf("%v", val) + s = strings.TrimSpace(s) + if s == "" { + return 0 + } + // 只保留数字和点号 (处理 "¥158.40" 这种情况) + var sb strings.Builder + for _, r := range s { + if unicode.IsDigit(r) || r == '.' { + sb.WriteRune(r) + } + } + cleanStr := sb.String() + if f, err := strconv.ParseFloat(cleanStr, 64); err == nil { + // 字符串一律按元转分处理 (兼容旧逻辑) + return int64(f*100 + 0.5) + } + return 0 + } + var order model.DouyinOrders err := db.Where("shop_order_id = ?", item.ShopOrderID).First(&order).Error @@ -374,12 +341,14 @@ func (s *service) SyncOrder(ctx context.Context, item *DouyinOrderItem, suggestU fmt.Printf("[DEBUG] 抖店辅助关联成功: %s -> User %d\n", item.ShopOrderID, suggestUserID) } - // 更新状态 + // 更新状态与金额 (确保之前因解析失败导致的 0 金额被修复) db.Model(&order).Updates(map[string]any{ - "order_status": item.OrderStatus, - "remark": item.Remark, + "order_status": item.OrderStatus, + "remark": item.Remark, + "actual_receive_amount": parseMoney(item.ActualReceiveAmount), + "actual_pay_amount": parseMoney(item.ActualPayAmount), }) - // 重要:同步内存状态,防止后续判断逻辑失效 + // 重要:同步内存状态 order.OrderStatus = int32(item.OrderStatus) order.Remark = item.Remark } else { @@ -392,13 +361,8 @@ func (s *service) SyncOrder(ctx context.Context, item *DouyinOrderItem, suggestU fmt.Printf("[DEBUG] 抖店新订单: %s, UserID: %s, Recommend: %s\n", item.ShopOrderID, item.UserID, localUserIDStr) - // 解析金额 - var amount int64 - if item.ActualReceiveAmount != "" { - if f, err := strconv.ParseFloat(strings.TrimSpace(item.ActualReceiveAmount), 64); err == nil { - amount = int64(f * 100) - } - } + amount := parseMoney(item.ActualReceiveAmount) + payAmount := parseMoney(item.ActualPayAmount) // 计算商品数量:如果指定了 productID,则只统计该商品的数量;否则使用总数量 pCount := item.ProductCount @@ -429,11 +393,12 @@ func (s *service) SyncOrder(ctx context.Context, item *DouyinOrderItem, suggestU order = model.DouyinOrders{ ShopOrderID: item.ShopOrderID, - DouyinProductID: productID, // 写入商品ID - ProductCount: pCount, // 写入计算后的商品数量 + DouyinProductID: productID, // 写入商品ID + ProductCount: int32(pCount), // 写入计算后的商品数量 OrderStatus: int32(item.OrderStatus), DouyinUserID: item.UserID, ActualReceiveAmount: amount, + ActualPayAmount: payAmount, PayTypeDesc: item.PayTypeDesc, Remark: item.Remark, UserNickname: item.UserNickname, @@ -459,21 +424,31 @@ func (s *service) SyncOrder(ctx context.Context, item *DouyinOrderItem, suggestU // ---- 统一处理:发放奖励 ---- isMatched = order.LocalUserID != "" && order.LocalUserID != "0" - if isMatched && order.RewardGranted == 0 && order.OrderStatus == 5 { - localUserID, _ := strconv.ParseInt(order.LocalUserID, 10, 64) - fmt.Printf("[DEBUG] 准备发放奖励: User: %d, ShopOrder: %s\n", localUserID, item.ShopOrderID) + // [修复] 禁用自动发放扫雷资格,防止占用直播间抽奖配额 + /* + if isMatched && order.RewardGranted == 0 && order.OrderStatus == 2 { + // 检查黑名单 + var blacklistCount int64 + if err := db.Table("douyin_blacklist").Where("douyin_user_id = ?", item.UserID).Count(&blacklistCount).Error; err == nil && blacklistCount > 0 { + fmt.Printf("[DEBUG] 用户 %s 在黑名单中,跳过发奖\n", item.UserID) + return isNew, isMatched + } - if localUserID > 0 && s.ticketSvc != nil { - err := s.ticketSvc.GrantTicket(ctx, localUserID, "minesweeper", 1, "douyin_order", order.ID, "抖店订单奖励") - if err == nil { - db.Model(&order).Update("reward_granted", 1) - order.RewardGranted = 1 - fmt.Printf("[DEBUG] 订单 %s 发放奖励成功\n", item.ShopOrderID) - } else { - fmt.Printf("[DEBUG] 订单 %s 发放奖励失败: %v\n", item.ShopOrderID, err) + localUserID, _ := strconv.ParseInt(order.LocalUserID, 10, 64) + fmt.Printf("[DEBUG] 准备发放奖励: User: %d, ShopOrder: %s\n", localUserID, item.ShopOrderID) + + if localUserID > 0 && s.ticketSvc != nil { + err := s.ticketSvc.GrantTicket(ctx, localUserID, "minesweeper", 1, "douyin_order", order.ID, "抖店订单奖励") + if err == nil { + db.Model(&order).Update("reward_granted", 1) + order.RewardGranted = 1 + fmt.Printf("[DEBUG] 订单 %s 发放奖励成功\n", item.ShopOrderID) + } else { + fmt.Printf("[DEBUG] 订单 %s 发放奖励失败: %v\n", item.ShopOrderID, err) + } } } - } + */ return isNew, isMatched } @@ -485,3 +460,55 @@ func min(a, b int) int { } return b } + +// SyncAllOrders 批量同步所有订单变更 (基于更新时间,不分状态) +func (s *service) SyncAllOrders(ctx context.Context, duration time.Duration) (*SyncResult, error) { + cfg, err := s.GetConfig(ctx) + if err != nil { + return nil, fmt.Errorf("获取配置失败: %w", err) + } + if cfg.Cookie == "" { + return nil, fmt.Errorf("抖店 Cookie 未配置") + } + + // 临时:强制使用用户提供的最新 Cookie (与 SyncShopOrders 保持一致的调试逻辑) + if len(cfg.Cookie) < 100 { + cfg.Cookie = "passport_csrf_token=afcc4debfeacce6454979bb9465999dc; passport_csrf_token_default=afcc4debfeacce6454979bb9465999dc; is_staff_user=false; zsgw_business_data=%7B%22uuid%22%3A%22fa769974-ba17-4daf-94cb-3162ba299c40%22%2C%22platform%22%3A%22pc%22%2C%22source%22%3A%22seo.fxg.jinritemai.com%22%7D; s_v_web_id=verify_mjqlw6yx_mNQjOEnB_oXBo_4Etb_AVQ9_7tQGH9WORNRy; SHOP_ID=47668214; PIGEON_CID=3501298428676440; x-web-secsdk-uid=663d5a20-e75c-4789-bc98-839744bf70bc; Hm_lvt_b6520b076191ab4b36812da4c90f7a5e=1766891015,1766979339,1767628404,1768381245; HMACCOUNT=95F3EBE1C47ED196; ttcid=7962a054674f4dd7bf895af73ae3f34142; passport_mfa_token=CjfZetGovLzEQb6MwoEpMQnvCSomMC9o0P776kEFy77vhrRCAdFvvrnTSpTXY2aib8hCdU5w3tQvGkoKPAAAAAAAAAAAAABP88E%2FGYNOqYg7lJ6fcoAzlVHbNi0bqTR%2Fru8noACGHR%2BtNjtq%2FnW9rBK32mcHCC5TzRDW8YYOGPax0WwgAiIBA3WMQyg%3D; source=seo.fxg.jinritemai.com; gfkadpd=4272,23756; csrf_session_id=b7b4150c5eeefaede4ef5e71473e9dc1; Hm_lpvt_b6520b076191ab4b36812da4c90f7a5e=1768381314; ttwid=1%7CAwu3-vdDBhOP12XdEzmCJlbyX3Qt_5RcioPVgjBIDps%7C1768381315%7Ca763fd05ed6fa274ed997007385cc0090896c597cfac0b812c962faf34f04897; tt_scid=f4YqIWnO3OdWrfVz0YVnJmYahx-qu9o9j.VZC2op7nwrQRodgrSh1ka0Ow3g5nyKd42a; odin_tt=bcf942ae72bd6b4b8f357955b71cc21199b6aec5e9acee4ce64f80704f08ea1cbaaa6e70f444f6a09712806aa424f4d0cce236e77b0bfa2991aa8a23dab27e1e; passport_auth_status=b3b3a865e0bd3857e6a28ea5a6854830%2C228cf6630632c26472c096506639ed6e; passport_auth_status_ss=b3b3a865e0bd3857e6a28ea5a6854830%2C228cf6630632c26472c096506639ed6e; uid_tt=4dfa662033e2e4eefe629ad8815f076f; uid_tt_ss=4dfa662033e2e4eefe629ad8815f076f; sid_tt=4cc6aa2f1a6e338ec72d663a0b611d3c; sessionid=4cc6aa2f1a6e338ec72d663a0b611d3c; sessionid_ss=4cc6aa2f1a6e338ec72d663a0b611d3c; PHPSESSID=a1b2fd062c1346e5c6f94bac3073cd7d; PHPSESSID_SS=a1b2fd062c1346e5c6f94bac3073cd7d; ucas_c0=CkEKBTEuMC4wEJOIgezc9NazaRjmJiD61rDnqc2DBCiwITCb1oDYuM3aB0Cpt53LBkip69nNBlC_vL6Ekt3t1GdYbhIU2LuS6yHmC8_SKu9Jok5ToGxfQIg; ucas_c0_ss=CkEKBTEuMC4wEJOIgezc9NazaRjmJiD61rDnqc2DBCiwITCb1oDYuM3aB0Cpt53LBkip69nNBlC_vL6Ekt3t1GdYbhIU2LuS6yHmC8_SKu9Jok5ToGxfQIg; ecom_gray_shop_id=156231010; sid_guard=4cc6aa2f1a6e338ec72d663a0b611d3c%7C1768381360%7C5184000%7CSun%2C+15-Mar-2026+09%3A02%3A40+GMT; session_tlb_tag=sttt%7C4%7CTMaqLxpuM47HLWY6C2EdPP________-x3_oZvMYjz8-Uw3dAm6JiPFDhS1ih9XTV79AgAO_5cvo%3D; sid_ucp_v1=1.0.0-KGRmNzNkZjM2YjUwZDk2M2M0MjQ5MGE2NzNkNGZkZjNhZWFhYmJkMmIKGQib1oDYuM3aBxCwt53LBhiwISAMOAZA9AcaAmxmIiA0Y2M2YWEyZjFhNmUzMzhlYzcyZDY2M2EwYjYxMWQzYw; ssid_ucp_v1=1.0.0-KGRmNzNkZjM2YjUwZDk2M2M0MjQ5MGE2NzNkNGZkZjNhZWFhYmJkMmIKGQib1oDYuM3aBxCwt53LBhiwISAMOAZA9AcaAmxmIiA0Y2M2YWEyZjFhNmUzMzhlYzcyZDY2M2EwYjYxMWQzYw; COMPASS_LUOPAN_DT=session_7595137429020049706; BUYIN_SASID=SID2_7595138116287152420" + } + + startTime := time.Now().Add(-duration) + + queryParams := url.Values{ + "page": {"0"}, + "pageSize": {"50"}, + "order_by": {"update_time"}, + "order": {"desc"}, + "appid": {"1"}, + "_bid": {"ffa_order"}, + "aid": {"4272"}, + "tab": {"all"}, // 全量状态 + "update_time_start": {strconv.FormatInt(startTime.Unix(), 10)}, + } + + orders, err := s.fetchDouyinOrders(cfg.Cookie, queryParams) + if err != nil { + return nil, fmt.Errorf("抓取增量订单失败: %w", err) + } + + result := &SyncResult{ + TotalFetched: len(orders), + DebugInfo: fmt.Sprintf("UpdateSince: %s, Fetched: %d", startTime.Format("15:04:05"), len(orders)), + } + + for _, order := range orders { + isNew, matched := s.SyncOrder(ctx, &order, 0, "") // 不指定 productID,主要用于更新状态 + if isNew { + result.NewOrders++ + } + if matched { + result.MatchedUsers++ + } + } + + return result, nil +} diff --git a/internal/service/douyin/scheduler.go b/internal/service/douyin/scheduler.go index 861d20f..e4a024c 100644 --- a/internal/service/douyin/scheduler.go +++ b/internal/service/douyin/scheduler.go @@ -24,6 +24,7 @@ func StartDouyinOrderSync(l logger.CustomLogger, repo mysql.Repo, syscfg sysconf // 初始等待30秒让服务完全启动 time.Sleep(30 * time.Second) + firstRun := true for { ctx := context.Background() @@ -59,52 +60,28 @@ func StartDouyinOrderSync(l logger.CustomLogger, repo mysql.Repo, syscfg sysconf } // ========== 自动补发扫雷游戏资格 (针对刚才同步到的订单) ========== - if err := svc.GrantMinesweeperQualifications(ctx); err != nil { - l.Error("[定时补发] 补发扫雷资格失败", zap.Error(err)) - } + // [修复] 禁用自动补发逻辑,防止占用直播间抽奖配额 + // if err := svc.GrantMinesweeperQualifications(ctx); err != nil { + // l.Error("[定时补发] 补发扫雷资格失败", zap.Error(err)) + // } // ========== 自动发放直播间奖品 ========== if err := svc.GrantLivestreamPrizes(ctx); err != nil { l.Error("[定时发放] 发放直播奖品失败", zap.Error(err)) } - // ========== 后置:按活动商品ID同步 (全量兜底) ========== - var activities []model.LivestreamActivities - if err := repo.GetDbR().Where("status = ?", 1).Find(&activities).Error; err == nil && len(activities) > 0 { - l.Info("[抖店定时同步] 发现进行中的直播活动 (全量兜底)", zap.Int("count", len(activities))) - for _, act := range activities { - if act.DouyinProductID == "" { - continue // 跳过未配置商品ID的活动 - } - // SyncShopOrders 会拉取所有订单,如果之前 UserSync 没拉到的(比如未绑定的用户下单),这里可以拉到 - // 并在之后用户绑定时由 GrantMinesweeperQualifications 的关联逻辑进行补救 - result, err := svc.SyncShopOrders(ctx, act.ID) - if err != nil { - l.Error("[抖店定时同步] 活动同步失败", - zap.Int64("activity_id", act.ID), - zap.String("product_id", act.DouyinProductID), - zap.Error(err), - ) - } else { - l.Info("[抖店定时同步] 活动同步成功", - zap.Int64("activity_id", act.ID), - zap.String("product_id", act.DouyinProductID), - zap.Int("total_fetched", result.TotalFetched), - zap.Int("new_orders", result.NewOrders), - ) - } - } + // ========== 核心:批量同步最近所有订单变更 (基于更新时间,不分状态) ========== + // 首次运行同步最近 48 小时以修复潜在的历史遗漏,之后同步最近 1 小时 + syncDuration := 1 * time.Hour + if firstRun { + syncDuration = 48 * time.Hour } - - // ========== 新增:自动补发扫雷游戏资格 ========== - if err := svc.GrantMinesweeperQualifications(ctx); err != nil { - l.Error("[定时补发] 补发扫雷资格失败", zap.Error(err)) - } - - // ========== 新增:自动发放直播间奖品 ========== - if err := svc.GrantLivestreamPrizes(ctx); err != nil { - l.Error("[定时发放] 发放直播奖品失败", zap.Error(err)) + if res, err := svc.SyncAllOrders(ctx, syncDuration); err != nil { + l.Error("[定时同步] 全量同步失败", zap.Error(err)) + } else { + l.Info("[定时同步] 全量同步完成", zap.String("info", res.DebugInfo)) } + firstRun = false // ========== 新增:同步退款状态 ========== if err := svc.SyncRefundStatus(ctx); err != nil { @@ -131,6 +108,12 @@ func (s *service) GrantMinesweeperQualifications(ctx context.Context) error { } for _, u := range users { + // 1.1 检查是否在黑名单中 + var blacklistCount int64 + if err := s.repo.GetDbR().Table("douyin_blacklist").Where("douyin_user_id = ?", u.DouyinUserID).Count(&blacklistCount).Error; err == nil && blacklistCount > 0 { + continue + } + // 2. 查找该抖音ID下未关联(local_user_id=0 or empty)的订单 var orders []model.DouyinOrders if err := db.Where("douyin_user_id = ? AND (local_user_id = '' OR local_user_id = '0')", u.DouyinUserID).Find(&orders).Error; err != nil { @@ -144,15 +127,15 @@ func (s *service) GrantMinesweeperQualifications(ctx context.Context) error { continue } - // 4. 如果是已完成的订单(5),且未发奖,则补发 - if order.OrderStatus == 5 && order.RewardGranted == 0 { + // 4. 如果是已支付待发货的订单(2),且未发奖,则补发 + if order.OrderStatus == 2 && order.RewardGranted == 0 { orderID := order.ID s.logger.Info("[自动补发] 开始补发扫雷资格", zap.Int64("user_id", u.ID), zap.String("shop_order_id", order.ShopOrderID)) // 调用发奖服务 count := int64(1) if order.ProductCount > 0 { - count = order.ProductCount + count = int64(order.ProductCount) } s.logger.Info("[自动补发] 发放数量", zap.Int64("count", count)) @@ -223,7 +206,7 @@ func (s *service) GrantLivestreamPrizes(ctx context.Context) error { zap.String("prize", log.PrizeName), ) - _, err := s.userSvc.GrantReward(ctx, log.LocalUserID, req) + res, err := s.userSvc.GrantReward(ctx, log.LocalUserID, req) if err != nil { s.logger.Error("[自动发放] 发放失败", zap.Error(err)) // 如果发放失败是库存原因等,可能需要告警。暂时不重试,等下个周期。 @@ -231,6 +214,30 @@ func (s *service) GrantLivestreamPrizes(ctx context.Context) error { // 4. 更新发放状态 db.Model(&log).Update("is_granted", 1) s.logger.Info("[自动发放] 发放成功", zap.Int64("log_id", log.ID)) + + // 5. 自动虚拟发货 (本地状态更新) + // 直播间奖品通常为虚拟发货,直接标记为已消费/已发货 + if res != nil && res.OrderID > 0 { + updates := map[string]interface{}{ + "is_consumed": 1, + "updated_at": time.Now(), + } + if err := s.repo.GetDbW().WithContext(ctx).Model(&model.Orders{}).Where("id = ?", res.OrderID).Updates(updates).Error; err != nil { + s.logger.Error("[自动发放] 更新订单状态失败", zap.Int64("order_id", res.OrderID), zap.Error(err)) + } + + // 更新发货记录 + shippingUpdates := map[string]interface{}{ + "status": 2, // 已发货 + "shipped_at": time.Now(), + "updated_at": time.Now(), + } + if err := s.repo.GetDbW().WithContext(ctx).Model(&model.ShippingRecords{}).Where("order_id = ?", res.OrderID).Updates(shippingUpdates).Error; err != nil { + s.logger.Error("[自动发放] 更新发货记录失败", zap.Int64("order_id", res.OrderID), zap.Error(err)) + } else { + s.logger.Info("[自动发放] 虚拟发货完成(本地)", zap.Int64("order_id", res.OrderID)) + } + } } } return nil diff --git a/internal/service/game/token.go b/internal/service/game/token.go index 6d6396b..e8290c4 100644 --- a/internal/service/game/token.go +++ b/internal/service/game/token.go @@ -7,6 +7,7 @@ import ( "bindbox-game/internal/repository/mysql/model" "context" "fmt" + "strings" "time" "github.com/golang-jwt/jwt/v5" @@ -53,18 +54,23 @@ func NewGameTokenService(l logger.CustomLogger, db mysql.Repo, rdb *redis.Client // GenerateToken creates a new game token for a user (does NOT consume ticket, only validates) func (s *gameTokenService) GenerateToken(ctx context.Context, userID int64, username, avatar, gameType string) (token string, ticket string, expiresAt time.Time, err error) { // 1. Check if user has game tickets (do NOT deduct - will be done on match success) - var userTicket model.UserGameTickets - if err = s.db.GetDbR().Where("user_id = ? AND game_code = ? AND available > 0", userID, gameType).First(&userTicket).Error; err != nil { - return "", "", time.Time{}, fmt.Errorf("no available game tickets") + // For free mode, we skip this check + if gameType != "minesweeper_free" { + var userTicket model.UserGameTickets + if err = s.db.GetDbR().Where("user_id = ? AND game_code = ? AND available > 0", userID, gameType).First(&userTicket).Error; err != nil { + return "", "", time.Time{}, fmt.Errorf("no available game tickets") + } } // 2. Generate unique ticket ID ticket = fmt.Sprintf("GT%d%d", userID, time.Now().UnixNano()) // 3. Store ticket in Redis (for single-use validation) + // Value format: "{userID}:{gameType}" (to allow game type verification in settlement) ticketKey := fmt.Sprintf("game:token:ticket:%s", ticket) // Check for error when setting Redis key - CRITICAL FIX - if err := s.redis.Set(ctx, ticketKey, fmt.Sprintf("%d", userID), 30*time.Minute).Err(); err != nil { + redisValue := fmt.Sprintf("%d:%s", userID, gameType) + if err := s.redis.Set(ctx, ticketKey, redisValue, 30*time.Minute).Err(); err != nil { s.logger.Error("Failed to store ticket in Redis", zap.Error(err), zap.String("ticket", ticket), zap.Int64("user_id", userID)) return "", "", time.Time{}, fmt.Errorf("failed to generate ticket: %w", err) } @@ -120,7 +126,7 @@ func (s *gameTokenService) ValidateToken(ctx context.Context, tokenString string // 2. Check if ticket is still valid (not used) // TODO: 临时跳过 Redis 验证,仅记录日志用于排查 ticketKey := fmt.Sprintf("game:token:ticket:%s", claims.Ticket) - storedUserID, err := s.redis.Get(ctx, ticketKey).Result() + storedValue, err := s.redis.Get(ctx, ticketKey).Result() if err != nil { s.logger.Warn("DEBUG: Ticket not found in Redis (SKIPPING validation temporarily)", zap.String("ticket", claims.Ticket), @@ -128,9 +134,19 @@ func (s *gameTokenService) ValidateToken(ctx context.Context, tokenString string zap.Error(err)) // 临时跳过验证,允许游戏继续 // return nil, fmt.Errorf("ticket not found or expired") - } else if storedUserID != fmt.Sprintf("%d", claims.UserID) { - s.logger.Warn("DEBUG: Ticket user mismatch", zap.String("stored", storedUserID), zap.Int64("claim_user", claims.UserID)) - return nil, fmt.Errorf("ticket user mismatch") + } else { + // Parse stored value "userID:gameType" + parts := strings.Split(storedValue, ":") + if len(parts) < 2 { + s.logger.Warn("DEBUG: Invalid ticket format in Redis", zap.String("value", storedValue)) + return nil, fmt.Errorf("invalid ticket format") + } + storedUserID := parts[0] + + if storedUserID != fmt.Sprintf("%d", claims.UserID) { + s.logger.Warn("DEBUG: Ticket user mismatch", zap.String("stored", storedUserID), zap.Int64("claim_user", claims.UserID)) + return nil, fmt.Errorf("ticket user mismatch") + } } s.logger.Info("DEBUG: Token validated successfully", zap.String("ticket", claims.Ticket)) diff --git a/internal/service/game/token_test.go b/internal/service/game/token_test.go new file mode 100644 index 0000000..5ceedde --- /dev/null +++ b/internal/service/game/token_test.go @@ -0,0 +1,121 @@ +package game_test + +import ( + "bindbox-game/internal/repository/mysql" + "bindbox-game/internal/service/game" + "context" + "fmt" + "testing" + "time" + + "bindbox-game/internal/pkg/logger" + + "github.com/alicebob/miniredis/v2" + "github.com/redis/go-redis/v9" + "github.com/stretchr/testify/assert" + "go.uber.org/zap" + "gorm.io/driver/sqlite" + "gorm.io/gorm" +) + +// Mock logger +type MockLogger struct { + logger.CustomLogger +} + +func (l *MockLogger) Info(msg string, fields ...zap.Field) {} +func (l *MockLogger) Error(msg string, fields ...zap.Field) {} +func (l *MockLogger) Warn(msg string, fields ...zap.Field) {} +func (l *MockLogger) Debug(msg string, fields ...zap.Field) {} + +func TestGenerateToken_FreeMode(t *testing.T) { + // 1. Setup Miniredis + mr, err := miniredis.Run() + assert.NoError(t, err) + defer mr.Close() + + rdb := redis.NewClient(&redis.Options{ + Addr: mr.Addr(), + }) + + // 2. Setup GORM (SQLite in-memory) + // We use an empty DB to ensure NO ticket exists + db, err := gorm.Open(sqlite.Open("file::memory:?cache=shared"), &gorm.Config{}) + assert.NoError(t, err) + + // AutoMigrate to make sure table exists (even if empty) to avoid SQL errors + // err = db.AutoMigrate(&model.UserGameTickets{}) + // assert.NoError(t, err) + + repo := mysql.NewTestRepo(db) + + // 3. Create Service + svc := game.NewGameTokenService(&MockLogger{}, repo, rdb) + + // 4. Test Case: minesweeper_free + ctx := context.Background() + userID := int64(12345) + username := "testuser" + gameCode := "minesweeper_free" + + // Should succeed even though DB is empty (bypasses ticket check) + token, ticket, expiresAt, err := svc.GenerateToken(ctx, userID, username, "", gameCode) + + assert.NoError(t, err) + assert.NotEmpty(t, token) + assert.NotEmpty(t, ticket) + assert.True(t, expiresAt.After(time.Now())) + + // 5. Verify Redis Key Format + // Expected: "userID:gameCode" + ticketKey := fmt.Sprintf("game:token:ticket:%s", ticket) + val, err := rdb.Get(ctx, ticketKey).Result() + assert.NoError(t, err) + assert.Equal(t, fmt.Sprintf("%d:%s", userID, gameCode), val) + + // 6. Test Validation + claims, err := svc.ValidateToken(ctx, token) + assert.NoError(t, err) + assert.Equal(t, userID, claims.UserID) + assert.Equal(t, gameCode, claims.GameType) +} + +func TestGenerateToken_PaidMode_NoTicket(t *testing.T) { + // 1. Setup + mr, err := miniredis.Run() + assert.NoError(t, err) + defer mr.Close() + rdb := redis.NewClient(&redis.Options{Addr: mr.Addr()}) + db, _ := gorm.Open(sqlite.Open("file::memory:?cache=shared"), &gorm.Config{}) + // db.AutoMigrate(&model.UserGameTickets{}) // Empty table + repo := mysql.NewTestRepo(db) + svc := game.NewGameTokenService(&MockLogger{}, repo, rdb) + + // 2. Test Case: normal game + // Should FAIL because no ticket in DB + _, _, _, err = svc.GenerateToken(context.Background(), 12345, "user", "", "minesweeper_paid") + + assert.Error(t, err) + assert.Contains(t, err.Error(), "no available game tickets") +} + +func TestValidateToken_LegacyFormat_ShouldFail(t *testing.T) { + // Test that strict mode rejects legacy keys + mr, _ := miniredis.Run() + defer mr.Close() + rdb := redis.NewClient(&redis.Options{Addr: mr.Addr()}) + + svc := game.NewGameTokenService(&MockLogger{}, mysql.NewTestRepo(nil), rdb) + userID := int64(999) + + // Generate valid token first + token, ticket, _, _ := svc.GenerateToken(context.Background(), userID, "user", "", "minesweeper_free") + + // Overwrite Redis with legacy format (just userID) + rdb.Set(context.Background(), "game:token:ticket:"+ticket, fmt.Sprintf("%d", userID), time.Hour) + + // Now Validate - SHOULD FAIL + _, err := svc.ValidateToken(context.Background(), token) + assert.Error(t, err) + assert.Contains(t, err.Error(), "invalid ticket format") +} diff --git a/internal/service/livestream/livestream.go b/internal/service/livestream/livestream.go index 644c713..09010be 100644 --- a/internal/service/livestream/livestream.go +++ b/internal/service/livestream/livestream.go @@ -136,6 +136,7 @@ type CommitmentSummary struct { HasSeed bool `json:"has_seed"` LenSeed int `json:"len_seed_master"` LenHash int `json:"len_seed_hash"` + SeedHashHex string `json:"seed_hash_hex"` // 种子哈希的十六进制表示(可公开) } // DrawReceipt 抽奖凭证 @@ -159,7 +160,7 @@ func (s *service) CreateActivity(ctx context.Context, input CreateActivityInput) StreamerContact: input.StreamerContact, AccessCode: accessCode, DouyinProductID: input.DouyinProductID, - TicketPrice: input.TicketPrice, + TicketPrice: int32(input.TicketPrice), Status: 1, } @@ -195,7 +196,7 @@ func (s *service) UpdateActivity(ctx context.Context, id int64, input UpdateActi updates["douyin_product_id"] = input.DouyinProductID } if input.TicketPrice != nil { - updates["ticket_price"] = *input.TicketPrice + updates["ticket_price"] = int32(*input.TicketPrice) } if input.Status != nil { updates["status"] = *input.Status @@ -364,6 +365,17 @@ func (s *service) DeletePrize(ctx context.Context, prizeID int64) error { // ========== 抽奖逻辑 ========== func (s *service) Draw(ctx context.Context, input DrawInput) (*DrawResult, error) { + // 0. 检查黑名单 + if input.DouyinUserID != "" { + var blacklistCount int64 + if err := s.repo.GetDbR().WithContext(ctx). + Table("douyin_blacklist"). + Where("douyin_user_id = ? AND status = 1", input.DouyinUserID). + Count(&blacklistCount).Error; err == nil && blacklistCount > 0 { + return nil, fmt.Errorf("该用户已被列入黑名单,无法开奖") + } + } + // 1. 获取可用奖品 prizes, err := s.ListPrizes(ctx, input.ActivityID) if err != nil { @@ -566,12 +578,19 @@ func (s *service) GetCommitmentSummary(ctx context.Context, activityID int64) (* return nil, fmt.Errorf("活动不存在: %w", err) } + // 将种子哈希转为十六进制字符串 + seedHashHex := "" + if len(activity.CommitmentSeedHash) > 0 { + seedHashHex = hex.EncodeToString(activity.CommitmentSeedHash) + } + return &CommitmentSummary{ SeedVersion: activity.CommitmentStateVersion, Algo: activity.CommitmentAlgo, HasSeed: len(activity.CommitmentSeedMaster) > 0, LenSeed: len(activity.CommitmentSeedMaster), LenHash: len(activity.CommitmentSeedHash), + SeedHashHex: seedHashHex, }, nil } diff --git a/internal/service/sysconfig/dynamic_config.go b/internal/service/sysconfig/dynamic_config.go index a758b0c..49ae7b9 100644 --- a/internal/service/sysconfig/dynamic_config.go +++ b/internal/service/sysconfig/dynamic_config.go @@ -324,23 +324,25 @@ func (d *DynamicConfig) GetCOS(ctx context.Context) COSConfig { // GetWechat 获取微信小程序配置 func (d *DynamicConfig) GetWechat(ctx context.Context) WechatConfig { + staticCfg := configs.Get().Wechat return WechatConfig{ - AppID: d.Get(ctx, KeyWechatAppID), - AppSecret: d.Get(ctx, KeyWechatAppSecret), - LotteryResultTemplateID: d.Get(ctx, KeyWechatLotteryResultTemplateID), + AppID: d.GetWithFallback(ctx, KeyWechatAppID, staticCfg.AppID), + AppSecret: d.GetWithFallback(ctx, KeyWechatAppSecret, staticCfg.AppSecret), + LotteryResultTemplateID: d.GetWithFallback(ctx, KeyWechatLotteryResultTemplateID, staticCfg.LotteryResultTemplateID), } } // GetWechatPay 获取微信支付配置 func (d *DynamicConfig) GetWechatPay(ctx context.Context) WechatPayConfig { + staticCfg := configs.Get().WechatPay return WechatPayConfig{ - MchID: d.Get(ctx, KeyWechatPayMchID), - SerialNo: d.Get(ctx, KeyWechatPaySerialNo), - PrivateKey: d.Get(ctx, KeyWechatPayPrivateKey), - ApiV3Key: d.Get(ctx, KeyWechatPayApiV3Key), - NotifyURL: d.Get(ctx, KeyWechatPayNotifyURL), - PublicKeyID: d.Get(ctx, KeyWechatPayPublicKeyID), - PublicKey: d.Get(ctx, KeyWechatPayPublicKey), + MchID: d.GetWithFallback(ctx, KeyWechatPayMchID, staticCfg.MchID), + SerialNo: d.GetWithFallback(ctx, KeyWechatPaySerialNo, staticCfg.SerialNo), + PrivateKey: d.Get(ctx, KeyWechatPayPrivateKey), // Key content only, no fallback to file path + ApiV3Key: d.GetWithFallback(ctx, KeyWechatPayApiV3Key, staticCfg.ApiV3Key), + NotifyURL: d.GetWithFallback(ctx, KeyWechatPayNotifyURL, staticCfg.NotifyURL), + PublicKeyID: d.GetWithFallback(ctx, KeyWechatPayPublicKeyID, staticCfg.PublicKeyID), + PublicKey: d.Get(ctx, KeyWechatPayPublicKey), // Key content only } } diff --git a/internal/service/task_center/invite_logic_test.go b/internal/service/task_center/invite_logic_test.go new file mode 100644 index 0000000..fd092be --- /dev/null +++ b/internal/service/task_center/invite_logic_test.go @@ -0,0 +1,76 @@ +package taskcenter + +import ( + "context" + "testing" + + "bindbox-game/internal/repository/mysql" +) + +// TestInviteLogicSymmetry 专项测试:验证邀请人数在不同配置下的统计对称性 +func TestInviteLogicSymmetry(t *testing.T) { + repo, err := mysql.NewSQLiteRepoForTest() + if err != nil { + t.Fatalf("创建 repo 失败: %v", err) + } + db := repo.GetDbW() + initTestTables(t, db) + + // 手动补齐必要的表结构(集成测试需要) + db.Exec(`CREATE TABLE orders ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL, + status INTEGER NOT NULL DEFAULT 1, + source_type INTEGER NOT NULL DEFAULT 0, + total_amount INTEGER NOT NULL DEFAULT 0, + remark TEXT, + deleted_at DATETIME + );`) + db.Exec(`CREATE TABLE user_invites ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + inviter_id INTEGER NOT NULL, + invitee_id INTEGER NOT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + deleted_at DATETIME + );`) + db.Exec(`CREATE TABLE activity_draw_logs (order_id INTEGER, issue_id INTEGER);`) + db.Exec(`CREATE TABLE activity_issues (id INTEGER, activity_id INTEGER);`) + + svc := New(nil, repo, nil, nil, nil) + inviterID := int64(888) + + // === 数据准备 === + // 邀请了 3 个人:101, 102, 103 + db.Exec("INSERT INTO user_invites (inviter_id, invitee_id) VALUES (?, 101), (?, 102), (?, 103)", inviterID, inviterID, inviterID) + + // 只有 101 在活动 77 中下过单并开奖 + db.Exec("INSERT INTO activity_issues (id, activity_id) VALUES (1, 77)") + 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)") + + // === 场景 1:全局任务 (ActivityID = 0) === + t.Run("GlobalInviteTask", func(t *testing.T) { + taskID, _ := InsertTaskWithTierAndReward(t, db, TaskCombination{Metric: MetricInviteCount}) + db.Exec("UPDATE task_center_task_tiers SET activity_id = 0 WHERE task_id = ?", taskID) + + progress, _ := svc.GetUserProgress(context.Background(), inviterID, taskID) + if progress.InviteCount != 3 { + t.Errorf("全局任务失败: 期望 3 (注册即计入), 实际 %d", progress.InviteCount) + } else { + t.Logf("✅ 全局任务验证通过: 邀请人数为 %d (与邀请记录页一致)", progress.InviteCount) + } + }) + + // === 场景 2:特定活动任务 (ActivityID = 77) === + t.Run("ActivitySpecificInviteTask", func(t *testing.T) { + taskID, _ := InsertTaskWithTierAndReward(t, db, TaskCombination{Metric: MetricInviteCount}) + db.Exec("UPDATE task_center_task_tiers SET activity_id = 77 WHERE task_id = ?", taskID) + + progress, _ := svc.GetUserProgress(context.Background(), inviterID, taskID) + if progress.InviteCount != 1 { + t.Errorf("活动专属任务失败: 期望 1 (仅统计活动77的有效转化), 实际 %d", progress.InviteCount) + } else { + t.Logf("✅ 活动专属任务验证通过: 邀请人数为 %d (仅包含活动77下的有效用户)", progress.InviteCount) + } + }) +} diff --git a/internal/service/task_center/service.go b/internal/service/task_center/service.go index c207242..ad5d3ed 100644 --- a/internal/service/task_center/service.go +++ b/internal/service/task_center/service.go @@ -345,10 +345,10 @@ func (s *service) GetUserProgress(ctx context.Context, userID int64, taskID int6 queryAmount.Select("COALESCE(SUM(total_amount), 0)").Scan(&orderAmount) } - // 2. 实时统计邀请数据(有效邀请:被邀请人有消费记录) - // 同样应用“已开奖”逻辑过滤 + // 2. 实时统计邀请数据 var inviteCount int64 if targetActivityID > 0 { + // 根据配置计算:如果任务限定了活动,则只统计在该活动中有有效抽奖的人数(有效转化) db.Raw(` SELECT COUNT(DISTINCT ui.invitee_id) FROM user_invites ui @@ -362,13 +362,8 @@ func (s *service) GetUserProgress(ctx context.Context, userID int64, taskID int6 ) `, userID, targetActivityID).Scan(&inviteCount) } else { - 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 EXISTS (SELECT 1 FROM activity_draw_logs WHERE activity_draw_logs.order_id = o.id) - `, userID).Scan(&inviteCount) + // 全量统计(注册即计入):为了与前端“邀请记录”页面的总数对齐(针对全局任务) + db.Model(&model.UserInvites{}).Where("inviter_id = ?", userID).Count(&inviteCount) } // 3. 首单判断 diff --git a/internal/service/task_center/task_center_test.go b/internal/service/task_center/task_center_test.go index 79bf59d..f74e840 100644 --- a/internal/service/task_center/task_center_test.go +++ b/internal/service/task_center/task_center_test.go @@ -658,15 +658,37 @@ func TestGetUserProgress_ActivityFilter_Integration(t *testing.T) { id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL, status INTEGER NOT NULL DEFAULT 1, + source_type INTEGER NOT NULL DEFAULT 0, + total_amount INTEGER NOT NULL DEFAULT 0, actual_amount INTEGER NOT NULL DEFAULT 0, remark TEXT, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, - updated_at DATETIME DEFAULT CURRENT_TIMESTAMP + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, + deleted_at DATETIME );`).Error; err != nil { t.Fatalf("创建 orders 表失败: %v", err) } } + // Create activity_draw_logs and activity_issues table for joins + if !db.Migrator().HasTable("activity_draw_logs") { + if err := db.Exec(`CREATE TABLE activity_draw_logs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + order_id INTEGER NOT NULL, + issue_id INTEGER NOT NULL + );`).Error; err != nil { + 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) + } + } + // Create user_invites table if !db.Migrator().HasTable("user_invites") { if err := db.Exec(`CREATE TABLE user_invites ( @@ -675,7 +697,8 @@ func TestGetUserProgress_ActivityFilter_Integration(t *testing.T) { invitee_id INTEGER NOT NULL, accumulated_amount INTEGER NOT NULL DEFAULT 0, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, - updated_at DATETIME DEFAULT CURRENT_TIMESTAMP + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, + deleted_at DATETIME );`).Error; err != nil { t.Fatalf("创建 user_invites 表失败: %v", err) } @@ -699,28 +722,49 @@ func TestGetUserProgress_ActivityFilter_Integration(t *testing.T) { userID := int64(999) // 2. 插入不同类型的订单 - // 订单 A: 匹配活动 100 - db.Exec("INSERT INTO orders (user_id, status, actual_amount, remark) VALUES (?, 2, 100, ?)", userID, "activity:100|count:1") - // 订单 B: 匹配活动 200 (不应被统计) - db.Exec("INSERT INTO orders (user_id, status, actual_amount, remark) VALUES (?, 2, 200, ?)", userID, "activity:200|count:1") - // 订单 C: 普通订单 (不应被统计) - db.Exec("INSERT INTO orders (user_id, status, actual_amount, remark) VALUES (?, 2, 300, ?)", userID, "normal_order") - // 订单 D: 匹配活动 100 但未支付 (不应被统计) - db.Exec("INSERT INTO orders (user_id, status, actual_amount, remark) VALUES (?, 1, 100, ?)", userID, "activity:100|count:1") + // 准备活动和期数数据 + db.Exec("INSERT INTO activity_issues (id, activity_id) VALUES (10, 100)") + db.Exec("INSERT INTO activity_issues (id, activity_id) VALUES (20, 200)") - // 3. 调用 GetUserProgress + // 订单 A: 匹配活动 100 + db.Exec("INSERT INTO orders (id, user_id, status, total_amount, source_type, remark) VALUES (1, ?, 2, 100, 0, ?)", userID, "activity:100|count:1") + db.Exec("INSERT INTO activity_draw_logs (order_id, issue_id) VALUES (1, 10)") + + // 订单 B: 匹配活动 200 (不应被统计,因为任务关联的是 100) + db.Exec("INSERT INTO orders (id, user_id, status, total_amount, source_type, remark) VALUES (2, ?, 2, 200, 0, ?)", userID, "activity:200|count:1") + db.Exec("INSERT INTO activity_draw_logs (order_id, issue_id) VALUES (2, 20)") + + // 订单 C: 普通订单 (不应被统计,因为没有关联活动 100) + db.Exec("INSERT INTO orders (id, user_id, status, total_amount, source_type, remark) VALUES (3, ?, 2, 300, 0, ?)", userID, "normal_order") + + // 订单 D: 匹配活动 100 但未支付 (不应被统计) + db.Exec("INSERT INTO orders (id, user_id, status, total_amount, source_type, remark) VALUES (4, ?, 1, 100, 0, ?)", userID, "activity:100|count:1") + + // 3. 插入邀请记录 + db.Exec("INSERT INTO user_invites (inviter_id, invitee_id) VALUES (?, 1001)", userID) + db.Exec("INSERT INTO user_invites (inviter_id, invitee_id) VALUES (?, 1002)", userID) + + // 4. 让其中一个被邀请人(1001)在活动 100 中产生有效订单(使其成为“有效邀请”) + db.Exec("INSERT INTO orders (id, user_id, status, total_amount, source_type, remark) VALUES (10, 1001, 2, 50, 0, ?)", "activity:100|count:1") + db.Exec("INSERT INTO activity_draw_logs (order_id, issue_id) VALUES (10, 10)") + + // 5. 调用 GetUserProgress progress, err := svc.GetUserProgress(context.Background(), userID, taskID) if err != nil { t.Fatalf("GetUserProgress 失败: %v", err) } - // 4. 验证 + // 6. 验证 if progress.OrderCount != 1 { t.Errorf("OrderCount 错误: 期望 1, 实际 %d", progress.OrderCount) } if progress.OrderAmount != 100 { t.Errorf("OrderAmount 错误: 期望 100, 实际 %d", progress.OrderAmount) } + // 期望 1 (只有 1001 在活动 100 有消费,1002 虽然被邀请但没消费) + if progress.InviteCount != 1 { + t.Errorf("InviteCount 错误: 期望 1, 实际 %d", progress.InviteCount) + } - t.Logf("ActivityID 过滤测试通过: OrderCount=%d, OrderAmount=%d", progress.OrderCount, progress.OrderAmount) + t.Logf("ActivityID 过滤测试通过: OrderCount=%d, OrderAmount=%d, InviteCount=%d", progress.OrderCount, progress.OrderAmount, progress.InviteCount) } diff --git a/internal/service/user/address_share.go b/internal/service/user/address_share.go index b5d83b2..72634fd 100644 --- a/internal/service/user/address_share.go +++ b/internal/service/user/address_share.go @@ -333,12 +333,29 @@ func (s *service) RequestShippings(ctx context.Context, userID int64, inventoryI } else { da, e := s.readDB.UserAddresses.WithContext(ctx).Where(s.readDB.UserAddresses.UserID.Eq(userID), s.readDB.UserAddresses.IsDefault.Eq(1)).First() if e != nil || da == nil { - return 0, "", nil, nil, []struct { - ID int64 - Reason string - }{{ID: 0, Reason: "no_default_address"}}, nil + // 尝试查询用户所有地址 + addrs, errFind := s.readDB.UserAddresses.WithContext(ctx).Where(s.readDB.UserAddresses.UserID.Eq(userID)).Find() + if errFind == nil && len(addrs) == 1 { + // 如果只有一个地址,自动设为默认 + target := addrs[0] + if _, errUpd := s.readDB.UserAddresses.WithContext(ctx).Where(s.readDB.UserAddresses.ID.Eq(target.ID)).UpdateSimple(s.readDB.UserAddresses.IsDefault.Value(1)); errUpd == nil { + addrID = target.ID + } else { + s.logger.Error("Auto set default address failed", zap.Error(errUpd)) + return 0, "", nil, nil, []struct { + ID int64 + Reason string + }{{ID: 0, Reason: "no_default_address"}}, nil + } + } else { + return 0, "", nil, nil, []struct { + ID int64 + Reason string + }{{ID: 0, Reason: "no_default_address"}}, nil + } + } else { + addrID = da.ID } - addrID = da.ID } // 3. 生成批次号 diff --git a/internal/service/user/coupons_list.go b/internal/service/user/coupons_list.go index 2889697..5102184 100644 --- a/internal/service/user/coupons_list.go +++ b/internal/service/user/coupons_list.go @@ -35,3 +35,53 @@ func (s *service) ListCouponsByStatus(ctx context.Context, userID int64, status } return items, total, nil } + +// ListAppCoupons APP端查看优惠券(分类逻辑优化:未使用=未动过,已使用=部分使用or已用完) +// ListAppCoupons APP端查看优惠券(分类逻辑优化:未使用=未动过,已使用=部分使用or已用完) +func (s *service) ListAppCoupons(ctx context.Context, userID int64, tabType int32, page, pageSize int) (items []*model.UserCoupons, total int64, err error) { + u := s.readDB.UserCoupons + c := s.readDB.SystemCoupons + + // 使用 UnderlyingDB 绕过 GEN 的类型限制,确保 SQL 逻辑正确 + tableName := u.TableName() + sysTableName := c.TableName() + db := u.UnderlyingDB().WithContext(ctx). + Table(tableName). + Select("`"+tableName+"`.*"). + Joins("LEFT JOIN `"+sysTableName+"` ON `"+sysTableName+"`.id = `"+tableName+"`.coupon_id"). + Where("`"+tableName+"`.user_id = ?", userID) + + // 过滤逻辑 + switch tabType { + case 0: // 未使用 (Status=1且余额>=满额) + db = db.Where(u.TableName()+".status = ? AND "+u.TableName()+".balance_amount >= "+c.TableName()+".discount_value", 1) + case 1: // 已使用 (Status=2 或 (Status=1且余额<满额)) + // Condition: (Status=1 AND Balance < Max) OR Status=2 + db = db.Where("("+u.TableName()+".status = ? AND "+u.TableName()+".balance_amount < "+c.TableName()+".discount_value) OR "+u.TableName()+".status = ?", 1, 2) + case 2: // 已过期 + db = db.Where(u.TableName()+".status = ?", 3) + default: + // 默认只查未使用 (fallback to 0 logic) + db = db.Where(u.TableName()+".status = ? AND "+u.TableName()+".balance_amount >= "+c.TableName()+".discount_value", 1) + } + + // Count + if err = db.Count(&total).Error; err != nil { + return nil, 0, err + } + + if page <= 0 { + page = 1 + } + if pageSize <= 0 { + pageSize = 20 + } + + // Find + err = db.Order("`" + tableName + "`.id DESC").Offset((page - 1) * pageSize).Limit(pageSize).Scan(&items).Error + if err != nil { + return nil, 0, err + } + + return items, total, nil +} diff --git a/internal/service/user/expiration_task.go b/internal/service/user/expiration_task.go index f3fa695..19a33a0 100644 --- a/internal/service/user/expiration_task.go +++ b/internal/service/user/expiration_task.go @@ -33,12 +33,8 @@ func StartExpirationCheck(l logger.CustomLogger, repo mysql.Repo) { l.Info(fmt.Sprintf("[Scheduled] Expired %d item cards", result.RowsAffected)) } - // 2. Expire Coupons - // Status: 1 (Unused) -> 3 (Expired) - // Based on frontend logic and DB comment, 1 is Unused, 2 is Used, 3 is Expired. - // Assuming DB stores 1 for Unused initially. resultC, errC := db.UserCoupons.WithContext(ctx). - Where(db.UserCoupons.Status.Eq(1), db.UserCoupons.ValidEnd.Lt(now)). + Where(db.UserCoupons.Status.In(1, 2, 4), db.UserCoupons.ValidEnd.Lt(now)). Updates(map[string]interface{}{"status": 3}) if errC != nil { diff --git a/internal/service/user/inventory_list.go b/internal/service/user/inventory_list.go index 1d9b325..f650dc5 100644 --- a/internal/service/user/inventory_list.go +++ b/internal/service/user/inventory_list.go @@ -29,8 +29,14 @@ type AggregatedInventory struct { UpdatedAt string `json:"updated_at"` } -func (s *service) ListInventoryWithProduct(ctx context.Context, userID int64, page, pageSize int) (items []*InventoryWithProduct, total int64, err error) { +func (s *service) ListInventoryWithProduct(ctx context.Context, userID int64, page, pageSize int, status int32) (items []*InventoryWithProduct, total int64, err error) { q := s.readDB.UserInventory.WithContext(ctx).ReadDB().Where(s.readDB.UserInventory.UserID.Eq(userID)) + if status > 0 { + q = q.Where(s.readDB.UserInventory.Status.Eq(status)) + } else { + // status=0时,默认行为是不加额外状态过滤,查询所有记录。 + // 如果需要保持兼容性(例如之前可能过滤了软删除),需确认。假设这里是查询所有历史记录。 + } total, err = q.Count() if err != nil { return nil, 0, err diff --git a/internal/service/user/login_weixin.go b/internal/service/user/login_weixin.go index 53d92f2..87d998c 100644 --- a/internal/service/user/login_weixin.go +++ b/internal/service/user/login_weixin.go @@ -6,7 +6,6 @@ import ( "encoding/base64" "errors" "image/png" - "strconv" "time" "bindbox-game/internal/pkg/wechat" @@ -156,8 +155,13 @@ func (s *service) LoginWeixin(ctx context.Context, in LoginWeixinInput) (*LoginW if existed == nil { inviter, _ := tx.Users.WithContext(ctx).Where(tx.Users.InviteCode.Eq(in.InviteCode)).First() if inviter != nil && inviter.ID != u.ID { - // reward := int64(10) // Removed hardcoded reward as per instruction - inv := &model.UserInvites{InviterID: inviter.ID, InviteeID: u.ID, InviteCode: in.InviteCode, RewardPoints: 0, RewardedAt: time.Now()} // RewardPoints set to 0 as reward is removed + inv := &model.UserInvites{ + InviterID: inviter.ID, + InviteeID: u.ID, + InviteCode: in.InviteCode, + RewardPoints: 0, + RewardedAt: time.Now(), + } if err := tx.UserInvites.WithContext(ctx).Create(inv); err != nil { return err } @@ -166,25 +170,6 @@ func (s *service) LoginWeixin(ctx context.Context, in LoginWeixinInput) (*LoginW return err } } - points, _ := tx.UserPoints.WithContext(ctx).Clauses(clause.Locking{Strength: "UPDATE"}).Where(tx.UserPoints.UserID.Eq(inviter.ID)).Where(tx.UserPoints.Kind.Eq("invite")).First() - if points == nil { - points = &model.UserPoints{UserID: inviter.ID, Kind: "invite", Points: 0, ValidStart: time.Now()} // Points set to 0 as reward is removed - do := tx.UserPoints.WithContext(ctx) - if points.ValidEnd.IsZero() { - do = do.Omit(tx.UserPoints.ValidEnd) - } - if err := do.Create(points); err != nil { - return err - } - } else { - if _, err := tx.UserPoints.WithContext(ctx).Where(tx.UserPoints.ID.Eq(points.ID)).Updates(map[string]any{"points": points.Points + 0}); err != nil { // Points set to 0 as reward is removed - return err - } - } - ledger := &model.UserPointsLedger{UserID: inviter.ID, Action: "invite_reward", Points: 0, RefTable: "user_invites", RefID: strconv.FormatInt(inv.ID, 10), Remark: "invite_reward"} // Points set to 0 as reward is removed - if err := tx.UserPointsLedger.WithContext(ctx).Create(ledger); err != nil { - return err - } // 返回邀请人ID,以便外层触发任务中心逻辑 inviterID = inviter.ID s.logger.Info("微信登录邀请关系建立成功", zap.Int64("user_id", u.ID), zap.Int64("inviter_id", inviter.ID)) diff --git a/internal/service/user/order_coupons.go b/internal/service/user/order_coupons.go index 1ad41b5..29ead4c 100644 --- a/internal/service/user/order_coupons.go +++ b/internal/service/user/order_coupons.go @@ -13,10 +13,14 @@ func (s *service) RecordOrderCouponUsage(ctx context.Context, orderID int64, use if orderID <= 0 || userCouponID <= 0 || appliedAmount <= 0 { return nil } - return s.repo.GetDbW().Exec("INSERT INTO order_coupons (order_id, user_coupon_id, applied_amount, created_at) VALUES (?,?,?,NOW(3))", orderID, userCouponID, appliedAmount).Error + // 根治方案:使用唯一索引保证记录不重复 + return s.repo.GetDbW().Exec("INSERT IGNORE INTO order_coupons (order_id, user_coupon_id, applied_amount, created_at) VALUES (?,?,?,NOW(3))", orderID, userCouponID, appliedAmount).Error } -// DeductCouponsForPaidOrder 支付成功后扣减金额券余额或核销一次性券 +// DeductCouponsForPaidOrder 支付成功后确认优惠券预扣 +// 新逻辑:优惠券余额已在下单时预扣(status=4),支付成功后只需确认状态 +// 金额券:status 4 → 2(若余额为0)或保持 4(若还有余额,等后续订单继续使用) +// 满减/折扣券:status 4 → 2 func (s *service) DeductCouponsForPaidOrder(ctx context.Context, tx *dao.Query, userID int64, orderID int64, paidAt time.Time) error { type row struct { UserCouponID int64 @@ -24,6 +28,7 @@ func (s *service) DeductCouponsForPaidOrder(ctx context.Context, tx *dao.Query, Status int32 UsedOrderID int64 DiscountType int32 + BalanceAmount int64 } var rows []row @@ -37,74 +42,65 @@ func (s *service) DeductCouponsForPaidOrder(ctx context.Context, tx *dao.Query, readDb = tx } - // 1. 获取该订单关联的所有优惠券使用记录 - _ = readDb.OrderCoupons.WithContext(ctx).UnderlyingDB().Raw("SELECT oc.user_coupon_id AS user_coupon_id, oc.applied_amount AS applied_amount, uc.status AS status, uc.used_order_id AS used_order_id, sc.discount_type AS discount_type FROM order_coupons oc JOIN user_coupons uc ON uc.id=oc.user_coupon_id JOIN system_coupons sc ON sc.id=uc.coupon_id WHERE oc.order_id=?", orderID).Scan(&rows).Error + // 1. 获取该订单关联的所有优惠券使用记录(包含当前余额) + _ = readDb.OrderCoupons.WithContext(ctx).UnderlyingDB().Raw(` + SELECT oc.user_coupon_id AS user_coupon_id, + oc.applied_amount AS applied_amount, + uc.status AS status, + uc.used_order_id AS used_order_id, + sc.discount_type AS discount_type, + uc.balance_amount AS balance_amount + FROM order_coupons oc + JOIN user_coupons uc ON uc.id=oc.user_coupon_id + JOIN system_coupons sc ON sc.id=uc.coupon_id + WHERE oc.order_id=? + `, orderID).Scan(&rows).Error for _, r := range rows { - // 幂等校验:已核销且绑定本订单,则跳过 - if r.Status == 2 && r.UsedOrderID == orderID { + // 幂等校验:检查是否已有确认流水 + ledgerExists, _ := readDb.UserCouponLedger.WithContext(ctx).Where( + readDb.UserCouponLedger.UserCouponID.Eq(r.UserCouponID), + readDb.UserCouponLedger.OrderID.Eq(orderID), + readDb.UserCouponLedger.Action.Eq("confirm"), + ).Count() + if ledgerExists > 0 { continue } - var currentBal int64 - var ucUserID int64 - uc, err := readDb.UserCoupons.WithContext(ctx).Where(readDb.UserCoupons.ID.Eq(r.UserCouponID)).First() - if err != nil || uc == nil { - continue // 或记录日志 - } - currentBal = uc.BalanceAmount - ucUserID = uc.UserID - - if r.DiscountType == 1 { // 金额券:按量扣减 - nb := currentBal - r.AppliedAmount - if nb < 0 { - nb = 0 - } - - upd := map[string]any{ - "balance_amount": nb, - "used_order_id": orderID, - "used_at": paidAt, - } - if nb == 0 { - upd["status"] = 2 // 余额扣完,标记为已完成 - } - - _, err = db.UserCoupons.WithContext(ctx).Where(db.UserCoupons.ID.Eq(r.UserCouponID)).Updates(upd) - if err != nil { - return fmt.Errorf("failed to update user coupon balance: %w", err) - } - - // 记录账变流水 + // 2. 确认预扣:将 status=4 (预扣中) 更新为最终状态 + if r.DiscountType == 1 { // 金额券 + // 下单时已扣减余额并更新状态,此处无需再次更新状态 + // 仅记录确认流水 ledger := &model.UserCouponLedger{ - UserID: ucUserID, + UserID: userID, UserCouponID: r.UserCouponID, - ChangeAmount: -r.AppliedAmount, - BalanceAfter: nb, + ChangeAmount: 0, // 确认操作不改变余额 + BalanceAfter: r.BalanceAmount, OrderID: orderID, - Action: "apply", + Action: "confirm", CreatedAt: time.Now(), } _ = db.UserCouponLedger.WithContext(ctx).Create(ledger) } else { // 满减/折扣券:一次性核销 - _, err = db.UserCoupons.WithContext(ctx).Where(db.UserCoupons.ID.Eq(r.UserCouponID)).Updates(map[string]any{ - "status": 2, - "used_order_id": orderID, - "used_at": paidAt, - }) - if err != nil { - return fmt.Errorf("failed to verify user coupon: %w", err) + res := db.UserCoupons.WithContext(ctx).UnderlyingDB().Exec(` + UPDATE user_coupons + SET status = 2 + WHERE id = ? AND status = 4 + `, r.UserCouponID) + + if res.Error != nil { + return fmt.Errorf("failed to confirm coupon: %w", res.Error) } - // 记录账变流水 + // 记录确认流水 ledger := &model.UserCouponLedger{ - UserID: ucUserID, + UserID: userID, UserCouponID: r.UserCouponID, - ChangeAmount: -r.AppliedAmount, + ChangeAmount: 0, BalanceAfter: 0, OrderID: orderID, - Action: "apply", + Action: "confirm", CreatedAt: time.Now(), } _ = db.UserCouponLedger.WithContext(ctx).Create(ledger) diff --git a/internal/service/user/order_timeout.go b/internal/service/user/order_timeout.go new file mode 100644 index 0000000..00ef993 --- /dev/null +++ b/internal/service/user/order_timeout.go @@ -0,0 +1,153 @@ +package user + +import ( + "context" + "fmt" + "time" + + "bindbox-game/internal/repository/mysql/model" + + "gorm.io/gorm/clause" +) + +// StartOrderTimeoutTask 启动订单超时清理任务(每分钟执行) +func (s *service) StartOrderTimeoutTask(ctx context.Context) { + ticker := time.NewTicker(1 * time.Minute) + defer ticker.Stop() + + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + s.cleanupExpiredOrders() + } + } +} + +// cleanupExpiredOrders 清理超时未支付的订单 +// 规则:待支付订单超过30分钟自动取消,并恢复已预扣的优惠券 +func (s *service) cleanupExpiredOrders() { + ctx := context.Background() + cutoff := time.Now().Add(-30 * time.Minute) + + // 查找所有超时的待支付订单(排除一番赏订单,因为有专门的清理任务) + var expiredOrders []struct { + ID int64 + UserID int64 + CouponID int64 + OrderNo string + PointsAmount int64 + } + + err := s.readDB.Orders.WithContext(ctx).UnderlyingDB().Raw(` + SELECT id, user_id, coupon_id, order_no, points_amount + FROM orders + WHERE status = 1 AND created_at < ? AND source_type != 3 + LIMIT 100 + `, cutoff).Scan(&expiredOrders).Error + + if err != nil { + fmt.Printf("OrderTimeoutTask: 查询超时订单失败: %v\n", err) + return + } + + for _, order := range expiredOrders { + s.cancelExpiredOrder(ctx, order.ID, order.UserID, order.CouponID, order.PointsAmount) + } + + if len(expiredOrders) > 0 { + fmt.Printf("OrderTimeoutTask: 已处理 %d 个超时订单\n", len(expiredOrders)) + } +} + +// cancelExpiredOrder 取消超时订单并恢复优惠券 +func (s *service) cancelExpiredOrder(ctx context.Context, orderID int64, userID int64, couponID int64, pointsAmount int64) { + // 1. 恢复优惠券 + if couponID > 0 { + type couponRow struct { + AppliedAmount int64 + DiscountType int32 + } + var cr couponRow + s.readDB.OrderCoupons.WithContext(ctx).UnderlyingDB().Raw(` + SELECT oc.applied_amount, sc.discount_type + FROM order_coupons oc + JOIN user_coupons uc ON uc.id = oc.user_coupon_id + JOIN system_coupons sc ON sc.id = uc.coupon_id + WHERE oc.order_id = ? AND oc.user_coupon_id = ? + `, orderID, couponID).Scan(&cr) + + if cr.AppliedAmount > 0 { + if cr.DiscountType == 1 { + // 金额券:恢复余额 + // 此时 coupon 可能 status=1 (Active) 或 2 (Used/Exhausted) + // 不需要 status=4 限制,因为下单时已直接扣减 + res := s.writeDB.UserCoupons.WithContext(ctx).UnderlyingDB().Exec(` + UPDATE user_coupons + SET balance_amount = balance_amount + ?, + status = 1, + used_order_id = NULL, + used_at = NULL + WHERE id = ? + `, cr.AppliedAmount, couponID) + + if res.RowsAffected > 0 { + // 记录流水 + s.writeDB.UserCouponLedger.WithContext(ctx).Create(&model.UserCouponLedger{ + UserID: userID, + UserCouponID: couponID, + ChangeAmount: cr.AppliedAmount, + OrderID: orderID, + Action: "timeout_refund", + CreatedAt: time.Now(), + }) + } + } else { + // 满减/折扣券:恢复状态 + s.writeDB.UserCoupons.WithContext(ctx).UnderlyingDB().Exec(` + UPDATE user_coupons + SET status = 1, + used_order_id = NULL, + used_at = NULL + WHERE id = ? AND status = 4 + `, couponID) + } + } + } + + // 2. 退还积分(如有) + if pointsAmount > 0 { + existing, _ := s.writeDB.UserPoints.WithContext(ctx). + Clauses(clause.Locking{Strength: "UPDATE"}). + Where(s.writeDB.UserPoints.UserID.Eq(userID)).First() + + if existing != nil { + s.writeDB.UserPoints.WithContext(ctx). + Where(s.writeDB.UserPoints.ID.Eq(existing.ID)). + Updates(map[string]any{"points": existing.Points + pointsAmount}) + } else { + s.writeDB.UserPoints.WithContext(ctx). + Omit(s.writeDB.UserPoints.ValidEnd). + Create(&model.UserPoints{UserID: userID, Points: pointsAmount}) + } + + s.writeDB.UserPointsLedger.WithContext(ctx).Create(&model.UserPointsLedger{ + UserID: userID, + Action: "timeout_refund", + Points: pointsAmount, + RefTable: "orders", + RefID: fmt.Sprintf("%d", orderID), + Remark: "order_timeout", + }) + } + + // 3. 更新订单状态为已取消 + res := s.writeDB.Orders.WithContext(ctx).UnderlyingDB().Exec(` + UPDATE orders SET status = 3, cancelled_at = NOW() WHERE id = ? AND status = 1 + `, orderID) + + if res.RowsAffected > 0 { + fmt.Printf("OrderTimeoutTask: 订单 %d 已超时取消\n", orderID) + } +} diff --git a/internal/service/user/orders_action.go b/internal/service/user/orders_action.go index 2c89a68..ebba616 100644 --- a/internal/service/user/orders_action.go +++ b/internal/service/user/orders_action.go @@ -57,7 +57,58 @@ func (s *service) CancelOrder(ctx context.Context, userID int64, orderID int64, } } - // 4. 更新订单状态 + // 4. 退还优惠券(恢复预扣的余额和状态) + if order.CouponID > 0 { + // 查询订单使用的优惠券及扣减金额 + type couponRow struct { + AppliedAmount int64 + DiscountType int32 + } + var cr couponRow + _ = tx.OrderCoupons.WithContext(ctx).UnderlyingDB().Raw(` + SELECT oc.applied_amount AS applied_amount, sc.discount_type AS discount_type + FROM order_coupons oc + JOIN user_coupons uc ON uc.id = oc.user_coupon_id + JOIN system_coupons sc ON sc.id = uc.coupon_id + WHERE oc.order_id = ? AND oc.user_coupon_id = ? + `, order.ID, order.CouponID).Scan(&cr) + + if cr.AppliedAmount > 0 { + if cr.DiscountType == 1 { // 金额券:恢复余额 + res := tx.UserCoupons.WithContext(ctx).UnderlyingDB().Exec(` + UPDATE user_coupons + SET balance_amount = balance_amount + ?, + status = 1, + used_order_id = NULL, + used_at = NULL + WHERE id = ? + `, cr.AppliedAmount, order.CouponID) + + if res.RowsAffected > 0 { + // 记录退还流水 + _ = tx.UserCouponLedger.WithContext(ctx).Create(&model.UserCouponLedger{ + UserID: userID, + UserCouponID: order.CouponID, + ChangeAmount: cr.AppliedAmount, + BalanceAfter: 0, // 简化处理,后续可查询精确值 + OrderID: order.ID, + Action: "refund", + CreatedAt: time.Now(), + }) + } + } else { // 满减/折扣券:恢复状态 + tx.UserCoupons.WithContext(ctx).UnderlyingDB().Exec(` + UPDATE user_coupons + SET status = 1, + used_order_id = NULL, + used_at = NULL + WHERE id = ? AND status = 4 + `, order.CouponID) + } + } + } + + // 5. 更新订单状态 updates := map[string]any{ tx.Orders.Status.ColumnName().String(): 3, tx.Orders.CancelledAt.ColumnName().String(): time.Now(), diff --git a/internal/service/user/reward_grant.go b/internal/service/user/reward_grant.go index f56f092..9187dd1 100644 --- a/internal/service/user/reward_grant.go +++ b/internal/service/user/reward_grant.go @@ -3,6 +3,7 @@ package user import ( "context" "fmt" + "math/rand" "time" "go.uber.org/zap" @@ -248,9 +249,10 @@ func (s *service) GrantReward(ctx context.Context, userID int64, req GrantReward func generateOrderNo() string { // 使用当前时间戳 + 随机数生成订单号 // 格式:RG + 年月日时分秒 + 6位随机数 - return fmt.Sprintf("RG%s%d", + r := rand.New(rand.NewSource(time.Now().UnixNano())) + return fmt.Sprintf("RG%s%06d", time.Now().Format("20060102150405"), - time.Now().UnixNano()%1000000, + r.Intn(1000000), ) } diff --git a/internal/service/user/sms_login.go b/internal/service/user/sms_login.go index ee074a7..ea5f663 100644 --- a/internal/service/user/sms_login.go +++ b/internal/service/user/sms_login.go @@ -193,6 +193,7 @@ func (s *service) LoginByCode(ctx context.Context, in SmsLoginInput) (*SmsLoginO // 6. 查找或创建用户 var user *model.Users isNewUser := false + var inviterID int64 err = s.writeDB.Transaction(func(tx *dao.Query) error { var txErr error @@ -240,12 +241,11 @@ func (s *service) LoginByCode(ctx context.Context, in SmsLoginInput) (*SmsLoginO if existed == nil { inviter, _ := tx.Users.WithContext(ctx).Where(tx.Users.InviteCode.Eq(in.InviteCode)).First() if inviter != nil && inviter.ID != user.ID { - reward := int64(10) inv := &model.UserInvites{ InviterID: inviter.ID, InviteeID: user.ID, InviteCode: in.InviteCode, - RewardPoints: reward, + RewardPoints: 0, RewardedAt: time.Now(), } if txErr = tx.UserInvites.WithContext(ctx).Create(inv); txErr != nil { @@ -257,43 +257,9 @@ func (s *service) LoginByCode(ctx context.Context, in SmsLoginInput) (*SmsLoginO tx.Users.WithContext(ctx).Where(tx.Users.ID.Eq(user.ID)).Updates(map[string]any{"inviter_id": inviter.ID}) } - // 为邀请人增加积分 - points, _ := tx.UserPoints.WithContext(ctx). - Clauses(clause.Locking{Strength: "UPDATE"}). - Where(tx.UserPoints.UserID.Eq(inviter.ID)). - Where(tx.UserPoints.Kind.Eq("invite")). - First() - - if points == nil { - points = &model.UserPoints{ - UserID: inviter.ID, - Kind: "invite", - Points: reward, - ValidStart: time.Now(), - } - do := tx.UserPoints.WithContext(ctx) - if points.ValidEnd.IsZero() { - do = do.Omit(tx.UserPoints.ValidEnd) - } - if txErr = do.Create(points); txErr != nil { - return txErr - } - } else { - tx.UserPoints.WithContext(ctx). - Where(tx.UserPoints.ID.Eq(points.ID)). - Updates(map[string]any{"points": points.Points + reward}) - } - - // 记录积分流水 - ledger := &model.UserPointsLedger{ - UserID: inviter.ID, - Action: "invite_reward", - Points: reward, - RefTable: "user_invites", - RefID: strconv.FormatInt(inv.ID, 10), - Remark: "invite_reward", - } - tx.UserPointsLedger.WithContext(ctx).Create(ledger) + // 设置返回结构体中的邀请人ID + inviterID = inviter.ID + s.logger.Info("短信登录邀请关系建立成功", zap.Int64("user_id", user.ID), zap.Int64("inviter_id", inviter.ID)) } } } @@ -311,6 +277,7 @@ func (s *service) LoginByCode(ctx context.Context, in SmsLoginInput) (*SmsLoginO return &SmsLoginOutput{ User: user, IsNewUser: isNewUser, + InviterID: inviterID, }, nil } diff --git a/internal/service/user/user.go b/internal/service/user/user.go index 654041c..e2d5c98 100644 --- a/internal/service/user/user.go +++ b/internal/service/user/user.go @@ -14,11 +14,12 @@ type Service interface { GetProfile(ctx context.Context, userID int64) (*model.Users, error) ListOrders(ctx context.Context, userID int64, page, pageSize int) (items []*model.Orders, total int64, err error) ListOrdersWithItems(ctx context.Context, userID int64, status int32, isConsumed *int32, page, pageSize int) (items []*OrderWithItems, total int64, err error) - ListInventoryWithProduct(ctx context.Context, userID int64, page, pageSize int) (items []*InventoryWithProduct, total int64, err error) + ListInventoryWithProduct(ctx context.Context, userID int64, page, pageSize int, status int32) (items []*InventoryWithProduct, total int64, err error) ListInventoryWithProductActive(ctx context.Context, userID int64, page, pageSize int, status int32) (items []*InventoryWithProduct, total int64, err error) ListInventoryAggregated(ctx context.Context, userID int64, page, pageSize int, status int32) (items []*AggregatedInventory, total int64, err error) ListCoupons(ctx context.Context, userID int64, page, pageSize int) (items []*model.UserCoupons, total int64, err error) ListCouponsByStatus(ctx context.Context, userID int64, status int32, page, pageSize int) (items []*model.UserCoupons, total int64, err error) + ListAppCoupons(ctx context.Context, userID int64, tabType int32, page, pageSize int) (items []*model.UserCoupons, total int64, err error) ListPointsLedger(ctx context.Context, userID int64, page, pageSize int) (items []*model.UserPointsLedger, total int64, err error) GetPointsBalance(ctx context.Context, userID int64) (int64, error) LoginWeixin(ctx context.Context, in LoginWeixinInput) (*LoginWeixinOutput, error) diff --git a/migrations/20260118_douyin_blacklist.sql b/migrations/20260118_douyin_blacklist.sql new file mode 100644 index 0000000..18b5ee0 --- /dev/null +++ b/migrations/20260118_douyin_blacklist.sql @@ -0,0 +1,13 @@ +-- 抖音用户黑名单表 +CREATE TABLE IF NOT EXISTS `douyin_blacklist` ( + `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID', + `douyin_user_id` VARCHAR(64) NOT NULL COMMENT '抖音用户ID', + `reason` VARCHAR(255) DEFAULT '' COMMENT '拉黑原因', + `operator_id` BIGINT DEFAULT 0 COMMENT '操作人ID', + `status` TINYINT NOT NULL DEFAULT 1 COMMENT '状态: 1=生效, 0=已解除', + `created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + `updated_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_douyin_user_id` (`douyin_user_id`), + KEY `idx_status` (`status`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='抖音用户黑名单表'; diff --git a/migrations/20260121_add_order_coupon_unique_index.sql b/migrations/20260121_add_order_coupon_unique_index.sql new file mode 100644 index 0000000..8acea25 --- /dev/null +++ b/migrations/20260121_add_order_coupon_unique_index.sql @@ -0,0 +1,2 @@ +-- Add unique index to order_coupons table to prevent duplicate deductions for the same order and coupon +ALTER TABLE order_coupons ADD UNIQUE INDEX idx_order_user_coupon (order_id, user_coupon_id); diff --git a/migrations/20260121_add_user_remark.sql b/migrations/20260121_add_user_remark.sql new file mode 100644 index 0000000..3414ba6 --- /dev/null +++ b/migrations/20260121_add_user_remark.sql @@ -0,0 +1 @@ +ALTER TABLE `users` ADD COLUMN `remark` VARCHAR(255) NOT NULL DEFAULT '' COMMENT '管理员备注'; diff --git a/migrations/20260121_reconcile_coupon_data.sql b/migrations/20260121_reconcile_coupon_data.sql new file mode 100644 index 0000000..9c02d9d --- /dev/null +++ b/migrations/20260121_reconcile_coupon_data.sql @@ -0,0 +1,46 @@ +-- 1. 修正余额 (依据核销事实) +-- 这一步首先将 balance_amount 修正为客观的使用事实 +UPDATE user_coupons uc +JOIN system_coupons sc ON uc.coupon_id = sc.id +LEFT JOIN ( + SELECT user_coupon_id, SUM(applied_amount) as used_sum FROM order_coupons GROUP BY user_coupon_id +) oc_agg ON uc.id = oc_agg.user_coupon_id +LEFT JOIN ( + SELECT user_coupon_id, ABS(SUM(change_amount)) as used_sum FROM user_coupon_ledger WHERE action = 'apply' GROUP BY user_coupon_id +) l_agg ON uc.id = l_agg.user_coupon_id +SET uc.balance_amount = sc.discount_value - GREATEST(IFNULL(oc_agg.used_sum, 0), IFNULL(l_agg.used_sum, 0)) +WHERE sc.discount_type = 1 -- 仅限余额券 + AND uc.balance_amount != (sc.discount_value - GREATEST(IFNULL(oc_agg.used_sum, 0), IFNULL(l_agg.used_sum, 0))); + +-- 2. 逻辑 A: 如果余额已经扣完 (balance_amount = 0),状态必须为 2 (已使用) +-- 优先级最高,无论是否到期,只要用完了就是“已使用” +UPDATE user_coupons uc +SET uc.status = 2 +WHERE uc.balance_amount = 0 + AND uc.status != 2; + +-- 3. 逻辑 B: 如果余额 > 0,且已到期 (valid_end < NOW),状态必须为 3 (已过期) +-- 包含:完全没用的到期了、用了一半的到期了 +UPDATE user_coupons uc +SET uc.status = 3 +WHERE uc.balance_amount > 0 + AND uc.valid_end < NOW() + AND uc.status != 3; + +-- 4. 逻辑 C: 如果余额 > 0,且未到期 (valid_end > NOW),状态必须为 1 (未使用/进行中) +UPDATE user_coupons uc +SET uc.status = 1 +WHERE uc.balance_amount > 0 + AND uc.valid_end > NOW() + AND uc.status != 1; + +-- 5. 补全 used_order_id:对于已用完的券,确保关联了最后一次使用的订单ID +UPDATE user_coupons uc +JOIN ( + SELECT user_coupon_id, MAX(order_id) as last_order_id + FROM order_coupons + GROUP BY user_coupon_id +) last_oc ON uc.id = last_oc.user_coupon_id +SET uc.used_order_id = last_oc.last_order_id +WHERE uc.balance_amount = 0 + AND (uc.used_order_id IS NULL OR uc.used_order_id = 0); diff --git a/migrations/20260121_repair_coupon_data.sql b/migrations/20260121_repair_coupon_data.sql new file mode 100644 index 0000000..128ac56 --- /dev/null +++ b/migrations/20260121_repair_coupon_data.sql @@ -0,0 +1,18 @@ +-- 1. 修复【金额券】:余额已为 0,且尚未到期,但状态被错误标记为“已过期”(3) 的券,统一修复为“已使用”(2) +UPDATE user_coupons +SET status = 2, + used_at = IFNULL(used_at, updated_at) +WHERE balance_amount = 0 + AND status = 3 + AND valid_end > NOW(); + +-- 2. 修复【核销记录一致性】:已经在 order_coupons 表中有抵扣记录,但状态仍为“已过期”(3) 的券 +UPDATE user_coupons uc +JOIN order_coupons oc ON uc.id = oc.user_coupon_id +SET uc.status = 2, + uc.used_at = IFNULL(uc.used_at, oc.created_at) +WHERE uc.status = 3 + AND uc.valid_end > NOW(); + +-- 3. (可选) 检查修复后的 User 9090 数据 +-- SELECT id, status, balance_amount, valid_end, used_at FROM user_coupons WHERE user_id = 9090; diff --git a/scripts/add_is_granted_col.go b/scripts/add_is_granted_col.go deleted file mode 100644 index 76b6251..0000000 --- a/scripts/add_is_granted_col.go +++ /dev/null @@ -1,38 +0,0 @@ -package main - -import ( - "bindbox-game/configs" - "bindbox-game/internal/repository/mysql" - "flag" - "fmt" - "os" -) - -func main() { - flag.Parse() - configs.Init() - - repo, err := mysql.New() - if err != nil { - fmt.Printf("DB Error: %v\n", err) - os.Exit(1) - } - db := repo.GetDbW() - - // 添加 is_granted 字段 - sql := "ALTER TABLE livestream_draw_logs ADD COLUMN is_granted TINYINT(1) DEFAULT 0 COMMENT '是否已发放奖品' AFTER created_at" - - // 检查列是否存在 - var count int64 - db.Raw("SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'livestream_draw_logs' AND COLUMN_NAME = 'is_granted'").Scan(&count) - - if count == 0 { - if err := db.Exec(sql).Error; err != nil { - fmt.Printf("Failed to add column: %v\n", err) - os.Exit(1) - } - fmt.Println("SUCCESS: Added is_granted column") - } else { - fmt.Println("Column is_granted already exists") - } -} diff --git a/scripts/add_product_count_col.go b/scripts/add_product_count_col.go deleted file mode 100644 index 37ea2c1..0000000 --- a/scripts/add_product_count_col.go +++ /dev/null @@ -1,43 +0,0 @@ -package main - -import ( - "bindbox-game/configs" - "bindbox-game/internal/repository/mysql" - "bindbox-game/internal/repository/mysql/model" - "fmt" - "log" -) - -func main() { - configs.Init() - - repo, err := mysql.New() - if err != nil { - log.Fatalf("mysql init failed: %v", err) - } - - db := repo.GetDbW() - - // Use raw SQL to add column if not exists - tableName := model.TableNameDouyinOrders - columnName := "product_count" - - // Check if column exists - var count int64 - checkSQL := fmt.Sprintf("SELECT count(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = '%s' AND COLUMN_NAME = '%s'", tableName, columnName) - if err := db.Raw(checkSQL).Scan(&count).Error; err != nil { - log.Fatalf("check column failed: %v", err) - } - - if count == 0 { - log.Printf("Adding column %s to table %s...", columnName, tableName) - // Add column - alterSQL := fmt.Sprintf("ALTER TABLE `%s` ADD COLUMN `%s` INT NOT NULL DEFAULT 1 COMMENT '商品数量';", tableName, columnName) - if err := db.Exec(alterSQL).Error; err != nil { - log.Fatalf("add column failed: %v", err) - } - log.Println("Column added successfully.") - } else { - log.Println("Column already exists.") - } -} diff --git a/scripts/check_coupon.go b/scripts/check_coupon.go new file mode 100644 index 0000000..003201a --- /dev/null +++ b/scripts/check_coupon.go @@ -0,0 +1,98 @@ +package main + +import ( + "database/sql" + "fmt" + "log" + + _ "github.com/go-sql-driver/mysql" +) + +func main() { + // 连接数据库 + dsn := "root:bindbox2025kdy@tcp(150.158.78.154:3306)/dev_game?parseTime=true" + db, err := sql.Open("mysql", dsn) + if err != nil { + log.Fatal("连接失败:", err) + } + defer db.Close() + + fmt.Println("=== 优惠券 ID 275 当前状态 ===") + var ucID, userID, couponID int64 + var status int32 + var balanceAmount int64 + var usedOrderID sql.NullInt64 + var usedAt sql.NullTime + err = db.QueryRow("SELECT id, user_id, coupon_id, status, balance_amount, used_order_id, used_at FROM user_coupons WHERE id = 275").Scan( + &ucID, &userID, &couponID, &status, &balanceAmount, &usedOrderID, &usedAt, + ) + if err != nil { + log.Println("查询优惠券失败:", err) + } else { + fmt.Printf("用户券ID: %d | 用户ID: %d | 模板ID: %d | 状态: %d | 余额(分): %d | 使用订单ID: %v | 使用时间: %v\n", + ucID, userID, couponID, status, balanceAmount, usedOrderID, usedAt) + } + + // 查询系统券模板 + fmt.Println("\n=== 系统优惠券模板 ===") + var scID int64 + var scName string + var discountType int32 + var discountValue int64 + err = db.QueryRow("SELECT id, name, discount_type, discount_value FROM system_coupons WHERE id = ?", couponID).Scan(&scID, &scName, &discountType, &discountValue) + if err == nil { + fmt.Printf("模板ID: %d | 名称: %s | 类型: %d | 面值(分): %d\n", scID, scName, discountType, discountValue) + } + + fmt.Println("\n=== 优惠券 ID 275 的所有流水记录 ===") + rows, err := db.Query(` + SELECT id, user_id, user_coupon_id, change_amount, balance_after, order_id, action, created_at + FROM user_coupon_ledger + WHERE user_coupon_id = 275 + ORDER BY created_at DESC + `) + if err != nil { + log.Println("查询流水失败:", err) + } else { + defer rows.Close() + for rows.Next() { + var id, userID, userCouponID, changeAmount, balanceAfter, orderID int64 + var action string + var createdAt sql.NullTime + rows.Scan(&id, &userID, &userCouponID, &changeAmount, &balanceAfter, &orderID, &action, &createdAt) + fmt.Printf("流水ID: %d | 变动: %d分 | 余额: %d分 | 订单ID: %d | 动作: %s | 时间: %v\n", + id, changeAmount, balanceAfter, orderID, action, createdAt) + } + } + + fmt.Println("\n=== order_coupons 表中使用优惠券 275 的记录 ===") + rows2, err := db.Query(` + SELECT oc.id, oc.order_id, oc.user_coupon_id, oc.applied_amount, oc.created_at, + o.order_no, o.status, o.total_amount, o.discount_amount, o.actual_amount + FROM order_coupons oc + LEFT JOIN orders o ON o.id = oc.order_id + WHERE oc.user_coupon_id = 275 + ORDER BY oc.created_at DESC + `) + if err != nil { + log.Println("查询 order_coupons 失败:", err) + } else { + defer rows2.Close() + for rows2.Next() { + var id, orderID, userCouponID, appliedAmount int64 + var createdAt sql.NullTime + var orderNo sql.NullString + var orderStatus sql.NullInt32 + var totalAmount, discountAmount, actualAmount sql.NullInt64 + rows2.Scan(&id, &orderID, &userCouponID, &appliedAmount, &createdAt, &orderNo, &orderStatus, &totalAmount, &discountAmount, &actualAmount) + fmt.Printf("记录ID: %d | 订单ID: %d | 订单号: %v | 扣减金额: %d分 | 时间: %v\n", + id, orderID, orderNo.String, appliedAmount, createdAt) + } + } + + // 计算总扣减金额 + var totalApplied int64 + db.QueryRow("SELECT COALESCE(SUM(applied_amount), 0) FROM order_coupons WHERE user_coupon_id = 275").Scan(&totalApplied) + fmt.Printf("\n=== 统计 ===\n优惠券 275 累计扣减: %d 分 (%.2f 元)\n", totalApplied, float64(totalApplied)/100) + fmt.Printf("当前余额: %d 分 (%.2f 元)\n", balanceAmount, float64(balanceAmount)/100) +} diff --git a/scripts/check_db_schema.go b/scripts/check_db_schema.go new file mode 100644 index 0000000..514bf51 --- /dev/null +++ b/scripts/check_db_schema.go @@ -0,0 +1,36 @@ +package main + +import ( + "database/sql" + "fmt" + "log" + + _ "github.com/go-sql-driver/mysql" +) + +func main() { + dsn := "root:bindbox2025kdy@tcp(150.158.78.154:3306)/bindbox_game" + db, err := sql.Open("mysql", dsn) + if err != nil { + log.Fatal(err) + } + defer db.Close() + + rows, err := db.Query("DESCRIBE douyin_orders") + if err != nil { + log.Fatal(err) + } + defer rows.Close() + + fmt.Println("Table: douyin_orders") + fmt.Println("Field | Type | Null | Key | Default | Extra") + for rows.Next() { + var field, typ, null, key, extra string + var def sql.NullString + err := rows.Scan(&field, &typ, &null, &key, &def, &extra) + if err != nil { + log.Fatal(err) + } + fmt.Printf("%s | %s | %s | %s | %v | %s\n", field, typ, null, key, def.String, extra) + } +} diff --git a/scripts/check_duplicates.go b/scripts/check_duplicates.go new file mode 100644 index 0000000..5aee8e1 --- /dev/null +++ b/scripts/check_duplicates.go @@ -0,0 +1,58 @@ +package main + +import ( + "database/sql" + "fmt" + "log" + + _ "github.com/go-sql-driver/mysql" +) + +func main() { + dsn := "root:bindbox2025kdy@tcp(150.158.78.154:3306)/bindbox_game" + db, err := sql.Open("mysql", dsn) + if err != nil { + log.Fatal(err) + } + defer db.Close() + + // 1. 检查直播间抽奖是否有重复 (Draw Count > Product Count) + // 注意:shop_order_id 是字符串,join 时注意字符集,不过这里都是 utf8mb4 应该没问题 + query := ` + SELECT + o.shop_order_id, + o.product_count, + COUNT(l.id) as draw_count, + o.user_nickname + FROM douyin_orders o + JOIN livestream_draw_logs l ON CONVERT(o.shop_order_id USING utf8mb4) = CONVERT(l.shop_order_id USING utf8mb4) + GROUP BY o.shop_order_id, o.product_count, o.user_nickname + HAVING draw_count > o.product_count + ` + + rows, err := db.Query(query) + if err != nil { + log.Fatal(err) + } + defer rows.Close() + + fmt.Println("--- Duplicate Rewards Check (Livestream) ---") + found := false + for rows.Next() { + var orderID, nickname string + var pCount, dCount int + if err := rows.Scan(&orderID, &pCount, &dCount, &nickname); err != nil { + log.Fatal(err) + } + fmt.Printf("Order: %s | Nickname: %s | Bought: %d | Issued: %d\n", orderID, nickname, pCount, dCount) + found = true + } + + if !found { + fmt.Println("No duplicate rewards found in livestream_draw_logs.") + } + + // 2. 额外检查:是否有同一个 shop_order_id 在极短时间内产生多条 log (并发问题特质) + // 这里简单检查是否有完全重复的 log (除了主键不同,其他关键字段相同) + // 或者检查是否有订单在非直播抽奖表也发了奖 (如果两边系统混用) +} diff --git a/scripts/check_orders.go b/scripts/check_orders.go new file mode 100644 index 0000000..12622ff --- /dev/null +++ b/scripts/check_orders.go @@ -0,0 +1,35 @@ +package main + +import ( + "database/sql" + "fmt" + "log" + + _ "github.com/go-sql-driver/mysql" +) + +func main() { + dsn := "root:bindbox2025kdy@tcp(150.158.78.154:3306)/bindbox_game" + db, err := sql.Open("mysql", dsn) + if err != nil { + log.Fatal(err) + } + defer db.Close() + + rows, err := db.Query("SELECT shop_order_id, actual_pay_amount, actual_receive_amount, raw_data FROM douyin_orders ORDER BY id DESC LIMIT 5") + if err != nil { + log.Fatal(err) + } + defer rows.Close() + + for rows.Next() { + var id string + var pay, recv int64 + var raw string + err := rows.Scan(&id, &pay, &recv, &raw) + if err != nil { + log.Fatal(err) + } + fmt.Printf("ID: %s | Pay(DB): %d | Recv(DB): %d\nRaw: %s\n\n", id, pay, recv, raw) + } +} diff --git a/scripts/check_points_integrity.go b/scripts/check_points_integrity.go deleted file mode 100644 index d1aaa29..0000000 --- a/scripts/check_points_integrity.go +++ /dev/null @@ -1,70 +0,0 @@ -package main - -import ( - "bindbox-game/internal/repository/mysql" - "bindbox-game/internal/repository/mysql/model" - "fmt" - "log" -) - -func main() { - // Initialize DB (Implicitly loads configs via init()) - db, err := mysql.New() - if err != nil { - log.Fatalf("failed to init db: %v", err) - } - - // 1. Get all users - var userIDs []int64 - if err := db.GetDbR().Model(&model.Users{}).Pluck("id", &userIDs).Error; err != nil { - log.Fatalf("failed to get users: %v", err) - } - - fmt.Printf("Checking points integrity for %d users...\n", len(userIDs)) - fmt.Println("UserID | LedgerSum | BalanceSum | Diff") - fmt.Println("-------|-----------|------------|------") - - errorCount := 0 - - for _, uid := range userIDs { - // Sum Ledger - var ledgerSum int64 - if err := db.GetDbR().Model(&model.UserPointsLedger{}). - Where("user_id = ?", uid). - Select("COALESCE(SUM(points), 0)"). - Scan(&ledgerSum).Error; err != nil { - log.Printf("failed to sum ledger for user %d: %v", uid, err) - continue - } - - // Sum Balance - var balanceSum int64 - if err := db.GetDbR().Model(&model.UserPoints{}). - Where("user_id = ?", uid). - Select("COALESCE(SUM(points), 0)"). - Scan(&balanceSum).Error; err != nil { - log.Printf("failed to sum balance for user %d: %v", uid, err) - continue - } - - diff := ledgerSum - balanceSum - if diff != 0 || uid == 9018 { - errorCount++ - fmt.Printf("%6d | %9d | %10d | %4d\n", uid, ledgerSum, balanceSum, diff) - // Show ledgers for 9018 - if uid == 9018 { - var ledgers []model.UserPointsLedger - db.GetDbR().Where("user_id = ?", uid).Find(&ledgers) - for _, l := range ledgers { - fmt.Printf(" -> Ledger ID: %d, Action: %s, Points: %d\n", l.ID, l.Action, l.Points) - } - } - } - } - - if errorCount == 0 { - fmt.Println("\nAll users verified. No discrepancies found.") - } else { - fmt.Printf("\nFound %d users with point discrepancies.\n", errorCount) - } -} diff --git a/scripts/debug_matching_order.go b/scripts/debug_matching_order.go new file mode 100644 index 0000000..e0d0f87 --- /dev/null +++ b/scripts/debug_matching_order.go @@ -0,0 +1,311 @@ +package main + +import ( + "crypto/hmac" + "crypto/sha256" + "database/sql" + "encoding/binary" + "encoding/hex" + "fmt" + "log" + "time" + + _ "github.com/go-sql-driver/mysql" +) + +func main() { + dsn := "root:bindbox2025kdy@tcp(150.158.78.154:3306)/dev_game?parseTime=true" + db, err := sql.Open("mysql", dsn) + if err != nil { + log.Fatal("Connection failed:", err) + } + defer db.Close() + + orderNo := "O20260125201015731" + fmt.Printf("Querying Order: %s\n", orderNo) + + var id, userID int64 + var status, sourceType int32 + var total, discount, actual int64 + var remark, createdAt string + + // Check Order + err = db.QueryRow("SELECT id, user_id, status, source_type, total_amount, discount_amount, actual_amount, remark, created_at FROM orders WHERE order_no = ?", orderNo). + Scan(&id, &userID, &status, &sourceType, &total, &discount, &actual, &remark, &createdAt) + + if err != nil { + log.Fatalf("Order not found: %v", err) + } + + fmt.Printf("ID: %d\nUser: %d\nStatus: %d (1=Pending, 2=Paid)\nSourceType: %d\nTotal: %d\nDiscount: %d\nActual: %d\nRemark: %s\nCreated: %s\n", + id, userID, status, sourceType, total, discount, actual, remark, createdAt) + + // Check Draw Logs + rows, err := db.Query("SELECT id, reward_id, is_winner, draw_index, created_at FROM activity_draw_logs WHERE order_id = ?", id) + if err != nil { + log.Fatal("Query logs failed:", err) + } + defer rows.Close() + + fmt.Println("\n--- Draw Logs ---") + count := 0 + for rows.Next() { + var logID, rID, dIdx int64 + var isWinner int32 + var ca time.Time + rows.Scan(&logID, &rID, &isWinner, &dIdx, &ca) + fmt.Printf("LogID: %d, RewardID: %d, IsWinner: %d, DrawIndex: %d, Time: %s\n", logID, rID, isWinner, dIdx, ca) + count++ + } + if count == 0 { + fmt.Println("No draw logs found.") + } + + // Check Inventory (Grants) + rowsInv, err := db.Query("SELECT id, reward_id, product_id, status FROM user_inventory WHERE order_id = ?", id) + if err != nil { + log.Fatal("Query inventory failed:", err) + } + defer rowsInv.Close() + + fmt.Println("\n--- Inventory (Grants) ---") + invCount := 0 + for rowsInv.Next() { + var invID, rID, pID int64 + var s int + rowsInv.Scan(&invID, &rID, &pID, &s) + fmt.Printf("InvID: %d, RewardID: %d, ProductID: %d, Status: %d\n", invID, rID, pID, s) + invCount++ + } + + if invCount == 0 { + fmt.Println("No inventory grants found.") + } + + // Check Issue 104 + fmt.Println("\n--- Issue 104 ---") + var actID int64 + var issueNumber string + err = db.QueryRow("SELECT activity_id, issue_number FROM activity_issues WHERE id = 104").Scan(&actID, &issueNumber) + if err != nil { + fmt.Printf("Issue query failed: %v\n", err) + } else { + fmt.Printf("Issue Number: %s, ActivityID: %d\n", issueNumber, actID) + // Query Activity by ID + var actName, playType string + err = db.QueryRow("SELECT name, play_type FROM activities WHERE id = ?", actID).Scan(&actName, &playType) + if err != nil { + fmt.Printf("Activity query failed: %v\n", err) + } else { + fmt.Printf("Activity: %s, PlayType: %s\n", actName, playType) + + // Reconstruct Game + fmt.Println("\n--- Reconstructing Game ---") + + // Fetch Draw Log + var drawLogID, issueID int64 + err = db.QueryRow("SELECT id, issue_id FROM activity_draw_logs WHERE order_id = ?", id).Scan(&drawLogID, &issueID) + if err != nil { + log.Fatal("Draw log not found:", err) + } + + // Fetch Receipt + var subSeedHex, position string + err = db.QueryRow("SELECT server_sub_seed, client_seed FROM activity_draw_receipts WHERE draw_log_id = ?", drawLogID).Scan(&subSeedHex, &position) + if err != nil { + log.Printf("Receipt not found: %v", err) + return + } + + fmt.Printf("Receipt Found. SubSeed: %s, Position (ClientSeed): %s\n", subSeedHex, position) + + serverSeed, err := hex.DecodeString(subSeedHex) + if err != nil { + log.Fatal("Invalid seed hex:", err) + } + + // Fetch Card Configs + rowsCards, err := db.Query("SELECT code, name, quantity FROM matching_card_types WHERE status=1 ORDER BY sort ASC") + if err != nil { + log.Fatal("Card types query failed:", err) + } + defer rowsCards.Close() + + type CardTypeConfig struct { + Code string + Name string + Quantity int32 + } + var configs []CardTypeConfig + for rowsCards.Next() { + var c CardTypeConfig + rowsCards.Scan(&c.Code, &c.Name, &c.Quantity) + configs = append(configs, c) + } + + // Create Game Logic + fmt.Println("Simulating Game Logic...") + + cardIDCounter := int64(0) + type MatchingCard struct { + ID string + Type string + } + + var deck []*MatchingCard + for _, cfg := range configs { + for i := int32(0); i < cfg.Quantity; i++ { + cardIDCounter++ + deck = append(deck, &MatchingCard{ + ID: fmt.Sprintf("c%d", cardIDCounter), + Type: cfg.Code, + }) + } + } + + // SecureShuffle + secureRandInt := func(max int, context string, nonce *int64) int { + *nonce++ + message := fmt.Sprintf("%s|nonce:%d", context, *nonce) + mac := hmac.New(sha256.New, serverSeed) + mac.Write([]byte(message)) + sum := mac.Sum(nil) + val := binary.BigEndian.Uint64(sum[:8]) + return int(val % uint64(max)) + } + + nonce := int64(0) + n := len(deck) + for i := n - 1; i > 0; i-- { + j := secureRandInt(i+1, fmt.Sprintf("shuffle:%d", i), &nonce) + deck[i], deck[j] = deck[j], deck[i] + } + + // Distribute to Board (first 9) + board := make([]*MatchingCard, 9) + for i := 0; i < 9; i++ { + if len(deck) > 0 { + board[i] = deck[0] + deck = deck[1:] + } + } + + fmt.Printf("Board Types: ") + for _, c := range board { + if c != nil { + fmt.Printf("%s ", c.Type) + } + } + fmt.Println() + + // SimulateMaxPairs + // Reconstruct allCards (Board + Deck) + allCards := make([]*MatchingCard, 0, len(board)+len(deck)) + for _, c := range board { + if c != nil { + allCards = append(allCards, c) + } + } + allCards = append(allCards, deck...) + + selectedType := position + hand := make([]*MatchingCard, 9) + copy(hand, allCards[:9]) + deckIndex := 9 + chance := int64(0) + for _, c := range hand { + if c != nil && c.Type == selectedType { + chance++ + } + } + + fmt.Printf("Selected Type: %s, Initial Chance: %d\n", selectedType, chance) + + totalPairs := int64(0) + + guard := 0 + for guard < 1000 { + guard++ + + // canEliminate + counts := make(map[string]int) + pairType := "" + for _, c := range hand { + if c == nil { + continue + } + counts[c.Type]++ + if counts[c.Type] >= 2 { + pairType = c.Type + break + } + } + + if pairType != "" { + // Eliminate + first, second := -1, -1 + for i, c := range hand { + if c == nil || c.Type != pairType { + continue + } + if first < 0 { + first = i + } else { + second = i + break + } + } + if first >= 0 && second >= 0 { + newHand := make([]*MatchingCard, 0, len(hand)-2) + for i, c := range hand { + if i != first && i != second { + newHand = append(newHand, c) + } + } + hand = newHand + totalPairs++ + chance++ + continue + } + } + + // Draw + if chance > 0 && deckIndex < len(allCards) { + newCard := allCards[deckIndex] + hand = append(hand, newCard) + deckIndex++ + chance-- + continue + } + + break + } + + fmt.Printf("Simulation Finished. Total Pairs: %d\n", totalPairs) + + // Check Rewards + rowsRewards, err := db.Query("SELECT id, min_score, product_id, quantity, level FROM activity_reward_settings WHERE issue_id = ?", issueID) + if err != nil { + log.Printf("Query rewards failed: %v", err) + } else { + defer rowsRewards.Close() + fmt.Println("\n--- Matching Rewards ---") + found := false + for rowsRewards.Next() { + var rwID, minScore, pID int64 + var qty, level int32 + rowsRewards.Scan(&rwID, &minScore, &pID, &qty, &level) + if int64(minScore) == totalPairs { + var pName string + _ = db.QueryRow("SELECT name FROM products WHERE id=?", pID).Scan(&pName) + fmt.Printf("MATCH! RewardID: %d, ProductID: %d (%s), Qty: %d, Level: %d\n", rwID, pID, pName, qty, level) + found = true + } + } + if !found { + fmt.Println("No matching reward found for this score.") + } + } + } + } +} diff --git a/scripts/fix_db_column.py b/scripts/fix_db_column.py deleted file mode 100644 index 2f391ce..0000000 --- a/scripts/fix_db_column.py +++ /dev/null @@ -1,48 +0,0 @@ -import pymysql - -# DB Configs -host = '150.158.78.154' -port = 3306 -user = 'root' -password = 'bindbox2025kdy' -database = 'bindbox_game' - -# Connect -try: - connection = pymysql.connect( - host=host, - port=port, - user=user, - password=password, - database=database, - charset='utf8mb4', - cursorclass=pymysql.cursors.DictCursor - ) - - with connection.cursor() as cursor: - # Check columns - cursor.execute("SHOW COLUMNS FROM livestream_draw_logs LIKE 'shop_order_id'") - result = cursor.fetchone() - if not result: - print("Adding shop_order_id column...") - cursor.execute("ALTER TABLE livestream_draw_logs ADD COLUMN shop_order_id VARCHAR(255) DEFAULT '' COMMENT '抖店订单号' AFTER douyin_order_id") - connection.commit() - print("shop_order_id added.") - else: - print("shop_order_id already exists.") - - cursor.execute("SHOW COLUMNS FROM livestream_draw_logs LIKE 'user_nickname'") - result = cursor.fetchone() - if not result: - print("Adding user_nickname column...") - cursor.execute("ALTER TABLE livestream_draw_logs ADD COLUMN user_nickname VARCHAR(255) DEFAULT '' COMMENT '用户昵称' AFTER douyin_user_id") - connection.commit() - print("user_nickname added.") - else: - print("user_nickname already exists.") - -except Exception as e: - print(f"Error: {e}") -finally: - if 'connection' in locals() and connection.open: - connection.close() diff --git a/scripts/fix_db_columns.go b/scripts/fix_db_columns.go new file mode 100644 index 0000000..58c709e --- /dev/null +++ b/scripts/fix_db_columns.go @@ -0,0 +1,35 @@ +package main + +import ( + "database/sql" + "fmt" + "log" + + _ "github.com/go-sql-driver/mysql" +) + +func main() { + dsn := "root:bindbox2025kdy@tcp(150.158.78.154:3306)/bindbox_game" + db, err := sql.Open("mysql", dsn) + if err != nil { + log.Fatal(err) + } + defer db.Close() + + sqls := []string{ + "ALTER TABLE douyin_orders ADD COLUMN douyin_product_id VARCHAR(128) COMMENT '关联商品ID' AFTER shop_order_id", + "ALTER TABLE douyin_orders ADD COLUMN product_count BIGINT NOT NULL DEFAULT 1 COMMENT '商品数量' AFTER douyin_product_id", + "ALTER TABLE douyin_orders ADD COLUMN actual_pay_amount BIGINT DEFAULT 0 COMMENT '实付金额(分)' AFTER actual_receive_amount", + "ALTER TABLE douyin_orders ADD COLUMN reward_granted INT NOT NULL DEFAULT 0 COMMENT '奖励已发放' AFTER raw_data", + } + + for _, s := range sqls { + fmt.Printf("Executing: %s\n", s) + _, err := db.Exec(s) + if err != nil { + fmt.Printf("Error executing %s: %v\n", s, err) + } else { + fmt.Println("Success") + } + } +} diff --git a/scripts/inspect_column_names.go b/scripts/inspect_column_names.go new file mode 100644 index 0000000..17c1528 --- /dev/null +++ b/scripts/inspect_column_names.go @@ -0,0 +1,33 @@ +package main + +import ( + "database/sql" + "fmt" + "log" + + _ "github.com/go-sql-driver/mysql" +) + +func main() { + dsn := "root:bindbox2025kdy@tcp(150.158.78.154:3306)/bindbox_game" + db, err := sql.Open("mysql", dsn) + if err != nil { + log.Fatal(err) + } + defer db.Close() + + rows, err := db.Query("SELECT COLUMN_NAME FROM information_schema.COLUMNS WHERE TABLE_NAME='douyin_orders' AND TABLE_SCHEMA='bindbox_game'") + if err != nil { + log.Fatal(err) + } + defer rows.Close() + + fmt.Println("Columns in douyin_orders:") + for rows.Next() { + var name string + if err := rows.Scan(&name); err != nil { + log.Fatal(err) + } + fmt.Printf("[%s] (len:%d)\n", name, len(name)) + } +} diff --git a/scripts/list_tables.go b/scripts/list_tables.go new file mode 100644 index 0000000..bd2cbad --- /dev/null +++ b/scripts/list_tables.go @@ -0,0 +1,33 @@ +package main + +import ( + "database/sql" + "fmt" + "log" + + _ "github.com/go-sql-driver/mysql" +) + +func main() { + dsn := "root:bindbox2025kdy@tcp(150.158.78.154:3306)/bindbox_game" + db, err := sql.Open("mysql", dsn) + if err != nil { + log.Fatal(err) + } + defer db.Close() + + rows, err := db.Query("SHOW TABLES") + if err != nil { + log.Fatal(err) + } + defer rows.Close() + + fmt.Println("Tables in bindbox_game:") + for rows.Next() { + var name string + if err := rows.Scan(&name); err != nil { + log.Fatal(err) + } + fmt.Println(name) + } +} diff --git a/scripts/migrate_cost_price.go b/scripts/migrate_cost_price.go deleted file mode 100644 index cbd76a4..0000000 --- a/scripts/migrate_cost_price.go +++ /dev/null @@ -1,43 +0,0 @@ -package main - -import ( - "fmt" - "log" - - "bindbox-game/configs" - "bindbox-game/internal/repository/mysql" - "bindbox-game/internal/repository/mysql/model" - - "gorm.io/gorm" -) - -func main() { - // Initialize Config - configs.Init() - - // Initialize Database - dbRepo, err := mysql.New() - if err != nil { - log.Fatalf("Failed to init db: %v", err) - } - db := dbRepo.GetDbW() - - // Add column - msg := addColumn(db) - fmt.Println(msg) -} - -func addColumn(db *gorm.DB) string { - // Check if column exists - if db.Migrator().HasColumn(&model.LivestreamPrizes{}, "CostPrice") { - return "Column 'cost_price' already exists in 'livestream_prizes'" - } - - // Add column - err := db.Migrator().AddColumn(&model.LivestreamPrizes{}, "CostPrice") - if err != nil { - log.Fatalf("Failed to add column: %v", err) - } - - return "Successfully added column 'cost_price' to 'livestream_prizes'" -} diff --git a/scripts/output.json b/scripts/output.json deleted file mode 100644 index cb860c9..0000000 --- a/scripts/output.json +++ /dev/null @@ -1,15 +0,0 @@ -正在请求: https://fxg.jinritemai.com/api/order/searchlist -参数: { - "page": "0", - "pageSize": "10", - "order_by": "create_time", - "order": "desc", - "tab": "all", - "appid": "1", - "_bid": "ffa_order", - "aid": "4272" -} -状态码: 200 - -✅ 测试成功 -订单字段: ['order_id', 'shop_order_id', 'order_status', 'user_id', 'now_ts', 'pay_type', 'order_type', 'b_type', 'c_biz', 'biz', 'receive_type', 'e_express', 'repeat', 'is_dup', 'pre_receive_info_exist', 'has_write_off_record', 'is_already_modify_amount', 'user_is_auth', 'can_modify_amount', 'change_addr', 'store_name', 'wait_ship_count', 'shipped_count', 'product_count', 'total_post_amount', 'total_pay_amount', 'pay_amount', 'post_amount', 'total_tax_amount', 'total_include_tax_amount', 'total_excluding_tax_amount', 'total_goods_amount', 'promotion_amount', 'modify_amount', 'modify_post_amount', 'sku_modify_amount', 'shop_receive_amount', 'promotion_pay_amount', 'envelope_promotion_amount', 'total_tax_amount_desc', 'actual_pay_amount', 'actual_receive_amount', 'actual_receive_amount_desc', 'actual_receive_amount_int', 'create_time', 'confirm_time', 'pay_time', 'logistics_time', 'receipt_time', 'group_time', 'exp_ship_time', 'order_type_desc', 'pay_type_desc', 'write_off_desc', 'buyer_words', 'remark', 'star', 'user_nickname', 'has_write_off', 'has_more', 'pre_sale_desc', 'receive_info', 'receiver_info', 'policy_info', 'order_status_info', 'operation_actions', 'action_map', 'button', 'order_bottom_card', 'product_item', 'shop_order_tag', 'pay_amount_detail', 'way_bill_url', 'cross_border_send_type', 'order_amount_card', 'pay_amount_desc', 'shop_receive_amount_desc', 'serial_numbers', 'address_tag', 'support_detail', 'need_serial_number', 'b_type_desc', 'c_biz_desc', 'price_detail', 'promotion_detail', 'pay_type_desc_hover', 'manual_order_type', 'order_id_for_show', 'order_tag_stamp', 'url_map', 'user_profile_tag', 'supermarket_order_serial_no', 'deliver_name', 'deliver_mobile', 'receipt_time_fmt', 'logistics_status', 'greet_words', 'transfer_receiver_info', 'total_product_count', 'total_price', 'latest_logistic_info', 'create_time_str', 'amount_detail_map', 'extra_tag', 'shop_privilege_info_list', 'gift_receive_time_str', 'relate_infos', 'base_card', 'receiver_common', 'is_order_in_ab_test'] diff --git a/scripts/query_user.go b/scripts/query_user.go new file mode 100644 index 0000000..c2bc0b5 --- /dev/null +++ b/scripts/query_user.go @@ -0,0 +1,26 @@ +package main + +import ( + "database/sql" + "fmt" + "log" + + _ "github.com/go-sql-driver/mysql" +) + +func main() { + dsn := "root:bindbox2025kdy@tcp(150.158.78.154:3306)/dev_game?parseTime=true" + db, err := sql.Open("mysql", dsn) + if err != nil { + log.Fatal("Connection failed:", err) + } + defer db.Close() + + userID := 9116 + var nickname, mobile string + err = db.QueryRow("SELECT nickname, mobile FROM users WHERE id = ?", userID).Scan(&nickname, &mobile) + if err != nil { + log.Fatal("Query failed:", err) + } + fmt.Printf("User ID: %d\nNickname: %s\nMobile: %s\n", userID, nickname, mobile) +} diff --git a/scripts/read_raw_order.go b/scripts/read_raw_order.go new file mode 100644 index 0000000..74932c0 --- /dev/null +++ b/scripts/read_raw_order.go @@ -0,0 +1,28 @@ +package main + +import ( + "database/sql" + "fmt" + "log" + + _ "github.com/go-sql-driver/mysql" +) + +func main() { + dsn := "root:bindbox2025kdy@tcp(150.158.78.154:3306)/bindbox_game" + db, err := sql.Open("mysql", dsn) + if err != nil { + log.Fatal(err) + } + defer db.Close() + + shopOrderID := "6950061667188872453" + var rawData string + err = db.QueryRow("SELECT raw_data FROM douyin_orders WHERE shop_order_id=?", shopOrderID).Scan(&rawData) + if err != nil { + log.Fatal(err) + } + + fmt.Println("Raw Data for Order 6950061667188872453:") + fmt.Println(rawData) +} diff --git a/scripts/reset_inventory.go b/scripts/reset_inventory.go deleted file mode 100644 index a2011f6..0000000 --- a/scripts/reset_inventory.go +++ /dev/null @@ -1,31 +0,0 @@ -package main - -import ( - "fmt" - "log" - - "bindbox-game/internal/repository/mysql" -) - -func main() { - // 1. 初始化配置 (configs 包的 init 函数会自动加载) - // configs.Init() - - // 2. 连接数据库 - repo, err := mysql.New() - if err != nil { - log.Fatalf("Failed to connect to database: %v", err) - } - - db := repo.GetDbW() - - fmt.Println("Starting inventory reset...") - - // 3. 执行重置操作:将所有奖品的剩余库存 (quantity) 重置为初始库存 (original_qty) - result := db.Exec("UPDATE activity_reward_settings SET quantity = original_qty WHERE quantity != original_qty") - if result.Error != nil { - log.Fatalf("Failed to reset inventory: %v", result.Error) - } - - fmt.Printf("Successfully repaired inventory data.\nRows affected: %d\n", result.RowsAffected) -} diff --git a/scripts/test_coupon_prededuct/main.go b/scripts/test_coupon_prededuct/main.go new file mode 100644 index 0000000..9e7e1c2 --- /dev/null +++ b/scripts/test_coupon_prededuct/main.go @@ -0,0 +1,362 @@ +package main + +import ( + "context" + "database/sql" + "fmt" + "os" + "time" + + _ "github.com/go-sql-driver/mysql" +) + +// 测试优惠券预扣机制的各种场景 +// 数据库连接配置来自 configs/dev_configs.toml + +const ( + // 从 dev_configs.toml [mysql.read] 读取 + dsn = "root:bindbox2025kdy@tcp(150.158.78.154:3306)/dev_game?parseTime=true&loc=Local" +) + +var db *sql.DB + +func main() { + var err error + db, err = sql.Open("mysql", dsn) + if err != nil { + fmt.Printf("❌ 数据库连接失败: %v\n", err) + os.Exit(1) + } + defer db.Close() + + if err = db.Ping(); err != nil { + fmt.Printf("❌ 数据库 Ping 失败: %v\n", err) + os.Exit(1) + } + + fmt.Println("========================================") + fmt.Println("优惠券预扣机制测试") + fmt.Println("========================================") + + // 准备测试数据 + testUserID := int64(99999) // 测试用户 + testCouponValue := int64(500) // 5元 = 500分 + + // 清理之前的测试数据 + cleanup(testUserID) + + // 创建测试优惠券模板和用户券 + systemCouponID := createTestSystemCoupon(testCouponValue) + if systemCouponID == 0 { + fmt.Println("❌ 创建测试优惠券模板失败") + return + } + + fmt.Println("\n========================================") + fmt.Println("测试场景 1: 正常下单→支付流程") + fmt.Println("========================================") + testNormalFlow(testUserID, systemCouponID, testCouponValue) + + fmt.Println("\n========================================") + fmt.Println("测试场景 2: 下单→取消流程") + fmt.Println("========================================") + testCancelFlow(testUserID, systemCouponID, testCouponValue) + + fmt.Println("\n========================================") + fmt.Println("测试场景 3: 并发下单(同一优惠券)") + fmt.Println("========================================") + testConcurrentFlow(testUserID, systemCouponID, testCouponValue) + + fmt.Println("\n========================================") + fmt.Println("测试场景 4: 金额券部分使用") + fmt.Println("========================================") + testPartialUse(testUserID, systemCouponID, testCouponValue) + + fmt.Println("\n========================================") + fmt.Println("测试场景 5: 状态查询兼容性") + fmt.Println("========================================") + testStatusQueries(testUserID, systemCouponID) + + // 清理测试数据 + cleanup(testUserID) + cleanupSystemCoupon(systemCouponID) + + fmt.Println("\n========================================") + fmt.Println("所有测试完成!") + fmt.Println("========================================") +} + +func cleanup(userID int64) { + db.Exec("DELETE FROM order_coupons WHERE order_id IN (SELECT id FROM orders WHERE user_id = ?)", userID) + db.Exec("DELETE FROM user_coupon_ledger WHERE user_id = ?", userID) + db.Exec("DELETE FROM orders WHERE user_id = ?", userID) + db.Exec("DELETE FROM user_coupons WHERE user_id = ?", userID) +} + +func cleanupSystemCoupon(id int64) { + db.Exec("DELETE FROM system_coupons WHERE id = ?", id) +} + +func createTestSystemCoupon(value int64) int64 { + res, err := db.Exec(` + INSERT INTO system_coupons + (name, discount_type, discount_value, min_spend, scope_type, status, created_at, updated_at) + VALUES ('测试金额券', 1, ?, 0, 1, 1, NOW(), NOW()) + `, value) + if err != nil { + fmt.Printf("❌ 创建优惠券模板失败: %v\n", err) + return 0 + } + id, _ := res.LastInsertId() + fmt.Printf("✅ 创建测试优惠券模板 ID=%d 面值=%d分\n", id, value) + return id +} + +func createTestUserCoupon(userID int64, systemCouponID int64, balance int64) int64 { + validEnd := time.Now().Add(24 * time.Hour) + res, err := db.Exec(` + INSERT INTO user_coupons + (user_id, coupon_id, balance_amount, status, valid_start, valid_end, created_at, updated_at) + VALUES (?, ?, ?, 1, NOW(), ?, NOW(), NOW()) + `, userID, systemCouponID, balance, validEnd) + if err != nil { + fmt.Printf("❌ 创建用户优惠券失败: %v\n", err) + return 0 + } + id, _ := res.LastInsertId() + return id +} + +func getCouponStatus(userCouponID int64) (status int32, balance int64) { + db.QueryRow("SELECT status, balance_amount FROM user_coupons WHERE id = ?", userCouponID).Scan(&status, &balance) + return +} + +// 测试场景 1: 正常下单→支付流程 +func testNormalFlow(userID int64, systemCouponID int64, couponValue int64) { + userCouponID := createTestUserCoupon(userID, systemCouponID, couponValue) + if userCouponID == 0 { + return + } + fmt.Printf("✅ 创建用户优惠券 ID=%d 余额=%d分 状态=1(未使用)\n", userCouponID, couponValue) + + status, balance := getCouponStatus(userCouponID) + assert("初始状态", status == 1 && balance == couponValue, fmt.Sprintf("status=%d balance=%d", status, balance)) + + // 模拟下单预扣 + orderID := createTestOrder(userID, userCouponID, 200) // 扣200分 + if orderID == 0 { + return + } + simulatePreDeduct(userCouponID, 200) + + status, balance = getCouponStatus(userCouponID) + assert("下单后预扣", status == 4 && balance == couponValue-200, fmt.Sprintf("status=%d balance=%d", status, balance)) + + // 模拟支付成功确认 + simulatePayConfirm(userCouponID, balance) + + status, balance = getCouponStatus(userCouponID) + assert("支付确认后", (status == 1 || status == 2) && balance == couponValue-200, fmt.Sprintf("status=%d balance=%d", status, balance)) +} + +// 测试场景 2: 下单→取消流程 +func testCancelFlow(userID int64, systemCouponID int64, couponValue int64) { + userCouponID := createTestUserCoupon(userID, systemCouponID, couponValue) + if userCouponID == 0 { + return + } + fmt.Printf("✅ 创建用户优惠券 ID=%d 余额=%d分\n", userCouponID, couponValue) + + // 模拟下单预扣 + orderID := createTestOrder(userID, userCouponID, 300) + if orderID == 0 { + return + } + simulatePreDeduct(userCouponID, 300) + + status, balance := getCouponStatus(userCouponID) + assert("下单后", status == 4 && balance == couponValue-300, fmt.Sprintf("status=%d balance=%d", status, balance)) + + // 模拟取消订单,恢复优惠券 + simulateCancelRestore(userCouponID, 300) + + status, balance = getCouponStatus(userCouponID) + assert("取消后恢复", status == 1 && balance == couponValue, fmt.Sprintf("status=%d balance=%d", status, balance)) +} + +// 测试场景 3: 并发下单(同一优惠券) +func testConcurrentFlow(userID int64, systemCouponID int64, couponValue int64) { + userCouponID := createTestUserCoupon(userID, systemCouponID, couponValue) // 500分 + if userCouponID == 0 { + return + } + fmt.Printf("✅ 创建用户优惠券 ID=%d 余额=%d分\n", userCouponID, couponValue) + + // 模拟两个并发请求,都尝试使用全部余额 + // 第一个应该成功,第二个应该失败 + + // 使用事务模拟并发操作 + ctx := context.Background() + tx1, err := db.BeginTx(ctx, nil) + if err != nil { + fmt.Printf("❌ 事务1创建失败: %v\n", err) + return + } + tx2, err := db.BeginTx(ctx, nil) + if err != nil { + tx1.Rollback() + fmt.Printf("❌ 事务2创建失败: %v\n", err) + return + } + + // 事务1: 原子预扣 (应该成功) + res1, err1 := tx1.Exec(` + UPDATE user_coupons + SET balance_amount = balance_amount - ?, + status = 4 + WHERE id = ? AND balance_amount >= ? AND status IN (1, 4) + `, couponValue, userCouponID, couponValue) + + var affected1 int64 + if err1 == nil && res1 != nil { + affected1, _ = res1.RowsAffected() + } + success1 := err1 == nil && affected1 > 0 + + // 提交事务1 + tx1.Commit() + + // 事务2: 原子预扣 (应该失败,余额不足) + res2, err2 := tx2.Exec(` + UPDATE user_coupons + SET balance_amount = balance_amount - ?, + status = 4 + WHERE id = ? AND balance_amount >= ? AND status IN (1, 4) + `, couponValue, userCouponID, couponValue) + + var affected2 int64 + if err2 == nil && res2 != nil { + affected2, _ = res2.RowsAffected() + } + success2 := err2 == nil && affected2 > 0 + + tx2.Commit() + + assert("并发测试: 第一个成功", success1, fmt.Sprintf("err=%v affected=%d", err1, affected1)) + assert("并发测试: 第二个失败", !success2, fmt.Sprintf("err=%v affected=%d", err2, affected2)) +} + +// 测试场景 4: 金额券部分使用 +func testPartialUse(userID int64, systemCouponID int64, couponValue int64) { + userCouponID := createTestUserCoupon(userID, systemCouponID, couponValue) // 500分 + if userCouponID == 0 { + return + } + fmt.Printf("✅ 创建用户优惠券 ID=%d 余额=%d分\n", userCouponID, couponValue) + + // 第一次使用200分 + createTestOrder(userID, userCouponID, 200) + simulatePreDeduct(userCouponID, 200) + simulatePayConfirm(userCouponID, couponValue-200) + + status, balance := getCouponStatus(userCouponID) + assert("第一次使用后", status == 1 && balance == 300, fmt.Sprintf("status=%d balance=%d", status, balance)) + + // 第二次使用150分 + createTestOrder(userID, userCouponID, 150) + simulatePreDeduct(userCouponID, 150) + simulatePayConfirm(userCouponID, 150) + + status, balance = getCouponStatus(userCouponID) + assert("第二次使用后", status == 1 && balance == 150, fmt.Sprintf("status=%d balance=%d", status, balance)) + + // 第三次用完剩余 + createTestOrder(userID, userCouponID, 150) + simulatePreDeduct(userCouponID, 150) + simulatePayConfirm(userCouponID, 0) + + status, balance = getCouponStatus(userCouponID) + assert("用完后", status == 2 && balance == 0, fmt.Sprintf("status=%d balance=%d", status, balance)) +} + +// 测试场景 5: 状态查询兼容性 +func testStatusQueries(userID int64, systemCouponID int64) { + // 创建不同状态的优惠券 + uc1 := createTestUserCoupon(userID, systemCouponID, 100) // status=1 + uc2 := createTestUserCoupon(userID, systemCouponID, 200) + simulatePreDeduct(uc2, 100) // status=4 + uc3 := createTestUserCoupon(userID, systemCouponID, 300) + db.Exec("UPDATE user_coupons SET status = 2 WHERE id = ?", uc3) // status=2 + + // 测试 applyCouponWithCap 的查询条件 (应该能找到 status IN (1, 4)) + var count int + db.QueryRow(` + SELECT COUNT(*) FROM user_coupons + WHERE user_id = ? AND status IN (1, 4) + `, userID).Scan(&count) + assert("IN (1,4) 查询", count >= 2, fmt.Sprintf("count=%d (应>=2)", count)) + + // 测试 expiration 的查询条件 (应该包含 status IN (1, 2, 4)) + db.QueryRow(` + SELECT COUNT(*) FROM user_coupons + WHERE user_id = ? AND status IN (1, 2, 4) + `, userID).Scan(&count) + assert("IN (1,2,4) 查询", count >= 3, fmt.Sprintf("count=%d (应>=3)", count)) + + fmt.Printf("✅ 优惠券 ID %d (status=1), %d (status=4), %d (status=2)\n", uc1, uc2, uc3) +} + +// 辅助函数 +func createTestOrder(userID int64, couponID int64, amount int64) int64 { + orderNo := fmt.Sprintf("TEST%d", time.Now().UnixNano()) + res, err := db.Exec(` + INSERT INTO orders + (user_id, order_no, source_type, total_amount, discount_amount, actual_amount, coupon_id, status, created_at, updated_at) + VALUES (?, ?, 2, ?, ?, ?, ?, 1, NOW(), NOW()) + `, userID, orderNo, 1000, amount, 1000-amount, couponID) + if err != nil { + fmt.Printf("❌ 创建订单失败: %v\n", err) + return 0 + } + id, _ := res.LastInsertId() + + // 记录 order_coupons + db.Exec(`INSERT INTO order_coupons (order_id, user_coupon_id, applied_amount, created_at) VALUES (?, ?, ?, NOW())`, id, couponID, amount) + + return id +} + +func simulatePreDeduct(userCouponID int64, amount int64) { + db.Exec(` + UPDATE user_coupons + SET balance_amount = balance_amount - ?, + status = 4 + WHERE id = ? AND balance_amount >= ? + `, amount, userCouponID, amount) +} + +func simulatePayConfirm(userCouponID int64, newBalance int64) { + newStatus := 1 + if newBalance <= 0 { + newStatus = 2 + } + db.Exec(`UPDATE user_coupons SET status = ? WHERE id = ? AND status = 4`, newStatus, userCouponID) +} + +func simulateCancelRestore(userCouponID int64, amount int64) { + db.Exec(` + UPDATE user_coupons + SET balance_amount = balance_amount + ?, + status = 1 + WHERE id = ? AND status = 4 + `, amount, userCouponID) +} + +func assert(name string, condition bool, detail string) { + if condition { + fmt.Printf("✅ %s: PASS (%s)\n", name, detail) + } else { + fmt.Printf("❌ %s: FAIL (%s)\n", name, detail) + } +} diff --git a/scripts/test_douyin_sync.py b/scripts/test_douyin_sync.py deleted file mode 100644 index 9fd6a85..0000000 --- a/scripts/test_douyin_sync.py +++ /dev/null @@ -1,58 +0,0 @@ -import requests -import json -import time - -def test_douyin_sync(): - # 用户提供的 Cookie - cookie = "zsgw_business_data=%7B%22uuid%22%3A%2286276357-089d-4844-93c9-bdf1b1e65ee6%22%2C%22platform%22%3A%22pc%22%2C%22source%22%3A%22bd.pcpz.30%22%7D; source=bd.pcpz.30; x-web-secsdk-uid=09481e47-b8cf-4757-81e0-af505a39d0aa; Hm_lvt_b6520b076191ab4b36812da4c90f7a5e=1768049462; HMACCOUNT=7DD25A4453689E0B; passport_csrf_token=77c40059afcea4fc1706178e96bacfaa; passport_csrf_token_default=77c40059afcea4fc1706178e96bacfaa; ttcid=aa179f0f1c514923b6b7d7e853ce373737; tt_scid=mHXrvwiVzL6PDC4sh38F8CI6aSw5GAAubYwmtKSTljHim8X5v3lMpai6cQ8asHkc4337; odin_tt=da3f1dde2546c094ba6a800e6f3117975c0dbd2162b8bd5478ac08aca51302144994ec271048d05b02f5a5c5744e175a2a071a31db8ed24d7eeb5be0bd7d8967; passport_auth_status=ce4c2cd652aff0df69f57a3a27d28284%2C; passport_auth_status_ss=ce4c2cd652aff0df69f57a3a27d28284%2C; uid_tt=73e8562f3280861db5ec3669ea4d06c2; uid_tt_ss=73e8562f3280861db5ec3669ea4d06c2; sid_tt=1d3b3b3c38d3f42f40dc2b28191e5039; sessionid=1d3b3b3c38d3f42f40dc2b28191e5039; sessionid_ss=1d3b3b3c38d3f42f40dc2b28191e5039; is_staff_user=false; PHPSESSID=4e4e0f987481cbd3f8a98dbb1fce5901; PHPSESSID_SS=4e4e0f987481cbd3f8a98dbb1fce5901; ucas_c0=CkEKBTEuMC4wEKOIjriN6ZKxaRjmJiD61rDnqc2DBCiwITCb1oDYuM3aB0DRlonLBkjRysXNBlC_vL6Ekt3t1GdYbhIUwSNcefkX8KzDmEbEw61q_XBD2c4; ucas_c0_ss=CkEKBTEuMC4wEKOIjriN6ZKxaRjmJiD61rDnqc2DBCiwITCb1oDYuM3aB0DRlonLBkjRysXNBlC_vL6Ekt3t1GdYbhIUwSNcefkX8KzDmEbEw61q_XBD2c4; csrf_session_id=3e3be9049498206e97df3a6d41696fe9; s_v_web_id=verify_mk8b0ntk_qptpzrvX_hjTu_4WCq_9zwq_FXtbbba6u272; COMPASS_LUOPAN_DT=session_7593712980437549363; Hm_lpvt_b6520b076191ab4b36812da4c90f7a5e=1768379293; ttwid=1%7CS_Ap3Z1--fOYmwY3vpxK8a2XoNu3eVhAT5kqA5mLGv4%7C1768379293%7C0eeb178b9757c52e917b36207bd87835b0efc4989e8f4c12cd4059bf88c8e885; gfkadpd=4272,23756; ecom_gray_shop_id=156231010; sid_guard=1d3b3b3c38d3f42f40dc2b28191e5039%7C1768379311%7C5184000%7CSun%2C+15-Mar-2026+08%3A28%3A31+GMT; session_tlb_tag=sttt%7C18%7CHTs7PDjT9C9A3CsoGR5QOf_________O-D0GAd06qWfkOVxj-KjALh7_D9vaVt0zdqsst9p2yRQ%3D; sid_ucp_v1=1.0.0-KGQ5OWQyNzI1NmJiYWU2OWJkZWE3YmZjZmJmNmFhMzRiMmJjYjZkMWUKGQib1oDYuM3aBxCvp53LBhiwISAMOAZA9AcaAmhsIiAxZDNiM2IzYzM4ZDNmNDJmNDBkYzJiMjgxOTFlNTAzOQ; ssid_ucp_v1=1.0.0-KGQ5OWQyNzI1NmJiYWU2OWJkZWE3YmZjZmJmNmFhMzRiMmJjYjZkMWUKGQib1oDYuM3aBxCvp53LBhiwISAMOAZA9AcaAmhsIiAxZDNiM2IzYzM4ZDNmNDJmNDBkYzJiMjgxOTFlNTAzOQ; BUYIN_SASID=SID2_7595130123662917930" - - url = "https://fxg.jinritemai.com/api/order/searchlist" - - headers = { - "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36", - "Accept": "application/json, text/plain, */*", - "Cookie": cookie, - "Referer": "https://fxg.jinritemai.com/ffa/morder/order/list" - } - - params = { - "page": "0", - "pageSize": "10", - "order_by": "create_time", - "order": "desc", - "tab": "all", - "appid": "1", - "_bid": "ffa_order", - "aid": "4272" - } - - print(f"正在请求: {url}") - print(f"参数: {json.dumps(params, indent=2, ensure_ascii=False)}") - - try: - response = requests.get(url, headers=headers, params=params, timeout=30) - print(f"状态码: {response.status_code}") - - try: - data = response.json() - if data.get("st") == 0 or data.get("code") == 0: - print("\n✅ 测试成功") - orders = data.get("data", []) - if orders: - first_order = orders[0] - print(f"订单字段: {list(first_order.keys())}") - if "sku_order_list" in first_order: - print(f"SKU 列表第一个子项字段: {list(first_order['sku_order_list'][0].keys())}") - print(f"Product ID 示例: {first_order['sku_order_list'][0].get('product_id')}") - else: - print(f"\n❌ 测试失败: {data.get('msg')}") - - except json.JSONDecodeError: - print(f"\n❌ 解析 JSON 失败") - print(f"原始响应内容: {response.text[:500]}") - - except Exception as e: - print(f"\n❌ 发送请求失败: {str(e)}") - -if __name__ == "__main__": - test_douyin_sync() diff --git a/scripts/test_douyin_uid.go b/scripts/test_douyin_uid.go deleted file mode 100644 index 9285c42..0000000 --- a/scripts/test_douyin_uid.go +++ /dev/null @@ -1,50 +0,0 @@ -package main - -import ( - "fmt" - "io" - "net/http" - "time" -) - -func main() { - url := "https://pigeon.jinritemai.com/backstage/queryOriginUid?biz_type=4&PIGEON_BIZ_TYPE=2&_pms=1&FUSION=true&user_id=AQDhjrlOTbdy6KuSWdeUaOgdDMC-Du5_0jrWCPec4bezjpwavPsjF4ccY5Xh_ismfd4S4mqQTg9_BNroR6puftMW&from_order=6946598919988843948&verifyFp=verify_mjqm0v8i_T6A7WUSJ_8DIX_4pWZ_A2IZ_wkv5aC3uVnam&fp=verify_mjqm0v8i_T6A7WUSJ_8DIX_4pWZ_A2IZ_wkv5aC3uVnam&msToken=DPDL9nCqmlG8xAiMGiYrD69TP4mjyLUe6PAHFNTfU5Osfe5fWdkO3xCakiTNtR6l2-IirQmU7evb5KD7JbLTQiiIFMmbmyLVnonF3cLoM4v57gcsSdtHQAyPCLbXKVI-K6oMcAfRZcUdJqBA5NzAsIGLKs8SOJVrni8AIXl5-KoNqfngwkUUH9I%3D&a_bogus=YJ05ktSiDZA5FpCtmOa8y4%2FlWZxlNB8y7eTKRKKz7qPIO7FP0jBwKrbRcxwv5XDZURpR2eV7RDMMYEVc0bG0ZZrkFmpkS%2FJyeWOC98sLgqwkbFhkEqfBCuuwCJtYWYkEm%2Fo6J1k1l0WO2xA4D3aiUB5r7ATHsQkdKNrbdnRGx9evgM49zpMqPufAcDCCUarhBt7SHqb%3D" - - req, err := http.NewRequest("GET", url, nil) - if err != nil { - fmt.Printf("创建请求失败: %v\n", err) - return - } - - // 设置 Header - req.Header.Set("accept", "application/json, text/plain, */*") - req.Header.Set("accept-language", "zh-CN,zh;q=0.9") - req.Header.Set("origin", "https://im.jinritemai.com") - req.Header.Set("referer", "https://im.jinritemai.com/") - req.Header.Set("user-agent", "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36") - req.Header.Set("x-secsdk-csrf-token", "0001000000017d052e72d67cea10001d8a0938ac943b8d6a2e6d27a9ad7c99a59ed7a4ce3d741887dab95d16a595,b7b4150c5eeefaede4ef5e71473e9dc1") - - // 设置 Cookie - req.Header.Set("Cookie", "passport_csrf_token=afcc4debfeacce6454979bb9465999dc; passport_csrf_token_default=afcc4debfeacce6454979bb9465999dc; passport_mfa_token=CjeFM9DjoKw5ZpfSXbltDwu5w%2FfC9ff%2BmJ1SnSMVFE4sFluTjapS0dE7iyY6l9SOHXbpe6tnJ%2BErGkoKPAAAAAAAAAAAAABP4kEu3aMxKlU%2Ffnu1I7drSQejvQQ41aNpbaoWScxScKhuKLwR1YvembHqZro7QE0RnhCTuYUOGPax0WwgAiIBA%2FhvYRo%3D; passport_auth_status=26cd27ba377557fee00599de3db7cebe%2C; passport_auth_status_ss=26cd27ba377557fee00599de3db7cebe%2C; uid_tt=6e62906911e209eb0460f172ce88e770; uid_tt_ss=6e62906911e209eb0460f172ce88e770; sid_tt=f52081faaea135495e5c7f1d731aca79; sessionid=f52081faaea135495e5c7f1d731aca79; sessionid_ss=f52081faaea135495e5c7f1d731aca79; is_staff_user=false; PHPSESSID=73bc52676d8a441a5128f86c164d91a5; PHPSESSID_SS=73bc52676d8a441a5128f86c164d91a5; ucas_c0=CkEKBTEuMC4wEJOIj8aV5P2oaRjmJiD8vrDz9Iy9AyiwITDYkuCImY2cBkCm7sfKBkimooTNBlCPvJrqxbSyumJYbhIU_065hMaNOcS1wHbVur_NQsaamsw; ucas_c0_ss=CkEKBTEuMC4wEJOIj8aV5P2oaRjmJiD8vrDz9Iy9AyiwITDYkuCImY2cBkCm7sfKBkimooTNBlCPvJrqxbSyumJYbhIU_065hMaNOcS1wHbVur_NQsaamsw; COMPASS_LUOPAN_DT=session_7589117384745566498; SHOP_ID=47668214; PIGEON_CID=3501298428676440; odin_tt=ae3c1b406c2527cb1dafe2ce0c5d7fa6e8dd0633f2991f50f0388dc4f83c30fcd5553f029b89c4f61587ea7e1065b232; ttwid=1%7CAwu3-vdDBhOP12XdEzmCJlbyX3Qt_5RcioPVgjBIDps%7C1767452427%7Ca6b23c96c08851a0abfa663ac7be9d19fa11e4cf120f3adc483edb358e0880bc; sid_guard=f52081faaea135495e5c7f1d731aca79%7C1767544182%7C5184000%7CThu%2C+05-Mar-2026+16%3A29%3A42+GMT; session_tlb_tag=sttt%7C7%7C9SCB-q6hNUleXH8dcxrKef________-kReT5b1uIKAfFTzjQUFgobm4jLDmrqpsvf_u6YPGCZ7A%3D; sid_ucp_v1=1.0.0-KDNmM2NjMzBmYjFhYThjZWIxNTUwMGE1ZWMzZjZmYWQ4MzJlYzNkYzQKGQjYkuCImY2cBhD2qurKBhiwISAMOAJA8QcaAmhsIiBmNTIwODFmYWFlYTEzNTQ5NWU1YzdmMWQ3MzFhY2E3OQ; ssid_ucp_v1=1.0.0-KDNmM2NjMzBmYjFhYThjZWIxNTUwMGE1ZWMzZjZmYWQ4MzJlYzNkYzQKGQjYkuCImY2cBhD2qurKBhiwISAMOAJA8QcaAmhsIiBmNTIwODFmYWFlYTEzNTQ5NWU1YzdmMWQ3MzFhY2E3OQ; BUYIN_SASID=SID2_7591542322157568294; csrf_session_id=b7b4150c5eeefaede4ef5e71473e9dc1") - - client := &http.Client{ - Timeout: 15 * time.Second, - } - - fmt.Println("正在发送请求到抖店接口...") - resp, err := client.Do(req) - if err != nil { - fmt.Printf("请求执行失败: %v\n", err) - return - } - defer resp.Body.Close() - - body, err := io.ReadAll(resp.Body) - if err != nil { - fmt.Printf("读取响应内容失败: %v\n", err) - return - } - - fmt.Printf("响应状态码: %d\n", resp.StatusCode) - fmt.Printf("响应数据: %s\n", string(body)) -} diff --git a/scripts/test_order_no.go b/scripts/test_order_no.go new file mode 100644 index 0000000..9aecdca --- /dev/null +++ b/scripts/test_order_no.go @@ -0,0 +1,24 @@ +package main + +import ( + "fmt" + "math/rand" + "time" +) + +func generateOrderNo() string { + // 使用当前时间戳 + 随机数生成订单号 + // 格式:RG + 年月日时分秒 + 6位随机数 + r := rand.New(rand.NewSource(time.Now().UnixNano())) + return fmt.Sprintf("RG%s%06d", + time.Now().Format("20060102150405"), + r.Intn(1000000), + ) +} + +func main() { + for i := 0; i < 10; i++ { + fmt.Println(generateOrderNo()) + time.Sleep(1 * time.Millisecond) + } +} diff --git a/scripts/test_update.go b/scripts/test_update.go new file mode 100644 index 0000000..5cd5974 --- /dev/null +++ b/scripts/test_update.go @@ -0,0 +1,28 @@ +package main + +import ( + "database/sql" + "fmt" + "log" + + _ "github.com/go-sql-driver/mysql" +) + +func main() { + dsn := "root:bindbox2025kdy@tcp(150.158.78.154:3306)/bindbox_game" + db, err := sql.Open("mysql", dsn) + if err != nil { + log.Fatal(err) + } + defer db.Close() + + // 尝试更新一个已存在的订单 (从日志中找一个 ID) + shopOrderID := "6950057259045885189" + res, err := db.Exec("UPDATE douyin_orders SET actual_pay_amount=100, actual_receive_amount=100 WHERE shop_order_id=?", shopOrderID) + if err != nil { + fmt.Printf("Update failed: %v\n", err) + } else { + affected, _ := res.RowsAffected() + fmt.Printf("Update success, rows affected: %d\n", affected) + } +} diff --git a/scripts/verify_coupon_fix.go b/scripts/verify_coupon_fix.go new file mode 100644 index 0000000..efe0f1f --- /dev/null +++ b/scripts/verify_coupon_fix.go @@ -0,0 +1,42 @@ +package main + +import ( + "database/sql" + "fmt" + "log" + + _ "github.com/go-sql-driver/mysql" +) + +func main() { + // 连接数据库 + dsn := "root:bindbox2025kdy@tcp(150.158.78.154:3306)/dev_game?parseTime=true" + db, err := sql.Open("mysql", dsn) + if err != nil { + log.Fatal("连接失败:", err) + } + defer db.Close() + + // 1. 重置优惠券状态用于测试 + // 注意:这里为了不破坏现场,我们创建一个新的优惠券用于测试 + // 或者,如果用户允许,我们可以修复已经超额的优惠券? + // 这里我们只观察,不测试下单(因为需要调用 API)。 + // 但我们可以手动验证 "Mock" ApplyCouponWithCap 逻辑。 + + fmt.Println("此脚本仅打印当前优惠券 275 的状态,请手动进行下单测试验证修复效果。") + + // 查询优惠券状态 + var bal int64 + var status int32 + err = db.QueryRow("SELECT balance_amount, status FROM user_coupons WHERE id = 275").Scan(&bal, &status) + if err != nil { + log.Fatal(err) + } + fmt.Printf("当前优惠券余额: %d 分, 状态: %d\n", bal, status) + + if bal > 0 { + fmt.Println("余额 > 0,正常情况。请尝试下单直到余额耗尽,再次下单应失败或不使用优惠券。") + } else { + fmt.Println("余额 <= 0,修复前这会导致重新使用 5000 面值。修复后应该无法使用。") + } +}