feat: implement coupon management and receive flow
This commit is contained in:
2
TODO.md
2
TODO.md
@@ -11,4 +11,4 @@
|
|||||||
- [x] **Media Pipeline**: Implement FFmpeg integration (mock call in job).
|
- [x] **Media Pipeline**: Implement FFmpeg integration (mock call in job).
|
||||||
|
|
||||||
## 3. Growth
|
## 3. Growth
|
||||||
- [ ] **Coupons**: Implement coupon logic.
|
- [x] **Coupons**: Implement coupon logic.
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package v1
|
package v1
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"quyun/v2/app/errorx"
|
||||||
"quyun/v2/app/http/v1/dto"
|
"quyun/v2/app/http/v1/dto"
|
||||||
"quyun/v2/app/requests"
|
"quyun/v2/app/requests"
|
||||||
"quyun/v2/app/services"
|
"quyun/v2/app/services"
|
||||||
@@ -359,3 +360,103 @@ func (c *Creator) Withdraw(ctx fiber.Ctx, user *models.User, form *dto.WithdrawF
|
|||||||
tenantID := getTenantID(ctx)
|
tenantID := getTenantID(ctx)
|
||||||
return services.Creator.Withdraw(ctx, tenantID, user.ID, form)
|
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
|
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 {
|
type UserCouponItem struct {
|
||||||
// ID 用户券ID。
|
// ID 用户券ID。
|
||||||
ID int64 `json:"id"`
|
ID int64 `json:"id"`
|
||||||
|
|||||||
@@ -175,6 +175,18 @@ func (r *Routes) Register(router fiber.Router) {
|
|||||||
Local[*models.User]("__ctx_user"),
|
Local[*models.User]("__ctx_user"),
|
||||||
PathParam[int64]("id"),
|
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")
|
r.log.Debugf("Registering route: Get /t/:tenantCode/v1/creator/dashboard -> creator.Dashboard")
|
||||||
router.Get("/t/:tenantCode/v1/creator/dashboard"[len(r.Path()):], DataFunc1(
|
router.Get("/t/:tenantCode/v1/creator/dashboard"[len(r.Path()):], DataFunc1(
|
||||||
r.creator.Dashboard,
|
r.creator.Dashboard,
|
||||||
@@ -214,6 +226,19 @@ func (r *Routes) Register(router fiber.Router) {
|
|||||||
Local[*models.User]("__ctx_user"),
|
Local[*models.User]("__ctx_user"),
|
||||||
Body[dto.ContentCreateForm]("form"),
|
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")
|
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(
|
router.Post("/t/:tenantCode/v1/creator/members/:id<int>/review"[len(r.Path()):], Func3(
|
||||||
r.creator.ReviewMember,
|
r.creator.ReviewMember,
|
||||||
@@ -259,6 +284,13 @@ func (r *Routes) Register(router fiber.Router) {
|
|||||||
PathParam[int64]("id"),
|
PathParam[int64]("id"),
|
||||||
Body[dto.ContentUpdateForm]("form"),
|
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")
|
r.log.Debugf("Registering route: Put /t/:tenantCode/v1/creator/settings -> creator.UpdateSettings")
|
||||||
router.Put("/t/:tenantCode/v1/creator/settings"[len(r.Path()):], Func2(
|
router.Put("/t/:tenantCode/v1/creator/settings"[len(r.Path()):], Func2(
|
||||||
r.creator.UpdateSettings,
|
r.creator.UpdateSettings,
|
||||||
@@ -374,6 +406,12 @@ func (r *Routes) Register(router fiber.Router) {
|
|||||||
Local[*models.User]("__ctx_user"),
|
Local[*models.User]("__ctx_user"),
|
||||||
QueryParam[string]("status"),
|
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")
|
r.log.Debugf("Registering route: Get /t/:tenantCode/v1/me/favorites -> user.Favorites")
|
||||||
router.Get("/t/:tenantCode/v1/me/favorites"[len(r.Path()):], DataFunc1(
|
router.Get("/t/:tenantCode/v1/me/favorites"[len(r.Path()):], DataFunc1(
|
||||||
r.user.Favorites,
|
r.user.Favorites,
|
||||||
@@ -418,6 +456,12 @@ func (r *Routes) Register(router fiber.Router) {
|
|||||||
r.user.Wallet,
|
r.user.Wallet,
|
||||||
Local[*models.User]("__ctx_user"),
|
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")
|
r.log.Debugf("Registering route: Post /t/:tenantCode/v1/me/favorites -> user.AddFavorite")
|
||||||
router.Post("/t/:tenantCode/v1/me/favorites"[len(r.Path()):], Func2(
|
router.Post("/t/:tenantCode/v1/me/favorites"[len(r.Path()):], Func2(
|
||||||
r.user.AddFavorite,
|
r.user.AddFavorite,
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package v1
|
package v1
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"quyun/v2/app/errorx"
|
||||||
"quyun/v2/app/http/v1/dto"
|
"quyun/v2/app/http/v1/dto"
|
||||||
auth_dto "quyun/v2/app/http/v1/dto"
|
auth_dto "quyun/v2/app/http/v1/dto"
|
||||||
"quyun/v2/app/requests"
|
"quyun/v2/app/requests"
|
||||||
@@ -319,3 +320,40 @@ func (u *User) MyCoupons(ctx fiber.Ctx, user *models.User, status string) ([]dto
|
|||||||
tenantID := getTenantID(ctx)
|
tenantID := getTenantID(ctx)
|
||||||
return services.Coupon.ListUserCoupons(ctx, tenantID, user.ID, status)
|
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 (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"quyun/v2/app/errorx"
|
"quyun/v2/app/errorx"
|
||||||
coupon_dto "quyun/v2/app/http/v1/dto"
|
coupon_dto "quyun/v2/app/http/v1/dto"
|
||||||
|
"quyun/v2/app/requests"
|
||||||
"quyun/v2/database/models"
|
"quyun/v2/database/models"
|
||||||
|
"quyun/v2/pkg/consts"
|
||||||
|
|
||||||
|
"go.ipao.vip/gen/field"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
|
"gorm.io/gorm/clause"
|
||||||
)
|
)
|
||||||
|
|
||||||
// @provider
|
// @provider
|
||||||
@@ -24,13 +29,15 @@ func (s *coupon) ListUserCoupons(
|
|||||||
if userID == 0 {
|
if userID == 0 {
|
||||||
return nil, errorx.ErrUnauthorized
|
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)
|
tbl, q := models.UserCouponQuery.QueryContext(ctx)
|
||||||
q = q.Where(tbl.UserID.Eq(uid))
|
q = q.Where(tbl.UserID.Eq(userID))
|
||||||
if status != "" {
|
|
||||||
q = q.Where(tbl.Status.Eq(status))
|
|
||||||
}
|
|
||||||
|
|
||||||
list, err := q.Order(tbl.CreatedAt.Desc()).Find()
|
list, err := q.Order(tbl.CreatedAt.Desc()).Find()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -40,9 +47,690 @@ func (s *coupon) ListUserCoupons(
|
|||||||
return []coupon_dto.UserCouponItem{}, nil
|
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))
|
couponIDSet := make(map[int64]struct{}, len(list))
|
||||||
couponIDs := make([]int64, 0, len(list))
|
couponIDs := make([]int64, 0, len(list))
|
||||||
for _, v := range list {
|
for _, v := range list {
|
||||||
|
if v == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
if _, ok := couponIDSet[v.CouponID]; ok {
|
if _, ok := couponIDSet[v.CouponID]; ok {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -64,129 +752,118 @@ func (s *coupon) ListUserCoupons(
|
|||||||
for _, c := range coupons {
|
for _, c := range coupons {
|
||||||
couponMap[c.ID] = c
|
couponMap[c.ID] = c
|
||||||
}
|
}
|
||||||
|
return couponMap, nil
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate checks if a coupon can be used for an order and returns the discount amount
|
func (s *coupon) composeUserCouponItem(uc *models.UserCoupon, c *models.Coupon, status consts.UserCouponStatus) coupon_dto.UserCouponItem {
|
||||||
func (s *coupon) Validate(ctx context.Context, tenantID, userID, userCouponID, amount int64) (int64, error) {
|
item := coupon_dto.UserCouponItem{
|
||||||
uc, err := models.UserCouponQuery.WithContext(ctx).Where(models.UserCouponQuery.ID.Eq(userCouponID)).First()
|
ID: uc.ID,
|
||||||
if err != nil {
|
CouponID: uc.CouponID,
|
||||||
return 0, errorx.ErrRecordNotFound.WithMsg("优惠券不存在")
|
Status: string(status),
|
||||||
}
|
}
|
||||||
if uc.UserID != userID {
|
if c != nil {
|
||||||
return 0, errorx.ErrUnauthorized.WithMsg("无权使用该优惠券")
|
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 item
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MarkUsed marks a user coupon as used (intended to be called inside a transaction)
|
func (s *coupon) composeCouponItem(c *models.Coupon) coupon_dto.CouponItem {
|
||||||
func (s *coupon) MarkUsed(ctx context.Context, tx *models.Query, tenantID, userCouponID, orderID int64) error {
|
return coupon_dto.CouponItem{
|
||||||
uc, err := tx.UserCoupon.WithContext(ctx).Where(tx.UserCoupon.ID.Eq(userCouponID)).First()
|
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 err != nil {
|
||||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
||||||
return errorx.ErrRecordNotFound.WithMsg("优惠券不存在")
|
|
||||||
}
|
|
||||||
return errorx.ErrDatabaseError.WithCause(err)
|
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
|
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{
|
cp := &models.Coupon{
|
||||||
TenantID: tenantID,
|
TenantID: tenantID,
|
||||||
Title: "Save 5",
|
Title: "Save 5",
|
||||||
Type: "fix_amount",
|
Type: consts.CouponTypeFixAmount,
|
||||||
Value: 500,
|
Value: 500,
|
||||||
MinOrderAmount: 1000,
|
MinOrderAmount: 1000,
|
||||||
}
|
}
|
||||||
@@ -68,7 +68,7 @@ func (s *CouponTestSuite) Test_CouponFlow() {
|
|||||||
uc := &models.UserCoupon{
|
uc := &models.UserCoupon{
|
||||||
UserID: user.ID,
|
UserID: user.ID,
|
||||||
CouponID: cp.ID,
|
CouponID: cp.ID,
|
||||||
Status: "unused",
|
Status: consts.UserCouponStatusUnused,
|
||||||
}
|
}
|
||||||
models.UserCouponQuery.WithContext(ctx).Create(uc)
|
models.UserCouponQuery.WithContext(ctx).Create(uc)
|
||||||
|
|
||||||
@@ -91,7 +91,7 @@ func (s *CouponTestSuite) Test_CouponFlow() {
|
|||||||
TenantID: tenantID,
|
TenantID: tenantID,
|
||||||
ContentID: c.ID,
|
ContentID: c.ID,
|
||||||
PriceAmount: 2000, // 20.00 CNY
|
PriceAmount: 2000, // 20.00 CNY
|
||||||
Currency: "CNY",
|
Currency: consts.CurrencyCNY,
|
||||||
})
|
})
|
||||||
|
|
||||||
form := &order_dto.OrderCreateForm{
|
form := &order_dto.OrderCreateForm{
|
||||||
@@ -112,8 +112,87 @@ func (s *CouponTestSuite) Test_CouponFlow() {
|
|||||||
|
|
||||||
// Verify Coupon Status
|
// Verify Coupon Status
|
||||||
ucReload, _ := models.UserCouponQuery.WithContext(ctx).Where(models.UserCouponQuery.ID.Eq(uc.ID)).First()
|
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)
|
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)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -48,6 +48,10 @@ field_type:
|
|||||||
status: consts.ContentAccessStatus
|
status: consts.ContentAccessStatus
|
||||||
tenant_ledgers:
|
tenant_ledgers:
|
||||||
type: consts.TenantLedgerType
|
type: consts.TenantLedgerType
|
||||||
|
coupons:
|
||||||
|
type: consts.CouponType
|
||||||
|
user_coupons:
|
||||||
|
status: consts.UserCouponStatus
|
||||||
field_relate:
|
field_relate:
|
||||||
contents:
|
contents:
|
||||||
Author:
|
Author:
|
||||||
|
|||||||
@@ -40,9 +40,9 @@ type Content struct {
|
|||||||
DeletedAt gorm.DeletedAt `gorm:"column:deleted_at;type:timestamp with time zone" json:"deleted_at"`
|
DeletedAt gorm.DeletedAt `gorm:"column:deleted_at;type:timestamp with time zone" json:"deleted_at"`
|
||||||
Key string `gorm:"column:key;type:character varying(32);comment:Musical key/tone" json:"key"` // Musical key/tone
|
Key string `gorm:"column:key;type:character varying(32);comment:Musical key/tone" json:"key"` // Musical key/tone
|
||||||
IsPinned bool `gorm:"column:is_pinned;type:boolean;comment:Whether content is pinned/featured" json:"is_pinned"` // Whether content is pinned/featured
|
IsPinned bool `gorm:"column:is_pinned;type:boolean;comment:Whether content is pinned/featured" json:"is_pinned"` // Whether content is pinned/featured
|
||||||
|
Comments []*Comment `gorm:"foreignKey:ContentID;references:ID" json:"comments,omitempty"`
|
||||||
Author *User `gorm:"foreignKey:UserID;references:ID" json:"author,omitempty"`
|
Author *User `gorm:"foreignKey:UserID;references:ID" json:"author,omitempty"`
|
||||||
ContentAssets []*ContentAsset `gorm:"foreignKey:ContentID;references:ID" json:"content_assets,omitempty"`
|
ContentAssets []*ContentAsset `gorm:"foreignKey:ContentID;references:ID" json:"content_assets,omitempty"`
|
||||||
Comments []*Comment `gorm:"foreignKey:ContentID;references:ID" json:"comments,omitempty"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Quick operations without importing query package
|
// Quick operations without importing query package
|
||||||
|
|||||||
@@ -46,6 +46,12 @@ func newContent(db *gorm.DB, opts ...gen.DOOption) contentQuery {
|
|||||||
_contentQuery.DeletedAt = field.NewField(tableName, "deleted_at")
|
_contentQuery.DeletedAt = field.NewField(tableName, "deleted_at")
|
||||||
_contentQuery.Key = field.NewString(tableName, "key")
|
_contentQuery.Key = field.NewString(tableName, "key")
|
||||||
_contentQuery.IsPinned = field.NewBool(tableName, "is_pinned")
|
_contentQuery.IsPinned = field.NewBool(tableName, "is_pinned")
|
||||||
|
_contentQuery.Comments = contentQueryHasManyComments{
|
||||||
|
db: db.Session(&gorm.Session{}),
|
||||||
|
|
||||||
|
RelationField: field.NewRelation("Comments", "Comment"),
|
||||||
|
}
|
||||||
|
|
||||||
_contentQuery.Author = contentQueryBelongsToAuthor{
|
_contentQuery.Author = contentQueryBelongsToAuthor{
|
||||||
db: db.Session(&gorm.Session{}),
|
db: db.Session(&gorm.Session{}),
|
||||||
|
|
||||||
@@ -58,12 +64,6 @@ func newContent(db *gorm.DB, opts ...gen.DOOption) contentQuery {
|
|||||||
RelationField: field.NewRelation("ContentAssets", "ContentAsset"),
|
RelationField: field.NewRelation("ContentAssets", "ContentAsset"),
|
||||||
}
|
}
|
||||||
|
|
||||||
_contentQuery.Comments = contentQueryHasManyComments{
|
|
||||||
db: db.Session(&gorm.Session{}),
|
|
||||||
|
|
||||||
RelationField: field.NewRelation("Comments", "Comment"),
|
|
||||||
}
|
|
||||||
|
|
||||||
_contentQuery.fillFieldMap()
|
_contentQuery.fillFieldMap()
|
||||||
|
|
||||||
return _contentQuery
|
return _contentQuery
|
||||||
@@ -94,12 +94,12 @@ type contentQuery struct {
|
|||||||
DeletedAt field.Field
|
DeletedAt field.Field
|
||||||
Key field.String // Musical key/tone
|
Key field.String // Musical key/tone
|
||||||
IsPinned field.Bool // Whether content is pinned/featured
|
IsPinned field.Bool // Whether content is pinned/featured
|
||||||
Author contentQueryBelongsToAuthor
|
Comments contentQueryHasManyComments
|
||||||
|
|
||||||
|
Author contentQueryBelongsToAuthor
|
||||||
|
|
||||||
ContentAssets contentQueryHasManyContentAssets
|
ContentAssets contentQueryHasManyContentAssets
|
||||||
|
|
||||||
Comments contentQueryHasManyComments
|
|
||||||
|
|
||||||
fieldMap map[string]field.Expr
|
fieldMap map[string]field.Expr
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -195,23 +195,104 @@ func (c *contentQuery) fillFieldMap() {
|
|||||||
|
|
||||||
func (c contentQuery) clone(db *gorm.DB) contentQuery {
|
func (c contentQuery) clone(db *gorm.DB) contentQuery {
|
||||||
c.contentQueryDo.ReplaceConnPool(db.Statement.ConnPool)
|
c.contentQueryDo.ReplaceConnPool(db.Statement.ConnPool)
|
||||||
|
c.Comments.db = db.Session(&gorm.Session{Initialized: true})
|
||||||
|
c.Comments.db.Statement.ConnPool = db.Statement.ConnPool
|
||||||
c.Author.db = db.Session(&gorm.Session{Initialized: true})
|
c.Author.db = db.Session(&gorm.Session{Initialized: true})
|
||||||
c.Author.db.Statement.ConnPool = db.Statement.ConnPool
|
c.Author.db.Statement.ConnPool = db.Statement.ConnPool
|
||||||
c.ContentAssets.db = db.Session(&gorm.Session{Initialized: true})
|
c.ContentAssets.db = db.Session(&gorm.Session{Initialized: true})
|
||||||
c.ContentAssets.db.Statement.ConnPool = db.Statement.ConnPool
|
c.ContentAssets.db.Statement.ConnPool = db.Statement.ConnPool
|
||||||
c.Comments.db = db.Session(&gorm.Session{Initialized: true})
|
|
||||||
c.Comments.db.Statement.ConnPool = db.Statement.ConnPool
|
|
||||||
return c
|
return c
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c contentQuery) replaceDB(db *gorm.DB) contentQuery {
|
func (c contentQuery) replaceDB(db *gorm.DB) contentQuery {
|
||||||
c.contentQueryDo.ReplaceDB(db)
|
c.contentQueryDo.ReplaceDB(db)
|
||||||
|
c.Comments.db = db.Session(&gorm.Session{})
|
||||||
c.Author.db = db.Session(&gorm.Session{})
|
c.Author.db = db.Session(&gorm.Session{})
|
||||||
c.ContentAssets.db = db.Session(&gorm.Session{})
|
c.ContentAssets.db = db.Session(&gorm.Session{})
|
||||||
c.Comments.db = db.Session(&gorm.Session{})
|
|
||||||
return c
|
return c
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type contentQueryHasManyComments struct {
|
||||||
|
db *gorm.DB
|
||||||
|
|
||||||
|
field.RelationField
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a contentQueryHasManyComments) Where(conds ...field.Expr) *contentQueryHasManyComments {
|
||||||
|
if len(conds) == 0 {
|
||||||
|
return &a
|
||||||
|
}
|
||||||
|
|
||||||
|
exprs := make([]clause.Expression, 0, len(conds))
|
||||||
|
for _, cond := range conds {
|
||||||
|
exprs = append(exprs, cond.BeCond().(clause.Expression))
|
||||||
|
}
|
||||||
|
a.db = a.db.Clauses(clause.Where{Exprs: exprs})
|
||||||
|
return &a
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a contentQueryHasManyComments) WithContext(ctx context.Context) *contentQueryHasManyComments {
|
||||||
|
a.db = a.db.WithContext(ctx)
|
||||||
|
return &a
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a contentQueryHasManyComments) Session(session *gorm.Session) *contentQueryHasManyComments {
|
||||||
|
a.db = a.db.Session(session)
|
||||||
|
return &a
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a contentQueryHasManyComments) Model(m *Content) *contentQueryHasManyCommentsTx {
|
||||||
|
return &contentQueryHasManyCommentsTx{a.db.Model(m).Association(a.Name())}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a contentQueryHasManyComments) Unscoped() *contentQueryHasManyComments {
|
||||||
|
a.db = a.db.Unscoped()
|
||||||
|
return &a
|
||||||
|
}
|
||||||
|
|
||||||
|
type contentQueryHasManyCommentsTx struct{ tx *gorm.Association }
|
||||||
|
|
||||||
|
func (a contentQueryHasManyCommentsTx) Find() (result []*Comment, err error) {
|
||||||
|
return result, a.tx.Find(&result)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a contentQueryHasManyCommentsTx) Append(values ...*Comment) (err error) {
|
||||||
|
targetValues := make([]interface{}, len(values))
|
||||||
|
for i, v := range values {
|
||||||
|
targetValues[i] = v
|
||||||
|
}
|
||||||
|
return a.tx.Append(targetValues...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a contentQueryHasManyCommentsTx) Replace(values ...*Comment) (err error) {
|
||||||
|
targetValues := make([]interface{}, len(values))
|
||||||
|
for i, v := range values {
|
||||||
|
targetValues[i] = v
|
||||||
|
}
|
||||||
|
return a.tx.Replace(targetValues...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a contentQueryHasManyCommentsTx) Delete(values ...*Comment) (err error) {
|
||||||
|
targetValues := make([]interface{}, len(values))
|
||||||
|
for i, v := range values {
|
||||||
|
targetValues[i] = v
|
||||||
|
}
|
||||||
|
return a.tx.Delete(targetValues...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a contentQueryHasManyCommentsTx) Clear() error {
|
||||||
|
return a.tx.Clear()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a contentQueryHasManyCommentsTx) Count() int64 {
|
||||||
|
return a.tx.Count()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a contentQueryHasManyCommentsTx) Unscoped() *contentQueryHasManyCommentsTx {
|
||||||
|
a.tx = a.tx.Unscoped()
|
||||||
|
return &a
|
||||||
|
}
|
||||||
|
|
||||||
type contentQueryBelongsToAuthor struct {
|
type contentQueryBelongsToAuthor struct {
|
||||||
db *gorm.DB
|
db *gorm.DB
|
||||||
|
|
||||||
@@ -374,87 +455,6 @@ func (a contentQueryHasManyContentAssetsTx) Unscoped() *contentQueryHasManyConte
|
|||||||
return &a
|
return &a
|
||||||
}
|
}
|
||||||
|
|
||||||
type contentQueryHasManyComments struct {
|
|
||||||
db *gorm.DB
|
|
||||||
|
|
||||||
field.RelationField
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a contentQueryHasManyComments) Where(conds ...field.Expr) *contentQueryHasManyComments {
|
|
||||||
if len(conds) == 0 {
|
|
||||||
return &a
|
|
||||||
}
|
|
||||||
|
|
||||||
exprs := make([]clause.Expression, 0, len(conds))
|
|
||||||
for _, cond := range conds {
|
|
||||||
exprs = append(exprs, cond.BeCond().(clause.Expression))
|
|
||||||
}
|
|
||||||
a.db = a.db.Clauses(clause.Where{Exprs: exprs})
|
|
||||||
return &a
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a contentQueryHasManyComments) WithContext(ctx context.Context) *contentQueryHasManyComments {
|
|
||||||
a.db = a.db.WithContext(ctx)
|
|
||||||
return &a
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a contentQueryHasManyComments) Session(session *gorm.Session) *contentQueryHasManyComments {
|
|
||||||
a.db = a.db.Session(session)
|
|
||||||
return &a
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a contentQueryHasManyComments) Model(m *Content) *contentQueryHasManyCommentsTx {
|
|
||||||
return &contentQueryHasManyCommentsTx{a.db.Model(m).Association(a.Name())}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a contentQueryHasManyComments) Unscoped() *contentQueryHasManyComments {
|
|
||||||
a.db = a.db.Unscoped()
|
|
||||||
return &a
|
|
||||||
}
|
|
||||||
|
|
||||||
type contentQueryHasManyCommentsTx struct{ tx *gorm.Association }
|
|
||||||
|
|
||||||
func (a contentQueryHasManyCommentsTx) Find() (result []*Comment, err error) {
|
|
||||||
return result, a.tx.Find(&result)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a contentQueryHasManyCommentsTx) Append(values ...*Comment) (err error) {
|
|
||||||
targetValues := make([]interface{}, len(values))
|
|
||||||
for i, v := range values {
|
|
||||||
targetValues[i] = v
|
|
||||||
}
|
|
||||||
return a.tx.Append(targetValues...)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a contentQueryHasManyCommentsTx) Replace(values ...*Comment) (err error) {
|
|
||||||
targetValues := make([]interface{}, len(values))
|
|
||||||
for i, v := range values {
|
|
||||||
targetValues[i] = v
|
|
||||||
}
|
|
||||||
return a.tx.Replace(targetValues...)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a contentQueryHasManyCommentsTx) Delete(values ...*Comment) (err error) {
|
|
||||||
targetValues := make([]interface{}, len(values))
|
|
||||||
for i, v := range values {
|
|
||||||
targetValues[i] = v
|
|
||||||
}
|
|
||||||
return a.tx.Delete(targetValues...)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a contentQueryHasManyCommentsTx) Clear() error {
|
|
||||||
return a.tx.Clear()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a contentQueryHasManyCommentsTx) Count() int64 {
|
|
||||||
return a.tx.Count()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a contentQueryHasManyCommentsTx) Unscoped() *contentQueryHasManyCommentsTx {
|
|
||||||
a.tx = a.tx.Unscoped()
|
|
||||||
return &a
|
|
||||||
}
|
|
||||||
|
|
||||||
type contentQueryDo struct{ gen.DO }
|
type contentQueryDo struct{ gen.DO }
|
||||||
|
|
||||||
func (c contentQueryDo) Debug() *contentQueryDo {
|
func (c contentQueryDo) Debug() *contentQueryDo {
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"quyun/v2/pkg/consts"
|
||||||
|
|
||||||
"go.ipao.vip/gen"
|
"go.ipao.vip/gen"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -15,20 +17,20 @@ const TableNameCoupon = "coupons"
|
|||||||
|
|
||||||
// Coupon mapped from table <coupons>
|
// Coupon mapped from table <coupons>
|
||||||
type Coupon struct {
|
type Coupon struct {
|
||||||
ID int64 `gorm:"column:id;type:bigint;primaryKey;autoIncrement:true" json:"id"`
|
ID int64 `gorm:"column:id;type:bigint;primaryKey;autoIncrement:true" json:"id"`
|
||||||
TenantID int64 `gorm:"column:tenant_id;type:bigint;not null" json:"tenant_id"`
|
TenantID int64 `gorm:"column:tenant_id;type:bigint;not null" json:"tenant_id"`
|
||||||
Title string `gorm:"column:title;type:character varying(255);not null" json:"title"`
|
Title string `gorm:"column:title;type:character varying(255);not null" json:"title"`
|
||||||
Description string `gorm:"column:description;type:text" json:"description"`
|
Description string `gorm:"column:description;type:text" json:"description"`
|
||||||
Type string `gorm:"column:type;type:character varying(32);not null" json:"type"`
|
Type consts.CouponType `gorm:"column:type;type:character varying(32);not null" json:"type"`
|
||||||
Value int64 `gorm:"column:value;type:bigint;not null" json:"value"`
|
Value int64 `gorm:"column:value;type:bigint;not null" json:"value"`
|
||||||
MinOrderAmount int64 `gorm:"column:min_order_amount;type:bigint;not null" json:"min_order_amount"`
|
MinOrderAmount int64 `gorm:"column:min_order_amount;type:bigint;not null" json:"min_order_amount"`
|
||||||
MaxDiscount int64 `gorm:"column:max_discount;type:bigint" json:"max_discount"`
|
MaxDiscount int64 `gorm:"column:max_discount;type:bigint" json:"max_discount"`
|
||||||
TotalQuantity int32 `gorm:"column:total_quantity;type:integer;not null" json:"total_quantity"`
|
TotalQuantity int32 `gorm:"column:total_quantity;type:integer;not null" json:"total_quantity"`
|
||||||
UsedQuantity int32 `gorm:"column:used_quantity;type:integer;not null" json:"used_quantity"`
|
UsedQuantity int32 `gorm:"column:used_quantity;type:integer;not null" json:"used_quantity"`
|
||||||
StartAt time.Time `gorm:"column:start_at;type:timestamp with time zone" json:"start_at"`
|
StartAt time.Time `gorm:"column:start_at;type:timestamp with time zone" json:"start_at"`
|
||||||
EndAt time.Time `gorm:"column:end_at;type:timestamp with time zone" json:"end_at"`
|
EndAt time.Time `gorm:"column:end_at;type:timestamp with time zone" json:"end_at"`
|
||||||
CreatedAt time.Time `gorm:"column:created_at;type:timestamp with time zone;not null;default:now()" json:"created_at"`
|
CreatedAt time.Time `gorm:"column:created_at;type:timestamp with time zone;not null;default:now()" json:"created_at"`
|
||||||
UpdatedAt time.Time `gorm:"column:updated_at;type:timestamp with time zone;not null;default:now()" json:"updated_at"`
|
UpdatedAt time.Time `gorm:"column:updated_at;type:timestamp with time zone;not null;default:now()" json:"updated_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Quick operations without importing query package
|
// Quick operations without importing query package
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ func newCoupon(db *gorm.DB, opts ...gen.DOOption) couponQuery {
|
|||||||
_couponQuery.TenantID = field.NewInt64(tableName, "tenant_id")
|
_couponQuery.TenantID = field.NewInt64(tableName, "tenant_id")
|
||||||
_couponQuery.Title = field.NewString(tableName, "title")
|
_couponQuery.Title = field.NewString(tableName, "title")
|
||||||
_couponQuery.Description = field.NewString(tableName, "description")
|
_couponQuery.Description = field.NewString(tableName, "description")
|
||||||
_couponQuery.Type = field.NewString(tableName, "type")
|
_couponQuery.Type = field.NewField(tableName, "type")
|
||||||
_couponQuery.Value = field.NewInt64(tableName, "value")
|
_couponQuery.Value = field.NewInt64(tableName, "value")
|
||||||
_couponQuery.MinOrderAmount = field.NewInt64(tableName, "min_order_amount")
|
_couponQuery.MinOrderAmount = field.NewInt64(tableName, "min_order_amount")
|
||||||
_couponQuery.MaxDiscount = field.NewInt64(tableName, "max_discount")
|
_couponQuery.MaxDiscount = field.NewInt64(tableName, "max_discount")
|
||||||
@@ -53,7 +53,7 @@ type couponQuery struct {
|
|||||||
TenantID field.Int64
|
TenantID field.Int64
|
||||||
Title field.String
|
Title field.String
|
||||||
Description field.String
|
Description field.String
|
||||||
Type field.String
|
Type field.Field
|
||||||
Value field.Int64
|
Value field.Int64
|
||||||
MinOrderAmount field.Int64
|
MinOrderAmount field.Int64
|
||||||
MaxDiscount field.Int64
|
MaxDiscount field.Int64
|
||||||
@@ -83,7 +83,7 @@ func (c *couponQuery) updateTableName(table string) *couponQuery {
|
|||||||
c.TenantID = field.NewInt64(table, "tenant_id")
|
c.TenantID = field.NewInt64(table, "tenant_id")
|
||||||
c.Title = field.NewString(table, "title")
|
c.Title = field.NewString(table, "title")
|
||||||
c.Description = field.NewString(table, "description")
|
c.Description = field.NewString(table, "description")
|
||||||
c.Type = field.NewString(table, "type")
|
c.Type = field.NewField(table, "type")
|
||||||
c.Value = field.NewInt64(table, "value")
|
c.Value = field.NewInt64(table, "value")
|
||||||
c.MinOrderAmount = field.NewInt64(table, "min_order_amount")
|
c.MinOrderAmount = field.NewInt64(table, "min_order_amount")
|
||||||
c.MaxDiscount = field.NewInt64(table, "max_discount")
|
c.MaxDiscount = field.NewInt64(table, "max_discount")
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"quyun/v2/pkg/consts"
|
||||||
|
|
||||||
"go.ipao.vip/gen"
|
"go.ipao.vip/gen"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -15,13 +17,13 @@ const TableNameUserCoupon = "user_coupons"
|
|||||||
|
|
||||||
// UserCoupon mapped from table <user_coupons>
|
// UserCoupon mapped from table <user_coupons>
|
||||||
type UserCoupon struct {
|
type UserCoupon struct {
|
||||||
ID int64 `gorm:"column:id;type:bigint;primaryKey;autoIncrement:true" json:"id"`
|
ID int64 `gorm:"column:id;type:bigint;primaryKey;autoIncrement:true" json:"id"`
|
||||||
UserID int64 `gorm:"column:user_id;type:bigint;not null" json:"user_id"`
|
UserID int64 `gorm:"column:user_id;type:bigint;not null" json:"user_id"`
|
||||||
CouponID int64 `gorm:"column:coupon_id;type:bigint;not null" json:"coupon_id"`
|
CouponID int64 `gorm:"column:coupon_id;type:bigint;not null" json:"coupon_id"`
|
||||||
OrderID int64 `gorm:"column:order_id;type:bigint" json:"order_id"`
|
OrderID int64 `gorm:"column:order_id;type:bigint" json:"order_id"`
|
||||||
Status string `gorm:"column:status;type:character varying(32);not null;default:unused" json:"status"`
|
Status consts.UserCouponStatus `gorm:"column:status;type:character varying(32);not null;default:unused" json:"status"`
|
||||||
UsedAt time.Time `gorm:"column:used_at;type:timestamp with time zone" json:"used_at"`
|
UsedAt time.Time `gorm:"column:used_at;type:timestamp with time zone" json:"used_at"`
|
||||||
CreatedAt time.Time `gorm:"column:created_at;type:timestamp with time zone;not null;default:now()" json:"created_at"`
|
CreatedAt time.Time `gorm:"column:created_at;type:timestamp with time zone;not null;default:now()" json:"created_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Quick operations without importing query package
|
// Quick operations without importing query package
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ func newUserCoupon(db *gorm.DB, opts ...gen.DOOption) userCouponQuery {
|
|||||||
_userCouponQuery.UserID = field.NewInt64(tableName, "user_id")
|
_userCouponQuery.UserID = field.NewInt64(tableName, "user_id")
|
||||||
_userCouponQuery.CouponID = field.NewInt64(tableName, "coupon_id")
|
_userCouponQuery.CouponID = field.NewInt64(tableName, "coupon_id")
|
||||||
_userCouponQuery.OrderID = field.NewInt64(tableName, "order_id")
|
_userCouponQuery.OrderID = field.NewInt64(tableName, "order_id")
|
||||||
_userCouponQuery.Status = field.NewString(tableName, "status")
|
_userCouponQuery.Status = field.NewField(tableName, "status")
|
||||||
_userCouponQuery.UsedAt = field.NewTime(tableName, "used_at")
|
_userCouponQuery.UsedAt = field.NewTime(tableName, "used_at")
|
||||||
_userCouponQuery.CreatedAt = field.NewTime(tableName, "created_at")
|
_userCouponQuery.CreatedAt = field.NewTime(tableName, "created_at")
|
||||||
|
|
||||||
@@ -46,7 +46,7 @@ type userCouponQuery struct {
|
|||||||
UserID field.Int64
|
UserID field.Int64
|
||||||
CouponID field.Int64
|
CouponID field.Int64
|
||||||
OrderID field.Int64
|
OrderID field.Int64
|
||||||
Status field.String
|
Status field.Field
|
||||||
UsedAt field.Time
|
UsedAt field.Time
|
||||||
CreatedAt field.Time
|
CreatedAt field.Time
|
||||||
|
|
||||||
@@ -69,7 +69,7 @@ func (u *userCouponQuery) updateTableName(table string) *userCouponQuery {
|
|||||||
u.UserID = field.NewInt64(table, "user_id")
|
u.UserID = field.NewInt64(table, "user_id")
|
||||||
u.CouponID = field.NewInt64(table, "coupon_id")
|
u.CouponID = field.NewInt64(table, "coupon_id")
|
||||||
u.OrderID = field.NewInt64(table, "order_id")
|
u.OrderID = field.NewInt64(table, "order_id")
|
||||||
u.Status = field.NewString(table, "status")
|
u.Status = field.NewField(table, "status")
|
||||||
u.UsedAt = field.NewTime(table, "used_at")
|
u.UsedAt = field.NewTime(table, "used_at")
|
||||||
u.CreatedAt = field.NewTime(table, "created_at")
|
u.CreatedAt = field.NewTime(table, "created_at")
|
||||||
|
|
||||||
|
|||||||
339
backend/pkg/consts/coupon.gen.go
Normal file
339
backend/pkg/consts/coupon.gen.go
Normal file
@@ -0,0 +1,339 @@
|
|||||||
|
// Code generated by go-enum DO NOT EDIT.
|
||||||
|
// Version: -
|
||||||
|
// Revision: -
|
||||||
|
// Build Date: -
|
||||||
|
// Built By: -
|
||||||
|
|
||||||
|
package consts
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql/driver"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
// CouponTypeFixAmount is a CouponType of type fix_amount.
|
||||||
|
CouponTypeFixAmount CouponType = "fix_amount"
|
||||||
|
// CouponTypeDiscount is a CouponType of type discount.
|
||||||
|
CouponTypeDiscount CouponType = "discount"
|
||||||
|
)
|
||||||
|
|
||||||
|
var ErrInvalidCouponType = fmt.Errorf("not a valid CouponType, try [%s]", strings.Join(_CouponTypeNames, ", "))
|
||||||
|
|
||||||
|
var _CouponTypeNames = []string{
|
||||||
|
string(CouponTypeFixAmount),
|
||||||
|
string(CouponTypeDiscount),
|
||||||
|
}
|
||||||
|
|
||||||
|
// CouponTypeNames returns a list of possible string values of CouponType.
|
||||||
|
func CouponTypeNames() []string {
|
||||||
|
tmp := make([]string, len(_CouponTypeNames))
|
||||||
|
copy(tmp, _CouponTypeNames)
|
||||||
|
return tmp
|
||||||
|
}
|
||||||
|
|
||||||
|
// CouponTypeValues returns a list of the values for CouponType
|
||||||
|
func CouponTypeValues() []CouponType {
|
||||||
|
return []CouponType{
|
||||||
|
CouponTypeFixAmount,
|
||||||
|
CouponTypeDiscount,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// String implements the Stringer interface.
|
||||||
|
func (x CouponType) String() string {
|
||||||
|
return string(x)
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsValid provides a quick way to determine if the typed value is
|
||||||
|
// part of the allowed enumerated values
|
||||||
|
func (x CouponType) IsValid() bool {
|
||||||
|
_, err := ParseCouponType(string(x))
|
||||||
|
return err == nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var _CouponTypeValue = map[string]CouponType{
|
||||||
|
"fix_amount": CouponTypeFixAmount,
|
||||||
|
"discount": CouponTypeDiscount,
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParseCouponType attempts to convert a string to a CouponType.
|
||||||
|
func ParseCouponType(name string) (CouponType, error) {
|
||||||
|
if x, ok := _CouponTypeValue[name]; ok {
|
||||||
|
return x, nil
|
||||||
|
}
|
||||||
|
return CouponType(""), fmt.Errorf("%s is %w", name, ErrInvalidCouponType)
|
||||||
|
}
|
||||||
|
|
||||||
|
var errCouponTypeNilPtr = errors.New("value pointer is nil") // one per type for package clashes
|
||||||
|
|
||||||
|
// Scan implements the Scanner interface.
|
||||||
|
func (x *CouponType) Scan(value interface{}) (err error) {
|
||||||
|
if value == nil {
|
||||||
|
*x = CouponType("")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// A wider range of scannable types.
|
||||||
|
// driver.Value values at the top of the list for expediency
|
||||||
|
switch v := value.(type) {
|
||||||
|
case string:
|
||||||
|
*x, err = ParseCouponType(v)
|
||||||
|
case []byte:
|
||||||
|
*x, err = ParseCouponType(string(v))
|
||||||
|
case CouponType:
|
||||||
|
*x = v
|
||||||
|
case *CouponType:
|
||||||
|
if v == nil {
|
||||||
|
return errCouponTypeNilPtr
|
||||||
|
}
|
||||||
|
*x = *v
|
||||||
|
case *string:
|
||||||
|
if v == nil {
|
||||||
|
return errCouponTypeNilPtr
|
||||||
|
}
|
||||||
|
*x, err = ParseCouponType(*v)
|
||||||
|
default:
|
||||||
|
return errors.New("invalid type for CouponType")
|
||||||
|
}
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Value implements the driver Valuer interface.
|
||||||
|
func (x CouponType) Value() (driver.Value, error) {
|
||||||
|
return x.String(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set implements the Golang flag.Value interface func.
|
||||||
|
func (x *CouponType) Set(val string) error {
|
||||||
|
v, err := ParseCouponType(val)
|
||||||
|
*x = v
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get implements the Golang flag.Getter interface func.
|
||||||
|
func (x *CouponType) Get() interface{} {
|
||||||
|
return *x
|
||||||
|
}
|
||||||
|
|
||||||
|
// Type implements the github.com/spf13/pFlag Value interface.
|
||||||
|
func (x *CouponType) Type() string {
|
||||||
|
return "CouponType"
|
||||||
|
}
|
||||||
|
|
||||||
|
type NullCouponType struct {
|
||||||
|
CouponType CouponType
|
||||||
|
Valid bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewNullCouponType(val interface{}) (x NullCouponType) {
|
||||||
|
err := x.Scan(val) // yes, we ignore this error, it will just be an invalid value.
|
||||||
|
_ = err // make any errcheck linters happy
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Scan implements the Scanner interface.
|
||||||
|
func (x *NullCouponType) Scan(value interface{}) (err error) {
|
||||||
|
if value == nil {
|
||||||
|
x.CouponType, x.Valid = CouponType(""), false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
err = x.CouponType.Scan(value)
|
||||||
|
x.Valid = (err == nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Value implements the driver Valuer interface.
|
||||||
|
func (x NullCouponType) Value() (driver.Value, error) {
|
||||||
|
if !x.Valid {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
// driver.Value accepts int64 for int values.
|
||||||
|
return string(x.CouponType), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type NullCouponTypeStr struct {
|
||||||
|
NullCouponType
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewNullCouponTypeStr(val interface{}) (x NullCouponTypeStr) {
|
||||||
|
x.Scan(val) // yes, we ignore this error, it will just be an invalid value.
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Value implements the driver Valuer interface.
|
||||||
|
func (x NullCouponTypeStr) Value() (driver.Value, error) {
|
||||||
|
if !x.Valid {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
return x.CouponType.String(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
const (
|
||||||
|
// UserCouponStatusUnused is a UserCouponStatus of type unused.
|
||||||
|
UserCouponStatusUnused UserCouponStatus = "unused"
|
||||||
|
// UserCouponStatusUsed is a UserCouponStatus of type used.
|
||||||
|
UserCouponStatusUsed UserCouponStatus = "used"
|
||||||
|
// UserCouponStatusExpired is a UserCouponStatus of type expired.
|
||||||
|
UserCouponStatusExpired UserCouponStatus = "expired"
|
||||||
|
)
|
||||||
|
|
||||||
|
var ErrInvalidUserCouponStatus = fmt.Errorf("not a valid UserCouponStatus, try [%s]", strings.Join(_UserCouponStatusNames, ", "))
|
||||||
|
|
||||||
|
var _UserCouponStatusNames = []string{
|
||||||
|
string(UserCouponStatusUnused),
|
||||||
|
string(UserCouponStatusUsed),
|
||||||
|
string(UserCouponStatusExpired),
|
||||||
|
}
|
||||||
|
|
||||||
|
// UserCouponStatusNames returns a list of possible string values of UserCouponStatus.
|
||||||
|
func UserCouponStatusNames() []string {
|
||||||
|
tmp := make([]string, len(_UserCouponStatusNames))
|
||||||
|
copy(tmp, _UserCouponStatusNames)
|
||||||
|
return tmp
|
||||||
|
}
|
||||||
|
|
||||||
|
// UserCouponStatusValues returns a list of the values for UserCouponStatus
|
||||||
|
func UserCouponStatusValues() []UserCouponStatus {
|
||||||
|
return []UserCouponStatus{
|
||||||
|
UserCouponStatusUnused,
|
||||||
|
UserCouponStatusUsed,
|
||||||
|
UserCouponStatusExpired,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// String implements the Stringer interface.
|
||||||
|
func (x UserCouponStatus) String() string {
|
||||||
|
return string(x)
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsValid provides a quick way to determine if the typed value is
|
||||||
|
// part of the allowed enumerated values
|
||||||
|
func (x UserCouponStatus) IsValid() bool {
|
||||||
|
_, err := ParseUserCouponStatus(string(x))
|
||||||
|
return err == nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var _UserCouponStatusValue = map[string]UserCouponStatus{
|
||||||
|
"unused": UserCouponStatusUnused,
|
||||||
|
"used": UserCouponStatusUsed,
|
||||||
|
"expired": UserCouponStatusExpired,
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParseUserCouponStatus attempts to convert a string to a UserCouponStatus.
|
||||||
|
func ParseUserCouponStatus(name string) (UserCouponStatus, error) {
|
||||||
|
if x, ok := _UserCouponStatusValue[name]; ok {
|
||||||
|
return x, nil
|
||||||
|
}
|
||||||
|
return UserCouponStatus(""), fmt.Errorf("%s is %w", name, ErrInvalidUserCouponStatus)
|
||||||
|
}
|
||||||
|
|
||||||
|
var errUserCouponStatusNilPtr = errors.New("value pointer is nil") // one per type for package clashes
|
||||||
|
|
||||||
|
// Scan implements the Scanner interface.
|
||||||
|
func (x *UserCouponStatus) Scan(value interface{}) (err error) {
|
||||||
|
if value == nil {
|
||||||
|
*x = UserCouponStatus("")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// A wider range of scannable types.
|
||||||
|
// driver.Value values at the top of the list for expediency
|
||||||
|
switch v := value.(type) {
|
||||||
|
case string:
|
||||||
|
*x, err = ParseUserCouponStatus(v)
|
||||||
|
case []byte:
|
||||||
|
*x, err = ParseUserCouponStatus(string(v))
|
||||||
|
case UserCouponStatus:
|
||||||
|
*x = v
|
||||||
|
case *UserCouponStatus:
|
||||||
|
if v == nil {
|
||||||
|
return errUserCouponStatusNilPtr
|
||||||
|
}
|
||||||
|
*x = *v
|
||||||
|
case *string:
|
||||||
|
if v == nil {
|
||||||
|
return errUserCouponStatusNilPtr
|
||||||
|
}
|
||||||
|
*x, err = ParseUserCouponStatus(*v)
|
||||||
|
default:
|
||||||
|
return errors.New("invalid type for UserCouponStatus")
|
||||||
|
}
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Value implements the driver Valuer interface.
|
||||||
|
func (x UserCouponStatus) Value() (driver.Value, error) {
|
||||||
|
return x.String(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set implements the Golang flag.Value interface func.
|
||||||
|
func (x *UserCouponStatus) Set(val string) error {
|
||||||
|
v, err := ParseUserCouponStatus(val)
|
||||||
|
*x = v
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get implements the Golang flag.Getter interface func.
|
||||||
|
func (x *UserCouponStatus) Get() interface{} {
|
||||||
|
return *x
|
||||||
|
}
|
||||||
|
|
||||||
|
// Type implements the github.com/spf13/pFlag Value interface.
|
||||||
|
func (x *UserCouponStatus) Type() string {
|
||||||
|
return "UserCouponStatus"
|
||||||
|
}
|
||||||
|
|
||||||
|
type NullUserCouponStatus struct {
|
||||||
|
UserCouponStatus UserCouponStatus
|
||||||
|
Valid bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewNullUserCouponStatus(val interface{}) (x NullUserCouponStatus) {
|
||||||
|
err := x.Scan(val) // yes, we ignore this error, it will just be an invalid value.
|
||||||
|
_ = err // make any errcheck linters happy
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Scan implements the Scanner interface.
|
||||||
|
func (x *NullUserCouponStatus) Scan(value interface{}) (err error) {
|
||||||
|
if value == nil {
|
||||||
|
x.UserCouponStatus, x.Valid = UserCouponStatus(""), false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
err = x.UserCouponStatus.Scan(value)
|
||||||
|
x.Valid = (err == nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Value implements the driver Valuer interface.
|
||||||
|
func (x NullUserCouponStatus) Value() (driver.Value, error) {
|
||||||
|
if !x.Valid {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
// driver.Value accepts int64 for int values.
|
||||||
|
return string(x.UserCouponStatus), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type NullUserCouponStatusStr struct {
|
||||||
|
NullUserCouponStatus
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewNullUserCouponStatusStr(val interface{}) (x NullUserCouponStatusStr) {
|
||||||
|
x.Scan(val) // yes, we ignore this error, it will just be an invalid value.
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Value implements the driver Valuer interface.
|
||||||
|
func (x NullUserCouponStatusStr) Value() (driver.Value, error) {
|
||||||
|
if !x.Valid {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
return x.UserCouponStatus.String(), nil
|
||||||
|
}
|
||||||
57
backend/pkg/consts/coupon.go
Normal file
57
backend/pkg/consts/coupon.go
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
package consts
|
||||||
|
|
||||||
|
import "quyun/v2/app/requests"
|
||||||
|
|
||||||
|
// swagger:enum CouponType
|
||||||
|
// ENUM( fix_amount, discount )
|
||||||
|
type CouponType string
|
||||||
|
|
||||||
|
// Description returns the Chinese label for the specific enum value.
|
||||||
|
func (t CouponType) Description() string {
|
||||||
|
switch t {
|
||||||
|
case CouponTypeFixAmount:
|
||||||
|
return "满减"
|
||||||
|
case CouponTypeDiscount:
|
||||||
|
return "折扣"
|
||||||
|
default:
|
||||||
|
return "未知类型"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// CouponTypeItems returns the KV list for FE dropdowns.
|
||||||
|
func CouponTypeItems() []requests.KV {
|
||||||
|
values := CouponTypeValues()
|
||||||
|
items := make([]requests.KV, 0, len(values))
|
||||||
|
for _, v := range values {
|
||||||
|
items = append(items, requests.NewKV(string(v), v.Description()))
|
||||||
|
}
|
||||||
|
return items
|
||||||
|
}
|
||||||
|
|
||||||
|
// swagger:enum UserCouponStatus
|
||||||
|
// ENUM( unused, used, expired )
|
||||||
|
type UserCouponStatus string
|
||||||
|
|
||||||
|
// Description returns the Chinese label for the specific enum value.
|
||||||
|
func (t UserCouponStatus) Description() string {
|
||||||
|
switch t {
|
||||||
|
case UserCouponStatusUnused:
|
||||||
|
return "未使用"
|
||||||
|
case UserCouponStatusUsed:
|
||||||
|
return "已使用"
|
||||||
|
case UserCouponStatusExpired:
|
||||||
|
return "已过期"
|
||||||
|
default:
|
||||||
|
return "未知状态"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// UserCouponStatusItems returns the KV list for FE dropdowns.
|
||||||
|
func UserCouponStatusItems() []requests.KV {
|
||||||
|
values := UserCouponStatusValues()
|
||||||
|
items := make([]requests.KV, 0, len(values))
|
||||||
|
for _, v := range values {
|
||||||
|
items = append(items, requests.NewKV(string(v), v.Description()))
|
||||||
|
}
|
||||||
|
return items
|
||||||
|
}
|
||||||
102
docs/coupon_plan.md
Normal file
102
docs/coupon_plan.md
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
# 优惠券功能规划(先规划,后执行)
|
||||||
|
|
||||||
|
## 1. 目标与范围
|
||||||
|
|
||||||
|
- 支持租户侧创建/管理优惠券模板。
|
||||||
|
- 支持用户领取/选择/使用优惠券,订单创建时自动校验与核销。
|
||||||
|
- 优惠券状态可追踪:未使用/已使用/已过期。
|
||||||
|
- 保持多租户隔离,避免跨租户误用。
|
||||||
|
|
||||||
|
## 2. 当前现状
|
||||||
|
|
||||||
|
- 数据表已存在:`coupons`、`user_coupons`、`orders.coupon_id`。
|
||||||
|
- 服务层已有部分能力:`Coupon.ListUserCoupons`、`Coupon.Validate`、`Coupon.MarkUsed`。
|
||||||
|
- 用户侧 API 已有:`GET /t/:tenantCode/v1/me/coupons`。
|
||||||
|
- 前端已有「我的优惠券」页面,但暂无领取/选择流程。
|
||||||
|
|
||||||
|
## 3. 领域规则(V1)
|
||||||
|
|
||||||
|
- `total_quantity = 0` 表示不限量;>0 表示最多可领取次数。
|
||||||
|
- 每个用户对同一优惠券默认只允许领取一次(可通过规则调整)。
|
||||||
|
- 过期判断以 `end_at` 为准:`end_at < now` 则视为过期。
|
||||||
|
- 使用时:订单金额需满足 `min_order_amount`;折扣券需遵守 `max_discount`。
|
||||||
|
|
||||||
|
## 4. 枚举与类型规范(必做)
|
||||||
|
|
||||||
|
- 新增枚举类型并统一使用(避免硬编码字符串):
|
||||||
|
- `consts.CouponType`:`fix_amount` / `discount`
|
||||||
|
- `consts.UserCouponStatus`:`unused` / `used` / `expired`
|
||||||
|
- `backend/database/.transform.yaml` 映射:
|
||||||
|
- `coupons.type -> consts.CouponType`
|
||||||
|
- `user_coupons.status -> consts.UserCouponStatus`
|
||||||
|
- 运行 `atomctl gen enum` + `atomctl gen model`,保持生成文件一致性。
|
||||||
|
|
||||||
|
## 5. 接口规划(按模块)
|
||||||
|
|
||||||
|
### 5.1 用户侧(UserCenter)
|
||||||
|
|
||||||
|
- `GET /t/:tenantCode/v1/me/coupons?status=unused|used|expired`
|
||||||
|
- 已有,补齐过期自动标记逻辑。
|
||||||
|
- `GET /t/:tenantCode/v1/me/coupons/available?amount=<int64>`
|
||||||
|
- 返回「当前订单金额可用」的优惠券列表(用于结算页选择)。
|
||||||
|
- `POST /t/:tenantCode/v1/me/coupons/receive`
|
||||||
|
- 领取优惠券(参数:`coupon_id`)。
|
||||||
|
|
||||||
|
### 5.2 租户侧(CreatorCenter)
|
||||||
|
|
||||||
|
- `POST /t/:tenantCode/v1/creator/coupons`
|
||||||
|
- 创建优惠券模板。
|
||||||
|
- `GET /t/:tenantCode/v1/creator/coupons`
|
||||||
|
- 分页查询模板列表(支持状态、有效期、类型过滤)。
|
||||||
|
- `GET /t/:tenantCode/v1/creator/coupons/:id<int>`
|
||||||
|
- 查看模板详情。
|
||||||
|
- `PUT /t/:tenantCode/v1/creator/coupons/:id<int>`
|
||||||
|
- 更新模板(仅未开始或未领取时允许修改核心字段)。
|
||||||
|
- `POST /t/:tenantCode/v1/creator/coupons/:id<int>/grant`
|
||||||
|
- 定向发放给用户(参数:`user_ids` 批量)。
|
||||||
|
|
||||||
|
## 6. 服务层设计(关键逻辑)
|
||||||
|
|
||||||
|
- `Coupon.Create/Update/List/Get`:模板管理。
|
||||||
|
- `Coupon.Receive`:
|
||||||
|
- 校验有效期与库存(`total_quantity`)。
|
||||||
|
- 校验用户是否已领取。
|
||||||
|
- 事务内写入 `user_coupons`。
|
||||||
|
- `Coupon.ListUserCoupons`:
|
||||||
|
- join `coupons` 读取模板信息。
|
||||||
|
- 对过期券自动标记 `expired`(可更新 DB)。
|
||||||
|
- `Coupon.ListAvailable`:
|
||||||
|
- `status = unused`
|
||||||
|
- `start_at <= now <= end_at`
|
||||||
|
- `min_order_amount <= amount`
|
||||||
|
- `Coupon.Validate` / `MarkUsed`:
|
||||||
|
- 已有,改用枚举类型 + 统一错误码语义。
|
||||||
|
|
||||||
|
## 7. 并发与一致性
|
||||||
|
|
||||||
|
- 领取优惠券时在事务中锁定 `coupons` 行或使用条件更新,防止超发:
|
||||||
|
- `total_quantity > 0` 时,基于 `COUNT(user_coupons)` 判断库存。
|
||||||
|
- 若性能成为瓶颈,再考虑新增 `claimed_quantity` 字段(V2 优化)。
|
||||||
|
|
||||||
|
## 8. 前端联动
|
||||||
|
|
||||||
|
- 结算页新增「选择优惠券」抽屉(参考 `docs/design/portal/PAGE_ORDER.md`)。
|
||||||
|
- 用户中心「优惠券」页保持与后端状态一致(unused/used/expired)。
|
||||||
|
- 订单创建前调用 `available` 接口,展示可用券。
|
||||||
|
|
||||||
|
## 9. 测试规划
|
||||||
|
|
||||||
|
- Service 单测:
|
||||||
|
- 领取成功/重复领取/超库存/过期不可领取。
|
||||||
|
- Validate:金额不足、折扣封顶、跨租户禁止。
|
||||||
|
- MarkUsed:幂等/重复使用失败。
|
||||||
|
- Order 相关:
|
||||||
|
- 使用优惠券创建订单成功并核销。
|
||||||
|
|
||||||
|
## 10. 实施顺序(建议)
|
||||||
|
|
||||||
|
1) 枚举 + transform + gen(保证类型安全)。
|
||||||
|
2) Service:Receive/ListAvailable/ListUserCoupons 过期处理。
|
||||||
|
3) HTTP:用户端 + CreatorCenter 管理端接口。
|
||||||
|
4) 前端:结算页优惠券选择 + 领券入口。
|
||||||
|
5) 补测试 + 回归订单流程。
|
||||||
@@ -20,4 +20,6 @@ export const userApi = {
|
|||||||
markAllNotificationsRead: () => request('/me/notifications/read-all', { method: 'POST' }),
|
markAllNotificationsRead: () => request('/me/notifications/read-all', { method: 'POST' }),
|
||||||
getFollowing: () => request('/me/following'),
|
getFollowing: () => request('/me/following'),
|
||||||
getCoupons: (status) => request(`/me/coupons?status=${status || 'unused'}`),
|
getCoupons: (status) => request(`/me/coupons?status=${status || 'unused'}`),
|
||||||
|
getAvailableCoupons: (amount) => request(`/me/coupons/available?amount=${amount}`),
|
||||||
|
receiveCoupon: (couponId) => request('/me/coupons/receive', { method: 'POST', body: { coupon_id: couponId } }),
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user