Files
junhong_cmp_fiber/openspec/changes/add-h5-risk-exchange-notifications/tasks.md
break 333ba4b647
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 9m23s
feat(H5弹窗): AUG26-007 风险换卡与运营弹窗投放通知
新增 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)与参数校验中文提示共用实现。
2026-09-15 15:23:52 +08:00

16 KiB
Raw Blame History

1. 数据与契约

  • 1.1 追踪个人通知、资产风险状态、物流换货、H5 认证和既有已读链路。
  • 1.2 新增运营弹窗配置表(含 version、页面、范围、优先级、频率、受控动作、启停、有效期)及所需索引,并在同一迁移内为 tb_notification 新增可空 JSONB 列 popup_snapshot;成对迁移编号从 000225 起,实施时复核当前最大编号。
  • 1.3 新增通知类型常量 h5.popup.risk_exchangeh5.popup.operationpkg/constants/notification.go)。
  • 1.4 通知注册表登记两个 Definition:类别沿用 system,接收人限个人客户,资源引用限既有 assetref_id 为资产数字 ID、ref_key 为资产标识快照,不承载配置信息)。
  • 1.5 同步个人通知查询的类型白名单(internal/query/notification/query.gopersonalNotificationScope)。
  • 1.6 同步个人通知已读的类型白名单(internal/application/notification/read.gopersonalReadScope);不新增通知类别、不改 DB CHECK。
  • 1.7 同步后台通知 DTO 的 type oneof/enums 与 NotificationItem 枚举文案ENG-DTO-001
  • 1.8 通知 DTO 增加可选弹窗快照字段(配置 ID、配置版本、资产类型与 ID、受控动作仅弹窗类型返回不返回任何 URL 或前端路由。
  • 1.9 实现管理端配置 CRUD、启停、版本递增与最近更新时间刷新、受控页面/动作/频率校验、范围维度校验和审计。

2. H5 行为

  • 2.1 实现窄接口 DirectWriter.CreateOrGetPersonal:与 Outbox 消费共用模板渲染、展示期计算与 CreateIdempotent,冲突时回查返回既有行;把既有直投分支重构为调用同一 helper。
  • 2.2 装配 DirectWriterinternal/bootstrap/handlers.gointernal/bootstrap/types.gopkg/openapi/handlers.go
  • 2.3 实现活动物流换货单查询:flow_type=shipping AND status IN (1,2,3,4)(含已完成),不限定流程会永久压制候选。
  • 2.4 实现候选接口(identifier 形态携带页面与当前资产):广电卡 + 风险停机(严格等于既有常量,不与已销户合并)+ 无活动物流换货单判定,资产归属使用 customer_binding.OwnsAsset,资产不存在与归属失败返回同态不可见。
  • 2.5 实现候选接口的风险优先返回、上海自然日去重(落在通知事件键)与运营配置匹配(时间/启停/页面/店铺/设备类型/卡类型范围/频率,priority DESC, updated_at DESC, id DESC),并按第 7 节写入 popup_snapshot(仅弹窗投放类型)。
  • 2.6 实现 SubmitRiskAddress:锁旧资产行 → 复核风险资格 → 去重查询 → 未命中才创建(待发货、shippingmigrate_data=falsemigration_status=not_migrated、原因非空、Creator/Updater 置 0不复用只查虚拟号的内部归属判定不沿用资产级群发通知。
  • 2.7 接入既有个人通知列表与已读,注册路由/OpenAPI 并同步两处装配。

3. 验证

  • 3.1 存在已完成物流换货单时不再返回风险候选。
  • 3.2 存在直接换货单(创建即已完成)时不压制风险候选。
  • 3.3 同一天重复请求返回同一通知 ID 且当日只返回一次候选。
  • 3.4 已读后当天不再返回候选,次日条件仍成立时创建新通知重新投放。
  • 3.5 风险与运营同时命中时只返回风险候选且通知保持未读。
  • 3.6 地址重复提交与并发提交只产生一张物流换货单,保留首次地址;migrate_data=falsemigration_status=not_migrated
  • 3.7 配置修改后版本递增且旧通知内容与快照不变;仅一次配置可向原命中客户重新投放一次。
  • 3.8 启停与同优先级最近更新的排序正确(启停后最近更新时间被刷新)。
  • 3.9 独立卡在设备类型维度为空时不命中已配置设备类型范围的配置;未配置该维度时全量命中。
  • 3.10 他人资产与不存在资产返回同态不可见;他人通知按既有隔离不可见。
  • 3.11 新建通知出现在个人通知未读数与列表中,且可被既有已读接口标记已读。
  • 3.12 弹窗通知返回可选快照字段(配置 ID、配置版本、资产类型与 ID、受控动作不含任何 URL 或前端路由;其他通知类型该字段为空。
  • 3.13 隔离库执行迁移 up/down/uppopup_snapshot 列的新增与回滚)。
  • 3.14 运行 gofmt -wgo build ./cmd/api ./cmd/workergo run cmd/gendocs/main.goopenspec validate add-h5-risk-exchange-notifications --strictopenspec doctor --json;自动化测试按项目决策为 N/A。

实施状态(本轮)

已完成

  • 1.11.9、2.12.7:已实现(详细落点见下方「实现落点」)。
  • 3.13.14已在测试环境真实执行。验证面PostgreSQL junhong_cmp_testscripts/migrate.sh 配显式 DB_*+ 本地 Redis DB 6 + 真实 API 进程(:18080JUNHONG_LOGGING_DEVELOPMENT=true 以便用 POST /api/c/v1/auth/dev-login 取得个人客户令牌)。全部 HTTP 请求为真实调用;库内事实用只读查询或夹具程序读取。
  • 证据日志:/tmp/h5popup-verify/evidence.log99 项 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_exchangenotification_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/notificationstotal>=1 且首项就是本次投放的通知(type=h5.popup.risk_exchange)→ PUT /:id/read 返回 success → 库内该行 is_read=trueread_at 非空 → 未读数回落 0
3.12 运营弹窗快照含 config_id>0config_versionasset_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 updown 1up225 双向成功);只读核对新表 18 列与 tb_notification.popup_snapshot
3.14 gofmt -wgo build ./...go run cmd/gendocs/main.go./scripts/context-health.shopenspec validate --strictopenspec 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.FindByIdentifierDeviceStore.GetByIdentifierIotCardStore.GetByIdentifier,后者已含 iccid_19/iccid_20卡/设备加载同样改用 GetByID。Store 由 bootstrap 注入 未登记进 tb_asset_identifier 的 20 位卡(iccid_20 非空且等于完整 ICCIDregistered=0)用 identifier=<20位 ICCID> 请求候选 → HTTP 200、popup_type=risk_exchangecode=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_apilatest=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.goGetCandidate
  • 运营弹窗频率去重键不含资产:按 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.gopkg/constants/notification.gopkg/constants/audit.gopkg/errors/codes.gointernal/infrastructure/audit/registry.go
  • 模型与 DTOinternal/model/h5_popup_configuration.gointernal/model/notification.gointernal/model/dto/h5_popup_dto.gointernal/model/dto/notification_dto.go
  • 通知链路:internal/application/notification/direct.go(窄接口 + 直投,含显式审计来源)、delivery.goprepareDelivery 一次计算 + deliverOne 单接收人落库)、read.gointernal/infrastructure/notification/repository.goregistry.gointernal/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.gointernal/handler/admin/h5_popup_configuration.gointernal/routes/personal_popup.gointernal/routes/h5_popup_configuration.gointernal/routes/{personal,admin}.go
  • 装配:internal/bootstrap/{handlers,types}.gopkg/openapi/handlers.gocmd/api/docs.gocmd/gendocs/main.go
  • 共用参数提示抽取:internal/handler/validation/validation.go(由 internal/handler/admin/withdrawal_qualification.go 委派复用)。
  • 验证期临时资产(复核完成后清理):.verify-h5popup//tmp/h5popup-verify/