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

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

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

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

View File

@@ -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:

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

View File

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

View File

@@ -4,6 +4,7 @@ import (
agentrechargeApp "github.com/break/junhong_cmp_fiber/internal/application/agentrecharge"
businessUserGroupApp "github.com/break/junhong_cmp_fiber/internal/application/businessusergroup"
employeecollectionApp "github.com/break/junhong_cmp_fiber/internal/application/employeecollection"
h5PopupApp "github.com/break/junhong_cmp_fiber/internal/application/h5popup"
merchantPaymentApp "github.com/break/junhong_cmp_fiber/internal/application/merchantpayment"
notificationApp "github.com/break/junhong_cmp_fiber/internal/application/notification"
roleApp "github.com/break/junhong_cmp_fiber/internal/application/role"
@@ -19,6 +20,7 @@ import (
auditInfra "github.com/break/junhong_cmp_fiber/internal/infrastructure/audit"
"github.com/break/junhong_cmp_fiber/internal/infrastructure/carriercallback"
"github.com/break/junhong_cmp_fiber/internal/infrastructure/integrationlog"
notificationInfra "github.com/break/junhong_cmp_fiber/internal/infrastructure/notification"
systemConfigInfra "github.com/break/junhong_cmp_fiber/internal/infrastructure/systemconfig"
wecomInfra "github.com/break/junhong_cmp_fiber/internal/infrastructure/wecom"
pollingPkg "github.com/break/junhong_cmp_fiber/internal/polling"
@@ -29,6 +31,7 @@ import (
distributionwithdrawalQuery "github.com/break/junhong_cmp_fiber/internal/query/distributionwithdrawal"
employeecollectionQuery "github.com/break/junhong_cmp_fiber/internal/query/employeecollection"
exchangeQuery "github.com/break/junhong_cmp_fiber/internal/query/exchange"
h5PopupQuery "github.com/break/junhong_cmp_fiber/internal/query/h5popup"
integrationQuery "github.com/break/junhong_cmp_fiber/internal/query/integration"
notificationQuery "github.com/break/junhong_cmp_fiber/internal/query/notification"
packageExpiryQuery "github.com/break/junhong_cmp_fiber/internal/query/packageexpiry"
@@ -165,6 +168,24 @@ func initHandlers(svc *services, deps *Dependencies) *Handlers {
))
svc.Account.SetWeComMemberFinder(wecomMembers)
// H5 弹窗候选必须当次返回可用通知标识,因此 API 进程直接复用 Outbox 消费的同一套渲染、展示期与幂等写入规则。
notificationAudit := auditInfra.NewWriter(auditInfra.NewRegistry(), nil)
notificationDirectWriter := notificationApp.NewDeliveryService(
notificationInfra.NewRepository(deps.DB), notificationInfra.NewRegistry(), nil, deps.Logger, notificationAudit,
)
// 资产标识解析复用既有 Store 方法,保证与资产详情、换货入口同一口径。
candidateService := h5PopupApp.NewCandidateService(
deps.DB,
postgres.NewAssetIdentifierStore(deps.DB),
postgres.NewIotCardStore(deps.DB, deps.Redis),
postgres.NewDeviceStore(deps.DB, deps.Redis),
svc.CustomerBinding,
notificationDirectWriter,
)
riskExchangeService := h5PopupApp.NewRiskExchangeService(deps.DB, svc.CustomerBinding, notificationAudit)
popupConfigurationService := h5PopupApp.NewConfigurationService(deps.DB, notificationAudit)
popupConfigurationQuery := h5PopupQuery.NewQuery(deps.DB)
return &Handlers{
Auth: authHandler.NewHandler(svc.Auth, validate),
Account: admin.NewAccountHandler(svc.Account),
@@ -203,7 +224,8 @@ func initHandlers(svc *services, deps *Dependencies) *Handlers {
}(),
ClientRechargeOrder: app.NewClientRechargeOrderHandler(rechargeOrderStore, paymentStore, deps.Logger),
ClientNotification: app.NewClientNotificationHandler(notificationQuery.NewQuery(deps.DB),
notificationApp.NewReadService(deps.DB, auditInfra.NewWriter(auditInfra.NewRegistry(), nil)), validate),
notificationApp.NewReadService(deps.DB, notificationAudit), validate),
ClientPopup: app.NewClientPopupHandler(candidateService, riskExchangeService, validate),
Shop: func() *admin.ShopHandler {
handler := admin.NewShopHandler(svc.Shop, validate)
handler.SetCreateService(shopApp.NewCreateService(deps.DB, svc.AccessAudit))
@@ -250,7 +272,8 @@ func initHandlers(svc *services, deps *Dependencies) *Handlers {
IotCardImport: admin.NewIotCardImportHandler(svc.IotCardImport),
ExportTask: admin.NewExportTaskHandler(svc.ExportTask),
Notification: admin.NewNotificationHandler(notificationQuery.NewQuery(deps.DB),
notificationApp.NewReadService(deps.DB, auditInfra.NewWriter(auditInfra.NewRegistry(), nil)), validate),
notificationApp.NewReadService(deps.DB, notificationAudit), validate),
H5PopupConfiguration: admin.NewH5PopupConfigurationHandler(popupConfigurationService, popupConfigurationQuery, validate),
Device: admin.NewDeviceHandler(svc.Device),
DeviceImport: admin.NewDeviceImportHandler(svc.DeviceImport),
AssetAllocationRecord: admin.NewAssetAllocationRecordHandler(svc.AssetAllocationRecord),

View File

@@ -25,6 +25,7 @@ type Handlers struct {
ClientDevice *app.ClientDeviceHandler
ClientRechargeOrder *app.ClientRechargeOrderHandler
ClientNotification *app.ClientNotificationHandler
ClientPopup *app.ClientPopupHandler
Shop *admin.ShopHandler
ShopRole *admin.ShopRoleHandler
AdminAuth *admin.AuthHandler
@@ -41,6 +42,7 @@ type Handlers struct {
IotCardImport *admin.IotCardImportHandler
ExportTask *admin.ExportTaskHandler
Notification *admin.NotificationHandler
H5PopupConfiguration *admin.H5PopupConfigurationHandler
Device *admin.DeviceHandler
DeviceImport *admin.DeviceImportHandler
AssetAllocationRecord *admin.AssetAllocationRecordHandler

View File

@@ -0,0 +1,178 @@
package admin
import (
"strconv"
"github.com/go-playground/validator/v10"
"github.com/gofiber/fiber/v2"
h5popupapp "github.com/break/junhong_cmp_fiber/internal/application/h5popup"
"github.com/break/junhong_cmp_fiber/internal/handler/validation"
"github.com/break/junhong_cmp_fiber/internal/model/dto"
h5popupquery "github.com/break/junhong_cmp_fiber/internal/query/h5popup"
"github.com/break/junhong_cmp_fiber/pkg/errors"
"github.com/break/junhong_cmp_fiber/pkg/response"
)
// H5PopupConfigurationHandler H5 运营弹窗配置后台 Handler。
// 全部接口仅超级管理员与平台账号可用,代理与企业统一返回 403。
type H5PopupConfigurationHandler struct {
service *h5popupapp.ConfigurationService
query *h5popupquery.Query
validator *validator.Validate
}
// NewH5PopupConfigurationHandler 创建 H5 运营弹窗配置后台 Handler。
func NewH5PopupConfigurationHandler(service *h5popupapp.ConfigurationService, query *h5popupquery.Query, validate *validator.Validate) *H5PopupConfigurationHandler {
return &H5PopupConfigurationHandler{service: service, query: query, validator: validate}
}
// ListH5PopupConfigurations 查询运营弹窗配置列表。
// GET /api/admin/h5-popup-configurations
func (h *H5PopupConfigurationHandler) ListH5PopupConfigurations(c *fiber.Ctx) error {
if err := requirePlatformManagement(c); err != nil {
return err
}
var request dto.H5PopupConfigurationListRequest
if err := c.QueryParser(&request); err != nil {
return errors.New(errors.CodeInvalidParam)
}
if h.validator != nil {
if err := h.validator.Struct(&request); err != nil {
return errors.New(errors.CodeInvalidParam, validation.Message("运营弹窗配置列表参数不合法", &request, err))
}
}
if h.query == nil {
return errors.New(errors.CodeServiceUnavailable, "运营弹窗配置维护能力尚未配置")
}
result, err := h.query.List(c.UserContext(), request)
if err != nil {
return err
}
return response.SuccessWithPagination(c, result.Items, result.Total, result.Page, result.Size)
}
// GetH5PopupConfiguration 查询运营弹窗配置详情。
// GET /api/admin/h5-popup-configurations/:id
func (h *H5PopupConfigurationHandler) GetH5PopupConfiguration(c *fiber.Ctx) error {
if err := requirePlatformManagement(c); err != nil {
return err
}
id, err := h.parseID(c)
if err != nil {
return err
}
if h.query == nil {
return errors.New(errors.CodeServiceUnavailable, "运营弹窗配置维护能力尚未配置")
}
result, err := h.query.Get(c.UserContext(), id)
if err != nil {
return err
}
return response.Success(c, result)
}
// CreateH5PopupConfiguration 创建运营弹窗配置,初始版本为 1。
// POST /api/admin/h5-popup-configurations
func (h *H5PopupConfigurationHandler) CreateH5PopupConfiguration(c *fiber.Ctx) error {
if err := requirePlatformManagement(c); err != nil {
return err
}
var request dto.CreateH5PopupConfigurationRequest
if err := c.BodyParser(&request); err != nil {
return errors.New(errors.CodeInvalidParam)
}
if h.validator != nil {
if err := h.validator.Struct(&request); err != nil {
return errors.New(errors.CodeInvalidParam, validation.Message("创建运营弹窗配置参数不合法", &request, err))
}
}
if h.service == nil || h.query == nil {
return errors.New(errors.CodeServiceUnavailable, "运营弹窗配置维护能力尚未配置")
}
id, err := h.service.Create(c.UserContext(), request)
if err != nil {
return err
}
result, err := h.query.Get(c.UserContext(), id)
if err != nil {
return err
}
return response.Success(c, result)
}
// UpdateH5PopupConfiguration 更新运营弹窗配置并递增版本。
// PUT /api/admin/h5-popup-configurations/:id
func (h *H5PopupConfigurationHandler) UpdateH5PopupConfiguration(c *fiber.Ctx) error {
if err := requirePlatformManagement(c); err != nil {
return err
}
id, err := h.parseID(c)
if err != nil {
return err
}
var request dto.UpdateH5PopupConfigurationParams
if err := c.BodyParser(&request); err != nil {
return errors.New(errors.CodeInvalidParam)
}
// 路径来源字段必须由 Handler 回填后再校验,避免被请求体覆盖,也避免 required 恒失败。
request.ID = id
if h.validator != nil {
if err := h.validator.Struct(&request); err != nil {
return errors.New(errors.CodeInvalidParam, validation.Message("更新运营弹窗配置参数不合法", &request, err))
}
}
if h.service == nil || h.query == nil {
return errors.New(errors.CodeServiceUnavailable, "运营弹窗配置维护能力尚未配置")
}
if err := h.service.Update(c.UserContext(), id, request.UpdateH5PopupConfigurationRequest); err != nil {
return err
}
result, err := h.query.Get(c.UserContext(), id)
if err != nil {
return err
}
return response.Success(c, result)
}
// EnableH5PopupConfiguration 启用运营弹窗配置,仅影响后续候选并刷新最近更新时间。
// POST /api/admin/h5-popup-configurations/:id/enable
func (h *H5PopupConfigurationHandler) EnableH5PopupConfiguration(c *fiber.Ctx) error {
return h.setEnabled(c, true)
}
// DisableH5PopupConfiguration 停用运营弹窗配置,仅影响后续候选并刷新最近更新时间。
// POST /api/admin/h5-popup-configurations/:id/disable
func (h *H5PopupConfigurationHandler) DisableH5PopupConfiguration(c *fiber.Ctx) error {
return h.setEnabled(c, false)
}
func (h *H5PopupConfigurationHandler) setEnabled(c *fiber.Ctx, enabled bool) error {
if err := requirePlatformManagement(c); err != nil {
return err
}
id, err := h.parseID(c)
if err != nil {
return err
}
if h.service == nil || h.query == nil {
return errors.New(errors.CodeServiceUnavailable, "运营弹窗配置维护能力尚未配置")
}
if err := h.service.SetEnabled(c.UserContext(), id, enabled); err != nil {
return err
}
result, err := h.query.Get(c.UserContext(), id)
if err != nil {
return err
}
return response.Success(c, result)
}
// parseID 从路径解析运营弹窗配置 ID。
func (h *H5PopupConfigurationHandler) parseID(c *fiber.Ctx) (uint, error) {
id, err := strconv.ParseUint(c.Params("id"), 10, 64)
if err != nil || id == 0 {
return 0, errors.New(errors.CodeInvalidParam, "运营弹窗配置ID不合法")
}
return uint(id), nil
}

View File

@@ -1,15 +1,14 @@
package admin
import (
"reflect"
"strconv"
"strings"
"github.com/go-playground/validator/v10"
"github.com/gofiber/fiber/v2"
distributionapp "github.com/break/junhong_cmp_fiber/internal/application/distributionwithdrawal"
distributiondomain "github.com/break/junhong_cmp_fiber/internal/domain/distribution"
"github.com/break/junhong_cmp_fiber/internal/handler/validation"
"github.com/break/junhong_cmp_fiber/internal/model/dto"
distributionquery "github.com/break/junhong_cmp_fiber/internal/query/distributionwithdrawal"
"github.com/break/junhong_cmp_fiber/pkg/constants"
@@ -107,91 +106,9 @@ func (h *WithdrawalQualificationHandler) VoidWithdrawalQualification(c *fiber.Ct
}
// validationMessage 把请求校验失败转换为可定位字段的中文提示。
// 只使用字段的 description 与校验规则,不拼接底层错误文本,也不回显字段值
// 规则实现收口在 internal/handler/validation管理端与 C 端共用同一套提示口径
func validationMessage(prefix string, req any, err error) string {
fieldErrs, ok := err.(validator.ValidationErrors)
if !ok || len(fieldErrs) == 0 {
return prefix
}
return prefix + "" + describeFieldError(req, fieldErrs[0])
}
// describeFieldError 用字段中文名与失败规则描述单个字段错误。
func describeFieldError(req any, fieldErr validator.FieldError) string {
label := fieldDescription(req, fieldErr.StructField())
switch fieldErr.Tag() {
case "required":
// 数字字段的 required 只在零值失败;说“不能为空”会误导为缺字段。
if isNumericField(req, fieldErr.StructField()) {
return label + "必须大于 0"
}
return label + "不能为空"
case "min":
if isNumericField(req, fieldErr.StructField()) {
return label + "不能小于 " + fieldErr.Param()
}
return label + "长度不能小于 " + fieldErr.Param()
case "max":
if isNumericField(req, fieldErr.StructField()) {
return label + "不能超过 " + fieldErr.Param()
}
return label + "长度不能超过 " + fieldErr.Param()
case "oneof":
return label + "必须为 " + strings.ReplaceAll(fieldErr.Param(), " ", "/") + " 之一"
default:
return label + "不合法(" + fieldErr.Tag() + ""
}
}
// isNumericField 判断字段是否为整数或浮点类型。
func isNumericField(req any, fieldName string) bool {
field, ok := lookupField(req, fieldName)
if !ok {
return false
}
switch field.Type.Kind() {
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64,
reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64,
reflect.Float32, reflect.Float64:
return true
default:
return false
}
}
// lookupField 在去指针的结构体类型上按名取字段。
func lookupField(req any, fieldName string) (reflect.StructField, bool) {
typ := reflect.TypeOf(req)
for typ != nil && typ.Kind() == reflect.Ptr {
typ = typ.Elem()
}
if typ == nil || typ.Kind() != reflect.Struct {
return reflect.StructField{}, false
}
return typ.FieldByName(fieldName)
}
// fieldDescription 取字段 description 的首个中文短语作为提示名,缺失时退回字段名。
func fieldDescription(req any, fieldName string) string {
field, ok := lookupField(req, fieldName)
if !ok {
return fieldName
}
description := strings.TrimSpace(field.Tag.Get("description"))
if description == "" {
return fieldName
}
if cut := strings.IndexAny(description, "(:,;"); cut > 0 {
description = strings.TrimSpace(description[:cut])
}
if description == "" {
return fieldName
}
// 提示名以拉丁字母/数字结尾时补一个空格,避免与后续中文粘连。
if last := description[len(description)-1]; last < 0x80 {
description += " "
}
return description
return validation.Message(prefix, req, err)
}
// ListWithdrawalQualifications 查询提现资料资格版本

View File

@@ -0,0 +1,104 @@
package app
import (
"strconv"
"github.com/go-playground/validator/v10"
"github.com/gofiber/fiber/v2"
"go.uber.org/zap"
h5popupapp "github.com/break/junhong_cmp_fiber/internal/application/h5popup"
"github.com/break/junhong_cmp_fiber/internal/handler/validation"
"github.com/break/junhong_cmp_fiber/internal/middleware"
"github.com/break/junhong_cmp_fiber/internal/model/dto"
"github.com/break/junhong_cmp_fiber/pkg/errors"
"github.com/break/junhong_cmp_fiber/pkg/logger"
"github.com/break/junhong_cmp_fiber/pkg/response"
)
// ClientPopupHandler 提供 H5 弹窗候选查询与风险换卡地址提交。
type ClientPopupHandler struct {
candidates *h5popupapp.CandidateService
riskExchanges *h5popupapp.RiskExchangeService
validate *validator.Validate
}
// NewClientPopupHandler 创建 H5 弹窗 Handler。
func NewClientPopupHandler(candidates *h5popupapp.CandidateService, riskExchanges *h5popupapp.RiskExchangeService, validate *validator.Validate) *ClientPopupHandler {
return &ClientPopupHandler{candidates: candidates, riskExchanges: riskExchanges, validate: validate}
}
// GetCandidates 查询当前页面与当前资产的弹窗候选。
// GET /api/c/v1/popup-candidates
// 该查询有副作用:命中时会创建或复用个人站内通知并保持未读,这是产品契约(不预生成通知),
// 客户端关闭或稍后处理时必须用返回的 notification_id 调用既有已读接口。
func (h *ClientPopupHandler) GetCandidates(c *fiber.Ctx) error {
var request dto.PopupCandidateRequest
if err := c.QueryParser(&request); err != nil {
logPopupValidationFailure(c, "弹窗候选参数不合法", err)
return errors.New(errors.CodeInvalidParam)
}
if h.validate != nil {
if err := h.validate.Struct(&request); err != nil {
logPopupValidationFailure(c, "弹窗候选参数不合法", err)
return errors.New(errors.CodeInvalidParam, validation.Message("弹窗候选参数不合法", &request, err))
}
}
customerID, ok := middleware.GetCustomerID(c)
if !ok || customerID == 0 {
return errors.New(errors.CodeUnauthorized)
}
if h.candidates == nil {
return errors.New(errors.CodeServiceUnavailable, "弹窗投放能力尚未配置")
}
result, err := h.candidates.GetCandidate(c.UserContext(), customerID, request)
if err != nil {
return err
}
return response.Success(c, result)
}
// SubmitRiskAddress 提交风险换卡收货地址。
// POST /api/c/v1/risk-exchanges/:asset_id/address
// 重复提交返回首次创建的物流换货单与首次地址;首地址锁定,客户不能修改。
func (h *ClientPopupHandler) SubmitRiskAddress(c *fiber.Ctx) error {
assetID, err := strconv.ParseUint(c.Params("asset_id"), 10, 64)
if err != nil || assetID == 0 {
return errors.New(errors.CodeInvalidParam, "风险换卡资产ID不合法")
}
var request dto.ClientRiskExchangeAddressParams
if err := c.BodyParser(&request); err != nil {
logPopupValidationFailure(c, "风险换卡地址参数不合法", err)
return errors.New(errors.CodeInvalidParam)
}
// 路径来源字段必须由 Handler 回填后再校验,避免被请求体覆盖,也避免 required 恒失败。
request.AssetID = uint(assetID)
if h.validate != nil {
if err := h.validate.Struct(&request); err != nil {
logPopupValidationFailure(c, "风险换卡地址参数不合法", err)
return errors.New(errors.CodeInvalidParam, validation.Message("风险换卡地址参数不合法", &request, err))
}
}
customerID, ok := middleware.GetCustomerID(c)
if !ok || customerID == 0 {
return errors.New(errors.CodeUnauthorized)
}
if h.riskExchanges == nil {
return errors.New(errors.CodeServiceUnavailable, "风险换卡能力尚未配置")
}
result, err := h.riskExchanges.Submit(c.UserContext(), customerID, uint(assetID), request)
if err != nil {
return err
}
return response.Success(c, result)
}
// logPopupValidationFailure 记录参数校验失败,仅记录字段错误,不回显请求体内容。
func logPopupValidationFailure(c *fiber.Ctx, message string, err error) {
logger.GetAppLogger().Warn("H5 弹窗接口参数验证失败",
zap.String("method", c.Method()),
zap.String("path", c.Path()),
zap.String("message", message),
zap.Error(err),
)
}

View File

@@ -0,0 +1,114 @@
// Package validation 提供请求参数校验失败的可定位中文提示。
// 提示只使用字段的 description 与校验规则,不拼接底层错误文本,也不回显字段值。
package validation
import (
"reflect"
"strings"
"github.com/go-playground/validator/v10"
)
// Message 把请求校验失败转换为可定位字段的中文提示。
func Message(prefix string, req any, err error) string {
fieldErrs, ok := err.(validator.ValidationErrors)
if !ok || len(fieldErrs) == 0 {
return prefix
}
return prefix + "" + describeFieldError(req, fieldErrs[0])
}
// describeFieldError 用字段中文名与失败规则描述单个字段错误。
func describeFieldError(req any, fieldErr validator.FieldError) string {
label := fieldDescription(req, fieldErr.StructField())
switch fieldErr.Tag() {
case "required":
// 数字字段的 required 只在零值失败;说“不能为空”会误导为缺字段。
if isNumericField(req, fieldErr.StructField()) {
return label + "必须大于 0"
}
return label + "不能为空"
case "min":
if isNumericField(req, fieldErr.StructField()) {
return label + "不能小于 " + fieldErr.Param()
}
return label + "长度不能小于 " + fieldErr.Param()
case "max":
if isNumericField(req, fieldErr.StructField()) {
return label + "不能超过 " + fieldErr.Param()
}
return label + "长度不能超过 " + fieldErr.Param()
case "oneof":
return label + "必须为 " + strings.ReplaceAll(fieldErr.Param(), " ", "/") + " 之一"
default:
return label + "不合法(" + fieldErr.Tag() + ""
}
}
// isNumericField 判断字段是否为整数或浮点类型。
func isNumericField(req any, fieldName string) bool {
field, ok := lookupField(req, fieldName)
if !ok {
return false
}
switch field.Type.Kind() {
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64,
reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64,
reflect.Float32, reflect.Float64:
return true
default:
return false
}
}
// lookupField 在去指针的结构体类型上按名取字段。
func lookupField(req any, fieldName string) (reflect.StructField, bool) {
typ := reflect.TypeOf(req)
for typ != nil && typ.Kind() == reflect.Ptr {
typ = typ.Elem()
}
if typ == nil || typ.Kind() != reflect.Struct {
return reflect.StructField{}, false
}
if field, ok := typ.FieldByName(fieldName); ok {
return field, true
}
// 嵌套(含匿名嵌入)结构体字段:校验错误报告的是内层字段名,提示也要能取到它的 description。
for index := range typ.NumField() {
field := typ.Field(index)
nested := field.Type
for nested.Kind() == reflect.Ptr {
nested = nested.Elem()
}
if nested.Kind() != reflect.Struct {
continue
}
if inner, ok := nested.FieldByName(fieldName); ok {
return inner, true
}
}
return reflect.StructField{}, false
}
// fieldDescription 取字段 description 的首个中文短语作为提示名,缺失时退回字段名。
func fieldDescription(req any, fieldName string) string {
field, ok := lookupField(req, fieldName)
if !ok {
return fieldName
}
description := strings.TrimSpace(field.Tag.Get("description"))
if description == "" {
return fieldName
}
if cut := strings.IndexAny(description, "(:,;"); cut > 0 {
description = strings.TrimSpace(description[:cut])
}
if description == "" {
return fieldName
}
// 提示名以拉丁字母/数字结尾时补一个空格,避免与后续中文粘连。
if last := description[len(description)-1]; last < 0x80 {
description += " "
}
return description
}

View File

@@ -175,6 +175,13 @@ func NewRegistry() *Registry {
carrierUpdated := connectionConfigAction(constants.AuditActionCarrierUpdated, "更新运营商配置", constants.AuditResourceCarrier, constants.AuditRiskNormal)
carrierDeleted := connectionConfigAction(constants.AuditActionCarrierDeleted, "删除运营商配置", constants.AuditResourceCarrier, constants.AuditRiskHigh)
carrierStatusUpdated := connectionConfigAction(constants.AuditActionCarrierStatusUpdated, "更新运营商配置状态", constants.AuditResourceCarrier, constants.AuditRiskHigh)
// H5 运营弹窗配置:启停只影响后续投放,但会改变客户端可见内容与频率口径,统一按关键配置记录前后值。
h5PopupConfigCreated := connectionConfigAction(constants.AuditActionH5PopupConfigurationCreated, "创建 H5 运营弹窗配置", constants.AuditResourceH5PopupConfiguration, constants.AuditRiskHigh)
h5PopupConfigUpdated := connectionConfigAction(constants.AuditActionH5PopupConfigurationUpdated, "更新 H5 运营弹窗配置", constants.AuditResourceH5PopupConfiguration, constants.AuditRiskHigh)
h5PopupConfigEnabled := connectionConfigAction(constants.AuditActionH5PopupConfigurationEnabled, "启用 H5 运营弹窗配置", constants.AuditResourceH5PopupConfiguration, constants.AuditRiskHigh)
h5PopupConfigDisabled := connectionConfigAction(constants.AuditActionH5PopupConfigurationDisabled, "停用 H5 运营弹窗配置", constants.AuditResourceH5PopupConfiguration, constants.AuditRiskHigh)
// 客户自助风险换卡由个人客户入口发起,创建的是共用换货表事实,因此允许个人客户来源并复用换货单主资源。
cardRiskExchangeRequested := cardExchangeAction(constants.AuditActionCardRiskExchangeRequested, "客户自助发起风险换卡", constants.AuditRiskHigh, true)
wecomApplicationSaved := connectionConfigAction(constants.AuditActionWeComApplicationSaved, "保存企业微信应用配置", constants.AuditResourceWeComApplication, constants.AuditRiskHigh)
wecomDefaultCreatorSaved := connectionConfigAction(constants.AuditActionWeComDefaultCreatorSaved, "保存企业微信默认审批发起人", constants.AuditResourceWeComApplication, constants.AuditRiskHigh)
wecomMembersSynced := connectionConfigAction(constants.AuditActionWeComMembersSynced, "同步企业微信应用可见成员", constants.AuditResourceWeComApplication, constants.AuditRiskNormal)
@@ -227,6 +234,8 @@ func NewRegistry() *Registry {
phoneAssetUnbindImportTaskCreated := taskAction(constants.AuditActionPhoneAssetUnbindImportTaskCreated, "创建手机号资产解绑导入任务", constants.AuditResourcePhoneAssetUnbindImportTask, constants.AuditActorAccount, constants.AuditSourceAdminAPI)
phoneAssetUnbindImportTaskCompleted := taskAction(constants.AuditActionPhoneAssetUnbindImportTaskCompleted, "完成手机号资产解绑导入任务", constants.AuditResourcePhoneAssetUnbindImportTask, constants.AuditActorSystemTask, constants.AuditSourceWorker)
notificationDelivered := notificationAction(constants.AuditActionNotificationDelivered, "生成站内通知", constants.AuditResourceNotification, constants.AuditActorSystemTask, constants.AuditSourceWorker)
// H5 候选查询会当次直投个人客户通知(不依赖 Outbox 消费),因此同一动作必须同时允许个人客户入口。
notificationDelivered.AllowedOrigins = []ActionOrigin{{Actor: constants.AuditActorPersonalCustomer, Source: constants.AuditSourcePersonalAPI}}
notificationRead := notificationAction(constants.AuditActionNotificationRead, "标记通知已读", constants.AuditResourceNotification, constants.AuditActorAccount, constants.AuditSourceAdminAPI)
notificationRead.AllowedOrigins = []ActionOrigin{{Actor: constants.AuditActorPersonalCustomer, Source: constants.AuditSourcePersonalAPI}}
notificationReadAll := notificationAction(constants.AuditActionNotificationReadAll, "批量标记通知已读", constants.AuditResourceNotificationReadBatch, constants.AuditActorAccount, constants.AuditSourceAdminAPI)
@@ -426,6 +435,10 @@ func NewRegistry() *Registry {
constants.AuditOperationCarrierUpdate: carrierUpdated,
constants.AuditOperationCarrierDelete: carrierDeleted,
constants.AuditOperationCarrierStatusUpdate: carrierStatusUpdated,
constants.AuditOperationH5PopupConfigurationCreate: h5PopupConfigCreated,
constants.AuditOperationH5PopupConfigurationUpdate: h5PopupConfigUpdated,
constants.AuditOperationH5PopupConfigurationEnable: h5PopupConfigEnabled,
constants.AuditOperationH5PopupConfigurationDisable: h5PopupConfigDisabled,
constants.AuditOperationWeComApplicationSave: wecomApplicationSaved,
constants.AuditOperationWeComDefaultCreatorSave: wecomDefaultCreatorSaved,
constants.AuditOperationWeComMembersSync: wecomMembersSynced,
@@ -545,6 +558,11 @@ func NewRegistry() *Registry {
constants.AuditActionCarrierUpdated: carrierUpdated,
constants.AuditActionCarrierDeleted: carrierDeleted,
constants.AuditActionCarrierStatusUpdated: carrierStatusUpdated,
constants.AuditActionH5PopupConfigurationCreated: h5PopupConfigCreated,
constants.AuditActionH5PopupConfigurationUpdated: h5PopupConfigUpdated,
constants.AuditActionH5PopupConfigurationEnabled: h5PopupConfigEnabled,
constants.AuditActionH5PopupConfigurationDisabled: h5PopupConfigDisabled,
constants.AuditActionCardRiskExchangeRequested: cardRiskExchangeRequested,
constants.AuditActionEmployeeCollectionPaymentMethodCreated: employeeCollectionPaymentMethodCreated,
constants.AuditActionEmployeeCollectionPaymentMethodUpdated: employeeCollectionPaymentMethodUpdated,
constants.AuditActionEmployeeCollectionPaymentMethodDeleted: employeeCollectionPaymentMethodDeleted,
@@ -731,6 +749,13 @@ func NewRegistry() *Registry {
Type: constants.AuditResourceCarrier, Name: "运营商配置",
IdentityFields: []string{"id", "carrier_code", "carrier_name", "carrier_type", "status"},
},
constants.AuditResourceH5PopupConfiguration: {
Type: constants.AuditResourceH5PopupConfiguration, Name: "H5 运营弹窗配置",
IdentityFields: []string{
"id", "title", "pages", "shop_ids", "device_types", "card_types",
"priority", "frequency", "action_type", "enabled", "starts_at", "ends_at", "version",
},
},
constants.AuditResourceEmployeeCollectionPaymentMethod: {
Type: constants.AuditResourceEmployeeCollectionPaymentMethod, Name: "线下收款方式",
IdentityFields: []string{"id", "code", "name", "status", "sort"},

View File

@@ -118,6 +118,30 @@ func NewRegistry() *Registry {
constants.NotificationRefTypeShopFund: {},
},
},
// 风险换卡弹窗:内容固定,不含任何配置信息;资源引用只指向旧资产。
constants.NotificationTypeH5PopupRiskExchange: {
Type: constants.NotificationTypeH5PopupRiskExchange, Category: constants.NotificationCategorySystem,
Severity: constants.NotificationSeverityWarning,
TitleTemplate: "换卡地址待填写",
BodyTemplate: "您的广电卡已被运营商风险停机,请填写收货地址以便寄送新卡。",
TemplateFields: map[string]struct{}{},
RecipientKinds: map[string]struct{}{constants.NotificationRecipientKindPersonalCustomer: {}},
AllowedRefTypes: map[string]struct{}{
constants.NotificationRefTypeAsset: {},
},
},
// 运营弹窗:标题与正文由运营配置在投放时冻结,禁止 HTML 与 URL资源引用只指向当前资产。
constants.NotificationTypeH5PopupOperation: {
Type: constants.NotificationTypeH5PopupOperation, Category: constants.NotificationCategorySystem,
Severity: constants.NotificationSeverityInfo,
TitleTemplate: "{{.title}}",
BodyTemplate: "{{.content}}",
TemplateFields: map[string]struct{}{"title": {}, "content": {}},
RecipientKinds: map[string]struct{}{constants.NotificationRecipientKindPersonalCustomer: {}},
AllowedRefTypes: map[string]struct{}{
constants.NotificationRefTypeAsset: {},
},
},
}}
}

View File

@@ -35,6 +35,17 @@ func (r *Repository) CreateIdempotent(ctx context.Context, notification *model.N
return result.RowsAffected == 1, result.Error
}
// FindByEventRecipient 按事件键与接收人回查既有通知,用于幂等冲突时返回既有行而不是重复投放。
func (r *Repository) FindByEventRecipient(ctx context.Context, eventID, recipientKind string, recipientID uint) (*model.Notification, error) {
var notification model.Notification
if err := r.db.WithContext(ctx).
Where("event_id = ? AND recipient_kind = ? AND recipient_id = ?", eventID, recipientKind, recipientID).
Take(&notification).Error; err != nil {
return nil, err
}
return &notification, nil
}
// IsActiveAccount 判断明确后台账号是否仍启用且未软删除。
func (r *Repository) IsActiveAccount(ctx context.Context, accountID uint) (bool, error) {
var count int64

View File

@@ -0,0 +1,147 @@
package dto
import "time"
// CreateH5PopupConfigurationRequest 是创建 H5 运营弹窗配置的请求。
// 范围四维中只有 pages 必填;店铺、设备类型、卡类型不传或传空数组表示该维度全量。
// action_type 只接受受控动作白名单,不接受 URL、前端路由或任意参数。
type CreateH5PopupConfigurationRequest struct {
Title string `json:"title" validate:"required,min=1,max=100" required:"true" minLength:"1" maxLength:"100" description:"弹窗标题1100 字符;投放时冻结写入通知标题"`
Content string `json:"content" validate:"required,min=1,max=2000" required:"true" minLength:"1" maxLength:"2000" description:"弹窗正文12000 字符;投放时冻结写入通知正文,不接受 HTML、URL 或前端路由"`
Pages []string `json:"pages" validate:"required,min=1,max=4,dive,oneof=home asset_detail package_purchase asset_wallet_recharge" required:"true" description:"命中页面集合,至少一项 (home:首页, asset_detail:资产详情, package_purchase:套餐购买, asset_wallet_recharge:资产钱包充值)"`
ShopIDs []uint `json:"shop_ids" validate:"omitempty,max=200,dive,min=1" maxLength:"200" description:"店铺范围;不传或空数组表示全量,同维度多选取任一命中"`
DeviceTypes []string `json:"device_types" validate:"omitempty,max=100,dive,min=1,max=50" maxLength:"100" description:"设备类型范围;不传或空数组表示全量;资产该维度无值(独立卡或未绑定设备)时不命中已配置范围"`
CardTypes []string `json:"card_types" validate:"omitempty,max=4,dive,oneof=CMCC CUCC CTCC CBN" maxLength:"4" description:"卡类型范围 (CMCC:中国移动, CUCC:中国联通, CTCC:中国电信, CBN:中国广电);不传或空数组表示全量;非卡资产该维度无值时不命中已配置范围"`
Priority *int `json:"priority" validate:"omitempty,min=0,max=1000000" minimum:"0" maximum:"1000000" description:"优先级,数值越大越优先,默认 0"`
Frequency string `json:"frequency" validate:"required,oneof=once daily" required:"true" enum:"once,daily" description:"投放频率 (once:每客户每配置版本仅一次, daily:每客户每配置版本每个上海自然日一次)"`
Enabled *bool `json:"enabled" description:"是否启用;不传按停用创建"`
ActionType *string `json:"action_type" validate:"omitempty,oneof=package_purchase asset_wallet_recharge" enum:"package_purchase,asset_wallet_recharge" description:"受控动作 (package_purchase:套餐购买, asset_wallet_recharge:资产钱包充值);不传表示无受控动作"`
StartsAt time.Time `json:"starts_at" validate:"required" required:"true" description:"生效开始时间ISO 8601"`
EndsAt time.Time `json:"ends_at" validate:"required" required:"true" description:"生效结束时间ISO 8601不得早于开始时间"`
}
// UpdateH5PopupConfigurationRequest 是更新 H5 运营弹窗配置的请求。
// 除范围集合外均为指针:字段缺省表示保持原值;范围字段传空数组表示该维度改为全量。
// 更新成功即递增配置版本,旧版本已投放通知的内容与快照不被改写。
type UpdateH5PopupConfigurationRequest struct {
Title *string `json:"title" validate:"omitempty,min=1,max=100" minLength:"1" maxLength:"100" description:"弹窗标题1100 字符;不传保持原值"`
Content *string `json:"content" validate:"omitempty,min=1,max=2000" minLength:"1" maxLength:"2000" description:"弹窗正文12000 字符;不传保持原值,不接受 HTML、URL 或前端路由"`
Pages *[]string `json:"pages" validate:"omitempty,min=1,max=4,dive,oneof=home asset_detail package_purchase asset_wallet_recharge" description:"命中页面集合;不传保持原值,传空数组等价于非法(页面必选)"`
ShopIDs *[]uint `json:"shop_ids" validate:"omitempty,max=200,dive,min=1" maxLength:"200" description:"店铺范围;不传保持原值,传空数组表示改为全量"`
DeviceTypes *[]string `json:"device_types" validate:"omitempty,max=100,dive,min=1,max=50" maxLength:"100" description:"设备类型范围;不传保持原值,传空数组表示改为全量"`
CardTypes *[]string `json:"card_types" validate:"omitempty,max=4,dive,oneof=CMCC CUCC CTCC CBN" maxLength:"4" description:"卡类型范围;不传保持原值,传空数组表示改为全量"`
Priority *int `json:"priority" validate:"omitempty,min=0,max=1000000" minimum:"0" maximum:"1000000" description:"优先级,数值越大越优先;不传保持原值"`
Frequency *string `json:"frequency" validate:"omitempty,oneof=once daily" enum:"once,daily" description:"投放频率 (once:每客户每配置版本仅一次, daily:每客户每配置版本每个上海自然日一次);不传保持原值"`
Enabled *bool `json:"enabled" description:"是否启用;不传保持原值。启停会刷新最近更新时间并影响同优先级排序"`
ActionType *string `json:"action_type" validate:"omitempty,oneof=package_purchase asset_wallet_recharge" enum:"package_purchase,asset_wallet_recharge" description:"受控动作;传空字符串表示清除受控动作,不传保持原值"`
StartsAt *time.Time `json:"starts_at" description:"生效开始时间ISO 8601不传保持原值"`
EndsAt *time.Time `json:"ends_at" description:"生效结束时间ISO 8601不传保持原值不得早于开始时间"`
}
// H5PopupConfigurationIDParams 是运营弹窗配置的路径参数。
type H5PopupConfigurationIDParams struct {
ID uint `json:"id" path:"id" required:"true" description:"运营弹窗配置ID"`
}
// UpdateH5PopupConfigurationParams 是更新运营弹窗配置的路径参数与请求体。
// 路径字段必须由 Handler 从 c.Params 回填后再校验,避免 required 恒失败或被请求体覆盖。
type UpdateH5PopupConfigurationParams struct {
ID uint `json:"id" path:"id" required:"true" description:"运营弹窗配置ID"`
UpdateH5PopupConfigurationRequest
}
// H5PopupConfigurationResponse 是运营弹窗配置投影。
type H5PopupConfigurationResponse struct {
ID uint `json:"id" description:"配置ID"`
Title string `json:"title" description:"弹窗标题"`
Content string `json:"content" description:"弹窗正文"`
Pages []string `json:"pages" enums:"home,asset_detail,package_purchase,asset_wallet_recharge" description:"命中页面集合 (home:首页, asset_detail:资产详情, package_purchase:套餐购买, asset_wallet_recharge:资产钱包充值)"`
ShopIDs []uint `json:"shop_ids" description:"店铺范围;空数组表示全量"`
DeviceTypes []string `json:"device_types" description:"设备类型范围;空数组表示全量"`
CardTypes []string `json:"card_types" enums:"CMCC,CUCC,CTCC,CBN" description:"卡类型范围 (CMCC:中国移动, CUCC:中国联通, CTCC:中国电信, CBN:中国广电);空数组表示全量"`
Priority int `json:"priority" description:"优先级,数值越大越优先"`
Frequency string `json:"frequency" enums:"once,daily" description:"投放频率 (once:每客户每配置版本仅一次, daily:每客户每配置版本每个上海自然日一次)"`
FrequencyText string `json:"frequency_text" description:"投放频率名称(中文)"`
Enabled bool `json:"enabled" description:"是否启用;停用后停止新投放,历史通知在展示期内仍可见"`
EnabledText string `json:"enabled_text" description:"启停状态名称(中文)"`
ActionType string `json:"action_type" enums:"package_purchase,asset_wallet_recharge" description:"受控动作 (package_purchase:套餐购买, asset_wallet_recharge:资产钱包充值);空值表示无受控动作"`
StartsAt time.Time `json:"starts_at" description:"生效开始时间ISO 8601"`
EndsAt time.Time `json:"ends_at" description:"生效结束时间ISO 8601"`
Version int64 `json:"version" description:"配置版本,每次更新递增;版本参与频率去重,旧版本通知保留原快照"`
CreatedAt time.Time `json:"created_at" description:"创建时间ISO 8601"`
UpdatedAt time.Time `json:"updated_at" description:"最近更新时间ISO 8601启停同样刷新该时间"`
Creator uint `json:"creator" description:"创建人账号ID"`
Updater uint `json:"updater" description:"最近更新人账号ID"`
}
// H5PopupConfigurationListRequest 是运营弹窗配置列表分页参数。
type H5PopupConfigurationListRequest struct {
Page int `json:"page" query:"page" validate:"omitempty,min=1,max=10000" minimum:"1" maximum:"10000" description:"页码,默认 1最大 10000"`
PageSize int `json:"page_size" query:"page_size" validate:"omitempty,min=1,max=100" minimum:"1" maximum:"100" description:"每页数量,默认 20最大 100"`
Enabled *bool `json:"enabled" query:"enabled" description:"启停筛选;不传时查询全部"`
}
// H5PopupConfigurationListResponse 是运营弹窗配置分页结果。
type H5PopupConfigurationListResponse struct {
Items []H5PopupConfigurationResponse `json:"items" description:"运营弹窗配置列表"`
Total int64 `json:"total" description:"总数量"`
Page int `json:"page" description:"页码"`
Size int `json:"size" description:"每页数量"`
}
// PopupCandidateRequest 是当前客户查询弹窗候选的请求。
// 首页也必须先由客户选定当前资产,因此 identifier 在所有页面都是必填。
type PopupCandidateRequest struct {
Page string `json:"page" query:"page" validate:"required,oneof=home asset_detail package_purchase asset_wallet_recharge" required:"true" enum:"home,asset_detail,package_purchase,asset_wallet_recharge" description:"当前页面 (home:首页, asset_detail:资产详情, package_purchase:套餐购买, asset_wallet_recharge:资产钱包充值)"`
Identifier string `json:"identifier" query:"identifier" validate:"required,min=1,max=100" required:"true" minLength:"1" maxLength:"100" description:"当前资产标识符,卡支持 ICCID、接入号、虚拟号设备支持虚拟号、IMEI、SN"`
}
// PopupCandidateResponse 是弹窗候选结果candidate 为空表示当前页面与资产没有可投放弹窗。
type PopupCandidateResponse struct {
Candidate *PopupCandidateItem `json:"candidate,omitempty" description:"弹窗候选;为空表示当前页面与资产没有可投放弹窗"`
}
// PopupCandidateItem 是当次投放或复用的弹窗候选。
// 只返回类型、资产关联与受控动作,不返回任何 URL 或前端路由;前端按 action_type 白名单映射页面。
type PopupCandidateItem struct {
NotificationID uint `json:"notification_id" description:"投放或复用的站内通知ID关闭或稍后处理时用它调用既有个人通知已读接口"`
PopupType string `json:"popup_type" enums:"risk_exchange,operation" description:"弹窗类型 (risk_exchange:风险换卡, operation:运营弹窗);风险换卡优先级固定高于运营弹窗"`
NotificationType string `json:"notification_type" enums:"h5.popup.risk_exchange,h5.popup.operation" description:"稳定通知类型 (h5.popup.risk_exchange:风险换卡弹窗, h5.popup.operation:运营弹窗)"`
Title string `json:"title" description:"弹窗标题,投放时冻结"`
Body string `json:"body" description:"弹窗正文,投放时冻结"`
AssetType string `json:"asset_type" enums:"iot_card,device" description:"弹窗关联资产类型 (iot_card:物联网卡, device:设备)"`
AssetID uint `json:"asset_id" description:"弹窗关联资产数字ID"`
ActionType string `json:"action_type,omitempty" enums:"package_purchase,asset_wallet_recharge" description:"受控动作 (package_purchase:套餐购买, asset_wallet_recharge:资产钱包充值);为空表示无受控动作"`
ConfigID uint `json:"config_id" description:"运营弹窗配置ID风险换卡弹窗固定为 0"`
ConfigVersion int64 `json:"config_version" description:"投放时的配置版本;风险换卡弹窗固定为 0"`
ExpiresAt *time.Time `json:"expires_at,omitempty" description:"展示截止时间ISO 8601自投放时间起 90 天"`
CreatedAt time.Time `json:"created_at" description:"投放时间ISO 8601"`
}
// ClientRiskExchangeAddressParams 是风险换卡地址提交的路径参数与请求体。
// 地址字段沿用既有换货地址字段与长度校验,不额外拆分省市区。
type ClientRiskExchangeAddressParams struct {
AssetID uint `json:"asset_id" path:"asset_id" required:"true" description:"待换卡资产ID物联网卡数字ID"`
ClientShippingInfoRequest
}
// ClientRiskExchangeResponse 是风险换卡地址提交结果。
// 重复提交返回首次创建的换货单与首次地址,不覆盖既有地址。
type ClientRiskExchangeResponse struct {
ID uint `json:"id" description:"物流换货单ID"`
ExchangeNo string `json:"exchange_no" description:"换货单号"`
Status int `json:"status" description:"换货状态 (2:待发货)"`
StatusName string `json:"status_name" description:"换货状态名称(中文)"`
FlowType string `json:"flow_type" description:"换货流程类型 (shipping:物流换货)"`
OldAssetType string `json:"old_asset_type" description:"旧资产类型 (iot_card:物联网卡)"`
OldAssetID uint `json:"old_asset_id" description:"旧资产ID"`
OldAssetIdentifier string `json:"old_asset_identifier" description:"旧资产权威快照,卡为完整 ICCID"`
RecipientName string `json:"recipient_name" description:"收件人姓名,首次提交后锁定"`
RecipientPhone string `json:"recipient_phone" description:"收件人电话,首次提交后锁定"`
RecipientAddress string `json:"recipient_address" description:"收货地址,首次提交后锁定,客户不可修改"`
MigrateData bool `json:"migrate_data" description:"是否执行全量迁移;风险换卡固定 false发货选新资产时仍由后台按既有流程决定"`
MigrationStatus string `json:"migration_status" enums:"not_migrated,pending,migrated,failed" description:"业务数据迁移状态 (not_migrated:不迁移, pending:待迁移, migrated:已迁移, failed:迁移失败);迁移结果以本字段为准"`
MigrationStatusName string `json:"migration_status_name" description:"业务数据迁移状态名称(中文)"`
ExchangeReason string `json:"exchange_reason" description:"换货原因"`
CreatedAt time.Time `json:"created_at" description:"创建时间ISO 8601"`
}

View File

@@ -11,7 +11,7 @@ type NotificationUnreadCountResponse struct {
// NotificationListRequest 是后台通知基础分页参数。
type NotificationListRequest struct {
Category string `json:"category" query:"category" validate:"omitempty,oneof=approval expiry sync system" enums:"approval,expiry,sync,system" description:"通知类别 (approval:审批, expiry:临期, sync:同步, system:系统)"`
Type string `json:"type" query:"type" validate:"omitempty,oneof=system.notice package.expiring agent.recharge.completed refund.completed exchange.shipping.created agent.main_wallet.low_balance" enums:"system.notice,package.expiring,agent.recharge.completed,refund.completed,exchange.shipping.created,agent.main_wallet.low_balance" description:"稳定通知类型 (system.notice:系统通知, package.expiring:套餐临期, agent.recharge.completed:店铺充值入账, refund.completed:店铺退款完成, exchange.shipping.created:换货申请待处理, agent.main_wallet.low_balance:主钱包低余额)"`
Type string `json:"type" query:"type" validate:"omitempty,oneof=system.notice package.expiring agent.recharge.completed refund.completed exchange.shipping.created agent.main_wallet.low_balance h5.popup.risk_exchange h5.popup.operation" enums:"system.notice,package.expiring,agent.recharge.completed,refund.completed,exchange.shipping.created,agent.main_wallet.low_balance,h5.popup.risk_exchange,h5.popup.operation" description:"稳定通知类型 (system.notice:系统通知, package.expiring:套餐临期, agent.recharge.completed:店铺充值入账, refund.completed:店铺退款完成, exchange.shipping.created:换货申请待处理, agent.main_wallet.low_balance:主钱包低余额, h5.popup.risk_exchange:风险换卡弹窗, h5.popup.operation:运营弹窗)"`
Severity string `json:"severity" query:"severity" validate:"omitempty,oneof=info warning error critical" enums:"info,warning,error,critical" description:"通知级别 (info:提示, warning:警告, error:错误, critical:严重)"`
IsRead *bool `json:"is_read" query:"is_read" description:"已读状态;不传时查询全部"`
Page int `json:"page" query:"page" validate:"omitempty,min=1,max=10000" minimum:"1" maximum:"10000" description:"页码,默认 1最大 10000"`
@@ -22,7 +22,7 @@ type NotificationListRequest struct {
type NotificationItem struct {
ID uint `json:"id" description:"通知ID"`
Category string `json:"category" enums:"approval,expiry,sync,system" description:"通知类别 (approval:审批, expiry:临期, sync:同步, system:系统)"`
Type string `json:"type" enums:"system.notice,package.expiring,agent.recharge.completed,refund.completed,exchange.shipping.created,agent.main_wallet.low_balance" description:"稳定通知类型 (system.notice:系统通知, package.expiring:套餐临期, agent.recharge.completed:店铺充值入账, refund.completed:店铺退款完成, exchange.shipping.created:换货申请待处理, agent.main_wallet.low_balance:主钱包低余额)"`
Type string `json:"type" enums:"system.notice,package.expiring,agent.recharge.completed,refund.completed,exchange.shipping.created,agent.main_wallet.low_balance,h5.popup.risk_exchange,h5.popup.operation" description:"稳定通知类型 (system.notice:系统通知, package.expiring:套餐临期, agent.recharge.completed:店铺充值入账, refund.completed:店铺退款完成, exchange.shipping.created:换货申请待处理, agent.main_wallet.low_balance:主钱包低余额, h5.popup.risk_exchange:风险换卡弹窗, h5.popup.operation:运营弹窗)"`
Severity string `json:"severity" enums:"info,warning,error,critical" description:"通知级别 (info:提示, warning:警告, error:错误, critical:严重)"`
Title string `json:"title" description:"纯文本标题"`
Body string `json:"body" description:"纯文本正文"`
@@ -32,6 +32,17 @@ type NotificationItem struct {
IsRead bool `json:"is_read" description:"是否已读"`
ReadAt *time.Time `json:"read_at,omitempty" description:"首次已读时间ISO 8601"`
CreatedAt time.Time `json:"created_at" description:"创建时间ISO 8601"`
// PopupSnapshot 仅弹窗投放类型返回;其他通知类型为空。只包含配置标识、资产关联与受控动作,不含任何 URL 或前端路由。
PopupSnapshot *NotificationPopupSnapshotItem `json:"popup_snapshot,omitempty" description:"弹窗投放快照;仅 h5.popup.risk_exchange 与 h5.popup.operation 返回,其他通知类型为空"`
}
// NotificationPopupSnapshotItem 是弹窗投放通知冻结的快照投影,不含任何 URL 或前端路由。
type NotificationPopupSnapshotItem struct {
ConfigID uint `json:"config_id" description:"运营弹窗配置ID风险换卡弹窗没有配置固定为 0"`
ConfigVersion int64 `json:"config_version" description:"投放时的配置版本;风险换卡弹窗固定为 0。旧版本通知保留原快照不被改写"`
AssetType string `json:"asset_type" enums:"iot_card,device" description:"弹窗关联资产类型 (iot_card:物联网卡, device:设备)"`
AssetID uint `json:"asset_id" description:"弹窗关联资产数字ID"`
ActionType string `json:"action_type,omitempty" enums:"package_purchase,asset_wallet_recharge" description:"受控动作 (package_purchase:套餐购买, asset_wallet_recharge:资产钱包充值);为空表示无受控动作。前端按白名单映射页面,不得由后端下发 URL 或前端路由"`
}
// NotificationListResponse 是后台通知基础分页结果。

View File

@@ -0,0 +1,69 @@
package model
import (
"database/sql/driver"
"time"
"github.com/bytedance/sonic"
)
// H5PopupConfiguration 是 H5 运营弹窗全局配置的 PostgreSQL 持久化事实。
// 配置只决定后续投放:启停与有效期不删除行,停用走 Enabled 置 0
// Version 是配置版本而非乐观锁,更新事务内递增并参与频率去重键,旧版本通知保留原快照不被改写。
// 范围四维统一用 JSONB 字符串数组:空数组表示该维度未配置即全量,已配置而资产该维度无值时不命中。
type H5PopupConfiguration struct {
ID uint `gorm:"column:id;primaryKey;autoIncrement" json:"id"`
Title string `gorm:"column:title;type:varchar(100);not null" json:"title"`
Content string `gorm:"column:content;type:varchar(2000);not null" json:"content"`
Pages StringJSONBArray `gorm:"column:pages;type:jsonb;not null;default:'[]'" json:"pages"`
ShopIDs StringJSONBArray `gorm:"column:shop_ids;type:jsonb;not null;default:'[]'" json:"shop_ids"`
DeviceTypes StringJSONBArray `gorm:"column:device_types;type:jsonb;not null;default:'[]'" json:"device_types"`
CardTypes StringJSONBArray `gorm:"column:card_types;type:jsonb;not null;default:'[]'" json:"card_types"`
Priority int `gorm:"column:priority;type:integer;not null;default:0" json:"priority"`
Frequency string `gorm:"column:frequency;type:varchar(20);not null;default:'once'" json:"frequency"`
ActionType string `gorm:"column:action_type;type:varchar(40);not null;default:''" json:"action_type"`
Enabled int `gorm:"column:enabled;type:smallint;not null;default:0" json:"enabled"`
StartsAt time.Time `gorm:"column:starts_at;type:timestamptz;not null" json:"starts_at"`
EndsAt time.Time `gorm:"column:ends_at;type:timestamptz;not null" json:"ends_at"`
Version int64 `gorm:"column:version;type:bigint;not null;default:1" json:"version"`
BaseModel `gorm:"embedded"`
CreatedAt time.Time `gorm:"column:created_at;type:timestamptz;not null;autoCreateTime" json:"created_at"`
UpdatedAt time.Time `gorm:"column:updated_at;type:timestamptz;not null;autoUpdateTime" json:"updated_at"`
}
// TableName 返回 H5 运营弹窗配置表名。
func (H5PopupConfiguration) TableName() string {
return "tb_h5_popup_configuration"
}
// NotificationPopupSnapshot 是弹窗投放通知冻结的投放快照,作为可空 JSONB 写入 tb_notification。
// 只承载配置标识与受控动作,不含任何 URL 或前端路由风险换卡弹窗没有配置ConfigID 与 ConfigVersion 为 0。
type NotificationPopupSnapshot struct {
ConfigID uint `json:"config_id"`
ConfigVersion int64 `json:"config_version"`
AssetType string `json:"asset_type"`
AssetID uint `json:"asset_id"`
ActionType string `json:"action_type"`
}
// Value 将弹窗快照序列化为 JSONBnil 接收者写入 NULL供非弹窗通知保持该列为空。
func (s *NotificationPopupSnapshot) Value() (driver.Value, error) {
if s == nil {
return nil, nil
}
return sonic.Marshal(s)
}
// Scan 从 JSONB 读取弹窗快照;数据库 NULL 时清空快照。
func (s *NotificationPopupSnapshot) Scan(value any) error {
if value == nil {
*s = NotificationPopupSnapshot{}
return nil
}
data, ok := value.([]byte)
if !ok {
*s = NotificationPopupSnapshot{}
return nil
}
return sonic.Unmarshal(data, s)
}

View File

@@ -20,6 +20,8 @@ type Notification struct {
ReadAt *time.Time `gorm:"column:read_at;type:timestamptz" json:"read_at,omitempty"`
ExpiresAt *time.Time `gorm:"column:expires_at;type:timestamptz" json:"expires_at,omitempty"`
CreatedAt time.Time `gorm:"column:created_at;type:timestamptz;not null;autoCreateTime" json:"created_at"`
// PopupSnapshot 只由弹窗投放类型h5.popup.risk_exchange、h5.popup.operation写入其他通知类型保持 NULL。
PopupSnapshot *NotificationPopupSnapshot `gorm:"column:popup_snapshot;type:jsonb" json:"popup_snapshot,omitempty"`
}
// TableName 返回站内通知表名。

View File

@@ -0,0 +1,126 @@
// Package h5popup 提供 H5 运营弹窗配置的后台只读投影。
// 该投影只读,不修改任何状态;启停与版本递增由 application 层写用例完成。
package h5popup
import (
"context"
"strconv"
"gorm.io/gorm"
"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"
)
// Query 提供 H5 运营弹窗配置的后台列表与详情查询。
type Query struct {
db *gorm.DB
}
// NewQuery 创建 H5 运营弹窗配置查询。
func NewQuery(db *gorm.DB) *Query {
return &Query{db: db}
}
// List 按最近更新时间倒序分页查询运营弹窗配置。
func (q *Query) List(ctx context.Context, request dto.H5PopupConfigurationListRequest) (*dto.H5PopupConfigurationListResponse, error) {
page, pageSize, offset, err := normalizePagination(request.Page, request.PageSize)
if err != nil {
return nil, err
}
base := q.db.WithContext(ctx).Model(&model.H5PopupConfiguration{})
if request.Enabled != nil {
status := constants.H5PopupStatusDisabled
if *request.Enabled {
status = constants.H5PopupStatusEnabled
}
base = base.Where("enabled = ?", status)
}
var total int64
if err := base.Count(&total).Error; err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询运营弹窗配置总数失败")
}
var records []model.H5PopupConfiguration
if err := base.Order("updated_at DESC, id DESC").Offset(offset).Limit(pageSize).Find(&records).Error; err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询运营弹窗配置列表失败")
}
items := make([]dto.H5PopupConfigurationResponse, 0, len(records))
for index := range records {
items = append(items, *toConfigurationResponse(&records[index]))
}
return &dto.H5PopupConfigurationListResponse{Items: items, Total: total, Page: page, Size: pageSize}, nil
}
// Get 按 ID 查询运营弹窗配置详情。
func (q *Query) Get(ctx context.Context, id uint) (*dto.H5PopupConfigurationResponse, error) {
if id == 0 {
return nil, errors.New(errors.CodeH5PopupConfigurationNotFound)
}
var record model.H5PopupConfiguration
if err := q.db.WithContext(ctx).Where("id = ?", id).Take(&record).Error; err != nil {
if err == gorm.ErrRecordNotFound {
return nil, errors.New(errors.CodeH5PopupConfigurationNotFound)
}
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询运营弹窗配置失败")
}
return toConfigurationResponse(&record), nil
}
// normalizePagination 归一化页码与每页数量并返回偏移量。
func normalizePagination(page, pageSize int) (int, int, int, error) {
if page <= 0 {
page = 1
}
if page > constants.NotificationMaxPage {
return 0, 0, 0, errors.New(errors.CodeInvalidParam, "页码超出允许范围")
}
if pageSize <= 0 {
pageSize = constants.DefaultPageSize
}
if pageSize > constants.MaxPageSize {
return 0, 0, 0, errors.New(errors.CodeInvalidParam, "每页数量超出允许范围")
}
return page, pageSize, (page - 1) * pageSize, nil
}
// toConfigurationResponse 将配置投影为对外响应,范围维度空数组表示全量。
func toConfigurationResponse(record *model.H5PopupConfiguration) *dto.H5PopupConfigurationResponse {
if record == nil {
return nil
}
return &dto.H5PopupConfigurationResponse{
ID: record.ID, Title: record.Title, Content: record.Content,
Pages: append([]string{}, record.Pages...),
ShopIDs: toShopIDs(record.ShopIDs),
DeviceTypes: append([]string{}, record.DeviceTypes...),
CardTypes: append([]string{}, record.CardTypes...),
Priority: record.Priority,
Frequency: record.Frequency,
FrequencyText: constants.GetH5PopupFrequencyName(record.Frequency),
Enabled: record.Enabled == constants.H5PopupStatusEnabled,
EnabledText: constants.GetH5PopupEnabledName(record.Enabled),
ActionType: record.ActionType,
StartsAt: record.StartsAt,
EndsAt: record.EndsAt,
Version: record.Version,
CreatedAt: record.CreatedAt,
UpdatedAt: record.UpdatedAt,
Creator: record.Creator,
Updater: record.Updater,
}
}
// toShopIDs 将 JSONB 文本数组还原为店铺 ID 列表;非法项在写入侧已被拒绝,这里按跳过处理保证读取可用。
func toShopIDs(values model.StringJSONBArray) []uint {
shopIDs := make([]uint, 0, len(values))
for _, value := range values {
parsed, err := strconv.ParseUint(value, 10, 64)
if err != nil || parsed == 0 {
continue
}
shopIDs = append(shopIDs, uint(parsed))
}
return shopIDs
}

View File

@@ -79,15 +79,9 @@ func (q *Query) List(ctx context.Context, recipientID uint, request dto.Notifica
if err := base.Order("created_at DESC, id DESC").Offset(offset).Limit(pageSize).Find(&records).Error; err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询通知列表失败")
}
items := make([]dto.NotificationItem, 0, len(records))
for _, record := range records {
items = append(items, dto.NotificationItem{
ID: record.ID, Category: record.Category, Type: record.Type, Severity: record.Severity,
Title: record.Title, Body: record.Body, RefType: record.RefType, RefID: record.RefID,
RefKey: record.RefKey, IsRead: record.IsRead, ReadAt: record.ReadAt, CreatedAt: record.CreatedAt,
})
}
return &dto.NotificationListResponse{Items: items, Total: total, Page: page, Size: pageSize}, nil
return &dto.NotificationListResponse{
Items: notificationItems(records), Total: total, Page: page, Size: pageSize,
}, nil
}
// PersonalUnreadCount 查询当前个人客户可见业务通知的准确未读数。
@@ -198,7 +192,12 @@ func personalNotificationScope(db *gorm.DB, customerID uint, now time.Time) *gor
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,
)
}
@@ -218,7 +217,23 @@ func notificationItems(records []model.Notification) []dto.NotificationItem {
ID: record.ID, Category: record.Category, Type: record.Type, Severity: record.Severity,
Title: record.Title, Body: record.Body, RefType: record.RefType, RefID: record.RefID,
RefKey: record.RefKey, IsRead: record.IsRead, ReadAt: record.ReadAt, CreatedAt: record.CreatedAt,
PopupSnapshot: popupSnapshotItem(record),
})
}
return items
}
// popupSnapshotItem 只对弹窗投放类型投影投放快照,其他类型(含历史数据)一律为空。
// 快照只含配置标识、资产关联与受控动作,不含任何 URL 或前端路由。
func popupSnapshotItem(record model.Notification) *dto.NotificationPopupSnapshotItem {
if !constants.IsH5PopupNotificationType(record.Type) || record.PopupSnapshot == nil {
return nil
}
return &dto.NotificationPopupSnapshotItem{
ConfigID: record.PopupSnapshot.ConfigID,
ConfigVersion: record.PopupSnapshot.ConfigVersion,
AssetType: record.PopupSnapshot.AssetType,
AssetID: record.PopupSnapshot.AssetID,
ActionType: record.PopupSnapshot.ActionType,
}
}

View File

@@ -76,6 +76,9 @@ func RegisterAdminRoutes(router fiber.Router, handlers *bootstrap.Handlers, midd
if handlers.Notification != nil {
registerNotificationRoutes(authGroup, handlers.Notification, doc, basePath)
}
if handlers.H5PopupConfiguration != nil {
registerH5PopupConfigurationRoutes(authGroup, handlers.H5PopupConfiguration, doc, basePath)
}
if handlers.Device != nil {
registerDeviceRoutes(authGroup, handlers.Device, handlers.DeviceImport, doc, basePath)
}

View File

@@ -0,0 +1,72 @@
package routes
import (
"github.com/gofiber/fiber/v2"
"github.com/break/junhong_cmp_fiber/internal/handler/admin"
"github.com/break/junhong_cmp_fiber/internal/model/dto"
"github.com/break/junhong_cmp_fiber/pkg/openapi"
)
// registerH5PopupConfigurationRoutes 注册 H5 运营弹窗配置的维护路由。
// 全部接口仅超级管理员与平台账号可用,代理与企业统一返回 403。
func registerH5PopupConfigurationRoutes(router fiber.Router, handler *admin.H5PopupConfigurationHandler, doc *openapi.Generator, basePath string) {
configurations := router.Group("/h5-popup-configurations")
groupPath := basePath + "/h5-popup-configurations"
Register(configurations, doc, groupPath, "GET", "", handler.ListH5PopupConfigurations, RouteSpec{
Summary: "查询运营弹窗配置列表",
Description: "按最近更新时间倒序分页返回运营弹窗配置,可按启停筛选。",
Tags: []string{"H5 运营弹窗配置"},
Auth: true,
Input: new(dto.H5PopupConfigurationListRequest),
Output: new(dto.H5PopupConfigurationListResponse),
})
Register(configurations, doc, groupPath, "POST", "", handler.CreateH5PopupConfiguration, RouteSpec{
Summary: "创建运营弹窗配置",
Description: "创建全局运营弹窗配置,初始版本为 1。范围同一维度多选取任一命中未配置范围即全量" +
"页面必选;受控动作只允许套餐购买或资产钱包充值,不接受 URL、前端路由或任意动作结束时间不得早于开始时间。",
Tags: []string{"H5 运营弹窗配置"},
Auth: true,
Input: new(dto.CreateH5PopupConfigurationRequest),
Output: new(dto.H5PopupConfigurationResponse),
})
Register(configurations, doc, groupPath, "GET", "/:id", handler.GetH5PopupConfiguration, RouteSpec{
Summary: "查询运营弹窗配置详情",
Tags: []string{"H5 运营弹窗配置"},
Auth: true,
Input: new(dto.H5PopupConfigurationIDParams),
Output: new(dto.H5PopupConfigurationResponse),
})
Register(configurations, doc, groupPath, "PUT", "/:id", handler.UpdateH5PopupConfiguration, RouteSpec{
Summary: "更新运营弹窗配置",
Description: "更新运营弹窗配置并递增版本;旧版本已投放通知的内容与快照不被改写," +
"新版本可向原命中客户按频率重新投放一次。",
Tags: []string{"H5 运营弹窗配置"},
Auth: true,
Input: new(dto.UpdateH5PopupConfigurationParams),
Output: new(dto.H5PopupConfigurationResponse),
})
// 静态后缀必须先于 /:id 动态路径注册,避免被动态参数吞掉。
Register(configurations, doc, groupPath, "POST", "/:id/enable", handler.EnableH5PopupConfiguration, RouteSpec{
Summary: "启用运营弹窗配置",
Description: "启用后参与候选匹配,并刷新最近更新时间,影响同优先级排序。",
Tags: []string{"H5 运营弹窗配置"},
Auth: true,
Input: new(dto.H5PopupConfigurationIDParams),
Output: new(dto.H5PopupConfigurationResponse),
})
Register(configurations, doc, groupPath, "POST", "/:id/disable", handler.DisableH5PopupConfiguration, RouteSpec{
Summary: "停用运营弹窗配置",
Description: "停用后停止新投放,并刷新最近更新时间;历史通知在展示期内仍可见。",
Tags: []string{"H5 运营弹窗配置"},
Auth: true,
Input: new(dto.H5PopupConfigurationIDParams),
Output: new(dto.H5PopupConfigurationResponse),
})
}

View File

@@ -121,6 +121,9 @@ func RegisterPersonalCustomerRoutes(router fiber.Router, doc *openapi.Generator,
if handlers.ClientNotification != nil {
registerPersonalNotificationRoutes(authGroup, handlers.ClientNotification, doc, basePath)
}
if handlers.ClientPopup != nil {
registerPersonalPopupRoutes(authGroup, handlers.ClientPopup, doc, basePath)
}
// 获取个人资料
Register(authGroup, doc, basePath, "GET", "/profile", handlers.PersonalCustomer.GetProfile, RouteSpec{

View File

@@ -0,0 +1,35 @@
package routes
import (
"github.com/gofiber/fiber/v2"
apphandler "github.com/break/junhong_cmp_fiber/internal/handler/app"
"github.com/break/junhong_cmp_fiber/internal/model/dto"
"github.com/break/junhong_cmp_fiber/pkg/openapi"
)
// registerPersonalPopupRoutes 注册当前个人客户的 H5 弹窗候选与风险换卡地址路由。
// 候选查询是产品契约中的带副作用 GET命中时会创建或复用未读通知客户端随后走既有已读接口。
func registerPersonalPopupRoutes(router fiber.Router, handler *apphandler.ClientPopupHandler, doc *openapi.Generator, basePath string) {
Register(router, doc, basePath, "GET", "/popup-candidates", handler.GetCandidates, RouteSpec{
Summary: "查询当前页面的弹窗候选",
Description: "按当前页面与当前资产实时匹配弹窗:先判风险换卡资格,命中只返回风险候选;未命中再按时间、启停、页面、店铺、设备类型、卡类型范围与频率匹配运营配置,只返回优先级最高一条。" +
"该查询会创建或复用个人站内通知并保持未读(不预生成通知),关闭或稍后处理请用返回的 notification_id 调用 PUT /api/c/v1/notifications/{id}/read。" +
"资产不属于当前客户或资产不存在统一返回资源不可见。响应只返回受控动作,不含任何 URL 或前端路由。",
Tags: []string{"个人客户 - 弹窗"},
Auth: true,
Input: &dto.PopupCandidateRequest{},
Output: &dto.PopupCandidateResponse{},
})
Register(router, doc, basePath, "POST", "/risk-exchanges/:asset_id/address", handler.SubmitRiskAddress, RouteSpec{
Summary: "提交风险换卡收货地址",
Description: "当前个人客户为本人风险停机资产提交收货人姓名、手机号与完整地址,幂等创建关联旧资产的物流换货单。" +
"首次地址锁定,重复或并发提交返回首次创建的换货单与首次地址且不覆盖;换货单创建即待发货,不预设业务数据迁移。" +
"资产不属于当前客户或资产不存在统一返回资源不可见。",
Tags: []string{"个人客户 - 弹窗"},
Auth: true,
Input: &dto.ClientRiskExchangeAddressParams{},
Output: &dto.ClientRiskExchangeResponse{},
})
}