package h5popup import ( "context" "crypto/sha256" "encoding/hex" "strconv" "strings" "time" "github.com/bytedance/sonic" "gorm.io/gorm" notificationapp "github.com/break/junhong_cmp_fiber/internal/application/notification" "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" "github.com/break/junhong_cmp_fiber/pkg/constants" "github.com/break/junhong_cmp_fiber/pkg/errors" ) // activeShippingExchangeStatuses 是压制风险候选的物流换货单状态集合。 // 含已完成的 4:已完成物流换货单说明风险换卡已走完流程,此时必须停止新投放; // 必须同时限定 flow_type=shipping,直接换货单创建即已完成,不限定会永久压制风险候选。 var activeShippingExchangeStatuses = []int{ constants.ExchangeStatusPendingInfo, constants.ExchangeStatusPendingShip, constants.ExchangeStatusShipped, constants.ExchangeStatusCompleted, } // CandidateService 按当前资产事实投放风险换卡或运营弹窗候选。 // 查询会创建或复用通知并保持未读,即 GET 有副作用:运营弹窗只在客户请求页面时实时匹配、不预生成。 type CandidateService struct { db *gorm.DB identifiers *postgres.AssetIdentifierStore cards *postgres.IotCardStore devices *postgres.DeviceStore ownership AssetOwnership notifications notificationapp.DirectWriter now func() time.Time } // NewCandidateService 创建 H5 弹窗候选投放用例。 // 资产标识解析复用既有 Store 方法,保证口径与资产详情、换货等入口一致。 func NewCandidateService( db *gorm.DB, identifiers *postgres.AssetIdentifierStore, cards *postgres.IotCardStore, devices *postgres.DeviceStore, ownership AssetOwnership, notifications notificationapp.DirectWriter, ) *CandidateService { return &CandidateService{ db: db, identifiers: identifiers, cards: cards, devices: devices, ownership: ownership, notifications: notifications, now: time.Now, } } // GetCandidate 返回当前页面与当前资产的唯一弹窗候选;没有可投放弹窗时 candidate 为空。 // 顺序固定:先判风险换卡资格,命中则只处理风险分支;未命中再匹配运营配置。 func (s *CandidateService) GetCandidate(ctx context.Context, customerID uint, request dto.PopupCandidateRequest) (*dto.PopupCandidateResponse, error) { if customerID == 0 { return nil, errors.New(errors.CodeUnauthorized) } identifier := strings.TrimSpace(request.Identifier) if !constants.IsH5PopupPage(request.Page) || identifier == "" { return nil, errors.New(errors.CodeInvalidParam, "弹窗候选参数不合法") } if s == nil || s.db == nil || s.identifiers == nil || s.cards == nil || s.devices == nil || s.ownership == nil || s.notifications == nil { return nil, errors.New(errors.CodeServiceUnavailable, "弹窗投放能力尚未配置") } assetType, assetID, err := s.resolveAssetIdentity(ctx, identifier) if err != nil { return nil, err } if assetType == "" { return nil, invisibleAssetError() } owned, err := s.ownership.OwnsAsset(ctx, customerID, assetType, assetID) if err != nil { if isAssetNotFound(err) { return nil, invisibleAssetError() } return nil, err } if !owned { return nil, invisibleAssetError() } facts, err := s.loadAssetFacts(ctx, assetType, assetID) if err != nil { return nil, err } now := s.now().UTC() if facts.RiskStopped { blocked, err := findActiveShippingExchange(ctx, s.db, facts.AssetType, facts.AssetID) if err != nil { return nil, err } if blocked == nil { candidate, err := s.deliverRiskCandidate(ctx, customerID, facts, now) if err != nil { return nil, err } return &dto.PopupCandidateResponse{Candidate: candidate}, nil } } candidate, err := s.deliverOperationCandidate(ctx, customerID, request.Page, facts, now) if err != nil { return nil, err } return &dto.PopupCandidateResponse{Candidate: candidate}, nil } // deliverRiskCandidate 创建或复用「客户+资产+上海自然日」的风险换卡通知。 // 当日通知已存在且未读时返回同一通知;已被客户关闭(已读)时当日不再返回候选,次日条件成立会创建新通知。 func (s *CandidateService) deliverRiskCandidate(ctx context.Context, customerID uint, facts *assetFacts, now time.Time) (*dto.PopupCandidateItem, error) { notification, err := s.notifications.CreateOrGetPersonal(ctx, riskEventKey(customerID, facts, now), customerID, notificationapp.PersonalDirectRequest{ NotificationType: constants.NotificationTypeH5PopupRiskExchange, RefType: constants.NotificationRefTypeAsset, RefID: strconv.FormatUint(uint64(facts.AssetID), 10), RefKey: facts.Identifier, ExpiresAt: popupExpiresAt(now), PopupSnapshot: &model.NotificationPopupSnapshot{ AssetType: facts.AssetType, AssetID: facts.AssetID, }, }) if err != nil { return nil, err } if notification.IsRead { return nil, nil } return toCandidateItem(notification), nil } // deliverOperationCandidate 匹配运营配置并按频率创建或复用运营弹窗通知。 // 只返回优先级最高一条;同优先级取最近更新时间最新,启停同样刷新该时间。 func (s *CandidateService) deliverOperationCandidate(ctx context.Context, customerID uint, page string, facts *assetFacts, now time.Time) (*dto.PopupCandidateItem, error) { config, err := s.matchOperationConfig(ctx, page, facts, now) if err != nil { return nil, err } if config == nil { return nil, nil } notification, err := s.notifications.CreateOrGetPersonal(ctx, operationEventKey(customerID, config, now), customerID, notificationapp.PersonalDirectRequest{ NotificationType: constants.NotificationTypeH5PopupOperation, TemplateData: map[string]string{"title": config.Title, "content": config.Content}, RefType: constants.NotificationRefTypeAsset, RefID: strconv.FormatUint(uint64(facts.AssetID), 10), RefKey: facts.Identifier, ExpiresAt: popupExpiresAt(now), PopupSnapshot: &model.NotificationPopupSnapshot{ ConfigID: config.ID, ConfigVersion: config.Version, AssetType: facts.AssetType, AssetID: facts.AssetID, ActionType: config.ActionType, }, }) if err != nil { return nil, err } if notification.IsRead { return nil, nil } return toCandidateItem(notification), nil } // matchOperationConfig 按时间、启停、页面、店铺、设备类型、卡类型范围匹配运营配置。 // 范围同一维度多选取任一命中;未配置该维度即全量;已配置而资产该维度无值时该配置不命中。 func (s *CandidateService) matchOperationConfig(ctx context.Context, page string, facts *assetFacts, now time.Time) (*model.H5PopupConfiguration, error) { pageJSON, err := jsonbScalar(page) if err != nil { return nil, err } var shopID *string if facts.ShopID != nil { text := strconv.FormatUint(uint64(*facts.ShopID), 10) shopID = &text } shopJSON, err := jsonbScalarPointer(shopID) if err != nil { return nil, err } deviceJSON, err := jsonbScalar(facts.DeviceType) if err != nil { return nil, err } cardJSON, err := jsonbScalar(facts.CarrierType) if err != nil { return nil, err } var config model.H5PopupConfiguration err = s.db.WithContext(ctx).Model(&model.H5PopupConfiguration{}). Where("enabled = ?", constants.H5PopupStatusEnabled). Where("starts_at <= ? AND ends_at >= ?", now, now). Where("?::jsonb <@ pages", pageJSON). Where("(jsonb_array_length(shop_ids) = 0 OR ?::jsonb <@ shop_ids)", shopJSON). Where("(jsonb_array_length(device_types) = 0 OR ?::jsonb <@ device_types)", deviceJSON). Where("(jsonb_array_length(card_types) = 0 OR ?::jsonb <@ card_types)", cardJSON). Order("priority DESC, updated_at DESC, id DESC"). Take(&config).Error if err != nil { if isRecordNotFound(err) { return nil, nil } return nil, errors.Wrap(errors.CodeDatabaseError, err, "匹配运营弹窗配置失败") } return &config, nil } // findActiveShippingExchange 查询指定资产是否已存在活动物流换货单。 // 取 flow_type=shipping 且状态属于待填写、待发货、已发货待确认、已完成,任一命中即视为已处理。 func findActiveShippingExchange(ctx context.Context, db *gorm.DB, assetType string, assetID uint) (*model.ExchangeOrder, error) { var order model.ExchangeOrder err := db.WithContext(ctx). Where("old_asset_type = ? AND old_asset_id = ? AND flow_type = ?", assetType, assetID, constants.ExchangeFlowTypeShipping). Where("status IN ?", activeShippingExchangeStatuses). Order("id DESC"). Take(&order).Error if err != nil { if isRecordNotFound(err) { return nil, nil } return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询活动物流换货单失败") } return &order, nil } // riskEventKey 生成风险换卡通知事件键:客户 + 资产 + 上海自然日,复用通知唯一约束保证一天一条。 func riskEventKey(customerID uint, facts *assetFacts, now time.Time) string { return popupEventKey(constants.H5PopupRiskEventKeyPrefix+"."+shanghaiDate(now), strconv.FormatUint(uint64(customerID), 10), facts.AssetType, strconv.FormatUint(uint64(facts.AssetID), 10)) } // operationEventKey 生成运营弹窗通知事件键:客户 + 配置 + 版本,daily 频率再追加上海自然日。 // 频率口径按「每客户每配置支持仅一次或每天一次」,因此键内不含资产,客户换资产不会额外获得投放。 func operationEventKey(customerID uint, config *model.H5PopupConfiguration, now time.Time) string { prefix := constants.H5PopupOperationOnceEventKeyPrefix if config.Frequency == constants.H5PopupFrequencyDaily { prefix = constants.H5PopupOperationDailyEventKeyPrefix + "." + shanghaiDate(now) } return popupEventKey(prefix, strconv.FormatUint(uint64(customerID), 10), strconv.FormatUint(uint64(config.ID), 10), strconv.FormatInt(config.Version, 10)) } // popupEventKey 生成固定长度的通知事件键:前缀 + 身份摘要。 // tb_notification.event_id 为 varchar(64),身份部分用 sha256 前 12 字节十六进制压缩, // 保证资产与客户 ID 位数增长后仍不超长,同时保持确定性以便复用既有唯一约束去重。 func popupEventKey(prefix string, parts ...string) string { sum := sha256.Sum256([]byte(strings.Join(parts, "|"))) return prefix + "." + hex.EncodeToString(sum[:12]) } // popupExpiresAt 返回弹窗投放通知的展示截止时间:投放时间 + 90 天。 // 弹窗类别沿用 system(展示上限 365 天),90 天在其内,事实物理保留仍按系统类别的 365 天。 func popupExpiresAt(now time.Time) *time.Time { expiresAt := now.AddDate(0, 0, constants.H5PopupDisplayDays) return &expiresAt } // jsonbScalar 将字符串编码为可直接参与 jsonb 包含判断的 JSON 标量。 func jsonbScalar(value string) (string, error) { encoded, err := sonic.Marshal(value) if err != nil { return "", errors.Wrap(errors.CodeInternalError, err, "编码弹窗匹配值失败") } return string(encoded), nil } // jsonbScalarPointer 将可空字符串编码为 JSON 标量;nil 编码为 JSON null,任何已配置范围都不命中。 func jsonbScalarPointer(value *string) (string, error) { if value == nil { return "null", nil } return jsonbScalar(*value) } // toCandidateItem 将冻结的通知投影为客户端候选;配置标识与受控动作取通知快照 // 而不是当前配置,保证配置修改后旧通知与旧快照不被改写。 func toCandidateItem(notification *model.Notification) *dto.PopupCandidateItem { if notification == nil { return nil } item := &dto.PopupCandidateItem{ NotificationID: notification.ID, NotificationType: notification.Type, Title: notification.Title, Body: notification.Body, ExpiresAt: notification.ExpiresAt, CreatedAt: notification.CreatedAt, } if notification.Type == constants.NotificationTypeH5PopupRiskExchange { item.PopupType = constants.H5PopupCandidateTypeRiskExchange } else { item.PopupType = constants.H5PopupCandidateTypeOperation } if snapshot := notification.PopupSnapshot; snapshot != nil { item.AssetType = snapshot.AssetType item.AssetID = snapshot.AssetID item.ConfigID = snapshot.ConfigID item.ConfigVersion = snapshot.ConfigVersion item.ActionType = snapshot.ActionType } return item }