From 6266cf9d86c34f2713b1c6fd4ed272c7e6de4d63 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=82=B9=E8=BF=9B?= Date: Thu, 29 Oct 2026 15:15:00 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=E7=BD=91=E5=85=B3=E5=8F=AA=E8=AF=BB?= =?UTF-8?q?=E5=8D=A1=E5=90=8D=E5=8D=95=EF=BC=8C=E6=B5=8B=E8=AF=95=E7=8E=AF?= =?UTF-8?q?=E5=A2=83=E5=AF=BC=E5=85=A5=E7=9A=84=E7=94=9F=E4=BA=A7=E5=8D=A1?= =?UTF-8?q?=E5=8F=AA=E5=85=81=E8=AE=B8=E6=9F=A5=E8=AF=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 JUNHONG_GATEWAY_READONLY_ICCIDS:名单内的卡只放行查流量、卡状态、实名查询, 停机、复机、限速、实名认证等写操作在网关出口本地拦截、不发请求,返回明确失败 - 未配置时不生效;测试环境部署脚本配置卡 89861590172420410612 用于流量采集验证 Co-Authored-By: Claude Opus 5.5 --- .gitea/workflows/deploy.yaml | 2 + cmd/api/main.go | 2 +- cmd/worker/main.go | 2 +- docker-compose.prod.yml | 2 + internal/gateway/client.go | 8 ++++ internal/gateway/readonly_guard.go | 73 ++++++++++++++++++++++++++++++ pkg/config/config.go | 3 ++ pkg/config/loader.go | 1 + 8 files changed, 91 insertions(+), 2 deletions(-) create mode 100644 internal/gateway/readonly_guard.go diff --git a/.gitea/workflows/deploy.yaml b/.gitea/workflows/deploy.yaml index 8beda915..36b668ce 100644 --- a/.gitea/workflows/deploy.yaml +++ b/.gitea/workflows/deploy.yaml @@ -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(确保使用最新配置) diff --git a/cmd/api/main.go b/cmd/api/main.go index cab54dcb..50309488 100644 --- a/cmd/api/main.go +++ b/cmd/api/main.go @@ -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), diff --git a/cmd/worker/main.go b/cmd/worker/main.go index f08f9e8c..cb7274a2 100644 --- a/cmd/worker/main.go +++ b/cmd/worker/main.go @@ -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), diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index 358c1b20..f7cdeabf 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -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} diff --git a/internal/gateway/client.go b/internal/gateway/client.go index a7a8324b..e1feabdf 100644 --- a/internal/gateway/client.go +++ b/internal/gateway/client.go @@ -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": } 格式 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, "序列化业务数据失败") diff --git a/internal/gateway/readonly_guard.go b/internal/gateway/readonly_guard.go new file mode 100644 index 00000000..c937b874 --- /dev/null +++ b/internal/gateway/readonly_guard.go @@ -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)) +} diff --git a/pkg/config/config.go b/pkg/config/config.go index ae590684..22e0cf9d 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -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 进程运行配置 diff --git a/pkg/config/loader.go b/pkg/config/loader.go index 5351f52f..324b6c82 100644 --- a/pkg/config/loader.go +++ b/pkg/config/loader.go @@ -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",