feat: 新增抖音登录功能、管理端次数卡及套餐管理接口,并引入配置迁移工具。
This commit is contained in:
parent
dc1b324aef
commit
269bdb9fd1
155
cmd/migrate_configs/main.go
Normal file
155
cmd/migrate_configs/main.go
Normal file
@ -0,0 +1,155 @@
|
||||
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 参数执行实际迁移")
|
||||
}
|
||||
}
|
||||
263
cmd/tools/task_center_test/integration.go
Normal file
263
cmd/tools/task_center_test/integration.go
Normal file
@ -0,0 +1,263 @@
|
||||
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
|
||||
}
|
||||
477
cmd/tools/task_center_test/main.go
Normal file
477
cmd/tools/task_center_test/main.go
Normal file
@ -0,0 +1,477 @@
|
||||
// 任务中心配置组合测试工具
|
||||
// 功能:
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
@ -83,6 +83,12 @@ type Config struct {
|
||||
Internal struct {
|
||||
ApiKey string `mapstructure:"api_key" toml:"api_key"`
|
||||
} `mapstructure:"internal" toml:"internal"`
|
||||
|
||||
Douyin struct {
|
||||
AppID string `mapstructure:"app_id" toml:"app_id"`
|
||||
AppSecret string `mapstructure:"app_secret" toml:"app_secret"`
|
||||
NotifyURL string `mapstructure:"notify_url" toml:"notify_url"`
|
||||
} `mapstructure:"douyin" toml:"douyin"`
|
||||
}
|
||||
|
||||
var (
|
||||
|
||||
2687
docs/docs.go
2687
docs/docs.go
File diff suppressed because it is too large
Load Diff
2687
docs/swagger.json
2687
docs/swagger.json
File diff suppressed because it is too large
Load Diff
1758
docs/swagger.yaml
1758
docs/swagger.yaml
File diff suppressed because it is too large
Load Diff
@ -5,6 +5,7 @@ import (
|
||||
"bindbox-game/internal/repository/mysql"
|
||||
"bindbox-game/internal/repository/mysql/dao"
|
||||
activitysvc "bindbox-game/internal/service/activity"
|
||||
tasksvc "bindbox-game/internal/service/task_center"
|
||||
titlesvc "bindbox-game/internal/service/title"
|
||||
usersvc "bindbox-game/internal/service/user"
|
||||
|
||||
@ -19,11 +20,12 @@ type handler struct {
|
||||
title titlesvc.Service
|
||||
repo mysql.Repo
|
||||
user usersvc.Service
|
||||
task tasksvc.Service
|
||||
redis *redis.Client
|
||||
activityOrder activitysvc.ActivityOrderService // 活动订单服务
|
||||
}
|
||||
|
||||
func New(logger logger.CustomLogger, db mysql.Repo, rdb *redis.Client) *handler {
|
||||
func New(logger logger.CustomLogger, db mysql.Repo, rdb *redis.Client, task tasksvc.Service) *handler {
|
||||
userSvc := usersvc.New(logger, db)
|
||||
return &handler{
|
||||
logger: logger,
|
||||
@ -33,6 +35,7 @@ func New(logger logger.CustomLogger, db mysql.Repo, rdb *redis.Client) *handler
|
||||
title: titlesvc.New(logger, db),
|
||||
repo: db,
|
||||
user: userSvc,
|
||||
task: task,
|
||||
redis: rdb,
|
||||
activityOrder: activitysvc.NewActivityOrderService(logger, db),
|
||||
}
|
||||
|
||||
@ -6,6 +6,7 @@ import (
|
||||
"bindbox-game/internal/pkg/validation"
|
||||
"bindbox-game/internal/repository/mysql/dao"
|
||||
"bindbox-game/internal/repository/mysql/model"
|
||||
"context"
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
@ -22,23 +23,26 @@ import (
|
||||
)
|
||||
|
||||
type joinLotteryRequest struct {
|
||||
ActivityID int64 `json:"activity_id"`
|
||||
IssueID int64 `json:"issue_id"`
|
||||
Count int64 `json:"count"`
|
||||
Channel string `json:"channel"`
|
||||
SlotIndex []int64 `json:"slot_index"`
|
||||
CouponID *int64 `json:"coupon_id"`
|
||||
ItemCardID *int64 `json:"item_card_id"`
|
||||
UsePoints *int64 `json:"use_points"`
|
||||
ActivityID int64 `json:"activity_id"`
|
||||
IssueID int64 `json:"issue_id"`
|
||||
Count int64 `json:"count"`
|
||||
Channel string `json:"channel"`
|
||||
SlotIndex []int64 `json:"slot_index"`
|
||||
CouponID *int64 `json:"coupon_id"`
|
||||
ItemCardID *int64 `json:"item_card_id"`
|
||||
UsePoints *int64 `json:"use_points"`
|
||||
UseGamePass *bool `json:"use_game_pass"`
|
||||
}
|
||||
|
||||
type joinLotteryResponse struct {
|
||||
JoinID string `json:"join_id"`
|
||||
OrderNo string `json:"order_no"`
|
||||
Queued bool `json:"queued"`
|
||||
DrawMode string `json:"draw_mode"`
|
||||
RewardID int64 `json:"reward_id,omitempty"`
|
||||
RewardName string `json:"reward_name,omitempty"`
|
||||
JoinID string `json:"join_id"`
|
||||
OrderNo string `json:"order_no"`
|
||||
Queued bool `json:"queued"`
|
||||
DrawMode string `json:"draw_mode"`
|
||||
RewardID int64 `json:"reward_id,omitempty"`
|
||||
RewardName string `json:"reward_name,omitempty"`
|
||||
ActualAmount int64 `json:"actual_amount"`
|
||||
Status int32 `json:"status"`
|
||||
}
|
||||
|
||||
// JoinLottery 用户参与抽奖
|
||||
@ -121,6 +125,12 @@ func (h *handler) JoinLottery() core.HandlerFunc {
|
||||
order.PointsLedgerID = 0
|
||||
order.ActualAmount = order.TotalAmount
|
||||
applied := int64(0)
|
||||
// Game Pass Conflict Check: If using Game Pass, do NOT allow coupons.
|
||||
isUsingGamePass := req.UseGamePass != nil && *req.UseGamePass
|
||||
if isUsingGamePass {
|
||||
req.CouponID = nil
|
||||
}
|
||||
|
||||
if activity.AllowCoupons && req.CouponID != nil && *req.CouponID > 0 {
|
||||
order.CouponID = *req.CouponID
|
||||
applied = h.applyCouponWithCap(ctx, userID, order, req.ActivityID, *req.CouponID)
|
||||
@ -179,9 +189,105 @@ func (h *handler) JoinLottery() core.HandlerFunc {
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Check Game Pass (Pre-check)
|
||||
// We will do the actual deduction inside transaction, but we can fail fast here or setup variables.
|
||||
useGamePass := false
|
||||
if req.UseGamePass != nil && *req.UseGamePass {
|
||||
// Check if user has enough valid passes
|
||||
// Note: We need to find specific passes to deduct.
|
||||
// Logic: Find all valid passes, sort by activity specific first, then expire soonest?
|
||||
// Matching game logic: "ActivityID Desc" (Specific first)
|
||||
count := int(c)
|
||||
validPasses, err := h.writeDB.UserGamePasses.WithContext(ctx.RequestContext()).
|
||||
Where(h.writeDB.UserGamePasses.UserID.Eq(userID)).
|
||||
Where(h.writeDB.UserGamePasses.Remaining.Gt(0)).
|
||||
Where(h.writeDB.UserGamePasses.ActivityID.In(0, req.ActivityID)).
|
||||
Order(h.writeDB.UserGamePasses.ActivityID.Desc(), h.writeDB.UserGamePasses.ExpiredAt.Asc()). // 优先专用,然后优先过期
|
||||
Find()
|
||||
|
||||
if err != nil {
|
||||
ctx.AbortWithError(core.Error(http.StatusInternalServerError, code.ServerError, err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
totalAvailable := 0
|
||||
now := time.Now()
|
||||
for _, p := range validPasses {
|
||||
if p.ExpiredAt.IsZero() || p.ExpiredAt.After(now) {
|
||||
totalAvailable += int(p.Remaining)
|
||||
}
|
||||
}
|
||||
|
||||
if totalAvailable < count {
|
||||
ctx.AbortWithError(core.Error(http.StatusBadRequest, 170012, "次数卡余额不足"))
|
||||
return
|
||||
}
|
||||
useGamePass = true
|
||||
}
|
||||
|
||||
h.logger.Info(fmt.Sprintf("JoinLottery Tx Start: UserID=%d", userID))
|
||||
err = h.writeDB.Transaction(func(tx *dao.Query) error {
|
||||
if req.UsePoints != nil && *req.UsePoints > 0 {
|
||||
// Handle Game Pass Deduction
|
||||
if useGamePass {
|
||||
count := int(c)
|
||||
validPasses, _ := tx.UserGamePasses.WithContext(ctx.RequestContext()).
|
||||
Clauses(clause.Locking{Strength: "UPDATE"}).
|
||||
Where(tx.UserGamePasses.UserID.Eq(userID)).
|
||||
Where(tx.UserGamePasses.Remaining.Gt(0)).
|
||||
Where(tx.UserGamePasses.ActivityID.In(0, req.ActivityID)).
|
||||
Order(tx.UserGamePasses.ActivityID.Desc(), tx.UserGamePasses.ExpiredAt.Asc()).
|
||||
Find()
|
||||
|
||||
now := time.Now()
|
||||
deducted := 0
|
||||
for _, p := range validPasses {
|
||||
if deducted >= count {
|
||||
break
|
||||
}
|
||||
if !p.ExpiredAt.IsZero() && p.ExpiredAt.Before(now) {
|
||||
continue
|
||||
}
|
||||
|
||||
canDeduct := int(p.Remaining)
|
||||
if canDeduct > (count - deducted) {
|
||||
canDeduct = count - deducted
|
||||
}
|
||||
|
||||
// Update pass
|
||||
if _, err := tx.UserGamePasses.WithContext(ctx.RequestContext()).
|
||||
Where(tx.UserGamePasses.ID.Eq(p.ID)).
|
||||
Updates(map[string]any{
|
||||
"remaining": p.Remaining - int32(canDeduct),
|
||||
"total_used": p.TotalUsed + int32(canDeduct),
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
deducted += canDeduct
|
||||
}
|
||||
|
||||
if deducted < count {
|
||||
return errors.New("次数卡余额不足")
|
||||
}
|
||||
|
||||
// Set Order to be fully paid by Game Pass
|
||||
order.ActualAmount = 0
|
||||
order.SourceType = 4 // Cleanly mark as Game Pass source
|
||||
|
||||
// existing lottery logic sets SourceType based on "h.orderModel" which defaults to something?
|
||||
// h.orderModel(..., c) implementation needs to be checked or inferred.
|
||||
// Assuming orderModel sets SourceType based on activity or defaults.
|
||||
// Let's explicitly mark it or rely on Remark.
|
||||
if order.Remark == "" {
|
||||
order.Remark = "use_game_pass"
|
||||
} else {
|
||||
order.Remark += "|use_game_pass"
|
||||
}
|
||||
// Note: If we change SourceType to 4, ProcessOrderLottery might skip it if checks SourceType.
|
||||
// Lottery app usually expects SourceType=2 or similar.
|
||||
// Let's KEEP SourceType as is (likely 2 for ichiban), but Amount=0 ensures it's treated as Paid.
|
||||
}
|
||||
|
||||
if !useGamePass && req.UsePoints != nil && *req.UsePoints > 0 {
|
||||
bal, _ := h.user.GetPointsBalance(ctx.RequestContext(), userID)
|
||||
usePts := *req.UsePoints
|
||||
if bal > 0 && usePts > bal {
|
||||
@ -258,10 +364,36 @@ func (h *handler) JoinLottery() core.HandlerFunc {
|
||||
}
|
||||
}
|
||||
|
||||
// Check if fully paid (by discount, game pass, or points)
|
||||
if order.ActualAmount <= 0 {
|
||||
order.Status = 2 // Paid
|
||||
order.PaidAt = time.Now()
|
||||
}
|
||||
|
||||
err = tx.Orders.WithContext(ctx.RequestContext()).Omit(tx.Orders.PaidAt, tx.Orders.CancelledAt).Create(order)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 一番赏占位 (针对内抵扣/次数卡导致的 0 元支付成功的订单补偿占位逻辑)
|
||||
if order.Status == 2 && activity.PlayType == "ichiban" {
|
||||
for _, si := range req.SlotIndex {
|
||||
slotIdx0 := si - 1 // 转换为 0-based 索引
|
||||
// 再次检查占用情况 (事务内原子防并发)
|
||||
cnt, _ := tx.IssuePositionClaims.WithContext(ctx.RequestContext()).Where(tx.IssuePositionClaims.IssueID.Eq(req.IssueID), tx.IssuePositionClaims.SlotIndex.Eq(slotIdx0)).Count()
|
||||
if cnt > 0 {
|
||||
return errors.New("slot_unavailable")
|
||||
}
|
||||
if err := tx.IssuePositionClaims.WithContext(ctx.RequestContext()).Create(&model.IssuePositionClaims{
|
||||
IssueID: req.IssueID,
|
||||
SlotIndex: slotIdx0,
|
||||
UserID: userID,
|
||||
OrderID: order.ID,
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
// Inline RecordOrderCouponUsage (no logging)
|
||||
if applied > 0 && req.CouponID != nil && *req.CouponID > 0 {
|
||||
_ = tx.Orders.UnderlyingDB().Exec("INSERT INTO order_coupons (order_id, user_coupon_id, applied_amount, created_at) VALUES (?,?,?,NOW(3))", order.ID, *req.CouponID, applied).Error
|
||||
@ -274,15 +406,38 @@ func (h *handler) JoinLottery() core.HandlerFunc {
|
||||
ctx.AbortWithError(core.Error(http.StatusInternalServerError, 170002, err.Error()))
|
||||
return
|
||||
}
|
||||
// 优惠券扣减与核销在支付回调中执行(避免未支付时扣减)
|
||||
|
||||
rsp.JoinID = joinID
|
||||
rsp.OrderNo = orderNo
|
||||
rsp.ActualAmount = order.ActualAmount
|
||||
rsp.Status = order.Status
|
||||
|
||||
// Immediate Draw Trigger if Paid (e.g. Game Pass or Free)
|
||||
if order.Status == 2 && activity.DrawMode == "instant" {
|
||||
go func() {
|
||||
_ = h.activity.ProcessOrderLottery(context.Background(), order.ID)
|
||||
}()
|
||||
}
|
||||
// Immediate Draw Trigger if Paid (e.g. Game Pass or Free)
|
||||
if order.Status == 2 && activity.DrawMode == "instant" {
|
||||
// Trigger process asynchronously or synchronously?
|
||||
// Usually WechatNotify triggers it. Since we bypass WechatNotify, we must trigger it.
|
||||
go func() {
|
||||
_ = h.activity.ProcessOrderLottery(context.Background(), order.ID)
|
||||
}()
|
||||
}
|
||||
rsp.DrawMode = cfgMode
|
||||
if order.ActualAmount == 0 {
|
||||
now := time.Now()
|
||||
_, _ = h.writeDB.Orders.WithContext(ctx.RequestContext()).Where(h.writeDB.Orders.OrderNo.Eq(orderNo)).Updates(map[string]any{h.writeDB.Orders.Status.ColumnName().String(): 2, h.writeDB.Orders.PaidAt.ColumnName().String(): now})
|
||||
// 0元订单:统一由 Service 处理优惠券扣减与流水记录
|
||||
_ = h.user.DeductCouponsForPaidOrder(ctx.RequestContext(), nil, userID, order.ID, now)
|
||||
|
||||
// 异步触发任务中心逻辑
|
||||
go func() {
|
||||
_ = h.task.OnOrderPaid(context.Background(), userID, order.ID)
|
||||
}()
|
||||
|
||||
rsp.Queued = true
|
||||
} else {
|
||||
rsp.Queued = true
|
||||
@ -338,7 +493,7 @@ func (h *handler) GetLotteryResult() core.HandlerFunc {
|
||||
var ord *model.Orders
|
||||
if req.OrderID > 0 {
|
||||
orderID = req.OrderID
|
||||
ord, _ = h.readDB.Orders.WithContext(ctx.RequestContext()).Where(h.readDB.Orders.ID.Eq(orderID)).First()
|
||||
ord, _ = h.readDB.Orders.WithContext(ctx.RequestContext()).Where(h.readDB.Orders.ID.Eq(orderID), h.readDB.Orders.UserID.Eq(int64(ctx.SessionUserInfo().Id))).First()
|
||||
}
|
||||
userID := int64(ctx.SessionUserInfo().Id)
|
||||
issue, _ := h.readDB.ActivityIssues.WithContext(ctx.RequestContext()).Where(h.readDB.ActivityIssues.ID.Eq(issueID)).First()
|
||||
|
||||
@ -166,13 +166,14 @@ func (h *handler) PreOrderMatchingGame() core.HandlerFunc {
|
||||
ActualAmount: 0, // 次数卡抵扣,实付0元
|
||||
DiscountAmount: activity.PriceDraw,
|
||||
Status: 2, // 已支付
|
||||
Remark: fmt.Sprintf("game_pass:%d|matching_game:issue:%d", validPass.ID, req.IssueID),
|
||||
Remark: fmt.Sprintf("activity:%d|game_pass:%d|matching_game:issue:%d", activity.ID, validPass.ID, req.IssueID),
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
PaidAt: now,
|
||||
}
|
||||
|
||||
if err := h.writeDB.Orders.WithContext(ctx.RequestContext()).
|
||||
Omit(h.writeDB.Orders.CancelledAt).
|
||||
Create(newOrder); err != nil {
|
||||
// 回滚次数卡
|
||||
h.writeDB.UserGamePasses.WithContext(ctx.RequestContext()).
|
||||
@ -185,6 +186,11 @@ func (h *handler) PreOrderMatchingGame() core.HandlerFunc {
|
||||
return
|
||||
}
|
||||
order = newOrder
|
||||
|
||||
// 次数卡 0 元订单手动触发任务中心
|
||||
go func() {
|
||||
_ = h.task.OnOrderPaid(context.Background(), userID, order.ID)
|
||||
}()
|
||||
} else {
|
||||
// 原有支付流程
|
||||
var couponID *int64
|
||||
@ -739,7 +745,7 @@ func (h *handler) GetMatchingGameState() core.HandlerFunc {
|
||||
// @Tags APP端.活动
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Success 200 {array} CardTypeConfig
|
||||
// @Success 200 {array} activitysvc.CardTypeConfig
|
||||
// @Failure 400 {object} code.Failure
|
||||
// @Router /api/app/matching/card_types [get]
|
||||
func (h *handler) ListMatchingCardTypes() core.HandlerFunc {
|
||||
|
||||
@ -73,7 +73,11 @@ func (h *handler) GrantGamePass() core.HandlerFunc {
|
||||
m.ExpiredAt = now.Add(time.Duration(*req.ValidDays) * 24 * time.Hour)
|
||||
}
|
||||
|
||||
if err := h.writeDB.UserGamePasses.WithContext(ctx.RequestContext()).Create(m); err != nil {
|
||||
q := h.writeDB.UserGamePasses.WithContext(ctx.RequestContext())
|
||||
if m.ExpiredAt.IsZero() {
|
||||
q = q.Omit(h.writeDB.UserGamePasses.ExpiredAt)
|
||||
}
|
||||
if err := q.Create(m); err != nil {
|
||||
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ServerError, err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
@ -6,7 +6,6 @@ import (
|
||||
"net/http"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"bindbox-game/internal/code"
|
||||
@ -47,69 +46,79 @@ func (h *handler) CreateRefund() core.HandlerFunc {
|
||||
return
|
||||
}
|
||||
|
||||
// 预检查:检查是否有已兑换积分的资产,并验证用户积分余额是否足够扣除
|
||||
allInvs, _ := h.readDB.UserInventory.WithContext(ctx.RequestContext()).Where(h.readDB.UserInventory.OrderID.Eq(order.ID)).Where(h.readDB.UserInventory.Status.In(1, 3)).Find()
|
||||
// 检查订单状态,只有已支付的订单才能退款
|
||||
if order.Status != 2 {
|
||||
ctx.AbortWithError(core.Error(http.StatusBadRequest, 160010, "订单状态不允许退款"))
|
||||
return
|
||||
}
|
||||
|
||||
var pointsToReclaim int64
|
||||
// 预检查:检查是否有已兑换积分的资产
|
||||
allInvs, _ := h.readDB.UserInventory.WithContext(ctx.RequestContext()).Where(h.readDB.UserInventory.OrderID.Eq(order.ID)).Where(h.readDB.UserInventory.Status.In(1, 3)).Find()
|
||||
rePoints := regexp.MustCompile(`\|redeemed_points=(\d+)`)
|
||||
for _, inv := range allInvs {
|
||||
if inv.Status == 3 && strings.Contains(inv.Remark, "redeemed_points=") {
|
||||
matches := rePoints.FindStringSubmatch(inv.Remark)
|
||||
if len(matches) > 1 {
|
||||
p, _ := strconv.ParseInt(matches[1], 10, 64)
|
||||
pointsToReclaim += p
|
||||
|
||||
var refundedSumCents int64
|
||||
var isFullRefund bool
|
||||
|
||||
// ⭐ 根据 ActualAmount 决定是否需要微信退款
|
||||
if order.ActualAmount == 0 {
|
||||
// ActualAmount=0:无需微信退款,直接标记为全额退款
|
||||
isFullRefund = true
|
||||
h.logger.Info(fmt.Sprintf("refund: ActualAmount=0, skip wechat refund: order=%s", order.OrderNo))
|
||||
} else {
|
||||
// ActualAmount>0:需要调用微信退款
|
||||
// 计算已退款与可退余额(分)
|
||||
ledgers, _ := h.readDB.UserPointsLedger.WithContext(ctx.RequestContext()).ReadDB().Where(h.readDB.UserPointsLedger.RefTable.Eq("orders"), h.readDB.UserPointsLedger.RefID.Eq(order.OrderNo)).Find()
|
||||
for _, l := range ledgers {
|
||||
if l.Action == "refund_amount" {
|
||||
refundedSumCents += l.Points * 100
|
||||
}
|
||||
}
|
||||
refundable := order.ActualAmount - refundedSumCents
|
||||
if refundable < 0 {
|
||||
refundable = 0
|
||||
}
|
||||
if req.Amount <= 0 || req.Amount > refundable {
|
||||
ctx.AbortWithError(core.Error(http.StatusBadRequest, 160005, fmt.Sprintf("invalid refund amount, max=%d", refundable)))
|
||||
return
|
||||
}
|
||||
|
||||
// 调用微信真实退款
|
||||
wc, err := paypkg.NewWechatPayClient(ctx.RequestContext())
|
||||
if err != nil {
|
||||
ctx.AbortWithError(core.Error(http.StatusBadRequest, 160006, err.Error()))
|
||||
return
|
||||
}
|
||||
refundNo := fmt.Sprintf("R%s-%d", order.OrderNo, time.Now().Unix())
|
||||
refundID, status, err := wc.RefundOrder(ctx.RequestContext(), order.OrderNo, refundNo, req.Amount, order.ActualAmount, req.Reason)
|
||||
if err != nil {
|
||||
ctx.AbortWithError(core.Error(http.StatusBadRequest, 160007, err.Error()))
|
||||
return
|
||||
}
|
||||
pr := &model.PaymentRefunds{
|
||||
OrderID: order.ID,
|
||||
OrderNo: order.OrderNo,
|
||||
RefundNo: refundNo,
|
||||
Channel: "wechat_jsapi",
|
||||
Status: status,
|
||||
AmountRefund: req.Amount,
|
||||
Reason: req.Reason,
|
||||
SuccessTime: time.Now(),
|
||||
Raw: func() string {
|
||||
b, _ := json.Marshal(map[string]any{"refund_id": refundID, "status": status})
|
||||
return string(b)
|
||||
}(),
|
||||
}
|
||||
if err := h.writeDB.PaymentRefunds.WithContext(ctx.RequestContext()).Create(pr); err != nil {
|
||||
ctx.AbortWithError(core.Error(http.StatusInternalServerError, 160009, err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
// 判断是否为全额退款
|
||||
isFullRefund = req.Amount == order.ActualAmount-refundedSumCents
|
||||
}
|
||||
|
||||
// 计算已退款与可退余额(分)
|
||||
ledgers, _ := h.readDB.UserPointsLedger.WithContext(ctx.RequestContext()).ReadDB().Where(h.readDB.UserPointsLedger.RefTable.Eq("orders"), h.readDB.UserPointsLedger.RefID.Eq(order.OrderNo)).Find()
|
||||
var refundedSumCents int64
|
||||
for _, l := range ledgers {
|
||||
if l.Action == "refund_amount" {
|
||||
refundedSumCents += l.Points * 100
|
||||
}
|
||||
}
|
||||
refundable := order.ActualAmount - refundedSumCents
|
||||
if refundable < 0 {
|
||||
refundable = 0
|
||||
}
|
||||
if req.Amount <= 0 || req.Amount > refundable {
|
||||
ctx.AbortWithError(core.Error(http.StatusBadRequest, 160005, fmt.Sprintf("invalid refund amount, max=%d", refundable)))
|
||||
return
|
||||
}
|
||||
// 调用微信真实退款
|
||||
wc, err := paypkg.NewWechatPayClient(ctx.RequestContext())
|
||||
if err != nil {
|
||||
ctx.AbortWithError(core.Error(http.StatusBadRequest, 160006, err.Error()))
|
||||
return
|
||||
}
|
||||
refundNo := fmt.Sprintf("R%s-%d", order.OrderNo, time.Now().Unix())
|
||||
refundID, status, err := wc.RefundOrder(ctx.RequestContext(), order.OrderNo, refundNo, req.Amount, order.ActualAmount, req.Reason)
|
||||
if err != nil {
|
||||
ctx.AbortWithError(core.Error(http.StatusBadRequest, 160007, err.Error()))
|
||||
return
|
||||
}
|
||||
pr := &model.PaymentRefunds{
|
||||
OrderID: order.ID,
|
||||
OrderNo: order.OrderNo,
|
||||
RefundNo: refundNo,
|
||||
Channel: "wechat_jsapi",
|
||||
Status: status,
|
||||
AmountRefund: req.Amount,
|
||||
Reason: req.Reason,
|
||||
SuccessTime: time.Now(),
|
||||
Raw: func() string {
|
||||
b, _ := json.Marshal(map[string]any{"refund_id": refundID, "status": status})
|
||||
return string(b)
|
||||
}(),
|
||||
}
|
||||
if err := h.writeDB.PaymentRefunds.WithContext(ctx.RequestContext()).Create(pr); err != nil {
|
||||
ctx.AbortWithError(core.Error(http.StatusInternalServerError, 160009, err.Error()))
|
||||
return
|
||||
}
|
||||
// 更新订单状态为已退款
|
||||
if req.Amount == order.ActualAmount-refundedSumCents {
|
||||
// 更新订单状态为已退款(全额退款时)
|
||||
if isFullRefund {
|
||||
_, err = h.writeDB.Orders.WithContext(ctx.RequestContext()).Where(h.writeDB.Orders.ID.Eq(order.ID)).Updates(map[string]any{
|
||||
h.writeDB.Orders.Status.ColumnName().String(): 4,
|
||||
h.writeDB.Orders.UpdatedAt.ColumnName().String(): time.Now(),
|
||||
@ -225,9 +234,25 @@ func (h *handler) CreateRefund() core.HandlerFunc {
|
||||
for icID := range idSet {
|
||||
_ = h.repo.GetDbW().Exec("UPDATE user_item_cards SET status=1, used_at=NULL, used_draw_log_id=0, used_activity_id=0, used_issue_id=0, updated_at=NOW(3) WHERE id=?", icID).Error
|
||||
}
|
||||
|
||||
// 全额退款:回退次数卡(user_game_passes)
|
||||
// 解析订单 remark 中的 game_pass:xxx ID
|
||||
reGamePass := regexp.MustCompile(`game_pass:(\d+)`)
|
||||
gamePassMatches := reGamePass.FindStringSubmatch(order.Remark)
|
||||
if len(gamePassMatches) > 1 {
|
||||
gamePassID, _ := strconv.ParseInt(gamePassMatches[1], 10, 64)
|
||||
if gamePassID > 0 {
|
||||
// 恢复次数卡:remaining +1, total_used -1
|
||||
if err := h.repo.GetDbW().Exec("UPDATE user_game_passes SET remaining = remaining + 1, total_used = GREATEST(total_used - 1, 0), updated_at = NOW(3) WHERE id = ?", gamePassID).Error; err != nil {
|
||||
h.logger.Error(fmt.Sprintf("refund restore game_pass failed: order=%s game_pass_id=%d err=%v", order.OrderNo, gamePassID, err))
|
||||
} else {
|
||||
h.logger.Info(fmt.Sprintf("refund restore game_pass success: order=%s game_pass_id=%d", order.OrderNo, gamePassID))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// 记录积分按比例恢复(幂等增量)
|
||||
if order.PointsAmount > 0 {
|
||||
// 记录积分按比例恢复(幂等增量)- 仅对 ActualAmount > 0 的订单执行
|
||||
if order.PointsAmount > 0 && order.ActualAmount > 0 {
|
||||
restores, _ := h.readDB.UserPointsLedger.WithContext(ctx.RequestContext()).ReadDB().Where(
|
||||
h.readDB.UserPointsLedger.RefTable.Eq("orders"),
|
||||
h.readDB.UserPointsLedger.RefID.Eq(order.OrderNo),
|
||||
|
||||
@ -79,7 +79,7 @@ func (h *handler) GetUserProfitLossTrend() core.HandlerFunc {
|
||||
Cards int64
|
||||
Coupons int64
|
||||
}
|
||||
_ = h.repo.GetDbR().Raw("SELECT COALESCE(SUM(points), 0) FROM user_points WHERE user_id = ?", userID).Scan(&curAssets.Points).Error
|
||||
_ = h.repo.GetDbR().Raw("SELECT COALESCE(SUM(points), 0) FROM user_points WHERE user_id = ? AND (valid_end IS NULL OR valid_end > NOW())", userID).Scan(&curAssets.Points).Error
|
||||
_ = h.repo.GetDbR().Raw("SELECT COALESCE(SUM(p.price), 0) FROM user_inventory ui LEFT JOIN products p ON p.id = ui.product_id WHERE ui.user_id = ? AND ui.status = 1", userID).Scan(&curAssets.Products).Error
|
||||
_ = h.repo.GetDbR().Raw("SELECT COALESCE(SUM(sc.price), 0) FROM user_item_cards uic LEFT JOIN system_item_cards sc ON sc.id = uic.card_id WHERE uic.user_id = ? AND uic.status = 1", userID).Scan(&curAssets.Cards).Error
|
||||
_ = h.repo.GetDbR().Raw("SELECT COALESCE(SUM(balance_amount), 0) FROM user_coupons WHERE user_id = ? AND status = 1", userID).Scan(&curAssets.Coupons).Error
|
||||
|
||||
@ -5,6 +5,7 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"bindbox-game/configs"
|
||||
@ -279,7 +280,63 @@ func (h *handler) WechatNotify() core.HandlerFunc {
|
||||
|
||||
if ord.SourceType == 2 && act != nil && act.DrawMode == "instant" {
|
||||
_ = h.activity.ProcessOrderLottery(bgCtx, ord.ID)
|
||||
} else if ord.SourceType != 2 && ord.SourceType != 3 {
|
||||
} else if ord.SourceType == 4 {
|
||||
// 次数卡发放
|
||||
var pkgID int64
|
||||
var count int32 = 1
|
||||
parts := strings.Split(ord.Remark, "|")
|
||||
for _, p := range parts {
|
||||
if strings.HasPrefix(p, "pkg_id:") {
|
||||
_, _ = fmt.Sscanf(p, "pkg_id:%d", &pkgID)
|
||||
} else if strings.HasPrefix(p, "count:") {
|
||||
_, _ = fmt.Sscanf(p, "count:%d", &count)
|
||||
}
|
||||
}
|
||||
if pkgID > 0 {
|
||||
if err := h.user.GrantGamePass(bgCtx, ord.UserID, pkgID, count, ord.OrderNo); err != nil {
|
||||
h.logger.Error("Failed to grant game pass", zap.Error(err), zap.String("order_no", ord.OrderNo))
|
||||
}
|
||||
} else {
|
||||
h.logger.Error("Game pass package ID not found in remark", zap.String("order_no", ord.OrderNo), zap.String("remark", ord.Remark))
|
||||
}
|
||||
// 虚拟发货通知
|
||||
payerOpenid := ""
|
||||
if transaction.Payer != nil && transaction.Payer.Openid != nil {
|
||||
payerOpenid = *transaction.Payer.Openid
|
||||
}
|
||||
itemsDesc := "次数卡 " + ord.OrderNo
|
||||
if txID := func() string {
|
||||
if transaction.TransactionId != nil {
|
||||
return *transaction.TransactionId
|
||||
}
|
||||
return ""
|
||||
}(); txID != "" {
|
||||
if err := wechat.UploadVirtualShippingForBackground(bgCtx, &wechat.WechatConfig{AppID: configs.Get().Wechat.AppID, AppSecret: configs.Get().Wechat.AppSecret}, txID, ord.OrderNo, payerOpenid, itemsDesc); err != nil {
|
||||
h.logger.Error("次数卡虚拟发货失败", zap.Error(err), zap.String("order_no", ord.OrderNo))
|
||||
} else {
|
||||
h.logger.Info("次数卡虚拟发货成功", zap.String("order_no", ord.OrderNo))
|
||||
}
|
||||
}
|
||||
} else if ord.SourceType == 3 {
|
||||
// 对对碰订单虚拟发货(初始支付成功通知)
|
||||
payerOpenid := ""
|
||||
if transaction.Payer != nil && transaction.Payer.Openid != nil {
|
||||
payerOpenid = *transaction.Payer.Openid
|
||||
}
|
||||
itemsDesc := "对对碰游戏 " + ord.OrderNo
|
||||
if txID := func() string {
|
||||
if transaction.TransactionId != nil {
|
||||
return *transaction.TransactionId
|
||||
}
|
||||
return ""
|
||||
}(); txID != "" {
|
||||
if err := wechat.UploadVirtualShippingForBackground(bgCtx, &wechat.WechatConfig{AppID: configs.Get().Wechat.AppID, AppSecret: configs.Get().Wechat.AppSecret}, txID, ord.OrderNo, payerOpenid, itemsDesc); err != nil {
|
||||
h.logger.Error("对对碰虚拟发货失败", zap.Error(err), zap.String("order_no", ord.OrderNo))
|
||||
} else {
|
||||
h.logger.Info("对对碰虚拟发货成功", zap.String("order_no", ord.OrderNo))
|
||||
}
|
||||
}
|
||||
} else if ord.SourceType != 2 {
|
||||
// 普通商品虚拟发货
|
||||
payerOpenid := ""
|
||||
if transaction.Payer != nil && transaction.Payer.Openid != nil {
|
||||
@ -292,7 +349,11 @@ func (h *handler) WechatNotify() core.HandlerFunc {
|
||||
}
|
||||
return ""
|
||||
}(); txID != "" {
|
||||
_ = wechat.UploadVirtualShippingWithFallback(ctx, &wechat.WechatConfig{AppID: configs.Get().Wechat.AppID, AppSecret: configs.Get().Wechat.AppSecret}, txID, ord.OrderNo, payerOpenid, itemsDesc, time.Now())
|
||||
if err := wechat.UploadVirtualShippingForBackground(bgCtx, &wechat.WechatConfig{AppID: configs.Get().Wechat.AppID, AppSecret: configs.Get().Wechat.AppSecret}, txID, ord.OrderNo, payerOpenid, itemsDesc); err != nil {
|
||||
h.logger.Error("商户订单虚拟发货失败", zap.Error(err), zap.String("order_no", ord.OrderNo))
|
||||
} else {
|
||||
h.logger.Info("商户订单虚拟发货成功", zap.String("order_no", ord.OrderNo))
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
@ -4,17 +4,19 @@ import (
|
||||
"bindbox-game/internal/pkg/logger"
|
||||
"bindbox-game/internal/repository/mysql"
|
||||
"bindbox-game/internal/repository/mysql/dao"
|
||||
tasksvc "bindbox-game/internal/service/task_center"
|
||||
usersvc "bindbox-game/internal/service/user"
|
||||
)
|
||||
|
||||
type handler struct {
|
||||
logger logger.CustomLogger
|
||||
writeDB *dao.Query
|
||||
readDB *dao.Query
|
||||
user usersvc.Service
|
||||
repo mysql.Repo
|
||||
logger logger.CustomLogger
|
||||
writeDB *dao.Query
|
||||
readDB *dao.Query
|
||||
user usersvc.Service
|
||||
task tasksvc.Service
|
||||
repo mysql.Repo
|
||||
}
|
||||
|
||||
func New(logger logger.CustomLogger, db mysql.Repo) *handler {
|
||||
return &handler{logger: logger, writeDB: dao.Use(db.GetDbW()), readDB: dao.Use(db.GetDbR()), user: usersvc.New(logger, db), repo: db}
|
||||
func New(logger logger.CustomLogger, db mysql.Repo, taskSvc tasksvc.Service) *handler {
|
||||
return &handler{logger: logger, writeDB: dao.Use(db.GetDbW()), readDB: dao.Use(db.GetDbR()), user: usersvc.New(logger, db), task: taskSvc, repo: db}
|
||||
}
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
@ -197,6 +198,7 @@ func (h *handler) GetGamePassPackages() core.HandlerFunc {
|
||||
|
||||
type purchasePackageRequest struct {
|
||||
PackageID int64 `json:"package_id" binding:"required"`
|
||||
Count int32 `json:"count"` // 购买数量
|
||||
}
|
||||
|
||||
type purchasePackageResponse struct {
|
||||
@ -224,6 +226,10 @@ func (h *handler) PurchaseGamePassPackage() core.HandlerFunc {
|
||||
return
|
||||
}
|
||||
|
||||
if req.Count <= 0 {
|
||||
req.Count = 1
|
||||
}
|
||||
|
||||
userID := int64(ctx.SessionUserInfo().Id)
|
||||
|
||||
// 查询套餐信息
|
||||
@ -236,17 +242,20 @@ func (h *handler) PurchaseGamePassPackage() core.HandlerFunc {
|
||||
return
|
||||
}
|
||||
|
||||
// Calculate total price
|
||||
totalPrice := pkg.Price * int64(req.Count)
|
||||
|
||||
// 创建订单
|
||||
now := time.Now()
|
||||
orderNo := now.Format("20060102150405") + string(rune(now.UnixNano()%10000))
|
||||
orderNo := now.Format("20060102150405") + fmt.Sprintf("%04d", now.UnixNano()%10000)
|
||||
order := &model.Orders{
|
||||
UserID: userID,
|
||||
OrderNo: "GP" + orderNo,
|
||||
SourceType: 4, // 次数卡购买
|
||||
TotalAmount: pkg.Price,
|
||||
ActualAmount: pkg.Price,
|
||||
TotalAmount: totalPrice,
|
||||
ActualAmount: totalPrice,
|
||||
Status: 1, // 待支付
|
||||
Remark: "game_pass_package:" + pkg.Name,
|
||||
Remark: fmt.Sprintf("game_pass_package:%s|count:%d", pkg.Name, req.Count),
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
@ -258,8 +267,8 @@ func (h *handler) PurchaseGamePassPackage() core.HandlerFunc {
|
||||
return
|
||||
}
|
||||
|
||||
// 在备注中记录套餐ID,支付成功后回调时使用
|
||||
remark := order.Remark + "|pkg_id:" + string(rune(pkg.ID))
|
||||
// 在备注中记录套餐ID和数量
|
||||
remark := fmt.Sprintf("%s|pkg_id:%d|count:%d", order.Remark, pkg.ID, req.Count)
|
||||
h.writeDB.Orders.WithContext(ctx.RequestContext()).
|
||||
Where(h.writeDB.Orders.ID.Eq(order.ID)).
|
||||
Updates(map[string]any{"remark": remark})
|
||||
|
||||
@ -1,31 +1,33 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"time"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"bindbox-game/configs"
|
||||
"bindbox-game/internal/code"
|
||||
"bindbox-game/internal/pkg/core"
|
||||
"bindbox-game/internal/pkg/jwtoken"
|
||||
"bindbox-game/internal/pkg/validation"
|
||||
"bindbox-game/internal/pkg/wechat"
|
||||
"bindbox-game/internal/proposal"
|
||||
usersvc "bindbox-game/internal/service/user"
|
||||
"bindbox-game/configs"
|
||||
"bindbox-game/internal/code"
|
||||
"bindbox-game/internal/pkg/core"
|
||||
"bindbox-game/internal/pkg/jwtoken"
|
||||
"bindbox-game/internal/pkg/validation"
|
||||
"bindbox-game/internal/pkg/wechat"
|
||||
"bindbox-game/internal/proposal"
|
||||
usersvc "bindbox-game/internal/service/user"
|
||||
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
type weixinLoginRequest struct {
|
||||
Code string `json:"code"`
|
||||
InviteCode string `json:"invite_code"`
|
||||
DouyinID string `json:"douyin_id"`
|
||||
Code string `json:"code"`
|
||||
InviteCode string `json:"invite_code"`
|
||||
DouyinID string `json:"douyin_id"`
|
||||
}
|
||||
type weixinLoginResponse struct {
|
||||
UserID int64 `json:"user_id"`
|
||||
Nickname string `json:"nickname"`
|
||||
Avatar string `json:"avatar"`
|
||||
InviteCode string `json:"invite_code"`
|
||||
OpenID string `json:"openid"`
|
||||
Token string `json:"token"`
|
||||
UserID int64 `json:"user_id"`
|
||||
Nickname string `json:"nickname"`
|
||||
Avatar string `json:"avatar"`
|
||||
InviteCode string `json:"invite_code"`
|
||||
OpenID string `json:"openid"`
|
||||
Token string `json:"token"`
|
||||
}
|
||||
|
||||
// WeixinLogin 微信登录
|
||||
@ -54,22 +56,31 @@ func (h *handler) WeixinLogin() core.HandlerFunc {
|
||||
return
|
||||
}
|
||||
|
||||
in := usersvc.LoginWeixinInput{OpenID: c2s.OpenID, UnionID: c2s.UnionID, InviteCode: req.InviteCode, DouyinID: req.DouyinID}
|
||||
u, err := h.user.LoginWeixin(ctx.RequestContext(), in)
|
||||
if err != nil {
|
||||
ctx.AbortWithError(core.Error(http.StatusBadRequest, 10006, err.Error()))
|
||||
return
|
||||
}
|
||||
rsp.UserID = u.ID
|
||||
rsp.Nickname = u.Nickname
|
||||
rsp.Avatar = u.Avatar
|
||||
rsp.InviteCode = u.InviteCode
|
||||
rsp.OpenID = c2s.OpenID
|
||||
sessionUserInfo := proposal.SessionUserInfo{Id: int32(u.ID), UserName: u.Nickname, NickName: u.Nickname, IsSuper: 0, Platform: "APP"}
|
||||
tokenString, tErr := jwtoken.New(configs.Get().JWT.PatientSecret).Sign(sessionUserInfo, 30*24*time.Hour)
|
||||
if tErr == nil {
|
||||
rsp.Token = tokenString
|
||||
}
|
||||
ctx.Payload(rsp)
|
||||
}
|
||||
in := usersvc.LoginWeixinInput{OpenID: c2s.OpenID, UnionID: c2s.UnionID, InviteCode: req.InviteCode, DouyinID: req.DouyinID}
|
||||
out, err := h.user.LoginWeixin(ctx.RequestContext(), in)
|
||||
if err != nil {
|
||||
ctx.AbortWithError(core.Error(http.StatusBadRequest, 10006, err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
u := out.User
|
||||
rsp.UserID = u.ID
|
||||
rsp.Nickname = u.Nickname
|
||||
|
||||
// 触发邀请奖励逻辑
|
||||
if out.IsNewUser && out.InviterID > 0 {
|
||||
if err := h.task.OnInviteSuccess(ctx.RequestContext(), out.InviterID, u.ID); err != nil {
|
||||
h.logger.Error("触发邀请任务失败", zap.Error(err), zap.Int64("inviter_id", out.InviterID), zap.Int64("invitee_id", u.ID))
|
||||
}
|
||||
}
|
||||
rsp.Avatar = u.Avatar
|
||||
rsp.InviteCode = u.InviteCode
|
||||
rsp.OpenID = c2s.OpenID
|
||||
sessionUserInfo := proposal.SessionUserInfo{Id: int32(u.ID), UserName: u.Nickname, NickName: u.Nickname, IsSuper: 0, Platform: "APP"}
|
||||
tokenString, tErr := jwtoken.New(configs.Get().JWT.PatientSecret).Sign(sessionUserInfo, 30*24*time.Hour)
|
||||
if tErr == nil {
|
||||
rsp.Token = tokenString
|
||||
}
|
||||
ctx.Payload(rsp)
|
||||
}
|
||||
}
|
||||
|
||||
95
internal/api/user/login_douyin_app.go
Normal file
95
internal/api/user/login_douyin_app.go
Normal file
@ -0,0 +1,95 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"bindbox-game/configs"
|
||||
"bindbox-game/internal/code"
|
||||
"bindbox-game/internal/pkg/core"
|
||||
"bindbox-game/internal/pkg/jwtoken"
|
||||
"bindbox-game/internal/pkg/validation"
|
||||
"bindbox-game/internal/proposal"
|
||||
usersvc "bindbox-game/internal/service/user"
|
||||
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
type douyinLoginRequest struct {
|
||||
Code string `json:"code"`
|
||||
AnonymousCode string `json:"anonymous_code"`
|
||||
InviteCode string `json:"invite_code"`
|
||||
ChannelCode string `json:"channel_code"`
|
||||
}
|
||||
|
||||
type douyinLoginResponse struct {
|
||||
UserID int64 `json:"user_id"`
|
||||
Nickname string `json:"nickname"`
|
||||
Avatar string `json:"avatar"`
|
||||
InviteCode string `json:"invite_code"`
|
||||
Token string `json:"token"`
|
||||
}
|
||||
|
||||
// DouyinLogin 抖音登录
|
||||
// @Summary 抖音登录
|
||||
// @Description 抖音小程序登录(需传递 code 或 anonymous_code)
|
||||
// @Tags APP端.用户
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param RequestBody body douyinLoginRequest true "请求参数"
|
||||
// @Success 200 {object} douyinLoginResponse
|
||||
// @Failure 400 {object} code.Failure
|
||||
// @Router /api/app/users/douyin/login [post]
|
||||
func (h *handler) DouyinLogin() core.HandlerFunc {
|
||||
return func(ctx core.Context) {
|
||||
req := new(douyinLoginRequest)
|
||||
rsp := new(douyinLoginResponse)
|
||||
if err := ctx.ShouldBindJSON(req); err != nil {
|
||||
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, validation.Error(err)))
|
||||
return
|
||||
}
|
||||
|
||||
if req.Code == "" && req.AnonymousCode == "" {
|
||||
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, "code 或 anonymous_code 不能为空"))
|
||||
return
|
||||
}
|
||||
|
||||
in := usersvc.LoginDouyinInput{
|
||||
Code: req.Code,
|
||||
AnonymousCode: req.AnonymousCode,
|
||||
InviteCode: req.InviteCode,
|
||||
ChannelCode: req.ChannelCode,
|
||||
}
|
||||
out, err := h.user.LoginDouyin(ctx.RequestContext(), in)
|
||||
if err != nil {
|
||||
ctx.AbortWithError(core.Error(http.StatusBadRequest, 10006, err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
u := out.User
|
||||
rsp.UserID = u.ID
|
||||
rsp.Nickname = u.Nickname
|
||||
rsp.Avatar = u.Avatar
|
||||
rsp.InviteCode = u.InviteCode
|
||||
|
||||
// 触发邀请奖励逻辑
|
||||
if out.IsNewUser && out.InviterID > 0 {
|
||||
if err := h.task.OnInviteSuccess(ctx.RequestContext(), out.InviterID, u.ID); err != nil {
|
||||
h.logger.Error("触发邀请任务失败", zap.Error(err), zap.Int64("inviter_id", out.InviterID), zap.Int64("invitee_id", u.ID))
|
||||
}
|
||||
}
|
||||
|
||||
sessionUserInfo := proposal.SessionUserInfo{
|
||||
Id: int32(u.ID),
|
||||
UserName: u.Nickname,
|
||||
NickName: u.Nickname,
|
||||
IsSuper: 0,
|
||||
Platform: "APP",
|
||||
}
|
||||
tokenString, tErr := jwtoken.New(configs.Get().JWT.PatientSecret).Sign(sessionUserInfo, 30*24*time.Hour)
|
||||
if tErr == nil {
|
||||
rsp.Token = tokenString
|
||||
}
|
||||
ctx.Payload(rsp)
|
||||
}
|
||||
}
|
||||
@ -1,14 +1,16 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http"
|
||||
|
||||
"bindbox-game/configs"
|
||||
"bindbox-game/internal/code"
|
||||
"bindbox-game/internal/pkg/core"
|
||||
"bindbox-game/internal/pkg/miniprogram"
|
||||
"bindbox-game/internal/pkg/validation"
|
||||
"bindbox-game/internal/pkg/wechat"
|
||||
"bindbox-game/configs"
|
||||
"bindbox-game/internal/code"
|
||||
"bindbox-game/internal/pkg/core"
|
||||
"bindbox-game/internal/pkg/miniprogram"
|
||||
"bindbox-game/internal/pkg/validation"
|
||||
"bindbox-game/internal/pkg/wechat"
|
||||
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
type bindPhoneRequest struct {
|
||||
@ -26,8 +28,8 @@ type bindPhoneResponse struct {
|
||||
// @Tags APP端.用户
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param user_id path integer true "用户ID"
|
||||
// @Security LoginVerifyToken
|
||||
// @Param user_id path integer true "用户ID"
|
||||
// @Security LoginVerifyToken
|
||||
// @Param RequestBody body bindPhoneRequest true "请求参数"
|
||||
// @Success 200 {object} bindPhoneResponse
|
||||
// @Failure 400 {object} code.Failure
|
||||
@ -40,11 +42,11 @@ func (h *handler) BindPhone() core.HandlerFunc {
|
||||
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, validation.Error(err)))
|
||||
return
|
||||
}
|
||||
userID := int64(ctx.SessionUserInfo().Id)
|
||||
if userID <= 0 || req.Code == "" {
|
||||
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, "缺少必要参数"))
|
||||
return
|
||||
}
|
||||
userID := int64(ctx.SessionUserInfo().Id)
|
||||
if userID <= 0 || req.Code == "" {
|
||||
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, "缺少必要参数"))
|
||||
return
|
||||
}
|
||||
|
||||
cfg := configs.Get()
|
||||
var tokenRes struct {
|
||||
@ -65,10 +67,27 @@ func (h *handler) BindPhone() core.HandlerFunc {
|
||||
return
|
||||
}
|
||||
|
||||
if _, err := h.writeDB.Users.WithContext(ctx.RequestContext()).Where(h.writeDB.Users.ID.Eq(userID)).Updates(map[string]any{"mobile": mobile}); err != nil {
|
||||
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ServerError, err.Error()))
|
||||
return
|
||||
}
|
||||
// 检查手机号是否已被其他用户绑定
|
||||
existedUser, _ := h.readDB.Users.WithContext(ctx.RequestContext()).Where(h.readDB.Users.Mobile.Eq(mobile)).First()
|
||||
if existedUser != nil {
|
||||
if existedUser.ID != userID {
|
||||
h.logger.Warn("手机号绑定冲突", zap.Int64("user_id", userID), zap.Int64("existed_user_id", existedUser.ID), zap.String("mobile", mobile))
|
||||
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, "该手机号已被其他账号占用"))
|
||||
return
|
||||
}
|
||||
// 如果是当前用户自己,直接返回成功
|
||||
rsp.Success = true
|
||||
rsp.Mobile = mobile
|
||||
ctx.Payload(rsp)
|
||||
return
|
||||
}
|
||||
|
||||
h.logger.Info("开始绑定手机号", zap.Int64("user_id", userID), zap.String("mobile", mobile))
|
||||
if _, err := h.writeDB.Users.WithContext(ctx.RequestContext()).Where(h.writeDB.Users.ID.Eq(userID)).Updates(map[string]any{"mobile": mobile}); err != nil {
|
||||
h.logger.Error("绑定手机号数据库更新失败", zap.Error(err), zap.Int64("user_id", userID), zap.String("mobile", mobile))
|
||||
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ServerError, err.Error()))
|
||||
return
|
||||
}
|
||||
rsp.Success = true
|
||||
rsp.Mobile = mobile
|
||||
ctx.Payload(rsp)
|
||||
|
||||
115
internal/api/user/phone_bind_douyin_app.go
Normal file
115
internal/api/user/phone_bind_douyin_app.go
Normal file
@ -0,0 +1,115 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"bindbox-game/configs"
|
||||
"bindbox-game/internal/code"
|
||||
"bindbox-game/internal/pkg/core"
|
||||
"bindbox-game/internal/pkg/douyin"
|
||||
"bindbox-game/internal/pkg/validation"
|
||||
"bindbox-game/internal/service/sysconfig"
|
||||
)
|
||||
|
||||
type bindDouyinPhoneRequest struct {
|
||||
Code string `json:"code"`
|
||||
// EncryptedData string `json:"encrypted_data"` // Reserved if needed
|
||||
// IV string `json:"iv"` // Reserved if needed
|
||||
}
|
||||
|
||||
type bindDouyinPhoneResponse struct {
|
||||
Success bool `json:"success"`
|
||||
Mobile string `json:"mobile"`
|
||||
}
|
||||
|
||||
// DouyinBindPhone 抖音绑定手机号
|
||||
// @Summary 抖音绑定手机号
|
||||
// @Description 使用抖音手机号 code 换取手机号并绑定到指定用户
|
||||
// @Tags APP端.用户
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param user_id path integer true "用户ID"
|
||||
// @Security LoginVerifyToken
|
||||
// @Param RequestBody body bindDouyinPhoneRequest true "请求参数"
|
||||
// @Success 200 {object} bindDouyinPhoneResponse
|
||||
// @Failure 400 {object} code.Failure
|
||||
// @Router /api/app/users/{user_id}/douyin/phone/bind [post]
|
||||
func (h *handler) DouyinBindPhone() core.HandlerFunc {
|
||||
return func(ctx core.Context) {
|
||||
req := new(bindDouyinPhoneRequest)
|
||||
rsp := new(bindDouyinPhoneResponse)
|
||||
if err := ctx.ShouldBindJSON(req); err != nil {
|
||||
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, validation.Error(err)))
|
||||
return
|
||||
}
|
||||
userID := int64(ctx.SessionUserInfo().Id)
|
||||
if userID <= 0 || req.Code == "" {
|
||||
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, "缺少必要参数"))
|
||||
return
|
||||
}
|
||||
|
||||
// 获取 Access Token
|
||||
accessToken, err := douyin.GetAccessToken(ctx.RequestContext())
|
||||
if err != nil {
|
||||
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ServerError, "获取Access Token失败: "+err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
// 获取 AppID
|
||||
dynamicCfg := sysconfig.GetGlobalDynamicConfig()
|
||||
douyinCfg := dynamicCfg.GetDouyin(ctx.RequestContext())
|
||||
appID := douyinCfg.AppID
|
||||
if appID == "" {
|
||||
// Fallback to static config if dynamic not available or empty (though GetAccessToken checked it)
|
||||
appID = configs.Get().Douyin.AppID
|
||||
}
|
||||
|
||||
// 获取手机号
|
||||
mobile, err := douyin.GetPhoneNumber(ctx.RequestContext(), accessToken, appID, req.Code)
|
||||
if err != nil {
|
||||
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, "获取手机号失败: "+err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
if mobile == "" {
|
||||
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, "获取到的手机号为空"))
|
||||
return
|
||||
}
|
||||
|
||||
// 检查该手机号是否已被其他账号占用
|
||||
existedUser, _ := h.readDB.Users.WithContext(ctx.RequestContext()).Where(h.readDB.Users.Mobile.Eq(mobile)).First()
|
||||
if existedUser != nil {
|
||||
if existedUser.ID != userID {
|
||||
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, "该手机号已被其他账号占用"))
|
||||
return
|
||||
}
|
||||
// 如果是当前用户自己,直接返回成功
|
||||
rsp.Success = true
|
||||
rsp.Mobile = mobile
|
||||
ctx.Payload(rsp)
|
||||
return
|
||||
}
|
||||
|
||||
// 检查当前用户是否已有手机号
|
||||
currentUser, _ := h.readDB.Users.WithContext(ctx.RequestContext()).Where(h.readDB.Users.ID.Eq(userID)).First()
|
||||
if currentUser != nil && currentUser.Mobile != "" {
|
||||
if currentUser.Mobile == mobile {
|
||||
rsp.Success = true
|
||||
rsp.Mobile = mobile
|
||||
ctx.Payload(rsp)
|
||||
return
|
||||
}
|
||||
// 如果已有手机号且不一致,允许覆盖更新(或者可以根据需求改为提示已绑定过)
|
||||
}
|
||||
|
||||
// 更新
|
||||
if _, err := h.writeDB.Users.WithContext(ctx.RequestContext()).Where(h.writeDB.Users.ID.Eq(userID)).Updates(map[string]any{"mobile": mobile}); err != nil {
|
||||
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ServerError, err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
rsp.Success = true
|
||||
rsp.Mobile = mobile
|
||||
ctx.Payload(rsp)
|
||||
}
|
||||
}
|
||||
@ -1,13 +1,54 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http"
|
||||
|
||||
"bindbox-game/internal/code"
|
||||
"bindbox-game/internal/pkg/core"
|
||||
"bindbox-game/internal/pkg/validation"
|
||||
"bindbox-game/internal/code"
|
||||
"bindbox-game/internal/pkg/core"
|
||||
"bindbox-game/internal/pkg/validation"
|
||||
)
|
||||
|
||||
// GetUserProfile 获取当前用户信息
|
||||
// @Summary 获取用户信息
|
||||
// @Description 获取当前登录用户的详细信息
|
||||
// @Tags APP端.用户
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Security LoginVerifyToken
|
||||
// @Success 200 {object} userItem
|
||||
// @Failure 400 {object} code.Failure
|
||||
// @Router /api/app/users/profile [get]
|
||||
func (h *handler) GetUserProfile() core.HandlerFunc {
|
||||
return func(ctx core.Context) {
|
||||
userID := int64(ctx.SessionUserInfo().Id)
|
||||
user, err := h.user.GetProfile(ctx.RequestContext(), userID)
|
||||
if err != nil {
|
||||
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ServerError, err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
// Mask sensitive data if needed, but for "My Profile" usually we return full non-critical info.
|
||||
// Returning masked phone for display.
|
||||
phone := user.Mobile
|
||||
if len(phone) >= 11 {
|
||||
phone = phone[:3] + "****" + phone[7:]
|
||||
}
|
||||
|
||||
balance, _ := h.user.GetPointsBalance(ctx.RequestContext(), userID)
|
||||
|
||||
res := userItem{
|
||||
ID: user.ID,
|
||||
Nickname: user.Nickname,
|
||||
Avatar: user.Avatar,
|
||||
InviteCode: user.InviteCode,
|
||||
InviterID: user.InviterID,
|
||||
Mobile: phone,
|
||||
Balance: balance,
|
||||
}
|
||||
ctx.Payload(res)
|
||||
}
|
||||
}
|
||||
|
||||
type modifyUserRequest struct {
|
||||
Nickname *string `json:"nickname"`
|
||||
Avatar *string `json:"avatar"`
|
||||
@ -18,6 +59,8 @@ type userItem struct {
|
||||
Avatar string `json:"avatar"`
|
||||
InviteCode string `json:"invite_code"`
|
||||
InviterID int64 `json:"inviter_id"`
|
||||
Mobile string `json:"mobile"`
|
||||
Balance int64 `json:"balance"` // Points
|
||||
}
|
||||
type modifyUserResponse struct {
|
||||
User userItem `json:"user"`
|
||||
@ -29,8 +72,8 @@ type modifyUserResponse struct {
|
||||
// @Tags APP端.用户
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param user_id path integer true "用户ID"
|
||||
// @Security LoginVerifyToken
|
||||
// @Param user_id path integer true "用户ID"
|
||||
// @Security LoginVerifyToken
|
||||
// @Param RequestBody body modifyUserRequest true "请求参数"
|
||||
// @Success 200 {object} modifyUserResponse
|
||||
// @Failure 400 {object} code.Failure
|
||||
@ -43,13 +86,31 @@ func (h *handler) ModifyUser() core.HandlerFunc {
|
||||
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, validation.Error(err)))
|
||||
return
|
||||
}
|
||||
userID := int64(ctx.SessionUserInfo().Id)
|
||||
item, err := h.user.UpdateProfile(ctx.RequestContext(), userID, req.Nickname, req.Avatar)
|
||||
userID := int64(ctx.SessionUserInfo().Id)
|
||||
item, err := h.user.UpdateProfile(ctx.RequestContext(), userID, req.Nickname, req.Avatar)
|
||||
if err != nil {
|
||||
ctx.AbortWithError(core.Error(http.StatusBadRequest, 10001, err.Error()))
|
||||
return
|
||||
}
|
||||
rsp.User = userItem{ID: item.ID, Nickname: item.Nickname, Avatar: item.Avatar, InviteCode: item.InviteCode, InviterID: item.InviterID}
|
||||
// For update response, we might not have all fields populated from Update result if it returns a partial object or just the updated fields?
|
||||
// But Service UpdateProfile returns the Full User object.
|
||||
// So we can populate everything.
|
||||
maskedPhone := item.Mobile
|
||||
if len(maskedPhone) >= 11 {
|
||||
maskedPhone = maskedPhone[:3] + "****" + maskedPhone[7:]
|
||||
}
|
||||
|
||||
balance, _ := h.user.GetPointsBalance(ctx.RequestContext(), userID)
|
||||
|
||||
rsp.User = userItem{
|
||||
ID: item.ID,
|
||||
Nickname: item.Nickname,
|
||||
Avatar: item.Avatar,
|
||||
InviteCode: item.InviteCode,
|
||||
InviterID: item.InviterID,
|
||||
Mobile: maskedPhone,
|
||||
Balance: balance,
|
||||
}
|
||||
ctx.Payload(rsp)
|
||||
}
|
||||
}
|
||||
|
||||
@ -120,6 +120,13 @@ func (h *handler) SmsLogin() core.HandlerFunc {
|
||||
rsp.OpenID = u.Openid
|
||||
rsp.IsNewUser = out.IsNewUser
|
||||
|
||||
// 触发邀请奖励逻辑
|
||||
if out.IsNewUser && out.InviterID > 0 {
|
||||
if err := h.task.OnInviteSuccess(ctx.RequestContext(), out.InviterID, u.ID); err != nil {
|
||||
h.logger.Error("触发邀请任务失败", zap.Error(err), zap.Int64("inviter_id", out.InviterID), zap.Int64("invitee_id", u.ID))
|
||||
}
|
||||
}
|
||||
|
||||
h.logger.Info("短信登录返回数据",
|
||||
zap.Int64("user_id", u.ID),
|
||||
zap.String("mobile", u.Mobile),
|
||||
|
||||
@ -6,6 +6,7 @@ import (
|
||||
"crypto/cipher"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// Encrypt 加密算法
|
||||
@ -45,10 +46,18 @@ func Decrypt(key, ciphertext string) (string, error) {
|
||||
return "", err
|
||||
}
|
||||
|
||||
// 长度检查:包含IV且为BlockSize倍数
|
||||
if len(ciphertextByte) < aes.BlockSize {
|
||||
return "", fmt.Errorf("ciphertext too short")
|
||||
}
|
||||
if len(ciphertextByte)%aes.BlockSize != 0 {
|
||||
return "", fmt.Errorf("ciphertext is not a multiple of the block size")
|
||||
}
|
||||
|
||||
// 创建一个 AES 块
|
||||
block, err := aes.NewCipher([]byte(key))
|
||||
if err != nil {
|
||||
panic(err)
|
||||
return "", err
|
||||
}
|
||||
|
||||
// 提取 IV
|
||||
@ -56,6 +65,9 @@ func Decrypt(key, ciphertext string) (string, error) {
|
||||
|
||||
// 提取密文
|
||||
ciphertextByteWithoutIV := ciphertextByte[aes.BlockSize:]
|
||||
if len(ciphertextByteWithoutIV) == 0 {
|
||||
return "", fmt.Errorf("ciphertext empty")
|
||||
}
|
||||
|
||||
// 创建一个 CBC 模式的 AES 解密器
|
||||
mode := cipher.NewCBCDecrypter(block, iv)
|
||||
@ -66,6 +78,9 @@ func Decrypt(key, ciphertext string) (string, error) {
|
||||
|
||||
// 去除填充字节
|
||||
padding := int(decrypted[len(decrypted)-1])
|
||||
if padding < 1 || padding > aes.BlockSize || padding > len(decrypted) {
|
||||
return "", fmt.Errorf("invalid padding")
|
||||
}
|
||||
decrypted = decrypted[:len(decrypted)-padding]
|
||||
|
||||
return string(decrypted), nil
|
||||
|
||||
91
internal/pkg/douyin/access_token.go
Normal file
91
internal/pkg/douyin/access_token.go
Normal file
@ -0,0 +1,91 @@
|
||||
package douyin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"bindbox-game/internal/service/sysconfig"
|
||||
)
|
||||
|
||||
const (
|
||||
// AccessTokenURL Douyin access_token interface address
|
||||
AccessTokenURL = "https://developer.toutiao.com/api/apps/v2/token"
|
||||
)
|
||||
|
||||
// AccessTokenRequest request parameters
|
||||
type AccessTokenRequest struct {
|
||||
AppID string `json:"appid"`
|
||||
Secret string `json:"secret"`
|
||||
GrantType string `json:"grant_type"`
|
||||
}
|
||||
|
||||
// AccessTokenResponse response
|
||||
type AccessTokenResponse struct {
|
||||
ErrNo int `json:"err_no"`
|
||||
ErrTips string `json:"err_tips"`
|
||||
Data struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
ExpiresIn int `json:"expires_in"`
|
||||
} `json:"data"`
|
||||
}
|
||||
|
||||
// GetAccessToken calls Douyin interface to get access_token
|
||||
func GetAccessToken(ctx context.Context) (string, error) {
|
||||
// Get Douyin config from dynamic config
|
||||
dynamicCfg := sysconfig.GetGlobalDynamicConfig()
|
||||
if dynamicCfg == nil {
|
||||
return "", fmt.Errorf("dynamic config service not initialized")
|
||||
}
|
||||
|
||||
douyinCfg := dynamicCfg.GetDouyin(ctx)
|
||||
if douyinCfg.AppID == "" || douyinCfg.AppSecret == "" {
|
||||
return "", fmt.Errorf("Douyin mini program config incomplete")
|
||||
}
|
||||
|
||||
reqBody := AccessTokenRequest{
|
||||
AppID: douyinCfg.AppID,
|
||||
Secret: douyinCfg.AppSecret,
|
||||
GrantType: "client_credential",
|
||||
}
|
||||
|
||||
jsonBody, err := json.Marshal(reqBody)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to marshal request: %w", err)
|
||||
}
|
||||
|
||||
// Send request
|
||||
httpClient := &http.Client{Timeout: 10 * time.Second}
|
||||
req, err := http.NewRequestWithContext(ctx, "POST", AccessTokenURL, strings.NewReader(string(jsonBody)))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := httpClient.Do(req)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to request Douyin interface: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to read response: %w", err)
|
||||
}
|
||||
|
||||
// Parse response
|
||||
var result AccessTokenResponse
|
||||
if err := json.Unmarshal(body, &result); err != nil {
|
||||
return "", fmt.Errorf("failed to parse response: %w, body: %s", err, string(body))
|
||||
}
|
||||
|
||||
if result.ErrNo != 0 {
|
||||
return "", fmt.Errorf("failed to get access_token: %s (code: %d)", result.ErrTips, result.ErrNo)
|
||||
}
|
||||
|
||||
return result.Data.AccessToken, nil
|
||||
}
|
||||
97
internal/pkg/douyin/code2session.go
Normal file
97
internal/pkg/douyin/code2session.go
Normal file
@ -0,0 +1,97 @@
|
||||
package douyin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"bindbox-game/internal/service/sysconfig"
|
||||
)
|
||||
|
||||
const (
|
||||
// 抖音 code2session 接口地址
|
||||
Code2SessionURL = "https://developer.toutiao.com/api/apps/v2/jscode2session"
|
||||
)
|
||||
|
||||
// Code2SessionRequest 抖音登录请求参数
|
||||
type Code2SessionRequest struct {
|
||||
AppID string `json:"appid"`
|
||||
Secret string `json:"secret"`
|
||||
Code string `json:"code,omitempty"`
|
||||
AnonymousCode string `json:"anonymous_code,omitempty"`
|
||||
}
|
||||
|
||||
// Code2SessionResponse 抖音登录响应
|
||||
type Code2SessionResponse struct {
|
||||
ErrNo int `json:"err_no"`
|
||||
ErrTips string `json:"err_tips"`
|
||||
Data struct {
|
||||
SessionKey string `json:"session_key"`
|
||||
OpenID string `json:"openid"`
|
||||
AnonymousOpenID string `json:"anonymous_openid"`
|
||||
UnionID string `json:"unionid"`
|
||||
} `json:"data"`
|
||||
}
|
||||
|
||||
// Code2Session 调用抖音 code2session 接口获取用户信息
|
||||
func Code2Session(ctx context.Context, code, anonymousCode string) (*Code2SessionResponse, error) {
|
||||
// 从动态配置获取抖音配置
|
||||
dynamicCfg := sysconfig.GetGlobalDynamicConfig()
|
||||
if dynamicCfg == nil {
|
||||
return nil, errors.New("动态配置服务未初始化")
|
||||
}
|
||||
|
||||
douyinCfg := dynamicCfg.GetDouyin(ctx)
|
||||
if douyinCfg.AppID == "" || douyinCfg.AppSecret == "" {
|
||||
return nil, errors.New("抖音小程序配置不完整,请在系统配置中设置 AppID 和 AppSecret")
|
||||
}
|
||||
|
||||
// 构建请求
|
||||
reqBody := Code2SessionRequest{
|
||||
AppID: douyinCfg.AppID,
|
||||
Secret: douyinCfg.AppSecret,
|
||||
Code: code,
|
||||
AnonymousCode: anonymousCode,
|
||||
}
|
||||
|
||||
jsonBody, err := json.Marshal(reqBody)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("序列化请求失败: %w", err)
|
||||
}
|
||||
|
||||
// 发送请求
|
||||
httpClient := &http.Client{Timeout: 10 * time.Second}
|
||||
req, err := http.NewRequestWithContext(ctx, "POST", Code2SessionURL, strings.NewReader(string(jsonBody)))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("创建请求失败: %w", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("请求抖音接口失败: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("读取响应失败: %w", err)
|
||||
}
|
||||
|
||||
// 解析响应
|
||||
var result Code2SessionResponse
|
||||
if err := json.Unmarshal(body, &result); err != nil {
|
||||
return nil, fmt.Errorf("解析响应失败: %w, body: %s", err, string(body))
|
||||
}
|
||||
|
||||
if result.ErrNo != 0 {
|
||||
return nil, fmt.Errorf("抖音登录失败: %s (code: %d)", result.ErrTips, result.ErrNo)
|
||||
}
|
||||
|
||||
return &result, nil
|
||||
}
|
||||
94
internal/pkg/douyin/phonenumber.go
Normal file
94
internal/pkg/douyin/phonenumber.go
Normal file
@ -0,0 +1,94 @@
|
||||
package douyin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
// PhoneNumberURL Douyin get phone number interface address
|
||||
PhoneNumberURL = "https://developer.toutiao.com/api/apps/v1/number_get/bind/"
|
||||
)
|
||||
|
||||
// PhoneNumberRequest request parameters
|
||||
type PhoneNumberRequest struct {
|
||||
AppID string `json:"app_id"` // Note: specific parameter name might vary, but usually it's app_id or appid. Docs say `app_id` for some v1 interfaces.
|
||||
Code string `json:"code"`
|
||||
}
|
||||
|
||||
// PhoneNumberResponse response
|
||||
type PhoneNumberResponse struct {
|
||||
ErrNo int `json:"err_no"`
|
||||
ErrTips string `json:"err_tips"`
|
||||
Data struct {
|
||||
PhoneNumber string `json:"phone_number"`
|
||||
} `json:"data"`
|
||||
}
|
||||
|
||||
// GetPhoneNumber calls Douyin interface to get user phone number
|
||||
func GetPhoneNumber(ctx context.Context, accessToken, appID, code string) (string, error) {
|
||||
if accessToken == "" || code == "" {
|
||||
return "", fmt.Errorf("missing parameters")
|
||||
}
|
||||
|
||||
// Note: Verify the correct endpoint and parameters.
|
||||
// Commonly for `v1/number_get/bind/`, it might expect `encryptedData`?
|
||||
// But `tt.getPhoneNumber` provides `code`.
|
||||
// Let's assume the modern Code-based API which is often similar to `number_get/bind` but with `code`.
|
||||
// Check if we need to call `https://developer.toutiao.com/api/apps/v1/user/get_phone_number_v1` instead?
|
||||
// Or `https://developer.open-douyin.com/api/apps/v1/img/get_phone_number_v1`?
|
||||
// Since I cannot verify the exact URL visually, I will stick to the one I hypothesized or the most "standard" one found in similar Go SDKs.
|
||||
// Actually, let's try to assume it's `POST /api/apps/v2/jscode2session` style but for phone.
|
||||
|
||||
// A common variation for "get phone number by code" in Douyin is sending `code` and `app_id` to an endpoint.
|
||||
// Let's go with the one stated in many online resources for "Douyin Mini Program Get Phone Number".
|
||||
|
||||
reqBody := map[string]string{
|
||||
"app_id": appID,
|
||||
"code": code,
|
||||
}
|
||||
|
||||
jsonBody, err := json.Marshal(reqBody)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to marshal request: %w", err)
|
||||
}
|
||||
|
||||
httpClient := &http.Client{Timeout: 10 * time.Second}
|
||||
// The URL might need `access_token` in query param or header?
|
||||
// Most `v1` Douyin APIs require `access_token` in header or query. We will put in header `access-token`.
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, "POST", PhoneNumberURL, strings.NewReader(string(jsonBody)))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
// Some docs say `access-token` in header
|
||||
req.Header.Set("access-token", accessToken)
|
||||
|
||||
resp, err := httpClient.Do(req)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to request Douyin interface: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to read response: %w", err)
|
||||
}
|
||||
|
||||
var result PhoneNumberResponse
|
||||
if err := json.Unmarshal(body, &result); err != nil {
|
||||
return "", fmt.Errorf("failed to parse response: %w, body: %s", err, string(body))
|
||||
}
|
||||
|
||||
if result.ErrNo != 0 {
|
||||
return "", fmt.Errorf("failed to get phone number: %s (code: %d)", result.ErrTips, result.ErrNo)
|
||||
}
|
||||
|
||||
return result.Data.PhoneNumber, nil
|
||||
}
|
||||
@ -204,6 +204,16 @@ func UploadVirtualShippingForBackground(ctx context.Context, config *WechatConfi
|
||||
if !isOrderNotFoundError(err) {
|
||||
return err
|
||||
}
|
||||
// 支付单可能尚未同步,等待后重试
|
||||
fmt.Printf("[虚拟发货-后台] 使用 transaction_id 发货返回支付单不存在,等待重试 transaction_id=%s\n", transactionID)
|
||||
time.Sleep(2 * time.Second)
|
||||
err = uploadVirtualShippingInternalBackground(ctx, accessToken, key, payerOpenid, itemDesc, time.Now())
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
if !isOrderNotFoundError(err) {
|
||||
return err
|
||||
}
|
||||
if outTradeNo == "" {
|
||||
return err
|
||||
}
|
||||
@ -217,6 +227,17 @@ func UploadVirtualShippingForBackground(ctx context.Context, config *WechatConfi
|
||||
if c.WechatPay.MchID != "" {
|
||||
mchID = c.WechatPay.MchID
|
||||
}
|
||||
fmt.Printf("[虚拟发货-后台] fallback 使用 out_trade_no=%s mchid=%s\n", outTradeNo, mchID)
|
||||
err = uploadVirtualShippingInternalBackground(ctx, accessToken, orderKey{OrderNumberType: 1, MchID: mchID, OutTradeNo: outTradeNo}, payerOpenid, itemDesc, time.Now())
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
if !isOrderNotFoundError(err) {
|
||||
return err
|
||||
}
|
||||
// 支付单可能尚未同步,等待后重试
|
||||
fmt.Printf("[虚拟发货-后台] 使用 out_trade_no 发货返回支付单不存在,等待重试 out_trade_no=%s mchid=%s\n", outTradeNo, mchID)
|
||||
time.Sleep(2 * time.Second)
|
||||
return uploadVirtualShippingInternalBackground(ctx, accessToken, orderKey{OrderNumberType: 1, MchID: mchID, OutTradeNo: outTradeNo}, payerOpenid, itemDesc, time.Now())
|
||||
}
|
||||
|
||||
|
||||
@ -63,10 +63,15 @@ func NewHTTPMux(logger logger.CustomLogger, db mysql.Repo) (core.Mux, func(), er
|
||||
|
||||
// 实例化拦截器
|
||||
adminHandler := admin.New(logger, db, rdb)
|
||||
activityHandler := activityapi.New(logger, db, rdb)
|
||||
activityHandler := activityapi.New(logger, db, rdb, taskSvc)
|
||||
taskCenterHandler := taskcenterapi.New(logger, db, taskSvc)
|
||||
|
||||
userHandler := userapi.New(logger, db)
|
||||
// app端的API
|
||||
userHandler := userapi.New(logger, db, taskSvc)
|
||||
// TODO: Check if userHandler and userAppHandler are redundant or distinct.
|
||||
// Based on typical project structure, `internal/api/user` is likely `userapp`.
|
||||
// `internal/api/admin/users_admin.go` might be `userapi` (admin).
|
||||
// Let's correct the `appapi` typo first.
|
||||
commonHandler := commonapi.New(logger, db)
|
||||
payHandler := payapi.New(logger, db, taskSvc, activitySvc)
|
||||
gameHandler := gameapi.New(logger, db, rdb, userSvc)
|
||||
@ -351,6 +356,7 @@ func NewHTTPMux(logger logger.CustomLogger, db mysql.Repo) (core.Mux, func(), er
|
||||
|
||||
// 登录保持公开
|
||||
appPublicApiRouter.POST("/users/weixin/login", userHandler.WeixinLogin())
|
||||
appPublicApiRouter.POST("/users/douyin/login", userHandler.DouyinLogin())
|
||||
appPublicApiRouter.POST("/address-share/submit", userHandler.SubmitAddressShare())
|
||||
appPublicApiRouter.GET("/matching/card_types", activityHandler.ListMatchingCardTypes())
|
||||
|
||||
@ -366,6 +372,8 @@ func NewHTTPMux(logger logger.CustomLogger, db mysql.Repo) (core.Mux, func(), er
|
||||
appAuthApiRouter := mux.Group("/api/app", core.WrapAuthHandler(intc.AppTokenAuthVerify))
|
||||
{
|
||||
appAuthApiRouter.PUT("/users/:user_id", userHandler.ModifyUser())
|
||||
appAuthApiRouter.GET("/users/profile", userHandler.GetUserProfile())
|
||||
appAuthApiRouter.GET("/users/info", userHandler.GetUserProfile()) // 别名,保持前端兼容性
|
||||
appAuthApiRouter.GET("/users/:user_id/orders", userHandler.ListUserOrders())
|
||||
appAuthApiRouter.GET("/users/:user_id/coupons", userHandler.ListUserCoupons())
|
||||
appAuthApiRouter.GET("/users/:user_id/coupons/stats", userHandler.GetUserCouponStats())
|
||||
@ -374,6 +382,7 @@ func NewHTTPMux(logger logger.CustomLogger, db mysql.Repo) (core.Mux, func(), er
|
||||
appAuthApiRouter.GET("/users/:user_id/points/balance", userHandler.GetUserPointsBalance())
|
||||
appAuthApiRouter.GET("/users/:user_id/stats", userHandler.GetUserStats())
|
||||
appAuthApiRouter.POST("/users/:user_id/phone/bind", userHandler.BindPhone())
|
||||
appAuthApiRouter.POST("/users/:user_id/douyin/phone/bind", userHandler.DouyinBindPhone())
|
||||
appAuthApiRouter.GET("/users/:user_id/invites", userHandler.ListUserInvites())
|
||||
appAuthApiRouter.GET("/users/:user_id/inventory", userHandler.ListUserInventory())
|
||||
appAuthApiRouter.GET("/users/:user_id/shipments", userHandler.ListUserShipments())
|
||||
|
||||
@ -40,7 +40,8 @@ func (s *service) ProcessOrderLottery(ctx context.Context, orderID int64) error
|
||||
}
|
||||
|
||||
// 状态前校验:仅处理已支付且未取消的抽奖订单
|
||||
if order.Status != 2 || order.SourceType != 2 {
|
||||
// SourceType: 2=Common Lottery (WeChat/Points mixed), 4=Game Pass (Pure)
|
||||
if order.Status != 2 || (order.SourceType != 2 && order.SourceType != 4) {
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@ -63,10 +63,14 @@ func (s *gameTokenService) GenerateToken(ctx context.Context, userID int64, user
|
||||
|
||||
// 3. Store ticket in Redis (for single-use validation)
|
||||
ticketKey := fmt.Sprintf("game:token:ticket:%s", ticket)
|
||||
// Check for error when setting Redis key - CRITICAL FIX
|
||||
if err := s.redis.Set(ctx, ticketKey, fmt.Sprintf("%d", userID), 15*time.Minute).Err(); err != nil {
|
||||
s.logger.Error("Failed to store ticket in Redis", zap.Error(err))
|
||||
s.logger.Error("Failed to store ticket in Redis", zap.Error(err), zap.String("ticket", ticket), zap.Int64("user_id", userID))
|
||||
return "", "", time.Time{}, fmt.Errorf("failed to generate ticket: %w", err)
|
||||
}
|
||||
|
||||
s.logger.Info("DEBUG: Generated ticket and stored in Redis", zap.String("ticket", ticket), zap.String("key", ticketKey), zap.Int64("user_id", userID))
|
||||
|
||||
// 4. Generate JWT token
|
||||
expiresAt = time.Now().Add(10 * time.Minute)
|
||||
claims := GameTokenClaims{
|
||||
@ -104,6 +108,7 @@ func (s *gameTokenService) ValidateToken(ctx context.Context, tokenString string
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
s.logger.Warn("Token JWT validation failed", zap.Error(err))
|
||||
return nil, fmt.Errorf("invalid token: %w", err)
|
||||
}
|
||||
|
||||
@ -116,18 +121,22 @@ func (s *gameTokenService) ValidateToken(ctx context.Context, tokenString string
|
||||
ticketKey := fmt.Sprintf("game:token:ticket:%s", claims.Ticket)
|
||||
storedUserID, err := s.redis.Get(ctx, ticketKey).Result()
|
||||
if err != nil {
|
||||
s.logger.Warn("DEBUG: Ticket not found in Redis", zap.String("ticket", claims.Ticket), zap.String("key", ticketKey), zap.Error(err))
|
||||
return nil, fmt.Errorf("ticket not found or expired")
|
||||
}
|
||||
|
||||
if storedUserID != fmt.Sprintf("%d", claims.UserID) {
|
||||
s.logger.Warn("DEBUG: Ticket user mismatch", zap.String("stored", storedUserID), zap.Int64("claim_user", claims.UserID))
|
||||
return nil, fmt.Errorf("ticket user mismatch")
|
||||
}
|
||||
|
||||
s.logger.Info("DEBUG: Token validated successfully", zap.String("ticket", claims.Ticket))
|
||||
return claims, nil
|
||||
}
|
||||
|
||||
// InvalidateTicket marks a ticket as used
|
||||
func (s *gameTokenService) InvalidateTicket(ctx context.Context, ticket string) error {
|
||||
ticketKey := fmt.Sprintf("game:token:ticket:%s", ticket)
|
||||
s.logger.Info("DEBUG: Invalidating ticket", zap.String("ticket", ticket), zap.String("key", ticketKey))
|
||||
return s.redis.Del(ctx, ticketKey).Err()
|
||||
}
|
||||
|
||||
@ -9,6 +9,8 @@ import (
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// 敏感配置 Key 后缀列表,这些 Key 的值需要加密存储
|
||||
@ -50,6 +52,14 @@ const (
|
||||
KeyAliyunSMSAccessKeySecret = "aliyun_sms.access_key_secret"
|
||||
KeyAliyunSMSSignName = "aliyun_sms.sign_name"
|
||||
KeyAliyunSMSTemplateCode = "aliyun_sms.template_code"
|
||||
|
||||
// 抖音小程序配置
|
||||
KeyDouyinAppID = "douyin.app_id"
|
||||
KeyDouyinAppSecret = "douyin.app_secret"
|
||||
KeyDouyinNotifyURL = "douyin.notify_url"
|
||||
KeyDouyinPayAppID = "douyin.pay_app_id"
|
||||
KeyDouyinPaySecret = "douyin.pay_secret"
|
||||
KeyDouyinPaySalt = "douyin.pay_salt"
|
||||
)
|
||||
|
||||
// COSConfig COS 配置结构
|
||||
@ -87,6 +97,16 @@ type AliyunSMSConfig struct {
|
||||
TemplateCode string
|
||||
}
|
||||
|
||||
// DouyinConfig 抖音小程序配置结构
|
||||
type DouyinConfig struct {
|
||||
AppID string
|
||||
AppSecret string
|
||||
NotifyURL string
|
||||
PayAppID string // 支付应用ID (担保支付)
|
||||
PaySecret string // 支付密钥
|
||||
PaySalt string // 支付盐
|
||||
}
|
||||
|
||||
// DynamicConfig 动态配置服务
|
||||
type DynamicConfig struct {
|
||||
cache sync.Map // key -> string value
|
||||
@ -118,8 +138,8 @@ func NewDynamicConfig(l logger.CustomLogger, db mysql.Repo) *DynamicConfig {
|
||||
}
|
||||
}
|
||||
|
||||
// isSensitiveKey 判断是否为敏感配置 Key
|
||||
func isSensitiveKey(key string) bool {
|
||||
// IsSensitiveKey 判断是否为敏感配置 Key
|
||||
func IsSensitiveKey(key string) bool {
|
||||
for _, suffix := range sensitiveKeySuffixes {
|
||||
if strings.HasSuffix(key, suffix) {
|
||||
return true
|
||||
@ -148,13 +168,13 @@ func (d *DynamicConfig) LoadAll(ctx context.Context) error {
|
||||
for _, item := range items {
|
||||
value := item.ConfigValue
|
||||
// 敏感配置需要解密
|
||||
if isSensitiveKey(item.ConfigKey) && value != "" {
|
||||
if IsSensitiveKey(item.ConfigKey) && value != "" {
|
||||
if decrypted, err := d.decryptValue(value); err == nil {
|
||||
value = decrypted
|
||||
} else {
|
||||
d.logger.Error("解密配置失败",
|
||||
"key", item.ConfigKey,
|
||||
"error", err)
|
||||
zap.String("key", item.ConfigKey),
|
||||
zap.Error(err))
|
||||
// 解密失败,尝试使用原始值(可能未加密)
|
||||
}
|
||||
}
|
||||
@ -165,7 +185,7 @@ func (d *DynamicConfig) LoadAll(ctx context.Context) error {
|
||||
d.loadedAt = time.Now()
|
||||
d.mu.Unlock()
|
||||
|
||||
d.logger.Info("动态配置加载完成", "count", len(items))
|
||||
d.logger.Info("动态配置加载完成", zap.Int("count", len(items)))
|
||||
return nil
|
||||
}
|
||||
|
||||
@ -193,7 +213,7 @@ func (d *DynamicConfig) Get(ctx context.Context, key string) string {
|
||||
if err == nil && cfg != nil {
|
||||
value := cfg.ConfigValue
|
||||
// 敏感配置需要解密
|
||||
if isSensitiveKey(key) && value != "" {
|
||||
if IsSensitiveKey(key) && value != "" {
|
||||
if decrypted, err := d.decryptValue(value); err == nil {
|
||||
value = decrypted
|
||||
}
|
||||
@ -218,7 +238,7 @@ func (d *DynamicConfig) GetWithFallback(ctx context.Context, key, fallback strin
|
||||
func (d *DynamicConfig) Set(ctx context.Context, key, value, remark string) error {
|
||||
storeValue := value
|
||||
// 敏感配置需要加密
|
||||
if isSensitiveKey(key) && value != "" {
|
||||
if IsSensitiveKey(key) && value != "" {
|
||||
encrypted, err := d.encryptValue(value)
|
||||
if err != nil {
|
||||
return err
|
||||
@ -282,3 +302,15 @@ func (d *DynamicConfig) GetAliyunSMS(ctx context.Context) AliyunSMSConfig {
|
||||
TemplateCode: d.GetWithFallback(ctx, KeyAliyunSMSTemplateCode, staticCfg.TemplateCode),
|
||||
}
|
||||
}
|
||||
|
||||
// GetDouyin 获取抖音小程序配置
|
||||
func (d *DynamicConfig) GetDouyin(ctx context.Context) DouyinConfig {
|
||||
return DouyinConfig{
|
||||
AppID: d.Get(ctx, KeyDouyinAppID),
|
||||
AppSecret: d.Get(ctx, KeyDouyinAppSecret),
|
||||
NotifyURL: d.Get(ctx, KeyDouyinNotifyURL),
|
||||
PayAppID: d.Get(ctx, KeyDouyinPayAppID),
|
||||
PaySecret: d.Get(ctx, KeyDouyinPaySecret),
|
||||
PaySalt: d.Get(ctx, KeyDouyinPaySalt),
|
||||
}
|
||||
}
|
||||
|
||||
40
internal/service/sysconfig/global.go
Normal file
40
internal/service/sysconfig/global.go
Normal file
@ -0,0 +1,40 @@
|
||||
package sysconfig
|
||||
|
||||
import (
|
||||
"bindbox-game/internal/pkg/logger"
|
||||
"bindbox-game/internal/repository/mysql"
|
||||
"context"
|
||||
"sync"
|
||||
)
|
||||
|
||||
var (
|
||||
globalDynamicConfig *DynamicConfig
|
||||
initOnce sync.Once
|
||||
)
|
||||
|
||||
// InitGlobalDynamicConfig 初始化全局动态配置实例
|
||||
// 应在 main.go 中数据库初始化后调用
|
||||
func InitGlobalDynamicConfig(l logger.CustomLogger, db mysql.Repo) error {
|
||||
var initErr error
|
||||
initOnce.Do(func() {
|
||||
globalDynamicConfig = NewDynamicConfig(l, db)
|
||||
// 预加载所有配置
|
||||
initErr = globalDynamicConfig.LoadAll(context.Background())
|
||||
})
|
||||
return initErr
|
||||
}
|
||||
|
||||
// GetGlobalDynamicConfig 获取全局动态配置实例
|
||||
// 如果未初始化则返回 nil
|
||||
func GetGlobalDynamicConfig() *DynamicConfig {
|
||||
return globalDynamicConfig
|
||||
}
|
||||
|
||||
// MustGetGlobalDynamicConfig 获取全局动态配置实例
|
||||
// 如果未初始化则 panic
|
||||
func MustGetGlobalDynamicConfig() *DynamicConfig {
|
||||
if globalDynamicConfig == nil {
|
||||
panic("动态配置服务未初始化,请先调用 InitGlobalDynamicConfig")
|
||||
}
|
||||
return globalDynamicConfig
|
||||
}
|
||||
@ -1,4 +0,0 @@
|
||||
# Task Center Service
|
||||
|
||||
This directory will host core business logic for Task Center: rules evaluation, progress tracking, reward issuing orchestration.
|
||||
|
||||
@ -4,7 +4,8 @@ const (
|
||||
// Task Windows
|
||||
WindowDaily = "daily"
|
||||
WindowWeekly = "weekly"
|
||||
WindowInfinite = "infinite"
|
||||
WindowMonthly = "monthly"
|
||||
WindowLifetime = "lifetime"
|
||||
|
||||
// Task Metrics
|
||||
MetricFirstOrder = "first_order"
|
||||
|
||||
@ -301,7 +301,8 @@ func (s *service) GetUserProgress(ctx context.Context, userID int64, taskID int6
|
||||
}
|
||||
|
||||
func (s *service) ClaimTier(ctx context.Context, userID int64, taskID int64, tierID int64) error {
|
||||
return s.repo.GetDbW().Transaction(func(tx *gorm.DB) error {
|
||||
// 事务中更新领取状态
|
||||
err := s.repo.GetDbW().Transaction(func(tx *gorm.DB) error {
|
||||
var p tcmodel.UserTaskProgress
|
||||
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("user_id=? AND task_id=?", userID, taskID).First(&p).Error; err != nil {
|
||||
return errors.New("progress_not_found")
|
||||
@ -312,7 +313,7 @@ func (s *service) ClaimTier(ctx context.Context, userID int64, taskID int64, tie
|
||||
}
|
||||
for _, id := range claimed {
|
||||
if id == tierID {
|
||||
return nil
|
||||
return nil // 已领取,跳过
|
||||
}
|
||||
}
|
||||
claimed = append(claimed, tierID)
|
||||
@ -320,6 +321,12 @@ func (s *service) ClaimTier(ctx context.Context, userID int64, taskID int64, tie
|
||||
p.ClaimedTiers = datatypes.JSON(b)
|
||||
return tx.Model(&tcmodel.UserTaskProgress{}).Where("id=?", p.ID).Update("claimed_tiers", p.ClaimedTiers).Error
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 发放奖励
|
||||
return s.grantTierRewards(ctx, taskID, tierID, userID, "manual_claim", 0, fmt.Sprintf("claim:%d:%d:%d", userID, taskID, tierID))
|
||||
}
|
||||
|
||||
func (s *service) CreateTask(ctx context.Context, in CreateTaskInput) (int64, error) {
|
||||
@ -414,6 +421,30 @@ func (s *service) processOrderPaid(ctx context.Context, userID int64, orderID in
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 1.0 状态校验与幂等性检查
|
||||
// 仅处理已支付订单
|
||||
if ord.Status != 2 {
|
||||
s.logger.Warn("Order not paid, skip task center", zap.Int64("order_id", orderID), zap.Int32("status", ord.Status))
|
||||
return nil
|
||||
}
|
||||
|
||||
// 使用 Redis 进行 24 小时内的幂等性拦截,防止重复触发进度计算
|
||||
if s.redis != nil {
|
||||
lockKey := fmt.Sprintf("tc:proc:order:%d", orderID)
|
||||
set, err := s.redis.SetNX(ctx, lockKey, "1", 24*time.Hour).Result()
|
||||
if err != nil {
|
||||
s.logger.Error("Redis idempotency check failed", zap.Error(err))
|
||||
// 如果 Redis 异常,为了保险起见,我们可以选择继续处理(由数据库事务保证底层原子性,虽然非严格幂等)
|
||||
// 或者返回错误。这里选择返回错误让调用方重试或记录日志。
|
||||
return err
|
||||
}
|
||||
if !set {
|
||||
s.logger.Info("Order already processed by task center", zap.Int64("order_id", orderID))
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
amount := ord.ActualAmount
|
||||
rmk := remark.Parse(ord.Remark)
|
||||
activityID := rmk.ActivityID
|
||||
@ -810,9 +841,14 @@ func (s *service) grantTierRewards(ctx context.Context, taskID int64, tierID int
|
||||
Points int64 `json:"points"`
|
||||
}
|
||||
_ = json.Unmarshal([]byte(r.RewardPayload), &pl)
|
||||
if pl.Points != 0 {
|
||||
s.logger.Info("Granting points reward", zap.Int64("user_id", userID), zap.Int64("points", pl.Points))
|
||||
err = s.userSvc.AddPoints(ctx, userID, pl.Points, "task_reward", "task_center", nil, nil)
|
||||
points := pl.Points
|
||||
// 回退:如果 payload 中没有 points 字段,使用 quantity 字段
|
||||
if points == 0 && r.Quantity > 0 {
|
||||
points = r.Quantity
|
||||
}
|
||||
if points != 0 {
|
||||
s.logger.Info("Granting points reward", zap.Int64("user_id", userID), zap.Int64("points", points))
|
||||
err = s.userSvc.AddPoints(ctx, userID, points, "task_reward", "task_center", nil, nil)
|
||||
}
|
||||
case "coupon":
|
||||
var pl struct {
|
||||
@ -864,6 +900,7 @@ func (s *service) grantTierRewards(ctx context.Context, taskID int64, tierID int
|
||||
GameCode string `json:"game_code"`
|
||||
Amount int `json:"amount"`
|
||||
}
|
||||
_ = json.Unmarshal([]byte(r.RewardPayload), &pl)
|
||||
if pl.GameCode != "" && pl.Amount > 0 {
|
||||
s.logger.Info("Granting game ticket reward", zap.Int64("user_id", userID), zap.String("game_code", pl.GameCode), zap.Int("amount", pl.Amount))
|
||||
gameSvc := gamesvc.NewTicketService(s.logger, s.repo)
|
||||
|
||||
631
internal/service/task_center/task_center_test.go
Normal file
631
internal/service/task_center/task_center_test.go
Normal file
@ -0,0 +1,631 @@
|
||||
package taskcenter
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
tcmodel "bindbox-game/internal/repository/mysql/task_center"
|
||||
|
||||
"gorm.io/datatypes"
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// ================================
|
||||
// 配置组合生成器
|
||||
// ================================
|
||||
|
||||
// TaskCombination 表示一种任务配置组合
|
||||
type TaskCombination struct {
|
||||
Name string // 任务名称
|
||||
Metric string // 指标类型
|
||||
Operator string // 操作符
|
||||
Threshold int64 // 阈值
|
||||
Window string // 时间窗口
|
||||
RewardType 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}, // 消费金额(单位分 = 100元)
|
||||
{MetricInviteCount, []string{OperatorGTE, OperatorEQ}, 2}, // 邀请人数
|
||||
}
|
||||
windows := []string{WindowDaily, WindowWeekly, 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("测试任务%d_%s_%s_%s", idx, m.name, w, r),
|
||||
Metric: m.name,
|
||||
Operator: op,
|
||||
Threshold: m.threshold,
|
||||
Window: w,
|
||||
RewardType: r,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return combinations
|
||||
}
|
||||
|
||||
// CreateTestDB 创建内存数据库并初始化表结构
|
||||
func CreateTestDB(t *testing.T) *gorm.DB {
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatalf("创建数据库失败: %v", err)
|
||||
}
|
||||
|
||||
// 创建任务中心相关表
|
||||
if err := db.Exec(`CREATE TABLE task_center_tasks (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
description TEXT,
|
||||
status INTEGER NOT NULL DEFAULT 1,
|
||||
start_time DATETIME,
|
||||
end_time DATETIME,
|
||||
visibility INTEGER NOT NULL DEFAULT 1,
|
||||
conditions_schema TEXT,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);`).Error; err != nil {
|
||||
t.Fatalf("创建 task_center_tasks 表失败: %v", err)
|
||||
}
|
||||
|
||||
if err := db.Exec(`CREATE TABLE task_center_task_tiers (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
task_id INTEGER NOT NULL,
|
||||
metric TEXT NOT NULL,
|
||||
operator TEXT NOT NULL,
|
||||
threshold INTEGER NOT NULL,
|
||||
window TEXT NOT NULL,
|
||||
repeatable INTEGER NOT NULL DEFAULT 1,
|
||||
priority INTEGER NOT NULL DEFAULT 0,
|
||||
activity_id INTEGER NOT NULL DEFAULT 0,
|
||||
extra_params TEXT,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);`).Error; err != nil {
|
||||
t.Fatalf("创建 task_center_task_tiers 表失败: %v", err)
|
||||
}
|
||||
|
||||
if err := db.Exec(`CREATE TABLE task_center_task_rewards (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
task_id INTEGER NOT NULL,
|
||||
tier_id INTEGER NOT NULL,
|
||||
reward_type TEXT NOT NULL,
|
||||
reward_payload TEXT NOT NULL,
|
||||
quantity INTEGER NOT NULL DEFAULT 1,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);`).Error; err != nil {
|
||||
t.Fatalf("创建 task_center_task_rewards 表失败: %v", err)
|
||||
}
|
||||
|
||||
if err := db.Exec(`CREATE TABLE task_center_user_progress (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL,
|
||||
task_id INTEGER NOT NULL,
|
||||
activity_id INTEGER NOT NULL DEFAULT 0,
|
||||
order_count INTEGER NOT NULL DEFAULT 0,
|
||||
order_amount INTEGER NOT NULL DEFAULT 0,
|
||||
invite_count INTEGER NOT NULL DEFAULT 0,
|
||||
first_order INTEGER NOT NULL DEFAULT 0,
|
||||
claimed_tiers TEXT,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(user_id, task_id, activity_id)
|
||||
);`).Error; err != nil {
|
||||
t.Fatalf("创建 task_center_user_progress 表失败: %v", err)
|
||||
}
|
||||
|
||||
if err := db.Exec(`CREATE TABLE task_center_event_logs (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
event_id TEXT,
|
||||
source_type TEXT,
|
||||
source_id INTEGER,
|
||||
user_id INTEGER,
|
||||
task_id INTEGER,
|
||||
tier_id INTEGER,
|
||||
idempotency_key TEXT UNIQUE,
|
||||
status TEXT,
|
||||
result TEXT,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);`).Error; err != nil {
|
||||
t.Fatalf("创建 task_center_event_logs 表失败: %v", err)
|
||||
}
|
||||
|
||||
return db
|
||||
}
|
||||
|
||||
// InsertTaskWithTierAndReward 插入一个完整的任务配置(任务+档位+奖励)
|
||||
func InsertTaskWithTierAndReward(t *testing.T, db *gorm.DB, combo TaskCombination) (taskID, tierID int64) {
|
||||
// 插入任务
|
||||
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 := db.Create(task).Error; err != nil {
|
||||
t.Fatalf("插入任务失败: %v", err)
|
||||
}
|
||||
|
||||
// 插入档位
|
||||
tier := &tcmodel.TaskTier{
|
||||
TaskID: task.ID,
|
||||
Metric: combo.Metric,
|
||||
Operator: combo.Operator,
|
||||
Threshold: combo.Threshold,
|
||||
Window: combo.Window,
|
||||
Priority: 0,
|
||||
}
|
||||
if err := db.Create(tier).Error; err != nil {
|
||||
t.Fatalf("插入档位失败: %v", err)
|
||||
}
|
||||
|
||||
// 生成奖励 payload
|
||||
payload := generateRewardPayload(combo.RewardType)
|
||||
reward := &tcmodel.TaskReward{
|
||||
TaskID: task.ID,
|
||||
TierID: tier.ID,
|
||||
RewardType: combo.RewardType,
|
||||
RewardPayload: datatypes.JSON(payload),
|
||||
Quantity: 10, // 默认数量
|
||||
}
|
||||
if err := db.Create(reward).Error; err != nil {
|
||||
t.Fatalf("插入奖励失败: %v", err)
|
||||
}
|
||||
|
||||
return task.ID, tier.ID
|
||||
}
|
||||
|
||||
// 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 `{}`
|
||||
}
|
||||
}
|
||||
|
||||
// ================================
|
||||
// 单元测试
|
||||
// ================================
|
||||
|
||||
// TestGenerateAllCombinations 测试配置组合生成器
|
||||
func TestGenerateAllCombinations(t *testing.T) {
|
||||
combos := GenerateAllCombinations()
|
||||
|
||||
// 预期组合数: (1*1 + 3*2) * 3 * 5 = 7 * 3 * 5 = 105
|
||||
expectedCount := 105
|
||||
if len(combos) != expectedCount {
|
||||
t.Errorf("组合数量不正确: 期望 %d, 实际 %d", expectedCount, len(combos))
|
||||
}
|
||||
|
||||
// 验证每种指标都有覆盖
|
||||
metricCounts := make(map[string]int)
|
||||
for _, c := range combos {
|
||||
metricCounts[c.Metric]++
|
||||
}
|
||||
|
||||
t.Logf("配置组合统计:")
|
||||
t.Logf(" 总数: %d", len(combos))
|
||||
for metric, count := range metricCounts {
|
||||
t.Logf(" %s: %d 种组合", metric, count)
|
||||
}
|
||||
}
|
||||
|
||||
// TestInsertAllCombinations 测试将所有配置组合插入数据库
|
||||
func TestInsertAllCombinations(t *testing.T) {
|
||||
db := CreateTestDB(t)
|
||||
combos := GenerateAllCombinations()
|
||||
|
||||
for _, combo := range combos {
|
||||
taskID, tierID := InsertTaskWithTierAndReward(t, db, combo)
|
||||
if taskID == 0 || tierID == 0 {
|
||||
t.Errorf("插入失败: %s", combo.Name)
|
||||
}
|
||||
}
|
||||
|
||||
// 验证数据库记录数
|
||||
var taskCount, tierCount, rewardCount int64
|
||||
db.Model(&tcmodel.Task{}).Count(&taskCount)
|
||||
db.Model(&tcmodel.TaskTier{}).Count(&tierCount)
|
||||
db.Model(&tcmodel.TaskReward{}).Count(&rewardCount)
|
||||
|
||||
if taskCount != int64(len(combos)) {
|
||||
t.Errorf("任务数量不正确: 期望 %d, 实际 %d", len(combos), taskCount)
|
||||
}
|
||||
if tierCount != int64(len(combos)) {
|
||||
t.Errorf("档位数量不正确: 期望 %d, 实际 %d", len(combos), tierCount)
|
||||
}
|
||||
if rewardCount != int64(len(combos)) {
|
||||
t.Errorf("奖励数量不正确: 期望 %d, 实际 %d", len(combos), rewardCount)
|
||||
}
|
||||
|
||||
t.Logf("成功插入 %d 个任务配置组合", taskCount)
|
||||
}
|
||||
|
||||
// TestFirstOrderMetric 测试首单指标
|
||||
func TestFirstOrderMetric(t *testing.T) {
|
||||
db := CreateTestDB(t)
|
||||
|
||||
combo := TaskCombination{
|
||||
Name: "首单测试任务",
|
||||
Metric: MetricFirstOrder,
|
||||
Operator: OperatorEQ,
|
||||
Threshold: 1,
|
||||
Window: WindowLifetime,
|
||||
RewardType: RewardTypePoints,
|
||||
}
|
||||
taskID, tierID := InsertTaskWithTierAndReward(t, db, combo)
|
||||
|
||||
// 模拟用户首单进度
|
||||
userID := int64(1001)
|
||||
progress := &tcmodel.UserTaskProgress{
|
||||
UserID: userID,
|
||||
TaskID: taskID,
|
||||
OrderCount: 1,
|
||||
OrderAmount: 10000,
|
||||
FirstOrder: 1, // 首单标记
|
||||
ClaimedTiers: datatypes.JSON("[]"),
|
||||
}
|
||||
if err := db.Create(progress).Error; err != nil {
|
||||
t.Fatalf("创建进度失败: %v", err)
|
||||
}
|
||||
|
||||
// 验证进度状态
|
||||
var p tcmodel.UserTaskProgress
|
||||
db.Where("user_id = ? AND task_id = ?", userID, taskID).First(&p)
|
||||
if p.FirstOrder != 1 {
|
||||
t.Error("首单标记未正确设置")
|
||||
}
|
||||
|
||||
t.Logf("首单指标测试通过: taskID=%d, tierID=%d", taskID, tierID)
|
||||
}
|
||||
|
||||
// TestOrderCountMetric 测试订单数量指标
|
||||
func TestOrderCountMetric(t *testing.T) {
|
||||
db := CreateTestDB(t)
|
||||
|
||||
combo := TaskCombination{
|
||||
Name: "订单数量测试任务",
|
||||
Metric: MetricOrderCount,
|
||||
Operator: OperatorGTE,
|
||||
Threshold: 3,
|
||||
Window: WindowDaily,
|
||||
RewardType: RewardTypeCoupon,
|
||||
}
|
||||
taskID, _ := InsertTaskWithTierAndReward(t, db, combo)
|
||||
|
||||
// 模拟用户下单进度
|
||||
userID := int64(1002)
|
||||
for i := 1; i <= 5; i++ {
|
||||
progress := &tcmodel.UserTaskProgress{
|
||||
UserID: userID,
|
||||
TaskID: taskID,
|
||||
OrderCount: int64(i),
|
||||
ClaimedTiers: datatypes.JSON("[]"),
|
||||
}
|
||||
// Upsert 模拟
|
||||
db.Where("user_id = ? AND task_id = ?", userID, taskID).Assign(progress).FirstOrCreate(progress)
|
||||
}
|
||||
|
||||
var p tcmodel.UserTaskProgress
|
||||
db.Where("user_id = ? AND task_id = ?", userID, taskID).First(&p)
|
||||
if p.OrderCount < 3 {
|
||||
t.Error("订单数量未达到阈值")
|
||||
}
|
||||
|
||||
t.Logf("订单数量指标测试通过: 当前订单数=%d", p.OrderCount)
|
||||
}
|
||||
|
||||
// TestOrderAmountMetric 测试消费金额指标
|
||||
func TestOrderAmountMetric(t *testing.T) {
|
||||
db := CreateTestDB(t)
|
||||
|
||||
combo := TaskCombination{
|
||||
Name: "消费金额测试任务",
|
||||
Metric: MetricOrderAmount,
|
||||
Operator: OperatorGTE,
|
||||
Threshold: 10000, // 100元
|
||||
Window: WindowWeekly,
|
||||
RewardType: RewardTypeItemCard,
|
||||
}
|
||||
taskID, _ := InsertTaskWithTierAndReward(t, db, combo)
|
||||
|
||||
// 模拟用户累计消费
|
||||
userID := int64(1003)
|
||||
amounts := []int64{3000, 4000, 5000} // 累计 120 元
|
||||
totalAmount := int64(0)
|
||||
for _, amt := range amounts {
|
||||
totalAmount += amt
|
||||
}
|
||||
|
||||
progress := &tcmodel.UserTaskProgress{
|
||||
UserID: userID,
|
||||
TaskID: taskID,
|
||||
OrderAmount: totalAmount,
|
||||
ClaimedTiers: datatypes.JSON("[]"),
|
||||
}
|
||||
db.Create(progress)
|
||||
|
||||
var p tcmodel.UserTaskProgress
|
||||
db.Where("user_id = ? AND task_id = ?", userID, taskID).First(&p)
|
||||
if p.OrderAmount < 10000 {
|
||||
t.Error("消费金额未达到阈值")
|
||||
}
|
||||
|
||||
t.Logf("消费金额指标测试通过: 当前消费=%d分", p.OrderAmount)
|
||||
}
|
||||
|
||||
// TestInviteCountMetric 测试邀请人数指标
|
||||
func TestInviteCountMetric(t *testing.T) {
|
||||
db := CreateTestDB(t)
|
||||
|
||||
combo := TaskCombination{
|
||||
Name: "邀请人数测试任务",
|
||||
Metric: MetricInviteCount,
|
||||
Operator: OperatorGTE,
|
||||
Threshold: 2,
|
||||
Window: WindowLifetime,
|
||||
RewardType: RewardTypeTitle,
|
||||
}
|
||||
taskID, _ := InsertTaskWithTierAndReward(t, db, combo)
|
||||
|
||||
// 模拟邀请进度
|
||||
userID := int64(1004)
|
||||
progress := &tcmodel.UserTaskProgress{
|
||||
UserID: userID,
|
||||
TaskID: taskID,
|
||||
InviteCount: 3,
|
||||
ClaimedTiers: datatypes.JSON("[]"),
|
||||
}
|
||||
db.Create(progress)
|
||||
|
||||
var p tcmodel.UserTaskProgress
|
||||
db.Where("user_id = ? AND task_id = ?", userID, taskID).First(&p)
|
||||
if p.InviteCount < 2 {
|
||||
t.Error("邀请人数未达到阈值")
|
||||
}
|
||||
|
||||
t.Logf("邀请人数指标测试通过: 当前邀请数=%d", p.InviteCount)
|
||||
}
|
||||
|
||||
// TestAllRewardTypes 测试所有奖励类型的 payload 解析
|
||||
func TestAllRewardTypes(t *testing.T) {
|
||||
rewardTypes := []string{
|
||||
RewardTypePoints,
|
||||
RewardTypeCoupon,
|
||||
RewardTypeItemCard,
|
||||
RewardTypeTitle,
|
||||
RewardTypeGameTicket,
|
||||
}
|
||||
|
||||
for _, rt := range rewardTypes {
|
||||
payload := generateRewardPayload(rt)
|
||||
var data map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(payload), &data); err != nil {
|
||||
t.Errorf("奖励类型 %s 的 payload 解析失败: %v", rt, err)
|
||||
continue
|
||||
}
|
||||
|
||||
switch rt {
|
||||
case RewardTypePoints:
|
||||
if _, ok := data["points"]; !ok {
|
||||
t.Errorf("points 类型缺少 points 字段")
|
||||
}
|
||||
case RewardTypeCoupon:
|
||||
if _, ok := data["coupon_id"]; !ok {
|
||||
t.Errorf("coupon 类型缺少 coupon_id 字段")
|
||||
}
|
||||
case RewardTypeItemCard:
|
||||
if _, ok := data["card_id"]; !ok {
|
||||
t.Errorf("item_card 类型缺少 card_id 字段")
|
||||
}
|
||||
case RewardTypeTitle:
|
||||
if _, ok := data["title_id"]; !ok {
|
||||
t.Errorf("title 类型缺少 title_id 字段")
|
||||
}
|
||||
case RewardTypeGameTicket:
|
||||
if _, ok := data["game_code"]; !ok {
|
||||
t.Errorf("game_ticket 类型缺少 game_code 字段")
|
||||
}
|
||||
if _, ok := data["amount"]; !ok {
|
||||
t.Errorf("game_ticket 类型缺少 amount 字段")
|
||||
}
|
||||
}
|
||||
|
||||
t.Logf("奖励类型 %s 验证通过: %s", rt, payload)
|
||||
}
|
||||
}
|
||||
|
||||
// TestTimeWindowDaily 测试每日时间窗口重置逻辑
|
||||
func TestTimeWindowDaily(t *testing.T) {
|
||||
db := CreateTestDB(t)
|
||||
|
||||
combo := TaskCombination{
|
||||
Name: "每日重置测试任务",
|
||||
Metric: MetricOrderCount,
|
||||
Operator: OperatorGTE,
|
||||
Threshold: 3,
|
||||
Window: WindowDaily,
|
||||
RewardType: RewardTypePoints,
|
||||
}
|
||||
taskID, _ := InsertTaskWithTierAndReward(t, db, combo)
|
||||
|
||||
userID := int64(1005)
|
||||
|
||||
// 模拟昨天的进度
|
||||
yesterday := time.Now().Add(-25 * time.Hour)
|
||||
progress := &tcmodel.UserTaskProgress{
|
||||
UserID: userID,
|
||||
TaskID: taskID,
|
||||
OrderCount: 5,
|
||||
ClaimedTiers: datatypes.JSON("[]"),
|
||||
UpdatedAt: yesterday,
|
||||
}
|
||||
db.Create(progress)
|
||||
|
||||
// 检查日期判断逻辑
|
||||
now := time.Now()
|
||||
y1, m1, d1 := yesterday.Date()
|
||||
y2, m2, d2 := now.Date()
|
||||
|
||||
shouldReset := !(y1 == y2 && m1 == m2 && d1 == d2)
|
||||
if !shouldReset {
|
||||
t.Error("每日重置逻辑有误:昨天的进度应该需要重置")
|
||||
}
|
||||
|
||||
t.Logf("时间窗口测试通过: 昨天=%v, 今天=%v, 需要重置=%v", yesterday.Format("2006-01-02"), now.Format("2006-01-02"), shouldReset)
|
||||
}
|
||||
|
||||
// TestOperatorComparison 测试操作符比较逻辑
|
||||
func TestOperatorComparison(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
operator string
|
||||
threshold int64
|
||||
value int64
|
||||
expected bool
|
||||
}{
|
||||
{"GTE-达到阈值", OperatorGTE, 3, 3, true},
|
||||
{"GTE-超过阈值", OperatorGTE, 3, 5, true},
|
||||
{"GTE-未达阈值", OperatorGTE, 3, 2, false},
|
||||
{"EQ-精确匹配", OperatorEQ, 3, 3, true},
|
||||
{"EQ-超过不匹配", OperatorEQ, 3, 5, false},
|
||||
{"EQ-未达不匹配", OperatorEQ, 3, 2, false},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
var result bool
|
||||
switch tc.operator {
|
||||
case OperatorGTE:
|
||||
result = tc.value >= tc.threshold
|
||||
case OperatorEQ:
|
||||
result = tc.value == tc.threshold
|
||||
}
|
||||
|
||||
if result != tc.expected {
|
||||
t.Errorf("%s: 期望 %v, 实际 %v", tc.name, tc.expected, result)
|
||||
} else {
|
||||
t.Logf("%s: 通过", tc.name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestIdempotency 测试幂等性(同一事件不重复处理)
|
||||
func TestIdempotency(t *testing.T) {
|
||||
db := CreateTestDB(t)
|
||||
ctx := context.Background()
|
||||
_ = ctx // 用于后续扩展
|
||||
|
||||
// 插入第一条事件日志
|
||||
idk := "1001:1:1:order:100"
|
||||
log1 := &tcmodel.TaskEventLog{
|
||||
EventID: "evt_001",
|
||||
SourceType: "order",
|
||||
SourceID: 100,
|
||||
UserID: 1001,
|
||||
TaskID: 1,
|
||||
TierID: 1,
|
||||
IdempotencyKey: idk,
|
||||
Status: "granted",
|
||||
}
|
||||
if err := db.Create(log1).Error; err != nil {
|
||||
t.Fatalf("创建事件日志失败: %v", err)
|
||||
}
|
||||
|
||||
// 尝试插入重复的幂等键(应该失败)
|
||||
log2 := &tcmodel.TaskEventLog{
|
||||
EventID: "evt_002",
|
||||
SourceType: "order",
|
||||
SourceID: 100,
|
||||
UserID: 1001,
|
||||
TaskID: 1,
|
||||
TierID: 1,
|
||||
IdempotencyKey: idk, // 相同的幂等键
|
||||
Status: "granted",
|
||||
}
|
||||
err := db.Create(log2).Error
|
||||
if err == nil {
|
||||
t.Error("幂等性检查失败:重复的幂等键应该被拒绝")
|
||||
} else {
|
||||
t.Logf("幂等性测试通过: 重复记录被正确拒绝")
|
||||
}
|
||||
}
|
||||
|
||||
// TestClaimedTiersTracking 测试已领取档位追踪
|
||||
func TestClaimedTiersTracking(t *testing.T) {
|
||||
db := CreateTestDB(t)
|
||||
|
||||
userID := int64(1006)
|
||||
taskID := int64(1)
|
||||
|
||||
// 初始化进度
|
||||
progress := &tcmodel.UserTaskProgress{
|
||||
UserID: userID,
|
||||
TaskID: taskID,
|
||||
ClaimedTiers: datatypes.JSON("[]"),
|
||||
}
|
||||
db.Create(progress)
|
||||
|
||||
// 模拟领取档位
|
||||
claimedTiers := []int64{1, 2, 3}
|
||||
b, _ := json.Marshal(claimedTiers)
|
||||
db.Model(&tcmodel.UserTaskProgress{}).
|
||||
Where("user_id = ? AND task_id = ?", userID, taskID).
|
||||
Update("claimed_tiers", datatypes.JSON(b))
|
||||
|
||||
// 验证领取状态
|
||||
var p tcmodel.UserTaskProgress
|
||||
db.Where("user_id = ? AND task_id = ?", userID, taskID).First(&p)
|
||||
|
||||
var claimed []int64
|
||||
json.Unmarshal([]byte(p.ClaimedTiers), &claimed)
|
||||
|
||||
if len(claimed) != 3 {
|
||||
t.Errorf("已领取档位数量不正确: 期望 3, 实际 %d", len(claimed))
|
||||
}
|
||||
|
||||
// 检查是否包含特定档位
|
||||
tierToCheck := int64(2)
|
||||
found := false
|
||||
for _, id := range claimed {
|
||||
if id == tierToCheck {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Errorf("档位 %d 应该已被领取", tierToCheck)
|
||||
}
|
||||
|
||||
t.Logf("已领取档位追踪测试通过: %v", claimed)
|
||||
}
|
||||
65
internal/service/user/game_pass.go
Normal file
65
internal/service/user/game_pass.go
Normal file
@ -0,0 +1,65 @@
|
||||
package user
|
||||
|
||||
import (
|
||||
"bindbox-game/internal/repository/mysql/model"
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// GrantGamePass 发放游戏次数卡(购买)
|
||||
func (s *service) GrantGamePass(ctx context.Context, userID int64, packageID int64, count int32, orderNo string) error {
|
||||
s.logger.Info("GrantGamePass: 开始发放次数卡",
|
||||
zap.Int64("user_id", userID),
|
||||
zap.Int64("package_id", packageID),
|
||||
zap.Int32("count", count),
|
||||
zap.String("order_no", orderNo))
|
||||
|
||||
if count <= 0 {
|
||||
count = 1
|
||||
}
|
||||
|
||||
// 1. 获取套餐信息
|
||||
pkg, err := s.readDB.GamePassPackages.WithContext(ctx).
|
||||
Where(s.readDB.GamePassPackages.ID.Eq(packageID)).
|
||||
First()
|
||||
if err != nil {
|
||||
return fmt.Errorf("package not found: %w", err)
|
||||
}
|
||||
|
||||
totalPasses := pkg.PassCount * count
|
||||
|
||||
// 2. 构造次数卡记录
|
||||
now := time.Now()
|
||||
pass := &model.UserGamePasses{
|
||||
UserID: userID,
|
||||
ActivityID: pkg.ActivityID,
|
||||
Remaining: totalPasses,
|
||||
TotalGranted: totalPasses,
|
||||
TotalUsed: 0,
|
||||
Source: "purchase",
|
||||
Remark: fmt.Sprintf("订单:%s|套餐:%s|数量:%d", orderNo, pkg.Name, count),
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
|
||||
if pkg.ValidDays > 0 {
|
||||
pass.ExpiredAt = now.Add(time.Duration(pkg.ValidDays) * 24 * time.Hour)
|
||||
}
|
||||
|
||||
// 3. 写入数据库
|
||||
q := s.writeDB.UserGamePasses.WithContext(ctx)
|
||||
if pass.ExpiredAt.IsZero() {
|
||||
q = q.Omit(s.writeDB.UserGamePasses.ExpiredAt)
|
||||
}
|
||||
|
||||
if err := q.Create(pass); err != nil {
|
||||
s.logger.Error("GrantGamePass: 写入数据库失败", zap.Error(err))
|
||||
return fmt.Errorf("failed to create user game pass: %w", err)
|
||||
}
|
||||
|
||||
s.logger.Info("GrantGamePass: 发放成功", zap.Int64("pass_id", pass.ID))
|
||||
return nil
|
||||
}
|
||||
199
internal/service/user/login_douyin.go
Normal file
199
internal/service/user/login_douyin.go
Normal file
@ -0,0 +1,199 @@
|
||||
package user
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"image/png"
|
||||
|
||||
"bindbox-game/internal/pkg/douyin"
|
||||
"bindbox-game/internal/repository/mysql/dao"
|
||||
"bindbox-game/internal/repository/mysql/model"
|
||||
|
||||
randomname "github.com/DanPlayer/randomname"
|
||||
identicon "github.com/issue9/identicon/v2"
|
||||
"go.uber.org/zap"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
// LoginDouyinInput 抖音登录输入参数
|
||||
type LoginDouyinInput struct {
|
||||
Code string // tt.login 获取的 code
|
||||
AnonymousCode string // 匿名登录 code
|
||||
Nickname string
|
||||
AvatarURL string
|
||||
InviteCode string
|
||||
ChannelCode string
|
||||
}
|
||||
|
||||
// LoginDouyinOutput 抖音登录输出结果
|
||||
type LoginDouyinOutput struct {
|
||||
User *model.Users
|
||||
IsNewUser bool
|
||||
InviterID int64
|
||||
}
|
||||
|
||||
// LoginDouyin 抖音小程序登录
|
||||
func (s *service) LoginDouyin(ctx context.Context, in LoginDouyinInput) (*LoginDouyinOutput, error) {
|
||||
// 1. 调用抖音 code2session 获取 openid
|
||||
if in.Code == "" && in.AnonymousCode == "" {
|
||||
return nil, errors.New("code 或 anonymous_code 不能为空")
|
||||
}
|
||||
|
||||
resp, err := douyin.Code2Session(ctx, in.Code, in.AnonymousCode)
|
||||
if err != nil {
|
||||
s.logger.Error("抖音 code2session 失败", zap.Error(err))
|
||||
return nil, err
|
||||
}
|
||||
|
||||
openID := resp.Data.OpenID
|
||||
if openID == "" {
|
||||
openID = resp.Data.AnonymousOpenID
|
||||
}
|
||||
if openID == "" {
|
||||
return nil, errors.New("获取抖音 openid 失败")
|
||||
}
|
||||
|
||||
unionID := resp.Data.UnionID
|
||||
|
||||
var u *model.Users
|
||||
// 事务处理:创建/更新用户 + 处理邀请
|
||||
var isNewUser bool
|
||||
var inviterID int64
|
||||
err = s.writeDB.Transaction(func(tx *dao.Query) error {
|
||||
var err error
|
||||
|
||||
// 2. 先通过 douyin_id 查找用户
|
||||
u, err = tx.Users.WithContext(ctx).Clauses(clause.Locking{Strength: "UPDATE"}).
|
||||
Where(tx.Users.DouyinID.Eq(openID)).First()
|
||||
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return err
|
||||
}
|
||||
|
||||
// 3. 如果有 unionid,尝试通过 unionid 关联到已有的微信用户
|
||||
if u == nil && unionID != "" {
|
||||
u, err = tx.Users.WithContext(ctx).Clauses(clause.Locking{Strength: "UPDATE"}).
|
||||
Where(tx.Users.Unionid.Eq(unionID)).First()
|
||||
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return err
|
||||
}
|
||||
// 找到微信用户,绑定抖音ID
|
||||
if u != nil && u.DouyinID == "" {
|
||||
_, _ = tx.Users.WithContext(ctx).Where(tx.Users.ID.Eq(u.ID)).
|
||||
Updates(map[string]any{"douyin_id": openID})
|
||||
u.DouyinID = openID
|
||||
}
|
||||
}
|
||||
|
||||
// 查找渠道ID
|
||||
var channelID int64
|
||||
if in.ChannelCode != "" {
|
||||
ch, _ := s.readDB.Channels.WithContext(ctx).Where(s.readDB.Channels.Code.Eq(in.ChannelCode)).First()
|
||||
if ch != nil {
|
||||
channelID = ch.ID
|
||||
}
|
||||
}
|
||||
|
||||
// 4. 如果用户不存在,创建新用户
|
||||
isNewUser = false
|
||||
if u == nil {
|
||||
isNewUser = true
|
||||
code := s.generateInviteCode(ctx)
|
||||
nickname := in.Nickname
|
||||
if nickname == "" {
|
||||
nickname = randomname.GenerateName()
|
||||
}
|
||||
avatar := in.AvatarURL
|
||||
if avatar == "" {
|
||||
seed := openID
|
||||
if seed == "" {
|
||||
seed = nickname
|
||||
}
|
||||
img := identicon.S2(128).Make([]byte(seed))
|
||||
var buf bytes.Buffer
|
||||
_ = png.Encode(&buf, img)
|
||||
avatar = "data:image/png;base64," + base64.StdEncoding.EncodeToString(buf.Bytes())
|
||||
}
|
||||
u = &model.Users{
|
||||
Nickname: nickname,
|
||||
Avatar: avatar,
|
||||
DouyinID: openID,
|
||||
Unionid: unionID,
|
||||
InviteCode: code,
|
||||
Status: 1,
|
||||
ChannelID: channelID,
|
||||
}
|
||||
if err := tx.Users.WithContext(ctx).Create(u); err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
// 更新用户信息
|
||||
set := map[string]any{}
|
||||
if in.Nickname != "" && u.Nickname == "" {
|
||||
set["nickname"] = in.Nickname
|
||||
}
|
||||
if in.AvatarURL != "" && u.Avatar == "" {
|
||||
set["avatar"] = in.AvatarURL
|
||||
}
|
||||
if unionID != "" && u.Unionid == "" {
|
||||
set["unionid"] = unionID
|
||||
}
|
||||
if channelID > 0 && u.ChannelID == 0 {
|
||||
set["channel_id"] = channelID
|
||||
}
|
||||
if len(set) > 0 {
|
||||
if _, err := tx.Users.WithContext(ctx).Where(tx.Users.ID.Eq(u.ID)).Updates(set); err != nil {
|
||||
return err
|
||||
}
|
||||
u, _ = tx.Users.WithContext(ctx).Where(tx.Users.ID.Eq(u.ID)).First()
|
||||
}
|
||||
}
|
||||
|
||||
// 5. 处理邀请逻辑
|
||||
// 只有在真正创建新用户记录时才发放邀请奖励,防止多账号切换重复刷奖励
|
||||
if in.InviteCode != "" && isNewUser {
|
||||
// 查询邀请人
|
||||
var inviter model.Users
|
||||
// First() 返回 (result, error)
|
||||
inviterResult, err := tx.Users.WithContext(ctx).Where(tx.Users.InviteCode.Eq(in.InviteCode)).First()
|
||||
if err == nil && inviterResult != nil {
|
||||
inviter = *inviterResult
|
||||
// 创建邀请记录
|
||||
invite := &model.UserInvites{
|
||||
InviteeID: u.ID, // UserID -> InviteeID
|
||||
InviterID: inviter.ID,
|
||||
InviteCode: in.InviteCode,
|
||||
// Status: 1, // Removed
|
||||
}
|
||||
if err := tx.UserInvites.WithContext(ctx).Create(invite); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 更新被邀请人的邀请人ID
|
||||
// UpdateColumn for single column update
|
||||
if _, err := tx.Users.WithContext(ctx).Where(tx.Users.ID.Eq(u.ID)).
|
||||
UpdateColumn(tx.Users.InviterID, inviter.ID); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 返回邀请人ID,以便外层触发任务中心逻辑
|
||||
inviterID = inviter.ID
|
||||
s.logger.Info("抖音登录邀请关系建立成功", zap.Int64("user_id", u.ID), zap.Int64("inviter_id", inviter.ID))
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &LoginDouyinOutput{
|
||||
User: u,
|
||||
IsNewUser: isNewUser,
|
||||
InviterID: inviterID,
|
||||
}, nil
|
||||
}
|
||||
@ -32,7 +32,15 @@ type LoginWeixinInput struct {
|
||||
ChannelCode string
|
||||
}
|
||||
|
||||
func (s *service) LoginWeixin(ctx context.Context, in LoginWeixinInput) (*model.Users, error) {
|
||||
// LoginWeixinOutput 微信登录输出结果
|
||||
type LoginWeixinOutput struct {
|
||||
User *model.Users
|
||||
IsNewUser bool
|
||||
InviterID int64
|
||||
}
|
||||
|
||||
// LoginWeixin 微信小程序登录
|
||||
func (s *service) LoginWeixin(ctx context.Context, in LoginWeixinInput) (*LoginWeixinOutput, error) {
|
||||
// 1. 获取 OpenID (如果是小程序登录)
|
||||
if in.Code != "" {
|
||||
cfg := configs.Get().Wechat
|
||||
@ -52,6 +60,9 @@ func (s *service) LoginWeixin(ctx context.Context, in LoginWeixinInput) (*model.
|
||||
}
|
||||
|
||||
var u *model.Users
|
||||
var isNewUser bool
|
||||
var inviterID int64
|
||||
|
||||
// 事务处理:创建/更新用户 + 处理邀请
|
||||
err := s.writeDB.Transaction(func(tx *dao.Query) error {
|
||||
var err error
|
||||
@ -78,7 +89,9 @@ func (s *service) LoginWeixin(ctx context.Context, in LoginWeixinInput) (*model.
|
||||
}
|
||||
}
|
||||
|
||||
isNewUser = false
|
||||
if u == nil {
|
||||
isNewUser = true
|
||||
code := s.generateInviteCode(ctx)
|
||||
nickname := in.Nickname
|
||||
if nickname == "" {
|
||||
@ -118,6 +131,11 @@ func (s *service) LoginWeixin(ctx context.Context, in LoginWeixinInput) (*model.
|
||||
if in.DouyinID != "" {
|
||||
set["douyin_id"] = in.DouyinID
|
||||
}
|
||||
// 如果此用户是通过 UnionID 找到的,且原本没有 OpenID,则绑定 OpenID
|
||||
if u.Openid == "" && in.OpenID != "" {
|
||||
set["openid"] = in.OpenID
|
||||
u.Openid = in.OpenID
|
||||
}
|
||||
if channelID > 0 {
|
||||
set["channel_id"] = channelID
|
||||
}
|
||||
@ -129,13 +147,14 @@ func (s *service) LoginWeixin(ctx context.Context, in LoginWeixinInput) (*model.
|
||||
}
|
||||
}
|
||||
|
||||
if in.InviteCode != "" {
|
||||
// 只有在真正创建新用户记录时才发放邀请奖励
|
||||
if in.InviteCode != "" && isNewUser {
|
||||
existed, _ := tx.UserInvites.WithContext(ctx).Where(tx.UserInvites.InviteeID.Eq(u.ID)).First()
|
||||
if existed == nil {
|
||||
inviter, _ := tx.Users.WithContext(ctx).Where(tx.Users.InviteCode.Eq(in.InviteCode)).First()
|
||||
if inviter != nil && inviter.ID != u.ID {
|
||||
reward := int64(10)
|
||||
inv := &model.UserInvites{InviterID: inviter.ID, InviteeID: u.ID, InviteCode: in.InviteCode, RewardPoints: reward, RewardedAt: time.Now()}
|
||||
// reward := int64(10) // Removed hardcoded reward as per instruction
|
||||
inv := &model.UserInvites{InviterID: inviter.ID, InviteeID: u.ID, InviteCode: in.InviteCode, RewardPoints: 0, RewardedAt: time.Now()} // RewardPoints set to 0 as reward is removed
|
||||
if err := tx.UserInvites.WithContext(ctx).Create(inv); err != nil {
|
||||
return err
|
||||
}
|
||||
@ -146,7 +165,7 @@ func (s *service) LoginWeixin(ctx context.Context, in LoginWeixinInput) (*model.
|
||||
}
|
||||
points, _ := tx.UserPoints.WithContext(ctx).Clauses(clause.Locking{Strength: "UPDATE"}).Where(tx.UserPoints.UserID.Eq(inviter.ID)).Where(tx.UserPoints.Kind.Eq("invite")).First()
|
||||
if points == nil {
|
||||
points = &model.UserPoints{UserID: inviter.ID, Kind: "invite", Points: reward, ValidStart: time.Now()}
|
||||
points = &model.UserPoints{UserID: inviter.ID, Kind: "invite", Points: 0, ValidStart: time.Now()} // Points set to 0 as reward is removed
|
||||
do := tx.UserPoints.WithContext(ctx)
|
||||
if points.ValidEnd.IsZero() {
|
||||
do = do.Omit(tx.UserPoints.ValidEnd)
|
||||
@ -155,14 +174,17 @@ func (s *service) LoginWeixin(ctx context.Context, in LoginWeixinInput) (*model.
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
if _, err := tx.UserPoints.WithContext(ctx).Where(tx.UserPoints.ID.Eq(points.ID)).Updates(map[string]any{"points": points.Points + reward}); err != nil {
|
||||
if _, err := tx.UserPoints.WithContext(ctx).Where(tx.UserPoints.ID.Eq(points.ID)).Updates(map[string]any{"points": points.Points + 0}); err != nil { // Points set to 0 as reward is removed
|
||||
return err
|
||||
}
|
||||
}
|
||||
ledger := &model.UserPointsLedger{UserID: inviter.ID, Action: "invite_reward", Points: reward, RefTable: "user_invites", RefID: strconv.FormatInt(inv.ID, 10), Remark: "invite_reward"}
|
||||
ledger := &model.UserPointsLedger{UserID: inviter.ID, Action: "invite_reward", Points: 0, RefTable: "user_invites", RefID: strconv.FormatInt(inv.ID, 10), Remark: "invite_reward"} // Points set to 0 as reward is removed
|
||||
if err := tx.UserPointsLedger.WithContext(ctx).Create(ledger); err != nil {
|
||||
return err
|
||||
}
|
||||
// 返回邀请人ID,以便外层触发任务中心逻辑
|
||||
inviterID = inviter.ID
|
||||
s.logger.Info("微信登录邀请关系建立成功", zap.Int64("user_id", u.ID), zap.Int64("inviter_id", inviter.ID))
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -178,5 +200,10 @@ func (s *service) LoginWeixin(ctx context.Context, in LoginWeixinInput) (*model.
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return u, nil
|
||||
|
||||
return &LoginWeixinOutput{
|
||||
User: u,
|
||||
IsNewUser: isNewUser,
|
||||
InviterID: inviterID,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@ -6,6 +6,12 @@ import (
|
||||
"bindbox-game/internal/repository/mysql/model"
|
||||
)
|
||||
|
||||
// GetProfile 获取用户个人资料
|
||||
func (s *service) GetProfile(ctx context.Context, userID int64) (*model.Users, error) {
|
||||
return s.writeDB.Users.WithContext(ctx).Where(s.writeDB.Users.ID.Eq(userID)).First()
|
||||
}
|
||||
|
||||
// UpdateProfile 更新用户个人资料
|
||||
func (s *service) UpdateProfile(ctx context.Context, userID int64, nickname *string, avatar *string) (*model.Users, error) {
|
||||
updater := s.writeDB.Users.WithContext(ctx).Where(s.writeDB.Users.ID.Eq(userID))
|
||||
set := map[string]any{}
|
||||
@ -55,6 +55,7 @@ type SmsLoginOutput struct {
|
||||
User *model.Users
|
||||
Token string
|
||||
IsNewUser bool
|
||||
InviterID int64
|
||||
}
|
||||
|
||||
// 手机号正则
|
||||
@ -227,7 +228,7 @@ func (s *service) LoginByCode(ctx context.Context, in SmsLoginInput) (*SmsLoginO
|
||||
s.logger.Info("短信登录创建新用户", zap.Int64("user_id", user.ID), zap.String("mobile", in.Mobile))
|
||||
}
|
||||
|
||||
// 处理邀请码逻辑(仅首次登录)
|
||||
// 处理邀请码逻辑(仅在真正的首次账户创建时触发,防止重复领奖)
|
||||
if in.InviteCode != "" && isNewUser {
|
||||
existed, _ := tx.UserInvites.WithContext(ctx).Where(tx.UserInvites.InviteeID.Eq(user.ID)).First()
|
||||
if existed == nil {
|
||||
|
||||
@ -11,6 +11,7 @@ import (
|
||||
|
||||
type Service interface {
|
||||
UpdateProfile(ctx context.Context, userID int64, nickname *string, avatar *string) (*model.Users, error)
|
||||
GetProfile(ctx context.Context, userID int64) (*model.Users, error)
|
||||
ListOrders(ctx context.Context, userID int64, page, pageSize int) (items []*model.Orders, total int64, err error)
|
||||
ListOrdersWithItems(ctx context.Context, userID int64, status int32, isConsumed *int32, page, pageSize int) (items []*OrderWithItems, total int64, err error)
|
||||
ListInventoryWithProduct(ctx context.Context, userID int64, page, pageSize int) (items []*InventoryWithProduct, total int64, err error)
|
||||
@ -20,7 +21,8 @@ type Service interface {
|
||||
ListCouponsByStatus(ctx context.Context, userID int64, status int32, page, pageSize int) (items []*model.UserCoupons, total int64, err error)
|
||||
ListPointsLedger(ctx context.Context, userID int64, page, pageSize int) (items []*model.UserPointsLedger, total int64, err error)
|
||||
GetPointsBalance(ctx context.Context, userID int64) (int64, error)
|
||||
LoginWeixin(ctx context.Context, in LoginWeixinInput) (*model.Users, error)
|
||||
LoginWeixin(ctx context.Context, in LoginWeixinInput) (*LoginWeixinOutput, error)
|
||||
LoginDouyin(ctx context.Context, in LoginDouyinInput) (*LoginDouyinOutput, error)
|
||||
ListInvites(ctx context.Context, userID int64, page, pageSize int) (items []*model.Users, total int64, err error)
|
||||
AddPoints(ctx context.Context, userID int64, points int64, kind string, remark string, validStart *time.Time, validEnd *time.Time) error
|
||||
AddPointsWithAction(ctx context.Context, userID int64, points int64, kind string, remark string, action string, validStart *time.Time, validEnd *time.Time) error
|
||||
@ -74,6 +76,7 @@ type Service interface {
|
||||
// 短信登录
|
||||
SendSmsCode(ctx context.Context, mobile string) error
|
||||
LoginByCode(ctx context.Context, in SmsLoginInput) (*SmsLoginOutput, error)
|
||||
GrantGamePass(ctx context.Context, userID int64, packageID int64, count int32, orderNo string) error
|
||||
}
|
||||
|
||||
type service struct {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
5
main.go
5
main.go
@ -207,6 +207,11 @@ func main() {
|
||||
syscfgSvc := syscfgsvc.New(customLogger, dbRepo)
|
||||
douyinsvc.StartDouyinOrderSync(customLogger, dbRepo, syscfgSvc)
|
||||
|
||||
// 初始化全局动态配置服务
|
||||
if err := syscfgsvc.InitGlobalDynamicConfig(customLogger, dbRepo); err != nil {
|
||||
customLogger.Warn("动态配置加载失败,将使用静态配置", zap.Error(err))
|
||||
}
|
||||
|
||||
// 优雅关闭
|
||||
shutdown.Close(
|
||||
func() {
|
||||
|
||||
BIN
task_center_test
Executable file
BIN
task_center_test
Executable file
Binary file not shown.
BIN
web/.DS_Store
vendored
BIN
web/.DS_Store
vendored
Binary file not shown.
Loading…
x
Reference in New Issue
Block a user