diff --git a/internal/application/notification/delivery.go b/internal/application/notification/delivery.go index a6d8d9e3..489e22cf 100644 --- a/internal/application/notification/delivery.go +++ b/internal/application/notification/delivery.go @@ -27,6 +27,8 @@ type AdminDirectPayload struct { RefID string `json:"ref_id,omitempty"` RefKey string `json:"ref_key,omitempty"` ExpiresAt *time.Time `json:"expires_at,omitempty"` + // MarkRead 为 true 时通知写入即已读,用于投递同时已在前端展示过的场景;缺省按未读写入。 + MarkRead bool `json:"mark_read,omitempty"` } // PersonalCustomerDirectPayload 是明确个人客户通知的结构化 Outbox 载荷。 @@ -52,6 +54,7 @@ type deliveryRequest struct { refKey string expiresAt *time.Time popupSnapshot *model.NotificationPopupSnapshot + markRead bool } // DeliveryService 校验接收人并幂等生成站内通知。 @@ -102,6 +105,7 @@ func (s *DeliveryService) consumeDirect(ctx context.Context, envelope outbox.Del request := deliveryRequest{ notificationType: payload.NotificationType, templateData: payload.TemplateData, refType: payload.RefType, refID: payload.RefID, refKey: payload.RefKey, expiresAt: payload.ExpiresAt, + markRead: payload.MarkRead, } if err := validateDeliveryRequest(request); err != nil { return err @@ -248,6 +252,11 @@ func (s *DeliveryService) deliverOne(ctx context.Context, eventID, recipientKind RefType: request.refType, RefID: request.refID, RefKey: request.refKey, ExpiresAt: prepared.expiresAt, CreatedAt: prepared.now, PopupSnapshot: request.popupSnapshot, } + if request.markRead { + readAt := prepared.now + notification.IsRead = true + notification.ReadAt = &readAt + } created := false err = s.repository.DB().WithContext(ctx).Transaction(func(tx *gorm.DB) error { var createErr error @@ -263,7 +272,7 @@ func (s *DeliveryService) deliverOne(ctx context.Context, eventID, recipientKind 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})}, + nil, map[string]any{"created": true, "is_read": notification.IsRead})}, }) }) if err != nil { diff --git a/internal/infrastructure/packageexpiry/reminder_publisher.go b/internal/infrastructure/packageexpiry/reminder_publisher.go index 3b24c752..c6471d9a 100644 --- a/internal/infrastructure/packageexpiry/reminder_publisher.go +++ b/internal/infrastructure/packageexpiry/reminder_publisher.go @@ -108,6 +108,8 @@ func (p *ReminderPublisher) appendNotification(ctx context.Context, tx *gorm.DB, RecipientID: customerID, NotificationType: constants.NotificationTypePackageExpiring, TemplateData: expiryTemplateData(candidate), RefType: constants.NotificationRefTypeAsset, RefID: assetID, RefKey: candidate.Identifier, ExpiresAt: candidate.EstimatedFinalExpiresAt, + // 进入资产时 H5 已同步弹窗展示该提醒,通知写入即已读,避免通知中心重复红点。 + MarkRead: true, }, }) if err != nil { diff --git a/openspec/changes/scope-expiry-reminder-to-current-asset/design.md b/openspec/changes/scope-expiry-reminder-to-current-asset/design.md index e76ff1ca..67cabe05 100644 --- a/openspec/changes/scope-expiry-reminder-to-current-asset/design.md +++ b/openspec/changes/scope-expiry-reminder-to-current-asset/design.md @@ -15,16 +15,18 @@ 3. **Redis 不可用按未提醒处理。** 字段可能当天多次返回,通知由事件号兜底不重复;接受该降级,不因 Redis 故障让资产信息接口失败。 4. **只用 `ref_key` 过滤,列表、未读数、全部已读同一口径。** `ref_id` 不带资产类型,卡 5 与设备 5 冲突。过滤只作用于 `type = package.expiring AND ref_type = asset`。“全部已读”(`PUT /notifications/read-all`)也加同样的条件,否则会把客户在其他资产才能看到的临期提醒一并标为已读。 5. **停用个人客户预发布。** 去掉 `Publish` 里给绑定客户批量写通知的循环,店铺通知不变。 +6. **临期个人通知写入即已读,用载荷可选标记实现。** 直发 Outbox 载荷(`PersonalCustomerDirectPayload`,和 `AdminDirectPayload` 共用一个结构)新增可选字段 `mark_read`,默认 false。只有 `appendNotification`(进入资产的临期提醒)设置为 true。`deliverOne` 创建通知时,标记为 true 就写 `is_read=true`、`read_at=now`;审计 `after_data` 改为按实际值记录,不再固定写 `false`。不采用“返回通知 ID,让前端关闭弹窗时调已读接口”(运营弹窗的做法),原因是通知由 Outbox 异步投递,接口响应时通知还不存在,拿不到 ID。改成按事件号标记已读又会带来时序问题,而且前后端都要改。 ## Risks / Trade-offs - [GET 接口产生写副作用] → 只在当天首次命中时写一次通知,其余请求只读 Redis。 - [设备补虚拟号后历史提醒匹配不上] → 影响可接受,已在 spec 中写明。 - [资产信息接口延迟增加] → 推算与 Redis 检查各一次;推算结果为非临期时不访问 Redis。 +- [接口已返回 `expiry_reminder`,但前端没有展示弹窗(页面关闭、网络中断),通知却已经是已读] → 接受这个风险。第二天剩余天数变化后会生成新通知、再次弹窗;通知中心仍能看到这条历史记录。 ## 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 index bbc27897..a9cf3338 100644 --- a/openspec/changes/scope-expiry-reminder-to-current-asset/proposal.md +++ b/openspec/changes/scope-expiry-reminder-to-current-asset/proposal.md @@ -7,6 +7,7 @@ - H5 资产信息接口 `GET /api/c/v1/asset/info` 在当前资产处于临期窗口(剩余 0~15 天)且当天未提醒过时,返回临期提醒字段(剩余天数、到期时间、等级),并为“当前客户、当前资产”写一条 `package.expiring` 个人通知;同一客户、同一资产、同一北京时间日期只提醒一次。 - **BREAKING** 每日扫描不再给个人客户写临期通知;店铺通知不变。 - **BREAKING** 个人通知列表、未读数与“全部已读”中,临期提醒只处理当前资产的;其他类型通知不受影响,接口入参不变。 +- H5 进入资产时写入的个人客户临期通知**写入即已读**(`is_read=true`,`read_at` 为写入时间),不计入未读数:同一时刻 H5 已经弹窗展示过,不应该再在通知中心产生红点;而且通知每天按新的剩余天数生成一条,保持未读的话会不断累积。其他个人直发通知(换货发货、自动续费)和店铺临期通知仍然是未读。 ## Capabilities @@ -20,7 +21,7 @@ ## Impact -- 代码:`internal/infrastructure/packageexpiry/reminder_publisher.go`(去掉个人客户批量写入)、H5 资产信息 Handler 与服务(复用 `packageexpiry` 最终到期推算、写 Outbox 个人通知)、个人通知列表与未读数查询、`pkg/constants/redis.go`(去重键)、资产信息响应 DTO。 +- 代码:`internal/infrastructure/packageexpiry/reminder_publisher.go`(去掉个人客户批量写入)、H5 资产信息 Handler 与服务(复用 `packageexpiry` 最终到期推算、写 Outbox 个人通知)、个人通知列表与未读数查询、`pkg/constants/redis.go`(去重键)、资产信息响应 DTO、`internal/application/notification/delivery.go`(直发载荷新增可选的写入即已读标记,`deliverOne` 按标记写入,审计 `after_data` 按实际已读状态记录)。 - 接口:`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 index 07917b5d..5cab9f38 100644 --- 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 @@ -26,9 +26,11 @@ 同一客户、同一资产、同一上海自然日 MUST 至多返回一次提醒字段、至多产生一条通知;去重键为“客户 + 资产标识 + 日期”。系统 MUST 先检查去重键,已存在则不返回字段、不写通知;不存在时先写通知,写入成功后再占用去重键,抢占成功才返回字段。通知写入失败时 MUST NOT 占用去重键、MUST NOT 返回字段,下次进入重试。去重存储不可用时系统按“未提醒过”处理,提醒字段可能当天多次返回,通知由事件号幂等保证不重复。 +该通知 MUST 在创建时即为已读(`read_at` 为创建时间),MUST NOT 计入个人未读数;其他类型的个人直发通知和店铺临期通知 MUST 仍以未读创建。 + #### Scenario: 当天首次进入临期资产 - **WHEN** 客户首次在当天进入一项剩余 2 天的资产 -- **THEN** 响应返回剩余天数 2、到期时间与红色等级,并新增一条该资产的临期通知 +- **THEN** 响应返回剩余天数 2、到期时间与红色等级,并新增一条该资产的已读临期通知 #### Scenario: 同一天再次进入 - **WHEN** 同一客户当天再次进入该资产 @@ -46,6 +48,14 @@ - **WHEN** 客户进入剩余超过 15 天或最终到期不可精确推算的资产 - **THEN** 响应不返回临期提醒字段,不写通知 +#### Scenario: 临期提醒通知写入即已读 +- **WHEN** 客户当天首次进入临期资产,响应返回了提醒字段 +- **THEN** 新增的该资产临期通知为已读,通知中心可见,个人未读数不增加 + +#### Scenario: 其他直发通知仍为未读 +- **WHEN** 系统为个人客户投递换货发货通知或自动续费通知 +- **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,补号后不再匹配,属可接受限制。 diff --git a/openspec/changes/scope-expiry-reminder-to-current-asset/tasks.md b/openspec/changes/scope-expiry-reminder-to-current-asset/tasks.md index 748f29c8..bb30eabf 100644 --- a/openspec/changes/scope-expiry-reminder-to-current-asset/tasks.md +++ b/openspec/changes/scope-expiry-reminder-to-current-asset/tasks.md @@ -18,3 +18,10 @@ - [x] 4.2 按 ENG-TEST-001(测试库 `junhong_cmp_test`、Redis DB 6)构造同一客户绑定两个临期资产:进入 A 只返回 A 的字段并新增 A 的通知,通知中心与未读数只含 A;在 A 中“全部已读”后 B 的临期通知仍未读;同一天再次进入 A 不返回字段、不新增通知;清理键模拟第二天后再次提醒;客户 B 进入 A 独立提醒一次;非临期通知不受过滤。 - [x] 4.3 手动触发每日扫描:不再给个人客户写临期通知,店铺通知照常。 - [ ] 4.4 运行 `openspec validate --all` 与 `./scripts/context-health.sh`。 + +## 5. 临期个人通知写入即已读 + +- [x] 5.1 `internal/application/notification/delivery.go`:直发载荷新增可选 `mark_read`(默认 false);`deliverOne` 标记为 true 时写 `is_read=true`、`read_at=now`;审计 `after_data` 按实际已读状态记录。 +- [x] 5.2 `internal/infrastructure/packageexpiry/reminder_publisher.go` 的 `appendNotification` 设置 `mark_read=true`;`appendShopNotification` 不变。 +- [ ] 5.3 测试环境验证(AGENTS.md 规定自动化测试 N/A,不写单元测试):临期个人通知以已读创建;换货发货、自动续费等直发通知仍为未读;店铺临期通知仍为未读。 +- [ ] 5.4 `gofmt`、`go build ./cmd/api ./cmd/worker`、`go run cmd/gendocs/main.go`(已通过,接口文档无变化);部署后测试环境验证:进入卡 89860624590014594318(已造好临期数据,10-20 到期)后,新通知 `is_read=true`,个人未读数不变。