From ba435dd6a6e404445b1954c9416175a32c128313 Mon Sep 17 00:00:00 2001 From: break Date: Mon, 22 Jun 2026 12:08:41 +0900 Subject: [PATCH] =?UTF-8?q?=E5=85=B3=E4=BA=8E=E7=BB=91=E5=AE=9A=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/bootstrap/handlers.go | 11 +- internal/bootstrap/services.go | 10 +- internal/handler/app/client_asset.go | 24 +- internal/handler/app/client_device.go | 11 +- internal/handler/app/client_realname.go | 11 +- internal/handler/app/client_wallet.go | 24 +- internal/service/client_auth/service.go | 101 +-- internal/service/client_order/service.go | 62 +- internal/service/customer_binding/service.go | 473 ++++++++++++ .../service/customer_binding/service_test.go | 715 ++++++++++++++++++ internal/service/exchange/service.go | 35 +- .../personal_customer_device_store.go | 20 + .../postgres/personal_customer_iccid_store.go | 19 + 13 files changed, 1306 insertions(+), 210 deletions(-) create mode 100644 internal/service/customer_binding/service.go create mode 100644 internal/service/customer_binding/service_test.go diff --git a/internal/bootstrap/handlers.go b/internal/bootstrap/handlers.go index f79ccef..7fea7db 100644 --- a/internal/bootstrap/handlers.go +++ b/internal/bootstrap/handlers.go @@ -17,7 +17,6 @@ import ( func initHandlers(svc *services, deps *Dependencies) *Handlers { validate := validator.New() - personalCustomerDeviceStore := postgres.NewPersonalCustomerDeviceStore(deps.DB) assetWalletStore := postgres.NewAssetWalletStore(deps.DB, deps.Redis) packageStore := postgres.NewPackageStore(deps.DB) shopPackageAllocationStore := postgres.NewShopPackageAllocationStore(deps.DB) @@ -56,7 +55,7 @@ func initHandlers(svc *services, deps *Dependencies) *Handlers { rechargeOrderStore, paymentStore, assetWalletStore, - personalCustomerDeviceStore, + svc.CustomerBinding, personalCustomerOpenIDStore, personalCustomerStore, personalCustomerPhoneStore, @@ -78,12 +77,12 @@ func initHandlers(svc *services, deps *Dependencies) *Handlers { Permission: admin.NewPermissionHandler(svc.Permission), PersonalCustomer: app.NewPersonalCustomerHandler(svc.PersonalCustomer, deps.Logger), ClientAuth: app.NewClientAuthHandler(svc.ClientAuth, deps.Logger), - ClientAsset: app.NewClientAssetHandler(svc.Asset, personalCustomerDeviceStore, assetWalletStore, packageStore, shopPackageAllocationStore, iotCardStore, deviceStore, deps.DB, deps.Logger), - ClientWallet: app.NewClientWalletHandler(svc.Asset, personalCustomerDeviceStore, assetWalletStore, assetWalletTransactionStore, rechargeOrderStore, paymentStore, svc.Recharge, personalCustomerOpenIDStore, svc.WechatConfig, deps.Redis, deps.Logger, deps.DB, iotCardStore, deviceStore), + ClientAsset: app.NewClientAssetHandler(svc.Asset, svc.CustomerBinding, assetWalletStore, packageStore, shopPackageAllocationStore, iotCardStore, deviceStore, deps.DB, deps.Logger), + ClientWallet: app.NewClientWalletHandler(svc.Asset, svc.CustomerBinding, assetWalletStore, assetWalletTransactionStore, rechargeOrderStore, paymentStore, svc.Recharge, personalCustomerOpenIDStore, svc.WechatConfig, deps.Redis, deps.Logger, deps.DB, iotCardStore, deviceStore), ClientOrder: app.NewClientOrderHandler(clientOrderService, deps.Logger), ClientExchange: app.NewClientExchangeHandler(svc.Exchange), - ClientRealname: app.NewClientRealnameHandler(svc.Asset, personalCustomerDeviceStore, iotCardStore, deviceSimBindingStore, carrierStore, deps.GatewayClient, deps.Logger, svc.PollingManualTrigger), - ClientDevice: app.NewClientDeviceHandler(svc.Asset, personalCustomerDeviceStore, deviceStore, deviceSimBindingStore, iotCardStore, deps.GatewayClient, deps.Logger), + ClientRealname: app.NewClientRealnameHandler(svc.Asset, svc.CustomerBinding, iotCardStore, deviceSimBindingStore, carrierStore, deps.GatewayClient, deps.Logger, svc.PollingManualTrigger), + ClientDevice: app.NewClientDeviceHandler(svc.Asset, svc.CustomerBinding, deviceStore, deviceSimBindingStore, iotCardStore, deps.GatewayClient, deps.Logger), ClientRechargeOrder: app.NewClientRechargeOrderHandler(rechargeOrderStore, paymentStore, deps.Logger), Shop: admin.NewShopHandler(svc.Shop), ShopRole: admin.NewShopRoleHandler(svc.Shop), diff --git a/internal/bootstrap/services.go b/internal/bootstrap/services.go index 523ea18..6027bd5 100644 --- a/internal/bootstrap/services.go +++ b/internal/bootstrap/services.go @@ -14,6 +14,7 @@ import ( authSvc "github.com/break/junhong_cmp_fiber/internal/service/auth" carrierSvc "github.com/break/junhong_cmp_fiber/internal/service/carrier" clientAuthSvc "github.com/break/junhong_cmp_fiber/internal/service/client_auth" + customerBindingSvc "github.com/break/junhong_cmp_fiber/internal/service/customer_binding" commissionCalculationSvc "github.com/break/junhong_cmp_fiber/internal/service/commission_calculation" commissionStatsSvc "github.com/break/junhong_cmp_fiber/internal/service/commission_stats" commissionWithdrawalSvc "github.com/break/junhong_cmp_fiber/internal/service/commission_withdrawal" @@ -109,9 +110,13 @@ type services struct { TrafficQuery *trafficSvc.QueryService OperationPassword *operationPasswordSvc.Service AgentOpenAPI *agentOpenAPISvc.Service + CustomerBinding *customerBindingSvc.Service } func initServices(s *stores, deps *Dependencies) *services { + // CustomerBinding 模块依赖最少,最先初始化 + customerBinding := customerBindingSvc.New(deps.DB, s.IotCard, s.Device) + purchaseValidation := purchaseValidationSvc.New(deps.DB, s.IotCard, s.Device, s.Package, s.ShopPackageAllocation) accountAudit := accountAuditSvc.NewService(s.AccountOperationLog) assetAudit := assetAuditSvc.NewService(s.AssetOperationLog, deps.DB) @@ -210,7 +215,6 @@ func initServices(s *stores, deps *Dependencies) *services { deps.DB, s.PersonalCustomerOpenID, s.PersonalCustomer, - s.PersonalCustomerDevice, s.PersonalCustomerPhone, s.IotCard, s.Device, @@ -219,6 +223,7 @@ func initServices(s *stores, deps *Dependencies) *services { deps.JWTManager, deps.Redis, deps.Logger, + customerBinding, ), Shop: shopSvc.New(s.Shop, s.Account, s.ShopRole, s.Role, s.AccountRole, s.AgentWallet), Auth: authSvc.New(s.Account, s.AccountRole, s.RolePermission, s.Permission, s.Shop, deps.TokenManager, deps.Logger), @@ -264,7 +269,7 @@ func initServices(s *stores, deps *Dependencies) *services { CommissionStats: commissionStatsSvc.New(s.ShopSeriesCommissionStats), PurchaseValidation: purchaseValidation, Order: orderService, - Exchange: exchangeSvc.New(deps.DB, s.ExchangeOrder, s.IotCard, s.Device, s.AssetWallet, s.AssetWalletTransaction, s.PackageUsage, s.PackageUsageDailyRecord, s.ResourceTag, s.PersonalCustomerDevice, deps.Logger), + Exchange: exchangeSvc.New(deps.DB, s.ExchangeOrder, s.IotCard, s.Device, s.AssetWallet, s.AssetWalletTransaction, s.PackageUsage, s.PackageUsageDailyRecord, s.ResourceTag, customerBinding, deps.Logger), Recharge: rechargeSvc.New(deps.DB, s.AssetWallet, s.AssetWalletTransaction, s.IotCard, s.Device, s.ShopSeriesAllocation, s.PackageSeries, s.CommissionRecord, wechatConfig, paymentLoader, deps.Logger), PollingConfig: pollingSvc.NewConfigService(s.PollingConfig, deps.Redis, deps.Logger), PollingConcurrency: pollingSvc.NewConcurrencyService(s.PollingConcurrencyConfig, deps.Redis), @@ -308,5 +313,6 @@ func initServices(s *stores, deps *Dependencies) *services { s.AssetWallet, deps.Logger, ), + CustomerBinding: customerBinding, } } diff --git a/internal/handler/app/client_asset.go b/internal/handler/app/client_asset.go index c7763c2..12b66f6 100644 --- a/internal/handler/app/client_asset.go +++ b/internal/handler/app/client_asset.go @@ -11,6 +11,7 @@ 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" + customerBinding "github.com/break/junhong_cmp_fiber/internal/service/customer_binding" 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/store" @@ -27,7 +28,7 @@ import ( // 提供 B1~B4 资产信息、可购套餐、套餐历史、手动刷新接口 type ClientAssetHandler struct { assetService *asset.Service - personalDeviceStore *postgres.PersonalCustomerDeviceStore + customerBinding *customerBinding.Service assetWalletStore *postgres.AssetWalletStore packageStore *postgres.PackageStore shopPackageAllocationStore *postgres.ShopPackageAllocationStore @@ -40,7 +41,7 @@ type ClientAssetHandler struct { // NewClientAssetHandler 创建 C 端资产信息处理器 func NewClientAssetHandler( assetService *asset.Service, - personalDeviceStore *postgres.PersonalCustomerDeviceStore, + binding *customerBinding.Service, assetWalletStore *postgres.AssetWalletStore, packageStore *postgres.PackageStore, shopPackageAllocationStore *postgres.ShopPackageAllocationStore, @@ -51,7 +52,7 @@ func NewClientAssetHandler( ) *ClientAssetHandler { return &ClientAssetHandler{ assetService: assetService, - personalDeviceStore: personalDeviceStore, + customerBinding: binding, assetWalletStore: assetWalletStore, packageStore: packageStore, shopPackageAllocationStore: shopPackageAllocationStore, @@ -102,7 +103,7 @@ func (h *ClientAssetHandler) resolveAssetFromIdentifier(c *fiber.Ctx, identifier return nil, err } - owned, ownErr := h.isCustomerOwnAsset(skipPermissionCtx, customerID, assetInfo.VirtualNo) + owned, ownErr := h.customerBinding.OwnsAsset(skipPermissionCtx, customerID, assetInfo.AssetType, assetInfo.AssetID) if ownErr != nil { return nil, errors.Wrap(errors.CodeDatabaseError, ownErr, "查询资产归属失败") } @@ -422,21 +423,6 @@ func (h *ClientAssetHandler) RefreshAsset(c *fiber.Ctx) error { return response.Success(c, resp) } -func (h *ClientAssetHandler) isCustomerOwnAsset(ctx context.Context, customerID uint, virtualNo string) (bool, error) { - records, err := h.personalDeviceStore.GetByCustomerID(ctx, customerID) - if err != nil { - return false, err - } - for _, record := range records { - if record == nil { - continue - } - if record.Status == constants.StatusEnabled && record.VirtualNo == virtualNo { - return true, nil - } - } - return false, nil -} func (h *ClientAssetHandler) getAssetGeneration(ctx context.Context, assetType string, assetID uint) (int, error) { switch assetType { diff --git a/internal/handler/app/client_device.go b/internal/handler/app/client_device.go index 680a03b..5cd4e5c 100644 --- a/internal/handler/app/client_device.go +++ b/internal/handler/app/client_device.go @@ -5,6 +5,7 @@ import ( "github.com/break/junhong_cmp_fiber/internal/middleware" "github.com/break/junhong_cmp_fiber/internal/model/dto" assetSvc "github.com/break/junhong_cmp_fiber/internal/service/asset" + customerBinding "github.com/break/junhong_cmp_fiber/internal/service/customer_binding" "github.com/break/junhong_cmp_fiber/internal/store/postgres" "github.com/break/junhong_cmp_fiber/pkg/errors" "github.com/break/junhong_cmp_fiber/pkg/logger" @@ -27,7 +28,7 @@ type deviceAssetInfo struct { // 提供设备卡列表、重启、恢复出厂、WiFi 配置、切卡等操作 type ClientDeviceHandler struct { assetService *assetSvc.Service - personalDeviceStore *postgres.PersonalCustomerDeviceStore + customerBinding *customerBinding.Service deviceStore *postgres.DeviceStore deviceSimBindingStore *postgres.DeviceSimBindingStore iotCardStore *postgres.IotCardStore @@ -38,7 +39,7 @@ type ClientDeviceHandler struct { // NewClientDeviceHandler 创建 C 端设备能力处理器 func NewClientDeviceHandler( assetService *assetSvc.Service, - personalDeviceStore *postgres.PersonalCustomerDeviceStore, + binding *customerBinding.Service, deviceStore *postgres.DeviceStore, deviceSimBindingStore *postgres.DeviceSimBindingStore, iotCardStore *postgres.IotCardStore, @@ -47,7 +48,7 @@ func NewClientDeviceHandler( ) *ClientDeviceHandler { return &ClientDeviceHandler{ assetService: assetService, - personalDeviceStore: personalDeviceStore, + customerBinding: binding, deviceStore: deviceStore, deviceSimBindingStore: deviceSimBindingStore, iotCardStore: iotCardStore, @@ -79,11 +80,11 @@ func (h *ClientDeviceHandler) validateDeviceAsset(c *fiber.Ctx, identifier strin } // 校验个人客户对该设备的所有权 - owns, err := h.personalDeviceStore.ExistsByCustomerAndDevice(ctx, customerID, asset.VirtualNo) + owns, err := h.customerBinding.OwnsAsset(ctx, customerID, asset.AssetType, asset.AssetID) if err != nil { h.logger.Error("校验设备所有权失败", zap.Uint("customer_id", customerID), - zap.String("virtual_no", asset.VirtualNo), + zap.Uint("asset_id", asset.AssetID), zap.Error(err)) return nil, errors.New(errors.CodeInternalError) } diff --git a/internal/handler/app/client_realname.go b/internal/handler/app/client_realname.go index 1281ded..e8f4ed5 100644 --- a/internal/handler/app/client_realname.go +++ b/internal/handler/app/client_realname.go @@ -13,6 +13,7 @@ import ( "github.com/break/junhong_cmp_fiber/internal/model" "github.com/break/junhong_cmp_fiber/internal/model/dto" assetService "github.com/break/junhong_cmp_fiber/internal/service/asset" + customerBinding "github.com/break/junhong_cmp_fiber/internal/service/customer_binding" pollingSvc "github.com/break/junhong_cmp_fiber/internal/service/polling" "github.com/break/junhong_cmp_fiber/internal/store/postgres" "github.com/break/junhong_cmp_fiber/pkg/config" @@ -29,7 +30,7 @@ var clientRealnameValidator = validator.New() // ClientRealnameHandler C 端实名认证处理器 type ClientRealnameHandler struct { assetService *assetService.Service - personalDeviceStore *postgres.PersonalCustomerDeviceStore + customerBinding *customerBinding.Service iotCardStore *postgres.IotCardStore deviceSimBindingStore *postgres.DeviceSimBindingStore carrierStore *postgres.CarrierStore @@ -41,7 +42,7 @@ type ClientRealnameHandler struct { // NewClientRealnameHandler 创建 C 端实名认证处理器 func NewClientRealnameHandler( assetSvc *assetService.Service, - personalDeviceStore *postgres.PersonalCustomerDeviceStore, + binding *customerBinding.Service, iotCardStore *postgres.IotCardStore, deviceSimBindingStore *postgres.DeviceSimBindingStore, carrierStore *postgres.CarrierStore, @@ -51,7 +52,7 @@ func NewClientRealnameHandler( ) *ClientRealnameHandler { return &ClientRealnameHandler{ assetService: assetSvc, - personalDeviceStore: personalDeviceStore, + customerBinding: binding, iotCardStore: iotCardStore, deviceSimBindingStore: deviceSimBindingStore, carrierStore: carrierStore, @@ -89,11 +90,11 @@ func (h *ClientRealnameHandler) GetRealnameLink(c *fiber.Ctx) error { } // 4. 验证资产归属(个人客户必须绑定过该资产) - owned, err := h.personalDeviceStore.ExistsByCustomerAndDevice(ctx, customerID, asset.VirtualNo) + owned, err := h.customerBinding.OwnsAsset(ctx, customerID, asset.AssetType, asset.AssetID) if err != nil { logger.GetAppLogger().Error("查询资产归属失败", zap.Uint("customer_id", customerID), - zap.String("virtual_no", asset.VirtualNo), + zap.Uint("asset_id", asset.AssetID), zap.Error(err)) return errors.New(errors.CodeInternalError, "查询资产归属失败") } diff --git a/internal/handler/app/client_wallet.go b/internal/handler/app/client_wallet.go index f9949b3..cb81c3d 100644 --- a/internal/handler/app/client_wallet.go +++ b/internal/handler/app/client_wallet.go @@ -12,6 +12,7 @@ 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" + customerBinding "github.com/break/junhong_cmp_fiber/internal/service/customer_binding" rechargeSvc "github.com/break/junhong_cmp_fiber/internal/service/recharge" wechatConfigSvc "github.com/break/junhong_cmp_fiber/internal/service/wechat_config" "github.com/break/junhong_cmp_fiber/internal/store/postgres" @@ -31,7 +32,7 @@ import ( // 提供 C1~C5 钱包详情、流水、充值前校验、充值下单、充值记录接口 type ClientWalletHandler struct { assetService *asset.Service - personalDeviceStore *postgres.PersonalCustomerDeviceStore + customerBinding *customerBinding.Service walletStore *postgres.AssetWalletStore transactionStore *postgres.AssetWalletTransactionStore rechargeOrderStore *postgres.RechargeOrderStore @@ -49,7 +50,7 @@ type ClientWalletHandler struct { // NewClientWalletHandler 创建 C 端钱包处理器 func NewClientWalletHandler( assetService *asset.Service, - personalDeviceStore *postgres.PersonalCustomerDeviceStore, + binding *customerBinding.Service, walletStore *postgres.AssetWalletStore, transactionStore *postgres.AssetWalletTransactionStore, rechargeOrderStore *postgres.RechargeOrderStore, @@ -65,7 +66,7 @@ func NewClientWalletHandler( ) *ClientWalletHandler { return &ClientWalletHandler{ assetService: assetService, - personalDeviceStore: personalDeviceStore, + customerBinding: binding, walletStore: walletStore, transactionStore: transactionStore, rechargeOrderStore: rechargeOrderStore, @@ -544,7 +545,7 @@ func (h *ClientWalletHandler) resolveAssetFromIdentifier(c *fiber.Ctx, identifie return nil, err } - owned, ownErr := h.isCustomerOwnAsset(skipPermissionCtx, customerID, assetInfo.VirtualNo) + owned, ownErr := h.customerBinding.OwnsAsset(skipPermissionCtx, customerID, assetInfo.AssetType, assetInfo.AssetID) if ownErr != nil { return nil, errors.Wrap(errors.CodeDatabaseError, ownErr, "查询资产归属失败") } @@ -572,21 +573,6 @@ func (h *ClientWalletHandler) resolveAssetFromIdentifier(c *fiber.Ctx, identifie }, nil } -func (h *ClientWalletHandler) isCustomerOwnAsset(ctx context.Context, customerID uint, virtualNo string) (bool, error) { - records, err := h.personalDeviceStore.GetByCustomerID(ctx, customerID) - if err != nil { - return false, err - } - for _, record := range records { - if record == nil { - continue - } - if record.Status == constants.StatusEnabled && record.VirtualNo == virtualNo { - return true, nil - } - } - return false, nil -} func (h *ClientWalletHandler) getAssetGeneration(ctx context.Context, assetType string, assetID uint) (int, error) { switch assetType { diff --git a/internal/service/client_auth/service.go b/internal/service/client_auth/service.go index d1d9d1f..1996ebb 100644 --- a/internal/service/client_auth/service.go +++ b/internal/service/client_auth/service.go @@ -10,6 +10,7 @@ import ( "github.com/ArtisanCloud/PowerWeChat/v3/src/kernel" "github.com/break/junhong_cmp_fiber/internal/model" "github.com/break/junhong_cmp_fiber/internal/model/dto" + customerBinding "github.com/break/junhong_cmp_fiber/internal/service/customer_binding" "github.com/break/junhong_cmp_fiber/internal/service/verification" wechatConfigSvc "github.com/break/junhong_cmp_fiber/internal/service/wechat_config" "github.com/break/junhong_cmp_fiber/internal/store/postgres" @@ -41,7 +42,6 @@ type Service struct { db *gorm.DB openidStore *postgres.PersonalCustomerOpenIDStore customerStore *postgres.PersonalCustomerStore - deviceBindStore *postgres.PersonalCustomerDeviceStore phoneStore *postgres.PersonalCustomerPhoneStore iotCardStore *postgres.IotCardStore deviceStore *postgres.DeviceStore @@ -51,6 +51,7 @@ type Service struct { redis *redis.Client logger *zap.Logger wechatCache kernel.CacheInterface + customerBinding *customerBinding.Service } // New 创建 C 端认证服务实例 @@ -58,7 +59,6 @@ func New( db *gorm.DB, openidStore *postgres.PersonalCustomerOpenIDStore, customerStore *postgres.PersonalCustomerStore, - deviceBindStore *postgres.PersonalCustomerDeviceStore, phoneStore *postgres.PersonalCustomerPhoneStore, iotCardStore *postgres.IotCardStore, deviceStore *postgres.DeviceStore, @@ -67,12 +67,12 @@ func New( jwtManager *auth.JWTManager, redisClient *redis.Client, logger *zap.Logger, + binding *customerBinding.Service, ) *Service { return &Service{ db: db, openidStore: openidStore, customerStore: customerStore, - deviceBindStore: deviceBindStore, phoneStore: phoneStore, iotCardStore: iotCardStore, deviceStore: deviceStore, @@ -82,6 +82,7 @@ func New( redis: redisClient, logger: logger, wechatCache: wechat.NewRedisCache(redisClient), + customerBinding: binding, } } @@ -630,99 +631,9 @@ func (s *Service) checkCardBoundToDevice(ctx context.Context, cardID uint) (bool return !card.IsStandalone || card.DeviceVirtualNo != "", nil } -// bindAsset 绑定客户与资产关系 +// bindAsset 委托 CustomerBinding 模块处理客户与资产的绑定关系 func (s *Service) bindAsset(ctx context.Context, tx *gorm.DB, customerID uint, assetType string, assetID uint) error { - assetKey, err := s.resolveAssetBindingKey(ctx, tx, assetType, assetID) - if err != nil { - return err - } - - var bindCount int64 - if err := tx.WithContext(ctx). - Model(&model.PersonalCustomerDevice{}). - Where("virtual_no = ?", assetKey). - Count(&bindCount).Error; err != nil { - return errors.Wrap(errors.CodeInternalError, err, "查询资产绑定关系失败") - } - firstEverBind := bindCount == 0 - - bindStore := postgres.NewPersonalCustomerDeviceStore(tx) - exists, err := bindStore.ExistsByCustomerAndDevice(ctx, customerID, assetKey) - if err != nil { - return errors.Wrap(errors.CodeInternalError, err, "查询客户资产绑定关系失败") - } - - if !exists { - record := &model.PersonalCustomerDevice{ - CustomerID: customerID, - VirtualNo: assetKey, - Status: 1, - } - if err := bindStore.Create(ctx, record); err != nil { - return errors.Wrap(errors.CodeInternalError, err, "创建资产绑定关系失败") - } - } - - if firstEverBind { - if err := s.markAssetAsSold(ctx, tx, assetType, assetID); err != nil { - return err - } - } - - return nil -} - -func (s *Service) resolveAssetBindingKey(ctx context.Context, tx *gorm.DB, assetType string, assetID uint) (string, error) { - if assetType == assetTypeIotCard { - var card model.IotCard - if err := tx.WithContext(ctx).First(&card, assetID).Error; err != nil { - if err == gorm.ErrRecordNotFound { - return "", errors.New(errors.CodeAssetNotFound) - } - return "", errors.Wrap(errors.CodeInternalError, err, "查询卡资产失败") - } - return card.VirtualNo, nil - } - - if assetType == assetTypeDevice { - var device model.Device - if err := tx.WithContext(ctx).First(&device, assetID).Error; err != nil { - if err == gorm.ErrRecordNotFound { - return "", errors.New(errors.CodeAssetNotFound) - } - return "", errors.Wrap(errors.CodeInternalError, err, "查询设备资产失败") - } - if device.VirtualNo != "" { - return device.VirtualNo, nil - } - return device.IMEI, nil - } - - return "", errors.New(errors.CodeInvalidParam) -} - -func (s *Service) markAssetAsSold(ctx context.Context, tx *gorm.DB, assetType string, assetID uint) error { - if assetType == assetTypeIotCard { - if err := tx.WithContext(ctx). - Model(&model.IotCard{}). - Where("id = ? AND asset_status = ?", assetID, 1). - Update("asset_status", 2).Error; err != nil { - return errors.Wrap(errors.CodeInternalError, err, "更新卡资产状态失败") - } - return nil - } - - if assetType == assetTypeDevice { - if err := tx.WithContext(ctx). - Model(&model.Device{}). - Where("id = ? AND asset_status = ?", assetID, 1). - Update("asset_status", 2).Error; err != nil { - return errors.Wrap(errors.CodeInternalError, err, "更新设备资产状态失败") - } - return nil - } - - return errors.New(errors.CodeInvalidParam) + return s.customerBinding.Bind(ctx, tx, customerID, assetType, assetID) } func (s *Service) issueLoginToken(ctx context.Context, customerID uint, assetType string, assetID uint) (string, bool, error) { diff --git a/internal/service/client_order/service.go b/internal/service/client_order/service.go index d53264d..13ca0fa 100644 --- a/internal/service/client_order/service.go +++ b/internal/service/client_order/service.go @@ -13,6 +13,7 @@ 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" + customerBinding "github.com/break/junhong_cmp_fiber/internal/service/customer_binding" "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" @@ -55,7 +56,7 @@ type Service struct { rechargeOrderStore *postgres.RechargeOrderStore paymentStore *postgres.PaymentStore walletStore *postgres.AssetWalletStore - personalDeviceStore *postgres.PersonalCustomerDeviceStore + customerBinding *customerBinding.Service openIDStore *postgres.PersonalCustomerOpenIDStore personalCustomerStore *postgres.PersonalCustomerStore personalCustomerPhoneStore *postgres.PersonalCustomerPhoneStore @@ -78,7 +79,7 @@ func New( rechargeOrderStore *postgres.RechargeOrderStore, paymentStore *postgres.PaymentStore, walletStore *postgres.AssetWalletStore, - personalDeviceStore *postgres.PersonalCustomerDeviceStore, + binding *customerBinding.Service, openIDStore *postgres.PersonalCustomerOpenIDStore, personalCustomerStore *postgres.PersonalCustomerStore, personalCustomerPhoneStore *postgres.PersonalCustomerPhoneStore, @@ -99,7 +100,7 @@ func New( rechargeOrderStore: rechargeOrderStore, paymentStore: paymentStore, walletStore: walletStore, - personalDeviceStore: personalDeviceStore, + customerBinding: binding, openIDStore: openIDStore, personalCustomerStore: personalCustomerStore, personalCustomerPhoneStore: personalCustomerPhoneStore, @@ -132,8 +133,10 @@ func (s *Service) CreateOrder(ctx context.Context, customerID uint, req *dto.Cli return nil, err } - if err := s.checkAssetOwnership(skipCtx, customerID, assetInfo.VirtualNo); err != nil { - return nil, err + if owned, err := s.customerBinding.OwnsAsset(skipCtx, customerID, assetInfo.AssetType, assetInfo.AssetID); err != nil { + return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询资产归属失败") + } else if !owned { + return nil, errors.New(errors.CodeForbidden, "无权限操作该资产或资源不存在") } validationResult, err := s.validatePurchase(skipCtx, assetInfo, req.PackageIDs) @@ -256,29 +259,25 @@ func (s *Service) CreateOrder(ctx context.Context, customerID uint, req *dto.Cli return s.createPackageOrder(skipCtx, customerID, validationResult, redisKey, &created) } -func (s *Service) checkAssetOwnership(ctx context.Context, customerID uint, virtualNo string) error { - owned, err := s.personalDeviceStore.ExistsByCustomerAndDevice(ctx, customerID, virtualNo) - if err != nil { - return errors.Wrap(errors.CodeDatabaseError, err, "查询资产归属失败") +// getOrderAssetInfo 从订单中提取资产类型和 ID,无需额外 DB 查询 +func getOrderAssetInfo(order *model.Order) (string, uint, error) { + if order == nil { + return "", 0, errors.New(errors.CodeNotFound, "订单不存在") } - if owned { - return nil - } - - records, err := s.personalDeviceStore.GetByCustomerID(ctx, customerID) - if err != nil { - return errors.Wrap(errors.CodeDatabaseError, err, "查询资产归属失败") - } - for _, record := range records { - if record == nil { - continue + switch order.OrderType { + case model.OrderTypeSingleCard: + if order.IotCardID == nil || *order.IotCardID == 0 { + return "", 0, errors.New(errors.CodeInvalidParam) } - if record.Status == constants.StatusEnabled && record.VirtualNo == virtualNo { - return nil + return "iot_card", *order.IotCardID, nil + case model.OrderTypeDevice: + if order.DeviceID == nil || *order.DeviceID == 0 { + return "", 0, errors.New(errors.CodeInvalidParam) } + return "device", *order.DeviceID, nil + default: + return "", 0, errors.New(errors.CodeInvalidParam) } - - return errors.New(errors.CodeForbidden, "无权限操作该资产或资源不存在") } func (s *Service) validatePurchase(ctx context.Context, assetInfo *dto.AssetResolveResponse, packageIDs []uint) (*purchase_validation.PurchaseValidationResult, error) { @@ -1066,8 +1065,10 @@ func (s *Service) ListOrders(ctx context.Context, customerID uint, req *dto.Clie return nil, 0, err } - if err := s.checkAssetOwnership(skipCtx, customerID, assetInfo.VirtualNo); err != nil { - return nil, 0, err + if owned, err := s.customerBinding.OwnsAsset(skipCtx, customerID, assetInfo.AssetType, assetInfo.AssetID); err != nil { + return nil, 0, errors.Wrap(errors.CodeDatabaseError, err, "查询资产归属失败") + } else if !owned { + return nil, 0, errors.New(errors.CodeForbidden, "无权限操作该资产或资源不存在") } generation, err := s.getAssetGeneration(skipCtx, assetInfo.AssetType, assetInfo.AssetID) @@ -1150,13 +1151,14 @@ func (s *Service) GetOrderDetail(ctx context.Context, customerID uint, orderID u } skipCtx := context.WithValue(ctx, constants.ContextKeySubordinateShopIDs, []uint{}) - virtualNo, err := s.getOrderVirtualNo(skipCtx, order) + orderAssetType, orderAssetID, err := getOrderAssetInfo(order) if err != nil { return nil, err } - - if err := s.checkAssetOwnership(skipCtx, customerID, virtualNo); err != nil { - return nil, err + if owned, ownErr := s.customerBinding.OwnsAsset(skipCtx, customerID, orderAssetType, orderAssetID); ownErr != nil { + return nil, errors.Wrap(errors.CodeDatabaseError, ownErr, "查询资产归属失败") + } else if !owned { + return nil, errors.New(errors.CodeForbidden, "无权限操作该资产或资源不存在") } packages := make([]dto.ClientOrderPackageItem, 0, len(items)) diff --git a/internal/service/customer_binding/service.go b/internal/service/customer_binding/service.go new file mode 100644 index 0000000..90537d3 --- /dev/null +++ b/internal/service/customer_binding/service.go @@ -0,0 +1,473 @@ +// Package customer_binding 封装客户与资产之间的绑定创建和归属验证逻辑 +// 屏蔽"有虚拟号走 tb_personal_customer_device / 无虚拟号走 tb_personal_customer_iccid"的路由细节 +package customer_binding + +import ( + "context" + "time" + + "gorm.io/gorm" + + "github.com/break/junhong_cmp_fiber/internal/model" + "github.com/break/junhong_cmp_fiber/internal/store/postgres" + "github.com/break/junhong_cmp_fiber/pkg/errors" +) + +const ( + assetTypeIotCard = "iot_card" + assetTypeDevice = "device" +) + +// normalizeAssetType 统一资产类型字符串 +// assetService.Resolve 返回 "card",client_auth 内部使用 "iot_card",两者等价 +func normalizeAssetType(assetType string) string { + if assetType == "card" { + return assetTypeIotCard + } + return assetType +} + +// cardReader 按 ID 查询 IoT 卡(OwnsAsset 使用,无需感知事务) +type cardReader interface { + GetByID(ctx context.Context, id uint) (*model.IotCard, error) +} + +// deviceReader 按 ID 查询设备(OwnsAsset 使用,无需感知事务) +type deviceReader interface { + GetByID(ctx context.Context, id uint) (*model.Device, error) +} + +// pcdOps tb_personal_customer_device 操作接口 +type pcdOps interface { + ExistsByCustomerAndDevice(ctx context.Context, customerID uint, deviceNo string) (bool, error) + Create(ctx context.Context, record *model.PersonalCustomerDevice) error + CountByVirtualNo(ctx context.Context, virtualNo string) (int64, error) + GetByDeviceNo(ctx context.Context, deviceNo string) ([]*model.PersonalCustomerDevice, error) + UpdateStatus(ctx context.Context, id uint, status int) error + UpdateVirtualNo(ctx context.Context, id uint, newVirtualNo string) error +} + +// pciOps tb_personal_customer_iccid 操作接口 +type pciOps interface { + ExistsByCustomerAndICCID(ctx context.Context, customerID uint, iccid string) (bool, error) + Create(ctx context.Context, record *model.PersonalCustomerICCID) error + CountByICCID(ctx context.Context, iccid string) (int64, error) + GetByICCID(ctx context.Context, iccid string) ([]*model.PersonalCustomerICCID, error) + UpdateStatus(ctx context.Context, id uint, status int) error +} + +// Service 客户绑定服务 +type Service struct { + db *gorm.DB + cards cardReader + devices deviceReader + makePCD func(*gorm.DB) pcdOps + makePCI func(*gorm.DB) pciOps + markAsSold func(ctx context.Context, tx *gorm.DB, assetType string, assetID uint) error + // readCard/readDevice 用于 Bind/Migrate 内部,接收事务 db 以保持读写在同一事务内 + readCard func(ctx context.Context, db *gorm.DB, id uint) (*model.IotCard, error) + readDevice func(ctx context.Context, db *gorm.DB, id uint) (*model.Device, error) +} + +// New 创建客户绑定服务实例 +func New(db *gorm.DB, iotCardStore cardReader, deviceStore deviceReader) *Service { + return &Service{ + db: db, + cards: iotCardStore, + devices: deviceStore, + makePCD: func(db *gorm.DB) pcdOps { + return postgres.NewPersonalCustomerDeviceStore(db) + }, + makePCI: func(db *gorm.DB) pciOps { + return postgres.NewPersonalCustomerICCIDStore(db) + }, + markAsSold: realMarkAssetAsSold, + readCard: func(ctx context.Context, db *gorm.DB, id uint) (*model.IotCard, error) { + var card model.IotCard + if err := db.WithContext(ctx).First(&card, id).Error; err != nil { + if err == gorm.ErrRecordNotFound { + return nil, errors.New(errors.CodeAssetNotFound) + } + return nil, errors.Wrap(errors.CodeInternalError, err, "查询卡资产失败") + } + return &card, nil + }, + readDevice: func(ctx context.Context, db *gorm.DB, id uint) (*model.Device, error) { + var device model.Device + if err := db.WithContext(ctx).First(&device, id).Error; err != nil { + if err == gorm.ErrRecordNotFound { + return nil, errors.New(errors.CodeAssetNotFound) + } + return nil, errors.Wrap(errors.CodeInternalError, err, "查询设备资产失败") + } + return &device, nil + }, + } +} + +// OwnsAsset 验证客户是否持有指定资产的有效绑定(status=1) +// 内部根据 assetType + assetID 自动路由到对应绑定表,调用方无需感知底层存储细节 +func (s *Service) OwnsAsset(ctx context.Context, customerID uint, assetType string, assetID uint) (bool, error) { + pcd := s.makePCD(s.db) + + switch normalizeAssetType(assetType) { + case assetTypeIotCard: + pci := s.makePCI(s.db) + card, err := s.cards.GetByID(ctx, assetID) + if err != nil { + if err == gorm.ErrRecordNotFound { + return false, errors.New(errors.CodeAssetNotFound) + } + return false, errors.Wrap(errors.CodeInternalError, err, "查询卡资产失败") + } + if card.VirtualNo != "" { + return pcd.ExistsByCustomerAndDevice(ctx, customerID, card.VirtualNo) + } + // 无虚拟号:查 pci 表(Issue 02 完整实现,此处已路由到正确路径) + return pci.ExistsByCustomerAndICCID(ctx, customerID, card.ICCID) + + case assetTypeDevice: + device, err := s.devices.GetByID(ctx, assetID) + if err != nil { + if err == gorm.ErrRecordNotFound { + return false, errors.New(errors.CodeAssetNotFound) + } + return false, errors.Wrap(errors.CodeInternalError, err, "查询设备资产失败") + } + key := device.VirtualNo + if key == "" { + key = device.IMEI + } + return pcd.ExistsByCustomerAndDevice(ctx, customerID, key) + } + + return false, errors.New(errors.CodeInvalidParam) +} + +// Bind 在事务中创建客户与资产的绑定关系 +// 有虚拟号的 IoT 卡 / 设备 → tb_personal_customer_device +// 无虚拟号的 IoT 卡 → tb_personal_customer_iccid(Issue 02) +func (s *Service) Bind(ctx context.Context, tx *gorm.DB, customerID uint, assetType string, assetID uint) error { + if tx == nil { + tx = s.db + } + pcd := s.makePCD(tx) + pci := s.makePCI(tx) + + switch normalizeAssetType(assetType) { + case assetTypeIotCard: + card, err := s.readCard(ctx, tx, assetID) + if err != nil { + return err + } + if card.VirtualNo != "" { + return s.bindViaPCD(ctx, pcd, tx, customerID, card.VirtualNo, assetTypeIotCard, assetID) + } + // 无虚拟号路径(Issue 02) + return s.bindViaPCI(ctx, pci, tx, customerID, card.ICCID, assetTypeIotCard, assetID) + + case assetTypeDevice: + device, err := s.readDevice(ctx, tx, assetID) + if err != nil { + return err + } + key := device.VirtualNo + if key == "" { + key = device.IMEI + } + return s.bindViaPCD(ctx, pcd, tx, customerID, key, assetType, assetID) + } + + return errors.New(errors.CodeInvalidParam) +} + +// bindViaPCD 通过 tb_personal_customer_device 创建绑定 +func (s *Service) bindViaPCD(ctx context.Context, pcd pcdOps, tx *gorm.DB, customerID uint, virtualNo string, assetType string, assetID uint) error { + count, err := pcd.CountByVirtualNo(ctx, virtualNo) + if err != nil { + return errors.Wrap(errors.CodeInternalError, err, "查询资产绑定数量失败") + } + firstEverBind := count == 0 + + exists, err := pcd.ExistsByCustomerAndDevice(ctx, customerID, virtualNo) + if err != nil { + return errors.Wrap(errors.CodeInternalError, err, "查询客户资产绑定关系失败") + } + + if !exists { + record := &model.PersonalCustomerDevice{ + CustomerID: customerID, + VirtualNo: virtualNo, + Status: 1, + } + if err := pcd.Create(ctx, record); err != nil { + return errors.Wrap(errors.CodeInternalError, err, "创建资产绑定关系失败") + } + } + + if firstEverBind { + return s.markAsSold(ctx, tx, assetType, assetID) + } + + return nil +} + +// bindViaPCI 通过 tb_personal_customer_iccid 创建绑定(无虚拟号卡专用,Issue 02) +func (s *Service) bindViaPCI(ctx context.Context, pci pciOps, tx *gorm.DB, customerID uint, iccid string, assetType string, assetID uint) error { + count, err := pci.CountByICCID(ctx, iccid) + if err != nil { + return errors.Wrap(errors.CodeInternalError, err, "查询 ICCID 绑定数量失败") + } + firstEverBind := count == 0 + + exists, err := pci.ExistsByCustomerAndICCID(ctx, customerID, iccid) + if err != nil { + return errors.Wrap(errors.CodeInternalError, err, "查询客户 ICCID 绑定关系失败") + } + + if !exists { + iccid19 := iccid + if len(iccid) == 20 { + iccid19 = iccid[:19] + } + record := &model.PersonalCustomerICCID{ + CustomerID: customerID, + ICCID: iccid, + ICCID19: iccid19, + Status: 1, + } + if err := pci.Create(ctx, record); err != nil { + return errors.Wrap(errors.CodeInternalError, err, "创建 ICCID 绑定关系失败") + } + } + + if firstEverBind { + return s.markAsSold(ctx, tx, assetType, assetID) + } + + return nil +} + +// Migrate 将旧资产的所有有效客户绑定迁移到新资产(换货专用) +// 无绑定时静默跳过;按旧/新资产虚拟号有无路由到 pcd 或 pci +func (s *Service) Migrate(ctx context.Context, tx *gorm.DB, oldAssetType string, oldAssetID uint, newAssetType string, newAssetID uint) error { + if tx == nil { + tx = s.db + } + pcd := s.makePCD(tx) + pci := s.makePCI(tx) + + switch normalizeAssetType(oldAssetType) { + case assetTypeIotCard: + oldCard, err := s.readCard(ctx, tx, oldAssetID) + if err != nil { + return err + } + if oldCard.VirtualNo != "" { + return s.migrateFromPCD(ctx, tx, pcd, pci, oldCard.VirtualNo, newAssetType, newAssetID) + } + return s.migrateFromPCI(ctx, tx, pcd, pci, oldCard.ICCID, newAssetType, newAssetID) + + case assetTypeDevice: + oldDevice, err := s.readDevice(ctx, tx, oldAssetID) + if err != nil { + return err + } + key := oldDevice.VirtualNo + if key == "" { + key = oldDevice.IMEI + } + return s.migrateFromPCD(ctx, tx, pcd, pci, key, newAssetType, newAssetID) + } + + return errors.New(errors.CodeInvalidParam) +} + +// migrateFromPCD 将 tb_personal_customer_device 中 oldKey 的所有有效绑定迁移到新资产 +func (s *Service) migrateFromPCD(ctx context.Context, db *gorm.DB, pcd pcdOps, pci pciOps, oldKey string, newAssetType string, newAssetID uint) error { + records, err := pcd.GetByDeviceNo(ctx, oldKey) + if err != nil { + return errors.Wrap(errors.CodeInternalError, err, "查询旧资产绑定记录失败") + } + + // 仅处理 status=1 的有效记录 + var active []*model.PersonalCustomerDevice + for _, r := range records { + if r.Status == 1 { + active = append(active, r) + } + } + if len(active) == 0 { + return nil + } + + switch normalizeAssetType(newAssetType) { + case assetTypeIotCard: + newCard, err := s.readCard(ctx, db, newAssetID) + if err != nil { + return err + } + if newCard.VirtualNo != "" { + // 新卡有虚拟号:更新 pcd virtual_no + for _, rec := range active { + if err := pcd.UpdateVirtualNo(ctx, rec.ID, newCard.VirtualNo); err != nil { + return errors.Wrap(errors.CodeInternalError, err, "迁移客户绑定关系失败") + } + } + } else { + // 新卡无虚拟号:禁用旧 pcd + 创建新 pci + iccid19 := newCard.ICCID + if len(newCard.ICCID) == 20 { + iccid19 = newCard.ICCID[:19] + } + for _, rec := range active { + if err := pcd.UpdateStatus(ctx, rec.ID, 0); err != nil { + return errors.Wrap(errors.CodeInternalError, err, "禁用旧客户绑定关系失败") + } + newPCI := &model.PersonalCustomerICCID{ + CustomerID: rec.CustomerID, + ICCID: newCard.ICCID, + ICCID19: iccid19, + Status: 1, + } + if err := pci.Create(ctx, newPCI); err != nil { + return errors.Wrap(errors.CodeInternalError, err, "创建新客户绑定关系失败") + } + } + } + + case assetTypeDevice: + newDevice, err := s.readDevice(ctx, db, newAssetID) + if err != nil { + return err + } + newKey := newDevice.VirtualNo + if newKey == "" { + newKey = newDevice.IMEI + } + for _, rec := range active { + if err := pcd.UpdateVirtualNo(ctx, rec.ID, newKey); err != nil { + return errors.Wrap(errors.CodeInternalError, err, "迁移设备客户绑定关系失败") + } + } + + default: + return errors.New(errors.CodeInvalidParam) + } + + return nil +} + +// migrateFromPCI 将 tb_personal_customer_iccid 中 oldICCID 的所有有效绑定迁移到新资产 +func (s *Service) migrateFromPCI(ctx context.Context, db *gorm.DB, pcd pcdOps, pci pciOps, oldICCID string, newAssetType string, newAssetID uint) error { + records, err := pci.GetByICCID(ctx, oldICCID) + if err != nil { + return errors.Wrap(errors.CodeInternalError, err, "查询旧 ICCID 绑定记录失败") + } + + var active []*model.PersonalCustomerICCID + for _, r := range records { + if r.Status == 1 { + active = append(active, r) + } + } + if len(active) == 0 { + return nil + } + + switch normalizeAssetType(newAssetType) { + case assetTypeIotCard: + newCard, err := s.readCard(ctx, db, newAssetID) + if err != nil { + return err + } + if newCard.VirtualNo != "" { + // 新卡有虚拟号:禁用旧 pci + 创建新 pcd + for _, rec := range active { + if err := pci.UpdateStatus(ctx, rec.ID, 0); err != nil { + return errors.Wrap(errors.CodeInternalError, err, "禁用旧 ICCID 绑定关系失败") + } + newPCD := &model.PersonalCustomerDevice{ + CustomerID: rec.CustomerID, + VirtualNo: newCard.VirtualNo, + Status: 1, + } + if err := pcd.Create(ctx, newPCD); err != nil { + return errors.Wrap(errors.CodeInternalError, err, "创建新客户绑定关系失败") + } + } + } else { + // 新卡也无虚拟号:禁用旧 pci + 创建新 pci(新 ICCID) + iccid19 := newCard.ICCID + if len(newCard.ICCID) == 20 { + iccid19 = newCard.ICCID[:19] + } + for _, rec := range active { + if err := pci.UpdateStatus(ctx, rec.ID, 0); err != nil { + return errors.Wrap(errors.CodeInternalError, err, "禁用旧 ICCID 绑定关系失败") + } + newPCI := &model.PersonalCustomerICCID{ + CustomerID: rec.CustomerID, + ICCID: newCard.ICCID, + ICCID19: iccid19, + Status: 1, + } + if err := pci.Create(ctx, newPCI); err != nil { + return errors.Wrap(errors.CodeInternalError, err, "创建新 ICCID 绑定关系失败") + } + } + } + + case assetTypeDevice: + newDevice, err := s.readDevice(ctx, db, newAssetID) + if err != nil { + return err + } + newKey := newDevice.VirtualNo + if newKey == "" { + newKey = newDevice.IMEI + } + for _, rec := range active { + if err := pci.UpdateStatus(ctx, rec.ID, 0); err != nil { + return errors.Wrap(errors.CodeInternalError, err, "禁用旧 ICCID 绑定关系失败") + } + newPCD := &model.PersonalCustomerDevice{ + CustomerID: rec.CustomerID, + VirtualNo: newKey, + Status: 1, + } + if err := pcd.Create(ctx, newPCD); err != nil { + return errors.Wrap(errors.CodeInternalError, err, "创建新客户绑定关系失败") + } + } + + default: + return errors.New(errors.CodeInvalidParam) + } + + return nil +} + +// realMarkAssetAsSold 将资产状态从在库(1)更新为已销售(2) +func realMarkAssetAsSold(ctx context.Context, tx *gorm.DB, assetType string, assetID uint) error { + now := time.Now() + switch assetType { + case assetTypeIotCard: + if err := tx.WithContext(ctx). + Model(&model.IotCard{}). + Where("id = ? AND asset_status = ?", assetID, 1). + Updates(map[string]any{"asset_status": 2, "updated_at": now}).Error; err != nil { + return errors.Wrap(errors.CodeInternalError, err, "更新卡资产状态失败") + } + case assetTypeDevice: + if err := tx.WithContext(ctx). + Model(&model.Device{}). + Where("id = ? AND asset_status = ?", assetID, 1). + Updates(map[string]any{"asset_status": 2, "updated_at": now}).Error; err != nil { + return errors.Wrap(errors.CodeInternalError, err, "更新设备资产状态失败") + } + default: + return errors.New(errors.CodeInvalidParam) + } + return nil +} diff --git a/internal/service/customer_binding/service_test.go b/internal/service/customer_binding/service_test.go new file mode 100644 index 0000000..0c8a870 --- /dev/null +++ b/internal/service/customer_binding/service_test.go @@ -0,0 +1,715 @@ +package customer_binding + +import ( + "context" + "fmt" + "testing" + + "gorm.io/gorm" + + "github.com/break/junhong_cmp_fiber/internal/model" +) + +// ---- 测试工具 ---- + +// bindKey 构建 mock 中使用的查找键 +func bindKey(customerID uint, key string) string { + return fmt.Sprintf("%d:%s", customerID, key) +} + +// ---- mock 实现 ---- + +type mockCardReader struct { + cards map[uint]*model.IotCard +} + +func (m *mockCardReader) GetByID(_ context.Context, id uint) (*model.IotCard, error) { + if c, ok := m.cards[id]; ok { + return c, nil + } + return nil, gorm.ErrRecordNotFound +} + +type mockDeviceReader struct { + devices map[uint]*model.Device +} + +func (m *mockDeviceReader) GetByID(_ context.Context, id uint) (*model.Device, error) { + if d, ok := m.devices[id]; ok { + return d, nil + } + return nil, gorm.ErrRecordNotFound +} + +// mockPCDOps 模拟 tb_personal_customer_device 操作 +type mockPCDOps struct { + // bindings["customerID:virtualNo"] = true 表示有效(status=1)绑定 + bindings map[string]bool + created []*model.PersonalCustomerDevice + counts map[string]int64 // virtualNo → count(用于首绑判断) + allRecords []*model.PersonalCustomerDevice // GetByDeviceNo 和 UpdateStatus/UpdateVirtualNo 使用 +} + +func (m *mockPCDOps) ExistsByCustomerAndDevice(_ context.Context, customerID uint, deviceNo string) (bool, error) { + return m.bindings[bindKey(customerID, deviceNo)], nil +} + +func (m *mockPCDOps) Create(_ context.Context, record *model.PersonalCustomerDevice) error { + m.created = append(m.created, record) + return nil +} + +func (m *mockPCDOps) CountByVirtualNo(_ context.Context, virtualNo string) (int64, error) { + if m.counts != nil { + return m.counts[virtualNo], nil + } + return 0, nil +} + +func (m *mockPCDOps) GetByDeviceNo(_ context.Context, deviceNo string) ([]*model.PersonalCustomerDevice, error) { + var result []*model.PersonalCustomerDevice + for _, r := range m.allRecords { + if r.VirtualNo == deviceNo { + result = append(result, r) + } + } + return result, nil +} + +func (m *mockPCDOps) UpdateStatus(_ context.Context, id uint, status int) error { + for _, r := range m.allRecords { + if r.ID == id { + r.Status = status + return nil + } + } + return nil +} + +func (m *mockPCDOps) UpdateVirtualNo(_ context.Context, id uint, newVirtualNo string) error { + for _, r := range m.allRecords { + if r.ID == id { + r.VirtualNo = newVirtualNo + return nil + } + } + return nil +} + +// mockPCIOps 模拟 tb_personal_customer_iccid 操作 +type mockPCIOps struct { + bindings map[string]bool + created []*model.PersonalCustomerICCID + counts map[string]int64 // iccid → count + allRecords []*model.PersonalCustomerICCID // GetByICCID 和 UpdateStatus 使用 +} + +func (m *mockPCIOps) ExistsByCustomerAndICCID(_ context.Context, customerID uint, iccid string) (bool, error) { + return m.bindings[bindKey(customerID, iccid)], nil +} + +func (m *mockPCIOps) Create(_ context.Context, record *model.PersonalCustomerICCID) error { + m.created = append(m.created, record) + return nil +} + +func (m *mockPCIOps) CountByICCID(_ context.Context, iccid string) (int64, error) { + if m.counts != nil { + return m.counts[iccid], nil + } + return 0, nil +} + +func (m *mockPCIOps) GetByICCID(_ context.Context, iccid string) ([]*model.PersonalCustomerICCID, error) { + var result []*model.PersonalCustomerICCID + for _, r := range m.allRecords { + if r.ICCID == iccid { + result = append(result, r) + } + } + return result, nil +} + +func (m *mockPCIOps) UpdateStatus(_ context.Context, id uint, status int) error { + for _, r := range m.allRecords { + if r.ID == id { + r.Status = status + return nil + } + } + return nil +} + +// ---- 测试 Service 构造器 ---- + +// soldCalls 记录 markAsSold 调用 +type soldCalls struct { + items []string +} + +func (s *soldCalls) mark(_ context.Context, _ *gorm.DB, assetType string, _ uint) error { + s.items = append(s.items, assetType) + return nil +} + +func newTestService(cards cardReader, devices deviceReader, pcd pcdOps, pci pciOps) (*Service, *soldCalls) { + sold := &soldCalls{} + return &Service{ + cards: cards, + devices: devices, + readCard: func(ctx context.Context, _ *gorm.DB, id uint) (*model.IotCard, error) { + return cards.GetByID(ctx, id) + }, + readDevice: func(ctx context.Context, _ *gorm.DB, id uint) (*model.Device, error) { + return devices.GetByID(ctx, id) + }, + makePCD: func(_ *gorm.DB) pcdOps { return pcd }, + makePCI: func(_ *gorm.DB) pciOps { return pci }, + markAsSold: sold.mark, + }, sold +} + +// ---- OwnsAsset 测试 ---- + +// 验证:有虚拟号卡 + 客户有有效绑定 → true(tracer bullet) +func TestOwnsAsset_有虚拟号卡_有效绑定_返回true(t *testing.T) { + card := &model.IotCard{VirtualNo: "VN001"} + card.ID = 1 + + pcd := &mockPCDOps{ + bindings: map[string]bool{bindKey(10, "VN001"): true}, + } + svc, _ := newTestService( + &mockCardReader{cards: map[uint]*model.IotCard{1: card}}, + &mockDeviceReader{}, + pcd, + &mockPCIOps{}, + ) + + owned, err := svc.OwnsAsset(context.Background(), 10, "iot_card", 1) + + if err != nil { + t.Fatalf("期望无错误,实际: %v", err) + } + if !owned { + t.Fatal("期望返回 true,实际返回 false") + } +} + +// 验证:有虚拟号卡 + status=0 的绑定 → false(修复安全缺口) +func TestOwnsAsset_有虚拟号卡_禁用绑定_返回false(t *testing.T) { + card := &model.IotCard{VirtualNo: "VN002"} + card.ID = 2 + + pcd := &mockPCDOps{ + // status=0 的绑定:在 ExistsByCustomerAndDevice 中会过滤掉(bindings 中不存在) + bindings: map[string]bool{}, + } + svc, _ := newTestService( + &mockCardReader{cards: map[uint]*model.IotCard{2: card}}, + &mockDeviceReader{}, + pcd, + &mockPCIOps{}, + ) + + owned, err := svc.OwnsAsset(context.Background(), 10, "iot_card", 2) + + if err != nil { + t.Fatalf("期望无错误,实际: %v", err) + } + if owned { + t.Fatal("期望返回 false(status=0),实际返回 true") + } +} + +// 验证:有虚拟号卡 + 无绑定 → false +func TestOwnsAsset_有虚拟号卡_无绑定_返回false(t *testing.T) { + card := &model.IotCard{VirtualNo: "VN003"} + card.ID = 3 + + svc, _ := newTestService( + &mockCardReader{cards: map[uint]*model.IotCard{3: card}}, + &mockDeviceReader{}, + &mockPCDOps{bindings: map[string]bool{}}, + &mockPCIOps{}, + ) + + owned, err := svc.OwnsAsset(context.Background(), 10, "iot_card", 3) + + if err != nil { + t.Fatalf("期望无错误,实际: %v", err) + } + if owned { + t.Fatal("期望返回 false(无绑定),实际返回 true") + } +} + +// 验证:assetType="card"(来自 assetService.Resolve)等价于 "iot_card" +func TestOwnsAsset_assetType_card_等价iot_card(t *testing.T) { + card := &model.IotCard{VirtualNo: "VN_CARD"} + card.ID = 9 + + pcd := &mockPCDOps{ + bindings: map[string]bool{bindKey(10, "VN_CARD"): true}, + } + svc, _ := newTestService( + &mockCardReader{cards: map[uint]*model.IotCard{9: card}}, + &mockDeviceReader{}, + pcd, + &mockPCIOps{}, + ) + + owned, err := svc.OwnsAsset(context.Background(), 10, "card", 9) + + if err != nil { + t.Fatalf("期望无错误,实际: %v", err) + } + if !owned { + t.Fatal("期望 card 类型等价 iot_card 返回 true,实际 false") + } +} + +// 验证:设备资产 + 有效绑定 → true +func TestOwnsAsset_设备_有效绑定_返回true(t *testing.T) { + device := &model.Device{VirtualNo: "DEV001"} + device.ID = 5 + + pcd := &mockPCDOps{ + bindings: map[string]bool{bindKey(10, "DEV001"): true}, + } + svc, _ := newTestService( + &mockCardReader{}, + &mockDeviceReader{devices: map[uint]*model.Device{5: device}}, + pcd, + &mockPCIOps{}, + ) + + owned, err := svc.OwnsAsset(context.Background(), 10, "device", 5) + + if err != nil { + t.Fatalf("期望无错误,实际: %v", err) + } + if !owned { + t.Fatal("期望返回 true,实际返回 false") + } +} + +// ---- Bind 测试 ---- + +// 验证:首次绑定有虚拟号卡 → 写入 pcd 记录 + 触发 markAssetAsSold +func TestBind_有虚拟号卡_首次绑定_创建记录并标记已售(t *testing.T) { + card := &model.IotCard{VirtualNo: "VN010"} + card.ID = 10 + + pcd := &mockPCDOps{ + bindings: map[string]bool{}, + counts: map[string]int64{"VN010": 0}, // 首次绑定 + } + svc, sold := newTestService( + &mockCardReader{cards: map[uint]*model.IotCard{10: card}}, + &mockDeviceReader{}, + pcd, + &mockPCIOps{}, + ) + + err := svc.Bind(context.Background(), nil, 20, "iot_card", 10) + + if err != nil { + t.Fatalf("期望无错误,实际: %v", err) + } + if len(pcd.created) != 1 { + t.Fatalf("期望创建 1 条 pcd 记录,实际: %d", len(pcd.created)) + } + if pcd.created[0].VirtualNo != "VN010" { + t.Errorf("期望 VirtualNo=VN010,实际: %s", pcd.created[0].VirtualNo) + } + if pcd.created[0].CustomerID != 20 { + t.Errorf("期望 CustomerID=20,实际: %d", pcd.created[0].CustomerID) + } + if len(sold.items) != 1 || sold.items[0] != "iot_card" { + t.Errorf("期望触发 markAssetAsSold(iot_card),实际: %v", sold.items) + } +} + +// 验证:重复绑定 → 不创建新记录 +func TestBind_有虚拟号卡_已有绑定_不重复创建(t *testing.T) { + card := &model.IotCard{VirtualNo: "VN011"} + card.ID = 11 + + pcd := &mockPCDOps{ + bindings: map[string]bool{bindKey(20, "VN011"): true}, + counts: map[string]int64{"VN011": 1}, + } + svc, _ := newTestService( + &mockCardReader{cards: map[uint]*model.IotCard{11: card}}, + &mockDeviceReader{}, + pcd, + &mockPCIOps{}, + ) + + err := svc.Bind(context.Background(), nil, 20, "iot_card", 11) + + if err != nil { + t.Fatalf("期望无错误,实际: %v", err) + } + if len(pcd.created) != 0 { + t.Fatalf("期望不创建新记录,实际创建了 %d 条", len(pcd.created)) + } +} + +// 验证:非首次绑定(已有其他客户绑定)→ 创建记录但不触发 markAssetAsSold +func TestBind_有虚拟号卡_非首次绑定_创建记录不标记已售(t *testing.T) { + card := &model.IotCard{VirtualNo: "VN012"} + card.ID = 12 + + pcd := &mockPCDOps{ + bindings: map[string]bool{}, + counts: map[string]int64{"VN012": 1}, // 已有其他绑定 + } + svc, sold := newTestService( + &mockCardReader{cards: map[uint]*model.IotCard{12: card}}, + &mockDeviceReader{}, + pcd, + &mockPCIOps{}, + ) + + err := svc.Bind(context.Background(), nil, 30, "iot_card", 12) + + if err != nil { + t.Fatalf("期望无错误,实际: %v", err) + } + if len(pcd.created) != 1 { + t.Fatalf("期望创建 1 条记录,实际: %d", len(pcd.created)) + } + if len(sold.items) != 0 { + t.Errorf("期望不触发 markAssetAsSold,实际触发了: %v", sold.items) + } +} + +// ---- Issue 02: 无虚拟号卡 pci 路径测试 ---- + +// 验证:无虚拟号卡 + 客户有有效 PCI 绑定 → true +func TestOwnsAsset_无虚拟号卡_有效PCI绑定_返回true(t *testing.T) { + card := &model.IotCard{ICCID: "89860000000000000001"} // VirtualNo 为空 + card.ID = 20 + + pci := &mockPCIOps{ + bindings: map[string]bool{bindKey(10, "89860000000000000001"): true}, + } + svc, _ := newTestService( + &mockCardReader{cards: map[uint]*model.IotCard{20: card}}, + &mockDeviceReader{}, + &mockPCDOps{bindings: map[string]bool{}}, + pci, + ) + + owned, err := svc.OwnsAsset(context.Background(), 10, "iot_card", 20) + + if err != nil { + t.Fatalf("期望无错误,实际: %v", err) + } + if !owned { + t.Fatal("期望返回 true,实际返回 false") + } +} + +// 验证:无虚拟号卡 + 无 PCI 绑定 → false +func TestOwnsAsset_无虚拟号卡_无PCI绑定_返回false(t *testing.T) { + card := &model.IotCard{ICCID: "89860000000000000002"} // VirtualNo 为空 + card.ID = 21 + + svc, _ := newTestService( + &mockCardReader{cards: map[uint]*model.IotCard{21: card}}, + &mockDeviceReader{}, + &mockPCDOps{bindings: map[string]bool{}}, + &mockPCIOps{bindings: map[string]bool{}}, + ) + + owned, err := svc.OwnsAsset(context.Background(), 10, "iot_card", 21) + + if err != nil { + t.Fatalf("期望无错误,实际: %v", err) + } + if owned { + t.Fatal("期望返回 false(无 PCI 绑定),实际返回 true") + } +} + +// 验证:无虚拟号卡首次绑定 → 写入 pci 记录 + 触发 markAssetAsSold +func TestBind_无虚拟号卡_首次绑定_创建PCI记录并标记已售(t *testing.T) { + card := &model.IotCard{ICCID: "89860000000000000010"} // VirtualNo 为空 + card.ID = 30 + + pci := &mockPCIOps{ + bindings: map[string]bool{}, + counts: map[string]int64{"89860000000000000010": 0}, // 首次绑定 + } + svc, sold := newTestService( + &mockCardReader{cards: map[uint]*model.IotCard{30: card}}, + &mockDeviceReader{}, + &mockPCDOps{bindings: map[string]bool{}}, + pci, + ) + + err := svc.Bind(context.Background(), nil, 50, "iot_card", 30) + + if err != nil { + t.Fatalf("期望无错误,实际: %v", err) + } + if len(pci.created) != 1 { + t.Fatalf("期望创建 1 条 pci 记录,实际: %d", len(pci.created)) + } + if pci.created[0].ICCID != "89860000000000000010" { + t.Errorf("期望 ICCID=89860000000000000010,实际: %s", pci.created[0].ICCID) + } + if pci.created[0].CustomerID != 50 { + t.Errorf("期望 CustomerID=50,实际: %d", pci.created[0].CustomerID) + } + if len(sold.items) != 1 || sold.items[0] != "iot_card" { + t.Errorf("期望触发 markAssetAsSold(iot_card),实际: %v", sold.items) + } +} + +// 验证:无虚拟号卡已有绑定 → 不重复创建 +func TestBind_无虚拟号卡_已有绑定_不重复创建(t *testing.T) { + card := &model.IotCard{ICCID: "89860000000000000011"} // VirtualNo 为空 + card.ID = 31 + + pci := &mockPCIOps{ + bindings: map[string]bool{bindKey(50, "89860000000000000011"): true}, + counts: map[string]int64{"89860000000000000011": 1}, + } + svc, _ := newTestService( + &mockCardReader{cards: map[uint]*model.IotCard{31: card}}, + &mockDeviceReader{}, + &mockPCDOps{bindings: map[string]bool{}}, + pci, + ) + + err := svc.Bind(context.Background(), nil, 50, "iot_card", 31) + + if err != nil { + t.Fatalf("期望无错误,实际: %v", err) + } + if len(pci.created) != 0 { + t.Fatalf("期望不创建新记录,实际创建了 %d 条", len(pci.created)) + } +} + +// ---- Issue 03: Migrate 迁移测试 ---- + +// newMockPCDRecord 构建一条带 ID 的 pcd 记录(用于迁移测试) +func newMockPCDRecord(id, customerID uint, virtualNo string, status int) *model.PersonalCustomerDevice { + r := &model.PersonalCustomerDevice{ + CustomerID: customerID, + VirtualNo: virtualNo, + Status: status, + } + r.ID = id + return r +} + +// newMockPCIRecord 构建一条带 ID 的 pci 记录(用于迁移测试) +func newMockPCIRecord(id, customerID uint, iccid string, status int) *model.PersonalCustomerICCID { + r := &model.PersonalCustomerICCID{ + CustomerID: customerID, + ICCID: iccid, + Status: status, + } + r.ID = id + return r +} + +// 验证:旧卡有虚拟号 + pcd 有绑定 + 新卡有虚拟号 → pcd.virtual_no 更新为新卡虚拟号 +func TestMigrate_有虚拟号旧卡_有绑定_换有虚拟号新卡_更新VirtualNo(t *testing.T) { + oldCard := &model.IotCard{VirtualNo: "OLD_VN"} + oldCard.ID = 100 + newCard := &model.IotCard{VirtualNo: "NEW_VN"} + newCard.ID = 101 + + existing := newMockPCDRecord(1, 50, "OLD_VN", 1) + pcd := &mockPCDOps{ + bindings: map[string]bool{}, + allRecords: []*model.PersonalCustomerDevice{existing}, + } + svc, _ := newTestService( + &mockCardReader{cards: map[uint]*model.IotCard{100: oldCard, 101: newCard}}, + &mockDeviceReader{}, + pcd, + &mockPCIOps{}, + ) + + err := svc.Migrate(context.Background(), nil, "iot_card", 100, "iot_card", 101) + + if err != nil { + t.Fatalf("期望无错误,实际: %v", err) + } + if existing.VirtualNo != "NEW_VN" { + t.Errorf("期望 VirtualNo 更新为 NEW_VN,实际: %s", existing.VirtualNo) + } +} + +// 验证:旧卡有虚拟号 + pcd 有绑定 + 新卡无虚拟号 → 旧 pcd status=0,新 pci 创建 +func TestMigrate_有虚拟号旧卡_有绑定_换无虚拟号新卡_迁移到PCI(t *testing.T) { + oldCard := &model.IotCard{VirtualNo: "OLD_VN2"} + oldCard.ID = 110 + newCard := &model.IotCard{ICCID: "89860000000000000099"} // 无虚拟号 + newCard.ID = 111 + + existing := newMockPCDRecord(2, 60, "OLD_VN2", 1) + pcd := &mockPCDOps{ + allRecords: []*model.PersonalCustomerDevice{existing}, + } + pci := &mockPCIOps{} + svc, _ := newTestService( + &mockCardReader{cards: map[uint]*model.IotCard{110: oldCard, 111: newCard}}, + &mockDeviceReader{}, + pcd, + pci, + ) + + err := svc.Migrate(context.Background(), nil, "iot_card", 110, "iot_card", 111) + + if err != nil { + t.Fatalf("期望无错误,实际: %v", err) + } + if existing.Status != 0 { + t.Errorf("期望旧 pcd 记录 status=0,实际: %d", existing.Status) + } + if len(pci.created) != 1 { + t.Fatalf("期望创建 1 条 pci 记录,实际: %d", len(pci.created)) + } + if pci.created[0].CustomerID != 60 { + t.Errorf("期望 pci CustomerID=60,实际: %d", pci.created[0].CustomerID) + } + if pci.created[0].ICCID != "89860000000000000099" { + t.Errorf("期望 pci ICCID=89860000000000000099,实际: %s", pci.created[0].ICCID) + } +} + +// 验证:旧卡有虚拟号 + pcd 无绑定 + 新卡无虚拟号 → 跳过,无写入 +func TestMigrate_有虚拟号旧卡_无绑定_换无虚拟号新卡_跳过(t *testing.T) { + oldCard := &model.IotCard{VirtualNo: "OLD_VN3"} + oldCard.ID = 120 + newCard := &model.IotCard{ICCID: "89860000000000000088"} // 无虚拟号 + newCard.ID = 121 + + pcd := &mockPCDOps{allRecords: []*model.PersonalCustomerDevice{}} // 空 + pci := &mockPCIOps{} + svc, _ := newTestService( + &mockCardReader{cards: map[uint]*model.IotCard{120: oldCard, 121: newCard}}, + &mockDeviceReader{}, + pcd, + pci, + ) + + err := svc.Migrate(context.Background(), nil, "iot_card", 120, "iot_card", 121) + + if err != nil { + t.Fatalf("期望无错误,实际: %v", err) + } + if len(pci.created) != 0 { + t.Fatalf("期望无写入,实际创建了 %d 条 pci 记录", len(pci.created)) + } +} + +// 验证:旧卡无虚拟号 + pci 有绑定 + 新卡有虚拟号 → 旧 pci status=0,新 pcd 创建 +func TestMigrate_无虚拟号旧卡_有绑定_换有虚拟号新卡_迁移到PCD(t *testing.T) { + oldCard := &model.IotCard{ICCID: "89860000000000000077"} // 无虚拟号 + oldCard.ID = 130 + newCard := &model.IotCard{VirtualNo: "NEW_VN3"} + newCard.ID = 131 + + existingPCI := newMockPCIRecord(3, 70, "89860000000000000077", 1) + pci := &mockPCIOps{allRecords: []*model.PersonalCustomerICCID{existingPCI}} + pcd := &mockPCDOps{} + svc, _ := newTestService( + &mockCardReader{cards: map[uint]*model.IotCard{130: oldCard, 131: newCard}}, + &mockDeviceReader{}, + pcd, + pci, + ) + + err := svc.Migrate(context.Background(), nil, "iot_card", 130, "iot_card", 131) + + if err != nil { + t.Fatalf("期望无错误,实际: %v", err) + } + if existingPCI.Status != 0 { + t.Errorf("期望旧 pci 记录 status=0,实际: %d", existingPCI.Status) + } + if len(pcd.created) != 1 { + t.Fatalf("期望创建 1 条 pcd 记录,实际: %d", len(pcd.created)) + } + if pcd.created[0].CustomerID != 70 { + t.Errorf("期望 pcd CustomerID=70,实际: %d", pcd.created[0].CustomerID) + } + if pcd.created[0].VirtualNo != "NEW_VN3" { + t.Errorf("期望 pcd VirtualNo=NEW_VN3,实际: %s", pcd.created[0].VirtualNo) + } +} + +// 验证:旧卡无虚拟号 + pci 有绑定 + 新卡也无虚拟号 → 旧 pci status=0,新 pci 创建新 ICCID +func TestMigrate_无虚拟号旧卡_有绑定_换无虚拟号新卡_迁移PCI(t *testing.T) { + oldCard := &model.IotCard{ICCID: "89860000000000000066"} // 无虚拟号 + oldCard.ID = 140 + newCard := &model.IotCard{ICCID: "89860000000000000055"} // 无虚拟号 + newCard.ID = 141 + + existingPCI := newMockPCIRecord(4, 80, "89860000000000000066", 1) + pci := &mockPCIOps{allRecords: []*model.PersonalCustomerICCID{existingPCI}} + pcd := &mockPCDOps{} + svc, _ := newTestService( + &mockCardReader{cards: map[uint]*model.IotCard{140: oldCard, 141: newCard}}, + &mockDeviceReader{}, + pcd, + pci, + ) + + err := svc.Migrate(context.Background(), nil, "iot_card", 140, "iot_card", 141) + + if err != nil { + t.Fatalf("期望无错误,实际: %v", err) + } + if existingPCI.Status != 0 { + t.Errorf("期望旧 pci 记录 status=0,实际: %d", existingPCI.Status) + } + if len(pci.created) != 1 { + t.Fatalf("期望创建 1 条新 pci 记录,实际: %d", len(pci.created)) + } + if pci.created[0].CustomerID != 80 { + t.Errorf("期望 pci CustomerID=80,实际: %d", pci.created[0].CustomerID) + } + if pci.created[0].ICCID != "89860000000000000055" { + t.Errorf("期望 pci ICCID=89860000000000000055,实际: %s", pci.created[0].ICCID) + } +} + +// 验证:无虚拟号卡非首次绑定(已有其他客户)→ 创建记录但不触发 markAssetAsSold +func TestBind_无虚拟号卡_非首次绑定_创建记录不标记已售(t *testing.T) { + card := &model.IotCard{ICCID: "89860000000000000012"} // VirtualNo 为空 + card.ID = 32 + + pci := &mockPCIOps{ + bindings: map[string]bool{}, + counts: map[string]int64{"89860000000000000012": 1}, // 已有其他客户绑定 + } + svc, sold := newTestService( + &mockCardReader{cards: map[uint]*model.IotCard{32: card}}, + &mockDeviceReader{}, + &mockPCDOps{bindings: map[string]bool{}}, + pci, + ) + + err := svc.Bind(context.Background(), nil, 60, "iot_card", 32) + + if err != nil { + t.Fatalf("期望无错误,实际: %v", err) + } + if len(pci.created) != 1 { + t.Fatalf("期望创建 1 条记录,实际: %d", len(pci.created)) + } + if len(sold.items) != 0 { + t.Errorf("期望不触发 markAssetAsSold,实际触发了: %v", sold.items) + } +} diff --git a/internal/service/exchange/service.go b/internal/service/exchange/service.go index 24ee539..f72d0cc 100644 --- a/internal/service/exchange/service.go +++ b/internal/service/exchange/service.go @@ -5,6 +5,7 @@ import ( "strings" "time" + customerBindingSvc "github.com/break/junhong_cmp_fiber/internal/service/customer_binding" "github.com/break/junhong_cmp_fiber/internal/model" "github.com/break/junhong_cmp_fiber/internal/model/dto" "github.com/break/junhong_cmp_fiber/internal/store/postgres" @@ -26,7 +27,7 @@ type Service struct { packageUsageStore *postgres.PackageUsageStore packageUsageDailyRecordStore *postgres.PackageUsageDailyRecordStore resourceTagStore *postgres.ResourceTagStore - personalCustomerDeviceStore *postgres.PersonalCustomerDeviceStore + customerBinding *customerBindingSvc.Service logger *zap.Logger } @@ -40,7 +41,7 @@ func New( packageUsageStore *postgres.PackageUsageStore, packageUsageDailyRecordStore *postgres.PackageUsageDailyRecordStore, resourceTagStore *postgres.ResourceTagStore, - personalCustomerDeviceStore *postgres.PersonalCustomerDeviceStore, + customerBinding *customerBindingSvc.Service, logger *zap.Logger, ) *Service { return &Service{ @@ -53,7 +54,7 @@ func New( packageUsageStore: packageUsageStore, packageUsageDailyRecordStore: packageUsageDailyRecordStore, resourceTagStore: resourceTagStore, - personalCustomerDeviceStore: personalCustomerDeviceStore, + customerBinding: customerBinding, logger: logger, } } @@ -802,19 +803,8 @@ func (s *Service) validateExchangeAssetsWithTx(ctx context.Context, tx *gorm.DB, } func (s *Service) ensureNewAssetBindingAvailableWithTx(ctx context.Context, tx *gorm.DB, oldAsset, newAsset *resolvedExchangeAsset) error { - oldKey := exchangeAssetBindingKey(oldAsset) newKey := exchangeAssetBindingKey(newAsset) - oldBindCount := int64(0) - if oldKey != "" { - if err := tx.WithContext(ctx).Model(&model.PersonalCustomerDevice{}). - Where("virtual_no = ? AND status = ?", oldKey, constants.StatusEnabled). - Count(&oldBindCount).Error; err != nil { - return errors.Wrap(errors.CodeDatabaseError, err, "查询旧资产客户绑定失败") - } - } - if oldBindCount > 0 && newKey == "" { - return errors.New(errors.CodeExchangeStatusInvalid, "新资产无法承接客户绑定") - } + // 新资产无虚拟号时不再拦截:Migrate() 已能正确处理无绑定跳过、有绑定迁移到 pci 的情形 if newKey == "" { return nil } @@ -831,20 +821,7 @@ func (s *Service) ensureNewAssetBindingAvailableWithTx(ctx context.Context, tx * } func (s *Service) switchCustomerBindingWithTx(ctx context.Context, tx *gorm.DB, oldAsset, newAsset *resolvedExchangeAsset) error { - oldKey := exchangeAssetBindingKey(oldAsset) - if oldKey == "" { - return nil - } - newKey := exchangeAssetBindingKey(newAsset) - if newKey == "" { - return errors.New(errors.CodeExchangeStatusInvalid, "新资产无法承接客户绑定") - } - if err := tx.WithContext(ctx).Model(&model.PersonalCustomerDevice{}). - Where("virtual_no = ? AND status = ?", oldKey, constants.StatusEnabled). - Updates(map[string]any{"virtual_no": newKey, "updated_at": time.Now()}).Error; err != nil { - return errors.Wrap(errors.CodeDatabaseError, err, "更新客户绑定关系失败") - } - return nil + return s.customerBinding.Migrate(ctx, tx, oldAsset.AssetType, oldAsset.AssetID, newAsset.AssetType, newAsset.AssetID) } func (s *Service) updateAssetStatusesForCompletion(ctx context.Context, tx *gorm.DB, oldAsset, newAsset *resolvedExchangeAsset) error { diff --git a/internal/store/postgres/personal_customer_device_store.go b/internal/store/postgres/personal_customer_device_store.go index 6ead19e..b763548 100644 --- a/internal/store/postgres/personal_customer_device_store.go +++ b/internal/store/postgres/personal_customer_device_store.go @@ -78,6 +78,14 @@ func (s *PersonalCustomerDeviceStore) UpdateStatus(ctx context.Context, id uint, Update("status", status).Error } +// UpdateVirtualNo 更新指定记录的虚拟号(换货迁移专用) +func (s *PersonalCustomerDeviceStore) UpdateVirtualNo(ctx context.Context, id uint, newVirtualNo string) error { + return s.db.WithContext(ctx). + Model(&model.PersonalCustomerDevice{}). + Where("id = ?", id). + Update("virtual_no", newVirtualNo).Error +} + // Delete 软删除绑定记录 func (s *PersonalCustomerDeviceStore) Delete(ctx context.Context, id uint) error { return s.db.WithContext(ctx).Delete(&model.PersonalCustomerDevice{}, id).Error @@ -95,6 +103,18 @@ func (s *PersonalCustomerDeviceStore) ExistsByCustomerAndDevice(ctx context.Cont return count > 0, nil } +// CountByVirtualNo 统计指定虚拟号的绑定记录数量(不过滤 status) +func (s *PersonalCustomerDeviceStore) CountByVirtualNo(ctx context.Context, virtualNo string) (int64, error) { + var count int64 + if err := s.db.WithContext(ctx). + Model(&model.PersonalCustomerDevice{}). + Where("virtual_no = ?", virtualNo). + Count(&count).Error; err != nil { + return 0, err + } + return count, nil +} + // CreateOrUpdateLastUsed 创建或更新绑定记录的最后使用时间 // 如果绑定记录存在,更新最后使用时间;如果不存在,创建新记录 func (s *PersonalCustomerDeviceStore) CreateOrUpdateLastUsed(ctx context.Context, customerID uint, deviceNo string) error { diff --git a/internal/store/postgres/personal_customer_iccid_store.go b/internal/store/postgres/personal_customer_iccid_store.go index a03aa86..456b49e 100644 --- a/internal/store/postgres/personal_customer_iccid_store.go +++ b/internal/store/postgres/personal_customer_iccid_store.go @@ -116,6 +116,25 @@ func (s *PersonalCustomerICCIDStore) ExistsByCustomerAndICCID(ctx context.Contex return count > 0, nil } +// CountByICCID 统计指定 ICCID 的绑定记录数量(不过滤 status) +func (s *PersonalCustomerICCIDStore) CountByICCID(ctx context.Context, iccid string) (int64, error) { + var column string + switch len(iccid) { + case 19: + column = "iccid_19" + default: + column = "iccid" + } + var count int64 + if err := s.db.WithContext(ctx). + Model(&model.PersonalCustomerICCID{}). + Where(column+" = ?", iccid). + Count(&count).Error; err != nil { + return 0, err + } + return count, nil +} + // CreateOrUpdateLastUsed 创建或更新绑定记录的最后使用时间 // 如果绑定记录存在,更新最后使用时间;如果不存在,创建新记录 func (s *PersonalCustomerICCIDStore) CreateOrUpdateLastUsed(ctx context.Context, customerID uint, iccid string) error {