feat: 新增抖音登录功能、管理端次数卡及套餐管理接口,并引入配置迁移工具。

This commit is contained in:
邹方成 2026-01-04 01:40:11 +08:00
parent dc1b324aef
commit 269bdb9fd1
46 changed files with 87760 additions and 349 deletions

155
cmd/migrate_configs/main.go Normal file
View File

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

View File

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

View File

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

View File

@ -83,6 +83,12 @@ type Config struct {
Internal struct { Internal struct {
ApiKey string `mapstructure:"api_key" toml:"api_key"` ApiKey string `mapstructure:"api_key" toml:"api_key"`
} `mapstructure:"internal" toml:"internal"` } `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 ( var (

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -5,6 +5,7 @@ import (
"bindbox-game/internal/repository/mysql" "bindbox-game/internal/repository/mysql"
"bindbox-game/internal/repository/mysql/dao" "bindbox-game/internal/repository/mysql/dao"
activitysvc "bindbox-game/internal/service/activity" activitysvc "bindbox-game/internal/service/activity"
tasksvc "bindbox-game/internal/service/task_center"
titlesvc "bindbox-game/internal/service/title" titlesvc "bindbox-game/internal/service/title"
usersvc "bindbox-game/internal/service/user" usersvc "bindbox-game/internal/service/user"
@ -19,11 +20,12 @@ type handler struct {
title titlesvc.Service title titlesvc.Service
repo mysql.Repo repo mysql.Repo
user usersvc.Service user usersvc.Service
task tasksvc.Service
redis *redis.Client redis *redis.Client
activityOrder activitysvc.ActivityOrderService // 活动订单服务 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) userSvc := usersvc.New(logger, db)
return &handler{ return &handler{
logger: logger, logger: logger,
@ -33,6 +35,7 @@ func New(logger logger.CustomLogger, db mysql.Repo, rdb *redis.Client) *handler
title: titlesvc.New(logger, db), title: titlesvc.New(logger, db),
repo: db, repo: db,
user: userSvc, user: userSvc,
task: task,
redis: rdb, redis: rdb,
activityOrder: activitysvc.NewActivityOrderService(logger, db), activityOrder: activitysvc.NewActivityOrderService(logger, db),
} }

View File

@ -6,6 +6,7 @@ import (
"bindbox-game/internal/pkg/validation" "bindbox-game/internal/pkg/validation"
"bindbox-game/internal/repository/mysql/dao" "bindbox-game/internal/repository/mysql/dao"
"bindbox-game/internal/repository/mysql/model" "bindbox-game/internal/repository/mysql/model"
"context"
"crypto/hmac" "crypto/hmac"
"crypto/sha256" "crypto/sha256"
"encoding/base64" "encoding/base64"
@ -22,23 +23,26 @@ import (
) )
type joinLotteryRequest struct { type joinLotteryRequest struct {
ActivityID int64 `json:"activity_id"` ActivityID int64 `json:"activity_id"`
IssueID int64 `json:"issue_id"` IssueID int64 `json:"issue_id"`
Count int64 `json:"count"` Count int64 `json:"count"`
Channel string `json:"channel"` Channel string `json:"channel"`
SlotIndex []int64 `json:"slot_index"` SlotIndex []int64 `json:"slot_index"`
CouponID *int64 `json:"coupon_id"` CouponID *int64 `json:"coupon_id"`
ItemCardID *int64 `json:"item_card_id"` ItemCardID *int64 `json:"item_card_id"`
UsePoints *int64 `json:"use_points"` UsePoints *int64 `json:"use_points"`
UseGamePass *bool `json:"use_game_pass"`
} }
type joinLotteryResponse struct { type joinLotteryResponse struct {
JoinID string `json:"join_id"` JoinID string `json:"join_id"`
OrderNo string `json:"order_no"` OrderNo string `json:"order_no"`
Queued bool `json:"queued"` Queued bool `json:"queued"`
DrawMode string `json:"draw_mode"` DrawMode string `json:"draw_mode"`
RewardID int64 `json:"reward_id,omitempty"` RewardID int64 `json:"reward_id,omitempty"`
RewardName string `json:"reward_name,omitempty"` RewardName string `json:"reward_name,omitempty"`
ActualAmount int64 `json:"actual_amount"`
Status int32 `json:"status"`
} }
// JoinLottery 用户参与抽奖 // JoinLottery 用户参与抽奖
@ -121,6 +125,12 @@ func (h *handler) JoinLottery() core.HandlerFunc {
order.PointsLedgerID = 0 order.PointsLedgerID = 0
order.ActualAmount = order.TotalAmount order.ActualAmount = order.TotalAmount
applied := int64(0) 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 { if activity.AllowCoupons && req.CouponID != nil && *req.CouponID > 0 {
order.CouponID = *req.CouponID order.CouponID = *req.CouponID
applied = h.applyCouponWithCap(ctx, userID, order, req.ActivityID, *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)) h.logger.Info(fmt.Sprintf("JoinLottery Tx Start: UserID=%d", userID))
err = h.writeDB.Transaction(func(tx *dao.Query) error { 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) bal, _ := h.user.GetPointsBalance(ctx.RequestContext(), userID)
usePts := *req.UsePoints usePts := *req.UsePoints
if bal > 0 && usePts > bal { 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) err = tx.Orders.WithContext(ctx.RequestContext()).Omit(tx.Orders.PaidAt, tx.Orders.CancelledAt).Create(order)
if err != nil { if err != nil {
return err 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) // Inline RecordOrderCouponUsage (no logging)
if applied > 0 && req.CouponID != nil && *req.CouponID > 0 { 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 _ = 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())) ctx.AbortWithError(core.Error(http.StatusInternalServerError, 170002, err.Error()))
return return
} }
// 优惠券扣减与核销在支付回调中执行(避免未支付时扣减)
rsp.JoinID = joinID rsp.JoinID = joinID
rsp.OrderNo = orderNo 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 rsp.DrawMode = cfgMode
if order.ActualAmount == 0 { if order.ActualAmount == 0 {
now := time.Now() 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}) _, _ = 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 处理优惠券扣减与流水记录 // 0元订单统一由 Service 处理优惠券扣减与流水记录
_ = h.user.DeductCouponsForPaidOrder(ctx.RequestContext(), nil, userID, order.ID, now) _ = h.user.DeductCouponsForPaidOrder(ctx.RequestContext(), nil, userID, order.ID, now)
// 异步触发任务中心逻辑
go func() {
_ = h.task.OnOrderPaid(context.Background(), userID, order.ID)
}()
rsp.Queued = true rsp.Queued = true
} else { } else {
rsp.Queued = true rsp.Queued = true
@ -338,7 +493,7 @@ func (h *handler) GetLotteryResult() core.HandlerFunc {
var ord *model.Orders var ord *model.Orders
if req.OrderID > 0 { if req.OrderID > 0 {
orderID = req.OrderID 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) userID := int64(ctx.SessionUserInfo().Id)
issue, _ := h.readDB.ActivityIssues.WithContext(ctx.RequestContext()).Where(h.readDB.ActivityIssues.ID.Eq(issueID)).First() issue, _ := h.readDB.ActivityIssues.WithContext(ctx.RequestContext()).Where(h.readDB.ActivityIssues.ID.Eq(issueID)).First()

View File

@ -166,13 +166,14 @@ func (h *handler) PreOrderMatchingGame() core.HandlerFunc {
ActualAmount: 0, // 次数卡抵扣实付0元 ActualAmount: 0, // 次数卡抵扣实付0元
DiscountAmount: activity.PriceDraw, DiscountAmount: activity.PriceDraw,
Status: 2, // 已支付 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, CreatedAt: now,
UpdatedAt: now, UpdatedAt: now,
PaidAt: now, PaidAt: now,
} }
if err := h.writeDB.Orders.WithContext(ctx.RequestContext()). if err := h.writeDB.Orders.WithContext(ctx.RequestContext()).
Omit(h.writeDB.Orders.CancelledAt).
Create(newOrder); err != nil { Create(newOrder); err != nil {
// 回滚次数卡 // 回滚次数卡
h.writeDB.UserGamePasses.WithContext(ctx.RequestContext()). h.writeDB.UserGamePasses.WithContext(ctx.RequestContext()).
@ -185,6 +186,11 @@ func (h *handler) PreOrderMatchingGame() core.HandlerFunc {
return return
} }
order = newOrder order = newOrder
// 次数卡 0 元订单手动触发任务中心
go func() {
_ = h.task.OnOrderPaid(context.Background(), userID, order.ID)
}()
} else { } else {
// 原有支付流程 // 原有支付流程
var couponID *int64 var couponID *int64
@ -739,7 +745,7 @@ func (h *handler) GetMatchingGameState() core.HandlerFunc {
// @Tags APP端.活动 // @Tags APP端.活动
// @Accept json // @Accept json
// @Produce json // @Produce json
// @Success 200 {array} CardTypeConfig // @Success 200 {array} activitysvc.CardTypeConfig
// @Failure 400 {object} code.Failure // @Failure 400 {object} code.Failure
// @Router /api/app/matching/card_types [get] // @Router /api/app/matching/card_types [get]
func (h *handler) ListMatchingCardTypes() core.HandlerFunc { func (h *handler) ListMatchingCardTypes() core.HandlerFunc {

View File

@ -73,7 +73,11 @@ func (h *handler) GrantGamePass() core.HandlerFunc {
m.ExpiredAt = now.Add(time.Duration(*req.ValidDays) * 24 * time.Hour) 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())) ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ServerError, err.Error()))
return return
} }

View File

@ -6,7 +6,6 @@ import (
"net/http" "net/http"
"regexp" "regexp"
"strconv" "strconv"
"strings"
"time" "time"
"bindbox-game/internal/code" "bindbox-game/internal/code"
@ -47,69 +46,79 @@ func (h *handler) CreateRefund() core.HandlerFunc {
return 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+)`) rePoints := regexp.MustCompile(`\|redeemed_points=(\d+)`)
for _, inv := range allInvs {
if inv.Status == 3 && strings.Contains(inv.Remark, "redeemed_points=") { var refundedSumCents int64
matches := rePoints.FindStringSubmatch(inv.Remark) var isFullRefund bool
if len(matches) > 1 {
p, _ := strconv.ParseInt(matches[1], 10, 64) // ⭐ 根据 ActualAmount 决定是否需要微信退款
pointsToReclaim += p 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() if isFullRefund {
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 {
_, err = h.writeDB.Orders.WithContext(ctx.RequestContext()).Where(h.writeDB.Orders.ID.Eq(order.ID)).Updates(map[string]any{ _, 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.Status.ColumnName().String(): 4,
h.writeDB.Orders.UpdatedAt.ColumnName().String(): time.Now(), h.writeDB.Orders.UpdatedAt.ColumnName().String(): time.Now(),
@ -225,9 +234,25 @@ func (h *handler) CreateRefund() core.HandlerFunc {
for icID := range idSet { 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 _ = 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))
}
}
}
} }
// 记录积分按比例恢复(幂等增量) // 记录积分按比例恢复(幂等增量)- 仅对 ActualAmount > 0 的订单执行
if order.PointsAmount > 0 { if order.PointsAmount > 0 && order.ActualAmount > 0 {
restores, _ := h.readDB.UserPointsLedger.WithContext(ctx.RequestContext()).ReadDB().Where( restores, _ := h.readDB.UserPointsLedger.WithContext(ctx.RequestContext()).ReadDB().Where(
h.readDB.UserPointsLedger.RefTable.Eq("orders"), h.readDB.UserPointsLedger.RefTable.Eq("orders"),
h.readDB.UserPointsLedger.RefID.Eq(order.OrderNo), h.readDB.UserPointsLedger.RefID.Eq(order.OrderNo),

View File

@ -79,7 +79,7 @@ func (h *handler) GetUserProfitLossTrend() core.HandlerFunc {
Cards int64 Cards int64
Coupons 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(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(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 _ = h.repo.GetDbR().Raw("SELECT COALESCE(SUM(balance_amount), 0) FROM user_coupons WHERE user_id = ? AND status = 1", userID).Scan(&curAssets.Coupons).Error

View File

@ -5,6 +5,7 @@ import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"net/http" "net/http"
"strings"
"time" "time"
"bindbox-game/configs" "bindbox-game/configs"
@ -279,7 +280,63 @@ func (h *handler) WechatNotify() core.HandlerFunc {
if ord.SourceType == 2 && act != nil && act.DrawMode == "instant" { if ord.SourceType == 2 && act != nil && act.DrawMode == "instant" {
_ = h.activity.ProcessOrderLottery(bgCtx, ord.ID) _ = 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 := "" payerOpenid := ""
if transaction.Payer != nil && transaction.Payer.Openid != nil { if transaction.Payer != nil && transaction.Payer.Openid != nil {
@ -292,7 +349,11 @@ func (h *handler) WechatNotify() core.HandlerFunc {
} }
return "" return ""
}(); txID != "" { }(); 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))
}
} }
} }
}() }()

View File

@ -4,17 +4,19 @@ import (
"bindbox-game/internal/pkg/logger" "bindbox-game/internal/pkg/logger"
"bindbox-game/internal/repository/mysql" "bindbox-game/internal/repository/mysql"
"bindbox-game/internal/repository/mysql/dao" "bindbox-game/internal/repository/mysql/dao"
tasksvc "bindbox-game/internal/service/task_center"
usersvc "bindbox-game/internal/service/user" usersvc "bindbox-game/internal/service/user"
) )
type handler struct { type handler struct {
logger logger.CustomLogger logger logger.CustomLogger
writeDB *dao.Query writeDB *dao.Query
readDB *dao.Query readDB *dao.Query
user usersvc.Service user usersvc.Service
repo mysql.Repo task tasksvc.Service
repo mysql.Repo
} }
func New(logger logger.CustomLogger, db mysql.Repo) *handler { 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), repo: db} return &handler{logger: logger, writeDB: dao.Use(db.GetDbW()), readDB: dao.Use(db.GetDbR()), user: usersvc.New(logger, db), task: taskSvc, repo: db}
} }

View File

@ -1,6 +1,7 @@
package app package app
import ( import (
"fmt"
"net/http" "net/http"
"time" "time"
@ -197,6 +198,7 @@ func (h *handler) GetGamePassPackages() core.HandlerFunc {
type purchasePackageRequest struct { type purchasePackageRequest struct {
PackageID int64 `json:"package_id" binding:"required"` PackageID int64 `json:"package_id" binding:"required"`
Count int32 `json:"count"` // 购买数量
} }
type purchasePackageResponse struct { type purchasePackageResponse struct {
@ -224,6 +226,10 @@ func (h *handler) PurchaseGamePassPackage() core.HandlerFunc {
return return
} }
if req.Count <= 0 {
req.Count = 1
}
userID := int64(ctx.SessionUserInfo().Id) userID := int64(ctx.SessionUserInfo().Id)
// 查询套餐信息 // 查询套餐信息
@ -236,17 +242,20 @@ func (h *handler) PurchaseGamePassPackage() core.HandlerFunc {
return return
} }
// Calculate total price
totalPrice := pkg.Price * int64(req.Count)
// 创建订单 // 创建订单
now := time.Now() 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{ order := &model.Orders{
UserID: userID, UserID: userID,
OrderNo: "GP" + orderNo, OrderNo: "GP" + orderNo,
SourceType: 4, // 次数卡购买 SourceType: 4, // 次数卡购买
TotalAmount: pkg.Price, TotalAmount: totalPrice,
ActualAmount: pkg.Price, ActualAmount: totalPrice,
Status: 1, // 待支付 Status: 1, // 待支付
Remark: "game_pass_package:" + pkg.Name, Remark: fmt.Sprintf("game_pass_package:%s|count:%d", pkg.Name, req.Count),
CreatedAt: now, CreatedAt: now,
UpdatedAt: now, UpdatedAt: now,
} }
@ -258,8 +267,8 @@ func (h *handler) PurchaseGamePassPackage() core.HandlerFunc {
return return
} }
// 在备注中记录套餐ID,支付成功后回调时使用 // 在备注中记录套餐ID和数量
remark := order.Remark + "|pkg_id:" + string(rune(pkg.ID)) remark := fmt.Sprintf("%s|pkg_id:%d|count:%d", order.Remark, pkg.ID, req.Count)
h.writeDB.Orders.WithContext(ctx.RequestContext()). h.writeDB.Orders.WithContext(ctx.RequestContext()).
Where(h.writeDB.Orders.ID.Eq(order.ID)). Where(h.writeDB.Orders.ID.Eq(order.ID)).
Updates(map[string]any{"remark": remark}) Updates(map[string]any{"remark": remark})

View File

@ -1,31 +1,33 @@
package app package app
import ( import (
"net/http" "net/http"
"time" "time"
"bindbox-game/configs" "bindbox-game/configs"
"bindbox-game/internal/code" "bindbox-game/internal/code"
"bindbox-game/internal/pkg/core" "bindbox-game/internal/pkg/core"
"bindbox-game/internal/pkg/jwtoken" "bindbox-game/internal/pkg/jwtoken"
"bindbox-game/internal/pkg/validation" "bindbox-game/internal/pkg/validation"
"bindbox-game/internal/pkg/wechat" "bindbox-game/internal/pkg/wechat"
"bindbox-game/internal/proposal" "bindbox-game/internal/proposal"
usersvc "bindbox-game/internal/service/user" usersvc "bindbox-game/internal/service/user"
"go.uber.org/zap"
) )
type weixinLoginRequest struct { type weixinLoginRequest struct {
Code string `json:"code"` Code string `json:"code"`
InviteCode string `json:"invite_code"` InviteCode string `json:"invite_code"`
DouyinID string `json:"douyin_id"` DouyinID string `json:"douyin_id"`
} }
type weixinLoginResponse struct { type weixinLoginResponse struct {
UserID int64 `json:"user_id"` UserID int64 `json:"user_id"`
Nickname string `json:"nickname"` Nickname string `json:"nickname"`
Avatar string `json:"avatar"` Avatar string `json:"avatar"`
InviteCode string `json:"invite_code"` InviteCode string `json:"invite_code"`
OpenID string `json:"openid"` OpenID string `json:"openid"`
Token string `json:"token"` Token string `json:"token"`
} }
// WeixinLogin 微信登录 // WeixinLogin 微信登录
@ -54,22 +56,31 @@ func (h *handler) WeixinLogin() core.HandlerFunc {
return return
} }
in := usersvc.LoginWeixinInput{OpenID: c2s.OpenID, UnionID: c2s.UnionID, InviteCode: req.InviteCode, DouyinID: req.DouyinID} in := usersvc.LoginWeixinInput{OpenID: c2s.OpenID, UnionID: c2s.UnionID, InviteCode: req.InviteCode, DouyinID: req.DouyinID}
u, err := h.user.LoginWeixin(ctx.RequestContext(), in) out, err := h.user.LoginWeixin(ctx.RequestContext(), in)
if err != nil { if err != nil {
ctx.AbortWithError(core.Error(http.StatusBadRequest, 10006, err.Error())) ctx.AbortWithError(core.Error(http.StatusBadRequest, 10006, err.Error()))
return return
} }
rsp.UserID = u.ID
rsp.Nickname = u.Nickname u := out.User
rsp.Avatar = u.Avatar rsp.UserID = u.ID
rsp.InviteCode = u.InviteCode rsp.Nickname = u.Nickname
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 out.IsNewUser && out.InviterID > 0 {
if tErr == nil { if err := h.task.OnInviteSuccess(ctx.RequestContext(), out.InviterID, u.ID); err != nil {
rsp.Token = tokenString h.logger.Error("触发邀请任务失败", zap.Error(err), zap.Int64("inviter_id", out.InviterID), zap.Int64("invitee_id", u.ID))
} }
ctx.Payload(rsp) }
} 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)
}
} }

View File

@ -0,0 +1,95 @@
package app
import (
"net/http"
"time"
"bindbox-game/configs"
"bindbox-game/internal/code"
"bindbox-game/internal/pkg/core"
"bindbox-game/internal/pkg/jwtoken"
"bindbox-game/internal/pkg/validation"
"bindbox-game/internal/proposal"
usersvc "bindbox-game/internal/service/user"
"go.uber.org/zap"
)
type douyinLoginRequest struct {
Code string `json:"code"`
AnonymousCode string `json:"anonymous_code"`
InviteCode string `json:"invite_code"`
ChannelCode string `json:"channel_code"`
}
type douyinLoginResponse struct {
UserID int64 `json:"user_id"`
Nickname string `json:"nickname"`
Avatar string `json:"avatar"`
InviteCode string `json:"invite_code"`
Token string `json:"token"`
}
// DouyinLogin 抖音登录
// @Summary 抖音登录
// @Description 抖音小程序登录(需传递 code 或 anonymous_code
// @Tags APP端.用户
// @Accept json
// @Produce json
// @Param RequestBody body douyinLoginRequest true "请求参数"
// @Success 200 {object} douyinLoginResponse
// @Failure 400 {object} code.Failure
// @Router /api/app/users/douyin/login [post]
func (h *handler) DouyinLogin() core.HandlerFunc {
return func(ctx core.Context) {
req := new(douyinLoginRequest)
rsp := new(douyinLoginResponse)
if err := ctx.ShouldBindJSON(req); err != nil {
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, validation.Error(err)))
return
}
if req.Code == "" && req.AnonymousCode == "" {
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, "code 或 anonymous_code 不能为空"))
return
}
in := usersvc.LoginDouyinInput{
Code: req.Code,
AnonymousCode: req.AnonymousCode,
InviteCode: req.InviteCode,
ChannelCode: req.ChannelCode,
}
out, err := h.user.LoginDouyin(ctx.RequestContext(), in)
if err != nil {
ctx.AbortWithError(core.Error(http.StatusBadRequest, 10006, err.Error()))
return
}
u := out.User
rsp.UserID = u.ID
rsp.Nickname = u.Nickname
rsp.Avatar = u.Avatar
rsp.InviteCode = u.InviteCode
// 触发邀请奖励逻辑
if out.IsNewUser && out.InviterID > 0 {
if err := h.task.OnInviteSuccess(ctx.RequestContext(), out.InviterID, u.ID); err != nil {
h.logger.Error("触发邀请任务失败", zap.Error(err), zap.Int64("inviter_id", out.InviterID), zap.Int64("invitee_id", u.ID))
}
}
sessionUserInfo := proposal.SessionUserInfo{
Id: int32(u.ID),
UserName: u.Nickname,
NickName: u.Nickname,
IsSuper: 0,
Platform: "APP",
}
tokenString, tErr := jwtoken.New(configs.Get().JWT.PatientSecret).Sign(sessionUserInfo, 30*24*time.Hour)
if tErr == nil {
rsp.Token = tokenString
}
ctx.Payload(rsp)
}
}

View File

@ -1,14 +1,16 @@
package app package app
import ( import (
"net/http" "net/http"
"bindbox-game/configs" "bindbox-game/configs"
"bindbox-game/internal/code" "bindbox-game/internal/code"
"bindbox-game/internal/pkg/core" "bindbox-game/internal/pkg/core"
"bindbox-game/internal/pkg/miniprogram" "bindbox-game/internal/pkg/miniprogram"
"bindbox-game/internal/pkg/validation" "bindbox-game/internal/pkg/validation"
"bindbox-game/internal/pkg/wechat" "bindbox-game/internal/pkg/wechat"
"go.uber.org/zap"
) )
type bindPhoneRequest struct { type bindPhoneRequest struct {
@ -26,8 +28,8 @@ type bindPhoneResponse struct {
// @Tags APP端.用户 // @Tags APP端.用户
// @Accept json // @Accept json
// @Produce json // @Produce json
// @Param user_id path integer true "用户ID" // @Param user_id path integer true "用户ID"
// @Security LoginVerifyToken // @Security LoginVerifyToken
// @Param RequestBody body bindPhoneRequest true "请求参数" // @Param RequestBody body bindPhoneRequest true "请求参数"
// @Success 200 {object} bindPhoneResponse // @Success 200 {object} bindPhoneResponse
// @Failure 400 {object} code.Failure // @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))) ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, validation.Error(err)))
return return
} }
userID := int64(ctx.SessionUserInfo().Id) userID := int64(ctx.SessionUserInfo().Id)
if userID <= 0 || req.Code == "" { if userID <= 0 || req.Code == "" {
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, "缺少必要参数")) ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, "缺少必要参数"))
return return
} }
cfg := configs.Get() cfg := configs.Get()
var tokenRes struct { var tokenRes struct {
@ -65,10 +67,27 @@ func (h *handler) BindPhone() core.HandlerFunc {
return 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())) existedUser, _ := h.readDB.Users.WithContext(ctx.RequestContext()).Where(h.readDB.Users.Mobile.Eq(mobile)).First()
return 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.Success = true
rsp.Mobile = mobile rsp.Mobile = mobile
ctx.Payload(rsp) ctx.Payload(rsp)

View File

@ -0,0 +1,115 @@
package app
import (
"net/http"
"bindbox-game/configs"
"bindbox-game/internal/code"
"bindbox-game/internal/pkg/core"
"bindbox-game/internal/pkg/douyin"
"bindbox-game/internal/pkg/validation"
"bindbox-game/internal/service/sysconfig"
)
type bindDouyinPhoneRequest struct {
Code string `json:"code"`
// EncryptedData string `json:"encrypted_data"` // Reserved if needed
// IV string `json:"iv"` // Reserved if needed
}
type bindDouyinPhoneResponse struct {
Success bool `json:"success"`
Mobile string `json:"mobile"`
}
// DouyinBindPhone 抖音绑定手机号
// @Summary 抖音绑定手机号
// @Description 使用抖音手机号 code 换取手机号并绑定到指定用户
// @Tags APP端.用户
// @Accept json
// @Produce json
// @Param user_id path integer true "用户ID"
// @Security LoginVerifyToken
// @Param RequestBody body bindDouyinPhoneRequest true "请求参数"
// @Success 200 {object} bindDouyinPhoneResponse
// @Failure 400 {object} code.Failure
// @Router /api/app/users/{user_id}/douyin/phone/bind [post]
func (h *handler) DouyinBindPhone() core.HandlerFunc {
return func(ctx core.Context) {
req := new(bindDouyinPhoneRequest)
rsp := new(bindDouyinPhoneResponse)
if err := ctx.ShouldBindJSON(req); err != nil {
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, validation.Error(err)))
return
}
userID := int64(ctx.SessionUserInfo().Id)
if userID <= 0 || req.Code == "" {
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, "缺少必要参数"))
return
}
// 获取 Access Token
accessToken, err := douyin.GetAccessToken(ctx.RequestContext())
if err != nil {
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ServerError, "获取Access Token失败: "+err.Error()))
return
}
// 获取 AppID
dynamicCfg := sysconfig.GetGlobalDynamicConfig()
douyinCfg := dynamicCfg.GetDouyin(ctx.RequestContext())
appID := douyinCfg.AppID
if appID == "" {
// Fallback to static config if dynamic not available or empty (though GetAccessToken checked it)
appID = configs.Get().Douyin.AppID
}
// 获取手机号
mobile, err := douyin.GetPhoneNumber(ctx.RequestContext(), accessToken, appID, req.Code)
if err != nil {
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, "获取手机号失败: "+err.Error()))
return
}
if mobile == "" {
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, "获取到的手机号为空"))
return
}
// 检查该手机号是否已被其他账号占用
existedUser, _ := h.readDB.Users.WithContext(ctx.RequestContext()).Where(h.readDB.Users.Mobile.Eq(mobile)).First()
if existedUser != nil {
if existedUser.ID != userID {
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, "该手机号已被其他账号占用"))
return
}
// 如果是当前用户自己,直接返回成功
rsp.Success = true
rsp.Mobile = mobile
ctx.Payload(rsp)
return
}
// 检查当前用户是否已有手机号
currentUser, _ := h.readDB.Users.WithContext(ctx.RequestContext()).Where(h.readDB.Users.ID.Eq(userID)).First()
if currentUser != nil && currentUser.Mobile != "" {
if currentUser.Mobile == mobile {
rsp.Success = true
rsp.Mobile = mobile
ctx.Payload(rsp)
return
}
// 如果已有手机号且不一致,允许覆盖更新(或者可以根据需求改为提示已绑定过)
}
// 更新
if _, err := h.writeDB.Users.WithContext(ctx.RequestContext()).Where(h.writeDB.Users.ID.Eq(userID)).Updates(map[string]any{"mobile": mobile}); err != nil {
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ServerError, err.Error()))
return
}
rsp.Success = true
rsp.Mobile = mobile
ctx.Payload(rsp)
}
}

View File

@ -1,13 +1,54 @@
package app package app
import ( import (
"net/http" "net/http"
"bindbox-game/internal/code" "bindbox-game/internal/code"
"bindbox-game/internal/pkg/core" "bindbox-game/internal/pkg/core"
"bindbox-game/internal/pkg/validation" "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 { type modifyUserRequest struct {
Nickname *string `json:"nickname"` Nickname *string `json:"nickname"`
Avatar *string `json:"avatar"` Avatar *string `json:"avatar"`
@ -18,6 +59,8 @@ type userItem struct {
Avatar string `json:"avatar"` Avatar string `json:"avatar"`
InviteCode string `json:"invite_code"` InviteCode string `json:"invite_code"`
InviterID int64 `json:"inviter_id"` InviterID int64 `json:"inviter_id"`
Mobile string `json:"mobile"`
Balance int64 `json:"balance"` // Points
} }
type modifyUserResponse struct { type modifyUserResponse struct {
User userItem `json:"user"` User userItem `json:"user"`
@ -29,8 +72,8 @@ type modifyUserResponse struct {
// @Tags APP端.用户 // @Tags APP端.用户
// @Accept json // @Accept json
// @Produce json // @Produce json
// @Param user_id path integer true "用户ID" // @Param user_id path integer true "用户ID"
// @Security LoginVerifyToken // @Security LoginVerifyToken
// @Param RequestBody body modifyUserRequest true "请求参数" // @Param RequestBody body modifyUserRequest true "请求参数"
// @Success 200 {object} modifyUserResponse // @Success 200 {object} modifyUserResponse
// @Failure 400 {object} code.Failure // @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))) ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, validation.Error(err)))
return return
} }
userID := int64(ctx.SessionUserInfo().Id) userID := int64(ctx.SessionUserInfo().Id)
item, err := h.user.UpdateProfile(ctx.RequestContext(), userID, req.Nickname, req.Avatar) item, err := h.user.UpdateProfile(ctx.RequestContext(), userID, req.Nickname, req.Avatar)
if err != nil { if err != nil {
ctx.AbortWithError(core.Error(http.StatusBadRequest, 10001, err.Error())) ctx.AbortWithError(core.Error(http.StatusBadRequest, 10001, err.Error()))
return 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) ctx.Payload(rsp)
} }
} }

View File

@ -120,6 +120,13 @@ func (h *handler) SmsLogin() core.HandlerFunc {
rsp.OpenID = u.Openid rsp.OpenID = u.Openid
rsp.IsNewUser = out.IsNewUser 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("短信登录返回数据", h.logger.Info("短信登录返回数据",
zap.Int64("user_id", u.ID), zap.Int64("user_id", u.ID),
zap.String("mobile", u.Mobile), zap.String("mobile", u.Mobile),

View File

@ -6,6 +6,7 @@ import (
"crypto/cipher" "crypto/cipher"
"crypto/rand" "crypto/rand"
"encoding/base64" "encoding/base64"
"fmt"
) )
// Encrypt 加密算法 // Encrypt 加密算法
@ -45,10 +46,18 @@ func Decrypt(key, ciphertext string) (string, error) {
return "", err 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 块 // 创建一个 AES 块
block, err := aes.NewCipher([]byte(key)) block, err := aes.NewCipher([]byte(key))
if err != nil { if err != nil {
panic(err) return "", err
} }
// 提取 IV // 提取 IV
@ -56,6 +65,9 @@ func Decrypt(key, ciphertext string) (string, error) {
// 提取密文 // 提取密文
ciphertextByteWithoutIV := ciphertextByte[aes.BlockSize:] ciphertextByteWithoutIV := ciphertextByte[aes.BlockSize:]
if len(ciphertextByteWithoutIV) == 0 {
return "", fmt.Errorf("ciphertext empty")
}
// 创建一个 CBC 模式的 AES 解密器 // 创建一个 CBC 模式的 AES 解密器
mode := cipher.NewCBCDecrypter(block, iv) mode := cipher.NewCBCDecrypter(block, iv)
@ -66,6 +78,9 @@ func Decrypt(key, ciphertext string) (string, error) {
// 去除填充字节 // 去除填充字节
padding := int(decrypted[len(decrypted)-1]) 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] decrypted = decrypted[:len(decrypted)-padding]
return string(decrypted), nil return string(decrypted), nil

View File

@ -0,0 +1,91 @@
package douyin
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"time"
"bindbox-game/internal/service/sysconfig"
)
const (
// AccessTokenURL Douyin access_token interface address
AccessTokenURL = "https://developer.toutiao.com/api/apps/v2/token"
)
// AccessTokenRequest request parameters
type AccessTokenRequest struct {
AppID string `json:"appid"`
Secret string `json:"secret"`
GrantType string `json:"grant_type"`
}
// AccessTokenResponse response
type AccessTokenResponse struct {
ErrNo int `json:"err_no"`
ErrTips string `json:"err_tips"`
Data struct {
AccessToken string `json:"access_token"`
ExpiresIn int `json:"expires_in"`
} `json:"data"`
}
// GetAccessToken calls Douyin interface to get access_token
func GetAccessToken(ctx context.Context) (string, error) {
// Get Douyin config from dynamic config
dynamicCfg := sysconfig.GetGlobalDynamicConfig()
if dynamicCfg == nil {
return "", fmt.Errorf("dynamic config service not initialized")
}
douyinCfg := dynamicCfg.GetDouyin(ctx)
if douyinCfg.AppID == "" || douyinCfg.AppSecret == "" {
return "", fmt.Errorf("Douyin mini program config incomplete")
}
reqBody := AccessTokenRequest{
AppID: douyinCfg.AppID,
Secret: douyinCfg.AppSecret,
GrantType: "client_credential",
}
jsonBody, err := json.Marshal(reqBody)
if err != nil {
return "", fmt.Errorf("failed to marshal request: %w", err)
}
// Send request
httpClient := &http.Client{Timeout: 10 * time.Second}
req, err := http.NewRequestWithContext(ctx, "POST", AccessTokenURL, strings.NewReader(string(jsonBody)))
if err != nil {
return "", fmt.Errorf("failed to create request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
resp, err := httpClient.Do(req)
if err != nil {
return "", fmt.Errorf("failed to request Douyin interface: %w", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return "", fmt.Errorf("failed to read response: %w", err)
}
// Parse response
var result AccessTokenResponse
if err := json.Unmarshal(body, &result); err != nil {
return "", fmt.Errorf("failed to parse response: %w, body: %s", err, string(body))
}
if result.ErrNo != 0 {
return "", fmt.Errorf("failed to get access_token: %s (code: %d)", result.ErrTips, result.ErrNo)
}
return result.Data.AccessToken, nil
}

View File

@ -0,0 +1,97 @@
package douyin
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"strings"
"time"
"bindbox-game/internal/service/sysconfig"
)
const (
// 抖音 code2session 接口地址
Code2SessionURL = "https://developer.toutiao.com/api/apps/v2/jscode2session"
)
// Code2SessionRequest 抖音登录请求参数
type Code2SessionRequest struct {
AppID string `json:"appid"`
Secret string `json:"secret"`
Code string `json:"code,omitempty"`
AnonymousCode string `json:"anonymous_code,omitempty"`
}
// Code2SessionResponse 抖音登录响应
type Code2SessionResponse struct {
ErrNo int `json:"err_no"`
ErrTips string `json:"err_tips"`
Data struct {
SessionKey string `json:"session_key"`
OpenID string `json:"openid"`
AnonymousOpenID string `json:"anonymous_openid"`
UnionID string `json:"unionid"`
} `json:"data"`
}
// Code2Session 调用抖音 code2session 接口获取用户信息
func Code2Session(ctx context.Context, code, anonymousCode string) (*Code2SessionResponse, error) {
// 从动态配置获取抖音配置
dynamicCfg := sysconfig.GetGlobalDynamicConfig()
if dynamicCfg == nil {
return nil, errors.New("动态配置服务未初始化")
}
douyinCfg := dynamicCfg.GetDouyin(ctx)
if douyinCfg.AppID == "" || douyinCfg.AppSecret == "" {
return nil, errors.New("抖音小程序配置不完整,请在系统配置中设置 AppID 和 AppSecret")
}
// 构建请求
reqBody := Code2SessionRequest{
AppID: douyinCfg.AppID,
Secret: douyinCfg.AppSecret,
Code: code,
AnonymousCode: anonymousCode,
}
jsonBody, err := json.Marshal(reqBody)
if err != nil {
return nil, fmt.Errorf("序列化请求失败: %w", err)
}
// 发送请求
httpClient := &http.Client{Timeout: 10 * time.Second}
req, err := http.NewRequestWithContext(ctx, "POST", Code2SessionURL, strings.NewReader(string(jsonBody)))
if err != nil {
return nil, fmt.Errorf("创建请求失败: %w", err)
}
req.Header.Set("Content-Type", "application/json")
resp, err := httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("请求抖音接口失败: %w", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("读取响应失败: %w", err)
}
// 解析响应
var result Code2SessionResponse
if err := json.Unmarshal(body, &result); err != nil {
return nil, fmt.Errorf("解析响应失败: %w, body: %s", err, string(body))
}
if result.ErrNo != 0 {
return nil, fmt.Errorf("抖音登录失败: %s (code: %d)", result.ErrTips, result.ErrNo)
}
return &result, nil
}

View File

@ -0,0 +1,94 @@
package douyin
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"time"
)
const (
// PhoneNumberURL Douyin get phone number interface address
PhoneNumberURL = "https://developer.toutiao.com/api/apps/v1/number_get/bind/"
)
// PhoneNumberRequest request parameters
type PhoneNumberRequest struct {
AppID string `json:"app_id"` // Note: specific parameter name might vary, but usually it's app_id or appid. Docs say `app_id` for some v1 interfaces.
Code string `json:"code"`
}
// PhoneNumberResponse response
type PhoneNumberResponse struct {
ErrNo int `json:"err_no"`
ErrTips string `json:"err_tips"`
Data struct {
PhoneNumber string `json:"phone_number"`
} `json:"data"`
}
// GetPhoneNumber calls Douyin interface to get user phone number
func GetPhoneNumber(ctx context.Context, accessToken, appID, code string) (string, error) {
if accessToken == "" || code == "" {
return "", fmt.Errorf("missing parameters")
}
// Note: Verify the correct endpoint and parameters.
// Commonly for `v1/number_get/bind/`, it might expect `encryptedData`?
// But `tt.getPhoneNumber` provides `code`.
// Let's assume the modern Code-based API which is often similar to `number_get/bind` but with `code`.
// Check if we need to call `https://developer.toutiao.com/api/apps/v1/user/get_phone_number_v1` instead?
// Or `https://developer.open-douyin.com/api/apps/v1/img/get_phone_number_v1`?
// Since I cannot verify the exact URL visually, I will stick to the one I hypothesized or the most "standard" one found in similar Go SDKs.
// Actually, let's try to assume it's `POST /api/apps/v2/jscode2session` style but for phone.
// A common variation for "get phone number by code" in Douyin is sending `code` and `app_id` to an endpoint.
// Let's go with the one stated in many online resources for "Douyin Mini Program Get Phone Number".
reqBody := map[string]string{
"app_id": appID,
"code": code,
}
jsonBody, err := json.Marshal(reqBody)
if err != nil {
return "", fmt.Errorf("failed to marshal request: %w", err)
}
httpClient := &http.Client{Timeout: 10 * time.Second}
// The URL might need `access_token` in query param or header?
// Most `v1` Douyin APIs require `access_token` in header or query. We will put in header `access-token`.
req, err := http.NewRequestWithContext(ctx, "POST", PhoneNumberURL, strings.NewReader(string(jsonBody)))
if err != nil {
return "", fmt.Errorf("failed to create request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
// Some docs say `access-token` in header
req.Header.Set("access-token", accessToken)
resp, err := httpClient.Do(req)
if err != nil {
return "", fmt.Errorf("failed to request Douyin interface: %w", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return "", fmt.Errorf("failed to read response: %w", err)
}
var result PhoneNumberResponse
if err := json.Unmarshal(body, &result); err != nil {
return "", fmt.Errorf("failed to parse response: %w, body: %s", err, string(body))
}
if result.ErrNo != 0 {
return "", fmt.Errorf("failed to get phone number: %s (code: %d)", result.ErrTips, result.ErrNo)
}
return result.Data.PhoneNumber, nil
}

View File

@ -204,6 +204,16 @@ func UploadVirtualShippingForBackground(ctx context.Context, config *WechatConfi
if !isOrderNotFoundError(err) { if !isOrderNotFoundError(err) {
return 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 == "" { if outTradeNo == "" {
return err return err
} }
@ -217,6 +227,17 @@ func UploadVirtualShippingForBackground(ctx context.Context, config *WechatConfi
if c.WechatPay.MchID != "" { if c.WechatPay.MchID != "" {
mchID = 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()) return uploadVirtualShippingInternalBackground(ctx, accessToken, orderKey{OrderNumberType: 1, MchID: mchID, OutTradeNo: outTradeNo}, payerOpenid, itemDesc, time.Now())
} }

View File

@ -63,10 +63,15 @@ func NewHTTPMux(logger logger.CustomLogger, db mysql.Repo) (core.Mux, func(), er
// 实例化拦截器 // 实例化拦截器
adminHandler := admin.New(logger, db, rdb) 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) 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) commonHandler := commonapi.New(logger, db)
payHandler := payapi.New(logger, db, taskSvc, activitySvc) payHandler := payapi.New(logger, db, taskSvc, activitySvc)
gameHandler := gameapi.New(logger, db, rdb, userSvc) 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/weixin/login", userHandler.WeixinLogin())
appPublicApiRouter.POST("/users/douyin/login", userHandler.DouyinLogin())
appPublicApiRouter.POST("/address-share/submit", userHandler.SubmitAddressShare()) appPublicApiRouter.POST("/address-share/submit", userHandler.SubmitAddressShare())
appPublicApiRouter.GET("/matching/card_types", activityHandler.ListMatchingCardTypes()) 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 := mux.Group("/api/app", core.WrapAuthHandler(intc.AppTokenAuthVerify))
{ {
appAuthApiRouter.PUT("/users/:user_id", userHandler.ModifyUser()) 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/orders", userHandler.ListUserOrders())
appAuthApiRouter.GET("/users/:user_id/coupons", userHandler.ListUserCoupons()) appAuthApiRouter.GET("/users/:user_id/coupons", userHandler.ListUserCoupons())
appAuthApiRouter.GET("/users/:user_id/coupons/stats", userHandler.GetUserCouponStats()) 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/points/balance", userHandler.GetUserPointsBalance())
appAuthApiRouter.GET("/users/:user_id/stats", userHandler.GetUserStats()) appAuthApiRouter.GET("/users/:user_id/stats", userHandler.GetUserStats())
appAuthApiRouter.POST("/users/:user_id/phone/bind", userHandler.BindPhone()) 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/invites", userHandler.ListUserInvites())
appAuthApiRouter.GET("/users/:user_id/inventory", userHandler.ListUserInventory()) appAuthApiRouter.GET("/users/:user_id/inventory", userHandler.ListUserInventory())
appAuthApiRouter.GET("/users/:user_id/shipments", userHandler.ListUserShipments()) appAuthApiRouter.GET("/users/:user_id/shipments", userHandler.ListUserShipments())

View File

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

View File

@ -63,10 +63,14 @@ func (s *gameTokenService) GenerateToken(ctx context.Context, userID int64, user
// 3. Store ticket in Redis (for single-use validation) // 3. Store ticket in Redis (for single-use validation)
ticketKey := fmt.Sprintf("game:token:ticket:%s", ticket) 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 { 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 // 4. Generate JWT token
expiresAt = time.Now().Add(10 * time.Minute) expiresAt = time.Now().Add(10 * time.Minute)
claims := GameTokenClaims{ claims := GameTokenClaims{
@ -104,6 +108,7 @@ func (s *gameTokenService) ValidateToken(ctx context.Context, tokenString string
}) })
if err != nil { if err != nil {
s.logger.Warn("Token JWT validation failed", zap.Error(err))
return nil, fmt.Errorf("invalid token: %w", 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) ticketKey := fmt.Sprintf("game:token:ticket:%s", claims.Ticket)
storedUserID, err := s.redis.Get(ctx, ticketKey).Result() storedUserID, err := s.redis.Get(ctx, ticketKey).Result()
if err != nil { 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") return nil, fmt.Errorf("ticket not found or expired")
} }
if storedUserID != fmt.Sprintf("%d", claims.UserID) { 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") return nil, fmt.Errorf("ticket user mismatch")
} }
s.logger.Info("DEBUG: Token validated successfully", zap.String("ticket", claims.Ticket))
return claims, nil return claims, nil
} }
// InvalidateTicket marks a ticket as used // InvalidateTicket marks a ticket as used
func (s *gameTokenService) InvalidateTicket(ctx context.Context, ticket string) error { func (s *gameTokenService) InvalidateTicket(ctx context.Context, ticket string) error {
ticketKey := fmt.Sprintf("game:token:ticket:%s", ticket) 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() return s.redis.Del(ctx, ticketKey).Err()
} }

View File

@ -9,6 +9,8 @@ import (
"strings" "strings"
"sync" "sync"
"time" "time"
"go.uber.org/zap"
) )
// 敏感配置 Key 后缀列表,这些 Key 的值需要加密存储 // 敏感配置 Key 后缀列表,这些 Key 的值需要加密存储
@ -50,6 +52,14 @@ const (
KeyAliyunSMSAccessKeySecret = "aliyun_sms.access_key_secret" KeyAliyunSMSAccessKeySecret = "aliyun_sms.access_key_secret"
KeyAliyunSMSSignName = "aliyun_sms.sign_name" KeyAliyunSMSSignName = "aliyun_sms.sign_name"
KeyAliyunSMSTemplateCode = "aliyun_sms.template_code" 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 配置结构 // COSConfig COS 配置结构
@ -87,6 +97,16 @@ type AliyunSMSConfig struct {
TemplateCode string TemplateCode string
} }
// DouyinConfig 抖音小程序配置结构
type DouyinConfig struct {
AppID string
AppSecret string
NotifyURL string
PayAppID string // 支付应用ID (担保支付)
PaySecret string // 支付密钥
PaySalt string // 支付盐
}
// DynamicConfig 动态配置服务 // DynamicConfig 动态配置服务
type DynamicConfig struct { type DynamicConfig struct {
cache sync.Map // key -> string value cache sync.Map // key -> string value
@ -118,8 +138,8 @@ func NewDynamicConfig(l logger.CustomLogger, db mysql.Repo) *DynamicConfig {
} }
} }
// isSensitiveKey 判断是否为敏感配置 Key // IsSensitiveKey 判断是否为敏感配置 Key
func isSensitiveKey(key string) bool { func IsSensitiveKey(key string) bool {
for _, suffix := range sensitiveKeySuffixes { for _, suffix := range sensitiveKeySuffixes {
if strings.HasSuffix(key, suffix) { if strings.HasSuffix(key, suffix) {
return true return true
@ -148,13 +168,13 @@ func (d *DynamicConfig) LoadAll(ctx context.Context) error {
for _, item := range items { for _, item := range items {
value := item.ConfigValue value := item.ConfigValue
// 敏感配置需要解密 // 敏感配置需要解密
if isSensitiveKey(item.ConfigKey) && value != "" { if IsSensitiveKey(item.ConfigKey) && value != "" {
if decrypted, err := d.decryptValue(value); err == nil { if decrypted, err := d.decryptValue(value); err == nil {
value = decrypted value = decrypted
} else { } else {
d.logger.Error("解密配置失败", d.logger.Error("解密配置失败",
"key", item.ConfigKey, zap.String("key", item.ConfigKey),
"error", err) zap.Error(err))
// 解密失败,尝试使用原始值(可能未加密) // 解密失败,尝试使用原始值(可能未加密)
} }
} }
@ -165,7 +185,7 @@ func (d *DynamicConfig) LoadAll(ctx context.Context) error {
d.loadedAt = time.Now() d.loadedAt = time.Now()
d.mu.Unlock() d.mu.Unlock()
d.logger.Info("动态配置加载完成", "count", len(items)) d.logger.Info("动态配置加载完成", zap.Int("count", len(items)))
return nil return nil
} }
@ -193,7 +213,7 @@ func (d *DynamicConfig) Get(ctx context.Context, key string) string {
if err == nil && cfg != nil { if err == nil && cfg != nil {
value := cfg.ConfigValue value := cfg.ConfigValue
// 敏感配置需要解密 // 敏感配置需要解密
if isSensitiveKey(key) && value != "" { if IsSensitiveKey(key) && value != "" {
if decrypted, err := d.decryptValue(value); err == nil { if decrypted, err := d.decryptValue(value); err == nil {
value = decrypted 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 { func (d *DynamicConfig) Set(ctx context.Context, key, value, remark string) error {
storeValue := value storeValue := value
// 敏感配置需要加密 // 敏感配置需要加密
if isSensitiveKey(key) && value != "" { if IsSensitiveKey(key) && value != "" {
encrypted, err := d.encryptValue(value) encrypted, err := d.encryptValue(value)
if err != nil { if err != nil {
return err return err
@ -282,3 +302,15 @@ func (d *DynamicConfig) GetAliyunSMS(ctx context.Context) AliyunSMSConfig {
TemplateCode: d.GetWithFallback(ctx, KeyAliyunSMSTemplateCode, staticCfg.TemplateCode), 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),
}
}

View File

@ -0,0 +1,40 @@
package sysconfig
import (
"bindbox-game/internal/pkg/logger"
"bindbox-game/internal/repository/mysql"
"context"
"sync"
)
var (
globalDynamicConfig *DynamicConfig
initOnce sync.Once
)
// InitGlobalDynamicConfig 初始化全局动态配置实例
// 应在 main.go 中数据库初始化后调用
func InitGlobalDynamicConfig(l logger.CustomLogger, db mysql.Repo) error {
var initErr error
initOnce.Do(func() {
globalDynamicConfig = NewDynamicConfig(l, db)
// 预加载所有配置
initErr = globalDynamicConfig.LoadAll(context.Background())
})
return initErr
}
// GetGlobalDynamicConfig 获取全局动态配置实例
// 如果未初始化则返回 nil
func GetGlobalDynamicConfig() *DynamicConfig {
return globalDynamicConfig
}
// MustGetGlobalDynamicConfig 获取全局动态配置实例
// 如果未初始化则 panic
func MustGetGlobalDynamicConfig() *DynamicConfig {
if globalDynamicConfig == nil {
panic("动态配置服务未初始化,请先调用 InitGlobalDynamicConfig")
}
return globalDynamicConfig
}

View File

@ -1,4 +0,0 @@
# Task Center Service
This directory will host core business logic for Task Center: rules evaluation, progress tracking, reward issuing orchestration.

View File

@ -4,7 +4,8 @@ const (
// Task Windows // Task Windows
WindowDaily = "daily" WindowDaily = "daily"
WindowWeekly = "weekly" WindowWeekly = "weekly"
WindowInfinite = "infinite" WindowMonthly = "monthly"
WindowLifetime = "lifetime"
// Task Metrics // Task Metrics
MetricFirstOrder = "first_order" MetricFirstOrder = "first_order"

View File

@ -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 { 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 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 { 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") 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 { for _, id := range claimed {
if id == tierID { if id == tierID {
return nil return nil // 已领取,跳过
} }
} }
claimed = append(claimed, tierID) 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) p.ClaimedTiers = datatypes.JSON(b)
return tx.Model(&tcmodel.UserTaskProgress{}).Where("id=?", p.ID).Update("claimed_tiers", p.ClaimedTiers).Error 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) { 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 { if err != nil {
return err 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 amount := ord.ActualAmount
rmk := remark.Parse(ord.Remark) rmk := remark.Parse(ord.Remark)
activityID := rmk.ActivityID activityID := rmk.ActivityID
@ -810,9 +841,14 @@ func (s *service) grantTierRewards(ctx context.Context, taskID int64, tierID int
Points int64 `json:"points"` Points int64 `json:"points"`
} }
_ = json.Unmarshal([]byte(r.RewardPayload), &pl) _ = json.Unmarshal([]byte(r.RewardPayload), &pl)
if pl.Points != 0 { points := pl.Points
s.logger.Info("Granting points reward", zap.Int64("user_id", userID), zap.Int64("points", pl.Points)) // 回退:如果 payload 中没有 points 字段,使用 quantity 字段
err = s.userSvc.AddPoints(ctx, userID, pl.Points, "task_reward", "task_center", nil, nil) 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": case "coupon":
var pl struct { var pl struct {
@ -864,6 +900,7 @@ func (s *service) grantTierRewards(ctx context.Context, taskID int64, tierID int
GameCode string `json:"game_code"` GameCode string `json:"game_code"`
Amount int `json:"amount"` Amount int `json:"amount"`
} }
_ = json.Unmarshal([]byte(r.RewardPayload), &pl)
if pl.GameCode != "" && pl.Amount > 0 { 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)) 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) gameSvc := gamesvc.NewTicketService(s.logger, s.repo)

View File

@ -0,0 +1,631 @@
package taskcenter
import (
"context"
"encoding/json"
"fmt"
"testing"
"time"
tcmodel "bindbox-game/internal/repository/mysql/task_center"
"gorm.io/datatypes"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
)
// ================================
// 配置组合生成器
// ================================
// TaskCombination 表示一种任务配置组合
type TaskCombination struct {
Name string // 任务名称
Metric string // 指标类型
Operator string // 操作符
Threshold int64 // 阈值
Window string // 时间窗口
RewardType string // 奖励类型
}
// GenerateAllCombinations 生成所有有效的任务配置组合
func GenerateAllCombinations() []TaskCombination {
metrics := []struct {
name string
operators []string
threshold int64
}{
{MetricFirstOrder, []string{OperatorEQ}, 1}, // 首单只用 =
{MetricOrderCount, []string{OperatorGTE, OperatorEQ}, 3}, // 订单数量支持 >= 和 =
{MetricOrderAmount, []string{OperatorGTE, OperatorEQ}, 10000}, // 消费金额(单位分 = 100元
{MetricInviteCount, []string{OperatorGTE, OperatorEQ}, 2}, // 邀请人数
}
windows := []string{WindowDaily, WindowWeekly, WindowLifetime}
rewards := []string{RewardTypePoints, RewardTypeCoupon, RewardTypeItemCard, RewardTypeTitle, RewardTypeGameTicket}
var combinations []TaskCombination
idx := 0
for _, m := range metrics {
for _, op := range m.operators {
for _, w := range windows {
for _, r := range rewards {
idx++
combinations = append(combinations, TaskCombination{
Name: fmt.Sprintf("测试任务%d_%s_%s_%s", idx, m.name, w, r),
Metric: m.name,
Operator: op,
Threshold: m.threshold,
Window: w,
RewardType: r,
})
}
}
}
}
return combinations
}
// CreateTestDB 创建内存数据库并初始化表结构
func CreateTestDB(t *testing.T) *gorm.DB {
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
if err != nil {
t.Fatalf("创建数据库失败: %v", err)
}
// 创建任务中心相关表
if err := db.Exec(`CREATE TABLE task_center_tasks (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
description TEXT,
status INTEGER NOT NULL DEFAULT 1,
start_time DATETIME,
end_time DATETIME,
visibility INTEGER NOT NULL DEFAULT 1,
conditions_schema TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
);`).Error; err != nil {
t.Fatalf("创建 task_center_tasks 表失败: %v", err)
}
if err := db.Exec(`CREATE TABLE task_center_task_tiers (
id INTEGER PRIMARY KEY AUTOINCREMENT,
task_id INTEGER NOT NULL,
metric TEXT NOT NULL,
operator TEXT NOT NULL,
threshold INTEGER NOT NULL,
window TEXT NOT NULL,
repeatable INTEGER NOT NULL DEFAULT 1,
priority INTEGER NOT NULL DEFAULT 0,
activity_id INTEGER NOT NULL DEFAULT 0,
extra_params TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
);`).Error; err != nil {
t.Fatalf("创建 task_center_task_tiers 表失败: %v", err)
}
if err := db.Exec(`CREATE TABLE task_center_task_rewards (
id INTEGER PRIMARY KEY AUTOINCREMENT,
task_id INTEGER NOT NULL,
tier_id INTEGER NOT NULL,
reward_type TEXT NOT NULL,
reward_payload TEXT NOT NULL,
quantity INTEGER NOT NULL DEFAULT 1,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
);`).Error; err != nil {
t.Fatalf("创建 task_center_task_rewards 表失败: %v", err)
}
if err := db.Exec(`CREATE TABLE task_center_user_progress (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
task_id INTEGER NOT NULL,
activity_id INTEGER NOT NULL DEFAULT 0,
order_count INTEGER NOT NULL DEFAULT 0,
order_amount INTEGER NOT NULL DEFAULT 0,
invite_count INTEGER NOT NULL DEFAULT 0,
first_order INTEGER NOT NULL DEFAULT 0,
claimed_tiers TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
UNIQUE(user_id, task_id, activity_id)
);`).Error; err != nil {
t.Fatalf("创建 task_center_user_progress 表失败: %v", err)
}
if err := db.Exec(`CREATE TABLE task_center_event_logs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
event_id TEXT,
source_type TEXT,
source_id INTEGER,
user_id INTEGER,
task_id INTEGER,
tier_id INTEGER,
idempotency_key TEXT UNIQUE,
status TEXT,
result TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);`).Error; err != nil {
t.Fatalf("创建 task_center_event_logs 表失败: %v", err)
}
return db
}
// InsertTaskWithTierAndReward 插入一个完整的任务配置(任务+档位+奖励)
func InsertTaskWithTierAndReward(t *testing.T, db *gorm.DB, combo TaskCombination) (taskID, tierID int64) {
// 插入任务
task := &tcmodel.Task{
Name: combo.Name,
Description: fmt.Sprintf("测试 %s + %s + %s + %s", combo.Metric, combo.Operator, combo.Window, combo.RewardType),
Status: 1,
Visibility: 1,
}
if err := db.Create(task).Error; err != nil {
t.Fatalf("插入任务失败: %v", err)
}
// 插入档位
tier := &tcmodel.TaskTier{
TaskID: task.ID,
Metric: combo.Metric,
Operator: combo.Operator,
Threshold: combo.Threshold,
Window: combo.Window,
Priority: 0,
}
if err := db.Create(tier).Error; err != nil {
t.Fatalf("插入档位失败: %v", err)
}
// 生成奖励 payload
payload := generateRewardPayload(combo.RewardType)
reward := &tcmodel.TaskReward{
TaskID: task.ID,
TierID: tier.ID,
RewardType: combo.RewardType,
RewardPayload: datatypes.JSON(payload),
Quantity: 10, // 默认数量
}
if err := db.Create(reward).Error; err != nil {
t.Fatalf("插入奖励失败: %v", err)
}
return task.ID, tier.ID
}
// generateRewardPayload 根据奖励类型生成对应的 JSON payload
func generateRewardPayload(rewardType string) string {
switch rewardType {
case RewardTypePoints:
return `{"points": 100}`
case RewardTypeCoupon:
return `{"coupon_id": 1, "quantity": 1}`
case RewardTypeItemCard:
return `{"card_id": 1, "quantity": 1}`
case RewardTypeTitle:
return `{"title_id": 1}`
case RewardTypeGameTicket:
return `{"game_code": "minesweeper", "amount": 5}`
default:
return `{}`
}
}
// ================================
// 单元测试
// ================================
// TestGenerateAllCombinations 测试配置组合生成器
func TestGenerateAllCombinations(t *testing.T) {
combos := GenerateAllCombinations()
// 预期组合数: (1*1 + 3*2) * 3 * 5 = 7 * 3 * 5 = 105
expectedCount := 105
if len(combos) != expectedCount {
t.Errorf("组合数量不正确: 期望 %d, 实际 %d", expectedCount, len(combos))
}
// 验证每种指标都有覆盖
metricCounts := make(map[string]int)
for _, c := range combos {
metricCounts[c.Metric]++
}
t.Logf("配置组合统计:")
t.Logf(" 总数: %d", len(combos))
for metric, count := range metricCounts {
t.Logf(" %s: %d 种组合", metric, count)
}
}
// TestInsertAllCombinations 测试将所有配置组合插入数据库
func TestInsertAllCombinations(t *testing.T) {
db := CreateTestDB(t)
combos := GenerateAllCombinations()
for _, combo := range combos {
taskID, tierID := InsertTaskWithTierAndReward(t, db, combo)
if taskID == 0 || tierID == 0 {
t.Errorf("插入失败: %s", combo.Name)
}
}
// 验证数据库记录数
var taskCount, tierCount, rewardCount int64
db.Model(&tcmodel.Task{}).Count(&taskCount)
db.Model(&tcmodel.TaskTier{}).Count(&tierCount)
db.Model(&tcmodel.TaskReward{}).Count(&rewardCount)
if taskCount != int64(len(combos)) {
t.Errorf("任务数量不正确: 期望 %d, 实际 %d", len(combos), taskCount)
}
if tierCount != int64(len(combos)) {
t.Errorf("档位数量不正确: 期望 %d, 实际 %d", len(combos), tierCount)
}
if rewardCount != int64(len(combos)) {
t.Errorf("奖励数量不正确: 期望 %d, 实际 %d", len(combos), rewardCount)
}
t.Logf("成功插入 %d 个任务配置组合", taskCount)
}
// TestFirstOrderMetric 测试首单指标
func TestFirstOrderMetric(t *testing.T) {
db := CreateTestDB(t)
combo := TaskCombination{
Name: "首单测试任务",
Metric: MetricFirstOrder,
Operator: OperatorEQ,
Threshold: 1,
Window: WindowLifetime,
RewardType: RewardTypePoints,
}
taskID, tierID := InsertTaskWithTierAndReward(t, db, combo)
// 模拟用户首单进度
userID := int64(1001)
progress := &tcmodel.UserTaskProgress{
UserID: userID,
TaskID: taskID,
OrderCount: 1,
OrderAmount: 10000,
FirstOrder: 1, // 首单标记
ClaimedTiers: datatypes.JSON("[]"),
}
if err := db.Create(progress).Error; err != nil {
t.Fatalf("创建进度失败: %v", err)
}
// 验证进度状态
var p tcmodel.UserTaskProgress
db.Where("user_id = ? AND task_id = ?", userID, taskID).First(&p)
if p.FirstOrder != 1 {
t.Error("首单标记未正确设置")
}
t.Logf("首单指标测试通过: taskID=%d, tierID=%d", taskID, tierID)
}
// TestOrderCountMetric 测试订单数量指标
func TestOrderCountMetric(t *testing.T) {
db := CreateTestDB(t)
combo := TaskCombination{
Name: "订单数量测试任务",
Metric: MetricOrderCount,
Operator: OperatorGTE,
Threshold: 3,
Window: WindowDaily,
RewardType: RewardTypeCoupon,
}
taskID, _ := InsertTaskWithTierAndReward(t, db, combo)
// 模拟用户下单进度
userID := int64(1002)
for i := 1; i <= 5; i++ {
progress := &tcmodel.UserTaskProgress{
UserID: userID,
TaskID: taskID,
OrderCount: int64(i),
ClaimedTiers: datatypes.JSON("[]"),
}
// Upsert 模拟
db.Where("user_id = ? AND task_id = ?", userID, taskID).Assign(progress).FirstOrCreate(progress)
}
var p tcmodel.UserTaskProgress
db.Where("user_id = ? AND task_id = ?", userID, taskID).First(&p)
if p.OrderCount < 3 {
t.Error("订单数量未达到阈值")
}
t.Logf("订单数量指标测试通过: 当前订单数=%d", p.OrderCount)
}
// TestOrderAmountMetric 测试消费金额指标
func TestOrderAmountMetric(t *testing.T) {
db := CreateTestDB(t)
combo := TaskCombination{
Name: "消费金额测试任务",
Metric: MetricOrderAmount,
Operator: OperatorGTE,
Threshold: 10000, // 100元
Window: WindowWeekly,
RewardType: RewardTypeItemCard,
}
taskID, _ := InsertTaskWithTierAndReward(t, db, combo)
// 模拟用户累计消费
userID := int64(1003)
amounts := []int64{3000, 4000, 5000} // 累计 120 元
totalAmount := int64(0)
for _, amt := range amounts {
totalAmount += amt
}
progress := &tcmodel.UserTaskProgress{
UserID: userID,
TaskID: taskID,
OrderAmount: totalAmount,
ClaimedTiers: datatypes.JSON("[]"),
}
db.Create(progress)
var p tcmodel.UserTaskProgress
db.Where("user_id = ? AND task_id = ?", userID, taskID).First(&p)
if p.OrderAmount < 10000 {
t.Error("消费金额未达到阈值")
}
t.Logf("消费金额指标测试通过: 当前消费=%d分", p.OrderAmount)
}
// TestInviteCountMetric 测试邀请人数指标
func TestInviteCountMetric(t *testing.T) {
db := CreateTestDB(t)
combo := TaskCombination{
Name: "邀请人数测试任务",
Metric: MetricInviteCount,
Operator: OperatorGTE,
Threshold: 2,
Window: WindowLifetime,
RewardType: RewardTypeTitle,
}
taskID, _ := InsertTaskWithTierAndReward(t, db, combo)
// 模拟邀请进度
userID := int64(1004)
progress := &tcmodel.UserTaskProgress{
UserID: userID,
TaskID: taskID,
InviteCount: 3,
ClaimedTiers: datatypes.JSON("[]"),
}
db.Create(progress)
var p tcmodel.UserTaskProgress
db.Where("user_id = ? AND task_id = ?", userID, taskID).First(&p)
if p.InviteCount < 2 {
t.Error("邀请人数未达到阈值")
}
t.Logf("邀请人数指标测试通过: 当前邀请数=%d", p.InviteCount)
}
// TestAllRewardTypes 测试所有奖励类型的 payload 解析
func TestAllRewardTypes(t *testing.T) {
rewardTypes := []string{
RewardTypePoints,
RewardTypeCoupon,
RewardTypeItemCard,
RewardTypeTitle,
RewardTypeGameTicket,
}
for _, rt := range rewardTypes {
payload := generateRewardPayload(rt)
var data map[string]interface{}
if err := json.Unmarshal([]byte(payload), &data); err != nil {
t.Errorf("奖励类型 %s 的 payload 解析失败: %v", rt, err)
continue
}
switch rt {
case RewardTypePoints:
if _, ok := data["points"]; !ok {
t.Errorf("points 类型缺少 points 字段")
}
case RewardTypeCoupon:
if _, ok := data["coupon_id"]; !ok {
t.Errorf("coupon 类型缺少 coupon_id 字段")
}
case RewardTypeItemCard:
if _, ok := data["card_id"]; !ok {
t.Errorf("item_card 类型缺少 card_id 字段")
}
case RewardTypeTitle:
if _, ok := data["title_id"]; !ok {
t.Errorf("title 类型缺少 title_id 字段")
}
case RewardTypeGameTicket:
if _, ok := data["game_code"]; !ok {
t.Errorf("game_ticket 类型缺少 game_code 字段")
}
if _, ok := data["amount"]; !ok {
t.Errorf("game_ticket 类型缺少 amount 字段")
}
}
t.Logf("奖励类型 %s 验证通过: %s", rt, payload)
}
}
// TestTimeWindowDaily 测试每日时间窗口重置逻辑
func TestTimeWindowDaily(t *testing.T) {
db := CreateTestDB(t)
combo := TaskCombination{
Name: "每日重置测试任务",
Metric: MetricOrderCount,
Operator: OperatorGTE,
Threshold: 3,
Window: WindowDaily,
RewardType: RewardTypePoints,
}
taskID, _ := InsertTaskWithTierAndReward(t, db, combo)
userID := int64(1005)
// 模拟昨天的进度
yesterday := time.Now().Add(-25 * time.Hour)
progress := &tcmodel.UserTaskProgress{
UserID: userID,
TaskID: taskID,
OrderCount: 5,
ClaimedTiers: datatypes.JSON("[]"),
UpdatedAt: yesterday,
}
db.Create(progress)
// 检查日期判断逻辑
now := time.Now()
y1, m1, d1 := yesterday.Date()
y2, m2, d2 := now.Date()
shouldReset := !(y1 == y2 && m1 == m2 && d1 == d2)
if !shouldReset {
t.Error("每日重置逻辑有误:昨天的进度应该需要重置")
}
t.Logf("时间窗口测试通过: 昨天=%v, 今天=%v, 需要重置=%v", yesterday.Format("2006-01-02"), now.Format("2006-01-02"), shouldReset)
}
// TestOperatorComparison 测试操作符比较逻辑
func TestOperatorComparison(t *testing.T) {
testCases := []struct {
name string
operator string
threshold int64
value int64
expected bool
}{
{"GTE-达到阈值", OperatorGTE, 3, 3, true},
{"GTE-超过阈值", OperatorGTE, 3, 5, true},
{"GTE-未达阈值", OperatorGTE, 3, 2, false},
{"EQ-精确匹配", OperatorEQ, 3, 3, true},
{"EQ-超过不匹配", OperatorEQ, 3, 5, false},
{"EQ-未达不匹配", OperatorEQ, 3, 2, false},
}
for _, tc := range testCases {
var result bool
switch tc.operator {
case OperatorGTE:
result = tc.value >= tc.threshold
case OperatorEQ:
result = tc.value == tc.threshold
}
if result != tc.expected {
t.Errorf("%s: 期望 %v, 实际 %v", tc.name, tc.expected, result)
} else {
t.Logf("%s: 通过", tc.name)
}
}
}
// TestIdempotency 测试幂等性(同一事件不重复处理)
func TestIdempotency(t *testing.T) {
db := CreateTestDB(t)
ctx := context.Background()
_ = ctx // 用于后续扩展
// 插入第一条事件日志
idk := "1001:1:1:order:100"
log1 := &tcmodel.TaskEventLog{
EventID: "evt_001",
SourceType: "order",
SourceID: 100,
UserID: 1001,
TaskID: 1,
TierID: 1,
IdempotencyKey: idk,
Status: "granted",
}
if err := db.Create(log1).Error; err != nil {
t.Fatalf("创建事件日志失败: %v", err)
}
// 尝试插入重复的幂等键(应该失败)
log2 := &tcmodel.TaskEventLog{
EventID: "evt_002",
SourceType: "order",
SourceID: 100,
UserID: 1001,
TaskID: 1,
TierID: 1,
IdempotencyKey: idk, // 相同的幂等键
Status: "granted",
}
err := db.Create(log2).Error
if err == nil {
t.Error("幂等性检查失败:重复的幂等键应该被拒绝")
} else {
t.Logf("幂等性测试通过: 重复记录被正确拒绝")
}
}
// TestClaimedTiersTracking 测试已领取档位追踪
func TestClaimedTiersTracking(t *testing.T) {
db := CreateTestDB(t)
userID := int64(1006)
taskID := int64(1)
// 初始化进度
progress := &tcmodel.UserTaskProgress{
UserID: userID,
TaskID: taskID,
ClaimedTiers: datatypes.JSON("[]"),
}
db.Create(progress)
// 模拟领取档位
claimedTiers := []int64{1, 2, 3}
b, _ := json.Marshal(claimedTiers)
db.Model(&tcmodel.UserTaskProgress{}).
Where("user_id = ? AND task_id = ?", userID, taskID).
Update("claimed_tiers", datatypes.JSON(b))
// 验证领取状态
var p tcmodel.UserTaskProgress
db.Where("user_id = ? AND task_id = ?", userID, taskID).First(&p)
var claimed []int64
json.Unmarshal([]byte(p.ClaimedTiers), &claimed)
if len(claimed) != 3 {
t.Errorf("已领取档位数量不正确: 期望 3, 实际 %d", len(claimed))
}
// 检查是否包含特定档位
tierToCheck := int64(2)
found := false
for _, id := range claimed {
if id == tierToCheck {
found = true
break
}
}
if !found {
t.Errorf("档位 %d 应该已被领取", tierToCheck)
}
t.Logf("已领取档位追踪测试通过: %v", claimed)
}

View File

@ -0,0 +1,65 @@
package user
import (
"bindbox-game/internal/repository/mysql/model"
"context"
"fmt"
"time"
"go.uber.org/zap"
)
// GrantGamePass 发放游戏次数卡(购买)
func (s *service) GrantGamePass(ctx context.Context, userID int64, packageID int64, count int32, orderNo string) error {
s.logger.Info("GrantGamePass: 开始发放次数卡",
zap.Int64("user_id", userID),
zap.Int64("package_id", packageID),
zap.Int32("count", count),
zap.String("order_no", orderNo))
if count <= 0 {
count = 1
}
// 1. 获取套餐信息
pkg, err := s.readDB.GamePassPackages.WithContext(ctx).
Where(s.readDB.GamePassPackages.ID.Eq(packageID)).
First()
if err != nil {
return fmt.Errorf("package not found: %w", err)
}
totalPasses := pkg.PassCount * count
// 2. 构造次数卡记录
now := time.Now()
pass := &model.UserGamePasses{
UserID: userID,
ActivityID: pkg.ActivityID,
Remaining: totalPasses,
TotalGranted: totalPasses,
TotalUsed: 0,
Source: "purchase",
Remark: fmt.Sprintf("订单:%s|套餐:%s|数量:%d", orderNo, pkg.Name, count),
CreatedAt: now,
UpdatedAt: now,
}
if pkg.ValidDays > 0 {
pass.ExpiredAt = now.Add(time.Duration(pkg.ValidDays) * 24 * time.Hour)
}
// 3. 写入数据库
q := s.writeDB.UserGamePasses.WithContext(ctx)
if pass.ExpiredAt.IsZero() {
q = q.Omit(s.writeDB.UserGamePasses.ExpiredAt)
}
if err := q.Create(pass); err != nil {
s.logger.Error("GrantGamePass: 写入数据库失败", zap.Error(err))
return fmt.Errorf("failed to create user game pass: %w", err)
}
s.logger.Info("GrantGamePass: 发放成功", zap.Int64("pass_id", pass.ID))
return nil
}

View File

@ -0,0 +1,199 @@
package user
import (
"bytes"
"context"
"encoding/base64"
"errors"
"image/png"
"bindbox-game/internal/pkg/douyin"
"bindbox-game/internal/repository/mysql/dao"
"bindbox-game/internal/repository/mysql/model"
randomname "github.com/DanPlayer/randomname"
identicon "github.com/issue9/identicon/v2"
"go.uber.org/zap"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
// LoginDouyinInput 抖音登录输入参数
type LoginDouyinInput struct {
Code string // tt.login 获取的 code
AnonymousCode string // 匿名登录 code
Nickname string
AvatarURL string
InviteCode string
ChannelCode string
}
// LoginDouyinOutput 抖音登录输出结果
type LoginDouyinOutput struct {
User *model.Users
IsNewUser bool
InviterID int64
}
// LoginDouyin 抖音小程序登录
func (s *service) LoginDouyin(ctx context.Context, in LoginDouyinInput) (*LoginDouyinOutput, error) {
// 1. 调用抖音 code2session 获取 openid
if in.Code == "" && in.AnonymousCode == "" {
return nil, errors.New("code 或 anonymous_code 不能为空")
}
resp, err := douyin.Code2Session(ctx, in.Code, in.AnonymousCode)
if err != nil {
s.logger.Error("抖音 code2session 失败", zap.Error(err))
return nil, err
}
openID := resp.Data.OpenID
if openID == "" {
openID = resp.Data.AnonymousOpenID
}
if openID == "" {
return nil, errors.New("获取抖音 openid 失败")
}
unionID := resp.Data.UnionID
var u *model.Users
// 事务处理:创建/更新用户 + 处理邀请
var isNewUser bool
var inviterID int64
err = s.writeDB.Transaction(func(tx *dao.Query) error {
var err error
// 2. 先通过 douyin_id 查找用户
u, err = tx.Users.WithContext(ctx).Clauses(clause.Locking{Strength: "UPDATE"}).
Where(tx.Users.DouyinID.Eq(openID)).First()
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
return err
}
// 3. 如果有 unionid尝试通过 unionid 关联到已有的微信用户
if u == nil && unionID != "" {
u, err = tx.Users.WithContext(ctx).Clauses(clause.Locking{Strength: "UPDATE"}).
Where(tx.Users.Unionid.Eq(unionID)).First()
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
return err
}
// 找到微信用户绑定抖音ID
if u != nil && u.DouyinID == "" {
_, _ = tx.Users.WithContext(ctx).Where(tx.Users.ID.Eq(u.ID)).
Updates(map[string]any{"douyin_id": openID})
u.DouyinID = openID
}
}
// 查找渠道ID
var channelID int64
if in.ChannelCode != "" {
ch, _ := s.readDB.Channels.WithContext(ctx).Where(s.readDB.Channels.Code.Eq(in.ChannelCode)).First()
if ch != nil {
channelID = ch.ID
}
}
// 4. 如果用户不存在,创建新用户
isNewUser = false
if u == nil {
isNewUser = true
code := s.generateInviteCode(ctx)
nickname := in.Nickname
if nickname == "" {
nickname = randomname.GenerateName()
}
avatar := in.AvatarURL
if avatar == "" {
seed := openID
if seed == "" {
seed = nickname
}
img := identicon.S2(128).Make([]byte(seed))
var buf bytes.Buffer
_ = png.Encode(&buf, img)
avatar = "data:image/png;base64," + base64.StdEncoding.EncodeToString(buf.Bytes())
}
u = &model.Users{
Nickname: nickname,
Avatar: avatar,
DouyinID: openID,
Unionid: unionID,
InviteCode: code,
Status: 1,
ChannelID: channelID,
}
if err := tx.Users.WithContext(ctx).Create(u); err != nil {
return err
}
} else {
// 更新用户信息
set := map[string]any{}
if in.Nickname != "" && u.Nickname == "" {
set["nickname"] = in.Nickname
}
if in.AvatarURL != "" && u.Avatar == "" {
set["avatar"] = in.AvatarURL
}
if unionID != "" && u.Unionid == "" {
set["unionid"] = unionID
}
if channelID > 0 && u.ChannelID == 0 {
set["channel_id"] = channelID
}
if len(set) > 0 {
if _, err := tx.Users.WithContext(ctx).Where(tx.Users.ID.Eq(u.ID)).Updates(set); err != nil {
return err
}
u, _ = tx.Users.WithContext(ctx).Where(tx.Users.ID.Eq(u.ID)).First()
}
}
// 5. 处理邀请逻辑
// 只有在真正创建新用户记录时才发放邀请奖励,防止多账号切换重复刷奖励
if in.InviteCode != "" && isNewUser {
// 查询邀请人
var inviter model.Users
// First() 返回 (result, error)
inviterResult, err := tx.Users.WithContext(ctx).Where(tx.Users.InviteCode.Eq(in.InviteCode)).First()
if err == nil && inviterResult != nil {
inviter = *inviterResult
// 创建邀请记录
invite := &model.UserInvites{
InviteeID: u.ID, // UserID -> InviteeID
InviterID: inviter.ID,
InviteCode: in.InviteCode,
// Status: 1, // Removed
}
if err := tx.UserInvites.WithContext(ctx).Create(invite); err != nil {
return err
}
// 更新被邀请人的邀请人ID
// UpdateColumn for single column update
if _, err := tx.Users.WithContext(ctx).Where(tx.Users.ID.Eq(u.ID)).
UpdateColumn(tx.Users.InviterID, inviter.ID); err != nil {
return err
}
// 返回邀请人ID以便外层触发任务中心逻辑
inviterID = inviter.ID
s.logger.Info("抖音登录邀请关系建立成功", zap.Int64("user_id", u.ID), zap.Int64("inviter_id", inviter.ID))
}
}
return nil
})
if err != nil {
return nil, err
}
return &LoginDouyinOutput{
User: u,
IsNewUser: isNewUser,
InviterID: inviterID,
}, nil
}

View File

@ -32,7 +32,15 @@ type LoginWeixinInput struct {
ChannelCode string 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 (如果是小程序登录) // 1. 获取 OpenID (如果是小程序登录)
if in.Code != "" { if in.Code != "" {
cfg := configs.Get().Wechat cfg := configs.Get().Wechat
@ -52,6 +60,9 @@ func (s *service) LoginWeixin(ctx context.Context, in LoginWeixinInput) (*model.
} }
var u *model.Users var u *model.Users
var isNewUser bool
var inviterID int64
// 事务处理:创建/更新用户 + 处理邀请 // 事务处理:创建/更新用户 + 处理邀请
err := s.writeDB.Transaction(func(tx *dao.Query) error { err := s.writeDB.Transaction(func(tx *dao.Query) error {
var err error var err error
@ -78,7 +89,9 @@ func (s *service) LoginWeixin(ctx context.Context, in LoginWeixinInput) (*model.
} }
} }
isNewUser = false
if u == nil { if u == nil {
isNewUser = true
code := s.generateInviteCode(ctx) code := s.generateInviteCode(ctx)
nickname := in.Nickname nickname := in.Nickname
if nickname == "" { if nickname == "" {
@ -118,6 +131,11 @@ func (s *service) LoginWeixin(ctx context.Context, in LoginWeixinInput) (*model.
if in.DouyinID != "" { if in.DouyinID != "" {
set["douyin_id"] = 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 { if channelID > 0 {
set["channel_id"] = channelID 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() existed, _ := tx.UserInvites.WithContext(ctx).Where(tx.UserInvites.InviteeID.Eq(u.ID)).First()
if existed == nil { if existed == nil {
inviter, _ := tx.Users.WithContext(ctx).Where(tx.Users.InviteCode.Eq(in.InviteCode)).First() inviter, _ := tx.Users.WithContext(ctx).Where(tx.Users.InviteCode.Eq(in.InviteCode)).First()
if inviter != nil && inviter.ID != u.ID { if inviter != nil && inviter.ID != u.ID {
reward := int64(10) // reward := int64(10) // Removed hardcoded reward as per instruction
inv := &model.UserInvites{InviterID: inviter.ID, InviteeID: u.ID, InviteCode: in.InviteCode, RewardPoints: reward, RewardedAt: time.Now()} 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 { if err := tx.UserInvites.WithContext(ctx).Create(inv); err != nil {
return err 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() 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 { 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) do := tx.UserPoints.WithContext(ctx)
if points.ValidEnd.IsZero() { if points.ValidEnd.IsZero() {
do = do.Omit(tx.UserPoints.ValidEnd) do = do.Omit(tx.UserPoints.ValidEnd)
@ -155,14 +174,17 @@ func (s *service) LoginWeixin(ctx context.Context, in LoginWeixinInput) (*model.
return err return err
} }
} else { } 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 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 { if err := tx.UserPointsLedger.WithContext(ctx).Create(ledger); err != nil {
return err 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 { if err != nil {
return nil, err return nil, err
} }
return u, nil
return &LoginWeixinOutput{
User: u,
IsNewUser: isNewUser,
InviterID: inviterID,
}, nil
} }

View File

@ -6,6 +6,12 @@ import (
"bindbox-game/internal/repository/mysql/model" "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) { 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)) updater := s.writeDB.Users.WithContext(ctx).Where(s.writeDB.Users.ID.Eq(userID))
set := map[string]any{} set := map[string]any{}

View File

@ -55,6 +55,7 @@ type SmsLoginOutput struct {
User *model.Users User *model.Users
Token string Token string
IsNewUser bool 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)) s.logger.Info("短信登录创建新用户", zap.Int64("user_id", user.ID), zap.String("mobile", in.Mobile))
} }
// 处理邀请码逻辑(仅首次登录 // 处理邀请码逻辑(仅在真正的首次账户创建时触发,防止重复领奖
if in.InviteCode != "" && isNewUser { if in.InviteCode != "" && isNewUser {
existed, _ := tx.UserInvites.WithContext(ctx).Where(tx.UserInvites.InviteeID.Eq(user.ID)).First() existed, _ := tx.UserInvites.WithContext(ctx).Where(tx.UserInvites.InviteeID.Eq(user.ID)).First()
if existed == nil { if existed == nil {

View File

@ -11,6 +11,7 @@ import (
type Service interface { type Service interface {
UpdateProfile(ctx context.Context, userID int64, nickname *string, avatar *string) (*model.Users, error) 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) 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) 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) 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) 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) ListPointsLedger(ctx context.Context, userID int64, page, pageSize int) (items []*model.UserPointsLedger, total int64, err error)
GetPointsBalance(ctx context.Context, userID int64) (int64, 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) 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 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 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 SendSmsCode(ctx context.Context, mobile string) error
LoginByCode(ctx context.Context, in SmsLoginInput) (*SmsLoginOutput, 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 { type service struct {

File diff suppressed because it is too large Load Diff

View File

@ -207,6 +207,11 @@ func main() {
syscfgSvc := syscfgsvc.New(customLogger, dbRepo) syscfgSvc := syscfgsvc.New(customLogger, dbRepo)
douyinsvc.StartDouyinOrderSync(customLogger, dbRepo, syscfgSvc) douyinsvc.StartDouyinOrderSync(customLogger, dbRepo, syscfgSvc)
// 初始化全局动态配置服务
if err := syscfgsvc.InitGlobalDynamicConfig(customLogger, dbRepo); err != nil {
customLogger.Warn("动态配置加载失败,将使用静态配置", zap.Error(err))
}
// 优雅关闭 // 优雅关闭
shutdown.Close( shutdown.Close(
func() { func() {

BIN
task_center_test Executable file

Binary file not shown.

BIN
web/.DS_Store vendored

Binary file not shown.