package threshold_activity import ( "context" "errors" "fmt" "time" ) func (s *service) Join(ctx context.Context, activityID int64, userID int64) error { if activityID <= 0 || userID <= 0 { return errors.New("活动或用户无效") } var activity Activity if err := s.repo.GetDbR().WithContext(ctx).Where("id = ? AND deleted_at IS NULL", activityID).First(&activity).Error; err != nil { return err } now := time.Now() if !isJoinWindowOpen(activity, now) { if now.Before(activity.StartTime) { return errors.New("活动未开始") } return errors.New("当前不在活动参与时间内") } progress, period, err := s.evaluateQualification(ctx, activity, userID, now) if err != nil { return err } if !qualificationSatisfied(activity, progress) { return errors.New(joinQualificationError(activity, progress)) } participant := &Participant{ ActivityID: activityID, UserID: userID, PeriodKey: period, QualificationSource: progress.QualificationSource, PaidAmountSnapshot: progress.CurrentPaid, EffectiveInviteCountSnapshot: progress.EffectiveInviteCount, } return s.repo.GetDbW().WithContext(ctx).Create(participant).Error } func (s *service) ListParticipants(ctx context.Context, activityID int64, page int, pageSize int) (*ParticipantResponse, error) { if page <= 0 { page = 1 } if pageSize <= 0 { pageSize = 20 } if pageSize > 100 { pageSize = 100 } var total int64 db := s.repo.GetDbR().WithContext(ctx).Table("threshold_activity_participants").Where("activity_id = ?", activityID) if err := db.Count(&total).Error; err != nil { return nil, err } var list []ParticipantAvatar err := s.repo.GetDbR().WithContext(ctx).Table("threshold_activity_participants p"). Select("p.user_id, COALESCE(u.nickname, '') AS nickname, COALESCE(u.avatar, '') AS avatar"). Joins("LEFT JOIN users u ON u.id = p.user_id"). Where("p.activity_id = ?", activityID). Order("p.id DESC"). Offset((page - 1) * pageSize). Limit(pageSize). Scan(&list).Error return &ParticipantResponse{Page: page, PageSize: pageSize, Total: total, List: list}, err } func (s *service) ListJoinableActivitiesForUser(ctx context.Context, userID int64) ([]JoinableActivityItem, error) { now := time.Now() var activities []Activity if err := s.repo.GetDbR().WithContext(ctx).Where("deleted_at IS NULL AND status = ? AND start_time <= ? AND end_time > ? AND draw_time > ?", StatusActive, now, now, now).Order("draw_time ASC, id ASC").Find(&activities).Error; err != nil { return nil, err } if len(activities) == 0 { return []JoinableActivityItem{}, nil } items := make([]JoinableActivityItem, 0, len(activities)) for _, activity := range activities { progress, period, err := s.evaluateQualification(ctx, activity, userID, now) if err != nil { return nil, err } var count int64 s.repo.GetDbR().WithContext(ctx).Model(&Participant{}).Where("activity_id = ? AND user_id = ? AND period_key = ?", activity.ID, userID, period).Count(&count) joined := count > 0 canJoin := !joined && qualificationSatisfied(activity, progress) items = append(items, JoinableActivityItem{ ActivityID: activity.ID, Title: activity.Title, Type: activity.Type, QualificationMode: activity.QualificationMode, SpendThresholdAmount: activity.SpendThresholdAmount, InviteThresholdCount: activity.InviteThresholdCount, InviteEffectiveAmount: activity.InviteEffectiveAmount, MinParticipants: activity.MinParticipants, QualificationProgress: progress, CanJoin: canJoin, Joined: joined, StartTime: activity.StartTime, EndTime: activity.EndTime, DrawTime: activity.DrawTime, }) } return items, nil } func (s *service) evaluateQualification(ctx context.Context, activity Activity, userID int64, now time.Time) (QualificationProgress, string, error) { start, end, period := periodRange(activity.Type, now) paid, err := s.sumPaidAmount(ctx, userID, start, end) if err != nil { return QualificationProgress{}, "", err } inviteCount, err := s.countEffectiveInvites(ctx, userID, activity.StartTime, start, end, activity.InviteEffectiveAmount) if err != nil { return QualificationProgress{}, "", err } progress := QualificationProgress{ CurrentPaid: paid, EffectiveInviteCount: inviteCount, SpendQualified: activity.SpendThresholdAmount > 0 && paid >= activity.SpendThresholdAmount, InviteQualified: activity.InviteThresholdCount > 0 && inviteCount >= activity.InviteThresholdCount, } switch { case progress.SpendQualified && progress.InviteQualified: progress.QualificationSource = QualificationSourceBoth case progress.SpendQualified: progress.QualificationSource = QualificationSourceSpend case progress.InviteQualified: progress.QualificationSource = QualificationSourceInvite } return progress, period, nil } func qualificationSatisfied(activity Activity, progress QualificationProgress) bool { switch normalizeQualificationMode(activity.QualificationMode) { case QualificationModeSpendOnly: return progress.SpendQualified case QualificationModeInviteOnly: return progress.InviteQualified default: return progress.SpendQualified || progress.InviteQualified } } func joinQualificationError(activity Activity, progress QualificationProgress) string { switch normalizeQualificationMode(activity.QualificationMode) { case QualificationModeSpendOnly: return fmt.Sprintf("未达到消费门槛,还差%d分", activity.SpendThresholdAmount-progress.CurrentPaid) case QualificationModeInviteOnly: return fmt.Sprintf("未达到有效邀请门槛,还差%d人", activity.InviteThresholdCount-progress.EffectiveInviteCount) default: if activity.SpendThresholdAmount > 0 && progress.CurrentPaid < activity.SpendThresholdAmount { return fmt.Sprintf("未达到参与门槛:消费还差%d分,或邀请还差%d人", activity.SpendThresholdAmount-progress.CurrentPaid, maxInt64(0, activity.InviteThresholdCount-progress.EffectiveInviteCount)) } return fmt.Sprintf("未达到参与门槛:邀请还差%d人", maxInt64(0, activity.InviteThresholdCount-progress.EffectiveInviteCount)) } } func (s *service) sumPaidAmount(ctx context.Context, userID int64, start time.Time, end time.Time) (int64, error) { var total int64 err := s.repo.GetDbR().WithContext(ctx).Table("orders"). Select("COALESCE(SUM(actual_amount), 0)"). Where("user_id = ? AND status = 2 AND actual_amount > 0", userID). Where("COALESCE(NULLIF(paid_at, '1970-01-01 00:00:00'), created_at) >= ?", start). Where("COALESCE(NULLIF(paid_at, '1970-01-01 00:00:00'), created_at) < ?", end). Scan(&total).Error return total, err } func (s *service) countEffectiveInvites(ctx context.Context, inviterID int64, activityStart time.Time, start time.Time, end time.Time, thresholdAmount int64) (int64, error) { if thresholdAmount <= 0 { return 0, nil } var count int64 query := ` SELECT COUNT(1) FROM ( SELECT ui.invitee_id FROM user_invites ui JOIN orders o ON o.user_id = ui.invitee_id WHERE ui.inviter_id = ? AND ui.created_at >= ? AND o.status = 2 AND o.actual_amount > 0 AND COALESCE(NULLIF(o.paid_at, '1970-01-01 00:00:00'), o.created_at) >= ? AND COALESCE(NULLIF(o.paid_at, '1970-01-01 00:00:00'), o.created_at) < ? GROUP BY ui.invitee_id HAVING COALESCE(SUM(o.actual_amount), 0) >= ? ) t ` if err := s.repo.GetDbR().WithContext(ctx).Raw(query, inviterID, activityStart, start, end, thresholdAmount).Scan(&count).Error; err != nil { return 0, err } return count, nil } func periodRange(activityType string, now time.Time) (time.Time, time.Time, string) { loc := now.Location() switch activityType { case TypeWeekly: dayOffset := (int(now.Weekday()) + 6) % 7 start := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, loc).AddDate(0, 0, -dayOffset) end := start.AddDate(0, 0, 7) y, w := start.ISOWeek() return start, end, fmt.Sprintf("%04d-W%02d", y, w) case TypeMonthly: start := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, loc) end := start.AddDate(0, 1, 0) return start, end, start.Format("2006-01") default: start := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, loc) end := start.AddDate(0, 0, 1) return start, end, start.Format("2006-01-02") } } func maxInt64(a, b int64) int64 { if a > b { return a } return b }