fix: 修复过期优惠券仍可兑换/使用的漏洞

- store.go: 积分商城优惠券列表加 valid_end > now 过滤
- coupons_list.go: 修复 NULL valid_end 被错误排除,无截止日期券正确显示为有效
- activity_order_service.go: 过期/不可用券下单返回明确错误,不再静默跳过
- points_redeem_coupon_app.go: 积分兑换前校验模板 valid_end
- coupon_add.go: 发券前校验模板 valid_end,过期拒绝发放
This commit is contained in:
Zuncle 2026-03-18 21:58:25 +08:00
parent 9f7a7d29fb
commit 4ffd8e8326
5 changed files with 35 additions and 19 deletions

View File

@ -1,6 +1,8 @@
package app
import (
"time"
"bindbox-game/internal/code"
"bindbox-game/internal/pkg/core"
"bindbox-game/internal/pkg/logger"
@ -120,7 +122,8 @@ func (h *storeHandler) ListStoreItemsForApp() core.HandlerFunc {
list[i] = listStoreItem{ID: it.ID, Kind: "item_card", Name: it.Name, Price: it.Price, PointsRequired: pts, Status: it.Status, Supported: true}
}
case "coupon":
q := h.readDB.SystemCoupons.WithContext(ctx.RequestContext()).ReadDB().Where(h.readDB.SystemCoupons.Status.Eq(1), h.readDB.SystemCoupons.ShowInMiniapp.Eq(1))
now := time.Now()
q := h.readDB.SystemCoupons.WithContext(ctx.RequestContext()).ReadDB().Where(h.readDB.SystemCoupons.Status.Eq(1), h.readDB.SystemCoupons.ShowInMiniapp.Eq(1), h.readDB.SystemCoupons.ValidEnd.Gt(now))
// 关键词筛选
if req.Keyword != "" {
q = q.Where(h.readDB.SystemCoupons.Name.Like("%" + req.Keyword + "%"))

View File

@ -1,11 +1,13 @@
package app
import (
"net/http"
"strconv"
"time"
"bindbox-game/internal/code"
"bindbox-game/internal/pkg/core"
"bindbox-game/internal/pkg/validation"
"net/http"
"strconv"
)
type redeemCouponRequest struct {
@ -52,6 +54,10 @@ func (h *handler) RedeemPointsToCoupon() core.HandlerFunc {
ctx.AbortWithError(core.Error(http.StatusBadRequest, 150002, "only amount coupons supported"))
return
}
if !sc.ValidEnd.IsZero() && sc.ValidEnd.Before(time.Now()) {
ctx.AbortWithError(core.Error(http.StatusBadRequest, 150005, "该优惠券模板已过期,无法兑换"))
return
}
// sc.DiscountValue 是优惠券面值(分),直接用于扣除
// 例如30 元优惠券 = 3000 分
needCents := sc.DiscountValue

View File

@ -229,8 +229,7 @@ func (s *activityOrderService) applyCouponWithLock(ctx context.Context, tx *dao.
// 如果是金额券status=1。
// 如果是满减券status=1。
if uc.Status != 1 {
fmt.Printf("[优惠券] 状态不可用 id=%d status=%d\n", userCouponID, uc.Status)
return 0, nil, nil
return 0, nil, fmt.Errorf("优惠券不可用")
}
fmt.Printf("[优惠券] 用户券ID=%d 开始=%s 结束=%s 余额=%d\n", userCouponID, uc.ValidStart.Format(time.RFC3339), func() string {
@ -243,25 +242,20 @@ func (s *activityOrderService) applyCouponWithLock(ctx context.Context, tx *dao.
sc, _ := tx.SystemCoupons.WithContext(ctx).Where(tx.SystemCoupons.ID.Eq(uc.CouponID), tx.SystemCoupons.Status.Eq(1)).First()
now := time.Now()
if sc == nil {
fmt.Printf("[优惠券] 模板不存在 用户券ID=%d\n", userCouponID)
return 0, nil, nil
return 0, nil, fmt.Errorf("优惠券模板不存在")
}
if uc.ValidStart.After(now) {
fmt.Printf("[优惠券] 未到开始时间 用户券ID=%d\n", userCouponID)
return 0, nil, nil
return 0, nil, fmt.Errorf("优惠券未到使用时间")
}
if !uc.ValidEnd.IsZero() && uc.ValidEnd.Before(now) {
fmt.Printf("[优惠券] 已过期 用户券ID=%d\n", userCouponID)
return 0, nil, nil
return 0, nil, fmt.Errorf("优惠券已过期")
}
scopeOK := (sc.ScopeType == 1) || (sc.ScopeType == 2 && sc.ActivityID == activityID)
if !scopeOK {
fmt.Printf("[优惠券] 范围不匹配 用户券ID=%d scope_type=%d\n", userCouponID, sc.ScopeType)
return 0, nil, nil
return 0, nil, fmt.Errorf("优惠券不适用于当前活动")
}
if order.TotalAmount < sc.MinSpend {
fmt.Printf("[优惠券] 未达使用门槛 用户券ID=%d min_spend(分)=%d 订单总额(分)=%d\n", userCouponID, sc.MinSpend, order.TotalAmount)
return 0, nil, nil
return 0, nil, fmt.Errorf("订单金额未达优惠券使用门槛")
}
// 50% 封顶

View File

@ -26,6 +26,9 @@ func (s *service) AddCoupon(ctx context.Context, userID int64, couponID int64) e
}())
return errors.New("coupon not found or disabled")
}
if !tpl.ValidEnd.IsZero() && tpl.ValidEnd.Before(time.Now()) {
return errors.New("coupon template expired")
}
// 配额检查:若 TotalQuantity > 0 则限制发放总量
if tpl.TotalQuantity > 0 {
issued, ierr := s.readDB.UserCoupons.WithContext(ctx).Where(s.readDB.UserCoupons.CouponID.Eq(couponID)).Count()

View File

@ -53,12 +53,22 @@ func (s *service) ListAppCoupons(ctx context.Context, userID int64, status int32
Where("`"+tableName+"`.user_id = ?", userID)
switch status {
case 1: // 有效:余额 > 0 且 未过期
db = db.Where(tableName+".balance_amount > ? AND "+tableName+".valid_end > ? AND "+tableName+".status IN (?, ?, ?)", 0, now, 1, 2, 4)
case 1: // 有效:余额 > 0 且 未过期NULL/零值 valid_end 视为永久有效)
db = db.Where(
tableName+".balance_amount > ? AND "+
"("+tableName+".valid_end IS NULL OR "+tableName+".valid_end = ? OR "+tableName+".valid_end > ?) AND "+
tableName+".status IN (?, ?, ?)",
0, time.Time{}, now, 1, 2, 4,
)
case 2: // 已失效:余额用完 OR 已标记过期 OR 已过截止时间
db = db.Where("("+tableName+".balance_amount = ?) OR "+tableName+".status = ? OR "+tableName+".valid_end <= ?", 0, 3, now)
db = db.Where("("+tableName+".balance_amount = ?) OR "+tableName+".status = ? OR ("+tableName+".valid_end IS NOT NULL AND "+tableName+".valid_end != ? AND "+tableName+".valid_end <= ?)", 0, 3, time.Time{}, now)
default:
db = db.Where(tableName+".balance_amount > ? AND "+tableName+".valid_end > ? AND "+tableName+".status IN (?, ?, ?)", 0, now, 1, 2, 4)
db = db.Where(
tableName+".balance_amount > ? AND "+
"("+tableName+".valid_end IS NULL OR "+tableName+".valid_end = ? OR "+tableName+".valid_end > ?) AND "+
tableName+".status IN (?, ?, ?)",
0, time.Time{}, now, 1, 2, 4,
)
}
if err = db.Count(&total).Error; err != nil {