feat: 网关只读资产改为数据表,只读资产只允许查询类调用
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 9m36s
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:
@@ -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})
|
||||
|
||||
@@ -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 {
|
||||
|
||||
24
internal/model/gateway_readonly_card.go
Normal file
24
internal/model/gateway_readonly_card.go
Normal 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"
|
||||
}
|
||||
39
internal/store/postgres/gateway_readonly_card_store.go
Normal file
39
internal/store/postgres/gateway_readonly_card_store.go
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user