package upstreamimpl import ( "context" "encoding/json" "fmt" "strconv" "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" ) // 移动 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)) if jobID := strings.TrimSpace(input.ReqData.QueryJobID); jobID != "" { return mapYdQueryResult(input.SimKeys, jobID, queryBatchResultOnceYD(ctx, c, jobID)), nil } // 缺 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)...) if input.ReqData.SubmitOnly { if checkpoint, ok := ctx.Value(model.ApnOperationCheckpointKey{}).(func(model.SimCardOperateChangeApnOutput) error); ok { if err := checkpoint(items); err != nil { g.Log().Errorf(ctx, "APN批量结果持久化失败 operationId=%s error=%v", input.ReqData.OperationID, err) return items, fmt.Errorf("APN批量结果持久化失败,停止后续办理:%w", err) } } } } if input.ReqData.SubmitOnly { for _, key := range singleKeys { items = append(items, changeApnSingleYD(ctx, c, []*model.SimKey{key}, input.ReqData)...) if checkpoint, ok := ctx.Value(model.ApnOperationCheckpointKey{}).(func(model.SimCardOperateChangeApnOutput) error); ok { if err := checkpoint(items); err != nil { g.Log().Errorf(ctx, "APN单卡结果持久化失败 operationId=%s error=%v", input.ReqData.OperationID, err) return items, fmt.Errorf("APN单卡结果持久化失败,停止后续办理:%w", err) } } } } else { 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":"89860000000000000002","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) item := ydApnErrorItem(simKey, err.Error()) item.ResData.Uncertain = req.SubmitOnly items = append(items, item) 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) item := ydApnErrorItem(simKey, err.Error()) item.ResData.Uncertain = req.SubmitOnly items = append(items, item) 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, ydApnRejectedItem(simKey, 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 { item := ydApnErrorItem(simKey, err.Error()) item.ResData.Uncertain = req.SubmitOnly items = append(items, item) } 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 { item := ydApnErrorItem(simKey, err.Error()) item.ResData.Uncertain = req.SubmitOnly items = append(items, item) } 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, ydApnRejectedItem(simKey, 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 { item := ydApnResultItem(simKey, consts.ApiStatusError, "上游受理成功但未返回 jobId,需核对受理结果", "") item.ResData.Uncertain = true items = append(items, item) } return items } if req.SubmitOnly { for _, simKey := range simKeys { item := ydApnResultItem(simKey, 0, "上游已受理,等待查询最终结果", jobId) item.ResData.Pending = true items = append(items, item) } 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 { item := ydApnResultItem(simKey, consts.ApiStatusError, "上游批量结果未返回该卡", jobId) item.ResData.Uncertain = true items = append(items, item) continue } item := ydApnResultItem(simKey, card.code, card.message, jobId) item.ResData.Uncertain = card.uncertain items = append(items, item) } default: // 轮询超时或查询失败:该子批下每张卡都按"查询上游通信结果失败"落库,避免误报成功。 // 该文案会逐卡写进执行日志明细;日后若增加"重新查询上游结果"的补偿能力,从这一层补做即可。 g.Log().Warningf(ctx, "changeApnBatchYD result poll not finished, jobId %s, params %+v", jobId, params) for _, simKey := range simKeys { item := ydApnResultItem(simKey, consts.ApiStatusError, ydBatchResultQueryFailedMessage+":"+result.reason, jobId) item.ResData.Uncertain = true items = append(items, item) } } return items } func (s *sSimYD) QuerySimApnFunctionResult(ctx context.Context, input *model.SimCardOperateChangeApnInput) (model.SimCardOperateChangeApnOutput, error) { return mapYdQueryResult(input.SimKeys, input.ReqData.QueryJobID, queryBatchResultOnceYD(ctx, s, input.ReqData.QueryJobID)), nil } func (s *sSimYDMock) QuerySimApnFunctionResult(ctx context.Context, input *model.SimCardOperateChangeApnInput) (model.SimCardOperateChangeApnOutput, error) { return mapYdQueryResult(input.SimKeys, input.ReqData.QueryJobID, queryBatchResultOnceYD(ctx, s, input.ReqData.QueryJobID)), nil } // ydBatchCardResult 批量任务中单张卡的最终办理结果。 type ydBatchCardResult struct { code int message string uncertain bool } // ydBatchQueryResult 批量办理结果查询的结论。 type ydBatchQueryResult struct { finished bool // 是否已取得终态(jobStatus 2/3/4) failed bool // jobStatus=4,整批处理失败 cards map[string]ydBatchCardResult // resultId(msisdn)-> 单卡结果 reason string } // 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 { pollCtx, cancel := context.WithTimeout(ctx, ydBatchResultPollTimeout) defer cancel() var last ydBatchQueryResult for { if err := pollCtx.Err(); err != nil { last.reason = "查询上游通信结果截止:" + err.Error() + ";" + last.reason return last } last = queryBatchResultOnceYD(pollCtx, c, jobId) if last.finished { return last } timer := time.NewTimer(ydBatchResultPollInterval) select { case <-pollCtx.Done(): timer.Stop() last.reason = "查询上游通信结果截止:" + pollCtx.Err().Error() + ";" + last.reason return last case <-timer.C: } } } // queryBatchResultOnceYD 只查询一次;持久化调度器负责后续补查。 func queryBatchResultOnceYD(ctx context.Context, c ydApnRequester, jobID string) ydBatchQueryResult { params := g.Map{"transid": c.getTransId(ctx), "jobId": jobID} resp, err := c.requestYd(ctx, "QuerySimBatchResult", c.getUrl(ctx, ydCommunicationBatchResultPath), params) if err != nil { return ydBatchQueryResult{reason: "查询上游通信结果失败:" + err.Error()} } body := resp.ReadAllString() var parsed ydBatchResultResp if err := json.Unmarshal([]byte(body), &parsed); err != nil { return ydBatchQueryResult{reason: "查询上游通信结果解析失败:" + err.Error()} } if parsed.Status != "0" { return ydBatchQueryResult{reason: "查询上游通信结果失败,status=" + parsed.Status + ",message=" + parsed.Message} } if len(parsed.Result) == 0 { return ydBatchQueryResult{reason: "上游查询未返回任务结果"} } 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, reason: parsed.Message} default: return ydBatchQueryResult{reason: "上游任务尚未完成,jobStatus=" + parsed.Result[0].JobStatus} } } func mapYdQueryResult(keys []*model.SimKey, jobID string, result ydBatchQueryResult) model.SimCardOperateChangeApnOutput { items := make(model.SimCardOperateChangeApnOutput, 0, len(keys)) for _, key := range keys { if key == nil { continue } item := ydApnResultItem(key, consts.ApiStatusError, result.reason, jobID) switch { case !result.finished: item.ResData.Pending = true case result.failed: item.Message = "上游批量任务处理失败:" + result.reason default: if card, ok := result.cards[strings.TrimSpace(key.MSISDN)]; ok { item.Code, item.Message = card.code, card.message item.ResData.Uncertain = card.uncertain } else { item.Message = "上游批量结果未返回该卡,需继续核对" item.ResData.Uncertain = true } } items = append(items, item) } return items } // 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 } code, err := strconv.Atoi(strings.TrimSpace(item.Status)) if err != nil { cards[id] = ydBatchCardResult{code: consts.ApiStatusError, message: "上游逐卡结果状态无效,status=" + item.Status + ",message=" + item.Message, uncertain: true} continue } cards[id] = ydBatchCardResult{code: code, message: item.Message} } return cards } func ydApnRejectedItem(key *model.SimKey, status, message string) *model.SimBaseRes[model.SimCardOperateChangeApnItem] { code, err := strconv.Atoi(strings.TrimSpace(status)) if err != nil || code == 0 { item := ydApnErrorItem(key, "上游受理状态无效,status="+status+",message="+message) item.ResData.Uncertain = true return item } return ydApnResultItem(key, code, message, "") } 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}, } }