chore: 初始开源发布
iotlcm-upstream-chan:运营商上游通道适配服务(统一 /sim/* 协议)。 - 内联原私有依赖:yunze-public → internal/pub/(10 包 18 文件), yunze-pb/pb → internal/pb/(9 文件);Consul 注册改用官方 gogf/gf/contrib/registry/consul/v2(API 兼容) - Go module:yz-svr → git.5iot.com/iotlcm-public/iotlcm-upstream-chan - 修复 mingqian 包测试:实现重构后 `adapter.operations` 字段已移除, 测试断言按当前实现语义重写(不改产品行为) - 凭据与内部标识脱敏:MySQL/Redis/Consul 凭据、私网与生产 IP、 内网域名、真实手机号、注释里的真实 token/transid - 移除内部资产:CI 触发器、生产通道种子 SQL、真实凭据测试资源文档 - Dockerfile 改用公共基础镜像,移除写死的 registry 凭据 - 补 Apache-2.0 LICENSE,重写 README,LF 行尾 + .gitattributes
This commit is contained in:
@@ -0,0 +1,33 @@
|
||||
package upstreamimpl
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/model"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/model/entity"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/service"
|
||||
)
|
||||
|
||||
func TestApikaTemplateRegistration(t *testing.T) {
|
||||
template, ok := service.UpstreamAPI().GetTemp(sharedOneLinkApikaTemplateKey)
|
||||
if !ok || template == nil {
|
||||
t.Fatal("apika template not registered")
|
||||
}
|
||||
|
||||
config := &model.SimChannelConfigItemCache{
|
||||
Template: &entity.TSimChannelTemplate{
|
||||
TempKey: sharedOneLinkApikaTemplateKey,
|
||||
TempConfig: `{"token":{"provider":"apika","baseUrl":"https://example.com","path":"/iot-api/iot-card-info/getRefreshToken"},"api":{"baseUrl":"https://api.iot.10086.cn/v5/","timeout":"1s"}}`,
|
||||
},
|
||||
Channel: &entity.TSimChannels{
|
||||
ChanKey: "apika-test",
|
||||
ChanConfig: `{"appId":"appid"}`,
|
||||
AppKey: "app-key",
|
||||
AppSecret: "app-secret",
|
||||
},
|
||||
}
|
||||
if instance := template.NewTemp(context.Background(), config); instance == nil {
|
||||
t.Fatal("apika template failed to instantiate")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
package upstreamimpl
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
sharedtoken "git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/token"
|
||||
)
|
||||
|
||||
const apikaTokenPath = "/iot-api/iot-card-info/getRefreshToken"
|
||||
|
||||
type ApikaTokenConfig struct {
|
||||
BaseURL string
|
||||
Path string
|
||||
AppKey string
|
||||
AppSecret string
|
||||
AppID string
|
||||
Timeout time.Duration
|
||||
}
|
||||
|
||||
type ApikaTokenProvider struct {
|
||||
baseURL string
|
||||
path string
|
||||
appKey string
|
||||
appSecret string
|
||||
appID string
|
||||
client *http.Client
|
||||
}
|
||||
|
||||
type apikaTokenResponse struct {
|
||||
Code int `json:"code"`
|
||||
Msg string `json:"msg"`
|
||||
Data struct {
|
||||
ShareToken string `json:"shareToken"`
|
||||
} `json:"data"`
|
||||
}
|
||||
|
||||
type apikaFetcher struct{ provider *ApikaTokenProvider }
|
||||
|
||||
func NewApikaTokenProvider(config ApikaTokenConfig) *ApikaTokenProvider {
|
||||
timeout := config.Timeout
|
||||
if timeout <= 0 {
|
||||
timeout = defaultUpstreamHTTPTimeout
|
||||
}
|
||||
return &ApikaTokenProvider{
|
||||
baseURL: strings.TrimRight(strings.TrimSpace(config.BaseURL), "/"),
|
||||
path: strings.TrimSpace(normalizeProviderPath(config.Path, apikaTokenPath)),
|
||||
appKey: strings.TrimSpace(config.AppKey), appSecret: config.AppSecret,
|
||||
appID: strings.TrimSpace(config.AppID), client: &http.Client{Timeout: timeout},
|
||||
}
|
||||
}
|
||||
|
||||
func apikaSignature(appKey, appSecret string) string {
|
||||
data := "{" + appKey + "}:" + "{" + appSecret + "}"
|
||||
sum := sha256.Sum256([]byte(data))
|
||||
hexValue := strings.ToUpper(hex.EncodeToString(sum[:]))
|
||||
return base64.StdEncoding.EncodeToString([]byte(hexValue))
|
||||
}
|
||||
|
||||
func (f apikaFetcher) FetchToken(ctx context.Context, _ string) (sharedtoken.OneLinkToken, error) {
|
||||
return f.provider.Fetch(ctx)
|
||||
}
|
||||
|
||||
func (p *ApikaTokenProvider) Fetch(ctx context.Context) (sharedtoken.OneLinkToken, error) {
|
||||
if p == nil || p.client == nil {
|
||||
return sharedtoken.OneLinkToken{}, fmt.Errorf("apika token provider is not initialized")
|
||||
}
|
||||
if p.baseURL == "" {
|
||||
return sharedtoken.OneLinkToken{}, fmt.Errorf("apika token base URL is required")
|
||||
}
|
||||
if p.appKey == "" || strings.TrimSpace(p.appSecret) == "" || p.appID == "" {
|
||||
return sharedtoken.OneLinkToken{}, fmt.Errorf("apika appKey, appSecret and appid are required")
|
||||
}
|
||||
signature := apikaSignature(p.appKey, p.appSecret)
|
||||
payload, err := json.Marshal(struct {
|
||||
AppID string `json:"appid"`
|
||||
AppKey string `json:"appKey"`
|
||||
AppSecret string `json:"appSecret"`
|
||||
Signature string `json:"signature"`
|
||||
}{AppID: p.appID, AppKey: p.appKey, AppSecret: p.appSecret, Signature: signature})
|
||||
if err != nil {
|
||||
return sharedtoken.OneLinkToken{}, fmt.Errorf("encode apika token request: %w", err)
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, joinProviderURL(p.baseURL, p.path), bytes.NewReader(payload))
|
||||
if err != nil {
|
||||
return sharedtoken.OneLinkToken{}, fmt.Errorf("create apika token request: %w", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Accept", "application/json")
|
||||
req.Header.Set("appKey", p.appKey)
|
||||
req.Header.Set("appSecret", p.appSecret)
|
||||
req.Header.Set("signature", signature)
|
||||
resp, err := p.client.Do(req)
|
||||
if err != nil {
|
||||
return sharedtoken.OneLinkToken{}, fmt.Errorf("request apika token: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
body, err := io.ReadAll(io.LimitReader(resp.Body, 4*1024*1024+1))
|
||||
if err != nil {
|
||||
return sharedtoken.OneLinkToken{}, fmt.Errorf("read apika token response: %w", err)
|
||||
}
|
||||
if len(body) > 4*1024*1024 {
|
||||
return sharedtoken.OneLinkToken{}, fmt.Errorf("apika token response is too large")
|
||||
}
|
||||
if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices {
|
||||
return sharedtoken.OneLinkToken{}, fmt.Errorf("apika token HTTP status %d", resp.StatusCode)
|
||||
}
|
||||
var output apikaTokenResponse
|
||||
if err := json.Unmarshal(body, &output); err != nil {
|
||||
return sharedtoken.OneLinkToken{}, fmt.Errorf("decode apika token response: %w", err)
|
||||
}
|
||||
if output.Code != http.StatusOK {
|
||||
return sharedtoken.OneLinkToken{}, fmt.Errorf("apika token failed: code=%d msg=%s", output.Code, strings.TrimSpace(output.Msg))
|
||||
}
|
||||
value := strings.TrimSpace(output.Data.ShareToken)
|
||||
if value == "" {
|
||||
return sharedtoken.OneLinkToken{}, fmt.Errorf("apika shareToken is empty")
|
||||
}
|
||||
now := time.Now()
|
||||
return sharedtoken.OneLinkToken{Value: value, CreatedAt: now, ExpiresAt: now.Add(5 * time.Minute)}, nil
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package upstreamimpl
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestApikaSignature(t *testing.T) {
|
||||
got := apikaSignature("00009ac515b442d6812f892d6e4c9f6a", "7d9264596bea4c5e9f4bcde7a314eb9c")
|
||||
want := "MTY0MzE2QjgzRjg3MDFEQzlCNjRBNDA0QzMyRjM4QURGRDhCN0Y5ODY5Qjc0QjNFOEI3NkVEMUJCQ0IwMEU3OQ=="
|
||||
if got != want {
|
||||
t.Fatalf("signature = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApikaTokenProviderFetch(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost || r.URL.Path != apikaTokenPath {
|
||||
t.Fatalf("request = %s %s", r.Method, r.URL.Path)
|
||||
}
|
||||
if r.Header.Get("appKey") != "key" || r.Header.Get("appSecret") != "secret" || r.Header.Get("signature") != apikaSignature("key", "secret") {
|
||||
t.Fatalf("headers = %#v", r.Header)
|
||||
}
|
||||
var input struct {
|
||||
AppID string `json:"appid"`
|
||||
AppKey string `json:"appKey"`
|
||||
AppSecret string `json:"appSecret"`
|
||||
Signature string `json:"signature"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if input.AppID != "appid" || input.AppKey != "key" || input.AppSecret != "secret" || input.Signature != apikaSignature("key", "secret") {
|
||||
t.Fatalf("payload = %#v", input)
|
||||
}
|
||||
_, _ = w.Write([]byte(`{"code":200,"msg":"success","data":{"shareToken":"token-1","transid":"tx-1"}}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
provider := NewApikaTokenProvider(ApikaTokenConfig{BaseURL: server.URL, AppKey: "key", AppSecret: "secret", AppID: "appid", Timeout: time.Second})
|
||||
token, err := provider.Fetch(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("fetch token: %v", err)
|
||||
}
|
||||
if token.Value != "token-1" || !token.ExpiresAt.After(token.CreatedAt) {
|
||||
t.Fatalf("token = %#v", token)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
package upstreamimpl
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
sharedtoken "git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/token"
|
||||
)
|
||||
|
||||
// beixinOneLinkTokenPath is the fallback used when Path is empty. On the
|
||||
// template path this default is unreachable: parseSharedOneLinkConfig rejects
|
||||
// an empty token.path before any provider is built, so Path always carries the
|
||||
// value declared in temp_config. It only applies to direct construction
|
||||
// (tests). The same shape exists in every shared-token provider.
|
||||
const beixinOneLinkTokenPath = "/prod-api/open/api/oneLinkToken"
|
||||
|
||||
// beixinTokenTTL is the local validity window. The upstream returns only the
|
||||
// token string and no expiry field, so the window is held locally; a token
|
||||
// rejected earlier than this is recovered through RecoverToken's invalidation
|
||||
// path instead of being trusted until the TTL elapses.
|
||||
const beixinTokenTTL = time.Hour
|
||||
|
||||
// BeixinTokenConfig contains all values required by the Beixin (江苏倍信)
|
||||
// shared-token protocol. BaseURL and Path come from temp_config
|
||||
// token.baseUrl/token.path (with the per-channel tokenBaseUrl override
|
||||
// applied by parseSharedOneLinkConfig); the credentials come from chan_config.
|
||||
type BeixinTokenConfig struct {
|
||||
BaseURL string
|
||||
Path string
|
||||
PwdKey string
|
||||
ChannelNo string
|
||||
Timeout time.Duration
|
||||
}
|
||||
|
||||
// BeixinTokenProvider fetches a Beixin shared OneLink token.
|
||||
//
|
||||
// Wire contract (verified against the live endpoint on 2026-10-19):
|
||||
//
|
||||
// GET {baseUrl}{path}?pwdkey=<pwdKey>&c_no=<channelNo>
|
||||
// → {"code":200,"msg":"请求成功","data":"<64 chars>"}
|
||||
//
|
||||
// The token is consumed by the standard OneLink business API
|
||||
// (https://api.iot.10086.cn/v5/), so all /sim/* routes work unchanged.
|
||||
type BeixinTokenProvider struct {
|
||||
baseURL string
|
||||
path string
|
||||
pwdKey string
|
||||
channelNo string
|
||||
client *http.Client
|
||||
}
|
||||
|
||||
type beixinTokenResponse struct {
|
||||
Code int `json:"code"`
|
||||
Msg string `json:"msg"`
|
||||
Data json.RawMessage `json:"data"`
|
||||
}
|
||||
|
||||
func NewBeixinTokenProvider(config BeixinTokenConfig) *BeixinTokenProvider {
|
||||
timeout := config.Timeout
|
||||
if timeout <= 0 {
|
||||
timeout = defaultUpstreamHTTPTimeout
|
||||
}
|
||||
return &BeixinTokenProvider{
|
||||
baseURL: strings.TrimRight(strings.TrimSpace(config.BaseURL), "/"),
|
||||
path: normalizeProviderPath(config.Path, beixinOneLinkTokenPath),
|
||||
pwdKey: config.PwdKey,
|
||||
channelNo: strings.TrimSpace(config.ChannelNo),
|
||||
client: &http.Client{Timeout: timeout},
|
||||
}
|
||||
}
|
||||
|
||||
func (p *BeixinTokenProvider) Fetch(ctx context.Context) (sharedtoken.OneLinkToken, error) {
|
||||
if p == nil || p.client == nil {
|
||||
return sharedtoken.OneLinkToken{}, fmt.Errorf("beixin token provider is not initialized")
|
||||
}
|
||||
if p.baseURL == "" || strings.TrimSpace(p.pwdKey) == "" || p.channelNo == "" {
|
||||
return sharedtoken.OneLinkToken{}, fmt.Errorf("beixin baseUrl, pwdKey and channelNo are required")
|
||||
}
|
||||
|
||||
endpoint, err := url.Parse(joinProviderURL(p.baseURL, p.path))
|
||||
if err != nil {
|
||||
return sharedtoken.OneLinkToken{}, fmt.Errorf("parse beixin token URL: %w", err)
|
||||
}
|
||||
query := endpoint.Query()
|
||||
query.Set("pwdkey", p.pwdKey)
|
||||
query.Set("c_no", p.channelNo)
|
||||
endpoint.RawQuery = query.Encode()
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint.String(), nil)
|
||||
if err != nil {
|
||||
return sharedtoken.OneLinkToken{}, fmt.Errorf("create beixin token request: %w", err)
|
||||
}
|
||||
req.Header.Set("Accept", "application/json")
|
||||
|
||||
resp, err := p.client.Do(req)
|
||||
if err != nil {
|
||||
return sharedtoken.OneLinkToken{}, fmt.Errorf("request beixin token: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(io.LimitReader(resp.Body, 4*1024*1024+1))
|
||||
if err != nil {
|
||||
return sharedtoken.OneLinkToken{}, fmt.Errorf("read beixin token response: %w", err)
|
||||
}
|
||||
if len(body) > 4*1024*1024 {
|
||||
return sharedtoken.OneLinkToken{}, fmt.Errorf("beixin token response is too large")
|
||||
}
|
||||
if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices {
|
||||
return sharedtoken.OneLinkToken{}, fmt.Errorf("beixin token HTTP status %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
|
||||
}
|
||||
|
||||
var output beixinTokenResponse
|
||||
if err := json.Unmarshal(body, &output); err != nil {
|
||||
return sharedtoken.OneLinkToken{}, fmt.Errorf("decode beixin token response: %w", err)
|
||||
}
|
||||
if output.Code != http.StatusOK {
|
||||
return sharedtoken.OneLinkToken{}, fmt.Errorf("beixin token failed: code=%d, message=%s", output.Code, output.Msg)
|
||||
}
|
||||
// data is a bare JSON string, not an object.
|
||||
var value string
|
||||
if err := json.Unmarshal(output.Data, &value); err != nil {
|
||||
return sharedtoken.OneLinkToken{}, fmt.Errorf("decode beixin shared token: %w", err)
|
||||
}
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
return sharedtoken.OneLinkToken{}, fmt.Errorf("beixin shared token is empty")
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
return sharedtoken.OneLinkToken{Value: value, CreatedAt: now, ExpiresAt: now.Add(beixinTokenTTL)}, nil
|
||||
}
|
||||
|
||||
func (p *BeixinTokenProvider) FetchToken(ctx context.Context, _ string) (sharedtoken.OneLinkToken, error) {
|
||||
return p.Fetch(ctx)
|
||||
}
|
||||
|
||||
// beixinFetcher adapts BeixinTokenProvider to the shared token TokenFetcher
|
||||
// contract, mirroring tengyuFetcher.
|
||||
type beixinFetcher struct{ provider *BeixinTokenProvider }
|
||||
|
||||
func (f beixinFetcher) FetchToken(ctx context.Context, _ string) (sharedtoken.OneLinkToken, error) {
|
||||
return f.provider.Fetch(ctx)
|
||||
}
|
||||
@@ -0,0 +1,335 @@
|
||||
package upstreamimpl
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/model"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/model/entity"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/service"
|
||||
)
|
||||
|
||||
const (
|
||||
beixinTestPwdKey = "test-pwdkey"
|
||||
beixinTestChannelNo = "chanel-202607100639054511"
|
||||
beixinTestToken = "v5feb8f288c5ba96631b627dc18a5b6a5318e7e7b1923cf7e0aa0efdefa746e4"
|
||||
)
|
||||
|
||||
func newBeixinTestProvider(t *testing.T, baseURL string) *BeixinTokenProvider {
|
||||
t.Helper()
|
||||
return NewBeixinTokenProvider(BeixinTokenConfig{
|
||||
BaseURL: baseURL, Path: beixinOneLinkTokenPath,
|
||||
PwdKey: beixinTestPwdKey, ChannelNo: beixinTestChannelNo, Timeout: time.Second,
|
||||
})
|
||||
}
|
||||
|
||||
// TestBeixinTokenProviderSendRequestContract pins the exact wire shape as
|
||||
// observed against the live endpoint on 2026-10-19: a GET with lowercase
|
||||
// pwdkey/c_no query parameters, and a bare JSON string in data.
|
||||
func TestBeixinTokenProviderSendRequestContract(t *testing.T) {
|
||||
var (
|
||||
gotMethod string
|
||||
gotPath string
|
||||
gotPwdKey string
|
||||
gotCNo string
|
||||
)
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
gotMethod = r.Method
|
||||
gotPath = r.URL.Path
|
||||
gotPwdKey = r.URL.Query().Get("pwdkey")
|
||||
gotCNo = r.URL.Query().Get("c_no")
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"code":200,"msg":"请求成功","data":"` + beixinTestToken + `"}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
if _, err := newBeixinTestProvider(t, server.URL).Fetch(context.Background()); err != nil {
|
||||
t.Fatalf("fetch token: %v", err)
|
||||
}
|
||||
|
||||
if gotMethod != http.MethodGet {
|
||||
t.Fatalf("method = %q, want GET", gotMethod)
|
||||
}
|
||||
if gotPath != beixinOneLinkTokenPath {
|
||||
t.Fatalf("path = %q, want %q", gotPath, beixinOneLinkTokenPath)
|
||||
}
|
||||
if gotPwdKey != beixinTestPwdKey {
|
||||
t.Fatalf("pwdkey = %q, want %q", gotPwdKey, beixinTestPwdKey)
|
||||
}
|
||||
if gotCNo != beixinTestChannelNo {
|
||||
t.Fatalf("c_no = %q, want %q", gotCNo, beixinTestChannelNo)
|
||||
}
|
||||
}
|
||||
|
||||
// TestBeixinTokenProviderReturnsValueAndLocalTTL pins that the bare string in
|
||||
// data is unwrapped and that the local one-hour window is applied, since the
|
||||
// upstream reports no expiry.
|
||||
func TestBeixinTokenProviderReturnsValueAndLocalTTL(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = w.Write([]byte(`{"code":200,"msg":"请求成功","data":"` + beixinTestToken + `"}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
before := time.Now()
|
||||
token, err := newBeixinTestProvider(t, server.URL).Fetch(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("fetch token: %v", err)
|
||||
}
|
||||
if token.Value != beixinTestToken {
|
||||
t.Fatalf("token value = %q, want %q", token.Value, beixinTestToken)
|
||||
}
|
||||
// Allow a second of slack for slow CI clocks.
|
||||
if delta := token.ExpiresAt.Sub(before); delta < beixinTokenTTL-time.Second || delta > beixinTokenTTL+time.Second {
|
||||
t.Fatalf("expiresAt - before = %s, want ~%s", delta, beixinTokenTTL)
|
||||
}
|
||||
}
|
||||
|
||||
// TestBeixinTokenProviderRejectsBusinessFailure pins that a non-200 code is an
|
||||
// error rather than an empty-but-successful token.
|
||||
func TestBeixinTokenProviderRejectsBusinessFailure(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = w.Write([]byte(`{"code":500,"msg":"获取token失败","data":null}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
_, err := newBeixinTestProvider(t, server.URL).Fetch(context.Background())
|
||||
if err == nil {
|
||||
t.Fatal("business failure must return an error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "获取token失败") {
|
||||
t.Fatalf("error = %v, want the upstream message", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestBeixinTokenProviderRejectsEmptyToken pins that a successful envelope with
|
||||
// an empty data string is still an error.
|
||||
func TestBeixinTokenProviderRejectsEmptyToken(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = w.Write([]byte(`{"code":200,"msg":"请求成功","data":""}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
_, err := newBeixinTestProvider(t, server.URL).Fetch(context.Background())
|
||||
if err == nil {
|
||||
t.Fatal("empty token must return an error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "beixin shared token is empty") {
|
||||
t.Fatalf("error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestBeixinTokenProviderRejectsObjectData pins that data is expected to be a
|
||||
// JSON string. A future upstream change to an object shape must fail loudly
|
||||
// instead of silently yielding an empty token.
|
||||
func TestBeixinTokenProviderRejectsObjectData(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = w.Write([]byte(`{"code":200,"msg":"请求成功","data":{"token":"abc"}}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
if _, err := newBeixinTestProvider(t, server.URL).Fetch(context.Background()); err == nil {
|
||||
t.Fatal("object-shaped data must return an error")
|
||||
}
|
||||
}
|
||||
|
||||
// TestBeixinTokenProviderRequiresCredentials pins the guard so a channel
|
||||
// missing chan_config.pwdKey or channelNo cannot build a provider that fails
|
||||
// on every request.
|
||||
func TestBeixinTokenProviderRequiresCredentials(t *testing.T) {
|
||||
cases := map[string]BeixinTokenConfig{
|
||||
"missing baseUrl": {Path: beixinOneLinkTokenPath, PwdKey: beixinTestPwdKey, ChannelNo: beixinTestChannelNo},
|
||||
"missing pwdKey": {BaseURL: "http://example.com", Path: beixinOneLinkTokenPath, ChannelNo: beixinTestChannelNo},
|
||||
"missing channelNo": {BaseURL: "http://example.com", Path: beixinOneLinkTokenPath, PwdKey: beixinTestPwdKey},
|
||||
}
|
||||
for name, cfg := range cases {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
start := time.Now()
|
||||
_, err := NewBeixinTokenProvider(cfg).Fetch(context.Background())
|
||||
if err == nil {
|
||||
t.Fatal("incomplete credentials must return an error")
|
||||
}
|
||||
if elapsed := time.Since(start); elapsed > time.Second {
|
||||
t.Fatalf("validation must not hit the network, took %s", elapsed)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "beixin baseUrl, pwdKey and channelNo are required") {
|
||||
t.Fatalf("error = %v, want the credential guard message", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestBeixinTemplateRegistration pins that a provider=beixin template
|
||||
// instantiates through the shared_token model with a chan_config carrying the
|
||||
// two required credentials.
|
||||
func TestBeixinTemplateRegistration(t *testing.T) {
|
||||
template, ok := service.UpstreamAPI().GetTemp(sharedOneLinkBeixinTemplateKey)
|
||||
if !ok || template == nil {
|
||||
t.Fatal("beixin template not registered")
|
||||
}
|
||||
|
||||
config := &model.SimChannelConfigItemCache{
|
||||
Template: &entity.TSimChannelTemplate{
|
||||
TempKey: sharedOneLinkBeixinTemplateKey,
|
||||
TempModel: templateModelSharedToken,
|
||||
TempConfig: `{"token":{"provider":"beixin","baseUrl":"http://www.jsbxkj.vip",` +
|
||||
`"path":"/prod-api/open/api/oneLinkToken"},"api":{"baseUrl":"https://api.iot.10086.cn/v5/","timeout":"1s"}}`,
|
||||
},
|
||||
Channel: &entity.TSimChannels{
|
||||
ChanKey: "252",
|
||||
ChanConfig: `{"pwdKey":"` + beixinTestPwdKey + `","channelNo":"` + beixinTestChannelNo + `"}`,
|
||||
},
|
||||
}
|
||||
if instance := template.NewTemp(context.Background(), config); instance == nil {
|
||||
t.Fatal("beixin template failed to instantiate")
|
||||
}
|
||||
}
|
||||
|
||||
// TestBeixinTokenPathComesFromTemplate pins that the request path is whatever
|
||||
// the template declares, not a hardcoded default. This is the same property
|
||||
// TestLanShuTokenPathComesFromTemplate pins for lanshu, and it is what makes a
|
||||
// template reusable across upstream hosts.
|
||||
func TestBeixinTokenPathComesFromTemplate(t *testing.T) {
|
||||
const templatePath = "/some/other/open/route"
|
||||
if templatePath == beixinOneLinkTokenPath {
|
||||
t.Fatal("test precondition broken: templatePath must differ from beixinOneLinkTokenPath")
|
||||
}
|
||||
|
||||
var gotPath string
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
gotPath = r.URL.Path
|
||||
_, _ = w.Write([]byte(`{"code":200,"msg":"请求成功","data":"` + beixinTestToken + `"}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
channelConfig := &model.SimChannelConfigItemCache{
|
||||
Template: &entity.TSimChannelTemplate{
|
||||
TempKey: sharedOneLinkBeixinTemplateKey,
|
||||
TempConfig: `{"token":{"provider":"beixin","baseUrl":"` + server.URL + `",` +
|
||||
`"path":"` + templatePath + `"}}`,
|
||||
},
|
||||
Channel: &entity.TSimChannels{
|
||||
ChanKey: "252",
|
||||
ChanConfig: `{"pwdKey":"` + beixinTestPwdKey + `","channelNo":"` + beixinTestChannelNo + `"}`,
|
||||
},
|
||||
}
|
||||
|
||||
cfg, _, err := parseSharedOneLinkConfig(context.Background(), channelConfig)
|
||||
if err != nil {
|
||||
t.Fatalf("parse config: %v", err)
|
||||
}
|
||||
if cfg.TokenPath != templatePath {
|
||||
t.Fatalf("parsed TokenPath = %q, want the template-declared %q", cfg.TokenPath, templatePath)
|
||||
}
|
||||
|
||||
provider := NewBeixinTokenProvider(BeixinTokenConfig{
|
||||
BaseURL: cfg.TokenBaseURL, Path: cfg.TokenPath,
|
||||
PwdKey: cfg.PwdKey, ChannelNo: cfg.ChannelNo, Timeout: cfg.Timeout,
|
||||
})
|
||||
if _, err := provider.Fetch(context.Background()); err != nil {
|
||||
t.Fatalf("fetch token: %v", err)
|
||||
}
|
||||
if gotPath != templatePath {
|
||||
t.Fatalf("request path = %q, want the template-declared %q", gotPath, templatePath)
|
||||
}
|
||||
}
|
||||
|
||||
// TestBeixinChannelConfigRejectsIncompleteCredentials pins the parse-time
|
||||
// guard, so the gate fires before any network call.
|
||||
func TestBeixinChannelConfigRejectsIncompleteCredentials(t *testing.T) {
|
||||
base := func(chanConfig string) *model.SimChannelConfigItemCache {
|
||||
return &model.SimChannelConfigItemCache{
|
||||
Template: &entity.TSimChannelTemplate{
|
||||
TempKey: sharedOneLinkBeixinTemplateKey,
|
||||
TempConfig: `{"token":{"provider":"beixin","baseUrl":"http://www.jsbxkj.vip",` +
|
||||
`"path":"/prod-api/open/api/oneLinkToken"}}`,
|
||||
},
|
||||
Channel: &entity.TSimChannels{ChanKey: "252", ChanConfig: chanConfig},
|
||||
}
|
||||
}
|
||||
|
||||
if _, _, err := parseSharedOneLinkConfig(context.Background(), base(`{"pwdKey":"only"}`)); err == nil {
|
||||
t.Fatal("chan_config without channelNo must be rejected")
|
||||
}
|
||||
if _, _, err := parseSharedOneLinkConfig(context.Background(), base(`{}`)); err == nil {
|
||||
t.Fatal("empty chan_config must be rejected")
|
||||
}
|
||||
|
||||
parsed, _, err := parseSharedOneLinkConfig(context.Background(), base(
|
||||
`{"pwdKey":"`+beixinTestPwdKey+`","channelNo":"`+beixinTestChannelNo+`"}`))
|
||||
if err != nil {
|
||||
t.Fatalf("complete chan_config must parse: %v", err)
|
||||
}
|
||||
if parsed.PwdKey != beixinTestPwdKey || parsed.ChannelNo != beixinTestChannelNo {
|
||||
t.Fatalf("parsed credentials = %q / %q", parsed.PwdKey, parsed.ChannelNo)
|
||||
}
|
||||
}
|
||||
|
||||
// TestBeixinChannelTokenBaseURLOverride pins the per-channel host override that
|
||||
// lets several Beixin accounts (different c_no values, possibly different
|
||||
// hosts) share one template without a code change.
|
||||
func TestBeixinChannelTokenBaseURLOverride(t *testing.T) {
|
||||
const templateHost = "http://template-host.example.com"
|
||||
const channelHost = "http://channel-host.example.com"
|
||||
|
||||
config := &model.SimChannelConfigItemCache{
|
||||
Template: &entity.TSimChannelTemplate{
|
||||
TempKey: sharedOneLinkBeixinTemplateKey,
|
||||
TempConfig: `{"token":{"provider":"beixin","baseUrl":"` + templateHost + `",` +
|
||||
`"path":"/prod-api/open/api/oneLinkToken"}}`,
|
||||
},
|
||||
Channel: &entity.TSimChannels{
|
||||
ChanKey: "252",
|
||||
ChanConfig: `{"pwdKey":"` + beixinTestPwdKey + `","channelNo":"` + beixinTestChannelNo + `",` +
|
||||
`"tokenBaseUrl":"` + channelHost + `"}`,
|
||||
},
|
||||
}
|
||||
|
||||
parsed, _, err := parseSharedOneLinkConfig(context.Background(), config)
|
||||
if err != nil {
|
||||
t.Fatalf("parse config: %v", err)
|
||||
}
|
||||
if parsed.TokenBaseURL != channelHost {
|
||||
t.Fatalf("TokenBaseURL = %q, want the channel override %q", parsed.TokenBaseURL, channelHost)
|
||||
}
|
||||
|
||||
// Without the override the template host must win.
|
||||
config.Channel.ChanConfig = `{"pwdKey":"` + beixinTestPwdKey + `","channelNo":"` + beixinTestChannelNo + `"}`
|
||||
parsed, _, err = parseSharedOneLinkConfig(context.Background(), config)
|
||||
if err != nil {
|
||||
t.Fatalf("parse config: %v", err)
|
||||
}
|
||||
if parsed.TokenBaseURL != templateHost {
|
||||
t.Fatalf("TokenBaseURL = %q, want the template default %q", parsed.TokenBaseURL, templateHost)
|
||||
}
|
||||
}
|
||||
|
||||
// TestBeixinResponseShapeMatchesObservedBody replays the exact body captured
|
||||
// from the live endpoint, so field-case drift breaks the build rather than
|
||||
// production.
|
||||
func TestBeixinResponseShapeMatchesObservedBody(t *testing.T) {
|
||||
const observed = `{"code":200,"msg":"请求成功","data":"v5feb8f288c5ba96631b627dc18a5b6a5318e7e7b1923cf7e0aa0efdefa746e4"}`
|
||||
|
||||
var output beixinTokenResponse
|
||||
if err := json.Unmarshal([]byte(observed), &output); err != nil {
|
||||
t.Fatalf("decode observed body: %v", err)
|
||||
}
|
||||
if output.Code != 200 || output.Msg != "请求成功" {
|
||||
t.Fatalf("envelope = %+v", output)
|
||||
}
|
||||
var value string
|
||||
if err := json.Unmarshal(output.Data, &value); err != nil {
|
||||
t.Fatalf("decode data: %v", err)
|
||||
}
|
||||
if len(value) != 64 || !strings.HasPrefix(value, "v5") {
|
||||
t.Fatalf("token shape changed: %q", value)
|
||||
}
|
||||
}
|
||||
|
||||
// silence unused-import drift if the file is trimmed later.
|
||||
var _ = io.Discard
|
||||
@@ -0,0 +1,60 @@
|
||||
package upstreamimpl
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/service/daihe"
|
||||
sharedtoken "git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/token"
|
||||
)
|
||||
|
||||
type DaiheTokenConfig struct {
|
||||
BaseURL string
|
||||
Path string
|
||||
AppID string
|
||||
AppSecret string
|
||||
CMCCAppID string
|
||||
Timeout time.Duration
|
||||
}
|
||||
|
||||
type DaiheTokenProvider struct{ config DaiheTokenConfig }
|
||||
type daiheTokenFetcher struct{ provider *DaiheTokenProvider }
|
||||
|
||||
func NewDaiheTokenProvider(config DaiheTokenConfig) *DaiheTokenProvider {
|
||||
if strings.TrimSpace(config.Path) == "" {
|
||||
config.Path = "/sim/query/cmccToken"
|
||||
}
|
||||
if strings.TrimSpace(config.BaseURL) == "" {
|
||||
config.BaseURL = "https://open.flymodem.com.cn"
|
||||
}
|
||||
return &DaiheTokenProvider{config: config}
|
||||
}
|
||||
|
||||
func (p *DaiheTokenProvider) Fetch(ctx context.Context) (sharedtoken.OneLinkToken, error) {
|
||||
if p == nil || p.config.AppID == "" || p.config.AppSecret == "" || p.config.CMCCAppID == "" {
|
||||
return sharedtoken.OneLinkToken{}, fmt.Errorf("daihe token credentials and cmccAppId are required")
|
||||
}
|
||||
client := daihe.NewClient(daihe.Config{BaseURL: p.config.BaseURL, AppID: p.config.AppID, AppSecret: p.config.AppSecret, Timeout: p.config.Timeout})
|
||||
var response daihe.CMCCToken
|
||||
if err := client.QueryCMCCToken(ctx, p.config.CMCCAppID, &response); err != nil {
|
||||
return sharedtoken.OneLinkToken{}, err
|
||||
}
|
||||
token := strings.TrimSpace(response.Token)
|
||||
deviceToken := strings.TrimSpace(response.TokenDevice)
|
||||
if token == "" {
|
||||
return sharedtoken.OneLinkToken{}, fmt.Errorf("daihe cmcc token response is empty")
|
||||
}
|
||||
ttl, err := strconv.ParseInt(strings.TrimSpace(response.DeviceTTL), 10, 64)
|
||||
if err != nil || ttl <= 0 {
|
||||
return sharedtoken.OneLinkToken{}, fmt.Errorf("daihe device_ttl must be positive: %q", response.DeviceTTL)
|
||||
}
|
||||
now := time.Now()
|
||||
return sharedtoken.OneLinkToken{Value: token, DeviceValue: deviceToken, CreatedAt: now, ExpiresAt: now.Add(time.Duration(ttl) * time.Second)}, nil
|
||||
}
|
||||
|
||||
func (f daiheTokenFetcher) FetchToken(ctx context.Context, _ string) (sharedtoken.OneLinkToken, error) {
|
||||
return f.provider.Fetch(ctx)
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
package dx
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// HTTPStatusError preserves the telecom response body for non-200 responses.
|
||||
// RespCode is the raw carrier error code when the gateway supplies one.
|
||||
type HTTPStatusError struct {
|
||||
StatusCode int
|
||||
Body string
|
||||
RespCode string
|
||||
}
|
||||
|
||||
func (e *HTTPStatusError) Error() string {
|
||||
if e == nil {
|
||||
return "telecom HTTP status error"
|
||||
}
|
||||
return fmt.Sprintf("http status %d: %s", e.StatusCode, e.Body)
|
||||
}
|
||||
|
||||
// Config 网关鉴权与请求配置
|
||||
type Config struct {
|
||||
BaseURL string // 默认 https://cmp-api.ctwing.cn:20164
|
||||
AppKey string
|
||||
AppSecret string
|
||||
Timeout time.Duration
|
||||
UppercaseSign bool
|
||||
}
|
||||
|
||||
// Client API 客户端
|
||||
type Client struct {
|
||||
baseURL string
|
||||
appKey string
|
||||
appSecret string
|
||||
http *http.Client
|
||||
uppercaseSign bool
|
||||
}
|
||||
|
||||
// New 创建客户端
|
||||
func NewClient(cfg Config) *Client {
|
||||
base := cfg.BaseURL
|
||||
if base == "" {
|
||||
base = DefaultBaseURL
|
||||
}
|
||||
timeout := cfg.Timeout
|
||||
if timeout <= 0 {
|
||||
timeout = 10 * time.Second
|
||||
}
|
||||
return &Client{
|
||||
baseURL: strings.TrimSuffix(base, "/"),
|
||||
appKey: cfg.AppKey,
|
||||
appSecret: cfg.AppSecret,
|
||||
uppercaseSign: cfg.UppercaseSign,
|
||||
http: &http.Client{
|
||||
Timeout: timeout,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// DoGetRequest 通用GET请求(自动处理签名)
|
||||
// endpoint: API路径,如 "/api/v1/openbill/queryTraffic"
|
||||
// params: 查询参数,需调用方自行构建
|
||||
func (c *Client) DoGetRequest(ctx context.Context, endpoint string, params url.Values) ([]byte, error) {
|
||||
req, err := c.doGetRequest(ctx, endpoint, params)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return c.doRequest(req)
|
||||
}
|
||||
|
||||
func (c *Client) DoPostJSONRequest(ctx context.Context, endpoint string, body []byte) ([]byte, error) {
|
||||
timestamp := GetTimestampGMT()
|
||||
signStr, err := BuildSign("", string(body), c.appSecret, timestamp)
|
||||
if c.uppercaseSign {
|
||||
signStr, err = BuildSignV3("", string(body), c.appSecret, timestamp)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+endpoint, strings.NewReader(string(body)))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("AppKey", c.appKey)
|
||||
req.Header.Set("Sign", c.formatSign(signStr))
|
||||
req.Header.Set("Timestamp", timestamp)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
return c.doRequest(req)
|
||||
}
|
||||
|
||||
// doGetRequest 封装通用的 GET 请求构建逻辑
|
||||
func (c *Client) doGetRequest(ctx context.Context, endpoint string, params url.Values) (*http.Request, error) {
|
||||
queryString := params.Encode()
|
||||
timestamp := GetTimestampGMT()
|
||||
signStr, err := BuildSign(queryString, "", c.appSecret, timestamp)
|
||||
if c.uppercaseSign {
|
||||
signStr, err = BuildSignV3(queryString, "", c.appSecret, timestamp)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
reqURL := c.baseURL + endpoint + "?" + queryString
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, reqURL, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("AppKey", c.appKey)
|
||||
req.Header.Set("Sign", c.formatSign(signStr))
|
||||
req.Header.Set("Timestamp", timestamp)
|
||||
return req, nil
|
||||
}
|
||||
|
||||
func (c *Client) formatSign(sign string) string {
|
||||
if c.uppercaseSign {
|
||||
return strings.ToUpper(sign)
|
||||
}
|
||||
return sign
|
||||
}
|
||||
|
||||
// doRequest 执行HTTP请求
|
||||
func (c *Client) doRequest(req *http.Request) ([]byte, error) {
|
||||
resp, err := c.http.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
var errorBody struct {
|
||||
RespCode string `json:"respCode"`
|
||||
}
|
||||
_ = json.Unmarshal(body, &errorBody)
|
||||
if c.uppercaseSign && strings.TrimSpace(errorBody.RespCode) == "" {
|
||||
errorBody.RespCode = resp.Header.Get("gw-errorCode")
|
||||
}
|
||||
return nil, &HTTPStatusError{
|
||||
StatusCode: resp.StatusCode,
|
||||
Body: string(body),
|
||||
RespCode: strings.TrimSpace(errorBody.RespCode),
|
||||
}
|
||||
}
|
||||
return body, nil
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
package dx
|
||||
|
||||
import (
|
||||
"crypto/md5"
|
||||
"encoding/hex"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
// DefaultBaseURL 电信API基础地址
|
||||
DefaultBaseURL = "https://cmp-api.ctwing.cn:20164"
|
||||
)
|
||||
|
||||
// GetTimestampGMT 返回 GMT 时区的 yyyyMMddHHmmss 格式时间戳
|
||||
func GetTimestampGMT() string {
|
||||
return time.Now().UTC().Format("20060102150405")
|
||||
}
|
||||
|
||||
// OrderParams 将 URL params 按 key 字典序排序
|
||||
func OrderParams(params string) (string, error) {
|
||||
if params == "" {
|
||||
return "", nil
|
||||
}
|
||||
kvs := strings.Split(params, "&")
|
||||
m := make(map[string]string, len(kvs))
|
||||
for _, kv := range kvs {
|
||||
idx := strings.Index(kv, "=")
|
||||
if idx < 0 {
|
||||
continue
|
||||
}
|
||||
m[kv[:idx]] = kv[idx+1:]
|
||||
}
|
||||
keys := make([]string, 0, len(m))
|
||||
for k := range m {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
var parts []string
|
||||
for _, k := range keys {
|
||||
parts = append(parts, k+"="+m[k])
|
||||
}
|
||||
return strings.Join(parts, "&"), nil
|
||||
}
|
||||
|
||||
// SignMD5 对 reqStr 做 MD5 加密,返回小写十六进制字符串
|
||||
func SignMD5(reqStr string) string {
|
||||
h := md5.Sum([]byte(reqStr))
|
||||
return strings.ToLower(hex.EncodeToString(h[:]))
|
||||
}
|
||||
|
||||
// BuildSign 构造签名:params + body + secretKey + timestamp → MD5 → hex
|
||||
func BuildSign(params, body, secretKey, timestamp string) (string, error) {
|
||||
ordered, err := OrderParams(params)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
reqStr := ordered + body + secretKey + timestamp
|
||||
md5Str := SignMD5(reqStr)
|
||||
return hex.EncodeToString([]byte(md5Str)), nil
|
||||
}
|
||||
|
||||
// BuildSignV3 follows the V3 Java sample: encode the lowercase MD5 digest
|
||||
// string as hex a second time, then uppercase that outer encoding.
|
||||
func BuildSignV3(params, body, secretKey, timestamp string) (string, error) {
|
||||
sign, err := BuildSign(params, body, secretKey, timestamp)
|
||||
return strings.ToUpper(sign), err
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package dx
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestV3SignatureKeepsLegacyEncodingAndUsesUppercase(t *testing.T) {
|
||||
// MD5("abc") is 900150983cd24fb0d6963f7d28e17f72.
|
||||
legacy, err := BuildSign("", "abc", "", "")
|
||||
if err != nil || legacy != "3930303135303938336364323466623064363936336637643238653137663732" {
|
||||
t.Fatalf("legacy signature: %s, %v", legacy, err)
|
||||
}
|
||||
v3, err := BuildSignV3("", "abc", "", "")
|
||||
if err != nil || v3 != "3930303135303938336364323466623064363936336637643238653137663732" {
|
||||
t.Fatalf("v3 signature: %s, %v", v3, err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,256 @@
|
||||
package upstreamimpl
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
henanLelianLoginPath = "/api/v2/loginVerify"
|
||||
henanLelianSharePath = "/api/v2/getShareToken"
|
||||
henanLelianPlatformTTL = 23 * time.Hour
|
||||
henanLelianShareInterval = time.Minute
|
||||
)
|
||||
|
||||
// HenanLelianTokenConfig contains the public provider endpoints and the
|
||||
// per-channel credentials required to obtain a shared OneLink token.
|
||||
type HenanLelianTokenConfig struct {
|
||||
BaseURL string
|
||||
LoginPath string
|
||||
Path string
|
||||
UserName string
|
||||
Password string
|
||||
PasswayUID string
|
||||
Timeout time.Duration
|
||||
}
|
||||
|
||||
// HenanLelianTokenProvider first obtains a platform access token and then
|
||||
// exchanges it for the carrier-issued OneLink token.
|
||||
type HenanLelianTokenProvider struct {
|
||||
baseURL string
|
||||
loginPath string
|
||||
path string
|
||||
userName string
|
||||
password string
|
||||
passwayUID string
|
||||
client *http.Client
|
||||
now func() time.Time
|
||||
|
||||
platformMu sync.Mutex
|
||||
platformToken string
|
||||
platformTokenExp time.Time
|
||||
|
||||
shareMu sync.Mutex
|
||||
nextShareAt time.Time
|
||||
shareInterval time.Duration
|
||||
}
|
||||
|
||||
type henanLelianResponse struct {
|
||||
Code json.RawMessage `json:"code"`
|
||||
Message string `json:"message"`
|
||||
Payload struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
Token string `json:"token"`
|
||||
} `json:"payload"`
|
||||
}
|
||||
|
||||
func NewHenanLelianTokenProvider(config HenanLelianTokenConfig) *HenanLelianTokenProvider {
|
||||
timeout := config.Timeout
|
||||
if timeout <= 0 {
|
||||
timeout = defaultUpstreamHTTPTimeout
|
||||
}
|
||||
return &HenanLelianTokenProvider{
|
||||
baseURL: strings.TrimRight(strings.TrimSpace(config.BaseURL), "/"),
|
||||
loginPath: normalizeProviderPath(config.LoginPath, henanLelianLoginPath),
|
||||
path: normalizeProviderPath(config.Path, henanLelianSharePath),
|
||||
userName: strings.TrimSpace(config.UserName),
|
||||
password: config.Password,
|
||||
passwayUID: strings.TrimSpace(config.PasswayUID),
|
||||
client: &http.Client{Timeout: timeout},
|
||||
now: time.Now,
|
||||
shareInterval: henanLelianShareInterval,
|
||||
}
|
||||
}
|
||||
|
||||
func (p *HenanLelianTokenProvider) Fetch(ctx context.Context) (OneLinkToken, error) {
|
||||
if p == nil || p.client == nil || p.now == nil {
|
||||
return OneLinkToken{}, fmt.Errorf("Henan Lelian token provider is not initialized")
|
||||
}
|
||||
if p.baseURL == "" {
|
||||
return OneLinkToken{}, fmt.Errorf("Henan Lelian token base URL is required")
|
||||
}
|
||||
if p.userName == "" || strings.TrimSpace(p.password) == "" {
|
||||
return OneLinkToken{}, fmt.Errorf("Henan Lelian username and password are required")
|
||||
}
|
||||
if p.passwayUID == "" {
|
||||
return OneLinkToken{}, fmt.Errorf("Henan Lelian passwayUid is required")
|
||||
}
|
||||
|
||||
platformToken, err := p.getPlatformToken(ctx)
|
||||
if err != nil {
|
||||
return OneLinkToken{}, err
|
||||
}
|
||||
value, authFailed, err := p.fetchSharedToken(ctx, platformToken)
|
||||
if authFailed {
|
||||
p.clearPlatformToken(platformToken)
|
||||
}
|
||||
if err != nil {
|
||||
return OneLinkToken{}, err
|
||||
}
|
||||
now := p.now()
|
||||
return OneLinkToken{Value: value, CreatedAt: now}, nil
|
||||
}
|
||||
|
||||
func (p *HenanLelianTokenProvider) getPlatformToken(ctx context.Context) (string, error) {
|
||||
p.platformMu.Lock()
|
||||
defer p.platformMu.Unlock()
|
||||
|
||||
now := p.now()
|
||||
if p.platformToken != "" && now.Before(p.platformTokenExp) {
|
||||
return p.platformToken, nil
|
||||
}
|
||||
body, err := json.Marshal(map[string]string{
|
||||
"userName": p.userName,
|
||||
"password": p.password,
|
||||
})
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("encode Henan Lelian login request: %w", err)
|
||||
}
|
||||
responseBody, status, err := p.postJSON(ctx, p.loginPath, body, "")
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("request Henan Lelian login: %w", err)
|
||||
}
|
||||
if status < http.StatusOK || status >= http.StatusMultipleChoices {
|
||||
return "", fmt.Errorf("Henan Lelian login HTTP status %d: %s", status, strings.TrimSpace(string(responseBody)))
|
||||
}
|
||||
var output henanLelianResponse
|
||||
if err = json.Unmarshal(responseBody, &output); err != nil {
|
||||
return "", fmt.Errorf("decode Henan Lelian login response: %w", err)
|
||||
}
|
||||
code, err := parseHenanLelianCode(output.Code)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("decode Henan Lelian login code: %w", err)
|
||||
}
|
||||
if code != "0" {
|
||||
return "", fmt.Errorf("Henan Lelian login failed: code=%s, message=%s", code, output.Message)
|
||||
}
|
||||
token := strings.TrimSpace(output.Payload.AccessToken)
|
||||
if token == "" {
|
||||
return "", fmt.Errorf("Henan Lelian login access_token is empty")
|
||||
}
|
||||
p.platformToken = token
|
||||
p.platformTokenExp = now.Add(henanLelianPlatformTTL)
|
||||
return token, nil
|
||||
}
|
||||
|
||||
func (p *HenanLelianTokenProvider) fetchSharedToken(ctx context.Context, platformToken string) (string, bool, error) {
|
||||
if err := p.reserveShareAttempt(); err != nil {
|
||||
return "", false, err
|
||||
}
|
||||
body, err := json.Marshal(map[string]string{"passwayUid": p.passwayUID})
|
||||
if err != nil {
|
||||
return "", false, fmt.Errorf("encode Henan Lelian shared token request: %w", err)
|
||||
}
|
||||
responseBody, status, err := p.postJSON(ctx, p.path, body, platformToken)
|
||||
if err != nil {
|
||||
return "", false, fmt.Errorf("request Henan Lelian shared token: %w", err)
|
||||
}
|
||||
if status < http.StatusOK || status >= http.StatusMultipleChoices {
|
||||
authFailed := status == http.StatusUnauthorized || status == http.StatusForbidden
|
||||
return "", authFailed, fmt.Errorf("Henan Lelian shared token HTTP status %d: %s", status, strings.TrimSpace(string(responseBody)))
|
||||
}
|
||||
var output henanLelianResponse
|
||||
if err = json.Unmarshal(responseBody, &output); err != nil {
|
||||
return "", false, fmt.Errorf("decode Henan Lelian shared token response: %w", err)
|
||||
}
|
||||
code, err := parseHenanLelianCode(output.Code)
|
||||
if err != nil {
|
||||
return "", false, fmt.Errorf("decode Henan Lelian shared token code: %w", err)
|
||||
}
|
||||
if code != "0" {
|
||||
authFailed := isHenanLelianAuthFailure(code, output.Message)
|
||||
return "", authFailed, fmt.Errorf("Henan Lelian shared token failed: code=%s, message=%s", code, output.Message)
|
||||
}
|
||||
token := strings.TrimSpace(output.Payload.Token)
|
||||
if token == "" {
|
||||
return "", false, fmt.Errorf("Henan Lelian shared token is empty")
|
||||
}
|
||||
return token, false, nil
|
||||
}
|
||||
|
||||
func (p *HenanLelianTokenProvider) reserveShareAttempt() error {
|
||||
p.shareMu.Lock()
|
||||
defer p.shareMu.Unlock()
|
||||
|
||||
now := p.now()
|
||||
if now.Before(p.nextShareAt) {
|
||||
return fmt.Errorf("Henan Lelian shared token is rate limited until %s", p.nextShareAt.Format(time.RFC3339))
|
||||
}
|
||||
p.nextShareAt = now.Add(p.shareInterval)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *HenanLelianTokenProvider) clearPlatformToken(rejected string) {
|
||||
p.platformMu.Lock()
|
||||
if p.platformToken == rejected {
|
||||
p.platformToken = ""
|
||||
p.platformTokenExp = time.Time{}
|
||||
}
|
||||
p.platformMu.Unlock()
|
||||
}
|
||||
|
||||
func (p *HenanLelianTokenProvider) postJSON(ctx context.Context, path string, body []byte, platformToken string) ([]byte, int, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, joinProviderURL(p.baseURL, path), bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
req.Header.Set("Accept", "application/json")
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
if platformToken != "" {
|
||||
req.Header.Set("access_token", platformToken)
|
||||
}
|
||||
resp, err := p.client.Do(req)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
responseBody, err := io.ReadAll(resp.Body)
|
||||
return responseBody, resp.StatusCode, err
|
||||
}
|
||||
|
||||
func parseHenanLelianCode(raw json.RawMessage) (string, error) {
|
||||
var value string
|
||||
if err := json.Unmarshal(raw, &value); err == nil {
|
||||
return strings.TrimSpace(value), nil
|
||||
}
|
||||
var number json.Number
|
||||
if err := json.Unmarshal(raw, &number); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if _, err := strconv.ParseInt(number.String(), 10, 64); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return number.String(), nil
|
||||
}
|
||||
|
||||
func isHenanLelianAuthFailure(code, message string) bool {
|
||||
switch strings.TrimSpace(code) {
|
||||
case "401", "40101", "40102", "403":
|
||||
return true
|
||||
}
|
||||
message = strings.ToLower(strings.TrimSpace(message))
|
||||
return strings.Contains(message, "access_token") ||
|
||||
strings.Contains(message, "access token") ||
|
||||
strings.Contains(message, "token expired") ||
|
||||
strings.Contains(message, "token invalid") ||
|
||||
strings.Contains(message, "鉴权") ||
|
||||
strings.Contains(message, "认证失败")
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
package upstreamimpl
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/md5"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// henanXinjingClient is the direct HTTP client for the Henan Xinjing SIM
|
||||
// platform. Authentication is userId+apikey+times+sign where
|
||||
// sign = UPPERCASE(MD5("userId=<id>&apikey=<key>×=<unix_seconds>")).
|
||||
type henanXinjingClient struct {
|
||||
baseURL string
|
||||
userID string
|
||||
apiKey string
|
||||
httpClient *http.Client
|
||||
}
|
||||
|
||||
// henanXinjingResponse matches the platform's standard envelope.
|
||||
// Code can be either a JSON number or a JSON string (the platform
|
||||
// returns both shapes for the same logical value), so keep it raw.
|
||||
type henanXinjingResponse struct {
|
||||
Code json.RawMessage `json:"code"`
|
||||
Msg string `json:"msg"`
|
||||
Data json.RawMessage `json:"data"`
|
||||
}
|
||||
|
||||
func newHenanXinjingClient(baseURL, userID, apiKey string, timeout time.Duration) *henanXinjingClient {
|
||||
if timeout <= 0 {
|
||||
timeout = defaultUpstreamHTTPTimeout
|
||||
}
|
||||
return &henanXinjingClient{
|
||||
baseURL: strings.TrimRight(strings.TrimSpace(baseURL), "/"),
|
||||
userID: strings.TrimSpace(userID),
|
||||
apiKey: strings.TrimSpace(apiKey),
|
||||
httpClient: &http.Client{Timeout: timeout},
|
||||
}
|
||||
}
|
||||
|
||||
// buildSign returns UPPERCASE(MD5(...)) over the document-defined form:
|
||||
//
|
||||
// userId=<id>&apikey=<key>×=<unix_seconds>
|
||||
func henanXinjingSign(userID, apiKey, times string) string {
|
||||
raw := "userId=" + userID + "&apikey=" + apiKey + "×=" + times
|
||||
sum := md5.Sum([]byte(raw))
|
||||
return strings.ToUpper(hex.EncodeToString(sum[:]))
|
||||
}
|
||||
|
||||
// decodeCode normalizes the response code. The platform sometimes returns
|
||||
// `"code":0` and sometimes `"code":"-2"`. Both are valid; we treat any
|
||||
// numeric zero (in either form) as success.
|
||||
func henanXinjingDecodeCode(raw json.RawMessage) (int, error) {
|
||||
if len(raw) == 0 {
|
||||
return -1, fmt.Errorf("missing code field")
|
||||
}
|
||||
var n int
|
||||
if err := json.Unmarshal(raw, &n); err == nil {
|
||||
return n, nil
|
||||
}
|
||||
var s string
|
||||
if err := json.Unmarshal(raw, &s); err != nil {
|
||||
return -1, fmt.Errorf("code is neither number nor string: %s", string(raw))
|
||||
}
|
||||
s = strings.TrimSpace(s)
|
||||
if s == "" {
|
||||
return -1, fmt.Errorf("empty code string")
|
||||
}
|
||||
v, err := strconv.Atoi(s)
|
||||
if err != nil {
|
||||
return -1, fmt.Errorf("invalid code string %q: %w", s, err)
|
||||
}
|
||||
return v, nil
|
||||
}
|
||||
|
||||
// get performs a signed GET request and unmarshals the `data` field into out
|
||||
// when out is non-nil. Business-layer errors (code != 0) become errors.
|
||||
func (c *henanXinjingClient) get(ctx context.Context, path string, params map[string]string, out any) error {
|
||||
if c == nil || c.baseURL == "" || c.userID == "" || c.apiKey == "" {
|
||||
return fmt.Errorf("henan xinjing: baseURL, userID and apiKey are required")
|
||||
}
|
||||
values := url.Values{}
|
||||
for k, v := range params {
|
||||
if strings.TrimSpace(v) != "" {
|
||||
values.Set(k, v)
|
||||
}
|
||||
}
|
||||
times := strconv.FormatInt(time.Now().Unix(), 10)
|
||||
values.Set("userId", c.userID)
|
||||
values.Set("times", times)
|
||||
values.Set("sign", henanXinjingSign(c.userID, c.apiKey, times))
|
||||
|
||||
endpoint := c.baseURL + "/" + strings.TrimLeft(path, "/") + "?" + values.Encode()
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("henan xinjing: build request %s: %w", path, err)
|
||||
}
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("henan xinjing: request %s: %w", path, err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return fmt.Errorf("henan xinjing: read body %s: %w", path, err)
|
||||
}
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return fmt.Errorf("henan xinjing: HTTP %d from %s: %s", resp.StatusCode, path, string(body))
|
||||
}
|
||||
var envelope henanXinjingResponse
|
||||
if err = json.Unmarshal(body, &envelope); err != nil {
|
||||
return fmt.Errorf("henan xinjing: decode envelope %s: %w (body=%s)", path, err, string(body))
|
||||
}
|
||||
code, err := henanXinjingDecodeCode(envelope.Code)
|
||||
if err != nil {
|
||||
return fmt.Errorf("henan xinjing: %s: %w (body=%s)", path, err, string(body))
|
||||
}
|
||||
if code != 0 {
|
||||
return fmt.Errorf("henan xinjing: %s failed: code=%d msg=%s", path, code, envelope.Msg)
|
||||
}
|
||||
if out != nil && len(envelope.Data) > 0 && string(envelope.Data) != "null" {
|
||||
if err = json.Unmarshal(envelope.Data, out); err != nil {
|
||||
return fmt.Errorf("henan xinjing: decode data %s: %w (data=%s)", path, err, string(envelope.Data))
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
package upstreamimpl
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
sharedtoken "git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/token"
|
||||
)
|
||||
|
||||
// HenanXinjingTokenConfig carries the credentials needed to call the
|
||||
// platform's `/api/v1/searchToken` endpoint and convert the response into a
|
||||
// sharedtoken.OneLinkToken for the sharedtoken manager.
|
||||
//
|
||||
// The channel card-no is optional at construction time because the
|
||||
// underlying platform scopes tokens to (channel_id, channel_pname), not to
|
||||
// a specific card. When CardNo is empty the fetcher falls back to the
|
||||
// chanKey passed by the token manager (matching how zibo/tengyu providers
|
||||
// treat the per-channel seed value).
|
||||
type HenanXinjingTokenConfig struct {
|
||||
BaseURL string
|
||||
Path string // defaults to /api/v1/searchToken
|
||||
UserID string
|
||||
APIKey string
|
||||
ChannelID string // e.g. "80", "97", "194"
|
||||
ChannelPName string // e.g. "cmiotonelinkqx"
|
||||
CardNo string // optional seed card number; may be overridden by manager
|
||||
Timeout time.Duration
|
||||
}
|
||||
|
||||
// HenanXinjingTokenProvider fetches a shared token for a given
|
||||
// (userId, channel_id, channel_pname) triple from the Henan Xinjing
|
||||
// platform.
|
||||
type HenanXinjingTokenProvider struct {
|
||||
baseURL, path, userID, apiKey, channelID, channelPName, cardNo string
|
||||
client *http.Client
|
||||
}
|
||||
|
||||
// henanXinjingSearchTokenResponse matches the upstream response shape:
|
||||
//
|
||||
// {
|
||||
// "code": 0,
|
||||
// "msg": "查询成功",
|
||||
// "data": {
|
||||
// "id": 80,
|
||||
// "chl_username": "C5010371A16640006582238",
|
||||
// "accesstoken": "v1b74e73d741cea620f0ceecaf7a733fa6964aa53fffcbf8d3a5a4c8715aabe3",
|
||||
// "accesstoken_at": "2026-09-16 13:19:29"
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// `code` may be either a JSON number or string; decode via henanXinjingDecodeCode.
|
||||
type henanXinjingSearchTokenResponse struct {
|
||||
Code json.RawMessage `json:"code"`
|
||||
Msg string `json:"msg"`
|
||||
Data struct {
|
||||
ID json.RawMessage `json:"id"`
|
||||
ChlUsername string `json:"chl_username"`
|
||||
AccessToken string `json:"accesstoken"`
|
||||
AccessTokenAt string `json:"accesstoken_at"`
|
||||
AccessTokenSB string `json:"accesstokensb"`
|
||||
AccessTokenAlias string `json:"access_token"`
|
||||
} `json:"data"`
|
||||
}
|
||||
|
||||
const (
|
||||
henanXinjingSearchTokenPath = "/api/v1/searchToken"
|
||||
henanXinjingTokenExpiryLayout = "2006-01-02 15:04:05"
|
||||
henanXinjingTokenRefreshBackoff = 5 * time.Minute
|
||||
)
|
||||
|
||||
func NewHenanXinjingTokenProvider(config HenanXinjingTokenConfig) *HenanXinjingTokenProvider {
|
||||
timeout := config.Timeout
|
||||
if timeout <= 0 {
|
||||
timeout = defaultUpstreamHTTPTimeout
|
||||
}
|
||||
return &HenanXinjingTokenProvider{
|
||||
baseURL: strings.TrimRight(strings.TrimSpace(config.BaseURL), "/"),
|
||||
path: normalizeProviderPath(config.Path, henanXinjingSearchTokenPath),
|
||||
userID: strings.TrimSpace(config.UserID),
|
||||
apiKey: strings.TrimSpace(config.APIKey),
|
||||
channelID: strings.TrimSpace(config.ChannelID),
|
||||
channelPName: strings.TrimSpace(config.ChannelPName),
|
||||
cardNo: strings.TrimSpace(config.CardNo),
|
||||
client: &http.Client{Timeout: timeout},
|
||||
}
|
||||
}
|
||||
|
||||
// parseExpiry interprets the platform's `accesstoken_at` field. The
|
||||
// platform uses local China time without an explicit zone, so parse in
|
||||
// Asia/Shanghai.
|
||||
func henanXinjingParseExpiry(raw string) (time.Time, error) {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return time.Time{}, fmt.Errorf("empty accesstoken_at")
|
||||
}
|
||||
loc, err := time.LoadLocation("Asia/Shanghai")
|
||||
if err != nil {
|
||||
loc = time.Local
|
||||
}
|
||||
t, err := time.ParseInLocation(henanXinjingTokenExpiryLayout, raw, loc)
|
||||
if err != nil {
|
||||
return time.Time{}, fmt.Errorf("parse accesstoken_at %q: %w", raw, err)
|
||||
}
|
||||
return t, nil
|
||||
}
|
||||
|
||||
// firstToken returns the first non-empty token field. The platform has been
|
||||
// observed returning the token under `accesstoken`, but earlier docs
|
||||
// referenced `accesstokensb` / `access_token`; accept all three.
|
||||
func henanXinjingFirstToken(resp *henanXinjingSearchTokenResponse) string {
|
||||
if v := strings.TrimSpace(resp.Data.AccessToken); v != "" {
|
||||
return v
|
||||
}
|
||||
if v := strings.TrimSpace(resp.Data.AccessTokenSB); v != "" {
|
||||
return v
|
||||
}
|
||||
return strings.TrimSpace(resp.Data.AccessTokenAlias)
|
||||
}
|
||||
|
||||
// fetch performs the signed GET against /api/v1/searchToken for the given
|
||||
// card number. Card number may come from the manager's chanKey seed.
|
||||
func (p *HenanXinjingTokenProvider) fetch(ctx context.Context, cardNo string) (sharedtoken.OneLinkToken, error) {
|
||||
if p == nil || p.client == nil {
|
||||
return sharedtoken.OneLinkToken{}, fmt.Errorf("henan xinjing token provider is not initialized")
|
||||
}
|
||||
if p.baseURL == "" || p.userID == "" || p.apiKey == "" || p.channelID == "" || p.channelPName == "" {
|
||||
return sharedtoken.OneLinkToken{}, fmt.Errorf("henan xinjing: baseURL, userID, apiKey, channelID and channelPName are required")
|
||||
}
|
||||
cardNo = strings.TrimSpace(cardNo)
|
||||
if cardNo == "" {
|
||||
cardNo = p.cardNo
|
||||
}
|
||||
// cardno is optional on the real platform (verified live): the token is
|
||||
// scoped to (userId, channel_id, channel_pname), so omit the param when
|
||||
// no seed card number is configured instead of failing the fetch.
|
||||
|
||||
times := strconv.FormatInt(time.Now().Unix(), 10)
|
||||
values := url.Values{}
|
||||
values.Set("userId", p.userID)
|
||||
if cardNo != "" {
|
||||
values.Set("cardno", cardNo)
|
||||
}
|
||||
values.Set("channel_id", p.channelID)
|
||||
values.Set("channel_pname", p.channelPName)
|
||||
values.Set("times", times)
|
||||
values.Set("sign", henanXinjingSign(p.userID, p.apiKey, times))
|
||||
|
||||
endpoint := p.baseURL + p.path + "?" + values.Encode()
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
|
||||
if err != nil {
|
||||
return sharedtoken.OneLinkToken{}, fmt.Errorf("henan xinjing: build searchToken request: %w", err)
|
||||
}
|
||||
resp, err := p.client.Do(req)
|
||||
if err != nil {
|
||||
return sharedtoken.OneLinkToken{}, fmt.Errorf("henan xinjing: request searchToken: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return sharedtoken.OneLinkToken{}, fmt.Errorf("henan xinjing: read searchToken body: %w", err)
|
||||
}
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return sharedtoken.OneLinkToken{}, fmt.Errorf("henan xinjing: searchToken HTTP %d: %s", resp.StatusCode, string(body))
|
||||
}
|
||||
var output henanXinjingSearchTokenResponse
|
||||
if err := json.Unmarshal(body, &output); err != nil {
|
||||
return sharedtoken.OneLinkToken{}, fmt.Errorf("henan xinjing: decode searchToken response: %w (body=%s)", err, string(body))
|
||||
}
|
||||
code, err := henanXinjingDecodeCode(output.Code)
|
||||
if err != nil {
|
||||
return sharedtoken.OneLinkToken{}, fmt.Errorf("henan xinjing: searchToken: %w (body=%s)", err, string(body))
|
||||
}
|
||||
if code != 0 {
|
||||
return sharedtoken.OneLinkToken{}, fmt.Errorf("henan xinjing: searchToken failed: code=%d msg=%s", code, output.Msg)
|
||||
}
|
||||
tokenValue := henanXinjingFirstToken(&output)
|
||||
if tokenValue == "" {
|
||||
return sharedtoken.OneLinkToken{}, fmt.Errorf("henan xinjing: searchToken returned empty accesstoken")
|
||||
}
|
||||
expiresAt, err := henanXinjingParseExpiry(output.Data.AccessTokenAt)
|
||||
if err != nil {
|
||||
return sharedtoken.OneLinkToken{}, err
|
||||
}
|
||||
// Refresh slightly ahead of the declared expiry to absorb clock skew.
|
||||
if until := time.Until(expiresAt); until > henanXinjingTokenRefreshBackoff {
|
||||
expiresAt = expiresAt.Add(-henanXinjingTokenRefreshBackoff)
|
||||
}
|
||||
return sharedtoken.OneLinkToken{
|
||||
Value: tokenValue,
|
||||
CreatedAt: time.Now(),
|
||||
ExpiresAt: expiresAt,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Fetch satisfies the sharedtoken.Fetcher-style interface used by the
|
||||
// token manager. The chanKey argument is unused here because Henan Xinjing
|
||||
// scopes tokens to the (channel_id, channel_pname) pair rather than to a
|
||||
// specific chan_key; we always fetch with the configured cardNo.
|
||||
func (p *HenanXinjingTokenProvider) Fetch(ctx context.Context) (sharedtoken.OneLinkToken, error) {
|
||||
return p.fetch(ctx, "")
|
||||
}
|
||||
|
||||
// FetchToken matches the sharedtoken.TokenFetcher interface used by
|
||||
// sharedtoken.NewTokenManager. chanKey is the token manager's cache key
|
||||
// (typically the channel's chan_key); it does not influence the upstream
|
||||
// request but lets the manager cache one token per channel.
|
||||
func (p *HenanXinjingTokenProvider) FetchToken(ctx context.Context, _ string) (sharedtoken.OneLinkToken, error) {
|
||||
return p.fetch(ctx, "")
|
||||
}
|
||||
|
||||
// henanXinjingFetcher adapts HenanXinjingTokenProvider to the sharedtoken
|
||||
// fetcher shape used by RegisterTokenProviderFactory.
|
||||
type henanXinjingFetcher struct{ provider *HenanXinjingTokenProvider }
|
||||
|
||||
func (f henanXinjingFetcher) FetchToken(ctx context.Context, chanKey string) (sharedtoken.OneLinkToken, error) {
|
||||
return f.provider.fetch(ctx, "")
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
package upstreamimpl
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/model"
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultUpstreamHTTPTimeout = 10 * time.Second
|
||||
upstreamHTTPTimeoutKey = "dependencies.upstream_http.timeout"
|
||||
)
|
||||
|
||||
func templateConfigJSON(channelConfig *model.SimChannelConfigItemCache) string {
|
||||
if channelConfig == nil || channelConfig.Template == nil {
|
||||
return ""
|
||||
}
|
||||
return channelConfig.Template.TempConfig
|
||||
}
|
||||
|
||||
// parseUpstreamDuration accepts Go duration strings and positive numeric
|
||||
// seconds so template and application configuration use the same contract.
|
||||
func parseUpstreamDuration(raw json.RawMessage, field string) (time.Duration, error) {
|
||||
if len(raw) == 0 || string(raw) == "null" {
|
||||
return 0, fmt.Errorf("%s is empty", field)
|
||||
}
|
||||
var value any
|
||||
if err := json.Unmarshal(raw, &value); err != nil {
|
||||
return 0, fmt.Errorf("invalid %s: %w", field, err)
|
||||
}
|
||||
switch v := value.(type) {
|
||||
case float64:
|
||||
if v <= 0 || v != float64(int64(v)) {
|
||||
return 0, fmt.Errorf("invalid %s: expected positive seconds", field)
|
||||
}
|
||||
return time.Duration(v) * time.Second, nil
|
||||
case string:
|
||||
value := strings.TrimSpace(v)
|
||||
if value == "" {
|
||||
return 0, fmt.Errorf("invalid %s: empty value", field)
|
||||
}
|
||||
duration, err := time.ParseDuration(value)
|
||||
if err != nil || duration <= 0 {
|
||||
return 0, fmt.Errorf("invalid %s: expected positive duration", field)
|
||||
}
|
||||
return duration, nil
|
||||
default:
|
||||
return 0, fmt.Errorf("invalid %s: expected duration or seconds", field)
|
||||
}
|
||||
}
|
||||
|
||||
func parseUpstreamConfigDuration(value any, field string) (time.Duration, error) {
|
||||
raw, err := json.Marshal(value)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("encode %s: %w", field, err)
|
||||
}
|
||||
return parseUpstreamDuration(raw, field)
|
||||
}
|
||||
|
||||
func loadDefaultUpstreamHTTPTimeout(ctx context.Context) time.Duration {
|
||||
value := g.Cfg().MustGet(ctx, upstreamHTTPTimeoutKey, defaultUpstreamHTTPTimeout.String()).Val()
|
||||
timeout, err := parseUpstreamConfigDuration(value, upstreamHTTPTimeoutKey)
|
||||
if err != nil {
|
||||
g.Log().Warningf(ctx, "%v, fallback to %s", err, defaultUpstreamHTTPTimeout)
|
||||
return defaultUpstreamHTTPTimeout
|
||||
}
|
||||
return timeout
|
||||
}
|
||||
|
||||
// resolveUpstreamHTTPTimeout applies the single timeout override for a
|
||||
// template. Missing or invalid template values fall back to application
|
||||
// configuration and finally the code default.
|
||||
func resolveUpstreamHTTPTimeout(ctx context.Context, tempConfig string) time.Duration {
|
||||
defaultTimeout := loadDefaultUpstreamHTTPTimeout(ctx)
|
||||
if strings.TrimSpace(tempConfig) == "" {
|
||||
return defaultTimeout
|
||||
}
|
||||
var raw struct {
|
||||
HTTP struct {
|
||||
Timeout json.RawMessage `json:"timeout"`
|
||||
} `json:"http"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(tempConfig), &raw); err != nil {
|
||||
g.Log().Warningf(ctx, "parse upstream template config failed: %v, fallback to %s", err, defaultTimeout)
|
||||
return defaultTimeout
|
||||
}
|
||||
if len(raw.HTTP.Timeout) == 0 || string(raw.HTTP.Timeout) == "null" {
|
||||
return defaultTimeout
|
||||
}
|
||||
timeout, err := parseUpstreamDuration(raw.HTTP.Timeout, "http.timeout")
|
||||
if err != nil {
|
||||
g.Log().Warningf(ctx, "%v, fallback to %s", err, defaultTimeout)
|
||||
return defaultTimeout
|
||||
}
|
||||
return timeout
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
package upstreamimpl
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
sharedtoken "git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/token"
|
||||
)
|
||||
|
||||
// LanShangTokenConfig contains the endpoint from the template and credentials
|
||||
// from the channel configuration.
|
||||
type LanShangTokenConfig struct {
|
||||
BaseURL string
|
||||
Path string
|
||||
SubAccount string
|
||||
Secret string
|
||||
Timeout time.Duration
|
||||
}
|
||||
|
||||
type LanShangTokenProvider struct {
|
||||
baseURL string
|
||||
path string
|
||||
subAccount string
|
||||
secret string
|
||||
client *http.Client
|
||||
}
|
||||
|
||||
type lanShangTokenResponse struct {
|
||||
Token string `json:"token"`
|
||||
ExpireIn int `json:"expire_in"`
|
||||
}
|
||||
|
||||
type lanshangFetcher struct{ provider *LanShangTokenProvider }
|
||||
|
||||
func NewLanShangTokenProvider(config LanShangTokenConfig) *LanShangTokenProvider {
|
||||
timeout := config.Timeout
|
||||
if timeout <= 0 {
|
||||
timeout = defaultUpstreamHTTPTimeout
|
||||
}
|
||||
return &LanShangTokenProvider{
|
||||
baseURL: strings.TrimRight(strings.TrimSpace(config.BaseURL), "/"),
|
||||
path: normalizeProviderPath(config.Path, "/get_token"),
|
||||
subAccount: strings.TrimSpace(config.SubAccount),
|
||||
secret: config.Secret,
|
||||
client: &http.Client{Timeout: timeout},
|
||||
}
|
||||
}
|
||||
|
||||
func (f lanshangFetcher) FetchToken(ctx context.Context, _ string) (sharedtoken.OneLinkToken, error) {
|
||||
return f.provider.Fetch(ctx)
|
||||
}
|
||||
|
||||
func (p *LanShangTokenProvider) Fetch(ctx context.Context) (sharedtoken.OneLinkToken, error) {
|
||||
if p == nil || p.client == nil {
|
||||
return sharedtoken.OneLinkToken{}, fmt.Errorf("lanshang token provider is not initialized")
|
||||
}
|
||||
if p.baseURL == "" || p.subAccount == "" || p.secret == "" {
|
||||
return sharedtoken.OneLinkToken{}, fmt.Errorf("lanshang token endpoint and credentials are required")
|
||||
}
|
||||
|
||||
endpoint, err := url.Parse(joinProviderURL(p.baseURL, p.path))
|
||||
if err != nil {
|
||||
return sharedtoken.OneLinkToken{}, fmt.Errorf("parse lanshang token URL: %w", err)
|
||||
}
|
||||
query := endpoint.Query()
|
||||
query.Set("sub_account", p.subAccount)
|
||||
query.Set("secret", p.secret)
|
||||
endpoint.RawQuery = query.Encode()
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint.String(), nil)
|
||||
if err != nil {
|
||||
return sharedtoken.OneLinkToken{}, fmt.Errorf("create lanshang token request: %w", err)
|
||||
}
|
||||
req.Header.Set("Accept", "application/json")
|
||||
resp, err := p.client.Do(req)
|
||||
if err != nil {
|
||||
return sharedtoken.OneLinkToken{}, fmt.Errorf("request lanshang token: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return sharedtoken.OneLinkToken{}, fmt.Errorf("read lanshang token response: %w", err)
|
||||
}
|
||||
if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices {
|
||||
return sharedtoken.OneLinkToken{}, fmt.Errorf("lanshang token HTTP status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var output lanShangTokenResponse
|
||||
if err := json.Unmarshal(body, &output); err != nil {
|
||||
return sharedtoken.OneLinkToken{}, fmt.Errorf("decode lanshang token response: %w", err)
|
||||
}
|
||||
value := strings.TrimSpace(output.Token)
|
||||
if value == "" {
|
||||
return sharedtoken.OneLinkToken{}, fmt.Errorf("lanshang token is empty")
|
||||
}
|
||||
now := time.Now()
|
||||
expiresAt := time.Time{}
|
||||
if output.ExpireIn > 0 {
|
||||
expiresAt = now.Add(time.Duration(output.ExpireIn) * time.Second)
|
||||
}
|
||||
return sharedtoken.OneLinkToken{Value: value, CreatedAt: now, ExpiresAt: expiresAt}, nil
|
||||
}
|
||||
|
||||
func (p *LanShangTokenProvider) FetchToken(ctx context.Context, _ string) (sharedtoken.OneLinkToken, error) {
|
||||
return p.Fetch(ctx)
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package upstreamimpl
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestLanShangTokenProviderFetch(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/get_token" {
|
||||
t.Fatalf("path = %q", r.URL.Path)
|
||||
}
|
||||
if got := r.URL.Query().Get("sub_account"); got != "\u66fc\u76db\u7269\u8054\u7f51" {
|
||||
t.Fatalf("sub_account = %q", got)
|
||||
}
|
||||
if got := r.URL.Query().Get("secret"); got != "Qw@0371252" {
|
||||
t.Fatalf("secret = %q", got)
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"token":"abc123","expire_in":120}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
provider := NewLanShangTokenProvider(LanShangTokenConfig{
|
||||
BaseURL: server.URL, Path: "/get_token", SubAccount: "\u66fc\u76db\u7269\u8054\u7f51",
|
||||
Secret: "Qw@0371252", Timeout: time.Second,
|
||||
})
|
||||
token, err := provider.Fetch(context.Background())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if token.Value != "abc123" {
|
||||
t.Fatalf("token = %q", token.Value)
|
||||
}
|
||||
if token.ExpiresAt.Before(time.Now().Add(100 * time.Second)) {
|
||||
t.Fatalf("expiry too short: %s", token.ExpiresAt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLanShangTokenProviderRejectsEmptyToken(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = w.Write([]byte(`{"expire_in":120}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
provider := NewLanShangTokenProvider(LanShangTokenConfig{
|
||||
BaseURL: server.URL, SubAccount: "a", Secret: "b", Timeout: time.Second,
|
||||
})
|
||||
if _, err := provider.Fetch(context.Background()); err == nil {
|
||||
t.Fatal("expected empty token error")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
package upstreamimpl
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
sharedtoken "git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/token"
|
||||
)
|
||||
|
||||
// lanshuTokenPath is the fallback used when Path is empty. On the template
|
||||
// path this default is unreachable: parseSharedOneLinkConfig rejects an empty
|
||||
// token.path before any provider is built, so Path always carries the value
|
||||
// declared in temp_config. It only applies to direct construction (tests).
|
||||
// The same unreachable-fallback shape exists in every shared-token provider.
|
||||
const lanshuTokenPath = "/route/open/sharedToken"
|
||||
|
||||
// LanShuTokenConfig contains the endpoint from the template and the
|
||||
// credentials from the channel configuration. BaseURL and Path come from
|
||||
// temp_config token.baseUrl/token.path; the credentials come from chan_config.
|
||||
// Sign is a fixed value issued by the upstream and is passed through
|
||||
// unchanged: the operator does not derive it from appId/password/timeStamp.
|
||||
type LanShuTokenConfig struct {
|
||||
BaseURL string
|
||||
Path string
|
||||
AppID string
|
||||
Password string
|
||||
Sign string
|
||||
TokenType string
|
||||
Timeout time.Duration
|
||||
}
|
||||
|
||||
// LanShuTokenProvider fetches a shared token from the LanShu open route.
|
||||
type LanShuTokenProvider struct {
|
||||
baseURL string
|
||||
path string
|
||||
appID string
|
||||
password string
|
||||
sign string
|
||||
tokenType string
|
||||
client *http.Client
|
||||
}
|
||||
|
||||
// lanshuTokenRequest is the documented request envelope. Note the capitalized
|
||||
// Param/Verify keys.
|
||||
type lanshuTokenRequest struct {
|
||||
Param lanshuTokenParam `json:"Param"`
|
||||
Verify lanshuTokenVerify `json:"verify"`
|
||||
}
|
||||
|
||||
type lanshuTokenParam struct {
|
||||
TokenType string `json:"tokenType"`
|
||||
}
|
||||
|
||||
type lanshuTokenVerify struct {
|
||||
TimeStamp int64 `json:"timeStamp"`
|
||||
Password string `json:"password"`
|
||||
AppID string `json:"appId"`
|
||||
Sign string `json:"sign"`
|
||||
}
|
||||
|
||||
// lanshuTokenResponse mirrors the upstream envelope. The data object is
|
||||
// uppercase on the wire ("result.Data"), so the JSON tag must match exactly.
|
||||
type lanshuTokenResponse struct {
|
||||
Result struct {
|
||||
Data struct {
|
||||
ExpireTime int64 `json:"expireTime"`
|
||||
Token string `json:"token"`
|
||||
} `json:"Data"`
|
||||
} `json:"result"`
|
||||
Message string `json:"message"`
|
||||
Status string `json:"status"`
|
||||
}
|
||||
|
||||
func NewLanShuTokenProvider(config LanShuTokenConfig) *LanShuTokenProvider {
|
||||
timeout := config.Timeout
|
||||
if timeout <= 0 {
|
||||
timeout = defaultUpstreamHTTPTimeout
|
||||
}
|
||||
return &LanShuTokenProvider{
|
||||
baseURL: strings.TrimRight(strings.TrimSpace(config.BaseURL), "/"),
|
||||
path: normalizeProviderPath(config.Path, lanshuTokenPath),
|
||||
appID: strings.TrimSpace(config.AppID),
|
||||
password: config.Password,
|
||||
sign: strings.TrimSpace(config.Sign),
|
||||
tokenType: strings.TrimSpace(config.TokenType),
|
||||
client: &http.Client{Timeout: timeout},
|
||||
}
|
||||
}
|
||||
|
||||
func (p *LanShuTokenProvider) Fetch(ctx context.Context) (sharedtoken.OneLinkToken, error) {
|
||||
if p == nil || p.client == nil {
|
||||
return sharedtoken.OneLinkToken{}, fmt.Errorf("lanshu token provider is not initialized")
|
||||
}
|
||||
if p.baseURL == "" || p.appID == "" || p.password == "" || p.sign == "" || p.tokenType == "" {
|
||||
return sharedtoken.OneLinkToken{}, fmt.Errorf("lanshu baseUrl, appId, password, sign and tokenType are required")
|
||||
}
|
||||
|
||||
// The upstream does not validate timeStamp freshness, but it must be a
|
||||
// real millisecond timestamp for the request to be well-formed.
|
||||
payload, err := json.Marshal(lanshuTokenRequest{
|
||||
Param: lanshuTokenParam{TokenType: p.tokenType},
|
||||
Verify: lanshuTokenVerify{
|
||||
TimeStamp: time.Now().UnixMilli(),
|
||||
Password: p.password,
|
||||
AppID: p.appID,
|
||||
Sign: p.sign,
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return sharedtoken.OneLinkToken{}, fmt.Errorf("encode lanshu token request: %w", err)
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(
|
||||
ctx,
|
||||
http.MethodPost,
|
||||
joinProviderURL(p.baseURL, p.path),
|
||||
bytes.NewReader(payload),
|
||||
)
|
||||
if err != nil {
|
||||
return sharedtoken.OneLinkToken{}, fmt.Errorf("create lanshu token request: %w", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Accept", "application/json")
|
||||
|
||||
resp, err := p.client.Do(req)
|
||||
if err != nil {
|
||||
return sharedtoken.OneLinkToken{}, fmt.Errorf("request lanshu token: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(io.LimitReader(resp.Body, 4*1024*1024+1))
|
||||
if err != nil {
|
||||
return sharedtoken.OneLinkToken{}, fmt.Errorf("read lanshu token response: %w", err)
|
||||
}
|
||||
if len(body) > 4*1024*1024 {
|
||||
return sharedtoken.OneLinkToken{}, fmt.Errorf("lanshu token response is too large")
|
||||
}
|
||||
if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices {
|
||||
return sharedtoken.OneLinkToken{}, fmt.Errorf("lanshu token HTTP status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var output lanshuTokenResponse
|
||||
if err := json.Unmarshal(body, &output); err != nil {
|
||||
return sharedtoken.OneLinkToken{}, fmt.Errorf("decode lanshu token response: %w", err)
|
||||
}
|
||||
if status := strings.TrimSpace(output.Status); status != "0" {
|
||||
return sharedtoken.OneLinkToken{}, fmt.Errorf(
|
||||
"lanshu token failed: status=%s message=%s",
|
||||
status,
|
||||
strings.TrimSpace(output.Message),
|
||||
)
|
||||
}
|
||||
|
||||
value := strings.TrimSpace(output.Result.Data.Token)
|
||||
if value == "" {
|
||||
return sharedtoken.OneLinkToken{}, fmt.Errorf("lanshu token is empty")
|
||||
}
|
||||
// expireTime is reported in seconds (observed 1162~1565s), not
|
||||
// milliseconds, so the refresh-ahead window in the factory can rely on it.
|
||||
now := time.Now()
|
||||
var expiresAt time.Time
|
||||
if output.Result.Data.ExpireTime > 0 {
|
||||
expiresAt = now.Add(time.Duration(output.Result.Data.ExpireTime) * time.Second)
|
||||
}
|
||||
return sharedtoken.OneLinkToken{Value: value, CreatedAt: now, ExpiresAt: expiresAt}, nil
|
||||
}
|
||||
|
||||
func (p *LanShuTokenProvider) FetchToken(ctx context.Context, _ string) (sharedtoken.OneLinkToken, error) {
|
||||
return p.Fetch(ctx)
|
||||
}
|
||||
|
||||
// lanshuFetcher adapts LanShuTokenProvider to the shared token TokenFetcher
|
||||
// contract, mirroring qianxunFetcher.
|
||||
type lanshuFetcher struct{ provider *LanShuTokenProvider }
|
||||
|
||||
func (f lanshuFetcher) FetchToken(ctx context.Context, _ string) (sharedtoken.OneLinkToken, error) {
|
||||
return f.provider.Fetch(ctx)
|
||||
}
|
||||
@@ -0,0 +1,336 @@
|
||||
package upstreamimpl
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/model"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/model/entity"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/service"
|
||||
)
|
||||
|
||||
const (
|
||||
lanshuTestAppID = "HeNanPuWeiTongXun10W"
|
||||
lanshuTestPassword = "test-password"
|
||||
lanshuTestSign = "7F4F56AEF91980FEBEAA9EC28E958EAA"
|
||||
lanshuTestTokenType = "EC11_XY"
|
||||
lanshuTestToken = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
|
||||
)
|
||||
|
||||
func newLanshuTestProvider(t *testing.T, baseURL string) *LanShuTokenProvider {
|
||||
t.Helper()
|
||||
return NewLanShuTokenProvider(LanShuTokenConfig{
|
||||
BaseURL: baseURL, Path: "/route/open/sharedToken",
|
||||
AppID: lanshuTestAppID, Password: lanshuTestPassword,
|
||||
Sign: lanshuTestSign, TokenType: lanshuTestTokenType, Timeout: time.Second,
|
||||
})
|
||||
}
|
||||
|
||||
// TestLanShuTokenProviderSendRequestContract pins the exact wire shape that
|
||||
// the upstream expects: capitalized Param, verify.* fields passed through
|
||||
// verbatim, and a real millisecond timeStamp.
|
||||
func TestLanShuTokenProviderSendRequestContract(t *testing.T) {
|
||||
var (
|
||||
gotMethod string
|
||||
gotPath string
|
||||
gotContentType string
|
||||
gotBody []byte
|
||||
)
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
gotMethod = r.Method
|
||||
gotPath = r.URL.Path
|
||||
gotContentType = r.Header.Get("Content-Type")
|
||||
body, _ := io.ReadAll(r.Body)
|
||||
gotBody = body
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"result":{"Data":{"expireTime":1162,"token":"` + lanshuTestToken + `"}},"message":"success","status":"0"}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
provider := newLanshuTestProvider(t, server.URL)
|
||||
before := time.Now().UnixMilli()
|
||||
token, err := provider.Fetch(context.Background())
|
||||
after := time.Now().UnixMilli()
|
||||
if err != nil {
|
||||
t.Fatalf("fetch token: %v", err)
|
||||
}
|
||||
|
||||
if gotMethod != http.MethodPost {
|
||||
t.Fatalf("method = %q, want POST", gotMethod)
|
||||
}
|
||||
if gotPath != "/route/open/sharedToken" {
|
||||
t.Fatalf("path = %q", gotPath)
|
||||
}
|
||||
if gotContentType != "application/json" {
|
||||
t.Fatalf("Content-Type = %q", gotContentType)
|
||||
}
|
||||
|
||||
var payload struct {
|
||||
Param struct {
|
||||
TokenType string `json:"tokenType"`
|
||||
} `json:"Param"`
|
||||
Verify struct {
|
||||
TimeStamp int64 `json:"timeStamp"`
|
||||
Password string `json:"password"`
|
||||
AppID string `json:"appId"`
|
||||
Sign string `json:"sign"`
|
||||
} `json:"verify"`
|
||||
}
|
||||
if err := json.Unmarshal(gotBody, &payload); err != nil {
|
||||
t.Fatalf("decode request body %s: %v", gotBody, err)
|
||||
}
|
||||
if payload.Param.TokenType != lanshuTestTokenType {
|
||||
t.Fatalf("Param.tokenType = %q", payload.Param.TokenType)
|
||||
}
|
||||
if payload.Verify.AppID != lanshuTestAppID {
|
||||
t.Fatalf("verify.appId = %q", payload.Verify.AppID)
|
||||
}
|
||||
if payload.Verify.Password != lanshuTestPassword {
|
||||
t.Fatalf("verify.password = %q", payload.Verify.Password)
|
||||
}
|
||||
if payload.Verify.Sign != lanshuTestSign {
|
||||
t.Fatalf("verify.sign = %q (must be passed through verbatim)", payload.Verify.Sign)
|
||||
}
|
||||
if payload.Verify.TimeStamp < before || payload.Verify.TimeStamp > after {
|
||||
t.Fatalf("verify.timeStamp = %d, want millis in [%d, %d]", payload.Verify.TimeStamp, before, after)
|
||||
}
|
||||
|
||||
if token.Value != lanshuTestToken {
|
||||
t.Fatalf("token = %q", token.Value)
|
||||
}
|
||||
// expireTime is seconds, so the expiry must land near now+1162s rather
|
||||
// than now+1162ms.
|
||||
wantExpiry := time.Now().Add(1162 * time.Second)
|
||||
if diff := token.ExpiresAt.Sub(wantExpiry); diff > 5*time.Second || diff < -5*time.Second {
|
||||
t.Fatalf("ExpiresAt = %s, want ~%s", token.ExpiresAt, wantExpiry)
|
||||
}
|
||||
if token.CreatedAt.IsZero() {
|
||||
t.Fatal("CreatedAt must be set")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLanShuTokenProviderRejectsBusinessFailure(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"result":{},"message":"未找到对应token获取","status":"500"}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
provider := newLanshuTestProvider(t, server.URL)
|
||||
token, err := provider.Fetch(context.Background())
|
||||
if err == nil {
|
||||
t.Fatalf("expected error for status=500, got token %q", token.Value)
|
||||
}
|
||||
if token.Value != "" {
|
||||
t.Fatalf("token must not be returned on failure, got %q", token.Value)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLanShuTokenProviderRejectsEmptyToken(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"result":{"Data":{"expireTime":1162,"token":""}},"message":"success","status":"0"}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
provider := newLanshuTestProvider(t, server.URL)
|
||||
if _, err := provider.Fetch(context.Background()); err == nil {
|
||||
t.Fatal("expected empty token error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLanShuTokenProviderRequiresCredentials(t *testing.T) {
|
||||
// The base URL points at an unroutable TEST-NET-1 address, so a missing
|
||||
// validation guard would surface as a dial timeout instead of the
|
||||
// credential error this test asserts on.
|
||||
const deadURL = "http://192.0.2.1:1"
|
||||
cases := map[string]LanShuTokenConfig{
|
||||
"missing sign": {
|
||||
BaseURL: deadURL, Path: "/route/open/sharedToken",
|
||||
AppID: lanshuTestAppID, Password: lanshuTestPassword, TokenType: lanshuTestTokenType,
|
||||
},
|
||||
"missing password": {
|
||||
BaseURL: deadURL, Path: "/route/open/sharedToken",
|
||||
AppID: lanshuTestAppID, Sign: lanshuTestSign, TokenType: lanshuTestTokenType,
|
||||
},
|
||||
"missing appId": {
|
||||
BaseURL: deadURL, Path: "/route/open/sharedToken",
|
||||
Password: lanshuTestPassword, Sign: lanshuTestSign, TokenType: lanshuTestTokenType,
|
||||
},
|
||||
"missing tokenType": {
|
||||
BaseURL: deadURL, Path: "/route/open/sharedToken",
|
||||
AppID: lanshuTestAppID, Password: lanshuTestPassword, Sign: lanshuTestSign,
|
||||
},
|
||||
"missing baseUrl": {
|
||||
Path: "/route/open/sharedToken", AppID: lanshuTestAppID,
|
||||
Password: lanshuTestPassword, Sign: lanshuTestSign, TokenType: lanshuTestTokenType,
|
||||
},
|
||||
}
|
||||
for name, config := range cases {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
provider := NewLanShuTokenProvider(config)
|
||||
start := time.Now()
|
||||
_, err := provider.Fetch(context.Background())
|
||||
if err == nil {
|
||||
t.Fatal("expected credential validation error")
|
||||
}
|
||||
// The guard must reject before any HTTP round trip.
|
||||
if elapsed := time.Since(start); elapsed > time.Second {
|
||||
t.Fatalf("validation must not hit the network, took %s", elapsed)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "lanshu baseUrl, appId, password, sign and tokenType are required") {
|
||||
t.Fatalf("error = %v, want the credential guard message", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestLanShuTemplateRegistration pins that a provider=lanshu template
|
||||
// instantiates with a chan_config carrying the four required credentials.
|
||||
func TestLanShuTemplateRegistration(t *testing.T) {
|
||||
template, ok := service.UpstreamAPI().GetTemp(sharedOneLinkLanshuTemplateKey)
|
||||
if !ok || template == nil {
|
||||
t.Fatal("lanshu template not registered")
|
||||
}
|
||||
|
||||
config := &model.SimChannelConfigItemCache{
|
||||
Template: &entity.TSimChannelTemplate{
|
||||
TempKey: sharedOneLinkLanshuTemplateKey,
|
||||
TempConfig: `{"token":{"provider":"lanshu","baseUrl":"http://api.jswl999.com",` +
|
||||
`"path":"/route/open/sharedToken"},"api":{"baseUrl":"https://api.iot.10086.cn/v5/","timeout":"1s"}}`,
|
||||
},
|
||||
Channel: &entity.TSimChannels{
|
||||
ChanKey: "LSH-001",
|
||||
ChanConfig: `{"appId":"` + lanshuTestAppID + `","password":"` + lanshuTestPassword +
|
||||
`","sign":"` + lanshuTestSign + `","tokenType":"` + lanshuTestTokenType + `"}`,
|
||||
},
|
||||
}
|
||||
if instance := template.NewTemp(context.Background(), config); instance == nil {
|
||||
t.Fatal("lanshu template failed to instantiate")
|
||||
}
|
||||
}
|
||||
|
||||
// TestLanShuTokenPathComesFromTemplate pins the answer to "is the token path
|
||||
// read from the template, or hardcoded in the provider?": the provider's
|
||||
// normalizeProviderPath fallback is unreachable on the template path, so the
|
||||
// request must land on whatever token.path the template declares. The path
|
||||
// used here deliberately differs from lanshuTokenPath, so a hardcoded default
|
||||
// would fail this assertion.
|
||||
//
|
||||
// This stops one layer below the token manager: going through GetSharedToken
|
||||
// would also exercise the DB-backed persister, which is out of scope here.
|
||||
func TestLanShuTokenPathComesFromTemplate(t *testing.T) {
|
||||
// A path that is deliberately NOT lanshuTokenPath.
|
||||
const templatePath = "/some/other/open/route"
|
||||
if templatePath == lanshuTokenPath {
|
||||
t.Fatal("test precondition broken: templatePath must differ from lanshuTokenPath")
|
||||
}
|
||||
|
||||
var gotPath string
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
gotPath = r.URL.Path
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"result":{"Data":{"expireTime":600,"token":"` + lanshuTestToken + `"}},"message":"success","status":"0"}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
channelConfig := &model.SimChannelConfigItemCache{
|
||||
Template: &entity.TSimChannelTemplate{
|
||||
TempKey: sharedOneLinkLanshuTemplateKey,
|
||||
TempConfig: `{"token":{"provider":"lanshu","baseUrl":"` + server.URL + `",` +
|
||||
`"path":"` + templatePath + `"}}`,
|
||||
},
|
||||
Channel: &entity.TSimChannels{
|
||||
ChanKey: "LSH-001",
|
||||
ChanConfig: `{"appId":"` + lanshuTestAppID + `","password":"` + lanshuTestPassword +
|
||||
`","sign":"` + lanshuTestSign + `","tokenType":"` + lanshuTestTokenType + `"}`,
|
||||
},
|
||||
}
|
||||
|
||||
// The parsed config must carry the template path, not the fallback.
|
||||
cfg, _, err := parseSharedOneLinkConfig(context.Background(), channelConfig)
|
||||
if err != nil {
|
||||
t.Fatalf("parse config: %v", err)
|
||||
}
|
||||
if cfg.TokenPath != templatePath {
|
||||
t.Fatalf("parsed TokenPath = %q, want the template-declared %q", cfg.TokenPath, templatePath)
|
||||
}
|
||||
|
||||
// And the provider built from it must issue the request to that path.
|
||||
provider := NewLanShuTokenProvider(LanShuTokenConfig{
|
||||
BaseURL: cfg.TokenBaseURL, Path: cfg.TokenPath, AppID: cfg.AppID,
|
||||
Password: cfg.Password, Sign: cfg.Sign, TokenType: cfg.TokenType, Timeout: cfg.Timeout,
|
||||
})
|
||||
if _, err := provider.Fetch(context.Background()); err != nil {
|
||||
t.Fatalf("fetch token: %v", err)
|
||||
}
|
||||
if gotPath != templatePath {
|
||||
t.Fatalf("request path = %q, want the template-declared %q", gotPath, templatePath)
|
||||
}
|
||||
}
|
||||
|
||||
// TestLanShuTokenPathIsRequiredFromTemplate pins that a template without
|
||||
// token.path is rejected rather than silently falling back to a default.
|
||||
func TestLanShuTokenPathIsRequiredFromTemplate(t *testing.T) {
|
||||
config := &model.SimChannelConfigItemCache{
|
||||
Template: &entity.TSimChannelTemplate{
|
||||
TempKey: sharedOneLinkLanshuTemplateKey,
|
||||
TempConfig: `{"token":{"provider":"lanshu","baseUrl":"http://api.jswl999.com"}}`,
|
||||
},
|
||||
Channel: &entity.TSimChannels{
|
||||
ChanKey: "LSH-001",
|
||||
ChanConfig: `{"appId":"` + lanshuTestAppID + `","password":"` + lanshuTestPassword +
|
||||
`","sign":"` + lanshuTestSign + `","tokenType":"` + lanshuTestTokenType + `"}`,
|
||||
},
|
||||
}
|
||||
if _, _, err := parseSharedOneLinkConfig(context.Background(), config); err == nil {
|
||||
t.Fatal("missing token.path must be rejected")
|
||||
}
|
||||
}
|
||||
|
||||
// TestLanShuChannelConfigRejectsIncompleteCredentials pins the parse-time
|
||||
// guard so a channel missing chan_config.sign cannot silently build a
|
||||
// provider that fails on every request.
|
||||
func TestLanShuChannelConfigRejectsIncompleteCredentials(t *testing.T) {
|
||||
base := func(chanConfig string) *model.SimChannelConfigItemCache {
|
||||
return &model.SimChannelConfigItemCache{
|
||||
Template: &entity.TSimChannelTemplate{
|
||||
TempKey: sharedOneLinkLanshuTemplateKey,
|
||||
TempConfig: `{"token":{"provider":"lanshu","baseUrl":"http://api.jswl999.com",` +
|
||||
`"path":"/route/open/sharedToken"}}`,
|
||||
},
|
||||
Channel: &entity.TSimChannels{ChanKey: "LSH-001", ChanConfig: chanConfig},
|
||||
}
|
||||
}
|
||||
|
||||
complete := `{"appId":"` + lanshuTestAppID + `","password":"` + lanshuTestPassword +
|
||||
`","sign":"` + lanshuTestSign + `","tokenType":"` + lanshuTestTokenType + `"}`
|
||||
parsed, _, err := parseSharedOneLinkConfig(context.Background(), base(complete))
|
||||
if err != nil {
|
||||
t.Fatalf("complete chan_config must parse: %v", err)
|
||||
}
|
||||
if parsed.Sign != lanshuTestSign {
|
||||
t.Fatalf("parsed sign = %q", parsed.Sign)
|
||||
}
|
||||
if parsed.TokenType != lanshuTestTokenType {
|
||||
t.Fatalf("parsed tokenType = %q", parsed.TokenType)
|
||||
}
|
||||
|
||||
for name, incomplete := range map[string]string{
|
||||
"missing sign": `{"appId":"` + lanshuTestAppID + `","password":"` + lanshuTestPassword + `","tokenType":"` + lanshuTestTokenType + `"}`,
|
||||
"missing password": `{"appId":"` + lanshuTestAppID + `","sign":"` + lanshuTestSign + `","tokenType":"` + lanshuTestTokenType + `"}`,
|
||||
"missing tokenType": `{"appId":"` + lanshuTestAppID + `","password":"` + lanshuTestPassword + `","sign":"` + lanshuTestSign + `"}`,
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
if _, _, err := parseSharedOneLinkConfig(context.Background(), base(incomplete)); err == nil {
|
||||
t.Fatal("expected chan_config validation error")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package upstreamimpl_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
_ "git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/logic"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/model"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/model/entity"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/service"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestMingqianRegistration(t *testing.T) {
|
||||
template, ok := service.UpstreamAPI().GetTemp("sim_mingqian_v3")
|
||||
if !ok || template == nil {
|
||||
t.Fatal("mingqian template not registered")
|
||||
}
|
||||
cfg := &model.SimChannelConfigItemCache{Template: &entity.TSimChannelTemplate{}, Channel: &entity.TSimChannels{AppKey: "test", AppSecret: "test"}}
|
||||
if instance := template.NewTemp(context.Background(), cfg); instance == nil {
|
||||
t.Fatal("mingqian instance creation failed")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
package upstreamimpl
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/md5"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
sharedtoken "git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/token"
|
||||
)
|
||||
|
||||
const mobilePublicTokenPath = "/api/get/yd/api/msg"
|
||||
|
||||
// MobilePublicTokenConfig contains the credentials used to request a shared
|
||||
// China Mobile OneLink token. Secret is sourced from t_sim_channels.app_secret.
|
||||
type MobilePublicTokenConfig struct {
|
||||
BaseURL string
|
||||
Path string
|
||||
UserID string
|
||||
Secret string
|
||||
Timeout time.Duration
|
||||
}
|
||||
|
||||
// MobilePublicTokenProvider fetches a shared OneLink token from the mobile
|
||||
// public-token upstream.
|
||||
type MobilePublicTokenProvider struct {
|
||||
baseURL string
|
||||
path string
|
||||
userID string
|
||||
secret string
|
||||
client *http.Client
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
type mobilePublicTokenResponse struct {
|
||||
Code int `json:"code"`
|
||||
Msg string `json:"msg"`
|
||||
Data struct {
|
||||
Token string `json:"token"`
|
||||
ExpireTime int64 `json:"expire_time"`
|
||||
} `json:"data"`
|
||||
}
|
||||
|
||||
func NewMobilePublicTokenProvider(config MobilePublicTokenConfig) *MobilePublicTokenProvider {
|
||||
timeout := config.Timeout
|
||||
if timeout <= 0 {
|
||||
timeout = defaultUpstreamHTTPTimeout
|
||||
}
|
||||
return &MobilePublicTokenProvider{
|
||||
baseURL: strings.TrimRight(strings.TrimSpace(config.BaseURL), "/"),
|
||||
path: normalizeProviderPath(config.Path, mobilePublicTokenPath),
|
||||
userID: strings.TrimSpace(config.UserID),
|
||||
secret: config.Secret,
|
||||
client: &http.Client{Timeout: timeout},
|
||||
now: time.Now,
|
||||
}
|
||||
}
|
||||
|
||||
func mobilePublicSign(secret, userID string, timestamp int64) string {
|
||||
source := secret + userID + strconv.FormatInt(timestamp, 10) + secret
|
||||
sum := md5.Sum([]byte(source))
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
func (p *MobilePublicTokenProvider) Fetch(ctx context.Context) (sharedtoken.OneLinkToken, error) {
|
||||
if p == nil || p.client == nil || p.now == nil {
|
||||
return sharedtoken.OneLinkToken{}, fmt.Errorf("mobile public token provider is not initialized")
|
||||
}
|
||||
if p.baseURL == "" {
|
||||
return sharedtoken.OneLinkToken{}, fmt.Errorf("mobile public token base URL is required")
|
||||
}
|
||||
if p.userID == "" || p.secret == "" {
|
||||
return sharedtoken.OneLinkToken{}, fmt.Errorf("mobile public token userId and secret are required")
|
||||
}
|
||||
userID, err := strconv.ParseInt(p.userID, 10, 64)
|
||||
if err != nil || userID <= 0 {
|
||||
return sharedtoken.OneLinkToken{}, fmt.Errorf("mobile public token userId must be a positive integer")
|
||||
}
|
||||
|
||||
now := p.now()
|
||||
payload := struct {
|
||||
UserID int64 `json:"userid"`
|
||||
Time int64 `json:"time"`
|
||||
Sign string `json:"sign"`
|
||||
}{
|
||||
UserID: userID,
|
||||
Time: now.Unix(),
|
||||
Sign: mobilePublicSign(p.secret, strconv.FormatInt(userID, 10), now.Unix()),
|
||||
}
|
||||
body, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return sharedtoken.OneLinkToken{}, fmt.Errorf("encode mobile public token request: %w", err)
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, joinProviderURL(p.baseURL, p.path), strings.NewReader(string(body)))
|
||||
if err != nil {
|
||||
return sharedtoken.OneLinkToken{}, fmt.Errorf("create mobile public token request: %w", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Accept", "application/json")
|
||||
resp, err := p.client.Do(req)
|
||||
if err != nil {
|
||||
return sharedtoken.OneLinkToken{}, fmt.Errorf("request mobile public token: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
responseBody, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return sharedtoken.OneLinkToken{}, fmt.Errorf("read mobile public token response: %w", err)
|
||||
}
|
||||
if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices {
|
||||
return sharedtoken.OneLinkToken{}, fmt.Errorf("mobile public token HTTP status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var output mobilePublicTokenResponse
|
||||
if err := json.Unmarshal(responseBody, &output); err != nil {
|
||||
return sharedtoken.OneLinkToken{}, fmt.Errorf("decode mobile public token response: %w", err)
|
||||
}
|
||||
if output.Code != http.StatusOK {
|
||||
return sharedtoken.OneLinkToken{}, fmt.Errorf("mobile public token failed: code=%d msg=%s", output.Code, strings.TrimSpace(output.Msg))
|
||||
}
|
||||
value := strings.TrimSpace(output.Data.Token)
|
||||
if value == "" {
|
||||
return sharedtoken.OneLinkToken{}, fmt.Errorf("mobile public token is empty")
|
||||
}
|
||||
expiresAt := time.Unix(output.Data.ExpireTime, 0)
|
||||
if output.Data.ExpireTime <= 0 || !expiresAt.After(now) {
|
||||
return sharedtoken.OneLinkToken{}, fmt.Errorf("mobile public token expire_time must be in the future: %d", output.Data.ExpireTime)
|
||||
}
|
||||
return sharedtoken.OneLinkToken{Value: value, CreatedAt: now, ExpiresAt: expiresAt}, nil
|
||||
}
|
||||
|
||||
func (p *MobilePublicTokenProvider) FetchToken(ctx context.Context, _ string) (sharedtoken.OneLinkToken, error) {
|
||||
return p.Fetch(ctx)
|
||||
}
|
||||
|
||||
type mobilePublicFetcher struct{ provider *MobilePublicTokenProvider }
|
||||
|
||||
func (f mobilePublicFetcher) FetchToken(ctx context.Context, _ string) (sharedtoken.OneLinkToken, error) {
|
||||
return f.provider.Fetch(ctx)
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
package upstreamimpl
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/model"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/model/entity"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/service"
|
||||
)
|
||||
|
||||
func TestMobilePublicTemplateRegistration(t *testing.T) {
|
||||
for _, templateKey := range []string{
|
||||
sharedOneLinkMobilePublicTemplateKey,
|
||||
sharedOneLinkMobilePublicPreviousKey,
|
||||
sharedOneLinkMobilePublicLegacyKey,
|
||||
} {
|
||||
t.Run(templateKey, func(t *testing.T) {
|
||||
template, ok := service.UpstreamAPI().GetTemp(templateKey)
|
||||
if !ok || template == nil {
|
||||
t.Fatal("mobile public template not registered")
|
||||
}
|
||||
config := &model.SimChannelConfigItemCache{
|
||||
Template: &entity.TSimChannelTemplate{
|
||||
TempKey: templateKey,
|
||||
TempConfig: `{"token":{"provider":"mobile_public","baseUrl":"https://example.com","path":"/api/get/yd/api/msg"}}`,
|
||||
},
|
||||
Channel: &entity.TSimChannels{
|
||||
ChanKey: "daliuliang-test",
|
||||
ChanConfig: `{"userId":"102369"}`,
|
||||
AppSecret: "test-secret",
|
||||
},
|
||||
}
|
||||
if instance := template.NewTemp(context.Background(), config); instance == nil {
|
||||
t.Fatal("mobile public channel failed to instantiate")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestMobilePublicSign(t *testing.T) {
|
||||
const timestamp = int64(1699606000)
|
||||
if got, want := mobilePublicSign("bbb", "aaa", timestamp), "2337f8d0d5c0cd9e415a3b3c27c4a595"; got != want {
|
||||
t.Fatalf("sign = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMobilePublicTokenProviderFetch(t *testing.T) {
|
||||
now := time.Unix(1700000000, 0)
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost || r.URL.Path != mobilePublicTokenPath {
|
||||
t.Fatalf("request = %s %s", r.Method, r.URL.Path)
|
||||
}
|
||||
var input struct {
|
||||
UserID int64 `json:"userid"`
|
||||
Time int64 `json:"time"`
|
||||
Sign string `json:"sign"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if input.UserID != 102369 || input.Time != now.Unix() {
|
||||
t.Fatalf("payload = %#v", input)
|
||||
}
|
||||
if want := mobilePublicSign("secret", "102369", input.Time); input.Sign != want {
|
||||
t.Fatalf("sign = %q, want %q", input.Sign, want)
|
||||
}
|
||||
_, _ = w.Write([]byte(`{"code":200,"msg":"success","data":{"token":"token-1","expire_time":1700003600}}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
provider := NewMobilePublicTokenProvider(MobilePublicTokenConfig{
|
||||
BaseURL: server.URL, UserID: "102369", Secret: "secret", Timeout: time.Second,
|
||||
})
|
||||
provider.now = func() time.Time { return now }
|
||||
token, err := provider.Fetch(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("fetch token: %v", err)
|
||||
}
|
||||
if token.Value != "token-1" || !token.ExpiresAt.Equal(time.Unix(1700003600, 0)) {
|
||||
t.Fatalf("token = %#v", token)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMobilePublicTokenProviderRejectsExpiredToken(t *testing.T) {
|
||||
now := time.Unix(1700000000, 0)
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = w.Write([]byte(`{"code":200,"data":{"token":"token-1","expire_time":1699999999}}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
provider := NewMobilePublicTokenProvider(MobilePublicTokenConfig{
|
||||
BaseURL: server.URL, UserID: "102369", Secret: "secret", Timeout: time.Second,
|
||||
})
|
||||
provider.now = func() time.Time { return now }
|
||||
if _, err := provider.Fetch(context.Background()); err == nil {
|
||||
t.Fatal("expected expired token error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMobilePublicTokenProviderRejectsNonNumericUserID(t *testing.T) {
|
||||
provider := NewMobilePublicTokenProvider(MobilePublicTokenConfig{
|
||||
BaseURL: "http://127.0.0.1", UserID: "not-a-number", Secret: "secret", Timeout: time.Second,
|
||||
})
|
||||
if _, err := provider.Fetch(context.Background()); err == nil {
|
||||
t.Fatal("expected invalid userId error")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
package upstreamimpl
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/md5"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
sharedtoken "git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/token"
|
||||
)
|
||||
|
||||
type QianxunTokenConfig struct {
|
||||
BaseURL string
|
||||
Path string
|
||||
OperatorID string
|
||||
AppID string
|
||||
TokenType string
|
||||
Key string
|
||||
Timeout time.Duration
|
||||
}
|
||||
|
||||
type QianxunTokenProvider struct {
|
||||
baseURL, path, operatorID, appID, tokenType, key string
|
||||
client *http.Client
|
||||
}
|
||||
|
||||
type qianxunTokenResponse struct {
|
||||
Code int `json:"code"`
|
||||
Msg string `json:"msg"`
|
||||
Data struct {
|
||||
Token string `json:"token"`
|
||||
ExpireDate int64 `json:"expireDate"`
|
||||
} `json:"data"`
|
||||
}
|
||||
|
||||
func NewQianxunTokenProvider(config QianxunTokenConfig) *QianxunTokenProvider {
|
||||
timeout := config.Timeout
|
||||
if timeout <= 0 {
|
||||
timeout = defaultUpstreamHTTPTimeout
|
||||
}
|
||||
return &QianxunTokenProvider{
|
||||
baseURL: strings.TrimRight(strings.TrimSpace(config.BaseURL), "/"),
|
||||
path: normalizeProviderPath(config.Path, "/"), operatorID: strings.TrimSpace(config.OperatorID),
|
||||
appID: strings.TrimSpace(config.AppID), tokenType: strings.TrimSpace(config.TokenType), key: config.Key,
|
||||
client: &http.Client{Timeout: timeout},
|
||||
}
|
||||
}
|
||||
|
||||
func qianxunSharedSign(operatorID, appID, key string) string {
|
||||
sum := md5.Sum([]byte(operatorID + appID + key))
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
func (p *QianxunTokenProvider) Fetch(ctx context.Context) (sharedtoken.OneLinkToken, error) {
|
||||
if p == nil || p.client == nil {
|
||||
return sharedtoken.OneLinkToken{}, fmt.Errorf("qianxun token provider is not initialized")
|
||||
}
|
||||
if p.baseURL == "" || p.operatorID == "" || p.appID == "" || p.tokenType == "" || p.key == "" {
|
||||
return sharedtoken.OneLinkToken{}, fmt.Errorf("qianxun token endpoint and credentials are required")
|
||||
}
|
||||
payload := map[string]string{"operatorId": p.operatorID, "appId": p.appID, "tokenType": p.tokenType, "sign": qianxunSharedSign(p.operatorID, p.appID, p.key)}
|
||||
body, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return sharedtoken.OneLinkToken{}, fmt.Errorf("encode qianxun token request: %w", err)
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, joinProviderURL(p.baseURL, p.path), strings.NewReader(string(body)))
|
||||
if err != nil {
|
||||
return sharedtoken.OneLinkToken{}, fmt.Errorf("create qianxun token request: %w", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Accept", "application/json")
|
||||
resp, err := p.client.Do(req)
|
||||
if err != nil {
|
||||
return sharedtoken.OneLinkToken{}, fmt.Errorf("request qianxun token: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
responseBody, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return sharedtoken.OneLinkToken{}, fmt.Errorf("read qianxun token response: %w", err)
|
||||
}
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return sharedtoken.OneLinkToken{}, fmt.Errorf("qianxun token HTTP status %d", resp.StatusCode)
|
||||
}
|
||||
var output qianxunTokenResponse
|
||||
if err := json.Unmarshal(responseBody, &output); err != nil {
|
||||
return sharedtoken.OneLinkToken{}, fmt.Errorf("decode qianxun token response: %w", err)
|
||||
}
|
||||
if output.Code != 0 {
|
||||
return sharedtoken.OneLinkToken{}, fmt.Errorf("qianxun token failed: code=%d msg=%s", output.Code, output.Msg)
|
||||
}
|
||||
value := strings.TrimSpace(output.Data.Token)
|
||||
if value == "" {
|
||||
return sharedtoken.OneLinkToken{}, fmt.Errorf("qianxun token is empty")
|
||||
}
|
||||
now := time.Now()
|
||||
var expiresAt time.Time
|
||||
if output.Data.ExpireDate > 0 {
|
||||
expiresAt = now.Add(time.Duration(output.Data.ExpireDate) * time.Second)
|
||||
}
|
||||
return sharedtoken.OneLinkToken{Value: value, CreatedAt: now, ExpiresAt: expiresAt}, nil
|
||||
}
|
||||
|
||||
func (p *QianxunTokenProvider) FetchToken(ctx context.Context, _ string) (sharedtoken.OneLinkToken, error) {
|
||||
return p.Fetch(ctx)
|
||||
}
|
||||
|
||||
type qianxunFetcher struct{ provider *QianxunTokenProvider }
|
||||
|
||||
func (f qianxunFetcher) FetchToken(ctx context.Context, _ string) (sharedtoken.OneLinkToken, error) {
|
||||
return f.provider.Fetch(ctx)
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package upstreamimpl
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestQianxunTokenProviderAcceptsTokenWithoutExpiry(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost || r.URL.Path != "/oauth/access_token" {
|
||||
t.Fatalf("request = %s %s", r.Method, r.URL.Path)
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"code":0,"msg":"success","data":{"token":"token-1","expireDate":0}}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
provider := NewQianxunTokenProvider(QianxunTokenConfig{
|
||||
BaseURL: server.URL, Path: "/oauth/access_token", OperatorID: "operator-1",
|
||||
AppID: "app-1", TokenType: "card", Key: "key-1", Timeout: time.Second,
|
||||
})
|
||||
token, err := provider.Fetch(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("fetch token: %v", err)
|
||||
}
|
||||
if token.Value != "token-1" {
|
||||
t.Fatalf("token = %q", token.Value)
|
||||
}
|
||||
if !token.ExpiresAt.IsZero() {
|
||||
t.Fatalf("expireDate=0 must produce a token without expiry: %s", token.ExpiresAt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestQianxunTokenProviderRejectsEmptyToken(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = w.Write([]byte(`{"code":0,"msg":"success","data":{"token":"","expireDate":0}}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
provider := NewQianxunTokenProvider(QianxunTokenConfig{
|
||||
BaseURL: server.URL, OperatorID: "operator-1", AppID: "app-1",
|
||||
TokenType: "card", Key: "key-1", Timeout: time.Second,
|
||||
})
|
||||
if _, err := provider.Fetch(context.Background()); err == nil {
|
||||
t.Fatal("expected empty token error")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
package recharge
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/md5"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
const DefaultBaseURL = "http://www.sdtywl.cn/"
|
||||
const requestInterval = time.Second
|
||||
|
||||
var requestGates sync.Map
|
||||
|
||||
// 上游业务码(综合充值平台API文档-通用反馈状态码)
|
||||
const (
|
||||
CodeSuccess = 200 // 成功
|
||||
CodeMissingParam = 400 // 缺少参数
|
||||
CodeSignError = 401 // sign错误
|
||||
CodeForbidden = 403 // 无权访问
|
||||
CodePwdKeyError = 404 // pwdkey错误
|
||||
CodeNotWhiteList = 407 // 非白名单IP
|
||||
CodeRateLimited = 429 // 访问超频(单接口60次/分)
|
||||
CodeServerError = 500 // 其他错误
|
||||
)
|
||||
|
||||
// BusinessError 上游业务错误(HTTP 200 但 code != 200)
|
||||
type BusinessError struct {
|
||||
Code int
|
||||
Msg string
|
||||
}
|
||||
|
||||
func (e *BusinessError) Error() string {
|
||||
return fmt.Sprintf("recharge upstream business error, code %d, msg %s", e.Code, e.Msg)
|
||||
}
|
||||
|
||||
// Response 上游统一响应信封
|
||||
type Response struct {
|
||||
Code int `json:"code"`
|
||||
Msg string `json:"msg"`
|
||||
Data json.RawMessage `json:"data"`
|
||||
}
|
||||
|
||||
type Config struct {
|
||||
BaseURL string
|
||||
PwdKey string
|
||||
Pwdiv string
|
||||
Timeout time.Duration
|
||||
}
|
||||
|
||||
type Client struct {
|
||||
baseURL string
|
||||
pwdKey string
|
||||
pwdiv string
|
||||
http *http.Client
|
||||
}
|
||||
|
||||
type requestGate struct {
|
||||
mu sync.Mutex
|
||||
next time.Time
|
||||
}
|
||||
|
||||
func (gate *requestGate) wait(ctx context.Context) error {
|
||||
for {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
gate.mu.Lock()
|
||||
delay := time.Until(gate.next)
|
||||
if delay <= 0 {
|
||||
gate.next = time.Now().Add(requestInterval)
|
||||
gate.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
gate.mu.Unlock()
|
||||
|
||||
timer := time.NewTimer(delay)
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
timer.Stop()
|
||||
return ctx.Err()
|
||||
case <-timer.C:
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func NewClient(cfg Config) *Client {
|
||||
baseURL := cfg.BaseURL
|
||||
if baseURL == "" {
|
||||
baseURL = DefaultBaseURL
|
||||
}
|
||||
timeout := cfg.Timeout
|
||||
if timeout <= 0 {
|
||||
timeout = 10 * time.Second
|
||||
}
|
||||
return &Client{
|
||||
baseURL: strings.TrimSuffix(baseURL, "/"),
|
||||
pwdKey: cfg.PwdKey,
|
||||
pwdiv: cfg.Pwdiv,
|
||||
http: &http.Client{Timeout: timeout},
|
||||
}
|
||||
}
|
||||
|
||||
// DoPost 调用上游 POST 接口,返回业务信封;HTTP 层错误直接返回 error,
|
||||
// 业务码(code != 200)由调用方判断
|
||||
func (c *Client) DoPost(ctx context.Context, endpoint string, data any) (*Response, error) {
|
||||
if err := c.requestGate(endpoint).wait(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
timestamp := strconv.FormatInt(time.Now().UnixMilli(), 10)
|
||||
payload := map[string]any{
|
||||
"pwdkey": c.pwdKey,
|
||||
"timestamp": timestamp,
|
||||
"sign": c.sign(timestamp),
|
||||
}
|
||||
if data != nil {
|
||||
payload["data"] = data
|
||||
}
|
||||
body, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return c.do(ctx, http.MethodPost, c.baseURL+"/prod-api/open/api"+endpoint, body)
|
||||
}
|
||||
|
||||
func (c *Client) requestGate(endpoint string) *requestGate {
|
||||
key := c.baseURL + "|" + c.pwdKey + "|" + strings.TrimSpace(endpoint)
|
||||
gate, _ := requestGates.LoadOrStore(key, &requestGate{})
|
||||
return gate.(*requestGate)
|
||||
}
|
||||
|
||||
func (c *Client) sign(timestamp string) string {
|
||||
sum := md5.Sum([]byte(c.pwdKey + c.pwdiv + timestamp))
|
||||
return strings.ToLower(hex.EncodeToString(sum[:]))
|
||||
}
|
||||
|
||||
func (c *Client) do(ctx context.Context, method, endpoint string, body []byte) (*Response, error) {
|
||||
var reader io.Reader
|
||||
if body != nil {
|
||||
reader = bytes.NewReader(body)
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, method, endpoint, reader)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Accept", "application/json")
|
||||
if body != nil {
|
||||
req.Header.Set("Content-Type", "application/json; charset=utf-8")
|
||||
}
|
||||
|
||||
resp, err := c.http.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
respBody, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices {
|
||||
return nil, fmt.Errorf("recharge upstream http status %d: %s", resp.StatusCode, string(respBody))
|
||||
}
|
||||
var output Response
|
||||
if err := json.Unmarshal(respBody, &output); err != nil {
|
||||
return nil, fmt.Errorf("decode recharge upstream response: %w, body=%s", err, string(respBody))
|
||||
}
|
||||
return &output, nil
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package recharge
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestRequestGateHonorsCancellation(t *testing.T) {
|
||||
gate := &requestGate{}
|
||||
if err := gate.wait(context.Background()); err != nil {
|
||||
t.Fatalf("first wait failed: %v", err)
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Millisecond)
|
||||
defer cancel()
|
||||
if err := gate.wait(ctx); !errors.Is(err, context.DeadlineExceeded) {
|
||||
t.Fatalf("wait error = %v, want deadline exceeded", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientSharesGatePerAccountAndEndpoint(t *testing.T) {
|
||||
first := NewClient(Config{BaseURL: "http://example.com", PwdKey: "account"})
|
||||
second := NewClient(Config{BaseURL: "http://example.com", PwdKey: "account"})
|
||||
|
||||
if first.requestGate("/findCard") != second.requestGate("/findCard") {
|
||||
t.Fatal("same account and endpoint must share the rate limiter")
|
||||
}
|
||||
if first.requestGate("/findCard") == first.requestGate("/changeState") {
|
||||
t.Fatal("different endpoints must not share the rate limiter")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,265 @@
|
||||
package upstreamimpl
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/consts"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/model"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/service"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/service/daihe"
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
)
|
||||
|
||||
const daiheTemplateKey = "sim_daihe_v1"
|
||||
|
||||
type daiheConfig struct {
|
||||
AppID string `json:"appId"`
|
||||
AppSecret string `json:"appSecret"`
|
||||
BaseURL string `json:"baseUrl"`
|
||||
Timeout string `json:"timeout"`
|
||||
API struct {
|
||||
BaseURL string `json:"baseUrl"`
|
||||
Timeout string `json:"timeout"`
|
||||
} `json:"api"`
|
||||
}
|
||||
type daiheUsage struct {
|
||||
ICCID string `json:"iccid"`
|
||||
Month int `json:"month"`
|
||||
Used float64 `json:"used"`
|
||||
}
|
||||
type daiheStatus struct {
|
||||
ICCID string `json:"iccid"`
|
||||
Status string `json:"status"`
|
||||
}
|
||||
|
||||
type sSimDaihe struct {
|
||||
channelConfig *model.SimChannelConfigItemCache
|
||||
client *daihe.Client
|
||||
}
|
||||
|
||||
func init() { service.UpstreamAPI().RegisterTemp(daiheTemplateKey, NewSimDaihe()) }
|
||||
func NewSimDaihe() *sSimDaihe { return &sSimDaihe{} }
|
||||
|
||||
func parseDaiheConfig(raw string) daiheConfig {
|
||||
var cfg daiheConfig
|
||||
_ = json.Unmarshal([]byte(raw), &cfg)
|
||||
return cfg
|
||||
}
|
||||
func pickDaiheConfig(cfg *model.SimChannelConfigItemCache) (daiheConfig, bool) {
|
||||
if cfg == nil || cfg.Channel == nil || cfg.Template == nil {
|
||||
return daiheConfig{}, false
|
||||
}
|
||||
channel := parseDaiheConfig(cfg.Channel.ChanConfig)
|
||||
template := parseDaiheConfig(cfg.Template.TempConfig)
|
||||
out := template
|
||||
if strings.TrimSpace(channel.AppID) != "" {
|
||||
out.AppID = channel.AppID
|
||||
}
|
||||
if strings.TrimSpace(channel.AppSecret) != "" {
|
||||
out.AppSecret = channel.AppSecret
|
||||
}
|
||||
if strings.TrimSpace(channel.BaseURL) != "" {
|
||||
out.BaseURL = channel.BaseURL
|
||||
}
|
||||
if strings.TrimSpace(channel.API.BaseURL) != "" {
|
||||
out.BaseURL = channel.API.BaseURL
|
||||
}
|
||||
if strings.TrimSpace(channel.Timeout) != "" {
|
||||
out.Timeout = channel.Timeout
|
||||
}
|
||||
if strings.TrimSpace(channel.API.Timeout) != "" {
|
||||
out.Timeout = channel.API.Timeout
|
||||
}
|
||||
if out.AppID == "" {
|
||||
out.AppID = cfg.Channel.AppKey
|
||||
}
|
||||
if out.AppSecret == "" {
|
||||
out.AppSecret = cfg.Channel.AppSecret
|
||||
}
|
||||
if out.BaseURL == "" {
|
||||
out.BaseURL = "https://open.flymodem.com.cn"
|
||||
}
|
||||
return out, out.AppID != "" && out.AppSecret != ""
|
||||
}
|
||||
|
||||
func (s *sSimDaihe) NewTemp(ctx context.Context, cfg *model.SimChannelConfigItemCache) model.SimCardChannelI {
|
||||
raw, ok := pickDaiheConfig(cfg)
|
||||
if !ok {
|
||||
g.Log().Warning(ctx, "daihe.NewTemp: missing appId/appSecret")
|
||||
return nil
|
||||
}
|
||||
timeout := 15 * time.Second
|
||||
if d, err := time.ParseDuration(raw.Timeout); err == nil && d > 0 {
|
||||
timeout = d
|
||||
}
|
||||
return &sSimDaihe{channelConfig: cfg, client: daihe.NewClient(daihe.Config{BaseURL: raw.BaseURL, AppID: raw.AppID, AppSecret: raw.AppSecret, Timeout: timeout})}
|
||||
}
|
||||
|
||||
func daiheICCID(key *model.SimKey) string {
|
||||
if key == nil {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(key.ICCID)
|
||||
}
|
||||
func daiheStatusCode(status string) (int, bool) {
|
||||
switch strings.TrimSpace(status) {
|
||||
case "库存":
|
||||
return 7, true
|
||||
case "可测试", "测试激活", "测试去激活":
|
||||
return 6, true
|
||||
case "可激活", "开始":
|
||||
return 1, true
|
||||
case "已激活":
|
||||
return 2, true
|
||||
case "已停用":
|
||||
return 4, true
|
||||
case "预清除":
|
||||
return 8, true
|
||||
case "已清除", "已失效", "已更换":
|
||||
return 9, true
|
||||
default:
|
||||
return 0, false
|
||||
}
|
||||
}
|
||||
func daiheUnsupported[T any](keys []*model.SimKey, name string) (out []*model.SimBaseRes[T], err error) {
|
||||
for _, key := range keys {
|
||||
out = append(out, &model.SimBaseRes[T]{Code: consts.ApiStatusError, SimKey: key, Message: "daihe does not support " + name})
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func (s *sSimDaihe) QuerySimCardStatus(ctx context.Context, in *model.SimCardStatusInput) (out model.SimCardStatusOutput, err error) {
|
||||
for _, key := range in.SimKeys {
|
||||
r := &model.SimBaseRes[model.SimCardStatusItem]{SimKey: key}
|
||||
iccid := daiheICCID(key)
|
||||
var data daiheStatus
|
||||
if iccid == "" {
|
||||
r.Code = consts.ApiStatusError
|
||||
r.Message = "iccid is required"
|
||||
} else if e := s.client.QueryStatus(ctx, iccid, &data); e != nil {
|
||||
r.Code = consts.ApiStatusError
|
||||
r.Message = e.Error()
|
||||
} else if status, ok := daiheStatusCode(data.Status); !ok {
|
||||
r.Code = consts.ApiStatusError
|
||||
r.Message = fmt.Sprintf("unsupported daihe card status: %s", data.Status)
|
||||
} else {
|
||||
r.Code = consts.ApiStatusSuccess
|
||||
r.ResData.CardStatus = status
|
||||
}
|
||||
out = append(out, r)
|
||||
}
|
||||
return
|
||||
}
|
||||
func (s *sSimDaihe) QuerySimDataUsage(ctx context.Context, in *model.SimDataUsageInput) (out model.SimDataUsageOutput, err error) {
|
||||
return s.queryUsage(ctx, in, "")
|
||||
}
|
||||
func (s *sSimDaihe) QuerySimDataUsageMonthly(ctx context.Context, in *model.SimDataUsageMonthlyInput) (out model.SimDataUsageMonthlyOutput, err error) {
|
||||
month := ""
|
||||
if in != nil {
|
||||
month = strings.TrimSpace(in.ReqData.QueryDate)
|
||||
}
|
||||
result := make(model.SimDataUsageMonthlyOutput, 0, len(in.SimKeys))
|
||||
for _, key := range in.SimKeys {
|
||||
r := &model.SimBaseRes[model.SimDataUsageMonthlyOutputItem]{SimKey: key}
|
||||
iccid := daiheICCID(key)
|
||||
var data daiheUsage
|
||||
if iccid == "" {
|
||||
r.Code = consts.ApiStatusError
|
||||
r.Message = "iccid is required"
|
||||
} else if e := s.client.QueryUsage(ctx, iccid, month, &data); e != nil {
|
||||
r.Code = consts.ApiStatusError
|
||||
r.Message = e.Error()
|
||||
} else {
|
||||
r.Code = consts.ApiStatusSuccess
|
||||
r.ResData.DeadLine = fmt.Sprintf("%06d", data.Month)
|
||||
r.ResData.DataAmount = data.Used
|
||||
}
|
||||
result = append(result, r)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
func (s *sSimDaihe) queryUsage(ctx context.Context, in *model.SimDataUsageInput, month string) (out model.SimDataUsageOutput, err error) {
|
||||
for _, key := range in.SimKeys {
|
||||
r := &model.SimBaseRes[model.SimDataUsageItem]{SimKey: key}
|
||||
iccid := daiheICCID(key)
|
||||
var data daiheUsage
|
||||
if iccid == "" {
|
||||
r.Code = consts.ApiStatusError
|
||||
r.Message = "iccid is required"
|
||||
} else if e := s.client.QueryUsage(ctx, iccid, month, &data); e != nil {
|
||||
r.Code = consts.ApiStatusError
|
||||
r.Message = e.Error()
|
||||
} else {
|
||||
r.Code = consts.ApiStatusSuccess
|
||||
r.ResData.DataUsage = data.Used
|
||||
}
|
||||
out = append(out, r)
|
||||
}
|
||||
return
|
||||
}
|
||||
func (s *sSimDaihe) ChangeSimStatus(ctx context.Context, in *model.SimCardStatusChangeInput) (out model.SimCardStatusChangeOutput, err error) {
|
||||
op, ok := normalizeCardStatusChangeOperType(ctx, in.ReqData.OperType)
|
||||
if !ok {
|
||||
return newInvalidCardStatusChangeOutput(in.SimKeys), nil
|
||||
}
|
||||
for _, key := range in.SimKeys {
|
||||
r := &model.SimBaseRes[model.SimCardStatusChangeItem]{SimKey: key}
|
||||
iccid := daiheICCID(key)
|
||||
var e error
|
||||
if iccid == "" {
|
||||
e = fmt.Errorf("iccid is required")
|
||||
} else if op == consts.CardStatusChangeOperTypeApplyStop {
|
||||
e = s.client.Deactivate(ctx, iccid)
|
||||
} else {
|
||||
e = s.client.Activate(ctx, iccid)
|
||||
}
|
||||
if e != nil {
|
||||
r.Code = consts.ApiStatusError
|
||||
r.Message = e.Error()
|
||||
} else {
|
||||
r.Code = consts.ApiStatusSuccess
|
||||
}
|
||||
out = append(out, r)
|
||||
}
|
||||
return
|
||||
}
|
||||
func (s *sSimDaihe) QuerySimBasicInfo(ctx context.Context, in *model.SimBasicInfoInput) (model.SimBasicInfoOutput, error) {
|
||||
return daiheUnsupported[model.SimBasicInfoItem](in.SimKeys, "basic info query")
|
||||
}
|
||||
func (s *sSimDaihe) QuerySimSession(ctx context.Context, in *model.SimSessionInput) (model.SimSessionOutput, error) {
|
||||
return daiheUnsupported[model.SimSessionItem](in.SimKeys, "session query")
|
||||
}
|
||||
func (s *sSimDaihe) QuerySimStopReason(ctx context.Context, in *model.SimStopReasonInput) (model.SimStopReasonOutput, error) {
|
||||
return daiheUnsupported[model.SimStopReasonItem](in.SimKeys, "stop reason query")
|
||||
}
|
||||
func (s *sSimDaihe) QuerySimFrozenState(ctx context.Context, in *model.SimFrozenStateInput) (model.SimFrozenStateOutput, error) {
|
||||
return daiheUnsupported[model.SimFrozenStateItem](in.SimKeys, "frozen state query")
|
||||
}
|
||||
func (s *sSimDaihe) QuerySimCardBindStatus(ctx context.Context, in *model.SimCardBindStatusInput) (model.SimCardBindStatusOutput, error) {
|
||||
return daiheUnsupported[model.SimCardBindStatusItem](in.SimKeys, "card binding query")
|
||||
}
|
||||
func (s *sSimDaihe) QuerySimDeviceStatus(ctx context.Context, in *model.SimDeviceStatusInput) (model.SimDeviceStatusOutput, error) {
|
||||
return daiheUnsupported[model.SimDeviceStatusItem](in.SimKeys, "device status query")
|
||||
}
|
||||
func (s *sSimDaihe) QuerySimRegionLimitStatus(ctx context.Context, in *model.SimRegionLimitStatusInput) (model.SimRegionLimitStatusOutput, error) {
|
||||
return daiheUnsupported[model.SimRegionLimitStatusItem](in.SimKeys, "region limit query")
|
||||
}
|
||||
func (s *sSimDaihe) QuerySimImei(ctx context.Context, in *model.SimImeiInput) (model.SimImeiOutput, error) {
|
||||
return daiheUnsupported[model.SimImeiItem](in.SimKeys, "IMEI query")
|
||||
}
|
||||
func (s *sSimDaihe) QuerySimDataDiagnosis(ctx context.Context, in *model.SimDataDiagnosisInput) (model.SimDataDiagnosisOutput, error) {
|
||||
return daiheUnsupported[any](in.SimKeys, "data diagnosis")
|
||||
}
|
||||
func (s *sSimDaihe) QuerySimDataUsageDaily(ctx context.Context, in *model.SimDataUsageDailyInput) (model.SimDataUsageDailyOutput, error) {
|
||||
return daiheUnsupported[model.SimDataUsageDailyOutputItem](in.SimKeys, "daily usage query")
|
||||
}
|
||||
func (s *sSimDaihe) QuerySimApnInfo(ctx context.Context, in *model.SimApnInfoInput) (model.SimApnInfoOutput, error) {
|
||||
return daiheUnsupported[model.SimApnInfoItemList](in.SimKeys, "APN query")
|
||||
}
|
||||
func (s *sSimDaihe) ChangeSimApnFunction(ctx context.Context, in *model.SimCardOperateChangeApnInput) (model.SimCardOperateChangeApnOutput, error) {
|
||||
return daiheUnsupported[model.SimCardOperateChangeApnItem](in.SimKeys, "APN change")
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
package upstreamimpl
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
|
||||
"git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/consts"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/model"
|
||||
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
"github.com/gogf/gf/v2/util/gconv"
|
||||
)
|
||||
|
||||
type ydDataDiagnosisRequestFunc func(ctx context.Context, operation, url string, params g.Map) (*ydHTTPResponse, error)
|
||||
|
||||
type ydDataDiagnosisResp struct {
|
||||
Status string `json:"status"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
// QuerySimDataDiagnosis 物联卡达量限速档位查询
|
||||
func (s *sSimYD) QuerySimDataDiagnosis(ctx context.Context, input *model.SimDataDiagnosisInput) (model.SimDataDiagnosisOutput, error) {
|
||||
return queryYdSimDataDiagnosis(ctx, input, s.getUrl(ctx, "ec/query/sim-data-diagnosis"), s.getTransId, s.requestYd)
|
||||
}
|
||||
|
||||
// QuerySimDataDiagnosis 物联卡达量限速档位查询
|
||||
func (s *sSimYDMock) QuerySimDataDiagnosis(ctx context.Context, input *model.SimDataDiagnosisInput) (model.SimDataDiagnosisOutput, error) {
|
||||
return queryYdSimDataDiagnosis(ctx, input, s.getUrl(ctx, "ec/query/sim-data-diagnosis"), s.getTransId, s.requestYd)
|
||||
}
|
||||
|
||||
// QuerySimDataDiagnosis 物联卡达量限速档位查询
|
||||
func (s *sSimDX) QuerySimDataDiagnosis(ctx context.Context, input *model.SimDataDiagnosisInput) (model.SimDataDiagnosisOutput, error) {
|
||||
items := make(model.SimDataDiagnosisOutput, 0)
|
||||
if input == nil {
|
||||
return items, nil
|
||||
}
|
||||
for _, simKey := range input.SimKeys {
|
||||
items = append(items, &model.SimBaseRes[any]{
|
||||
SimKey: simKey,
|
||||
Code: consts.ApiInvokeException,
|
||||
Message: "电信官方接口暂不提供当前达量限速查询能力",
|
||||
})
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
func queryYdSimDataDiagnosis(
|
||||
ctx context.Context,
|
||||
input *model.SimDataDiagnosisInput,
|
||||
url string,
|
||||
getTransID func(context.Context) string,
|
||||
request ydDataDiagnosisRequestFunc,
|
||||
) (model.SimDataDiagnosisOutput, error) {
|
||||
items := make(model.SimDataDiagnosisOutput, 0)
|
||||
if input == nil {
|
||||
return items, nil
|
||||
}
|
||||
for _, simKey := range input.SimKeys {
|
||||
params := g.Map{"transid": getTransID(ctx)}
|
||||
paramName, paramValue := pickSimQueryParam(simKey)
|
||||
if paramName == "" {
|
||||
items = append(items, &model.SimBaseRes[any]{
|
||||
SimKey: simKey,
|
||||
Code: consts.ApiStatusError,
|
||||
Message: "missing iccid/msisdn/imsi",
|
||||
})
|
||||
continue
|
||||
}
|
||||
params[paramName] = paramValue
|
||||
|
||||
resp, err := request(ctx, "QuerySimDataDiagnosis", url, params)
|
||||
if err != nil {
|
||||
g.Log().Errorf(ctx, "QuerySimDataDiagnosis get error %+v,url %s, params %+v", err, url, params)
|
||||
items = append(items, &model.SimBaseRes[any]{
|
||||
SimKey: simKey,
|
||||
Code: consts.ApiStatusError,
|
||||
Message: err.Error(),
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
body := resp.ReadAllString()
|
||||
var ydResp ydDataDiagnosisResp
|
||||
if err = json.Unmarshal([]byte(body), &ydResp); err != nil {
|
||||
g.Log().Errorf(ctx, "QuerySimDataDiagnosis unmarshal error %+v,url %s, params %+v, body %s", err, url, params, body)
|
||||
items = append(items, &model.SimBaseRes[any]{
|
||||
SimKey: simKey,
|
||||
Code: consts.ApiStatusError,
|
||||
Message: err.Error(),
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
items = append(items, &model.SimBaseRes[any]{
|
||||
SimKey: simKey,
|
||||
Code: gconv.Int(ydResp.Status),
|
||||
Message: ydResp.Message,
|
||||
ResData: json.RawMessage(body),
|
||||
})
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
@@ -0,0 +1,288 @@
|
||||
package upstreamimpl
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/consts"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/model"
|
||||
|
||||
"github.com/gogf/gf/v2/util/gconv"
|
||||
)
|
||||
|
||||
func pickSimQueryParam(simKey *model.SimKey) (string, string) {
|
||||
if simKey == nil {
|
||||
return "", ""
|
||||
}
|
||||
if value := strings.TrimSpace(simKey.ICCID); value != "" {
|
||||
return "iccid", value
|
||||
}
|
||||
if value := strings.TrimSpace(simKey.MSISDN); value != "" {
|
||||
return "msisdn", value
|
||||
}
|
||||
if value := strings.TrimSpace(simKey.IMSI); value != "" {
|
||||
return "imsi", value
|
||||
}
|
||||
return "", ""
|
||||
}
|
||||
|
||||
func ydBasicInfoHasIdentity(iccid, msisdn, imsi string) bool {
|
||||
return strings.TrimSpace(iccid) != "" || strings.TrimSpace(msisdn) != "" || strings.TrimSpace(imsi) != ""
|
||||
}
|
||||
|
||||
func mapYdStopReasonText(stopReason string) string {
|
||||
value := strings.TrimSpace(stopReason)
|
||||
if len(value) == 12 && strings.Trim(value, "02") == "" {
|
||||
if value == "000000000000" {
|
||||
return "未停机或系统暂无停机原因"
|
||||
}
|
||||
var reasons []string
|
||||
for _, code := range []struct {
|
||||
position int
|
||||
label string
|
||||
}{
|
||||
{0, "窄带网商品到期失效停机"},
|
||||
{1, "机卡分离停机"},
|
||||
{3, "M2M管理停机"},
|
||||
{4, "信控双向停机"},
|
||||
{8, "主动申请双向停机"},
|
||||
{9, "区域限制管理型停机"},
|
||||
{10, "强制双向停机"},
|
||||
} {
|
||||
if value[code.position] == '2' {
|
||||
reasons = append(reasons, code.label)
|
||||
}
|
||||
}
|
||||
if len(reasons) > 0 {
|
||||
if strings.Count(value, "2") == len(reasons) {
|
||||
return strings.Join(reasons, "、")
|
||||
}
|
||||
return strings.Join(reasons, "、") + " (原始码: " + value + ")"
|
||||
}
|
||||
}
|
||||
switch value {
|
||||
case "":
|
||||
return "未停机"
|
||||
case "000000000000":
|
||||
return "未停机或系统暂无停机原因"
|
||||
case "000000000020":
|
||||
return "强制双向停机"
|
||||
case "000000002000":
|
||||
return "主动申请双向停机"
|
||||
case "000020000000":
|
||||
return "信控双向停机"
|
||||
case "020000000000":
|
||||
return "机卡分离停机"
|
||||
case "000200000000":
|
||||
return "M2M管理停机"
|
||||
case "000000000200":
|
||||
return "区域限制管理型停机"
|
||||
default:
|
||||
return value
|
||||
}
|
||||
}
|
||||
|
||||
func mapYdFrozenStatusText(frozenStatus string) string {
|
||||
value := strings.TrimSpace(frozenStatus)
|
||||
switch value {
|
||||
case "", "0":
|
||||
return "未冻结"
|
||||
case "1":
|
||||
return "已冻结"
|
||||
default:
|
||||
return value
|
||||
}
|
||||
}
|
||||
|
||||
func mapYdOperatingStatus(status string) string {
|
||||
switch strings.ToUpper(strings.TrimSpace(status)) {
|
||||
case "1", "01", "ON", "ONLINE":
|
||||
return "1"
|
||||
case "0", "00", "OFF", "OFFLINE", "-1":
|
||||
return "0"
|
||||
default:
|
||||
return consts.CardSessionStatusUnknown
|
||||
}
|
||||
}
|
||||
|
||||
func mapYdDeviceStatus(status string) string {
|
||||
return mapYdOperatingStatus(status)
|
||||
}
|
||||
|
||||
func mapDxOperatingStatus(status string) string {
|
||||
switch strings.TrimSpace(status) {
|
||||
case "0":
|
||||
// Telecom NQ007: 0 = power on, 1 = power off.
|
||||
return "1"
|
||||
case "1":
|
||||
return "0"
|
||||
default:
|
||||
return consts.CardSessionStatusUnknown
|
||||
}
|
||||
}
|
||||
|
||||
func pickYdCardBindStatus(result map[string]any) string {
|
||||
if len(result) == 0 {
|
||||
return ""
|
||||
}
|
||||
if value := strings.TrimSpace(toString(result["result"])); value != "" {
|
||||
switch value {
|
||||
case "0", "1", "2":
|
||||
return value
|
||||
case "OK", "ok":
|
||||
return "1"
|
||||
}
|
||||
}
|
||||
for _, key := range []string{"bind_status", "bindStatus", "cardBindStatus", "cardBindingType", "status"} {
|
||||
value := strings.TrimSpace(toString(result[key]))
|
||||
switch value {
|
||||
case "0", "1":
|
||||
return value
|
||||
case "OK", "ok":
|
||||
return "1"
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func pickYdCardBindStatusErrorCode(result map[string]any) string {
|
||||
if len(result) == 0 {
|
||||
return ""
|
||||
}
|
||||
for _, key := range []string{"errorCode", "error_code", "errCode", "errcode"} {
|
||||
value := strings.TrimSpace(toString(result[key]))
|
||||
if value != "" {
|
||||
return value
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func pickYdCardBindStatusErrorDes(result map[string]any) string {
|
||||
if len(result) == 0 {
|
||||
return ""
|
||||
}
|
||||
for _, key := range []string{"errorDes", "error_des", "errDes", "errMsg", "message"} {
|
||||
value := strings.TrimSpace(toString(result[key]))
|
||||
if value != "" {
|
||||
return value
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func toString(value any) string {
|
||||
return strings.TrimSpace(gconv.String(value))
|
||||
}
|
||||
|
||||
func mapDxStopTypeListText(stopTypeList []string) string {
|
||||
if len(stopTypeList) == 0 {
|
||||
return "未停机"
|
||||
}
|
||||
texts := make([]string, 0, len(stopTypeList))
|
||||
for _, stopType := range stopTypeList {
|
||||
switch strings.TrimSpace(stopType) {
|
||||
case "":
|
||||
continue
|
||||
case "100000":
|
||||
texts = append(texts, "申请停机")
|
||||
case "110000":
|
||||
texts = append(texts, "挂失停机")
|
||||
case "120000":
|
||||
texts = append(texts, "停机保号")
|
||||
case "130000":
|
||||
texts = append(texts, "欠费停机")
|
||||
case "130001":
|
||||
texts = append(texts, "欠费单停")
|
||||
case "130002":
|
||||
texts = append(texts, "欠费双停")
|
||||
case "140000":
|
||||
texts = append(texts, "预拆机停机")
|
||||
case "150000":
|
||||
texts = append(texts, "违章停机")
|
||||
case "150001":
|
||||
texts = append(texts, "未实名制违规停机")
|
||||
case "160000":
|
||||
texts = append(texts, "数据异常停机")
|
||||
case "170000":
|
||||
texts = append(texts, "局方原因停机")
|
||||
case "180000":
|
||||
texts = append(texts, "强制停机")
|
||||
case "190000":
|
||||
texts = append(texts, "超范围使用违规停机")
|
||||
case "200000":
|
||||
texts = append(texts, "去激活停机")
|
||||
case "210000":
|
||||
texts = append(texts, "紧急停机")
|
||||
case "220000":
|
||||
texts = append(texts, "安全事件关停")
|
||||
case "230000":
|
||||
texts = append(texts, "到期停机")
|
||||
case "240000":
|
||||
texts = append(texts, "公免到期停机")
|
||||
case "250000":
|
||||
texts = append(texts, "限额管控停机")
|
||||
case "260000":
|
||||
texts = append(texts, "欺诈停机")
|
||||
case "270000":
|
||||
texts = append(texts, "模组融合停机")
|
||||
case "280100":
|
||||
texts = append(texts, "区域限制停机")
|
||||
default:
|
||||
texts = append(texts, strings.TrimSpace(stopType))
|
||||
}
|
||||
}
|
||||
if len(texts) == 0 {
|
||||
return "未停机"
|
||||
}
|
||||
return strings.Join(texts, "、")
|
||||
}
|
||||
|
||||
func mapDxRegionLimitStatus(areaLimitType string, areaLimitInfo string) string {
|
||||
areaLimitType = strings.TrimSpace(areaLimitType)
|
||||
areaLimitInfo = strings.TrimSpace(areaLimitInfo)
|
||||
if areaLimitType == "" || areaLimitType == "-1" || areaLimitType == "0" {
|
||||
if areaLimitInfo == "" {
|
||||
return "1"
|
||||
}
|
||||
}
|
||||
return "0"
|
||||
}
|
||||
|
||||
func mapDxRegionLimitStatusByOrder(orderStatus string, limitStatus string) string {
|
||||
orderStatus = strings.TrimSpace(orderStatus)
|
||||
limitStatus = strings.TrimSpace(limitStatus)
|
||||
switch {
|
||||
case orderStatus == "1":
|
||||
return "2"
|
||||
case orderStatus == "0" && limitStatus == "0":
|
||||
return "1"
|
||||
case orderStatus == "0" && limitStatus == "1":
|
||||
return "0"
|
||||
case orderStatus == "0" && limitStatus == "-":
|
||||
return "2"
|
||||
default:
|
||||
return "2"
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeDxRegionLimitDetails(orderStatus string, limitStatus string, limitType string, limitArea string) (string, string, string) {
|
||||
if strings.TrimSpace(orderStatus) != "0" {
|
||||
return "", "", ""
|
||||
}
|
||||
|
||||
switch strings.TrimSpace(limitStatus) {
|
||||
case "0", "1", "-":
|
||||
limitStatus = strings.TrimSpace(limitStatus)
|
||||
default:
|
||||
limitStatus = ""
|
||||
}
|
||||
|
||||
switch strings.TrimSpace(limitType) {
|
||||
case "1", "2", "3", "4":
|
||||
limitType = strings.TrimSpace(limitType)
|
||||
default:
|
||||
limitType = ""
|
||||
}
|
||||
|
||||
return limitStatus, limitType, strings.TrimSpace(limitArea)
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
package upstreamimpl
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/logic/upstreamimpl/dx"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/model"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/model/entity"
|
||||
)
|
||||
|
||||
type diagnosisTestToken struct{}
|
||||
|
||||
func (diagnosisTestToken) GetToken(context.Context) (string, error) { return "test-token", nil }
|
||||
func (diagnosisTestToken) RecoverToken(context.Context, string) (string, error) {
|
||||
return "test-token", nil
|
||||
}
|
||||
|
||||
func TestYdImeiUsesTerminalDeviceInfo(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/v5/ec/query/sim-terminal-device-info" || r.URL.Query().Get("iccid") != "898604F4152391115474" {
|
||||
t.Errorf("unexpected request %s", r.URL.String())
|
||||
}
|
||||
_, _ = w.Write([]byte(`{"status":"0","message":"正确","result":[{"terminalDeviceImei":"123456789012345"}]}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
sim := &sSimYD{
|
||||
channelConfig: &model.SimChannelConfigItemCache{Channel: &entity.TSimChannels{ChanKey: "test", AppKey: "test"}},
|
||||
tokenProvider: diagnosisTestToken{}, apiConfig: &oneLinkAPIConfig{BaseURL: server.URL + "/v5"},
|
||||
}
|
||||
result, err := sim.QuerySimImei(context.Background(), &model.SimImeiInput{SimKeys: []*model.SimKey{{ICCID: "898604F4152391115474"}}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(result) != 1 || result[0].Code != 0 || result[0].ResData.Imei != "123456789012345" {
|
||||
t.Fatalf("unexpected IMEI response: %+v", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestYdStopReasonCombination(t *testing.T) {
|
||||
for _, tc := range []struct{ input, want string }{
|
||||
{"020000002000", "机卡分离停机、主动申请双向停机"},
|
||||
{"200000000000", "窄带网商品到期失效停机"},
|
||||
{"002000000000", "002000000000"},
|
||||
} {
|
||||
if got := mapYdStopReasonText(tc.input); got != tc.want {
|
||||
t.Errorf("%s => %s, want %s", tc.input, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDxRegionLimitStatusByOrder(t *testing.T) {
|
||||
for _, tc := range []struct{ order, limit, want string }{
|
||||
{"1", "", "2"}, {"0", "0", "1"}, {"0", "1", "0"}, {"0", "-", "2"},
|
||||
} {
|
||||
if got := mapDxRegionLimitStatusByOrder(tc.order, tc.limit); got != tc.want {
|
||||
t.Errorf("%s/%s => %s, want %s", tc.order, tc.limit, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDxCardBindingUsesDedicatedEndpoint(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/api/v1/corp/connect/chkDeviceBindingInfo" || r.URL.Query().Get("iccid") != "8986112223600594371" {
|
||||
t.Errorf("unexpected request %s", r.URL.String())
|
||||
}
|
||||
_, _ = w.Write([]byte(`{"code":"0","data":{"orderStaus":"0","bindType":"1","bindImei":"123456789012345","bindStaus":"1"}}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
sim := &sSimDX{client: dx.NewClient(dx.Config{BaseURL: server.URL, AppKey: "test", AppSecret: "secret"})}
|
||||
result, err := sim.QuerySimCardBindStatus(context.Background(), &model.SimCardBindStatusInput{SimKeys: []*model.SimKey{{ICCID: "8986112223600594371"}}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(result) != 1 || result[0].Code != 0 || result[0].ResData.OrderStatus != "0" || result[0].ResData.BindStatus != "1" || result[0].ResData.BindImei != "123456789012345" {
|
||||
t.Fatalf("unexpected response: %+v", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDxImeiReadsNQ003Response(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
body string
|
||||
wantCode int
|
||||
wantImei, wantMessage string
|
||||
}{
|
||||
{`{"RESULT":"0","MESSAGE":"查询成功","IMEI":"863644077218049"}`, 0, "863644077218049", "查询成功"},
|
||||
{`{"RESULT":"S00004","MESSAGE":"没有查询权限"}`, 9999, "", "没有查询权限"},
|
||||
} {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/openapi/v1/prodinst/gete2einfo" || r.URL.Query().Get("iccid") != "8986112223600594371" {
|
||||
t.Errorf("unexpected request: %s", r.URL.String())
|
||||
}
|
||||
_, _ = w.Write([]byte(tc.body))
|
||||
}))
|
||||
sim := &sSimDX{client: dx.NewClient(dx.Config{BaseURL: server.URL, AppKey: "test", AppSecret: "secret"})}
|
||||
results, err := sim.QuerySimImei(context.Background(), &model.SimImeiInput{SimKeys: []*model.SimKey{{ICCID: "8986112223600594371"}}})
|
||||
server.Close()
|
||||
if err != nil || len(results) != 1 || results[0].Code != tc.wantCode || results[0].ResData.Imei != tc.wantImei || results[0].Message != tc.wantMessage {
|
||||
t.Errorf("response = %+v, error = %v", results, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,216 @@
|
||||
package upstreamimpl
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"encoding/xml"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/consts"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/model"
|
||||
|
||||
"github.com/gogf/gf/v2/errors/gerror"
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
)
|
||||
|
||||
type dxAPNResponse[T any] struct {
|
||||
Code json.RawMessage `json:"code"`
|
||||
Message string `json:"message"`
|
||||
Result *T `json:"result"`
|
||||
}
|
||||
|
||||
type dxAPNInfo struct {
|
||||
ApnInfoList []struct {
|
||||
ApnName string `json:"apnName"`
|
||||
Status *string `json:"status"`
|
||||
ApnStatus *string `json:"apnStatus"`
|
||||
} `json:"apnInfoList"`
|
||||
}
|
||||
|
||||
func dxAPNParams(simKey *model.SimKey) url.Values {
|
||||
params := url.Values{}
|
||||
if simKey == nil {
|
||||
return params
|
||||
}
|
||||
if value := strings.TrimSpace(simKey.MSISDN); value != "" {
|
||||
params.Set("msisdn", value)
|
||||
} else if value := strings.TrimSpace(simKey.ICCID); value != "" {
|
||||
params.Set("iccid", value)
|
||||
} else if value := strings.TrimSpace(simKey.IMSI); value != "" {
|
||||
params.Set("imsi", value)
|
||||
}
|
||||
return params
|
||||
}
|
||||
|
||||
func decodeDXAPNResponse[T any](body []byte) (*dxAPNResponse[T], int, string, error) {
|
||||
var response dxAPNResponse[T]
|
||||
if err := json.Unmarshal(body, &response); err != nil {
|
||||
return nil, consts.ApiStatusError, "", gerror.Wrap(err, "电信 APN 响应格式错误")
|
||||
}
|
||||
rawCode := strings.TrimSpace(string(response.Code))
|
||||
if strings.HasPrefix(rawCode, "\"") {
|
||||
if err := json.Unmarshal(response.Code, &rawCode); err != nil {
|
||||
return nil, consts.ApiStatusError, "", gerror.Wrap(err, "电信 APN 返回码格式错误")
|
||||
}
|
||||
}
|
||||
code, err := strconv.Atoi(rawCode)
|
||||
if err != nil || (code == 0 && rawCode != "0") {
|
||||
return &response, consts.ApiStatusError, rawCode, gerror.Newf("电信 APN 返回码无效:%s,%s", rawCode, response.Message)
|
||||
}
|
||||
if code != 0 {
|
||||
return &response, code, rawCode, nil
|
||||
}
|
||||
if response.Result == nil {
|
||||
return &response, consts.ApiStatusError, "", gerror.New("电信 APN 成功响应缺少 result")
|
||||
}
|
||||
return &response, code, "", nil
|
||||
}
|
||||
|
||||
func (s *sSimDX) QuerySimApnInfo(ctx context.Context, input *model.SimApnInfoInput) (model.SimApnInfoOutput, error) {
|
||||
items := make(model.SimApnInfoOutput, 0, len(input.SimKeys))
|
||||
for _, simKey := range input.SimKeys {
|
||||
item := &model.SimBaseRes[model.SimApnInfoItemList]{SimKey: simKey, ResData: model.SimApnInfoItemList{ApnList: []model.SimApnInfoItem{}}}
|
||||
items = append(items, item)
|
||||
params := dxAPNParams(simKey)
|
||||
if len(params) == 0 {
|
||||
item.Code, item.Message = consts.ApiStatusError, "电信 APN 查询缺少卡标识"
|
||||
continue
|
||||
}
|
||||
body, err := s.client.DoGetRequest(ctx, "/api/v3/getApnInfo", params)
|
||||
if err != nil {
|
||||
item.Code, item.Message = consts.ApiStatusError, err.Error()
|
||||
continue
|
||||
}
|
||||
response, code, rawCode, err := decodeDXAPNResponse[dxAPNInfo](body)
|
||||
item.Code, item.ErrCode = code, rawCode
|
||||
if err != nil {
|
||||
item.Message = err.Error()
|
||||
continue
|
||||
}
|
||||
item.Message = response.Message
|
||||
if code != 0 {
|
||||
continue
|
||||
}
|
||||
if response.Result.ApnInfoList == nil {
|
||||
item.Code, item.Message = consts.ApiStatusError, "电信 APN 成功响应缺少 apnInfoList"
|
||||
continue
|
||||
}
|
||||
for _, apn := range response.Result.ApnInfoList {
|
||||
status := ""
|
||||
if apn.Status != nil {
|
||||
status = strings.TrimSpace(*apn.Status)
|
||||
} else if apn.ApnStatus != nil {
|
||||
status = strings.TrimSpace(*apn.ApnStatus)
|
||||
}
|
||||
if apn.Status != nil && apn.ApnStatus != nil && strings.TrimSpace(*apn.Status) != strings.TrimSpace(*apn.ApnStatus) {
|
||||
status = ""
|
||||
}
|
||||
if status != consts.CardApnStatusNormal && status != consts.CardApnStatusPaused {
|
||||
status = ""
|
||||
}
|
||||
item.ResData.ApnList = append(item.ResData.ApnList, model.SimApnInfoItem{ApnName: strings.TrimSpace(apn.ApnName), Status: status})
|
||||
}
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
// dxCutNetSuccessCode 是 singleCutNet 响应中表示成功接收的 RspCode。
|
||||
const dxCutNetSuccessCode = "0000"
|
||||
|
||||
// ChangeSimApnFunction 添加或取消单独断网
|
||||
// CTIOT_5GCMP_BA004 - 添加或取消单独断网
|
||||
// - URL: /5gcmp/openapi/v1/common/singleCutNet
|
||||
// - 参数: access_number + action (ADD/DEL)
|
||||
// - 响应: XML,RspCode=0000 表示成功接收,GROUP_TRANSACTIONID 为流水号
|
||||
//
|
||||
// 这是整卡数据开停语义(不是按 APN 定向):
|
||||
// - 平台 0(开通数据) -> action=DEL(取消单独断网,恢复联网)
|
||||
// - 平台 1(停用数据) -> action=ADD(单独断网)
|
||||
//
|
||||
// 电信侧没有「按 APN 定向」这一维度,因此忽略调用方携带的 ApnName
|
||||
// (预警自动断网、批量数据开停都会固定带 apnName="CMIOT"),不能因此拒绝请求。
|
||||
func (s *sSimDX) ChangeSimApnFunction(ctx context.Context, input *model.SimCardOperateChangeApnInput) (model.SimCardOperateChangeApnOutput, error) {
|
||||
// 响应结构 (XML)
|
||||
type dxCutNetResponse struct {
|
||||
XMLName xml.Name `xml:"SvcCont"`
|
||||
GroupTransactionID string `xml:"GROUP_TRANSACTIONID"`
|
||||
Response struct {
|
||||
RspType string `xml:"RspType"`
|
||||
RspCode string `xml:"RspCode"`
|
||||
RspDesc string `xml:"RspDesc"`
|
||||
} `xml:"Response"`
|
||||
}
|
||||
|
||||
action, ok := convertDxApnOperType(ctx, input.ReqData.OperType)
|
||||
|
||||
items := make(model.SimCardOperateChangeApnOutput, 0, len(input.SimKeys))
|
||||
if !ok {
|
||||
for _, simKey := range input.SimKeys {
|
||||
items = append(items, &model.SimBaseRes[model.SimCardOperateChangeApnItem]{
|
||||
SimKey: simKey,
|
||||
Code: consts.ApiStatusError,
|
||||
Message: "APN 操作类型必须为 0(开通)或 1(停止)",
|
||||
ResData: model.SimCardOperateChangeApnItem{},
|
||||
})
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
for _, simKey := range input.SimKeys {
|
||||
item := &model.SimBaseRes[model.SimCardOperateChangeApnItem]{SimKey: simKey}
|
||||
items = append(items, item)
|
||||
|
||||
// singleCutNet 只按物联网接入号定位,缺接入号无法定位卡。
|
||||
accessNumber := strings.TrimSpace(simKey.MSISDN)
|
||||
if accessNumber == "" {
|
||||
item.Code, item.Message = consts.ApiStatusError, "电信单独断网缺少物联网接入号(MSISDN)"
|
||||
continue
|
||||
}
|
||||
|
||||
params := url.Values{
|
||||
"access_number": []string{accessNumber},
|
||||
"action": []string{action},
|
||||
}
|
||||
respBody, err := s.client.DoGetRequest(ctx, "/5gcmp/openapi/v1/common/singleCutNet", params)
|
||||
if err != nil {
|
||||
g.Log().Errorf(ctx, "ChangeSimApnFunction error %+v, simKey %s, params %+v", err, simKey.ChanKey, params)
|
||||
item.Code, item.Message = consts.ApiStatusError, err.Error()
|
||||
continue
|
||||
}
|
||||
|
||||
g.Log().Debugf(ctx, "ChangeSimApnFunction response: %s", string(respBody))
|
||||
|
||||
// 去除XML命名空间前缀
|
||||
xmlBody := xmlNamespaceRegex.ReplaceAllString(string(respBody), "")
|
||||
|
||||
var dxResp dxCutNetResponse
|
||||
if err = xml.Unmarshal([]byte(xmlBody), &dxResp); err != nil {
|
||||
g.Log().Errorf(ctx, "ChangeSimApnFunction unmarshal error %+v, body %s", err, xmlBody)
|
||||
item.Code, item.Message = consts.ApiStatusError, err.Error()
|
||||
continue
|
||||
}
|
||||
|
||||
item.Message = strings.TrimSpace(dxResp.Response.RspDesc)
|
||||
rawCode := strings.TrimSpace(dxResp.Response.RspCode)
|
||||
item.ErrCode = rawCode
|
||||
// 缺返回码、非 0000 均不视为成功,不自动重试以免重复操作。
|
||||
if rawCode != dxCutNetSuccessCode {
|
||||
g.Log().Warningf(ctx, "ChangeSimApnFunction upstream error %s, %s, simKey %s", rawCode, item.Message, simKey.ChanKey)
|
||||
item.Code = consts.ApiStatusError
|
||||
if item.Message == "" {
|
||||
item.Message = "电信单独断网未返回结果码,办理结果未确认,请核对上游"
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
item.Code = consts.ApiStatusSuccess
|
||||
item.ResData.OrderNum = strings.TrimSpace(dxResp.GroupTransactionID)
|
||||
if item.ResData.OrderNum == "" {
|
||||
item.Code, item.Message = consts.ApiStatusError, "电信单独断网未返回订单号,办理结果未确认,请核对上游"
|
||||
}
|
||||
}
|
||||
|
||||
return items, nil
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
package upstreamimpl
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/logic/upstreamimpl/dx"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/model"
|
||||
)
|
||||
|
||||
func TestDXQueryAPNV3(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
body string
|
||||
wantCode int
|
||||
wantStatus string
|
||||
wantCount int
|
||||
wantErrCode string
|
||||
}{
|
||||
{name: "table status enabled", body: `{"code":"0","result":{"apnInfoList":[{"apnName":"ctnet","status":"1"}]}}`, wantStatus: "1", wantCount: 1},
|
||||
{name: "example apnStatus stopped", body: `{"code":"0","result":{"apnInfoList":[{"apnName":"ctnet","apnStatus":"0"}]}}`, wantStatus: "0", wantCount: 1},
|
||||
{name: "missing status stays unknown", body: `{"code":"0","result":{"apnInfoList":[{"apnName":"ctnet"}]}}`, wantCount: 1},
|
||||
{name: "conflicting aliases stay unknown", body: `{"code":"0","result":{"apnInfoList":[{"apnName":"ctnet","status":"1","apnStatus":"0"}]}}`, wantCount: 1},
|
||||
{name: "invalid status stays unknown", body: `{"code":"0","result":{"apnInfoList":[{"apnName":"ctnet","status":"2"}]}}`, wantCount: 1},
|
||||
{name: "empty list", body: `{"code":0,"result":{"apnInfoList":[]}}`},
|
||||
{name: "missing code", body: `{"result":{"apnInfoList":[]}}`, wantCode: -1},
|
||||
{name: "null code", body: `{"code":null,"result":{"apnInfoList":[]}}`, wantCode: -1, wantErrCode: "null"},
|
||||
{name: "missing result", body: `{"code":"0"}`, wantCode: -1},
|
||||
{name: "missing list", body: `{"code":"0","result":{}}`, wantCode: -1},
|
||||
{name: "carrier failure", body: `{"code":"14008","message":"not found","result":null}`, wantCode: 14008, wantErrCode: "14008"},
|
||||
{name: "symbolic carrier failure", body: `{"code":"S00004","message":"denied"}`, wantCode: -1, wantErrCode: "S00004"},
|
||||
{name: "malformed", body: `<xml/>`, wantCode: -1},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
||||
if request.URL.Path != "/api/v3/getApnInfo" || request.Method != http.MethodGet {
|
||||
t.Errorf("unexpected request: %s %s", request.Method, request.URL.Path)
|
||||
}
|
||||
if request.URL.Query().Get("msisdn") != "138****8000" || request.URL.Query().Get("access_number") != "" {
|
||||
t.Errorf("query = %s", request.URL.RawQuery)
|
||||
}
|
||||
if request.Header.Get("AppKey") != "test" || request.Header.Get("Sign") == "" || request.Header.Get("Timestamp") == "" {
|
||||
t.Error("missing signed request headers")
|
||||
}
|
||||
fmt.Fprint(writer, test.body)
|
||||
}))
|
||||
defer server.Close()
|
||||
sim := &sSimDX{client: dx.NewClient(dx.Config{BaseURL: server.URL, AppKey: "test", AppSecret: "secret"})}
|
||||
output, err := sim.QuerySimApnInfo(context.Background(), &model.SimApnInfoInput{SimKeys: []*model.SimKey{{MSISDN: "138****8000"}}})
|
||||
if err != nil || len(output) != 1 {
|
||||
t.Fatalf("output = %+v, error = %v", output, err)
|
||||
}
|
||||
item := output[0]
|
||||
if (test.wantCode == -1 && item.Code == 0) || (test.wantCode != -1 && item.Code != test.wantCode) || item.ErrCode != test.wantErrCode {
|
||||
t.Fatalf("code = %d, raw = %q", item.Code, item.ErrCode)
|
||||
}
|
||||
if len(item.ResData.ApnList) != test.wantCount {
|
||||
t.Fatalf("APNs = %+v", item.ResData.ApnList)
|
||||
}
|
||||
if test.wantCount != 0 && (item.ResData.ApnList[0].Status != test.wantStatus || item.ResData.ApnList[0].ApnName != "ctnet") {
|
||||
t.Fatalf("APN = %+v", item.ResData.ApnList[0])
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDXAPNIdentifierFallback(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
key *model.SimKey
|
||||
param string
|
||||
value string
|
||||
}{
|
||||
{name: "iccid", key: &model.SimKey{ICCID: " 8986110000000000000 "}, param: "iccid", value: "8986110000000000000"},
|
||||
{name: "imsi", key: &model.SimKey{IMSI: "460030000000000"}, param: "imsi", value: "460030000000000"},
|
||||
{name: "nil", key: nil},
|
||||
{name: "empty", key: &model.SimKey{}},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
requests := 0
|
||||
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
||||
requests++
|
||||
if request.URL.Query().Get(test.param) != test.value {
|
||||
t.Errorf("query = %s", request.URL.RawQuery)
|
||||
}
|
||||
fmt.Fprint(writer, `{"code":"0","result":{"apnInfoList":[]}}`)
|
||||
}))
|
||||
defer server.Close()
|
||||
sim := &sSimDX{client: dx.NewClient(dx.Config{BaseURL: server.URL})}
|
||||
output, err := sim.QuerySimApnInfo(context.Background(), &model.SimApnInfoInput{SimKeys: []*model.SimKey{test.key}})
|
||||
if err != nil || len(output) != 1 {
|
||||
t.Fatalf("output = %+v, error = %v", output, err)
|
||||
}
|
||||
if test.param == "" {
|
||||
if output[0].Code == 0 || requests != 0 {
|
||||
t.Fatal("missing identifier must not request upstream")
|
||||
}
|
||||
} else if output[0].Code != 0 || requests != 1 {
|
||||
t.Fatalf("code = %d, requests = %d", output[0].Code, requests)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestDXChangeAPNSingleCutNet 验证电信数据开停走 singleCutNet,且 ADD/DEL 方向与平台语义相反。
|
||||
func TestDXChangeAPNSingleCutNet(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
operType string
|
||||
wantAction string
|
||||
}{
|
||||
{name: "platform open resumes network", operType: "0", wantAction: "DEL"},
|
||||
{name: "platform stop cuts network", operType: "1", wantAction: "ADD"},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
requests := 0
|
||||
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
||||
requests++
|
||||
if request.URL.Path != "/5gcmp/openapi/v1/common/singleCutNet" || request.Method != http.MethodGet {
|
||||
t.Errorf("unexpected request: %s %s", request.Method, request.URL.Path)
|
||||
}
|
||||
query := request.URL.Query()
|
||||
if query.Get("action") != test.wantAction {
|
||||
t.Errorf("action = %q, want %q", query.Get("action"), test.wantAction)
|
||||
}
|
||||
if query.Get("access_number") != "138****8000" {
|
||||
t.Errorf("access_number = %q", query.Get("access_number"))
|
||||
}
|
||||
// 不能携带 APN 定向参数,singleCutNet 是整卡语义。
|
||||
if query.Get("apnName") != "" || query.Get("operType") != "" {
|
||||
t.Errorf("APN-scoped params must not be sent: %s", request.URL.RawQuery)
|
||||
}
|
||||
if request.Header.Get("AppKey") != "test" || request.Header.Get("Sign") == "" || request.Header.Get("Timestamp") == "" {
|
||||
t.Error("missing signed request headers")
|
||||
}
|
||||
fmt.Fprint(writer, `<?xml version="1.0" encoding="UTF-8"?><SvcCont><Response><RspType>0</RspType><RspCode>0000</RspCode><RspDesc>成功接收消息</RspDesc></Response><GROUP_TRANSACTIONID>order-1</GROUP_TRANSACTIONID></SvcCont>`)
|
||||
}))
|
||||
defer server.Close()
|
||||
sim := &sSimDX{client: dx.NewClient(dx.Config{BaseURL: server.URL, AppKey: "test", AppSecret: "secret"})}
|
||||
// 即使调用方传了移动默认的 apnName="CMIOT",也必须正常执行。
|
||||
output, err := sim.ChangeSimApnFunction(context.Background(), &model.SimCardOperateChangeApnInput{
|
||||
SimKeys: []*model.SimKey{{MSISDN: "138****8000", ChanKey: "71"}},
|
||||
ReqData: model.SimCardOperateChangeApnInputItem{ApnName: "CMIOT", OperType: test.operType},
|
||||
})
|
||||
if err != nil || len(output) != 1 || output[0].Code != 0 || output[0].ResData.OrderNum != "order-1" || requests != 1 {
|
||||
t.Fatalf("output = %+v, error = %v, requests = %d", output, err, requests)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestDXChangeAPNSingleCutNetFailClosed 验证失败保护:不成功不重试、不盲目视为成功。
|
||||
func TestDXChangeAPNSingleCutNetFailClosed(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
operType string
|
||||
msisdn string
|
||||
body string
|
||||
httpStatus int
|
||||
wantRequests int
|
||||
}{
|
||||
{name: "missing access number", operType: "0"},
|
||||
{name: "invalid operation", operType: "2", msisdn: "138****8000"},
|
||||
{name: "carrier rejected", operType: "1", msisdn: "138****8000", body: `<?xml version="1.0"?><SvcCont><Response><RspCode>1000</RspCode><RspDesc>rejected</RspDesc></Response></SvcCont>`, wantRequests: 1},
|
||||
{name: "missing result code", operType: "0", msisdn: "138****8000", body: `<?xml version="1.0"?><SvcCont><Response><RspDesc>ok</RspDesc></Response><GROUP_TRANSACTIONID>order</GROUP_TRANSACTIONID></SvcCont>`, wantRequests: 1},
|
||||
{name: "missing order number", operType: "0", msisdn: "138****8000", body: `<?xml version="1.0"?><SvcCont><Response><RspCode>0000</RspCode><RspDesc>ok</RspDesc></Response></SvcCont>`, wantRequests: 1},
|
||||
{name: "malformed response", operType: "0", msisdn: "138****8000", body: `{"code":"0"}`, wantRequests: 1},
|
||||
{name: "HTTP failed no retry", operType: "0", msisdn: "138****8000", body: `gateway down`, httpStatus: 503, wantRequests: 1},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
requests := 0
|
||||
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
||||
requests++
|
||||
if request.URL.Path != "/5gcmp/openapi/v1/common/singleCutNet" {
|
||||
t.Errorf("unexpected endpoint: %s", request.URL.Path)
|
||||
}
|
||||
if test.httpStatus != 0 {
|
||||
writer.WriteHeader(test.httpStatus)
|
||||
}
|
||||
fmt.Fprint(writer, test.body)
|
||||
}))
|
||||
defer server.Close()
|
||||
sim := &sSimDX{client: dx.NewClient(dx.Config{BaseURL: server.URL})}
|
||||
output, err := sim.ChangeSimApnFunction(context.Background(), &model.SimCardOperateChangeApnInput{
|
||||
SimKeys: []*model.SimKey{{MSISDN: test.msisdn, ChanKey: "71"}},
|
||||
ReqData: model.SimCardOperateChangeApnInputItem{ApnName: "CMIOT", OperType: test.operType},
|
||||
})
|
||||
if err != nil || len(output) != 1 || output[0].Code == 0 || output[0].ResData.OrderNum != "" || requests != test.wantRequests {
|
||||
t.Fatalf("output = %+v, error = %v, requests = %d", output, err, requests)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,641 @@
|
||||
package upstreamimpl
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/consts"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/logic/upstreamimpl/dx"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/model"
|
||||
"math"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
"github.com/gogf/gf/v2/util/gconv"
|
||||
)
|
||||
|
||||
const (
|
||||
dxBatchStatusLimit = 10000
|
||||
dxBatchUsageLimit = 100
|
||||
)
|
||||
|
||||
type telecomBatchKey struct {
|
||||
index int
|
||||
simKey *model.SimKey
|
||||
accessNumber string
|
||||
}
|
||||
|
||||
type dxBatchRequest struct {
|
||||
AccessNumbers []string `json:"accessNumbers"`
|
||||
CustNumber string `json:"custNumber"`
|
||||
GroupID string `json:"groupId,omitempty"`
|
||||
SimStatus string `json:"simStatus,omitempty"`
|
||||
FlowDate string `json:"flowDate,omitempty"`
|
||||
}
|
||||
|
||||
type dxBatchStatusResponse struct {
|
||||
Code any `json:"code"`
|
||||
Message string `json:"message"`
|
||||
Msg string `json:"msg"`
|
||||
Data struct {
|
||||
QrySimStatusList []dxBatchStatusItem `json:"qrySimStatusList"`
|
||||
StatusList []dxBatchStatusItem `json:"statusList"`
|
||||
} `json:"data"`
|
||||
}
|
||||
|
||||
type dxBatchStatusItem struct {
|
||||
AccessNumber string `json:"accessNumber"`
|
||||
AccNum string `json:"accNum"`
|
||||
Result any `json:"result"`
|
||||
Iccid string `json:"iccid"`
|
||||
Imsi string `json:"imsi"`
|
||||
SimStatus any `json:"simStatus"`
|
||||
}
|
||||
|
||||
type dxBatchFlowResponse struct {
|
||||
Code any `json:"code"`
|
||||
Message string `json:"message"`
|
||||
Msg string `json:"msg"`
|
||||
Data []dxBatchFlowItem `json:"data"`
|
||||
}
|
||||
|
||||
type dxBatchFlowItem struct {
|
||||
AccessNumber string `json:"accessNumber"`
|
||||
AccNum string `json:"accNum"`
|
||||
Result any `json:"result"`
|
||||
Unit string `json:"unit"`
|
||||
FlowAmount any `json:"flowAmount"`
|
||||
FlowDate string `json:"flowDate"`
|
||||
FlowDay string `json:"flowDay"`
|
||||
}
|
||||
|
||||
func parseDXCustNumber(ctx context.Context, channelConfig *model.SimChannelConfigItemCache) string {
|
||||
if channelConfig == nil || channelConfig.Channel == nil {
|
||||
return ""
|
||||
}
|
||||
var cfg struct {
|
||||
CustNumber string `json:"custNumber"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(channelConfig.Channel.ChanConfig), &cfg); err != nil {
|
||||
g.Log().Warningf(ctx, "parse telecom channel config failed, chanKey=%s err=%+v", channelConfig.Channel.ChanKey, err)
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(cfg.CustNumber)
|
||||
}
|
||||
|
||||
func parseDXBaseURL(ctx context.Context, channelConfig *model.SimChannelConfigItemCache) string {
|
||||
if channelConfig == nil || channelConfig.Channel == nil {
|
||||
return ""
|
||||
}
|
||||
if channelConfig.Template != nil {
|
||||
var template struct {
|
||||
API struct {
|
||||
BaseURL string `json:"baseUrl"`
|
||||
} `json:"api"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(channelConfig.Template.TempConfig), &template); err == nil && strings.TrimSpace(template.API.BaseURL) != "" {
|
||||
return strings.TrimRight(strings.TrimSpace(template.API.BaseURL), "/")
|
||||
}
|
||||
}
|
||||
var cfg struct {
|
||||
BaseURL string `json:"baseURL"`
|
||||
URL string `json:"url"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(channelConfig.Channel.ChanConfig), &cfg); err != nil {
|
||||
g.Log().Warningf(ctx, "parse telecom channel config failed, chanKey=%s err=%+v", channelConfig.Channel.ChanKey, err)
|
||||
return ""
|
||||
}
|
||||
baseURL := strings.TrimSpace(firstNonEmpty(cfg.BaseURL, cfg.URL))
|
||||
if baseURL == "" {
|
||||
baseURL = strings.TrimSpace(g.Cfg().MustGet(ctx, "dependencies.sim_dx_v1.url", dx.DefaultBaseURL).String())
|
||||
}
|
||||
return strings.TrimRight(baseURL, "/")
|
||||
}
|
||||
|
||||
func (s *sSimDX) QuerySimCardStatusBatch(ctx context.Context, input *model.SimCardStatusInput) (model.SimCardStatusOutput, error) {
|
||||
return s.queryCardStatusBatch(ctx, input)
|
||||
}
|
||||
|
||||
func (s *sSimDX) QuerySimDataUsageMonthlyBatch(ctx context.Context, input *model.SimDataUsageMonthlyInput) (model.SimDataUsageMonthlyOutput, error) {
|
||||
return s.queryDataUsageMonthlyBatch(ctx, input)
|
||||
}
|
||||
|
||||
func (s *sSimDX) QuerySimDataUsageDailyBatch(ctx context.Context, input *model.SimDataUsageDailyInput) (model.SimDataUsageDailyOutput, error) {
|
||||
return s.queryDataUsageDailyBatch(ctx, input)
|
||||
}
|
||||
|
||||
func (s *sSimDX) queryCardStatusBatch(ctx context.Context, input *model.SimCardStatusInput) (model.SimCardStatusOutput, error) {
|
||||
if err := s.ensureDXBatchConfig(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if input == nil || len(input.SimKeys) == 0 {
|
||||
return make(model.SimCardStatusOutput, 0), nil
|
||||
}
|
||||
|
||||
results := make(model.SimCardStatusOutput, len(input.SimKeys))
|
||||
pending := make([]telecomBatchKey, 0, len(input.SimKeys))
|
||||
for i, simKey := range input.SimKeys {
|
||||
if simKey == nil || strings.TrimSpace(simKey.MSISDN) == "" {
|
||||
results[i] = &model.SimBaseRes[model.SimCardStatusItem]{
|
||||
SimKey: simKey,
|
||||
Code: consts.ApiStatusError,
|
||||
Message: "MSISDN not found",
|
||||
}
|
||||
continue
|
||||
}
|
||||
pending = append(pending, telecomBatchKey{
|
||||
index: i,
|
||||
simKey: simKey,
|
||||
accessNumber: strings.TrimSpace(simKey.MSISDN),
|
||||
})
|
||||
}
|
||||
|
||||
for _, chunk := range chunkTelecomBatchKeys(pending, dxBatchStatusLimit) {
|
||||
accessNumbers := make([]string, 0, len(chunk))
|
||||
for _, item := range chunk {
|
||||
accessNumbers = append(accessNumbers, item.accessNumber)
|
||||
}
|
||||
|
||||
resp, err := s.doDXBatchStatusPost(ctx, "/api/v1/prod/batchQrySimStatus", dxBatchRequest{
|
||||
AccessNumbers: accessNumbers,
|
||||
CustNumber: s.custNumber,
|
||||
GroupID: "",
|
||||
SimStatus: "",
|
||||
})
|
||||
if err != nil {
|
||||
for _, item := range chunk {
|
||||
results[item.index] = &model.SimBaseRes[model.SimCardStatusItem]{
|
||||
SimKey: item.simKey,
|
||||
Code: consts.ApiStatusError,
|
||||
Message: err.Error(),
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
batchItems := resp.Data.QrySimStatusList
|
||||
if len(batchItems) == 0 {
|
||||
batchItems = resp.Data.StatusList
|
||||
}
|
||||
itemMap := make(map[string]dxBatchStatusItem, len(batchItems))
|
||||
for _, item := range batchItems {
|
||||
accessNumber := telecomBatchAccessNumber(item.AccessNumber, item.AccNum)
|
||||
if accessNumber == "" {
|
||||
continue
|
||||
}
|
||||
itemMap[accessNumber] = item
|
||||
}
|
||||
|
||||
for _, item := range chunk {
|
||||
batchItem, ok := itemMap[item.accessNumber]
|
||||
if !ok {
|
||||
results[item.index] = &model.SimBaseRes[model.SimCardStatusItem]{
|
||||
SimKey: item.simKey,
|
||||
Code: consts.ApiStatusError,
|
||||
Message: "batch response missing accessNumber",
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
itemResult := gconv.String(batchItem.Result)
|
||||
itemStatus := gconv.String(batchItem.SimStatus)
|
||||
if itemResult != "1" {
|
||||
results[item.index] = &model.SimBaseRes[model.SimCardStatusItem]{
|
||||
SimKey: item.simKey,
|
||||
Code: telecomBatchResultCode(itemResult),
|
||||
Message: firstNonEmpty(resp.Message, resp.Msg, "telecom batch status query failed"),
|
||||
ResData: model.SimCardStatusItem{
|
||||
CardStatus: 0,
|
||||
LastChangeDate: "",
|
||||
},
|
||||
}
|
||||
continue
|
||||
}
|
||||
if itemStatus == "" {
|
||||
results[item.index] = &model.SimBaseRes[model.SimCardStatusItem]{
|
||||
SimKey: item.simKey,
|
||||
Code: consts.ApiStatusError,
|
||||
Message: "simStatus missing in batch response",
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
results[item.index] = &model.SimBaseRes[model.SimCardStatusItem]{
|
||||
SimKey: item.simKey,
|
||||
Code: 0,
|
||||
Message: "",
|
||||
ResData: model.SimCardStatusItem{
|
||||
CardStatus: convertDXBatchCardStatus(ctx, itemStatus),
|
||||
LastChangeDate: "",
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return compactStatusBatchResults(results), nil
|
||||
}
|
||||
|
||||
func (s *sSimDX) queryDataUsageMonthlyBatch(ctx context.Context, input *model.SimDataUsageMonthlyInput) (model.SimDataUsageMonthlyOutput, error) {
|
||||
if err := s.ensureDXBatchConfig(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if input == nil || len(input.SimKeys) == 0 {
|
||||
return make(model.SimDataUsageMonthlyOutput, 0), nil
|
||||
}
|
||||
queryMonth := strings.TrimSpace(input.ReqData.QueryDate)
|
||||
if queryMonth == "" {
|
||||
return nil, fmt.Errorf("queryDate is required")
|
||||
}
|
||||
if _, err := time.Parse("200601", queryMonth); err != nil {
|
||||
return nil, fmt.Errorf("invalid queryDate: %s", queryMonth)
|
||||
}
|
||||
if !dxBatchMonthAvailable(queryMonth, time.Now()) {
|
||||
return nil, fmt.Errorf("电信月批量接口仅支持当月;历史月份请使用 /sim/query/sim-datausage-monthly")
|
||||
}
|
||||
|
||||
results := make(model.SimDataUsageMonthlyOutput, len(input.SimKeys))
|
||||
pending := make([]telecomBatchKey, 0, len(input.SimKeys))
|
||||
for i, simKey := range input.SimKeys {
|
||||
if simKey == nil || strings.TrimSpace(simKey.MSISDN) == "" {
|
||||
results[i] = &model.SimBaseRes[model.SimDataUsageMonthlyOutputItem]{
|
||||
SimKey: simKey,
|
||||
Code: consts.ApiStatusError,
|
||||
Message: "MSISDN not found",
|
||||
ResData: model.SimDataUsageMonthlyOutputItem{},
|
||||
}
|
||||
continue
|
||||
}
|
||||
pending = append(pending, telecomBatchKey{
|
||||
index: i,
|
||||
simKey: simKey,
|
||||
accessNumber: strings.TrimSpace(simKey.MSISDN),
|
||||
})
|
||||
}
|
||||
|
||||
for _, chunk := range chunkTelecomBatchKeys(pending, dxBatchUsageLimit) {
|
||||
accessNumbers := make([]string, 0, len(chunk))
|
||||
for _, item := range chunk {
|
||||
accessNumbers = append(accessNumbers, item.accessNumber)
|
||||
}
|
||||
|
||||
resp, err := s.doDXBatchFlowPost(ctx, "/api/v1/batchQry/batchQryFlowByMonth", dxBatchRequest{
|
||||
AccessNumbers: accessNumbers,
|
||||
CustNumber: s.custNumber,
|
||||
})
|
||||
if err != nil {
|
||||
for _, item := range chunk {
|
||||
results[item.index] = &model.SimBaseRes[model.SimDataUsageMonthlyOutputItem]{
|
||||
SimKey: item.simKey,
|
||||
Code: consts.ApiStatusError,
|
||||
Message: err.Error(),
|
||||
ResData: model.SimDataUsageMonthlyOutputItem{DeadLine: queryMonth},
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
itemMap := make(map[string]dxBatchFlowItem, len(resp.Data))
|
||||
for _, item := range resp.Data {
|
||||
accessNumber := telecomBatchAccessNumber(item.AccessNumber, item.AccNum)
|
||||
if accessNumber == "" {
|
||||
continue
|
||||
}
|
||||
itemMap[accessNumber] = item
|
||||
}
|
||||
|
||||
for _, item := range chunk {
|
||||
batchItem, ok := itemMap[item.accessNumber]
|
||||
if !ok {
|
||||
results[item.index] = &model.SimBaseRes[model.SimDataUsageMonthlyOutputItem]{
|
||||
SimKey: item.simKey,
|
||||
Code: consts.ApiStatusError,
|
||||
Message: "batch response missing accessNumber",
|
||||
ResData: model.SimDataUsageMonthlyOutputItem{},
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
itemResult := gconv.String(batchItem.Result)
|
||||
dataAmount, amountErr := parseDXBatchAmount(batchItem)
|
||||
if itemResult == "1" && amountErr != nil {
|
||||
results[item.index] = &model.SimBaseRes[model.SimDataUsageMonthlyOutputItem]{SimKey: item.simKey, Code: consts.ApiStatusError, Message: amountErr.Error()}
|
||||
continue
|
||||
}
|
||||
if itemResult != "1" {
|
||||
results[item.index] = &model.SimBaseRes[model.SimDataUsageMonthlyOutputItem]{
|
||||
SimKey: item.simKey,
|
||||
Code: telecomBatchResultCode(itemResult),
|
||||
Message: firstNonEmpty(resp.Message, resp.Msg, "telecom batch monthly usage query failed"),
|
||||
ResData: model.SimDataUsageMonthlyOutputItem{
|
||||
DeadLine: queryMonth,
|
||||
DataAmount: dataAmount,
|
||||
},
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
results[item.index] = &model.SimBaseRes[model.SimDataUsageMonthlyOutputItem]{
|
||||
SimKey: item.simKey,
|
||||
Code: 0,
|
||||
Message: "",
|
||||
ResData: model.SimDataUsageMonthlyOutputItem{
|
||||
DeadLine: queryMonth,
|
||||
DataAmount: dataAmount,
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return compactMonthlyBatchResults(results), nil
|
||||
}
|
||||
|
||||
func (s *sSimDX) queryDataUsageDailyBatch(ctx context.Context, input *model.SimDataUsageDailyInput) (model.SimDataUsageDailyOutput, error) {
|
||||
if err := s.ensureDXBatchConfig(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if input == nil || len(input.SimKeys) == 0 {
|
||||
return make(model.SimDataUsageDailyOutput, 0), nil
|
||||
}
|
||||
queryDate := strings.TrimSpace(input.ReqData.QueryDate)
|
||||
if queryDate == "" {
|
||||
return nil, fmt.Errorf("queryDate is required")
|
||||
}
|
||||
if _, err := time.Parse("20060102", queryDate); err != nil {
|
||||
return nil, fmt.Errorf("invalid queryDate: %s", queryDate)
|
||||
}
|
||||
if !dxBatchDayAvailable(queryDate, time.Now()) {
|
||||
return nil, fmt.Errorf("电信日批量接口仅支持最近7个已结束日期")
|
||||
}
|
||||
|
||||
results := make(model.SimDataUsageDailyOutput, len(input.SimKeys))
|
||||
pending := make([]telecomBatchKey, 0, len(input.SimKeys))
|
||||
for i, simKey := range input.SimKeys {
|
||||
if simKey == nil || strings.TrimSpace(simKey.MSISDN) == "" {
|
||||
results[i] = &model.SimBaseRes[model.SimDataUsageDailyOutputItem]{
|
||||
SimKey: simKey,
|
||||
Code: consts.ApiStatusError,
|
||||
Message: "MSISDN not found",
|
||||
ResData: model.SimDataUsageDailyOutputItem{QueryDate: queryDate},
|
||||
}
|
||||
continue
|
||||
}
|
||||
pending = append(pending, telecomBatchKey{
|
||||
index: i,
|
||||
simKey: simKey,
|
||||
accessNumber: strings.TrimSpace(simKey.MSISDN),
|
||||
})
|
||||
}
|
||||
|
||||
for _, chunk := range chunkTelecomBatchKeys(pending, dxBatchUsageLimit) {
|
||||
accessNumbers := make([]string, 0, len(chunk))
|
||||
for _, item := range chunk {
|
||||
accessNumbers = append(accessNumbers, item.accessNumber)
|
||||
}
|
||||
|
||||
resp, err := s.doDXBatchFlowPost(ctx, "/api/v1/batchQry/batchQryFlowByDate", dxBatchRequest{
|
||||
AccessNumbers: accessNumbers,
|
||||
CustNumber: s.custNumber,
|
||||
FlowDate: queryDate,
|
||||
})
|
||||
if err != nil {
|
||||
for _, item := range chunk {
|
||||
results[item.index] = &model.SimBaseRes[model.SimDataUsageDailyOutputItem]{
|
||||
SimKey: item.simKey,
|
||||
Code: consts.ApiStatusError,
|
||||
Message: err.Error(),
|
||||
ResData: model.SimDataUsageDailyOutputItem{QueryDate: queryDate},
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
itemMap := make(map[string]dxBatchFlowItem, len(resp.Data))
|
||||
for _, item := range resp.Data {
|
||||
accessNumber := telecomBatchAccessNumber(item.AccessNumber, item.AccNum)
|
||||
if accessNumber == "" {
|
||||
continue
|
||||
}
|
||||
itemMap[accessNumber] = item
|
||||
}
|
||||
|
||||
for _, item := range chunk {
|
||||
batchItem, ok := itemMap[item.accessNumber]
|
||||
if !ok {
|
||||
results[item.index] = &model.SimBaseRes[model.SimDataUsageDailyOutputItem]{
|
||||
SimKey: item.simKey,
|
||||
Code: consts.ApiStatusError,
|
||||
Message: "batch response missing accessNumber",
|
||||
ResData: model.SimDataUsageDailyOutputItem{QueryDate: queryDate},
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
itemResult := gconv.String(batchItem.Result)
|
||||
dataAmount, amountErr := parseDXBatchAmount(batchItem)
|
||||
if itemResult == "1" && amountErr != nil {
|
||||
results[item.index] = &model.SimBaseRes[model.SimDataUsageDailyOutputItem]{SimKey: item.simKey, Code: consts.ApiStatusError, Message: amountErr.Error()}
|
||||
continue
|
||||
}
|
||||
batchQueryDate := firstNonEmpty(strings.TrimSpace(batchItem.FlowDate), strings.TrimSpace(batchItem.FlowDay))
|
||||
if batchQueryDate == "" {
|
||||
batchQueryDate = queryDate
|
||||
}
|
||||
if batchQueryDate != queryDate {
|
||||
results[item.index] = &model.SimBaseRes[model.SimDataUsageDailyOutputItem]{SimKey: item.simKey, Code: consts.ApiStatusError, Message: "batch response date mismatched"}
|
||||
continue
|
||||
}
|
||||
if itemResult != "1" {
|
||||
results[item.index] = &model.SimBaseRes[model.SimDataUsageDailyOutputItem]{
|
||||
SimKey: item.simKey,
|
||||
Code: telecomBatchResultCode(itemResult),
|
||||
Message: firstNonEmpty(resp.Message, resp.Msg, "telecom batch daily usage query failed"),
|
||||
ResData: model.SimDataUsageDailyOutputItem{
|
||||
QueryDate: batchQueryDate,
|
||||
DataAmount: dataAmount,
|
||||
},
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
results[item.index] = &model.SimBaseRes[model.SimDataUsageDailyOutputItem]{
|
||||
SimKey: item.simKey,
|
||||
Code: 0,
|
||||
Message: "",
|
||||
ResData: model.SimDataUsageDailyOutputItem{
|
||||
QueryDate: batchQueryDate,
|
||||
DataAmount: dataAmount,
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return compactDailyBatchResults(results), nil
|
||||
}
|
||||
|
||||
func (s *sSimDX) ensureDXBatchConfig() error {
|
||||
if strings.TrimSpace(s.custNumber) == "" {
|
||||
return fmt.Errorf("telecom custNumber not configured")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
var dxBatchLocation = time.FixedZone("UTC+8", 8*60*60)
|
||||
|
||||
func parseDXBatchAmount(item dxBatchFlowItem) (float64, error) {
|
||||
amount, err := strconv.ParseFloat(strings.TrimSpace(gconv.String(item.FlowAmount)), 64)
|
||||
if err != nil || math.IsNaN(amount) || math.IsInf(amount, 0) || amount < 0 {
|
||||
return 0, fmt.Errorf("invalid telecom batch flowAmount")
|
||||
}
|
||||
if unit := strings.ToUpper(strings.TrimSpace(item.Unit)); unit != "" && unit != "MB" {
|
||||
return 0, fmt.Errorf("unexpected telecom batch usage unit: %s", unit)
|
||||
}
|
||||
return amount, nil
|
||||
}
|
||||
|
||||
func dailyBatchOutputSuccessful(input *model.SimDataUsageDailyInput, output model.SimDataUsageDailyOutput) bool {
|
||||
if input == nil || len(output) != len(input.SimKeys) {
|
||||
return false
|
||||
}
|
||||
queryDate := strings.TrimSpace(input.ReqData.QueryDate)
|
||||
for _, item := range output {
|
||||
if item == nil || item.Code != 0 || item.ResData.QueryDate != queryDate {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func dxBatchDayAvailable(queryDate string, now time.Time) bool {
|
||||
date, err := time.ParseInLocation("20060102", strings.TrimSpace(queryDate), dxBatchLocation)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
now = now.In(dxBatchLocation)
|
||||
today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, dxBatchLocation)
|
||||
// CTIOT_5GCMP_UQ013 only accepts one completed day within the latest 7 days.
|
||||
return !date.Before(today.AddDate(0, 0, -7)) && date.Before(today)
|
||||
}
|
||||
|
||||
func dxBatchMonthAvailable(queryMonth string, now time.Time) bool {
|
||||
month, err := time.ParseInLocation("200601", strings.TrimSpace(queryMonth), dxBatchLocation)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
now = now.In(dxBatchLocation)
|
||||
// CTIOT_5GCMP_UQ014 is explicitly a current-month batch endpoint.
|
||||
return month.Year() == now.Year() && month.Month() == now.Month()
|
||||
}
|
||||
|
||||
func (s *sSimDX) doDXBatchPost(ctx context.Context, endpoint string, req dxBatchRequest) ([]byte, error) {
|
||||
body, err := json.Marshal(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.client.DoPostJSONRequest(ctx, endpoint, body)
|
||||
}
|
||||
|
||||
func (s *sSimDX) doDXBatchStatusPost(ctx context.Context, endpoint string, req dxBatchRequest) (*dxBatchStatusResponse, error) {
|
||||
respBody, err := s.doDXBatchPost(ctx, endpoint, req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var resp dxBatchStatusResponse
|
||||
if err := json.Unmarshal(respBody, &resp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if gconv.String(resp.Code) != "0" {
|
||||
return nil, fmt.Errorf("telecom batch status request failed: code=%s message=%s", gconv.String(resp.Code), firstNonEmpty(resp.Message, resp.Msg))
|
||||
}
|
||||
return &resp, nil
|
||||
}
|
||||
|
||||
func (s *sSimDX) doDXBatchFlowPost(ctx context.Context, endpoint string, req dxBatchRequest) (*dxBatchFlowResponse, error) {
|
||||
respBody, err := s.doDXBatchPost(ctx, endpoint, req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var resp dxBatchFlowResponse
|
||||
if err := json.Unmarshal(respBody, &resp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if gconv.String(resp.Code) != "0" {
|
||||
return nil, fmt.Errorf("telecom batch flow request failed: code=%s message=%s", gconv.String(resp.Code), firstNonEmpty(resp.Message, resp.Msg))
|
||||
}
|
||||
return &resp, nil
|
||||
}
|
||||
|
||||
func chunkTelecomBatchKeys(items []telecomBatchKey, size int) [][]telecomBatchKey {
|
||||
if size <= 0 {
|
||||
size = 1
|
||||
}
|
||||
if len(items) == 0 {
|
||||
return nil
|
||||
}
|
||||
chunks := make([][]telecomBatchKey, 0, (len(items)+size-1)/size)
|
||||
for start := 0; start < len(items); start += size {
|
||||
end := start + size
|
||||
if end > len(items) {
|
||||
end = len(items)
|
||||
}
|
||||
chunks = append(chunks, items[start:end])
|
||||
}
|
||||
return chunks
|
||||
}
|
||||
|
||||
func telecomBatchAccessNumber(values ...string) string {
|
||||
for _, value := range values {
|
||||
if value = strings.TrimSpace(value); value != "" {
|
||||
return value
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func telecomBatchResultCode(result string) int {
|
||||
result = strings.TrimSpace(result)
|
||||
if result == "" {
|
||||
return consts.ApiStatusError
|
||||
}
|
||||
return gconv.Int(result)
|
||||
}
|
||||
|
||||
func firstNonEmpty(values ...string) string {
|
||||
for _, value := range values {
|
||||
if strings.TrimSpace(value) != "" {
|
||||
return value
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func compactStatusBatchResults(items model.SimCardStatusOutput) model.SimCardStatusOutput {
|
||||
output := make(model.SimCardStatusOutput, 0, len(items))
|
||||
for _, item := range items {
|
||||
if item != nil {
|
||||
output = append(output, item)
|
||||
}
|
||||
}
|
||||
return output
|
||||
}
|
||||
|
||||
func compactMonthlyBatchResults(items model.SimDataUsageMonthlyOutput) model.SimDataUsageMonthlyOutput {
|
||||
output := make(model.SimDataUsageMonthlyOutput, 0, len(items))
|
||||
for _, item := range items {
|
||||
if item != nil {
|
||||
output = append(output, item)
|
||||
}
|
||||
}
|
||||
return output
|
||||
}
|
||||
|
||||
func compactDailyBatchResults(items model.SimDataUsageDailyOutput) model.SimDataUsageDailyOutput {
|
||||
output := make(model.SimDataUsageDailyOutput, 0, len(items))
|
||||
for _, item := range items {
|
||||
if item != nil {
|
||||
output = append(output, item)
|
||||
}
|
||||
}
|
||||
return output
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package upstreamimpl
|
||||
|
||||
import (
|
||||
"git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/model"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func dxSessionAccessMode(netModel string, accessType string) string {
|
||||
switch strings.TrimSpace(netModel) {
|
||||
case "1":
|
||||
return "4G"
|
||||
case "2":
|
||||
return "5G"
|
||||
default:
|
||||
return strings.TrimSpace(accessType)
|
||||
}
|
||||
}
|
||||
|
||||
func dxNetworkBlockStatus(name string, types []model.SimNetBlockType) *string {
|
||||
var status string
|
||||
switch strings.TrimSpace(name) {
|
||||
case "未断网":
|
||||
status = "0"
|
||||
case "已断网":
|
||||
status = "1"
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
for _, item := range types {
|
||||
blockType := strings.TrimSpace(item.BlockType)
|
||||
if (status == "0" && blockType != "" && blockType != "0") || (status == "1" && blockType == "0") {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return &status
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
package upstreamimpl
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/logic/upstreamimpl/dx"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/model"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestDXNetworkBlockStatus(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
types []model.SimNetBlockType
|
||||
want string
|
||||
}{
|
||||
{name: "未断网", types: []model.SimNetBlockType{{BlockType: "0"}}, want: "0"},
|
||||
{name: "已断网", types: []model.SimNetBlockType{{BlockType: "1", BlockTypeName: "达量断网"}}, want: "1"},
|
||||
{name: "已断网", want: "1"},
|
||||
{name: ""},
|
||||
{name: "未知"},
|
||||
{name: "未断网", types: []model.SimNetBlockType{{BlockType: "1"}}},
|
||||
{name: "已断网", types: []model.SimNetBlockType{{BlockType: "0"}}},
|
||||
}
|
||||
for index, test := range tests {
|
||||
got := dxNetworkBlockStatus(test.name, test.types)
|
||||
if test.want == "" {
|
||||
if got != nil {
|
||||
t.Fatalf("case %d: status = %q", index, *got)
|
||||
}
|
||||
} else if got == nil || *got != test.want {
|
||||
t.Fatalf("case %d: status = %v, want %s", index, got, test.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDXCardStatusIncludesNetworkBlock(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
||||
if request.URL.Path != "/openapi/v1/prodinst/queryCardMainStatus" || request.URL.Query().Get("access_number") != "1410964350275" {
|
||||
t.Errorf("request = %s", request.URL)
|
||||
}
|
||||
fmt.Fprint(writer, `{"result":"0","resultMsg":"处理成功","productInfo":[{"productMainStatusCd":"4"}],"netBlockStatusName":"已断网","netBlockTypeList":[{"blockType":"1","blockTypeName":"达量断网"}]}`)
|
||||
}))
|
||||
defer server.Close()
|
||||
sim := &sSimDX{client: dx.NewClient(dx.Config{BaseURL: server.URL})}
|
||||
output, err := sim.QuerySimCardStatus(context.Background(), &model.SimCardStatusInput{SimKeys: []*model.SimKey{{MSISDN: "1410964350275"}}})
|
||||
if err != nil || len(output) != 1 {
|
||||
t.Fatalf("output = %v, err = %v", output, err)
|
||||
}
|
||||
item := output[0]
|
||||
if item.Code != 0 || item.ResData.CardStatus != 2 || item.ResData.NetBlockStatus == nil || *item.ResData.NetBlockStatus != "1" || len(item.ResData.NetBlockTypes) != 1 || item.ResData.NetBlockTypes[0].BlockTypeName != "达量断网" {
|
||||
t.Fatalf("item = %+v", item)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDXSessionAccessMode(t *testing.T) {
|
||||
if got := dxSessionAccessMode("1", ""); got != "4G" {
|
||||
t.Fatalf("mode = %s", got)
|
||||
}
|
||||
if got := dxSessionAccessMode("2", ""); got != "5G" {
|
||||
t.Fatalf("mode = %s", got)
|
||||
}
|
||||
if got := dxSessionAccessMode("", "NB-IOT"); got != "NB-IOT" {
|
||||
t.Fatalf("mode = %s", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
package upstreamimpl
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/consts"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/logic/upstreamimpl/dx"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/model"
|
||||
)
|
||||
|
||||
func TestMapDxRegionLimitStatusByOrder(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
orderStatus string
|
||||
limitStatus string
|
||||
want string
|
||||
}{
|
||||
{name: "not ordered", orderStatus: "1", limitStatus: "0", want: "2"},
|
||||
{name: "ordered and disconnected", orderStatus: "0", limitStatus: "0", want: "1"},
|
||||
{name: "ordered and connected", orderStatus: "0", limitStatus: "1", want: "0"},
|
||||
{name: "network unknown", orderStatus: "0", limitStatus: "-", want: "2"},
|
||||
{name: "unexpected values", orderStatus: "", limitStatus: "", want: "2"},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
if got := mapDxRegionLimitStatusByOrder(test.orderStatus, test.limitStatus); got != test.want {
|
||||
t.Fatalf("mapDxRegionLimitStatusByOrder(%q, %q) = %q, want %q", test.orderStatus, test.limitStatus, got, test.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeDxRegionLimitDetails(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
orderStatus string
|
||||
limitStatus string
|
||||
limitType string
|
||||
limitArea string
|
||||
want []string
|
||||
}{
|
||||
{name: "not ordered hides details", orderStatus: "1", limitStatus: "0", limitType: "4", limitArea: "park", want: []string{"", "", ""}},
|
||||
{name: "ordered preserves documented values", orderStatus: "0", limitStatus: "-", limitType: "4", limitArea: " park ", want: []string{"-", "4", "park"}},
|
||||
{name: "ordered rejects undocumented values", orderStatus: "0", limitStatus: "9", limitType: "0", limitArea: "", want: []string{"", "", ""}},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
limitStatus, limitType, limitArea := normalizeDxRegionLimitDetails(test.orderStatus, test.limitStatus, test.limitType, test.limitArea)
|
||||
if got := []string{limitStatus, limitType, limitArea}; !reflect.DeepEqual(got, test.want) {
|
||||
t.Fatalf("normalizeDxRegionLimitDetails() = %#v, want %#v", got, test.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestQuerySimRegionLimitStatusUsesDocumentedParameter(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/openapi/v1/areaLimit/qryAreaLimitInfo" {
|
||||
t.Fatalf("path = %q", r.URL.Path)
|
||||
}
|
||||
if got := r.URL.Query().Get("accNum"); got != "138****8000" {
|
||||
t.Fatalf("accNum = %q", got)
|
||||
}
|
||||
if got := r.URL.Query().Get("access_number"); got != "" {
|
||||
t.Fatalf("access_number = %q, want empty", got)
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"code":"0","message":"ok","data":{"orderStatus":1,"limitType":0,"limitArea":"","limitStatus":"0"}}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
sim := &sSimDX{client: dx.NewClient(dx.Config{BaseURL: server.URL, AppKey: "test", AppSecret: "secret"})}
|
||||
output, err := sim.QuerySimRegionLimitStatus(context.Background(), &model.SimRegionLimitStatusInput{SimKeys: []*model.SimKey{{MSISDN: "138****8000"}}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(output) != 1 {
|
||||
t.Fatalf("results = %d", len(output))
|
||||
}
|
||||
data := output[0].ResData
|
||||
if data.RegionLimitStatus != "2" || data.OrderStatus != "1" || data.LimitStatus != "" || data.LimitType != "" || data.LimitArea != "" {
|
||||
t.Fatalf("result = %+v", data)
|
||||
}
|
||||
}
|
||||
|
||||
func TestQuerySimCardStatusPreservesTelecomHTTPRespCode(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/openapi/v1/prodinst/queryCardMainStatus" {
|
||||
t.Fatalf("path = %q", r.URL.Path)
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(797)
|
||||
_, _ = w.Write([]byte(`{"respCode":"S00004","respMsg":"request limit exceeded"}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
sim := &sSimDX{client: dx.NewClient(dx.Config{BaseURL: server.URL, AppKey: "test", AppSecret: "secret"})}
|
||||
output, err := sim.QuerySimCardStatus(context.Background(), &model.SimCardStatusInput{SimKeys: []*model.SimKey{{MSISDN: "138****8000"}}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(output) != 1 {
|
||||
t.Fatalf("results = %d", len(output))
|
||||
}
|
||||
result := output[0]
|
||||
if result.Code != consts.ApiStatusError || result.ErrCode != "S00004" {
|
||||
t.Fatalf("result = %+v, want gateway code with S00004", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestQuerySimCardStatusPreservesTelecomBusinessCode(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"result":"11011","resultMsg":"card does not exist"}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
sim := &sSimDX{client: dx.NewClient(dx.Config{BaseURL: server.URL, AppKey: "test", AppSecret: "secret"})}
|
||||
output, err := sim.QuerySimCardStatus(context.Background(), &model.SimCardStatusInput{SimKeys: []*model.SimKey{{MSISDN: "138****8000"}}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(output) != 1 {
|
||||
t.Fatalf("results = %d", len(output))
|
||||
}
|
||||
result := output[0]
|
||||
if result.Code != 11011 || result.ErrCode != "11011" {
|
||||
t.Fatalf("result = %+v, want business code 11011", result)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
package upstreamimpl
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/logic/upstreamimpl/dx"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/model"
|
||||
)
|
||||
|
||||
func TestDXBatchDayAvailable(t *testing.T) {
|
||||
now := time.Date(2026, 9, 20, 12, 0, 0, 0, dxBatchLocation)
|
||||
tests := map[string]bool{
|
||||
"20260913": true,
|
||||
"20260919": true,
|
||||
"20260912": false,
|
||||
"20260920": false,
|
||||
"20260921": false,
|
||||
}
|
||||
for queryDate, want := range tests {
|
||||
if got := dxBatchDayAvailable(queryDate, now); got != want {
|
||||
t.Fatalf("dxBatchDayAvailable(%q) = %v, want %v", queryDate, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDXBatchMonthAvailable(t *testing.T) {
|
||||
now := time.Date(2026, 9, 20, 12, 0, 0, 0, dxBatchLocation)
|
||||
if !dxBatchMonthAvailable("202609", now) {
|
||||
t.Fatal("current month should use telecom batch API")
|
||||
}
|
||||
if dxBatchMonthAvailable("202608", now) || dxBatchMonthAvailable("invalid", now) {
|
||||
t.Fatal("historical or invalid month must not use telecom current-month batch API")
|
||||
}
|
||||
}
|
||||
|
||||
func TestQuerySimDataUsageMonthlyUsesBatchForCurrentMonth(t *testing.T) {
|
||||
var calls atomic.Int32
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
calls.Add(1)
|
||||
if r.URL.Path != "/api/v1/batchQry/batchQryFlowByMonth" {
|
||||
t.Fatalf("path = %q", r.URL.Path)
|
||||
}
|
||||
var request dxBatchRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(request.AccessNumbers) != 2 || request.CustNumber != "cust-1" {
|
||||
t.Fatalf("request = %+v", request)
|
||||
}
|
||||
_, _ = w.Write([]byte(`{"code":0,"msg":"查询成功","data":[{"accessNumber":"138****8001","result":"1","unit":"MB","flowAmount":"12.5"},{"accessNumber":"138****8002","result":"1","unit":"MB","flowAmount":"7.5"}]}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
sim := &sSimDX{client: dx.NewClient(dx.Config{BaseURL: server.URL, AppKey: "test", AppSecret: "secret"}), custNumber: "cust-1"}
|
||||
queryMonth := time.Now().In(dxBatchLocation).Format("200601")
|
||||
output, err := sim.QuerySimDataUsageMonthly(context.Background(), &model.SimDataUsageMonthlyInput{
|
||||
SimKeys: []*model.SimKey{{MSISDN: "138****8001"}, {MSISDN: "138****8002"}},
|
||||
ReqData: model.SimDataUsageMonthlyInputItem{QueryDate: queryMonth},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if calls.Load() != 1 || len(output) != 2 || output[0].ResData.DataAmount != 12.5 || output[1].ResData.DataAmount != 7.5 {
|
||||
t.Fatalf("calls=%d output=%+v", calls.Load(), output)
|
||||
}
|
||||
}
|
||||
|
||||
func TestQuerySimDataUsageDailyLegacyFallbackIsBounded(t *testing.T) {
|
||||
var active atomic.Int32
|
||||
var maximum atomic.Int32
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
current := active.Add(1)
|
||||
defer active.Add(-1)
|
||||
for {
|
||||
previous := maximum.Load()
|
||||
if current <= previous || maximum.CompareAndSwap(previous, current) {
|
||||
break
|
||||
}
|
||||
}
|
||||
time.Sleep(30 * time.Millisecond)
|
||||
w.Header().Set("Content-Type", "application/xml")
|
||||
_, _ = w.Write([]byte(`<root><NEW_DATA_TICKET_QRsp><IRESULT>0</IRESULT><SMSG>OK</SMSG><TOTAL_BYTES_CNT>1MB</TOTAL_BYTES_CNT></NEW_DATA_TICKET_QRsp></root>`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
sim := &sSimDX{client: dx.NewClient(dx.Config{BaseURL: server.URL, AppKey: "test", AppSecret: "secret", Timeout: time.Second})}
|
||||
keys := make([]*model.SimKey, 12)
|
||||
for index := range keys {
|
||||
keys[index] = &model.SimKey{MSISDN: "138****8003"}
|
||||
}
|
||||
output, err := sim.QuerySimDataUsageDaily(context.Background(), &model.SimDataUsageDailyInput{
|
||||
SimKeys: keys,
|
||||
ReqData: model.SimDataUsageDailyInputItem{QueryDate: "20260101"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(output) != len(keys) {
|
||||
t.Fatalf("results = %d, want %d", len(output), len(keys))
|
||||
}
|
||||
if maximum.Load() > dxLegacyUsageConcurrency || maximum.Load() < 2 {
|
||||
t.Fatalf("maximum concurrency = %d, want 2..%d", maximum.Load(), dxLegacyUsageConcurrency)
|
||||
}
|
||||
}
|
||||
|
||||
func TestQuerySimDataUsageDailyRejectsMalformedXML(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
body string
|
||||
}{
|
||||
{name: "missing business node", body: `<root><ERROR>denied</ERROR></root>`},
|
||||
{name: "missing result", body: `<root><NEW_DATA_TICKET_QRsp><TOTAL_BYTES_CNT>12MB</TOTAL_BYTES_CNT></NEW_DATA_TICKET_QRsp></root>`},
|
||||
{name: "missing amount", body: `<root><NEW_DATA_TICKET_QRsp><IRESULT>0</IRESULT></NEW_DATA_TICKET_QRsp></root>`},
|
||||
{name: "invalid amount", body: `<root><NEW_DATA_TICKET_QRsp><IRESULT>0</IRESULT><TOTAL_BYTES_CNT>invalidMB</TOTAL_BYTES_CNT></NEW_DATA_TICKET_QRsp></root>`},
|
||||
{name: "non numeric result", body: `<root><NEW_DATA_TICKET_QRsp><IRESULT>ERROR</IRESULT></NEW_DATA_TICKET_QRsp></root>`},
|
||||
}
|
||||
for _, item := range cases {
|
||||
t.Run(item.name, func(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
||||
writer.Header().Set("Content-Type", "application/xml")
|
||||
_, _ = writer.Write([]byte(item.body))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
sim := &sSimDX{client: dx.NewClient(dx.Config{BaseURL: server.URL, AppKey: "test", AppSecret: "test"})}
|
||||
output, err := sim.QuerySimDataUsageDaily(context.Background(), &model.SimDataUsageDailyInput{
|
||||
SimKeys: []*model.SimKey{{ICCID: "audit-card", MSISDN: "138****8001"}},
|
||||
ReqData: model.SimDataUsageDailyInputItem{QueryDate: "20260101"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(output) != 1 || output[0].Code == 0 {
|
||||
t.Fatalf("malformed response accepted: %+v", output)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestQuerySimDataUsageDailyBatchFailureFallsBackToLegacy(t *testing.T) {
|
||||
var batchCalls atomic.Int32
|
||||
var singleCalls atomic.Int32
|
||||
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
||||
if request.URL.Path == "/api/v1/batchQry/batchQryFlowByDate" {
|
||||
batchCalls.Add(1)
|
||||
writer.Header().Set("Content-Type", "application/json")
|
||||
_, _ = writer.Write([]byte(`{"code":403,"msg":"denied"}`))
|
||||
return
|
||||
}
|
||||
singleCalls.Add(1)
|
||||
writer.Header().Set("Content-Type", "application/xml")
|
||||
_, _ = writer.Write([]byte(`<root><NEW_DATA_TICKET_QRsp><IRESULT>0</IRESULT><TOTAL_BYTES_CNT>12MB</TOTAL_BYTES_CNT></NEW_DATA_TICKET_QRsp></root>`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
sim := &sSimDX{client: dx.NewClient(dx.Config{BaseURL: server.URL, AppKey: "test", AppSecret: "test"}), custNumber: "audit-customer"}
|
||||
queryDate := time.Now().In(dxBatchLocation).AddDate(0, 0, -1).Format("20060102")
|
||||
output, err := sim.QuerySimDataUsageDaily(context.Background(), &model.SimDataUsageDailyInput{
|
||||
SimKeys: []*model.SimKey{{MSISDN: "138****8001"}},
|
||||
ReqData: model.SimDataUsageDailyInputItem{QueryDate: queryDate},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if batchCalls.Load() != 1 || singleCalls.Load() != 1 {
|
||||
t.Fatalf("batch calls=%d, single calls=%d, want 1 each", batchCalls.Load(), singleCalls.Load())
|
||||
}
|
||||
if len(output) != 1 || output[0].Code != 0 || output[0].ResData.DataAmount != 12 {
|
||||
t.Fatalf("unexpected fallback output: %+v", output)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseDXDataUsage(t *testing.T) {
|
||||
cases := []struct {
|
||||
value string
|
||||
want float64
|
||||
invalid bool
|
||||
}{
|
||||
{value: "0MB", want: 0},
|
||||
{value: "12.5MB", want: 12.5},
|
||||
{value: " 1 gb ", want: 1024},
|
||||
{value: "1024KB", want: 1},
|
||||
{value: "12", want: 12},
|
||||
{value: "", invalid: true},
|
||||
{value: "invalidMB", invalid: true},
|
||||
{value: "-1MB", invalid: true},
|
||||
{value: "NaNMB", invalid: true},
|
||||
{value: "InfMB", invalid: true},
|
||||
{value: "1e308GB", invalid: true},
|
||||
}
|
||||
for _, item := range cases {
|
||||
t.Run(item.value, func(t *testing.T) {
|
||||
amount, err := parseDXDataUsage(item.value)
|
||||
if (err != nil) != item.invalid || (!item.invalid && amount != item.want) {
|
||||
t.Fatalf("parseDXDataUsage(%q) = %v, %v, want %v, invalid=%v", item.value, amount, err, item.want, item.invalid)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,568 @@
|
||||
package upstreamimpl
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/consts"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/logic/upstreamimpl/dx"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/model"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/service"
|
||||
"math"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
)
|
||||
|
||||
type sSimDXV3 struct {
|
||||
client *dx.Client
|
||||
custNbr string
|
||||
enableOperations bool
|
||||
}
|
||||
|
||||
func init() {
|
||||
service.UpstreamAPI().RegisterTemp("sim_dx_v3", &sSimDXV3{})
|
||||
}
|
||||
|
||||
func (s *sSimDXV3) NewTemp(ctx context.Context, cfg *model.SimChannelConfigItemCache) model.SimCardChannelI {
|
||||
if cfg == nil || cfg.Channel == nil || cfg.Template == nil {
|
||||
return nil
|
||||
}
|
||||
var template struct {
|
||||
API struct {
|
||||
BaseURL string `json:"baseUrl"`
|
||||
EnableOperations bool `json:"enableOperations"`
|
||||
} `json:"api"`
|
||||
}
|
||||
var channel struct {
|
||||
CustNbr string `json:"custNbr"`
|
||||
CustNumber string `json:"custNumber"`
|
||||
}
|
||||
channelConfig := strings.TrimSpace(cfg.Channel.ChanConfig)
|
||||
if channelConfig == "" {
|
||||
channelConfig = "{}"
|
||||
}
|
||||
if json.Unmarshal([]byte(cfg.Template.TempConfig), &template) != nil || json.Unmarshal([]byte(channelConfig), &channel) != nil {
|
||||
g.Log().Error(ctx, "invalid telecom v3 JSON configuration")
|
||||
return nil
|
||||
}
|
||||
baseURL := strings.TrimRight(strings.TrimSpace(template.API.BaseURL), "/")
|
||||
if baseURL == "" {
|
||||
baseURL = dx.DefaultBaseURL
|
||||
}
|
||||
u, err := url.Parse(baseURL)
|
||||
if err != nil || u.Host == "" || (u.Scheme != "https" && u.Scheme != "http") || u.RawQuery != "" || u.Fragment != "" || u.User != nil || (u.Path != "" && u.Path != "/") {
|
||||
g.Log().Error(ctx, "telecom v3 baseUrl must be a gateway origin")
|
||||
return nil
|
||||
}
|
||||
if strings.TrimSpace(cfg.Channel.AppKey) == "" || strings.TrimSpace(cfg.Channel.AppSecret) == "" {
|
||||
g.Log().Error(ctx, "telecom v3 credentials are incomplete")
|
||||
return nil
|
||||
}
|
||||
return &sSimDXV3{
|
||||
client: dx.NewClient(dx.Config{BaseURL: baseURL, AppKey: cfg.Channel.AppKey, AppSecret: cfg.Channel.AppSecret,
|
||||
Timeout: resolveUpstreamHTTPTimeout(ctx, cfg.Template.TempConfig), UppercaseSign: true}),
|
||||
custNbr: firstNonEmpty(channel.CustNbr, channel.CustNumber), enableOperations: template.API.EnableOperations,
|
||||
}
|
||||
}
|
||||
|
||||
type dxV3Error struct{ code, message string }
|
||||
|
||||
func (e *dxV3Error) Error() string { return e.message }
|
||||
|
||||
func (s *sSimDXV3) request(ctx context.Context, path string, params url.Values, body any) (map[string]any, error) {
|
||||
var data []byte
|
||||
var err error
|
||||
if body == nil {
|
||||
data, err = s.client.DoGetRequest(ctx, "/api/v3/"+path, params)
|
||||
} else {
|
||||
data, err = json.Marshal(body)
|
||||
if err == nil {
|
||||
data, err = s.client.DoPostJSONRequest(ctx, "/api/v3/"+path, data)
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var envelope struct {
|
||||
Code json.RawMessage `json:"code"`
|
||||
Message string `json:"message"`
|
||||
Result json.RawMessage `json:"result"`
|
||||
}
|
||||
if err = json.Unmarshal(data, &envelope); err != nil {
|
||||
return nil, fmt.Errorf("invalid telecom v3 JSON response: %w", err)
|
||||
}
|
||||
code := dxV3RawCode(envelope.Code)
|
||||
if code == "" {
|
||||
return nil, fmt.Errorf("telecom v3 response is missing code")
|
||||
}
|
||||
if code != "0" {
|
||||
return nil, &dxV3Error{code: code, message: firstNonEmpty(envelope.Message, "telecom v3 business error")}
|
||||
}
|
||||
var result map[string]any
|
||||
decoder := json.NewDecoder(bytes.NewReader(envelope.Result))
|
||||
decoder.UseNumber()
|
||||
if err = decoder.Decode(&result); err != nil {
|
||||
return nil, fmt.Errorf("invalid telecom v3 result: %w", err)
|
||||
}
|
||||
if result == nil {
|
||||
return nil, fmt.Errorf("telecom v3 response has no result")
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func dxV3RawCode(raw json.RawMessage) string {
|
||||
if len(raw) == 0 || strings.TrimSpace(string(raw)) == "null" {
|
||||
return ""
|
||||
}
|
||||
var str string
|
||||
if json.Unmarshal(raw, &str) == nil {
|
||||
return strings.TrimSpace(str)
|
||||
}
|
||||
var number json.Number
|
||||
if json.Unmarshal(raw, &number) == nil {
|
||||
return number.String()
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func dxV3String(item map[string]any, name string) string {
|
||||
switch value := item[name].(type) {
|
||||
case string:
|
||||
return strings.TrimSpace(value)
|
||||
case json.Number:
|
||||
return value.String()
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func dxV3Params(key *model.SimKey) (url.Values, error) {
|
||||
if key != nil {
|
||||
for _, value := range []struct{ name, value string }{{"msisdn", key.MSISDN}, {"iccid", key.ICCID}, {"imsi", key.IMSI}} {
|
||||
if strings.TrimSpace(value.value) != "" {
|
||||
return url.Values{value.name: {strings.TrimSpace(value.value)}}, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil, fmt.Errorf("missing msisdn/iccid/imsi")
|
||||
}
|
||||
|
||||
func dxV3Failure[T any](key *model.SimKey, err error) *model.SimBaseRes[T] {
|
||||
item := &model.SimBaseRes[T]{SimKey: key, Code: consts.ApiStatusError, Message: err.Error()}
|
||||
var business *dxV3Error
|
||||
var httpError *dx.HTTPStatusError
|
||||
if errors.As(err, &business) {
|
||||
item.ErrCode = business.code
|
||||
if code, parseErr := strconv.Atoi(business.code); parseErr == nil && code != 0 {
|
||||
item.Code = code
|
||||
}
|
||||
} else if errors.As(err, &httpError) {
|
||||
item.ErrCode = httpError.RespCode
|
||||
}
|
||||
return item
|
||||
}
|
||||
|
||||
// dxV3LegacyUsageConcurrency 是 v3 逐卡回退查询的并发度,与 v1 保持一致,
|
||||
// 避免多卡查询退化成串行 N 次请求。
|
||||
const dxV3LegacyUsageConcurrency = 5
|
||||
|
||||
func dxV3QueryOne[T any](ctx context.Context, s *sSimDXV3, key *model.SimKey, path string,
|
||||
prepare func(url.Values) error, convert func(map[string]any) (T, error), post bool) *model.SimBaseRes[T] {
|
||||
params, err := dxV3Params(key)
|
||||
if err == nil && prepare != nil {
|
||||
err = prepare(params)
|
||||
}
|
||||
var result map[string]any
|
||||
if err == nil {
|
||||
var body any
|
||||
if post {
|
||||
values := make(map[string]string, len(params))
|
||||
for name := range params {
|
||||
values[name] = params.Get(name)
|
||||
}
|
||||
body = values
|
||||
}
|
||||
result, err = s.request(ctx, path, params, body)
|
||||
}
|
||||
if err == nil {
|
||||
for _, identity := range []struct{ name, value string }{{"msisdn", key.MSISDN}, {"iccid", key.ICCID}, {"imsi", key.IMSI}} {
|
||||
if returned := dxV3String(result, identity.name); returned != "" && identity.value != "" && returned != strings.TrimSpace(identity.value) {
|
||||
err = fmt.Errorf("telecom v3 returned a mismatched card identifier")
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
var output T
|
||||
if err == nil {
|
||||
output, err = convert(result)
|
||||
}
|
||||
if err != nil {
|
||||
return dxV3Failure[T](key, err)
|
||||
}
|
||||
return &model.SimBaseRes[T]{SimKey: key, ResData: output}
|
||||
}
|
||||
|
||||
func dxV3Query[T any](ctx context.Context, s *sSimDXV3, keys []*model.SimKey, path string,
|
||||
prepare func(url.Values) error, convert func(map[string]any) (T, error), post bool) []*model.SimBaseRes[T] {
|
||||
items := make([]*model.SimBaseRes[T], 0, len(keys))
|
||||
for _, key := range keys {
|
||||
items = append(items, dxV3QueryOne(ctx, s, key, path, prepare, convert, post))
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
// dxV3QueryConcurrent 与 dxV3Query 结果完全一致(保持输入顺序),只是并行发出单卡请求。
|
||||
func dxV3QueryConcurrent[T any](ctx context.Context, s *sSimDXV3, keys []*model.SimKey, path string,
|
||||
prepare func(url.Values) error, convert func(map[string]any) (T, error), post bool) []*model.SimBaseRes[T] {
|
||||
results := make([]*model.SimBaseRes[T], len(keys))
|
||||
if len(keys) == 0 {
|
||||
return results
|
||||
}
|
||||
jobs := make(chan int)
|
||||
var group sync.WaitGroup
|
||||
workers := min(dxV3LegacyUsageConcurrency, len(keys))
|
||||
for worker := 0; worker < workers; worker++ {
|
||||
group.Add(1)
|
||||
go func() {
|
||||
defer group.Done()
|
||||
for index := range jobs {
|
||||
results[index] = dxV3QueryOne(ctx, s, keys[index], path, prepare, convert, post)
|
||||
}
|
||||
}()
|
||||
}
|
||||
for index := range keys {
|
||||
jobs <- index
|
||||
}
|
||||
close(jobs)
|
||||
group.Wait()
|
||||
return results
|
||||
}
|
||||
|
||||
// dxV3BatchThenFill 让批量接口只承担"加速",正确性以单卡查询为基准:
|
||||
// 先按批量取一轮结果,再把其中失败或缺失的卡用单卡并发查询补齐,保留批量成功的项。
|
||||
// 这样批量级故障(鉴权、报文、网关错误)或某张卡被批量接口遗漏时,不会整批/逐卡报废;
|
||||
// 批量整体不可用(参数/窗口/报文异常)时直接退化为逐卡并发,不丢卡、不错位。
|
||||
// 本方法只用于查询类接口(幂等),重复一次调用是安全的。
|
||||
func dxV3BatchThenFill[T any](ctx context.Context, s *sSimDXV3, keys []*model.SimKey, path string,
|
||||
prepare func(url.Values) error, convert func(map[string]any) (T, error),
|
||||
batch func() ([]*model.SimBaseRes[T], error)) []*model.SimBaseRes[T] {
|
||||
results, err := batch()
|
||||
if err != nil || len(results) != len(keys) {
|
||||
return dxV3QueryConcurrent(ctx, s, keys, path, prepare, convert, false)
|
||||
}
|
||||
retryKeys := make([]*model.SimKey, 0, len(keys))
|
||||
retryIndex := make([]int, 0, len(keys))
|
||||
for index, item := range results {
|
||||
if item == nil || item.Code != consts.ApiStatusSuccess {
|
||||
retryKeys = append(retryKeys, keys[index])
|
||||
retryIndex = append(retryIndex, index)
|
||||
}
|
||||
}
|
||||
if len(retryKeys) == 0 {
|
||||
return results
|
||||
}
|
||||
filled := dxV3QueryConcurrent(ctx, s, retryKeys, path, prepare, convert, false)
|
||||
for offset, index := range retryIndex {
|
||||
results[index] = filled[offset]
|
||||
}
|
||||
return results
|
||||
}
|
||||
|
||||
func dxV3BasicInfo(item map[string]any) (model.SimBasicInfoItem, error) {
|
||||
output := model.SimBasicInfoItem{ICCID: dxV3String(item, "iccid"), IMSI: dxV3String(item, "imsi"), MSISDN: dxV3String(item, "msisdn"),
|
||||
OpenDate: dxV3String(item, "createTime"), ActiveDate: dxV3String(item, "activeTime"), SimStatus: dxV3String(item, "simStatus"),
|
||||
MainOfferName: firstNonEmpty(dxV3String(item, "mainPackageName"), dxV3String(item, "prodName")), CardBindingType: dxV3String(item, "cardBindingType"),
|
||||
IsDirectVisit: dxV3String(item, "isDirectVisit"), IsHumanLimit: dxV3String(item, "isHumanLimit"), VpdnGreDoamin: dxV3String(item, "vpdnGreDoamin"),
|
||||
VpdnL2tpDoamin: dxV3String(item, "vpdnL2tpDoamin"), AreaLimitType: dxV3String(item, "areaLimitType"), AreaLimitInfo: dxV3String(item, "areaLimitInfo")}
|
||||
if output.ICCID == "" && output.MSISDN == "" && output.IMSI == "" {
|
||||
return output, fmt.Errorf("telecom v3 basic info is missing card identifiers")
|
||||
}
|
||||
output.StopTypeList = normalizeDXStringList(item["stopTypeList"])
|
||||
return output, nil
|
||||
}
|
||||
|
||||
func (s *sSimDXV3) QuerySimBasicInfo(ctx context.Context, input *model.SimBasicInfoInput) (model.SimBasicInfoOutput, error) {
|
||||
return dxV3Query(ctx, s, input.SimKeys, "getSimInfo", nil, dxV3BasicInfo, false), nil
|
||||
}
|
||||
|
||||
func dxV3Status(item map[string]any) (model.SimCardStatusItem, error) {
|
||||
status := dxV3String(item, "simStatus")
|
||||
switch status {
|
||||
case "1", "2", "3", "4", "5", "6", "7":
|
||||
default:
|
||||
return model.SimCardStatusItem{}, fmt.Errorf("invalid telecom v3 simStatus: %s", status)
|
||||
}
|
||||
return model.SimCardStatusItem{CardStatus: convertDxCardStatus(context.Background(), status), LastChangeDate: dxV3String(item, "lastChangeTime")}, nil
|
||||
}
|
||||
|
||||
func (s *sSimDXV3) QuerySimCardStatus(ctx context.Context, input *model.SimCardStatusInput) (model.SimCardStatusOutput, error) {
|
||||
return dxV3Query(ctx, s, input.SimKeys, "getSimStatus", nil, dxV3Status, false), nil
|
||||
}
|
||||
|
||||
func (s *sSimDXV3) QuerySimSession(ctx context.Context, input *model.SimSessionInput) (model.SimSessionOutput, error) {
|
||||
return dxV3Query(ctx, s, input.SimKeys, "getOnlineStatus", nil, func(item map[string]any) (model.SimSessionItem, error) {
|
||||
status := consts.CardSessionStatusUnknown
|
||||
switch dxV3String(item, "onlineStatus") {
|
||||
case "1":
|
||||
status = consts.CardSessionStatusOnline
|
||||
case "2":
|
||||
status = consts.CardSessionStatusOffline
|
||||
}
|
||||
return model.SimSessionItem{Status: status, ApnId: dxV3String(item, "apnName"), Imei: dxV3String(item, "imei"), Rat: dxV3String(item, "ratType"),
|
||||
Ipv4: dxV3String(item, "ipv4Address"), Ipv6: dxV3String(item, "ipv6Address"), Ipv6Prefix: dxV3String(item, "ipv6PrefixLen"),
|
||||
CreateTime: firstNonEmpty(dxV3String(item, "startTime"), dxV3String(item, "eventTime"))}, nil
|
||||
}, false), nil
|
||||
}
|
||||
|
||||
func (s *sSimDXV3) QuerySimImei(ctx context.Context, input *model.SimImeiInput) (model.SimImeiOutput, error) {
|
||||
return dxV3Query(ctx, s, input.SimKeys, "getImei", nil, func(item map[string]any) (model.SimImeiItem, error) {
|
||||
return model.SimImeiItem{Imei: dxV3String(item, "imei")}, nil
|
||||
}, false), nil
|
||||
}
|
||||
|
||||
func dxV3Amount(item map[string]any) (float64, error) {
|
||||
value := firstNonEmpty(dxV3String(item, "dataAmount"), dxV3String(item, "totalUsageAmount"))
|
||||
amount, err := strconv.ParseFloat(value, 64)
|
||||
if err != nil || math.IsNaN(amount) || math.IsInf(amount, 0) || amount < 0 {
|
||||
return 0, fmt.Errorf("invalid telecom v3 dataAmount")
|
||||
}
|
||||
if unit := strings.ToUpper(dxV3String(item, "unit")); unit != "" && unit != "MB" {
|
||||
return 0, fmt.Errorf("unexpected telecom v3 usage unit: %s", unit)
|
||||
}
|
||||
return amount, nil
|
||||
}
|
||||
|
||||
func (s *sSimDXV3) QuerySimDataUsage(ctx context.Context, input *model.SimDataUsageInput) (model.SimDataUsageOutput, error) {
|
||||
return dxV3Query(ctx, s, input.SimKeys, "getDataUsage", nil, func(item map[string]any) (model.SimDataUsageItem, error) {
|
||||
amount, err := dxV3Amount(item)
|
||||
return model.SimDataUsageItem{DataUsage: amount}, err
|
||||
}, false), nil
|
||||
}
|
||||
|
||||
func (s *sSimDXV3) QuerySimDataUsageMonthly(ctx context.Context, input *model.SimDataUsageMonthlyInput) (model.SimDataUsageMonthlyOutput, error) {
|
||||
prepare := func(params url.Values) error {
|
||||
if _, err := time.Parse("200601", input.ReqData.QueryDate); err != nil {
|
||||
return fmt.Errorf("invalid queryDate: expected yyyyMM")
|
||||
}
|
||||
params.Set("month", input.ReqData.QueryDate)
|
||||
return nil
|
||||
}
|
||||
convert := func(item map[string]any) (model.SimDataUsageMonthlyOutputItem, error) {
|
||||
amount, err := dxV3Amount(item)
|
||||
return model.SimDataUsageMonthlyOutputItem{DataAmount: amount}, err
|
||||
}
|
||||
if s.monthlyBatchEligible(input) {
|
||||
return dxV3BatchThenFill(ctx, s, input.SimKeys, "getDataUsageByMonth", prepare, convert,
|
||||
func() (model.SimDataUsageMonthlyOutput, error) { return s.QuerySimDataUsageMonthlyBatch(ctx, input) }), nil
|
||||
}
|
||||
if len(input.SimKeys) > 1 {
|
||||
return dxV3QueryConcurrent(ctx, s, input.SimKeys, "getDataUsageByMonth", prepare, convert, false), nil
|
||||
}
|
||||
return dxV3Query(ctx, s, input.SimKeys, "getDataUsageByMonth", prepare, convert, false), nil
|
||||
}
|
||||
|
||||
// monthlyBatchEligible 判断多卡月流量查询能否升级为批量接口:需要配置 custNbr、每张卡都有
|
||||
// MSISDN,且查询月份为当月。与 v1 一致:v1 的自动批量同样只在当月生效,历史月份继续逐卡查询。
|
||||
// 任一不满足则回退逐卡(并发)查询。
|
||||
func (s *sSimDXV3) monthlyBatchEligible(input *model.SimDataUsageMonthlyInput) bool {
|
||||
if input == nil || len(input.SimKeys) < 2 || strings.TrimSpace(s.custNbr) == "" {
|
||||
return false
|
||||
}
|
||||
if !dxBatchMonthAvailable(input.ReqData.QueryDate, time.Now()) {
|
||||
return false
|
||||
}
|
||||
for _, key := range input.SimKeys {
|
||||
if key == nil || strings.TrimSpace(key.MSISDN) == "" {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (s *sSimDXV3) QuerySimDataUsageDaily(ctx context.Context, input *model.SimDataUsageDailyInput) (model.SimDataUsageDailyOutput, error) {
|
||||
prepare := func(params url.Values) error {
|
||||
day, err := time.ParseInLocation("20060102", input.ReqData.QueryDate, dxBatchLocation)
|
||||
now := time.Now().In(dxBatchLocation)
|
||||
today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, dxBatchLocation)
|
||||
if err != nil || !day.Before(today) {
|
||||
return fmt.Errorf("queryDate must be a completed day in yyyyMMdd format")
|
||||
}
|
||||
params.Set("beginDate", input.ReqData.QueryDate)
|
||||
params.Set("endDate", input.ReqData.QueryDate)
|
||||
return nil
|
||||
}
|
||||
convert := func(item map[string]any) (model.SimDataUsageDailyOutputItem, error) {
|
||||
amount, err := dxV3Amount(item)
|
||||
return model.SimDataUsageDailyOutputItem{QueryDate: input.ReqData.QueryDate, DataAmount: amount}, err
|
||||
}
|
||||
if s.dailyBatchEligible(input) {
|
||||
return dxV3BatchThenFill(ctx, s, input.SimKeys, "getDataUsageBetweenDate", prepare, convert,
|
||||
func() (model.SimDataUsageDailyOutput, error) { return s.QuerySimDataUsageDailyBatch(ctx, input) }), nil
|
||||
}
|
||||
if len(input.SimKeys) > 1 {
|
||||
return dxV3QueryConcurrent(ctx, s, input.SimKeys, "getDataUsageBetweenDate", prepare, convert, false), nil
|
||||
}
|
||||
return dxV3Query(ctx, s, input.SimKeys, "getDataUsageBetweenDate", prepare, convert, false), nil
|
||||
}
|
||||
|
||||
// dailyBatchEligible 与 monthlyBatchEligible 同构:批量仅在已结束且落在最近七天窗口内的日期可用。
|
||||
func (s *sSimDXV3) dailyBatchEligible(input *model.SimDataUsageDailyInput) bool {
|
||||
if input == nil || len(input.SimKeys) < 2 || strings.TrimSpace(s.custNbr) == "" {
|
||||
return false
|
||||
}
|
||||
if !dxBatchDayAvailable(input.ReqData.QueryDate, time.Now()) {
|
||||
return false
|
||||
}
|
||||
for _, key := range input.SimKeys {
|
||||
if key == nil || strings.TrimSpace(key.MSISDN) == "" {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (s *sSimDXV3) QuerySimStopReason(ctx context.Context, input *model.SimStopReasonInput) (model.SimStopReasonOutput, error) {
|
||||
return dxV3Query(ctx, s, input.SimKeys, "getSimInfo", nil, func(item map[string]any) (model.SimStopReasonItem, error) {
|
||||
reason := strings.Join(trimNonEmptyStrings([]string{dxV3String(item, "stopStatusName"), dxV3String(item, "stopTypeName")}), "; ")
|
||||
if reason == "" {
|
||||
reason = mapDxStopTypeListText(normalizeDXStringList(item["stopTypeList"]))
|
||||
}
|
||||
return model.SimStopReasonItem{StopReason: reason}, nil
|
||||
}, false), nil
|
||||
}
|
||||
|
||||
func (s *sSimDXV3) QuerySimApnInfo(ctx context.Context, input *model.SimApnInfoInput) (model.SimApnInfoOutput, error) {
|
||||
return dxV3Query(ctx, s, input.SimKeys, "getApnInfo", nil, func(item map[string]any) (model.SimApnInfoItemList, error) {
|
||||
list, ok := item["apnInfoList"].([]any)
|
||||
if !ok {
|
||||
return model.SimApnInfoItemList{}, fmt.Errorf("telecom v3 response is missing apnInfoList")
|
||||
}
|
||||
output := model.SimApnInfoItemList{ApnList: make([]model.SimApnInfoItem, 0, len(list))}
|
||||
for _, entry := range list {
|
||||
apn, ok := entry.(map[string]any)
|
||||
if !ok {
|
||||
return output, fmt.Errorf("invalid telecom v3 APN entry")
|
||||
}
|
||||
output.ApnList = append(output.ApnList, model.SimApnInfoItem{ApnName: dxV3String(apn, "apnName"), Status: dxV3String(apn, "status")})
|
||||
}
|
||||
return output, nil
|
||||
}, false), nil
|
||||
}
|
||||
|
||||
func (s *sSimDXV3) QuerySimCardBindStatus(ctx context.Context, input *model.SimCardBindStatusInput) (model.SimCardBindStatusOutput, error) {
|
||||
return dxV3Query(ctx, s, input.SimKeys, "getSimBindingInfo", nil, func(item map[string]any) (model.SimCardBindStatusItem, error) {
|
||||
return model.SimCardBindStatusItem{BindStatus: dxV3String(item, "simSeparationStatus"), CardBindingType: dxV3String(item, "bindType"),
|
||||
OrderStatus: dxV3String(item, "isOrdered"), BindImei: dxV3String(item, "bindImei"), LastImei: dxV3String(item, "lastImei")}, nil
|
||||
}, false), nil
|
||||
}
|
||||
|
||||
// QuerySimDeviceStatus 查询终端开关机状态(电信口径为“仅 3G”)。
|
||||
//
|
||||
// 注意方法必须是 GET:官方文档把 /api/v3/getDevicePowerStatus 写成 POST,但网关实际只接受
|
||||
// GET(实测 POST 返回 799 S10010“请求方法不匹配”,GET 返回 200)。同一网关下 v1 的
|
||||
// /api/v1/prod/getDevicePowerStatus 反而只接受 POST,两者不可互推。
|
||||
//
|
||||
// 响应有两种形态,需兼容键名:
|
||||
// - 有记录:{"status":"0","statusName":"开机"}(文档口径,小写)
|
||||
// - 无记录:{"Result":"-1","resultName":"无终端开关机信息"}(大写 R,仅 3G 接口对 4G/5G 卡常见)
|
||||
func (s *sSimDXV3) QuerySimDeviceStatus(ctx context.Context, input *model.SimDeviceStatusInput) (model.SimDeviceStatusOutput, error) {
|
||||
return dxV3Query(ctx, s, input.SimKeys, "getDevicePowerStatus", nil, func(item map[string]any) (model.SimDeviceStatusItem, error) {
|
||||
status := firstNonEmpty(dxV3String(item, "status"), dxV3String(item, "Status"), dxV3String(item, "Result"))
|
||||
return model.SimDeviceStatusItem{DeviceStatus: consts.CardSessionStatusUnknown, OperatingStatus: mapDxOperatingStatus(status)}, nil
|
||||
}, false), nil
|
||||
}
|
||||
|
||||
func (s *sSimDXV3) QuerySimRegionLimitStatus(ctx context.Context, input *model.SimRegionLimitStatusInput) (model.SimRegionLimitStatusOutput, error) {
|
||||
return dxV3Query(ctx, s, input.SimKeys, "getSimRegionLimitStatus", nil, func(item map[string]any) (model.SimRegionLimitStatusItem, error) {
|
||||
order, limit := dxV3String(item, "orderStatus"), dxV3String(item, "regionLimitStatus")
|
||||
return model.SimRegionLimitStatusItem{RegionLimitStatus: mapDxRegionLimitStatusByOrder(order, limit), OrderStatus: order, LimitStatus: limit, LimitType: dxV3String(item, "limitType"), LimitArea: dxV3String(item, "restrictedArea")}, nil
|
||||
}, false), nil
|
||||
}
|
||||
|
||||
// QuerySimFrozenState 电信没有独立的冻结状态查询接口。与 v1 一致,返回成功项并附说明文案,
|
||||
// 避免诊断页与通道自检把它当成上游错误(v1 返回 code=0 + “不支持查询”)。
|
||||
func (s *sSimDXV3) QuerySimFrozenState(_ context.Context, input *model.SimFrozenStateInput) (model.SimFrozenStateOutput, error) {
|
||||
output := make(model.SimFrozenStateOutput, 0, len(input.SimKeys))
|
||||
for _, key := range input.SimKeys {
|
||||
output = append(output, &model.SimBaseRes[model.SimFrozenStateItem]{
|
||||
SimKey: key,
|
||||
Code: consts.ApiStatusSuccess,
|
||||
ResData: model.SimFrozenStateItem{
|
||||
FrozenStatus: "不支持查询",
|
||||
FrozenReason: "电信当前无独立冻结查询接口",
|
||||
},
|
||||
})
|
||||
}
|
||||
return output, nil
|
||||
}
|
||||
|
||||
// QuerySimDataDiagnosis 电信不提供达量限速档位查询。沿用 v1 的约定返回 ApiInvokeException,
|
||||
// 让上层按“暂不支持”降级为跳过(诊断页 skipped)而不是报错。
|
||||
func (s *sSimDXV3) QuerySimDataDiagnosis(_ context.Context, input *model.SimDataDiagnosisInput) (model.SimDataDiagnosisOutput, error) {
|
||||
output := make(model.SimDataDiagnosisOutput, 0, len(input.SimKeys))
|
||||
for _, key := range input.SimKeys {
|
||||
output = append(output, &model.SimBaseRes[any]{
|
||||
SimKey: key,
|
||||
Code: consts.ApiInvokeException,
|
||||
Message: "电信官方接口暂不提供当前达量限速查询能力",
|
||||
})
|
||||
}
|
||||
return output, nil
|
||||
}
|
||||
|
||||
func dxV3Order(item map[string]any) (model.SimCardStatusChangeItem, error) {
|
||||
order := dxV3String(item, "transactionId")
|
||||
if order == "" {
|
||||
return model.SimCardStatusChangeItem{}, fmt.Errorf("telecom v3 operation returned no transactionId")
|
||||
}
|
||||
return model.SimCardStatusChangeItem{OrderNum: order}, nil
|
||||
}
|
||||
|
||||
func (s *sSimDXV3) ChangeSimStatus(ctx context.Context, input *model.SimCardStatusChangeInput) (model.SimCardStatusChangeOutput, error) {
|
||||
return dxV3Query(ctx, s, input.SimKeys, "updateSimStatus", func(params url.Values) error {
|
||||
if !s.enableOperations {
|
||||
return fmt.Errorf("telecom v3 operations are disabled")
|
||||
}
|
||||
oper, ok := convertDxCardStatusChangeOperType(ctx, input.ReqData.OperType)
|
||||
if !ok {
|
||||
return fmt.Errorf("unsupported status operType")
|
||||
}
|
||||
params.Set("operType", oper)
|
||||
return nil
|
||||
}, dxV3Order, false), nil
|
||||
}
|
||||
|
||||
func (s *sSimDXV3) ChangeSimApnFunction(ctx context.Context, input *model.SimCardOperateChangeApnInput) (model.SimCardOperateChangeApnOutput, error) {
|
||||
return dxV3Query(ctx, s, input.SimKeys, "singleCutNet", func(params url.Values) error {
|
||||
if !s.enableOperations {
|
||||
return fmt.Errorf("telecom v3 operations are disabled")
|
||||
}
|
||||
// singleCutNet 是整卡数据开停语义,电信侧没有“按 APN 定向”这一维度。
|
||||
// 与 v1 保持一致:忽略调用方携带的 ApnName(预警自动断网、批量数据开停都会固定带
|
||||
// apnName="CMIOT"),不能因此拒绝请求。
|
||||
if params.Get("msisdn") == "" {
|
||||
return fmt.Errorf("singleCutNet requires msisdn")
|
||||
}
|
||||
switch strings.TrimSpace(input.ReqData.OperType) {
|
||||
case "0":
|
||||
params.Set("operType", "1")
|
||||
case "1":
|
||||
params.Set("operType", "2")
|
||||
default:
|
||||
return fmt.Errorf("unsupported data operType")
|
||||
}
|
||||
return nil
|
||||
}, func(item map[string]any) (model.SimCardOperateChangeApnItem, error) {
|
||||
order, err := dxV3Order(item)
|
||||
return model.SimCardOperateChangeApnItem{OrderNum: order.OrderNum}, err
|
||||
}, true), nil
|
||||
}
|
||||
|
||||
var _ model.SimCardChannelI = (*sSimDXV3)(nil)
|
||||
@@ -0,0 +1,148 @@
|
||||
package upstreamimpl
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/model"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const dxV3BatchLimit = 100
|
||||
|
||||
// dxV3BatchMonthAvailable 电信 v3 月批量接口仅支持最近六个含当月的月份。
|
||||
func dxV3BatchMonthAvailable(month time.Time) bool {
|
||||
now := time.Now().In(dxBatchLocation)
|
||||
current := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, dxBatchLocation)
|
||||
return !month.Before(current.AddDate(0, -5, 0)) && !month.After(current)
|
||||
}
|
||||
|
||||
func dxV3Batch[T any](ctx context.Context, s *sSimDXV3, keys []*model.SimKey, path, listName, dateName, date string,
|
||||
convert func(map[string]any) (T, error)) []*model.SimBaseRes[T] {
|
||||
output := make([]*model.SimBaseRes[T], len(keys))
|
||||
pending := make([]int, 0, len(keys))
|
||||
for index, key := range keys {
|
||||
if key == nil || strings.TrimSpace(key.MSISDN) == "" {
|
||||
output[index] = dxV3Failure[T](key, fmt.Errorf("telecom v3 batch requires msisdn"))
|
||||
continue
|
||||
}
|
||||
pending = append(pending, index)
|
||||
}
|
||||
for offset := 0; offset < len(pending); offset += dxV3BatchLimit {
|
||||
end := offset + dxV3BatchLimit
|
||||
if end > len(pending) {
|
||||
end = len(pending)
|
||||
}
|
||||
chunk := pending[offset:end]
|
||||
msisdns := make([]string, 0, len(chunk))
|
||||
for _, index := range chunk {
|
||||
msisdns = append(msisdns, strings.TrimSpace(keys[index].MSISDN))
|
||||
}
|
||||
body := map[string]any{"msisdnList": msisdns, "custNbr": s.custNbr}
|
||||
if dateName != "" {
|
||||
body[dateName] = date
|
||||
}
|
||||
var err error
|
||||
var result map[string]any
|
||||
if s.custNbr == "" {
|
||||
err = fmt.Errorf("telecom v3 custNbr not configured")
|
||||
} else {
|
||||
result, err = s.request(ctx, path, nil, body)
|
||||
}
|
||||
byNumber := make(map[string]map[string]any)
|
||||
if err == nil {
|
||||
list, ok := result[listName].([]any)
|
||||
if !ok {
|
||||
err = fmt.Errorf("telecom v3 result is missing %s", listName)
|
||||
} else {
|
||||
for _, entry := range list {
|
||||
item, ok := entry.(map[string]any)
|
||||
if !ok {
|
||||
err = fmt.Errorf("invalid telecom v3 batch entry")
|
||||
break
|
||||
}
|
||||
number := dxV3String(item, "msisdn")
|
||||
if number == "" {
|
||||
err = fmt.Errorf("telecom v3 batch entry is missing msisdn")
|
||||
break
|
||||
}
|
||||
if _, duplicate := byNumber[number]; duplicate {
|
||||
err = fmt.Errorf("duplicate telecom v3 batch result for msisdn")
|
||||
break
|
||||
}
|
||||
byNumber[number] = item
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, index := range chunk {
|
||||
key := keys[index]
|
||||
itemErr := err
|
||||
item := byNumber[strings.TrimSpace(key.MSISDN)]
|
||||
if itemErr == nil && item == nil {
|
||||
itemErr = fmt.Errorf("telecom v3 batch omitted requested card")
|
||||
}
|
||||
if itemErr == nil {
|
||||
code := dxV3String(item, "result")
|
||||
if code == "" {
|
||||
itemErr = fmt.Errorf("telecom v3 batch entry is missing result")
|
||||
} else if code != "0" {
|
||||
itemErr = &dxV3Error{code: code, message: firstNonEmpty(dxV3String(item, "resultDesc"), "telecom v3 card query failed")}
|
||||
}
|
||||
}
|
||||
var value T
|
||||
if itemErr == nil && dateName != "" && dxV3String(item, dateName) != date {
|
||||
itemErr = fmt.Errorf("telecom v3 batch returned a different query period")
|
||||
}
|
||||
if itemErr == nil {
|
||||
value, itemErr = convert(item)
|
||||
}
|
||||
if itemErr != nil {
|
||||
output[index] = dxV3Failure[T](key, itemErr)
|
||||
} else {
|
||||
output[index] = &model.SimBaseRes[T]{SimKey: key, ResData: value}
|
||||
}
|
||||
}
|
||||
}
|
||||
return output
|
||||
}
|
||||
|
||||
func (s *sSimDXV3) QuerySimCardStatusBatch(ctx context.Context, input *model.SimCardStatusInput) (model.SimCardStatusOutput, error) {
|
||||
if input == nil {
|
||||
return model.SimCardStatusOutput{}, nil
|
||||
}
|
||||
return dxV3Batch(ctx, s, input.SimKeys, "getSimStatusBatch", "simStatusList", "", "", dxV3Status), nil
|
||||
}
|
||||
|
||||
func (s *sSimDXV3) QuerySimDataUsageMonthlyBatch(ctx context.Context, input *model.SimDataUsageMonthlyInput) (model.SimDataUsageMonthlyOutput, error) {
|
||||
if input == nil {
|
||||
return model.SimDataUsageMonthlyOutput{}, nil
|
||||
}
|
||||
month, err := time.ParseInLocation("200601", input.ReqData.QueryDate, dxBatchLocation)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid queryDate: expected yyyyMM")
|
||||
}
|
||||
if !dxV3BatchMonthAvailable(month) {
|
||||
return nil, fmt.Errorf("telecom v3 monthly batch supports the latest six months")
|
||||
}
|
||||
return dxV3Batch(ctx, s, input.SimKeys, "getDataUsageByMonthBatch", "batchTrafficList", "month", input.ReqData.QueryDate,
|
||||
func(item map[string]any) (model.SimDataUsageMonthlyOutputItem, error) {
|
||||
amount, err := dxV3Amount(item)
|
||||
return model.SimDataUsageMonthlyOutputItem{DataAmount: amount}, err
|
||||
}), nil
|
||||
}
|
||||
|
||||
func (s *sSimDXV3) QuerySimDataUsageDailyBatch(ctx context.Context, input *model.SimDataUsageDailyInput) (model.SimDataUsageDailyOutput, error) {
|
||||
if input == nil {
|
||||
return model.SimDataUsageDailyOutput{}, nil
|
||||
}
|
||||
if !dxBatchDayAvailable(input.ReqData.QueryDate, time.Now()) {
|
||||
return nil, fmt.Errorf("telecom v3 daily batch requires a completed day within the latest seven days")
|
||||
}
|
||||
return dxV3Batch(ctx, s, input.SimKeys, "getDataUsageByDayBatch", "batchTrafficList", "date", input.ReqData.QueryDate,
|
||||
func(item map[string]any) (model.SimDataUsageDailyOutputItem, error) {
|
||||
amount, err := dxV3Amount(item)
|
||||
return model.SimDataUsageDailyOutputItem{QueryDate: input.ReqData.QueryDate, DataAmount: amount}, err
|
||||
}), nil
|
||||
}
|
||||
|
||||
var _ model.SimCardBatchQueryI = (*sSimDXV3)(nil)
|
||||
@@ -0,0 +1,468 @@
|
||||
package upstreamimpl
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/consts"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/logic/upstreamimpl/dx"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/model"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/model/entity"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/service"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func newDXV3TestClient(t *testing.T, handler http.HandlerFunc) *sSimDXV3 {
|
||||
t.Helper()
|
||||
server := httptest.NewServer(handler)
|
||||
t.Cleanup(server.Close)
|
||||
return &sSimDXV3{client: dx.NewClient(dx.Config{BaseURL: server.URL, AppKey: "test-key", AppSecret: "test-secret", UppercaseSign: true}), custNbr: "test-customer"}
|
||||
}
|
||||
|
||||
func TestDXV3TemplateCoexistsWithV1(t *testing.T) {
|
||||
for _, key := range []string{"sim_dx_v1", "sim_dx_v3"} {
|
||||
if _, ok := service.UpstreamAPI().GetTemp(key); !ok {
|
||||
t.Fatalf("missing template %s", key)
|
||||
}
|
||||
}
|
||||
factory := &sSimDXV3{}
|
||||
config := &model.SimChannelConfigItemCache{Channel: &entity.TSimChannels{AppKey: "key", AppSecret: "secret", ChanConfig: `{"custNumber":"customer"}`}, Template: &entity.TSimChannelTemplate{TempConfig: `{"api":{"baseUrl":"https://cmp-api.ctwing.cn:20164"}}`}}
|
||||
instance, ok := factory.NewTemp(context.Background(), config).(*sSimDXV3)
|
||||
if !ok || instance.enableOperations || instance.custNbr != "customer" {
|
||||
t.Fatalf("unexpected v3 configuration: %+v", instance)
|
||||
}
|
||||
config.Channel.ChanConfig = ""
|
||||
if factory.NewTemp(context.Background(), config) == nil {
|
||||
t.Fatal("single-card configuration should not require custNbr")
|
||||
}
|
||||
config.Template.TempConfig = `{"api":{"baseUrl":"https://cmp-api.ctwing.cn:20164/api/v3"}}`
|
||||
if factory.NewTemp(context.Background(), config) != nil {
|
||||
t.Fatal("accepted base URL containing protocol path")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDXV3SessionAndRealTimeUsageContracts(t *testing.T) {
|
||||
client := newDXV3TestClient(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Query().Get("msisdn") != "106123" || len(r.URL.Query()) != 1 {
|
||||
t.Errorf("unexpected query: %s", r.URL.RawQuery)
|
||||
}
|
||||
want, _ := dx.BuildSignV3(r.URL.RawQuery, "", "test-secret", r.Header.Get("Timestamp"))
|
||||
if r.Header.Get("Sign") != want || r.Header.Get("AppKey") != "test-key" {
|
||||
t.Error("unexpected signed request")
|
||||
}
|
||||
switch r.URL.Path {
|
||||
case "/api/v3/getOnlineStatus":
|
||||
fmt.Fprint(w, `{"code":"0","result":{"onlineStatus":"1","apnName":"iot.apn","ipv4Address":"192.0.2.1"}}`)
|
||||
case "/api/v3/getDataUsage":
|
||||
fmt.Fprint(w, `{"code":0,"result":{"dataAmount":"12.5"}}`)
|
||||
default:
|
||||
t.Errorf("unexpected path: %s", r.URL.Path)
|
||||
}
|
||||
})
|
||||
keys := []*model.SimKey{{MSISDN: "106123", ICCID: "8986", IMSI: "46011"}}
|
||||
session, err := client.QuerySimSession(context.Background(), &model.SimSessionInput{SimKeys: keys})
|
||||
if err != nil || len(session) != 1 || session[0].Code != 0 || session[0].ResData.Status != consts.CardSessionStatusOnline || session[0].ResData.ApnId != "iot.apn" {
|
||||
t.Fatalf("session: %+v, %v", session, err)
|
||||
}
|
||||
usage, err := client.QuerySimDataUsage(context.Background(), &model.SimDataUsageInput{SimKeys: keys})
|
||||
if err != nil || usage[0].Code != 0 || usage[0].ResData.DataUsage != 12.5 {
|
||||
t.Fatalf("usage: %+v, %v", usage, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDXV3ErrorsNeverBecomeZeroUsage(t *testing.T) {
|
||||
for _, test := range []struct {
|
||||
name, body, code string
|
||||
httpStatus int
|
||||
}{
|
||||
{"missing code", `{"result":{"dataAmount":"0"}}`, "", 200},
|
||||
{"null code", `{"code":null,"result":{"dataAmount":"0"}}`, "", 200},
|
||||
{"mismatched card", `{"code":"0","result":{"msisdn":"2","dataAmount":"5"}}`, "", 200},
|
||||
{"invalid amount", `{"code":"0","result":{"dataAmount":"garbage"}}`, "", 200},
|
||||
{"missing result", `{"code":"0","result":null}`, "", 200},
|
||||
{"business failure", `{"code":"S00004","message":"limited"}`, "S00004", 200},
|
||||
{"gateway failure", `{}`, "S00004", 799},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
client := newDXV3TestClient(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
if test.httpStatus == 799 {
|
||||
w.Header().Set("gw-errorCode", "S00004")
|
||||
}
|
||||
w.WriteHeader(test.httpStatus)
|
||||
fmt.Fprint(w, test.body)
|
||||
})
|
||||
items, err := client.QuerySimDataUsage(context.Background(), &model.SimDataUsageInput{SimKeys: []*model.SimKey{{MSISDN: "1"}}})
|
||||
if err != nil || items[0].Code == 0 || items[0].ErrCode != test.code {
|
||||
t.Fatalf("unexpected error result: %+v, %v", items[0], err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDXV3HistoryDayAndDiagnosticMappings(t *testing.T) {
|
||||
day := time.Now().In(dxBatchLocation).AddDate(0, 0, -1).Format("20060102")
|
||||
calls := 0
|
||||
client := newDXV3TestClient(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
calls++
|
||||
switch r.URL.Path {
|
||||
case "/api/v3/getDataUsageBetweenDate":
|
||||
if r.URL.Query().Get("beginDate") != day || r.URL.Query().Get("endDate") != day {
|
||||
t.Error("history day must use the same beginning and ending date")
|
||||
}
|
||||
fmt.Fprint(w, `{"code":"0","result":{"totalUsageAmount":"4.5"}}`)
|
||||
case "/api/v3/getApnInfo":
|
||||
fmt.Fprint(w, `{"code":"0","result":{"apnInfoList":[{"apnName":"iot","status":"1"}]}}`)
|
||||
case "/api/v3/getSimBindingInfo":
|
||||
fmt.Fprint(w, `{"code":"0","result":{"isOrdered":"0","bindType":"2","simSeparationStatus":"0","bindImei":"123"}}`)
|
||||
case "/api/v3/getSimRegionLimitStatus":
|
||||
fmt.Fprint(w, `{"code":"0","result":{"orderStatus":0,"limitType":3,"regionLimitStatus":"0","restrictedArea":"110000"}}`)
|
||||
case "/api/v3/getDevicePowerStatus":
|
||||
// 网关实测只接受 GET(文档写 POST 与网关不符),参数走 query。
|
||||
if r.Method != "GET" || r.URL.Query().Get("msisdn") != "1" {
|
||||
t.Errorf("invalid power-status request: %s %s", r.Method, r.URL.RawQuery)
|
||||
}
|
||||
fmt.Fprint(w, `{"code":"0","result":{"status":"0"}}`)
|
||||
default:
|
||||
t.Errorf("unexpected diagnostic path: %s", r.URL.Path)
|
||||
}
|
||||
})
|
||||
ctx := context.Background()
|
||||
keys := []*model.SimKey{{MSISDN: "1"}}
|
||||
usage, err := client.QuerySimDataUsageDaily(ctx, &model.SimDataUsageDailyInput{SimKeys: keys, ReqData: model.SimDataUsageDailyInputItem{QueryDate: day}})
|
||||
if err != nil || usage[0].Code != 0 || usage[0].ResData.QueryDate != day || usage[0].ResData.DataAmount != 4.5 {
|
||||
t.Fatalf("history day: %+v, %v", usage, err)
|
||||
}
|
||||
today := time.Now().In(dxBatchLocation).Format("20060102")
|
||||
usage, _ = client.QuerySimDataUsageDaily(ctx, &model.SimDataUsageDailyInput{SimKeys: keys, ReqData: model.SimDataUsageDailyInputItem{QueryDate: today}})
|
||||
if usage[0].Code == 0 || calls != 1 {
|
||||
t.Fatal("unfinished day reached the historical endpoint")
|
||||
}
|
||||
apns, _ := client.QuerySimApnInfo(ctx, &model.SimApnInfoInput{SimKeys: keys})
|
||||
if apns[0].Code != 0 || len(apns[0].ResData.ApnList) != 1 || apns[0].ResData.ApnList[0].Status != "1" {
|
||||
t.Fatalf("APN mapping: %+v", apns)
|
||||
}
|
||||
bind, _ := client.QuerySimCardBindStatus(ctx, &model.SimCardBindStatusInput{SimKeys: keys})
|
||||
if bind[0].Code != 0 || bind[0].ResData.BindStatus != "0" || bind[0].ResData.OrderStatus != "0" || bind[0].ResData.CardBindingType != "2" || bind[0].ResData.BindImei != "123" {
|
||||
t.Fatalf("binding mapping: %+v", bind)
|
||||
}
|
||||
region, _ := client.QuerySimRegionLimitStatus(ctx, &model.SimRegionLimitStatusInput{SimKeys: keys})
|
||||
if region[0].Code != 0 || region[0].ResData.RegionLimitStatus != "1" || region[0].ResData.LimitType != "3" || region[0].ResData.LimitArea != "110000" {
|
||||
t.Fatalf("region mapping: %+v", region)
|
||||
}
|
||||
power, _ := client.QuerySimDeviceStatus(ctx, &model.SimDeviceStatusInput{SimKeys: keys})
|
||||
if power[0].Code != 0 || power[0].ResData.OperatingStatus != "1" || power[0].ResData.DeviceStatus != consts.CardSessionStatusUnknown {
|
||||
t.Fatalf("power mapping: %+v", power)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDXV3DevicePowerStatusSupportsEmptyRecordShape 锁定该接口的真实返回形态:
|
||||
// 仅 3G 接口对 4G/5G 卡返回 {"Result":"-1","resultName":"无终端开关机信息"}(值是 Result,
|
||||
// 大写 R),应映射为“未知”而不是报错;同时确认请求走 GET(文档写 POST 与网关不符)。
|
||||
func TestDXV3DevicePowerStatusSupportsEmptyRecordShape(t *testing.T) {
|
||||
client := newDXV3TestClient(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != "GET" {
|
||||
t.Errorf("device power status must be sent with GET, got %s", r.Method)
|
||||
}
|
||||
fmt.Fprint(w, `{"code":"0","result":{"Result":"-1","resultName":"无终端开关机信息"}}`)
|
||||
})
|
||||
items, err := client.QuerySimDeviceStatus(context.Background(), &model.SimDeviceStatusInput{SimKeys: []*model.SimKey{{MSISDN: "1"}}})
|
||||
if err != nil || items[0].Code != 0 || items[0].ResData.OperatingStatus != consts.CardSessionStatusUnknown {
|
||||
t.Fatalf("empty power record must map to unknown: %+v, %v", items, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDXV3BasicInfoAllowsDocumentedPartialIdentifiers(t *testing.T) {
|
||||
for _, body := range []string{
|
||||
`{"code":"0","result":{"msisdn":"1","createTime":"2023-03-16 11:07:50","activeTime":"2023-03-16 11:07:50","prodName":"test-product"}}`,
|
||||
`{"code":"0","result":{"msisdn":"1","iccid":"8986","imsi":"46011","prodName":"test-product"}}`,
|
||||
} {
|
||||
client := newDXV3TestClient(t, func(w http.ResponseWriter, r *http.Request) { fmt.Fprint(w, body) })
|
||||
items, err := client.QuerySimBasicInfo(context.Background(), &model.SimBasicInfoInput{SimKeys: []*model.SimKey{{MSISDN: "1"}}})
|
||||
if err != nil || items[0].Code != 0 || items[0].ResData.MSISDN != "1" || items[0].ResData.MainOfferName != "test-product" {
|
||||
t.Fatalf("basic info: %+v, %v", items, err)
|
||||
}
|
||||
}
|
||||
if _, err := dxV3BasicInfo(map[string]any{}); err == nil {
|
||||
t.Fatal("accepted basic info with no identifiers")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDXV3StatusBatchChunksAndMatchesCards(t *testing.T) {
|
||||
calls := 0
|
||||
client := newDXV3TestClient(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
calls++
|
||||
if r.Method != "POST" || r.URL.Path != "/api/v3/getSimStatusBatch" {
|
||||
t.Errorf("unexpected batch request")
|
||||
}
|
||||
body, _ := io.ReadAll(r.Body)
|
||||
want, _ := dx.BuildSignV3("", string(body), "test-secret", r.Header.Get("Timestamp"))
|
||||
if r.Header.Get("Sign") != want {
|
||||
t.Error("body differs from signed body")
|
||||
}
|
||||
var request struct {
|
||||
Numbers []string `json:"msisdnList"`
|
||||
Customer string `json:"custNbr"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &request); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(request.Numbers) > 100 || request.Customer != "test-customer" {
|
||||
t.Fatalf("invalid batch: %+v", request)
|
||||
}
|
||||
list := make([]map[string]string, 0, len(request.Numbers))
|
||||
for i := len(request.Numbers) - 1; i >= 0; i-- {
|
||||
number := request.Numbers[i]
|
||||
if number == "7" {
|
||||
continue
|
||||
}
|
||||
item := map[string]string{"msisdn": number, "result": "0", "simStatus": "4"}
|
||||
if number == "8" {
|
||||
item["result"] = "-1"
|
||||
}
|
||||
list = append(list, item)
|
||||
}
|
||||
json.NewEncoder(w).Encode(map[string]any{"code": "0", "result": map[string]any{"simStatusList": list}})
|
||||
})
|
||||
keys := make([]*model.SimKey, 101)
|
||||
for i := range keys {
|
||||
keys[i] = &model.SimKey{MSISDN: fmt.Sprint(i)}
|
||||
}
|
||||
items, err := client.QuerySimCardStatusBatch(context.Background(), &model.SimCardStatusInput{SimKeys: keys})
|
||||
if err != nil || len(items) != 101 || calls != 2 {
|
||||
t.Fatalf("batch size/calls: %d/%d err=%v", len(items), calls, err)
|
||||
}
|
||||
for i, item := range items {
|
||||
if item.SimKey != keys[i] {
|
||||
t.Fatal("card identity/order changed")
|
||||
}
|
||||
if i == 7 || i == 8 {
|
||||
if item.Code == 0 {
|
||||
t.Fatalf("partial failure %d was successful", i)
|
||||
}
|
||||
} else if item.Code != 0 || item.ResData.CardStatus != 2 {
|
||||
t.Fatalf("card %d: %+v", i, item)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDXV3BatchUsageChecksPeriodAndItemCode(t *testing.T) {
|
||||
month := time.Now().In(dxBatchLocation).Format("200601")
|
||||
client := newDXV3TestClient(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
fmt.Fprintf(w, `{"code":"0","result":{"batchTrafficList":[{"msisdn":"1","result":"0","month":%q,"unit":"MB","dataAmount":"2.5"},{"msisdn":"2","result":"1","month":%q,"dataAmount":"9"},{"msisdn":"3","result":"0","month":"200001","dataAmount":"9"}]}}`, month, month)
|
||||
})
|
||||
items, err := client.QuerySimDataUsageMonthlyBatch(context.Background(), &model.SimDataUsageMonthlyInput{SimKeys: []*model.SimKey{{MSISDN: "1"}, {MSISDN: "2"}, {MSISDN: "3"}}, ReqData: model.SimDataUsageMonthlyInputItem{QueryDate: month}})
|
||||
if err != nil || items[0].Code != 0 || items[0].ResData.DataAmount != 2.5 || items[1].Code == 0 || items[2].Code == 0 {
|
||||
t.Fatalf("unexpected usage batch: %+v, %v", items, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDXV3OperationsDefaultOffAndUseNewSemantics(t *testing.T) {
|
||||
calls := 0
|
||||
client := newDXV3TestClient(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
calls++
|
||||
switch r.URL.Path {
|
||||
case "/api/v3/singleCutNet":
|
||||
var body map[string]string
|
||||
json.NewDecoder(r.Body).Decode(&body)
|
||||
if r.Method != "POST" || body["operType"] != "2" || body["msisdn"] != "1" {
|
||||
t.Errorf("invalid cut-net request: %+v", body)
|
||||
}
|
||||
case "/api/v3/updateSimStatus":
|
||||
if r.URL.Query().Get("operType") != "19" {
|
||||
t.Error("wrong status operation")
|
||||
}
|
||||
default:
|
||||
t.Error("unexpected operation path")
|
||||
}
|
||||
fmt.Fprint(w, `{"code":"0","result":{"transactionId":"order-1"}}`)
|
||||
})
|
||||
status := &model.SimCardStatusChangeInput{SimKeys: []*model.SimKey{{MSISDN: "1"}}, ReqData: model.SimCardStatusChangeInputItem{OperType: "0"}}
|
||||
items, _ := client.ChangeSimStatus(context.Background(), status)
|
||||
if items[0].Code == 0 || calls != 0 {
|
||||
t.Fatal("disabled operation sent a request")
|
||||
}
|
||||
client.enableOperations = true
|
||||
items, _ = client.ChangeSimStatus(context.Background(), status)
|
||||
if items[0].Code != 0 || items[0].ResData.OrderNum != "order-1" {
|
||||
t.Fatalf("status: %+v", items[0])
|
||||
}
|
||||
data, _ := client.ChangeSimApnFunction(context.Background(), &model.SimCardOperateChangeApnInput{SimKeys: status.SimKeys, ReqData: model.SimCardOperateChangeApnInputItem{OperType: "1"}})
|
||||
if data[0].Code != 0 || calls != 2 {
|
||||
t.Fatal("new cut-net operation did not succeed")
|
||||
}
|
||||
if _, err := dxV3Params(nil); err == nil {
|
||||
t.Fatal("nil key accepted")
|
||||
}
|
||||
if !strings.Contains(items[0].ResData.OrderNum, "order") {
|
||||
t.Fatal("missing operation reference")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDXV3CutNetIgnoresApnNameLikeV1(t *testing.T) {
|
||||
calls := 0
|
||||
client := newDXV3TestClient(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
calls++
|
||||
if r.URL.Path != "/api/v3/singleCutNet" {
|
||||
t.Errorf("unexpected path: %s", r.URL.Path)
|
||||
}
|
||||
var body map[string]string
|
||||
json.NewDecoder(r.Body).Decode(&body)
|
||||
if body["operType"] != "2" || body["msisdn"] != "1" {
|
||||
t.Errorf("invalid cut-net request: %+v", body)
|
||||
}
|
||||
if _, ok := body["apnName"]; ok {
|
||||
t.Errorf("ApnName must not be forwarded: %+v", body)
|
||||
}
|
||||
fmt.Fprint(w, `{"code":"0","result":{"transactionId":"order-9"}}`)
|
||||
})
|
||||
client.enableOperations = true
|
||||
items, err := client.ChangeSimApnFunction(context.Background(), &model.SimCardOperateChangeApnInput{
|
||||
SimKeys: []*model.SimKey{{MSISDN: "1"}},
|
||||
ReqData: model.SimCardOperateChangeApnInputItem{OperType: "1", ApnName: "CMIOT"},
|
||||
})
|
||||
if err != nil || calls != 1 || len(items) != 1 || items[0].Code != 0 || items[0].ResData.OrderNum != "order-9" {
|
||||
t.Fatalf("named APN must be ignored, not rejected: %+v, %v", items, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDXV3UnsupportedQueriesDegradeLikeV1(t *testing.T) {
|
||||
client := newDXV3TestClient(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
t.Errorf("unsupported queries must not reach the upstream: %s", r.URL.Path)
|
||||
})
|
||||
keys := []*model.SimKey{{MSISDN: "1"}}
|
||||
frozen, err := client.QuerySimFrozenState(context.Background(), &model.SimFrozenStateInput{SimKeys: keys})
|
||||
if err != nil || len(frozen) != 1 || frozen[0].Code != consts.ApiStatusSuccess || frozen[0].ResData.FrozenStatus != "不支持查询" {
|
||||
t.Fatalf("frozen state should degrade gracefully: %+v, %v", frozen, err)
|
||||
}
|
||||
diagnosis, err := client.QuerySimDataDiagnosis(context.Background(), &model.SimDataDiagnosisInput{SimKeys: keys})
|
||||
if err != nil || len(diagnosis) != 1 || diagnosis[0].Code != consts.ApiInvokeException || diagnosis[0].Message == "" {
|
||||
t.Fatalf("data diagnosis should report as unsupported: %+v, %v", diagnosis, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDXV3MultiCardUsageUpgradesToBatch(t *testing.T) {
|
||||
month := time.Now().In(dxBatchLocation).Format("200601")
|
||||
day := time.Now().In(dxBatchLocation).AddDate(0, 0, -1).Format("20060102")
|
||||
paths := make([]string, 0, 4)
|
||||
client := newDXV3TestClient(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
paths = append(paths, r.URL.Path)
|
||||
switch r.URL.Path {
|
||||
case "/api/v3/getDataUsageByMonthBatch":
|
||||
fmt.Fprintf(w, `{"code":"0","result":{"batchTrafficList":[{"msisdn":"1","result":"0","month":%q,"unit":"MB","dataAmount":"2.5"},{"msisdn":"2","result":"0","month":%q,"unit":"MB","dataAmount":"3.5"}]}}`, month, month)
|
||||
case "/api/v3/getDataUsageByDayBatch":
|
||||
fmt.Fprintf(w, `{"code":"0","result":{"batchTrafficList":[{"msisdn":"1","result":"0","date":%q,"unit":"MB","dataAmount":"1.5"},{"msisdn":"2","result":"0","date":%q,"unit":"MB","dataAmount":"2.5"}]}}`, day, day)
|
||||
default:
|
||||
fmt.Fprint(w, `{"code":"0","result":{"dataAmount":"1","unit":"MB"}}`)
|
||||
}
|
||||
})
|
||||
keys := []*model.SimKey{{MSISDN: "1"}, {MSISDN: "2"}}
|
||||
monthly, err := client.QuerySimDataUsageMonthly(context.Background(), &model.SimDataUsageMonthlyInput{SimKeys: keys, ReqData: model.SimDataUsageMonthlyInputItem{QueryDate: month}})
|
||||
if err != nil || len(monthly) != 2 || monthly[0].ResData.DataAmount != 2.5 || monthly[1].ResData.DataAmount != 3.5 {
|
||||
t.Fatalf("monthly upgrade: %+v, %v", monthly, err)
|
||||
}
|
||||
if len(paths) != 1 || paths[0] != "/api/v3/getDataUsageByMonthBatch" {
|
||||
t.Fatalf("monthly query should collapse into one batch call, got %v", paths)
|
||||
}
|
||||
paths = paths[:0]
|
||||
daily, err := client.QuerySimDataUsageDaily(context.Background(), &model.SimDataUsageDailyInput{SimKeys: keys, ReqData: model.SimDataUsageDailyInputItem{QueryDate: day}})
|
||||
if err != nil || len(daily) != 2 || daily[0].ResData.DataAmount != 1.5 {
|
||||
t.Fatalf("daily upgrade: %+v, %v", daily, err)
|
||||
}
|
||||
if len(paths) != 1 || paths[0] != "/api/v3/getDataUsageByDayBatch" {
|
||||
t.Fatalf("daily query should collapse into one batch call, got %v", paths)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDXV3MultiCardUsageFallsBackToConcurrentPerCard(t *testing.T) {
|
||||
month := time.Now().In(dxBatchLocation).Format("200601")
|
||||
calls := 0
|
||||
client := newDXV3TestClient(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
calls++
|
||||
if r.URL.Path != "/api/v3/getDataUsageByMonth" {
|
||||
t.Errorf("batch must not be used without custNbr: %s", r.URL.Path)
|
||||
}
|
||||
fmt.Fprint(w, `{"code":"0","result":{"dataAmount":"1","unit":"MB"}}`)
|
||||
})
|
||||
client.custNbr = ""
|
||||
keys := []*model.SimKey{{MSISDN: "1"}, {MSISDN: "2"}, {MSISDN: "3"}}
|
||||
items, err := client.QuerySimDataUsageMonthly(context.Background(), &model.SimDataUsageMonthlyInput{SimKeys: keys, ReqData: model.SimDataUsageMonthlyInputItem{QueryDate: month}})
|
||||
if err != nil || len(items) != 3 || calls != 3 {
|
||||
t.Fatalf("fallback should query every card once: %d, %+v, %v", calls, items, err)
|
||||
}
|
||||
for index, item := range items {
|
||||
if item.SimKey != keys[index] || item.Code != 0 {
|
||||
t.Fatalf("fallback lost order or failed: %+v", item)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 批量接口整批不可用(网关/鉴权/报文错误)时,必须回退单卡查询,而不是整批报废。
|
||||
func TestDXV3BatchOutageFallsBackToSingleCard(t *testing.T) {
|
||||
month := time.Now().In(dxBatchLocation).Format("200601")
|
||||
batchCalls, singleCalls := 0, 0
|
||||
client := newDXV3TestClient(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/api/v3/getDataUsageByMonthBatch":
|
||||
batchCalls++
|
||||
w.Header().Set("gw-errorCode", "S00004")
|
||||
w.WriteHeader(http.StatusBadGateway)
|
||||
fmt.Fprint(w, `{}`)
|
||||
case "/api/v3/getDataUsageByMonth":
|
||||
singleCalls++
|
||||
fmt.Fprint(w, `{"code":"0","result":{"dataAmount":"4","unit":"MB"}}`)
|
||||
default:
|
||||
t.Errorf("unexpected path: %s", r.URL.Path)
|
||||
}
|
||||
})
|
||||
keys := []*model.SimKey{{MSISDN: "1"}, {MSISDN: "2"}}
|
||||
items, err := client.QuerySimDataUsageMonthly(context.Background(), &model.SimDataUsageMonthlyInput{
|
||||
SimKeys: keys, ReqData: model.SimDataUsageMonthlyInputItem{QueryDate: month}})
|
||||
if err != nil || len(items) != 2 {
|
||||
t.Fatalf("batch outage must not fail the whole query: %+v, %v", items, err)
|
||||
}
|
||||
for index, item := range items {
|
||||
if item.SimKey != keys[index] || item.Code != consts.ApiStatusSuccess || item.ResData.DataAmount != 4 {
|
||||
t.Fatalf("card %d was not recovered by the single-card fallback: %+v", index, item)
|
||||
}
|
||||
}
|
||||
if batchCalls != 1 || singleCalls != 2 {
|
||||
t.Fatalf("unexpected call split: batch=%d single=%d", batchCalls, singleCalls)
|
||||
}
|
||||
}
|
||||
|
||||
// 批量只漏/错某张卡时,只对该卡用单卡补齐,批量成功项保留(不重复请求)。
|
||||
func TestDXV3BatchOnlyRefillsFailedCards(t *testing.T) {
|
||||
month := time.Now().In(dxBatchLocation).Format("200601")
|
||||
singleCalls := 0
|
||||
client := newDXV3TestClient(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/api/v3/getDataUsageByMonthBatch":
|
||||
fmt.Fprintf(w, `{"code":"0","result":{"batchTrafficList":[{"msisdn":"1","result":"0","month":%q,"unit":"MB","dataAmount":"2.5"}]}}`, month)
|
||||
case "/api/v3/getDataUsageByMonth":
|
||||
singleCalls++
|
||||
if r.URL.Query().Get("msisdn") != "2" {
|
||||
t.Errorf("only the failed card may be refilled, got %s", r.URL.Query().Get("msisdn"))
|
||||
}
|
||||
fmt.Fprint(w, `{"code":"0","result":{"dataAmount":"7","unit":"MB"}}`)
|
||||
default:
|
||||
t.Errorf("unexpected path: %s", r.URL.Path)
|
||||
}
|
||||
})
|
||||
keys := []*model.SimKey{{MSISDN: "1"}, {MSISDN: "2"}}
|
||||
items, err := client.QuerySimDataUsageMonthly(context.Background(), &model.SimDataUsageMonthlyInput{
|
||||
SimKeys: keys, ReqData: model.SimDataUsageMonthlyInputItem{QueryDate: month}})
|
||||
if err != nil || len(items) != 2 || singleCalls != 1 {
|
||||
t.Fatalf("only failed cards should be refilled: single=%d, %+v, %v", singleCalls, items, err)
|
||||
}
|
||||
if items[0].Code != consts.ApiStatusSuccess || items[0].ResData.DataAmount != 2.5 {
|
||||
t.Fatalf("batch success must be kept: %+v", items[0])
|
||||
}
|
||||
if items[1].Code != consts.ApiStatusSuccess || items[1].ResData.DataAmount != 7 {
|
||||
t.Fatalf("omitted card must be refilled by single query: %+v", items[1])
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
package upstreamimpl
|
||||
|
||||
import (
|
||||
"context"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/consts"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/model"
|
||||
"strings"
|
||||
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
)
|
||||
|
||||
func convertDxSessionStatus(ctx context.Context, upstreamStatus string) string {
|
||||
switch strings.TrimSpace(upstreamStatus) {
|
||||
case "0":
|
||||
return consts.CardSessionStatusOnline
|
||||
case "1":
|
||||
return consts.CardSessionStatusOffline
|
||||
case "2", "":
|
||||
return consts.CardSessionStatusUnknown
|
||||
default:
|
||||
g.Log().Warningf(ctx, "Unknown DX session status %s from upstream", upstreamStatus)
|
||||
return consts.CardSessionStatusUnknown
|
||||
}
|
||||
}
|
||||
|
||||
func convertYdSessionStatus(ctx context.Context, upstreamStatus string) string {
|
||||
switch strings.ToUpper(strings.TrimSpace(upstreamStatus)) {
|
||||
case "01", "1", "0", "ONLINE":
|
||||
return consts.CardSessionStatusOnline
|
||||
case "00", "-1", "OFFLINE":
|
||||
return consts.CardSessionStatusOffline
|
||||
case "02", "2", "-2", "", "UNKNOWN":
|
||||
return consts.CardSessionStatusUnknown
|
||||
default:
|
||||
g.Log().Warningf(ctx, "Unknown YD session status %s from upstream", upstreamStatus)
|
||||
return consts.CardSessionStatusUnknown
|
||||
}
|
||||
}
|
||||
|
||||
func convertYdApnStatus(ctx context.Context, upstreamStatus string) string {
|
||||
switch strings.TrimSpace(upstreamStatus) {
|
||||
case consts.CardApnStatusPaused:
|
||||
return consts.CardApnStatusPaused
|
||||
case consts.CardApnStatusNormal:
|
||||
return consts.CardApnStatusNormal
|
||||
default:
|
||||
g.Log().Warningf(ctx, "Unknown YD APN status %s from upstream", upstreamStatus)
|
||||
return upstreamStatus
|
||||
}
|
||||
}
|
||||
|
||||
// convertDxApnOperType 把平台 APN 开停编码转换为电信 singleCutNet 的 action 取值,语义相反。
|
||||
// 平台编码(全平台统一「开=0 / 停=1」,见 consts.CardApnOperType*):
|
||||
//
|
||||
// 0 = 开通数据(恢复联网)
|
||||
// 1 = 停用数据(断网,含达量断网)
|
||||
//
|
||||
// 电信 singleCutNet(CTIOT_5GCMP_BA004)action:
|
||||
//
|
||||
// ADD = 单独断网(添加断网)
|
||||
// DEL = 取消单独断网(恢复联网)
|
||||
//
|
||||
// 两者恰好相反:平台 0(开) -> 电信 DEL(取消断网),平台 1(停) -> 电信 ADD(断网)。禁止透传。
|
||||
//
|
||||
// 注意:singleCutNet 是整卡数据开停语义,电信侧没有「按 APN 定向」这一维度,
|
||||
// 因此忽略调用方携带的 ApnName(预警自动断网、批量数据开停都会固定带 apnName="CMIOT")。
|
||||
func convertDxApnOperType(ctx context.Context, operType string) (string, bool) {
|
||||
switch strings.TrimSpace(operType) {
|
||||
case consts.CardApnOperTypeOpen:
|
||||
return "DEL", true
|
||||
case consts.CardApnOperTypeStop:
|
||||
return "ADD", true
|
||||
default:
|
||||
g.Log().Warningf(ctx, "Unknown APN operType %s for DX upstream", operType)
|
||||
return "", false
|
||||
}
|
||||
}
|
||||
|
||||
// convertYdCommunicationBusinessType 把平台 APN 开停编码转换为移动 OneLink「通信业务开停-单卡」
|
||||
// (7.2.1 CMIOT_API25M19,ec/manage/sim-communication-service)的 businessType。两者同向,无需翻转。
|
||||
//
|
||||
// 平台编码(全平台统一「开=0 / 停=1」,见 consts.CardApnOperType*):
|
||||
//
|
||||
// 0 = 开通数据(启用 APN)
|
||||
// 1 = 停用数据(停用 APN,含达量断网)
|
||||
//
|
||||
// 移动 7.2.1 businessType:0:开 1:停(同向,直接透传)
|
||||
func convertYdCommunicationBusinessType(ctx context.Context, operType string) (string, bool) {
|
||||
switch strings.TrimSpace(operType) {
|
||||
case consts.CardApnOperTypeOpen:
|
||||
return "0", true
|
||||
case consts.CardApnOperTypeStop:
|
||||
return "1", true
|
||||
default:
|
||||
g.Log().Warningf(ctx, "Unknown APN operType %s for YD sim-communication-service", operType)
|
||||
return "", false
|
||||
}
|
||||
}
|
||||
|
||||
// convertYdCommunicationOperType 把平台 APN 开停编码转换为移动 OneLink「通信功能开停-批量」
|
||||
// (7.2.2 CMIOT_API23M09,ec/operate/sim-communication-function/batch)的 operType。语义相反,必须翻转。
|
||||
//
|
||||
// 平台编码(全平台统一「开=0 / 停=1」,见 consts.CardApnOperType*):
|
||||
//
|
||||
// 0 = 开通数据(启用 APN)
|
||||
// 1 = 停用数据(停用 APN,含达量断网)
|
||||
//
|
||||
// 移动 7.2.2 operType:0:暂停(停) 1:恢复(开)(与平台相反)
|
||||
//
|
||||
// 平台 0(开) -> 移动 1(恢复),平台 1(停) -> 移动 0(暂停)。禁止透传。
|
||||
func convertYdCommunicationOperType(ctx context.Context, operType string) (string, bool) {
|
||||
switch strings.TrimSpace(operType) {
|
||||
case consts.CardApnOperTypeOpen:
|
||||
return "1", true
|
||||
case consts.CardApnOperTypeStop:
|
||||
return "0", true
|
||||
default:
|
||||
g.Log().Warningf(ctx, "Unknown APN operType %s for YD sim-communication-function", operType)
|
||||
return "", false
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeCardStatusChangeOperType(ctx context.Context, operType string) (string, bool) {
|
||||
switch strings.TrimSpace(operType) {
|
||||
case consts.CardStatusChangeOperTypeApplyStop:
|
||||
return consts.CardStatusChangeOperTypeApplyStop, true
|
||||
case consts.CardStatusChangeOperTypeApplyResume:
|
||||
return consts.CardStatusChangeOperTypeApplyResume, true
|
||||
default:
|
||||
g.Log().Warningf(ctx, "Unknown card status change operType %s", operType)
|
||||
return "", false
|
||||
}
|
||||
}
|
||||
|
||||
func convertDxCardStatusChangeOperType(ctx context.Context, operType string) (string, bool) {
|
||||
switch strings.TrimSpace(operType) {
|
||||
case consts.CardStatusChangeOperTypeApplyStop:
|
||||
return "19", true
|
||||
case consts.CardStatusChangeOperTypeApplyResume:
|
||||
return "20", true
|
||||
default:
|
||||
g.Log().Warningf(ctx, "Unknown card status change operType %s for DX upstream", operType)
|
||||
return "", false
|
||||
}
|
||||
}
|
||||
|
||||
func newInvalidCardStatusChangeOutput(simKeys []*model.SimKey) model.SimCardStatusChangeOutput {
|
||||
items := make(model.SimCardStatusChangeOutput, 0, len(simKeys))
|
||||
for _, simKey := range simKeys {
|
||||
items = append(items, &model.SimBaseRes[model.SimCardStatusChangeItem]{
|
||||
SimKey: simKey,
|
||||
Code: consts.ApiStatusError,
|
||||
Message: "unsupported operType, expected 0(stop) or 1(resume)",
|
||||
ResData: model.SimCardStatusChangeItem{},
|
||||
})
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
// convertTibiotCardStatus 转换Tibiot卡状态码
|
||||
// Tibiot: 1正常 2停机 3沉默期 5测试期 6停机保号
|
||||
// 系统: 1待激活 2已激活 4停机 6可测试 7库存 8预销户 9已销户
|
||||
func convertTibiotCardStatus(ctx context.Context, upstreamStatus int) int {
|
||||
switch upstreamStatus {
|
||||
case 1: // 正常
|
||||
return 2 // 已激活
|
||||
case 2: // 停机
|
||||
return 4 // 停机
|
||||
case 3: // 沉默期
|
||||
return 1 // 待激活
|
||||
case 5: // 测试期
|
||||
return 6 // 可测试
|
||||
case 6: // 停机保号
|
||||
return 4 // 停机
|
||||
default:
|
||||
g.Log().Warningf(ctx, "Unknown Tibiot card status %d", upstreamStatus)
|
||||
return 0 // 未知
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,521 @@
|
||||
package upstreamimpl
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
|
||||
"git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/consts"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/model"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/service"
|
||||
)
|
||||
|
||||
const henanXinjingDirectTemplateKey = "sim_henan_xinjing_direct_v1"
|
||||
|
||||
// henanXinjingConfig is the channel-level config for the direct Henan Xinjing
|
||||
// template. The top-level userId/apiKey form is the canonical shape; the
|
||||
// nested `credentials` block is kept for backward compatibility with earlier
|
||||
// drafts of this template.
|
||||
type henanXinjingConfig struct {
|
||||
UserID string `json:"userId"`
|
||||
APIKey string `json:"apiKey"`
|
||||
APIKeyLegacy string `json:"apikey"`
|
||||
API struct {
|
||||
BaseURL string `json:"baseUrl"`
|
||||
Timeout string `json:"timeout"`
|
||||
} `json:"api"`
|
||||
Credentials struct {
|
||||
UserID string `json:"userId"`
|
||||
APIKey string `json:"apiKey"`
|
||||
APIKeyLegacy string `json:"apikey"`
|
||||
} `json:"credentials"`
|
||||
}
|
||||
|
||||
// henanXinjingParseConfig unmarshals one JSON config blob (channel or
|
||||
// template) into henanXinjingConfig. Empty input yields a zero config.
|
||||
func henanXinjingParseConfig(raw string) henanXinjingConfig {
|
||||
var c henanXinjingConfig
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return c
|
||||
}
|
||||
_ = json.Unmarshal([]byte(raw), &c)
|
||||
return c
|
||||
}
|
||||
|
||||
// henanXinjingPickCreds resolves (userId, apiKey) with precedence:
|
||||
// channel.chan_config > channel.app_key/app_secret > template.temp_config.
|
||||
// This matches how the LCM admin stores credentials: per-channel in
|
||||
// t_sim_channels, not in the shared template row.
|
||||
func henanXinjingPickCreds(chanCfg, tplCfg henanXinjingConfig, appKey, appSecret string) (user, key string) {
|
||||
firstNonEmptyStr := func(vals ...string) string {
|
||||
for _, v := range vals {
|
||||
if v = strings.TrimSpace(v); v != "" {
|
||||
return v
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
user = firstNonEmptyStr(
|
||||
chanCfg.UserID, chanCfg.Credentials.UserID,
|
||||
appKey,
|
||||
tplCfg.UserID, tplCfg.Credentials.UserID,
|
||||
)
|
||||
key = firstNonEmptyStr(
|
||||
chanCfg.APIKey, chanCfg.APIKeyLegacy,
|
||||
chanCfg.Credentials.APIKey, chanCfg.Credentials.APIKeyLegacy,
|
||||
appSecret,
|
||||
tplCfg.APIKey, tplCfg.APIKeyLegacy,
|
||||
tplCfg.Credentials.APIKey, tplCfg.Credentials.APIKeyLegacy,
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
// henanXinjingFlexFloat accepts either a JSON number or a JSON string and
|
||||
// normalizes to float64. The platform returns `used` as a number and
|
||||
// `surplus`/`canused` as strings in some responses.
|
||||
type henanXinjingFlexFloat float64
|
||||
|
||||
func (f *henanXinjingFlexFloat) UnmarshalJSON(data []byte) error {
|
||||
if len(data) == 0 || string(data) == "null" {
|
||||
*f = 0
|
||||
return nil
|
||||
}
|
||||
var n float64
|
||||
if err := json.Unmarshal(data, &n); err == nil {
|
||||
*f = henanXinjingFlexFloat(n)
|
||||
return nil
|
||||
}
|
||||
var s string
|
||||
if err := json.Unmarshal(data, &s); err != nil {
|
||||
return err
|
||||
}
|
||||
s = strings.TrimSpace(s)
|
||||
if s == "" {
|
||||
*f = 0
|
||||
return nil
|
||||
}
|
||||
v, err := strconv.ParseFloat(s, 64)
|
||||
if err != nil {
|
||||
return fmt.Errorf("henan xinjing: parse float %q: %w", s, err)
|
||||
}
|
||||
*f = henanXinjingFlexFloat(v)
|
||||
return nil
|
||||
}
|
||||
|
||||
// henanXinjingCard mirrors `data` in `/api/v1/getChaxun`. Only fields we
|
||||
// actually use are decoded; the platform returns many more.
|
||||
//
|
||||
// Documented field meanings (per upstream doc):
|
||||
// - state : string card status code ("00" ok, "02" stopped, etc.)
|
||||
// - status : int online/auth status (NOT the card lifecycle status)
|
||||
// - used : KB (current month)
|
||||
// - surplus : KB
|
||||
// - expired_at / shouchong_time : yyyy-MM-dd HH:mm:ss strings
|
||||
// - gprs_status : 0 unknown 1 on 2 off
|
||||
// - power_status: 0 unknown 1 online 2 offline
|
||||
// - display_status: 1 pending, 2 normal, 3 abnormal, 4 pre-close, 5 closed
|
||||
type henanXinjingCard struct {
|
||||
ICCID string `json:"iccid"`
|
||||
CardNo string `json:"cardno"`
|
||||
IMEI string `json:"imei"`
|
||||
Used henanXinjingFlexFloat `json:"used"`
|
||||
Surplus henanXinjingFlexFloat `json:"surplus"`
|
||||
CanUsed henanXinjingFlexFloat `json:"canused"`
|
||||
State string `json:"state"`
|
||||
Status int `json:"status"`
|
||||
OnlineState int `json:"onlinestate"`
|
||||
GPRSStatus int `json:"gprs_status"`
|
||||
PowerStatus int `json:"power_status"`
|
||||
DisplayStatus int `json:"display_status"`
|
||||
ExpiredAt string `json:"expired_at"`
|
||||
FirstChargeAt string `json:"shouchong_time"`
|
||||
Message string `json:"message"`
|
||||
AutoName string `json:"autoname"`
|
||||
MonthUseds []struct {
|
||||
MonthDate string `json:"month_date"` // yyyyMM
|
||||
UsedFlow string `json:"used_flow"` // MB per upstream doc
|
||||
} `json:"month_useds"`
|
||||
Package []struct {
|
||||
OrderNo string `json:"orderno"`
|
||||
PackageName string `json:"package_name"`
|
||||
State int `json:"state"`
|
||||
ExpiredAt string `json:"expired_at"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
Flow string `json:"flow"`
|
||||
MonthlyFlow henanXinjingFlexFloat `json:"monthly_flow"`
|
||||
Surplus henanXinjingFlexFloat `json:"surplus"`
|
||||
Used henanXinjingFlexFloat `json:"used"`
|
||||
} `json:"package"`
|
||||
}
|
||||
|
||||
type sSimHenanXinjingDirect struct {
|
||||
channelConfig *model.SimChannelConfigItemCache
|
||||
client *henanXinjingClient
|
||||
}
|
||||
|
||||
func init() {
|
||||
service.UpstreamAPI().RegisterTemp(henanXinjingDirectTemplateKey, NewSimHenanXinjingDirect())
|
||||
}
|
||||
|
||||
func NewSimHenanXinjingDirect() *sSimHenanXinjingDirect { return &sSimHenanXinjingDirect{} }
|
||||
|
||||
func (s *sSimHenanXinjingDirect) NewTemp(ctx context.Context, cfg *model.SimChannelConfigItemCache) model.SimCardChannelI {
|
||||
chanKey := ""
|
||||
if cfg != nil && cfg.Channel != nil {
|
||||
chanKey = cfg.Channel.ChanKey
|
||||
}
|
||||
if cfg == nil || cfg.Channel == nil || cfg.Template == nil {
|
||||
g.Log().Warningf(ctx, "henan_xinjing_direct.NewTemp: nil cfg/channel/template, chanKey=%s", chanKey)
|
||||
return nil
|
||||
}
|
||||
chanCfg := henanXinjingParseConfig(cfg.Channel.ChanConfig)
|
||||
tplCfg := henanXinjingParseConfig(cfg.Template.TempConfig)
|
||||
user, key := henanXinjingPickCreds(chanCfg, tplCfg, cfg.Channel.AppKey, cfg.Channel.AppSecret)
|
||||
baseURL := strings.TrimSpace(chanCfg.API.BaseURL)
|
||||
if baseURL == "" {
|
||||
baseURL = strings.TrimSpace(tplCfg.API.BaseURL)
|
||||
}
|
||||
if user == "" || key == "" || baseURL == "" {
|
||||
g.Log().Warningf(ctx,
|
||||
"henan_xinjing_direct.NewTemp: missing required config, chanKey=%s userIdEmpty=%t apiKeyEmpty=%t baseURLEmpty=%t",
|
||||
chanKey, user == "", key == "", baseURL == "")
|
||||
return nil
|
||||
}
|
||||
timeout := defaultUpstreamHTTPTimeout
|
||||
for _, raw := range []string{chanCfg.API.Timeout, tplCfg.API.Timeout} {
|
||||
if raw == "" {
|
||||
continue
|
||||
}
|
||||
if d, err := time.ParseDuration(raw); err == nil && d > 0 {
|
||||
timeout = d
|
||||
break
|
||||
}
|
||||
}
|
||||
return &sSimHenanXinjingDirect{
|
||||
channelConfig: cfg,
|
||||
client: newHenanXinjingClient(baseURL, user, key, timeout),
|
||||
}
|
||||
}
|
||||
|
||||
// simKeyCardNo picks the identifier Henan Xinjing accepts for `cardno`.
|
||||
// The platform keys on its own card-number column, which corresponds to
|
||||
// MSISDN for these cards; fall back to ICCID/IMSI when MSISDN is absent.
|
||||
func henanXinjingCardNo(k *model.SimKey) string {
|
||||
if k == nil {
|
||||
return ""
|
||||
}
|
||||
if v := strings.TrimSpace(k.MSISDN); v != "" {
|
||||
return v
|
||||
}
|
||||
if v := strings.TrimSpace(k.ICCID); v != "" {
|
||||
return v
|
||||
}
|
||||
return strings.TrimSpace(k.IMSI)
|
||||
}
|
||||
|
||||
// fetchCard calls /api/v1/getChaxun for a single SimKey.
|
||||
func (s *sSimHenanXinjingDirect) fetchCard(ctx context.Context, k *model.SimKey) (henanXinjingCard, error) {
|
||||
if k == nil {
|
||||
return henanXinjingCard{}, fmt.Errorf("sim key is required")
|
||||
}
|
||||
no := henanXinjingCardNo(k)
|
||||
if no == "" {
|
||||
return henanXinjingCard{}, fmt.Errorf("msisdn/iccid/imsi is required")
|
||||
}
|
||||
var c henanXinjingCard
|
||||
err := s.client.get(ctx, "/api/v1/getChaxun", map[string]string{"cardno": no}, &c)
|
||||
return c, err
|
||||
}
|
||||
|
||||
// henanXinjingKBToMB converts upstream KB to the MB unit SimDataUsageItem
|
||||
// requires. Rounds to 4 decimal places to keep values readable.
|
||||
func henanXinjingKBToMB(kb float64) float64 {
|
||||
if kb <= 0 {
|
||||
return 0
|
||||
}
|
||||
mb := kb / 1024
|
||||
return float64(int(mb*10000+0.5)) / 10000
|
||||
}
|
||||
|
||||
// henanXinjingMapCardStatus translates the platform's `state` string into the
|
||||
// internal CardStatus code used across the gateway.
|
||||
//
|
||||
// Upstream doc:
|
||||
//
|
||||
// 0 unknown, 00 normal, 01 one-way stop, 02 stopped, 03 pre-close,
|
||||
// 04 closed, 05 transfer, 06 dormant, 07 pending, 08 disabled,
|
||||
// 09 inventory, 10 expired, 11 violation stop, 12 lost, 13 user-stop,
|
||||
// 14 testing, 99 not-exists
|
||||
//
|
||||
// Internal consts (from existing adapters like tibiot):
|
||||
//
|
||||
// 1待激活 2已激活 4停机 6可测试 7库存 8预销户 9已销户
|
||||
func henanXinjingMapCardStatus(state string) int {
|
||||
switch strings.TrimSpace(state) {
|
||||
case "00":
|
||||
return 2 // 已激活/正常
|
||||
case "01", "02", "08", "11", "12", "13":
|
||||
return 4 // 停机
|
||||
case "03":
|
||||
return 8 // 预销户
|
||||
case "04", "10":
|
||||
return 9 // 已销户
|
||||
case "06":
|
||||
return 1 // 休眠 → 待激活
|
||||
case "07":
|
||||
return 1 // 待激活
|
||||
case "09":
|
||||
return 7 // 库存
|
||||
case "14":
|
||||
return 6 // 可测试
|
||||
default:
|
||||
return 0 // 未知
|
||||
}
|
||||
}
|
||||
|
||||
func henanXinjingStatusChangePath(operType string) (string, bool) {
|
||||
switch operType {
|
||||
case consts.CardStatusChangeOperTypeApplyStop:
|
||||
return "/api/v1/stop", true
|
||||
case consts.CardStatusChangeOperTypeApplyResume:
|
||||
return "/api/v1/start", true
|
||||
default:
|
||||
return "", false
|
||||
}
|
||||
}
|
||||
|
||||
func henanXinjingMainOfferName(card henanXinjingCard) string {
|
||||
if name := strings.TrimSpace(card.AutoName); name != "" {
|
||||
return name
|
||||
}
|
||||
for _, item := range card.Package {
|
||||
if name := strings.TrimSpace(item.PackageName); name != "" {
|
||||
return name
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (s *sSimHenanXinjingDirect) QuerySimBasicInfo(ctx context.Context, in *model.SimBasicInfoInput) (out model.SimBasicInfoOutput, err error) {
|
||||
for _, k := range in.SimKeys {
|
||||
c, e := s.fetchCard(ctx, k)
|
||||
r := &model.SimBaseRes[model.SimBasicInfoItem]{SimKey: k}
|
||||
if e != nil {
|
||||
r.Code = consts.ApiStatusError
|
||||
r.Message = e.Error()
|
||||
} else {
|
||||
r.Code = 0
|
||||
r.ResData = model.SimBasicInfoItem{
|
||||
ICCID: firstNonEmpty(c.ICCID, k.ICCID),
|
||||
IMSI: k.IMSI,
|
||||
MSISDN: firstNonEmpty(c.CardNo, k.MSISDN),
|
||||
ActiveDate: c.FirstChargeAt,
|
||||
OpenDate: c.FirstChargeAt, // use 首充时间 as the open/activate timestamp; expired_at goes to remark
|
||||
Remark: c.Message,
|
||||
SimStatus: strconv.Itoa(henanXinjingMapCardStatus(c.State)),
|
||||
MainOfferName: henanXinjingMainOfferName(c),
|
||||
}
|
||||
}
|
||||
out = append(out, r)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func (s *sSimHenanXinjingDirect) QuerySimImei(ctx context.Context, in *model.SimImeiInput) (out model.SimImeiOutput, err error) {
|
||||
for _, k := range in.SimKeys {
|
||||
c, e := s.fetchCard(ctx, k)
|
||||
r := &model.SimBaseRes[model.SimImeiItem]{SimKey: k}
|
||||
if e != nil {
|
||||
r.Code = consts.ApiStatusError
|
||||
r.Message = e.Error()
|
||||
} else {
|
||||
r.Code = 0
|
||||
r.ResData.Imei = c.IMEI
|
||||
}
|
||||
out = append(out, r)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func (s *sSimHenanXinjingDirect) QuerySimCardStatus(ctx context.Context, in *model.SimCardStatusInput) (out model.SimCardStatusOutput, err error) {
|
||||
for _, k := range in.SimKeys {
|
||||
c, e := s.fetchCard(ctx, k)
|
||||
r := &model.SimBaseRes[model.SimCardStatusItem]{SimKey: k}
|
||||
if e != nil {
|
||||
r.Code = consts.ApiStatusError
|
||||
r.Message = e.Error()
|
||||
} else {
|
||||
r.Code = 0
|
||||
r.ResData.CardStatus = henanXinjingMapCardStatus(c.State)
|
||||
}
|
||||
out = append(out, r)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func (s *sSimHenanXinjingDirect) QuerySimDataUsage(ctx context.Context, in *model.SimDataUsageInput) (out model.SimDataUsageOutput, err error) {
|
||||
for _, k := range in.SimKeys {
|
||||
c, e := s.fetchCard(ctx, k)
|
||||
r := &model.SimBaseRes[model.SimDataUsageItem]{SimKey: k}
|
||||
if e != nil {
|
||||
r.Code = consts.ApiStatusError
|
||||
r.Message = e.Error()
|
||||
} else {
|
||||
r.Code = 0
|
||||
r.ResData.DataUsage = henanXinjingKBToMB(float64(c.Used))
|
||||
total := henanXinjingKBToMB(float64(c.Used) + float64(c.Surplus))
|
||||
if total > 0 {
|
||||
r.ResData.TotalFlow = &total
|
||||
}
|
||||
}
|
||||
out = append(out, r)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func (s *sSimHenanXinjingDirect) ChangeSimStatus(ctx context.Context, in *model.SimCardStatusChangeInput) (out model.SimCardStatusChangeOutput, err error) {
|
||||
operType, ok := normalizeCardStatusChangeOperType(ctx, in.ReqData.OperType)
|
||||
if !ok {
|
||||
return newInvalidCardStatusChangeOutput(in.SimKeys), nil
|
||||
}
|
||||
for _, k := range in.SimKeys {
|
||||
r := &model.SimBaseRes[model.SimCardStatusChangeItem]{SimKey: k}
|
||||
path, _ := henanXinjingStatusChangePath(operType)
|
||||
no := henanXinjingCardNo(k)
|
||||
var e error
|
||||
if no == "" {
|
||||
e = fmt.Errorf("msisdn/iccid/imsi is required")
|
||||
} else {
|
||||
e = s.client.get(ctx, path, map[string]string{"cardno": no}, nil)
|
||||
}
|
||||
if e != nil {
|
||||
r.Code = consts.ApiStatusError
|
||||
r.Message = e.Error()
|
||||
} else {
|
||||
r.Code = 0
|
||||
}
|
||||
out = append(out, r)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func henanXinjingUnsupported[T any](keys []*model.SimKey, name string) (out []*model.SimBaseRes[T], err error) {
|
||||
for _, k := range keys {
|
||||
out = append(out, &model.SimBaseRes[T]{SimKey: k, Code: consts.ApiStatusError, Message: "henan xinjing direct does not support " + name})
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func (s *sSimHenanXinjingDirect) QuerySimSession(ctx context.Context, in *model.SimSessionInput) (model.SimSessionOutput, error) {
|
||||
return henanXinjingUnsupported[model.SimSessionItem](in.SimKeys, "session query")
|
||||
}
|
||||
func (s *sSimHenanXinjingDirect) QuerySimStopReason(ctx context.Context, in *model.SimStopReasonInput) (model.SimStopReasonOutput, error) {
|
||||
return henanXinjingUnsupported[model.SimStopReasonItem](in.SimKeys, "stop reason query")
|
||||
}
|
||||
func (s *sSimHenanXinjingDirect) QuerySimFrozenState(ctx context.Context, in *model.SimFrozenStateInput) (model.SimFrozenStateOutput, error) {
|
||||
return henanXinjingUnsupported[model.SimFrozenStateItem](in.SimKeys, "frozen state query")
|
||||
}
|
||||
func (s *sSimHenanXinjingDirect) QuerySimCardBindStatus(ctx context.Context, in *model.SimCardBindStatusInput) (model.SimCardBindStatusOutput, error) {
|
||||
return henanXinjingUnsupported[model.SimCardBindStatusItem](in.SimKeys, "card binding query")
|
||||
}
|
||||
func (s *sSimHenanXinjingDirect) QuerySimDeviceStatus(ctx context.Context, in *model.SimDeviceStatusInput) (model.SimDeviceStatusOutput, error) {
|
||||
out := make(model.SimDeviceStatusOutput, 0, len(in.SimKeys))
|
||||
for _, k := range in.SimKeys {
|
||||
c, e := s.fetchCard(ctx, k)
|
||||
r := &model.SimBaseRes[model.SimDeviceStatusItem]{SimKey: k}
|
||||
if e != nil {
|
||||
r.Code = consts.ApiStatusError
|
||||
r.Message = e.Error()
|
||||
} else {
|
||||
r.Code = 0
|
||||
// gprs_status: 0 unknown 1 power-on 2 power-off
|
||||
// power_status: 0 unknown 1 online 2 offline
|
||||
// DeviceStatus (compatible semantics): 0 abnormal 1 normal.
|
||||
r.ResData.DeviceStatus = henanXinjingMapDeviceStatus(c.GPRSStatus, c.PowerStatus)
|
||||
switch c.GPRSStatus {
|
||||
case 1:
|
||||
r.ResData.OperatingStatus = "1"
|
||||
case 2:
|
||||
r.ResData.OperatingStatus = "0"
|
||||
}
|
||||
}
|
||||
out = append(out, r)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// henanXinjingMapDeviceStatus merges the platform's gprs_status (power on/off)
|
||||
// and power_status (online/offline) into the gateway's binary device status.
|
||||
// Only a card that is powered on AND online is "normal" (1); anything else,
|
||||
// including unknown, maps to abnormal (0).
|
||||
func henanXinjingMapDeviceStatus(gprsStatus, powerStatus int) string {
|
||||
if gprsStatus == 1 && powerStatus == 1 {
|
||||
return "1"
|
||||
}
|
||||
return "0"
|
||||
}
|
||||
func (s *sSimHenanXinjingDirect) QuerySimRegionLimitStatus(ctx context.Context, in *model.SimRegionLimitStatusInput) (model.SimRegionLimitStatusOutput, error) {
|
||||
return henanXinjingUnsupported[model.SimRegionLimitStatusItem](in.SimKeys, "region limit query")
|
||||
}
|
||||
func (s *sSimHenanXinjingDirect) QuerySimDataDiagnosis(ctx context.Context, in *model.SimDataDiagnosisInput) (model.SimDataDiagnosisOutput, error) {
|
||||
return henanXinjingUnsupported[any](in.SimKeys, "data diagnosis")
|
||||
}
|
||||
func (s *sSimHenanXinjingDirect) QuerySimDataUsageMonthly(ctx context.Context, in *model.SimDataUsageMonthlyInput) (model.SimDataUsageMonthlyOutput, error) {
|
||||
queryMonth := ""
|
||||
if in != nil {
|
||||
queryMonth = strings.TrimSpace(in.ReqData.QueryDate)
|
||||
}
|
||||
out := make(model.SimDataUsageMonthlyOutput, 0, len(in.SimKeys))
|
||||
for _, k := range in.SimKeys {
|
||||
c, e := s.fetchCard(ctx, k)
|
||||
r := &model.SimBaseRes[model.SimDataUsageMonthlyOutputItem]{SimKey: k}
|
||||
if e != nil {
|
||||
r.Code = consts.ApiStatusError
|
||||
r.Message = e.Error()
|
||||
} else {
|
||||
var selected *struct {
|
||||
MonthDate string
|
||||
UsedFlow string
|
||||
}
|
||||
for i := range c.MonthUseds {
|
||||
m := strings.TrimSpace(c.MonthUseds[i].MonthDate)
|
||||
if queryMonth == "" || strings.Contains(m, queryMonth) {
|
||||
selected = &struct {
|
||||
MonthDate string
|
||||
UsedFlow string
|
||||
}{c.MonthUseds[i].MonthDate, c.MonthUseds[i].UsedFlow}
|
||||
break
|
||||
}
|
||||
}
|
||||
if selected == nil {
|
||||
r.Code = consts.ApiStatusError
|
||||
r.Message = "monthly usage data not found"
|
||||
} else {
|
||||
r.Code = 0
|
||||
r.ResData.DeadLine = strings.TrimSpace(selected.MonthDate)
|
||||
// month_useds.used_flow is MB per the platform doc, unlike the
|
||||
// KB used/surplus fields on the same response.
|
||||
if v, perr := strconv.ParseFloat(strings.TrimSpace(selected.UsedFlow), 64); perr == nil {
|
||||
r.ResData.DataAmount = v
|
||||
}
|
||||
}
|
||||
}
|
||||
out = append(out, r)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
func (s *sSimHenanXinjingDirect) QuerySimDataUsageDaily(ctx context.Context, in *model.SimDataUsageDailyInput) (model.SimDataUsageDailyOutput, error) {
|
||||
return henanXinjingUnsupported[model.SimDataUsageDailyOutputItem](in.SimKeys, "historical daily usage query")
|
||||
}
|
||||
func (s *sSimHenanXinjingDirect) QuerySimApnInfo(ctx context.Context, in *model.SimApnInfoInput) (model.SimApnInfoOutput, error) {
|
||||
return henanXinjingUnsupported[model.SimApnInfoItemList](in.SimKeys, "APN query")
|
||||
}
|
||||
func (s *sSimHenanXinjingDirect) ChangeSimApnFunction(ctx context.Context, in *model.SimCardOperateChangeApnInput) (model.SimCardOperateChangeApnOutput, error) {
|
||||
return henanXinjingUnsupported[model.SimCardOperateChangeApnItem](in.SimKeys, "APN change")
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package upstreamimpl
|
||||
|
||||
import (
|
||||
"context"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/model"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/service"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/service/mingqian"
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
)
|
||||
|
||||
type simMingqianTemplate struct{}
|
||||
|
||||
func init() { service.UpstreamAPI().RegisterTemp(mingqian.TemplateKey, &simMingqianTemplate{}) }
|
||||
|
||||
func (*simMingqianTemplate) NewTemp(ctx context.Context, cfg *model.SimChannelConfigItemCache) model.SimCardChannelI {
|
||||
adapter, err := mingqian.New(cfg)
|
||||
if err != nil {
|
||||
g.Log().Errorf(ctx, "create mingqian channel failed: %v", err)
|
||||
return nil
|
||||
}
|
||||
return adapter
|
||||
}
|
||||
@@ -0,0 +1,615 @@
|
||||
package upstreamimpl
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/model"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/service"
|
||||
sharedtoken "git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/token"
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
)
|
||||
|
||||
const (
|
||||
sharedOneLinkTengyuTemplateKey = "sim_onelink_tengyu_v1"
|
||||
sharedOneLinkXuzhouTemplateKey = "sim_onelink_xuzhou_mobile_v1"
|
||||
sharedOneLinkLelianTemplateKey = "sim_onelink_lelian_v1"
|
||||
sharedOneLinkQianxunTemplateKey = "sim_onelink_qianxun_v1"
|
||||
sharedOneLinkHenanXinjingTemplateKey = "sim_onelink_henan_xinjing_v1"
|
||||
sharedOneLinkDaiheTemplateKey = "sim_onelink_daihe_v1"
|
||||
sharedOneLinkMobilePublicTemplateKey = "sim_onelink_daliuliang_v1"
|
||||
sharedOneLinkMobilePublicPreviousKey = "sim__daliuliang_v1"
|
||||
sharedOneLinkMobilePublicLegacyKey = "sim_onelink_mobile_public_v1"
|
||||
sharedOneLinkApikaTemplateKey = "sim_onelink_kedang_v1"
|
||||
sharedOneLinkYunzeTemplateKey = "sim_onelink_yunze_v1"
|
||||
sharedOneLinkLanshuTemplateKey = "sim_onelink_lanshu_v1"
|
||||
sharedOneLinkBeixinTemplateKey = "sim_onelink_beixin_v1"
|
||||
sharedOneLinkAPIBaseURL = "https://api.iot.10086.cn/v5/"
|
||||
sharedOneLinkTransIDPrefix = "ONELINK"
|
||||
sharedOneLinkXuzhouTokenTTL = 40 * time.Minute
|
||||
)
|
||||
|
||||
// SharedTokenConfig is the per-channel configuration passed to a provider
|
||||
// factory. It intentionally contains no provider implementation details.
|
||||
type SharedTokenConfig struct {
|
||||
Provider string
|
||||
TokenBaseURL string
|
||||
LoginPath string
|
||||
TokenPath string
|
||||
CarrierType int
|
||||
PwdKey string
|
||||
ChannelNo string
|
||||
AppKey string
|
||||
AppID string
|
||||
AppSecret string
|
||||
SubAccount string
|
||||
Secret string
|
||||
OperatorID string
|
||||
UserID string
|
||||
TokenType string
|
||||
UserName string
|
||||
Password string
|
||||
PasswayUID string
|
||||
Sign string
|
||||
ChannelID string
|
||||
ChannelPName string
|
||||
CardNo string
|
||||
CMCCAppID string
|
||||
Timeout time.Duration
|
||||
}
|
||||
|
||||
// TokenProviderFactory is the adapter point for the token-core/provider
|
||||
// integration. Provider branches can register factories without changing this
|
||||
// channel implementation.
|
||||
type TokenProviderFactory func(context.Context, *model.SimChannelConfigItemCache, SharedTokenConfig) (ydTokenProvider, error)
|
||||
|
||||
var sharedTokenFactories = struct {
|
||||
sync.RWMutex
|
||||
m map[string]TokenProviderFactory
|
||||
}{m: make(map[string]TokenProviderFactory)}
|
||||
|
||||
func RegisterTokenProviderFactory(provider string, factory TokenProviderFactory) {
|
||||
provider = strings.ToLower(strings.TrimSpace(provider))
|
||||
if provider == "" || factory == nil {
|
||||
return
|
||||
}
|
||||
sharedTokenFactories.Lock()
|
||||
sharedTokenFactories.m[provider] = factory
|
||||
sharedTokenFactories.Unlock()
|
||||
}
|
||||
|
||||
func tokenProviderFactory(provider string) (TokenProviderFactory, bool) {
|
||||
sharedTokenFactories.RLock()
|
||||
f, ok := sharedTokenFactories.m[strings.ToLower(strings.TrimSpace(provider))]
|
||||
sharedTokenFactories.RUnlock()
|
||||
return f, ok
|
||||
}
|
||||
|
||||
type oneLinkAPIConfig struct {
|
||||
BaseURL string
|
||||
TokenPath string
|
||||
Timeout time.Duration
|
||||
transIDPrefix string
|
||||
}
|
||||
|
||||
type sharedOneLinkChannelConfig struct {
|
||||
PwdKey string `json:"pwdKey"`
|
||||
ChannelNo string `json:"channelNo"`
|
||||
AppID string `json:"appId"`
|
||||
SubAccount string `json:"subAccount"`
|
||||
Secret string `json:"secret"`
|
||||
OperatorID string `json:"operatorId"`
|
||||
UserID string `json:"userId"`
|
||||
TokenType string `json:"tokenType"`
|
||||
UserName string `json:"userName"`
|
||||
Password string `json:"password"`
|
||||
PasswayUID string `json:"passwayUid"`
|
||||
ChannelID string `json:"channelId"`
|
||||
ChannelPName string `json:"channelPname"`
|
||||
CardNo string `json:"cardNo"`
|
||||
CMCCAppID string `json:"cmccAppId"`
|
||||
// Sign is the fixed signature issued by the LanShu open route. It is
|
||||
// passed through verbatim and is never derived locally.
|
||||
Sign string `json:"sign"`
|
||||
// TokenBaseURL lets a channel override the template's token.baseUrl, so
|
||||
// accounts living on a different upstream host can share one template.
|
||||
TokenBaseURL string `json:"tokenBaseUrl"`
|
||||
LegacyCredentials *struct {
|
||||
PwdKey string `json:"pwdKey"`
|
||||
ChannelNo string `json:"channelNo"`
|
||||
AppID string `json:"appId"`
|
||||
UserName string `json:"userName"`
|
||||
Password string `json:"password"`
|
||||
PasswayUID string `json:"passwayUid"`
|
||||
} `json:"credentials"`
|
||||
}
|
||||
|
||||
type sharedOneLinkTemplateConfig struct {
|
||||
API struct {
|
||||
BaseURL string `json:"baseUrl"`
|
||||
} `json:"api"`
|
||||
Token struct {
|
||||
Provider string `json:"provider"`
|
||||
BaseURL string `json:"baseUrl"`
|
||||
Path string `json:"path"`
|
||||
Params struct {
|
||||
LoginPath string `json:"loginPath"`
|
||||
CarrierType *int `json:"carrierType"`
|
||||
} `json:"params"`
|
||||
LegacyLoginPath string `json:"loginPath"`
|
||||
LegacyCarrierType *int `json:"carrierType"`
|
||||
} `json:"token"`
|
||||
}
|
||||
|
||||
type sharedTokenManagerProvider struct {
|
||||
manager sharedtoken.TokenManager
|
||||
chanKey string
|
||||
includeDeviceToken bool
|
||||
}
|
||||
|
||||
func (p *sharedTokenManagerProvider) GetToken(ctx context.Context) (string, error) {
|
||||
return p.manager.GetToken(ctx, p.chanKey)
|
||||
}
|
||||
|
||||
func (p *sharedTokenManagerProvider) GetDeviceToken(ctx context.Context) (string, error) {
|
||||
if !p.includeDeviceToken {
|
||||
return "", nil
|
||||
}
|
||||
token, err := p.manager.Get(ctx, p.chanKey)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return token.DeviceValue, nil
|
||||
}
|
||||
|
||||
func (p *sharedTokenManagerProvider) RecoverToken(ctx context.Context, rejected string) (string, error) {
|
||||
p.manager.InvalidateIfCurrent(p.chanKey, rejected)
|
||||
return p.manager.GetToken(ctx, p.chanKey)
|
||||
}
|
||||
|
||||
// GetSharedToken returns the shared token together with its expiry, fetching
|
||||
// from the provider on demand when the cached value is missing or expired.
|
||||
// Expiry is zero when the provider does not report one.
|
||||
func (p *sharedTokenManagerProvider) GetSharedToken(ctx context.Context) (sharedtoken.OneLinkToken, error) {
|
||||
return p.manager.Get(ctx, p.chanKey)
|
||||
}
|
||||
|
||||
type tengyuFetcher struct{ provider *TengyuTokenProvider }
|
||||
|
||||
func (f tengyuFetcher) FetchToken(ctx context.Context, _ string) (sharedtoken.OneLinkToken, error) {
|
||||
t, err := f.provider.Fetch(ctx)
|
||||
if err != nil {
|
||||
return sharedtoken.OneLinkToken{}, err
|
||||
}
|
||||
return sharedtoken.OneLinkToken{Value: t.Value, CreatedAt: t.CreatedAt, ExpiresAt: t.ExpiresAt}, nil
|
||||
}
|
||||
|
||||
type xuzhouMobileFetcher struct{ provider *XuzhouMobileTokenProvider }
|
||||
|
||||
func (f xuzhouMobileFetcher) FetchToken(ctx context.Context, _ string) (sharedtoken.OneLinkToken, error) {
|
||||
t, err := f.provider.Fetch(ctx)
|
||||
if err != nil {
|
||||
return sharedtoken.OneLinkToken{}, err
|
||||
}
|
||||
return sharedtoken.OneLinkToken{Value: t.Value, ExpiresAt: t.ExpiresAt}, nil
|
||||
}
|
||||
|
||||
type henanLelianFetcher struct{ provider *HenanLelianTokenProvider }
|
||||
|
||||
func (f henanLelianFetcher) FetchToken(ctx context.Context, _ string) (sharedtoken.OneLinkToken, error) {
|
||||
t, err := f.provider.Fetch(ctx)
|
||||
if err != nil {
|
||||
return sharedtoken.OneLinkToken{}, err
|
||||
}
|
||||
return sharedtoken.OneLinkToken{Value: t.Value, CreatedAt: t.CreatedAt}, nil
|
||||
}
|
||||
|
||||
func parseSharedOneLinkConfig(ctx context.Context, channelConfig *model.SimChannelConfigItemCache) (SharedTokenConfig, *oneLinkAPIConfig, error) {
|
||||
if channelConfig == nil || channelConfig.Channel == nil || channelConfig.Template == nil {
|
||||
return SharedTokenConfig{}, nil, fmt.Errorf("channel and template config are required")
|
||||
}
|
||||
var template sharedOneLinkTemplateConfig
|
||||
if strings.TrimSpace(channelConfig.Template.TempConfig) == "" {
|
||||
return SharedTokenConfig{}, nil, fmt.Errorf("temp_config is required")
|
||||
}
|
||||
if err := json.Unmarshal([]byte(channelConfig.Template.TempConfig), &template); err != nil {
|
||||
return SharedTokenConfig{}, nil, fmt.Errorf("invalid temp_config: %w", err)
|
||||
}
|
||||
var channel sharedOneLinkChannelConfig
|
||||
if strings.TrimSpace(channelConfig.Channel.ChanConfig) != "" {
|
||||
if err := json.Unmarshal([]byte(channelConfig.Channel.ChanConfig), &channel); err != nil {
|
||||
return SharedTokenConfig{}, nil, fmt.Errorf("invalid chan_config: %w", err)
|
||||
}
|
||||
}
|
||||
legacy := channel.LegacyCredentials
|
||||
provider := strings.ToLower(strings.TrimSpace(template.Token.Provider))
|
||||
if provider == "" {
|
||||
provider = "tengyu"
|
||||
}
|
||||
tokenBaseURL := strings.TrimSpace(template.Token.BaseURL)
|
||||
// A channel may point token fetches at a different upstream host than the
|
||||
// template default (e.g. the 361 account lives on gxhy.ge-xing.cn while
|
||||
// the template points at hywx.xjict.com).
|
||||
if override := strings.TrimSpace(channel.TokenBaseURL); override != "" {
|
||||
tokenBaseURL = override
|
||||
}
|
||||
if tokenBaseURL == "" {
|
||||
return SharedTokenConfig{}, nil, fmt.Errorf("token.baseUrl is required")
|
||||
}
|
||||
if strings.TrimSpace(template.Token.Path) == "" {
|
||||
return SharedTokenConfig{}, nil, fmt.Errorf("token.path is required")
|
||||
}
|
||||
pwdKey := strings.TrimSpace(channel.PwdKey)
|
||||
channelNo := strings.TrimSpace(channel.ChannelNo)
|
||||
appID := strings.TrimSpace(channel.AppID)
|
||||
appSecret := strings.TrimSpace(channelConfig.Channel.AppSecret)
|
||||
subAccount := strings.TrimSpace(channel.SubAccount)
|
||||
secret := channel.Secret
|
||||
operatorID := strings.TrimSpace(channel.OperatorID)
|
||||
userID := strings.TrimSpace(channel.UserID)
|
||||
tokenType := strings.TrimSpace(channel.TokenType)
|
||||
channelID := strings.TrimSpace(channel.ChannelID)
|
||||
channelPName := strings.TrimSpace(channel.ChannelPName)
|
||||
cardNo := strings.TrimSpace(channel.CardNo)
|
||||
cmccAppID := strings.TrimSpace(channel.CMCCAppID)
|
||||
sign := strings.TrimSpace(channel.Sign)
|
||||
userName := strings.TrimSpace(channel.UserName)
|
||||
password := channel.Password
|
||||
passwayUID := strings.TrimSpace(channel.PasswayUID)
|
||||
if legacy != nil {
|
||||
if pwdKey == "" {
|
||||
pwdKey = strings.TrimSpace(legacy.PwdKey)
|
||||
}
|
||||
if channelNo == "" {
|
||||
channelNo = strings.TrimSpace(legacy.ChannelNo)
|
||||
}
|
||||
if appID == "" {
|
||||
appID = strings.TrimSpace(legacy.AppID)
|
||||
}
|
||||
if userName == "" {
|
||||
userName = strings.TrimSpace(legacy.UserName)
|
||||
}
|
||||
if password == "" {
|
||||
password = legacy.Password
|
||||
}
|
||||
if passwayUID == "" {
|
||||
passwayUID = strings.TrimSpace(legacy.PasswayUID)
|
||||
}
|
||||
g.Log().Warningf(ctx, "shared token channel %s uses deprecated chan_config.credentials; migrate fields to the root", channelConfig.Channel.ChanKey)
|
||||
}
|
||||
carrierType := 1
|
||||
if template.Token.Params.CarrierType != nil {
|
||||
carrierType = *template.Token.Params.CarrierType
|
||||
} else if template.Token.LegacyCarrierType != nil {
|
||||
carrierType = *template.Token.LegacyCarrierType
|
||||
g.Log().Warningf(ctx, "shared token template %s uses deprecated token.carrierType; move it to token.params.carrierType", channelConfig.Template.TempKey)
|
||||
}
|
||||
loginPath := strings.TrimSpace(template.Token.Params.LoginPath)
|
||||
if loginPath == "" && strings.TrimSpace(template.Token.LegacyLoginPath) != "" {
|
||||
loginPath = strings.TrimSpace(template.Token.LegacyLoginPath)
|
||||
g.Log().Warningf(ctx, "shared token template %s uses deprecated token.loginPath; move it to token.params.loginPath", channelConfig.Template.TempKey)
|
||||
}
|
||||
timeout := resolveUpstreamHTTPTimeout(ctx, channelConfig.Template.TempConfig)
|
||||
config := SharedTokenConfig{
|
||||
Provider: provider, TokenBaseURL: tokenBaseURL, LoginPath: loginPath, TokenPath: template.Token.Path,
|
||||
CarrierType: carrierType, PwdKey: pwdKey, ChannelNo: channelNo, AppKey: strings.TrimSpace(channelConfig.Channel.AppKey), AppID: appID,
|
||||
AppSecret: appSecret, SubAccount: subAccount, Secret: secret, OperatorID: operatorID, UserID: userID, TokenType: tokenType,
|
||||
UserName: userName, Password: password, PasswayUID: passwayUID,
|
||||
ChannelID: channelID, ChannelPName: channelPName, CardNo: cardNo, CMCCAppID: cmccAppID,
|
||||
Sign: sign, Timeout: timeout,
|
||||
}
|
||||
if provider == "daihe" && config.AppID == "" {
|
||||
config.AppID = strings.TrimSpace(channelConfig.Channel.AppKey)
|
||||
}
|
||||
if provider == "daihe" && (config.AppID == "" || config.AppSecret == "" || config.CMCCAppID == "") {
|
||||
return SharedTokenConfig{}, nil, fmt.Errorf("chan_config.appId, app_secret and chan_config.cmccAppId are required for daihe")
|
||||
}
|
||||
if provider == "zibo" {
|
||||
config.AppID = strings.TrimSpace(channelConfig.Channel.AppKey)
|
||||
config.AppSecret = strings.TrimSpace(channelConfig.Channel.AppSecret)
|
||||
if config.AppID == "" || config.AppSecret == "" {
|
||||
return SharedTokenConfig{}, nil, fmt.Errorf("app_key and app_secret are required for zibo")
|
||||
}
|
||||
}
|
||||
if provider == "yunze" {
|
||||
config.AppID = strings.TrimSpace(channelConfig.Channel.AppKey)
|
||||
if config.AppID == "" {
|
||||
return SharedTokenConfig{}, nil, fmt.Errorf("app_key is required for yunze")
|
||||
}
|
||||
}
|
||||
if provider == "tengyu" && (config.PwdKey == "" || config.ChannelNo == "") {
|
||||
return SharedTokenConfig{}, nil, fmt.Errorf("chan_config.pwdKey and chan_config.channelNo are required")
|
||||
}
|
||||
if provider == "xuzhou_mobile" && config.AppID == "" {
|
||||
return SharedTokenConfig{}, nil, fmt.Errorf("chan_config.appId is required for xuzhou_mobile")
|
||||
}
|
||||
if provider == "lanshang" && (config.SubAccount == "" || config.Secret == "") {
|
||||
return SharedTokenConfig{}, nil, fmt.Errorf("chan_config.subAccount and chan_config.secret are required for lanshang")
|
||||
}
|
||||
if provider == "qianxun" && (config.OperatorID == "" || config.AppID == "" || config.TokenType == "" || config.AppSecret == "") {
|
||||
return SharedTokenConfig{}, nil, fmt.Errorf("chan_config.operatorId, chan_config.appId, chan_config.tokenType and app_secret are required for qianxun")
|
||||
}
|
||||
if provider == "lanshu" && (config.AppID == "" || config.Password == "" || config.Sign == "" || config.TokenType == "") {
|
||||
return SharedTokenConfig{}, nil, fmt.Errorf("chan_config.appId, chan_config.password, chan_config.sign and chan_config.tokenType are required for lanshu")
|
||||
}
|
||||
if provider == "beixin" && (config.PwdKey == "" || config.ChannelNo == "") {
|
||||
return SharedTokenConfig{}, nil, fmt.Errorf("chan_config.pwdKey and chan_config.channelNo are required for beixin")
|
||||
}
|
||||
if provider == "mobile_public" && (config.UserID == "" || config.AppSecret == "") {
|
||||
return SharedTokenConfig{}, nil, fmt.Errorf("chan_config.userId and app_secret are required for mobile_public")
|
||||
}
|
||||
if provider == "apika" && (config.AppKey == "" || config.AppSecret == "" || config.AppID == "") {
|
||||
return SharedTokenConfig{}, nil, fmt.Errorf("app_key, app_secret and chan_config.appId are required for apika")
|
||||
}
|
||||
if provider == "henan_lelian" {
|
||||
if strings.TrimSpace(config.LoginPath) == "" {
|
||||
return SharedTokenConfig{}, nil, fmt.Errorf("token.params.loginPath is required for henan_lelian")
|
||||
}
|
||||
if config.UserName == "" || strings.TrimSpace(config.Password) == "" || config.PasswayUID == "" {
|
||||
return SharedTokenConfig{}, nil, fmt.Errorf("chan_config.userName, chan_config.password and chan_config.passwayUid are required for henan_lelian")
|
||||
}
|
||||
}
|
||||
if provider == "henan_xinjing" {
|
||||
if config.AppID == "" || config.AppSecret == "" {
|
||||
return SharedTokenConfig{}, nil, fmt.Errorf("chan_config.appId (userId) and app_secret (apikey) are required for henan_xinjing")
|
||||
}
|
||||
if config.ChannelID == "" || config.ChannelPName == "" {
|
||||
return SharedTokenConfig{}, nil, fmt.Errorf("chan_config.channelId and chan_config.channelPname are required for henan_xinjing")
|
||||
}
|
||||
}
|
||||
transIDPrefix := sharedOneLinkTransIDPrefix
|
||||
if provider == "daihe" {
|
||||
transIDPrefix = config.CMCCAppID
|
||||
}
|
||||
if provider == "zibo" {
|
||||
transIDPrefix = config.AppID
|
||||
}
|
||||
businessBaseURL := strings.TrimSpace(template.API.BaseURL)
|
||||
if businessBaseURL == "" {
|
||||
businessBaseURL = sharedOneLinkAPIBaseURL
|
||||
}
|
||||
return config, &oneLinkAPIConfig{
|
||||
BaseURL: businessBaseURL, transIDPrefix: transIDPrefix, Timeout: timeout,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func parseOneLinkTemplateAPI(ctx context.Context, channelConfig *model.SimChannelConfigItemCache) *oneLinkAPIConfig {
|
||||
if channelConfig == nil || channelConfig.Template == nil || strings.TrimSpace(channelConfig.Template.TempConfig) == "" {
|
||||
return nil
|
||||
}
|
||||
var raw sharedOneLinkTemplateConfig
|
||||
if err := json.Unmarshal([]byte(channelConfig.Template.TempConfig), &raw); err != nil {
|
||||
g.Log().Warningf(ctx, "parse OneLink template config failed: %v", err)
|
||||
return nil
|
||||
}
|
||||
baseURL := strings.TrimSpace(raw.API.BaseURL)
|
||||
if baseURL == "" {
|
||||
baseURL = gUrlBase
|
||||
}
|
||||
timeout := resolveUpstreamHTTPTimeout(ctx, channelConfig.Template.TempConfig)
|
||||
return &oneLinkAPIConfig{
|
||||
BaseURL: baseURL, TokenPath: raw.Token.Path, Timeout: timeout,
|
||||
}
|
||||
}
|
||||
|
||||
type sSimOneLinkShared struct{}
|
||||
|
||||
func NewSimOneLinkShared() *sSimOneLinkShared { return &sSimOneLinkShared{} }
|
||||
|
||||
func init() {
|
||||
shared := NewSimOneLinkShared()
|
||||
service.UpstreamAPI().RegisterTemp(sharedOneLinkTengyuTemplateKey, shared)
|
||||
service.UpstreamAPI().RegisterTemp(sharedOneLinkXuzhouTemplateKey, shared)
|
||||
service.UpstreamAPI().RegisterTemp(sharedOneLinkLelianTemplateKey, shared)
|
||||
service.UpstreamAPI().RegisterTemp(sharedOneLinkQianxunTemplateKey, shared)
|
||||
service.UpstreamAPI().RegisterTemp(sharedOneLinkHenanXinjingTemplateKey, shared)
|
||||
service.UpstreamAPI().RegisterTemp(sharedOneLinkDaiheTemplateKey, shared)
|
||||
service.UpstreamAPI().RegisterTemp(sharedOneLinkMobilePublicTemplateKey, shared)
|
||||
service.UpstreamAPI().RegisterTemp(sharedOneLinkMobilePublicPreviousKey, shared)
|
||||
service.UpstreamAPI().RegisterTemp(sharedOneLinkMobilePublicLegacyKey, shared)
|
||||
service.UpstreamAPI().RegisterTemp(sharedOneLinkApikaTemplateKey, shared)
|
||||
service.UpstreamAPI().RegisterTemp(sharedOneLinkYunzeTemplateKey, shared)
|
||||
service.UpstreamAPI().RegisterTemp(sharedOneLinkLanshuTemplateKey, shared)
|
||||
service.UpstreamAPI().RegisterTemp(sharedOneLinkBeixinTemplateKey, shared)
|
||||
}
|
||||
|
||||
func (s *sSimOneLinkShared) NewTemp(ctx context.Context, channelConfig *model.SimChannelConfigItemCache) model.SimCardChannelI {
|
||||
providerConfig, apiConfig, err := parseSharedOneLinkConfig(ctx, channelConfig)
|
||||
if err != nil {
|
||||
g.Log().Errorf(ctx, "create shared_token_onelink failed: %v", err)
|
||||
return nil
|
||||
}
|
||||
factory, ok := tokenProviderFactory(providerConfig.Provider)
|
||||
if !ok || factory == nil {
|
||||
g.Log().Errorf(ctx, "create shared_token_onelink failed: unsupported provider %s", providerConfig.Provider)
|
||||
return nil
|
||||
}
|
||||
provider, err := factory(ctx, channelConfig, providerConfig)
|
||||
if err != nil || provider == nil {
|
||||
if err == nil {
|
||||
err = fmt.Errorf("factory returned nil provider")
|
||||
}
|
||||
g.Log().Errorf(ctx, "create shared_token_onelink failed: %v", err)
|
||||
return nil
|
||||
}
|
||||
return &sSimYD{channelConfig: channelConfig, tokenProvider: provider, apiConfig: apiConfig}
|
||||
}
|
||||
|
||||
func init() {
|
||||
RegisterTokenProviderFactory("mobile_public", func(ctx context.Context, channelConfig *model.SimChannelConfigItemCache, cfg SharedTokenConfig) (ydTokenProvider, error) {
|
||||
if channelConfig == nil || channelConfig.Channel == nil {
|
||||
return nil, fmt.Errorf("channel config is required")
|
||||
}
|
||||
fetcher := mobilePublicFetcher{provider: NewMobilePublicTokenProvider(MobilePublicTokenConfig{
|
||||
BaseURL: cfg.TokenBaseURL,
|
||||
Path: cfg.TokenPath,
|
||||
UserID: cfg.UserID,
|
||||
Secret: cfg.AppSecret,
|
||||
Timeout: cfg.Timeout,
|
||||
})}
|
||||
manager := sharedtoken.NewTokenManager(fetcher, sharedtoken.WithRefreshAhead(30*time.Second))
|
||||
manager.SeedFromConfig(channelConfig)
|
||||
return &sharedTokenManagerProvider{manager: manager, chanKey: channelConfig.Channel.ChanKey}, nil
|
||||
})
|
||||
RegisterTokenProviderFactory("apika", func(ctx context.Context, channelConfig *model.SimChannelConfigItemCache, cfg SharedTokenConfig) (ydTokenProvider, error) {
|
||||
if channelConfig == nil || channelConfig.Channel == nil {
|
||||
return nil, fmt.Errorf("channel config is required")
|
||||
}
|
||||
fetcher := apikaFetcher{provider: NewApikaTokenProvider(ApikaTokenConfig{
|
||||
BaseURL: cfg.TokenBaseURL, Path: cfg.TokenPath, AppKey: cfg.AppKey,
|
||||
AppSecret: cfg.AppSecret, AppID: cfg.AppID, Timeout: cfg.Timeout,
|
||||
})}
|
||||
manager := sharedtoken.NewTokenManager(fetcher, sharedtoken.WithRefreshAhead(30*time.Second))
|
||||
manager.SeedFromConfig(channelConfig)
|
||||
return &sharedTokenManagerProvider{manager: manager, chanKey: channelConfig.Channel.ChanKey}, nil
|
||||
})
|
||||
RegisterTokenProviderFactory("qianxun", func(ctx context.Context, channelConfig *model.SimChannelConfigItemCache, cfg SharedTokenConfig) (ydTokenProvider, error) {
|
||||
if channelConfig == nil || channelConfig.Channel == nil {
|
||||
return nil, fmt.Errorf("channel config is required")
|
||||
}
|
||||
fetcher := qianxunFetcher{provider: NewQianxunTokenProvider(QianxunTokenConfig{BaseURL: cfg.TokenBaseURL, Path: cfg.TokenPath, OperatorID: cfg.OperatorID, AppID: cfg.AppID, TokenType: cfg.TokenType, Key: cfg.AppSecret, Timeout: cfg.Timeout})}
|
||||
manager := sharedtoken.NewTokenManager(fetcher, sharedtoken.WithUntilInvalidated())
|
||||
manager.SeedFromConfig(channelConfig)
|
||||
return &sharedTokenManagerProvider{manager: manager, chanKey: channelConfig.Channel.ChanKey}, nil
|
||||
})
|
||||
RegisterTokenProviderFactory("henan_xinjing", func(ctx context.Context, channelConfig *model.SimChannelConfigItemCache, cfg SharedTokenConfig) (ydTokenProvider, error) {
|
||||
if channelConfig == nil || channelConfig.Channel == nil {
|
||||
return nil, fmt.Errorf("channel config is required")
|
||||
}
|
||||
fetcher := henanXinjingFetcher{provider: NewHenanXinjingTokenProvider(HenanXinjingTokenConfig{
|
||||
BaseURL: cfg.TokenBaseURL,
|
||||
Path: cfg.TokenPath,
|
||||
UserID: cfg.AppID,
|
||||
APIKey: cfg.AppSecret,
|
||||
ChannelID: cfg.ChannelID,
|
||||
ChannelPName: cfg.ChannelPName,
|
||||
CardNo: cfg.CardNo,
|
||||
Timeout: cfg.Timeout,
|
||||
})}
|
||||
manager := sharedtoken.NewTokenManager(fetcher)
|
||||
manager.SeedFromConfig(channelConfig)
|
||||
return &sharedTokenManagerProvider{manager: manager, chanKey: channelConfig.Channel.ChanKey}, nil
|
||||
})
|
||||
RegisterTokenProviderFactory("tengyu", func(ctx context.Context, channelConfig *model.SimChannelConfigItemCache, cfg SharedTokenConfig) (ydTokenProvider, error) {
|
||||
fetcher := tengyuFetcher{provider: NewTengyuTokenProvider(TengyuTokenConfig{BaseURL: cfg.TokenBaseURL, Path: cfg.TokenPath, PwdKey: cfg.PwdKey, ChannelNo: cfg.ChannelNo, Timeout: cfg.Timeout})}
|
||||
// Tengyu does not document a refresh-ahead mechanism. Reuse the cached
|
||||
// token until its local one-hour validity expires, then fetch on demand.
|
||||
manager := sharedtoken.NewTokenManager(fetcher)
|
||||
manager.SeedFromConfig(channelConfig)
|
||||
return &sharedTokenManagerProvider{manager: manager, chanKey: channelConfig.Channel.ChanKey}, nil
|
||||
})
|
||||
RegisterTokenProviderFactory("xuzhou_mobile", func(ctx context.Context, channelConfig *model.SimChannelConfigItemCache, cfg SharedTokenConfig) (ydTokenProvider, error) {
|
||||
if channelConfig == nil || channelConfig.Channel == nil {
|
||||
return nil, fmt.Errorf("channel config is required")
|
||||
}
|
||||
fetcher := xuzhouMobileFetcher{provider: NewXuzhouMobileTokenProvider(XuzhouMobileTokenConfig{
|
||||
BaseURL: cfg.TokenBaseURL, Path: cfg.TokenPath, AppID: cfg.AppID,
|
||||
CarrierType: cfg.CarrierType, TTL: sharedOneLinkXuzhouTokenTTL, Timeout: cfg.Timeout,
|
||||
})}
|
||||
manager := sharedtoken.NewTokenManager(fetcher)
|
||||
manager.SeedFromConfig(channelConfig)
|
||||
return &sharedTokenManagerProvider{manager: manager, chanKey: channelConfig.Channel.ChanKey}, nil
|
||||
})
|
||||
RegisterTokenProviderFactory("daihe", func(ctx context.Context, channelConfig *model.SimChannelConfigItemCache, cfg SharedTokenConfig) (ydTokenProvider, error) {
|
||||
if channelConfig == nil || channelConfig.Channel == nil {
|
||||
return nil, fmt.Errorf("channel config is required")
|
||||
}
|
||||
fetcher := daiheTokenFetcher{provider: NewDaiheTokenProvider(DaiheTokenConfig{
|
||||
BaseURL: cfg.TokenBaseURL, Path: cfg.TokenPath, AppID: cfg.AppID, AppSecret: cfg.AppSecret,
|
||||
CMCCAppID: cfg.CMCCAppID, Timeout: cfg.Timeout,
|
||||
})}
|
||||
manager := sharedtoken.NewTokenManager(fetcher)
|
||||
manager.SeedFromConfig(channelConfig)
|
||||
return &sharedTokenManagerProvider{manager: manager, chanKey: channelConfig.Channel.ChanKey, includeDeviceToken: true}, nil
|
||||
})
|
||||
RegisterTokenProviderFactory("zibo", func(ctx context.Context, channelConfig *model.SimChannelConfigItemCache, cfg SharedTokenConfig) (ydTokenProvider, error) {
|
||||
if channelConfig == nil || channelConfig.Channel == nil {
|
||||
return nil, fmt.Errorf("channel config is required")
|
||||
}
|
||||
fetcher := ziboFetcher{provider: NewZiboTokenProvider(ZiboTokenConfig{
|
||||
BaseURL: cfg.TokenBaseURL,
|
||||
Path: cfg.TokenPath,
|
||||
AppID: cfg.AppID,
|
||||
Password: cfg.AppSecret,
|
||||
Timeout: cfg.Timeout,
|
||||
})}
|
||||
manager := sharedtoken.NewTokenManager(fetcher)
|
||||
manager.SeedFromConfig(channelConfig)
|
||||
return &sharedTokenManagerProvider{manager: manager, chanKey: channelConfig.Channel.ChanKey}, nil
|
||||
})
|
||||
RegisterTokenProviderFactory("yunze", func(ctx context.Context, channelConfig *model.SimChannelConfigItemCache, cfg SharedTokenConfig) (ydTokenProvider, error) {
|
||||
if channelConfig == nil || channelConfig.Channel == nil {
|
||||
return nil, fmt.Errorf("channel config is required")
|
||||
}
|
||||
fetcher := yunzeFetcher{provider: NewYunzeTokenProvider(YunzeTokenConfig{
|
||||
BaseURL: cfg.TokenBaseURL,
|
||||
Path: cfg.TokenPath,
|
||||
AppID: cfg.AppID,
|
||||
Timeout: cfg.Timeout,
|
||||
})}
|
||||
manager := sharedtoken.NewTokenManager(fetcher, sharedtoken.WithRefreshAhead(30*time.Second))
|
||||
manager.SeedFromConfig(channelConfig)
|
||||
return &sharedTokenManagerProvider{manager: manager, chanKey: channelConfig.Channel.ChanKey}, nil
|
||||
})
|
||||
RegisterTokenProviderFactory("lanshu", func(ctx context.Context, channelConfig *model.SimChannelConfigItemCache, cfg SharedTokenConfig) (ydTokenProvider, error) {
|
||||
if channelConfig == nil || channelConfig.Channel == nil {
|
||||
return nil, fmt.Errorf("channel config is required")
|
||||
}
|
||||
fetcher := lanshuFetcher{provider: NewLanShuTokenProvider(LanShuTokenConfig{
|
||||
BaseURL: cfg.TokenBaseURL, Path: cfg.TokenPath, AppID: cfg.AppID,
|
||||
Password: cfg.Password, Sign: cfg.Sign, TokenType: cfg.TokenType, Timeout: cfg.Timeout,
|
||||
})}
|
||||
// The upstream reports a real expireTime (~19-26 minutes) and issues a
|
||||
// new token per request, so refresh proactively inside the window.
|
||||
manager := sharedtoken.NewTokenManager(fetcher, sharedtoken.WithRefreshAhead(60*time.Second))
|
||||
manager.SeedFromConfig(channelConfig)
|
||||
return &sharedTokenManagerProvider{manager: manager, chanKey: channelConfig.Channel.ChanKey}, nil
|
||||
})
|
||||
RegisterTokenProviderFactory("beixin", func(ctx context.Context, channelConfig *model.SimChannelConfigItemCache, cfg SharedTokenConfig) (ydTokenProvider, error) {
|
||||
if channelConfig == nil || channelConfig.Channel == nil {
|
||||
return nil, fmt.Errorf("channel config is required")
|
||||
}
|
||||
fetcher := beixinFetcher{provider: NewBeixinTokenProvider(BeixinTokenConfig{
|
||||
BaseURL: cfg.TokenBaseURL, Path: cfg.TokenPath, PwdKey: cfg.PwdKey,
|
||||
ChannelNo: cfg.ChannelNo, Timeout: cfg.Timeout,
|
||||
})}
|
||||
// The upstream returns no expireTime, so the provider holds a local
|
||||
// one-hour window (mirroring tengyu). Refresh proactively inside it
|
||||
// rather than waiting for a 12021 rejection.
|
||||
manager := sharedtoken.NewTokenManager(fetcher, sharedtoken.WithRefreshAhead(30*time.Second))
|
||||
manager.SeedFromConfig(channelConfig)
|
||||
return &sharedTokenManagerProvider{manager: manager, chanKey: channelConfig.Channel.ChanKey}, nil
|
||||
})
|
||||
RegisterTokenProviderFactory("lanshang", func(ctx context.Context, channelConfig *model.SimChannelConfigItemCache, cfg SharedTokenConfig) (ydTokenProvider, error) {
|
||||
if channelConfig == nil || channelConfig.Channel == nil {
|
||||
return nil, fmt.Errorf("channel config is required")
|
||||
}
|
||||
if cfg.SubAccount == "" || cfg.Secret == "" {
|
||||
return nil, fmt.Errorf("lanshang subAccount and secret are required")
|
||||
}
|
||||
fetcher := lanshangFetcher{provider: NewLanShangTokenProvider(LanShangTokenConfig{
|
||||
BaseURL: cfg.TokenBaseURL, Path: cfg.TokenPath,
|
||||
SubAccount: cfg.SubAccount, Secret: cfg.Secret, Timeout: cfg.Timeout,
|
||||
})}
|
||||
manager := sharedtoken.NewTokenManager(fetcher)
|
||||
manager.SeedFromConfig(channelConfig)
|
||||
return &sharedTokenManagerProvider{manager: manager, chanKey: channelConfig.Channel.ChanKey}, nil
|
||||
})
|
||||
RegisterTokenProviderFactory("henan_lelian", func(ctx context.Context, channelConfig *model.SimChannelConfigItemCache, cfg SharedTokenConfig) (ydTokenProvider, error) {
|
||||
if channelConfig == nil || channelConfig.Channel == nil {
|
||||
return nil, fmt.Errorf("channel config is required")
|
||||
}
|
||||
fetcher := henanLelianFetcher{provider: NewHenanLelianTokenProvider(HenanLelianTokenConfig{
|
||||
BaseURL: cfg.TokenBaseURL, LoginPath: cfg.LoginPath, Path: cfg.TokenPath,
|
||||
UserName: cfg.UserName, Password: cfg.Password, PasswayUID: cfg.PasswayUID,
|
||||
Timeout: cfg.Timeout,
|
||||
})}
|
||||
manager := sharedtoken.NewTokenManager(fetcher, sharedtoken.WithUntilInvalidated())
|
||||
manager.SeedFromConfig(channelConfig)
|
||||
return &sharedTokenManagerProvider{manager: manager, chanKey: channelConfig.Channel.ChanKey}, nil
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,880 @@
|
||||
package upstreamimpl
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/md5"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/consts"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/model"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/service"
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
"github.com/gogf/gf/v2/util/gconv"
|
||||
)
|
||||
|
||||
const (
|
||||
qianxunTempKey = "sim_qianxun_v3"
|
||||
qianxunSuccessCode = "000000"
|
||||
qianxunTimeLayout = "20060102150405"
|
||||
qianxunBatchLimit = 200
|
||||
)
|
||||
|
||||
var qianxunEast8 = time.FixedZone("UTC+8", 8*60*60)
|
||||
|
||||
type sSimQianxun struct {
|
||||
channelConfig *model.SimChannelConfigItemCache
|
||||
baseURL string
|
||||
appKey string
|
||||
appSecret string
|
||||
userName string
|
||||
http *http.Client
|
||||
}
|
||||
|
||||
var _ model.SimCardChannelI = (*sSimQianxun)(nil)
|
||||
var _ model.SimCardBatchQueryI = (*sSimQianxun)(nil)
|
||||
|
||||
type qianxunResponse struct {
|
||||
Status string `json:"status"`
|
||||
Message string `json:"message"`
|
||||
Result json.RawMessage `json:"result"`
|
||||
}
|
||||
|
||||
type qianxunCardInfo struct {
|
||||
ICCID string `json:"iccid"`
|
||||
IMSI string `json:"imsi"`
|
||||
IMEI string `json:"imei"`
|
||||
MSISDN string `json:"msisdn"`
|
||||
AccessNumber string `json:"accessNumber"`
|
||||
CardNo string `json:"cardNo"`
|
||||
CardStatus any `json:"cardStatus"`
|
||||
Status any `json:"status"`
|
||||
SimStatus any `json:"simStatus"`
|
||||
RealStatus any `json:"realStatus"`
|
||||
StatusName string `json:"statusName"`
|
||||
OpenDate string `json:"openDate"`
|
||||
OpenTime string `json:"openTime"`
|
||||
ActiveDate string `json:"activeDate"`
|
||||
ActiveTime string `json:"activeTime"`
|
||||
ActivateTime string `json:"activateTime"`
|
||||
PackageName string `json:"packageName"`
|
||||
MainOfferName string `json:"mainOfferName"`
|
||||
ProdName string `json:"prodName"`
|
||||
Remark string `json:"remark"`
|
||||
UsageTotal any `json:"usageTotal"`
|
||||
UsedGprs any `json:"usedGprs"`
|
||||
UsedFlow any `json:"usedFlow"`
|
||||
UseFlow any `json:"useFlow"`
|
||||
CardFlow any `json:"cardFlow"`
|
||||
Flow any `json:"flow"`
|
||||
TotalFlow any `json:"totalFlow"`
|
||||
ProdTotal any `json:"prodTotal"`
|
||||
PackageFlow any `json:"packageFlow"`
|
||||
LeftFlow any `json:"leftFlow"`
|
||||
LastChangeDate string `json:"lastChangeDate"`
|
||||
UpdateTime string `json:"updateTime"`
|
||||
}
|
||||
|
||||
type qianxunFlowHistory struct {
|
||||
ICCID string `json:"iccid"`
|
||||
BillingMonth string `json:"billingMonth"`
|
||||
UsageTotal any `json:"usageTotal"`
|
||||
Status string `json:"status"`
|
||||
}
|
||||
|
||||
type qianxunBatchCardResult struct {
|
||||
ICCID string `json:"iccid"`
|
||||
ICCIDData json.RawMessage `json:"iccidData"`
|
||||
ICCIDResult json.RawMessage `json:"iccidResult"`
|
||||
}
|
||||
|
||||
func NewSimQianxun() *sSimQianxun {
|
||||
return &sSimQianxun{}
|
||||
}
|
||||
|
||||
func init() {
|
||||
service.UpstreamAPI().RegisterTemp(qianxunTempKey, NewSimQianxun())
|
||||
}
|
||||
|
||||
func (s *sSimQianxun) NewTemp(ctx context.Context, channelConfig *model.SimChannelConfigItemCache) model.SimCardChannelI {
|
||||
baseURL := ""
|
||||
if channelConfig != nil && channelConfig.Template != nil {
|
||||
var templateConfig struct {
|
||||
API struct {
|
||||
BaseURL string `json:"baseUrl"`
|
||||
} `json:"api"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(channelConfig.Template.TempConfig), &templateConfig); err != nil {
|
||||
g.Log().Warningf(ctx, "parse Qianxun template config failed: %v", err)
|
||||
} else {
|
||||
baseURL = strings.TrimRight(strings.TrimSpace(templateConfig.API.BaseURL), "/")
|
||||
}
|
||||
}
|
||||
|
||||
var channelExtra struct {
|
||||
UserName string `json:"userName"`
|
||||
}
|
||||
if channelConfig != nil && channelConfig.Channel != nil {
|
||||
if err := json.Unmarshal([]byte(channelConfig.Channel.ChanConfig), &channelExtra); err != nil {
|
||||
g.Log().Warningf(ctx, "parse Qianxun channel config failed, chanKey=%s err=%v", channelConfig.Channel.ChanKey, err)
|
||||
}
|
||||
}
|
||||
|
||||
appKey := ""
|
||||
appSecret := ""
|
||||
if channelConfig != nil && channelConfig.Channel != nil {
|
||||
appKey = strings.TrimSpace(channelConfig.Channel.AppKey)
|
||||
appSecret = strings.TrimSpace(channelConfig.Channel.AppSecret)
|
||||
}
|
||||
|
||||
return &sSimQianxun{
|
||||
channelConfig: channelConfig,
|
||||
baseURL: baseURL,
|
||||
appKey: appKey,
|
||||
appSecret: appSecret,
|
||||
userName: strings.TrimSpace(channelExtra.UserName),
|
||||
http: &http.Client{Timeout: resolveUpstreamHTTPTimeout(ctx, templateConfigJSON(channelConfig))},
|
||||
}
|
||||
}
|
||||
|
||||
func qianxunSign(appKey, appSecret, userName, timestamp string) string {
|
||||
plain := fmt.Sprintf("appKey=%s&timeStamp=%s&userName=%s&appSecret=%s", appKey, timestamp, userName, appSecret)
|
||||
sum := md5.Sum([]byte(plain))
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
func (s *sSimQianxun) authParams() (url.Values, error) {
|
||||
if s == nil {
|
||||
return nil, fmt.Errorf("Qianxun client is nil")
|
||||
}
|
||||
if strings.TrimSpace(s.baseURL) == "" {
|
||||
return nil, fmt.Errorf("Qianxun baseUrl is not configured")
|
||||
}
|
||||
if strings.TrimSpace(s.appKey) == "" || strings.TrimSpace(s.appSecret) == "" || strings.TrimSpace(s.userName) == "" {
|
||||
return nil, fmt.Errorf("Qianxun credentials are not configured")
|
||||
}
|
||||
timestamp := time.Now().In(qianxunEast8).Format(qianxunTimeLayout)
|
||||
params := url.Values{}
|
||||
params.Set("appKey", s.appKey)
|
||||
params.Set("timeStamp", timestamp)
|
||||
params.Set("userName", s.userName)
|
||||
params.Set("sign", qianxunSign(s.appKey, s.appSecret, s.userName, timestamp))
|
||||
return params, nil
|
||||
}
|
||||
|
||||
func (s *sSimQianxun) post(ctx context.Context, endpoint string, params url.Values) (qianxunResponse, error) {
|
||||
authParams, err := s.authParams()
|
||||
if err != nil {
|
||||
return qianxunResponse{}, err
|
||||
}
|
||||
for key, values := range params {
|
||||
for _, value := range values {
|
||||
authParams.Add(key, value)
|
||||
}
|
||||
}
|
||||
|
||||
target := strings.TrimRight(s.baseURL, "/") + "/" + strings.TrimLeft(endpoint, "/")
|
||||
body := authParams.Encode()
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, target, bytes.NewBufferString(body))
|
||||
if err != nil {
|
||||
return qianxunResponse{}, fmt.Errorf("create Qianxun request %s: %w", endpoint, err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
|
||||
client := s.http
|
||||
if client == nil {
|
||||
client = &http.Client{Timeout: defaultUpstreamHTTPTimeout}
|
||||
}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return qianxunResponse{}, fmt.Errorf("Qianxun request %s: %w", endpoint, err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
responseBody, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return qianxunResponse{}, fmt.Errorf("read Qianxun response %s: %w", endpoint, err)
|
||||
}
|
||||
rawResponse := strings.TrimSpace(string(responseBody))
|
||||
if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices {
|
||||
g.Log().Warningf(ctx, "Qianxun HTTP error endpoint=%s status=%d response=%s", endpoint, resp.StatusCode, rawResponse)
|
||||
return qianxunResponse{}, fmt.Errorf("Qianxun HTTP status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var output qianxunResponse
|
||||
if err := json.Unmarshal(responseBody, &output); err != nil {
|
||||
g.Log().Warningf(ctx, "decode Qianxun response failed endpoint=%s response=%s err=%v", endpoint, rawResponse, err)
|
||||
return qianxunResponse{}, fmt.Errorf("decode Qianxun response %s: %w", endpoint, err)
|
||||
}
|
||||
if !qianxunSuccess(output) {
|
||||
g.Log().Warningf(ctx, "Qianxun business error endpoint=%s status=%s message=%s response=%s", endpoint, output.Status, output.Message, rawResponse)
|
||||
} else {
|
||||
g.Log().Infof(ctx, "Qianxun response endpoint=%s status=%s response=%s", endpoint, output.Status, rawResponse)
|
||||
}
|
||||
return output, nil
|
||||
}
|
||||
|
||||
func qianxunSuccess(resp qianxunResponse) bool {
|
||||
return strings.TrimSpace(resp.Status) == qianxunSuccessCode
|
||||
}
|
||||
|
||||
func qianxunBusinessError[T any](simKey *model.SimKey, resp qianxunResponse) *model.SimBaseRes[T] {
|
||||
return &model.SimBaseRes[T]{
|
||||
SimKey: simKey,
|
||||
Code: gconv.Int(resp.Status),
|
||||
ErrCode: resp.Status,
|
||||
Message: resp.Message,
|
||||
}
|
||||
}
|
||||
|
||||
func qianxunError[T any](simKey *model.SimKey, err error) *model.SimBaseRes[T] {
|
||||
return &model.SimBaseRes[T]{SimKey: simKey, Code: consts.ApiStatusError, Message: err.Error()}
|
||||
}
|
||||
|
||||
func qianxunUnsupported[T any](simKeys []*model.SimKey, message string) []*model.SimBaseRes[T] {
|
||||
items := make([]*model.SimBaseRes[T], 0, len(simKeys))
|
||||
for _, simKey := range simKeys {
|
||||
items = append(items, &model.SimBaseRes[T]{
|
||||
SimKey: simKey,
|
||||
Code: consts.ApiInvokeException,
|
||||
Message: message,
|
||||
})
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
func qianxunICCID(simKey *model.SimKey) (string, error) {
|
||||
if simKey == nil || strings.TrimSpace(simKey.ICCID) == "" {
|
||||
return "", fmt.Errorf("ICCID not found")
|
||||
}
|
||||
return strings.TrimSpace(simKey.ICCID), nil
|
||||
}
|
||||
|
||||
func decodeQianxunData[T any](raw json.RawMessage, target *T) error {
|
||||
if len(raw) == 0 || string(raw) == "null" {
|
||||
return fmt.Errorf("Qianxun response result is empty")
|
||||
}
|
||||
if err := json.Unmarshal(raw, target); err != nil {
|
||||
return fmt.Errorf("decode Qianxun response result: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func decodeQianxunStringJSON(raw json.RawMessage, target any) error {
|
||||
if len(raw) == 0 || string(raw) == "null" {
|
||||
return fmt.Errorf("Qianxun batch item result is empty")
|
||||
}
|
||||
var text string
|
||||
if err := json.Unmarshal(raw, &text); err == nil {
|
||||
text = strings.TrimSpace(text)
|
||||
if !strings.HasPrefix(text, "{") && !strings.HasPrefix(text, "[") {
|
||||
return errors.New(text)
|
||||
}
|
||||
return json.Unmarshal([]byte(text), target)
|
||||
}
|
||||
return json.Unmarshal(raw, target)
|
||||
}
|
||||
|
||||
func qianxunCardStatus(ctx context.Context, value any) int {
|
||||
status := strings.TrimSpace(gconv.String(value))
|
||||
switch status {
|
||||
case "":
|
||||
return 0
|
||||
case "0", "10":
|
||||
return 2
|
||||
case "1":
|
||||
return 1
|
||||
case "3", "4", "8":
|
||||
return 4
|
||||
case "6":
|
||||
return 6
|
||||
case "7":
|
||||
return 7
|
||||
case "9":
|
||||
return 9
|
||||
default:
|
||||
g.Log().Warningf(ctx, "Unknown Qianxun card status %s from upstream", status)
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
func qianxunUsageMB(values ...any) float64 {
|
||||
for _, value := range values {
|
||||
text := strings.TrimSpace(gconv.String(value))
|
||||
if text != "" && text != "<nil>" {
|
||||
return parseDataUsage(text)
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func qianxunCardMessage(resp qianxunResponse) string {
|
||||
return firstNonEmpty(resp.Message, "成功")
|
||||
}
|
||||
|
||||
func qianxunCardToBasic(data qianxunCardInfo) model.SimBasicInfoItem {
|
||||
status := strconv.Itoa(qianxunCardStatus(context.Background(), qianxunStatusValue(data)))
|
||||
return model.SimBasicInfoItem{
|
||||
ICCID: data.ICCID,
|
||||
IMSI: data.IMSI,
|
||||
MSISDN: firstNonEmpty(data.MSISDN, data.AccessNumber, data.CardNo),
|
||||
ActiveDate: firstNonEmpty(data.ActiveDate, data.ActiveTime, data.ActivateTime),
|
||||
OpenDate: firstNonEmpty(data.OpenDate, data.OpenTime),
|
||||
Remark: data.Remark,
|
||||
SimStatus: status,
|
||||
MainOfferName: firstNonEmpty(data.MainOfferName, data.PackageName, data.ProdName),
|
||||
}
|
||||
}
|
||||
|
||||
func qianxunStatusValue(data qianxunCardInfo) string {
|
||||
return firstNonEmpty(gconv.String(data.SimStatus), gconv.String(data.CardStatus), gconv.String(data.Status), gconv.String(data.RealStatus))
|
||||
}
|
||||
|
||||
func (s *sSimQianxun) queryCardInfo(ctx context.Context, simKey *model.SimKey) (qianxunCardInfo, qianxunResponse, error) {
|
||||
iccid, err := qianxunICCID(simKey)
|
||||
if err != nil {
|
||||
return qianxunCardInfo{}, qianxunResponse{}, err
|
||||
}
|
||||
params := url.Values{}
|
||||
params.Set("iccid", iccid)
|
||||
resp, err := s.post(ctx, "/api/v3/cardInfo", params)
|
||||
if err != nil || !qianxunSuccess(resp) {
|
||||
return qianxunCardInfo{}, resp, err
|
||||
}
|
||||
var data qianxunCardInfo
|
||||
if err := decodeQianxunData(resp.Result, &data); err != nil {
|
||||
return qianxunCardInfo{}, resp, err
|
||||
}
|
||||
return data, resp, nil
|
||||
}
|
||||
|
||||
func (s *sSimQianxun) QuerySimBasicInfo(ctx context.Context, input *model.SimBasicInfoInput) (model.SimBasicInfoOutput, error) {
|
||||
if input == nil {
|
||||
return make(model.SimBasicInfoOutput, 0), nil
|
||||
}
|
||||
items := make(model.SimBasicInfoOutput, 0, len(input.SimKeys))
|
||||
for _, simKey := range input.SimKeys {
|
||||
data, resp, err := s.queryCardInfo(ctx, simKey)
|
||||
if err != nil {
|
||||
items = append(items, qianxunError[model.SimBasicInfoItem](simKey, err))
|
||||
continue
|
||||
}
|
||||
if !qianxunSuccess(resp) {
|
||||
items = append(items, qianxunBusinessError[model.SimBasicInfoItem](simKey, resp))
|
||||
continue
|
||||
}
|
||||
items = append(items, &model.SimBaseRes[model.SimBasicInfoItem]{
|
||||
SimKey: simKey,
|
||||
Code: consts.ApiStatusSuccess,
|
||||
Message: qianxunCardMessage(resp),
|
||||
ResData: qianxunCardToBasic(data),
|
||||
})
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
func (s *sSimQianxun) QuerySimCardStatus(ctx context.Context, input *model.SimCardStatusInput) (model.SimCardStatusOutput, error) {
|
||||
if input == nil {
|
||||
return make(model.SimCardStatusOutput, 0), nil
|
||||
}
|
||||
items := make(model.SimCardStatusOutput, 0, len(input.SimKeys))
|
||||
for _, simKey := range input.SimKeys {
|
||||
data, resp, err := s.queryCardInfo(ctx, simKey)
|
||||
if err != nil {
|
||||
items = append(items, qianxunError[model.SimCardStatusItem](simKey, err))
|
||||
continue
|
||||
}
|
||||
if !qianxunSuccess(resp) {
|
||||
items = append(items, qianxunBusinessError[model.SimCardStatusItem](simKey, resp))
|
||||
continue
|
||||
}
|
||||
items = append(items, &model.SimBaseRes[model.SimCardStatusItem]{
|
||||
SimKey: simKey,
|
||||
Code: consts.ApiStatusSuccess,
|
||||
Message: qianxunCardMessage(resp),
|
||||
ResData: model.SimCardStatusItem{
|
||||
CardStatus: qianxunCardStatus(ctx, qianxunStatusValue(data)),
|
||||
LastChangeDate: firstNonEmpty(data.LastChangeDate, data.UpdateTime),
|
||||
},
|
||||
})
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
func (s *sSimQianxun) QuerySimDataUsage(ctx context.Context, input *model.SimDataUsageInput) (model.SimDataUsageOutput, error) {
|
||||
if input == nil {
|
||||
return make(model.SimDataUsageOutput, 0), nil
|
||||
}
|
||||
items := make(model.SimDataUsageOutput, 0, len(input.SimKeys))
|
||||
for _, simKey := range input.SimKeys {
|
||||
data, resp, err := s.queryCardInfo(ctx, simKey)
|
||||
if err != nil {
|
||||
items = append(items, qianxunError[model.SimDataUsageItem](simKey, err))
|
||||
continue
|
||||
}
|
||||
if !qianxunSuccess(resp) {
|
||||
items = append(items, qianxunBusinessError[model.SimDataUsageItem](simKey, resp))
|
||||
continue
|
||||
}
|
||||
item := model.SimDataUsageItem{
|
||||
DataUsage: qianxunUsageMB(data.UsedGprs, data.UsageTotal, data.UsedFlow, data.UseFlow, data.CardFlow, data.Flow),
|
||||
}
|
||||
total := qianxunUsageMB(data.ProdTotal, data.TotalFlow, data.PackageFlow)
|
||||
if total > 0 {
|
||||
item.TotalFlow = &total
|
||||
}
|
||||
items = append(items, &model.SimBaseRes[model.SimDataUsageItem]{
|
||||
SimKey: simKey,
|
||||
Code: consts.ApiStatusSuccess,
|
||||
Message: qianxunCardMessage(resp),
|
||||
ResData: item,
|
||||
})
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
func (s *sSimQianxun) QuerySimDataUsageMonthly(ctx context.Context, input *model.SimDataUsageMonthlyInput) (model.SimDataUsageMonthlyOutput, error) {
|
||||
if input == nil {
|
||||
return make(model.SimDataUsageMonthlyOutput, 0), nil
|
||||
}
|
||||
queryMonth, err := qianxunMonth(input.ReqData.QueryDate)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items := make(model.SimDataUsageMonthlyOutput, 0, len(input.SimKeys))
|
||||
for _, simKey := range input.SimKeys {
|
||||
iccid, err := qianxunICCID(simKey)
|
||||
if err != nil {
|
||||
items = append(items, qianxunError[model.SimDataUsageMonthlyOutputItem](simKey, err))
|
||||
continue
|
||||
}
|
||||
params := url.Values{}
|
||||
params.Set("iccid", iccid)
|
||||
params.Set("billingMonth", queryMonth)
|
||||
resp, err := s.post(ctx, "/api/v3/queryFlowHistory", params)
|
||||
if err != nil {
|
||||
items = append(items, qianxunError[model.SimDataUsageMonthlyOutputItem](simKey, err))
|
||||
continue
|
||||
}
|
||||
if !qianxunSuccess(resp) {
|
||||
items = append(items, qianxunBusinessError[model.SimDataUsageMonthlyOutputItem](simKey, resp))
|
||||
continue
|
||||
}
|
||||
var data qianxunFlowHistory
|
||||
if err := decodeQianxunData(resp.Result, &data); err != nil {
|
||||
items = append(items, qianxunError[model.SimDataUsageMonthlyOutputItem](simKey, err))
|
||||
continue
|
||||
}
|
||||
if strings.EqualFold(strings.TrimSpace(data.Status), "N") {
|
||||
items = append(items, &model.SimBaseRes[model.SimDataUsageMonthlyOutputItem]{
|
||||
SimKey: simKey,
|
||||
Code: consts.ApiStatusError,
|
||||
Message: "Qianxun monthly usage data not synced",
|
||||
ResData: model.SimDataUsageMonthlyOutputItem{DeadLine: firstNonEmpty(data.BillingMonth, queryMonth)},
|
||||
})
|
||||
continue
|
||||
}
|
||||
items = append(items, &model.SimBaseRes[model.SimDataUsageMonthlyOutputItem]{
|
||||
SimKey: simKey,
|
||||
Code: consts.ApiStatusSuccess,
|
||||
Message: qianxunCardMessage(resp),
|
||||
ResData: model.SimDataUsageMonthlyOutputItem{
|
||||
DeadLine: firstNonEmpty(data.BillingMonth, queryMonth),
|
||||
DataAmount: qianxunUsageMB(data.UsageTotal),
|
||||
},
|
||||
})
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
func qianxunMonth(queryDate string) (string, error) {
|
||||
queryDate = strings.TrimSpace(queryDate)
|
||||
if queryDate == "" {
|
||||
return "", fmt.Errorf("queryDate is required")
|
||||
}
|
||||
for _, layout := range []string{"200601", "2006-01", "20060102", "2006-01-02"} {
|
||||
if parsed, err := time.Parse(layout, queryDate); err == nil {
|
||||
return parsed.Format("200601"), nil
|
||||
}
|
||||
}
|
||||
return "", fmt.Errorf("invalid queryDate: %s", queryDate)
|
||||
}
|
||||
|
||||
func (s *sSimQianxun) QuerySimCardStatusBatch(ctx context.Context, input *model.SimCardStatusInput) (model.SimCardStatusOutput, error) {
|
||||
if input == nil || len(input.SimKeys) == 0 {
|
||||
return make(model.SimCardStatusOutput, 0), nil
|
||||
}
|
||||
cards, errs := s.queryCardInfoBatch(ctx, input.SimKeys)
|
||||
items := make(model.SimCardStatusOutput, 0, len(input.SimKeys))
|
||||
for _, simKey := range input.SimKeys {
|
||||
iccid, err := qianxunICCID(simKey)
|
||||
if err != nil {
|
||||
items = append(items, qianxunError[model.SimCardStatusItem](simKey, err))
|
||||
continue
|
||||
}
|
||||
if err = errs[iccid]; err != nil {
|
||||
items = append(items, qianxunError[model.SimCardStatusItem](simKey, err))
|
||||
continue
|
||||
}
|
||||
data, ok := cards[iccid]
|
||||
if !ok {
|
||||
items = append(items, &model.SimBaseRes[model.SimCardStatusItem]{SimKey: simKey, Code: consts.ApiStatusError, Message: "batch response missing card"})
|
||||
continue
|
||||
}
|
||||
items = append(items, &model.SimBaseRes[model.SimCardStatusItem]{
|
||||
SimKey: simKey,
|
||||
Code: consts.ApiStatusSuccess,
|
||||
ResData: model.SimCardStatusItem{
|
||||
CardStatus: qianxunCardStatus(ctx, qianxunStatusValue(data)),
|
||||
LastChangeDate: firstNonEmpty(data.LastChangeDate, data.UpdateTime),
|
||||
},
|
||||
})
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
func (s *sSimQianxun) QuerySimDataUsageMonthlyBatch(ctx context.Context, input *model.SimDataUsageMonthlyInput) (model.SimDataUsageMonthlyOutput, error) {
|
||||
if input == nil || len(input.SimKeys) == 0 {
|
||||
return make(model.SimDataUsageMonthlyOutput, 0), nil
|
||||
}
|
||||
queryMonth, err := qianxunMonth(input.ReqData.QueryDate)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
results := make(model.SimDataUsageMonthlyOutput, len(input.SimKeys))
|
||||
for _, chunk := range qianxunChunkSimKeys(input.SimKeys, qianxunBatchLimit) {
|
||||
iccids := make([]string, 0, len(chunk))
|
||||
for _, item := range chunk {
|
||||
iccid, err := qianxunICCID(item.simKey)
|
||||
if err != nil {
|
||||
results[item.index] = qianxunError[model.SimDataUsageMonthlyOutputItem](item.simKey, err)
|
||||
continue
|
||||
}
|
||||
iccids = append(iccids, iccid)
|
||||
}
|
||||
if len(iccids) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
params := url.Values{}
|
||||
params.Set("iccids", strings.Join(iccids, "_"))
|
||||
params.Set("billingMonth", queryMonth)
|
||||
resp, err := s.post(ctx, "/api/v3/batchQueryFlowHistory", params)
|
||||
if err != nil {
|
||||
for _, item := range chunk {
|
||||
if results[item.index] == nil {
|
||||
results[item.index] = qianxunError[model.SimDataUsageMonthlyOutputItem](item.simKey, err)
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
if !qianxunSuccess(resp) {
|
||||
for _, item := range chunk {
|
||||
if results[item.index] == nil {
|
||||
results[item.index] = qianxunBusinessError[model.SimDataUsageMonthlyOutputItem](item.simKey, resp)
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
history, itemErrs, err := decodeQianxunBatchHistory(resp.Result)
|
||||
if err != nil {
|
||||
for _, item := range chunk {
|
||||
if results[item.index] == nil {
|
||||
results[item.index] = qianxunError[model.SimDataUsageMonthlyOutputItem](item.simKey, err)
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
for _, item := range chunk {
|
||||
if results[item.index] != nil {
|
||||
continue
|
||||
}
|
||||
iccid, _ := qianxunICCID(item.simKey)
|
||||
if itemErrs[iccid] != nil {
|
||||
results[item.index] = qianxunError[model.SimDataUsageMonthlyOutputItem](item.simKey, itemErrs[iccid])
|
||||
continue
|
||||
}
|
||||
data, ok := history[iccid]
|
||||
if !ok {
|
||||
results[item.index] = &model.SimBaseRes[model.SimDataUsageMonthlyOutputItem]{SimKey: item.simKey, Code: consts.ApiStatusError, Message: "batch response missing card", ResData: model.SimDataUsageMonthlyOutputItem{DeadLine: queryMonth}}
|
||||
continue
|
||||
}
|
||||
if strings.EqualFold(strings.TrimSpace(data.Status), "N") {
|
||||
results[item.index] = &model.SimBaseRes[model.SimDataUsageMonthlyOutputItem]{SimKey: item.simKey, Code: consts.ApiStatusError, Message: "Qianxun monthly usage data not synced", ResData: model.SimDataUsageMonthlyOutputItem{DeadLine: firstNonEmpty(data.BillingMonth, queryMonth)}}
|
||||
continue
|
||||
}
|
||||
results[item.index] = &model.SimBaseRes[model.SimDataUsageMonthlyOutputItem]{
|
||||
SimKey: item.simKey,
|
||||
Code: consts.ApiStatusSuccess,
|
||||
Message: qianxunCardMessage(resp),
|
||||
ResData: model.SimDataUsageMonthlyOutputItem{
|
||||
DeadLine: firstNonEmpty(data.BillingMonth, queryMonth),
|
||||
DataAmount: qianxunUsageMB(data.UsageTotal),
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
return compactMonthlyBatchResults(results), nil
|
||||
}
|
||||
|
||||
type qianxunBatchKey struct {
|
||||
index int
|
||||
simKey *model.SimKey
|
||||
}
|
||||
|
||||
func qianxunChunkSimKeys(simKeys []*model.SimKey, size int) [][]qianxunBatchKey {
|
||||
if size <= 0 {
|
||||
size = 1
|
||||
}
|
||||
items := make([]qianxunBatchKey, 0, len(simKeys))
|
||||
for i, simKey := range simKeys {
|
||||
items = append(items, qianxunBatchKey{index: i, simKey: simKey})
|
||||
}
|
||||
chunks := make([][]qianxunBatchKey, 0, (len(items)+size-1)/size)
|
||||
for start := 0; start < len(items); start += size {
|
||||
end := start + size
|
||||
if end > len(items) {
|
||||
end = len(items)
|
||||
}
|
||||
chunks = append(chunks, items[start:end])
|
||||
}
|
||||
return chunks
|
||||
}
|
||||
|
||||
func (s *sSimQianxun) queryCardInfoBatch(ctx context.Context, simKeys []*model.SimKey) (map[string]qianxunCardInfo, map[string]error) {
|
||||
cards := make(map[string]qianxunCardInfo, len(simKeys))
|
||||
errs := make(map[string]error, len(simKeys))
|
||||
for _, chunk := range qianxunChunkSimKeys(simKeys, qianxunBatchLimit) {
|
||||
iccids := make([]string, 0, len(chunk))
|
||||
for _, item := range chunk {
|
||||
iccid, err := qianxunICCID(item.simKey)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
iccids = append(iccids, iccid)
|
||||
}
|
||||
if len(iccids) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
params := url.Values{}
|
||||
params.Set("iccids", strings.Join(iccids, "_"))
|
||||
resp, err := s.post(ctx, "/api/v3/cardInfos", params)
|
||||
if err != nil {
|
||||
for _, iccid := range iccids {
|
||||
errs[iccid] = err
|
||||
}
|
||||
continue
|
||||
}
|
||||
if !qianxunSuccess(resp) {
|
||||
for _, iccid := range iccids {
|
||||
errs[iccid] = fmt.Errorf("Qianxun batch card info failed: code=%s message=%s", resp.Status, resp.Message)
|
||||
}
|
||||
continue
|
||||
}
|
||||
batchCards, itemErrs, err := decodeQianxunBatchCards(resp.Result)
|
||||
if err != nil {
|
||||
for _, iccid := range iccids {
|
||||
errs[iccid] = err
|
||||
}
|
||||
continue
|
||||
}
|
||||
for iccid, data := range batchCards {
|
||||
cards[iccid] = data
|
||||
}
|
||||
for iccid, itemErr := range itemErrs {
|
||||
errs[iccid] = itemErr
|
||||
}
|
||||
}
|
||||
return cards, errs
|
||||
}
|
||||
|
||||
func decodeQianxunBatchCards(raw json.RawMessage) (map[string]qianxunCardInfo, map[string]error, error) {
|
||||
cards := make(map[string]qianxunCardInfo)
|
||||
errs := make(map[string]error)
|
||||
|
||||
var wrapped []qianxunBatchCardResult
|
||||
if err := decodeQianxunData(raw, &wrapped); err == nil && qianxunHasWrappedData(wrapped) {
|
||||
for _, item := range wrapped {
|
||||
iccid := strings.TrimSpace(item.ICCID)
|
||||
var card qianxunCardInfo
|
||||
if err := decodeQianxunStringJSON(firstQianxunRaw(item.ICCIDData, item.ICCIDResult), &card); err != nil {
|
||||
errs[iccid] = err
|
||||
continue
|
||||
}
|
||||
if strings.TrimSpace(card.ICCID) == "" {
|
||||
card.ICCID = iccid
|
||||
}
|
||||
cards[card.ICCID] = card
|
||||
}
|
||||
return cards, errs, nil
|
||||
}
|
||||
|
||||
var list []qianxunCardInfo
|
||||
if err := decodeQianxunData(raw, &list); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
for _, item := range list {
|
||||
iccid := strings.TrimSpace(item.ICCID)
|
||||
if iccid == "" {
|
||||
continue
|
||||
}
|
||||
cards[iccid] = item
|
||||
}
|
||||
return cards, errs, nil
|
||||
}
|
||||
|
||||
func decodeQianxunBatchHistory(raw json.RawMessage) (map[string]qianxunFlowHistory, map[string]error, error) {
|
||||
history := make(map[string]qianxunFlowHistory)
|
||||
errs := make(map[string]error)
|
||||
|
||||
var wrapped []qianxunBatchCardResult
|
||||
if err := decodeQianxunData(raw, &wrapped); err == nil && qianxunHasWrappedData(wrapped) {
|
||||
for _, item := range wrapped {
|
||||
iccid := strings.TrimSpace(item.ICCID)
|
||||
var data qianxunFlowHistory
|
||||
if err := decodeQianxunStringJSON(firstQianxunRaw(item.ICCIDResult, item.ICCIDData), &data); err != nil {
|
||||
errs[iccid] = err
|
||||
continue
|
||||
}
|
||||
if strings.TrimSpace(data.ICCID) == "" {
|
||||
data.ICCID = iccid
|
||||
}
|
||||
history[data.ICCID] = data
|
||||
}
|
||||
return history, errs, nil
|
||||
}
|
||||
|
||||
var list []qianxunFlowHistory
|
||||
if err := decodeQianxunData(raw, &list); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
for _, item := range list {
|
||||
iccid := strings.TrimSpace(item.ICCID)
|
||||
if iccid == "" {
|
||||
continue
|
||||
}
|
||||
history[iccid] = item
|
||||
}
|
||||
return history, errs, nil
|
||||
}
|
||||
|
||||
func qianxunHasWrappedData(items []qianxunBatchCardResult) bool {
|
||||
for _, item := range items {
|
||||
if len(item.ICCIDData) > 0 && string(item.ICCIDData) != "null" {
|
||||
return true
|
||||
}
|
||||
if len(item.ICCIDResult) > 0 && string(item.ICCIDResult) != "null" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func firstQianxunRaw(values ...json.RawMessage) json.RawMessage {
|
||||
for _, value := range values {
|
||||
if len(value) > 0 && string(value) != "null" {
|
||||
return value
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *sSimQianxun) QuerySimSession(ctx context.Context, input *model.SimSessionInput) (model.SimSessionOutput, error) {
|
||||
if input == nil {
|
||||
return make(model.SimSessionOutput, 0), nil
|
||||
}
|
||||
return qianxunUnsupported[model.SimSessionItem](input.SimKeys, "千寻通道暂不支持在线信息查询"), nil
|
||||
}
|
||||
|
||||
func (s *sSimQianxun) QuerySimStopReason(ctx context.Context, input *model.SimStopReasonInput) (model.SimStopReasonOutput, error) {
|
||||
if input == nil {
|
||||
return make(model.SimStopReasonOutput, 0), nil
|
||||
}
|
||||
return qianxunUnsupported[model.SimStopReasonItem](input.SimKeys, "千寻通道暂不支持停机原因查询"), nil
|
||||
}
|
||||
|
||||
func (s *sSimQianxun) QuerySimFrozenState(ctx context.Context, input *model.SimFrozenStateInput) (model.SimFrozenStateOutput, error) {
|
||||
if input == nil {
|
||||
return make(model.SimFrozenStateOutput, 0), nil
|
||||
}
|
||||
return qianxunUnsupported[model.SimFrozenStateItem](input.SimKeys, "千寻通道暂不支持冻结状态查询"), nil
|
||||
}
|
||||
|
||||
func (s *sSimQianxun) QuerySimCardBindStatus(ctx context.Context, input *model.SimCardBindStatusInput) (model.SimCardBindStatusOutput, error) {
|
||||
if input == nil {
|
||||
return make(model.SimCardBindStatusOutput, 0), nil
|
||||
}
|
||||
return qianxunUnsupported[model.SimCardBindStatusItem](input.SimKeys, "千寻通道暂不支持机卡绑定状态查询"), nil
|
||||
}
|
||||
|
||||
func (s *sSimQianxun) QuerySimDeviceStatus(ctx context.Context, input *model.SimDeviceStatusInput) (model.SimDeviceStatusOutput, error) {
|
||||
if input == nil {
|
||||
return make(model.SimDeviceStatusOutput, 0), nil
|
||||
}
|
||||
return qianxunUnsupported[model.SimDeviceStatusItem](input.SimKeys, "千寻通道暂不支持设备状态查询"), nil
|
||||
}
|
||||
|
||||
func (s *sSimQianxun) QuerySimRegionLimitStatus(ctx context.Context, input *model.SimRegionLimitStatusInput) (model.SimRegionLimitStatusOutput, error) {
|
||||
if input == nil {
|
||||
return make(model.SimRegionLimitStatusOutput, 0), nil
|
||||
}
|
||||
return qianxunUnsupported[model.SimRegionLimitStatusItem](input.SimKeys, "千寻通道暂不支持地域限制状态查询"), nil
|
||||
}
|
||||
|
||||
func (s *sSimQianxun) QuerySimImei(ctx context.Context, input *model.SimImeiInput) (model.SimImeiOutput, error) {
|
||||
if input == nil {
|
||||
return make(model.SimImeiOutput, 0), nil
|
||||
}
|
||||
return qianxunUnsupported[model.SimImeiItem](input.SimKeys, "千寻通道暂不支持IMEI查询"), nil
|
||||
}
|
||||
|
||||
func (s *sSimQianxun) QuerySimDataDiagnosis(ctx context.Context, input *model.SimDataDiagnosisInput) (model.SimDataDiagnosisOutput, error) {
|
||||
if input == nil {
|
||||
return make(model.SimDataDiagnosisOutput, 0), nil
|
||||
}
|
||||
return qianxunUnsupported[any](input.SimKeys, "千寻通道暂不支持达量限速档位查询"), nil
|
||||
}
|
||||
|
||||
func (s *sSimQianxun) QuerySimDataUsageDaily(ctx context.Context, input *model.SimDataUsageDailyInput) (model.SimDataUsageDailyOutput, error) {
|
||||
if input == nil {
|
||||
return make(model.SimDataUsageDailyOutput, 0), nil
|
||||
}
|
||||
return qianxunUnsupported[model.SimDataUsageDailyOutputItem](input.SimKeys, "千寻通道暂不支持单日流量查询"), nil
|
||||
}
|
||||
|
||||
func (s *sSimQianxun) QuerySimDataUsageDailyBatch(ctx context.Context, input *model.SimDataUsageDailyInput) (model.SimDataUsageDailyOutput, error) {
|
||||
return s.QuerySimDataUsageDaily(ctx, input)
|
||||
}
|
||||
|
||||
func (s *sSimQianxun) QuerySimApnInfo(ctx context.Context, input *model.SimApnInfoInput) (model.SimApnInfoOutput, error) {
|
||||
if input == nil {
|
||||
return make(model.SimApnInfoOutput, 0), nil
|
||||
}
|
||||
return qianxunUnsupported[model.SimApnInfoItemList](input.SimKeys, "千寻通道暂不支持APN信息查询"), nil
|
||||
}
|
||||
|
||||
func (s *sSimQianxun) ChangeSimStatus(ctx context.Context, input *model.SimCardStatusChangeInput) (model.SimCardStatusChangeOutput, error) {
|
||||
if input == nil {
|
||||
return make(model.SimCardStatusChangeOutput, 0), nil
|
||||
}
|
||||
return qianxunUnsupported[model.SimCardStatusChangeItem](input.SimKeys, "千寻通道暂不支持卡状态变更"), nil
|
||||
}
|
||||
|
||||
func (s *sSimQianxun) ChangeSimApnFunction(ctx context.Context, input *model.SimCardOperateChangeApnInput) (model.SimCardOperateChangeApnOutput, error) {
|
||||
if input == nil {
|
||||
return make(model.SimCardOperateChangeApnOutput, 0), nil
|
||||
}
|
||||
return qianxunUnsupported[model.SimCardOperateChangeApnItem](input.SimKeys, "千寻通道暂不支持APN功能开停"), nil
|
||||
}
|
||||
@@ -0,0 +1,519 @@
|
||||
package upstreamimpl
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/consts"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/logic/upstreamimpl/recharge"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/model"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/service"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/gogf/gf/v2/errors/gerror"
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
"golang.org/x/sync/singleflight"
|
||||
)
|
||||
|
||||
const rechargeTempKey = "sim_sdtywl_v1"
|
||||
const rechargeFindCardCacheTTL = 30 * time.Second
|
||||
|
||||
// 腾宇卡状态 status_show_id -> 系统 cardStatus
|
||||
// 腾宇: 0未知 1库存 2可测试 3待激活 4已激活 5已停机 6预销户 7已销户
|
||||
// 系统: 0未知 1待激活 2已激活 4已停机 6可测试 7库存 8预销户 9已销户
|
||||
var rechargeCardStatusMap = map[int]int{
|
||||
0: 0,
|
||||
1: 7,
|
||||
2: 6,
|
||||
3: 1,
|
||||
4: 2,
|
||||
5: 4,
|
||||
6: 8,
|
||||
7: 9,
|
||||
}
|
||||
|
||||
type sSimRecharge struct {
|
||||
channelConfig *model.SimChannelConfigItemCache
|
||||
client *recharge.Client
|
||||
cardCache sync.Map
|
||||
cardFlight singleflight.Group
|
||||
}
|
||||
|
||||
type rechargeCardCacheEntry struct {
|
||||
Info model.RechargeCardInfo
|
||||
UpdatedAt string
|
||||
ExpiresAt time.Time
|
||||
}
|
||||
|
||||
type rechargeFindCardResult struct {
|
||||
Info *model.RechargeCardInfo
|
||||
Cached bool
|
||||
UpdatedAt string
|
||||
}
|
||||
|
||||
var _ model.SimCardChannelI = (*sSimRecharge)(nil)
|
||||
|
||||
type rechargeChannelConfig struct {
|
||||
PwdKey string `json:"pwdkey"`
|
||||
Pwdiv string `json:"pwdiv"`
|
||||
LegacyTimeout json.RawMessage `json:"timeout"`
|
||||
}
|
||||
|
||||
func NewSimRecharge() *sSimRecharge {
|
||||
return &sSimRecharge{}
|
||||
}
|
||||
|
||||
func init() {
|
||||
service.UpstreamAPI().RegisterTemp(rechargeTempKey, NewSimRecharge())
|
||||
}
|
||||
|
||||
func (s *sSimRecharge) NewTemp(ctx context.Context, channelConfig *model.SimChannelConfigItemCache) model.SimCardChannelI {
|
||||
cfg := rechargeChannelConfig{}
|
||||
if channelConfig != nil && channelConfig.Channel != nil {
|
||||
_ = json.Unmarshal([]byte(channelConfig.Channel.ChanConfig), &cfg)
|
||||
if len(cfg.LegacyTimeout) > 0 && string(cfg.LegacyTimeout) != "null" {
|
||||
g.Log().Warningf(ctx, "recharge channel %s uses deprecated chan_config.timeout; configure template http.timeout instead", channelConfig.Channel.ChanKey)
|
||||
}
|
||||
}
|
||||
|
||||
baseURL := ""
|
||||
if channelConfig != nil && channelConfig.Template != nil {
|
||||
var templateConfig struct {
|
||||
API struct {
|
||||
BaseURL string `json:"baseUrl"`
|
||||
} `json:"api"`
|
||||
LegacyBaseURL string `json:"baseUrl"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(channelConfig.Template.TempConfig), &templateConfig); err != nil {
|
||||
g.Log().Warningf(ctx, "parse recharge template config failed, chanKey=%s err=%+v", channelConfig.Channel.ChanKey, err)
|
||||
} else {
|
||||
baseURL = strings.TrimSpace(templateConfig.API.BaseURL)
|
||||
if baseURL == "" && strings.TrimSpace(templateConfig.LegacyBaseURL) != "" {
|
||||
baseURL = strings.TrimSpace(templateConfig.LegacyBaseURL)
|
||||
g.Log().Warningf(ctx, "recharge template %s uses deprecated root baseUrl; move it to api.baseUrl", channelConfig.Template.TempKey)
|
||||
}
|
||||
}
|
||||
}
|
||||
if baseURL == "" {
|
||||
baseURL = recharge.DefaultBaseURL
|
||||
}
|
||||
|
||||
// 字段复用约定: app_key=pwdkey, app_secret=pwdiv
|
||||
pwdKey := cfg.PwdKey
|
||||
pwdiv := cfg.Pwdiv
|
||||
if channelConfig != nil && channelConfig.Channel != nil {
|
||||
if pwdKey == "" {
|
||||
pwdKey = channelConfig.Channel.AppKey
|
||||
}
|
||||
if pwdiv == "" {
|
||||
pwdiv = channelConfig.Channel.AppSecret
|
||||
}
|
||||
}
|
||||
|
||||
timeout := resolveUpstreamHTTPTimeout(ctx, templateConfigJSON(channelConfig))
|
||||
|
||||
return &sSimRecharge{
|
||||
channelConfig: channelConfig,
|
||||
client: recharge.NewClient(recharge.Config{
|
||||
BaseURL: baseURL,
|
||||
PwdKey: pwdKey,
|
||||
Pwdiv: pwdiv,
|
||||
Timeout: timeout,
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
func convertRechargeCardStatus(ctx context.Context, statusShowID int) int {
|
||||
if status, ok := rechargeCardStatusMap[statusShowID]; ok {
|
||||
return status
|
||||
}
|
||||
g.Log().Warningf(ctx, "Unknown recharge card status %d from upstream", statusShowID)
|
||||
return 0
|
||||
}
|
||||
|
||||
// 系统 operType(0停机/1复机) -> 腾宇 changeState type(1停机/0复机),语义相反,禁止透传
|
||||
func convertRechargeChangeStateType(ctx context.Context, operType string) (int, bool) {
|
||||
switch operType {
|
||||
case consts.CardStatusChangeOperTypeApplyStop:
|
||||
return 1, true
|
||||
case consts.CardStatusChangeOperTypeApplyResume:
|
||||
return 0, true
|
||||
default:
|
||||
g.Log().Warningf(ctx, "Unknown card status change operType %s for recharge upstream", operType)
|
||||
return 0, false
|
||||
}
|
||||
}
|
||||
|
||||
func (s *sSimRecharge) findCard(ctx context.Context, simKey *model.SimKey) (*rechargeFindCardResult, error) {
|
||||
card := simKey.ICCID
|
||||
if card == "" {
|
||||
card = simKey.MSISDN
|
||||
}
|
||||
card = strings.TrimSpace(card)
|
||||
if cached, ok := s.cachedCard(card); ok {
|
||||
return cached, nil
|
||||
}
|
||||
|
||||
value, err, _ := s.cardFlight.Do(card, func() (any, error) {
|
||||
if cached, ok := s.cachedCard(card); ok {
|
||||
return cached, nil
|
||||
}
|
||||
output, requestErr := s.client.DoPost(ctx, "/findCard", map[string]any{"card": card})
|
||||
if requestErr != nil {
|
||||
return nil, requestErr
|
||||
}
|
||||
if output.Code == recharge.CodeRateLimited {
|
||||
g.Log().Warningf(ctx, "recharge findCard rate limited, chanKey %s, card %s", simKey.ChanKey, card)
|
||||
}
|
||||
if output.Code != recharge.CodeSuccess {
|
||||
return nil, &recharge.BusinessError{Code: output.Code, Msg: output.Msg}
|
||||
}
|
||||
var info model.RechargeCardInfo
|
||||
if unmarshalErr := json.Unmarshal(output.Data, &info); unmarshalErr != nil {
|
||||
return nil, unmarshalErr
|
||||
}
|
||||
updatedAt := time.Now().Format("2006-01-02 15:04:05")
|
||||
s.cardCache.Store(card, rechargeCardCacheEntry{
|
||||
Info: info,
|
||||
UpdatedAt: updatedAt,
|
||||
ExpiresAt: time.Now().Add(rechargeFindCardCacheTTL),
|
||||
})
|
||||
return &rechargeFindCardResult{Info: &info, UpdatedAt: updatedAt}, nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result, ok := value.(*rechargeFindCardResult)
|
||||
if !ok || result == nil || result.Info == nil {
|
||||
return nil, gerror.New("recharge findCard returned invalid result")
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (s *sSimRecharge) cachedCard(card string) (*rechargeFindCardResult, bool) {
|
||||
value, ok := s.cardCache.Load(strings.TrimSpace(card))
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
entry, ok := value.(rechargeCardCacheEntry)
|
||||
if !ok || time.Now().After(entry.ExpiresAt) {
|
||||
s.cardCache.Delete(strings.TrimSpace(card))
|
||||
return nil, false
|
||||
}
|
||||
info := entry.Info
|
||||
return &rechargeFindCardResult{Info: &info, Cached: true, UpdatedAt: entry.UpdatedAt}, true
|
||||
}
|
||||
|
||||
func (s *sSimRecharge) invalidateCardCache(card string) {
|
||||
s.cardCache.Delete(strings.TrimSpace(card))
|
||||
}
|
||||
|
||||
func rechargeErrorMessage(err error) string {
|
||||
return err.Error()
|
||||
}
|
||||
|
||||
// QuerySimBasicInfo 单卡基本信息查询
|
||||
// findCard -> ActiveDate=activate_date, Remark 附带实名状态
|
||||
func (s *sSimRecharge) QuerySimBasicInfo(ctx context.Context, input *model.SimBasicInfoInput) (model.SimBasicInfoOutput, error) {
|
||||
items := make(model.SimBasicInfoOutput, 0, len(input.SimKeys))
|
||||
for _, simKey := range input.SimKeys {
|
||||
result, err := s.findCard(ctx, simKey)
|
||||
if err != nil {
|
||||
g.Log().Errorf(ctx, "Recharge QuerySimBasicInfo error %+v, simKey %+v", err, simKey)
|
||||
items = append(items, &model.SimBaseRes[model.SimBasicInfoItem]{
|
||||
SimKey: simKey,
|
||||
Code: consts.ApiStatusError,
|
||||
Message: rechargeErrorMessage(err),
|
||||
})
|
||||
continue
|
||||
}
|
||||
info := result.Info
|
||||
remark := "未实名"
|
||||
if info.RealName == 1 {
|
||||
remark = "已实名"
|
||||
}
|
||||
items = append(items, &model.SimBaseRes[model.SimBasicInfoItem]{
|
||||
SimKey: simKey,
|
||||
Code: consts.ApiStatusSuccess,
|
||||
Cached: result.Cached,
|
||||
UpdatedAt: result.UpdatedAt,
|
||||
ResData: model.SimBasicInfoItem{
|
||||
ActiveDate: info.ActivateDate,
|
||||
OpenDate: "",
|
||||
Remark: remark,
|
||||
},
|
||||
})
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
// QuerySimCardStatus 物联网卡卡状态查询
|
||||
// findCard.status_show_id -> 系统 cardStatus
|
||||
func (s *sSimRecharge) QuerySimCardStatus(ctx context.Context, input *model.SimCardStatusInput) (model.SimCardStatusOutput, error) {
|
||||
items := make(model.SimCardStatusOutput, 0, len(input.SimKeys))
|
||||
for _, simKey := range input.SimKeys {
|
||||
result, err := s.findCard(ctx, simKey)
|
||||
if err != nil {
|
||||
g.Log().Errorf(ctx, "Recharge QuerySimCardStatus error %+v, simKey %+v", err, simKey)
|
||||
items = append(items, &model.SimBaseRes[model.SimCardStatusItem]{
|
||||
SimKey: simKey,
|
||||
Code: consts.ApiStatusError,
|
||||
Message: rechargeErrorMessage(err),
|
||||
})
|
||||
continue
|
||||
}
|
||||
info := result.Info
|
||||
items = append(items, &model.SimBaseRes[model.SimCardStatusItem]{
|
||||
SimKey: simKey,
|
||||
Code: consts.ApiStatusSuccess,
|
||||
Cached: result.Cached,
|
||||
UpdatedAt: result.UpdatedAt,
|
||||
ResData: model.SimCardStatusItem{
|
||||
CardStatus: convertRechargeCardStatus(ctx, info.StatusShowID),
|
||||
LastChangeDate: info.ExpireTime,
|
||||
},
|
||||
})
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
// QuerySimDataUsage 单卡本月流量累计使用量查询
|
||||
// findCard -> c_used/c_total (单位 MB)
|
||||
func (s *sSimRecharge) QuerySimDataUsage(ctx context.Context, input *model.SimDataUsageInput) (model.SimDataUsageOutput, error) {
|
||||
items := make(model.SimDataUsageOutput, 0, len(input.SimKeys))
|
||||
for _, simKey := range input.SimKeys {
|
||||
result, err := s.findCard(ctx, simKey)
|
||||
if err != nil {
|
||||
g.Log().Errorf(ctx, "Recharge QuerySimDataUsage error %+v, simKey %+v", err, simKey)
|
||||
items = append(items, &model.SimBaseRes[model.SimDataUsageItem]{
|
||||
SimKey: simKey,
|
||||
Code: consts.ApiStatusError,
|
||||
Message: rechargeErrorMessage(err),
|
||||
})
|
||||
continue
|
||||
}
|
||||
info := result.Info
|
||||
totalFlow := info.TotalFlow
|
||||
items = append(items, &model.SimBaseRes[model.SimDataUsageItem]{
|
||||
SimKey: simKey,
|
||||
Code: consts.ApiStatusSuccess,
|
||||
Cached: result.Cached,
|
||||
UpdatedAt: result.UpdatedAt,
|
||||
ResData: model.SimDataUsageItem{
|
||||
DataUsage: info.UsedFlow,
|
||||
TotalFlow: &totalFlow,
|
||||
},
|
||||
})
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
// QuerySimDataDiagnosis 腾宇通道暂不支持达量限速档位查询。
|
||||
func (s *sSimRecharge) QuerySimDataDiagnosis(ctx context.Context, input *model.SimDataDiagnosisInput) (model.SimDataDiagnosisOutput, error) {
|
||||
items := make(model.SimDataDiagnosisOutput, 0)
|
||||
if input == nil {
|
||||
return items, nil
|
||||
}
|
||||
for _, simKey := range input.SimKeys {
|
||||
items = append(items, &model.SimBaseRes[any]{
|
||||
SimKey: simKey,
|
||||
Code: consts.ApiInvokeException,
|
||||
Message: rechargeUnsupportedMessage("达量限速档位查询"),
|
||||
})
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
// ChangeSimStatus 物联网卡状态变更(停复机)
|
||||
// 系统 operType(0停机/1复机) -> 腾宇 changeState type(1停机/0复机)
|
||||
func (s *sSimRecharge) ChangeSimStatus(ctx context.Context, input *model.SimCardStatusChangeInput) (model.SimCardStatusChangeOutput, error) {
|
||||
changeType, ok := convertRechargeChangeStateType(ctx, input.ReqData.OperType)
|
||||
if !ok {
|
||||
return newInvalidCardStatusChangeOutput(input.SimKeys), nil
|
||||
}
|
||||
|
||||
items := make(model.SimCardStatusChangeOutput, 0, len(input.SimKeys))
|
||||
for _, simKey := range input.SimKeys {
|
||||
card := simKey.ICCID
|
||||
if card == "" {
|
||||
card = simKey.MSISDN
|
||||
}
|
||||
output, err := s.client.DoPost(ctx, "/changeState", map[string]any{
|
||||
"card": card,
|
||||
"type": changeType,
|
||||
})
|
||||
if err != nil {
|
||||
g.Log().Errorf(ctx, "Recharge ChangeSimStatus error %+v, simKey %+v", err, simKey)
|
||||
items = append(items, &model.SimBaseRes[model.SimCardStatusChangeItem]{
|
||||
SimKey: simKey,
|
||||
Code: consts.ApiStatusError,
|
||||
Message: err.Error(),
|
||||
ResData: model.SimCardStatusChangeItem{},
|
||||
})
|
||||
continue
|
||||
}
|
||||
if output.Code != recharge.CodeSuccess {
|
||||
g.Log().Warningf(ctx, "Recharge ChangeSimStatus upstream error code %d msg %s, simKey %+v", output.Code, output.Msg, simKey)
|
||||
items = append(items, &model.SimBaseRes[model.SimCardStatusChangeItem]{
|
||||
SimKey: simKey,
|
||||
Code: consts.ApiStatusError,
|
||||
Message: output.Msg,
|
||||
ResData: model.SimCardStatusChangeItem{},
|
||||
})
|
||||
continue
|
||||
}
|
||||
s.invalidateCardCache(card)
|
||||
items = append(items, &model.SimBaseRes[model.SimCardStatusChangeItem]{
|
||||
SimKey: simKey,
|
||||
Code: consts.ApiStatusSuccess,
|
||||
Message: output.Msg,
|
||||
ResData: model.SimCardStatusChangeItem{
|
||||
OrderNum: "",
|
||||
},
|
||||
})
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
// 腾宇通道暂未支持的查询,参照 sim_dx.go 模式返回 ApiInvokeException
|
||||
func rechargeUnsupportedMessage(name string) string {
|
||||
return "腾宇通道暂不支持" + name
|
||||
}
|
||||
|
||||
func (s *sSimRecharge) QuerySimSession(ctx context.Context, input *model.SimSessionInput) (model.SimSessionOutput, error) {
|
||||
items := make(model.SimSessionOutput, 0, len(input.SimKeys))
|
||||
for _, simKey := range input.SimKeys {
|
||||
items = append(items, &model.SimBaseRes[model.SimSessionItem]{
|
||||
SimKey: simKey,
|
||||
Code: consts.ApiInvokeException,
|
||||
Message: rechargeUnsupportedMessage("在线信息查询"),
|
||||
ResData: model.SimSessionItem{Status: consts.CardSessionStatusUnknown},
|
||||
})
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
func (s *sSimRecharge) QuerySimStopReason(ctx context.Context, input *model.SimStopReasonInput) (model.SimStopReasonOutput, error) {
|
||||
items := make(model.SimStopReasonOutput, 0, len(input.SimKeys))
|
||||
for _, simKey := range input.SimKeys {
|
||||
items = append(items, &model.SimBaseRes[model.SimStopReasonItem]{
|
||||
SimKey: simKey,
|
||||
Code: consts.ApiInvokeException,
|
||||
Message: rechargeUnsupportedMessage("停机原因查询"),
|
||||
})
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
func (s *sSimRecharge) QuerySimFrozenState(ctx context.Context, input *model.SimFrozenStateInput) (model.SimFrozenStateOutput, error) {
|
||||
items := make(model.SimFrozenStateOutput, 0, len(input.SimKeys))
|
||||
for _, simKey := range input.SimKeys {
|
||||
items = append(items, &model.SimBaseRes[model.SimFrozenStateItem]{
|
||||
SimKey: simKey,
|
||||
Code: consts.ApiInvokeException,
|
||||
Message: rechargeUnsupportedMessage("冻结状态查询"),
|
||||
})
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
func (s *sSimRecharge) QuerySimCardBindStatus(ctx context.Context, input *model.SimCardBindStatusInput) (model.SimCardBindStatusOutput, error) {
|
||||
items := make(model.SimCardBindStatusOutput, 0, len(input.SimKeys))
|
||||
for _, simKey := range input.SimKeys {
|
||||
items = append(items, &model.SimBaseRes[model.SimCardBindStatusItem]{
|
||||
SimKey: simKey,
|
||||
Code: consts.ApiInvokeException,
|
||||
Message: rechargeUnsupportedMessage("机卡绑定状态查询"),
|
||||
})
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
func (s *sSimRecharge) QuerySimDeviceStatus(ctx context.Context, input *model.SimDeviceStatusInput) (model.SimDeviceStatusOutput, error) {
|
||||
items := make(model.SimDeviceStatusOutput, 0, len(input.SimKeys))
|
||||
for _, simKey := range input.SimKeys {
|
||||
items = append(items, &model.SimBaseRes[model.SimDeviceStatusItem]{
|
||||
SimKey: simKey,
|
||||
Code: consts.ApiInvokeException,
|
||||
Message: rechargeUnsupportedMessage("设备状态查询"),
|
||||
ResData: model.SimDeviceStatusItem{DeviceStatus: consts.CardSessionStatusUnknown},
|
||||
})
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
func (s *sSimRecharge) QuerySimRegionLimitStatus(ctx context.Context, input *model.SimRegionLimitStatusInput) (model.SimRegionLimitStatusOutput, error) {
|
||||
items := make(model.SimRegionLimitStatusOutput, 0, len(input.SimKeys))
|
||||
for _, simKey := range input.SimKeys {
|
||||
items = append(items, &model.SimBaseRes[model.SimRegionLimitStatusItem]{
|
||||
SimKey: simKey,
|
||||
Code: consts.ApiInvokeException,
|
||||
Message: rechargeUnsupportedMessage("地域限制状态查询"),
|
||||
})
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
func (s *sSimRecharge) QuerySimImei(ctx context.Context, input *model.SimImeiInput) (model.SimImeiOutput, error) {
|
||||
items := make(model.SimImeiOutput, 0, len(input.SimKeys))
|
||||
for _, simKey := range input.SimKeys {
|
||||
items = append(items, &model.SimBaseRes[model.SimImeiItem]{
|
||||
SimKey: simKey,
|
||||
Code: consts.ApiInvokeException,
|
||||
Message: rechargeUnsupportedMessage("IMEI查询"),
|
||||
})
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
func (s *sSimRecharge) QuerySimDataUsageMonthly(ctx context.Context, input *model.SimDataUsageMonthlyInput) (model.SimDataUsageMonthlyOutput, error) {
|
||||
items := make(model.SimDataUsageMonthlyOutput, 0, len(input.SimKeys))
|
||||
for _, simKey := range input.SimKeys {
|
||||
items = append(items, &model.SimBaseRes[model.SimDataUsageMonthlyOutputItem]{
|
||||
SimKey: simKey,
|
||||
Code: consts.ApiInvokeException,
|
||||
Message: rechargeUnsupportedMessage("单月流量查询"),
|
||||
})
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
func (s *sSimRecharge) QuerySimDataUsageDaily(ctx context.Context, input *model.SimDataUsageDailyInput) (model.SimDataUsageDailyOutput, error) {
|
||||
items := make(model.SimDataUsageDailyOutput, 0, len(input.SimKeys))
|
||||
for _, simKey := range input.SimKeys {
|
||||
items = append(items, &model.SimBaseRes[model.SimDataUsageDailyOutputItem]{
|
||||
SimKey: simKey,
|
||||
Code: consts.ApiInvokeException,
|
||||
Message: rechargeUnsupportedMessage("单日流量查询"),
|
||||
})
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
func (s *sSimRecharge) QuerySimApnInfo(ctx context.Context, input *model.SimApnInfoInput) (model.SimApnInfoOutput, error) {
|
||||
items := make(model.SimApnInfoOutput, 0, len(input.SimKeys))
|
||||
for _, simKey := range input.SimKeys {
|
||||
items = append(items, &model.SimBaseRes[model.SimApnInfoItemList]{
|
||||
SimKey: simKey,
|
||||
Code: consts.ApiInvokeException,
|
||||
Message: rechargeUnsupportedMessage("APN信息查询"),
|
||||
ResData: model.SimApnInfoItemList{ApnList: make([]model.SimApnInfoItem, 0)},
|
||||
})
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
// ChangeSimApnFunction 腾宇无 APN 开关语义(changeNet 是整卡开断网,不等价),不做映射
|
||||
func (s *sSimRecharge) ChangeSimApnFunction(ctx context.Context, input *model.SimCardOperateChangeApnInput) (model.SimCardOperateChangeApnOutput, error) {
|
||||
items := make(model.SimCardOperateChangeApnOutput, 0, len(input.SimKeys))
|
||||
for _, simKey := range input.SimKeys {
|
||||
items = append(items, &model.SimBaseRes[model.SimCardOperateChangeApnItem]{
|
||||
SimKey: simKey,
|
||||
Code: consts.ApiInvokeException,
|
||||
Message: rechargeUnsupportedMessage("APN功能开停"),
|
||||
ResData: model.SimCardOperateChangeApnItem{},
|
||||
})
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package upstreamimpl
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/logic/upstreamimpl/recharge"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/model"
|
||||
)
|
||||
|
||||
func TestRechargeFindCardCacheIsSharedAcrossDiagnosisQueries(t *testing.T) {
|
||||
var calls atomic.Int32
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
calls.Add(1)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"code":200,"msg":"ok","data":{"w_real_name":1,"c_no":"8986000000000000000","status_show_id":4,"c_total":1024,"c_used":128,"c_left":896,"prodinstexp_time":"2026-12-31","activate_date":"2026-01-01"}}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
sim := &sSimRecharge{client: recharge.NewClient(recharge.Config{
|
||||
BaseURL: server.URL,
|
||||
PwdKey: "test",
|
||||
Pwdiv: "secret",
|
||||
Timeout: time.Second,
|
||||
})}
|
||||
simKey := &model.SimKey{ICCID: "8986000000000000000"}
|
||||
|
||||
basic, err := sim.QuerySimBasicInfo(context.Background(), &model.SimBasicInfoInput{SimKeys: []*model.SimKey{simKey}})
|
||||
if err != nil || len(basic) != 1 || basic[0].Code != 0 {
|
||||
t.Fatalf("basic query failed: output=%#v err=%v", basic, err)
|
||||
}
|
||||
status, err := sim.QuerySimCardStatus(context.Background(), &model.SimCardStatusInput{SimKeys: []*model.SimKey{simKey}})
|
||||
if err != nil || len(status) != 1 || status[0].Code != 0 {
|
||||
t.Fatalf("status query failed: output=%#v err=%v", status, err)
|
||||
}
|
||||
if calls.Load() != 1 {
|
||||
t.Fatalf("findCard upstream calls = %d, want 1", calls.Load())
|
||||
}
|
||||
if !status[0].Cached || status[0].UpdatedAt == "" {
|
||||
t.Fatalf("cached metadata missing: %#v", status[0])
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,94 @@
|
||||
package upstreamimpl
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/model"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/model/entity"
|
||||
)
|
||||
|
||||
func TestTibiotRateLimiterSpacesRequestsAndHonorsCancellation(t *testing.T) {
|
||||
limiter := newTibiotRateLimiter(25 * time.Millisecond)
|
||||
if err := limiter.wait(context.Background()); err != nil {
|
||||
t.Fatalf("first wait failed: %v", err)
|
||||
}
|
||||
|
||||
started := time.Now()
|
||||
if err := limiter.wait(context.Background()); err != nil {
|
||||
t.Fatalf("second wait failed: %v", err)
|
||||
}
|
||||
if elapsed := time.Since(started); elapsed < 20*time.Millisecond {
|
||||
t.Fatalf("second slot was granted too early: %s", elapsed)
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Millisecond)
|
||||
defer cancel()
|
||||
reservedBeforeCancel := limiter.next
|
||||
if err := limiter.wait(ctx); err == nil {
|
||||
t.Fatal("expected cancellation while waiting for a slot")
|
||||
}
|
||||
if !limiter.next.Equal(reservedBeforeCancel) {
|
||||
t.Fatalf("canceled waiter reserved a slot: before=%s after=%s", reservedBeforeCancel, limiter.next)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTibiotQuerySimDataUsageRetriesOnceAfterUpstreamFailure(t *testing.T) {
|
||||
previousLimiter := tibiotSimDataMarginLimiter
|
||||
tibiotSimDataMarginLimiter = newTibiotRateLimiter(time.Millisecond)
|
||||
defer func() { tibiotSimDataMarginLimiter = previousLimiter }()
|
||||
|
||||
var calls atomic.Int32
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
var body map[string]any
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
t.Fatalf("decode request: %v", err)
|
||||
}
|
||||
if body["flowUnit"] != "MB" {
|
||||
t.Fatalf("flowUnit missing: %#v", body)
|
||||
}
|
||||
if calls.Add(1) == 1 {
|
||||
_, _ = w.Write([]byte(`{"status":10049,"message":"too many requests","data":null}`))
|
||||
return
|
||||
}
|
||||
_, _ = w.Write([]byte(`{"status":0,"message":"ok","data":"2MB"}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := NewSimTibiot().NewTemp(context.Background(), &model.SimChannelConfigItemCache{
|
||||
Template: &entity.TSimChannelTemplate{
|
||||
TempConfig: `{"api":{"baseUrl":"` + server.URL + `"},"http":{"timeout":"2s"}}`,
|
||||
},
|
||||
Channel: &entity.TSimChannels{ChanConfig: `{"userName":"tester","password":"secret"}`},
|
||||
}).(*sSimTibiot)
|
||||
items, err := client.QuerySimDataUsage(context.Background(), &model.SimDataUsageInput{
|
||||
SimKeys: []*model.SimKey{{ICCID: "8986000000000000000"}},
|
||||
})
|
||||
if err != nil || len(items) != 1 {
|
||||
t.Fatalf("unexpected query result: items=%#v err=%v", items, err)
|
||||
}
|
||||
if calls.Load() != 2 {
|
||||
t.Fatalf("upstream calls = %d, want one retry", calls.Load())
|
||||
}
|
||||
if items[0].Code != 0 || items[0].ResData.DataUsage != 2 {
|
||||
t.Fatalf("unexpected successful retry result: %#v", items[0])
|
||||
}
|
||||
|
||||
cachedItems, err := client.QuerySimDataUsage(context.Background(), &model.SimDataUsageInput{
|
||||
SimKeys: []*model.SimKey{{ICCID: "8986000000000000000"}},
|
||||
})
|
||||
if err != nil || len(cachedItems) != 1 {
|
||||
t.Fatalf("unexpected cached query result: items=%#v err=%v", cachedItems, err)
|
||||
}
|
||||
if calls.Load() != 2 {
|
||||
t.Fatalf("cached query called upstream again: calls=%d", calls.Load())
|
||||
}
|
||||
if !cachedItems[0].Cached || cachedItems[0].UpdatedAt == "" {
|
||||
t.Fatalf("cached query metadata missing: %#v", cachedItems[0])
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,381 @@
|
||||
package upstreamimpl
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/consts"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/model"
|
||||
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
"github.com/gogf/gf/v2/util/gconv"
|
||||
)
|
||||
|
||||
// 移动 OneLink「通信功能开停」接口(2026 接口改版后,替代原 ec/operate/sim-apn-function)。
|
||||
//
|
||||
// 7.2.1 单卡(CMIOT_API25M19) GET /v5/ec/manage/sim-communication-service
|
||||
// functionType=01(APN上网功能),businessType:0=开 1=停,APN 名称字段为 apn
|
||||
// 7.2.2 批量(CMIOT_API23M09) GET /v5/ec/operate/sim-communication-function/batch
|
||||
// serviceType=11(数据通信服务),operType:0=暂停(停) 1=恢复(开),APN 名称字段为 apnName
|
||||
//
|
||||
// 分流策略(与产品约定):
|
||||
//
|
||||
// 卡数 <10 逐张走 7.2.1
|
||||
// 10~100 一次走 7.2.2
|
||||
// >100 按 100 拆块,不足 10 的尾块退回 7.2.1
|
||||
//
|
||||
// 批量接口 7.2.2 只认 msisdn,缺号码的卡降级走 7.2.1(用 iccid)。
|
||||
// 两个接口同卡同类型均有限流(3 分钟一次),且 7.2.2 批量中任一张被限流则整批失败(status 13009)。
|
||||
const (
|
||||
ydCommunicationServicePath = "ec/manage/sim-communication-service"
|
||||
ydCommunicationFunctionBatchPath = "ec/operate/sim-communication-function/batch"
|
||||
ydCommunicationBatchResultPath = "ec/query/sim-batch-result" // 5.1.2 CMIOT_API23M10 批量办理结果查询
|
||||
ydCommunicationFunctionTypeApn = "01" // 7.2.1 functionType:APN 上网功能
|
||||
ydCommunicationServiceTypeData = "11" // 7.2.2 serviceType:数据通信服务
|
||||
ydCommunicationBatchMin = 10 // 7.2.2 每批最少卡数
|
||||
ydCommunicationBatchMax = 100 // 7.2.2 每批最多卡数
|
||||
)
|
||||
|
||||
// 批量办理任务状态(5.1.2 sim-batch-result 返回的 jobStatus)。
|
||||
const (
|
||||
ydJobStatusDone = "2" // 处理完成(全部成功)
|
||||
ydJobStatusDonePartial = "3" // 处理完成(含失败记录)
|
||||
ydJobStatusFailed = "4" // 处理失败
|
||||
)
|
||||
|
||||
// ydBatchResultQueryFailedMessage 是批量异步任务结果查询失败(轮询超时 / 查询请求失败)时,
|
||||
// 逐卡写入执行日志明细的失败原因:该子批次下每张卡都记这一条,代表"结果未确认"而非"办理失败"。
|
||||
const ydBatchResultQueryFailedMessage = "查询上游通信结果失败,未取得最终办理结果,请稍后核对"
|
||||
|
||||
// 批量结果轮询参数;声明为变量以便单测缩短周期。
|
||||
//
|
||||
// 两个值均维持原值,原因(实测:上游批量任务约 60s 出终态):
|
||||
// - 间隔只决定终态被捕获的延迟(0~interval)。整批 2 万张 = 200 子批 / 并发 5 = 40 波,
|
||||
// 总等待由"每波等上游 job 完成"主导,缩间隔对总时长影响可忽略,却成倍增加查询接口压力。
|
||||
// - 上限若下调到 60s 会踩在上游耗时边界上,超时即把整个子批 100 张记为失败,而失败结果
|
||||
// 不回写本地 data_service_status,会造成平台状态与卡真实状态不一致。
|
||||
//
|
||||
// 真正压缩总等待应把"提交"与"结果查询"分离(全部子批先拿 jobId,再统一并发轮询),
|
||||
// 使总等待从 40 × 上限 降到 ≈ 1 × 上限,而不是靠缩短这里的超时。
|
||||
var (
|
||||
ydBatchResultPollInterval = 5 * time.Second
|
||||
ydBatchResultPollTimeout = 180 * time.Second
|
||||
)
|
||||
|
||||
// ydApnRequester 抽出移动通道发起请求所需的最小能力,供真实通道(sSimYD)与 mock 通道(sSimYDMock)
|
||||
// 共用同一套开停逻辑,避免两份实现走偏。
|
||||
type ydApnRequester interface {
|
||||
getUrl(ctx context.Context, serviceUrl string) string
|
||||
getTransId(ctx context.Context) string
|
||||
requestYd(ctx context.Context, operation, url string, params g.Map) (*ydHTTPResponse, error)
|
||||
}
|
||||
|
||||
// changeSimApnFunctionYD 平台「开通数据(dataStart)/停用数据(dataStop)/达量断网(dataCut)」在移动侧的统一入口。
|
||||
func changeSimApnFunctionYD(ctx context.Context, c ydApnRequester, input *model.SimCardOperateChangeApnInput) (model.SimCardOperateChangeApnOutput, error) {
|
||||
items := make(model.SimCardOperateChangeApnOutput, 0, len(input.SimKeys))
|
||||
|
||||
// 缺 msisdn 的卡不能进批量接口,先分流出来单独走单卡。
|
||||
withMsisdn := make([]*model.SimKey, 0, len(input.SimKeys))
|
||||
singleKeys := make([]*model.SimKey, 0)
|
||||
for _, simKey := range input.SimKeys {
|
||||
if simKey == nil {
|
||||
continue
|
||||
}
|
||||
if strings.TrimSpace(simKey.MSISDN) == "" {
|
||||
singleKeys = append(singleKeys, simKey)
|
||||
continue
|
||||
}
|
||||
withMsisdn = append(withMsisdn, simKey)
|
||||
}
|
||||
|
||||
for start := 0; start < len(withMsisdn); start += ydCommunicationBatchMax {
|
||||
end := start + ydCommunicationBatchMax
|
||||
if end > len(withMsisdn) {
|
||||
end = len(withMsisdn)
|
||||
}
|
||||
chunk := withMsisdn[start:end]
|
||||
if len(chunk) < ydCommunicationBatchMin {
|
||||
singleKeys = append(singleKeys, chunk...)
|
||||
continue
|
||||
}
|
||||
items = append(items, changeApnBatchYD(ctx, c, chunk, input.ReqData)...)
|
||||
}
|
||||
|
||||
items = append(items, changeApnSingleYD(ctx, c, singleKeys, input.ReqData)...)
|
||||
return items, nil
|
||||
}
|
||||
|
||||
// changeApnSingleYD 逐张调用 7.2.1 单卡接口办理。
|
||||
func changeApnSingleYD(ctx context.Context, c ydApnRequester, simKeys []*model.SimKey, req model.SimCardOperateChangeApnInputItem) model.SimCardOperateChangeApnOutput {
|
||||
items := make(model.SimCardOperateChangeApnOutput, 0, len(simKeys))
|
||||
if len(simKeys) == 0 {
|
||||
return items
|
||||
}
|
||||
|
||||
businessType, ok := convertYdCommunicationBusinessType(ctx, req.OperType)
|
||||
if !ok {
|
||||
for _, simKey := range simKeys {
|
||||
items = append(items, ydApnErrorItem(simKey, "unsupported operType, expected 0(open) or 1(stop)"))
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
url := c.getUrl(ctx, ydCommunicationServicePath)
|
||||
// {"status":"0","message":"正确","result":[{"iccid":"898602B2221430000005","orderNum":"xxxxxxx"}]}
|
||||
type ydSingleResp struct {
|
||||
Status string `json:"status"`
|
||||
Message string `json:"message"`
|
||||
Result []struct {
|
||||
OrderNum string `json:"orderNum"`
|
||||
} `json:"result"`
|
||||
}
|
||||
|
||||
for _, simKey := range simKeys {
|
||||
params := g.Map{
|
||||
"transid": c.getTransId(ctx),
|
||||
"functionType": ydCommunicationFunctionTypeApn,
|
||||
"businessType": businessType,
|
||||
"apn": req.ApnName,
|
||||
}
|
||||
// 卡号三选一,必须有且只有一项:优先 iccid,其次 msisdn,最后 imsi。
|
||||
switch {
|
||||
case strings.TrimSpace(simKey.ICCID) != "":
|
||||
params["iccid"] = strings.TrimSpace(simKey.ICCID)
|
||||
case strings.TrimSpace(simKey.MSISDN) != "":
|
||||
params["msisdn"] = strings.TrimSpace(simKey.MSISDN)
|
||||
case strings.TrimSpace(simKey.IMSI) != "":
|
||||
params["imsi"] = strings.TrimSpace(simKey.IMSI)
|
||||
default:
|
||||
g.Log().Warningf(ctx, "changeApnSingleYD missing iccid/msisdn/imsi, simKey %+v", simKey)
|
||||
items = append(items, ydApnErrorItem(simKey, "missing iccid/msisdn/imsi"))
|
||||
continue
|
||||
}
|
||||
|
||||
resp, err := c.requestYd(ctx, "ChangeSimApnFunction", url, params)
|
||||
if err != nil {
|
||||
g.Log().Errorf(ctx, "changeApnSingleYD request error %+v, url %s, params %+v", err, url, params)
|
||||
items = append(items, ydApnErrorItem(simKey, err.Error()))
|
||||
continue
|
||||
}
|
||||
body := resp.ReadAllString()
|
||||
var ydResp ydSingleResp
|
||||
if err = json.Unmarshal([]byte(body), &ydResp); err != nil {
|
||||
g.Log().Errorf(ctx, "changeApnSingleYD unmarshal error %+v, url %s, params %+v, body %s", err, url, params, body)
|
||||
items = append(items, ydApnErrorItem(simKey, err.Error()))
|
||||
continue
|
||||
}
|
||||
if ydResp.Status != "0" {
|
||||
g.Log().Warningf(ctx, "changeApnSingleYD upstream error %s, url %s, params %+v, body %s", ydResp.Message, url, params, body)
|
||||
items = append(items, ydApnResultItem(simKey, gconv.Int(ydResp.Status), ydResp.Message, ""))
|
||||
continue
|
||||
}
|
||||
orderNum := ""
|
||||
if len(ydResp.Result) > 0 {
|
||||
orderNum = ydResp.Result[0].OrderNum
|
||||
}
|
||||
items = append(items, ydApnResultItem(simKey, 0, ydResp.Message, orderNum))
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
// changeApnBatchYD 用一批(10~100 张)调用 7.2.2 批量接口一次办理。
|
||||
// 该接口异步返回 jobId,status=0 即视为本批全部受理成功,逐卡回填同一个 jobId。
|
||||
func changeApnBatchYD(ctx context.Context, c ydApnRequester, simKeys []*model.SimKey, req model.SimCardOperateChangeApnInputItem) model.SimCardOperateChangeApnOutput {
|
||||
items := make(model.SimCardOperateChangeApnOutput, 0, len(simKeys))
|
||||
|
||||
operType, ok := convertYdCommunicationOperType(ctx, req.OperType)
|
||||
if !ok {
|
||||
for _, simKey := range simKeys {
|
||||
items = append(items, ydApnErrorItem(simKey, "unsupported operType, expected 0(open) or 1(stop)"))
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
msisdns := make([]string, 0, len(simKeys))
|
||||
for _, simKey := range simKeys {
|
||||
msisdns = append(msisdns, strings.TrimSpace(simKey.MSISDN))
|
||||
}
|
||||
|
||||
url := c.getUrl(ctx, ydCommunicationFunctionBatchPath)
|
||||
params := g.Map{
|
||||
"transid": c.getTransId(ctx),
|
||||
"msisdns": strings.Join(msisdns, "_"),
|
||||
"serviceType": ydCommunicationServiceTypeData,
|
||||
"operType": operType,
|
||||
"apnName": req.ApnName,
|
||||
}
|
||||
// {"status":"0","message":"正确","result":[{"jobId":"xxxxxxx"}]}
|
||||
type ydBatchResp struct {
|
||||
Status string `json:"status"`
|
||||
Message string `json:"message"`
|
||||
Result []struct {
|
||||
JobId string `json:"jobId"`
|
||||
} `json:"result"`
|
||||
}
|
||||
|
||||
resp, err := c.requestYd(ctx, "ChangeSimApnFunction", url, params)
|
||||
if err != nil {
|
||||
g.Log().Errorf(ctx, "changeApnBatchYD request error %+v, url %s, params %+v", err, url, params)
|
||||
for _, simKey := range simKeys {
|
||||
items = append(items, ydApnErrorItem(simKey, err.Error()))
|
||||
}
|
||||
return items
|
||||
}
|
||||
body := resp.ReadAllString()
|
||||
var ydResp ydBatchResp
|
||||
if err = json.Unmarshal([]byte(body), &ydResp); err != nil {
|
||||
g.Log().Errorf(ctx, "changeApnBatchYD unmarshal error %+v, url %s, params %+v, body %s", err, url, params, body)
|
||||
for _, simKey := range simKeys {
|
||||
items = append(items, ydApnErrorItem(simKey, err.Error()))
|
||||
}
|
||||
return items
|
||||
}
|
||||
if ydResp.Status != "0" {
|
||||
g.Log().Warningf(ctx, "changeApnBatchYD upstream error %s, url %s, params %+v, body %s", ydResp.Message, url, params, body)
|
||||
for _, simKey := range simKeys {
|
||||
items = append(items, ydApnResultItem(simKey, gconv.Int(ydResp.Status), ydResp.Message, ""))
|
||||
}
|
||||
return items
|
||||
}
|
||||
jobId := ""
|
||||
if len(ydResp.Result) > 0 {
|
||||
jobId = ydResp.Result[0].JobId
|
||||
}
|
||||
if jobId == "" {
|
||||
g.Log().Warningf(ctx, "changeApnBatchYD accepted without jobId, url %s, params %+v, body %s", url, params, body)
|
||||
for _, simKey := range simKeys {
|
||||
items = append(items, ydApnResultItem(simKey, consts.ApiStatusError, "上游受理成功但未返回 jobId", ""))
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
// 7.2.2 是异步接口:受理(status=0)不等于办理成功。必须轮询结果接口拿到每张卡的最终结果,
|
||||
// 否则执行日志会把"部分失败"的批次整批误记为成功。
|
||||
result := queryBatchResultYD(ctx, c, jobId)
|
||||
switch {
|
||||
case result.finished && result.failed:
|
||||
for _, simKey := range simKeys {
|
||||
items = append(items, ydApnResultItem(simKey, consts.ApiStatusError, "上游批量任务处理失败", jobId))
|
||||
}
|
||||
case result.finished:
|
||||
for _, simKey := range simKeys {
|
||||
card, exist := result.cards[strings.TrimSpace(simKey.MSISDN)]
|
||||
if !exist {
|
||||
items = append(items, ydApnResultItem(simKey, consts.ApiStatusError, "上游批量结果未返回该卡", jobId))
|
||||
continue
|
||||
}
|
||||
items = append(items, ydApnResultItem(simKey, card.code, card.message, jobId))
|
||||
}
|
||||
default:
|
||||
// 轮询超时或查询失败:该子批下每张卡都按"查询上游通信结果失败"落库,避免误报成功。
|
||||
// 该文案会逐卡写进执行日志明细;日后若增加"重新查询上游结果"的补偿能力,从这一层补做即可。
|
||||
g.Log().Warningf(ctx, "changeApnBatchYD result poll not finished, jobId %s, params %+v", jobId, params)
|
||||
for _, simKey := range simKeys {
|
||||
items = append(items, ydApnResultItem(simKey, consts.ApiStatusError, ydBatchResultQueryFailedMessage, jobId))
|
||||
}
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
// ydBatchCardResult 批量任务中单张卡的最终办理结果。
|
||||
type ydBatchCardResult struct {
|
||||
code int
|
||||
message string
|
||||
}
|
||||
|
||||
// ydBatchQueryResult 批量办理结果查询的结论。
|
||||
type ydBatchQueryResult struct {
|
||||
finished bool // 是否已取得终态(jobStatus 2/3/4)
|
||||
failed bool // jobStatus=4,整批处理失败
|
||||
cards map[string]ydBatchCardResult // resultId(msisdn)-> 单卡结果
|
||||
}
|
||||
|
||||
// ydBatchResultItem 5.1.2 sim-batch-result 的逐卡结果项。
|
||||
type ydBatchResultItem struct {
|
||||
Status string `json:"status"`
|
||||
Message string `json:"message"`
|
||||
ResultType string `json:"resultType"`
|
||||
ResultId string `json:"resultId"`
|
||||
}
|
||||
|
||||
// ydBatchResultResp 5.1.2 sim-batch-result 的响应体。
|
||||
type ydBatchResultResp struct {
|
||||
Status string `json:"status"`
|
||||
Message string `json:"message"`
|
||||
Result []struct {
|
||||
JobStatus string `json:"jobStatus"`
|
||||
ResultList []ydBatchResultItem `json:"resultList"`
|
||||
} `json:"result"`
|
||||
}
|
||||
|
||||
// queryBatchResultYD 轮询 7.2.2 批量任务结果,直到取得终态或超时。
|
||||
// 超时 / 请求失败均返回 finished=false,由调用方按"未确认"处理。
|
||||
func queryBatchResultYD(ctx context.Context, c ydApnRequester, jobId string) ydBatchQueryResult {
|
||||
url := c.getUrl(ctx, ydCommunicationBatchResultPath)
|
||||
deadline := time.Now().Add(ydBatchResultPollTimeout)
|
||||
for {
|
||||
params := g.Map{
|
||||
"transid": c.getTransId(ctx),
|
||||
"jobId": jobId,
|
||||
}
|
||||
resp, err := c.requestYd(ctx, "QuerySimBatchResult", url, params)
|
||||
if err != nil {
|
||||
g.Log().Warningf(ctx, "queryBatchResultYD request error %+v, jobId %s", err, jobId)
|
||||
} else {
|
||||
body := resp.ReadAllString()
|
||||
var parsed ydBatchResultResp
|
||||
if jsonErr := json.Unmarshal([]byte(body), &parsed); jsonErr != nil {
|
||||
g.Log().Warningf(ctx, "queryBatchResultYD unmarshal error %+v, jobId %s, body %s", jsonErr, jobId, body)
|
||||
} else if parsed.Status != "0" {
|
||||
g.Log().Warningf(ctx, "queryBatchResultYD upstream error %s, jobId %s, body %s", parsed.Message, jobId, body)
|
||||
} else if len(parsed.Result) > 0 {
|
||||
switch parsed.Result[0].JobStatus {
|
||||
case ydJobStatusDone, ydJobStatusDonePartial:
|
||||
return ydBatchQueryResult{finished: true, cards: toYdBatchCardMap(parsed.Result[0].ResultList)}
|
||||
case ydJobStatusFailed:
|
||||
return ydBatchQueryResult{finished: true, failed: true}
|
||||
}
|
||||
}
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
return ydBatchQueryResult{}
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ydBatchQueryResult{}
|
||||
case <-time.After(ydBatchResultPollInterval):
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// toYdBatchCardMap 把逐卡结果按 resultId(msisdn)建索引。
|
||||
func toYdBatchCardMap(list []ydBatchResultItem) map[string]ydBatchCardResult {
|
||||
cards := make(map[string]ydBatchCardResult, len(list))
|
||||
for _, item := range list {
|
||||
id := strings.TrimSpace(item.ResultId)
|
||||
if id == "" {
|
||||
continue
|
||||
}
|
||||
cards[id] = ydBatchCardResult{code: gconv.Int(item.Status), message: item.Message}
|
||||
}
|
||||
return cards
|
||||
}
|
||||
|
||||
func ydApnErrorItem(simKey *model.SimKey, message string) *model.SimBaseRes[model.SimCardOperateChangeApnItem] {
|
||||
return &model.SimBaseRes[model.SimCardOperateChangeApnItem]{
|
||||
SimKey: simKey,
|
||||
Code: consts.ApiStatusError,
|
||||
Message: message,
|
||||
}
|
||||
}
|
||||
|
||||
func ydApnResultItem(simKey *model.SimKey, code int, message, orderNum string) *model.SimBaseRes[model.SimCardOperateChangeApnItem] {
|
||||
return &model.SimBaseRes[model.SimCardOperateChangeApnItem]{
|
||||
SimKey: simKey,
|
||||
Code: code,
|
||||
Message: message,
|
||||
ResData: model.SimCardOperateChangeApnItem{OrderNum: orderNum},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,366 @@
|
||||
package upstreamimpl
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/model"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/model/entity"
|
||||
)
|
||||
|
||||
func TestYdCommunicationBusinessType(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
in string
|
||||
want string
|
||||
valid bool
|
||||
}{
|
||||
{"0", "0", true}, // 平台 开 -> 7.2.1 businessType 开(同向)
|
||||
{"1", "1", true}, // 平台 停 -> 7.2.1 businessType 停
|
||||
{"", "", false},
|
||||
{"2", "", false},
|
||||
} {
|
||||
got, ok := convertYdCommunicationBusinessType(context.Background(), tc.in)
|
||||
if got != tc.want || ok != tc.valid {
|
||||
t.Errorf("businessType(%q) = (%q,%v), want (%q,%v)", tc.in, got, ok, tc.want, tc.valid)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestYdCommunicationOperType(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
in string
|
||||
want string
|
||||
valid bool
|
||||
}{
|
||||
{"0", "1", true}, // 平台 开 -> 7.2.2 恢复(翻转)
|
||||
{"1", "0", true}, // 平台 停 -> 7.2.2 暂停
|
||||
{"", "", false},
|
||||
{"9", "", false},
|
||||
} {
|
||||
got, ok := convertYdCommunicationOperType(context.Background(), tc.in)
|
||||
if got != tc.want || ok != tc.valid {
|
||||
t.Errorf("operType(%q) = (%q,%v), want (%q,%v)", tc.in, got, ok, tc.want, tc.valid)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type ydApnCapture struct {
|
||||
paths []string
|
||||
query []url.Values
|
||||
msisdns string
|
||||
}
|
||||
|
||||
// shortenYdBatchPoll 把批量结果轮询周期压缩到毫秒级,返回恢复函数。
|
||||
func shortenYdBatchPoll(timeout time.Duration) func() {
|
||||
oldInterval, oldTimeout := ydBatchResultPollInterval, ydBatchResultPollTimeout
|
||||
ydBatchResultPollInterval, ydBatchResultPollTimeout = time.Millisecond, timeout
|
||||
return func() { ydBatchResultPollInterval, ydBatchResultPollTimeout = oldInterval, oldTimeout }
|
||||
}
|
||||
|
||||
func newYdApnChannelWithServer(server *httptest.Server) *sSimYD {
|
||||
return &sSimYD{
|
||||
channelConfig: &model.SimChannelConfigItemCache{Channel: &entity.TSimChannels{ChanKey: "test", AppKey: "test"}},
|
||||
tokenProvider: diagnosisTestToken{},
|
||||
apiConfig: &oneLinkAPIConfig{BaseURL: server.URL + "/v5"},
|
||||
}
|
||||
}
|
||||
|
||||
func newYdApnTestChannel(capture *ydApnCapture) (*sSimYD, func()) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
capture.paths = append(capture.paths, r.URL.Path)
|
||||
capture.query = append(capture.query, r.URL.Query())
|
||||
switch {
|
||||
case strings.Contains(r.URL.Path, "sim-communication-function/batch"):
|
||||
capture.msisdns = r.URL.Query().Get("msisdns")
|
||||
_, _ = w.Write([]byte(`{"status":"0","message":"正确","result":[{"jobId":"JOB1"}]}`))
|
||||
case strings.Contains(r.URL.Path, "sim-batch-result"):
|
||||
_, _ = w.Write([]byte(ydBatchResultAllSuccessBody(capture.msisdns)))
|
||||
default:
|
||||
_, _ = w.Write([]byte(`{"status":"0","message":"正确","result":[{"orderNum":"ORD1"}]}`))
|
||||
}
|
||||
}))
|
||||
return newYdApnChannelWithServer(server), server.Close
|
||||
}
|
||||
|
||||
// ydBatchResultAllSuccessBody 依据批量请求的 msisdns 拼一个"全部成功"的结果查询响应。
|
||||
func ydBatchResultAllSuccessBody(msisdns string) string {
|
||||
items := make([]string, 0)
|
||||
for _, id := range strings.Split(msisdns, "_") {
|
||||
id = strings.TrimSpace(id)
|
||||
if id == "" {
|
||||
continue
|
||||
}
|
||||
items = append(items, fmt.Sprintf(`{"status":"0","message":"正确","resultType":"1","resultId":%q}`, id))
|
||||
}
|
||||
return fmt.Sprintf(`{"status":"0","message":"正确","result":[{"jobStatus":"2","resultList":[%s]}]}`, strings.Join(items, ","))
|
||||
}
|
||||
|
||||
func ydSimKeysWithMsisdn(n int) []*model.SimKey {
|
||||
keys := make([]*model.SimKey, 0, n)
|
||||
for i := 0; i < n; i++ {
|
||||
keys = append(keys, &model.SimKey{
|
||||
ICCID: fmt.Sprintf("898604F41523911%05d", i),
|
||||
MSISDN: fmt.Sprintf("14415436%05d", i),
|
||||
IMSI: fmt.Sprintf("46008743601%05d", i),
|
||||
})
|
||||
}
|
||||
return keys
|
||||
}
|
||||
|
||||
func ydApnCounts(paths []string) (batch, single, query int) {
|
||||
for _, p := range paths {
|
||||
switch {
|
||||
case strings.Contains(p, "sim-communication-function/batch"):
|
||||
batch++
|
||||
case strings.Contains(p, "sim-batch-result"):
|
||||
query++
|
||||
default:
|
||||
single++
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// 少于 10 张:逐张走 7.2.1 单卡。
|
||||
func TestYdApnChangeSmallBatchUsesSingle(t *testing.T) {
|
||||
defer shortenYdBatchPoll(2 * time.Second)()
|
||||
capture := &ydApnCapture{}
|
||||
sim, closeFn := newYdApnTestChannel(capture)
|
||||
defer closeFn()
|
||||
|
||||
out, err := sim.ChangeSimApnFunction(context.Background(), &model.SimCardOperateChangeApnInput{
|
||||
SimKeys: ydSimKeysWithMsisdn(5),
|
||||
ReqData: model.SimCardOperateChangeApnInputItem{OperType: "0", ApnName: "CMIOT"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(capture.paths) != 5 {
|
||||
t.Fatalf("expected 5 single calls, got %d (%v)", len(capture.paths), capture.paths)
|
||||
}
|
||||
for i, p := range capture.paths {
|
||||
if p != "/v5/ec/manage/sim-communication-service" {
|
||||
t.Fatalf("call %d unexpected path %s", i, p)
|
||||
}
|
||||
q := capture.query[i]
|
||||
if q.Get("functionType") != "01" || q.Get("businessType") != "0" || q.Get("apn") != "CMIOT" {
|
||||
t.Fatalf("call %d unexpected query %v", i, q)
|
||||
}
|
||||
if q.Get("iccid") == "" || q.Get("transid") == "" {
|
||||
t.Fatalf("call %d missing iccid/transid: %v", i, q)
|
||||
}
|
||||
}
|
||||
if len(out) != 5 || out[0].Code != 0 || out[0].ResData.OrderNum != "ORD1" {
|
||||
t.Fatalf("unexpected output %+v", out)
|
||||
}
|
||||
}
|
||||
|
||||
// 10~100 张:一次走 7.2.2 批量,open 翻成 operType=1;受理后必须回查结果。
|
||||
func TestYdApnChangeTenToHundredUsesBatch(t *testing.T) {
|
||||
defer shortenYdBatchPoll(2 * time.Second)()
|
||||
for _, n := range []int{10, 100} {
|
||||
capture := &ydApnCapture{}
|
||||
sim, closeFn := newYdApnTestChannel(capture)
|
||||
|
||||
out, err := sim.ChangeSimApnFunction(context.Background(), &model.SimCardOperateChangeApnInput{
|
||||
SimKeys: ydSimKeysWithMsisdn(n),
|
||||
ReqData: model.SimCardOperateChangeApnInputItem{OperType: "0", ApnName: "CMIOT"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
batch, single, query := ydApnCounts(capture.paths)
|
||||
if batch != 1 || single != 0 || query == 0 {
|
||||
t.Fatalf("n=%d expected 1 batch + result query, got batch=%d single=%d query=%d (%v)", n, batch, single, query, capture.paths)
|
||||
}
|
||||
q := capture.query[0]
|
||||
if q.Get("serviceType") != "11" || q.Get("operType") != "1" || q.Get("apnName") != "CMIOT" {
|
||||
t.Fatalf("n=%d unexpected batch query %v", n, q)
|
||||
}
|
||||
if got := len(strings.Split(q.Get("msisdns"), "_")); got != n {
|
||||
t.Fatalf("n=%d msisdns count = %d", n, got)
|
||||
}
|
||||
if len(out) != n || out[0].Code != 0 || out[0].ResData.OrderNum != "JOB1" {
|
||||
t.Fatalf("n=%d unexpected output %+v", n, out)
|
||||
}
|
||||
closeFn()
|
||||
}
|
||||
}
|
||||
|
||||
// 批量任务部分失败:结果查询返回的失败项必须如实回填,不能整批记成功。
|
||||
func TestYdApnChangeBatchReflectsPartialFailure(t *testing.T) {
|
||||
defer shortenYdBatchPoll(2 * time.Second)()
|
||||
|
||||
keys := ydSimKeysWithMsisdn(10)
|
||||
failId := keys[3].MSISDN
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch {
|
||||
case strings.Contains(r.URL.Path, "sim-communication-function/batch"):
|
||||
_, _ = w.Write([]byte(`{"status":"0","message":"正确","result":[{"jobId":"JOB9"}]}`))
|
||||
case strings.Contains(r.URL.Path, "sim-batch-result"):
|
||||
items := make([]string, 0, len(keys))
|
||||
for _, k := range keys {
|
||||
if k.MSISDN == failId {
|
||||
items = append(items, fmt.Sprintf(`{"status":"12047","message":"用户该服务已经是暂停状态,不能重复暂停","resultType":"1","resultId":%q}`, k.MSISDN))
|
||||
continue
|
||||
}
|
||||
items = append(items, fmt.Sprintf(`{"status":"0","message":"正确","resultType":"1","resultId":%q}`, k.MSISDN))
|
||||
}
|
||||
_, _ = w.Write([]byte(fmt.Sprintf(`{"status":"0","message":"正确","result":[{"jobStatus":"3","resultList":[%s]}]}`, strings.Join(items, ","))))
|
||||
default:
|
||||
t.Fatalf("unexpected path %s", r.URL.Path)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
out, err := newYdApnChannelWithServer(server).ChangeSimApnFunction(context.Background(), &model.SimCardOperateChangeApnInput{
|
||||
SimKeys: keys,
|
||||
ReqData: model.SimCardOperateChangeApnInputItem{OperType: "0", ApnName: "CMIOT"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(out) != 10 {
|
||||
t.Fatalf("output len = %d, want 10", len(out))
|
||||
}
|
||||
failed := 0
|
||||
for _, item := range out {
|
||||
if item.Code != 0 {
|
||||
failed++
|
||||
if !strings.Contains(item.Message, "不能重复暂停") {
|
||||
t.Fatalf("unexpected failure message %q", item.Message)
|
||||
}
|
||||
}
|
||||
}
|
||||
if failed != 1 {
|
||||
t.Fatalf("failed count = %d, want 1 (%+v)", failed, out)
|
||||
}
|
||||
}
|
||||
|
||||
// 轮询超时:未取得终态时按失败记录,并注明"未确认",避免误报成功。
|
||||
func TestYdApnChangeBatchResultTimeout(t *testing.T) {
|
||||
defer shortenYdBatchPoll(20 * time.Millisecond)()
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch {
|
||||
case strings.Contains(r.URL.Path, "sim-communication-function/batch"):
|
||||
_, _ = w.Write([]byte(`{"status":"0","message":"正确","result":[{"jobId":"JOB5"}]}`))
|
||||
case strings.Contains(r.URL.Path, "sim-batch-result"):
|
||||
_, _ = w.Write([]byte(`{"status":"0","message":"正确","result":[{"jobStatus":"1","resultList":[]}]}`))
|
||||
default:
|
||||
t.Fatalf("unexpected path %s", r.URL.Path)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
out, err := newYdApnChannelWithServer(server).ChangeSimApnFunction(context.Background(), &model.SimCardOperateChangeApnInput{
|
||||
SimKeys: ydSimKeysWithMsisdn(10),
|
||||
ReqData: model.SimCardOperateChangeApnInputItem{OperType: "0", ApnName: "CMIOT"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(out) != 10 {
|
||||
t.Fatalf("output len = %d, want 10", len(out))
|
||||
}
|
||||
for _, item := range out {
|
||||
if item.Code == 0 {
|
||||
t.Fatalf("timeout must not be reported as success: %+v", item)
|
||||
}
|
||||
if !strings.Contains(item.Message, "查询上游通信结果失败") {
|
||||
t.Fatalf("unexpected timeout message %q", item.Message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 停用数据:批量 operType 翻成 0,单卡 businessType=1。
|
||||
func TestYdApnChangeStopDirection(t *testing.T) {
|
||||
defer shortenYdBatchPoll(2 * time.Second)()
|
||||
capture := &ydApnCapture{}
|
||||
sim, closeFn := newYdApnTestChannel(capture)
|
||||
defer closeFn()
|
||||
|
||||
if _, err := sim.ChangeSimApnFunction(context.Background(), &model.SimCardOperateChangeApnInput{
|
||||
SimKeys: ydSimKeysWithMsisdn(10),
|
||||
ReqData: model.SimCardOperateChangeApnInputItem{OperType: "1", ApnName: "CMIOT"},
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
capture2 := &ydApnCapture{}
|
||||
sim2, closeFn2 := newYdApnTestChannel(capture2)
|
||||
defer closeFn2()
|
||||
if _, err := sim2.ChangeSimApnFunction(context.Background(), &model.SimCardOperateChangeApnInput{
|
||||
SimKeys: ydSimKeysWithMsisdn(3),
|
||||
ReqData: model.SimCardOperateChangeApnInputItem{OperType: "1", ApnName: "CMIOT"},
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if capture.query[0].Get("operType") != "0" {
|
||||
t.Fatalf("batch stop operType = %q, want 0", capture.query[0].Get("operType"))
|
||||
}
|
||||
if capture2.query[0].Get("businessType") != "1" {
|
||||
t.Fatalf("single stop businessType = %q, want 1", capture2.query[0].Get("businessType"))
|
||||
}
|
||||
}
|
||||
|
||||
// 超过 100 张:按 100 拆块,不足 10 的尾块退回单卡。
|
||||
func TestYdApnChangeOverHundredSplits(t *testing.T) {
|
||||
defer shortenYdBatchPoll(2 * time.Second)()
|
||||
capture := &ydApnCapture{}
|
||||
sim, closeFn := newYdApnTestChannel(capture)
|
||||
defer closeFn()
|
||||
|
||||
out, err := sim.ChangeSimApnFunction(context.Background(), &model.SimCardOperateChangeApnInput{
|
||||
SimKeys: ydSimKeysWithMsisdn(105),
|
||||
ReqData: model.SimCardOperateChangeApnInputItem{OperType: "0", ApnName: "CMIOT"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
batch, single, _ := ydApnCounts(capture.paths)
|
||||
if batch != 1 || single != 5 {
|
||||
t.Fatalf("expected 1 batch + 5 singles, got batch=%d single=%d (%v)", batch, single, capture.paths)
|
||||
}
|
||||
if len(out) != 105 {
|
||||
t.Fatalf("output len = %d, want 105", len(out))
|
||||
}
|
||||
}
|
||||
|
||||
// 批量里有卡缺 msisdn:缺号降级走 7.2.1,其余凑够 10 张仍走 7.2.2。
|
||||
func TestYdApnChangeMissingMsisdnFallsBackToSingle(t *testing.T) {
|
||||
defer shortenYdBatchPoll(2 * time.Second)()
|
||||
capture := &ydApnCapture{}
|
||||
sim, closeFn := newYdApnTestChannel(capture)
|
||||
defer closeFn()
|
||||
|
||||
keys := ydSimKeysWithMsisdn(15)
|
||||
keys[0].MSISDN = ""
|
||||
keys[5].MSISDN = ""
|
||||
keys[14].MSISDN = ""
|
||||
|
||||
out, err := sim.ChangeSimApnFunction(context.Background(), &model.SimCardOperateChangeApnInput{
|
||||
SimKeys: keys,
|
||||
ReqData: model.SimCardOperateChangeApnInputItem{OperType: "0", ApnName: "CMIOT"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
batch, single, _ := ydApnCounts(capture.paths)
|
||||
if batch != 1 || single != 3 {
|
||||
t.Fatalf("expected 1 batch + 3 singles, got batch=%d single=%d (%v)", batch, single, capture.paths)
|
||||
}
|
||||
if len(out) != 15 {
|
||||
t.Fatalf("output len = %d, want 15", len(out))
|
||||
}
|
||||
for _, item := range out {
|
||||
if item.Code != 0 {
|
||||
t.Fatalf("unexpected failure item %+v", item)
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,224 @@
|
||||
package upstreamimpl
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/model/entity"
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
)
|
||||
|
||||
const ydTokenInvalidCode = "12021"
|
||||
|
||||
var errYdTokenUnchanged = errors.New("mobile token recovery returned the rejected token")
|
||||
|
||||
type ydResponseMeta struct {
|
||||
Status string `json:"status"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
type ydHTTPResponse struct{ body string }
|
||||
|
||||
func (r *ydHTTPResponse) ReadAllString() string { return r.body }
|
||||
|
||||
type ydRequestFunc func(params g.Map) (string, error)
|
||||
|
||||
// ydTokenProvider is the construction seam for channels whose credential is
|
||||
// shared outside t_sim_channels. Implementations own storage and refresh;
|
||||
// RecoverToken must use compare-and-swap semantics for rejectedToken.
|
||||
type ydTokenProvider interface {
|
||||
GetToken(ctx context.Context) (string, error)
|
||||
RecoverToken(ctx context.Context, rejectedToken string) (string, error)
|
||||
}
|
||||
|
||||
const (
|
||||
// expired_only reuses the current token until it actually expires.
|
||||
ydTokenRefreshModeExpiredOnly = "expired_only"
|
||||
// proactive enters the configured advance window before requesting a token.
|
||||
ydTokenRefreshModeProactive = "proactive"
|
||||
ydTokenDefaultSkewSeconds = int64(600)
|
||||
)
|
||||
|
||||
type ydTokenRefreshPolicy struct {
|
||||
mode string
|
||||
skewSecond int64
|
||||
}
|
||||
|
||||
func loadYdTokenRefreshPolicy(ctx context.Context) ydTokenRefreshPolicy {
|
||||
mode := g.Cfg().MustGet(ctx, "dependencies.token_refresh.mode", ydTokenRefreshModeExpiredOnly).String()
|
||||
if mode != ydTokenRefreshModeExpiredOnly && mode != ydTokenRefreshModeProactive {
|
||||
g.Log().Warningf(ctx, "unknown token refresh mode=%s, fallback to %s", mode, ydTokenRefreshModeExpiredOnly)
|
||||
mode = ydTokenRefreshModeExpiredOnly
|
||||
}
|
||||
skew := g.Cfg().MustGet(ctx, "dependencies.token_refresh.skew_seconds", ydTokenDefaultSkewSeconds).Int64()
|
||||
if skew < 0 {
|
||||
g.Log().Warningf(ctx, "invalid token refresh skew_seconds=%d, fallback to 0", skew)
|
||||
skew = 0
|
||||
}
|
||||
return ydTokenRefreshPolicy{mode: mode, skewSecond: skew}
|
||||
}
|
||||
|
||||
func (p ydTokenRefreshPolicy) shouldRefresh(channel *entity.TSimChannels, now time.Time) bool {
|
||||
if channel == nil || channel.AccessToken == "" || channel.AccessTokenExpire == nil {
|
||||
return true
|
||||
}
|
||||
expire := channel.AccessTokenExpire.Time
|
||||
if p.mode == ydTokenRefreshModeProactive {
|
||||
return !now.Before(expire.Add(-time.Duration(p.skewSecond) * time.Second))
|
||||
}
|
||||
return !now.Before(expire)
|
||||
}
|
||||
|
||||
var (
|
||||
ydTokenGuards sync.Map
|
||||
ydTokenStates sync.Map // map[string]*ydTokenState
|
||||
)
|
||||
|
||||
type ydTokenState struct {
|
||||
mu sync.RWMutex
|
||||
token string
|
||||
createdAt time.Time
|
||||
expiresAt time.Time
|
||||
}
|
||||
|
||||
func ydTokenStateFor(chanKey string) *ydTokenState {
|
||||
state, _ := ydTokenStates.LoadOrStore(chanKey, &ydTokenState{})
|
||||
return state.(*ydTokenState)
|
||||
}
|
||||
|
||||
func seedYdTokenState(channel *entity.TSimChannels) {
|
||||
if channel == nil || channel.ChanKey == "" || channel.AccessToken == "" || channel.AccessTokenExpire == nil {
|
||||
return
|
||||
}
|
||||
state := ydTokenStateFor(channel.ChanKey)
|
||||
state.mu.Lock()
|
||||
createdAt := time.Time{}
|
||||
if channel.AccessTokenCreated != nil {
|
||||
createdAt = channel.AccessTokenCreated.Time
|
||||
}
|
||||
if state.token == "" || createdAt.After(state.createdAt) {
|
||||
state.token = channel.AccessToken
|
||||
state.createdAt = createdAt
|
||||
state.expiresAt = channel.AccessTokenExpire.Time
|
||||
}
|
||||
state.mu.Unlock()
|
||||
}
|
||||
|
||||
func currentYdToken(channel *entity.TSimChannels) (string, time.Time) {
|
||||
if channel == nil {
|
||||
return "", time.Time{}
|
||||
}
|
||||
seedYdTokenState(channel)
|
||||
state := ydTokenStateFor(channel.ChanKey)
|
||||
state.mu.RLock()
|
||||
token, expiresAt := state.token, state.expiresAt
|
||||
state.mu.RUnlock()
|
||||
if token == "" || expiresAt.IsZero() || !time.Now().Before(expiresAt) {
|
||||
return "", expiresAt
|
||||
}
|
||||
return token, expiresAt
|
||||
}
|
||||
|
||||
func updateYdTokenState(chanKey, token string, createdAt, expiresAt time.Time) {
|
||||
state := ydTokenStateFor(chanKey)
|
||||
state.mu.Lock()
|
||||
state.token = token
|
||||
state.createdAt = createdAt
|
||||
state.expiresAt = expiresAt
|
||||
state.mu.Unlock()
|
||||
}
|
||||
|
||||
func clearYdTokenStateIfCurrent(chanKey, rejected string) {
|
||||
state := ydTokenStateFor(chanKey)
|
||||
state.mu.Lock()
|
||||
if state.token == rejected {
|
||||
state.token = ""
|
||||
state.createdAt = time.Time{}
|
||||
state.expiresAt = time.Time{}
|
||||
}
|
||||
state.mu.Unlock()
|
||||
}
|
||||
|
||||
func withYdTokenGuard(chanKey string, fn func() string) string {
|
||||
guard, _ := ydTokenGuards.LoadOrStore(chanKey, &sync.Mutex{})
|
||||
mu := guard.(*sync.Mutex)
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
return fn()
|
||||
}
|
||||
|
||||
// validYdToken only checks actual expiry. Proactive refresh decisions belong
|
||||
// to ydTokenRefreshPolicy so refresh=0 integrations can reuse the token until
|
||||
// it really expires.
|
||||
func validYdToken(channel *entity.TSimChannels, now time.Time) string {
|
||||
if channel == nil || channel.AccessToken == "" || channel.AccessTokenExpire == nil {
|
||||
return ""
|
||||
}
|
||||
if !now.Before(channel.AccessTokenExpire.Time) {
|
||||
return ""
|
||||
}
|
||||
return channel.AccessToken
|
||||
}
|
||||
|
||||
func recoverYdToken(ctx context.Context, chanKey string, channel *entity.TSimChannels, rejectedToken string, refresh func(context.Context) string) (token string, err error) {
|
||||
withYdTokenGuard(chanKey, func() string {
|
||||
if channel == nil {
|
||||
err = errors.New("mobile token recovery has no channel")
|
||||
return ""
|
||||
}
|
||||
seedYdTokenState(channel)
|
||||
if current, _ := currentYdToken(channel); current != "" && current != rejectedToken {
|
||||
channel.AccessToken = current
|
||||
token = current
|
||||
return ""
|
||||
}
|
||||
clearYdTokenStateIfCurrent(chanKey, rejectedToken)
|
||||
channel.AccessToken = ""
|
||||
channel.AccessTokenExpire = nil
|
||||
token = refresh(ctx)
|
||||
if token == "" {
|
||||
err = errors.New("mobile token recovery failed to acquire a token")
|
||||
} else if token == rejectedToken {
|
||||
err = errYdTokenUnchanged
|
||||
}
|
||||
return ""
|
||||
})
|
||||
return token, err
|
||||
}
|
||||
|
||||
func doYdRequest(ctx context.Context, operation, chanKey string, getToken func(context.Context) string, recoverToken func(context.Context, string) (string, error), params g.Map, request ydRequestFunc) (*ydHTTPResponse, error) {
|
||||
var lastBody string
|
||||
for attempt := 0; attempt < 2; attempt++ {
|
||||
token := getToken(ctx)
|
||||
requestParams := make(g.Map, len(params)+1)
|
||||
for key, value := range params {
|
||||
requestParams[key] = value
|
||||
}
|
||||
requestParams["token"] = token
|
||||
body, err := request(requestParams)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
lastBody = body
|
||||
var meta ydResponseMeta
|
||||
if err = json.Unmarshal([]byte(body), &meta); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if meta.Status != ydTokenInvalidCode || attempt == 1 {
|
||||
return &ydHTTPResponse{body: body}, nil
|
||||
}
|
||||
g.Log().Warningf(ctx, "%s token rejected, invalidate and retry once, chanKey:%s", operation, chanKey)
|
||||
if _, err = recoverToken(ctx, token); err != nil {
|
||||
if errors.Is(err, errYdTokenUnchanged) {
|
||||
g.Log().Warningf(ctx, "%s token recovery returned the rejected token, stop retrying, chanKey:%s", operation, chanKey)
|
||||
return &ydHTTPResponse{body: body}, nil
|
||||
}
|
||||
return nil, fmt.Errorf("%s token recovery failed: %w", operation, err)
|
||||
}
|
||||
}
|
||||
return &ydHTTPResponse{body: lastBody}, nil
|
||||
}
|
||||
@@ -0,0 +1,495 @@
|
||||
package upstreamimpl
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/consts"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/logic/upstreamimpl/yunda"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/model"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/service"
|
||||
)
|
||||
|
||||
const yundaTemplateKey = "sim_yunda_v1"
|
||||
|
||||
type sSimYunda struct {
|
||||
channelConfig *model.SimChannelConfigItemCache
|
||||
client *yunda.Client
|
||||
}
|
||||
|
||||
type yundaFloat float64
|
||||
|
||||
func (f *yundaFloat) UnmarshalJSON(data []byte) error {
|
||||
var number float64
|
||||
if err := json.Unmarshal(data, &number); err == nil {
|
||||
*f = yundaFloat(number)
|
||||
return nil
|
||||
}
|
||||
var text string
|
||||
if err := json.Unmarshal(data, &text); err != nil {
|
||||
return err
|
||||
}
|
||||
if text == "" {
|
||||
*f = 0
|
||||
return nil
|
||||
}
|
||||
value, err := strconv.ParseFloat(text, 64)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
*f = yundaFloat(value)
|
||||
return nil
|
||||
}
|
||||
|
||||
type yundaCards []yundaCard
|
||||
|
||||
func (cards *yundaCards) UnmarshalJSON(data []byte) error {
|
||||
data = bytes.TrimSpace(data)
|
||||
if len(data) == 0 || bytes.Equal(data, []byte("null")) {
|
||||
*cards = nil
|
||||
return nil
|
||||
}
|
||||
if data[0] == '[' {
|
||||
return json.Unmarshal(data, (*[]yundaCard)(cards))
|
||||
}
|
||||
var card yundaCard
|
||||
if err := json.Unmarshal(data, &card); err != nil {
|
||||
return err
|
||||
}
|
||||
*cards = []yundaCard{card}
|
||||
return nil
|
||||
}
|
||||
|
||||
type yundaCard struct {
|
||||
MSISDN string `json:"msisdn"`
|
||||
ICCID string `json:"iccid"`
|
||||
IMEI string `json:"imei"`
|
||||
IMSI string `json:"imsi"`
|
||||
Code string `json:"code"`
|
||||
Error string `json:"error"`
|
||||
Status string `json:"status"`
|
||||
GPRSStatus string `json:"gprsStatus"`
|
||||
GPRSStatusLower string `json:"gprsstatus"`
|
||||
IP string `json:"ip"`
|
||||
RAT string `json:"rat"`
|
||||
StatTimestamp string `json:"statustimestamp"`
|
||||
OpenTime string `json:"opentime"`
|
||||
PackageID string `json:"packageid"`
|
||||
PackageName string `json:"packagename"`
|
||||
LifecycleStatus string `json:"lifecycleStatus"`
|
||||
LifecycleStatusLower string `json:"lifecyclestatus"`
|
||||
APNList []yundaAPN `json:"apnlist"`
|
||||
APN string `json:"apn"`
|
||||
}
|
||||
|
||||
type yundaAPN struct {
|
||||
APNName string `json:"apnname"`
|
||||
UsedFlow yundaFloat `json:"usedflow"`
|
||||
DailyList []yundaDailyUse `json:"dailylist"`
|
||||
}
|
||||
|
||||
type yundaDailyUse struct {
|
||||
UsedFlow yundaFloat `json:"usedflow"`
|
||||
Date string `json:"date"`
|
||||
}
|
||||
|
||||
type yundaConfig struct {
|
||||
BaseURL string `json:"baseUrl"`
|
||||
Timeout string `json:"timeout"`
|
||||
}
|
||||
|
||||
type yundaConfigEnvelope struct {
|
||||
API yundaConfig `json:"api"`
|
||||
BaseURL string `json:"baseUrl"`
|
||||
Timeout string `json:"timeout"`
|
||||
}
|
||||
|
||||
func NewSimYunda() *sSimYunda { return &sSimYunda{} }
|
||||
|
||||
func init() {
|
||||
service.UpstreamAPI().RegisterTemp(yundaTemplateKey, NewSimYunda())
|
||||
}
|
||||
|
||||
func (s *sSimYunda) NewTemp(ctx context.Context, channelConfig *model.SimChannelConfigItemCache) model.SimCardChannelI {
|
||||
cfg := yundaConfigEnvelope{}
|
||||
if channelConfig != nil && channelConfig.Template != nil {
|
||||
_ = json.Unmarshal([]byte(channelConfig.Template.TempConfig), &cfg)
|
||||
}
|
||||
if channelConfig != nil && channelConfig.Channel != nil && strings.TrimSpace(channelConfig.Channel.ChanConfig) != "" {
|
||||
var channelCfg yundaConfigEnvelope
|
||||
if json.Unmarshal([]byte(channelConfig.Channel.ChanConfig), &channelCfg) == nil {
|
||||
if channelCfg.API.BaseURL != "" {
|
||||
cfg.API.BaseURL = channelCfg.API.BaseURL
|
||||
}
|
||||
if channelCfg.BaseURL != "" {
|
||||
cfg.BaseURL = channelCfg.BaseURL
|
||||
}
|
||||
if channelCfg.API.Timeout != "" {
|
||||
cfg.API.Timeout = channelCfg.API.Timeout
|
||||
}
|
||||
if channelCfg.Timeout != "" {
|
||||
cfg.Timeout = channelCfg.Timeout
|
||||
}
|
||||
}
|
||||
}
|
||||
baseURL := cfg.API.BaseURL
|
||||
if baseURL == "" {
|
||||
baseURL = cfg.BaseURL
|
||||
}
|
||||
if baseURL == "" {
|
||||
baseURL = "http://api.yundaiot.cn/v1"
|
||||
}
|
||||
timeout := 10 * time.Second
|
||||
if cfg.API.Timeout != "" {
|
||||
if parsed, err := time.ParseDuration(cfg.API.Timeout); err == nil {
|
||||
timeout = parsed
|
||||
}
|
||||
}
|
||||
if cfg.Timeout != "" {
|
||||
if parsed, err := time.ParseDuration(cfg.Timeout); err == nil {
|
||||
timeout = parsed
|
||||
}
|
||||
}
|
||||
appKey, appSecret := "", ""
|
||||
if channelConfig != nil && channelConfig.Channel != nil {
|
||||
appKey = channelConfig.Channel.AppKey
|
||||
appSecret = channelConfig.Channel.AppSecret
|
||||
}
|
||||
return &sSimYunda{channelConfig: channelConfig, client: yunda.NewClient(yunda.Config{BaseURL: baseURL, AppKey: appKey, AppSecret: appSecret, Timeout: timeout})}
|
||||
}
|
||||
|
||||
func yundaCardValue(key *model.SimKey, field string) string {
|
||||
if key == nil {
|
||||
return ""
|
||||
}
|
||||
switch field {
|
||||
case "iccid":
|
||||
return key.ICCID
|
||||
case "msisdn":
|
||||
return key.MSISDN
|
||||
case "imsi":
|
||||
return key.IMSI
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (s *sSimYunda) clientPost(ctx context.Context, path string, params map[string]string, out *yundaCards) error {
|
||||
_, err := s.client.Post(ctx, path, params, out)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *sSimYunda) requestCard(ctx context.Context, path string, key *model.SimKey, fields map[string]string, out *yundaCards) error {
|
||||
if key == nil {
|
||||
return errors.New("sim key is required")
|
||||
}
|
||||
params := map[string]string{}
|
||||
for name, field := range fields {
|
||||
if value := yundaCardValue(key, field); value != "" {
|
||||
params[name] = value
|
||||
}
|
||||
}
|
||||
if len(params) == 0 {
|
||||
return errors.New("iccid, msisdn or imsi is required")
|
||||
}
|
||||
return s.clientPost(ctx, path, params, out)
|
||||
}
|
||||
|
||||
func (s *sSimYunda) QuerySimBasicInfo(ctx context.Context, input *model.SimBasicInfoInput) (model.SimBasicInfoOutput, error) {
|
||||
items := make(model.SimBasicInfoOutput, 0, len(input.SimKeys))
|
||||
for _, key := range input.SimKeys {
|
||||
var result yundaCards
|
||||
err := s.requestCard(ctx, "BatchGetSimInfo", key, map[string]string{"iccids": "iccid", "msisdns": "msisdn", "imsis": "imsi"}, &result)
|
||||
card := firstYundaCard(result)
|
||||
if err != nil || card == nil {
|
||||
items = append(items, yundaBasicError(key, errOr(err, "yunda returned empty card info")))
|
||||
continue
|
||||
}
|
||||
items = append(items, &model.SimBaseRes[model.SimBasicInfoItem]{Code: consts.ApiStatusSuccess, SimKey: key, ResData: model.SimBasicInfoItem{ICCID: card.ICCID, IMSI: card.IMSI, MSISDN: card.MSISDN, OpenDate: card.OpenTime}})
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
func (s *sSimYunda) QuerySimImei(ctx context.Context, input *model.SimImeiInput) (model.SimImeiOutput, error) {
|
||||
items := make(model.SimImeiOutput, 0, len(input.SimKeys))
|
||||
for _, key := range input.SimKeys {
|
||||
var result yundaCards
|
||||
err := s.requestCard(ctx, "BatchGetSimInfo", key, map[string]string{"iccids": "iccid", "msisdns": "msisdn", "imsis": "imsi"}, &result)
|
||||
card := firstYundaCard(result)
|
||||
if err != nil || card == nil {
|
||||
items = append(items, &model.SimBaseRes[model.SimImeiItem]{Code: consts.ApiStatusError, SimKey: key, Message: errMessage(err, "yunda returned empty card info")})
|
||||
continue
|
||||
}
|
||||
items = append(items, &model.SimBaseRes[model.SimImeiItem]{Code: consts.ApiStatusSuccess, SimKey: key, ResData: model.SimImeiItem{Imei: card.IMEI}})
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
func (s *sSimYunda) QuerySimCardStatus(ctx context.Context, input *model.SimCardStatusInput) (model.SimCardStatusOutput, error) {
|
||||
items := make(model.SimCardStatusOutput, 0, len(input.SimKeys))
|
||||
for _, key := range input.SimKeys {
|
||||
var result yundaCards
|
||||
err := s.requestCard(ctx, "BatchGetSimStatus", key, map[string]string{"iccids": "iccid", "msisdns": "msisdn"}, &result)
|
||||
card := firstYundaCard(result)
|
||||
if err != nil || card == nil {
|
||||
items = append(items, &model.SimBaseRes[model.SimCardStatusItem]{Code: consts.ApiStatusError, SimKey: key, Message: errMessage(err, "yunda returned empty card status")})
|
||||
continue
|
||||
}
|
||||
status, err := mapYundaCardStatus(card.Status)
|
||||
if err != nil {
|
||||
items = append(items, &model.SimBaseRes[model.SimCardStatusItem]{Code: consts.ApiStatusError, SimKey: key, Message: err.Error()})
|
||||
continue
|
||||
}
|
||||
items = append(items, &model.SimBaseRes[model.SimCardStatusItem]{Code: consts.ApiStatusSuccess, SimKey: key, ResData: model.SimCardStatusItem{CardStatus: status}})
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
// Yunda lifecycle codes: 0 testing, 1 dormant, 2 inventory, 3 in use, 4 stopped, 5 cancelled.
|
||||
func mapYundaCardStatus(value string) (int, error) {
|
||||
switch strings.TrimSpace(value) {
|
||||
case "0", "测试期":
|
||||
return 6, nil
|
||||
case "1", "沉默期":
|
||||
return 1, nil
|
||||
case "2", "库存期":
|
||||
return 7, nil
|
||||
case "3", "正使用":
|
||||
return 2, nil
|
||||
case "4", "已停机":
|
||||
return 4, nil
|
||||
case "5", "已销户":
|
||||
return 9, nil
|
||||
default:
|
||||
return 0, fmt.Errorf("yunda: unsupported card status %q", value)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *sSimYunda) QuerySimDataUsage(ctx context.Context, input *model.SimDataUsageInput) (model.SimDataUsageOutput, error) {
|
||||
items := make(model.SimDataUsageOutput, 0, len(input.SimKeys))
|
||||
for _, key := range input.SimKeys {
|
||||
var result yundaCards
|
||||
err := s.requestCard(ctx, "BatchGetCurrentMonthGprs", key, map[string]string{"iccids": "iccid", "msisdns": "msisdn"}, &result)
|
||||
card := firstYundaCard(result)
|
||||
if err != nil || card == nil {
|
||||
items = append(items, &model.SimBaseRes[model.SimDataUsageItem]{Code: consts.ApiStatusError, SimKey: key, Message: errMessage(err, "yunda returned empty usage")})
|
||||
continue
|
||||
}
|
||||
items = append(items, &model.SimBaseRes[model.SimDataUsageItem]{Code: consts.ApiStatusSuccess, SimKey: key, ResData: model.SimDataUsageItem{DataUsage: firstYundaUsage(card)}})
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
func (s *sSimYunda) QuerySimDataUsageMonthly(ctx context.Context, input *model.SimDataUsageMonthlyInput) (model.SimDataUsageMonthlyOutput, error) {
|
||||
items := make(model.SimDataUsageMonthlyOutput, 0, len(input.SimKeys))
|
||||
for _, key := range input.SimKeys {
|
||||
var result yundaCards
|
||||
params := map[string]string{"month": input.ReqData.QueryDate}
|
||||
if key != nil {
|
||||
params["iccids"] = key.ICCID
|
||||
params["msisdns"] = key.MSISDN
|
||||
}
|
||||
err := s.clientPost(ctx, "BatchGetSimMonthGPRS", params, &result)
|
||||
card := firstYundaCard(result)
|
||||
if err != nil || card == nil {
|
||||
items = append(items, &model.SimBaseRes[model.SimDataUsageMonthlyOutputItem]{Code: consts.ApiStatusError, SimKey: key, Message: errMessage(err, "yunda returned empty monthly usage"), ResData: model.SimDataUsageMonthlyOutputItem{DeadLine: input.ReqData.QueryDate}})
|
||||
continue
|
||||
}
|
||||
items = append(items, &model.SimBaseRes[model.SimDataUsageMonthlyOutputItem]{Code: consts.ApiStatusSuccess, SimKey: key, ResData: model.SimDataUsageMonthlyOutputItem{DeadLine: input.ReqData.QueryDate, DataAmount: firstYundaUsage(card)}})
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
func (s *sSimYunda) QuerySimDataUsageDaily(ctx context.Context, input *model.SimDataUsageDailyInput) (model.SimDataUsageDailyOutput, error) {
|
||||
items := make(model.SimDataUsageDailyOutput, 0, len(input.SimKeys))
|
||||
for _, key := range input.SimKeys {
|
||||
var result yundaCards
|
||||
month, date, dateErr := yundaMonthAndDate(input.ReqData.QueryDate)
|
||||
if dateErr != nil {
|
||||
items = append(items, &model.SimBaseRes[model.SimDataUsageDailyOutputItem]{Code: consts.ApiStatusError, SimKey: key, Message: dateErr.Error()})
|
||||
continue
|
||||
}
|
||||
params := map[string]string{"month": month, "date": date}
|
||||
if key != nil {
|
||||
params["iccids"] = key.ICCID
|
||||
params["msisdns"] = key.MSISDN
|
||||
}
|
||||
err := s.clientPost(ctx, "BatchGetSimDayGPRS", params, &result)
|
||||
card := firstYundaCard(result)
|
||||
amount, date := firstYundaDailyUsage(card, date)
|
||||
if err != nil || card == nil {
|
||||
items = append(items, &model.SimBaseRes[model.SimDataUsageDailyOutputItem]{Code: consts.ApiStatusError, SimKey: key, Message: errMessage(err, "yunda returned empty daily usage"), ResData: model.SimDataUsageDailyOutputItem{QueryDate: date}})
|
||||
continue
|
||||
}
|
||||
items = append(items, &model.SimBaseRes[model.SimDataUsageDailyOutputItem]{Code: consts.ApiStatusSuccess, SimKey: key, ResData: model.SimDataUsageDailyOutputItem{QueryDate: date, DataAmount: amount}})
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
func (s *sSimYunda) QuerySimSession(ctx context.Context, input *model.SimSessionInput) (model.SimSessionOutput, error) {
|
||||
items := make(model.SimSessionOutput, 0, len(input.SimKeys))
|
||||
for _, key := range input.SimKeys {
|
||||
keyword := yundaCardValue(key, "iccid")
|
||||
if keyword == "" {
|
||||
keyword = yundaCardValue(key, "msisdn")
|
||||
}
|
||||
if keyword == "" {
|
||||
keyword = yundaCardValue(key, "imsi")
|
||||
}
|
||||
var result yundaCards
|
||||
err := s.clientPost(ctx, "SimOnlineQuery", map[string]string{"keyword": keyword}, &result)
|
||||
card := firstYundaCard(result)
|
||||
if err != nil || card == nil {
|
||||
items = append(items, &model.SimBaseRes[model.SimSessionItem]{Code: consts.ApiStatusError, SimKey: key, Message: errMessage(err, "yunda returned empty online status")})
|
||||
continue
|
||||
}
|
||||
status := "-1"
|
||||
if card.GPRSStatus == "01" || card.GPRSStatusLower == "01" {
|
||||
status = "0"
|
||||
}
|
||||
items = append(items, &model.SimBaseRes[model.SimSessionItem]{Code: consts.ApiStatusSuccess, SimKey: key, ResData: model.SimSessionItem{ApnId: card.APN, Imei: card.IMEI, Rat: card.RAT, Ipv4: card.IP, Status: status, CreateTime: card.StatTimestamp}})
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
func (s *sSimYunda) QuerySimDeviceStatus(ctx context.Context, input *model.SimDeviceStatusInput) (model.SimDeviceStatusOutput, error) {
|
||||
items := make(model.SimDeviceStatusOutput, 0, len(input.SimKeys))
|
||||
for _, key := range input.SimKeys {
|
||||
var result yundaCards
|
||||
err := s.requestCard(ctx, "BatchGetSimGPRSStatus", key, map[string]string{"iccids": "iccid", "msisdns": "msisdn"}, &result)
|
||||
card := firstYundaCard(result)
|
||||
if err != nil || card == nil {
|
||||
items = append(items, &model.SimBaseRes[model.SimDeviceStatusItem]{Code: consts.ApiStatusError, SimKey: key, Message: errMessage(err, "yunda returned empty GPRS status")})
|
||||
continue
|
||||
}
|
||||
status := card.GPRSStatus
|
||||
if status == "" {
|
||||
status = card.GPRSStatusLower
|
||||
}
|
||||
items = append(items, &model.SimBaseRes[model.SimDeviceStatusItem]{Code: consts.ApiStatusSuccess, SimKey: key, ResData: model.SimDeviceStatusItem{DeviceStatus: status}})
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
func yundaUnsupported[T any](items []*model.SimBaseRes[T], keys []*model.SimKey, operation string) ([]*model.SimBaseRes[T], error) {
|
||||
for _, key := range keys {
|
||||
items = append(items, &model.SimBaseRes[T]{Code: consts.ApiStatusError, SimKey: key, Message: fmt.Sprintf("yunda does not support %s yet", operation)})
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
func (s *sSimYunda) QuerySimStopReason(ctx context.Context, input *model.SimStopReasonInput) (model.SimStopReasonOutput, error) {
|
||||
return yundaUnsupported[model.SimStopReasonItem](nil, input.SimKeys, "stop reason query")
|
||||
}
|
||||
func (s *sSimYunda) QuerySimFrozenState(ctx context.Context, input *model.SimFrozenStateInput) (model.SimFrozenStateOutput, error) {
|
||||
return yundaUnsupported[model.SimFrozenStateItem](nil, input.SimKeys, "frozen state query")
|
||||
}
|
||||
func (s *sSimYunda) QuerySimDataDiagnosis(ctx context.Context, input *model.SimDataDiagnosisInput) (model.SimDataDiagnosisOutput, error) {
|
||||
return yundaUnsupported[any](nil, input.SimKeys, "data diagnosis")
|
||||
}
|
||||
func (s *sSimYunda) QuerySimApnInfo(ctx context.Context, input *model.SimApnInfoInput) (model.SimApnInfoOutput, error) {
|
||||
items := make(model.SimApnInfoOutput, 0, len(input.SimKeys))
|
||||
for _, key := range input.SimKeys {
|
||||
keyword := yundaCardValue(key, "iccid")
|
||||
if keyword == "" {
|
||||
keyword = yundaCardValue(key, "msisdn")
|
||||
}
|
||||
if keyword == "" {
|
||||
keyword = yundaCardValue(key, "imsi")
|
||||
}
|
||||
var result yundaCards
|
||||
err := s.clientPost(ctx, "SimOnlineQuery", map[string]string{"keyword": keyword}, &result)
|
||||
card := firstYundaCard(result)
|
||||
if err != nil || card == nil {
|
||||
items = append(items, &model.SimBaseRes[model.SimApnInfoItemList]{Code: consts.ApiStatusError, SimKey: key, Message: errMessage(err, "yunda returned empty APN info")})
|
||||
continue
|
||||
}
|
||||
status := "0"
|
||||
if strings.TrimSpace(card.APN) != "" {
|
||||
status = "1"
|
||||
}
|
||||
items = append(items, &model.SimBaseRes[model.SimApnInfoItemList]{Code: consts.ApiStatusSuccess, SimKey: key, ResData: model.SimApnInfoItemList{ApnList: []model.SimApnInfoItem{{ApnName: card.APN, Status: status}}}})
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
func (s *sSimYunda) ChangeSimStatus(ctx context.Context, input *model.SimCardStatusChangeInput) (model.SimCardStatusChangeOutput, error) {
|
||||
return yundaUnsupported[model.SimCardStatusChangeItem](nil, input.SimKeys, "status change")
|
||||
}
|
||||
func (s *sSimYunda) ChangeSimApnFunction(ctx context.Context, input *model.SimCardOperateChangeApnInput) (model.SimCardOperateChangeApnOutput, error) {
|
||||
return yundaUnsupported[model.SimCardOperateChangeApnItem](nil, input.SimKeys, "APN change")
|
||||
}
|
||||
func (s *sSimYunda) QuerySimCardBindStatus(ctx context.Context, input *model.SimCardBindStatusInput) (model.SimCardBindStatusOutput, error) {
|
||||
items := make(model.SimCardBindStatusOutput, 0, len(input.SimKeys))
|
||||
for _, key := range input.SimKeys {
|
||||
var result yundaCards
|
||||
err := s.requestCard(ctx, "BatchGetSimInfo", key, map[string]string{"iccids": "iccid", "msisdns": "msisdn", "imsis": "imsi"}, &result)
|
||||
card := firstYundaCard(result)
|
||||
if err != nil || card == nil {
|
||||
items = append(items, &model.SimBaseRes[model.SimCardBindStatusItem]{Code: consts.ApiStatusError, SimKey: key, Message: errMessage(err, "yunda returned empty card info")})
|
||||
continue
|
||||
}
|
||||
bindStatus, bindingType := "0", "sim"
|
||||
if strings.TrimSpace(card.IMEI) != "" {
|
||||
bindStatus, bindingType = "1", "imei"
|
||||
}
|
||||
items = append(items, &model.SimBaseRes[model.SimCardBindStatusItem]{Code: consts.ApiStatusSuccess, SimKey: key, ResData: model.SimCardBindStatusItem{BindStatus: bindStatus, CardBindingType: bindingType}})
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
func (s *sSimYunda) QuerySimRegionLimitStatus(ctx context.Context, input *model.SimRegionLimitStatusInput) (model.SimRegionLimitStatusOutput, error) {
|
||||
return yundaUnsupported[model.SimRegionLimitStatusItem](nil, input.SimKeys, "region limit query")
|
||||
}
|
||||
|
||||
func firstYundaCard(cards yundaCards) *yundaCard {
|
||||
if len(cards) == 0 {
|
||||
return nil
|
||||
}
|
||||
return &cards[0]
|
||||
}
|
||||
func firstYundaUsage(card *yundaCard) float64 {
|
||||
if card == nil || len(card.APNList) == 0 {
|
||||
return 0
|
||||
}
|
||||
return float64(card.APNList[0].UsedFlow)
|
||||
}
|
||||
func firstYundaDailyUsage(card *yundaCard, fallback string) (float64, string) {
|
||||
if card == nil || len(card.APNList) == 0 || len(card.APNList[0].DailyList) == 0 {
|
||||
return 0, fallback
|
||||
}
|
||||
item := card.APNList[0].DailyList[0]
|
||||
if item.Date == "" {
|
||||
item.Date = fallback
|
||||
}
|
||||
return float64(item.UsedFlow), item.Date
|
||||
}
|
||||
func yundaBasicError(key *model.SimKey, err error) *model.SimBaseRes[model.SimBasicInfoItem] {
|
||||
return &model.SimBaseRes[model.SimBasicInfoItem]{Code: consts.ApiStatusError, SimKey: key, Message: err.Error()}
|
||||
}
|
||||
func errOr(err error, fallback string) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return errors.New(fallback)
|
||||
}
|
||||
|
||||
func errMessage(err error, fallback string) string {
|
||||
if err != nil {
|
||||
return err.Error()
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
func yundaMonthAndDate(value string) (string, string, error) {
|
||||
value = strings.TrimSpace(value)
|
||||
for _, layout := range []string{"20060102", "2006-01-02"} {
|
||||
parsed, err := time.Parse(layout, value)
|
||||
if err == nil {
|
||||
return parsed.Format("2006-01"), parsed.Format("2006-01-02"), nil
|
||||
}
|
||||
}
|
||||
return "", "", fmt.Errorf("invalid daily queryDate: %s", value)
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
package upstreamimpl
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestYundaMonthAndDate(t *testing.T) {
|
||||
tests := []struct {
|
||||
input string
|
||||
month string
|
||||
date string
|
||||
}{
|
||||
{input: "20260921", month: "2026-09", date: "2026-09-21"},
|
||||
{input: "2026-09-21", month: "2026-09", date: "2026-09-21"},
|
||||
}
|
||||
for _, test := range tests {
|
||||
month, date, err := yundaMonthAndDate(test.input)
|
||||
if err != nil || month != test.month || date != test.date {
|
||||
t.Fatalf("input=%s month=%s date=%s err=%v", test.input, month, date, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestYundaMonthAndDateRejectsInvalidValue(t *testing.T) {
|
||||
if _, _, err := yundaMonthAndDate("202609"); err == nil {
|
||||
t.Fatal("expected invalid daily date")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMapYundaCardStatus(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
input string
|
||||
want int
|
||||
}{
|
||||
{"0", 6}, {"1", 1}, {"2", 7}, {"3", 2}, {"正使用", 2}, {"4", 4}, {"已停机", 4}, {"5", 9}, {"已销户", 9},
|
||||
} {
|
||||
got, err := mapYundaCardStatus(tc.input)
|
||||
if err != nil || got != tc.want {
|
||||
t.Fatalf("mapYundaCardStatus(%q) = %d, %v; want %d", tc.input, got, err, tc.want)
|
||||
}
|
||||
}
|
||||
if _, err := mapYundaCardStatus("unknown"); err == nil {
|
||||
t.Fatal("expected unsupported Yunda status to return an error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTelecomCardStatusSourcesDiffer(t *testing.T) {
|
||||
if got := convertDxCardStatus(context.Background(), "3"); got != 7 {
|
||||
t.Fatalf("single-card product state 3 = %d, want inventory 7", got)
|
||||
}
|
||||
if got := convertDXBatchCardStatus(context.Background(), "3"); got != 6 {
|
||||
t.Fatalf("BQ018 test deactivation 3 = %d, want testable 6", got)
|
||||
}
|
||||
if got := mapDXBatchInfoToBasicInfo(dxBatchInfoItem{SimStatus: "4"}).SimStatus; got != "2" {
|
||||
t.Fatalf("BQ018 in-use state 4 = %q, want activated 2", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMapDXLegacyBasicStatusKeepsMissingValueEmpty(t *testing.T) {
|
||||
if got := mapDXLegacyBasicStatus(context.Background(), ""); got != "" {
|
||||
t.Fatalf("missing telecom status = %q, want empty", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package upstreamimpl
|
||||
|
||||
import (
|
||||
"context"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/model"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/service"
|
||||
)
|
||||
|
||||
const (
|
||||
templateModelDirect = "direct"
|
||||
templateModelSharedToken = "shared_token"
|
||||
)
|
||||
|
||||
// Model registrations are deliberately separate from concrete temp_key
|
||||
// registrations. This lets multiple source-specific templates share one
|
||||
// implementation while keeping existing exact-key behavior unchanged.
|
||||
func init() {
|
||||
api := service.UpstreamAPI()
|
||||
// direct is a classification value, not a generic implementation. Direct
|
||||
// protocols must register their concrete temp_key (for example sim_yd_v5).
|
||||
// shared_token is the only model-backed implementation because multiple
|
||||
// source-specific templates share the OneLink business API.
|
||||
api.RegisterModel(templateModelSharedToken, func(ctx context.Context, cfg *model.SimChannelConfigItemCache) model.SimCardChannelI {
|
||||
return NewSimOneLinkShared().NewTemp(ctx, cfg)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
package upstreamimpl
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
tengyuOneLinkTokenPath = "/prod-api/open/api/oneLinkToken"
|
||||
tengyuTokenTTL = time.Hour
|
||||
)
|
||||
|
||||
// OneLinkToken is the value returned by a shared-token fetcher.
|
||||
type OneLinkToken struct {
|
||||
Value string
|
||||
CreatedAt time.Time
|
||||
ExpiresAt time.Time
|
||||
}
|
||||
|
||||
// TokenFetcher mirrors the token core fetch boundary. chanKey is the
|
||||
// protocol's c_no; it is never inferred from unrelated channel fields.
|
||||
type TokenFetcher interface {
|
||||
FetchToken(context.Context, string) (OneLinkToken, error)
|
||||
}
|
||||
|
||||
// TokenProvider is the consumer-facing token core boundary.
|
||||
type TokenProvider interface {
|
||||
GetToken(context.Context, string) (string, error)
|
||||
}
|
||||
|
||||
// TengyuTokenConfig contains all values required by the Tengyu protocol.
|
||||
// The provider intentionally does not infer these values from channel config.
|
||||
type TengyuTokenConfig struct {
|
||||
BaseURL string
|
||||
Path string
|
||||
PwdKey string
|
||||
ChannelNo string
|
||||
Timeout time.Duration
|
||||
}
|
||||
|
||||
// TengyuTokenProvider fetches a Tengyu shared OneLink token.
|
||||
type TengyuTokenProvider struct {
|
||||
baseURL string
|
||||
path string
|
||||
pwdKey string
|
||||
channelNo string
|
||||
client *http.Client
|
||||
}
|
||||
|
||||
type tengyuTokenResponse struct {
|
||||
Code int `json:"code"`
|
||||
Msg string `json:"msg"`
|
||||
Data json.RawMessage `json:"data"`
|
||||
}
|
||||
|
||||
func NewTengyuTokenProvider(config TengyuTokenConfig) *TengyuTokenProvider {
|
||||
return &TengyuTokenProvider{
|
||||
baseURL: strings.TrimRight(strings.TrimSpace(config.BaseURL), "/"),
|
||||
path: normalizeProviderPath(config.Path, tengyuOneLinkTokenPath),
|
||||
pwdKey: config.PwdKey,
|
||||
channelNo: config.ChannelNo,
|
||||
client: newTengyuHTTPClient(config.Timeout),
|
||||
}
|
||||
}
|
||||
|
||||
// Historical in-package names are retained as a small adapter for callers
|
||||
// that constructed the provider before the token-core interfaces were named.
|
||||
type tengyuTokenConfig = TengyuTokenConfig
|
||||
|
||||
func newTengyuTokenProvider(config tengyuTokenConfig) *TengyuTokenProvider {
|
||||
return NewTengyuTokenProvider(config)
|
||||
}
|
||||
|
||||
func newTengyuHTTPClient(timeout time.Duration) *http.Client {
|
||||
if timeout <= 0 {
|
||||
timeout = defaultUpstreamHTTPTimeout
|
||||
}
|
||||
return &http.Client{Timeout: timeout}
|
||||
}
|
||||
|
||||
func (p *TengyuTokenProvider) Fetch(ctx context.Context) (OneLinkToken, error) {
|
||||
return p.fetch(ctx, p.channelNo)
|
||||
}
|
||||
|
||||
func (p *TengyuTokenProvider) FetchToken(ctx context.Context, channelNo string) (OneLinkToken, error) {
|
||||
if strings.TrimSpace(channelNo) == "" {
|
||||
channelNo = p.channelNo
|
||||
}
|
||||
return p.fetch(ctx, channelNo)
|
||||
}
|
||||
|
||||
func (p *TengyuTokenProvider) GetToken(ctx context.Context, channelNo string) (string, error) {
|
||||
token, err := p.FetchToken(ctx, channelNo)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return token.Value, nil
|
||||
}
|
||||
|
||||
func (p *TengyuTokenProvider) fetch(ctx context.Context, channelNo string) (OneLinkToken, error) {
|
||||
if p == nil || p.client == nil {
|
||||
return OneLinkToken{}, fmt.Errorf("tengyu token provider is not initialized")
|
||||
}
|
||||
if p.baseURL == "" {
|
||||
return OneLinkToken{}, fmt.Errorf("tengyu token base URL is required")
|
||||
}
|
||||
if strings.TrimSpace(p.pwdKey) == "" {
|
||||
return OneLinkToken{}, fmt.Errorf("tengyu pwdkey is required")
|
||||
}
|
||||
if strings.TrimSpace(channelNo) == "" {
|
||||
return OneLinkToken{}, fmt.Errorf("tengyu channel number is required")
|
||||
}
|
||||
|
||||
endpoint, err := url.Parse(joinProviderURL(p.baseURL, p.path))
|
||||
if err != nil {
|
||||
return OneLinkToken{}, fmt.Errorf("parse Tengyu token URL: %w", err)
|
||||
}
|
||||
query := endpoint.Query()
|
||||
query.Set("pwdkey", p.pwdKey)
|
||||
query.Set("c_no", channelNo)
|
||||
endpoint.RawQuery = query.Encode()
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint.String(), nil)
|
||||
if err != nil {
|
||||
return OneLinkToken{}, fmt.Errorf("create Tengyu token request: %w", err)
|
||||
}
|
||||
req.Header.Set("Accept", "application/json")
|
||||
resp, err := p.client.Do(req)
|
||||
if err != nil {
|
||||
return OneLinkToken{}, fmt.Errorf("request Tengyu token: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return OneLinkToken{}, fmt.Errorf("read Tengyu token response: %w", err)
|
||||
}
|
||||
if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices {
|
||||
return OneLinkToken{}, fmt.Errorf("Tengyu token HTTP status %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
|
||||
}
|
||||
|
||||
var output tengyuTokenResponse
|
||||
if err := json.Unmarshal(body, &output); err != nil {
|
||||
return OneLinkToken{}, fmt.Errorf("decode Tengyu token response: %w", err)
|
||||
}
|
||||
if output.Code != http.StatusOK {
|
||||
return OneLinkToken{}, fmt.Errorf("Tengyu token failed: code=%d, message=%s", output.Code, output.Msg)
|
||||
}
|
||||
var value string
|
||||
if err := json.Unmarshal(output.Data, &value); err != nil {
|
||||
return OneLinkToken{}, fmt.Errorf("decode Tengyu shared token: %w", err)
|
||||
}
|
||||
if strings.TrimSpace(value) == "" {
|
||||
return OneLinkToken{}, fmt.Errorf("Tengyu shared token is empty")
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
return OneLinkToken{Value: value, CreatedAt: now, ExpiresAt: now.Add(tengyuTokenTTL)}, nil
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
package upstreamimpl
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
const tibiotSimDataMarginInterval = 650 * time.Millisecond
|
||||
|
||||
// tibiotSimDataMarginLimiter spaces requests globally because Tibiot applies
|
||||
// the simDataMargin quota to the gateway's public IP, not to a channel.
|
||||
var tibiotSimDataMarginLimiter = newTibiotRateLimiter(tibiotSimDataMarginInterval)
|
||||
|
||||
type tibiotRateLimiter struct {
|
||||
mu sync.Mutex
|
||||
interval time.Duration
|
||||
next time.Time
|
||||
}
|
||||
|
||||
func newTibiotRateLimiter(interval time.Duration) *tibiotRateLimiter {
|
||||
if interval <= 0 {
|
||||
interval = tibiotSimDataMarginInterval
|
||||
}
|
||||
return &tibiotRateLimiter{interval: interval}
|
||||
}
|
||||
|
||||
// wait blocks until a slot is available and only consumes the slot when the
|
||||
// caller's context is still active. Waiting callers do not reserve future
|
||||
// slots, so canceled requests cannot delay later retries.
|
||||
func (l *tibiotRateLimiter) wait(ctx context.Context) error {
|
||||
if l == nil {
|
||||
return nil
|
||||
}
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
|
||||
for {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
l.mu.Lock()
|
||||
now := time.Now()
|
||||
if !l.next.After(now) {
|
||||
// Check again while holding the mutex so a cancellation racing with
|
||||
// the slot boundary cannot consume a slot unnecessarily.
|
||||
if err := ctx.Err(); err != nil {
|
||||
l.mu.Unlock()
|
||||
return err
|
||||
}
|
||||
l.next = now.Add(l.interval)
|
||||
l.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
wakeAt := l.next
|
||||
l.mu.Unlock()
|
||||
|
||||
timer := time.NewTimer(time.Until(wakeAt))
|
||||
select {
|
||||
case <-timer.C:
|
||||
case <-ctx.Done():
|
||||
timer.Stop()
|
||||
return ctx.Err()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
package upstreamimpl
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const xuzhouMobileTokenPath = "/admin/api/get/token"
|
||||
|
||||
// XuzhouMobileTokenConfig contains the public provider endpoint and the
|
||||
// carrier type configured by the template. AppID is the per-channel
|
||||
// credential and is deliberately not read from the template.
|
||||
type XuzhouMobileTokenConfig struct {
|
||||
BaseURL string
|
||||
Path string
|
||||
AppID string
|
||||
CarrierType int
|
||||
TTL time.Duration
|
||||
Timeout time.Duration
|
||||
}
|
||||
|
||||
// XuzhouMobileTokenProvider fetches the shared OneLink token issued by the
|
||||
// Xuzhou Mobile provider.
|
||||
type XuzhouMobileTokenProvider struct {
|
||||
baseURL string
|
||||
path string
|
||||
appID string
|
||||
carrierType int
|
||||
ttl time.Duration
|
||||
client *http.Client
|
||||
}
|
||||
|
||||
type xuzhouMobileTokenResponse struct {
|
||||
Success bool `json:"success"`
|
||||
ErrorCode json.RawMessage `json:"errorCode"`
|
||||
Body struct {
|
||||
Token string `json:"token"`
|
||||
} `json:"body"`
|
||||
}
|
||||
|
||||
func parseXuzhouErrorCode(raw json.RawMessage) (int, error) {
|
||||
var code int
|
||||
if err := json.Unmarshal(raw, &code); err == nil {
|
||||
return code, nil
|
||||
}
|
||||
var value string
|
||||
if err := json.Unmarshal(raw, &value); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return strconv.Atoi(strings.TrimSpace(value))
|
||||
}
|
||||
|
||||
func NewXuzhouMobileTokenProvider(config XuzhouMobileTokenConfig) *XuzhouMobileTokenProvider {
|
||||
timeout := config.Timeout
|
||||
if timeout <= 0 {
|
||||
timeout = defaultUpstreamHTTPTimeout
|
||||
}
|
||||
return &XuzhouMobileTokenProvider{
|
||||
baseURL: strings.TrimRight(strings.TrimSpace(config.BaseURL), "/"),
|
||||
path: normalizeProviderPath(config.Path, xuzhouMobileTokenPath),
|
||||
appID: strings.TrimSpace(config.AppID),
|
||||
carrierType: config.CarrierType,
|
||||
ttl: config.TTL,
|
||||
client: &http.Client{Timeout: timeout},
|
||||
}
|
||||
}
|
||||
|
||||
func (p *XuzhouMobileTokenProvider) Fetch(ctx context.Context) (OneLinkToken, error) {
|
||||
if p == nil || p.client == nil {
|
||||
return OneLinkToken{}, fmt.Errorf("xuzhou mobile token provider is not initialized")
|
||||
}
|
||||
if p.baseURL == "" {
|
||||
return OneLinkToken{}, fmt.Errorf("xuzhou mobile token base URL is required")
|
||||
}
|
||||
if p.appID == "" {
|
||||
return OneLinkToken{}, fmt.Errorf("xuzhou mobile appId is required")
|
||||
}
|
||||
|
||||
endpoint, err := url.Parse(joinProviderURL(p.baseURL, p.path))
|
||||
if err != nil {
|
||||
return OneLinkToken{}, fmt.Errorf("parse Xuzhou Mobile token URL: %w", err)
|
||||
}
|
||||
query := endpoint.Query()
|
||||
query.Set("appid", p.appID)
|
||||
query.Set("carrierType", strconv.Itoa(p.carrierType))
|
||||
endpoint.RawQuery = query.Encode()
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint.String(), nil)
|
||||
if err != nil {
|
||||
return OneLinkToken{}, fmt.Errorf("create Xuzhou Mobile token request: %w", err)
|
||||
}
|
||||
req.Header.Set("Accept", "application/json")
|
||||
resp, err := p.client.Do(req)
|
||||
if err != nil {
|
||||
return OneLinkToken{}, fmt.Errorf("request Xuzhou Mobile token: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return OneLinkToken{}, fmt.Errorf("read Xuzhou Mobile token response: %w", err)
|
||||
}
|
||||
if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices {
|
||||
return OneLinkToken{}, fmt.Errorf("Xuzhou Mobile token HTTP status %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
|
||||
}
|
||||
|
||||
var output xuzhouMobileTokenResponse
|
||||
if err := json.Unmarshal(body, &output); err != nil {
|
||||
return OneLinkToken{}, fmt.Errorf("decode Xuzhou Mobile token response: %w", err)
|
||||
}
|
||||
errorCode, err := parseXuzhouErrorCode(output.ErrorCode)
|
||||
if err != nil {
|
||||
return OneLinkToken{}, fmt.Errorf("decode Xuzhou Mobile errorCode: %w", err)
|
||||
}
|
||||
if !output.Success || errorCode != 0 {
|
||||
return OneLinkToken{}, fmt.Errorf("Xuzhou Mobile token failed: success=%t, errorCode=%d", output.Success, errorCode)
|
||||
}
|
||||
value := strings.TrimSpace(output.Body.Token)
|
||||
if value == "" {
|
||||
return OneLinkToken{}, fmt.Errorf("Xuzhou Mobile shared token is empty")
|
||||
}
|
||||
now := time.Now()
|
||||
expiresAt := time.Time{}
|
||||
if p.ttl > 0 {
|
||||
expiresAt = now.Add(p.ttl)
|
||||
}
|
||||
return OneLinkToken{Value: value, CreatedAt: now, ExpiresAt: expiresAt}, nil
|
||||
}
|
||||
|
||||
func (p *XuzhouMobileTokenProvider) FetchToken(ctx context.Context, _ string) (OneLinkToken, error) {
|
||||
return p.Fetch(ctx)
|
||||
}
|
||||
|
||||
func normalizeProviderPath(path, fallback string) string {
|
||||
path = strings.TrimSpace(path)
|
||||
if path == "" {
|
||||
path = fallback
|
||||
}
|
||||
if !strings.HasPrefix(path, "/") {
|
||||
path = "/" + path
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
func joinProviderURL(baseURL, path string) string {
|
||||
return strings.TrimRight(strings.TrimSpace(baseURL), "/") + normalizeProviderPath(path, "/")
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
package yunda
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
BaseURL string
|
||||
AppKey string
|
||||
AppSecret string
|
||||
Timeout time.Duration
|
||||
}
|
||||
type Client struct {
|
||||
baseURL string
|
||||
appKey string
|
||||
appSecret string
|
||||
httpClient *http.Client
|
||||
}
|
||||
type Response struct {
|
||||
Status json.RawMessage `json:"status"`
|
||||
Message string `json:"message"`
|
||||
Result json.RawMessage `json:"result"`
|
||||
}
|
||||
|
||||
func NewClient(cfg Config) *Client {
|
||||
timeout := cfg.Timeout
|
||||
if timeout <= 0 {
|
||||
timeout = 10 * time.Second
|
||||
}
|
||||
return &Client{baseURL: strings.TrimRight(strings.TrimSpace(cfg.BaseURL), "/"), appKey: strings.TrimSpace(cfg.AppKey), appSecret: cfg.AppSecret, httpClient: &http.Client{Timeout: timeout}}
|
||||
}
|
||||
|
||||
func (c *Client) Post(ctx context.Context, path string, params map[string]string, out any) (Response, error) {
|
||||
if c.baseURL == "" {
|
||||
return Response{}, errors.New("yunda base URL is required")
|
||||
}
|
||||
if c.appKey == "" || c.appSecret == "" {
|
||||
return Response{}, errors.New("yunda appKey and appSecret are required")
|
||||
}
|
||||
signed := make(map[string]string, len(params)+3)
|
||||
for key, value := range params {
|
||||
if strings.TrimSpace(value) != "" {
|
||||
signed[key] = value
|
||||
}
|
||||
}
|
||||
signed["appKey"] = c.appKey
|
||||
signed["timestamp"] = strconv.FormatInt(time.Now().Unix(), 10)
|
||||
signed["sign"] = BuildSign(c.appKey, c.appSecret, signed)
|
||||
form := url.Values{}
|
||||
for key, value := range signed {
|
||||
form.Set(key, value)
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+"/"+strings.TrimLeft(path, "/"), strings.NewReader(form.Encode()))
|
||||
if err != nil {
|
||||
return Response{}, err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return Response{}, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return Response{}, err
|
||||
}
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return Response{}, fmt.Errorf("yunda HTTP status %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
|
||||
}
|
||||
var response Response
|
||||
if err = json.Unmarshal(body, &response); err != nil {
|
||||
return Response{}, fmt.Errorf("decode yunda response: %w", err)
|
||||
}
|
||||
if status := response.StatusString(); status != "0" && status != "" {
|
||||
return response, fmt.Errorf("yunda business status %s: %s", status, response.Message)
|
||||
}
|
||||
if out != nil && len(response.Result) > 0 && string(response.Result) != "null" {
|
||||
if err = json.Unmarshal(response.Result, out); err != nil {
|
||||
return response, fmt.Errorf("decode yunda result: %w", err)
|
||||
}
|
||||
}
|
||||
return response, nil
|
||||
}
|
||||
|
||||
func (r Response) StatusString() string {
|
||||
var value string
|
||||
if json.Unmarshal(r.Status, &value) == nil {
|
||||
return value
|
||||
}
|
||||
var number json.Number
|
||||
if json.Unmarshal(r.Status, &number) == nil {
|
||||
return number.String()
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package yunda
|
||||
|
||||
import (
|
||||
"crypto/md5"
|
||||
"encoding/hex"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func BuildSign(appKey, appSecret string, params map[string]string) string {
|
||||
values := make(map[string]string, len(params)+2)
|
||||
for key, value := range params {
|
||||
values[key] = value
|
||||
}
|
||||
values["appKey"] = appKey
|
||||
values["appSecret"] = appSecret
|
||||
keys := make([]string, 0, len(values))
|
||||
for key := range values {
|
||||
keys = append(keys, key)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
source := appKey
|
||||
for _, key := range keys {
|
||||
source += key + values[key]
|
||||
}
|
||||
source += appKey
|
||||
sum := md5.Sum([]byte(source))
|
||||
return strings.ToUpper(hex.EncodeToString(sum[:]))
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
package upstreamimpl
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
sharedtoken "git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/token"
|
||||
)
|
||||
|
||||
const (
|
||||
yunzeTokenPath = "/openapi/v3/query/operator-token"
|
||||
yunzeTokenExpiryLayout = "2006-01-02 15:04:05"
|
||||
)
|
||||
|
||||
// YunzeTokenConfig contains the platform endpoint and the per-channel app id.
|
||||
// The Yunze operator-token API does not require app_secret.
|
||||
type YunzeTokenConfig struct {
|
||||
BaseURL string
|
||||
Path string
|
||||
AppID string
|
||||
Timeout time.Duration
|
||||
}
|
||||
|
||||
// YunzeTokenProvider fetches the shared token from the Yunze platform.
|
||||
type YunzeTokenProvider struct {
|
||||
baseURL string
|
||||
path string
|
||||
appID string
|
||||
client *http.Client
|
||||
}
|
||||
|
||||
type yunzeTokenResponse struct {
|
||||
Code json.RawMessage `json:"code"`
|
||||
Message string `json:"message"`
|
||||
Data struct {
|
||||
ChanAlias string `json:"chanAlias"`
|
||||
ChanKey string `json:"chanKey"`
|
||||
AccessToken string `json:"accessToken"`
|
||||
// The live endpoint returns the expiry as tokenExpire. The published
|
||||
// documentation incorrectly lists accessTokenExpire; it is being
|
||||
// corrected to match the implementation.
|
||||
TokenExpire string `json:"tokenExpire"`
|
||||
} `json:"data"`
|
||||
Error json.RawMessage `json:"error"`
|
||||
}
|
||||
|
||||
// yunzeTokenRequest is the operator-token request body.
|
||||
type yunzeTokenRequest struct {
|
||||
AppID string `json:"appId"`
|
||||
ChanKey string `json:"chanKey"`
|
||||
}
|
||||
|
||||
// yunzeFetcher adapts YunzeTokenProvider to the shared token TokenFetcher
|
||||
// contract, mirroring ziboFetcher and apikaFetcher.
|
||||
type yunzeFetcher struct{ provider *YunzeTokenProvider }
|
||||
|
||||
func (f yunzeFetcher) FetchToken(ctx context.Context, chanKey string) (sharedtoken.OneLinkToken, error) {
|
||||
return f.provider.Fetch(ctx, chanKey)
|
||||
}
|
||||
|
||||
func NewYunzeTokenProvider(config YunzeTokenConfig) *YunzeTokenProvider {
|
||||
timeout := config.Timeout
|
||||
if timeout <= 0 {
|
||||
timeout = defaultUpstreamHTTPTimeout
|
||||
}
|
||||
return &YunzeTokenProvider{
|
||||
baseURL: strings.TrimRight(strings.TrimSpace(config.BaseURL), "/"),
|
||||
path: normalizeProviderPath(config.Path, yunzeTokenPath),
|
||||
appID: strings.TrimSpace(config.AppID),
|
||||
client: &http.Client{Timeout: timeout},
|
||||
}
|
||||
}
|
||||
|
||||
func (p *YunzeTokenProvider) Fetch(ctx context.Context, chanKey string) (sharedtoken.OneLinkToken, error) {
|
||||
if p == nil || p.client == nil {
|
||||
return sharedtoken.OneLinkToken{}, fmt.Errorf("yunze token provider is not initialized")
|
||||
}
|
||||
if p.baseURL == "" {
|
||||
return sharedtoken.OneLinkToken{}, fmt.Errorf("yunze token base URL is required")
|
||||
}
|
||||
if p.appID == "" {
|
||||
return sharedtoken.OneLinkToken{}, fmt.Errorf("yunze appId is required")
|
||||
}
|
||||
chanKey = strings.TrimSpace(chanKey)
|
||||
if chanKey == "" {
|
||||
return sharedtoken.OneLinkToken{}, fmt.Errorf("yunze chanKey is required")
|
||||
}
|
||||
|
||||
payload, err := json.Marshal(yunzeTokenRequest{AppID: p.appID, ChanKey: chanKey})
|
||||
if err != nil {
|
||||
return sharedtoken.OneLinkToken{}, fmt.Errorf("encode yunze token request: %w", err)
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(
|
||||
ctx,
|
||||
http.MethodPost,
|
||||
joinProviderURL(p.baseURL, p.path),
|
||||
bytes.NewReader(payload),
|
||||
)
|
||||
if err != nil {
|
||||
return sharedtoken.OneLinkToken{}, fmt.Errorf("create yunze token request: %w", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Accept", "application/json")
|
||||
|
||||
resp, err := p.client.Do(req)
|
||||
if err != nil {
|
||||
return sharedtoken.OneLinkToken{}, fmt.Errorf("request yunze token: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(io.LimitReader(resp.Body, 4*1024*1024+1))
|
||||
if err != nil {
|
||||
return sharedtoken.OneLinkToken{}, fmt.Errorf("read yunze token response: %w", err)
|
||||
}
|
||||
if len(body) > 4*1024*1024 {
|
||||
return sharedtoken.OneLinkToken{}, fmt.Errorf("yunze token response is too large")
|
||||
}
|
||||
if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices {
|
||||
return sharedtoken.OneLinkToken{}, fmt.Errorf("yunze token HTTP status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var output yunzeTokenResponse
|
||||
if err := json.Unmarshal(body, &output); err != nil {
|
||||
return sharedtoken.OneLinkToken{}, fmt.Errorf("decode yunze token response: %w", err)
|
||||
}
|
||||
code, err := decodeYunzeCode(output.Code)
|
||||
if err != nil {
|
||||
return sharedtoken.OneLinkToken{}, fmt.Errorf("decode yunze token code: %w", err)
|
||||
}
|
||||
if code != 0 {
|
||||
return sharedtoken.OneLinkToken{}, fmt.Errorf(
|
||||
"yunze token failed: code=%d message=%s",
|
||||
code,
|
||||
strings.TrimSpace(output.Message),
|
||||
)
|
||||
}
|
||||
if len(output.Error) > 0 && string(output.Error) != "null" {
|
||||
return sharedtoken.OneLinkToken{}, fmt.Errorf("yunze token error: %s", string(output.Error))
|
||||
}
|
||||
|
||||
value := strings.TrimSpace(output.Data.AccessToken)
|
||||
if value == "" {
|
||||
return sharedtoken.OneLinkToken{}, fmt.Errorf("yunze accessToken is empty")
|
||||
}
|
||||
expiresAt, err := parseYunzeTokenExpiry(output.Data.TokenExpire)
|
||||
if err != nil {
|
||||
return sharedtoken.OneLinkToken{}, err
|
||||
}
|
||||
|
||||
return sharedtoken.OneLinkToken{
|
||||
Value: value,
|
||||
CreatedAt: time.Now(),
|
||||
ExpiresAt: expiresAt,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func decodeYunzeCode(raw json.RawMessage) (int, error) {
|
||||
var code int
|
||||
if err := json.Unmarshal(raw, &code); err == nil {
|
||||
return code, nil
|
||||
}
|
||||
var value string
|
||||
if err := json.Unmarshal(raw, &value); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
var parsed int
|
||||
if _, err := fmt.Sscanf(strings.TrimSpace(value), "%d", &parsed); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return parsed, nil
|
||||
}
|
||||
|
||||
// yunzeTokenLocation is the timezone used by the Yunze operator-token API.
|
||||
// It is resolved once so the hot refresh path does not re-read tzdata.
|
||||
var yunzeTokenLocation = func() *time.Location {
|
||||
if loc, err := time.LoadLocation("Asia/Shanghai"); err == nil {
|
||||
return loc
|
||||
}
|
||||
return time.Local
|
||||
}()
|
||||
|
||||
func parseYunzeTokenExpiry(raw string) (time.Time, error) {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return time.Time{}, fmt.Errorf("yunze tokenExpire is empty")
|
||||
}
|
||||
expiresAt, err := time.ParseInLocation(yunzeTokenExpiryLayout, raw, yunzeTokenLocation)
|
||||
if err != nil {
|
||||
return time.Time{}, fmt.Errorf("parse yunze tokenExpire %q: %w", raw, err)
|
||||
}
|
||||
return expiresAt, nil
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
package upstreamimpl
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/model"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/model/entity"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/service"
|
||||
)
|
||||
|
||||
// yunzeExpire renders a wall-clock expiry in the operator-token wire format
|
||||
// (Asia/Shanghai, "2006-01-02 15:04:05").
|
||||
func yunzeExpire() string {
|
||||
return time.Now().In(yunzeTokenLocation).Add(10 * time.Minute).Format(yunzeTokenExpiryLayout)
|
||||
}
|
||||
|
||||
func TestYunzeTokenProviderFetch(t *testing.T) {
|
||||
expire := time.Now().In(yunzeTokenLocation).Add(10 * time.Minute)
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
t.Fatalf("method = %s, want POST", r.Method)
|
||||
}
|
||||
if r.URL.Path != "/openapi/v3/query/operator-token" {
|
||||
t.Fatalf("path = %s", r.URL.Path)
|
||||
}
|
||||
if got := r.Header.Get("Content-Type"); got != "application/json" {
|
||||
t.Fatalf("content type = %q", got)
|
||||
}
|
||||
|
||||
var request yunzeTokenRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
|
||||
t.Fatalf("decode request: %v", err)
|
||||
}
|
||||
if request.AppID != "app-id" || request.ChanKey != "249" {
|
||||
t.Fatalf("request = %+v", request)
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"code":0,"message":"","data":{"chanAlias":"中国移动物联网","chanKey":"249","accessToken":"token-1","tokenExpire":"` + expire.Format(yunzeTokenExpiryLayout) + `"},"error":null}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
provider := NewYunzeTokenProvider(YunzeTokenConfig{
|
||||
BaseURL: server.URL,
|
||||
Path: "/openapi/v3/query/operator-token",
|
||||
AppID: "app-id",
|
||||
Timeout: time.Second,
|
||||
})
|
||||
token, err := provider.Fetch(context.Background(), "249")
|
||||
if err != nil {
|
||||
t.Fatalf("fetch token: %v", err)
|
||||
}
|
||||
if token.Value != "token-1" {
|
||||
t.Fatalf("token = %q", token.Value)
|
||||
}
|
||||
if token.ExpiresAt.IsZero() {
|
||||
t.Fatal("token expiry is empty")
|
||||
}
|
||||
if token.ExpiresAt.Sub(expire).Abs() > time.Second {
|
||||
t.Fatalf("expiry = %s, want %s", token.ExpiresAt, expire)
|
||||
}
|
||||
}
|
||||
|
||||
// Pins the wire field name to the live endpoint. The published documentation
|
||||
// lists data.accessTokenExpire, but the endpoint returns data.tokenExpire; if
|
||||
// either side changes, this test fails and forces a deliberate decision.
|
||||
func TestYunzeTokenProviderExpiryFieldName(t *testing.T) {
|
||||
expire := yunzeExpire()
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = w.Write([]byte(`{"code":0,"data":{"accessToken":"token-1","tokenExpire":"` + expire + `"},"error":null}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
provider := NewYunzeTokenProvider(YunzeTokenConfig{BaseURL: server.URL, AppID: "app-id", Timeout: time.Second})
|
||||
token, err := provider.Fetch(context.Background(), "249")
|
||||
if err != nil {
|
||||
t.Fatalf("fetch token: %v", err)
|
||||
}
|
||||
if token.Value != "token-1" || token.ExpiresAt.IsZero() {
|
||||
t.Fatalf("token = %+v", token)
|
||||
}
|
||||
}
|
||||
|
||||
func TestYunzeTokenProviderAcceptsStringCode(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = w.Write([]byte(`{"code":"0","data":{"accessToken":"token-str","tokenExpire":"` + yunzeExpire() + `"},"error":null}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
provider := NewYunzeTokenProvider(YunzeTokenConfig{BaseURL: server.URL, AppID: "app-id", Timeout: time.Second})
|
||||
token, err := provider.Fetch(context.Background(), "249")
|
||||
if err != nil {
|
||||
t.Fatalf("fetch token: %v", err)
|
||||
}
|
||||
if token.Value != "token-str" {
|
||||
t.Fatalf("token = %q, want token-str", token.Value)
|
||||
}
|
||||
}
|
||||
|
||||
func TestYunzeTokenProviderRejectsFailedResponse(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = w.Write([]byte(`{"code":1001,"message":"通道不存在","data":null,"error":null}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
provider := NewYunzeTokenProvider(YunzeTokenConfig{BaseURL: server.URL, AppID: "app-id", Timeout: time.Second})
|
||||
if _, err := provider.Fetch(context.Background(), "249"); err == nil {
|
||||
t.Fatal("expected failed response error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestYunzeTokenProviderRejectsErrorPayload(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = w.Write([]byte(`{"code":0,"data":{"accessToken":"token-1","tokenExpire":"` + yunzeExpire() + `"},"error":{"msg":"account frozen"}}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
provider := NewYunzeTokenProvider(YunzeTokenConfig{BaseURL: server.URL, AppID: "app-id", Timeout: time.Second})
|
||||
if _, err := provider.Fetch(context.Background(), "249"); err == nil {
|
||||
t.Fatal("expected error payload rejection")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseSharedOneLinkConfigBusinessURL(t *testing.T) {
|
||||
newConfig := func(tempConfig string) *model.SimChannelConfigItemCache {
|
||||
return &model.SimChannelConfigItemCache{
|
||||
Template: &entity.TSimChannelTemplate{TempConfig: tempConfig},
|
||||
Channel: &entity.TSimChannels{
|
||||
ChanKey: "249",
|
||||
AppKey: "app-id",
|
||||
ChanConfig: "{}",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
t.Run("defaults to shared OneLink URL", func(t *testing.T) {
|
||||
config, api, err := parseSharedOneLinkConfig(context.Background(), newConfig(
|
||||
`{"token":{"provider":"yunze","baseUrl":"http://apiv3.example.com","path":"/route/open/sharedToken"}}`,
|
||||
))
|
||||
if err != nil {
|
||||
t.Fatalf("parse config: %v", err)
|
||||
}
|
||||
if config.Provider != "yunze" || config.AppID != "app-id" {
|
||||
t.Fatalf("config = %+v", config)
|
||||
}
|
||||
if api.BaseURL != sharedOneLinkAPIBaseURL {
|
||||
t.Fatalf("business base URL = %q, want %q", api.BaseURL, sharedOneLinkAPIBaseURL)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("honours template api.baseUrl", func(t *testing.T) {
|
||||
const custom = "https://custom.example/v5/"
|
||||
config, api, err := parseSharedOneLinkConfig(context.Background(), newConfig(
|
||||
`{"api":{"baseUrl":"`+custom+`"},"token":{"provider":"yunze","baseUrl":"http://apiv3.example.com","path":"/route/open/sharedToken"}}`,
|
||||
))
|
||||
if err != nil {
|
||||
t.Fatalf("parse config: %v", err)
|
||||
}
|
||||
if config.Provider != "yunze" || config.AppID != "app-id" {
|
||||
t.Fatalf("config = %+v", config)
|
||||
}
|
||||
if api.BaseURL != custom {
|
||||
t.Fatalf("business base URL = %q, want %q", api.BaseURL, custom)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// The yunze channel template is registered by its concrete temp_key, matching
|
||||
// the other shared-token templates (sim_onelink_<provider>_v1).
|
||||
func TestYunzeTemplateRegistration(t *testing.T) {
|
||||
template, ok := service.UpstreamAPI().GetTemp(sharedOneLinkYunzeTemplateKey)
|
||||
if !ok || template == nil {
|
||||
t.Fatal("yunze template not registered")
|
||||
}
|
||||
|
||||
config := &model.SimChannelConfigItemCache{
|
||||
Template: &entity.TSimChannelTemplate{
|
||||
TempKey: sharedOneLinkYunzeTemplateKey,
|
||||
TempModel: templateModelSharedToken,
|
||||
TempConfig: `{"token":{"provider":"yunze","baseUrl":"http://apiv3.example.com","path":"/route/open/sharedToken"}}`,
|
||||
},
|
||||
Channel: &entity.TSimChannels{
|
||||
ChanKey: "249",
|
||||
AppKey: "TEST_APP_KEY_0001",
|
||||
ChanConfig: "{}",
|
||||
},
|
||||
}
|
||||
if instance := template.NewTemp(context.Background(), config); instance == nil {
|
||||
t.Fatal("yunze template failed to instantiate")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
package upstreamimpl
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
sharedtoken "git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/token"
|
||||
)
|
||||
|
||||
const ziboOneLinkTokenPath = "/ec/get/token"
|
||||
|
||||
// ZiboTokenConfig contains the official OneLink token endpoint and the
|
||||
// per-channel credentials stored in t_sim_channels.
|
||||
type ZiboTokenConfig struct {
|
||||
BaseURL string
|
||||
Path string
|
||||
AppID string
|
||||
Password string
|
||||
Timeout time.Duration
|
||||
}
|
||||
|
||||
// ZiboTokenProvider fetches a token from the official OneLink endpoint.
|
||||
type ZiboTokenProvider struct {
|
||||
baseURL string
|
||||
path string
|
||||
appID string
|
||||
password string
|
||||
client *http.Client
|
||||
sequence atomic.Uint64
|
||||
}
|
||||
|
||||
type ziboTokenResponse struct {
|
||||
Status string `json:"status"`
|
||||
Message string `json:"message"`
|
||||
Result []struct {
|
||||
Token string `json:"token"`
|
||||
TTL int `json:"ttl"`
|
||||
} `json:"result"`
|
||||
}
|
||||
|
||||
type ziboFetcher struct {
|
||||
provider *ZiboTokenProvider
|
||||
}
|
||||
|
||||
func NewZiboTokenProvider(config ZiboTokenConfig) *ZiboTokenProvider {
|
||||
timeout := config.Timeout
|
||||
if timeout <= 0 {
|
||||
timeout = defaultUpstreamHTTPTimeout
|
||||
}
|
||||
return &ZiboTokenProvider{
|
||||
baseURL: strings.TrimRight(strings.TrimSpace(config.BaseURL), "/"),
|
||||
path: normalizeProviderPath(config.Path, ziboOneLinkTokenPath),
|
||||
appID: strings.TrimSpace(config.AppID),
|
||||
password: config.Password,
|
||||
client: &http.Client{Timeout: timeout},
|
||||
}
|
||||
}
|
||||
|
||||
func (f ziboFetcher) FetchToken(ctx context.Context, _ string) (sharedtoken.OneLinkToken, error) {
|
||||
token, err := f.provider.Fetch(ctx)
|
||||
if err != nil {
|
||||
return sharedtoken.OneLinkToken{}, err
|
||||
}
|
||||
return sharedtoken.OneLinkToken{
|
||||
Value: token.Value,
|
||||
CreatedAt: token.CreatedAt,
|
||||
ExpiresAt: token.ExpiresAt,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (p *ZiboTokenProvider) Fetch(ctx context.Context) (OneLinkToken, error) {
|
||||
if p == nil || p.client == nil {
|
||||
return OneLinkToken{}, fmt.Errorf("zibo token provider is not initialized")
|
||||
}
|
||||
if p.baseURL == "" {
|
||||
return OneLinkToken{}, fmt.Errorf("zibo token base URL is required")
|
||||
}
|
||||
if p.appID == "" || strings.TrimSpace(p.password) == "" {
|
||||
return OneLinkToken{}, fmt.Errorf("zibo appid and password are required")
|
||||
}
|
||||
|
||||
endpoint, err := url.Parse(joinProviderURL(p.baseURL, p.path))
|
||||
if err != nil {
|
||||
return OneLinkToken{}, fmt.Errorf("parse zibo token URL: %w", err)
|
||||
}
|
||||
query := endpoint.Query()
|
||||
query.Set("appid", p.appID)
|
||||
query.Set("password", p.password)
|
||||
query.Set("transid", p.nextTransID())
|
||||
query.Set("refresh", "0")
|
||||
endpoint.RawQuery = query.Encode()
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint.String(), nil)
|
||||
if err != nil {
|
||||
return OneLinkToken{}, fmt.Errorf("create zibo token request: %w", err)
|
||||
}
|
||||
req.Header.Set("Accept", "application/json")
|
||||
|
||||
resp, err := p.client.Do(req)
|
||||
if err != nil {
|
||||
return OneLinkToken{}, fmt.Errorf("request zibo token: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return OneLinkToken{}, fmt.Errorf("read zibo token response: %w", err)
|
||||
}
|
||||
if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices {
|
||||
return OneLinkToken{}, fmt.Errorf("zibo token HTTP status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var output ziboTokenResponse
|
||||
if err := json.Unmarshal(body, &output); err != nil {
|
||||
return OneLinkToken{}, fmt.Errorf("decode zibo token response: %w", err)
|
||||
}
|
||||
if strings.TrimSpace(output.Status) != "0" {
|
||||
return OneLinkToken{}, fmt.Errorf("zibo token failed: status=%s, message=%s", output.Status, output.Message)
|
||||
}
|
||||
if len(output.Result) == 0 || strings.TrimSpace(output.Result[0].Token) == "" {
|
||||
return OneLinkToken{}, fmt.Errorf("zibo token response is empty")
|
||||
}
|
||||
if output.Result[0].TTL <= 0 {
|
||||
return OneLinkToken{}, fmt.Errorf("zibo token ttl must be positive")
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
return OneLinkToken{
|
||||
Value: strings.TrimSpace(output.Result[0].Token),
|
||||
CreatedAt: now,
|
||||
ExpiresAt: now.Add(time.Duration(output.Result[0].TTL) * time.Second),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (p *ZiboTokenProvider) nextTransID() string {
|
||||
return p.appID + time.Now().Format("20060102150405") +
|
||||
fmt.Sprintf("%08d", p.sequence.Add(1)%100000000)
|
||||
}
|
||||
Reference in New Issue
Block a user