feat(H5弹窗): AUG26-007 风险换卡与运营弹窗投放通知
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 9m23s
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:
@@ -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.1~1.9、2.1~2.7:已实现(详细落点见下方「实现落点」)。
|
||||
- 3.1~3.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-1~SUG-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/`。
|
||||
|
||||
Reference in New Issue
Block a user