修复活动门槛统计使用自定义时间范围

This commit is contained in:
Zuncle 2026-06-12 18:37:42 +08:00
parent f8c4e17ccc
commit 9f96f235e7
7 changed files with 256 additions and 70 deletions

View File

@ -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

View File

@ -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 {

View File

@ -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()

View File

@ -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
}

View File

@ -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))
}

View File

@ -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)
}
}

View File

@ -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,