feat(H5弹窗): AUG26-007 风险换卡与运营弹窗投放通知
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:
2026-09-15 15:23:52 +08:00
parent 70e680eb0a
commit 333ba4b647
39 changed files with 2861 additions and 166 deletions

View 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, "运营弹窗标题长度必须在 1100 字符之间")
}
// 标题与正文同一口径:两者都会冻结进通知并参与渲染,任一都不接受 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, "运营弹窗正文长度必须在 12000 字符之间")
}
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, "运营弹窗优先级必须在 01000000 之间")
}
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,
}
}