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:
2025-12-30 17:28:21 +08:00
parent 69d750800c
commit dbfb08ed37
14 changed files with 1454 additions and 35 deletions

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

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

View File

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

View File

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

View File

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