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 }