修复错误购买报错的问题,修复接口返回虚流量启动错误的问题
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 7m48s

This commit is contained in:
Break
2026-06-11 14:51:26 +08:00
parent 134021c91e
commit c437593a8c
9 changed files with 145 additions and 196 deletions

View File

@@ -63,16 +63,15 @@ 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 统一执行资产解析与归属校验
@@ -243,16 +242,6 @@ 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 {
@@ -376,23 +365,24 @@ func (h *ClientAssetHandler) GetPackageHistory(c *fiber.Ctx) error {
} }
list = append(list, dto.AssetPackageResponse{ list = append(list, dto.AssetPackageResponse{
PackageUsageID: usage.ID, PackageUsageID: usage.ID,
PackageID: usage.PackageID, PackageID: usage.PackageID,
PackageName: pkgName, PackageName: pkgName,
PackageType: pkgType, PackageType: pkgType,
UsageType: usage.UsageType, UsageType: usage.UsageType,
Status: usage.Status, Status: usage.Status,
StatusName: packageStatusName(usage.Status), StatusName: packageStatusName(usage.Status),
RealTotalMB: metrics.RealTotalMB, RealTotalMB: metrics.RealTotalMB,
RealUsedMB: metrics.RealUsedMB, RealUsedMB: metrics.RealUsedMB,
VirtualTotalMB: metrics.VirtualTotalMB, VirtualTotalMB: metrics.VirtualTotalMB,
VirtualUsedMB: metrics.VirtualUsedMB, VirtualUsedMB: metrics.VirtualUsedMB,
ReductionPct: metrics.ReductionPct, ReductionPct: metrics.ReductionPct,
ActivatedAt: usage.ActivatedAt, EnableVirtualData: usage.EnableVirtualDataSnapshot,
ExpiresAt: usage.ExpiresAt, ActivatedAt: usage.ActivatedAt,
MasterUsageID: usage.MasterUsageID, ExpiresAt: usage.ExpiresAt,
Priority: usage.Priority, MasterUsageID: usage.MasterUsageID,
CreatedAt: usage.CreatedAt, Priority: usage.Priority,
CreatedAt: usage.CreatedAt,
}) })
} }
@@ -541,9 +531,6 @@ 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
} }

View File

@@ -129,6 +129,10 @@ func (h *Handler) CreateWalletPackageOrders(c *fiber.Ctx) error {
if err != nil { if err != nil {
return err return err
} }
if resp.FailedCount > 0 {
failure := resp.FailedCards[0]
return response.Business(c, failure.Code, resp, failure.Message)
}
return response.Success(c, resp) return response.Success(c, resp)
} }

View File

@@ -13,7 +13,6 @@ 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"
@@ -1248,10 +1247,6 @@ 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
@@ -1308,10 +1303,6 @@ 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 {
@@ -1346,58 +1337,6 @@ 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":

View File

@@ -2227,13 +2227,10 @@ func (s *Service) activateMainPackage(ctx context.Context, tx *gorm.DB, order *m
return err return err
} }
hasBlockingMain, err := packagepkg.HasBlockingMainPackageForFormal(tx.WithContext(ctx), carrierType, carrierID, now) hasCurrentMain, err := packagepkg.HasCurrentMainPackageForQueue(tx.WithContext(ctx), carrierType, carrierID, now)
if err != nil { if err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "查询主套餐失败") 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
@@ -2242,62 +2239,73 @@ 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
status = constants.PackageUsageStatusActive if hasCurrentMain {
priority = 1 // 当前仍有在用主套餐时,新主套餐进入待生效队列,后续由过期/退款等流程接续激活。
activatedAt = now status = constants.PackageUsageStatusPending
// 使用工具函数计算过期时间 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 {
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 + 实名状态)
// 查询卡类型(行业卡永远直接激活,不等实名) // 查询卡类型(行业卡永远直接激活,不等实名)
// 同时查询载体当前实名状态:已实名则跳过 pending直接激活 // 同时查询载体当前实名状态:已实名则跳过 pending直接激活
var cardCategory string var cardCategory string
var currentlyRealnamed bool var currentlyRealnamed bool
if carrierType == "iot_card" { if carrierType == "iot_card" {
var card model.IotCard var card model.IotCard
if err := tx.Select("card_category", "real_name_status").First(&card, carrierID).Error; err == nil { if err := tx.Select("card_category", "real_name_status").First(&card, carrierID).Error; err == nil {
cardCategory = card.CardCategory cardCategory = card.CardCategory
currentlyRealnamed = card.RealNameStatus == constants.RealNameStatusVerified 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不阻断购买流程
}
// 判断是否 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
} }
// 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不阻断购买流程
} }
// from_purchase立即激活status 已为 Active保持不变
// 判断是否 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保持不变
} }
// 行业卡或 C 端购买直接激活status 已为 Active保持不变
// 创建套餐使用记录 // 创建套餐使用记录
virtualTotalMBSnapshot, displayGainRatioSnapshot, enableVirtualDataSnapshot := model.BuildPackageUsageSnapshotValues(pkg) virtualTotalMBSnapshot, displayGainRatioSnapshot, enableVirtualDataSnapshot := model.BuildPackageUsageSnapshotValues(pkg)

View File

@@ -40,21 +40,17 @@ func FindAttachableMainPackageForAddon(tx *gorm.DB, carrierType string, carrierI
return &mainPackage, nil return &mainPackage, nil
} }
// FindBlockingMainPackageForFormal 查询阻止购买新主套餐的现有主套餐。 // FindCurrentMainPackageForQueue 查询应让新主套餐排队的现有主套餐。
// 待生效、生效中、未过期的已用完主套餐仍占用主套餐资格;只有过期或失效后才允许再次购买主套餐 // 未过期的生效中、已用完主套餐仍占用当前周期;待生效只是队列记录,不阻止继续排队购买
func FindBlockingMainPackageForFormal(tx *gorm.DB, carrierType string, carrierID uint, now time.Time) (*model.PackageUsage, error) { func FindCurrentMainPackageForQueue(tx *gorm.DB, carrierType string, carrierID uint, now time.Time) (*model.PackageUsage, error) {
var mainPackage model.PackageUsage var mainPackage model.PackageUsage
query := tx.Model(&model.PackageUsage{}). query := tx.Model(&model.PackageUsage{}).
Where("master_usage_id IS NULL"). Where("master_usage_id IS NULL").
Where( Where("status IN ?", []int{
"(status IN ? OR (status = ? AND (expires_at IS NULL OR expires_at > ?)))", constants.PackageUsageStatusActive,
[]int{
constants.PackageUsageStatusPending,
constants.PackageUsageStatusActive,
},
constants.PackageUsageStatusDepleted, constants.PackageUsageStatusDepleted,
now, }).
). Where("expires_at IS NULL OR expires_at > ?", now).
Order("priority ASC, created_at ASC") Order("priority ASC, created_at ASC")
switch carrierType { switch carrierType {
@@ -72,9 +68,9 @@ func FindBlockingMainPackageForFormal(tx *gorm.DB, carrierType string, carrierID
return &mainPackage, nil return &mainPackage, nil
} }
// HasBlockingMainPackageForFormal 判断载体是否已有阻止新主套餐购买的主套餐。 // HasCurrentMainPackageForQueue 判断载体是否已有应让新主套餐排队的主套餐。
func HasBlockingMainPackageForFormal(tx *gorm.DB, carrierType string, carrierID uint, now time.Time) (bool, error) { func HasCurrentMainPackageForQueue(tx *gorm.DB, carrierType string, carrierID uint, now time.Time) (bool, error) {
_, err := FindBlockingMainPackageForFormal(tx, carrierType, carrierID, now) _, err := FindCurrentMainPackageForQueue(tx, carrierType, carrierID, now)
if err == nil { if err == nil {
return true, nil return true, nil
} }

View File

@@ -212,21 +212,6 @@ func (s *Service) validatePackageUsageRules(ctx context.Context, carrierType str
return errors.New(errors.CodeMainPackageExists, "一次只能购买一个主套餐") 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 hasAddon {
if _, err := packagepkg.FindAttachableMainPackageForAddon( if _, err := packagepkg.FindAttachableMainPackageForAddon(
s.db.WithContext(ctx), s.db.WithContext(ctx),

View File

@@ -501,13 +501,10 @@ func (h *AutoPurchaseHandler) activateMainPackage(
return err return err
} }
hasBlockingMain, err := packagepkg.HasBlockingMainPackageForFormal(tx.WithContext(ctx), carrierType, carrierID, now) hasCurrentMain, err := packagepkg.HasCurrentMainPackageForQueue(tx.WithContext(ctx), carrierType, carrierID, now)
if err != nil { if err != nil {
return err return err
} }
if hasBlockingMain {
return errors.New("已有主套餐,不能重复购买主套餐")
}
var status int var status int
var priority int var priority int
@@ -516,11 +513,21 @@ func (h *AutoPurchaseHandler) activateMainPackage(
var nextResetAt *time.Time var nextResetAt *time.Time
var pendingRealnameActivation bool var pendingRealnameActivation bool
status = constants.PackageUsageStatusActive if hasCurrentMain {
priority = 1 status = constants.PackageUsageStatusPending
activatedAt = now var maxPriority int
expiresAt = packagepkg.CalculateExpiryTime(pkg.CalendarType, activatedAt, pkg.DurationMonths, pkg.DurationDays) tx.Model(&model.PackageUsage{}).
nextResetAt = packagepkg.CalculateNextResetTime(pkg.DataResetCycle, pkg.CalendarType, now, activatedAt) Where(carrierType+"_id = ?", carrierID).
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 仅影响后台囤货路径,此处无需判断

View File

@@ -184,10 +184,17 @@ account
响应 data MUST 包含:`batch_no``package_code``success_count``failed_count``orders``failed_cards``orders` 每项 MUST 包含 `card_no``order_no``failed_cards` 每项 MUST 包含 `card_no``code``message` 响应 data MUST 包含:`batch_no``package_code``success_count``failed_count``orders``failed_cards``orders` 每项 MUST 包含 `card_no``order_no``failed_cards` 每项 MUST 包含 `card_no``code``message`
当任一卡购买失败时,外层 `code`/`msg` MUST 返回首条失败卡的错误码和原因,`data` MUST 保留批次明细;调用方 MUST 以 `success_count``failed_count``orders``failed_cards` 判断每张卡的处理结果,避免整批重试导致重复下单。
#### Scenario: 多卡部分成功 #### Scenario: 多卡部分成功
- **WHEN** 代理提交 3 张卡购买同一个套餐编码,其中 2 张成功、1 张失败 - **WHEN** 代理提交 3 张卡购买同一个套餐编码,其中 2 张成功、1 张失败
- **THEN** 系统返回 `success_count=2``failed_count=1`、2 条订单号和 1 条失败卡原因 - **THEN** 系统外层 `code``msg` 返回首条失败卡的错误码和原因,`data` 返回 `success_count=2``failed_count=1`、2 条订单号和 1 条失败卡原因
#### Scenario: 全部购买失败
- **WHEN** 代理提交的卡全部购买失败
- **THEN** 系统外层 `code``msg` 返回首条失败卡的错误码和原因,`data.failed_cards` 返回全部失败明细
#### Scenario: 单套餐编码限制 #### Scenario: 单套餐编码限制

View File

@@ -39,6 +39,22 @@ func SuccessWithMessage(c *fiber.Ctx, data any, message string) error {
return err return err
} }
// Business 返回带业务码的统一响应,并保留业务数据。
// 适用于批量接口需要返回失败明细但外层 code 不能为 0 的场景。
func Business(c *fiber.Ctx, code int, data any, message string) error {
if message == "" {
message = errors.GetMessage(code, "zh-CN")
}
err := c.JSON(Response{
Code: code,
Data: data,
Message: message,
Timestamp: time.Now().Format(time.RFC3339),
})
c.Set(fiber.HeaderContentType, "application/json; charset=utf-8")
return err
}
// PaginationData 分页数据结构 // PaginationData 分页数据结构
type PaginationData struct { type PaginationData struct {
Items any `json:"items"` // 数据列表 Items any `json:"items"` // 数据列表