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