package upstreamimpl import ( "bytes" "context" "encoding/json" "errors" "fmt" "math" "net/url" "strconv" "strings" "sync" "time" "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" "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 { if path == "singleCutNet" { data, err = s.client.DoPostJSONWriteRequest(ctx, "/api/v3/"+path, data) } else { 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 apnWriteStarted := err == nil && post && path == "singleCutNet" 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 { item := dxV3Failure[T](key, err) var business *dxV3Error if apnWriteStarted && !errors.As(err, &business) { if apn, ok := any(&item.ResData).(*model.SimCardOperateChangeApnItem); ok { apn.Uncertain = true } } return item } 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) { prepare := 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 } convert := func(item map[string]any) (model.SimCardOperateChangeApnItem, error) { order, err := dxV3Order(item) return model.SimCardOperateChangeApnItem{OrderNum: order.OrderNum}, err } items := make(model.SimCardOperateChangeApnOutput, 0, len(input.SimKeys)) checkpoint, durable := ctx.Value(model.ApnOperationCheckpointKey{}).(func(model.SimCardOperateChangeApnOutput) error) for _, key := range input.SimKeys { item := dxV3QueryOne(ctx, s, key, "singleCutNet", prepare, convert, true) if item.Code == consts.ApiStatusSuccess && (input.ReqData.SubmitOnly || durable) { item.ResData.Uncertain = true item.Message = "电信单独断网请求已受理,最终办理结果未确认,请核对上游" } items = append(items, item) if durable { if err := checkpoint(items); err != nil { return nil, fmt.Errorf("电信 v3 第%d张卡办理结果持久化失败,停止后续写入:%w", len(items), err) } } } return items, nil } var _ model.SimCardChannelI = (*sSimDXV3)(nil)