feat: add coupon support to orders and create user_coupons model
- Added CouponID field to Order model to track used coupons. - Updated order query generation to include CouponID. - Introduced UserCoupon model to manage user coupon associations. - Implemented query methods for UserCoupon to facilitate CRUD operations. - Updated query context and default query setup to include UserCoupon.
This commit is contained in:
84
backend/app/services/coupon.go
Normal file
84
backend/app/services/coupon.go
Normal file
@@ -0,0 +1,84 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"quyun/v2/app/errorx"
|
||||
"quyun/v2/database/models"
|
||||
)
|
||||
|
||||
// @provider
|
||||
type coupon struct{}
|
||||
|
||||
// Validate checks if a coupon can be used for an order and returns the discount amount
|
||||
func (s *coupon) Validate(ctx context.Context, 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 != "unused" {
|
||||
return 0, errorx.ErrBusinessLogic.WithMsg("优惠券已使用或失效")
|
||||
}
|
||||
|
||||
c, err := models.CouponQuery.WithContext(ctx).Where(models.CouponQuery.ID.Eq(uc.CouponID)).First()
|
||||
if err != nil {
|
||||
return 0, errorx.ErrRecordNotFound.WithMsg("优惠券信息缺失")
|
||||
}
|
||||
|
||||
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) MarkUsed(ctx context.Context, tx *models.Query, userCouponID, orderID int64) error {
|
||||
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)
|
||||
// We need CouponID from uc
|
||||
uc, _ := tx.UserCoupon.WithContext(ctx).Where(tx.UserCoupon.ID.Eq(userCouponID)).First()
|
||||
_, _ = tx.Coupon.WithContext(ctx).Where(tx.Coupon.ID.Eq(uc.CouponID)).UpdateSimple(tx.Coupon.UsedQuantity.Add(1))
|
||||
|
||||
return nil
|
||||
}
|
||||
111
backend/app/services/coupon_test.go
Normal file
111
backend/app/services/coupon_test.go
Normal file
@@ -0,0 +1,111 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"testing"
|
||||
|
||||
"quyun/v2/app/commands/testx"
|
||||
order_dto "quyun/v2/app/http/v1/dto"
|
||||
"quyun/v2/database"
|
||||
"quyun/v2/database/models"
|
||||
"quyun/v2/pkg/consts"
|
||||
"quyun/v2/providers/storage"
|
||||
|
||||
. "github.com/smartystreets/goconvey/convey"
|
||||
"github.com/spf13/cast"
|
||||
"github.com/stretchr/testify/suite"
|
||||
"go.ipao.vip/atom/contracts"
|
||||
"go.uber.org/dig"
|
||||
)
|
||||
|
||||
type CouponTestSuiteInjectParams struct {
|
||||
dig.In
|
||||
|
||||
DB *sql.DB
|
||||
Initials []contracts.Initial `group:"initials"`
|
||||
}
|
||||
|
||||
type CouponTestSuite struct {
|
||||
suite.Suite
|
||||
CouponTestSuiteInjectParams
|
||||
}
|
||||
|
||||
func Test_Coupon(t *testing.T) {
|
||||
providers := testx.Default().With(Provide).With(storage.Provide)
|
||||
|
||||
testx.Serve(providers, t, func(p CouponTestSuiteInjectParams) {
|
||||
suite.Run(t, &CouponTestSuite{CouponTestSuiteInjectParams: p})
|
||||
})
|
||||
}
|
||||
|
||||
func (s *CouponTestSuite) Test_CouponFlow() {
|
||||
Convey("Coupon Flow", s.T(), func() {
|
||||
ctx := s.T().Context()
|
||||
database.Truncate(ctx, s.DB, models.TableNameCoupon, models.TableNameUserCoupon, models.TableNameOrder, models.TableNameUser, models.TableNameContent, models.TableNameContentPrice)
|
||||
|
||||
user := &models.User{Username: "coupon_user", Phone: "13800000001"}
|
||||
models.UserQuery.WithContext(ctx).Create(user)
|
||||
|
||||
// 1. Create Coupon (Fixed 5.00 CNY, Min 10.00 CNY)
|
||||
cp := &models.Coupon{
|
||||
Title: "Save 5",
|
||||
Type: "fix_amount",
|
||||
Value: 500,
|
||||
MinOrderAmount: 1000,
|
||||
}
|
||||
models.CouponQuery.WithContext(ctx).Create(cp)
|
||||
|
||||
// 2. Give to User
|
||||
uc := &models.UserCoupon{
|
||||
UserID: user.ID,
|
||||
CouponID: cp.ID,
|
||||
Status: "unused",
|
||||
}
|
||||
models.UserCouponQuery.WithContext(ctx).Create(uc)
|
||||
|
||||
Convey("should validate coupon successfully", func() {
|
||||
discount, err := Coupon.Validate(ctx, user.ID, uc.ID, 1500)
|
||||
So(err, ShouldBeNil)
|
||||
So(discount, ShouldEqual, 500)
|
||||
})
|
||||
|
||||
Convey("should fail if below min amount", func() {
|
||||
_, err := Coupon.Validate(ctx, user.ID, uc.ID, 800)
|
||||
So(err, ShouldNotBeNil)
|
||||
})
|
||||
|
||||
Convey("should apply in Order.Create", func() {
|
||||
// Setup Content
|
||||
c := &models.Content{UserID: 99, Title: "Test", Status: consts.ContentStatusPublished}
|
||||
models.ContentQuery.WithContext(ctx).Create(c)
|
||||
models.ContentPriceQuery.WithContext(ctx).Create(&models.ContentPrice{
|
||||
ContentID: c.ID,
|
||||
PriceAmount: 2000, // 20.00 CNY
|
||||
Currency: "CNY",
|
||||
})
|
||||
|
||||
form := &order_dto.OrderCreateForm{
|
||||
ContentID: cast.ToString(c.ID),
|
||||
UserCouponID: cast.ToString(uc.ID),
|
||||
}
|
||||
// Simulate Auth context for Order service
|
||||
authCtx := context.WithValue(ctx, consts.CtxKeyUser, user.ID)
|
||||
res, err := Order.Create(authCtx, form)
|
||||
So(err, ShouldBeNil)
|
||||
|
||||
// Verify Order
|
||||
oid := cast.ToInt64(res.OrderID)
|
||||
o, _ := models.OrderQuery.WithContext(ctx).Where(models.OrderQuery.ID.Eq(oid)).First()
|
||||
So(o.AmountOriginal, ShouldEqual, 2000)
|
||||
So(o.AmountDiscount, ShouldEqual, 500)
|
||||
So(o.AmountPaid, ShouldEqual, 1500)
|
||||
So(o.CouponID, ShouldEqual, cp.ID)
|
||||
|
||||
// Verify Coupon Status
|
||||
ucReload, _ := models.UserCouponQuery.WithContext(ctx).Where(models.UserCouponQuery.ID.Eq(uc.ID)).First()
|
||||
So(ucReload.Status, ShouldEqual, "used")
|
||||
So(ucReload.OrderID, ShouldEqual, oid)
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -91,11 +91,30 @@ func (s *order) Create(ctx context.Context, form *transaction_dto.OrderCreateFor
|
||||
|
||||
price, err := models.ContentPriceQuery.WithContext(ctx).Where(models.ContentPriceQuery.ContentID.Eq(cid)).First()
|
||||
if err != nil {
|
||||
// If price missing, treat as error? Or maybe 0?
|
||||
// Better to require price record.
|
||||
return nil, errorx.ErrDataCorrupted.WithCause(err).WithMsg("价格信息缺失")
|
||||
}
|
||||
|
||||
amountOriginal := price.PriceAmount
|
||||
var amountDiscount int64 = 0
|
||||
var couponID int64 = 0
|
||||
|
||||
// Validate Coupon
|
||||
if form.UserCouponID != "" {
|
||||
ucid := cast.ToInt64(form.UserCouponID)
|
||||
discount, err := Coupon.Validate(ctx, uid, ucid, amountOriginal)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
amountDiscount = discount
|
||||
|
||||
uc, err := models.UserCouponQuery.WithContext(ctx).Where(models.UserCouponQuery.ID.Eq(ucid)).First()
|
||||
if err == nil {
|
||||
couponID = uc.CouponID
|
||||
}
|
||||
}
|
||||
|
||||
amountPaid := amountOriginal - amountDiscount
|
||||
|
||||
// 2. Create Order (Status: Created)
|
||||
order := &models.Order{
|
||||
TenantID: content.TenantID,
|
||||
@@ -103,27 +122,46 @@ func (s *order) Create(ctx context.Context, form *transaction_dto.OrderCreateFor
|
||||
Type: consts.OrderTypeContentPurchase,
|
||||
Status: consts.OrderStatusCreated,
|
||||
Currency: consts.Currency(price.Currency),
|
||||
AmountOriginal: price.PriceAmount,
|
||||
AmountDiscount: 0,
|
||||
AmountPaid: price.PriceAmount,
|
||||
AmountOriginal: amountOriginal,
|
||||
AmountDiscount: amountDiscount,
|
||||
AmountPaid: amountPaid,
|
||||
CouponID: couponID,
|
||||
IdempotencyKey: uuid.NewString(),
|
||||
Snapshot: types.NewJSONType(fields.OrdersSnapshot{}),
|
||||
}
|
||||
|
||||
if err := models.OrderQuery.WithContext(ctx).Create(order); err != nil {
|
||||
return nil, errorx.ErrDatabaseError.WithCause(err)
|
||||
}
|
||||
err = models.Q.Transaction(func(tx *models.Query) error {
|
||||
if err := tx.Order.WithContext(ctx).Create(order); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 3. Create Order Item
|
||||
item := &models.OrderItem{
|
||||
TenantID: content.TenantID,
|
||||
UserID: uid,
|
||||
OrderID: order.ID,
|
||||
ContentID: cid,
|
||||
ContentUserID: content.UserID,
|
||||
AmountPaid: order.AmountPaid,
|
||||
}
|
||||
if err := models.OrderItemQuery.WithContext(ctx).Create(item); err != nil {
|
||||
// 3. Create Order Item
|
||||
item := &models.OrderItem{
|
||||
TenantID: content.TenantID,
|
||||
UserID: uid,
|
||||
OrderID: order.ID,
|
||||
ContentID: cid,
|
||||
ContentUserID: content.UserID,
|
||||
AmountPaid: amountPaid,
|
||||
}
|
||||
if err := tx.OrderItem.WithContext(ctx).Create(item); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Mark Coupon Used
|
||||
if form.UserCouponID != "" {
|
||||
if err := Coupon.MarkUsed(ctx, tx, cast.ToInt64(form.UserCouponID), order.ID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
if _, ok := err.(*errorx.AppError); ok {
|
||||
return nil, err
|
||||
}
|
||||
return nil, errorx.ErrDatabaseError.WithCause(err)
|
||||
}
|
||||
|
||||
|
||||
@@ -38,6 +38,13 @@ func Provide(opts ...opt.Option) error {
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := container.Container.Provide(func() (*coupon, error) {
|
||||
obj := &coupon{}
|
||||
|
||||
return obj, nil
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := container.Container.Provide(func() (*creator, error) {
|
||||
obj := &creator{}
|
||||
|
||||
@@ -67,12 +74,11 @@ func Provide(opts ...opt.Option) error {
|
||||
audit *audit,
|
||||
common *common,
|
||||
content *content,
|
||||
coupon *coupon,
|
||||
creator *creator,
|
||||
db *gorm.DB,
|
||||
job *job.Job,
|
||||
notification *notification,
|
||||
order *order,
|
||||
storage *storage.Storage,
|
||||
super *super,
|
||||
tenant *tenant,
|
||||
user *user,
|
||||
@@ -82,12 +88,11 @@ func Provide(opts ...opt.Option) error {
|
||||
audit: audit,
|
||||
common: common,
|
||||
content: content,
|
||||
coupon: coupon,
|
||||
creator: creator,
|
||||
db: db,
|
||||
job: job,
|
||||
notification: notification,
|
||||
order: order,
|
||||
storage: storage,
|
||||
super: super,
|
||||
tenant: tenant,
|
||||
user: user,
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"quyun/v2/providers/job"
|
||||
"quyun/v2/providers/storage"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
@@ -14,11 +11,10 @@ var (
|
||||
Audit *audit
|
||||
Common *common
|
||||
Content *content
|
||||
Coupon *coupon
|
||||
Creator *creator
|
||||
Job *job.Job
|
||||
Notification *notification
|
||||
Order *order
|
||||
Storage *storage.Storage
|
||||
Super *super
|
||||
Tenant *tenant
|
||||
User *user
|
||||
@@ -32,11 +28,10 @@ type services struct {
|
||||
audit *audit
|
||||
common *common
|
||||
content *content
|
||||
coupon *coupon
|
||||
creator *creator
|
||||
job *job.Job
|
||||
notification *notification
|
||||
order *order
|
||||
storage *storage.Storage
|
||||
super *super
|
||||
tenant *tenant
|
||||
user *user
|
||||
@@ -50,11 +45,10 @@ func (svc *services) Prepare() error {
|
||||
Audit = svc.audit
|
||||
Common = svc.common
|
||||
Content = svc.content
|
||||
Coupon = svc.coupon
|
||||
Creator = svc.creator
|
||||
Job = svc.job
|
||||
Notification = svc.notification
|
||||
Order = svc.order
|
||||
Storage = svc.storage
|
||||
Super = svc.super
|
||||
Tenant = svc.tenant
|
||||
User = svc.user
|
||||
|
||||
Reference in New Issue
Block a user