实现已发放优惠券的管理接口

This commit is contained in:
2026-04-29 16:59:14 +08:00
parent ccb8db555e
commit a4d9c28702
7 changed files with 622 additions and 50 deletions

View File

@@ -127,7 +127,8 @@ insert into permission (name, description, sort) values
('trade', '交易', 12),
('bill', '账单', 13),
('balance_activity', '余额变动', 14),
('proxy', '代理', 15);
('proxy', '代理', 15),
('coupon_user', '已发放优惠券', 16);
-- --------------------------
-- level 2
@@ -209,6 +210,11 @@ insert into permission (parent_id, name, description, sort) values
insert into permission (parent_id, name, description, sort) values
((select id from permission where name = 'balance_activity' and deleted_at is null), 'balance_activity:read', '读取余额变动列表', 1);
-- coupon_user 子权限
insert into permission (parent_id, name, description, sort) values
((select id from permission where name = 'coupon_user' and deleted_at is null), 'coupon_user:read', '读取已发放优惠券列表', 1),
((select id from permission where name = 'coupon_user' and deleted_at is null), 'coupon_user:write', '编辑已发放优惠券', 2);
-- --------------------------
-- level 3
-- --------------------------
@@ -263,6 +269,10 @@ insert into permission (parent_id, name, description, sort) values
insert into permission (parent_id, name, description, sort) values
((select id from permission where name = 'coupon:write' and deleted_at is null), 'coupon:write:assign', '发放优惠券', 1);
-- coupon_user:read 子权限
insert into permission (parent_id, name, description, sort) values
((select id from permission where name = 'coupon_user:read' and deleted_at is null), 'coupon_user:read:of_user', '读取指定用户的已发放优惠券列表', 1);
-- --------------------------
-- level 4
-- --------------------------

View File

@@ -53,6 +53,11 @@ const (
ScopeCouponWrite = string("coupon:write") // 写入优惠券
ScopeCouponWriteAssign = string("coupon:write:assign") // 发放优惠券
ScopeCouponUser = string("coupon_user") // 用户优惠券
ScopeCouponUserRead = string("coupon_user:read") // 读取用户优惠券列表
ScopeCouponUserReadOfUser = string("coupon_user:read:of_user") // 读取指定用户的用户优惠券列表
ScopeCouponUserWrite = string("coupon_user:write") // 写入用户优惠券
ScopeBatch = string("batch") // 批次
ScopeBatchRead = string("batch:read") // 读取批次列表
ScopeBatchReadOfUser = string("batch:read:of_user") // 读取指定用户的批次列表

330
web/handlers/coupon_user.go Normal file
View File

@@ -0,0 +1,330 @@
package handlers
import (
"errors"
"platform/pkg/u"
"platform/web/auth"
"platform/web/core"
g "platform/web/globals"
m "platform/web/models"
q "platform/web/queries"
s "platform/web/services"
"time"
"github.com/gofiber/fiber/v2"
"gorm.io/gen"
"gorm.io/gen/field"
"gorm.io/gorm"
)
// PageCouponUser 分页查询当前用户已发放优惠券
func PageCouponUser(c *fiber.Ctx) error {
authCtx, err := auth.GetAuthCtx(c).PermitUser()
if err != nil {
return err
}
var req PageCouponUserReq
if err := g.Validator.ParseBody(c, &req); err != nil {
return err
}
conds := couponUserPageConditions(req.CouponUserPageFilter)
conds = append(conds, q.CouponUser.UserID.Eq(authCtx.User.ID))
list, total, err := q.CouponUser.
Joins(q.CouponUser.Coupon).
Select(couponUserSelect(false)...).
Where(conds...).
Order(q.CouponUser.CreatedAt.Desc()).
FindByPage(req.GetOffset(), req.GetLimit())
if err != nil {
return core.NewBizErr("获取数据失败", err)
}
return c.JSON(core.PageResp{
List: list,
Total: int(total),
Page: req.GetPage(),
Size: req.GetSize(),
})
}
type PageCouponUserReq struct {
core.PageReq
CouponUserPageFilter
}
// GetCouponUser 获取当前用户已发放优惠券详情
func GetCouponUser(c *fiber.Ctx) error {
authCtx, err := auth.GetAuthCtx(c).PermitUser()
if err != nil {
return err
}
var req core.IdReq
if err := g.Validator.ParseBody(c, &req); err != nil {
return err
}
item, err := q.CouponUser.
Joins(q.CouponUser.Coupon).
Select(couponUserSelect(false)...).
Where(
q.CouponUser.ID.Eq(req.Id),
q.CouponUser.UserID.Eq(authCtx.User.ID),
).
Take()
if errors.Is(err, gorm.ErrRecordNotFound) {
return core.NewBizErr("已发放优惠券不存在")
}
if err != nil {
return core.NewBizErr("获取数据失败", err)
}
return c.JSON(item)
}
// PageCouponUserByAdmin 分页查询全部已发放优惠券
func PageCouponUserByAdmin(c *fiber.Ctx) error {
_, err := auth.GetAuthCtx(c).PermitAdmin(core.ScopeCouponUserRead)
if err != nil {
return err
}
var req PageCouponUserByAdminReq
if err := g.Validator.ParseBody(c, &req); err != nil {
return err
}
conds := couponUserPageConditions(req.CouponUserPageFilter)
if req.UserID != nil {
conds = append(conds, q.CouponUser.UserID.Eq(*req.UserID))
}
if req.UserPhone != nil {
conds = append(conds, q.User.As("User").Phone.Eq(*req.UserPhone))
}
list, total, err := q.CouponUser.
Joins(q.CouponUser.Coupon, q.CouponUser.User).
Select(couponUserSelect(true)...).
Where(conds...).
Order(q.CouponUser.CreatedAt.Desc()).
FindByPage(req.GetOffset(), req.GetLimit())
if err != nil {
return core.NewBizErr("获取数据失败", err)
}
return c.JSON(core.PageResp{
List: list,
Total: int(total),
Page: req.GetPage(),
Size: req.GetSize(),
})
}
type PageCouponUserByAdminReq struct {
core.PageReq
CouponUserPageFilter
UserID *int32 `json:"user_id,omitempty"`
UserPhone *string `json:"user_phone,omitempty"`
}
// PageCouponUserOfUserByAdmin 分页查询指定用户已发放优惠券
func PageCouponUserOfUserByAdmin(c *fiber.Ctx) error {
_, err := auth.GetAuthCtx(c).PermitAdmin(core.ScopeCouponUserReadOfUser)
if err != nil {
return err
}
var req PageCouponUserOfUserByAdminReq
if err := g.Validator.ParseBody(c, &req); err != nil {
return err
}
conds := couponUserPageConditions(req.CouponUserPageFilter)
conds = append(conds, q.CouponUser.UserID.Eq(req.UserID))
list, total, err := q.CouponUser.
Joins(q.CouponUser.Coupon, q.CouponUser.User).
Select(couponUserSelect(true)...).
Where(conds...).
Order(q.CouponUser.CreatedAt.Desc()).
FindByPage(req.GetOffset(), req.GetLimit())
if err != nil {
return core.NewBizErr("获取数据失败", err)
}
return c.JSON(core.PageResp{
List: list,
Total: int(total),
Page: req.GetPage(),
Size: req.GetSize(),
})
}
type PageCouponUserOfUserByAdminReq struct {
core.PageReq
CouponUserPageFilter
UserID int32 `json:"user_id" validate:"required"`
}
// GetCouponUserByAdmin 获取已发放优惠券详情
func GetCouponUserByAdmin(c *fiber.Ctx) error {
_, err := auth.GetAuthCtx(c).PermitAdmin(core.ScopeCouponUserRead)
if err != nil {
return err
}
var req core.IdReq
if err := g.Validator.ParseBody(c, &req); err != nil {
return err
}
item, err := q.CouponUser.
Joins(q.CouponUser.Coupon, q.CouponUser.User).
Select(couponUserSelect(true)...).
Where(q.CouponUser.ID.Eq(req.Id)).
Take()
if errors.Is(err, gorm.ErrRecordNotFound) {
return core.NewBizErr("已发放优惠券不存在")
}
if err != nil {
return core.NewBizErr("获取数据失败", err)
}
return c.JSON(item)
}
func CreateCouponUserByAdmin(c *fiber.Ctx) error {
_, err := auth.GetAuthCtx(c).PermitAdmin(core.ScopeCouponUserWrite)
if err != nil {
return err
}
var req s.CreateCouponUserData
if err := g.Validator.ParseBody(c, &req); err != nil {
return err
}
if err := s.CouponUser.Create(req); err != nil {
return err
}
return nil
}
func UpdateCouponUserByAdmin(c *fiber.Ctx) error {
_, err := auth.GetAuthCtx(c).PermitAdmin(core.ScopeCouponUserWrite)
if err != nil {
return err
}
var req s.UpdateCouponUserData
if err := g.Validator.ParseBody(c, &req); err != nil {
return err
}
if err := s.CouponUser.Update(req); err != nil {
return err
}
return nil
}
func DeleteCouponUserByAdmin(c *fiber.Ctx) error {
_, err := auth.GetAuthCtx(c).PermitAdmin(core.ScopeCouponUserWrite)
if err != nil {
return err
}
var req core.IdReq
if err := g.Validator.ParseBody(c, &req); err != nil {
return err
}
if err := s.CouponUser.Delete(req.Id); err != nil {
return err
}
return nil
}
type CouponUserPageFilter struct {
CouponID *int32 `json:"coupon_id,omitempty"`
CouponName *string `json:"coupon_name,omitempty"`
Status *m.CouponUserStatus `json:"status,omitempty"`
Expired *bool `json:"expired,omitempty"`
CreatedAtStart *time.Time `json:"created_at_start,omitempty"`
CreatedAtEnd *time.Time `json:"created_at_end,omitempty"`
ExpireAtStart *time.Time `json:"expire_at_start,omitempty"`
ExpireAtEnd *time.Time `json:"expire_at_end,omitempty"`
UsedAtStart *time.Time `json:"used_at_start,omitempty"`
UsedAtEnd *time.Time `json:"used_at_end,omitempty"`
}
func couponUserPageConditions(req CouponUserPageFilter) []gen.Condition {
conds := make([]gen.Condition, 0)
if req.CouponID != nil {
conds = append(conds, q.CouponUser.CouponID.Eq(*req.CouponID))
}
if req.CouponName != nil {
conds = append(conds, q.Coupon.As("Coupon").Name.Like("%"+*req.CouponName+"%"))
}
if req.Status != nil {
conds = append(conds, q.CouponUser.Status.Eq(int(*req.Status)))
}
if req.Expired != nil {
if *req.Expired {
conds = append(conds, q.CouponUser.ExpireAt.IsNotNull(), q.CouponUser.ExpireAt.Lte(time.Now()))
} else {
conds = append(conds, q.CouponUser.Where(q.CouponUser.ExpireAt.IsNull()).Or(q.CouponUser.ExpireAt.Gt(time.Now())))
}
}
if req.CreatedAtStart != nil {
conds = append(conds, q.CouponUser.CreatedAt.Gte(u.DateHead(*req.CreatedAtStart)))
}
if req.CreatedAtEnd != nil {
conds = append(conds, q.CouponUser.CreatedAt.Lte(u.DateTail(*req.CreatedAtEnd)))
}
if req.ExpireAtStart != nil {
conds = append(conds, q.CouponUser.ExpireAt.Gte(u.DateHead(*req.ExpireAtStart)))
}
if req.ExpireAtEnd != nil {
conds = append(conds, q.CouponUser.ExpireAt.Lte(u.DateTail(*req.ExpireAtEnd)))
}
if req.UsedAtStart != nil {
conds = append(conds, q.CouponUser.UsedAt.Gte(u.DateHead(*req.UsedAtStart)))
}
if req.UsedAtEnd != nil {
conds = append(conds, q.CouponUser.UsedAt.Lte(u.DateTail(*req.UsedAtEnd)))
}
return conds
}
func couponUserSelect(includeUser bool) []field.Expr {
cols := []field.Expr{
q.CouponUser.ALL,
q.Coupon.As("Coupon").ID.As("Coupon__id"),
q.Coupon.As("Coupon").Name.As("Coupon__name"),
q.Coupon.As("Coupon").Amount.As("Coupon__amount"),
q.Coupon.As("Coupon").MinAmount.As("Coupon__min_amount"),
q.Coupon.As("Coupon").Count_.As("Coupon__count"),
q.Coupon.As("Coupon").Status.As("Coupon__status"),
q.Coupon.As("Coupon").ExpireType.As("Coupon__expire_type"),
q.Coupon.As("Coupon").ExpireAt.As("Coupon__expire_at"),
q.Coupon.As("Coupon").ExpireIn.As("Coupon__expire_in"),
q.Coupon.As("Coupon").CreatedAt.As("Coupon__created_at"),
q.Coupon.As("Coupon").UpdatedAt.As("Coupon__updated_at"),
}
if includeUser {
cols = append(cols,
q.User.As("User").ID.As("User__id"),
q.User.As("User").Phone.As("User__phone"),
q.User.As("User").Name.As("User__name"),
)
}
return cols
}

View File

@@ -20,6 +20,7 @@ type CouponUser struct {
type CouponUserStatus int
const (
CouponUserStatusUnused CouponUserStatus = 0 // 未使用
CouponUserStatusUsed CouponUserStatus = 1 // 已使用
CouponUserStatusUnused CouponUserStatus = 0 // 未使用
CouponUserStatusUsed CouponUserStatus = 1 // 已使用
CouponUserStatusDisabled CouponUserStatus = 2 // 已禁用
)

View File

@@ -95,6 +95,11 @@ func userRouter(api fiber.Router) {
balance := api.Group("/balance")
balance.Post("/page", handlers.PageBalanceActivity)
// 已发放优惠券
couponUser := api.Group("/coupon-user")
couponUser.Post("/page", handlers.PageCouponUser)
couponUser.Post("/get", handlers.GetCouponUser)
// 公告
announcement := api.Group("/announcement")
announcement.Post("/list", handlers.ListAnnouncements)
@@ -254,4 +259,13 @@ func adminRouter(api fiber.Router) {
coupon.Post("/update", handlers.UpdateCoupon)
coupon.Post("/remove", handlers.DeleteCoupon)
coupon.Post("/update/assign", handlers.AssignCoupon)
// coupon-user 已发放优惠券
var couponUser = api.Group("/coupon-user")
couponUser.Post("/page", handlers.PageCouponUserByAdmin)
couponUser.Post("/page/of-user", handlers.PageCouponUserOfUserByAdmin)
couponUser.Post("/get", handlers.GetCouponUserByAdmin)
couponUser.Post("/create", handlers.CreateCouponUserByAdmin)
couponUser.Post("/update", handlers.UpdateCouponUserByAdmin)
couponUser.Post("/remove", handlers.DeleteCouponUserByAdmin)
}

View File

@@ -108,54 +108,11 @@ func (s *couponService) Delete(id int32) error {
return err
}
// 发放优惠券
func (s *couponService) Assign(couponID int32, userID int32) error {
// 获取优惠券信息
coupon, err := q.Coupon.Where(q.Coupon.ID.Eq(couponID)).Take()
if errors.Is(err, gorm.ErrRecordNotFound) {
return core.NewBizErr("优惠券不存在")
}
if err != nil {
return core.NewBizErr("获取优惠券数据失败", err)
}
// 检查优惠券是否可用
// 1. 状态必须为正常
if coupon.Status != m.CouponStatusEnabled {
return core.NewBizErr("优惠券不可用")
}
// 2. 数量必须大于0
if coupon.Count <= 0 {
return core.NewBizErr("优惠券已发放完")
}
// 发放优惠券
err = q.Q.Transaction(func(q *q.Query) error {
// 创建用户优惠券记录
err := q.CouponUser.Create(&m.CouponUser{
UserID: userID,
CouponID: couponID,
Status: m.CouponUserStatusUnused,
})
if err != nil {
return err
}
// 扣减优惠券数量
_, err = q.Coupon.
Where(q.Coupon.ID.Eq(couponID), q.Coupon.Count_.Eq(coupon.Count)).
UpdateSimple(q.Coupon.Count_.Value(coupon.Count - 1))
if err != nil {
return err
}
return nil
return CouponUser.Create(CreateCouponUserData{
CouponID: couponID,
UserID: userID,
})
if err != nil {
return core.NewBizErr("发放优惠券失败", err)
}
return nil
}
// GetUserCoupon 获取用户的指定优惠券
@@ -165,7 +122,7 @@ func (s *couponService) GetUserCoupon(uid int32, cuid int32, amount decimal.Deci
q.CouponUser.ID.Eq(cuid),
q.CouponUser.UserID.Eq(uid),
q.CouponUser.Status.Eq(int(m.CouponUserStatusUnused)),
q.CouponUser.ExpireAt.Gt(time.Now()),
q.CouponUser.Where(q.CouponUser.ExpireAt.IsNull()).Or(q.CouponUser.ExpireAt.Gt(time.Now())),
).Take()
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, core.NewBizErr("优惠券不存在或已失效")

255
web/services/coupon_user.go Normal file
View File

@@ -0,0 +1,255 @@
package services
import (
"errors"
"platform/pkg/u"
"platform/web/core"
m "platform/web/models"
q "platform/web/queries"
"time"
"gorm.io/gen/field"
"gorm.io/gorm"
)
var CouponUser = &couponUserService{}
type couponUserService struct{}
func (s *couponUserService) Create(data CreateCouponUserData) error {
now := time.Now()
status := u.Else(data.Status, m.CouponUserStatusUnused)
if err := validateCouponUserStatus(status); err != nil {
return err
}
return q.Q.Transaction(func(tx *q.Query) error {
coupon, err := tx.Coupon.Where(tx.Coupon.ID.Eq(data.CouponID)).Take()
if errors.Is(err, gorm.ErrRecordNotFound) {
return core.NewBizErr("优惠券不存在")
}
if err != nil {
return core.NewServErr("获取优惠券数据失败", err)
}
if coupon.Status != m.CouponStatusEnabled {
return core.NewBizErr("优惠券不可用")
}
if coupon.Count <= 0 {
return core.NewBizErr("优惠券已发放完")
}
_, err = tx.User.Where(tx.User.ID.Eq(data.UserID)).Take()
if errors.Is(err, gorm.ErrRecordNotFound) {
return core.NewBizErr("用户不存在")
}
if err != nil {
return core.NewServErr("获取用户数据失败", err)
}
expireAt := data.ExpireAt
if expireAt == nil {
expireAt = couponUserExpireAt(coupon, now)
}
usedAt := data.UsedAt
if status == m.CouponUserStatusUsed && usedAt == nil {
usedAt = &now
}
if status == m.CouponUserStatusUnused {
usedAt = nil
}
err = tx.CouponUser.Create(&m.CouponUser{
UserID: data.UserID,
CouponID: data.CouponID,
Status: status,
ExpireAt: expireAt,
UsedAt: usedAt,
})
if err != nil {
return core.NewServErr("发放优惠券失败", err)
}
return adjustCouponCount(tx, coupon.ID, -1)
})
}
type CreateCouponUserData struct {
CouponID int32 `json:"coupon_id" validate:"required"`
UserID int32 `json:"user_id" validate:"required"`
Status *m.CouponUserStatus `json:"status"`
ExpireAt *time.Time `json:"expire_at"`
UsedAt *time.Time `json:"used_at"`
}
func (s *couponUserService) Update(data UpdateCouponUserData) error {
return q.Q.Transaction(func(tx *q.Query) error {
current, err := tx.CouponUser.Where(tx.CouponUser.ID.Eq(data.ID)).Take()
if errors.Is(err, gorm.ErrRecordNotFound) {
return core.NewBizErr("已发放优惠券不存在")
}
if err != nil {
return core.NewServErr("获取已发放优惠券失败", err)
}
do := make([]field.AssignExpr, 0)
if data.ExpireAtClear != nil && *data.ExpireAtClear {
do = append(do, tx.CouponUser.ExpireAt.Null())
} else if data.ExpireAt != nil {
do = append(do, tx.CouponUser.ExpireAt.Value(*data.ExpireAt))
}
if data.UsedAtClear != nil && *data.UsedAtClear {
do = append(do, tx.CouponUser.UsedAt.Null())
} else if data.UsedAt != nil {
do = append(do, tx.CouponUser.UsedAt.Value(*data.UsedAt))
}
if data.Status != nil {
if err := validateCouponUserStatus(*data.Status); err != nil {
return err
}
if current.Status != *data.Status {
if current.Status == m.CouponUserStatusUsed {
return core.NewBizErr("已使用的优惠券不能修改状态")
}
if current.Status == m.CouponUserStatusDisabled && *data.Status == m.CouponUserStatusUsed {
return core.NewBizErr("已禁用的优惠券不能标记为已使用")
}
switch *data.Status {
case m.CouponUserStatusUnused:
if current.Status == m.CouponUserStatusDisabled {
if err := adjustCouponCount(tx, current.CouponID, -1); err != nil {
return err
}
}
if data.UsedAt == nil && (data.UsedAtClear == nil || !*data.UsedAtClear) {
do = append(do, tx.CouponUser.UsedAt.Null())
}
case m.CouponUserStatusUsed:
if data.UsedAt == nil && (data.UsedAtClear == nil || !*data.UsedAtClear) {
do = append(do, tx.CouponUser.UsedAt.Value(time.Now()))
}
case m.CouponUserStatusDisabled:
if current.Status == m.CouponUserStatusUnused {
if err := adjustCouponCount(tx, current.CouponID, 1); err != nil {
return err
}
}
}
do = append(do, tx.CouponUser.Status.Value(int(*data.Status)))
}
}
if len(do) == 0 {
return nil
}
result, err := tx.CouponUser.
Where(
tx.CouponUser.ID.Eq(data.ID),
tx.CouponUser.Status.Eq(int(current.Status)),
).
UpdateSimple(do...)
if err != nil {
return core.NewServErr("更新已发放优惠券失败", err)
}
if result.RowsAffected == 0 {
return core.NewBizErr("已发放优惠券状态已变化,请重试")
}
return nil
})
}
type UpdateCouponUserData struct {
ID int32 `json:"id" validate:"required"`
Status *m.CouponUserStatus `json:"status"`
ExpireAt *time.Time `json:"expire_at"`
ExpireAtClear *bool `json:"expire_at_clear"`
UsedAt *time.Time `json:"used_at"`
UsedAtClear *bool `json:"used_at_clear"`
}
func (s *couponUserService) Delete(id int32) error {
status := m.CouponUserStatusDisabled
return s.Update(UpdateCouponUserData{
ID: id,
Status: &status,
})
}
func (s *couponUserService) DeleteOfUser(id int32, userID int32) error {
assigned, err := q.CouponUser.Where(
q.CouponUser.ID.Eq(id),
q.CouponUser.UserID.Eq(userID),
).Take()
if errors.Is(err, gorm.ErrRecordNotFound) {
return core.NewBizErr("已发放优惠券不存在")
}
if err != nil {
return core.NewServErr("获取已发放优惠券失败", err)
}
if assigned.Status != m.CouponUserStatusUnused {
return core.NewBizErr("只能撤销未使用的优惠券")
}
return s.Delete(id)
}
func couponUserExpireAt(coupon *m.Coupon, now time.Time) *time.Time {
if coupon == nil {
return nil
}
switch coupon.ExpireType {
case m.CouponExpireTypeFixed:
return coupon.ExpireAt
case m.CouponExpireTypeRelative:
if coupon.ExpireIn == nil {
return nil
}
expireAt := now.Add(time.Duration(*coupon.ExpireIn) * 24 * time.Hour)
return &expireAt
default:
return nil
}
}
func validateCouponUserStatus(status m.CouponUserStatus) error {
switch status {
case m.CouponUserStatusUnused, m.CouponUserStatusUsed, m.CouponUserStatusDisabled:
return nil
default:
return core.NewBizErr("优惠券发放状态无效")
}
}
func adjustCouponCount(tx *q.Query, couponID int32, delta int32) error {
coupon, err := tx.Coupon.Where(tx.Coupon.ID.Eq(couponID)).Take()
if errors.Is(err, gorm.ErrRecordNotFound) {
return core.NewBizErr("优惠券不存在")
}
if err != nil {
return core.NewServErr("获取优惠券数据失败", err)
}
next := coupon.Count + delta
if next < 0 {
return core.NewBizErr("优惠券已发放完")
}
result, err := tx.Coupon.
Where(tx.Coupon.ID.Eq(couponID), tx.Coupon.Count_.Eq(coupon.Count)).
UpdateSimple(tx.Coupon.Count_.Value(next))
if err != nil {
return core.NewServErr("更新优惠券数量失败", err)
}
if result.RowsAffected == 0 {
return core.NewBizErr("优惠券库存已变化,请重试")
}
return nil
}