feat: 网关只读卡名单,测试环境导入的生产卡只允许查询
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 9m30s

- 新增 JUNHONG_GATEWAY_READONLY_ICCIDS:名单内的卡只放行查流量、卡状态、实名查询,
  停机、复机、限速、实名认证等写操作在网关出口本地拦截、不发请求,返回明确失败
- 未配置时不生效;测试环境部署脚本配置卡 89861590172420410612 用于流量采集验证

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
邹进
2026-10-29 15:15:00 +09:00
parent 9c3c258f16
commit 6266cf9d86
8 changed files with 91 additions and 2 deletions

View File

@@ -60,6 +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\n'
} > ${{ env.DEPLOY_DIR }}/.env
# 强制更新 docker-compose.prod.yml(确保使用最新配置)

View File

@@ -459,7 +459,7 @@ 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)
).WithTimeout(time.Duration(cfg.Gateway.Timeout) * time.Second).WithReadonlyCards(cfg.Gateway.ReadonlyICCIDs)
appLogger.Info("Gateway 客户端初始化成功",
zap.String("base_url", cfg.Gateway.BaseURL),

View File

@@ -1456,7 +1456,7 @@ 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)
).WithTimeout(time.Duration(cfg.Gateway.Timeout) * time.Second).WithReadonlyCards(cfg.Gateway.ReadonlyICCIDs)
appLogger.Info("Gateway 客户端初始化成功",
zap.String("base_url", cfg.Gateway.BaseURL),

View File

@@ -72,6 +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:-}
# 七月迭代:企业微信 Adapter 与旧审批入口切换
- JUNHONG_WECOM_BASE_URL=https://qyapi.weixin.qq.com
- JUNHONG_WECOM_TIMEOUT=10s
@@ -140,6 +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_POLLING_VERBOSE_LOG=true
# 七月迭代:单 Worker 测试环境同时承担调度和消费
- JUNHONG_WORKER_ROLE=${JUNHONG_WORKER_ROLE:-all}

View File

@@ -38,6 +38,8 @@ type Client struct {
logger *zap.Logger
maxRetries int
guard CallGuard // 卡网关调用守卫(限流与统计),为 nil 时不生效
// readonlyCards 是只读卡名单(ICCID 大写),名单内的卡只放行查询接口,为空时不生效。
readonlyCards map[string]struct{}
}
type attemptObserverKey struct{}
@@ -101,6 +103,9 @@ 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 {
return nil, err
}
// 将参数包装为 {"params": ...} 格式后序列化
wrapper := requestWrapper{Params: params}
dataBytes, err := sonic.Marshal(wrapper)
@@ -121,6 +126,9 @@ 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 {
return nil, err
}
dataBytes, err := sonic.Marshal(requestWrapper{Params: params})
if err != nil {
return nil, errors.Wrap(errors.CodeInternalError, err, "序列化业务数据失败")

View File

@@ -0,0 +1,73 @@
package gateway
import (
"encoding/json"
"strings"
"go.uber.org/zap"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
)
// readonlyAllowedPaths 是只读卡允许调用的网关接口:只查流量、卡状态与实名状态。
var readonlyAllowedPaths = map[string]struct{}{
"/flow-card/flow": {},
"/flow-card/status": {},
"/flow-card/realName": {},
}
// 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)))
}
return c
}
// rejectReadonlyWrite 判断本次请求是否为只读卡的写操作;是则记日志并返回明确失败(不是结果未知),不发请求。
func (c *Client) rejectReadonlyWrite(path string, params interface{}) error {
if len(c.readonlyCards) == 0 {
return nil
}
if _, ok := readonlyAllowedPaths[path]; ok {
return nil
}
cardNo := requestCardNo(params)
if _, blocked := c.readonlyCards[cardNo]; !blocked {
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"))
}
return newGatewayError(constants.GatewayErrorUpstream, errors.New(errors.CodeGatewayError, "测试环境只读卡,已拦截写操作:"+path))
}
// requestCardNo 取请求里的卡号(cardNo 或 iccid 字段),取不到时返回空串。
func requestCardNo(params interface{}) string {
raw, err := json.Marshal(params)
if err != nil {
return ""
}
var fields struct {
CardNo string `json:"cardNo"`
ICCID string `json:"iccid"`
}
if err := json.Unmarshal(raw, &fields); err != nil {
return ""
}
if fields.CardNo != "" {
return strings.ToUpper(strings.TrimSpace(fields.CardNo))
}
return strings.ToUpper(strings.TrimSpace(fields.ICCID))
}

View File

@@ -163,6 +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"`
}
// WorkerConfig Worker 进程运行配置

View File

@@ -130,6 +130,7 @@ func bindEnvVariables(v *viper.Viper) {
"gateway.app_id",
"gateway.app_secret",
"gateway.timeout",
"gateway.readonly_iccids",
"polling.verbose_log",
"polling.carrier.nsqd_address",
"polling.carrier.nsqlookupd_addresses",