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

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