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, } }