修复(#85): 促销系统 quantity 设计修正补丁(跨任务统一修复)
- subscribe_promo 表加 quantity 字段,BIGINT NOT NULL DEFAULT 1 - EvaluatePromo 加 quantity 参数,按 subscribeID + quantity 精确匹配 - Promo 从 Subscribe 顶层移到 SubscribeDiscount - 查询加 quantity,返回 map[subscribeID][quantity] 二级映射 - recordPromoUsage 错误向上传播,不再静默吞掉 - preCreate 和 purchase 的 allowPromo 判定统一为 orderType==1 - 迁移脚本增加幂等处理(guarded DROP/ADD/MODIFY) Co-authored-by: multica-agent <github@multica.ai>
This commit is contained in:
@@ -4316,6 +4316,9 @@
|
|||||||
"discount": {
|
"discount": {
|
||||||
"type": "number",
|
"type": "number",
|
||||||
"format": "double"
|
"format": "double"
|
||||||
|
},
|
||||||
|
"promo": {
|
||||||
|
"$ref": "#/definitions/SubscribePromo"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"title": "SubscribeDiscount",
|
"title": "SubscribeDiscount",
|
||||||
|
|||||||
@@ -14,19 +14,155 @@ CREATE TABLE IF NOT EXISTS `promo_rule` (
|
|||||||
KEY `idx_enabled_priority_deleted` (`enabled`, `deleted_at`, `priority` DESC)
|
KEY `idx_enabled_priority_deleted` (`enabled`, `deleted_at`, `priority` DESC)
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='促销规则表';
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='促销规则表';
|
||||||
|
|
||||||
|
SET @index_exists = (
|
||||||
|
SELECT COUNT(*)
|
||||||
|
FROM INFORMATION_SCHEMA.STATISTICS
|
||||||
|
WHERE TABLE_SCHEMA = DATABASE()
|
||||||
|
AND TABLE_NAME = 'promo_rule'
|
||||||
|
AND INDEX_NAME = 'idx_enabled_priority'
|
||||||
|
);
|
||||||
|
|
||||||
|
SET @sql = IF(
|
||||||
|
@index_exists = 1,
|
||||||
|
'ALTER TABLE `promo_rule` DROP INDEX `idx_enabled_priority`',
|
||||||
|
'SELECT ''Index idx_enabled_priority does not exist on promo_rule table'''
|
||||||
|
);
|
||||||
|
|
||||||
|
PREPARE stmt FROM @sql;
|
||||||
|
EXECUTE stmt;
|
||||||
|
DEALLOCATE PREPARE stmt;
|
||||||
|
|
||||||
|
SET @index_exists = (
|
||||||
|
SELECT COUNT(*)
|
||||||
|
FROM INFORMATION_SCHEMA.STATISTICS
|
||||||
|
WHERE TABLE_SCHEMA = DATABASE()
|
||||||
|
AND TABLE_NAME = 'promo_rule'
|
||||||
|
AND INDEX_NAME = 'idx_deleted_at'
|
||||||
|
);
|
||||||
|
|
||||||
|
SET @sql = IF(
|
||||||
|
@index_exists = 1,
|
||||||
|
'ALTER TABLE `promo_rule` DROP INDEX `idx_deleted_at`',
|
||||||
|
'SELECT ''Index idx_deleted_at does not exist on promo_rule table'''
|
||||||
|
);
|
||||||
|
|
||||||
|
PREPARE stmt FROM @sql;
|
||||||
|
EXECUTE stmt;
|
||||||
|
DEALLOCATE PREPARE stmt;
|
||||||
|
|
||||||
|
SET @index_exists = (
|
||||||
|
SELECT COUNT(*)
|
||||||
|
FROM INFORMATION_SCHEMA.STATISTICS
|
||||||
|
WHERE TABLE_SCHEMA = DATABASE()
|
||||||
|
AND TABLE_NAME = 'promo_rule'
|
||||||
|
AND INDEX_NAME = 'idx_enabled_priority_deleted'
|
||||||
|
);
|
||||||
|
|
||||||
|
SET @sql = IF(
|
||||||
|
@index_exists = 0,
|
||||||
|
'ALTER TABLE `promo_rule` ADD KEY `idx_enabled_priority_deleted` (`enabled`, `deleted_at`, `priority` DESC)',
|
||||||
|
'SELECT ''Index idx_enabled_priority_deleted already exists on promo_rule table'''
|
||||||
|
);
|
||||||
|
|
||||||
|
PREPARE stmt FROM @sql;
|
||||||
|
EXECUTE stmt;
|
||||||
|
DEALLOCATE PREPARE stmt;
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS `subscribe_promo` (
|
CREATE TABLE IF NOT EXISTS `subscribe_promo` (
|
||||||
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||||
`subscribe_id` BIGINT UNSIGNED NOT NULL COMMENT '套餐规格 ID',
|
`subscribe_id` BIGINT UNSIGNED NOT NULL COMMENT '套餐规格 ID',
|
||||||
`quantity` INT NOT NULL DEFAULT 0 COMMENT '购买数量',
|
`quantity` BIGINT NOT NULL DEFAULT 1 COMMENT '购买数量',
|
||||||
`promo_rule_id` BIGINT UNSIGNED NOT NULL COMMENT '促销规则 ID',
|
`promo_rule_id` BIGINT UNSIGNED NOT NULL COMMENT '促销规则 ID',
|
||||||
`promo_price` BIGINT NOT NULL DEFAULT 0 COMMENT '该规格在此规则下的优惠价(分)',
|
`promo_price` BIGINT NOT NULL DEFAULT 0 COMMENT '该规格在此规则下的优惠价(分)',
|
||||||
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
PRIMARY KEY (`id`),
|
PRIMARY KEY (`id`),
|
||||||
UNIQUE KEY `uk_subscribe_qty_rule` (`subscribe_id`, `quantity`, `promo_rule_id`),
|
UNIQUE KEY `uk_subscribe_quantity_rule` (`subscribe_id`, `quantity`, `promo_rule_id`),
|
||||||
KEY `idx_promo_rule_id` (`promo_rule_id`)
|
KEY `idx_promo_rule_id` (`promo_rule_id`)
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='规格促销价表';
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='规格促销价表';
|
||||||
|
|
||||||
|
SET @column_exists = (
|
||||||
|
SELECT COUNT(*)
|
||||||
|
FROM INFORMATION_SCHEMA.COLUMNS
|
||||||
|
WHERE TABLE_SCHEMA = DATABASE()
|
||||||
|
AND TABLE_NAME = 'subscribe_promo'
|
||||||
|
AND COLUMN_NAME = 'quantity'
|
||||||
|
);
|
||||||
|
|
||||||
|
SET @sql = IF(
|
||||||
|
@column_exists = 0,
|
||||||
|
'ALTER TABLE `subscribe_promo` ADD COLUMN `quantity` BIGINT NOT NULL DEFAULT 1 COMMENT ''购买数量'' AFTER `subscribe_id`',
|
||||||
|
'SELECT ''Column quantity already exists in subscribe_promo table'''
|
||||||
|
);
|
||||||
|
|
||||||
|
PREPARE stmt FROM @sql;
|
||||||
|
EXECUTE stmt;
|
||||||
|
DEALLOCATE PREPARE stmt;
|
||||||
|
|
||||||
|
SET @sql = IF(
|
||||||
|
@column_exists = 1,
|
||||||
|
'ALTER TABLE `subscribe_promo` MODIFY COLUMN `quantity` BIGINT NOT NULL DEFAULT 1 COMMENT ''购买数量''',
|
||||||
|
'SELECT ''Column quantity does not exist in subscribe_promo table'''
|
||||||
|
);
|
||||||
|
|
||||||
|
PREPARE stmt FROM @sql;
|
||||||
|
EXECUTE stmt;
|
||||||
|
DEALLOCATE PREPARE stmt;
|
||||||
|
|
||||||
|
SET @index_exists = (
|
||||||
|
SELECT COUNT(*)
|
||||||
|
FROM INFORMATION_SCHEMA.STATISTICS
|
||||||
|
WHERE TABLE_SCHEMA = DATABASE()
|
||||||
|
AND TABLE_NAME = 'subscribe_promo'
|
||||||
|
AND INDEX_NAME = 'uk_subscribe_rule'
|
||||||
|
);
|
||||||
|
|
||||||
|
SET @sql = IF(
|
||||||
|
@index_exists = 1,
|
||||||
|
'ALTER TABLE `subscribe_promo` DROP INDEX `uk_subscribe_rule`',
|
||||||
|
'SELECT ''Index uk_subscribe_rule does not exist on subscribe_promo table'''
|
||||||
|
);
|
||||||
|
|
||||||
|
PREPARE stmt FROM @sql;
|
||||||
|
EXECUTE stmt;
|
||||||
|
DEALLOCATE PREPARE stmt;
|
||||||
|
|
||||||
|
SET @index_exists = (
|
||||||
|
SELECT COUNT(*)
|
||||||
|
FROM INFORMATION_SCHEMA.STATISTICS
|
||||||
|
WHERE TABLE_SCHEMA = DATABASE()
|
||||||
|
AND TABLE_NAME = 'subscribe_promo'
|
||||||
|
AND INDEX_NAME = 'uk_subscribe_qty_rule'
|
||||||
|
);
|
||||||
|
|
||||||
|
SET @sql = IF(
|
||||||
|
@index_exists = 1,
|
||||||
|
'ALTER TABLE `subscribe_promo` DROP INDEX `uk_subscribe_qty_rule`',
|
||||||
|
'SELECT ''Index uk_subscribe_qty_rule does not exist on subscribe_promo table'''
|
||||||
|
);
|
||||||
|
|
||||||
|
PREPARE stmt FROM @sql;
|
||||||
|
EXECUTE stmt;
|
||||||
|
DEALLOCATE PREPARE stmt;
|
||||||
|
|
||||||
|
SET @index_exists = (
|
||||||
|
SELECT COUNT(*)
|
||||||
|
FROM INFORMATION_SCHEMA.STATISTICS
|
||||||
|
WHERE TABLE_SCHEMA = DATABASE()
|
||||||
|
AND TABLE_NAME = 'subscribe_promo'
|
||||||
|
AND INDEX_NAME = 'uk_subscribe_quantity_rule'
|
||||||
|
);
|
||||||
|
|
||||||
|
SET @sql = IF(
|
||||||
|
@index_exists = 0,
|
||||||
|
'ALTER TABLE `subscribe_promo` ADD UNIQUE KEY `uk_subscribe_quantity_rule` (`subscribe_id`, `quantity`, `promo_rule_id`)',
|
||||||
|
'SELECT ''Index uk_subscribe_quantity_rule already exists on subscribe_promo table'''
|
||||||
|
);
|
||||||
|
|
||||||
|
PREPARE stmt FROM @sql;
|
||||||
|
EXECUTE stmt;
|
||||||
|
DEALLOCATE PREPARE stmt;
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS `promo_usage` (
|
CREATE TABLE IF NOT EXISTS `promo_usage` (
|
||||||
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||||
`user_id` BIGINT UNSIGNED NOT NULL COMMENT '用户 ID',
|
`user_id` BIGINT UNSIGNED NOT NULL COMMENT '用户 ID',
|
||||||
|
|||||||
@@ -27,13 +27,13 @@ type promoRuleParams struct {
|
|||||||
InactiveMonths int `json:"inactive_months"`
|
InactiveMonths int `json:"inactive_months"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func EvaluatePromo(ctx context.Context, svcCtx *svc.ServiceContext, userID int64, subscribeID int64) (*PromoResult, error) {
|
func EvaluatePromo(ctx context.Context, svcCtx *svc.ServiceContext, userID int64, subscribeID int64, quantity int64) (*PromoResult, error) {
|
||||||
result := &PromoResult{}
|
result := &PromoResult{}
|
||||||
if svcCtx == nil || svcCtx.PromoModel == nil || svcCtx.DB == nil || userID <= 0 || subscribeID <= 0 {
|
if svcCtx == nil || svcCtx.PromoModel == nil || svcCtx.DB == nil || userID <= 0 || subscribeID <= 0 || quantity <= 0 {
|
||||||
return result, nil
|
return result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
rules, err := svcCtx.PromoModel.QueryEligibleRules(ctx, subscribeID)
|
rules, err := svcCtx.PromoModel.QueryEligibleRules(ctx, subscribeID, quantity)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "query promo rules failed: %v", err.Error())
|
return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "query promo rules failed: %v", err.Error())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -47,13 +47,18 @@ func (l *PreCreateOrderLogic) PreCreateOrder(req *types.PurchaseOrderRequest) (r
|
|||||||
l.Debugf("[PreCreateOrder] Quantity is less than or equal to 0, setting to 1")
|
l.Debugf("[PreCreateOrder] Quantity is less than or equal to 0, setting to 1")
|
||||||
req.Quantity = 1
|
req.Quantity = 1
|
||||||
}
|
}
|
||||||
|
entitlement, entErr := commonLogic.ResolveEntitlementUser(l.ctx, l.svcCtx.DB, u.Id)
|
||||||
|
if entErr != nil {
|
||||||
|
return nil, entErr
|
||||||
|
}
|
||||||
|
|
||||||
targetSubscribeID := req.SubscribeId
|
targetSubscribeID := req.SubscribeId
|
||||||
|
orderType := uint8(1)
|
||||||
isSingleModeRenewal := false
|
isSingleModeRenewal := false
|
||||||
decision, routeErr := commonLogic.ResolvePurchaseRoute(
|
decision, routeErr := commonLogic.ResolvePurchaseRoute(
|
||||||
l.ctx,
|
l.ctx,
|
||||||
l.svcCtx.Config.Subscribe.SingleModel,
|
l.svcCtx.Config.Subscribe.SingleModel,
|
||||||
u.Id,
|
entitlement.EffectiveUserID,
|
||||||
req.SubscribeId,
|
req.SubscribeId,
|
||||||
l.svcCtx.UserModel.FindSingleModeAnchorSubscribe,
|
l.svcCtx.UserModel.FindSingleModeAnchorSubscribe,
|
||||||
)
|
)
|
||||||
@@ -68,15 +73,44 @@ func (l *PreCreateOrderLogic) PreCreateOrder(req *types.PurchaseOrderRequest) (r
|
|||||||
targetSubscribeID = decision.ResolvedSubscribeID
|
targetSubscribeID = decision.ResolvedSubscribeID
|
||||||
isSingleModeRenewal = decision.Route == commonLogic.PurchaseRoutePurchaseToRenewal
|
isSingleModeRenewal = decision.Route == commonLogic.PurchaseRoutePurchaseToRenewal
|
||||||
if isSingleModeRenewal && decision.Anchor != nil {
|
if isSingleModeRenewal && decision.Anchor != nil {
|
||||||
|
orderType = 2
|
||||||
l.Infow("[PreCreateOrder] single mode purchase routed to renewal preview",
|
l.Infow("[PreCreateOrder] single mode purchase routed to renewal preview",
|
||||||
logger.Field("mode", "single"),
|
logger.Field("mode", "single"),
|
||||||
logger.Field("route", "purchase_to_renewal"),
|
logger.Field("route", "purchase_to_renewal"),
|
||||||
logger.Field("anchor_user_subscribe_id", decision.Anchor.Id),
|
logger.Field("anchor_user_subscribe_id", decision.Anchor.Id),
|
||||||
logger.Field("user_id", u.Id),
|
logger.Field("user_id", u.Id),
|
||||||
|
logger.Field("effective_user_id", entitlement.EffectiveUserID),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Keep promo eligibility preview aligned with Purchase: an existing paid subscription
|
||||||
|
// routes the request to renewal semantics, where first-purchase promos are disabled.
|
||||||
|
if !l.svcCtx.Config.Subscribe.SingleModel && orderType == 1 {
|
||||||
|
var existSub user.Subscribe
|
||||||
|
if e := l.svcCtx.DB.WithContext(l.ctx).
|
||||||
|
Model(&user.Subscribe{}).
|
||||||
|
Where("user_id = ? AND token != '' AND (order_id > 0 OR token LIKE 'iap:%')", entitlement.EffectiveUserID).
|
||||||
|
Order("expire_time DESC").
|
||||||
|
Order("updated_at DESC").
|
||||||
|
Order("id DESC").
|
||||||
|
First(&existSub).Error; e == nil && existSub.Id > 0 && existSub.Token != "" {
|
||||||
|
orderType = 2
|
||||||
|
l.Infow("[PreCreateOrder] purchase preview routed to renewal because an existing subscription was found",
|
||||||
|
logger.Field("route_mode", "global_single_subscription"),
|
||||||
|
logger.Field("route", "purchase_to_existing_subscription"),
|
||||||
|
logger.Field("existing_subscribe_id", existSub.Id),
|
||||||
|
logger.Field("existing_status", existSub.Status),
|
||||||
|
logger.Field("user_id", u.Id),
|
||||||
|
logger.Field("effective_user_id", entitlement.EffectiveUserID),
|
||||||
|
logger.Field("resolved_subscribe_id", targetSubscribeID),
|
||||||
|
)
|
||||||
|
} else if e != nil && !errors.Is(e, gorm.ErrRecordNotFound) {
|
||||||
|
l.Errorw("[PreCreateOrder] Database query error", logger.Field("error", e.Error()), logger.Field("user_id", u.Id))
|
||||||
|
return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "find existing subscription error: %v", e.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// find subscribe plan
|
// find subscribe plan
|
||||||
sub, err := l.svcCtx.SubscribeModel.FindOne(l.ctx, targetSubscribeID)
|
sub, err := l.svcCtx.SubscribeModel.FindOne(l.ctx, targetSubscribeID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -86,7 +120,7 @@ func (l *PreCreateOrderLogic) PreCreateOrder(req *types.PurchaseOrderRequest) (r
|
|||||||
|
|
||||||
// check subscribe plan quota limit for new purchase flow only
|
// check subscribe plan quota limit for new purchase flow only
|
||||||
if !isSingleModeRenewal && sub.Quota > 0 {
|
if !isSingleModeRenewal && sub.Quota > 0 {
|
||||||
userSub, err := l.svcCtx.UserModel.QueryUserSubscribe(l.ctx, u.Id)
|
userSub, err := l.svcCtx.UserModel.QueryUserSubscribe(l.ctx, entitlement.EffectiveUserID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
l.Errorw("[PreCreateOrder] Database query error", logger.Field("error", err.Error()), logger.Field("user_id", u.Id))
|
l.Errorw("[PreCreateOrder] Database query error", logger.Field("error", err.Error()), logger.Field("user_id", u.Id))
|
||||||
return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "find user subscription error: %v", err.Error())
|
return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "find user subscription error: %v", err.Error())
|
||||||
@@ -102,7 +136,7 @@ func (l *PreCreateOrderLogic) PreCreateOrder(req *types.PurchaseOrderRequest) (r
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
newUserDiscount, err := resolveNewUserDiscountEligibility(l.ctx, l.svcCtx.DB, u.Id, targetSubscribeID, req.Quantity, sub.Discount)
|
newUserDiscount, err := resolveNewUserDiscountEligibility(l.ctx, l.svcCtx.DB, entitlement.EffectiveUserID, targetSubscribeID, req.Quantity, sub.Discount)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
l.Errorw("[PreCreateOrder] Database query error resolving new user eligibility",
|
l.Errorw("[PreCreateOrder] Database query error resolving new user eligibility",
|
||||||
logger.Field("error", err.Error()),
|
logger.Field("error", err.Error()),
|
||||||
@@ -117,13 +151,13 @@ func (l *PreCreateOrderLogic) PreCreateOrder(req *types.PurchaseOrderRequest) (r
|
|||||||
priceResult, err := calculatePurchasePrice(
|
priceResult, err := calculatePurchasePrice(
|
||||||
l.ctx,
|
l.ctx,
|
||||||
l.svcCtx,
|
l.svcCtx,
|
||||||
u.Id,
|
entitlement.EffectiveUserID,
|
||||||
targetSubscribeID,
|
targetSubscribeID,
|
||||||
sub.UnitPrice,
|
sub.UnitPrice,
|
||||||
req.Quantity,
|
req.Quantity,
|
||||||
newUserDiscount.Discounts,
|
newUserDiscount.Discounts,
|
||||||
newUserDiscount.EligibleForDiscount,
|
newUserDiscount.EligibleForDiscount,
|
||||||
!isSingleModeRenewal,
|
orderType == 1,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
l.Errorw("[PreCreateOrder] Promo price calculation error",
|
l.Errorw("[PreCreateOrder] Promo price calculation error",
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ func calculatePurchasePrice(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if allowPromo {
|
if allowPromo {
|
||||||
promoResult, err := commonLogic.EvaluatePromo(ctx, svcCtx, userID, subscribeID)
|
promoResult, err := commonLogic.EvaluatePromo(ctx, svcCtx, userID, subscribeID, quantity)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,21 +11,30 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type fakePromoModel struct {
|
type fakePromoModel struct {
|
||||||
rules []*promo.RuleWithPrice
|
rules []*promo.RuleWithPrice
|
||||||
|
lastSubscribeID int64
|
||||||
|
lastQuantity int64
|
||||||
|
requireQuantity int64
|
||||||
|
quantityMismatch []*promo.RuleWithPrice
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m fakePromoModel) QueryEligibleRules(context.Context, int64) ([]*promo.RuleWithPrice, error) {
|
func (m *fakePromoModel) QueryEligibleRules(_ context.Context, subscribeID int64, quantity int64) ([]*promo.RuleWithPrice, error) {
|
||||||
|
m.lastSubscribeID = subscribeID
|
||||||
|
m.lastQuantity = quantity
|
||||||
|
if m.requireQuantity > 0 && quantity != m.requireQuantity {
|
||||||
|
return m.quantityMismatch, nil
|
||||||
|
}
|
||||||
return m.rules, nil
|
return m.rules, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m fakePromoModel) InsertUsage(context.Context, *promo.Usage, ...*gorm.DB) error {
|
func (m *fakePromoModel) InsertUsage(context.Context, *promo.Usage, ...*gorm.DB) error {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestCalculatePurchasePricePromoSkipsPercentDiscount(t *testing.T) {
|
func TestCalculatePurchasePricePromoSkipsPercentDiscount(t *testing.T) {
|
||||||
svcCtx := &svc.ServiceContext{
|
svcCtx := &svc.ServiceContext{
|
||||||
DB: &gorm.DB{},
|
DB: &gorm.DB{},
|
||||||
PromoModel: fakePromoModel{rules: []*promo.RuleWithPrice{
|
PromoModel: &fakePromoModel{rules: []*promo.RuleWithPrice{
|
||||||
{
|
{
|
||||||
Rule: promo.Rule{
|
Rule: promo.Rule{
|
||||||
Id: 9,
|
Id: 9,
|
||||||
@@ -73,7 +82,7 @@ func TestCalculatePurchasePricePromoSkipsPercentDiscount(t *testing.T) {
|
|||||||
func TestCalculatePurchasePriceIgnoresInvalidPromoPrice(t *testing.T) {
|
func TestCalculatePurchasePriceIgnoresInvalidPromoPrice(t *testing.T) {
|
||||||
svcCtx := &svc.ServiceContext{
|
svcCtx := &svc.ServiceContext{
|
||||||
DB: &gorm.DB{},
|
DB: &gorm.DB{},
|
||||||
PromoModel: fakePromoModel{rules: []*promo.RuleWithPrice{
|
PromoModel: &fakePromoModel{rules: []*promo.RuleWithPrice{
|
||||||
{
|
{
|
||||||
Rule: promo.Rule{
|
Rule: promo.Rule{
|
||||||
Id: 10,
|
Id: 10,
|
||||||
@@ -111,3 +120,52 @@ func TestCalculatePurchasePriceIgnoresInvalidPromoPrice(t *testing.T) {
|
|||||||
t.Fatalf("promo fields = (%d, %d), want (0, 0)", result.PromoRuleId, result.PromoDiscount)
|
t.Fatalf("promo fields = (%d, %d), want (0, 0)", result.PromoRuleId, result.PromoDiscount)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestCalculatePurchasePricePassesQuantityToPromoEvaluation(t *testing.T) {
|
||||||
|
promoModel := &fakePromoModel{
|
||||||
|
requireQuantity: 6,
|
||||||
|
rules: []*promo.RuleWithPrice{
|
||||||
|
{
|
||||||
|
Rule: promo.Rule{
|
||||||
|
Id: 11,
|
||||||
|
Name: "quantity campaign",
|
||||||
|
Type: promo.RuleTypeCampaign,
|
||||||
|
Enabled: true,
|
||||||
|
},
|
||||||
|
PromoPrice: 500,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
svcCtx := &svc.ServiceContext{
|
||||||
|
DB: &gorm.DB{},
|
||||||
|
PromoModel: promoModel,
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := calculatePurchasePrice(
|
||||||
|
context.Background(),
|
||||||
|
svcCtx,
|
||||||
|
1,
|
||||||
|
2,
|
||||||
|
1000,
|
||||||
|
6,
|
||||||
|
[]types.SubscribeDiscount{{Quantity: 6, Discount: 80}},
|
||||||
|
true,
|
||||||
|
true,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("calculatePurchasePrice returned error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if promoModel.lastSubscribeID != 2 {
|
||||||
|
t.Fatalf("lastSubscribeID = %d, want 2", promoModel.lastSubscribeID)
|
||||||
|
}
|
||||||
|
if promoModel.lastQuantity != 6 {
|
||||||
|
t.Fatalf("lastQuantity = %d, want 6", promoModel.lastQuantity)
|
||||||
|
}
|
||||||
|
if result.PayableBase != 3000 {
|
||||||
|
t.Fatalf("PayableBase = %d, want 3000", result.PayableBase)
|
||||||
|
}
|
||||||
|
if result.PromoRuleId != 11 {
|
||||||
|
t.Fatalf("PromoRuleId = %d, want 11", result.PromoRuleId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -57,6 +57,12 @@ func loadSubscribePromoMap(ctx context.Context, svcCtx *svc.ServiceContext, subs
|
|||||||
evaluator := promoEligibilityEvaluator{ctx: ctx, db: svcCtx.DB, userInfo: userInfo}
|
evaluator := promoEligibilityEvaluator{ctx: ctx, db: svcCtx.DB, userInfo: userInfo}
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
for _, candidate := range candidates {
|
for _, candidate := range candidates {
|
||||||
|
if candidate.Quantity <= 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if result[candidate.SubscribeId] == nil {
|
||||||
|
result[candidate.SubscribeId] = make(map[int64]*types.SubscribePromo)
|
||||||
|
}
|
||||||
if _, exists := result[candidate.SubscribeId][candidate.Quantity]; exists {
|
if _, exists := result[candidate.SubscribeId][candidate.Quantity]; exists {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -70,9 +76,6 @@ func loadSubscribePromoMap(ctx context.Context, svcCtx *svc.ServiceContext, subs
|
|||||||
if !ok {
|
if !ok {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if _, exists := result[candidate.SubscribeId]; !exists {
|
|
||||||
result[candidate.SubscribeId] = make(map[int64]*types.SubscribePromo)
|
|
||||||
}
|
|
||||||
result[candidate.SubscribeId][candidate.Quantity] = &types.SubscribePromo{
|
result[candidate.SubscribeId][candidate.Quantity] = &types.SubscribePromo{
|
||||||
RuleName: candidate.RuleName,
|
RuleName: candidate.RuleName,
|
||||||
RuleType: candidate.RuleType,
|
RuleType: candidate.RuleType,
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ type RuleWithPrice struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type Model interface {
|
type Model interface {
|
||||||
QueryEligibleRules(ctx context.Context, subscribeId int64) ([]*RuleWithPrice, error)
|
QueryEligibleRules(ctx context.Context, subscribeId int64, quantity int64) ([]*RuleWithPrice, error)
|
||||||
InsertUsage(ctx context.Context, data *Usage, tx ...*gorm.DB) error
|
InsertUsage(ctx context.Context, data *Usage, tx ...*gorm.DB) error
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -25,13 +25,13 @@ func NewModel(db *gorm.DB, _ *redis.Client) Model {
|
|||||||
return &defaultPromoModel{db: db}
|
return &defaultPromoModel{db: db}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *defaultPromoModel) QueryEligibleRules(ctx context.Context, subscribeId int64) ([]*RuleWithPrice, error) {
|
func (m *defaultPromoModel) QueryEligibleRules(ctx context.Context, subscribeId int64, quantity int64) ([]*RuleWithPrice, error) {
|
||||||
var list []*RuleWithPrice
|
var list []*RuleWithPrice
|
||||||
err := m.db.WithContext(ctx).
|
err := m.db.WithContext(ctx).
|
||||||
Table("promo_rule AS pr").
|
Table("promo_rule AS pr").
|
||||||
Select("pr.*, sp.promo_price").
|
Select("pr.*, sp.promo_price").
|
||||||
Joins("JOIN subscribe_promo AS sp ON sp.promo_rule_id = pr.id").
|
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("sp.subscribe_id = ? AND sp.quantity = ? AND sp.promo_price > 0 AND pr.enabled = ?", subscribeId, quantity, true).
|
||||||
Where("pr.deleted_at IS NULL").
|
Where("pr.deleted_at IS NULL").
|
||||||
Order("pr.priority DESC").
|
Order("pr.priority DESC").
|
||||||
Order("pr.id ASC").
|
Order("pr.id ASC").
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ func (Rule) TableName() string {
|
|||||||
type SubscribePromo struct {
|
type SubscribePromo struct {
|
||||||
Id int64 `gorm:"primaryKey"`
|
Id int64 `gorm:"primaryKey"`
|
||||||
SubscribeId int64 `gorm:"type:bigint unsigned;not null;comment:Subscribe ID"`
|
SubscribeId int64 `gorm:"type:bigint unsigned;not null;comment:Subscribe ID"`
|
||||||
|
Quantity int64 `gorm:"type:bigint;not null;default:1;comment:Quantity"`
|
||||||
PromoRuleId int64 `gorm:"type:bigint unsigned;not null;comment:Promo Rule 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"`
|
PromoPrice int64 `gorm:"type:bigint;not null;default:0;comment:Promo Price"`
|
||||||
CreatedAt time.Time `gorm:"<-:create;comment:Create Time"`
|
CreatedAt time.Time `gorm:"<-:create;comment:Create Time"`
|
||||||
|
|||||||
@@ -3680,6 +3680,9 @@
|
|||||||
"discount": {
|
"discount": {
|
||||||
"type": "number",
|
"type": "number",
|
||||||
"format": "double"
|
"format": "double"
|
||||||
|
},
|
||||||
|
"promo": {
|
||||||
|
"$ref": "#/definitions/SubscribePromo"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"title": "SubscribeDiscount",
|
"title": "SubscribeDiscount",
|
||||||
|
|||||||
@@ -149,7 +149,20 @@ func (l *ActivateOrderLogic) ProcessTask(ctx context.Context, task *asynq.Task)
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
l.recordPromoUsage(ctx, orderInfo)
|
if err = l.recordPromoUsage(ctx, orderInfo); err != nil {
|
||||||
|
if releaseErr := l.releaseClaim(ctx, orderInfo.OrderNo); releaseErr != nil {
|
||||||
|
logger.WithContext(ctx).Error("[ActivateOrderLogic] releaseClaim also failed, stuck recovery will handle",
|
||||||
|
logger.Field("order_no", orderInfo.OrderNo),
|
||||||
|
logger.Field("release_error", releaseErr.Error()),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
logger.WithContext(ctx).Error("[ActivateOrderLogic] 促销使用记录写入失败,将重试",
|
||||||
|
logger.Field("order_no", orderInfo.OrderNo),
|
||||||
|
logger.Field("promo_rule_id", orderInfo.PromoRuleId),
|
||||||
|
logger.Field("error", err.Error()),
|
||||||
|
)
|
||||||
|
return err
|
||||||
|
}
|
||||||
l.finalizeCouponAndOrder(ctx, orderInfo)
|
l.finalizeCouponAndOrder(ctx, orderInfo)
|
||||||
|
|
||||||
commonLogic.SubscriptionTraceInfo(logger.WithContext(ctx), commonLogic.SubscriptionTraceFlowOrder, "activation_finished",
|
commonLogic.SubscriptionTraceInfo(logger.WithContext(ctx), commonLogic.SubscriptionTraceFlowOrder, "activation_finished",
|
||||||
@@ -159,9 +172,9 @@ func (l *ActivateOrderLogic) ProcessTask(ctx context.Context, task *asynq.Task)
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (l *ActivateOrderLogic) recordPromoUsage(ctx context.Context, orderInfo *order.Order) {
|
func (l *ActivateOrderLogic) recordPromoUsage(ctx context.Context, orderInfo *order.Order) error {
|
||||||
if orderInfo == nil || orderInfo.PromoRuleId <= 0 || orderInfo.Quantity <= 0 || orderInfo.SubscribeId <= 0 || orderInfo.OrderNo == "" {
|
if orderInfo == nil || orderInfo.PromoRuleId <= 0 || orderInfo.Quantity <= 0 || orderInfo.SubscribeId <= 0 || orderInfo.OrderNo == "" {
|
||||||
return
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
promoPrice := int64(0)
|
promoPrice := int64(0)
|
||||||
@@ -169,10 +182,10 @@ func (l *ActivateOrderLogic) recordPromoUsage(ctx context.Context, orderInfo *or
|
|||||||
promoPrice = (orderInfo.Price - orderInfo.PromoDiscount) / orderInfo.Quantity
|
promoPrice = (orderInfo.Price - orderInfo.PromoDiscount) / orderInfo.Quantity
|
||||||
}
|
}
|
||||||
if promoPrice <= 0 {
|
if promoPrice <= 0 {
|
||||||
return
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
err := l.svc.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
return l.svc.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||||
var count int64
|
var count int64
|
||||||
if e := tx.Model(&promo.Usage{}).Where("order_no = ?", orderInfo.OrderNo).Count(&count).Error; e != nil {
|
if e := tx.Model(&promo.Usage{}).Where("order_no = ?", orderInfo.OrderNo).Count(&count).Error; e != nil {
|
||||||
return e
|
return e
|
||||||
@@ -188,13 +201,6 @@ func (l *ActivateOrderLogic) recordPromoUsage(ctx context.Context, orderInfo *or
|
|||||||
PromoPrice: promoPrice,
|
PromoPrice: promoPrice,
|
||||||
}, tx)
|
}, 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
|
// parsePayload unMarshals the task payload into a structured format
|
||||||
|
|||||||
Reference in New Issue
Block a user