feat(H5弹窗): AUG26-007 风险换卡与运营弹窗投放通知
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 9m23s

新增 000225 迁移:运营弹窗配置表 tb_h5_popup_configuration(页面/范围/优先级/频率/受控动作/启停/有效期/版本)
与 tb_notification 可空 JSONB 列 popup_snapshot。

新增通知直建窄接口 DirectWriter.CreateOrGetPersonal:与 Outbox 消费共用 prepareDelivery 的渲染、
展示期与 CreateIdempotent 规则,冲突时回查返回既有行;同步扩展个人通知查询与已读两处类型白名单,
并按个人客户入口补齐投递审计来源。

新增 H5 候选与风险换卡:GET /api/c/v1/popup-candidates 先判风险资格(广电卡 + 风险停机 +
无活动物流换货单),命中只返回风险候选;未命中再按时间/启停/页面/店铺/设备类型/卡类型范围/频率
匹配运营配置。POST /api/c/v1/risk-exchanges/:asset_id/address 锁资产行后幂等创建待发货物流换货单,
首次地址锁定,不沿用资产级群发通知。

新增后台运营弹窗配置 CRUD 与启停(仅超级管理员与平台账号),更新递增版本并刷新最近更新时间,
标题与正文统一拒绝 URL 与前端路由,全部写操作记录操作者、前后值、版本与时间。

同步 OpenAPI(cmd/gendocs、cmd/api/docs.go、pkg/openapi/handlers.go)与参数校验中文提示共用实现。
This commit is contained in:
2026-09-15 15:23:52 +08:00
parent 70e680eb0a
commit 333ba4b647
39 changed files with 2861 additions and 166 deletions

View File

@@ -0,0 +1,305 @@
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
}