feat(H5弹窗): AUG26-007 风险换卡与运营弹窗投放通知
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 9m23s
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:
171
internal/application/h5popup/asset.go
Normal file
171
internal/application/h5popup/asset.go
Normal file
@@ -0,0 +1,171 @@
|
||||
// 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
|
||||
}
|
||||
305
internal/application/h5popup/candidate.go
Normal file
305
internal/application/h5popup/candidate.go
Normal 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
|
||||
}
|
||||
490
internal/application/h5popup/configuration.go
Normal file
490
internal/application/h5popup/configuration.go
Normal file
@@ -0,0 +1,490 @@
|
||||
package h5popup
|
||||
|
||||
import (
|
||||
"context"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
|
||||
systemconfigapp "github.com/break/junhong_cmp_fiber/internal/application/systemconfig"
|
||||
"github.com/break/junhong_cmp_fiber/internal/infrastructure/audit"
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
"github.com/break/junhong_cmp_fiber/internal/model/dto"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/errors"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/middleware"
|
||||
)
|
||||
|
||||
var (
|
||||
// popupURLPattern 匹配任意 URL 形态:带协议的绝对地址、www 前缀或站点域名。
|
||||
// 弹窗只允许受控动作,前端按 action_type 白名单映射页面,不接受运营配置下发跳转目标。
|
||||
popupURLPattern = regexp.MustCompile(`(?i)([a-z][a-z0-9+.\-]*://|www\.|\.(com|cn|net|org)(/|$|\s))`)
|
||||
// popupRoutePattern 匹配前端路由形态:以 / 开头的路径片段或 /#/ 哈希路由。
|
||||
popupRoutePattern = regexp.MustCompile(`(^|[\s((])/[A-Za-z#]`)
|
||||
)
|
||||
|
||||
// ConfigurationService 维护 H5 运营弹窗配置。
|
||||
// 配置只决定后续投放:更新在事务内递增版本,启停只改启停位并刷新最近更新时间,两者都记录前后值与版本。
|
||||
type ConfigurationService struct {
|
||||
db *gorm.DB
|
||||
audit *audit.Writer
|
||||
}
|
||||
|
||||
// NewConfigurationService 创建运营弹窗配置事务脚本。
|
||||
func NewConfigurationService(db *gorm.DB, audit *audit.Writer) *ConfigurationService {
|
||||
return &ConfigurationService{db: db, audit: audit}
|
||||
}
|
||||
|
||||
// configurationInput 是校验后的配置值,创建与更新共用同一套归一化规则。
|
||||
type configurationInput struct {
|
||||
Title string
|
||||
Content string
|
||||
Pages []string
|
||||
ShopIDs []uint
|
||||
DeviceTypes []string
|
||||
CardTypes []string
|
||||
Priority int
|
||||
Frequency string
|
||||
ActionType string
|
||||
Enabled int
|
||||
StartsAt time.Time
|
||||
EndsAt time.Time
|
||||
}
|
||||
|
||||
// Create 创建运营弹窗配置,初始版本为 1,并在同一事务内写入配置审计。
|
||||
func (s *ConfigurationService) Create(ctx context.Context, request dto.CreateH5PopupConfigurationRequest) (uint, error) {
|
||||
operatorID, err := requirePlatformOperator(ctx)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if err = s.ensureConfigured(); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
enabled := constants.H5PopupStatusDisabled
|
||||
if request.Enabled != nil && *request.Enabled {
|
||||
enabled = constants.H5PopupStatusEnabled
|
||||
}
|
||||
priority := 0
|
||||
if request.Priority != nil {
|
||||
priority = *request.Priority
|
||||
}
|
||||
actionType := ""
|
||||
if request.ActionType != nil {
|
||||
actionType = *request.ActionType
|
||||
}
|
||||
normalized, err := normalizeConfigurationInput(configurationInput{
|
||||
Title: request.Title, Content: request.Content, Pages: request.Pages,
|
||||
ShopIDs: request.ShopIDs, DeviceTypes: request.DeviceTypes, CardTypes: request.CardTypes,
|
||||
Priority: priority, Frequency: request.Frequency, ActionType: actionType,
|
||||
Enabled: enabled, StartsAt: request.StartsAt, EndsAt: request.EndsAt,
|
||||
})
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
record := &model.H5PopupConfiguration{
|
||||
Title: normalized.Title, Content: normalized.Content,
|
||||
Pages: model.StringJSONBArray(normalized.Pages), ShopIDs: toJSONBStrings(normalized.ShopIDs),
|
||||
DeviceTypes: model.StringJSONBArray(normalized.DeviceTypes), CardTypes: model.StringJSONBArray(normalized.CardTypes),
|
||||
Priority: normalized.Priority, Frequency: normalized.Frequency, ActionType: normalized.ActionType,
|
||||
Enabled: normalized.Enabled, StartsAt: normalized.StartsAt, EndsAt: normalized.EndsAt,
|
||||
Version: 1, BaseModel: model.BaseModel{Creator: operatorID, Updater: operatorID},
|
||||
CreatedAt: now, UpdatedAt: now,
|
||||
}
|
||||
err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.WithContext(ctx).Create(record).Error; err != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "创建运营弹窗配置失败")
|
||||
}
|
||||
return s.audit.WriteConfigChange(ctx, tx, systemconfigapp.ChangeAudit{
|
||||
OperatorID: operatorID, OperationType: constants.AuditOperationH5PopupConfigurationCreate,
|
||||
Description: "创建运营弹窗配置", ConfigKey: configurationAuditKey(record.ID),
|
||||
Module: constants.H5PopupAuditModule, ResourceID: configurationAuditResourceID(record.ID),
|
||||
DisplayName: record.Title, Identity: configurationAuditIdentity(record),
|
||||
AfterData: configurationAuditSnapshot(record), Result: constants.AuditResultSuccess,
|
||||
})
|
||||
})
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return record.ID, nil
|
||||
}
|
||||
|
||||
// Update 更新运营弹窗配置:合并入参后整体校验,事务内递增版本并刷新最近更新时间。
|
||||
// 旧版本已投放通知的内容与快照不被改写,新版本可向原命中客户按频率重新投放。
|
||||
func (s *ConfigurationService) Update(ctx context.Context, id uint, request dto.UpdateH5PopupConfigurationRequest) error {
|
||||
operatorID, err := requirePlatformOperator(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err = s.ensureConfigured(); err != nil {
|
||||
return err
|
||||
}
|
||||
if id == 0 {
|
||||
return errors.New(errors.CodeH5PopupConfigurationNotFound)
|
||||
}
|
||||
return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
record, err := lockConfiguration(ctx, tx, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
before := *record
|
||||
beforeData := configurationAuditSnapshot(&before)
|
||||
|
||||
merged := configurationInput{
|
||||
Title: record.Title, Content: record.Content, Pages: storePages(record),
|
||||
ShopIDs: storeShopIDs(record), DeviceTypes: storeDeviceTypes(record), CardTypes: storeCardTypes(record),
|
||||
Priority: record.Priority, Frequency: record.Frequency, ActionType: record.ActionType,
|
||||
Enabled: record.Enabled, StartsAt: record.StartsAt, EndsAt: record.EndsAt,
|
||||
}
|
||||
if request.Title != nil {
|
||||
merged.Title = *request.Title
|
||||
}
|
||||
if request.Content != nil {
|
||||
merged.Content = *request.Content
|
||||
}
|
||||
if request.Pages != nil {
|
||||
merged.Pages = *request.Pages
|
||||
}
|
||||
if request.ShopIDs != nil {
|
||||
merged.ShopIDs = *request.ShopIDs
|
||||
}
|
||||
if request.DeviceTypes != nil {
|
||||
merged.DeviceTypes = *request.DeviceTypes
|
||||
}
|
||||
if request.CardTypes != nil {
|
||||
merged.CardTypes = *request.CardTypes
|
||||
}
|
||||
if request.Priority != nil {
|
||||
merged.Priority = *request.Priority
|
||||
}
|
||||
if request.Frequency != nil {
|
||||
merged.Frequency = *request.Frequency
|
||||
}
|
||||
if request.ActionType != nil {
|
||||
merged.ActionType = *request.ActionType
|
||||
}
|
||||
if request.Enabled != nil {
|
||||
merged.Enabled = enabledStatus(*request.Enabled)
|
||||
}
|
||||
if request.StartsAt != nil {
|
||||
merged.StartsAt = *request.StartsAt
|
||||
}
|
||||
if request.EndsAt != nil {
|
||||
merged.EndsAt = *request.EndsAt
|
||||
}
|
||||
normalized, err := normalizeConfigurationInput(merged)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
record.Title = normalized.Title
|
||||
record.Content = normalized.Content
|
||||
record.Pages = model.StringJSONBArray(normalized.Pages)
|
||||
record.ShopIDs = toJSONBStrings(normalized.ShopIDs)
|
||||
record.DeviceTypes = model.StringJSONBArray(normalized.DeviceTypes)
|
||||
record.CardTypes = model.StringJSONBArray(normalized.CardTypes)
|
||||
record.Priority = normalized.Priority
|
||||
record.Frequency = normalized.Frequency
|
||||
record.ActionType = normalized.ActionType
|
||||
record.Enabled = normalized.Enabled
|
||||
record.StartsAt = normalized.StartsAt
|
||||
record.EndsAt = normalized.EndsAt
|
||||
record.Version++
|
||||
record.Updater = operatorID
|
||||
record.UpdatedAt = now
|
||||
if err := tx.WithContext(ctx).Save(record).Error; err != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "更新运营弹窗配置失败")
|
||||
}
|
||||
if err := s.audit.WriteConfigChange(ctx, tx, systemconfigapp.ChangeAudit{
|
||||
OperatorID: operatorID, OperationType: constants.AuditOperationH5PopupConfigurationUpdate,
|
||||
Description: "更新运营弹窗配置", ConfigKey: configurationAuditKey(record.ID),
|
||||
Module: constants.H5PopupAuditModule, ResourceID: configurationAuditResourceID(record.ID),
|
||||
DisplayName: record.Title, Identity: configurationAuditIdentity(record),
|
||||
BeforeData: beforeData, AfterData: configurationAuditSnapshot(record), Result: constants.AuditResultSuccess,
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// SetEnabled 启停运营弹窗配置,只影响后续候选,并必须刷新最近更新时间。
|
||||
// 启停不递增版本:版本表达配置内容变化,频率去重键因此保持不变,已投放通知不会被再次投放。
|
||||
func (s *ConfigurationService) SetEnabled(ctx context.Context, id uint, enabled bool) error {
|
||||
operatorID, err := requirePlatformOperator(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err = s.ensureConfigured(); err != nil {
|
||||
return err
|
||||
}
|
||||
if id == 0 {
|
||||
return errors.New(errors.CodeH5PopupConfigurationNotFound)
|
||||
}
|
||||
operationType := constants.AuditOperationH5PopupConfigurationDisable
|
||||
description := "停用运营弹窗配置"
|
||||
if enabled {
|
||||
operationType = constants.AuditOperationH5PopupConfigurationEnable
|
||||
description = "启用运营弹窗配置"
|
||||
}
|
||||
return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
record, err := lockConfiguration(ctx, tx, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
beforeData := configurationAuditSnapshot(record)
|
||||
now := time.Now().UTC()
|
||||
record.Enabled = enabledStatus(enabled)
|
||||
record.Updater = operatorID
|
||||
record.UpdatedAt = now
|
||||
if err := tx.WithContext(ctx).Save(record).Error; err != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "更新运营弹窗配置启停失败")
|
||||
}
|
||||
if err := s.audit.WriteConfigChange(ctx, tx, systemconfigapp.ChangeAudit{
|
||||
OperatorID: operatorID, OperationType: operationType,
|
||||
Description: description, ConfigKey: configurationAuditKey(record.ID),
|
||||
Module: constants.H5PopupAuditModule, ResourceID: configurationAuditResourceID(record.ID),
|
||||
DisplayName: record.Title, Identity: configurationAuditIdentity(record),
|
||||
BeforeData: beforeData, AfterData: configurationAuditSnapshot(record), Result: constants.AuditResultSuccess,
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func (s *ConfigurationService) ensureConfigured() error {
|
||||
if s == nil || s.db == nil || s.audit == nil {
|
||||
return errors.New(errors.CodeServiceUnavailable, "运营弹窗配置维护能力尚未配置")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// requirePlatformOperator 校验当前调用者仅限超级管理员与平台账号,并返回其账号 ID。
|
||||
// 非上述身份与资源不存在返回同一禁止访问错误,避免形成可枚举差异。
|
||||
func requirePlatformOperator(ctx context.Context) (uint, error) {
|
||||
userType := middleware.GetUserTypeFromContext(ctx)
|
||||
if userType != constants.UserTypeSuperAdmin && userType != constants.UserTypePlatform {
|
||||
return 0, errors.New(errors.CodeForbidden, "无权限操作该资源或资源不存在")
|
||||
}
|
||||
operatorID := middleware.GetUserIDFromContext(ctx)
|
||||
if operatorID == 0 {
|
||||
return 0, errors.New(errors.CodeUnauthorized)
|
||||
}
|
||||
return operatorID, nil
|
||||
}
|
||||
|
||||
// lockConfiguration 以行锁读取运营弹窗配置,未找到返回稳定不存在错误。
|
||||
func lockConfiguration(ctx context.Context, tx *gorm.DB, id uint) (*model.H5PopupConfiguration, error) {
|
||||
var record model.H5PopupConfiguration
|
||||
err := tx.WithContext(ctx).Clauses(clause.Locking{Strength: "UPDATE"}).Where("id = ?", id).Take(&record).Error
|
||||
if err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return nil, errors.New(errors.CodeH5PopupConfigurationNotFound)
|
||||
}
|
||||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询运营弹窗配置失败")
|
||||
}
|
||||
return &record, nil
|
||||
}
|
||||
|
||||
// normalizeConfigurationInput 归一化并校验配置,创建与更新共用同一套规则。
|
||||
// 拒绝任意 URL 与前端路由是应用层第一道保险,通知渲染的 URL 拦截是第二道。
|
||||
func normalizeConfigurationInput(input configurationInput) (configurationInput, error) {
|
||||
normalized := input
|
||||
normalized.Title = strings.TrimSpace(input.Title)
|
||||
if runes := utf8.RuneCountInString(normalized.Title); runes < 1 || runes > 100 {
|
||||
return configurationInput{}, errors.New(errors.CodeInvalidParam, "运营弹窗标题长度必须在 1~100 字符之间")
|
||||
}
|
||||
// 标题与正文同一口径:两者都会冻结进通知并参与渲染,任一都不接受 URL 或前端路由。
|
||||
if popupURLPattern.MatchString(normalized.Title) || popupRoutePattern.MatchString(normalized.Title) {
|
||||
return configurationInput{}, errors.New(errors.CodeInvalidParam, "运营弹窗标题不接受 URL 或前端路由,只能使用受控动作")
|
||||
}
|
||||
normalized.Content = strings.TrimSpace(input.Content)
|
||||
if runes := utf8.RuneCountInString(normalized.Content); runes < 1 || runes > 2000 {
|
||||
return configurationInput{}, errors.New(errors.CodeInvalidParam, "运营弹窗正文长度必须在 1~2000 字符之间")
|
||||
}
|
||||
if popupURLPattern.MatchString(normalized.Content) || popupRoutePattern.MatchString(normalized.Content) {
|
||||
return configurationInput{}, errors.New(errors.CodeInvalidParam, "运营弹窗正文不接受 URL 或前端路由,只能使用受控动作")
|
||||
}
|
||||
normalized.Pages = dedupeStrings(input.Pages)
|
||||
if len(normalized.Pages) == 0 {
|
||||
return configurationInput{}, errors.New(errors.CodeInvalidParam, "运营弹窗至少需要一个命中页面")
|
||||
}
|
||||
if len(normalized.Pages) > 4 {
|
||||
return configurationInput{}, errors.New(errors.CodeInvalidParam, "运营弹窗命中页面超出受控范围")
|
||||
}
|
||||
for _, page := range normalized.Pages {
|
||||
if !constants.IsH5PopupPage(page) {
|
||||
return configurationInput{}, errors.New(errors.CodeInvalidParam, "运营弹窗命中页面不在受控白名单内")
|
||||
}
|
||||
}
|
||||
normalized.ShopIDs = dedupeShopIDs(input.ShopIDs)
|
||||
if len(normalized.ShopIDs) > 200 {
|
||||
return configurationInput{}, errors.New(errors.CodeInvalidParam, "运营弹窗店铺范围超过 200 项")
|
||||
}
|
||||
normalized.DeviceTypes = dedupeStrings(input.DeviceTypes)
|
||||
if len(normalized.DeviceTypes) > 100 {
|
||||
return configurationInput{}, errors.New(errors.CodeInvalidParam, "运营弹窗设备类型范围超过 100 项")
|
||||
}
|
||||
for _, deviceType := range normalized.DeviceTypes {
|
||||
if utf8.RuneCountInString(deviceType) > 50 {
|
||||
return configurationInput{}, errors.New(errors.CodeInvalidParam, "运营弹窗设备类型超过 50 字符")
|
||||
}
|
||||
}
|
||||
// 卡类型是与 tb_iot_card.carrier_type 直接比较的受控枚举,统一大写后再校验。
|
||||
normalized.CardTypes = dedupeStrings(upperStrings(input.CardTypes))
|
||||
if len(normalized.CardTypes) > 4 {
|
||||
return configurationInput{}, errors.New(errors.CodeInvalidParam, "运营弹窗卡类型范围超过受控取值数量")
|
||||
}
|
||||
for _, cardType := range normalized.CardTypes {
|
||||
if !constants.IsCarrierType(cardType) {
|
||||
return configurationInput{}, errors.New(errors.CodeInvalidParam, "运营弹窗卡类型不在受控白名单内")
|
||||
}
|
||||
}
|
||||
if normalized.Priority < 0 || normalized.Priority > 1000000 {
|
||||
return configurationInput{}, errors.New(errors.CodeInvalidParam, "运营弹窗优先级必须在 0~1000000 之间")
|
||||
}
|
||||
if !constants.IsH5PopupFrequency(normalized.Frequency) {
|
||||
return configurationInput{}, errors.New(errors.CodeInvalidParam, "运营弹窗投放频率不在受控白名单内")
|
||||
}
|
||||
if !constants.IsH5PopupActionType(normalized.ActionType) {
|
||||
return configurationInput{}, errors.New(errors.CodeInvalidParam, "运营弹窗受控动作不在受控白名单内")
|
||||
}
|
||||
if normalized.Enabled != constants.H5PopupStatusEnabled && normalized.Enabled != constants.H5PopupStatusDisabled {
|
||||
return configurationInput{}, errors.New(errors.CodeInvalidParam, "运营弹窗启停状态不合法")
|
||||
}
|
||||
if normalized.StartsAt.IsZero() || normalized.EndsAt.IsZero() {
|
||||
return configurationInput{}, errors.New(errors.CodeInvalidParam, "运营弹窗必须同时提供生效开始与结束时间")
|
||||
}
|
||||
normalized.StartsAt = normalized.StartsAt.UTC()
|
||||
normalized.EndsAt = normalized.EndsAt.UTC()
|
||||
if normalized.EndsAt.Before(normalized.StartsAt) {
|
||||
return configurationInput{}, errors.New(errors.CodeInvalidParam, "运营弹窗结束时间不得早于开始时间")
|
||||
}
|
||||
return normalized, nil
|
||||
}
|
||||
|
||||
// enabledStatus 把布尔启停转换为 0/1 状态。
|
||||
func enabledStatus(enabled bool) int {
|
||||
if enabled {
|
||||
return constants.H5PopupStatusEnabled
|
||||
}
|
||||
return constants.H5PopupStatusDisabled
|
||||
}
|
||||
|
||||
// dedupeStrings 去空白并按出现顺序去重,保留原始大小写。
|
||||
func dedupeStrings(values []string) []string {
|
||||
result := make([]string, 0, len(values))
|
||||
seen := make(map[string]struct{}, len(values))
|
||||
for _, value := range values {
|
||||
trimmed := strings.TrimSpace(value)
|
||||
if trimmed == "" {
|
||||
continue
|
||||
}
|
||||
if _, exists := seen[trimmed]; exists {
|
||||
continue
|
||||
}
|
||||
seen[trimmed] = struct{}{}
|
||||
result = append(result, trimmed)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// upperStrings 去空白并统一大写,供受控枚举范围使用;空白项不保留。
|
||||
func upperStrings(values []string) []string {
|
||||
result := make([]string, 0, len(values))
|
||||
for _, value := range values {
|
||||
trimmed := strings.TrimSpace(value)
|
||||
if trimmed == "" {
|
||||
continue
|
||||
}
|
||||
result = append(result, strings.ToUpper(trimmed))
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// dedupeShopIDs 去重店铺 ID 并丢弃非法值。
|
||||
func dedupeShopIDs(values []uint) []uint {
|
||||
result := make([]uint, 0, len(values))
|
||||
seen := make(map[uint]struct{}, len(values))
|
||||
for _, value := range values {
|
||||
if value == 0 {
|
||||
continue
|
||||
}
|
||||
if _, exists := seen[value]; exists {
|
||||
continue
|
||||
}
|
||||
seen[value] = struct{}{}
|
||||
result = append(result, value)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// toJSONBStrings 将店铺 ID 编码为 JSONB 文本数组,与范围匹配的文本比较口径一致。
|
||||
func toJSONBStrings(values []uint) model.StringJSONBArray {
|
||||
encoded := make(model.StringJSONBArray, 0, len(values))
|
||||
for _, value := range values {
|
||||
encoded = append(encoded, strconv.FormatUint(uint64(value), 10))
|
||||
}
|
||||
return encoded
|
||||
}
|
||||
|
||||
func storePages(record *model.H5PopupConfiguration) []string {
|
||||
return append([]string{}, record.Pages...)
|
||||
}
|
||||
|
||||
func storeDeviceTypes(record *model.H5PopupConfiguration) []string {
|
||||
return append([]string{}, record.DeviceTypes...)
|
||||
}
|
||||
|
||||
func storeCardTypes(record *model.H5PopupConfiguration) []string {
|
||||
return append([]string{}, record.CardTypes...)
|
||||
}
|
||||
|
||||
// storeShopIDs 将 JSONB 店铺范围还原为 ID 列表用于合并更新。
|
||||
func storeShopIDs(record *model.H5PopupConfiguration) []uint {
|
||||
shopIDs := make([]uint, 0, len(record.ShopIDs))
|
||||
for _, value := range record.ShopIDs {
|
||||
parsed, err := strconv.ParseUint(value, 10, 64)
|
||||
if err != nil || parsed == 0 {
|
||||
continue
|
||||
}
|
||||
shopIDs = append(shopIDs, uint(parsed))
|
||||
}
|
||||
return shopIDs
|
||||
}
|
||||
|
||||
func configurationAuditKey(id uint) string {
|
||||
return constants.H5PopupAuditConfigKeyPrefix + "." + strconv.FormatUint(uint64(id), 10)
|
||||
}
|
||||
|
||||
func configurationAuditResourceID(id uint) *string {
|
||||
value := strconv.FormatUint(uint64(id), 10)
|
||||
return &value
|
||||
}
|
||||
|
||||
// configurationAuditIdentity 生成配置身份快照,不含正文内容。
|
||||
func configurationAuditIdentity(record *model.H5PopupConfiguration) map[string]any {
|
||||
return map[string]any{
|
||||
"id": record.ID, "title": record.Title, "pages": storePages(record),
|
||||
"priority": record.Priority, "frequency": record.Frequency, "action_type": record.ActionType,
|
||||
"enabled": record.Enabled, "version": record.Version,
|
||||
}
|
||||
}
|
||||
|
||||
// configurationAuditSnapshot 生成配置审计前后值快照,覆盖范围、优先级、频率、受控动作、启停、有效期与版本。
|
||||
func configurationAuditSnapshot(record *model.H5PopupConfiguration) map[string]any {
|
||||
return map[string]any{
|
||||
"id": record.ID, "title": record.Title, "content": record.Content,
|
||||
"pages": storePages(record), "shop_ids": storeShopIDs(record),
|
||||
"device_types": storeDeviceTypes(record), "card_types": storeCardTypes(record),
|
||||
"priority": record.Priority, "frequency": record.Frequency, "action_type": record.ActionType,
|
||||
"enabled": record.Enabled, "starts_at": record.StartsAt, "ends_at": record.EndsAt,
|
||||
"version": record.Version, "updated_at": record.UpdatedAt,
|
||||
}
|
||||
}
|
||||
170
internal/application/h5popup/risk_exchange.go
Normal file
170
internal/application/h5popup/risk_exchange.go
Normal file
@@ -0,0 +1,170 @@
|
||||
package h5popup
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/internal/infrastructure/audit"
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
"github.com/break/junhong_cmp_fiber/internal/model/dto"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/errors"
|
||||
)
|
||||
|
||||
// RiskExchangeService 处理个人客户自助风险换卡的地址提交。
|
||||
// 幂等靠「锁定旧资产行 + 去重查询既有活动物流换货单」实现,不引入数据库唯一约束:
|
||||
// 资产实例同一时刻只属于一个客户,锁资产行即可覆盖重复提交与并发提交。
|
||||
type RiskExchangeService struct {
|
||||
db *gorm.DB
|
||||
ownership AssetOwnership
|
||||
auditWriter *audit.Writer
|
||||
}
|
||||
|
||||
// NewRiskExchangeService 创建风险换卡地址提交事务脚本。
|
||||
func NewRiskExchangeService(db *gorm.DB, ownership AssetOwnership, auditWriter *audit.Writer) *RiskExchangeService {
|
||||
return &RiskExchangeService{db: db, ownership: ownership, auditWriter: auditWriter}
|
||||
}
|
||||
|
||||
// Submit 幂等提交风险换卡收货地址,创建关联旧资产的物流换货单。
|
||||
// 事务内顺序固定为:锁旧资产行 → 复核风险资格 → 去重查询 → 未命中才插入。
|
||||
// 重复提交返回首次创建的换货单与首次地址,不覆盖既有地址。
|
||||
func (s *RiskExchangeService) Submit(ctx context.Context, customerID, assetID uint, request dto.ClientRiskExchangeAddressParams) (*dto.ClientRiskExchangeResponse, error) {
|
||||
if customerID == 0 {
|
||||
return nil, errors.New(errors.CodeUnauthorized)
|
||||
}
|
||||
if assetID == 0 {
|
||||
return nil, errors.New(errors.CodeInvalidParam, "风险换卡资产ID不合法")
|
||||
}
|
||||
if s == nil || s.db == nil || s.ownership == nil || s.auditWriter == nil {
|
||||
return nil, errors.New(errors.CodeServiceUnavailable, "风险换卡能力尚未配置")
|
||||
}
|
||||
// 归属校验必须使用权威实现;资产不存在与归属失败返回同态不可见结果。
|
||||
owned, err := s.ownership.OwnsAsset(ctx, customerID, constants.AssetTypeIotCard, assetID)
|
||||
if err != nil {
|
||||
if isAssetNotFound(err) {
|
||||
return nil, invisibleAssetError()
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
if !owned {
|
||||
return nil, invisibleAssetError()
|
||||
}
|
||||
|
||||
var result *dto.ClientRiskExchangeResponse
|
||||
err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
var card model.IotCard
|
||||
if err := tx.WithContext(ctx).Clauses(clause.Locking{Strength: "UPDATE"}).
|
||||
Where("id = ?", assetID).Take(&card).Error; err != nil {
|
||||
if isRecordNotFound(err) {
|
||||
return invisibleAssetError()
|
||||
}
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "锁定换卡资产失败")
|
||||
}
|
||||
// 锁内复核风险资格:持锁前的判定可能已被并发状态同步改变。
|
||||
if card.CarrierType != constants.CarrierTypeCBN ||
|
||||
strings.TrimSpace(card.GatewayExtend) != constants.GatewayCardExtendRiskStop {
|
||||
return errors.New(errors.CodeH5PopupRiskNotEligible)
|
||||
}
|
||||
existing, err := findActiveShippingExchange(ctx, tx, constants.AssetTypeIotCard, card.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if existing != nil {
|
||||
result = toRiskExchangeResponse(existing)
|
||||
return nil
|
||||
}
|
||||
order := &model.ExchangeOrder{
|
||||
ExchangeNo: model.GenerateExchangeNo(),
|
||||
FlowType: constants.ExchangeFlowTypeShipping,
|
||||
OldAssetType: constants.AssetTypeIotCard,
|
||||
OldAssetID: card.ID,
|
||||
OldAssetIdentifier: card.ICCID,
|
||||
RecipientName: request.RecipientName,
|
||||
RecipientPhone: request.RecipientPhone,
|
||||
RecipientAddress: request.RecipientAddress,
|
||||
ShopID: card.ShopID,
|
||||
ExchangeReason: constants.H5PopupRiskExchangeReason,
|
||||
// 客户已提交收货信息,因此创建即待发货;不预设业务数据迁移,发货选新资产时仍由后台按既有流程决定。
|
||||
Status: constants.ExchangeStatusPendingShip,
|
||||
MigrateData: false,
|
||||
MigrationStatus: constants.ExchangeMigrationStatusNotMigrated,
|
||||
// H5 客户上下文没有后台账号 ID,置 0 表示由客户自助发起,不冒用任何后台账号身份。
|
||||
BaseModel: model.BaseModel{Creator: 0, Updater: 0},
|
||||
}
|
||||
if err := tx.WithContext(ctx).Create(order).Error; err != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "创建风险换卡单失败")
|
||||
}
|
||||
result = toRiskExchangeResponse(order)
|
||||
return s.appendRiskExchangeAudit(ctx, tx, customerID, order, &card)
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// appendRiskExchangeAudit 在同一事务内记录客户自助换卡的状态事实与旧卡引用。
|
||||
func (s *RiskExchangeService) appendRiskExchangeAudit(ctx context.Context, tx *gorm.DB, customerID uint, order *model.ExchangeOrder, card *model.IotCard) error {
|
||||
orderID := strconv.FormatUint(uint64(order.ID), 10)
|
||||
cardID := strconv.FormatUint(uint64(card.ID), 10)
|
||||
customerText := strconv.FormatUint(uint64(customerID), 10)
|
||||
summary := "客户自助提交风险换卡地址"
|
||||
return s.auditWriter.Append(ctx, tx, audit.AppendInput{
|
||||
ActionCode: constants.AuditActionCardRiskExchangeRequested, Summary: summary,
|
||||
Actor: audit.ActorInput{Kind: constants.AuditActorPersonalCustomer, ID: customerText},
|
||||
Source: constants.AuditSourcePersonalAPI,
|
||||
// 个人客户本人业务范围;不使用 platform,避免把客户自助事实记成后台操作。
|
||||
ScopeType: constants.AuditScopePersonalCustomer, ScopeID: customerText,
|
||||
Result: constants.AuditResultSuccess,
|
||||
Metadata: map[string]any{"flow_type": constants.ExchangeFlowTypeShipping, "migrate_data": false},
|
||||
Resources: []audit.ResourceInput{
|
||||
{
|
||||
Type: constants.AuditResourceExchangeOrder, ID: &orderID, Key: order.ExchangeNo, DisplayName: order.ExchangeNo,
|
||||
Relation: constants.AuditResourceRelationPrimary, Role: constants.AuditResourceRoleCardExchangeOrder,
|
||||
IdentitySnapshot: map[string]any{
|
||||
"id": order.ID, "exchange_no": order.ExchangeNo, "flow_type": order.FlowType,
|
||||
"old_asset_type": order.OldAssetType, "old_asset_id": order.OldAssetID,
|
||||
"old_asset_identifier": order.OldAssetIdentifier, "shop_id": order.ShopID, "status": order.Status,
|
||||
},
|
||||
AfterData: map[string]any{
|
||||
"status": order.Status, "migrate_data": order.MigrateData, "migration_status": order.MigrationStatus,
|
||||
},
|
||||
SubjectVisibility: constants.AuditSubjectResult, SubjectSummary: summary,
|
||||
},
|
||||
{
|
||||
Type: constants.AuditResourceIotCard, ID: &cardID, Key: audit.IotCardResourceKey(card), DisplayName: card.ICCID,
|
||||
Relation: constants.AuditResourceRelationReference, Role: constants.AuditResourceRoleCardExchangeOldCard,
|
||||
IdentitySnapshot: audit.IotCardIdentitySnapshot(card),
|
||||
SubjectVisibility: constants.AuditSubjectResult, SubjectSummary: summary,
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// toRiskExchangeResponse 将换货单投影为地址提交结果。
|
||||
// 地址取记录中的既有值:重复提交返回首次地址,不做任何覆盖。
|
||||
func toRiskExchangeResponse(order *model.ExchangeOrder) *dto.ClientRiskExchangeResponse {
|
||||
if order == nil {
|
||||
return nil
|
||||
}
|
||||
return &dto.ClientRiskExchangeResponse{
|
||||
ID: order.ID, ExchangeNo: order.ExchangeNo,
|
||||
Status: order.Status, StatusName: constants.GetExchangeStatusName(order.Status),
|
||||
FlowType: order.FlowType,
|
||||
OldAssetType: order.OldAssetType,
|
||||
OldAssetID: order.OldAssetID,
|
||||
OldAssetIdentifier: order.OldAssetIdentifier,
|
||||
RecipientName: order.RecipientName,
|
||||
RecipientPhone: order.RecipientPhone,
|
||||
RecipientAddress: order.RecipientAddress,
|
||||
MigrateData: order.MigrateData,
|
||||
MigrationStatus: order.MigrationStatus,
|
||||
MigrationStatusName: constants.GetExchangeMigrationStatusName(order.MigrationStatus),
|
||||
ExchangeReason: order.ExchangeReason,
|
||||
CreatedAt: order.CreatedAt,
|
||||
}
|
||||
}
|
||||
@@ -51,6 +51,7 @@ type deliveryRequest struct {
|
||||
refID string
|
||||
refKey string
|
||||
expiresAt *time.Time
|
||||
popupSnapshot *model.NotificationPopupSnapshot
|
||||
}
|
||||
|
||||
// DeliveryService 校验接收人并幂等生成站内通知。
|
||||
@@ -123,6 +124,7 @@ func (s *DeliveryService) consumeDynamic(ctx context.Context, envelope outbox.De
|
||||
notificationType: payload.NotificationType, templateData: payload.TemplateData,
|
||||
refType: payload.RefType, refID: payload.RefID, refKey: payload.RefKey, expiresAt: payload.ExpiresAt,
|
||||
}
|
||||
// 载荷校验必须先于接收人解析:无效事件不应触发接收人查询。
|
||||
if err := validateDeliveryRequest(request); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -153,19 +155,45 @@ func validateDeliveryRequest(request deliveryRequest) error {
|
||||
if request.refType != "" && request.refID == "" && request.refKey == "" {
|
||||
return errors.New(errors.CodeInvalidParam, "通知资源引用缺少定位值")
|
||||
}
|
||||
// 投放快照与弹窗类型必须成对出现:非弹窗类型不得写快照,弹窗类型不得缺少快照。
|
||||
isPopup := constants.IsH5PopupNotificationType(request.notificationType)
|
||||
if request.popupSnapshot != nil && !isPopup {
|
||||
return errors.New(errors.CodeInvalidParam, "投放快照只允许用于弹窗通知类型")
|
||||
}
|
||||
if isPopup && request.popupSnapshot == nil {
|
||||
return errors.New(errors.CodeInvalidParam, "弹窗通知缺少投放快照")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *DeliveryService) deliver(ctx context.Context, eventID, recipientKind string, recipientIDs []uint, request deliveryRequest) error {
|
||||
// preparedDelivery 是一次事件共享的渲染结果、展示期与审计来源,与接收人数量无关。
|
||||
type preparedDelivery struct {
|
||||
rendered notificationinfra.Rendered
|
||||
now time.Time
|
||||
expiresAt *time.Time
|
||||
origin deliveryOrigin
|
||||
}
|
||||
|
||||
// deliveryOrigin 是投递审计的操作者与入口。
|
||||
// Outbox 消费路径留空,由统一审计从任务上下文补齐(与既有 worker 入口一致);
|
||||
// API 直投路径必须显式提供,因为个人客户请求上下文不携带审计上下文。
|
||||
type deliveryOrigin struct {
|
||||
actor audit.ActorInput
|
||||
source string
|
||||
}
|
||||
|
||||
// prepareDelivery 渲染模板并计算展示期;同一事件只计算一次,不随接收人重复计算。
|
||||
// 审计接缝缺失在此一次性判空:与既有行为一致,渲染之前就失败,而不是按接收人重复判断。
|
||||
func (s *DeliveryService) prepareDelivery(eventID, recipientKind string, request deliveryRequest, origin deliveryOrigin) (*preparedDelivery, error) {
|
||||
if s.auditWriter == nil {
|
||||
return errors.New(errors.CodeInvalidStatus, "通知统一审计接缝未配置")
|
||||
return nil, errors.New(errors.CodeInvalidStatus, "通知统一审计接缝未配置")
|
||||
}
|
||||
rendered, err := s.registry.Render(request.notificationType, request.templateData, request.refType, recipientKind)
|
||||
if err != nil {
|
||||
s.logger.Error("站内通知模板校验失败",
|
||||
zap.String("event_id", eventID), zap.String("notification_type", request.notificationType),
|
||||
zap.String("failure_category", "template"))
|
||||
return errors.Wrap(errors.CodeInvalidParam, err, "站内通知模板校验失败")
|
||||
return nil, errors.Wrap(errors.CodeInvalidParam, err, "站内通知模板校验失败")
|
||||
}
|
||||
now := s.now().UTC()
|
||||
expiresAt, err := notificationDisplayExpiry(rendered.Category, request.expiresAt, now)
|
||||
@@ -173,46 +201,23 @@ func (s *DeliveryService) deliver(ctx context.Context, eventID, recipientKind st
|
||||
s.logger.Error("站内通知展示期限校验失败",
|
||||
zap.String("event_id", eventID), zap.String("notification_type", request.notificationType),
|
||||
zap.String("failure_category", "display_policy"))
|
||||
return nil, err
|
||||
}
|
||||
return &preparedDelivery{rendered: rendered, now: now, expiresAt: expiresAt, origin: origin}, nil
|
||||
}
|
||||
|
||||
// deliver 对每个接收人执行同一套单接收人投放规则;接收人不可用时跳过,不影响其他接收人。
|
||||
func (s *DeliveryService) deliver(ctx context.Context, eventID, recipientKind string, recipientIDs []uint, request deliveryRequest) error {
|
||||
prepared, err := s.prepareDelivery(eventID, recipientKind, request, deliveryOrigin{})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, recipientID := range recipientIDs {
|
||||
active, err := s.isActiveRecipient(ctx, recipientKind, recipientID)
|
||||
notification, created, err := s.deliverOne(ctx, eventID, recipientKind, recipientID, request, prepared)
|
||||
if err != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "校验通知接收人失败")
|
||||
return err
|
||||
}
|
||||
if !active {
|
||||
s.logger.Info("站内通知接收人不可用,已跳过",
|
||||
zap.String("event_id", eventID), zap.String("recipient_kind", recipientKind), zap.Uint("recipient_id", recipientID))
|
||||
continue
|
||||
}
|
||||
notification := &model.Notification{
|
||||
EventID: eventID, RecipientKind: recipientKind,
|
||||
RecipientID: recipientID, Category: rendered.Category, Type: rendered.Type,
|
||||
Severity: rendered.Severity, Title: rendered.Title, Body: rendered.Body,
|
||||
RefType: request.refType, RefID: request.refID, RefKey: request.refKey,
|
||||
ExpiresAt: expiresAt, CreatedAt: now,
|
||||
}
|
||||
created := false
|
||||
err = s.repository.DB().WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
var createErr error
|
||||
created, createErr = s.repository.WithTx(tx).CreateIdempotent(ctx, notification)
|
||||
if createErr != nil || !created {
|
||||
return createErr
|
||||
}
|
||||
return s.auditWriter.Append(ctx, tx, audit.AppendInput{
|
||||
EventID: audit.TaskEventID(constants.AuditResourceNotification, notification.ID, "delivered"),
|
||||
ActionCode: constants.AuditActionNotificationDelivered, Summary: "生成站内通知",
|
||||
ScopeType: constants.AuditScopePlatform, Result: constants.AuditResultSuccess,
|
||||
Metadata: map[string]any{"outbox_event_id": eventID},
|
||||
Resources: []audit.ResourceInput{audit.NotificationResource(notification,
|
||||
constants.AuditResourceRelationPrimary, constants.AuditResourceRoleNotificationTarget,
|
||||
nil, map[string]any{"created": true, "is_read": false})},
|
||||
})
|
||||
})
|
||||
if err != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "写入站内通知失败")
|
||||
}
|
||||
if !created {
|
||||
if notification != nil && !created {
|
||||
s.logger.Info("站内通知重复事件已幂等忽略",
|
||||
zap.String("event_id", eventID), zap.String("recipient_kind", recipientKind), zap.Uint("recipient_id", recipientID))
|
||||
}
|
||||
@@ -220,6 +225,60 @@ func (s *DeliveryService) deliver(ctx context.Context, eventID, recipientKind st
|
||||
return nil
|
||||
}
|
||||
|
||||
// deliverOne 校验接收人并在单事务内幂等写入一条通知。
|
||||
// 事件键与接收人已存在时不重复投放,回查并返回既有行(created=false);接收人不可用时返回 (nil, false, nil)。
|
||||
// Outbox 消费与候选查询直投共用本方法,落库规则只有一处。
|
||||
func (s *DeliveryService) deliverOne(ctx context.Context, eventID, recipientKind string, recipientID uint, request deliveryRequest, prepared *preparedDelivery) (*model.Notification, bool, error) {
|
||||
if eventID == "" || recipientID == 0 || prepared == nil {
|
||||
return nil, false, errors.New(errors.CodeInvalidParam, "通知事件或接收人不完整")
|
||||
}
|
||||
active, err := s.isActiveRecipient(ctx, recipientKind, recipientID)
|
||||
if err != nil {
|
||||
return nil, false, errors.Wrap(errors.CodeDatabaseError, err, "校验通知接收人失败")
|
||||
}
|
||||
if !active {
|
||||
s.logger.Info("站内通知接收人不可用,已跳过",
|
||||
zap.String("event_id", eventID), zap.String("recipient_kind", recipientKind), zap.Uint("recipient_id", recipientID))
|
||||
return nil, false, nil
|
||||
}
|
||||
notification := &model.Notification{
|
||||
EventID: eventID, RecipientKind: recipientKind,
|
||||
RecipientID: recipientID, Category: prepared.rendered.Category, Type: prepared.rendered.Type,
|
||||
Severity: prepared.rendered.Severity, Title: prepared.rendered.Title, Body: prepared.rendered.Body,
|
||||
RefType: request.refType, RefID: request.refID, RefKey: request.refKey,
|
||||
ExpiresAt: prepared.expiresAt, CreatedAt: prepared.now, PopupSnapshot: request.popupSnapshot,
|
||||
}
|
||||
created := false
|
||||
err = s.repository.DB().WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
var createErr error
|
||||
created, createErr = s.repository.WithTx(tx).CreateIdempotent(ctx, notification)
|
||||
if createErr != nil || !created {
|
||||
return createErr
|
||||
}
|
||||
return s.auditWriter.Append(ctx, tx, audit.AppendInput{
|
||||
EventID: audit.TaskEventID(constants.AuditResourceNotification, notification.ID, "delivered"),
|
||||
ActionCode: constants.AuditActionNotificationDelivered, Summary: "生成站内通知",
|
||||
Actor: prepared.origin.actor, Source: prepared.origin.source,
|
||||
ScopeType: constants.AuditScopePlatform, Result: constants.AuditResultSuccess,
|
||||
Metadata: map[string]any{"outbox_event_id": eventID},
|
||||
Resources: []audit.ResourceInput{audit.NotificationResource(notification,
|
||||
constants.AuditResourceRelationPrimary, constants.AuditResourceRoleNotificationTarget,
|
||||
nil, map[string]any{"created": true, "is_read": false})},
|
||||
})
|
||||
})
|
||||
if err != nil {
|
||||
return nil, false, errors.Wrap(errors.CodeDatabaseError, err, "写入站内通知失败")
|
||||
}
|
||||
if created {
|
||||
return notification, true, nil
|
||||
}
|
||||
existing, err := s.repository.FindByEventRecipient(ctx, eventID, recipientKind, recipientID)
|
||||
if err != nil {
|
||||
return nil, false, errors.Wrap(errors.CodeDatabaseError, err, "回查既有站内通知失败")
|
||||
}
|
||||
return existing, false, nil
|
||||
}
|
||||
|
||||
func notificationDisplayExpiry(category string, requested *time.Time, now time.Time) (*time.Time, error) {
|
||||
switch category {
|
||||
case constants.NotificationCategoryApproval:
|
||||
|
||||
63
internal/application/notification/direct.go
Normal file
63
internal/application/notification/direct.go
Normal file
@@ -0,0 +1,63 @@
|
||||
package notification
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/internal/infrastructure/audit"
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/errors"
|
||||
)
|
||||
|
||||
// PersonalDirectRequest 是当次直投或复用个人客户通知的请求参数。
|
||||
// PopupSnapshot 只允许弹窗投放类型携带,其余类型必须为空。
|
||||
type PersonalDirectRequest struct {
|
||||
NotificationType string
|
||||
TemplateData map[string]string
|
||||
RefType string
|
||||
RefID string
|
||||
RefKey string
|
||||
ExpiresAt *time.Time
|
||||
PopupSnapshot *model.NotificationPopupSnapshot
|
||||
}
|
||||
|
||||
// DirectWriter 是「当次创建或复用个人客户通知」的窄接口。
|
||||
// 候选查询必须当次拿到可用通知标识,不能依赖 Outbox 消费延迟,因此需要这条同步入口。
|
||||
type DirectWriter interface {
|
||||
CreateOrGetPersonal(ctx context.Context, eventID string, customerID uint, request PersonalDirectRequest) (*model.Notification, error)
|
||||
}
|
||||
|
||||
// CreateOrGetPersonal 当次渲染并幂等写入个人客户通知;事件键已存在时不重复投放,回查并返回既有行。
|
||||
// 与 Outbox 消费共用同一渲染、展示期与幂等写入规则,避免两条链路规则漂移。
|
||||
func (s *DeliveryService) CreateOrGetPersonal(ctx context.Context, eventID string, customerID uint, request PersonalDirectRequest) (*model.Notification, error) {
|
||||
if customerID == 0 || eventID == "" {
|
||||
return nil, errors.New(errors.CodeInvalidParam, "个人客户通知参数不完整")
|
||||
}
|
||||
delivery := deliveryRequest{
|
||||
notificationType: request.NotificationType, templateData: request.TemplateData,
|
||||
refType: request.RefType, refID: request.RefID, refKey: request.RefKey,
|
||||
expiresAt: request.ExpiresAt, popupSnapshot: request.PopupSnapshot,
|
||||
}
|
||||
if err := validateDeliveryRequest(delivery); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// API 直投不经过 Outbox 消费,自行提供渲染结果与展示期,但仍复用同一落库规则。
|
||||
// 个人客户请求上下文不携带审计上下文,直投必须显式声明操作者与入口,否则投递审计会被入口规则拒绝并静默降级。
|
||||
prepared, err := s.prepareDelivery(eventID, constants.NotificationRecipientKindPersonalCustomer, delivery, deliveryOrigin{
|
||||
actor: audit.ActorInput{Kind: constants.AuditActorPersonalCustomer, ID: strconv.FormatUint(uint64(customerID), 10)},
|
||||
source: constants.AuditSourcePersonalAPI,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
notification, _, err := s.deliverOne(ctx, eventID, constants.NotificationRecipientKindPersonalCustomer, customerID, delivery, prepared)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if notification == nil {
|
||||
return nil, errors.New(errors.CodeInvalidStatus, "个人客户通知接收人不可用")
|
||||
}
|
||||
return notification, nil
|
||||
}
|
||||
@@ -214,7 +214,12 @@ func personalReadScope(db *gorm.DB, customerID uint, now time.Time) *gorm.DB {
|
||||
constants.NotificationRecipientKindPersonalCustomer,
|
||||
customerID,
|
||||
[]string{constants.NotificationCategoryApproval, constants.NotificationCategoryExpiry, constants.NotificationCategorySystem},
|
||||
[]string{constants.NotificationTypePackageExpiring, constants.NotificationTypeExchangeShippingCreated},
|
||||
[]string{
|
||||
constants.NotificationTypePackageExpiring,
|
||||
constants.NotificationTypeExchangeShippingCreated,
|
||||
constants.NotificationTypeH5PopupRiskExchange,
|
||||
constants.NotificationTypeH5PopupOperation,
|
||||
},
|
||||
now,
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user