feat: 网关只读资产改为数据表,只读资产只允许查询类调用
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 9m36s
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:
@@ -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(确保使用最新配置)
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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}
|
||||
|
||||
150
docs/测试环境导入生产数据验证流量计量技术方案.md
Normal file
150
docs/测试环境导入生产数据验证流量计量技术方案.md
Normal file
@@ -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、虚拟号等按原值导入,不脱敏。
|
||||
47
docs/测试环境生产卡只读模式清理顺序.md
Normal file
47
docs/测试环境生产卡只读模式清理顺序.md
Normal file
@@ -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 + 备注),删表瞬间完成,无锁表风险。
|
||||
- 留痕:拦截记录在日志、停复机记录与审计中,与该表无关,删表不影响已有记录。
|
||||
@@ -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": <JSON>} 格式
|
||||
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})
|
||||
|
||||
@@ -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 {
|
||||
|
||||
24
internal/model/gateway_readonly_card.go
Normal file
24
internal/model/gateway_readonly_card.go
Normal file
@@ -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"
|
||||
}
|
||||
39
internal/store/postgres/gateway_readonly_card_store.go
Normal file
39
internal/store/postgres/gateway_readonly_card_store.go
Normal file
@@ -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
|
||||
}
|
||||
2
migrations/000269_add_gateway_readonly_card.down.sql
Normal file
2
migrations/000269_add_gateway_readonly_card.down.sql
Normal file
@@ -0,0 +1,2 @@
|
||||
-- 回滚网关只读资产表。执行前必须先关闭 JUNHONG_GATEWAY_READONLY_ENABLED 并处理导入资产(见清理顺序文档)。
|
||||
DROP TABLE IF EXISTS tb_gateway_readonly_card;
|
||||
24
migrations/000269_add_gateway_readonly_card.up.sql
Normal file
24
migrations/000269_add_gateway_readonly_card.up.sql
Normal file
@@ -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 '备注';
|
||||
@@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-10-29
|
||||
62
openspec/changes/add-gateway-readonly-prod-import/design.md
Normal file
62
openspec/changes/add-gateway-readonly-prod-import/design.md
Normal 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 节已全部确认)
|
||||
@@ -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`,生产只读导出。
|
||||
- 范围:测试环境。
|
||||
@@ -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** 系统拦截该复机请求
|
||||
38
openspec/changes/add-gateway-readonly-prod-import/tasks.md
Normal file
38
openspec/changes/add-gateway-readonly-prod-import/tasks.md
Normal 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 本月读数给出导入前差距上限,与报表一并提交。
|
||||
@@ -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 进程运行配置
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user