From 3e5a7453b86bbb1af0fcf925112462400bbe5239 Mon Sep 17 00:00:00 2001 From: MiFaZhan Date: Thu, 29 Oct 2026 14:59:32 +0800 Subject: [PATCH] =?UTF-8?q?sync:=20=E5=90=8C=E6=AD=A5=20v1.0.4=20=E5=BC=80?= =?UTF-8?q?=E6=BA=90=E4=BB=A3=E7=A0=81=E5=B9=B6=E8=84=B1=E6=95=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitattributes | 1 + README.MD | 40 ++ api/cardquery/cardquery.go | 1 + api/cardquery/v1/mingqian_package_flow.go | 2 +- api/cardquery/v1/simcard.go | 7 + build/Dockerfile | 21 +- .../telecom-v3-integration-and-switch.md | 2 +- docs/scripts/query_sim_card_status.sh | 10 +- docs/scripts/query_sim_session.sh | 12 +- docs/sql/migrate_shared_token_templates.sql | 37 ++ docs/sql/seed_onelink_shared_channel.sql | 65 +++ docs/sql/seed_recharge_channel.sql | 100 +++++ docs/sql/seed_template_models.sql | 19 + go.mod | 7 +- go.sum | 60 +++ hack/hack.mk | 9 +- internal/cmd/http_test.go | 259 +++++++++++- .../cardquery/cardquery_v1_simcard.go | 8 + .../cardquery/mingqian_package_flow.go | 2 +- .../logic/channelconfig/channel_config.go | 2 +- .../channelconfig/channel_config_test.go | 2 +- internal/logic/upstreamapi/apn_operation.go | 300 ++++++++++++++ .../upstreamapi/apn_operation_mysql_test.go | 163 ++++++++ .../logic/upstreamapi/apn_operation_test.go | 376 ++++++++++++++++++ .../logic/upstreamapi/channel_cache_test.go | 6 +- .../logic/upstreamapi/channel_test_runtime.go | 4 +- .../logic/upstreamapi/pack_chan_key_test.go | 229 +++++++++++ internal/logic/upstreamapi/upstreamapi.go | 73 +++- .../logic/upstreamapi/upstreamapi_batch.go | 2 +- .../apn_real_carrier_measure_test.go | 255 ++++++++++++ .../upstreamimpl/apn_real_carrier_test.go | 269 +++++++++++++ .../beixin_token_provider_test.go | 6 +- internal/logic/upstreamimpl/dx/client.go | 47 ++- internal/logic/upstreamimpl/http_config.go | 2 +- .../mingqian_registration_test.go | 2 +- internal/logic/upstreamimpl/sim_daihe.go | 2 +- .../upstreamimpl/sim_diag_mapping_test.go | 12 +- internal/logic/upstreamimpl/sim_dx.go | 8 +- internal/logic/upstreamimpl/sim_dx_apn.go | 43 +- .../logic/upstreamimpl/sim_dx_apn_test.go | 157 +++++++- internal/logic/upstreamimpl/sim_dx_batch.go | 6 +- .../upstreamimpl/sim_dx_network_block.go | 2 +- .../upstreamimpl/sim_dx_network_block_test.go | 8 +- .../logic/upstreamimpl/sim_dx_status_test.go | 8 +- .../logic/upstreamimpl/sim_dx_usage_test.go | 10 +- internal/logic/upstreamimpl/sim_dx_v3.go | 47 ++- .../logic/upstreamimpl/sim_dx_v3_batch.go | 2 +- internal/logic/upstreamimpl/sim_dx_v3_test.go | 176 +++++++- internal/logic/upstreamimpl/sim_enum.go | 2 +- internal/logic/upstreamimpl/sim_mingqian.go | 2 +- .../logic/upstreamimpl/sim_onelink_shared.go | 2 +- internal/logic/upstreamimpl/sim_qianxun.go | 4 +- internal/logic/upstreamimpl/sim_recharge.go | 6 +- .../upstreamimpl/sim_recharge_cache_test.go | 4 +- internal/logic/upstreamimpl/sim_tibiot.go | 34 +- .../sim_tibiot_rate_limit_test.go | 84 +++- internal/logic/upstreamimpl/sim_yd.go | 30 +- .../upstreamimpl/sim_yd_apn_communication.go | 204 +++++++--- .../sim_yd_apn_communication_test.go | 191 +++++++++ internal/logic/upstreamimpl/sim_yd_mock.go | 30 +- internal/logic/upstreamimpl/sim_yd_token.go | 2 +- .../upstreamimpl/yunze_token_provider_test.go | 8 +- internal/model/sim_chan_api.go | 21 +- internal/pub/pkg/version/version.go | 2 +- internal/pub/xjpubconsts/http.go | 2 +- .../pub/xjpublogic/middleware/middleware.go | 2 +- internal/pub/xjpubmodel/response/common.go | 2 +- internal/pub/xjpubmodel/response/response.go | 2 +- internal/service/mingqian/adapter.go | 2 +- internal/service/mingqian/mingqian_test.go | 17 +- internal/service/mingqian/package_flow.go | 2 +- internal/service/upstreamapi.go | 1 + internal/token/token.go | 6 +- main.go | 2 +- 74 files changed, 3297 insertions(+), 248 deletions(-) create mode 100644 README.MD mode change 100644 => 100755 docs/scripts/query_sim_card_status.sh mode change 100644 => 100755 docs/scripts/query_sim_session.sh create mode 100644 docs/sql/migrate_shared_token_templates.sql create mode 100644 docs/sql/seed_onelink_shared_channel.sql create mode 100644 docs/sql/seed_recharge_channel.sql create mode 100644 docs/sql/seed_template_models.sql create mode 100644 internal/logic/upstreamapi/apn_operation.go create mode 100644 internal/logic/upstreamapi/apn_operation_mysql_test.go create mode 100644 internal/logic/upstreamapi/apn_operation_test.go create mode 100644 internal/logic/upstreamapi/pack_chan_key_test.go create mode 100644 internal/logic/upstreamimpl/apn_real_carrier_measure_test.go create mode 100644 internal/logic/upstreamimpl/apn_real_carrier_test.go diff --git a/.gitattributes b/.gitattributes index 5837541..357a48f 100644 --- a/.gitattributes +++ b/.gitattributes @@ -11,3 +11,4 @@ *.ttf binary *.pdf binary *.exe binary +*.go linguist-language=Go diff --git a/README.MD b/README.MD new file mode 100644 index 0000000..bcaba2e --- /dev/null +++ b/README.MD @@ -0,0 +1,40 @@ +# iotlcm-upstream-chan + +配置示例见 `build/config.example.yaml` 与 `config/config.example.yaml`。请使用获授权的测试卡。 + +## 本地测试 +``` +curl -d '{"simKeys":[ + {"chanKey":"channel_003", + "iccid":"89860000000000000003", + "imsi":"", + "msisdn":""} +], "reqData":{"yearMonth":"202602"}}' 127.0.0.1:8110/sim/query/sim-datausage-monthly + +curl -d '{"simKeys":[ + {"chanKey":"channel_003", + "iccid":"89860000000000000003", + "imsi":"", + "msisdn":""} +], "reqData":{"queryDate":"20260201"}}' 127.0.0.1:8110/sim/query/sim-datausage-daily +``` + +### 异步全量消费者直调约定 + +本月实时累计用量全量同步使用 `POST /sim/query/sim-datausage`,不经过 Proxy。消费者从卡信息表取得 `chanKey` 和三码后直接发送: + +```json +{ + "simKeys": [ + { + "chanKey": "channel_003", + "iccid": "89860000000000000003", + "imsi": "", + "msisdn": "1440000000003" + } + ], + "reqData": {} +} +``` + +一条请求应只包含同一个 `chanKey` 的卡。响应由 GoFrame 统一包装为 `{code,message,data}`;`data` 按输入卡逐项返回,单项 `code=0` 表示成功,通道或网络等本地错误使用 `9999`,运营商返回的业务错误码原样保留。`data[].resData.dataUsage` 已统一为 MB。该接口查询调用时的当前月份,不使用 `queryDate`;需要指定历史月份时使用独立的月查询接口。 diff --git a/api/cardquery/cardquery.go b/api/cardquery/cardquery.go index 991465c..9ece5a7 100644 --- a/api/cardquery/cardquery.go +++ b/api/cardquery/cardquery.go @@ -29,5 +29,6 @@ type ICardqueryV1 interface { SimDataUsageDailyBatch(ctx context.Context, req *v1.SimDataUsageDailyBatchReq) (res *v1.SimDataUsageDailyBatchRes, err error) SimApnInfo(ctx context.Context, req *v1.SimApnInfoReq) (res *v1.SimApnInfoRes, err error) SimStatusChange(ctx context.Context, req *v1.SimStatusChangeReq) (res *v1.SimStatusChangeRes, err error) + SimApnCapability(ctx context.Context, req *v1.SimApnCapabilityReq) (res *v1.SimApnCapabilityRes, err error) SimApnChange(ctx context.Context, req *v1.SimApnChangeReq) (res *v1.SimApnChangeRes, err error) } diff --git a/api/cardquery/v1/mingqian_package_flow.go b/api/cardquery/v1/mingqian_package_flow.go index cc06a5d..bdf5bdc 100644 --- a/api/cardquery/v1/mingqian_package_flow.go +++ b/api/cardquery/v1/mingqian_package_flow.go @@ -1,8 +1,8 @@ package v1 import ( - "git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/model" "github.com/gogf/gf/v2/frame/g" + "git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/model" ) type MingqianPackageFlowReq struct { diff --git a/api/cardquery/v1/simcard.go b/api/cardquery/v1/simcard.go index 2e9dccf..eb122f6 100644 --- a/api/cardquery/v1/simcard.go +++ b/api/cardquery/v1/simcard.go @@ -161,4 +161,11 @@ type SimApnChangeReq struct { } // 物联网卡APN变更 响应参数 +type SimApnCapabilityReq struct { + g.Meta `path:"/sim/query/sim-apn-capability" tags:"CardQuery" method:"POST" summary:"只读探测APN异步持久化协议能力"` + model.SimCardOperateChangeApnInput +} + +type SimApnCapabilityRes = model.SimApnCapabilityOutput + type SimApnChangeRes = model.SimCardOperateChangeApnOutput diff --git a/build/Dockerfile b/build/Dockerfile index 1206796..9502fad 100644 --- a/build/Dockerfile +++ b/build/Dockerfile @@ -1,33 +1,38 @@ -FROM golang:1.25-alpine AS builder - +ARG BASE_REGISTRY=docker.io/library +FROM ${BASE_REGISTRY}/golang:1.25-alpine AS builder LABEL stage=gobuilder ENV GOPROXY=https://goproxy.cn,direct +# RUN apk update --no-cache && apk add --no-cache tzdata +# 安装GCC和相关依赖 +# RUN apk add --no-cache gcc g++ make autoconf automake binutils-dev gmp-dev isl-dev patchelf +# RUN apk update --no-cache && apk add --no-cache tzdata git expect + WORKDIR /build -COPY go.mod go.sum ./ +COPY go.mod go.sum ./ RUN go mod download -COPY . . +COPY . . ARG BuildVersion ARG BuildTime ARG CommitID -RUN go build -o iotlcmupstreamchan_svc -ldflags "-w -s -X main.BuildVersion=${BuildVersion} -X main.CommitID=${CommitID} -X main.BuildTime=${BuildTime}" +RUN go build -o iotlcmupstreamchan_svc -ldflags "-w -s -X main.BuildVersion=${BuildVersion} -X main.CommitID=${CommitID} -X main.BuildTime=${BuildTime}" -FROM alpine:3.23 +FROM ${BASE_REGISTRY}/alpine:3.23 AS alpine RUN apk add --no-cache ca-certificates tzdata - +# RUN apk update --no-cache && apk add --no-cache ca-certificates ENV TZ=Asia/Shanghai EXPOSE 11140 WORKDIR /app COPY --from=builder /build/iotlcmupstreamchan_svc /app/iotlcmupstreamchan_svc -COPY --from=builder /build/build/dev_config.yaml /app/config/config.yaml +COPY --from=builder /build/build/config.example.yaml /app/config/config.yaml CMD ["./iotlcmupstreamchan_svc"] diff --git a/docs/plans/telecom-v3-integration-and-switch.md b/docs/plans/telecom-v3-integration-and-switch.md index c42c2e8..eb5a7b3 100644 --- a/docs/plans/telecom-v3-integration-and-switch.md +++ b/docs/plans/telecom-v3-integration-and-switch.md @@ -1,6 +1,6 @@ # 电信 v1 现状、v3 并行接入与切换 -调查日期:2026-10-10。代码依据为当前本地三个服务仓库;数据库通过 DBX 只读查询 `开发环境(192.0.2.29).db_iotlcm`。未修改数据库、未部署、未调用电信真实查询或操作接口。 +调查日期:2026-10-10。代码依据为当前本地三个服务仓库;数据库通过 DBX 只读查询 `开发环境(127.0.0.1).db_iotlcm`。未修改数据库、未部署、未调用电信真实查询或操作接口。 接口依据:用户提供的《中国电信天翼物联5GCMP能力调用参考文档-V3.pdf》,464 页,封面版本 `V3.2026.1.7`;《iotlcm上游API静态审计明细(41条审计记录).xlsx》,其中 21 条为电信记录,其余为移动记录。推荐路径以 PDF 接口定义为准;示例与参数表冲突的地方单独记录。 diff --git a/docs/scripts/query_sim_card_status.sh b/docs/scripts/query_sim_card_status.sh old mode 100644 new mode 100755 index 807bee1..0fed31e --- a/docs/scripts/query_sim_card_status.sh +++ b/docs/scripts/query_sim_card_status.sh @@ -1,6 +1,6 @@ #!/usr/bin/env bash # 物联网卡卡状态查询 — 请求体与 internal/model/sim_chan_api.go 中 SimCardStatusInput 一致(simKeys) -# 参数说明见 docs/测试资源.md +# 请仅使用获得授权的测试卡 # 用法:在工程根目录执行 # chmod +x docs/scripts/query_sim_card_status.sh # ./docs/scripts/query_sim_card_status.sh @@ -12,11 +12,11 @@ BASE_URL="${BASE_URL:-http://127.0.0.1:8110}" PATH_QUERY="/query/sim-status" CHAN_KEY="${CHAN_KEY:-YiDong_EC}" -if [[ -n "${ICCID:-}" ]]; then - IFS=',' read -r -a ICCIDS <<< "${ICCID}" -else - ICCIDS=("898604F4152391115473" "898604F4152391115474") +if [[ -z "${ICCID:-}" ]]; then + echo "请设置 ICCID(仅获授权的测试卡)" >&2 + exit 2 fi +IFS=',' read -r -a ICCIDS <<< "${ICCID}" json_simkeys="[" sep="" diff --git a/docs/scripts/query_sim_session.sh b/docs/scripts/query_sim_session.sh old mode 100644 new mode 100755 index df4affe..ae2149f --- a/docs/scripts/query_sim_session.sh +++ b/docs/scripts/query_sim_session.sh @@ -1,6 +1,6 @@ #!/usr/bin/env bash # 查询 SIM 卡会话 — 请求体与 internal/model/sim_chan_api.go 中 SimSessionInput 一致(simKeys) -# 参数说明见 docs/测试资源.md +# 请仅使用获得授权的测试卡 # 用法:在工程根目录执行 # chmod +x docs/scripts/query_sim_session.sh # ./docs/scripts/query_sim_session.sh @@ -12,12 +12,12 @@ BASE_URL="${BASE_URL:-http://127.0.0.1:8110}" PATH_QUERY="/query/sim-session" CHAN_KEY="${CHAN_KEY:-YiDong_EC}" -# 默认 ICCID 来自测试资源(两张移动卡) -if [[ -n "${ICCID:-}" ]]; then - IFS=',' read -r -a ICCIDS <<< "${ICCID}" -else - ICCIDS=("898604F4152391115473" "898604F4152391115474") +# 不内置真实卡号;调用者必须提供 ICCID +if [[ -z "${ICCID:-}" ]]; then + echo "请设置 ICCID(仅获授权的测试卡)" >&2 + exit 2 fi +IFS=',' read -r -a ICCIDS <<< "${ICCID}" json_simkeys="[" sep="" diff --git a/docs/sql/migrate_shared_token_templates.sql b/docs/sql/migrate_shared_token_templates.sql new file mode 100644 index 0000000..8838052 --- /dev/null +++ b/docs/sql/migrate_shared_token_templates.sql @@ -0,0 +1,37 @@ +-- Migration guide for the shared-token template split. +-- Review rows before running against a production database. This script does +-- not move or retain Henan Lelian credentials. +SET NAMES utf8mb4; + +-- 1. Inspect the archived template and its channels. +SELECT temp_key, temp_model, temp_config +FROM t_sim_channel_template +WHERE temp_key = 'sim_onelink_shared_v5'; + +SELECT chan_key, chan_config, chan_status +FROM t_sim_channels +WHERE temp_key = 'sim_onelink_shared_v5'; + +-- 2. Seed the new templates first (see seed_onelink_shared_channel.sql), +-- then migrate only Tengyu rows after reviewing their provider value. +-- The JSON functions require MySQL 5.7+ and intentionally preserve only the +-- channel credentials. Public API/provider settings live in the new template. +UPDATE t_sim_channels +SET temp_key = 'sim_onelink_tengyu_v1', + chan_config = JSON_OBJECT( + 'credentials', JSON_OBJECT( + 'pwdKey', JSON_UNQUOTE(JSON_EXTRACT(chan_config, '$.auth.pwdKey')), + 'channelNo', JSON_UNQUOTE(JSON_EXTRACT(chan_config, '$.auth.channelNo')) + ) + ) +WHERE temp_key = 'sim_onelink_shared_v5' + AND LOWER(JSON_UNQUOTE(JSON_EXTRACT(chan_config, '$.auth.provider'))) = 'tengyu'; + +-- 3. Henan Lelian rows must be disabled and manually migrated or removed. +UPDATE t_sim_channels +SET chan_status = 0 +WHERE temp_key = 'sim_onelink_shared_v5' + AND LOWER(JSON_UNQUOTE(JSON_EXTRACT(chan_config, '$.auth.provider'))) = 'henan_lelian'; + +-- 4. After all rows are reviewed and migrated, remove the archived template. +-- DELETE FROM t_sim_channel_template WHERE temp_key = 'sim_onelink_shared_v5'; diff --git a/docs/sql/seed_onelink_shared_channel.sql b/docs/sql/seed_onelink_shared_channel.sql new file mode 100644 index 0000000..579cc35 --- /dev/null +++ b/docs/sql/seed_onelink_shared_channel.sql @@ -0,0 +1,65 @@ +-- Shared OneLink template/channel examples. Token endpoint and credentials +-- belong to the shared-token template configuration. +SET NAMES utf8mb4; + +INSERT INTO `t_sim_channel_template` ( + `temp_key`, `temp_name`, `temp_model`, `temp_config`, `temp_status`, + `tenant_id`, `created_dept`, `created_by`, `updated_by` +) VALUES ( + 'sim_onelink_tengyu_v1', + 'Tengyu shared OneLink token v1', + 'shared_token', + '{"token":{"provider":"tengyu","baseUrl":"http://www.sdtywl.cn/","path":"/prod-api/open/api/oneLinkToken"}}', + 1, '000000', 103, 1, 1 +) +ON DUPLICATE KEY UPDATE + `temp_name` = VALUES(`temp_name`), `temp_model` = VALUES(`temp_model`), + `temp_config` = VALUES(`temp_config`), `temp_status` = VALUES(`temp_status`), + `updated_by` = VALUES(`updated_by`); + +INSERT INTO `t_sim_channels` ( + `temp_key`, `chan_key`, `chan_name`, `chan_config`, `chan_status`, + `chan_alias`, `region_code`, `app_key`, `app_secret`, `access_token`, + `refresh_token`, `tenant_id`, `created_dept`, `created_by`, `updated_by` +) VALUES ( + 'sim_onelink_tengyu_v1', + 'OneLink_TENGYU_EXAMPLE', + 'OneLink Tengyu shared-token example', + '{"credentials":{"pwdKey":"REPLACE_WITH_PWDKEY","channelNo":"REPLACE_WITH_CHANNEL_NO"}}', + 1, '', '', '', '', '', '', '000000', 103, 1, 1 +) +ON DUPLICATE KEY UPDATE + `temp_key` = VALUES(`temp_key`), `chan_key` = VALUES(`chan_key`), + `chan_config` = VALUES(`chan_config`), `chan_status` = VALUES(`chan_status`), + `updated_by` = VALUES(`updated_by`); + +INSERT INTO `t_sim_channel_template` ( + `temp_key`, `temp_name`, `temp_model`, `temp_config`, `temp_status`, + `tenant_id`, `created_dept`, `created_by`, `updated_by` +) VALUES ( + 'sim_onelink_xuzhou_mobile_v1', + 'Xuzhou Mobile shared OneLink token v1', + 'shared_token', + '{"token":{"provider":"xuzhou_mobile","baseUrl":"http://iot.yuanjianiot.com","path":"/admin/api/get/token","carrierType":1}}', + 1, '000000', 103, 1, 1 +) +ON DUPLICATE KEY UPDATE + `temp_name` = VALUES(`temp_name`), `temp_model` = VALUES(`temp_model`), + `temp_config` = VALUES(`temp_config`), `temp_status` = VALUES(`temp_status`), + `updated_by` = VALUES(`updated_by`); + +INSERT INTO `t_sim_channels` ( + `temp_key`, `chan_key`, `chan_name`, `chan_config`, `chan_status`, + `chan_alias`, `region_code`, `app_key`, `app_secret`, `access_token`, + `refresh_token`, `tenant_id`, `created_dept`, `created_by`, `updated_by` +) VALUES ( + 'sim_onelink_xuzhou_mobile_v1', + 'OneLink_XUZHOU_MOBILE_EXAMPLE', + 'OneLink Xuzhou Mobile shared-token example', + '{"credentials":{"appId":"REPLACE_WITH_APP_ID"}}', + 1, '', '', '', '', '', '', '000000', 103, 1, 1 +) +ON DUPLICATE KEY UPDATE + `temp_key` = VALUES(`temp_key`), `chan_name` = VALUES(`chan_name`), + `chan_config` = VALUES(`chan_config`), `chan_status` = VALUES(`chan_status`), + `updated_by` = VALUES(`updated_by`); diff --git a/docs/sql/seed_recharge_channel.sql b/docs/sql/seed_recharge_channel.sql new file mode 100644 index 0000000..1031e3d --- /dev/null +++ b/docs/sql/seed_recharge_channel.sql @@ -0,0 +1,100 @@ +SET NAMES utf8mb4; + +-- 腾宇(综合充值平台)通道初始化。 +-- 字段复用约定(internal/logic/upstreamimpl/sim_recharge.go): +-- t_sim_channels.app_key = pwdkey(个人中心 > 密钥 Key) +-- t_sim_channels.app_secret = pwdiv(签名 IV,sign=md5(pwdkey+pwdiv+timestamp)) +-- 执行前必须替换以下三个占位符。 +SET @recharge_pwdkey = 'REPLACE_WITH_PWDKEY'; +SET @recharge_pwdiv = 'REPLACE_WITH_PWDIV_OR_IV'; +SET @recharge_chan_key = 'REPLACE_WITH_LOCAL_CHAN_KEY'; + +-- 上线前需在腾宇后台配置网关出口 IP 白名单,否则上游返回 407。 +-- 上游单接口限流 60 次/分;全量同步触发 429 时由下轮重试兜底。 + +START TRANSACTION; + +INSERT INTO `t_sim_channel_template` ( + `temp_key`, + `temp_name`, + `temp_model`, + `temp_config`, + `temp_status`, + `tenant_id`, + `created_dept`, + `created_by`, + `updated_by` +) VALUES ( + 'sim_recharge_sdtywl_v1', + '综合充值平台 API', + '{}', + '{"baseUrl":"http://www.sdtywl.cn/"}', + 0, + '000000', + 100, + 1, + 1 +) +ON DUPLICATE KEY UPDATE + `temp_name` = VALUES(`temp_name`), + `temp_model` = VALUES(`temp_model`), + `temp_config` = VALUES(`temp_config`), + `temp_status` = VALUES(`temp_status`), + `updated_by` = VALUES(`updated_by`); + +INSERT INTO `t_sim_channels` ( + `temp_key`, + `chan_key`, + `chan_name`, + `chan_config`, + `chan_status`, + `chan_alias`, + `region_code`, + `app_key`, + `app_secret`, + `access_token`, + `access_token_created`, + `access_token_expire`, + `refresh_token`, + `login_url`, + `tenant_id`, + `created_dept`, + `created_by`, + `updated_by` +) VALUES ( + 'sim_recharge_sdtywl_v1', + @recharge_chan_key, + '仟所腾宇', + '{}', + 0, + '腾宇', + '', + @recharge_pwdkey, + @recharge_pwdiv, + NULL, + NULL, + NULL, + NULL, + 'http://www.sdtywl.cn/', + '000000', + 100, + 1, + 1 +) +ON DUPLICATE KEY UPDATE + `temp_key` = VALUES(`temp_key`), + `chan_name` = VALUES(`chan_name`), + `chan_config` = VALUES(`chan_config`), + `chan_status` = VALUES(`chan_status`), + `chan_alias` = VALUES(`chan_alias`), + `region_code` = VALUES(`region_code`), + `app_key` = VALUES(`app_key`), + `app_secret` = VALUES(`app_secret`), + `access_token` = NULL, + `access_token_created` = NULL, + `access_token_expire` = NULL, + `refresh_token` = NULL, + `login_url` = VALUES(`login_url`), + `updated_by` = VALUES(`updated_by`); + +COMMIT; diff --git a/docs/sql/seed_template_models.sql b/docs/sql/seed_template_models.sql new file mode 100644 index 0000000..e65f9de --- /dev/null +++ b/docs/sql/seed_template_models.sql @@ -0,0 +1,19 @@ +-- Public protocol configuration for the built-in templates. +-- Run after the corresponding template rows exist. +SET NAMES utf8mb4; + +INSERT INTO t_sim_channel_template + (temp_key, temp_name, temp_model, temp_config, temp_status, tenant_id, created_dept, created_by, updated_by) +VALUES + ('sim_yd_v5', 'China Mobile OneLink v5', 'direct', + '{"api":{"baseUrl":"https://api.iot.10086.cn/v5/","timeout":"10s"},"token":{"path":"/ec/get/token"}}', + 1, '000000', 103, 1, 1), + ('sim_yd_mock_v5', 'China Mobile OneLink mock v5', 'direct', + '{"api":{"baseUrl":"http://iotlcm.example.com/onelink-mock/v5/"}}', + 1, '000000', 103, 1, 1), + ('sim_dx_v1', 'China Telecom API v1', 'direct', + '{"api":{"baseUrl":"https://cmp-api.ctwing.cn:20164"}}', + 1, '000000', 103, 1, 1) +ON DUPLICATE KEY UPDATE + temp_model = VALUES(temp_model), temp_config = VALUES(temp_config), + temp_status = VALUES(temp_status), updated_by = VALUES(updated_by); diff --git a/go.mod b/go.mod index b53e010..cd694cd 100644 --- a/go.mod +++ b/go.mod @@ -6,12 +6,10 @@ require ( github.com/gogf/gf/contrib/drivers/mysql/v2 v2.10.0 github.com/gogf/gf/contrib/drivers/sqlite/v2 v2.10.0 github.com/gogf/gf/contrib/nosql/redis/v2 v2.10.0 - github.com/gogf/gf/contrib/registry/consul/v2 v2.10.3 github.com/gogf/gf/contrib/rpc/grpcx/v2 v2.10.0 github.com/gogf/gf/v2 v2.10.3 golang.org/x/sync v0.20.0 google.golang.org/grpc v1.79.3 - google.golang.org/protobuf v1.36.11 ) require ( @@ -27,8 +25,9 @@ require ( github.com/glebarez/go-sqlite v1.21.2 // indirect github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/stdr v1.2.2 // indirect - github.com/go-sql-driver/mysql v1.7.1 // indirect + github.com/go-sql-driver/mysql v1.7.1 github.com/go-viper/mapstructure/v2 v2.4.0 // indirect + github.com/gogf/gf/contrib/registry/consul/v2 v2.10.3 github.com/gogf/gf/contrib/registry/file/v2 v2.10.0 // indirect github.com/google/uuid v1.6.0 // indirect github.com/gorilla/websocket v1.5.3 // indirect @@ -53,6 +52,7 @@ require ( github.com/redis/go-redis/v9 v9.18.0 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/rivo/uniseg v0.4.7 // indirect + github.com/tjfoc/gmsm v1.4.1 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect go.opentelemetry.io/otel v1.39.0 // indirect go.opentelemetry.io/otel/metric v1.39.0 // indirect @@ -64,6 +64,7 @@ require ( golang.org/x/sys v0.39.0 // indirect golang.org/x/text v0.32.0 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 // indirect + google.golang.org/protobuf v1.36.11 gopkg.in/yaml.v3 v3.0.1 // indirect modernc.org/libc v1.22.5 // indirect modernc.org/mathutil v1.5.0 // indirect diff --git a/go.sum b/go.sum index c798b85..43c1b75 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,5 @@ +cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= +github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= github.com/BurntSushi/toml v1.5.0 h1:W5quZX/G/csjUnuI8SUYlsHs9M38FC7znL0lIO+DvMg= github.com/BurntSushi/toml v1.5.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= github.com/DataDog/datadog-go v3.2.0+incompatible/go.mod h1:LButxg5PwREeZtORoXG3tL4fMGNddJ+vMq1mwgfaqoQ= @@ -19,6 +21,7 @@ github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs= github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c= github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA= github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0= +github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= @@ -26,6 +29,8 @@ github.com/circonus-labs/circonus-gometrics v2.3.1+incompatible/go.mod h1:nmEj6D github.com/circonus-labs/circonusllhist v0.1.3/go.mod h1:kMXHVDlOchFAehlya5ePtbp5jckzBHf4XRpQvBOLI+I= github.com/clbanning/mxj/v2 v2.7.0 h1:WA/La7UGCanFe5NpHF0Q3DNtnCsVoxbPKuyBNHWRyME= github.com/clbanning/mxj/v2 v2.7.0/go.mod h1:hNiWqW14h+kc+MdF9C6/YoRfjEJoR3ou6tn/Qo+ve2s= +github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= +github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= @@ -36,6 +41,9 @@ github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkp github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/emirpasic/gods/v2 v2.0.0-alpha h1:dwFlh8pBg1VMOXWGipNMRt8v96dKAIvBehtCt6OtunU= github.com/emirpasic/gods/v2 v2.0.0-alpha/go.mod h1:W0y4M2dtBB9U5z3YlghmpuUhiaZT2h6yoeE+C1sCp6A= +github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= +github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98= +github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4= github.com/fatih/color v1.9.0/go.mod h1:eQcE1qtQxscV5RaZvpXrrb8Drkc3/DdQ+uUYCNjL+zU= github.com/fatih/color v1.13.0/go.mod h1:kLAiJbzzSOZDVNGyDpeOxJ47H46qBXwg5ILebYFFOfk= @@ -71,17 +79,30 @@ github.com/gogf/gf/contrib/registry/file/v2 v2.10.0 h1:yy93PGhz2CE3PpF2JSGa2MXJW github.com/gogf/gf/contrib/registry/file/v2 v2.10.0/go.mod h1:ZzGWiTbQ9nAmDynrmzaRJlqYHD/F3MeMwCvHrkv/LP4= github.com/gogf/gf/contrib/rpc/grpcx/v2 v2.10.0 h1:MWHgX/h/62+Oo1Jbeq8DrXbEWSOZxskpBBm3cFAV4/Q= github.com/gogf/gf/contrib/rpc/grpcx/v2 v2.10.0/go.mod h1:zGZdjS08IqiSGhKfy69/qgcYKUYUL/VsOx5fuT++TVE= +github.com/gogf/gf/v2 v2.10.0 h1:rzDROlyqGMe/eM6dCalSR8dZOuMIdLhmxKSH1DGhbFs= +github.com/gogf/gf/v2 v2.10.0/go.mod h1:Svl1N+E8G/QshU2DUbh/3J/AJauqCgUnxHurXWR4Qx0= github.com/gogf/gf/v2 v2.10.3 h1:tZvEk+58x9uyuOAMgT5VV8yAPHGPBwJkPYRuXj2cfBI= github.com/gogf/gf/v2 v2.10.3/go.mod h1:Svl1N+E8G/QshU2DUbh/3J/AJauqCgUnxHurXWR4Qx0= github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ= +github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= +github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.3.3/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw= +github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8= +github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA= +github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs= +github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w= +github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0= +github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= github.com/google/btree v1.0.1 h1:gK4Kx5IaGY9CD5sPJ36FHiBJ6ZXl0kilRiiCj+jdYp4= github.com/google/btree v1.0.1/go.mod h1:xXMiIv4Fb/0kKde4SpL7qlzvu5cMJDRkFDxJfI9uaxA= +github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= +github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= @@ -208,6 +229,7 @@ github.com/prometheus/client_golang v1.0.0/go.mod h1:db9x61etRT2tGnBNRi70OPL5Fsn github.com/prometheus/client_golang v1.4.0/go.mod h1:e9GMxYsXl05ICDXkRhurwBS4Q3OK1iX/F2sw+iXX5zU= github.com/prometheus/client_model v0.0.0-20180712105110-5c3871d89910/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo= github.com/prometheus/client_model v0.0.0-20190129233127-fd36f4220a90/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= +github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= github.com/prometheus/client_model v0.2.0/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= github.com/prometheus/common v0.4.1/go.mod h1:TNfzLD0ON7rHzMJeJkieUDPYmFC7Snx/y86RQel1bk4= github.com/prometheus/common v0.9.1/go.mod h1:yhUN8i9wzaXS3w1O07YhxHEBxD+W35wd8bs7vj7HSQ4= @@ -239,6 +261,8 @@ github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81P github.com/stretchr/testify v1.7.2/go.mod h1:R6va5+xMeoiuVRoj+gSkQ7d3FALtqAAGI1FQKckRals= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/tjfoc/gmsm v1.4.1 h1:aMe1GlZb+0bLjn+cKTPEvvn9oUEBlJitaZiiBwsbgho= +github.com/tjfoc/gmsm v1.4.1/go.mod h1:j4INPkHWMrhJb38G+J6W4Tw0AbuN8Thu3PbdVYhVcTE= github.com/tv42/httpunix v0.0.0-20150427012821-b75d8614f926/go.mod h1:9ESjWnEqriFuLhtthL60Sar/7RFoluCcXsuvEwTV5KM= github.com/zeebo/xxh3 v1.0.2 h1:xZmwmqxHZA8AI603jOQ0tMqmBr9lPeFwGg6d+xy9DC0= github.com/zeebo/xxh3 v1.0.2/go.mod h1:5NWz9Sef7zIDm2JHfFlcQvNekmcEl9ekUZQQKCYaDcA= @@ -261,17 +285,30 @@ go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20190923035154-9ee001bba392/go.mod h1:/lpIB1dKB+9EgE3H3cr1v9wB50oz8l4C4h62xy7jSTY= +golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.0.0-20201012173705-84dcc777aaee/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20260312153236-7ab1446f8b90 h1:jiDhWWeC7jfWqR9c/uplMOqJ0sbNlNWv0UkzE0vX1MA= golang.org/x/exp v0.0.0-20260312153236-7ab1446f8b90/go.mod h1:xE1HEv6b+1SCZ5/uscMRjUBKtIxworgEcEi+/n9NQDQ= +golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= +golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU= +golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= +golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20181114220301-adae6a3d119a/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190613194153-d28f0bde5980/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20190923162816-aa69164e4478/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20201010224723-4f7140c49acb/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20210410081132-afb366fc7cd1/go.mod h1:9tjilg8BloeKEkVJvy7fQ90B1CfIiPueXVOjqfkSzI8= golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU= golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY= +golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= +golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -280,10 +317,12 @@ golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJ golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.0.0-20180823144017-11551d06cbcc/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20181116152217-5ac8a444bdc5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190922100055-0a153f010e69/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190924154521-2837fb4f24fe/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -291,6 +330,7 @@ golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200122134326-e047566fdf82/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210303074136-134d130e1a04/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -310,15 +350,33 @@ golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU= golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY= +golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= golang.org/x/tools v0.0.0-20190907020128-2ca718005c18/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= +google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= +google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= +google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= +google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 h1:gRkg/vSppuSQoDjxyiGfN4Upv/h/DQmIR10ZU8dh4Ww= google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk= +google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= +google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg= +google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY= +google.golang.org/grpc v1.31.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak= google.golang.org/grpc v1.79.3 h1:sybAEdRIEtvcD68Gx7dmnwjZKlyfuc61Dyo9pGXXkKE= google.golang.org/grpc v1.79.3/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ= +google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= +google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= +google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= +google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE= +google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo= +google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw= @@ -332,6 +390,8 @@ gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.5/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= +honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= modernc.org/libc v1.22.5 h1:91BNch/e5B0uPbJFgqbxXuOnxBQjlS//icfQEGmvyjE= modernc.org/libc v1.22.5/go.mod h1:jj+Z7dTNX8fBScMVNRAYZ/jF91K8fdT2hYMThc3YjBY= modernc.org/mathutil v1.5.0 h1:rV0Ko/6SfM+8G+yKiyI830l3Wuz1zRutdslNoQ0kfiQ= diff --git a/hack/hack.mk b/hack/hack.mk index 119acb9..a78e6bf 100644 --- a/hack/hack.mk +++ b/hack/hack.mk @@ -5,7 +5,7 @@ up: cli.install @gf up -a -# Build binary using configuration from hack/config.yaml. +# Build binary using configuration from hack/config.example.yaml. .PHONY: build build: cli.install @gf build -ew @@ -75,10 +75,6 @@ pbentity: cli.install @gf gen pbentity -mod: - - - export AppName=jieiot_linux export BuildVersion=$(shell git describe --abbrev=0 --tags) export BuildTime=$(shell date +%FT%T%z) @@ -88,7 +84,7 @@ export HasImages=$(shell docker images | grep docker_iotlcmupstreamchan:v0 | gre .PHONY: docker_iotlcmupstreamchan docker_iotlcmupstreamchan: if [ -n "${HasImages}" ]; then \ - docker tag docker_iotlcmupstreamchan:v0 ${REGISTRY}/iotlcm/iotlcmupstreamchansvr:v0 \ + docker tag docker_iotlcmupstreamchan:v0 docker_iotlcmupstreamchan:v0.${NOWTIME}.bak; \ docker rmi docker_iotlcmupstreamchan:v0; \ fi docker build --rm -f build/Dockerfile --progress=plain --platform linux/amd64 -t docker_iotlcmupstreamchan:v0 --build-arg BuildVersion=${BuildVersion} --build-arg BuildTime=${BuildTime} --build-arg CommitID=${CommitID} . @@ -96,4 +92,3 @@ docker_iotlcmupstreamchan: dockerbuild: docker_iotlcmupstreamchan echo "docker build success" -dockerpush: \ No newline at end of file diff --git a/internal/cmd/http_test.go b/internal/cmd/http_test.go index f469ff4..38ba93b 100644 --- a/internal/cmd/http_test.go +++ b/internal/cmd/http_test.go @@ -1,16 +1,268 @@ package cmd import ( + "context" + "encoding/json" "fmt" + "io" "net/http" + "net/http/httptest" + "os" + "path/filepath" "strings" + "sync" "testing" + "time" + + "github.com/go-sql-driver/mysql" + _ "github.com/gogf/gf/contrib/drivers/mysql/v2" + "github.com/gogf/gf/v2/database/gdb" "github.com/gogf/gf/v2/frame/g" "github.com/gogf/gf/v2/net/ghttp" "github.com/gogf/gf/v2/os/gcfg" + "github.com/gogf/gf/v2/os/gtime" + "gopkg.in/yaml.v3" + "git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/logic/upstreamapi" + "git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/logic/upstreamimpl" + "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" ) +// TestAPNHTTPHarness 仅供跨模块测试子进程使用,不启动真实运营商或默认业务连接。 +func TestAPNHTTPHarness(t *testing.T) { + if os.Getenv("APN_HTTP_CHAIN_HELPER") != "1" { + t.Skip("未启用本机HTTP模拟链") + } + body, err := os.ReadFile(filepath.Join("..", "..", "..", "iotlcm-sms", "deploy", "config.yaml")) + if err != nil { + t.Fatal("读取本机认证配置失败") + } + var source struct { + DSN string `yaml:"sceneDbDsn"` + } + if yaml.Unmarshal(body, &source) != nil { + t.Fatal("解析本机认证配置失败") + } + dsn, err := mysql.ParseDSN(source.DSN) + if err != nil || dsn.Net != "tcp" || (dsn.Addr != "127.0.0.1:3306" && dsn.Addr != "localhost:3306") { + t.Fatal("不是已授权本机连接") + } + node := gdb.ConfigNode{Type: "mysql", Host: "127.0.0.1", Port: "3306", User: dsn.User, Pass: dsn.Passwd, Name: "iotdb_apn_chan_test_20261015"} + db, err := gdb.New(node) + if err != nil { + t.Fatal("创建专用测试连接失败") + } + identity, err := db.GetOne(context.Background(), "SELECT @@port AS port,@@server_uuid AS uuid,@@datadir AS datadir,DATABASE() AS dbname") + if err != nil || identity["port"].String() != "3306" || identity["uuid"].String() != "4fa545af-ab36-11f1-be02-040e3c9de361" || identity["dbname"].String() != node.Name || strings.TrimRight(strings.ReplaceAll(identity["datadir"].String(), "\\", "/"), "/") != "D:/MySQL/MySQL Server 8.0/Data" { + t.Fatal("专用测试实例身份核验失败") + } + if _, err := db.GetAll(context.Background(), "SELECT revision FROM t_apn_operation LIMIT 0"); err != nil { + t.Fatal("先执行指定测试库迁移回归") + } + adapter, _ := gcfg.NewAdapterContent(`{}`) + g.Cfg().SetAdapter(adapter) + if err := gdb.SetConfig(gdb.Config{"default": gdb.ConfigGroup{node}}); err != nil { + t.Fatal("设置专用测试连接失败") + } + var mu sync.Mutex + writes, queries := map[string]int{}, map[string]int{} + submits, unexpected := 0, 0 + jobs := map[string][]string{} + operator := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + mu.Lock() + defer mu.Unlock() + if strings.HasSuffix(r.URL.Path, "/sim-communication-function/batch") { + numbers := strings.Split(r.URL.Query().Get("msisdns"), "_") + first := numbers[0] + submits++ + for _, number := range numbers { + writes[number]++ + } + if strings.HasPrefix(first, "lost-") { + conn, _, e := w.(http.Hijacker).Hijack() + if e != nil { + t.Error(e) + return + } + _ = conn.Close() + return + } + job := "fake-job-" + first + jobs[job] = numbers + _ = json.NewEncoder(w).Encode(g.Map{"status": "0", "result": []g.Map{{"jobId": job}}}) + return + } + if strings.HasSuffix(r.URL.Path, "/sim-batch-result") { + job := r.URL.Query().Get("jobId") + queries[job]++ + if queries[job] == 1 { + _, _ = io.WriteString(w, `{"status":"14008","message":"尚未可查"}`) + return + } + if queries[job] == 2 { + _, _ = io.WriteString(w, `{"status":"0","result":[{"jobStatus":"1"}]}`) + return + } + if strings.Contains(job, "failed-") { + _, _ = io.WriteString(w, `{"status":"0","message":"明确失败","result":[{"jobStatus":"4"}]}`) + return + } + cards := []g.Map{} + for _, number := range jobs[job] { + cards = append(cards, g.Map{"resultId": number, "status": "0", "message": "明确成功"}) + } + _ = json.NewEncoder(w).Encode(g.Map{"status": "0", "result": []g.Map{{"jobStatus": "2", "resultList": cards}}}) + return + } + unexpected++ + t.Errorf("模拟运营商收到非预期请求:%s", r.URL.Path) + http.Error(w, "非预期请求", 500) + })) + defer operator.Close() + channel := upstreamimpl.NewSimYD().NewTemp(context.Background(), &model.SimChannelConfigItemCache{ + Template: &entity.TSimChannelTemplate{TempConfig: `{"api":{"baseUrl":"` + operator.URL + `/v5"},"http":{"timeout":"2s"}}`}, + Channel: &entity.TSimChannels{ChanKey: "fake-channel", AppKey: "fake", AccessToken: "fake-token", AccessTokenExpire: gtime.New(time.Now().Add(time.Hour))}, + }) + register := func() { + api := upstreamapi.NewUpstreamAPI() + api.RegisterChan("fake-channel", "1", &model.SimCardChannelCache{Instance: channel}) + service.RegisterUpstreamAPI(api) + } + register() + server := ghttp.GetServer(t.Name()) + server.SetAddr("127.0.0.1:0") + bindHTTPRoutes(server) + if err := server.Start(); err != nil { + t.Fatal(err) + } + defer server.Shutdown() + stop := make(chan struct{}) + var stopOnce sync.Once + dropped := map[string]bool{} + front := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/__test/stats": + mu.Lock() + defer mu.Unlock() + _ = json.NewEncoder(w).Encode(g.Map{"writes": writes, "queries": queries, "submits": submits, "unexpected": unexpected}) + return + case "/__test/stop": + stopOnce.Do(func() { close(stop) }) + return + case "/__test/restart": + register() + return + } + raw, _ := io.ReadAll(r.Body) + var input model.SimCardOperateChangeApnInput + if json.Unmarshal(raw, &input) != nil || len(input.SimKeys) == 0 || input.SimKeys[0].ChanKey != "fake-channel" { + http.Error(w, "只接受假卡", 400) + return + } + req, _ := http.NewRequestWithContext(r.Context(), r.Method, fmt.Sprintf("http://127.0.0.1:%d%s", server.GetListenedPort(), r.URL.Path), strings.NewReader(string(raw))) + req.Header.Set("Content-Type", "application/json") + res, e := http.DefaultClient.Do(req) + if e != nil { + http.Error(w, e.Error(), 500) + return + } + out, _ := io.ReadAll(res.Body) + _ = res.Body.Close() + mu.Lock() + lose := input.ReqData.SubmitOnly && !dropped[input.ReqData.OperationID] && (strings.Contains(input.ReqData.OperationID, "drop-response") || strings.Contains(input.ReqData.OperationID, "top-error")) + if lose { + dropped[input.ReqData.OperationID] = true + } + mu.Unlock() + if lose { + if strings.Contains(input.ReqData.OperationID, "top-error") { + _, _ = io.WriteString(w, `{"code":9999,"message":"模拟受理后网关结果丢失"}`) + return + } + conn, _, e := w.(http.Hijacker).Hijack() + if e != nil { + t.Error(e) + return + } + _ = conn.Close() + return + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(res.StatusCode) + _, _ = w.Write(out) + })) + defer front.Close() + fmt.Println("APN_CHAIN_READY=" + front.URL) + select { + case <-stop: + case <-time.After(3 * time.Minute): + t.Error("HTTP模拟链未正常结束") + } +} + +type apnBindingTestAPI struct { + service.IUpstreamAPI + captured chan model.SimCardOperateChangeApnInput +} + +func (api *apnBindingTestAPI) ChangeSimCardOperateApn(_ context.Context, input *model.SimCardOperateChangeApnInput) (model.SimCardOperateChangeApnOutput, error) { + api.captured <- *input + return model.SimCardOperateChangeApnOutput{}, nil +} + +func (api *apnBindingTestAPI) QuerySimApnCapability(_ context.Context, input *model.SimCardOperateChangeApnInput) (model.SimApnCapabilityOutput, error) { + api.captured <- *input + return model.SimApnCapabilityOutput{}, nil +} + +func TestAPNProductionHTTPBindingPreservesAsyncFields(t *testing.T) { + old := service.UpstreamAPI() + api := &apnBindingTestAPI{IUpstreamAPI: old, captured: make(chan model.SimCardOperateChangeApnInput, 1)} + service.RegisterUpstreamAPI(api) + t.Cleanup(func() { service.RegisterUpstreamAPI(old) }) + server := ghttp.GetServer(t.Name()) + server.SetAddr("127.0.0.1:0") + bindHTTPRoutes(server) + if err := server.Start(); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = server.Shutdown() }) + for _, tc := range []struct { + path string + data model.SimCardOperateChangeApnInputItem + }{ + {"/sim/change/sim-apn", model.SimCardOperateChangeApnInputItem{OperationID: "frozen-operation", OperType: "1", ApnName: "CMIOT", SubmitOnly: true}}, + {"/sim/change/sim-apn", model.SimCardOperateChangeApnInputItem{OperationID: "frozen-operation", OperType: "1", ApnName: "CMIOT", QueryJobID: "saved-job"}}, + {"/sim/change/sim-apn", model.SimCardOperateChangeApnInputItem{OperationID: "frozen-operation", OperType: "1", ApnName: "CMIOT", RecoverOnly: true}}, + {"/sim/query/sim-apn-capability", model.SimCardOperateChangeApnInputItem{OperationID: "frozen-operation", OperType: "1", ApnName: "CMIOT"}}, + } { + input := model.SimCardOperateChangeApnInput{SimKeys: []*model.SimKey{{ChanKey: "fake", ICCID: "fake-card", MSISDN: "frozen-number", IMSI: "frozen-imsi"}}, ReqData: tc.data} + client := g.Client().ContentJson() + res, err := client.Post(context.Background(), fmt.Sprintf("http://127.0.0.1:%d%s", server.GetListenedPort(), tc.path), input) + if err != nil { + t.Fatal(err) + } + body := res.ReadAll() + _ = res.Close() + var envelope struct { + Code int `json:"code"` + } + if json.Unmarshal(body, &envelope) != nil || envelope.Code != 0 { + t.Fatalf("生产绑定失败:%s", body) + } + select { + case got := <-api.captured: + if got.ReqData != tc.data || len(got.SimKeys) != 1 || *got.SimKeys[0] != *input.SimKeys[0] { + t.Fatalf("异步字段或快照丢失:%+v", got) + } + default: + t.Fatalf("未调用生产controller:%s", body) + } + } +} + func TestInternalChannelTestWithoutTokenReachesRequestValidation(t *testing.T) { oldAdapter := g.Cfg().GetAdapter() adapter, err := gcfg.NewAdapterContent(`{"internal":{}}`) @@ -51,9 +303,10 @@ func TestBindHTTPRoutesRegistersChannelControlRoutes(t *testing.T) { }) want := map[string]bool{ - "/internal/channel/test": false, - "/internal/channel/refresh": false, - "/internal/channel/remove": false, + "/internal/channel/test": false, + "/sim/query/sim-apn-capability": false, + "/internal/channel/refresh": false, + "/internal/channel/remove": false, } for _, route := range server.GetRoutes() { if _, ok := want[route.Route]; ok && strings.EqualFold(route.Method, "POST") { diff --git a/internal/controller/cardquery/cardquery_v1_simcard.go b/internal/controller/cardquery/cardquery_v1_simcard.go index bcb61b1..3316ae3 100644 --- a/internal/controller/cardquery/cardquery_v1_simcard.go +++ b/internal/controller/cardquery/cardquery_v1_simcard.go @@ -151,6 +151,14 @@ func (c *ControllerV1) SimStatusChange(ctx context.Context, req *v1.SimStatusCha return &output, nil } +func (c *ControllerV1) SimApnCapability(ctx context.Context, req *v1.SimApnCapabilityReq) (res *v1.SimApnCapabilityRes, err error) { + output, err := service.UpstreamAPI().QuerySimApnCapability(ctx, &req.SimCardOperateChangeApnInput) + if err != nil { + return nil, err + } + return &output, nil +} + func (c *ControllerV1) SimApnChange(ctx context.Context, req *v1.SimApnChangeReq) (res *v1.SimApnChangeRes, err error) { output, err := service.UpstreamAPI().ChangeSimCardOperateApn(ctx, &req.SimCardOperateChangeApnInput) if err != nil { diff --git a/internal/controller/cardquery/mingqian_package_flow.go b/internal/controller/cardquery/mingqian_package_flow.go index 87b02da..f368207 100644 --- a/internal/controller/cardquery/mingqian_package_flow.go +++ b/internal/controller/cardquery/mingqian_package_flow.go @@ -3,10 +3,10 @@ package cardquery import ( "context" + "github.com/gogf/gf/v2/errors/gerror" v1 "git.5iot.com/iotlcm-public/iotlcm-upstream-chan/api/cardquery/v1" "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/errors/gerror" ) func (controller *ControllerV1) MingqianPackageFlow(ctx context.Context, req *v1.MingqianPackageFlowReq) (*v1.MingqianPackageFlowRes, error) { diff --git a/internal/logic/channelconfig/channel_config.go b/internal/logic/channelconfig/channel_config.go index d82a04e..a7fe710 100644 --- a/internal/logic/channelconfig/channel_config.go +++ b/internal/logic/channelconfig/channel_config.go @@ -9,9 +9,9 @@ import ( "git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/model/entity" "git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/service" - "git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/dao" "sync" "time" + "git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/dao" "github.com/gogf/gf/v2/crypto/gmd5" "github.com/gogf/gf/v2/encoding/gjson" diff --git a/internal/logic/channelconfig/channel_config_test.go b/internal/logic/channelconfig/channel_config_test.go index 47059e0..9e338ec 100644 --- a/internal/logic/channelconfig/channel_config_test.go +++ b/internal/logic/channelconfig/channel_config_test.go @@ -2,8 +2,8 @@ package channelconfig import ( "context" - "git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/model/entity" "testing" + "git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/model/entity" ) func TestChannelVersionDetectsCredentialChanges(t *testing.T) { diff --git a/internal/logic/upstreamapi/apn_operation.go b/internal/logic/upstreamapi/apn_operation.go new file mode 100644 index 0000000..2bbeaba --- /dev/null +++ b/internal/logic/upstreamapi/apn_operation.go @@ -0,0 +1,300 @@ +package upstreamapi + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "sort" + "strings" + "sync" + "time" + "unicode/utf8" + + "github.com/go-sql-driver/mysql" + "github.com/gogf/gf/v2/database/gdb" + "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" +) + +type apnOperationStore interface { + Claim(context.Context, string, string, string) (bool, error) + Load(context.Context, string, string) (string, string, error) + Save(context.Context, string, string, string, string) error +} + +type sqlApnOperationStore struct{ db gdb.DB } + +func (s sqlApnOperationStore) database() gdb.DB { + if s.db != nil { + return s.db + } + return g.DB() +} + +func validateApnOperationIdentity(id, channel, hash string) error { + for _, value := range []string{id, channel} { + if len(value) == 0 || len(value) > 128 || !utf8.ValidString(value) || strings.TrimSpace(value) != value || strings.ContainsRune(value, 0) { + return fmt.Errorf("operationId和chanKey必须为1至128字节且不含首尾空白或空字符") + } + } + decoded, err := hex.DecodeString(hash) + if err != nil || len(decoded) != sha256.Size || hash != strings.ToLower(hash) { + return fmt.Errorf("APN请求摘要必须为小写SHA-256") + } + return nil +} + +func (s sqlApnOperationStore) Claim(ctx context.Context, id, channel, hash string) (bool, error) { + if err := validateApnOperationIdentity(id, channel, hash); err != nil { + return false, err + } + result, err := s.database().Exec(ctx, "INSERT INTO t_apn_operation (operation_id,chan_key,request_hash,response_json,created_at,updated_at) VALUES (?,?,?,'',NOW(),NOW())", id, channel, hash) + if err != nil { + var duplicate *mysql.MySQLError + if errors.As(err, &duplicate) && duplicate.Number == 1062 { + return false, nil + } + return false, err + } + count, err := result.RowsAffected() + if err != nil { + return false, err + } + if count != 1 { + return false, fmt.Errorf("APN提交意图插入行数异常:%d", count) + } + return true, nil +} + +func (s sqlApnOperationStore) Load(ctx context.Context, id, channel string) (string, string, error) { + row, err := s.database().GetOne(ctx, "SELECT request_hash,response_json FROM t_apn_operation WHERE operation_id=? AND chan_key=? AND BINARY operation_id=BINARY ? AND BINARY chan_key=BINARY ?", id, channel, id, channel) + if err != nil { + return "", "", err + } + if len(row) == 0 { + return "", "", nil + } + return row["request_hash"].String(), row["response_json"].String(), nil +} + +func (s sqlApnOperationStore) Save(ctx context.Context, id, channel, hash, response string) error { + if err := validateApnOperationIdentity(id, channel, hash); err != nil { + return err + } + return s.database().Transaction(ctx, func(ctx context.Context, tx gdb.TX) error { + row, err := tx.GetOne("SELECT request_hash,response_json FROM t_apn_operation WHERE operation_id=? AND chan_key=? AND BINARY operation_id=BINARY ? AND BINARY chan_key=BINARY ? FOR UPDATE", id, channel, id, channel) + if err != nil { + return err + } + if len(row) == 0 { + return fmt.Errorf("APN受理映射不存在,禁止无意图保存") + } + if row["request_hash"].String() != hash { + return fmt.Errorf("operationId已绑定不同的办理请求") + } + merged, err := mergeApnOperationJSON(row["response_json"].String(), response) + if err != nil { + return err + } + result, err := tx.Exec("UPDATE t_apn_operation SET response_json=?,revision=revision+1,updated_at=NOW(3) WHERE operation_id=? AND chan_key=? AND request_hash=?", merged, id, channel, hash) + if err != nil { + return err + } + count, err := result.RowsAffected() + if err != nil { + return err + } + if count != 1 { + return fmt.Errorf("APN受理映射保存行数异常:%d", count) + } + return nil + }) +} + +// 已确认逐卡结果不回退未知,已持久化jobId不能被后续空响应或错误抹掉。 +func mergeApnOperationJSON(saved, incoming string) (string, error) { + var output, updates model.SimCardOperateChangeApnOutput + if saved != "" { + if err := json.Unmarshal([]byte(saved), &output); err != nil { + return "", fmt.Errorf("解析已存APN受理映射失败:%w", err) + } + } + if incoming != "" { + if err := json.Unmarshal([]byte(incoming), &updates); err != nil { + return "", fmt.Errorf("解析待存APN受理映射失败:%w", err) + } + } + positions := make(map[model.SimKey]int) + for i, item := range output { + if item == nil || item.SimKey == nil { + return "", fmt.Errorf("已存APN受理映射缺少卡关联") + } + positions[*item.SimKey] = i + } + for _, item := range updates { + if item == nil || item.SimKey == nil { + return "", fmt.Errorf("待存APN受理映射缺少卡关联") + } + i, exists := positions[*item.SimKey] + if !exists { + positions[*item.SimKey] = len(output) + output = append(output, item) + continue + } + old := output[i] + if old.ResData.OrderNum != "" && item.ResData.OrderNum != "" && old.ResData.OrderNum != item.ResData.OrderNum { + return "", fmt.Errorf("同卡APN受理映射出现不同jobId,禁止覆盖") + } + oldTerminal := !old.ResData.Pending && !old.ResData.Uncertain + newTerminal := !item.ResData.Pending && !item.ResData.Uncertain + if oldTerminal || (!newTerminal && old.ResData.OrderNum != "") { + continue + } + if item.ResData.OrderNum == "" { + item.ResData.OrderNum = old.ResData.OrderNum + } + output[i] = item + } + body, err := json.Marshal(output) + return string(body), err +} + +func unknownApnOperation(input *model.SimCardOperateChangeApnInput, reason string) model.SimCardOperateChangeApnOutput { + output := make(model.SimCardOperateChangeApnOutput, 0, len(input.SimKeys)) + for _, key := range input.SimKeys { + output = append(output, &model.SimBaseRes[model.SimCardOperateChangeApnItem]{SimKey: key, Code: consts.ApiStatusError, Message: reason, ResData: model.SimCardOperateChangeApnItem{Uncertain: true}}) + } + return output +} + +// durableApnOperation 先落提交意图,重复投递与重启恢复均不重新办理。 +func durableApnOperation(ctx context.Context, store apnOperationStore, channel string, input *model.SimCardOperateChangeApnInput, submit func(context.Context) (model.SimCardOperateChangeApnOutput, error)) (model.SimCardOperateChangeApnOutput, error) { + id := input.ReqData.OperationID + if len(input.SimKeys) == 0 || len(input.SimKeys) > 100 { + return nil, fmt.Errorf("异步APN子任务必须包含1至100张卡") + } + normalized := *input + normalized.SimKeys = append([]*model.SimKey(nil), input.SimKeys...) + for _, key := range normalized.SimKeys { + if key == nil || key.ChanKey != channel || key.ICCID == "" { + return nil, fmt.Errorf("异步APN子任务卡标识或通道归属无效") + } + } + sort.Slice(normalized.SimKeys, func(i, j int) bool { + a, b := normalized.SimKeys[i], normalized.SimKeys[j] + return a.ICCID+"|"+a.MSISDN+"|"+a.IMSI < b.ICCID+"|"+b.MSISDN+"|"+b.IMSI + }) + normalized.ReqData.QueryJobID = "" + normalized.ReqData.RecoverOnly = false + normalized.ReqData.SubmitOnly = true + payload, err := json.Marshal(normalized) + if err != nil { + return nil, err + } + digest := sha256.Sum256(payload) + hash := hex.EncodeToString(digest[:]) + if err := validateApnOperationIdentity(id, channel, hash); err != nil { + return nil, err + } + claimed := false + if !input.ReqData.RecoverOnly { + claimed, err = store.Claim(ctx, id, channel, hash) + if err != nil { + return nil, fmt.Errorf("保存APN提交意图失败,未调用运营商:%w", err) + } + } + if !claimed { + savedHash, saved, err := store.Load(ctx, id, channel) + if err != nil { + return nil, fmt.Errorf("读取APN受理映射失败:%w", err) + } + if savedHash == "" { + return unknownApnOperation(input, "未找到提交意图,禁止自动重新办理,请核对"), nil + } + if savedHash != hash { + return nil, fmt.Errorf("operationId已绑定不同的办理请求") + } + if saved == "" { + return unknownApnOperation(input, "已记录提交意图但受理结果未确认,禁止重复办理,请核对"), nil + } + return completeApnOperation(input, saved) + } + var checkpointMu sync.Mutex + latest := "" + checkpoint := func(partial model.SimCardOperateChangeApnOutput) error { + checkpointMu.Lock() + defer checkpointMu.Unlock() + snapshot, err := json.Marshal(partial) + if err != nil { + return err + } + merged, err := mergeApnOperationJSON(latest, string(snapshot)) + if err != nil { + return err + } + latest = merged + saveCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 10*time.Second) + defer cancel() + return store.Save(saveCtx, id, channel, hash, latest) + } + submitCtx := context.WithValue(ctx, model.ApnOperationCheckpointKey{}, checkpoint) + output, submitErr := submit(submitCtx) + if submitErr != nil { + output = unknownApnOperation(input, "提交结果未确认:"+submitErr.Error()) + } else if len(output) == 0 { + output = unknownApnOperation(input, "提交未返回逐卡受理结果,请核对") + } + if err := checkpoint(output); err != nil { + g.Log().Errorf(context.WithoutCancel(ctx), "APN受理结果持久化失败 operationId=%s chanKey=%s error=%v", id, channel, err) + return nil, fmt.Errorf("上游受理结果持久化失败,禁止重新办理:%w", err) + } + return completeApnOperation(input, latest) +} + +func (u *sUpstreamAPI) QuerySimApnCapability(ctx context.Context, input *model.SimCardOperateChangeApnInput) (model.SimApnCapabilityOutput, error) { + if len(input.SimKeys) == 0 || len(input.SimKeys) > 100 { + return nil, fmt.Errorf("APN能力探测必须包含1至100张卡") + } + output := make(model.SimApnCapabilityOutput, 0, len(input.SimKeys)) + for _, key := range input.SimKeys { + if key == nil || key.ChanKey == "" || key.ICCID == "" { + return nil, fmt.Errorf("APN能力探测缺少冻结卡标识") + } + output = append(output, &model.SimBaseRes[model.SimApnCapabilityItem]{SimKey: key, ResData: model.SimApnCapabilityItem{ProtocolVersion: 1}}) + } + // 已有 jobId 只读查询运营商,不依赖本地提交意图表;提交和 operationId 恢复仍需该表。 + if strings.TrimSpace(input.ReqData.QueryJobID) == "" || input.ReqData.SubmitOnly || input.ReqData.RecoverOnly { + probeCtx, cancel := context.WithTimeout(ctx, 10*time.Second) + defer cancel() + if _, err := g.DB().GetAll(probeCtx, "SELECT revision FROM t_apn_operation LIMIT 0"); err != nil { + return nil, fmt.Errorf("APN持久化迁移未就绪:%w", err) + } + } + return output, nil +} + +func completeApnOperation(input *model.SimCardOperateChangeApnInput, saved string) (model.SimCardOperateChangeApnOutput, error) { + var output model.SimCardOperateChangeApnOutput + if err := json.Unmarshal([]byte(saved), &output); err != nil { + return nil, fmt.Errorf("解析已持久化受理映射失败:%w", err) + } + for _, key := range input.SimKeys { + found := false + for _, item := range output { + if item != nil && item.SimKey != nil && *item.SimKey == *key { + found = true + break + } + } + if !found { + missing := *input + missing.SimKeys = []*model.SimKey{key} + output = append(output, unknownApnOperation(&missing, "该卡未持久化受理结果,禁止重新办理,请核对")...) + } + } + return output, nil +} diff --git a/internal/logic/upstreamapi/apn_operation_mysql_test.go b/internal/logic/upstreamapi/apn_operation_mysql_test.go new file mode 100644 index 0000000..d6eedae --- /dev/null +++ b/internal/logic/upstreamapi/apn_operation_mysql_test.go @@ -0,0 +1,163 @@ +package upstreamapi + +import ( + "context" + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" + "sync" + "testing" + "time" + + "github.com/go-sql-driver/mysql" + _ "github.com/gogf/gf/contrib/drivers/mysql/v2" + "github.com/gogf/gf/v2/database/gdb" + "gopkg.in/yaml.v3" + "git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/model" +) + +// 仅显式授权的loopback专用实例可写;身份核验始终先于DDL和DML。 +func TestApnOperationMySQLIntegration(t *testing.T) { + if os.Getenv("APN_MYSQL_INTEGRATION") != "1" { + t.Skip("未启用本机专用MySQL集成;不会连接默认业务数据库") + } + const port = "3306" + const name = "iotdb_apn_chan_test_20261015" + const expectedUUID = "4fa545af-ab36-11f1-be02-040e3c9de361" + const expectedDir = "D:/MySQL/MySQL Server 8.0/Data/" + if value := os.Getenv("APN_MYSQL_PORT"); value != "" && value != port { + t.Fatal("只允许已核实的127.0.0.1:3306") + } + if value := os.Getenv("APN_MYSQL_DATABASE"); value != "" && value != name { + t.Fatal("只允许指定chan测试库") + } + configPath := filepath.Join("..", "..", "..", "..", "iotlcm-sms", "deploy", "config.yaml") + configBody, err := os.ReadFile(configPath) + if err != nil { + t.Fatal("无法读取本机已有配置") + } + var config struct { + SceneDbDsn string `yaml:"sceneDbDsn"` + } + if err := yaml.Unmarshal(configBody, &config); err != nil { + t.Fatal("本机已有配置解析失败") + } + dsn, err := mysql.ParseDSN(config.SceneDbDsn) + if err != nil || dsn.Net != "tcp" || (dsn.Addr != "127.0.0.1:3306" && dsn.Addr != "localhost:3306") { + t.Fatal("已有配置不是已授权本机连接,禁止连接") + } + db, err := gdb.New(gdb.ConfigNode{Type: "mysql", Host: "127.0.0.1", Port: port, User: dsn.User, Pass: dsn.Passwd, Name: name}) + if err != nil { + t.Fatal("建立本机测试连接失败") + } + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + identity, err := db.GetOne(ctx, "SELECT @@hostname AS hostname,@@port AS port,@@datadir AS datadir,@@server_uuid AS server_uuid,DATABASE() AS dbname") + if err != nil { + t.Fatal("读取本机实例身份失败") + } + normalize := func(s string) string { + return strings.ToLower(strings.TrimRight(filepath.ToSlash(strings.ReplaceAll(s, "\\", "/")), "/")) + } + if identity["port"].String() != port || identity["server_uuid"].String() != expectedUUID || identity["dbname"].String() != name || normalize(identity["datadir"].String()) != normalize(expectedDir) { + t.Fatalf("隔离实例身份不符,禁止写入:%v", identity) + } + t.Logf("本机专用库核验通过 hostname=%s port=%s uuid=%s datadir=%s database=%s", identity["hostname"], identity["port"], identity["server_uuid"], identity["datadir"], identity["dbname"]) + exists, err := db.GetValue(ctx, "SELECT COUNT(*) FROM information_schema.tables WHERE table_schema=DATABASE() AND table_name='t_apn_operation'") + if err != nil { + t.Fatal(err) + } + if exists.Int() == 0 { + _, err = db.Exec(ctx, "CREATE TABLE t_apn_operation (operation_id varchar(128) NOT NULL,chan_key varchar(128) NOT NULL,request_hash char(64) NOT NULL,response_json longtext NOT NULL,created_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),updated_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),PRIMARY KEY(operation_id,chan_key)) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4") + if err != nil { + t.Fatal(err) + } + } + migration, err := os.ReadFile(filepath.Join("..", "..", "..", "..", "iotlcm-database", "SQL", "20261015_apn_operation_chan.sql")) + if err != nil { + t.Fatal(err) + } + master, err := db.Master() + if err != nil { + t.Fatal(err) + } + connection, err := master.Conn(ctx) + if err != nil { + t.Fatal(err) + } + defer connection.Close() + for repeat := 0; repeat < 2; repeat++ { + for _, statement := range strings.Split(string(migration), ";") { + if strings.TrimSpace(statement) == "" { + continue + } + if _, err := connection.ExecContext(ctx, statement); err != nil { + t.Fatalf("迁移执行失败:%v", err) + } + } + } + store := sqlApnOperationStore{db: db} + id := fmt.Sprintf("integration-%d", time.Now().UnixNano()) + channel := "isolated-test-channel" + hash := strings.Repeat("a", 64) + claimed, err := store.Claim(ctx, id, channel, hash) + if err != nil || !claimed { + t.Fatalf("Claim失败:%v %v", claimed, err) + } + t.Cleanup(func() { + _, _ = db.Exec(context.Background(), "DELETE FROM t_apn_operation WHERE operation_id=? AND chan_key=?", id, channel) + }) + if claimed, err = store.Claim(ctx, id, channel, hash); err != nil || claimed { + t.Fatalf("重复Claim未幂等:%v %v", claimed, err) + } + if err := store.Save(ctx, id+"-missing", channel, hash, "[]"); err == nil { + t.Fatal("不存在的映射保存必须失败") + } + if err := store.Save(ctx, id, channel, strings.Repeat("b", 64), "[]"); err == nil { + t.Fatal("不同摘要不得覆盖") + } + if claimed, err = store.Claim(ctx, strings.Repeat("x", 129), channel, hash); err == nil || claimed { + t.Fatal("非法ID不得被INSERT忽略为成功") + } + var wg sync.WaitGroup + errors := make(chan error, 10) + for i := 0; i < 10; i++ { + wg.Add(1) + go func(i int) { + defer wg.Done() + key := &model.SimKey{ChanKey: channel, ICCID: fmt.Sprintf("fake-%d", i)} + body, _ := json.Marshal(model.SimCardOperateChangeApnOutput{&model.SimBaseRes[model.SimCardOperateChangeApnItem]{SimKey: key, ResData: model.SimCardOperateChangeApnItem{Pending: true, OrderNum: fmt.Sprintf("fake-job-%d", i)}}}) + errors <- store.Save(ctx, id, channel, hash, string(body)) + }(i) + } + wg.Wait() + close(errors) + for err := range errors { + if err != nil { + t.Fatal(err) + } + } + _, saved, err := store.Load(ctx, id, channel) + var output model.SimCardOperateChangeApnOutput + if err != nil || json.Unmarshal([]byte(saved), &output) != nil || len(output) != 10 { + t.Fatalf("并发保存丢失卡映射:%s %v", saved, err) + } + unknown := &model.SimCardOperateChangeApnInput{SimKeys: []*model.SimKey{output[0].SimKey}} + body, _ := json.Marshal(unknownApnOperation(unknown, "模拟checkpoint后错误")) + if err := store.Save(ctx, id, channel, hash, string(body)); err != nil { + t.Fatal(err) + } + _, merged, err := store.Load(ctx, id, channel) + if err != nil || merged != saved { + t.Fatalf("已存jobId被unknown覆盖:%s %v", merged, err) + } + if err := store.Save(ctx, id, channel, hash, "[]"); err != nil { + t.Fatal(err) + } + row, err := db.GetOne(ctx, "SELECT revision FROM t_apn_operation WHERE operation_id=? AND chan_key=?", id, channel) + if err != nil || row["revision"].Int() != 12 { + t.Fatalf("每次保存必须影响一行并递增revision:%v %v", row, err) + } +} diff --git a/internal/logic/upstreamapi/apn_operation_test.go b/internal/logic/upstreamapi/apn_operation_test.go new file mode 100644 index 0000000..a4c3bcf --- /dev/null +++ b/internal/logic/upstreamapi/apn_operation_test.go @@ -0,0 +1,376 @@ +package upstreamapi + +import ( + "context" + "crypto/sha256" + "database/sql" + "encoding/hex" + "encoding/json" + "errors" + "strings" + "sync" + "testing" + + "github.com/gogf/gf/v2/container/gvar" + "github.com/gogf/gf/v2/database/gdb" + "git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/model" +) + +type memoryApnOperationStore struct { + mu sync.Mutex + rows map[string][2]string + failSave bool +} + +func (s *memoryApnOperationStore) Claim(_ context.Context, id, ch, hash string) (bool, error) { + s.mu.Lock() + defer s.mu.Unlock() + k := id + ch + if _, ok := s.rows[k]; ok { + return false, nil + } + s.rows[k] = [2]string{hash, ""} + return true, nil +} +func (s *memoryApnOperationStore) Load(_ context.Context, id, ch string) (string, string, error) { + s.mu.Lock() + defer s.mu.Unlock() + r := s.rows[id+ch] + return r[0], r[1], nil +} +func (s *memoryApnOperationStore) Save(ctx context.Context, id, ch, hash, body string) error { + if err := ctx.Err(); err != nil { + return err + } + s.mu.Lock() + defer s.mu.Unlock() + if s.failSave { + return errors.New("模拟磁盘失败") + } + r, exists := s.rows[id+ch] + if !exists || r[0] != hash { + return errors.New("意图不存在或摘要不符") + } + merged, err := mergeApnOperationJSON(r[1], body) + if err != nil { + return err + } + r[1] = merged + s.rows[id+ch] = r + return nil +} +func operationTestInput() *model.SimCardOperateChangeApnInput { + return &model.SimCardOperateChangeApnInput{SimKeys: []*model.SimKey{{ICCID: "test", ChanKey: "channel"}}, ReqData: model.SimCardOperateChangeApnInputItem{OperationID: "stable-operation", SubmitOnly: true, OperType: "1"}} +} +func acceptedOperation(input *model.SimCardOperateChangeApnInput) model.SimCardOperateChangeApnOutput { + return model.SimCardOperateChangeApnOutput{&model.SimBaseRes[model.SimCardOperateChangeApnItem]{SimKey: input.SimKeys[0], ResData: model.SimCardOperateChangeApnItem{OrderNum: "saved-job", Pending: true}}} +} + +func TestApnCapabilityExistingJobNeedsNoIntentDatabase(t *testing.T) { + input := operationTestInput() + input.ReqData.SubmitOnly = false + input.ReqData.QueryJobID = "existing-job" + // 此包未配置数据库:已有 jobId 的只读协议探测应仍然返回能力。 + output, err := (&sUpstreamAPI{}).QuerySimApnCapability(context.Background(), input) + if err != nil || len(output) != 1 || output[0].ResData.ProtocolVersion != 1 || *output[0].SimKey != *input.SimKeys[0] { + t.Fatalf("已有任务的只读协议探测依赖了提交意图表: output=%+v err=%v", output, err) + } + input.SimKeys = []*model.SimKey{nil} + if _, err := (&sUpstreamAPI{}).QuerySimApnCapability(context.Background(), input); err == nil { + t.Fatal("已有jobId也必须校验冻结卡标识") + } +} + +func TestApnOperationResponseLossAndRestartRecovery(t *testing.T) { + store := &memoryApnOperationStore{rows: map[string][2]string{}} + input := operationTestInput() + calls := 0 + submit := func(context.Context) (model.SimCardOperateChangeApnOutput, error) { + calls++ + return acceptedOperation(input), nil + } + if _, err := durableApnOperation(context.Background(), store, "channel", input, submit); err != nil { + t.Fatal(err) + } + // 丢弃首次响应后,另一次调用只读受理映射,不访问运营商。 + input.ReqData.RecoverOnly = true + recovered, err := durableApnOperation(context.Background(), store, "channel", input, submit) + if err != nil || calls != 1 || recovered[0].ResData.OrderNum != "saved-job" { + t.Fatalf("恢复失败 calls=%d result=%+v err=%v", calls, recovered, err) + } + input.ReqData.RecoverOnly = false + if _, err = durableApnOperation(context.Background(), store, "channel", input, submit); err != nil || calls != 1 { + t.Fatalf("重复投递重新办理 calls=%d err=%v", calls, err) + } + input.ReqData.OperType = "0" + if _, err = durableApnOperation(context.Background(), store, "channel", input, submit); err == nil { + t.Fatal("相同ID不得绑定不同请求") + } +} + +func TestApnOperationConcurrentSubmitOnlyCallsCarrierOnce(t *testing.T) { + store := &memoryApnOperationStore{rows: map[string][2]string{}} + input := operationTestInput() + started, release, done := make(chan struct{}), make(chan struct{}), make(chan error, 1) + go func() { + _, err := durableApnOperation(context.Background(), store, "channel", input, func(context.Context) (model.SimCardOperateChangeApnOutput, error) { + close(started) + <-release + return acceptedOperation(input), nil + }) + done <- err + }() + <-started + duplicate, err := durableApnOperation(context.Background(), store, "channel", input, func(context.Context) (model.SimCardOperateChangeApnOutput, error) { + t.Error("并发重复operationId不得重新办理") + return nil, nil + }) + close(release) + if err != nil || len(duplicate) != 1 || !duplicate[0].ResData.Uncertain { + t.Fatalf("并发处理中应标待核对: %v %+v", err, duplicate) + } + if err := <-done; err != nil { + t.Fatal(err) + } +} + +func TestApnOperationRecoveryRejectsDifferentPayload(t *testing.T) { + store := &memoryApnOperationStore{rows: map[string][2]string{}} + input := operationTestInput() + _, err := durableApnOperation(context.Background(), store, "channel", input, func(context.Context) (model.SimCardOperateChangeApnOutput, error) { + return acceptedOperation(input), nil + }) + if err != nil { + t.Fatal(err) + } + input.ReqData.RecoverOnly = true + input.ReqData.OperType = "0" + if _, err = durableApnOperation(context.Background(), store, "channel", input, nil); err == nil { + t.Fatal("恢复请求不得读取其他操作的受理映射") + } +} + +func TestApnOperationIntentWithoutResponseNeverResubmits(t *testing.T) { + store := &memoryApnOperationStore{rows: map[string][2]string{}} + input := operationTestInput() + payload, _ := json.Marshal(input) + digest := sha256.Sum256(payload) + _, _ = store.Claim(context.Background(), input.ReqData.OperationID, "channel", hex.EncodeToString(digest[:])) + input.ReqData.RecoverOnly = true + calls := 0 + output, err := durableApnOperation(context.Background(), store, "channel", input, func(context.Context) (model.SimCardOperateChangeApnOutput, error) { calls++; return nil, nil }) + if err != nil || calls != 0 || !output[0].ResData.Uncertain { + t.Fatalf("未确认意图被重新提交:calls=%d err=%v", calls, err) + } +} + +func TestApnOperationPersistenceFailureNeverResubmits(t *testing.T) { + store := &memoryApnOperationStore{rows: map[string][2]string{}, failSave: true} + input := operationTestInput() + calls := 0 + submit := func(context.Context) (model.SimCardOperateChangeApnOutput, error) { + calls++ + return acceptedOperation(input), nil + } + if _, err := durableApnOperation(context.Background(), store, "channel", input, submit); err == nil { + t.Fatal("持久化失败不得返回已可靠保存") + } + store.failSave = false + input.ReqData.RecoverOnly = true + output, err := durableApnOperation(context.Background(), store, "channel", input, submit) + if err != nil || calls != 1 || !output[0].ResData.Uncertain { + t.Fatalf("受理映射失败后不得重复办理:calls=%d err=%v", calls, err) + } +} + +func TestApnOperationClientCancellationDoesNotLoseJob(t *testing.T) { + store := &memoryApnOperationStore{rows: map[string][2]string{}} + input := operationTestInput() + ctx, cancel := context.WithCancel(context.Background()) + output, err := durableApnOperation(ctx, store, "channel", input, func(context.Context) (model.SimCardOperateChangeApnOutput, error) { + cancel() + return acceptedOperation(input), nil + }) + if err != nil || output[0].ResData.OrderNum != "saved-job" { + t.Fatalf("客户端取消导致jobId丢失:%v", err) + } + _, saved, _ := store.Load(context.Background(), input.ReqData.OperationID, "channel") + if saved == "" { + t.Fatal("jobId未落库") + } +} + +type affectedApnTestDB struct { + gdb.DB + tx gdb.TX +} + +func (d affectedApnTestDB) Transaction(ctx context.Context, f func(context.Context, gdb.TX) error) error { + return f(ctx, d.tx) +} + +type affectedApnTestTX struct { + gdb.TX + hash string + rows int64 +} + +func (tx affectedApnTestTX) GetOne(string, ...any) (gdb.Record, error) { + return gdb.Record{"request_hash": gvar.New(tx.hash), "response_json": gvar.New("")}, nil +} +func (tx affectedApnTestTX) Exec(string, ...any) (sql.Result, error) { + return affectedApnTestResult(tx.rows), nil +} + +type affectedApnTestResult int64 + +func (r affectedApnTestResult) LastInsertId() (int64, error) { return 0, nil } +func (r affectedApnTestResult) RowsAffected() (int64, error) { return int64(r), nil } +func TestApnOperationSQLSaveRejectsAffectedZeroAndHashMismatch(t *testing.T) { + hash := "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + for _, tc := range []struct { + rows int64 + hash string + wantError bool + }{{0, hash, true}, {1, "wrong-hash", true}, {1, hash, false}} { + store := sqlApnOperationStore{db: affectedApnTestDB{tx: affectedApnTestTX{hash: tc.hash, rows: tc.rows}}} + err := store.Save(context.Background(), "test-operation", "test-channel", hash, "[]") + if (err != nil) != tc.wantError { + t.Fatalf("rows=%d hash=%s err=%v", tc.rows, tc.hash, err) + } + } +} + +type retryCheckpointApnStore struct { + memoryApnOperationStore + failed bool +} + +func (s *retryCheckpointApnStore) Save(ctx context.Context, id, channel, hash, body string) error { + if !s.failed { + s.failed = true + return errors.New("模拟首次checkpoint磁盘故障") + } + return s.memoryApnOperationStore.Save(ctx, id, channel, hash, body) +} +func TestApnOperationFailedCheckpointRetriesKnownJobNotUnknown(t *testing.T) { + store := &retryCheckpointApnStore{memoryApnOperationStore: memoryApnOperationStore{rows: map[string][2]string{}}} + input := operationTestInput() + output, err := durableApnOperation(context.Background(), store, "channel", input, func(ctx context.Context) (model.SimCardOperateChangeApnOutput, error) { + checkpoint := ctx.Value(model.ApnOperationCheckpointKey{}).(func(model.SimCardOperateChangeApnOutput) error) + if err := checkpoint(acceptedOperation(input)); err == nil { + t.Fatal("应模拟checkpoint失败") + } + return nil, errors.New("受理后响应断开") + }) + if err != nil || output[0].ResData.OrderNum != "saved-job" || !output[0].ResData.Pending { + t.Fatalf("保存重试丢掉已取得jobId:%+v %v", output, err) + } + _, saved, _ := store.Load(context.Background(), input.ReqData.OperationID, "channel") + if !strings.Contains(saved, "saved-job") { + t.Fatalf("重试未可靠保存jobId:%s", saved) + } +} + +func TestApnOperationCheckpointCannotBeErased(t *testing.T) { + for _, failed := range []bool{false, true} { + store := &memoryApnOperationStore{rows: map[string][2]string{}} + input := operationTestInput() + output, err := durableApnOperation(context.Background(), store, "channel", input, func(ctx context.Context) (model.SimCardOperateChangeApnOutput, error) { + checkpoint := ctx.Value(model.ApnOperationCheckpointKey{}).(func(model.SimCardOperateChangeApnOutput) error) + if err := checkpoint(acceptedOperation(input)); err != nil { + t.Fatal(err) + } + if failed { + return nil, errors.New("模拟受理后请求失败") + } + return nil, nil + }) + if err != nil || len(output) != 1 || output[0].ResData.OrderNum != "saved-job" || !output[0].ResData.Pending { + t.Fatalf("后续空响应或错误抹掉jobId:%+v %v", output, err) + } + input.ReqData.RecoverOnly = true + output, err = durableApnOperation(context.Background(), store, "channel", input, nil) + if err != nil || output[0].ResData.OrderNum != "saved-job" { + t.Fatalf("持久化映射已被回退:%+v %v", output, err) + } + } +} + +func TestApnOperationMergePreservesTerminalAndCardAssociation(t *testing.T) { + input := operationTestInput() + terminal := acceptedOperation(input) + terminal[0].ResData.Pending = false + terminal[0].Message = "明确成功" + body, _ := json.Marshal(terminal) + unknown, _ := json.Marshal(unknownApnOperation(input, "后来的未知结果")) + merged, err := mergeApnOperationJSON(string(body), string(unknown)) + if err != nil || merged != string(body) { + t.Fatalf("逐卡终态被未知覆盖:%s %v", merged, err) + } + second := *input.SimKeys[0] + second.ICCID = "second" + other, _ := json.Marshal(model.SimCardOperateChangeApnOutput{&model.SimBaseRes[model.SimCardOperateChangeApnItem]{SimKey: &second, ResData: model.SimCardOperateChangeApnItem{Pending: true, OrderNum: "second-job"}}}) + merged, err = mergeApnOperationJSON(merged, string(other)) + var results model.SimCardOperateChangeApnOutput + _ = json.Unmarshal([]byte(merged), &results) + if err != nil || len(results) != 2 || results[0].SimKey.ICCID != "test" || results[1].SimKey.ICCID != "second" { + t.Fatalf("逐卡合并丢失卡关联:%s %v", merged, err) + } +} + +type failingClaimApnStore struct{ memoryApnOperationStore } + +func (s *failingClaimApnStore) Claim(context.Context, string, string, string) (bool, error) { + return false, errors.New("模拟INSERT失败") +} +func TestApnOperationClaimFailureNeverCallsCarrier(t *testing.T) { + called := false + _, err := durableApnOperation(context.Background(), &failingClaimApnStore{}, "channel", operationTestInput(), func(context.Context) (model.SimCardOperateChangeApnOutput, error) { + called = true + return nil, nil + }) + if err == nil || called { + t.Fatalf("意图INSERT失败仍调用运营商:called=%v err=%v", called, err) + } +} + +func TestApnOperationExplicitFailureWinsPendingAndUnknown(t *testing.T) { + input := operationTestInput() + pending, _ := json.Marshal(acceptedOperation(input)) + failure := model.SimCardOperateChangeApnOutput{&model.SimBaseRes[model.SimCardOperateChangeApnItem]{SimKey: input.SimKeys[0], Code: 9999, Message: "运营商明确拒绝"}} + failed, _ := json.Marshal(failure) + merged, err := mergeApnOperationJSON(string(pending), string(failed)) + if err != nil { + t.Fatal(err) + } + unknown, _ := json.Marshal(unknownApnOperation(input, "后到的通信错误")) + merged, err = mergeApnOperationJSON(merged, string(unknown)) + var output model.SimCardOperateChangeApnOutput + if err != nil || json.Unmarshal([]byte(merged), &output) != nil || len(output) != 1 || output[0].Code != 9999 || output[0].Message != "运营商明确拒绝" || output[0].ResData.Pending || output[0].ResData.Uncertain || output[0].ResData.OrderNum != "saved-job" { + t.Fatalf("明确失败被pending或未知覆盖:%s %v", merged, err) + } +} + +func TestApnOperationCheckpointSurvivesCrashBeforeReturn(t *testing.T) { + store := &memoryApnOperationStore{rows: map[string][2]string{}} + input := operationTestInput() + func() { + defer func() { _ = recover() }() + _, _ = durableApnOperation(context.Background(), store, "channel", input, func(ctx context.Context) (model.SimCardOperateChangeApnOutput, error) { + checkpoint := ctx.Value(model.ApnOperationCheckpointKey{}).(func(model.SimCardOperateChangeApnOutput) error) + if err := checkpoint(acceptedOperation(input)); err != nil { + t.Fatal(err) + } + panic("模拟拿jobId后进程中断") + }) + }() + input.ReqData.RecoverOnly = true + output, err := durableApnOperation(context.Background(), store, "channel", input, func(context.Context) (model.SimCardOperateChangeApnOutput, error) { + t.Fatal("恢复不得重新办理") + return nil, nil + }) + if err != nil || output[0].ResData.OrderNum != "saved-job" { + t.Fatalf("checkpoint未恢复:%+v %v", output, err) + } +} diff --git a/internal/logic/upstreamapi/channel_cache_test.go b/internal/logic/upstreamapi/channel_cache_test.go index 3584f40..f33f044 100644 --- a/internal/logic/upstreamapi/channel_cache_test.go +++ b/internal/logic/upstreamapi/channel_cache_test.go @@ -4,10 +4,10 @@ import "testing" func TestICCIDFromChannelCacheKey(t *testing.T) { tests := []struct{ key, want string }{ - {"sim:chan:iccid:898604F4152391115474", "898604F4152391115474"}, - {"sim:chan:iccid:8986112223600594371X", "8986112223600594371X"}, + {"sim:chan:iccid:89860000000000000004", "89860000000000000004"}, + {"sim:chan:iccid:8986000000000000007X", "8986000000000000007X"}, {"sim:chan:iccid:", ""}, - {"unrelated:898604F4152391115474", ""}, + {"unrelated:89860000000000000004", ""}, } for _, tt := range tests { if got := iccidFromChannelCacheKey(tt.key); got != tt.want { diff --git a/internal/logic/upstreamapi/channel_test_runtime.go b/internal/logic/upstreamapi/channel_test_runtime.go index a243c8a..4fe8677 100644 --- a/internal/logic/upstreamapi/channel_test_runtime.go +++ b/internal/logic/upstreamapi/channel_test_runtime.go @@ -6,11 +6,11 @@ import ( "strings" "time" + "github.com/gogf/gf/v2/errors/gerror" + "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" - "github.com/gogf/gf/v2/errors/gerror" - "github.com/gogf/gf/v2/frame/g" ) const ( diff --git a/internal/logic/upstreamapi/pack_chan_key_test.go b/internal/logic/upstreamapi/pack_chan_key_test.go new file mode 100644 index 0000000..aff4972 --- /dev/null +++ b/internal/logic/upstreamapi/pack_chan_key_test.go @@ -0,0 +1,229 @@ +package upstreamapi + +import ( + "context" + "os" + "os/exec" + "path/filepath" + "testing" + + _ "github.com/gogf/gf/contrib/drivers/sqlite/v2" + "github.com/gogf/gf/v2/container/gvar" + "github.com/gogf/gf/v2/database/gdb" + "github.com/gogf/gf/v2/database/gredis" + "github.com/gogf/gf/v2/frame/g" + "git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/model" +) + +type packChanKeyRedis struct { + gredis.Adapter + gredis.IGroupString + values map[string]string + mgetCalls int + setKeys []string +} + +func (r *packChanKeyRedis) GroupGeneric() gredis.IGroupGeneric { return nil } +func (r *packChanKeyRedis) GroupHash() gredis.IGroupHash { return nil } +func (r *packChanKeyRedis) GroupList() gredis.IGroupList { return nil } +func (r *packChanKeyRedis) GroupPubSub() gredis.IGroupPubSub { return nil } +func (r *packChanKeyRedis) GroupScript() gredis.IGroupScript { return nil } +func (r *packChanKeyRedis) GroupSet() gredis.IGroupSet { return nil } +func (r *packChanKeyRedis) GroupSortedSet() gredis.IGroupSortedSet { return nil } +func (r *packChanKeyRedis) GroupString() gredis.IGroupString { return r } + +func (r *packChanKeyRedis) MGet(_ context.Context, keys ...string) (map[string]*gvar.Var, error) { + r.mgetCalls++ + values := make(map[string]*gvar.Var, len(keys)) + for _, key := range keys { + values[key] = gvar.New(r.values[key]) + } + return values, nil +} + +func (r *packChanKeyRedis) Set(_ context.Context, key string, value any, _ ...gredis.SetOption) (*gvar.Var, error) { + r.values[key] = gvar.New(value).String() + r.setKeys = append(r.setKeys, key) + return gvar.New("OK"), nil +} + +func TestPackChanKeyPreservesIdentifiersAcrossLookupBranches(t *testing.T) { + // 子进程隔离 GoFrame 缓存的默认 DB/Redis 实例,所有查询只使用临时 SQLite 和内存适配器。 + const childEnv = "PACK_CHAN_KEY_TEST_CHILD" + if os.Getenv(childEnv) != "1" { + executable, err := os.Executable() + if err != nil { + t.Fatal(err) + } + command := exec.CommandContext(t.Context(), executable, "-test.run=^TestPackChanKeyPreservesIdentifiersAcrossLookupBranches$", "-test.v") + command.Env = append(os.Environ(), childEnv+"=1") + output, err := command.CombinedOutput() + t.Logf("%s", output) + if err != nil { + t.Fatalf("隔离回归失败:%v", err) + } + return + } + + ctx := context.Background() + cache := &packChanKeyRedis{} + gredis.RegisterAdapterFunc(func(*gredis.Config) gredis.Adapter { return cache }) + gredis.SetConfig(&gredis.Config{Address: "in-memory-test"}) + if err := gdb.SetConfigGroup("default", gdb.ConfigGroup{{ + Type: "sqlite", Name: filepath.Join(t.TempDir(), "pack-chan-key.db"), + }}); err != nil { + t.Fatal(err) + } + db := g.DB() + t.Cleanup(func() { _ = db.Close(ctx) }) + if _, err := db.Exec(ctx, "CREATE TABLE t_sim_card_info (iccid TEXT PRIMARY KEY, chan_key TEXT)"); err != nil { + t.Fatal(err) + } + + api := NewUpstreamAPI() + for _, branch := range []string{"redis", "database", "unmatched"} { + t.Run(branch, func(t *testing.T) { + cache.values = make(map[string]string) + cache.mgetCalls = 0 + cache.setKeys = nil + if _, err := db.Exec(ctx, "DELETE FROM t_sim_card_info"); err != nil { + t.Fatal(err) + } + first := &model.SimKey{ICCID: "iccid-duplicate", MSISDN: "msisdn-first", IMSI: "imsi-first"} + second := &model.SimKey{ICCID: first.ICCID, MSISDN: "msisdn-second", IMSI: "imsi-second"} + other := &model.SimKey{ICCID: "iccid-other", MSISDN: "msisdn-other", IMSI: "imsi-other"} + inputs := []*model.SimKey{first, second, first, other} + wantChannel := "" + if branch != "unmatched" { + wantChannel = branch + "-channel" + for _, key := range []*model.SimKey{first, other} { + if branch == "redis" { + cache.values["sim:chan:iccid:"+key.ICCID] = wantChannel + } else if _, err := db.Exec(ctx, "INSERT INTO t_sim_card_info (iccid, chan_key) VALUES (?, ?)", key.ICCID, wantChannel); err != nil { + t.Fatal(err) + } + } + } + + before := make([]model.SimKey, len(inputs)) + for i, key := range inputs { + before[i] = *key + } + got, err := api.packChanKey(ctx, inputs) + if err != nil { + t.Fatal(err) + } + if len(got) != 1 || len(got[wantChannel]) != len(inputs) { + t.Fatalf("分组数量错误:%+v", got) + } + assertPackedSimKeys(t, got[wantChannel], inputs, before, wantChannel) + if cache.mgetCalls != 1 { + t.Fatalf("MGet 调用次数=%d,期望1", cache.mgetCalls) + } + wantSets := 0 + if branch == "database" { + wantSets = 2 + } + if len(cache.setKeys) != wantSets { + t.Fatalf("缓存回填数量=%d,期望%d", len(cache.setKeys), wantSets) + } + }) + } + + t.Run("mixed_with_explicit_channel", func(t *testing.T) { + cache.values = map[string]string{"sim:chan:iccid:redis-card": "redis-channel"} + if _, err := db.Exec(ctx, "DELETE FROM t_sim_card_info"); err != nil { + t.Fatal(err) + } + if _, err := db.Exec(ctx, "INSERT INTO t_sim_card_info (iccid, chan_key) VALUES ('database-card', 'database-channel')"); err != nil { + t.Fatal(err) + } + explicit := &model.SimKey{ChanKey: "explicit-channel", ICCID: "redis-card", MSISDN: "msisdn-explicit", IMSI: "imsi-explicit"} + inputs := []*model.SimKey{explicit} + wanted := map[string][]*model.SimKey{} + for _, tc := range []struct{ iccid, channel string }{ + {"redis-card", "redis-channel"}, {"database-card", "database-channel"}, {"missing-card", ""}, + } { + first := &model.SimKey{ICCID: tc.iccid, MSISDN: tc.iccid + "-msisdn-first", IMSI: tc.iccid + "-imsi-first"} + second := &model.SimKey{ICCID: tc.iccid, MSISDN: tc.iccid + "-msisdn-second", IMSI: tc.iccid + "-imsi-second"} + inputs = append(inputs, first, second) + wanted[tc.channel] = []*model.SimKey{first, second} + } + before := make(map[*model.SimKey]model.SimKey, len(inputs)) + for _, key := range inputs { + before[key] = *key + } + got, err := api.packChanKey(ctx, inputs) + if err != nil { + t.Fatal(err) + } + if len(got) != 4 || len(got[explicit.ChanKey]) != 1 || got[explicit.ChanKey][0] != explicit { + t.Fatalf("显式通道分组被改变:%+v", got) + } + for channel, originals := range wanted { + assertPackedSimKeys(t, got[channel], originals, []model.SimKey{before[originals[0]], before[originals[1]]}, channel) + } + if *explicit != before[explicit] { + t.Fatal("显式通道原始输入被修改") + } + }) +} + +func assertPackedSimKeys(t *testing.T, got, originals []*model.SimKey, before []model.SimKey, channel string) { + t.Helper() + if len(got) != len(originals) { + t.Fatalf("输出数量=%d,期望%d", len(got), len(originals)) + } + want := make(map[model.SimKey]int, len(originals)) + for i, original := range originals { + if *original != before[i] { + t.Fatalf("原始输入被修改:%+v", original) + } + key := before[i] + key.ChanKey = channel + want[key]++ + } + seen := make(map[*model.SimKey]bool, len(got)) + for _, key := range got { + if key == nil || seen[key] { + t.Fatal("输出为空或复用了同一指针") + } + seen[key] = true + for _, original := range originals { + if key == original { + t.Fatal("输出复用了原始输入指针") + } + } + if want[*key] == 0 { + t.Fatalf("卡标识丢失或被另一条重复ICCID输入覆盖:%+v", key) + } + want[*key]-- + } + for _, remaining := range want { + if remaining != 0 { + t.Fatal("输出未保留每条原始输入") + } + } + if len(got) > 1 { + other := *got[1] + got[0].MSISDN = "changed-output" + got[0].IMSI = "changed-output" + if *got[1] != other { + t.Fatal("修改一条输出影响了另一条输出") + } + } + for i, original := range originals { + if *original != before[i] { + t.Fatalf("修改输出影响了原始输入:%+v", original) + } + } +} + +func TestPackChanKeyEmptyInput(t *testing.T) { + for _, inputs := range [][]*model.SimKey{nil, {}} { + got, err := NewUpstreamAPI().packChanKey(context.Background(), inputs) + if err != nil || got == nil || len(got) != 0 { + t.Fatalf("空输入结果=%+v,错误=%v", got, err) + } + } +} diff --git a/internal/logic/upstreamapi/upstreamapi.go b/internal/logic/upstreamapi/upstreamapi.go index d116b69..1512e56 100644 --- a/internal/logic/upstreamapi/upstreamapi.go +++ b/internal/logic/upstreamapi/upstreamapi.go @@ -3,14 +3,14 @@ package upstreamapi import ( "context" "database/sql" + "strings" + "sync" + "time" "git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/consts" "git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/dao" "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" - "strings" - "sync" - "time" "github.com/gogf/gf/v2/errors/gerror" "github.com/gogf/gf/v2/frame/g" @@ -285,9 +285,11 @@ func (u *sUpstreamAPI) packChanKey(ctx context.Context, simKeys []*model.SimKey) queryRedisIccids := make([]string, 0) queryDbIccids := make([]string, 0) chanKeyMap := make(map[string][]*model.SimKey) + simKeyMap := make(map[string][]*model.SimKey, len(simKeys)) // 检查通道id是都携带 for _, simKey := range simKeys { if simKey.ChanKey == "" { + simKeyMap[simKey.ICCID] = append(simKeyMap[simKey.ICCID], simKey) unknownChanKeyIccids = append(unknownChanKeyIccids, simKey.ICCID) queryRedisIccids = append(queryRedisIccids, redisKey+simKey.ICCID) } else { @@ -319,10 +321,11 @@ func (u *sUpstreamAPI) packChanKey(ctx context.Context, simKeys []*model.SimKey) continue } allIccid[iccid] = true - chanKeyMap[chanKeyStr] = append(chanKeyMap[chanKeyStr], &model.SimKey{ - ChanKey: chanKeyStr, - ICCID: iccid, - }) + for _, original := range simKeyMap[iccid] { + key := *original + key.ChanKey = chanKeyStr + chanKeyMap[chanKeyStr] = append(chanKeyMap[chanKeyStr], &key) + } } for iccid, found := range allIccid { if !found { @@ -347,18 +350,20 @@ func (u *sUpstreamAPI) packChanKey(ctx context.Context, simKeys []*model.SimKey) for _, simCardInfo := range simCardInfos { allIccid[simCardInfo.Iccid] = true - chanKeyMap[simCardInfo.ChanKey] = append(chanKeyMap[simCardInfo.ChanKey], &model.SimKey{ - ChanKey: simCardInfo.ChanKey, - ICCID: simCardInfo.Iccid, - }) + for _, original := range simKeyMap[simCardInfo.Iccid] { + key := *original + key.ChanKey = simCardInfo.ChanKey + chanKeyMap[simCardInfo.ChanKey] = append(chanKeyMap[simCardInfo.ChanKey], &key) + } g.Redis().Set(ctx, redisKey+simCardInfo.Iccid, simCardInfo.ChanKey) } for iccid, found := range allIccid { if !found { - chanKeyMap[""] = append(chanKeyMap[""], &model.SimKey{ - ChanKey: "", - ICCID: iccid, - }) + for _, original := range simKeyMap[iccid] { + key := *original + key.ChanKey = "" + chanKeyMap[""] = append(chanKeyMap[""], &key) + } } } return chanKeyMap, nil @@ -1273,7 +1278,19 @@ func (u *sUpstreamAPI) ChangeSimCardOperateApn(ctx context.Context, input *model } } for chanKey, inputNew := range inputMap { + if inputNew.ReqData.RecoverOnly && inputNew.ReqData.QueryJobID == "" { + recovered, recoverErr := durableApnOperation(ctx, sqlApnOperationStore{}, chanKey, inputNew, nil) + if recoverErr != nil { + recovered = unknownApnOperation(inputNew, recoverErr.Error()) + } + output = append(output, recovered...) + continue + } if chanKey == "" { + if inputNew.ReqData.SubmitOnly || inputNew.ReqData.QueryJobID != "" { + output = append(output, unknownApnOperation(inputNew, "通道未配置,异步结果未确认")...) + continue + } for _, simKey := range inputNew.SimKeys { output = append(output, &model.SimBaseRes[model.SimCardOperateChangeApnItem]{ SimKey: simKey, @@ -1285,6 +1302,10 @@ func (u *sUpstreamAPI) ChangeSimCardOperateApn(ctx context.Context, input *model } channel, ok := u.GetChan(chanKey) if !ok || channel.Instance == nil { + if inputNew.ReqData.SubmitOnly || inputNew.ReqData.QueryJobID != "" { + output = append(output, unknownApnOperation(inputNew, "通道未实例化,异步结果未确认")...) + continue + } for _, simKey := range inputNew.SimKeys { output = append(output, &model.SimBaseRes[model.SimCardOperateChangeApnItem]{ SimKey: simKey, @@ -1294,13 +1315,33 @@ func (u *sUpstreamAPI) ChangeSimCardOperateApn(ctx context.Context, input *model } continue } - out, err := channel.Instance.ChangeSimApnFunction(ctx, inputNew) + var out model.SimCardOperateChangeApnOutput + var err error + if inputNew.ReqData.QueryJobID == "" && (inputNew.ReqData.RecoverOnly || inputNew.ReqData.SubmitOnly) { + out, err = durableApnOperation(ctx, sqlApnOperationStore{}, chanKey, inputNew, func(submitCtx context.Context) (model.SimCardOperateChangeApnOutput, error) { + return channel.Instance.ChangeSimApnFunction(submitCtx, inputNew) + }) + } else if inputNew.ReqData.QueryJobID != "" { + querier, supported := channel.Instance.(interface { + QuerySimApnFunctionResult(context.Context, *model.SimCardOperateChangeApnInput) (model.SimCardOperateChangeApnOutput, error) + }) + if !supported { + for _, key := range inputNew.SimKeys { + output = append(output, &model.SimBaseRes[model.SimCardOperateChangeApnItem]{SimKey: key, Code: consts.ApiStatusError, Message: "该通道不支持异步APN任务查询", ResData: model.SimCardOperateChangeApnItem{OrderNum: inputNew.ReqData.QueryJobID, Uncertain: true}}) + } + continue + } + out, err = querier.QuerySimApnFunctionResult(ctx, inputNew) + } else { + out, err = channel.Instance.ChangeSimApnFunction(ctx, inputNew) + } if err != nil { for _, simKey := range inputNew.SimKeys { output = append(output, &model.SimBaseRes[model.SimCardOperateChangeApnItem]{ SimKey: simKey, Code: consts.ApiStatusError, Message: err.Error(), + ResData: model.SimCardOperateChangeApnItem{OrderNum: inputNew.ReqData.QueryJobID, Uncertain: inputNew.ReqData.SubmitOnly || inputNew.ReqData.RecoverOnly || inputNew.ReqData.QueryJobID != ""}, }) } } else { diff --git a/internal/logic/upstreamapi/upstreamapi_batch.go b/internal/logic/upstreamapi/upstreamapi_batch.go index defe79b..80443df 100644 --- a/internal/logic/upstreamapi/upstreamapi_batch.go +++ b/internal/logic/upstreamapi/upstreamapi_batch.go @@ -2,9 +2,9 @@ package upstreamapi import ( "context" + "time" "git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/consts" "git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/model" - "time" ) func (u *sUpstreamAPI) QuerySimCardStatusBatch(ctx context.Context, input *model.SimCardStatusInput) (output model.SimCardStatusOutput, err error) { diff --git a/internal/logic/upstreamimpl/apn_real_carrier_measure_test.go b/internal/logic/upstreamimpl/apn_real_carrier_measure_test.go new file mode 100644 index 0000000..d2cec67 --- /dev/null +++ b/internal/logic/upstreamimpl/apn_real_carrier_measure_test.go @@ -0,0 +1,255 @@ +package upstreamimpl + +import ( + "context" + "fmt" + "os" + "sort" + "strconv" + "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" +) + +// 真实上游时延测量(默认跳过)。 +// +// 目的:实测移动 OneLink「批量提交返回 jobId」与「按 jobId 查到终态」各自的耗时, +// 用于校准平台超时与补查节奏。 +// +// 仅在 APN_REAL_CARRIER_TEST=1 时执行。 +// +// APN_REAL_CREDS_FILE= 两行:appid / appsecret +// APN_REAL_BASE_URL= 默认 https://api.iot.10086.cn/v5 +// APN_REAL_APN=CMIOT +// APN_REAL_BATCHES=||... 每批:operType,iccid:msisdn,iccid:msisdn,... +// APN_REAL_POLL_INTERVAL=15s 查询间隔 +// APN_REAL_MAX_WAIT=15m 最长等待终态 +func TestRealCarrierApnLatencyMeasure(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" + } + apn := strings.TrimSpace(os.Getenv("APN_REAL_APN")) + if apn == "" { + apn = "CMIOT" + } + batches, operTypes, err := parseRealBatches(os.Getenv("APN_REAL_BATCHES")) + if err != nil { + t.Fatalf("解析批次失败:%v", err) + } + pollInterval := 15 * time.Second + if raw := strings.TrimSpace(os.Getenv("APN_REAL_POLL_INTERVAL")); raw != "" { + if d, derr := time.ParseDuration(raw); derr == nil && d > 0 { + pollInterval = d + } + } + maxWait := 15 * time.Minute + if raw := strings.TrimSpace(os.Getenv("APN_REAL_MAX_WAIT")); raw != "" { + if d, derr := time.ParseDuration(raw); derr == nil && d > 0 { + maxWait = d + } + } + + channel := &sSimYD{ + channelConfig: &model.SimChannelConfigItemCache{Channel: &entity.TSimChannels{ChanKey: "real-carrier-measure", AppKey: appID}}, + tokenProvider: &realCarrierToken{appID: appID, appSecret: appSecret, baseURL: baseURL}, + apiConfig: &oneLinkAPIConfig{BaseURL: baseURL, Timeout: 60 * time.Second}, + } + + type batchOutcome struct { + index int + operType string + cards int + elapsed time.Duration + jobID string + failed string + } + start := time.Now() + ctx := context.Background() + results := make([]batchOutcome, len(batches)) + var wg sync.WaitGroup + for i := range batches { + wg.Add(1) + go func(i int) { + defer wg.Done() + outcome := batchOutcome{index: i, operType: operTypes[i], cards: len(batches[i])} + begin := time.Now() + items, callErr := changeSimApnFunctionYD(ctx, channel, &model.SimCardOperateChangeApnInput{ + SimKeys: batches[i], + ReqData: model.SimCardOperateChangeApnInputItem{ + OperType: operTypes[i], + ApnName: apn, + SubmitOnly: true, + OperationID: fmt.Sprintf("measure-%d-%d", i, begin.UnixNano()), + }, + }) + outcome.elapsed = time.Since(begin) + switch { + case callErr != nil: + outcome.failed = callErr.Error() + case len(items) == 0: + outcome.failed = "空返回" + default: + outcome.jobID = items[0].ResData.OrderNum + for _, item := range items { + if item == nil { + continue + } + if item.ResData.OrderNum != "" && item.ResData.OrderNum != outcome.jobID { + outcome.failed = "同批出现多个jobId" + } + if !item.ResData.Pending && !item.ResData.Uncertain { + outcome.failed = fmt.Sprintf("提交即终态 code=%d msg=%s", item.Code, item.Message) + } + } + } + results[i] = outcome + }(i) + } + wg.Wait() + submitWall := time.Since(start) + + fmt.Printf("MEASURE_SUBMIT batches=%d cards=%d wall=%s\n", len(batches), totalRealCards(batches), submitWall) + for _, r := range results { + fmt.Printf("MEASURE_SUBMIT_BATCH idx=%d operType=%s cards=%d elapsed=%s jobId=%s failed=%s\n", + r.index, r.operType, r.cards, r.elapsed, r.jobID, r.failed) + } + + // 汇总待查任务 + pendingJobs := map[string][]*model.SimKey{} + for _, r := range results { + if r.jobID != "" { + pendingJobs[r.jobID] = batches[r.index] + } + } + terminalAt := map[string]time.Duration{} + fmt.Printf("MEASURE_JOBS distinct=%d %v\n", len(pendingJobs), sortedJobIDs(pendingJobs)) + + deadline := start.Add(maxWait) + for len(terminalAt) < len(pendingJobs) && time.Now().Before(deadline) { + time.Sleep(pollInterval) + elapsed := time.Since(start) + for jobID, keys := range pendingJobs { + if _, done := terminalAt[jobID]; done { + continue + } + items, err := changeSimApnFunctionYD(ctx, channel, &model.SimCardOperateChangeApnInput{ + SimKeys: keys, + ReqData: model.SimCardOperateChangeApnInputItem{QueryJobID: jobID}, + }) + if err != nil { + fmt.Printf("MEASURE_QUERY_ERR jobId=%s at=%s err=%v\n", jobID, elapsed.Round(time.Second), err) + continue + } + allTerminal := len(items) > 0 + for _, item := range items { + if item == nil || item.ResData.Pending { + allTerminal = false + break + } + } + if allTerminal { + terminalAt[jobID] = elapsed + fmt.Printf("MEASURE_TERMINAL jobId=%s at=%s\n", jobID, elapsed.Round(time.Second)) + for _, item := range items { + fmt.Printf("MEASURE_CARD jobId=%s iccid=%s msisdn=%s code=%d uncertain=%v message=%s\n", + jobID, item.SimKey.ICCID, item.SimKey.MSISDN, item.Code, item.ResData.Uncertain, item.Message) + } + } else { + fmt.Printf("MEASURE_PENDING jobId=%s at=%s note=%s\n", jobID, elapsed.Round(time.Second), firstMessage(items)) + } + } + } + + fmt.Printf("MEASURE_SUMMARY\n") + for _, jobID := range sortedJobIDs(pendingJobs) { + if at, ok := terminalAt[jobID]; ok { + fmt.Printf("MEASURE_RESULT jobId=%s terminalAt=%s\n", jobID, at.Round(time.Second)) + } else { + fmt.Printf("MEASURE_RESULT jobId=%s terminalAt=NONE within %s\n", jobID, maxWait) + } + } +} + +func totalRealCards(batches [][]*model.SimKey) int { + total := 0 + for _, b := range batches { + total += len(b) + } + return total +} + +func sortedJobIDs(jobs map[string][]*model.SimKey) []string { + ids := make([]string, 0, len(jobs)) + for id := range jobs { + ids = append(ids, id) + } + sort.Strings(ids) + return ids +} + +func firstMessage(items model.SimCardOperateChangeApnOutput) string { + for _, item := range items { + if item != nil { + return item.Message + } + } + return "" +} + +// parseRealBatches 解析 APN_REAL_BATCHES:批之间用 | 分隔,每批首项为 operType,其余为 iccid:msisdn。 +func parseRealBatches(raw string) ([][]*model.SimKey, []string, error) { + raw = strings.TrimSpace(raw) + if raw == "" { + return nil, nil, fmt.Errorf("未设置 APN_REAL_BATCHES") + } + batches := make([][]*model.SimKey, 0) + operTypes := make([]string, 0) + for _, batchRaw := range strings.Split(raw, "|") { + batchRaw = strings.TrimSpace(batchRaw) + if batchRaw == "" { + continue + } + fields := strings.Split(batchRaw, ",") + operType := strings.TrimSpace(fields[0]) + if _, err := strconv.Atoi(operType); err != nil { + return nil, nil, fmt.Errorf("批次首项应为 operType:%q", batchRaw) + } + keys := make([]*model.SimKey, 0, len(fields)-1) + for _, card := range fields[1:] { + card = strings.TrimSpace(card) + if card == "" { + continue + } + parts := strings.Split(card, ":") + if len(parts) < 2 { + return nil, nil, fmt.Errorf("卡项格式应为 iccid:msisdn:%q", card) + } + keys = append(keys, &model.SimKey{ + ChanKey: "real-carrier-measure", + ICCID: strings.TrimSpace(parts[0]), + MSISDN: strings.TrimSpace(parts[1]), + }) + } + if len(keys) == 0 { + continue + } + batches = append(batches, keys) + operTypes = append(operTypes, operType) + } + if len(batches) == 0 { + return nil, nil, fmt.Errorf("批次为空") + } + return batches, operTypes, nil +} diff --git a/internal/logic/upstreamimpl/apn_real_carrier_test.go b/internal/logic/upstreamimpl/apn_real_carrier_test.go new file mode 100644 index 0000000..607042c --- /dev/null +++ b/internal/logic/upstreamimpl/apn_real_carrier_test.go @@ -0,0 +1,269 @@ +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", " ") +} diff --git a/internal/logic/upstreamimpl/beixin_token_provider_test.go b/internal/logic/upstreamimpl/beixin_token_provider_test.go index cd8bfd9..d9d70b3 100644 --- a/internal/logic/upstreamimpl/beixin_token_provider_test.go +++ b/internal/logic/upstreamimpl/beixin_token_provider_test.go @@ -175,13 +175,13 @@ func TestBeixinTemplateRegistration(t *testing.T) { config := &model.SimChannelConfigItemCache{ Template: &entity.TSimChannelTemplate{ - TempKey: sharedOneLinkBeixinTemplateKey, - TempModel: templateModelSharedToken, + 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", + ChanKey: "252", ChanConfig: `{"pwdKey":"` + beixinTestPwdKey + `","channelNo":"` + beixinTestChannelNo + `"}`, }, } diff --git a/internal/logic/upstreamimpl/dx/client.go b/internal/logic/upstreamimpl/dx/client.go index 83a3f62..2b13288 100644 --- a/internal/logic/upstreamimpl/dx/client.go +++ b/internal/logic/upstreamimpl/dx/client.go @@ -76,7 +76,46 @@ func (c *Client) DoGetRequest(ctx context.Context, endpoint string, params url.V return c.doRequest(req) } +// DoGetWriteRequest 用独立无复用连接发送非幂等GET,禁止运输层重放与跳转。 +func (c *Client) DoGetWriteRequest(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.doWriteRequest(req) +} + +func (c *Client) doWriteRequest(req *http.Request) ([]byte, error) { + transport := http.DefaultTransport.(*http.Transport).Clone() + if original, ok := c.http.Transport.(*http.Transport); ok { + transport = original.Clone() + } + transport.DisableKeepAlives = true + defer transport.CloseIdleConnections() + client := *c.http + client.Transport = transport + client.CheckRedirect = func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse } + return c.doRequestWithClient(&client, req) +} + func (c *Client) DoPostJSONRequest(ctx context.Context, endpoint string, body []byte) ([]byte, error) { + req, err := c.doPostJSONRequest(ctx, endpoint, body) + if err != nil { + return nil, err + } + return c.doRequest(req) +} + +// DoPostJSONWriteRequest 仅用于办理写请求,禁止连接复用及自动跳转重放。 +func (c *Client) DoPostJSONWriteRequest(ctx context.Context, endpoint string, body []byte) ([]byte, error) { + req, err := c.doPostJSONRequest(ctx, endpoint, body) + if err != nil { + return nil, err + } + return c.doWriteRequest(req) +} + +func (c *Client) doPostJSONRequest(ctx context.Context, endpoint string, body []byte) (*http.Request, error) { timestamp := GetTimestampGMT() signStr, err := BuildSign("", string(body), c.appSecret, timestamp) if c.uppercaseSign { @@ -93,7 +132,7 @@ func (c *Client) DoPostJSONRequest(ctx context.Context, endpoint string, body [] req.Header.Set("Sign", c.formatSign(signStr)) req.Header.Set("Timestamp", timestamp) req.Header.Set("Content-Type", "application/json") - return c.doRequest(req) + return req, nil } // doGetRequest 封装通用的 GET 请求构建逻辑 @@ -127,7 +166,11 @@ func (c *Client) formatSign(sign string) string { // doRequest 执行HTTP请求 func (c *Client) doRequest(req *http.Request) ([]byte, error) { - resp, err := c.http.Do(req) + return c.doRequestWithClient(c.http, req) +} + +func (c *Client) doRequestWithClient(client *http.Client, req *http.Request) ([]byte, error) { + resp, err := client.Do(req) if err != nil { return nil, err } diff --git a/internal/logic/upstreamimpl/http_config.go b/internal/logic/upstreamimpl/http_config.go index db3b1b7..aeae137 100644 --- a/internal/logic/upstreamimpl/http_config.go +++ b/internal/logic/upstreamimpl/http_config.go @@ -7,8 +7,8 @@ import ( "strings" "time" - "git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/model" "github.com/gogf/gf/v2/frame/g" + "git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/model" ) const ( diff --git a/internal/logic/upstreamimpl/mingqian_registration_test.go b/internal/logic/upstreamimpl/mingqian_registration_test.go index d51c8e9..f2ec3d0 100644 --- a/internal/logic/upstreamimpl/mingqian_registration_test.go +++ b/internal/logic/upstreamimpl/mingqian_registration_test.go @@ -2,11 +2,11 @@ package upstreamimpl_test import ( "context" + "testing" _ "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) { diff --git a/internal/logic/upstreamimpl/sim_daihe.go b/internal/logic/upstreamimpl/sim_daihe.go index 4f2bfa9..84adfbb 100644 --- a/internal/logic/upstreamimpl/sim_daihe.go +++ b/internal/logic/upstreamimpl/sim_daihe.go @@ -7,11 +7,11 @@ import ( "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" "git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/service/daihe" - "github.com/gogf/gf/v2/frame/g" ) const daiheTemplateKey = "sim_daihe_v1" diff --git a/internal/logic/upstreamimpl/sim_diag_mapping_test.go b/internal/logic/upstreamimpl/sim_diag_mapping_test.go index 37aff45..0ed6fab 100644 --- a/internal/logic/upstreamimpl/sim_diag_mapping_test.go +++ b/internal/logic/upstreamimpl/sim_diag_mapping_test.go @@ -20,7 +20,7 @@ func (diagnosisTestToken) RecoverToken(context.Context, string) (string, error) 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" { + if r.URL.Path != "/v5/ec/query/sim-terminal-device-info" || r.URL.Query().Get("iccid") != "89860000000000000004" { t.Errorf("unexpected request %s", r.URL.String()) } _, _ = w.Write([]byte(`{"status":"0","message":"正确","result":[{"terminalDeviceImei":"123456789012345"}]}`)) @@ -30,7 +30,7 @@ func TestYdImeiUsesTerminalDeviceInfo(t *testing.T) { 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"}}}) + result, err := sim.QuerySimImei(context.Background(), &model.SimImeiInput{SimKeys: []*model.SimKey{{ICCID: "89860000000000000004"}}}) if err != nil { t.Fatal(err) } @@ -63,14 +63,14 @@ func TestDxRegionLimitStatusByOrder(t *testing.T) { 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" { + if r.URL.Path != "/api/v1/corp/connect/chkDeviceBindingInfo" || r.URL.Query().Get("iccid") != "8986000000000000007" { 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"}}}) + result, err := sim.QuerySimCardBindStatus(context.Background(), &model.SimCardBindStatusInput{SimKeys: []*model.SimKey{{ICCID: "8986000000000000007"}}}) if err != nil { t.Fatal(err) } @@ -89,13 +89,13 @@ func TestDxImeiReadsNQ003Response(t *testing.T) { {`{"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" { + if r.URL.Path != "/openapi/v1/prodinst/gete2einfo" || r.URL.Query().Get("iccid") != "8986000000000000007" { 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"}}}) + results, err := sim.QuerySimImei(context.Background(), &model.SimImeiInput{SimKeys: []*model.SimKey{{ICCID: "8986000000000000007"}}}) 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) diff --git a/internal/logic/upstreamimpl/sim_dx.go b/internal/logic/upstreamimpl/sim_dx.go index d89c7a4..3c721a0 100644 --- a/internal/logic/upstreamimpl/sim_dx.go +++ b/internal/logic/upstreamimpl/sim_dx.go @@ -6,10 +6,6 @@ import ( "encoding/xml" "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" "regexp" @@ -17,6 +13,10 @@ import ( "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" "github.com/gogf/gf/v2/util/gconv" diff --git a/internal/logic/upstreamimpl/sim_dx_apn.go b/internal/logic/upstreamimpl/sim_dx_apn.go index 5acd6fe..6f97433 100644 --- a/internal/logic/upstreamimpl/sim_dx_apn.go +++ b/internal/logic/upstreamimpl/sim_dx_apn.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "encoding/xml" + "fmt" "net/url" "strconv" "strings" @@ -132,6 +133,27 @@ const dxCutNetSuccessCode = "0000" // 电信侧没有「按 APN 定向」这一维度,因此忽略调用方携带的 ApnName // (预警自动断网、批量数据开停都会固定带 apnName="CMIOT"),不能因此拒绝请求。 func (s *sSimDX) ChangeSimApnFunction(ctx context.Context, input *model.SimCardOperateChangeApnInput) (model.SimCardOperateChangeApnOutput, error) { + checkpoint, durable := ctx.Value(model.ApnOperationCheckpointKey{}).(func(model.SimCardOperateChangeApnOutput) error) + if !durable { + return s.changeSimApnFunctionCards(ctx, input) + } + items := make(model.SimCardOperateChangeApnOutput, 0, len(input.SimKeys)) + for _, key := range input.SimKeys { + one := *input + one.SimKeys = []*model.SimKey{key} + result, err := s.changeSimApnFunctionCards(ctx, &one) + if err != nil { + return nil, err + } + items = append(items, result...) + if err := checkpoint(items); err != nil { + return nil, fmt.Errorf("电信 v1 第%d张卡办理结果持久化失败,停止后续写入:%w", len(items), err) + } + } + return items, nil +} + +func (s *sSimDX) changeSimApnFunctionCards(ctx context.Context, input *model.SimCardOperateChangeApnInput) (model.SimCardOperateChangeApnOutput, error) { // 响应结构 (XML) type dxCutNetResponse struct { XMLName xml.Name `xml:"SvcCont"` @@ -144,6 +166,7 @@ func (s *sSimDX) ChangeSimApnFunction(ctx context.Context, input *model.SimCardO } action, ok := convertDxApnOperType(ctx, input.ReqData.OperType) + _, durable := ctx.Value(model.ApnOperationCheckpointKey{}).(func(model.SimCardOperateChangeApnOutput) error) items := make(model.SimCardOperateChangeApnOutput, 0, len(input.SimKeys)) if !ok { @@ -173,15 +196,16 @@ func (s *sSimDX) ChangeSimApnFunction(ctx context.Context, input *model.SimCardO "access_number": []string{accessNumber}, "action": []string{action}, } - respBody, err := s.client.DoGetRequest(ctx, "/5gcmp/openapi/v1/common/singleCutNet", params) + // singleCutNet 虽使用 GET,但为非幂等写请求,禁止运输层重放和重定向。 + respBody, err := s.client.DoGetWriteRequest(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) + g.Log().Errorf(ctx, "电信单独断网请求异常,办理结果未确认,chanKey=%s,错误类型=%T", simKey.ChanKey, err) item.Code, item.Message = consts.ApiStatusError, err.Error() + item.ResData.Uncertain = true continue } g.Log().Debugf(ctx, "ChangeSimApnFunction response: %s", string(respBody)) - // 去除XML命名空间前缀 xmlBody := xmlNamespaceRegex.ReplaceAllString(string(respBody), "") @@ -189,18 +213,21 @@ func (s *sSimDX) ChangeSimApnFunction(ctx context.Context, input *model.SimCardO 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() + item.ResData.Uncertain = true 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 == "" { + if rawCode == "" { + item.ResData.Uncertain = true item.Message = "电信单独断网未返回结果码,办理结果未确认,请核对上游" + } else if item.Message == "" { + item.Message = "电信单独断网请求被上游拒绝" } continue } @@ -209,6 +236,10 @@ func (s *sSimDX) ChangeSimApnFunction(ctx context.Context, input *model.SimCardO item.ResData.OrderNum = strings.TrimSpace(dxResp.GroupTransactionID) if item.ResData.OrderNum == "" { item.Code, item.Message = consts.ApiStatusError, "电信单独断网未返回订单号,办理结果未确认,请核对上游" + item.ResData.Uncertain = true + } else if input.ReqData.SubmitOnly || durable { + item.ResData.Uncertain = true + item.Message = "电信单独断网请求已受理,最终办理结果未确认,请核对上游" } } diff --git a/internal/logic/upstreamimpl/sim_dx_apn_test.go b/internal/logic/upstreamimpl/sim_dx_apn_test.go index f8f5b11..a6dadfa 100644 --- a/internal/logic/upstreamimpl/sim_dx_apn_test.go +++ b/internal/logic/upstreamimpl/sim_dx_apn_test.go @@ -5,6 +5,7 @@ import ( "fmt" "net/http" "net/http/httptest" + "strings" "testing" "git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/logic/upstreamimpl/dx" @@ -40,7 +41,7 @@ func TestDXQueryAPNV3(t *testing.T) { 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") != "" { + if request.URL.Query().Get("msisdn") != "13300000000" || 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") == "" { @@ -50,7 +51,7 @@ func TestDXQueryAPNV3(t *testing.T) { })) 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"}}}) + output, err := sim.QuerySimApnInfo(context.Background(), &model.SimApnInfoInput{SimKeys: []*model.SimKey{{MSISDN: "13300000000"}}}) if err != nil || len(output) != 1 { t.Fatalf("output = %+v, error = %v", output, err) } @@ -75,8 +76,8 @@ func TestDXAPNIdentifierFallback(t *testing.T) { 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: "iccid", key: &model.SimKey{ICCID: " 8986000000000000006 "}, param: "iccid", value: "8986000000000000006"}, + {name: "imsi", key: &model.SimKey{IMSI: "460000000000001"}, param: "imsi", value: "460000000000001"}, {name: "nil", key: nil}, {name: "empty", key: &model.SimKey{}}, } @@ -129,7 +130,7 @@ func TestDXChangeAPNSingleCutNet(t *testing.T) { if query.Get("action") != test.wantAction { t.Errorf("action = %q, want %q", query.Get("action"), test.wantAction) } - if query.Get("access_number") != "138****8000" { + if query.Get("access_number") != "13300000000" { t.Errorf("access_number = %q", query.Get("access_number")) } // 不能携带 APN 定向参数,singleCutNet 是整卡语义。 @@ -145,7 +146,7 @@ func TestDXChangeAPNSingleCutNet(t *testing.T) { 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"}}, + SimKeys: []*model.SimKey{{MSISDN: "13300000000", 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 { @@ -155,6 +156,138 @@ func TestDXChangeAPNSingleCutNet(t *testing.T) { } } +func TestDXSingleCutNetAcceptanceModes(t *testing.T) { + for _, mode := range []string{"同步兼容", "仅提交", "持久化回调"} { + for _, accepted := range []bool{true, false} { + t.Run(fmt.Sprintf("%s/受理=%t", mode, accepted), func(t *testing.T) { + writes, checkpoints := 0, 0 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + writes++ + code := "1000" + if accepted { + code = "0000" + } + fmt.Fprintf(w, `%s响应order-%d`, code, writes) + })) + defer server.Close() + sim := &sSimDX{client: dx.NewClient(dx.Config{BaseURL: server.URL})} + wantUncertain := accepted && mode != "同步兼容" + check := func(items model.SimCardOperateChangeApnOutput) { + t.Helper() + for i, item := range items { + if (item.Code == 0) != accepted || item.ResData.Uncertain != wantUncertain || item.ResData.Pending { + t.Fatalf("受理与完成分类错误:%+v", item) + } + if accepted && (item.ErrCode != "0000" || item.ResData.OrderNum != fmt.Sprintf("order-%d", i+1)) { + t.Fatalf("未保留逐卡受理流水与原始码:%+v", item) + } + if wantUncertain && !strings.Contains(item.Message, "最终办理结果未确认") { + t.Fatalf("受理消息未明确最终结果未知:%s", item.Message) + } + if !accepted && (item.ErrCode != "1000" || item.ResData.OrderNum != "") { + t.Fatalf("明确拒绝不应携带受理流水:%+v", item) + } + } + } + ctx := context.Background() + if mode == "持久化回调" { + ctx = context.WithValue(ctx, model.ApnOperationCheckpointKey{}, func(items model.SimCardOperateChangeApnOutput) error { + checkpoints++ + check(items) + return nil + }) + } + items, err := sim.ChangeSimApnFunction(ctx, &model.SimCardOperateChangeApnInput{ + SimKeys: []*model.SimKey{{ChanKey: "test", ICCID: "card-1", MSISDN: "test-msisdn-1"}, {ChanKey: "test", ICCID: "card-2", MSISDN: "test-msisdn-2"}}, + ReqData: model.SimCardOperateChangeApnInputItem{OperType: "1", SubmitOnly: mode == "仅提交"}, + }) + if err != nil || len(items) != 2 || writes != 2 || (mode == "持久化回调" && checkpoints != 2) { + t.Fatalf("逐卡处理异常:items=%+v writes=%d checkpoints=%d err=%v", items, writes, checkpoints, err) + } + check(items) + }) + } + } +} + +func TestDXSingleCutNetRedirectDoesNotReplay(t *testing.T) { + for _, status := range []int{301, 302, 307, 308} { + t.Run(fmt.Sprint(status), func(t *testing.T) { + writes, redirected := 0, 0 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/redirected" { + redirected++ + return + } + writes++ + w.Header().Set("Location", "/redirected") + w.WriteHeader(status) + })) + 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: "13300000000"}}, + ReqData: model.SimCardOperateChangeApnInputItem{OperType: "1"}, + }) + if err != nil || len(output) != 1 { + t.Fatalf("output=%+v err=%v", output, err) + } + if writes != 1 || redirected != 0 || output[0].Code == 0 || !output[0].ResData.Uncertain { + t.Fatalf("writes=%d redirected=%d output=%+v", writes, redirected, output) + } + }) + } +} + +func TestDXSingleCutNetResponseLossIsUncertain(t *testing.T) { + writes := 0 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + writes++ + conn, _, err := w.(http.Hijacker).Hijack() + if err != nil { + t.Errorf("hijack: %v", err) + return + } + _ = conn.Close() + })) + 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: "13300000000"}}, + ReqData: model.SimCardOperateChangeApnInputItem{OperType: "1"}, + }) + if err != nil || len(output) != 1 { + t.Fatalf("output=%+v err=%v", output, err) + } + if writes != 1 || output[0].Code == 0 || !output[0].ResData.Uncertain { + t.Fatalf("writes=%d output=%+v", writes, output) + } +} + +func TestDXSingleCutNetDurableCheckpoint(t *testing.T) { + writes, checkpoints := 0, 0 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + writes++ + fmt.Fprint(w, `0000job-1`) + })) + defer server.Close() + sim := &sSimDX{client: dx.NewClient(dx.Config{BaseURL: server.URL})} + ctx := context.WithValue(context.Background(), model.ApnOperationCheckpointKey{}, func(items model.SimCardOperateChangeApnOutput) error { + checkpoints++ + if len(items) != 1 || items[0].ResData.OrderNum != "job-1" { + t.Errorf("checkpoint must preserve the first card job ID: %+v", items) + } + return fmt.Errorf("stop after durable checkpoint") + }) + _, err := sim.ChangeSimApnFunction(ctx, &model.SimCardOperateChangeApnInput{ + SimKeys: []*model.SimKey{{MSISDN: "13300000000"}, {MSISDN: "13300000001"}}, + ReqData: model.SimCardOperateChangeApnInputItem{OperType: "1"}, + }) + if err == nil || writes != 1 || checkpoints != 1 { + t.Fatalf("must stop before second write: writes=%d checkpoints=%d err=%v", writes, checkpoints, err) + } +} + // TestDXChangeAPNSingleCutNetFailClosed 验证失败保护:不成功不重试、不盲目视为成功。 func TestDXChangeAPNSingleCutNetFailClosed(t *testing.T) { tests := []struct { @@ -166,12 +299,12 @@ func TestDXChangeAPNSingleCutNetFailClosed(t *testing.T) { 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: `1000rejected`, wantRequests: 1}, - {name: "missing result code", operType: "0", msisdn: "138****8000", body: `okorder`, wantRequests: 1}, - {name: "missing order number", operType: "0", msisdn: "138****8000", body: `0000ok`, 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}, + {name: "invalid operation", operType: "2", msisdn: "13300000000"}, + {name: "carrier rejected", operType: "1", msisdn: "13300000000", body: `1000rejected`, wantRequests: 1}, + {name: "missing result code", operType: "0", msisdn: "13300000000", body: `okorder`, wantRequests: 1}, + {name: "missing order number", operType: "0", msisdn: "13300000000", body: `0000ok`, wantRequests: 1}, + {name: "malformed response", operType: "0", msisdn: "13300000000", body: `{"code":"0"}`, wantRequests: 1}, + {name: "HTTP failed no retry", operType: "0", msisdn: "13300000000", body: `gateway down`, httpStatus: 503, wantRequests: 1}, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { diff --git a/internal/logic/upstreamimpl/sim_dx_batch.go b/internal/logic/upstreamimpl/sim_dx_batch.go index 37e3710..735ea1d 100644 --- a/internal/logic/upstreamimpl/sim_dx_batch.go +++ b/internal/logic/upstreamimpl/sim_dx_batch.go @@ -4,13 +4,13 @@ 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" + "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" "github.com/gogf/gf/v2/frame/g" "github.com/gogf/gf/v2/util/gconv" diff --git a/internal/logic/upstreamimpl/sim_dx_network_block.go b/internal/logic/upstreamimpl/sim_dx_network_block.go index 8cacd51..b74cdc2 100644 --- a/internal/logic/upstreamimpl/sim_dx_network_block.go +++ b/internal/logic/upstreamimpl/sim_dx_network_block.go @@ -1,8 +1,8 @@ package upstreamimpl import ( - "git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/model" "strings" + "git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/model" ) func dxSessionAccessMode(netModel string, accessType string) string { diff --git a/internal/logic/upstreamimpl/sim_dx_network_block_test.go b/internal/logic/upstreamimpl/sim_dx_network_block_test.go index 3c54f7e..87e0980 100644 --- a/internal/logic/upstreamimpl/sim_dx_network_block_test.go +++ b/internal/logic/upstreamimpl/sim_dx_network_block_test.go @@ -3,11 +3,11 @@ 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" + "git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/logic/upstreamimpl/dx" + "git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/model" ) func TestDXNetworkBlockStatus(t *testing.T) { @@ -38,14 +38,14 @@ func TestDXNetworkBlockStatus(t *testing.T) { 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" { + if request.URL.Path != "/openapi/v1/prodinst/queryCardMainStatus" || request.URL.Query().Get("access_number") != "1440000000001" { 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"}}}) + output, err := sim.QuerySimCardStatus(context.Background(), &model.SimCardStatusInput{SimKeys: []*model.SimKey{{MSISDN: "1440000000001"}}}) if err != nil || len(output) != 1 { t.Fatalf("output = %v, err = %v", output, err) } diff --git a/internal/logic/upstreamimpl/sim_dx_status_test.go b/internal/logic/upstreamimpl/sim_dx_status_test.go index 557b604..09b24d3 100644 --- a/internal/logic/upstreamimpl/sim_dx_status_test.go +++ b/internal/logic/upstreamimpl/sim_dx_status_test.go @@ -64,7 +64,7 @@ func TestQuerySimRegionLimitStatusUsesDocumentedParameter(t *testing.T) { 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" { + if got := r.URL.Query().Get("accNum"); got != "13300000000" { t.Fatalf("accNum = %q", got) } if got := r.URL.Query().Get("access_number"); got != "" { @@ -76,7 +76,7 @@ func TestQuerySimRegionLimitStatusUsesDocumentedParameter(t *testing.T) { 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"}}}) + output, err := sim.QuerySimRegionLimitStatus(context.Background(), &model.SimRegionLimitStatusInput{SimKeys: []*model.SimKey{{MSISDN: "13300000000"}}}) if err != nil { t.Fatal(err) } @@ -101,7 +101,7 @@ func TestQuerySimCardStatusPreservesTelecomHTTPRespCode(t *testing.T) { 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"}}}) + output, err := sim.QuerySimCardStatus(context.Background(), &model.SimCardStatusInput{SimKeys: []*model.SimKey{{MSISDN: "13300000000"}}}) if err != nil { t.Fatal(err) } @@ -122,7 +122,7 @@ func TestQuerySimCardStatusPreservesTelecomBusinessCode(t *testing.T) { 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"}}}) + output, err := sim.QuerySimCardStatus(context.Background(), &model.SimCardStatusInput{SimKeys: []*model.SimKey{{MSISDN: "13300000000"}}}) if err != nil { t.Fatal(err) } diff --git a/internal/logic/upstreamimpl/sim_dx_usage_test.go b/internal/logic/upstreamimpl/sim_dx_usage_test.go index 743c0f1..a8b42a4 100644 --- a/internal/logic/upstreamimpl/sim_dx_usage_test.go +++ b/internal/logic/upstreamimpl/sim_dx_usage_test.go @@ -53,14 +53,14 @@ func TestQuerySimDataUsageMonthlyUsesBatchForCurrentMonth(t *testing.T) { 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"}]}`)) + _, _ = w.Write([]byte(`{"code":0,"msg":"查询成功","data":[{"accessNumber":"13300000001","result":"1","unit":"MB","flowAmount":"12.5"},{"accessNumber":"13300000002","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"}}, + SimKeys: []*model.SimKey{{MSISDN: "13300000001"}, {MSISDN: "13300000002"}}, ReqData: model.SimDataUsageMonthlyInputItem{QueryDate: queryMonth}, }) if err != nil { @@ -92,7 +92,7 @@ func TestQuerySimDataUsageDailyLegacyFallbackIsBounded(t *testing.T) { 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"} + keys[index] = &model.SimKey{MSISDN: "13300000000"} } output, err := sim.QuerySimDataUsageDaily(context.Background(), &model.SimDataUsageDailyInput{ SimKeys: keys, @@ -130,7 +130,7 @@ func TestQuerySimDataUsageDailyRejectsMalformedXML(t *testing.T) { 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"}}, + SimKeys: []*model.SimKey{{ICCID: "audit-card", MSISDN: "13300000001"}}, ReqData: model.SimDataUsageDailyInputItem{QueryDate: "20260101"}, }) if err != nil { @@ -162,7 +162,7 @@ func TestQuerySimDataUsageDailyBatchFailureFallsBackToLegacy(t *testing.T) { 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"}}, + SimKeys: []*model.SimKey{{MSISDN: "13300000001"}}, ReqData: model.SimDataUsageDailyInputItem{QueryDate: queryDate}, }) if err != nil { diff --git a/internal/logic/upstreamimpl/sim_dx_v3.go b/internal/logic/upstreamimpl/sim_dx_v3.go index 34139de..44431b4 100644 --- a/internal/logic/upstreamimpl/sim_dx_v3.go +++ b/internal/logic/upstreamimpl/sim_dx_v3.go @@ -6,16 +6,16 @@ import ( "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" + "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" ) @@ -84,7 +84,11 @@ func (s *sSimDXV3) request(ctx context.Context, path string, params url.Values, } else { data, err = json.Marshal(body) if err == nil { - data, err = s.client.DoPostJSONRequest(ctx, "/api/v3/"+path, data) + 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 { @@ -180,6 +184,7 @@ func dxV3QueryOne[T any](ctx context.Context, s *sSimDXV3, key *model.SimKey, pa err = prepare(params) } var result map[string]any + apnWriteStarted := err == nil && post && path == "singleCutNet" if err == nil { var body any if post { @@ -204,7 +209,14 @@ func dxV3QueryOne[T any](ctx context.Context, s *sSimDXV3, key *model.SimKey, pa output, err = convert(result) } if err != nil { - return dxV3Failure[T](key, err) + 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} } @@ -540,7 +552,7 @@ func (s *sSimDXV3) ChangeSimStatus(ctx context.Context, input *model.SimCardStat } 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 { + prepare := func(params url.Values) error { if !s.enableOperations { return fmt.Errorf("telecom v3 operations are disabled") } @@ -559,10 +571,27 @@ func (s *sSimDXV3) ChangeSimApnFunction(ctx context.Context, input *model.SimCar return fmt.Errorf("unsupported data operType") } return nil - }, func(item map[string]any) (model.SimCardOperateChangeApnItem, error) { + } + convert := func(item map[string]any) (model.SimCardOperateChangeApnItem, error) { order, err := dxV3Order(item) return model.SimCardOperateChangeApnItem{OrderNum: order.OrderNum}, err - }, true), nil + } + 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) diff --git a/internal/logic/upstreamimpl/sim_dx_v3_batch.go b/internal/logic/upstreamimpl/sim_dx_v3_batch.go index 3a6c1cb..52d752a 100644 --- a/internal/logic/upstreamimpl/sim_dx_v3_batch.go +++ b/internal/logic/upstreamimpl/sim_dx_v3_batch.go @@ -3,9 +3,9 @@ package upstreamimpl import ( "context" "fmt" - "git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/model" "strings" "time" + "git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/model" ) const dxV3BatchLimit = 100 diff --git a/internal/logic/upstreamimpl/sim_dx_v3_test.go b/internal/logic/upstreamimpl/sim_dx_v3_test.go index 40ec814..4ac1b6f 100644 --- a/internal/logic/upstreamimpl/sim_dx_v3_test.go +++ b/internal/logic/upstreamimpl/sim_dx_v3_test.go @@ -4,17 +4,18 @@ import ( "context" "encoding/json" "fmt" + "io" + "net/http" + "net/http/httptest" + "strings" + "sync/atomic" + "testing" + "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/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 { @@ -24,6 +25,167 @@ func newDXV3TestClient(t *testing.T, handler http.HandlerFunc) *sSimDXV3 { return &sSimDXV3{client: dx.NewClient(dx.Config{BaseURL: server.URL, AppKey: "test-key", AppSecret: "test-secret", UppercaseSign: true}), custNbr: "test-customer"} } +func TestDXV3APNWriteUncertainAndNoReplay(t *testing.T) { + for _, tc := range []struct { + name, body string + drop, uncertain bool + redirect int + }{ + {name: "断响应", drop: true, uncertain: true}, + {name: "坏JSON", body: "{", uncertain: true}, + {name: "缺result", body: `{"code":0}`, uncertain: true}, + {name: "卡号不符", body: `{"code":0,"result":{"msisdn":"other","transactionId":"job"}}`, uncertain: true}, + {name: "缺transactionId", body: `{"code":0,"result":{}}`, uncertain: true}, + {name: "明确拒绝", body: `{"code":9999,"message":"拒绝"}`}, + {name: "明确成功", body: `{"code":0,"result":{"transactionId":"job"}}`}, + {name: "跳转307", redirect: 307, uncertain: true}, + {name: "跳转308", redirect: 308, uncertain: true}, + } { + t.Run(tc.name, func(t *testing.T) { + var writes, targetWrites atomic.Int32 + client := newDXV3TestClient(t, func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + t.Errorf("办理方法被改变:%s", r.Method) + } + writes.Add(1) + if r.URL.Path == "/redirect-target" { + targetWrites.Add(1) + fmt.Fprint(w, `{"code":0,"result":{"transactionId":"unexpected"}}`) + return + } + if r.URL.Path != "/api/v3/singleCutNet" { + t.Errorf("错误办理路径:%s", r.URL.Path) + } + var body map[string]string + if err := json.NewDecoder(r.Body).Decode(&body); err != nil || body["msisdn"] != "fake-msisdn" || body["operType"] != "1" { + t.Errorf("办理请求不符:%+v %v", body, err) + } + if tc.drop { + conn, _, err := w.(http.Hijacker).Hijack() + if err != nil { + t.Error(err) + return + } + _ = conn.Close() + return + } + if tc.redirect != 0 { + w.Header().Set("Location", "/redirect-target") + w.WriteHeader(tc.redirect) + return + } + fmt.Fprint(w, tc.body) + }) + client.enableOperations = true + out, err := client.ChangeSimApnFunction(context.Background(), &model.SimCardOperateChangeApnInput{SimKeys: []*model.SimKey{{MSISDN: "fake-msisdn"}}, ReqData: model.SimCardOperateChangeApnInputItem{OperType: "0"}}) + if err != nil || len(out) != 1 || writes.Load() != 1 || targetWrites.Load() != 0 || out[0].ResData.Uncertain != tc.uncertain || out[0].ResData.Pending { + t.Fatalf("写后分类/重放错误:out=%+v writes=%d target=%d err=%v", out, writes.Load(), targetWrites.Load(), err) + } + if (tc.name == "明确成功") != (out[0].Code == 0) { + t.Fatalf("成功失败分类错误:%+v", out[0]) + } + t.Logf("writes=%d targetWrites=%d uncertain=%v code=%d", writes.Load(), targetWrites.Load(), out[0].ResData.Uncertain, out[0].Code) + }) + } +} + +func TestDXV3SingleCutNetAcceptanceModes(t *testing.T) { + for _, mode := range []string{"同步兼容", "仅提交", "持久化回调"} { + for _, accepted := range []bool{true, false} { + t.Run(fmt.Sprintf("%s/受理=%t", mode, accepted), func(t *testing.T) { + writes, checkpoints := 0, 0 + client := newDXV3TestClient(t, func(w http.ResponseWriter, r *http.Request) { + writes++ + if !accepted { + fmt.Fprint(w, `{"code":9999,"message":"明确拒绝"}`) + return + } + fmt.Fprintf(w, `{"code":0,"result":{"transactionId":"order-%d"}}`, writes) + }) + client.enableOperations = true + wantUncertain := accepted && mode != "同步兼容" + check := func(items model.SimCardOperateChangeApnOutput) { + t.Helper() + for i, item := range items { + if (item.Code == 0) != accepted || item.ResData.Uncertain != wantUncertain || item.ResData.Pending { + t.Fatalf("受理与完成分类错误:%+v", item) + } + if accepted && item.ResData.OrderNum != fmt.Sprintf("order-%d", i+1) { + t.Fatalf("未保留逐卡受理流水:%+v", item) + } + if wantUncertain && !strings.Contains(item.Message, "最终办理结果未确认") { + t.Fatalf("受理消息未明确最终结果未知:%s", item.Message) + } + if !accepted && (item.ErrCode != "9999" || item.ResData.OrderNum != "") { + t.Fatalf("明确拒绝不应携带受理流水:%+v", item) + } + } + } + ctx := context.Background() + if mode == "持久化回调" { + ctx = context.WithValue(ctx, model.ApnOperationCheckpointKey{}, func(items model.SimCardOperateChangeApnOutput) error { + checkpoints++ + check(items) + return nil + }) + } + items, err := client.ChangeSimApnFunction(ctx, &model.SimCardOperateChangeApnInput{ + SimKeys: []*model.SimKey{{ChanKey: "test", ICCID: "card-1", MSISDN: "test-msisdn-1"}, {ChanKey: "test", ICCID: "card-2", MSISDN: "test-msisdn-2"}}, + ReqData: model.SimCardOperateChangeApnInputItem{OperType: "1", SubmitOnly: mode == "仅提交"}, + }) + if err != nil || len(items) != 2 || writes != 2 || (mode == "持久化回调" && checkpoints != 2) { + t.Fatalf("逐卡处理异常:items=%+v writes=%d checkpoints=%d err=%v", items, writes, checkpoints, err) + } + check(items) + }) + } + } +} + +func TestDXV3APNPrewriteValidationSendsNothing(t *testing.T) { + for _, tc := range []struct { + name string + enabled bool + key *model.SimKey + oper string + }{ + {"禁用办理", false, &model.SimKey{MSISDN: "fake"}, "0"}, + {"缺卡号", true, nil, "0"}, + {"缺msisdn", true, &model.SimKey{ICCID: "fake"}, "0"}, + {"错误操作", true, &model.SimKey{MSISDN: "fake"}, "invalid"}, + } { + t.Run(tc.name, func(t *testing.T) { + var writes atomic.Int32 + client := newDXV3TestClient(t, func(w http.ResponseWriter, r *http.Request) { writes.Add(1) }) + client.enableOperations = tc.enabled + out, err := client.ChangeSimApnFunction(context.Background(), &model.SimCardOperateChangeApnInput{SimKeys: []*model.SimKey{tc.key}, ReqData: model.SimCardOperateChangeApnInputItem{OperType: tc.oper}}) + if err != nil || len(out) != 1 || writes.Load() != 0 || out[0].Code == 0 || out[0].ResData.Uncertain { + t.Fatalf("预写拒绝错误:out=%+v writes=%d err=%v", out, writes.Load(), err) + } + }) + } +} + +func TestDXV3ReadRedirectUnchanged(t *testing.T) { + var calls atomic.Int32 + client := newDXV3TestClient(t, func(w http.ResponseWriter, r *http.Request) { + calls.Add(1) + if r.Method != http.MethodGet { + t.Errorf("查询方法改变:%s", r.Method) + } + if r.URL.Path != "/read-target" { + w.Header().Set("Location", "/read-target") + w.WriteHeader(http.StatusTemporaryRedirect) + return + } + fmt.Fprint(w, `{"code":0,"result":{"dataAmount":"2"}}`) + }) + out, err := client.QuerySimDataUsage(context.Background(), &model.SimDataUsageInput{SimKeys: []*model.SimKey{{MSISDN: "fake"}}}) + if err != nil || len(out) != 1 || out[0].Code != 0 || calls.Load() != 2 { + t.Fatalf("查询跳转被改变:%+v %v calls=%d", out, err, calls.Load()) + } +} + func TestDXV3TemplateCoexistsWithV1(t *testing.T) { for _, key := range []string{"sim_dx_v1", "sim_dx_v3"} { if _, ok := service.UpstreamAPI().GetTemp(key); !ok { @@ -57,7 +219,7 @@ func TestDXV3SessionAndRealTimeUsageContracts(t *testing.T) { } switch r.URL.Path { case "/api/v3/getOnlineStatus": - fmt.Fprint(w, `{"code":"0","result":{"onlineStatus":"1","apnName":"iot.apn","ipv4Address":"192.0.2.1"}}`) + fmt.Fprint(w, `{"code":"0","result":{"onlineStatus":"1","apnName":"iot.apn","ipv4Address":"127.0.0.1"}}`) case "/api/v3/getDataUsage": fmt.Fprint(w, `{"code":0,"result":{"dataAmount":"12.5"}}`) default: diff --git a/internal/logic/upstreamimpl/sim_enum.go b/internal/logic/upstreamimpl/sim_enum.go index bc32325..9653aa3 100644 --- a/internal/logic/upstreamimpl/sim_enum.go +++ b/internal/logic/upstreamimpl/sim_enum.go @@ -2,9 +2,9 @@ package upstreamimpl import ( "context" + "strings" "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" ) diff --git a/internal/logic/upstreamimpl/sim_mingqian.go b/internal/logic/upstreamimpl/sim_mingqian.go index 14b3a3c..c19872d 100644 --- a/internal/logic/upstreamimpl/sim_mingqian.go +++ b/internal/logic/upstreamimpl/sim_mingqian.go @@ -2,10 +2,10 @@ package upstreamimpl import ( "context" + "github.com/gogf/gf/v2/frame/g" "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{} diff --git a/internal/logic/upstreamimpl/sim_onelink_shared.go b/internal/logic/upstreamimpl/sim_onelink_shared.go index 47e250d..ac55b7e 100644 --- a/internal/logic/upstreamimpl/sim_onelink_shared.go +++ b/internal/logic/upstreamimpl/sim_onelink_shared.go @@ -8,10 +8,10 @@ import ( "sync" "time" + "github.com/gogf/gf/v2/frame/g" "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 ( diff --git a/internal/logic/upstreamimpl/sim_qianxun.go b/internal/logic/upstreamimpl/sim_qianxun.go index f26719f..bee028d 100644 --- a/internal/logic/upstreamimpl/sim_qianxun.go +++ b/internal/logic/upstreamimpl/sim_qianxun.go @@ -15,11 +15,11 @@ import ( "strings" "time" + "github.com/gogf/gf/v2/frame/g" + "github.com/gogf/gf/v2/util/gconv" "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 ( diff --git a/internal/logic/upstreamimpl/sim_recharge.go b/internal/logic/upstreamimpl/sim_recharge.go index d0f62f7..743da4c 100644 --- a/internal/logic/upstreamimpl/sim_recharge.go +++ b/internal/logic/upstreamimpl/sim_recharge.go @@ -3,13 +3,13 @@ package upstreamimpl import ( "context" "encoding/json" + "strings" + "sync" + "time" "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" diff --git a/internal/logic/upstreamimpl/sim_recharge_cache_test.go b/internal/logic/upstreamimpl/sim_recharge_cache_test.go index 03e569e..bb91006 100644 --- a/internal/logic/upstreamimpl/sim_recharge_cache_test.go +++ b/internal/logic/upstreamimpl/sim_recharge_cache_test.go @@ -17,7 +17,7 @@ func TestRechargeFindCardCacheIsSharedAcrossDiagnosisQueries(t *testing.T) { 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"}}`)) + _, _ = w.Write([]byte(`{"code":200,"msg":"ok","data":{"w_real_name":1,"c_no":"8986000000000000001","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() @@ -27,7 +27,7 @@ func TestRechargeFindCardCacheIsSharedAcrossDiagnosisQueries(t *testing.T) { Pwdiv: "secret", Timeout: time.Second, })} - simKey := &model.SimKey{ICCID: "8986000000000000000"} + simKey := &model.SimKey{ICCID: "8986000000000000001"} basic, err := sim.QuerySimBasicInfo(context.Background(), &model.SimBasicInfoInput{SimKeys: []*model.SimKey{simKey}}) if err != nil || len(basic) != 1 || basic[0].Code != 0 { diff --git a/internal/logic/upstreamimpl/sim_tibiot.go b/internal/logic/upstreamimpl/sim_tibiot.go index ffe4d19..b3ce2df 100644 --- a/internal/logic/upstreamimpl/sim_tibiot.go +++ b/internal/logic/upstreamimpl/sim_tibiot.go @@ -15,12 +15,12 @@ import ( "sync" "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" "golang.org/x/sync/singleflight" + "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 ( @@ -206,6 +206,18 @@ func (s *sSimTibiot) post(ctx context.Context, endpoint string, params map[strin if client == nil { client = &http.Client{Timeout: defaultUpstreamHTTPTimeout} } + if endpoint == "/api/v1/card/cardNetWork" { + transport := http.DefaultTransport.(*http.Transport).Clone() + if original, ok := client.Transport.(*http.Transport); ok { + transport = original.Clone() + } + transport.DisableKeepAlives = true + defer transport.CloseIdleConnections() + writeClient := *client + writeClient.Transport = transport + writeClient.CheckRedirect = func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse } + client = &writeClient + } resp, err := client.Do(req) if err != nil { return tibiotResponse{}, fmt.Errorf("Tibiot request %s: %w", endpoint, err) @@ -930,10 +942,22 @@ func (s *sSimTibiot) ChangeSimApnFunction(ctx context.Context, input *model.SimC params["operateType"] = operateType resp, err := s.post(ctx, "/api/v1/card/cardNetWork", params) if err != nil { - items = append(items, tibiotError[model.SimCardOperateChangeApnItem](simKey, err)) + item := tibiotError[model.SimCardOperateChangeApnItem](simKey, err) + item.ResData.Uncertain = true + items = append(items, item) continue } - if !tibiotSuccess(resp) { + statusText := strings.TrimSpace(fmt.Sprint(resp.Status)) + status, statusErr := strconv.Atoi(statusText) + if statusErr != nil || (status == 0 && statusText != "0") { + items = append(items, &model.SimBaseRes[model.SimCardOperateChangeApnItem]{ + SimKey: simKey, Code: consts.ApiStatusError, + Message: "天比高 APN 开停响应状态无效,办理结果未确认:" + statusText, + ResData: model.SimCardOperateChangeApnItem{Uncertain: true}, + }) + continue + } + if status != 0 { items = append(items, tibiotBusinessError[model.SimCardOperateChangeApnItem](simKey, resp)) continue } diff --git a/internal/logic/upstreamimpl/sim_tibiot_rate_limit_test.go b/internal/logic/upstreamimpl/sim_tibiot_rate_limit_test.go index c804c4f..d7f716f 100644 --- a/internal/logic/upstreamimpl/sim_tibiot_rate_limit_test.go +++ b/internal/logic/upstreamimpl/sim_tibiot_rate_limit_test.go @@ -13,6 +13,86 @@ import ( "git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/model/entity" ) +func TestTibiotAPNWriteResponseLossIsUncertain(t *testing.T) { + for _, tc := range []struct { + name string + body string + drop bool + uncertain bool + wantCode int + }{ + {"已办理后断响应", "", true, true, 9999}, + {"响应损坏", "{", false, true, 9999}, + {"缺status", `{}`, false, true, 9999}, + {"畸形status", `{"status":true}`, false, true, 9999}, + {"小数status", `{"status":0.5}`, false, true, 9999}, + {"明确拒绝", `{"status":9999,"message":"拒绝"}`, false, false, 9999}, + {"明确成功", `{"status":0,"message":"成功"}`, false, false, 0}, + } { + t.Run(tc.name, func(t *testing.T) { + var writes atomic.Int32 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/api/v1/card/cardNetWork" { + t.Errorf("错误路由:%s", r.URL.Path) + } + writes.Add(1) + if tc.drop { + conn, _, err := w.(http.Hijacker).Hijack() + if err != nil { + t.Error(err) + return + } + _ = conn.Close() + return + } + _, _ = w.Write([]byte(tc.body)) + })) + defer server.Close() + client := &sSimTibiot{baseURL: server.URL, userName: "tester", password: "fake", http: server.Client()} + out, err := client.ChangeSimApnFunction(context.Background(), &model.SimCardOperateChangeApnInput{ + SimKeys: []*model.SimKey{{ICCID: "fake-card"}}, + ReqData: model.SimCardOperateChangeApnInputItem{OperType: "0", SubmitOnly: true}, + }) + if err != nil || len(out) != 1 || writes.Load() != 1 || out[0].Code != tc.wantCode || out[0].ResData.Uncertain != tc.uncertain || out[0].ResData.Pending { + t.Fatalf("写后语义错误:out=%+v writes=%d err=%v", out, writes.Load(), err) + } + t.Logf("writes=%d uncertain=%v code=%d", writes.Load(), out[0].ResData.Uncertain, out[0].Code) + }) + } +} + +func TestTibiotAPNRedirectDoesNotReplayAndReadUnchanged(t *testing.T) { + for _, status := range []int{307, 308} { + t.Run(http.StatusText(status), func(t *testing.T) { + var writes, targets atomic.Int32 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + t.Errorf("方法改变:%s", r.Method) + } + writes.Add(1) + if r.URL.Path == "/target" { + targets.Add(1) + _, _ = w.Write([]byte(`{"status":0}`)) + return + } + w.Header().Set("Location", "/target") + w.WriteHeader(status) + })) + defer server.Close() + client := &sSimTibiot{baseURL: server.URL, userName: "tester", password: "fake", http: server.Client()} + out, err := client.ChangeSimApnFunction(context.Background(), &model.SimCardOperateChangeApnInput{SimKeys: []*model.SimKey{{ICCID: "fake"}}, ReqData: model.SimCardOperateChangeApnInputItem{OperType: "0"}}) + if err != nil || len(out) != 1 || writes.Load() != 1 || targets.Load() != 0 || out[0].Code == 0 || !out[0].ResData.Uncertain { + t.Fatalf("跳转重放/分类错误:out=%+v writes=%d targets=%d err=%v", out, writes.Load(), targets.Load(), err) + } + t.Logf("APN writes=%d targetWrites=%d uncertain=%v", writes.Load(), targets.Load(), out[0].ResData.Uncertain) + resp, err := client.post(context.Background(), "/read-endpoint", map[string]any{}) + if err != nil || !tibiotSuccess(resp) || writes.Load() != 3 || targets.Load() != 1 { + t.Fatalf("只读跳转改变:%+v %v calls=%d targets=%d", resp, err, writes.Load(), targets.Load()) + } + }) + } +} + func TestTibiotRateLimiterSpacesRequestsAndHonorsCancellation(t *testing.T) { limiter := newTibiotRateLimiter(25 * time.Millisecond) if err := limiter.wait(context.Background()); err != nil { @@ -67,7 +147,7 @@ func TestTibiotQuerySimDataUsageRetriesOnceAfterUpstreamFailure(t *testing.T) { Channel: &entity.TSimChannels{ChanConfig: `{"userName":"tester","password":"secret"}`}, }).(*sSimTibiot) items, err := client.QuerySimDataUsage(context.Background(), &model.SimDataUsageInput{ - SimKeys: []*model.SimKey{{ICCID: "8986000000000000000"}}, + SimKeys: []*model.SimKey{{ICCID: "8986000000000000001"}}, }) if err != nil || len(items) != 1 { t.Fatalf("unexpected query result: items=%#v err=%v", items, err) @@ -80,7 +160,7 @@ func TestTibiotQuerySimDataUsageRetriesOnceAfterUpstreamFailure(t *testing.T) { } cachedItems, err := client.QuerySimDataUsage(context.Background(), &model.SimDataUsageInput{ - SimKeys: []*model.SimKey{{ICCID: "8986000000000000000"}}, + SimKeys: []*model.SimKey{{ICCID: "8986000000000000001"}}, }) if err != nil || len(cachedItems) != 1 { t.Fatalf("unexpected cached query result: items=%#v err=%v", cachedItems, err) diff --git a/internal/logic/upstreamimpl/sim_yd.go b/internal/logic/upstreamimpl/sim_yd.go index 76e273d..54d0515 100644 --- a/internal/logic/upstreamimpl/sim_yd.go +++ b/internal/logic/upstreamimpl/sim_yd.go @@ -4,13 +4,13 @@ import ( "context" "encoding/json" "fmt" + "strings" + "sync/atomic" + "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" sharedtoken "git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/token" - "strings" - "sync/atomic" - "time" "github.com/gogf/gf/v2/errors/gerror" "github.com/gogf/gf/v2/frame/g" @@ -266,16 +266,16 @@ func (s *sSimYD) httpTimeout() time.Duration { } // 单卡基本信息查询 -// http://api.iot.10086.cn/v5/ec/query/sim-basic-info?transid=YOUR_TRANSID&token=YOUR_TOKEN&iccid=898604F4152391115474 +// http://api.iot.10086.cn/v5/ec/query/sim-basic-info?transid=REPLACE_WITH_VALUE&token=REPLACE_WITH_VALUE&iccid=89860000000000000004 func (s *sSimYD) QuerySimBasicInfo(ctx context.Context, input *model.SimBasicInfoInput) (model.SimBasicInfoOutput, error) { // { // "status": "0", // "message": "正确", // "result": [ // { - // "msisdn": "1441543605474", - // "imsi": "460087436015474", - // "iccid": "898604F4152391115474", + // "msisdn": "1440000000002", + // "imsi": "460000000000002", + // "iccid": "89860000000000000004", // "activeDate": "2023-05-11 15:07:06", // 激活日期 // "openDate": "2023-05-11 14:53:24", // 开卡日期 // "remark": "" @@ -559,7 +559,7 @@ func (s *sSimYD) QuerySimSession(ctx context.Context, input *model.SimSessionInp } // 5.1.6 单卡状态查询 -// https://api.iot.10086.cn/v5/ec/query/sim-status?transid=YOUR_TRANSID&token=YOUR_TOKEN&iccid=898604F4152391115474 +// https://api.iot.10086.cn/v5/ec/query/sim-status?transid=REPLACE_WITH_VALUE&token=REPLACE_WITH_VALUE&iccid=89860000000000000004 func (s *sSimYD) QuerySimCardStatus(ctx context.Context, input *model.SimCardStatusInput) (model.SimCardStatusOutput, error) { url := s.getUrl(ctx, "ec/query/sim-status") @@ -664,7 +664,7 @@ func (s *sSimYD) QuerySimCardStatus(ctx context.Context, input *model.SimCardSta } // 物联网卡IMEI查询 -// https://api.iot.10086.cn/v5/ec/query/sim-imei?transid=371AIOT20210605101712810732026031520280010000000&token=YOUR_TOKEN&iccid=898604F81623D2249725 +// https://api.iot.10086.cn/v5/ec/query/sim-imei?transid=REPLACE_WITH_VALUE&token=REPLACE_WITH_VALUE&iccid=89860000000000000005 func (s *sSimYD) QuerySimStopReason(ctx context.Context, input *model.SimStopReasonInput) (model.SimStopReasonOutput, error) { url := s.getUrl(ctx, "ec/query/sim-stop-reason") @@ -929,7 +929,7 @@ func (s *sSimYD) QuerySimImei(ctx context.Context, input *model.SimImeiInput) (m } // 单卡本月流量累计使用量查询 -// https://api.iot.10086.cn/v5/ec/query/sim-data-usage-inpool?transid=371AIOT20210605101712810732026032616060010000000&token=YOUR_TOKEN&msisdn=1441589159774 +// https://api.iot.10086.cn/v5/ec/query/sim-data-usage-inpool?transid=REPLACE_WITH_VALUE&token=REPLACE_WITH_VALUE&msisdn=1440000000003 func (s *sSimYD) QuerySimDataUsageInPool(ctx context.Context, input *model.SimDataUsageInPoolInput) (model.SimDataUsageInPoolOutput, error) { url := s.getUrl(ctx, "ec/query/sim-data-usage-inpool") // {"status":"0","message":"正确","result":[{"apnList":[{"apnName":"CMIOT","apnUseAmount":"5994479616"}]}]} @@ -1023,7 +1023,7 @@ func (s *sSimYD) QuerySimDataUsageInPool(ctx context.Context, input *model.SimDa } // 单卡本月流量累计使用量查询 -// https://api.iot.10086.cn/v5/ec/query/sim-data-usage?transid=371AIOT20210605101712810732026032616060010000000&token=YOUR_TOKEN&msisdn=1441589159774 +// https://api.iot.10086.cn/v5/ec/query/sim-data-usage?transid=REPLACE_WITH_VALUE&token=REPLACE_WITH_VALUE&msisdn=1440000000003 func (s *sSimYD) QuerySimDataUsage(ctx context.Context, input *model.SimDataUsageInput) (model.SimDataUsageOutput, error) { url := s.getUrl(ctx, "ec/query/sim-data-usage") // {"status":"0","message":"正确","result":[{"dataAmount":"710569.00","apnUseAmountList":[{"apnName":"CMIOT","apnUseAmount":"710569.00","pccCodeUseAmountList":[]}]}]} @@ -1367,7 +1367,7 @@ func (s *sSimYD) QuerySimDataUsageDaily(ctx context.Context, input *model.SimDat } // 单卡已开通APN信息查询 -// https://api.iot.10086.cn/v5/ec/query/apn-info?transid=C5010531Af52022004082026060114500001000000&token=YOUR_TOKEN&iccid=898604F4152391115473 +// https://api.iot.10086.cn/v5/ec/query/apn-info?transid=REPLACE_WITH_VALUE&token=REPLACE_WITH_VALUE&iccid=89860000000000000003 func (s *sSimYD) QuerySimApnInfo(ctx context.Context, input *model.SimApnInfoInput) (model.SimApnInfoOutput, error) { url := s.getUrl(ctx, "ec/query/apn-info") // {"status":"0","message":"正确","result":[{"apnList":[{"apnName":"CMIOT","status":"1"}]}]} @@ -1487,7 +1487,7 @@ func (s *sSimYD) QuerySimApnInfo(ctx context.Context, input *model.SimApnInfoInp // 单卡状态变更 // 平台 operType: 0=申请停机(已激活转已停机), 1=申请复机(已停机转已激活) -// https://api.iot.10086.cn/v5/ec/change/sim-status?transid=371AIOT20210605101712810732026031315120010000000&token=YOUR_TOKEN&msisdn=1441589159774&operType=0 +// https://api.iot.10086.cn/v5/ec/change/sim-status?transid=REPLACE_WITH_VALUE&token=REPLACE_WITH_VALUE&msisdn=1440000000003&operType=0 func (s *sSimYD) ChangeSimStatus(ctx context.Context, input *model.SimCardStatusChangeInput) (model.SimCardStatusChangeOutput, error) { operType, ok := normalizeCardStatusChangeOperType(ctx, input.ReqData.OperType) if !ok { @@ -1495,7 +1495,7 @@ func (s *sSimYD) ChangeSimStatus(ctx context.Context, input *model.SimCardStatus } url := s.getUrl(ctx, "ec/change/sim-status") - // {"status":"0","message":"正确","result":[{"iccid":"898604F4152391115474","orderNum":"995000011515720993"}]} + // {"status":"0","message":"正确","result":[{"iccid":"89860000000000000004","orderNum":"995000011515720993"}]} type YdResp struct { Status string `json:"status"` Message string `json:"message"` @@ -1701,7 +1701,7 @@ func (s *sSimYD) QuerySimCardBindStatus(ctx context.Context, input *model.SimCar } // 单卡设备状态查询 -// https://api.iot.10086.cn/v5/ec/query/sim-operating-status?transid=xxx&token=xxx&iccid=xxx +// https://api.iot.10086.cn/v5/ec/query/sim-operating-status?transid=REPLACE_WITH_VALUE&token=REPLACE_WITH_VALUE&iccid=xxx // 上游返回 result 中的 operatingStatus/onOffStatus/status 作为设备状态 func (s *sSimYD) QuerySimDeviceStatus(ctx context.Context, input *model.SimDeviceStatusInput) (model.SimDeviceStatusOutput, error) { url := s.getUrl(ctx, "ec/query/sim-operating-status") diff --git a/internal/logic/upstreamimpl/sim_yd_apn_communication.go b/internal/logic/upstreamimpl/sim_yd_apn_communication.go index 564311b..796364e 100644 --- a/internal/logic/upstreamimpl/sim_yd_apn_communication.go +++ b/internal/logic/upstreamimpl/sim_yd_apn_communication.go @@ -3,6 +3,8 @@ package upstreamimpl import ( "context" "encoding/json" + "fmt" + "strconv" "strings" "time" @@ -10,7 +12,6 @@ import ( "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)。 @@ -75,6 +76,9 @@ type ydApnRequester interface { // 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)) @@ -101,9 +105,29 @@ func changeSimApnFunctionYD(ctx context.Context, c ydApnRequester, input *model. 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) + } + } + } } - items = append(items, changeApnSingleYD(ctx, c, singleKeys, input.ReqData)...) + 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 } @@ -123,7 +147,7 @@ func changeApnSingleYD(ctx context.Context, c ydApnRequester, simKeys []*model.S } url := c.getUrl(ctx, ydCommunicationServicePath) - // {"status":"0","message":"正确","result":[{"iccid":"898602B2221430000005","orderNum":"xxxxxxx"}]} + // {"status":"0","message":"正确","result":[{"iccid":"89860000000000000002","orderNum":"xxxxxxx"}]} type ydSingleResp struct { Status string `json:"status"` Message string `json:"message"` @@ -156,19 +180,23 @@ func changeApnSingleYD(ctx context.Context, c ydApnRequester, simKeys []*model.S 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())) + 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) - items = append(items, ydApnErrorItem(simKey, err.Error())) + 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, ydApnResultItem(simKey, gconv.Int(ydResp.Status), ydResp.Message, "")) + items = append(items, ydApnRejectedItem(simKey, ydResp.Status, ydResp.Message)) continue } orderNum := "" @@ -219,7 +247,9 @@ func changeApnBatchYD(ctx context.Context, c ydApnRequester, simKeys []*model.Si 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())) + item := ydApnErrorItem(simKey, err.Error()) + item.ResData.Uncertain = req.SubmitOnly + items = append(items, item) } return items } @@ -228,14 +258,16 @@ func changeApnBatchYD(ctx context.Context, c ydApnRequester, simKeys []*model.Si 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())) + 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, ydApnResultItem(simKey, gconv.Int(ydResp.Status), ydResp.Message, "")) + items = append(items, ydApnRejectedItem(simKey, ydResp.Status, ydResp.Message)) } return items } @@ -246,7 +278,18 @@ func changeApnBatchYD(ctx context.Context, c ydApnRequester, simKeys []*model.Si 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", "")) + 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 } @@ -263,26 +306,41 @@ func changeApnBatchYD(ctx context.Context, c ydApnRequester, simKeys []*model.Si for _, simKey := range simKeys { card, exist := result.cards[strings.TrimSpace(simKey.MSISDN)] if !exist { - items = append(items, ydApnResultItem(simKey, consts.ApiStatusError, "上游批量结果未返回该卡", jobId)) + item := ydApnResultItem(simKey, consts.ApiStatusError, "上游批量结果未返回该卡", jobId) + item.ResData.Uncertain = true + items = append(items, item) continue } - items = append(items, ydApnResultItem(simKey, card.code, card.message, jobId)) + 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 { - items = append(items, ydApnResultItem(simKey, consts.ApiStatusError, ydBatchResultQueryFailedMessage, jobId)) + 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 + code int + message string + uncertain bool } // ydBatchQueryResult 批量办理结果查询的结论。 @@ -290,6 +348,7 @@ 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 的逐卡结果项。 @@ -313,43 +372,83 @@ type ydBatchResultResp struct { // 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) + pollCtx, cancel := context.WithTimeout(ctx, ydBatchResultPollTimeout) + defer cancel() + var last ydBatchQueryResult for { - params := g.Map{ - "transid": c.getTransId(ctx), - "jobId": jobId, + if err := pollCtx.Err(); err != nil { + last.reason = "查询上游通信结果截止:" + err.Error() + ";" + last.reason + return last } - 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{} + last = queryBatchResultOnceYD(pollCtx, c, jobId) + if last.finished { + return last } + timer := time.NewTimer(ydBatchResultPollInterval) select { - case <-ctx.Done(): - return ydBatchQueryResult{} - case <-time.After(ydBatchResultPollInterval): + 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)) @@ -358,11 +457,26 @@ func toYdBatchCardMap(list []ydBatchResultItem) map[string]ydBatchCardResult { if id == "" { continue } - cards[id] = ydBatchCardResult{code: gconv.Int(item.Status), message: item.Message} + 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, diff --git a/internal/logic/upstreamimpl/sim_yd_apn_communication_test.go b/internal/logic/upstreamimpl/sim_yd_apn_communication_test.go index 73f0466..ab36a34 100644 --- a/internal/logic/upstreamimpl/sim_yd_apn_communication_test.go +++ b/internal/logic/upstreamimpl/sim_yd_apn_communication_test.go @@ -2,6 +2,7 @@ package upstreamimpl import ( "context" + "errors" "fmt" "net/http" "net/http/httptest" @@ -14,6 +15,196 @@ import ( "git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/model/entity" ) +func TestYdLegacyPollingHardDeadlineCancelsInflightRequest(t *testing.T) { + defer shortenYdBatchPoll(50 * time.Millisecond)() + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + select { + case <-r.Context().Done(): + case <-time.After(time.Second): + _, _ = w.Write([]byte(`{"status":"0","result":[{"jobStatus":"1"}]}`)) + } + })) + defer server.Close() + started := time.Now() + result := queryBatchResultYD(context.Background(), newYdApnChannelWithServer(server), "deadline-job") + if result.finished || time.Since(started) > 500*time.Millisecond || !strings.Contains(result.reason, "deadline") { + t.Fatalf("轮询没有硬截止:elapsed=%s result=%+v", time.Since(started), result) + } +} + +func TestYdAsyncCheckpointFailureStopsFurtherCarrierWrites(t *testing.T) { + for _, tc := range []struct { + name string + keys []*model.SimKey + firstPath string + firstLen int + }{ + {name: "single_then_single", keys: ydSimKeysWithMsisdn(2), firstPath: ydCommunicationServicePath, firstLen: 1}, + {name: "batch_then_single", keys: func() []*model.SimKey { + keys := ydSimKeysWithMsisdn(11) + keys[10].MSISDN = "" + return keys + }(), firstPath: ydCommunicationFunctionBatchPath, firstLen: 10}, + } { + t.Run(tc.name, func(t *testing.T) { + capture := &ydApnCapture{} + channel, closeServer := newYdApnTestChannel(capture) + defer closeServer() + sentinel := errors.New("checkpoint unavailable") + checkpoints := 0 + ctx := context.WithValue(context.Background(), model.ApnOperationCheckpointKey{}, func(output model.SimCardOperateChangeApnOutput) error { + checkpoints++ + if len(output) != tc.firstLen { + t.Errorf("checkpoint contains %d results, want %d", len(output), tc.firstLen) + } + return sentinel + }) + items, err := changeSimApnFunctionYD(ctx, channel, &model.SimCardOperateChangeApnInput{ + SimKeys: tc.keys, ReqData: model.SimCardOperateChangeApnInputItem{OperType: "0", SubmitOnly: true, OperationID: "test-operation"}, + }) + if !errors.Is(err, sentinel) || checkpoints != 1 || len(items) != tc.firstLen { + t.Fatalf("checkpoint failure not propagated: err=%v checkpoints=%d items=%d", err, checkpoints, len(items)) + } + if len(capture.paths) != 1 || !strings.HasSuffix(capture.paths[0], tc.firstPath) { + t.Fatalf("wrote another carrier request after checkpoint failure: %v", capture.paths) + } + }) + } +} + +func TestYdAsyncSubmitReturnsJobWithoutQuery(t *testing.T) { + capture := &ydApnCapture{} + channel, closeServer := newYdApnTestChannel(capture) + defer closeServer() + checkpoints := 0 + ctx := context.WithValue(context.Background(), model.ApnOperationCheckpointKey{}, func(output model.SimCardOperateChangeApnOutput) error { + checkpoints++ + if output[0].ResData.OrderNum != "JOB1" { + t.Fatal("checkpoint缺少jobId") + } + return nil + }) + items, err := changeSimApnFunctionYD(ctx, channel, &model.SimCardOperateChangeApnInput{ + SimKeys: ydSimKeysWithMsisdn(100), + ReqData: model.SimCardOperateChangeApnInputItem{OperType: "1", SubmitOnly: true}, + }) + if checkpoints != 1 { + t.Fatalf("拿jobId后未立即持久化:%d", checkpoints) + } + if err != nil || len(items) != 100 || len(capture.paths) != 1 { + t.Fatalf("提交应仅调用一次,items=%d paths=%v err=%v", len(items), capture.paths, err) + } + for _, item := range items { + if !item.ResData.Pending || item.ResData.OrderNum != "JOB1" || item.ResData.Uncertain { + t.Fatalf("受理状态错误:%+v", item) + } + } +} + +func TestYdAsyncQueryOnlyOnceAndPreservesReason(t *testing.T) { + for _, body := range []string{ + `{"status":"13012","message":"查询限流"}`, + `{"status":"0","result":[{"jobStatus":"1"}]}`, + `invalid-json`, + `{"status":"0","result":[]}`, + } { + t.Run(body, func(t *testing.T) { + calls := 0 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + calls++ + if !strings.Contains(r.URL.Path, "sim-batch-result") || r.URL.Query().Get("jobId") != "persisted-job" { + t.Errorf("不应重新提交:%s", r.URL.String()) + } + _, _ = w.Write([]byte(body)) + })) + defer server.Close() + items, err := changeSimApnFunctionYD(context.Background(), newYdApnChannelWithServer(server), &model.SimCardOperateChangeApnInput{ + SimKeys: ydSimKeysWithMsisdn(10), ReqData: model.SimCardOperateChangeApnInputItem{QueryJobID: "persisted-job"}, + }) + if err != nil || calls != 1 || len(items) != 10 { + t.Fatalf("查询次数错误:calls=%d err=%v", calls, err) + } + for _, item := range items { + if !item.ResData.Pending || item.Message == "" || item.ResData.OrderNum != "persisted-job" { + t.Fatalf("应保留待查状态和具体原因:%+v", item) + } + } + }) + } +} + +func TestYdAsyncSubmitUnknownIsNotConfirmedFailure(t *testing.T) { + for _, body := range []string{`{"status":"0","result":[]}`, `not-json`, `{}`, `{"status":"bad"}`} { + t.Run(body, func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte(body)) + })) + defer server.Close() + items, err := changeSimApnFunctionYD(context.Background(), newYdApnChannelWithServer(server), &model.SimCardOperateChangeApnInput{ + SimKeys: ydSimKeysWithMsisdn(10), ReqData: model.SimCardOperateChangeApnInputItem{OperType: "1", SubmitOnly: true}, + }) + if err != nil || len(items) != 10 { + t.Fatalf("未知提交结果应逐卡返回:%v", err) + } + for _, item := range items { + if !item.ResData.Uncertain || item.Message == "" { + t.Fatalf("提交结果未知不得确认办理失败:%+v", item) + } + } + }) + } +} + +func TestYdAsyncQueryTerminalResults(t *testing.T) { + keys := ydSimKeysWithMsisdn(10) + for _, tc := range []struct{ status, want string }{{"2", "0"}, {"3", "4001"}, {"4", "failed"}} { + t.Run(tc.status, func(t *testing.T) { + calls := 0 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + calls++ + if !strings.Contains(r.URL.Path, "sim-batch-result") { + t.Error("查询不得提交") + } + _, _ = fmt.Fprintf(w, `{"status":"0","message":"任务完成","result":[{"jobStatus":%q,"resultList":[{"resultId":%q,"status":%q,"message":"逐卡结果"}]}]}`, tc.status, keys[0].MSISDN, tc.want) + })) + defer server.Close() + items, err := newYdApnChannelWithServer(server).QuerySimApnFunctionResult(context.Background(), &model.SimCardOperateChangeApnInput{SimKeys: keys, ReqData: model.SimCardOperateChangeApnInputItem{QueryJobID: "job"}}) + if err != nil || calls != 1 || len(items) != 10 { + t.Fatalf("终态查询错误:%v", err) + } + if items[0].ResData.Pending || items[0].ResData.Uncertain { + t.Fatalf("已确认结果仍标未知:%+v", items[0]) + } + if tc.status == "2" && items[0].Code != 0 { + t.Fatal("成功结果未映射") + } + if tc.status == "3" && items[0].Code != 4001 { + t.Fatal("逐卡失败原因未映射") + } + if tc.status == "4" && items[0].Code == 0 { + t.Fatal("任务失败误报成功") + } + }) + } +} + +func TestYdAsyncMalformedCardStatusIsUnconfirmed(t *testing.T) { + keys := ydSimKeysWithMsisdn(1) + cards := toYdBatchCardMap([]ydBatchResultItem{{ResultId: keys[0].MSISDN, Status: "invalid", Message: "bad status"}}) + items := mapYdQueryResult(keys, "job", ydBatchQueryResult{finished: true, cards: cards}) + if !items[0].ResData.Uncertain || items[0].Code == 0 || !strings.Contains(items[0].Message, "invalid") { + t.Fatalf("无效状态不能按成功处理:%+v", items[0]) + } +} + +func TestYdAsyncMissingCardRemainsUnconfirmed(t *testing.T) { + keys := ydSimKeysWithMsisdn(2) + items := mapYdQueryResult(keys, "job", ydBatchQueryResult{finished: true, cards: map[string]ydBatchCardResult{keys[0].MSISDN: {code: 0}}}) + if items[0].Code != 0 || !items[1].ResData.Uncertain || items[1].ResData.OrderNum != "job" { + t.Fatalf("缺失结果应待核对:%+v", items) + } +} + func TestYdCommunicationBusinessType(t *testing.T) { for _, tc := range []struct { in string diff --git a/internal/logic/upstreamimpl/sim_yd_mock.go b/internal/logic/upstreamimpl/sim_yd_mock.go index c047116..babba67 100644 --- a/internal/logic/upstreamimpl/sim_yd_mock.go +++ b/internal/logic/upstreamimpl/sim_yd_mock.go @@ -4,12 +4,12 @@ import ( "context" "encoding/json" "fmt" - "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" "strings" "sync/atomic" "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/os/gtime" @@ -195,16 +195,16 @@ func (s *sSimYDMock) getTransId(ctx context.Context) string { } // 单卡基本信息查询 -// http://api.iot.10086.cn/v5/ec/query/sim-basic-info?transid=YOUR_TRANSID&token=YOUR_TOKEN&iccid=898604F4152391115474 +// http://api.iot.10086.cn/v5/ec/query/sim-basic-info?transid=REPLACE_WITH_VALUE&token=REPLACE_WITH_VALUE&iccid=89860000000000000004 func (s *sSimYDMock) QuerySimBasicInfo(ctx context.Context, input *model.SimBasicInfoInput) (model.SimBasicInfoOutput, error) { // { // "status": "0", // "message": "正确", // "result": [ // { - // "msisdn": "1441543605474", - // "imsi": "460087436015474", - // "iccid": "898604F4152391115474", + // "msisdn": "1440000000002", + // "imsi": "460000000000002", + // "iccid": "89860000000000000004", // "activeDate": "2023-05-11 15:07:06", // 激活日期 // "openDate": "2023-05-11 14:53:24", // 开卡日期 // "remark": "" @@ -487,7 +487,7 @@ func (s *sSimYDMock) QuerySimSession(ctx context.Context, input *model.SimSessio } // 5.1.6 单卡状态查询 -// https://api.iot.10086.cn/v5/ec/query/sim-status?transid=YOUR_TRANSID&token=YOUR_TOKEN&iccid=898604F4152391115474 +// https://api.iot.10086.cn/v5/ec/query/sim-status?transid=REPLACE_WITH_VALUE&token=REPLACE_WITH_VALUE&iccid=89860000000000000004 func (s *sSimYDMock) QuerySimCardStatus(ctx context.Context, input *model.SimCardStatusInput) (model.SimCardStatusOutput, error) { url := s.getUrl(ctx, "ec/query/sim-status") @@ -592,7 +592,7 @@ func (s *sSimYDMock) QuerySimCardStatus(ctx context.Context, input *model.SimCar } // 物联网卡IMEI查询 -// https://api.iot.10086.cn/v5/ec/query/sim-imei?transid=371AIOT20210605101712810732026031520280010000000&token=YOUR_TOKEN&iccid=898604F81623D2249725 +// https://api.iot.10086.cn/v5/ec/query/sim-imei?transid=REPLACE_WITH_VALUE&token=REPLACE_WITH_VALUE&iccid=89860000000000000005 func (s *sSimYDMock) QuerySimStopReason(ctx context.Context, input *model.SimStopReasonInput) (model.SimStopReasonOutput, error) { url := s.getUrl(ctx, "ec/query/sim-stop-reason") @@ -855,7 +855,7 @@ func (s *sSimYDMock) QuerySimImei(ctx context.Context, input *model.SimImeiInput } // 单卡本月流量累计使用量查询 -// https://api.iot.10086.cn/v5/ec/query/sim-data-usage-inpool?transid=371AIOT20210605101712810732026032616060010000000&token=YOUR_TOKEN&msisdn=1441589159774 +// https://api.iot.10086.cn/v5/ec/query/sim-data-usage-inpool?transid=REPLACE_WITH_VALUE&token=REPLACE_WITH_VALUE&msisdn=1440000000003 func (s *sSimYDMock) QuerySimDataUsageInPool(ctx context.Context, input *model.SimDataUsageInPoolInput) (model.SimDataUsageInPoolOutput, error) { url := s.getUrl(ctx, "ec/query/sim-data-usage-inpool") // {"status":"0","message":"正确","result":[{"apnList":[{"apnName":"CMIOT","apnUseAmount":"5994479616"}]}]} @@ -949,7 +949,7 @@ func (s *sSimYDMock) QuerySimDataUsageInPool(ctx context.Context, input *model.S } // 单卡本月流量累计使用量查询 -// https://api.iot.10086.cn/v5/ec/query/sim-data-usage?transid=371AIOT20210605101712810732026032616060010000000&token=YOUR_TOKEN&msisdn=1441589159774 +// https://api.iot.10086.cn/v5/ec/query/sim-data-usage?transid=REPLACE_WITH_VALUE&token=REPLACE_WITH_VALUE&msisdn=1440000000003 func (s *sSimYDMock) QuerySimDataUsage(ctx context.Context, input *model.SimDataUsageInput) (model.SimDataUsageOutput, error) { url := s.getUrl(ctx, "ec/query/sim-data-usage") // {"status":"0","message":"正确","result":[{"dataAmount":"710569.00","apnUseAmountList":[{"apnName":"CMIOT","apnUseAmount":"710569.00","pccCodeUseAmountList":[]}]}]} @@ -1293,7 +1293,7 @@ func (s *sSimYDMock) QuerySimDataUsageDaily(ctx context.Context, input *model.Si } // 单卡已开通APN信息查询 -// https://api.iot.10086.cn/v5/ec/query/apn-info?transid=C5010531Af52022004082026060114500001000000&token=YOUR_TOKEN&iccid=898604F4152391115473 +// https://api.iot.10086.cn/v5/ec/query/apn-info?transid=REPLACE_WITH_VALUE&token=REPLACE_WITH_VALUE&iccid=89860000000000000003 func (s *sSimYDMock) QuerySimApnInfo(ctx context.Context, input *model.SimApnInfoInput) (model.SimApnInfoOutput, error) { url := s.getUrl(ctx, "ec/query/apn-info") // {"status":"0","message":"正确","result":[{"apnList":[{"apnName":"CMIOT","status":"1"}]}]} @@ -1413,7 +1413,7 @@ func (s *sSimYDMock) QuerySimApnInfo(ctx context.Context, input *model.SimApnInf // 单卡状态变更 // 平台 operType: 0=申请停机(已激活转已停机), 1=申请复机(已停机转已激活) -// https://api.iot.10086.cn/v5/ec/change/sim-status?transid=371AIOT20210605101712810732026031315120010000000&token=YOUR_TOKEN&msisdn=1441589159774&operType=0 +// https://api.iot.10086.cn/v5/ec/change/sim-status?transid=REPLACE_WITH_VALUE&token=REPLACE_WITH_VALUE&msisdn=1440000000003&operType=0 func (s *sSimYDMock) ChangeSimStatus(ctx context.Context, input *model.SimCardStatusChangeInput) (model.SimCardStatusChangeOutput, error) { operType, ok := normalizeCardStatusChangeOperType(ctx, input.ReqData.OperType) if !ok { @@ -1421,7 +1421,7 @@ func (s *sSimYDMock) ChangeSimStatus(ctx context.Context, input *model.SimCardSt } url := s.getUrl(ctx, "ec/change/sim-status") - // {"status":"0","message":"正确","result":[{"iccid":"898604F4152391115474","orderNum":"995000011515720993"}]} + // {"status":"0","message":"正确","result":[{"iccid":"89860000000000000004","orderNum":"995000011515720993"}]} type YdResp struct { Status string `json:"status"` Message string `json:"message"` @@ -1621,7 +1621,7 @@ func (s *sSimYDMock) QuerySimCardBindStatus(ctx context.Context, input *model.Si } // 单卡设备状态查询 -// https://api.iot.10086.cn/v5/ec/query/sim-operating-status?transid=xxx&token=xxx&iccid=xxx +// https://api.iot.10086.cn/v5/ec/query/sim-operating-status?transid=REPLACE_WITH_VALUE&token=REPLACE_WITH_VALUE&iccid=xxx // 上游返回 result 中的 operatingStatus/onOffStatus/status 作为设备状态 func (s *sSimYDMock) QuerySimDeviceStatus(ctx context.Context, input *model.SimDeviceStatusInput) (model.SimDeviceStatusOutput, error) { url := s.getUrl(ctx, "ec/query/sim-operating-status") diff --git a/internal/logic/upstreamimpl/sim_yd_token.go b/internal/logic/upstreamimpl/sim_yd_token.go index fe0ed06..4ae376b 100644 --- a/internal/logic/upstreamimpl/sim_yd_token.go +++ b/internal/logic/upstreamimpl/sim_yd_token.go @@ -8,8 +8,8 @@ import ( "sync" "time" - "git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/model/entity" "github.com/gogf/gf/v2/frame/g" + "git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/model/entity" ) const ydTokenInvalidCode = "12021" diff --git a/internal/logic/upstreamimpl/yunze_token_provider_test.go b/internal/logic/upstreamimpl/yunze_token_provider_test.go index 9121713..bea1282 100644 --- a/internal/logic/upstreamimpl/yunze_token_provider_test.go +++ b/internal/logic/upstreamimpl/yunze_token_provider_test.go @@ -140,7 +140,7 @@ func TestParseSharedOneLinkConfigBusinessURL(t *testing.T) { 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"}}`, + `{"token":{"provider":"yunze","baseUrl":"http://apiv3.example.com","path":"/openapi/v3/query/operator-token"}}`, )) if err != nil { t.Fatalf("parse config: %v", err) @@ -156,7 +156,7 @@ func TestParseSharedOneLinkConfigBusinessURL(t *testing.T) { 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"}}`, + `{"api":{"baseUrl":"`+custom+`"},"token":{"provider":"yunze","baseUrl":"http://apiv3.example.com","path":"/openapi/v3/query/operator-token"}}`, )) if err != nil { t.Fatalf("parse config: %v", err) @@ -182,11 +182,11 @@ func TestYunzeTemplateRegistration(t *testing.T) { Template: &entity.TSimChannelTemplate{ TempKey: sharedOneLinkYunzeTemplateKey, TempModel: templateModelSharedToken, - TempConfig: `{"token":{"provider":"yunze","baseUrl":"http://apiv3.example.com","path":"/route/open/sharedToken"}}`, + TempConfig: `{"token":{"provider":"yunze","baseUrl":"http://apiv3.example.com","path":"/openapi/v3/query/operator-token"}}`, }, Channel: &entity.TSimChannels{ ChanKey: "249", - AppKey: "TEST_APP_KEY_0001", + AppKey: "YZTKB3FT1SJR8652C4WP9VM", ChanConfig: "{}", }, } diff --git a/internal/model/sim_chan_api.go b/internal/model/sim_chan_api.go index 15e58bb..8d11f3e 100644 --- a/internal/model/sim_chan_api.go +++ b/internal/model/sim_chan_api.go @@ -254,17 +254,32 @@ type SimCardStatusChangeItem struct { type SimCardOperateChangeApnInput = SimBaseReq[SimCardOperateChangeApnInputItem] // 单卡 APN功能开停 请求参数 操作类型 +// ApnOperationCheckpointKey 传递受理即落库回调,避免等待同请求内其他单卡办理。 +type ApnOperationCheckpointKey struct{} + type SimCardOperateChangeApnInputItem struct { - OperType string `json:"operType" dc:"APN功能开停操作类型(0:开 1:停)"` - ApnName string `json:"apnName" dc:"APN名称"` + OperType string `json:"operType" dc:"APN功能开停操作类型(0:开 1:停)"` + ApnName string `json:"apnName" dc:"APN名称"` + SubmitOnly bool `json:"submitOnly,omitempty"` + QueryJobID string `json:"queryJobId,omitempty"` + OperationID string `json:"operationId,omitempty"` + RecoverOnly bool `json:"recoverOnly,omitempty"` } // 单卡 APN功能开停 响应参数 type SimCardOperateChangeApnOutput = []*SimBaseRes[SimCardOperateChangeApnItem] // 单卡 APN功能开停 响应参数 数据项 +type SimApnCapabilityItem struct { + ProtocolVersion int `json:"protocolVersion"` +} + +type SimApnCapabilityOutput = []*SimBaseRes[SimApnCapabilityItem] + type SimCardOperateChangeApnItem struct { - OrderNum string `json:"orderNum" dc:"订单号"` + OrderNum string `json:"orderNum" dc:"订单号"` + Pending bool `json:"pending,omitempty"` + Uncertain bool `json:"uncertain,omitempty"` } // 物联网卡绑定状态查询 请求参数 diff --git a/internal/pub/pkg/version/version.go b/internal/pub/pkg/version/version.go index 9951863..832d581 100644 --- a/internal/pub/pkg/version/version.go +++ b/internal/pub/pkg/version/version.go @@ -1,7 +1,7 @@ // package version // @Link https://github.com/xiujiecn/xiu-admin // @Copyright Copyright (c) 2025 LiXiujie -// @Author Lxj +// @Author Lxj // @License https://github.com/xiujiecn/xiu-admin/blob/master/LICENSE package version diff --git a/internal/pub/xjpubconsts/http.go b/internal/pub/xjpubconsts/http.go index 8d1ddac..99b49a3 100644 --- a/internal/pub/xjpubconsts/http.go +++ b/internal/pub/xjpubconsts/http.go @@ -1,7 +1,7 @@ // Package xjpubconsts // @Link https://github.com/xiujiecn/xiu-admin // @Copyright Copyright (c) 2025 LiXiujie -// @Author Lxj +// @Author Lxj // @License https://github.com/xiujiecn/xiu-admin/blob/master/LICENSE package xjpubconsts diff --git a/internal/pub/xjpublogic/middleware/middleware.go b/internal/pub/xjpublogic/middleware/middleware.go index 06425f8..fbb6122 100644 --- a/internal/pub/xjpublogic/middleware/middleware.go +++ b/internal/pub/xjpublogic/middleware/middleware.go @@ -1,7 +1,7 @@ // package middleware // @Link https://github.com/xiujiecn/xiu-admin // @Copyright Copyright (c) 2025 LiXiujie -// @Author Lxj +// @Author Lxj // @License https://github.com/xiujiecn/xiu-admin/blob/master/LICENSE package middleware diff --git a/internal/pub/xjpubmodel/response/common.go b/internal/pub/xjpubmodel/response/common.go index 9f9000a..472fd7a 100644 --- a/internal/pub/xjpubmodel/response/common.go +++ b/internal/pub/xjpubmodel/response/common.go @@ -1,7 +1,7 @@ // package response // @Link https://github.com/xiujiecn/xiu-admin // @Copyright Copyright (c) 2025 LiXiujie -// @Author Lxj +// @Author Lxj // @License https://github.com/xiujiecn/xiu-admin/blob/master/LICENSE package response diff --git a/internal/pub/xjpubmodel/response/response.go b/internal/pub/xjpubmodel/response/response.go index 5f38047..8bb5888 100644 --- a/internal/pub/xjpubmodel/response/response.go +++ b/internal/pub/xjpubmodel/response/response.go @@ -1,7 +1,7 @@ // package response // @Link https://github.com/xiujiecn/xiu-admin // @Copyright Copyright (c) 2025 LiXiujie -// @Author Lxj +// @Author Lxj // @License https://github.com/xiujiecn/xiu-admin/blob/master/LICENSE package response diff --git a/internal/service/mingqian/adapter.go b/internal/service/mingqian/adapter.go index 9c1591a..8d31ba6 100644 --- a/internal/service/mingqian/adapter.go +++ b/internal/service/mingqian/adapter.go @@ -8,9 +8,9 @@ import ( "strings" "time" + "github.com/gogf/gf/v2/errors/gerror" "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" ) const TemplateKey = "sim_mingqian_v3" diff --git a/internal/service/mingqian/mingqian_test.go b/internal/service/mingqian/mingqian_test.go index 69f968b..a36ec01 100644 --- a/internal/service/mingqian/mingqian_test.go +++ b/internal/service/mingqian/mingqian_test.go @@ -4,8 +4,6 @@ import ( "context" "encoding/json" "fmt" - "git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/model" - "git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/model/entity" "net/http" "net/http/httptest" "regexp" @@ -13,6 +11,8 @@ import ( "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 testAdapter(t *testing.T, handler http.HandlerFunc) *Adapter { @@ -165,13 +165,13 @@ func TestOperationsAreSafe(t *testing.T) { }) input := &model.SimCardStatusChangeInput{SimKeys: []*model.SimKey{{ICCID: "A"}, {ICCID: "B"}}, ReqData: model.SimCardStatusChangeInputItem{OperType: "0"}} result, _ := adapter.ChangeSimStatus(context.Background(), input) - if calls != 1 || result[0].Code != 0 || result[1].Code == 0 { - t.Fatal("stop operation must reach the confirmed endpoint and inspect per-card success flags") + if calls != 0 || result[0].Code == 0 { + t.Fatal("operations must be disabled by default") } - before := calls + adapter.operations = true input.ReqData.OperType = "1" result, _ = adapter.ChangeSimStatus(context.Background(), input) - if calls != before || result[0].Code == 0 { + if calls != 0 || result[0].Code == 0 { t.Fatal("unconfirmed resume path must not send any request") } adapter.resumePath = "/simlist/test-resume" @@ -182,7 +182,7 @@ func TestOperationsAreSafe(t *testing.T) { t.Fatal("must inspect per-card success flags") } } - before = calls + before := calls input.ReqData.OperType = "invalid" adapter.ChangeSimStatus(context.Background(), input) if calls != before { @@ -206,7 +206,7 @@ func TestResponseFailuresAndNoRetry(t *testing.T) { func TestConfiguration(t *testing.T) { cfg := &model.SimChannelConfigItemCache{Template: &entity.TSimChannelTemplate{TempConfig: `{"api":{"timeout":"1s"}}`}, Channel: &entity.TSimChannels{AppKey: "app", AppSecret: "secret"}} adapter, err := New(cfg) - if err != nil || adapter.resumePath != "" { + if err != nil || adapter.operations { t.Fatalf("invalid default configuration: %v", err) } cfg.Channel.AppSecret = "" @@ -243,6 +243,7 @@ func TestOperationBatchLimitAndHTTPFailure(t *testing.T) { } writer.WriteHeader(http.StatusTooManyRequests) }) + adapter.operations = true keys := make([]*model.SimKey, 21) for index := range keys { keys[index] = &model.SimKey{ICCID: strconv.Itoa(index)} diff --git a/internal/service/mingqian/package_flow.go b/internal/service/mingqian/package_flow.go index a627105..fbb5e03 100644 --- a/internal/service/mingqian/package_flow.go +++ b/internal/service/mingqian/package_flow.go @@ -5,9 +5,9 @@ import ( "math" "strings" + "github.com/gogf/gf/v2/errors/gerror" "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" ) type PackageFlowOutput = []*model.SimBaseRes[model.SimPackageFlowItem] diff --git a/internal/service/upstreamapi.go b/internal/service/upstreamapi.go index 90cd0c4..9ea7688 100644 --- a/internal/service/upstreamapi.go +++ b/internal/service/upstreamapi.go @@ -54,6 +54,7 @@ type ( QuerySimApnInfo(ctx context.Context, input *model.SimApnInfoInput) (output model.SimApnInfoOutput, err error) // 物联网卡状态变更 ChangeSimStatus(ctx context.Context, input *model.SimCardStatusChangeInput) (output model.SimCardStatusChangeOutput, err error) + QuerySimApnCapability(ctx context.Context, input *model.SimCardOperateChangeApnInput) (model.SimApnCapabilityOutput, error) ChangeSimCardOperateApn(ctx context.Context, input *model.SimCardOperateChangeApnInput) (output model.SimCardOperateChangeApnOutput, err error) } ) diff --git a/internal/token/token.go b/internal/token/token.go index d3c4bc7..99c56a9 100644 --- a/internal/token/token.go +++ b/internal/token/token.go @@ -11,12 +11,12 @@ import ( "sync" "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" "github.com/gogf/gf/v2/frame/g" "github.com/gogf/gf/v2/os/gtime" "golang.org/x/sync/singleflight" + "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" ) var ( diff --git a/main.go b/main.go index d211e3d..87bba67 100644 --- a/main.go +++ b/main.go @@ -1,8 +1,8 @@ package main import ( - _ "git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/packed" "os" + _ "git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/packed" _ "git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/pub/xjpublogic" _ "github.com/gogf/gf/contrib/drivers/mysql/v2"