Merge pull request #2557 from Arron196/fix/issue-2542-daily-card-expiry-mode
fix: 修复日卡跨日重复刷新额度
This commit is contained in:
commit
548c71c8bb
@ -199,6 +199,24 @@ func (s *subscriptionUserSubRepoStub) GetByID(_ context.Context, id int64) (*Use
|
|||||||
return &cp, nil
|
return &cp, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *subscriptionUserSubRepoStub) Update(_ context.Context, sub *UserSubscription) error {
|
||||||
|
if sub == nil {
|
||||||
|
return ErrSubscriptionNilInput
|
||||||
|
}
|
||||||
|
existing := s.byID[sub.ID]
|
||||||
|
if existing == nil {
|
||||||
|
return ErrSubscriptionNotFound
|
||||||
|
}
|
||||||
|
oldKey := s.key(existing.UserID, existing.GroupID)
|
||||||
|
cp := *sub
|
||||||
|
s.byID[cp.ID] = &cp
|
||||||
|
if oldKey != s.key(cp.UserID, cp.GroupID) {
|
||||||
|
delete(s.byUserGroup, oldKey)
|
||||||
|
}
|
||||||
|
s.byUserGroup[s.key(cp.UserID, cp.GroupID)] = &cp
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func TestAssignSubscriptionReuseWhenSemanticsMatch(t *testing.T) {
|
func TestAssignSubscriptionReuseWhenSemanticsMatch(t *testing.T) {
|
||||||
start := time.Date(2026, 2, 20, 10, 0, 0, 0, time.UTC)
|
start := time.Date(2026, 2, 20, 10, 0, 0, 0, time.UTC)
|
||||||
groupRepo := &subscriptionGroupRepoStub{
|
groupRepo := &subscriptionGroupRepoStub{
|
||||||
|
|||||||
@ -66,6 +66,30 @@ func TestCalculateProgress_DailyUsage(t *testing.T) {
|
|||||||
assert.Equal(t, dailyStart, progress.Daily.WindowStart)
|
assert.Equal(t, dailyStart, progress.Daily.WindowStart)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestCalculateProgress_DailyCardUsesExpiryAsDailyResetTime(t *testing.T) {
|
||||||
|
svc := newTestSubscriptionService()
|
||||||
|
startsAt := time.Now().Add(-12 * time.Hour)
|
||||||
|
dailyStart := time.Date(startsAt.Year(), startsAt.Month(), startsAt.Day(), 0, 0, 0, 0, startsAt.Location())
|
||||||
|
expiresAt := startsAt.Add(24 * time.Hour)
|
||||||
|
|
||||||
|
sub := &UserSubscription{
|
||||||
|
ID: 1,
|
||||||
|
StartsAt: startsAt,
|
||||||
|
ExpiresAt: expiresAt,
|
||||||
|
DailyUsageUSD: 3.0,
|
||||||
|
DailyWindowStart: ptrTime(dailyStart),
|
||||||
|
}
|
||||||
|
group := &Group{
|
||||||
|
Name: "Daily",
|
||||||
|
DailyLimitUSD: ptrFloat64(10.0),
|
||||||
|
}
|
||||||
|
|
||||||
|
progress := svc.calculateProgress(sub, group)
|
||||||
|
|
||||||
|
require.NotNil(t, progress.Daily, "日卡有日限额和窗口时 Daily 不应为 nil")
|
||||||
|
assert.Equal(t, expiresAt, progress.Daily.ResetsAt, "日卡的一次性日额度结束时间应为订阅过期时间")
|
||||||
|
}
|
||||||
|
|
||||||
func TestCalculateProgress_WeeklyUsage(t *testing.T) {
|
func TestCalculateProgress_WeeklyUsage(t *testing.T) {
|
||||||
svc := newTestSubscriptionService()
|
svc := newTestSubscriptionService()
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
|
|||||||
@ -196,7 +196,8 @@ func (s *SubscriptionService) AssignOrExtendSubscription(ctx context.Context, in
|
|||||||
now := time.Now()
|
now := time.Now()
|
||||||
var newExpiresAt time.Time
|
var newExpiresAt time.Time
|
||||||
|
|
||||||
if existingSub.ExpiresAt.After(now) {
|
isExpired := !existingSub.ExpiresAt.After(now)
|
||||||
|
if !isExpired {
|
||||||
// 未过期:从当前过期时间累加
|
// 未过期:从当前过期时间累加
|
||||||
newExpiresAt = existingSub.ExpiresAt.AddDate(0, 0, validityDays)
|
newExpiresAt = existingSub.ExpiresAt.AddDate(0, 0, validityDays)
|
||||||
} else {
|
} else {
|
||||||
@ -209,43 +210,8 @@ func (s *SubscriptionService) AssignOrExtendSubscription(ctx context.Context, in
|
|||||||
newExpiresAt = MaxExpiresAt
|
newExpiresAt = MaxExpiresAt
|
||||||
}
|
}
|
||||||
|
|
||||||
// 开启事务:ExtendExpiry + UpdateStatus + UpdateNotes 在同一事务中完成
|
if err := s.updateExistingSubscriptionTerm(ctx, existingSub, input.Notes, now, newExpiresAt, isExpired); err != nil {
|
||||||
tx, err := s.entClient.Tx(ctx)
|
return nil, false, err
|
||||||
if err != nil {
|
|
||||||
return nil, false, fmt.Errorf("begin transaction: %w", err)
|
|
||||||
}
|
|
||||||
txCtx := dbent.NewTxContext(ctx, tx)
|
|
||||||
|
|
||||||
// 更新过期时间
|
|
||||||
if err := s.userSubRepo.ExtendExpiry(txCtx, existingSub.ID, newExpiresAt); err != nil {
|
|
||||||
_ = tx.Rollback()
|
|
||||||
return nil, false, fmt.Errorf("extend subscription: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 如果订阅已过期或被暂停,恢复为active状态
|
|
||||||
if existingSub.Status != SubscriptionStatusActive {
|
|
||||||
if err := s.userSubRepo.UpdateStatus(txCtx, existingSub.ID, SubscriptionStatusActive); err != nil {
|
|
||||||
_ = tx.Rollback()
|
|
||||||
return nil, false, fmt.Errorf("update subscription status: %w", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 追加备注
|
|
||||||
if input.Notes != "" {
|
|
||||||
newNotes := existingSub.Notes
|
|
||||||
if newNotes != "" {
|
|
||||||
newNotes += "\n"
|
|
||||||
}
|
|
||||||
newNotes += input.Notes
|
|
||||||
if err := s.userSubRepo.UpdateNotes(txCtx, existingSub.ID, newNotes); err != nil {
|
|
||||||
_ = tx.Rollback()
|
|
||||||
return nil, false, fmt.Errorf("update subscription notes: %w", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 提交事务
|
|
||||||
if err := tx.Commit(); err != nil {
|
|
||||||
return nil, false, fmt.Errorf("commit transaction: %w", err)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 失效订阅缓存
|
// 失效订阅缓存
|
||||||
@ -284,6 +250,94 @@ func (s *SubscriptionService) AssignOrExtendSubscription(ctx context.Context, in
|
|||||||
return sub, false, nil // false 表示是新建
|
return sub, false, nil // false 表示是新建
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *SubscriptionService) updateExistingSubscriptionTerm(
|
||||||
|
ctx context.Context,
|
||||||
|
existingSub *UserSubscription,
|
||||||
|
notes string,
|
||||||
|
startsAt time.Time,
|
||||||
|
newExpiresAt time.Time,
|
||||||
|
isExpired bool,
|
||||||
|
) error {
|
||||||
|
return s.withSubscriptionUpdateTx(ctx, func(txCtx context.Context) error {
|
||||||
|
if isExpired {
|
||||||
|
renewed := renewedSubscriptionTerm(existingSub, notes, startsAt, newExpiresAt)
|
||||||
|
if err := s.userSubRepo.Update(txCtx, renewed); err != nil {
|
||||||
|
return fmt.Errorf("renew expired subscription: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新过期时间
|
||||||
|
if err := s.userSubRepo.ExtendExpiry(txCtx, existingSub.ID, newExpiresAt); err != nil {
|
||||||
|
return fmt.Errorf("extend subscription: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果订阅被暂停,恢复为 active 状态
|
||||||
|
if existingSub.Status != SubscriptionStatusActive {
|
||||||
|
if err := s.userSubRepo.UpdateStatus(txCtx, existingSub.ID, SubscriptionStatusActive); err != nil {
|
||||||
|
return fmt.Errorf("update subscription status: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 追加备注
|
||||||
|
if notes != "" {
|
||||||
|
if err := s.userSubRepo.UpdateNotes(txCtx, existingSub.ID, appendSubscriptionNotes(existingSub.Notes, notes)); err != nil {
|
||||||
|
return fmt.Errorf("update subscription notes: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SubscriptionService) withSubscriptionUpdateTx(ctx context.Context, fn func(context.Context) error) error {
|
||||||
|
if s.entClient == nil {
|
||||||
|
return fn(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
tx, err := s.entClient.Tx(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("begin transaction: %w", err)
|
||||||
|
}
|
||||||
|
txCtx := dbent.NewTxContext(ctx, tx)
|
||||||
|
|
||||||
|
if err := fn(txCtx); err != nil {
|
||||||
|
_ = tx.Rollback()
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := tx.Commit(); err != nil {
|
||||||
|
return fmt.Errorf("commit transaction: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func renewedSubscriptionTerm(existingSub *UserSubscription, notes string, startsAt, expiresAt time.Time) *UserSubscription {
|
||||||
|
renewed := *existingSub
|
||||||
|
windowStart := startOfDay(startsAt)
|
||||||
|
renewed.StartsAt = startsAt
|
||||||
|
renewed.ExpiresAt = expiresAt
|
||||||
|
renewed.Status = SubscriptionStatusActive
|
||||||
|
renewed.DailyWindowStart = &windowStart
|
||||||
|
renewed.WeeklyWindowStart = &windowStart
|
||||||
|
renewed.MonthlyWindowStart = &windowStart
|
||||||
|
renewed.DailyUsageUSD = 0
|
||||||
|
renewed.WeeklyUsageUSD = 0
|
||||||
|
renewed.MonthlyUsageUSD = 0
|
||||||
|
renewed.Notes = appendSubscriptionNotes(existingSub.Notes, notes)
|
||||||
|
return &renewed
|
||||||
|
}
|
||||||
|
|
||||||
|
func appendSubscriptionNotes(existingNotes, newNotes string) string {
|
||||||
|
if newNotes == "" {
|
||||||
|
return existingNotes
|
||||||
|
}
|
||||||
|
if existingNotes == "" {
|
||||||
|
return newNotes
|
||||||
|
}
|
||||||
|
return existingNotes + "\n" + newNotes
|
||||||
|
}
|
||||||
|
|
||||||
// createSubscription 创建新订阅(内部方法)
|
// createSubscription 创建新订阅(内部方法)
|
||||||
func (s *SubscriptionService) createSubscription(ctx context.Context, input *AssignSubscriptionInput) (*UserSubscription, error) {
|
func (s *SubscriptionService) createSubscription(ctx context.Context, input *AssignSubscriptionInput) (*UserSubscription, error) {
|
||||||
validityDays := input.ValidityDays
|
validityDays := input.ValidityDays
|
||||||
@ -945,6 +999,9 @@ func (s *SubscriptionService) calculateProgress(sub *UserSubscription, group *Gr
|
|||||||
if group.HasDailyLimit() && sub.DailyWindowStart != nil {
|
if group.HasDailyLimit() && sub.DailyWindowStart != nil {
|
||||||
limit := *group.DailyLimitUSD
|
limit := *group.DailyLimitUSD
|
||||||
resetsAt := sub.DailyWindowStart.Add(24 * time.Hour)
|
resetsAt := sub.DailyWindowStart.Add(24 * time.Hour)
|
||||||
|
if dailyResetTime := sub.DailyResetTime(); dailyResetTime != nil {
|
||||||
|
resetsAt = *dailyResetTime
|
||||||
|
}
|
||||||
progress.Daily = &UsageWindowProgress{
|
progress.Daily = &UsageWindowProgress{
|
||||||
LimitUSD: limit,
|
LimitUSD: limit,
|
||||||
UsedUSD: sub.DailyUsageUSD,
|
UsedUSD: sub.DailyUsageUSD,
|
||||||
|
|||||||
@ -50,11 +50,25 @@ func (s *UserSubscription) IsWindowActivated() bool {
|
|||||||
return s.DailyWindowStart != nil || s.WeeklyWindowStart != nil || s.MonthlyWindowStart != nil
|
return s.DailyWindowStart != nil || s.WeeklyWindowStart != nil || s.MonthlyWindowStart != nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *UserSubscription) HasOneTimeDailyQuota() bool {
|
||||||
|
if s == nil || s.StartsAt.IsZero() || s.ExpiresAt.IsZero() {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return !s.ExpiresAt.After(s.StartsAt.AddDate(0, 0, 1))
|
||||||
|
}
|
||||||
|
|
||||||
func (s *UserSubscription) NeedsDailyReset() bool {
|
func (s *UserSubscription) NeedsDailyReset() bool {
|
||||||
|
return s.NeedsDailyResetAt(time.Now())
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *UserSubscription) NeedsDailyResetAt(now time.Time) bool {
|
||||||
if s.DailyWindowStart == nil {
|
if s.DailyWindowStart == nil {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
return time.Since(*s.DailyWindowStart) >= 24*time.Hour
|
if s.HasOneTimeDailyQuota() {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return !now.Before(s.DailyWindowStart.Add(24 * time.Hour))
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *UserSubscription) NeedsWeeklyReset() bool {
|
func (s *UserSubscription) NeedsWeeklyReset() bool {
|
||||||
@ -75,6 +89,10 @@ func (s *UserSubscription) DailyResetTime() *time.Time {
|
|||||||
if s.DailyWindowStart == nil {
|
if s.DailyWindowStart == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
if s.HasOneTimeDailyQuota() {
|
||||||
|
t := s.ExpiresAt
|
||||||
|
return &t
|
||||||
|
}
|
||||||
t := s.DailyWindowStart.Add(24 * time.Hour)
|
t := s.DailyWindowStart.Add(24 * time.Hour)
|
||||||
return &t
|
return &t
|
||||||
}
|
}
|
||||||
|
|||||||
178
backend/internal/service/user_subscription_daily_quota_test.go
Normal file
178
backend/internal/service/user_subscription_daily_quota_test.go
Normal file
@ -0,0 +1,178 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
type dailyResetTrackingUserSubRepo struct {
|
||||||
|
userSubRepoNoop
|
||||||
|
|
||||||
|
resetDailyCalled bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *dailyResetTrackingUserSubRepo) ResetDailyUsage(context.Context, int64, time.Time) error {
|
||||||
|
r.resetDailyCalled = true
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAssignOrExtendSubscription_ExpiredDailyCardStartsNewOneTimeQuota(t *testing.T) {
|
||||||
|
groupRepo := &subscriptionGroupRepoStub{
|
||||||
|
group: &Group{ID: 1, SubscriptionType: SubscriptionTypeSubscription},
|
||||||
|
}
|
||||||
|
subRepo := newSubscriptionUserSubRepoStub()
|
||||||
|
oldStart := time.Now().AddDate(0, 0, -3)
|
||||||
|
oldWindowStart := startOfDay(oldStart)
|
||||||
|
subRepo.seed(&UserSubscription{
|
||||||
|
ID: 100,
|
||||||
|
UserID: 200,
|
||||||
|
GroupID: 1,
|
||||||
|
StartsAt: oldStart,
|
||||||
|
ExpiresAt: oldStart.AddDate(0, 0, 1),
|
||||||
|
Status: SubscriptionStatusExpired,
|
||||||
|
DailyWindowStart: &oldWindowStart,
|
||||||
|
WeeklyWindowStart: &oldWindowStart,
|
||||||
|
MonthlyWindowStart: &oldWindowStart,
|
||||||
|
DailyUsageUSD: 10,
|
||||||
|
WeeklyUsageUSD: 20,
|
||||||
|
MonthlyUsageUSD: 30,
|
||||||
|
Notes: "old",
|
||||||
|
})
|
||||||
|
svc := NewSubscriptionService(groupRepo, subRepo, nil, nil, nil)
|
||||||
|
|
||||||
|
renewed, reused, err := svc.AssignOrExtendSubscription(context.Background(), &AssignSubscriptionInput{
|
||||||
|
UserID: 200,
|
||||||
|
GroupID: 1,
|
||||||
|
ValidityDays: 1,
|
||||||
|
Notes: "new",
|
||||||
|
})
|
||||||
|
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.True(t, reused)
|
||||||
|
require.True(t, renewed.HasOneTimeDailyQuota(), "过期后重新购买 1 日卡仍应被识别为一次性日额度")
|
||||||
|
require.Equal(t, SubscriptionStatusActive, renewed.Status)
|
||||||
|
require.True(t, renewed.StartsAt.After(oldStart), "重新购买过期订阅时应重置当前周期 StartsAt")
|
||||||
|
require.False(t, renewed.ExpiresAt.After(renewed.StartsAt.AddDate(0, 0, 1)))
|
||||||
|
require.NotNil(t, renewed.DailyWindowStart)
|
||||||
|
require.Equal(t, startOfDay(renewed.StartsAt), *renewed.DailyWindowStart)
|
||||||
|
require.Equal(t, 0.0, renewed.DailyUsageUSD)
|
||||||
|
require.Equal(t, 0.0, renewed.WeeklyUsageUSD)
|
||||||
|
require.Equal(t, 0.0, renewed.MonthlyUsageUSD)
|
||||||
|
require.Equal(t, "old\nnew", 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)
|
||||||
|
sub := &UserSubscription{
|
||||||
|
StartsAt: start,
|
||||||
|
ExpiresAt: start.Add(24 * time.Hour),
|
||||||
|
DailyWindowStart: &dailyWindowStart,
|
||||||
|
DailyUsageUSD: 10,
|
||||||
|
}
|
||||||
|
|
||||||
|
require.True(t, sub.HasOneTimeDailyQuota())
|
||||||
|
require.False(t, sub.NeedsDailyResetAt(dailyWindowStart.Add(25*time.Hour)), "日卡应作为一次性配额,跨 0 点后不再刷新日额度")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUserSubscriptionNeedsDailyReset_MultiDaySubscriptionStillRefreshes(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)
|
||||||
|
sub := &UserSubscription{
|
||||||
|
StartsAt: start,
|
||||||
|
ExpiresAt: start.AddDate(0, 0, 2),
|
||||||
|
DailyWindowStart: &dailyWindowStart,
|
||||||
|
}
|
||||||
|
|
||||||
|
require.False(t, sub.HasOneTimeDailyQuota())
|
||||||
|
require.True(t, sub.NeedsDailyResetAt(dailyWindowStart.Add(24*time.Hour)), "多日订阅仍应按 24 小时日窗口刷新")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUserSubscriptionDailyResetTime_DailyCardReturnsExpiry(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)
|
||||||
|
expiresAt := start.Add(24 * time.Hour)
|
||||||
|
sub := &UserSubscription{
|
||||||
|
StartsAt: start,
|
||||||
|
ExpiresAt: expiresAt,
|
||||||
|
DailyWindowStart: &dailyWindowStart,
|
||||||
|
}
|
||||||
|
|
||||||
|
resetAt := sub.DailyResetTime()
|
||||||
|
require.NotNil(t, resetAt)
|
||||||
|
require.Equal(t, expiresAt, *resetAt, "日卡展示的日额度结束时间应为订阅过期时间")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCheckAndResetWindows_DailyCardDoesNotResetDailyUsage(t *testing.T) {
|
||||||
|
now := time.Now()
|
||||||
|
startsAt := now.Add(-23 * time.Hour)
|
||||||
|
dailyWindowStart := now.Add(-25 * time.Hour)
|
||||||
|
repo := &dailyResetTrackingUserSubRepo{}
|
||||||
|
svc := NewSubscriptionService(groupRepoNoop{}, repo, nil, nil, nil)
|
||||||
|
sub := &UserSubscription{
|
||||||
|
ID: 1,
|
||||||
|
UserID: 10,
|
||||||
|
GroupID: 20,
|
||||||
|
StartsAt: startsAt,
|
||||||
|
ExpiresAt: startsAt.Add(24 * time.Hour),
|
||||||
|
DailyUsageUSD: 10,
|
||||||
|
DailyWindowStart: &dailyWindowStart,
|
||||||
|
}
|
||||||
|
|
||||||
|
err := svc.CheckAndResetWindows(context.Background(), sub)
|
||||||
|
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.False(t, repo.resetDailyCalled, "日卡作为一次性配额,过了 24 小时日窗口也不应重置 daily usage")
|
||||||
|
require.Equal(t, 10.0, sub.DailyUsageUSD)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCheckAndResetWindows_MultiDaySubscriptionStillResetsDailyUsage(t *testing.T) {
|
||||||
|
now := time.Now()
|
||||||
|
startsAt := now.Add(-48 * time.Hour)
|
||||||
|
dailyWindowStart := now.Add(-25 * time.Hour)
|
||||||
|
repo := &dailyResetTrackingUserSubRepo{}
|
||||||
|
svc := NewSubscriptionService(groupRepoNoop{}, repo, nil, nil, nil)
|
||||||
|
sub := &UserSubscription{
|
||||||
|
ID: 1,
|
||||||
|
UserID: 10,
|
||||||
|
GroupID: 20,
|
||||||
|
StartsAt: startsAt,
|
||||||
|
ExpiresAt: startsAt.AddDate(0, 0, 2),
|
||||||
|
DailyUsageUSD: 10,
|
||||||
|
DailyWindowStart: &dailyWindowStart,
|
||||||
|
}
|
||||||
|
|
||||||
|
err := svc.CheckAndResetWindows(context.Background(), sub)
|
||||||
|
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.True(t, repo.resetDailyCalled, "多日订阅仍应重置过期 daily window")
|
||||||
|
require.Equal(t, 0.0, sub.DailyUsageUSD)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateAndCheckLimits_DailyCardDoesNotAllowSecondQuotaAfterMidnight(t *testing.T) {
|
||||||
|
start := time.Now().Add(-23 * time.Hour)
|
||||||
|
dailyWindowStart := time.Now().Add(-25 * time.Hour)
|
||||||
|
dailyLimit := 10.0
|
||||||
|
sub := &UserSubscription{
|
||||||
|
Status: SubscriptionStatusActive,
|
||||||
|
StartsAt: start,
|
||||||
|
ExpiresAt: start.Add(24 * time.Hour),
|
||||||
|
DailyWindowStart: &dailyWindowStart,
|
||||||
|
DailyUsageUSD: dailyLimit + 0.01,
|
||||||
|
}
|
||||||
|
group := &Group{
|
||||||
|
SubscriptionType: SubscriptionTypeSubscription,
|
||||||
|
DailyLimitUSD: &dailyLimit,
|
||||||
|
}
|
||||||
|
svc := NewSubscriptionService(groupRepoNoop{}, userSubRepoNoop{}, nil, nil, nil)
|
||||||
|
|
||||||
|
needsMaintenance, err := svc.ValidateAndCheckLimits(sub, group)
|
||||||
|
|
||||||
|
require.False(t, needsMaintenance, "日卡跨过日窗口后不应触发 daily reset 维护")
|
||||||
|
require.True(t, errors.Is(err, ErrDailyLimitExceeded))
|
||||||
|
require.Equal(t, dailyLimit+0.01, sub.DailyUsageUSD, "热路径不应清零日卡已用额度")
|
||||||
|
}
|
||||||
@ -2683,6 +2683,9 @@ export default {
|
|||||||
resetInMinutes: 'Resets in {minutes}m',
|
resetInMinutes: 'Resets in {minutes}m',
|
||||||
resetInHoursMinutes: 'Resets in {hours}h {minutes}m',
|
resetInHoursMinutes: 'Resets in {hours}h {minutes}m',
|
||||||
resetInDaysHours: 'Resets in {days}d {hours}h',
|
resetInDaysHours: 'Resets in {days}d {hours}h',
|
||||||
|
quotaEndsInMinutes: 'Quota ends in {minutes}m',
|
||||||
|
quotaEndsInHoursMinutes: 'Quota ends in {hours}h {minutes}m',
|
||||||
|
quotaEndsInDaysHours: 'Quota ends in {days}d {hours}h',
|
||||||
daysRemaining: 'days remaining',
|
daysRemaining: 'days remaining',
|
||||||
remainingDays: 'Remaining days',
|
remainingDays: 'Remaining days',
|
||||||
noExpiration: 'No expiration',
|
noExpiration: 'No expiration',
|
||||||
@ -6284,6 +6287,7 @@ export default {
|
|||||||
daysRemaining: '{days} days remaining',
|
daysRemaining: '{days} days remaining',
|
||||||
expiresOn: 'Expires on {date}',
|
expiresOn: 'Expires on {date}',
|
||||||
resetIn: 'Resets in {time}',
|
resetIn: 'Resets in {time}',
|
||||||
|
quotaEndsIn: 'Quota ends in {time}',
|
||||||
windowNotActive: 'Awaiting first use',
|
windowNotActive: 'Awaiting first use',
|
||||||
usageOf: '{used} of {limit}'
|
usageOf: '{used} of {limit}'
|
||||||
},
|
},
|
||||||
|
|||||||
@ -2760,6 +2760,9 @@ export default {
|
|||||||
resetInMinutes: '{minutes} 分钟后重置',
|
resetInMinutes: '{minutes} 分钟后重置',
|
||||||
resetInHoursMinutes: '{hours} 小时 {minutes} 分钟后重置',
|
resetInHoursMinutes: '{hours} 小时 {minutes} 分钟后重置',
|
||||||
resetInDaysHours: '{days} 天 {hours} 小时后重置',
|
resetInDaysHours: '{days} 天 {hours} 小时后重置',
|
||||||
|
quotaEndsInMinutes: '额度将在 {minutes} 分钟后结束',
|
||||||
|
quotaEndsInHoursMinutes: '额度将在 {hours} 小时 {minutes} 分钟后结束',
|
||||||
|
quotaEndsInDaysHours: '额度将在 {days} 天 {hours} 小时后结束',
|
||||||
daysRemaining: '天剩余',
|
daysRemaining: '天剩余',
|
||||||
remainingDays: '剩余天数',
|
remainingDays: '剩余天数',
|
||||||
noExpiration: '无过期时间',
|
noExpiration: '无过期时间',
|
||||||
@ -6442,6 +6445,7 @@ export default {
|
|||||||
daysRemaining: '剩余 {days} 天',
|
daysRemaining: '剩余 {days} 天',
|
||||||
expiresOn: '{date} 到期',
|
expiresOn: '{date} 到期',
|
||||||
resetIn: '{time} 后重置',
|
resetIn: '{time} 后重置',
|
||||||
|
quotaEndsIn: '额度将在 {time} 后结束',
|
||||||
windowNotActive: '等待首次使用',
|
windowNotActive: '等待首次使用',
|
||||||
usageOf: '已用 {used} / {limit}'
|
usageOf: '已用 {used} / {limit}'
|
||||||
},
|
},
|
||||||
|
|||||||
@ -1492,6 +1492,7 @@ export interface UserSubscription {
|
|||||||
user_id: number
|
user_id: number
|
||||||
group_id: number
|
group_id: number
|
||||||
status: 'active' | 'expired' | 'revoked'
|
status: 'active' | 'expired' | 'revoked'
|
||||||
|
starts_at: string
|
||||||
daily_usage_usd: number
|
daily_usage_usd: number
|
||||||
weekly_usage_usd: number
|
weekly_usage_usd: number
|
||||||
monthly_usage_usd: number
|
monthly_usage_usd: number
|
||||||
|
|||||||
42
frontend/src/utils/subscriptionQuota.ts
Normal file
42
frontend/src/utils/subscriptionQuota.ts
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
import type { UserSubscription } from '@/types'
|
||||||
|
|
||||||
|
const ONE_DAY_MS = 24 * 60 * 60 * 1000
|
||||||
|
|
||||||
|
export interface RemainingDurationParts {
|
||||||
|
days: number
|
||||||
|
hours: number
|
||||||
|
minutes: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isOneTimeDailyQuota(
|
||||||
|
subscription: Pick<UserSubscription, 'starts_at' | 'expires_at'>
|
||||||
|
): boolean {
|
||||||
|
if (!subscription.starts_at || !subscription.expires_at) return false
|
||||||
|
|
||||||
|
const startsAt = new Date(subscription.starts_at).getTime()
|
||||||
|
const expiresAt = new Date(subscription.expires_at).getTime()
|
||||||
|
|
||||||
|
if (!Number.isFinite(startsAt) || !Number.isFinite(expiresAt)) return false
|
||||||
|
|
||||||
|
return expiresAt <= startsAt + ONE_DAY_MS
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getRemainingDurationParts(
|
||||||
|
targetAt: Date | string,
|
||||||
|
now: Date = new Date()
|
||||||
|
): RemainingDurationParts | null {
|
||||||
|
const targetTime = targetAt instanceof Date ? targetAt.getTime() : new Date(targetAt).getTime()
|
||||||
|
const nowTime = now.getTime()
|
||||||
|
|
||||||
|
if (!Number.isFinite(targetTime) || !Number.isFinite(nowTime)) return null
|
||||||
|
|
||||||
|
const diffMs = targetTime - nowTime
|
||||||
|
if (diffMs <= 0) return null
|
||||||
|
|
||||||
|
const totalMinutes = Math.floor(diffMs / (1000 * 60))
|
||||||
|
const days = Math.floor(totalMinutes / (24 * 60))
|
||||||
|
const hours = Math.floor((totalMinutes % (24 * 60)) / 60)
|
||||||
|
const minutes = totalMinutes % 60
|
||||||
|
|
||||||
|
return { days, hours, minutes }
|
||||||
|
}
|
||||||
@ -246,7 +246,7 @@
|
|||||||
d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"
|
d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
<span>{{ formatResetTime(row.daily_window_start, 'daily') }}</span>
|
<span>{{ formatDailyUsageWindow(row) }}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -758,6 +758,7 @@ import Select from '@/components/common/Select.vue'
|
|||||||
import GroupBadge from '@/components/common/GroupBadge.vue'
|
import GroupBadge from '@/components/common/GroupBadge.vue'
|
||||||
import GroupOptionItem from '@/components/common/GroupOptionItem.vue'
|
import GroupOptionItem from '@/components/common/GroupOptionItem.vue'
|
||||||
import Icon from '@/components/icons/Icon.vue'
|
import Icon from '@/components/icons/Icon.vue'
|
||||||
|
import { getRemainingDurationParts, isOneTimeDailyQuota, type RemainingDurationParts } from '@/utils/subscriptionQuota'
|
||||||
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
const appStore = useAppStore()
|
const appStore = useAppStore()
|
||||||
@ -1313,8 +1314,41 @@ const getProgressClass = (used: number | null | undefined, limit: number | null)
|
|||||||
return 'bg-green-500'
|
return 'bg-green-500'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const formatResetDuration = (parts: RemainingDurationParts): string => {
|
||||||
|
if (parts.days > 0) {
|
||||||
|
return t('admin.subscriptions.resetInDaysHours', { days: parts.days, hours: parts.hours })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parts.hours > 0) {
|
||||||
|
return t('admin.subscriptions.resetInHoursMinutes', { hours: parts.hours, minutes: parts.minutes })
|
||||||
|
}
|
||||||
|
|
||||||
|
return t('admin.subscriptions.resetInMinutes', { minutes: parts.minutes })
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatQuotaEndDuration = (parts: RemainingDurationParts): string => {
|
||||||
|
if (parts.days > 0) {
|
||||||
|
return t('admin.subscriptions.quotaEndsInDaysHours', { days: parts.days, hours: parts.hours })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parts.hours > 0) {
|
||||||
|
return t('admin.subscriptions.quotaEndsInHoursMinutes', { hours: parts.hours, minutes: parts.minutes })
|
||||||
|
}
|
||||||
|
|
||||||
|
return t('admin.subscriptions.quotaEndsInMinutes', { minutes: parts.minutes })
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatDailyUsageWindow = (subscription: UserSubscription): string => {
|
||||||
|
if (isOneTimeDailyQuota(subscription) && subscription.expires_at) {
|
||||||
|
const parts = getRemainingDurationParts(subscription.expires_at)
|
||||||
|
return parts ? formatQuotaEndDuration(parts) : t('admin.subscriptions.windowNotActive')
|
||||||
|
}
|
||||||
|
|
||||||
|
return formatResetTime(subscription.daily_window_start, 'daily')
|
||||||
|
}
|
||||||
|
|
||||||
// Format reset time based on window start and period type
|
// Format reset time based on window start and period type
|
||||||
const formatResetTime = (windowStart: string, period: 'daily' | 'weekly' | 'monthly'): string => {
|
const formatResetTime = (windowStart: string | null, period: 'daily' | 'weekly' | 'monthly'): string => {
|
||||||
if (!windowStart) return t('admin.subscriptions.windowNotActive')
|
if (!windowStart) return t('admin.subscriptions.windowNotActive')
|
||||||
|
|
||||||
const start = new Date(windowStart)
|
const start = new Date(windowStart)
|
||||||
@ -1334,21 +1368,9 @@ const formatResetTime = (windowStart: string, period: 'daily' | 'weekly' | 'mont
|
|||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
const diffMs = resetTime.getTime() - now.getTime()
|
const parts = getRemainingDurationParts(resetTime, now)
|
||||||
if (diffMs <= 0) return t('admin.subscriptions.windowNotActive')
|
|
||||||
|
|
||||||
const diffSeconds = Math.floor(diffMs / 1000)
|
return parts ? formatResetDuration(parts) : t('admin.subscriptions.windowNotActive')
|
||||||
const days = Math.floor(diffSeconds / 86400)
|
|
||||||
const hours = Math.floor((diffSeconds % 86400) / 3600)
|
|
||||||
const minutes = Math.floor((diffSeconds % 3600) / 60)
|
|
||||||
|
|
||||||
if (days > 0) {
|
|
||||||
return t('admin.subscriptions.resetInDaysHours', { days, hours })
|
|
||||||
} else if (hours > 0) {
|
|
||||||
return t('admin.subscriptions.resetInHoursMinutes', { hours, minutes })
|
|
||||||
} else {
|
|
||||||
return t('admin.subscriptions.resetInMinutes', { minutes })
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle click outside to close dropdowns
|
// Handle click outside to close dropdowns
|
||||||
|
|||||||
@ -127,11 +127,7 @@
|
|||||||
v-if="subscription.daily_window_start"
|
v-if="subscription.daily_window_start"
|
||||||
class="text-xs text-gray-500 dark:text-dark-400"
|
class="text-xs text-gray-500 dark:text-dark-400"
|
||||||
>
|
>
|
||||||
{{
|
{{ formatDailyUsageWindow(subscription) }}
|
||||||
t('userSubscriptions.resetIn', {
|
|
||||||
time: formatResetTime(subscription.daily_window_start, 24)
|
|
||||||
})
|
|
||||||
}}
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -256,6 +252,7 @@ import AppLayout from '@/components/layout/AppLayout.vue'
|
|||||||
import Icon from '@/components/icons/Icon.vue'
|
import Icon from '@/components/icons/Icon.vue'
|
||||||
import { formatDateOnly } from '@/utils/format'
|
import { formatDateOnly } from '@/utils/format'
|
||||||
import { platformBorderClass, platformBadgeClass, platformButtonClass, platformLabel } from '@/utils/platformColors'
|
import { platformBorderClass, platformBadgeClass, platformButtonClass, platformLabel } from '@/utils/platformColors'
|
||||||
|
import { getRemainingDurationParts, isOneTimeDailyQuota, type RemainingDurationParts } from '@/utils/subscriptionQuota'
|
||||||
|
|
||||||
function platformAccentDotClass(p: string): string {
|
function platformAccentDotClass(p: string): string {
|
||||||
switch (p) {
|
switch (p) {
|
||||||
@ -334,30 +331,38 @@ function getExpirationClass(expiresAt: string): string {
|
|||||||
return 'text-gray-700 dark:text-gray-300'
|
return 'text-gray-700 dark:text-gray-300'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatDurationParts(parts: RemainingDurationParts): string {
|
||||||
|
if (parts.days > 0) {
|
||||||
|
return `${parts.days}d ${parts.hours}h`
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parts.hours > 0) {
|
||||||
|
return `${parts.hours}h ${parts.minutes}m`
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${parts.minutes}m`
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDailyUsageWindow(subscription: UserSubscription): string {
|
||||||
|
if (isOneTimeDailyQuota(subscription) && subscription.expires_at) {
|
||||||
|
const parts = getRemainingDurationParts(subscription.expires_at)
|
||||||
|
if (!parts) return t('userSubscriptions.windowNotActive')
|
||||||
|
return t('userSubscriptions.quotaEndsIn', { time: formatDurationParts(parts) })
|
||||||
|
}
|
||||||
|
|
||||||
|
return t('userSubscriptions.resetIn', {
|
||||||
|
time: formatResetTime(subscription.daily_window_start, 24)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
function formatResetTime(windowStart: string | null, windowHours: number): string {
|
function formatResetTime(windowStart: string | null, windowHours: number): string {
|
||||||
if (!windowStart) return t('userSubscriptions.windowNotActive')
|
if (!windowStart) return t('userSubscriptions.windowNotActive')
|
||||||
|
|
||||||
const start = new Date(windowStart)
|
const start = new Date(windowStart)
|
||||||
const end = new Date(start.getTime() + windowHours * 60 * 60 * 1000)
|
const end = new Date(start.getTime() + windowHours * 60 * 60 * 1000)
|
||||||
const now = new Date()
|
const parts = getRemainingDurationParts(end)
|
||||||
const diff = end.getTime() - now.getTime()
|
|
||||||
|
|
||||||
if (diff <= 0) return t('userSubscriptions.windowNotActive')
|
return parts ? formatDurationParts(parts) : t('userSubscriptions.windowNotActive')
|
||||||
|
|
||||||
const hours = Math.floor(diff / (1000 * 60 * 60))
|
|
||||||
const minutes = Math.floor((diff % (1000 * 60 * 60)) / (1000 * 60))
|
|
||||||
|
|
||||||
if (hours > 24) {
|
|
||||||
const days = Math.floor(hours / 24)
|
|
||||||
const remainingHours = hours % 24
|
|
||||||
return `${days}d ${remainingHours}h`
|
|
||||||
}
|
|
||||||
|
|
||||||
if (hours > 0) {
|
|
||||||
return `${hours}h ${minutes}m`
|
|
||||||
}
|
|
||||||
|
|
||||||
return `${minutes}m`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user