This commit is contained in:
邹方成 2026-01-28 21:41:47 +08:00
parent ff404e21f0
commit f8624cca49
82 changed files with 0 additions and 6549 deletions

View File

View File

@ -1,47 +0,0 @@
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

@ -1,85 +0,0 @@
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

@ -1,28 +0,0 @@
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

@ -1,43 +0,0 @@
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

@ -1,35 +0,0 @@
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

@ -1,33 +0,0 @@
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)
}

View File

@ -1,28 +0,0 @@
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"])
}
}

View File

@ -1,85 +0,0 @@
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

@ -1,20 +0,0 @@
package main
import (
"bindbox-game/internal/pkg/utils"
"fmt"
)
func main() {
password := "123456"
hash, err := utils.GenerateAdminHashedPassword(password)
if err != nil {
fmt.Println("Error:", err)
return
}
fmt.Println("Password hash for '123456':")
fmt.Println(hash)
fmt.Println()
fmt.Println("SQL to insert admin:")
fmt.Printf("INSERT INTO admin (username, nickname, password, login_status, is_super, created_user, created_at) VALUES ('CC', 'CC', '%s', 1, 0, 'system', NOW());\n", hash)
}

View File

@ -1,38 +0,0 @@
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

@ -1,95 +0,0 @@
package main
import (
"fmt"
"gorm.io/driver/mysql"
"gorm.io/gorm"
"gorm.io/gorm/logger"
)
func main() {
dsn := "root:bindbox2025kdy@tcp(106.54.232.2:3306)/bindbox_game?charset=utf8mb4&parseTime=True&loc=Local"
db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{Logger: logger.Default.LogMode(logger.Info)})
if err != nil {
panic("failed to connect database: " + err.Error())
}
// 检查对对碰活动
fmt.Println("========== 检查对对碰活动 (activity_category_id=3) ==========")
type Activity struct {
ID int64
Name string
PlayType string
ActivityCategoryID int64
}
var matchingActs []Activity
db.Table("activities").Where("activity_category_id = ?", 3).Limit(5).Find(&matchingActs)
fmt.Printf("找到 %d 个对对碰活动\n", len(matchingActs))
for _, act := range matchingActs {
fmt.Printf("\n--- Activity ID=%d Name='%s' PlayType='%s' ---\n", act.ID, act.Name, act.PlayType)
// 获取该活动的 issues
type Issue struct {
ID int64
ActivityID int64
}
var issues []Issue
db.Table("activity_issues").Where("activity_id = ?", act.ID).Find(&issues)
if len(issues) == 0 {
fmt.Println(" No issues found")
continue
}
issueIDs := make([]int64, len(issues))
for i, iss := range issues {
issueIDs[i] = iss.ID
}
fmt.Printf(" Issues: %v\n", issueIDs)
// 统计 activity_draw_logs
var drawLogsCount int64
db.Table("activity_draw_logs").Where("issue_id IN ?", issueIDs).Count(&drawLogsCount)
fmt.Printf(" Draw Logs count: %d\n", drawLogsCount)
// 检查 reward_settings
type RewardStat struct {
Level int32
TotalOrig int64
TotalRemain int64
}
var rewardStats []RewardStat
db.Table("activity_reward_settings").
Select("level, SUM(original_qty) as total_orig, SUM(quantity) as total_remain").
Where("issue_id IN ?", issueIDs).
Group("level").
Scan(&rewardStats)
for _, rs := range rewardStats {
issued := rs.TotalOrig - rs.TotalRemain
fmt.Printf(" Level %d: OrigQty=%d Remain=%d Issued(库存差)=%d\n", rs.Level, rs.TotalOrig, rs.TotalRemain, issued)
}
// 统计 draw_logs 按 level
type DrawLogStat struct {
Level int32
WinCount int64
}
var drawStats []DrawLogStat
db.Table("activity_draw_logs").
Joins("JOIN activity_reward_settings ON activity_reward_settings.id = activity_draw_logs.reward_id").
Where("activity_draw_logs.issue_id IN ?", issueIDs).
Where("activity_draw_logs.is_winner = ?", 1).
Select("activity_reward_settings.level, COUNT(activity_draw_logs.id) as win_count").
Group("activity_reward_settings.level").
Scan(&drawStats)
for _, ds := range drawStats {
fmt.Printf(" Level %d: WinCount(实际抽奖)=%d\n", ds.Level, ds.WinCount)
}
}
fmt.Println("\n============================================")
}

View File

@ -1,32 +0,0 @@
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"])
}
}

View File

@ -1,44 +0,0 @@
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)
}
}

View File

@ -1,50 +0,0 @@
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)
}
}

View File

@ -1,95 +0,0 @@
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

@ -1,122 +0,0 @@
package main
import (
"fmt"
"time"
"gorm.io/driver/mysql"
"gorm.io/gorm"
)
type Orders struct {
ID int64
OrderNo string
SourceType int32
Status int32
UserID int64
TotalAmount int64
CreatedAt time.Time
}
type ActivityDrawLogs struct {
ID int64
OrderID int64
IssueID int64
}
type ActivityIssues struct {
ID int64
ActivityID int64
}
type Activities struct {
ID int64
PlayType string
Name string
}
func (Orders) TableName() string { return "orders" }
func (ActivityDrawLogs) TableName() string { return "activity_draw_logs" }
func (ActivityIssues) TableName() string { return "activity_issues" }
func (Activities) TableName() string { return "activities" }
func main() {
dsn := "root:bindbox2025kdy@tcp(106.54.232.2:3306)/bindbox_game?charset=utf8mb4&parseTime=True&loc=Local"
db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{})
if err != nil {
panic("failed to connect database: " + err.Error())
}
var count int64
db.Model(&Orders{}).Count(&count)
fmt.Printf("Total Orders in DB: %d\n", count)
var orders []Orders
if err := db.Order("id DESC").Limit(5).Find(&orders).Error; err != nil {
fmt.Printf("Error finding orders: %v\n", err)
return
}
fmt.Printf("========== Latest 5 Orders ==========\n")
for _, o := range orders {
fmt.Printf("Order %s (ID: %d): Status=%d, SourceType=%d, Amount=%d, Time=%s\n", o.OrderNo, o.ID, o.Status, o.SourceType, o.TotalAmount, o.CreatedAt)
}
fmt.Printf("=====================================\n\n")
checkSourceType(db, 3, "Matching") // SourceType 3 = Matching
checkSourceType(db, 2, "Ichiban") // SourceType 2 = Ichiban
checkPlayType(db, "default", "Default PlayType")
}
func checkPlayType(db *gorm.DB, playType string, label string) {
fmt.Printf("========== Checking %s (PlayType='%s') ==========\n", label, playType)
var acts []Activities
if err := db.Where("play_type = ?", playType).Limit(5).Find(&acts).Error; err != nil {
fmt.Printf("Error finding activities: %v\n", err)
return
}
for _, a := range acts {
fmt.Printf("Activity ID=%d Name='%s' PlayType='%s'\n", a.ID, a.Name, a.PlayType)
}
fmt.Printf("============================================\n\n")
}
func checkSourceType(db *gorm.DB, sourceType int, label string) {
fmt.Printf("========== Checking %s (SourceType=%d) ==========\n", label, sourceType)
var orders []Orders
// Get last 5 paid orders
if err := db.Where("source_type = ? AND status = 2", sourceType).Order("id DESC").Limit(5).Find(&orders).Error; err != nil {
fmt.Printf("Error finding orders: %v\n", err)
return
}
if len(orders) == 0 {
fmt.Printf("No paid orders found for %s\n", label)
return
}
for _, o := range orders {
fmt.Printf("Order %s (ID: %d): ", o.OrderNo, o.ID)
// Find DrawLog
var log ActivityDrawLogs
if err := db.Where("order_id = ?", o.ID).First(&log).Error; err != nil {
fmt.Printf("DrawLog MISSING (%v)\n", err)
continue
}
// Find Issue
var issue ActivityIssues
if err := db.Where("id = ?", log.IssueID).First(&issue).Error; err != nil {
fmt.Printf("Issue MISSING (ID: %d, Err: %v)\n", log.IssueID, err)
continue
}
// Find Activity
var act Activities
if err := db.Where("id = ?", issue.ActivityID).First(&act).Error; err != nil {
fmt.Printf("Activity MISSING (ID: %d, Err: %v)\n", issue.ActivityID, err)
continue
}
fmt.Printf("PlayType='%s' Name='%s' (ActivityID: %d)\n", act.PlayType, act.Name, act.ID)
}
fmt.Printf("============================================\n\n")
}

View File

@ -1,48 +0,0 @@
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

@ -1,72 +0,0 @@
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)
}

View File

@ -1,29 +0,0 @@
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

@ -1,29 +0,0 @@
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"])
}
}

View File

@ -1,102 +0,0 @@
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,123 +0,0 @@
package main
import (
"bindbox-game/configs"
"bindbox-game/internal/repository/mysql"
"bindbox-game/internal/repository/mysql/model"
"flag"
"fmt"
)
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

@ -1,35 +0,0 @@
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

@ -1,29 +0,0 @@
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

@ -1,74 +0,0 @@
package main
import (
"fmt"
"time"
"gorm.io/driver/mysql"
"gorm.io/gorm"
)
// 简单的 struct 映射
type Orders struct {
ID int64
OrderNo string
SourceType int32
Status int32
Remark string
UserID int64
TotalAmount int64
ActualAmount int64
CreatedAt time.Time
}
type IssuePositionClaims struct {
ID int64
IssueID int64
SlotIndex int64
OrderID int64
UserID int64
}
// 表名映射
func (Orders) TableName() string { return "orders" }
func (IssuePositionClaims) TableName() string { return "issue_position_claims" }
func main() {
// 尝试使用 config 中的密码
dsn := "root:api2api..@tcp(sh-cynosdbmysql-grp-88th45wy.sql.tencentcdb.com:28555)/bindbox_game?charset=utf8mb4&parseTime=True&loc=Local"
db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{})
if err != nil {
panic("failed to connect database: " + err.Error())
}
targetOrderNo := "O20260107092217073"
var order Orders
if err := db.Where("order_no = ?", targetOrderNo).First(&order).Error; err != nil {
fmt.Printf("Error finding order: %v\n", err)
return
}
fmt.Printf("========== Order Info ==========\n")
fmt.Printf("ID: %d\n", order.ID)
fmt.Printf("OrderNo: %s\n", order.OrderNo)
fmt.Printf("UserID: %d\n", order.UserID)
fmt.Printf("SourceType: %d\n", order.SourceType)
fmt.Printf("Status: %d (2=Paid)\n", order.Status)
fmt.Printf("Amount: Total=%d, Actual=%d\n", order.TotalAmount, order.ActualAmount)
fmt.Printf("Remark Length: %d\n", len(order.Remark))
fmt.Printf("Remark Content: %s\n", order.Remark)
fmt.Printf("================================\n")
var claims []IssuePositionClaims
if err := db.Where("order_id = ?", order.ID).Find(&claims).Error; err != nil {
fmt.Printf("Error checking claims: %v\n", err)
}
fmt.Printf("========== Claims Info ==========\n")
fmt.Printf("Claims Count: %d\n", len(claims))
for _, c := range claims {
fmt.Printf("- Claim: SlotIndex=%d IssueID=%d\n", c.SlotIndex, c.IssueID)
}
fmt.Printf("=================================\n")
}

View File

@ -1,40 +0,0 @@
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

@ -1,42 +0,0 @@
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)
}
}

View File

@ -1,36 +0,0 @@
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

@ -1,41 +0,0 @@
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)
}
}

View File

@ -1,28 +0,0 @@
## 自动生成数据库模型和常见的 CRUD 操作
### Usage
```shell
go run cmd/handlergen/main.go -h
Usage of ./cmd/handlergen/main.go:
-table string
enter the required data table
```
#### -table
指定要生成的表名称。
eg :
```shell
--tables="admin" # generate from `admin`
```
## 示例
```shell
# 根目录下执行
go run cmd/handlergen/main.go -table "customer"
```

View File

@ -1,265 +0,0 @@
package {{.PackageName}}
import (
"net/http"
"strconv"
"bindbox-game/internal/code"
"bindbox-game/internal/pkg/core"
"WeChatService/internal/pkg/logger"
"bindbox-game/internal/repository/mysql"
"bindbox-game/internal/repository/mysql/dao"
"bindbox-game/internal/repository/mysql/model"
"go.uber.org/zap"
"gorm.io/gorm"
)
type handler struct {
logger logger.CustomLoggerLogger
writeDB *dao.Query
readDB *dao.Query
}
type genResultInfo struct {
RowsAffected int64 `json:"rows_affected"`
Error error `json:"error"`
}
func New(logger logger.CustomLogger, db mysql.Repo) *handler {
return &handler{
logger: logger,
writeDB: dao.Use(db.GetDbW()),
readDB: dao.Use(db.GetDbR()),
}
}
// Create 新增数据
// @Summary 新增数据
// @Description 新增数据
// @Tags API.{{.VariableName}}
// @Accept json
// @Produce json
// @Param RequestBody body model.{{.StructName}} true "请求参数"
// @Success 200 {object} model.{{.StructName}}
// @Failure 400 {object} code.Failure
// @Router /api/{{.VariableName}} [post]
func (h *handler) Create() core.HandlerFunc {
return func(ctx core.Context) {
var createData model.{{.StructName}}
if err := ctx.ShouldBindJSON(&createData); err != nil {
ctx.AbortWithError(core.Error(
http.StatusBadRequest,
code.ParamBindError,
err.Error()),
)
return
}
if err := h.writeDB.{{.StructName}}.WithContext(ctx.RequestContext()).Create(&createData); err != nil {
ctx.AbortWithError(core.Error(
http.StatusBadRequest,
code.ServerError,
err.Error()),
)
return
}
ctx.Payload(createData)
}
}
// List 获取列表数据
// @Summary 获取列表数据
// @Description 获取列表数据
// @Tags API.{{.VariableName}}
// @Accept json
// @Produce json
// @Success 200 {object} []model.{{.StructName}}
// @Failure 400 {object} code.Failure
// @Router /api/{{.VariableName}}s [get]
func (h *handler) List() core.HandlerFunc {
return func(ctx core.Context) {
list, err := h.readDB.{{.StructName}}.WithContext(ctx.RequestContext()).Find()
if err != nil {
ctx.AbortWithError(core.Error(
http.StatusBadRequest,
code.ServerError,
err.Error()),
)
return
}
ctx.Payload(list)
}
}
// GetByID 根据 ID 获取数据
// @Summary 根据 ID 获取数据
// @Description 根据 ID 获取数据
// @Tags API.{{.VariableName}}
// @Accept json
// @Produce json
// @Param id path string true "ID"
// @Success 200 {object} model.{{.StructName}}
// @Failure 400 {object} code.Failure
// @Router /api/{{.VariableName}}/{id} [get]
func (h *handler) GetByID() core.HandlerFunc {
return func(ctx core.Context) {
id, err := strconv.Atoi(ctx.Param("id"))
if err != nil {
ctx.AbortWithError(core.Error(
http.StatusBadRequest,
code.ParamBindError,
err.Error()),
)
return
}
info, err := h.readDB.{{.StructName}}.WithContext(ctx.RequestContext()).Where(h.readDB.{{.StructName}}.ID.Eq(int32(id))).First()
if err != nil {
if err == gorm.ErrRecordNotFound {
ctx.AbortWithError(core.Error(
http.StatusBadRequest,
code.ServerError,
"record not found"),
)
} else {
ctx.AbortWithError(core.Error(
http.StatusBadRequest,
code.ServerError,
err.Error()),
)
}
return
}
ctx.Payload(info)
}
}
// DeleteByID 根据 ID 删除数据
// @Summary 根据 ID 删除数据
// @Description 根据 ID 删除数据
// @Tags API.{{.VariableName}}
// @Accept json
// @Produce json
// @Param id path string true "ID"
// @Success 200 {object} genResultInfo
// @Failure 400 {object} code.Failure
// @Router /api/{{.VariableName}}/{id} [delete]
func (h *handler) DeleteByID() core.HandlerFunc {
return func(ctx core.Context) {
id, err := strconv.Atoi(ctx.Param("id"))
if err != nil {
ctx.AbortWithError(core.Error(
http.StatusBadRequest,
code.ParamBindError,
err.Error()),
)
return
}
info, err := h.readDB.{{.StructName}}.WithContext(ctx.RequestContext()).Where(h.readDB.{{.StructName}}.ID.Eq(int32(id))).First()
if err != nil {
if err == gorm.ErrRecordNotFound {
ctx.AbortWithError(core.Error(
http.StatusBadRequest,
code.ServerError,
"record not found"),
)
} else {
ctx.AbortWithError(core.Error(
http.StatusBadRequest,
code.ServerError,
err.Error()),
)
}
return
}
result, err := h.writeDB.{{.StructName}}.Delete(info)
if err != nil {
ctx.AbortWithError(core.Error(
http.StatusBadRequest,
code.ServerError,
err.Error()),
)
}
resultInfo := new(genResultInfo)
resultInfo.RowsAffected = result.RowsAffected
resultInfo.Error = result.Error
ctx.Payload(resultInfo)
}
}
// UpdateByID 根据 ID 更新数据
// @Summary 根据 ID 更新数据
// @Description 根据 ID 更新数据
// @Tags API.{{.VariableName}}
// @Accept json
// @Produce json
// @Param id path string true "ID"
// @Param RequestBody body model.{{.StructName}} true "请求参数"
// @Success 200 {object} genResultInfo
// @Failure 400 {object} code.Failure
// @Router /api/{{.VariableName}}/{id} [put]
func (h *handler) UpdateByID() core.HandlerFunc {
return func(ctx core.Context) {
id, err := strconv.Atoi(ctx.Param("id"))
if err != nil {
ctx.AbortWithError(core.Error(
http.StatusBadRequest,
code.ParamBindError,
err.Error()),
)
return
}
var updateData map[string]interface{}
if err := ctx.ShouldBindJSON(&updateData); err != nil {
ctx.AbortWithError(core.Error(
http.StatusBadRequest,
code.ServerError,
err.Error()),
)
return
}
info, err := h.readDB.{{.StructName}}.WithContext(ctx.RequestContext()).Where(h.readDB.{{.StructName}}.ID.Eq(int32(id))).First()
if err != nil {
if err == gorm.ErrRecordNotFound {
ctx.AbortWithError(core.Error(
http.StatusBadRequest,
code.ServerError,
"record not found"),
)
} else {
ctx.AbortWithError(core.Error(
http.StatusBadRequest,
code.ServerError,
err.Error()),
)
}
return
}
result, err := h.writeDB.{{.StructName}}.WithContext(ctx.RequestContext()).Where(h.writeDB.{{.StructName}}.ID.Eq(info.ID)).Updates(updateData)
if err != nil {
ctx.AbortWithError(core.Error(
http.StatusBadRequest,
code.ServerError,
err.Error()),
)
return
}
resultInfo := new(genResultInfo)
resultInfo.RowsAffected = result.RowsAffected
resultInfo.Error = result.Error
ctx.Payload(resultInfo)
}
}

View File

@ -1,90 +0,0 @@
package main
import (
"flag"
"fmt"
"log"
"os"
"path/filepath"
"strings"
"text/template"
)
type TemplateData struct {
PackageName string
VariableName string
StructName string
}
func main() {
table := flag.String("table", "", "enter the required data table")
flag.Parse()
tableName := *table
if tableName == "" {
log.Fatal("table cannot be empty, please provide a valid table name.")
}
// 获取当前工作目录
wd, err := os.Getwd()
if err != nil {
log.Fatalf("Error getting working directory:%s", err.Error())
}
// 模板文件路径
tmplPath := fmt.Sprintf("%s/cmd/handlergen/handler_template.go.tpl", wd)
tmpl, err := template.ParseFiles(tmplPath)
if err != nil {
log.Fatal(err)
}
log.Printf("Template file parsed: %s", tmplPath)
// 替换的变量
data := TemplateData{
PackageName: tableName,
VariableName: tableName,
StructName: toCamelCase(tableName),
}
// 生成文件的目录和文件名
outputDir := fmt.Sprintf("%s/internal/api/%s", wd, tableName)
outputFile := filepath.Join(outputDir, fmt.Sprintf("%s.gen.go", tableName))
// 创建目录
err = os.MkdirAll(outputDir, os.ModePerm)
if err != nil {
log.Fatal(err)
}
// 创建文件
file, err := os.Create(outputFile)
if err != nil {
log.Fatal(err)
}
defer file.Close()
log.Printf("File created: %s", outputFile)
// 执行模板并生成文件
err = tmpl.Execute(file, data)
if err != nil {
log.Fatal(err)
}
log.Println("Template execution completed successfully.")
}
// 将字符串转为驼峰式命名
func toCamelCase(s string) string {
// 用下划线分割字符串
parts := strings.Split(s, "_")
// 对每个部分首字母大写
for i := 0; i < len(parts); i++ {
parts[i] = strings.Title(parts[i])
}
// 拼接所有部分
return strings.Join(parts, "")
}

View File

@ -1,73 +0,0 @@
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

@ -1,32 +0,0 @@
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

@ -1,81 +0,0 @@
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

@ -1,93 +0,0 @@
package main
import (
"context"
"flag"
"fmt"
"sort"
"bindbox-game/internal/pkg/logger"
"bindbox-game/internal/pkg/redis"
"bindbox-game/internal/repository/mysql"
"bindbox-game/internal/repository/mysql/dao"
activitysvc "bindbox-game/internal/service/activity"
)
// usage: go run cmd/matching_sim/main.go -env dev -runs 10000
func main() {
runs := flag.Int("runs", 10000, "运行模拟的次数")
flag.Parse()
// 1. 初始化数据库
dbRepo, err := mysql.New()
if err != nil {
panic(fmt.Sprintf("数据库连接失败: %v", err))
}
// 2. 初始化日志 (模拟 Service 需要)
l, err := logger.NewCustomLogger(dao.Use(dbRepo.GetDbW()))
if err != nil {
panic(err)
}
// 3. 初始化 Service (完全模拟真实注入)
// 注意:这里不需要真实的 user service传入 nil 即可
svc := activitysvc.New(l, dbRepo, nil, redis.GetClient())
ctx := context.Background()
// 4. 从真实数据库加载卡牌配置
fmt.Println(">>> 正在从数据库加载真实卡牌配置...")
configs, err := svc.ListMatchingCardTypes(ctx)
if err != nil {
panic(fmt.Sprintf("读取卡牌配置失败: %v", err))
}
if len(configs) == 0 {
fmt.Println("警告: 数据库中没有启用的卡牌配置,将使用默认配置。")
configs = []activitysvc.CardTypeConfig{
{Code: "A", Quantity: 9}, {Code: "B", Quantity: 9}, {Code: "C", Quantity: 9},
{Code: "D", Quantity: 9}, {Code: "E", Quantity: 9}, {Code: "F", Quantity: 9},
{Code: "G", Quantity: 9}, {Code: "H", Quantity: 9}, {Code: "I", Quantity: 9},
{Code: "J", Quantity: 9}, {Code: "K", Quantity: 9},
}
}
fmt.Println("当前生效配置:")
for _, c := range configs {
fmt.Printf(" - [%s]: %d张\n", c.Code, c.Quantity)
}
// 5. 开始执行模拟
fmt.Printf("\n>>> 正在执行 %d 次大规模真实模拟...\n", *runs)
results := make(map[int64]int)
mseed := []byte("production_simulation_seed")
position := "B" // 默认模拟选中 B 类型
for i := 0; i < *runs; i++ {
// 调用真实业务函数创建游戏 (固定数量逻辑)
game := activitysvc.NewMatchingGameWithConfig(configs, position, mseed)
// 调用真实业务模拟函数
pairs := game.SimulateMaxPairs()
results[pairs]++
}
// 6. 统计并输出
fmt.Println("\n对数分布统计 (100% 模拟真实生产路径):")
var pairsList []int64
for k := range results {
pairsList = append(pairsList, k)
}
sort.Slice(pairsList, func(i, j int) bool {
return pairsList[i] < pairsList[j]
})
sumPairs := int64(0)
for _, p := range pairsList {
count := results[p]
sumPairs += p * int64(count)
fmt.Printf(" %2d 对: %5d 次 (%5.2f%%)\n", p, count, float64(count)/float64(*runs)*100)
}
fmt.Printf("\n平均对数: %.4f\n\n", float64(sumPairs)/float64(*runs))
}

View File

@ -1,155 +0,0 @@
package main
import (
"context"
"encoding/base64"
"flag"
"fmt"
"os"
"bindbox-game/configs"
"bindbox-game/internal/pkg/logger"
"bindbox-game/internal/repository/mysql"
"bindbox-game/internal/repository/mysql/dao"
"bindbox-game/internal/service/sysconfig"
)
var (
dryRun = flag.Bool("dry-run", false, "仅打印将要写入的配置,不实际写入数据库")
force = flag.Bool("force", false, "强制覆盖已存在的配置")
)
func main() {
flag.Parse()
// 初始化数据库
dbRepo, err := mysql.New()
if err != nil {
fmt.Printf("数据库连接失败: %v\n", err)
os.Exit(1)
}
// 初始化 logger (简化版)
customLogger, err := logger.NewCustomLogger(dao.Use(dbRepo.GetDbW()),
logger.WithDebugLevel(),
logger.WithOutputInConsole(),
)
if err != nil {
fmt.Printf("Logger 初始化失败: %v\n", err)
os.Exit(1)
}
ctx := context.Background()
// 创建动态配置服务
dynamicCfg := sysconfig.NewDynamicConfig(customLogger, dbRepo)
staticCfg := configs.Get()
// 定义要迁移的配置项
type configItem struct {
Key string
Value string
Remark string
}
// 读取证书文件内容并 Base64 编码
readAndEncode := func(path string) string {
if path == "" {
return ""
}
data, err := os.ReadFile(path)
if err != nil {
fmt.Printf("警告: 读取文件 %s 失败: %v\n", path, err)
return ""
}
return base64.StdEncoding.EncodeToString(data)
}
items := []configItem{
// COS 配置
{sysconfig.KeyCOSBucket, staticCfg.COS.Bucket, "COS Bucket名称"},
{sysconfig.KeyCOSRegion, staticCfg.COS.Region, "COS 地域"},
{sysconfig.KeyCOSSecretID, staticCfg.COS.SecretID, "COS SecretID (加密存储)"},
{sysconfig.KeyCOSSecretKey, staticCfg.COS.SecretKey, "COS SecretKey (加密存储)"},
{sysconfig.KeyCOSBaseURL, staticCfg.COS.BaseURL, "COS 自定义域名"},
// 微信小程序配置
{sysconfig.KeyWechatAppID, staticCfg.Wechat.AppID, "微信小程序 AppID"},
{sysconfig.KeyWechatAppSecret, staticCfg.Wechat.AppSecret, "微信小程序 AppSecret (加密存储)"},
{sysconfig.KeyWechatLotteryResultTemplateID, staticCfg.Wechat.LotteryResultTemplateID, "中奖结果订阅消息模板ID"},
// 微信支付配置
{sysconfig.KeyWechatPayMchID, staticCfg.WechatPay.MchID, "微信支付商户号"},
{sysconfig.KeyWechatPaySerialNo, staticCfg.WechatPay.SerialNo, "微信支付证书序列号"},
{sysconfig.KeyWechatPayPrivateKey, readAndEncode(staticCfg.WechatPay.PrivateKeyPath), "微信支付私钥 (Base64编码, 加密存储)"},
{sysconfig.KeyWechatPayApiV3Key, staticCfg.WechatPay.ApiV3Key, "微信支付 API v3 密钥 (加密存储)"},
{sysconfig.KeyWechatPayNotifyURL, staticCfg.WechatPay.NotifyURL, "微信支付回调地址"},
{sysconfig.KeyWechatPayPublicKeyID, staticCfg.WechatPay.PublicKeyID, "微信支付公钥ID"},
{sysconfig.KeyWechatPayPublicKey, readAndEncode(staticCfg.WechatPay.PublicKeyPath), "微信支付公钥 (Base64编码, 加密存储)"},
// 阿里云短信配置
{sysconfig.KeyAliyunSMSAccessKeyID, staticCfg.AliyunSMS.AccessKeyID, "阿里云短信 AccessKeyID"},
{sysconfig.KeyAliyunSMSAccessKeySecret, staticCfg.AliyunSMS.AccessKeySecret, "阿里云短信 AccessKeySecret (加密存储)"},
{sysconfig.KeyAliyunSMSSignName, staticCfg.AliyunSMS.SignName, "短信签名"},
{sysconfig.KeyAliyunSMSTemplateCode, staticCfg.AliyunSMS.TemplateCode, "短信模板Code"},
}
fmt.Println("========== 配置迁移工具 ==========")
fmt.Printf("环境: %s\n", configs.ProjectName)
fmt.Printf("Dry Run: %v\n", *dryRun)
fmt.Printf("Force: %v\n", *force)
fmt.Println()
successCount := 0
skipCount := 0
failCount := 0
for _, item := range items {
if item.Value == "" {
fmt.Printf("[跳过] %s: 值为空\n", item.Key)
skipCount++
continue
}
// 检查是否已存在
existing := dynamicCfg.Get(ctx, item.Key)
if existing != "" && !*force {
fmt.Printf("[跳过] %s: 已存在 (使用 -force 覆盖)\n", item.Key)
skipCount++
continue
}
// 脱敏显示
displayValue := item.Value
if sysconfig.IsSensitiveKey(item.Key) {
if len(displayValue) > 8 {
displayValue = displayValue[:4] + "****" + displayValue[len(displayValue)-4:]
} else {
displayValue = "****"
}
} else if len(displayValue) > 50 {
displayValue = displayValue[:50] + "..."
}
if *dryRun {
fmt.Printf("[预览] %s = %s\n", item.Key, displayValue)
successCount++
} else {
if err := dynamicCfg.Set(ctx, item.Key, item.Value, item.Remark); err != nil {
fmt.Printf("[失败] %s: %v\n", item.Key, err)
failCount++
} else {
fmt.Printf("[成功] %s = %s\n", item.Key, displayValue)
successCount++
}
}
}
fmt.Println()
fmt.Printf("========== 迁移结果 ==========\n")
fmt.Printf("成功: %d, 跳过: %d, 失败: %d\n", successCount, skipCount, failCount)
if *dryRun {
fmt.Println("\n这只是预览使用不带 -dry-run 参数执行实际迁移")
}
}

View File

@ -1,85 +0,0 @@
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)
}
}
}

View File

@ -1,186 +0,0 @@
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

@ -1,102 +0,0 @@
package main
import (
"context"
"fmt"
"time"
"bindbox-game/configs"
"bindbox-game/internal/pkg/notify"
gormmysql "gorm.io/driver/mysql"
"gorm.io/gorm"
gormlogger "gorm.io/gorm/logger"
)
func main() {
// 配置会在 init 时自动加载
c := configs.Get()
fmt.Printf("========== 微信通知配置检查 ==========\n")
fmt.Printf("静态配置 (configs):\n")
fmt.Printf(" AppID: %s\n", maskStr(c.Wechat.AppID))
fmt.Printf(" AppSecret: %s\n", maskStr(c.Wechat.AppSecret))
fmt.Printf(" LotteryResultTemplateID: %s\n", c.Wechat.LotteryResultTemplateID)
// 连接数据库检查 system_configs
dsn := "root:bindbox2025kdy@tcp(106.54.232.2:3306)/bindbox_game?charset=utf8mb4&parseTime=True&loc=Local"
db, err := gorm.Open(gormmysql.Open(dsn), &gorm.Config{Logger: gormlogger.Default.LogMode(gormlogger.Silent)})
if err != nil {
panic("failed to connect database: " + err.Error())
}
// 检查 system_configs 中的模板 ID
type SystemConfig struct {
ConfigKey string
ConfigValue string
}
var cfg SystemConfig
err = db.Table("system_configs").Where("config_key = ?", "wechat.lottery_result_template_id").First(&cfg).Error
if err == nil {
fmt.Printf("\n动态配置 (system_configs):\n")
fmt.Printf(" wechat.lottery_result_template_id: %s\n", cfg.ConfigValue)
} else {
fmt.Printf("\n动态配置 (system_configs): 未配置 wechat.lottery_result_template_id\n")
fmt.Println("将使用静态配置的模板 ID")
}
// 确定要使用的模板 ID
templateID := c.Wechat.LotteryResultTemplateID
if cfg.ConfigValue != "" {
templateID = cfg.ConfigValue
}
if templateID == "" {
fmt.Println("\n❌ LotteryResultTemplateID 未配置!")
return
}
fmt.Printf("\n使用的模板 ID: %s\n", templateID)
// 获取一个有 openid 的用户进行测试
type User struct {
ID int64
Openid string
}
var user User
if err := db.Table("users").Where("openid != ''").First(&user).Error; err != nil {
fmt.Printf("\n❌ 没有找到有 openid 的用户: %v\n", err)
return
}
fmt.Printf("测试用户: ID=%d, Openid=%s\n", user.ID, maskStr(user.Openid))
// 尝试发送通知
fmt.Println("\n========== 发送测试通知 ==========")
notifyCfg := &notify.WechatNotifyConfig{
AppID: c.Wechat.AppID,
AppSecret: c.Wechat.AppSecret,
LotteryResultTemplateID: templateID,
}
err = notify.SendLotteryResultNotification(
context.Background(),
notifyCfg,
user.Openid,
"测试活动名称",
[]string{"测试奖品A", "测试奖品B"},
"TEST_ORDER_001",
time.Now(),
)
if err != nil {
fmt.Printf("\n❌ 发送失败: %v\n", err)
} else {
fmt.Println("\n✅ 发送成功!请检查微信是否收到通知。")
}
}
func maskStr(s string) string {
if len(s) <= 8 {
return s
}
return s[:4] + "****" + s[len(s)-4:]
}

View File

@ -1,263 +0,0 @@
package main
import (
"context"
"fmt"
"time"
"bindbox-game/configs"
"bindbox-game/internal/pkg/logger"
"bindbox-game/internal/repository/mysql"
"bindbox-game/internal/repository/mysql/dao"
"bindbox-game/internal/repository/mysql/model"
tcmodel "bindbox-game/internal/repository/mysql/task_center"
tasksvc "bindbox-game/internal/service/task_center"
"bindbox-game/internal/service/title"
"bindbox-game/internal/service/user"
"github.com/redis/go-redis/v9"
)
// IntegrationTest 运行集成测试流
func IntegrationTest(repo mysql.Repo) error {
ctx := context.Background()
cfg := configs.Get()
// 1. 初始化日志(自定义)
l, err := logger.NewCustomLogger(dao.Use(repo.GetDbW()))
if err != nil {
return fmt.Errorf("初始化日志失败: %v", err)
}
// 2. 初始化 Redis
rdb := redis.NewClient(&redis.Options{
Addr: cfg.Redis.Addr,
Password: cfg.Redis.Pass,
DB: cfg.Redis.DB,
})
if err := rdb.Ping(ctx).Err(); err != nil {
return fmt.Errorf("连接 Redis 失败: %v", err)
}
// 3. 初始化依赖服务
userSvc := user.New(l, repo)
titleSvc := title.New(l, repo)
taskSvc := tasksvc.New(l, repo, rdb, userSvc, titleSvc)
// 3.5 清理缓存以确保能加载最新配置
if err := rdb.Del(ctx, "task_center:active_tasks").Err(); err != nil {
fmt.Printf("⚠️ 清理缓存失败: %v\n", err)
}
// 4. 选择一个测试用户和任务
// ... (代码逻辑不变)
userID := int64(8888)
// 搜索一个首单任务(满足 lifetime 窗口,奖励为点数)
var task tcmodel.Task
db := repo.GetDbW()
if err := db.Joins("JOIN task_center_task_tiers ON task_center_task_tiers.task_id = task_center_tasks.id").
Joins("JOIN task_center_task_rewards ON task_center_task_rewards.task_id = task_center_tasks.id").
Where("task_center_task_tiers.metric = ? AND task_center_task_tiers.window = ? AND task_center_task_rewards.reward_type = ?", "first_order", "lifetime", "points").
First(&task).Error; err != nil {
return fmt.Errorf("未找到符合条件的集成测试任务: %v", err)
}
fmt.Printf("--- 开始集成测试 ---\n")
fmt.Printf("用户ID: %d, 任务ID: %d (%s)\n", userID, task.ID, task.Name)
// 5. 创建一个模拟订单
orderNo := fmt.Sprintf("TEST_ORDER_%d", time.Now().Unix())
order := &model.Orders{
UserID: userID,
OrderNo: orderNo,
TotalAmount: 100,
ActualAmount: 100,
Status: 2, // 已支付
PaidAt: time.Now(),
}
if err := db.Omit("cancelled_at").Create(order).Error; err != nil {
return fmt.Errorf("创建测试订单失败: %v", err)
}
fmt.Printf("创建测试订单: %s (ID: %d)\n", orderNo, order.ID)
// 6. 触发 OnOrderPaid
fmt.Println("触发 OnOrderPaid 事件...")
if err := taskSvc.OnOrderPaid(ctx, userID, order.ID); err != nil {
return fmt.Errorf("OnOrderPaid 失败: %v", err)
}
// 7. 验证结果
// A. 检查进度是否更新
var progress tcmodel.UserTaskProgress
if err := db.Where("user_id = ? AND task_id = ?", userID, task.ID).First(&progress).Error; err != nil {
fmt.Printf("❌ 进度记录未找到: %v\n", err)
} else {
fmt.Printf("✅ 进度记录已更新: first_order=%d\n", progress.FirstOrder)
}
// B. 检查奖励日志
time.Sleep(1 * time.Second)
var eventLog tcmodel.TaskEventLog
if err := db.Where("user_id = ? AND task_id = ?", userID, task.ID).Order("id desc").First(&eventLog).Error; err != nil {
fmt.Printf("❌ 奖励日志未找到: %v\n", err)
} else {
fmt.Printf("✅ 奖励日志已找到: Status=%s, Result=%s\n", eventLog.Status, eventLog.Result)
if eventLog.Status == "granted" {
fmt.Printf("🎉 集成测试通过!奖励已成功发放。\n")
} else {
fmt.Printf("⚠️ 奖励发放状态异常: %s\n", eventLog.Status)
}
}
return nil
}
// InviteAndTaskIntegrationTest 运行邀请与任务全链路集成测试
func InviteAndTaskIntegrationTest(repo mysql.Repo) error {
ctx := context.Background()
cfg := configs.Get()
db := repo.GetDbW()
// 1. 初始化
l, _ := logger.NewCustomLogger(dao.Use(db))
rdb := redis.NewClient(&redis.Options{Addr: cfg.Redis.Addr, Password: cfg.Redis.Pass, DB: cfg.Redis.DB})
userSvc := user.New(l, repo)
titleSvc := title.New(l, repo)
taskSvc := tasksvc.New(l, repo, rdb, userSvc, titleSvc)
// 2. 准备角色
inviterID := int64(9001)
inviteeID := int64(9002)
_ = ensureUserExists(repo, inviterID, "老司机(邀请者)")
_ = ensureUserExists(repo, inviteeID, "萌新(被邀请者)")
// 3. 建立邀请关系
if err := ensureInviteRelationship(repo, inviterID, inviteeID); err != nil {
return fmt.Errorf("建立邀请关系失败: %v", err)
}
// 4. 清理 Redis 缓存
_ = rdb.Del(ctx, "task_center:active_tasks").Err()
// 5. 查找测试任务
var inviteTask tcmodel.Task
if err := db.Joins("JOIN task_center_task_tiers ON task_center_task_tiers.task_id = task_center_tasks.id").
Where("task_center_task_tiers.metric = ? AND task_center_task_tiers.window = ?", "invite_count", "lifetime").
First(&inviteTask).Error; err != nil {
return fmt.Errorf("未找到邀请任务: %v", err)
}
var firstOrderTask tcmodel.Task
if err := db.Joins("JOIN task_center_task_tiers ON task_center_task_tiers.task_id = task_center_tasks.id").
Where("task_center_task_tiers.metric = ? AND task_center_task_tiers.window = ?", "first_order", "lifetime").
First(&firstOrderTask).Error; err != nil {
return fmt.Errorf("未找到首单任务: %v", err)
}
fmt.Printf("--- 开始邀请全链路测试 ---\n")
fmt.Printf("邀请人: %d, 被邀请人: %d\n", inviterID, inviteeID)
// 6. 模拟邀请成功事件 (触发两次以确保达到默认阈值 2)
fmt.Println("触发 OnInviteSuccess 事件 (第1次)...")
if err := taskSvc.OnInviteSuccess(ctx, inviterID, inviteeID); err != nil {
return fmt.Errorf("OnInviteSuccess 失败: %v", err)
}
fmt.Println("触发 OnInviteSuccess 事件 (第2次, 换个用户ID)...")
if err := taskSvc.OnInviteSuccess(ctx, inviterID, 9999); err != nil {
return fmt.Errorf("OnInviteSuccess 失败: %v", err)
}
// 7. 模拟被邀请者下单
orderNo := fmt.Sprintf("INVITE_ORDER_%d", time.Now().Unix())
order := &model.Orders{
UserID: inviteeID,
OrderNo: orderNo,
TotalAmount: 100,
ActualAmount: 100,
Status: 2, // 已支付
PaidAt: time.Now(),
}
if err := db.Omit("cancelled_at").Create(order).Error; err != nil {
return fmt.Errorf("创建被邀请者订单失败: %v", err)
}
fmt.Printf("被邀请者下单成功: %s (ID: %d)\n", orderNo, order.ID)
fmt.Println("触发 OnOrderPaid 事件 (被邀请者)...")
if err := taskSvc.OnOrderPaid(ctx, inviteeID, order.ID); err != nil {
return fmt.Errorf("OnOrderPaid 失败: %v", err)
}
// 8. 验证
time.Sleep(1 * time.Second)
fmt.Println("\n--- 数据库进度核查 ---")
var allProgress []tcmodel.UserTaskProgress
db.Where("user_id IN (?)", []int64{inviterID, inviteeID}).Find(&allProgress)
if len(allProgress) == 0 {
fmt.Println("⚠️ 数据库中未找到任何进度记录!")
}
for _, p := range allProgress {
userLabel := "邀请人"
if p.UserID == inviteeID {
userLabel = "被邀请人"
}
fmt.Printf("[%s] 用户:%d 任务:%d | Invite=%d, OrderCount=%d, FirstOrder=%d\n",
userLabel, p.UserID, p.TaskID, p.InviteCount, p.OrderCount, p.FirstOrder)
}
fmt.Println("\n--- 奖励发放核查 ---")
var logs []tcmodel.TaskEventLog
db.Where("user_id IN (?) AND status = ?", []int64{inviterID, inviteeID}, "granted").Find(&logs)
fmt.Printf("✅ 累计发放奖励次数: %d\n", len(logs))
for _, l := range logs {
fmt.Printf(" - 用户 %d 触发任务 %d 奖励 | Source:%s\n", l.UserID, l.TaskID, l.SourceType)
}
if len(logs) >= 2 {
fmt.Println("\n🎉 邀请全链路集成测试通过!邀请人和被邀请人都获得了奖励。")
} else {
fmt.Printf("\n⚠ 测试部分完成,奖励次数(%d)少于预期(2)\n", len(logs))
}
return nil
}
// 模拟创建用户的方法(如果不存在)
func ensureUserExists(repo mysql.Repo, userID int64, nickname string) error {
db := repo.GetDbW()
var user model.Users
if err := db.Where("id = ?", userID).First(&user).Error; err != nil {
user = model.Users{
ID: userID,
Nickname: nickname,
Avatar: "http://example.com/a.png",
Status: 1,
InviteCode: fmt.Sprintf("CODE%d", userID),
}
if err := db.Create(&user).Error; err != nil {
return err
}
fmt.Printf("已确保测试用户存在: %d (%s)\n", userID, nickname)
}
return nil
}
// 建立邀请关系
func ensureInviteRelationship(repo mysql.Repo, inviterID, inviteeID int64) error {
db := repo.GetDbW()
var rel model.UserInvites
if err := db.Where("invitee_id = ?", inviteeID).First(&rel).Error; err != nil {
rel = model.UserInvites{
InviterID: inviterID,
InviteeID: inviteeID,
InviteCode: fmt.Sprintf("CODE%d", inviterID),
}
return db.Omit("rewarded_at").Create(&rel).Error
}
// 如果已存在但邀请人不对,修正它
if rel.InviterID != inviterID {
return db.Model(&rel).Update("inviter_id", inviterID).Error
}
return nil
}

View File

@ -1,477 +0,0 @@
// 任务中心配置组合测试工具
// 功能:
// 1. 生成所有有效的任务配置组合到 MySQL 数据库
// 2. 模拟用户任务进度
// 3. 验证任务功能是否正常
package main
import (
"encoding/json"
"flag"
"fmt"
"log"
"os"
"time"
"bindbox-game/configs"
"bindbox-game/internal/repository/mysql"
tcmodel "bindbox-game/internal/repository/mysql/task_center"
"gorm.io/datatypes"
)
// ================================
// 常量定义
// ================================
const (
// 任务指标
MetricFirstOrder = "first_order"
MetricOrderCount = "order_count"
MetricOrderAmount = "order_amount"
MetricInviteCount = "invite_count"
// 操作符
OperatorGTE = ">="
OperatorEQ = "="
// 时间窗口
WindowDaily = "daily"
WindowWeekly = "weekly"
WindowMonthly = "monthly"
WindowLifetime = "lifetime"
// 奖励类型
RewardTypePoints = "points"
RewardTypeCoupon = "coupon"
RewardTypeItemCard = "item_card"
RewardTypeTitle = "title"
RewardTypeGameTicket = "game_ticket"
)
// TaskCombination 表示一种任务配置组合
type TaskCombination struct {
Name string
Metric string
Operator string
Threshold int64
Window string
RewardType string
}
// TestResult 测试结果
type TestResult struct {
Name string
Passed bool
Message string
}
// ================================
// 配置组合生成器
// ================================
// GenerateAllCombinations 生成所有有效的任务配置组合
func GenerateAllCombinations() []TaskCombination {
metrics := []struct {
name string
operators []string
threshold int64
}{
{MetricFirstOrder, []string{OperatorEQ}, 1},
{MetricOrderCount, []string{OperatorGTE, OperatorEQ}, 3},
{MetricOrderAmount, []string{OperatorGTE, OperatorEQ}, 10000},
{MetricInviteCount, []string{OperatorGTE, OperatorEQ}, 2},
}
windows := []string{WindowDaily, WindowWeekly, WindowMonthly, WindowLifetime}
rewards := []string{RewardTypePoints, RewardTypeCoupon, RewardTypeItemCard, RewardTypeTitle, RewardTypeGameTicket}
var combinations []TaskCombination
idx := 0
for _, m := range metrics {
for _, op := range m.operators {
for _, w := range windows {
for _, r := range rewards {
idx++
combinations = append(combinations, TaskCombination{
Name: fmt.Sprintf("测试任务%03d_%s_%s_%s", idx, m.name, w, r),
Metric: m.name,
Operator: op,
Threshold: m.threshold,
Window: w,
RewardType: r,
})
}
}
}
}
return combinations
}
// generateRewardPayload 根据奖励类型生成对应的 JSON payload
func generateRewardPayload(rewardType string) string {
switch rewardType {
case RewardTypePoints:
return `{"points": 100}`
case RewardTypeCoupon:
return `{"coupon_id": 1, "quantity": 1}`
case RewardTypeItemCard:
return `{"card_id": 1, "quantity": 1}`
case RewardTypeTitle:
return `{"title_id": 1}`
case RewardTypeGameTicket:
return `{"game_code": "minesweeper", "amount": 5}`
default:
return `{}`
}
}
// ================================
// 数据库操作
// ================================
// SeedAllCombinations 将所有配置组合写入数据库
func SeedAllCombinations(repo mysql.Repo, dryRun bool) error {
db := repo.GetDbW()
combos := GenerateAllCombinations()
fmt.Printf("准备生成 %d 个任务配置组合\n", len(combos))
if dryRun {
fmt.Println("【试运行模式】不会实际写入数据库")
for i, c := range combos {
fmt.Printf(" %3d. %s (指标=%s, 操作符=%s, 窗口=%s, 奖励=%s)\n",
i+1, c.Name, c.Metric, c.Operator, c.Window, c.RewardType)
}
return nil
}
// 开始事务
tx := db.Begin()
defer func() {
if r := recover(); r != nil {
tx.Rollback()
}
}()
// 清理旧的测试数据
if err := tx.Where("name LIKE ?", "测试任务%").Delete(&tcmodel.Task{}).Error; err != nil {
tx.Rollback()
return fmt.Errorf("清理旧任务失败: %v", err)
}
fmt.Println("已清理旧的测试任务数据")
created := 0
for _, combo := range combos {
// 检查是否已存在
var exists tcmodel.Task
if err := tx.Where("name = ?", combo.Name).First(&exists).Error; err == nil {
fmt.Printf(" 跳过: %s (已存在)\n", combo.Name)
continue
}
// 插入任务
task := &tcmodel.Task{
Name: combo.Name,
Description: fmt.Sprintf("测试 %s + %s + %s + %s", combo.Metric, combo.Operator, combo.Window, combo.RewardType),
Status: 1,
Visibility: 1,
}
if err := tx.Create(task).Error; err != nil {
tx.Rollback()
return fmt.Errorf("插入任务失败: %v", err)
}
// 插入档位
tier := &tcmodel.TaskTier{
TaskID: task.ID,
Metric: combo.Metric,
Operator: combo.Operator,
Threshold: combo.Threshold,
Window: combo.Window,
Priority: 0,
}
if err := tx.Create(tier).Error; err != nil {
tx.Rollback()
return fmt.Errorf("插入档位失败: %v", err)
}
// 插入奖励
payload := generateRewardPayload(combo.RewardType)
reward := &tcmodel.TaskReward{
TaskID: task.ID,
TierID: tier.ID,
RewardType: combo.RewardType,
RewardPayload: datatypes.JSON(payload),
Quantity: 10,
}
if err := tx.Create(reward).Error; err != nil {
tx.Rollback()
return fmt.Errorf("插入奖励失败: %v", err)
}
created++
if created%10 == 0 {
fmt.Printf(" 已创建 %d 个任务...\n", created)
}
}
if err := tx.Commit().Error; err != nil {
return fmt.Errorf("提交事务失败: %v", err)
}
fmt.Printf("✅ 成功创建 %d 个任务配置组合\n", created)
return nil
}
// ================================
// 模拟用户任务
// ================================
// SimulateUserTask 模拟用户完成任务
func SimulateUserTask(repo mysql.Repo, userID int64, taskID int64) error {
db := repo.GetDbW()
// 查询任务和档位
var task tcmodel.Task
if err := db.Where("id = ?", taskID).First(&task).Error; err != nil {
return fmt.Errorf("任务不存在: %v", err)
}
var tier tcmodel.TaskTier
if err := db.Where("task_id = ?", taskID).First(&tier).Error; err != nil {
return fmt.Errorf("档位不存在: %v", err)
}
fmt.Printf("模拟任务: %s (指标=%s, 阈值=%d)\n", task.Name, tier.Metric, tier.Threshold)
// 创建或更新用户进度
progress := &tcmodel.UserTaskProgress{
UserID: userID,
TaskID: taskID,
ClaimedTiers: datatypes.JSON("[]"),
}
// 根据指标类型设置进度
switch tier.Metric {
case MetricFirstOrder:
progress.FirstOrder = 1
progress.OrderCount = 1
progress.OrderAmount = 10000
case MetricOrderCount:
progress.OrderCount = tier.Threshold
case MetricOrderAmount:
progress.OrderAmount = tier.Threshold
progress.OrderCount = 1
case MetricInviteCount:
progress.InviteCount = tier.Threshold
}
// Upsert
if err := db.Where("user_id = ? AND task_id = ?", userID, taskID).
Assign(progress).
FirstOrCreate(progress).Error; err != nil {
return fmt.Errorf("创建进度失败: %v", err)
}
fmt.Printf("✅ 用户 %d 的任务进度已更新: order_count=%d, order_amount=%d, invite_count=%d, first_order=%d\n",
userID, progress.OrderCount, progress.OrderAmount, progress.InviteCount, progress.FirstOrder)
return nil
}
// ================================
// 验证功能
// ================================
// VerifyAllConfigs 验证所有配置是否正确
func VerifyAllConfigs(repo mysql.Repo) []TestResult {
db := repo.GetDbR()
var results []TestResult
// 1. 检查任务数量
var taskCount int64
var sampleTasks []tcmodel.Task
db.Model(&tcmodel.Task{}).Where("name LIKE ?", "测试任务%").Count(&taskCount)
db.Model(&tcmodel.Task{}).Where("name LIKE ?", "测试任务%").Limit(5).Find(&sampleTasks)
var sampleMsg string
for _, t := range sampleTasks {
sampleMsg += fmt.Sprintf("[%d:%s] ", t.ID, t.Name)
}
results = append(results, TestResult{
Name: "任务数量检查",
Passed: taskCount > 0,
Message: fmt.Sprintf("找到 %d 个测试任务. 样本: %s", taskCount, sampleMsg),
})
// 2. 检查每种指标的覆盖
metrics := []string{MetricFirstOrder, MetricOrderCount, MetricOrderAmount, MetricInviteCount}
for _, m := range metrics {
var count int64
db.Model(&tcmodel.TaskTier{}).Where("metric = ?", m).Count(&count)
results = append(results, TestResult{
Name: fmt.Sprintf("指标覆盖: %s", m),
Passed: count > 0,
Message: fmt.Sprintf("找到 %d 个档位使用此指标", count),
})
}
// 3. 检查每种时间窗口的覆盖
windows := []string{WindowDaily, WindowWeekly, WindowMonthly, WindowLifetime}
for _, w := range windows {
var count int64
db.Model(&tcmodel.TaskTier{}).Where("window = ?", w).Count(&count)
results = append(results, TestResult{
Name: fmt.Sprintf("时间窗口覆盖: %s", w),
Passed: count > 0,
Message: fmt.Sprintf("找到 %d 个档位使用此时间窗口", count),
})
}
// 4. 检查每种奖励类型的覆盖
rewards := []string{RewardTypePoints, RewardTypeCoupon, RewardTypeItemCard, RewardTypeTitle, RewardTypeGameTicket}
for _, r := range rewards {
var count int64
db.Model(&tcmodel.TaskReward{}).Where("reward_type = ?", r).Count(&count)
results = append(results, TestResult{
Name: fmt.Sprintf("奖励类型覆盖: %s", r),
Passed: count > 0,
Message: fmt.Sprintf("找到 %d 个奖励使用此类型", count),
})
}
// 5. 检查奖励 payload 格式
var rewardList []tcmodel.TaskReward
db.Limit(20).Find(&rewardList)
for _, r := range rewardList {
var data map[string]interface{}
err := json.Unmarshal([]byte(r.RewardPayload), &data)
passed := err == nil
msg := "JSON 格式正确"
if err != nil {
msg = fmt.Sprintf("JSON 解析失败: %v", err)
}
results = append(results, TestResult{
Name: fmt.Sprintf("奖励Payload格式: ID=%d, Type=%s", r.ID, r.RewardType),
Passed: passed,
Message: msg,
})
}
return results
}
// PrintResults 打印测试结果
func PrintResults(results []TestResult) {
passed := 0
failed := 0
fmt.Println("\n========== 测试结果 ==========")
for _, r := range results {
status := "✅ PASS"
if !r.Passed {
status = "❌ FAIL"
failed++
} else {
passed++
}
fmt.Printf("%s | %s | %s\n", status, r.Name, r.Message)
}
fmt.Println("==============================")
fmt.Printf("总计: %d 通过, %d 失败\n", passed, failed)
}
// ================================
// 主程序
// ================================
func main() {
// 命令行参数
action := flag.String("action", "help", "操作类型: seed/simulate/verify/integration/invite-test/help")
dryRun := flag.Bool("dry-run", false, "试运行模式,不实际写入数据库")
userID := flag.Int64("user", 8888, "用户ID (用于 simulate 或 integration)")
taskID := flag.Int64("task", 0, "任务ID")
flag.Parse()
// 显示帮助
if *action == "help" {
fmt.Println(`
任务中心配置组合测试工具
用法:
go run main.go -action=<操作>
操作类型:
seed - 生成所有配置组合到数据库
simulate - 简单模拟用户进度 (仅修改进度表)
integration - 真实集成测试 (触发 OnOrderPaid, 验证全流程)
invite-test - 邀请全链路测试 (模拟邀请下单双端奖励发放)
verify - 验证配置是否正确
参数:
-dry-run - 试运行模式不实际写入数据库
-user - 用户ID (默认: 8888)
-task - 任务ID
示例:
# 邀请全链路测试
go run main.go -action=invite-test
`)
return
}
// 初始化数据库连接
repo, err := mysql.New()
if err != nil {
log.Fatalf("连接数据库失败: %v", err)
}
cfg := configs.Get()
fmt.Printf("已连接到数据库: %s\n", cfg.MySQL.Write.Name)
fmt.Printf("时间: %s\n", time.Now().Format("2006-01-02 15:04:05"))
// 执行操作
switch *action {
case "seed":
if err := SeedAllCombinations(repo, *dryRun); err != nil {
log.Printf("生成配置失败: %v", err)
os.Exit(1)
}
case "simulate":
if *taskID == 0 {
fmt.Println("请指定任务ID: -task=<ID>")
os.Exit(1)
}
if err := SimulateUserTask(repo, *userID, *taskID); err != nil {
log.Printf("模拟失败: %v", err)
os.Exit(1)
}
case "integration":
// 确保用户存在
if err := ensureUserExists(repo, *userID, "测试用户"); err != nil {
log.Printf("预检用户失败: %v", err)
os.Exit(1)
}
if err := IntegrationTest(repo); err != nil {
log.Printf("集成测试失败: %v", err)
os.Exit(1)
}
case "invite-test":
if err := InviteAndTaskIntegrationTest(repo); err != nil {
log.Printf("邀请测试失败: %v", err)
os.Exit(1)
}
case "verify":
results := VerifyAllConfigs(repo)
PrintResults(results)
default:
fmt.Printf("未知操作: %s\n", *action)
os.Exit(1)
}
}

View File

@ -1,42 +0,0 @@
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

@ -1,70 +0,0 @@
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

@ -1,100 +0,0 @@
# BUG修复需求分析
## 任务概述
修复盲盒游戏系统中6个BUG问题。
## BUG清单
### BUG 1: 任务中心任务类型统计错误
**问题描述**: 设置任务是完成A活动才可以算完成但玩了一局B活动竟然也算任务成功了。
**根因分析**:
- 任务中心 `GetUserProgress` 函数 (`internal/service/task_center/service.go:290-386`)
- 该函数通过订单 `remark` 字段使用 LIKE 匹配来过滤活动ID
- 匹配模式: `%%activity:%d%%`
- **问题**: 虽然有活动ID过滤逻辑但需要确认任务配置时是否正确设置了 `activity_id`
- 相关代码位置: `service.go` 第306-312行
---
### BUG 2: 任务中心把商城订单也计入了
**问题描述**: 任务中心统计时不应该包含商城订单,应该根据设置的类型来结算。
**根因分析**:
- `GetUserProgress` 函数统计订单时只过滤了 `status = 2`(已支付)
- **问题**: 没有过滤 `source_type`,导致商城订单(`source_type = 1`)也被计入
- 订单 `source_type` 定义 (`model/orders.gen.go:20`):
- 1: 商城直购
- 2: 抽奖票据
- 3: 其他
- 4: 次数卡支付
---
### BUG 3: 活动盈亏仪表盘退款订单未排除
**问题描述**: 对用户订单进行退款了,统计不应该把这个订单累计进来。
**根因分析**:
- `DashboardActivityProfitLoss` 函数 (`internal/api/admin/dashboard_activity.go:132-139`)
- 营收统计查询条件: `orders.status = 2`(已支付)
- **问题**: 订单状态4表示已退款但当前只过滤了 `status = 2`,不会包含退款订单
- **实际问题**: 退款后订单状态应该从2变成4但如果状态未更新则会被统计。需要确认退款流程是否正确更新订单状态
---
### BUG 4: 活动盈亏抽奖记录缺少字段
**问题描述**: 需要在抽奖记录中体现 优惠券 / 道具卡 / 次数卡 字段。
**根因分析**:
- `DashboardActivityLogs` 函数 (`internal/api/admin/dashboard_activity.go:222-354`)
- 当前返回字段已包含:
- `coupon_name`: 通过 `orders.coupon_id` LEFT JOIN `system_coupons`
- `item_card_name`: 通过 `orders.item_card_id` LEFT JOIN `system_item_cards`
- **问题**: `source_type = 4` 表示次数卡支付,但次数卡使用信息存储在订单 `remark` 字段中(格式: `gp_use:ID:Count`),当前未解析显示
- 需要增加解析 `remark` 字段中的次数卡使用信息
---
### BUG 5: 一番赏不能使用优惠券
**问题描述**: 一番赏目前不能使用优惠券。
**根因分析**:
- `JoinLottery` 函数 (`internal/api/activity/lottery_app.go:78-81`)
- 优惠券检查逻辑: `if !activity.AllowCoupons && req.CouponID != nil`
- **问题**: 一番赏活动的 `AllowCoupons` 字段可能被设置为 `false`
- 数据库字段定义 (`model/activities.gen.go:27`): `AllowCoupons bool` 默认值为1允许
- **解决方向**:
1. 检查一番赏活动在数据库中的 `allow_coupons` 字段值
2. 如果业务上确实不允许,则是配置问题而非代码问题
3. 如果业务上应该允许,需修改活动配置
---
### BUG 6: 活动盈亏出现已下架活动数据
**问题描述**: 活动盈亏里面出现了以前已经下架了的数据,应该按照现在活动表存在的活动来统计。
**根因分析**:
- `DashboardActivityProfitLoss` 函数 (`internal/api/admin/dashboard_activity.go:58-75`)
- 当前查询直接从 `activities` 表获取活动列表
- 支持按 `status` 过滤1进行中 2下线
- **问题**: 虽然支持状态过滤,但默认不过滤任何状态
- 另外活动表使用了软删除 (`deleted_at`),但需确认是否正确应用了软删除条件
## 需求理解
| BUG编号 | 问题类型 | 修复难度 | 涉及文件 |
|---------|----------|----------|----------|
| BUG 1 | 业务逻辑 | 中 | `service/task_center/service.go` |
| BUG 2 | 业务逻辑 | 低 | `service/task_center/service.go` |
| BUG 3 | 数据过滤 | 低 | `api/admin/dashboard_activity.go` |
| BUG 4 | 字段缺失 | 低 | `api/admin/dashboard_activity.go` |
| BUG 5 | 配置问题 | 低 | 需检查数据库配置 |
| BUG 6 | 数据过滤 | 低 | `api/admin/dashboard_activity.go` |
## 待确认问题
1. **BUG 1**: 任务配置时,`task_center_task_tiers.activity_id` 字段是否正确设置?
2. **BUG 3**: 退款时订单状态是否正确更新为4
3. **BUG 5**: 一番赏活动的 `allow_coupons` 数据库字段当前值是什么?是配置问题还是需要代码修复?
4. **BUG 6**: 是否需要默认只显示在线活动status=1还是只过滤软删除的活动

View File

@ -1,47 +0,0 @@
# 任务后台Go项目接入 Loki 成本评估与实施方案 (ALIGNMENT) - 性能分析版
## 1. 核心问题:性能消耗分析
用户疑问:**"Loki + Promtail + Grafana 这个组合消耗性能么"**
### 1.1 结论先行
在**极简模式**(只采服务端日志)下,**消耗非常低**。
对于现代开发机(或 2核4G 以上服务器),**几乎无感**。
### 1.2 详细资源开销预估 (以每天产生 100MB 日志为例)
| 组件 | 作用 | 内存 (RAM) | CPU (平时) | CPU (查询时) | 磁盘 IO | 评价 |
| :--- | :--- | :--- | :--- | :--- | :--- | :--- |
| **Promtail** | 搬运工 | ~50MB | < 1% | - | 极低 (仅读取) | **极轻**就像运行了一个 `tail -f` 命令 |
| **Loki** | 存储 | ~150MB - 300MB | < 2% | 10% - 30% | (顺序写入) | **轻量**不像 ES 那样建全文索引只压缩存储不做繁重计算 |
| **Grafana** | 界面 | ~100MB - 200MB | 0% (Idle) | 5% - 10% | 忽略不计 | **静默**,没人访问网页时几乎不干活 |
| **总计** | **全套** | **约 400MB - 600MB** | **日常忽略不计** | **查询时微升** | **可控** | **安全** |
### 1.3 为什么这么省?
1. **不建全文索引**: Loki 的设计哲学是 "Log everything, index only labels"。它不像 Elasticsearch (ELK) 那样对每个词都建索引那样极耗内存和CPU。Loki 只索引 "时间" 和 "标签" (如 `app=bindbox-game`)。
2. **流式压缩**: 日志被压缩成块存储,占用很少写 IO。
3. **按需计算**: 只有当你发起查询(比如搜 "error"CPU 才会工作去解压和匹配数据。平时它只是在静静地写文件。
### 1.4 极端情况
- **甚至可以更省**: 我们可以限制 docker 容器的内存上限。
- **对比 ELK**: ELK 动辄需要 2G-4G 起步内存Loki 是专为云原生和低资源环境设计的轻量级替代品。
---
## 2. 极简实施方案 (Server Logs Only)
### 2.1 架构调整
- **Log Source**: 仅采集 `bindbox-game` 容器。
- **采集过滤**: 在 Promtail 配置中设置 Filter**丢弃** 所有非 `bindbox-game` 的日志。
### 2.2 资源进一步优化
- 由于只采集核心服务Loki 和 Promtail 的 CPU/内存消耗将降至最低。
- 磁盘占用将非常小。
---
## 3. 实施步骤
1. **配置 Promtail**: 编写 `promtail-config.yaml`,只监听 `container_name="bindbox-game"`
2. **配置 Docker Compose**: 添加 Loki, Promtail, Grafana 服务。
3. **配置 Grafana**: 预配置好 Loki 数据源,开箱即用。
## 4. 结论
可以放心接入。这套组合主要消耗的是**几百兆内存**,对 CPU 和磁盘的影响极小,完全适合本地开发和中小型服务器部署。

View File

@ -1,149 +0,0 @@
# 抽奖与公平性算法技术白皮书
## 1. 概述
本系统采用 **「承诺机制 (Commitment Scheme)」** 结合 **HMAC-SHA256** 算法,确保抽奖过程的**不可预测性**、**可验证性**和**不可篡改性**。
核心原则:
1. **事前承诺**活动开始前生成随机种子并公布其哈希值Commitment
2. **事后验证**活动结束后公布种子明文Reveal用户可复算验证。
3. **确定性算法**:输入(种子 + 上下文)确定,输出必然唯一。
---
## 2. 核心机制:承诺方案
### 2.1 种子生成
每个活动 (`Activity`) 在创建或发布时,系统服务器端会生成一个高质量的 32 字节随机种子 (`ServerSeed`)。
```go
// 伪代码示例
seed := make([]byte, 32)
rand.Read(seed) // 使用 crypto/rand 生成强随机数
```
### 2.2 承诺哈希 (Commitment Hash)
在数据库确立活动数据的一瞬间,系统计算种子的 SHA256 哈希值,作为**承诺**存储并对用户可见(虽然前端可能选择性展示)。
$$ SeedHash = \text{SHA256}(ServerSeed) $$
此哈希值一经生成不可更改,确保了服务器无法在后续过程中偷偷替换种子来操纵结果。
### 2.3 验证凭据 (Receipt)
每次抽奖完成后,系统会生成一份数字凭据 (`ActivityDrawReceipts`),其中包含:
- `issue_id`: 期号
- `seed_hash`: 对应的种子哈希
- `nonce` / `salt`: 随机盐值或防重随机数
- `snapshot`: 当时的奖池状态快照(权重/格位)
---
## 3. 算法实现
### 3.1 无限赏 (Weighted Random)
适用于奖品无限库存或按权重概率抽取的模式。
**算法流程**
1. **输入**
- $Seed$: 全局活动种子
- $IssueID$: 期号
- $UserID$: 用户ID
- $Salt$: 每次请求生成的 16 字节随机盐值
- $Rewards$: 奖品列表,包含权重 $w_i$
2. **随机数生成**
使用 HMAC-SHA256 派生出一个确定性的随机数 $R$。
$$ \text{payload} = \text{fmt.Sprintf("draw:issue:\%d|user:\%d|salt:\%x", IssueID, UserID, Salt)} $$
$$ H = \text{HMAC-SHA256}(Seed, \text{payload}) $$
$$ R = \text{BigEndianUint64}(H[0:8]) \pmod {\sum w_i} $$
3. **结果选择**
遍历奖品列表,累加权重查找 $R$ 落在哪个区间。
**代码逻辑**
```go
mac := hmac.New(sha256.New, seedKey)
mac.Write([]byte(fmt.Sprintf("draw:issue:%d|user:%d|salt:%x", issueID, userID, salt)))
sum := mac.Sum(nil)
rnd := int64(binary.BigEndian.Uint64(sum[:8]) % uint64(totalWeight))
```
### 3.2 一番赏 (Ichiban / Shuffle)
适用于“箱内抽赏”模式,奖品总量固定,位置固定,采用先洗牌后抽取的逻辑。
**算法流程**
1. **输入**
- $Seed$: 全局活动种子
- $IssueID$: 期号(每一个箱子是一个 Issue
- $TotalSlots$: 总格位数(例如 80 发)
- $Rewards$: 初始有序的奖品列表(填充后的平铺列表)
2. **确定性洗牌 (Deterministic Shuffle)**
使用 Fisher-Yates 洗牌算法,配合 HMAC-SHA256 生成的随机序列对奖品位置进行打乱。
对于 $i$ 从 $TotalSlots-1$ 到 $1$
$$ \text{payload}_i = \text{fmt.Sprintf("shuffle:\%d|issue:\%d", i, IssueID)} $$
$$ H_i = \text{HMAC-SHA256}(Seed, \text{payload}_i) $$
$$ j = \text{BigEndianUint64}(H_i[0:8]) \pmod {(i+1)} $$
交换索引 $i$ 和 $j$ 的元素。
3. **结果获取**
用户选择的格位号 $k$ (1-based) 对应洗牌后数组的索引 $k-1$ 处的奖品。
$$ Reward = ShuffledRewards[SelectedSlot - 1] $$
**特性**
- **预定性**:只要种子确定,箱子那一刻的奖品排列就已注定,不论谁来抽、何时抽,第 N 格永远是那个奖品。
- **公平性**HMAC 的均匀分布保证了洗牌的随机性。
---
## 4. 验证指南
为了验证系统的公平性,用户或监管方可以使用官方提供的验证工具(`VerifyTool.exe`)进行独立计算。
### 4.1 获取验证参数
从 API 或页面获取以下信息(活动结束后公开):
1. `server_seed_hex`: 服务器种子(十六进制)
2. `issue_id`: 期号
3. `user_id` & `salt`: (仅无限赏需要)
4. `slot_index`: (仅一番赏需要)
5. `reward_config`: 奖品配置列表(验证前需要构建相同的初始列表)
### 4.2 运行验证工具
**无限赏验证命令示例**
```bash
./VerifyTool verify-unlimited \
--seed "WaitToReveal32BytesHex..." \
--issue 1001 \
--user 12345 \
--salt "RandomSaltHex..." \
--weights "10,50,200,500"
```
**一番赏验证命令示例**
```bash
./VerifyTool verify-ichiban \
--seed "WaitToReveal32BytesHex..." \
--issue 2002 \
--slot 5 \
--rewards "A:2,B:4,C:10,D:64"
```
*(注rewards 格式为 `奖项:数量`,如 A赏2个, B赏4个...)*
### 4.3 验证原理
验证工具内置了与服务器完全相同的算法逻辑Go 源码编译)。输入相同的种子和上下文,必将输出相同的中奖结果。
---
## 5. 安全性声明
1. **种子保密**`ServerSeed` 在存储层加密保存,仅在活动结束或特定审计时刻解密公开。
2. **结果不可逆**:无法通过哈希值反推种子。
3. **防预测**
- 无限赏:引入了 `Salt`(真随机生成),即使用户猜到了种子,也无法预测下一次抽奖结果(因为 Salt 每次不同)。
- 一番赏:种子一旦确定,序列即确定。我们在活动开始前才生成种子和 Commitment确保无人包括管理员能提前知晓排列。

View File

@ -1,83 +0,0 @@
# 任务对齐:统一积分与元比例 (Standardize Points and Yuan Ratio)
## 1. 项目上下文分析
- **项目结构**:
- 后端: `bindbox_game` (Go)
- 管理后台: `bindbox_game/web/admin` (Vue 3)
- 小程序: `bindbox-mini` (UniApp / Vue)
- **核心问题**:
- 当前代码逻辑隐含 "1积分 = 1分钱" (100积分=1元),前端通过 `/100` 强行展示为 "1.00",导致逻辑割裂。
- 用户期望标准: **1元 = 1积分**
- 后端配置缺失,且计算公式需要适配 "元" 为单位。
## 2. 需求确认
- **目标**:
1. **统一比例**: 全局统一为 **1 元 = 1 积分** (即 1 积分价值 100 分钱)。
2. **配置化**: 后台可配置 "积分/元 兑换比例" (Rate),当前固定为 1。
3. **整改范围**: 修正后端转换公式,修正前端展示逻辑,添加后台配置界面。
4. **业务场景**: 暂时不涉及"消费送积分" (即订单完成后自动按比例赠送),主要关注积分价值本身(如充值、抵扣、退款等场景的换算)。
## 3. 现状分析 (As-Is)
- **后端 (`bindbox_game`)**:
- `CentsToPoints`: 依赖 `points_exchange_per_cent` (分换积分比例),默认为 1。即 1 分钱 = 1 积分单位。
- `RefundPointsAmount`: 存在硬编码 `/ 100`,逻辑存疑。
- **小程序 (`bindbox-mini`)**:
- `formatPoints`: `value / 100`
- 如果后端给 100 积分单位,前端展示为 "1.0"。
- 含义模糊是“1积分”还是“1.0元价值”?
- **后台 (`web/admin`)**:
- 缺少积分比例配置界面。
## 4. 关键决策点 (Resolved)
1. **积分定义**:
- 确认采用 **1 积分 = 1 元** 的价值锚点。
- 数据库存储: 存 `1` 代表 1 积分 (即 1 元)。
- *变更*: 现在的 `100` (分) 对应 1 元,未来 `1` (积分) 对应 1 元。需要明确是否存在历史数据需要迁移(或者是新项目/可重置)。**假设目前无存量包袱或接受重置/迁移,或者我们调整代码适配现有数值**。
- *风险提示*: 如果仅仅改代码不改数据,原来的 100 积分 (1元) 瞬间变成 100 积分 (100元)。**必须确认是否需要数据清洗脚本**。
2. **兑换比例配置**:
- 配置项: `points_exchange_rate` (1 元对应多少积分)。
- 默认值: 1。
- 公式:
- 元转积分 (Amount -> Points): `points = amount_yuan * rate` => `points = (cents / 100) * rate`
- 积分转元 (Points -> Amount): `cents = (points / rate) * 100`
## 5. 实施方案 (Architecture)
### 5.1 数据库与配置
- **配置表 (`sys_configs`)**:
- Key: `points_exchange_rate`
- Value: `1` (Default)
- Description: "积分/元 兑换比例(多少积分=1元)"。
### 5.2 后端改造 (`bindbox_game`)
- **`internal/pkg/points/convert.go`**:
- `CentsToPoints(cents, rate)`:
- Old: `cents * rate` (Assumed rate per cent)
- New: `(cents * rate) / 100` (Rate per Yuan)
- `PointsToCents(points, rate)`:
- Old: `points / rate`
- New: `(points * 100) / rate`
- `RefundPointsAmount`: 适配新公式。
- **Service Layer**:
- 确保读取新的配置 Key。
- 检查所有手动计算积分的地方,全部收敛到 `convert` 包。
### 5.3 后台改造 (`web/admin`)
- **界面**: 在 `SystemConfigs` (系统配置) -> 新增 "积分配置" 分组。
- **功能**: 编辑 `points_exchange_rate`
### 5.4 小程序/前端改造 (`bindbox-mini`)
- **展示逻辑**:
- 移除 `formatPoints` 中的 `/ 100`
- 直接展示后端返回的整数积分。
- 检查 "积分抵扣" 等页面,确保传给后端的数值正确。
## 6. 执行计划 (Task Split)
1. **Design**: 确认方案无误 (当前步骤)。
2. **Backend**:
- 修改 Convert 算法。
- 确保 Config 读取逻辑正确。
- (Optional) 数据迁移脚本/重置脚本 (如果已有数据)。
3. **Frontend (Admin)**: 添加配置界面。
4. **Frontend (App)**: 修正展示逻辑。
5. **Verify**: 验证 1 元订单是否对应 1 积分(模拟),或者充值/手动增加 1 积分是否显示为 1。

View File

@ -1,67 +0,0 @@
# 架构设计:统一积分与元比例 (Design)
## 1. 总体架构
本次改造主要涉及 **计算逻辑层的标准化****配置数据的动态化**,不涉及大规模架构重构。
核心思路:**后端收敛计算逻辑,前端收敛展示逻辑**。
```mermaid
graph TD
A[Admin User] -->|配置 ExchangeRate| B(Admin Panel)
B -->|API: upsertSystemConfig| C(Backend API)
C -->|Update| D[(MySQL: sys_configs)]
E[App User] -->|Action: Pay/Refund| F(Backend Service)
F -->|Read Config| D
F -->|Call| G{pkg/points/convert}
G -->|Calculate based on Rate| F
F -->|Save Integer Points| D
H[Mini App] -->|Read Points| F
H -->|Display Raw Integer| I[UI Display]
```
## 2. 模块设计
### 2.1 后端 (`bindbox_game`)
- **`internal/pkg/points`**: 核心计算包。
- 职责:提供基于汇率的 `Cents <-> Points` 转换函数。
- 变更:
- `CentsToPoints(cents, rate)`: 逻辑改为 `cents * rate / 100`
- `PointsToCents(points, rate)`: 逻辑改为 `points * 100 / rate`
- **`internal/service/user`**: 业务服务层。
- 职责:在积分变动(增加、扣减、退款)时,从 `sys_configs` 读取最新 `points_exchange_rate` 并传入计算包。
- 变更:检查所有调用点,确保不再硬编码。
### 2.2 数据库 (`MySQL`)
- **Schema**: 无变更。
- **Data Migration**:
- 需执行数据清洗,将现有的积分数值 `x` 更新为 `x / 100` (假设之前是按分存的)。
- **Risk**: 需要用户确认是否执行此 SQL。**默认提供 SQL 但不自动运行**。
### 2.3 管理后台 (`web/admin`)
- **SystemConfigs**:
- 新增 "积分配置" Section。
- Key: `points_exchange_rate`
- 验证:必须为正整数,默认为 1。
### 2.4 小程序 (`bindbox-mini`)
- **Utils**:
- `formatPoints`: 移除除以 100 的逻辑,仅保留千分位格式化。
- **Pages**:
- 检查积分明细、下单抵扣、商品兑换等页面的展示。
- **Vue Filters/Formatters**: 全局搜索使用 `/ 100` 展示积分的地方进行替换。
## 3. 接口规范
- **API**: `POST /admin/system/configs` (现有)
- Payload: `{ "key": "points_exchange_rate", "value": "1" }`
## 4. 迁移策略
1. **停机维护** (建议): 防止数据在迁移过程中变动。
2. **代码部署**: 部署新版后端(新算法)。
3. **数据清洗**:
```sql
-- 假设所有用户的积分都需要缩小 100 倍以适配新算法
UPDATE user_points SET points = FLOOR(points / 100);
UPDATE user_points_ledger SET points = FLOOR(points / 100);
```
4. **验证**: 检查某测试账号积分是否符合预期。

View File

@ -1,45 +0,0 @@
# 任务任务:统一积分与元比例 (Task List)
## 0. 预备工作
- [ ] **确认数据备份**: 提醒用户备份数据库。
- [ ] **代码同步**: 确保本地代码是最新的。
## 1. 后端改造 (Backend)
- [ ] **修改核心计算包 (`internal/pkg/points/convert.go`)**
- [ ] 修改 `CentsToPoints``(cents * rate) / 100`
- [ ] 修改 `PointsToCents``(points * 100) / rate`
- [ ] 修改 `RefundPointsAmount` 适配新公式
- [ ] **业务逻辑适配 (`internal/service/user`)**
- [ ] 检查 `points_convert.go` 中的 `CentsToPoints` 调用,确保读取配置 Key `points_exchange_rate` (或复用旧 Key 但明确含义)。
- [ ] 检查 `points_consume.go` 等文件,确保无其他硬编码。
- [ ] **单元测试**
- [ ] 运行 `internal/pkg/points` 的测试,确保 100 分钱在 Rate=1 时转为 1 积分。
## 2. 管理后台改造 (Admin Frontend)
- [ ] **更新系统配置页 (`web/admin/src/views/system/configs/index.vue`)**
- [ ] 新增 "积分配置" 卡片。
- [ ] 添加 `points_exchange_rate` 编辑项。
- [ ] 添加说明: "1元 = ? 积分"。
## 3. 小程序改造 (Mini Program)
- [ ] **全局搜索积分展示**
- [ ] 搜索 `/ 100``* 0.01` 相关的积分代码。
- [ ] **修复工具函数**
- [ ] 修改 `utils/format.js` 或类似文件中的 `formatPoints`
- [ ] **修复页面展示**
- [ ] `pages-user/points/index.vue` (积分明细)
- [ ] `pages-user/orders/detail.vue` (如果有积分抵扣展示)
- [ ] 其他涉及积分展示的页面。
## 4. 数据迁移 (Migration)
- [ ] **提供 SQL 脚本**
- [ ] 存入 `docs/standardize_points_ratio/migration.sql`
- [ ] **(Optional) 执行迁移**
- [ ] 根据用户指示决定是否执行。
## 5. 验证 (Verification)
- [ ] **后端验证**
- [ ] 重启服务。
- [ ] 模拟支付 1 元,查看数据库增加 1 积分。
- [ ] **前端验证**
- [ ] 查看积分列表,显示为 1 (而不是 0.01 或 100)。

View File

@ -1,62 +0,0 @@
-- Standardize Points Ratio Migration Script
-- Purpose: Convert point values from "1 Yuan = 100 Points" (Cents) to "1 Yuan = 1 Point" (Integer).
-- Target Tables: user_points, user_points_ledger, orders.
BEGIN;
-- 1. Update User Points Current Balance
-- Description: Reduce all current point balances by factor of 100.
UPDATE user_points
SET points = FLOOR(points / 100);
-- 2. Update User Points Ledger (History)
-- Description: Adjust historical records to reflect the new unit.
UPDATE user_points_ledger
SET points = FLOOR(points / 100);
-- 3. Update Orders Points Usage
-- Description: Adjust recorded points usage in orders.
UPDATE orders
SET points_amount = FLOOR(points_amount / 100)
WHERE points_amount > 0;
-- 4. Update Task Center Rewards (Points)
-- Description: Adjust configured point rewards in Task Center.
-- Note: Logic prioritizes 'points' in JSON payload, then 'quantity'.
-- Updating 'quantity' is safe. Updating JSON is complex in standard SQL without knowing exact structure/version.
-- Assuming simple structure {"points": 100} or similar.
-- 4a. Update Quantity for type 'points'
UPDATE task_center_task_rewards
SET quantity = FLOOR(quantity / 100)
WHERE reward_type = 'points' AND quantity >= 100;
-- 4b. Update RewardPayload? (Optional/Manual)
-- Warning: Modifying JSON string requires robust parsing.
-- Providing a best-effort text replacement for simple cases {"points": 100} -> {"points": 1}
-- This relies on the pattern '"points": ' followed by numbers.
-- Recommendation: Manually verify Task Center configurations in Admin Panel after migration.
-- 5. System Config Update (Values)
-- Description: Convert known config values if they represent points.
-- Example: 'register_points', 'daily_sign_in_points' (if they exist).
-- This attempts to update common point-related configs.
UPDATE system_configs
SET config_value = FLOOR(config_value / 100)
WHERE config_key IN ('register_reward_points', 'daily_sign_in_reward_points') AND config_value REGEXP '^[0-9]+$';
-- 6. System Config Update (Exchange Rate)
-- Description: Ensure the new config key is set.
INSERT INTO system_configs (config_key, config_value, remark, created_at, updated_at)
VALUES ('points_exchange_rate', '1', '1元对应多少积分', NOW(), NOW())
ON DUPLICATE KEY UPDATE config_value = '1';
COMMIT;
-- NOTE on 'user_inventory':
-- Some points data might be embedded in 'remark' field (e.g. '|redeemed_points=100').
-- Updating these embedded strings via SQL is complex and risky.
-- Refunds for OLD items (redeemed before migration) might fail or deduct excessive points
-- if this field is not updated.
-- Recommendation: Handle 'refund' logic gracefully for legacy items in code if possible,
-- or accept that old items cannot be refunded automatically.

View File

@ -1,52 +0,0 @@
# Walkthrough: Standardize Points Ratio
## 1. Goal
Standardize the Points-to-Yuan ratio to **1 Yuan = 1 Point**, and remove the decimal display logic (previous 100 Points = 1 Yuan) across the system.
## 2. Changes
### Backend (`bindbox_game`)
- **Core Logic (`internal/pkg/points/convert.go`)**:
- Updated `CentsToPoints` to `(cents * rate) / 100`.
- Updated `PointsToCents` to `(points * 100) / rate`.
- Added unit tests to verify 100 Cents = 1 Point.
- **Service Layer (`internal/service/user`)**:
- Updated `points_convert.go` to use `points_exchange_rate` config key.
- Added `PointsToCents` method for reverse calculation.
- Updated `lottery_app.go` to use `PointsToCents` for accurate deduction calculation.
- **DB Config**:
- Expects `points_exchange_rate` (default 1) instead of `points_exchange_per_cent`.
### Admin Frontend (`web/admin`)
- **System Configs**:
- Added "Points Configuration" section.
- Allows setting "1 Yuan = N Points" (Default 1).
### Mini-Program Frontend Display Logic
- **Goal**: Ensure Points are displayed as integers and values are consistent.
- **Files Modified**:
- `pages-user/points/index.vue`
- `pages-user/orders/detail.vue`
- `pages-user/tasks/index.vue`
- `pages/shop/index.vue` (Fixed `/ 100` division for points)
- `pages-shop/shop/detail.vue` (Fixed `/ 100` division for points)
- **Changes**:
- Removed incorrect `/ 100` division for Points display while keeping it for Money (Yuan) display.
- formated points to use `.toFixed(0)` to remove decimal places.
### Database Migration
- **Script**: `docs/standardize_points_ratio/migration.sql`
- **Action Required**: Run this script to shrink existing point values by 100x to match the new 1:1 definition.
## 3. Verification
- **Unit Tests**:
- `go test internal/pkg/points/...` passed.
- **Manual Check**:
- `TestCentsToPoints` confirmed 100 Cents -> 1 Point.
- `TestRefundPointsAmount` confirmed proportional refund works with integer points.
## 4. Next Steps for User
1. **Backup Database**.
2. **Deploy Backend & Admin**.
3. **Run Migration Script** (`docs/standardize_points_ratio/migration.sql`).
4. **Deploy Mini Program**.

View File

@ -1,46 +0,0 @@
# 任务:修复一番赏次数卡支付与退款逻辑
## 1. 项目上下文分析
- **项目**: BindBox (Blind Box / Ichiban Kuji Game Platform)
- **技术栈**: Go (Backend), Vue3/UniApp (Frontend)
- **涉及模块**:
- 前端:一番赏活动页 (`bindbox-mini/pages-activity/activity/yifanshang/index.vue`)
- 后端:抽奖接口 (`internal/api/activity/lottery_app.go`)
- 后端:退款接口 (`internal/api/admin/pay_refund_admin.go`)
## 2. 需求理解与确认
### 原始需求
1. **不掉支付**: 使用次数卡Count Card进行一番赏抽奖时不应拉起微信支付因为金额应为0
2. **退款退卡**: 对使用次数卡支付的订单进行退款时应退还次数卡次数而非退款金额因为金额为0或无操作。
### 问题分析
通过代码审查,发现以下问题:
1. **前端支付逻辑缺陷**: `index.vue` 在调用 `joinLottery` 后,未根据返回的订单状态或金额判断是否需要支付,而是无条件调用 `createWechatOrder` 并拉起支付。
2. **后端抽奖逻辑缺陷**: `lottery_app.go` 在使用次数卡扣费时,虽然将 `ActualAmount` 设为 0但在 `order.Remark` 中仅记录了 `use_game_pass`,未记录具体使用的次数卡 ID 和扣除数量。
3. **后端退款逻辑缺陷**: `pay_refund_admin.go` 在退款时尝试从 `order.Remark` 解析 `game_pass:ID`,但由于上述原因无法解析。且当前逻辑仅尝试恢复 `remaining + 1`未考虑到一单多抽Count > 1的情况。
## 3. 智能决策策略
### 决策点 1前端如何判断跳过支付
- **方案**: 检查 `joinLottery` 返回的 `actual_amount``status`
- **依据**: `lottery_app.go` 中,若金额为 0`Status` 会被置为 2 (Paid),且 `ActualAmount` 为 0。
- **实现**: 若 `res.actual_amount === 0``res.status === 2`,则直接进入抽奖结果展示流程。
### 决策点 2后端如何记录次数卡使用情况
- **方案**: 修改 `lottery_app.go`,在 Deduction 循环中记录所有使用的 Pass ID 和对应扣除数。
- **格式建议**: `gp_use:ID1:Count1|gp_use:ID2:Count2`
- **兼容性**: 需确保不破坏现有 Remark 的其他信息。
### 决策点 3退款如何恢复次数
- **方案**: 修改 `pay_refund_admin.go`,解析新的 Remark 格式。
- **逻辑**: 遍历所有记录的 ID按记录的扣除数执行 `UPDATE user_game_passes SET remaining = remaining + ?, total_used = total_used - ? WHERE id = ?`
## 4. 最终共识 (Consensus)
### 任务边界
1. **Frontend**: 修改 `yifanshang/index.vue``onPaymentConfirm` 方法。
2. **Backend**: 修改 `lottery_app.go` 记录 Game Pass Usage。
3. **Backend**: 修改 `pay_refund_admin.go` 实现精准的次数卡退还。
### 验收标准
1. **支付测试**: 使用次数卡购买一番赏(单抽或多抽),前端不弹出微信支付,直接显示抽奖结果。
2. **数据验证**: 数据库 `orders` 表的 `remark` 字段包含具体的次数卡使用记录(如 `gp_use:101:5`)。
3. **退款测试**: 对该订单执行全额退款对应的次数卡ID 101`remaining` 增加 5`total_used` 减少 5。

View File

@ -1,43 +0,0 @@
# 共识:修复一番赏次数卡支付与退款逻辑
## 1. 需求描述与验收标准
### 需求描述
- **不掉支付**: 使用次数卡支付时若订单金额为0前端不应拉起微信支付直接视为支付成功。
- **退款退卡**: 次数卡支付的订单退款时,需准确退还使用的次数卡次数。
### 验收标准
1. **前端支付流程**:
- [ ] 使用次数卡全额抵扣时,点击“去支付”后直接弹出成功/翻牌界面,无微信支付弹窗。
- [ ] 混合支付(次数卡不足补差价)时,仍正常拉起支付(本次主要关注全额抵扣)。
2. **后端订单记录**:
- [ ] 数据库 `orders.remark` 字段正确记录 `gp_use:<ID>:<Count>`
- [ ] 支持单次使用多张次数卡的情况(如 `gp_use:101:5|gp_use:102:1`)。
3. **退款流程**:
- [ ] 对次数卡订单执行退款,对应的 `user_game_passes` 记录 `remaining` 增加,`total_used` 减少。
- [ ] 退还数量与扣除数量完全一致。
- [ ] 退款流水清晰。
## 2. 技术实现方案
### 前端 (Mini-Program)
- 修改 `bindbox-mini/pages-activity/activity/yifanshang/index.vue`
- 在 `onPaymentConfirm` 中,接收 `joinLottery` 响应后,判断 `order.ActualAmount == 0``Status == 2`
- 若满足,直接调用 `onPaymentSuccess` 或模拟成功逻辑,跳过 `createWechatOrder`
### 后端 (Go)
- **抽奖 (Lottery)**:
- 修改 `internal/api/activity/lottery_app.go`
- 在扣除 Game Pass 的循环中,动态构建 Remark 字符串,记录每个 Pass 的扣除量。
- **退款 (Refund)**:
- 修改 `internal/api/admin/pay_refund_admin.go`
- 升级 Remark 解析逻辑,支持 `gp_use:ID:Count` 格式。
- 遍历所有使用的 Pass逐一执行恢复 SQL。
## 3. 任务边界限制
- 仅针对一番赏业务Ichiban
- 仅针对 Game Pass (次数卡) 支付方式。
- 不涉及优惠券退款逻辑变更(除非受全额退款逻辑影响,需确保兼容)。
## 4. 确认所有不确定性已解决
- 已确认当前 Frontend 无条件拉起支付是 Bug。
- 已确认当前 Backend 未记录详细 Pass ID 是导致无法精确退款的原因。
- 已确认 Refund 逻辑需升级以支持多卡/多数量退还。

View File

@ -1,85 +0,0 @@
# 设计:修复一番赏次数卡支付与退款逻辑
## 1. 整体架构与流程
### 支付流程 (Modified)
```mermaid
sequenceDiagram
participant User
participant Frontend (Vue)
participant Backend (API)
participant DB
User->>Frontend: 选择一番赏格位,点击支付 (使用次数卡)
Frontend->>Backend: API: /api/app/lottery/join (use_game_pass=true)
Backend->>DB: Check Game Pass Balance
Backend->>DB: Deduct Game Pass (Remaining - N)
Backend->>DB: Create Order (Amount=0, Status=2, Remark="gp_use:ID:N")
Backend-->>Frontend: Response (Success, Amount=0, Status=2)
alt Amount == 0
Frontend->>Frontend: Skip WeChat Pay
Frontend->>Frontend: Show Result / Flip Cards
else Amount > 0
Frontend->>Backend: Create WeChat Order
Frontend->>WeChat: Request Payment
end
```
### 退款流程 (Modified)
```mermaid
sequenceDiagram
participant Admin
participant Backend (Refund API)
participant DB
Admin->>Backend: API: /api/admin/refund (OrderNo)
Backend->>DB: Get Order & Remark
Backend->>Backend: Parse Remark for "gp_use:ID:Count" pairs
loop For Each Pass
Backend->>DB: Update user_game_passes SET remaining+=Count
end
Backend->>DB: Update Order Status = Refunded
Backend-->>Admin: Success
```
## 2. 核心组件设计
### 2.1 Lottery App (`lottery_app.go`)
- **Logic**: In `JoinLottery` handler, inside the transaction where `useGamePass` is true.
- **Change**:
```go
// Before
deducted += canDeduct
// After
deducted += canDeduct
gamePassUsage = append(gamePassUsage, fmt.Sprintf("gp_use:%d:%d", p.ID, canDeduct))
...
order.Remark += "|" + strings.Join(gamePassUsage, "|")
```
### 2.2 Refund Admin (`pay_refund_admin.go`)
- **Logic**: In `CreateRefund`.
- **Change**:
- Remove simple regex `game_pass:(\d+)`.
- Add loop to find all `gp_use:(\d+):(\d+)`.
- Execute restoration for each match.
### 2.3 Frontend (`index.vue`)
- **Logic**: `onPaymentConfirm`.
- **Change**:
```javascript
if (joinResult.actual_amount === 0 || joinResult.status === 2 || joinResult.status === 'paid') {
// Direct Success
onPaymentSuccess({ result: lotteryResult })
return
}
```
## 3. 接口契约
- 无新增接口。
- `JoinLottery` 响应保持不变,前端需利用现有的 `actual_amount``status` 字段。
## 4. 异常处理
- **Refund Partial Failure**: Cannot easily happen in transaction. If one update fails, whole refund fails.
- **Legacy Orders**: Old orders have `use_game_pass` but no `gp_use:ID`. Refund logic should fallback to old behavior (try to find 1 pass or just warn/skip).
- *Fallback Strategy*: If no `gp_use` found but `use_game_pass` is present, log warning or try to restore 1 count to *any* valid pass of user?
- *Decision*: Since user specifically asked for this fix, we assume it's for FUTURE/NEW orders or current testing. For legacy orders, we can leave as is or try best effort. Given "Strict" requirement, we will implement the new logic. Legacy fallback: Scan `game_pass:ID` (old format if any?) - wait, old code didn't write ID at all. So legacy orders cannot be automatically restored safely. This is acceptable for a "Fix".

View File

@ -1,28 +0,0 @@
# 项目总结报告:修复一番赏次数卡支付与退款逻辑
## 1. 任务概述
本任务旨在修复“一番赏”业务中使用次数卡Game Pass支付时前端仍拉起微信支付的问题以及退款时未能正确退还次数卡次数的问题。
## 2. 完成情况
### 2.1 需求实现
- [x] **前端支付优化**: `yifanshang/index.vue` 已增加判断逻辑,当 `ActualAmount == 0``Status == 2` 时,直接跳过微信支付流程,进入开奖结果查询。
- [x] **后端记录优化**: `lottery_app.go` 现在会在订单 `Remark` 中以 `gp_use:ID:Count` 格式记录具体的次数卡使用明细。
- [x] **后端退款优化**: `pay_refund_admin.go` 已支持解析新的 `gp_use` 格式,并能准确恢复多张卡、多数量的消耗。同时保留了对旧格式 `game_pass:ID` 的兼容支持。
### 2.2 代码变更
- `internal/api/activity/lottery_app.go`: 记录 Game Pass 扣除明细。
- `internal/api/admin/pay_refund_admin.go`: 增强退款逻辑,支持多卡恢复。
- `bindbox-mini/pages-activity/activity/yifanshang/index.vue`: 优化支付流程,支持 0 元订单。
## 3. 质量评估
- **编译通过**: 后端代码 `go build` 成功,无语法错误。
- **逻辑完备性**:
- 覆盖了“不掉支付”的核心诉求。
- 覆盖了“精准退卡”的核心诉求。
- 考虑了新旧数据格式兼容性。
- **风险控制**:
- 仅针对 `Ichiban` 逻辑生效,不影响其他业务。
- 前端改动范围局限于支付确认回调,风险可控。
## 4. 交付结论
以完成所有关键路径的修复,代码已准备就绪,可以部署测试。

View File

@ -1,40 +0,0 @@
# 任务拆解:修复一番赏次数卡支付与退款逻辑
## 1. Backend Tasks
### 1.1 Update Lottery Logic (Atomize)
- **File**: `internal/api/activity/lottery_app.go`
- **Goal**: Record specific Game Pass usage in Order Remark.
- **Steps**:
- Locate `JoinLottery` function.
- Inside `useGamePass` block, accumulate used pass IDs and counts.
- Append formatted string `gp_use:ID:Count` to `order.Remark`.
- **Verification**: Run local test, buy with count card, check DB `orders` table remark.
### 1.2 Update Refund Logic (Atomize)
- **File**: `internal/api/admin/pay_refund_admin.go`
- **Goal**: Parse `gp_use` and restore counts.
- **Steps**:
- Locate `CreateRefund` function.
- Replace/Extend existing Game Pass restoration logic.
- Implement regex to find all `gp_use:(\d+):(\d+)`.
- Loop and execute SQL updates.
- **Verification**: Create order with count card (using 1.1), then call refund API, check `user_game_passes` table restoration.
## 2. Frontend Tasks
### 2.1 Update Payment Flow (Atomize)
- **File**: `bindbox-mini/pages-activity/activity/yifanshang/index.vue`
- **Goal**: Skip WeChat Pay for 0-amount orders.
- **Steps**:
- Locate `onPaymentConfirm`.
- After `joinLottery` returns, check `joinResult.actual_amount === 0` or `status`.
- If true, directly call logic to show results (e.g. `onPaymentSuccess` or equivalent logic to flip cards).
- Ensure `getLotteryResult` is called (which is already there in logic, just need to skip `createWechatOrder`).
- **Verification**: UI test with count card.
## 3. Dependency Graph
```mermaid
graph TD
B1[Backend: Lottery Logic] --> B2[Backend: Refund Logic]
B1 --> F1[Frontend: Payment Flow]
```
(B2 and F1 can be parallel, but B1 is prerequisite for B2 to be testable with new data).

View File

@ -1,12 +0,0 @@
# 待办事项:一番赏次数卡修复后续
## 1. 测试与验证
- [ ] **真机验证**: 需要在真机小程序环境测试使用次数卡购买一番赏,确认是否直接跳过支付。
- [ ] **退款验证**: 需要在管理后台对生成的次数卡订单进行退款,并检查数据库 `user_game_passes` 表确认次数是否正确恢复。
## 2. 遗留/潜在问题
- [ ] **历史订单处理**: 此修复仅对新生成的订单生效(因为旧订单缺少 `gp_use:ID:Count` 记录)。旧订单退款仍将使用旧逻辑(仅退 1 次)。如果需要处理大量历史订单的批量退款,建议通过 SQL 手动修复或编写一次性脚本。
- [ ] **多端兼容**: 目前仅修改了 `bindbox-mini`(小程序端)。如果存在 App 端或 H5 端,需确认是否复用相同逻辑或需要单独修改。
## 3. 配置建议
- 无新增配置项。

View File

@ -1,259 +0,0 @@
# 后台工作台页面接口分析
## 一、原始需求
分析后台工作台页面的所有设计,确定需要对应哪些接口,并补充后端缺失的接口实现。
## 二、项目特性规范
### 技术栈
- **前端**: Vue 3 + TypeScript + Element Plus
- **后端**: Go + Gin + GORM
- **API风格**: RESTful
### 现有架构
- 前端API定义: `web/admin/src/api/dashboard.ts`, `web/admin/src/api/operations.ts`
- 后端处理器: `internal/api/admin/dashboard_admin.go`
---
## 三、工作台页面模块与接口对应关系
### 维度1: 经营大盘 (overview)
| 组件 | 功能 | 前端API | 后端状态 | 备注 |
|------|------|---------|----------|------|
| `card-list.vue` | 顶部统计卡片 | `fetchCardStats` | ✅ 已实现 | `DashboardCards` |
| `sales-overview.vue` | 销售趋势分析 | `fetchSalesDrawTrend` | ✅ 已实现 | `DashboardSalesDrawTrend` |
| `product-performance.vue` | 产品动销排行 | `fetchProductPerformance` | ⚠️ Mock数据 | 需要实现 |
| `user-economics.vue` | 用户经济分析 | `fetchUserEconomics` | ✅ 已实现 | `DashboardUserEconomics` |
---
### 维度2: 奖池与欧气 (lottery)
| 组件 | 功能 | 前端API | 后端状态 | 备注 |
|------|------|---------|----------|------|
| `prize-pool-health.vue` | 奖池健康度分析 | `fetchPrizeDistribution` | ✅ 已实现 | `DashboardPrizeDistribution` |
| `live-stream-premium.vue` | 全服欧气实时播报 | 无(模拟数据) | ⚠️ 需要实现 | 需要新增实时中奖播报接口 |
---
### 维度3: 营销转化 (marketing)
| 组件 | 功能 | 前端API | 后端状态 | 备注 |
|------|------|---------|----------|------|
| `growth-analytics.vue` | 增长经济模型分析 | `fetchUserEconomics` | ✅ 已实现 | 复用 `DashboardUserEconomics` |
| `coupon-roi.vue` | 营销券效能排行 | `fetchCouponEffectiveness` | ⚠️ Mock数据 | 需要实现 |
| `retention-cohort.vue` | 留存同类群组分析 | `fetchRetentionAnalytics` | ✅ 已实现 | `DashboardRetentionAnalytics` |
| `marketing-conversion.vue` | 订单转化全链路监控 | `fetchOrderFunnel` | ✅ 已实现 | `DashboardOrderFunnel` |
---
### 维度4: 风控预警 (security)
| 组件 | 功能 | 前端API | 后端状态 | 备注 |
|------|------|---------|----------|------|
| `inventory-alert.vue` | 库存预警监控 | `fetchInventoryAlerts` | ⚠️ Mock数据 | 需要实现 |
| `risk-monitor.vue` | 异常风险监控 | `fetchRiskEvents` | ⚠️ Mock数据 | 需要实现 |
| `points-economy.vue` | 积分经济总览 | `fetchPointsEconomySummary`<br>`fetchPointsTrend`<br>`fetchPointsStructure` | ⚠️ Mock数据 | 需要实现3个接口 |
---
## 四、需要补充的后端接口清单
### 4.1 运营分析接口 (Operations)
#### 1. 产品动销排行 `GET /admin/operations/product_performance`
```json
// Request
{ "rangeType": "7d|30d|today" }
// Response
[
{
"id": 1,
"seriesName": "系列名称",
"salesCount": 1540,
"amount": 285000, // 销售金额(分)
"contributionRate": 35.5, // 利润贡献率%
"inventoryTurnover": 8.5 // 周转率
}
]
```
#### 2. 优惠券效能排行 `GET /admin/operations/coupon_effectiveness`
```json
// Request
{ "rangeType": "7d|30d" }
// Response
[
{
"couponId": 1,
"couponName": "新用户专享券",
"type": "满减券",
"issuedCount": 1200,
"usedCount": 680,
"usedRate": 56.7, // 使用率%
"broughtOrders": 720, // 带动订单数
"broughtAmount": 3600000, // 带动金额(分)
"roi": 3.2 // 投资回报率
}
]
```
#### 3. 库存预警列表 `GET /admin/operations/inventory_alerts`
```json
// 无请求参数
// Response
[
{
"id": 101,
"name": "商品名称",
"type": "physical|virtual|coupon",
"stock": 3,
"threshold": 5,
"salesSpeed": 1.2 // 日均消耗速度
}
]
```
#### 4. 风险事件监控 `GET /admin/operations/risk_events`
```json
// 无请求参数
// Response
[
{
"userId": 5001,
"nickname": "用户昵称",
"avatar": "头像URL",
"type": "frequent_win|batch_register|ip_clash",
"description": "24小时内中奖5次一等奖",
"riskLevel": "high|medium|low",
"createdAt": "13:20"
}
]
```
---
### 4.2 积分经济接口 (Points Economy)
#### 5. 积分经济总览 `GET /admin/operations/points_economy_summary`
```json
// Request
{ "rangeType": "7d|30d" }
// Response
{
"totalIssued": 1258400, // 发行总积分
"totalConsumed": 985600, // 消耗总积分
"netChange": 272800, // 净变化
"activeUsersWithPoints": 5640, // 持分活跃用户数
"conversionRate": 78.5 // 活跃持仓率%
}
```
#### 6. 积分趋势 `GET /admin/operations/points_trend`
```json
// Request
{ "rangeType": "7d|30d" }
// Response
[
{
"date": "2026-01-01",
"issued": 20000,
"consumed": 15000,
"expired": 1000,
"netChange": 4000,
"balance": 250000
}
]
```
#### 7. 积分收支结构 `GET /admin/operations/points_structure`
```json
// Request
{ "rangeType": "7d|30d" }
// Response
[
{
"category": "任务奖励",
"amount": 85000,
"percentage": 45.2,
"trend": "+12.5%"
}
]
```
---
### 4.3 实时播报接口 (Live Stream)
#### 8. 实时中奖播报 `GET /admin/dashboard/live_winners`
```json
// Request
{ "sinceId": 0, "limit": 20 }
// Response
{
"list": [
{
"id": 12345,
"nickname": "用户昵称",
"avatar": "头像URL",
"issueName": "活动期名称",
"prizeName": "奖品名称",
"isBigWin": true,
"createdAt": "刚刚"
}
],
"stats": {
"hourlyWinRate": 4.2, // 近1小时爆率
"drawsPerMinute": 128 // 连抽频率
}
}
```
---
## 五、疑问澄清
### 5.1 需要确认的问题
1. **风险事件监控**: 目前系统是否有用户行为日志表可供分析?如果没有,是否需要先创建相关基础设施?
2. **库存预警阈值**: 库存预警的阈值应该从哪里获取?是固定配置还是每个商品可单独设置?
3. **积分经济统计范围**: 积分发行/消耗是否需要区分来源类型(任务奖励、抽奖中奖、兑换消耗等)?
4. **实时播报频率**: 前端轮询间隔建议设为多少3秒是否合适
---
## 六、边界与限制
### 6.1 任务边界
- ✅ 补充后端缺失的运营分析接口
- ✅ 实现积分经济相关接口
- ✅ 实现库存预警和风险监控接口
- ✅ 更新前端API调用从Mock数据改为真实后端调用
- ❌ 不涉及前端UI重构
- ❌ 不涉及权限管理改动
### 6.2 依赖关系
- 依赖现有数据库表: `users`, `orders`, `user_points_logs`, `coupons`, `user_coupons`, `prizes`, `draw_logs`
- 可能需要新增数据库视图或缓存层以提高查询性能
---
## 七、预期验收标准
1. 所有前端Mock数据的接口均已替换为真实后端调用
2. 后端接口响应格式与前端类型定义一致
3. 各项统计数据计算逻辑准确
4. 查询性能在可接受范围内(响应时间 < 500ms

View File

@ -1,91 +0,0 @@
# 后台工作台接口 - 共识文档
## 一、明确需求描述
补充后台工作台页面中使用Mock数据的8个接口实现真实的后端数据查询逻辑。
### 需求范围
1. **运营分析接口** (4个)
- 产品动销排行
- 优惠券效能排行
- 库存预警列表
- 风险事件监控
2. **积分经济接口** (3个)
- 积分经济总览
- 积分趋势
- 积分收支结构
3. **实时播报接口** (1个)
- 实时中奖播报
---
## 二、技术实现方案
### 2.1 后端接口实现位置
- **文件**: `internal/api/admin/dashboard_admin.go` (现有文件,追加新接口)
- **路由注册**: 在现有admin路由组中添加新路径
### 2.2 数据来源表
| 接口 | 主要数据表 | 关联表 |
|------|-----------|--------|
| 产品动销排行 | `orders`, `activities` | `issues`, `products` |
| 优惠券效能 | `user_coupons`, `coupons` | `orders` |
| 库存预警 | `issues`, `prizes` | `products` |
| 风险事件 | `draw_logs`, `users` | `user_login_logs` (如存在) |
| 积分经济 | `user_points_logs` | `users` |
| 实时中奖 | `draw_logs` | `users`, `prizes` |
### 2.3 接口设计原则
- 保持与现有接口风格一致
- 使用 `rangeType` 参数统一时间范围过滤
- 金额单位统一为**分**
- 百分比保留2位小数
---
## 三、验收标准
### 功能验收
- [ ] 所有8个接口正常返回数据
- [ ] 响应格式与前端TypeScript类型定义一致
- [ ] 时间范围过滤逻辑正确
### 性能验收
- [ ] 单接口响应时间 < 500ms
- [ ] 无N+1查询问题
### 集成验收
- [ ] 前端调用后端接口无报错
- [ ] 工作台各模块正确展示真实数据
---
## 四、技术约束
1. **不引入新依赖**: 使用现有GORM查询
2. **复用现有工具函数**: 如 `parseRange()`, `percentChange()`
3. **统一错误处理**: 使用现有 `core.HandlerFunc` 模式
---
## 五、已解决的不确定性
基于现有代码分析:
- ✅ 积分日志表 `user_points_logs` 已存在,包含 `change_type` 字段可区分来源
- ✅ 用户登录日志可通过 `draw_logs``orders` 推断活跃度
- ✅ 库存阈值可通过 `prizes.quantity` 与剩余数量对比计算
---
## 六、实现优先级
| 优先级 | 接口 | 原因 |
|--------|------|------|
| P0 | 产品动销排行 | 经营大盘核心指标 |
| P0 | 积分经济总览+趋势 | 风控预警必需 |
| P1 | 优惠券效能 | 营销分析重要 |
| P1 | 库存预警 | 运营监控 |
| P2 | 风险事件 | 可先用简化版 |
| P2 | 实时中奖播报 | 可复用现有 `draw_stream` |

View File

@ -1,95 +0,0 @@
# 玩家管理Bug修复 - 需求对齐
## 项目上下文
- **前端**: Vue 3 + TypeScript + Element Plus
- **后端**: Go (Gin框架)
- **关键文件**:
- 玩家列表: `web/admin/src/views/player-manage/index.vue`
- 分页Hook: `web/admin/src/hooks/core/useTable.ts`
- 用户详情抽屉: `web/admin/src/views/player-manage/modules/player-detail-drawer.vue`
- 用户盈亏图表: `web/admin/src/views/player-manage/modules/player-profit-loss-chart.vue`
- 活动分析抽屉: `web/admin/src/views/activity/manage/components/ActivityAnalysisDrawer.vue`
---
## Bug列表
### Bug 1: 玩家列表分页失效
**现象**: 点击下一页后会自动跳回第1页
**初步分析**:
- 玩家列表使用 `useTable` hook 管理分页
- `handleCurrentChange` 函数会修改 `pagination.current` 并调用 `getData`
- 可能原因:
1. `handleSearch` 函数在搜索时重置了页码但没有正确更新
2. `getDataDebounced` 使用的参数可能覆盖了新的页码值
3. 搜索参数和分页参数同步问题
**需要确认**: 具体是在什么场景下触发?是否有搜索条件?
---
### Bug 2: 活动的游戏盈亏仪表盘
**现象描述不清,需要澄清**
**可能的理解**:
1. 需要在仪表盘Dashboard添加活动的游戏盈亏分析组件
2. 现有的 `ActivityAnalysisDrawer` 有问题需要修复?
3. 需要一个全局的活动盈亏汇总仪表盘?
**当前现有功能**:
- `ActivityAnalysisDrawer.vue`: 单个活动的数据分析抽屉,包含总营收、总成本、毛利润、参与人数等
**需要澄清**: 具体需要什么功能?是新增组件还是修复现有问题?
---
### Bug 3: 用户盈亏分析需要明细
**需求理解**:
- 当前 `player-profit-loss-chart.vue` 显示用户盈亏趋势图表和汇总数据
- 需要增加订单明细列表,可以点击查看每笔订单的盈亏
- 明细需包含: 道具卡、优惠券等使用情况
**当前支持**:
- 有资产分项概览: 商品产出、积分收益、道具卡价值、优惠券价值
- 有趋势图表展示投入、产出、净盈亏
**待实现**:
- 盈亏明细列表(可分页、可搜索)
- 每条记录显示: 订单信息、支付金额、获得奖品价值、使用的优惠券/道具卡及其价值
---
### Bug 4: 用户资产加一个搜索
**需求理解**:
- 在用户详情抽屉的"资产"Tab中增加搜索功能
- 当前资产列表使用 `ArtDataListCard` 组件展示
**待实现**:
- 搜索框(按商品名称、订单号等搜索)
- 可能需要修改后端API支持搜索参数
---
## 疑问澄清
### 优先级问题
1. **Bug 2** 描述不够清晰,需要进一步说明具体需求:
- 是否需要在主仪表盘添加新组件?
- 还是修复现有 `ActivityAnalysisDrawer` 的问题?
- 需要展示哪些数据?
### 技术问题
2. **Bug 1** 分页问题:
- 是否只在有搜索条件时出现?
- 是否与特定浏览器相关?
### 范围确认
3. **Bug 3** 盈亏明细:
- 明细是否需要导出功能?
- 是否需要按时间范围筛选?
- 每条明细需要展示哪些具体字段?
4. **Bug 4** 资产搜索:
- 支持哪些搜索条件?商品名称?订单号?
- 是否需要后端支持模糊搜索?

View File

@ -1,59 +0,0 @@
# 玩家管理Bug修复 - 共识文档
## 需求确认
### Bug 1: 玩家列表分页失效
**现象**: 点击下一页后自动跳回第1页
**确认范围**: 在玩家列表页面点击分页控件的下一页时出现
### Bug 2: 活动游戏盈亏仪表盘
**确认理解**: 需要修复/完善活动的盈亏分析功能
- 目标:完善现有的 `ActivityAnalysisDrawer` 组件功能
### Bug 3: 用户盈亏分析明细
**确认理解**: 在用户盈亏分析中增加订单级明细列表
- 每条明细显示:订单信息、支付金额、获得奖品价值
- 包含使用的优惠券、道具卡及其价值
### Bug 4: 用户资产搜索
**确认理解**: 在用户详情抽屉的资产Tab中增加搜索功能
---
## 技术实现方案
### Bug 1 修复方案
**根因分析**:
- 玩家管理页面使用 `useTable` hook 管理分页
- `ArtTable` 组件通过 `pagination:current-change` 事件通知页码变化
- 页面监听该事件调用 `handleCurrentChange``getData(params)`
- 问题可能出在 `useTable.ts` 中搜索参数和分页参数的同步逻辑
**修复方案**:
- 检查 `handleCurrentChange` 函数中页码参数的传递
- 确保分页参数不被搜索参数覆盖
### Bug 2 修复方案
**现有功能**: `ActivityAnalysisDrawer.vue` 已有活动数据分析功能
**待完善**: 确认功能是否正常工作,是否需要增强
### Bug 3 实现方案
**新增功能**:
1. 后端新增API: `GET /api/admin/users/{user_id}/profit_loss/details`
- 返回每笔订单的盈亏明细
- 包含:订单信息、支付金额、获得价值、使用的优惠券/道具卡
2. 前端增加明细列表组件
### Bug 4 实现方案
**新增功能**:
1. 后端API修改: `GET /api/admin/users/{user_id}/inventory` 支持搜索参数
2. 前端在资产Tab增加搜索框
---
## 验收标准
1. **Bug 1**: 玩家列表可以正常翻页,页码不会跳回第一页
2. **Bug 2**: 活动盈亏仪表盘功能正常工作
3. **Bug 3**: 用户盈亏分析页可以查看订单明细列表
4. **Bug 4**: 用户资产列表可以按商品名称搜索

View File

@ -1,177 +0,0 @@
# 玩家管理Bug修复 - 架构设计
## 整体架构图
```mermaid
graph TB
subgraph Frontend["前端 Vue3"]
PM[玩家管理页面]
PD[用户详情抽屉]
PL[盈亏分析组件]
AD["活动分析抽屉(已有)"]
end
subgraph Backend["后端 Go/Gin"]
UA[users_admin.go]
UP[users_profit_loss.go]
AA[activities_admin.go]
end
PM --> |分页请求| UA
PD --> |资产列表+搜索| UA
PL --> |盈亏趋势| UP
PL --> |盈亏明细| UP
```
---
## Bug 1: 分页修复
### 问题分析
```
player-manage/index.vue
└── useTable hook
├── handleCurrentChange(newCurrent)
│ └── getData(params) // params中需要包含正确的page参数
└── 问题searchParams可能覆盖新的页码
```
### 修复方案
`index.vue` 中,`handleSearch` 函数调用 `getDataDebounced` 时传递了搜索参数,这可能导致分页参数被覆盖。
需要检查的代码路径:
1. `ArtTable` 组件发出 `pagination:current-change` 事件
2. `useTable``handleCurrentChange` 接收新页码
3. 确保页码正确传递给 API 请求
---
## Bug 2: 活动盈亏仪表盘
### 现有组件
- `ActivityAnalysisDrawer.vue`: 单活动数据分析
- 计算: 总营收、总成本、毛利润、参与人数
### 待确认/完善
- 检查计算逻辑是否正确
- 确保数据展示完整
---
## Bug 3: 用户盈亏明细
### 新增API
#### 后端: `GET /api/admin/users/{user_id}/stats/profit_loss_details`
**请求参数**:
```go
type profitLossDetailsRequest struct {
Page int `form:"page"`
PageSize int `form:"page_size"`
RangeType string `form:"rangeType"` // today, 7d, 30d, all
}
```
**响应结构**:
```go
type profitLossDetailItem struct {
OrderID int64 `json:"order_id"`
OrderNo string `json:"order_no"`
CreatedAt string `json:"created_at"`
ActualAmount int64 `json:"actual_amount"` // 实际支付金额(分)
RefundAmount int64 `json:"refund_amount"` // 退款金额(分)
NetCost int64 `json:"net_cost"` // 净投入(分)
PrizeValue int64 `json:"prize_value"` // 获得奖品价值(分)
PointsEarned int64 `json:"points_earned"` // 获得积分
PointsValue int64 `json:"points_value"` // 积分价值(分)
CouponUsedValue int64 `json:"coupon_used_value"` // 使用优惠券价值(分)
ItemCardUsed string `json:"item_card_used"` // 使用的道具卡名称
ItemCardValue int64 `json:"item_card_value"` // 道具卡价值(分)
NetProfit int64 `json:"net_profit"` // 净盈亏
ActivityName string `json:"activity_name"` // 活动名称
PrizeName string `json:"prize_name"` // 奖品名称
SourceType int `json:"source_type"` // 来源类型
}
type profitLossDetailsResponse struct {
Page int `json:"page"`
PageSize int `json:"page_size"`
Total int64 `json:"total"`
List []profitLossDetailItem `json:"list"`
Summary struct {
TotalCost int64 `json:"total_cost"`
TotalValue int64 `json:"total_value"`
TotalProfit int64 `json:"total_profit"`
} `json:"summary"`
}
```
### 前端组件修改
`player-profit-loss-chart.vue` 中增加:
1. "查看明细" 按钮
2. 明细表格(支持分页)
3. 显示字段:时间、订单号、支付金额、获得价值、使用优惠券/道具卡、净盈亏
---
## Bug 4: 资产搜索
### 后端修改
修改 `users_admin.go` 中的 `ListUserInventory`:
```go
type listInventoryRequest struct {
Page int `form:"page"`
PageSize int `form:"page_size"`
Keyword string `form:"keyword"` // 新增:搜索关键词
}
```
查询逻辑增加:
```go
if req.Keyword != "" {
query = query.Where(db.Products.Name.Like("%" + req.Keyword + "%"))
}
```
### 前端修改
`player-detail-drawer.vue` 资产Tab中:
1. 增加搜索输入框
2. 调用API时传递 `keyword` 参数
---
## 数据流图
```mermaid
sequenceDiagram
participant User as 用户
participant FE as 前端
participant BE as 后端
participant DB as 数据库
Note over User, DB: Bug 3: 盈亏明细查询
User->>FE: 点击"查看明细"
FE->>BE: GET /users/:id/stats/profit_loss_details
BE->>DB: 查询订单+奖品+优惠券+道具卡
DB-->>BE: 返回数据
BE-->>FE: JSON响应
FE-->>User: 展示明细列表
```
---
## 文件变更清单
| 文件 | 变更类型 | 说明 |
|------|---------|------|
| `web/admin/src/views/player-manage/index.vue` | 修改 | 修复分页问题 |
| `web/admin/src/hooks/core/useTable.ts` | 检查 | 确认分页逻辑 |
| `web/admin/src/views/player-manage/modules/player-profit-loss-chart.vue` | 修改 | 添加明细列表 |
| `web/admin/src/views/player-manage/modules/player-detail-drawer.vue` | 修改 | 添加资产搜索 |
| `web/admin/src/api/player-manage.ts` | 修改 | 添加明细API调用 |
| `internal/api/admin/users_profit_loss.go` | 修改 | 添加明细API |
| `internal/api/admin/users_admin.go` | 修改 | 资产列表添加搜索 |

View File

@ -1,121 +0,0 @@
# 抖音游戏翻牌特效需求对齐
## 原始需求
用户希望在 `douyin_game` 项目中开发一个翻牌 Web 应用,参考泡泡玛特直播间的翻牌抽盒效果。
## 参考截图分析
![参考图1](/Users/win/.gemini/antigravity/brain/192e2707-e873-48c7-9161-73a09b835351/uploaded_image_0_1768026980546.jpg)
![参考图2](/Users/win/.gemini/antigravity/brain/192e2707-e873-48c7-9161-73a09b835351/uploaded_image_1_1768026980546.jpg)
![参考图3](/Users/win/.gemini/antigravity/brain/192e2707-e873-48c7-9161-73a09b835351/uploaded_image_2_1768026980546.jpg)
### 核心功能分析
从截图中观察到以下特征:
1. **卡片网格布局**
- 3x4 的卡片网格(共 12 张卡片)
- 每张卡片显示挂件产品图片
- 绿色方格背景,白色卡片
2. **卡片状态**
- 未翻开状态:显示产品缩略图+用户头像+昵称+倒计时
- 翻开后状态:大图展示产品详情
3. **翻牌特效**图3展示
- 深色星空背景
- 星星闪烁粒子效果
- 产品大图居中展示
- 卡片 3D 翻转动画
4. **交互元素**
- 用户头像标识
- 昵称显示
- 抽取倒计时
---
## 边界确认
### 开发范围
- [x] 卡片网格布局 UI
- [x] 3D 翻牌动画效果
- [x] 星空背景特效
- [x] 粒子闪烁效果
- [x] 产品大图展示遮罩层
### 排除范围
- [ ] 后端抽盒逻辑(已有)
- [ ] 支付流程
- [ ] 用户身份认证
---
## 疑问澄清
> [!IMPORTANT]
> 以下问题需要用户确认
### 1. 项目位置
当前 `douyin_game` 目录为空,请确认:
- 是否在此目录新建独立项目?
- 还是集成到现有 `game/app` 项目中?
### 2. 技术栈选择
现有项目使用 **React + TypeScript + Vite + TailwindCSS**
- 是否沿用相同技术栈?
- 或者使用纯 HTML/CSS/JS 开发独立页面?
### 3. 数据来源
翻牌游戏的数据(产品信息、用户信息等):
- 是否需要对接后端 API
- 还是先开发静态演示版本?
### 4. 翻牌触发方式
用户如何触发翻牌:
- 点击自己预定的卡片?
- 观看他人翻牌的直播效果?
- 两者结合?
### 5. 特效细节偏好
关于"人物背后的翻牌特效",请确认:
- **星空背景**:是否需要动态渐变星空?
- **粒子效果**:闪烁星星数量和密度?
- **翻转动画**:水平翻转还是垂直翻转?
- **展示遮罩**:是否需要毛玻璃效果?
---
## 技术理解
### 现有项目分析
项目 `game/app` 技术栈:
- React 19 + TypeScript
- Vite 构建工具
- TailwindCSS 样式
- 已有丰富的 CSS 动画效果(`Explosion.css`
### 翻牌特效技术方案
| 特效组件 | 技术实现 |
|---------|---------|
| 3D 翻牌动画 | CSS `transform: rotateY()` + `perspective` |
| 星空背景 | 深色渐变 + CSS `radial-gradient` |
| 星星闪烁 | CSS `@keyframes` 动画 + 随机延迟 |
| 粒子效果 | Canvas API 或 CSS 伪元素 |
| 遮罩层 | `backdrop-filter: blur()` 毛玻璃效果 |
---
## 等待用户回复
上述疑问需要用户回复后才能进入架构设计阶段。

View File

@ -1,27 +0,0 @@
# 翻牌特效项目共识 (CONSENSUS)
## 需求描述
开发一个基于 React 的翻牌 Web 应用,模拟抽盒机的翻牌流程及特效。重点在于 3D 翻牌动画、星空粒子背景以及整体视觉体验。
## 验收标准
1. **网格布局**:实现 3x4 的响应式卡片网格。
2. **3D 翻牌**:卡片点击后执行平滑的 3D 翻转动画。
3. **特效层**:翻牌时伴随全屏星空背景和闪烁粒子特效。
4. **大图展示**:翻牌后产品大图居中弹出,具备毛玻璃遮罩。
5. **静态数据**:使用 Mock 数据驱动,包含产品图片、用户头像、昵称、倒计时。
## 技术方案
- **框架**React 19 + TypeScript
- **构建工具**Vite
- **样式**TailwindCSS + CSS Modules/Raw CSS (用于复杂动画)
- **动效库**Framer Motion (可选,若需更细腻控制) 或 纯 CSS 3D Transforms。
## 技术约束
- 纯前端实现,暂不对接后端接口。
- 代码部署在 `douyin_game` 目录下。
## 集成方案
- 作为一个独立项目在 `douyin_game` 中进行初始化。
## 风险与假设
- 所有图像资源(产品图、头像)暂时使用 generate_image 工具生成的占位图或默认素材。

View File

@ -1,53 +0,0 @@
# 翻牌特效架构设计 (DESIGN)
## 整体架构
项目采用单页应用架构,通过 React 状态驱动 UI 更新和动效触发。
```mermaid
graph TD
App[App Component] --> GameState[Game State: revealedCards, selectedId]
App --> StarLayer[StarryBackground Component]
App --> Grid[CardGrid Component]
Grid --> Card[FlipCard Component]
Card --> CardUI[Front: Avatar/Info | Back: ProductImg]
App --> Modal[ProductModal Component]
```
## 核心组件设计
### 1. FlipCard (翻牌组件)
- **Props**: `id`, `user`, `product`, `isRevealed`, `onFlip`.
- **CSS**:
- `.card-inner`: `transition: transform 0.6s; transform-style: preserve-3d;`
- `.card-front`, `.card-back`: `backface-visibility: hidden;`
### 2. StarryBackground (星空背景)
- 实现多层叠加背景:
- 底层:深蓝色渐变 `#0a0b1e` -> `#161b33`
- 中层静态微小星星CSS 粒状纹理)。
- 高层:关键帧动画模拟的闪烁星星(不同大小、延时)。
### 3. ProductModal (展示遮罩)
- 当 `selectedId` 存在时显示。
- **Style**: `fixed inset-0`, `backdrop-filter: blur(8px)`, `bg-black/40`
- **Animation**: 放大缩放并带有一圈光晕特效。
## 实现细节:粒子特效
当翻牌触发时,在卡片位置生成一组临时的粒子元素:
- 随机方向发射。
- 逐渐变小并透明。
- 使用 React `useState` 管理粒子生命周期。
## 目录结构 (douyin_game)
```text
src/
components/
CardGrid.tsx
FlipCard.tsx
StarryBackground.tsx
ProductModal.tsx
assets/
images/
App.tsx
index.css
```

View File

@ -1,37 +0,0 @@
# 翻牌特效原子任务 (TASK)
## 任务依赖图
```mermaid
graph TD
T1[T1: 初始化项目环境] --> T2[T2: 实现星空背景层]
T2 --> T3[T3: 开发 3D 翻牌组件]
T3 --> T4[T4: 组装网格与逻辑控制]
T4 --> T5[T5: 完善大图展示与粒子特效]
```
## 原子任务定义
### T1: 初始化项目环境
- **输入**: 空目录 `douyin_game`
- **输出**: Vite + React 项目骨架,安装 TailwindCSS
- **验收**: `npm run dev` 可正常启动
### T2: 实现星空背景层 (StarryBackground)
- **输入**: Tailwind 配置
- **输出**: 一个全屏背景组件,带有动态闪烁星星
- **验收**: 背景显示深蓝渐变,星星随机分布且有呼吸感
### T3: 开发 3D 翻牌组件 (FlipCard)
- **输入**: 基础 CSS 3D 知识
- **输出**: 支持正面(用户信息)和背面(产品图)切换的卡片
- **验收**: 点击触发平滑翻转,背面图片居中
### T4: 组装网格与逻辑控制 (CardGrid)
- **输入**: 3x4 布局需求
- **输出**: 一个包含 12 张卡片的网格,支持单次点击状态管理
- **验收**: 点击不同卡片各自翻转
### T5: 完善大图展示与粒子特效 (ProductModal)
- **输入**: 翻牌触发回调
- **输出**: 点击翻牌后弹出居中大图,背景变暗且带粒子飞散
- **验收**: 展示效果震撼,符合泡泡玛特直播间风格

View File

@ -1,98 +0,0 @@
package main
import (
"database/sql"
"fmt"
"log"
_ "github.com/go-sql-driver/mysql"
)
func main() {
// 连接数据库
dsn := "root:bindbox2025kdy@tcp(150.158.78.154:3306)/dev_game?parseTime=true"
db, err := sql.Open("mysql", dsn)
if err != nil {
log.Fatal("连接失败:", err)
}
defer db.Close()
fmt.Println("=== 优惠券 ID 275 当前状态 ===")
var ucID, userID, couponID int64
var status int32
var balanceAmount int64
var usedOrderID sql.NullInt64
var usedAt sql.NullTime
err = db.QueryRow("SELECT id, user_id, coupon_id, status, balance_amount, used_order_id, used_at FROM user_coupons WHERE id = 275").Scan(
&ucID, &userID, &couponID, &status, &balanceAmount, &usedOrderID, &usedAt,
)
if err != nil {
log.Println("查询优惠券失败:", err)
} else {
fmt.Printf("用户券ID: %d | 用户ID: %d | 模板ID: %d | 状态: %d | 余额(分): %d | 使用订单ID: %v | 使用时间: %v\n",
ucID, userID, couponID, status, balanceAmount, usedOrderID, usedAt)
}
// 查询系统券模板
fmt.Println("\n=== 系统优惠券模板 ===")
var scID int64
var scName string
var discountType int32
var discountValue int64
err = db.QueryRow("SELECT id, name, discount_type, discount_value FROM system_coupons WHERE id = ?", couponID).Scan(&scID, &scName, &discountType, &discountValue)
if err == nil {
fmt.Printf("模板ID: %d | 名称: %s | 类型: %d | 面值(分): %d\n", scID, scName, discountType, discountValue)
}
fmt.Println("\n=== 优惠券 ID 275 的所有流水记录 ===")
rows, err := db.Query(`
SELECT id, user_id, user_coupon_id, change_amount, balance_after, order_id, action, created_at
FROM user_coupon_ledger
WHERE user_coupon_id = 275
ORDER BY created_at DESC
`)
if err != nil {
log.Println("查询流水失败:", err)
} else {
defer rows.Close()
for rows.Next() {
var id, userID, userCouponID, changeAmount, balanceAfter, orderID int64
var action string
var createdAt sql.NullTime
rows.Scan(&id, &userID, &userCouponID, &changeAmount, &balanceAfter, &orderID, &action, &createdAt)
fmt.Printf("流水ID: %d | 变动: %d分 | 余额: %d分 | 订单ID: %d | 动作: %s | 时间: %v\n",
id, changeAmount, balanceAfter, orderID, action, createdAt)
}
}
fmt.Println("\n=== order_coupons 表中使用优惠券 275 的记录 ===")
rows2, err := db.Query(`
SELECT oc.id, oc.order_id, oc.user_coupon_id, oc.applied_amount, oc.created_at,
o.order_no, o.status, o.total_amount, o.discount_amount, o.actual_amount
FROM order_coupons oc
LEFT JOIN orders o ON o.id = oc.order_id
WHERE oc.user_coupon_id = 275
ORDER BY oc.created_at DESC
`)
if err != nil {
log.Println("查询 order_coupons 失败:", err)
} else {
defer rows2.Close()
for rows2.Next() {
var id, orderID, userCouponID, appliedAmount int64
var createdAt sql.NullTime
var orderNo sql.NullString
var orderStatus sql.NullInt32
var totalAmount, discountAmount, actualAmount sql.NullInt64
rows2.Scan(&id, &orderID, &userCouponID, &appliedAmount, &createdAt, &orderNo, &orderStatus, &totalAmount, &discountAmount, &actualAmount)
fmt.Printf("记录ID: %d | 订单ID: %d | 订单号: %v | 扣减金额: %d分 | 时间: %v\n",
id, orderID, orderNo.String, appliedAmount, createdAt)
}
}
// 计算总扣减金额
var totalApplied int64
db.QueryRow("SELECT COALESCE(SUM(applied_amount), 0) FROM order_coupons WHERE user_coupon_id = 275").Scan(&totalApplied)
fmt.Printf("\n=== 统计 ===\n优惠券 275 累计扣减: %d 分 (%.2f 元)\n", totalApplied, float64(totalApplied)/100)
fmt.Printf("当前余额: %d 分 (%.2f 元)\n", balanceAmount, float64(balanceAmount)/100)
}

View File

@ -1,36 +0,0 @@
package main
import (
"database/sql"
"fmt"
"log"
_ "github.com/go-sql-driver/mysql"
)
func main() {
dsn := "root:bindbox2025kdy@tcp(150.158.78.154:3306)/bindbox_game"
db, err := sql.Open("mysql", dsn)
if err != nil {
log.Fatal(err)
}
defer db.Close()
rows, err := db.Query("DESCRIBE douyin_orders")
if err != nil {
log.Fatal(err)
}
defer rows.Close()
fmt.Println("Table: douyin_orders")
fmt.Println("Field | Type | Null | Key | Default | Extra")
for rows.Next() {
var field, typ, null, key, extra string
var def sql.NullString
err := rows.Scan(&field, &typ, &null, &key, &def, &extra)
if err != nil {
log.Fatal(err)
}
fmt.Printf("%s | %s | %s | %s | %v | %s\n", field, typ, null, key, def.String, extra)
}
}

View File

@ -1,58 +0,0 @@
package main
import (
"database/sql"
"fmt"
"log"
_ "github.com/go-sql-driver/mysql"
)
func main() {
dsn := "root:bindbox2025kdy@tcp(150.158.78.154:3306)/bindbox_game"
db, err := sql.Open("mysql", dsn)
if err != nil {
log.Fatal(err)
}
defer db.Close()
// 1. 检查直播间抽奖是否有重复 (Draw Count > Product Count)
// 注意shop_order_id 是字符串join 时注意字符集,不过这里都是 utf8mb4 应该没问题
query := `
SELECT
o.shop_order_id,
o.product_count,
COUNT(l.id) as draw_count,
o.user_nickname
FROM douyin_orders o
JOIN livestream_draw_logs l ON CONVERT(o.shop_order_id USING utf8mb4) = CONVERT(l.shop_order_id USING utf8mb4)
GROUP BY o.shop_order_id, o.product_count, o.user_nickname
HAVING draw_count > o.product_count
`
rows, err := db.Query(query)
if err != nil {
log.Fatal(err)
}
defer rows.Close()
fmt.Println("--- Duplicate Rewards Check (Livestream) ---")
found := false
for rows.Next() {
var orderID, nickname string
var pCount, dCount int
if err := rows.Scan(&orderID, &pCount, &dCount, &nickname); err != nil {
log.Fatal(err)
}
fmt.Printf("Order: %s | Nickname: %s | Bought: %d | Issued: %d\n", orderID, nickname, pCount, dCount)
found = true
}
if !found {
fmt.Println("No duplicate rewards found in livestream_draw_logs.")
}
// 2. 额外检查:是否有同一个 shop_order_id 在极短时间内产生多条 log (并发问题特质)
// 这里简单检查是否有完全重复的 log (除了主键不同,其他关键字段相同)
// 或者检查是否有订单在非直播抽奖表也发了奖 (如果两边系统混用)
}

View File

@ -1,35 +0,0 @@
package main
import (
"database/sql"
"fmt"
"log"
_ "github.com/go-sql-driver/mysql"
)
func main() {
dsn := "root:bindbox2025kdy@tcp(150.158.78.154:3306)/bindbox_game"
db, err := sql.Open("mysql", dsn)
if err != nil {
log.Fatal(err)
}
defer db.Close()
rows, err := db.Query("SELECT shop_order_id, actual_pay_amount, actual_receive_amount, raw_data FROM douyin_orders ORDER BY id DESC LIMIT 5")
if err != nil {
log.Fatal(err)
}
defer rows.Close()
for rows.Next() {
var id string
var pay, recv int64
var raw string
err := rows.Scan(&id, &pay, &recv, &raw)
if err != nil {
log.Fatal(err)
}
fmt.Printf("ID: %s | Pay(DB): %d | Recv(DB): %d\nRaw: %s\n\n", id, pay, recv, raw)
}
}

View File

@ -1,311 +0,0 @@
package main
import (
"crypto/hmac"
"crypto/sha256"
"database/sql"
"encoding/binary"
"encoding/hex"
"fmt"
"log"
"time"
_ "github.com/go-sql-driver/mysql"
)
func main() {
dsn := "root:bindbox2025kdy@tcp(150.158.78.154:3306)/dev_game?parseTime=true"
db, err := sql.Open("mysql", dsn)
if err != nil {
log.Fatal("Connection failed:", err)
}
defer db.Close()
orderNo := "O20260125201015731"
fmt.Printf("Querying Order: %s\n", orderNo)
var id, userID int64
var status, sourceType int32
var total, discount, actual int64
var remark, createdAt string
// Check Order
err = db.QueryRow("SELECT id, user_id, status, source_type, total_amount, discount_amount, actual_amount, remark, created_at FROM orders WHERE order_no = ?", orderNo).
Scan(&id, &userID, &status, &sourceType, &total, &discount, &actual, &remark, &createdAt)
if err != nil {
log.Fatalf("Order not found: %v", err)
}
fmt.Printf("ID: %d\nUser: %d\nStatus: %d (1=Pending, 2=Paid)\nSourceType: %d\nTotal: %d\nDiscount: %d\nActual: %d\nRemark: %s\nCreated: %s\n",
id, userID, status, sourceType, total, discount, actual, remark, createdAt)
// Check Draw Logs
rows, err := db.Query("SELECT id, reward_id, is_winner, draw_index, created_at FROM activity_draw_logs WHERE order_id = ?", id)
if err != nil {
log.Fatal("Query logs failed:", err)
}
defer rows.Close()
fmt.Println("\n--- Draw Logs ---")
count := 0
for rows.Next() {
var logID, rID, dIdx int64
var isWinner int32
var ca time.Time
rows.Scan(&logID, &rID, &isWinner, &dIdx, &ca)
fmt.Printf("LogID: %d, RewardID: %d, IsWinner: %d, DrawIndex: %d, Time: %s\n", logID, rID, isWinner, dIdx, ca)
count++
}
if count == 0 {
fmt.Println("No draw logs found.")
}
// Check Inventory (Grants)
rowsInv, err := db.Query("SELECT id, reward_id, product_id, status FROM user_inventory WHERE order_id = ?", id)
if err != nil {
log.Fatal("Query inventory failed:", err)
}
defer rowsInv.Close()
fmt.Println("\n--- Inventory (Grants) ---")
invCount := 0
for rowsInv.Next() {
var invID, rID, pID int64
var s int
rowsInv.Scan(&invID, &rID, &pID, &s)
fmt.Printf("InvID: %d, RewardID: %d, ProductID: %d, Status: %d\n", invID, rID, pID, s)
invCount++
}
if invCount == 0 {
fmt.Println("No inventory grants found.")
}
// Check Issue 104
fmt.Println("\n--- Issue 104 ---")
var actID int64
var issueNumber string
err = db.QueryRow("SELECT activity_id, issue_number FROM activity_issues WHERE id = 104").Scan(&actID, &issueNumber)
if err != nil {
fmt.Printf("Issue query failed: %v\n", err)
} else {
fmt.Printf("Issue Number: %s, ActivityID: %d\n", issueNumber, actID)
// Query Activity by ID
var actName, playType string
err = db.QueryRow("SELECT name, play_type FROM activities WHERE id = ?", actID).Scan(&actName, &playType)
if err != nil {
fmt.Printf("Activity query failed: %v\n", err)
} else {
fmt.Printf("Activity: %s, PlayType: %s\n", actName, playType)
// Reconstruct Game
fmt.Println("\n--- Reconstructing Game ---")
// Fetch Draw Log
var drawLogID, issueID int64
err = db.QueryRow("SELECT id, issue_id FROM activity_draw_logs WHERE order_id = ?", id).Scan(&drawLogID, &issueID)
if err != nil {
log.Fatal("Draw log not found:", err)
}
// Fetch Receipt
var subSeedHex, position string
err = db.QueryRow("SELECT server_sub_seed, client_seed FROM activity_draw_receipts WHERE draw_log_id = ?", drawLogID).Scan(&subSeedHex, &position)
if err != nil {
log.Printf("Receipt not found: %v", err)
return
}
fmt.Printf("Receipt Found. SubSeed: %s, Position (ClientSeed): %s\n", subSeedHex, position)
serverSeed, err := hex.DecodeString(subSeedHex)
if err != nil {
log.Fatal("Invalid seed hex:", err)
}
// Fetch Card Configs
rowsCards, err := db.Query("SELECT code, name, quantity FROM matching_card_types WHERE status=1 ORDER BY sort ASC")
if err != nil {
log.Fatal("Card types query failed:", err)
}
defer rowsCards.Close()
type CardTypeConfig struct {
Code string
Name string
Quantity int32
}
var configs []CardTypeConfig
for rowsCards.Next() {
var c CardTypeConfig
rowsCards.Scan(&c.Code, &c.Name, &c.Quantity)
configs = append(configs, c)
}
// Create Game Logic
fmt.Println("Simulating Game Logic...")
cardIDCounter := int64(0)
type MatchingCard struct {
ID string
Type string
}
var deck []*MatchingCard
for _, cfg := range configs {
for i := int32(0); i < cfg.Quantity; i++ {
cardIDCounter++
deck = append(deck, &MatchingCard{
ID: fmt.Sprintf("c%d", cardIDCounter),
Type: cfg.Code,
})
}
}
// SecureShuffle
secureRandInt := func(max int, context string, nonce *int64) int {
*nonce++
message := fmt.Sprintf("%s|nonce:%d", context, *nonce)
mac := hmac.New(sha256.New, serverSeed)
mac.Write([]byte(message))
sum := mac.Sum(nil)
val := binary.BigEndian.Uint64(sum[:8])
return int(val % uint64(max))
}
nonce := int64(0)
n := len(deck)
for i := n - 1; i > 0; i-- {
j := secureRandInt(i+1, fmt.Sprintf("shuffle:%d", i), &nonce)
deck[i], deck[j] = deck[j], deck[i]
}
// Distribute to Board (first 9)
board := make([]*MatchingCard, 9)
for i := 0; i < 9; i++ {
if len(deck) > 0 {
board[i] = deck[0]
deck = deck[1:]
}
}
fmt.Printf("Board Types: ")
for _, c := range board {
if c != nil {
fmt.Printf("%s ", c.Type)
}
}
fmt.Println()
// SimulateMaxPairs
// Reconstruct allCards (Board + Deck)
allCards := make([]*MatchingCard, 0, len(board)+len(deck))
for _, c := range board {
if c != nil {
allCards = append(allCards, c)
}
}
allCards = append(allCards, deck...)
selectedType := position
hand := make([]*MatchingCard, 9)
copy(hand, allCards[:9])
deckIndex := 9
chance := int64(0)
for _, c := range hand {
if c != nil && c.Type == selectedType {
chance++
}
}
fmt.Printf("Selected Type: %s, Initial Chance: %d\n", selectedType, chance)
totalPairs := int64(0)
guard := 0
for guard < 1000 {
guard++
// canEliminate
counts := make(map[string]int)
pairType := ""
for _, c := range hand {
if c == nil {
continue
}
counts[c.Type]++
if counts[c.Type] >= 2 {
pairType = c.Type
break
}
}
if pairType != "" {
// Eliminate
first, second := -1, -1
for i, c := range hand {
if c == nil || c.Type != pairType {
continue
}
if first < 0 {
first = i
} else {
second = i
break
}
}
if first >= 0 && second >= 0 {
newHand := make([]*MatchingCard, 0, len(hand)-2)
for i, c := range hand {
if i != first && i != second {
newHand = append(newHand, c)
}
}
hand = newHand
totalPairs++
chance++
continue
}
}
// Draw
if chance > 0 && deckIndex < len(allCards) {
newCard := allCards[deckIndex]
hand = append(hand, newCard)
deckIndex++
chance--
continue
}
break
}
fmt.Printf("Simulation Finished. Total Pairs: %d\n", totalPairs)
// Check Rewards
rowsRewards, err := db.Query("SELECT id, min_score, product_id, quantity, level FROM activity_reward_settings WHERE issue_id = ?", issueID)
if err != nil {
log.Printf("Query rewards failed: %v", err)
} else {
defer rowsRewards.Close()
fmt.Println("\n--- Matching Rewards ---")
found := false
for rowsRewards.Next() {
var rwID, minScore, pID int64
var qty, level int32
rowsRewards.Scan(&rwID, &minScore, &pID, &qty, &level)
if int64(minScore) == totalPairs {
var pName string
_ = db.QueryRow("SELECT name FROM products WHERE id=?", pID).Scan(&pName)
fmt.Printf("MATCH! RewardID: %d, ProductID: %d (%s), Qty: %d, Level: %d\n", rwID, pID, pName, qty, level)
found = true
}
}
if !found {
fmt.Println("No matching reward found for this score.")
}
}
}
}
}

View File

@ -1,35 +0,0 @@
package main
import (
"database/sql"
"fmt"
"log"
_ "github.com/go-sql-driver/mysql"
)
func main() {
dsn := "root:bindbox2025kdy@tcp(150.158.78.154:3306)/bindbox_game"
db, err := sql.Open("mysql", dsn)
if err != nil {
log.Fatal(err)
}
defer db.Close()
sqls := []string{
"ALTER TABLE douyin_orders ADD COLUMN douyin_product_id VARCHAR(128) COMMENT '关联商品ID' AFTER shop_order_id",
"ALTER TABLE douyin_orders ADD COLUMN product_count BIGINT NOT NULL DEFAULT 1 COMMENT '商品数量' AFTER douyin_product_id",
"ALTER TABLE douyin_orders ADD COLUMN actual_pay_amount BIGINT DEFAULT 0 COMMENT '实付金额(分)' AFTER actual_receive_amount",
"ALTER TABLE douyin_orders ADD COLUMN reward_granted INT NOT NULL DEFAULT 0 COMMENT '奖励已发放' AFTER raw_data",
}
for _, s := range sqls {
fmt.Printf("Executing: %s\n", s)
_, err := db.Exec(s)
if err != nil {
fmt.Printf("Error executing %s: %v\n", s, err)
} else {
fmt.Println("Success")
}
}
}

View File

@ -1,33 +0,0 @@
package main
import (
"database/sql"
"fmt"
"log"
_ "github.com/go-sql-driver/mysql"
)
func main() {
dsn := "root:bindbox2025kdy@tcp(150.158.78.154:3306)/bindbox_game"
db, err := sql.Open("mysql", dsn)
if err != nil {
log.Fatal(err)
}
defer db.Close()
rows, err := db.Query("SELECT COLUMN_NAME FROM information_schema.COLUMNS WHERE TABLE_NAME='douyin_orders' AND TABLE_SCHEMA='bindbox_game'")
if err != nil {
log.Fatal(err)
}
defer rows.Close()
fmt.Println("Columns in douyin_orders:")
for rows.Next() {
var name string
if err := rows.Scan(&name); err != nil {
log.Fatal(err)
}
fmt.Printf("[%s] (len:%d)\n", name, len(name))
}
}

View File

@ -1,33 +0,0 @@
package main
import (
"database/sql"
"fmt"
"log"
_ "github.com/go-sql-driver/mysql"
)
func main() {
dsn := "root:bindbox2025kdy@tcp(150.158.78.154:3306)/bindbox_game"
db, err := sql.Open("mysql", dsn)
if err != nil {
log.Fatal(err)
}
defer db.Close()
rows, err := db.Query("SHOW TABLES")
if err != nil {
log.Fatal(err)
}
defer rows.Close()
fmt.Println("Tables in bindbox_game:")
for rows.Next() {
var name string
if err := rows.Scan(&name); err != nil {
log.Fatal(err)
}
fmt.Println(name)
}
}

View File

@ -1,26 +0,0 @@
package main
import (
"database/sql"
"fmt"
"log"
_ "github.com/go-sql-driver/mysql"
)
func main() {
dsn := "root:bindbox2025kdy@tcp(150.158.78.154:3306)/dev_game?parseTime=true"
db, err := sql.Open("mysql", dsn)
if err != nil {
log.Fatal("Connection failed:", err)
}
defer db.Close()
userID := 9116
var nickname, mobile string
err = db.QueryRow("SELECT nickname, mobile FROM users WHERE id = ?", userID).Scan(&nickname, &mobile)
if err != nil {
log.Fatal("Query failed:", err)
}
fmt.Printf("User ID: %d\nNickname: %s\nMobile: %s\n", userID, nickname, mobile)
}

View File

@ -1,28 +0,0 @@
package main
import (
"database/sql"
"fmt"
"log"
_ "github.com/go-sql-driver/mysql"
)
func main() {
dsn := "root:bindbox2025kdy@tcp(150.158.78.154:3306)/bindbox_game"
db, err := sql.Open("mysql", dsn)
if err != nil {
log.Fatal(err)
}
defer db.Close()
shopOrderID := "6950061667188872453"
var rawData string
err = db.QueryRow("SELECT raw_data FROM douyin_orders WHERE shop_order_id=?", shopOrderID).Scan(&rawData)
if err != nil {
log.Fatal(err)
}
fmt.Println("Raw Data for Order 6950061667188872453:")
fmt.Println(rawData)
}

View File

@ -1,362 +0,0 @@
package main
import (
"context"
"database/sql"
"fmt"
"os"
"time"
_ "github.com/go-sql-driver/mysql"
)
// 测试优惠券预扣机制的各种场景
// 数据库连接配置来自 configs/dev_configs.toml
const (
// 从 dev_configs.toml [mysql.read] 读取
dsn = "root:bindbox2025kdy@tcp(150.158.78.154:3306)/dev_game?parseTime=true&loc=Local"
)
var db *sql.DB
func main() {
var err error
db, err = sql.Open("mysql", dsn)
if err != nil {
fmt.Printf("❌ 数据库连接失败: %v\n", err)
os.Exit(1)
}
defer db.Close()
if err = db.Ping(); err != nil {
fmt.Printf("❌ 数据库 Ping 失败: %v\n", err)
os.Exit(1)
}
fmt.Println("========================================")
fmt.Println("优惠券预扣机制测试")
fmt.Println("========================================")
// 准备测试数据
testUserID := int64(99999) // 测试用户
testCouponValue := int64(500) // 5元 = 500分
// 清理之前的测试数据
cleanup(testUserID)
// 创建测试优惠券模板和用户券
systemCouponID := createTestSystemCoupon(testCouponValue)
if systemCouponID == 0 {
fmt.Println("❌ 创建测试优惠券模板失败")
return
}
fmt.Println("\n========================================")
fmt.Println("测试场景 1: 正常下单→支付流程")
fmt.Println("========================================")
testNormalFlow(testUserID, systemCouponID, testCouponValue)
fmt.Println("\n========================================")
fmt.Println("测试场景 2: 下单→取消流程")
fmt.Println("========================================")
testCancelFlow(testUserID, systemCouponID, testCouponValue)
fmt.Println("\n========================================")
fmt.Println("测试场景 3: 并发下单(同一优惠券)")
fmt.Println("========================================")
testConcurrentFlow(testUserID, systemCouponID, testCouponValue)
fmt.Println("\n========================================")
fmt.Println("测试场景 4: 金额券部分使用")
fmt.Println("========================================")
testPartialUse(testUserID, systemCouponID, testCouponValue)
fmt.Println("\n========================================")
fmt.Println("测试场景 5: 状态查询兼容性")
fmt.Println("========================================")
testStatusQueries(testUserID, systemCouponID)
// 清理测试数据
cleanup(testUserID)
cleanupSystemCoupon(systemCouponID)
fmt.Println("\n========================================")
fmt.Println("所有测试完成!")
fmt.Println("========================================")
}
func cleanup(userID int64) {
db.Exec("DELETE FROM order_coupons WHERE order_id IN (SELECT id FROM orders WHERE user_id = ?)", userID)
db.Exec("DELETE FROM user_coupon_ledger WHERE user_id = ?", userID)
db.Exec("DELETE FROM orders WHERE user_id = ?", userID)
db.Exec("DELETE FROM user_coupons WHERE user_id = ?", userID)
}
func cleanupSystemCoupon(id int64) {
db.Exec("DELETE FROM system_coupons WHERE id = ?", id)
}
func createTestSystemCoupon(value int64) int64 {
res, err := db.Exec(`
INSERT INTO system_coupons
(name, discount_type, discount_value, min_spend, scope_type, status, created_at, updated_at)
VALUES ('测试金额券', 1, ?, 0, 1, 1, NOW(), NOW())
`, value)
if err != nil {
fmt.Printf("❌ 创建优惠券模板失败: %v\n", err)
return 0
}
id, _ := res.LastInsertId()
fmt.Printf("✅ 创建测试优惠券模板 ID=%d 面值=%d分\n", id, value)
return id
}
func createTestUserCoupon(userID int64, systemCouponID int64, balance int64) int64 {
validEnd := time.Now().Add(24 * time.Hour)
res, err := db.Exec(`
INSERT INTO user_coupons
(user_id, coupon_id, balance_amount, status, valid_start, valid_end, created_at, updated_at)
VALUES (?, ?, ?, 1, NOW(), ?, NOW(), NOW())
`, userID, systemCouponID, balance, validEnd)
if err != nil {
fmt.Printf("❌ 创建用户优惠券失败: %v\n", err)
return 0
}
id, _ := res.LastInsertId()
return id
}
func getCouponStatus(userCouponID int64) (status int32, balance int64) {
db.QueryRow("SELECT status, balance_amount FROM user_coupons WHERE id = ?", userCouponID).Scan(&status, &balance)
return
}
// 测试场景 1: 正常下单→支付流程
func testNormalFlow(userID int64, systemCouponID int64, couponValue int64) {
userCouponID := createTestUserCoupon(userID, systemCouponID, couponValue)
if userCouponID == 0 {
return
}
fmt.Printf("✅ 创建用户优惠券 ID=%d 余额=%d分 状态=1(未使用)\n", userCouponID, couponValue)
status, balance := getCouponStatus(userCouponID)
assert("初始状态", status == 1 && balance == couponValue, fmt.Sprintf("status=%d balance=%d", status, balance))
// 模拟下单预扣
orderID := createTestOrder(userID, userCouponID, 200) // 扣200分
if orderID == 0 {
return
}
simulatePreDeduct(userCouponID, 200)
status, balance = getCouponStatus(userCouponID)
assert("下单后预扣", status == 4 && balance == couponValue-200, fmt.Sprintf("status=%d balance=%d", status, balance))
// 模拟支付成功确认
simulatePayConfirm(userCouponID, balance)
status, balance = getCouponStatus(userCouponID)
assert("支付确认后", (status == 1 || status == 2) && balance == couponValue-200, fmt.Sprintf("status=%d balance=%d", status, balance))
}
// 测试场景 2: 下单→取消流程
func testCancelFlow(userID int64, systemCouponID int64, couponValue int64) {
userCouponID := createTestUserCoupon(userID, systemCouponID, couponValue)
if userCouponID == 0 {
return
}
fmt.Printf("✅ 创建用户优惠券 ID=%d 余额=%d分\n", userCouponID, couponValue)
// 模拟下单预扣
orderID := createTestOrder(userID, userCouponID, 300)
if orderID == 0 {
return
}
simulatePreDeduct(userCouponID, 300)
status, balance := getCouponStatus(userCouponID)
assert("下单后", status == 4 && balance == couponValue-300, fmt.Sprintf("status=%d balance=%d", status, balance))
// 模拟取消订单,恢复优惠券
simulateCancelRestore(userCouponID, 300)
status, balance = getCouponStatus(userCouponID)
assert("取消后恢复", status == 1 && balance == couponValue, fmt.Sprintf("status=%d balance=%d", status, balance))
}
// 测试场景 3: 并发下单(同一优惠券)
func testConcurrentFlow(userID int64, systemCouponID int64, couponValue int64) {
userCouponID := createTestUserCoupon(userID, systemCouponID, couponValue) // 500分
if userCouponID == 0 {
return
}
fmt.Printf("✅ 创建用户优惠券 ID=%d 余额=%d分\n", userCouponID, couponValue)
// 模拟两个并发请求,都尝试使用全部余额
// 第一个应该成功,第二个应该失败
// 使用事务模拟并发操作
ctx := context.Background()
tx1, err := db.BeginTx(ctx, nil)
if err != nil {
fmt.Printf("❌ 事务1创建失败: %v\n", err)
return
}
tx2, err := db.BeginTx(ctx, nil)
if err != nil {
tx1.Rollback()
fmt.Printf("❌ 事务2创建失败: %v\n", err)
return
}
// 事务1: 原子预扣 (应该成功)
res1, err1 := tx1.Exec(`
UPDATE user_coupons
SET balance_amount = balance_amount - ?,
status = 4
WHERE id = ? AND balance_amount >= ? AND status IN (1, 4)
`, couponValue, userCouponID, couponValue)
var affected1 int64
if err1 == nil && res1 != nil {
affected1, _ = res1.RowsAffected()
}
success1 := err1 == nil && affected1 > 0
// 提交事务1
tx1.Commit()
// 事务2: 原子预扣 (应该失败,余额不足)
res2, err2 := tx2.Exec(`
UPDATE user_coupons
SET balance_amount = balance_amount - ?,
status = 4
WHERE id = ? AND balance_amount >= ? AND status IN (1, 4)
`, couponValue, userCouponID, couponValue)
var affected2 int64
if err2 == nil && res2 != nil {
affected2, _ = res2.RowsAffected()
}
success2 := err2 == nil && affected2 > 0
tx2.Commit()
assert("并发测试: 第一个成功", success1, fmt.Sprintf("err=%v affected=%d", err1, affected1))
assert("并发测试: 第二个失败", !success2, fmt.Sprintf("err=%v affected=%d", err2, affected2))
}
// 测试场景 4: 金额券部分使用
func testPartialUse(userID int64, systemCouponID int64, couponValue int64) {
userCouponID := createTestUserCoupon(userID, systemCouponID, couponValue) // 500分
if userCouponID == 0 {
return
}
fmt.Printf("✅ 创建用户优惠券 ID=%d 余额=%d分\n", userCouponID, couponValue)
// 第一次使用200分
createTestOrder(userID, userCouponID, 200)
simulatePreDeduct(userCouponID, 200)
simulatePayConfirm(userCouponID, couponValue-200)
status, balance := getCouponStatus(userCouponID)
assert("第一次使用后", status == 1 && balance == 300, fmt.Sprintf("status=%d balance=%d", status, balance))
// 第二次使用150分
createTestOrder(userID, userCouponID, 150)
simulatePreDeduct(userCouponID, 150)
simulatePayConfirm(userCouponID, 150)
status, balance = getCouponStatus(userCouponID)
assert("第二次使用后", status == 1 && balance == 150, fmt.Sprintf("status=%d balance=%d", status, balance))
// 第三次用完剩余
createTestOrder(userID, userCouponID, 150)
simulatePreDeduct(userCouponID, 150)
simulatePayConfirm(userCouponID, 0)
status, balance = getCouponStatus(userCouponID)
assert("用完后", status == 2 && balance == 0, fmt.Sprintf("status=%d balance=%d", status, balance))
}
// 测试场景 5: 状态查询兼容性
func testStatusQueries(userID int64, systemCouponID int64) {
// 创建不同状态的优惠券
uc1 := createTestUserCoupon(userID, systemCouponID, 100) // status=1
uc2 := createTestUserCoupon(userID, systemCouponID, 200)
simulatePreDeduct(uc2, 100) // status=4
uc3 := createTestUserCoupon(userID, systemCouponID, 300)
db.Exec("UPDATE user_coupons SET status = 2 WHERE id = ?", uc3) // status=2
// 测试 applyCouponWithCap 的查询条件 (应该能找到 status IN (1, 4))
var count int
db.QueryRow(`
SELECT COUNT(*) FROM user_coupons
WHERE user_id = ? AND status IN (1, 4)
`, userID).Scan(&count)
assert("IN (1,4) 查询", count >= 2, fmt.Sprintf("count=%d (应>=2)", count))
// 测试 expiration 的查询条件 (应该包含 status IN (1, 2, 4))
db.QueryRow(`
SELECT COUNT(*) FROM user_coupons
WHERE user_id = ? AND status IN (1, 2, 4)
`, userID).Scan(&count)
assert("IN (1,2,4) 查询", count >= 3, fmt.Sprintf("count=%d (应>=3)", count))
fmt.Printf("✅ 优惠券 ID %d (status=1), %d (status=4), %d (status=2)\n", uc1, uc2, uc3)
}
// 辅助函数
func createTestOrder(userID int64, couponID int64, amount int64) int64 {
orderNo := fmt.Sprintf("TEST%d", time.Now().UnixNano())
res, err := db.Exec(`
INSERT INTO orders
(user_id, order_no, source_type, total_amount, discount_amount, actual_amount, coupon_id, status, created_at, updated_at)
VALUES (?, ?, 2, ?, ?, ?, ?, 1, NOW(), NOW())
`, userID, orderNo, 1000, amount, 1000-amount, couponID)
if err != nil {
fmt.Printf("❌ 创建订单失败: %v\n", err)
return 0
}
id, _ := res.LastInsertId()
// 记录 order_coupons
db.Exec(`INSERT INTO order_coupons (order_id, user_coupon_id, applied_amount, created_at) VALUES (?, ?, ?, NOW())`, id, couponID, amount)
return id
}
func simulatePreDeduct(userCouponID int64, amount int64) {
db.Exec(`
UPDATE user_coupons
SET balance_amount = balance_amount - ?,
status = 4
WHERE id = ? AND balance_amount >= ?
`, amount, userCouponID, amount)
}
func simulatePayConfirm(userCouponID int64, newBalance int64) {
newStatus := 1
if newBalance <= 0 {
newStatus = 2
}
db.Exec(`UPDATE user_coupons SET status = ? WHERE id = ? AND status = 4`, newStatus, userCouponID)
}
func simulateCancelRestore(userCouponID int64, amount int64) {
db.Exec(`
UPDATE user_coupons
SET balance_amount = balance_amount + ?,
status = 1
WHERE id = ? AND status = 4
`, amount, userCouponID)
}
func assert(name string, condition bool, detail string) {
if condition {
fmt.Printf("✅ %s: PASS (%s)\n", name, detail)
} else {
fmt.Printf("❌ %s: FAIL (%s)\n", name, detail)
}
}

View File

@ -1,24 +0,0 @@
package main
import (
"fmt"
"math/rand"
"time"
)
func generateOrderNo() string {
// 使用当前时间戳 + 随机数生成订单号
// 格式RG + 年月日时分秒 + 6位随机数
r := rand.New(rand.NewSource(time.Now().UnixNano()))
return fmt.Sprintf("RG%s%06d",
time.Now().Format("20060102150405"),
r.Intn(1000000),
)
}
func main() {
for i := 0; i < 10; i++ {
fmt.Println(generateOrderNo())
time.Sleep(1 * time.Millisecond)
}
}

View File

@ -1,28 +0,0 @@
package main
import (
"database/sql"
"fmt"
"log"
_ "github.com/go-sql-driver/mysql"
)
func main() {
dsn := "root:bindbox2025kdy@tcp(150.158.78.154:3306)/bindbox_game"
db, err := sql.Open("mysql", dsn)
if err != nil {
log.Fatal(err)
}
defer db.Close()
// 尝试更新一个已存在的订单 (从日志中找一个 ID)
shopOrderID := "6950057259045885189"
res, err := db.Exec("UPDATE douyin_orders SET actual_pay_amount=100, actual_receive_amount=100 WHERE shop_order_id=?", shopOrderID)
if err != nil {
fmt.Printf("Update failed: %v\n", err)
} else {
affected, _ := res.RowsAffected()
fmt.Printf("Update success, rows affected: %d\n", affected)
}
}

View File

@ -1,42 +0,0 @@
package main
import (
"database/sql"
"fmt"
"log"
_ "github.com/go-sql-driver/mysql"
)
func main() {
// 连接数据库
dsn := "root:bindbox2025kdy@tcp(150.158.78.154:3306)/dev_game?parseTime=true"
db, err := sql.Open("mysql", dsn)
if err != nil {
log.Fatal("连接失败:", err)
}
defer db.Close()
// 1. 重置优惠券状态用于测试
// 注意:这里为了不破坏现场,我们创建一个新的优惠券用于测试
// 或者,如果用户允许,我们可以修复已经超额的优惠券?
// 这里我们只观察,不测试下单(因为需要调用 API
// 但我们可以手动验证 "Mock" ApplyCouponWithCap 逻辑。
fmt.Println("此脚本仅打印当前优惠券 275 的状态,请手动进行下单测试验证修复效果。")
// 查询优惠券状态
var bal int64
var status int32
err = db.QueryRow("SELECT balance_amount, status FROM user_coupons WHERE id = 275").Scan(&bal, &status)
if err != nil {
log.Fatal(err)
}
fmt.Printf("当前优惠券余额: %d 分, 状态: %d\n", bal, status)
if bal > 0 {
fmt.Println("余额 > 0正常情况。请尝试下单直到余额耗尽再次下单应失败或不使用优惠券。")
} else {
fmt.Println("余额 <= 0修复前这会导致重新使用 5000 面值。修复后应该无法使用。")
}
}