## 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` 里给绑定客户批量写通知的循环,店铺通知不变。 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 - 资产信息响应中提醒字段的具体命名在实现时确定,并写入接口文档。