fix:盈亏计算

This commit is contained in:
邹方成 2026-01-27 01:33:32 +08:00
parent 5ad2f4ace3
commit 6d33cc7fd0
139 changed files with 7078 additions and 1343 deletions

9
.gitignore vendored
View File

@ -27,3 +27,12 @@ go.work.sum
resources/*
build/resources/admin/
logs/
# 敏感配置文件
configs/*.toml
!configs/*.example.toml
# 环境变量
.env
.env.*
!.env.example

0
bindbox_game.db Normal file
View File

47
cmd/9090_audit/main.go Normal file
View File

@ -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)
}
}
}

View File

@ -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)
}
}
}

View File

@ -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.")
}
}

View File

@ -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"])
}
}

View File

@ -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)
}
}

View File

@ -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)
}

28
cmd/check_index/main.go Normal file
View File

@ -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"])
}
}

85
cmd/check_refunds/main.go Normal file
View File

@ -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)
}
}

View File

@ -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"])
}
}

View File

@ -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"])
}
}

44
cmd/debug_balance/main.go Normal file
View File

@ -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)
}
}

50
cmd/debug_card/main.go Normal file
View File

@ -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)
}
}

95
cmd/debug_coupon/main.go Normal file
View File

@ -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")
}
}
}
}

View File

@ -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)
}
}

View File

@ -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)
}

29
cmd/debug_ledger/main.go Normal file
View File

@ -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"])
}
}

View File

@ -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"])
}
}

102
cmd/debug_query/main.go Normal file
View File

@ -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)
}
}

View File

@ -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)
}
}

View File

@ -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"])
}
}

View File

@ -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)
}

View File

@ -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"])
}
}

View File

@ -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)
}
}

36
cmd/fix_order/main.go Normal file
View File

@ -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).")
}
}

View File

@ -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)
}
}

73
cmd/inspect_order/main.go Normal file
View File

@ -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)
}

View File

@ -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)
}
}

View File

@ -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)
}
}
}

View File

@ -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)
}
}
}

186
cmd/simulate_test/main.go Normal file
View File

@ -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).")
}
}

View File

@ -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"])
}
}

View File

@ -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.")
}
}

View File

@ -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'

5
go.mod
View File

@ -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

6
go.sum
View File

@ -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=

View File

@ -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)
}
}

View File

@ -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
}

View File

@ -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)
}()

View File

@ -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)
}
}

View File

@ -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"
}

View File

@ -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: 范围校验失败")

View File

@ -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应正常匹配

View File

@ -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 {

View File

@ -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)

View File

@ -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,
})
}
}

View File

@ -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 == "" {

View File

@ -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)

View File

@ -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)
}

View File

@ -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:

View File

@ -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,
}

View File

@ -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,
})
}
}

View File

@ -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,
})
}
}

View File

@ -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})
}
}

View File

@ -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 +

View File

@ -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

View File

@ -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)

View File

@ -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 失效(防止重复扣减)

View File

@ -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 {

View File

@ -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)
}
}

View File

@ -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)

View File

@ -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 {

View File

@ -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

View File

View File

@ -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
}

View File

@ -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 {

View File

@ -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
}

View File

@ -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),

View File

@ -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 {

View File

@ -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 {

View File

@ -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 {

View File

@ -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 {

View File

@ -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
}

View File

@ -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

View File

@ -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
}

View File

@ -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
}

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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:下单用户IDuser_members.id" json:"user_id"` // 下单用户IDuser_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:关联预支付单IDpayment_preorder.id" json:"pay_preorder_id"` // 关联预支付单IDpayment_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:收货地址IDuser_addresses.id" json:"user_address_id"` // 收货地址IDuser_addresses.id
IsConsumed int32 `gorm:"column:is_consumed;not null;comment:是否已履约/消耗(对虚拟资产)" json:"is_consumed"` // 是否已履约/消耗(对虚拟资产)
PointsLedgerID int64 `gorm:"column:points_ledger_id;comment:积分扣减流水IDuser_points_ledger.id" json:"points_ledger_id"` // 积分扣减流水IDuser_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:下单用户IDuser_members.id" json:"user_id"` // 下单用户IDuser_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:关联预支付单IDpayment_preorder.id" json:"pay_preorder_id"` // 关联预支付单IDpayment_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:收货地址IDuser_addresses.id" json:"user_address_id"` // 收货地址IDuser_addresses.id
IsConsumed int32 `gorm:"column:is_consumed;not null;comment:是否已履约/消耗(对虚拟资产)" json:"is_consumed"` // 是否已履约/消耗(对虚拟资产)
PointsLedgerID int64 `gorm:"column:points_ledger_id;comment:积分扣减流水IDuser_points_ledger.id" json:"points_ledger_id"` // 积分扣减流水IDuser_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

View File

@ -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

View File

@ -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}
}

View File

@ -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
}
}
}

View File

@ -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 {

View File

@ -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())

View File

@ -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
}

View File

@ -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,
})
}
}

View File

@ -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)
}
}

View File

@ -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: 卡片状态无效")
}
}

View File

@ -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
}

View File

@ -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
}

View File

@ -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

View File

@ -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))

View File

@ -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")
}

View File

@ -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
}

View File

@ -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
}
}

View File

@ -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)
}
})
}

View File

@ -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. 首单判断

View File

@ -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)
}

Some files were not shown because too many files have changed in this diff Show More