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:
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,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user