diff --git a/initialize/migrate/database/02154_promo_system.down.sql b/initialize/migrate/database/02154_promo_system.down.sql new file mode 100644 index 0000000..ea3d471 --- /dev/null +++ b/initialize/migrate/database/02154_promo_system.down.sql @@ -0,0 +1,39 @@ +SET @column_exists = ( + SELECT COUNT(*) + FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'order' + AND COLUMN_NAME = 'promo_discount' +); + +SET @sql = IF( + @column_exists = 1, + 'ALTER TABLE `order` DROP COLUMN `promo_discount`', + 'SELECT ''Column promo_discount does not exist in order table''' +); + +PREPARE stmt FROM @sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +SET @column_exists = ( + SELECT COUNT(*) + FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'order' + AND COLUMN_NAME = 'promo_rule_id' +); + +SET @sql = IF( + @column_exists = 1, + 'ALTER TABLE `order` DROP COLUMN `promo_rule_id`', + 'SELECT ''Column promo_rule_id does not exist in order table''' +); + +PREPARE stmt FROM @sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +DROP TABLE IF EXISTS `promo_usage`; +DROP TABLE IF EXISTS `subscribe_promo`; +DROP TABLE IF EXISTS `promo_rule`; diff --git a/initialize/migrate/database/02154_promo_system.up.sql b/initialize/migrate/database/02154_promo_system.up.sql new file mode 100644 index 0000000..2bb4e0f --- /dev/null +++ b/initialize/migrate/database/02154_promo_system.up.sql @@ -0,0 +1,77 @@ +CREATE TABLE IF NOT EXISTS `promo_rule` ( + `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + `name` VARCHAR(100) NOT NULL DEFAULT '' COMMENT '规则名称', + `type` VARCHAR(32) NOT NULL DEFAULT '' COMMENT '规则类型:new_user / inactive_user / campaign', + `params` JSON NOT NULL COMMENT '类型专属参数', + `priority` INT NOT NULL DEFAULT 0 COMMENT '优先级,数值越大越优先匹配', + `enabled` TINYINT(1) NOT NULL DEFAULT 1 COMMENT '是否启用', + `start_time` DATETIME DEFAULT NULL COMMENT '生效开始时间', + `end_time` DATETIME DEFAULT NULL COMMENT '生效结束时间', + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + `deleted_at` DATETIME DEFAULT NULL COMMENT '软删除时间', + PRIMARY KEY (`id`), + KEY `idx_enabled_priority` (`enabled`, `priority` DESC), + KEY `idx_deleted_at` (`deleted_at`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='促销规则表'; + +CREATE TABLE IF NOT EXISTS `subscribe_promo` ( + `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + `subscribe_id` BIGINT UNSIGNED NOT NULL COMMENT '套餐规格 ID', + `promo_rule_id` BIGINT UNSIGNED NOT NULL COMMENT '促销规则 ID', + `promo_price` BIGINT NOT NULL DEFAULT 0 COMMENT '该规格在此规则下的优惠价(分)', + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + UNIQUE KEY `uk_subscribe_rule` (`subscribe_id`, `promo_rule_id`), + KEY `idx_promo_rule_id` (`promo_rule_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='规格促销价表'; + +CREATE TABLE IF NOT EXISTS `promo_usage` ( + `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + `user_id` BIGINT UNSIGNED NOT NULL COMMENT '用户 ID', + `promo_rule_id` BIGINT UNSIGNED NOT NULL COMMENT '使用的规则 ID', + `subscribe_id` BIGINT UNSIGNED NOT NULL COMMENT '购买的规格 ID', + `order_no` VARCHAR(255) NOT NULL DEFAULT '' COMMENT '关联订单号', + `promo_price` BIGINT NOT NULL DEFAULT 0 COMMENT '使用时的促销单价(分)', + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + KEY `idx_user_rule` (`user_id`, `promo_rule_id`), + KEY `idx_order_no` (`order_no`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='促销使用记录表'; + +SET @column_exists = ( + SELECT COUNT(*) + FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'order' + AND COLUMN_NAME = 'promo_rule_id' +); + +SET @sql = IF( + @column_exists = 0, + 'ALTER TABLE `order` ADD COLUMN `promo_rule_id` BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT ''促销规则ID, 0=未使用促销'' AFTER `discount`', + 'SELECT ''Column promo_rule_id already exists in order table''' +); + +PREPARE stmt FROM @sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +SET @column_exists = ( + SELECT COUNT(*) + FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'order' + AND COLUMN_NAME = 'promo_discount' +); + +SET @sql = IF( + @column_exists = 0, + 'ALTER TABLE `order` ADD COLUMN `promo_discount` BIGINT NOT NULL DEFAULT 0 COMMENT ''促销优惠金额(分)'' AFTER `promo_rule_id`', + 'SELECT ''Column promo_discount already exists in order table''' +); + +PREPARE stmt FROM @sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; diff --git a/internal/logic/common/promoEligibility.go b/internal/logic/common/promoEligibility.go new file mode 100644 index 0000000..f50a4bb --- /dev/null +++ b/internal/logic/common/promoEligibility.go @@ -0,0 +1,170 @@ +package common + +import ( + "context" + "encoding/json" + "time" + + "github.com/perfect-panel/server/internal/model/promo" + "github.com/perfect-panel/server/internal/model/user" + "github.com/perfect-panel/server/internal/svc" + "github.com/perfect-panel/server/pkg/xerr" + "github.com/pkg/errors" + "gorm.io/gorm" +) + +type PromoResult struct { + Eligible bool + RuleID int64 + RuleName string + RuleType string + PromoPrice int64 + ExpiresAt time.Time +} + +type promoRuleParams struct { + WindowHours int `json:"window_hours"` + InactiveMonths int `json:"inactive_months"` +} + +func EvaluatePromo(ctx context.Context, svcCtx *svc.ServiceContext, userID int64, subscribeID int64) (*PromoResult, error) { + result := &PromoResult{} + if svcCtx == nil || svcCtx.PromoModel == nil || svcCtx.DB == nil || userID <= 0 || subscribeID <= 0 { + return result, nil + } + + rules, err := svcCtx.PromoModel.QueryEligibleRules(ctx, subscribeID) + if err != nil { + return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "query promo rules failed: %v", err.Error()) + } + if len(rules) == 0 { + return result, nil + } + + var currentUser user.User + now := time.Now() + for _, rule := range rules { + if rule == nil || !isPromoRuleInTimeWindow(rule, now) { + continue + } + if rule.PromoPrice <= 0 { + continue + } + + params := promoRuleParams{} + if rule.Params != "" { + if err = json.Unmarshal([]byte(rule.Params), ¶ms); err != nil { + continue + } + } + + eligible, expiresAt, err := evaluatePromoRule(ctx, svcCtx.DB, rule, params, userID, ¤tUser, now) + if err != nil { + return nil, err + } + if !eligible { + continue + } + + return &PromoResult{ + Eligible: true, + RuleID: rule.Id, + RuleName: rule.Name, + RuleType: rule.Type, + PromoPrice: rule.PromoPrice, + ExpiresAt: expiresAt, + }, nil + } + + return result, nil +} + +func isPromoRuleInTimeWindow(rule *promo.RuleWithPrice, now time.Time) bool { + if rule.StartTime != nil && !rule.StartTime.IsZero() && now.Before(*rule.StartTime) { + return false + } + if rule.EndTime != nil && !rule.EndTime.IsZero() && now.After(*rule.EndTime) { + return false + } + return true +} + +func evaluatePromoRule( + ctx context.Context, + db *gorm.DB, + rule *promo.RuleWithPrice, + params promoRuleParams, + userID int64, + currentUser *user.User, + now time.Time, +) (bool, time.Time, error) { + switch rule.Type { + case promo.RuleTypeNewUser: + return evaluateNewUserPromo(ctx, db, params, userID, currentUser, now) + case promo.RuleTypeInactiveUser: + return evaluateInactiveUserPromo(ctx, db, params, userID, promoRuleExpiresAt(rule), now) + case promo.RuleTypeCampaign: + return true, promoRuleExpiresAt(rule), nil + default: + return false, time.Time{}, nil + } +} + +func evaluateNewUserPromo( + ctx context.Context, + db *gorm.DB, + params promoRuleParams, + userID int64, + currentUser *user.User, + now time.Time, +) (bool, time.Time, error) { + if params.WindowHours <= 0 { + return false, time.Time{}, nil + } + + if currentUser.Id == 0 { + if err := db.WithContext(ctx).Model(&user.User{}).Where("id = ?", userID).First(currentUser).Error; err != nil { + return false, time.Time{}, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "query promo user failed") + } + } + + expiresAt := currentUser.CreatedAt.Add(time.Duration(params.WindowHours) * time.Hour) + return now.Before(expiresAt), expiresAt, nil +} + +func evaluateInactiveUserPromo( + ctx context.Context, + db *gorm.DB, + params promoRuleParams, + userID int64, + ruleExpiresAt time.Time, + now time.Time, +) (bool, time.Time, error) { + if params.InactiveMonths <= 0 { + return false, time.Time{}, nil + } + + var lastSub user.Subscribe + err := db.WithContext(ctx). + Model(&user.Subscribe{}). + Where("user_id = ?", userID). + Order("expire_time DESC"). + Limit(1). + Take(&lastSub).Error + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return true, ruleExpiresAt, nil + } + return false, time.Time{}, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "query promo inactive user subscription failed") + } + + threshold := now.AddDate(0, -params.InactiveMonths, 0) + return lastSub.ExpireTime.Before(threshold) || lastSub.ExpireTime.Equal(threshold), ruleExpiresAt, nil +} + +func promoRuleExpiresAt(rule *promo.RuleWithPrice) time.Time { + if rule != nil && rule.EndTime != nil { + return *rule.EndTime + } + return time.Time{} +} diff --git a/internal/logic/public/order/preCreateOrderLogic.go b/internal/logic/public/order/preCreateOrderLogic.go index 09415a8..ac9ba92 100644 --- a/internal/logic/public/order/preCreateOrderLogic.go +++ b/internal/logic/public/order/preCreateOrderLogic.go @@ -2,7 +2,6 @@ package order import ( "context" - "math" commonLogic "github.com/perfect-panel/server/internal/logic/common" "github.com/perfect-panel/server/internal/model/order" @@ -115,15 +114,28 @@ func (l *PreCreateOrderLogic) PreCreateOrder(req *types.PurchaseOrderRequest) (r return nil, errors.Wrapf(xerr.NewErrCode(xerr.SubscribeNewUserOnly), "not a new user") } - var discount float64 = 1 - if len(newUserDiscount.Discounts) > 0 { - discount = getDiscount(newUserDiscount.Discounts, req.Quantity, newUserDiscount.EligibleForDiscount) + priceResult, err := calculatePurchasePrice( + l.ctx, + l.svcCtx, + u.Id, + targetSubscribeID, + sub.UnitPrice, + req.Quantity, + newUserDiscount.Discounts, + newUserDiscount.EligibleForDiscount, + !isSingleModeRenewal, + ) + if err != nil { + l.Errorw("[PreCreateOrder] Promo price calculation error", + logger.Field("error", err.Error()), + logger.Field("user_id", u.Id), + logger.Field("subscribe_id", targetSubscribeID), + ) + return nil, err } - price := sub.UnitPrice * req.Quantity - - amount := int64(math.Round(float64(price) * discount)) - discountAmount := price - amount - var promoDiscount int64 + price := priceResult.OriginalPrice + amount := priceResult.PayableBase + discountAmount := priceResult.DiscountAmount var couponAmount int64 if req.Coupon != "" { couponInfo, err := l.svcCtx.CouponModel.FindOneByCode(l.ctx, req.Coupon) @@ -186,7 +198,7 @@ func (l *PreCreateOrderLogic) PreCreateOrder(req *types.PurchaseOrderRequest) (r Price: price, Amount: amount, Discount: discountAmount, - PromoDiscount: promoDiscount, + PromoDiscount: priceResult.PromoDiscount, GiftAmount: deductionAmount, Coupon: req.Coupon, CouponDiscount: couponAmount, diff --git a/internal/logic/public/order/promoPricing.go b/internal/logic/public/order/promoPricing.go new file mode 100644 index 0000000..119328e --- /dev/null +++ b/internal/logic/public/order/promoPricing.go @@ -0,0 +1,62 @@ +package order + +import ( + "context" + "math" + + commonLogic "github.com/perfect-panel/server/internal/logic/common" + "github.com/perfect-panel/server/internal/svc" + "github.com/perfect-panel/server/internal/types" +) + +type orderPriceResult struct { + OriginalPrice int64 + PayableBase int64 + DiscountAmount int64 + PromoRuleId int64 + PromoDiscount int64 + PromoPrice int64 +} + +func calculatePurchasePrice( + ctx context.Context, + svcCtx *svc.ServiceContext, + userID int64, + subscribeID int64, + unitPrice int64, + quantity int64, + discounts []types.SubscribeDiscount, + eligibleForDiscount bool, + allowPromo bool, +) (*orderPriceResult, error) { + originalPrice := unitPrice * quantity + result := &orderPriceResult{ + OriginalPrice: originalPrice, + PayableBase: originalPrice, + } + + if allowPromo { + promoResult, err := commonLogic.EvaluatePromo(ctx, svcCtx, userID, subscribeID) + if err != nil { + return nil, err + } + if promoResult != nil && promoResult.Eligible && promoResult.PromoPrice < unitPrice { + result.PayableBase = promoResult.PromoPrice * quantity + result.PromoRuleId = promoResult.RuleID + result.PromoDiscount = originalPrice - result.PayableBase + result.PromoPrice = promoResult.PromoPrice + if result.PromoDiscount < 0 { + result.PromoDiscount = 0 + } + return result, nil + } + } + + discount := float64(1) + if len(discounts) > 0 { + discount = getDiscount(discounts, quantity, eligibleForDiscount) + } + result.PayableBase = int64(math.Round(float64(originalPrice) * discount)) + result.DiscountAmount = originalPrice - result.PayableBase + return result, nil +} diff --git a/internal/logic/public/order/promoPricing_test.go b/internal/logic/public/order/promoPricing_test.go new file mode 100644 index 0000000..2c3f579 --- /dev/null +++ b/internal/logic/public/order/promoPricing_test.go @@ -0,0 +1,113 @@ +package order + +import ( + "context" + "testing" + + "github.com/perfect-panel/server/internal/model/promo" + "github.com/perfect-panel/server/internal/svc" + "github.com/perfect-panel/server/internal/types" + "gorm.io/gorm" +) + +type fakePromoModel struct { + rules []*promo.RuleWithPrice +} + +func (m fakePromoModel) QueryEligibleRules(context.Context, int64) ([]*promo.RuleWithPrice, error) { + return m.rules, nil +} + +func (m fakePromoModel) InsertUsage(context.Context, *promo.Usage, ...*gorm.DB) error { + return nil +} + +func TestCalculatePurchasePricePromoSkipsPercentDiscount(t *testing.T) { + svcCtx := &svc.ServiceContext{ + DB: &gorm.DB{}, + PromoModel: fakePromoModel{rules: []*promo.RuleWithPrice{ + { + Rule: promo.Rule{ + Id: 9, + Name: "campaign", + Type: promo.RuleTypeCampaign, + Enabled: true, + }, + PromoPrice: 600, + }, + }}, + } + + result, err := calculatePurchasePrice( + context.Background(), + svcCtx, + 1, + 2, + 1000, + 3, + []types.SubscribeDiscount{{Quantity: 3, Discount: 50}}, + true, + true, + ) + if err != nil { + t.Fatalf("calculatePurchasePrice returned error: %v", err) + } + + if result.OriginalPrice != 3000 { + t.Fatalf("OriginalPrice = %d, want 3000", result.OriginalPrice) + } + if result.PayableBase != 1800 { + t.Fatalf("PayableBase = %d, want 1800", result.PayableBase) + } + if result.DiscountAmount != 0 { + t.Fatalf("DiscountAmount = %d, want 0", result.DiscountAmount) + } + if result.PromoRuleId != 9 { + t.Fatalf("PromoRuleId = %d, want 9", result.PromoRuleId) + } + if result.PromoDiscount != 1200 { + t.Fatalf("PromoDiscount = %d, want 1200", result.PromoDiscount) + } +} + +func TestCalculatePurchasePriceIgnoresInvalidPromoPrice(t *testing.T) { + svcCtx := &svc.ServiceContext{ + DB: &gorm.DB{}, + PromoModel: fakePromoModel{rules: []*promo.RuleWithPrice{ + { + Rule: promo.Rule{ + Id: 10, + Name: "invalid campaign", + Type: promo.RuleTypeCampaign, + Enabled: true, + }, + PromoPrice: 1000, + }, + }}, + } + + result, err := calculatePurchasePrice( + context.Background(), + svcCtx, + 1, + 2, + 1000, + 3, + []types.SubscribeDiscount{{Quantity: 3, Discount: 50}}, + true, + true, + ) + if err != nil { + t.Fatalf("calculatePurchasePrice returned error: %v", err) + } + + if result.PayableBase != 1500 { + t.Fatalf("PayableBase = %d, want 1500", result.PayableBase) + } + if result.DiscountAmount != 1500 { + t.Fatalf("DiscountAmount = %d, want 1500", result.DiscountAmount) + } + if result.PromoRuleId != 0 || result.PromoDiscount != 0 { + t.Fatalf("promo fields = (%d, %d), want (0, 0)", result.PromoRuleId, result.PromoDiscount) + } +} diff --git a/internal/logic/public/order/purchaseLogic.go b/internal/logic/public/order/purchaseLogic.go index df02908..772ad74 100644 --- a/internal/logic/public/order/purchaseLogic.go +++ b/internal/logic/public/order/purchaseLogic.go @@ -3,7 +3,6 @@ package order import ( "context" "encoding/json" - "math" "strings" "time" @@ -204,14 +203,28 @@ func (l *PurchaseLogic) Purchase(req *types.PurchaseOrderRequest) (resp *types.P return nil, err } - var discount float64 = 1 - if len(newUserDiscount.Discounts) > 0 { - discount = getDiscount(newUserDiscount.Discounts, req.Quantity, newUserDiscount.EligibleForDiscount) + priceResult, err := calculatePurchasePrice( + l.ctx, + l.svcCtx, + u.Id, + targetSubscribeID, + sub.UnitPrice, + req.Quantity, + newUserDiscount.Discounts, + newUserDiscount.EligibleForDiscount, + orderType == 1, + ) + if err != nil { + l.Errorw("[Purchase] Promo price calculation error", + logger.Field("error", err.Error()), + logger.Field("user_id", u.Id), + logger.Field("subscribe_id", targetSubscribeID), + ) + return nil, err } - price := sub.UnitPrice * req.Quantity - // discount amount - amount := int64(math.Round(float64(price) * discount)) - discountAmount := price - amount + price := priceResult.OriginalPrice + amount := priceResult.PayableBase + discountAmount := priceResult.DiscountAmount // Validate amount to prevent overflow if amount > MaxOrderAmount { @@ -306,6 +319,8 @@ func (l *PurchaseLogic) Purchase(req *types.PurchaseOrderRequest) (resp *types.P Price: price, Amount: amount, Discount: discountAmount, + PromoRuleId: priceResult.PromoRuleId, + PromoDiscount: priceResult.PromoDiscount, GiftAmount: deductionAmount, Coupon: req.Coupon, CouponDiscount: coupon, diff --git a/internal/model/order/model.go b/internal/model/order/model.go index 89d1b5f..cfb6b46 100644 --- a/internal/model/order/model.go +++ b/internal/model/order/model.go @@ -22,6 +22,8 @@ type Details struct { Price int64 `gorm:"type:int;not null;default:0;comment:Original price"` Amount int64 `gorm:"type:int;not null;default:0;comment:Order Amount"` Discount int64 `gorm:"type:int;not null;default:0;comment:Order Discount"` + PromoRuleId int64 `gorm:"type:bigint unsigned;not null;default:0;comment:Promo Rule ID"` + PromoDiscount int64 `gorm:"type:bigint;not null;default:0;comment:Promo Discount Amount"` Coupon string `gorm:"type:varchar(255);default:null;comment:Coupon"` CouponDiscount int64 `gorm:"type:int;not null;default:0;comment:Coupon Discount"` PaymentId int64 `gorm:"type:bigint;not null;default:0;comment:Payment Id"` diff --git a/internal/model/order/order.go b/internal/model/order/order.go index cd90153..f351273 100644 --- a/internal/model/order/order.go +++ b/internal/model/order/order.go @@ -3,32 +3,34 @@ package order import "time" type Order struct { - Id int64 `gorm:"primaryKey"` - ParentId int64 `gorm:"type:bigint;default:null;comment:Parent Order Id"` + Id int64 `gorm:"primaryKey"` + ParentId int64 `gorm:"type:bigint;default:null;comment:Parent Order Id"` UserId int64 `gorm:"type:bigint;not null;default:0;comment:User Id"` SubscriptionUserId int64 `gorm:"type:bigint;not null;default:0;comment:Target user ID for subscription (0=same as UserId)"` - OrderNo string `gorm:"type:varchar(255);not null;default:'';unique;comment:Order No"` - Type uint8 `gorm:"type:tinyint(1);not null;default:1;comment:Order Type: 1: Subscribe, 2: Renewal, 3: ResetTraffic, 4: Recharge"` - Quantity int64 `gorm:"type:bigint;not null;default:1;comment:Quantity"` - Price int64 `gorm:"type:int;not null;default:0;comment:Original price"` - Amount int64 `gorm:"type:int;not null;default:0;comment:Order Amount"` - GiftAmount int64 `gorm:"type:int;not null;default:0;comment:User Gift Amount"` - Discount int64 `gorm:"type:int;not null;default:0;comment:Discount Amount"` - Coupon string `gorm:"type:varchar(255);default:null;comment:Coupon"` - CouponDiscount int64 `gorm:"type:int;not null;default:0;comment:Coupon Discount Amount"` - Commission int64 `gorm:"type:int;not null;default:0;comment:Order Commission"` - PaymentId int64 `gorm:"type:bigint;not null;default:0;comment:Payment Method Id"` - Method string `gorm:"type:varchar(255);not null;default:'';comment:Payment Method"` - FeeAmount int64 `gorm:"type:int;not null;default:0;comment:Fee Amount"` - TradeNo string `gorm:"type:varchar(255);default:null;comment:Trade No"` - Status uint8 `gorm:"type:tinyint(1);not null;default:1;comment:Order Status: 1: Pending, 2: Paid, 3:Close, 4: Failed, 5:Finished;"` - SubscribeId int64 `gorm:"type:bigint;not null;default:0;comment:Subscribe Id"` - SubscribeToken string `gorm:"type:varchar(255);default:null;comment:Renewal Subscribe Token"` - AppAccountToken string `gorm:"type:varchar(36);default:null;comment:Apple IAP App Account Token (UUID)"` - ActivationContext string `gorm:"type:text;default:null;comment:Activation context JSON (guest/redemption info for DB fallback)"` - IsNew bool `gorm:"type:tinyint(1);not null;default:0;comment:Is New Order"` - CreatedAt time.Time `gorm:"<-:create;comment:Create Time"` - UpdatedAt time.Time `gorm:"comment:Update Time"` + OrderNo string `gorm:"type:varchar(255);not null;default:'';unique;comment:Order No"` + Type uint8 `gorm:"type:tinyint(1);not null;default:1;comment:Order Type: 1: Subscribe, 2: Renewal, 3: ResetTraffic, 4: Recharge"` + Quantity int64 `gorm:"type:bigint;not null;default:1;comment:Quantity"` + Price int64 `gorm:"type:int;not null;default:0;comment:Original price"` + Amount int64 `gorm:"type:int;not null;default:0;comment:Order Amount"` + GiftAmount int64 `gorm:"type:int;not null;default:0;comment:User Gift Amount"` + Discount int64 `gorm:"type:int;not null;default:0;comment:Discount Amount"` + PromoRuleId int64 `gorm:"type:bigint unsigned;not null;default:0;comment:Promo Rule ID"` + PromoDiscount int64 `gorm:"type:bigint;not null;default:0;comment:Promo Discount Amount"` + Coupon string `gorm:"type:varchar(255);default:null;comment:Coupon"` + CouponDiscount int64 `gorm:"type:int;not null;default:0;comment:Coupon Discount Amount"` + Commission int64 `gorm:"type:int;not null;default:0;comment:Order Commission"` + PaymentId int64 `gorm:"type:bigint;not null;default:0;comment:Payment Method Id"` + Method string `gorm:"type:varchar(255);not null;default:'';comment:Payment Method"` + FeeAmount int64 `gorm:"type:int;not null;default:0;comment:Fee Amount"` + TradeNo string `gorm:"type:varchar(255);default:null;comment:Trade No"` + Status uint8 `gorm:"type:tinyint(1);not null;default:1;comment:Order Status: 1: Pending, 2: Paid, 3:Close, 4: Failed, 5:Finished;"` + SubscribeId int64 `gorm:"type:bigint;not null;default:0;comment:Subscribe Id"` + SubscribeToken string `gorm:"type:varchar(255);default:null;comment:Renewal Subscribe Token"` + AppAccountToken string `gorm:"type:varchar(36);default:null;comment:Apple IAP App Account Token (UUID)"` + ActivationContext string `gorm:"type:text;default:null;comment:Activation context JSON (guest/redemption info for DB fallback)"` + IsNew bool `gorm:"type:tinyint(1);not null;default:0;comment:Is New Order"` + CreatedAt time.Time `gorm:"<-:create;comment:Create Time"` + UpdatedAt time.Time `gorm:"comment:Update Time"` } type OrdersTotal struct { diff --git a/internal/model/promo/model.go b/internal/model/promo/model.go new file mode 100644 index 0000000..ff48935 --- /dev/null +++ b/internal/model/promo/model.go @@ -0,0 +1,48 @@ +package promo + +import ( + "context" + + "github.com/redis/go-redis/v9" + "gorm.io/gorm" +) + +type RuleWithPrice struct { + Rule + PromoPrice int64 `gorm:"column:promo_price"` +} + +type Model interface { + QueryEligibleRules(ctx context.Context, subscribeId int64) ([]*RuleWithPrice, error) + InsertUsage(ctx context.Context, data *Usage, tx ...*gorm.DB) error +} + +type defaultPromoModel struct { + db *gorm.DB +} + +func NewModel(db *gorm.DB, _ *redis.Client) Model { + return &defaultPromoModel{db: db} +} + +func (m *defaultPromoModel) QueryEligibleRules(ctx context.Context, subscribeId int64) ([]*RuleWithPrice, error) { + var list []*RuleWithPrice + err := m.db.WithContext(ctx). + Table("promo_rule AS pr"). + Select("pr.*, sp.promo_price"). + Joins("JOIN subscribe_promo AS sp ON sp.promo_rule_id = pr.id"). + Where("sp.subscribe_id = ? AND sp.promo_price > 0 AND pr.enabled = ?", subscribeId, true). + Where("pr.deleted_at IS NULL"). + Order("pr.priority DESC"). + Order("pr.id ASC"). + Find(&list).Error + return list, err +} + +func (m *defaultPromoModel) InsertUsage(ctx context.Context, data *Usage, tx ...*gorm.DB) error { + db := m.db.WithContext(ctx) + if len(tx) > 0 { + db = tx[0].WithContext(ctx) + } + return db.Model(&Usage{}).Create(data).Error +} diff --git a/internal/model/promo/promo.go b/internal/model/promo/promo.go new file mode 100644 index 0000000..1a6f26f --- /dev/null +++ b/internal/model/promo/promo.go @@ -0,0 +1,58 @@ +package promo + +import ( + "time" + + "gorm.io/gorm" +) + +const ( + RuleTypeNewUser = "new_user" + RuleTypeInactiveUser = "inactive_user" + RuleTypeCampaign = "campaign" +) + +type Rule struct { + Id int64 `gorm:"primaryKey"` + Name string `gorm:"type:varchar(100);not null;default:'';comment:Rule Name"` + Type string `gorm:"type:varchar(32);not null;default:'';comment:Rule Type"` + Params string `gorm:"type:json;not null;comment:Rule Params"` + Priority int64 `gorm:"type:int;not null;default:0;comment:Priority"` + Enabled bool `gorm:"type:tinyint(1);not null;default:1;comment:Enabled"` + StartTime *time.Time `gorm:"default:null;comment:Start Time"` + EndTime *time.Time `gorm:"default:null;comment:End Time"` + CreatedAt time.Time `gorm:"<-:create;comment:Create Time"` + UpdatedAt time.Time `gorm:"comment:Update Time"` + DeletedAt gorm.DeletedAt `gorm:"index;comment:Delete Time"` +} + +func (Rule) TableName() string { + return "promo_rule" +} + +type SubscribePromo struct { + Id int64 `gorm:"primaryKey"` + SubscribeId int64 `gorm:"type:bigint unsigned;not null;comment:Subscribe ID"` + PromoRuleId int64 `gorm:"type:bigint unsigned;not null;comment:Promo Rule ID"` + PromoPrice int64 `gorm:"type:bigint;not null;default:0;comment:Promo Price"` + CreatedAt time.Time `gorm:"<-:create;comment:Create Time"` + UpdatedAt time.Time `gorm:"comment:Update Time"` +} + +func (SubscribePromo) TableName() string { + return "subscribe_promo" +} + +type Usage struct { + Id int64 `gorm:"primaryKey"` + UserId int64 `gorm:"type:bigint unsigned;not null;comment:User ID"` + PromoRuleId int64 `gorm:"type:bigint unsigned;not null;comment:Promo Rule ID"` + SubscribeId int64 `gorm:"type:bigint unsigned;not null;comment:Subscribe ID"` + OrderNo string `gorm:"type:varchar(255);not null;default:'';comment:Order No"` + PromoPrice int64 `gorm:"type:bigint;not null;default:0;comment:Promo Price"` + CreatedAt time.Time `gorm:"<-:create;comment:Create Time"` +} + +func (Usage) TableName() string { + return "promo_usage" +} diff --git a/internal/svc/serviceContext.go b/internal/svc/serviceContext.go index e1c5aaa..168b626 100644 --- a/internal/svc/serviceContext.go +++ b/internal/svc/serviceContext.go @@ -21,6 +21,7 @@ import ( logmessage "github.com/perfect-panel/server/internal/model/logmessage" "github.com/perfect-panel/server/internal/model/order" "github.com/perfect-panel/server/internal/model/payment" + "github.com/perfect-panel/server/internal/model/promo" "github.com/perfect-panel/server/internal/model/subscribe" "github.com/perfect-panel/server/internal/model/system" "github.com/perfect-panel/server/internal/model/ticket" @@ -45,7 +46,7 @@ type ServiceContext struct { ExchangeRate float64 GeoIP *IPLocation SignatureValidator *signature.Validator - S3Store *storage.S3Store + S3Store *storage.S3Store //NodeCache *cache.NodeCacheClient AuthModel auth.Model @@ -63,6 +64,7 @@ type ServiceContext struct { RedemptionCodeModel redemption.RedemptionCodeModel RedemptionRecordModel redemption.RedemptionRecordModel PaymentModel payment.Model + PromoModel promo.Model DocumentModel document.Model SubscribeModel subscribe.Model TrafficLogModel traffic.Model @@ -138,6 +140,7 @@ func NewServiceContext(c config.Config) *ServiceContext { RedemptionCodeModel: redemption.NewRedemptionCodeModel(db, rds), RedemptionRecordModel: redemption.NewRedemptionRecordModel(db, rds), PaymentModel: payment.NewModel(db, rds), + PromoModel: promo.NewModel(db, rds), DocumentModel: document.NewModel(db, rds), SubscribeModel: subscribe.NewModel(db, rds), TrafficLogModel: traffic.NewModel(db), diff --git a/internal/types/types.go b/internal/types/types.go index cc3e493..4941bc0 100644 --- a/internal/types/types.go +++ b/internal/types/types.go @@ -1868,6 +1868,8 @@ type Order struct { Amount int64 `json:"amount"` GiftAmount int64 `json:"gift_amount"` Discount int64 `json:"discount"` + PromoRuleId int64 `json:"promo_rule_id"` + PromoDiscount int64 `json:"promo_discount"` Coupon string `json:"coupon"` CouponDiscount int64 `json:"coupon_discount"` Commission int64 `json:"commission,omitempty"` @@ -1891,6 +1893,8 @@ type OrderDetail struct { Amount int64 `json:"amount"` GiftAmount int64 `json:"gift_amount"` Discount int64 `json:"discount"` + PromoRuleId int64 `json:"promo_rule_id"` + PromoDiscount int64 `json:"promo_discount"` Coupon string `json:"coupon"` CouponDiscount int64 `json:"coupon_discount"` Commission int64 `json:"commission,omitempty"` diff --git a/queue/logic/order/activateOrderLogic.go b/queue/logic/order/activateOrderLogic.go index 8b20137..955d502 100644 --- a/queue/logic/order/activateOrderLogic.go +++ b/queue/logic/order/activateOrderLogic.go @@ -19,6 +19,7 @@ import ( "github.com/google/uuid" "github.com/hibiken/asynq" "github.com/perfect-panel/server/internal/model/order" + "github.com/perfect-panel/server/internal/model/promo" "github.com/perfect-panel/server/internal/model/redemption" "github.com/perfect-panel/server/internal/model/subscribe" "github.com/perfect-panel/server/internal/model/user" @@ -148,6 +149,7 @@ func (l *ActivateOrderLogic) ProcessTask(ctx context.Context, task *asynq.Task) return err } + l.recordPromoUsage(ctx, orderInfo) l.finalizeCouponAndOrder(ctx, orderInfo) commonLogic.SubscriptionTraceInfo(logger.WithContext(ctx), commonLogic.SubscriptionTraceFlowOrder, "activation_finished", @@ -157,6 +159,44 @@ func (l *ActivateOrderLogic) ProcessTask(ctx context.Context, task *asynq.Task) return nil } +func (l *ActivateOrderLogic) recordPromoUsage(ctx context.Context, orderInfo *order.Order) { + if orderInfo == nil || orderInfo.PromoRuleId <= 0 || orderInfo.Quantity <= 0 || orderInfo.SubscribeId <= 0 || orderInfo.OrderNo == "" { + return + } + + promoPrice := int64(0) + if orderInfo.Price > orderInfo.PromoDiscount { + promoPrice = (orderInfo.Price - orderInfo.PromoDiscount) / orderInfo.Quantity + } + if promoPrice <= 0 { + return + } + + err := l.svc.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + var count int64 + if e := tx.Model(&promo.Usage{}).Where("order_no = ?", orderInfo.OrderNo).Count(&count).Error; e != nil { + return e + } + if count > 0 { + return nil + } + return l.svc.PromoModel.InsertUsage(ctx, &promo.Usage{ + UserId: orderInfo.UserId, + PromoRuleId: orderInfo.PromoRuleId, + SubscribeId: orderInfo.SubscribeId, + OrderNo: orderInfo.OrderNo, + PromoPrice: promoPrice, + }, tx) + }) + if err != nil { + logger.WithContext(ctx).Error("Insert promo usage failed", + logger.Field("error", err.Error()), + logger.Field("order_no", orderInfo.OrderNo), + logger.Field("promo_rule_id", orderInfo.PromoRuleId), + ) + } +} + // parsePayload unMarshals the task payload into a structured format func (l *ActivateOrderLogic) parsePayload(ctx context.Context, payload []byte) (*queueTypes.ForthwithActivateOrderPayload, error) { var p queueTypes.ForthwithActivateOrderPayload