This commit is contained in:
邹方成 2026-02-06 16:49:27 +08:00
parent 029ed489bc
commit fbdaf77eda
9 changed files with 321 additions and 36 deletions

View 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})
}
}

View 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})
}
}

View File

@ -24,6 +24,8 @@ type TaskCenterTaskTiers struct {
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

View File

@ -35,6 +35,8 @@ type TaskTier struct {
Priority int32 `gorm:"not null"`
ActivityID int64 `gorm:"not null;default:0;index"` // 关联活动ID0为全局
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
}

View File

@ -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())

View File

@ -135,6 +135,9 @@ type TaskTierItem struct {
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
}

View 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
}

View File

@ -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 {

View 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;