All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 9m33s
- 新增网关限流:按本系统运营商账户(卡 carrier_id)→ 限流组 → 操作限制每秒请求数,每次 HTTP 尝试计量,默认限流、仅后台人工点击/通道阈值/自动续费/手动刷新豁免;支持只统计与拦截两种模式,默认只统计 - Asynq 遇限流不计失败、固定短延迟重试;轮询任务与停复机业务重试遇限流短延迟重排 - 停机、复机不再使用网关内部重试,只发一次请求 - 网关返回结构化错误;新增上游错误分类规则表与测试接口 - 新增按月分区的停复机记录表:系统自动停复机只写记录,不再写统一审计与集成日志;普通轮询查询不再写集成日志 - 新增限流组、错误分类、停复机记录、按账户调用统计与枚举后台接口 - 迁移 000243~000246;同步 OpenSpec 提案(含后续 6 个 change)与技术方案、工程约束
143 lines
5.0 KiB
Go
143 lines
5.0 KiB
Go
package gateway
|
||
|
||
import (
|
||
"context"
|
||
stderrors "errors"
|
||
"time"
|
||
"unicode/utf8"
|
||
|
||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||
"github.com/break/junhong_cmp_fiber/pkg/errors"
|
||
)
|
||
|
||
// GatewayError 是网关调用失败的结构化错误。
|
||
// 它包裹原有的 *errors.AppError:Error() 文本与错误码保持不变,调用方仍可 errors.As 取 AppError;
|
||
// 需要类别、上游业务码、上游信息与耗时的调用方用 errors.As 取 *GatewayError。
|
||
type GatewayError struct {
|
||
Category string // 错误类别,见 constants.GatewayError*
|
||
ProviderCode int // 网关业务码(仅上游业务错误时非 0)
|
||
ProviderMessage string // 网关返回信息(已截断,仅上游业务错误时非空)
|
||
HTTPStatus int // HTTP 状态码(仅 HTTP 异常时非 0)
|
||
Duration time.Duration // 本次 HTTP 尝试耗时
|
||
app *errors.AppError
|
||
}
|
||
|
||
// Error 返回与原 AppError 一致的文本。
|
||
func (e *GatewayError) Error() string {
|
||
if e == nil || e.app == nil {
|
||
return "Gateway 调用失败"
|
||
}
|
||
return e.app.Error()
|
||
}
|
||
|
||
// Unwrap 返回原 AppError,保持错误码判断兼容。
|
||
func (e *GatewayError) Unwrap() error {
|
||
if e == nil || e.app == nil {
|
||
return nil
|
||
}
|
||
return e.app
|
||
}
|
||
|
||
func newGatewayError(category string, app *errors.AppError) *GatewayError {
|
||
return &GatewayError{Category: category, app: app}
|
||
}
|
||
|
||
// AsGatewayError 从错误链中取出结构化网关错误。
|
||
func AsGatewayError(err error) (*GatewayError, bool) {
|
||
var gwErr *GatewayError
|
||
if stderrors.As(err, &gwErr) && gwErr != nil {
|
||
return gwErr, true
|
||
}
|
||
return nil, false
|
||
}
|
||
|
||
// IsRateLimited 判断错误是否为本系统运营商账户限流拒绝(未发出请求)。
|
||
func IsRateLimited(err error) bool {
|
||
gwErr, ok := AsGatewayError(err)
|
||
return ok && gwErr.Category == constants.GatewayErrorRateLimited
|
||
}
|
||
|
||
// NewRateLimitedError 创建限流拒绝错误;由限流守卫在拿不到名额时返回。
|
||
func NewRateLimitedError() error {
|
||
return newGatewayError(constants.GatewayErrorRateLimited,
|
||
errors.New(errors.CodeTooManyRequests, "运营商账户网关调用已达限流上限,请稍后重试"))
|
||
}
|
||
|
||
// truncateProviderMessage 按字符截断上游信息,避免超长内容落库或展示。
|
||
func truncateProviderMessage(msg string) string {
|
||
if utf8.RuneCountInString(msg) <= constants.GatewayProviderMessageMaxLen {
|
||
return msg
|
||
}
|
||
runes := []rune(msg)
|
||
return string(runes[:constants.GatewayProviderMessageMaxLen])
|
||
}
|
||
|
||
// CallInfo 描述一次卡网关 HTTP 尝试,供调用守卫限流与统计。
|
||
type CallInfo struct {
|
||
Operation string // 卡网关操作,见 constants.GatewayOp*
|
||
CardNo string // 卡号(ICCID)
|
||
CarrierID uint // 调用方已知的运营商账户 ID(本系统 tb_carrier.id),0 表示由守卫按卡号解析
|
||
ExemptSource string // 豁免限流来源,空表示受限流约束
|
||
Attempt int // 本次方法调用内的尝试序号,从 1 开始
|
||
}
|
||
|
||
// CallGuard 是卡网关调用守卫:每次 HTTP 尝试前取限流名额,尝试结束后记录统计。
|
||
// BeforeAttempt 返回错误时本次方法调用立即结束,不再发起后续尝试。
|
||
type CallGuard interface {
|
||
BeforeAttempt(ctx context.Context, call CallInfo) error
|
||
AfterAttempt(ctx context.Context, call CallInfo, callErr error, duration time.Duration)
|
||
}
|
||
|
||
type cardCallKey struct{}
|
||
type carrierKey struct{}
|
||
type exemptKey struct{}
|
||
|
||
type cardCall struct {
|
||
operation string
|
||
cardNo string
|
||
}
|
||
|
||
// WithCarrier 声明本次调用目标卡所属的运营商账户(本系统 tb_carrier.id),避免守卫按卡号再查一次。
|
||
func WithCarrier(ctx context.Context, carrierID uint) context.Context {
|
||
if carrierID == 0 {
|
||
return ctx
|
||
}
|
||
return context.WithValue(ctx, carrierKey{}, carrierID)
|
||
}
|
||
|
||
// WithRateLimitExempt 声明本次调用豁免运营商账户限流,只允许后台人工点击、通道阈值、自动续费、手动刷新使用。
|
||
func WithRateLimitExempt(ctx context.Context, source string) context.Context {
|
||
if source == "" {
|
||
return ctx
|
||
}
|
||
return context.WithValue(ctx, exemptKey{}, source)
|
||
}
|
||
|
||
// RateLimitExemptSource 返回 ctx 中声明的豁免来源,未声明返回空串。
|
||
func RateLimitExemptSource(ctx context.Context) string {
|
||
source, _ := ctx.Value(exemptKey{}).(string)
|
||
return source
|
||
}
|
||
|
||
func withCardCall(ctx context.Context, operation, cardNo string) context.Context {
|
||
return context.WithValue(ctx, cardCallKey{}, cardCall{operation: operation, cardNo: cardNo})
|
||
}
|
||
|
||
// cardCallInfo 从 ctx 取出本次卡网关调用信息;非卡操作返回 false。
|
||
func cardCallInfo(ctx context.Context) (CallInfo, bool) {
|
||
call, ok := ctx.Value(cardCallKey{}).(cardCall)
|
||
if !ok {
|
||
return CallInfo{}, false
|
||
}
|
||
carrierID, _ := ctx.Value(carrierKey{}).(uint)
|
||
return CallInfo{
|
||
Operation: call.operation, CardNo: call.cardNo, CarrierID: carrierID,
|
||
ExemptSource: RateLimitExemptSource(ctx),
|
||
}, true
|
||
}
|
||
|
||
// SetCallGuard 设置卡网关调用守卫;为 nil 时不限流、不统计。
|
||
func (c *Client) SetCallGuard(guard CallGuard) {
|
||
c.guard = guard
|
||
}
|