diff --git a/cmd/migrate_configs/main.go b/cmd/migrate_configs/main.go new file mode 100644 index 0000000..9101717 --- /dev/null +++ b/cmd/migrate_configs/main.go @@ -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 参数执行实际迁移") + } +} diff --git a/cmd/tools/task_center_test/integration.go b/cmd/tools/task_center_test/integration.go new file mode 100644 index 0000000..8918c02 --- /dev/null +++ b/cmd/tools/task_center_test/integration.go @@ -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 +} diff --git a/cmd/tools/task_center_test/main.go b/cmd/tools/task_center_test/main.go new file mode 100644 index 0000000..69eecf3 --- /dev/null +++ b/cmd/tools/task_center_test/main.go @@ -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=") + 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) + } +} diff --git a/configs/configs.go b/configs/configs.go index 0dadddd..86cd08d 100644 --- a/configs/configs.go +++ b/configs/configs.go @@ -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 ( diff --git a/docs/docs.go b/docs/docs.go index 05427e6..efd7f4d 100644 --- a/docs/docs.go +++ b/docs/docs.go @@ -657,6 +657,49 @@ const docTemplate = `{ } } }, + "/api/admin/activities/{activity_id}/game-passes/check": { + "get": { + "security": [ + { + "LoginVerifyToken": [] + } + ], + "description": "检查指定活动是否有用户持有未使用的次数卡,用于下架/删除前校验", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "管理端.次数卡" + ], + "summary": "检查活动次数卡", + "parameters": [ + { + "type": "integer", + "description": "活动ID", + "name": "activity_id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/admin.checkActivityGamePassesResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/code.Failure" + } + } + } + } + }, "/api/admin/activities/{activity_id}/issues": { "get": { "security": [ @@ -1414,6 +1457,307 @@ const docTemplate = `{ } } }, + "/api/admin/game-pass-packages": { + "get": { + "security": [ + { + "LoginVerifyToken": [] + } + ], + "description": "查询次数卡套餐列表", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "管理端.次数卡套餐" + ], + "summary": "次数卡套餐列表", + "parameters": [ + { + "type": "integer", + "description": "活动ID", + "name": "activity_id", + "in": "query" + }, + { + "type": "integer", + "description": "状态: 1=上架 2=下架", + "name": "status", + "in": "query" + }, + { + "type": "integer", + "description": "页码", + "name": "page", + "in": "query" + }, + { + "type": "integer", + "description": "每页条数", + "name": "page_size", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/admin.listGamePassPackagesResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/code.Failure" + } + } + } + }, + "post": { + "security": [ + { + "LoginVerifyToken": [] + } + ], + "description": "创建可购买的次数卡套餐", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "管理端.次数卡套餐" + ], + "summary": "创建次数卡套餐", + "parameters": [ + { + "description": "请求参数", + "name": "RequestBody", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/admin.createGamePassPackageRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/admin.createGamePassPackageResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/code.Failure" + } + } + } + } + }, + "/api/admin/game-pass-packages/{package_id}": { + "put": { + "security": [ + { + "LoginVerifyToken": [] + } + ], + "description": "修改次数卡套餐配置", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "管理端.次数卡套餐" + ], + "summary": "修改次数卡套餐", + "parameters": [ + { + "type": "integer", + "description": "套餐ID", + "name": "package_id", + "in": "path", + "required": true + }, + { + "description": "请求参数", + "name": "RequestBody", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/admin.modifyGamePassPackageRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/admin.simpleMessageResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/code.Failure" + } + } + } + }, + "delete": { + "security": [ + { + "LoginVerifyToken": [] + } + ], + "description": "软删除次数卡套餐", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "管理端.次数卡套餐" + ], + "summary": "删除次数卡套餐", + "parameters": [ + { + "type": "integer", + "description": "套餐ID", + "name": "package_id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/admin.simpleMessageResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/code.Failure" + } + } + } + } + }, + "/api/admin/game-passes/grant": { + "post": { + "security": [ + { + "LoginVerifyToken": [] + } + ], + "description": "管理员为用户发放游戏次数卡", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "管理端.次数卡" + ], + "summary": "发放次数卡", + "parameters": [ + { + "description": "请求参数", + "name": "RequestBody", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/admin.grantGamePassRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/admin.grantGamePassResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/code.Failure" + } + } + } + } + }, + "/api/admin/game-passes/list": { + "get": { + "security": [ + { + "LoginVerifyToken": [] + } + ], + "description": "查询用户次数卡列表,支持按用户、活动过滤", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "管理端.次数卡" + ], + "summary": "次数卡列表", + "parameters": [ + { + "type": "integer", + "description": "用户ID", + "name": "user_id", + "in": "query" + }, + { + "type": "integer", + "description": "活动ID", + "name": "activity_id", + "in": "query" + }, + { + "type": "integer", + "description": "页码", + "name": "page", + "in": "query" + }, + { + "type": "integer", + "description": "每页条数", + "name": "page_size", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/admin.listGamePassesResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/code.Failure" + } + } + } + } + }, "/api/admin/login": { "post": { "description": "管理员登录", @@ -1454,6 +1798,49 @@ const docTemplate = `{ } } }, + "/api/admin/matching/audit/{order_no}": { + "get": { + "security": [ + { + "AdminAuth": [] + } + ], + "description": "运营通过单号查询发牌过程", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "管理端.对对碰" + ], + "summary": "获取对对碰审计数据", + "parameters": [ + { + "type": "string", + "description": "订单号", + "name": "order_no", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/activity.MatchingGame" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/code.Failure" + } + } + } + } + }, "/api/admin/matching_card_types": { "get": { "security": [ @@ -1625,6 +2012,79 @@ const docTemplate = `{ } } }, + "/api/admin/orders/{order_id}/rollback": { + "post": { + "description": "基于快照将用户数据回滚到消费前状态,包括恢复积分、优惠券、道具卡,作废资产,调用微信退款", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Admin-Audit" + ], + "summary": "回滚订单到消费前状态", + "parameters": [ + { + "type": "integer", + "description": "订单ID", + "name": "order_id", + "in": "path", + "required": true + }, + { + "description": "回滚请求", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/admin.rollbackOrderRequest" + } + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "$ref": "#/definitions/admin.rollbackOrderResponse" + } + } + } + } + }, + "/api/admin/orders/{order_id}/snapshots": { + "get": { + "description": "获取订单消费前后的完整用户状态快照", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Admin-Audit" + ], + "summary": "获取订单审计快照", + "parameters": [ + { + "type": "integer", + "description": "订单ID", + "name": "order_id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "$ref": "#/definitions/admin.getOrderSnapshotsResponse" + } + } + } + } + }, "/api/admin/product_categories": { "get": { "security": [ @@ -2680,6 +3140,120 @@ const docTemplate = `{ } } }, + "/api/admin/users/{user_id}/game-passes": { + "get": { + "security": [ + { + "LoginVerifyToken": [] + } + ], + "description": "查询指定用户的所有次数卡", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "管理端.次数卡" + ], + "summary": "查询用户次数卡", + "parameters": [ + { + "type": "integer", + "description": "用户ID", + "name": "user_id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/admin.getUserGamePassesResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/code.Failure" + } + } + } + } + }, + "/api/admin/users/{user_id}/game_tickets": { + "get": { + "tags": [ + "管理端.游戏" + ], + "summary": "查询用户游戏资格日志", + "parameters": [ + { + "type": "integer", + "description": "用户ID", + "name": "user_id", + "in": "path", + "required": true + }, + { + "type": "integer", + "description": "页码", + "name": "page", + "in": "query" + }, + { + "type": "integer", + "description": "每页数量", + "name": "page_size", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + }, + "post": { + "tags": [ + "管理端.游戏" + ], + "summary": "发放游戏资格", + "parameters": [ + { + "type": "integer", + "description": "用户ID", + "name": "user_id", + "in": "path", + "required": true + }, + { + "description": "请求参数", + "name": "RequestBody", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/game.grantTicketRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + } + }, "/api/admin/users/{user_id}/inventory": { "get": { "security": [ @@ -3045,7 +3619,7 @@ const docTemplate = `{ "LoginVerifyToken": [] } ], - "description": "管理端为指定用户发放积分,支持设置有效期", + "description": "管理端为指定用户发放或扣减积分,正数为增加,负数为扣减", "consumes": [ "application/json" ], @@ -3055,7 +3629,7 @@ const docTemplate = `{ "tags": [ "管理端.用户" ], - "summary": "给用户添加积分", + "summary": "给用户增加或扣减积分", "parameters": [ { "type": "integer", @@ -3133,6 +3707,49 @@ const docTemplate = `{ } } }, + "/api/admin/users/{user_id}/profile": { + "get": { + "security": [ + { + "LoginVerifyToken": [] + } + ], + "description": "聚合用户基本信息、生命周期财务指标、当前资产快照", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "管理端.用户" + ], + "summary": "获取用户综合画像", + "parameters": [ + { + "type": "integer", + "description": "用户ID", + "name": "user_id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/admin.UserProfileResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/code.Failure" + } + } + } + } + }, "/api/admin/users/{user_id}/rewards/grant": { "post": { "description": "管理员给用户发放奖励,支持实物和虚拟奖品,可选择关联活动和奖励配置", @@ -3687,6 +4304,157 @@ const docTemplate = `{ } } }, + "/api/app/game-passes/available": { + "get": { + "security": [ + { + "LoginVerifyToken": [] + } + ], + "description": "查询当前用户可用的游戏次数卡", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "APP端.用户" + ], + "summary": "获取用户次数卡", + "parameters": [ + { + "type": "integer", + "description": "活动ID,不传返回所有可用次数卡", + "name": "activity_id", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/app.getGamePassesResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/code.Failure" + } + } + } + } + }, + "/api/app/game-passes/packages": { + "get": { + "description": "获取可购买的次数卡套餐列表", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "APP端.用户" + ], + "summary": "获取次数卡套餐", + "parameters": [ + { + "type": "integer", + "description": "活动ID,不传返回全局套餐", + "name": "activity_id", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/app.getPackagesResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/code.Failure" + } + } + } + } + }, + "/api/app/game-passes/purchase": { + "post": { + "security": [ + { + "LoginVerifyToken": [] + } + ], + "description": "购买次数卡套餐,创建订单等待支付", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "APP端.用户" + ], + "summary": "购买次数卡套餐", + "parameters": [ + { + "description": "请求参数", + "name": "RequestBody", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/app.purchasePackageRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/app.purchasePackageResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/code.Failure" + } + } + } + } + }, + "/api/app/games/enter": { + "post": { + "tags": [ + "APP端.游戏" + ], + "summary": "进入游戏", + "parameters": [ + { + "description": "请求参数", + "name": "RequestBody", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/game.enterGameRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/game.enterGameResponse" + } + } + } + } + }, "/api/app/lottery/join": { "post": { "security": [ @@ -3794,7 +4562,7 @@ const docTemplate = `{ "schema": { "type": "array", "items": { - "$ref": "#/definitions/app.CardTypeConfig" + "$ref": "#/definitions/activity.CardTypeConfig" } } }, @@ -3807,6 +4575,49 @@ const docTemplate = `{ } } }, + "/api/app/matching/cards": { + "get": { + "security": [ + { + "LoginVerifyToken": [] + } + ], + "description": "只有支付成功后才能获取游戏牌组数据,防止未支付用户获取牌组信息", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "APP端.活动" + ], + "summary": "获取对对碰游戏数据", + "parameters": [ + { + "type": "string", + "description": "游戏ID", + "name": "game_id", + "in": "query", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/app.matchingGameCardsResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/code.Failure" + } + } + } + } + }, "/api/app/matching/check": { "post": { "security": [ @@ -4200,6 +5011,86 @@ const docTemplate = `{ } } }, + "/api/app/sms/login": { + "post": { + "description": "使用短信验证码登录或注册(新手机号自动创建账户)", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "APP端.用户" + ], + "summary": "短信验证码登录", + "parameters": [ + { + "description": "请求参数", + "name": "RequestBody", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/app.smsLoginRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/app.smsLoginResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/code.Failure" + } + } + } + } + }, + "/api/app/sms/send-code": { + "post": { + "description": "发送短信验证码到指定手机号(60秒内不可重复发送,每日最多10次)", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "APP端.用户" + ], + "summary": "发送短信验证码", + "parameters": [ + { + "description": "请求参数", + "name": "RequestBody", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/app.sendSmsCodeRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/app.sendSmsCodeResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/code.Failure" + } + } + } + } + }, "/api/app/store/items": { "get": { "security": [ @@ -4383,6 +5274,80 @@ const docTemplate = `{ } } }, + "/api/app/users/douyin/login": { + "post": { + "description": "抖音小程序登录(需传递 code 或 anonymous_code)", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "APP端.用户" + ], + "summary": "抖音登录", + "parameters": [ + { + "description": "请求参数", + "name": "RequestBody", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/app.douyinLoginRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/app.douyinLoginResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/code.Failure" + } + } + } + } + }, + "/api/app/users/profile": { + "get": { + "security": [ + { + "LoginVerifyToken": [] + } + ], + "description": "获取当前登录用户的详细信息", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "APP端.用户" + ], + "summary": "获取用户信息", + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/app.userItem" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/code.Failure" + } + } + } + } + }, "/api/app/users/weixin/login": { "post": { "description": "微信静默登录(需传递 code;可选 invite_code)", @@ -4605,6 +5570,75 @@ const docTemplate = `{ } }, "/api/app/users/{user_id}/addresses/{address_id}": { + "put": { + "security": [ + { + "LoginVerifyToken": [] + } + ], + "description": "更新当前登录用户的指定收货地址", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "APP端.用户" + ], + "summary": "更新用户地址", + "parameters": [ + { + "type": "integer", + "description": "用户ID", + "name": "user_id", + "in": "path", + "required": true + }, + { + "type": "integer", + "description": "地址ID", + "name": "address_id", + "in": "path", + "required": true + }, + { + "description": "请求参数", + "name": "RequestBody", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/app.updateAddressRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/app.okResponse" + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/code.Failure" + } + }, + "401": { + "description": "未授权", + "schema": { + "$ref": "#/definitions/code.Failure" + } + }, + "500": { + "description": "服务器内部错误", + "schema": { + "$ref": "#/definitions/code.Failure" + } + } + } + }, "delete": { "security": [ { @@ -4898,6 +5932,86 @@ const docTemplate = `{ } } }, + "/api/app/users/{user_id}/douyin/phone/bind": { + "post": { + "security": [ + { + "LoginVerifyToken": [] + } + ], + "description": "使用抖音手机号 code 换取手机号并绑定到指定用户", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "APP端.用户" + ], + "summary": "抖音绑定手机号", + "parameters": [ + { + "type": "integer", + "description": "用户ID", + "name": "user_id", + "in": "path", + "required": true + }, + { + "description": "请求参数", + "name": "RequestBody", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/app.bindDouyinPhoneRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/app.bindDouyinPhoneResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/code.Failure" + } + } + } + } + }, + "/api/app/users/{user_id}/game_tickets": { + "get": { + "tags": [ + "APP端.游戏" + ], + "summary": "获取我的游戏资格", + "parameters": [ + { + "type": "integer", + "description": "用户ID", + "name": "user_id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": { + "type": "integer" + } + } + } + } + } + }, "/api/app/users/{user_id}/inventory": { "get": { "security": [ @@ -5064,7 +6178,7 @@ const docTemplate = `{ "LoginVerifyToken": [] } ], - "description": "取消已提交但未发货的申请;恢复库存状态", + "description": "取消已提交但未发货的申请;恢复库存状态。支持按单个资产ID取消或按批次号批量取消", "consumes": [ "application/json" ], @@ -5084,7 +6198,7 @@ const docTemplate = `{ "required": true }, { - "description": "请求参数:资产ID", + "description": "请求参数:资产ID或批次号(二选一)", "name": "RequestBody", "in": "body", "required": true, @@ -5696,7 +6810,7 @@ const docTemplate = `{ "LoginVerifyToken": [] } ], - "description": "使用积分兑换指定直减金额券,按比率 1元=100积分(券面值为分)", + "description": "使用积分兑换指定直减金额券,按比率 1积分=1元(券面值为分)", "consumes": [ "application/json" ], @@ -5793,7 +6907,7 @@ const docTemplate = `{ "LoginVerifyToken": [] } ], - "description": "使用积分按比率1元=100积分兑换商品,生成系统发放订单与用户资产", + "description": "使用积分按比率1积分=1元兑换商品,生成系统发放订单与用户资产", "consumes": [ "application/json" ], @@ -6004,6 +7118,51 @@ const docTemplate = `{ } } }, + "/api/user": { + "post": { + "security": [ + { + "LoginVerifyToken": [] + } + ], + "description": "创建新的管理员账号", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "管理端.系统" + ], + "summary": "创建系统用户", + "parameters": [ + { + "description": "请求参数", + "name": "RequestBody", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/admin.createUserRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/admin.createUserResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/code.Failure" + } + } + } + } + }, "/api/user/list": { "get": { "security": [ @@ -6119,6 +7278,50 @@ const docTemplate = `{ } } }, + "/internal/game/consume-ticket": { + "post": { + "tags": [ + "Internal.游戏" + ], + "summary": "扣减游戏次数", + "parameters": [ + { + "description": "请求参数", + "name": "RequestBody", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/game.consumeTicketRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/game.consumeTicketResponse" + } + } + } + } + }, + "/internal/game/minesweeper/config": { + "get": { + "tags": [ + "Internal.游戏" + ], + "summary": "获取扫雷配置", + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + } + }, "/internal/game/settle": { "post": { "description": "游戏结束后结算并发放奖励", @@ -6153,6 +7356,33 @@ const docTemplate = `{ } } }, + "/internal/game/validate-token": { + "post": { + "tags": [ + "Internal.游戏" + ], + "summary": "验证GameToken", + "parameters": [ + { + "description": "请求参数", + "name": "RequestBody", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/game.validateTokenRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/game.validateTokenResponse" + } + } + } + } + }, "/internal/game/verify": { "post": { "description": "验证游戏票据是否有效", @@ -6224,6 +7454,101 @@ const docTemplate = `{ } }, "definitions": { + "activity.CardTypeConfig": { + "type": "object", + "properties": { + "code": { + "type": "string" + }, + "image_url": { + "type": "string" + }, + "name": { + "type": "string" + }, + "quantity": { + "type": "integer" + } + } + }, + "activity.MatchingCard": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string" + } + } + }, + "activity.MatchingGame": { + "type": "object", + "properties": { + "activity_id": { + "type": "integer" + }, + "board": { + "type": "array", + "items": { + "$ref": "#/definitions/activity.MatchingCard" + } + }, + "card_configs": { + "type": "array", + "items": { + "$ref": "#/definitions/activity.CardTypeConfig" + } + }, + "card_id_counter": { + "type": "integer" + }, + "created_at": { + "description": "游戏创建时间,用于自动开奖超时判断", + "type": "string" + }, + "deck": { + "type": "array", + "items": { + "$ref": "#/definitions/activity.MatchingCard" + } + }, + "issue_id": { + "type": "integer" + }, + "last_activity": { + "type": "string" + }, + "max_possible_pairs": { + "type": "integer" + }, + "nonce": { + "type": "integer" + }, + "order_id": { + "type": "integer" + }, + "position": { + "description": "用户选择的类型,用于服务端验证", + "type": "string" + }, + "server_seed": { + "type": "array", + "items": { + "type": "integer" + } + }, + "server_seed_hash": { + "type": "string" + }, + "total_pairs": { + "type": "integer" + }, + "user_id": { + "type": "integer" + } + } + }, "admin.GrantRewardRequest": { "type": "object", "required": [ @@ -6238,6 +7563,10 @@ const docTemplate = `{ "description": "收货地址ID(可选,实物商品需要)", "type": "integer" }, + "points_amount": { + "description": "消耗积分", + "type": "integer" + }, "product_id": { "description": "商品ID", "type": "integer" @@ -6377,6 +7706,107 @@ const docTemplate = `{ } } }, + "admin.UserProfileResponse": { + "type": "object", + "properties": { + "avatar": { + "type": "string" + }, + "channel_id": { + "type": "integer" + }, + "created_at": { + "type": "string" + }, + "current_assets": { + "description": "当前资产快照", + "type": "object", + "properties": { + "coupon_count": { + "description": "持有优惠券数", + "type": "integer" + }, + "coupon_value": { + "description": "持有优惠券价值", + "type": "integer" + }, + "inventory_count": { + "description": "持有商品数", + "type": "integer" + }, + "inventory_value": { + "description": "持有商品价值", + "type": "integer" + }, + "item_card_count": { + "description": "持有道具卡数", + "type": "integer" + }, + "item_card_value": { + "description": "持有道具卡价值", + "type": "integer" + }, + "points_balance": { + "description": "积分余额", + "type": "integer" + }, + "profit_loss_ratio": { + "description": "累计盈亏比", + "type": "number" + }, + "total_asset_value": { + "description": "总资产估值", + "type": "integer" + } + } + }, + "douyin_id": { + "type": "string" + }, + "id": { + "description": "基本信息", + "type": "integer" + }, + "invite_code": { + "type": "string" + }, + "invite_count": { + "description": "邀请统计", + "type": "integer" + }, + "inviter_id": { + "type": "integer" + }, + "lifetime_stats": { + "description": "生命周期财务指标", + "type": "object", + "properties": { + "net_cash_cost": { + "description": "净现金支出", + "type": "integer" + }, + "order_count": { + "description": "订单数", + "type": "integer" + }, + "total_paid": { + "description": "累计支付", + "type": "integer" + }, + "total_refunded": { + "description": "累计退款", + "type": "integer" + } + } + }, + "mobile": { + "type": "string" + }, + "nickname": { + "type": "string" + } + } + }, "admin.activityDetailResponse": { "type": "object", "properties": { @@ -6521,14 +7951,12 @@ const docTemplate = `{ }, "admin.addPointsRequest": { "type": "object", - "required": [ - "points" - ], "properties": { "kind": { "type": "string" }, "points": { + "description": "正数=增加,负数=扣减", "type": "integer" }, "remark": { @@ -6627,6 +8055,9 @@ const docTemplate = `{ "nickname": { "type": "string" }, + "points_balance": { + "type": "integer" + }, "seven_day_consume": { "type": "integer" }, @@ -6682,7 +8113,7 @@ const docTemplate = `{ "type": "integer" }, "weight": { - "type": "integer", + "type": "number", "minimum": 0 } } @@ -6743,6 +8174,26 @@ const docTemplate = `{ } } }, + "admin.checkActivityGamePassesResponse": { + "type": "object", + "properties": { + "activity_id": { + "type": "integer" + }, + "can_delete": { + "type": "boolean" + }, + "message": { + "type": "string" + }, + "total_remaining": { + "type": "integer" + }, + "user_count": { + "type": "integer" + } + } + }, "admin.copyActivityResponse": { "type": "object", "properties": { @@ -6861,6 +8312,54 @@ const docTemplate = `{ } } }, + "admin.createGamePassPackageRequest": { + "type": "object", + "required": [ + "name", + "pass_count", + "price" + ], + "properties": { + "activity_id": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "original_price": { + "type": "integer" + }, + "pass_count": { + "type": "integer", + "minimum": 1 + }, + "price": { + "type": "integer", + "minimum": 0 + }, + "sort_order": { + "type": "integer" + }, + "status": { + "description": "1=上架 2=下架", + "type": "integer" + }, + "valid_days": { + "type": "integer" + } + } + }, + "admin.createGamePassPackageResponse": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "message": { + "type": "string" + } + } + }, "admin.createIssueRequest": { "type": "object", "required": [ @@ -6989,6 +8488,9 @@ const docTemplate = `{ "category_id": { "type": "integer" }, + "description": { + "type": "string" + }, "images_json": { "type": "string" }, @@ -7031,6 +8533,218 @@ const docTemplate = `{ } } }, + "admin.createUserRequest": { + "type": "object", + "required": [ + "password", + "username" + ], + "properties": { + "avatar": { + "type": "string" + }, + "mobile": { + "type": "string" + }, + "nickname": { + "type": "string" + }, + "password": { + "type": "string" + }, + "username": { + "type": "string" + } + } + }, + "admin.createUserResponse": { + "type": "object", + "properties": { + "message": { + "type": "string" + }, + "success": { + "type": "boolean" + } + } + }, + "admin.gamePassItem": { + "type": "object", + "properties": { + "activity_id": { + "type": "integer" + }, + "activity_name": { + "type": "string" + }, + "created_at": { + "type": "string" + }, + "expired_at": { + "type": "string" + }, + "id": { + "type": "integer" + }, + "remaining": { + "type": "integer" + }, + "remark": { + "type": "string" + }, + "source": { + "type": "string" + }, + "total_granted": { + "type": "integer" + }, + "total_used": { + "type": "integer" + }, + "user_id": { + "type": "integer" + }, + "user_name": { + "type": "string" + } + } + }, + "admin.gamePassPackageItem": { + "type": "object", + "properties": { + "activity_id": { + "type": "integer" + }, + "activity_name": { + "type": "string" + }, + "created_at": { + "type": "string" + }, + "id": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "original_price": { + "type": "integer" + }, + "pass_count": { + "type": "integer" + }, + "price": { + "type": "integer" + }, + "sort_order": { + "type": "integer" + }, + "status": { + "type": "integer" + }, + "valid_days": { + "type": "integer" + } + } + }, + "admin.getOrderSnapshotsResponse": { + "type": "object", + "properties": { + "after_snapshot": { + "$ref": "#/definitions/snapshot.UserStateSnapshot" + }, + "before_snapshot": { + "$ref": "#/definitions/snapshot.UserStateSnapshot" + }, + "diff": { + "$ref": "#/definitions/snapshot.SnapshotDiff" + }, + "has_snapshots": { + "type": "boolean" + }, + "order": { + "type": "object", + "properties": { + "actual_amount": { + "type": "integer" + }, + "id": { + "type": "integer" + }, + "order_no": { + "type": "string" + }, + "paid_at": { + "type": "string" + }, + "status": { + "type": "integer" + }, + "user_id": { + "type": "integer" + } + } + } + } + }, + "admin.getUserGamePassesResponse": { + "type": "object", + "properties": { + "activity_passes": { + "type": "array", + "items": { + "$ref": "#/definitions/admin.gamePassItem" + } + }, + "global_remaining": { + "type": "integer" + }, + "total_remaining": { + "type": "integer" + }, + "user_id": { + "type": "integer" + } + } + }, + "admin.grantGamePassRequest": { + "type": "object", + "required": [ + "count", + "user_id" + ], + "properties": { + "activity_id": { + "description": "可选,NULL表示全局通用", + "type": "integer" + }, + "count": { + "type": "integer", + "minimum": 1 + }, + "remark": { + "type": "string" + }, + "user_id": { + "type": "integer" + }, + "valid_days": { + "description": "可选,NULL表示永久有效", + "type": "integer" + } + } + }, + "admin.grantGamePassResponse": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "message": { + "type": "string" + } + } + }, "admin.issueUserTokenResponse": { "type": "object", "properties": { @@ -7175,6 +8889,46 @@ const docTemplate = `{ } } }, + "admin.listGamePassPackagesResponse": { + "type": "object", + "properties": { + "list": { + "type": "array", + "items": { + "$ref": "#/definitions/admin.gamePassPackageItem" + } + }, + "page": { + "type": "integer" + }, + "page_size": { + "type": "integer" + }, + "total": { + "type": "integer" + } + } + }, + "admin.listGamePassesResponse": { + "type": "object", + "properties": { + "list": { + "type": "array", + "items": { + "$ref": "#/definitions/admin.gamePassItem" + } + }, + "page": { + "type": "integer" + }, + "page_size": { + "type": "integer" + }, + "total": { + "type": "integer" + } + } + }, "admin.listInventoryResponse": { "type": "object", "properties": { @@ -7527,6 +9281,9 @@ const docTemplate = `{ "end_time": { "type": "string" }, + "force": { + "type": "boolean" + }, "gameplay_intro": { "type": "string" }, @@ -7594,6 +9351,35 @@ const docTemplate = `{ } } }, + "admin.modifyGamePassPackageRequest": { + "type": "object", + "properties": { + "activity_id": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "original_price": { + "type": "integer" + }, + "pass_count": { + "type": "integer" + }, + "price": { + "type": "integer" + }, + "sort_order": { + "type": "integer" + }, + "status": { + "type": "integer" + }, + "valid_days": { + "type": "integer" + } + } + }, "admin.modifyIssueRequest": { "type": "object", "properties": { @@ -7681,6 +9467,9 @@ const docTemplate = `{ "category_id": { "type": "integer" }, + "description": { + "type": "string" + }, "images_json": { "type": "string" }, @@ -7710,9 +9499,6 @@ const docTemplate = `{ "min_score": { "type": "integer" }, - "name": { - "type": "string" - }, "original_qty": { "type": "integer" }, @@ -7726,7 +9512,7 @@ const docTemplate = `{ "type": "integer" }, "weight": { - "type": "integer" + "type": "number" } } }, @@ -7769,6 +9555,9 @@ const docTemplate = `{ "category_id": { "type": "integer" }, + "description": { + "type": "string" + }, "id": { "type": "integer" }, @@ -7807,7 +9596,6 @@ const docTemplate = `{ "type": "object", "required": [ "level", - "name", "original_qty", "quantity", "weight" @@ -7825,15 +9613,21 @@ const docTemplate = `{ "min_score": { "type": "integer" }, - "name": { - "type": "string" - }, "original_qty": { "type": "integer" }, "product_id": { "type": "integer" }, + "product_image_url": { + "type": "string" + }, + "product_name": { + "type": "string" + }, + "product_price": { + "type": "number" + }, "quantity": { "type": "integer" }, @@ -7841,7 +9635,7 @@ const docTemplate = `{ "type": "integer" }, "weight": { - "type": "integer" + "type": "number" } } }, @@ -7888,6 +9682,46 @@ const docTemplate = `{ } } }, + "admin.rollbackOrderRequest": { + "type": "object", + "properties": { + "confirm": { + "type": "boolean" + }, + "reason": { + "type": "string" + } + } + }, + "admin.rollbackOrderResponse": { + "type": "object", + "properties": { + "coupons_restored": { + "type": "integer" + }, + "error_msg": { + "type": "string" + }, + "inventory_revoked": { + "type": "integer" + }, + "item_cards_restored": { + "type": "integer" + }, + "points_restored": { + "type": "integer" + }, + "refund_amount": { + "type": "integer" + }, + "rollback_log_id": { + "type": "integer" + }, + "success": { + "type": "boolean" + } + } + }, "admin.simpleMessage": { "type": "object", "properties": { @@ -8006,34 +9840,6 @@ const docTemplate = `{ } } }, - "app.CardTypeConfig": { - "type": "object", - "properties": { - "code": { - "type": "string" - }, - "image_url": { - "type": "string" - }, - "name": { - "type": "string" - }, - "quantity": { - "type": "integer" - } - } - }, - "app.MatchingCard": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "type": { - "type": "string" - } - } - }, "app.MatchingRewardInfo": { "type": "object", "properties": { @@ -8043,6 +9849,14 @@ const docTemplate = `{ "name": { "type": "string" }, + "product_image": { + "description": "商品图片", + "type": "string" + }, + "product_name": { + "description": "商品原始名称", + "type": "string" + }, "reward_id": { "type": "integer" } @@ -8210,6 +10024,9 @@ const docTemplate = `{ }, "share_url": { "type": "string" + }, + "short_link": { + "type": "string" } } }, @@ -8294,6 +10111,25 @@ const docTemplate = `{ } } }, + "app.bindDouyinPhoneRequest": { + "type": "object", + "properties": { + "code": { + "type": "string" + } + } + }, + "app.bindDouyinPhoneResponse": { + "type": "object", + "properties": { + "mobile": { + "type": "string" + }, + "success": { + "type": "boolean" + } + } + }, "app.bindPhoneRequest": { "type": "object", "properties": { @@ -8341,13 +10177,24 @@ const docTemplate = `{ "app.cancelShippingRequest": { "type": "object", "properties": { + "batch_no": { + "description": "批次号(与inventory_id二选一,取消整批)", + "type": "string" + }, "inventory_id": { + "description": "单个资产ID(与batch_no二选一)", "type": "integer" } } }, "app.cancelShippingResponse": { - "type": "object" + "type": "object", + "properties": { + "cancelled_count": { + "description": "成功取消的数量", + "type": "integer" + } + } }, "app.couponDetail": { "type": "object", @@ -8466,6 +10313,43 @@ const docTemplate = `{ } } }, + "app.douyinLoginRequest": { + "type": "object", + "properties": { + "anonymous_code": { + "type": "string" + }, + "channel_code": { + "type": "string" + }, + "code": { + "type": "string" + }, + "invite_code": { + "type": "string" + } + } + }, + "app.douyinLoginResponse": { + "type": "object", + "properties": { + "avatar": { + "type": "string" + }, + "invite_code": { + "type": "string" + }, + "nickname": { + "type": "string" + }, + "token": { + "type": "string" + }, + "user_id": { + "type": "integer" + } + } + }, "app.drawLogGroup": { "type": "object", "properties": { @@ -8489,6 +10373,9 @@ const docTemplate = `{ "avatar": { "type": "string" }, + "created_at": { + "type": "string" + }, "current_level": { "type": "integer" }, @@ -8542,6 +10429,9 @@ const docTemplate = `{ "name": { "type": "string" }, + "points_required": { + "type": "integer" + }, "price": { "type": "integer" }, @@ -8565,6 +10455,35 @@ const docTemplate = `{ } } }, + "app.getGamePassesResponse": { + "type": "object", + "properties": { + "global_remaining": { + "description": "全局通用次数", + "type": "integer" + }, + "passes": { + "type": "array", + "items": { + "$ref": "#/definitions/app.userGamePassItem" + } + }, + "total_remaining": { + "type": "integer" + } + } + }, + "app.getPackagesResponse": { + "type": "object", + "properties": { + "packages": { + "type": "array", + "items": { + "$ref": "#/definitions/app.packageItem" + } + } + } + }, "app.inviteUserItem": { "type": "object", "properties": { @@ -8632,6 +10551,9 @@ const docTemplate = `{ "type": "integer" } }, + "use_game_pass": { + "type": "boolean" + }, "use_points": { "type": "integer" } @@ -8640,6 +10562,9 @@ const docTemplate = `{ "app.joinLotteryResponse": { "type": "object", "properties": { + "actual_amount": { + "type": "integer" + }, "draw_mode": { "type": "string" }, @@ -8657,6 +10582,9 @@ const docTemplate = `{ }, "reward_name": { "type": "string" + }, + "status": { + "type": "integer" } } }, @@ -8779,6 +10707,9 @@ const docTemplate = `{ "name": { "type": "string" }, + "points_required": { + "type": "integer" + }, "price": { "type": "integer" }, @@ -8887,7 +10818,7 @@ const docTemplate = `{ "list": { "type": "array", "items": { - "$ref": "#/definitions/user.InventoryWithProduct" + "$ref": "#/definitions/user.AggregatedInventory" } }, "page": { @@ -9009,6 +10940,10 @@ const docTemplate = `{ "items": { "$ref": "#/definitions/app.rewardItem" } + }, + "play_type": { + "description": "活动类型", + "type": "string" } } }, @@ -9059,6 +10994,9 @@ const docTemplate = `{ "name": { "type": "string" }, + "points_required": { + "type": "integer" + }, "price": { "type": "integer" }, @@ -9130,6 +11068,20 @@ const docTemplate = `{ } } }, + "app.matchingGameCardsResponse": { + "type": "object", + "properties": { + "all_cards": { + "type": "array", + "items": { + "$ref": "#/definitions/activity.MatchingCard" + } + }, + "game_id": { + "type": "string" + } + } + }, "app.matchingGameCheckRequest": { "type": "object", "required": [ @@ -9176,19 +11128,16 @@ const docTemplate = `{ }, "position": { "type": "string" + }, + "use_game_pass": { + "description": "新增:是否使用次数卡", + "type": "boolean" } } }, "app.matchingGamePreOrderResponse": { "type": "object", "properties": { - "all_cards": { - "description": "全量99张卡牌(乱序)", - "type": "array", - "items": { - "$ref": "#/definitions/app.MatchingCard" - } - }, "game_id": { "type": "string" }, @@ -9245,6 +11194,9 @@ const docTemplate = `{ "draw_index": { "type": "integer" }, + "image": { + "type": "string" + }, "level": { "type": "integer" }, @@ -9292,6 +11244,32 @@ const docTemplate = `{ } } }, + "app.packageItem": { + "type": "object", + "properties": { + "activity_id": { + "type": "integer" + }, + "id": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "original_price": { + "type": "integer" + }, + "pass_count": { + "type": "integer" + }, + "price": { + "type": "integer" + }, + "valid_days": { + "type": "integer" + } + } + }, "app.pointsBalanceResponse": { "type": "object", "properties": { @@ -9300,6 +11278,32 @@ const docTemplate = `{ } } }, + "app.purchasePackageRequest": { + "type": "object", + "required": [ + "package_id" + ], + "properties": { + "count": { + "description": "购买数量", + "type": "integer" + }, + "package_id": { + "type": "integer" + } + } + }, + "app.purchasePackageResponse": { + "type": "object", + "properties": { + "message": { + "type": "string" + }, + "order_no": { + "type": "string" + } + } + }, "app.redeemCouponRequest": { "type": "object", "properties": { @@ -9426,6 +11430,9 @@ const docTemplate = `{ "address_id": { "type": "integer" }, + "batch_no": { + "type": "string" + }, "failed": { "type": "array", "items": { @@ -9474,24 +11481,41 @@ const docTemplate = `{ "result": { "type": "object", "additionalProperties": {} + }, + "results": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": {} + } } } }, "app.rewardItem": { "type": "object", "properties": { + "id": { + "type": "integer" + }, "is_boss": { "type": "integer" }, "level": { "type": "integer" }, + "min_score": { + "type": "integer" + }, "name": { "type": "string" }, "original_qty": { "type": "integer" }, + "prize_level": { + "description": "兼容部分前端逻辑", + "type": "integer" + }, "product_id": { "type": "integer" }, @@ -9509,12 +11533,134 @@ const docTemplate = `{ } } }, + "app.sendSmsCodeRequest": { + "type": "object", + "required": [ + "mobile" + ], + "properties": { + "mobile": { + "type": "string" + } + } + }, + "app.sendSmsCodeResponse": { + "type": "object", + "properties": { + "expire_seconds": { + "type": "integer" + }, + "success": { + "type": "boolean" + } + } + }, + "app.smsLoginRequest": { + "type": "object", + "required": [ + "code", + "mobile" + ], + "properties": { + "code": { + "type": "string" + }, + "invite_code": { + "type": "string" + }, + "mobile": { + "type": "string" + } + } + }, + "app.smsLoginResponse": { + "type": "object", + "properties": { + "avatar": { + "type": "string" + }, + "invite_code": { + "type": "string" + }, + "is_new_user": { + "type": "boolean" + }, + "mobile": { + "type": "string" + }, + "nickname": { + "type": "string" + }, + "openid": { + "type": "string" + }, + "token": { + "type": "string" + }, + "user_id": { + "type": "integer" + } + } + }, + "app.updateAddressRequest": { + "type": "object", + "properties": { + "address": { + "type": "string" + }, + "city": { + "type": "string" + }, + "district": { + "type": "string" + }, + "is_default": { + "type": "boolean" + }, + "mobile": { + "type": "string" + }, + "name": { + "type": "string" + }, + "province": { + "type": "string" + } + } + }, + "app.userGamePassItem": { + "type": "object", + "properties": { + "activity_id": { + "type": "integer" + }, + "activity_name": { + "type": "string" + }, + "expired_at": { + "type": "string" + }, + "id": { + "type": "integer" + }, + "remaining": { + "type": "integer" + }, + "source": { + "type": "string" + } + } + }, "app.userItem": { "type": "object", "properties": { "avatar": { "type": "string" }, + "balance": { + "description": "Points", + "type": "integer" + }, "id": { "type": "integer" }, @@ -9524,6 +11670,9 @@ const docTemplate = `{ "inviter_id": { "type": "integer" }, + "mobile": { + "type": "string" + }, "nickname": { "type": "string" } @@ -9593,6 +11742,178 @@ const docTemplate = `{ } } }, + "game.consumeTicketRequest": { + "type": "object", + "properties": { + "game_code": { + "type": "string" + }, + "ticket": { + "type": "string" + }, + "user_id": { + "type": "string" + } + } + }, + "game.consumeTicketResponse": { + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "success": { + "type": "boolean" + } + } + }, + "game.enterGameRequest": { + "type": "object", + "required": [ + "game_code" + ], + "properties": { + "game_code": { + "type": "string" + } + } + }, + "game.enterGameResponse": { + "type": "object", + "properties": { + "client_url": { + "type": "string" + }, + "expires_at": { + "type": "string" + }, + "game_token": { + "type": "string" + }, + "nakama_key": { + "type": "string" + }, + "nakama_server": { + "type": "string" + }, + "remaining_times": { + "type": "integer" + } + } + }, + "game.grantTicketRequest": { + "type": "object", + "required": [ + "amount", + "game_code" + ], + "properties": { + "amount": { + "type": "integer", + "minimum": 1 + }, + "game_code": { + "type": "string" + }, + "remark": { + "type": "string" + } + } + }, + "game.settleRequest": { + "type": "object", + "properties": { + "match_id": { + "type": "string" + }, + "score": { + "type": "integer" + }, + "ticket": { + "type": "string" + }, + "user_id": { + "type": "string" + }, + "win": { + "type": "boolean" + } + } + }, + "game.settleResponse": { + "type": "object", + "properties": { + "reward": { + "type": "string" + }, + "success": { + "type": "boolean" + } + } + }, + "game.validateTokenRequest": { + "type": "object", + "required": [ + "game_token" + ], + "properties": { + "game_token": { + "type": "string" + } + } + }, + "game.validateTokenResponse": { + "type": "object", + "properties": { + "avatar": { + "type": "string" + }, + "error": { + "type": "string" + }, + "game_type": { + "type": "string" + }, + "ticket": { + "type": "string" + }, + "user_id": { + "type": "integer" + }, + "username": { + "type": "string" + }, + "valid": { + "type": "boolean" + } + } + }, + "game.verifyRequest": { + "type": "object", + "properties": { + "ticket": { + "type": "string" + }, + "user_id": { + "type": "string" + } + } + }, + "game.verifyResponse": { + "type": "object", + "properties": { + "game_config": { + "type": "object", + "additionalProperties": {} + }, + "user_id": { + "type": "string" + }, + "valid": { + "type": "boolean" + } + } + }, "minesweeper.SettleGameRequest": { "type": "object", "properties": { @@ -9944,6 +12265,126 @@ const docTemplate = `{ } } }, + "snapshot.CouponInfo": { + "type": "object", + "properties": { + "balance_amount": { + "type": "integer" + }, + "coupon_id": { + "type": "integer" + }, + "coupon_name": { + "type": "string" + }, + "status": { + "type": "integer" + }, + "user_coupon_id": { + "type": "integer" + }, + "valid_end": { + "type": "string" + } + } + }, + "snapshot.ItemCardInfo": { + "type": "object", + "properties": { + "card_id": { + "type": "integer" + }, + "card_name": { + "type": "string" + }, + "status": { + "type": "integer" + }, + "user_item_card_id": { + "type": "integer" + }, + "valid_end": { + "type": "string" + } + } + }, + "snapshot.PointsInfo": { + "type": "object", + "properties": { + "balance": { + "type": "integer" + }, + "version": { + "type": "integer" + } + } + }, + "snapshot.SnapshotDiff": { + "type": "object", + "properties": { + "coupons_used": { + "type": "array", + "items": { + "$ref": "#/definitions/snapshot.CouponInfo" + } + }, + "inventory_added": { + "type": "integer" + }, + "item_cards_used": { + "type": "array", + "items": { + "$ref": "#/definitions/snapshot.ItemCardInfo" + } + }, + "points_changed": { + "type": "integer" + } + } + }, + "snapshot.UserInfo": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "mobile": { + "type": "string" + }, + "nickname": { + "type": "string" + } + } + }, + "snapshot.UserStateSnapshot": { + "type": "object", + "properties": { + "coupons": { + "type": "array", + "items": { + "$ref": "#/definitions/snapshot.CouponInfo" + } + }, + "inventory_count": { + "type": "integer" + }, + "item_cards": { + "type": "array", + "items": { + "$ref": "#/definitions/snapshot.ItemCardInfo" + } + }, + "points": { + "$ref": "#/definitions/snapshot.PointsInfo" + }, + "snapshot_time": { + "type": "string" + }, + "user": { + "$ref": "#/definitions/snapshot.UserInfo" + } + } + }, "taskcenter.claimTaskRequest": { "type": "object", "properties": { @@ -9958,9 +12399,15 @@ const docTemplate = `{ "description": { "type": "string" }, + "end_time": { + "type": "string" + }, "name": { "type": "string" }, + "start_time": { + "type": "string" + }, "status": { "type": "integer" }, @@ -9995,9 +12442,15 @@ const docTemplate = `{ "description": { "type": "string" }, + "end_time": { + "type": "string" + }, "name": { "type": "string" }, + "start_time": { + "type": "string" + }, "status": { "type": "integer" }, @@ -10078,6 +12531,9 @@ const docTemplate = `{ "invite_count": { "type": "integer" }, + "order_amount": { + "type": "integer" + }, "order_count": { "type": "integer" }, @@ -10098,6 +12554,9 @@ const docTemplate = `{ "quantity": { "type": "integer" }, + "reward_name": { + "type": "string" + }, "reward_payload": { "type": "object", "additionalProperties": {} @@ -10172,6 +12631,15 @@ const docTemplate = `{ "items": { "type": "object", "properties": { + "activity_id": { + "type": "integer" + }, + "extra_params": { + "type": "array", + "items": { + "type": "integer" + } + }, "metric": { "type": "string" }, @@ -10195,6 +12663,63 @@ const docTemplate = `{ } } }, + "user.AggregatedInventory": { + "type": "object", + "properties": { + "count": { + "type": "integer" + }, + "has_shipment": { + "type": "boolean" + }, + "inventory_ids": { + "type": "array", + "items": { + "type": "integer" + } + }, + "product_id": { + "type": "integer" + }, + "product_images": { + "type": "string" + }, + "product_name": { + "type": "string" + }, + "product_price": { + "type": "integer" + }, + "shipping_status": { + "type": "integer" + }, + "status": { + "description": "用于区分 1持有 3已处理", + "type": "integer" + }, + "updated_at": { + "type": "string" + } + } + }, + "user.CouponSimpleInfo": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "type": { + "description": "1:满减 2:折扣", + "type": "integer" + }, + "user_coupon_id": { + "type": "integer" + }, + "value": { + "type": "integer" + } + } + }, "user.DrawReceiptInfo": { "type": "object", "properties": { @@ -10286,6 +12811,10 @@ const docTemplate = `{ "product_name": { "type": "string" }, + "product_price": { + "description": "新增价格字段", + "type": "integer" + }, "remark": { "description": "备注", "type": "string" @@ -10294,6 +12823,10 @@ const docTemplate = `{ "description": "来源奖励ID(activity_reward_settings.id)", "type": "integer" }, + "shipping_no": { + "description": "发货单号", + "type": "string" + }, "shipping_status": { "type": "integer" }, @@ -10311,6 +12844,20 @@ const docTemplate = `{ } } }, + "user.ItemCardSimpleInfo": { + "type": "object", + "properties": { + "effect_type": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "user_card_id": { + "type": "integer" + } + } + }, "user.ItemCardWithTemplate": { "type": "object", "properties": { @@ -10321,6 +12868,9 @@ const docTemplate = `{ "card_type": { "type": "integer" }, + "count": { + "type": "integer" + }, "created_at": { "description": "创建时间", "type": "string" @@ -10356,6 +12906,9 @@ const docTemplate = `{ "description": "使用时活动ID", "type": "integer" }, + "used_activity_name": { + "type": "string" + }, "used_at": { "description": "使用时间", "type": "string" @@ -10368,6 +12921,12 @@ const docTemplate = `{ "description": "使用时期ID", "type": "integer" }, + "used_issue_number": { + "type": "string" + }, + "used_reward_name": { + "type": "string" + }, "user_id": { "description": "用户ID(users.id)", "type": "integer" @@ -10405,6 +12964,13 @@ const docTemplate = `{ "category_name": { "type": "string" }, + "coupon_id": { + "description": "使用的优惠券ID", + "type": "integer" + }, + "coupon_info": { + "$ref": "#/definitions/user.CouponSimpleInfo" + }, "created_at": { "description": "创建时间", "type": "string" @@ -10436,6 +13002,13 @@ const docTemplate = `{ "issue_number": { "type": "string" }, + "item_card_id": { + "description": "使用的道具卡ID", + "type": "integer" + }, + "item_card_info": { + "$ref": "#/definitions/user.ItemCardSimpleInfo" + }, "items": { "type": "array", "items": { diff --git a/docs/swagger.json b/docs/swagger.json index 897fe26..196d479 100644 --- a/docs/swagger.json +++ b/docs/swagger.json @@ -649,6 +649,49 @@ } } }, + "/api/admin/activities/{activity_id}/game-passes/check": { + "get": { + "security": [ + { + "LoginVerifyToken": [] + } + ], + "description": "检查指定活动是否有用户持有未使用的次数卡,用于下架/删除前校验", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "管理端.次数卡" + ], + "summary": "检查活动次数卡", + "parameters": [ + { + "type": "integer", + "description": "活动ID", + "name": "activity_id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/admin.checkActivityGamePassesResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/code.Failure" + } + } + } + } + }, "/api/admin/activities/{activity_id}/issues": { "get": { "security": [ @@ -1406,6 +1449,307 @@ } } }, + "/api/admin/game-pass-packages": { + "get": { + "security": [ + { + "LoginVerifyToken": [] + } + ], + "description": "查询次数卡套餐列表", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "管理端.次数卡套餐" + ], + "summary": "次数卡套餐列表", + "parameters": [ + { + "type": "integer", + "description": "活动ID", + "name": "activity_id", + "in": "query" + }, + { + "type": "integer", + "description": "状态: 1=上架 2=下架", + "name": "status", + "in": "query" + }, + { + "type": "integer", + "description": "页码", + "name": "page", + "in": "query" + }, + { + "type": "integer", + "description": "每页条数", + "name": "page_size", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/admin.listGamePassPackagesResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/code.Failure" + } + } + } + }, + "post": { + "security": [ + { + "LoginVerifyToken": [] + } + ], + "description": "创建可购买的次数卡套餐", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "管理端.次数卡套餐" + ], + "summary": "创建次数卡套餐", + "parameters": [ + { + "description": "请求参数", + "name": "RequestBody", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/admin.createGamePassPackageRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/admin.createGamePassPackageResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/code.Failure" + } + } + } + } + }, + "/api/admin/game-pass-packages/{package_id}": { + "put": { + "security": [ + { + "LoginVerifyToken": [] + } + ], + "description": "修改次数卡套餐配置", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "管理端.次数卡套餐" + ], + "summary": "修改次数卡套餐", + "parameters": [ + { + "type": "integer", + "description": "套餐ID", + "name": "package_id", + "in": "path", + "required": true + }, + { + "description": "请求参数", + "name": "RequestBody", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/admin.modifyGamePassPackageRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/admin.simpleMessageResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/code.Failure" + } + } + } + }, + "delete": { + "security": [ + { + "LoginVerifyToken": [] + } + ], + "description": "软删除次数卡套餐", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "管理端.次数卡套餐" + ], + "summary": "删除次数卡套餐", + "parameters": [ + { + "type": "integer", + "description": "套餐ID", + "name": "package_id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/admin.simpleMessageResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/code.Failure" + } + } + } + } + }, + "/api/admin/game-passes/grant": { + "post": { + "security": [ + { + "LoginVerifyToken": [] + } + ], + "description": "管理员为用户发放游戏次数卡", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "管理端.次数卡" + ], + "summary": "发放次数卡", + "parameters": [ + { + "description": "请求参数", + "name": "RequestBody", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/admin.grantGamePassRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/admin.grantGamePassResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/code.Failure" + } + } + } + } + }, + "/api/admin/game-passes/list": { + "get": { + "security": [ + { + "LoginVerifyToken": [] + } + ], + "description": "查询用户次数卡列表,支持按用户、活动过滤", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "管理端.次数卡" + ], + "summary": "次数卡列表", + "parameters": [ + { + "type": "integer", + "description": "用户ID", + "name": "user_id", + "in": "query" + }, + { + "type": "integer", + "description": "活动ID", + "name": "activity_id", + "in": "query" + }, + { + "type": "integer", + "description": "页码", + "name": "page", + "in": "query" + }, + { + "type": "integer", + "description": "每页条数", + "name": "page_size", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/admin.listGamePassesResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/code.Failure" + } + } + } + } + }, "/api/admin/login": { "post": { "description": "管理员登录", @@ -1446,6 +1790,49 @@ } } }, + "/api/admin/matching/audit/{order_no}": { + "get": { + "security": [ + { + "AdminAuth": [] + } + ], + "description": "运营通过单号查询发牌过程", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "管理端.对对碰" + ], + "summary": "获取对对碰审计数据", + "parameters": [ + { + "type": "string", + "description": "订单号", + "name": "order_no", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/activity.MatchingGame" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/code.Failure" + } + } + } + } + }, "/api/admin/matching_card_types": { "get": { "security": [ @@ -1617,6 +2004,79 @@ } } }, + "/api/admin/orders/{order_id}/rollback": { + "post": { + "description": "基于快照将用户数据回滚到消费前状态,包括恢复积分、优惠券、道具卡,作废资产,调用微信退款", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Admin-Audit" + ], + "summary": "回滚订单到消费前状态", + "parameters": [ + { + "type": "integer", + "description": "订单ID", + "name": "order_id", + "in": "path", + "required": true + }, + { + "description": "回滚请求", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/admin.rollbackOrderRequest" + } + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "$ref": "#/definitions/admin.rollbackOrderResponse" + } + } + } + } + }, + "/api/admin/orders/{order_id}/snapshots": { + "get": { + "description": "获取订单消费前后的完整用户状态快照", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Admin-Audit" + ], + "summary": "获取订单审计快照", + "parameters": [ + { + "type": "integer", + "description": "订单ID", + "name": "order_id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "$ref": "#/definitions/admin.getOrderSnapshotsResponse" + } + } + } + } + }, "/api/admin/product_categories": { "get": { "security": [ @@ -2672,6 +3132,120 @@ } } }, + "/api/admin/users/{user_id}/game-passes": { + "get": { + "security": [ + { + "LoginVerifyToken": [] + } + ], + "description": "查询指定用户的所有次数卡", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "管理端.次数卡" + ], + "summary": "查询用户次数卡", + "parameters": [ + { + "type": "integer", + "description": "用户ID", + "name": "user_id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/admin.getUserGamePassesResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/code.Failure" + } + } + } + } + }, + "/api/admin/users/{user_id}/game_tickets": { + "get": { + "tags": [ + "管理端.游戏" + ], + "summary": "查询用户游戏资格日志", + "parameters": [ + { + "type": "integer", + "description": "用户ID", + "name": "user_id", + "in": "path", + "required": true + }, + { + "type": "integer", + "description": "页码", + "name": "page", + "in": "query" + }, + { + "type": "integer", + "description": "每页数量", + "name": "page_size", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + }, + "post": { + "tags": [ + "管理端.游戏" + ], + "summary": "发放游戏资格", + "parameters": [ + { + "type": "integer", + "description": "用户ID", + "name": "user_id", + "in": "path", + "required": true + }, + { + "description": "请求参数", + "name": "RequestBody", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/game.grantTicketRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + } + }, "/api/admin/users/{user_id}/inventory": { "get": { "security": [ @@ -3037,7 +3611,7 @@ "LoginVerifyToken": [] } ], - "description": "管理端为指定用户发放积分,支持设置有效期", + "description": "管理端为指定用户发放或扣减积分,正数为增加,负数为扣减", "consumes": [ "application/json" ], @@ -3047,7 +3621,7 @@ "tags": [ "管理端.用户" ], - "summary": "给用户添加积分", + "summary": "给用户增加或扣减积分", "parameters": [ { "type": "integer", @@ -3125,6 +3699,49 @@ } } }, + "/api/admin/users/{user_id}/profile": { + "get": { + "security": [ + { + "LoginVerifyToken": [] + } + ], + "description": "聚合用户基本信息、生命周期财务指标、当前资产快照", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "管理端.用户" + ], + "summary": "获取用户综合画像", + "parameters": [ + { + "type": "integer", + "description": "用户ID", + "name": "user_id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/admin.UserProfileResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/code.Failure" + } + } + } + } + }, "/api/admin/users/{user_id}/rewards/grant": { "post": { "description": "管理员给用户发放奖励,支持实物和虚拟奖品,可选择关联活动和奖励配置", @@ -3679,6 +4296,157 @@ } } }, + "/api/app/game-passes/available": { + "get": { + "security": [ + { + "LoginVerifyToken": [] + } + ], + "description": "查询当前用户可用的游戏次数卡", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "APP端.用户" + ], + "summary": "获取用户次数卡", + "parameters": [ + { + "type": "integer", + "description": "活动ID,不传返回所有可用次数卡", + "name": "activity_id", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/app.getGamePassesResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/code.Failure" + } + } + } + } + }, + "/api/app/game-passes/packages": { + "get": { + "description": "获取可购买的次数卡套餐列表", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "APP端.用户" + ], + "summary": "获取次数卡套餐", + "parameters": [ + { + "type": "integer", + "description": "活动ID,不传返回全局套餐", + "name": "activity_id", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/app.getPackagesResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/code.Failure" + } + } + } + } + }, + "/api/app/game-passes/purchase": { + "post": { + "security": [ + { + "LoginVerifyToken": [] + } + ], + "description": "购买次数卡套餐,创建订单等待支付", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "APP端.用户" + ], + "summary": "购买次数卡套餐", + "parameters": [ + { + "description": "请求参数", + "name": "RequestBody", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/app.purchasePackageRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/app.purchasePackageResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/code.Failure" + } + } + } + } + }, + "/api/app/games/enter": { + "post": { + "tags": [ + "APP端.游戏" + ], + "summary": "进入游戏", + "parameters": [ + { + "description": "请求参数", + "name": "RequestBody", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/game.enterGameRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/game.enterGameResponse" + } + } + } + } + }, "/api/app/lottery/join": { "post": { "security": [ @@ -3786,7 +4554,7 @@ "schema": { "type": "array", "items": { - "$ref": "#/definitions/app.CardTypeConfig" + "$ref": "#/definitions/activity.CardTypeConfig" } } }, @@ -3799,6 +4567,49 @@ } } }, + "/api/app/matching/cards": { + "get": { + "security": [ + { + "LoginVerifyToken": [] + } + ], + "description": "只有支付成功后才能获取游戏牌组数据,防止未支付用户获取牌组信息", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "APP端.活动" + ], + "summary": "获取对对碰游戏数据", + "parameters": [ + { + "type": "string", + "description": "游戏ID", + "name": "game_id", + "in": "query", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/app.matchingGameCardsResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/code.Failure" + } + } + } + } + }, "/api/app/matching/check": { "post": { "security": [ @@ -4192,6 +5003,86 @@ } } }, + "/api/app/sms/login": { + "post": { + "description": "使用短信验证码登录或注册(新手机号自动创建账户)", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "APP端.用户" + ], + "summary": "短信验证码登录", + "parameters": [ + { + "description": "请求参数", + "name": "RequestBody", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/app.smsLoginRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/app.smsLoginResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/code.Failure" + } + } + } + } + }, + "/api/app/sms/send-code": { + "post": { + "description": "发送短信验证码到指定手机号(60秒内不可重复发送,每日最多10次)", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "APP端.用户" + ], + "summary": "发送短信验证码", + "parameters": [ + { + "description": "请求参数", + "name": "RequestBody", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/app.sendSmsCodeRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/app.sendSmsCodeResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/code.Failure" + } + } + } + } + }, "/api/app/store/items": { "get": { "security": [ @@ -4375,6 +5266,80 @@ } } }, + "/api/app/users/douyin/login": { + "post": { + "description": "抖音小程序登录(需传递 code 或 anonymous_code)", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "APP端.用户" + ], + "summary": "抖音登录", + "parameters": [ + { + "description": "请求参数", + "name": "RequestBody", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/app.douyinLoginRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/app.douyinLoginResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/code.Failure" + } + } + } + } + }, + "/api/app/users/profile": { + "get": { + "security": [ + { + "LoginVerifyToken": [] + } + ], + "description": "获取当前登录用户的详细信息", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "APP端.用户" + ], + "summary": "获取用户信息", + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/app.userItem" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/code.Failure" + } + } + } + } + }, "/api/app/users/weixin/login": { "post": { "description": "微信静默登录(需传递 code;可选 invite_code)", @@ -4597,6 +5562,75 @@ } }, "/api/app/users/{user_id}/addresses/{address_id}": { + "put": { + "security": [ + { + "LoginVerifyToken": [] + } + ], + "description": "更新当前登录用户的指定收货地址", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "APP端.用户" + ], + "summary": "更新用户地址", + "parameters": [ + { + "type": "integer", + "description": "用户ID", + "name": "user_id", + "in": "path", + "required": true + }, + { + "type": "integer", + "description": "地址ID", + "name": "address_id", + "in": "path", + "required": true + }, + { + "description": "请求参数", + "name": "RequestBody", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/app.updateAddressRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/app.okResponse" + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/code.Failure" + } + }, + "401": { + "description": "未授权", + "schema": { + "$ref": "#/definitions/code.Failure" + } + }, + "500": { + "description": "服务器内部错误", + "schema": { + "$ref": "#/definitions/code.Failure" + } + } + } + }, "delete": { "security": [ { @@ -4890,6 +5924,86 @@ } } }, + "/api/app/users/{user_id}/douyin/phone/bind": { + "post": { + "security": [ + { + "LoginVerifyToken": [] + } + ], + "description": "使用抖音手机号 code 换取手机号并绑定到指定用户", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "APP端.用户" + ], + "summary": "抖音绑定手机号", + "parameters": [ + { + "type": "integer", + "description": "用户ID", + "name": "user_id", + "in": "path", + "required": true + }, + { + "description": "请求参数", + "name": "RequestBody", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/app.bindDouyinPhoneRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/app.bindDouyinPhoneResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/code.Failure" + } + } + } + } + }, + "/api/app/users/{user_id}/game_tickets": { + "get": { + "tags": [ + "APP端.游戏" + ], + "summary": "获取我的游戏资格", + "parameters": [ + { + "type": "integer", + "description": "用户ID", + "name": "user_id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": { + "type": "integer" + } + } + } + } + } + }, "/api/app/users/{user_id}/inventory": { "get": { "security": [ @@ -5056,7 +6170,7 @@ "LoginVerifyToken": [] } ], - "description": "取消已提交但未发货的申请;恢复库存状态", + "description": "取消已提交但未发货的申请;恢复库存状态。支持按单个资产ID取消或按批次号批量取消", "consumes": [ "application/json" ], @@ -5076,7 +6190,7 @@ "required": true }, { - "description": "请求参数:资产ID", + "description": "请求参数:资产ID或批次号(二选一)", "name": "RequestBody", "in": "body", "required": true, @@ -5688,7 +6802,7 @@ "LoginVerifyToken": [] } ], - "description": "使用积分兑换指定直减金额券,按比率 1元=100积分(券面值为分)", + "description": "使用积分兑换指定直减金额券,按比率 1积分=1元(券面值为分)", "consumes": [ "application/json" ], @@ -5785,7 +6899,7 @@ "LoginVerifyToken": [] } ], - "description": "使用积分按比率1元=100积分兑换商品,生成系统发放订单与用户资产", + "description": "使用积分按比率1积分=1元兑换商品,生成系统发放订单与用户资产", "consumes": [ "application/json" ], @@ -5996,6 +7110,51 @@ } } }, + "/api/user": { + "post": { + "security": [ + { + "LoginVerifyToken": [] + } + ], + "description": "创建新的管理员账号", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "管理端.系统" + ], + "summary": "创建系统用户", + "parameters": [ + { + "description": "请求参数", + "name": "RequestBody", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/admin.createUserRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/admin.createUserResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/code.Failure" + } + } + } + } + }, "/api/user/list": { "get": { "security": [ @@ -6111,6 +7270,50 @@ } } }, + "/internal/game/consume-ticket": { + "post": { + "tags": [ + "Internal.游戏" + ], + "summary": "扣减游戏次数", + "parameters": [ + { + "description": "请求参数", + "name": "RequestBody", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/game.consumeTicketRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/game.consumeTicketResponse" + } + } + } + } + }, + "/internal/game/minesweeper/config": { + "get": { + "tags": [ + "Internal.游戏" + ], + "summary": "获取扫雷配置", + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + } + }, "/internal/game/settle": { "post": { "description": "游戏结束后结算并发放奖励", @@ -6145,6 +7348,33 @@ } } }, + "/internal/game/validate-token": { + "post": { + "tags": [ + "Internal.游戏" + ], + "summary": "验证GameToken", + "parameters": [ + { + "description": "请求参数", + "name": "RequestBody", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/game.validateTokenRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/game.validateTokenResponse" + } + } + } + } + }, "/internal/game/verify": { "post": { "description": "验证游戏票据是否有效", @@ -6216,6 +7446,101 @@ } }, "definitions": { + "activity.CardTypeConfig": { + "type": "object", + "properties": { + "code": { + "type": "string" + }, + "image_url": { + "type": "string" + }, + "name": { + "type": "string" + }, + "quantity": { + "type": "integer" + } + } + }, + "activity.MatchingCard": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string" + } + } + }, + "activity.MatchingGame": { + "type": "object", + "properties": { + "activity_id": { + "type": "integer" + }, + "board": { + "type": "array", + "items": { + "$ref": "#/definitions/activity.MatchingCard" + } + }, + "card_configs": { + "type": "array", + "items": { + "$ref": "#/definitions/activity.CardTypeConfig" + } + }, + "card_id_counter": { + "type": "integer" + }, + "created_at": { + "description": "游戏创建时间,用于自动开奖超时判断", + "type": "string" + }, + "deck": { + "type": "array", + "items": { + "$ref": "#/definitions/activity.MatchingCard" + } + }, + "issue_id": { + "type": "integer" + }, + "last_activity": { + "type": "string" + }, + "max_possible_pairs": { + "type": "integer" + }, + "nonce": { + "type": "integer" + }, + "order_id": { + "type": "integer" + }, + "position": { + "description": "用户选择的类型,用于服务端验证", + "type": "string" + }, + "server_seed": { + "type": "array", + "items": { + "type": "integer" + } + }, + "server_seed_hash": { + "type": "string" + }, + "total_pairs": { + "type": "integer" + }, + "user_id": { + "type": "integer" + } + } + }, "admin.GrantRewardRequest": { "type": "object", "required": [ @@ -6230,6 +7555,10 @@ "description": "收货地址ID(可选,实物商品需要)", "type": "integer" }, + "points_amount": { + "description": "消耗积分", + "type": "integer" + }, "product_id": { "description": "商品ID", "type": "integer" @@ -6369,6 +7698,107 @@ } } }, + "admin.UserProfileResponse": { + "type": "object", + "properties": { + "avatar": { + "type": "string" + }, + "channel_id": { + "type": "integer" + }, + "created_at": { + "type": "string" + }, + "current_assets": { + "description": "当前资产快照", + "type": "object", + "properties": { + "coupon_count": { + "description": "持有优惠券数", + "type": "integer" + }, + "coupon_value": { + "description": "持有优惠券价值", + "type": "integer" + }, + "inventory_count": { + "description": "持有商品数", + "type": "integer" + }, + "inventory_value": { + "description": "持有商品价值", + "type": "integer" + }, + "item_card_count": { + "description": "持有道具卡数", + "type": "integer" + }, + "item_card_value": { + "description": "持有道具卡价值", + "type": "integer" + }, + "points_balance": { + "description": "积分余额", + "type": "integer" + }, + "profit_loss_ratio": { + "description": "累计盈亏比", + "type": "number" + }, + "total_asset_value": { + "description": "总资产估值", + "type": "integer" + } + } + }, + "douyin_id": { + "type": "string" + }, + "id": { + "description": "基本信息", + "type": "integer" + }, + "invite_code": { + "type": "string" + }, + "invite_count": { + "description": "邀请统计", + "type": "integer" + }, + "inviter_id": { + "type": "integer" + }, + "lifetime_stats": { + "description": "生命周期财务指标", + "type": "object", + "properties": { + "net_cash_cost": { + "description": "净现金支出", + "type": "integer" + }, + "order_count": { + "description": "订单数", + "type": "integer" + }, + "total_paid": { + "description": "累计支付", + "type": "integer" + }, + "total_refunded": { + "description": "累计退款", + "type": "integer" + } + } + }, + "mobile": { + "type": "string" + }, + "nickname": { + "type": "string" + } + } + }, "admin.activityDetailResponse": { "type": "object", "properties": { @@ -6513,14 +7943,12 @@ }, "admin.addPointsRequest": { "type": "object", - "required": [ - "points" - ], "properties": { "kind": { "type": "string" }, "points": { + "description": "正数=增加,负数=扣减", "type": "integer" }, "remark": { @@ -6619,6 +8047,9 @@ "nickname": { "type": "string" }, + "points_balance": { + "type": "integer" + }, "seven_day_consume": { "type": "integer" }, @@ -6674,7 +8105,7 @@ "type": "integer" }, "weight": { - "type": "integer", + "type": "number", "minimum": 0 } } @@ -6735,6 +8166,26 @@ } } }, + "admin.checkActivityGamePassesResponse": { + "type": "object", + "properties": { + "activity_id": { + "type": "integer" + }, + "can_delete": { + "type": "boolean" + }, + "message": { + "type": "string" + }, + "total_remaining": { + "type": "integer" + }, + "user_count": { + "type": "integer" + } + } + }, "admin.copyActivityResponse": { "type": "object", "properties": { @@ -6853,6 +8304,54 @@ } } }, + "admin.createGamePassPackageRequest": { + "type": "object", + "required": [ + "name", + "pass_count", + "price" + ], + "properties": { + "activity_id": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "original_price": { + "type": "integer" + }, + "pass_count": { + "type": "integer", + "minimum": 1 + }, + "price": { + "type": "integer", + "minimum": 0 + }, + "sort_order": { + "type": "integer" + }, + "status": { + "description": "1=上架 2=下架", + "type": "integer" + }, + "valid_days": { + "type": "integer" + } + } + }, + "admin.createGamePassPackageResponse": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "message": { + "type": "string" + } + } + }, "admin.createIssueRequest": { "type": "object", "required": [ @@ -6981,6 +8480,9 @@ "category_id": { "type": "integer" }, + "description": { + "type": "string" + }, "images_json": { "type": "string" }, @@ -7023,6 +8525,218 @@ } } }, + "admin.createUserRequest": { + "type": "object", + "required": [ + "password", + "username" + ], + "properties": { + "avatar": { + "type": "string" + }, + "mobile": { + "type": "string" + }, + "nickname": { + "type": "string" + }, + "password": { + "type": "string" + }, + "username": { + "type": "string" + } + } + }, + "admin.createUserResponse": { + "type": "object", + "properties": { + "message": { + "type": "string" + }, + "success": { + "type": "boolean" + } + } + }, + "admin.gamePassItem": { + "type": "object", + "properties": { + "activity_id": { + "type": "integer" + }, + "activity_name": { + "type": "string" + }, + "created_at": { + "type": "string" + }, + "expired_at": { + "type": "string" + }, + "id": { + "type": "integer" + }, + "remaining": { + "type": "integer" + }, + "remark": { + "type": "string" + }, + "source": { + "type": "string" + }, + "total_granted": { + "type": "integer" + }, + "total_used": { + "type": "integer" + }, + "user_id": { + "type": "integer" + }, + "user_name": { + "type": "string" + } + } + }, + "admin.gamePassPackageItem": { + "type": "object", + "properties": { + "activity_id": { + "type": "integer" + }, + "activity_name": { + "type": "string" + }, + "created_at": { + "type": "string" + }, + "id": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "original_price": { + "type": "integer" + }, + "pass_count": { + "type": "integer" + }, + "price": { + "type": "integer" + }, + "sort_order": { + "type": "integer" + }, + "status": { + "type": "integer" + }, + "valid_days": { + "type": "integer" + } + } + }, + "admin.getOrderSnapshotsResponse": { + "type": "object", + "properties": { + "after_snapshot": { + "$ref": "#/definitions/snapshot.UserStateSnapshot" + }, + "before_snapshot": { + "$ref": "#/definitions/snapshot.UserStateSnapshot" + }, + "diff": { + "$ref": "#/definitions/snapshot.SnapshotDiff" + }, + "has_snapshots": { + "type": "boolean" + }, + "order": { + "type": "object", + "properties": { + "actual_amount": { + "type": "integer" + }, + "id": { + "type": "integer" + }, + "order_no": { + "type": "string" + }, + "paid_at": { + "type": "string" + }, + "status": { + "type": "integer" + }, + "user_id": { + "type": "integer" + } + } + } + } + }, + "admin.getUserGamePassesResponse": { + "type": "object", + "properties": { + "activity_passes": { + "type": "array", + "items": { + "$ref": "#/definitions/admin.gamePassItem" + } + }, + "global_remaining": { + "type": "integer" + }, + "total_remaining": { + "type": "integer" + }, + "user_id": { + "type": "integer" + } + } + }, + "admin.grantGamePassRequest": { + "type": "object", + "required": [ + "count", + "user_id" + ], + "properties": { + "activity_id": { + "description": "可选,NULL表示全局通用", + "type": "integer" + }, + "count": { + "type": "integer", + "minimum": 1 + }, + "remark": { + "type": "string" + }, + "user_id": { + "type": "integer" + }, + "valid_days": { + "description": "可选,NULL表示永久有效", + "type": "integer" + } + } + }, + "admin.grantGamePassResponse": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "message": { + "type": "string" + } + } + }, "admin.issueUserTokenResponse": { "type": "object", "properties": { @@ -7167,6 +8881,46 @@ } } }, + "admin.listGamePassPackagesResponse": { + "type": "object", + "properties": { + "list": { + "type": "array", + "items": { + "$ref": "#/definitions/admin.gamePassPackageItem" + } + }, + "page": { + "type": "integer" + }, + "page_size": { + "type": "integer" + }, + "total": { + "type": "integer" + } + } + }, + "admin.listGamePassesResponse": { + "type": "object", + "properties": { + "list": { + "type": "array", + "items": { + "$ref": "#/definitions/admin.gamePassItem" + } + }, + "page": { + "type": "integer" + }, + "page_size": { + "type": "integer" + }, + "total": { + "type": "integer" + } + } + }, "admin.listInventoryResponse": { "type": "object", "properties": { @@ -7519,6 +9273,9 @@ "end_time": { "type": "string" }, + "force": { + "type": "boolean" + }, "gameplay_intro": { "type": "string" }, @@ -7586,6 +9343,35 @@ } } }, + "admin.modifyGamePassPackageRequest": { + "type": "object", + "properties": { + "activity_id": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "original_price": { + "type": "integer" + }, + "pass_count": { + "type": "integer" + }, + "price": { + "type": "integer" + }, + "sort_order": { + "type": "integer" + }, + "status": { + "type": "integer" + }, + "valid_days": { + "type": "integer" + } + } + }, "admin.modifyIssueRequest": { "type": "object", "properties": { @@ -7673,6 +9459,9 @@ "category_id": { "type": "integer" }, + "description": { + "type": "string" + }, "images_json": { "type": "string" }, @@ -7702,9 +9491,6 @@ "min_score": { "type": "integer" }, - "name": { - "type": "string" - }, "original_qty": { "type": "integer" }, @@ -7718,7 +9504,7 @@ "type": "integer" }, "weight": { - "type": "integer" + "type": "number" } } }, @@ -7761,6 +9547,9 @@ "category_id": { "type": "integer" }, + "description": { + "type": "string" + }, "id": { "type": "integer" }, @@ -7799,7 +9588,6 @@ "type": "object", "required": [ "level", - "name", "original_qty", "quantity", "weight" @@ -7817,15 +9605,21 @@ "min_score": { "type": "integer" }, - "name": { - "type": "string" - }, "original_qty": { "type": "integer" }, "product_id": { "type": "integer" }, + "product_image_url": { + "type": "string" + }, + "product_name": { + "type": "string" + }, + "product_price": { + "type": "number" + }, "quantity": { "type": "integer" }, @@ -7833,7 +9627,7 @@ "type": "integer" }, "weight": { - "type": "integer" + "type": "number" } } }, @@ -7880,6 +9674,46 @@ } } }, + "admin.rollbackOrderRequest": { + "type": "object", + "properties": { + "confirm": { + "type": "boolean" + }, + "reason": { + "type": "string" + } + } + }, + "admin.rollbackOrderResponse": { + "type": "object", + "properties": { + "coupons_restored": { + "type": "integer" + }, + "error_msg": { + "type": "string" + }, + "inventory_revoked": { + "type": "integer" + }, + "item_cards_restored": { + "type": "integer" + }, + "points_restored": { + "type": "integer" + }, + "refund_amount": { + "type": "integer" + }, + "rollback_log_id": { + "type": "integer" + }, + "success": { + "type": "boolean" + } + } + }, "admin.simpleMessage": { "type": "object", "properties": { @@ -7998,34 +9832,6 @@ } } }, - "app.CardTypeConfig": { - "type": "object", - "properties": { - "code": { - "type": "string" - }, - "image_url": { - "type": "string" - }, - "name": { - "type": "string" - }, - "quantity": { - "type": "integer" - } - } - }, - "app.MatchingCard": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "type": { - "type": "string" - } - } - }, "app.MatchingRewardInfo": { "type": "object", "properties": { @@ -8035,6 +9841,14 @@ "name": { "type": "string" }, + "product_image": { + "description": "商品图片", + "type": "string" + }, + "product_name": { + "description": "商品原始名称", + "type": "string" + }, "reward_id": { "type": "integer" } @@ -8202,6 +10016,9 @@ }, "share_url": { "type": "string" + }, + "short_link": { + "type": "string" } } }, @@ -8286,6 +10103,25 @@ } } }, + "app.bindDouyinPhoneRequest": { + "type": "object", + "properties": { + "code": { + "type": "string" + } + } + }, + "app.bindDouyinPhoneResponse": { + "type": "object", + "properties": { + "mobile": { + "type": "string" + }, + "success": { + "type": "boolean" + } + } + }, "app.bindPhoneRequest": { "type": "object", "properties": { @@ -8333,13 +10169,24 @@ "app.cancelShippingRequest": { "type": "object", "properties": { + "batch_no": { + "description": "批次号(与inventory_id二选一,取消整批)", + "type": "string" + }, "inventory_id": { + "description": "单个资产ID(与batch_no二选一)", "type": "integer" } } }, "app.cancelShippingResponse": { - "type": "object" + "type": "object", + "properties": { + "cancelled_count": { + "description": "成功取消的数量", + "type": "integer" + } + } }, "app.couponDetail": { "type": "object", @@ -8458,6 +10305,43 @@ } } }, + "app.douyinLoginRequest": { + "type": "object", + "properties": { + "anonymous_code": { + "type": "string" + }, + "channel_code": { + "type": "string" + }, + "code": { + "type": "string" + }, + "invite_code": { + "type": "string" + } + } + }, + "app.douyinLoginResponse": { + "type": "object", + "properties": { + "avatar": { + "type": "string" + }, + "invite_code": { + "type": "string" + }, + "nickname": { + "type": "string" + }, + "token": { + "type": "string" + }, + "user_id": { + "type": "integer" + } + } + }, "app.drawLogGroup": { "type": "object", "properties": { @@ -8481,6 +10365,9 @@ "avatar": { "type": "string" }, + "created_at": { + "type": "string" + }, "current_level": { "type": "integer" }, @@ -8534,6 +10421,9 @@ "name": { "type": "string" }, + "points_required": { + "type": "integer" + }, "price": { "type": "integer" }, @@ -8557,6 +10447,35 @@ } } }, + "app.getGamePassesResponse": { + "type": "object", + "properties": { + "global_remaining": { + "description": "全局通用次数", + "type": "integer" + }, + "passes": { + "type": "array", + "items": { + "$ref": "#/definitions/app.userGamePassItem" + } + }, + "total_remaining": { + "type": "integer" + } + } + }, + "app.getPackagesResponse": { + "type": "object", + "properties": { + "packages": { + "type": "array", + "items": { + "$ref": "#/definitions/app.packageItem" + } + } + } + }, "app.inviteUserItem": { "type": "object", "properties": { @@ -8624,6 +10543,9 @@ "type": "integer" } }, + "use_game_pass": { + "type": "boolean" + }, "use_points": { "type": "integer" } @@ -8632,6 +10554,9 @@ "app.joinLotteryResponse": { "type": "object", "properties": { + "actual_amount": { + "type": "integer" + }, "draw_mode": { "type": "string" }, @@ -8649,6 +10574,9 @@ }, "reward_name": { "type": "string" + }, + "status": { + "type": "integer" } } }, @@ -8771,6 +10699,9 @@ "name": { "type": "string" }, + "points_required": { + "type": "integer" + }, "price": { "type": "integer" }, @@ -8879,7 +10810,7 @@ "list": { "type": "array", "items": { - "$ref": "#/definitions/user.InventoryWithProduct" + "$ref": "#/definitions/user.AggregatedInventory" } }, "page": { @@ -9001,6 +10932,10 @@ "items": { "$ref": "#/definitions/app.rewardItem" } + }, + "play_type": { + "description": "活动类型", + "type": "string" } } }, @@ -9051,6 +10986,9 @@ "name": { "type": "string" }, + "points_required": { + "type": "integer" + }, "price": { "type": "integer" }, @@ -9122,6 +11060,20 @@ } } }, + "app.matchingGameCardsResponse": { + "type": "object", + "properties": { + "all_cards": { + "type": "array", + "items": { + "$ref": "#/definitions/activity.MatchingCard" + } + }, + "game_id": { + "type": "string" + } + } + }, "app.matchingGameCheckRequest": { "type": "object", "required": [ @@ -9168,19 +11120,16 @@ }, "position": { "type": "string" + }, + "use_game_pass": { + "description": "新增:是否使用次数卡", + "type": "boolean" } } }, "app.matchingGamePreOrderResponse": { "type": "object", "properties": { - "all_cards": { - "description": "全量99张卡牌(乱序)", - "type": "array", - "items": { - "$ref": "#/definitions/app.MatchingCard" - } - }, "game_id": { "type": "string" }, @@ -9237,6 +11186,9 @@ "draw_index": { "type": "integer" }, + "image": { + "type": "string" + }, "level": { "type": "integer" }, @@ -9284,6 +11236,32 @@ } } }, + "app.packageItem": { + "type": "object", + "properties": { + "activity_id": { + "type": "integer" + }, + "id": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "original_price": { + "type": "integer" + }, + "pass_count": { + "type": "integer" + }, + "price": { + "type": "integer" + }, + "valid_days": { + "type": "integer" + } + } + }, "app.pointsBalanceResponse": { "type": "object", "properties": { @@ -9292,6 +11270,32 @@ } } }, + "app.purchasePackageRequest": { + "type": "object", + "required": [ + "package_id" + ], + "properties": { + "count": { + "description": "购买数量", + "type": "integer" + }, + "package_id": { + "type": "integer" + } + } + }, + "app.purchasePackageResponse": { + "type": "object", + "properties": { + "message": { + "type": "string" + }, + "order_no": { + "type": "string" + } + } + }, "app.redeemCouponRequest": { "type": "object", "properties": { @@ -9418,6 +11422,9 @@ "address_id": { "type": "integer" }, + "batch_no": { + "type": "string" + }, "failed": { "type": "array", "items": { @@ -9466,24 +11473,41 @@ "result": { "type": "object", "additionalProperties": {} + }, + "results": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": {} + } } } }, "app.rewardItem": { "type": "object", "properties": { + "id": { + "type": "integer" + }, "is_boss": { "type": "integer" }, "level": { "type": "integer" }, + "min_score": { + "type": "integer" + }, "name": { "type": "string" }, "original_qty": { "type": "integer" }, + "prize_level": { + "description": "兼容部分前端逻辑", + "type": "integer" + }, "product_id": { "type": "integer" }, @@ -9501,12 +11525,134 @@ } } }, + "app.sendSmsCodeRequest": { + "type": "object", + "required": [ + "mobile" + ], + "properties": { + "mobile": { + "type": "string" + } + } + }, + "app.sendSmsCodeResponse": { + "type": "object", + "properties": { + "expire_seconds": { + "type": "integer" + }, + "success": { + "type": "boolean" + } + } + }, + "app.smsLoginRequest": { + "type": "object", + "required": [ + "code", + "mobile" + ], + "properties": { + "code": { + "type": "string" + }, + "invite_code": { + "type": "string" + }, + "mobile": { + "type": "string" + } + } + }, + "app.smsLoginResponse": { + "type": "object", + "properties": { + "avatar": { + "type": "string" + }, + "invite_code": { + "type": "string" + }, + "is_new_user": { + "type": "boolean" + }, + "mobile": { + "type": "string" + }, + "nickname": { + "type": "string" + }, + "openid": { + "type": "string" + }, + "token": { + "type": "string" + }, + "user_id": { + "type": "integer" + } + } + }, + "app.updateAddressRequest": { + "type": "object", + "properties": { + "address": { + "type": "string" + }, + "city": { + "type": "string" + }, + "district": { + "type": "string" + }, + "is_default": { + "type": "boolean" + }, + "mobile": { + "type": "string" + }, + "name": { + "type": "string" + }, + "province": { + "type": "string" + } + } + }, + "app.userGamePassItem": { + "type": "object", + "properties": { + "activity_id": { + "type": "integer" + }, + "activity_name": { + "type": "string" + }, + "expired_at": { + "type": "string" + }, + "id": { + "type": "integer" + }, + "remaining": { + "type": "integer" + }, + "source": { + "type": "string" + } + } + }, "app.userItem": { "type": "object", "properties": { "avatar": { "type": "string" }, + "balance": { + "description": "Points", + "type": "integer" + }, "id": { "type": "integer" }, @@ -9516,6 +11662,9 @@ "inviter_id": { "type": "integer" }, + "mobile": { + "type": "string" + }, "nickname": { "type": "string" } @@ -9585,6 +11734,178 @@ } } }, + "game.consumeTicketRequest": { + "type": "object", + "properties": { + "game_code": { + "type": "string" + }, + "ticket": { + "type": "string" + }, + "user_id": { + "type": "string" + } + } + }, + "game.consumeTicketResponse": { + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "success": { + "type": "boolean" + } + } + }, + "game.enterGameRequest": { + "type": "object", + "required": [ + "game_code" + ], + "properties": { + "game_code": { + "type": "string" + } + } + }, + "game.enterGameResponse": { + "type": "object", + "properties": { + "client_url": { + "type": "string" + }, + "expires_at": { + "type": "string" + }, + "game_token": { + "type": "string" + }, + "nakama_key": { + "type": "string" + }, + "nakama_server": { + "type": "string" + }, + "remaining_times": { + "type": "integer" + } + } + }, + "game.grantTicketRequest": { + "type": "object", + "required": [ + "amount", + "game_code" + ], + "properties": { + "amount": { + "type": "integer", + "minimum": 1 + }, + "game_code": { + "type": "string" + }, + "remark": { + "type": "string" + } + } + }, + "game.settleRequest": { + "type": "object", + "properties": { + "match_id": { + "type": "string" + }, + "score": { + "type": "integer" + }, + "ticket": { + "type": "string" + }, + "user_id": { + "type": "string" + }, + "win": { + "type": "boolean" + } + } + }, + "game.settleResponse": { + "type": "object", + "properties": { + "reward": { + "type": "string" + }, + "success": { + "type": "boolean" + } + } + }, + "game.validateTokenRequest": { + "type": "object", + "required": [ + "game_token" + ], + "properties": { + "game_token": { + "type": "string" + } + } + }, + "game.validateTokenResponse": { + "type": "object", + "properties": { + "avatar": { + "type": "string" + }, + "error": { + "type": "string" + }, + "game_type": { + "type": "string" + }, + "ticket": { + "type": "string" + }, + "user_id": { + "type": "integer" + }, + "username": { + "type": "string" + }, + "valid": { + "type": "boolean" + } + } + }, + "game.verifyRequest": { + "type": "object", + "properties": { + "ticket": { + "type": "string" + }, + "user_id": { + "type": "string" + } + } + }, + "game.verifyResponse": { + "type": "object", + "properties": { + "game_config": { + "type": "object", + "additionalProperties": {} + }, + "user_id": { + "type": "string" + }, + "valid": { + "type": "boolean" + } + } + }, "minesweeper.SettleGameRequest": { "type": "object", "properties": { @@ -9936,6 +12257,126 @@ } } }, + "snapshot.CouponInfo": { + "type": "object", + "properties": { + "balance_amount": { + "type": "integer" + }, + "coupon_id": { + "type": "integer" + }, + "coupon_name": { + "type": "string" + }, + "status": { + "type": "integer" + }, + "user_coupon_id": { + "type": "integer" + }, + "valid_end": { + "type": "string" + } + } + }, + "snapshot.ItemCardInfo": { + "type": "object", + "properties": { + "card_id": { + "type": "integer" + }, + "card_name": { + "type": "string" + }, + "status": { + "type": "integer" + }, + "user_item_card_id": { + "type": "integer" + }, + "valid_end": { + "type": "string" + } + } + }, + "snapshot.PointsInfo": { + "type": "object", + "properties": { + "balance": { + "type": "integer" + }, + "version": { + "type": "integer" + } + } + }, + "snapshot.SnapshotDiff": { + "type": "object", + "properties": { + "coupons_used": { + "type": "array", + "items": { + "$ref": "#/definitions/snapshot.CouponInfo" + } + }, + "inventory_added": { + "type": "integer" + }, + "item_cards_used": { + "type": "array", + "items": { + "$ref": "#/definitions/snapshot.ItemCardInfo" + } + }, + "points_changed": { + "type": "integer" + } + } + }, + "snapshot.UserInfo": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "mobile": { + "type": "string" + }, + "nickname": { + "type": "string" + } + } + }, + "snapshot.UserStateSnapshot": { + "type": "object", + "properties": { + "coupons": { + "type": "array", + "items": { + "$ref": "#/definitions/snapshot.CouponInfo" + } + }, + "inventory_count": { + "type": "integer" + }, + "item_cards": { + "type": "array", + "items": { + "$ref": "#/definitions/snapshot.ItemCardInfo" + } + }, + "points": { + "$ref": "#/definitions/snapshot.PointsInfo" + }, + "snapshot_time": { + "type": "string" + }, + "user": { + "$ref": "#/definitions/snapshot.UserInfo" + } + } + }, "taskcenter.claimTaskRequest": { "type": "object", "properties": { @@ -9950,9 +12391,15 @@ "description": { "type": "string" }, + "end_time": { + "type": "string" + }, "name": { "type": "string" }, + "start_time": { + "type": "string" + }, "status": { "type": "integer" }, @@ -9987,9 +12434,15 @@ "description": { "type": "string" }, + "end_time": { + "type": "string" + }, "name": { "type": "string" }, + "start_time": { + "type": "string" + }, "status": { "type": "integer" }, @@ -10070,6 +12523,9 @@ "invite_count": { "type": "integer" }, + "order_amount": { + "type": "integer" + }, "order_count": { "type": "integer" }, @@ -10090,6 +12546,9 @@ "quantity": { "type": "integer" }, + "reward_name": { + "type": "string" + }, "reward_payload": { "type": "object", "additionalProperties": {} @@ -10164,6 +12623,15 @@ "items": { "type": "object", "properties": { + "activity_id": { + "type": "integer" + }, + "extra_params": { + "type": "array", + "items": { + "type": "integer" + } + }, "metric": { "type": "string" }, @@ -10187,6 +12655,63 @@ } } }, + "user.AggregatedInventory": { + "type": "object", + "properties": { + "count": { + "type": "integer" + }, + "has_shipment": { + "type": "boolean" + }, + "inventory_ids": { + "type": "array", + "items": { + "type": "integer" + } + }, + "product_id": { + "type": "integer" + }, + "product_images": { + "type": "string" + }, + "product_name": { + "type": "string" + }, + "product_price": { + "type": "integer" + }, + "shipping_status": { + "type": "integer" + }, + "status": { + "description": "用于区分 1持有 3已处理", + "type": "integer" + }, + "updated_at": { + "type": "string" + } + } + }, + "user.CouponSimpleInfo": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "type": { + "description": "1:满减 2:折扣", + "type": "integer" + }, + "user_coupon_id": { + "type": "integer" + }, + "value": { + "type": "integer" + } + } + }, "user.DrawReceiptInfo": { "type": "object", "properties": { @@ -10278,6 +12803,10 @@ "product_name": { "type": "string" }, + "product_price": { + "description": "新增价格字段", + "type": "integer" + }, "remark": { "description": "备注", "type": "string" @@ -10286,6 +12815,10 @@ "description": "来源奖励ID(activity_reward_settings.id)", "type": "integer" }, + "shipping_no": { + "description": "发货单号", + "type": "string" + }, "shipping_status": { "type": "integer" }, @@ -10303,6 +12836,20 @@ } } }, + "user.ItemCardSimpleInfo": { + "type": "object", + "properties": { + "effect_type": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "user_card_id": { + "type": "integer" + } + } + }, "user.ItemCardWithTemplate": { "type": "object", "properties": { @@ -10313,6 +12860,9 @@ "card_type": { "type": "integer" }, + "count": { + "type": "integer" + }, "created_at": { "description": "创建时间", "type": "string" @@ -10348,6 +12898,9 @@ "description": "使用时活动ID", "type": "integer" }, + "used_activity_name": { + "type": "string" + }, "used_at": { "description": "使用时间", "type": "string" @@ -10360,6 +12913,12 @@ "description": "使用时期ID", "type": "integer" }, + "used_issue_number": { + "type": "string" + }, + "used_reward_name": { + "type": "string" + }, "user_id": { "description": "用户ID(users.id)", "type": "integer" @@ -10397,6 +12956,13 @@ "category_name": { "type": "string" }, + "coupon_id": { + "description": "使用的优惠券ID", + "type": "integer" + }, + "coupon_info": { + "$ref": "#/definitions/user.CouponSimpleInfo" + }, "created_at": { "description": "创建时间", "type": "string" @@ -10428,6 +12994,13 @@ "issue_number": { "type": "string" }, + "item_card_id": { + "description": "使用的道具卡ID", + "type": "integer" + }, + "item_card_info": { + "$ref": "#/definitions/user.ItemCardSimpleInfo" + }, "items": { "type": "array", "items": { diff --git a/docs/swagger.yaml b/docs/swagger.yaml index e190294..e758f58 100644 --- a/docs/swagger.yaml +++ b/docs/swagger.yaml @@ -1,5 +1,68 @@ basePath: / definitions: + activity.CardTypeConfig: + properties: + code: + type: string + image_url: + type: string + name: + type: string + quantity: + type: integer + type: object + activity.MatchingCard: + properties: + id: + type: string + type: + type: string + type: object + activity.MatchingGame: + properties: + activity_id: + type: integer + board: + items: + $ref: '#/definitions/activity.MatchingCard' + type: array + card_configs: + items: + $ref: '#/definitions/activity.CardTypeConfig' + type: array + card_id_counter: + type: integer + created_at: + description: 游戏创建时间,用于自动开奖超时判断 + type: string + deck: + items: + $ref: '#/definitions/activity.MatchingCard' + type: array + issue_id: + type: integer + last_activity: + type: string + max_possible_pairs: + type: integer + nonce: + type: integer + order_id: + type: integer + position: + description: 用户选择的类型,用于服务端验证 + type: string + server_seed: + items: + type: integer + type: array + server_seed_hash: + type: string + total_pairs: + type: integer + user_id: + type: integer + type: object admin.GrantRewardRequest: properties: activity_id: @@ -8,6 +71,9 @@ definitions: address_id: description: 收货地址ID(可选,实物商品需要) type: integer + points_amount: + description: 消耗积分 + type: integer product_id: description: 商品ID type: integer @@ -108,6 +174,78 @@ definitions: description: 用户昵称 type: string type: object + admin.UserProfileResponse: + properties: + avatar: + type: string + channel_id: + type: integer + created_at: + type: string + current_assets: + description: 当前资产快照 + properties: + coupon_count: + description: 持有优惠券数 + type: integer + coupon_value: + description: 持有优惠券价值 + type: integer + inventory_count: + description: 持有商品数 + type: integer + inventory_value: + description: 持有商品价值 + type: integer + item_card_count: + description: 持有道具卡数 + type: integer + item_card_value: + description: 持有道具卡价值 + type: integer + points_balance: + description: 积分余额 + type: integer + profit_loss_ratio: + description: 累计盈亏比 + type: number + total_asset_value: + description: 总资产估值 + type: integer + type: object + douyin_id: + type: string + id: + description: 基本信息 + type: integer + invite_code: + type: string + invite_count: + description: 邀请统计 + type: integer + inviter_id: + type: integer + lifetime_stats: + description: 生命周期财务指标 + properties: + net_cash_cost: + description: 净现金支出 + type: integer + order_count: + description: 订单数 + type: integer + total_paid: + description: 累计支付 + type: integer + total_refunded: + description: 累计退款 + type: integer + type: object + mobile: + type: string + nickname: + type: string + type: object admin.activityDetailResponse: properties: activity_category_id: @@ -206,13 +344,12 @@ definitions: kind: type: string points: + description: 正数=增加,负数=扣减 type: integer remark: type: string valid_days: type: integer - required: - - points type: object admin.addPointsResponse: properties: @@ -272,6 +409,8 @@ definitions: type: integer nickname: type: string + points_balance: + type: integer seven_day_consume: type: integer today_consume: @@ -307,7 +446,7 @@ definitions: type: integer weight: minimum: 0 - type: integer + type: number required: - reward_id type: object @@ -347,6 +486,19 @@ definitions: name: type: string type: object + admin.checkActivityGamePassesResponse: + properties: + activity_id: + type: integer + can_delete: + type: boolean + message: + type: string + total_remaining: + type: integer + user_count: + type: integer + type: object admin.copyActivityResponse: properties: new_activity_id: @@ -425,6 +577,39 @@ definitions: message: type: string type: object + admin.createGamePassPackageRequest: + properties: + activity_id: + type: integer + name: + type: string + original_price: + type: integer + pass_count: + minimum: 1 + type: integer + price: + minimum: 0 + type: integer + sort_order: + type: integer + status: + description: 1=上架 2=下架 + type: integer + valid_days: + type: integer + required: + - name + - pass_count + - price + type: object + admin.createGamePassPackageResponse: + properties: + id: + type: integer + message: + type: string + type: object admin.createIssueRequest: properties: issue_number: @@ -506,6 +691,8 @@ definitions: properties: category_id: type: integer + description: + type: string images_json: type: string name: @@ -538,6 +725,146 @@ definitions: required: - rewards type: object + admin.createUserRequest: + properties: + avatar: + type: string + mobile: + type: string + nickname: + type: string + password: + type: string + username: + type: string + required: + - password + - username + type: object + admin.createUserResponse: + properties: + message: + type: string + success: + type: boolean + type: object + admin.gamePassItem: + properties: + activity_id: + type: integer + activity_name: + type: string + created_at: + type: string + expired_at: + type: string + id: + type: integer + remaining: + type: integer + remark: + type: string + source: + type: string + total_granted: + type: integer + total_used: + type: integer + user_id: + type: integer + user_name: + type: string + type: object + admin.gamePassPackageItem: + properties: + activity_id: + type: integer + activity_name: + type: string + created_at: + type: string + id: + type: integer + name: + type: string + original_price: + type: integer + pass_count: + type: integer + price: + type: integer + sort_order: + type: integer + status: + type: integer + valid_days: + type: integer + type: object + admin.getOrderSnapshotsResponse: + properties: + after_snapshot: + $ref: '#/definitions/snapshot.UserStateSnapshot' + before_snapshot: + $ref: '#/definitions/snapshot.UserStateSnapshot' + diff: + $ref: '#/definitions/snapshot.SnapshotDiff' + has_snapshots: + type: boolean + order: + properties: + actual_amount: + type: integer + id: + type: integer + order_no: + type: string + paid_at: + type: string + status: + type: integer + user_id: + type: integer + type: object + type: object + admin.getUserGamePassesResponse: + properties: + activity_passes: + items: + $ref: '#/definitions/admin.gamePassItem' + type: array + global_remaining: + type: integer + total_remaining: + type: integer + user_id: + type: integer + type: object + admin.grantGamePassRequest: + properties: + activity_id: + description: 可选,NULL表示全局通用 + type: integer + count: + minimum: 1 + type: integer + remark: + type: string + user_id: + type: integer + valid_days: + description: 可选,NULL表示永久有效 + type: integer + required: + - count + - user_id + type: object + admin.grantGamePassResponse: + properties: + id: + type: integer + message: + type: string + type: object admin.issueUserTokenResponse: properties: expires_in: @@ -632,6 +959,32 @@ definitions: total: type: integer type: object + admin.listGamePassPackagesResponse: + properties: + list: + items: + $ref: '#/definitions/admin.gamePassPackageItem' + type: array + page: + type: integer + page_size: + type: integer + total: + type: integer + type: object + admin.listGamePassesResponse: + properties: + list: + items: + $ref: '#/definitions/admin.gamePassItem' + type: array + page: + type: integer + page_size: + type: integer + total: + type: integer + type: object admin.listInventoryResponse: properties: list: @@ -863,6 +1216,8 @@ definitions: type: string end_time: type: string + force: + type: boolean gameplay_intro: type: string image: @@ -907,6 +1262,25 @@ definitions: title: type: string type: object + admin.modifyGamePassPackageRequest: + properties: + activity_id: + type: integer + name: + type: string + original_price: + type: integer + pass_count: + type: integer + price: + type: integer + sort_order: + type: integer + status: + type: integer + valid_days: + type: integer + type: object admin.modifyIssueRequest: properties: issue_number: @@ -964,6 +1338,8 @@ definitions: properties: category_id: type: integer + description: + type: string images_json: type: string name: @@ -983,8 +1359,6 @@ definitions: type: integer min_score: type: integer - name: - type: string original_qty: type: integer product_id: @@ -994,7 +1368,7 @@ definitions: sort: type: integer weight: - type: integer + type: number type: object admin.pcSimpleMessage: properties: @@ -1021,6 +1395,8 @@ definitions: properties: category_id: type: integer + description: + type: string id: type: integer images_json: @@ -1053,21 +1429,24 @@ definitions: type: integer min_score: type: integer - name: - type: string original_qty: type: integer product_id: type: integer + product_image_url: + type: string + product_name: + type: string + product_price: + type: number quantity: type: integer sort: type: integer weight: - type: integer + type: number required: - level - - name - original_qty - quantity - weight @@ -1100,6 +1479,32 @@ definitions: total: type: integer type: object + admin.rollbackOrderRequest: + properties: + confirm: + type: boolean + reason: + type: string + type: object + admin.rollbackOrderResponse: + properties: + coupons_restored: + type: integer + error_msg: + type: string + inventory_revoked: + type: integer + item_cards_restored: + type: integer + points_restored: + type: integer + refund_amount: + type: integer + rollback_log_id: + type: integer + success: + type: boolean + type: object admin.simpleMessage: properties: message: @@ -1178,30 +1583,18 @@ definitions: total: type: integer type: object - app.CardTypeConfig: - properties: - code: - type: string - image_url: - type: string - name: - type: string - quantity: - type: integer - type: object - app.MatchingCard: - properties: - id: - type: string - type: - type: string - type: object app.MatchingRewardInfo: properties: level: type: integer name: type: string + product_image: + description: 商品图片 + type: string + product_name: + description: 商品原始名称 + type: string reward_id: type: integer type: object @@ -1312,6 +1705,8 @@ definitions: type: string share_url: type: string + short_link: + type: string type: object app.addressShareRevokeRequest: properties: @@ -1365,6 +1760,18 @@ definitions: content: type: string type: object + app.bindDouyinPhoneRequest: + properties: + code: + type: string + type: object + app.bindDouyinPhoneResponse: + properties: + mobile: + type: string + success: + type: boolean + type: object app.bindPhoneRequest: properties: code: @@ -1395,10 +1802,18 @@ definitions: type: object app.cancelShippingRequest: properties: + batch_no: + description: 批次号(与inventory_id二选一,取消整批) + type: string inventory_id: + description: 单个资产ID(与batch_no二选一) type: integer type: object app.cancelShippingResponse: + properties: + cancelled_count: + description: 成功取消的数量 + type: integer type: object app.couponDetail: properties: @@ -1481,6 +1896,30 @@ definitions: description: 订单号 type: string type: object + app.douyinLoginRequest: + properties: + anonymous_code: + type: string + channel_code: + type: string + code: + type: string + invite_code: + type: string + type: object + app.douyinLoginResponse: + properties: + avatar: + type: string + invite_code: + type: string + nickname: + type: string + token: + type: string + user_id: + type: integer + type: object app.drawLogGroup: properties: level: @@ -1496,6 +1935,8 @@ definitions: properties: avatar: type: string + created_at: + type: string current_level: type: integer id: @@ -1531,6 +1972,8 @@ definitions: type: integer name: type: string + points_required: + type: integer price: type: integer recommendations: @@ -1546,6 +1989,25 @@ definitions: stock: type: integer type: object + app.getGamePassesResponse: + properties: + global_remaining: + description: 全局通用次数 + type: integer + passes: + items: + $ref: '#/definitions/app.userGamePassItem' + type: array + total_remaining: + type: integer + type: object + app.getPackagesResponse: + properties: + packages: + items: + $ref: '#/definitions/app.packageItem' + type: array + type: object app.inviteUserItem: properties: avatar: @@ -1590,11 +2052,15 @@ definitions: items: type: integer type: array + use_game_pass: + type: boolean use_points: type: integer type: object app.joinLotteryResponse: properties: + actual_amount: + type: integer draw_mode: type: string join_id: @@ -1607,6 +2073,8 @@ definitions: type: integer reward_name: type: string + status: + type: integer type: object app.jsapiPreorderRequest: properties: @@ -1685,6 +2153,8 @@ definitions: type: string name: type: string + points_required: + type: integer price: type: integer sales: @@ -1755,7 +2225,7 @@ definitions: properties: list: items: - $ref: '#/definitions/user.InventoryWithProduct' + $ref: '#/definitions/user.AggregatedInventory' type: array page: type: integer @@ -1835,6 +2305,9 @@ definitions: items: $ref: '#/definitions/app.rewardItem' type: array + play_type: + description: 活动类型 + type: string type: object app.listShipmentsResponse: properties: @@ -1867,6 +2340,8 @@ definitions: type: integer name: type: string + points_required: + type: integer price: type: integer status: @@ -1913,6 +2388,15 @@ definitions: total: type: integer type: object + app.matchingGameCardsResponse: + properties: + all_cards: + items: + $ref: '#/definitions/activity.MatchingCard' + type: array + game_id: + type: string + type: object app.matchingGameCheckRequest: properties: game_id: @@ -1944,14 +2428,12 @@ definitions: type: integer position: type: string + use_game_pass: + description: 新增:是否使用次数卡 + type: boolean type: object app.matchingGamePreOrderResponse: properties: - all_cards: - description: 全量99张卡牌(乱序) - items: - $ref: '#/definitions/app.MatchingCard' - type: array game_id: type: string order_no: @@ -1988,6 +2470,8 @@ definitions: properties: draw_index: type: integer + image: + type: string level: type: integer reward_id: @@ -2019,11 +2503,45 @@ definitions: status: type: string type: object + app.packageItem: + properties: + activity_id: + type: integer + id: + type: integer + name: + type: string + original_price: + type: integer + pass_count: + type: integer + price: + type: integer + valid_days: + type: integer + type: object app.pointsBalanceResponse: properties: balance: type: integer type: object + app.purchasePackageRequest: + properties: + count: + description: 购买数量 + type: integer + package_id: + type: integer + required: + - package_id + type: object + app.purchasePackageResponse: + properties: + message: + type: string + order_no: + type: string + type: object app.redeemCouponRequest: properties: coupon_id: @@ -2105,6 +2623,8 @@ definitions: properties: address_id: type: integer + batch_no: + type: string failed: items: additionalProperties: {} @@ -2138,17 +2658,29 @@ definitions: result: additionalProperties: {} type: object + results: + items: + additionalProperties: {} + type: object + type: array type: object app.rewardItem: properties: + id: + type: integer is_boss: type: integer level: type: integer + min_score: + type: integer name: type: string original_qty: type: integer + prize_level: + description: 兼容部分前端逻辑 + type: integer product_id: type: integer product_image: @@ -2160,16 +2692,98 @@ definitions: weight: type: integer type: object + app.sendSmsCodeRequest: + properties: + mobile: + type: string + required: + - mobile + type: object + app.sendSmsCodeResponse: + properties: + expire_seconds: + type: integer + success: + type: boolean + type: object + app.smsLoginRequest: + properties: + code: + type: string + invite_code: + type: string + mobile: + type: string + required: + - code + - mobile + type: object + app.smsLoginResponse: + properties: + avatar: + type: string + invite_code: + type: string + is_new_user: + type: boolean + mobile: + type: string + nickname: + type: string + openid: + type: string + token: + type: string + user_id: + type: integer + type: object + app.updateAddressRequest: + properties: + address: + type: string + city: + type: string + district: + type: string + is_default: + type: boolean + mobile: + type: string + name: + type: string + province: + type: string + type: object + app.userGamePassItem: + properties: + activity_id: + type: integer + activity_name: + type: string + expired_at: + type: string + id: + type: integer + remaining: + type: integer + source: + type: string + type: object app.userItem: properties: avatar: type: string + balance: + description: Points + type: integer id: type: integer invite_code: type: string inviter_id: type: integer + mobile: + type: string nickname: type: string type: object @@ -2215,6 +2829,118 @@ definitions: description: 描述信息 type: string type: object + game.consumeTicketRequest: + properties: + game_code: + type: string + ticket: + type: string + user_id: + type: string + type: object + game.consumeTicketResponse: + properties: + error: + type: string + success: + type: boolean + type: object + game.enterGameRequest: + properties: + game_code: + type: string + required: + - game_code + type: object + game.enterGameResponse: + properties: + client_url: + type: string + expires_at: + type: string + game_token: + type: string + nakama_key: + type: string + nakama_server: + type: string + remaining_times: + type: integer + type: object + game.grantTicketRequest: + properties: + amount: + minimum: 1 + type: integer + game_code: + type: string + remark: + type: string + required: + - amount + - game_code + type: object + game.settleRequest: + properties: + match_id: + type: string + score: + type: integer + ticket: + type: string + user_id: + type: string + win: + type: boolean + type: object + game.settleResponse: + properties: + reward: + type: string + success: + type: boolean + type: object + game.validateTokenRequest: + properties: + game_token: + type: string + required: + - game_token + type: object + game.validateTokenResponse: + properties: + avatar: + type: string + error: + type: string + game_type: + type: string + ticket: + type: string + user_id: + type: integer + username: + type: string + valid: + type: boolean + type: object + game.verifyRequest: + properties: + ticket: + type: string + user_id: + type: string + type: object + game.verifyResponse: + properties: + game_config: + additionalProperties: {} + type: object + user_id: + type: string + valid: + type: boolean + type: object minesweeper.SettleGameRequest: properties: match_id: @@ -2467,6 +3193,84 @@ definitions: message: type: string type: object + snapshot.CouponInfo: + properties: + balance_amount: + type: integer + coupon_id: + type: integer + coupon_name: + type: string + status: + type: integer + user_coupon_id: + type: integer + valid_end: + type: string + type: object + snapshot.ItemCardInfo: + properties: + card_id: + type: integer + card_name: + type: string + status: + type: integer + user_item_card_id: + type: integer + valid_end: + type: string + type: object + snapshot.PointsInfo: + properties: + balance: + type: integer + version: + type: integer + type: object + snapshot.SnapshotDiff: + properties: + coupons_used: + items: + $ref: '#/definitions/snapshot.CouponInfo' + type: array + inventory_added: + type: integer + item_cards_used: + items: + $ref: '#/definitions/snapshot.ItemCardInfo' + type: array + points_changed: + type: integer + type: object + snapshot.UserInfo: + properties: + id: + type: integer + mobile: + type: string + nickname: + type: string + type: object + snapshot.UserStateSnapshot: + properties: + coupons: + items: + $ref: '#/definitions/snapshot.CouponInfo' + type: array + inventory_count: + type: integer + item_cards: + items: + $ref: '#/definitions/snapshot.ItemCardInfo' + type: array + points: + $ref: '#/definitions/snapshot.PointsInfo' + snapshot_time: + type: string + user: + $ref: '#/definitions/snapshot.UserInfo' + type: object taskcenter.claimTaskRequest: properties: tier_id: @@ -2476,8 +3280,12 @@ definitions: properties: description: type: string + end_time: + type: string name: type: string + start_time: + type: string status: type: integer visibility: @@ -2500,8 +3308,12 @@ definitions: properties: description: type: string + end_time: + type: string name: type: string + start_time: + type: string status: type: integer visibility: @@ -2554,6 +3366,8 @@ definitions: type: boolean invite_count: type: integer + order_amount: + type: integer order_count: type: integer task_id: @@ -2567,6 +3381,8 @@ definitions: type: integer quantity: type: integer + reward_name: + type: string reward_payload: additionalProperties: {} type: object @@ -2615,6 +3431,12 @@ definitions: tiers: items: properties: + activity_id: + type: integer + extra_params: + items: + type: integer + type: array metric: type: string operator: @@ -2630,6 +3452,44 @@ definitions: type: object type: array type: object + user.AggregatedInventory: + properties: + count: + type: integer + has_shipment: + type: boolean + inventory_ids: + items: + type: integer + type: array + product_id: + type: integer + product_images: + type: string + product_name: + type: string + product_price: + type: integer + shipping_status: + type: integer + status: + description: 用于区分 1持有 3已处理 + type: integer + updated_at: + type: string + type: object + user.CouponSimpleInfo: + properties: + name: + type: string + type: + description: 1:满减 2:折扣 + type: integer + user_coupon_id: + type: integer + value: + type: integer + type: object user.DrawReceiptInfo: properties: algo_version: @@ -2692,12 +3552,18 @@ definitions: type: string product_name: type: string + product_price: + description: 新增价格字段 + type: integer remark: description: 备注 type: string reward_id: description: 来源奖励ID(activity_reward_settings.id) type: integer + shipping_no: + description: 发货单号 + type: string shipping_status: type: integer status: @@ -2710,6 +3576,15 @@ definitions: description: 资产归属用户ID type: integer type: object + user.ItemCardSimpleInfo: + properties: + effect_type: + type: integer + name: + type: string + user_card_id: + type: integer + type: object user.ItemCardWithTemplate: properties: card_id: @@ -2717,6 +3592,8 @@ definitions: type: integer card_type: type: integer + count: + type: integer created_at: description: 创建时间 type: string @@ -2742,6 +3619,8 @@ definitions: used_activity_id: description: 使用时活动ID type: integer + used_activity_name: + type: string used_at: description: 使用时间 type: string @@ -2751,6 +3630,10 @@ definitions: used_issue_id: description: 使用时期ID type: integer + used_issue_number: + type: string + used_reward_name: + type: string user_id: description: 用户ID(users.id) type: integer @@ -2777,6 +3660,11 @@ definitions: type: integer category_name: type: string + coupon_id: + description: 使用的优惠券ID + type: integer + coupon_info: + $ref: '#/definitions/user.CouponSimpleInfo' created_at: description: 创建时间 type: string @@ -2799,6 +3687,11 @@ definitions: type: boolean issue_number: type: string + item_card_id: + description: 使用的道具卡ID + type: integer + item_card_info: + $ref: '#/definitions/user.ItemCardSimpleInfo' items: items: $ref: '#/definitions/model.OrderItems' @@ -3309,6 +4202,33 @@ paths: summary: 复制活动 tags: - 管理端.活动 + /api/admin/activities/{activity_id}/game-passes/check: + get: + consumes: + - application/json + description: 检查指定活动是否有用户持有未使用的次数卡,用于下架/删除前校验 + parameters: + - description: 活动ID + in: path + name: activity_id + required: true + type: integer + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/admin.checkActivityGamePassesResponse' + "400": + description: Bad Request + schema: + $ref: '#/definitions/code.Failure' + security: + - LoginVerifyToken: [] + summary: 检查活动次数卡 + tags: + - 管理端.次数卡 /api/admin/activities/{activity_id}/issues: get: consumes: @@ -3793,6 +4713,196 @@ paths: summary: 修改轮播图 tags: - 管理端.运营 + /api/admin/game-pass-packages: + get: + consumes: + - application/json + description: 查询次数卡套餐列表 + parameters: + - description: 活动ID + in: query + name: activity_id + type: integer + - description: '状态: 1=上架 2=下架' + in: query + name: status + type: integer + - description: 页码 + in: query + name: page + type: integer + - description: 每页条数 + in: query + name: page_size + type: integer + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/admin.listGamePassPackagesResponse' + "400": + description: Bad Request + schema: + $ref: '#/definitions/code.Failure' + security: + - LoginVerifyToken: [] + summary: 次数卡套餐列表 + tags: + - 管理端.次数卡套餐 + post: + consumes: + - application/json + description: 创建可购买的次数卡套餐 + parameters: + - description: 请求参数 + in: body + name: RequestBody + required: true + schema: + $ref: '#/definitions/admin.createGamePassPackageRequest' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/admin.createGamePassPackageResponse' + "400": + description: Bad Request + schema: + $ref: '#/definitions/code.Failure' + security: + - LoginVerifyToken: [] + summary: 创建次数卡套餐 + tags: + - 管理端.次数卡套餐 + /api/admin/game-pass-packages/{package_id}: + delete: + consumes: + - application/json + description: 软删除次数卡套餐 + parameters: + - description: 套餐ID + in: path + name: package_id + required: true + type: integer + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/admin.simpleMessageResponse' + "400": + description: Bad Request + schema: + $ref: '#/definitions/code.Failure' + security: + - LoginVerifyToken: [] + summary: 删除次数卡套餐 + tags: + - 管理端.次数卡套餐 + put: + consumes: + - application/json + description: 修改次数卡套餐配置 + parameters: + - description: 套餐ID + in: path + name: package_id + required: true + type: integer + - description: 请求参数 + in: body + name: RequestBody + required: true + schema: + $ref: '#/definitions/admin.modifyGamePassPackageRequest' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/admin.simpleMessageResponse' + "400": + description: Bad Request + schema: + $ref: '#/definitions/code.Failure' + security: + - LoginVerifyToken: [] + summary: 修改次数卡套餐 + tags: + - 管理端.次数卡套餐 + /api/admin/game-passes/grant: + post: + consumes: + - application/json + description: 管理员为用户发放游戏次数卡 + parameters: + - description: 请求参数 + in: body + name: RequestBody + required: true + schema: + $ref: '#/definitions/admin.grantGamePassRequest' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/admin.grantGamePassResponse' + "400": + description: Bad Request + schema: + $ref: '#/definitions/code.Failure' + security: + - LoginVerifyToken: [] + summary: 发放次数卡 + tags: + - 管理端.次数卡 + /api/admin/game-passes/list: + get: + consumes: + - application/json + description: 查询用户次数卡列表,支持按用户、活动过滤 + parameters: + - description: 用户ID + in: query + name: user_id + type: integer + - description: 活动ID + in: query + name: activity_id + type: integer + - description: 页码 + in: query + name: page + type: integer + - description: 每页条数 + in: query + name: page_size + type: integer + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/admin.listGamePassesResponse' + "400": + description: Bad Request + schema: + $ref: '#/definitions/code.Failure' + security: + - LoginVerifyToken: [] + summary: 次数卡列表 + tags: + - 管理端.次数卡 /api/admin/login: post: consumes: @@ -3819,6 +4929,33 @@ paths: summary: 管理员登录 tags: - 管理端.登录 + /api/admin/matching/audit/{order_no}: + get: + consumes: + - application/json + description: 运营通过单号查询发牌过程 + parameters: + - description: 订单号 + in: path + name: order_no + required: true + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/activity.MatchingGame' + "400": + description: Bad Request + schema: + $ref: '#/definitions/code.Failure' + security: + - AdminAuth: [] + summary: 获取对对碰审计数据 + tags: + - 管理端.对对碰 /api/admin/matching_card_types: get: consumes: @@ -3927,6 +5064,54 @@ paths: summary: 修改卡牌类型 tags: - 管理端.对对碰 + /api/admin/orders/{order_id}/rollback: + post: + consumes: + - application/json + description: 基于快照将用户数据回滚到消费前状态,包括恢复积分、优惠券、道具卡,作废资产,调用微信退款 + parameters: + - description: 订单ID + in: path + name: order_id + required: true + type: integer + - description: 回滚请求 + in: body + name: request + required: true + schema: + $ref: '#/definitions/admin.rollbackOrderRequest' + produces: + - application/json + responses: + "200": + description: 成功 + schema: + $ref: '#/definitions/admin.rollbackOrderResponse' + summary: 回滚订单到消费前状态 + tags: + - Admin-Audit + /api/admin/orders/{order_id}/snapshots: + get: + consumes: + - application/json + description: 获取订单消费前后的完整用户状态快照 + parameters: + - description: 订单ID + in: path + name: order_id + required: true + type: integer + produces: + - application/json + responses: + "200": + description: 成功 + schema: + $ref: '#/definitions/admin.getOrderSnapshotsResponse' + summary: 获取订单审计快照 + tags: + - Admin-Audit /api/admin/product_categories: get: consumes: @@ -4605,6 +5790,80 @@ paths: summary: 给用户添加优惠券 tags: - 管理端.用户 + /api/admin/users/{user_id}/game-passes: + get: + consumes: + - application/json + description: 查询指定用户的所有次数卡 + parameters: + - description: 用户ID + in: path + name: user_id + required: true + type: integer + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/admin.getUserGamePassesResponse' + "400": + description: Bad Request + schema: + $ref: '#/definitions/code.Failure' + security: + - LoginVerifyToken: [] + summary: 查询用户次数卡 + tags: + - 管理端.次数卡 + /api/admin/users/{user_id}/game_tickets: + get: + parameters: + - description: 用户ID + in: path + name: user_id + required: true + type: integer + - description: 页码 + in: query + name: page + type: integer + - description: 每页数量 + in: query + name: page_size + type: integer + responses: + "200": + description: OK + schema: + additionalProperties: true + type: object + summary: 查询用户游戏资格日志 + tags: + - 管理端.游戏 + post: + parameters: + - description: 用户ID + in: path + name: user_id + required: true + type: integer + - description: 请求参数 + in: body + name: RequestBody + required: true + schema: + $ref: '#/definitions/game.grantTicketRequest' + responses: + "200": + description: OK + schema: + additionalProperties: true + type: object + summary: 发放游戏资格 + tags: + - 管理端.游戏 /api/admin/users/{user_id}/inventory: get: consumes: @@ -4846,7 +6105,7 @@ paths: post: consumes: - application/json - description: 管理端为指定用户发放积分,支持设置有效期 + description: 管理端为指定用户发放或扣减积分,正数为增加,负数为扣减 parameters: - description: 用户ID in: path @@ -4872,7 +6131,7 @@ paths: $ref: '#/definitions/code.Failure' security: - LoginVerifyToken: [] - summary: 给用户添加积分 + summary: 给用户增加或扣减积分 tags: - 管理端.用户 /api/admin/users/{user_id}/points/balance: @@ -4902,6 +6161,33 @@ paths: summary: 查看用户积分余额 tags: - 管理端.用户 + /api/admin/users/{user_id}/profile: + get: + consumes: + - application/json + description: 聚合用户基本信息、生命周期财务指标、当前资产快照 + parameters: + - description: 用户ID + in: path + name: user_id + required: true + type: integer + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/admin.UserProfileResponse' + "400": + description: Bad Request + schema: + $ref: '#/definitions/code.Failure' + security: + - LoginVerifyToken: [] + summary: 获取用户综合画像 + tags: + - 管理端.用户 /api/admin/users/{user_id}/rewards/grant: post: consumes: @@ -5269,6 +6555,101 @@ paths: summary: 获取分类列表 tags: - APP端.基础 + /api/app/game-passes/available: + get: + consumes: + - application/json + description: 查询当前用户可用的游戏次数卡 + parameters: + - description: 活动ID,不传返回所有可用次数卡 + in: query + name: activity_id + type: integer + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/app.getGamePassesResponse' + "400": + description: Bad Request + schema: + $ref: '#/definitions/code.Failure' + security: + - LoginVerifyToken: [] + summary: 获取用户次数卡 + tags: + - APP端.用户 + /api/app/game-passes/packages: + get: + consumes: + - application/json + description: 获取可购买的次数卡套餐列表 + parameters: + - description: 活动ID,不传返回全局套餐 + in: query + name: activity_id + type: integer + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/app.getPackagesResponse' + "400": + description: Bad Request + schema: + $ref: '#/definitions/code.Failure' + summary: 获取次数卡套餐 + tags: + - APP端.用户 + /api/app/game-passes/purchase: + post: + consumes: + - application/json + description: 购买次数卡套餐,创建订单等待支付 + parameters: + - description: 请求参数 + in: body + name: RequestBody + required: true + schema: + $ref: '#/definitions/app.purchasePackageRequest' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/app.purchasePackageResponse' + "400": + description: Bad Request + schema: + $ref: '#/definitions/code.Failure' + security: + - LoginVerifyToken: [] + summary: 购买次数卡套餐 + tags: + - APP端.用户 + /api/app/games/enter: + post: + parameters: + - description: 请求参数 + in: body + name: RequestBody + required: true + schema: + $ref: '#/definitions/game.enterGameRequest' + responses: + "200": + description: OK + schema: + $ref: '#/definitions/game.enterGameResponse' + summary: 进入游戏 + tags: + - APP端.游戏 /api/app/lottery/join: post: consumes: @@ -5336,7 +6717,7 @@ paths: description: OK schema: items: - $ref: '#/definitions/app.CardTypeConfig' + $ref: '#/definitions/activity.CardTypeConfig' type: array "400": description: Bad Request @@ -5345,6 +6726,33 @@ paths: summary: 列出对对碰卡牌类型 tags: - APP端.活动 + /api/app/matching/cards: + get: + consumes: + - application/json + description: 只有支付成功后才能获取游戏牌组数据,防止未支付用户获取牌组信息 + parameters: + - description: 游戏ID + in: query + name: game_id + required: true + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/app.matchingGameCardsResponse' + "400": + description: Bad Request + schema: + $ref: '#/definitions/code.Failure' + security: + - LoginVerifyToken: [] + summary: 获取对对碰游戏数据 + tags: + - APP端.活动 /api/app/matching/check: post: consumes: @@ -5592,6 +7000,58 @@ paths: summary: 商品详情 tags: - APP端.商品 + /api/app/sms/login: + post: + consumes: + - application/json + description: 使用短信验证码登录或注册(新手机号自动创建账户) + parameters: + - description: 请求参数 + in: body + name: RequestBody + required: true + schema: + $ref: '#/definitions/app.smsLoginRequest' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/app.smsLoginResponse' + "400": + description: Bad Request + schema: + $ref: '#/definitions/code.Failure' + summary: 短信验证码登录 + tags: + - APP端.用户 + /api/app/sms/send-code: + post: + consumes: + - application/json + description: 发送短信验证码到指定手机号(60秒内不可重复发送,每日最多10次) + parameters: + - description: 请求参数 + in: body + name: RequestBody + required: true + schema: + $ref: '#/definitions/app.sendSmsCodeRequest' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/app.sendSmsCodeResponse' + "400": + description: Bad Request + schema: + $ref: '#/definitions/code.Failure' + summary: 发送短信验证码 + tags: + - APP端.用户 /api/app/store/items: get: consumes: @@ -5869,6 +7329,51 @@ paths: summary: 删除用户地址 tags: - APP端.用户 + put: + consumes: + - application/json + description: 更新当前登录用户的指定收货地址 + parameters: + - description: 用户ID + in: path + name: user_id + required: true + type: integer + - description: 地址ID + in: path + name: address_id + required: true + type: integer + - description: 请求参数 + in: body + name: RequestBody + required: true + schema: + $ref: '#/definitions/app.updateAddressRequest' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/app.okResponse' + "400": + description: 参数错误 + schema: + $ref: '#/definitions/code.Failure' + "401": + description: 未授权 + schema: + $ref: '#/definitions/code.Failure' + "500": + description: 服务器内部错误 + schema: + $ref: '#/definitions/code.Failure' + security: + - LoginVerifyToken: [] + summary: 更新用户地址 + tags: + - APP端.用户 /api/app/users/{user_id}/addresses/{address_id}/default: put: consumes: @@ -6019,6 +7524,57 @@ paths: summary: 获取用户优惠券统计 tags: - APP端.用户 + /api/app/users/{user_id}/douyin/phone/bind: + post: + consumes: + - application/json + description: 使用抖音手机号 code 换取手机号并绑定到指定用户 + parameters: + - description: 用户ID + in: path + name: user_id + required: true + type: integer + - description: 请求参数 + in: body + name: RequestBody + required: true + schema: + $ref: '#/definitions/app.bindDouyinPhoneRequest' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/app.bindDouyinPhoneResponse' + "400": + description: Bad Request + schema: + $ref: '#/definitions/code.Failure' + security: + - LoginVerifyToken: [] + summary: 抖音绑定手机号 + tags: + - APP端.用户 + /api/app/users/{user_id}/game_tickets: + get: + parameters: + - description: 用户ID + in: path + name: user_id + required: true + type: integer + responses: + "200": + description: OK + schema: + additionalProperties: + type: integer + type: object + summary: 获取我的游戏资格 + tags: + - APP端.游戏 /api/app/users/{user_id}/inventory: get: consumes: @@ -6124,14 +7680,14 @@ paths: post: consumes: - application/json - description: 取消已提交但未发货的申请;恢复库存状态 + description: 取消已提交但未发货的申请;恢复库存状态。支持按单个资产ID取消或按批次号批量取消 parameters: - description: 用户ID in: path name: user_id required: true type: integer - - description: 请求参数:资产ID + - description: 请求参数:资产ID或批次号(二选一) in: body name: RequestBody required: true @@ -6531,7 +8087,7 @@ paths: post: consumes: - application/json - description: 使用积分兑换指定直减金额券,按比率 1元=100积分(券面值为分) + description: 使用积分兑换指定直减金额券,按比率 1积分=1元(券面值为分) parameters: - description: 请求参数 in: body @@ -6592,7 +8148,7 @@ paths: post: consumes: - application/json - description: 使用积分按比率1元=100积分兑换商品,生成系统发放订单与用户资产 + description: 使用积分按比率1积分=1元兑换商品,生成系统发放订单与用户资产 parameters: - description: 请求参数 in: body @@ -6686,6 +8242,53 @@ paths: summary: 获取用户统计 tags: - APP端.用户 + /api/app/users/douyin/login: + post: + consumes: + - application/json + description: 抖音小程序登录(需传递 code 或 anonymous_code) + parameters: + - description: 请求参数 + in: body + name: RequestBody + required: true + schema: + $ref: '#/definitions/app.douyinLoginRequest' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/app.douyinLoginResponse' + "400": + description: Bad Request + schema: + $ref: '#/definitions/code.Failure' + summary: 抖音登录 + tags: + - APP端.用户 + /api/app/users/profile: + get: + consumes: + - application/json + description: 获取当前登录用户的详细信息 + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/app.userItem' + "400": + description: Bad Request + schema: + $ref: '#/definitions/code.Failure' + security: + - LoginVerifyToken: [] + summary: 获取用户信息 + tags: + - APP端.用户 /api/app/users/weixin/login: post: consumes: @@ -6753,6 +8356,34 @@ paths: summary: 角色列表 tags: - 管理端.系统 + /api/user: + post: + consumes: + - application/json + description: 创建新的管理员账号 + parameters: + - description: 请求参数 + in: body + name: RequestBody + required: true + schema: + $ref: '#/definitions/admin.createUserRequest' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/admin.createUserResponse' + "400": + description: Bad Request + schema: + $ref: '#/definitions/code.Failure' + security: + - LoginVerifyToken: [] + summary: 创建系统用户 + tags: + - 管理端.系统 /api/user/list: get: consumes: @@ -6829,6 +8460,34 @@ paths: summary: WangEditor图片上传 tags: - Common + /internal/game/consume-ticket: + post: + parameters: + - description: 请求参数 + in: body + name: RequestBody + required: true + schema: + $ref: '#/definitions/game.consumeTicketRequest' + responses: + "200": + description: OK + schema: + $ref: '#/definitions/game.consumeTicketResponse' + summary: 扣减游戏次数 + tags: + - Internal.游戏 + /internal/game/minesweeper/config: + get: + responses: + "200": + description: OK + schema: + additionalProperties: true + type: object + summary: 获取扫雷配置 + tags: + - Internal.游戏 /internal/game/settle: post: consumes: @@ -6851,6 +8510,23 @@ paths: summary: 结算游戏 tags: - Internal + /internal/game/validate-token: + post: + parameters: + - description: 请求参数 + in: body + name: RequestBody + required: true + schema: + $ref: '#/definitions/game.validateTokenRequest' + responses: + "200": + description: OK + schema: + $ref: '#/definitions/game.validateTokenResponse' + summary: 验证GameToken + tags: + - Internal.游戏 /internal/game/verify: post: consumes: diff --git a/internal/api/activity/app.go b/internal/api/activity/app.go index fc90f8f..ea46465 100644 --- a/internal/api/activity/app.go +++ b/internal/api/activity/app.go @@ -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), } diff --git a/internal/api/activity/lottery_app.go b/internal/api/activity/lottery_app.go index ccaceda..36bbc96 100644 --- a/internal/api/activity/lottery_app.go +++ b/internal/api/activity/lottery_app.go @@ -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() diff --git a/internal/api/activity/matching_game_app.go b/internal/api/activity/matching_game_app.go index d9ec217..f22e6e6 100644 --- a/internal/api/activity/matching_game_app.go +++ b/internal/api/activity/matching_game_app.go @@ -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 { diff --git a/internal/api/admin/game_passes_admin.go b/internal/api/admin/game_passes_admin.go index 936538d..1df9390 100644 --- a/internal/api/admin/game_passes_admin.go +++ b/internal/api/admin/game_passes_admin.go @@ -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 } diff --git a/internal/api/admin/pay_refund_admin.go b/internal/api/admin/pay_refund_admin.go index b188ea9..a224ff4 100644 --- a/internal/api/admin/pay_refund_admin.go +++ b/internal/api/admin/pay_refund_admin.go @@ -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), diff --git a/internal/api/admin/users_profit_loss.go b/internal/api/admin/users_profit_loss.go index f5cd374..c6b96f3 100644 --- a/internal/api/admin/users_profit_loss.go +++ b/internal/api/admin/users_profit_loss.go @@ -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 diff --git a/internal/api/pay/wechat_notify.go b/internal/api/pay/wechat_notify.go index ee0eb4a..6ab67cb 100644 --- a/internal/api/pay/wechat_notify.go +++ b/internal/api/pay/wechat_notify.go @@ -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)) + } } } }() diff --git a/internal/api/user/app.go b/internal/api/user/app.go index 19d24c5..eebda06 100644 --- a/internal/api/user/app.go +++ b/internal/api/user/app.go @@ -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} } diff --git a/internal/api/user/game_passes_app.go b/internal/api/user/game_passes_app.go index 70db3d2..ea44ba4 100644 --- a/internal/api/user/game_passes_app.go +++ b/internal/api/user/game_passes_app.go @@ -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}) diff --git a/internal/api/user/login_app.go b/internal/api/user/login_app.go index 5f295e0..50c0900 100644 --- a/internal/api/user/login_app.go +++ b/internal/api/user/login_app.go @@ -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) + } } diff --git a/internal/api/user/login_douyin_app.go b/internal/api/user/login_douyin_app.go new file mode 100644 index 0000000..f5f2169 --- /dev/null +++ b/internal/api/user/login_douyin_app.go @@ -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) + } +} diff --git a/internal/api/user/phone_bind.go b/internal/api/user/phone_bind.go index 4e0ec21..6eec86b 100644 --- a/internal/api/user/phone_bind.go +++ b/internal/api/user/phone_bind.go @@ -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) diff --git a/internal/api/user/phone_bind_douyin_app.go b/internal/api/user/phone_bind_douyin_app.go new file mode 100644 index 0000000..b04a49c --- /dev/null +++ b/internal/api/user/phone_bind_douyin_app.go @@ -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) + } +} diff --git a/internal/api/user/profile_app.go b/internal/api/user/profile_app.go index 1c6ba2b..77d903c 100644 --- a/internal/api/user/profile_app.go +++ b/internal/api/user/profile_app.go @@ -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) } } diff --git a/internal/api/user/sms_login_app.go b/internal/api/user/sms_login_app.go index b85e621..11dffee 100644 --- a/internal/api/user/sms_login_app.go +++ b/internal/api/user/sms_login_app.go @@ -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), diff --git a/internal/pkg/cryptoaes/cryptoaes.go b/internal/pkg/cryptoaes/cryptoaes.go index e466d76..74c536d 100644 --- a/internal/pkg/cryptoaes/cryptoaes.go +++ b/internal/pkg/cryptoaes/cryptoaes.go @@ -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 diff --git a/internal/pkg/douyin/access_token.go b/internal/pkg/douyin/access_token.go new file mode 100644 index 0000000..2238b3d --- /dev/null +++ b/internal/pkg/douyin/access_token.go @@ -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 +} diff --git a/internal/pkg/douyin/code2session.go b/internal/pkg/douyin/code2session.go new file mode 100644 index 0000000..9312427 --- /dev/null +++ b/internal/pkg/douyin/code2session.go @@ -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 +} diff --git a/internal/pkg/douyin/phonenumber.go b/internal/pkg/douyin/phonenumber.go new file mode 100644 index 0000000..064d74d --- /dev/null +++ b/internal/pkg/douyin/phonenumber.go @@ -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 +} diff --git a/internal/pkg/wechat/shipping.go b/internal/pkg/wechat/shipping.go index a239212..6aa8d51 100644 --- a/internal/pkg/wechat/shipping.go +++ b/internal/pkg/wechat/shipping.go @@ -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()) } diff --git a/internal/router/router.go b/internal/router/router.go index 5c5279e..ee11cc9 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -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()) diff --git a/internal/service/activity/lottery_process.go b/internal/service/activity/lottery_process.go index 566316f..2c4637c 100644 --- a/internal/service/activity/lottery_process.go +++ b/internal/service/activity/lottery_process.go @@ -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 } diff --git a/internal/service/game/token.go b/internal/service/game/token.go index 55a1210..7c4a300 100644 --- a/internal/service/game/token.go +++ b/internal/service/game/token.go @@ -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() } diff --git a/internal/service/sysconfig/dynamic_config.go b/internal/service/sysconfig/dynamic_config.go index 656c152..34fd83c 100644 --- a/internal/service/sysconfig/dynamic_config.go +++ b/internal/service/sysconfig/dynamic_config.go @@ -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), + } +} diff --git a/internal/service/sysconfig/global.go b/internal/service/sysconfig/global.go new file mode 100644 index 0000000..e4c121a --- /dev/null +++ b/internal/service/sysconfig/global.go @@ -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 +} diff --git a/internal/service/task_center/README.md b/internal/service/task_center/README.md deleted file mode 100644 index 0aea91e..0000000 --- a/internal/service/task_center/README.md +++ /dev/null @@ -1,4 +0,0 @@ -# Task Center Service - -This directory will host core business logic for Task Center: rules evaluation, progress tracking, reward issuing orchestration. - diff --git a/internal/service/task_center/constants.go b/internal/service/task_center/constants.go index 818be2b..c086451 100644 --- a/internal/service/task_center/constants.go +++ b/internal/service/task_center/constants.go @@ -4,7 +4,8 @@ const ( // Task Windows WindowDaily = "daily" WindowWeekly = "weekly" - WindowInfinite = "infinite" + WindowMonthly = "monthly" + WindowLifetime = "lifetime" // Task Metrics MetricFirstOrder = "first_order" diff --git a/internal/service/task_center/service.go b/internal/service/task_center/service.go index 24a0ec8..834c3b5 100644 --- a/internal/service/task_center/service.go +++ b/internal/service/task_center/service.go @@ -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) diff --git a/internal/service/task_center/task_center_test.go b/internal/service/task_center/task_center_test.go new file mode 100644 index 0000000..ceda825 --- /dev/null +++ b/internal/service/task_center/task_center_test.go @@ -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) +} diff --git a/internal/service/user/game_pass.go b/internal/service/user/game_pass.go new file mode 100644 index 0000000..e061976 --- /dev/null +++ b/internal/service/user/game_pass.go @@ -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 +} diff --git a/internal/service/user/login_douyin.go b/internal/service/user/login_douyin.go new file mode 100644 index 0000000..ec40e6d --- /dev/null +++ b/internal/service/user/login_douyin.go @@ -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 +} diff --git a/internal/service/user/login_weixin.go b/internal/service/user/login_weixin.go index 6a3b24a..b84f01f 100644 --- a/internal/service/user/login_weixin.go +++ b/internal/service/user/login_weixin.go @@ -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 } diff --git a/internal/service/user/update_profile.go b/internal/service/user/profile.go similarity index 70% rename from internal/service/user/update_profile.go rename to internal/service/user/profile.go index a6f989b..14f4ee9 100644 --- a/internal/service/user/update_profile.go +++ b/internal/service/user/profile.go @@ -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{} diff --git a/internal/service/user/sms_login.go b/internal/service/user/sms_login.go index f396d19..8a4d8cd 100644 --- a/internal/service/user/sms_login.go +++ b/internal/service/user/sms_login.go @@ -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 { diff --git a/internal/service/user/user.go b/internal/service/user/user.go index 660c271..206d317 100644 --- a/internal/service/user/user.go +++ b/internal/service/user/user.go @@ -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 { diff --git a/main.go b/main.go index c326b36..1893900 100644 --- a/main.go +++ b/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() {