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)与参数校验中文提示共用实现。
16 KiB
16 KiB
1. 数据与契约
- 1.1 追踪个人通知、资产风险状态、物流换货、H5 认证和既有已读链路。
- 1.2 新增运营弹窗配置表(含
version、页面、范围、优先级、频率、受控动作、启停、有效期)及所需索引,并在同一迁移内为tb_notification新增可空 JSONB 列popup_snapshot;成对迁移编号从000225起,实施时复核当前最大编号。 - 1.3 新增通知类型常量
h5.popup.risk_exchange、h5.popup.operation(pkg/constants/notification.go)。 - 1.4 通知注册表登记两个
Definition:类别沿用system,接收人限个人客户,资源引用限既有asset(ref_id为资产数字 ID、ref_key为资产标识快照,不承载配置信息)。 - 1.5 同步个人通知查询的类型白名单(
internal/query/notification/query.go的personalNotificationScope)。 - 1.6 同步个人通知已读的类型白名单(
internal/application/notification/read.go的personalReadScope);不新增通知类别、不改 DB CHECK。 - 1.7 同步后台通知 DTO 的
typeoneof/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 装配
DirectWriter到internal/bootstrap/handlers.go、internal/bootstrap/types.go与pkg/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:锁旧资产行 → 复核风险资格 → 去重查询 → 未命中才创建(待发货、shipping、migrate_data=false、migration_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=false、migration_status=not_migrated。 - 3.7 配置修改后版本递增且旧通知内容与快照不变;仅一次配置可向原命中客户重新投放一次。
- 3.8 启停与同优先级最近更新的排序正确(启停后最近更新时间被刷新)。
- 3.9 独立卡在设备类型维度为空时不命中已配置设备类型范围的配置;未配置该维度时全量命中。
- 3.10 他人资产与不存在资产返回同态不可见;他人通知按既有隔离不可见。
- 3.11 新建通知出现在个人通知未读数与列表中,且可被既有已读接口标记已读。
- 3.12 弹窗通知返回可选快照字段(配置 ID、配置版本、资产类型与 ID、受控动作),不含任何 URL 或前端路由;其他通知类型该字段为空。
- 3.13 隔离库执行迁移 up/down/up(含
popup_snapshot列的新增与回滚)。 - 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/。