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)与参数校验中文提示共用实现。
306 lines
12 KiB
Go
306 lines
12 KiB
Go
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
|
||
}
|