diff --git a/backend/internal/handler/admin/redeem_handler.go b/backend/internal/handler/admin/redeem_handler.go index 4e8e48e6..45fae43a 100644 --- a/backend/internal/handler/admin/redeem_handler.go +++ b/backend/internal/handler/admin/redeem_handler.go @@ -30,8 +30,8 @@ type GenerateRedeemCodesRequest struct { Count int `json:"count" binding:"required,min=1,max=100"` Type string `json:"type" binding:"required,oneof=balance concurrency subscription"` Value float64 `json:"value" binding:"min=0"` - GroupID *int64 `json:"group_id"` // 订阅类型必填 - ValidityDays int `json:"validity_days"` // 订阅类型使用,默认30天 + GroupID *int64 `json:"group_id"` // 订阅类型必填 + ValidityDays int `json:"validity_days" binding:"omitempty,max=36500"` // 订阅类型使用,默认30天,最大100年 } // List handles listing all redeem codes with pagination diff --git a/backend/internal/handler/admin/subscription_handler.go b/backend/internal/handler/admin/subscription_handler.go index ca69eb05..08db999a 100644 --- a/backend/internal/handler/admin/subscription_handler.go +++ b/backend/internal/handler/admin/subscription_handler.go @@ -41,7 +41,7 @@ func NewSubscriptionHandler(subscriptionService *service.SubscriptionService) *S type AssignSubscriptionRequest struct { UserID int64 `json:"user_id" binding:"required"` GroupID int64 `json:"group_id" binding:"required"` - ValidityDays int `json:"validity_days"` + ValidityDays int `json:"validity_days" binding:"omitempty,max=36500"` // max 100 years Notes string `json:"notes"` } @@ -49,13 +49,13 @@ type AssignSubscriptionRequest struct { type BulkAssignSubscriptionRequest struct { UserIDs []int64 `json:"user_ids" binding:"required,min=1"` GroupID int64 `json:"group_id" binding:"required"` - ValidityDays int `json:"validity_days"` + ValidityDays int `json:"validity_days" binding:"omitempty,max=36500"` // max 100 years Notes string `json:"notes"` } // ExtendSubscriptionRequest represents extend subscription request type ExtendSubscriptionRequest struct { - Days int `json:"days" binding:"required,min=1"` + Days int `json:"days" binding:"required,min=1,max=36500"` // max 100 years } // List handles listing all subscriptions with pagination and filters diff --git a/backend/internal/repository/auto_migrate.go b/backend/internal/repository/auto_migrate.go index 6ca28036..9127eeb9 100644 --- a/backend/internal/repository/auto_migrate.go +++ b/backend/internal/repository/auto_migrate.go @@ -1,11 +1,20 @@ package repository -import "gorm.io/gorm" +import ( + "log" + "time" + + "gorm.io/gorm" +) + +// MaxExpiresAt is the maximum allowed expiration date for subscriptions (year 2099) +// This prevents time.Time JSON serialization errors (RFC 3339 requires year <= 9999) +var maxExpiresAt = time.Date(2099, 12, 31, 23, 59, 59, 0, time.UTC) // AutoMigrate runs schema migrations for all repository persistence models. // Persistence models are defined within individual `*_repo.go` files. func AutoMigrate(db *gorm.DB) error { - return db.AutoMigrate( + err := db.AutoMigrate( &userModel{}, &apiKeyModel{}, &groupModel{}, @@ -17,4 +26,24 @@ func AutoMigrate(db *gorm.DB) error { &settingModel{}, &userSubscriptionModel{}, ) + if err != nil { + return err + } + + // 修复无效的过期时间(年份超过 2099 会导致 JSON 序列化失败) + return fixInvalidExpiresAt(db) +} + +// fixInvalidExpiresAt 修复 user_subscriptions 表中无效的过期时间 +func fixInvalidExpiresAt(db *gorm.DB) error { + result := db.Model(&userSubscriptionModel{}). + Where("expires_at > ?", maxExpiresAt). + Update("expires_at", maxExpiresAt) + if result.Error != nil { + return result.Error + } + if result.RowsAffected > 0 { + log.Printf("[AutoMigrate] Fixed %d subscriptions with invalid expires_at (year > 2099)", result.RowsAffected) + } + return nil } diff --git a/backend/internal/service/subscription_service.go b/backend/internal/service/subscription_service.go index 5b957094..fec6c147 100644 --- a/backend/internal/service/subscription_service.go +++ b/backend/internal/service/subscription_service.go @@ -10,6 +10,13 @@ import ( "github.com/Wei-Shaw/sub2api/internal/pkg/pagination" ) +// MaxExpiresAt is the maximum allowed expiration date (year 2099) +// This prevents time.Time JSON serialization errors (RFC 3339 requires year <= 9999) +var MaxExpiresAt = time.Date(2099, 12, 31, 23, 59, 59, 0, time.UTC) + +// MaxValidityDays is the maximum allowed validity days for subscriptions (100 years) +const MaxValidityDays = 36500 + var ( ErrSubscriptionNotFound = infraerrors.NotFound("SUBSCRIPTION_NOT_FOUND", "subscription not found") ErrSubscriptionExpired = infraerrors.Forbidden("SUBSCRIPTION_EXPIRED", "subscription has expired") @@ -111,6 +118,9 @@ func (s *SubscriptionService) AssignOrExtendSubscription(ctx context.Context, in if validityDays <= 0 { validityDays = 30 } + if validityDays > MaxValidityDays { + validityDays = MaxValidityDays + } // 已有订阅,执行续期 if existingSub != nil { @@ -125,6 +135,11 @@ func (s *SubscriptionService) AssignOrExtendSubscription(ctx context.Context, in newExpiresAt = now.AddDate(0, 0, validityDays) } + // 确保不超过最大过期时间 + if newExpiresAt.After(MaxExpiresAt) { + newExpiresAt = MaxExpiresAt + } + // 更新过期时间 if err := s.userSubRepo.ExtendExpiry(ctx, existingSub.ID, newExpiresAt); err != nil { return nil, false, fmt.Errorf("extend subscription: %w", err) @@ -189,13 +204,21 @@ func (s *SubscriptionService) createSubscription(ctx context.Context, input *Ass if validityDays <= 0 { validityDays = 30 } + if validityDays > MaxValidityDays { + validityDays = MaxValidityDays + } now := time.Now() + expiresAt := now.AddDate(0, 0, validityDays) + if expiresAt.After(MaxExpiresAt) { + expiresAt = MaxExpiresAt + } + sub := &UserSubscription{ UserID: input.UserID, GroupID: input.GroupID, StartsAt: now, - ExpiresAt: now.AddDate(0, 0, validityDays), + ExpiresAt: expiresAt, Status: SubscriptionStatusActive, AssignedAt: now, Notes: input.Notes, @@ -291,8 +314,17 @@ func (s *SubscriptionService) ExtendSubscription(ctx context.Context, subscripti return nil, ErrSubscriptionNotFound } + // 限制延长天数 + if days > MaxValidityDays { + days = MaxValidityDays + } + // 计算新的过期时间 newExpiresAt := sub.ExpiresAt.AddDate(0, 0, days) + if newExpiresAt.After(MaxExpiresAt) { + newExpiresAt = MaxExpiresAt + } + if err := s.userSubRepo.ExtendExpiry(ctx, subscriptionID, newExpiresAt); err != nil { return nil, err }