From 4f315cc2db4fcff30589883f73ba5b284534b491 Mon Sep 17 00:00:00 2001 From: Rogee Date: Tue, 13 Jan 2026 18:19:29 +0800 Subject: [PATCH] feat: implement coupon management and receive flow --- TODO.md | 2 +- backend/app/http/v1/creator.go | 101 ++ backend/app/http/v1/dto/coupon.go | 94 ++ backend/app/http/v1/routes.gen.go | 44 + backend/app/http/v1/user.go | 38 + backend/app/services/coupon.go | 917 +++++++++++++++--- backend/app/services/coupon_test.go | 87 +- backend/database/.transform.yaml | 4 + backend/database/models/contents.gen.go | 2 +- backend/database/models/contents.query.gen.go | 186 ++-- backend/database/models/coupons.gen.go | 30 +- backend/database/models/coupons.query.gen.go | 6 +- backend/database/models/user_coupons.gen.go | 16 +- .../database/models/user_coupons.query.gen.go | 6 +- backend/pkg/consts/coupon.gen.go | 339 +++++++ backend/pkg/consts/coupon.go | 57 ++ docs/coupon_plan.md | 102 ++ frontend/portal/src/api/user.js | 2 + 18 files changed, 1787 insertions(+), 246 deletions(-) create mode 100644 backend/pkg/consts/coupon.gen.go create mode 100644 backend/pkg/consts/coupon.go create mode 100644 docs/coupon_plan.md diff --git a/TODO.md b/TODO.md index 5a347c3..8f78817 100644 --- a/TODO.md +++ b/TODO.md @@ -11,4 +11,4 @@ - [x] **Media Pipeline**: Implement FFmpeg integration (mock call in job). ## 3. Growth -- [ ] **Coupons**: Implement coupon logic. +- [x] **Coupons**: Implement coupon logic. diff --git a/backend/app/http/v1/creator.go b/backend/app/http/v1/creator.go index 7092579..e4037bb 100644 --- a/backend/app/http/v1/creator.go +++ b/backend/app/http/v1/creator.go @@ -1,6 +1,7 @@ package v1 import ( + "quyun/v2/app/errorx" "quyun/v2/app/http/v1/dto" "quyun/v2/app/requests" "quyun/v2/app/services" @@ -359,3 +360,103 @@ func (c *Creator) Withdraw(ctx fiber.Ctx, user *models.User, form *dto.WithdrawF tenantID := getTenantID(ctx) return services.Creator.Withdraw(ctx, tenantID, user.ID, form) } + +// Create coupon +// +// @Router /t/:tenantCode/v1/creator/coupons [post] +// @Summary Create coupon +// @Description Create coupon template +// @Tags CreatorCenter +// @Accept json +// @Produce json +// @Param form body dto.CouponCreateForm true "Coupon form" +// @Success 200 {object} dto.CouponItem +// @Bind user local key(__ctx_user) +// @Bind form body +func (c *Creator) CreateCoupon(ctx fiber.Ctx, user *models.User, form *dto.CouponCreateForm) (*dto.CouponItem, error) { + tenantID := getTenantID(ctx) + return services.Coupon.Create(ctx, tenantID, user.ID, form) +} + +// List coupons +// +// @Router /t/:tenantCode/v1/creator/coupons [get] +// @Summary List coupons +// @Description List coupon templates +// @Tags CreatorCenter +// @Accept json +// @Produce json +// @Param page query int false "Page" +// @Param limit query int false "Limit" +// @Param type query string false "Type (fix_amount/discount)" +// @Param status query string false "Status (active/expired)" +// @Param keyword query string false "Keyword" +// @Success 200 {object} requests.Pager +// @Bind user local key(__ctx_user) +// @Bind filter query +func (c *Creator) ListCoupons(ctx fiber.Ctx, user *models.User, filter *dto.CouponListFilter) (*requests.Pager, error) { + tenantID := getTenantID(ctx) + return services.Coupon.List(ctx, tenantID, filter) +} + +// Get coupon +// +// @Router /t/:tenantCode/v1/creator/coupons/:id [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 [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/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 +} diff --git a/backend/app/http/v1/dto/coupon.go b/backend/app/http/v1/dto/coupon.go index 94701a2..450e410 100644 --- a/backend/app/http/v1/dto/coupon.go +++ b/backend/app/http/v1/dto/coupon.go @@ -1,5 +1,99 @@ package dto +import "quyun/v2/app/requests" + +type CouponCreateForm struct { + // Title 优惠券标题。 + Title string `json:"title"` + // Description 优惠券描述。 + Description string `json:"description"` + // Type 优惠券类型(fix_amount/discount)。 + Type string `json:"type"` + // Value 优惠券面值(分/折扣百分比)。 + Value int64 `json:"value"` + // MinOrderAmount 使用门槛金额(分)。 + MinOrderAmount int64 `json:"min_order_amount"` + // MaxDiscount 折扣券最高抵扣金额(分)。 + MaxDiscount int64 `json:"max_discount"` + // TotalQuantity 发行总量(0 表示不限量)。 + TotalQuantity int32 `json:"total_quantity"` + // StartAt 生效时间(RFC3339,可为空)。 + StartAt string `json:"start_at"` + // EndAt 过期时间(RFC3339,可为空)。 + EndAt string `json:"end_at"` +} + +type CouponUpdateForm struct { + // Title 优惠券标题(为空表示不修改)。 + Title *string `json:"title"` + // Description 优惠券描述(为空表示不修改)。 + Description *string `json:"description"` + // Type 优惠券类型(fix_amount/discount)。 + Type *string `json:"type"` + // Value 优惠券面值(分/折扣百分比)。 + Value *int64 `json:"value"` + // MinOrderAmount 使用门槛金额(分)。 + MinOrderAmount *int64 `json:"min_order_amount"` + // MaxDiscount 折扣券最高抵扣金额(分)。 + MaxDiscount *int64 `json:"max_discount"` + // TotalQuantity 发行总量(0 表示不限量)。 + TotalQuantity *int32 `json:"total_quantity"` + // StartAt 生效时间(RFC3339,可为空)。 + StartAt *string `json:"start_at"` + // EndAt 过期时间(RFC3339,可为空)。 + EndAt *string `json:"end_at"` +} + +type CouponItem struct { + // ID 券模板ID。 + ID int64 `json:"id"` + // Title 优惠券标题。 + Title string `json:"title"` + // Description 优惠券描述。 + Description string `json:"description"` + // Type 优惠券类型(fix_amount/discount)。 + Type string `json:"type"` + // Value 优惠券面值(分/折扣百分比)。 + Value int64 `json:"value"` + // MinOrderAmount 使用门槛金额(分)。 + MinOrderAmount int64 `json:"min_order_amount"` + // MaxDiscount 折扣券最高抵扣金额(分)。 + MaxDiscount int64 `json:"max_discount"` + // TotalQuantity 发行总量。 + TotalQuantity int32 `json:"total_quantity"` + // UsedQuantity 已使用数量。 + UsedQuantity int32 `json:"used_quantity"` + // StartAt 生效时间(RFC3339)。 + StartAt string `json:"start_at"` + // EndAt 过期时间(RFC3339)。 + EndAt string `json:"end_at"` + // CreatedAt 创建时间(RFC3339)。 + CreatedAt string `json:"created_at"` + // UpdatedAt 更新时间(RFC3339)。 + UpdatedAt string `json:"updated_at"` +} + +type CouponListFilter struct { + // Pagination 分页参数(page/limit)。 + requests.Pagination + // Type 优惠券类型过滤。 + Type *string `query:"type"` + // Status 状态过滤(active/expired)。 + Status *string `query:"status"` + // Keyword 关键词搜索(标题/描述)。 + Keyword *string `query:"keyword"` +} + +type CouponReceiveForm struct { + // CouponID 券模板ID。 + CouponID int64 `json:"coupon_id"` +} + +type CouponGrantForm struct { + // UserIDs 领取用户ID集合。 + UserIDs []int64 `json:"user_ids"` +} + type UserCouponItem struct { // ID 用户券ID。 ID int64 `json:"id"` diff --git a/backend/app/http/v1/routes.gen.go b/backend/app/http/v1/routes.gen.go index f4071d4..f52050b 100644 --- a/backend/app/http/v1/routes.gen.go +++ b/backend/app/http/v1/routes.gen.go @@ -175,6 +175,18 @@ func (r *Routes) Register(router fiber.Router) { Local[*models.User]("__ctx_user"), PathParam[int64]("id"), )) + r.log.Debugf("Registering route: Get /t/:tenantCode/v1/creator/coupons -> creator.ListCoupons") + router.Get("/t/:tenantCode/v1/creator/coupons"[len(r.Path()):], DataFunc2( + r.creator.ListCoupons, + Local[*models.User]("__ctx_user"), + Query[dto.CouponListFilter]("filter"), + )) + r.log.Debugf("Registering route: Get /t/:tenantCode/v1/creator/coupons/:id -> creator.GetCoupon") + router.Get("/t/:tenantCode/v1/creator/coupons/:id"[len(r.Path()):], DataFunc2( + r.creator.GetCoupon, + Local[*models.User]("__ctx_user"), + PathParam[int64]("id"), + )) r.log.Debugf("Registering route: Get /t/:tenantCode/v1/creator/dashboard -> creator.Dashboard") router.Get("/t/:tenantCode/v1/creator/dashboard"[len(r.Path()):], DataFunc1( r.creator.Dashboard, @@ -214,6 +226,19 @@ func (r *Routes) Register(router fiber.Router) { Local[*models.User]("__ctx_user"), Body[dto.ContentCreateForm]("form"), )) + r.log.Debugf("Registering route: Post /t/:tenantCode/v1/creator/coupons -> creator.CreateCoupon") + router.Post("/t/:tenantCode/v1/creator/coupons"[len(r.Path()):], DataFunc2( + r.creator.CreateCoupon, + Local[*models.User]("__ctx_user"), + Body[dto.CouponCreateForm]("form"), + )) + r.log.Debugf("Registering route: Post /t/:tenantCode/v1/creator/coupons/:id/grant -> creator.GrantCoupon") + router.Post("/t/:tenantCode/v1/creator/coupons/:id/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/review -> creator.ReviewMember") router.Post("/t/:tenantCode/v1/creator/members/:id/review"[len(r.Path()):], Func3( r.creator.ReviewMember, @@ -259,6 +284,13 @@ func (r *Routes) Register(router fiber.Router) { PathParam[int64]("id"), Body[dto.ContentUpdateForm]("form"), )) + r.log.Debugf("Registering route: Put /t/:tenantCode/v1/creator/coupons/:id -> creator.UpdateCoupon") + router.Put("/t/:tenantCode/v1/creator/coupons/:id"[len(r.Path()):], DataFunc3( + r.creator.UpdateCoupon, + Local[*models.User]("__ctx_user"), + PathParam[int64]("id"), + Body[dto.CouponUpdateForm]("form"), + )) r.log.Debugf("Registering route: Put /t/:tenantCode/v1/creator/settings -> creator.UpdateSettings") router.Put("/t/:tenantCode/v1/creator/settings"[len(r.Path()):], Func2( r.creator.UpdateSettings, @@ -374,6 +406,12 @@ func (r *Routes) Register(router fiber.Router) { Local[*models.User]("__ctx_user"), QueryParam[string]("status"), )) + r.log.Debugf("Registering route: Get /t/:tenantCode/v1/me/coupons/available -> user.AvailableCoupons") + router.Get("/t/:tenantCode/v1/me/coupons/available"[len(r.Path()):], DataFunc2( + r.user.AvailableCoupons, + Local[*models.User]("__ctx_user"), + QueryParam[int64]("amount"), + )) r.log.Debugf("Registering route: Get /t/:tenantCode/v1/me/favorites -> user.Favorites") router.Get("/t/:tenantCode/v1/me/favorites"[len(r.Path()):], DataFunc1( r.user.Favorites, @@ -418,6 +456,12 @@ func (r *Routes) Register(router fiber.Router) { r.user.Wallet, Local[*models.User]("__ctx_user"), )) + r.log.Debugf("Registering route: Post /t/:tenantCode/v1/me/coupons/receive -> user.ReceiveCoupon") + router.Post("/t/:tenantCode/v1/me/coupons/receive"[len(r.Path()):], DataFunc2( + r.user.ReceiveCoupon, + Local[*models.User]("__ctx_user"), + Body[dto.CouponReceiveForm]("form"), + )) r.log.Debugf("Registering route: Post /t/:tenantCode/v1/me/favorites -> user.AddFavorite") router.Post("/t/:tenantCode/v1/me/favorites"[len(r.Path()):], Func2( r.user.AddFavorite, diff --git a/backend/app/http/v1/user.go b/backend/app/http/v1/user.go index f38b9cf..7ec61d1 100644 --- a/backend/app/http/v1/user.go +++ b/backend/app/http/v1/user.go @@ -1,6 +1,7 @@ package v1 import ( + "quyun/v2/app/errorx" "quyun/v2/app/http/v1/dto" auth_dto "quyun/v2/app/http/v1/dto" "quyun/v2/app/requests" @@ -319,3 +320,40 @@ func (u *User) MyCoupons(ctx fiber.Ctx, user *models.User, status string) ([]dto tenantID := getTenantID(ctx) return services.Coupon.ListUserCoupons(ctx, tenantID, user.ID, status) } + +// List available coupons for order amount +// +// @Router /t/:tenantCode/v1/me/coupons/available [get] +// @Summary List available coupons +// @Description List coupons available for the given order amount +// @Tags UserCenter +// @Accept json +// @Produce json +// @Param amount query int64 true "Order amount (cents)" +// @Success 200 {array} dto.UserCouponItem +// @Bind user local key(__ctx_user) +// @Bind amount query +func (u *User) AvailableCoupons(ctx fiber.Ctx, user *models.User, amount int64) ([]dto.UserCouponItem, error) { + tenantID := getTenantID(ctx) + return services.Coupon.ListAvailable(ctx, tenantID, user.ID, amount) +} + +// Receive coupon +// +// @Router /t/:tenantCode/v1/me/coupons/receive [post] +// @Summary Receive coupon +// @Description Receive a coupon by coupon_id +// @Tags UserCenter +// @Accept json +// @Produce json +// @Param form body dto.CouponReceiveForm true "Receive form" +// @Success 200 {object} dto.UserCouponItem +// @Bind user local key(__ctx_user) +// @Bind form body +func (u *User) ReceiveCoupon(ctx fiber.Ctx, user *models.User, form *dto.CouponReceiveForm) (*dto.UserCouponItem, error) { + tenantID := getTenantID(ctx) + if form == nil { + return nil, errorx.ErrInvalidParameter.WithMsg("参数无效") + } + return services.Coupon.Receive(ctx, tenantID, user.ID, form.CouponID) +} diff --git a/backend/app/services/coupon.go b/backend/app/services/coupon.go index bb23fb8..f502591 100644 --- a/backend/app/services/coupon.go +++ b/backend/app/services/coupon.go @@ -3,13 +3,18 @@ package services import ( "context" "errors" + "strings" "time" "quyun/v2/app/errorx" coupon_dto "quyun/v2/app/http/v1/dto" + "quyun/v2/app/requests" "quyun/v2/database/models" + "quyun/v2/pkg/consts" + "go.ipao.vip/gen/field" "gorm.io/gorm" + "gorm.io/gorm/clause" ) // @provider @@ -24,13 +29,15 @@ func (s *coupon) ListUserCoupons( if userID == 0 { return nil, errorx.ErrUnauthorized } - uid := userID + statusFilter := strings.TrimSpace(status) + if statusFilter != "" && statusFilter != "all" { + if _, err := consts.ParseUserCouponStatus(statusFilter); err != nil { + return nil, errorx.ErrInvalidParameter.WithMsg("状态参数无效") + } + } tbl, q := models.UserCouponQuery.QueryContext(ctx) - q = q.Where(tbl.UserID.Eq(uid)) - if status != "" { - q = q.Where(tbl.Status.Eq(status)) - } + q = q.Where(tbl.UserID.Eq(userID)) list, err := q.Order(tbl.CreatedAt.Desc()).Find() if err != nil { @@ -40,9 +47,690 @@ func (s *coupon) ListUserCoupons( return []coupon_dto.UserCouponItem{}, nil } + couponMap, err := s.fetchCouponMap(ctx, tenantID, list) + if err != nil { + return nil, err + } + + now := time.Now() + res := make([]coupon_dto.UserCouponItem, 0, len(list)) + for _, v := range list { + if v == nil { + continue + } + c, ok := couponMap[v.CouponID] + if !ok { + continue + } + + finalStatus := v.Status + if s.isCouponExpired(c, now) && v.Status == consts.UserCouponStatusUnused { + if err := s.markUserCouponExpired(ctx, v.ID); err != nil { + return nil, err + } + finalStatus = consts.UserCouponStatusExpired + } + + if statusFilter != "" && statusFilter != "all" && string(finalStatus) != statusFilter { + continue + } + + res = append(res, s.composeUserCouponItem(v, c, finalStatus)) + } + return res, nil +} + +func (s *coupon) ListAvailable( + ctx context.Context, + tenantID int64, + userID int64, + amount int64, +) ([]coupon_dto.UserCouponItem, error) { + if userID == 0 { + return nil, errorx.ErrUnauthorized + } + if amount <= 0 { + return nil, errorx.ErrInvalidParameter.WithMsg("订单金额无效") + } + + tbl, q := models.UserCouponQuery.QueryContext(ctx) + q = q.Where(tbl.UserID.Eq(userID), tbl.Status.Eq(consts.UserCouponStatusUnused)) + + list, err := q.Order(tbl.CreatedAt.Desc()).Find() + if err != nil { + return nil, errorx.ErrDatabaseError.WithCause(err) + } + if len(list) == 0 { + return []coupon_dto.UserCouponItem{}, nil + } + + couponMap, err := s.fetchCouponMap(ctx, tenantID, list) + if err != nil { + return nil, err + } + + now := time.Now() + res := make([]coupon_dto.UserCouponItem, 0, len(list)) + for _, v := range list { + if v == nil { + continue + } + c, ok := couponMap[v.CouponID] + if !ok { + continue + } + + if !s.isCouponActive(c, now) { + if s.isCouponExpired(c, now) && v.Status == consts.UserCouponStatusUnused { + if err := s.markUserCouponExpired(ctx, v.ID); err != nil { + return nil, err + } + } + continue + } + + if amount < c.MinOrderAmount { + continue + } + + res = append(res, s.composeUserCouponItem(v, c, consts.UserCouponStatusUnused)) + } + + return res, nil +} + +func (s *coupon) Receive( + ctx context.Context, + tenantID int64, + userID int64, + couponID int64, +) (*coupon_dto.UserCouponItem, error) { + if userID == 0 { + return nil, errorx.ErrUnauthorized + } + if couponID == 0 { + return nil, errorx.ErrInvalidParameter.WithMsg("优惠券不存在") + } + + var result *coupon_dto.UserCouponItem + err := models.Q.Transaction(func(tx *models.Query) error { + // 锁定优惠券行,防止并发超发。 + tbl, q := tx.Coupon.QueryContext(ctx) + q = q.Where(tbl.ID.Eq(couponID)) + if tenantID > 0 { + q = q.Where(tbl.TenantID.Eq(tenantID)) + } + coupon, err := q.Clauses(clause.Locking{Strength: "UPDATE"}).First() + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return errorx.ErrRecordNotFound.WithMsg("优惠券不存在") + } + return errorx.ErrDatabaseError.WithCause(err) + } + if tenantID > 0 && coupon.TenantID != tenantID { + return errorx.ErrForbidden.WithMsg("优惠券租户不匹配") + } + + now := time.Now() + if !coupon.StartAt.IsZero() && now.Before(coupon.StartAt) { + return errorx.ErrBusinessLogic.WithMsg("优惠券尚未生效") + } + if !coupon.EndAt.IsZero() && now.After(coupon.EndAt) { + return errorx.ErrBusinessLogic.WithMsg("优惠券已过期") + } + + existing, err := tx.UserCoupon.WithContext(ctx). + Where(tx.UserCoupon.UserID.Eq(userID), tx.UserCoupon.CouponID.Eq(coupon.ID)). + First() + if err == nil { + item := s.composeUserCouponItem(existing, coupon, existing.Status) + result = &item + return nil + } + if !errors.Is(err, gorm.ErrRecordNotFound) { + return errorx.ErrDatabaseError.WithCause(err) + } + + if coupon.TotalQuantity > 0 { + count, err := tx.UserCoupon.WithContext(ctx). + Where(tx.UserCoupon.CouponID.Eq(coupon.ID)). + Count() + if err != nil { + return errorx.ErrDatabaseError.WithCause(err) + } + if int32(count) >= coupon.TotalQuantity { + return errorx.ErrBusinessLogic.WithMsg("优惠券已领完") + } + } + + uc := &models.UserCoupon{ + UserID: userID, + CouponID: coupon.ID, + Status: consts.UserCouponStatusUnused, + } + if err := tx.UserCoupon.WithContext(ctx).Create(uc); err != nil { + return errorx.ErrDatabaseError.WithCause(err) + } + + item := s.composeUserCouponItem(uc, coupon, consts.UserCouponStatusUnused) + result = &item + return nil + }) + if err != nil { + return nil, err + } + return result, nil +} + +func (s *coupon) Create( + ctx context.Context, + tenantID int64, + userID int64, + form *coupon_dto.CouponCreateForm, +) (*coupon_dto.CouponItem, error) { + if tenantID == 0 { + return nil, errorx.ErrForbidden.WithMsg("租户未绑定") + } + if form == nil { + return nil, errorx.ErrInvalidParameter.WithMsg("参数无效") + } + _ = userID + + title := strings.TrimSpace(form.Title) + if title == "" { + return nil, errorx.ErrInvalidParameter.WithMsg("优惠券标题不能为空") + } + + couponType, err := s.parseCouponType(form.Type) + if err != nil { + return nil, err + } + if err := s.validateCouponValue(couponType, form.Value, form.MaxDiscount); err != nil { + return nil, err + } + if form.MinOrderAmount < 0 { + return nil, errorx.ErrInvalidParameter.WithMsg("使用门槛无效") + } + if form.TotalQuantity < 0 { + return nil, errorx.ErrInvalidParameter.WithMsg("发行数量无效") + } + + startAt, err := s.parseTime(form.StartAt) + if err != nil { + return nil, err + } + endAt, err := s.parseTime(form.EndAt) + if err != nil { + return nil, err + } + if !startAt.IsZero() && !endAt.IsZero() && startAt.After(endAt) { + return nil, errorx.ErrInvalidParameter.WithMsg("生效时间不能晚于过期时间") + } + + coupon := &models.Coupon{ + TenantID: tenantID, + Title: title, + Description: strings.TrimSpace(form.Description), + Type: couponType, + Value: form.Value, + MinOrderAmount: form.MinOrderAmount, + MaxDiscount: form.MaxDiscount, + TotalQuantity: form.TotalQuantity, + UsedQuantity: 0, + StartAt: startAt, + EndAt: endAt, + } + + if err := models.CouponQuery.WithContext(ctx).Create(coupon); err != nil { + return nil, errorx.ErrDatabaseError.WithCause(err) + } + + item := s.composeCouponItem(coupon) + return &item, nil +} + +func (s *coupon) Update( + ctx context.Context, + tenantID int64, + userID int64, + id int64, + form *coupon_dto.CouponUpdateForm, +) (*coupon_dto.CouponItem, error) { + if tenantID == 0 { + return nil, errorx.ErrForbidden.WithMsg("租户未绑定") + } + if id == 0 || form == nil { + return nil, errorx.ErrInvalidParameter.WithMsg("参数无效") + } + _ = userID + + var result *coupon_dto.CouponItem + err := models.Q.Transaction(func(tx *models.Query) error { + tbl, q := tx.Coupon.QueryContext(ctx) + q = q.Where(tbl.ID.Eq(id), tbl.TenantID.Eq(tenantID)) + coupon, err := q.Clauses(clause.Locking{Strength: "UPDATE"}).First() + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return errorx.ErrRecordNotFound.WithMsg("优惠券不存在") + } + return errorx.ErrDatabaseError.WithCause(err) + } + + now := time.Now() + claimedCount, err := tx.UserCoupon.WithContext(ctx). + Where(tx.UserCoupon.CouponID.Eq(coupon.ID)). + Count() + if err != nil { + return errorx.ErrDatabaseError.WithCause(err) + } + if claimedCount > 0 || (!coupon.StartAt.IsZero() && now.After(coupon.StartAt)) { + return errorx.ErrBusinessLogic.WithMsg("优惠券已领取或已生效,禁止修改") + } + + updates := make([]field.AssignExpr, 0) + nextType := coupon.Type + nextValue := coupon.Value + nextMaxDiscount := coupon.MaxDiscount + startAt := coupon.StartAt + endAt := coupon.EndAt + + if form.Title != nil { + value := strings.TrimSpace(*form.Title) + if value == "" { + return errorx.ErrInvalidParameter.WithMsg("优惠券标题不能为空") + } + updates = append(updates, tbl.Title.Value(value)) + } + if form.Description != nil { + updates = append(updates, tbl.Description.Value(strings.TrimSpace(*form.Description))) + } + if form.Type != nil { + parsed, err := s.parseCouponType(*form.Type) + if err != nil { + return err + } + nextType = parsed + updates = append(updates, tbl.Type.Value(parsed)) + } + if form.Value != nil { + nextValue = *form.Value + updates = append(updates, tbl.Value.Value(*form.Value)) + } + if form.MinOrderAmount != nil { + if *form.MinOrderAmount < 0 { + return errorx.ErrInvalidParameter.WithMsg("使用门槛无效") + } + updates = append(updates, tbl.MinOrderAmount.Value(*form.MinOrderAmount)) + } + if form.MaxDiscount != nil { + nextMaxDiscount = *form.MaxDiscount + updates = append(updates, tbl.MaxDiscount.Value(*form.MaxDiscount)) + } + if form.TotalQuantity != nil { + if *form.TotalQuantity < 0 { + return errorx.ErrInvalidParameter.WithMsg("发行数量无效") + } + if *form.TotalQuantity > 0 && int32(claimedCount) > *form.TotalQuantity { + return errorx.ErrBusinessLogic.WithMsg("发行数量不足以覆盖已领取数量") + } + updates = append(updates, tbl.TotalQuantity.Value(*form.TotalQuantity)) + } + if form.StartAt != nil { + parsed, err := s.parseTime(*form.StartAt) + if err != nil { + return err + } + startAt = parsed + updates = append(updates, tbl.StartAt.Value(parsed)) + } + if form.EndAt != nil { + parsed, err := s.parseTime(*form.EndAt) + if err != nil { + return err + } + endAt = parsed + updates = append(updates, tbl.EndAt.Value(parsed)) + } + + if err := s.validateCouponValue(nextType, nextValue, nextMaxDiscount); err != nil { + return err + } + if !startAt.IsZero() && !endAt.IsZero() && startAt.After(endAt) { + return errorx.ErrInvalidParameter.WithMsg("生效时间不能晚于过期时间") + } + + if len(updates) == 0 { + resultItem := s.composeCouponItem(coupon) + result = &resultItem + return nil + } + + updates = append(updates, tbl.UpdatedAt.Value(time.Now())) + if _, err := tx.Coupon.WithContext(ctx).Where(tbl.ID.Eq(coupon.ID)).UpdateSimple(updates...); err != nil { + return errorx.ErrDatabaseError.WithCause(err) + } + updated, err := tx.Coupon.WithContext(ctx).Where(tbl.ID.Eq(coupon.ID)).First() + if err != nil { + return errorx.ErrDatabaseError.WithCause(err) + } + resultItem := s.composeCouponItem(updated) + result = &resultItem + return nil + }) + if err != nil { + return nil, err + } + return result, nil +} + +func (s *coupon) Get( + ctx context.Context, + tenantID int64, + id int64, +) (*coupon_dto.CouponItem, error) { + if id == 0 { + return nil, errorx.ErrInvalidParameter.WithMsg("优惠券不存在") + } + query := models.CouponQuery.WithContext(ctx).Where(models.CouponQuery.ID.Eq(id)) + if tenantID > 0 { + query = query.Where(models.CouponQuery.TenantID.Eq(tenantID)) + } + coupon, err := query.First() + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, errorx.ErrRecordNotFound.WithMsg("优惠券不存在") + } + return nil, errorx.ErrDatabaseError.WithCause(err) + } + item := s.composeCouponItem(coupon) + return &item, nil +} + +func (s *coupon) List( + ctx context.Context, + tenantID int64, + filter *coupon_dto.CouponListFilter, +) (*requests.Pager, error) { + if tenantID == 0 { + return nil, errorx.ErrForbidden.WithMsg("租户未绑定") + } + if filter == nil { + filter = &coupon_dto.CouponListFilter{} + } + + tbl, q := models.CouponQuery.QueryContext(ctx) + q = q.Where(tbl.TenantID.Eq(tenantID)) + + if filter.Type != nil && strings.TrimSpace(*filter.Type) != "" { + parsed, err := s.parseCouponType(*filter.Type) + if err != nil { + return nil, err + } + q = q.Where(tbl.Type.Eq(parsed)) + } + if filter.Keyword != nil && strings.TrimSpace(*filter.Keyword) != "" { + keyword := strings.TrimSpace(*filter.Keyword) + q = q.Where(field.Or( + tbl.Title.Like("%"+keyword+"%"), + tbl.Description.Like("%"+keyword+"%"), + )) + } + if filter.Status != nil && strings.TrimSpace(*filter.Status) != "" { + status := strings.TrimSpace(*filter.Status) + now := time.Now() + switch status { + case "active": + q = q.Where(field.Or(tbl.StartAt.Lte(now), tbl.StartAt.IsNull())) + q = q.Where(field.Or(tbl.EndAt.Gte(now), tbl.EndAt.IsNull())) + case "expired": + q = q.Where(tbl.EndAt.IsNotNull(), tbl.EndAt.Lt(now)) + default: + return nil, errorx.ErrInvalidParameter.WithMsg("状态参数无效") + } + } + + p := requests.Pagination{Page: filter.Page, Limit: filter.Limit} + total, err := q.Count() + if err != nil { + return nil, errorx.ErrDatabaseError.WithCause(err) + } + + list, err := q.Order(tbl.CreatedAt.Desc()).Offset(int(p.Offset())).Limit(int(p.Limit)).Find() + if err != nil { + return nil, errorx.ErrDatabaseError.WithCause(err) + } + + items := make([]coupon_dto.CouponItem, 0, len(list)) + for _, c := range list { + if c == nil { + continue + } + items = append(items, s.composeCouponItem(c)) + } + + return &requests.Pager{ + Pagination: requests.Pagination{Page: p.Page, Limit: p.Limit}, + Total: total, + Items: items, + }, nil +} + +func (s *coupon) Grant( + ctx context.Context, + tenantID int64, + couponID int64, + userIDs []int64, +) (int, error) { + if tenantID == 0 { + return 0, errorx.ErrForbidden.WithMsg("租户未绑定") + } + if couponID == 0 { + return 0, errorx.ErrInvalidParameter.WithMsg("优惠券不存在") + } + if len(userIDs) == 0 { + return 0, errorx.ErrInvalidParameter.WithMsg("用户列表为空") + } + + uniq := make(map[int64]struct{}, len(userIDs)) + cleanIDs := make([]int64, 0, len(userIDs)) + for _, id := range userIDs { + if id <= 0 { + continue + } + if _, ok := uniq[id]; ok { + continue + } + uniq[id] = struct{}{} + cleanIDs = append(cleanIDs, id) + } + if len(cleanIDs) == 0 { + return 0, errorx.ErrInvalidParameter.WithMsg("用户列表为空") + } + + var granted int + err := models.Q.Transaction(func(tx *models.Query) error { + // 锁定优惠券行,防止并发超发。 + tbl, q := tx.Coupon.QueryContext(ctx) + q = q.Where(tbl.ID.Eq(couponID), tbl.TenantID.Eq(tenantID)) + coupon, err := q.Clauses(clause.Locking{Strength: "UPDATE"}).First() + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return errorx.ErrRecordNotFound.WithMsg("优惠券不存在") + } + return errorx.ErrDatabaseError.WithCause(err) + } + + now := time.Now() + if !coupon.StartAt.IsZero() && now.Before(coupon.StartAt) { + return errorx.ErrBusinessLogic.WithMsg("优惠券尚未生效") + } + if !coupon.EndAt.IsZero() && now.After(coupon.EndAt) { + return errorx.ErrBusinessLogic.WithMsg("优惠券已过期") + } + + existingList, err := tx.UserCoupon.WithContext(ctx). + Where(tx.UserCoupon.CouponID.Eq(coupon.ID), tx.UserCoupon.UserID.In(cleanIDs...)). + Find() + if err != nil { + return errorx.ErrDatabaseError.WithCause(err) + } + existMap := make(map[int64]struct{}, len(existingList)) + for _, item := range existingList { + existMap[item.UserID] = struct{}{} + } + + newIDs := make([]int64, 0, len(cleanIDs)) + for _, id := range cleanIDs { + if _, ok := existMap[id]; ok { + continue + } + newIDs = append(newIDs, id) + } + if len(newIDs) == 0 { + return nil + } + + if coupon.TotalQuantity > 0 { + count, err := tx.UserCoupon.WithContext(ctx). + Where(tx.UserCoupon.CouponID.Eq(coupon.ID)). + Count() + if err != nil { + return errorx.ErrDatabaseError.WithCause(err) + } + if int32(count)+int32(len(newIDs)) > coupon.TotalQuantity { + return errorx.ErrBusinessLogic.WithMsg("优惠券库存不足") + } + } + + for _, id := range newIDs { + uc := &models.UserCoupon{ + UserID: id, + CouponID: coupon.ID, + Status: consts.UserCouponStatusUnused, + } + if err := tx.UserCoupon.WithContext(ctx).Create(uc); err != nil { + return errorx.ErrDatabaseError.WithCause(err) + } + granted++ + } + return nil + }) + if err != nil { + return 0, err + } + return granted, nil +} + +// Validate checks if a coupon can be used for an order and returns the discount amount +func (s *coupon) Validate(ctx context.Context, tenantID, userID, userCouponID, amount int64) (int64, error) { + uc, err := models.UserCouponQuery.WithContext(ctx).Where(models.UserCouponQuery.ID.Eq(userCouponID)).First() + if err != nil { + return 0, errorx.ErrRecordNotFound.WithMsg("优惠券不存在") + } + if uc.UserID != userID { + return 0, errorx.ErrUnauthorized.WithMsg("无权使用该优惠券") + } + if uc.Status != consts.UserCouponStatusUnused { + return 0, errorx.ErrBusinessLogic.WithMsg("优惠券已使用或失效") + } + + c, err := models.CouponQuery.WithContext(ctx).Where(models.CouponQuery.ID.Eq(uc.CouponID)).First() + if err != nil { + return 0, errorx.ErrRecordNotFound.WithMsg("优惠券信息缺失") + } + if tenantID > 0 && c.TenantID != tenantID { + return 0, errorx.ErrForbidden.WithMsg("优惠券租户不匹配") + } + + now := time.Now() + if !c.StartAt.IsZero() && now.Before(c.StartAt) { + return 0, errorx.ErrBusinessLogic.WithMsg("优惠券尚未生效") + } + if !c.EndAt.IsZero() && now.After(c.EndAt) { + if err := s.markUserCouponExpired(ctx, uc.ID); err != nil { + return 0, err + } + return 0, errorx.ErrBusinessLogic.WithMsg("优惠券已过期") + } + + if amount < c.MinOrderAmount { + return 0, errorx.ErrBusinessLogic.WithMsg("未达到优惠券使用门槛") + } + + var discount int64 + switch c.Type { + case consts.CouponTypeFixAmount: + discount = c.Value + case consts.CouponTypeDiscount: + discount = (amount * c.Value) / 100 + if c.MaxDiscount > 0 && discount > c.MaxDiscount { + discount = c.MaxDiscount + } + default: + return 0, errorx.ErrDataCorrupted.WithMsg("优惠券类型异常") + } + + // Discount cannot exceed order amount + if discount > amount { + discount = amount + } + + return discount, nil +} + +// MarkUsed marks a user coupon as used (intended to be called inside a transaction) +func (s *coupon) MarkUsed(ctx context.Context, tx *models.Query, tenantID, userCouponID, orderID int64) error { + uc, err := tx.UserCoupon.WithContext(ctx).Where(tx.UserCoupon.ID.Eq(userCouponID)).First() + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return errorx.ErrRecordNotFound.WithMsg("优惠券不存在") + } + return errorx.ErrDatabaseError.WithCause(err) + } + if uc.Status != consts.UserCouponStatusUnused { + return errorx.ErrBusinessLogic.WithMsg("优惠券核销失败") + } + + c, err := tx.Coupon.WithContext(ctx).Where(tx.Coupon.ID.Eq(uc.CouponID)).First() + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return errorx.ErrRecordNotFound.WithMsg("优惠券信息缺失") + } + return errorx.ErrDatabaseError.WithCause(err) + } + if tenantID > 0 && c.TenantID != tenantID { + return errorx.ErrForbidden.WithMsg("优惠券租户不匹配") + } + + now := time.Now() + // Update User Coupon + info, err := tx.UserCoupon.WithContext(ctx). + Where(tx.UserCoupon.ID.Eq(userCouponID), tx.UserCoupon.Status.Eq(consts.UserCouponStatusUnused)). + Updates(&models.UserCoupon{ + Status: consts.UserCouponStatusUsed, + OrderID: orderID, + UsedAt: now, + }) + if err != nil { + return errorx.ErrDatabaseError.WithCause(err) + } + if info.RowsAffected == 0 { + return errorx.ErrBusinessLogic.WithMsg("优惠券核销失败") + } + + // Update Coupon used quantity (Optional, but good for stats) + _, _ = tx.Coupon.WithContext(ctx).Where(tx.Coupon.ID.Eq(uc.CouponID)).UpdateSimple(tx.Coupon.UsedQuantity.Add(1)) + + return nil +} + +func (s *coupon) fetchCouponMap(ctx context.Context, tenantID int64, list []*models.UserCoupon) (map[int64]*models.Coupon, error) { couponIDSet := make(map[int64]struct{}, len(list)) couponIDs := make([]int64, 0, len(list)) for _, v := range list { + if v == nil { + continue + } if _, ok := couponIDSet[v.CouponID]; ok { continue } @@ -64,129 +752,118 @@ func (s *coupon) ListUserCoupons( for _, c := range coupons { couponMap[c.ID] = c } - - res := make([]coupon_dto.UserCouponItem, 0, len(list)) - for _, v := range list { - c, ok := couponMap[v.CouponID] - if !ok { - continue - } - item := coupon_dto.UserCouponItem{ - ID: v.ID, - CouponID: v.CouponID, - Status: v.Status, - } - if c != nil { - item.Title = c.Title - item.Description = c.Description - item.Type = c.Type - item.Value = c.Value - item.MinOrderAmount = c.MinOrderAmount - if !c.StartAt.IsZero() { - item.StartAt = c.StartAt.Format(time.RFC3339) - } - if !c.EndAt.IsZero() { - item.EndAt = c.EndAt.Format(time.RFC3339) - } - } - res = append(res, item) - } - return res, nil + return couponMap, nil } -// Validate checks if a coupon can be used for an order and returns the discount amount -func (s *coupon) Validate(ctx context.Context, tenantID, userID, userCouponID, amount int64) (int64, error) { - uc, err := models.UserCouponQuery.WithContext(ctx).Where(models.UserCouponQuery.ID.Eq(userCouponID)).First() - if err != nil { - return 0, errorx.ErrRecordNotFound.WithMsg("优惠券不存在") +func (s *coupon) composeUserCouponItem(uc *models.UserCoupon, c *models.Coupon, status consts.UserCouponStatus) coupon_dto.UserCouponItem { + item := coupon_dto.UserCouponItem{ + ID: uc.ID, + CouponID: uc.CouponID, + Status: string(status), } - if uc.UserID != userID { - return 0, errorx.ErrUnauthorized.WithMsg("无权使用该优惠券") + if c != nil { + item.Title = c.Title + item.Description = c.Description + item.Type = string(c.Type) + item.Value = c.Value + item.MinOrderAmount = c.MinOrderAmount + item.StartAt = s.formatTime(c.StartAt) + item.EndAt = s.formatTime(c.EndAt) } - if uc.Status != "unused" { - return 0, errorx.ErrBusinessLogic.WithMsg("优惠券已使用或失效") - } - - c, err := models.CouponQuery.WithContext(ctx).Where(models.CouponQuery.ID.Eq(uc.CouponID)).First() - if err != nil { - return 0, errorx.ErrRecordNotFound.WithMsg("优惠券信息缺失") - } - if tenantID > 0 && c.TenantID != tenantID { - return 0, errorx.ErrForbidden.WithMsg("优惠券租户不匹配") - } - - now := time.Now() - if !c.StartAt.IsZero() && now.Before(c.StartAt) { - return 0, errorx.ErrBusinessLogic.WithMsg("优惠券尚未生效") - } - if !c.EndAt.IsZero() && now.After(c.EndAt) { - return 0, errorx.ErrBusinessLogic.WithMsg("优惠券已过期") - } - - if amount < c.MinOrderAmount { - return 0, errorx.ErrBusinessLogic.WithMsg("未达到优惠券使用门槛") - } - - var discount int64 - if c.Type == "fix_amount" { - discount = c.Value - } else if c.Type == "discount" { - discount = (amount * c.Value) / 100 - if c.MaxDiscount > 0 && discount > c.MaxDiscount { - discount = c.MaxDiscount - } - } - - // Discount cannot exceed order amount - if discount > amount { - discount = amount - } - - return discount, nil + return item } -// MarkUsed marks a user coupon as used (intended to be called inside a transaction) -func (s *coupon) MarkUsed(ctx context.Context, tx *models.Query, tenantID, userCouponID, orderID int64) error { - uc, err := tx.UserCoupon.WithContext(ctx).Where(tx.UserCoupon.ID.Eq(userCouponID)).First() +func (s *coupon) composeCouponItem(c *models.Coupon) coupon_dto.CouponItem { + return coupon_dto.CouponItem{ + ID: c.ID, + Title: c.Title, + Description: c.Description, + Type: string(c.Type), + Value: c.Value, + MinOrderAmount: c.MinOrderAmount, + MaxDiscount: c.MaxDiscount, + TotalQuantity: c.TotalQuantity, + UsedQuantity: c.UsedQuantity, + StartAt: s.formatTime(c.StartAt), + EndAt: s.formatTime(c.EndAt), + CreatedAt: s.formatTime(c.CreatedAt), + UpdatedAt: s.formatTime(c.UpdatedAt), + } +} + +func (s *coupon) markUserCouponExpired(ctx context.Context, userCouponID int64) error { + if userCouponID == 0 { + return nil + } + _, err := models.UserCouponQuery.WithContext(ctx). + Where(models.UserCouponQuery.ID.Eq(userCouponID), models.UserCouponQuery.Status.Eq(consts.UserCouponStatusUnused)). + UpdateSimple(models.UserCouponQuery.Status.Value(consts.UserCouponStatusExpired)) if err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - return errorx.ErrRecordNotFound.WithMsg("优惠券不存在") - } return errorx.ErrDatabaseError.WithCause(err) } - if uc.Status != "unused" { - return errorx.ErrBusinessLogic.WithMsg("优惠券核销失败") - } - - c, err := tx.Coupon.WithContext(ctx).Where(tx.Coupon.ID.Eq(uc.CouponID)).First() - if err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - return errorx.ErrRecordNotFound.WithMsg("优惠券信息缺失") - } - return errorx.ErrDatabaseError.WithCause(err) - } - if tenantID > 0 && c.TenantID != tenantID { - return errorx.ErrForbidden.WithMsg("优惠券租户不匹配") - } - - now := time.Now() - // Update User Coupon - info, err := tx.UserCoupon.WithContext(ctx). - Where(tx.UserCoupon.ID.Eq(userCouponID), tx.UserCoupon.Status.Eq("unused")). - Updates(&models.UserCoupon{ - Status: "used", - OrderID: orderID, - UsedAt: now, - }) - if err != nil { - return err - } - if info.RowsAffected == 0 { - return errorx.ErrBusinessLogic.WithMsg("优惠券核销失败") - } - - // Update Coupon used quantity (Optional, but good for stats) - _, _ = tx.Coupon.WithContext(ctx).Where(tx.Coupon.ID.Eq(uc.CouponID)).UpdateSimple(tx.Coupon.UsedQuantity.Add(1)) - return nil } + +func (s *coupon) isCouponExpired(c *models.Coupon, now time.Time) bool { + return !c.EndAt.IsZero() && now.After(c.EndAt) +} + +func (s *coupon) isCouponActive(c *models.Coupon, now time.Time) bool { + if !c.StartAt.IsZero() && now.Before(c.StartAt) { + return false + } + if !c.EndAt.IsZero() && now.After(c.EndAt) { + return false + } + return true +} + +func (s *coupon) parseTime(val string) (time.Time, error) { + value := strings.TrimSpace(val) + if value == "" { + return time.Time{}, nil + } + tm, err := time.Parse(time.RFC3339, value) + if err != nil { + return time.Time{}, errorx.ErrInvalidFormat.WithMsg("时间格式无效") + } + return tm, nil +} + +func (s *coupon) parseCouponType(val string) (consts.CouponType, error) { + value := strings.TrimSpace(val) + if value == "" { + return "", errorx.ErrInvalidParameter.WithMsg("优惠券类型不能为空") + } + couponType, err := consts.ParseCouponType(value) + if err != nil { + return "", errorx.ErrInvalidParameter.WithMsg("优惠券类型无效") + } + return couponType, nil +} + +func (s *coupon) validateCouponValue(typ consts.CouponType, value, maxDiscount int64) error { + switch typ { + case consts.CouponTypeFixAmount: + if value <= 0 { + return errorx.ErrInvalidParameter.WithMsg("优惠券面值无效") + } + case consts.CouponTypeDiscount: + if value <= 0 || value > 100 { + return errorx.ErrInvalidParameter.WithMsg("折扣比例无效") + } + if maxDiscount < 0 { + return errorx.ErrInvalidParameter.WithMsg("最高抵扣金额无效") + } + default: + return errorx.ErrInvalidParameter.WithMsg("优惠券类型无效") + } + return nil +} + +func (s *coupon) formatTime(t time.Time) string { + if t.IsZero() { + return "" + } + return t.Format(time.RFC3339) +} diff --git a/backend/app/services/coupon_test.go b/backend/app/services/coupon_test.go index 8f5f71b..a1291b1 100644 --- a/backend/app/services/coupon_test.go +++ b/backend/app/services/coupon_test.go @@ -58,7 +58,7 @@ func (s *CouponTestSuite) Test_CouponFlow() { cp := &models.Coupon{ TenantID: tenantID, Title: "Save 5", - Type: "fix_amount", + Type: consts.CouponTypeFixAmount, Value: 500, MinOrderAmount: 1000, } @@ -68,7 +68,7 @@ func (s *CouponTestSuite) Test_CouponFlow() { uc := &models.UserCoupon{ UserID: user.ID, CouponID: cp.ID, - Status: "unused", + Status: consts.UserCouponStatusUnused, } models.UserCouponQuery.WithContext(ctx).Create(uc) @@ -91,7 +91,7 @@ func (s *CouponTestSuite) Test_CouponFlow() { TenantID: tenantID, ContentID: c.ID, PriceAmount: 2000, // 20.00 CNY - Currency: "CNY", + Currency: consts.CurrencyCNY, }) form := &order_dto.OrderCreateForm{ @@ -112,8 +112,87 @@ func (s *CouponTestSuite) Test_CouponFlow() { // Verify Coupon Status ucReload, _ := models.UserCouponQuery.WithContext(ctx).Where(models.UserCouponQuery.ID.Eq(uc.ID)).First() - So(ucReload.Status, ShouldEqual, "used") + So(ucReload.Status, ShouldEqual, consts.UserCouponStatusUsed) So(ucReload.OrderID, ShouldEqual, oid) }) }) } + +func (s *CouponTestSuite) Test_Receive() { + Convey("Receive", s.T(), func() { + ctx := s.T().Context() + tenantID := int64(2) + database.Truncate( + ctx, + s.DB, + models.TableNameCoupon, + models.TableNameUserCoupon, + models.TableNameUser, + ) + + user := &models.User{Username: "coupon_receive", Phone: "13800000002"} + So(models.UserQuery.WithContext(ctx).Create(user), ShouldBeNil) + + cp := &models.Coupon{ + TenantID: tenantID, + Title: "Receive Test", + Type: consts.CouponTypeFixAmount, + Value: 300, + MinOrderAmount: 0, + TotalQuantity: 1, + } + So(models.CouponQuery.WithContext(ctx).Create(cp), ShouldBeNil) + + item, err := Coupon.Receive(ctx, tenantID, user.ID, cp.ID) + So(err, ShouldBeNil) + So(item, ShouldNotBeNil) + So(item.CouponID, ShouldEqual, cp.ID) + + // second receive should return existing + item2, err := Coupon.Receive(ctx, tenantID, user.ID, cp.ID) + So(err, ShouldBeNil) + So(item2.CouponID, ShouldEqual, cp.ID) + }) +} + +func (s *CouponTestSuite) Test_ListAvailable() { + Convey("ListAvailable", s.T(), func() { + ctx := s.T().Context() + tenantID := int64(3) + database.Truncate( + ctx, + s.DB, + models.TableNameCoupon, + models.TableNameUserCoupon, + models.TableNameUser, + ) + + user := &models.User{Username: "coupon_available", Phone: "13800000003"} + So(models.UserQuery.WithContext(ctx).Create(user), ShouldBeNil) + + cp := &models.Coupon{ + TenantID: tenantID, + Title: "Available Test", + Type: consts.CouponTypeFixAmount, + Value: 500, + MinOrderAmount: 1000, + } + So(models.CouponQuery.WithContext(ctx).Create(cp), ShouldBeNil) + + uc := &models.UserCoupon{ + UserID: user.ID, + CouponID: cp.ID, + Status: consts.UserCouponStatusUnused, + } + So(models.UserCouponQuery.WithContext(ctx).Create(uc), ShouldBeNil) + + list, err := Coupon.ListAvailable(ctx, tenantID, user.ID, 500) + So(err, ShouldBeNil) + So(len(list), ShouldEqual, 0) + + list, err = Coupon.ListAvailable(ctx, tenantID, user.ID, 1200) + So(err, ShouldBeNil) + So(len(list), ShouldEqual, 1) + So(list[0].CouponID, ShouldEqual, cp.ID) + }) +} diff --git a/backend/database/.transform.yaml b/backend/database/.transform.yaml index 92ce64e..149ddc6 100644 --- a/backend/database/.transform.yaml +++ b/backend/database/.transform.yaml @@ -48,6 +48,10 @@ field_type: status: consts.ContentAccessStatus tenant_ledgers: type: consts.TenantLedgerType + coupons: + type: consts.CouponType + user_coupons: + status: consts.UserCouponStatus field_relate: contents: Author: diff --git a/backend/database/models/contents.gen.go b/backend/database/models/contents.gen.go index fc251f6..a3de8e8 100644 --- a/backend/database/models/contents.gen.go +++ b/backend/database/models/contents.gen.go @@ -40,9 +40,9 @@ type Content struct { 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 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"` 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 diff --git a/backend/database/models/contents.query.gen.go b/backend/database/models/contents.query.gen.go index d462865..ddbf1b6 100644 --- a/backend/database/models/contents.query.gen.go +++ b/backend/database/models/contents.query.gen.go @@ -46,6 +46,12 @@ func newContent(db *gorm.DB, opts ...gen.DOOption) contentQuery { _contentQuery.DeletedAt = field.NewField(tableName, "deleted_at") _contentQuery.Key = field.NewString(tableName, "key") _contentQuery.IsPinned = field.NewBool(tableName, "is_pinned") + _contentQuery.Comments = contentQueryHasManyComments{ + db: db.Session(&gorm.Session{}), + + RelationField: field.NewRelation("Comments", "Comment"), + } + _contentQuery.Author = contentQueryBelongsToAuthor{ db: db.Session(&gorm.Session{}), @@ -58,12 +64,6 @@ func newContent(db *gorm.DB, opts ...gen.DOOption) contentQuery { RelationField: field.NewRelation("ContentAssets", "ContentAsset"), } - _contentQuery.Comments = contentQueryHasManyComments{ - db: db.Session(&gorm.Session{}), - - RelationField: field.NewRelation("Comments", "Comment"), - } - _contentQuery.fillFieldMap() return _contentQuery @@ -94,12 +94,12 @@ type contentQuery struct { DeletedAt field.Field Key field.String // Musical key/tone IsPinned field.Bool // Whether content is pinned/featured - Author contentQueryBelongsToAuthor + Comments contentQueryHasManyComments + + Author contentQueryBelongsToAuthor ContentAssets contentQueryHasManyContentAssets - Comments contentQueryHasManyComments - fieldMap map[string]field.Expr } @@ -195,23 +195,104 @@ func (c *contentQuery) fillFieldMap() { func (c contentQuery) clone(db *gorm.DB) contentQuery { 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.Statement.ConnPool = db.Statement.ConnPool c.ContentAssets.db = db.Session(&gorm.Session{Initialized: true}) 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 } func (c contentQuery) replaceDB(db *gorm.DB) contentQuery { c.contentQueryDo.ReplaceDB(db) + c.Comments.db = db.Session(&gorm.Session{}) c.Author.db = db.Session(&gorm.Session{}) c.ContentAssets.db = db.Session(&gorm.Session{}) - c.Comments.db = db.Session(&gorm.Session{}) 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 { db *gorm.DB @@ -374,87 +455,6 @@ func (a contentQueryHasManyContentAssetsTx) Unscoped() *contentQueryHasManyConte 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 } func (c contentQueryDo) Debug() *contentQueryDo { diff --git a/backend/database/models/coupons.gen.go b/backend/database/models/coupons.gen.go index 6c020a8..2ad2c95 100644 --- a/backend/database/models/coupons.gen.go +++ b/backend/database/models/coupons.gen.go @@ -8,6 +8,8 @@ import ( "context" "time" + "quyun/v2/pkg/consts" + "go.ipao.vip/gen" ) @@ -15,20 +17,20 @@ const TableNameCoupon = "coupons" // Coupon mapped from table type Coupon struct { - 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"` - Title string `gorm:"column:title;type:character varying(255);not null" json:"title"` - Description string `gorm:"column:description;type:text" json:"description"` - Type string `gorm:"column:type;type:character varying(32);not null" json:"type"` - 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"` - MaxDiscount int64 `gorm:"column:max_discount;type:bigint" json:"max_discount"` - 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"` - 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"` - 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"` + 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"` + Title string `gorm:"column:title;type:character varying(255);not null" json:"title"` + Description string `gorm:"column:description;type:text" json:"description"` + 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"` + 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"` + 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"` + 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"` + 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"` } // Quick operations without importing query package diff --git a/backend/database/models/coupons.query.gen.go b/backend/database/models/coupons.query.gen.go index 842edaf..d13552d 100644 --- a/backend/database/models/coupons.query.gen.go +++ b/backend/database/models/coupons.query.gen.go @@ -29,7 +29,7 @@ func newCoupon(db *gorm.DB, opts ...gen.DOOption) couponQuery { _couponQuery.TenantID = field.NewInt64(tableName, "tenant_id") _couponQuery.Title = field.NewString(tableName, "title") _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.MinOrderAmount = field.NewInt64(tableName, "min_order_amount") _couponQuery.MaxDiscount = field.NewInt64(tableName, "max_discount") @@ -53,7 +53,7 @@ type couponQuery struct { TenantID field.Int64 Title field.String Description field.String - Type field.String + Type field.Field Value field.Int64 MinOrderAmount field.Int64 MaxDiscount field.Int64 @@ -83,7 +83,7 @@ func (c *couponQuery) updateTableName(table string) *couponQuery { c.TenantID = field.NewInt64(table, "tenant_id") c.Title = field.NewString(table, "title") 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.MinOrderAmount = field.NewInt64(table, "min_order_amount") c.MaxDiscount = field.NewInt64(table, "max_discount") diff --git a/backend/database/models/user_coupons.gen.go b/backend/database/models/user_coupons.gen.go index a9aa065..4bbc6ba 100644 --- a/backend/database/models/user_coupons.gen.go +++ b/backend/database/models/user_coupons.gen.go @@ -8,6 +8,8 @@ import ( "context" "time" + "quyun/v2/pkg/consts" + "go.ipao.vip/gen" ) @@ -15,13 +17,13 @@ const TableNameUserCoupon = "user_coupons" // UserCoupon mapped from table type UserCoupon struct { - 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"` - CouponID int64 `gorm:"column:coupon_id;type:bigint;not null" json:"coupon_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"` - 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"` + 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"` + CouponID int64 `gorm:"column:coupon_id;type:bigint;not null" json:"coupon_id"` + OrderID int64 `gorm:"column:order_id;type:bigint" json:"order_id"` + 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"` + 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 diff --git a/backend/database/models/user_coupons.query.gen.go b/backend/database/models/user_coupons.query.gen.go index 690d74d..277b0c6 100644 --- a/backend/database/models/user_coupons.query.gen.go +++ b/backend/database/models/user_coupons.query.gen.go @@ -29,7 +29,7 @@ func newUserCoupon(db *gorm.DB, opts ...gen.DOOption) userCouponQuery { _userCouponQuery.UserID = field.NewInt64(tableName, "user_id") _userCouponQuery.CouponID = field.NewInt64(tableName, "coupon_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.CreatedAt = field.NewTime(tableName, "created_at") @@ -46,7 +46,7 @@ type userCouponQuery struct { UserID field.Int64 CouponID field.Int64 OrderID field.Int64 - Status field.String + Status field.Field UsedAt field.Time CreatedAt field.Time @@ -69,7 +69,7 @@ func (u *userCouponQuery) updateTableName(table string) *userCouponQuery { u.UserID = field.NewInt64(table, "user_id") u.CouponID = field.NewInt64(table, "coupon_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.CreatedAt = field.NewTime(table, "created_at") diff --git a/backend/pkg/consts/coupon.gen.go b/backend/pkg/consts/coupon.gen.go new file mode 100644 index 0000000..9118392 --- /dev/null +++ b/backend/pkg/consts/coupon.gen.go @@ -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 +} diff --git a/backend/pkg/consts/coupon.go b/backend/pkg/consts/coupon.go new file mode 100644 index 0000000..ca97303 --- /dev/null +++ b/backend/pkg/consts/coupon.go @@ -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 +} diff --git a/docs/coupon_plan.md b/docs/coupon_plan.md new file mode 100644 index 0000000..e09ccb3 --- /dev/null +++ b/docs/coupon_plan.md @@ -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=` + - 返回「当前订单金额可用」的优惠券列表(用于结算页选择)。 +- `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` + - 查看模板详情。 +- `PUT /t/:tenantCode/v1/creator/coupons/:id` + - 更新模板(仅未开始或未领取时允许修改核心字段)。 +- `POST /t/:tenantCode/v1/creator/coupons/:id/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) 补测试 + 回归订单流程。 diff --git a/frontend/portal/src/api/user.js b/frontend/portal/src/api/user.js index 08f7cc6..bfc400b 100644 --- a/frontend/portal/src/api/user.js +++ b/frontend/portal/src/api/user.js @@ -20,4 +20,6 @@ export const userApi = { markAllNotificationsRead: () => request('/me/notifications/read-all', { method: 'POST' }), getFollowing: () => request('/me/following'), 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 } }), };