feat: implement coupon management and receive flow
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
package v1
|
||||
|
||||
import (
|
||||
"quyun/v2/app/errorx"
|
||||
"quyun/v2/app/http/v1/dto"
|
||||
"quyun/v2/app/requests"
|
||||
"quyun/v2/app/services"
|
||||
@@ -359,3 +360,103 @@ func (c *Creator) Withdraw(ctx fiber.Ctx, user *models.User, form *dto.WithdrawF
|
||||
tenantID := getTenantID(ctx)
|
||||
return services.Creator.Withdraw(ctx, tenantID, user.ID, form)
|
||||
}
|
||||
|
||||
// Create coupon
|
||||
//
|
||||
// @Router /t/:tenantCode/v1/creator/coupons [post]
|
||||
// @Summary Create coupon
|
||||
// @Description Create coupon template
|
||||
// @Tags CreatorCenter
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param form body dto.CouponCreateForm true "Coupon form"
|
||||
// @Success 200 {object} dto.CouponItem
|
||||
// @Bind user local key(__ctx_user)
|
||||
// @Bind form body
|
||||
func (c *Creator) CreateCoupon(ctx fiber.Ctx, user *models.User, form *dto.CouponCreateForm) (*dto.CouponItem, error) {
|
||||
tenantID := getTenantID(ctx)
|
||||
return services.Coupon.Create(ctx, tenantID, user.ID, form)
|
||||
}
|
||||
|
||||
// List coupons
|
||||
//
|
||||
// @Router /t/:tenantCode/v1/creator/coupons [get]
|
||||
// @Summary List coupons
|
||||
// @Description List coupon templates
|
||||
// @Tags CreatorCenter
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param page query int false "Page"
|
||||
// @Param limit query int false "Limit"
|
||||
// @Param type query string false "Type (fix_amount/discount)"
|
||||
// @Param status query string false "Status (active/expired)"
|
||||
// @Param keyword query string false "Keyword"
|
||||
// @Success 200 {object} requests.Pager
|
||||
// @Bind user local key(__ctx_user)
|
||||
// @Bind filter query
|
||||
func (c *Creator) ListCoupons(ctx fiber.Ctx, user *models.User, filter *dto.CouponListFilter) (*requests.Pager, error) {
|
||||
tenantID := getTenantID(ctx)
|
||||
return services.Coupon.List(ctx, tenantID, filter)
|
||||
}
|
||||
|
||||
// Get coupon
|
||||
//
|
||||
// @Router /t/:tenantCode/v1/creator/coupons/:id<int> [get]
|
||||
// @Summary Get coupon
|
||||
// @Description Get coupon template detail
|
||||
// @Tags CreatorCenter
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param id path int64 true "Coupon ID"
|
||||
// @Success 200 {object} dto.CouponItem
|
||||
// @Bind user local key(__ctx_user)
|
||||
// @Bind id path
|
||||
func (c *Creator) GetCoupon(ctx fiber.Ctx, user *models.User, id int64) (*dto.CouponItem, error) {
|
||||
tenantID := getTenantID(ctx)
|
||||
return services.Coupon.Get(ctx, tenantID, id)
|
||||
}
|
||||
|
||||
// Update coupon
|
||||
//
|
||||
// @Router /t/:tenantCode/v1/creator/coupons/:id<int> [put]
|
||||
// @Summary Update coupon
|
||||
// @Description Update coupon template
|
||||
// @Tags CreatorCenter
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param id path int64 true "Coupon ID"
|
||||
// @Param form body dto.CouponUpdateForm true "Coupon form"
|
||||
// @Success 200 {object} dto.CouponItem
|
||||
// @Bind user local key(__ctx_user)
|
||||
// @Bind id path
|
||||
// @Bind form body
|
||||
func (c *Creator) UpdateCoupon(ctx fiber.Ctx, user *models.User, id int64, form *dto.CouponUpdateForm) (*dto.CouponItem, error) {
|
||||
tenantID := getTenantID(ctx)
|
||||
return services.Coupon.Update(ctx, tenantID, user.ID, id, form)
|
||||
}
|
||||
|
||||
// Grant coupon
|
||||
//
|
||||
// @Router /t/:tenantCode/v1/creator/coupons/:id<int>/grant [post]
|
||||
// @Summary Grant coupon
|
||||
// @Description Grant coupon to users
|
||||
// @Tags CreatorCenter
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param id path int64 true "Coupon ID"
|
||||
// @Param form body dto.CouponGrantForm true "Grant form"
|
||||
// @Success 200 {string} string "Granted"
|
||||
// @Bind user local key(__ctx_user)
|
||||
// @Bind id path
|
||||
// @Bind form body
|
||||
func (c *Creator) GrantCoupon(ctx fiber.Ctx, user *models.User, id int64, form *dto.CouponGrantForm) (string, error) {
|
||||
tenantID := getTenantID(ctx)
|
||||
if form == nil {
|
||||
return "", errorx.ErrInvalidParameter.WithMsg("参数无效")
|
||||
}
|
||||
_, err := services.Coupon.Grant(ctx, tenantID, id, form.UserIDs)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return "Granted", nil
|
||||
}
|
||||
|
||||
@@ -1,5 +1,99 @@
|
||||
package dto
|
||||
|
||||
import "quyun/v2/app/requests"
|
||||
|
||||
type CouponCreateForm struct {
|
||||
// Title 优惠券标题。
|
||||
Title string `json:"title"`
|
||||
// Description 优惠券描述。
|
||||
Description string `json:"description"`
|
||||
// Type 优惠券类型(fix_amount/discount)。
|
||||
Type string `json:"type"`
|
||||
// Value 优惠券面值(分/折扣百分比)。
|
||||
Value int64 `json:"value"`
|
||||
// MinOrderAmount 使用门槛金额(分)。
|
||||
MinOrderAmount int64 `json:"min_order_amount"`
|
||||
// MaxDiscount 折扣券最高抵扣金额(分)。
|
||||
MaxDiscount int64 `json:"max_discount"`
|
||||
// TotalQuantity 发行总量(0 表示不限量)。
|
||||
TotalQuantity int32 `json:"total_quantity"`
|
||||
// StartAt 生效时间(RFC3339,可为空)。
|
||||
StartAt string `json:"start_at"`
|
||||
// EndAt 过期时间(RFC3339,可为空)。
|
||||
EndAt string `json:"end_at"`
|
||||
}
|
||||
|
||||
type CouponUpdateForm struct {
|
||||
// Title 优惠券标题(为空表示不修改)。
|
||||
Title *string `json:"title"`
|
||||
// Description 优惠券描述(为空表示不修改)。
|
||||
Description *string `json:"description"`
|
||||
// Type 优惠券类型(fix_amount/discount)。
|
||||
Type *string `json:"type"`
|
||||
// Value 优惠券面值(分/折扣百分比)。
|
||||
Value *int64 `json:"value"`
|
||||
// MinOrderAmount 使用门槛金额(分)。
|
||||
MinOrderAmount *int64 `json:"min_order_amount"`
|
||||
// MaxDiscount 折扣券最高抵扣金额(分)。
|
||||
MaxDiscount *int64 `json:"max_discount"`
|
||||
// TotalQuantity 发行总量(0 表示不限量)。
|
||||
TotalQuantity *int32 `json:"total_quantity"`
|
||||
// StartAt 生效时间(RFC3339,可为空)。
|
||||
StartAt *string `json:"start_at"`
|
||||
// EndAt 过期时间(RFC3339,可为空)。
|
||||
EndAt *string `json:"end_at"`
|
||||
}
|
||||
|
||||
type CouponItem struct {
|
||||
// ID 券模板ID。
|
||||
ID int64 `json:"id"`
|
||||
// Title 优惠券标题。
|
||||
Title string `json:"title"`
|
||||
// Description 优惠券描述。
|
||||
Description string `json:"description"`
|
||||
// Type 优惠券类型(fix_amount/discount)。
|
||||
Type string `json:"type"`
|
||||
// Value 优惠券面值(分/折扣百分比)。
|
||||
Value int64 `json:"value"`
|
||||
// MinOrderAmount 使用门槛金额(分)。
|
||||
MinOrderAmount int64 `json:"min_order_amount"`
|
||||
// MaxDiscount 折扣券最高抵扣金额(分)。
|
||||
MaxDiscount int64 `json:"max_discount"`
|
||||
// TotalQuantity 发行总量。
|
||||
TotalQuantity int32 `json:"total_quantity"`
|
||||
// UsedQuantity 已使用数量。
|
||||
UsedQuantity int32 `json:"used_quantity"`
|
||||
// StartAt 生效时间(RFC3339)。
|
||||
StartAt string `json:"start_at"`
|
||||
// EndAt 过期时间(RFC3339)。
|
||||
EndAt string `json:"end_at"`
|
||||
// CreatedAt 创建时间(RFC3339)。
|
||||
CreatedAt string `json:"created_at"`
|
||||
// UpdatedAt 更新时间(RFC3339)。
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
}
|
||||
|
||||
type CouponListFilter struct {
|
||||
// Pagination 分页参数(page/limit)。
|
||||
requests.Pagination
|
||||
// Type 优惠券类型过滤。
|
||||
Type *string `query:"type"`
|
||||
// Status 状态过滤(active/expired)。
|
||||
Status *string `query:"status"`
|
||||
// Keyword 关键词搜索(标题/描述)。
|
||||
Keyword *string `query:"keyword"`
|
||||
}
|
||||
|
||||
type CouponReceiveForm struct {
|
||||
// CouponID 券模板ID。
|
||||
CouponID int64 `json:"coupon_id"`
|
||||
}
|
||||
|
||||
type CouponGrantForm struct {
|
||||
// UserIDs 领取用户ID集合。
|
||||
UserIDs []int64 `json:"user_ids"`
|
||||
}
|
||||
|
||||
type UserCouponItem struct {
|
||||
// ID 用户券ID。
|
||||
ID int64 `json:"id"`
|
||||
|
||||
@@ -175,6 +175,18 @@ func (r *Routes) Register(router fiber.Router) {
|
||||
Local[*models.User]("__ctx_user"),
|
||||
PathParam[int64]("id"),
|
||||
))
|
||||
r.log.Debugf("Registering route: Get /t/:tenantCode/v1/creator/coupons -> creator.ListCoupons")
|
||||
router.Get("/t/:tenantCode/v1/creator/coupons"[len(r.Path()):], DataFunc2(
|
||||
r.creator.ListCoupons,
|
||||
Local[*models.User]("__ctx_user"),
|
||||
Query[dto.CouponListFilter]("filter"),
|
||||
))
|
||||
r.log.Debugf("Registering route: Get /t/:tenantCode/v1/creator/coupons/:id<int> -> creator.GetCoupon")
|
||||
router.Get("/t/:tenantCode/v1/creator/coupons/:id<int>"[len(r.Path()):], DataFunc2(
|
||||
r.creator.GetCoupon,
|
||||
Local[*models.User]("__ctx_user"),
|
||||
PathParam[int64]("id"),
|
||||
))
|
||||
r.log.Debugf("Registering route: Get /t/:tenantCode/v1/creator/dashboard -> creator.Dashboard")
|
||||
router.Get("/t/:tenantCode/v1/creator/dashboard"[len(r.Path()):], DataFunc1(
|
||||
r.creator.Dashboard,
|
||||
@@ -214,6 +226,19 @@ func (r *Routes) Register(router fiber.Router) {
|
||||
Local[*models.User]("__ctx_user"),
|
||||
Body[dto.ContentCreateForm]("form"),
|
||||
))
|
||||
r.log.Debugf("Registering route: Post /t/:tenantCode/v1/creator/coupons -> creator.CreateCoupon")
|
||||
router.Post("/t/:tenantCode/v1/creator/coupons"[len(r.Path()):], DataFunc2(
|
||||
r.creator.CreateCoupon,
|
||||
Local[*models.User]("__ctx_user"),
|
||||
Body[dto.CouponCreateForm]("form"),
|
||||
))
|
||||
r.log.Debugf("Registering route: Post /t/:tenantCode/v1/creator/coupons/:id<int>/grant -> creator.GrantCoupon")
|
||||
router.Post("/t/:tenantCode/v1/creator/coupons/:id<int>/grant"[len(r.Path()):], DataFunc3(
|
||||
r.creator.GrantCoupon,
|
||||
Local[*models.User]("__ctx_user"),
|
||||
PathParam[int64]("id"),
|
||||
Body[dto.CouponGrantForm]("form"),
|
||||
))
|
||||
r.log.Debugf("Registering route: Post /t/:tenantCode/v1/creator/members/:id<int>/review -> creator.ReviewMember")
|
||||
router.Post("/t/:tenantCode/v1/creator/members/:id<int>/review"[len(r.Path()):], Func3(
|
||||
r.creator.ReviewMember,
|
||||
@@ -259,6 +284,13 @@ func (r *Routes) Register(router fiber.Router) {
|
||||
PathParam[int64]("id"),
|
||||
Body[dto.ContentUpdateForm]("form"),
|
||||
))
|
||||
r.log.Debugf("Registering route: Put /t/:tenantCode/v1/creator/coupons/:id<int> -> creator.UpdateCoupon")
|
||||
router.Put("/t/:tenantCode/v1/creator/coupons/:id<int>"[len(r.Path()):], DataFunc3(
|
||||
r.creator.UpdateCoupon,
|
||||
Local[*models.User]("__ctx_user"),
|
||||
PathParam[int64]("id"),
|
||||
Body[dto.CouponUpdateForm]("form"),
|
||||
))
|
||||
r.log.Debugf("Registering route: Put /t/:tenantCode/v1/creator/settings -> creator.UpdateSettings")
|
||||
router.Put("/t/:tenantCode/v1/creator/settings"[len(r.Path()):], Func2(
|
||||
r.creator.UpdateSettings,
|
||||
@@ -374,6 +406,12 @@ func (r *Routes) Register(router fiber.Router) {
|
||||
Local[*models.User]("__ctx_user"),
|
||||
QueryParam[string]("status"),
|
||||
))
|
||||
r.log.Debugf("Registering route: Get /t/:tenantCode/v1/me/coupons/available -> user.AvailableCoupons")
|
||||
router.Get("/t/:tenantCode/v1/me/coupons/available"[len(r.Path()):], DataFunc2(
|
||||
r.user.AvailableCoupons,
|
||||
Local[*models.User]("__ctx_user"),
|
||||
QueryParam[int64]("amount"),
|
||||
))
|
||||
r.log.Debugf("Registering route: Get /t/:tenantCode/v1/me/favorites -> user.Favorites")
|
||||
router.Get("/t/:tenantCode/v1/me/favorites"[len(r.Path()):], DataFunc1(
|
||||
r.user.Favorites,
|
||||
@@ -418,6 +456,12 @@ func (r *Routes) Register(router fiber.Router) {
|
||||
r.user.Wallet,
|
||||
Local[*models.User]("__ctx_user"),
|
||||
))
|
||||
r.log.Debugf("Registering route: Post /t/:tenantCode/v1/me/coupons/receive -> user.ReceiveCoupon")
|
||||
router.Post("/t/:tenantCode/v1/me/coupons/receive"[len(r.Path()):], DataFunc2(
|
||||
r.user.ReceiveCoupon,
|
||||
Local[*models.User]("__ctx_user"),
|
||||
Body[dto.CouponReceiveForm]("form"),
|
||||
))
|
||||
r.log.Debugf("Registering route: Post /t/:tenantCode/v1/me/favorites -> user.AddFavorite")
|
||||
router.Post("/t/:tenantCode/v1/me/favorites"[len(r.Path()):], Func2(
|
||||
r.user.AddFavorite,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package v1
|
||||
|
||||
import (
|
||||
"quyun/v2/app/errorx"
|
||||
"quyun/v2/app/http/v1/dto"
|
||||
auth_dto "quyun/v2/app/http/v1/dto"
|
||||
"quyun/v2/app/requests"
|
||||
@@ -319,3 +320,40 @@ func (u *User) MyCoupons(ctx fiber.Ctx, user *models.User, status string) ([]dto
|
||||
tenantID := getTenantID(ctx)
|
||||
return services.Coupon.ListUserCoupons(ctx, tenantID, user.ID, status)
|
||||
}
|
||||
|
||||
// List available coupons for order amount
|
||||
//
|
||||
// @Router /t/:tenantCode/v1/me/coupons/available [get]
|
||||
// @Summary List available coupons
|
||||
// @Description List coupons available for the given order amount
|
||||
// @Tags UserCenter
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param amount query int64 true "Order amount (cents)"
|
||||
// @Success 200 {array} dto.UserCouponItem
|
||||
// @Bind user local key(__ctx_user)
|
||||
// @Bind amount query
|
||||
func (u *User) AvailableCoupons(ctx fiber.Ctx, user *models.User, amount int64) ([]dto.UserCouponItem, error) {
|
||||
tenantID := getTenantID(ctx)
|
||||
return services.Coupon.ListAvailable(ctx, tenantID, user.ID, amount)
|
||||
}
|
||||
|
||||
// Receive coupon
|
||||
//
|
||||
// @Router /t/:tenantCode/v1/me/coupons/receive [post]
|
||||
// @Summary Receive coupon
|
||||
// @Description Receive a coupon by coupon_id
|
||||
// @Tags UserCenter
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param form body dto.CouponReceiveForm true "Receive form"
|
||||
// @Success 200 {object} dto.UserCouponItem
|
||||
// @Bind user local key(__ctx_user)
|
||||
// @Bind form body
|
||||
func (u *User) ReceiveCoupon(ctx fiber.Ctx, user *models.User, form *dto.CouponReceiveForm) (*dto.UserCouponItem, error) {
|
||||
tenantID := getTenantID(ctx)
|
||||
if form == nil {
|
||||
return nil, errorx.ErrInvalidParameter.WithMsg("参数无效")
|
||||
}
|
||||
return services.Coupon.Receive(ctx, tenantID, user.ID, form.CouponID)
|
||||
}
|
||||
|
||||
@@ -3,13 +3,18 @@ package services
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"quyun/v2/app/errorx"
|
||||
coupon_dto "quyun/v2/app/http/v1/dto"
|
||||
"quyun/v2/app/requests"
|
||||
"quyun/v2/database/models"
|
||||
"quyun/v2/pkg/consts"
|
||||
|
||||
"go.ipao.vip/gen/field"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
// @provider
|
||||
@@ -24,13 +29,15 @@ func (s *coupon) ListUserCoupons(
|
||||
if userID == 0 {
|
||||
return nil, errorx.ErrUnauthorized
|
||||
}
|
||||
uid := userID
|
||||
statusFilter := strings.TrimSpace(status)
|
||||
if statusFilter != "" && statusFilter != "all" {
|
||||
if _, err := consts.ParseUserCouponStatus(statusFilter); err != nil {
|
||||
return nil, errorx.ErrInvalidParameter.WithMsg("状态参数无效")
|
||||
}
|
||||
}
|
||||
|
||||
tbl, q := models.UserCouponQuery.QueryContext(ctx)
|
||||
q = q.Where(tbl.UserID.Eq(uid))
|
||||
if status != "" {
|
||||
q = q.Where(tbl.Status.Eq(status))
|
||||
}
|
||||
q = q.Where(tbl.UserID.Eq(userID))
|
||||
|
||||
list, err := q.Order(tbl.CreatedAt.Desc()).Find()
|
||||
if err != nil {
|
||||
@@ -40,9 +47,690 @@ func (s *coupon) ListUserCoupons(
|
||||
return []coupon_dto.UserCouponItem{}, nil
|
||||
}
|
||||
|
||||
couponMap, err := s.fetchCouponMap(ctx, tenantID, list)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
res := make([]coupon_dto.UserCouponItem, 0, len(list))
|
||||
for _, v := range list {
|
||||
if v == nil {
|
||||
continue
|
||||
}
|
||||
c, ok := couponMap[v.CouponID]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
finalStatus := v.Status
|
||||
if s.isCouponExpired(c, now) && v.Status == consts.UserCouponStatusUnused {
|
||||
if err := s.markUserCouponExpired(ctx, v.ID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
finalStatus = consts.UserCouponStatusExpired
|
||||
}
|
||||
|
||||
if statusFilter != "" && statusFilter != "all" && string(finalStatus) != statusFilter {
|
||||
continue
|
||||
}
|
||||
|
||||
res = append(res, s.composeUserCouponItem(v, c, finalStatus))
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
|
||||
func (s *coupon) ListAvailable(
|
||||
ctx context.Context,
|
||||
tenantID int64,
|
||||
userID int64,
|
||||
amount int64,
|
||||
) ([]coupon_dto.UserCouponItem, error) {
|
||||
if userID == 0 {
|
||||
return nil, errorx.ErrUnauthorized
|
||||
}
|
||||
if amount <= 0 {
|
||||
return nil, errorx.ErrInvalidParameter.WithMsg("订单金额无效")
|
||||
}
|
||||
|
||||
tbl, q := models.UserCouponQuery.QueryContext(ctx)
|
||||
q = q.Where(tbl.UserID.Eq(userID), tbl.Status.Eq(consts.UserCouponStatusUnused))
|
||||
|
||||
list, err := q.Order(tbl.CreatedAt.Desc()).Find()
|
||||
if err != nil {
|
||||
return nil, errorx.ErrDatabaseError.WithCause(err)
|
||||
}
|
||||
if len(list) == 0 {
|
||||
return []coupon_dto.UserCouponItem{}, nil
|
||||
}
|
||||
|
||||
couponMap, err := s.fetchCouponMap(ctx, tenantID, list)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
res := make([]coupon_dto.UserCouponItem, 0, len(list))
|
||||
for _, v := range list {
|
||||
if v == nil {
|
||||
continue
|
||||
}
|
||||
c, ok := couponMap[v.CouponID]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
if !s.isCouponActive(c, now) {
|
||||
if s.isCouponExpired(c, now) && v.Status == consts.UserCouponStatusUnused {
|
||||
if err := s.markUserCouponExpired(ctx, v.ID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if amount < c.MinOrderAmount {
|
||||
continue
|
||||
}
|
||||
|
||||
res = append(res, s.composeUserCouponItem(v, c, consts.UserCouponStatusUnused))
|
||||
}
|
||||
|
||||
return res, nil
|
||||
}
|
||||
|
||||
func (s *coupon) Receive(
|
||||
ctx context.Context,
|
||||
tenantID int64,
|
||||
userID int64,
|
||||
couponID int64,
|
||||
) (*coupon_dto.UserCouponItem, error) {
|
||||
if userID == 0 {
|
||||
return nil, errorx.ErrUnauthorized
|
||||
}
|
||||
if couponID == 0 {
|
||||
return nil, errorx.ErrInvalidParameter.WithMsg("优惠券不存在")
|
||||
}
|
||||
|
||||
var result *coupon_dto.UserCouponItem
|
||||
err := models.Q.Transaction(func(tx *models.Query) error {
|
||||
// 锁定优惠券行,防止并发超发。
|
||||
tbl, q := tx.Coupon.QueryContext(ctx)
|
||||
q = q.Where(tbl.ID.Eq(couponID))
|
||||
if tenantID > 0 {
|
||||
q = q.Where(tbl.TenantID.Eq(tenantID))
|
||||
}
|
||||
coupon, err := q.Clauses(clause.Locking{Strength: "UPDATE"}).First()
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return errorx.ErrRecordNotFound.WithMsg("优惠券不存在")
|
||||
}
|
||||
return errorx.ErrDatabaseError.WithCause(err)
|
||||
}
|
||||
if tenantID > 0 && coupon.TenantID != tenantID {
|
||||
return errorx.ErrForbidden.WithMsg("优惠券租户不匹配")
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
if !coupon.StartAt.IsZero() && now.Before(coupon.StartAt) {
|
||||
return errorx.ErrBusinessLogic.WithMsg("优惠券尚未生效")
|
||||
}
|
||||
if !coupon.EndAt.IsZero() && now.After(coupon.EndAt) {
|
||||
return errorx.ErrBusinessLogic.WithMsg("优惠券已过期")
|
||||
}
|
||||
|
||||
existing, err := tx.UserCoupon.WithContext(ctx).
|
||||
Where(tx.UserCoupon.UserID.Eq(userID), tx.UserCoupon.CouponID.Eq(coupon.ID)).
|
||||
First()
|
||||
if err == nil {
|
||||
item := s.composeUserCouponItem(existing, coupon, existing.Status)
|
||||
result = &item
|
||||
return nil
|
||||
}
|
||||
if !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return errorx.ErrDatabaseError.WithCause(err)
|
||||
}
|
||||
|
||||
if coupon.TotalQuantity > 0 {
|
||||
count, err := tx.UserCoupon.WithContext(ctx).
|
||||
Where(tx.UserCoupon.CouponID.Eq(coupon.ID)).
|
||||
Count()
|
||||
if err != nil {
|
||||
return errorx.ErrDatabaseError.WithCause(err)
|
||||
}
|
||||
if int32(count) >= coupon.TotalQuantity {
|
||||
return errorx.ErrBusinessLogic.WithMsg("优惠券已领完")
|
||||
}
|
||||
}
|
||||
|
||||
uc := &models.UserCoupon{
|
||||
UserID: userID,
|
||||
CouponID: coupon.ID,
|
||||
Status: consts.UserCouponStatusUnused,
|
||||
}
|
||||
if err := tx.UserCoupon.WithContext(ctx).Create(uc); err != nil {
|
||||
return errorx.ErrDatabaseError.WithCause(err)
|
||||
}
|
||||
|
||||
item := s.composeUserCouponItem(uc, coupon, consts.UserCouponStatusUnused)
|
||||
result = &item
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (s *coupon) Create(
|
||||
ctx context.Context,
|
||||
tenantID int64,
|
||||
userID int64,
|
||||
form *coupon_dto.CouponCreateForm,
|
||||
) (*coupon_dto.CouponItem, error) {
|
||||
if tenantID == 0 {
|
||||
return nil, errorx.ErrForbidden.WithMsg("租户未绑定")
|
||||
}
|
||||
if form == nil {
|
||||
return nil, errorx.ErrInvalidParameter.WithMsg("参数无效")
|
||||
}
|
||||
_ = userID
|
||||
|
||||
title := strings.TrimSpace(form.Title)
|
||||
if title == "" {
|
||||
return nil, errorx.ErrInvalidParameter.WithMsg("优惠券标题不能为空")
|
||||
}
|
||||
|
||||
couponType, err := s.parseCouponType(form.Type)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := s.validateCouponValue(couponType, form.Value, form.MaxDiscount); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if form.MinOrderAmount < 0 {
|
||||
return nil, errorx.ErrInvalidParameter.WithMsg("使用门槛无效")
|
||||
}
|
||||
if form.TotalQuantity < 0 {
|
||||
return nil, errorx.ErrInvalidParameter.WithMsg("发行数量无效")
|
||||
}
|
||||
|
||||
startAt, err := s.parseTime(form.StartAt)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
endAt, err := s.parseTime(form.EndAt)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !startAt.IsZero() && !endAt.IsZero() && startAt.After(endAt) {
|
||||
return nil, errorx.ErrInvalidParameter.WithMsg("生效时间不能晚于过期时间")
|
||||
}
|
||||
|
||||
coupon := &models.Coupon{
|
||||
TenantID: tenantID,
|
||||
Title: title,
|
||||
Description: strings.TrimSpace(form.Description),
|
||||
Type: couponType,
|
||||
Value: form.Value,
|
||||
MinOrderAmount: form.MinOrderAmount,
|
||||
MaxDiscount: form.MaxDiscount,
|
||||
TotalQuantity: form.TotalQuantity,
|
||||
UsedQuantity: 0,
|
||||
StartAt: startAt,
|
||||
EndAt: endAt,
|
||||
}
|
||||
|
||||
if err := models.CouponQuery.WithContext(ctx).Create(coupon); err != nil {
|
||||
return nil, errorx.ErrDatabaseError.WithCause(err)
|
||||
}
|
||||
|
||||
item := s.composeCouponItem(coupon)
|
||||
return &item, nil
|
||||
}
|
||||
|
||||
func (s *coupon) Update(
|
||||
ctx context.Context,
|
||||
tenantID int64,
|
||||
userID int64,
|
||||
id int64,
|
||||
form *coupon_dto.CouponUpdateForm,
|
||||
) (*coupon_dto.CouponItem, error) {
|
||||
if tenantID == 0 {
|
||||
return nil, errorx.ErrForbidden.WithMsg("租户未绑定")
|
||||
}
|
||||
if id == 0 || form == nil {
|
||||
return nil, errorx.ErrInvalidParameter.WithMsg("参数无效")
|
||||
}
|
||||
_ = userID
|
||||
|
||||
var result *coupon_dto.CouponItem
|
||||
err := models.Q.Transaction(func(tx *models.Query) error {
|
||||
tbl, q := tx.Coupon.QueryContext(ctx)
|
||||
q = q.Where(tbl.ID.Eq(id), tbl.TenantID.Eq(tenantID))
|
||||
coupon, err := q.Clauses(clause.Locking{Strength: "UPDATE"}).First()
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return errorx.ErrRecordNotFound.WithMsg("优惠券不存在")
|
||||
}
|
||||
return errorx.ErrDatabaseError.WithCause(err)
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
claimedCount, err := tx.UserCoupon.WithContext(ctx).
|
||||
Where(tx.UserCoupon.CouponID.Eq(coupon.ID)).
|
||||
Count()
|
||||
if err != nil {
|
||||
return errorx.ErrDatabaseError.WithCause(err)
|
||||
}
|
||||
if claimedCount > 0 || (!coupon.StartAt.IsZero() && now.After(coupon.StartAt)) {
|
||||
return errorx.ErrBusinessLogic.WithMsg("优惠券已领取或已生效,禁止修改")
|
||||
}
|
||||
|
||||
updates := make([]field.AssignExpr, 0)
|
||||
nextType := coupon.Type
|
||||
nextValue := coupon.Value
|
||||
nextMaxDiscount := coupon.MaxDiscount
|
||||
startAt := coupon.StartAt
|
||||
endAt := coupon.EndAt
|
||||
|
||||
if form.Title != nil {
|
||||
value := strings.TrimSpace(*form.Title)
|
||||
if value == "" {
|
||||
return errorx.ErrInvalidParameter.WithMsg("优惠券标题不能为空")
|
||||
}
|
||||
updates = append(updates, tbl.Title.Value(value))
|
||||
}
|
||||
if form.Description != nil {
|
||||
updates = append(updates, tbl.Description.Value(strings.TrimSpace(*form.Description)))
|
||||
}
|
||||
if form.Type != nil {
|
||||
parsed, err := s.parseCouponType(*form.Type)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
nextType = parsed
|
||||
updates = append(updates, tbl.Type.Value(parsed))
|
||||
}
|
||||
if form.Value != nil {
|
||||
nextValue = *form.Value
|
||||
updates = append(updates, tbl.Value.Value(*form.Value))
|
||||
}
|
||||
if form.MinOrderAmount != nil {
|
||||
if *form.MinOrderAmount < 0 {
|
||||
return errorx.ErrInvalidParameter.WithMsg("使用门槛无效")
|
||||
}
|
||||
updates = append(updates, tbl.MinOrderAmount.Value(*form.MinOrderAmount))
|
||||
}
|
||||
if form.MaxDiscount != nil {
|
||||
nextMaxDiscount = *form.MaxDiscount
|
||||
updates = append(updates, tbl.MaxDiscount.Value(*form.MaxDiscount))
|
||||
}
|
||||
if form.TotalQuantity != nil {
|
||||
if *form.TotalQuantity < 0 {
|
||||
return errorx.ErrInvalidParameter.WithMsg("发行数量无效")
|
||||
}
|
||||
if *form.TotalQuantity > 0 && int32(claimedCount) > *form.TotalQuantity {
|
||||
return errorx.ErrBusinessLogic.WithMsg("发行数量不足以覆盖已领取数量")
|
||||
}
|
||||
updates = append(updates, tbl.TotalQuantity.Value(*form.TotalQuantity))
|
||||
}
|
||||
if form.StartAt != nil {
|
||||
parsed, err := s.parseTime(*form.StartAt)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
startAt = parsed
|
||||
updates = append(updates, tbl.StartAt.Value(parsed))
|
||||
}
|
||||
if form.EndAt != nil {
|
||||
parsed, err := s.parseTime(*form.EndAt)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
endAt = parsed
|
||||
updates = append(updates, tbl.EndAt.Value(parsed))
|
||||
}
|
||||
|
||||
if err := s.validateCouponValue(nextType, nextValue, nextMaxDiscount); err != nil {
|
||||
return err
|
||||
}
|
||||
if !startAt.IsZero() && !endAt.IsZero() && startAt.After(endAt) {
|
||||
return errorx.ErrInvalidParameter.WithMsg("生效时间不能晚于过期时间")
|
||||
}
|
||||
|
||||
if len(updates) == 0 {
|
||||
resultItem := s.composeCouponItem(coupon)
|
||||
result = &resultItem
|
||||
return nil
|
||||
}
|
||||
|
||||
updates = append(updates, tbl.UpdatedAt.Value(time.Now()))
|
||||
if _, err := tx.Coupon.WithContext(ctx).Where(tbl.ID.Eq(coupon.ID)).UpdateSimple(updates...); err != nil {
|
||||
return errorx.ErrDatabaseError.WithCause(err)
|
||||
}
|
||||
updated, err := tx.Coupon.WithContext(ctx).Where(tbl.ID.Eq(coupon.ID)).First()
|
||||
if err != nil {
|
||||
return errorx.ErrDatabaseError.WithCause(err)
|
||||
}
|
||||
resultItem := s.composeCouponItem(updated)
|
||||
result = &resultItem
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (s *coupon) Get(
|
||||
ctx context.Context,
|
||||
tenantID int64,
|
||||
id int64,
|
||||
) (*coupon_dto.CouponItem, error) {
|
||||
if id == 0 {
|
||||
return nil, errorx.ErrInvalidParameter.WithMsg("优惠券不存在")
|
||||
}
|
||||
query := models.CouponQuery.WithContext(ctx).Where(models.CouponQuery.ID.Eq(id))
|
||||
if tenantID > 0 {
|
||||
query = query.Where(models.CouponQuery.TenantID.Eq(tenantID))
|
||||
}
|
||||
coupon, err := query.First()
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, errorx.ErrRecordNotFound.WithMsg("优惠券不存在")
|
||||
}
|
||||
return nil, errorx.ErrDatabaseError.WithCause(err)
|
||||
}
|
||||
item := s.composeCouponItem(coupon)
|
||||
return &item, nil
|
||||
}
|
||||
|
||||
func (s *coupon) List(
|
||||
ctx context.Context,
|
||||
tenantID int64,
|
||||
filter *coupon_dto.CouponListFilter,
|
||||
) (*requests.Pager, error) {
|
||||
if tenantID == 0 {
|
||||
return nil, errorx.ErrForbidden.WithMsg("租户未绑定")
|
||||
}
|
||||
if filter == nil {
|
||||
filter = &coupon_dto.CouponListFilter{}
|
||||
}
|
||||
|
||||
tbl, q := models.CouponQuery.QueryContext(ctx)
|
||||
q = q.Where(tbl.TenantID.Eq(tenantID))
|
||||
|
||||
if filter.Type != nil && strings.TrimSpace(*filter.Type) != "" {
|
||||
parsed, err := s.parseCouponType(*filter.Type)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
q = q.Where(tbl.Type.Eq(parsed))
|
||||
}
|
||||
if filter.Keyword != nil && strings.TrimSpace(*filter.Keyword) != "" {
|
||||
keyword := strings.TrimSpace(*filter.Keyword)
|
||||
q = q.Where(field.Or(
|
||||
tbl.Title.Like("%"+keyword+"%"),
|
||||
tbl.Description.Like("%"+keyword+"%"),
|
||||
))
|
||||
}
|
||||
if filter.Status != nil && strings.TrimSpace(*filter.Status) != "" {
|
||||
status := strings.TrimSpace(*filter.Status)
|
||||
now := time.Now()
|
||||
switch status {
|
||||
case "active":
|
||||
q = q.Where(field.Or(tbl.StartAt.Lte(now), tbl.StartAt.IsNull()))
|
||||
q = q.Where(field.Or(tbl.EndAt.Gte(now), tbl.EndAt.IsNull()))
|
||||
case "expired":
|
||||
q = q.Where(tbl.EndAt.IsNotNull(), tbl.EndAt.Lt(now))
|
||||
default:
|
||||
return nil, errorx.ErrInvalidParameter.WithMsg("状态参数无效")
|
||||
}
|
||||
}
|
||||
|
||||
p := requests.Pagination{Page: filter.Page, Limit: filter.Limit}
|
||||
total, err := q.Count()
|
||||
if err != nil {
|
||||
return nil, errorx.ErrDatabaseError.WithCause(err)
|
||||
}
|
||||
|
||||
list, err := q.Order(tbl.CreatedAt.Desc()).Offset(int(p.Offset())).Limit(int(p.Limit)).Find()
|
||||
if err != nil {
|
||||
return nil, errorx.ErrDatabaseError.WithCause(err)
|
||||
}
|
||||
|
||||
items := make([]coupon_dto.CouponItem, 0, len(list))
|
||||
for _, c := range list {
|
||||
if c == nil {
|
||||
continue
|
||||
}
|
||||
items = append(items, s.composeCouponItem(c))
|
||||
}
|
||||
|
||||
return &requests.Pager{
|
||||
Pagination: requests.Pagination{Page: p.Page, Limit: p.Limit},
|
||||
Total: total,
|
||||
Items: items,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *coupon) Grant(
|
||||
ctx context.Context,
|
||||
tenantID int64,
|
||||
couponID int64,
|
||||
userIDs []int64,
|
||||
) (int, error) {
|
||||
if tenantID == 0 {
|
||||
return 0, errorx.ErrForbidden.WithMsg("租户未绑定")
|
||||
}
|
||||
if couponID == 0 {
|
||||
return 0, errorx.ErrInvalidParameter.WithMsg("优惠券不存在")
|
||||
}
|
||||
if len(userIDs) == 0 {
|
||||
return 0, errorx.ErrInvalidParameter.WithMsg("用户列表为空")
|
||||
}
|
||||
|
||||
uniq := make(map[int64]struct{}, len(userIDs))
|
||||
cleanIDs := make([]int64, 0, len(userIDs))
|
||||
for _, id := range userIDs {
|
||||
if id <= 0 {
|
||||
continue
|
||||
}
|
||||
if _, ok := uniq[id]; ok {
|
||||
continue
|
||||
}
|
||||
uniq[id] = struct{}{}
|
||||
cleanIDs = append(cleanIDs, id)
|
||||
}
|
||||
if len(cleanIDs) == 0 {
|
||||
return 0, errorx.ErrInvalidParameter.WithMsg("用户列表为空")
|
||||
}
|
||||
|
||||
var granted int
|
||||
err := models.Q.Transaction(func(tx *models.Query) error {
|
||||
// 锁定优惠券行,防止并发超发。
|
||||
tbl, q := tx.Coupon.QueryContext(ctx)
|
||||
q = q.Where(tbl.ID.Eq(couponID), tbl.TenantID.Eq(tenantID))
|
||||
coupon, err := q.Clauses(clause.Locking{Strength: "UPDATE"}).First()
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return errorx.ErrRecordNotFound.WithMsg("优惠券不存在")
|
||||
}
|
||||
return errorx.ErrDatabaseError.WithCause(err)
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
if !coupon.StartAt.IsZero() && now.Before(coupon.StartAt) {
|
||||
return errorx.ErrBusinessLogic.WithMsg("优惠券尚未生效")
|
||||
}
|
||||
if !coupon.EndAt.IsZero() && now.After(coupon.EndAt) {
|
||||
return errorx.ErrBusinessLogic.WithMsg("优惠券已过期")
|
||||
}
|
||||
|
||||
existingList, err := tx.UserCoupon.WithContext(ctx).
|
||||
Where(tx.UserCoupon.CouponID.Eq(coupon.ID), tx.UserCoupon.UserID.In(cleanIDs...)).
|
||||
Find()
|
||||
if err != nil {
|
||||
return errorx.ErrDatabaseError.WithCause(err)
|
||||
}
|
||||
existMap := make(map[int64]struct{}, len(existingList))
|
||||
for _, item := range existingList {
|
||||
existMap[item.UserID] = struct{}{}
|
||||
}
|
||||
|
||||
newIDs := make([]int64, 0, len(cleanIDs))
|
||||
for _, id := range cleanIDs {
|
||||
if _, ok := existMap[id]; ok {
|
||||
continue
|
||||
}
|
||||
newIDs = append(newIDs, id)
|
||||
}
|
||||
if len(newIDs) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
if coupon.TotalQuantity > 0 {
|
||||
count, err := tx.UserCoupon.WithContext(ctx).
|
||||
Where(tx.UserCoupon.CouponID.Eq(coupon.ID)).
|
||||
Count()
|
||||
if err != nil {
|
||||
return errorx.ErrDatabaseError.WithCause(err)
|
||||
}
|
||||
if int32(count)+int32(len(newIDs)) > coupon.TotalQuantity {
|
||||
return errorx.ErrBusinessLogic.WithMsg("优惠券库存不足")
|
||||
}
|
||||
}
|
||||
|
||||
for _, id := range newIDs {
|
||||
uc := &models.UserCoupon{
|
||||
UserID: id,
|
||||
CouponID: coupon.ID,
|
||||
Status: consts.UserCouponStatusUnused,
|
||||
}
|
||||
if err := tx.UserCoupon.WithContext(ctx).Create(uc); err != nil {
|
||||
return errorx.ErrDatabaseError.WithCause(err)
|
||||
}
|
||||
granted++
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return granted, nil
|
||||
}
|
||||
|
||||
// Validate checks if a coupon can be used for an order and returns the discount amount
|
||||
func (s *coupon) Validate(ctx context.Context, tenantID, userID, userCouponID, amount int64) (int64, error) {
|
||||
uc, err := models.UserCouponQuery.WithContext(ctx).Where(models.UserCouponQuery.ID.Eq(userCouponID)).First()
|
||||
if err != nil {
|
||||
return 0, errorx.ErrRecordNotFound.WithMsg("优惠券不存在")
|
||||
}
|
||||
if uc.UserID != userID {
|
||||
return 0, errorx.ErrUnauthorized.WithMsg("无权使用该优惠券")
|
||||
}
|
||||
if uc.Status != consts.UserCouponStatusUnused {
|
||||
return 0, errorx.ErrBusinessLogic.WithMsg("优惠券已使用或失效")
|
||||
}
|
||||
|
||||
c, err := models.CouponQuery.WithContext(ctx).Where(models.CouponQuery.ID.Eq(uc.CouponID)).First()
|
||||
if err != nil {
|
||||
return 0, errorx.ErrRecordNotFound.WithMsg("优惠券信息缺失")
|
||||
}
|
||||
if tenantID > 0 && c.TenantID != tenantID {
|
||||
return 0, errorx.ErrForbidden.WithMsg("优惠券租户不匹配")
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
if !c.StartAt.IsZero() && now.Before(c.StartAt) {
|
||||
return 0, errorx.ErrBusinessLogic.WithMsg("优惠券尚未生效")
|
||||
}
|
||||
if !c.EndAt.IsZero() && now.After(c.EndAt) {
|
||||
if err := s.markUserCouponExpired(ctx, uc.ID); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return 0, errorx.ErrBusinessLogic.WithMsg("优惠券已过期")
|
||||
}
|
||||
|
||||
if amount < c.MinOrderAmount {
|
||||
return 0, errorx.ErrBusinessLogic.WithMsg("未达到优惠券使用门槛")
|
||||
}
|
||||
|
||||
var discount int64
|
||||
switch c.Type {
|
||||
case consts.CouponTypeFixAmount:
|
||||
discount = c.Value
|
||||
case consts.CouponTypeDiscount:
|
||||
discount = (amount * c.Value) / 100
|
||||
if c.MaxDiscount > 0 && discount > c.MaxDiscount {
|
||||
discount = c.MaxDiscount
|
||||
}
|
||||
default:
|
||||
return 0, errorx.ErrDataCorrupted.WithMsg("优惠券类型异常")
|
||||
}
|
||||
|
||||
// Discount cannot exceed order amount
|
||||
if discount > amount {
|
||||
discount = amount
|
||||
}
|
||||
|
||||
return discount, nil
|
||||
}
|
||||
|
||||
// MarkUsed marks a user coupon as used (intended to be called inside a transaction)
|
||||
func (s *coupon) MarkUsed(ctx context.Context, tx *models.Query, tenantID, userCouponID, orderID int64) error {
|
||||
uc, err := tx.UserCoupon.WithContext(ctx).Where(tx.UserCoupon.ID.Eq(userCouponID)).First()
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return errorx.ErrRecordNotFound.WithMsg("优惠券不存在")
|
||||
}
|
||||
return errorx.ErrDatabaseError.WithCause(err)
|
||||
}
|
||||
if uc.Status != consts.UserCouponStatusUnused {
|
||||
return errorx.ErrBusinessLogic.WithMsg("优惠券核销失败")
|
||||
}
|
||||
|
||||
c, err := tx.Coupon.WithContext(ctx).Where(tx.Coupon.ID.Eq(uc.CouponID)).First()
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return errorx.ErrRecordNotFound.WithMsg("优惠券信息缺失")
|
||||
}
|
||||
return errorx.ErrDatabaseError.WithCause(err)
|
||||
}
|
||||
if tenantID > 0 && c.TenantID != tenantID {
|
||||
return errorx.ErrForbidden.WithMsg("优惠券租户不匹配")
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
// Update User Coupon
|
||||
info, err := tx.UserCoupon.WithContext(ctx).
|
||||
Where(tx.UserCoupon.ID.Eq(userCouponID), tx.UserCoupon.Status.Eq(consts.UserCouponStatusUnused)).
|
||||
Updates(&models.UserCoupon{
|
||||
Status: consts.UserCouponStatusUsed,
|
||||
OrderID: orderID,
|
||||
UsedAt: now,
|
||||
})
|
||||
if err != nil {
|
||||
return errorx.ErrDatabaseError.WithCause(err)
|
||||
}
|
||||
if info.RowsAffected == 0 {
|
||||
return errorx.ErrBusinessLogic.WithMsg("优惠券核销失败")
|
||||
}
|
||||
|
||||
// Update Coupon used quantity (Optional, but good for stats)
|
||||
_, _ = tx.Coupon.WithContext(ctx).Where(tx.Coupon.ID.Eq(uc.CouponID)).UpdateSimple(tx.Coupon.UsedQuantity.Add(1))
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *coupon) fetchCouponMap(ctx context.Context, tenantID int64, list []*models.UserCoupon) (map[int64]*models.Coupon, error) {
|
||||
couponIDSet := make(map[int64]struct{}, len(list))
|
||||
couponIDs := make([]int64, 0, len(list))
|
||||
for _, v := range list {
|
||||
if v == nil {
|
||||
continue
|
||||
}
|
||||
if _, ok := couponIDSet[v.CouponID]; ok {
|
||||
continue
|
||||
}
|
||||
@@ -64,129 +752,118 @@ func (s *coupon) ListUserCoupons(
|
||||
for _, c := range coupons {
|
||||
couponMap[c.ID] = c
|
||||
}
|
||||
|
||||
res := make([]coupon_dto.UserCouponItem, 0, len(list))
|
||||
for _, v := range list {
|
||||
c, ok := couponMap[v.CouponID]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
item := coupon_dto.UserCouponItem{
|
||||
ID: v.ID,
|
||||
CouponID: v.CouponID,
|
||||
Status: v.Status,
|
||||
}
|
||||
if c != nil {
|
||||
item.Title = c.Title
|
||||
item.Description = c.Description
|
||||
item.Type = c.Type
|
||||
item.Value = c.Value
|
||||
item.MinOrderAmount = c.MinOrderAmount
|
||||
if !c.StartAt.IsZero() {
|
||||
item.StartAt = c.StartAt.Format(time.RFC3339)
|
||||
}
|
||||
if !c.EndAt.IsZero() {
|
||||
item.EndAt = c.EndAt.Format(time.RFC3339)
|
||||
}
|
||||
}
|
||||
res = append(res, item)
|
||||
}
|
||||
return res, nil
|
||||
return couponMap, nil
|
||||
}
|
||||
|
||||
// Validate checks if a coupon can be used for an order and returns the discount amount
|
||||
func (s *coupon) Validate(ctx context.Context, tenantID, userID, userCouponID, amount int64) (int64, error) {
|
||||
uc, err := models.UserCouponQuery.WithContext(ctx).Where(models.UserCouponQuery.ID.Eq(userCouponID)).First()
|
||||
if err != nil {
|
||||
return 0, errorx.ErrRecordNotFound.WithMsg("优惠券不存在")
|
||||
func (s *coupon) composeUserCouponItem(uc *models.UserCoupon, c *models.Coupon, status consts.UserCouponStatus) coupon_dto.UserCouponItem {
|
||||
item := coupon_dto.UserCouponItem{
|
||||
ID: uc.ID,
|
||||
CouponID: uc.CouponID,
|
||||
Status: string(status),
|
||||
}
|
||||
if uc.UserID != userID {
|
||||
return 0, errorx.ErrUnauthorized.WithMsg("无权使用该优惠券")
|
||||
if c != nil {
|
||||
item.Title = c.Title
|
||||
item.Description = c.Description
|
||||
item.Type = string(c.Type)
|
||||
item.Value = c.Value
|
||||
item.MinOrderAmount = c.MinOrderAmount
|
||||
item.StartAt = s.formatTime(c.StartAt)
|
||||
item.EndAt = s.formatTime(c.EndAt)
|
||||
}
|
||||
if uc.Status != "unused" {
|
||||
return 0, errorx.ErrBusinessLogic.WithMsg("优惠券已使用或失效")
|
||||
}
|
||||
|
||||
c, err := models.CouponQuery.WithContext(ctx).Where(models.CouponQuery.ID.Eq(uc.CouponID)).First()
|
||||
if err != nil {
|
||||
return 0, errorx.ErrRecordNotFound.WithMsg("优惠券信息缺失")
|
||||
}
|
||||
if tenantID > 0 && c.TenantID != tenantID {
|
||||
return 0, errorx.ErrForbidden.WithMsg("优惠券租户不匹配")
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
if !c.StartAt.IsZero() && now.Before(c.StartAt) {
|
||||
return 0, errorx.ErrBusinessLogic.WithMsg("优惠券尚未生效")
|
||||
}
|
||||
if !c.EndAt.IsZero() && now.After(c.EndAt) {
|
||||
return 0, errorx.ErrBusinessLogic.WithMsg("优惠券已过期")
|
||||
}
|
||||
|
||||
if amount < c.MinOrderAmount {
|
||||
return 0, errorx.ErrBusinessLogic.WithMsg("未达到优惠券使用门槛")
|
||||
}
|
||||
|
||||
var discount int64
|
||||
if c.Type == "fix_amount" {
|
||||
discount = c.Value
|
||||
} else if c.Type == "discount" {
|
||||
discount = (amount * c.Value) / 100
|
||||
if c.MaxDiscount > 0 && discount > c.MaxDiscount {
|
||||
discount = c.MaxDiscount
|
||||
}
|
||||
}
|
||||
|
||||
// Discount cannot exceed order amount
|
||||
if discount > amount {
|
||||
discount = amount
|
||||
}
|
||||
|
||||
return discount, nil
|
||||
return item
|
||||
}
|
||||
|
||||
// MarkUsed marks a user coupon as used (intended to be called inside a transaction)
|
||||
func (s *coupon) MarkUsed(ctx context.Context, tx *models.Query, tenantID, userCouponID, orderID int64) error {
|
||||
uc, err := tx.UserCoupon.WithContext(ctx).Where(tx.UserCoupon.ID.Eq(userCouponID)).First()
|
||||
func (s *coupon) composeCouponItem(c *models.Coupon) coupon_dto.CouponItem {
|
||||
return coupon_dto.CouponItem{
|
||||
ID: c.ID,
|
||||
Title: c.Title,
|
||||
Description: c.Description,
|
||||
Type: string(c.Type),
|
||||
Value: c.Value,
|
||||
MinOrderAmount: c.MinOrderAmount,
|
||||
MaxDiscount: c.MaxDiscount,
|
||||
TotalQuantity: c.TotalQuantity,
|
||||
UsedQuantity: c.UsedQuantity,
|
||||
StartAt: s.formatTime(c.StartAt),
|
||||
EndAt: s.formatTime(c.EndAt),
|
||||
CreatedAt: s.formatTime(c.CreatedAt),
|
||||
UpdatedAt: s.formatTime(c.UpdatedAt),
|
||||
}
|
||||
}
|
||||
|
||||
func (s *coupon) markUserCouponExpired(ctx context.Context, userCouponID int64) error {
|
||||
if userCouponID == 0 {
|
||||
return nil
|
||||
}
|
||||
_, err := models.UserCouponQuery.WithContext(ctx).
|
||||
Where(models.UserCouponQuery.ID.Eq(userCouponID), models.UserCouponQuery.Status.Eq(consts.UserCouponStatusUnused)).
|
||||
UpdateSimple(models.UserCouponQuery.Status.Value(consts.UserCouponStatusExpired))
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return errorx.ErrRecordNotFound.WithMsg("优惠券不存在")
|
||||
}
|
||||
return errorx.ErrDatabaseError.WithCause(err)
|
||||
}
|
||||
if uc.Status != "unused" {
|
||||
return errorx.ErrBusinessLogic.WithMsg("优惠券核销失败")
|
||||
}
|
||||
|
||||
c, err := tx.Coupon.WithContext(ctx).Where(tx.Coupon.ID.Eq(uc.CouponID)).First()
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return errorx.ErrRecordNotFound.WithMsg("优惠券信息缺失")
|
||||
}
|
||||
return errorx.ErrDatabaseError.WithCause(err)
|
||||
}
|
||||
if tenantID > 0 && c.TenantID != tenantID {
|
||||
return errorx.ErrForbidden.WithMsg("优惠券租户不匹配")
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
// Update User Coupon
|
||||
info, err := tx.UserCoupon.WithContext(ctx).
|
||||
Where(tx.UserCoupon.ID.Eq(userCouponID), tx.UserCoupon.Status.Eq("unused")).
|
||||
Updates(&models.UserCoupon{
|
||||
Status: "used",
|
||||
OrderID: orderID,
|
||||
UsedAt: now,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if info.RowsAffected == 0 {
|
||||
return errorx.ErrBusinessLogic.WithMsg("优惠券核销失败")
|
||||
}
|
||||
|
||||
// Update Coupon used quantity (Optional, but good for stats)
|
||||
_, _ = tx.Coupon.WithContext(ctx).Where(tx.Coupon.ID.Eq(uc.CouponID)).UpdateSimple(tx.Coupon.UsedQuantity.Add(1))
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *coupon) isCouponExpired(c *models.Coupon, now time.Time) bool {
|
||||
return !c.EndAt.IsZero() && now.After(c.EndAt)
|
||||
}
|
||||
|
||||
func (s *coupon) isCouponActive(c *models.Coupon, now time.Time) bool {
|
||||
if !c.StartAt.IsZero() && now.Before(c.StartAt) {
|
||||
return false
|
||||
}
|
||||
if !c.EndAt.IsZero() && now.After(c.EndAt) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (s *coupon) parseTime(val string) (time.Time, error) {
|
||||
value := strings.TrimSpace(val)
|
||||
if value == "" {
|
||||
return time.Time{}, nil
|
||||
}
|
||||
tm, err := time.Parse(time.RFC3339, value)
|
||||
if err != nil {
|
||||
return time.Time{}, errorx.ErrInvalidFormat.WithMsg("时间格式无效")
|
||||
}
|
||||
return tm, nil
|
||||
}
|
||||
|
||||
func (s *coupon) parseCouponType(val string) (consts.CouponType, error) {
|
||||
value := strings.TrimSpace(val)
|
||||
if value == "" {
|
||||
return "", errorx.ErrInvalidParameter.WithMsg("优惠券类型不能为空")
|
||||
}
|
||||
couponType, err := consts.ParseCouponType(value)
|
||||
if err != nil {
|
||||
return "", errorx.ErrInvalidParameter.WithMsg("优惠券类型无效")
|
||||
}
|
||||
return couponType, nil
|
||||
}
|
||||
|
||||
func (s *coupon) validateCouponValue(typ consts.CouponType, value, maxDiscount int64) error {
|
||||
switch typ {
|
||||
case consts.CouponTypeFixAmount:
|
||||
if value <= 0 {
|
||||
return errorx.ErrInvalidParameter.WithMsg("优惠券面值无效")
|
||||
}
|
||||
case consts.CouponTypeDiscount:
|
||||
if value <= 0 || value > 100 {
|
||||
return errorx.ErrInvalidParameter.WithMsg("折扣比例无效")
|
||||
}
|
||||
if maxDiscount < 0 {
|
||||
return errorx.ErrInvalidParameter.WithMsg("最高抵扣金额无效")
|
||||
}
|
||||
default:
|
||||
return errorx.ErrInvalidParameter.WithMsg("优惠券类型无效")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *coupon) formatTime(t time.Time) string {
|
||||
if t.IsZero() {
|
||||
return ""
|
||||
}
|
||||
return t.Format(time.RFC3339)
|
||||
}
|
||||
|
||||
@@ -58,7 +58,7 @@ func (s *CouponTestSuite) Test_CouponFlow() {
|
||||
cp := &models.Coupon{
|
||||
TenantID: tenantID,
|
||||
Title: "Save 5",
|
||||
Type: "fix_amount",
|
||||
Type: consts.CouponTypeFixAmount,
|
||||
Value: 500,
|
||||
MinOrderAmount: 1000,
|
||||
}
|
||||
@@ -68,7 +68,7 @@ func (s *CouponTestSuite) Test_CouponFlow() {
|
||||
uc := &models.UserCoupon{
|
||||
UserID: user.ID,
|
||||
CouponID: cp.ID,
|
||||
Status: "unused",
|
||||
Status: consts.UserCouponStatusUnused,
|
||||
}
|
||||
models.UserCouponQuery.WithContext(ctx).Create(uc)
|
||||
|
||||
@@ -91,7 +91,7 @@ func (s *CouponTestSuite) Test_CouponFlow() {
|
||||
TenantID: tenantID,
|
||||
ContentID: c.ID,
|
||||
PriceAmount: 2000, // 20.00 CNY
|
||||
Currency: "CNY",
|
||||
Currency: consts.CurrencyCNY,
|
||||
})
|
||||
|
||||
form := &order_dto.OrderCreateForm{
|
||||
@@ -112,8 +112,87 @@ func (s *CouponTestSuite) Test_CouponFlow() {
|
||||
|
||||
// Verify Coupon Status
|
||||
ucReload, _ := models.UserCouponQuery.WithContext(ctx).Where(models.UserCouponQuery.ID.Eq(uc.ID)).First()
|
||||
So(ucReload.Status, ShouldEqual, "used")
|
||||
So(ucReload.Status, ShouldEqual, consts.UserCouponStatusUsed)
|
||||
So(ucReload.OrderID, ShouldEqual, oid)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func (s *CouponTestSuite) Test_Receive() {
|
||||
Convey("Receive", s.T(), func() {
|
||||
ctx := s.T().Context()
|
||||
tenantID := int64(2)
|
||||
database.Truncate(
|
||||
ctx,
|
||||
s.DB,
|
||||
models.TableNameCoupon,
|
||||
models.TableNameUserCoupon,
|
||||
models.TableNameUser,
|
||||
)
|
||||
|
||||
user := &models.User{Username: "coupon_receive", Phone: "13800000002"}
|
||||
So(models.UserQuery.WithContext(ctx).Create(user), ShouldBeNil)
|
||||
|
||||
cp := &models.Coupon{
|
||||
TenantID: tenantID,
|
||||
Title: "Receive Test",
|
||||
Type: consts.CouponTypeFixAmount,
|
||||
Value: 300,
|
||||
MinOrderAmount: 0,
|
||||
TotalQuantity: 1,
|
||||
}
|
||||
So(models.CouponQuery.WithContext(ctx).Create(cp), ShouldBeNil)
|
||||
|
||||
item, err := Coupon.Receive(ctx, tenantID, user.ID, cp.ID)
|
||||
So(err, ShouldBeNil)
|
||||
So(item, ShouldNotBeNil)
|
||||
So(item.CouponID, ShouldEqual, cp.ID)
|
||||
|
||||
// second receive should return existing
|
||||
item2, err := Coupon.Receive(ctx, tenantID, user.ID, cp.ID)
|
||||
So(err, ShouldBeNil)
|
||||
So(item2.CouponID, ShouldEqual, cp.ID)
|
||||
})
|
||||
}
|
||||
|
||||
func (s *CouponTestSuite) Test_ListAvailable() {
|
||||
Convey("ListAvailable", s.T(), func() {
|
||||
ctx := s.T().Context()
|
||||
tenantID := int64(3)
|
||||
database.Truncate(
|
||||
ctx,
|
||||
s.DB,
|
||||
models.TableNameCoupon,
|
||||
models.TableNameUserCoupon,
|
||||
models.TableNameUser,
|
||||
)
|
||||
|
||||
user := &models.User{Username: "coupon_available", Phone: "13800000003"}
|
||||
So(models.UserQuery.WithContext(ctx).Create(user), ShouldBeNil)
|
||||
|
||||
cp := &models.Coupon{
|
||||
TenantID: tenantID,
|
||||
Title: "Available Test",
|
||||
Type: consts.CouponTypeFixAmount,
|
||||
Value: 500,
|
||||
MinOrderAmount: 1000,
|
||||
}
|
||||
So(models.CouponQuery.WithContext(ctx).Create(cp), ShouldBeNil)
|
||||
|
||||
uc := &models.UserCoupon{
|
||||
UserID: user.ID,
|
||||
CouponID: cp.ID,
|
||||
Status: consts.UserCouponStatusUnused,
|
||||
}
|
||||
So(models.UserCouponQuery.WithContext(ctx).Create(uc), ShouldBeNil)
|
||||
|
||||
list, err := Coupon.ListAvailable(ctx, tenantID, user.ID, 500)
|
||||
So(err, ShouldBeNil)
|
||||
So(len(list), ShouldEqual, 0)
|
||||
|
||||
list, err = Coupon.ListAvailable(ctx, tenantID, user.ID, 1200)
|
||||
So(err, ShouldBeNil)
|
||||
So(len(list), ShouldEqual, 1)
|
||||
So(list[0].CouponID, ShouldEqual, cp.ID)
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user