## 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 ### 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`(1~100 字符)、`content`(1~2000 字符)、`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` 仅影响后续候选且必须更新最近更新时间;全部成功写操作记录操作者、前后值、版本和时间。 ### H5 候选查询与风险换卡 - `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 天;弹窗投放通知在 `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 新增成对迁移(编号从 `000225` 起,实施时复核当前最大编号):运营弹窗配置表(含 `version`、页面、范围、优先级、频率、受控动作、启停、有效期)与所需索引;并在同一迁移内为 `tb_notification` 新增可空 JSONB 列 `popup_snapshot`。隔离库验证风险条件、每日限制、地址幂等、优先级、版本重投、范围匹配、通知隔离及 up/down/up。