// 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 }