From 9f96f235e7bd94340a0a94add35e2d677744517e Mon Sep 17 00:00:00 2001 From: Zuncle <34310384@qq.com> Date: Fri, 12 Jun 2026 18:37:42 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E6=B4=BB=E5=8A=A8=E9=97=A8?= =?UTF-8?q?=E6=A7=9B=E7=BB=9F=E8=AE=A1=E4=BD=BF=E7=94=A8=E8=87=AA=E5=AE=9A?= =?UTF-8?q?=E4=B9=89=E6=97=B6=E9=97=B4=E8=8C=83=E5=9B=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/threshold_activity/activity.go | 4 +- .../service/threshold_activity/participant.go | 45 +++--- .../threshold_activity/participant_test.go | 70 +++++++++- internal/service/welfare_activity/activity.go | 7 +- .../service/welfare_activity/participant.go | 37 +++-- .../welfare_activity/participant_test.go | 132 ++++++++++++++++++ .../service/welfare_activity/test_tool.go | 31 ++-- 7 files changed, 256 insertions(+), 70 deletions(-) create mode 100644 internal/service/welfare_activity/participant_test.go diff --git a/internal/service/threshold_activity/activity.go b/internal/service/threshold_activity/activity.go index 6f81b02..a6c3953 100644 --- a/internal/service/threshold_activity/activity.go +++ b/internal/service/threshold_activity/activity.go @@ -204,14 +204,14 @@ func (s *service) buildActivityDetail(ctx context.Context, item Activity, userID detail.Winners = winners.List } if userID > 0 { - progress, period, err := s.evaluateQualification(ctx, item, userID, time.Now()) + progress, _, err := s.evaluateQualification(ctx, item, userID) if err != nil { return nil, err } detail.QualificationProgress = progress detail.CanJoin = isJoinWindowOpen(item, time.Now()) && qualificationSatisfied(item, progress) var count int64 - s.repo.GetDbR().WithContext(ctx).Model(&Participant{}).Where("activity_id = ? AND user_id = ? AND period_key = ?", item.ID, userID, period).Count(&count) + s.repo.GetDbR().WithContext(ctx).Model(&Participant{}).Where("activity_id = ? AND user_id = ?", item.ID, userID).Count(&count) detail.Joined = count > 0 if detail.Joined { detail.CanJoin = false diff --git a/internal/service/threshold_activity/participant.go b/internal/service/threshold_activity/participant.go index 991d7eb..1ef9a48 100644 --- a/internal/service/threshold_activity/participant.go +++ b/internal/service/threshold_activity/participant.go @@ -22,7 +22,14 @@ func (s *service) Join(ctx context.Context, activityID int64, userID int64) erro } return errors.New("当前不在活动参与时间内") } - progress, period, err := s.evaluateQualification(ctx, activity, userID, now) + var existing int64 + if err := s.repo.GetDbW().WithContext(ctx).Model(&Participant{}).Where("activity_id = ? AND user_id = ?", activityID, userID).Count(&existing).Error; err != nil { + return err + } + if existing > 0 { + return errors.New("已参与该活动") + } + progress, period, err := s.evaluateQualification(ctx, activity, userID) if err != nil { return err } @@ -78,12 +85,12 @@ func (s *service) ListJoinableActivitiesForUser(ctx context.Context, userID int6 } items := make([]JoinableActivityItem, 0, len(activities)) for _, activity := range activities { - progress, period, err := s.evaluateQualification(ctx, activity, userID, now) + progress, _, err := s.evaluateQualification(ctx, activity, userID) 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) + s.repo.GetDbR().WithContext(ctx).Model(&Participant{}).Where("activity_id = ? AND user_id = ?", activity.ID, userID).Count(&count) joined := count > 0 canJoin := !joined && qualificationSatisfied(activity, progress) items = append(items, JoinableActivityItem{ @@ -106,8 +113,8 @@ func (s *service) ListJoinableActivitiesForUser(ctx context.Context, userID int6 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) +func (s *service) evaluateQualification(ctx context.Context, activity Activity, userID int64) (QualificationProgress, string, error) { + start, end, period := activityStatsRange(activity) paid, err := s.sumPaidAmount(ctx, userID, start, end) if err != nil { return QualificationProgress{}, "", err @@ -196,24 +203,16 @@ func (s *service) countEffectiveInvites(ctx context.Context, inviterID int64, ac 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 activityStatsRange(activity Activity) (time.Time, time.Time, string) { + return activity.StartTime, exclusiveEndTime(activity.EndTime), activityPeriodKey(activity) +} + +func exclusiveEndTime(end time.Time) time.Time { + return end.Add(time.Second) +} + +func activityPeriodKey(activity Activity) string { + return fmt.Sprintf("%016x", uint64(activity.ID)) } func maxInt64(a, b int64) int64 { diff --git a/internal/service/threshold_activity/participant_test.go b/internal/service/threshold_activity/participant_test.go index ee4e229..a9822fd 100644 --- a/internal/service/threshold_activity/participant_test.go +++ b/internal/service/threshold_activity/participant_test.go @@ -11,7 +11,8 @@ func TestCountEffectiveInvites_OnlyCountsInvitesCreatedAfterActivityStart(t *tes ctx := context.Background() now := time.Now() activityStart := now.Add(-2 * time.Hour) - periodStart, periodEnd, _ := periodRange(TypeDaily, now) + periodStart := activityStart + periodEnd := now.Add(time.Hour) mustExec(t, db, `INSERT INTO user_invites (inviter_id, invitee_id, invite_code, created_at, updated_at) VALUES (1, 2, 'INV1', ?, ?)`, activityStart.Add(-time.Hour), activityStart.Add(-time.Hour)) mustExec(t, db, `INSERT INTO user_invites (inviter_id, invitee_id, invite_code, created_at, updated_at) VALUES (1, 3, 'INV1', ?, ?)`, activityStart.Add(10*time.Minute), activityStart.Add(10*time.Minute)) @@ -27,6 +28,73 @@ func TestCountEffectiveInvites_OnlyCountsInvitesCreatedAfterActivityStart(t *tes } } +func TestEvaluateQualification_UsesConfiguredActivityTimeRangeForSpend(t *testing.T) { + svc, _, db := newThresholdTestService(t) + ctx := context.Background() + now := time.Date(2026, 6, 10, 12, 0, 0, 0, time.Local) + activity := Activity{ + ID: 4, + Title: "自定义消费时间", + Type: TypeMonthly, + QualificationMode: QualificationModeSpendOnly, + SpendThresholdAmount: 1000, + MinParticipants: 1, + StartTime: now.Add(-time.Hour), + EndTime: now.Add(time.Hour), + DrawTime: now.Add(2 * time.Hour), + Status: StatusActive, + } + mustExec(t, db, `INSERT INTO orders (user_id, order_no, source_type, actual_amount, total_amount, status, paid_at, created_at, updated_at, cancelled_at) VALUES (40, 'CUSTOM_BEFORE', 1, 5000, 5000, 2, ?, ?, ?, ?)`, activity.StartTime.Add(-time.Second), activity.StartTime.Add(-time.Second), activity.StartTime.Add(-time.Second), time.Unix(0, 0)) + mustExec(t, db, `INSERT INTO orders (user_id, order_no, source_type, actual_amount, total_amount, status, paid_at, created_at, updated_at, cancelled_at) VALUES (40, 'CUSTOM_INSIDE', 1, 1000, 1000, 2, ?, ?, ?, ?)`, now, now, now, time.Unix(0, 0)) + mustExec(t, db, `INSERT INTO orders (user_id, order_no, source_type, actual_amount, total_amount, status, paid_at, created_at, updated_at, cancelled_at) VALUES (40, 'CUSTOM_AFTER', 1, 7000, 7000, 2, ?, ?, ?, ?)`, activity.EndTime.Add(2*time.Second), activity.EndTime.Add(2*time.Second), activity.EndTime.Add(2*time.Second), time.Unix(0, 0)) + + progress, _, err := svc.evaluateQualification(ctx, activity, 40) + if err != nil { + t.Fatalf("evaluateQualification failed: %v", err) + } + if progress.CurrentPaid != 1000 { + t.Fatalf("expected only configured-range spend 1000, got %d", progress.CurrentPaid) + } + if !progress.SpendQualified { + t.Fatalf("expected spend to be qualified") + } +} + +func TestEvaluateQualification_UsesConfiguredActivityTimeRangeForInvites(t *testing.T) { + svc, _, db := newThresholdTestService(t) + ctx := context.Background() + now := time.Date(2026, 6, 10, 12, 0, 0, 0, time.Local) + activity := Activity{ + ID: 5, + Title: "自定义邀请时间", + Type: TypeMonthly, + QualificationMode: QualificationModeInviteOnly, + InviteThresholdCount: 1, + InviteEffectiveAmount: 1000, + MinParticipants: 1, + StartTime: now.Add(-time.Hour), + EndTime: now.Add(time.Hour), + DrawTime: now.Add(2 * time.Hour), + Status: StatusActive, + } + mustExec(t, db, `INSERT INTO user_invites (inviter_id, invitee_id, invite_code, created_at, updated_at) VALUES (50, 51, 'INV50', ?, ?)`, activity.StartTime.Add(time.Minute), activity.StartTime.Add(time.Minute)) + mustExec(t, db, `INSERT INTO user_invites (inviter_id, invitee_id, invite_code, created_at, updated_at) VALUES (50, 52, 'INV50', ?, ?)`, activity.StartTime.Add(time.Minute), activity.StartTime.Add(time.Minute)) + mustExec(t, db, `INSERT INTO orders (user_id, order_no, source_type, actual_amount, total_amount, status, paid_at, created_at, updated_at, cancelled_at) VALUES (51, 'INV_BEFORE', 1, 1500, 1500, 2, ?, ?, ?, ?)`, activity.StartTime.Add(-time.Second), activity.StartTime.Add(-time.Second), activity.StartTime.Add(-time.Second), time.Unix(0, 0)) + mustExec(t, db, `INSERT INTO orders (user_id, order_no, source_type, actual_amount, total_amount, status, paid_at, created_at, updated_at, cancelled_at) VALUES (51, 'INV_AFTER', 1, 1500, 1500, 2, ?, ?, ?, ?)`, activity.EndTime.Add(2*time.Second), activity.EndTime.Add(2*time.Second), activity.EndTime.Add(2*time.Second), time.Unix(0, 0)) + mustExec(t, db, `INSERT INTO orders (user_id, order_no, source_type, actual_amount, total_amount, status, paid_at, created_at, updated_at, cancelled_at) VALUES (52, 'INV_INSIDE', 1, 1200, 1200, 2, ?, ?, ?, ?)`, now, now, now, time.Unix(0, 0)) + + progress, _, err := svc.evaluateQualification(ctx, activity, 50) + if err != nil { + t.Fatalf("evaluateQualification failed: %v", err) + } + if progress.EffectiveInviteCount != 1 { + t.Fatalf("expected only one effective invite in configured range, got %d", progress.EffectiveInviteCount) + } + if !progress.InviteQualified { + t.Fatalf("expected invite to be qualified") + } +} + func TestJoin_SpendOnlyQualifiedWritesParticipant(t *testing.T) { svc, _, db := newThresholdTestService(t) ctx := context.Background() diff --git a/internal/service/welfare_activity/activity.go b/internal/service/welfare_activity/activity.go index 2a85bcf..ab376a4 100644 --- a/internal/service/welfare_activity/activity.go +++ b/internal/service/welfare_activity/activity.go @@ -189,12 +189,15 @@ func (s *service) buildActivityDetail(ctx context.Context, item Activity, userID detail.Winners = winners.List } if userID > 0 { - start, end, period := periodRange(item.Type, time.Now()) + start, end, _ := activityStatsRange(item) detail.CurrentPaid, _ = s.sumPaidAmount(ctx, userID, start, end) detail.CanJoin = isJoinWindowOpen(item, time.Now()) && detail.CurrentPaid >= item.ThresholdAmount var count int64 - s.repo.GetDbR().WithContext(ctx).Model(&Participant{}).Where("activity_id = ? AND user_id = ? AND period_key = ?", item.ID, userID, period).Count(&count) + s.repo.GetDbR().WithContext(ctx).Model(&Participant{}).Where("activity_id = ? AND user_id = ?", item.ID, userID).Count(&count) detail.Joined = count > 0 + if detail.Joined { + detail.CanJoin = false + } } return detail, nil } diff --git a/internal/service/welfare_activity/participant.go b/internal/service/welfare_activity/participant.go index 00a339a..ae64978 100644 --- a/internal/service/welfare_activity/participant.go +++ b/internal/service/welfare_activity/participant.go @@ -22,7 +22,14 @@ func (s *service) Join(ctx context.Context, activityID int64, userID int64) erro } return errors.New("当前不在活动参与时间内") } - start, end, period := periodRange(activity.Type, now) + var existing int64 + if err := s.repo.GetDbW().WithContext(ctx).Model(&Participant{}).Where("activity_id = ? AND user_id = ?", activityID, userID).Count(&existing).Error; err != nil { + return err + } + if existing > 0 { + return errors.New("已参与该活动") + } + start, end, period := activityStatsRange(activity) paid, err := s.sumPaidAmount(ctx, userID, start, end) if err != nil { return err @@ -72,22 +79,14 @@ func (s *service) sumPaidAmount(ctx context.Context, userID int64, start time.Ti return total, err } -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 activityStatsRange(activity Activity) (time.Time, time.Time, string) { + return activity.StartTime, exclusiveEndTime(activity.EndTime), activityPeriodKey(activity) +} + +func exclusiveEndTime(end time.Time) time.Time { + return end.Add(time.Second) +} + +func activityPeriodKey(activity Activity) string { + return fmt.Sprintf("%016x", uint64(activity.ID)) } diff --git a/internal/service/welfare_activity/participant_test.go b/internal/service/welfare_activity/participant_test.go new file mode 100644 index 0000000..f6edd0e --- /dev/null +++ b/internal/service/welfare_activity/participant_test.go @@ -0,0 +1,132 @@ +package welfare_activity + +import ( + "context" + "testing" + "time" + + "bindbox-game/internal/repository/mysql" + + "gorm.io/gorm" +) + +func TestJoin_UsesConfiguredActivityTimeRangeForSpend(t *testing.T) { + svc, _, db := newWelfareTestService(t) + ctx := context.Background() + now := time.Now().Truncate(time.Second) + activity := Activity{ + ID: 1, + Title: "自定义福利时间", + Type: TypeMonthly, + ThresholdAmount: 1000, + StartTime: now.Add(-time.Hour), + EndTime: now.Add(time.Hour), + DrawTime: now.Add(2 * time.Hour), + Status: StatusActive, + } + mustInsertWelfareActivity(t, db, activity) + mustExecWelfare(t, db, `INSERT INTO orders (user_id, order_no, source_type, actual_amount, total_amount, status, paid_at, created_at, updated_at, cancelled_at) VALUES (10, 'WELFARE_BEFORE', 1, 5000, 5000, 2, ?, ?, ?, ?)`, activity.StartTime.Add(-time.Second), activity.StartTime.Add(-time.Second), activity.StartTime.Add(-time.Second), time.Unix(0, 0)) + mustExecWelfare(t, db, `INSERT INTO orders (user_id, order_no, source_type, actual_amount, total_amount, status, paid_at, created_at, updated_at, cancelled_at) VALUES (10, 'WELFARE_INSIDE', 1, 1000, 1000, 2, ?, ?, ?, ?)`, now, now, now, time.Unix(0, 0)) + mustExecWelfare(t, db, `INSERT INTO orders (user_id, order_no, source_type, actual_amount, total_amount, status, paid_at, created_at, updated_at, cancelled_at) VALUES (10, 'WELFARE_AFTER', 1, 7000, 7000, 2, ?, ?, ?, ?)`, activity.EndTime.Add(2*time.Second), activity.EndTime.Add(2*time.Second), activity.EndTime.Add(2*time.Second), time.Unix(0, 0)) + + if err := svc.Join(ctx, activity.ID, 10); err != nil { + t.Fatalf("Join failed: %v", err) + } + var participant Participant + if err := db.Where("activity_id = ? AND user_id = ?", activity.ID, 10).First(&participant).Error; err != nil { + t.Fatalf("query participant failed: %v", err) + } + if participant.PaidAmountSnapshot != 1000 { + t.Fatalf("expected only configured-range spend 1000, got %d", participant.PaidAmountSnapshot) + } +} + +func newWelfareTestService(t *testing.T) (*service, mysql.Repo, *gorm.DB) { + t.Helper() + repo, err := mysql.NewSQLiteRepoForTest() + if err != nil { + t.Fatalf("create sqlite repo failed: %v", err) + } + db := repo.GetDbW() + initWelfareTestTables(t, db) + return &service{repo: repo}, repo, db +} + +func initWelfareTestTables(t *testing.T, db *gorm.DB) { + t.Helper() + stmts := []string{ + `CREATE TABLE welfare_activities ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + title TEXT NOT NULL, + type TEXT NOT NULL, + threshold_amount INTEGER NOT NULL DEFAULT 0, + start_time DATETIME NOT NULL, + end_time DATETIME NOT NULL, + draw_time DATETIME NOT NULL, + status TEXT NOT NULL DEFAULT 'active', + description TEXT, + cover_image TEXT, + draw_batch TEXT NOT NULL DEFAULT '', + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, + deleted_at DATETIME + );`, + `CREATE TABLE welfare_activity_participants ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + activity_id INTEGER NOT NULL, + user_id INTEGER NOT NULL, + period_key TEXT NOT NULL, + paid_amount_snapshot INTEGER NOT NULL DEFAULT 0, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + UNIQUE(activity_id, user_id, period_key) + );`, + `CREATE TABLE orders ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, + user_id INTEGER NOT NULL, + order_no TEXT NOT NULL, + source_type INTEGER NOT NULL DEFAULT 1, + total_amount INTEGER NOT NULL DEFAULT 0, + discount_amount INTEGER NOT NULL DEFAULT 0, + points_amount INTEGER NOT NULL DEFAULT 0, + actual_amount INTEGER NOT NULL DEFAULT 0, + status INTEGER NOT NULL DEFAULT 1, + pay_preorder_id INTEGER NOT NULL DEFAULT 0, + paid_at DATETIME, + cancelled_at DATETIME, + user_address_id INTEGER NOT NULL DEFAULT 0, + is_consumed INTEGER NOT NULL DEFAULT 0, + points_ledger_id INTEGER NOT NULL DEFAULT 0, + coupon_id INTEGER NOT NULL DEFAULT 0, + item_card_id INTEGER NOT NULL DEFAULT 0, + remark TEXT, + ext_order_id TEXT NOT NULL DEFAULT '' + );`, + } + for _, stmt := range stmts { + if err := db.Exec(stmt).Error; err != nil { + t.Fatalf("create table failed: %v", err) + } + } +} + +func mustExecWelfare(t *testing.T, db *gorm.DB, sql string, args ...any) { + t.Helper() + if err := db.Exec(sql, args...).Error; err != nil { + t.Fatalf("exec failed: %v, sql=%s", err, sql) + } +} + +func mustInsertWelfareActivity(t *testing.T, db *gorm.DB, activity Activity) { + t.Helper() + if activity.CreatedAt.IsZero() { + activity.CreatedAt = time.Now() + } + if activity.UpdatedAt.IsZero() { + activity.UpdatedAt = activity.CreatedAt + } + if err := db.Create(&activity).Error; err != nil { + t.Fatalf("insert activity failed: %v", err) + } +} diff --git a/internal/service/welfare_activity/test_tool.go b/internal/service/welfare_activity/test_tool.go index f5154ca..b704f83 100644 --- a/internal/service/welfare_activity/test_tool.go +++ b/internal/service/welfare_activity/test_tool.go @@ -15,21 +15,6 @@ func (s *service) ListJoinableActivitiesForUser(ctx context.Context, userID int6 return []JoinableActivityItem{}, nil } - paidByType := map[string]int64{} - periodByType := map[string]string{} - for _, activity := range activities { - if _, ok := paidByType[activity.Type]; ok { - continue - } - start, end, period := periodRange(activity.Type, now) - paid, err := s.sumPaidAmount(ctx, userID, start, end) - if err != nil { - return nil, err - } - paidByType[activity.Type] = paid - periodByType[activity.Type] = period - } - activityIDs := make([]int64, 0, len(activities)) for _, activity := range activities { activityIDs = append(activityIDs, activity.ID) @@ -38,19 +23,19 @@ func (s *service) ListJoinableActivitiesForUser(ctx context.Context, userID int6 if err := s.repo.GetDbR().WithContext(ctx).Where("user_id = ? AND activity_id IN ?", userID, activityIDs).Find(&participants).Error; err != nil { return nil, err } - joinedMap := map[int64]map[string]bool{} + joinedMap := map[int64]bool{} for _, participant := range participants { - if _, ok := joinedMap[participant.ActivityID]; !ok { - joinedMap[participant.ActivityID] = map[string]bool{} - } - joinedMap[participant.ActivityID][participant.PeriodKey] = true + joinedMap[participant.ActivityID] = true } items := make([]JoinableActivityItem, 0, len(activities)) for _, activity := range activities { - currentPaid := paidByType[activity.Type] - periodKey := periodByType[activity.Type] - joined := joinedMap[activity.ID][periodKey] + start, end, _ := activityStatsRange(activity) + currentPaid, err := s.sumPaidAmount(ctx, userID, start, end) + if err != nil { + return nil, err + } + joined := joinedMap[activity.ID] canJoin := !joined && currentPaid >= activity.ThresholdAmount items = append(items, JoinableActivityItem{ ActivityID: activity.ID,