fix: 第1批 M9 H5 进入资产的临期个人通知写入即已读
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 12m9s
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 12m9s
H5 进入资产时已同步弹窗展示临期提醒,通知中心再记未读会重复提醒; 通知按剩余天数每天新增一条,保持未读会持续累积红点。 直发通知载荷新增可选 mark_read,仅临期个人通知设置,其他直发通知与店铺临期通知仍为未读。 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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`)。
|
||||
- 无数据库迁移。
|
||||
|
||||
@@ -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,补号后不再匹配,属可接受限制。
|
||||
|
||||
@@ -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`,个人未读数不变。
|
||||
|
||||
Reference in New Issue
Block a user