feat: 网关只读资产改为数据表,只读资产只允许查询类调用
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 9m36s

- 新增 tb_gateway_readonly_card(卡 ICCID / 设备 IMEI)与开关 JUNHONG_GATEWAY_READONLY_ENABLED,
  替换环境变量名单 JUNHONG_GATEWAY_READONLY_ICCIDS
- 只读资产只放行查卡状态、查实名、查流量、查设备信息、查设备槽位、同步设备状态;
  停机、复机、限速、实名认证链接与设备写操作在网关出口本地拦截;查表失败按拦截;不缓存
- 测试环境部署开启只读模式;新增技术方案与清理顺序文档

Change: add-gateway-readonly-prod-import

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
邹进
2026-10-29 17:55:52 +09:00
parent a7428c7924
commit 5cf67b98de
19 changed files with 536 additions and 41 deletions

View File

@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-10-29

View File

@@ -0,0 +1,62 @@
## Context
完整背景、数据规模与确认事项见 `docs/测试环境导入生产数据验证流量计量技术方案.md`,撤除顺序见 `docs/测试环境生产卡只读模式清理顺序.md`。本文只记录实现层的关键决策。
现状:
- 提交 `6266cf9d` 已在 `internal/gateway/readonly_guard.go` 实现按环境变量 `JUNHONG_GATEWAY_READONLY_ICCIDS`(内存 map)拦截写操作,测试环境配置了两张卡(测试卡 12244、12245)。
- 全部网关调用经过 `internal/gateway/client.go` 的 `doRequest` / `doRequestWithoutPayloadLog`;网关路径共 15 个,其中查询类 6 个:`/flow-card/flow`、`/flow-card/status`、`/flow-card/realName`、`/device/info`、`/device/slot-info`、`/device/sync-info`。
- 生产(迁移 240):卡 98,567、设备 20,144、有效绑定 38,667、待生效 / 生效中 / 已用完套餐 29,175;测试库迁移 268。
- 部署是否完成以 Gitea Actions 运行结果为准;数据库连接池每 5 分钟重建连接,连接建立时间不能作为重启依据。
## Goals / Non-Goals
**Goals:**
- 只读资产在测试环境只能被查询,任何入口都发不出写调用。
- 承载约 12 万个卡号与设备号,写入后立即生效。
- 全量导入后按现有轮询运行,算出新计量逻辑与生产的差距。
**Non-Goals:**
- 订单、钱包、分佣、退款、个人客户、审批等数据导入与流程验证。
- 生产发布、回滚;验证结束后的撤除(按清理文档另行执行)。
- 修改业务代码中的停复机、套餐、轮询流程。
## Decisions
**D1 只在网关出口判断。** 停复机、限速、设备指令的入口分散在轮询、扣减、套餐生命周期、订单、退款、后台按钮等十余处,逐处加判断容易漏;网关客户端是唯一出口,在这里判断一处覆盖全部,业务代码不动。
**D2 白名单放行查询类调用,其余按表拦截。** 只读资产的调用按路径分两类:6 个查询类路径(查卡状态、查实名、查流量、查设备信息、查设备槽位、同步设备状态)直接放行且不查表(轮询绝大部分调用是查询,不增加数据库压力);其他路径一律按卡号查表,命中即拦截。新增网关接口时默认被拦截,比黑名单安全。
**D3 不缓存,逐次查表。** 非查询类调用不频繁,按唯一索引查表约 1 ms;不缓存避免了"写入只读表到生效"的空窗,导入流程可以写表后立即导数据。网关包不依赖 GORM:以函数接缝 `func(ctx, cardNo) (bool, error)` 注入,存储实现放在 `internal/store/postgres`。
**D4 查表失败按拦截。** 宁可写调用失败,也不放过真卡。代价是开关开着时删表会使所有非查询调用失败,因此清理顺序要求先关开关再删表。
**D5 拦截返回明确失败。** 返回网关错误类别 `upstream`(业务拒绝),不是超时或结果未知,避免停复机进入"结果未知 → 恢复扫描"路径;拦截日志带 `metric=gateway.readonly_card.blocked`、路径与卡号。
**D6 卡号取请求体 `cardNo`。** 卡接口的 `cardNo` 是 ICCID,设备接口的 `cardNo` 是 IMEI;只读表按大写保存与比较。取不到卡号的非查询请求不拦截(目前没有此类调用,批量查询接口未实现)。
**D7 导入用临时脚本直接写测试库。** 后台导卡接口只能导卡,不能导设备与套餐,也不适合 10 万级数据。脚本只读导出生产、只允许连接 `junhong_cmp_test`;顺序为:写只读表 → 确认开关已部署且行数一致 → 导入卡、资产标识、设备、绑定、套餐 → 置待建起点 → 触发轮询重新加载。套餐只存真实已用量,虚流量已用量由展示倍率现算;条款快照(虚总量、展示倍率、是否启用虚流量)照原样复制。
**D8 差距按增量比较。** 套餐带入生产导入时刻的真实已用量,两边起点一致;差额 = 测试环境新增扣减 − 生产同期新增扣减。导入前的差距由 flowcollector 本月读数给出上限。
## Risks / Trade-offs
- [只读表写入前就导入数据,导入卡立即触发停复机] → 导入脚本先写只读表并核对行数,且只在 Gitea Actions 显示开关部署成功后执行导入。
- [开关开着时删表,所有非查询调用失败] → 清理顺序:先处理导入资产 → 关开关并部署 → 删表。
- [新增网关写接口忘记评估] → 白名单策略下默认拦截。
- [导入数据量大,轮询与采集记录、日快照增长快] → 测试环境可接受;采集记录按月分区、90 天保留。
- [导入资产的停复机失败记录大量出现] → 预期行为,用于识别"按新逻辑该停 / 该复"的资产。
## Migration Plan
1. 新增成对迁移建 `tb_gateway_readonly_card`(up 建表;down 删表)。
2. 代码切换为按表判断,删除 `JUNHONG_GATEWAY_READONLY_ICCIDS`;部署脚本配置 `JUNHONG_GATEWAY_READONLY_ENABLED=true`。
3. 部署前在测试库写入现有两张只读卡,再推送部署,避免两张卡在切换期间失去保护。
4. 部署成功后执行全量导入(D7)。
5. 回滚 / 撤除:按 `docs/测试环境生产卡只读模式清理顺序.md`。
## Open Questions
(无,技术方案第 9 节已全部确认)

View File

@@ -0,0 +1,31 @@
## Why
生产仍是卡表基线算法,2026-10-29 核对到 1632 张有生效套餐的卡被"卡住"(基线高于运营商读数、套餐长期扣 0),其中 916 张自 10-26 起持续用量、合计约 1635 GB 未扣。单卡导入测试环境已证明新计量逻辑(`add-card-traffic-queue-and-observed-deduction`)能解冻并按增量扣费,但无法说明全量差距。需要把生产卡、设备与套餐全量导入测试环境,按现有轮询跑真实读数算出差距;这些是真实用户的卡与设备,测试环境对它们只能查询,绝不能停复机或发其他写指令。现有环境变量名单 `JUNHONG_GATEWAY_READONLY_ICCIDS` 承载不了约 12 万个卡号与设备号。依据为 `docs/测试环境导入生产数据验证流量计量技术方案.md`(2026-10-29 已确认)。
## What Changes
- 新增网关只读资产表 `tb_gateway_readonly_card`(`identifier` 为网关请求卡号:卡为 ICCID、设备为 IMEI,唯一)。
- 新增环境变量 `JUNHONG_GATEWAY_READONLY_ENABLED`:开启后,只读表中的资产只允许查询类网关调用(查卡状态、查实名、查流量、查设备信息、查设备槽位、同步设备状态);停机、复机、限速、实名认证链接及设备切卡、恢复出厂、重启、WiFi 配置、模式切换在网关出口本地拦截、不发请求、返回明确失败;查表失败按拦截处理;不做内存缓存。未配置或关闭时不查表(生产不配置)。
- **BREAKING**(测试环境配置):删除 `JUNHONG_GATEWAY_READONLY_ICCIDS`,现有两张只读卡迁入只读表。
- 测试环境全量导入生产卡(约 9.86 万)、设备(约 2.01 万)、有效绑定、卡套餐与设备套餐(待生效、生效中、已用完;保留开始与结束时间、真实已用量、真总量与虚流量条款快照、重置周期、优先级与主套餐 / 加油包关系),全部挂测试代理 9「测试采购部」并置"待建起点";手机号、虚拟号按原值导入。导入用临时脚本,不提交;只读表必须先于导入写入并确认开关已部署。
- 导入资产完全按测试环境现有轮询配置与代码逻辑运行,不另设配置、不改业务流程。
- 输出差距报表:导入后测试环境新增扣减 vs 生产同期新增扣减、是否已到停机线。
- 不在本 Change:订单、钱包、分佣、退款、个人客户、审批等导入与验证;生产发布与回滚;验证结束后的撤除按 `docs/测试环境生产卡只读模式清理顺序.md` 另行执行。
## Capabilities
### New Capabilities
- `gateway-readonly-assets`:网关只读资产表、开关、查询类调用放行与其余调用拦截、查表失败拦截。
### Modified Capabilities
(无)
## Impact
- 数据库:新增成对迁移建 `tb_gateway_readonly_card`(不写入数据)。
- 代码:`internal/gateway/client.go`、`internal/gateway/readonly_guard.go`(改为按表判断)、新增只读表存储、`pkg/config`(删 `ReadonlyICCIDs`,加 `ReadonlyEnabled`)、`cmd/api/main.go`、`cmd/worker/main.go` 注入。
- 部署:`.gitea/workflows/deploy.yaml` 改为 `JUNHONG_GATEWAY_READONLY_ENABLED=true`;`docker-compose.prod.yml` 传递新变量、删除旧变量。
- 数据:测试库写入约 9.86 万卡、2.01 万设备、约 2.9 万套餐及只读表约 12 万行;导入脚本只连接 `junhong_cmp_test`,生产只读导出。
- 范围:测试环境。

View File

@@ -0,0 +1,57 @@
## ADDED Requirements
### Requirement: 网关只读资产表
系统 SHALL 提供网关只读资产表,每行保存一个网关请求卡号(卡为 ICCID,设备为 IMEI,统一大写)及其类型、来源、备注与写入时间,卡号唯一。表中的资产即"只读资产"。系统 MUST NOT 在迁移中写入只读资产数据;数据只由测试环境的导入脚本写入。
#### Scenario: 同一卡号重复写入
- **WHEN** 导入脚本对已存在的卡号再次写入
- **THEN** 只读表中该卡号仍只有一行
#### Scenario: 卡号大小写
- **WHEN** 网关请求卡号为小写,表中保存的是大写
- **THEN** 系统视为同一只读资产
### Requirement: 只读模式开关
系统 SHALL 通过环境变量 `JUNHONG_GATEWAY_READONLY_ENABLED` 控制只读模式:为 `true` 时按只读表限制网关调用;未配置或为 `false` 时 MUST NOT 查询只读表,所有网关调用行为与本变更前一致。系统 MUST NOT 再读取 `JUNHONG_GATEWAY_READONLY_ICCIDS`。API 与全部 Worker 进程 MUST 使用一致的取值。
#### Scenario: 生产未配置
- **WHEN** 环境未配置 `JUNHONG_GATEWAY_READONLY_ENABLED`
- **THEN** 网关调用不查只读表,停机、复机等照常发出
#### Scenario: 旧名单变量不再生效
- **WHEN** 环境只配置了 `JUNHONG_GATEWAY_READONLY_ICCIDS`,未开启只读模式
- **THEN** 系统不按该名单拦截任何调用
### Requirement: 只读资产只允许查询类调用
只读模式开启时,对只读资产系统 SHALL 只允许查询类网关调用:查卡状态(`/flow-card/status`)、查实名(`/flow-card/realName`)、查流量(`/flow-card/flow`)、查设备信息(`/device/info`)、查设备槽位(`/device/slot-info`)、同步设备状态(`/device/sync-info`),这些调用 MUST NOT 查只读表。其余网关调用(包括停机、复机、限速、实名认证链接,以及设备切卡、恢复出厂、重启、WiFi 配置、模式切换)在请求卡号命中只读表时,系统 MUST 在本地拦截、MUST NOT 向网关发送请求,并返回明确失败(不是结果未知),同时记录拦截日志。拦截 MUST 在网关客户端统一出口实现,覆盖自动停复机、人工停复机、套餐激活与到期、订单、退款、通道阈值、限速与设备操作等全部入口;业务流程 MUST NOT 为此改变。被拦截的停复机按现有失败处理记录与重试,本地卡状态不改变。
#### Scenario: 只读卡触发自动停机
- **WHEN** 只读卡在测试环境因无套餐或流量用完触发自动停机
- **THEN** 系统不向网关发送停机请求,记录停机失败与拦截日志,卡的本地网络状态不变
#### Scenario: 只读卡查流量
- **WHEN** 轮询对只读卡查询流量
- **THEN** 请求照常发往网关,不查只读表
#### Scenario: 只读设备重启
- **WHEN** 管理员对只读设备执行重启
- **THEN** 系统不向网关发送请求并返回失败
#### Scenario: 只读设备查实时状态
- **WHEN** 后台查看只读设备详情,读取设备信息与同步状态
- **THEN** 请求照常发往网关,不查只读表
#### Scenario: 非只读卡停机
- **WHEN** 只读模式开启,对不在只读表中的卡发起停机
- **THEN** 请求照常发往网关
### Requirement: 查表失败按拦截处理
只读模式开启时,非查询类网关调用在查询只读表失败(包括超时、连接错误与表不存在)时,系统 MUST 按拦截处理:不发送请求、返回明确失败并记录日志。系统 MUST NOT 缓存只读表;只读表写入后,下一次调用即按新数据判断。
#### Scenario: 只读表查询超时
- **WHEN** 只读模式开启,停机前查询只读表超时
- **THEN** 系统不发送停机请求并返回失败
#### Scenario: 新写入的只读卡立即生效
- **WHEN** 只读表刚写入某卡号,随即对该卡发起复机
- **THEN** 系统拦截该复机请求

View File

@@ -0,0 +1,38 @@
## 1. 只读表与存储
- [x] 1.1 新增成对迁移:建 `tb_gateway_readonly_card`(`id`、`identifier` 唯一、`identifier_type`(iccid / imei)、`source`、`remark`、`created_at`),注释写明清理顺序文档;down 删表;不写入数据。
- [x] 1.2 新增 model 与 `internal/store/postgres` 存储:按卡号(大写)判断是否只读资产;批量写入(`ON CONFLICT DO NOTHING`)供导入脚本复用。
## 2. 网关拦截
- [x] 2.1 `internal/gateway/readonly_guard.go` 改为函数接缝 `func(ctx, cardNo) (bool, error)`:查询类路径(`/flow-card/status`、`/flow-card/realName`、`/flow-card/flow`、`/device/info`、`/device/slot-info`、`/device/sync-info`)直接放行不查表;其余路径取请求 `cardNo`(大写)查表,命中或查表失败则本地拦截,返回 `upstream` 类别的明确失败并记录 `gateway.readonly_card.blocked` 日志。
- [x] 2.2 删除 `WithReadonlyCards` 与内存名单;`doRequest` / `doRequestWithoutPayloadLog` 改调新判断(需 ctx)。
## 3. 配置与部署
- [x] 3.1 `pkg/config`:删除 `ReadonlyICCIDs` 与 `gateway.readonly_iccids` 绑定,新增 `ReadonlyEnabled`(`gateway.readonly_enabled` → `JUNHONG_GATEWAY_READONLY_ENABLED`)。
- [x] 3.2 `cmd/api/main.go`、`cmd/worker/main.go`:开关开启时注入只读表判断,并在启动日志说明只读模式已开启。
- [x] 3.3 `docker-compose.prod.yml` 传递 `JUNHONG_GATEWAY_READONLY_ENABLED`、删除旧变量;`.gitea/workflows/deploy.yaml` 改为 `JUNHONG_GATEWAY_READONLY_ENABLED=true`。
## 4. 切换(测试环境,先于推送)
- [x] 4.1 按 ENG-TEST-001 在测试库执行迁移 up/down/up,核对表结构。(10-29 执行 268→269→268→269,版本 269)
- [x] 4.2 测试库写入现有两张只读卡(`89861590172420410612`、`89861590172420420169`)。
- [ ] 4.3 `gofmt`、`go build ./cmd/api ./cmd/worker`、`openspec validate add-gateway-readonly-prod-import --strict`;提交并推送,以 Gitea Actions 运行成功确认部署。
- [ ] 4.4 部署后核对:只读卡的流量、卡状态、实名查询正常,只读设备详情实时状态正常;对只读卡发起一次停机(后台按钮)被拦截、未发出请求、卡状态未变;对非只读测试卡的查询不受影响。
## 5. 全量导入(临时脚本 `cmd/zz_tmp_*`,不提交)
- [ ] 5.1 生产只读导出:卡、设备、有效绑定、卡套餐与设备套餐(待生效 / 生效中 / 已用完)、通道编码与重置日。
- [ ] 5.2 通道映射:按 `carrier_code` 映射到测试库通道,缺失的按生产配置补建;输出映射与未映射清单。
- [ ] 5.3 写只读表:全部卡 ICCID 与设备 IMEI;核对行数与导出一致。
- [ ] 5.4 导入卡与资产标识(ICCID、虚拟号)、设备与绑定,全部挂测试代理 9「测试采购部」,手机号、虚拟号按原值;同卡号已存在则跳过并记录;把测试卡 12244、12245 也挂到代理 9。
- [ ] 5.5 导入套餐使用记录:保留开始与结束时间、真实已用量、真总量、虚总量、展示倍率、是否启用虚流量、重置周期与下次重置时间、优先级、主套餐 / 加油包关系;订单号 `PROD-<原订单号>`、订单 ID 占位、`package_id` 指向测试占位套餐。
- [ ] 5.6 全部导入卡置"待建起点";触发轮询重新加载并确认导入卡进入调度。
- [ ] 5.7 导入后核对:卡、设备、绑定、套餐行数与导出一致;抽查 10 张卡的套餐时间与已用量与生产一致;导入后 1 小时内停复机记录全部为拦截失败、无发出的写请求。
## 6. 差距报表
- [ ] 6.1 记录导入时刻生产各套餐真实已用量作为起点。
- [ ] 6.2 运行至少 24 小时后,按套餐(设备套餐按设备合并)输出:测试环境新增扣减、生产同期新增扣减、差额、是否已到停机线;汇总卡住卡数、总差额(GB)、按新逻辑已到停机线但生产未停的套餐数。
- [ ] 6.3 用 flowcollector 本月读数给出导入前差距上限,与报表一并提交。