From 5cf67b98de2968aa7f144fa5a2afc6d12401b56a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=82=B9=E8=BF=9B?= Date: Thu, 29 Oct 2026 17:55:52 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=E7=BD=91=E5=85=B3=E5=8F=AA=E8=AF=BB?= =?UTF-8?q?=E8=B5=84=E4=BA=A7=E6=94=B9=E4=B8=BA=E6=95=B0=E6=8D=AE=E8=A1=A8?= =?UTF-8?q?=EF=BC=8C=E5=8F=AA=E8=AF=BB=E8=B5=84=E4=BA=A7=E5=8F=AA=E5=85=81?= =?UTF-8?q?=E8=AE=B8=E6=9F=A5=E8=AF=A2=E7=B1=BB=E8=B0=83=E7=94=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 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 --- .gitea/workflows/deploy.yaml | 4 +- cmd/api/main.go | 11 +- cmd/worker/main.go | 10 +- docker-compose.prod.yml | 4 +- ...µ‹试环境导入生产数据验证流量计量技术方案.md | 150 ++++++++++++++++++ docs/测试环境生产卡只读模式清理顺序.md | 47 ++++++ internal/gateway/client.go | 8 +- internal/gateway/readonly_guard.go | 56 ++++--- internal/model/gateway_readonly_card.go | 24 +++ .../postgres/gateway_readonly_card_store.go | 39 +++++ .../000269_add_gateway_readonly_card.down.sql | 2 + .../000269_add_gateway_readonly_card.up.sql | 24 +++ .../.openspec.yaml | 2 + .../design.md | 62 ++++++++ .../proposal.md | 31 ++++ .../specs/gateway-readonly-assets/spec.md | 57 +++++++ .../add-gateway-readonly-prod-import/tasks.md | 38 +++++ pkg/config/config.go | 6 +- pkg/config/loader.go | 2 +- 19 files changed, 536 insertions(+), 41 deletions(-) create mode 100644 docs/测试环境导入生产数据验证流量计量技术方案.md create mode 100644 docs/测试环境生产卡只读模式清理顺序.md create mode 100644 internal/model/gateway_readonly_card.go create mode 100644 internal/store/postgres/gateway_readonly_card_store.go create mode 100644 migrations/000269_add_gateway_readonly_card.down.sql create mode 100644 migrations/000269_add_gateway_readonly_card.up.sql create mode 100644 openspec/changes/add-gateway-readonly-prod-import/.openspec.yaml create mode 100644 openspec/changes/add-gateway-readonly-prod-import/design.md create mode 100644 openspec/changes/add-gateway-readonly-prod-import/proposal.md create mode 100644 openspec/changes/add-gateway-readonly-prod-import/specs/gateway-readonly-assets/spec.md create mode 100644 openspec/changes/add-gateway-readonly-prod-import/tasks.md diff --git a/.gitea/workflows/deploy.yaml b/.gitea/workflows/deploy.yaml index 6bac7494..738cf8b8 100644 --- a/.gitea/workflows/deploy.yaml +++ b/.gitea/workflows/deploy.yaml @@ -60,8 +60,8 @@ jobs: printf 'JUNHONG_APPROVAL_LEGACY_OFFLINE_RECHARGE_PAY_ENABLED=true\n' printf 'JUNHONG_WORKER_ROLE=all\n' printf 'JUNHONG_WORKER_INSTANCE_NAME=test-worker-all-1\n' - # 测试环境只读卡:从生产导入做流量采集验证的卡,只允许查询,停复机等写操作在网关出口拦截 - printf 'JUNHONG_GATEWAY_READONLY_ICCIDS=89861590172420410612,89861590172420420169\n' + # 测试环境只读模式:tb_gateway_readonly_card 中的生产卡与设备只允许查询类网关调用(add-gateway-readonly-prod-import) + printf 'JUNHONG_GATEWAY_READONLY_ENABLED=true\n' } > ${{ env.DEPLOY_DIR }}/.env # 强制更新 docker-compose.prod.yml(确保使用最新配置) diff --git a/cmd/api/main.go b/cmd/api/main.go index 50309488..1b57be37 100644 --- a/cmd/api/main.go +++ b/cmd/api/main.go @@ -26,6 +26,7 @@ import ( internalMiddleware "github.com/break/junhong_cmp_fiber/internal/middleware" "github.com/break/junhong_cmp_fiber/internal/routes" "github.com/break/junhong_cmp_fiber/internal/service/verification" + "github.com/break/junhong_cmp_fiber/internal/store/postgres" "github.com/break/junhong_cmp_fiber/pkg/auth" pkgbootstrap "github.com/break/junhong_cmp_fiber/pkg/bootstrap" "github.com/break/junhong_cmp_fiber/pkg/config" @@ -71,7 +72,7 @@ func main() { storageSvc := initStorage(cfg, appLogger) // 9. 初始化 Gateway 客户端(可选) - gatewayClient := initGateway(cfg, appLogger) + gatewayClient := initGateway(cfg, db, appLogger) // 卡网关调用按本系统运营商账户限流与统计(默认只统计不拦截)。 gatewayLimit := gatewaylimit.NewRuntime(db, redisClient, appLogger) if gatewayClient != nil { @@ -448,7 +449,7 @@ func initStorage(cfg *config.Config, appLogger *zap.Logger) *storage.Service { return storage.NewService(provider, &cfg.Storage) } -func initGateway(cfg *config.Config, appLogger *zap.Logger) *gateway.Client { +func initGateway(cfg *config.Config, db *gorm.DB, appLogger *zap.Logger) *gateway.Client { if cfg.Gateway.BaseURL == "" { appLogger.Info("Gateway 未配置,跳过初始化") return nil @@ -459,7 +460,11 @@ func initGateway(cfg *config.Config, appLogger *zap.Logger) *gateway.Client { cfg.Gateway.AppID, cfg.Gateway.AppSecret, appLogger, - ).WithTimeout(time.Duration(cfg.Gateway.Timeout) * time.Second).WithReadonlyCards(cfg.Gateway.ReadonlyICCIDs) + ).WithTimeout(time.Duration(cfg.Gateway.Timeout) * time.Second) + if cfg.Gateway.ReadonlyEnabled { + // 测试环境只读模式:只读资产表中的卡与设备只允许查询类调用(add-gateway-readonly-prod-import)。 + client.WithReadonlyChecker(postgres.NewGatewayReadonlyCardStore(db).IsReadonly) + } appLogger.Info("Gateway 客户端初始化成功", zap.String("base_url", cfg.Gateway.BaseURL), diff --git a/cmd/worker/main.go b/cmd/worker/main.go index cb7274a2..3074d508 100644 --- a/cmd/worker/main.go +++ b/cmd/worker/main.go @@ -294,7 +294,7 @@ func initWorkerRuntime(ctx context.Context, cfg *config.Config, appLogger *zap.L } storageSvc := initStorage(cfg, appLogger) - gatewayClient := initGateway(cfg, appLogger) + gatewayClient := initGateway(cfg, db, appLogger) // 卡网关调用按本系统运营商账户限流与统计(默认只统计不拦截)。 gatewayLimit := gatewaylimit.NewRuntime(db, redisClient, appLogger) if gatewayClient != nil { @@ -1445,7 +1445,7 @@ func initStorage(cfg *config.Config, appLogger *zap.Logger) *storage.Service { } // initGateway 初始化 Gateway 客户端 -func initGateway(cfg *config.Config, appLogger *zap.Logger) *gateway.Client { +func initGateway(cfg *config.Config, db *gorm.DB, appLogger *zap.Logger) *gateway.Client { if cfg.Gateway.BaseURL == "" { appLogger.Info("Gateway 未配置,跳过初始化(轮询任务将无法查询真实数据)") return nil @@ -1456,7 +1456,11 @@ func initGateway(cfg *config.Config, appLogger *zap.Logger) *gateway.Client { cfg.Gateway.AppID, cfg.Gateway.AppSecret, appLogger, - ).WithTimeout(time.Duration(cfg.Gateway.Timeout) * time.Second).WithReadonlyCards(cfg.Gateway.ReadonlyICCIDs) + ).WithTimeout(time.Duration(cfg.Gateway.Timeout) * time.Second) + if cfg.Gateway.ReadonlyEnabled { + // 测试环境只读模式:只读资产表中的卡与设备只允许查询类调用(add-gateway-readonly-prod-import)。 + client.WithReadonlyChecker(postgres.NewGatewayReadonlyCardStore(db).IsReadonly) + } appLogger.Info("Gateway 客户端初始化成功", zap.String("base_url", cfg.Gateway.BaseURL), diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index f7cdeabf..c1b03620 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -72,7 +72,7 @@ services: - JUNHONG_GATEWAY_APP_ID=LfjL0WjUqpwkItQ0 - JUNHONG_GATEWAY_APP_SECRET=K0DYuWzbRE6zg5bX - JUNHONG_GATEWAY_TIMEOUT=30 - - JUNHONG_GATEWAY_READONLY_ICCIDS=${JUNHONG_GATEWAY_READONLY_ICCIDS:-} + - JUNHONG_GATEWAY_READONLY_ENABLED=${JUNHONG_GATEWAY_READONLY_ENABLED:-false} # 七月迭代:企业微信 Adapter 与旧审批入口切换 - JUNHONG_WECOM_BASE_URL=https://qyapi.weixin.qq.com - JUNHONG_WECOM_TIMEOUT=10s @@ -141,7 +141,7 @@ services: - JUNHONG_GATEWAY_APP_ID=LfjL0WjUqpwkItQ0 - JUNHONG_GATEWAY_APP_SECRET=K0DYuWzbRE6zg5bX - JUNHONG_GATEWAY_TIMEOUT=30 - - JUNHONG_GATEWAY_READONLY_ICCIDS=${JUNHONG_GATEWAY_READONLY_ICCIDS:-} + - JUNHONG_GATEWAY_READONLY_ENABLED=${JUNHONG_GATEWAY_READONLY_ENABLED:-false} - JUNHONG_POLLING_VERBOSE_LOG=true # 七月迭代:单 Worker 测试环境同时承担调度和消费 - JUNHONG_WORKER_ROLE=${JUNHONG_WORKER_ROLE:-all} diff --git a/docs/测试环境导入生产数据验证流量计量技术方案.md b/docs/测试环境导入生产数据验证流量计量技术方案.md new file mode 100644 index 00000000..f98561a2 --- /dev/null +++ b/docs/测试环境导入生产数据验证流量计量技术方案.md @@ -0,0 +1,150 @@ +# 测试环境导入生产数据验证流量计量技术方案 + +| 项目 | 内容 | +|---|---| +| 日期 | 2026-10-29 | +| 状态 | 方案已确认(2026-10-29),代码未修改 | +| 范围 | 测试环境 | +| 关联 | `docs/卡流量采集写入与扣减技术方案.md`、Change `add-card-traffic-queue-and-observed-deduction`、`docs/测试环境生产卡只读模式清理顺序.md` | + +## 1. 背景与目标 + +生产(迁移版本 240,仍是卡表基线算法)大量卡"卡住":卡表基线 `last_gateway_reading_mb` 高于运营商读数,读数被当成下降拒绝,套餐长期扣 0。2026-10-29 核对: + +- 生产有生效套餐、开启轮询、本月用量为 0、基线大于 0 的卡:1632 张(独立卡 1233 张)。 +- 其中 916 张在 flowcollector 10-26 起的读数里持续上涨、一直未扣,合计约 1635 GB。 +- 单卡导入测试环境已验证新算法可解冻并按增量扣费(测试卡 12244、12245),但单卡无法说明全量差距。 + +目标:把生产的卡、设备与套餐数据导入测试环境,用新计量逻辑(采集队列、日快照、建起点、按采集时刻扣减)跑真实读数,算出全量差距;**全过程不得对真实卡、设备发起任何写操作(停复机、限速、实名认证、设备指令)**。 + +不做:订单、钱包、分佣、退款、审批、个人客户、通知等业务流程的验证;生产发布与回滚。 + +## 2. 数据规模(2026-10-29) + +| 项 | 生产 | 测试 | +|---|---|---| +| 卡 | 98,567(开启轮询 90,073,独立卡 59,900) | 33 | +| 设备 | 20,144(有效绑定 38,667) | 6 | +| 生效中套餐 | 22,426(其中设备套餐 10,799) | — | +| 待生效 / 生效中 / 已用完套餐 | 29,175 | 128(全部状态) | +| 使用中的运营商通道 | 34 | 13 | +| 迁移版本 | 240 | 268 | + +## 3. 方案总览 + +``` +① 建只读表 + 开关上线(测试环境) +② 生产数据 → 只读表(全部 ICCID 与设备 IMEI) +③ 确认部署生效、只读表已写入 +④ 导入卡、设备、绑定、套餐(测试库),全部卡打"待建起点" +⑤ 轮询按新逻辑采集 → 采集记录、日快照、套餐扣减 +⑥ 差距报表:新逻辑应扣 vs 生产已扣 +⑦ 验证结束后按清理文档撤除 +``` + +必须先 ①②③ 再 ④:导入的卡在测试环境一开始没有套餐或状态不一致,会立即触发停复机判断,此时只读保护必须已生效。 + +## 4. 只读表与开关 + +### 4.1 表 `tb_gateway_readonly_card` + +| 字段 | 说明 | +|---|---| +| `id` | 主键 | +| `identifier` | 网关请求里的卡号 `cardNo`:卡为 ICCID,设备指令为设备 IMEI;大写;唯一索引 | +| `identifier_type` | `iccid` / `imei` | +| `source` | 来源,如 `prod_import_20261029` | +| `remark` | 备注 | +| `created_at` | 写入时间 | + +- 新的成对迁移建表;不写入数据(数据是测试环境专用,由导入脚本写入)。 +- 不使用数据库外键。 + +### 4.2 开关 `JUNHONG_GATEWAY_READONLY_ENABLED` + +只读表里的卡与设备**只允许查询类调用**,其余一律不允许: + +| 允许 | 接口 | +|---|---| +| 查卡状态 | `/flow-card/status` | +| 查实名 | `/flow-card/realName` | +| 查流量 | `/flow-card/flow` | +| 查设备信息 | `/device/info` | +| 查设备槽位 | `/device/slot-info` | +| 同步设备状态 | `/device/sync-info` | + +- `true`:网关客户端每次调用前,接口不在上表时,按请求卡号(`cardNo`)查表,命中则本地拦截、不发请求,返回明确失败(不是结果未知),记日志 `gateway.readonly_card.blocked`。上表接口不查表、直接放行。 +- 被拦截的包括但不限于:停机 `/flow-card/cardStop`、复机 `/flow-card/cardStart`、限速 `/flow-card/speedLimit`、实名认证链接 `/flow-card/RealNameVerification`,以及设备切卡、恢复出厂、重启、WiFi 配置、模式切换。 +- 查表失败(含超时、表不存在)按拦截处理:宁可调用失败,不放过真卡。 +- 不做内存缓存:非查询类调用本身不频繁,按唯一索引查表约 1 ms;表一写入立即生效,没有刷新空窗。 +- 未配置或为 `false`:不查表。生产不配置。 +- 替换现有 `JUNHONG_GATEWAY_READONLY_ICCIDS`;名单内现有两张卡(`89861590172420410612`、`89861590172420420169`)迁入表中。 + +### 4.3 拦截覆盖面 + +所有网关调用都经过 `internal/gateway/client.go` 的 `doRequest` / `doRequestWithoutPayloadLog`,在这里判断即可覆盖所有入口:自动停复机(轮询、流量扣减、通道阈值、套餐激活与到期、订单、退款)、人工停复机按钮、限速档位、实名认证链接、设备各类接口等。业务代码不做任何改动。 + +## 5. 数据导入 + +### 5.1 范围(2026-10-29 已确认:全量) + +| 数据 | 导入内容 | +|---|---| +| 运营商通道 | 不导入;按 `carrier_code` 映射到测试库已有通道,测试库缺的通道按生产配置补建(含重置日) | +| 卡 | 生产全部卡(98,567):ICCID、ICCID19/20、MSISDN、虚拟号、通道、卡类别、实名状态、网络状态、实名策略、轮询开关;店铺统一挂测试代理 9「测试采购部」 | +| 资产标识 | 每张卡的 ICCID、虚拟号写入 `tb_asset_identifier` | +| 设备与绑定 | 生产全部设备(20,144):设备号、IMEI、SN、槽位与有效绑定关系;店铺统一挂测试代理 9「测试采购部」 | +| 套餐使用记录 | 卡套餐与设备套餐中待生效、生效中、已用完的记录(约 29,175 条),保留生产的开始(生效)时间、结束(到期)时间、真总量、虚总量、重置周期与下次重置时间、优先级与主套餐 / 加油包关系;**只保存真实已用量**(`data_usage_mb`,导入时刻的生产当前值;待生效套餐本来为 0)。虚流量已用量不存储,由真实已用量 × 展示倍率 `display_gain_ratio_snapshot` 现算(上限为真总量);虚总量、展示倍率、是否启用虚流量是套餐条款快照(停机线 = 启用虚流量时的虚总量,否则真总量),照原样复制;订单号用 `PROD-<原订单号>` 标记,订单 ID 用不存在的占位值;`package_id` 指向一个测试占位套餐 | +| 不导入 | 订单、钱包、分佣、退款、个人客户、审批、历史日用量与审计 | + +### 5.2 方式 + +用导入脚本直接写测试库(后台导卡接口一次只能导卡,不能导设备与套餐,也不适合 10 万级数据): + +1. 从生产只读导出(dbhub / 只读连接),生成 CSV。 +2. 写只读表:全部 ICCID 与设备 IMEI。 +3. 确认开关已部署(以 Gitea Actions 部署成功为准)且只读表行数与导出一致。 +4. 按通道映射写卡、资产标识、设备、绑定、套餐;同 ICCID / IMEI 已存在则跳过并记录。 +5. 全部导入的卡置"待建起点"(`traffic_baseline_pending = true`)。 +6. 触发轮询重新加载(重启 Worker 或调用轮询初始化),使导入的卡进入调度。 + +导入脚本放在 `cmd/zz_tmp_*`,不提交;只允许连接 `junhong_cmp_test`。 + +### 5.3 建起点 + +每张卡在测试环境第一次通过队列写入时只建起点:把当时运营商读数对应的本月累计全部记为已结算,不扣费。之后按增量扣到导入的套餐上。因此测试环境算出的是"导入之后"的应扣,导入之前的差距由第 7 节用 flowcollector 读数补充。 + +## 6. 轮询 + +- **完全按测试环境现有的轮询配置与代码逻辑运行**,不为导入的卡、设备单独配置,也不改业务流程:查卡状态、查实名、查流量、查套餐照常执行;流量照常进采集队列、写日快照、建起点、按增量扣套餐;套餐照常激活、到期、重置;停复机判断照常执行并记录。 +- 唯一不同:导入资产的网关调用只放行查询类(查卡状态、查实名、查流量、查设备信息、查设备槽位、同步设备状态),停机、复机等其他调用在网关出口拦截(第 4 节)。被拦截的停复机记为失败并按退避重试,本地卡状态不变;这些失败记录是预期的,也正好说明"按新逻辑该停/该复"的卡有哪些。 +- 查卡状态、查实名放行后,测试环境的网络状态、实名状态跟随真实情况。 +- 轮询频率不作为本方案的约束。 + +## 7. 差距报表 + +按套餐(设备套餐按设备合并)输出: + +| 列 | 来源 | +|---|---| +| 卡 / 设备、套餐、生效与到期时间、真总量、虚总量 | 导入数据 | +| 导入时已用量 | 导入时刻生产套餐已用量(两边共同起点) | +| 测试环境新增扣减 | 测试环境套餐已用量 − 导入时已用量(建起点之后按新逻辑扣) | +| 导入前差距上限 | 本月运营商读数(重置日按通道配置换算)对生效后的上限,来自 flowcollector | +| 生产同期新增扣减 | 生产套餐已用量(同一时间点只读查询)− 导入时已用量 | +| 差额、是否已到停机线 | 计算 | + +汇总:卡住卡数、总差额(GB)、按新逻辑已到停机线但生产未停的套餐数。 + +## 8. 清理 + +按 `docs/测试环境生产卡只读模式清理顺序.md` 执行:先处理测试环境里的生产卡 → 关开关并重新部署 → 新迁移删表 → 可选清理代码。导入的卡、设备、套餐与采集数据在第一步删除。 + +## 9. 已确认事项 + +已确认(2026-10-29): + +1. 导入全量:卡、设备、绑定,以及卡套餐与设备套餐(含开始、结束时间)。 +2. 套餐只保存真实已用量(导入时刻生产当前值),虚流量已用量不存储、由真实已用量 × 展示倍率现算。差距报表因此按"导入后测试环境新增扣减 vs 生产同期新增扣减"对比,两边起点一致。 +3. 卡和设备全部挂测试代理 9「测试采购部」。之前单独导入的两张卡(测试卡 12244、12245,目前未挂店铺)一并挂过去。 +4. 生产卡的 MSISDN、虚拟号等按原值导入,不脱敏。 diff --git a/docs/测试环境生产卡只读模式清理顺序.md b/docs/测试环境生产卡只读模式清理顺序.md new file mode 100644 index 00000000..f54918e1 --- /dev/null +++ b/docs/测试环境生产卡只读模式清理顺序.md @@ -0,0 +1,47 @@ +# 测试环境生产卡只读模式清理顺序 + +| 项目 | 内容 | +|---|---| +| 日期 | 2026-10-29 | +| 范围 | 测试环境 | +| 对应机制 | 网关只读卡表 `tb_gateway_readonly_card` + 环境变量 `JUNHONG_GATEWAY_READONLY_ENABLED` | + +## 背景 + +为验证卡流量采集与扣减,把生产上"卡住"的卡导入测试环境。这些是真实用户的卡,测试环境只能查询,不能停复机。 + +- 只读卡表 `tb_gateway_readonly_card`:存放导入卡的 ICCID 与导入设备的 IMEI(`identifier` 唯一索引)。 +- 环境变量 `JUNHONG_GATEWAY_READONLY_ENABLED=true` 时,网关每次写操作(停机、复机、限速、实名认证、设备指令)前按请求卡号查表:在表里就本地拦截、不发请求;查流量、卡状态、实名这三类查询不查表、直接放行。 +- 查表失败按拦截处理(宁可停复机失败,不放过真卡)。 +- 生产不配置该环境变量,代码不查表。 + +## 清理顺序(必须按顺序执行) + +### 1. 先处理测试环境里的生产卡(保护仍在,安全) + +- 把表里这些卡在测试环境的 `enable_polling` 设为 `false`,或删除卡及其测试套餐、采集记录、日快照。 +- 确认这些卡已不在轮询队列中,也没有待执行的停复机指令。 + +> 顺序错误的后果:保护撤掉后这些卡仍在测试环境,一旦触发无套餐、套餐用完、套餐到期等停复机,会**真实停掉用户的卡**。 + +### 2. 关闭开关并重新部署 + +- 部署脚本去掉 `JUNHONG_GATEWAY_READONLY_ENABLED`(或设为 `false`),重新部署 API 与 Worker。 +- 以 Gitea Actions 部署成功为准确认生效(不要用数据库连接建立时间判断,连接池每 5 分钟会重建连接)。 + +> 顺序错误的后果:开关开着就删表,查表报"表不存在"按失败处理,测试环境**所有卡**的写操作都会被拦截,直到关闭开关。 + +### 3. 删表 + +- 新建一对迁移:up 执行 `DROP TABLE tb_gateway_readonly_card`,down 重建该表。 +- 不修改原建表迁移。 + +### 4. 清理代码(可选) + +- 删除写操作前的查表逻辑与开关配置。开关关闭时这段代码不生效,不删也不影响。 + +## 其他说明 + +- 生产:不配置开关,不查表。若该版本发布到生产,建表迁移会建出空表,不影响功能,后续删表迁移随之执行即可。 +- 数据量:几万行(ICCID + 备注),删表瞬间完成,无锁表风险。 +- 留痕:拦截记录在日志、停复机记录与审计中,与该表无关,删表不影响已有记录。 diff --git a/internal/gateway/client.go b/internal/gateway/client.go index e1feabdf..c1fd11f9 100644 --- a/internal/gateway/client.go +++ b/internal/gateway/client.go @@ -38,8 +38,8 @@ type Client struct { logger *zap.Logger maxRetries int guard CallGuard // 卡网关调用守卫(限流与统计),为 nil 时不生效 - // readonlyCards 是只读卡名单(ICCID 大写),名单内的卡只放行查询接口,为空时不生效。 - readonlyCards map[string]struct{} + // readonlyChecker 判断请求卡号是否为只读资产(测试环境只读模式),为 nil 时不生效。 + readonlyChecker ReadonlyChecker } type attemptObserverKey struct{} @@ -103,7 +103,7 @@ func (c *Client) WithRetry(maxRetries int) *Client { // 流程:包装参数 → 序列化 → 加密 → 签名 → HTTP POST(带重试)→ 解析响应 → 检查业务状态码 // params: 请求参数结构体,内部自动包装为 {"params": } 格式 func (c *Client) doRequest(ctx context.Context, path string, params interface{}) (json.RawMessage, error) { - if err := c.rejectReadonlyWrite(path, params); err != nil { + if err := c.rejectReadonlyWrite(ctx, path, params); err != nil { return nil, err } // 将参数包装为 {"params": ...} 格式后序列化 @@ -126,7 +126,7 @@ func (c *Client) doRequest(ctx context.Context, path string, params interface{}) // doRequestWithoutPayloadLog 执行 Gateway 请求,但不记录请求体与响应体。 // 仅用于载荷含敏感内容(如付款凭证图片)的能力;成功与失败都只记录路径、耗时与结果摘要。 func (c *Client) doRequestWithoutPayloadLog(ctx context.Context, path string, params interface{}) (json.RawMessage, error) { - if err := c.rejectReadonlyWrite(path, params); err != nil { + if err := c.rejectReadonlyWrite(ctx, path, params); err != nil { return nil, err } dataBytes, err := sonic.Marshal(requestWrapper{Params: params}) diff --git a/internal/gateway/readonly_guard.go b/internal/gateway/readonly_guard.go index c937b874..98cb76e7 100644 --- a/internal/gateway/readonly_guard.go +++ b/internal/gateway/readonly_guard.go @@ -1,6 +1,7 @@ package gateway import ( + "context" "encoding/json" "strings" @@ -10,50 +11,59 @@ import ( "github.com/break/junhong_cmp_fiber/pkg/errors" ) -// readonlyAllowedPaths 是只读卡允许调用的网关接口:只查流量、卡状态与实名状态。 +// readonlyAllowedPaths 是只读资产允许调用的查询类网关接口,这些调用不查只读表。 var readonlyAllowedPaths = map[string]struct{}{ - "/flow-card/flow": {}, "/flow-card/status": {}, "/flow-card/realName": {}, + "/flow-card/flow": {}, + "/device/info": {}, + "/device/slot-info": {}, + "/device/sync-info": {}, } -// WithReadonlyCards 设置只读卡名单(逗号分隔的 ICCID,支持链式调用)。 -// 名单里的卡只放行查询接口,停机、复机、限速、实名认证等写操作在本地拦截、不发请求; -// 用于把生产卡导入测试环境做流量采集验证,名单为空时不生效(生产不配置)。 -func (c *Client) WithReadonlyCards(iccids string) *Client { - cards := map[string]struct{}{} - for _, iccid := range strings.Split(iccids, ",") { - if iccid = strings.ToUpper(strings.TrimSpace(iccid)); iccid != "" { - cards[iccid] = struct{}{} - } - } - c.readonlyCards = cards - if len(cards) > 0 && c.logger != nil { - c.logger.Warn("Gateway 只读卡名单已启用:名单内的卡只允许查询,写操作一律拦截", zap.Int("card_count", len(cards))) +// ReadonlyChecker 判断网关请求卡号(卡为 ICCID,设备为 IMEI)是否为只读资产。 +type ReadonlyChecker func(ctx context.Context, identifier string) (bool, error) + +// WithReadonlyChecker 开启只读模式(支持链式调用):只读资产只允许查询类调用, +// 停机、复机、限速、实名认证链接与设备写操作在本地拦截、不发请求。checker 为 nil 时不生效(生产不开启)。 +func (c *Client) WithReadonlyChecker(checker ReadonlyChecker) *Client { + c.readonlyChecker = checker + if checker != nil && c.logger != nil { + c.logger.Warn("Gateway 只读模式已开启:只读资产只允许查询类调用,其余调用一律拦截") } return c } -// rejectReadonlyWrite 判断本次请求是否为只读卡的写操作;是则记日志并返回明确失败(不是结果未知),不发请求。 -func (c *Client) rejectReadonlyWrite(path string, params interface{}) error { - if len(c.readonlyCards) == 0 { +// rejectReadonlyWrite 判断本次请求是否为只读资产的非查询调用;是则记日志并返回明确失败(不是结果未知),不发请求。 +// 查只读表失败也按拦截处理:宁可调用失败,不放过真卡。 +func (c *Client) rejectReadonlyWrite(ctx context.Context, path string, params interface{}) error { + if c.readonlyChecker == nil { return nil } if _, ok := readonlyAllowedPaths[path]; ok { return nil } cardNo := requestCardNo(params) - if _, blocked := c.readonlyCards[cardNo]; !blocked { + if cardNo == "" { + return nil + } + readonly, err := c.readonlyChecker(ctx, cardNo) + if err == nil && !readonly { return nil } if c.logger != nil { - c.logger.Warn("Gateway 只读卡写操作已拦截,未发送请求", - zap.String("path", path), zap.String("card_no", cardNo), zap.String("metric", "gateway.readonly_card.blocked")) + fields := []zap.Field{zap.String("path", path), zap.String("card_no", cardNo), zap.String("metric", "gateway.readonly_card.blocked")} + if err != nil { + fields = append(fields, zap.Error(err)) + c.logger.Error("Gateway 只读表查询失败,按只读拦截,未发送请求", fields...) + } else { + c.logger.Warn("Gateway 只读资产非查询调用已拦截,未发送请求", fields...) + } } - return newGatewayError(constants.GatewayErrorUpstream, errors.New(errors.CodeGatewayError, "测试环境只读卡,已拦截写操作:"+path)) + return newGatewayError(constants.GatewayErrorUpstream, errors.New(errors.CodeGatewayError, "测试环境只读资产,已拦截调用:"+path)) } -// requestCardNo 取请求里的卡号(cardNo 或 iccid 字段),取不到时返回空串。 +// requestCardNo 取请求里的卡号(cardNo 或 iccid 字段,大写),取不到时返回空串。 func requestCardNo(params interface{}) string { raw, err := json.Marshal(params) if err != nil { diff --git a/internal/model/gateway_readonly_card.go b/internal/model/gateway_readonly_card.go new file mode 100644 index 00000000..d0ce23e5 --- /dev/null +++ b/internal/model/gateway_readonly_card.go @@ -0,0 +1,24 @@ +package model + +import "time" + +// 网关只读资产卡号类型。 +const ( + GatewayReadonlyTypeICCID = "iccid" + GatewayReadonlyTypeIMEI = "imei" +) + +// GatewayReadonlyCard 网关只读资产:表中的卡与设备只允许查询类网关调用。 +type GatewayReadonlyCard struct { + ID uint `gorm:"column:id;primaryKey;autoIncrement" json:"id"` + Identifier string `gorm:"column:identifier;type:varchar(64);not null;uniqueIndex:uk_gateway_readonly_card_identifier" json:"identifier"` + IdentifierType string `gorm:"column:identifier_type;type:varchar(16);not null" json:"identifier_type"` + Source string `gorm:"column:source;type:varchar(64);not null;default:''" json:"source"` + Remark string `gorm:"column:remark;type:varchar(255);not null;default:''" json:"remark"` + CreatedAt time.Time `gorm:"column:created_at;not null;default:now()" json:"created_at"` +} + +// TableName 指定表名。 +func (GatewayReadonlyCard) TableName() string { + return "tb_gateway_readonly_card" +} diff --git a/internal/store/postgres/gateway_readonly_card_store.go b/internal/store/postgres/gateway_readonly_card_store.go new file mode 100644 index 00000000..53e467cc --- /dev/null +++ b/internal/store/postgres/gateway_readonly_card_store.go @@ -0,0 +1,39 @@ +package postgres + +import ( + "context" + "strings" + + "gorm.io/gorm" + "gorm.io/gorm/clause" + + "github.com/break/junhong_cmp_fiber/internal/model" +) + +// GatewayReadonlyCardStore 网关只读资产存储。 +type GatewayReadonlyCardStore struct { + db *gorm.DB +} + +// NewGatewayReadonlyCardStore 创建网关只读资产存储。 +func NewGatewayReadonlyCardStore(db *gorm.DB) *GatewayReadonlyCardStore { + return &GatewayReadonlyCardStore{db: db} +} + +// IsReadonly 判断网关请求卡号(ICCID 或设备 IMEI)是否为只读资产;按唯一索引查询,不缓存。 +func (s *GatewayReadonlyCardStore) IsReadonly(ctx context.Context, identifier string) (bool, error) { + var count int64 + err := s.db.WithContext(ctx).Model(&model.GatewayReadonlyCard{}). + Where("identifier = ?", strings.ToUpper(strings.TrimSpace(identifier))). + Limit(1).Count(&count).Error + return count > 0, err +} + +// BatchCreate 批量写入只读资产,卡号统一大写,已存在的跳过。 +func (s *GatewayReadonlyCardStore) BatchCreate(ctx context.Context, rows []*model.GatewayReadonlyCard) error { + for _, row := range rows { + row.Identifier = strings.ToUpper(strings.TrimSpace(row.Identifier)) + } + return s.db.WithContext(ctx).Clauses(clause.OnConflict{Columns: []clause.Column{{Name: "identifier"}}, DoNothing: true}). + CreateInBatches(rows, 1000).Error +} diff --git a/migrations/000269_add_gateway_readonly_card.down.sql b/migrations/000269_add_gateway_readonly_card.down.sql new file mode 100644 index 00000000..cc19a54d --- /dev/null +++ b/migrations/000269_add_gateway_readonly_card.down.sql @@ -0,0 +1,2 @@ +-- 回滚网关只读资产表。执行前必须先关闭 JUNHONG_GATEWAY_READONLY_ENABLED 并处理导入资产(见清理顺序文档)。 +DROP TABLE IF EXISTS tb_gateway_readonly_card; diff --git a/migrations/000269_add_gateway_readonly_card.up.sql b/migrations/000269_add_gateway_readonly_card.up.sql new file mode 100644 index 00000000..6a399a5e --- /dev/null +++ b/migrations/000269_add_gateway_readonly_card.up.sql @@ -0,0 +1,24 @@ +-- 网关只读资产表(add-gateway-readonly-prod-import)。 +-- 测试环境导入生产卡与设备做流量计量验证时使用:开关 JUNHONG_GATEWAY_READONLY_ENABLED=true 时, +-- 表中的卡(ICCID)与设备(IMEI)只允许查询类网关调用(查卡状态、查实名、查流量、查设备信息、查设备槽位、同步设备状态), +-- 停机、复机、限速、实名认证链接与设备写操作在网关出口本地拦截。生产不配置该开关,不查本表。 +-- 本迁移不写入数据;数据由测试环境导入脚本写入。 +-- 撤除顺序见 docs/测试环境生产卡只读模式清理顺序.md:先处理导入资产 → 关开关并部署 → 新迁移删表。 +-- 开关开启时删表会使所有非查询类调用失败(查表失败按拦截处理)。 +CREATE TABLE tb_gateway_readonly_card ( + id BIGSERIAL PRIMARY KEY, + identifier VARCHAR(64) NOT NULL, + identifier_type VARCHAR(16) NOT NULL, + source VARCHAR(64) NOT NULL DEFAULT '', + remark VARCHAR(255) NOT NULL DEFAULT '', + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + CONSTRAINT ck_gateway_readonly_card_type CHECK (identifier_type IN ('iccid', 'imei')) +); + +CREATE UNIQUE INDEX uk_gateway_readonly_card_identifier ON tb_gateway_readonly_card (identifier); + +COMMENT ON TABLE tb_gateway_readonly_card IS '网关只读资产:表中的卡与设备只允许查询类网关调用(测试环境导入生产数据验证用)'; +COMMENT ON COLUMN tb_gateway_readonly_card.identifier IS '网关请求卡号(大写):卡为 ICCID,设备为 IMEI'; +COMMENT ON COLUMN tb_gateway_readonly_card.identifier_type IS '卡号类型:iccid / imei'; +COMMENT ON COLUMN tb_gateway_readonly_card.source IS '来源,如 prod_import_20261029'; +COMMENT ON COLUMN tb_gateway_readonly_card.remark IS '备注'; diff --git a/openspec/changes/add-gateway-readonly-prod-import/.openspec.yaml b/openspec/changes/add-gateway-readonly-prod-import/.openspec.yaml new file mode 100644 index 00000000..5c99d6d7 --- /dev/null +++ b/openspec/changes/add-gateway-readonly-prod-import/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-10-29 diff --git a/openspec/changes/add-gateway-readonly-prod-import/design.md b/openspec/changes/add-gateway-readonly-prod-import/design.md new file mode 100644 index 00000000..ca206b05 --- /dev/null +++ b/openspec/changes/add-gateway-readonly-prod-import/design.md @@ -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 节已全部确认) diff --git a/openspec/changes/add-gateway-readonly-prod-import/proposal.md b/openspec/changes/add-gateway-readonly-prod-import/proposal.md new file mode 100644 index 00000000..5b7f242e --- /dev/null +++ b/openspec/changes/add-gateway-readonly-prod-import/proposal.md @@ -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`,生产只读导出。 +- 范围:测试环境。 diff --git a/openspec/changes/add-gateway-readonly-prod-import/specs/gateway-readonly-assets/spec.md b/openspec/changes/add-gateway-readonly-prod-import/specs/gateway-readonly-assets/spec.md new file mode 100644 index 00000000..2e16d4e5 --- /dev/null +++ b/openspec/changes/add-gateway-readonly-prod-import/specs/gateway-readonly-assets/spec.md @@ -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** 系统拦截该复机请求 diff --git a/openspec/changes/add-gateway-readonly-prod-import/tasks.md b/openspec/changes/add-gateway-readonly-prod-import/tasks.md new file mode 100644 index 00000000..9187b669 --- /dev/null +++ b/openspec/changes/add-gateway-readonly-prod-import/tasks.md @@ -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 本月读数给出导入前差距上限,与报表一并提交。 diff --git a/pkg/config/config.go b/pkg/config/config.go index 22e0cf9d..7ccd77f6 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -163,9 +163,9 @@ type GatewayConfig struct { AppID string `mapstructure:"app_id"` // 应用 ID AppSecret string `mapstructure:"app_secret"` // 应用密钥 Timeout int `mapstructure:"timeout"` // 超时时间(秒) - // ReadonlyICCIDs 只读卡名单(逗号分隔 ICCID):名单内的卡只放行查流量、卡状态、实名查询,写操作在本地拦截。 - // 只在测试环境导入生产卡做流量采集验证时配置,生产不配置。环境变量:JUNHONG_GATEWAY_READONLY_ICCIDS - ReadonlyICCIDs string `mapstructure:"readonly_iccids"` + // ReadonlyEnabled 只读模式:开启后网关只读资产表 tb_gateway_readonly_card 中的卡与设备只允许查询类调用, + // 其余调用在本地拦截。只在测试环境导入生产数据验证时开启,生产不配置。环境变量:JUNHONG_GATEWAY_READONLY_ENABLED + ReadonlyEnabled bool `mapstructure:"readonly_enabled"` } // WorkerConfig Worker 进程运行配置 diff --git a/pkg/config/loader.go b/pkg/config/loader.go index 324b6c82..77c3f71f 100644 --- a/pkg/config/loader.go +++ b/pkg/config/loader.go @@ -130,7 +130,7 @@ func bindEnvVariables(v *viper.Viper) { "gateway.app_id", "gateway.app_secret", "gateway.timeout", - "gateway.readonly_iccids", + "gateway.readonly_enabled", "polling.verbose_log", "polling.carrier.nsqd_address", "polling.carrier.nsqlookupd_addresses",