270 lines
8.7 KiB
Go
270 lines
8.7 KiB
Go
package upstreamimpl
|
||
|
||
import (
|
||
"context"
|
||
"encoding/json"
|
||
"fmt"
|
||
"net/http"
|
||
"net/url"
|
||
"os"
|
||
"strings"
|
||
"sync"
|
||
"testing"
|
||
"time"
|
||
|
||
"git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/model"
|
||
"git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/model/entity"
|
||
|
||
"github.com/gogf/gf/v2/frame/g"
|
||
)
|
||
|
||
// 真实移动 OneLink 上游功能验证(默认跳过)。
|
||
//
|
||
// 仅在显式设置 APN_REAL_CARRIER_TEST=1 时执行,并且只做本文件声明的动作,
|
||
// 用于小范围真实卡的功能验证。凭证从文件读取(APN_REAL_CREDS_FILE),
|
||
// 不出现在命令行或代码里。
|
||
//
|
||
// 环境变量:
|
||
//
|
||
// APN_REAL_CARRIER_TEST=1 必需,否则整包跳过
|
||
// APN_REAL_CREDS_FILE=<path> 两行:appid / appsecret
|
||
// APN_REAL_BASE_URL=<url> 默认 https://api.iot.10086.cn/v5
|
||
// APN_REAL_CARDS=<iccid:msisdn:imsi;...> 卡清单
|
||
// APN_REAL_ACTION=apninfo|submit|query
|
||
// APN_REAL_OPERTYPE=0|1 submit 时使用:0=开 1=停
|
||
// APN_REAL_APN=CMIOT submit 时使用
|
||
// APN_REAL_JOBID=<jobId> query 时使用
|
||
func TestRealCarrierApnFunctional(t *testing.T) {
|
||
if strings.TrimSpace(os.Getenv("APN_REAL_CARRIER_TEST")) != "1" {
|
||
t.Skip("真实上游测试未启用:设置 APN_REAL_CARRIER_TEST=1 才执行")
|
||
}
|
||
appID, appSecret, err := loadRealCarrierCreds()
|
||
if err != nil {
|
||
t.Fatalf("读取凭证失败:%v", err)
|
||
}
|
||
baseURL := strings.TrimSpace(os.Getenv("APN_REAL_BASE_URL"))
|
||
if baseURL == "" {
|
||
baseURL = "https://api.iot.10086.cn/v5"
|
||
}
|
||
keys, err := loadRealCarrierKeys()
|
||
if err != nil {
|
||
t.Fatalf("解析卡清单失败:%v", err)
|
||
}
|
||
channel := &sSimYD{
|
||
channelConfig: &model.SimChannelConfigItemCache{Channel: &entity.TSimChannels{ChanKey: "real-carrier-verify", AppKey: appID}},
|
||
tokenProvider: &realCarrierToken{appID: appID, appSecret: appSecret, baseURL: baseURL},
|
||
apiConfig: &oneLinkAPIConfig{BaseURL: baseURL, Timeout: 30 * time.Second},
|
||
}
|
||
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute)
|
||
defer cancel()
|
||
|
||
switch strings.TrimSpace(os.Getenv("APN_REAL_ACTION")) {
|
||
case "apninfo":
|
||
apnInfoProbe(t, ctx, channel, keys)
|
||
case "submit":
|
||
submitProbe(t, ctx, channel, keys)
|
||
case "query":
|
||
queryProbe(t, ctx, channel, keys)
|
||
default:
|
||
t.Fatalf("APN_REAL_ACTION 必须为 apninfo|submit|query")
|
||
}
|
||
}
|
||
|
||
// apninfoProbe 只读:查询每张卡当前已开通 APN 列表及状态。
|
||
func apnInfoProbe(t *testing.T, ctx context.Context, channel *sSimYD, keys []*model.SimKey) {
|
||
for _, key := range keys {
|
||
params := g.Map{"transid": channel.getTransId(ctx), "iccid": key.ICCID}
|
||
resp, err := channel.requestYd(ctx, "QuerySimApnInfo", channel.getUrl(ctx, "ec/query/apn-info"), params)
|
||
if err != nil {
|
||
t.Errorf("apn-info %s 请求失败:%v", key.ICCID, err)
|
||
continue
|
||
}
|
||
body := resp.ReadAllString()
|
||
fmt.Printf("APNINFO iccid=%s msisdn=%s body=%s\n", key.ICCID, key.MSISDN, oneLine(body))
|
||
}
|
||
}
|
||
|
||
// submitProbe 真实提交一次批量开/停,只取受理结果(SubmitOnly),不轮询。
|
||
func submitProbe(t *testing.T, ctx context.Context, channel *sSimYD, keys []*model.SimKey) {
|
||
operType := strings.TrimSpace(os.Getenv("APN_REAL_OPERTYPE"))
|
||
if operType == "" {
|
||
t.Fatalf("submit 必须指定 APN_REAL_OPERTYPE(0开/1停)")
|
||
}
|
||
apn := strings.TrimSpace(os.Getenv("APN_REAL_APN"))
|
||
if apn == "" {
|
||
apn = "CMIOT"
|
||
}
|
||
checkpointed := make(model.SimCardOperateChangeApnOutput, 0)
|
||
ctx = context.WithValue(ctx, model.ApnOperationCheckpointKey{}, func(partial model.SimCardOperateChangeApnOutput) error {
|
||
checkpointed = append(checkpointed, partial...)
|
||
return nil
|
||
})
|
||
items, err := changeSimApnFunctionYD(ctx, channel, &model.SimCardOperateChangeApnInput{
|
||
SimKeys: keys,
|
||
ReqData: model.SimCardOperateChangeApnInputItem{OperType: operType, ApnName: apn, SubmitOnly: true, OperationID: "real-verify-" + fmt.Sprint(time.Now().Unix())},
|
||
})
|
||
if err != nil {
|
||
t.Fatalf("提交失败:%v", err)
|
||
}
|
||
fmt.Printf("SUBMIT operType=%s apn=%s cards=%d checkpointed=%d\n", operType, apn, len(items), len(checkpointed))
|
||
jobIDs := map[string]int{}
|
||
for _, item := range items {
|
||
if item == nil || item.SimKey == nil {
|
||
t.Errorf("返回项缺少卡关联")
|
||
continue
|
||
}
|
||
jobIDs[item.ResData.OrderNum]++
|
||
fmt.Printf("SUBMIT_ITEM iccid=%s code=%d pending=%v uncertain=%v jobId=%s message=%s\n",
|
||
item.SimKey.ICCID, item.Code, item.ResData.Pending, item.ResData.Uncertain, item.ResData.OrderNum, item.Message)
|
||
}
|
||
fmt.Printf("SUBMIT_JOBS %v\n", jobIDs)
|
||
}
|
||
|
||
// queryProbe 真实查询一次批量任务结果(单次,不轮询)。
|
||
func queryProbe(t *testing.T, ctx context.Context, channel *sSimYD, keys []*model.SimKey) {
|
||
jobID := strings.TrimSpace(os.Getenv("APN_REAL_JOBID"))
|
||
if jobID == "" {
|
||
t.Fatalf("query 必须指定 APN_REAL_JOBID")
|
||
}
|
||
items, err := changeSimApnFunctionYD(ctx, channel, &model.SimCardOperateChangeApnInput{
|
||
SimKeys: keys,
|
||
ReqData: model.SimCardOperateChangeApnInputItem{QueryJobID: jobID},
|
||
})
|
||
if err != nil {
|
||
t.Fatalf("查询失败:%v", err)
|
||
}
|
||
fmt.Printf("QUERY jobId=%s cards=%d\n", jobID, len(items))
|
||
for _, item := range items {
|
||
if item == nil || item.SimKey == nil {
|
||
t.Errorf("返回项缺少卡关联")
|
||
continue
|
||
}
|
||
fmt.Printf("QUERY_ITEM iccid=%s msisdn=%s code=%d pending=%v uncertain=%v jobId=%s message=%s\n",
|
||
item.SimKey.ICCID, item.SimKey.MSISDN, item.Code, item.ResData.Pending, item.ResData.Uncertain, item.ResData.OrderNum, item.Message)
|
||
}
|
||
}
|
||
|
||
// realCarrierToken 直接从 OneLink 换 token,不落库、不依赖数据库。
|
||
type realCarrierToken struct {
|
||
appID string
|
||
appSecret string
|
||
baseURL string
|
||
|
||
mu sync.Mutex
|
||
token string
|
||
expireAt time.Time
|
||
seq int64
|
||
}
|
||
|
||
func (p *realCarrierToken) GetToken(ctx context.Context) (string, error) {
|
||
p.mu.Lock()
|
||
defer p.mu.Unlock()
|
||
if p.token != "" && time.Now().Before(p.expireAt.Add(-2*time.Minute)) {
|
||
return p.token, nil
|
||
}
|
||
return p.fetch(ctx)
|
||
}
|
||
|
||
func (p *realCarrierToken) RecoverToken(ctx context.Context, rejected string) (string, error) {
|
||
p.mu.Lock()
|
||
defer p.mu.Unlock()
|
||
if p.token != "" && p.token != rejected {
|
||
return p.token, nil
|
||
}
|
||
p.token = ""
|
||
return p.fetch(ctx)
|
||
}
|
||
|
||
func (p *realCarrierToken) fetch(ctx context.Context) (string, error) {
|
||
p.seq++
|
||
transid := p.appID + time.Now().Format("20060102150405") + fmt.Sprintf("%08d", p.seq)
|
||
endpoint := strings.TrimRight(p.baseURL, "/") + "/ec/get/token?" + url.Values{
|
||
"appid": {p.appID},
|
||
"password": {p.appSecret},
|
||
"transid": {transid},
|
||
"refresh": {"0"},
|
||
}.Encode()
|
||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
resp, err := http.DefaultClient.Do(req)
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
defer resp.Body.Close()
|
||
var parsed struct {
|
||
Status string `json:"status"`
|
||
Message string `json:"message"`
|
||
Result []struct {
|
||
Token string `json:"token"`
|
||
Ttl int `json:"ttl"`
|
||
} `json:"result"`
|
||
}
|
||
if err := json.NewDecoder(resp.Body).Decode(&parsed); err != nil {
|
||
return "", err
|
||
}
|
||
if parsed.Status != "0" || len(parsed.Result) == 0 || parsed.Result[0].Token == "" {
|
||
return "", fmt.Errorf("换 token 失败 status=%s message=%s", parsed.Status, parsed.Message)
|
||
}
|
||
p.token = parsed.Result[0].Token
|
||
ttl := parsed.Result[0].Ttl
|
||
if ttl <= 0 {
|
||
ttl = 3600
|
||
}
|
||
p.expireAt = time.Now().Add(time.Duration(ttl) * time.Second)
|
||
return p.token, nil
|
||
}
|
||
|
||
func loadRealCarrierCreds() (string, string, error) {
|
||
path := strings.TrimSpace(os.Getenv("APN_REAL_CREDS_FILE"))
|
||
if path == "" {
|
||
return "", "", fmt.Errorf("未设置 APN_REAL_CREDS_FILE")
|
||
}
|
||
raw, err := os.ReadFile(path)
|
||
if err != nil {
|
||
return "", "", err
|
||
}
|
||
lines := make([]string, 0, 2)
|
||
for _, line := range strings.Split(string(raw), "\n") {
|
||
if line = strings.TrimSpace(line); line != "" {
|
||
lines = append(lines, line)
|
||
}
|
||
}
|
||
if len(lines) < 2 {
|
||
return "", "", fmt.Errorf("凭证文件需要两行:appid / appsecret")
|
||
}
|
||
return lines[0], lines[1], nil
|
||
}
|
||
|
||
func loadRealCarrierKeys() ([]*model.SimKey, error) {
|
||
raw := strings.TrimSpace(os.Getenv("APN_REAL_CARDS"))
|
||
if raw == "" {
|
||
return nil, fmt.Errorf("未设置 APN_REAL_CARDS")
|
||
}
|
||
keys := make([]*model.SimKey, 0)
|
||
for _, entry := range strings.Split(raw, ";") {
|
||
entry = strings.TrimSpace(entry)
|
||
if entry == "" {
|
||
continue
|
||
}
|
||
parts := strings.Split(entry, ":")
|
||
if len(parts) < 2 {
|
||
return nil, fmt.Errorf("卡项格式应为 iccid:msisdn[:imsi]:%q", entry)
|
||
}
|
||
key := &model.SimKey{ChanKey: "real-carrier-verify", ICCID: strings.TrimSpace(parts[0]), MSISDN: strings.TrimSpace(parts[1])}
|
||
if len(parts) > 2 {
|
||
key.IMSI = strings.TrimSpace(parts[2])
|
||
}
|
||
keys = append(keys, key)
|
||
}
|
||
if len(keys) == 0 {
|
||
return nil, fmt.Errorf("卡清单为空")
|
||
}
|
||
return keys, nil
|
||
}
|
||
|
||
func oneLine(body string) string {
|
||
return strings.ReplaceAll(strings.ReplaceAll(body, "\n", " "), "\r", " ")
|
||
}
|