不允许购买第二个主套餐
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 7m50s
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 7m50s
This commit is contained in:
@@ -63,15 +63,16 @@ func NewClientAssetHandler(
|
|||||||
}
|
}
|
||||||
|
|
||||||
type resolvedAssetContext struct {
|
type resolvedAssetContext struct {
|
||||||
CustomerID uint
|
CustomerID uint
|
||||||
Identifier string
|
Identifier string
|
||||||
Asset *dto.AssetResolveResponse
|
Asset *dto.AssetResolveResponse
|
||||||
Generation int
|
Generation int
|
||||||
WalletBalance int64
|
WalletBalance int64
|
||||||
SkipPermissionCtx context.Context
|
SkipPermissionCtx context.Context
|
||||||
IsAgentChannel bool
|
IsAgentChannel bool
|
||||||
SellerShopID uint
|
SellerShopID uint
|
||||||
HasAddonMainPackage bool
|
HasAddonMainPackage bool
|
||||||
|
HasFormalMainPackage bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// resolveAssetFromIdentifier 统一执行资产解析与归属校验
|
// resolveAssetFromIdentifier 统一执行资产解析与归属校验
|
||||||
@@ -242,6 +243,16 @@ func (h *ClientAssetHandler) GetAvailablePackages(c *fiber.Ctx) error {
|
|||||||
} else if err != gorm.ErrRecordNotFound {
|
} else if err != gorm.ErrRecordNotFound {
|
||||||
return errors.Wrap(errors.CodeDatabaseError, err, "查询主套餐失败")
|
return errors.Wrap(errors.CodeDatabaseError, err, "查询主套餐失败")
|
||||||
}
|
}
|
||||||
|
if hasBlockingMain, err := packagepkg.HasBlockingMainPackageForFormal(
|
||||||
|
h.db.WithContext(resolved.SkipPermissionCtx),
|
||||||
|
resolved.Asset.AssetType,
|
||||||
|
resolved.Asset.AssetID,
|
||||||
|
time.Now(),
|
||||||
|
); err != nil {
|
||||||
|
return errors.Wrap(errors.CodeDatabaseError, err, "查询主套餐失败")
|
||||||
|
} else {
|
||||||
|
resolved.HasFormalMainPackage = hasBlockingMain
|
||||||
|
}
|
||||||
|
|
||||||
listCtx := resolved.SkipPermissionCtx
|
listCtx := resolved.SkipPermissionCtx
|
||||||
if resolved.IsAgentChannel {
|
if resolved.IsAgentChannel {
|
||||||
@@ -530,6 +541,9 @@ func buildClientPackageItem(
|
|||||||
}
|
}
|
||||||
|
|
||||||
isAddon := pkg.PackageType == constants.PackageTypeAddon
|
isAddon := pkg.PackageType == constants.PackageTypeAddon
|
||||||
|
if pkg.PackageType == constants.PackageTypeFormal && resolved.HasFormalMainPackage {
|
||||||
|
return dto.ClientPackageItem{}, false
|
||||||
|
}
|
||||||
if isAddon && !resolved.HasAddonMainPackage {
|
if isAddon && !resolved.HasAddonMainPackage {
|
||||||
return dto.ClientPackageItem{}, false
|
return dto.ClientPackageItem{}, false
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import (
|
|||||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||||
"github.com/break/junhong_cmp_fiber/internal/model/dto"
|
"github.com/break/junhong_cmp_fiber/internal/model/dto"
|
||||||
asset "github.com/break/junhong_cmp_fiber/internal/service/asset"
|
asset "github.com/break/junhong_cmp_fiber/internal/service/asset"
|
||||||
|
packagepkg "github.com/break/junhong_cmp_fiber/internal/service/package"
|
||||||
"github.com/break/junhong_cmp_fiber/internal/service/purchase_validation"
|
"github.com/break/junhong_cmp_fiber/internal/service/purchase_validation"
|
||||||
"github.com/break/junhong_cmp_fiber/internal/store/postgres"
|
"github.com/break/junhong_cmp_fiber/internal/store/postgres"
|
||||||
"github.com/break/junhong_cmp_fiber/pkg/alipay"
|
"github.com/break/junhong_cmp_fiber/pkg/alipay"
|
||||||
@@ -1247,6 +1248,10 @@ func (s *Service) PayOrder(ctx context.Context, customerID uint, orderID uint, r
|
|||||||
return &dto.ClientPayOrderResponse{PaymentMethod: model.PaymentMethodWallet}, nil
|
return &dto.ClientPayOrderResponse{PaymentMethod: model.PaymentMethodWallet}, nil
|
||||||
|
|
||||||
case model.PaymentMethodWechat:
|
case model.PaymentMethodWechat:
|
||||||
|
if err := s.ensureOrderFormalPackagePayable(skipCtx, order); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
activeConfig, appID, err := s.resolveWechatConfig(skipCtx, req.AppType)
|
activeConfig, appID, err := s.resolveWechatConfig(skipCtx, req.AppType)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -1303,6 +1308,10 @@ func (s *Service) PayOrder(ctx context.Context, customerID uint, orderID uint, r
|
|||||||
}, nil
|
}, nil
|
||||||
|
|
||||||
case model.PaymentMethodAlipay:
|
case model.PaymentMethodAlipay:
|
||||||
|
if err := s.ensureOrderFormalPackagePayable(skipCtx, order); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
// 支付宝支付:不需要 app_type / OpenID
|
// 支付宝支付:不需要 app_type / OpenID
|
||||||
activeConfig, err := s.wechatConfigService.GetActiveConfig(skipCtx)
|
activeConfig, err := s.wechatConfigService.GetActiveConfig(skipCtx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -1337,6 +1346,58 @@ func (s *Service) PayOrder(ctx context.Context, customerID uint, orderID uint, r
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Service) ensureOrderFormalPackagePayable(ctx context.Context, order *model.Order) error {
|
||||||
|
if order == nil {
|
||||||
|
return errors.New(errors.CodeInvalidParam)
|
||||||
|
}
|
||||||
|
|
||||||
|
var items []*model.OrderItem
|
||||||
|
if err := s.db.WithContext(ctx).Where("order_id = ?", order.ID).Find(&items).Error; err != nil {
|
||||||
|
return errors.Wrap(errors.CodeDatabaseError, err, "查询订单明细失败")
|
||||||
|
}
|
||||||
|
|
||||||
|
hasFormal := false
|
||||||
|
for _, item := range items {
|
||||||
|
if item == nil || item.PackageType == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if *item.PackageType == constants.PackageTypeFormal {
|
||||||
|
hasFormal = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !hasFormal {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
carrierType := ""
|
||||||
|
carrierID := uint(0)
|
||||||
|
if order.OrderType == model.OrderTypeSingleCard && order.IotCardID != nil {
|
||||||
|
carrierType = constants.AssetWalletResourceTypeIotCard
|
||||||
|
carrierID = *order.IotCardID
|
||||||
|
} else if order.OrderType == model.OrderTypeDevice && order.DeviceID != nil {
|
||||||
|
carrierType = constants.AssetWalletResourceTypeDevice
|
||||||
|
carrierID = *order.DeviceID
|
||||||
|
}
|
||||||
|
if carrierID == 0 {
|
||||||
|
return errors.New(errors.CodeInvalidParam, "订单缺少套餐载体")
|
||||||
|
}
|
||||||
|
|
||||||
|
hasBlockingMain, err := packagepkg.HasBlockingMainPackageForFormal(
|
||||||
|
s.db.WithContext(ctx),
|
||||||
|
carrierType,
|
||||||
|
carrierID,
|
||||||
|
time.Now(),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(errors.CodeDatabaseError, err, "查询主套餐失败")
|
||||||
|
}
|
||||||
|
if hasBlockingMain {
|
||||||
|
return errors.New(errors.CodeMainPackageExists)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Service) getAssetGeneration(ctx context.Context, assetType string, assetID uint) (int, error) {
|
func (s *Service) getAssetGeneration(ctx context.Context, assetType string, assetID uint) (int, error) {
|
||||||
switch assetType {
|
switch assetType {
|
||||||
case "card":
|
case "card":
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import (
|
|||||||
"github.com/redis/go-redis/v9"
|
"github.com/redis/go-redis/v9"
|
||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
|
"gorm.io/gorm/clause"
|
||||||
)
|
)
|
||||||
|
|
||||||
// WechatConfigServiceInterface 支付配置服务接口
|
// WechatConfigServiceInterface 支付配置服务接口
|
||||||
@@ -602,7 +603,7 @@ func (s *Service) CreateH5Order(ctx context.Context, req *dto.CreateOrderRequest
|
|||||||
purchaseRole := ""
|
purchaseRole := ""
|
||||||
var sellerShopID *uint = resourceShopID
|
var sellerShopID *uint = resourceShopID
|
||||||
var sellerCostPrice int64
|
var sellerCostPrice int64
|
||||||
var expiresAt *time.Time // 待支付订单设置过期时间,立即支付的订单为 nil
|
var expiresAt *time.Time // 待支付订单设置过期时间,立即支付的订单为 nil
|
||||||
var itemCostPriceMap map[uint]int64 // 各套餐成本单价映射,用于 OrderItem.CostPrice 字段
|
var itemCostPriceMap map[uint]int64 // 各套餐成本单价映射,用于 OrderItem.CostPrice 字段
|
||||||
|
|
||||||
// 场景判断:offline(平台代购)、wallet(代理钱包支付)、其他(待支付)
|
// 场景判断:offline(平台代购)、wallet(代理钱包支付)、其他(待支付)
|
||||||
@@ -2029,6 +2030,24 @@ func (s *Service) activatePackage(ctx context.Context, tx *gorm.DB, order *model
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Service) lockPackageCarrier(ctx context.Context, tx *gorm.DB, carrierType string, carrierID uint) error {
|
||||||
|
switch carrierType {
|
||||||
|
case constants.AssetWalletResourceTypeIotCard, "card":
|
||||||
|
var card model.IotCard
|
||||||
|
if err := tx.WithContext(ctx).Clauses(clause.Locking{Strength: "UPDATE"}).First(&card, carrierID).Error; err != nil {
|
||||||
|
return errors.Wrap(errors.CodeDatabaseError, err, "锁定卡失败")
|
||||||
|
}
|
||||||
|
case constants.AssetWalletResourceTypeDevice:
|
||||||
|
var device model.Device
|
||||||
|
if err := tx.WithContext(ctx).Clauses(clause.Locking{Strength: "UPDATE"}).First(&device, carrierID).Error; err != nil {
|
||||||
|
return errors.Wrap(errors.CodeDatabaseError, err, "锁定设备失败")
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return errors.New(errors.CodeInvalidParam, "无效的套餐载体类型")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// validatePackageTypeMix 任务 8.1: 检查混买限制
|
// validatePackageTypeMix 任务 8.1: 检查混买限制
|
||||||
func (s *Service) validatePackageTypeMix(tx *gorm.DB, items []*model.OrderItem) error {
|
func (s *Service) validatePackageTypeMix(tx *gorm.DB, items []*model.OrderItem) error {
|
||||||
hasFormal := false
|
hasFormal := false
|
||||||
@@ -2204,15 +2223,17 @@ func (s *Service) markOrderCreated(ctx context.Context, idempotencyKey string, o
|
|||||||
|
|
||||||
// activateMainPackage 任务 8.2-8.4: 主套餐激活逻辑
|
// activateMainPackage 任务 8.2-8.4: 主套餐激活逻辑
|
||||||
func (s *Service) activateMainPackage(ctx context.Context, tx *gorm.DB, order *model.Order, pkg *model.Package, carrierType string, carrierID uint, now time.Time) error {
|
func (s *Service) activateMainPackage(ctx context.Context, tx *gorm.DB, order *model.Order, pkg *model.Package, carrierType string, carrierID uint, now time.Time) error {
|
||||||
// 检查是否有生效中主套餐
|
if err := s.lockPackageCarrier(ctx, tx, carrierType, carrierID); err != nil {
|
||||||
var activeMainPackage model.PackageUsage
|
return err
|
||||||
err := tx.Where("status = ?", constants.PackageUsageStatusActive).
|
}
|
||||||
Where("master_usage_id IS NULL").
|
|
||||||
Where(carrierType+"_id = ?", carrierID).
|
|
||||||
Order("priority ASC").
|
|
||||||
First(&activeMainPackage).Error
|
|
||||||
|
|
||||||
hasActiveMain := err == nil
|
hasBlockingMain, err := packagepkg.HasBlockingMainPackageForFormal(tx.WithContext(ctx), carrierType, carrierID, now)
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(errors.CodeDatabaseError, err, "查询主套餐失败")
|
||||||
|
}
|
||||||
|
if hasBlockingMain {
|
||||||
|
return errors.New(errors.CodeMainPackageExists)
|
||||||
|
}
|
||||||
|
|
||||||
var status int
|
var status int
|
||||||
var priority int
|
var priority int
|
||||||
@@ -2221,80 +2242,63 @@ func (s *Service) activateMainPackage(ctx context.Context, tx *gorm.DB, order *m
|
|||||||
var nextResetAt *time.Time
|
var nextResetAt *time.Time
|
||||||
var pendingRealnameActivation bool
|
var pendingRealnameActivation bool
|
||||||
|
|
||||||
if hasActiveMain {
|
status = constants.PackageUsageStatusActive
|
||||||
// 任务 8.3: 有生效中主套餐,新套餐排队
|
priority = 1
|
||||||
status = constants.PackageUsageStatusPending
|
activatedAt = now
|
||||||
// 查询当前最大优先级
|
// 使用工具函数计算过期时间
|
||||||
var maxPriority int
|
expiresAt = packagepkg.CalculateExpiryTime(pkg.CalendarType, activatedAt, pkg.DurationMonths, pkg.DurationDays)
|
||||||
tx.Model(&model.PackageUsage{}).
|
// 计算下次重置时间(基于套餐周期类型)
|
||||||
Where(carrierType+"_id = ?", carrierID).
|
nextResetAt = packagepkg.CalculateNextResetTime(pkg.DataResetCycle, pkg.CalendarType, now, activatedAt)
|
||||||
Select("COALESCE(MAX(priority), 0)").
|
|
||||||
Scan(&maxPriority)
|
|
||||||
priority = maxPriority + 1
|
|
||||||
// 排队套餐暂不设置激活时间和过期时间(由激活任务处理)
|
|
||||||
} else {
|
|
||||||
// 任务 8.4: 无生效中主套餐,立即激活
|
|
||||||
status = constants.PackageUsageStatusActive
|
|
||||||
priority = 1
|
|
||||||
activatedAt = now
|
|
||||||
// 使用工具函数计算过期时间
|
|
||||||
expiresAt = packagepkg.CalculateExpiryTime(pkg.CalendarType, activatedAt, pkg.DurationMonths, pkg.DurationDays)
|
|
||||||
// 计算下次重置时间(基于套餐周期类型)
|
|
||||||
nextResetAt = packagepkg.CalculateNextResetTime(pkg.DataResetCycle, pkg.CalendarType, now, activatedAt)
|
|
||||||
}
|
|
||||||
|
|
||||||
// REALNAME-02: 后台囤货场景三维决策(卡类型 + 购买路径 + expiry_base + 实名状态)
|
// REALNAME-02: 后台囤货场景三维决策(卡类型 + 购买路径 + expiry_base + 实名状态)
|
||||||
// 注意:仅在 else 分支(立即激活)时才需要判断是否切换为等实名
|
// 查询卡类型(行业卡永远直接激活,不等实名)
|
||||||
if !hasActiveMain {
|
// 同时查询载体当前实名状态:已实名则跳过 pending,直接激活
|
||||||
// 查询卡类型(行业卡永远直接激活,不等实名)
|
var cardCategory string
|
||||||
// 同时查询载体当前实名状态:已实名则跳过 pending,直接激活
|
var currentlyRealnamed bool
|
||||||
var cardCategory string
|
if carrierType == "iot_card" {
|
||||||
var currentlyRealnamed bool
|
var card model.IotCard
|
||||||
if carrierType == "iot_card" {
|
if err := tx.Select("card_category", "real_name_status").First(&card, carrierID).Error; err == nil {
|
||||||
var card model.IotCard
|
cardCategory = card.CardCategory
|
||||||
if err := tx.Select("card_category", "real_name_status").First(&card, carrierID).Error; err == nil {
|
currentlyRealnamed = card.RealNameStatus == constants.RealNameStatusVerified
|
||||||
cardCategory = card.CardCategory
|
|
||||||
currentlyRealnamed = card.RealNameStatus == constants.RealNameStatusVerified
|
|
||||||
}
|
|
||||||
// err != nil 时 currentlyRealnamed 保守默认 false,不阻断购买流程
|
|
||||||
} else if carrierType == "device" {
|
|
||||||
// 子查询:统计该设备绑定的已实名卡数量(GORM 自动注入 deleted_at IS NULL)
|
|
||||||
var count int64
|
|
||||||
subQuery := tx.Model(&model.DeviceSimBinding{}).
|
|
||||||
Select("iot_card_id").
|
|
||||||
Where("device_id = ? AND bind_status = 1", carrierID)
|
|
||||||
if err := tx.Model(&model.IotCard{}).
|
|
||||||
Where("id IN (?) AND real_name_status = 1", subQuery).
|
|
||||||
Count(&count).Error; err == nil {
|
|
||||||
currentlyRealnamed = count > 0
|
|
||||||
}
|
|
||||||
// err != nil 时保守默认 false,不阻断购买流程
|
|
||||||
}
|
}
|
||||||
|
// err != nil 时 currentlyRealnamed 保守默认 false,不阻断购买流程
|
||||||
// 判断是否 C 端购买(C 端购买前已做实名前置检查,直接激活)
|
} else if carrierType == "device" {
|
||||||
isCEndPurchase := order.BuyerType == model.BuyerTypePersonal
|
// 子查询:统计该设备绑定的已实名卡数量(GORM 自动注入 deleted_at IS NULL)
|
||||||
|
var count int64
|
||||||
if cardCategory != "industry" && !isCEndPurchase {
|
subQuery := tx.Model(&model.DeviceSimBinding{}).
|
||||||
// 后台囤货路径:按 expiry_base 决定是否等实名
|
Select("iot_card_id").
|
||||||
if pkg.ExpiryBase != "from_purchase" {
|
Where("device_id = ? AND bind_status = 1", carrierID)
|
||||||
if currentlyRealnamed {
|
if err := tx.Model(&model.IotCard{}).
|
||||||
s.logger.Info("购买时载体已实名,直接激活套餐",
|
Where("id IN (?) AND real_name_status = 1", subQuery).
|
||||||
zap.String("carrier_type", carrierType),
|
Count(&count).Error; err == nil {
|
||||||
zap.Uint("carrier_id", carrierID))
|
currentlyRealnamed = count > 0
|
||||||
} else {
|
|
||||||
// from_activation(默认):未实名,等实名后激活
|
|
||||||
status = constants.PackageUsageStatusPending
|
|
||||||
pendingRealnameActivation = true
|
|
||||||
activatedAt = time.Time{}
|
|
||||||
expiresAt = time.Time{}
|
|
||||||
nextResetAt = nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// from_purchase:立即激活,status 已为 Active,保持不变
|
|
||||||
}
|
}
|
||||||
// 行业卡或 C 端购买:直接激活,status 已为 Active,保持不变
|
// err != nil 时保守默认 false,不阻断购买流程
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 判断是否 C 端购买(C 端购买前已做实名前置检查,直接激活)
|
||||||
|
isCEndPurchase := order.BuyerType == model.BuyerTypePersonal
|
||||||
|
|
||||||
|
if cardCategory != "industry" && !isCEndPurchase {
|
||||||
|
// 后台囤货路径:按 expiry_base 决定是否等实名
|
||||||
|
if pkg.ExpiryBase != "from_purchase" {
|
||||||
|
if currentlyRealnamed {
|
||||||
|
s.logger.Info("购买时载体已实名,直接激活套餐",
|
||||||
|
zap.String("carrier_type", carrierType),
|
||||||
|
zap.Uint("carrier_id", carrierID))
|
||||||
|
} else {
|
||||||
|
// from_activation(默认):未实名,等实名后激活
|
||||||
|
status = constants.PackageUsageStatusPending
|
||||||
|
pendingRealnameActivation = true
|
||||||
|
activatedAt = time.Time{}
|
||||||
|
expiresAt = time.Time{}
|
||||||
|
nextResetAt = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// from_purchase:立即激活,status 已为 Active,保持不变
|
||||||
|
}
|
||||||
|
// 行业卡或 C 端购买:直接激活,status 已为 Active,保持不变
|
||||||
|
|
||||||
// 创建套餐使用记录
|
// 创建套餐使用记录
|
||||||
virtualTotalMBSnapshot, displayGainRatioSnapshot, enableVirtualDataSnapshot := model.BuildPackageUsageSnapshotValues(pkg)
|
virtualTotalMBSnapshot, displayGainRatioSnapshot, enableVirtualDataSnapshot := model.BuildPackageUsageSnapshotValues(pkg)
|
||||||
retailAmount := order.TotalAmount
|
retailAmount := order.TotalAmount
|
||||||
|
|||||||
@@ -39,3 +39,47 @@ func FindAttachableMainPackageForAddon(tx *gorm.DB, carrierType string, carrierI
|
|||||||
}
|
}
|
||||||
return &mainPackage, nil
|
return &mainPackage, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// FindBlockingMainPackageForFormal 查询阻止购买新主套餐的现有主套餐。
|
||||||
|
// 待生效、生效中、未过期的已用完主套餐都仍占用主套餐资格;只有过期或失效后才允许再次购买主套餐。
|
||||||
|
func FindBlockingMainPackageForFormal(tx *gorm.DB, carrierType string, carrierID uint, now time.Time) (*model.PackageUsage, error) {
|
||||||
|
var mainPackage model.PackageUsage
|
||||||
|
query := tx.Model(&model.PackageUsage{}).
|
||||||
|
Where("master_usage_id IS NULL").
|
||||||
|
Where(
|
||||||
|
"(status IN ? OR (status = ? AND (expires_at IS NULL OR expires_at > ?)))",
|
||||||
|
[]int{
|
||||||
|
constants.PackageUsageStatusPending,
|
||||||
|
constants.PackageUsageStatusActive,
|
||||||
|
},
|
||||||
|
constants.PackageUsageStatusDepleted,
|
||||||
|
now,
|
||||||
|
).
|
||||||
|
Order("priority ASC, created_at ASC")
|
||||||
|
|
||||||
|
switch carrierType {
|
||||||
|
case constants.AssetWalletResourceTypeIotCard, "card":
|
||||||
|
query = query.Where("iot_card_id = ?", carrierID)
|
||||||
|
case constants.AssetWalletResourceTypeDevice:
|
||||||
|
query = query.Where("device_id = ?", carrierID)
|
||||||
|
default:
|
||||||
|
return nil, gorm.ErrRecordNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := query.First(&mainPackage).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &mainPackage, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// HasBlockingMainPackageForFormal 判断载体是否已有阻止新主套餐购买的主套餐。
|
||||||
|
func HasBlockingMainPackageForFormal(tx *gorm.DB, carrierType string, carrierID uint, now time.Time) (bool, error) {
|
||||||
|
_, err := FindBlockingMainPackageForFormal(tx, carrierType, carrierID, now)
|
||||||
|
if err == nil {
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
if err == gorm.ErrRecordNotFound {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,8 +2,10 @@ package purchase_validation
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||||
|
packagepkg "github.com/break/junhong_cmp_fiber/internal/service/package"
|
||||||
"github.com/break/junhong_cmp_fiber/internal/service/packageprice"
|
"github.com/break/junhong_cmp_fiber/internal/service/packageprice"
|
||||||
"github.com/break/junhong_cmp_fiber/internal/store/postgres"
|
"github.com/break/junhong_cmp_fiber/internal/store/postgres"
|
||||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||||
@@ -82,6 +84,10 @@ func (s *Service) ValidateCardPurchase(ctx context.Context, cardID uint, package
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if err := s.validatePackageUsageRules(ctx, constants.AssetWalletResourceTypeIotCard, card.ID, packages); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
return &PurchaseValidationResult{
|
return &PurchaseValidationResult{
|
||||||
Card: card,
|
Card: card,
|
||||||
Packages: packages,
|
Packages: packages,
|
||||||
@@ -113,6 +119,10 @@ func (s *Service) ValidateDevicePurchase(ctx context.Context, deviceID uint, pac
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if err := s.validatePackageUsageRules(ctx, constants.AssetWalletResourceTypeDevice, device.ID, packages); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
return &PurchaseValidationResult{
|
return &PurchaseValidationResult{
|
||||||
Device: device,
|
Device: device,
|
||||||
Packages: packages,
|
Packages: packages,
|
||||||
@@ -179,6 +189,61 @@ func (s *Service) validatePackages(ctx context.Context, packageIDs []uint, serie
|
|||||||
return packages, totalPrice, nil
|
return packages, totalPrice, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// validatePackageUsageRules 校验主套餐和加油包的购买前置条件。
|
||||||
|
func (s *Service) validatePackageUsageRules(ctx context.Context, carrierType string, carrierID uint, packages []*model.Package) error {
|
||||||
|
formalCount := 0
|
||||||
|
hasAddon := false
|
||||||
|
for _, pkg := range packages {
|
||||||
|
if pkg == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
switch pkg.PackageType {
|
||||||
|
case constants.PackageTypeFormal:
|
||||||
|
formalCount++
|
||||||
|
case constants.PackageTypeAddon:
|
||||||
|
hasAddon = true
|
||||||
|
}
|
||||||
|
if formalCount > 0 && hasAddon {
|
||||||
|
return errors.New(errors.CodeMixedOrderForbidden)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if formalCount > 1 {
|
||||||
|
return errors.New(errors.CodeMainPackageExists, "一次只能购买一个主套餐")
|
||||||
|
}
|
||||||
|
|
||||||
|
if formalCount == 1 {
|
||||||
|
hasBlockingMain, err := packagepkg.HasBlockingMainPackageForFormal(
|
||||||
|
s.db.WithContext(ctx),
|
||||||
|
carrierType,
|
||||||
|
carrierID,
|
||||||
|
time.Now(),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(errors.CodeDatabaseError, err, "查询主套餐失败")
|
||||||
|
}
|
||||||
|
if hasBlockingMain {
|
||||||
|
return errors.New(errors.CodeMainPackageExists)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if hasAddon {
|
||||||
|
if _, err := packagepkg.FindAttachableMainPackageForAddon(
|
||||||
|
s.db.WithContext(ctx),
|
||||||
|
carrierType,
|
||||||
|
carrierID,
|
||||||
|
time.Now(),
|
||||||
|
); err != nil {
|
||||||
|
if err == gorm.ErrRecordNotFound {
|
||||||
|
return errors.New(errors.CodeNoMainPackage)
|
||||||
|
}
|
||||||
|
return errors.Wrap(errors.CodeDatabaseError, err, "查询主套餐失败")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// validateAgentAllocation 校验卖家代理的分配记录上架状态,并返回分配记录
|
// validateAgentAllocation 校验卖家代理的分配记录上架状态,并返回分配记录
|
||||||
func (s *Service) validateAgentAllocation(ctx context.Context, sellerShopID, packageID uint) (*model.ShopPackageAllocation, error) {
|
func (s *Service) validateAgentAllocation(ctx context.Context, sellerShopID, packageID uint) (*model.ShopPackageAllocation, error) {
|
||||||
allocation, err := s.packageAllocationStore.GetByShopAndPackageForSystem(ctx, sellerShopID, packageID)
|
allocation, err := s.packageAllocationStore.GetByShopAndPackageForSystem(ctx, sellerShopID, packageID)
|
||||||
@@ -252,6 +317,10 @@ func (s *Service) ValidateAdminOfflineCardPurchase(ctx context.Context, cardID u
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if err := s.validatePackageUsageRules(ctx, constants.AssetWalletResourceTypeIotCard, card.ID, packages); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
return &PurchaseValidationResult{
|
return &PurchaseValidationResult{
|
||||||
Card: card,
|
Card: card,
|
||||||
Packages: packages,
|
Packages: packages,
|
||||||
@@ -279,6 +348,10 @@ func (s *Service) ValidateAdminOfflineDevicePurchase(ctx context.Context, device
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if err := s.validatePackageUsageRules(ctx, constants.AssetWalletResourceTypeDevice, device.ID, packages); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
return &PurchaseValidationResult{
|
return &PurchaseValidationResult{
|
||||||
Device: device,
|
Device: device,
|
||||||
Packages: packages,
|
Packages: packages,
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import (
|
|||||||
"github.com/redis/go-redis/v9"
|
"github.com/redis/go-redis/v9"
|
||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
|
"gorm.io/gorm/clause"
|
||||||
|
|
||||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||||
packagepkg "github.com/break/junhong_cmp_fiber/internal/service/package"
|
packagepkg "github.com/break/junhong_cmp_fiber/internal/service/package"
|
||||||
@@ -496,14 +497,17 @@ func (h *AutoPurchaseHandler) activateMainPackage(
|
|||||||
now time.Time,
|
now time.Time,
|
||||||
) error {
|
) error {
|
||||||
_ = ctx
|
_ = ctx
|
||||||
var activeMainPackage model.PackageUsage
|
if err := h.lockPackageCarrier(ctx, tx, carrierType, carrierID); err != nil {
|
||||||
err := tx.Where("status = ?", constants.PackageUsageStatusActive).
|
return err
|
||||||
Where("master_usage_id IS NULL").
|
}
|
||||||
Where(carrierType+"_id = ?", carrierID).
|
|
||||||
Order("priority ASC").
|
|
||||||
First(&activeMainPackage).Error
|
|
||||||
|
|
||||||
hasActiveMain := err == nil
|
hasBlockingMain, err := packagepkg.HasBlockingMainPackageForFormal(tx.WithContext(ctx), carrierType, carrierID, now)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if hasBlockingMain {
|
||||||
|
return errors.New("已有主套餐,不能重复购买主套餐")
|
||||||
|
}
|
||||||
|
|
||||||
var status int
|
var status int
|
||||||
var priority int
|
var priority int
|
||||||
@@ -512,21 +516,11 @@ func (h *AutoPurchaseHandler) activateMainPackage(
|
|||||||
var nextResetAt *time.Time
|
var nextResetAt *time.Time
|
||||||
var pendingRealnameActivation bool
|
var pendingRealnameActivation bool
|
||||||
|
|
||||||
if hasActiveMain {
|
status = constants.PackageUsageStatusActive
|
||||||
status = constants.PackageUsageStatusPending
|
priority = 1
|
||||||
var maxPriority int
|
activatedAt = now
|
||||||
tx.Model(&model.PackageUsage{}).
|
expiresAt = packagepkg.CalculateExpiryTime(pkg.CalendarType, activatedAt, pkg.DurationMonths, pkg.DurationDays)
|
||||||
Where(carrierType+"_id = ?", carrierID).
|
nextResetAt = packagepkg.CalculateNextResetTime(pkg.DataResetCycle, pkg.CalendarType, now, activatedAt)
|
||||||
Select("COALESCE(MAX(priority), 0)").
|
|
||||||
Scan(&maxPriority)
|
|
||||||
priority = maxPriority + 1
|
|
||||||
} else {
|
|
||||||
status = constants.PackageUsageStatusActive
|
|
||||||
priority = 1
|
|
||||||
activatedAt = now
|
|
||||||
expiresAt = packagepkg.CalculateExpiryTime(pkg.CalendarType, activatedAt, pkg.DurationMonths, pkg.DurationDays)
|
|
||||||
nextResetAt = packagepkg.CalculateNextResetTime(pkg.DataResetCycle, pkg.CalendarType, now, activatedAt)
|
|
||||||
}
|
|
||||||
|
|
||||||
// REALNAME-02: 自动购包属于 C 端充值触发,不需要等实名(C 端已做前置实名检查)
|
// REALNAME-02: 自动购包属于 C 端充值触发,不需要等实名(C 端已做前置实名检查)
|
||||||
// ExpiryBase 仅影响后台囤货路径,此处无需判断
|
// ExpiryBase 仅影响后台囤货路径,此处无需判断
|
||||||
@@ -580,6 +574,19 @@ func (h *AutoPurchaseHandler) activateMainPackage(
|
|||||||
}).Error
|
}).Error
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (h *AutoPurchaseHandler) lockPackageCarrier(ctx context.Context, tx *gorm.DB, carrierType string, carrierID uint) error {
|
||||||
|
switch carrierType {
|
||||||
|
case constants.AssetWalletResourceTypeIotCard, "card":
|
||||||
|
var card model.IotCard
|
||||||
|
return tx.WithContext(ctx).Clauses(clause.Locking{Strength: "UPDATE"}).First(&card, carrierID).Error
|
||||||
|
case constants.AssetWalletResourceTypeDevice:
|
||||||
|
var device model.Device
|
||||||
|
return tx.WithContext(ctx).Clauses(clause.Locking{Strength: "UPDATE"}).First(&device, carrierID).Error
|
||||||
|
default:
|
||||||
|
return errors.New("无效的套餐载体类型")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (h *AutoPurchaseHandler) activateAddonPackage(
|
func (h *AutoPurchaseHandler) activateAddonPackage(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
tx *gorm.DB,
|
tx *gorm.DB,
|
||||||
|
|||||||
@@ -134,6 +134,7 @@ const (
|
|||||||
CodeNoMainPackage = 1162 // 必须有主套餐才能购买加油包
|
CodeNoMainPackage = 1162 // 必须有主套餐才能购买加油包
|
||||||
CodeRealnameRequired = 1163 // 设备/卡必须先完成实名认证才能购买套餐
|
CodeRealnameRequired = 1163 // 设备/卡必须先完成实名认证才能购买套餐
|
||||||
CodeMixedOrderForbidden = 1164 // 同订单不能同时购买正式套餐和加油包
|
CodeMixedOrderForbidden = 1164 // 同订单不能同时购买正式套餐和加油包
|
||||||
|
CodeMainPackageExists = 1165 // 已有主套餐,不能重复购买主套餐
|
||||||
|
|
||||||
// 微信配置相关错误 (1170-1179)
|
// 微信配置相关错误 (1170-1179)
|
||||||
CodeWechatConfigNotFound = 1170 // 微信支付配置不存在
|
CodeWechatConfigNotFound = 1170 // 微信支付配置不存在
|
||||||
@@ -279,6 +280,7 @@ var allErrorCodes = []int{
|
|||||||
CodeNoMainPackage,
|
CodeNoMainPackage,
|
||||||
CodeRealnameRequired,
|
CodeRealnameRequired,
|
||||||
CodeMixedOrderForbidden,
|
CodeMixedOrderForbidden,
|
||||||
|
CodeMainPackageExists,
|
||||||
CodeWechatConfigNotFound,
|
CodeWechatConfigNotFound,
|
||||||
CodeWechatConfigActive,
|
CodeWechatConfigActive,
|
||||||
CodeWechatConfigHasPendingOrders,
|
CodeWechatConfigHasPendingOrders,
|
||||||
@@ -413,6 +415,7 @@ var errorMessages = map[int]string{
|
|||||||
CodeNoMainPackage: "必须有主套餐才能购买加油包",
|
CodeNoMainPackage: "必须有主套餐才能购买加油包",
|
||||||
CodeRealnameRequired: "设备/卡必须先完成实名认证才能购买套餐",
|
CodeRealnameRequired: "设备/卡必须先完成实名认证才能购买套餐",
|
||||||
CodeMixedOrderForbidden: "同订单不能同时购买正式套餐和加油包",
|
CodeMixedOrderForbidden: "同订单不能同时购买正式套餐和加油包",
|
||||||
|
CodeMainPackageExists: "已有主套餐,不能重复购买主套餐",
|
||||||
CodeWechatConfigNotFound: "微信支付配置不存在",
|
CodeWechatConfigNotFound: "微信支付配置不存在",
|
||||||
CodeWechatConfigActive: "不能删除当前生效的支付配置,请先停用",
|
CodeWechatConfigActive: "不能删除当前生效的支付配置,请先停用",
|
||||||
CodeWechatConfigHasPendingOrders: "该配置存在未完成的支付订单,暂时无法删除",
|
CodeWechatConfigHasPendingOrders: "该配置存在未完成的支付订单,暂时无法删除",
|
||||||
|
|||||||
Reference in New Issue
Block a user