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= 两行:appid / appsecret // APN_REAL_BASE_URL= 默认 https://api.iot.10086.cn/v5 // APN_REAL_CARDS= 卡清单 // APN_REAL_ACTION=apninfo|submit|query // APN_REAL_OPERTYPE=0|1 submit 时使用:0=开 1=停 // APN_REAL_APN=CMIOT submit 时使用 // APN_REAL_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", " ") }