From 333ba4b6479aa48a0c92fbf81309726f04ef94ec Mon Sep 17 00:00:00 2001 From: break Date: Tue, 15 Sep 2026 15:23:52 +0800 Subject: [PATCH] =?UTF-8?q?feat(H5=E5=BC=B9=E7=AA=97):=20AUG26-007=20?= =?UTF-8?q?=E9=A3=8E=E9=99=A9=E6=8D=A2=E5=8D=A1=E4=B8=8E=E8=BF=90=E8=90=A5?= =?UTF-8?q?=E5=BC=B9=E7=AA=97=E6=8A=95=E6=94=BE=E9=80=9A=E7=9F=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增 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)与参数校验中文提示共用实现。 --- cmd/api/docs.go | 3 + cmd/gendocs/main.go | 3 + internal/application/h5popup/asset.go | 171 ++++++ internal/application/h5popup/candidate.go | 305 +++++++++++ internal/application/h5popup/configuration.go | 490 ++++++++++++++++++ internal/application/h5popup/risk_exchange.go | 170 ++++++ internal/application/notification/delivery.go | 135 +++-- internal/application/notification/direct.go | 63 +++ internal/application/notification/read.go | 7 +- internal/bootstrap/handlers.go | 27 +- internal/bootstrap/types.go | 2 + .../handler/admin/h5_popup_configuration.go | 178 +++++++ .../handler/admin/withdrawal_qualification.go | 89 +--- internal/handler/app/client_popup.go | 104 ++++ internal/handler/validation/validation.go | 114 ++++ internal/infrastructure/audit/registry.go | 25 + .../infrastructure/notification/registry.go | 24 + .../infrastructure/notification/repository.go | 11 + internal/model/dto/h5_popup_dto.go | 147 ++++++ internal/model/dto/notification_dto.go | 15 +- internal/model/h5_popup_configuration.go | 69 +++ internal/model/notification.go | 2 + internal/query/h5popup/query.go | 126 +++++ internal/query/notification/query.go | 35 +- internal/routes/admin.go | 3 + internal/routes/h5_popup_configuration.go | 72 +++ internal/routes/personal.go | 3 + internal/routes/personal_popup.go | 35 ++ ...000225_add_h5_popup_configuration.down.sql | 17 + .../000225_add_h5_popup_configuration.up.sql | 89 ++++ .../design.md | 136 ++++- .../proposal.md | 2 +- .../specs/h5-popup-notification/spec.md | 75 ++- .../tasks.md | 106 +++- pkg/constants/audit.go | 20 + pkg/constants/h5_popup.go | 140 +++++ pkg/constants/notification.go | 4 + pkg/errors/codes.go | 8 + pkg/openapi/handlers.go | 2 + 39 files changed, 2861 insertions(+), 166 deletions(-) create mode 100644 internal/application/h5popup/asset.go create mode 100644 internal/application/h5popup/candidate.go create mode 100644 internal/application/h5popup/configuration.go create mode 100644 internal/application/h5popup/risk_exchange.go create mode 100644 internal/application/notification/direct.go create mode 100644 internal/handler/admin/h5_popup_configuration.go create mode 100644 internal/handler/app/client_popup.go create mode 100644 internal/handler/validation/validation.go create mode 100644 internal/model/dto/h5_popup_dto.go create mode 100644 internal/model/h5_popup_configuration.go create mode 100644 internal/query/h5popup/query.go create mode 100644 internal/routes/h5_popup_configuration.go create mode 100644 internal/routes/personal_popup.go create mode 100644 migrations/000225_add_h5_popup_configuration.down.sql create mode 100644 migrations/000225_add_h5_popup_configuration.up.sql create mode 100644 pkg/constants/h5_popup.go diff --git a/cmd/api/docs.go b/cmd/api/docs.go index ec90acc..dea6ef4 100644 --- a/cmd/api/docs.go +++ b/cmd/api/docs.go @@ -6,6 +6,7 @@ import ( "github.com/break/junhong_cmp_fiber/internal/bootstrap" "github.com/break/junhong_cmp_fiber/internal/handler/admin" + apphandler "github.com/break/junhong_cmp_fiber/internal/handler/app" "github.com/break/junhong_cmp_fiber/internal/handler/callback" "github.com/break/junhong_cmp_fiber/internal/routes" "github.com/break/junhong_cmp_fiber/pkg/openapi" @@ -30,6 +31,8 @@ func generateOpenAPIDocs(outputPath string, logger *zap.Logger) { handlers.BusinessUserGroup = admin.NewBusinessUserGroupHandler(nil, nil) handlers.ShopBusinessOwnerImport = admin.NewShopBusinessOwnerImportHandler(nil) handlers.PhoneAssetAssociation = admin.NewPhoneAssetAssociationHandler(nil, nil) + handlers.ClientPopup = apphandler.NewClientPopupHandler(nil, nil, nil) + handlers.H5PopupConfiguration = admin.NewH5PopupConfigurationHandler(nil, nil, nil) // 企业微信 Handler 在此显式装配,避免新增管理接口遗漏文档注册。 handlers.WeCom = admin.NewWeComHandler(nil, nil) handlers.PaymentMerchant = admin.NewPaymentMerchantHandler(nil) diff --git a/cmd/gendocs/main.go b/cmd/gendocs/main.go index 6cc04b0..70b7853 100644 --- a/cmd/gendocs/main.go +++ b/cmd/gendocs/main.go @@ -8,6 +8,7 @@ import ( "github.com/break/junhong_cmp_fiber/internal/bootstrap" "github.com/break/junhong_cmp_fiber/internal/handler/admin" + apphandler "github.com/break/junhong_cmp_fiber/internal/handler/app" "github.com/break/junhong_cmp_fiber/internal/handler/callback" "github.com/break/junhong_cmp_fiber/internal/routes" "github.com/break/junhong_cmp_fiber/pkg/openapi" @@ -39,6 +40,8 @@ func generateAdminDocs(outputPath string) error { handlers.BusinessUserGroup = admin.NewBusinessUserGroupHandler(nil, nil) handlers.ShopBusinessOwnerImport = admin.NewShopBusinessOwnerImportHandler(nil) handlers.PhoneAssetAssociation = admin.NewPhoneAssetAssociationHandler(nil, nil) + handlers.ClientPopup = apphandler.NewClientPopupHandler(nil, nil, nil) + handlers.H5PopupConfiguration = admin.NewH5PopupConfigurationHandler(nil, nil, nil) // 企业微信 Handler 在此显式装配,避免新增管理接口遗漏文档注册。 handlers.WeCom = admin.NewWeComHandler(nil, nil) handlers.PaymentMerchant = admin.NewPaymentMerchantHandler(nil) diff --git a/internal/application/h5popup/asset.go b/internal/application/h5popup/asset.go new file mode 100644 index 0000000..09b8c59 --- /dev/null +++ b/internal/application/h5popup/asset.go @@ -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 +} diff --git a/internal/application/h5popup/candidate.go b/internal/application/h5popup/candidate.go new file mode 100644 index 0000000..b02f02a --- /dev/null +++ b/internal/application/h5popup/candidate.go @@ -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 +} diff --git a/internal/application/h5popup/configuration.go b/internal/application/h5popup/configuration.go new file mode 100644 index 0000000..68758f9 --- /dev/null +++ b/internal/application/h5popup/configuration.go @@ -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, + } +} diff --git a/internal/application/h5popup/risk_exchange.go b/internal/application/h5popup/risk_exchange.go new file mode 100644 index 0000000..ccc28c2 --- /dev/null +++ b/internal/application/h5popup/risk_exchange.go @@ -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, + } +} diff --git a/internal/application/notification/delivery.go b/internal/application/notification/delivery.go index 29bbaed..a6d8d9e 100644 --- a/internal/application/notification/delivery.go +++ b/internal/application/notification/delivery.go @@ -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: diff --git a/internal/application/notification/direct.go b/internal/application/notification/direct.go new file mode 100644 index 0000000..68e164f --- /dev/null +++ b/internal/application/notification/direct.go @@ -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 +} diff --git a/internal/application/notification/read.go b/internal/application/notification/read.go index 7f1dee8..df564d3 100644 --- a/internal/application/notification/read.go +++ b/internal/application/notification/read.go @@ -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, ) } diff --git a/internal/bootstrap/handlers.go b/internal/bootstrap/handlers.go index 9dd8f0d..489083b 100644 --- a/internal/bootstrap/handlers.go +++ b/internal/bootstrap/handlers.go @@ -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), diff --git a/internal/bootstrap/types.go b/internal/bootstrap/types.go index 43a892c..4af4960 100644 --- a/internal/bootstrap/types.go +++ b/internal/bootstrap/types.go @@ -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 diff --git a/internal/handler/admin/h5_popup_configuration.go b/internal/handler/admin/h5_popup_configuration.go new file mode 100644 index 0000000..85e5181 --- /dev/null +++ b/internal/handler/admin/h5_popup_configuration.go @@ -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 +} diff --git a/internal/handler/admin/withdrawal_qualification.go b/internal/handler/admin/withdrawal_qualification.go index 7b110f5..a969660 100644 --- a/internal/handler/admin/withdrawal_qualification.go +++ b/internal/handler/admin/withdrawal_qualification.go @@ -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 查询提现资料资格版本 diff --git a/internal/handler/app/client_popup.go b/internal/handler/app/client_popup.go new file mode 100644 index 0000000..5358d34 --- /dev/null +++ b/internal/handler/app/client_popup.go @@ -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), + ) +} diff --git a/internal/handler/validation/validation.go b/internal/handler/validation/validation.go new file mode 100644 index 0000000..e6d0b5f --- /dev/null +++ b/internal/handler/validation/validation.go @@ -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 +} diff --git a/internal/infrastructure/audit/registry.go b/internal/infrastructure/audit/registry.go index 4832e34..9fc6205 100644 --- a/internal/infrastructure/audit/registry.go +++ b/internal/infrastructure/audit/registry.go @@ -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"}, diff --git a/internal/infrastructure/notification/registry.go b/internal/infrastructure/notification/registry.go index 9a760ed..0174523 100644 --- a/internal/infrastructure/notification/registry.go +++ b/internal/infrastructure/notification/registry.go @@ -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: {}, + }, + }, }} } diff --git a/internal/infrastructure/notification/repository.go b/internal/infrastructure/notification/repository.go index 4001283..97da973 100644 --- a/internal/infrastructure/notification/repository.go +++ b/internal/infrastructure/notification/repository.go @@ -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 diff --git a/internal/model/dto/h5_popup_dto.go b/internal/model/dto/h5_popup_dto.go new file mode 100644 index 0000000..3fbfe14 --- /dev/null +++ b/internal/model/dto/h5_popup_dto.go @@ -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)"` +} diff --git a/internal/model/dto/notification_dto.go b/internal/model/dto/notification_dto.go index c099a38..df8dab1 100644 --- a/internal/model/dto/notification_dto.go +++ b/internal/model/dto/notification_dto.go @@ -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 是后台通知基础分页结果。 diff --git a/internal/model/h5_popup_configuration.go b/internal/model/h5_popup_configuration.go new file mode 100644 index 0000000..a31b782 --- /dev/null +++ b/internal/model/h5_popup_configuration.go @@ -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) +} diff --git a/internal/model/notification.go b/internal/model/notification.go index fe15d5d..61df222 100644 --- a/internal/model/notification.go +++ b/internal/model/notification.go @@ -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 返回站内通知表名。 diff --git a/internal/query/h5popup/query.go b/internal/query/h5popup/query.go new file mode 100644 index 0000000..c9419d2 --- /dev/null +++ b/internal/query/h5popup/query.go @@ -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 +} diff --git a/internal/query/notification/query.go b/internal/query/notification/query.go index ec7ab98..f58ae97 100644 --- a/internal/query/notification/query.go +++ b/internal/query/notification/query.go @@ -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, + } +} diff --git a/internal/routes/admin.go b/internal/routes/admin.go index cc0ce61..b49bc63 100644 --- a/internal/routes/admin.go +++ b/internal/routes/admin.go @@ -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) } diff --git a/internal/routes/h5_popup_configuration.go b/internal/routes/h5_popup_configuration.go new file mode 100644 index 0000000..0870cb9 --- /dev/null +++ b/internal/routes/h5_popup_configuration.go @@ -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), + }) +} diff --git a/internal/routes/personal.go b/internal/routes/personal.go index 1cbf276..f1fca3d 100644 --- a/internal/routes/personal.go +++ b/internal/routes/personal.go @@ -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{ diff --git a/internal/routes/personal_popup.go b/internal/routes/personal_popup.go new file mode 100644 index 0000000..4ee83b8 --- /dev/null +++ b/internal/routes/personal_popup.go @@ -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{}, + }) +} diff --git a/migrations/000225_add_h5_popup_configuration.down.sql b/migrations/000225_add_h5_popup_configuration.down.sql new file mode 100644 index 0000000..2ae92cd --- /dev/null +++ b/migrations/000225_add_h5_popup_configuration.down.sql @@ -0,0 +1,17 @@ +-- 回滚 H5 运营弹窗配置与弹窗投放快照。 +-- 先删快照约束与快照列,再删配置表与索引,与 up 的创建顺序成对;既有通知列、类别 CHECK 与保留清理不受影响。 +-- +-- 不可逆说明(ENG-MIG-001 例外条件):本迁移的 down 会删除 tb_notification.popup_snapshot +-- 与 tb_h5_popup_configuration。恢复方式为重新执行 up 重建结构;被删除的弹窗快照与配置行 +-- 无法由数据库自身重建,只能依据 tb_audit_event 中 h5_popup_configuration 的配置审计前后值与 +-- 已投递通知正文人工复核,因此仅在确认不再需要弹窗投放事实时才执行 down。 + +ALTER TABLE tb_notification + DROP CONSTRAINT IF EXISTS chk_notification_popup_snapshot_object; + +ALTER TABLE tb_notification + DROP COLUMN IF EXISTS popup_snapshot; + +DROP INDEX IF EXISTS idx_h5_popup_configuration_match; + +DROP TABLE IF EXISTS tb_h5_popup_configuration; diff --git a/migrations/000225_add_h5_popup_configuration.up.sql b/migrations/000225_add_h5_popup_configuration.up.sql new file mode 100644 index 0000000..c488256 --- /dev/null +++ b/migrations/000225_add_h5_popup_configuration.up.sql @@ -0,0 +1,89 @@ +-- H5 运营弹窗配置与弹窗投放快照。 +-- 背景:H5 需要在客户实际访问时投放风险换卡或运营弹窗,并把投放内容冻结为个人站内通知。 +-- 既有事实只有个人站内通知(tb_notification)与换货单(tb_exchange_order), +-- 没有任何可承载「页面、店铺/设备类型/卡类型范围、优先级、频率、受控动作、启停与有效期」的配置表, +-- 因此本迁移新增 tb_h5_popup_configuration;同时为 tb_notification 增加一个可空 JSONB 快照列, +-- 用于冻结弹窗投放时的配置 ID、配置版本、资产与受控动作。既有通知列、类别 CHECK 与保留清理均不改动。 +-- +-- 设计选择: +-- 1. version 是「配置版本」而非乐观锁:更新事务内自增,作为频率去重键的一部分, +-- 使修改后的配置能对同一客户重新投放一次,同时旧通知保留原快照不被改写。 +-- 2. 范围四维统一用 JSONB 字符串数组保存(pages/shop_ids/device_types/card_types)。 +-- 空数组表示该维度未配置即全量;已配置而资产该维度无值时不命中,由应用层与查询共同保证。 +-- 3. 受控动作只允许空值、package_purchase、asset_wallet_recharge,禁止任何 URL 或前端路由。 +-- 4. 有效期用 starts_at/ends_at 表达,并约束 ends_at >= starts_at。 +-- 5. popup_snapshot 可空且只由弹窗投放类型写入,其他通知类型与既有写入路径保持 NULL。 +-- 6. 不使用数据库外键,配置与通知、资产之间以 ID 保存并由应用层显式校验。 + +CREATE TABLE tb_h5_popup_configuration ( + id BIGSERIAL PRIMARY KEY, + title VARCHAR(100) NOT NULL, + content VARCHAR(2000) NOT NULL, + pages JSONB NOT NULL DEFAULT '[]', + shop_ids JSONB NOT NULL DEFAULT '[]', + device_types JSONB NOT NULL DEFAULT '[]', + card_types JSONB NOT NULL DEFAULT '[]', + priority INTEGER NOT NULL DEFAULT 0, + frequency VARCHAR(20) NOT NULL DEFAULT 'once', + action_type VARCHAR(40) NOT NULL DEFAULT '', + enabled SMALLINT NOT NULL DEFAULT 0, + starts_at TIMESTAMPTZ NOT NULL, + ends_at TIMESTAMPTZ NOT NULL, + version BIGINT NOT NULL DEFAULT 1, + creator BIGINT NOT NULL DEFAULT 0, + updater BIGINT NOT NULL DEFAULT 0, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + CONSTRAINT chk_h5_popup_configuration_title CHECK (title <> ''), + CONSTRAINT chk_h5_popup_configuration_content CHECK (content <> ''), + -- 页面必选且非空;其余三维允许空数组(空数组=该维度未配置即全量)。 + CONSTRAINT chk_h5_popup_configuration_pages CHECK (jsonb_typeof(pages) = 'array' AND jsonb_array_length(pages) > 0), + CONSTRAINT chk_h5_popup_configuration_ranges CHECK ( + jsonb_typeof(shop_ids) = 'array' + AND jsonb_typeof(device_types) = 'array' + AND jsonb_typeof(card_types) = 'array' + ), + CONSTRAINT chk_h5_popup_configuration_priority CHECK (priority >= 0), + CONSTRAINT chk_h5_popup_configuration_frequency CHECK (frequency IN ('once', 'daily')), + CONSTRAINT chk_h5_popup_configuration_action_type CHECK (action_type IN ('', 'package_purchase', 'asset_wallet_recharge')), + CONSTRAINT chk_h5_popup_configuration_enabled CHECK (enabled IN (0, 1)), + CONSTRAINT chk_h5_popup_configuration_period CHECK (ends_at >= starts_at), + CONSTRAINT chk_h5_popup_configuration_version CHECK (version > 0) +); + +-- 候选匹配固定按「启用 → 优先级倒序 → 最近更新时间倒序 → ID 倒序」取最高一条。 +-- 启停操作同步刷新 updated_at,因此该索引同时服务启停排序与时间窗口筛选。 +-- 本表为后台维护的小型运营配置表,停用走 enabled 置 0 且不做软删除,故索引无条件。 +-- 不额外建立 JSONB GIN 索引。 +CREATE INDEX idx_h5_popup_configuration_match + ON tb_h5_popup_configuration (enabled, priority DESC, updated_at DESC, id DESC); + +COMMENT ON TABLE tb_h5_popup_configuration IS 'H5 运营弹窗全局配置,仅超级管理员与平台账号维护,客户端只读匹配'; +COMMENT ON COLUMN tb_h5_popup_configuration.id IS '主键'; +COMMENT ON COLUMN tb_h5_popup_configuration.title IS '弹窗标题,投放时冻结写入通知标题'; +COMMENT ON COLUMN tb_h5_popup_configuration.content IS '弹窗正文,投放时冻结写入通知正文,禁止 HTML 与 URL'; +COMMENT ON COLUMN tb_h5_popup_configuration.pages IS '命中页面集合 JSONB 数组,必填非空,取值 home/asset_detail/package_purchase/asset_wallet_recharge'; +COMMENT ON COLUMN tb_h5_popup_configuration.shop_ids IS '店铺范围 JSONB 字符串数组,空数组表示全量;同维度多选取任一命中'; +COMMENT ON COLUMN tb_h5_popup_configuration.device_types IS '设备类型范围 JSONB 字符串数组,空数组表示全量;资产该维度无值时不命中已配置范围'; +COMMENT ON COLUMN tb_h5_popup_configuration.card_types IS '卡类型范围 JSONB 字符串数组(CMCC/CUCC/CTCC/CBN),空数组表示全量;资产该维度无值时不命中已配置范围'; +COMMENT ON COLUMN tb_h5_popup_configuration.priority IS '优先级,数值越大越优先,仅在启用且时间窗口内参与匹配'; +COMMENT ON COLUMN tb_h5_popup_configuration.frequency IS '投放频率 once-每客户每配置版本仅一次 daily-每客户每配置版本每个上海自然日一次'; +COMMENT ON COLUMN tb_h5_popup_configuration.action_type IS '受控动作 package_purchase-套餐购买 asset_wallet_recharge-资产钱包充值;空值表示无受控动作,不接受 URL 或前端路由'; +COMMENT ON COLUMN tb_h5_popup_configuration.enabled IS '状态 0-禁用 1-启用,启停只影响后续投放且必须刷新 updated_at'; +COMMENT ON COLUMN tb_h5_popup_configuration.starts_at IS '生效开始时间'; +COMMENT ON COLUMN tb_h5_popup_configuration.ends_at IS '生效结束时间,不得早于开始时间'; +COMMENT ON COLUMN tb_h5_popup_configuration.version IS '配置版本,更新事务内递增;旧版本通知保留原快照不被改写'; +COMMENT ON COLUMN tb_h5_popup_configuration.creator IS '创建人账号ID,系统写入为 0'; +COMMENT ON COLUMN tb_h5_popup_configuration.updater IS '最近更新人账号ID'; +COMMENT ON COLUMN tb_h5_popup_configuration.created_at IS '创建时间'; +COMMENT ON COLUMN tb_h5_popup_configuration.updated_at IS '最近更新时间,启停与更新均必须刷新'; + +ALTER TABLE tb_notification + ADD COLUMN IF NOT EXISTS popup_snapshot JSONB; + +-- 快照必须是 JSON 对象,避免写入标量或数组导致读取投影无法解析。 +ALTER TABLE tb_notification + ADD CONSTRAINT chk_notification_popup_snapshot_object + CHECK (popup_snapshot IS NULL OR jsonb_typeof(popup_snapshot) = 'object'); + +COMMENT ON COLUMN tb_notification.popup_snapshot IS '弹窗投放快照 config_id/config_version/asset_type/asset_id/action_type;仅 h5.popup.risk_exchange 与 h5.popup.operation 写入,其他通知类型保持 NULL'; diff --git a/openspec/changes/add-h5-risk-exchange-notifications/design.md b/openspec/changes/add-h5-risk-exchange-notifications/design.md index 1877ed3..ccfec84 100644 --- a/openspec/changes/add-h5-risk-exchange-notifications/design.md +++ b/openspec/changes/add-h5-risk-exchange-notifications/design.md @@ -1,33 +1,147 @@ ## Context -个人客户通知已有隔离、已读和投递能力,物流换货已有状态机。弹窗配置不能替代通知事实,风险换卡不能另建待处理记录。 +个人客户通知与物流换货都已有可用事实,本 Change 只在既有事实上补一条 H5 投放链路。动机见 `proposal.md`;以下只列影响方案的现状约束。 + +- **通知只有异步写入点**:唯一创建路径是 worker 进程的 Outbox 消费者 `DeliveryService.deliver` → `Repository.CreateIdempotent`(`internal/application/notification/delivery.go:159-221`、`internal/infrastructure/notification/repository.go:30-36`);`NewDeliveryService` 只出现在 `cmd/worker/main.go:602`,API 进程当前不创建通知。不存在事务内直建个人客户通知的方法。 +- **C 端可见性与已读各有硬编码白名单且重复实现**:`internal/query/notification/query.go:195-204` 与 `internal/application/notification/read.go:211-220` 都限定 `category IN (approval, expiry, system)` 且 `type IN (package.expiring, exchange.shipping.created)`。未列入白名单的类型在列表与未读数中不可见;已读接口对未命中行静默成功(`read.go:84-107`),失效不可观测。 +- **类型必须先注册**:`internal/infrastructure/notification/registry.go:48-122` 是唯一的类型与模板白名单;`tb_notification.type` 无 DB 约束,`category` 有 CHECK 四值(`migrations/000168_create_notification.up.sql:27`)。 +- **展示期与物理保留分离**:`expires_at` 决定停止展示(`internal/application/notification/delivery.go:223-256`),事实物理删除由按类别的保留清理任务执行(`internal/infrastructure/notification/cleanup.go:45-50`),`system` 类别物理保留 365 天。 +- **换货表没有客户维度**:`tb_exchange_order` 仅有 `exchange_no` 唯一索引,无客户列,也不存在「客户+旧资产」唯一约束;现有活动单判定只取状态 `{1,2,3}`(`internal/store/postgres/exchange_order_store.go:73-93`)。 +- **地址已是一个完整文本**:`recipient_name varchar(50)`、`recipient_phone varchar(20)`、`recipient_address text`(`internal/model/exchange_order.go:37-39`),入口校验 50/20/500(`internal/model/dto/exchange_dto.go:40-44`);无需拆分省市区。 +- **归属判定有两个实现**:权威实现 `customer_binding.Service.OwnsAsset`(`internal/service/customer_binding/service.go:114-153`);换货服务内部实现只查设备绑定虚拟号,对无虚拟号卡恒为假(`internal/service/exchange/service.go:1201-1235`)。 +- **既有常量可直接复用**:风险停机 `GatewayCardExtendRiskStop`(`pkg/constants/iot.go:66`)、广电运营商类型 `CarrierTypeCBN`(`pkg/constants/constants.go:191`)。仓库内没有广电卡判定逻辑。 +- **没有配置版本先例**:既有 `version` 列都是乐观锁;受控配置与轮询配置都没有版本递增语义。 + +## Goals / Non-Goals + +**Goals:** + +- 在客户实际访问 H5 时,按当前资产事实投放风险换卡或运营弹窗,并把投放内容冻结为个人客户通知。 +- 风险地址提交幂等创建一张关联旧资产的物流换货单,首次地址锁定。 +- 运营弹窗配置在页面、范围、优先级、频率、版本与受控动作上全部受控,不开放任意跳转。 + +**Non-Goals:** + +- 不新建风险换卡待处理记录表。 +- 不改共用换货表结构:不新增客户列、不新增换货唯一索引、不改既有换货状态机与后台换货接口。 +- 不新增通知类别、不修改 `tb_notification` 既有列与 CHECK;只为弹窗投放新增一个可空快照列。 +- 不做 ERP 对接、不做弹窗效果统计、不提供「不再提醒」。 +- 不修改既有个人通知列表与已读契约(只扩白名单)。 ## Decisions -- 新增运营弹窗配置及版本/投放去重事实;候选查询在同一资产上下文计算风险优先级和配置匹配,创建/复用个人通知。 -- 风险地址提交以客户+旧资产唯一约束和事务创建物流换货单,地址写入换货单而不单独建表。 -- 频率去重使用客户、配置版本、资产/日期键;通知内容冻结在投放时,已读复用现有服务。 -- 后台仅管理配置,不得写任意 URL;H5 只接收受控目标类型。 +### 1. 风险地址提交的幂等实现:锁 + 去重查询,不引入数据库唯一约束 + +事务内先对旧资产行加锁(卡按 `tb_iot_card` 行 `SELECT ... FOR UPDATE`),再复核风险资格,命中既有活动物流换货单即返回既有单,未命中才插入。并发同一资产由该行锁串行化,因此不需要数据库层面的唯一约束。 + +- 已否决「新增 `customer_id` 列 + 部分唯一索引」:现有表无客户列,加列需回填且会改动共用换货表;而资产实例同一时刻只属于一个客户,锁资产行即可覆盖并发与重复提交。 +- 因此明确:**不改共用换货表、不新增 `customer_id` 列、不新增部分唯一索引**。原设计稿中「客户+旧资产唯一约束」的表述被删除。 + +### 2. 新增最小换货创建入口 + +新增应用层入口(`SubmitRiskAddress`),单事务内按序执行:锁旧资产行 → 复核风险资格 → 去重查询既有活动物流换货单(命中即返回,不覆盖地址)→ 未命中才插入。 + +创建时固定写入: + +- 状态为**待发货**、流程类型为 `shipping`; +- `migrate_data=false`、`migration_status=not_migrated`(不预设业务数据迁移,发货时仍由后台按既有流程决定); +- `ExchangeReason` 非空; +- `BaseModel.Creator` / `BaseModel.Updater` 置 0,并记录该选择:H5 客户上下文没有后台账号 ID,置 0 表示由客户自助发起,不冒用任何后台账号身份。 + +约束: + +- 归属校验**必须使用 `customer_binding.OwnsAsset`**,不得复用换货服务内部只查设备绑定虚拟号的判定(该判定对无虚拟号卡恒为假)。 +- **不得沿用资产级群发通知投递**:既有换货创建会面向该资产的全部有效客户投递通知,风险换卡只服务当前客户,其弹窗通知由候选接口产生。 + +### 3. 通知同步直建:新增窄接口 `DirectWriter.CreateOrGetPersonal` + +候选查询必须当次返回可用通知标识供后续已读,不能依赖 Outbox 消费延迟,因此新增窄接口(`internal/application/notification`),签名形如: + +`CreateOrGetPersonal(ctx, eventID string, customerID uint, req PersonalDirectRequest) (*model.Notification, error)` + +- 与 Outbox 消费**共用**模板渲染、展示期计算与 `CreateIdempotent`,不复制规则。 +- 冲突时按唯一键回查并返回既有行,实现「复用」而非重复投放。 +- 不依赖消费延迟,候选查询不做异步等待。 +- 把既有直投分支重构为调用同一 helper,避免两套规则漂移。 +- 装配落在 `internal/bootstrap/handlers.go`、`internal/bootstrap/types.go` 与 `pkg/openapi/handlers.go`。 + +### 4. 可见性与已读白名单:四处必改 + +新增两个通知类型,命名固定为 `h5.popup.risk_exchange` 与 `h5.popup.operation`。必须同步的四处: + +1. 个人通知查询的类型白名单(`internal/query/notification/query.go` 的 `personalNotificationScope`)。 +2. 个人通知已读的类型白名单(`internal/application/notification/read.go` 的 `personalReadScope`)。 +3. 通知注册表新增两个 `Definition`(`internal/infrastructure/notification/registry.go`),接收人限个人客户,资源引用按第 7 节。 +4. 通知类型常量(`pkg/constants/notification.go`)与后台 DTO 的 `type` oneof/enums(`internal/model/dto/notification_dto.go`)。 + +类别复用既有 `system`,**不新增类别、不改 DB CHECK**。 + +必须写明的失效后果:任一白名单漏改都会导致该类型通知在 C 端列表与未读数中不可见,并且已读接口返回成功但事实不变(静默失效)。 + +### 5. 风险资格判定 + +- 卡运营商类型等于既有广电常量。 +- 运营商扩展状态**严格等于**既有风险停机常量,不得与「已销户」合并。 +- 活动物流换货单取 `flow_type=shipping AND status IN (1,2,3,4)`,**含已完成**。必须限定 `flow_type`:直接换货单创建即已完成,若不限定会永久压制风险候选。 +- 「未提交风险地址」由「不存在上述活动单」导出,不新增独立字段。 +- 上海自然日去重键落在通知 `event_id`,复用既有唯一约束保证一天一条。 + +### 6. 运营匹配维度与排序 + +- 卡类型 = `tb_iot_card.carrier_type`(广电/移动/联通/电信)。 +- 店铺 = 资产 `shop_id`。 +- 设备类型 = 经卡—设备绑定推导的 `tb_device.device_type`;独立卡或未绑定设备时该维度为空,**空值不匹配任何已配置范围**;仅「未配置范围」表示全量。 +- 同维度多选取任一命中。 +- 优先级排序为 `priority DESC, updated_at DESC, id DESC`;**启停操作必须更新 `updated_at`**,否则同优先级取「最近更新」失真。 + +### 7. 配置版本与投放快照 + +- 运营弹窗配置表新增 `version` 列,更新事务内递增。 +- 弹窗投放快照写入通知表**新增的可空 JSONB 列 `popup_snapshot`**,承载 `config_id`、`config_version`、`asset_type`、`asset_id`、`action_type`。该列仅由弹窗投放类型写入,其他通知类型保持为空,既有写入路径与保留清理不受影响。 +- 通知行既有 `ref_type` / `ref_id` / `ref_key` 沿用既有受控语义:`ref_type` 为 `asset`、`ref_id` 为资产数字 ID、`ref_key` 为资产标识快照,**不承载配置信息**。 +- 通知 DTO 增加可选快照字段,仅弹窗类型返回;**不得返回任何 URL 或前端路由**。 +- 旧版本通知不改写;修改后的仅一次配置可向原命中客户重新投放。 +- 频率去重键:`once` 为「客户+配置+版本」,`daily` 再追加上海自然日。 + +### 8. 保留期语义 + +「保留 90 天」明确为**展示保留**:`expires_at = 投放时间 + 90 天`,类别沿用 `system`(其展示上限 365 天,90 天在其内)。事实物理保留仍按既有类别约定(`system` 365 天)。配置到期或停用停止新投放,历史通知在展示期内可见。 + +### 9. 候选接口契约 + +- 候选查询**会创建或复用通知并保持未读**,即 GET 有副作用。理由:PRD 要求运营弹窗仅在客户请求配置页面时实时匹配、不预生成通知,投放事实又必须与「客户确实访问过」对齐;因此副作用是产品契约的一部分,不是实现疏漏。 +- 请求携带页面与当前资产标识;资产参数统一沿用既有 `identifier` 形态,**不采用路径参数**。 +- 资产归属使用 `customer_binding.OwnsAsset`;资产不存在与其归属校验失败**统一按资源不可见返回**,不形成可枚举差异。 +- 风险命中时只返回风险候选;未命中风险再按时间、启停、页面、范围与频率匹配运营配置。 ## 后台与 H5 动作契约 ### 运营弹窗配置 - `POST /h5-popup-configurations`:仅超级管理员、平台用户。请求 `title`(1~100 字符)、`content`(1~2000 字符)、`starts_at`、`ends_at`、`enabled`、`priority`、`pages`(首页/资产详情/套餐购买/资产钱包充值)、可选店铺/设备类型/卡类型集合、`frequency`(`once`/`daily`)和可选 `action_type`(`package_purchase`/`asset_wallet_recharge`)。结束时间不得早于开始时间;不接受 URL、前端路由或任意动作参数。 -- `PUT /h5-popup-configurations/:id` 更新时递增配置版本;旧版本通知不改写。`POST /:id/enable`、`/disable` 仅影响后续候选;全部成功写操作记录操作者、前后值、版本和时间。 +- `PUT /h5-popup-configurations/:id` 更新时递增配置版本;旧版本通知不改写。`POST /:id/enable`、`/disable` 仅影响后续候选且必须更新最近更新时间;全部成功写操作记录操作者、前后值、版本和时间。 ### H5 候选查询与风险换卡 -- `GET /api/c/v1/popup-candidates`:当前个人客户必须提交 `page` 和当前资产标识;首页也必须先由客户选定当前资产。服务校验该资产属于当前客户或其既有授权范围,否则按资源不可见返回。 -- 查询先判断广电卡、运营商扩展状态风险停机、无活动物流换货单、未提交风险地址和“客户+资产+上海自然日”未展示;命中时创建/复用风险通知并只返回风险换卡候选。关闭或稍后处理只调用既有通知已读,不修改风险资格,次日允许再次投放。 +- `GET /api/c/v1/popup-candidates`:当前个人客户必须提交 `page` 和当前资产 `identifier`;首页也必须先由客户选定当前资产。服务校验该资产属于当前客户,否则按资源不可见返回。 +- 查询先判断广电卡、运营商扩展状态风险停机、无活动物流换货单(`shipping` 且状态含已完成)、未提交风险地址和「客户+资产+上海自然日」未展示;命中时创建/复用风险通知并只返回风险换卡候选。关闭或稍后处理只调用既有通知已读,不修改风险资格,次日允许再次投放。 - 未命中风险时,按当前时间、启用状态、页面、店铺/设备类型/卡类型范围和频率匹配运营配置;同维度多值取任一命中,无配置即全量。只返回优先级最高一条,同优先级取最近更新时间;以客户、配置版本、资产、日期/一次性键创建或复用通知。 ### 风险地址提交与通知读取 -- `POST /api/c/v1/risk-exchanges/:asset_id/address`:当前个人客户提交 `recipient_name`、`recipient_phone`、`recipient_address`;均必填且沿用既有换货地址字段长度校验。事务中锁定客户和旧资产,复核风险资格,以客户+旧资产唯一约束创建物流换货单,`migrate_data=false`;重复提交返回首次创建的换货单与首次地址,禁止覆盖。 -- 弹窗通知内容、配置版本、资产和受控动作在投放时冻结并写个人站内通知,保留 90 天。候选查询不标记已读;关闭、点击受控操作、进入通知详情仅通过既有 `PUT /api/c/v1/notifications/:id/read` 幂等标记当前客户自己的通知。 +- `POST /api/c/v1/risk-exchanges/:asset_id/address`:当前个人客户提交 `recipient_name`、`recipient_phone`、`recipient_address`;均必填且沿用既有换货地址字段长度校验。事务中锁定旧资产行,复核风险资格,未命中既有活动单才创建物流换货单(待发货、`migrate_data=false`);重复提交返回首次创建的换货单与首次地址,禁止覆盖。 +- 弹窗通知内容在投放时冻结并写个人站内通知,展示期 90 天;弹窗投放通知在 `ref_type` / `ref_id` / `ref_key` 保留旧资产引用(受控类型 `asset`、资产数字 ID、资产标识快照),并以可空 `popup_snapshot` 保存配置 ID、配置版本、资产类型与 ID、受控动作。候选查询不标记已读;关闭、点击受控操作、进入通知详情仅通过既有 `PUT /api/c/v1/notifications/:id/read` 幂等标记当前客户自己的通知。 - 通知受控操作只返回类型与资产关联,不返回 URL;前端按白名单映射页面。客户读取他人通知或不属于其资产的风险换卡均按既有隔离规则不可见。 +## Risks / Trade-offs + +- **候选查询是带副作用的 GET**:语义上不够纯粹,但换来了「不预生成通知」。风险是前端预取或爬取会提前产生未读通知;由事件键幂等限制为每键一条,代价可控。 +- **通知表新增一列**:`popup_snapshot` 是增量、可空列,向后兼容,既有通知写入、读取与保留清理均不受影响。选择新增该列而非复用 `ref_key` 承载配置信息,是为了不破坏 `ref_key` 的既有文档语义(受控资源稳定键或展示快照,不是配置信息载体)。 +- **锁资产行会串行化同一卡的地址提交**:锁范围小、事务内无外部 I/O,但仍需保证事务内不持有长耗时操作,否则会阻塞同卡的其它写路径。 +- **两个白名单是重复实现**:本次按最小改动同时扩两处;漏改一处即静默失效,验证必须覆盖列表可见与已读生效两条。 +- **`Creator/Updater` 置 0**:保留「客户自助发起」语义,但会让换货单缺少后台操作者;后续如需追溯,应另行定义客户来源标识而非复用账号 ID。 +- **`flow_type` 必须参与资格判定**:若实现遗漏该条件,直接换货单会永久压制风险候选,且不会报错,属静默错误。 + ## Migration Plan -新增成对迁移和索引;隔离库验证风险条件、每日限制、地址幂等、优先级、版本重投、范围匹配、通知隔离及 up/down/up。 \ No newline at end of file +新增成对迁移(编号从 `000225` 起,实施时复核当前最大编号):运营弹窗配置表(含 `version`、页面、范围、优先级、频率、受控动作、启停、有效期)与所需索引;并在同一迁移内为 `tb_notification` 新增可空 JSONB 列 `popup_snapshot`。隔离库验证风险条件、每日限制、地址幂等、优先级、版本重投、范围匹配、通知隔离及 up/down/up。 diff --git a/openspec/changes/add-h5-risk-exchange-notifications/proposal.md b/openspec/changes/add-h5-risk-exchange-notifications/proposal.md index 566ebcf..32abdf8 100644 --- a/openspec/changes/add-h5-risk-exchange-notifications/proposal.md +++ b/openspec/changes/add-h5-risk-exchange-notifications/proposal.md @@ -10,7 +10,7 @@ - 新增风险换卡候选与地址提交,幂等创建物流换货单。 - 新增运营弹窗配置、范围/优先级/频率/版本投放和受控动作。 -- 复用个人客户通知保存快照、已读和 90 天历史。 +- 复用个人客户通知保存投放快照、已读和 90 天展示期;不新增通知类别,仅新增一个可空快照列。 ## Capabilities diff --git a/openspec/changes/add-h5-risk-exchange-notifications/specs/h5-popup-notification/spec.md b/openspec/changes/add-h5-risk-exchange-notifications/specs/h5-popup-notification/spec.md index 3e2e64e..ccd8fda 100644 --- a/openspec/changes/add-h5-risk-exchange-notifications/specs/h5-popup-notification/spec.md +++ b/openspec/changes/add-h5-risk-exchange-notifications/specs/h5-popup-notification/spec.md @@ -5,26 +5,89 @@ ## ADDED Requirements ### Requirement: 风险换卡候选与地址提交 -系统 SHALL 仅在当前 H5 客户访问的资产为广电卡、运营商扩展状态为风险停机、且不存在待填写信息、待发货、已发货待确认或已完成物流换货单时返回风险换卡弹窗。同一客户同一资产每天至多展示一次;稍后处理仅抑制当天,次日仍可命中。风险换卡优先级固定高于运营弹窗。 -客户提交收货人姓名、收货手机号和完整地址文本后,系统 MUST 幂等创建关联旧资产的物流换货单;首次地址锁定,客户不得修改。自动换货单不预设业务数据迁移,发货选择新资产时仍由后台按既有换货流程决定。风险条件不再成立或地址已提交后停止新投放,已投放通知保留 90 天。 +系统 SHALL 仅在当前 H5 客户访问的资产为广电卡、运营商扩展状态为风险停机、且不存在待填写收货信息、待发货、已发货待确认或已完成的物流换货单时返回风险换卡弹窗。物流换货单只统计物流换货流程;直接换货单不参与该判定。同一客户同一资产每天至多展示一次;稍后处理仅抑制当天,次日仍可命中。风险换卡优先级固定高于运营弹窗。 + +客户提交收货人姓名、收货手机号和完整地址文本后,系统 MUST 幂等创建关联旧资产的物流换货单;首次地址锁定,客户不得修改,重复提交返回首次创建的换货单与首次地址且不覆盖。并发提交同一资产时只创建一张换货单。自动换货单不预设业务数据迁移,发货选择新资产时仍由后台按既有换货流程决定。风险条件不再成立或地址已提交后停止新投放,已投放通知在通知中心展示 90 天。客户请求不属于自己的资产时,系统 MUST 返回与资产不存在相同的不可见结果。 #### Scenario: 重复提交风险地址 + - **WHEN** 客户对同一风险资产重复提交收货地址 -- **THEN** 系统保留首次地址和唯一物流换货单,不创建第二张换货单 +- **THEN** 系统保留首次地址和唯一物流换货单,不创建第二张换货单,也不覆盖首次地址 + +#### Scenario: 并发提交同一资产 + +- **WHEN** 同一风险资产同时收到两次地址提交 +- **THEN** 系统只创建一张物流换货单,两次均返回同一张换货单与首次地址 + +#### Scenario: 已完成物流换货单 + +- **WHEN** 当前资产已存在已完成的物流换货单 +- **THEN** 系统不再返回风险换卡候选 + +#### Scenario: 直接换货单不压制风险候选 + +- **WHEN** 当前资产只有已完成的直接换货单,且其余风险条件成立 +- **THEN** 系统仍返回风险换卡候选 + +#### Scenario: 他人资产 + +- **WHEN** 客户请求候选或提交地址的资产不属于自己 +- **THEN** 系统返回与资产不存在相同的不可见结果 ### Requirement: 运营弹窗实时匹配 -系统 SHALL 允许超级管理员和平台用户管理全局运营弹窗的标题、内容、有效期、启停、优先级、店铺/设备类型/卡类型范围、四种页面(首页、资产详情、套餐购买、资产钱包充值)、频率和一个可选受控操作。范围同一维度多选为任一匹配,未配置范围即全量;H5 请求必须携带当前页面资产标识,首页使用当前选中资产。操作仅可为套餐购买或资产钱包充值,不得配置任意 URL。 -运营弹窗仅在客户请求候选时实时匹配并创建或复用通知;每客户每配置支持仅一次或每天一次。候选只返回优先级最高一条,同优先级取最近更新时间最新;配置修改形成新版本,既有通知保留快照,修改后的仅一次配置可向原命中客户重新投放。配置到期/停用停止新投放,历史通知保留 90 天。 +系统 SHALL 允许超级管理员和平台用户管理全局运营弹窗的标题、内容、有效期、启停、优先级、店铺/设备类型/卡类型范围、四种页面(首页、资产详情、套餐购买、资产钱包充值)、频率和一个可选受控操作。范围同一维度多选为任一匹配,未配置范围即全量;已配置范围而当前资产在该维度没有可判定值时该配置不命中。H5 请求必须携带当前页面资产标识。操作仅可为套餐购买或资产钱包充值,不得配置任意 URL。 + +运营弹窗仅在客户请求候选时实时匹配并创建或复用通知;每客户每配置支持仅一次或每天一次。候选只返回优先级最高一条,同优先级取最近更新时间最新,启停操作同样更新最近更新时间。配置修改形成新版本,既有通知保留原快照且不被改写;修改后的仅一次配置可向原命中客户重新投放。配置到期或停用停止新投放,历史通知在通知中心展示 90 天。 #### Scenario: 风险与运营候选同时命中 + - **WHEN** 当前资产同时满足风险换卡和多个运营弹窗条件 - **THEN** 系统仅返回风险换卡候选,并保持通知未读 +#### Scenario: 优先级与最近更新排序 + +- **WHEN** 多条运营配置同时命中且优先级相同 +- **THEN** 系统只返回最近更新时间最新的一条 + +#### Scenario: 独立卡的设备类型维度 + +- **WHEN** 命中的运营配置配置了设备类型范围,但当前资产是未绑定设备的独立卡 +- **THEN** 系统不命中该配置 + +#### Scenario: 配置修改后重新投放 + +- **WHEN** 已按仅一次频率向客户投放过的配置被修改 +- **THEN** 配置版本递增,既有通知的内容与快照保持不变,该客户可再次命中一次 + +#### Scenario: 到期或停用 + +- **WHEN** 配置已到期或被停用 +- **THEN** 系统停止新投放,既有通知在展示期内仍可见 + ### Requirement: 通知留存与已读 -弹窗投放 SHALL 复用个人客户站内通知,保存投放时内容、配置版本、资产和受控操作快照,并同时出现在通知列表。创建或返回候选不得自动已读;客户关闭、点击操作或进入通知详情后通过既有已读接口幂等标记已读。通知读取必须维持个人客户隔离。 + +弹窗投放 SHALL 复用个人客户站内通知,保存投放时内容、受控操作与旧资产关联;运营弹窗通知额外保存配置标识与配置版本快照。投放后通知同时出现在通知列表与未读数中,并自投放时间起展示 90 天。 + +创建或返回候选不得自动已读;客户关闭、点击操作或进入通知详情后通过既有已读接口幂等标记已读。通知读取必须维持个人客户隔离。 #### Scenario: 关闭弹窗 + - **WHEN** 当前个人客户关闭其未读弹窗 - **THEN** 系统仅标记该客户该通知已读,不影响其他客户或未来符合条件的投放 + +#### Scenario: 同一天重复请求 + +- **WHEN** 客户在同一天多次请求同一资产的候选 +- **THEN** 系统复用同一条通知,不重复投放,且当日只返回一次候选 + +#### Scenario: 已读后当天不再返回 + +- **WHEN** 客户关闭弹窗后当天再次请求候选 +- **THEN** 系统不再返回该候选,次日条件仍成立时创建新通知重新投放 + +#### Scenario: 通知列表可见 + +- **WHEN** 投放产生了一条弹窗通知 +- **THEN** 该通知出现在当前客户的未读数与通知列表中,并可通过既有已读接口标记已读 diff --git a/openspec/changes/add-h5-risk-exchange-notifications/tasks.md b/openspec/changes/add-h5-risk-exchange-notifications/tasks.md index ab5fc53..dfc22fe 100644 --- a/openspec/changes/add-h5-risk-exchange-notifications/tasks.md +++ b/openspec/changes/add-h5-risk-exchange-notifications/tasks.md @@ -1,13 +1,101 @@ -## 1. 数据与配置 -- [ ] 1.1 追踪个人通知、资产风险状态、物流换货、H5 认证和既有已读链路。 -- [ ] 1.2 新增弹窗配置、版本/投放去重所需成对迁移、模型、范围和索引。 -- [ ] 1.3 实现管理端配置 CRUD、启停、受控页面/动作/频率校验和审计。 +## 1. 数据与契约 + +- [x] 1.1 追踪个人通知、资产风险状态、物流换货、H5 认证和既有已读链路。 +- [x] 1.2 新增运营弹窗配置表(含 `version`、页面、范围、优先级、频率、受控动作、启停、有效期)及所需索引,并在同一迁移内为 `tb_notification` 新增可空 JSONB 列 `popup_snapshot`;成对迁移编号从 `000225` 起,实施时复核当前最大编号。 +- [x] 1.3 新增通知类型常量 `h5.popup.risk_exchange`、`h5.popup.operation`(`pkg/constants/notification.go`)。 +- [x] 1.4 通知注册表登记两个 `Definition`:类别沿用 `system`,接收人限个人客户,资源引用限既有 `asset`(`ref_id` 为资产数字 ID、`ref_key` 为资产标识快照,不承载配置信息)。 +- [x] 1.5 同步个人通知查询的类型白名单(`internal/query/notification/query.go` 的 `personalNotificationScope`)。 +- [x] 1.6 同步个人通知已读的类型白名单(`internal/application/notification/read.go` 的 `personalReadScope`);不新增通知类别、不改 DB CHECK。 +- [x] 1.7 同步后台通知 DTO 的 `type` oneof/enums 与 `NotificationItem` 枚举文案(ENG-DTO-001)。 +- [x] 1.8 通知 DTO 增加可选弹窗快照字段(配置 ID、配置版本、资产类型与 ID、受控动作),仅弹窗类型返回,不返回任何 URL 或前端路由。 +- [x] 1.9 实现管理端配置 CRUD、启停、版本递增与最近更新时间刷新、受控页面/动作/频率校验、范围维度校验和审计。 ## 2. H5 行为 -- [ ] 2.1 实现携带资产标识的候选接口:风险条件、每日限制、运营范围/优先级/版本匹配和个人通知创建/复用。 -- [ ] 2.2 实现风险地址提交、唯一物流换货单创建、首次地址锁定及数据迁移默认边界。 -- [ ] 2.3 接入既有个人通知列表和已读,注册路由/OpenAPI。 + +- [x] 2.1 实现窄接口 `DirectWriter.CreateOrGetPersonal`:与 Outbox 消费共用模板渲染、展示期计算与 `CreateIdempotent`,冲突时回查返回既有行;把既有直投分支重构为调用同一 helper。 +- [x] 2.2 装配 `DirectWriter` 到 `internal/bootstrap/handlers.go`、`internal/bootstrap/types.go` 与 `pkg/openapi/handlers.go`。 +- [x] 2.3 实现活动物流换货单查询:`flow_type=shipping AND status IN (1,2,3,4)`(含已完成),不限定流程会永久压制候选。 +- [x] 2.4 实现候选接口(`identifier` 形态携带页面与当前资产):广电卡 + 风险停机(严格等于既有常量,不与已销户合并)+ 无活动物流换货单判定,资产归属使用 `customer_binding.OwnsAsset`,资产不存在与归属失败返回同态不可见。 +- [x] 2.5 实现候选接口的风险优先返回、上海自然日去重(落在通知事件键)与运营配置匹配(时间/启停/页面/店铺/设备类型/卡类型范围/频率,`priority DESC, updated_at DESC, id DESC`),并按第 7 节写入 `popup_snapshot`(仅弹窗投放类型)。 +- [x] 2.6 实现 `SubmitRiskAddress`:锁旧资产行 → 复核风险资格 → 去重查询 → 未命中才创建(待发货、`shipping`、`migrate_data=false`、`migration_status=not_migrated`、原因非空、`Creator/Updater` 置 0);不复用只查虚拟号的内部归属判定,不沿用资产级群发通知。 +- [x] 2.7 接入既有个人通知列表与已读,注册路由/OpenAPI 并同步两处装配。 ## 3. 验证 -- [ ] 3.1 隔离库验证风险/运营优先级、频率、范围、版本、地址重复提交、通知隔离及迁移 up/down/up。 -- [ ] 3.2 运行 `gofmt -w`、`go build ./cmd/api ./cmd/worker`、`go run cmd/gendocs/main.go`、`openspec validate add-h5-risk-exchange-notifications --strict` 和 `openspec doctor --json`;自动化测试按项目决策为 N/A。 \ No newline at end of file + +- [x] 3.1 存在已完成物流换货单时不再返回风险候选。 +- [x] 3.2 存在直接换货单(创建即已完成)时不压制风险候选。 +- [x] 3.3 同一天重复请求返回同一通知 ID 且当日只返回一次候选。 +- [x] 3.4 已读后当天不再返回候选,次日条件仍成立时创建新通知重新投放。 +- [x] 3.5 风险与运营同时命中时只返回风险候选且通知保持未读。 +- [x] 3.6 地址重复提交与并发提交只产生一张物流换货单,保留首次地址;`migrate_data=false`、`migration_status=not_migrated`。 +- [x] 3.7 配置修改后版本递增且旧通知内容与快照不变;仅一次配置可向原命中客户重新投放一次。 +- [x] 3.8 启停与同优先级最近更新的排序正确(启停后最近更新时间被刷新)。 +- [x] 3.9 独立卡在设备类型维度为空时不命中已配置设备类型范围的配置;未配置该维度时全量命中。 +- [x] 3.10 他人资产与不存在资产返回同态不可见;他人通知按既有隔离不可见。 +- [x] 3.11 新建通知出现在个人通知未读数与列表中,且可被既有已读接口标记已读。 +- [x] 3.12 弹窗通知返回可选快照字段(配置 ID、配置版本、资产类型与 ID、受控动作),不含任何 URL 或前端路由;其他通知类型该字段为空。 +- [x] 3.13 隔离库执行迁移 up/down/up(含 `popup_snapshot` 列的新增与回滚)。 +- [x] 3.14 运行 `gofmt -w`、`go build ./cmd/api ./cmd/worker`、`go run cmd/gendocs/main.go`、`openspec validate add-h5-risk-exchange-notifications --strict` 和 `openspec doctor --json`;自动化测试按项目决策为 N/A。 + + +## 实施状态(本轮) + +### 已完成 + +- 1.1~1.9、2.1~2.7:已实现(详细落点见下方「实现落点」)。 +- 3.1~3.14:已在测试环境真实执行。验证面:PostgreSQL `junhong_cmp_test`(`scripts/migrate.sh` 配显式 `DB_*`)+ 本地 Redis DB 6 + 真实 API 进程(`:18080`,`JUNHONG_LOGGING_DEVELOPMENT=true` 以便用 `POST /api/c/v1/auth/dev-login` 取得个人客户令牌)。全部 HTTP 请求为真实调用;库内事实用只读查询或夹具程序读取。 +- 证据日志:`/tmp/h5popup-verify/evidence.log`(99 项 PASS / 0 项 FAIL,脚本 `/tmp/h5popup-verify/run-verify.sh`,夹具程序 `.verify-h5popup/`)。 +- 独立审查提出的 5 条必修项已全部修复并单独留证(见下方「独立审查修复」),随后全量重跑无回归。 + +| 任务 | 关键证据 | +| --- | --- | +| 3.1 | 夹具插入 `flow_type=shipping,status=4` 换货单后 `GET /api/c/v1/popup-candidates` → HTTP 200 且 `data.candidate=null` | +| 3.2 | 夹具插入 `flow_type=direct,status=4` 换货单后同一接口 → `popup_type=risk_exchange`、`notification_type=h5.popup.risk_exchange` | +| 3.3 | 连续两次请求 → 两次均 200 且返回同一 `notification_id`;库内该客户当日 `h5.popup.risk_exchange` 行数 = 1 | +| 3.4 | 对当日通知调用 `PUT /api/c/v1/notifications/:id/read` 后当天再请求 → `candidate=null`;预置一条昨日 `event_id` 的已读通知后请求 → 新通知主键 ≠ 夹具主键(日期已进入去重键,跨自然日重新投放) | +| 3.5 | 风险与运营配置同时命中时 → `popup_type=risk_exchange`,库内该通知 `is_read=false`,且该客户无 `h5.popup.operation` 行 | +| 3.6 | 两个并发 POST `risk-exchanges/:asset_id/address` 均 200 且返回同一换货单主键;库内该卡换货单 1 行 `{status:2, flow_type:shipping, migrate_data:false, migration_status:not_migrated, creator:0, updater:0}`;随后用不同地址重复提交仍返回同一单且库内地址与首次一致(未被覆盖) | +| 3.7 | 配置 v1 投放后取该通知行(`popup_snapshot.config_version=1`)→ `PUT` 改标题 → 响应版本递增为 2 且该行改前改后逐字段 diff 无差异(title/body/popup_snapshot 均未变)→ 已读后再次请求产生新通知(版本 2、标题为新值) | +| 3.8 | 同优先级两条配置 → 取 `updated_at` 更新的那条(按标题比对);对另一条 `disable`+`enable` 刷新 `updated_at` 后该条胜出,且启停未改变版本(仍为 1) | +| 3.9 | 独立卡(设备类型维度为空)不命中 `device_types=["5G-CPE"]` 的高优先级配置(100),命中的是未配置该维度的配置(50);被排除配置的 `device_types[0]` 已核对为 `5G-CPE` | +| 3.10 | 他人资产与不存在资产:HTTP 400 / `code=1180` / `msg=资产不存在` 三者逐项一致;他人通知列表 `total=0` | +| 3.11 | 未读数 0 → 投放 → 未读数 1 → `GET /api/c/v1/notifications` 的 `total>=1` 且首项就是本次投放的通知(`type=h5.popup.risk_exchange`)→ `PUT /:id/read` 返回 success → 库内该行 `is_read=true` 且 `read_at` 非空 → 未读数回落 0 | +| 3.12 | 运营弹窗快照含 `config_id>0`、`config_version`、`asset_type=iot_card` 与关联 `asset_id`;风险弹窗快照 `config_id=0`;列表响应 `grep -c 'https\?://' = 0` 且无 `"/[A-Za-z#]` 形态路由串;`exchange.shipping.created` 通知 `popup_snapshot` 为空 | +| 3.13 | `scripts/migrate.sh up` → `down 1` → `up`(225 双向成功);只读核对新表 18 列与 `tb_notification.popup_snapshot` | +| 3.14 | `gofmt -w`、`go build ./...`、`go run cmd/gendocs/main.go`、`./scripts/context-health.sh`、`openspec validate --strict`、`openspec doctor --json` 全部通过;代理与企业令牌访问配置接口均 HTTP 403 / `code=1005`;正文含 `https://` 或 `/pages/...` 均 HTTP 400 / `code=1001` 且提示为「运营弹窗正文不接受 URL 或前端路由,只能使用受控动作」;`action_type=open_url` → 400 / 1001;结束时间早于开始时间 → 400 / 1001 | + +### 独立审查修复(本轮,均已留证据) + +| 编号 | 问题 | 修复 | 证据 | +| --- | --- | --- | --- | +| IMP-1 | 自实现标识解析只查 virtual_no/iccid/msisdn,缺 iccid_19/iccid_20,与既有口径不一致会导致静默不投放 | 删除自实现查询,`resolveAssetIdentity` 改为复用既有 Store 口径(`AssetIdentifierStore.FindByIdentifier` → `DeviceStore.GetByIdentifier` → `IotCardStore.GetByIdentifier`,后者已含 iccid_19/iccid_20);卡/设备加载同样改用 `GetByID`。Store 由 bootstrap 注入 | 未登记进 `tb_asset_identifier` 的 20 位卡(`iccid_20` 非空且等于完整 ICCID,`registered=0`)用 `identifier=<20位 ICCID>` 请求候选 → HTTP 200、`popup_type=risk_exchange`、`code=0`,不再返回「资产不存在」 | +| IMP-2 | `notification.deliver` 只登记 system_task/worker 入口,API 直投(personal_customer/personal_api)被入口规则拒绝后静默降级 | ①注册表为该动作追加 `AllowedOrigins{personal_customer, personal_api}`(保留既有 worker 入口);②`CreateOrGetPersonal` 显式声明审计操作者与入口(个人客户请求上下文不携带 auditcontext,仅改注册表仍会被拒) | 整轮 `grep -c '业务审计写入失败,已降级'` = 0;库内 `notification.deliver` 由 42 条(system_task/worker,历史保留)增至 53 条,其中 11 条 `actor_kind=personal_customer, source=personal_api`,`latest=2026-09-15 15:13:07+08` | +| MIN-1 | 重构后 Render/now/expiresAt 落入逐接收人循环,且动态接收人为 0 时早返回使载荷校验不再执行 | `deliver` 内新增 `prepareDelivery` 一次算出渲染结果、展示时间与审计来源,`deliverOne` 只做单接收人落库;`consumeDirect`/`consumeDynamic` 恢复在接收人解析前调用 `validateDeliveryRequest`;审计接缝判空回到渲染之前的单次判断 | 3.1/3.2/3.3/3.4/3.5/3.6/3.7/3.8/3.9/3.10/3.11/3.12/3.14 全量重跑无回归(99 PASS / 0 FAIL) | +| MIN-2 | 标题未做 URL/前端路由校验,可绕过正文拦截 | `normalizeConfigurationInput` 对 title 施加与 content 同一套 `popupURLPattern`/`popupRoutePattern` 校验 | `title=点击 https://evil.example/t` → HTTP 400 / `code=1001` / msg「运营弹窗标题不接受 URL 或前端路由,只能使用受控动作」;`title=打开 /pages/x 查看` 同样被拒 | +| MIN-4 | 新包内 `AuditWriter`/`ConfigAuditWriter` 是单实现接口 | 删除两个接口,直接使用具体类型 `*audit.Writer`(与 `businessusergroup.Service.auditWriter` 既有写法一致);`DirectWriter` 为 design §3 明确要求的窄接口,保留 | `go build ./...` 通过;审计记录由同一 Writer 写入(见 IMP-2 证据) | + +### 已知残留与后续 Change 建议(本轮不改代码) + +- **IMP-3(风险换卡地址提交缺少资产状态与退款守卫)**:`SubmitRiskAddress` 未校验资产状态与未终结退款。已确认不属于本 Change 需求:本 Change 的风险条件是穷举的(广电卡 + 严格等于风险停机的运营商扩展状态 + 无活动物流换货单),且 spec 场景明确要求「仅存在已完成 direct 换货单时仍返回风险候选」,而 direct 换货完成会把资产状态置为已换货,加状态守卫会与该场景冲突。**后续 Change 建议**:若业务确认风险换卡还需排除「已换货/已销户」或「存在未终结退款」的资产,应新建 Change 同时更新 spec 场景与守卫条件,并评估对已完成 direct 换货客户的影响。 +- **MIN-3(风险弹窗当天已读后,当天是否还应抑制运营弹窗)**:保持现状(风险资格成立即只处理风险分支,不下落运营匹配)。理由见上节;**建议后续在 spec 补一句**明确「风险资格成立但当日已投放并已读时,当日不再返回任何弹窗候选(含运营弹窗)」。 +- **MIN-6(设备类型为自由文本)**:运营弹窗的设备类型范围沿用 `tb_device.device_type` 自由文本,未收敛枚举。**后续 Change 建议**:若要做字典化管理,应新建 Change 收敛设备类型取值并同步范围校验口径。 +- **SUG-1~SUG-6**:审查提出的 6 条改进建议(具体条目见审查记录)本轮均不改代码,登记为后续 Change 候选。 + +### 与 design 的取舍(已确认保留) + +- **风险资格命中时不下落到运营匹配**:design §9 把「客户+资产+上海自然日未展示」列入「风险命中」集合,字面执行会得出「客户当天关闭风险弹窗后,再请求可返回运营弹窗」。本实现改为:风险资格(广电卡 + 严格等于风险停机的运营商扩展状态 + 无活动物流换货单)成立即只处理风险分支——当日通知存在且未读则返回同一通知(满足 3.3),已读则返回空候选(满足 3.4),既不下落运营匹配,也不在当天重复投放。理由:spec 场景「风险与运营候选同时命中 → 仅返回风险换卡候选」的前置条件只是「同时满足风险换卡与运营条件」,并未排除「当天已关闭」;且「稍后处理仅抑制当天」若同时放开运营投放,会与「风险换卡优先级固定高于运营弹窗」相冲突。取舍点注释在 `internal/application/h5popup/candidate.go` 的 `GetCandidate`。 +- **运营弹窗频率去重键不含资产**:按 design §7 与 spec「每客户每配置支持仅一次或每天一次」,键为「客户+配置+版本」(`daily` 再追加上海自然日);「后台与 H5 动作契约」中出现的「资产」只落在通知 `ref_type/ref_id/ref_key`。注释在 `operationEventKey`。 +- **未实测项**:个人客户令牌访问后台配置接口由后台认证中间件先行拒绝(HTTP 401 / `code=1003`),因此该用例证明的是「个人令牌无法进入后台配置边界」,平台范围校验由代理与企业两条 403 证据覆盖。 +- **`up`/`down` 破坏性说明**:`down` 会删除 `popup_snapshot` 与配置表,恢复方式与不可逆范围已写在 `000225_add_h5_popup_configuration.down.sql` 头部注释。 + +### 实现落点 + +- 迁移:`migrations/000225_add_h5_popup_configuration.{up,down}.sql`。 +- 常量/错误码/审计注册:`pkg/constants/h5_popup.go`、`pkg/constants/notification.go`、`pkg/constants/audit.go`、`pkg/errors/codes.go`、`internal/infrastructure/audit/registry.go`。 +- 模型与 DTO:`internal/model/h5_popup_configuration.go`、`internal/model/notification.go`、`internal/model/dto/h5_popup_dto.go`、`internal/model/dto/notification_dto.go`。 +- 通知链路:`internal/application/notification/direct.go`(窄接口 + 直投,含显式审计来源)、`delivery.go`(`prepareDelivery` 一次计算 + `deliverOne` 单接收人落库)、`read.go`;`internal/infrastructure/notification/repository.go`、`registry.go`;`internal/query/notification/query.go`;投递审计入口 `internal/infrastructure/audit/registry.go`。 +- 用例与查询:`internal/application/h5popup/{asset,candidate,risk_exchange,configuration}.go`(资产标识复用既有 Store 口径,审计使用具体 `*audit.Writer`)、`internal/query/h5popup/query.go`。 +- 边界与路由:`internal/handler/app/client_popup.go`、`internal/handler/admin/h5_popup_configuration.go`、`internal/routes/personal_popup.go`、`internal/routes/h5_popup_configuration.go`、`internal/routes/{personal,admin}.go`。 +- 装配:`internal/bootstrap/{handlers,types}.go`、`pkg/openapi/handlers.go`、`cmd/api/docs.go`、`cmd/gendocs/main.go`。 +- 共用参数提示抽取:`internal/handler/validation/validation.go`(由 `internal/handler/admin/withdrawal_qualification.go` 委派复用)。 +- 验证期临时资产(复核完成后清理):`.verify-h5popup/`、`/tmp/h5popup-verify/`。 diff --git a/pkg/constants/audit.go b/pkg/constants/audit.go index c6fdd2d..bad870a 100644 --- a/pkg/constants/audit.go +++ b/pkg/constants/audit.go @@ -446,6 +446,16 @@ const ( AuditActionCarrierDeleted = "carrier.delete" // AuditActionCarrierStatusUpdated 表示更新运营商配置状态。 AuditActionCarrierStatusUpdated = "carrier.update_status" + // AuditActionH5PopupConfigurationCreated 表示创建 H5 运营弹窗配置。 + AuditActionH5PopupConfigurationCreated = "h5_popup_configuration.create" + // AuditActionH5PopupConfigurationUpdated 表示更新 H5 运营弹窗配置并递增版本。 + AuditActionH5PopupConfigurationUpdated = "h5_popup_configuration.update" + // AuditActionH5PopupConfigurationEnabled 表示启用 H5 运营弹窗配置。 + AuditActionH5PopupConfigurationEnabled = "h5_popup_configuration.enable" + // AuditActionH5PopupConfigurationDisabled 表示停用 H5 运营弹窗配置。 + AuditActionH5PopupConfigurationDisabled = "h5_popup_configuration.disable" + // AuditActionCardRiskExchangeRequested 表示个人客户自助发起风险换卡。 + AuditActionCardRiskExchangeRequested = "exchange.card.request_risk_exchange" // AuditActionWeComApplicationSaved 表示保存企业微信应用配置。 AuditActionWeComApplicationSaved = "wecom.application.save" // AuditActionWeComDefaultCreatorSaved 表示保存企业微信默认审批发起人。 @@ -618,6 +628,14 @@ const ( AuditOperationCarrierDelete = "carrier_delete" // AuditOperationCarrierStatusUpdate 表示更新运营商配置状态。 AuditOperationCarrierStatusUpdate = "carrier_status_update" + // AuditOperationH5PopupConfigurationCreate 表示创建 H5 运营弹窗配置。 + AuditOperationH5PopupConfigurationCreate = "h5_popup_configuration_create" + // AuditOperationH5PopupConfigurationUpdate 表示更新 H5 运营弹窗配置。 + AuditOperationH5PopupConfigurationUpdate = "h5_popup_configuration_update" + // AuditOperationH5PopupConfigurationEnable 表示启用 H5 运营弹窗配置。 + AuditOperationH5PopupConfigurationEnable = "h5_popup_configuration_enable" + // AuditOperationH5PopupConfigurationDisable 表示停用 H5 运营弹窗配置。 + AuditOperationH5PopupConfigurationDisable = "h5_popup_configuration_disable" // AuditOperationWeComApplicationSave 表示保存企业微信应用配置。 AuditOperationWeComApplicationSave = "wecom_application_save" // AuditOperationWeComDefaultCreatorSave 表示保存企业微信默认审批发起人。 @@ -645,6 +663,8 @@ const ( AuditResourcePaymentConfig = "payment_config" // AuditResourceCarrier 表示运营商配置资源。 AuditResourceCarrier = "carrier" + // AuditResourceH5PopupConfiguration 表示 H5 运营弹窗配置资源。 + AuditResourceH5PopupConfiguration = "h5_popup_configuration" // AuditResourceEmployeeCollectionPaymentMethod 表示线下收款方式字典资源。 AuditResourceEmployeeCollectionPaymentMethod = "employee_collection_payment_method" // AuditResourceEmployeeCollectionBill 表示员工代收款账单资源。 diff --git a/pkg/constants/h5_popup.go b/pkg/constants/h5_popup.go new file mode 100644 index 0000000..f5125bf --- /dev/null +++ b/pkg/constants/h5_popup.go @@ -0,0 +1,140 @@ +package constants + +const ( + // H5PopupPageHome 表示 H5 首页。 + H5PopupPageHome = "home" + // H5PopupPageAssetDetail 表示 H5 资产详情页。 + H5PopupPageAssetDetail = "asset_detail" + // H5PopupPagePackagePurchase 表示 H5 套餐购买页。 + H5PopupPagePackagePurchase = "package_purchase" + // H5PopupPageAssetWalletRecharge 表示 H5 资产钱包充值页。 + H5PopupPageAssetWalletRecharge = "asset_wallet_recharge" + + // H5PopupFrequencyOnce 表示每客户每配置版本仅投放一次。 + H5PopupFrequencyOnce = "once" + // H5PopupFrequencyDaily 表示每客户每配置版本每个上海自然日投放一次。 + H5PopupFrequencyDaily = "daily" + + // H5PopupActionPackagePurchase 表示受控动作套餐购买。 + H5PopupActionPackagePurchase = "package_purchase" + // H5PopupActionAssetWalletRecharge 表示受控动作资产钱包充值。 + H5PopupActionAssetWalletRecharge = "asset_wallet_recharge" + + // H5PopupStatusDisabled 表示运营弹窗配置已停用。 + H5PopupStatusDisabled = 0 + // H5PopupStatusEnabled 表示运营弹窗配置已启用。 + H5PopupStatusEnabled = 1 + + // H5PopupCandidateTypeRiskExchange 表示候选为风险换卡弹窗。 + H5PopupCandidateTypeRiskExchange = "risk_exchange" + // H5PopupCandidateTypeOperation 表示候选为运营弹窗。 + H5PopupCandidateTypeOperation = "operation" + + // H5PopupRiskExchangeReason 是客户自助风险换卡单的固定换货原因,保证换货原因非空且可追溯来源。 + H5PopupRiskExchangeReason = "运营商风险停机,客户自助申请寄送新卡" + + // H5PopupDisplayDays 表示弹窗投放通知的展示保留天数,与投放时间共同决定 expires_at。 + H5PopupDisplayDays = 90 + + // H5PopupRiskEventKeyPrefix 是风险换卡候选通知的事件键前缀,键内包含上海自然日以保证一天一条。 + H5PopupRiskEventKeyPrefix = "h5popup.risk" + // H5PopupOperationOnceEventKeyPrefix 是仅一次运营弹窗通知的事件键前缀。 + H5PopupOperationOnceEventKeyPrefix = "h5popup.once" + // H5PopupOperationDailyEventKeyPrefix 是每天一次运营弹窗通知的事件键前缀,键内包含上海自然日。 + H5PopupOperationDailyEventKeyPrefix = "h5popup.daily" + + // H5PopupAuditConfigKeyPrefix 表示运营弹窗配置审计配置键前缀。 + H5PopupAuditConfigKeyPrefix = "h5_popup_configuration" + // H5PopupAuditModule 表示 H5 弹窗能力的审计模块标识。 + H5PopupAuditModule = "h5_popup" +) + +// IsH5PopupPage 判断页面是否为受控的 H5 弹窗页面。 +func IsH5PopupPage(page string) bool { + switch page { + case H5PopupPageHome, H5PopupPageAssetDetail, H5PopupPagePackagePurchase, H5PopupPageAssetWalletRecharge: + return true + default: + return false + } +} + +// IsH5PopupFrequency 判断投放频率是否为受控值。 +func IsH5PopupFrequency(frequency string) bool { + switch frequency { + case H5PopupFrequencyOnce, H5PopupFrequencyDaily: + return true + default: + return false + } +} + +// IsH5PopupActionType 判断受控动作是否合法;空值表示无受控动作。 +func IsH5PopupActionType(actionType string) bool { + switch actionType { + case "", H5PopupActionPackagePurchase, H5PopupActionAssetWalletRecharge: + return true + default: + return false + } +} + +// IsCarrierType 判断卡类型是否为受控运营商类型;用于范围配置的取值校验。 +func IsCarrierType(carrierType string) bool { + switch carrierType { + case CarrierTypeCMCC, CarrierTypeCUCC, CarrierTypeCTCC, CarrierTypeCBN: + return true + default: + return false + } +} + +// IsH5PopupNotificationType 判断通知类型是否为弹窗投放类型;只有弹窗类型才写投放快照。 +func IsH5PopupNotificationType(notificationType string) bool { + switch notificationType { + case NotificationTypeH5PopupRiskExchange, NotificationTypeH5PopupOperation: + return true + default: + return false + } +} + +// GetH5PopupFrequencyName 获取运营弹窗投放频率名称。 +func GetH5PopupFrequencyName(frequency string) string { + switch frequency { + case H5PopupFrequencyOnce: + return "仅一次" + case H5PopupFrequencyDaily: + return "每天一次" + default: + return "" + } +} + +// GetH5PopupEnabledName 获取运营弹窗启停状态名称。 +func GetH5PopupEnabledName(status int) string { + switch status { + case H5PopupStatusEnabled: + return "启用" + case H5PopupStatusDisabled: + return "停用" + default: + return "" + } +} + +// GetH5PopupPageName 获取 H5 弹窗页面名称。 +func GetH5PopupPageName(page string) string { + switch page { + case H5PopupPageHome: + return "首页" + case H5PopupPageAssetDetail: + return "资产详情" + case H5PopupPagePackagePurchase: + return "套餐购买" + case H5PopupPageAssetWalletRecharge: + return "资产钱包充值" + default: + return "" + } +} diff --git a/pkg/constants/notification.go b/pkg/constants/notification.go index 07090a5..c3dd5d1 100644 --- a/pkg/constants/notification.go +++ b/pkg/constants/notification.go @@ -42,6 +42,10 @@ const ( NotificationTypeExchangeShippingCreated = "exchange.shipping.created" // NotificationTypeAgentMainWalletLowBalance 表示代理主钱包余额低于固定阈值提醒。 NotificationTypeAgentMainWalletLowBalance = "agent.main_wallet.low_balance" + // NotificationTypeH5PopupRiskExchange 表示 H5 广电卡风险换卡弹窗投放。 + NotificationTypeH5PopupRiskExchange = "h5.popup.risk_exchange" + // NotificationTypeH5PopupOperation 表示 H5 运营弹窗投放。 + NotificationTypeH5PopupOperation = "h5.popup.operation" // NotificationRefTypeSystemConfig 表示系统配置资源引用。 NotificationRefTypeSystemConfig = "system_config" diff --git a/pkg/errors/codes.go b/pkg/errors/codes.go index 5c29df7..264bc19 100644 --- a/pkg/errors/codes.go +++ b/pkg/errors/codes.go @@ -192,6 +192,10 @@ const ( CodeEmployeeCollectionBillClosed = 1242 // 账单已关闭 CodeEmployeeCollectionVoucherInvalid = 1243 // 支付凭证数量或内容不符合要求 + // H5 弹窗相关错误 (1250-1259) + CodeH5PopupConfigurationNotFound = 1250 // 运营弹窗配置不存在 + CodeH5PopupRiskNotEligible = 1251 // 当前资产不满足风险换卡条件 + // 服务端错误 (2000-2999) -> 5xx HTTP 状态码 CodeInternalError = 2001 // 内部服务器错误 CodeDatabaseError = 2002 // 数据库错误 @@ -350,6 +354,8 @@ var allErrorCodes = []int{ CodeEmployeeCollectionApplicationPending, CodeEmployeeCollectionBillClosed, CodeEmployeeCollectionVoucherInvalid, + CodeH5PopupConfigurationNotFound, + CodeH5PopupRiskNotEligible, CodeInternalError, CodeDatabaseError, CodeRedisError, @@ -502,6 +508,8 @@ var errorMessages = map[int]string{ CodeEmployeeCollectionApplicationPending: "账单存在审批中核销申请,不能关闭", CodeEmployeeCollectionBillClosed: "账单已关闭", CodeEmployeeCollectionVoucherInvalid: "支付凭证数量或内容不符合要求", + CodeH5PopupConfigurationNotFound: "运营弹窗配置不存在", + CodeH5PopupRiskNotEligible: "当前资产不满足风险换卡条件", CodeInvalidCredentials: "用户名或密码错误", CodeAccountLocked: "账号已锁定", CodePasswordExpired: "密码已过期", diff --git a/pkg/openapi/handlers.go b/pkg/openapi/handlers.go index e3f4625..bb0992a 100644 --- a/pkg/openapi/handlers.go +++ b/pkg/openapi/handlers.go @@ -26,6 +26,7 @@ func BuildDocHandlers() *bootstrap.Handlers { ClientDevice: app.NewClientDeviceHandler(nil, nil, nil, nil, nil, nil, nil), ClientRechargeOrder: app.NewClientRechargeOrderHandler(nil, nil, nil), ClientNotification: app.NewClientNotificationHandler(nil, nil, nil), + ClientPopup: app.NewClientPopupHandler(nil, nil, nil), Shop: admin.NewShopHandler(nil, nil), ShopRole: admin.NewShopRoleHandler(nil), AdminAuth: admin.NewAuthHandler(nil, nil), @@ -42,6 +43,7 @@ func BuildDocHandlers() *bootstrap.Handlers { IotCardImport: admin.NewIotCardImportHandler(nil), ExportTask: admin.NewExportTaskHandler(nil), Notification: admin.NewNotificationHandler(nil, nil, nil), + H5PopupConfiguration: admin.NewH5PopupConfigurationHandler(nil, nil, nil), Device: admin.NewDeviceHandler(nil), DeviceImport: admin.NewDeviceImportHandler(nil), AssetAllocationRecord: admin.NewAssetAllocationRecordHandler(nil),