From 41722760b1b095ba638726e039e3500822afd74d Mon Sep 17 00:00:00 2001 From: break Date: Tue, 15 Sep 2026 16:29:56 +0800 Subject: [PATCH] =?UTF-8?q?docs(H5=E5=BC=B9=E7=AA=97):=20AUG26-007=20?= =?UTF-8?q?=E5=BD=92=E6=A1=A3=E5=8F=98=E6=9B=B4=E5=B9=B6=E5=90=8C=E6=AD=A5?= =?UTF-8?q?=20h5-popup-notification=20=E4=B8=BB=20Spec=20=E4=B8=8E?= =?UTF-8?q?=E8=AF=81=E6=8D=AE=E9=93=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../entry-capability-requirement-matrix.json | 74 +++++++++++ .../context-reset/requirement-evidence.json | 117 ++++++++++++++++++ .../.openspec.yaml | 0 .../design.md | 10 +- .../proposal.md | 0 .../specs/h5-popup-notification/spec.md | 2 +- .../tasks.md | 7 +- openspec/specs/h5-popup-notification/spec.md | 107 ++++++++++++++++ 8 files changed, 314 insertions(+), 3 deletions(-) rename openspec/changes/{add-h5-risk-exchange-notifications => archive/2026-09-15-add-h5-risk-exchange-notifications}/.openspec.yaml (100%) rename openspec/changes/{add-h5-risk-exchange-notifications => archive/2026-09-15-add-h5-risk-exchange-notifications}/design.md (89%) rename openspec/changes/{add-h5-risk-exchange-notifications => archive/2026-09-15-add-h5-risk-exchange-notifications}/proposal.md (100%) rename openspec/changes/{add-h5-risk-exchange-notifications => archive/2026-09-15-add-h5-risk-exchange-notifications}/specs/h5-popup-notification/spec.md (94%) rename openspec/changes/{add-h5-risk-exchange-notifications => archive/2026-09-15-add-h5-risk-exchange-notifications}/tasks.md (95%) create mode 100644 openspec/specs/h5-popup-notification/spec.md diff --git a/docs/verification/context-reset/entry-capability-requirement-matrix.json b/docs/verification/context-reset/entry-capability-requirement-matrix.json index d9fc4fb..2e393d1 100644 --- a/docs/verification/context-reset/entry-capability-requirement-matrix.json +++ b/docs/verification/context-reset/entry-capability-requirement-matrix.json @@ -4213,5 +4213,79 @@ "phone-asset-association::后台解除关联" ], "classification": "behavior" + }, + { + "entry_type": "http", + "entry": "GET /api/admin/h5-popup-configurations", + "capability": "h5-popup-notification", + "requirements": [ + "h5-popup-notification::运营弹窗实时匹配" + ], + "classification": "behavior" + }, + { + "entry_type": "http", + "entry": "POST /api/admin/h5-popup-configurations", + "capability": "h5-popup-notification", + "requirements": [ + "h5-popup-notification::运营弹窗实时匹配" + ], + "classification": "behavior" + }, + { + "entry_type": "http", + "entry": "GET /api/admin/h5-popup-configurations/{id}", + "capability": "h5-popup-notification", + "requirements": [ + "h5-popup-notification::运营弹窗实时匹配" + ], + "classification": "behavior" + }, + { + "entry_type": "http", + "entry": "PUT /api/admin/h5-popup-configurations/{id}", + "capability": "h5-popup-notification", + "requirements": [ + "h5-popup-notification::运营弹窗实时匹配" + ], + "classification": "behavior" + }, + { + "entry_type": "http", + "entry": "POST /api/admin/h5-popup-configurations/{id}/enable", + "capability": "h5-popup-notification", + "requirements": [ + "h5-popup-notification::运营弹窗实时匹配" + ], + "classification": "behavior" + }, + { + "entry_type": "http", + "entry": "POST /api/admin/h5-popup-configurations/{id}/disable", + "capability": "h5-popup-notification", + "requirements": [ + "h5-popup-notification::运营弹窗实时匹配" + ], + "classification": "behavior" + }, + { + "entry_type": "http", + "entry": "GET /api/c/v1/popup-candidates", + "capability": "h5-popup-notification", + "requirements": [ + "h5-popup-notification::风险换卡候选与地址提交", + "h5-popup-notification::运营弹窗实时匹配", + "h5-popup-notification::通知留存与已读" + ], + "classification": "behavior" + }, + { + "entry_type": "http", + "entry": "POST /api/c/v1/risk-exchanges/{asset_id}/address", + "capability": "h5-popup-notification", + "requirements": [ + "h5-popup-notification::风险换卡候选与地址提交" + ], + "classification": "behavior" } ] diff --git a/docs/verification/context-reset/requirement-evidence.json b/docs/verification/context-reset/requirement-evidence.json index d6758ce..5885517 100644 --- a/docs/verification/context-reset/requirement-evidence.json +++ b/docs/verification/context-reset/requirement-evidence.json @@ -3838,5 +3838,122 @@ ], "exit_status": 0 } + }, + { + "capability": "h5-popup-notification", + "requirement": "风险换卡候选与地址提交", + "spec": "openspec/specs/h5-popup-notification/spec.md", + "entries": [ + "GET /api/c/v1/popup-candidates", + "POST /api/c/v1/risk-exchanges/{asset_id}/address" + ], + "handler_consumer_job": [ + "internal/handler/app/client_popup.go" + ], + "application_service_query": [ + "internal/application/h5popup/asset.go", + "internal/application/h5popup/candidate.go", + "internal/application/h5popup/risk_exchange.go" + ], + "domain_state_amount": [ + "pkg/constants/iot.go", + "pkg/constants/constants.go", + "pkg/constants/h5_popup.go" + ], + "store_migration_config": [ + "migrations/000225_add_h5_popup_configuration.up.sql", + "internal/store/postgres/iot_card_store.go" + ], + "verification": { + "command": "代码中检索 GatewayCardExtendRiskStop、CarrierTypeCBN、activeShippingExchangeStatuses 与 flow_type 限定", + "literal_output": [ + "internal/application/h5popup/asset.go:112:\tRiskStopped: card.CarrierType == constants.CarrierTypeCBN &&", + "internal/application/h5popup/asset.go:113:\tstrings.TrimSpace(card.GatewayExtend) == constants.GatewayCardExtendRiskStop,", + "internal/application/h5popup/candidate.go:25:\tvar activeShippingExchangeStatuses = []int{", + "internal/application/h5popup/candidate.go:26:\t\tconstants.ExchangeStatusPendingInfo,", + "internal/application/h5popup/candidate.go:27:\t\tconstants.ExchangeStatusPendingShip,", + "internal/application/h5popup/candidate.go:28:\t\tconstants.ExchangeStatusShipped,", + "internal/application/h5popup/candidate.go:29:\t\tconstants.ExchangeStatusCompleted,", + "internal/application/h5popup/candidate.go:219:\tWhere(\"old_asset_type = ? AND old_asset_id = ? AND flow_type = ?\", assetType, assetID, constants.ExchangeFlowTypeShipping).", + "internal/application/h5popup/candidate.go:220:\tWhere(\"status IN ?\", activeShippingExchangeStatuses).", + "internal/application/h5popup/risk_exchange.go:60:\t\tif err := tx.WithContext(ctx).Clauses(clause.Locking{Strength: \"UPDATE\"}).", + "internal/application/h5popup/risk_exchange.go:68:\t\tif card.CarrierType != constants.CarrierTypeCBN ||" + ], + "exit_status": 0 + } + }, + { + "capability": "h5-popup-notification", + "requirement": "运营弹窗实时匹配", + "spec": "openspec/specs/h5-popup-notification/spec.md", + "entries": [ + "GET /api/admin/h5-popup-configurations", + "POST /api/admin/h5-popup-configurations", + "GET /api/admin/h5-popup-configurations/{id}", + "PUT /api/admin/h5-popup-configurations/{id}", + "POST /api/admin/h5-popup-configurations/{id}/enable", + "POST /api/admin/h5-popup-configurations/{id}/disable" + ], + "handler_consumer_job": [ + "internal/handler/admin/h5_popup_configuration.go" + ], + "application_service_query": [ + "internal/application/h5popup/configuration.go", + "internal/application/h5popup/candidate.go", + "internal/query/h5popup/query.go" + ], + "domain_state_amount": [ + "pkg/constants/h5_popup.go" + ], + "store_migration_config": [ + "migrations/000225_add_h5_popup_configuration.up.sql", + "internal/model/h5_popup_configuration.go" + ], + "verification": { + "command": "代码中检索 priority DESC 排序、配置版本递增与启停刷新最近更新时间", + "literal_output": [ + "internal/application/h5popup/candidate.go:203:\tOrder(\"priority DESC, updated_at DESC, id DESC\").", + "internal/application/h5popup/configuration.go:197:\trecord.Version++", + "internal/application/h5popup/configuration.go:199:\trecord.UpdatedAt = now", + "internal/application/h5popup/configuration.go:244:\trecord.UpdatedAt = now" + ], + "exit_status": 0 + } + }, + { + "capability": "h5-popup-notification", + "requirement": "通知留存与已读", + "spec": "openspec/specs/h5-popup-notification/spec.md", + "entries": [ + "GET /api/c/v1/popup-candidates", + "PUT /api/c/v1/notifications/{id}/read" + ], + "handler_consumer_job": [ + "internal/handler/app/client_popup.go", + "internal/handler/app/client_notification.go" + ], + "application_service_query": [ + "internal/application/notification/direct.go", + "internal/application/notification/read.go", + "internal/query/notification/query.go" + ], + "domain_state_amount": [ + "pkg/constants/notification.go" + ], + "store_migration_config": [ + "migrations/000225_add_h5_popup_configuration.up.sql", + "internal/infrastructure/notification/repository.go" + ], + "verification": { + "command": "代码中检索 CreateOrGetPersonal 与个人通知查询/已读两处弹窗类型白名单", + "literal_output": [ + "internal/application/notification/direct.go:34:\tfunc (s *DeliveryService) CreateOrGetPersonal(ctx context.Context, eventID string, customerID uint, request PersonalDirectRequest) (*model.Notification, error) {", + "internal/query/notification/query.go:198:\t\tconstants.NotificationTypeH5PopupRiskExchange,", + "internal/query/notification/query.go:199:\t\tconstants.NotificationTypeH5PopupOperation,", + "internal/application/notification/read.go:220:\t\tconstants.NotificationTypeH5PopupRiskExchange,", + "internal/application/notification/read.go:221:\t\tconstants.NotificationTypeH5PopupOperation," + ], + "exit_status": 0 + } } ] diff --git a/openspec/changes/add-h5-risk-exchange-notifications/.openspec.yaml b/openspec/changes/archive/2026-09-15-add-h5-risk-exchange-notifications/.openspec.yaml similarity index 100% rename from openspec/changes/add-h5-risk-exchange-notifications/.openspec.yaml rename to openspec/changes/archive/2026-09-15-add-h5-risk-exchange-notifications/.openspec.yaml diff --git a/openspec/changes/add-h5-risk-exchange-notifications/design.md b/openspec/changes/archive/2026-09-15-add-h5-risk-exchange-notifications/design.md similarity index 89% rename from openspec/changes/add-h5-risk-exchange-notifications/design.md rename to openspec/changes/archive/2026-09-15-add-h5-risk-exchange-notifications/design.md index ccfec84..33016aa 100644 --- a/openspec/changes/add-h5-risk-exchange-notifications/design.md +++ b/openspec/changes/archive/2026-09-15-add-h5-risk-exchange-notifications/design.md @@ -113,6 +113,7 @@ - 请求携带页面与当前资产标识;资产参数统一沿用既有 `identifier` 形态,**不采用路径参数**。 - 资产归属使用 `customer_binding.OwnsAsset`;资产不存在与其归属校验失败**统一按资源不可见返回**,不形成可枚举差异。 - 风险命中时只返回风险候选;未命中风险再按时间、启停、页面、范围与频率匹配运营配置。 +- 风险资格成立即只处理风险分支:当天已投放风险候选(无论客户是否已读)时,当天不再返回运营弹窗候选,次日恢复按各自频率与优先级匹配;「风险优先」只用于两者同时命中的判定。理由:避免客户关闭风险提示后当天立即看到营销弹窗,与「稍后处理仅抑制当天」的口径一致。 ## 后台与 H5 动作契约 @@ -124,7 +125,7 @@ ### H5 候选查询与风险换卡 - `GET /api/c/v1/popup-candidates`:当前个人客户必须提交 `page` 和当前资产 `identifier`;首页也必须先由客户选定当前资产。服务校验该资产属于当前客户,否则按资源不可见返回。 -- 查询先判断广电卡、运营商扩展状态风险停机、无活动物流换货单(`shipping` 且状态含已完成)、未提交风险地址和「客户+资产+上海自然日」未展示;命中时创建/复用风险通知并只返回风险换卡候选。关闭或稍后处理只调用既有通知已读,不修改风险资格,次日允许再次投放。 +- 查询先判断广电卡、运营商扩展状态风险停机、无活动物流换货单(`shipping` 且状态含已完成)、未提交风险地址和「客户+资产+上海自然日」未展示;命中时创建/复用风险通知并只返回风险换卡候选。关闭或稍后处理只调用既有通知已读,不修改风险资格,次日允许再次投放。当天已投放风险候选(无论客户是否已读)后,当天不再返回运营弹窗候选,次日恢复运营匹配。 - 未命中风险时,按当前时间、启用状态、页面、店铺/设备类型/卡类型范围和频率匹配运营配置;同维度多值取任一命中,无配置即全量。只返回优先级最高一条,同优先级取最近更新时间;以客户、配置版本、资产、日期/一次性键创建或复用通知。 ### 风险地址提交与通知读取 @@ -141,6 +142,13 @@ - **两个白名单是重复实现**:本次按最小改动同时扩两处;漏改一处即静默失效,验证必须覆盖列表可见与已读生效两条。 - **`Creator/Updater` 置 0**:保留「客户自助发起」语义,但会让换货单缺少后台操作者;后续如需追溯,应另行定义客户来源标识而非复用账号 ID。 - **`flow_type` 必须参与资格判定**:若实现遗漏该条件,直接换货单会永久压制风险候选,且不会报错,属静默错误。 +- **当天不投放运营候选**:风险资格成立当天不再做运营匹配,代价是当天放弃一次运营投放机会,次日恢复。这是产品口径,已固化在 spec 的风险换卡 Requirement 中。 + +### 后续候选(本 Change 不实施) + +- **风险换卡的资产状态与退款守卫**:当前按 PRD 只校验广电卡、运营商扩展状态严格等于风险停机、无活动物流换货单三项;是否还需排除「已换货/已停用」资产与「存在未终结退款」的资产尚未定论。属需求外增量,需独立 Change。 +- **设备类型取值归一化**:设备类型沿用 `tb_device.device_type` 自由文本,存在大小写与写法变体(如 MiFi/MiFI),归一化方案待定。属需求外增量,需独立 Change。 +- **H5 资产参数形态与错误码边界、投递侧类型约束**:候选用 query `identifier` 而地址提交用路径 `asset_id` 的形态统一、「资产不可见」在既有 H5 资产入口为 403 而本接口为 400 的边界口径、弹窗类型通知经 Outbox 通道投递时的载荷约束,三者的统一方案待定。属需求外增量,需独立 Change。 ## Migration Plan diff --git a/openspec/changes/add-h5-risk-exchange-notifications/proposal.md b/openspec/changes/archive/2026-09-15-add-h5-risk-exchange-notifications/proposal.md similarity index 100% rename from openspec/changes/add-h5-risk-exchange-notifications/proposal.md rename to openspec/changes/archive/2026-09-15-add-h5-risk-exchange-notifications/proposal.md diff --git a/openspec/changes/add-h5-risk-exchange-notifications/specs/h5-popup-notification/spec.md b/openspec/changes/archive/2026-09-15-add-h5-risk-exchange-notifications/specs/h5-popup-notification/spec.md similarity index 94% rename from openspec/changes/add-h5-risk-exchange-notifications/specs/h5-popup-notification/spec.md rename to openspec/changes/archive/2026-09-15-add-h5-risk-exchange-notifications/specs/h5-popup-notification/spec.md index ccd8fda..5c6dac8 100644 --- a/openspec/changes/add-h5-risk-exchange-notifications/specs/h5-popup-notification/spec.md +++ b/openspec/changes/archive/2026-09-15-add-h5-risk-exchange-notifications/specs/h5-popup-notification/spec.md @@ -6,7 +6,7 @@ ### Requirement: 风险换卡候选与地址提交 -系统 SHALL 仅在当前 H5 客户访问的资产为广电卡、运营商扩展状态为风险停机、且不存在待填写收货信息、待发货、已发货待确认或已完成的物流换货单时返回风险换卡弹窗。物流换货单只统计物流换货流程;直接换货单不参与该判定。同一客户同一资产每天至多展示一次;稍后处理仅抑制当天,次日仍可命中。风险换卡优先级固定高于运营弹窗。 +系统 SHALL 仅在当前 H5 客户访问的资产为广电卡、运营商扩展状态为风险停机、且不存在待填写收货信息、待发货、已发货待确认或已完成的物流换货单时返回风险换卡弹窗。物流换货单只统计物流换货流程;直接换货单不参与该判定。同一客户同一资产每天至多展示一次;稍后处理仅抑制当天,次日仍可命中。当天已投放风险换卡候选后,无论客户是否已读,当天不再返回运营弹窗候选;次日风险与运营按各自的频率与优先级规则恢复匹配。风险换卡优先级固定高于运营弹窗仅适用于两者同时命中的场景。 客户提交收货人姓名、收货手机号和完整地址文本后,系统 MUST 幂等创建关联旧资产的物流换货单;首次地址锁定,客户不得修改,重复提交返回首次创建的换货单与首次地址且不覆盖。并发提交同一资产时只创建一张换货单。自动换货单不预设业务数据迁移,发货选择新资产时仍由后台按既有换货流程决定。风险条件不再成立或地址已提交后停止新投放,已投放通知在通知中心展示 90 天。客户请求不属于自己的资产时,系统 MUST 返回与资产不存在相同的不可见结果。 diff --git a/openspec/changes/add-h5-risk-exchange-notifications/tasks.md b/openspec/changes/archive/2026-09-15-add-h5-risk-exchange-notifications/tasks.md similarity index 95% rename from openspec/changes/add-h5-risk-exchange-notifications/tasks.md rename to openspec/changes/archive/2026-09-15-add-h5-risk-exchange-notifications/tasks.md index dfc22fe..dfed27c 100644 --- a/openspec/changes/add-h5-risk-exchange-notifications/tasks.md +++ b/openspec/changes/archive/2026-09-15-add-h5-risk-exchange-notifications/tasks.md @@ -83,11 +83,16 @@ ### 与 design 的取舍(已确认保留) -- **风险资格命中时不下落到运营匹配**:design §9 把「客户+资产+上海自然日未展示」列入「风险命中」集合,字面执行会得出「客户当天关闭风险弹窗后,再请求可返回运营弹窗」。本实现改为:风险资格(广电卡 + 严格等于风险停机的运营商扩展状态 + 无活动物流换货单)成立即只处理风险分支——当日通知存在且未读则返回同一通知(满足 3.3),已读则返回空候选(满足 3.4),既不下落运营匹配,也不在当天重复投放。理由:spec 场景「风险与运营候选同时命中 → 仅返回风险换卡候选」的前置条件只是「同时满足风险换卡与运营条件」,并未排除「当天已关闭」;且「稍后处理仅抑制当天」若同时放开运营投放,会与「风险换卡优先级固定高于运营弹窗」相冲突。取舍点注释在 `internal/application/h5popup/candidate.go` 的 `GetCandidate`。 +- **风险资格命中时不下落到运营匹配**:design §9 把「客户+资产+上海自然日未展示」列入「风险命中」集合,字面执行会得出「客户当天关闭风险弹窗后,再请求可返回运营弹窗」。本实现改为:风险资格(广电卡 + 严格等于风险停机的运营商扩展状态 + 无活动物流换货单)成立即只处理风险分支——当日通知存在且未读则返回同一通知(满足 3.3),已读则返回空候选(满足 3.4),既不下落运营匹配,也不在当天重复投放。理由:spec 场景「风险与运营候选同时命中 → 仅返回风险换卡候选」的前置条件只是「同时满足风险换卡与运营条件」,并未排除「当天已关闭」;且「稍后处理仅抑制当天」若同时放开运营投放,会与「风险换卡优先级固定高于运营弹窗」相冲突。取舍点注释在 `internal/application/h5popup/candidate.go` 的 `GetCandidate`。该口径已在 spec 的风险换卡 Requirement 固化(当天不再返回运营弹窗候选,次日恢复)。 - **运营弹窗频率去重键不含资产**:按 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` 头部注释。 +### 验证说明(口径与限制) + +- **跨自然日重投**:以昨日 `event_id` 的已读通知作为夹具,证明「上海自然日已进入去重键、昨日已读不抑制今天」,未做真实跨日等待(未修改服务器时钟)。 +- **Redis 验证面**:本轮以本地 Redis DB 6 提供会话存储(仓库 `.env` 未提供测试 Redis 地址)。本 Change 的通知投放、幂等与匹配语义全部落在 PostgreSQL,不依赖 Redis,故不影响验证结论;后续涉及 Redis 语义的 Change 应使用测试环境地址。 + ### 实现落点 - 迁移:`migrations/000225_add_h5_popup_configuration.{up,down}.sql`。 diff --git a/openspec/specs/h5-popup-notification/spec.md b/openspec/specs/h5-popup-notification/spec.md new file mode 100644 index 0000000..9be878c --- /dev/null +++ b/openspec/specs/h5-popup-notification/spec.md @@ -0,0 +1,107 @@ +# h5-popup-notification 当前行为 + +## Purpose + +在客户实际访问 H5 时,按当前资产事实投放风险换卡或运营弹窗,并将投放内容作为个人客户通知留存,避免预生成通知或开放任意跳转链接。 + +## Requirements + +### Requirement: 风险换卡候选与地址提交 + +系统 SHALL 仅在当前 H5 客户访问的资产为广电卡、运营商扩展状态为风险停机、且不存在待填写收货信息、待发货、已发货待确认或已完成的物流换货单时返回风险换卡弹窗。物流换货单只统计物流换货流程;直接换货单不参与该判定。同一客户同一资产每天至多展示一次;稍后处理仅抑制当天,次日仍可命中。当天已投放风险换卡候选后,无论客户是否已读,当天不再返回运营弹窗候选;次日风险与运营按各自的频率与优先级规则恢复匹配。风险换卡优先级固定高于运营弹窗仅适用于两者同时命中的场景。 + +客户提交收货人姓名、收货手机号和完整地址文本后,系统 MUST 幂等创建关联旧资产的物流换货单;首次地址锁定,客户不得修改,重复提交返回首次创建的换货单与首次地址且不覆盖。并发提交同一资产时只创建一张换货单。自动换货单不预设业务数据迁移,发货选择新资产时仍由后台按既有换货流程决定。风险条件不再成立或地址已提交后停止新投放,已投放通知在通知中心展示 90 天。客户请求不属于自己的资产时,系统 MUST 返回与资产不存在相同的不可见结果。 + +#### Scenario: 重复提交风险地址 + +- **WHEN** 客户对同一风险资产重复提交收货地址 +- **THEN** 系统保留首次地址和唯一物流换货单,不创建第二张换货单,也不覆盖首次地址 + +#### Scenario: 并发提交同一资产 + +- **WHEN** 同一风险资产同时收到两次地址提交 +- **THEN** 系统只创建一张物流换货单,两次均返回同一张换货单与首次地址 + +#### Scenario: 已完成物流换货单 + +- **WHEN** 当前资产已存在已完成的物流换货单 +- **THEN** 系统不再返回风险换卡候选 + +#### Scenario: 直接换货单不压制风险候选 + +- **WHEN** 当前资产只有已完成的直接换货单,且其余风险条件成立 +- **THEN** 系统仍返回风险换卡候选 + +#### Scenario: 他人资产 + +- **WHEN** 客户请求候选或提交地址的资产不属于自己 +- **THEN** 系统返回与资产不存在相同的不可见结果 + +### Requirement: 运营弹窗实时匹配 + +系统 SHALL 允许超级管理员和平台用户管理全局运营弹窗的标题、内容、有效期、启停、优先级、店铺/设备类型/卡类型范围、四种页面(首页、资产详情、套餐购买、资产钱包充值)、频率和一个可选受控操作。范围同一维度多选为任一匹配,未配置范围即全量;已配置范围而当前资产在该维度没有可判定值时该配置不命中。H5 请求必须携带当前页面资产标识。操作仅可为套餐购买或资产钱包充值,不得配置任意 URL。 + +运营弹窗仅在客户请求候选时实时匹配并创建或复用通知;每客户每配置支持仅一次或每天一次。候选只返回优先级最高一条,同优先级取最近更新时间最新,启停操作同样更新最近更新时间。配置修改形成新版本,既有通知保留原快照且不被改写;修改后的仅一次配置可向原命中客户重新投放。配置到期或停用停止新投放,历史通知在通知中心展示 90 天。 + +#### Scenario: 风险与运营候选同时命中 + +- **WHEN** 当前资产同时满足风险换卡和多个运营弹窗条件 +- **THEN** 系统仅返回风险换卡候选,并保持通知未读 + +#### Scenario: 优先级与最近更新排序 + +- **WHEN** 多条运营配置同时命中且优先级相同 +- **THEN** 系统只返回最近更新时间最新的一条 + +#### Scenario: 独立卡的设备类型维度 + +- **WHEN** 命中的运营配置配置了设备类型范围,但当前资产是未绑定设备的独立卡 +- **THEN** 系统不命中该配置 + +#### Scenario: 配置修改后重新投放 + +- **WHEN** 已按仅一次频率向客户投放过的配置被修改 +- **THEN** 配置版本递增,既有通知的内容与快照保持不变,该客户可再次命中一次 + +#### Scenario: 到期或停用 + +- **WHEN** 配置已到期或被停用 +- **THEN** 系统停止新投放,既有通知在展示期内仍可见 + +### Requirement: 通知留存与已读 + +弹窗投放 SHALL 复用个人客户站内通知,保存投放时内容、受控操作与旧资产关联;运营弹窗通知额外保存配置标识与配置版本快照。投放后通知同时出现在通知列表与未读数中,并自投放时间起展示 90 天。 + +创建或返回候选不得自动已读;客户关闭、点击操作或进入通知详情后通过既有已读接口幂等标记已读。通知读取必须维持个人客户隔离。 + +#### Scenario: 关闭弹窗 + +- **WHEN** 当前个人客户关闭其未读弹窗 +- **THEN** 系统仅标记该客户该通知已读,不影响其他客户或未来符合条件的投放 + +#### Scenario: 同一天重复请求 + +- **WHEN** 客户在同一天多次请求同一资产的候选 +- **THEN** 系统复用同一条通知,不重复投放,且当日只返回一次候选 + +#### Scenario: 已读后当天不再返回 + +- **WHEN** 客户关闭弹窗后当天再次请求候选 +- **THEN** 系统不再返回该候选,次日条件仍成立时创建新通知重新投放 + +#### Scenario: 通知列表可见 + +- **WHEN** 投放产生了一条弹窗通知 +- **THEN** 该通知出现在当前客户的未读数与通知列表中,并可通过既有已读接口标记已读 + +## 可达操作索引 + +本节只用于入口导航,不是行为 Requirement;业务义务以上述 Requirements 为准。 + +### H5 运营弹窗配置 + +`GET /api/admin/h5-popup-configurations`(查询运营弹窗配置列表);`POST /api/admin/h5-popup-configurations`(创建运营弹窗配置);`GET /api/admin/h5-popup-configurations/{id}`(查询运营弹窗配置详情);`PUT /api/admin/h5-popup-configurations/{id}`(更新运营弹窗配置并递增版本);`POST /api/admin/h5-popup-configurations/{id}/enable`(启用运营弹窗配置);`POST /api/admin/h5-popup-configurations/{id}/disable`(停用运营弹窗配置)。 + +### H5 弹窗候选与风险换卡 + +`GET /api/c/v1/popup-candidates`(查询当前页面的弹窗候选,命中时创建或复用未读通知);`POST /api/c/v1/risk-exchanges/{asset_id}/address`(提交风险换卡收货地址,幂等创建物流换货单)。