// Package h5popup 提供 H5 风险换卡与运营弹窗的候选投放、风险地址提交与运营配置维护用例。 // // 候选查询会创建或复用个人客户通知并保持未读,即 GET 有副作用,这是产品契约的一部分: // 运营弹窗只在客户请求页面时实时匹配、不预生成通知,而投放事实又必须与「客户确实访问过」对齐。 package h5popup import ( "context" stderrors "errors" "strings" "time" "gorm.io/gorm" "github.com/break/junhong_cmp_fiber/internal/model" "github.com/break/junhong_cmp_fiber/pkg/constants" "github.com/break/junhong_cmp_fiber/pkg/errors" ) // shanghaiLocation 是每日去重键使用的上海自然日时区。 // 与 internal/query/packageexpiry 保持同一口径,避免跨自然日重投判定漂移。 var shanghaiLocation = time.FixedZone("Asia/Shanghai", 8*60*60) // AssetOwnership 校验当前个人客户是否持有指定资产的有效绑定。 // 归属判定必须使用权威实现 customer_binding.OwnsAsset:换货服务内部只查设备绑定虚拟号的判定 // 对无虚拟号卡恒为假,直接复用会让无虚拟号的广电卡永远无法自助换卡。 type AssetOwnership interface { OwnsAsset(ctx context.Context, customerID uint, assetType string, assetID uint) (bool, error) } // assetFacts 是候选匹配与风险资格判定依赖的当前资产事实。 type assetFacts struct { AssetType string AssetID uint Identifier string ShopID *uint CarrierType string DeviceType string // RiskStopped 只在卡资产上可能为真:运营商为广电且运营商扩展状态严格等于风险停机常量。 // 已销户不参与该判定,两者合并会把已销户卡一并当作风险换卡对象。 RiskStopped bool } // shanghaiDate 返回上海自然日的 yyyymmdd 文本。 func shanghaiDate(now time.Time) string { return now.In(shanghaiLocation).Format("20060102") } // invisibleAssetError 统一「资产不存在」与「资产不属于当前客户」的返回,避免形成可枚举差异。 func invisibleAssetError() error { return errors.New(errors.CodeAssetNotFound) } // isAssetNotFound 判断错误是否表示资产不存在或不可见(归属校验失败与资产不存在同态)。 func isAssetNotFound(err error) bool { var appErr *errors.AppError if stderrors.As(err, &appErr) { return appErr.Code == errors.CodeAssetNotFound } return false } // isRecordNotFound 判断错误是否为 GORM 未命中记录。 func isRecordNotFound(err error) bool { return stderrors.Is(err, gorm.ErrRecordNotFound) } // resolveAssetIdentity 按客户端提交的 identifier 定位资产:(资产类型, 资产ID)。 // 复用既有解析口径:先查全局标识注册表,再按设备与卡的既有标识回退; // 卡标识由 IotCardStore.GetByIdentifier 统一处理(virtual_no/iccid/msisdn/iccid_19/iccid_20), // 与资产详情解析保持一致,避免自实现查询漏掉 iccid_19/iccid_20 造成静默不投放。 // 未命中返回空类型,由调用方按不可见处理。 func (s *CandidateService) resolveAssetIdentity(ctx context.Context, identifier string) (string, uint, error) { record, err := s.identifiers.FindByIdentifier(ctx, identifier) if err != nil { return "", 0, errors.Wrap(errors.CodeDatabaseError, err, "查询资产标识失败") } if record != nil { return record.AssetType, record.AssetID, nil } device, err := s.devices.GetByIdentifier(ctx, identifier) if err == nil && device != nil { return constants.AssetTypeDevice, device.ID, nil } if err != nil && !isRecordNotFound(err) { return "", 0, errors.Wrap(errors.CodeDatabaseError, err, "查询设备失败") } card, err := s.cards.GetByIdentifier(ctx, identifier) if err == nil && card != nil { return constants.AssetTypeIotCard, card.ID, nil } if err != nil && !isRecordNotFound(err) { return "", 0, errors.Wrap(errors.CodeDatabaseError, err, "查询卡失败") } return "", 0, nil } // loadAssetFacts 读取候选匹配与风险资格判定所需的资产事实。 func (s *CandidateService) loadAssetFacts(ctx context.Context, assetType string, assetID uint) (*assetFacts, error) { switch assetType { case constants.AssetTypeIotCard: card, err := s.cards.GetByID(ctx, assetID) if err != nil { if isRecordNotFound(err) { return nil, invisibleAssetError() } return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询卡资产失败") } facts := &assetFacts{ AssetType: constants.AssetTypeIotCard, AssetID: card.ID, Identifier: card.ICCID, ShopID: card.ShopID, CarrierType: card.CarrierType, RiskStopped: card.CarrierType == constants.CarrierTypeCBN && strings.TrimSpace(card.GatewayExtend) == constants.GatewayCardExtendRiskStop, } deviceType, err := s.boundDeviceType(ctx, card.ID) if err != nil { return nil, err } facts.DeviceType = deviceType return facts, nil case constants.AssetTypeDevice: device, err := s.devices.GetByID(ctx, assetID) if err != nil { if isRecordNotFound(err) { return nil, invisibleAssetError() } return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询设备资产失败") } return &assetFacts{ AssetType: constants.AssetTypeDevice, AssetID: device.ID, Identifier: deviceIdentifier(device), ShopID: device.ShopID, DeviceType: device.DeviceType, }, nil default: return nil, invisibleAssetError() } } // boundDeviceType 经卡—设备绑定推导设备类型快照。 // 独立卡或未绑定设备时该维度为空;空值不匹配任何已配置范围,只有「未配置范围」表示全量。 func (s *CandidateService) boundDeviceType(ctx context.Context, cardID uint) (string, error) { var device model.Device err := s.db.WithContext(ctx). Table("tb_device AS d"). Joins("JOIN tb_device_sim_binding AS b ON b.device_id = d.id"). Where("b.iot_card_id = ? AND b.bind_status = ? AND b.deleted_at IS NULL AND d.deleted_at IS NULL", cardID, constants.BindStatusBound). Order("b.is_current DESC, b.id DESC"). Select("d.*"). Take(&device).Error if err != nil { if stderrors.Is(err, gorm.ErrRecordNotFound) { return "", nil } return "", errors.Wrap(errors.CodeDatabaseError, err, "查询卡绑定设备失败") } return device.DeviceType, nil } // deviceIdentifier 按虚拟号、IMEI、SN 的稳定优先级生成设备标识快照。 func deviceIdentifier(device *model.Device) string { if device == nil { return "" } if device.VirtualNo != "" { return device.VirtualNo } if device.IMEI != "" { return device.IMEI } return device.SN }