From fbdaf77eda5ae483952c0b4b57bc852a7b56cac5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=82=B9=E6=96=B9=E6=88=90?= Date: Fri, 6 Feb 2026 16:49:27 +0800 Subject: [PATCH] x --- internal/api/app/coupon_transfer.go | 81 +++++++++++++++ internal/api/app/product_category.go | 60 +++++++++++ .../mysql/model/task_center_task_tiers.gen.go | 26 ++--- .../repository/mysql/task_center/models.go | 26 ++--- internal/router/router.go | 2 + internal/service/task_center/service.go | 55 ++++++++--- internal/service/user/coupon_transfer.go | 99 +++++++++++++++++++ internal/service/user/user.go | 2 + migrations/20260206_add_task_tier_quota.sql | 6 ++ 9 files changed, 321 insertions(+), 36 deletions(-) create mode 100644 internal/api/app/coupon_transfer.go create mode 100644 internal/api/app/product_category.go create mode 100644 internal/service/user/coupon_transfer.go create mode 100644 migrations/20260206_add_task_tier_quota.sql diff --git a/internal/api/app/coupon_transfer.go b/internal/api/app/coupon_transfer.go new file mode 100644 index 0000000..b2c6356 --- /dev/null +++ b/internal/api/app/coupon_transfer.go @@ -0,0 +1,81 @@ +package app + +import ( + "net/http" + "strconv" + + "bindbox-game/internal/code" + "bindbox-game/internal/pkg/core" + "bindbox-game/internal/pkg/logger" + "bindbox-game/internal/repository/mysql" + usersvc "bindbox-game/internal/service/user" +) + +type couponTransferHandler struct { + logger logger.CustomLogger + repo mysql.Repo + userSvc usersvc.Service +} + +func NewCouponTransfer(l logger.CustomLogger, db mysql.Repo, userSvc usersvc.Service) *couponTransferHandler { + return &couponTransferHandler{logger: l, repo: db, userSvc: userSvc} +} + +type TransferCouponRequest struct { + ReceiverID int64 `json:"receiver_id" binding:"required"` +} + +// TransferCouponHandler 转赠优惠券 +// @Summary 转赠优惠券给其他用户 +// @Description 将自己持有的优惠券转赠给其他用户 +// @Tags APP端.优惠券 +// @Accept json +// @Produce json +// @Security LoginVerifyToken +// @Param user_id path int true "发送方用户ID" +// @Param user_coupon_id path int true "优惠券记录ID" +// @Param body body TransferCouponRequest true "接收方信息" +// @Success 200 {object} map[string]bool +// @Failure 400 {object} code.Failure +// @Router /api/app/users/{user_id}/coupons/{user_coupon_id}/transfer [post] +func (h *couponTransferHandler) TransferCouponHandler() core.HandlerFunc { + return func(c core.Context) { + userID, err := strconv.ParseInt(c.Param("user_id"), 10, 64) + if err != nil { + c.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, "user_id 无效")) + return + } + + userCouponID, err := strconv.ParseInt(c.Param("user_coupon_id"), 10, 64) + if err != nil { + c.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, "user_coupon_id 无效")) + return + } + + var req TransferCouponRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, "请求参数错误")) + return + } + + if err := h.userSvc.TransferCoupon(c.RequestContext(), userID, req.ReceiverID, userCouponID); err != nil { + switch err.Error() { + case "invalid_params": + c.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, "参数无效")) + case "cannot_transfer_to_self": + c.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, "不能转赠给自己")) + case "coupon_not_found": + c.AbortWithError(core.Error(http.StatusNotFound, code.ServerError, "优惠券不存在")) + case "coupon_not_available": + c.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, "优惠券状态不可用")) + case "receiver_not_found": + c.AbortWithError(core.Error(http.StatusNotFound, code.ServerError, "接收用户不存在")) + default: + c.AbortWithError(core.Error(http.StatusInternalServerError, code.ServerError, err.Error())) + } + return + } + + c.Payload(map[string]any{"success": true}) + } +} diff --git a/internal/api/app/product_category.go b/internal/api/app/product_category.go new file mode 100644 index 0000000..b33bce7 --- /dev/null +++ b/internal/api/app/product_category.go @@ -0,0 +1,60 @@ +package app + +import ( + "net/http" + + "bindbox-game/internal/code" + "bindbox-game/internal/pkg/core" + "bindbox-game/internal/pkg/logger" + "bindbox-game/internal/pkg/validation" + "bindbox-game/internal/repository/mysql" + "bindbox-game/internal/repository/mysql/dao" +) + +type productCategoryHandler struct { + logger logger.CustomLogger + readDB *dao.Query +} + +func NewProductCategory(logger logger.CustomLogger, db mysql.Repo) *productCategoryHandler { + return &productCategoryHandler{logger: logger, readDB: dao.Use(db.GetDbR())} +} + +type productCategoryItem struct { + ID int64 `json:"id"` + Name string `json:"name"` +} + +type listProductCategoriesResponse struct { + Items []productCategoryItem `json:"items"` +} + +// ListProductCategoriesForApp 商品分类列表 +// @Summary 获取商品分类列表 +// @Description 返回所有启用状态的商品分类 +// @Tags APP端.商品 +// @Accept json +// @Produce json +// @Security LoginVerifyToken +// @Success 200 {object} listProductCategoriesResponse +// @Failure 400 {object} code.Failure +// @Router /api/app/product_categories [get] +func (h *productCategoryHandler) ListProductCategoriesForApp() core.HandlerFunc { + return func(ctx core.Context) { + rows, err := h.readDB.ProductCategories.WithContext(ctx.RequestContext()). + Where(h.readDB.ProductCategories.Status.Eq(1)). + Order(h.readDB.ProductCategories.ID.Asc()). + Find() + if err != nil { + ctx.AbortWithError(core.Error(http.StatusInternalServerError, code.ServerError, validation.Error(err))) + return + } + + items := make([]productCategoryItem, len(rows)) + for i, row := range rows { + items[i] = productCategoryItem{ID: row.ID, Name: row.Name} + } + + ctx.Payload(&listProductCategoriesResponse{Items: items}) + } +} diff --git a/internal/repository/mysql/model/task_center_task_tiers.gen.go b/internal/repository/mysql/model/task_center_task_tiers.gen.go index 0995120..ae442dd 100644 --- a/internal/repository/mysql/model/task_center_task_tiers.gen.go +++ b/internal/repository/mysql/model/task_center_task_tiers.gen.go @@ -12,18 +12,20 @@ const TableNameTaskCenterTaskTiers = "task_center_task_tiers" // TaskCenterTaskTiers 任务中心-档位配置 type TaskCenterTaskTiers struct { - ID int64 `gorm:"column:id;primaryKey;autoIncrement:true;comment:主键ID" json:"id"` // 主键ID - TaskID int64 `gorm:"column:task_id;not null;comment:关联任务ID(task_center_tasks.id)" json:"task_id"` // 关联任务ID(task_center_tasks.id) - Metric string `gorm:"column:metric;not null;comment:指标:first_order|order_count|invite_count" json:"metric"` // 指标:first_order|order_count|invite_count - Operator string `gorm:"column:operator;not null;comment:比较符:>= 或 ==" json:"operator"` // 比较符:>= 或 == - Threshold int64 `gorm:"column:threshold;not null;comment:阈值(数量或布尔首单)" json:"threshold"` // 阈值(数量或布尔首单) - Window string `gorm:"column:window;not null;comment:时间窗口:activity_period|since_registration" json:"window"` // 时间窗口:activity_period|since_registration - Repeatable int32 `gorm:"column:repeatable;not null;default:1;comment:是否每档一次:0否 1是" json:"repeatable"` // 是否每档一次:0否 1是 - Priority int32 `gorm:"column:priority;not null;comment:匹配优先级(数值越小越先匹配)" json:"priority"` // 匹配优先级(数值越小越先匹配) - CreatedAt time.Time `gorm:"column:created_at;not null;default:CURRENT_TIMESTAMP;comment:创建时间" json:"created_at"` // 创建时间 - UpdatedAt time.Time `gorm:"column:updated_at;not null;default:CURRENT_TIMESTAMP;comment:更新时间" json:"updated_at"` // 更新时间 - ExtraParams string `gorm:"column:extra_params;comment:额外参数配置(如消费门槛)" json:"extra_params"` // 额外参数配置(如消费门槛) - ActivityID int32 `gorm:"column:activity_id;comment:活动ID" json:"activity_id"` // 活动ID + ID int64 `gorm:"column:id;primaryKey;autoIncrement:true;comment:主键ID" json:"id"` // 主键ID + TaskID int64 `gorm:"column:task_id;not null;comment:关联任务ID(task_center_tasks.id)" json:"task_id"` // 关联任务ID(task_center_tasks.id) + Metric string `gorm:"column:metric;not null;comment:指标:first_order|order_count|invite_count" json:"metric"` // 指标:first_order|order_count|invite_count + Operator string `gorm:"column:operator;not null;comment:比较符:>= 或 ==" json:"operator"` // 比较符:>= 或 == + Threshold int64 `gorm:"column:threshold;not null;comment:阈值(数量或布尔首单)" json:"threshold"` // 阈值(数量或布尔首单) + Window string `gorm:"column:window;not null;comment:时间窗口:activity_period|since_registration" json:"window"` // 时间窗口:activity_period|since_registration + Repeatable int32 `gorm:"column:repeatable;not null;default:1;comment:是否每档一次:0否 1是" json:"repeatable"` // 是否每档一次:0否 1是 + Priority int32 `gorm:"column:priority;not null;comment:匹配优先级(数值越小越先匹配)" json:"priority"` // 匹配优先级(数值越小越先匹配) + CreatedAt time.Time `gorm:"column:created_at;not null;default:CURRENT_TIMESTAMP;comment:创建时间" json:"created_at"` // 创建时间 + UpdatedAt time.Time `gorm:"column:updated_at;not null;default:CURRENT_TIMESTAMP;comment:更新时间" json:"updated_at"` // 更新时间 + ExtraParams string `gorm:"column:extra_params;comment:额外参数配置(如消费门槛)" json:"extra_params"` // 额外参数配置(如消费门槛) + ActivityID int32 `gorm:"column:activity_id;comment:活动ID" json:"activity_id"` // 活动ID + Quota int32 `gorm:"column:quota;default:0;comment:总限额,0表示不限" json:"quota"` // 总限额,0表示不限 + ClaimedCount int32 `gorm:"column:claimed_count;default:0;comment:已领取数" json:"claimed_count"` // 已领取数 } // TableName TaskCenterTaskTiers's table name diff --git a/internal/repository/mysql/task_center/models.go b/internal/repository/mysql/task_center/models.go index eb4cdf7..555b0b7 100644 --- a/internal/repository/mysql/task_center/models.go +++ b/internal/repository/mysql/task_center/models.go @@ -25,18 +25,20 @@ type Task struct { func (Task) TableName() string { return "task_center_tasks" } type TaskTier struct { - ID int64 `gorm:"primaryKey;autoIncrement"` - TaskID int64 `gorm:"index;not null"` - Metric string `gorm:"size:32;not null"` - Operator string `gorm:"size:8;not null"` - Threshold int64 `gorm:"not null"` - Window string `gorm:"size:32;not null"` - Repeatable int32 `gorm:"not null"` - Priority int32 `gorm:"not null"` - ActivityID int64 `gorm:"not null;default:0;index"` // 关联活动ID,0为全局 - ExtraParams datatypes.JSON `gorm:"type:json"` - CreatedAt time.Time - UpdatedAt time.Time + ID int64 `gorm:"primaryKey;autoIncrement"` + TaskID int64 `gorm:"index;not null"` + Metric string `gorm:"size:32;not null"` + Operator string `gorm:"size:8;not null"` + Threshold int64 `gorm:"not null"` + Window string `gorm:"size:32;not null"` + Repeatable int32 `gorm:"not null"` + Priority int32 `gorm:"not null"` + ActivityID int64 `gorm:"not null;default:0;index"` // 关联活动ID,0为全局 + ExtraParams datatypes.JSON `gorm:"type:json"` + Quota int32 `gorm:"not null;default:0"` // 总限额,0表示不限 + ClaimedCount int32 `gorm:"not null;default:0"` // 已领取数 + CreatedAt time.Time + UpdatedAt time.Time } func (TaskTier) TableName() string { return "task_center_task_tiers" } diff --git a/internal/router/router.go b/internal/router/router.go index 0c0fc57..8d27e41 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -457,6 +457,7 @@ func NewHTTPMux(logger logger.CustomLogger, db mysql.Repo) (core.Mux, func(), er appAuthApiRouter.GET("/users/:user_id/coupons", userHandler.ListUserCoupons()) appAuthApiRouter.GET("/users/:user_id/coupons/stats", userHandler.GetUserCouponStats()) appAuthApiRouter.GET("/users/:user_id/coupons/:user_coupon_id/usage", userHandler.ListUserCouponUsage()) + appAuthApiRouter.POST("/users/:user_id/coupons/:user_coupon_id/transfer", appapi.NewCouponTransfer(logger, db, userSvc).TransferCouponHandler()) appAuthApiRouter.GET("/users/:user_id/points", userHandler.ListUserPoints()) appAuthApiRouter.GET("/users/:user_id/points/balance", userHandler.GetUserPointsBalance()) appAuthApiRouter.GET("/users/:user_id/stats", userHandler.GetUserStats()) @@ -486,6 +487,7 @@ func NewHTTPMux(logger logger.CustomLogger, db mysql.Repo) (core.Mux, func(), er appAuthApiRouter.POST("/orders/:order_id/cancel", userHandler.CancelOrder()) appAuthApiRouter.GET("/products", appapi.NewProduct(logger, db, userSvc).ListProductsForApp()) appAuthApiRouter.GET("/products/:id", appapi.NewProduct(logger, db, userSvc).GetProductDetailForApp()) + appAuthApiRouter.GET("/product_categories", appapi.NewProductCategory(logger, db).ListProductCategoriesForApp()) appAuthApiRouter.GET("/store/items", appapi.NewStore(logger, db, userSvc).ListStoreItemsForApp()) appAuthApiRouter.GET("/lottery/result", activityHandler.LotteryResultByOrder()) diff --git a/internal/service/task_center/service.go b/internal/service/task_center/service.go index ad5d3ed..be5fba7 100644 --- a/internal/service/task_center/service.go +++ b/internal/service/task_center/service.go @@ -126,15 +126,18 @@ type TaskTierInput struct { } type TaskTierItem struct { - ID int64 `json:"id"` - Metric string `json:"metric"` - Operator string `json:"operator"` - Threshold int64 `json:"threshold"` - Window string `json:"window"` - Repeatable int32 `json:"repeatable"` - Priority int32 `json:"priority"` - ActivityID int64 `json:"activity_id"` - ExtraParams datatypes.JSON `json:"extra_params"` + ID int64 `json:"id"` + Metric string `json:"metric"` + Operator string `json:"operator"` + Threshold int64 `json:"threshold"` + Window string `json:"window"` + Repeatable int32 `json:"repeatable"` + Priority int32 `json:"priority"` + ActivityID int64 `json:"activity_id"` + ExtraParams datatypes.JSON `json:"extra_params"` + Quota int32 `json:"quota"` // 总限额,0表示不限 + ClaimedCount int32 `json:"claimed_count"` // 已领取数 + Remaining int32 `json:"remaining"` // 剩余可领,-1表示不限 } type TaskRewardInput struct { @@ -251,7 +254,14 @@ func (s *service) ListTasks(ctx context.Context, in ListTasksInput) (items []Tas // 填充 Tiers out[i].Tiers = make([]TaskTierItem, len(v.Tiers)) for j, t := range v.Tiers { - out[i].Tiers[j] = TaskTierItem{ID: t.ID, Metric: t.Metric, Operator: t.Operator, Threshold: t.Threshold, Window: t.Window, Repeatable: t.Repeatable, Priority: t.Priority, ActivityID: t.ActivityID, ExtraParams: t.ExtraParams} + remaining := int32(-1) // -1 表示不限 + if t.Quota > 0 { + remaining = t.Quota - t.ClaimedCount + if remaining < 0 { + remaining = 0 + } + } + out[i].Tiers[j] = TaskTierItem{ID: t.ID, Metric: t.Metric, Operator: t.Operator, Threshold: t.Threshold, Window: t.Window, Repeatable: t.Repeatable, Priority: t.Priority, ActivityID: t.ActivityID, ExtraParams: t.ExtraParams, Quota: t.Quota, ClaimedCount: t.ClaimedCount, Remaining: remaining} } // 填充 Rewards out[i].Rewards = make([]TaskRewardItem, len(v.Rewards)) @@ -442,7 +452,21 @@ func (s *service) ClaimTier(ctx context.Context, userID int64, taskID int64, tie return errors.New("任务条件未达成,无法领取") } - // 1. 先尝试发放奖励 (grantTierRewards 内部有幂等校验) + // 2. 限额校验:如果设置了限额(quota > 0),需要原子性地增加 claimed_count + if tier.Quota > 0 { + result := s.repo.GetDbW().Model(&tcmodel.TaskTier{}). + Where("id = ? AND claimed_count < quota", tierID). + Update("claimed_count", gorm.Expr("claimed_count + 1")) + if result.Error != nil { + return result.Error + } + if result.RowsAffected == 0 { + return errors.New("奖励已领完") + } + s.logger.Info("ClaimTier: Quota check passed", zap.Int64("tier_id", tierID), zap.Int32("quota", tier.Quota)) + } + + // 3. 先尝试发放奖励 (grantTierRewards 内部有幂等校验) // IDK logic inside grantTierRewards ensures we don't double grant. // We use "manual_claim" as source type. // IMPORTANT: Call this BEFORE updating the progress status to avoid "Claimed but not received" state if grant fails. @@ -528,7 +552,14 @@ func (s *service) ListTaskTiers(ctx context.Context, taskID int64) ([]TaskTierIt } out := make([]TaskTierItem, len(rows)) for i, v := range rows { - out[i] = TaskTierItem{ID: v.ID, Metric: v.Metric, Operator: v.Operator, Threshold: v.Threshold, Window: v.Window, Repeatable: v.Repeatable, Priority: v.Priority, ActivityID: v.ActivityID, ExtraParams: v.ExtraParams} + remaining := int32(-1) // -1 表示不限 + if v.Quota > 0 { + remaining = v.Quota - v.ClaimedCount + if remaining < 0 { + remaining = 0 + } + } + out[i] = TaskTierItem{ID: v.ID, Metric: v.Metric, Operator: v.Operator, Threshold: v.Threshold, Window: v.Window, Repeatable: v.Repeatable, Priority: v.Priority, ActivityID: v.ActivityID, ExtraParams: v.ExtraParams, Quota: v.Quota, ClaimedCount: v.ClaimedCount, Remaining: remaining} } return out, nil } diff --git a/internal/service/user/coupon_transfer.go b/internal/service/user/coupon_transfer.go new file mode 100644 index 0000000..2f06dc0 --- /dev/null +++ b/internal/service/user/coupon_transfer.go @@ -0,0 +1,99 @@ +package user + +import ( + "context" + "errors" + "fmt" + "time" + + "bindbox-game/internal/repository/mysql/model" + + "gorm.io/gorm" +) + +// TransferCoupon 转赠优惠券给另一个用户 +// fromUserID: 发送方用户ID +// toUserID: 接收方用户ID +// userCouponID: 用户持有的优惠券记录ID +func (s *service) TransferCoupon(ctx context.Context, fromUserID, toUserID, userCouponID int64) error { + if fromUserID <= 0 || toUserID <= 0 || userCouponID <= 0 { + return fmt.Errorf("invalid_params") + } + if fromUserID == toUserID { + return fmt.Errorf("cannot_transfer_to_self") + } + + // 1. 校验发送方持有该优惠券 + uc, err := s.readDB.UserCoupons.WithContext(ctx). + Where(s.readDB.UserCoupons.ID.Eq(userCouponID)). + Where(s.readDB.UserCoupons.UserID.Eq(fromUserID)). + First() + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return fmt.Errorf("coupon_not_found") + } + return err + } + + // 2. 校验优惠券状态为可用 + if uc.Status != 1 { + return fmt.Errorf("coupon_not_available") + } + + // 3. 校验接收方用户存在 + _, err = s.readDB.Users.WithContext(ctx).Where(s.readDB.Users.ID.Eq(toUserID)).First() + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return fmt.Errorf("receiver_not_found") + } + return err + } + + // 4. 执行转赠:更新 user_id + db := s.repo.GetDbW() + err = db.Transaction(func(tx *gorm.DB) error { + // 更新优惠券归属 + if err := tx.Model(&model.UserCoupons{}). + Where("id = ? AND user_id = ? AND status = 1", userCouponID, fromUserID). + Update("user_id", toUserID).Error; err != nil { + return err + } + + // 记录转出日志 + transferOutLog := &model.UserCouponLedger{ + UserID: fromUserID, + UserCouponID: userCouponID, + ChangeAmount: 0, // 转赠不涉及金额变动 + BalanceAfter: uc.BalanceAmount, + OrderID: 0, + Action: "transfer_out", + CreatedAt: time.Now(), + } + if err := tx.Create(transferOutLog).Error; err != nil { + return err + } + + // 记录转入日志 + transferInLog := &model.UserCouponLedger{ + UserID: toUserID, + UserCouponID: userCouponID, + ChangeAmount: 0, + BalanceAfter: uc.BalanceAmount, + OrderID: 0, + Action: "transfer_in", + CreatedAt: time.Now(), + } + if err := tx.Create(transferInLog).Error; err != nil { + return err + } + + return nil + }) + + if err != nil { + return err + } + + fmt.Printf("[优惠券转赠] 成功: coupon_id=%d from=%d to=%d\n", userCouponID, fromUserID, toUserID) + return nil +} diff --git a/internal/service/user/user.go b/internal/service/user/user.go index 70eb0c6..0fc8ec5 100644 --- a/internal/service/user/user.go +++ b/internal/service/user/user.go @@ -82,6 +82,8 @@ type Service interface { GrantGamePass(ctx context.Context, userID int64, packageID int64, count int32, orderNo string) error // 邀请人绑定 BindInviter(ctx context.Context, userID int64, in BindInviterInput) (*BindInviterOutput, error) + // 优惠券转赠 + TransferCoupon(ctx context.Context, fromUserID, toUserID, userCouponID int64) error } type service struct { diff --git a/migrations/20260206_add_task_tier_quota.sql b/migrations/20260206_add_task_tier_quota.sql new file mode 100644 index 0000000..448b5b4 --- /dev/null +++ b/migrations/20260206_add_task_tier_quota.sql @@ -0,0 +1,6 @@ +-- 任务限量领取功能 - 数据库迁移脚本 +-- 为 task_center_task_tiers 表添加限额字段 + +ALTER TABLE task_center_task_tiers + ADD COLUMN quota INT DEFAULT 0 COMMENT '总限额,0表示不限' AFTER activity_id, + ADD COLUMN claimed_count INT DEFAULT 0 COMMENT '已领取数' AFTER quota;