diff --git a/internal/application/notification/read.go b/internal/application/notification/read.go index 19c13561..ee815d7f 100644 --- a/internal/application/notification/read.go +++ b/internal/application/notification/read.go @@ -68,13 +68,17 @@ func (s *ReadService) MarkPersonalRead(ctx context.Context, customerID, notifica constants.AuditActorPersonalCustomer, constants.AuditSourcePersonalAPI) } -// MarkAllPersonalRead 将当前个人客户可见的全部未过期通知幂等标记为已读。 -func (s *ReadService) MarkAllPersonalRead(ctx context.Context, customerID uint) (*dto.NotificationReadAllResponse, error) { +// MarkAllPersonalRead 将当前个人客户可见的全部未过期通知幂等标记为已读; +// 套餐临期提醒只处理当前资产的,其他资产的临期提醒保持未读。 +func (s *ReadService) MarkAllPersonalRead(ctx context.Context, customerID uint, currentAssetKey string) (*dto.NotificationReadAllResponse, error) { if customerID == 0 { return nil, errors.New(errors.CodeInvalidParam) } count, err := s.markAllRead(ctx, constants.NotificationRecipientKindPersonalCustomer, customerID, "", - constants.AuditActorPersonalCustomer, constants.AuditSourcePersonalAPI) + constants.AuditActorPersonalCustomer, constants.AuditSourcePersonalAPI, func(query *gorm.DB) *gorm.DB { + return query.Where(constants.NotificationPersonalExpiringAssetCondition, + constants.NotificationTypePackageExpiring, constants.NotificationRefTypeAsset, currentAssetKey) + }) if err != nil { return nil, err } @@ -107,7 +111,7 @@ func (s *ReadService) markOneRead(ctx context.Context, recipientKind string, rec }) } -func (s *ReadService) markAllRead(ctx context.Context, recipientKind string, recipientID uint, category, actorKind, source string) (int64, error) { +func (s *ReadService) markAllRead(ctx context.Context, recipientKind string, recipientID uint, category, actorKind, source string, scopes ...func(*gorm.DB) *gorm.DB) (int64, error) { if s.auditWriter == nil { return 0, errors.New(errors.CodeInvalidStatus, "通知统一审计接缝未配置") } @@ -120,6 +124,9 @@ func (s *ReadService) markAllRead(ctx context.Context, recipientKind string, rec if category != "" { query = query.Where("category = ?", category) } + for _, scope := range scopes { + query = scope(query) + } if err := query.Order("id ASC").Find(¬ifications).Error; err != nil { return errors.Wrap(errors.CodeDatabaseError, err, "查询批量通知已读状态失败") } diff --git a/internal/application/packageexpiry/entry_reminder.go b/internal/application/packageexpiry/entry_reminder.go new file mode 100644 index 00000000..4191512a --- /dev/null +++ b/internal/application/packageexpiry/entry_reminder.go @@ -0,0 +1,98 @@ +package packageexpiry + +import ( + "context" + "time" + + "github.com/redis/go-redis/v9" + "go.uber.org/zap" + + "github.com/break/junhong_cmp_fiber/internal/model/dto" + packageexpiryquery "github.com/break/junhong_cmp_fiber/internal/query/packageexpiry" + "github.com/break/junhong_cmp_fiber/pkg/constants" +) + +// entryReminderWindowDays 临期提醒窗口:剩余 0~15 个上海自然日。 +const entryReminderWindowDays = 15 + +var entryReminderLocation = time.FixedZone("Asia/Shanghai", 8*60*60) + +// PersonalReminderPublisher 为单个个人客户幂等写一条临期通知。 +type PersonalReminderPublisher interface { + PublishPersonal(ctx context.Context, candidate dto.ExpiringAssetItem, customerID uint) error +} + +// EntryReminder 在 H5 进入资产时判定并发出套餐临期提醒,同一客户、同一资产、同一上海自然日至多一次。 +type EntryReminder struct { + redis *redis.Client + publisher PersonalReminderPublisher + logger *zap.Logger + now func() time.Time +} + +// NewEntryReminder 创建进入资产时的临期提醒用例。 +func NewEntryReminder(redisClient *redis.Client, publisher PersonalReminderPublisher, logger *zap.Logger) *EntryReminder { + return &EntryReminder{redis: redisClient, publisher: publisher, logger: logger, now: time.Now} +} + +// Remind 返回本次应展示的临期提醒;不在窗口、当天已提醒或通知写入失败时返回 nil,不让资产信息接口失败。 +// 顺序:检查去重键 → 写通知 → SET NX → 抢到键才返回;Redis 不可用按未提醒处理,通知由事件号幂等。 +func (r *EntryReminder) Remind(ctx context.Context, customerID uint, candidate dto.ExpiringAssetItem) *dto.AssetExpiryReminder { + if r == nil || r.publisher == nil || customerID == 0 || !inEntryReminderWindow(candidate.PackageExpiryEstimate) { + return nil + } + now := r.now().In(entryReminderLocation) + key := constants.RedisPackageExpiryEntryReminderKey(customerID, candidate.AssetType, candidate.Identifier, now.Format("20060102")) + redisOK := r.redis != nil + if redisOK { + exists, err := r.redis.Exists(ctx, key).Result() + if err != nil { + redisOK = false + r.warn("检查临期提醒去重键失败,按未提醒处理", err) + } else if exists > 0 { + return nil + } + } + if err := r.publisher.PublishPersonal(ctx, candidate, customerID); err != nil { + // 写通知失败不占键、不返回字段,下次进入重试。 + r.warn("写入进入资产临期通知失败", err) + return nil + } + if redisOK { + ttl := endOfShanghaiDay(now).Sub(now) + constants.PackageExpiryEntryReminderTTLBuffer + acquired, err := r.redis.SetNX(ctx, key, "1", ttl).Result() + if err != nil { + r.warn("占用临期提醒去重键失败,按未提醒处理", err) + } else if !acquired { + return nil + } + } + days := *candidate.DaysUntilFinalExpiry + level, levelName := packageexpiryquery.ExpiryLevel(days) + return &dto.AssetExpiryReminder{ + RemainingDays: days, ExpiresAt: *candidate.EstimatedFinalExpiresAt, + Level: level, LevelName: levelName, + } +} + +// inEntryReminderWindow 判断最终到期可精确推算且剩余 0~15 个上海自然日。 +func inEntryReminderWindow(estimate dto.PackageExpiryEstimate) bool { + if estimate.ExpiryEstimateStatus != constants.PackageExpiryEstimateStatusExact || + estimate.DaysUntilFinalExpiry == nil || estimate.EstimatedFinalExpiresAt == nil { + return false + } + days := *estimate.DaysUntilFinalExpiry + return days >= 0 && days <= entryReminderWindowDays +} + +// endOfShanghaiDay 返回上海时间当天结束时刻(次日零点)。 +func endOfShanghaiDay(now time.Time) time.Time { + year, month, day := now.In(entryReminderLocation).Date() + return time.Date(year, month, day+1, 0, 0, 0, 0, entryReminderLocation) +} + +func (r *EntryReminder) warn(message string, err error) { + if r.logger != nil { + r.logger.Warn(message, zap.Error(err)) + } +} diff --git a/internal/bootstrap/handlers.go b/internal/bootstrap/handlers.go index 83af3fc3..9456d29d 100644 --- a/internal/bootstrap/handlers.go +++ b/internal/bootstrap/handlers.go @@ -7,6 +7,7 @@ import ( 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" + packageExpiryApp "github.com/break/junhong_cmp_fiber/internal/application/packageexpiry" roleApp "github.com/break/junhong_cmp_fiber/internal/application/role" shopApp "github.com/break/junhong_cmp_fiber/internal/application/shop" systemConfigApp "github.com/break/junhong_cmp_fiber/internal/application/systemconfig" @@ -21,7 +22,9 @@ 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" + "github.com/break/junhong_cmp_fiber/internal/infrastructure/messaging/outbox" notificationInfra "github.com/break/junhong_cmp_fiber/internal/infrastructure/notification" + packageExpiryInfra "github.com/break/junhong_cmp_fiber/internal/infrastructure/packageexpiry" 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" @@ -209,6 +212,9 @@ func initHandlers(svc *services, deps *Dependencies) *Handlers { handler.SetObservationSeriesDispatcher(svc.ObservationSeries) handler.SetPaymentMethodPolicy(paymentMethodPolicy) handler.SetForceRechargeChecker(svc.Recharge) + handler.SetExpiryReminder(packageExpiryApp.NewEntryReminder( + deps.Redis, packageExpiryInfra.NewReminderPublisher(deps.DB, outbox.NewRepository()), deps.Logger, + )) return handler }(), ClientWallet: func() *app.ClientWalletHandler { diff --git a/internal/handler/app/client_asset.go b/internal/handler/app/client_asset.go index 7116def9..f8605318 100644 --- a/internal/handler/app/client_asset.go +++ b/internal/handler/app/client_asset.go @@ -46,6 +46,17 @@ type ClientAssetHandler struct { observationSeries cardObservationApp.BestEffortSeriesDispatcher paymentMethodPolicy ClientPaymentMethodPolicy forceRechargeChecker ClientForceRechargeChecker + expiryReminder ClientExpiryReminder +} + +// ClientExpiryReminder 在 H5 进入资产时判定并发出套餐临期提醒。 +type ClientExpiryReminder interface { + Remind(ctx context.Context, customerID uint, candidate dto.ExpiringAssetItem) *dto.AssetExpiryReminder +} + +// SetExpiryReminder 注入进入资产时的套餐临期提醒用例。 +func (h *ClientAssetHandler) SetExpiryReminder(reminder ClientExpiryReminder) { + h.expiryReminder = reminder } // ClientPaymentMethodPolicy 提供 C 端支付方式读取和校验能力。 @@ -279,6 +290,10 @@ func (h *ClientAssetHandler) GetAssetInfo(c *fiber.Ctx) error { BoundDeviceName: resolved.Asset.BoundDeviceName, } + if h.expiryReminder != nil { + resp.ExpiryReminder = h.expiryReminder.Remind(c.UserContext(), resolved.CustomerID, expiringCandidateOf(resolved.Asset)) + } + // 调用服务层获取设备实时 Gateway 数据(per D-11) // Gateway 失败时 DeviceRealtime 仅含 gateway_msg,不阻断主流程(per D-06) if resp.AssetType == "device" { @@ -851,3 +866,24 @@ func mapDeviceGatewayInfoToClientInfo(g *dto.DeviceGatewayInfo) *dto.DeviceRealt info.GatewayMsg = g.GatewayMsg return info } + +// expiringCandidateOf 把资产解析结果转换为临期提醒候选;资产标识与通知中心过滤口径一致(卡 ICCID;设备虚拟号,空则 IMEI)。 +func expiringCandidateOf(asset *dto.AssetResolveResponse) dto.ExpiringAssetItem { + candidate := dto.ExpiringAssetItem{ + AssetID: asset.AssetID, ShopID: asset.ShopID, PackageExpiryEstimate: asset.PackageExpiryEstimate, + } + if asset.CurrentPackageUsageID != nil { + candidate.PackageUsageID = *asset.CurrentPackageUsageID + } + if asset.AssetType == "device" { + candidate.AssetType = constants.AssetTypeDevice + candidate.Identifier = asset.VirtualNo + if candidate.Identifier == "" { + candidate.Identifier = asset.IMEI + } + return candidate + } + candidate.AssetType = constants.AssetTypeIotCard + candidate.Identifier = asset.ICCID + return candidate +} diff --git a/internal/handler/app/client_notification.go b/internal/handler/app/client_notification.go index 501bf5c2..777e2296 100644 --- a/internal/handler/app/client_notification.go +++ b/internal/handler/app/client_notification.go @@ -36,7 +36,11 @@ func (h *ClientNotificationHandler) UnreadCount(c *fiber.Ctx) error { if !ok || customerID == 0 { return errors.New(errors.CodeUnauthorized) } - result, err := h.query.PersonalUnreadCount(c.UserContext(), customerID) + assetKey, err := h.currentAssetKey(c) + if err != nil { + return err + } + result, err := h.query.PersonalUnreadCount(c.UserContext(), customerID, assetKey) if err != nil { return err } @@ -61,7 +65,11 @@ func (h *ClientNotificationHandler) List(c *fiber.Ctx) error { if !ok || customerID == 0 { return errors.New(errors.CodeUnauthorized) } - result, err := h.query.PersonalList(c.UserContext(), customerID, request) + assetKey, err := h.currentAssetKey(c) + if err != nil { + return err + } + result, err := h.query.PersonalList(c.UserContext(), customerID, assetKey, request) if err != nil { return err } @@ -75,7 +83,11 @@ func (h *ClientNotificationHandler) MarkAllRead(c *fiber.Ctx) error { if !ok || customerID == 0 { return errors.New(errors.CodeUnauthorized) } - result, err := h.readService.MarkAllPersonalRead(c.UserContext(), customerID) + assetKey, err := h.currentAssetKey(c) + if err != nil { + return err + } + result, err := h.readService.MarkAllPersonalRead(c.UserContext(), customerID, assetKey) if err != nil { return err } @@ -99,6 +111,15 @@ func (h *ClientNotificationHandler) MarkRead(c *fiber.Ctx) error { return response.Success(c, dto.NotificationReadResponse{Success: true}) } +// currentAssetKey 读取令牌当前资产的通知引用标识,用于只展示当前资产的套餐临期提醒。 +func (h *ClientNotificationHandler) currentAssetKey(c *fiber.Ctx) (string, error) { + assetType, assetID, ok := middleware.GetCurrentAsset(c) + if !ok { + return "", nil + } + return h.query.CurrentAssetRefKey(c.UserContext(), assetType, assetID) +} + func logPersonalNotificationListValidationFailure(c *fiber.Ctx, request dto.PersonalNotificationListRequest, err error) { logger.GetAppLogger().Warn("个人客户站内通知列表参数验证失败", zap.String("method", c.Method()), diff --git a/internal/infrastructure/packageexpiry/reminder_publisher.go b/internal/infrastructure/packageexpiry/reminder_publisher.go index b3e6f928..3b24c752 100644 --- a/internal/infrastructure/packageexpiry/reminder_publisher.go +++ b/internal/infrastructure/packageexpiry/reminder_publisher.go @@ -18,7 +18,7 @@ import ( var shanghaiLocation = time.FixedZone("Asia/Shanghai", 8*60*60) -// ReminderPublisher 将每日临期事实转换为店铺和个人客户通知 Outbox。 +// ReminderPublisher 将临期事实转换为店铺通知(每日扫描)与个人客户通知(H5 进入资产时)Outbox。 type ReminderPublisher struct { db *gorm.DB outbox *outbox.Repository @@ -29,41 +29,38 @@ func NewReminderPublisher(db *gorm.DB, repository *outbox.Repository) *ReminderP return &ReminderPublisher{db: db, outbox: repository} } -type recipientRow struct { - AssetID uint - CustomerID uint -} - -// Publish 批量解析资产关联个人客户,并在同一事务内幂等追加店铺和个人通知事件。 +// Publish 每日扫描在同一事务内幂等追加店铺通知事件。 +// 不再给个人客户批量写临期通知:个人客户只在 H5 进入该资产时由 PublishPersonal 提醒。 func (p *ReminderPublisher) Publish(ctx context.Context, candidates []dto.ExpiringAssetItem) error { if p == nil || p.db == nil || p.outbox == nil { return errors.New(errors.CodeInternalError, "套餐临期通知 Publisher 未配置") } - recipients, err := p.loadRecipients(ctx, candidates) - if err != nil { - return err - } return p.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { for _, candidate := range candidates { - if candidate.DaysUntilFinalExpiry == nil || candidate.EstimatedFinalExpiresAt == nil { + if candidate.DaysUntilFinalExpiry == nil || candidate.EstimatedFinalExpiresAt == nil || candidate.ShopID == nil { continue } - key := recipientKey(candidate.AssetType, candidate.AssetID) - if candidate.ShopID != nil { - if err := p.appendShopNotification(ctx, tx, candidate, *candidate.ShopID); err != nil { - return err - } - } - for customerID := range recipients[key] { - if err := p.appendNotification(ctx, tx, candidate, customerID); err != nil { - return err - } + if err := p.appendShopNotification(ctx, tx, candidate, *candidate.ShopID); err != nil { + return err } } return nil }) } +// PublishPersonal 为进入资产的个人客户幂等写一条临期通知;事件号含资产、到期日、剩余天数与客户。 +func (p *ReminderPublisher) PublishPersonal(ctx context.Context, candidate dto.ExpiringAssetItem, customerID uint) error { + if p == nil || p.db == nil || p.outbox == nil { + return errors.New(errors.CodeInternalError, "套餐临期通知 Publisher 未配置") + } + if candidate.DaysUntilFinalExpiry == nil || candidate.EstimatedFinalExpiresAt == nil || customerID == 0 { + return errors.New(errors.CodeInvalidParam, "套餐临期个人通知参数不完整") + } + return p.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + return p.appendNotification(ctx, tx, candidate, customerID) + }) +} + // appendShopNotification 按资产所属店铺写入一条动态接收人通知事件。 func (p *ReminderPublisher) appendShopNotification(ctx context.Context, tx *gorm.DB, candidate dto.ExpiringAssetItem, shopID uint) error { node := *candidate.DaysUntilFinalExpiry @@ -93,57 +90,6 @@ func (p *ReminderPublisher) appendShopNotification(ctx context.Context, tx *gorm return nil } -func (p *ReminderPublisher) loadRecipients(ctx context.Context, candidates []dto.ExpiringAssetItem) (map[string]map[uint]struct{}, error) { - cardIDs := make([]uint, 0) - deviceIDs := make([]uint, 0) - for _, candidate := range candidates { - if candidate.AssetType == constants.AssetTypeIotCard { - cardIDs = append(cardIDs, candidate.AssetID) - } else if candidate.AssetType == constants.AssetTypeDevice { - deviceIDs = append(deviceIDs, candidate.AssetID) - } - } - result := make(map[string]map[uint]struct{}) - queries := []struct { - assetType string - ids []uint - sql string - }{ - {constants.AssetTypeIotCard, cardIDs, ` - SELECT c.id AS asset_id, b.customer_id - FROM tb_iot_card c - JOIN tb_personal_customer_device b ON c.virtual_no <> '' AND b.virtual_no = c.virtual_no - WHERE c.id IN ? AND c.deleted_at IS NULL AND b.deleted_at IS NULL AND b.status = 1`}, - {constants.AssetTypeIotCard, cardIDs, ` - SELECT c.id AS asset_id, b.customer_id - FROM tb_iot_card c - JOIN tb_personal_customer_iccid b ON c.virtual_no = '' AND (b.iccid = c.iccid OR (b.iccid_19 <> '' AND b.iccid_19 = c.iccid_19)) - WHERE c.id IN ? AND c.deleted_at IS NULL AND b.deleted_at IS NULL AND b.status = 1`}, - {constants.AssetTypeDevice, deviceIDs, ` - SELECT d.id AS asset_id, b.customer_id - FROM tb_device d - JOIN tb_personal_customer_device b ON b.virtual_no = CASE WHEN d.virtual_no <> '' THEN d.virtual_no ELSE d.imei END - WHERE d.id IN ? AND d.deleted_at IS NULL AND b.deleted_at IS NULL AND b.status = 1`}, - } - for _, query := range queries { - if len(query.ids) == 0 { - continue - } - var rows []recipientRow - if err := p.db.WithContext(ctx).Raw(query.sql, query.ids).Scan(&rows).Error; err != nil { - return nil, errors.Wrap(errors.CodeDatabaseError, err, "批量查询临期资产关联客户失败") - } - for _, row := range rows { - key := recipientKey(query.assetType, row.AssetID) - if result[key] == nil { - result[key] = make(map[uint]struct{}) - } - result[key][row.CustomerID] = struct{}{} - } - } - return result, nil -} - func (p *ReminderPublisher) appendNotification(ctx context.Context, tx *gorm.DB, candidate dto.ExpiringAssetItem, customerID uint) error { node := *candidate.DaysUntilFinalExpiry expiryDate := candidate.EstimatedFinalExpiresAt.In(shanghaiLocation).Format("20060102") @@ -191,7 +137,3 @@ func expiryTemplateData(candidate dto.ExpiringAssetItem) map[string]string { "expiry_date": expiryDate, } } - -func recipientKey(assetType string, assetID uint) string { - return fmt.Sprintf("%s:%d", assetType, assetID) -} diff --git a/internal/model/dto/client_asset_dto.go b/internal/model/dto/client_asset_dto.go index 52290590..1ad6c1d8 100644 --- a/internal/model/dto/client_asset_dto.go +++ b/internal/model/dto/client_asset_dto.go @@ -13,8 +13,18 @@ type AssetInfoRequest struct { // AssetInfoResponse B1 资产信息响应 // 根据 asset_type 不同,设备专属字段或卡专属字段会分别填充(另一侧为零值/omit) +// AssetExpiryReminder H5 进入资产时的套餐临期提醒;同一客户、同一资产、同一上海自然日至多返回一次。 +type AssetExpiryReminder struct { + RemainingDays int `json:"remaining_days" description:"距最终到期的剩余上海自然日天数(0~15)"` + ExpiresAt time.Time `json:"expires_at" description:"预计套餐最终到期时间"` + Level string `json:"level" description:"提醒等级 (red:0至3天, purple:4至7天, pink:8至15天)"` + LevelName string `json:"level_name" description:"提醒等级中文名称"` +} + type AssetInfoResponse struct { PackageExpiryEstimate + // ExpiryReminder 当前资产临期且当天首次进入时返回,否则不返回该字段 + ExpiryReminder *AssetExpiryReminder `json:"expiry_reminder,omitempty" description:"套餐临期提醒:当前资产剩余 0~15 天且该客户当天首次进入时返回,同时写一条该资产的临期通知;否则不返回"` // === 登录用户信息 === BoundPhone string `json:"bound_phone" description:"当前登录用户绑定的手机号"` diff --git a/internal/query/notification/query.go b/internal/query/notification/query.go index 66044bac..c0f4ac26 100644 --- a/internal/query/notification/query.go +++ b/internal/query/notification/query.go @@ -84,13 +84,15 @@ func (q *Query) List(ctx context.Context, recipientID uint, request dto.Notifica }, nil } -// PersonalUnreadCount 查询当前个人客户可见业务通知的准确未读数。 -func (q *Query) PersonalUnreadCount(ctx context.Context, customerID uint) (*dto.NotificationUnreadCountResponse, error) { +// PersonalUnreadCount 查询当前个人客户可见业务通知的准确未读数;套餐临期提醒只计当前资产的。 +func (q *Query) PersonalUnreadCount(ctx context.Context, customerID uint, currentAssetKey string) (*dto.NotificationUnreadCountResponse, error) { if customerID == 0 { return nil, errors.New(errors.CodeInvalidParam) } var count int64 err := personalNotificationScope(q.db.WithContext(ctx).Model(&model.Notification{}), customerID, q.now().UTC()). + Where(constants.NotificationPersonalExpiringAssetCondition, + constants.NotificationTypePackageExpiring, constants.NotificationRefTypeAsset, currentAssetKey). Where("is_read = ?", false). Count(&count).Error if err != nil { @@ -99,8 +101,8 @@ func (q *Query) PersonalUnreadCount(ctx context.Context, customerID uint) (*dto. return newUnreadCountResponse(count), nil } -// PersonalList 查询当前个人客户可见的未过期业务通知简化列表。 -func (q *Query) PersonalList(ctx context.Context, customerID uint, request dto.PersonalNotificationListRequest) (*dto.PersonalNotificationListResponse, error) { +// PersonalList 查询当前个人客户可见的未过期业务通知简化列表;套餐临期提醒只含当前资产的。 +func (q *Query) PersonalList(ctx context.Context, customerID uint, currentAssetKey string, request dto.PersonalNotificationListRequest) (*dto.PersonalNotificationListResponse, error) { if customerID == 0 { return nil, errors.New(errors.CodeInvalidParam) } @@ -108,7 +110,9 @@ func (q *Query) PersonalList(ctx context.Context, customerID uint, request dto.P if err != nil { return nil, err } - base := personalNotificationScope(q.db.WithContext(ctx).Model(&model.Notification{}), customerID, q.now().UTC()) + base := personalNotificationScope(q.db.WithContext(ctx).Model(&model.Notification{}), customerID, q.now().UTC()). + Where(constants.NotificationPersonalExpiringAssetCondition, + constants.NotificationTypePackageExpiring, constants.NotificationRefTypeAsset, currentAssetKey) if request.IsRead != nil { base = base.Where("is_read = ?", *request.IsRead) } @@ -238,3 +242,35 @@ func popupSnapshotItem(record model.Notification) *dto.NotificationPopupSnapshot ActionType: record.PopupSnapshot.ActionType, } } + +// CurrentAssetRefKey 返回个人客户令牌当前资产的通知引用标识:卡为 ICCID;设备优先虚拟号,为空则 IMEI。 +// 资产不存在或类型未知时返回空串,此时个人通知中心不展示任何套餐临期提醒。 +func (q *Query) CurrentAssetRefKey(ctx context.Context, assetType string, assetID uint) (string, error) { + if assetID == 0 { + return "", nil + } + var row struct { + ICCID string `gorm:"column:iccid"` + VirtualNo string `gorm:"column:virtual_no"` + IMEI string `gorm:"column:imei"` + } + var result *gorm.DB + switch assetType { + case constants.AssetTypeIotCard, "card": + result = q.db.WithContext(ctx).Model(&model.IotCard{}).Select("iccid").Where("id = ?", assetID).Limit(1).Scan(&row) + case constants.AssetTypeDevice: + result = q.db.WithContext(ctx).Model(&model.Device{}).Select("virtual_no", "imei").Where("id = ?", assetID).Limit(1).Scan(&row) + default: + return "", nil + } + if result.Error != nil { + return "", errors.Wrap(errors.CodeDatabaseError, result.Error, "查询当前资产标识失败") + } + if row.ICCID != "" { + return row.ICCID, nil + } + if row.VirtualNo != "" { + return row.VirtualNo, nil + } + return row.IMEI, nil +} diff --git a/internal/query/packageexpiry/list.go b/internal/query/packageexpiry/list.go index 868d1de0..6e9eee25 100644 --- a/internal/query/packageexpiry/list.go +++ b/internal/query/packageexpiry/list.go @@ -246,7 +246,7 @@ func (q *Query) resolveCandidates(ctx context.Context, candidates []assetCandida if filter.PackageID != nil && usage.PackageID != *filter.PackageID { continue } - level, levelName := expiryLevel(days) + level, levelName := ExpiryLevel(days) items = append(items, dto.ExpiringAssetItem{ AssetType: candidate.AssetType, AssetID: candidate.AssetID, Identifier: candidate.Identifier, ShopID: candidate.ShopID, PackageUsageID: usage.ID, PackageID: usage.PackageID, PackageName: usage.PackageName, @@ -363,7 +363,8 @@ func validateListFilter(filter ListFilter) error { return nil } -func expiryLevel(days int) (string, string) { +// ExpiryLevel 返回临期高亮等级与中文名称:≤3 红、≤7 紫、其余粉红。 +func ExpiryLevel(days int) (string, string) { if days <= 3 { return "red", "红色" } diff --git a/internal/routes/personal.go b/internal/routes/personal.go index 230c2765..80b4d5aa 100644 --- a/internal/routes/personal.go +++ b/internal/routes/personal.go @@ -149,11 +149,12 @@ func RegisterPersonalCustomerRoutes(router fiber.Router, doc *openapi.Generator, }) Register(authGroup, doc, basePath, "GET", "/asset/info", handlers.ClientAsset.GetAssetInfo, RouteSpec{ - Summary: "资产信息", - Tags: []string{"个人客户 - 资产"}, - Auth: true, - Input: &dto.AssetInfoRequest{}, - Output: &dto.AssetInfoResponse{}, + Summary: "资产信息", + Description: "资产剩余 0~15 个上海自然日且当前客户当天首次进入时返回 expiry_reminder,并为该客户、该资产写一条套餐临期通知;同一客户、同一资产当天只返回一次。", + Tags: []string{"个人客户 - 资产"}, + Auth: true, + Input: &dto.AssetInfoRequest{}, + Output: &dto.AssetInfoResponse{}, }) Register(authGroup, doc, basePath, "GET", "/asset/packages", handlers.ClientAsset.GetAvailablePackages, RouteSpec{ diff --git a/internal/routes/personal_notification.go b/internal/routes/personal_notification.go index 7dd61b36..b7850550 100644 --- a/internal/routes/personal_notification.go +++ b/internal/routes/personal_notification.go @@ -15,7 +15,7 @@ func registerPersonalNotificationRoutes(router fiber.Router, handler *apphandler Register(notifications, doc, groupPath, "GET", "/unread-count", handler.UnreadCount, RouteSpec{ Summary: "查询个人客户通知未读数", - Description: "仅查询当前认证个人客户的未过期业务通知,平台同步和系统运维通知不会进入结果。", + Description: "仅查询当前认证个人客户的未过期业务通知,平台同步和系统运维通知不会进入结果;套餐临期提醒只计当前登录资产的。", Tags: []string{"个人客户 - 站内通知"}, Output: new(dto.NotificationUnreadCountResponse), Auth: true, @@ -23,7 +23,7 @@ func registerPersonalNotificationRoutes(router fiber.Router, handler *apphandler Register(notifications, doc, groupPath, "GET", "", handler.List, RouteSpec{ Summary: "查询个人客户通知列表", - Description: "仅返回当前认证个人客户的未过期业务通知,可按已读状态筛选,固定按创建时间和通知 ID 倒序。当前 C 端契约只承诺列表展示和未读弹窗;ref_type/ref_id/ref_key 是资源引用与展示快照,不是URL,也不承诺可直接拼接页面路由。", + Description: "仅返回当前认证个人客户的未过期业务通知,可按已读状态筛选,固定按创建时间和通知 ID 倒序;套餐临期提醒只返回当前登录资产的(按 ref_key 匹配,卡为 ICCID、设备为虚拟号或 IMEI),其他类型不受影响。当前 C 端契约只承诺列表展示和未读弹窗;ref_type/ref_id/ref_key 是资源引用与展示快照,不是URL,也不承诺可直接拼接页面路由。", Tags: []string{"个人客户 - 站内通知"}, Input: new(dto.PersonalNotificationListRequest), Output: new(dto.PersonalNotificationListResponse), @@ -33,7 +33,7 @@ func registerPersonalNotificationRoutes(router fiber.Router, handler *apphandler // 静态路径必须先于通知 ID 动态路径,避免被动态参数吞掉。 Register(notifications, doc, groupPath, "PUT", "/read-all", handler.MarkAllRead, RouteSpec{ Summary: "全部标记个人客户通知已读", - Description: "只更新当前认证个人客户可见的未过期未读业务通知,重复调用幂等成功。", + Description: "只更新当前认证个人客户可见的未过期未读业务通知,重复调用幂等成功;其他资产的套餐临期提醒保持未读。", Tags: []string{"个人客户 - 站内通知"}, Output: new(dto.NotificationReadAllResponse), Auth: true, diff --git a/openspec/changes/scope-expiry-reminder-to-current-asset/.openspec.yaml b/openspec/changes/scope-expiry-reminder-to-current-asset/.openspec.yaml new file mode 100644 index 00000000..a3692196 --- /dev/null +++ b/openspec/changes/scope-expiry-reminder-to-current-asset/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-10-11 diff --git a/openspec/changes/scope-expiry-reminder-to-current-asset/design.md b/openspec/changes/scope-expiry-reminder-to-current-asset/design.md new file mode 100644 index 00000000..e76ff1ca --- /dev/null +++ b/openspec/changes/scope-expiry-reminder-to-current-asset/design.md @@ -0,0 +1,31 @@ +## Context + +承接《第1批-8-9月迭代缺陷修复技术方案》第 4 节。`packageexpiry` 的 `Publish` 在每日扫描中给店铺接收人(`appendShopNotification`)和所有绑定客户(`loadRecipients`)批量写通知;个人通知列表(`PersonalNotificationListRequest`,`internal/model/dto/notification_dto.go:94`)无资产过滤。H5 令牌带当前资产(`middleware.GetCurrentAsset`),资产信息接口为 `GET /api/c/v1/asset/info`。Outbox 写入按 `event_id` 冲突忽略(`internal/infrastructure/messaging/outbox/repository.go:103`)。 + +## Goals / Non-Goals + +**Goals:** 临期提醒只在客户进入该资产时出现,每天一次;通知中心只显示当前资产的临期提醒。 + +**Non-Goals:** 不改到期推算、提醒窗口与等级、店铺通知;不新增接口入参;无迁移。 + +## Decisions + +1. **进入时判定,复用最终到期推算。** 在资产信息接口内按当前资产调用 `packageexpiry` 的推算,与每日扫描同口径。 +2. **去重顺序:先查键 → 写通知 → `SET NX` → 抢到才返回字段。** 方案原顺序是“先写通知再 `SET NX`”,当天每次进入都会写一次 Outbox(虽冲突忽略,仍是无效写)。评审确认先用 Redis `EXISTS`/`GET` 检查,键存在直接返回,只有未提醒时才写通知。写通知成功后才占键,写失败不占键、不返回字段,避免“当天已去重但通知没发出”。两个并发请求都通过检查时,通知由事件号幂等,只有抢到键的一方返回字段。键定义放 `pkg/constants/redis.go`,TTL 到当天上海时间结束再加缓冲。 +3. **Redis 不可用按未提醒处理。** 字段可能当天多次返回,通知由事件号兜底不重复;接受该降级,不因 Redis 故障让资产信息接口失败。 +4. **只用 `ref_key` 过滤,列表、未读数、全部已读同一口径。** `ref_id` 不带资产类型,卡 5 与设备 5 冲突。过滤只作用于 `type = package.expiring AND ref_type = asset`。“全部已读”(`PUT /notifications/read-all`)也加同样的条件,否则会把客户在其他资产才能看到的临期提醒一并标为已读。 +5. **停用个人客户预发布。** 去掉 `Publish` 里给绑定客户批量写通知的循环,店铺通知不变。 + +## Risks / Trade-offs + +- [GET 接口产生写副作用] → 只在当天首次命中时写一次通知,其余请求只读 Redis。 +- [设备补虚拟号后历史提醒匹配不上] → 影响可接受,已在 spec 中写明。 +- [资产信息接口延迟增加] → 推算与 Redis 检查各一次;推算结果为非临期时不访问 Redis。 + +## Migration Plan + +无迁移。测试环境部署后,每日扫描不再产生个人客户临期通知;已有个人通知按过滤规则展示。 + +## Open Questions + +- 资产信息响应中提醒字段的具体命名在实现时确定,并写入接口文档。 diff --git a/openspec/changes/scope-expiry-reminder-to-current-asset/proposal.md b/openspec/changes/scope-expiry-reminder-to-current-asset/proposal.md new file mode 100644 index 00000000..bbc27897 --- /dev/null +++ b/openspec/changes/scope-expiry-reminder-to-current-asset/proposal.md @@ -0,0 +1,26 @@ +## Why + +套餐临期提醒发给所有绑定过该资产的客户(`reminder_publisher.go` 的 `loadRecipients`),个人通知列表又没有资产过滤,客户在任一资产页都会看到名下全部资产的临期提醒(运营清单 26)。业务已确认改为按资产提醒:哪个资产被扫码进入就提醒哪个,同一客户、同一资产、同一天只提醒一次。依据为《第1批-8-9月迭代缺陷修复技术方案》第 4 节(M9)。 + +## What Changes + +- H5 资产信息接口 `GET /api/c/v1/asset/info` 在当前资产处于临期窗口(剩余 0~15 天)且当天未提醒过时,返回临期提醒字段(剩余天数、到期时间、等级),并为“当前客户、当前资产”写一条 `package.expiring` 个人通知;同一客户、同一资产、同一北京时间日期只提醒一次。 +- **BREAKING** 每日扫描不再给个人客户写临期通知;店铺通知不变。 +- **BREAKING** 个人通知列表、未读数与“全部已读”中,临期提醒只处理当前资产的;其他类型通知不受影响,接口入参不变。 + +## Capabilities + +### New Capabilities + +无。 + +### Modified Capabilities + +- `notification-delivery`:每日临期扫描只通知店铺接收人;新增 H5 进入资产时的临期提醒;个人通知中心临期提醒按当前资产过滤。 + +## Impact + +- 代码:`internal/infrastructure/packageexpiry/reminder_publisher.go`(去掉个人客户批量写入)、H5 资产信息 Handler 与服务(复用 `packageexpiry` 最终到期推算、写 Outbox 个人通知)、个人通知列表与未读数查询、`pkg/constants/redis.go`(去重键)、资产信息响应 DTO。 +- 接口:`GET /api/c/v1/asset/info` 新增字段;个人通知列表与未读数结果范围变化;重新生成接口文档。 +- 依赖:Redis(去重键);Outbox 按 `event_id` 冲突忽略写入(`internal/infrastructure/messaging/outbox/repository.go`)。 +- 无数据库迁移。 diff --git a/openspec/changes/scope-expiry-reminder-to-current-asset/specs/notification-delivery/spec.md b/openspec/changes/scope-expiry-reminder-to-current-asset/specs/notification-delivery/spec.md new file mode 100644 index 00000000..07917b5d --- /dev/null +++ b/openspec/changes/scope-expiry-reminder-to-current-asset/specs/notification-delivery/spec.md @@ -0,0 +1,65 @@ +## MODIFIED Requirements + +### Requirement: 套餐临期每日站内提醒 +系统 SHALL 在每日扫描时,向最终到期时间可精确推算且剩余 0 至 15 个上海自然日的资产所属店铺后台接收人创建套餐临期站内通知。系统 MUST 对同一资产、同一最终到期日期、同一剩余天数和同一接收人保持幂等。每日扫描 MUST NOT 向个人客户创建套餐临期通知;个人客户的临期提醒只按“H5 进入资产时的套餐临期提醒”产生。 + +#### Scenario: 临期窗口内连续两日提醒 +- **GIVEN** 一项资产的最终到期时间可精确推算,昨天剩余 3 个上海自然日,今天剩余 2 个上海自然日,且两日均有有效店铺接收人 +- **WHEN** 每日临期扫描分别执行 +- **THEN** 系统分别为店铺接收人创建昨天和今天的套餐临期通知 + +#### Scenario: 不在临期窗口的资产 +- **GIVEN** 一项资产剩余超过 15 个上海自然日、已经到期或最终到期时间不可精确推算 +- **WHEN** 每日临期扫描执行 +- **THEN** 系统不为该资产创建套餐临期通知 + +#### Scenario: 每日扫描不通知个人客户 +- **GIVEN** 一项临期资产绑定了个人客户 +- **WHEN** 每日临期扫描执行 +- **THEN** 系统只为店铺接收人创建通知,不为个人客户创建通知 + +## ADDED Requirements + +### Requirement: H5 进入资产时的套餐临期提醒 + +个人客户访问 `GET /api/c/v1/asset/info` 时,系统 SHALL 以令牌中的当前资产,按与每日扫描相同的最终到期推算判断是否临期(剩余 0~15 个上海自然日,≤3 红、≤7 紫、≤15 粉红)。命中且该客户当天(上海自然日)尚未就该资产提醒过时,系统 MUST 在响应中返回临期提醒字段(剩余天数、到期时间、等级),并为当前客户、当前资产写一条 `package.expiring` 个人通知,通知幂等事件号 MUST 包含资产、到期日、剩余天数与客户。 + +同一客户、同一资产、同一上海自然日 MUST 至多返回一次提醒字段、至多产生一条通知;去重键为“客户 + 资产标识 + 日期”。系统 MUST 先检查去重键,已存在则不返回字段、不写通知;不存在时先写通知,写入成功后再占用去重键,抢占成功才返回字段。通知写入失败时 MUST NOT 占用去重键、MUST NOT 返回字段,下次进入重试。去重存储不可用时系统按“未提醒过”处理,提醒字段可能当天多次返回,通知由事件号幂等保证不重复。 + +#### Scenario: 当天首次进入临期资产 +- **WHEN** 客户首次在当天进入一项剩余 2 天的资产 +- **THEN** 响应返回剩余天数 2、到期时间与红色等级,并新增一条该资产的临期通知 + +#### Scenario: 同一天再次进入 +- **WHEN** 同一客户当天再次进入该资产 +- **THEN** 响应不返回临期提醒字段,不新增通知 + +#### Scenario: 第二天再次进入 +- **WHEN** 同一客户第二天进入该资产且仍在临期窗口 +- **THEN** 再次返回提醒字段并新增一条通知 + +#### Scenario: 不同客户进入同一资产 +- **WHEN** 客户 B 当天进入客户 A 已被提醒过的资产 +- **THEN** 客户 B 独立获得一次提醒 + +#### Scenario: 非临期资产 +- **WHEN** 客户进入剩余超过 15 天或最终到期不可精确推算的资产 +- **THEN** 响应不返回临期提醒字段,不写通知 + +### Requirement: 个人通知中心临期提醒按当前资产过滤 + +个人客户通知列表、未读数与“全部已读”(`PUT /api/c/v1/notifications/read-all`)SHALL 对 `type = package.expiring` 且 `ref_type = asset` 的通知,只处理 `ref_key` 等于当前资产标识的通知(卡为 ICCID;设备优先虚拟号,为空则 IMEI);MUST NOT 使用 `ref_id` 过滤。“全部已读”MUST NOT 把其他资产的临期提醒标为已读。其他类型通知 MUST 不受影响,接口入参不变。设备后补虚拟号前产生的历史提醒 `ref_key` 为 IMEI,补号后不再匹配,属可接受限制。 + +#### Scenario: 客户名下两个临期资产 +- **GIVEN** 客户名下资产 A、B 都有临期通知 +- **WHEN** 客户在资产 A 中查询通知列表与未读数 +- **THEN** 临期通知只含 A 的,未读数只计 A 的临期通知与其他类型通知 + +#### Scenario: 全部已读只处理当前资产的临期提醒 +- **GIVEN** 客户名下资产 A、B 都有未读临期通知 +- **WHEN** 客户在资产 A 中点“全部已读” +- **THEN** A 的临期通知与其他类型通知变为已读,B 的临期通知仍为未读,客户进入 B 时仍能看到 + +#### Scenario: 非临期通知不受过滤 +- **WHEN** 客户在资产 A 中查询通知列表 +- **THEN** 非 `package.expiring` 类型的通知照常返回 diff --git a/openspec/changes/scope-expiry-reminder-to-current-asset/tasks.md b/openspec/changes/scope-expiry-reminder-to-current-asset/tasks.md new file mode 100644 index 00000000..162ea1ca --- /dev/null +++ b/openspec/changes/scope-expiry-reminder-to-current-asset/tasks.md @@ -0,0 +1,20 @@ +## 1. 停用个人客户预发布 + +- [x] 1.1 `internal/infrastructure/packageexpiry/reminder_publisher.go` 的 `Publish` 去掉给绑定客户批量写个人通知的循环,保留 `appendShopNotification`。 + +## 2. 进入资产时提醒 + +- [x] 2.1 `pkg/constants/redis.go` 新增去重键(客户 ID + 资产标识 + 上海日期)及 TTL 计算(当天结束加缓冲)。 +- [x] 2.2 资产信息服务按当前资产复用 `packageexpiry` 最终到期推算;命中窗口时依次:检查键 → 经 Outbox 写 `package.expiring` 个人通知(事件号 `pex:资产:到期日:剩余天数:客户`)→ `SET NX` → 抢到才返回字段;写通知失败不占键不返回;Redis 不可用按未提醒处理。 +- [x] 2.3 资产信息响应 DTO 新增临期提醒字段(剩余天数、到期时间、等级),更新接口说明。 + +## 3. 通知中心过滤 + +- [x] 3.1 个人通知列表、未读数与“全部已读”对 `type = package.expiring AND ref_type = asset` 只保留 `ref_key` 等于当前资产标识(卡 ICCID;设备虚拟号,空则 IMEI)的记录,其他类型不变,入参不变。 + +## 4. 文档与测试环境验证 + +- [x] 4.1 `gofmt`、`go build ./cmd/api ./cmd/worker`、`go run cmd/gendocs/main.go`。 +- [ ] 4.2 按 ENG-TEST-001(测试库 `junhong_cmp_test`、Redis DB 6)构造同一客户绑定两个临期资产:进入 A 只返回 A 的字段并新增 A 的通知,通知中心与未读数只含 A;在 A 中“全部已读”后 B 的临期通知仍未读;同一天再次进入 A 不返回字段、不新增通知;清理键模拟第二天后再次提醒;客户 B 进入 A 独立提醒一次;非临期通知不受过滤。 +- [ ] 4.3 手动触发每日扫描:不再给个人客户写临期通知,店铺通知照常。 +- [ ] 4.4 运行 `openspec validate --all` 与 `./scripts/context-health.sh`。 diff --git a/pkg/constants/notification.go b/pkg/constants/notification.go index c68c032f..d8e75615 100644 --- a/pkg/constants/notification.go +++ b/pkg/constants/notification.go @@ -59,6 +59,9 @@ const ( NotificationRefTypePackage = "package" // NotificationRefTypeAsset 表示个人客户资产资源引用。 NotificationRefTypeAsset = "asset" + // NotificationPersonalExpiringAssetCondition 个人通知中心只保留当前资产的套餐临期提醒: + // 参数依次为临期通知类型、资产引用类型、当前资产标识(卡 ICCID;设备虚拟号,空则 IMEI);不按 ref_id 过滤。 + NotificationPersonalExpiringAssetCondition = "(NOT (type = ? AND ref_type = ?) OR ref_key = ?)" // NotificationRefTypeRefund 表示退款详情资源引用。 NotificationRefTypeRefund = "refund" // NotificationRefTypeAgentRecharge 表示代理充值详情资源引用。 diff --git a/pkg/constants/redis.go b/pkg/constants/redis.go index 86006c25..72ce7b6f 100644 --- a/pkg/constants/redis.go +++ b/pkg/constants/redis.go @@ -1,6 +1,9 @@ package constants -import "fmt" +import ( + "fmt" + "time" +) // ======================================== // 认证相关 Redis Key @@ -556,6 +559,16 @@ func RedisPollingConfigChangedChannel() string { // 系统全局配置 Redis Key // ======================================== +// RedisPackageExpiryEntryReminderKey H5 进入资产时套餐临期提醒的去重键 +// 用途:同一客户、同一资产、同一上海自然日至多返回一次提醒字段 +// 过期时间:当天上海时间结束后再加缓冲,由调用方计算 +func RedisPackageExpiryEntryReminderKey(customerID uint, assetType, assetIdentifier, shanghaiDate string) string { + return fmt.Sprintf("package_expiry:entry_reminder:%d:%s:%s:%s", customerID, assetType, assetIdentifier, shanghaiDate) +} + +// PackageExpiryEntryReminderTTLBuffer 临期提醒去重键在当天结束后保留的缓冲时长。 +const PackageExpiryEntryReminderTTLBuffer = time.Hour + // RedisSystemOperationPasswordKey 全局操作密码 Redis 键 // 用途:存储超级管理员设置的全局操作密码(bcrypt 哈希值) // 过期时间:永久存储(无 TTL)