fix(subscription): renew expired admin assignments

This commit is contained in:
wucm667
2026-07-18 14:42:22 +08:00
parent b1a6b80267
commit 1db10dc559
3 changed files with 260 additions and 3 deletions
@@ -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},
@@ -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,
@@ -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)