From c437593a8c206c28bbafe626711d79b350fcbf7b Mon Sep 17 00:00:00 2001 From: Break Date: Thu, 11 Jun 2026 14:51:26 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E9=94=99=E8=AF=AF=E8=B4=AD?= =?UTF-8?q?=E4=B9=B0=E6=8A=A5=E9=94=99=E7=9A=84=E9=97=AE=E9=A2=98,?= =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E6=8E=A5=E5=8F=A3=E8=BF=94=E5=9B=9E=E8=99=9A?= =?UTF-8?q?=E6=B5=81=E9=87=8F=E5=90=AF=E5=8A=A8=E9=94=99=E8=AF=AF=E7=9A=84?= =?UTF-8?q?=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/handler/app/client_asset.go | 67 ++++------ internal/handler/openapi/handler.go | 4 + internal/service/client_order/service.go | 61 --------- internal/service/order/service.go | 120 ++++++++++-------- .../service/package/addon_main_package.go | 24 ++-- .../service/purchase_validation/service.go | 15 --- internal/task/auto_purchase.go | 25 ++-- openspec/specs/agent-open-api/spec.md | 9 +- pkg/response/response.go | 16 +++ 9 files changed, 145 insertions(+), 196 deletions(-) diff --git a/internal/handler/app/client_asset.go b/internal/handler/app/client_asset.go index 6953976..c7763c2 100644 --- a/internal/handler/app/client_asset.go +++ b/internal/handler/app/client_asset.go @@ -63,16 +63,15 @@ func NewClientAssetHandler( } type resolvedAssetContext struct { - CustomerID uint - Identifier string - Asset *dto.AssetResolveResponse - Generation int - WalletBalance int64 - SkipPermissionCtx context.Context - IsAgentChannel bool - SellerShopID uint - HasAddonMainPackage bool - HasFormalMainPackage bool + CustomerID uint + Identifier string + Asset *dto.AssetResolveResponse + Generation int + WalletBalance int64 + SkipPermissionCtx context.Context + IsAgentChannel bool + SellerShopID uint + HasAddonMainPackage bool } // resolveAssetFromIdentifier 统一执行资产解析与归属校验 @@ -243,16 +242,6 @@ func (h *ClientAssetHandler) GetAvailablePackages(c *fiber.Ctx) error { } else if err != gorm.ErrRecordNotFound { 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 if resolved.IsAgentChannel { @@ -376,23 +365,24 @@ func (h *ClientAssetHandler) GetPackageHistory(c *fiber.Ctx) error { } list = append(list, dto.AssetPackageResponse{ - PackageUsageID: usage.ID, - PackageID: usage.PackageID, - PackageName: pkgName, - PackageType: pkgType, - UsageType: usage.UsageType, - Status: usage.Status, - StatusName: packageStatusName(usage.Status), - RealTotalMB: metrics.RealTotalMB, - RealUsedMB: metrics.RealUsedMB, - VirtualTotalMB: metrics.VirtualTotalMB, - VirtualUsedMB: metrics.VirtualUsedMB, - ReductionPct: metrics.ReductionPct, - ActivatedAt: usage.ActivatedAt, - ExpiresAt: usage.ExpiresAt, - MasterUsageID: usage.MasterUsageID, - Priority: usage.Priority, - CreatedAt: usage.CreatedAt, + PackageUsageID: usage.ID, + PackageID: usage.PackageID, + PackageName: pkgName, + PackageType: pkgType, + UsageType: usage.UsageType, + Status: usage.Status, + StatusName: packageStatusName(usage.Status), + RealTotalMB: metrics.RealTotalMB, + RealUsedMB: metrics.RealUsedMB, + VirtualTotalMB: metrics.VirtualTotalMB, + VirtualUsedMB: metrics.VirtualUsedMB, + ReductionPct: metrics.ReductionPct, + EnableVirtualData: usage.EnableVirtualDataSnapshot, + ActivatedAt: usage.ActivatedAt, + ExpiresAt: usage.ExpiresAt, + MasterUsageID: usage.MasterUsageID, + Priority: usage.Priority, + CreatedAt: usage.CreatedAt, }) } @@ -541,9 +531,6 @@ func buildClientPackageItem( } isAddon := pkg.PackageType == constants.PackageTypeAddon - if pkg.PackageType == constants.PackageTypeFormal && resolved.HasFormalMainPackage { - return dto.ClientPackageItem{}, false - } if isAddon && !resolved.HasAddonMainPackage { return dto.ClientPackageItem{}, false } diff --git a/internal/handler/openapi/handler.go b/internal/handler/openapi/handler.go index d16089c..f1d59f4 100644 --- a/internal/handler/openapi/handler.go +++ b/internal/handler/openapi/handler.go @@ -129,6 +129,10 @@ func (h *Handler) CreateWalletPackageOrders(c *fiber.Ctx) error { if err != nil { return err } + if resp.FailedCount > 0 { + failure := resp.FailedCards[0] + return response.Business(c, failure.Code, resp, failure.Message) + } return response.Success(c, resp) } diff --git a/internal/service/client_order/service.go b/internal/service/client_order/service.go index 831f62c..d53264d 100644 --- a/internal/service/client_order/service.go +++ b/internal/service/client_order/service.go @@ -13,7 +13,6 @@ import ( "github.com/break/junhong_cmp_fiber/internal/model" "github.com/break/junhong_cmp_fiber/internal/model/dto" 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/store/postgres" "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 case model.PaymentMethodWechat: - if err := s.ensureOrderFormalPackagePayable(skipCtx, order); err != nil { - return nil, err - } - activeConfig, appID, err := s.resolveWechatConfig(skipCtx, req.AppType) if err != nil { return nil, err @@ -1308,10 +1303,6 @@ func (s *Service) PayOrder(ctx context.Context, customerID uint, orderID uint, r }, nil case model.PaymentMethodAlipay: - if err := s.ensureOrderFormalPackagePayable(skipCtx, order); err != nil { - return nil, err - } - // 支付宝支付:不需要 app_type / OpenID activeConfig, err := s.wechatConfigService.GetActiveConfig(skipCtx) 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) { switch assetType { case "card": diff --git a/internal/service/order/service.go b/internal/service/order/service.go index ca234a9..f443a69 100644 --- a/internal/service/order/service.go +++ b/internal/service/order/service.go @@ -2227,13 +2227,10 @@ func (s *Service) activateMainPackage(ctx context.Context, tx *gorm.DB, order *m 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 { return errors.Wrap(errors.CodeDatabaseError, err, "查询主套餐失败") } - if hasBlockingMain { - return errors.New(errors.CodeMainPackageExists) - } var status 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 pendingRealnameActivation bool - 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) + if hasCurrentMain { + // 当前仍有在用主套餐时,新主套餐进入待生效队列,后续由过期/退款等流程接续激活。 + status = constants.PackageUsageStatusPending + var maxPriority int + tx.Model(&model.PackageUsage{}). + 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: 后台囤货场景三维决策(卡类型 + 购买路径 + expiry_base + 实名状态) - // 查询卡类型(行业卡永远直接激活,不等实名) - // 同时查询载体当前实名状态:已实名则跳过 pending,直接激活 - var cardCategory string - var currentlyRealnamed bool - if carrierType == "iot_card" { - var card model.IotCard - if err := tx.Select("card_category", "real_name_status").First(&card, carrierID).Error; err == nil { - 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,不阻断购买流程 - } - - // 判断是否 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 + // REALNAME-02: 后台囤货场景三维决策(卡类型 + 购买路径 + expiry_base + 实名状态) + // 查询卡类型(行业卡永远直接激活,不等实名) + // 同时查询载体当前实名状态:已实名则跳过 pending,直接激活 + var cardCategory string + var currentlyRealnamed bool + if carrierType == "iot_card" { + var card model.IotCard + if err := tx.Select("card_category", "real_name_status").First(&card, carrierID).Error; err == nil { + 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,不阻断购买流程 } - // 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) diff --git a/internal/service/package/addon_main_package.go b/internal/service/package/addon_main_package.go index f99ce33..1e13f9d 100644 --- a/internal/service/package/addon_main_package.go +++ b/internal/service/package/addon_main_package.go @@ -40,21 +40,17 @@ func FindAttachableMainPackageForAddon(tx *gorm.DB, carrierType string, carrierI return &mainPackage, nil } -// FindBlockingMainPackageForFormal 查询阻止购买新主套餐的现有主套餐。 -// 待生效、生效中、未过期的已用完主套餐都仍占用主套餐资格;只有过期或失效后才允许再次购买主套餐。 -func FindBlockingMainPackageForFormal(tx *gorm.DB, carrierType string, carrierID uint, now time.Time) (*model.PackageUsage, error) { +// FindCurrentMainPackageForQueue 查询应让新主套餐排队的现有主套餐。 +// 未过期的生效中、已用完主套餐仍占用当前周期;待生效只是队列记录,不阻止继续排队购买。 +func FindCurrentMainPackageForQueue(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, - }, + Where("status IN ?", []int{ + constants.PackageUsageStatusActive, constants.PackageUsageStatusDepleted, - now, - ). + }). + Where("expires_at IS NULL OR expires_at > ?", now). Order("priority ASC, created_at ASC") switch carrierType { @@ -72,9 +68,9 @@ func FindBlockingMainPackageForFormal(tx *gorm.DB, carrierType string, carrierID return &mainPackage, nil } -// HasBlockingMainPackageForFormal 判断载体是否已有阻止新主套餐购买的主套餐。 -func HasBlockingMainPackageForFormal(tx *gorm.DB, carrierType string, carrierID uint, now time.Time) (bool, error) { - _, err := FindBlockingMainPackageForFormal(tx, carrierType, carrierID, now) +// HasCurrentMainPackageForQueue 判断载体是否已有应让新主套餐排队的主套餐。 +func HasCurrentMainPackageForQueue(tx *gorm.DB, carrierType string, carrierID uint, now time.Time) (bool, error) { + _, err := FindCurrentMainPackageForQueue(tx, carrierType, carrierID, now) if err == nil { return true, nil } diff --git a/internal/service/purchase_validation/service.go b/internal/service/purchase_validation/service.go index c955422..691d2c3 100644 --- a/internal/service/purchase_validation/service.go +++ b/internal/service/purchase_validation/service.go @@ -212,21 +212,6 @@ func (s *Service) validatePackageUsageRules(ctx context.Context, carrierType str 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), diff --git a/internal/task/auto_purchase.go b/internal/task/auto_purchase.go index e0c82cd..171a28b 100644 --- a/internal/task/auto_purchase.go +++ b/internal/task/auto_purchase.go @@ -501,13 +501,10 @@ func (h *AutoPurchaseHandler) activateMainPackage( 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 { return err } - if hasBlockingMain { - return errors.New("已有主套餐,不能重复购买主套餐") - } var status int var priority int @@ -516,11 +513,21 @@ func (h *AutoPurchaseHandler) activateMainPackage( var nextResetAt *time.Time var pendingRealnameActivation bool - 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) + if hasCurrentMain { + status = constants.PackageUsageStatusPending + var maxPriority int + tx.Model(&model.PackageUsage{}). + 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 端已做前置实名检查) // ExpiryBase 仅影响后台囤货路径,此处无需判断 diff --git a/openspec/specs/agent-open-api/spec.md b/openspec/specs/agent-open-api/spec.md index 2b05c3d..8fdc88c 100644 --- a/openspec/specs/agent-open-api/spec.md +++ b/openspec/specs/agent-open-api/spec.md @@ -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`。 +当任一卡购买失败时,外层 `code`/`msg` MUST 返回首条失败卡的错误码和原因,`data` MUST 保留批次明细;调用方 MUST 以 `success_count`、`failed_count`、`orders`、`failed_cards` 判断每张卡的处理结果,避免整批重试导致重复下单。 + #### Scenario: 多卡部分成功 - **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: 单套餐编码限制 diff --git a/pkg/response/response.go b/pkg/response/response.go index 88d8525..0086fdf 100644 --- a/pkg/response/response.go +++ b/pkg/response/response.go @@ -39,6 +39,22 @@ func SuccessWithMessage(c *fiber.Ctx, data any, message string) error { 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 分页数据结构 type PaginationData struct { Items any `json:"items"` // 数据列表