feat(H5弹窗): AUG26-007 风险换卡与运营弹窗投放通知
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 9m23s

新增 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)与参数校验中文提示共用实现。
This commit is contained in:
2026-09-15 15:23:52 +08:00
parent 70e680eb0a
commit 333ba4b647
39 changed files with 2861 additions and 166 deletions

View File

@@ -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
- 新增运营弹窗配置及版本/投放去重事实;候选查询在同一资产上下文计算风险优先级和配置匹配,创建/复用个人通知。
- 风险地址提交以客户+旧资产唯一约束和事务创建物流换货单,地址写入换货单而不单独建表。
- 频率去重使用客户、配置版本、资产/日期键;通知内容冻结在投放时,已读复用现有服务
- 后台仅管理配置,不得写任意 URLH5 只接收受控目标类型。
### 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`1100 字符)、`content`12000 字符)、`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。
新增成对迁移(编号从 `000225` 起,实施时复核当前最大编号):运营弹窗配置表(含 `version`、页面、范围、优先级、频率、受控动作、启停、有效期)与所需索引;并在同一迁移内为 `tb_notification` 新增可空 JSONB 列 `popup_snapshot`隔离库验证风险条件、每日限制、地址幂等、优先级、版本重投、范围匹配、通知隔离及 up/down/up。

View File

@@ -10,7 +10,7 @@
- 新增风险换卡候选与地址提交,幂等创建物流换货单。
- 新增运营弹窗配置、范围/优先级/频率/版本投放和受控动作。
- 复用个人客户通知保存快照、已读和 90 天历史
- 复用个人客户通知保存投放快照、已读和 90 天展示期;不新增通知类别,仅新增一个可空快照列
## Capabilities

View File

@@ -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** 该通知出现在当前客户的未读数与通知列表中,并可通过既有已读接口标记已读

View File

@@ -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。
- [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.11.9、2.12.7:已实现(详细落点见下方「实现落点」)。
- 3.13.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-1SUG-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/`