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

@@ -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(确保使用最新配置)

View File

@@ -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),

View File

@@ -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),

View File

@@ -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}

View 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、虚拟号等按原值导入,不脱敏。

View 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 + 备注),删表瞬间完成,无锁表风险。
- 留痕:拦截记录在日志、停复机记录与审计中,与该表无关,删表不影响已有记录。

View File

@@ -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})

View File

@@ -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 {

View 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"
}

View 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
}

View File

@@ -0,0 +1,2 @@
-- 回滚网关只读资产表。执行前必须先关闭 JUNHONG_GATEWAY_READONLY_ENABLED 并处理导入资产(见清理顺序文档)。
DROP TABLE IF EXISTS tb_gateway_readonly_card;

View 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 '备注';

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 本月读数给出导入前差距上限,与报表一并提交。

View File

@@ -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 进程运行配置

View File

@@ -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",