x
This commit is contained in:
parent
029ed489bc
commit
fbdaf77eda
81
internal/api/app/coupon_transfer.go
Normal file
81
internal/api/app/coupon_transfer.go
Normal file
@ -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})
|
||||||
|
}
|
||||||
|
}
|
||||||
60
internal/api/app/product_category.go
Normal file
60
internal/api/app/product_category.go
Normal file
@ -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})
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -12,18 +12,20 @@ const TableNameTaskCenterTaskTiers = "task_center_task_tiers"
|
|||||||
|
|
||||||
// TaskCenterTaskTiers 任务中心-档位配置
|
// TaskCenterTaskTiers 任务中心-档位配置
|
||||||
type TaskCenterTaskTiers struct {
|
type TaskCenterTaskTiers struct {
|
||||||
ID int64 `gorm:"column:id;primaryKey;autoIncrement:true;comment:主键ID" json:"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)
|
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
|
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"` // 比较符:>= 或 ==
|
Operator string `gorm:"column:operator;not null;comment:比较符:>= 或 ==" json:"operator"` // 比较符:>= 或 ==
|
||||||
Threshold int64 `gorm:"column:threshold;not null;comment:阈值(数量或布尔首单)" json:"threshold"` // 阈值(数量或布尔首单)
|
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
|
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是
|
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"` // 匹配优先级(数值越小越先匹配)
|
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"` // 创建时间
|
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"` // 更新时间
|
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"` // 额外参数配置(如消费门槛)
|
ExtraParams string `gorm:"column:extra_params;comment:额外参数配置(如消费门槛)" json:"extra_params"` // 额外参数配置(如消费门槛)
|
||||||
ActivityID int32 `gorm:"column:activity_id;comment:活动ID" json:"activity_id"` // 活动ID
|
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
|
// TableName TaskCenterTaskTiers's table name
|
||||||
|
|||||||
@ -25,18 +25,20 @@ type Task struct {
|
|||||||
func (Task) TableName() string { return "task_center_tasks" }
|
func (Task) TableName() string { return "task_center_tasks" }
|
||||||
|
|
||||||
type TaskTier struct {
|
type TaskTier struct {
|
||||||
ID int64 `gorm:"primaryKey;autoIncrement"`
|
ID int64 `gorm:"primaryKey;autoIncrement"`
|
||||||
TaskID int64 `gorm:"index;not null"`
|
TaskID int64 `gorm:"index;not null"`
|
||||||
Metric string `gorm:"size:32;not null"`
|
Metric string `gorm:"size:32;not null"`
|
||||||
Operator string `gorm:"size:8;not null"`
|
Operator string `gorm:"size:8;not null"`
|
||||||
Threshold int64 `gorm:"not null"`
|
Threshold int64 `gorm:"not null"`
|
||||||
Window string `gorm:"size:32;not null"`
|
Window string `gorm:"size:32;not null"`
|
||||||
Repeatable int32 `gorm:"not null"`
|
Repeatable int32 `gorm:"not null"`
|
||||||
Priority int32 `gorm:"not null"`
|
Priority int32 `gorm:"not null"`
|
||||||
ActivityID int64 `gorm:"not null;default:0;index"` // 关联活动ID,0为全局
|
ActivityID int64 `gorm:"not null;default:0;index"` // 关联活动ID,0为全局
|
||||||
ExtraParams datatypes.JSON `gorm:"type:json"`
|
ExtraParams datatypes.JSON `gorm:"type:json"`
|
||||||
CreatedAt time.Time
|
Quota int32 `gorm:"not null;default:0"` // 总限额,0表示不限
|
||||||
UpdatedAt time.Time
|
ClaimedCount int32 `gorm:"not null;default:0"` // 已领取数
|
||||||
|
CreatedAt time.Time
|
||||||
|
UpdatedAt time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
func (TaskTier) TableName() string { return "task_center_task_tiers" }
|
func (TaskTier) TableName() string { return "task_center_task_tiers" }
|
||||||
|
|||||||
@ -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", userHandler.ListUserCoupons())
|
||||||
appAuthApiRouter.GET("/users/:user_id/coupons/stats", userHandler.GetUserCouponStats())
|
appAuthApiRouter.GET("/users/:user_id/coupons/stats", userHandler.GetUserCouponStats())
|
||||||
appAuthApiRouter.GET("/users/:user_id/coupons/:user_coupon_id/usage", userHandler.ListUserCouponUsage())
|
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", userHandler.ListUserPoints())
|
||||||
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())
|
||||||
@ -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.POST("/orders/:order_id/cancel", userHandler.CancelOrder())
|
||||||
appAuthApiRouter.GET("/products", appapi.NewProduct(logger, db, userSvc).ListProductsForApp())
|
appAuthApiRouter.GET("/products", appapi.NewProduct(logger, db, userSvc).ListProductsForApp())
|
||||||
appAuthApiRouter.GET("/products/:id", appapi.NewProduct(logger, db, userSvc).GetProductDetailForApp())
|
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("/store/items", appapi.NewStore(logger, db, userSvc).ListStoreItemsForApp())
|
||||||
appAuthApiRouter.GET("/lottery/result", activityHandler.LotteryResultByOrder())
|
appAuthApiRouter.GET("/lottery/result", activityHandler.LotteryResultByOrder())
|
||||||
|
|
||||||
|
|||||||
@ -126,15 +126,18 @@ type TaskTierInput struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type TaskTierItem struct {
|
type TaskTierItem struct {
|
||||||
ID int64 `json:"id"`
|
ID int64 `json:"id"`
|
||||||
Metric string `json:"metric"`
|
Metric string `json:"metric"`
|
||||||
Operator string `json:"operator"`
|
Operator string `json:"operator"`
|
||||||
Threshold int64 `json:"threshold"`
|
Threshold int64 `json:"threshold"`
|
||||||
Window string `json:"window"`
|
Window string `json:"window"`
|
||||||
Repeatable int32 `json:"repeatable"`
|
Repeatable int32 `json:"repeatable"`
|
||||||
Priority int32 `json:"priority"`
|
Priority int32 `json:"priority"`
|
||||||
ActivityID int64 `json:"activity_id"`
|
ActivityID int64 `json:"activity_id"`
|
||||||
ExtraParams datatypes.JSON `json:"extra_params"`
|
ExtraParams datatypes.JSON `json:"extra_params"`
|
||||||
|
Quota int32 `json:"quota"` // 总限额,0表示不限
|
||||||
|
ClaimedCount int32 `json:"claimed_count"` // 已领取数
|
||||||
|
Remaining int32 `json:"remaining"` // 剩余可领,-1表示不限
|
||||||
}
|
}
|
||||||
|
|
||||||
type TaskRewardInput struct {
|
type TaskRewardInput struct {
|
||||||
@ -251,7 +254,14 @@ func (s *service) ListTasks(ctx context.Context, in ListTasksInput) (items []Tas
|
|||||||
// 填充 Tiers
|
// 填充 Tiers
|
||||||
out[i].Tiers = make([]TaskTierItem, len(v.Tiers))
|
out[i].Tiers = make([]TaskTierItem, len(v.Tiers))
|
||||||
for j, t := range 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
|
// 填充 Rewards
|
||||||
out[i].Rewards = make([]TaskRewardItem, len(v.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("任务条件未达成,无法领取")
|
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.
|
// IDK logic inside grantTierRewards ensures we don't double grant.
|
||||||
// We use "manual_claim" as source type.
|
// 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.
|
// 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))
|
out := make([]TaskTierItem, len(rows))
|
||||||
for i, v := range 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
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|||||||
99
internal/service/user/coupon_transfer.go
Normal file
99
internal/service/user/coupon_transfer.go
Normal file
@ -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
|
||||||
|
}
|
||||||
@ -82,6 +82,8 @@ type Service interface {
|
|||||||
GrantGamePass(ctx context.Context, userID int64, packageID int64, count int32, orderNo string) error
|
GrantGamePass(ctx context.Context, userID int64, packageID int64, count int32, orderNo string) error
|
||||||
// 邀请人绑定
|
// 邀请人绑定
|
||||||
BindInviter(ctx context.Context, userID int64, in BindInviterInput) (*BindInviterOutput, error)
|
BindInviter(ctx context.Context, userID int64, in BindInviterInput) (*BindInviterOutput, error)
|
||||||
|
// 优惠券转赠
|
||||||
|
TransferCoupon(ctx context.Context, fromUserID, toUserID, userCouponID int64) error
|
||||||
}
|
}
|
||||||
|
|
||||||
type service struct {
|
type service struct {
|
||||||
|
|||||||
6
migrations/20260206_add_task_tier_quota.sql
Normal file
6
migrations/20260206_add_task_tier_quota.sql
Normal file
@ -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;
|
||||||
Loading…
x
Reference in New Issue
Block a user