chore: 并入 iotlcm-worker-svr 历史到 worker-svr/(原提交 96f0f4e)
This commit is contained in:
@@ -0,0 +1,154 @@
|
||||
package upstream
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/gogf/gf/v2/errors/gerror"
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
)
|
||||
|
||||
// apnRequiredSchema 是异步APN提交依赖的表与关键列,对应 20261015 的四份迁移脚本。
|
||||
// 缺表或缺列意味着迁移没有执行完,此时提交必然写不进去,应在发请求前就拒绝。
|
||||
var apnRequiredSchema = map[string][]string{
|
||||
"t_apn_operation": {"operation_id", "chan_key", "request_hash", "response_json", "revision"},
|
||||
"data_operation_parent": {"batch_no", "ready", "finalize_token", "finalize_until"},
|
||||
"data_operation_sub_batch": {"operation_id", "batch_no", "chan_key", "request_data", "status", "poll_count", "lease_token"},
|
||||
"data_operation_sub_detail": {"detail_id", "operation_id", "batch_no", "iccid"},
|
||||
"data_operation_card_claim": {"tenant_id", "iccid", "batch_id", "active_batch_id"},
|
||||
}
|
||||
|
||||
// apnReadinessTTL 是依赖检查结果的缓存时间:迁移完成后最迟一分钟内自动放行,无需重启。
|
||||
const apnReadinessTTL = time.Minute
|
||||
|
||||
// apnReadinessVerifyTimeout 是单次结构校验的独立超时,与调用方上下文解耦。
|
||||
const apnReadinessVerifyTimeout = 5 * time.Second
|
||||
|
||||
type apnReadinessCache struct {
|
||||
ttl time.Duration
|
||||
mu sync.Mutex
|
||||
checkedAt time.Time
|
||||
err error
|
||||
// verify 默认指向 verifyAPNSchema;独立出来是为了让缓存行为可测。
|
||||
verify func(context.Context) error
|
||||
}
|
||||
|
||||
var defaultAPNReadiness = &apnReadinessCache{ttl: apnReadinessTTL, verify: verifyAPNSchema}
|
||||
|
||||
// check 返回异步APN提交的依赖状态,结果按 TTL 缓存,避免每次提交都查库。
|
||||
func (c *apnReadinessCache) check(ctx context.Context) error {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
if !c.checkedAt.IsZero() && time.Since(c.checkedAt) < c.ttl {
|
||||
return c.err
|
||||
}
|
||||
verify := c.verify
|
||||
if verify == nil {
|
||||
verify = verifyAPNSchema
|
||||
}
|
||||
// 校验使用独立超时,不受调用方取消影响;否则一次取消就会把失败结果缓存给全进程。
|
||||
verifyCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), apnReadinessVerifyTimeout)
|
||||
defer cancel()
|
||||
c.err = verify(verifyCtx)
|
||||
if c.err != nil && (errors.Is(c.err, context.Canceled) || errors.Is(c.err, context.DeadlineExceeded)) {
|
||||
// 只是本次校验没跑完,不代表依赖缺失,不写入缓存。
|
||||
c.checkedAt = time.Time{}
|
||||
return c.err
|
||||
}
|
||||
c.checkedAt = time.Now()
|
||||
return c.err
|
||||
}
|
||||
|
||||
// verifyAPNSchema 直接查询库里的实际表结构,而不是依赖人工开开关确认「迁移已执行」。
|
||||
func verifyAPNSchema(ctx context.Context) (err error) {
|
||||
if !databaseConfigured(ctx) {
|
||||
// 未配置数据库连接(测试桩、纯传输层用例)时跳过;生产 worker 不存在该情况。
|
||||
return nil
|
||||
}
|
||||
// g.DB() 在缺配置时会 panic,这里统一转成可读错误,避免拖垮提交链路。
|
||||
defer func() {
|
||||
if recovered := recover(); recovered != nil {
|
||||
err = gerror.Newf("无法获取数据库连接,不能确认异步APN依赖是否就绪:%v", recovered)
|
||||
}
|
||||
}()
|
||||
db := g.DB()
|
||||
if db == nil {
|
||||
return gerror.New("无法获取数据库连接,不能确认异步APN依赖是否就绪")
|
||||
}
|
||||
config := db.GetConfig()
|
||||
if strings.EqualFold(config.Type, "sqlite") {
|
||||
// 本地/单元测试使用 SQLite,没有 information_schema,跳过结构检查。
|
||||
return nil
|
||||
}
|
||||
names := make([]string, 0, len(apnRequiredSchema))
|
||||
for name := range apnRequiredSchema {
|
||||
names = append(names, name)
|
||||
}
|
||||
sort.Strings(names)
|
||||
placeholders := make([]string, 0, len(names))
|
||||
args := make([]interface{}, 0, len(names))
|
||||
for _, name := range names {
|
||||
placeholders = append(placeholders, "?")
|
||||
args = append(args, name)
|
||||
}
|
||||
records, err := db.GetAll(ctx, fmt.Sprintf(
|
||||
"SELECT TABLE_NAME AS table_name, COLUMN_NAME AS column_name FROM information_schema.columns WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME IN (%s)",
|
||||
strings.Join(placeholders, ",")), args...)
|
||||
if err != nil {
|
||||
return gerror.Wrap(err, "查询异步APN依赖表结构失败")
|
||||
}
|
||||
existing := make(map[string]map[string]bool, len(records))
|
||||
for _, record := range records {
|
||||
table := record["table_name"].String()
|
||||
if existing[table] == nil {
|
||||
existing[table] = make(map[string]bool)
|
||||
}
|
||||
existing[table][strings.ToLower(record["column_name"].String())] = true
|
||||
}
|
||||
return validateAPNSchema(existing)
|
||||
}
|
||||
|
||||
// databaseConfigured 判断当前配置里是否声明了数据库,用于区分测试桩与真实运行环境。
|
||||
func databaseConfigured(ctx context.Context) bool {
|
||||
value := g.Cfg().MustGet(ctx, "database")
|
||||
return value != nil && !value.IsNil() && !value.IsEmpty()
|
||||
}
|
||||
|
||||
// validateAPNSchema 比对必需的表与列,返回可直接据此执行迁移的错误信息。
|
||||
func validateAPNSchema(existing map[string]map[string]bool) error {
|
||||
tables := make([]string, 0, len(apnRequiredSchema))
|
||||
for table := range apnRequiredSchema {
|
||||
tables = append(tables, table)
|
||||
}
|
||||
sort.Strings(tables)
|
||||
var missingTables []string
|
||||
var missingColumns []string
|
||||
for _, table := range tables {
|
||||
columns, ok := existing[table]
|
||||
if !ok {
|
||||
missingTables = append(missingTables, table)
|
||||
continue
|
||||
}
|
||||
required := append([]string(nil), apnRequiredSchema[table]...)
|
||||
sort.Strings(required)
|
||||
for _, column := range required {
|
||||
if !columns[column] {
|
||||
missingColumns = append(missingColumns, table+"."+column)
|
||||
}
|
||||
}
|
||||
}
|
||||
switch {
|
||||
case len(missingTables) > 0:
|
||||
return gerror.Newf("异步APN依赖未就绪:缺少数据表 %s,请先执行 20261015 迁移脚本",
|
||||
strings.Join(missingTables, "、"))
|
||||
case len(missingColumns) > 0:
|
||||
return gerror.Newf("异步APN依赖未就绪:缺少字段 %s,请先执行 20261015 迁移脚本",
|
||||
strings.Join(missingColumns, "、"))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
package upstream
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
"github.com/gogf/gf/v2/os/gcfg"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/model"
|
||||
)
|
||||
|
||||
func fullAPNSchema() map[string]map[string]bool {
|
||||
schema := make(map[string]map[string]bool, len(apnRequiredSchema))
|
||||
for table, columns := range apnRequiredSchema {
|
||||
schema[table] = make(map[string]bool, len(columns))
|
||||
for _, column := range columns {
|
||||
schema[table][column] = true
|
||||
}
|
||||
}
|
||||
return schema
|
||||
}
|
||||
|
||||
// TestValidateAPNSchemaRequiresAllTablesAndColumns 固化依赖判据:
|
||||
// 缺表、缺列都必须给出可据此执行迁移的明确原因,而不是笼统的「未就绪」。
|
||||
func TestValidateAPNSchemaRequiresAllTablesAndColumns(t *testing.T) {
|
||||
if err := validateAPNSchema(fullAPNSchema()); err != nil {
|
||||
t.Fatalf("完整结构不应报错:%v", err)
|
||||
}
|
||||
|
||||
missingTable := fullAPNSchema()
|
||||
delete(missingTable, "data_operation_card_claim")
|
||||
err := validateAPNSchema(missingTable)
|
||||
if err == nil || !strings.Contains(err.Error(), "data_operation_card_claim") || !strings.Contains(err.Error(), "缺少数据表") {
|
||||
t.Fatalf("缺表未被识别:%v", err)
|
||||
}
|
||||
|
||||
missingColumn := fullAPNSchema()
|
||||
delete(missingColumn["t_apn_operation"], "revision")
|
||||
err = validateAPNSchema(missingColumn)
|
||||
if err == nil || !strings.Contains(err.Error(), "t_apn_operation.revision") || !strings.Contains(err.Error(), "缺少字段") {
|
||||
t.Fatalf("缺列未被识别:%v", err)
|
||||
}
|
||||
|
||||
// 表全部缺失时优先报缺表,避免一次报几十个字段干扰排查。
|
||||
err = validateAPNSchema(map[string]map[string]bool{})
|
||||
if err == nil || !strings.Contains(err.Error(), "缺少数据表") {
|
||||
t.Fatalf("空库应优先报缺表:%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAsyncApnSubmitBlockedWhenDependencyNotReady 验证依赖未就绪时在发请求前就拒绝,
|
||||
// 并把错误标记为「未发送」,避免上游误判为已办理。
|
||||
func TestAsyncApnSubmitBlockedWhenDependencyNotReady(t *testing.T) {
|
||||
oldAdapter := g.Cfg().GetAdapter()
|
||||
defer g.Cfg().SetAdapter(oldAdapter)
|
||||
oldReady := apnDependencyReady
|
||||
defer func() { apnDependencyReady = oldReady }()
|
||||
apnDependencyReady = func(ctx context.Context) error {
|
||||
return errors.New("缺少数据表 data_operation_card_claim")
|
||||
}
|
||||
|
||||
probes, submits := 0, 0
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path == "/sim/query/sim-apn-capability" {
|
||||
probes++
|
||||
return
|
||||
}
|
||||
submits++
|
||||
}))
|
||||
defer server.Close()
|
||||
adapter, err := gcfg.NewAdapterContent(fmt.Sprintf(`{"upstream":{"proxy":{"address":%q,"timeout":"1s"}}}`, strings.TrimPrefix(server.URL, "http://")))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
g.Cfg().SetAdapter(adapter)
|
||||
|
||||
_, err = New().SubmitSimApnFunction(context.Background(), &model.SimCardApnChangeInput{
|
||||
SimKeys: []*model.SimKey{{ChanKey: "test", ICCID: "fake-card"}},
|
||||
ReqData: model.SimCardApnChangeInputItem{OperationID: "fake-operation"},
|
||||
})
|
||||
var notSent interface{ APNRequestNotSent() bool }
|
||||
if err == nil || !strings.Contains(err.Error(), "依赖未就绪") {
|
||||
t.Fatalf("依赖未就绪应拒绝提交:%v", err)
|
||||
}
|
||||
if !errors.As(err, ¬Sent) || !notSent.APNRequestNotSent() {
|
||||
t.Fatalf("依赖拒绝必须标记为未发送:%v", err)
|
||||
}
|
||||
if probes != 0 || submits != 0 {
|
||||
t.Fatalf("依赖未就绪时不应发出任何请求:probes=%d submits=%d", probes, submits)
|
||||
}
|
||||
}
|
||||
|
||||
// TestReadinessCacheDoesNotCacheCanceledVerification 覆盖审查发现的场景:
|
||||
// 调用方取消导致的校验失败不能进进程级缓存,否则会误挡后续所有提交。
|
||||
func TestReadinessCacheDoesNotCacheCanceledVerification(t *testing.T) {
|
||||
cache := &apnReadinessCache{ttl: time.Minute}
|
||||
calls := 0
|
||||
cache.verify = func(ctx context.Context) error {
|
||||
calls++
|
||||
if calls == 1 {
|
||||
return context.Canceled
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if err := cache.check(context.Background()); !errors.Is(err, context.Canceled) {
|
||||
t.Fatalf("首次应返回取消错误:%v", err)
|
||||
}
|
||||
if err := cache.check(context.Background()); err != nil {
|
||||
t.Fatalf("取消不应被缓存,第二次应重新校验并放行:%v", err)
|
||||
}
|
||||
if calls != 2 {
|
||||
t.Fatalf("取消后应重新校验,实际调用%d次", calls)
|
||||
}
|
||||
}
|
||||
|
||||
// TestReadinessCacheReusesResult 保证依赖检查不会每次提交都查库,同时迁移完成后能自动恢复。
|
||||
func TestReadinessCacheReusesResult(t *testing.T) {
|
||||
cache := &apnReadinessCache{ttl: time.Minute}
|
||||
calls := 0
|
||||
cache.verify = func(ctx context.Context) error {
|
||||
calls++
|
||||
return nil
|
||||
}
|
||||
for i := 0; i < 3; i++ {
|
||||
if err := cache.check(context.Background()); err != nil {
|
||||
t.Fatalf("第%d次检查报错:%v", i+1, err)
|
||||
}
|
||||
}
|
||||
if calls != 1 {
|
||||
t.Fatalf("缓存期内应只查一次库,实际%d次", calls)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,393 @@
|
||||
package upstream
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gogf/gf/v2/errors/gerror"
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
"github.com/gogf/gf/v2/net/gclient"
|
||||
|
||||
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/model"
|
||||
)
|
||||
|
||||
const inboundBasicInfoClientNameEnv = "UPSTREAM_INBOUND_BASIC_INFO_CLIENT_NAME"
|
||||
const upstreamProxyMaxTimeoutConfigKey = "upstream.proxy.maxTimeout"
|
||||
|
||||
// apnAsyncProtocolVersionConfigKey 是异步APN提交的兼容开关。
|
||||
// 默认值改为开启:常规发布不再需要人工逐环境开开关,兼容性由提交前的协议能力探测兜底。
|
||||
// 只有需要临时回退到旧协议时,才显式配为 0。
|
||||
const apnAsyncProtocolVersionConfigKey = "upstream.proxy.apnAsyncProtocolVersion"
|
||||
|
||||
// defaultApnAsyncProtocolVersion 默认开启异步APN提交。
|
||||
const defaultApnAsyncProtocolVersion = 1
|
||||
|
||||
// defaultUpstreamProxyMaxTimeout 是单次批量转发的兜底上限。
|
||||
// 必须大于 upstream-proxy 的 gateway.batchTimeout(默认 20 分钟),
|
||||
// 让超时由更靠近通道的一层先判定,避免 worker 提前放弃而通道仍在办理,造成结果未知。
|
||||
const defaultUpstreamProxyMaxTimeout = 25 * time.Minute
|
||||
|
||||
// defaultUpstreamProxyTimeout 是未配置单卡预算时的默认值。
|
||||
const defaultUpstreamProxyTimeout = 10 * time.Second
|
||||
|
||||
type Client struct {
|
||||
currentUsageClientName string
|
||||
currentUsageTimeout time.Duration
|
||||
inboundBasicInfoClientName string
|
||||
imeiClientName string
|
||||
imeiTimeout time.Duration
|
||||
}
|
||||
|
||||
type response[T any] struct {
|
||||
Code int `json:"code"`
|
||||
Message string `json:"message"`
|
||||
Data []model.SimBaseRes[T] `json:"data"`
|
||||
}
|
||||
|
||||
func New() *Client {
|
||||
return &Client{}
|
||||
}
|
||||
|
||||
func (c *Client) QuerySimBasicInfo(ctx context.Context, input *model.SimBasicInfoInput) ([]model.SimBaseRes[model.SimBasicInfoItem], error) {
|
||||
address, err := c.inboundBasicInfoAddress(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return requestAllWithAddress[model.SimBasicInfoItem](ctx, "/sim/query/sim-basic-info", input, len(input.SimKeys), address)
|
||||
}
|
||||
|
||||
// NewWithCurrentUsageClient keeps the full-sync current-usage call independent
|
||||
// from the configured upstream.proxy endpoint.
|
||||
func NewWithCurrentUsageClient(address string, timeout ...time.Duration) *Client {
|
||||
var currentUsageTimeout time.Duration
|
||||
if len(timeout) > 0 {
|
||||
currentUsageTimeout = timeout[0]
|
||||
}
|
||||
return &Client{currentUsageClientName: address, currentUsageTimeout: currentUsageTimeout}
|
||||
}
|
||||
|
||||
func NewWithInboundBasicInfoClient(address string) *Client {
|
||||
return &Client{inboundBasicInfoClientName: address}
|
||||
}
|
||||
|
||||
// NewWithImeiClient pins the IMEI full-sync path to an explicit chan endpoint
|
||||
// and timeout. An empty address falls back to the shared upstream.proxy
|
||||
// configuration, which is only intended for local runs.
|
||||
func NewWithImeiClient(address string, timeout ...time.Duration) *Client {
|
||||
var imeiTimeout time.Duration
|
||||
if len(timeout) > 0 {
|
||||
imTimeout := timeout[0]
|
||||
if imTimeout > 0 {
|
||||
imeiTimeout = imTimeout
|
||||
}
|
||||
}
|
||||
return &Client{imeiClientName: address, imeiTimeout: imeiTimeout}
|
||||
}
|
||||
|
||||
func (c *Client) inboundBasicInfoAddress(ctx context.Context) (string, error) {
|
||||
if strings.TrimSpace(c.inboundBasicInfoClientName) != "" {
|
||||
return strings.TrimSpace(c.inboundBasicInfoClientName), nil
|
||||
}
|
||||
if address := strings.TrimSpace(os.Getenv(inboundBasicInfoClientNameEnv)); address != "" {
|
||||
return address, nil
|
||||
}
|
||||
config, ok, err := optionalUpstreamConfig(ctx, "upstream.gateway")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if !ok || strings.TrimSpace(config.Address) == "" {
|
||||
return "", gerror.Newf("upstream gateway address is empty, configure upstream.gateway.address or %s", inboundBasicInfoClientNameEnv)
|
||||
}
|
||||
return strings.TrimSpace(config.Address), nil
|
||||
}
|
||||
|
||||
// QuerySimDataUsage calls Chan's current-month real-time usage endpoint. The
|
||||
// response keeps one item per requested card so the caller can classify
|
||||
// per-card errors without reissuing successful cards.
|
||||
func (c *Client) QuerySimDataUsage(ctx context.Context, input *model.SimDataUsageInput) ([]model.SimBaseRes[model.SimDataUsageItem], error) {
|
||||
return requestAllWithAddressTimeout[model.SimDataUsageItem](ctx, "/sim/query/sim-datausage", input, len(input.SimKeys), c.currentUsageClientName, c.currentUsageTimeout)
|
||||
}
|
||||
|
||||
// QuerySimCardStatus queries the carrier status for the supplied cards. The
|
||||
// status queue currently sends one card per request, while retaining the
|
||||
// response shape needed for future batch support.
|
||||
func (c *Client) QuerySimCardStatus(ctx context.Context, input *model.SimCardStatusInput) ([]model.SimBaseRes[model.SimCardStatusItem], error) {
|
||||
return requestAll[model.SimCardStatusItem](ctx, "/sim/query/sim-status", input, len(input.SimKeys))
|
||||
}
|
||||
|
||||
// QuerySimImei queries the terminal IMEI for the supplied cards. The IMEI queue
|
||||
// sends one card per request.
|
||||
//
|
||||
// It deliberately uses requestAll rather than request: a per-item carrier
|
||||
// failure (unsupported template, missing value) must reach the handler as an
|
||||
// item with a non-zero Code, not be silently dropped from the slice.
|
||||
func (c *Client) QuerySimImei(ctx context.Context, input *model.SimImeiInput) ([]model.SimBaseRes[model.SimImeiItem], error) {
|
||||
return requestAllWithAddressTimeout[model.SimImeiItem](ctx, "/sim/query/sim-imei", input, len(input.SimKeys), c.imeiClientName, c.imeiTimeout)
|
||||
}
|
||||
|
||||
func (c *Client) QuerySimDatausageDaily(ctx context.Context, input *model.SimDataUsageDailyInput) ([]model.SimBaseRes[model.SimDataUsageDailyOutputItem], error) {
|
||||
return requestAll[model.SimDataUsageDailyOutputItem](ctx, "/sim/query/sim-datausage-daily", input, len(input.SimKeys))
|
||||
}
|
||||
|
||||
func (c *Client) QuerySimDatausageMonthly(ctx context.Context, input *model.SimDataUsageMonthlyInput) ([]model.SimBaseRes[model.SimDataUsageMonthlyOutputItem], error) {
|
||||
return requestAll[model.SimDataUsageMonthlyOutputItem](ctx, "/sim/query/sim-datausage-monthly", input, len(input.SimKeys))
|
||||
}
|
||||
|
||||
// ChangeSimStatus returns one result for every upstream item, including failed items.
|
||||
// Lifecycle batch execution needs those failures to be persisted to the matching detail.
|
||||
func (c *Client) ChangeSimStatus(ctx context.Context, input *model.SimCardStatusChangeInput) ([]model.SimBaseRes[model.SimCardStatusChangeOutputItem], error) {
|
||||
return requestAll[model.SimCardStatusChangeOutputItem](ctx, "/sim/change/sim-status", input, len(input.SimKeys))
|
||||
}
|
||||
|
||||
// ChangeSimApnFunction returns one result for every upstream item, including failed items.
|
||||
// Data service batch execution needs those failures to be persisted to the matching detail.
|
||||
func (c *Client) ChangeSimApnFunction(ctx context.Context, input *model.SimCardApnChangeInput) ([]model.SimBaseRes[model.SimCardApnChangeOutputItem], error) {
|
||||
return requestAll[model.SimCardApnChangeOutputItem](ctx, "/sim/change/sim-apn", input, len(input.SimKeys))
|
||||
}
|
||||
|
||||
// apnRequestNotSentError 仅描述任何APN写请求之前的能力拒绝。
|
||||
// 提交开始后的通信错误绝不能使用此类型。
|
||||
type apnRequestNotSentError struct{ cause error }
|
||||
|
||||
func (e *apnRequestNotSentError) Error() string { return e.cause.Error() }
|
||||
func (e *apnRequestNotSentError) Unwrap() error { return e.cause }
|
||||
func (e *apnRequestNotSentError) APNRequestNotSent() bool { return true }
|
||||
|
||||
func (c *Client) CheckSimApnAsyncCapability(ctx context.Context, input *model.SimCardApnChangeInput) (err error) {
|
||||
defer func() {
|
||||
if err != nil {
|
||||
err = &apnRequestNotSentError{cause: err}
|
||||
}
|
||||
}()
|
||||
if !apnAsyncSubmitEnabled(ctx) {
|
||||
return gerror.New("异步APN提交已被显式关闭(upstream.proxy.apnAsyncProtocolVersion=0);清除该配置或置为1后重试")
|
||||
}
|
||||
if err := apnDependencyReady(ctx); err != nil {
|
||||
return gerror.Wrap(err, "异步APN依赖未就绪,未发送办理请求")
|
||||
}
|
||||
if input == nil || strings.TrimSpace(input.ReqData.OperationID) == "" {
|
||||
return gerror.New("异步APN提交能力探测缺少operationId")
|
||||
}
|
||||
return c.checkSimApnProtocol(ctx, input)
|
||||
}
|
||||
|
||||
// apnDependencyReady 检查本地真实依赖(迁移表结构);测试可替换以便固定结论。
|
||||
var apnDependencyReady = func(ctx context.Context) error {
|
||||
return defaultAPNReadiness.check(ctx)
|
||||
}
|
||||
|
||||
// apnAsyncSubmitEnabled 判断应急总闸是否被显式关闭。
|
||||
// 默认放行:是否可提交由程序自行检查真实依赖决定——
|
||||
// - 本地依赖:apn_readiness 核对迁移表结构是否就绪;
|
||||
// - 远端兼容:每次提交前的协议能力探测,旧版 chan/proxy 会明确拒绝而不是按同步语义执行。
|
||||
// 因此常规发布不再需要人工逐环境开开关;仅回退到旧协议时把配置显式设为 0。
|
||||
func apnAsyncSubmitEnabled(ctx context.Context) bool {
|
||||
return g.Cfg().MustGet(ctx, apnAsyncProtocolVersionConfigKey, defaultApnAsyncProtocolVersion).Int() == 1
|
||||
}
|
||||
|
||||
// 仅验证远端协议:已有受理映射的只读补查不受新增提交门禁控制。
|
||||
// 旧版代理/通道缺少能力接口时必须拒绝,不能把查询当作普通办理转发。
|
||||
func (c *Client) checkSimApnProtocol(ctx context.Context, input *model.SimCardApnChangeInput) error {
|
||||
if len(input.SimKeys) == 0 || len(input.SimKeys) > 100 || (strings.TrimSpace(input.ReqData.OperationID) == "" && strings.TrimSpace(input.ReqData.QueryJobID) == "") {
|
||||
return gerror.New("异步APN能力探测需要operationId和1至100张卡快照")
|
||||
}
|
||||
type capability struct {
|
||||
ProtocolVersion int `json:"protocolVersion"`
|
||||
}
|
||||
probeCtx, cancel := context.WithTimeout(ctx, 10*time.Second)
|
||||
defer cancel()
|
||||
items, err := requestAll[capability](probeCtx, "/sim/query/sim-apn-capability", input, len(input.SimKeys))
|
||||
if err != nil {
|
||||
return gerror.Wrap(err, "异步APN协议能力探测失败,未发送办理或恢复请求")
|
||||
}
|
||||
if len(items) != len(input.SimKeys) {
|
||||
return gerror.New("异步APN协议能力响应卡数不匹配,禁止发送办理请求")
|
||||
}
|
||||
seen := make(map[model.SimKey]bool)
|
||||
for _, item := range items {
|
||||
if item.SimKey == nil || item.Code != 0 || item.ResData.ProtocolVersion != 1 || seen[*item.SimKey] {
|
||||
return gerror.New("通道未就绪或不支持异步APN协议,禁止发送办理请求")
|
||||
}
|
||||
seen[*item.SimKey] = true
|
||||
}
|
||||
for _, key := range input.SimKeys {
|
||||
if key == nil || !seen[*key] {
|
||||
return gerror.New("异步APN协议能力响应卡关联不匹配")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// SubmitSimApnFunction 仅提交异步批量任务;受理状态不能按办理成功回写。
|
||||
func (c *Client) SubmitSimApnFunction(ctx context.Context, input *model.SimCardApnChangeInput) ([]model.SimBaseRes[model.SimCardApnChangeOutputItem], error) {
|
||||
if err := c.CheckSimApnAsyncCapability(ctx, input); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
request := *input
|
||||
request.ReqData.SubmitOnly = true
|
||||
request.ReqData.QueryJobID = ""
|
||||
request.ReqData.RecoverOnly = false
|
||||
return c.ChangeSimApnFunction(ctx, &request)
|
||||
}
|
||||
|
||||
// QuerySimApnFunctionResult 根据已持久化的jobId只查一次,不重新提交。
|
||||
func (c *Client) QuerySimApnFunctionResult(ctx context.Context, input *model.SimCardApnChangeInput) ([]model.SimBaseRes[model.SimCardApnChangeOutputItem], error) {
|
||||
if strings.TrimSpace(input.ReqData.QueryJobID) == "" && strings.TrimSpace(input.ReqData.OperationID) == "" {
|
||||
return nil, gerror.New("异步APN结果查询缺少jobId和operationId")
|
||||
}
|
||||
request := *input
|
||||
request.ReqData.QueryJobID = strings.TrimSpace(request.ReqData.QueryJobID)
|
||||
request.ReqData.SubmitOnly = false
|
||||
request.ReqData.RecoverOnly = request.ReqData.QueryJobID == ""
|
||||
if err := c.checkSimApnProtocol(ctx, &request); err != nil {
|
||||
return nil, gerror.Wrap(err, "异步APN只读查询协议未就绪,未发送查询请求")
|
||||
}
|
||||
return c.ChangeSimApnFunction(ctx, &request)
|
||||
}
|
||||
|
||||
func request[T any](ctx context.Context, path string, input any, itemCount int) ([]model.SimBaseRes[T], error) {
|
||||
items, err := requestAll[T](ctx, path, input, itemCount)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
successItems := make([]model.SimBaseRes[T], 0, len(items))
|
||||
for _, item := range items {
|
||||
if item.Code == 0 {
|
||||
successItems = append(successItems, item)
|
||||
}
|
||||
}
|
||||
return successItems, nil
|
||||
}
|
||||
|
||||
func requestAll[T any](ctx context.Context, path string, input any, itemCount int) ([]model.SimBaseRes[T], error) {
|
||||
return requestAllWithAddress[T](ctx, path, input, itemCount, "")
|
||||
}
|
||||
|
||||
func requestAllWithAddress[T any](ctx context.Context, path string, input any, itemCount int, address string) ([]model.SimBaseRes[T], error) {
|
||||
return requestAllWithAddressTimeout[T](ctx, path, input, itemCount, address, 0)
|
||||
}
|
||||
|
||||
func requestAllWithAddressTimeout[T any](ctx context.Context, path string, input any, itemCount int, address string, timeoutOverride time.Duration) ([]model.SimBaseRes[T], error) {
|
||||
config, err := upstreamConfig(ctx, "upstream.proxy")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if address == "" {
|
||||
address = config.Address
|
||||
}
|
||||
if address == "" {
|
||||
return nil, gerror.New("upstream proxy address is empty")
|
||||
}
|
||||
if itemCount < 1 {
|
||||
itemCount = 1
|
||||
}
|
||||
|
||||
client := g.Client()
|
||||
if path == "/sim/change/sim-apn" || path == "/sim/query/sim-apn-capability" {
|
||||
client.SetContentType("application/json")
|
||||
}
|
||||
timeout := resolveUpstreamTimeout(path, itemCount, config.Timeout,
|
||||
timeoutOverride,
|
||||
configDuration(ctx, upstreamProxyMaxTimeoutConfigKey, defaultUpstreamProxyMaxTimeout),
|
||||
configDuration(ctx, "upstream.proxy.dataServiceTimeout", 240*time.Second))
|
||||
setClientTimeout(client, timeout)
|
||||
clientResponse, err := client.Post(ctx, fmt.Sprintf("http://%s%s", address, path), input)
|
||||
if err != nil {
|
||||
return nil, gerror.Wrap(err, "request upstream failed")
|
||||
}
|
||||
defer clientResponse.Close()
|
||||
|
||||
return decodeResponse[T](clientResponse.ReadAll())
|
||||
}
|
||||
|
||||
func optionalUpstreamConfig(ctx context.Context, key string) (model.UpstreamChanConfig, bool, error) {
|
||||
configValue, err := g.Cfg().Get(ctx, key)
|
||||
if err != nil {
|
||||
return model.UpstreamChanConfig{}, false, gerror.Wrapf(err, "read upstream configuration failed: %s", key)
|
||||
}
|
||||
if configValue == nil || configValue.IsNil() {
|
||||
return model.UpstreamChanConfig{}, false, nil
|
||||
}
|
||||
config := model.UpstreamChanConfig{}
|
||||
if err = configValue.Scan(&config); err != nil {
|
||||
return model.UpstreamChanConfig{}, false, gerror.Wrapf(err, "parse upstream configuration failed: %s", key)
|
||||
}
|
||||
return config, true, nil
|
||||
}
|
||||
|
||||
func upstreamConfig(ctx context.Context, key string) (model.UpstreamChanConfig, error) {
|
||||
config, ok, err := optionalUpstreamConfig(ctx, key)
|
||||
if err != nil {
|
||||
return model.UpstreamChanConfig{}, err
|
||||
}
|
||||
if !ok {
|
||||
return model.UpstreamChanConfig{}, gerror.Newf("upstream configuration is missing: %s", key)
|
||||
}
|
||||
return config, nil
|
||||
}
|
||||
|
||||
func decodeResponse[T any](body []byte) ([]model.SimBaseRes[T], error) {
|
||||
output := response[T]{}
|
||||
if err := json.Unmarshal(body, &output); err != nil {
|
||||
return nil, gerror.Wrap(err, "parse upstream response failed")
|
||||
}
|
||||
if output.Code != 0 {
|
||||
return nil, gerror.New(output.Message)
|
||||
}
|
||||
return output.Data, nil
|
||||
}
|
||||
|
||||
func setClientTimeout(client *gclient.Client, timeout time.Duration) {
|
||||
client.SetTimeout(timeout)
|
||||
if transport, ok := client.Transport.(*http.Transport); ok {
|
||||
transport.ResponseHeaderTimeout = timeout
|
||||
}
|
||||
}
|
||||
|
||||
// resolveUpstreamTimeout 计算单次转发预算:
|
||||
// 普通批量按「单卡预算 × 卡数」放大,并以 maxTimeout 兜底,避免批量操作被默认 2 分钟上限提前判失败;
|
||||
// 数据开停沿用独立预算,优先于按卡计时。
|
||||
func resolveUpstreamTimeout(path string, itemCount int, perCard, timeoutOverride, maxTimeout, dataServiceTimeout time.Duration) time.Duration {
|
||||
if itemCount < 1 {
|
||||
itemCount = 1
|
||||
}
|
||||
timeout := timeoutOverride
|
||||
if timeout <= 0 {
|
||||
timeout = perCard
|
||||
}
|
||||
if timeout <= 0 {
|
||||
timeout = defaultUpstreamProxyTimeout
|
||||
}
|
||||
timeout *= time.Duration(itemCount)
|
||||
if path == "/sim/change/sim-apn" {
|
||||
if dataServiceTimeout > 0 {
|
||||
return dataServiceTimeout
|
||||
}
|
||||
return 240 * time.Second
|
||||
}
|
||||
if maxTimeout > 0 && timeout > maxTimeout {
|
||||
timeout = maxTimeout
|
||||
}
|
||||
return timeout
|
||||
}
|
||||
|
||||
// configDuration 读取时长配置,兼容写成 "20m" 字符串或秒数整数两种形式。
|
||||
func configDuration(ctx context.Context, key string, fallback time.Duration) time.Duration {
|
||||
value := g.Cfg().MustGet(ctx, key)
|
||||
if value == nil || value.IsNil() {
|
||||
return fallback
|
||||
}
|
||||
if duration := value.Duration(); duration >= time.Second {
|
||||
return duration
|
||||
}
|
||||
if seconds := value.Int(); seconds > 0 {
|
||||
return time.Duration(seconds) * time.Second
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
@@ -0,0 +1,527 @@
|
||||
package upstream
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
"github.com/gogf/gf/v2/os/gcfg"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/model"
|
||||
)
|
||||
|
||||
func startAPNHTTPChainHelper(t *testing.T, module string, extraEnv ...string) string {
|
||||
t.Helper()
|
||||
root, err := filepath.Abs(filepath.Join("..", "..", "..", "..", module))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
buildCtx, buildCancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
||||
defer buildCancel()
|
||||
binary := filepath.Join(t.TempDir(), module+"-http-test.exe")
|
||||
build := exec.CommandContext(buildCtx, "go", "-C", root, "test", "-work", "-c", "-o", binary, "./internal/cmd")
|
||||
build.Env = append(os.Environ(), "GOTMPDIR="+t.TempDir())
|
||||
if output, err := build.CombinedOutput(); err != nil {
|
||||
t.Fatalf("%s模拟进程编译失败:%v\n%s", module, err, output)
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 170*time.Second)
|
||||
cmd := exec.CommandContext(ctx, binary, "-test.run=^TestAPNHTTPHarness$", "-test.v", "-test.count=1")
|
||||
cmd.Dir = filepath.Join(root, "internal", "cmd")
|
||||
cmd.Env = append(append(os.Environ(), "APN_HTTP_CHAIN_HELPER=1"), extraEnv...)
|
||||
stdout, err := cmd.StdoutPipe()
|
||||
if err != nil {
|
||||
cancel()
|
||||
t.Fatal(err)
|
||||
}
|
||||
cmd.Stderr = cmd.Stdout
|
||||
if err := cmd.Start(); err != nil {
|
||||
cancel()
|
||||
t.Fatal(err)
|
||||
}
|
||||
ready := make(chan string, 1)
|
||||
var mu sync.Mutex
|
||||
var log strings.Builder
|
||||
go func() {
|
||||
scanner := bufio.NewScanner(stdout)
|
||||
for scanner.Scan() {
|
||||
line := scanner.Text()
|
||||
mu.Lock()
|
||||
log.WriteString(line + "\n")
|
||||
mu.Unlock()
|
||||
if strings.HasPrefix(line, "APN_CHAIN_READY=") {
|
||||
ready <- strings.TrimPrefix(line, "APN_CHAIN_READY=")
|
||||
}
|
||||
}
|
||||
}()
|
||||
done := make(chan error, 1)
|
||||
go func() { done <- cmd.Wait() }()
|
||||
var address string
|
||||
select {
|
||||
case address = <-ready:
|
||||
case err := <-done:
|
||||
cancel()
|
||||
mu.Lock()
|
||||
text := log.String()
|
||||
mu.Unlock()
|
||||
t.Fatalf("%s模拟进程启动即退出:%v\n%s", module, err, text)
|
||||
case <-ctx.Done():
|
||||
cancel()
|
||||
<-done
|
||||
mu.Lock()
|
||||
text := log.String()
|
||||
mu.Unlock()
|
||||
t.Fatalf("%s模拟进程启动失败:%s", module, text)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
requestCtx, stop := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer stop()
|
||||
req, _ := http.NewRequestWithContext(requestCtx, http.MethodPost, address+"/__test/stop", nil)
|
||||
if res, err := http.DefaultClient.Do(req); err == nil {
|
||||
_ = res.Body.Close()
|
||||
if res.StatusCode != http.StatusOK {
|
||||
t.Errorf("%s停止请求失败:%d", module, res.StatusCode)
|
||||
}
|
||||
} else {
|
||||
t.Errorf("%s停止请求失败:%v", module, err)
|
||||
}
|
||||
if err := <-done; err != nil {
|
||||
mu.Lock()
|
||||
text := log.String()
|
||||
mu.Unlock()
|
||||
t.Errorf("%s模拟进程异常退出:%v\n%s", module, err, text)
|
||||
} else {
|
||||
t.Logf("%s helper正常shutdown并退出0", module)
|
||||
}
|
||||
cancel()
|
||||
})
|
||||
return address
|
||||
}
|
||||
|
||||
func TestAPNHTTPChainWorkerClientProxyChanCarrier(t *testing.T) {
|
||||
if os.Getenv("APN_HTTP_CHAIN_INTEGRATION") != "1" {
|
||||
t.Skip("未启用本机跨模块HTTP及专用MySQL模拟链")
|
||||
}
|
||||
chanURL := startAPNHTTPChainHelper(t, "iotlcm-upstream-chan")
|
||||
proxyURL := startAPNHTTPChainHelper(t, "iotlcm-upstream-proxy", "APN_HTTP_CHAIN_CHAN="+chanURL)
|
||||
oldAdapter := g.Cfg().GetAdapter()
|
||||
defer g.Cfg().SetAdapter(oldAdapter)
|
||||
adapter, err := gcfg.NewAdapterContent(fmt.Sprintf(`{"upstream":{"proxy":{"address":%q,"timeout":"2s","apnAsyncProtocolVersion":1}}}`, strings.TrimPrefix(proxyURL, "http://")))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
g.Cfg().SetAdapter(adapter)
|
||||
client := New()
|
||||
expectedWrites := map[string]int{}
|
||||
expectedQueries := map[string]int{}
|
||||
expectedSubmits := 0
|
||||
for _, scenario := range []string{"drop-response", "top-error", "lost-response", "failed-task"} {
|
||||
t.Run(scenario, func(t *testing.T) {
|
||||
input := &model.SimCardApnChangeInput{ReqData: model.SimCardApnChangeInputItem{OperationID: fmt.Sprintf("http-chain-%s-%d", scenario, time.Now().UnixNano()), OperType: "1", ApnName: "CMIOT"}}
|
||||
prefix := scenario + "-"
|
||||
if scenario == "lost-response" {
|
||||
prefix = "lost-"
|
||||
}
|
||||
if scenario == "failed-task" {
|
||||
prefix = "failed-"
|
||||
}
|
||||
for i := 0; i < 10; i++ {
|
||||
input.SimKeys = append(input.SimKeys, &model.SimKey{ChanKey: "fake-channel", ICCID: fmt.Sprintf("fake-card-%s-%d", scenario, i), MSISDN: fmt.Sprintf("%s%d", prefix, i)})
|
||||
}
|
||||
out, err := client.SubmitSimApnFunction(context.Background(), input)
|
||||
if err != nil || len(out) != 10 {
|
||||
t.Fatalf("首发结果无效:%+v %v", out, err)
|
||||
}
|
||||
for _, item := range out {
|
||||
if scenario != "failed-task" && !item.ResData.Uncertain {
|
||||
t.Fatalf("丢响应被确认:%+v", item)
|
||||
}
|
||||
}
|
||||
res, err := http.Post(chanURL+"/__test/restart", "application/json", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_ = res.Body.Close()
|
||||
recovered, err := client.QuerySimApnFunctionResult(context.Background(), input)
|
||||
if err != nil || len(recovered) != 10 {
|
||||
t.Fatalf("恢复失败:%v", err)
|
||||
}
|
||||
duplicate, err := client.SubmitSimApnFunction(context.Background(), input)
|
||||
if err != nil || len(duplicate) != 10 {
|
||||
t.Fatalf("重复恢复失败:%v", err)
|
||||
}
|
||||
if scenario == "lost-response" {
|
||||
for i, item := range recovered {
|
||||
if !item.ResData.Uncertain || item.ResData.OrderNum != "" || !duplicate[i].ResData.Uncertain {
|
||||
t.Fatalf("无jobId必须未知:%+v", item)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
job := recovered[0].ResData.OrderNum
|
||||
for i, item := range recovered {
|
||||
if job == "" || item.ResData.OrderNum != job || !item.ResData.Pending || duplicate[i].ResData.OrderNum != job {
|
||||
t.Fatalf("恢复丢失已持久化jobId:%+v", item)
|
||||
}
|
||||
}
|
||||
expectedQueries[job] = 3
|
||||
input.ReqData.QueryJobID = job
|
||||
for attempt := 0; attempt < 3; attempt++ {
|
||||
result, e := client.QuerySimApnFunctionResult(context.Background(), input)
|
||||
if e != nil || len(result) != 10 {
|
||||
t.Fatalf("单次查询失败:%v", e)
|
||||
}
|
||||
for _, item := range result {
|
||||
if attempt < 2 && (!item.ResData.Pending || item.Message == "") {
|
||||
t.Fatalf("14008/处理中不得终态:%+v", item)
|
||||
}
|
||||
if attempt == 2 && (item.ResData.Pending || item.ResData.Uncertain || (scenario == "failed-task" && item.Code != 9999) || (scenario != "failed-task" && item.Code != 0)) {
|
||||
t.Fatalf("终态语义错误:%+v", item)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
statsResponse, e := http.Get(chanURL + "/__test/stats")
|
||||
if e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
var stats struct {
|
||||
Writes map[string]int `json:"writes"`
|
||||
Queries map[string]int `json:"queries"`
|
||||
Submits int `json:"submits"`
|
||||
Unexpected int `json:"unexpected"`
|
||||
}
|
||||
e = json.NewDecoder(statsResponse.Body).Decode(&stats)
|
||||
_ = statsResponse.Body.Close()
|
||||
expectedSubmits++
|
||||
for _, key := range input.SimKeys {
|
||||
expectedWrites[key.MSISDN] = 1
|
||||
}
|
||||
if e != nil || stats.Submits != expectedSubmits || stats.Unexpected != 0 || len(stats.Writes) != len(expectedWrites) || len(stats.Queries) != len(expectedQueries) {
|
||||
t.Fatalf("额外或缺失上游请求:stats=%+v err=%v", stats, e)
|
||||
}
|
||||
for key, count := range expectedWrites {
|
||||
if stats.Writes[key] != count {
|
||||
t.Fatalf("逐卡办理次数错误:%s=%d,预期%d", key, stats.Writes[key], count)
|
||||
}
|
||||
}
|
||||
for job, count := range expectedQueries {
|
||||
if stats.Queries[job] != count {
|
||||
t.Fatalf("纯查询次数错误:%s=%d,预期%d", job, stats.Queries[job], count)
|
||||
}
|
||||
}
|
||||
counts := make([]int, 0, len(input.SimKeys))
|
||||
for _, key := range input.SimKeys {
|
||||
counts = append(counts, stats.Writes[key.MSISDN])
|
||||
}
|
||||
t.Logf("十卡实际办理计数=%v 累计提交=%d 无额外请求;丢失jobId=%v 明确失败终态=%v", counts, stats.Submits, scenario == "lost-response", scenario == "failed-task")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAsyncApnCapabilityGateBlocksOldChanBeforeSubmit(t *testing.T) {
|
||||
oldAdapter := g.Cfg().GetAdapter()
|
||||
defer g.Cfg().SetAdapter(oldAdapter)
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
enabled int
|
||||
version int
|
||||
wantError bool
|
||||
}{{"显式关闭", 0, 1, true}, {"旧版无能力", 1, 0, true}, {"能力就绪", 1, 1, false}} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
probes, submits := 0, 0
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path == "/sim/query/sim-apn-capability" {
|
||||
probes++
|
||||
_, _ = fmt.Fprintf(w, `{"code":0,"data":[{"code":0,"simKey":{"chanKey":"test","iccid":"fake-card"},"resData":{"protocolVersion":%d}}]}`, tc.version)
|
||||
return
|
||||
}
|
||||
submits++
|
||||
_, _ = w.Write([]byte(`{"code":0,"data":[{"code":0,"resData":{"pending":true,"orderNum":"fake-job"}}]}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
adapter, err := gcfg.NewAdapterContent(fmt.Sprintf(`{"upstream":{"proxy":{"address":%q,"timeout":"1s","apnAsyncProtocolVersion":%d}}}`, strings.TrimPrefix(server.URL, "http://"), tc.enabled))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
g.Cfg().SetAdapter(adapter)
|
||||
_, err = New().SubmitSimApnFunction(context.Background(), &model.SimCardApnChangeInput{SimKeys: []*model.SimKey{{ChanKey: "test", ICCID: "fake-card"}}, ReqData: model.SimCardApnChangeInputItem{OperationID: "fake-operation"}})
|
||||
var notSent interface{ APNRequestNotSent() bool }
|
||||
if (err != nil) != tc.wantError || (tc.wantError && (submits != 0 || !errors.As(err, ¬Sent) || !notSent.APNRequestNotSent())) || (!tc.wantError && (submits != 1 || probes != 1)) {
|
||||
t.Fatalf("能力门禁失效:probes=%d submits=%d err=%v", probes, submits, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestAsyncApnSubmitDefaultsEnabledWithoutGateConfig 固化默认开启:
|
||||
// 配置里不写 apnAsyncProtocolVersion 时,提交不应再被门禁拦下,兼容性交给协议能力探测。
|
||||
func TestAsyncApnSubmitDefaultsEnabledWithoutGateConfig(t *testing.T) {
|
||||
oldAdapter := g.Cfg().GetAdapter()
|
||||
defer g.Cfg().SetAdapter(oldAdapter)
|
||||
probes, submits := 0, 0
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path == "/sim/query/sim-apn-capability" {
|
||||
probes++
|
||||
_, _ = w.Write([]byte(`{"code":0,"data":[{"code":0,"simKey":{"chanKey":"test","iccid":"fake-card"},"resData":{"protocolVersion":1}}]}`))
|
||||
return
|
||||
}
|
||||
submits++
|
||||
_, _ = w.Write([]byte(`{"code":0,"data":[{"code":0,"resData":{"pending":true,"orderNum":"fake-job"}}]}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
adapter, err := gcfg.NewAdapterContent(fmt.Sprintf(`{"upstream":{"proxy":{"address":%q,"timeout":"1s"}}}`, strings.TrimPrefix(server.URL, "http://")))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
g.Cfg().SetAdapter(adapter)
|
||||
if _, err = New().SubmitSimApnFunction(context.Background(), &model.SimCardApnChangeInput{SimKeys: []*model.SimKey{{ChanKey: "test", ICCID: "fake-card"}}, ReqData: model.SimCardApnChangeInputItem{OperationID: "fake-operation"}}); err != nil {
|
||||
t.Fatalf("未配置开关时提交应默认放行:%v", err)
|
||||
}
|
||||
if probes != 1 || submits != 1 {
|
||||
t.Fatalf("默认放行后仍应做协议探测:probes=%d submits=%d", probes, submits)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAsyncApnReadOnlyQueryBypassesSubmitGateButRequiresProtocol(t *testing.T) {
|
||||
oldAdapter := g.Cfg().GetAdapter()
|
||||
defer g.Cfg().SetAdapter(oldAdapter)
|
||||
for _, tc := range []struct {
|
||||
name, jobID, operationID string
|
||||
version int
|
||||
missingProbe bool
|
||||
wantQuery bool
|
||||
}{
|
||||
{name: "existing_job", jobID: "existing-job", version: 1, wantQuery: true},
|
||||
{name: "existing_operation", operationID: "existing-operation", version: 1, wantQuery: true},
|
||||
{name: "legacy_protocol", jobID: "existing-job", version: 0},
|
||||
{name: "missing_capability", jobID: "existing-job", missingProbe: true},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
probes, queries := 0, 0
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path == "/sim/query/sim-apn-capability" {
|
||||
probes++
|
||||
if tc.missingProbe {
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"code": 404, "message": "missing capability"})
|
||||
return
|
||||
}
|
||||
_, _ = fmt.Fprintf(w, `{"code":0,"data":[{"code":0,"simKey":{"chanKey":"test","iccid":"fake-card"},"resData":{"protocolVersion":%d}}]}`, tc.version)
|
||||
return
|
||||
}
|
||||
if r.URL.Path != "/sim/change/sim-apn" {
|
||||
t.Errorf("unexpected endpoint: %s", r.URL.Path)
|
||||
}
|
||||
queries++
|
||||
var request model.SimCardApnChangeInput
|
||||
if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
if request.ReqData.SubmitOnly || request.ReqData.QueryJobID != tc.jobID || request.ReqData.RecoverOnly != (tc.jobID == "") {
|
||||
t.Errorf("query accidentally submitted: %+v", request.ReqData)
|
||||
}
|
||||
_, _ = w.Write([]byte(`{"code":0,"data":[{"code":0,"simKey":{"chanKey":"test","iccid":"fake-card"},"resData":{"orderNum":"existing-job","pending":true}}]}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
adapter, err := gcfg.NewAdapterContent(fmt.Sprintf(`{"upstream":{"proxy":{"address":%q,"timeout":"1s","apnAsyncProtocolVersion":0}}}`, strings.TrimPrefix(server.URL, "http://")))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
g.Cfg().SetAdapter(adapter)
|
||||
result, err := New().QuerySimApnFunctionResult(context.Background(), &model.SimCardApnChangeInput{
|
||||
SimKeys: []*model.SimKey{{ChanKey: "test", ICCID: "fake-card"}},
|
||||
ReqData: model.SimCardApnChangeInputItem{OperationID: tc.operationID, QueryJobID: tc.jobID},
|
||||
})
|
||||
if probes != 1 || (err == nil) != tc.wantQuery || queries != map[bool]int{true: 1, false: 0}[tc.wantQuery] {
|
||||
t.Fatalf("read-only protocol guard failed: probes=%d queries=%d err=%v result=%+v", probes, queries, err, result)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAsyncApnSecondProbeRejectionIsTypedAndTransportIsNot(t *testing.T) {
|
||||
oldAdapter := g.Cfg().GetAdapter()
|
||||
defer g.Cfg().SetAdapter(oldAdapter)
|
||||
for _, probeFails := range []bool{true, false} {
|
||||
probes, submits := 0, 0
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// 夹具不共享连接:并发跑包时复用空闲连接会被对端关闭,
|
||||
// 使失败从"提交阶段"漂移到"只读探测阶段",造成与被测语义无关的抖动。
|
||||
w.Header().Set("Connection", "close")
|
||||
if r.URL.Path == "/sim/query/sim-apn-capability" {
|
||||
probes++
|
||||
if probes == 2 && probeFails {
|
||||
http.Error(w, "模拟第二次探测失败", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
fmt.Fprint(w, `{"code":0,"data":[{"code":0,"simKey":{"chanKey":"test","iccid":"fake-card"},"resData":{"protocolVersion":1}}]}`)
|
||||
return
|
||||
}
|
||||
submits++
|
||||
conn, _, err := w.(http.Hijacker).Hijack()
|
||||
if err != nil {
|
||||
t.Error(err)
|
||||
return
|
||||
}
|
||||
_ = conn.Close()
|
||||
}))
|
||||
adapter, err := gcfg.NewAdapterContent(fmt.Sprintf(`{"upstream":{"proxy":{"address":%q,"timeout":"1s","apnAsyncProtocolVersion":1}}}`, strings.TrimPrefix(server.URL, "http://")))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
g.Cfg().SetAdapter(adapter)
|
||||
input := &model.SimCardApnChangeInput{SimKeys: []*model.SimKey{{ChanKey: "test", ICCID: "fake-card"}}, ReqData: model.SimCardApnChangeInputItem{OperationID: "fake-operation"}}
|
||||
client := New()
|
||||
if err := client.CheckSimApnAsyncCapability(context.Background(), input); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, err = client.SubmitSimApnFunction(context.Background(), input)
|
||||
server.Close()
|
||||
var notSent interface{ APNRequestNotSent() bool }
|
||||
isNotSent := errors.As(err, ¬Sent) && notSent.APNRequestNotSent()
|
||||
if err == nil || isNotSent != probeFails || (probeFails && submits != 0) || (!probeFails && submits != 1) || probes != 2 {
|
||||
t.Fatalf("零写类型边界错误:probeFails=%v probes=%d submits=%d err=%v", probeFails, probes, submits, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSimCardStatusChangeProtocol(t *testing.T) {
|
||||
input := model.SimCardStatusChangeInput{
|
||||
SimKeys: []*model.SimKey{{ChanKey: "channel-a", ICCID: "89860001"}},
|
||||
ReqData: model.SimCardStatusChangeInputItem{OperType: "1"},
|
||||
}
|
||||
body, err := json.Marshal(input)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
const want = `{"simKeys":[{"chanKey":"channel-a","iccid":"89860001","imsi":"","msisdn":""}],"reqData":{"operType":"1"}}`
|
||||
if got := string(body); got != want {
|
||||
t.Fatalf("request body = %s, want %s", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCurrentUsageConsumerProtocol(t *testing.T) {
|
||||
input := model.SimDataUsageInput{
|
||||
SimKeys: []*model.SimKey{{ChanKey: "channel-a", ICCID: "89860001", IMSI: "460001", MSISDN: "13800000000"}},
|
||||
}
|
||||
body, err := json.Marshal(input)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
const want = `{"simKeys":[{"chanKey":"channel-a","iccid":"89860001","imsi":"460001","msisdn":"13800000000"}],"reqData":{}}`
|
||||
if got := string(body); got != want {
|
||||
t.Fatalf("request body = %s, want %s", got, want)
|
||||
}
|
||||
client := NewWithCurrentUsageClient("iotlcmupstreamchan-http-svc:8110")
|
||||
if client.currentUsageClientName != "iotlcmupstreamchan-http-svc:8110" {
|
||||
t.Fatalf("current usage client = %q", client.currentUsageClientName)
|
||||
}
|
||||
if client.currentUsageTimeout != 0 {
|
||||
t.Fatalf("default current usage timeout = %s, want unset", client.currentUsageTimeout)
|
||||
}
|
||||
configured := NewWithCurrentUsageClient("iotlcmupstreamchan-http-svc:8110", 90*time.Second)
|
||||
if configured.currentUsageTimeout != 90*time.Second {
|
||||
t.Fatalf("configured current usage timeout = %s, want 90s", configured.currentUsageTimeout)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInboundBasicInfoClientCanUseDedicatedEndpoint(t *testing.T) {
|
||||
client := NewWithInboundBasicInfoClient("iotlcm.example.com:8110")
|
||||
clientName, err := client.inboundBasicInfoAddress(context.Background())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if clientName != "iotlcm.example.com:8110" {
|
||||
t.Fatalf("inbound basic info client = %q, want dedicated chan endpoint", clientName)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInboundBasicInfoClientCanUseEnvEndpoint(t *testing.T) {
|
||||
const endpoint = "iotlcm.example.com:8110"
|
||||
t.Setenv(inboundBasicInfoClientNameEnv, endpoint)
|
||||
client := New()
|
||||
clientName, err := client.inboundBasicInfoAddress(context.Background())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if clientName != endpoint {
|
||||
t.Fatalf("inbound basic info client = %q, want env endpoint", clientName)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfiguredInboundBasicInfoClientUsesDedicatedEndpoint(t *testing.T) {
|
||||
if value := os.Getenv(inboundBasicInfoClientNameEnv); value != "" {
|
||||
t.Setenv(inboundBasicInfoClientNameEnv, "")
|
||||
}
|
||||
client := New()
|
||||
clientName, err := client.inboundBasicInfoAddress(context.Background())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(clientName, ":8110") {
|
||||
t.Fatalf("inbound basic info client = %q, want dedicated chan endpoint", clientName)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecodeAsyncApnResponseKeepsUnconfirmedState(t *testing.T) {
|
||||
body := []byte(`{"code":0,"data":[{"code":0,"resData":{"orderNum":"job-1","pending":true}},{"code":1,"message":"响应未确认","resData":{"orderNum":"job-2","uncertain":true}}]}`)
|
||||
items, err := decodeResponse[model.SimCardApnChangeOutputItem](body)
|
||||
if err != nil || len(items) != 2 {
|
||||
t.Fatalf("解析异步响应失败:%v", err)
|
||||
}
|
||||
if !items[0].ResData.Pending || items[0].ResData.OrderNum != "job-1" || !items[1].ResData.Uncertain {
|
||||
t.Fatalf("异步待查字段丢失:%+v", items)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSimCardApnChangeProtocol(t *testing.T) {
|
||||
input := model.SimCardApnChangeInput{
|
||||
SimKeys: []*model.SimKey{{ChanKey: "channel-a", ICCID: "89860001"}},
|
||||
ReqData: model.SimCardApnChangeInputItem{OperType: "0", ApnName: "CMIOT"},
|
||||
}
|
||||
body, err := json.Marshal(input)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
const want = `{"simKeys":[{"chanKey":"channel-a","iccid":"89860001","imsi":"","msisdn":""}],"reqData":{"operType":"0","apnName":"CMIOT"}}`
|
||||
if got := string(body); got != want {
|
||||
t.Fatalf("request body = %s, want %s", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecodeSimCardApnChangeResponseKeepsFailedItems(t *testing.T) {
|
||||
body := []byte(`{"code":0,"message":"ok","data":[{"code":0,"message":"success","simKey":{"iccid":"89860001"},"resData":{"orderNum":"order-1"}},{"code":4001,"message":"denied","simKey":{"iccid":"89860002"},"resData":{"orderNum":"order-2"}}]}`)
|
||||
items, err := decodeResponse[model.SimCardApnChangeOutputItem](body)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(items) != 2 {
|
||||
t.Fatalf("item count = %d, want 2", len(items))
|
||||
}
|
||||
if items[1].Code != 4001 || items[1].ResData.OrderNum != "order-2" {
|
||||
t.Fatalf("failed item was not preserved: %+v", items[1])
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecodeSimCardStatusChangeResponseKeepsFailedItems(t *testing.T) {
|
||||
body := []byte(`{"code":0,"message":"ok","data":[{"code":0,"message":"success","simKey":{"iccid":"89860001"},"resData":{"orderNum":"order-1"}},{"code":4001,"message":"denied","simKey":{"iccid":"89860002"},"resData":{"orderNum":"order-2"}}]}`)
|
||||
items, err := decodeResponse[model.SimCardStatusChangeOutputItem](body)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(items) != 2 {
|
||||
t.Fatalf("item count = %d, want 2", len(items))
|
||||
}
|
||||
if items[1].Code != 4001 || items[1].ResData.OrderNum != "order-2" {
|
||||
t.Fatalf("failed item was not preserved: %+v", items[1])
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package upstream
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// TestResolveUpstreamTimeoutScalesWithCardCount 覆盖批量操作预算:
|
||||
// worker 不能再把批量请求压回 2 分钟,否则通道仍在按卡串行办理时会被提前判失败。
|
||||
func TestResolveUpstreamTimeoutScalesWithCardCount(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
path string
|
||||
itemCount int
|
||||
perCard time.Duration
|
||||
override time.Duration
|
||||
maxTimeout time.Duration
|
||||
apnTimeout time.Duration
|
||||
want time.Duration
|
||||
}{
|
||||
{"单卡", "/sim/change/sim-status", 1, 10 * time.Second, 0, defaultUpstreamProxyMaxTimeout, 240 * time.Second, 10 * time.Second},
|
||||
{"零卡按单卡处理", "/sim/change/sim-status", 0, 10 * time.Second, 0, defaultUpstreamProxyMaxTimeout, 240 * time.Second, 10 * time.Second},
|
||||
{"50卡按单卡预算放大", "/sim/change/sim-status", 50, 10 * time.Second, 0, defaultUpstreamProxyMaxTimeout, 240 * time.Second, 500 * time.Second},
|
||||
{"100卡恰好低于上限", "/sim/change/sim-status", 100, 10 * time.Second, 0, defaultUpstreamProxyMaxTimeout, 240 * time.Second, 1000 * time.Second},
|
||||
{"超大批量按上限截断", "/sim/query/usage", 1000, 10 * time.Second, 0, defaultUpstreamProxyMaxTimeout, 240 * time.Second, defaultUpstreamProxyMaxTimeout},
|
||||
{"单卡预算缺失用默认值", "/sim/query/usage", 2, 0, 0, defaultUpstreamProxyMaxTimeout, 240 * time.Second, 20 * time.Second},
|
||||
{"显式覆盖仍按卡数放大", "/sim/query/usage", 3, 10 * time.Second, 2 * time.Second, defaultUpstreamProxyMaxTimeout, 240 * time.Second, 6 * time.Second},
|
||||
{"数据开停使用独立预算", "/sim/change/sim-apn", 50, 10 * time.Second, 0, defaultUpstreamProxyMaxTimeout, 240 * time.Second, 240 * time.Second},
|
||||
{"数据开停预算缺失回退", "/sim/change/sim-apn", 1, 10 * time.Second, 0, defaultUpstreamProxyMaxTimeout, 0, 240 * time.Second},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
got := resolveUpstreamTimeout(c.path, c.itemCount, c.perCard, c.override, c.maxTimeout, c.apnTimeout)
|
||||
if got != c.want {
|
||||
t.Fatalf("resolveUpstreamTimeout=%s,期望%s", got, c.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestUpstreamMaxTimeoutOutlastsProxyBatchTimeout 固化跨服务预算约束:
|
||||
// worker 上限必须大于 proxy 的 gateway.batchTimeout(默认 20 分钟),
|
||||
// 使超时由更靠近通道的一层先判定,避免 worker 提前放弃而通道仍在办理。
|
||||
func TestUpstreamMaxTimeoutOutlastsProxyBatchTimeout(t *testing.T) {
|
||||
const proxyBatchTimeout = 20 * time.Minute
|
||||
if defaultUpstreamProxyMaxTimeout <= proxyBatchTimeout {
|
||||
t.Fatalf("worker 上限%s 必须大于 proxy 批量上限%s", defaultUpstreamProxyMaxTimeout, proxyBatchTimeout)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user