From 1db10dc5599d96f6613efa48ce161b0acb1f41bd Mon Sep 17 00:00:00 2001 From: wucm667 Date: Sat, 18 Jul 2026 14:42:22 +0800 Subject: [PATCH] fix(subscription): renew expired admin assignments --- .../subscription_assign_idempotency_test.go | 215 +++++++++++++++++- .../internal/service/subscription_service.go | 19 ++ .../user_subscription_daily_quota_test.go | 29 +++ 3 files changed, 260 insertions(+), 3 deletions(-) diff --git a/backend/internal/service/subscription_assign_idempotency_test.go b/backend/internal/service/subscription_assign_idempotency_test.go index d4913f8994..151b5c7928 100644 --- a/backend/internal/service/subscription_assign_idempotency_test.go +++ b/backend/internal/service/subscription_assign_idempotency_test.go @@ -268,7 +268,7 @@ func (s *subscriptionUserSubRepoStub) Update(_ context.Context, sub *UserSubscri } func TestAssignSubscriptionReuseWhenSemanticsMatch(t *testing.T) { - start := time.Date(2026, 2, 20, 10, 0, 0, 0, time.UTC) + start := time.Now().Add(-time.Hour) groupRepo := &subscriptionGroupRepoStub{ group: &Group{ID: 1, SubscriptionType: SubscriptionTypeSubscription}, } @@ -279,6 +279,7 @@ func TestAssignSubscriptionReuseWhenSemanticsMatch(t *testing.T) { GroupID: 1, StartsAt: start, ExpiresAt: start.AddDate(0, 0, 30), + Status: SubscriptionStatusActive, Notes: "init", }) @@ -292,10 +293,169 @@ func TestAssignSubscriptionReuseWhenSemanticsMatch(t *testing.T) { require.NoError(t, err) require.Equal(t, int64(10), sub.ID) require.Equal(t, 0, subRepo.createCalls, "reuse should not create new subscription") + require.Equal(t, start, sub.StartsAt) + require.Equal(t, start.AddDate(0, 0, 30), sub.ExpiresAt) +} + +func TestAssignSubscriptionDoesNotReactivateFutureSuspendedSubscription(t *testing.T) { + start := time.Now().Add(-time.Hour) + groupRepo := &subscriptionGroupRepoStub{ + group: &Group{ID: 1, SubscriptionType: SubscriptionTypeSubscription}, + } + subRepo := newSubscriptionUserSubRepoStub() + subRepo.seed(&UserSubscription{ + ID: 13, + UserID: 1003, + GroupID: 1, + StartsAt: start, + ExpiresAt: start.AddDate(0, 0, 30), + Status: SubscriptionStatusSuspended, + Notes: "assignment", + }) + + svc := NewSubscriptionService(groupRepo, subRepo, nil, nil, nil) + sub, err := svc.AssignSubscription(context.Background(), &AssignSubscriptionInput{ + UserID: 1003, + GroupID: 1, + ValidityDays: 30, + Notes: "assignment", + }) + + require.NoError(t, err) + require.Equal(t, int64(13), sub.ID) + require.Equal(t, SubscriptionStatusSuspended, sub.Status) + require.Equal(t, start, sub.StartsAt) + require.Equal(t, start.AddDate(0, 0, 30), sub.ExpiresAt) + require.Equal(t, "assignment", sub.Notes) + require.Equal(t, 0, subRepo.createCalls) +} + +func TestAssignSubscriptionDoesNotReactivatePastExpirySuspendedSubscription(t *testing.T) { + start := time.Now().AddDate(0, 0, -31) + expiresAt := start.AddDate(0, 0, 30) + windowStart := startOfDay(start) + groupRepo := &subscriptionGroupRepoStub{ + group: &Group{ID: 1, SubscriptionType: SubscriptionTypeSubscription}, + } + subRepo := newSubscriptionUserSubRepoStub() + subRepo.seed(&UserSubscription{ + ID: 15, + UserID: 1005, + GroupID: 1, + StartsAt: start, + ExpiresAt: expiresAt, + Status: SubscriptionStatusSuspended, + DailyWindowStart: &windowStart, + WeeklyWindowStart: &windowStart, + MonthlyWindowStart: &windowStart, + DailyUsageUSD: 1, + WeeklyUsageUSD: 2, + MonthlyUsageUSD: 3, + Notes: "suspended assignment", + }) + + svc := NewSubscriptionService(groupRepo, subRepo, nil, nil, nil) + sub, err := svc.AssignSubscription(context.Background(), &AssignSubscriptionInput{ + UserID: 1005, + GroupID: 1, + ValidityDays: 30, + Notes: "suspended assignment", + }) + + require.NoError(t, err) + require.Equal(t, int64(15), sub.ID) + require.Equal(t, SubscriptionStatusSuspended, sub.Status) + require.Equal(t, start, sub.StartsAt) + require.Equal(t, expiresAt, sub.ExpiresAt) + require.Equal(t, "suspended assignment", sub.Notes) + require.Equal(t, &windowStart, sub.DailyWindowStart) + require.Equal(t, &windowStart, sub.WeeklyWindowStart) + require.Equal(t, &windowStart, sub.MonthlyWindowStart) + require.Equal(t, float64(1), sub.DailyUsageUSD) + require.Equal(t, float64(2), sub.WeeklyUsageUSD) + require.Equal(t, float64(3), sub.MonthlyUsageUSD) + require.Equal(t, 0, subRepo.createCalls) +} + +func TestAssignSubscriptionRenewsExpiredSemanticMatch(t *testing.T) { + groupRepo := &subscriptionGroupRepoStub{ + group: &Group{ID: 1, SubscriptionType: SubscriptionTypeSubscription}, + } + subRepo := newSubscriptionUserSubRepoStub() + oldStart := time.Now().Add(-time.Hour) + oldWindowStart := startOfDay(oldStart) + subRepo.seed(&UserSubscription{ + ID: 12, + UserID: 1002, + GroupID: 1, + StartsAt: oldStart, + ExpiresAt: oldStart.AddDate(0, 0, 30), + Status: SubscriptionStatusExpired, + DailyWindowStart: &oldWindowStart, + WeeklyWindowStart: &oldWindowStart, + MonthlyWindowStart: &oldWindowStart, + DailyUsageUSD: 1, + WeeklyUsageUSD: 2, + MonthlyUsageUSD: 3, + Notes: " assignment ", + }) + + svc := NewSubscriptionService(groupRepo, subRepo, nil, nil, nil) + before := time.Now() + sub, err := svc.AssignSubscription(context.Background(), &AssignSubscriptionInput{ + UserID: 1002, + GroupID: 1, + ValidityDays: 30, + Notes: "assignment", + }) + after := time.Now() + + require.NoError(t, err) + require.Equal(t, int64(12), sub.ID) + require.Equal(t, 0, subRepo.createCalls) + require.Equal(t, SubscriptionStatusActive, sub.Status) + require.False(t, sub.StartsAt.Before(before)) + require.False(t, sub.StartsAt.After(after)) + require.Equal(t, sub.StartsAt.AddDate(0, 0, 30), sub.ExpiresAt) + require.Equal(t, startOfDay(sub.StartsAt), *sub.DailyWindowStart) + require.Equal(t, startOfDay(sub.StartsAt), *sub.WeeklyWindowStart) + require.Equal(t, startOfDay(sub.StartsAt), *sub.MonthlyWindowStart) + require.Zero(t, sub.DailyUsageUSD) + require.Zero(t, sub.WeeklyUsageUSD) + require.Zero(t, sub.MonthlyUsageUSD) + require.Equal(t, " assignment ", sub.Notes) +} + +func TestAssignSubscriptionRenewsExpiredAndAppendsDifferentNotes(t *testing.T) { + groupRepo := &subscriptionGroupRepoStub{ + group: &Group{ID: 1, SubscriptionType: SubscriptionTypeSubscription}, + } + subRepo := newSubscriptionUserSubRepoStub() + oldStart := time.Date(2025, 1, 2, 3, 4, 5, 0, time.UTC) + subRepo.seed(&UserSubscription{ + ID: 14, + UserID: 1004, + GroupID: 1, + StartsAt: oldStart, + ExpiresAt: oldStart.AddDate(0, 0, 30), + Status: SubscriptionStatusExpired, + Notes: "old assignment", + }) + + svc := NewSubscriptionService(groupRepo, subRepo, nil, nil, nil) + sub, err := svc.AssignSubscription(context.Background(), &AssignSubscriptionInput{ + UserID: 1004, + GroupID: 1, + ValidityDays: 30, + Notes: "new assignment", + }) + + require.NoError(t, err) + require.Equal(t, "old assignment\nnew assignment", sub.Notes) } func TestAssignSubscriptionConflictWhenSemanticsMismatch(t *testing.T) { - start := time.Date(2026, 2, 20, 10, 0, 0, 0, time.UTC) + start := time.Now().Add(-time.Hour) groupRepo := &subscriptionGroupRepoStub{ group: &Group{ID: 1, SubscriptionType: SubscriptionTypeSubscription}, } @@ -306,6 +466,7 @@ func TestAssignSubscriptionConflictWhenSemanticsMismatch(t *testing.T) { GroupID: 1, StartsAt: start, ExpiresAt: start.AddDate(0, 0, 30), + Status: SubscriptionStatusActive, Notes: "old-note", }) @@ -322,7 +483,7 @@ func TestAssignSubscriptionConflictWhenSemanticsMismatch(t *testing.T) { } func TestBulkAssignSubscriptionCreatedReusedAndConflict(t *testing.T) { - start := time.Date(2026, 2, 20, 10, 0, 0, 0, time.UTC) + start := time.Now().Add(-time.Hour) groupRepo := &subscriptionGroupRepoStub{ group: &Group{ID: 1, SubscriptionType: SubscriptionTypeSubscription}, } @@ -334,6 +495,7 @@ func TestBulkAssignSubscriptionCreatedReusedAndConflict(t *testing.T) { GroupID: 1, StartsAt: start, ExpiresAt: start.AddDate(0, 0, 30), + Status: SubscriptionStatusActive, Notes: "same-note", }) // user 3: 语义冲突(有效期不一致),应 failed @@ -343,6 +505,7 @@ func TestBulkAssignSubscriptionCreatedReusedAndConflict(t *testing.T) { GroupID: 1, StartsAt: start, ExpiresAt: start.AddDate(0, 0, 60), + Status: SubscriptionStatusActive, Notes: "same-note", }) @@ -365,6 +528,52 @@ func TestBulkAssignSubscriptionCreatedReusedAndConflict(t *testing.T) { require.Equal(t, 1, subRepo.createCalls) } +func TestBulkAssignSubscriptionRenewsExpiredSemanticMatch(t *testing.T) { + groupRepo := &subscriptionGroupRepoStub{ + group: &Group{ID: 1, SubscriptionType: SubscriptionTypeSubscription}, + } + subRepo := newSubscriptionUserSubRepoStub() + oldStart := time.Date(2025, 1, 2, 3, 4, 5, 0, time.UTC) + subRepo.seed(&UserSubscription{ + ID: 24, + UserID: 4, + GroupID: 1, + StartsAt: oldStart, + ExpiresAt: oldStart.AddDate(0, 0, 7), + Status: SubscriptionStatusExpired, + DailyUsageUSD: 1, + WeeklyUsageUSD: 2, + MonthlyUsageUSD: 3, + Notes: "bulk", + }) + + svc := NewSubscriptionService(groupRepo, subRepo, nil, nil, nil) + before := time.Now() + result, err := svc.BulkAssignSubscription(context.Background(), &BulkAssignSubscriptionInput{ + UserIDs: []int64{4}, + GroupID: 1, + ValidityDays: 7, + Notes: "bulk", + }) + after := time.Now() + + require.NoError(t, err) + require.Equal(t, 1, result.SuccessCount) + require.Equal(t, 0, result.CreatedCount) + require.Equal(t, 1, result.ReusedCount) + require.Equal(t, "reused", result.Statuses[4]) + require.Len(t, result.Subscriptions, 1) + renewed := result.Subscriptions[0] + require.Equal(t, SubscriptionStatusActive, renewed.Status) + require.False(t, renewed.StartsAt.Before(before)) + require.False(t, renewed.StartsAt.After(after)) + require.Equal(t, renewed.StartsAt.AddDate(0, 0, 7), renewed.ExpiresAt) + require.Zero(t, renewed.DailyUsageUSD) + require.Zero(t, renewed.WeeklyUsageUSD) + require.Zero(t, renewed.MonthlyUsageUSD) + require.Equal(t, "bulk", renewed.Notes) +} + func TestAssignSubscriptionKeepsWorkingWhenIdempotencyStoreUnavailable(t *testing.T) { groupRepo := &subscriptionGroupRepoStub{ group: &Group{ID: 1, SubscriptionType: SubscriptionTypeSubscription}, diff --git a/backend/internal/service/subscription_service.go b/backend/internal/service/subscription_service.go index ea1fd091d9..fbe99bf906 100644 --- a/backend/internal/service/subscription_service.go +++ b/backend/internal/service/subscription_service.go @@ -508,6 +508,25 @@ func (s *SubscriptionService) assignSubscriptionWithReuse(ctx context.Context, i if getErr != nil { return nil, false, getErr } + now := time.Now() + if sub.Status == SubscriptionStatusExpired || + (sub.Status != SubscriptionStatusSuspended && !sub.ExpiresAt.After(now)) { + validityDays := normalizeAssignValidityDays(input.ValidityDays) + newExpiresAt := now.AddDate(0, 0, validityDays) + if newExpiresAt.After(MaxExpiresAt) { + newExpiresAt = MaxExpiresAt + } + renewalNotes := input.Notes + if strings.TrimSpace(sub.Notes) == strings.TrimSpace(input.Notes) { + renewalNotes = "" + } + if err := s.updateExistingSubscriptionTerm(ctx, sub, renewalNotes, now, newExpiresAt, true); err != nil { + return nil, false, err + } + s.maybeInvalidateAssignmentCaches(input.UserID, input.GroupID, false) + renewed, getErr := s.userSubRepo.GetByID(ctx, sub.ID) + return renewed, true, getErr + } if conflictReason, conflict := detectAssignSemanticConflict(sub, input); conflict { return nil, false, ErrSubscriptionAssignConflict.WithMetadata(map[string]string{ "conflict_reason": conflictReason, diff --git a/backend/internal/service/user_subscription_daily_quota_test.go b/backend/internal/service/user_subscription_daily_quota_test.go index bf58de7f4c..bc04812166 100644 --- a/backend/internal/service/user_subscription_daily_quota_test.go +++ b/backend/internal/service/user_subscription_daily_quota_test.go @@ -65,6 +65,35 @@ func TestAssignOrExtendSubscription_ExpiredDailyCardStartsNewOneTimeQuota(t *tes require.Equal(t, "old\nnew", renewed.Notes) } +func TestAssignOrExtendSubscription_ExpiredSubscriptionAppendsMatchingNotes(t *testing.T) { + groupRepo := &subscriptionGroupRepoStub{ + group: &Group{ID: 1, SubscriptionType: SubscriptionTypeSubscription}, + } + subRepo := newSubscriptionUserSubRepoStub() + oldStart := time.Now().AddDate(0, 0, -3) + subRepo.seed(&UserSubscription{ + ID: 101, + UserID: 201, + GroupID: 1, + StartsAt: oldStart, + ExpiresAt: oldStart.AddDate(0, 0, 1), + Status: SubscriptionStatusExpired, + Notes: "same", + }) + svc := NewSubscriptionService(groupRepo, subRepo, nil, nil, nil) + + renewed, reused, err := svc.AssignOrExtendSubscription(context.Background(), &AssignSubscriptionInput{ + UserID: 201, + GroupID: 1, + ValidityDays: 1, + Notes: "same", + }) + + require.NoError(t, err) + require.True(t, reused) + require.Equal(t, "same\nsame", renewed.Notes) +} + func TestUserSubscriptionNeedsDailyReset_DailyCardKeepsOneTimeQuota(t *testing.T) { start := time.Date(2026, 5, 18, 12, 0, 0, 0, time.UTC) dailyWindowStart := time.Date(2026, 5, 18, 0, 0, 0, 0, time.UTC)