88 "errors"
99 "fmt"
1010 "math"
11+ "sort"
1112 "strconv"
1213 "strings"
1314 "sync"
@@ -64,6 +65,7 @@ type Service struct {
6465 platformModelIdentityResolver platformModelIdentityResolver
6566 modelPricingCatalog modelPricingCatalogProvider
6667 auditWriter auditWriter
68+ redemptionCodeSecret string
6769}
6870
6971type platformModelIdentityResolver interface {
@@ -268,6 +270,14 @@ func (s *Service) SetModelPricingCatalogProvider(provider modelPricingCatalogPro
268270 s .modelPricingCatalog = provider
269271}
270272
273+ // SetRedemptionCodeSecret 注入兑换码 HMAC 与密文存储密钥。
274+ func (s * Service ) SetRedemptionCodeSecret (secret string ) {
275+ if s == nil {
276+ return
277+ }
278+ s .redemptionCodeSecret = strings .TrimSpace (secret )
279+ }
280+
271281func (s * Service ) invalidatePublicModelPricingCache () {
272282 if s == nil {
273283 return
@@ -420,39 +430,24 @@ func (s *Service) ListCurrentSubscriptionSnapshots(
420430 return results , nil
421431 }
422432
423- subscriptions , err := s .repo . ListCurrentSubscriptionsByUserIDs (ctx , userIDs , now )
433+ subscriptions , planMap , err := s .listSubscriptionEntitlements (ctx , userIDs , now )
424434 if err != nil {
425435 return nil , err
426436 }
427437 if len (subscriptions ) == 0 {
428438 return results , nil
429439 }
430440
431- planIDs := make ([]uint , 0 , len (subscriptions ))
432- seenPlanIDs := make (map [uint ]struct {}, len (subscriptions ))
433- latestByUser := make (map [uint ]domainbilling.Subscription , len (subscriptions ))
441+ subscriptionsByUserID := make (map [uint ][]domainbilling.Subscription , len (userIDs ))
434442 for _ , item := range subscriptions {
435- if _ , exists := latestByUser [item .UserID ]; ! exists {
436- latestByUser [item .UserID ] = item
437- }
438- if _ , exists := seenPlanIDs [item .PlanID ]; exists {
439- continue
440- }
441- seenPlanIDs [item .PlanID ] = struct {}{}
442- planIDs = append (planIDs , item .PlanID )
443- }
444-
445- plans , err := s .repo .ListPlansByIDs (ctx , planIDs )
446- if err != nil {
447- return nil , err
443+ subscriptionsByUserID [item .UserID ] = append (subscriptionsByUserID [item .UserID ], item )
448444 }
449445
450- planMap := make (map [uint ]domainbilling.Plan , len (plans ))
451- for _ , item := range plans {
452- planMap [item .ID ] = item
453- }
454-
455- for userID , subscription := range latestByUser {
446+ for userID , items := range subscriptionsByUserID {
447+ subscription , ok := selectCurrentSubscription (items , planMap , now )
448+ if ! ok {
449+ continue
450+ }
456451 planID := subscription .PlanID
457452 planCode := ""
458453 planName := ""
@@ -462,7 +457,7 @@ func (s *Service) ListCurrentSubscriptionSnapshots(
462457 }
463458
464459 status := strings .TrimSpace (subscription .Status )
465- expiresAt := subscription . CurrentPeriodEndAt
460+ expiresAt := contiguousSubscriptionEnd ( subscription , items )
466461 if planCode == "free" {
467462 status = "free"
468463 expiresAt = nil
@@ -498,11 +493,24 @@ func (s *Service) Subscribe(ctx context.Context, userID uint, priceID uint, cycl
498493 if ! plan .IsActive || ! price .IsActive {
499494 return nil , repository .ErrNotFound
500495 }
496+ now := time .Now ()
497+ if strings .TrimSpace (plan .Code ) == "free" {
498+ subscriptions , planMap , entitlementErr := s .listSubscriptionEntitlements (ctx , []uint {userID }, now )
499+ if entitlementErr != nil {
500+ return nil , entitlementErr
501+ }
502+ for _ , subscription := range subscriptions {
503+ subscriptionPlan , ok := planMap [subscription .PlanID ]
504+ if ! ok || strings .TrimSpace (subscriptionPlan .Code ) == "free" {
505+ continue
506+ }
507+ return nil , ErrSubscriptionEntitlementActive
508+ }
509+ }
501510 if price .AmountCents > 0 {
502511 return nil , ErrPaymentRequired
503512 }
504513
505- now := time .Now ()
506514 endAt := resolvePeriodEnd (now , price .BillingInterval , cycles )
507515 item := & domainbilling.Subscription {
508516 UserID : userID ,
@@ -805,7 +813,7 @@ func (s *Service) CompletePaymentOrder(ctx context.Context, orderNo string, exte
805813 CanceledAt : nil ,
806814 AutoRenew : order .BillingInterval != domainbilling .IntervalLifetime ,
807815 }
808- return s .repo .MarkPaymentOrderPaidAndReplaceSubscription (ctx , orderNo , externalPaymentID , paidAt , subscription )
816+ return s .repo .MarkPaymentOrderPaidAndGrantSubscription (ctx , orderNo , externalPaymentID , paidAt , subscription )
809817}
810818
811819// UpdatePlan 保存周期套餐与默认价格。
@@ -996,24 +1004,221 @@ func (s *Service) currentPeriodPlan(
9961004 now time.Time ,
9971005) (domainbilling.Plan , time.Time , time.Time , error ) {
9981006 monthStart , monthEnd := monthBounds (now )
999- subscriptions , err := s .repo . ListCurrentSubscriptionsByUserIDs (ctx , []uint {userID }, now )
1007+ subscriptions , planMap , err := s .listSubscriptionEntitlements (ctx , []uint {userID }, now )
10001008 if err != nil {
10011009 return domainbilling.Plan {}, time.Time {}, time.Time {}, err
10021010 }
1003- if len (subscriptions ) == 0 {
1011+ subscription , ok := selectCurrentSubscription (subscriptions , planMap , now )
1012+ if ! ok {
10041013 plan , planErr := s .repo .GetActivePlanByCode (ctx , "free" )
10051014 if planErr != nil {
10061015 return domainbilling.Plan {}, time.Time {}, time.Time {}, planErr
10071016 }
10081017 return * plan , monthStart , monthEnd , nil
10091018 }
1019+ plan , ok := planMap [subscription .PlanID ]
1020+ if ! ok {
1021+ return domainbilling.Plan {}, time.Time {}, time.Time {}, repository .ErrNotFound
1022+ }
1023+ return plan , monthStart , monthEnd , nil
1024+ }
10101025
1011- subscription := subscriptions [0 ]
1012- plan , err := s .repo .GetPlanByID (ctx , subscription .PlanID )
1026+ func (s * Service ) listSubscriptionEntitlements (
1027+ ctx context.Context ,
1028+ userIDs []uint ,
1029+ now time.Time ,
1030+ ) ([]domainbilling.Subscription , map [uint ]domainbilling.Plan , error ) {
1031+ subscriptions , err := s .repo .ListSubscriptionEntitlementsByUserIDs (ctx , userIDs , now )
10131032 if err != nil {
1014- return domainbilling.Plan {}, time.Time {}, time.Time {}, err
1033+ return nil , nil , err
1034+ }
1035+ planIDs := make ([]uint , 0 , len (subscriptions ))
1036+ seenPlanIDs := make (map [uint ]struct {}, len (subscriptions ))
1037+ for _ , item := range subscriptions {
1038+ if item .PlanID == 0 {
1039+ continue
1040+ }
1041+ if _ , exists := seenPlanIDs [item .PlanID ]; exists {
1042+ continue
1043+ }
1044+ seenPlanIDs [item .PlanID ] = struct {}{}
1045+ planIDs = append (planIDs , item .PlanID )
1046+ }
1047+ plans , err := s .repo .ListPlansByIDs (ctx , planIDs )
1048+ if err != nil {
1049+ return nil , nil , err
1050+ }
1051+ planMap := make (map [uint ]domainbilling.Plan , len (plans ))
1052+ for _ , item := range plans {
1053+ planMap [item .ID ] = item
1054+ }
1055+ return subscriptions , planMap , nil
1056+ }
1057+
1058+ func selectCurrentSubscription (
1059+ subscriptions []domainbilling.Subscription ,
1060+ plans map [uint ]domainbilling.Plan ,
1061+ now time.Time ,
1062+ ) (domainbilling.Subscription , bool ) {
1063+ var result domainbilling.Subscription
1064+ found := false
1065+ for _ , item := range subscriptions {
1066+ if item .Status != "active" || item .CurrentPeriodStartAt .After (now ) {
1067+ continue
1068+ }
1069+ if item .CurrentPeriodEndAt != nil && ! item .CurrentPeriodEndAt .After (now ) {
1070+ continue
1071+ }
1072+ if ! found || isHigherPrioritySubscription (item , result , plans ) {
1073+ result = item
1074+ found = true
1075+ }
1076+ }
1077+ return result , found
1078+ }
1079+
1080+ func contiguousSubscriptionEnd (
1081+ current domainbilling.Subscription ,
1082+ subscriptions []domainbilling.Subscription ,
1083+ ) * time.Time {
1084+ if current .CurrentPeriodEndAt == nil {
1085+ return nil
1086+ }
1087+ endAt := * current .CurrentPeriodEndAt
1088+ for {
1089+ extended := false
1090+ for _ , item := range subscriptions {
1091+ if item .ID == current .ID || item .PlanID != current .PlanID || item .CurrentPeriodEndAt == nil {
1092+ continue
1093+ }
1094+ if item .CurrentPeriodStartAt .After (endAt ) || ! item .CurrentPeriodEndAt .After (endAt ) {
1095+ continue
1096+ }
1097+ endAt = * item .CurrentPeriodEndAt
1098+ extended = true
1099+ }
1100+ if ! extended {
1101+ break
1102+ }
1103+ }
1104+ return & endAt
1105+ }
1106+
1107+ func buildSubscriptionEntitlementViews (
1108+ subscriptions []domainbilling.Subscription ,
1109+ plans map [uint ]domainbilling.Plan ,
1110+ now time.Time ,
1111+ ) []SubscriptionEntitlementView {
1112+ current , hasCurrent := selectCurrentSubscription (subscriptions , plans , now )
1113+ items := make ([]domainbilling.Subscription , 0 , len (subscriptions ))
1114+ for _ , item := range subscriptions {
1115+ plan , ok := plans [item .PlanID ]
1116+ if ! ok || strings .TrimSpace (plan .Code ) == "free" || item .Status != "active" {
1117+ continue
1118+ }
1119+ if item .CurrentPeriodEndAt != nil && ! item .CurrentPeriodEndAt .After (now ) {
1120+ continue
1121+ }
1122+ items = append (items , item )
1123+ }
1124+ sort .SliceStable (items , func (i , j int ) bool {
1125+ if items [i ].CurrentPeriodStartAt .Equal (items [j ].CurrentPeriodStartAt ) {
1126+ leftRank := subscriptionPlanRank (plans [items [i ].PlanID ])
1127+ rightRank := subscriptionPlanRank (plans [items [j ].PlanID ])
1128+ if leftRank != rightRank {
1129+ return leftRank > rightRank
1130+ }
1131+ return items [i ].ID < items [j ].ID
1132+ }
1133+ return items [i ].CurrentPeriodStartAt .Before (items [j ].CurrentPeriodStartAt )
1134+ })
1135+
1136+ results := make ([]SubscriptionEntitlementView , 0 , len (items ))
1137+ for _ , item := range items {
1138+ plan := plans [item .PlanID ]
1139+ view := SubscriptionEntitlementView {
1140+ Subscription : item ,
1141+ Plan : toBillingPlanView (plan ),
1142+ IsCurrent : hasCurrent && item .ID == current .ID ,
1143+ }
1144+ lastIndex := len (results ) - 1
1145+ if lastIndex >= 0 && canMergeSubscriptionEntitlement (results [lastIndex ].Subscription , view .Subscription ) {
1146+ last := & results [lastIndex ]
1147+ if subscriptionEndsAfter (view .Subscription , last .Subscription ) {
1148+ last .Subscription .CurrentPeriodEndAt = view .Subscription .CurrentPeriodEndAt
1149+ }
1150+ last .IsCurrent = last .IsCurrent || view .IsCurrent
1151+ continue
1152+ }
1153+ results = append (results , view )
1154+ }
1155+ return results
1156+ }
1157+
1158+ func canMergeSubscriptionEntitlement (left domainbilling.Subscription , right domainbilling.Subscription ) bool {
1159+ if left .PlanID != right .PlanID || left .PriceID != right .PriceID || left .CurrentPeriodEndAt == nil {
1160+ return false
10151161 }
1016- return * plan , monthStart , monthEnd , nil
1162+ return ! right .CurrentPeriodStartAt .After (* left .CurrentPeriodEndAt )
1163+ }
1164+
1165+ func subscriptionEndsAfter (left domainbilling.Subscription , right domainbilling.Subscription ) bool {
1166+ if left .CurrentPeriodEndAt == nil {
1167+ return true
1168+ }
1169+ if right .CurrentPeriodEndAt == nil {
1170+ return false
1171+ }
1172+ return left .CurrentPeriodEndAt .After (* right .CurrentPeriodEndAt )
1173+ }
1174+
1175+ func toBillingPlanView (plan domainbilling.Plan ) BillingPlanView {
1176+ return BillingPlanView {
1177+ ID : plan .ID ,
1178+ Code : plan .Code ,
1179+ Name : plan .Name ,
1180+ Description : plan .Description ,
1181+ FeatureJSON : plan .FeatureJSON ,
1182+ PeriodCreditNanousd : plan .PeriodCreditNanousd ,
1183+ DiscountPercent : plan .DiscountPercent ,
1184+ SortOrder : plan .SortOrder ,
1185+ IsActive : plan .IsActive ,
1186+ }
1187+ }
1188+
1189+ func isHigherPrioritySubscription (candidate domainbilling.Subscription , current domainbilling.Subscription , plans map [uint ]domainbilling.Plan ) bool {
1190+ candidateRank := subscriptionPlanRank (plans [candidate .PlanID ])
1191+ currentRank := subscriptionPlanRank (plans [current .PlanID ])
1192+ if candidateRank != currentRank {
1193+ return candidateRank > currentRank
1194+ }
1195+ candidateEnd := subscriptionEndSortTime (candidate )
1196+ currentEnd := subscriptionEndSortTime (current )
1197+ if ! candidateEnd .Equal (currentEnd ) {
1198+ return candidateEnd .After (currentEnd )
1199+ }
1200+ return candidate .ID > current .ID
1201+ }
1202+
1203+ func subscriptionPlanRank (plan domainbilling.Plan ) int {
1204+ if isFreePlanCode (plan .Code ) {
1205+ return 0
1206+ }
1207+ if plan .SortOrder > 0 {
1208+ return plan .SortOrder
1209+ }
1210+ return int (plan .ID )
1211+ }
1212+
1213+ func isFreePlanCode (code string ) bool {
1214+ return strings .TrimSpace (code ) == "free"
1215+ }
1216+
1217+ func subscriptionEndSortTime (subscription domainbilling.Subscription ) time.Time {
1218+ if subscription .CurrentPeriodEndAt == nil {
1219+ return time .Date (9999 , 12 , 31 , 23 , 59 , 59 , 0 , time .UTC )
1220+ }
1221+ return * subscription .CurrentPeriodEndAt
10171222}
10181223
10191224// BuildUsageLedger 根据模型单价与用量构建账本记录。
@@ -2010,13 +2215,18 @@ func (s *Service) GetBillingOverview(ctx context.Context, userID uint, now time.
20102215 IsDefault : price .IsDefault ,
20112216 })
20122217 }
2218+ subscriptions , planMap , err := s .listSubscriptionEntitlements (ctx , []uint {userID }, now )
2219+ if err != nil {
2220+ return nil , err
2221+ }
20132222
20142223 overview .Plan = & planView
20152224 overview .PeriodStartAt = & startAt
20162225 overview .PeriodEndAt = & endAt
20172226 overview .PeriodCreditNanousd = plan .PeriodCreditNanousd
20182227 overview .PeriodUsedNanousd = usedNanousd
20192228 overview .PeriodRemainingNanousd = remainingNanousd
2229+ overview .SubscriptionEntitlements = buildSubscriptionEntitlementViews (subscriptions , planMap , now )
20202230 return overview , nil
20212231}
20222232
0 commit comments