delete
This commit is contained in:
parent
ff404e21f0
commit
f8624cca49
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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.")
|
||||
}
|
||||
}
|
||||
@ -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"])
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
@ -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"])
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
@ -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"])
|
||||
}
|
||||
}
|
||||
@ -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============================================")
|
||||
}
|
||||
@ -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"])
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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")
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
@ -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"])
|
||||
}
|
||||
}
|
||||
@ -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"])
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
@ -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"])
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
@ -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")
|
||||
}
|
||||
@ -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"])
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
@ -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).")
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
@ -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"
|
||||
```
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
@ -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, "")
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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))
|
||||
}
|
||||
@ -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 参数执行实际迁移")
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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).")
|
||||
}
|
||||
}
|
||||
@ -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 := ¬ify.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:]
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
@ -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"])
|
||||
}
|
||||
}
|
||||
@ -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.")
|
||||
}
|
||||
}
|
||||
@ -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)?还是只过滤软删除的活动?
|
||||
@ -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 和磁盘的影响极小,完全适合本地开发和中小型服务器部署。
|
||||
@ -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,确保无人(包括管理员)能提前知晓排列。
|
||||
@ -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。
|
||||
@ -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. **验证**: 检查某测试账号积分是否符合预期。
|
||||
@ -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)。
|
||||
@ -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.
|
||||
@ -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**.
|
||||
@ -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。
|
||||
@ -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 逻辑需升级以支持多卡/多数量退还。
|
||||
@ -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".
|
||||
@ -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. 交付结论
|
||||
以完成所有关键路径的修复,代码已准备就绪,可以部署测试。
|
||||
@ -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).
|
||||
@ -1,12 +0,0 @@
|
||||
# 待办事项:一番赏次数卡修复后续
|
||||
|
||||
## 1. 测试与验证
|
||||
- [ ] **真机验证**: 需要在真机小程序环境测试使用次数卡购买一番赏,确认是否直接跳过支付。
|
||||
- [ ] **退款验证**: 需要在管理后台对生成的次数卡订单进行退款,并检查数据库 `user_game_passes` 表确认次数是否正确恢复。
|
||||
|
||||
## 2. 遗留/潜在问题
|
||||
- [ ] **历史订单处理**: 此修复仅对新生成的订单生效(因为旧订单缺少 `gp_use:ID:Count` 记录)。旧订单退款仍将使用旧逻辑(仅退 1 次)。如果需要处理大量历史订单的批量退款,建议通过 SQL 手动修复或编写一次性脚本。
|
||||
- [ ] **多端兼容**: 目前仅修改了 `bindbox-mini`(小程序端)。如果存在 App 端或 H5 端,需确认是否复用相同逻辑或需要单独修改。
|
||||
|
||||
## 3. 配置建议
|
||||
- 无新增配置项。
|
||||
@ -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)
|
||||
@ -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` |
|
||||
@ -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** 资产搜索:
|
||||
- 支持哪些搜索条件?商品名称?订单号?
|
||||
- 是否需要后端支持模糊搜索?
|
||||
@ -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**: 用户资产列表可以按商品名称搜索
|
||||
@ -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` | 修改 | 资产列表添加搜索 |
|
||||
@ -1,121 +0,0 @@
|
||||
# 抖音游戏翻牌特效需求对齐
|
||||
|
||||
## 原始需求
|
||||
|
||||
用户希望在 `douyin_game` 项目中开发一个翻牌 Web 应用,参考泡泡玛特直播间的翻牌抽盒效果。
|
||||
|
||||
## 参考截图分析
|
||||
|
||||

|
||||

|
||||

|
||||
|
||||
### 核心功能分析
|
||||
|
||||
从截图中观察到以下特征:
|
||||
|
||||
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()` 毛玻璃效果 |
|
||||
|
||||
---
|
||||
|
||||
## 等待用户回复
|
||||
|
||||
上述疑问需要用户回复后才能进入架构设计阶段。
|
||||
@ -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 工具生成的占位图或默认素材。
|
||||
@ -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
|
||||
```
|
||||
@ -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)
|
||||
- **输入**: 翻牌触发回调
|
||||
- **输出**: 点击翻牌后弹出居中大图,背景变暗且带粒子飞散
|
||||
- **验收**: 展示效果震撼,符合泡泡玛特直播间风格
|
||||
@ -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)
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
@ -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 (除了主键不同,其他关键字段相同)
|
||||
// 或者检查是否有订单在非直播抽奖表也发了奖 (如果两边系统混用)
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
@ -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.")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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))
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
@ -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 面值。修复后应该无法使用。")
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user