Zuncle e2364f3831 feat(activity): 新增独立奖品发放活动模块
新增独立奖品发放活动的后端表结构、服务、管理端接口与小程序领取接口,支持待领取查询、批量加入已处理、删除记录与成本汇总。
2026-05-07 22:09:22 +08:00

548 lines
19 KiB
Go

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
}