Skip to content

Commit e99d8a7

Browse files
authored
Merge pull request #80 from DEEIX-AI/redemption_code
feat: implement redemption code management including creation, listing, and redemption functionality
2 parents f262686 + 94aa43e commit e99d8a7

39 files changed

Lines changed: 9201 additions & 1328 deletions

backend/docs/docs.go

Lines changed: 1378 additions & 578 deletions
Large diffs are not rendered by default.

backend/docs/swagger.json

Lines changed: 1378 additions & 578 deletions
Large diffs are not rendered by default.

backend/docs/swagger.yaml

Lines changed: 521 additions & 0 deletions
Large diffs are not rendered by default.

backend/internal/app/app.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -146,6 +146,7 @@ func NewApp() (*App, error) {
146146
billingRepo := billingrepo.NewRepo(db)
147147
billingService := billing.NewService(billingRepo)
148148
billingService.SetAuditWriter(auditService)
149+
billingService.SetRedemptionCodeSecret(cfg.DataEncryptionKey)
149150
billingHandler := billinghttp.NewHandler(billingService, settingsService, runtimeCfg)
150151
billingModule := billinghttp.NewModule(billingHandler)
151152
objectStoreProvider := appstorage.NewRuntimeProvider(runtimeCfg, nil)

backend/internal/application/billing/errs.go

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,4 +23,20 @@ var (
2323
ErrSubscriptionExpiryRequired = errors.New("subscription expiry required")
2424
// ErrInvalidSubscriptionExpiry 非法订阅到期时间。
2525
ErrInvalidSubscriptionExpiry = errors.New("invalid subscription expiry")
26+
// ErrSubscriptionEntitlementActive 当前仍存在有效付费订阅权益。
27+
ErrSubscriptionEntitlementActive = errors.New("subscription entitlement is active")
28+
// ErrRedemptionCodeHashUnavailable 兑换码哈希密钥不可用。
29+
ErrRedemptionCodeHashUnavailable = errors.New("redemption code hash secret unavailable")
30+
// ErrInvalidRedemptionCode 兑换码格式或配置非法。
31+
ErrInvalidRedemptionCode = errors.New("invalid redemption code")
32+
// ErrRedemptionCodeConflict 兑换码明文对应的哈希已存在。
33+
ErrRedemptionCodeConflict = errors.New("redemption code already exists")
34+
// ErrRedemptionCodeUnavailable 兑换码不存在、停用、过期或与当前计费模式不匹配。
35+
ErrRedemptionCodeUnavailable = errors.New("redemption code is unavailable")
36+
// ErrRedemptionCodePlaintextUnavailable 兑换码未保存可解密密文,无法再次展示明文。
37+
ErrRedemptionCodePlaintextUnavailable = errors.New("redemption code plaintext unavailable")
38+
// ErrRedemptionCodeExhausted 兑换码总次数已用完。
39+
ErrRedemptionCodeExhausted = errors.New("redemption code exhausted")
40+
// ErrRedemptionUserLimitExceeded 当前用户已达到兑换次数上限。
41+
ErrRedemptionUserLimitExceeded = errors.New("redemption user limit exceeded")
2642
)

backend/internal/application/billing/service.go

Lines changed: 242 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import (
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

6971
type 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+
271281
func (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

Comments
 (0)