feat: 网关只读卡名单,测试环境导入的生产卡只允许查询
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 9m30s
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:
@@ -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(确保使用最新配置)
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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, "序列化业务数据失败")
|
||||
|
||||
73
internal/gateway/readonly_guard.go
Normal file
73
internal/gateway/readonly_guard.go
Normal 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))
|
||||
}
|
||||
@@ -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 进程运行配置
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user