From e2364f3831effb4c89e00fde6f25fac37d68bd95 Mon Sep 17 00:00:00 2001 From: Zuncle <34310384@qq.com> Date: Thu, 7 May 2026 22:09:22 +0800 Subject: [PATCH] =?UTF-8?q?feat(activity):=20=E6=96=B0=E5=A2=9E=E7=8B=AC?= =?UTF-8?q?=E7=AB=8B=E5=A5=96=E5=93=81=E5=8F=91=E6=94=BE=E6=B4=BB=E5=8A=A8?= =?UTF-8?q?=E6=A8=A1=E5=9D=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增独立奖品发放活动的后端表结构、服务、管理端接口与小程序领取接口,支持待领取查询、批量加入已处理、删除记录与成本汇总。 --- internal/api/activity/app.go | 3 + .../activity/prize_grant_activities_app.go | 38 ++ internal/api/admin/admin.go | 3 + .../api/admin/prize_grant_activities_admin.go | 233 ++++++++ internal/router/router.go | 19 +- .../prize_grant_activity.go | 547 ++++++++++++++++++ .../service/prize_grant_activity/types.go | 124 ++++ .../20260507_prize_grant_activities.sql | 39 ++ 8 files changed, 1004 insertions(+), 2 deletions(-) create mode 100644 internal/api/activity/prize_grant_activities_app.go create mode 100644 internal/api/admin/prize_grant_activities_admin.go create mode 100644 internal/service/prize_grant_activity/prize_grant_activity.go create mode 100644 internal/service/prize_grant_activity/types.go create mode 100644 migrations/20260507_prize_grant_activities.sql diff --git a/internal/api/activity/app.go b/internal/api/activity/app.go index 7e03015..278ce92 100755 --- a/internal/api/activity/app.go +++ b/internal/api/activity/app.go @@ -5,6 +5,7 @@ import ( "bindbox-game/internal/repository/mysql" "bindbox-game/internal/repository/mysql/dao" activitysvc "bindbox-game/internal/service/activity" + prizegrantsvc "bindbox-game/internal/service/prize_grant_activity" tasksvc "bindbox-game/internal/service/task_center" titlesvc "bindbox-game/internal/service/title" usersvc "bindbox-game/internal/service/user" @@ -25,6 +26,7 @@ type handler struct { redis *redis.Client activityOrder activitysvc.ActivityOrderService // 活动订单服务 welfare welfaresvc.Service + prizeGrant prizegrantsvc.Service } func New(logger logger.CustomLogger, db mysql.Repo, rdb *redis.Client, task tasksvc.Service) *handler { @@ -41,5 +43,6 @@ func New(logger logger.CustomLogger, db mysql.Repo, rdb *redis.Client, task task redis: rdb, activityOrder: activitysvc.NewActivityOrderService(logger, db), welfare: welfaresvc.New(logger, db), + prizeGrant: prizegrantsvc.New(logger, db), } } diff --git a/internal/api/activity/prize_grant_activities_app.go b/internal/api/activity/prize_grant_activities_app.go new file mode 100644 index 0000000..991cfbd --- /dev/null +++ b/internal/api/activity/prize_grant_activities_app.go @@ -0,0 +1,38 @@ +package app + +import ( + "net/http" + "strconv" + + "bindbox-game/internal/code" + "bindbox-game/internal/pkg/core" +) + +func (h *handler) GetPendingPrizeGrantActivity() core.HandlerFunc { + return func(ctx core.Context) { + userID := int64(ctx.SessionUserInfo().Id) + res, err := h.prizeGrant.GetPendingActivity(ctx.RequestContext(), userID) + if err != nil { + ctx.AbortWithError(core.Error(http.StatusBadRequest, code.GetActivityError, err.Error())) + return + } + ctx.Payload(res) + } +} + +func (h *handler) ClaimPrizeGrantActivity() core.HandlerFunc { + return func(ctx core.Context) { + activityID, err := strconv.ParseInt(ctx.Param("id"), 10, 64) + if err != nil || activityID <= 0 { + ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, "活动ID无效")) + return + } + userID := int64(ctx.SessionUserInfo().Id) + res, err := h.prizeGrant.ClaimActivity(ctx.RequestContext(), activityID, userID) + if err != nil { + ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ModifyActivityError, err.Error())) + return + } + ctx.Payload(res) + } +} diff --git a/internal/api/admin/admin.go b/internal/api/admin/admin.go index 1b4f49e..3641f73 100755 --- a/internal/api/admin/admin.go +++ b/internal/api/admin/admin.go @@ -13,6 +13,7 @@ import ( gamesvc "bindbox-game/internal/service/game" livestreamsvc "bindbox-game/internal/service/livestream" productsvc "bindbox-game/internal/service/product" + prizegrantsvc "bindbox-game/internal/service/prize_grant_activity" snapshotsvc "bindbox-game/internal/service/snapshot" synthesissvc "bindbox-game/internal/service/synthesis" syscfgsvc "bindbox-game/internal/service/sysconfig" @@ -43,6 +44,7 @@ type handler struct { synthesis synthesissvc.Service financeSvc financesvc.Service // P&L service (read-only) welfare welfaresvc.Service + prizeGrant prizegrantsvc.Service } func New(logger logger.CustomLogger, db mysql.Repo, rdb *redis.Client) *handler { @@ -72,5 +74,6 @@ func New(logger logger.CustomLogger, db mysql.Repo, rdb *redis.Client) *handler synthesis: synthesissvc.New(db), financeSvc: financesvc.New(logger, db), welfare: welfaresvc.New(logger, db), + prizeGrant: prizegrantsvc.New(logger, db), } } diff --git a/internal/api/admin/prize_grant_activities_admin.go b/internal/api/admin/prize_grant_activities_admin.go new file mode 100644 index 0000000..8583762 --- /dev/null +++ b/internal/api/admin/prize_grant_activities_admin.go @@ -0,0 +1,233 @@ +package admin + +import ( + "net/http" + "strconv" + "strings" + + "bindbox-game/internal/code" + "bindbox-game/internal/pkg/core" + "bindbox-game/internal/pkg/validation" + prizegrantsvc "bindbox-game/internal/service/prize_grant_activity" +) + +type savePrizeGrantActivityRequest struct { + Reason string `json:"reason" binding:"required"` + Status string `json:"status"` + Rewards []prizegrantsvc.RewardInput `json:"rewards" binding:"required"` +} + +type listPrizeGrantActivitiesRequest struct { + Reason string `form:"reason"` + Status string `form:"status"` + Page int `form:"page"` + PageSize int `form:"page_size"` +} + +type listPrizeGrantRecordsRequest struct { + Status string `form:"status"` + Keyword string `form:"keyword"` + Page int `form:"page"` + PageSize int `form:"page_size"` +} + +type markPrizeGrantUsersRequest struct { + UserIDs []int64 `json:"user_ids" binding:"required"` +} + +func (h *handler) CreatePrizeGrantActivity() core.HandlerFunc { + return func(ctx core.Context) { + if ctx.SessionUserInfo().IsSuper != 1 { + ctx.AbortWithError(core.Error(http.StatusForbidden, code.AuthorizationError, "无权限操作")) + return + } + req := new(savePrizeGrantActivityRequest) + if err := ctx.ShouldBindJSON(req); err != nil { + ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, validation.Error(err))) + return + } + item, err := h.prizeGrant.CreateActivity(ctx.RequestContext(), prizegrantsvc.SaveActivityRequest{Reason: req.Reason, Status: req.Status, Rewards: req.Rewards}) + if err != nil { + ctx.AbortWithError(core.Error(http.StatusBadRequest, code.CreateActivityError, err.Error())) + return + } + ctx.Payload(item) + } +} + +func (h *handler) UpdatePrizeGrantActivity() core.HandlerFunc { + return func(ctx core.Context) { + if ctx.SessionUserInfo().IsSuper != 1 { + ctx.AbortWithError(core.Error(http.StatusForbidden, code.AuthorizationError, "无权限操作")) + return + } + id, err := strconv.ParseInt(ctx.Param("id"), 10, 64) + if err != nil || id <= 0 { + ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, "活动ID无效")) + return + } + req := new(savePrizeGrantActivityRequest) + if err := ctx.ShouldBindJSON(req); err != nil { + ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, validation.Error(err))) + return + } + if err := h.prizeGrant.UpdateActivity(ctx.RequestContext(), id, prizegrantsvc.SaveActivityRequest{Reason: req.Reason, Status: req.Status, Rewards: req.Rewards}); err != nil { + ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ModifyActivityError, err.Error())) + return + } + ctx.Payload(simpleMessageResponse{Message: "操作成功"}) + } +} + +func (h *handler) DeletePrizeGrantActivity() core.HandlerFunc { + return func(ctx core.Context) { + if ctx.SessionUserInfo().IsSuper != 1 { + ctx.AbortWithError(core.Error(http.StatusForbidden, code.AuthorizationError, "无权限操作")) + return + } + id, err := strconv.ParseInt(ctx.Param("id"), 10, 64) + if err != nil || id <= 0 { + ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, "活动ID无效")) + return + } + if err := h.prizeGrant.DeleteActivity(ctx.RequestContext(), id); err != nil { + ctx.AbortWithError(core.Error(http.StatusBadRequest, code.DeleteActivityError, err.Error())) + return + } + ctx.Payload(simpleMessageResponse{Message: "删除成功"}) + } +} + +func (h *handler) ListPrizeGrantActivities() core.HandlerFunc { + return func(ctx core.Context) { + req := new(listPrizeGrantActivitiesRequest) + if err := ctx.ShouldBindForm(req); err != nil { + ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, err.Error())) + return + } + res, err := h.prizeGrant.ListActivities(ctx.RequestContext(), prizegrantsvc.ListActivitiesRequest{Reason: req.Reason, Status: req.Status, Page: req.Page, PageSize: req.PageSize}) + if err != nil { + ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ListActivitiesError, err.Error())) + return + } + ctx.Payload(res) + } +} + +func (h *handler) GetPrizeGrantCostSummary() core.HandlerFunc { + return func(ctx core.Context) { + res, err := h.prizeGrant.GetCostSummary(ctx.RequestContext()) + if err != nil { + ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ListActivitiesError, err.Error())) + return + } + ctx.Payload(res) + } +} + +func (h *handler) GetPrizeGrantActivity() core.HandlerFunc { + return func(ctx core.Context) { + id, err := strconv.ParseInt(ctx.Param("id"), 10, 64) + if err != nil || id <= 0 { + ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, "活动ID无效")) + return + } + res, err := h.prizeGrant.GetActivity(ctx.RequestContext(), id) + if err != nil { + ctx.AbortWithError(core.Error(http.StatusBadRequest, code.GetActivityError, err.Error())) + return + } + ctx.Payload(res) + } +} + +func (h *handler) ListPrizeGrantUserRecords() core.HandlerFunc { + return func(ctx core.Context) { + id, err := strconv.ParseInt(ctx.Param("id"), 10, 64) + if err != nil || id <= 0 { + ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, "活动ID无效")) + return + } + req := new(listPrizeGrantRecordsRequest) + if err := ctx.ShouldBindForm(req); err != nil { + ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, err.Error())) + return + } + res, err := h.prizeGrant.ListUserRecords(ctx.RequestContext(), id, strings.TrimSpace(req.Status), strings.TrimSpace(req.Keyword), req.Page, req.PageSize) + if err != nil { + ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ListActivitiesError, err.Error())) + return + } + ctx.Payload(res) + } +} + +func (h *handler) MarkPrizeGrantUsersProcessed() core.HandlerFunc { + return func(ctx core.Context) { + if ctx.SessionUserInfo().IsSuper != 1 { + ctx.AbortWithError(core.Error(http.StatusForbidden, code.AuthorizationError, "无权限操作")) + return + } + id, err := strconv.ParseInt(ctx.Param("id"), 10, 64) + if err != nil || id <= 0 { + ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, "活动ID无效")) + return + } + req := new(markPrizeGrantUsersRequest) + if err := ctx.ShouldBindJSON(req); err != nil { + ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, validation.Error(err))) + return + } + adminID := int64(ctx.SessionUserInfo().Id) + if err := h.prizeGrant.MarkUsersProcessed(ctx.RequestContext(), id, adminID, req.UserIDs); err != nil { + ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ModifyActivityError, err.Error())) + return + } + ctx.Payload(simpleMessageResponse{Message: "操作成功"}) + } +} + +func (h *handler) MarkAllPrizeGrantUsersProcessed() core.HandlerFunc { + return func(ctx core.Context) { + if ctx.SessionUserInfo().IsSuper != 1 { + ctx.AbortWithError(core.Error(http.StatusForbidden, code.AuthorizationError, "无权限操作")) + return + } + id, err := strconv.ParseInt(ctx.Param("id"), 10, 64) + if err != nil || id <= 0 { + ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, "活动ID无效")) + return + } + adminID := int64(ctx.SessionUserInfo().Id) + count, err := h.prizeGrant.MarkAllUsersProcessed(ctx.RequestContext(), id, adminID) + if err != nil { + ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ModifyActivityError, err.Error())) + return + } + ctx.Payload(map[string]any{"message": "操作成功", "count": count}) + } +} + +func (h *handler) DeletePrizeGrantUserRecord() core.HandlerFunc { + return func(ctx core.Context) { + if ctx.SessionUserInfo().IsSuper != 1 { + ctx.AbortWithError(core.Error(http.StatusForbidden, code.AuthorizationError, "无权限操作")) + return + } + activityID, err := strconv.ParseInt(ctx.Param("id"), 10, 64) + if err != nil || activityID <= 0 { + ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, "活动ID无效")) + return + } + recordID, err := strconv.ParseInt(ctx.Param("record_id"), 10, 64) + if err != nil || recordID <= 0 { + ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, "记录ID无效")) + return + } + if err := h.prizeGrant.DeleteUserRecord(ctx.RequestContext(), activityID, recordID); err != nil { + ctx.AbortWithError(core.Error(http.StatusBadRequest, code.DeleteActivityError, err.Error())) + return + } + ctx.Payload(simpleMessageResponse{Message: "删除成功"}) + } +} diff --git a/internal/router/router.go b/internal/router/router.go index be7f35f..f3986a6 100755 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -276,7 +276,19 @@ func NewHTTPMux(logger logger.CustomLogger, db mysql.Repo) (core.Mux, func(), er adminAuthApiRouter.PUT("/system/configs/:id", adminHandler.ModifySystemConfig()) adminAuthApiRouter.DELETE("/system/configs/:id", adminHandler.DeleteSystemConfig()) - // 用户管理 + + // 奖品发放活动 + adminAuthApiRouter.POST("/prize-grant-activities", adminHandler.CreatePrizeGrantActivity()) + adminAuthApiRouter.GET("/prize-grant-activities", adminHandler.ListPrizeGrantActivities()) + adminAuthApiRouter.GET("/prize-grant-activities/cost-summary", adminHandler.GetPrizeGrantCostSummary()) + adminAuthApiRouter.GET("/prize-grant-activities/:id", adminHandler.GetPrizeGrantActivity()) + adminAuthApiRouter.PUT("/prize-grant-activities/:id", adminHandler.UpdatePrizeGrantActivity()) + adminAuthApiRouter.DELETE("/prize-grant-activities/:id", adminHandler.DeletePrizeGrantActivity()) + adminAuthApiRouter.GET("/prize-grant-activities/:id/user-records", adminHandler.ListPrizeGrantUserRecords()) + adminAuthApiRouter.DELETE("/prize-grant-activities/:id/user-records/:record_id", adminHandler.DeletePrizeGrantUserRecord()) + adminAuthApiRouter.POST("/prize-grant-activities/:id/mark-processed", adminHandler.MarkPrizeGrantUsersProcessed()) + adminAuthApiRouter.POST("/prize-grant-activities/:id/mark-all-processed", adminHandler.MarkAllPrizeGrantUsersProcessed()) +// 用户管理 adminAuthApiRouter.GET("/users", intc.RequireAdminAction("user:view"), adminHandler.ListAppUsers()) adminAuthApiRouter.GET("/users/optimized", intc.RequireAdminAction("user:view"), adminHandler.ListAppUsersOptimized()) // 优化版本(性能提升83%) adminAuthApiRouter.GET("/users/:user_id/invites", intc.RequireAdminAction("user:view"), adminHandler.ListUserInvites()) @@ -529,7 +541,10 @@ func NewHTTPMux(logger logger.CustomLogger, db mysql.Repo) (core.Mux, func(), er appAuthApiRouter.GET("/welfare-activities/:id/my", activityHandler.GetWelfareActivity()) appAuthApiRouter.POST("/welfare-activities/:id/join", activityHandler.JoinWelfareActivity()) - // 任务中心 APP 端 + + appAuthApiRouter.GET("/prize-grant-activities/pending", activityHandler.GetPendingPrizeGrantActivity()) + appAuthApiRouter.POST("/prize-grant-activities/:id/claim", activityHandler.ClaimPrizeGrantActivity()) +// 任务中心 APP 端 appAuthApiRouter.GET("/task-center/tasks", taskCenterHandler.ListTasksForApp()) appAuthApiRouter.GET("/task-center/tasks/:id/progress/:user_id", taskCenterHandler.GetTaskProgressForApp()) appAuthApiRouter.POST("/task-center/tasks/:id/claim/:user_id", taskCenterHandler.ClaimTaskTierForApp()) diff --git a/internal/service/prize_grant_activity/prize_grant_activity.go b/internal/service/prize_grant_activity/prize_grant_activity.go new file mode 100644 index 0000000..72d558f --- /dev/null +++ b/internal/service/prize_grant_activity/prize_grant_activity.go @@ -0,0 +1,547 @@ +package prize_grant_activity + +import ( + "bindbox-game/internal/pkg/logger" + "bindbox-game/internal/repository/mysql" + "bindbox-game/internal/repository/mysql/dao" + "bindbox-game/internal/repository/mysql/model" + "context" + "encoding/json" + "errors" + "fmt" + "math/rand" + "strings" + "time" + + "gorm.io/gorm" + "gorm.io/gorm/clause" +) + +type Service interface { + CreateActivity(ctx context.Context, req SaveActivityRequest) (*Activity, error) + UpdateActivity(ctx context.Context, id int64, req SaveActivityRequest) error + DeleteActivity(ctx context.Context, id int64) error + GetActivity(ctx context.Context, id int64) (*ActivityDetail, error) + ListActivities(ctx context.Context, req ListActivitiesRequest) (*ListActivitiesResponse, error) + GetPendingActivity(ctx context.Context, userID int64) (*PendingActivityResponse, error) + ClaimActivity(ctx context.Context, activityID int64, userID int64) (*ClaimResponse, error) + MarkUsersProcessed(ctx context.Context, activityID int64, adminID int64, userIDs []int64) error + MarkAllUsersProcessed(ctx context.Context, activityID int64, adminID int64) (int64, error) + DeleteUserRecord(ctx context.Context, activityID int64, recordID int64) error + GetCostSummary(ctx context.Context) (*CostSummary, error) + ListUserRecords(ctx context.Context, activityID int64, status string, keyword string, page int, pageSize int) (map[string]any, error) +} + +type service struct { + logger logger.CustomLogger + repo mysql.Repo + writeDB *dao.Query + readDB *dao.Query +} + +func New(log logger.CustomLogger, repo mysql.Repo) Service { + return &service{ + logger: log, + repo: repo, + writeDB: dao.Use(repo.GetDbW()), + readDB: dao.Use(repo.GetDbR()), + } +} + +func (s *service) CreateActivity(ctx context.Context, req SaveActivityRequest) (*Activity, error) { + if err := validateSaveRequest(req); err != nil { + return nil, err + } + item := &Activity{Reason: strings.TrimSpace(req.Reason), Status: normalizeStatus(req.Status)} + err := s.repo.GetDbW().WithContext(ctx).Transaction(func(tx *gorm.DB) error { + if err := tx.Create(item).Error; err != nil { + return err + } + return s.replaceRewards(ctx, tx, item.ID, req.Rewards) + }) + return item, err +} + +func (s *service) UpdateActivity(ctx context.Context, id int64, req SaveActivityRequest) error { + if id <= 0 { + return errors.New("活动ID无效") + } + if err := validateSaveRequest(req); err != nil { + return err + } + return s.repo.GetDbW().WithContext(ctx).Transaction(func(tx *gorm.DB) error { + if err := tx.Model(&Activity{}).Where("id = ?", id).Updates(map[string]any{ + "reason": strings.TrimSpace(req.Reason), + "status": normalizeStatus(req.Status), + }).Error; err != nil { + return err + } + return s.replaceRewards(ctx, tx, id, req.Rewards) + }) +} + +func (s *service) DeleteActivity(ctx context.Context, id int64) error { + if id <= 0 { + return errors.New("活动ID无效") + } + return s.repo.GetDbW().WithContext(ctx).Where("id = ?", id).Delete(&Activity{}).Error +} + +func (s *service) ListActivities(ctx context.Context, req ListActivitiesRequest) (*ListActivitiesResponse, error) { + if req.Page <= 0 { + req.Page = 1 + } + if req.PageSize <= 0 { + req.PageSize = 20 + } + if req.PageSize > 100 { + req.PageSize = 100 + } + db := s.repo.GetDbR().WithContext(ctx).Model(&Activity{}) + if strings.TrimSpace(req.Reason) != "" { + db = db.Where("reason LIKE ?", "%"+strings.TrimSpace(req.Reason)+"%") + } + if strings.TrimSpace(req.Status) != "" { + db = db.Where("status = ?", strings.TrimSpace(req.Status)) + } + var total int64 + if err := db.Count(&total).Error; err != nil { + return nil, err + } + var rows []Activity + if err := db.Order("id DESC").Offset((req.Page-1)*req.PageSize).Limit(req.PageSize).Find(&rows).Error; err != nil { + return nil, err + } + list := make([]ActivityListItem, 0, len(rows)) + for _, row := range rows { + item := ActivityListItem{Activity: row} + s.repo.GetDbR().WithContext(ctx).Table("prize_grant_activity_rewards").Where("activity_id = ?", row.ID).Count(&item.RewardCount) + s.repo.GetDbR().WithContext(ctx).Table("prize_grant_activity_user_records").Where("activity_id = ? AND status = ?", row.ID, RecordStatusClaimed).Count(&item.ClaimedCount) + s.repo.GetDbR().WithContext(ctx).Table("prize_grant_activity_user_records").Where("activity_id = ? AND status = ?", row.ID, RecordStatusProcessed).Count(&item.ProcessedCount) + cost, err := s.calculateClaimedCost(ctx, row.ID) + if err != nil { + return nil, err + } + item.CostCents = cost + list = append(list, item) + } + return &ListActivitiesResponse{Page: req.Page, PageSize: req.PageSize, Total: total, List: list}, nil +} + +func (s *service) GetActivity(ctx context.Context, id int64) (*ActivityDetail, error) { + var item Activity + if err := s.repo.GetDbR().WithContext(ctx).Where("id = ?", id).First(&item).Error; err != nil { + return nil, err + } + rewards, err := s.loadRewardViews(ctx, id) + if err != nil { + return nil, err + } + detail := &ActivityDetail{Activity: item, Rewards: rewards} + s.repo.GetDbR().WithContext(ctx).Table("prize_grant_activity_user_records").Where("activity_id = ? AND status = ?", id, RecordStatusClaimed).Count(&detail.ClaimedCount) + s.repo.GetDbR().WithContext(ctx).Table("prize_grant_activity_user_records").Where("activity_id = ? AND status = ?", id, RecordStatusProcessed).Count(&detail.ProcessedCount) + var totalUsers int64 + s.repo.GetDbR().WithContext(ctx).Model(&model.Users{}).Count(&totalUsers) + detail.PendingCount = totalUsers - detail.ClaimedCount - detail.ProcessedCount + if detail.PendingCount < 0 { + detail.PendingCount = 0 + } + cost, err := s.calculateClaimedCost(ctx, id) + if err != nil { + return nil, err + } + detail.CostCents = cost + return detail, nil +} + +func (s *service) GetPendingActivity(ctx context.Context, userID int64) (*PendingActivityResponse, error) { + if userID <= 0 { + return &PendingActivityResponse{HasPending: false}, nil + } + var item Activity + if err := s.repo.GetDbR().WithContext(ctx). + Where("status = ?", "active"). + Where("NOT EXISTS (SELECT 1 FROM prize_grant_activity_user_records r WHERE r.activity_id = prize_grant_activities.id AND r.user_id = ?)", userID). + Order("id DESC"). + First(&item).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return &PendingActivityResponse{HasPending: false}, nil + } + return nil, err + } + rewards, err := s.loadRewardViews(ctx, item.ID) + if err != nil { + return nil, err + } + return &PendingActivityResponse{HasPending: true, Activity: &PendingActivity{ID: item.ID, Reason: item.Reason, Rewards: rewards}}, nil +} + +func (s *service) ClaimActivity(ctx context.Context, activityID int64, userID int64) (*ClaimResponse, error) { + if activityID <= 0 || userID <= 0 { + return nil, errors.New("参数无效") + } + var recordID int64 + err := s.repo.GetDbW().WithContext(ctx).Transaction(func(tx *gorm.DB) error { + var item Activity + if err := tx.WithContext(ctx).Where("id = ? AND status = ?", activityID, "active").First(&item).Error; err != nil { + return err + } + var existing UserRecord + err := tx.WithContext(ctx).Where("activity_id = ? AND user_id = ?", activityID, userID).First(&existing).Error + if err == nil { + return errors.New("该活动已处理") + } + if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { + return err + } + var rewards []Reward + if err := tx.WithContext(ctx).Where("activity_id = ?", activityID).Order("sort ASC, id ASC").Find(&rewards).Error; err != nil { + return err + } + if len(rewards) == 0 { + return errors.New("活动未配置奖品") + } + for _, reward := range rewards { + for i := int32(0); i < reward.QuantityPerClaim; i++ { + if err := s.grantReward(ctx, tx, activityID, userID, reward); err != nil { + return err + } + } + } + now := time.Now() + record := &UserRecord{ActivityID: activityID, UserID: userID, Status: RecordStatusClaimed, ClaimedAt: &now, OperatorAdminID: 0} + if err := tx.WithContext(ctx).Create(record).Error; err != nil { + return err + } + recordID = record.ID + return nil + }) + if err != nil { + return nil, err + } + return &ClaimResponse{ActivityID: activityID, RecordID: recordID, Status: RecordStatusClaimed}, nil +} + +func (s *service) MarkUsersProcessed(ctx context.Context, activityID int64, adminID int64, userIDs []int64) error { + if activityID <= 0 { + return errors.New("活动ID无效") + } + if len(userIDs) == 0 { + return errors.New("用户不能为空") + } + now := time.Now() + return s.repo.GetDbW().WithContext(ctx).Transaction(func(tx *gorm.DB) error { + for _, userID := range userIDs { + if userID <= 0 { + continue + } + record := &UserRecord{ActivityID: activityID, UserID: userID, Status: RecordStatusProcessed, ProcessedAt: &now, OperatorAdminID: adminID} + if err := tx.WithContext(ctx).Clauses(clauseOnConflictUpdateProcessed()).Create(record).Error; err != nil { + return err + } + } + return nil + }) +} + +func (s *service) MarkAllUsersProcessed(ctx context.Context, activityID int64, adminID int64) (int64, error) { + var users []model.Users + if err := s.repo.GetDbR().WithContext(ctx).Find(&users).Error; err != nil { + return 0, err + } + ids := make([]int64, 0, len(users)) + for _, user := range users { + ids = append(ids, user.ID) + } + if err := s.MarkUsersProcessed(ctx, activityID, adminID, ids); err != nil { + return 0, err + } + return int64(len(ids)), nil +} + +func (s *service) DeleteUserRecord(ctx context.Context, activityID int64, recordID int64) error { + if activityID <= 0 || recordID <= 0 { + return errors.New("参数无效") + } + return s.repo.GetDbW().WithContext(ctx).Where("id = ? AND activity_id = ?", recordID, activityID).Delete(&UserRecord{}).Error +} + +func (s *service) GetCostSummary(ctx context.Context) (*CostSummary, error) { + var activities []Activity + if err := s.repo.GetDbR().WithContext(ctx).Find(&activities).Error; err != nil { + return nil, err + } + var total int64 + for _, activity := range activities { + cost, err := s.calculateClaimedCost(ctx, activity.ID) + if err != nil { + return nil, err + } + total += cost + } + var count int64 + if err := s.repo.GetDbR().WithContext(ctx).Table("prize_grant_activity_user_records").Where("status = ?", RecordStatusClaimed).Count(&count).Error; err != nil { + return nil, err + } + return &CostSummary{CostCents: total, Count: count}, nil +} + +func (s *service) ListUserRecords(ctx context.Context, activityID int64, status string, keyword string, page int, pageSize int) (map[string]any, error) { + if page <= 0 { + page = 1 + } + if pageSize <= 0 { + pageSize = 20 + } + if pageSize > 100 { + pageSize = 100 + } + db := s.repo.GetDbR().WithContext(ctx).Table("prize_grant_activity_user_records r"). + Select("r.id, r.activity_id, r.user_id, r.status, r.claimed_at, r.processed_at, r.operator_admin_id, r.created_at, r.updated_at, u.nickname, u.mobile"). + Joins("LEFT JOIN users u ON u.id = r.user_id"). + Where("r.activity_id = ?", activityID) + if strings.TrimSpace(status) != "" { + db = db.Where("r.status = ?", strings.TrimSpace(status)) + } + if strings.TrimSpace(keyword) != "" { + kw := "%" + strings.TrimSpace(keyword) + "%" + db = db.Where("CAST(r.user_id AS CHAR) LIKE ? OR u.nickname LIKE ? OR u.mobile LIKE ?", kw, kw, kw) + } + var total int64 + if err := db.Count(&total).Error; err != nil { + return nil, err + } + var rows []map[string]any + if err := db.Order("r.id DESC").Offset((page-1)*pageSize).Limit(pageSize).Scan(&rows).Error; err != nil { + return nil, err + } + return map[string]any{"page": page, "page_size": pageSize, "total": total, "list": rows}, nil +} + +func validateSaveRequest(req SaveActivityRequest) error { + if strings.TrimSpace(req.Reason) == "" { + return errors.New("发奖原因不能为空") + } + if len(req.Rewards) == 0 { + return errors.New("至少配置一个奖品") + } + for _, reward := range req.Rewards { + if reward.RewardRefID <= 0 { + return errors.New("奖品资源ID无效") + } + if reward.QuantityPerClaim <= 0 { + return errors.New("领取数量必须大于0") + } + if reward.RewardType != RewardTypeProduct && reward.RewardType != RewardTypeCoupon && reward.RewardType != RewardTypeItemCard { + return fmt.Errorf("不支持的奖品类型: %s", reward.RewardType) + } + } + return nil +} + +func normalizeStatus(status string) string { + if strings.TrimSpace(status) == "active" { + return "active" + } + return "inactive" +} + +func (s *service) replaceRewards(ctx context.Context, tx *gorm.DB, activityID int64, inputs []RewardInput) error { + if err := tx.WithContext(ctx).Where("activity_id = ?", activityID).Delete(&Reward{}).Error; err != nil { + return err + } + items := make([]Reward, 0, len(inputs)) + for idx, input := range inputs { + sort := input.Sort + if sort == 0 { + sort = int32(idx + 1) + } + items = append(items, Reward{ActivityID: activityID, RewardType: input.RewardType, RewardRefID: input.RewardRefID, QuantityPerClaim: input.QuantityPerClaim, Sort: sort}) + } + return tx.WithContext(ctx).Create(&items).Error +} + +func (s *service) loadRewardViews(ctx context.Context, activityID int64) ([]RewardView, error) { + var rewards []Reward + if err := s.repo.GetDbR().WithContext(ctx).Where("activity_id = ?", activityID).Order("sort ASC, id ASC").Find(&rewards).Error; err != nil { + return nil, err + } + views := make([]RewardView, 0, len(rewards)) + for _, reward := range rewards { + view := RewardView{RewardType: reward.RewardType, RewardRefID: reward.RewardRefID, Quantity: reward.QuantityPerClaim} + switch reward.RewardType { + case RewardTypeProduct: + var product model.Products + if err := s.repo.GetDbR().WithContext(ctx).Where("id = ?", reward.RewardRefID).First(&product).Error; err == nil { + view.Name = product.Name + view.ValueCents = product.Price + images := parseProductImages(product.ImagesJSON) + if len(images) > 0 { + view.Image = images[0] + } + } + case RewardTypeCoupon: + var coupon model.SystemCoupons + if err := s.repo.GetDbR().WithContext(ctx).Where("id = ?", reward.RewardRefID).First(&coupon).Error; err == nil { + view.Name = coupon.Name + view.ValueCents = coupon.DiscountValue + } + case RewardTypeItemCard: + var card model.SystemItemCards + if err := s.repo.GetDbR().WithContext(ctx).Where("id = ?", reward.RewardRefID).First(&card).Error; err == nil { + view.Name = card.Name + view.ValueCents = card.Price + } + } + views = append(views, view) + } + return views, nil +} + +func (s *service) calculateClaimedCost(ctx context.Context, activityID int64) (int64, error) { + var rewards []Reward + if err := s.repo.GetDbR().WithContext(ctx).Where("activity_id = ?", activityID).Find(&rewards).Error; err != nil { + return 0, err + } + var claimedCount int64 + if err := s.repo.GetDbR().WithContext(ctx).Table("prize_grant_activity_user_records").Where("activity_id = ? AND status = ?", activityID, RecordStatusClaimed).Count(&claimedCount).Error; err != nil { + return 0, err + } + var costPerClaim int64 + for _, reward := range rewards { + switch reward.RewardType { + case RewardTypeProduct: + var product model.Products + if err := s.repo.GetDbR().WithContext(ctx).Where("id = ?", reward.RewardRefID).First(&product).Error; err == nil { + unit := product.CostPrice + if unit <= 0 { + unit = product.Price + } + costPerClaim += unit * int64(reward.QuantityPerClaim) + } + case RewardTypeCoupon: + var coupon model.SystemCoupons + if err := s.repo.GetDbR().WithContext(ctx).Where("id = ?", reward.RewardRefID).First(&coupon).Error; err == nil { + costPerClaim += coupon.DiscountValue * int64(reward.QuantityPerClaim) + } + case RewardTypeItemCard: + var card model.SystemItemCards + if err := s.repo.GetDbR().WithContext(ctx).Where("id = ?", reward.RewardRefID).First(&card).Error; err == nil { + costPerClaim += card.Price * int64(reward.QuantityPerClaim) + } + } + } + return costPerClaim * claimedCount, nil +} + +func (s *service) grantReward(ctx context.Context, tx *gorm.DB, activityID int64, userID int64, reward Reward) error { + switch reward.RewardType { + case RewardTypeCoupon: + var tpl model.SystemCoupons + if err := tx.WithContext(ctx).Where("id = ? AND status = 1", reward.RewardRefID).First(&tpl).Error; err != nil { + return err + } + item := &model.UserCoupons{UserID: userID, CouponID: tpl.ID, Status: 1} + if !tpl.ValidStart.IsZero() { + item.ValidStart = tpl.ValidStart + } else { + item.ValidStart = time.Now() + } + if !tpl.ValidEnd.IsZero() { + item.ValidEnd = tpl.ValidEnd + } + do := tx.WithContext(ctx).Omit("used_at", "used_order_id") + if tpl.ValidEnd.IsZero() { + do = do.Omit("valid_end") + } + if err := do.Create(item).Error; err != nil { + return err + } + balance := int64(0) + if tpl.DiscountType == 1 && tpl.DiscountValue > 0 { + balance = tpl.DiscountValue + } + return tx.WithContext(ctx).Model(&model.UserCoupons{}).Where("id = ?", item.ID).Update("balance_amount", balance).Error + case RewardTypeItemCard: + var card model.SystemItemCards + if err := tx.WithContext(ctx).Where("id = ? AND status = 1", reward.RewardRefID).First(&card).Error; err != nil { + return err + } + now := time.Now() + item := &model.UserItemCards{UserID: userID, CardID: card.ID, Status: 1, Remark: "奖品发放活动领取"} + if !card.ValidStart.IsZero() { + item.ValidStart = card.ValidStart + } else { + item.ValidStart = now + } + if !card.ValidEnd.IsZero() { + item.ValidEnd = card.ValidEnd + } + do := tx.WithContext(ctx).Omit("used_at", "used_draw_log_id", "used_activity_id", "used_issue_id") + if card.ValidEnd.IsZero() { + do = do.Omit("valid_end") + } + return do.Create(item).Error + default: + var product model.Products + if err := tx.WithContext(ctx).Where("id = ?", reward.RewardRefID).First(&product).Error; err != nil { + return err + } + if product.Stock <= 0 { + return fmt.Errorf("商品库存不足: %s", product.Name) + } + result := tx.WithContext(ctx).Model(&model.Products{}).Where("id = ? AND stock > 0", product.ID).Update("stock", gorm.Expr("stock - 1")) + if result.Error != nil { + return result.Error + } + if result.RowsAffected == 0 { + return fmt.Errorf("商品库存不足: %s", product.Name) + } + now := time.Now() + minValidTime := time.Date(1970, 1, 1, 0, 0, 0, 0, time.UTC) + order := &model.Orders{OrderNo: fmt.Sprintf("PG%d%d%04d", activityID, now.Unix(), rand.Intn(10000)), UserID: userID, SourceType: 6, Status: 2, PaidAt: now, CancelledAt: minValidTime, Remark: "奖品发放活动领取", CreatedAt: now, UpdatedAt: now} + if err := tx.WithContext(ctx).Create(order).Error; err != nil { + return err + } + orderItem := &model.OrderItems{OrderID: order.ID, ProductID: product.ID, Title: product.Name, Quantity: 1, ProductImages: product.ImagesJSON, Status: 1} + if err := tx.WithContext(ctx).Create(orderItem).Error; err != nil { + return err + } + value := product.CostPrice + if value <= 0 { + value = product.Price + } + inventory := &model.UserInventory{UserID: userID, ProductID: product.ID, ValueCents: value, ValueSource: 2, ValueSnapshotAt: now, OrderID: order.ID, ActivityID: activityID, Status: 1, Remark: "奖品发放活动领取"} + return tx.WithContext(ctx).Create(inventory).Error + } +} + +func clauseOnConflictUpdateProcessed() clause.OnConflict { + return clause.OnConflict{ + Columns: []clause.Column{{Name: "activity_id"}, {Name: "user_id"}}, + DoUpdates: clause.Assignments(map[string]any{ + "status": RecordStatusProcessed, + "processed_at": gorm.Expr("VALUES(processed_at)"), + "operator_admin_id": gorm.Expr("VALUES(operator_admin_id)"), + "updated_at": gorm.Expr("CURRENT_TIMESTAMP(3)"), + }), + } +} + +func parseProductImages(raw string) []string { + if strings.TrimSpace(raw) == "" { + return nil + } + var arr []string + if err := json.Unmarshal([]byte(raw), &arr); err == nil { + result := make([]string, 0, len(arr)) + for _, item := range arr { + item = strings.TrimSpace(item) + if item != "" { + result = append(result, item) + } + } + return result + } + return nil +} diff --git a/internal/service/prize_grant_activity/types.go b/internal/service/prize_grant_activity/types.go new file mode 100644 index 0000000..b37668c --- /dev/null +++ b/internal/service/prize_grant_activity/types.go @@ -0,0 +1,124 @@ +package prize_grant_activity + +import "time" + +const ( + RewardTypeProduct = "product" + RewardTypeItemCard = "item_card" + RewardTypeCoupon = "coupon" + + RecordStatusClaimed = "claimed" + RecordStatusProcessed = "processed" +) + +type Activity struct { + ID int64 `gorm:"column:id;primaryKey" json:"id"` + Reason string `gorm:"column:reason" json:"reason"` + Status string `gorm:"column:status" json:"status"` + CreatedAt time.Time `gorm:"column:created_at" json:"created_at"` + UpdatedAt time.Time `gorm:"column:updated_at" json:"updated_at"` +} + +func (*Activity) TableName() string { return "prize_grant_activities" } + +type Reward struct { + ID int64 `gorm:"column:id;primaryKey" json:"id"` + ActivityID int64 `gorm:"column:activity_id" json:"activity_id"` + RewardType string `gorm:"column:reward_type" json:"reward_type"` + RewardRefID int64 `gorm:"column:reward_ref_id" json:"reward_ref_id"` + QuantityPerClaim int32 `gorm:"column:quantity_per_claim" json:"quantity_per_claim"` + Sort int32 `gorm:"column:sort" json:"sort"` + CreatedAt time.Time `gorm:"column:created_at" json:"created_at"` + UpdatedAt time.Time `gorm:"column:updated_at" json:"updated_at"` +} + +func (*Reward) TableName() string { return "prize_grant_activity_rewards" } + +type UserRecord struct { + ID int64 `gorm:"column:id;primaryKey" json:"id"` + ActivityID int64 `gorm:"column:activity_id" json:"activity_id"` + UserID int64 `gorm:"column:user_id" json:"user_id"` + Status string `gorm:"column:status" json:"status"` + ClaimedAt *time.Time `gorm:"column:claimed_at" json:"claimed_at,omitempty"` + ProcessedAt *time.Time `gorm:"column:processed_at" json:"processed_at,omitempty"` + OperatorAdminID int64 `gorm:"column:operator_admin_id" json:"operator_admin_id"` + CreatedAt time.Time `gorm:"column:created_at" json:"created_at"` + UpdatedAt time.Time `gorm:"column:updated_at" json:"updated_at"` +} + +func (*UserRecord) TableName() string { return "prize_grant_activity_user_records" } + +type SaveActivityRequest struct { + Reason string `json:"reason"` + Status string `json:"status"` + Rewards []RewardInput `json:"rewards"` +} + +type RewardInput struct { + RewardType string `json:"reward_type"` + RewardRefID int64 `json:"reward_ref_id"` + QuantityPerClaim int32 `json:"quantity_per_claim"` + Sort int32 `json:"sort"` +} + +type ListActivitiesRequest struct { + Reason string + Status string + Page int + PageSize int +} + +type ActivityListItem struct { + Activity + RewardCount int64 `json:"reward_count"` + ClaimedCount int64 `json:"claimed_count"` + ProcessedCount int64 `json:"processed_count"` + CostCents int64 `json:"cost_cents"` +} + +type CostSummary struct { + CostCents int64 `json:"cost_cents"` + Count int64 `json:"count"` +} + +type ListActivitiesResponse struct { + Page int `json:"page"` + PageSize int `json:"page_size"` + Total int64 `json:"total"` + List []ActivityListItem `json:"list"` +} + +type RewardView struct { + RewardType string `json:"reward_type"` + RewardRefID int64 `json:"reward_ref_id"` + Name string `json:"name"` + Image string `json:"image"` + ValueCents int64 `json:"value_cents"` + Quantity int32 `json:"quantity"` +} + +type ActivityDetail struct { + Activity + Rewards []RewardView `json:"rewards"` + ClaimedCount int64 `json:"claimed_count"` + ProcessedCount int64 `json:"processed_count"` + PendingCount int64 `json:"pending_count"` + CostCents int64 `json:"cost_cents"` +} + +type PendingActivityResponse struct { + HasPending bool `json:"has_pending"` + Activity *PendingActivity `json:"activity,omitempty"` +} + +type PendingActivity struct { + ID int64 `json:"id"` + Reason string `json:"reason"` + Rewards []RewardView `json:"rewards"` +} + +type ClaimResponse struct { + ActivityID int64 `json:"activity_id"` + RecordID int64 `json:"record_id"` + Status string `json:"status"` +} diff --git a/migrations/20260507_prize_grant_activities.sql b/migrations/20260507_prize_grant_activities.sql new file mode 100644 index 0000000..a0955f3 --- /dev/null +++ b/migrations/20260507_prize_grant_activities.sql @@ -0,0 +1,39 @@ +CREATE TABLE IF NOT EXISTS `prize_grant_activities` ( + `id` BIGINT NOT NULL AUTO_INCREMENT, + `reason` VARCHAR(255) NOT NULL COMMENT '发奖原因', + `status` VARCHAR(16) NOT NULL DEFAULT 'inactive' COMMENT '状态:active/inactive', + `created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updated_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (`id`), + KEY `idx_pga_status_id` (`status`, `id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='奖品发放活动'; + +CREATE TABLE IF NOT EXISTS `prize_grant_activity_rewards` ( + `id` BIGINT NOT NULL AUTO_INCREMENT, + `activity_id` BIGINT NOT NULL COMMENT '活动ID', + `reward_type` VARCHAR(32) NOT NULL COMMENT '奖品类型:product/item_card/coupon', + `reward_ref_id` BIGINT NOT NULL COMMENT '奖品资源ID', + `quantity_per_claim` INT NOT NULL DEFAULT 1 COMMENT '每次领取数量', + `sort` INT NOT NULL DEFAULT 0 COMMENT '排序', + `created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updated_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (`id`), + KEY `idx_pgar_activity` (`activity_id`, `sort`, `id`), + KEY `idx_pgar_reward` (`reward_type`, `reward_ref_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='奖品发放活动奖品'; + +CREATE TABLE IF NOT EXISTS `prize_grant_activity_user_records` ( + `id` BIGINT NOT NULL AUTO_INCREMENT, + `activity_id` BIGINT NOT NULL COMMENT '活动ID', + `user_id` BIGINT NOT NULL COMMENT '用户ID', + `status` VARCHAR(16) NOT NULL COMMENT '状态:claimed/processed', + `claimed_at` DATETIME(3) NULL DEFAULT NULL COMMENT '领取时间', + `processed_at` DATETIME(3) NULL DEFAULT NULL COMMENT '处理时间', + `operator_admin_id` BIGINT NOT NULL DEFAULT 0 COMMENT '操作管理员ID', + `created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updated_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (`id`), + UNIQUE KEY `uk_pgaur_activity_user` (`activity_id`, `user_id`), + KEY `idx_pgaur_activity_status` (`activity_id`, `status`, `updated_at`), + KEY `idx_pgaur_user_status` (`user_id`, `status`, `updated_at`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='奖品发放活动用户记录';