feat(H5弹窗): AUG26-007 风险换卡与运营弹窗投放通知
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 9m23s
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 9m23s
新增 000225 迁移:运营弹窗配置表 tb_h5_popup_configuration(页面/范围/优先级/频率/受控动作/启停/有效期/版本) 与 tb_notification 可空 JSONB 列 popup_snapshot。 新增通知直建窄接口 DirectWriter.CreateOrGetPersonal:与 Outbox 消费共用 prepareDelivery 的渲染、 展示期与 CreateIdempotent 规则,冲突时回查返回既有行;同步扩展个人通知查询与已读两处类型白名单, 并按个人客户入口补齐投递审计来源。 新增 H5 候选与风险换卡:GET /api/c/v1/popup-candidates 先判风险资格(广电卡 + 风险停机 + 无活动物流换货单),命中只返回风险候选;未命中再按时间/启停/页面/店铺/设备类型/卡类型范围/频率 匹配运营配置。POST /api/c/v1/risk-exchanges/:asset_id/address 锁资产行后幂等创建待发货物流换货单, 首次地址锁定,不沿用资产级群发通知。 新增后台运营弹窗配置 CRUD 与启停(仅超级管理员与平台账号),更新递增版本并刷新最近更新时间, 标题与正文统一拒绝 URL 与前端路由,全部写操作记录操作者、前后值、版本与时间。 同步 OpenAPI(cmd/gendocs、cmd/api/docs.go、pkg/openapi/handlers.go)与参数校验中文提示共用实现。
This commit is contained in:
171
internal/application/h5popup/asset.go
Normal file
171
internal/application/h5popup/asset.go
Normal 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
|
||||
}
|
||||
305
internal/application/h5popup/candidate.go
Normal file
305
internal/application/h5popup/candidate.go
Normal 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
|
||||
}
|
||||
490
internal/application/h5popup/configuration.go
Normal file
490
internal/application/h5popup/configuration.go
Normal file
@@ -0,0 +1,490 @@
|
||||
package h5popup
|
||||
|
||||
import (
|
||||
"context"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
|
||||
systemconfigapp "github.com/break/junhong_cmp_fiber/internal/application/systemconfig"
|
||||
"github.com/break/junhong_cmp_fiber/internal/infrastructure/audit"
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
"github.com/break/junhong_cmp_fiber/internal/model/dto"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/errors"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/middleware"
|
||||
)
|
||||
|
||||
var (
|
||||
// popupURLPattern 匹配任意 URL 形态:带协议的绝对地址、www 前缀或站点域名。
|
||||
// 弹窗只允许受控动作,前端按 action_type 白名单映射页面,不接受运营配置下发跳转目标。
|
||||
popupURLPattern = regexp.MustCompile(`(?i)([a-z][a-z0-9+.\-]*://|www\.|\.(com|cn|net|org)(/|$|\s))`)
|
||||
// popupRoutePattern 匹配前端路由形态:以 / 开头的路径片段或 /#/ 哈希路由。
|
||||
popupRoutePattern = regexp.MustCompile(`(^|[\s((])/[A-Za-z#]`)
|
||||
)
|
||||
|
||||
// ConfigurationService 维护 H5 运营弹窗配置。
|
||||
// 配置只决定后续投放:更新在事务内递增版本,启停只改启停位并刷新最近更新时间,两者都记录前后值与版本。
|
||||
type ConfigurationService struct {
|
||||
db *gorm.DB
|
||||
audit *audit.Writer
|
||||
}
|
||||
|
||||
// NewConfigurationService 创建运营弹窗配置事务脚本。
|
||||
func NewConfigurationService(db *gorm.DB, audit *audit.Writer) *ConfigurationService {
|
||||
return &ConfigurationService{db: db, audit: audit}
|
||||
}
|
||||
|
||||
// configurationInput 是校验后的配置值,创建与更新共用同一套归一化规则。
|
||||
type configurationInput struct {
|
||||
Title string
|
||||
Content string
|
||||
Pages []string
|
||||
ShopIDs []uint
|
||||
DeviceTypes []string
|
||||
CardTypes []string
|
||||
Priority int
|
||||
Frequency string
|
||||
ActionType string
|
||||
Enabled int
|
||||
StartsAt time.Time
|
||||
EndsAt time.Time
|
||||
}
|
||||
|
||||
// Create 创建运营弹窗配置,初始版本为 1,并在同一事务内写入配置审计。
|
||||
func (s *ConfigurationService) Create(ctx context.Context, request dto.CreateH5PopupConfigurationRequest) (uint, error) {
|
||||
operatorID, err := requirePlatformOperator(ctx)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if err = s.ensureConfigured(); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
enabled := constants.H5PopupStatusDisabled
|
||||
if request.Enabled != nil && *request.Enabled {
|
||||
enabled = constants.H5PopupStatusEnabled
|
||||
}
|
||||
priority := 0
|
||||
if request.Priority != nil {
|
||||
priority = *request.Priority
|
||||
}
|
||||
actionType := ""
|
||||
if request.ActionType != nil {
|
||||
actionType = *request.ActionType
|
||||
}
|
||||
normalized, err := normalizeConfigurationInput(configurationInput{
|
||||
Title: request.Title, Content: request.Content, Pages: request.Pages,
|
||||
ShopIDs: request.ShopIDs, DeviceTypes: request.DeviceTypes, CardTypes: request.CardTypes,
|
||||
Priority: priority, Frequency: request.Frequency, ActionType: actionType,
|
||||
Enabled: enabled, StartsAt: request.StartsAt, EndsAt: request.EndsAt,
|
||||
})
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
record := &model.H5PopupConfiguration{
|
||||
Title: normalized.Title, Content: normalized.Content,
|
||||
Pages: model.StringJSONBArray(normalized.Pages), ShopIDs: toJSONBStrings(normalized.ShopIDs),
|
||||
DeviceTypes: model.StringJSONBArray(normalized.DeviceTypes), CardTypes: model.StringJSONBArray(normalized.CardTypes),
|
||||
Priority: normalized.Priority, Frequency: normalized.Frequency, ActionType: normalized.ActionType,
|
||||
Enabled: normalized.Enabled, StartsAt: normalized.StartsAt, EndsAt: normalized.EndsAt,
|
||||
Version: 1, BaseModel: model.BaseModel{Creator: operatorID, Updater: operatorID},
|
||||
CreatedAt: now, UpdatedAt: now,
|
||||
}
|
||||
err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.WithContext(ctx).Create(record).Error; err != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "创建运营弹窗配置失败")
|
||||
}
|
||||
return s.audit.WriteConfigChange(ctx, tx, systemconfigapp.ChangeAudit{
|
||||
OperatorID: operatorID, OperationType: constants.AuditOperationH5PopupConfigurationCreate,
|
||||
Description: "创建运营弹窗配置", ConfigKey: configurationAuditKey(record.ID),
|
||||
Module: constants.H5PopupAuditModule, ResourceID: configurationAuditResourceID(record.ID),
|
||||
DisplayName: record.Title, Identity: configurationAuditIdentity(record),
|
||||
AfterData: configurationAuditSnapshot(record), Result: constants.AuditResultSuccess,
|
||||
})
|
||||
})
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return record.ID, nil
|
||||
}
|
||||
|
||||
// Update 更新运营弹窗配置:合并入参后整体校验,事务内递增版本并刷新最近更新时间。
|
||||
// 旧版本已投放通知的内容与快照不被改写,新版本可向原命中客户按频率重新投放。
|
||||
func (s *ConfigurationService) Update(ctx context.Context, id uint, request dto.UpdateH5PopupConfigurationRequest) error {
|
||||
operatorID, err := requirePlatformOperator(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err = s.ensureConfigured(); err != nil {
|
||||
return err
|
||||
}
|
||||
if id == 0 {
|
||||
return errors.New(errors.CodeH5PopupConfigurationNotFound)
|
||||
}
|
||||
return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
record, err := lockConfiguration(ctx, tx, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
before := *record
|
||||
beforeData := configurationAuditSnapshot(&before)
|
||||
|
||||
merged := configurationInput{
|
||||
Title: record.Title, Content: record.Content, Pages: storePages(record),
|
||||
ShopIDs: storeShopIDs(record), DeviceTypes: storeDeviceTypes(record), CardTypes: storeCardTypes(record),
|
||||
Priority: record.Priority, Frequency: record.Frequency, ActionType: record.ActionType,
|
||||
Enabled: record.Enabled, StartsAt: record.StartsAt, EndsAt: record.EndsAt,
|
||||
}
|
||||
if request.Title != nil {
|
||||
merged.Title = *request.Title
|
||||
}
|
||||
if request.Content != nil {
|
||||
merged.Content = *request.Content
|
||||
}
|
||||
if request.Pages != nil {
|
||||
merged.Pages = *request.Pages
|
||||
}
|
||||
if request.ShopIDs != nil {
|
||||
merged.ShopIDs = *request.ShopIDs
|
||||
}
|
||||
if request.DeviceTypes != nil {
|
||||
merged.DeviceTypes = *request.DeviceTypes
|
||||
}
|
||||
if request.CardTypes != nil {
|
||||
merged.CardTypes = *request.CardTypes
|
||||
}
|
||||
if request.Priority != nil {
|
||||
merged.Priority = *request.Priority
|
||||
}
|
||||
if request.Frequency != nil {
|
||||
merged.Frequency = *request.Frequency
|
||||
}
|
||||
if request.ActionType != nil {
|
||||
merged.ActionType = *request.ActionType
|
||||
}
|
||||
if request.Enabled != nil {
|
||||
merged.Enabled = enabledStatus(*request.Enabled)
|
||||
}
|
||||
if request.StartsAt != nil {
|
||||
merged.StartsAt = *request.StartsAt
|
||||
}
|
||||
if request.EndsAt != nil {
|
||||
merged.EndsAt = *request.EndsAt
|
||||
}
|
||||
normalized, err := normalizeConfigurationInput(merged)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
record.Title = normalized.Title
|
||||
record.Content = normalized.Content
|
||||
record.Pages = model.StringJSONBArray(normalized.Pages)
|
||||
record.ShopIDs = toJSONBStrings(normalized.ShopIDs)
|
||||
record.DeviceTypes = model.StringJSONBArray(normalized.DeviceTypes)
|
||||
record.CardTypes = model.StringJSONBArray(normalized.CardTypes)
|
||||
record.Priority = normalized.Priority
|
||||
record.Frequency = normalized.Frequency
|
||||
record.ActionType = normalized.ActionType
|
||||
record.Enabled = normalized.Enabled
|
||||
record.StartsAt = normalized.StartsAt
|
||||
record.EndsAt = normalized.EndsAt
|
||||
record.Version++
|
||||
record.Updater = operatorID
|
||||
record.UpdatedAt = now
|
||||
if err := tx.WithContext(ctx).Save(record).Error; err != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "更新运营弹窗配置失败")
|
||||
}
|
||||
if err := s.audit.WriteConfigChange(ctx, tx, systemconfigapp.ChangeAudit{
|
||||
OperatorID: operatorID, OperationType: constants.AuditOperationH5PopupConfigurationUpdate,
|
||||
Description: "更新运营弹窗配置", ConfigKey: configurationAuditKey(record.ID),
|
||||
Module: constants.H5PopupAuditModule, ResourceID: configurationAuditResourceID(record.ID),
|
||||
DisplayName: record.Title, Identity: configurationAuditIdentity(record),
|
||||
BeforeData: beforeData, AfterData: configurationAuditSnapshot(record), Result: constants.AuditResultSuccess,
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// SetEnabled 启停运营弹窗配置,只影响后续候选,并必须刷新最近更新时间。
|
||||
// 启停不递增版本:版本表达配置内容变化,频率去重键因此保持不变,已投放通知不会被再次投放。
|
||||
func (s *ConfigurationService) SetEnabled(ctx context.Context, id uint, enabled bool) error {
|
||||
operatorID, err := requirePlatformOperator(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err = s.ensureConfigured(); err != nil {
|
||||
return err
|
||||
}
|
||||
if id == 0 {
|
||||
return errors.New(errors.CodeH5PopupConfigurationNotFound)
|
||||
}
|
||||
operationType := constants.AuditOperationH5PopupConfigurationDisable
|
||||
description := "停用运营弹窗配置"
|
||||
if enabled {
|
||||
operationType = constants.AuditOperationH5PopupConfigurationEnable
|
||||
description = "启用运营弹窗配置"
|
||||
}
|
||||
return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
record, err := lockConfiguration(ctx, tx, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
beforeData := configurationAuditSnapshot(record)
|
||||
now := time.Now().UTC()
|
||||
record.Enabled = enabledStatus(enabled)
|
||||
record.Updater = operatorID
|
||||
record.UpdatedAt = now
|
||||
if err := tx.WithContext(ctx).Save(record).Error; err != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "更新运营弹窗配置启停失败")
|
||||
}
|
||||
if err := s.audit.WriteConfigChange(ctx, tx, systemconfigapp.ChangeAudit{
|
||||
OperatorID: operatorID, OperationType: operationType,
|
||||
Description: description, ConfigKey: configurationAuditKey(record.ID),
|
||||
Module: constants.H5PopupAuditModule, ResourceID: configurationAuditResourceID(record.ID),
|
||||
DisplayName: record.Title, Identity: configurationAuditIdentity(record),
|
||||
BeforeData: beforeData, AfterData: configurationAuditSnapshot(record), Result: constants.AuditResultSuccess,
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func (s *ConfigurationService) ensureConfigured() error {
|
||||
if s == nil || s.db == nil || s.audit == nil {
|
||||
return errors.New(errors.CodeServiceUnavailable, "运营弹窗配置维护能力尚未配置")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// requirePlatformOperator 校验当前调用者仅限超级管理员与平台账号,并返回其账号 ID。
|
||||
// 非上述身份与资源不存在返回同一禁止访问错误,避免形成可枚举差异。
|
||||
func requirePlatformOperator(ctx context.Context) (uint, error) {
|
||||
userType := middleware.GetUserTypeFromContext(ctx)
|
||||
if userType != constants.UserTypeSuperAdmin && userType != constants.UserTypePlatform {
|
||||
return 0, errors.New(errors.CodeForbidden, "无权限操作该资源或资源不存在")
|
||||
}
|
||||
operatorID := middleware.GetUserIDFromContext(ctx)
|
||||
if operatorID == 0 {
|
||||
return 0, errors.New(errors.CodeUnauthorized)
|
||||
}
|
||||
return operatorID, nil
|
||||
}
|
||||
|
||||
// lockConfiguration 以行锁读取运营弹窗配置,未找到返回稳定不存在错误。
|
||||
func lockConfiguration(ctx context.Context, tx *gorm.DB, id uint) (*model.H5PopupConfiguration, error) {
|
||||
var record model.H5PopupConfiguration
|
||||
err := tx.WithContext(ctx).Clauses(clause.Locking{Strength: "UPDATE"}).Where("id = ?", id).Take(&record).Error
|
||||
if err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return nil, errors.New(errors.CodeH5PopupConfigurationNotFound)
|
||||
}
|
||||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询运营弹窗配置失败")
|
||||
}
|
||||
return &record, nil
|
||||
}
|
||||
|
||||
// normalizeConfigurationInput 归一化并校验配置,创建与更新共用同一套规则。
|
||||
// 拒绝任意 URL 与前端路由是应用层第一道保险,通知渲染的 URL 拦截是第二道。
|
||||
func normalizeConfigurationInput(input configurationInput) (configurationInput, error) {
|
||||
normalized := input
|
||||
normalized.Title = strings.TrimSpace(input.Title)
|
||||
if runes := utf8.RuneCountInString(normalized.Title); runes < 1 || runes > 100 {
|
||||
return configurationInput{}, errors.New(errors.CodeInvalidParam, "运营弹窗标题长度必须在 1~100 字符之间")
|
||||
}
|
||||
// 标题与正文同一口径:两者都会冻结进通知并参与渲染,任一都不接受 URL 或前端路由。
|
||||
if popupURLPattern.MatchString(normalized.Title) || popupRoutePattern.MatchString(normalized.Title) {
|
||||
return configurationInput{}, errors.New(errors.CodeInvalidParam, "运营弹窗标题不接受 URL 或前端路由,只能使用受控动作")
|
||||
}
|
||||
normalized.Content = strings.TrimSpace(input.Content)
|
||||
if runes := utf8.RuneCountInString(normalized.Content); runes < 1 || runes > 2000 {
|
||||
return configurationInput{}, errors.New(errors.CodeInvalidParam, "运营弹窗正文长度必须在 1~2000 字符之间")
|
||||
}
|
||||
if popupURLPattern.MatchString(normalized.Content) || popupRoutePattern.MatchString(normalized.Content) {
|
||||
return configurationInput{}, errors.New(errors.CodeInvalidParam, "运营弹窗正文不接受 URL 或前端路由,只能使用受控动作")
|
||||
}
|
||||
normalized.Pages = dedupeStrings(input.Pages)
|
||||
if len(normalized.Pages) == 0 {
|
||||
return configurationInput{}, errors.New(errors.CodeInvalidParam, "运营弹窗至少需要一个命中页面")
|
||||
}
|
||||
if len(normalized.Pages) > 4 {
|
||||
return configurationInput{}, errors.New(errors.CodeInvalidParam, "运营弹窗命中页面超出受控范围")
|
||||
}
|
||||
for _, page := range normalized.Pages {
|
||||
if !constants.IsH5PopupPage(page) {
|
||||
return configurationInput{}, errors.New(errors.CodeInvalidParam, "运营弹窗命中页面不在受控白名单内")
|
||||
}
|
||||
}
|
||||
normalized.ShopIDs = dedupeShopIDs(input.ShopIDs)
|
||||
if len(normalized.ShopIDs) > 200 {
|
||||
return configurationInput{}, errors.New(errors.CodeInvalidParam, "运营弹窗店铺范围超过 200 项")
|
||||
}
|
||||
normalized.DeviceTypes = dedupeStrings(input.DeviceTypes)
|
||||
if len(normalized.DeviceTypes) > 100 {
|
||||
return configurationInput{}, errors.New(errors.CodeInvalidParam, "运营弹窗设备类型范围超过 100 项")
|
||||
}
|
||||
for _, deviceType := range normalized.DeviceTypes {
|
||||
if utf8.RuneCountInString(deviceType) > 50 {
|
||||
return configurationInput{}, errors.New(errors.CodeInvalidParam, "运营弹窗设备类型超过 50 字符")
|
||||
}
|
||||
}
|
||||
// 卡类型是与 tb_iot_card.carrier_type 直接比较的受控枚举,统一大写后再校验。
|
||||
normalized.CardTypes = dedupeStrings(upperStrings(input.CardTypes))
|
||||
if len(normalized.CardTypes) > 4 {
|
||||
return configurationInput{}, errors.New(errors.CodeInvalidParam, "运营弹窗卡类型范围超过受控取值数量")
|
||||
}
|
||||
for _, cardType := range normalized.CardTypes {
|
||||
if !constants.IsCarrierType(cardType) {
|
||||
return configurationInput{}, errors.New(errors.CodeInvalidParam, "运营弹窗卡类型不在受控白名单内")
|
||||
}
|
||||
}
|
||||
if normalized.Priority < 0 || normalized.Priority > 1000000 {
|
||||
return configurationInput{}, errors.New(errors.CodeInvalidParam, "运营弹窗优先级必须在 0~1000000 之间")
|
||||
}
|
||||
if !constants.IsH5PopupFrequency(normalized.Frequency) {
|
||||
return configurationInput{}, errors.New(errors.CodeInvalidParam, "运营弹窗投放频率不在受控白名单内")
|
||||
}
|
||||
if !constants.IsH5PopupActionType(normalized.ActionType) {
|
||||
return configurationInput{}, errors.New(errors.CodeInvalidParam, "运营弹窗受控动作不在受控白名单内")
|
||||
}
|
||||
if normalized.Enabled != constants.H5PopupStatusEnabled && normalized.Enabled != constants.H5PopupStatusDisabled {
|
||||
return configurationInput{}, errors.New(errors.CodeInvalidParam, "运营弹窗启停状态不合法")
|
||||
}
|
||||
if normalized.StartsAt.IsZero() || normalized.EndsAt.IsZero() {
|
||||
return configurationInput{}, errors.New(errors.CodeInvalidParam, "运营弹窗必须同时提供生效开始与结束时间")
|
||||
}
|
||||
normalized.StartsAt = normalized.StartsAt.UTC()
|
||||
normalized.EndsAt = normalized.EndsAt.UTC()
|
||||
if normalized.EndsAt.Before(normalized.StartsAt) {
|
||||
return configurationInput{}, errors.New(errors.CodeInvalidParam, "运营弹窗结束时间不得早于开始时间")
|
||||
}
|
||||
return normalized, nil
|
||||
}
|
||||
|
||||
// enabledStatus 把布尔启停转换为 0/1 状态。
|
||||
func enabledStatus(enabled bool) int {
|
||||
if enabled {
|
||||
return constants.H5PopupStatusEnabled
|
||||
}
|
||||
return constants.H5PopupStatusDisabled
|
||||
}
|
||||
|
||||
// dedupeStrings 去空白并按出现顺序去重,保留原始大小写。
|
||||
func dedupeStrings(values []string) []string {
|
||||
result := make([]string, 0, len(values))
|
||||
seen := make(map[string]struct{}, len(values))
|
||||
for _, value := range values {
|
||||
trimmed := strings.TrimSpace(value)
|
||||
if trimmed == "" {
|
||||
continue
|
||||
}
|
||||
if _, exists := seen[trimmed]; exists {
|
||||
continue
|
||||
}
|
||||
seen[trimmed] = struct{}{}
|
||||
result = append(result, trimmed)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// upperStrings 去空白并统一大写,供受控枚举范围使用;空白项不保留。
|
||||
func upperStrings(values []string) []string {
|
||||
result := make([]string, 0, len(values))
|
||||
for _, value := range values {
|
||||
trimmed := strings.TrimSpace(value)
|
||||
if trimmed == "" {
|
||||
continue
|
||||
}
|
||||
result = append(result, strings.ToUpper(trimmed))
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// dedupeShopIDs 去重店铺 ID 并丢弃非法值。
|
||||
func dedupeShopIDs(values []uint) []uint {
|
||||
result := make([]uint, 0, len(values))
|
||||
seen := make(map[uint]struct{}, len(values))
|
||||
for _, value := range values {
|
||||
if value == 0 {
|
||||
continue
|
||||
}
|
||||
if _, exists := seen[value]; exists {
|
||||
continue
|
||||
}
|
||||
seen[value] = struct{}{}
|
||||
result = append(result, value)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// toJSONBStrings 将店铺 ID 编码为 JSONB 文本数组,与范围匹配的文本比较口径一致。
|
||||
func toJSONBStrings(values []uint) model.StringJSONBArray {
|
||||
encoded := make(model.StringJSONBArray, 0, len(values))
|
||||
for _, value := range values {
|
||||
encoded = append(encoded, strconv.FormatUint(uint64(value), 10))
|
||||
}
|
||||
return encoded
|
||||
}
|
||||
|
||||
func storePages(record *model.H5PopupConfiguration) []string {
|
||||
return append([]string{}, record.Pages...)
|
||||
}
|
||||
|
||||
func storeDeviceTypes(record *model.H5PopupConfiguration) []string {
|
||||
return append([]string{}, record.DeviceTypes...)
|
||||
}
|
||||
|
||||
func storeCardTypes(record *model.H5PopupConfiguration) []string {
|
||||
return append([]string{}, record.CardTypes...)
|
||||
}
|
||||
|
||||
// storeShopIDs 将 JSONB 店铺范围还原为 ID 列表用于合并更新。
|
||||
func storeShopIDs(record *model.H5PopupConfiguration) []uint {
|
||||
shopIDs := make([]uint, 0, len(record.ShopIDs))
|
||||
for _, value := range record.ShopIDs {
|
||||
parsed, err := strconv.ParseUint(value, 10, 64)
|
||||
if err != nil || parsed == 0 {
|
||||
continue
|
||||
}
|
||||
shopIDs = append(shopIDs, uint(parsed))
|
||||
}
|
||||
return shopIDs
|
||||
}
|
||||
|
||||
func configurationAuditKey(id uint) string {
|
||||
return constants.H5PopupAuditConfigKeyPrefix + "." + strconv.FormatUint(uint64(id), 10)
|
||||
}
|
||||
|
||||
func configurationAuditResourceID(id uint) *string {
|
||||
value := strconv.FormatUint(uint64(id), 10)
|
||||
return &value
|
||||
}
|
||||
|
||||
// configurationAuditIdentity 生成配置身份快照,不含正文内容。
|
||||
func configurationAuditIdentity(record *model.H5PopupConfiguration) map[string]any {
|
||||
return map[string]any{
|
||||
"id": record.ID, "title": record.Title, "pages": storePages(record),
|
||||
"priority": record.Priority, "frequency": record.Frequency, "action_type": record.ActionType,
|
||||
"enabled": record.Enabled, "version": record.Version,
|
||||
}
|
||||
}
|
||||
|
||||
// configurationAuditSnapshot 生成配置审计前后值快照,覆盖范围、优先级、频率、受控动作、启停、有效期与版本。
|
||||
func configurationAuditSnapshot(record *model.H5PopupConfiguration) map[string]any {
|
||||
return map[string]any{
|
||||
"id": record.ID, "title": record.Title, "content": record.Content,
|
||||
"pages": storePages(record), "shop_ids": storeShopIDs(record),
|
||||
"device_types": storeDeviceTypes(record), "card_types": storeCardTypes(record),
|
||||
"priority": record.Priority, "frequency": record.Frequency, "action_type": record.ActionType,
|
||||
"enabled": record.Enabled, "starts_at": record.StartsAt, "ends_at": record.EndsAt,
|
||||
"version": record.Version, "updated_at": record.UpdatedAt,
|
||||
}
|
||||
}
|
||||
170
internal/application/h5popup/risk_exchange.go
Normal file
170
internal/application/h5popup/risk_exchange.go
Normal 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,
|
||||
}
|
||||
}
|
||||
@@ -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:
|
||||
|
||||
63
internal/application/notification/direct.go
Normal file
63
internal/application/notification/direct.go
Normal 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
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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
|
||||
|
||||
178
internal/handler/admin/h5_popup_configuration.go
Normal file
178
internal/handler/admin/h5_popup_configuration.go
Normal 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
|
||||
}
|
||||
@@ -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 查询提现资料资格版本
|
||||
|
||||
104
internal/handler/app/client_popup.go
Normal file
104
internal/handler/app/client_popup.go
Normal 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),
|
||||
)
|
||||
}
|
||||
114
internal/handler/validation/validation.go
Normal file
114
internal/handler/validation/validation.go
Normal 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
|
||||
}
|
||||
@@ -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"},
|
||||
|
||||
@@ -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: {},
|
||||
},
|
||||
},
|
||||
}}
|
||||
}
|
||||
|
||||
|
||||
@@ -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(¬ification).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return ¬ification, nil
|
||||
}
|
||||
|
||||
// IsActiveAccount 判断明确后台账号是否仍启用且未软删除。
|
||||
func (r *Repository) IsActiveAccount(ctx context.Context, accountID uint) (bool, error) {
|
||||
var count int64
|
||||
|
||||
147
internal/model/dto/h5_popup_dto.go
Normal file
147
internal/model/dto/h5_popup_dto.go
Normal 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:"弹窗标题,1~100 字符;投放时冻结写入通知标题"`
|
||||
Content string `json:"content" validate:"required,min=1,max=2000" required:"true" minLength:"1" maxLength:"2000" description:"弹窗正文,1~2000 字符;投放时冻结写入通知正文,不接受 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:"弹窗标题,1~100 字符;不传保持原值"`
|
||||
Content *string `json:"content" validate:"omitempty,min=1,max=2000" minLength:"1" maxLength:"2000" description:"弹窗正文,1~2000 字符;不传保持原值,不接受 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)"`
|
||||
}
|
||||
@@ -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 是后台通知基础分页结果。
|
||||
|
||||
69
internal/model/h5_popup_configuration.go
Normal file
69
internal/model/h5_popup_configuration.go
Normal 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 将弹窗快照序列化为 JSONB;nil 接收者写入 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)
|
||||
}
|
||||
@@ -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 返回站内通知表名。
|
||||
|
||||
126
internal/query/h5popup/query.go
Normal file
126
internal/query/h5popup/query.go
Normal 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
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
72
internal/routes/h5_popup_configuration.go
Normal file
72
internal/routes/h5_popup_configuration.go
Normal 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),
|
||||
})
|
||||
}
|
||||
@@ -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{
|
||||
|
||||
35
internal/routes/personal_popup.go
Normal file
35
internal/routes/personal_popup.go
Normal 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{},
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user