From 96b1edff4504dfe6fd06698da02e2b0497cdb8b3 Mon Sep 17 00:00:00 2001 From: MiFaZhan Date: Thu, 22 Oct 2026 15:42:29 +0800 Subject: [PATCH] =?UTF-8?q?chore:=20=E5=88=9D=E5=A7=8B=E5=BC=80=E6=BA=90?= =?UTF-8?q?=E5=8F=91=E5=B8=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit iotlcm-upstream-chan:运营商上游通道适配服务(统一 /sim/* 协议)。 - 内联原私有依赖:yunze-public → internal/pub/(10 包 18 文件), yunze-pb/pb → internal/pb/(9 文件);Consul 注册改用官方 gogf/gf/contrib/registry/consul/v2(API 兼容) - Go module:yz-svr → git.5iot.com/iotlcm-public/iotlcm-upstream-chan - 修复 mingqian 包测试:实现重构后 `adapter.operations` 字段已移除, 测试断言按当前实现语义重写(不改产品行为) - 凭据与内部标识脱敏:MySQL/Redis/Consul 凭据、私网与生产 IP、 内网域名、真实手机号、注释里的真实 token/transid - 移除内部资产:CI 触发器、生产通道种子 SQL、真实凭据测试资源文档 - Dockerfile 改用公共基础镜像,移除写死的 registry 凭据 - 补 Apache-2.0 LICENSE,重写 README,LF 行尾 + .gitattributes --- .gitattributes | 13 + .gitignore | 28 + LICENSE | 201 ++ Makefile | 7 + api/cardquery/cardquery.go | 33 + api/cardquery/v1/mingqian_package_flow.go | 13 + api/cardquery/v1/simcard.go | 164 ++ build/Dockerfile | 34 + docs/apika-shared-token-integration.md | 25 + docs/config/mingqian_channel.json | 1 + docs/config/mingqian_v3.json | 8 + docs/config/onelink_apika_v1.json | 11 + docs/config/onelink_lelian_v1.json | 10 + docs/config/onelink_mobile_public_v1.json | 7 + docs/config/onelink_tengyu_v1.json | 7 + docs/config/onelink_xuzhou_mobile_v1.json | 10 + docs/config/sim_dx_v3.json | 9 + docs/mingqian-integration.md | 214 ++ docs/mobile-public-token-integration.md | 24 + docs/plans/channel-template-model-design.md | 119 ++ .../telecom-v3-integration-and-switch.md | 130 ++ docs/scripts/query_sim_card_status.sh | 40 + docs/scripts/query_sim_session.sh | 41 + docs/telecom-v3-apn-integration.md | 65 + go.mod | 72 + go.sum | 342 +++ hack/hack-cli.mk | 20 + hack/hack.mk | 99 + internal/cmd/cmd.go | 232 ++ internal/cmd/http_test.go | 68 + internal/cmd/shared_token_gate_test.go | 84 + internal/consts/consts.go | 28 + internal/controller/cardquery/cardquery.go | 5 + .../controller/cardquery/cardquery_new.go | 15 + .../cardquery/cardquery_v1_simcard.go | 160 ++ .../cardquery/mingqian_package_flow.go | 21 + internal/controller/infopb/info.go | 35 + internal/controller/msgcallpb/msgcallpb.go | 47 + internal/dao/internal/t_api.go | 101 + internal/dao/internal/t_customer.go | 117 ++ internal/dao/internal/t_sim_agent_package.go | 115 + internal/dao/internal/t_sim_agent_packet.go | 105 + internal/dao/internal/t_sim_card_info.go | 193 ++ .../dao/internal/t_sim_channel_template.go | 101 + internal/dao/internal/t_sim_channels.go | 117 ++ internal/dao/t_api.go | 22 + internal/dao/t_customer.go | 22 + internal/dao/t_sim_agent_package.go | 22 + internal/dao/t_sim_agent_packet.go | 22 + internal/dao/t_sim_card_info.go | 22 + internal/dao/t_sim_channel_template.go | 22 + internal/dao/t_sim_channels.go | 22 + .../logic/channelconfig/channel_config.go | 298 +++ .../channelconfig/channel_config_test.go | 37 + .../logic/channelconfig/channel_update.go | 51 + internal/logic/logic.go | 11 + .../logic/upstreamapi/channel_cache_test.go | 17 + .../logic/upstreamapi/channel_test_runtime.go | 251 +++ .../upstreamapi/channel_test_runtime_test.go | 163 ++ internal/logic/upstreamapi/history_period.go | 58 + .../logic/upstreamapi/history_period_test.go | 294 +++ .../logic/upstreamapi/normal_gate_test.go | 158 ++ internal/logic/upstreamapi/stale_loop_test.go | 53 + internal/logic/upstreamapi/upstreamapi.go | 1313 ++++++++++++ .../logic/upstreamapi/upstreamapi_batch.go | 191 ++ .../logic/upstreamimpl/apika_template_test.go | 33 + .../upstreamimpl/apika_token_provider.go | 130 ++ .../upstreamimpl/apika_token_provider_test.go | 52 + .../upstreamimpl/beixin_token_provider.go | 149 ++ .../beixin_token_provider_test.go | 335 +++ .../upstreamimpl/daihe_token_provider.go | 60 + internal/logic/upstreamimpl/dx/client.go | 156 ++ internal/logic/upstreamimpl/dx/sign.go | 69 + internal/logic/upstreamimpl/dx/sign_test.go | 15 + .../henan_lelian_token_provider.go | 256 +++ .../upstreamimpl/henan_xinjing_client.go | 134 ++ .../henan_xinjing_token_provider.go | 224 ++ internal/logic/upstreamimpl/http_config.go | 101 + .../upstreamimpl/lanshang_token_provider.go | 112 + .../lanshang_token_provider_test.go | 55 + .../upstreamimpl/lanshu_token_provider.go | 184 ++ .../lanshu_token_provider_test.go | 336 +++ .../mingqian_registration_test.go | 21 + .../mobile_public_token_provider.go | 145 ++ .../mobile_public_token_provider_test.go | 112 + .../upstreamimpl/qianxun_token_provider.go | 116 + .../qianxun_token_provider_test.go | 50 + .../logic/upstreamimpl/recharge/client.go | 177 ++ .../upstreamimpl/recharge/client_test.go | 33 + internal/logic/upstreamimpl/sim_daihe.go | 265 +++ .../logic/upstreamimpl/sim_data_diagnosis.go | 102 + .../logic/upstreamimpl/sim_diag_helpers.go | 288 +++ .../upstreamimpl/sim_diag_mapping_test.go | 104 + internal/logic/upstreamimpl/sim_dx.go | 1794 ++++++++++++++++ internal/logic/upstreamimpl/sim_dx_apn.go | 216 ++ .../logic/upstreamimpl/sim_dx_apn_test.go | 200 ++ internal/logic/upstreamimpl/sim_dx_batch.go | 641 ++++++ .../upstreamimpl/sim_dx_network_block.go | 36 + .../upstreamimpl/sim_dx_network_block_test.go | 68 + .../logic/upstreamimpl/sim_dx_status_test.go | 136 ++ .../logic/upstreamimpl/sim_dx_usage_test.go | 205 ++ internal/logic/upstreamimpl/sim_dx_v3.go | 568 +++++ .../logic/upstreamimpl/sim_dx_v3_batch.go | 148 ++ internal/logic/upstreamimpl/sim_dx_v3_test.go | 468 +++++ internal/logic/upstreamimpl/sim_enum.go | 179 ++ .../upstreamimpl/sim_henan_xinjing_direct.go | 521 +++++ internal/logic/upstreamimpl/sim_mingqian.go | 22 + .../logic/upstreamimpl/sim_onelink_shared.go | 615 ++++++ internal/logic/upstreamimpl/sim_qianxun.go | 880 ++++++++ internal/logic/upstreamimpl/sim_recharge.go | 519 +++++ .../upstreamimpl/sim_recharge_cache_test.go | 46 + internal/logic/upstreamimpl/sim_tibiot.go | 1111 ++++++++++ .../sim_tibiot_rate_limit_test.go | 94 + internal/logic/upstreamimpl/sim_yd.go | 1871 +++++++++++++++++ .../upstreamimpl/sim_yd_apn_communication.go | 381 ++++ .../sim_yd_apn_communication_test.go | 366 ++++ internal/logic/upstreamimpl/sim_yd_mock.go | 1788 ++++++++++++++++ internal/logic/upstreamimpl/sim_yd_token.go | 224 ++ internal/logic/upstreamimpl/sim_yunda.go | 495 +++++ internal/logic/upstreamimpl/sim_yunda_test.go | 64 + .../logic/upstreamimpl/template_models.go | 26 + .../upstreamimpl/tengyu_token_provider.go | 164 ++ .../logic/upstreamimpl/tibiot_rate_limiter.go | 68 + .../xuzhou_mobile_token_provider.go | 153 ++ internal/logic/upstreamimpl/yunda/client.go | 104 + internal/logic/upstreamimpl/yunda/sign.go | 29 + .../upstreamimpl/yunze_token_provider.go | 199 ++ .../upstreamimpl/yunze_token_provider_test.go | 196 ++ .../logic/upstreamimpl/zibo_token_provider.go | 145 ++ internal/model/channel_test_models.go | 58 + internal/model/do/t_api.go | 27 + internal/model/do/t_customer.go | 35 + internal/model/do/t_sim_agent_package.go | 34 + internal/model/do/t_sim_agent_packet.go | 29 + internal/model/do/t_sim_card_info.go | 73 + internal/model/do/t_sim_channel_template.go | 27 + internal/model/do/t_sim_channels.go | 35 + internal/model/entity/t_api.go | 25 + internal/model/entity/t_customer.go | 33 + internal/model/entity/t_sim_agent_package.go | 32 + internal/model/entity/t_sim_agent_packet.go | 27 + internal/model/entity/t_sim_card_info.go | 71 + .../model/entity/t_sim_channel_template.go | 25 + internal/model/entity/t_sim_channels.go | 33 + internal/model/recharge.go | 13 + internal/model/sim_card_channel.go | 68 + internal/model/sim_chan_api.go | 309 +++ internal/model/sim_package_flow.go | 7 + internal/packed/packed.go | 1 + internal/pb/common.pb.go | 206 ++ internal/pb/info.pb.go | 257 +++ internal/pb/info_grpc.pb.go | 122 ++ internal/pb/msgbus.pb.go | 632 ++++++ internal/pb/msgbus_grpc.pb.go | 155 ++ internal/pb/msgcall.pb.go | 237 +++ internal/pb/msgcall_grpc.pb.go | 155 ++ internal/pb/notice.pb.go | 197 ++ internal/pb/notice_grpc.pb.go | 122 ++ internal/pub/pkg/initconsul/init_consul.go | 32 + internal/pub/pkg/version/version.go | 51 + internal/pub/xjpubconsts/http.go | 14 + internal/pub/xjpubconsts/services.go | 17 + .../pub/xjpublogic/httpclient/httpclient.go | 100 + .../httpclient/upstream_proxy_client.go | 37 + internal/pub/xjpublogic/info/info.go | 68 + internal/pub/xjpublogic/info/inforpc.go | 127 ++ .../pub/xjpublogic/middleware/middleware.go | 118 ++ internal/pub/xjpublogic/xjpublogic.go | 11 + internal/pub/xjpubmodel/info.go | 18 + internal/pub/xjpubmodel/response/common.go | 12 + internal/pub/xjpubmodel/response/response.go | 67 + internal/pub/xjpubmodel/sim_chan_api.go | 48 + internal/pub/xjpubmodel/xj_metric.go | 91 + internal/pub/xjpubservice/httpclient.go | 53 + internal/pub/xjpubservice/info.go | 60 + internal/pub/xjpubservice/middleware.go | 40 + internal/service/channelconfig.go | 56 + internal/service/daihe/client.go | 169 ++ internal/service/mingqian/adapter.go | 273 +++ internal/service/mingqian/client.go | 172 ++ internal/service/mingqian/flow_test.go | 159 ++ internal/service/mingqian/mingqian_test.go | 294 +++ internal/service/mingqian/package_flow.go | 82 + internal/service/mingqian/unsupported.go | 43 + internal/service/upstreamapi.go | 74 + internal/service/upstreamimpl.go | 112 + internal/token/token.go | 338 +++ internal/token/token_test.go | 64 + main.go | 42 + manifest/docker/Dockerfile | 16 + manifest/docker/docker.sh | 8 + 191 files changed, 30805 insertions(+) create mode 100644 .gitattributes create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 Makefile create mode 100644 api/cardquery/cardquery.go create mode 100644 api/cardquery/v1/mingqian_package_flow.go create mode 100644 api/cardquery/v1/simcard.go create mode 100644 build/Dockerfile create mode 100644 docs/apika-shared-token-integration.md create mode 100644 docs/config/mingqian_channel.json create mode 100644 docs/config/mingqian_v3.json create mode 100644 docs/config/onelink_apika_v1.json create mode 100644 docs/config/onelink_lelian_v1.json create mode 100644 docs/config/onelink_mobile_public_v1.json create mode 100644 docs/config/onelink_tengyu_v1.json create mode 100644 docs/config/onelink_xuzhou_mobile_v1.json create mode 100644 docs/config/sim_dx_v3.json create mode 100644 docs/mingqian-integration.md create mode 100644 docs/mobile-public-token-integration.md create mode 100644 docs/plans/channel-template-model-design.md create mode 100644 docs/plans/telecom-v3-integration-and-switch.md create mode 100644 docs/scripts/query_sim_card_status.sh create mode 100644 docs/scripts/query_sim_session.sh create mode 100644 docs/telecom-v3-apn-integration.md create mode 100644 go.mod create mode 100644 go.sum create mode 100644 hack/hack-cli.mk create mode 100644 hack/hack.mk create mode 100644 internal/cmd/cmd.go create mode 100644 internal/cmd/http_test.go create mode 100644 internal/cmd/shared_token_gate_test.go create mode 100644 internal/consts/consts.go create mode 100644 internal/controller/cardquery/cardquery.go create mode 100644 internal/controller/cardquery/cardquery_new.go create mode 100644 internal/controller/cardquery/cardquery_v1_simcard.go create mode 100644 internal/controller/cardquery/mingqian_package_flow.go create mode 100644 internal/controller/infopb/info.go create mode 100644 internal/controller/msgcallpb/msgcallpb.go create mode 100644 internal/dao/internal/t_api.go create mode 100644 internal/dao/internal/t_customer.go create mode 100644 internal/dao/internal/t_sim_agent_package.go create mode 100644 internal/dao/internal/t_sim_agent_packet.go create mode 100644 internal/dao/internal/t_sim_card_info.go create mode 100644 internal/dao/internal/t_sim_channel_template.go create mode 100644 internal/dao/internal/t_sim_channels.go create mode 100644 internal/dao/t_api.go create mode 100644 internal/dao/t_customer.go create mode 100644 internal/dao/t_sim_agent_package.go create mode 100644 internal/dao/t_sim_agent_packet.go create mode 100644 internal/dao/t_sim_card_info.go create mode 100644 internal/dao/t_sim_channel_template.go create mode 100644 internal/dao/t_sim_channels.go create mode 100644 internal/logic/channelconfig/channel_config.go create mode 100644 internal/logic/channelconfig/channel_config_test.go create mode 100644 internal/logic/channelconfig/channel_update.go create mode 100644 internal/logic/logic.go create mode 100644 internal/logic/upstreamapi/channel_cache_test.go create mode 100644 internal/logic/upstreamapi/channel_test_runtime.go create mode 100644 internal/logic/upstreamapi/channel_test_runtime_test.go create mode 100644 internal/logic/upstreamapi/history_period.go create mode 100644 internal/logic/upstreamapi/history_period_test.go create mode 100644 internal/logic/upstreamapi/normal_gate_test.go create mode 100644 internal/logic/upstreamapi/stale_loop_test.go create mode 100644 internal/logic/upstreamapi/upstreamapi.go create mode 100644 internal/logic/upstreamapi/upstreamapi_batch.go create mode 100644 internal/logic/upstreamimpl/apika_template_test.go create mode 100644 internal/logic/upstreamimpl/apika_token_provider.go create mode 100644 internal/logic/upstreamimpl/apika_token_provider_test.go create mode 100644 internal/logic/upstreamimpl/beixin_token_provider.go create mode 100644 internal/logic/upstreamimpl/beixin_token_provider_test.go create mode 100644 internal/logic/upstreamimpl/daihe_token_provider.go create mode 100644 internal/logic/upstreamimpl/dx/client.go create mode 100644 internal/logic/upstreamimpl/dx/sign.go create mode 100644 internal/logic/upstreamimpl/dx/sign_test.go create mode 100644 internal/logic/upstreamimpl/henan_lelian_token_provider.go create mode 100644 internal/logic/upstreamimpl/henan_xinjing_client.go create mode 100644 internal/logic/upstreamimpl/henan_xinjing_token_provider.go create mode 100644 internal/logic/upstreamimpl/http_config.go create mode 100644 internal/logic/upstreamimpl/lanshang_token_provider.go create mode 100644 internal/logic/upstreamimpl/lanshang_token_provider_test.go create mode 100644 internal/logic/upstreamimpl/lanshu_token_provider.go create mode 100644 internal/logic/upstreamimpl/lanshu_token_provider_test.go create mode 100644 internal/logic/upstreamimpl/mingqian_registration_test.go create mode 100644 internal/logic/upstreamimpl/mobile_public_token_provider.go create mode 100644 internal/logic/upstreamimpl/mobile_public_token_provider_test.go create mode 100644 internal/logic/upstreamimpl/qianxun_token_provider.go create mode 100644 internal/logic/upstreamimpl/qianxun_token_provider_test.go create mode 100644 internal/logic/upstreamimpl/recharge/client.go create mode 100644 internal/logic/upstreamimpl/recharge/client_test.go create mode 100644 internal/logic/upstreamimpl/sim_daihe.go create mode 100644 internal/logic/upstreamimpl/sim_data_diagnosis.go create mode 100644 internal/logic/upstreamimpl/sim_diag_helpers.go create mode 100644 internal/logic/upstreamimpl/sim_diag_mapping_test.go create mode 100644 internal/logic/upstreamimpl/sim_dx.go create mode 100644 internal/logic/upstreamimpl/sim_dx_apn.go create mode 100644 internal/logic/upstreamimpl/sim_dx_apn_test.go create mode 100644 internal/logic/upstreamimpl/sim_dx_batch.go create mode 100644 internal/logic/upstreamimpl/sim_dx_network_block.go create mode 100644 internal/logic/upstreamimpl/sim_dx_network_block_test.go create mode 100644 internal/logic/upstreamimpl/sim_dx_status_test.go create mode 100644 internal/logic/upstreamimpl/sim_dx_usage_test.go create mode 100644 internal/logic/upstreamimpl/sim_dx_v3.go create mode 100644 internal/logic/upstreamimpl/sim_dx_v3_batch.go create mode 100644 internal/logic/upstreamimpl/sim_dx_v3_test.go create mode 100644 internal/logic/upstreamimpl/sim_enum.go create mode 100644 internal/logic/upstreamimpl/sim_henan_xinjing_direct.go create mode 100644 internal/logic/upstreamimpl/sim_mingqian.go create mode 100644 internal/logic/upstreamimpl/sim_onelink_shared.go create mode 100644 internal/logic/upstreamimpl/sim_qianxun.go create mode 100644 internal/logic/upstreamimpl/sim_recharge.go create mode 100644 internal/logic/upstreamimpl/sim_recharge_cache_test.go create mode 100644 internal/logic/upstreamimpl/sim_tibiot.go create mode 100644 internal/logic/upstreamimpl/sim_tibiot_rate_limit_test.go create mode 100644 internal/logic/upstreamimpl/sim_yd.go create mode 100644 internal/logic/upstreamimpl/sim_yd_apn_communication.go create mode 100644 internal/logic/upstreamimpl/sim_yd_apn_communication_test.go create mode 100644 internal/logic/upstreamimpl/sim_yd_mock.go create mode 100644 internal/logic/upstreamimpl/sim_yd_token.go create mode 100644 internal/logic/upstreamimpl/sim_yunda.go create mode 100644 internal/logic/upstreamimpl/sim_yunda_test.go create mode 100644 internal/logic/upstreamimpl/template_models.go create mode 100644 internal/logic/upstreamimpl/tengyu_token_provider.go create mode 100644 internal/logic/upstreamimpl/tibiot_rate_limiter.go create mode 100644 internal/logic/upstreamimpl/xuzhou_mobile_token_provider.go create mode 100644 internal/logic/upstreamimpl/yunda/client.go create mode 100644 internal/logic/upstreamimpl/yunda/sign.go create mode 100644 internal/logic/upstreamimpl/yunze_token_provider.go create mode 100644 internal/logic/upstreamimpl/yunze_token_provider_test.go create mode 100644 internal/logic/upstreamimpl/zibo_token_provider.go create mode 100644 internal/model/channel_test_models.go create mode 100644 internal/model/do/t_api.go create mode 100644 internal/model/do/t_customer.go create mode 100644 internal/model/do/t_sim_agent_package.go create mode 100644 internal/model/do/t_sim_agent_packet.go create mode 100644 internal/model/do/t_sim_card_info.go create mode 100644 internal/model/do/t_sim_channel_template.go create mode 100644 internal/model/do/t_sim_channels.go create mode 100644 internal/model/entity/t_api.go create mode 100644 internal/model/entity/t_customer.go create mode 100644 internal/model/entity/t_sim_agent_package.go create mode 100644 internal/model/entity/t_sim_agent_packet.go create mode 100644 internal/model/entity/t_sim_card_info.go create mode 100644 internal/model/entity/t_sim_channel_template.go create mode 100644 internal/model/entity/t_sim_channels.go create mode 100644 internal/model/recharge.go create mode 100644 internal/model/sim_card_channel.go create mode 100644 internal/model/sim_chan_api.go create mode 100644 internal/model/sim_package_flow.go create mode 100644 internal/packed/packed.go create mode 100644 internal/pb/common.pb.go create mode 100644 internal/pb/info.pb.go create mode 100644 internal/pb/info_grpc.pb.go create mode 100644 internal/pb/msgbus.pb.go create mode 100644 internal/pb/msgbus_grpc.pb.go create mode 100644 internal/pb/msgcall.pb.go create mode 100644 internal/pb/msgcall_grpc.pb.go create mode 100644 internal/pb/notice.pb.go create mode 100644 internal/pb/notice_grpc.pb.go create mode 100644 internal/pub/pkg/initconsul/init_consul.go create mode 100644 internal/pub/pkg/version/version.go create mode 100644 internal/pub/xjpubconsts/http.go create mode 100644 internal/pub/xjpubconsts/services.go create mode 100644 internal/pub/xjpublogic/httpclient/httpclient.go create mode 100644 internal/pub/xjpublogic/httpclient/upstream_proxy_client.go create mode 100644 internal/pub/xjpublogic/info/info.go create mode 100644 internal/pub/xjpublogic/info/inforpc.go create mode 100644 internal/pub/xjpublogic/middleware/middleware.go create mode 100644 internal/pub/xjpublogic/xjpublogic.go create mode 100644 internal/pub/xjpubmodel/info.go create mode 100644 internal/pub/xjpubmodel/response/common.go create mode 100644 internal/pub/xjpubmodel/response/response.go create mode 100644 internal/pub/xjpubmodel/sim_chan_api.go create mode 100644 internal/pub/xjpubmodel/xj_metric.go create mode 100644 internal/pub/xjpubservice/httpclient.go create mode 100644 internal/pub/xjpubservice/info.go create mode 100644 internal/pub/xjpubservice/middleware.go create mode 100644 internal/service/channelconfig.go create mode 100644 internal/service/daihe/client.go create mode 100644 internal/service/mingqian/adapter.go create mode 100644 internal/service/mingqian/client.go create mode 100644 internal/service/mingqian/flow_test.go create mode 100644 internal/service/mingqian/mingqian_test.go create mode 100644 internal/service/mingqian/package_flow.go create mode 100644 internal/service/mingqian/unsupported.go create mode 100644 internal/service/upstreamapi.go create mode 100644 internal/service/upstreamimpl.go create mode 100644 internal/token/token.go create mode 100644 internal/token/token_test.go create mode 100644 main.go create mode 100644 manifest/docker/Dockerfile create mode 100644 manifest/docker/docker.sh diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..5837541 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,13 @@ +# 统一使用 LF,避免跨平台协作时产生整文件 diff +* text=auto eol=lf + +# 二进制文件不做行尾转换 +*.png binary +*.jpg binary +*.gif binary +*.ico binary +*.woff binary +*.woff2 binary +*.ttf binary +*.pdf binary +*.exe binary diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..246f61b --- /dev/null +++ b/.gitignore @@ -0,0 +1,28 @@ +.buildpath +.hgignore.swp +.project +.orig +.swp +.idea/ +.settings/ +.vscode/ +*.yaml +config.yaml +bin/ +**/.DS_Store +gf +main +*.exe +output/ +manifest/output/ +temp/ +temp.yaml +bin +*.log +.keep-if-necessary +.gitkeep +# 屏蔽Go编译缓存 +**/.gocache*/ +.gocache/ +.gocache-admin/ +.playwright-cli/ \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..261eeb9 --- /dev/null +++ b/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..2a6e6e9 --- /dev/null +++ b/Makefile @@ -0,0 +1,7 @@ +ROOT_DIR = $(shell pwd) +NAMESPACE = "default" +DEPLOY_NAME = "template-single" +DOCKER_NAME = "template-single" + +include ./hack/hack-cli.mk +include ./hack/hack.mk \ No newline at end of file diff --git a/api/cardquery/cardquery.go b/api/cardquery/cardquery.go new file mode 100644 index 0000000..991465c --- /dev/null +++ b/api/cardquery/cardquery.go @@ -0,0 +1,33 @@ +// ================================================================================= +// Code generated and maintained by GoFrame CLI tool. DO NOT EDIT. +// ================================================================================= + +package cardquery + +import ( + "context" + + "git.5iot.com/iotlcm-public/iotlcm-upstream-chan/api/cardquery/v1" +) + +type ICardqueryV1 interface { + SimBasicInfo(ctx context.Context, req *v1.SimBasicInfoReq) (res *v1.SimBasicInfoRes, err error) + SimSession(ctx context.Context, req *v1.SimSessionReq) (res *v1.SimSessionRes, err error) + SimCardStatus(ctx context.Context, req *v1.SimCardStatusReq) (res *v1.SimCardStatusRes, err error) + SimStopReason(ctx context.Context, req *v1.SimStopReasonReq) (res *v1.SimStopReasonRes, err error) + SimFrozenState(ctx context.Context, req *v1.SimFrozenStateReq) (res *v1.SimFrozenStateRes, err error) + SimCardStatusBatch(ctx context.Context, req *v1.SimCardStatusBatchReq) (res *v1.SimCardStatusBatchRes, err error) + SimCardBindStatus(ctx context.Context, req *v1.SimCardBindStatusReq) (res *v1.SimCardBindStatusRes, err error) + SimDeviceStatus(ctx context.Context, req *v1.SimDeviceStatusReq) (res *v1.SimDeviceStatusRes, err error) + SimRegionLimitStatus(ctx context.Context, req *v1.SimRegionLimitStatusReq) (res *v1.SimRegionLimitStatusRes, err error) + SimImei(ctx context.Context, req *v1.SimImeiReq) (res *v1.SimImeiRes, err error) + SimDataUsage(ctx context.Context, req *v1.SimDataUsageReq) (res *v1.SimDataUsageRes, err error) + SimDataDiagnosis(ctx context.Context, req *v1.SimDataDiagnosisReq) (res *v1.SimDataDiagnosisRes, err error) + SimDataUsageMonthly(ctx context.Context, req *v1.SimDataUsageMonthlyReq) (res *v1.SimDataUsageMonthlyRes, err error) + SimDataUsageMonthlyBatch(ctx context.Context, req *v1.SimDataUsageMonthlyBatchReq) (res *v1.SimDataUsageMonthlyBatchRes, err error) + SimDataUsageDaily(ctx context.Context, req *v1.SimDataUsageDailyReq) (res *v1.SimDataUsageDailyRes, err error) + 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) + 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 new file mode 100644 index 0000000..cc06a5d --- /dev/null +++ b/api/cardquery/v1/mingqian_package_flow.go @@ -0,0 +1,13 @@ +package v1 + +import ( + "git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/model" + "github.com/gogf/gf/v2/frame/g" +) + +type MingqianPackageFlowReq struct { + g.Meta `path:"/sim/query/mingqian-package-flow" tags:"CardQuery" method:"POST" summary:"Mingqian package-period flow in MB (not current-month usage)"` + model.SimDataUsageInput +} + +type MingqianPackageFlowRes = []*model.SimBaseRes[model.SimPackageFlowItem] diff --git a/api/cardquery/v1/simcard.go b/api/cardquery/v1/simcard.go new file mode 100644 index 0000000..2e9dccf --- /dev/null +++ b/api/cardquery/v1/simcard.go @@ -0,0 +1,164 @@ +package v1 + +import ( + "git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/model" + + "github.com/gogf/gf/v2/frame/g" +) + +// 物联网卡基本信息查询 请求参数 +type SimBasicInfoReq struct { + g.Meta `path:"/sim/query/sim-basic-info" tags:"CardQuery" method:"GET,POST" summary:"物联网卡基本信息查询"` + model.SimBasicInfoInput +} + +// 物联网卡基本信息查询 响应参数 +type SimBasicInfoRes = model.SimBasicInfoOutput + +// 物联网卡在线信息实时查询 请求参数 +type SimSessionReq struct { + g.Meta `path:"/sim/query/sim-session" tags:"CardQuery" method:"GET,POST" summary:"物联网卡在线信息实时查询"` + model.SimSessionInput +} + +type SimSessionRes = model.SimSessionOutput + +// 物联网卡卡状态查询 请求参数 +type SimCardStatusReq struct { + g.Meta `path:"/sim/query/sim-status" tags:"CardQuery" method:"GET,POST" summary:"物联网卡卡状态查询"` + model.SimCardStatusInput +} + +type SimCardStatusRes = model.SimCardStatusOutput + +type SimStopReasonReq struct { + g.Meta `path:"/sim/query/sim-stop-reason" tags:"CardQuery" method:"GET,POST" summary:"SIM card stop reason query"` + model.SimStopReasonInput +} + +type SimStopReasonRes = model.SimStopReasonOutput + +type SimFrozenStateReq struct { + g.Meta `path:"/sim/query/sim-frozen-state" tags:"CardQuery" method:"GET,POST" summary:"SIM card frozen state query"` + model.SimFrozenStateInput +} + +type SimFrozenStateRes = model.SimFrozenStateOutput + +// 物联网卡批量状态查询 请求参数 +type SimCardStatusBatchReq struct { + g.Meta `path:"/sim/query/sim-status-batch" tags:"CardQuery" method:"GET,POST" summary:"电信-物联网卡批量状态查询"` + model.SimCardStatusInput +} + +type SimCardStatusBatchRes = model.SimCardStatusOutput + +// 物联网卡机卡绑定状态查询 请求参数 +type SimCardBindStatusReq struct { + g.Meta `path:"/sim/query/sim-card-bind-status" tags:"CardQuery" method:"GET,POST" summary:"物联网卡机卡绑定状态查询"` + model.SimCardBindStatusInput +} + +// 物联网卡机卡绑定状态查询 响应参数 +type SimCardBindStatusRes = model.SimCardBindStatusOutput + +// 物联网卡设备状态查询 请求参数 +type SimDeviceStatusReq struct { + g.Meta `path:"/sim/query/sim-device-status" tags:"CardQuery" method:"GET,POST" summary:"物联网卡设备状态查询"` + model.SimDeviceStatusInput +} + +// 物联网卡设备状态查询 响应参数 +type SimDeviceStatusRes = model.SimDeviceStatusOutput + +// 物联网卡地域限制状态查询 请求参数 +type SimRegionLimitStatusReq struct { + g.Meta `path:"/sim/query/sim-region-limit-status" tags:"CardQuery" method:"GET,POST" summary:"物联网卡地域限制状态查询"` + model.SimRegionLimitStatusInput +} + +// 物联网卡地域限制状态查询 响应参数 +type SimRegionLimitStatusRes = model.SimRegionLimitStatusOutput + +// 物联网卡设备标识码查询 请求参数 +type SimImeiReq struct { + g.Meta `path:"/sim/query/sim-imei" tags:"CardQuery" method:"GET,POST" summary:"物联网卡设备标识码查询"` + model.SimImeiInput +} + +type SimImeiRes = model.SimImeiOutput + +// 物联网卡数据流量查询 请求参数 +type SimDataUsageReq struct { + g.Meta `path:"/sim/query/sim-datausage" tags:"CardQuery" method:"GET,POST" summary:"物联网卡数据流量查询"` + model.SimDataUsageInput +} + +type SimDataUsageRes = model.SimDataUsageOutput + +// 物联网卡达量限速档位查询 请求参数 +type SimDataDiagnosisReq struct { + g.Meta `path:"/sim/query/sim-data-diagnosis" tags:"CardQuery" method:"GET,POST" summary:"物联网卡达量限速档位查询"` + model.SimDataDiagnosisInput +} + +type SimDataDiagnosisRes = model.SimDataDiagnosisOutput + +// 物联网卡数据流量查询 月查询 请求参数 +type SimDataUsageMonthlyReq struct { + g.Meta `path:"/sim/query/sim-datausage-monthly" tags:"CardQuery" method:"GET,POST" summary:"物联网卡数据流量查询 月查询"` + model.SimDataUsageMonthlyInput +} + +type SimDataUsageMonthlyRes = model.SimDataUsageMonthlyOutput + +// 物联网卡数据流量查询 月批量查询 请求参数 +type SimDataUsageMonthlyBatchReq struct { + g.Meta `path:"/sim/query/sim-datausage-monthly-batch" tags:"CardQuery" method:"GET,POST" summary:"电信-物联网卡数据流量查询 月批量查询"` + model.SimDataUsageMonthlyInput +} + +type SimDataUsageMonthlyBatchRes = model.SimDataUsageMonthlyOutput + +// 物联网卡数据流量查询 日查询 请求参数 +type SimDataUsageDailyReq struct { + g.Meta `path:"/sim/query/sim-datausage-daily" tags:"CardQuery" method:"GET,POST" summary:"物联网卡数据流量查询 日查询"` + model.SimDataUsageDailyInput +} + +// 物联网卡数据流量查询 日查询 响应参数 +type SimDataUsageDailyRes = model.SimDataUsageDailyOutput + +// 物联网卡数据流量查询 日批量查询 请求参数 +type SimDataUsageDailyBatchReq struct { + g.Meta `path:"/sim/query/sim-datausage-daily-batch" tags:"CardQuery" method:"GET,POST" summary:"电信-物联网卡数据流量查询 日批量查询"` + model.SimDataUsageDailyInput +} + +type SimDataUsageDailyBatchRes = model.SimDataUsageDailyOutput + +// 物联网卡已开通APN信息查询 请求参数 +type SimApnInfoReq struct { + g.Meta `path:"/sim/query/sim-apn-info" tags:"CardQuery" method:"GET,POST" summary:"物联网卡已开通APN信息查询"` + model.SimApnInfoInput +} + +type SimApnInfoRes = model.SimApnInfoOutput + +// 物联网卡状态变更 请求参数 +type SimStatusChangeReq struct { + g.Meta `path:"/sim/change/sim-status" tags:"CardChange" method:"GET,POST" summary:"物联网卡状态变更"` + model.SimCardStatusChangeInput +} + +// 物联网卡状态变更 响应参数 +type SimStatusChangeRes = model.SimCardStatusChangeOutput + +// 物联网卡APN变更 请求参数 +type SimApnChangeReq struct { + g.Meta `path:"/sim/change/sim-apn" tags:"CardChange" method:"GET,POST" summary:"物联网卡APN变更"` + model.SimCardOperateChangeApnInput +} + +// 物联网卡APN变更 响应参数 +type SimApnChangeRes = model.SimCardOperateChangeApnOutput diff --git a/build/Dockerfile b/build/Dockerfile new file mode 100644 index 0000000..a4e03af --- /dev/null +++ b/build/Dockerfile @@ -0,0 +1,34 @@ +FROM golang:1.25-alpine AS builder + +LABEL stage=gobuilder + +ENV GOPROXY=https://goproxy.cn,direct + +WORKDIR /build + +COPY go.mod go.sum ./ + +RUN go mod download + +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}" + +FROM alpine:3.23 + +RUN apk add --no-cache ca-certificates tzdata + +COPY --from=builder /usr/share/zoneinfo/Asia/Shanghai /usr/share/zoneinfo/Asia/Shanghai +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 + +CMD ["./iotlcmupstreamchan_svc"] diff --git a/docs/apika-shared-token-integration.md b/docs/apika-shared-token-integration.md new file mode 100644 index 0000000..820f2ea --- /dev/null +++ b/docs/apika-shared-token-integration.md @@ -0,0 +1,25 @@ +# Apika mobile shared-token channel + +Use `temp_model=shared_token` with template key `sim_onelink_kedang_v1`. + +Set `token.baseUrl` to `http://test.iotapi.apika.cn` in the test environment +or `https://iotapi.apika.cn` in production. The token path is +`/iot-api/iot-card-info/getRefreshToken`. + +Channel fields: + +- `app_key`: Apika `appKey`, sent in the request header and body. +- `app_secret`: Apika `appSecret`, sent in the request header and body and used + to calculate the signature. +- `chan_config`: `{"appId":"..."}`; `appId` is sent as JSON field `appid`. + +The request sends `appKey`, `appSecret`, and `signature` in both the headers +and JSON body. The JSON body also contains `appid`. + +The signature is calculated by building `{appKey}:{appSecret}` with the +literal braces, hashing its UTF-8 bytes with SHA-256, converting the digest to +uppercase hexadecimal, and Base64-encoding that hexadecimal string. + +The returned `data.shareToken` is cached for five minutes and refreshed thirty +seconds before expiry. The returned `transid` is not used as the OneLink +business request transaction ID. diff --git a/docs/config/mingqian_channel.json b/docs/config/mingqian_channel.json new file mode 100644 index 0000000..0967ef4 --- /dev/null +++ b/docs/config/mingqian_channel.json @@ -0,0 +1 @@ +{} diff --git a/docs/config/mingqian_v3.json b/docs/config/mingqian_v3.json new file mode 100644 index 0000000..fe26e30 --- /dev/null +++ b/docs/config/mingqian_v3.json @@ -0,0 +1,8 @@ +{ + "api": { + "baseUrl": "http://iotapi.bjmqtx.com/openapi/wlw/v3/cool", + "timeout": "10s", + "enableOperations": false, + "resumePath": "" + } +} diff --git a/docs/config/onelink_apika_v1.json b/docs/config/onelink_apika_v1.json new file mode 100644 index 0000000..9bacf9c --- /dev/null +++ b/docs/config/onelink_apika_v1.json @@ -0,0 +1,11 @@ +{ + "token": { + "provider": "apika", + "baseUrl": "http://test.iotapi.apika.cn", + "path": "/iot-api/iot-card-info/getRefreshToken" + }, + "api": { + "baseUrl": "https://api.iot.10086.cn/v5/", + "timeout": "15s" + } +} diff --git a/docs/config/onelink_lelian_v1.json b/docs/config/onelink_lelian_v1.json new file mode 100644 index 0000000..1910374 --- /dev/null +++ b/docs/config/onelink_lelian_v1.json @@ -0,0 +1,10 @@ +{ + "token": { + "provider": "henan_lelian", + "baseUrl": "http://api.wwlelianiot.com", + "path": "/api/v2/getShareToken", + "params": { + "loginPath": "/api/v2/loginVerify" + } + } +} diff --git a/docs/config/onelink_mobile_public_v1.json b/docs/config/onelink_mobile_public_v1.json new file mode 100644 index 0000000..34b886f --- /dev/null +++ b/docs/config/onelink_mobile_public_v1.json @@ -0,0 +1,7 @@ +{ + "token": { + "provider": "mobile_public", + "baseUrl": "https://REPLACE_WITH_TOKEN_HOST", + "path": "/api/get/yd/api/msg" + } +} diff --git a/docs/config/onelink_tengyu_v1.json b/docs/config/onelink_tengyu_v1.json new file mode 100644 index 0000000..e1b5ac5 --- /dev/null +++ b/docs/config/onelink_tengyu_v1.json @@ -0,0 +1,7 @@ +{ + "token": { + "provider": "tengyu", + "baseUrl": "http://www.sdtywl.cn/", + "path": "/prod-api/open/api/oneLinkToken" + } +} diff --git a/docs/config/onelink_xuzhou_mobile_v1.json b/docs/config/onelink_xuzhou_mobile_v1.json new file mode 100644 index 0000000..d2adca0 --- /dev/null +++ b/docs/config/onelink_xuzhou_mobile_v1.json @@ -0,0 +1,10 @@ +{ + "token": { + "provider": "xuzhou_mobile", + "baseUrl": "http://iot.yuanjianiot.com", + "path": "/admin/api/get/token", + "params": { + "carrierType": 1 + } + } +} diff --git a/docs/config/sim_dx_v3.json b/docs/config/sim_dx_v3.json new file mode 100644 index 0000000..17b902d --- /dev/null +++ b/docs/config/sim_dx_v3.json @@ -0,0 +1,9 @@ +{ + "api": { + "baseUrl": "https://cmp-api.ctwing.cn:20164", + "enableOperations": false + }, + "http": { + "timeout": "10s" + } +} diff --git a/docs/mingqian-integration.md b/docs/mingqian-integration.md new file mode 100644 index 0000000..960d3f3 --- /dev/null +++ b/docs/mingqian-integration.md @@ -0,0 +1,214 @@ +# Mingqian v3 integration + +## Scope and deployment + +This is a third-party platform adapter, not a shared-token adapter. Use +`temp_key=sim_mingqian_v3` and `temp_model=direct`. Deploy BOTH the gateway +and proxy builds containing this key, then save/refresh the channel in both +services. A configuration refresh cannot load a template into an old binary. + +Worktrees for this feature: + +- Gateway: `F:\Work\iotlcm-upstream-chan` +- Proxy: `F:\Work\iotlcm-upstream-proxy` +- Branch in each repository: `feat/mingqian-api-integration` + +The LCM response model update lives in `F:\Work\lcm-mingqian`, branch +`feat/mingqian-flow-mapping`, based on `origin/master`. Deploy this LCM build +as well to preserve package flow fields in the channel test page. The page +already renders the returned JSON; no frontend changes are required. Without +the LCM model update, its typed response parser discards the new fields. + +Do not deploy the old checkout by mistake. This feature does not edit the +existing runtime service address, production database, or operator settings. +Point the proxy's `gateway.address` at the actual gateway HTTP listener. +Do not configure the proxy to call Mingqian directly. + +## Admin configuration + +Create a template named Mingqian third-party v3: + +- Template key: `sim_mingqian_v3` +- Model: `direct` +- Status: Enabled (select the UI option; do not assume the numeric enum) +- Template JSON: copy `docs/config/mingqian_v3.json` + +Create a channel: + +- Channel key: a unique value such as `mingqian_001` +- Template: select the template above +- AppKey: the upstream **appId** +- AppSecret: the upstream signing **APPKEY** (NOT a shared token) +- Channel JSON: `{}` (`docs/config/mingqian_channel.json`) +- Status: Enabled +- Carrier: select the actual carrier of the cards/account; this document + covers three carriers and does not establish which one your account uses. + +Credentials are read from channel AppKey/AppSecret only. Channel JSON does +not override template configuration. Never commit real credentials. + +## Supported standard gateway capabilities + +| Capability | Upstream relative path | Behavior | +| --- | --- | --- | +| Basic information | `/simlist/get-state-by-number` | ICCID, MSISDN, raw state and description only | +| Card lifecycle status | `/simlist/get-state-by-number` | Convert upstream states to gateway enums | +| Current-month usage | `/simlist/get-flow-use-by-number` | `use` KB / 1024 -> `dataUsage` MB | +| Package flow | `/use/get-card-use-by-number` | `totalpackage` and `targetpackageusage` MB -> separate `packageFlow` in the same real-time flow response | +| Stop | `/simlist/get-sim-switchoff-by-number` | Disabled unless `api.enableOperations=true` | +| Resume | Confirmed `api.resumePath` | Disabled by default; no guessed endpoint | + +Queries split into at most 50 identifiers. Status changes split into at most +20 identifiers. Responses are matched by the requested ICCID or MSISDN, not +by array position. Missing/duplicate cards, missing/negative usage, missing +status and per-card operation failures are returned as failures, never as +successful zero usage. IMSI-only requests are rejected before network access. + +Basic information is partial: no fake IMSI, activation date, or opening date. +The upstream `activedate` is a PACKAGE effective date, not card activation. +Use the admin test's direct-single-identifier mode. Three-code completion +cannot succeed unless all three codes are available from another source. + +State mapping: 1 -> pending(1), 2 -> active(2), 3 -> stopped(4), +4 -> test(6), 5 -> stock(7), 6 -> cancelled(9). States 4 and 5 appear in the +inventory example; the global table omits them. State 7 (expired) and other +unmapped states return an explicit error rather than treating expiry as +permanent cancellation. Raw state remains available in basic information. + +Not implemented as standard gateway capabilities: account-wide inventory +sync, IMEI lookup, APN/session/device/binding/stop-reason diagnostics, historical +daily/monthly usage, real-name status, voice-number +whitelists, SMS, account balance, recharge and order operations. Unsupported +standard methods return explicit errors without contacting upstream. An IMEI +field in the account inventory must not imply that binding is enabled. + +The inventory endpoint is account-wide and paginated; this adapter deliberately +does not scan all pages on every single-card query. + +## Flow mapping and partial failures + +Select the admin test's real-time flow item (`simDataUsage`), or POST to the +gateway/proxy `/sim/query/sim-datausage` with the normal `simKeys` and empty +`reqData`. For each batch of at most 50 identifiers the adapter calls both +read-only flow endpoints, sharing the same account rate limiter. Results from +each endpoint are matched independently by ICCID/MSISDN, not by response order. +For 51 cards this makes four upstream calls, not two. + +Example `resData`, all units MB: + +```json +{ + "dataUsage": 1, + "packageFlow": { + "totalFlow": 10240, + "dataUsage": 8192, + "remainingFlow": 2048 + } +} +``` + +- `dataUsage`: current-month usage, from `use / 1024`. Existing monthly + synchronization continues using this field only. +- `packageFlow.totalFlow`: upstream `totalpackage`, already MB. +- `packageFlow.dataUsage`: upstream `targetpackageusage`, already MB and + cumulative over the package period, not necessarily the current month. +- `packageFlow.remainingFlow`: derived as `max(totalpackage - targetpackageusage, 0)`. + Overage is retained in cumulative usage, but remaining flow never becomes negative. +- The legacy top-level `totalFlow` remains absent for Mingqian. Do not subtract + current-month usage from a cross-month package total. + +Missing/null/negative/non-numeric amounts are failures, not successful zeroes. +Actual zero values are valid. When monthly usage succeeds but package flow +fails, the card retains its successful monthly reading, omits `packageFlow`, +and includes `packageFlowError` plus a warning in the per-card `message`. +If monthly usage fails, the per-card code remains failure even if a package +snapshot is present; never write the package cumulative amount as monthly usage. + +The channel test's monthly and daily items request historical periods. The +current Mingqian documentation provides neither historical endpoint, so these +remain explicitly unsupported rather than returning current-period numbers. +Real-time sessions, online status, APN and IP likewise remain unsupported; +neither lifecycle state nor nonzero usage proves a currently active session. + +This update exposes package flow in API/channel-test results. It does not +persist package snapshots, change card-list balance calculations, or change +charging, quotas, or monthly synchronization semantics. + +## Wire contract and safety + +JSON POST envelope: + +```json +{ + "body": { + "appId": "YOUR_APP_ID", + "reqtime": "2026-09-17", + "timestamp": "CURRENT_UNIX_MILLISECONDS_PLUS_60000", + "transid": "yyyyMMddHHmmssfff_PLUS_SIX_RANDOM_HEX_CHARACTERS", + "version": "3.0", + "sign": "COMPUTED_SIGNATURE" + }, + "data": "{\"numberArray\":[\"YOUR_TEST_ICCID\"]}" +} +``` + +The `data` value is a serialized JSON STRING. Dates use UTC+8. The timestamp +is current time plus one minute, in milliseconds. Sign excludes `sign` itself: +sort common parameter keys, concatenate `key_value` for each without additional +separators, prepend/append APPKEY, and produce lowercase MD5. + +This is the literal interpretation of the signing prose and example casing; +no official complete credential/sign test vector was supplied. Local tests +verify this interpretation, not acceptance by the upstream server. Confirm +against one official sample or a permitted read-only query before release. + +Successful business code is 200 (not zero). HTTP errors, non-200 business +codes, malformed envelopes and absent data fail closed. No automatic retries +or HTTP redirects, especially for state-changing requests. Raw response bodies +and secrets are not logged. Client response size is bounded at 4 MiB. + +An in-process limiter shared by base URL + appId spaces calls at least 60ms +apart (~166/10s, below the documented 200/10s). It does NOT coordinate separate +gateway replicas or other applications using the same account. Use a shared +egress limit or allocate a per-replica budget before scaling those accounts. + +## Documentation contradictions requiring upstream confirmation + +1. Stop and resume pages both list `/simlist/get-sim-switchoff-by-number`. + Never enable resume using that path. Obtain the real resume path, configure + `api.resumePath`, then explicitly enable operations when authorized. +2. The state page example says `state=1` with an active description; the global + table says 1=pending, 2=active. Mapping follows the table, not that example. +3. Confirm signature concatenation/casing with an official sample. + +## Verification + +Use only a sandbox or local `httptest` for stop/resume. No real stop, resume, +SMS, purchase or recharge request was sent during implementation. + +Gateway targeted tests: + +```text +go test ./internal/service/mingqian +go test ./internal/logic/upstreamimpl -run TestMingqianRegistration +go build ./... +``` + +Proxy targeted tests: + +```text +go test ./internal/logic/upstreamimpl -run TestMingqianProxyTemplate +go test ./internal/logic/upstreamimpl -run TestMingqianProxyPreservesFlowFields +go build ./... +``` + +LCM response-model tests (run in `F:\Work\lcm-mingqian\server`): + +```text +go test ./internal/model -run SimDataUsage +go build ./... +``` + +Initial live verification should select ONLY basic info, card status and +current-month usage using a permitted ICCID/MSISDN. Do not use real operations +to validate connectivity. A successful mock test is not proof of live access. diff --git a/docs/mobile-public-token-integration.md b/docs/mobile-public-token-integration.md new file mode 100644 index 0000000..1d64e53 --- /dev/null +++ b/docs/mobile-public-token-integration.md @@ -0,0 +1,24 @@ +# 移动公共 Token 共享通道 + +## 模板 + +- 模板编号:`sim_onelink_daliuliang_v1`(开发库当前使用的标识) +- 模板名称:移动公共 Token 共享 OneLink v1 +- 模板模型:`shared_token` +- 模板配置:`docs/config/onelink_mobile_public_v1.json` + +`token.baseUrl` 必须填写上游提供的 Token 服务主机地址;当前协议只提供路径 `/api/get/yd/api/msg`,不能使用移动 OneLink 业务地址代替。 + +## 通道管理 + +- 通道名称:移动大流量插拔实名卡-OU2003457135923 +- 通道标识(`chan_key`):`daliuliang_001` +- `temp_key`:`sim_onelink_daliuliang_v1` +- `chan_config`:`{"userId":"102369"}` +- `app_secret`:上游提供的签名密钥 + +创建实例前,需要启用通道和模板,并确保通道的 `is_normal=0`;`is_normal` 为 `NULL` 或 `1` 时,服务不会创建运行实例。通道服务与代理服务都需要部署包含该模板编号注册的版本。 + +不要把签名密钥写入 `chan_config`、模板配置或日志。Token 获取请求使用当前 Unix 秒时间戳,并按 `md5(secret + userid + time + secret)` 计算小写签名。 + +上游响应的 `expire_time` 作为 Unix 秒绝对过期时间保存;服务会在到期前 30 秒刷新 Token,并在同一通道的并发请求间合并刷新动作。 diff --git a/docs/plans/channel-template-model-design.md b/docs/plans/channel-template-model-design.md new file mode 100644 index 0000000..4720c1c --- /dev/null +++ b/docs/plans/channel-template-model-design.md @@ -0,0 +1,119 @@ +# 通道模板模型设计 + +## 1. 设计结论 + +通道配置分成三层: + +```text +temp_model 接入方式大类 +temp_key 具体上游协议模板 +chan_config 具体通道的账号凭证和运行时状态 +``` + +`temp_model` 只使用两个值: + +```text +direct 直接调用上游 API +shared_token 先获取共享 Token,再调用业务 API +``` + +`direct` 是分类值,不是通用实现。普通模板必须通过 `temp_key` 找到精确注册的协议实现;不能仅凭 `direct` 推断运营商或请求协议。 +`shared_token` 是共享 Token 模型的路由值,可以由共享实现工厂创建具体通道。 + +## 2. Mermaid 架构图 + +```mermaid +flowchart TB + DB["模板数据"] --> TEMPLATE["通道模板"] + TEMPLATE --> KEY["temp_key\n具体协议模板"] + TEMPLATE --> MODEL["temp_model\n接入方式"] + + MODEL --> DIRECT["direct\n直接调用上游 API"] + MODEL --> SHARED["shared_token\n先获取共享 Token"] + + KEY --> TY["sim_recharge_sdtywl_v1\n腾宇直连"] + KEY --> YD["sim_yd_v5\n中国移动官方"] + KEY --> DX["sim_dx_v1\n中国电信官方"] + KEY --> TY_SHARED["sim_onelink_tengyu_v1\n腾宇获取 Token + OneLink"] + KEY --> XZ_SHARED["sim_onelink_xuzhou_mobile_v1\n徐州移动获取 Token + OneLink"] + + TY --> DIRECT_IMPL["精确 temp_key 注册的 direct 实现"] + YD --> DIRECT_IMPL + DX --> DIRECT_IMPL + + TY_SHARED --> SHARED_IMPL["shared_token 模型工厂\n复用 OneLink 业务能力"] + XZ_SHARED --> SHARED_IMPL + SHARED_IMPL --> PROVIDER["Token Provider"] + PROVIDER --> TOKEN["共享 Token"] + TOKEN --> ONELINK["OneLink 业务 API"] + + CREATE["新建通道"] --> SELECT["选择已有 temp_key"] + SELECT --> CREDENTIALS["填写通道凭证"] + CREDENTIALS --> CHANNEL["t_sim_channels"] + + RULES["规则"] --> R1["temp_model 只有 direct / shared_token"] + RULES --> R2["temp_key 决定具体协议"] + RULES --> R3["direct 必须有精确注册"] + RULES --> R4["shared_token 使用共享工厂"] +``` + +## 3. 模板示例 + +```text +sim_yd_v5 + temp_model = direct + 中国移动官方 OneLink + +sim_yd_mock_v5 + temp_model = direct + 中国移动 Mock OneLink + +sim_dx_v1 + temp_model = direct + 中国电信 CMP + +sim_recharge_sdtywl_v1 + temp_model = direct + 腾宇直连 API + +sim_onelink_tengyu_v1 + temp_model = shared_token + 腾宇获取 Token + OneLink + +sim_onelink_xuzhou_mobile_v1 + temp_model = shared_token + 徐州移动获取 Token + OneLink +``` + +## 4. 路由规则 + +运行时始终先按 `temp_key` 查找精确实现。精确注册优先于模型工厂,因此现有直连通道的协议行为不会因为模型分类改变。 + +当没有精确 `temp_key` 注册时: + +- `temp_model=shared_token` 使用共享 Token 模型工厂。 +- `temp_model=direct` 返回明确错误,因为 direct 本身不描述具体协议。 +- 空值、未知值或旧的具体实现名称返回明确配置错误。 + +## 5. 新增三方协议流程 + +1. 实现三方协议的请求、签名、响应映射、错误处理和业务能力。 +2. 增加新的 `temp_key` 并注册精确实现。 +3. 为模板设置 `temp_model=direct`;如果需要共享 Token,则设置 `temp_model=shared_token`。 +4. 在模板数据中保存公共配置,通道数据中保存账号凭证。 +5. 业务人员创建通道时选择已有 `temp_key`,只填写该通道自己的凭证。 + +## 6. 配置边界 + +模板配置适合保存公共 API 地址、固定路径、超时时间和其他非账号协议参数。 +通道配置适合保存 appKey、appSecret、pwdKey、channelNo、appId、账号密码及该通道独立的 Token 状态。 + +配置不能试图描述完整协议;新增协议通常仍需要修改代码。 + +## 7. 迁移约束 + +- 新代码只接受 `direct` 和 `shared_token` 两个模型值。 +- 部署前通过 DBX 将现有普通模板修正为 `direct`,共享 Token 模板修正为 `shared_token`。 +- 旧的 `onelink`、`onelink_mock`、`telecom`、`shared_token_onelink` 不作为新配置值保留。 +- 发现无法根据 `temp_key` 判断类型的模板时,停止自动迁移并人工确认。 +- 本仓库不提交数据库修复 SQL;数据库变更由 DBX 直接执行。 diff --git a/docs/plans/telecom-v3-integration-and-switch.md b/docs/plans/telecom-v3-integration-and-switch.md new file mode 100644 index 0000000..c42c2e8 --- /dev/null +++ b/docs/plans/telecom-v3-integration-and-switch.md @@ -0,0 +1,130 @@ +# 电信 v1 现状、v3 并行接入与切换 + +调查日期:2026-10-10。代码依据为当前本地三个服务仓库;数据库通过 DBX 只读查询 `开发环境(192.0.2.29).db_iotlcm`。未修改数据库、未部署、未调用电信真实查询或操作接口。 + +接口依据:用户提供的《中国电信天翼物联5GCMP能力调用参考文档-V3.pdf》,464 页,封面版本 `V3.2026.1.7`;《iotlcm上游API静态审计明细(41条审计记录).xlsx》,其中 21 条为电信记录,其余为移动记录。推荐路径以 PDF 接口定义为准;示例与参数表冲突的地方单独记录。 + +## 1. 原有设计 + +`sim_dx_v1` 是协议模板标识,不是某一个账号通道编号。模板表中的名称确实是“电信BOSS卡API”。同一模板被多个独立账号通道复用。 + +```mermaid +flowchart LR + CARD["卡: chan_key"] --> CHANNEL["t_sim_channels: chan_key / temp_key / 凭证"] + CHANNEL --> TEMPLATE["t_sim_channel_template: temp_key / temp_model / temp_config"] + TEMPLATE --> PROXY["upstream-proxy: 注册模板并转发统一请求"] + PROXY --> CHAN["upstream-chan: 创建协议实例"] + CHAN --> V1["sim_dx_v1: 旧接口"] + CHAN --> V3["sim_dx_v3: 新接口"] +``` + +运行时按 `chan_key` 路由,按模板 `temp_key` 精确创建实例。`temp_model=direct` 只是接入类型,不能代替具体协议实现。对外仍然使用统一 `SimCardChannelI`,批量能力使用 `SimCardBatchQueryI`,因此业务调用者无需知道电信 URL 或版本。 + +主要代码位置: + +| 模块 | 代码 | 职责 | +| --- | --- | --- | +| chan | `internal/logic/upstreamimpl/sim_dx.go`、`sim_dx_batch.go` | 原 v1 请求、XML/JSON 解析与状态转换 | +| chan | `internal/logic/upstreamimpl/dx/client.go`、`sign.go` | AppKey、Sign、GMT Timestamp 鉴权 | +| chan/proxy | `internal/logic/channelconfig/channel_config.go` | 读取数据库配置、计算版本、缓存快照 | +| chan/proxy | `internal/logic/upstreamapi/upstreamapi.go` | 模板注册、按通道创建实例、热刷新 | +| proxy | `internal/logic/upstreamimpl/sim_dx.go` | 同路径转发统一接口给 chan,实际运营商调用在 chan | +| admin | `server/internal/logic/iot/t_sim_channels.go` | 保存通道后发布刷新消息 | +| worker | `internal/consumer/rabbitmq/channel_refresh.go` | 消费刷新消息,先刷新 gateway,再刷新 proxy | + +v1 的网关地址优先取 `temp_config.api.baseUrl`,其次兼容通道配置 `baseURL/url`,最后取 `dependencies.sim_dx_v1.url` 或默认 `https://cmp-api.ctwing.cn:20164`。凭证来自通道列 `app_key/app_secret`;批量接口客户编码来自 `chan_config.custNumber`。 + +### 开发库现状 + +- 仅有电信模板 `sim_dx_v1`,`temp_model=direct`,网关地址为上述默认地址;查询时 `temp_status=0`。运行时创建的主要条件是 `is_normal=0`,不能只根据模板状态推断是否调用上游。 +- 19 个通道使用 v1,全部有非空 AppKey/AppSecret。16 个 `is_normal=0`、2 个 `is_normal=1`、1 个为 NULL。后两类不能创建正常运行实例。 +- 9 个通道 `chan_config` 含 `custNumber`,10 个为空对象;未确认这些客户编码是否对应新版 `custNbr`,不能仅凭名称自动替换实际值。 +- 19 个通道的 `sync_enabled=1`。当前字典语义为 0=允许轮询、1=不允许,切换模板不应顺带开启批量同步。 +- 17 个通道 `operator=3`、2 个为 1;开发库 `operators/t_operators` 字典均为 1=移动、2=联通、3=电信,而 worker 原部分辅助逻辑把 2 当电信。此次只对已知电信模板优先识别,其他运营商字典问题需单独处理。 +- 按租户和 `chan_key` 关联 `t_sim_card_info` 后,19 个通道卡量均为 0;这只是开发库快照,不代表生产无卡。真实切换前须单独核实目标环境库存卡、业务卡与在途任务。 + +## 2. 新增实现与接口对应 + +新增 `sim_dx_v3.go`、`sim_dx_v3_batch.go`。保留 `sim_dx_v1` 注册和所有旧路径。proxy 同时注册两个模板;v3 使用独立适配器,不把 v1 路径机械替换成 `/v3/`。 + +v3 同样使用 AppKey、Sign、Timestamp。文档第 10-12 页 Java 示例为:排序查询参数,拼接原样 body、SecretKey、GMT 时间戳,先生成小写 MD5 字符串,再把该字符串字节做十六进制编码并转大写。新增 `BuildSignV3` 显式遵循该例;v1 签名保持原样。业务 envelope 使用 `code/message/result`,逐卡 batch 的 `result=0` 表示成功。HTTP 799 的错误码从 `gw-errorCode` header 保留到统一 `errCode`。 + +| 功能 | v1 实际路径/行为 | v3 当前接入 | PDF 页 | +| --- | --- | --- | --- | +| 单卡资料 | `/openapi/v1/prodinst/getSIMList` | GET `/api/v3/getSimInfo` | 14-23 | +| 停机原因 | 复用基础资料 StopTypeList | 读取 getSimInfo 的 stopStatusName/stopTypeName,兼容 stopTypeList | 15-16 | +| 会话 | 多种 qryOnlineSataus/qryOnlineStatus 组合 | GET `/api/v3/getOnlineStatus`;1=在线、2=离线 | 31-35 | +| IMEI | `/openapi/v1/prodinst/gete2einfo` | GET `/api/v3/getImei` | 44-45 | +| 单卡实时当月流量 | `/api/v1/openbill/queryTraffic`,XML | GET `/api/v3/getDataUsage`;dataAmount 单位 MB | 107-110 | +| 历史月流量 | queryTrafficByDate | GET `/api/v3/getDataUsageByMonth`,month=yyyyMM | 111-114 | +| 历史日流量 | queryTrafficByDate | GET `/api/v3/getDataUsageBetweenDate`,beginDate=endDate=yyyyMMdd | 129-132 | +| 批量日流量 | batchQryFlowByDate | POST `/api/v3/getDataUsageByDayBatch` | 144-146 | +| 批量月流量 | batchQryFlowByMonth | POST `/api/v3/getDataUsageByMonthBatch` | 147-149 | +| APN 列表 | 复用会话 ApnId | GET `/api/v3/getApnInfo`,apnInfoList,0停用/1正常 | 159-161 | +| 终端开关机 | POST `/api/v1/prod/getDevicePowerStatus` | **GET** `/api/v3/getDevicePowerStatus`;仅 3G,保持统一设备健康未知。⚠️网关实测只接受 GET(官方文档写 POST 与网关不符;同一网关下 v1 的同名接口反而只接受 POST) | 190-192 | +| 整卡数据开停 | GET singleCutNet,DEL开/ADD停 | POST `/api/v3/singleCutNet`;平台开0→上游1、停1→上游2 | 198-200 | +| 卡状态 | queryCardMainStatus | GET `/api/v3/getSimStatus` | 317-321 | +| 停复机 | disabledNumberService,orderTypeId=19/20 | GET `/api/v3/updateSimStatus`,operType=19/20 | 321-323 | +| 批量卡状态 | batchQrySimStatus,旧分块10000 | POST `/api/v3/getSimStatusBatch`,新分块最多100 | 328-333 | +| 机卡绑定 | chkDeviceBindingInfo | GET `/api/v3/getSimBindingInfo` | 374-376 | +| 区域限制 | qryAreaLimitInfo | GET `/api/v3/getSimRegionLimitStatus`,含 restrictedArea | 381-383 | +| 冻结状态 | code=0 + “不支持查询” | code=0 + “不支持查询”,与 v1 对齐 | 电信无独立冻结查询接口 | +| 达量限速诊断 | code=5000 + “暂不提供” | code=5000 + “暂不提供”,使诊断页降级为跳过 | 电信无达量限速档位查询 | + +批量请求使用 `msisdnList` 和 `custNbr`,按 MSISDN 匹配返回数据并保持输入顺序;漏卡、重复卡、逐卡失败、日期错配和非 MB 单位返回失败。业务失败保留运营商原始错误码;缺失 code/result、无效金额不会被当成成功或零流量。 + +停复机与整卡数据开停默认由 `api.enableOperations=false` 关闭。开启后仅返回交易受理号,不代表已完成运营商状态变更;完成状态须按订单/状态查询确认。本次没有新增订单轮询工作流。 + +### 明确保留的限制 + +1. 批量资料 `batchQrySimInfo` 的参数表写 result、逐卡成功1,但示例使用 data.qrySimInfoList、请求示例又写 batchQrySimStatus。暂不调用这个有冲突的端点,基础资料走单卡 getSimInfo。新版基础资料与旧 batch 的全部扩展字段不完全一致,迁移验收须核对订购产品、停机原因码、区域/绑定信息完整性。 +2. 上层 `/sim/query/sim-datausage-monthly-batch` 仍保留原“仅当月”的校验;v3 适配器可处理近6个月,但对外历史月查询目前继续使用单卡 getDataUsageByMonth。扩展公共 batch 历史窗口须另行调整通道能力校验,不能直接扩大 v1 的窗口。 +3. 历史日接口要求已结束日期;batch 只查近7个已结束日。当日接口 getDataUsageOfToday 不接入当前历史日契约。文档示例 totalUsageAmount 与参数表 dataAmount 不一致,金额解析兼容两者。 +4. 指定 APN 开停、单卡多 APN 模板操作与“整卡单独断网”语义不同:`singleCutNet` 只有整卡数据开停语义,没有“按 APN 定向”维度。与 v1 一致,忽略请求中的 `ApnName`(预警自动断网、批量数据开停都会固定携带 `apnName="CMIOT"`,若据此拒绝会直接打断现有主流程)。新增 APN 管理需独立确认业务契约。 + (2026-10-14 更正:v1 在 2026-10-10 `b3f00a0` 曾被改成按 APN 定向的 `/api/v3/updateApnFunctionStatus`,导致电信数据开停全部失败,已回退为 singleCutNet;此处“与 v1 一致”现重新成立。) +5. 新版终端开关机仅3G,不能据此宣称4G/5G终端健康;机卡绑定断网状态与分离状态也不能互换语义。 +6. 不自动从 v3 回退并重发 v1 写操作。网络超时可能已经受理,重复调用会产生重复操作。 +7. getSimInfo 参数表含 ICCID/IMSI,但示例只返回 MSISDN。基础资料查询允许返回部分三码且不编造缺失值;现有后台单码补全流程仍要求完整三码,真实响应不足时会明确失败。必须通过授权测试卡核实这项差异后再迁移依赖三码补全的流程。 + +## 3. 快速切换与回退 + +复用通道模板作为版本开关:保留同一个 `chan_key`,把目标通道的 `temp_key` 从 `sim_dx_v1` 改成 `sim_dx_v3`。不用迁移卡表、库存关联、ICCID→通道 Redis 映射,也不需要在切换时重新部署。不同通道可选择不同版本,但同一通道全部卡共用该版本。 + +上线前一次性部署两个模板都存在的新 chan/proxy 及 worker。随后在后台新增模板:`temp_key=sim_dx_v3`、`temp_model=direct`,配置采用 `docs/config/sim_dx_v3.json`。通道凭证仍放 `app_key/app_secret`;客户编码用 `chan_config.custNbr`,代码兼容旧字段 custNumber。未把任何 v3 模板或通道记录写入开发库。 + +切换步骤: + +1. 保存目标通道的原 temp_key、app_key/app_secret、chan_config、is_normal、同步配置快照到受控存储;快照不写日志。确认账号已获 v3 能力授权、新旧凭证是否相同,以及新版 custNbr 的正确值。 +2. 新建测试用 v3 通道并保持非运行状态,使用后台通道测试创建临时实例,以完整三码和单码补全验证只读查询。该测试路径允许读取非运行通道,不注册生产实例;开发库目前无关联业务卡,需准备有授权的测试卡。 +3. 选择一个目标通道灰度。避免正在进行的管理操作和大批量同步;修改 temp_key,必要时同时更新新凭证/custNbr,保持 chan_key。不得顺带调整 sync_enabled 或 is_normal 来扩大操作范围。 +4. 通过后台保存,使事务完成后投递 `QueueChannelRefresh`。worker 先调用 gateway 的 `POST /internal/channel/refresh?chanKey=...`,再刷新 proxy,失败会重试。后台“保存成功”只说明消息已投递,仍需核对 worker 及两个上游的刷新结果。 +5. 核对只读查询、批量拆分、状态/会话映射、流量数值与单位、错误率和响应延迟。需要启用写操作时先在受控测试卡验证,再打开 enableOperations;用交易流水和状态查询确认最终结果。 +6. 逐通道推广。回退时恢复完整旧快照并重新保存/刷新;如果凭证相同且无其他配置变化,可只恢复 temp_key=sim_dx_v1。 + +运行中的实例以互斥锁替换,已取得旧实例的请求可继续完成,新请求使用新实例。但 gateway/proxy 两个服务之间不是分布式原子切换,RabbitMQ 消息也不是多实例广播。多副本部署须刷新每个实例并确认配置版本一致,当前地址的一次 HTTP 调用不能证明所有副本已经更新。刷新端点只能在现有内部控制网络调用。 + +配置缓存默认全量重读2小时,实例扫描默认60秒。直接执行数据库 UPDATE 不会投递刷新消息,不能用于即时切换;必须走后台保存或显式内部刷新。此次把凭证和 token 内容加入 chan/proxy 的配置版本哈希,防止仅更换凭证时实例版本不变;哈希异常日志不输出配置及凭证。 + +队列任务持有投递时的 temp_key 快照,而上游运行时按最新 chan_key 配置执行;切换前宜处理在途任务,新任务重新读取通道配置。本次统一 v1/v3 的 worker 电信模板分类,涵盖限速、月流量失败分类、状态重试、失败记录和入库限速;未知未来模板仍是 generic。v3 的 S00004 错误策略沿用原 v1 策略,真实账号联调需确认上游语义和 QPS,不把默认25 QPS视作电信承诺限额。 + +## 4. 验证与上线边界 + +新增测试覆盖 v1/v3 模板并存、配置校验、签名、在线枚举、实时流量 MB、异常金额/缺失结果/卡号错配、HTTP799与业务码、101张卡拆两批、乱序/漏卡/逐卡失败、错月份、历史日参数、部分三码、APN/绑定/区域/开关机字段、操作默认关闭及新参数映射。chan/proxy 的配置版本及刷新测试通过,worker 的入库限速/月流量/状态/失败记录测试通过;三个服务入口均完成编译验证。 + +worker 的完整 ZSet 测试曾出现租约定时测试失败(单独重跑通过)及 `TestMonthlyChannelBlockKeepsExistingCountingPolicy` 的 Redis Lua整数参数失败。通过 Go overlay 加载 `HEAD:internal/queue/zset/run.go` 原始代码后,后者同样复现,确认不是本次队列分类改动引入。本次未修改其 Lua 协议,仍需单独排查,不能宣称全量测试通过。 + +验证命令: + +```text +# iotlcm-upstream-chan +go test ./internal/logic/upstreamimpl ./internal/logic/upstreamimpl/dx ./internal/logic/channelconfig ./internal/logic/upstreamapi +# iotlcm-upstream-proxy +go test ./internal/logic/channelconfig ./internal/logic/upstreamapi ./internal/logic/upstreamimpl +# iotlcm-worker-svr +go test ./internal/service/inbound ./internal/service/monthlyusage ./internal/service/simstatus ./internal/queue/zset +go test ./internal/consumer/zset -run 'TestPolicyForTemplate|TestLostBatchLeasePreventsApplyAndAck' -count=1 +# 三个仓库分别编译入口,不启动服务 +go test -run '^$' . +``` + +本次为代码实现及本地契约验证。真实电信 API 的鉴权授权、字段差异、custNbr、频控、运营商订单完成状态及多副本热刷新仍需部署后联调。没有修改数据库、切换通道或调用真实停复机/断网。 diff --git a/docs/scripts/query_sim_card_status.sh b/docs/scripts/query_sim_card_status.sh new file mode 100644 index 0000000..807bee1 --- /dev/null +++ b/docs/scripts/query_sim_card_status.sh @@ -0,0 +1,40 @@ +#!/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 +# 可选环境变量:BASE_URL、CHAN_KEY、ICCID(多个用逗号分隔) + +set -euo pipefail + +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") +fi + +json_simkeys="[" +sep="" +for id in "${ICCIDS[@]}"; do + id="$(echo -n "$id" | tr -d '[:space:]')" + [[ -z "$id" ]] && continue + json_simkeys+="${sep}{\"chanKey\":\"${CHAN_KEY}\",\"iccid\":\"${id}\"}" + sep="," +done +json_simkeys+="]" + +body="{\"simKeys\":${json_simkeys}}" + +echo "# POST ${BASE_URL}${PATH_QUERY}" >&2 +echo "# body: ${body}" >&2 +echo >&2 + +curl -sS -X POST "${BASE_URL}${PATH_QUERY}" \ + -H 'Content-Type: application/json; charset=utf-8' \ + -d "${body}" +echo diff --git a/docs/scripts/query_sim_session.sh b/docs/scripts/query_sim_session.sh new file mode 100644 index 0000000..df4affe --- /dev/null +++ b/docs/scripts/query_sim_session.sh @@ -0,0 +1,41 @@ +#!/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 +# 可选环境变量:BASE_URL、CHAN_KEY、ICCID(多个用逗号分隔) + +set -euo pipefail + +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") +fi + +json_simkeys="[" +sep="" +for id in "${ICCIDS[@]}"; do + id="$(echo -n "$id" | tr -d '[:space:]')" + [[ -z "$id" ]] && continue + json_simkeys+="${sep}{\"chanKey\":\"${CHAN_KEY}\",\"iccid\":\"${id}\"}" + sep="," +done +json_simkeys+="]" + +body="{\"simKeys\":${json_simkeys}}" + +echo "# POST ${BASE_URL}${PATH_QUERY}" >&2 +echo "# body: ${body}" >&2 +echo >&2 + +curl -sS -X POST "${BASE_URL}${PATH_QUERY}" \ + -H 'Content-Type: application/json; charset=utf-8' \ + -d "${body}" +echo diff --git a/docs/telecom-v3-apn-integration.md b/docs/telecom-v3-apn-integration.md new file mode 100644 index 0000000..08c35a8 --- /dev/null +++ b/docs/telecom-v3-apn-integration.md @@ -0,0 +1,65 @@ +# 电信 V3 APN 状态与开停适配 + +## 接口依据 + +查询依据《中国电信天翼物联5GCMP能力调用参考文档-V3 (1).pdf》159–164 页; +开停依据 CTIOT_5GCMP_BA004(添加或取消单独断网,PDF p217-219)。 + +### 查询:GET /api/v3/getApnInfo + +- msisdn/iccid/imsi 至少一项;读取 result.apnInfoList。 +- 查询表格使用 status,示例使用 apnStatus,网关兼容两者;0 表示停用,1 表示正常。 +- 两字段冲突、缺失或不在 0/1 范围时,状态保留为空,不推断为正常。 + +### 开停:GET /5gcmp/openapi/v1/common/singleCutNet + +- 参数 access_number(物联网接入号,必传)+ action(ADD/DEL)。 +- **整卡数据开停语义**,电信侧没有「按 APN 定向」这一维度。 +- action 语义与平台相反,必须转换、禁止透传(见 `convertDxApnOperType`): + - 平台 0(开通数据) → `DEL`(取消单独断网,恢复联网) + - 平台 1(停用数据) → `ADD`(单独断网) +- 忽略调用方携带的 ApnName:预警自动断网、批量数据开停都会固定带 `apnName="CMIOT"`, + 电信卡不含该 APN,若据此拒绝会直接打断主流程。 +- 响应为 XML:`SvcCont > Response > RspCode`,`0000` 表示成功接收; + `GROUP_TRANSACTIONID` 为流水号,透传为 orderNum。 +- 缺返回码、非 `0000`、缺流水号、HTTP 失败均不视为成功,不自动重试以免重复操作。 + +> 注意:`/api/v3/updateApnFunctionStatus` 是另一个接口(单卡 APN 开停,按 APN 定向), +> 与「数据服务开停」业务语义不匹配,v1 不再使用。 +> 历史:2026-10-10 `b3f00a0` 曾把 v1 改成该接口,导致电信数据开停因 APN 名不匹配而全部失败,已回退。 + +## 读取链路 + +智能诊断展示与 APN 开关查询分离: + +- 电信:小程序/Web 后端复用卡状态查询 → 请求代理 → 网关 sim_dx_v1 → /openapi/v1/prodinst/queryCardMainStatus。 +- 网关透传独立 netBlockStatus(0 未断网、1 已断网)和 netBlockTypes,诊断返回 dataServiceSource=network_block、netBlockStatus、netBlockReason。 +- 电信 dataStatus 保持空值,不将断网状态转换成 APN 开关,也不以此驱动 APN 开停按钮;电信卡片排查不展示 APN 开停操作。 +- 未返回有效断网状态时显示 -;已断网时展示全部有效断网原因并去重,不推测原因。 +- 卡主状态查询失败时数据服务模块同步标记失败,不能展示本地旧状态。 +- 电信在线状态继续独立查询 /api/v1/onlineSessionOpen/qryOnlineSataus;APN、IP、IMEI、时间等会话字段不能推断 APN 开启。 +- 电信会话 netModel=1/2 统一为 4G/5G,不再透传数字让前端按移动编码误解释。 +- 移动数据服务仍使用 ec/query/apn-info 的 CMIOT 状态;移动和电信的会话状态都从会话接口获取。 +- 独立 APN 查询 /sim/query/sim-apn-info 仍使用 CM001 /api/v3/getApnInfo,但不再作为电信智能诊断数据服务断网展示的来源。 + +两端对非移动 APN 列表采用保守一致性判定:单个 APN 或所有 APN 的有效状态一致时才给出开/停;空列表、缺名称、缺状态、混合状态均不选第一项代替整体状态,界面无值显示 -。 +这不是多 APN 的业务目标选择规则;若要显示某个专用业务 APN,需进一步明确目标并让查询和操作使用同一配置。 + +## 开停保护与剩余事项 + +- 开停走整卡语义的 singleCutNet,只要求物联网接入号(MSISDN);缺接入号时明确失败。 +- 不将 CMIOT 自动改成 ctnet 或 CTIOT,也不猜选唯一 APN: + 指定 APN 开停需要另接 `/api/v3/updateApnFunctionStatus`,属独立业务契约,未接入。 +- 当前小程序/Web 批次入口仍默认写入 CMIOT;singleCutNet 忽略该字段,不影响执行。 +- 小程序操作预检仍读取本地 data_service_status,可能与实时状态不同;后续应与上游校验统一。 +- Worker 目前依据网关 code=0 判成功并更新本地状态。电信返回流水号表示订单受理,最终生效状态需要后续查单或实时 APN 查询核实,尚未做真实业务验收。 +- 若启停请求发送后网络中断或成功响应缺流水号,结果未确认,不应盲目重试以免重复操作;先核对上游状态。 +- singleCutNet 的 GET/POST 以网关实测为准:文档写 POST/GET,现网 v1 走 GET; + v3 适配器走 POST(`sim_dx_v3.go`),尚未实卡验收。 + +## 验证边界 + +回归测试采用 httptest 本地模拟上游,覆盖:查询字段兼容/缺字段/错误码; +开停的 ADD/DEL 方向、参数编码、XML 解析、缺结果码、缺流水号、非 0000、HTTP 失败保护, +并断言不携带 APN 定向参数。 +测试不对真实卡发起启停,不替代生产通道权限、配置及 Worker 全链路验收。 diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..b53e010 --- /dev/null +++ b/go.mod @@ -0,0 +1,72 @@ +module git.5iot.com/iotlcm-public/iotlcm-upstream-chan + +go 1.25.5 + +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 ( + github.com/BurntSushi/toml v1.5.0 // indirect + github.com/armon/go-metrics v0.4.1 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/clbanning/mxj/v2 v2.7.0 // indirect + github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/emirpasic/gods/v2 v2.0.0-alpha // indirect + github.com/fatih/color v1.18.0 // indirect + github.com/fsnotify/fsnotify v1.9.0 // indirect + 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-viper/mapstructure/v2 v2.4.0 // indirect + 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 + github.com/grokify/html-strip-tags-go v0.1.0 // indirect + github.com/hashicorp/consul/api v1.33.2 // indirect + github.com/hashicorp/errwrap v1.1.0 // indirect + github.com/hashicorp/go-cleanhttp v0.5.2 // indirect + github.com/hashicorp/go-hclog v1.5.0 // indirect + github.com/hashicorp/go-immutable-radix v1.3.1 // indirect + github.com/hashicorp/go-multierror v1.1.1 // indirect + github.com/hashicorp/go-rootcerts v1.0.2 // indirect + github.com/hashicorp/golang-lru v0.5.4 // indirect + github.com/hashicorp/serf v0.10.1 // indirect + github.com/magiconair/properties v1.8.10 // indirect + github.com/mattn/go-colorable v0.1.13 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-runewidth v0.0.16 // indirect + github.com/mitchellh/go-homedir v1.1.0 // indirect + github.com/olekukonko/errors v1.1.0 // indirect + github.com/olekukonko/ll v0.0.9 // indirect + github.com/olekukonko/tablewriter v1.1.0 // indirect + 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 + 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 + go.opentelemetry.io/otel/sdk v1.39.0 // indirect + go.opentelemetry.io/otel/trace v1.39.0 // indirect + go.uber.org/atomic v1.11.0 // indirect + golang.org/x/exp v0.0.0-20260312153236-7ab1446f8b90 // indirect + golang.org/x/net v0.48.0 // indirect + 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 + gopkg.in/yaml.v3 v3.0.1 // indirect + modernc.org/libc v1.22.5 // indirect + modernc.org/mathutil v1.5.0 // indirect + modernc.org/memory v1.5.0 // indirect + modernc.org/sqlite v1.23.1 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..c798b85 --- /dev/null +++ b/go.sum @@ -0,0 +1,342 @@ +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= +github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= +github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= +github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= +github.com/alecthomas/units v0.0.0-20190717042225-c3de453c63f4/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= +github.com/armon/circbuf v0.0.0-20150827004946-bbbad097214e/go.mod h1:3U/XgcO3hCbHZ8TKRvWD2dDTCfh9M9ya+I9JpbB7O8o= +github.com/armon/go-metrics v0.0.0-20180917152333-f0300d1749da/go.mod h1:Q73ZrmVTwzkszR9V5SSuryQ31EELlFMUz1kKyl939pY= +github.com/armon/go-metrics v0.4.1 h1:hR91U9KYmb6bLBYLQjyM+3j+rcd/UhE+G78SFnF8gJA= +github.com/armon/go-metrics v0.4.1/go.mod h1:E6amYzXo6aW1tqzoZGT755KkbgrJsSdpwZ+3JqfkOG4= +github.com/armon/go-radix v0.0.0-20180808171621-7fddfc383310/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8= +github.com/armon/go-radix v1.0.0/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8= +github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q= +github.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+CedLV8= +github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= +github.com/bgentry/speakeasy v0.1.0/go.mod h1:+zsyZBPWlz7T6j88CTgSN5bM796AkVf0kBD4zp0CCIs= +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/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= +github.com/circonus-labs/circonus-gometrics v2.3.1+incompatible/go.mod h1:nmEj6Dob7S7YxXgwXpfOuvO54S+tGdZdw9fuRZt25Ag= +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/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= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78= +github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +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/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= +github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= +github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU= +github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= +github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= +github.com/glebarez/go-sqlite v1.21.2 h1:3a6LFC4sKahUunAmynQKLZceZCOzUthkRkEAl9gAXWo= +github.com/glebarez/go-sqlite v1.21.2/go.mod h1:sfxdZyhQjTM2Wry3gVYWaW072Ri1WMdWJi0k6+3382k= +github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= +github.com/go-kit/kit v0.9.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= +github.com/go-logfmt/logfmt v0.3.0/go.mod h1:Qt1PoO58o5twSAckw1HlFXLmHsOX5/0LbT9GBnD5lWE= +github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V4qmtdjCk= +github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/go-sql-driver/mysql v1.7.1 h1:lUIinVbN1DY0xBg0eMOzmmtGoHwWBbvnWubQUrtU8EI= +github.com/go-sql-driver/mysql v1.7.1/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI= +github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY= +github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs= +github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= +github.com/gogf/gf/contrib/drivers/mysql/v2 v2.10.0 h1:UvqxwinkelKxwdwnKUfdy51/ls4RL7MCeJqAZOVAy0I= +github.com/gogf/gf/contrib/drivers/mysql/v2 v2.10.0/go.mod h1:6v7oGBF9wv59WERJIOJxXmLhkUcxwON3tPYW3AZ7wbY= +github.com/gogf/gf/contrib/drivers/sqlite/v2 v2.10.0 h1:OyAH7Ls2c9Un7CJiAq7G6eY1jWIICRkN8C5SyM94rnY= +github.com/gogf/gf/contrib/drivers/sqlite/v2 v2.10.0/go.mod h1:fwhAMG0qZpeHbbP2JE78rJRfV7eBbu9jXkxTMM1lwyo= +github.com/gogf/gf/contrib/nosql/redis/v2 v2.10.0 h1:N/F9CuDdUZLoM1nVRqrDE/33pDZuhVxpNY4wYdeIaBs= +github.com/gogf/gf/contrib/nosql/redis/v2 v2.10.0/go.mod h1:x6uoJGfZOtirIRQls8xUlYzC6f7T/eULPUa9er368X0= +github.com/gogf/gf/contrib/registry/consul/v2 v2.10.3 h1:h2tRpKTlEkBVnpmWdqrF5l6LmzjheDXAwSpR/zruxPM= +github.com/gogf/gf/contrib/registry/consul/v2 v2.10.3/go.mod h1:9Dk7HM3fXJxQp34CXWZEeHceJZwrMN1G4xLRVAgcdjs= +github.com/gogf/gf/contrib/registry/file/v2 v2.10.0 h1:yy93PGhz2CE3PpF2JSGa2MXJWd4JNHYC5l5NEFCFRV4= +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.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/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.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.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= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26 h1:Xim43kblpZXfIBQsbuBVKCudVG457BR2GZFIz3uw3hQ= +github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26/go.mod h1:dDKJzRmX4S37WGHujM7tX//fmj1uioxKzKxz3lo4HJo= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= +github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/grokify/html-strip-tags-go v0.1.0 h1:03UrQLjAny8xci+R+qjCce/MYnpNXCtgzltlQbOBae4= +github.com/grokify/html-strip-tags-go v0.1.0/go.mod h1:ZdzgfHEzAfz9X6Xe5eBLVblWIxXfYSQ40S/VKrAOGpc= +github.com/hashicorp/consul/api v1.33.2 h1:Q6mE0WZsUTJerlnl9TuXzqrtZ0cKdOCsxcZhj5mKbMs= +github.com/hashicorp/consul/api v1.33.2/go.mod h1:K3yoL/vnIBcQV/25NeMZVokRvPPERiqp2Udtr4xAfhs= +github.com/hashicorp/consul/sdk v0.17.1 h1:LumAh8larSXmXw2wvw/lK5ZALkJ2wK8VRwWMLVV5M5c= +github.com/hashicorp/consul/sdk v0.17.1/go.mod h1:EngiixMhmw9T7wApycq6rDRFXXVUwjjf7HuLiGMH/Sw= +github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= +github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I= +github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= +github.com/hashicorp/go-cleanhttp v0.5.0/go.mod h1:JpRdi6/HCYpAwUzNwuwqhbovhLtngrth3wmdIIUrZ80= +github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ= +github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48= +github.com/hashicorp/go-hclog v1.5.0 h1:bI2ocEMgcVlz55Oj1xZNBsVi900c7II+fWDyV9o+13c= +github.com/hashicorp/go-hclog v1.5.0/go.mod h1:W4Qnvbt70Wk/zYJryRzDRU/4r0kIg0PVHBcfoyhpF5M= +github.com/hashicorp/go-immutable-radix v1.0.0/go.mod h1:0y9vanUI8NX6FsYoO3zeMjhV/C5i9g4Q3DwcSNZ4P60= +github.com/hashicorp/go-immutable-radix v1.3.1 h1:DKHmCUm2hRBK510BaiZlwvpD40f8bJFeZnpfm2KLowc= +github.com/hashicorp/go-immutable-radix v1.3.1/go.mod h1:0y9vanUI8NX6FsYoO3zeMjhV/C5i9g4Q3DwcSNZ4P60= +github.com/hashicorp/go-msgpack v0.5.3/go.mod h1:ahLV/dePpqEmjfWmKiqvPkv/twdG7iPBM1vqhUKIvfM= +github.com/hashicorp/go-msgpack v0.5.5 h1:i9R9JSrqIz0QVLz3sz+i3YJdT7TTSLcfLLzJi9aZTuI= +github.com/hashicorp/go-msgpack v0.5.5/go.mod h1:ahLV/dePpqEmjfWmKiqvPkv/twdG7iPBM1vqhUKIvfM= +github.com/hashicorp/go-multierror v1.0.0/go.mod h1:dHtQlpGsu+cZNNAkkCN/P3hoUDHhCYQXV3UM06sGGrk= +github.com/hashicorp/go-multierror v1.1.0/go.mod h1:spPvp8C1qA32ftKqdAHm4hHTbPw+vmowP0z+KUhOZdA= +github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo= +github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM= +github.com/hashicorp/go-retryablehttp v0.5.3/go.mod h1:9B5zBasrRhHXnJnui7y6sL7es7NDiJgTc6Er0maI1Xs= +github.com/hashicorp/go-rootcerts v1.0.2 h1:jzhAVGtqPKbwpyCPELlgNWhE1znq+qwJtW5Oi2viEzc= +github.com/hashicorp/go-rootcerts v1.0.2/go.mod h1:pqUvnprVnM5bf7AOirdbb01K4ccR319Vf4pU3K5EGc8= +github.com/hashicorp/go-sockaddr v1.0.0/go.mod h1:7Xibr9yA9JjQq1JpNB2Vw7kxv8xerXegt+ozgdvDeDU= +github.com/hashicorp/go-sockaddr v1.0.2 h1:ztczhD1jLxIRjVejw8gFomI1BQZOe2WoVOu0SyteCQc= +github.com/hashicorp/go-sockaddr v1.0.2/go.mod h1:rB4wwRAUzs07qva3c5SdrY/NEtAUjGlgmH/UkBUC97A= +github.com/hashicorp/go-syslog v1.0.0/go.mod h1:qPfqrKkXGihmCqbJM2mZgkZGvKG1dFdvsLplgctolz4= +github.com/hashicorp/go-uuid v1.0.0/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= +github.com/hashicorp/go-uuid v1.0.1/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= +github.com/hashicorp/go-uuid v1.0.3 h1:2gKiV6YVmrJ1i2CKKa9obLvRieoRGviZFL26PcT/Co8= +github.com/hashicorp/go-uuid v1.0.3/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= +github.com/hashicorp/go-version v1.2.1 h1:zEfKbn2+PDgroKdiOzqiE8rsmLqU2uwi5PB5pBJ3TkI= +github.com/hashicorp/go-version v1.2.1/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA= +github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= +github.com/hashicorp/golang-lru v0.5.4 h1:YDjusn29QI/Das2iO9M0BHnIbxPeyuCHsjMW+lJfyTc= +github.com/hashicorp/golang-lru v0.5.4/go.mod h1:iADmTwqILo4mZ8BN3D2Q6+9jd8WM5uGBxy+E8yxSoD4= +github.com/hashicorp/logutils v1.0.0/go.mod h1:QIAnNjmIWmVIIkWDTG1z5v++HQmx9WQRO+LraFDTW64= +github.com/hashicorp/mdns v1.0.4/go.mod h1:mtBihi+LeNXGtG8L9dX59gAEa12BDtBQSp4v/YAJqrc= +github.com/hashicorp/memberlist v0.5.0 h1:EtYPN8DpAURiapus508I4n9CzHs2W+8NZGbmmR/prTM= +github.com/hashicorp/memberlist v0.5.0/go.mod h1:yvyXLpo0QaGE59Y7hDTsTzDD25JYBZ4mHgHUZ8lrOI0= +github.com/hashicorp/serf v0.10.1 h1:Z1H2J60yRKvfDYAOZLd2MU0ND4AH/WDz7xYHDWQsIPY= +github.com/hashicorp/serf v0.10.1/go.mod h1:yL2t6BqATOLGc5HF7qbFkTfXoPIY0WZdWHfEvMqbG+4= +github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU= +github.com/json-iterator/go v1.1.9/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= +github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w= +github.com/klauspost/cpuid/v2 v2.2.3 h1:sxCkb+qR91z4vsqw4vGGZlDgPz3G7gjaLyK3V8y70BU= +github.com/klauspost/cpuid/v2 v2.2.3/go.mod h1:RVVoqg1df56z8g3pUjL/3lE5UfnlrJX8tyFgg4nqhuY= +github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= +github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc= +github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/magiconair/properties v1.8.10 h1:s31yESBquKXCV9a/ScB3ESkOjUYYv+X0rg8SYxI99mE= +github.com/magiconair/properties v1.8.10/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= +github.com/mattn/go-colorable v0.0.9/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU= +github.com/mattn/go-colorable v0.1.4/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE= +github.com/mattn/go-colorable v0.1.6/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= +github.com/mattn/go-colorable v0.1.9/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= +github.com/mattn/go-colorable v0.1.12/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4= +github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= +github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= +github.com/mattn/go-isatty v0.0.3/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4= +github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s= +github.com/mattn/go-isatty v0.0.11/go.mod h1:PhnuNfih5lzO57/f3n+odYbM4JtupLOxQOAqxQCu2WE= +github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU= +github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94= +github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= +github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0= +github.com/miekg/dns v1.1.26/go.mod h1:bPDLeHnStXmXAq1m/Ch/hvfNHr14JKNPMBo3VZKjuso= +github.com/miekg/dns v1.1.41 h1:WMszZWJG0XmzbK9FEmzH2TVcqYzFesusSIB41b8KHxY= +github.com/miekg/dns v1.1.41/go.mod h1:p6aan82bvRIyn+zDIv9xYNUpwa73JcSh9BKwknJysuI= +github.com/mitchellh/cli v1.1.0/go.mod h1:xcISNoH86gajksDmfB23e/pu+B+GeFRMYmoHXxx3xhI= +github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y= +github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= +github.com/mitchellh/mapstructure v0.0.0-20160808181253-ca63d7c062ee/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= +github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= +github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= +github.com/olekukonko/errors v1.1.0 h1:RNuGIh15QdDenh+hNvKrJkmxxjV4hcS50Db478Ou5sM= +github.com/olekukonko/errors v1.1.0/go.mod h1:ppzxA5jBKcO1vIpCXQ9ZqgDh8iwODz6OXIGKU8r5m4Y= +github.com/olekukonko/ll v0.0.9 h1:Y+1YqDfVkqMWuEQMclsF9HUR5+a82+dxJuL1HHSRpxI= +github.com/olekukonko/ll v0.0.9/go.mod h1:En+sEW0JNETl26+K8eZ6/W4UQ7CYSrrgg/EdIYT2H8g= +github.com/olekukonko/tablewriter v1.1.0 h1:N0LHrshF4T39KvI96fn6GT8HEjXRXYNDrDjKFDB7RIY= +github.com/olekukonko/tablewriter v1.1.0/go.mod h1:5c+EBPeSqvXnLLgkm9isDdzR3wjfBkHR9Nhfp3NWrzo= +github.com/pascaldekloe/goe v0.0.0-20180627143212-57f6aae5913c/go.mod h1:lzWF7FIEvWOWxwDKqyGYQf6ZUaNfKdP144TG7ZOy1lc= +github.com/pascaldekloe/goe v0.1.0 h1:cBOtyMzM9HTpWjXfbbunk26uA6nG3a8n06Wieeh0MwY= +github.com/pascaldekloe/goe v0.1.0/go.mod h1:lzWF7FIEvWOWxwDKqyGYQf6ZUaNfKdP144TG7ZOy1lc= +github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/posener/complete v1.1.1/go.mod h1:em0nMJCgc9GFtwrmVmEMR/ZL6WyhyjMBndrE9hABlRI= +github.com/posener/complete v1.2.3/go.mod h1:WZIdtGGp+qx0sLrYKtIRAruyNpv6hFCicSgv7Sy7s/s= +github.com/prometheus/client_golang v0.9.1/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw= +github.com/prometheus/client_golang v1.0.0/go.mod h1:db9x61etRT2tGnBNRi70OPL5FsnadC4Ky3P0J6CfImo= +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.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= +github.com/prometheus/procfs v0.0.0-20181005140218-185b4288413d/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk= +github.com/prometheus/procfs v0.0.2/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsTZCD3I8kEA= +github.com/prometheus/procfs v0.0.8/go.mod h1:7Qr8sr6344vo1JqZ6HhLceV9o3AJ1Ff+GxbHq6oeK9A= +github.com/redis/go-redis/v9 v9.18.0 h1:pMkxYPkEbMPwRdenAzUNyFNrDgHx9U+DrBabWNfSRQs= +github.com/redis/go-redis/v9 v9.18.0/go.mod h1:k3ufPphLU5YXwNTUcCRXGxUoF1fqxnhFQmscfkCoDA0= +github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= +github.com/ryanuber/columnize v0.0.0-20160712163229-9b3edd62028f/go.mod h1:sm1tb6uqfes/u+d4ooFouqFdy9/2g9QGwK3SQygK0Ts= +github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529 h1:nn5Wsu0esKSJiIVhscUtVbo7ada43DJhG55ua/hjS5I= +github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529/go.mod h1:DxrIzT+xaE7yg65j358z/aeFdxmN0P9QXhEzd20vsDc= +github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo= +github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.5.0 h1:1zr/of2m5FGMsad5YfcqgdqdWrIhu+EBEJRhR1U7z/c= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= +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/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= +go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= +go.opentelemetry.io/otel v1.39.0 h1:8yPrr/S0ND9QEfTfdP9V+SiwT4E0G7Y5MO7p85nis48= +go.opentelemetry.io/otel v1.39.0/go.mod h1:kLlFTywNWrFyEdH0oj2xK0bFYZtHRYUdv1NklR/tgc8= +go.opentelemetry.io/otel/metric v1.39.0 h1:d1UzonvEZriVfpNKEVmHXbdf909uGTOQjA0HF0Ls5Q0= +go.opentelemetry.io/otel/metric v1.39.0/go.mod h1:jrZSWL33sD7bBxg1xjrqyDjnuzTUB0x1nBERXd7Ftcs= +go.opentelemetry.io/otel/sdk v1.39.0 h1:nMLYcjVsvdui1B/4FRkwjzoRVsMK8uL/cj0OyhKzt18= +go.opentelemetry.io/otel/sdk v1.39.0/go.mod h1:vDojkC4/jsTJsE+kh+LXYQlbL8CgrEcwmt1ENZszdJE= +go.opentelemetry.io/otel/sdk/metric v1.39.0 h1:cXMVVFVgsIf2YL6QkRF4Urbr/aMInf+2WKg+sEJTtB8= +go.opentelemetry.io/otel/sdk/metric v1.39.0/go.mod h1:xq9HEVH7qeX69/JnwEfp6fVq5wosJsY1mt4lLfYdVew= +go.opentelemetry.io/otel/trace v1.39.0 h1:2d2vfpEDmCJ5zVYz7ijaJdOF59xLomrvj7bjt6/qCJI= +go.opentelemetry.io/otel/trace v1.39.0/go.mod h1:88w4/PnZSazkGzz/w84VHpQafiU4EtqqlVdxWy+rNOA= +go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE= +go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +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/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/net v0.0.0-20181114220301-adae6a3d119a/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +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-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/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= +golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +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-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-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= +golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +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-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= +golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220503163025-988cb79eb6c6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220728004956-3c1f35247d10/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk= +golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +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-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/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.79.3 h1:sybAEdRIEtvcD68Gx7dmnwjZKlyfuc61Dyo9pGXXkKE= +google.golang.org/grpc v1.79.3/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ= +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= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +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= +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= +modernc.org/mathutil v1.5.0/go.mod h1:mZW8CKdRPY1v87qxC/wUdX5O1qDzXMP5TH3wjfpga6E= +modernc.org/memory v1.5.0 h1:N+/8c5rE6EqugZwHii4IFsaJ7MUhoWX07J5tC/iI5Ds= +modernc.org/memory v1.5.0/go.mod h1:PkUhL0Mugw21sHPeskwZW4D6VscE/GQJOnIpCnW6pSU= +modernc.org/sqlite v1.23.1 h1:nrSBg4aRQQwq59JpvGEQ15tNxoO5pX/kUjcRNwSAGQM= +modernc.org/sqlite v1.23.1/go.mod h1:OrDj17Mggn6MhE+iPbBNf7RGKODDE9NFT0f3EwDzJqk= diff --git a/hack/hack-cli.mk b/hack/hack-cli.mk new file mode 100644 index 0000000..f4e2ad2 --- /dev/null +++ b/hack/hack-cli.mk @@ -0,0 +1,20 @@ + +# Install/Update to the latest CLI tool. +.PHONY: cli +cli: + @set -e; \ + wget -O gf \ + https://github.com/gogf/gf/releases/latest/download/gf_$(shell go env GOOS)_$(shell go env GOARCH) && \ + chmod +x gf && \ + ./gf install -y && \ + rm ./gf + + +# Check and install CLI tool. +.PHONY: cli.install +cli.install: + @set -e; \ + gf -v > /dev/null 2>&1 || if [[ "$?" -ne "0" ]]; then \ + echo "GoFame CLI is not installed, start proceeding auto installation..."; \ + make cli; \ + fi; \ No newline at end of file diff --git a/hack/hack.mk b/hack/hack.mk new file mode 100644 index 0000000..119acb9 --- /dev/null +++ b/hack/hack.mk @@ -0,0 +1,99 @@ +.DEFAULT_GOAL := build + +# Update GoFrame and its CLI to latest stable version. +.PHONY: up +up: cli.install + @gf up -a + +# Build binary using configuration from hack/config.yaml. +.PHONY: build +build: cli.install + @gf build -ew + +# Parse api and generate controller/sdk. +.PHONY: ctrl +ctrl: cli.install + @gf gen ctrl -m + +# Generate Go files for DAO/DO/Entity. +.PHONY: dao +dao: cli.install + @gf gen dao + +# Parse current project go files and generate enums go file. +.PHONY: enums +enums: cli.install + @gf gen enums + +# Generate Go files for Service. +.PHONY: service +service: cli.install + @gf gen service + + +# Build docker image. +.PHONY: image +image: cli.install + $(eval _TAG = $(shell git rev-parse --short HEAD)) +ifneq (, $(shell git status --porcelain 2>/dev/null)) + $(eval _TAG = $(_TAG).dirty) +endif + $(eval _TAG = $(if ${TAG}, ${TAG}, $(_TAG))) + $(eval _PUSH = $(if ${PUSH}, ${PUSH}, )) + @gf docker ${_PUSH} -tn $(DOCKER_NAME):${_TAG}; + + +# Build docker image and automatically push to docker repo. +.PHONY: image.push +image.push: cli.install + @make image PUSH=-p; + + +# Deploy image and yaml to current kubectl environment. +.PHONY: deploy +deploy: cli.install + $(eval _TAG = $(if ${TAG}, ${TAG}, develop)) + + @set -e; \ + mkdir -p $(ROOT_DIR)/temp/kustomize;\ + cd $(ROOT_DIR)/manifest/deploy/kustomize/overlays/${_ENV};\ + kustomize build > $(ROOT_DIR)/temp/kustomize.yaml;\ + kubectl apply -f $(ROOT_DIR)/temp/kustomize.yaml; \ + if [ $(DEPLOY_NAME) != "" ]; then \ + kubectl patch -n $(NAMESPACE) deployment/$(DEPLOY_NAME) -p "{\"spec\":{\"template\":{\"metadata\":{\"labels\":{\"date\":\"$(shell date +%s)\"}}}}}"; \ + fi; + + +# Parsing protobuf files and generating go files. +.PHONY: pb +pb: cli.install + @gf gen pb + +# Generate protobuf files for database tables. +.PHONY: pbentity +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) +export CommitID=$(shell git rev-parse HEAD) +export NOWTIME=$(shell date "+%Y%m%d%H%M%S") +export HasImages=$(shell docker images | grep docker_iotlcmupstreamchan:v0 | grep -v docker_iotlcmupstreamchan:v0.) +.PHONY: docker_iotlcmupstreamchan +docker_iotlcmupstreamchan: + if [ -n "${HasImages}" ]; then \ + docker tag docker_iotlcmupstreamchan:v0 ${REGISTRY}/iotlcm/iotlcmupstreamchansvr:v0 \ + 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} . + # docker save -o docker_iotlcmupstreamchan.tar docker_iotlcmupstreamchan:v0 + +dockerbuild: docker_iotlcmupstreamchan + echo "docker build success" +dockerpush: \ No newline at end of file diff --git a/internal/cmd/cmd.go b/internal/cmd/cmd.go new file mode 100644 index 0000000..ac01f59 --- /dev/null +++ b/internal/cmd/cmd.go @@ -0,0 +1,232 @@ +package cmd + +import ( + "context" + "fmt" + "git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/controller/cardquery" + "git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/controller/infopb" + "git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/controller/msgcallpb" + "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" + + "git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/pub/xjpubconsts" + "git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/pub/xjpubservice" + "github.com/gogf/gf/contrib/rpc/grpcx/v2" + "github.com/gogf/gf/v2/frame/g" + "github.com/gogf/gf/v2/net/ghttp" + "github.com/gogf/gf/v2/net/gsvc" + "github.com/gogf/gf/v2/os/gcmd" + "github.com/gogf/gf/v2/util/gconv" + "google.golang.org/grpc" + "google.golang.org/grpc/reflection" +) + +const ( + ServiceNamePrefix = "iotlcmupstreamchan" +) + +var ( + gGrpcPort = 8700 + gAccessIP = "0.0.0.0" +) +var ( + Main = gcmd.Command{ + Name: "main", + Usage: "main", + Brief: "start http server", + Func: func(ctx context.Context, parser *gcmd.Parser) (err error) { + s := g.Server() + gGrpcPort = gconv.Int(gcmd.GetOptWithEnv("grpcport")) + gAccessIP = gconv.String(gcmd.GetOptWithEnv("accessip")) + + if len(gAccessIP) == 0 { + gAccessIP = "0.0.0.0" + } + if gGrpcPort == 0 { + gGrpcPort = 8110 + } + nodeName := fmt.Sprintf("%s", ServiceNamePrefix) + + xjpubservice.Info().SetInfoName(xjpubconsts.ServiceTypeAccess, fmt.Sprintf("%s-http-svc", ServiceNamePrefix), "上游运营商渠道服务", "上游运营商渠道服务") + xjpubservice.Info().AddInfoData("grpcport", gGrpcPort) + xjpubservice.Info().AddInfoData("accessip", gAccessIP) + xjpubservice.Info().AddInfoData("nodeName", nodeName) + + xjpubservice.InfoRpc().SetInfoName(xjpubconsts.ServiceTypeAccess, fmt.Sprintf("%s-grpc-svc", ServiceNamePrefix), "上游运营商渠道服务", "上游运营商渠道服务") + xjpubservice.InfoRpc().AddInfoData("grpcport", gGrpcPort) + xjpubservice.InfoRpc().AddInfoData("accessip", gAccessIP) + xjpubservice.InfoRpc().AddInfoData("nodeName", nodeName) + bindHTTPRoutes(s) + err = service.ChannelConfig().Start(ctx) + if err != nil { + return err + } + + err = service.UpstreamAPI().Start(ctx) + if err != nil { + return err + } + + go func() { + c := grpcx.Server.NewConfig() + c.Endpoints = []string{fmt.Sprintf("%s:%d", gAccessIP, gGrpcPort)} + c.Options = append(c.Options, []grpc.ServerOption{ + grpcx.Server.ChainUnary( + grpcx.Server.UnaryValidate, + ), + }...) + sGRPC := grpcx.Server.New(c) + sGRPC.Service(&gsvc.LocalService{ + Name: fmt.Sprintf("%s-grpc-svc", ServiceNamePrefix), + Endpoints: gsvc.Endpoints{gsvc.NewEndpoint(fmt.Sprintf("%s:%d", gAccessIP, gGrpcPort))}, + Metadata: gsvc.Metadata{ + gsvc.MDProtocol: "grpc", + gsvc.MDInsecure: true, + }, + }) + infopb.Register(sGRPC) + msgcallpb.Register(sGRPC) + reflection.Register(sGRPC.Server) + g.Log().Infof(ctx, "GRPC Server启动, listenPort:%d", gGrpcPort) + sGRPC.Run() + g.Log().Infof(ctx, "GRPC Server堵塞运行结束") + }() + + s.Run() + service.UpstreamAPI().Stop(ctx) + service.ChannelConfig().Stop(ctx) + return nil + }, + } +) + +type sharedTokenGetter interface { + GetSharedToken(ctx context.Context) (sharedtoken.OneLinkToken, error) +} + +func bindHTTPRoutes(s *ghttp.Server) { + s.Group("/", func(group *ghttp.RouterGroup) { + group.Middleware(ghttp.MiddlewareHandlerResponse) + group.Bind( + cardquery.NewV1(), + ) + // Internal control endpoint used to apply a single channel change + // without waiting for the periodic full reload. + group.POST("/internal/channel/test", func(r *ghttp.Request) { + input := new(model.ChannelTestInput) + if err := r.Parse(input); err != nil { + r.SetError(err) + return + } + output, err := service.UpstreamAPI().TestChannel(r.Context(), input) + if err != nil { + r.SetError(err) + return + } + r.Response.WriteJson(g.Map{"code": 0, "message": "success", "data": output}) + }) + group.POST("/internal/channel/refresh", func(r *ghttp.Request) { + chanKey := r.GetQuery("chanKey").String() + if chanKey == "" { + chanKey = r.Get("chanKey").String() + } + if err := service.UpstreamAPI().RefreshChannel(r.Context(), chanKey); err != nil { + r.SetError(err) + return + } + r.Response.WriteJson(g.Map{ + "code": 0, + "message": "success", + "data": g.Map{"chanKey": chanKey, "refreshed": true}, + }) + }) + group.POST("/internal/channel/remove", func(r *ghttp.Request) { + chanKey := r.GetQuery("chanKey").String() + if chanKey == "" { + chanKey = r.Get("chanKey").String() + } + if err := service.UpstreamAPI().RemoveChannel(r.Context(), chanKey); err != nil { + r.SetError(err) + return + } + r.Response.WriteJson(g.Map{ + "code": 0, + "message": "success", + "data": g.Map{"chanKey": chanKey, "removed": true}, + }) + }) + group.POST("/internal/channel/shared-token", func(r *ghttp.Request) { + chanKey := r.Get("chanKey").String() + if chanKey == "" { + r.Response.WriteJson(g.Map{ + "code": 50, + "message": "chanKey不能为空", + }) + return + } + channelCache, ok := service.UpstreamAPI().GetChan(chanKey) + if !ok || channelCache.Instance == nil { + if err := service.UpstreamAPI().RefreshChannel(r.Context(), chanKey); err != nil { + g.Log().Errorf(r.Context(), "shared-token refresh channel failed, chanKey: %s, err: %+v", chanKey, err) + r.Response.WriteJson(g.Map{ + "code": 50, + "message": "通道实例化失败", + }) + return + } + channelCache, ok = service.UpstreamAPI().GetChan(chanKey) + if !ok || channelCache.Instance == nil { + r.Response.WriteJson(g.Map{ + "code": 50, + "message": "通道不存在", + }) + return + } + } + provider, ok := channelCache.Instance.(sharedTokenGetter) + if !ok { + r.Response.WriteJson(g.Map{ + "code": 50, + "message": "该通道不支持共享Token", + }) + return + } + tok, err := provider.GetSharedToken(r.Context()) + if err != nil { + g.Log().Errorf(r.Context(), "shared-token get token failed, chanKey: %s, err: %+v", chanKey, err) + r.Response.WriteJson(g.Map{ + "code": 50, + "message": "获取共享Token失败", + }) + return + } + if tok.Value == "" { + r.Response.WriteJson(g.Map{ + "code": 50, + "message": "该通道尚未获取共享Token", + }) + return + } + chanAlias := "" + if channelConf, ok := service.ChannelConfig().GetChannel(chanKey); ok && channelConf.Channel != nil { + chanAlias = channelConf.Channel.ChanAlias + } + tokenExpire := "" + if !tok.ExpiresAt.IsZero() { + // Go time layouts use the reference date, not PHP-style format tokens. + tokenExpire = tok.ExpiresAt.Format("2006-01-02 15:04:05") + } + r.Response.WriteJson(g.Map{ + "code": 0, + "message": "success", + "data": g.Map{ + "chanAlias": chanAlias, + "chanKey": chanKey, + "accessToken": tok.Value, + "accessTokenExpire": tokenExpire, + }, + }) + }) + }) +} diff --git a/internal/cmd/http_test.go b/internal/cmd/http_test.go new file mode 100644 index 0000000..f469ff4 --- /dev/null +++ b/internal/cmd/http_test.go @@ -0,0 +1,68 @@ +package cmd + +import ( + "fmt" + "net/http" + "strings" + "testing" + + "github.com/gogf/gf/v2/frame/g" + "github.com/gogf/gf/v2/net/ghttp" + "github.com/gogf/gf/v2/os/gcfg" +) + +func TestInternalChannelTestWithoutTokenReachesRequestValidation(t *testing.T) { + oldAdapter := g.Cfg().GetAdapter() + adapter, err := gcfg.NewAdapterContent(`{"internal":{}}`) + if err != nil { + t.Fatal(err) + } + g.Cfg().SetAdapter(adapter) + t.Cleanup(func() { g.Cfg().SetAdapter(oldAdapter) }) + + 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() }) + response, err := http.Post(fmt.Sprintf("http://127.0.0.1:%d/internal/channel/test", server.GetListenedPort()), "application/json", strings.NewReader(`{}`)) + if err != nil { + t.Fatal(err) + } + defer response.Body.Close() + if response.StatusCode == http.StatusForbidden { + t.Fatalf("request was rejected before existing parameter validation: status=%d", response.StatusCode) + } +} + +func TestBindHTTPRoutesRegistersChannelControlRoutes(t *testing.T) { + server := ghttp.GetServer("chan-channel-control-route-test") + server.SetAddr("127.0.0.1:0") + bindHTTPRoutes(server) + if err := server.Start(); err != nil { + t.Fatalf("start HTTP server: %v", err) + } + t.Cleanup(func() { + if err := server.Shutdown(); err != nil { + t.Errorf("shutdown HTTP server: %v", err) + } + }) + + want := map[string]bool{ + "/internal/channel/test": 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") { + want[route.Route] = true + } + } + for route, found := range want { + if !found { + t.Fatalf("POST %s route was not registered: %+v", route, server.GetRoutes()) + } + } +} diff --git a/internal/cmd/shared_token_gate_test.go b/internal/cmd/shared_token_gate_test.go new file mode 100644 index 0000000..ef2e341 --- /dev/null +++ b/internal/cmd/shared_token_gate_test.go @@ -0,0 +1,84 @@ +package cmd + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "testing" + + _ "git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/logic/channelconfig" + "git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/logic/upstreamapi" + "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/net/ghttp" +) + +type sharedTokenGateConfig struct { + service.IChannelConfig + config *model.SimChannelConfigItemCache +} + +func (c *sharedTokenGateConfig) RefreshChannel(context.Context, string) error { return nil } +func (c *sharedTokenGateConfig) GetChannel(string) (*model.SimChannelConfigItemCache, bool) { + return c.config, true +} + +type sharedTokenGateTemplate struct{ calls int } + +func (f *sharedTokenGateTemplate) NewTemp(context.Context, *model.SimChannelConfigItemCache) model.SimCardChannelI { + f.calls++ + return nil +} + +func TestSharedTokenCannotCreateIneligibleChannel(t *testing.T) { + // The endpoint uses the real RefreshChannel implementation; no database or carrier calls. + oldAPI := service.UpstreamAPI() + t.Cleanup(func() { service.RegisterUpstreamAPI(oldAPI) }) + oldConfig := service.ChannelConfig() + config := &sharedTokenGateConfig{} + service.RegisterChannelConfig(config) + t.Cleanup(func() { service.RegisterChannelConfig(oldConfig) }) + api := upstreamapi.NewUpstreamAPI() + service.RegisterUpstreamAPI(api) + factory := &sharedTokenGateTemplate{} + api.RegisterTemp("gate_test", factory) + 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() }) + one, unexpected := 1, 2 + for _, value := range []*int{nil, &one, &unexpected} { + config.config = &model.SimChannelConfigItemCache{ + Channel: &entity.TSimChannels{ChanKey: "gate_channel", TempKey: "gate_test"}, + Template: &entity.TSimChannelTemplate{TempKey: "gate_test"}, + IsNormal: value, + } + resp, err := http.Post(fmt.Sprintf("http://127.0.0.1:%d/internal/channel/shared-token?chanKey=gate_channel", server.GetListenedPort()), "", nil) + if err != nil { + t.Fatal(err) + } + var body struct { + Code int `json:"code"` + } + err = json.NewDecoder(resp.Body).Decode(&body) + resp.Body.Close() + if err != nil { + t.Fatal(err) + } + if body.Code != 50 { + t.Fatalf("unexpected response: %+v", body) + } + if factory.calls != 0 { + t.Fatal("shared-token endpoint bypassed the normal gate") + } + if _, ok := api.GetChan("gate_channel"); ok { + t.Fatal("shared-token endpoint cached an ineligible instance") + } + } +} diff --git a/internal/consts/consts.go b/internal/consts/consts.go new file mode 100644 index 0000000..7babe48 --- /dev/null +++ b/internal/consts/consts.go @@ -0,0 +1,28 @@ +package consts + +const ( + ApiStatusSuccess = 0 + ApiStatusError = 9999 + ApiInvokeException = 5000 +) + +const ( + CardSessionStatusOnline = "0" + CardSessionStatusOffline = "-1" + CardSessionStatusUnknown = "-2" +) + +const ( + CardApnStatusPaused = "0" + CardApnStatusNormal = "1" +) + +const ( + CardApnOperTypeOpen = "0" + CardApnOperTypeStop = "1" +) + +const ( + CardStatusChangeOperTypeApplyStop = "0" + CardStatusChangeOperTypeApplyResume = "1" +) diff --git a/internal/controller/cardquery/cardquery.go b/internal/controller/cardquery/cardquery.go new file mode 100644 index 0000000..a6943c4 --- /dev/null +++ b/internal/controller/cardquery/cardquery.go @@ -0,0 +1,5 @@ +// ================================================================================= +// This is auto-generated by GoFrame CLI tool only once. Fill this file as you wish. +// ================================================================================= + +package cardquery diff --git a/internal/controller/cardquery/cardquery_new.go b/internal/controller/cardquery/cardquery_new.go new file mode 100644 index 0000000..b63ac65 --- /dev/null +++ b/internal/controller/cardquery/cardquery_new.go @@ -0,0 +1,15 @@ +// ================================================================================= +// This is auto-generated by GoFrame CLI tool only once. Fill this file as you wish. +// ================================================================================= + +package cardquery + +import ( + "git.5iot.com/iotlcm-public/iotlcm-upstream-chan/api/cardquery" +) + +type ControllerV1 struct{} + +func NewV1() cardquery.ICardqueryV1 { + return &ControllerV1{} +} diff --git a/internal/controller/cardquery/cardquery_v1_simcard.go b/internal/controller/cardquery/cardquery_v1_simcard.go new file mode 100644 index 0000000..bcb61b1 --- /dev/null +++ b/internal/controller/cardquery/cardquery_v1_simcard.go @@ -0,0 +1,160 @@ +package cardquery + +import ( + "context" + + v1 "git.5iot.com/iotlcm-public/iotlcm-upstream-chan/api/cardquery/v1" + "git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/service" +) + +func (c *ControllerV1) SimBasicInfo(ctx context.Context, req *v1.SimBasicInfoReq) (res *v1.SimBasicInfoRes, err error) { + output, err := service.UpstreamAPI().QuerySimBasicInfo(ctx, &req.SimBasicInfoInput) + if err != nil { + return nil, err + } + return &output, nil +} + +func (c *ControllerV1) SimCardStatus(ctx context.Context, req *v1.SimCardStatusReq) (res *v1.SimCardStatusRes, err error) { + output, err := service.UpstreamAPI().QuerySimCardStatus(ctx, &req.SimCardStatusInput) + if err != nil { + return nil, err + } + return &output, nil +} + +func (c *ControllerV1) SimStopReason(ctx context.Context, req *v1.SimStopReasonReq) (res *v1.SimStopReasonRes, err error) { + output, err := service.UpstreamAPI().QuerySimStopReason(ctx, &req.SimStopReasonInput) + if err != nil { + return nil, err + } + return &output, nil +} + +func (c *ControllerV1) SimFrozenState(ctx context.Context, req *v1.SimFrozenStateReq) (res *v1.SimFrozenStateRes, err error) { + output, err := service.UpstreamAPI().QuerySimFrozenState(ctx, &req.SimFrozenStateInput) + if err != nil { + return nil, err + } + return &output, nil +} + +func (c *ControllerV1) SimCardStatusBatch(ctx context.Context, req *v1.SimCardStatusBatchReq) (res *v1.SimCardStatusBatchRes, err error) { + output, err := service.UpstreamAPI().QuerySimCardStatusBatch(ctx, &req.SimCardStatusInput) + if err != nil { + return nil, err + } + return &output, nil +} + +func (c *ControllerV1) SimCardBindStatus(ctx context.Context, req *v1.SimCardBindStatusReq) (res *v1.SimCardBindStatusRes, err error) { + output, err := service.UpstreamAPI().QuerySimCardBindStatus(ctx, &req.SimCardBindStatusInput) + if err != nil { + return nil, err + } + return &output, nil +} + +func (c *ControllerV1) SimDeviceStatus(ctx context.Context, req *v1.SimDeviceStatusReq) (res *v1.SimDeviceStatusRes, err error) { + output, err := service.UpstreamAPI().QuerySimDeviceStatus(ctx, &req.SimDeviceStatusInput) + if err != nil { + return nil, err + } + return &output, nil +} + +func (c *ControllerV1) SimRegionLimitStatus(ctx context.Context, req *v1.SimRegionLimitStatusReq) (res *v1.SimRegionLimitStatusRes, err error) { + output, err := service.UpstreamAPI().QuerySimRegionLimitStatus(ctx, &req.SimRegionLimitStatusInput) + if err != nil { + return nil, err + } + return &output, nil +} + +func (c *ControllerV1) SimSession(ctx context.Context, req *v1.SimSessionReq) (res *v1.SimSessionRes, err error) { + output, err := service.UpstreamAPI().QuerySimSession(ctx, &req.SimSessionInput) + if err != nil { + return nil, err + } + return &output, nil +} + +func (c *ControllerV1) SimImei(ctx context.Context, req *v1.SimImeiReq) (res *v1.SimImeiRes, err error) { + output, err := service.UpstreamAPI().QuerySimImei(ctx, &req.SimImeiInput) + if err != nil { + return nil, err + } + return &output, nil +} + +func (c *ControllerV1) SimDataUsage(ctx context.Context, req *v1.SimDataUsageReq) (res *v1.SimDataUsageRes, err error) { + output, err := service.UpstreamAPI().QuerySimDataUsage(ctx, &req.SimDataUsageInput) + if err != nil { + return nil, err + } + return &output, nil +} + +func (c *ControllerV1) SimDataDiagnosis(ctx context.Context, req *v1.SimDataDiagnosisReq) (res *v1.SimDataDiagnosisRes, err error) { + output, err := service.UpstreamAPI().QuerySimDataDiagnosis(ctx, &req.SimDataDiagnosisInput) + if err != nil { + return nil, err + } + return &output, nil +} + +func (c *ControllerV1) SimDataUsageMonthly(ctx context.Context, req *v1.SimDataUsageMonthlyReq) (res *v1.SimDataUsageMonthlyRes, err error) { + output, err := service.UpstreamAPI().QuerySimDataUsageMonthly(ctx, &req.SimDataUsageMonthlyInput) + if err != nil { + return nil, err + } + return &output, nil +} + +func (c *ControllerV1) SimDataUsageMonthlyBatch(ctx context.Context, req *v1.SimDataUsageMonthlyBatchReq) (res *v1.SimDataUsageMonthlyBatchRes, err error) { + output, err := service.UpstreamAPI().QuerySimDataUsageMonthlyBatch(ctx, &req.SimDataUsageMonthlyInput) + if err != nil { + return nil, err + } + return &output, nil +} + +func (c *ControllerV1) SimDataUsageDaily(ctx context.Context, req *v1.SimDataUsageDailyReq) (res *v1.SimDataUsageDailyRes, err error) { + output, err := service.UpstreamAPI().QuerySimDataUsageDaily(ctx, &req.SimDataUsageDailyInput) + if err != nil { + return nil, err + } + return &output, nil +} + +func (c *ControllerV1) SimDataUsageDailyBatch(ctx context.Context, req *v1.SimDataUsageDailyBatchReq) (res *v1.SimDataUsageDailyBatchRes, err error) { + output, err := service.UpstreamAPI().QuerySimDataUsageDailyBatch(ctx, &req.SimDataUsageDailyInput) + if err != nil { + return nil, err + } + return &output, nil +} + +func (c *ControllerV1) SimApnInfo(ctx context.Context, req *v1.SimApnInfoReq) (res *v1.SimApnInfoRes, err error) { + output, err := service.UpstreamAPI().QuerySimApnInfo(ctx, &req.SimApnInfoInput) + if err != nil { + return nil, err + } + return &output, nil +} + +func (c *ControllerV1) SimStatusChange(ctx context.Context, req *v1.SimStatusChangeReq) (res *v1.SimStatusChangeRes, err error) { + output, err := service.UpstreamAPI().ChangeSimStatus(ctx, &req.SimCardStatusChangeInput) + 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 { + return nil, err + } + return &output, nil +} diff --git a/internal/controller/cardquery/mingqian_package_flow.go b/internal/controller/cardquery/mingqian_package_flow.go new file mode 100644 index 0000000..87b02da --- /dev/null +++ b/internal/controller/cardquery/mingqian_package_flow.go @@ -0,0 +1,21 @@ +package cardquery + +import ( + "context" + + 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) { + if req == nil { + return nil, gerror.New("mingqian: request required") + } + output, err := mingqian.QueryChannelsPackageFlow(ctx, &req.SimDataUsageInput, service.UpstreamAPI().GetChan) + if err != nil { + return nil, err + } + return &output, nil +} diff --git a/internal/controller/infopb/info.go b/internal/controller/infopb/info.go new file mode 100644 index 0000000..7f8d9ee --- /dev/null +++ b/internal/controller/infopb/info.go @@ -0,0 +1,35 @@ +package infopb + +import ( + "context" + + "git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/pb" + "git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/pub/xjpubservice" + + "github.com/gogf/gf/contrib/rpc/grpcx/v2" + "github.com/gogf/gf/v2/frame/g" +) + +type Controller struct { + pb.UnimplementedInfoServer +} + +func Register(s *grpcx.GrpcServer) { + pb.RegisterInfoServer(s.Server, &Controller{}) +} + +func (*Controller) Info(ctx context.Context, req *pb.InfoReq) (*pb.InfoRes, error) { + g.Log().Info(ctx, req) + info := xjpubservice.InfoRpc().GetInfo() + return &pb.InfoRes{ + Head: &pb.HeadRes{ + Code: 0, + Message: "success", + }, + Type: info.Type, + Name: info.Name, + Title: info.Title, + Description: info.Description, + Version: info.Version, + }, nil +} diff --git a/internal/controller/msgcallpb/msgcallpb.go b/internal/controller/msgcallpb/msgcallpb.go new file mode 100644 index 0000000..51f4844 --- /dev/null +++ b/internal/controller/msgcallpb/msgcallpb.go @@ -0,0 +1,47 @@ +package msgcallpb + +import ( + "context" + + "git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/pb" + "git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/pub/xjpubservice" + "github.com/gogf/gf/contrib/rpc/grpcx/v2" + "github.com/gogf/gf/v2/frame/g" +) + +type Controller struct { + pb.UnimplementedMsgCallServer +} + +func Register(s *grpcx.GrpcServer) { + pb.RegisterMsgCallServer(s.Server, &Controller{}) +} + +func (c *Controller) OneCall(ctx context.Context, req *pb.MsgReq) (res *pb.MsgRes, err error) { + res = &pb.MsgRes{ + Head: &pb.HeadRes{ + Code: -1, + Message: "service not found", + }, + Service: req.Service, + Method: req.Method, + } + if req.Head != nil { + res.Head.RouterId = req.Head.RouterId + res.Head.TraceId = req.Head.TraceId + } + + info := xjpubservice.InfoRpc().GetInfo() + if req.Service != info.Name { + g.Log().Errorf(ctx, "OneCall Service not found: service: %s, info: %s", req.Service, info.Name) + return res, nil + } + { + g.Log().Errorf(ctx, "OneCall Method not found: method: %s", req.Method) + res.Head.Code = -1 + res.Head.Message = "method not found" + return res, nil + } + + return res, nil +} diff --git a/internal/dao/internal/t_api.go b/internal/dao/internal/t_api.go new file mode 100644 index 0000000..864f1a7 --- /dev/null +++ b/internal/dao/internal/t_api.go @@ -0,0 +1,101 @@ +// ========================================================================== +// Code generated and maintained by GoFrame CLI tool. DO NOT EDIT. +// ========================================================================== + +package internal + +import ( + "context" + + "github.com/gogf/gf/v2/database/gdb" + "github.com/gogf/gf/v2/frame/g" +) + +// TApiDao is the data access object for the table t_api. +type TApiDao struct { + table string // table is the underlying table name of the DAO. + group string // group is the database configuration group name of the current DAO. + columns TApiColumns // columns contains all the column names of Table for convenient usage. + handlers []gdb.ModelHandler // handlers for customized model modification. +} + +// TApiColumns defines and stores column names for the table t_api. +type TApiColumns struct { + Id string // ID + AppKey string // APPKey + AppSecret string // APPSecret + RateLimitPeriod string // 限流周期(秒,0:不限流) + RateLimitCount string // 限流次数 + DeptId string // 所属机构 + TenantId string // 租户ID + CreatedDept string // 创建部门 + CreatedBy string // 创建者 + CreatedAt string // 创建时间 + UpdatedBy string // 更新者 + UpdatedAt string // 更新时间 +} + +// tApiColumns holds the columns for the table t_api. +var tApiColumns = TApiColumns{ + Id: "id", + AppKey: "app_key", + AppSecret: "app_secret", + RateLimitPeriod: "rate_limit_period", + RateLimitCount: "rate_limit_count", + DeptId: "dept_id", + TenantId: "tenant_id", + CreatedDept: "created_dept", + CreatedBy: "created_by", + CreatedAt: "created_at", + UpdatedBy: "updated_by", + UpdatedAt: "updated_at", +} + +// NewTApiDao creates and returns a new DAO object for table data access. +func NewTApiDao(handlers ...gdb.ModelHandler) *TApiDao { + return &TApiDao{ + group: "default", + table: "t_api", + columns: tApiColumns, + handlers: handlers, + } +} + +// DB retrieves and returns the underlying raw database management object of the current DAO. +func (dao *TApiDao) DB() gdb.DB { + return g.DB(dao.group) +} + +// Table returns the table name of the current DAO. +func (dao *TApiDao) Table() string { + return dao.table +} + +// Columns returns all column names of the current DAO. +func (dao *TApiDao) Columns() TApiColumns { + return dao.columns +} + +// Group returns the database configuration group name of the current DAO. +func (dao *TApiDao) Group() string { + return dao.group +} + +// Ctx creates and returns a Model for the current DAO. It automatically sets the context for the current operation. +func (dao *TApiDao) Ctx(ctx context.Context) *gdb.Model { + model := dao.DB().Model(dao.table) + for _, handler := range dao.handlers { + model = handler(model) + } + return model.Safe().Ctx(ctx) +} + +// Transaction wraps the transaction logic using function f. +// It rolls back the transaction and returns the error if function f returns a non-nil error. +// It commits the transaction and returns nil if function f returns nil. +// +// Note: Do not commit or roll back the transaction in function f, +// as it is automatically handled by this function. +func (dao *TApiDao) Transaction(ctx context.Context, f func(ctx context.Context, tx gdb.TX) error) (err error) { + return dao.Ctx(ctx).Transaction(ctx, f) +} diff --git a/internal/dao/internal/t_customer.go b/internal/dao/internal/t_customer.go new file mode 100644 index 0000000..a517407 --- /dev/null +++ b/internal/dao/internal/t_customer.go @@ -0,0 +1,117 @@ +// ========================================================================== +// Code generated and maintained by GoFrame CLI tool. DO NOT EDIT. +// ========================================================================== + +package internal + +import ( + "context" + + "github.com/gogf/gf/v2/database/gdb" + "github.com/gogf/gf/v2/frame/g" +) + +// TCustomerDao is the data access object for the table t_customer. +type TCustomerDao struct { + table string // table is the underlying table name of the DAO. + group string // group is the database configuration group name of the current DAO. + columns TCustomerColumns // columns contains all the column names of Table for convenient usage. + handlers []gdb.ModelHandler // handlers for customized model modification. +} + +// TCustomerColumns defines and stores column names for the table t_customer. +type TCustomerColumns struct { + Id string // ID + CKey string // 客户编号 + CType string // 客户类型(0:企业,1:个人) + CName string // 客户名称 + CShortName string // 客户简称 + ContactUserName string // 联系人 + ContactPhone string // 联系电话 + LicenseNumber string // 统一社会信用代码 + RegionCode string // 行政区域代码 + Address string // 地址 + DepositAmount string // 预存金额 + CreditAmountTotal string // 已授信额度 + CreditAmountUsed string // 已使用授信额度 + DeptId string // 所属机构 + TenantId string // 租户ID + CreatedDept string // 创建部门 + CreatedBy string // 创建者 + CreatedAt string // 创建时间 + UpdatedBy string // 更新者 + UpdatedAt string // 更新时间 +} + +// tCustomerColumns holds the columns for the table t_customer. +var tCustomerColumns = TCustomerColumns{ + Id: "id", + CKey: "c_key", + CType: "c_type", + CName: "c_name", + CShortName: "c_short_name", + ContactUserName: "contact_user_name", + ContactPhone: "contact_phone", + LicenseNumber: "license_number", + RegionCode: "region_code", + Address: "address", + DepositAmount: "deposit_amount", + CreditAmountTotal: "credit_amount_total", + CreditAmountUsed: "credit_amount_used", + DeptId: "dept_id", + TenantId: "tenant_id", + CreatedDept: "created_dept", + CreatedBy: "created_by", + CreatedAt: "created_at", + UpdatedBy: "updated_by", + UpdatedAt: "updated_at", +} + +// NewTCustomerDao creates and returns a new DAO object for table data access. +func NewTCustomerDao(handlers ...gdb.ModelHandler) *TCustomerDao { + return &TCustomerDao{ + group: "default", + table: "t_customer", + columns: tCustomerColumns, + handlers: handlers, + } +} + +// DB retrieves and returns the underlying raw database management object of the current DAO. +func (dao *TCustomerDao) DB() gdb.DB { + return g.DB(dao.group) +} + +// Table returns the table name of the current DAO. +func (dao *TCustomerDao) Table() string { + return dao.table +} + +// Columns returns all column names of the current DAO. +func (dao *TCustomerDao) Columns() TCustomerColumns { + return dao.columns +} + +// Group returns the database configuration group name of the current DAO. +func (dao *TCustomerDao) Group() string { + return dao.group +} + +// Ctx creates and returns a Model for the current DAO. It automatically sets the context for the current operation. +func (dao *TCustomerDao) Ctx(ctx context.Context) *gdb.Model { + model := dao.DB().Model(dao.table) + for _, handler := range dao.handlers { + model = handler(model) + } + return model.Safe().Ctx(ctx) +} + +// Transaction wraps the transaction logic using function f. +// It rolls back the transaction and returns the error if function f returns a non-nil error. +// It commits the transaction and returns nil if function f returns nil. +// +// Note: Do not commit or roll back the transaction in function f, +// as it is automatically handled by this function. +func (dao *TCustomerDao) Transaction(ctx context.Context, f func(ctx context.Context, tx gdb.TX) error) (err error) { + return dao.Ctx(ctx).Transaction(ctx, f) +} diff --git a/internal/dao/internal/t_sim_agent_package.go b/internal/dao/internal/t_sim_agent_package.go new file mode 100644 index 0000000..bf3fde5 --- /dev/null +++ b/internal/dao/internal/t_sim_agent_package.go @@ -0,0 +1,115 @@ +// ========================================================================== +// Code generated and maintained by GoFrame CLI tool. DO NOT EDIT. +// ========================================================================== + +package internal + +import ( + "context" + + "github.com/gogf/gf/v2/database/gdb" + "github.com/gogf/gf/v2/frame/g" +) + +// TSimAgentPackageDao is the data access object for the table t_sim_agent_package. +type TSimAgentPackageDao struct { + table string // table is the underlying table name of the DAO. + group string // group is the database configuration group name of the current DAO. + columns TSimAgentPackageColumns // columns contains all the column names of Table for convenient usage. + handlers []gdb.ModelHandler // handlers for customized model modification. +} + +// TSimAgentPackageColumns defines and stores column names for the table t_sim_agent_package. +type TSimAgentPackageColumns struct { + Id string // ID + PackageKey string // 套餐分类编码 + PackageName string // 套餐分类名称 + ParentPackageKey string // 上级套餐分类编码 + PackageStatus string // 是否启用(0:未启用,1启用) + BillingMethod string // 计费方式(0:月,1:季,2:年) + ChannelPrice string // 渠道价格 + MonthlyFee string // 月租费 + PackageDataMb string // 套餐流量(MB) + PackageDataGb string // 套餐流量(GB) + ValidityPeriod string // 有效期 + Description string // 描述信息 + OrderNum string // 排序 + TenantId string // 租户ID + CreatedDept string // 创建部门 + CreatedBy string // 创建者 + CreatedAt string // 创建时间 + UpdatedBy string // 更新者 + UpdatedAt string // 更新时间 +} + +// tSimAgentPackageColumns holds the columns for the table t_sim_agent_package. +var tSimAgentPackageColumns = TSimAgentPackageColumns{ + Id: "id", + PackageKey: "package_key", + PackageName: "package_name", + ParentPackageKey: "parent_package_key", + PackageStatus: "package_status", + BillingMethod: "billing_method", + ChannelPrice: "channel_price", + MonthlyFee: "monthly_fee", + PackageDataMb: "package_data_mb", + PackageDataGb: "package_data_gb", + ValidityPeriod: "validity_period", + Description: "description", + OrderNum: "order_num", + TenantId: "tenant_id", + CreatedDept: "created_dept", + CreatedBy: "created_by", + CreatedAt: "created_at", + UpdatedBy: "updated_by", + UpdatedAt: "updated_at", +} + +// NewTSimAgentPackageDao creates and returns a new DAO object for table data access. +func NewTSimAgentPackageDao(handlers ...gdb.ModelHandler) *TSimAgentPackageDao { + return &TSimAgentPackageDao{ + group: "default", + table: "t_sim_agent_package", + columns: tSimAgentPackageColumns, + handlers: handlers, + } +} + +// DB retrieves and returns the underlying raw database management object of the current DAO. +func (dao *TSimAgentPackageDao) DB() gdb.DB { + return g.DB(dao.group) +} + +// Table returns the table name of the current DAO. +func (dao *TSimAgentPackageDao) Table() string { + return dao.table +} + +// Columns returns all column names of the current DAO. +func (dao *TSimAgentPackageDao) Columns() TSimAgentPackageColumns { + return dao.columns +} + +// Group returns the database configuration group name of the current DAO. +func (dao *TSimAgentPackageDao) Group() string { + return dao.group +} + +// Ctx creates and returns a Model for the current DAO. It automatically sets the context for the current operation. +func (dao *TSimAgentPackageDao) Ctx(ctx context.Context) *gdb.Model { + model := dao.DB().Model(dao.table) + for _, handler := range dao.handlers { + model = handler(model) + } + return model.Safe().Ctx(ctx) +} + +// Transaction wraps the transaction logic using function f. +// It rolls back the transaction and returns the error if function f returns a non-nil error. +// It commits the transaction and returns nil if function f returns nil. +// +// Note: Do not commit or roll back the transaction in function f, +// as it is automatically handled by this function. +func (dao *TSimAgentPackageDao) Transaction(ctx context.Context, f func(ctx context.Context, tx gdb.TX) error) (err error) { + return dao.Ctx(ctx).Transaction(ctx, f) +} diff --git a/internal/dao/internal/t_sim_agent_packet.go b/internal/dao/internal/t_sim_agent_packet.go new file mode 100644 index 0000000..5e7e767 --- /dev/null +++ b/internal/dao/internal/t_sim_agent_packet.go @@ -0,0 +1,105 @@ +// ========================================================================== +// Code generated and maintained by GoFrame CLI tool. DO NOT EDIT. +// ========================================================================== + +package internal + +import ( + "context" + + "github.com/gogf/gf/v2/database/gdb" + "github.com/gogf/gf/v2/frame/g" +) + +// TSimAgentPacketDao is the data access object for the table t_sim_agent_packet. +type TSimAgentPacketDao struct { + table string // table is the underlying table name of the DAO. + group string // group is the database configuration group name of the current DAO. + columns TSimAgentPacketColumns // columns contains all the column names of Table for convenient usage. + handlers []gdb.ModelHandler // handlers for customized model modification. +} + +// TSimAgentPacketColumns defines and stores column names for the table t_sim_agent_packet. +type TSimAgentPacketColumns struct { + Id string // ID + PackageKey string // 套餐分类编码 + PacketKey string // 套餐编码 + PacketName string // 套餐名称 + ValidityPeriod string // 套餐有效期(月份) + PacketStatus string // 是否启用(0:未启用,1启用) + DiscountPercent string // 折扣率(百分比) + Description string // 描述信息 + TenantId string // 租户ID + CreatedDept string // 创建部门 + CreatedBy string // 创建者 + CreatedAt string // 创建时间 + UpdatedBy string // 更新者 + UpdatedAt string // 更新时间 +} + +// tSimAgentPacketColumns holds the columns for the table t_sim_agent_packet. +var tSimAgentPacketColumns = TSimAgentPacketColumns{ + Id: "id", + PackageKey: "package_key", + PacketKey: "packet_key", + PacketName: "packet_name", + ValidityPeriod: "validity_period", + PacketStatus: "packet_status", + DiscountPercent: "discount_percent", + Description: "description", + TenantId: "tenant_id", + CreatedDept: "created_dept", + CreatedBy: "created_by", + CreatedAt: "created_at", + UpdatedBy: "updated_by", + UpdatedAt: "updated_at", +} + +// NewTSimAgentPacketDao creates and returns a new DAO object for table data access. +func NewTSimAgentPacketDao(handlers ...gdb.ModelHandler) *TSimAgentPacketDao { + return &TSimAgentPacketDao{ + group: "default", + table: "t_sim_agent_packet", + columns: tSimAgentPacketColumns, + handlers: handlers, + } +} + +// DB retrieves and returns the underlying raw database management object of the current DAO. +func (dao *TSimAgentPacketDao) DB() gdb.DB { + return g.DB(dao.group) +} + +// Table returns the table name of the current DAO. +func (dao *TSimAgentPacketDao) Table() string { + return dao.table +} + +// Columns returns all column names of the current DAO. +func (dao *TSimAgentPacketDao) Columns() TSimAgentPacketColumns { + return dao.columns +} + +// Group returns the database configuration group name of the current DAO. +func (dao *TSimAgentPacketDao) Group() string { + return dao.group +} + +// Ctx creates and returns a Model for the current DAO. It automatically sets the context for the current operation. +func (dao *TSimAgentPacketDao) Ctx(ctx context.Context) *gdb.Model { + model := dao.DB().Model(dao.table) + for _, handler := range dao.handlers { + model = handler(model) + } + return model.Safe().Ctx(ctx) +} + +// Transaction wraps the transaction logic using function f. +// It rolls back the transaction and returns the error if function f returns a non-nil error. +// It commits the transaction and returns nil if function f returns nil. +// +// Note: Do not commit or roll back the transaction in function f, +// as it is automatically handled by this function. +func (dao *TSimAgentPacketDao) Transaction(ctx context.Context, f func(ctx context.Context, tx gdb.TX) error) (err error) { + return dao.Ctx(ctx).Transaction(ctx, f) +} diff --git a/internal/dao/internal/t_sim_card_info.go b/internal/dao/internal/t_sim_card_info.go new file mode 100644 index 0000000..df8edf1 --- /dev/null +++ b/internal/dao/internal/t_sim_card_info.go @@ -0,0 +1,193 @@ +// ========================================================================== +// Code generated and maintained by GoFrame CLI tool. DO NOT EDIT. +// ========================================================================== + +package internal + +import ( + "context" + + "github.com/gogf/gf/v2/database/gdb" + "github.com/gogf/gf/v2/frame/g" +) + +// TSimCardInfoDao is the data access object for the table t_sim_card_info. +type TSimCardInfoDao struct { + table string // table is the underlying table name of the DAO. + group string // group is the database configuration group name of the current DAO. + columns TSimCardInfoColumns // columns contains all the column names of Table for convenient usage. + handlers []gdb.ModelHandler // handlers for customized model modification. +} + +// TSimCardInfoColumns defines and stores column names for the table t_sim_card_info. +type TSimCardInfoColumns struct { + Id string // ID + Msisdn string // SIM号码 + Iccid string // SIM卡号 + Imsi string // SIM识别码 + Imei string // IMEI + OpenDate string // 开卡日期(运营商) + ActivateDate string // 激活时间(运营商) + AllocationBatch string // 划拨批次 + AllocationDate string // 划拨日期 + ChanKey string // 通道编号 + IsPool string // 是否流量池(0:否,1:是) + PoolKey string // 流量池编号 + BatchKey string // 入库批次 + BatchDate string // 入库日期 + PackageKey string // 套餐分类编码 + CurrPacketKey string // 当前套餐编码 + AllowPacketChange string // 是否允许更改套餐(0:否 1:是) + CardStatus string // 卡状态 + ValidityPeriod string // 有效期(月份) + ExpiryDate string // 套餐到期日期 + UpstreamExpiryDate string // 上游到期时间(运营商) + IpType string // IP版本(人工录入) + NetModel string // 网络模式(人工录入) + RegionCode string // 行政区域代码(省市) + HasOffNet string // 是否开启达量断网 + OffNetQuota string // 达量断网阈值(MB) + OffNetExpiryDate string // 达量断网过期时间 + AgentShowField string // 客户展示字段设置 + AgentShowValue string // 客户展示字段数据 + HasTestPeriod string // 是否有测试期(0:无,1:有) + TestPeriod string // 测试期(天) + TestPeriodExpiryDate string // 测试期过期日期 + HasSilentPeriod string // 是否有沉默期(0:无,1:有) + SilentPeriod string // 沉默期(月) + SilentPeriodExpiryDate string // 沉默期过期日期 + HasAdditional string // 是否有附加费用(0:无 1:有) + AdditionalMultiplier string // 附加费用倍数 + AdditionalAmount string // 附加金额 + DataService string // 数据服务(0:关闭 1:开启) + DataTotalAmount string // 套餐流量(MB) + DataUseAmount string // 已用流量(MB) + SmsService string // 短信服务(0:关闭 1:开启) + SmsTotalAmount string // 套餐短信数量 + SmsUseAmount string // 已用短信数量 + VoiceService string // 语音服务(0:关闭 1:开启) + VoiceTotalAmount string // 套餐通话分钟数 + VoiceUseAmount string // 已用通话分钟数 + IntlRoamingService string // 国际漫游服务(0:关闭 1:开启) + IntlRoamingTotalAmount string // 套餐国际漫游流量(MB) + IntlRoamingUseAmount string // 已用国际漫游流量(MB) + Description string // 描述信息 + DeptId string // 所属机构 + TenantId string // 租户ID + CreatedDept string // 创建部门 + CreatedBy string // 创建者 + CreatedAt string // 创建时间 + UpdatedBy string // 更新者 + UpdatedAt string // 更新时间 +} + +// tSimCardInfoColumns holds the columns for the table t_sim_card_info. +var tSimCardInfoColumns = TSimCardInfoColumns{ + Id: "id", + Msisdn: "msisdn", + Iccid: "iccid", + Imsi: "imsi", + Imei: "imei", + OpenDate: "open_date", + ActivateDate: "activate_date", + AllocationBatch: "allocation_batch", + AllocationDate: "allocation_date", + ChanKey: "chan_key", + IsPool: "is_pool", + PoolKey: "pool_key", + BatchKey: "batch_key", + BatchDate: "batch_date", + PackageKey: "package_key", + CurrPacketKey: "curr_packet_key", + AllowPacketChange: "allow_packet_change", + CardStatus: "card_status", + ValidityPeriod: "validity_period", + ExpiryDate: "expiry_date", + UpstreamExpiryDate: "upstream_expiry_date", + IpType: "ip_type", + NetModel: "net_model", + RegionCode: "region_code", + HasOffNet: "has_off_net", + OffNetQuota: "off_net_quota", + OffNetExpiryDate: "off_net_expiry_date", + AgentShowField: "agent_show_field", + AgentShowValue: "agent_show_value", + HasTestPeriod: "has_test_period", + TestPeriod: "test_period", + TestPeriodExpiryDate: "test_period_expiry_date", + HasSilentPeriod: "has_silent_period", + SilentPeriod: "silent_period", + SilentPeriodExpiryDate: "silent_period_expiry_date", + HasAdditional: "has_additional", + AdditionalMultiplier: "additional_multiplier", + AdditionalAmount: "additional_amount", + DataService: "data_service", + DataTotalAmount: "data_total_amount", + DataUseAmount: "data_use_amount", + SmsService: "sms_service", + SmsTotalAmount: "sms_total_amount", + SmsUseAmount: "sms_use_amount", + VoiceService: "voice_service", + VoiceTotalAmount: "voice_total_amount", + VoiceUseAmount: "voice_use_amount", + IntlRoamingService: "intl_roaming_service", + IntlRoamingTotalAmount: "intl_roaming_total_amount", + IntlRoamingUseAmount: "intl_roaming_use_amount", + Description: "description", + DeptId: "dept_id", + TenantId: "tenant_id", + CreatedDept: "created_dept", + CreatedBy: "created_by", + CreatedAt: "created_at", + UpdatedBy: "updated_by", + UpdatedAt: "updated_at", +} + +// NewTSimCardInfoDao creates and returns a new DAO object for table data access. +func NewTSimCardInfoDao(handlers ...gdb.ModelHandler) *TSimCardInfoDao { + return &TSimCardInfoDao{ + group: "default", + table: "t_sim_card_info", + columns: tSimCardInfoColumns, + handlers: handlers, + } +} + +// DB retrieves and returns the underlying raw database management object of the current DAO. +func (dao *TSimCardInfoDao) DB() gdb.DB { + return g.DB(dao.group) +} + +// Table returns the table name of the current DAO. +func (dao *TSimCardInfoDao) Table() string { + return dao.table +} + +// Columns returns all column names of the current DAO. +func (dao *TSimCardInfoDao) Columns() TSimCardInfoColumns { + return dao.columns +} + +// Group returns the database configuration group name of the current DAO. +func (dao *TSimCardInfoDao) Group() string { + return dao.group +} + +// Ctx creates and returns a Model for the current DAO. It automatically sets the context for the current operation. +func (dao *TSimCardInfoDao) Ctx(ctx context.Context) *gdb.Model { + model := dao.DB().Model(dao.table) + for _, handler := range dao.handlers { + model = handler(model) + } + return model.Safe().Ctx(ctx) +} + +// Transaction wraps the transaction logic using function f. +// It rolls back the transaction and returns the error if function f returns a non-nil error. +// It commits the transaction and returns nil if function f returns nil. +// +// Note: Do not commit or roll back the transaction in function f, +// as it is automatically handled by this function. +func (dao *TSimCardInfoDao) Transaction(ctx context.Context, f func(ctx context.Context, tx gdb.TX) error) (err error) { + return dao.Ctx(ctx).Transaction(ctx, f) +} diff --git a/internal/dao/internal/t_sim_channel_template.go b/internal/dao/internal/t_sim_channel_template.go new file mode 100644 index 0000000..e4901a9 --- /dev/null +++ b/internal/dao/internal/t_sim_channel_template.go @@ -0,0 +1,101 @@ +// ========================================================================== +// Code generated and maintained by GoFrame CLI tool. DO NOT EDIT. +// ========================================================================== + +package internal + +import ( + "context" + + "github.com/gogf/gf/v2/database/gdb" + "github.com/gogf/gf/v2/frame/g" +) + +// TSimChannelTemplateDao is the data access object for the table t_sim_channel_template. +type TSimChannelTemplateDao struct { + table string // table is the underlying table name of the DAO. + group string // group is the database configuration group name of the current DAO. + columns TSimChannelTemplateColumns // columns contains all the column names of Table for convenient usage. + handlers []gdb.ModelHandler // handlers for customized model modification. +} + +// TSimChannelTemplateColumns defines and stores column names for the table t_sim_channel_template. +type TSimChannelTemplateColumns struct { + Id string // ID + TempKey string // 模板标识 + TempName string // 模板名称 + TempModel string // 模板模型 + TempConfig string // 模板配置 + TempStatus string // 模板状态(0未启用,1启用) + TenantId string // 租户ID + CreatedDept string // 创建部门 + CreatedBy string // 创建者 + CreatedAt string // 创建时间 + UpdatedBy string // 更新者 + UpdatedAt string // 更新时间 +} + +// tSimChannelTemplateColumns holds the columns for the table t_sim_channel_template. +var tSimChannelTemplateColumns = TSimChannelTemplateColumns{ + Id: "id", + TempKey: "temp_key", + TempName: "temp_name", + TempModel: "temp_model", + TempConfig: "temp_config", + TempStatus: "temp_status", + TenantId: "tenant_id", + CreatedDept: "created_dept", + CreatedBy: "created_by", + CreatedAt: "created_at", + UpdatedBy: "updated_by", + UpdatedAt: "updated_at", +} + +// NewTSimChannelTemplateDao creates and returns a new DAO object for table data access. +func NewTSimChannelTemplateDao(handlers ...gdb.ModelHandler) *TSimChannelTemplateDao { + return &TSimChannelTemplateDao{ + group: "default", + table: "t_sim_channel_template", + columns: tSimChannelTemplateColumns, + handlers: handlers, + } +} + +// DB retrieves and returns the underlying raw database management object of the current DAO. +func (dao *TSimChannelTemplateDao) DB() gdb.DB { + return g.DB(dao.group) +} + +// Table returns the table name of the current DAO. +func (dao *TSimChannelTemplateDao) Table() string { + return dao.table +} + +// Columns returns all column names of the current DAO. +func (dao *TSimChannelTemplateDao) Columns() TSimChannelTemplateColumns { + return dao.columns +} + +// Group returns the database configuration group name of the current DAO. +func (dao *TSimChannelTemplateDao) Group() string { + return dao.group +} + +// Ctx creates and returns a Model for the current DAO. It automatically sets the context for the current operation. +func (dao *TSimChannelTemplateDao) Ctx(ctx context.Context) *gdb.Model { + model := dao.DB().Model(dao.table) + for _, handler := range dao.handlers { + model = handler(model) + } + return model.Safe().Ctx(ctx) +} + +// Transaction wraps the transaction logic using function f. +// It rolls back the transaction and returns the error if function f returns a non-nil error. +// It commits the transaction and returns nil if function f returns nil. +// +// Note: Do not commit or roll back the transaction in function f, +// as it is automatically handled by this function. +func (dao *TSimChannelTemplateDao) Transaction(ctx context.Context, f func(ctx context.Context, tx gdb.TX) error) (err error) { + return dao.Ctx(ctx).Transaction(ctx, f) +} diff --git a/internal/dao/internal/t_sim_channels.go b/internal/dao/internal/t_sim_channels.go new file mode 100644 index 0000000..7b8ad0d --- /dev/null +++ b/internal/dao/internal/t_sim_channels.go @@ -0,0 +1,117 @@ +// ========================================================================== +// Code generated and maintained by GoFrame CLI tool. DO NOT EDIT. +// ========================================================================== + +package internal + +import ( + "context" + + "github.com/gogf/gf/v2/database/gdb" + "github.com/gogf/gf/v2/frame/g" +) + +// TSimChannelsDao is the data access object for the table t_sim_channels. +type TSimChannelsDao struct { + table string // table is the underlying table name of the DAO. + group string // group is the database configuration group name of the current DAO. + columns TSimChannelsColumns // columns contains all the column names of Table for convenient usage. + handlers []gdb.ModelHandler // handlers for customized model modification. +} + +// TSimChannelsColumns defines and stores column names for the table t_sim_channels. +type TSimChannelsColumns struct { + Id string // ID + TempKey string // 模板标识 + ChanKey string // 通道编号 + ChanName string // 通道名称 + ChanConfig string // 通道配置 + ChanStatus string // 通道状态(0未启用,1启用) + ChanAlias string // 通道别名 + RegionCode string // 通道行政区域代码(省市) + AppKey string // 应用标识 + AppSecret string // 应用密钥 + AccessToken string // 接口调用凭证 + AccessTokenCreated string // 凭证申请时间 + AccessTokenExpire string // 凭证过期时间 + RefreshToken string // 刷新凭证 + TenantId string // 租户ID + CreatedDept string // 创建部门 + CreatedBy string // 创建者 + CreatedAt string // 创建时间 + UpdatedBy string // 更新者 + UpdatedAt string // 更新时间 +} + +// tSimChannelsColumns holds the columns for the table t_sim_channels. +var tSimChannelsColumns = TSimChannelsColumns{ + Id: "id", + TempKey: "temp_key", + ChanKey: "chan_key", + ChanName: "chan_name", + ChanConfig: "chan_config", + ChanStatus: "chan_status", + ChanAlias: "chan_alias", + RegionCode: "region_code", + AppKey: "app_key", + AppSecret: "app_secret", + AccessToken: "access_token", + AccessTokenCreated: "access_token_created", + AccessTokenExpire: "access_token_expire", + RefreshToken: "refresh_token", + TenantId: "tenant_id", + CreatedDept: "created_dept", + CreatedBy: "created_by", + CreatedAt: "created_at", + UpdatedBy: "updated_by", + UpdatedAt: "updated_at", +} + +// NewTSimChannelsDao creates and returns a new DAO object for table data access. +func NewTSimChannelsDao(handlers ...gdb.ModelHandler) *TSimChannelsDao { + return &TSimChannelsDao{ + group: "default", + table: "t_sim_channels", + columns: tSimChannelsColumns, + handlers: handlers, + } +} + +// DB retrieves and returns the underlying raw database management object of the current DAO. +func (dao *TSimChannelsDao) DB() gdb.DB { + return g.DB(dao.group) +} + +// Table returns the table name of the current DAO. +func (dao *TSimChannelsDao) Table() string { + return dao.table +} + +// Columns returns all column names of the current DAO. +func (dao *TSimChannelsDao) Columns() TSimChannelsColumns { + return dao.columns +} + +// Group returns the database configuration group name of the current DAO. +func (dao *TSimChannelsDao) Group() string { + return dao.group +} + +// Ctx creates and returns a Model for the current DAO. It automatically sets the context for the current operation. +func (dao *TSimChannelsDao) Ctx(ctx context.Context) *gdb.Model { + model := dao.DB().Model(dao.table) + for _, handler := range dao.handlers { + model = handler(model) + } + return model.Safe().Ctx(ctx) +} + +// Transaction wraps the transaction logic using function f. +// It rolls back the transaction and returns the error if function f returns a non-nil error. +// It commits the transaction and returns nil if function f returns nil. +// +// Note: Do not commit or roll back the transaction in function f, +// as it is automatically handled by this function. +func (dao *TSimChannelsDao) Transaction(ctx context.Context, f func(ctx context.Context, tx gdb.TX) error) (err error) { + return dao.Ctx(ctx).Transaction(ctx, f) +} diff --git a/internal/dao/t_api.go b/internal/dao/t_api.go new file mode 100644 index 0000000..3c50b9e --- /dev/null +++ b/internal/dao/t_api.go @@ -0,0 +1,22 @@ +// ================================================================================= +// This file is auto-generated by the GoFrame CLI tool. You may modify it as needed. +// ================================================================================= + +package dao + +import ( + "git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/dao/internal" +) + +// tApiDao is the data access object for the table t_api. +// You can define custom methods on it to extend its functionality as needed. +type tApiDao struct { + *internal.TApiDao +} + +var ( + // TApi is a globally accessible object for table t_api operations. + TApi = tApiDao{internal.NewTApiDao()} +) + +// Add your custom methods and functionality below. diff --git a/internal/dao/t_customer.go b/internal/dao/t_customer.go new file mode 100644 index 0000000..88960f1 --- /dev/null +++ b/internal/dao/t_customer.go @@ -0,0 +1,22 @@ +// ================================================================================= +// This file is auto-generated by the GoFrame CLI tool. You may modify it as needed. +// ================================================================================= + +package dao + +import ( + "git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/dao/internal" +) + +// tCustomerDao is the data access object for the table t_customer. +// You can define custom methods on it to extend its functionality as needed. +type tCustomerDao struct { + *internal.TCustomerDao +} + +var ( + // TCustomer is a globally accessible object for table t_customer operations. + TCustomer = tCustomerDao{internal.NewTCustomerDao()} +) + +// Add your custom methods and functionality below. diff --git a/internal/dao/t_sim_agent_package.go b/internal/dao/t_sim_agent_package.go new file mode 100644 index 0000000..16393ab --- /dev/null +++ b/internal/dao/t_sim_agent_package.go @@ -0,0 +1,22 @@ +// ================================================================================= +// This file is auto-generated by the GoFrame CLI tool. You may modify it as needed. +// ================================================================================= + +package dao + +import ( + "git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/dao/internal" +) + +// tSimAgentPackageDao is the data access object for the table t_sim_agent_package. +// You can define custom methods on it to extend its functionality as needed. +type tSimAgentPackageDao struct { + *internal.TSimAgentPackageDao +} + +var ( + // TSimAgentPackage is a globally accessible object for table t_sim_agent_package operations. + TSimAgentPackage = tSimAgentPackageDao{internal.NewTSimAgentPackageDao()} +) + +// Add your custom methods and functionality below. diff --git a/internal/dao/t_sim_agent_packet.go b/internal/dao/t_sim_agent_packet.go new file mode 100644 index 0000000..6046bc5 --- /dev/null +++ b/internal/dao/t_sim_agent_packet.go @@ -0,0 +1,22 @@ +// ================================================================================= +// This file is auto-generated by the GoFrame CLI tool. You may modify it as needed. +// ================================================================================= + +package dao + +import ( + "git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/dao/internal" +) + +// tSimAgentPacketDao is the data access object for the table t_sim_agent_packet. +// You can define custom methods on it to extend its functionality as needed. +type tSimAgentPacketDao struct { + *internal.TSimAgentPacketDao +} + +var ( + // TSimAgentPacket is a globally accessible object for table t_sim_agent_packet operations. + TSimAgentPacket = tSimAgentPacketDao{internal.NewTSimAgentPacketDao()} +) + +// Add your custom methods and functionality below. diff --git a/internal/dao/t_sim_card_info.go b/internal/dao/t_sim_card_info.go new file mode 100644 index 0000000..9dcc258 --- /dev/null +++ b/internal/dao/t_sim_card_info.go @@ -0,0 +1,22 @@ +// ================================================================================= +// This file is auto-generated by the GoFrame CLI tool. You may modify it as needed. +// ================================================================================= + +package dao + +import ( + "git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/dao/internal" +) + +// tSimCardInfoDao is the data access object for the table t_sim_card_info. +// You can define custom methods on it to extend its functionality as needed. +type tSimCardInfoDao struct { + *internal.TSimCardInfoDao +} + +var ( + // TSimCardInfo is a globally accessible object for table t_sim_card_info operations. + TSimCardInfo = tSimCardInfoDao{internal.NewTSimCardInfoDao()} +) + +// Add your custom methods and functionality below. diff --git a/internal/dao/t_sim_channel_template.go b/internal/dao/t_sim_channel_template.go new file mode 100644 index 0000000..42f4d0d --- /dev/null +++ b/internal/dao/t_sim_channel_template.go @@ -0,0 +1,22 @@ +// ================================================================================= +// This file is auto-generated by the GoFrame CLI tool. You may modify it as needed. +// ================================================================================= + +package dao + +import ( + "git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/dao/internal" +) + +// tSimChannelTemplateDao is the data access object for the table t_sim_channel_template. +// You can define custom methods on it to extend its functionality as needed. +type tSimChannelTemplateDao struct { + *internal.TSimChannelTemplateDao +} + +var ( + // TSimChannelTemplate is a globally accessible object for table t_sim_channel_template operations. + TSimChannelTemplate = tSimChannelTemplateDao{internal.NewTSimChannelTemplateDao()} +) + +// Add your custom methods and functionality below. diff --git a/internal/dao/t_sim_channels.go b/internal/dao/t_sim_channels.go new file mode 100644 index 0000000..ed8b263 --- /dev/null +++ b/internal/dao/t_sim_channels.go @@ -0,0 +1,22 @@ +// ================================================================================= +// This file is auto-generated by the GoFrame CLI tool. You may modify it as needed. +// ================================================================================= + +package dao + +import ( + "git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/dao/internal" +) + +// tSimChannelsDao is the data access object for the table t_sim_channels. +// You can define custom methods on it to extend its functionality as needed. +type tSimChannelsDao struct { + *internal.TSimChannelsDao +} + +var ( + // TSimChannels is a globally accessible object for table t_sim_channels operations. + TSimChannels = tSimChannelsDao{internal.NewTSimChannelsDao()} +) + +// Add your custom methods and functionality below. diff --git a/internal/logic/channelconfig/channel_config.go b/internal/logic/channelconfig/channel_config.go new file mode 100644 index 0000000..d82a04e --- /dev/null +++ b/internal/logic/channelconfig/channel_config.go @@ -0,0 +1,298 @@ +package channelconfig + +import ( + "context" + "database/sql" + "strings" + + "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" + + "git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/dao" + "sync" + "time" + + "github.com/gogf/gf/v2/crypto/gmd5" + "github.com/gogf/gf/v2/encoding/gjson" + "github.com/gogf/gf/v2/errors/gerror" + "github.com/gogf/gf/v2/frame/g" +) + +// channelConfigRow extends the generated entity without losing SQL NULL values. +type channelConfigRow struct { + entity.TSimChannels + IsNormal *int `json:"isNormal" orm:"is_normal"` +} + +type sChannelConfig struct { + sync.RWMutex + channelCache map[string]*model.SimChannelConfigItemCache + loadVersion uint8 + chgLogMaxId int64 + running bool +} + +func NewChannelConfig() *sChannelConfig { + return &sChannelConfig{ + channelCache: make(map[string]*model.SimChannelConfigItemCache), + } +} + +func init() { + service.RegisterChannelConfig(NewChannelConfig()) +} + +func (s *sChannelConfig) Start(ctx context.Context) error { + err := s.LoadAllChannelConfig(ctx) + if err != nil { + g.Log().Errorf(ctx, "ChannelConfig Start err %+v", err) + return err + } + s.running = true + err = s.Loop(ctx) + if err != nil { + g.Log().Errorf(ctx, "ChannelConfig Start err %+v", err) + return err + } + return nil +} + +func (s *sChannelConfig) Stop(ctx context.Context) error { + s.running = false + return nil +} + +func (s *sChannelConfig) Loop(ctx context.Context) error { + g.Log().Infof(ctx, "ChannelConfig loop begin.") + lastLoadAllChannelConfigTime := int64(0) + go func() { + for s.running { + now := time.Now().Unix() + loadAllInterval := g.Cfg().MustGet(ctx, "channel.load_all_interval", 2*60*60).Int64() + if now-lastLoadAllChannelConfigTime >= loadAllInterval { + lastLoadAllChannelConfigTime = now + s.LoadAllChannelConfig(ctx) + } + time.Sleep(1 * time.Second) + } + }() + g.Log().Infof(ctx, "ChannelConfig loop background goroutine started.") + return nil +} + +func (s *sChannelConfig) LoadAllChannelConfig(ctx context.Context) error { + s.loadVersion++ + const warnRowThreshold = 1000 + + dbSimChannels := make([]*channelConfigRow, 0) + err := dao.TSimChannels.Ctx(ctx). + OrderAsc(dao.TSimChannels.Columns().Id). + Scan(&dbSimChannels) + if err != nil && err != sql.ErrNoRows { + return err + } + if len(dbSimChannels) >= warnRowThreshold { + g.Log().Warningf(ctx, "LoadAllChannelConfig TSimChannels row count %d >= %d, confirm small-table assumption or add pagination", + len(dbSimChannels), warnRowThreshold) + } + + dbSimChannelTemplates := make([]*entity.TSimChannelTemplate, 0) + err = dao.TSimChannelTemplate.Ctx(ctx). + OrderAsc(dao.TSimChannelTemplate.Columns().Id). + Scan(&dbSimChannelTemplates) + if err != nil && err != sql.ErrNoRows { + return err + } + if len(dbSimChannelTemplates) >= warnRowThreshold { + g.Log().Warningf(ctx, "LoadAllChannelConfig TSimChannelTemplate row count %d >= %d, confirm small-table assumption or add pagination", + len(dbSimChannelTemplates), warnRowThreshold) + } + + templateByKey := make(map[string]*entity.TSimChannelTemplate, len(dbSimChannelTemplates)) + for _, tpl := range dbSimChannelTemplates { + if tpl != nil { + templateByKey[tpl.TempKey] = tpl + } + } + + s.Lock() + for _, dbSimChannel := range dbSimChannels { + if dbSimChannel == nil { + continue + } + cache := &model.SimChannelConfigItemCache{ + Channel: &dbSimChannel.TSimChannels, + IsNormal: dbSimChannel.IsNormal, + Version: s.getChanVersion(ctx, dbSimChannel), + } + oldChannelCache, _ := s.channelCache[dbSimChannel.ChanKey] + if cache.CanCreateInstance() { + template, ok := templateByKey[dbSimChannel.TempKey] + if !ok { + g.Log().Warningf(ctx, "LoadAllChannelConfig no template for tempKey=%s chanKey=%s", dbSimChannel.TempKey, dbSimChannel.ChanKey) + continue + } + cache.Template = template + cache.Version += s.getTemplateVersion(ctx, template) + } + + if oldChannelCache != nil && oldChannelCache.Version == cache.Version { + continue + } + s.channelCache[dbSimChannel.ChanKey] = cache + } + s.Unlock() + return nil +} + +// RefreshChannel reloads one channel and its template from the database. +func (s *sChannelConfig) RefreshChannel(ctx context.Context, chanKey string) error { + if chanKey == "" { + return gerror.New("chanKey is required") + } + channel := new(channelConfigRow) + err := dao.TSimChannels.Ctx(ctx).Where(dao.TSimChannels.Columns().ChanKey, chanKey).Scan(channel) + if err != nil { + return err + } + if channel.ChanKey == "" { + return gerror.Newf("channel not found: %s", chanKey) + } + cache := &model.SimChannelConfigItemCache{ + Channel: &channel.TSimChannels, + IsNormal: channel.IsNormal, + Version: s.getChanVersion(ctx, channel), + } + // Ineligible channels refresh successfully even while their template is incomplete. + if cache.CanCreateInstance() { + template := new(entity.TSimChannelTemplate) + if err := dao.TSimChannelTemplate.Ctx(ctx).Where(dao.TSimChannelTemplate.Columns().TempKey, channel.TempKey).Scan(template); err != nil { + return err + } + if template.TempKey == "" { + return gerror.Newf("channel template not found: %s", channel.TempKey) + } + cache.Template = template + cache.Version += s.getTemplateVersion(ctx, template) + } + s.Lock() + s.channelCache[chanKey] = cache + s.Unlock() + return nil +} + +// RemoveChannel removes a channel configuration cache. Missing keys are a +// successful no-op so rename retries are idempotent. +func (s *sChannelConfig) RemoveChannel(ctx context.Context, chanKey string) error { + chanKey = strings.TrimSpace(chanKey) + if chanKey == "" { + return gerror.New("chanKey is required") + } + s.Lock() + delete(s.channelCache, chanKey) + s.Unlock() + g.Log().Infof(ctx, "channel config removed, chanKey: %s", chanKey) + return nil +} + +// LoadChannelForTest reads an isolated configuration snapshot. It never +// mutates channelCache, including when the channel is not runtime-eligible. +func (s *sChannelConfig) LoadChannelForTest(ctx context.Context, channelID int64, tenantID string) (*model.SimChannelConfigItemCache, error) { + tenantID = strings.TrimSpace(tenantID) + if channelID <= 0 || tenantID == "" { + return nil, model.ErrChannelNotAccessible + } + channel := new(channelConfigRow) + if err := dao.TSimChannels.Ctx(ctx). + Where(dao.TSimChannels.Columns().Id, channelID). + Where(dao.TSimChannels.Columns().TenantId, tenantID). + Limit(1). + Scan(channel); err != nil { + return nil, err + } + if channel.Id == 0 || strings.TrimSpace(channel.ChanKey) == "" { + return nil, model.ErrChannelNotAccessible + } + template := new(entity.TSimChannelTemplate) + if err := dao.TSimChannelTemplate.Ctx(ctx). + Where(dao.TSimChannelTemplate.Columns().TempKey, channel.TempKey). + Where(dao.TSimChannelTemplate.Columns().TenantId, tenantID). + Limit(1). + Scan(template); err != nil { + return nil, err + } + if strings.TrimSpace(template.TempKey) == "" { + return nil, gerror.Newf("channel template not found: %s", channel.TempKey) + } + return &model.SimChannelConfigItemCache{ + Channel: &channel.TSimChannels, + Template: template, + IsNormal: channel.IsNormal, + Version: s.getChanVersion(ctx, channel) + s.getTemplateVersion(ctx, template), + }, nil +} + +// get alarm cache by id +func (s *sChannelConfig) GetChannel(chanKey string) (*model.SimChannelConfigItemCache, bool) { + s.RLock() + defer s.RUnlock() + channelCache, ok := s.channelCache[chanKey] + return channelCache, ok +} + +func (s *sChannelConfig) GetAllChannel(ctx context.Context) ([]*model.SimChannelConfigItemCache, error) { + s.RLock() + defer s.RUnlock() + channels := make([]*model.SimChannelConfigItemCache, 0) + for _, channelCache := range s.channelCache { + channels = append(channels, channelCache) + } + return channels, nil +} + +func (s *sChannelConfig) getChanVersion(ctx context.Context, channel *channelConfigRow) string { + chanKey := channel.ChanKey + + channelNew := &channelConfigRow{ + IsNormal: channel.IsNormal, + TSimChannels: entity.TSimChannels{ + TempKey: channel.TempKey, + ChanKey: channel.ChanKey, + ChanName: channel.ChanName, + ChanConfig: channel.ChanConfig, + ChanStatus: channel.ChanStatus, + ChanAlias: channel.ChanAlias, + RegionCode: channel.RegionCode, + AppKey: channel.AppKey, + AppSecret: channel.AppSecret, + AccessToken: channel.AccessToken, + RefreshToken: channel.RefreshToken, + }, + } + chanVersion, err := gmd5.EncryptString(gjson.MustEncodeString(channelNew)) + if err != nil { + g.Log().Errorf(ctx, "sChannelConfig.getChanVersion encrypt error %+v, chanKey %s", err, chanKey) + return "" + } + g.Log().Debugf(ctx, "sChannelConfig.getChanVersion chanVersion=%s chanKey=%s", chanVersion, chanKey) + return chanVersion +} + +func (s *sChannelConfig) getTemplateVersion(ctx context.Context, template *entity.TSimChannelTemplate) string { + templateKey := template.TempKey + templateNew := &entity.TSimChannelTemplate{ + TempKey: template.TempKey, + TempName: template.TempName, + TempModel: template.TempModel, + TempConfig: template.TempConfig, + TempStatus: template.TempStatus, + } + templateVersion, err := gmd5.EncryptString(gjson.MustEncodeString(templateNew)) + if err != nil { + g.Log().Errorf(ctx, "sChannelConfig.getTemplateVersion encrypt error %+v, templateKey %s, templateNew %+v", err, templateKey, templateNew) + return "" + } + g.Log().Debugf(ctx, "sChannelConfig.getTemplateVersion templateVersion=%s templateKey=%s", templateVersion, templateKey) + return templateVersion +} diff --git a/internal/logic/channelconfig/channel_config_test.go b/internal/logic/channelconfig/channel_config_test.go new file mode 100644 index 0000000..47059e0 --- /dev/null +++ b/internal/logic/channelconfig/channel_config_test.go @@ -0,0 +1,37 @@ +package channelconfig + +import ( + "context" + "git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/model/entity" + "testing" +) + +func TestChannelVersionDetectsCredentialChanges(t *testing.T) { + normal := 0 + row := &channelConfigRow{IsNormal: &normal, TSimChannels: entity.TSimChannels{ + ChanKey: "telecom-test", TempKey: "sim_dx_v1", AppKey: "key", AppSecret: "secret", + AccessToken: "token", RefreshToken: "refresh", + }} + config := NewChannelConfig() + before := config.getChanVersion(context.Background(), row) + for _, field := range []string{"appKey", "appSecret", "accessToken", "refreshToken", "tempKey"} { + t.Run(field, func(t *testing.T) { + changed := *row + switch field { + case "appKey": + changed.AppKey = "new-key" + case "appSecret": + changed.AppSecret = "new-secret" + case "accessToken": + changed.AccessToken = "new-token" + case "refreshToken": + changed.RefreshToken = "new-refresh" + case "tempKey": + changed.TempKey = "sim_dx_v3" + } + if got := config.getChanVersion(context.Background(), &changed); got == before { + t.Fatalf("%s change did not invalidate runtime version", field) + } + }) + } +} diff --git a/internal/logic/channelconfig/channel_update.go b/internal/logic/channelconfig/channel_update.go new file mode 100644 index 0000000..4398b03 --- /dev/null +++ b/internal/logic/channelconfig/channel_update.go @@ -0,0 +1,51 @@ +package channelconfig + +import ( + "context" + + "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/do" + "git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/service" + + "github.com/gogf/gf/v2/database/gdb" + "github.com/gogf/gf/v2/errors/gerror" +) + +type sChannelUpdate struct{} + +func NewChannelUpdate() *sChannelUpdate { + return &sChannelUpdate{} +} + +func init() { + service.RegisterChannelUpdate(NewChannelUpdate()) +} + +func (s *sChannelUpdate) UpdateAccessToken(ctx context.Context, input *model.UpdateAccessTokenInput) error { + if input == nil { + return gerror.New("access token update input is required") + } + data := do.TSimChannels{ + AccessToken: input.AccessToken, + AccessTokenCreated: input.AccessTokenCreated, + RefreshToken: input.RefreshToken, + } + if input.AccessTokenExpire != nil { + data.AccessTokenExpire = input.AccessTokenExpire + } + return dao.TSimChannels.Transaction(ctx, func(ctx context.Context, tx gdb.TX) error { + _, err := tx.Model(dao.TSimChannels.Table()).Where(dao.TSimChannels.Columns().ChanKey, input.ChanKey).Data(data).Update() + if err != nil || input.AccessTokenExpire != nil { + return err + } + // The generated DO field is a *gtime.Time, so it cannot carry + // gdb.Raw. Clear the nullable column explicitly in this transaction + // instead of letting a nil field be treated as an omitted update. + _, err = tx.Exec( + "UPDATE `t_sim_channels` SET `access_token_expire` = NULL WHERE `chan_key` = ?", + input.ChanKey, + ) + return err + }) +} diff --git a/internal/logic/logic.go b/internal/logic/logic.go new file mode 100644 index 0000000..3311527 --- /dev/null +++ b/internal/logic/logic.go @@ -0,0 +1,11 @@ +// ========================================================================== +// Code generated and maintained by GoFrame CLI tool. DO NOT EDIT. +// ========================================================================== + +package logic + +import ( + _ "git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/logic/channelconfig" + _ "git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/logic/upstreamapi" + _ "git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/logic/upstreamimpl" +) diff --git a/internal/logic/upstreamapi/channel_cache_test.go b/internal/logic/upstreamapi/channel_cache_test.go new file mode 100644 index 0000000..3584f40 --- /dev/null +++ b/internal/logic/upstreamapi/channel_cache_test.go @@ -0,0 +1,17 @@ +package upstreamapi + +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:", ""}, + {"unrelated:898604F4152391115474", ""}, + } + for _, tt := range tests { + if got := iccidFromChannelCacheKey(tt.key); got != tt.want { + t.Errorf("iccidFromChannelCacheKey(%q) = %q, want %q", tt.key, got, tt.want) + } + } +} diff --git a/internal/logic/upstreamapi/channel_test_runtime.go b/internal/logic/upstreamapi/channel_test_runtime.go new file mode 100644 index 0000000..a243c8a --- /dev/null +++ b/internal/logic/upstreamapi/channel_test_runtime.go @@ -0,0 +1,251 @@ +package upstreamapi + +import ( + "context" + "errors" + "strings" + "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/errors/gerror" + "github.com/gogf/gf/v2/frame/g" +) + +const ( + defaultChannelTestTimeout = 2 * time.Minute + defaultChannelTestItemTimeout = 10 * time.Second +) + +type channelTestOperation struct { + key string + name string + execute func(context.Context, model.SimCardChannelI, *model.SimKey, time.Time) (any, int, string, bool, error) +} + +func (u *sUpstreamAPI) TestChannel(ctx context.Context, input *model.ChannelTestInput) (*model.ChannelTestOutput, error) { + if input == nil { + return nil, gerror.New("channel test input is required") + } + if input.ChannelID <= 0 || strings.TrimSpace(input.TenantID) == "" { + return nil, gerror.New("channel is not accessible") + } + + operations, err := selectChannelTestOperations(input.Items) + if err != nil { + return nil, err + } + totalTimeout := g.Cfg().MustGet(ctx, "upstreamapi.channel_test.timeout", defaultChannelTestTimeout).Duration() + if totalTimeout <= 0 { + totalTimeout = defaultChannelTestTimeout + } + itemTimeout := g.Cfg().MustGet(ctx, "upstreamapi.channel_test.item_timeout", defaultChannelTestItemTimeout).Duration() + if itemTimeout <= 0 { + itemTimeout = defaultChannelTestItemTimeout + } + testCtx, cancel := context.WithTimeout(ctx, totalTimeout) + defer cancel() + + config, err := service.ChannelConfig().LoadChannelForTest(testCtx, input.ChannelID, input.TenantID) + if err != nil { + if errors.Is(err, model.ErrChannelNotAccessible) { + return nil, model.ErrChannelNotAccessible + } + return nil, gerror.Wrap(err, "load channel test configuration") + } + instance, err := u.newTestChannelInstance(testCtx, config) + if err != nil { + return nil, gerror.Wrap(err, "channel test configuration is invalid") + } + completionCtx, completionCancel := context.WithTimeout(testCtx, itemTimeout) + simKey, err := resolveChannelTestSimKey(completionCtx, instance, config.Channel.ChanKey, input) + completionCancel() + if err != nil { + return nil, err + } + + results := make([]*model.ChannelTestItem, 0, len(operations)) + now := time.Now() + for _, operation := range operations { + if testCtx.Err() != nil { + results = append(results, channelTestTimeoutResult(operation)) + continue + } + results = append(results, executeChannelTestOperation(testCtx, itemTimeout, operation, instance, simKey, now)) + } + return &model.ChannelTestOutput{Items: results}, nil +} + +func resolveChannelTestSimKey(ctx context.Context, instance model.SimCardChannelI, chanKey string, input *model.ChannelTestInput) (*model.SimKey, error) { + mode := strings.ToLower(strings.TrimSpace(input.InputMode)) + if mode == "" { + mode = model.ChannelTestInputModeComplete + } + cardType := strings.ToLower(strings.TrimSpace(input.CardValueType)) + cardValue := strings.TrimSpace(input.CardValue) + if mode == model.ChannelTestInputModeComplete { + if strings.TrimSpace(input.ICCID) == "" || strings.TrimSpace(input.IMSI) == "" || strings.TrimSpace(input.MSISDN) == "" { + return nil, gerror.New("complete mode requires iccid, imsi and msisdn") + } + return &model.SimKey{ChanKey: chanKey, ICCID: input.ICCID, IMSI: input.IMSI, MSISDN: input.MSISDN}, nil + } + if mode != model.ChannelTestInputModeSingle && mode != model.ChannelTestInputModeDirectSingle { + return nil, gerror.New("unsupported channel test input mode") + } + if cardValue == "" || !validChannelTestCardType(cardType) { + return nil, gerror.New("card value type and value are required") + } + key := &model.SimKey{ChanKey: chanKey} + setChannelTestCardValue(key, cardType, cardValue) + if mode == model.ChannelTestInputModeDirectSingle { + return key, nil + } + + // Card completion uses the same request-scoped instance as the selected + // checks; it never falls back to chanMap or creates a second instance. + basic, err := instance.QuerySimBasicInfo(ctx, &model.SimBasicInfoInput{SimKeys: []*model.SimKey{key}}) + if err != nil { + return nil, gerror.Wrap(err, "complete card identifiers") + } + if len(basic) == 0 || basic[0] == nil || basic[0].Code != consts.ApiStatusSuccess { + if len(basic) > 0 && basic[0] != nil && basic[0].Message != "" { + return nil, gerror.New(basic[0].Message) + } + return nil, gerror.New("carrier did not return card identifiers") + } + completed := basic[0].ResData + if strings.TrimSpace(completed.ICCID) == "" || strings.TrimSpace(completed.IMSI) == "" || strings.TrimSpace(completed.MSISDN) == "" { + return nil, gerror.New("carrier did not return complete card identifiers") + } + return &model.SimKey{ChanKey: chanKey, ICCID: completed.ICCID, IMSI: completed.IMSI, MSISDN: completed.MSISDN}, nil +} + +func validChannelTestCardType(valueType string) bool { + return valueType == "iccid" || valueType == "imsi" || valueType == "msisdn" +} + +func setChannelTestCardValue(key *model.SimKey, valueType, value string) { + switch valueType { + case "iccid": + key.ICCID = value + case "imsi": + key.IMSI = value + case "msisdn": + key.MSISDN = value + } +} + +func selectChannelTestOperations(requested []string) ([]channelTestOperation, error) { + all := newChannelTestOperations() + if len(requested) == 0 { + return all, nil + } + wanted := make(map[string]struct{}, len(requested)) + for _, key := range requested { + key = strings.TrimSpace(key) + if _, ok := model.ChannelTestOperationNames[key]; !ok { + return nil, gerror.Newf("unsupported channel test item: %s", key) + } + wanted[key] = struct{}{} + } + selected := make([]channelTestOperation, 0, len(wanted)) + for _, operation := range all { + if _, ok := wanted[operation.key]; ok { + selected = append(selected, operation) + } + } + return selected, nil +} + +func executeChannelTestOperation(parent context.Context, itemTimeout time.Duration, operation channelTestOperation, instance model.SimCardChannelI, simKey *model.SimKey, now time.Time) *model.ChannelTestItem { + started := time.Now() + callCtx, cancel := context.WithTimeout(parent, itemTimeout) + defer cancel() + data, errorCode, message, success, err := operation.execute(callCtx, instance, simKey, now) + result := &model.ChannelTestItem{Key: operation.key, Name: operation.name, Status: "failed", Data: data, ErrorCode: errorCode, DurationMs: time.Since(started).Milliseconds()} + if callCtx.Err() != nil { + result.Status = "timeout" + result.Message = "channel test item timed out" + return result + } + if err != nil { + result.Message = err.Error() + return result + } + if !success { + result.Message = message + return result + } + result.Status = "success" + result.Message = message + return result +} + +func channelTestTimeoutResult(operation channelTestOperation) *model.ChannelTestItem { + return &model.ChannelTestItem{Key: operation.key, Name: operation.name, Status: "timeout", Message: "channel test request timed out"} +} + +func newChannelTestOperations() []channelTestOperation { + testType := 0 + return []channelTestOperation{ + newChannelTestOperation("simBasicInfo", func(ctx context.Context, instance model.SimCardChannelI, key *model.SimKey, _ time.Time) ([]*model.SimBaseRes[model.SimBasicInfoItem], error) { + return instance.QuerySimBasicInfo(ctx, &model.SimBasicInfoInput{SimKeys: []*model.SimKey{key}}) + }), + newChannelTestOperation("simSession", func(ctx context.Context, instance model.SimCardChannelI, key *model.SimKey, _ time.Time) ([]*model.SimBaseRes[model.SimSessionItem], error) { + return instance.QuerySimSession(ctx, &model.SimSessionInput{SimKeys: []*model.SimKey{key}}) + }), + newChannelTestOperation("simStatus", func(ctx context.Context, instance model.SimCardChannelI, key *model.SimKey, _ time.Time) ([]*model.SimBaseRes[model.SimCardStatusItem], error) { + return instance.QuerySimCardStatus(ctx, &model.SimCardStatusInput{SimKeys: []*model.SimKey{key}}) + }), + newChannelTestOperation("simStopReason", func(ctx context.Context, instance model.SimCardChannelI, key *model.SimKey, _ time.Time) ([]*model.SimBaseRes[model.SimStopReasonItem], error) { + return instance.QuerySimStopReason(ctx, &model.SimStopReasonInput{SimKeys: []*model.SimKey{key}}) + }), + newChannelTestOperation("simFrozenState", func(ctx context.Context, instance model.SimCardChannelI, key *model.SimKey, _ time.Time) ([]*model.SimBaseRes[model.SimFrozenStateItem], error) { + return instance.QuerySimFrozenState(ctx, &model.SimFrozenStateInput{SimKeys: []*model.SimKey{key}}) + }), + newChannelTestOperation("simCardBindStatus", func(ctx context.Context, instance model.SimCardChannelI, key *model.SimKey, _ time.Time) ([]*model.SimBaseRes[model.SimCardBindStatusItem], error) { + return instance.QuerySimCardBindStatus(ctx, &model.SimCardBindStatusInput{SimKeys: []*model.SimKey{key}, ReqData: model.SimCardBindStatusInputItem{TestType: &testType}}) + }), + newChannelTestOperation("simDeviceStatus", func(ctx context.Context, instance model.SimCardChannelI, key *model.SimKey, _ time.Time) ([]*model.SimBaseRes[model.SimDeviceStatusItem], error) { + return instance.QuerySimDeviceStatus(ctx, &model.SimDeviceStatusInput{SimKeys: []*model.SimKey{key}}) + }), + newChannelTestOperation("simRegionLimitStatus", func(ctx context.Context, instance model.SimCardChannelI, key *model.SimKey, _ time.Time) ([]*model.SimBaseRes[model.SimRegionLimitStatusItem], error) { + return instance.QuerySimRegionLimitStatus(ctx, &model.SimRegionLimitStatusInput{SimKeys: []*model.SimKey{key}}) + }), + newChannelTestOperation("simImei", func(ctx context.Context, instance model.SimCardChannelI, key *model.SimKey, _ time.Time) ([]*model.SimBaseRes[model.SimImeiItem], error) { + return instance.QuerySimImei(ctx, &model.SimImeiInput{SimKeys: []*model.SimKey{key}}) + }), + newChannelTestOperation("simDataUsage", func(ctx context.Context, instance model.SimCardChannelI, key *model.SimKey, _ time.Time) ([]*model.SimBaseRes[model.SimDataUsageItem], error) { + return instance.QuerySimDataUsage(ctx, &model.SimDataUsageInput{SimKeys: []*model.SimKey{key}}) + }), + newChannelTestOperation("simDataUsageMonthly", func(ctx context.Context, instance model.SimCardChannelI, key *model.SimKey, now time.Time) ([]*model.SimBaseRes[model.SimDataUsageMonthlyOutputItem], error) { + last := now.AddDate(0, -1, 0) + return instance.QuerySimDataUsageMonthly(ctx, &model.SimDataUsageMonthlyInput{SimKeys: []*model.SimKey{key}, ReqData: model.SimDataUsageMonthlyInputItem{QueryDate: last.Format("200601")}}) + }), + newChannelTestOperation("simDataUsageDaily", func(ctx context.Context, instance model.SimCardChannelI, key *model.SimKey, now time.Time) ([]*model.SimBaseRes[model.SimDataUsageDailyOutputItem], error) { + yesterday := now.AddDate(0, 0, -1) + return instance.QuerySimDataUsageDaily(ctx, &model.SimDataUsageDailyInput{SimKeys: []*model.SimKey{key}, ReqData: model.SimDataUsageDailyInputItem{QueryDate: yesterday.Format("20060102")}}) + }), + newChannelTestOperation("simApnInfo", func(ctx context.Context, instance model.SimCardChannelI, key *model.SimKey, _ time.Time) ([]*model.SimBaseRes[model.SimApnInfoItemList], error) { + return instance.QuerySimApnInfo(ctx, &model.SimApnInfoInput{SimKeys: []*model.SimKey{key}}) + }), + } +} + +func newChannelTestOperation[T any](key string, query func(context.Context, model.SimCardChannelI, *model.SimKey, time.Time) ([]*model.SimBaseRes[T], error)) channelTestOperation { + return channelTestOperation{key: key, name: model.ChannelTestOperationNames[key], execute: func(ctx context.Context, instance model.SimCardChannelI, simKey *model.SimKey, now time.Time) (any, int, string, bool, error) { + output, err := query(ctx, instance, simKey, now) + if err != nil { + return nil, 0, "", false, err + } + if len(output) == 0 || output[0] == nil { + return nil, 0, "carrier returned no result", false, nil + } + first := output[0] + if first.Code != consts.ApiStatusSuccess { + return first.ResData, first.Code, first.Message, false, nil + } + return first.ResData, 0, first.Message, true, nil + }} +} diff --git a/internal/logic/upstreamapi/channel_test_runtime_test.go b/internal/logic/upstreamapi/channel_test_runtime_test.go new file mode 100644 index 0000000..a624902 --- /dev/null +++ b/internal/logic/upstreamapi/channel_test_runtime_test.go @@ -0,0 +1,163 @@ +package upstreamapi + +import ( + "context" + "testing" + + "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/model/entity" + "git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/service" +) + +type channelTestConfigSource struct { + service.IChannelConfig + config *model.SimChannelConfigItemCache + loadCall int +} + +func (s *channelTestConfigSource) LoadChannelForTest(_ context.Context, channelID int64, tenantID string) (*model.SimChannelConfigItemCache, error) { + s.loadCall++ + if channelID != 42 || tenantID != "tenant-a" { + return nil, model.ErrChannelNotAccessible + } + return s.config, nil +} + +type requestScopedTestChannel struct { + model.SimCardChannelI + basicCalls int + sessionCalls int +} + +func (c *requestScopedTestChannel) QuerySimBasicInfo(_ context.Context, input *model.SimBasicInfoInput) (model.SimBasicInfoOutput, error) { + c.basicCalls++ + return model.SimBasicInfoOutput{&model.SimBaseRes[model.SimBasicInfoItem]{ + Code: consts.ApiStatusSuccess, + SimKey: input.SimKeys[0], + ResData: model.SimBasicInfoItem{ + ICCID: "iccid-complete", IMSI: "imsi-complete", MSISDN: "msisdn-complete", + }, + }}, nil +} + +func (c *requestScopedTestChannel) QuerySimSession(_ context.Context, input *model.SimSessionInput) (model.SimSessionOutput, error) { + c.sessionCalls++ + return model.SimSessionOutput{&model.SimBaseRes[model.SimSessionItem]{ + Code: consts.ApiStatusSuccess, SimKey: input.SimKeys[0], ResData: model.SimSessionItem{Status: "0"}, + }}, nil +} + +type requestScopedTestTemplate struct { + calls int + channel *requestScopedTestChannel +} + +func (t *requestScopedTestTemplate) NewTemp(context.Context, *model.SimChannelConfigItemCache) model.SimCardChannelI { + t.calls++ + t.channel = &requestScopedTestChannel{} + return t.channel +} + +func TestChannelUsesOneTemporaryInstanceWithoutRuntimeRegistration(t *testing.T) { + abnormal := 1 + config := &model.SimChannelConfigItemCache{ + Channel: &entity.TSimChannels{Id: 42, TenantId: "tenant-a", ChanKey: "abnormal-channel", TempKey: "test-template"}, + Template: &entity.TSimChannelTemplate{TempKey: "test-template", TempModel: "direct"}, + IsNormal: &abnormal, + } + source := &channelTestConfigSource{config: config} + oldConfig := service.ChannelConfig() + service.RegisterChannelConfig(source) + t.Cleanup(func() { service.RegisterChannelConfig(oldConfig) }) + + api := NewUpstreamAPI() + template := &requestScopedTestTemplate{} + api.RegisterTemp("test-template", template) + output, err := api.TestChannel(context.Background(), &model.ChannelTestInput{ + ChannelID: 42, TenantID: "tenant-a", InputMode: model.ChannelTestInputModeSingle, + CardValueType: "iccid", CardValue: "iccid-only", Items: []string{"simBasicInfo", "simSession"}, + }) + if err != nil { + t.Fatal(err) + } + if source.loadCall != 1 || template.calls != 1 { + t.Fatalf("config loads=%d instance builds=%d", source.loadCall, template.calls) + } + if template.channel.basicCalls != 2 || template.channel.sessionCalls != 1 { + t.Fatalf("basic calls=%d session calls=%d", template.channel.basicCalls, template.channel.sessionCalls) + } + if len(output.Items) != 2 || output.Items[0].Status != "success" || output.Items[1].Status != "success" { + t.Fatalf("unexpected test output: %+v", output) + } + if _, ok := api.GetChan("abnormal-channel"); ok { + t.Fatal("temporary test instance entered the runtime channel map") + } +} + +func TestChannelPreservesExistingRuntimeInstance(t *testing.T) { + normal := 0 + config := &model.SimChannelConfigItemCache{ + Channel: &entity.TSimChannels{Id: 42, TenantId: "tenant-a", ChanKey: "normal-channel", TempKey: "test-template"}, + Template: &entity.TSimChannelTemplate{TempKey: "test-template", TempModel: "direct"}, + IsNormal: &normal, + } + source := &channelTestConfigSource{config: config} + oldConfig := service.ChannelConfig() + service.RegisterChannelConfig(source) + t.Cleanup(func() { service.RegisterChannelConfig(oldConfig) }) + + api := NewUpstreamAPI() + runtimeInstance := &requestScopedTestChannel{} + runtimeCache := &model.SimCardChannelCache{Instance: runtimeInstance, Version: "runtime-v1"} + api.RegisterChan("normal-channel", runtimeCache.Version, runtimeCache) + template := &requestScopedTestTemplate{} + api.RegisterTemp("test-template", template) + + _, err := api.TestChannel(context.Background(), &model.ChannelTestInput{ + ChannelID: 42, TenantID: "tenant-a", InputMode: model.ChannelTestInputModeComplete, + ICCID: "i", IMSI: "m", MSISDN: "n", Items: []string{"simSession"}, + }) + if err != nil { + t.Fatal(err) + } + got, ok := api.GetChan("normal-channel") + if !ok || got != runtimeCache || got.Instance != runtimeInstance { + t.Fatal("channel test replaced the existing runtime instance") + } +} + +func TestChannelRejectsTenantMismatchAndMutationItems(t *testing.T) { + normal := 0 + source := &channelTestConfigSource{config: &model.SimChannelConfigItemCache{ + Channel: &entity.TSimChannels{Id: 42, TenantId: "tenant-a", ChanKey: "channel", TempKey: "test-template"}, + Template: &entity.TSimChannelTemplate{TempKey: "test-template", TempModel: "direct"}, + IsNormal: &normal, + }} + oldConfig := service.ChannelConfig() + service.RegisterChannelConfig(source) + t.Cleanup(func() { service.RegisterChannelConfig(oldConfig) }) + api := NewUpstreamAPI() + api.RegisterTemp("test-template", &requestScopedTestTemplate{}) + + if _, err := api.TestChannel(context.Background(), &model.ChannelTestInput{ChannelID: 42, TenantID: "tenant-b"}); err == nil || err.Error() != "channel is not accessible" { + t.Fatalf("tenant mismatch error = %v", err) + } + if _, err := api.TestChannel(context.Background(), &model.ChannelTestInput{ + ChannelID: 42, TenantID: "tenant-a", InputMode: model.ChannelTestInputModeComplete, + ICCID: "i", IMSI: "m", MSISDN: "n", Items: []string{"changeSimStatus"}, + }); err == nil { + t.Fatal("mutation item was accepted") + } +} + +func TestUnregisterChanIsIdempotent(t *testing.T) { + api := NewUpstreamAPI() + api.UnregisterChan("missing") + api.RegisterChan("channel", "v1", &model.SimCardChannelCache{Instance: &requestScopedTestChannel{}}) + api.UnregisterChan("channel") + api.UnregisterChan("channel") + if _, ok := api.GetChan("channel"); ok { + t.Fatal("channel remains registered") + } +} diff --git a/internal/logic/upstreamapi/history_period.go b/internal/logic/upstreamapi/history_period.go new file mode 100644 index 0000000..b171127 --- /dev/null +++ b/internal/logic/upstreamapi/history_period.go @@ -0,0 +1,58 @@ +package upstreamapi + +import ( + "strings" + "time" +) + +const ( + historicalMonthLayout = "200601" + historicalDailyLayout = "20060102" + historicalMonthFormatError = "queryDate格式错误,应为yyyyMM" + historicalMonthRangeError = "月流量仅支持查询已结束月份;本月累计流量请调用 /sim/query/sim-datausage" + historicalDailyFormatError = "queryDate格式错误,应为yyyyMMdd" + historicalDailyRangeError = "日流量仅支持查询已结束日期,不支持当天或未来日期" +) + +var historicalPeriodLocation = time.FixedZone("UTC+8", 8*60*60) + +const currentBatchMonthRangeError = "电信月批量接口仅支持当月;历史月份请调用 /sim/query/sim-datausage-monthly" + +func validateCurrentBatchMonth(queryDate string, now time.Time) string { + queryDate = strings.TrimSpace(queryDate) + if _, err := time.ParseInLocation(historicalMonthLayout, queryDate, historicalPeriodLocation); err != nil { + return historicalMonthFormatError + } + if queryDate != now.In(historicalPeriodLocation).Format(historicalMonthLayout) { + return currentBatchMonthRangeError + } + return "" +} + +func validateHistoricalMonth(queryDate string, now time.Time) string { + queryDate = strings.TrimSpace(queryDate) + if len(queryDate) != len(historicalMonthLayout) { + return historicalMonthFormatError + } + if _, err := time.ParseInLocation(historicalMonthLayout, queryDate, historicalPeriodLocation); err != nil { + return historicalMonthFormatError + } + if queryDate >= now.In(historicalPeriodLocation).Format(historicalMonthLayout) { + return historicalMonthRangeError + } + return "" +} + +func validateHistoricalDay(queryDate string, now time.Time) string { + queryDate = strings.TrimSpace(queryDate) + if len(queryDate) != len(historicalDailyLayout) { + return historicalDailyFormatError + } + if _, err := time.ParseInLocation(historicalDailyLayout, queryDate, historicalPeriodLocation); err != nil { + return historicalDailyFormatError + } + if queryDate >= now.In(historicalPeriodLocation).Format(historicalDailyLayout) { + return historicalDailyRangeError + } + return "" +} diff --git a/internal/logic/upstreamapi/history_period_test.go b/internal/logic/upstreamapi/history_period_test.go new file mode 100644 index 0000000..d042a01 --- /dev/null +++ b/internal/logic/upstreamapi/history_period_test.go @@ -0,0 +1,294 @@ +package upstreamapi + +import ( + "context" + "testing" + "time" + + "git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/consts" + "git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/model" +) + +type historyUsageTestChannel struct { + model.SimCardChannelI + monthlyCalls int + dailyCalls int + monthlyBatchCalls int + dailyBatchCalls int + currentCalls int +} + +func (c *historyUsageTestChannel) QuerySimDataUsageMonthly(_ context.Context, input *model.SimDataUsageMonthlyInput) (model.SimDataUsageMonthlyOutput, error) { + c.monthlyCalls++ + output := make(model.SimDataUsageMonthlyOutput, 0, len(input.SimKeys)) + for _, simKey := range input.SimKeys { + output = append(output, &model.SimBaseRes[model.SimDataUsageMonthlyOutputItem]{ + SimKey: simKey, + Code: consts.ApiStatusSuccess, + }) + } + return output, nil +} + +func (c *historyUsageTestChannel) QuerySimDataUsageDaily(_ context.Context, input *model.SimDataUsageDailyInput) (model.SimDataUsageDailyOutput, error) { + c.dailyCalls++ + output := make(model.SimDataUsageDailyOutput, 0, len(input.SimKeys)) + for _, simKey := range input.SimKeys { + output = append(output, &model.SimBaseRes[model.SimDataUsageDailyOutputItem]{ + SimKey: simKey, + Code: consts.ApiStatusSuccess, + }) + } + return output, nil +} + +func (c *historyUsageTestChannel) QuerySimDataUsageMonthlyBatch(_ context.Context, input *model.SimDataUsageMonthlyInput) (model.SimDataUsageMonthlyOutput, error) { + c.monthlyBatchCalls++ + return c.QuerySimDataUsageMonthly(context.Background(), input) +} + +func (c *historyUsageTestChannel) QuerySimDataUsageDailyBatch(_ context.Context, input *model.SimDataUsageDailyInput) (model.SimDataUsageDailyOutput, error) { + c.dailyBatchCalls++ + return c.QuerySimDataUsageDaily(context.Background(), input) +} + +func (c *historyUsageTestChannel) QuerySimCardStatusBatch(_ context.Context, _ *model.SimCardStatusInput) (model.SimCardStatusOutput, error) { + return nil, nil +} + +func (c *historyUsageTestChannel) QuerySimDataUsage(_ context.Context, input *model.SimDataUsageInput) (model.SimDataUsageOutput, error) { + c.currentCalls++ + output := make(model.SimDataUsageOutput, 0, len(input.SimKeys)) + for _, simKey := range input.SimKeys { + output = append(output, &model.SimBaseRes[model.SimDataUsageItem]{ + SimKey: simKey, + Code: consts.ApiStatusSuccess, + }) + } + return output, nil +} + +func TestValidateHistoricalPeriodAtUTC8Boundary(t *testing.T) { + now := time.Date(2026, time.September, 2, 16, 0, 0, 0, time.UTC) + + monthCases := []struct { + name string + queryDate string + want string + }{ + {name: "previous month", queryDate: "202608", want: ""}, + {name: "current month", queryDate: "202609", want: historicalMonthRangeError}, + {name: "future month", queryDate: "202610", want: historicalMonthRangeError}, + {name: "empty month", queryDate: "", want: historicalMonthFormatError}, + {name: "hyphenated month", queryDate: "2026-08", want: historicalMonthFormatError}, + {name: "invalid month", queryDate: "202613", want: historicalMonthFormatError}, + } + for _, tc := range monthCases { + t.Run("month "+tc.name, func(t *testing.T) { + if got := validateHistoricalMonth(tc.queryDate, now); got != tc.want { + t.Fatalf("validateHistoricalMonth(%q) = %q, want %q", tc.queryDate, got, tc.want) + } + }) + } + + batchMonthCases := []struct { + name string + queryDate string + want string + }{ + {name: "previous month", queryDate: "202608", want: currentBatchMonthRangeError}, + {name: "current month", queryDate: "202609", want: ""}, + {name: "future month", queryDate: "202610", want: currentBatchMonthRangeError}, + {name: "empty month", queryDate: "", want: historicalMonthFormatError}, + {name: "invalid month", queryDate: "202613", want: historicalMonthFormatError}, + } + for _, tc := range batchMonthCases { + t.Run("batch month "+tc.name, func(t *testing.T) { + if got := validateCurrentBatchMonth(tc.queryDate, now); got != tc.want { + t.Fatalf("validateCurrentBatchMonth(%q) = %q, want %q", tc.queryDate, got, tc.want) + } + }) + } + + dayCases := []struct { + name string + queryDate string + want string + }{ + {name: "previous day", queryDate: "20260902", want: ""}, + {name: "current day", queryDate: "20260903", want: historicalDailyRangeError}, + {name: "future day", queryDate: "20260904", want: historicalDailyRangeError}, + {name: "empty day", queryDate: "", want: historicalDailyFormatError}, + {name: "hyphenated day", queryDate: "2026-09-02", want: historicalDailyFormatError}, + {name: "invalid day", queryDate: "20260230", want: historicalDailyFormatError}, + } + for _, tc := range dayCases { + t.Run("day "+tc.name, func(t *testing.T) { + if got := validateHistoricalDay(tc.queryDate, now); got != tc.want { + t.Fatalf("validateHistoricalDay(%q) = %q, want %q", tc.queryDate, got, tc.want) + } + }) + } +} + +func TestValidateHistoricalPeriodAcrossMonthEnd(t *testing.T) { + beforeMidnight := time.Date(2026, time.August, 31, 15, 59, 59, 0, time.UTC) + afterMidnight := time.Date(2026, time.August, 31, 16, 0, 0, 0, time.UTC) + + if got := validateHistoricalMonth("202608", beforeMidnight); got != historicalMonthRangeError { + t.Fatalf("current month before UTC+8 month end = %q", got) + } + if got := validateHistoricalDay("20260831", beforeMidnight); got != historicalDailyRangeError { + t.Fatalf("current day before UTC+8 month end = %q", got) + } + if got := validateHistoricalMonth("202608", afterMidnight); got != "" { + t.Fatalf("completed month after UTC+8 month end = %q", got) + } + if got := validateHistoricalDay("20260831", afterMidnight); got != "" { + t.Fatalf("completed day after UTC+8 month end = %q", got) + } + if got := validateCurrentBatchMonth("202608", beforeMidnight); got != "" { + t.Fatalf("current batch month before UTC+8 month end = %q", got) + } + if got := validateCurrentBatchMonth("202608", afterMidnight); got != currentBatchMonthRangeError { + t.Fatalf("previous batch month after UTC+8 month end = %q", got) + } + if got := validateCurrentBatchMonth("202609", afterMidnight); got != "" { + t.Fatalf("current batch month after UTC+8 month end = %q", got) + } +} + +func TestHistoricalPeriodValidationSkipsChannels(t *testing.T) { + ctx := context.Background() + api := NewUpstreamAPI() + channel := &historyUsageTestChannel{} + api.RegisterChan("test_channel", "v1", &model.SimCardChannelCache{Instance: channel}) + simKeys := []*model.SimKey{ + {ChanKey: "test_channel", ICCID: "iccid-1"}, + {ChanKey: "test_channel", ICCID: "iccid-2"}, + } + now := time.Now().In(historicalPeriodLocation) + + monthly, err := api.QuerySimDataUsageMonthly(ctx, &model.SimDataUsageMonthlyInput{ + SimKeys: simKeys, + ReqData: model.SimDataUsageMonthlyInputItem{QueryDate: now.Format("200601")}, + }) + if err != nil { + t.Fatalf("QuerySimDataUsageMonthly returned error: %v", err) + } + assertMonthlyFailures(t, monthly, simKeys, historicalMonthRangeError) + + daily, err := api.QuerySimDataUsageDaily(ctx, &model.SimDataUsageDailyInput{ + SimKeys: simKeys, + ReqData: model.SimDataUsageDailyInputItem{QueryDate: now.Format("20060102")}, + }) + if err != nil { + t.Fatalf("QuerySimDataUsageDaily returned error: %v", err) + } + assertDailyFailures(t, daily, simKeys, historicalDailyRangeError) + + monthlyBatch, err := api.QuerySimDataUsageMonthlyBatch(ctx, &model.SimDataUsageMonthlyInput{ + SimKeys: simKeys, + ReqData: model.SimDataUsageMonthlyInputItem{QueryDate: "999912"}, + }) + if err != nil { + t.Fatalf("QuerySimDataUsageMonthlyBatch returned error: %v", err) + } + assertMonthlyFailures(t, monthlyBatch, simKeys, currentBatchMonthRangeError) + + dailyBatch, err := api.QuerySimDataUsageDailyBatch(ctx, &model.SimDataUsageDailyInput{ + SimKeys: simKeys, + ReqData: model.SimDataUsageDailyInputItem{QueryDate: "99991231"}, + }) + if err != nil { + t.Fatalf("QuerySimDataUsageDailyBatch returned error: %v", err) + } + assertDailyFailures(t, dailyBatch, simKeys, historicalDailyRangeError) + + invalidMonthly, err := api.QuerySimDataUsageMonthly(ctx, &model.SimDataUsageMonthlyInput{ + SimKeys: simKeys, + ReqData: model.SimDataUsageMonthlyInputItem{QueryDate: "2026-08"}, + }) + if err != nil { + t.Fatalf("invalid monthly query returned error: %v", err) + } + assertMonthlyFailures(t, invalidMonthly, simKeys, historicalMonthFormatError) + + invalidDaily, err := api.QuerySimDataUsageDailyBatch(ctx, &model.SimDataUsageDailyInput{ + SimKeys: simKeys, + ReqData: model.SimDataUsageDailyInputItem{QueryDate: ""}, + }) + if err != nil { + t.Fatalf("invalid daily batch query returned error: %v", err) + } + assertDailyFailures(t, invalidDaily, simKeys, historicalDailyFormatError) + + if channel.monthlyCalls != 0 || channel.dailyCalls != 0 || channel.monthlyBatchCalls != 0 || channel.dailyBatchCalls != 0 { + t.Fatalf("rejected periods called channel: %+v", channel) + } +} + +func TestHistoricalPeriodsRouteAndCurrentUsageRemainsAvailable(t *testing.T) { + ctx := context.Background() + api := NewUpstreamAPI() + channel := &historyUsageTestChannel{} + api.RegisterChan("test_channel", "v1", &model.SimCardChannelCache{Instance: channel}) + simKeys := []*model.SimKey{{ChanKey: "test_channel", ICCID: "iccid-1"}} + currentMonth := time.Now().In(historicalPeriodLocation).Format(historicalMonthLayout) + + if _, err := api.QuerySimDataUsageMonthly(ctx, &model.SimDataUsageMonthlyInput{ + SimKeys: simKeys, + ReqData: model.SimDataUsageMonthlyInputItem{QueryDate: "200001"}, + }); err != nil { + t.Fatalf("historical monthly query returned error: %v", err) + } + if _, err := api.QuerySimDataUsageDaily(ctx, &model.SimDataUsageDailyInput{ + SimKeys: simKeys, + ReqData: model.SimDataUsageDailyInputItem{QueryDate: "20000101"}, + }); err != nil { + t.Fatalf("historical daily query returned error: %v", err) + } + if _, err := api.QuerySimDataUsageMonthlyBatch(ctx, &model.SimDataUsageMonthlyInput{ + SimKeys: simKeys, + ReqData: model.SimDataUsageMonthlyInputItem{QueryDate: currentMonth}, + }); err != nil { + t.Fatalf("current monthly batch query returned error: %v", err) + } + if _, err := api.QuerySimDataUsageDailyBatch(ctx, &model.SimDataUsageDailyInput{ + SimKeys: simKeys, + ReqData: model.SimDataUsageDailyInputItem{QueryDate: "20000101"}, + }); err != nil { + t.Fatalf("historical daily batch query returned error: %v", err) + } + if _, err := api.QuerySimDataUsage(ctx, &model.SimDataUsageInput{SimKeys: simKeys}); err != nil { + t.Fatalf("current usage query returned error: %v", err) + } + + if channel.monthlyCalls != 2 || channel.dailyCalls != 2 || channel.monthlyBatchCalls != 1 || channel.dailyBatchCalls != 1 || channel.currentCalls != 1 { + t.Fatalf("unexpected channel calls: %+v", channel) + } +} + +func assertMonthlyFailures(t *testing.T, output model.SimDataUsageMonthlyOutput, simKeys []*model.SimKey, message string) { + t.Helper() + if len(output) != len(simKeys) { + t.Fatalf("monthly output length = %d, want %d", len(output), len(simKeys)) + } + for i, item := range output { + if item.Code != consts.ApiStatusError || item.Message != message || item.SimKey != simKeys[i] { + t.Fatalf("monthly output[%d] = %+v", i, item) + } + } +} + +func assertDailyFailures(t *testing.T, output model.SimDataUsageDailyOutput, simKeys []*model.SimKey, message string) { + t.Helper() + if len(output) != len(simKeys) { + t.Fatalf("daily output length = %d, want %d", len(output), len(simKeys)) + } + for i, item := range output { + if item.Code != consts.ApiStatusError || item.Message != message || item.SimKey != simKeys[i] { + t.Fatalf("daily output[%d] = %+v", i, item) + } + } +} diff --git a/internal/logic/upstreamapi/normal_gate_test.go b/internal/logic/upstreamapi/normal_gate_test.go new file mode 100644 index 0000000..0dc476d --- /dev/null +++ b/internal/logic/upstreamapi/normal_gate_test.go @@ -0,0 +1,158 @@ +package upstreamapi + +import ( + "context" + "path/filepath" + "testing" + + "git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/logic/channelconfig" + "git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/model" + "git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/service" + + _ "github.com/gogf/gf/contrib/drivers/sqlite/v2" + "github.com/gogf/gf/v2/database/gdb" + "github.com/gogf/gf/v2/frame/g" +) + +type normalGateTestChannel struct { + model.SimCardChannelI +} + +type normalGateTestTemplate struct { + calls int + lastConfig *model.SimChannelConfigItemCache +} + +func (f *normalGateTestTemplate) NewTemp(_ context.Context, cfg *model.SimChannelConfigItemCache) model.SimCardChannelI { + f.calls++ + f.lastConfig = cfg + return &normalGateTestChannel{} +} + +// Exercise real ORM decoding and config versioning as well as both creation paths. +// All SQL runs against a temporary SQLite file, never a configured environment DB. +func TestChannelNormalCreationGate(t *testing.T) { + ctx := context.Background() + if err := gdb.SetConfigGroup("default", gdb.ConfigGroup{{ + Type: "sqlite", Name: filepath.Join(t.TempDir(), "channels.db"), + }}); err != nil { + t.Fatal(err) + } + db := g.DB() + t.Cleanup(func() { _ = db.Close(ctx) }) + execSQL := func(query string, args ...any) { + t.Helper() + if _, err := db.Exec(ctx, query, args...); err != nil { + t.Fatal(err) + } + } + execSQL("CREATE TABLE t_sim_channels (id INTEGER PRIMARY KEY, chan_key TEXT, temp_key TEXT, chan_name TEXT, is_normal INTEGER NULL)") + execSQL("CREATE TABLE t_sim_channel_template (id INTEGER PRIMARY KEY, temp_key TEXT, temp_model TEXT)") + execSQL("INSERT INTO t_sim_channel_template (id, temp_key, temp_model) VALUES (1, 'gate_test', 'direct')") + + oldConfig := service.ChannelConfig() + t.Cleanup(func() { service.RegisterChannelConfig(oldConfig) }) + + for _, path := range []string{"loop", "refresh"} { + t.Run(path, func(t *testing.T) { + for _, tc := range []struct { + name string + value any + eligible bool + }{ + {"yes_is_zero", 0, true}, + {"no_is_one", 1, false}, + {"null", nil, false}, + {"unexpected", 2, false}, + } { + t.Run(tc.name, func(t *testing.T) { + execSQL("DELETE FROM t_sim_channels") + execSQL("INSERT INTO t_sim_channels (id, chan_key, temp_key, is_normal) VALUES (1, 'gate_channel', 'gate_test', ?)", tc.value) + config := channelconfig.NewChannelConfig() + service.RegisterChannelConfig(config) + api := NewUpstreamAPI() + factory := &normalGateTestTemplate{} + api.RegisterTemp("gate_test", factory) + reload := func() { + t.Helper() + var err error + if path == "loop" { + err = config.LoadAllChannelConfig(ctx) + if err == nil { + err = api.loop(ctx) + } + } else { + err = api.RefreshChannel(ctx, "gate_channel") + } + if err != nil { + t.Fatalf("refresh must succeed: %v", err) + } + } + reload() + cfg, ok := config.GetChannel("gate_channel") + if !ok || cfg.CanCreateInstance() != tc.eligible { + t.Fatalf("wrong eligibility after ORM scan: %+v", cfg) + } + if tc.value == nil && cfg.IsNormal != nil { + t.Fatalf("SQL NULL decoded as non-nil: %v", *cfg.IsNormal) + } + if cfg.Channel.ChanKey != "gate_channel" { + t.Fatal("embedded channel fields were not scanned") + } + _, exists := api.GetChan("gate_channel") + if exists != tc.eligible || (factory.calls == 1) != tc.eligible { + t.Fatalf("instance exists=%v factory calls=%d eligible=%v", exists, factory.calls, tc.eligible) + } + initialVersion := cfg.Version + + // Only is_normal changes: the configuration cache must notice it. + execSQL("UPDATE t_sim_channels SET is_normal=0") + reload() + current, ok := api.GetChan("gate_channel") + if !ok || current.Instance == nil { + t.Fatal("eligible channel was not instantiated") + } + cfg, _ = config.GetChannel("gate_channel") + if !tc.eligible && cfg.Version == initialVersion { + t.Fatal("changing only is_normal did not change config version") + } + oldCalls := factory.calls + + // Becoming ineligible must remove the old runtime instance. + execSQL("UPDATE t_sim_channels SET is_normal=1, chan_name='changed while disabled', temp_key='missing_template'") + reload() + if _, exists := api.GetChan("gate_channel"); exists || factory.calls != oldCalls { + t.Fatal("ineligible channel retained or rebuilt its runtime instance") + } + cfg, _ = config.GetChannel("gate_channel") + if cfg.CanCreateInstance() { + t.Fatal("ineligible config must remain ineligible") + } + // Repeat the disabled refresh, as a retry would. + reload() + if factory.calls != oldCalls { + t.Fatal("retry created an ineligible instance") + } + + // A process restart cannot recreate the retained disabled instance. + restarted := NewUpstreamAPI() + restarted.RegisterTemp("gate_test", factory) + if err := restarted.loop(ctx); err != nil { + t.Fatal(err) + } + if _, ok := restarted.GetChan("gate_channel"); ok { + t.Fatal("restart recreated disabled instance") + } + + execSQL("UPDATE t_sim_channels SET is_normal=0, temp_key='gate_test'") + reload() + restored, _ := api.GetChan("gate_channel") + if restored == current || factory.calls != oldCalls+1 || + factory.lastConfig.Channel.ChanName != "changed while disabled" { + t.Fatal("restored channel did not rebuild using the latest configuration") + } + }) + } + }) + } +} diff --git a/internal/logic/upstreamapi/stale_loop_test.go b/internal/logic/upstreamapi/stale_loop_test.go new file mode 100644 index 0000000..7ce6db6 --- /dev/null +++ b/internal/logic/upstreamapi/stale_loop_test.go @@ -0,0 +1,53 @@ +package upstreamapi + +import ( + "context" + "testing" + + "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" +) + +type staleLoopConfigSource struct { + service.IChannelConfig + snapshot *model.SimChannelConfigItemCache + latest *model.SimChannelConfigItemCache +} + +func (s *staleLoopConfigSource) GetAllChannel(context.Context) ([]*model.SimChannelConfigItemCache, error) { + return []*model.SimChannelConfigItemCache{s.snapshot}, nil +} + +func (s *staleLoopConfigSource) GetChannel(string) (*model.SimChannelConfigItemCache, bool) { + return s.latest, true +} + +func TestLoopDoesNotReviveChannelFromStaleNormalSnapshot(t *testing.T) { + normal, abnormal := 0, 1 + snapshot := &model.SimChannelConfigItemCache{ + Channel: &entity.TSimChannels{ChanKey: "channel", TempKey: "template"}, + Template: &entity.TSimChannelTemplate{TempKey: "template", TempModel: "direct"}, + IsNormal: &normal, + Version: "old-normal", + } + latest := &model.SimChannelConfigItemCache{ + Channel: &entity.TSimChannels{ChanKey: "channel", TempKey: "template"}, + IsNormal: &abnormal, + Version: "new-abnormal", + } + source := &staleLoopConfigSource{snapshot: snapshot, latest: latest} + oldConfig := service.ChannelConfig() + service.RegisterChannelConfig(source) + t.Cleanup(func() { service.RegisterChannelConfig(oldConfig) }) + + api := NewUpstreamAPI() + api.RegisterChan("channel", "old-normal", &model.SimCardChannelCache{Instance: &requestScopedTestChannel{}, Version: "old-normal"}) + api.RegisterTemp("template", &requestScopedTestTemplate{}) + if err := api.loop(context.Background()); err != nil { + t.Fatal(err) + } + if _, ok := api.GetChan("channel"); ok { + t.Fatal("periodic loop revived or retained an abnormal channel from a stale snapshot") + } +} diff --git a/internal/logic/upstreamapi/upstreamapi.go b/internal/logic/upstreamapi/upstreamapi.go new file mode 100644 index 0000000..d116b69 --- /dev/null +++ b/internal/logic/upstreamapi/upstreamapi.go @@ -0,0 +1,1313 @@ +package upstreamapi + +import ( + "context" + "database/sql" + "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" +) + +type sUpstreamAPI struct { + tempMap map[string]model.SimCardTempI + modelMap map[string]model.SimCardModelFactory + chanMap map[string]*model.SimCardChannelCache + channelGuards sync.Map + sync.RWMutex + running bool +} + +func NewUpstreamAPI() *sUpstreamAPI { + return &sUpstreamAPI{ + chanMap: make(map[string]*model.SimCardChannelCache), + tempMap: make(map[string]model.SimCardTempI), + modelMap: make(map[string]model.SimCardModelFactory), + } +} + +func init() { + service.RegisterUpstreamAPI(NewUpstreamAPI()) +} + +func (u *sUpstreamAPI) Start(ctx context.Context) error { + u.running = true + lastReloadTime := int64(0) + go func() { + for u.running { + now := time.Now().Unix() + reloadInterval := g.Cfg().MustGet(ctx, "upstreamapi.reload_interval", 60).Int64() + if now-lastReloadTime >= reloadInterval { + lastReloadTime = now + err := u.loop(ctx) + if err != nil { + g.Log().Errorf(ctx, "sUpstreamAPI loop err %+v", err) + } + } + time.Sleep(1 * time.Second) + } + }() + g.Log().Infof(ctx, "sUpstreamAPI Start success") + return nil +} + +func (u *sUpstreamAPI) Stop(ctx context.Context) error { + u.running = false + return nil +} + +// RefreshChannel reloads the persisted channel configuration and atomically +// replaces the cached upstream implementation for that channel. +func (u *sUpstreamAPI) RefreshChannel(ctx context.Context, chanKey string) error { + chanKey = strings.TrimSpace(chanKey) + if chanKey == "" { + return gerror.New("chanKey is required") + } + return u.withChannelGuard(chanKey, func() error { + if err := service.ChannelConfig().RefreshChannel(ctx, chanKey); err != nil { + return err + } + channelConf, ok := service.ChannelConfig().GetChannel(chanKey) + if !ok || channelConf == nil { + return gerror.Newf("channel config not found: %s", chanKey) + } + if !channelConf.CanCreateInstance() { + u.UnregisterChan(chanKey) + g.Log().Infof(ctx, "channel instance removed because channel is abnormal, chanKey: %s", chanKey) + return nil + } + instance, err := u.newRuntimeChannelInstance(ctx, channelConf) + if err != nil { + return err + } + u.RegisterChan(chanKey, channelConf.Version, &model.SimCardChannelCache{Instance: instance, Version: channelConf.Version}) + g.Log().Infof(ctx, "channel refreshed, chanKey: %s, version: %s", chanKey, channelConf.Version) + return nil + }) +} + +// RemoveChannel drops the cached configuration and runtime instance for a +// channel. Deleting an already absent key is a successful no-op. +func (u *sUpstreamAPI) RemoveChannel(ctx context.Context, chanKey string) error { + chanKey = strings.TrimSpace(chanKey) + if chanKey == "" { + return gerror.New("chanKey is required") + } + return u.withChannelGuard(chanKey, func() error { + if err := service.ChannelConfig().RemoveChannel(ctx, chanKey); err != nil { + return err + } + u.UnregisterChan(chanKey) + g.Log().Infof(ctx, "channel removed, chanKey: %s", chanKey) + return nil + }) +} + +func (u *sUpstreamAPI) loop(ctx context.Context) error { + channelConfigs, err := service.ChannelConfig().GetAllChannel(ctx) + if err != nil { + return err + } + for _, channelConf := range channelConfigs { + if channelConf == nil || channelConf.Channel == nil { + continue + } + chanKey := strings.TrimSpace(channelConf.Channel.ChanKey) + if chanKey == "" { + continue + } + if err := u.withChannelGuard(chanKey, func() error { + latest, ok := service.ChannelConfig().GetChannel(chanKey) + if !ok || latest == nil || latest.Channel == nil { + u.UnregisterChan(chanKey) + return nil + } + if !latest.CanCreateInstance() { + u.UnregisterChan(chanKey) + return nil + } + tempKey := "" + if latest.Template != nil { + tempKey = latest.Template.TempKey + } + channelCache, ok := u.GetChan(chanKey) + if ok && channelCache != nil && channelCache.Version == latest.Version { + return nil + } + + isNew := !ok || channelCache == nil + g.Log().Infof(ctx, "Create channel instance begin, chanKey: %s, isNew: %v", chanKey, isNew) + instance, instanceErr := u.newRuntimeChannelInstance(ctx, latest) + if instanceErr != nil { + return gerror.Wrapf(instanceErr, "create channel instance failed, tempKey: %s", tempKey) + } + u.RegisterChan(chanKey, latest.Version, &model.SimCardChannelCache{ + Instance: instance, + Version: latest.Version, + }) + g.Log().Infof(ctx, "Create channel instance success, chanKey: %s, version: %s", chanKey, latest.Version) + return nil + }); err != nil { + g.Log().Errorf(ctx, "Create channel instance failed, chanKey: %s, err: %v", chanKey, err) + } + } + return nil +} + +func (u *sUpstreamAPI) RegisterChan(chanKey string, version string, channel *model.SimCardChannelCache) { + u.Lock() + defer u.Unlock() + u.chanMap[chanKey] = channel +} + +// UnregisterChan atomically removes a runtime channel. Missing keys are a +// successful no-op so refresh and rename retries remain idempotent. +func (u *sUpstreamAPI) UnregisterChan(chanKey string) { + u.Lock() + delete(u.chanMap, strings.TrimSpace(chanKey)) + u.Unlock() +} + +func (u *sUpstreamAPI) withChannelGuard(chanKey string, fn func() error) error { + guard, _ := u.channelGuards.LoadOrStore(chanKey, &sync.Mutex{}) + mu := guard.(*sync.Mutex) + mu.Lock() + defer mu.Unlock() + return fn() +} + +func (u *sUpstreamAPI) RegisterTemp(tempKey string, channel model.SimCardTempI) { + u.Lock() + defer u.Unlock() + u.tempMap[tempKey] = channel +} + +// RegisterModel registers a fallback implementation keyed by template model. +// Concrete temp_key registrations always take precedence over model factories. +func (u *sUpstreamAPI) RegisterModel(modelKey string, factory model.SimCardModelFactory) { + modelKey = strings.TrimSpace(modelKey) + if modelKey == "" || factory == nil { + return + } + u.Lock() + defer u.Unlock() + u.modelMap[modelKey] = factory +} + +func (u *sUpstreamAPI) GetTemp(tempKey string) (model.SimCardTempI, bool) { + u.RLock() + defer u.RUnlock() + temp, ok := u.tempMap[tempKey] + return temp, ok +} + +func (u *sUpstreamAPI) GetModel(modelKey string) (model.SimCardModelFactory, bool) { + u.RLock() + defer u.RUnlock() + factory, ok := u.modelMap[strings.TrimSpace(modelKey)] + return factory, ok +} + +func (u *sUpstreamAPI) newRuntimeChannelInstance(ctx context.Context, channelConf *model.SimChannelConfigItemCache) (model.SimCardChannelI, error) { + if !channelConf.CanCreateInstance() { + return nil, gerror.New("channel is_normal must be 0 to create a runtime instance") + } + return u.buildChannelInstance(ctx, channelConf) +} + +func (u *sUpstreamAPI) newTestChannelInstance(ctx context.Context, channelConf *model.SimChannelConfigItemCache) (model.SimCardChannelI, error) { + return u.buildChannelInstance(ctx, channelConf) +} + +func (u *sUpstreamAPI) buildChannelInstance(ctx context.Context, channelConf *model.SimChannelConfigItemCache) (model.SimCardChannelI, error) { + if channelConf == nil || channelConf.Channel == nil { + return nil, gerror.New("channel config is required") + } + if strings.TrimSpace(channelConf.Channel.ChanKey) == "" { + return nil, gerror.New("channel key is required") + } + if channelConf.Template == nil { + return nil, gerror.New("channel template config is required") + } + if strings.TrimSpace(channelConf.Template.TempKey) == "" { + return nil, gerror.New("channel template key is required") + } + if temp, ok := u.GetTemp(channelConf.Template.TempKey); ok && temp != nil { + instance := temp.NewTemp(ctx, channelConf) + if instance == nil { + return nil, gerror.Newf("create channel instance failed: %s", channelConf.Template.TempKey) + } + return instance, nil + } + modelKey := strings.TrimSpace(channelConf.Template.TempModel) + if modelKey == "" { + return nil, gerror.Newf("channel template model is required: tempKey=%s", channelConf.Template.TempKey) + } + if modelKey == "direct" { + return nil, gerror.Newf("direct channel template requires exact temp_key registration: tempKey=%s", channelConf.Template.TempKey) + } + factory, ok := u.GetModel(modelKey) + if !ok || factory == nil { + return nil, gerror.Newf("unsupported channel template model: tempKey=%s tempModel=%s", channelConf.Template.TempKey, modelKey) + } + instance := factory(ctx, channelConf) + if instance == nil { + return nil, gerror.Newf("create channel instance failed: tempKey=%s tempModel=%s", channelConf.Template.TempKey, modelKey) + } + return instance, nil +} + +func (u *sUpstreamAPI) GetChan(chanKey string) (*model.SimCardChannelCache, bool) { + u.RLock() + defer u.RUnlock() + channel, ok := u.chanMap[chanKey] + return channel, ok +} + +func iccidFromChannelCacheKey(key string) string { + const prefix = "sim:chan:iccid:" + if !strings.HasPrefix(key, prefix) { + return "" + } + return strings.TrimPrefix(key, prefix) +} + +func (u *sUpstreamAPI) packChanKey(ctx context.Context, simKeys []*model.SimKey) (map[string][]*model.SimKey, error) { + redisKey := "sim:chan:iccid:" + unknownChanKeyIccids := make([]string, 0) + queryRedisIccids := make([]string, 0) + queryDbIccids := make([]string, 0) + chanKeyMap := make(map[string][]*model.SimKey) + // 检查通道id是都携带 + for _, simKey := range simKeys { + if simKey.ChanKey == "" { + unknownChanKeyIccids = append(unknownChanKeyIccids, simKey.ICCID) + queryRedisIccids = append(queryRedisIccids, redisKey+simKey.ICCID) + } else { + chanKeyMap[simKey.ChanKey] = append(chanKeyMap[simKey.ChanKey], simKey) + } + } + if len(unknownChanKeyIccids) == 0 { + return chanKeyMap, nil + } + + allIccid := make(map[string]bool) + for _, iccid := range unknownChanKeyIccids { + allIccid[iccid] = false + } + // TODO: 临时数据库查询,后面添加内存缓存和Redis缓存 + // 从Redis中查询通道id + redisChanKeys, err := g.Redis().MGet(ctx, queryRedisIccids...) + if err != nil { + return nil, err + } + for iccidKey, chanKey := range redisChanKeys { + iccid := iccidFromChannelCacheKey(iccidKey) + if _, expected := allIccid[iccid]; !expected { + continue + } + chanKeyStr := chanKey.String() + if chanKeyStr == "" { + g.Log().Debug(ctx, "查询redis为空, iccid:"+iccid) + continue + } + allIccid[iccid] = true + chanKeyMap[chanKeyStr] = append(chanKeyMap[chanKeyStr], &model.SimKey{ + ChanKey: chanKeyStr, + ICCID: iccid, + }) + } + for iccid, found := range allIccid { + if !found { + queryDbIccids = append(queryDbIccids, iccid) + } + } + if len(queryDbIccids) == 0 { + return chanKeyMap, nil + } + + var simCardInfos []*entity.TSimCardInfo + err = dao.TSimCardInfo.Ctx(ctx). + Fields( + dao.TSimCardInfo.Columns().ChanKey, + dao.TSimCardInfo.Columns().Iccid, + ). + WhereIn(dao.TSimCardInfo.Columns().Iccid, queryDbIccids). + Scan(&simCardInfos) + if err != nil && err != sql.ErrNoRows { + return nil, err + } + + for _, simCardInfo := range simCardInfos { + allIccid[simCardInfo.Iccid] = true + chanKeyMap[simCardInfo.ChanKey] = append(chanKeyMap[simCardInfo.ChanKey], &model.SimKey{ + ChanKey: simCardInfo.ChanKey, + ICCID: simCardInfo.Iccid, + }) + g.Redis().Set(ctx, redisKey+simCardInfo.Iccid, simCardInfo.ChanKey) + } + for iccid, found := range allIccid { + if !found { + chanKeyMap[""] = append(chanKeyMap[""], &model.SimKey{ + ChanKey: "", + ICCID: iccid, + }) + } + } + return chanKeyMap, nil +} + +// 物联网卡基本信息查询 +func (u *sUpstreamAPI) QuerySimBasicInfo(ctx context.Context, input *model.SimBasicInfoInput) (model.SimBasicInfoOutput, error) { + output := make([]*model.SimBaseRes[model.SimBasicInfoItem], 0) + inputMap := make(map[string]*model.SimBasicInfoInput) + chanKeyMap, err := u.packChanKey(ctx, input.SimKeys) + if err != nil { + return nil, err + } + for chanKey, simKeys := range chanKeyMap { + inputMap[chanKey] = &model.SimBasicInfoInput{ + SimKeys: simKeys, + ReqData: input.ReqData, + } + } + for chanKey, inputNew := range inputMap { + if chanKey == "" { + for _, simKey := range inputNew.SimKeys { + output = append(output, &model.SimBaseRes[model.SimBasicInfoItem]{ + SimKey: simKey, + Code: consts.ApiStatusError, + Message: "通道未配置", + ResData: model.SimBasicInfoItem{ + ActiveDate: "", + OpenDate: "", + Remark: "", + }, + }) + } + continue + } + channel, ok := u.GetChan(chanKey) + if !ok || channel.Instance == nil { + for _, simKey := range inputNew.SimKeys { + output = append(output, &model.SimBaseRes[model.SimBasicInfoItem]{ + SimKey: simKey, + Code: consts.ApiStatusError, + Message: "通道未实例化", + ResData: model.SimBasicInfoItem{ + ActiveDate: "", + OpenDate: "", + Remark: "", + }, + }) + } + continue + } + + out, err := channel.Instance.QuerySimBasicInfo(ctx, inputNew) + if err != nil { + for _, simKey := range inputNew.SimKeys { + output = append(output, &model.SimBaseRes[model.SimBasicInfoItem]{ + SimKey: simKey, + Code: consts.ApiStatusError, + Message: err.Error(), + ResData: model.SimBasicInfoItem{ + ActiveDate: "", + OpenDate: "", + Remark: "", + }, + }) + } + } else { + for _, item := range out { + output = append(output, item) + } + } + } + return output, nil +} + +// 物联网卡在线信息实时查询 +func (u *sUpstreamAPI) QuerySimSession(ctx context.Context, input *model.SimSessionInput) (model.SimSessionOutput, error) { + output := make([]*model.SimBaseRes[model.SimSessionItem], 0) + inputMap := make(map[string]*model.SimSessionInput) + chanKeyMap, err := u.packChanKey(ctx, input.SimKeys) + if err != nil { + return nil, err + } + for chanKey, simKeys := range chanKeyMap { + inputMap[chanKey] = &model.SimSessionInput{ + SimKeys: simKeys, + ReqData: input.ReqData, + } + } + for chanKey, inputNew := range inputMap { + if chanKey == "" { + for _, simKey := range inputNew.SimKeys { + output = append(output, &model.SimBaseRes[model.SimSessionItem]{ + SimKey: simKey, + Code: consts.ApiStatusError, + Message: "通道未配置", + ResData: model.SimSessionItem{ + Status: "-2", + }, + }) + } + continue + } + channel, ok := u.GetChan(chanKey) + if !ok || channel.Instance == nil { + for _, simKey := range inputNew.SimKeys { + output = append(output, &model.SimBaseRes[model.SimSessionItem]{ + SimKey: simKey, + Code: consts.ApiStatusError, + Message: "通道未实例化", + ResData: model.SimSessionItem{ + Status: "-2", + }, + }) + } + continue + } + + out, err := channel.Instance.QuerySimSession(ctx, inputNew) + if err != nil { + for _, simKey := range inputNew.SimKeys { + output = append(output, &model.SimBaseRes[model.SimSessionItem]{ + SimKey: simKey, + Code: consts.ApiStatusError, + Message: err.Error(), + ResData: model.SimSessionItem{ + Status: "-2", + }, + }) + } + } else { + for _, item := range out { + output = append(output, item) + } + } + } + return output, nil +} + +// 物联网卡卡状态查询 +func (u *sUpstreamAPI) QuerySimCardStatus(ctx context.Context, input *model.SimCardStatusInput) (output model.SimCardStatusOutput, err error) { + output = make([]*model.SimBaseRes[model.SimCardStatusItem], 0) + inputMap := make(map[string]*model.SimCardStatusInput) + chanKeyMap, err := u.packChanKey(ctx, input.SimKeys) + if err != nil { + return nil, err + } + for chanKey, simKeys := range chanKeyMap { + inputMap[chanKey] = &model.SimCardStatusInput{ + SimKeys: simKeys, + ReqData: input.ReqData, + } + } + for chanKey, inputNew := range inputMap { + if chanKey == "" { + for _, simKey := range inputNew.SimKeys { + output = append(output, &model.SimBaseRes[model.SimCardStatusItem]{ + SimKey: simKey, + Code: consts.ApiStatusError, + Message: "通道未配置", + }) + } + continue + } + channel, ok := u.GetChan(chanKey) + if !ok || channel.Instance == nil { + for _, simKey := range inputNew.SimKeys { + output = append(output, &model.SimBaseRes[model.SimCardStatusItem]{ + SimKey: simKey, + Code: consts.ApiStatusError, + Message: "通道未实例化", + }) + } + continue + } + + out, err := channel.Instance.QuerySimCardStatus(ctx, inputNew) + if err != nil { + for _, simKey := range inputNew.SimKeys { + output = append(output, &model.SimBaseRes[model.SimCardStatusItem]{ + SimKey: simKey, + Code: consts.ApiStatusError, + Message: err.Error(), + }) + } + } else { + for _, item := range out { + output = append(output, item) + } + } + } + return output, nil +} + +// 物联网卡机卡绑定状态查询 +func (u *sUpstreamAPI) QuerySimStopReason(ctx context.Context, input *model.SimStopReasonInput) (output model.SimStopReasonOutput, err error) { + output = make([]*model.SimBaseRes[model.SimStopReasonItem], 0) + inputMap := make(map[string]*model.SimStopReasonInput) + chanKeyMap, err := u.packChanKey(ctx, input.SimKeys) + if err != nil { + return nil, err + } + for chanKey, simKeys := range chanKeyMap { + inputMap[chanKey] = &model.SimStopReasonInput{ + SimKeys: simKeys, + ReqData: input.ReqData, + } + } + for chanKey, inputNew := range inputMap { + if chanKey == "" { + for _, simKey := range inputNew.SimKeys { + output = append(output, &model.SimBaseRes[model.SimStopReasonItem]{ + SimKey: simKey, + Code: consts.ApiStatusError, + Message: "通道未配置", + ResData: model.SimStopReasonItem{}, + }) + } + continue + } + channel, ok := u.GetChan(chanKey) + if !ok || channel.Instance == nil { + for _, simKey := range inputNew.SimKeys { + output = append(output, &model.SimBaseRes[model.SimStopReasonItem]{ + SimKey: simKey, + Code: consts.ApiStatusError, + Message: "通道未实例化", + ResData: model.SimStopReasonItem{}, + }) + } + continue + } + + out, err := channel.Instance.QuerySimStopReason(ctx, inputNew) + if err != nil { + for _, simKey := range inputNew.SimKeys { + output = append(output, &model.SimBaseRes[model.SimStopReasonItem]{ + SimKey: simKey, + Code: consts.ApiStatusError, + Message: err.Error(), + ResData: model.SimStopReasonItem{}, + }) + } + } else { + for _, item := range out { + output = append(output, item) + } + } + } + return output, nil +} + +func (u *sUpstreamAPI) QuerySimFrozenState(ctx context.Context, input *model.SimFrozenStateInput) (output model.SimFrozenStateOutput, err error) { + output = make([]*model.SimBaseRes[model.SimFrozenStateItem], 0) + inputMap := make(map[string]*model.SimFrozenStateInput) + chanKeyMap, err := u.packChanKey(ctx, input.SimKeys) + if err != nil { + return nil, err + } + for chanKey, simKeys := range chanKeyMap { + inputMap[chanKey] = &model.SimFrozenStateInput{ + SimKeys: simKeys, + ReqData: input.ReqData, + } + } + for chanKey, inputNew := range inputMap { + if chanKey == "" { + for _, simKey := range inputNew.SimKeys { + output = append(output, &model.SimBaseRes[model.SimFrozenStateItem]{ + SimKey: simKey, + Code: consts.ApiStatusError, + Message: "通道未配置", + ResData: model.SimFrozenStateItem{}, + }) + } + continue + } + channel, ok := u.GetChan(chanKey) + if !ok || channel.Instance == nil { + for _, simKey := range inputNew.SimKeys { + output = append(output, &model.SimBaseRes[model.SimFrozenStateItem]{ + SimKey: simKey, + Code: consts.ApiStatusError, + Message: "通道未实例化", + ResData: model.SimFrozenStateItem{}, + }) + } + continue + } + + out, err := channel.Instance.QuerySimFrozenState(ctx, inputNew) + if err != nil { + for _, simKey := range inputNew.SimKeys { + output = append(output, &model.SimBaseRes[model.SimFrozenStateItem]{ + SimKey: simKey, + Code: consts.ApiStatusError, + Message: err.Error(), + ResData: model.SimFrozenStateItem{}, + }) + } + } else { + for _, item := range out { + output = append(output, item) + } + } + } + return output, nil +} + +func (u *sUpstreamAPI) QuerySimCardBindStatus(ctx context.Context, input *model.SimCardBindStatusInput) (output model.SimCardBindStatusOutput, err error) { + output = make([]*model.SimBaseRes[model.SimCardBindStatusItem], 0) + inputMap := make(map[string]*model.SimCardBindStatusInput) + chanKeyMap, err := u.packChanKey(ctx, input.SimKeys) + if err != nil { + return nil, err + } + for chanKey, simKeys := range chanKeyMap { + inputMap[chanKey] = &model.SimCardBindStatusInput{ + SimKeys: simKeys, + ReqData: input.ReqData, + } + } + for chanKey, inputNew := range inputMap { + if chanKey == "" { + for _, simKey := range inputNew.SimKeys { + output = append(output, &model.SimBaseRes[model.SimCardBindStatusItem]{ + SimKey: simKey, + Code: consts.ApiStatusError, + Message: "通道未配置", + ResData: model.SimCardBindStatusItem{ + BindStatus: "", + }, + }) + } + continue + } + channel, ok := u.GetChan(chanKey) + if !ok || channel.Instance == nil { + for _, simKey := range inputNew.SimKeys { + output = append(output, &model.SimBaseRes[model.SimCardBindStatusItem]{ + SimKey: simKey, + Code: consts.ApiStatusError, + Message: "通道未实例化", + ResData: model.SimCardBindStatusItem{ + BindStatus: "", + }, + }) + } + continue + } + + out, err := channel.Instance.QuerySimCardBindStatus(ctx, inputNew) + if err != nil { + for _, simKey := range inputNew.SimKeys { + output = append(output, &model.SimBaseRes[model.SimCardBindStatusItem]{ + SimKey: simKey, + Code: consts.ApiStatusError, + Message: err.Error(), + ResData: model.SimCardBindStatusItem{ + BindStatus: "", + }, + }) + } + } else { + for _, item := range out { + output = append(output, item) + } + } + } + return output, nil +} + +// 物联网卡设备状态查询 +func (u *sUpstreamAPI) QuerySimDeviceStatus(ctx context.Context, input *model.SimDeviceStatusInput) (output model.SimDeviceStatusOutput, err error) { + output = make([]*model.SimBaseRes[model.SimDeviceStatusItem], 0) + inputMap := make(map[string]*model.SimDeviceStatusInput) + chanKeyMap, err := u.packChanKey(ctx, input.SimKeys) + if err != nil { + return nil, err + } + for chanKey, simKeys := range chanKeyMap { + inputMap[chanKey] = &model.SimDeviceStatusInput{ + SimKeys: simKeys, + ReqData: input.ReqData, + } + } + for chanKey, inputNew := range inputMap { + if chanKey == "" { + for _, simKey := range inputNew.SimKeys { + output = append(output, &model.SimBaseRes[model.SimDeviceStatusItem]{ + SimKey: simKey, + Code: consts.ApiStatusError, + Message: "通道未配置", + ResData: model.SimDeviceStatusItem{ + DeviceStatus: "-2", + }, + }) + } + continue + } + channel, ok := u.GetChan(chanKey) + if !ok || channel.Instance == nil { + for _, simKey := range inputNew.SimKeys { + output = append(output, &model.SimBaseRes[model.SimDeviceStatusItem]{ + SimKey: simKey, + Code: consts.ApiStatusError, + Message: "通道未实例化", + ResData: model.SimDeviceStatusItem{ + DeviceStatus: "-2", + }, + }) + } + continue + } + + out, err := channel.Instance.QuerySimDeviceStatus(ctx, inputNew) + if err != nil { + for _, simKey := range inputNew.SimKeys { + output = append(output, &model.SimBaseRes[model.SimDeviceStatusItem]{ + SimKey: simKey, + Code: consts.ApiStatusError, + Message: err.Error(), + ResData: model.SimDeviceStatusItem{ + DeviceStatus: "-2", + }, + }) + } + } else { + for _, item := range out { + output = append(output, item) + } + } + } + return output, nil +} + +// 物联网卡地域限制状态查询 +func (u *sUpstreamAPI) QuerySimRegionLimitStatus(ctx context.Context, input *model.SimRegionLimitStatusInput) (output model.SimRegionLimitStatusOutput, err error) { + output = make([]*model.SimBaseRes[model.SimRegionLimitStatusItem], 0) + inputMap := make(map[string]*model.SimRegionLimitStatusInput) + chanKeyMap, err := u.packChanKey(ctx, input.SimKeys) + if err != nil { + return nil, err + } + for chanKey, simKeys := range chanKeyMap { + inputMap[chanKey] = &model.SimRegionLimitStatusInput{ + SimKeys: simKeys, + ReqData: input.ReqData, + } + } + for chanKey, inputNew := range inputMap { + if chanKey == "" { + for _, simKey := range inputNew.SimKeys { + output = append(output, &model.SimBaseRes[model.SimRegionLimitStatusItem]{ + SimKey: simKey, + Code: consts.ApiStatusError, + Message: "通道未配置", + ResData: model.SimRegionLimitStatusItem{ + RegionLimitStatus: "", + }, + }) + } + continue + } + channel, ok := u.GetChan(chanKey) + if !ok || channel.Instance == nil { + for _, simKey := range inputNew.SimKeys { + output = append(output, &model.SimBaseRes[model.SimRegionLimitStatusItem]{ + SimKey: simKey, + Code: consts.ApiStatusError, + Message: "通道未实例化", + ResData: model.SimRegionLimitStatusItem{ + RegionLimitStatus: "", + }, + }) + } + continue + } + + out, err := channel.Instance.QuerySimRegionLimitStatus(ctx, inputNew) + if err != nil { + for _, simKey := range inputNew.SimKeys { + output = append(output, &model.SimBaseRes[model.SimRegionLimitStatusItem]{ + SimKey: simKey, + Code: consts.ApiStatusError, + Message: err.Error(), + ResData: model.SimRegionLimitStatusItem{ + RegionLimitStatus: "", + }, + }) + } + } else { + for _, item := range out { + output = append(output, item) + } + } + } + return output, nil +} + +// 物联网卡IMEI查询 +func (u *sUpstreamAPI) QuerySimImei(ctx context.Context, input *model.SimImeiInput) (output model.SimImeiOutput, err error) { + output = make([]*model.SimBaseRes[model.SimImeiItem], 0) + inputMap := make(map[string]*model.SimImeiInput) + chanKeyMap, err := u.packChanKey(ctx, input.SimKeys) + if err != nil { + return nil, err + } + for chanKey, simKeys := range chanKeyMap { + inputMap[chanKey] = &model.SimImeiInput{ + SimKeys: simKeys, + ReqData: input.ReqData, + } + } + for chanKey, inputNew := range inputMap { + if chanKey == "" { + for _, simKey := range inputNew.SimKeys { + output = append(output, &model.SimBaseRes[model.SimImeiItem]{ + SimKey: simKey, + Code: consts.ApiStatusError, + Message: "通道未配置", + }) + } + continue + } + channel, ok := u.GetChan(chanKey) + if !ok || channel.Instance == nil { + for _, simKey := range inputNew.SimKeys { + output = append(output, &model.SimBaseRes[model.SimImeiItem]{ + SimKey: simKey, + Code: consts.ApiStatusError, + Message: "通道未实例化", + }) + } + continue + } + out, err := channel.Instance.QuerySimImei(ctx, inputNew) + if err != nil { + for _, simKey := range inputNew.SimKeys { + output = append(output, &model.SimBaseRes[model.SimImeiItem]{ + SimKey: simKey, + Code: consts.ApiStatusError, + Message: err.Error(), + }) + } + } else { + for _, item := range out { + output = append(output, item) + } + } + } + return output, nil +} + +// 单卡本月流量累计使用量查询 +func (u *sUpstreamAPI) QuerySimDataUsage(ctx context.Context, input *model.SimDataUsageInput) (output model.SimDataUsageOutput, err error) { + output = make([]*model.SimBaseRes[model.SimDataUsageItem], 0) + if input == nil || len(input.SimKeys) == 0 { + return output, nil + } + inputMap := make(map[string]*model.SimDataUsageInput) + chanKeyMap, err := u.packChanKey(ctx, input.SimKeys) + if err != nil { + return nil, err + } + for chanKey, simKeys := range chanKeyMap { + inputMap[chanKey] = &model.SimDataUsageInput{ + SimKeys: simKeys, + ReqData: input.ReqData, + } + } + for chanKey, inputNew := range inputMap { + if chanKey == "" { + for _, simKey := range inputNew.SimKeys { + output = append(output, &model.SimBaseRes[model.SimDataUsageItem]{ + SimKey: simKey, + Code: consts.ApiStatusError, + Message: "通道未配置", + }) + } + continue + } + channel, ok := u.GetChan(chanKey) + if !ok || channel.Instance == nil { + for _, simKey := range inputNew.SimKeys { + output = append(output, &model.SimBaseRes[model.SimDataUsageItem]{ + SimKey: simKey, + Code: consts.ApiStatusError, + Message: "通道未实例化", + }) + } + continue + } + + out, err := channel.Instance.QuerySimDataUsage(ctx, inputNew) + if err != nil { + for _, simKey := range inputNew.SimKeys { + output = append(output, &model.SimBaseRes[model.SimDataUsageItem]{ + SimKey: simKey, + Code: consts.ApiStatusError, + Message: err.Error(), + }) + } + } else { + for _, item := range out { + output = append(output, item) + } + } + } + return output, nil +} + +// 物联卡达量限速档位查询 +func (u *sUpstreamAPI) QuerySimDataDiagnosis(ctx context.Context, input *model.SimDataDiagnosisInput) (output model.SimDataDiagnosisOutput, err error) { + output = make([]*model.SimBaseRes[any], 0) + if input == nil || len(input.SimKeys) == 0 { + return output, nil + } + inputMap := make(map[string]*model.SimDataDiagnosisInput) + chanKeyMap, err := u.packChanKey(ctx, input.SimKeys) + if err != nil { + return nil, err + } + for chanKey, simKeys := range chanKeyMap { + inputMap[chanKey] = &model.SimDataDiagnosisInput{ + SimKeys: simKeys, + ReqData: input.ReqData, + } + } + for chanKey, inputNew := range inputMap { + if chanKey == "" { + for _, simKey := range inputNew.SimKeys { + output = append(output, &model.SimBaseRes[any]{ + SimKey: simKey, + Code: consts.ApiStatusError, + Message: "通道未配置", + }) + } + continue + } + channel, ok := u.GetChan(chanKey) + if !ok || channel.Instance == nil { + for _, simKey := range inputNew.SimKeys { + output = append(output, &model.SimBaseRes[any]{ + SimKey: simKey, + Code: consts.ApiStatusError, + Message: "通道未实例化", + }) + } + continue + } + out, err := channel.Instance.QuerySimDataDiagnosis(ctx, inputNew) + if err != nil { + for _, simKey := range inputNew.SimKeys { + output = append(output, &model.SimBaseRes[any]{ + SimKey: simKey, + Code: consts.ApiStatusError, + Message: err.Error(), + }) + } + } else { + for _, item := range out { + output = append(output, item) + } + } + } + return output, nil +} + +// 物联卡单月 GPRS流量使用量批量查询 +func (u *sUpstreamAPI) QuerySimDataUsageMonthly(ctx context.Context, input *model.SimDataUsageMonthlyInput) (output model.SimDataUsageMonthlyOutput, err error) { + output = make(model.SimDataUsageMonthlyOutput, 0) + if input == nil || len(input.SimKeys) == 0 { + return output, nil + } + if message := validateHistoricalMonth(input.ReqData.QueryDate, time.Now()); message != "" { + return appendBatchMonthlyFailures(output, input.SimKeys, message), nil + } + inputMap := make(map[string]*model.SimDataUsageMonthlyInput) + chanKeyMap, err := u.packChanKey(ctx, input.SimKeys) + if err != nil { + return nil, err + } + for chanKey, simKeys := range chanKeyMap { + inputMap[chanKey] = &model.SimDataUsageMonthlyInput{ + SimKeys: simKeys, + ReqData: input.ReqData, + } + } + for chanKey, inputNew := range inputMap { + if chanKey == "" { + for _, simKey := range inputNew.SimKeys { + output = append(output, &model.SimBaseRes[model.SimDataUsageMonthlyOutputItem]{ + SimKey: simKey, + Code: consts.ApiStatusError, + Message: "通道未配置", + }) + } + continue + } + channel, ok := u.GetChan(chanKey) + if !ok || channel.Instance == nil { + for _, simKey := range inputNew.SimKeys { + output = append(output, &model.SimBaseRes[model.SimDataUsageMonthlyOutputItem]{ + SimKey: simKey, + Code: consts.ApiStatusError, + Message: "通道未实例化", + }) + } + continue + } + + out, err := channel.Instance.QuerySimDataUsageMonthly(ctx, inputNew) + if err != nil { + for _, simKey := range inputNew.SimKeys { + output = append(output, &model.SimBaseRes[model.SimDataUsageMonthlyOutputItem]{ + SimKey: simKey, + Code: consts.ApiStatusError, + Message: err.Error(), + }) + } + } else { + for _, item := range out { + output = append(output, item) + } + } + } + return output, nil +} + +// 物联网卡数据流量查询 日查询 +func (u *sUpstreamAPI) QuerySimDataUsageDaily(ctx context.Context, input *model.SimDataUsageDailyInput) (output model.SimDataUsageDailyOutput, err error) { + output = make(model.SimDataUsageDailyOutput, 0) + if input == nil || len(input.SimKeys) == 0 { + return output, nil + } + if message := validateHistoricalDay(input.ReqData.QueryDate, time.Now()); message != "" { + return appendBatchDailyFailures(output, input.SimKeys, message), nil + } + inputMap := make(map[string]*model.SimDataUsageDailyInput) + chanKeyMap, err := u.packChanKey(ctx, input.SimKeys) + if err != nil { + return nil, err + } + for chanKey, simKeys := range chanKeyMap { + inputMap[chanKey] = &model.SimDataUsageDailyInput{ + SimKeys: simKeys, + ReqData: input.ReqData, + } + } + for chanKey, inputNew := range inputMap { + if chanKey == "" { + for _, simKey := range inputNew.SimKeys { + output = append(output, &model.SimBaseRes[model.SimDataUsageDailyOutputItem]{ + SimKey: simKey, + Code: consts.ApiStatusError, + Message: "通道未配置", + }) + } + continue + } + channel, ok := u.GetChan(chanKey) + if !ok || channel.Instance == nil { + for _, simKey := range inputNew.SimKeys { + output = append(output, &model.SimBaseRes[model.SimDataUsageDailyOutputItem]{ + SimKey: simKey, + Code: consts.ApiStatusError, + Message: "通道未实例化", + }) + } + continue + } + + out, err := channel.Instance.QuerySimDataUsageDaily(ctx, inputNew) + if err != nil { + for _, simKey := range inputNew.SimKeys { + output = append(output, &model.SimBaseRes[model.SimDataUsageDailyOutputItem]{ + SimKey: simKey, + Code: consts.ApiStatusError, + Message: err.Error(), + }) + } + } else { + for _, item := range out { + output = append(output, item) + } + } + } + return output, nil +} + +// 物联网卡已开通APN信息查询 +func (u *sUpstreamAPI) QuerySimApnInfo(ctx context.Context, input *model.SimApnInfoInput) (output model.SimApnInfoOutput, err error) { + output = make(model.SimApnInfoOutput, 0) + inputMap := make(map[string]*model.SimApnInfoInput) + chanKeyMap, err := u.packChanKey(ctx, input.SimKeys) + if err != nil { + return nil, err + } + for chanKey, simKeys := range chanKeyMap { + inputMap[chanKey] = &model.SimApnInfoInput{ + SimKeys: simKeys, + ReqData: input.ReqData, + } + } + for chanKey, inputNew := range inputMap { + if chanKey == "" { + for _, simKey := range inputNew.SimKeys { + output = append(output, &model.SimBaseRes[model.SimApnInfoItemList]{ + SimKey: simKey, + Code: consts.ApiStatusError, + Message: "通道未配置", + }) + } + continue + } + channel, ok := u.GetChan(chanKey) + if !ok || channel.Instance == nil { + for _, simKey := range inputNew.SimKeys { + output = append(output, &model.SimBaseRes[model.SimApnInfoItemList]{ + SimKey: simKey, + Code: consts.ApiStatusError, + Message: "通道未实例化", + }) + } + continue + } + out, err := channel.Instance.QuerySimApnInfo(ctx, inputNew) + if err != nil { + for _, simKey := range inputNew.SimKeys { + output = append(output, &model.SimBaseRes[model.SimApnInfoItemList]{ + SimKey: simKey, + Code: consts.ApiStatusError, + Message: err.Error(), + }) + } + } else { + for _, item := range out { + output = append(output, item) + } + } + } + return output, nil +} + +// 物联网卡状态变更 +func (u *sUpstreamAPI) ChangeSimStatus(ctx context.Context, input *model.SimCardStatusChangeInput) (output model.SimCardStatusChangeOutput, err error) { + output = make([]*model.SimBaseRes[model.SimCardStatusChangeItem], 0) + inputMap := make(map[string]*model.SimCardStatusChangeInput) + chanKeyMap, err := u.packChanKey(ctx, input.SimKeys) + if err != nil { + return nil, err + } + for chanKey, simKeys := range chanKeyMap { + inputMap[chanKey] = &model.SimCardStatusChangeInput{ + SimKeys: simKeys, + ReqData: input.ReqData, + } + } + for chanKey, inputNew := range inputMap { + if chanKey == "" { + for _, simKey := range inputNew.SimKeys { + output = append(output, &model.SimBaseRes[model.SimCardStatusChangeItem]{ + SimKey: simKey, + Code: consts.ApiStatusError, + Message: "通道未配置", + }) + } + continue + } + channel, ok := u.GetChan(chanKey) + if !ok || channel.Instance == nil { + for _, simKey := range inputNew.SimKeys { + output = append(output, &model.SimBaseRes[model.SimCardStatusChangeItem]{ + SimKey: simKey, + Code: consts.ApiStatusError, + Message: "通道未实例化", + }) + } + continue + } + + out, err := channel.Instance.ChangeSimStatus(ctx, inputNew) + if err != nil { + for _, simKey := range inputNew.SimKeys { + output = append(output, &model.SimBaseRes[model.SimCardStatusChangeItem]{ + SimKey: simKey, + Code: consts.ApiStatusError, + Message: err.Error(), + }) + } + } else { + for _, item := range out { + output = append(output, item) + } + } + } + return output, nil +} + +func (u *sUpstreamAPI) ChangeSimCardOperateApn(ctx context.Context, input *model.SimCardOperateChangeApnInput) (output model.SimCardOperateChangeApnOutput, err error) { + output = make([]*model.SimBaseRes[model.SimCardOperateChangeApnItem], 0) + inputMap := make(map[string]*model.SimCardOperateChangeApnInput) + chanKeyMap, err := u.packChanKey(ctx, input.SimKeys) + if err != nil { + return nil, err + } + for chanKey, simKeys := range chanKeyMap { + inputMap[chanKey] = &model.SimCardOperateChangeApnInput{ + SimKeys: simKeys, + ReqData: input.ReqData, + } + } + for chanKey, inputNew := range inputMap { + if chanKey == "" { + for _, simKey := range inputNew.SimKeys { + output = append(output, &model.SimBaseRes[model.SimCardOperateChangeApnItem]{ + SimKey: simKey, + Code: consts.ApiStatusError, + Message: "通道未配置", + }) + } + continue + } + channel, ok := u.GetChan(chanKey) + if !ok || channel.Instance == nil { + for _, simKey := range inputNew.SimKeys { + output = append(output, &model.SimBaseRes[model.SimCardOperateChangeApnItem]{ + SimKey: simKey, + Code: consts.ApiStatusError, + Message: "通道未实例化", + }) + } + continue + } + 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(), + }) + } + } else { + for _, item := range out { + output = append(output, item) + } + } + } + return output, nil +} diff --git a/internal/logic/upstreamapi/upstreamapi_batch.go b/internal/logic/upstreamapi/upstreamapi_batch.go new file mode 100644 index 0000000..defe79b --- /dev/null +++ b/internal/logic/upstreamapi/upstreamapi_batch.go @@ -0,0 +1,191 @@ +package upstreamapi + +import ( + "context" + "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) { + return u.queryStatusBatch(ctx, input) +} + +func (u *sUpstreamAPI) QuerySimDataUsageMonthlyBatch(ctx context.Context, input *model.SimDataUsageMonthlyInput) (output model.SimDataUsageMonthlyOutput, err error) { + return u.queryMonthlyBatch(ctx, input) +} + +func (u *sUpstreamAPI) QuerySimDataUsageDailyBatch(ctx context.Context, input *model.SimDataUsageDailyInput) (output model.SimDataUsageDailyOutput, err error) { + return u.queryDailyBatch(ctx, input) +} + +func (u *sUpstreamAPI) queryStatusBatch(ctx context.Context, input *model.SimCardStatusInput) (output model.SimCardStatusOutput, err error) { + output = make(model.SimCardStatusOutput, 0) + if input == nil || len(input.SimKeys) == 0 { + return output, nil + } + + inputMap := make(map[string]*model.SimCardStatusInput) + chanKeyMap, err := u.packChanKey(ctx, input.SimKeys) + if err != nil { + return nil, err + } + for chanKey, simKeys := range chanKeyMap { + inputMap[chanKey] = &model.SimCardStatusInput{ + SimKeys: simKeys, + ReqData: input.ReqData, + } + } + + for chanKey, inputNew := range inputMap { + if chanKey == "" { + output = appendBatchStatusFailures(output, inputNew.SimKeys, "通道未配置") + continue + } + channel, ok := u.GetChan(chanKey) + if !ok || channel.Instance == nil { + output = appendBatchStatusFailures(output, inputNew.SimKeys, "通道未实例化") + continue + } + batchChannel, ok := channel.Instance.(model.SimCardBatchQueryI) + if !ok { + output = appendBatchStatusFailures(output, inputNew.SimKeys, "通道不支持批量查询") + continue + } + + out, err := batchChannel.QuerySimCardStatusBatch(ctx, inputNew) + if err != nil { + output = appendBatchStatusFailures(output, inputNew.SimKeys, err.Error()) + continue + } + output = append(output, out...) + } + return output, nil +} + +func (u *sUpstreamAPI) queryMonthlyBatch(ctx context.Context, input *model.SimDataUsageMonthlyInput) (output model.SimDataUsageMonthlyOutput, err error) { + output = make(model.SimDataUsageMonthlyOutput, 0) + if input == nil || len(input.SimKeys) == 0 { + return output, nil + } + if message := validateCurrentBatchMonth(input.ReqData.QueryDate, time.Now()); message != "" { + return appendBatchMonthlyFailures(output, input.SimKeys, message), nil + } + + inputMap := make(map[string]*model.SimDataUsageMonthlyInput) + chanKeyMap, err := u.packChanKey(ctx, input.SimKeys) + if err != nil { + return nil, err + } + for chanKey, simKeys := range chanKeyMap { + inputMap[chanKey] = &model.SimDataUsageMonthlyInput{ + SimKeys: simKeys, + ReqData: input.ReqData, + } + } + + for chanKey, inputNew := range inputMap { + if chanKey == "" { + output = appendBatchMonthlyFailures(output, inputNew.SimKeys, "通道未配置") + continue + } + channel, ok := u.GetChan(chanKey) + if !ok || channel.Instance == nil { + output = appendBatchMonthlyFailures(output, inputNew.SimKeys, "通道未实例化") + continue + } + batchChannel, ok := channel.Instance.(model.SimCardBatchQueryI) + if !ok { + output = appendBatchMonthlyFailures(output, inputNew.SimKeys, "通道不支持批量查询") + continue + } + + out, err := batchChannel.QuerySimDataUsageMonthlyBatch(ctx, inputNew) + if err != nil { + output = appendBatchMonthlyFailures(output, inputNew.SimKeys, err.Error()) + continue + } + output = append(output, out...) + } + return output, nil +} + +func (u *sUpstreamAPI) queryDailyBatch(ctx context.Context, input *model.SimDataUsageDailyInput) (output model.SimDataUsageDailyOutput, err error) { + output = make(model.SimDataUsageDailyOutput, 0) + if input == nil || len(input.SimKeys) == 0 { + return output, nil + } + if message := validateHistoricalDay(input.ReqData.QueryDate, time.Now()); message != "" { + return appendBatchDailyFailures(output, input.SimKeys, message), nil + } + + inputMap := make(map[string]*model.SimDataUsageDailyInput) + chanKeyMap, err := u.packChanKey(ctx, input.SimKeys) + if err != nil { + return nil, err + } + for chanKey, simKeys := range chanKeyMap { + inputMap[chanKey] = &model.SimDataUsageDailyInput{ + SimKeys: simKeys, + ReqData: input.ReqData, + } + } + + for chanKey, inputNew := range inputMap { + if chanKey == "" { + output = appendBatchDailyFailures(output, inputNew.SimKeys, "通道未配置") + continue + } + channel, ok := u.GetChan(chanKey) + if !ok || channel.Instance == nil { + output = appendBatchDailyFailures(output, inputNew.SimKeys, "通道未实例化") + continue + } + batchChannel, ok := channel.Instance.(model.SimCardBatchQueryI) + if !ok { + output = appendBatchDailyFailures(output, inputNew.SimKeys, "通道不支持批量查询") + continue + } + + out, err := batchChannel.QuerySimDataUsageDailyBatch(ctx, inputNew) + if err != nil { + output = appendBatchDailyFailures(output, inputNew.SimKeys, err.Error()) + continue + } + output = append(output, out...) + } + return output, nil +} + +func appendBatchStatusFailures(output model.SimCardStatusOutput, simKeys []*model.SimKey, msg string) model.SimCardStatusOutput { + for _, simKey := range simKeys { + output = append(output, &model.SimBaseRes[model.SimCardStatusItem]{ + SimKey: simKey, + Code: consts.ApiStatusError, + Message: msg, + }) + } + return output +} + +func appendBatchMonthlyFailures(output model.SimDataUsageMonthlyOutput, simKeys []*model.SimKey, msg string) model.SimDataUsageMonthlyOutput { + for _, simKey := range simKeys { + output = append(output, &model.SimBaseRes[model.SimDataUsageMonthlyOutputItem]{ + SimKey: simKey, + Code: consts.ApiStatusError, + Message: msg, + }) + } + return output +} + +func appendBatchDailyFailures(output model.SimDataUsageDailyOutput, simKeys []*model.SimKey, msg string) model.SimDataUsageDailyOutput { + for _, simKey := range simKeys { + output = append(output, &model.SimBaseRes[model.SimDataUsageDailyOutputItem]{ + SimKey: simKey, + Code: consts.ApiStatusError, + Message: msg, + }) + } + return output +} diff --git a/internal/logic/upstreamimpl/apika_template_test.go b/internal/logic/upstreamimpl/apika_template_test.go new file mode 100644 index 0000000..6d66569 --- /dev/null +++ b/internal/logic/upstreamimpl/apika_template_test.go @@ -0,0 +1,33 @@ +package upstreamimpl + +import ( + "context" + "testing" + + "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" +) + +func TestApikaTemplateRegistration(t *testing.T) { + template, ok := service.UpstreamAPI().GetTemp(sharedOneLinkApikaTemplateKey) + if !ok || template == nil { + t.Fatal("apika template not registered") + } + + config := &model.SimChannelConfigItemCache{ + Template: &entity.TSimChannelTemplate{ + TempKey: sharedOneLinkApikaTemplateKey, + TempConfig: `{"token":{"provider":"apika","baseUrl":"https://example.com","path":"/iot-api/iot-card-info/getRefreshToken"},"api":{"baseUrl":"https://api.iot.10086.cn/v5/","timeout":"1s"}}`, + }, + Channel: &entity.TSimChannels{ + ChanKey: "apika-test", + ChanConfig: `{"appId":"appid"}`, + AppKey: "app-key", + AppSecret: "app-secret", + }, + } + if instance := template.NewTemp(context.Background(), config); instance == nil { + t.Fatal("apika template failed to instantiate") + } +} diff --git a/internal/logic/upstreamimpl/apika_token_provider.go b/internal/logic/upstreamimpl/apika_token_provider.go new file mode 100644 index 0000000..62ef33b --- /dev/null +++ b/internal/logic/upstreamimpl/apika_token_provider.go @@ -0,0 +1,130 @@ +package upstreamimpl + +import ( + "bytes" + "context" + "crypto/sha256" + "encoding/base64" + "encoding/hex" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + "time" + + sharedtoken "git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/token" +) + +const apikaTokenPath = "/iot-api/iot-card-info/getRefreshToken" + +type ApikaTokenConfig struct { + BaseURL string + Path string + AppKey string + AppSecret string + AppID string + Timeout time.Duration +} + +type ApikaTokenProvider struct { + baseURL string + path string + appKey string + appSecret string + appID string + client *http.Client +} + +type apikaTokenResponse struct { + Code int `json:"code"` + Msg string `json:"msg"` + Data struct { + ShareToken string `json:"shareToken"` + } `json:"data"` +} + +type apikaFetcher struct{ provider *ApikaTokenProvider } + +func NewApikaTokenProvider(config ApikaTokenConfig) *ApikaTokenProvider { + timeout := config.Timeout + if timeout <= 0 { + timeout = defaultUpstreamHTTPTimeout + } + return &ApikaTokenProvider{ + baseURL: strings.TrimRight(strings.TrimSpace(config.BaseURL), "/"), + path: strings.TrimSpace(normalizeProviderPath(config.Path, apikaTokenPath)), + appKey: strings.TrimSpace(config.AppKey), appSecret: config.AppSecret, + appID: strings.TrimSpace(config.AppID), client: &http.Client{Timeout: timeout}, + } +} + +func apikaSignature(appKey, appSecret string) string { + data := "{" + appKey + "}:" + "{" + appSecret + "}" + sum := sha256.Sum256([]byte(data)) + hexValue := strings.ToUpper(hex.EncodeToString(sum[:])) + return base64.StdEncoding.EncodeToString([]byte(hexValue)) +} + +func (f apikaFetcher) FetchToken(ctx context.Context, _ string) (sharedtoken.OneLinkToken, error) { + return f.provider.Fetch(ctx) +} + +func (p *ApikaTokenProvider) Fetch(ctx context.Context) (sharedtoken.OneLinkToken, error) { + if p == nil || p.client == nil { + return sharedtoken.OneLinkToken{}, fmt.Errorf("apika token provider is not initialized") + } + if p.baseURL == "" { + return sharedtoken.OneLinkToken{}, fmt.Errorf("apika token base URL is required") + } + if p.appKey == "" || strings.TrimSpace(p.appSecret) == "" || p.appID == "" { + return sharedtoken.OneLinkToken{}, fmt.Errorf("apika appKey, appSecret and appid are required") + } + signature := apikaSignature(p.appKey, p.appSecret) + payload, err := json.Marshal(struct { + AppID string `json:"appid"` + AppKey string `json:"appKey"` + AppSecret string `json:"appSecret"` + Signature string `json:"signature"` + }{AppID: p.appID, AppKey: p.appKey, AppSecret: p.appSecret, Signature: signature}) + if err != nil { + return sharedtoken.OneLinkToken{}, fmt.Errorf("encode apika token request: %w", err) + } + req, err := http.NewRequestWithContext(ctx, http.MethodPost, joinProviderURL(p.baseURL, p.path), bytes.NewReader(payload)) + if err != nil { + return sharedtoken.OneLinkToken{}, fmt.Errorf("create apika token request: %w", err) + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/json") + req.Header.Set("appKey", p.appKey) + req.Header.Set("appSecret", p.appSecret) + req.Header.Set("signature", signature) + resp, err := p.client.Do(req) + if err != nil { + return sharedtoken.OneLinkToken{}, fmt.Errorf("request apika token: %w", err) + } + defer resp.Body.Close() + body, err := io.ReadAll(io.LimitReader(resp.Body, 4*1024*1024+1)) + if err != nil { + return sharedtoken.OneLinkToken{}, fmt.Errorf("read apika token response: %w", err) + } + if len(body) > 4*1024*1024 { + return sharedtoken.OneLinkToken{}, fmt.Errorf("apika token response is too large") + } + if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices { + return sharedtoken.OneLinkToken{}, fmt.Errorf("apika token HTTP status %d", resp.StatusCode) + } + var output apikaTokenResponse + if err := json.Unmarshal(body, &output); err != nil { + return sharedtoken.OneLinkToken{}, fmt.Errorf("decode apika token response: %w", err) + } + if output.Code != http.StatusOK { + return sharedtoken.OneLinkToken{}, fmt.Errorf("apika token failed: code=%d msg=%s", output.Code, strings.TrimSpace(output.Msg)) + } + value := strings.TrimSpace(output.Data.ShareToken) + if value == "" { + return sharedtoken.OneLinkToken{}, fmt.Errorf("apika shareToken is empty") + } + now := time.Now() + return sharedtoken.OneLinkToken{Value: value, CreatedAt: now, ExpiresAt: now.Add(5 * time.Minute)}, nil +} diff --git a/internal/logic/upstreamimpl/apika_token_provider_test.go b/internal/logic/upstreamimpl/apika_token_provider_test.go new file mode 100644 index 0000000..7668e17 --- /dev/null +++ b/internal/logic/upstreamimpl/apika_token_provider_test.go @@ -0,0 +1,52 @@ +package upstreamimpl + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" +) + +func TestApikaSignature(t *testing.T) { + got := apikaSignature("00009ac515b442d6812f892d6e4c9f6a", "7d9264596bea4c5e9f4bcde7a314eb9c") + want := "MTY0MzE2QjgzRjg3MDFEQzlCNjRBNDA0QzMyRjM4QURGRDhCN0Y5ODY5Qjc0QjNFOEI3NkVEMUJCQ0IwMEU3OQ==" + if got != want { + t.Fatalf("signature = %q, want %q", got, want) + } +} + +func TestApikaTokenProviderFetch(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost || r.URL.Path != apikaTokenPath { + t.Fatalf("request = %s %s", r.Method, r.URL.Path) + } + if r.Header.Get("appKey") != "key" || r.Header.Get("appSecret") != "secret" || r.Header.Get("signature") != apikaSignature("key", "secret") { + t.Fatalf("headers = %#v", r.Header) + } + var input struct { + AppID string `json:"appid"` + AppKey string `json:"appKey"` + AppSecret string `json:"appSecret"` + Signature string `json:"signature"` + } + if err := json.NewDecoder(r.Body).Decode(&input); err != nil { + t.Fatal(err) + } + if input.AppID != "appid" || input.AppKey != "key" || input.AppSecret != "secret" || input.Signature != apikaSignature("key", "secret") { + t.Fatalf("payload = %#v", input) + } + _, _ = w.Write([]byte(`{"code":200,"msg":"success","data":{"shareToken":"token-1","transid":"tx-1"}}`)) + })) + defer server.Close() + + provider := NewApikaTokenProvider(ApikaTokenConfig{BaseURL: server.URL, AppKey: "key", AppSecret: "secret", AppID: "appid", Timeout: time.Second}) + token, err := provider.Fetch(context.Background()) + if err != nil { + t.Fatalf("fetch token: %v", err) + } + if token.Value != "token-1" || !token.ExpiresAt.After(token.CreatedAt) { + t.Fatalf("token = %#v", token) + } +} diff --git a/internal/logic/upstreamimpl/beixin_token_provider.go b/internal/logic/upstreamimpl/beixin_token_provider.go new file mode 100644 index 0000000..b41f5e8 --- /dev/null +++ b/internal/logic/upstreamimpl/beixin_token_provider.go @@ -0,0 +1,149 @@ +package upstreamimpl + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "time" + + sharedtoken "git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/token" +) + +// beixinOneLinkTokenPath is the fallback used when Path is empty. On the +// template path this default is unreachable: parseSharedOneLinkConfig rejects +// an empty token.path before any provider is built, so Path always carries the +// value declared in temp_config. It only applies to direct construction +// (tests). The same shape exists in every shared-token provider. +const beixinOneLinkTokenPath = "/prod-api/open/api/oneLinkToken" + +// beixinTokenTTL is the local validity window. The upstream returns only the +// token string and no expiry field, so the window is held locally; a token +// rejected earlier than this is recovered through RecoverToken's invalidation +// path instead of being trusted until the TTL elapses. +const beixinTokenTTL = time.Hour + +// BeixinTokenConfig contains all values required by the Beixin (江苏倍信) +// shared-token protocol. BaseURL and Path come from temp_config +// token.baseUrl/token.path (with the per-channel tokenBaseUrl override +// applied by parseSharedOneLinkConfig); the credentials come from chan_config. +type BeixinTokenConfig struct { + BaseURL string + Path string + PwdKey string + ChannelNo string + Timeout time.Duration +} + +// BeixinTokenProvider fetches a Beixin shared OneLink token. +// +// Wire contract (verified against the live endpoint on 2026-10-19): +// +// GET {baseUrl}{path}?pwdkey=&c_no= +// → {"code":200,"msg":"请求成功","data":"<64 chars>"} +// +// The token is consumed by the standard OneLink business API +// (https://api.iot.10086.cn/v5/), so all /sim/* routes work unchanged. +type BeixinTokenProvider struct { + baseURL string + path string + pwdKey string + channelNo string + client *http.Client +} + +type beixinTokenResponse struct { + Code int `json:"code"` + Msg string `json:"msg"` + Data json.RawMessage `json:"data"` +} + +func NewBeixinTokenProvider(config BeixinTokenConfig) *BeixinTokenProvider { + timeout := config.Timeout + if timeout <= 0 { + timeout = defaultUpstreamHTTPTimeout + } + return &BeixinTokenProvider{ + baseURL: strings.TrimRight(strings.TrimSpace(config.BaseURL), "/"), + path: normalizeProviderPath(config.Path, beixinOneLinkTokenPath), + pwdKey: config.PwdKey, + channelNo: strings.TrimSpace(config.ChannelNo), + client: &http.Client{Timeout: timeout}, + } +} + +func (p *BeixinTokenProvider) Fetch(ctx context.Context) (sharedtoken.OneLinkToken, error) { + if p == nil || p.client == nil { + return sharedtoken.OneLinkToken{}, fmt.Errorf("beixin token provider is not initialized") + } + if p.baseURL == "" || strings.TrimSpace(p.pwdKey) == "" || p.channelNo == "" { + return sharedtoken.OneLinkToken{}, fmt.Errorf("beixin baseUrl, pwdKey and channelNo are required") + } + + endpoint, err := url.Parse(joinProviderURL(p.baseURL, p.path)) + if err != nil { + return sharedtoken.OneLinkToken{}, fmt.Errorf("parse beixin token URL: %w", err) + } + query := endpoint.Query() + query.Set("pwdkey", p.pwdKey) + query.Set("c_no", p.channelNo) + endpoint.RawQuery = query.Encode() + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint.String(), nil) + if err != nil { + return sharedtoken.OneLinkToken{}, fmt.Errorf("create beixin token request: %w", err) + } + req.Header.Set("Accept", "application/json") + + resp, err := p.client.Do(req) + if err != nil { + return sharedtoken.OneLinkToken{}, fmt.Errorf("request beixin token: %w", err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(io.LimitReader(resp.Body, 4*1024*1024+1)) + if err != nil { + return sharedtoken.OneLinkToken{}, fmt.Errorf("read beixin token response: %w", err) + } + if len(body) > 4*1024*1024 { + return sharedtoken.OneLinkToken{}, fmt.Errorf("beixin token response is too large") + } + if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices { + return sharedtoken.OneLinkToken{}, fmt.Errorf("beixin token HTTP status %d: %s", resp.StatusCode, strings.TrimSpace(string(body))) + } + + var output beixinTokenResponse + if err := json.Unmarshal(body, &output); err != nil { + return sharedtoken.OneLinkToken{}, fmt.Errorf("decode beixin token response: %w", err) + } + if output.Code != http.StatusOK { + return sharedtoken.OneLinkToken{}, fmt.Errorf("beixin token failed: code=%d, message=%s", output.Code, output.Msg) + } + // data is a bare JSON string, not an object. + var value string + if err := json.Unmarshal(output.Data, &value); err != nil { + return sharedtoken.OneLinkToken{}, fmt.Errorf("decode beixin shared token: %w", err) + } + value = strings.TrimSpace(value) + if value == "" { + return sharedtoken.OneLinkToken{}, fmt.Errorf("beixin shared token is empty") + } + + now := time.Now() + return sharedtoken.OneLinkToken{Value: value, CreatedAt: now, ExpiresAt: now.Add(beixinTokenTTL)}, nil +} + +func (p *BeixinTokenProvider) FetchToken(ctx context.Context, _ string) (sharedtoken.OneLinkToken, error) { + return p.Fetch(ctx) +} + +// beixinFetcher adapts BeixinTokenProvider to the shared token TokenFetcher +// contract, mirroring tengyuFetcher. +type beixinFetcher struct{ provider *BeixinTokenProvider } + +func (f beixinFetcher) FetchToken(ctx context.Context, _ string) (sharedtoken.OneLinkToken, error) { + return f.provider.Fetch(ctx) +} diff --git a/internal/logic/upstreamimpl/beixin_token_provider_test.go b/internal/logic/upstreamimpl/beixin_token_provider_test.go new file mode 100644 index 0000000..cd8bfd9 --- /dev/null +++ b/internal/logic/upstreamimpl/beixin_token_provider_test.go @@ -0,0 +1,335 @@ +package upstreamimpl + +import ( + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + "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" +) + +const ( + beixinTestPwdKey = "test-pwdkey" + beixinTestChannelNo = "chanel-202607100639054511" + beixinTestToken = "v5feb8f288c5ba96631b627dc18a5b6a5318e7e7b1923cf7e0aa0efdefa746e4" +) + +func newBeixinTestProvider(t *testing.T, baseURL string) *BeixinTokenProvider { + t.Helper() + return NewBeixinTokenProvider(BeixinTokenConfig{ + BaseURL: baseURL, Path: beixinOneLinkTokenPath, + PwdKey: beixinTestPwdKey, ChannelNo: beixinTestChannelNo, Timeout: time.Second, + }) +} + +// TestBeixinTokenProviderSendRequestContract pins the exact wire shape as +// observed against the live endpoint on 2026-10-19: a GET with lowercase +// pwdkey/c_no query parameters, and a bare JSON string in data. +func TestBeixinTokenProviderSendRequestContract(t *testing.T) { + var ( + gotMethod string + gotPath string + gotPwdKey string + gotCNo string + ) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotMethod = r.Method + gotPath = r.URL.Path + gotPwdKey = r.URL.Query().Get("pwdkey") + gotCNo = r.URL.Query().Get("c_no") + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"code":200,"msg":"请求成功","data":"` + beixinTestToken + `"}`)) + })) + defer server.Close() + + if _, err := newBeixinTestProvider(t, server.URL).Fetch(context.Background()); err != nil { + t.Fatalf("fetch token: %v", err) + } + + if gotMethod != http.MethodGet { + t.Fatalf("method = %q, want GET", gotMethod) + } + if gotPath != beixinOneLinkTokenPath { + t.Fatalf("path = %q, want %q", gotPath, beixinOneLinkTokenPath) + } + if gotPwdKey != beixinTestPwdKey { + t.Fatalf("pwdkey = %q, want %q", gotPwdKey, beixinTestPwdKey) + } + if gotCNo != beixinTestChannelNo { + t.Fatalf("c_no = %q, want %q", gotCNo, beixinTestChannelNo) + } +} + +// TestBeixinTokenProviderReturnsValueAndLocalTTL pins that the bare string in +// data is unwrapped and that the local one-hour window is applied, since the +// upstream reports no expiry. +func TestBeixinTokenProviderReturnsValueAndLocalTTL(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte(`{"code":200,"msg":"请求成功","data":"` + beixinTestToken + `"}`)) + })) + defer server.Close() + + before := time.Now() + token, err := newBeixinTestProvider(t, server.URL).Fetch(context.Background()) + if err != nil { + t.Fatalf("fetch token: %v", err) + } + if token.Value != beixinTestToken { + t.Fatalf("token value = %q, want %q", token.Value, beixinTestToken) + } + // Allow a second of slack for slow CI clocks. + if delta := token.ExpiresAt.Sub(before); delta < beixinTokenTTL-time.Second || delta > beixinTokenTTL+time.Second { + t.Fatalf("expiresAt - before = %s, want ~%s", delta, beixinTokenTTL) + } +} + +// TestBeixinTokenProviderRejectsBusinessFailure pins that a non-200 code is an +// error rather than an empty-but-successful token. +func TestBeixinTokenProviderRejectsBusinessFailure(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte(`{"code":500,"msg":"获取token失败","data":null}`)) + })) + defer server.Close() + + _, err := newBeixinTestProvider(t, server.URL).Fetch(context.Background()) + if err == nil { + t.Fatal("business failure must return an error") + } + if !strings.Contains(err.Error(), "获取token失败") { + t.Fatalf("error = %v, want the upstream message", err) + } +} + +// TestBeixinTokenProviderRejectsEmptyToken pins that a successful envelope with +// an empty data string is still an error. +func TestBeixinTokenProviderRejectsEmptyToken(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte(`{"code":200,"msg":"请求成功","data":""}`)) + })) + defer server.Close() + + _, err := newBeixinTestProvider(t, server.URL).Fetch(context.Background()) + if err == nil { + t.Fatal("empty token must return an error") + } + if !strings.Contains(err.Error(), "beixin shared token is empty") { + t.Fatalf("error = %v", err) + } +} + +// TestBeixinTokenProviderRejectsObjectData pins that data is expected to be a +// JSON string. A future upstream change to an object shape must fail loudly +// instead of silently yielding an empty token. +func TestBeixinTokenProviderRejectsObjectData(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte(`{"code":200,"msg":"请求成功","data":{"token":"abc"}}`)) + })) + defer server.Close() + + if _, err := newBeixinTestProvider(t, server.URL).Fetch(context.Background()); err == nil { + t.Fatal("object-shaped data must return an error") + } +} + +// TestBeixinTokenProviderRequiresCredentials pins the guard so a channel +// missing chan_config.pwdKey or channelNo cannot build a provider that fails +// on every request. +func TestBeixinTokenProviderRequiresCredentials(t *testing.T) { + cases := map[string]BeixinTokenConfig{ + "missing baseUrl": {Path: beixinOneLinkTokenPath, PwdKey: beixinTestPwdKey, ChannelNo: beixinTestChannelNo}, + "missing pwdKey": {BaseURL: "http://example.com", Path: beixinOneLinkTokenPath, ChannelNo: beixinTestChannelNo}, + "missing channelNo": {BaseURL: "http://example.com", Path: beixinOneLinkTokenPath, PwdKey: beixinTestPwdKey}, + } + for name, cfg := range cases { + t.Run(name, func(t *testing.T) { + start := time.Now() + _, err := NewBeixinTokenProvider(cfg).Fetch(context.Background()) + if err == nil { + t.Fatal("incomplete credentials must return an error") + } + if elapsed := time.Since(start); elapsed > time.Second { + t.Fatalf("validation must not hit the network, took %s", elapsed) + } + if !strings.Contains(err.Error(), "beixin baseUrl, pwdKey and channelNo are required") { + t.Fatalf("error = %v, want the credential guard message", err) + } + }) + } +} + +// TestBeixinTemplateRegistration pins that a provider=beixin template +// instantiates through the shared_token model with a chan_config carrying the +// two required credentials. +func TestBeixinTemplateRegistration(t *testing.T) { + template, ok := service.UpstreamAPI().GetTemp(sharedOneLinkBeixinTemplateKey) + if !ok || template == nil { + t.Fatal("beixin template not registered") + } + + config := &model.SimChannelConfigItemCache{ + Template: &entity.TSimChannelTemplate{ + 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", + ChanConfig: `{"pwdKey":"` + beixinTestPwdKey + `","channelNo":"` + beixinTestChannelNo + `"}`, + }, + } + if instance := template.NewTemp(context.Background(), config); instance == nil { + t.Fatal("beixin template failed to instantiate") + } +} + +// TestBeixinTokenPathComesFromTemplate pins that the request path is whatever +// the template declares, not a hardcoded default. This is the same property +// TestLanShuTokenPathComesFromTemplate pins for lanshu, and it is what makes a +// template reusable across upstream hosts. +func TestBeixinTokenPathComesFromTemplate(t *testing.T) { + const templatePath = "/some/other/open/route" + if templatePath == beixinOneLinkTokenPath { + t.Fatal("test precondition broken: templatePath must differ from beixinOneLinkTokenPath") + } + + var gotPath string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotPath = r.URL.Path + _, _ = w.Write([]byte(`{"code":200,"msg":"请求成功","data":"` + beixinTestToken + `"}`)) + })) + defer server.Close() + + channelConfig := &model.SimChannelConfigItemCache{ + Template: &entity.TSimChannelTemplate{ + TempKey: sharedOneLinkBeixinTemplateKey, + TempConfig: `{"token":{"provider":"beixin","baseUrl":"` + server.URL + `",` + + `"path":"` + templatePath + `"}}`, + }, + Channel: &entity.TSimChannels{ + ChanKey: "252", + ChanConfig: `{"pwdKey":"` + beixinTestPwdKey + `","channelNo":"` + beixinTestChannelNo + `"}`, + }, + } + + cfg, _, err := parseSharedOneLinkConfig(context.Background(), channelConfig) + if err != nil { + t.Fatalf("parse config: %v", err) + } + if cfg.TokenPath != templatePath { + t.Fatalf("parsed TokenPath = %q, want the template-declared %q", cfg.TokenPath, templatePath) + } + + provider := NewBeixinTokenProvider(BeixinTokenConfig{ + BaseURL: cfg.TokenBaseURL, Path: cfg.TokenPath, + PwdKey: cfg.PwdKey, ChannelNo: cfg.ChannelNo, Timeout: cfg.Timeout, + }) + if _, err := provider.Fetch(context.Background()); err != nil { + t.Fatalf("fetch token: %v", err) + } + if gotPath != templatePath { + t.Fatalf("request path = %q, want the template-declared %q", gotPath, templatePath) + } +} + +// TestBeixinChannelConfigRejectsIncompleteCredentials pins the parse-time +// guard, so the gate fires before any network call. +func TestBeixinChannelConfigRejectsIncompleteCredentials(t *testing.T) { + base := func(chanConfig string) *model.SimChannelConfigItemCache { + return &model.SimChannelConfigItemCache{ + Template: &entity.TSimChannelTemplate{ + TempKey: sharedOneLinkBeixinTemplateKey, + TempConfig: `{"token":{"provider":"beixin","baseUrl":"http://www.jsbxkj.vip",` + + `"path":"/prod-api/open/api/oneLinkToken"}}`, + }, + Channel: &entity.TSimChannels{ChanKey: "252", ChanConfig: chanConfig}, + } + } + + if _, _, err := parseSharedOneLinkConfig(context.Background(), base(`{"pwdKey":"only"}`)); err == nil { + t.Fatal("chan_config without channelNo must be rejected") + } + if _, _, err := parseSharedOneLinkConfig(context.Background(), base(`{}`)); err == nil { + t.Fatal("empty chan_config must be rejected") + } + + parsed, _, err := parseSharedOneLinkConfig(context.Background(), base( + `{"pwdKey":"`+beixinTestPwdKey+`","channelNo":"`+beixinTestChannelNo+`"}`)) + if err != nil { + t.Fatalf("complete chan_config must parse: %v", err) + } + if parsed.PwdKey != beixinTestPwdKey || parsed.ChannelNo != beixinTestChannelNo { + t.Fatalf("parsed credentials = %q / %q", parsed.PwdKey, parsed.ChannelNo) + } +} + +// TestBeixinChannelTokenBaseURLOverride pins the per-channel host override that +// lets several Beixin accounts (different c_no values, possibly different +// hosts) share one template without a code change. +func TestBeixinChannelTokenBaseURLOverride(t *testing.T) { + const templateHost = "http://template-host.example.com" + const channelHost = "http://channel-host.example.com" + + config := &model.SimChannelConfigItemCache{ + Template: &entity.TSimChannelTemplate{ + TempKey: sharedOneLinkBeixinTemplateKey, + TempConfig: `{"token":{"provider":"beixin","baseUrl":"` + templateHost + `",` + + `"path":"/prod-api/open/api/oneLinkToken"}}`, + }, + Channel: &entity.TSimChannels{ + ChanKey: "252", + ChanConfig: `{"pwdKey":"` + beixinTestPwdKey + `","channelNo":"` + beixinTestChannelNo + `",` + + `"tokenBaseUrl":"` + channelHost + `"}`, + }, + } + + parsed, _, err := parseSharedOneLinkConfig(context.Background(), config) + if err != nil { + t.Fatalf("parse config: %v", err) + } + if parsed.TokenBaseURL != channelHost { + t.Fatalf("TokenBaseURL = %q, want the channel override %q", parsed.TokenBaseURL, channelHost) + } + + // Without the override the template host must win. + config.Channel.ChanConfig = `{"pwdKey":"` + beixinTestPwdKey + `","channelNo":"` + beixinTestChannelNo + `"}` + parsed, _, err = parseSharedOneLinkConfig(context.Background(), config) + if err != nil { + t.Fatalf("parse config: %v", err) + } + if parsed.TokenBaseURL != templateHost { + t.Fatalf("TokenBaseURL = %q, want the template default %q", parsed.TokenBaseURL, templateHost) + } +} + +// TestBeixinResponseShapeMatchesObservedBody replays the exact body captured +// from the live endpoint, so field-case drift breaks the build rather than +// production. +func TestBeixinResponseShapeMatchesObservedBody(t *testing.T) { + const observed = `{"code":200,"msg":"请求成功","data":"v5feb8f288c5ba96631b627dc18a5b6a5318e7e7b1923cf7e0aa0efdefa746e4"}` + + var output beixinTokenResponse + if err := json.Unmarshal([]byte(observed), &output); err != nil { + t.Fatalf("decode observed body: %v", err) + } + if output.Code != 200 || output.Msg != "请求成功" { + t.Fatalf("envelope = %+v", output) + } + var value string + if err := json.Unmarshal(output.Data, &value); err != nil { + t.Fatalf("decode data: %v", err) + } + if len(value) != 64 || !strings.HasPrefix(value, "v5") { + t.Fatalf("token shape changed: %q", value) + } +} + +// silence unused-import drift if the file is trimmed later. +var _ = io.Discard diff --git a/internal/logic/upstreamimpl/daihe_token_provider.go b/internal/logic/upstreamimpl/daihe_token_provider.go new file mode 100644 index 0000000..a8887fc --- /dev/null +++ b/internal/logic/upstreamimpl/daihe_token_provider.go @@ -0,0 +1,60 @@ +package upstreamimpl + +import ( + "context" + "fmt" + "strconv" + "strings" + "time" + + "git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/service/daihe" + sharedtoken "git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/token" +) + +type DaiheTokenConfig struct { + BaseURL string + Path string + AppID string + AppSecret string + CMCCAppID string + Timeout time.Duration +} + +type DaiheTokenProvider struct{ config DaiheTokenConfig } +type daiheTokenFetcher struct{ provider *DaiheTokenProvider } + +func NewDaiheTokenProvider(config DaiheTokenConfig) *DaiheTokenProvider { + if strings.TrimSpace(config.Path) == "" { + config.Path = "/sim/query/cmccToken" + } + if strings.TrimSpace(config.BaseURL) == "" { + config.BaseURL = "https://open.flymodem.com.cn" + } + return &DaiheTokenProvider{config: config} +} + +func (p *DaiheTokenProvider) Fetch(ctx context.Context) (sharedtoken.OneLinkToken, error) { + if p == nil || p.config.AppID == "" || p.config.AppSecret == "" || p.config.CMCCAppID == "" { + return sharedtoken.OneLinkToken{}, fmt.Errorf("daihe token credentials and cmccAppId are required") + } + client := daihe.NewClient(daihe.Config{BaseURL: p.config.BaseURL, AppID: p.config.AppID, AppSecret: p.config.AppSecret, Timeout: p.config.Timeout}) + var response daihe.CMCCToken + if err := client.QueryCMCCToken(ctx, p.config.CMCCAppID, &response); err != nil { + return sharedtoken.OneLinkToken{}, err + } + token := strings.TrimSpace(response.Token) + deviceToken := strings.TrimSpace(response.TokenDevice) + if token == "" { + return sharedtoken.OneLinkToken{}, fmt.Errorf("daihe cmcc token response is empty") + } + ttl, err := strconv.ParseInt(strings.TrimSpace(response.DeviceTTL), 10, 64) + if err != nil || ttl <= 0 { + return sharedtoken.OneLinkToken{}, fmt.Errorf("daihe device_ttl must be positive: %q", response.DeviceTTL) + } + now := time.Now() + return sharedtoken.OneLinkToken{Value: token, DeviceValue: deviceToken, CreatedAt: now, ExpiresAt: now.Add(time.Duration(ttl) * time.Second)}, nil +} + +func (f daiheTokenFetcher) FetchToken(ctx context.Context, _ string) (sharedtoken.OneLinkToken, error) { + return f.provider.Fetch(ctx) +} diff --git a/internal/logic/upstreamimpl/dx/client.go b/internal/logic/upstreamimpl/dx/client.go new file mode 100644 index 0000000..83a3f62 --- /dev/null +++ b/internal/logic/upstreamimpl/dx/client.go @@ -0,0 +1,156 @@ +package dx + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "time" +) + +// HTTPStatusError preserves the telecom response body for non-200 responses. +// RespCode is the raw carrier error code when the gateway supplies one. +type HTTPStatusError struct { + StatusCode int + Body string + RespCode string +} + +func (e *HTTPStatusError) Error() string { + if e == nil { + return "telecom HTTP status error" + } + return fmt.Sprintf("http status %d: %s", e.StatusCode, e.Body) +} + +// Config 网关鉴权与请求配置 +type Config struct { + BaseURL string // 默认 https://cmp-api.ctwing.cn:20164 + AppKey string + AppSecret string + Timeout time.Duration + UppercaseSign bool +} + +// Client API 客户端 +type Client struct { + baseURL string + appKey string + appSecret string + http *http.Client + uppercaseSign bool +} + +// New 创建客户端 +func NewClient(cfg Config) *Client { + base := cfg.BaseURL + if base == "" { + base = DefaultBaseURL + } + timeout := cfg.Timeout + if timeout <= 0 { + timeout = 10 * time.Second + } + return &Client{ + baseURL: strings.TrimSuffix(base, "/"), + appKey: cfg.AppKey, + appSecret: cfg.AppSecret, + uppercaseSign: cfg.UppercaseSign, + http: &http.Client{ + Timeout: timeout, + }, + } +} + +// DoGetRequest 通用GET请求(自动处理签名) +// endpoint: API路径,如 "/api/v1/openbill/queryTraffic" +// params: 查询参数,需调用方自行构建 +func (c *Client) DoGetRequest(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.doRequest(req) +} + +func (c *Client) DoPostJSONRequest(ctx context.Context, endpoint string, body []byte) ([]byte, error) { + timestamp := GetTimestampGMT() + signStr, err := BuildSign("", string(body), c.appSecret, timestamp) + if c.uppercaseSign { + signStr, err = BuildSignV3("", string(body), c.appSecret, timestamp) + } + if err != nil { + return nil, err + } + req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+endpoint, strings.NewReader(string(body))) + if err != nil { + return nil, err + } + req.Header.Set("AppKey", c.appKey) + req.Header.Set("Sign", c.formatSign(signStr)) + req.Header.Set("Timestamp", timestamp) + req.Header.Set("Content-Type", "application/json") + return c.doRequest(req) +} + +// doGetRequest 封装通用的 GET 请求构建逻辑 +func (c *Client) doGetRequest(ctx context.Context, endpoint string, params url.Values) (*http.Request, error) { + queryString := params.Encode() + timestamp := GetTimestampGMT() + signStr, err := BuildSign(queryString, "", c.appSecret, timestamp) + if c.uppercaseSign { + signStr, err = BuildSignV3(queryString, "", c.appSecret, timestamp) + } + if err != nil { + return nil, err + } + reqURL := c.baseURL + endpoint + "?" + queryString + req, err := http.NewRequestWithContext(ctx, http.MethodGet, reqURL, nil) + if err != nil { + return nil, err + } + req.Header.Set("AppKey", c.appKey) + req.Header.Set("Sign", c.formatSign(signStr)) + req.Header.Set("Timestamp", timestamp) + return req, nil +} + +func (c *Client) formatSign(sign string) string { + if c.uppercaseSign { + return strings.ToUpper(sign) + } + return sign +} + +// doRequest 执行HTTP请求 +func (c *Client) doRequest(req *http.Request) ([]byte, error) { + resp, err := c.http.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + + if resp.StatusCode != http.StatusOK { + var errorBody struct { + RespCode string `json:"respCode"` + } + _ = json.Unmarshal(body, &errorBody) + if c.uppercaseSign && strings.TrimSpace(errorBody.RespCode) == "" { + errorBody.RespCode = resp.Header.Get("gw-errorCode") + } + return nil, &HTTPStatusError{ + StatusCode: resp.StatusCode, + Body: string(body), + RespCode: strings.TrimSpace(errorBody.RespCode), + } + } + return body, nil +} diff --git a/internal/logic/upstreamimpl/dx/sign.go b/internal/logic/upstreamimpl/dx/sign.go new file mode 100644 index 0000000..69f00ac --- /dev/null +++ b/internal/logic/upstreamimpl/dx/sign.go @@ -0,0 +1,69 @@ +package dx + +import ( + "crypto/md5" + "encoding/hex" + "sort" + "strings" + "time" +) + +const ( + // DefaultBaseURL 电信API基础地址 + DefaultBaseURL = "https://cmp-api.ctwing.cn:20164" +) + +// GetTimestampGMT 返回 GMT 时区的 yyyyMMddHHmmss 格式时间戳 +func GetTimestampGMT() string { + return time.Now().UTC().Format("20060102150405") +} + +// OrderParams 将 URL params 按 key 字典序排序 +func OrderParams(params string) (string, error) { + if params == "" { + return "", nil + } + kvs := strings.Split(params, "&") + m := make(map[string]string, len(kvs)) + for _, kv := range kvs { + idx := strings.Index(kv, "=") + if idx < 0 { + continue + } + m[kv[:idx]] = kv[idx+1:] + } + keys := make([]string, 0, len(m)) + for k := range m { + keys = append(keys, k) + } + sort.Strings(keys) + var parts []string + for _, k := range keys { + parts = append(parts, k+"="+m[k]) + } + return strings.Join(parts, "&"), nil +} + +// SignMD5 对 reqStr 做 MD5 加密,返回小写十六进制字符串 +func SignMD5(reqStr string) string { + h := md5.Sum([]byte(reqStr)) + return strings.ToLower(hex.EncodeToString(h[:])) +} + +// BuildSign 构造签名:params + body + secretKey + timestamp → MD5 → hex +func BuildSign(params, body, secretKey, timestamp string) (string, error) { + ordered, err := OrderParams(params) + if err != nil { + return "", err + } + reqStr := ordered + body + secretKey + timestamp + md5Str := SignMD5(reqStr) + return hex.EncodeToString([]byte(md5Str)), nil +} + +// BuildSignV3 follows the V3 Java sample: encode the lowercase MD5 digest +// string as hex a second time, then uppercase that outer encoding. +func BuildSignV3(params, body, secretKey, timestamp string) (string, error) { + sign, err := BuildSign(params, body, secretKey, timestamp) + return strings.ToUpper(sign), err +} diff --git a/internal/logic/upstreamimpl/dx/sign_test.go b/internal/logic/upstreamimpl/dx/sign_test.go new file mode 100644 index 0000000..a72aef5 --- /dev/null +++ b/internal/logic/upstreamimpl/dx/sign_test.go @@ -0,0 +1,15 @@ +package dx + +import "testing" + +func TestV3SignatureKeepsLegacyEncodingAndUsesUppercase(t *testing.T) { + // MD5("abc") is 900150983cd24fb0d6963f7d28e17f72. + legacy, err := BuildSign("", "abc", "", "") + if err != nil || legacy != "3930303135303938336364323466623064363936336637643238653137663732" { + t.Fatalf("legacy signature: %s, %v", legacy, err) + } + v3, err := BuildSignV3("", "abc", "", "") + if err != nil || v3 != "3930303135303938336364323466623064363936336637643238653137663732" { + t.Fatalf("v3 signature: %s, %v", v3, err) + } +} diff --git a/internal/logic/upstreamimpl/henan_lelian_token_provider.go b/internal/logic/upstreamimpl/henan_lelian_token_provider.go new file mode 100644 index 0000000..e1337b8 --- /dev/null +++ b/internal/logic/upstreamimpl/henan_lelian_token_provider.go @@ -0,0 +1,256 @@ +package upstreamimpl + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "strconv" + "strings" + "sync" + "time" +) + +const ( + henanLelianLoginPath = "/api/v2/loginVerify" + henanLelianSharePath = "/api/v2/getShareToken" + henanLelianPlatformTTL = 23 * time.Hour + henanLelianShareInterval = time.Minute +) + +// HenanLelianTokenConfig contains the public provider endpoints and the +// per-channel credentials required to obtain a shared OneLink token. +type HenanLelianTokenConfig struct { + BaseURL string + LoginPath string + Path string + UserName string + Password string + PasswayUID string + Timeout time.Duration +} + +// HenanLelianTokenProvider first obtains a platform access token and then +// exchanges it for the carrier-issued OneLink token. +type HenanLelianTokenProvider struct { + baseURL string + loginPath string + path string + userName string + password string + passwayUID string + client *http.Client + now func() time.Time + + platformMu sync.Mutex + platformToken string + platformTokenExp time.Time + + shareMu sync.Mutex + nextShareAt time.Time + shareInterval time.Duration +} + +type henanLelianResponse struct { + Code json.RawMessage `json:"code"` + Message string `json:"message"` + Payload struct { + AccessToken string `json:"access_token"` + Token string `json:"token"` + } `json:"payload"` +} + +func NewHenanLelianTokenProvider(config HenanLelianTokenConfig) *HenanLelianTokenProvider { + timeout := config.Timeout + if timeout <= 0 { + timeout = defaultUpstreamHTTPTimeout + } + return &HenanLelianTokenProvider{ + baseURL: strings.TrimRight(strings.TrimSpace(config.BaseURL), "/"), + loginPath: normalizeProviderPath(config.LoginPath, henanLelianLoginPath), + path: normalizeProviderPath(config.Path, henanLelianSharePath), + userName: strings.TrimSpace(config.UserName), + password: config.Password, + passwayUID: strings.TrimSpace(config.PasswayUID), + client: &http.Client{Timeout: timeout}, + now: time.Now, + shareInterval: henanLelianShareInterval, + } +} + +func (p *HenanLelianTokenProvider) Fetch(ctx context.Context) (OneLinkToken, error) { + if p == nil || p.client == nil || p.now == nil { + return OneLinkToken{}, fmt.Errorf("Henan Lelian token provider is not initialized") + } + if p.baseURL == "" { + return OneLinkToken{}, fmt.Errorf("Henan Lelian token base URL is required") + } + if p.userName == "" || strings.TrimSpace(p.password) == "" { + return OneLinkToken{}, fmt.Errorf("Henan Lelian username and password are required") + } + if p.passwayUID == "" { + return OneLinkToken{}, fmt.Errorf("Henan Lelian passwayUid is required") + } + + platformToken, err := p.getPlatformToken(ctx) + if err != nil { + return OneLinkToken{}, err + } + value, authFailed, err := p.fetchSharedToken(ctx, platformToken) + if authFailed { + p.clearPlatformToken(platformToken) + } + if err != nil { + return OneLinkToken{}, err + } + now := p.now() + return OneLinkToken{Value: value, CreatedAt: now}, nil +} + +func (p *HenanLelianTokenProvider) getPlatformToken(ctx context.Context) (string, error) { + p.platformMu.Lock() + defer p.platformMu.Unlock() + + now := p.now() + if p.platformToken != "" && now.Before(p.platformTokenExp) { + return p.platformToken, nil + } + body, err := json.Marshal(map[string]string{ + "userName": p.userName, + "password": p.password, + }) + if err != nil { + return "", fmt.Errorf("encode Henan Lelian login request: %w", err) + } + responseBody, status, err := p.postJSON(ctx, p.loginPath, body, "") + if err != nil { + return "", fmt.Errorf("request Henan Lelian login: %w", err) + } + if status < http.StatusOK || status >= http.StatusMultipleChoices { + return "", fmt.Errorf("Henan Lelian login HTTP status %d: %s", status, strings.TrimSpace(string(responseBody))) + } + var output henanLelianResponse + if err = json.Unmarshal(responseBody, &output); err != nil { + return "", fmt.Errorf("decode Henan Lelian login response: %w", err) + } + code, err := parseHenanLelianCode(output.Code) + if err != nil { + return "", fmt.Errorf("decode Henan Lelian login code: %w", err) + } + if code != "0" { + return "", fmt.Errorf("Henan Lelian login failed: code=%s, message=%s", code, output.Message) + } + token := strings.TrimSpace(output.Payload.AccessToken) + if token == "" { + return "", fmt.Errorf("Henan Lelian login access_token is empty") + } + p.platformToken = token + p.platformTokenExp = now.Add(henanLelianPlatformTTL) + return token, nil +} + +func (p *HenanLelianTokenProvider) fetchSharedToken(ctx context.Context, platformToken string) (string, bool, error) { + if err := p.reserveShareAttempt(); err != nil { + return "", false, err + } + body, err := json.Marshal(map[string]string{"passwayUid": p.passwayUID}) + if err != nil { + return "", false, fmt.Errorf("encode Henan Lelian shared token request: %w", err) + } + responseBody, status, err := p.postJSON(ctx, p.path, body, platformToken) + if err != nil { + return "", false, fmt.Errorf("request Henan Lelian shared token: %w", err) + } + if status < http.StatusOK || status >= http.StatusMultipleChoices { + authFailed := status == http.StatusUnauthorized || status == http.StatusForbidden + return "", authFailed, fmt.Errorf("Henan Lelian shared token HTTP status %d: %s", status, strings.TrimSpace(string(responseBody))) + } + var output henanLelianResponse + if err = json.Unmarshal(responseBody, &output); err != nil { + return "", false, fmt.Errorf("decode Henan Lelian shared token response: %w", err) + } + code, err := parseHenanLelianCode(output.Code) + if err != nil { + return "", false, fmt.Errorf("decode Henan Lelian shared token code: %w", err) + } + if code != "0" { + authFailed := isHenanLelianAuthFailure(code, output.Message) + return "", authFailed, fmt.Errorf("Henan Lelian shared token failed: code=%s, message=%s", code, output.Message) + } + token := strings.TrimSpace(output.Payload.Token) + if token == "" { + return "", false, fmt.Errorf("Henan Lelian shared token is empty") + } + return token, false, nil +} + +func (p *HenanLelianTokenProvider) reserveShareAttempt() error { + p.shareMu.Lock() + defer p.shareMu.Unlock() + + now := p.now() + if now.Before(p.nextShareAt) { + return fmt.Errorf("Henan Lelian shared token is rate limited until %s", p.nextShareAt.Format(time.RFC3339)) + } + p.nextShareAt = now.Add(p.shareInterval) + return nil +} + +func (p *HenanLelianTokenProvider) clearPlatformToken(rejected string) { + p.platformMu.Lock() + if p.platformToken == rejected { + p.platformToken = "" + p.platformTokenExp = time.Time{} + } + p.platformMu.Unlock() +} + +func (p *HenanLelianTokenProvider) postJSON(ctx context.Context, path string, body []byte, platformToken string) ([]byte, int, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodPost, joinProviderURL(p.baseURL, path), bytes.NewReader(body)) + if err != nil { + return nil, 0, err + } + req.Header.Set("Accept", "application/json") + req.Header.Set("Content-Type", "application/json") + if platformToken != "" { + req.Header.Set("access_token", platformToken) + } + resp, err := p.client.Do(req) + if err != nil { + return nil, 0, err + } + defer resp.Body.Close() + responseBody, err := io.ReadAll(resp.Body) + return responseBody, resp.StatusCode, err +} + +func parseHenanLelianCode(raw json.RawMessage) (string, error) { + var value string + if err := json.Unmarshal(raw, &value); err == nil { + return strings.TrimSpace(value), nil + } + var number json.Number + if err := json.Unmarshal(raw, &number); err != nil { + return "", err + } + if _, err := strconv.ParseInt(number.String(), 10, 64); err != nil { + return "", err + } + return number.String(), nil +} + +func isHenanLelianAuthFailure(code, message string) bool { + switch strings.TrimSpace(code) { + case "401", "40101", "40102", "403": + return true + } + message = strings.ToLower(strings.TrimSpace(message)) + return strings.Contains(message, "access_token") || + strings.Contains(message, "access token") || + strings.Contains(message, "token expired") || + strings.Contains(message, "token invalid") || + strings.Contains(message, "鉴权") || + strings.Contains(message, "认证失败") +} diff --git a/internal/logic/upstreamimpl/henan_xinjing_client.go b/internal/logic/upstreamimpl/henan_xinjing_client.go new file mode 100644 index 0000000..9f69654 --- /dev/null +++ b/internal/logic/upstreamimpl/henan_xinjing_client.go @@ -0,0 +1,134 @@ +package upstreamimpl + +import ( + "context" + "crypto/md5" + "encoding/hex" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strconv" + "strings" + "time" +) + +// henanXinjingClient is the direct HTTP client for the Henan Xinjing SIM +// platform. Authentication is userId+apikey+times+sign where +// sign = UPPERCASE(MD5("userId=&apikey=×=")). +type henanXinjingClient struct { + baseURL string + userID string + apiKey string + httpClient *http.Client +} + +// henanXinjingResponse matches the platform's standard envelope. +// Code can be either a JSON number or a JSON string (the platform +// returns both shapes for the same logical value), so keep it raw. +type henanXinjingResponse struct { + Code json.RawMessage `json:"code"` + Msg string `json:"msg"` + Data json.RawMessage `json:"data"` +} + +func newHenanXinjingClient(baseURL, userID, apiKey string, timeout time.Duration) *henanXinjingClient { + if timeout <= 0 { + timeout = defaultUpstreamHTTPTimeout + } + return &henanXinjingClient{ + baseURL: strings.TrimRight(strings.TrimSpace(baseURL), "/"), + userID: strings.TrimSpace(userID), + apiKey: strings.TrimSpace(apiKey), + httpClient: &http.Client{Timeout: timeout}, + } +} + +// buildSign returns UPPERCASE(MD5(...)) over the document-defined form: +// +// userId=&apikey=×= +func henanXinjingSign(userID, apiKey, times string) string { + raw := "userId=" + userID + "&apikey=" + apiKey + "×=" + times + sum := md5.Sum([]byte(raw)) + return strings.ToUpper(hex.EncodeToString(sum[:])) +} + +// decodeCode normalizes the response code. The platform sometimes returns +// `"code":0` and sometimes `"code":"-2"`. Both are valid; we treat any +// numeric zero (in either form) as success. +func henanXinjingDecodeCode(raw json.RawMessage) (int, error) { + if len(raw) == 0 { + return -1, fmt.Errorf("missing code field") + } + var n int + if err := json.Unmarshal(raw, &n); err == nil { + return n, nil + } + var s string + if err := json.Unmarshal(raw, &s); err != nil { + return -1, fmt.Errorf("code is neither number nor string: %s", string(raw)) + } + s = strings.TrimSpace(s) + if s == "" { + return -1, fmt.Errorf("empty code string") + } + v, err := strconv.Atoi(s) + if err != nil { + return -1, fmt.Errorf("invalid code string %q: %w", s, err) + } + return v, nil +} + +// get performs a signed GET request and unmarshals the `data` field into out +// when out is non-nil. Business-layer errors (code != 0) become errors. +func (c *henanXinjingClient) get(ctx context.Context, path string, params map[string]string, out any) error { + if c == nil || c.baseURL == "" || c.userID == "" || c.apiKey == "" { + return fmt.Errorf("henan xinjing: baseURL, userID and apiKey are required") + } + values := url.Values{} + for k, v := range params { + if strings.TrimSpace(v) != "" { + values.Set(k, v) + } + } + times := strconv.FormatInt(time.Now().Unix(), 10) + values.Set("userId", c.userID) + values.Set("times", times) + values.Set("sign", henanXinjingSign(c.userID, c.apiKey, times)) + + endpoint := c.baseURL + "/" + strings.TrimLeft(path, "/") + "?" + values.Encode() + req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) + if err != nil { + return fmt.Errorf("henan xinjing: build request %s: %w", path, err) + } + resp, err := c.httpClient.Do(req) + if err != nil { + return fmt.Errorf("henan xinjing: request %s: %w", path, err) + } + defer resp.Body.Close() + body, err := io.ReadAll(resp.Body) + if err != nil { + return fmt.Errorf("henan xinjing: read body %s: %w", path, err) + } + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return fmt.Errorf("henan xinjing: HTTP %d from %s: %s", resp.StatusCode, path, string(body)) + } + var envelope henanXinjingResponse + if err = json.Unmarshal(body, &envelope); err != nil { + return fmt.Errorf("henan xinjing: decode envelope %s: %w (body=%s)", path, err, string(body)) + } + code, err := henanXinjingDecodeCode(envelope.Code) + if err != nil { + return fmt.Errorf("henan xinjing: %s: %w (body=%s)", path, err, string(body)) + } + if code != 0 { + return fmt.Errorf("henan xinjing: %s failed: code=%d msg=%s", path, code, envelope.Msg) + } + if out != nil && len(envelope.Data) > 0 && string(envelope.Data) != "null" { + if err = json.Unmarshal(envelope.Data, out); err != nil { + return fmt.Errorf("henan xinjing: decode data %s: %w (data=%s)", path, err, string(envelope.Data)) + } + } + return nil +} diff --git a/internal/logic/upstreamimpl/henan_xinjing_token_provider.go b/internal/logic/upstreamimpl/henan_xinjing_token_provider.go new file mode 100644 index 0000000..fb7d5ec --- /dev/null +++ b/internal/logic/upstreamimpl/henan_xinjing_token_provider.go @@ -0,0 +1,224 @@ +package upstreamimpl + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strconv" + "strings" + "time" + + sharedtoken "git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/token" +) + +// HenanXinjingTokenConfig carries the credentials needed to call the +// platform's `/api/v1/searchToken` endpoint and convert the response into a +// sharedtoken.OneLinkToken for the sharedtoken manager. +// +// The channel card-no is optional at construction time because the +// underlying platform scopes tokens to (channel_id, channel_pname), not to +// a specific card. When CardNo is empty the fetcher falls back to the +// chanKey passed by the token manager (matching how zibo/tengyu providers +// treat the per-channel seed value). +type HenanXinjingTokenConfig struct { + BaseURL string + Path string // defaults to /api/v1/searchToken + UserID string + APIKey string + ChannelID string // e.g. "80", "97", "194" + ChannelPName string // e.g. "cmiotonelinkqx" + CardNo string // optional seed card number; may be overridden by manager + Timeout time.Duration +} + +// HenanXinjingTokenProvider fetches a shared token for a given +// (userId, channel_id, channel_pname) triple from the Henan Xinjing +// platform. +type HenanXinjingTokenProvider struct { + baseURL, path, userID, apiKey, channelID, channelPName, cardNo string + client *http.Client +} + +// henanXinjingSearchTokenResponse matches the upstream response shape: +// +// { +// "code": 0, +// "msg": "查询成功", +// "data": { +// "id": 80, +// "chl_username": "C5010371A16640006582238", +// "accesstoken": "v1b74e73d741cea620f0ceecaf7a733fa6964aa53fffcbf8d3a5a4c8715aabe3", +// "accesstoken_at": "2026-09-16 13:19:29" +// } +// } +// +// `code` may be either a JSON number or string; decode via henanXinjingDecodeCode. +type henanXinjingSearchTokenResponse struct { + Code json.RawMessage `json:"code"` + Msg string `json:"msg"` + Data struct { + ID json.RawMessage `json:"id"` + ChlUsername string `json:"chl_username"` + AccessToken string `json:"accesstoken"` + AccessTokenAt string `json:"accesstoken_at"` + AccessTokenSB string `json:"accesstokensb"` + AccessTokenAlias string `json:"access_token"` + } `json:"data"` +} + +const ( + henanXinjingSearchTokenPath = "/api/v1/searchToken" + henanXinjingTokenExpiryLayout = "2006-01-02 15:04:05" + henanXinjingTokenRefreshBackoff = 5 * time.Minute +) + +func NewHenanXinjingTokenProvider(config HenanXinjingTokenConfig) *HenanXinjingTokenProvider { + timeout := config.Timeout + if timeout <= 0 { + timeout = defaultUpstreamHTTPTimeout + } + return &HenanXinjingTokenProvider{ + baseURL: strings.TrimRight(strings.TrimSpace(config.BaseURL), "/"), + path: normalizeProviderPath(config.Path, henanXinjingSearchTokenPath), + userID: strings.TrimSpace(config.UserID), + apiKey: strings.TrimSpace(config.APIKey), + channelID: strings.TrimSpace(config.ChannelID), + channelPName: strings.TrimSpace(config.ChannelPName), + cardNo: strings.TrimSpace(config.CardNo), + client: &http.Client{Timeout: timeout}, + } +} + +// parseExpiry interprets the platform's `accesstoken_at` field. The +// platform uses local China time without an explicit zone, so parse in +// Asia/Shanghai. +func henanXinjingParseExpiry(raw string) (time.Time, error) { + raw = strings.TrimSpace(raw) + if raw == "" { + return time.Time{}, fmt.Errorf("empty accesstoken_at") + } + loc, err := time.LoadLocation("Asia/Shanghai") + if err != nil { + loc = time.Local + } + t, err := time.ParseInLocation(henanXinjingTokenExpiryLayout, raw, loc) + if err != nil { + return time.Time{}, fmt.Errorf("parse accesstoken_at %q: %w", raw, err) + } + return t, nil +} + +// firstToken returns the first non-empty token field. The platform has been +// observed returning the token under `accesstoken`, but earlier docs +// referenced `accesstokensb` / `access_token`; accept all three. +func henanXinjingFirstToken(resp *henanXinjingSearchTokenResponse) string { + if v := strings.TrimSpace(resp.Data.AccessToken); v != "" { + return v + } + if v := strings.TrimSpace(resp.Data.AccessTokenSB); v != "" { + return v + } + return strings.TrimSpace(resp.Data.AccessTokenAlias) +} + +// fetch performs the signed GET against /api/v1/searchToken for the given +// card number. Card number may come from the manager's chanKey seed. +func (p *HenanXinjingTokenProvider) fetch(ctx context.Context, cardNo string) (sharedtoken.OneLinkToken, error) { + if p == nil || p.client == nil { + return sharedtoken.OneLinkToken{}, fmt.Errorf("henan xinjing token provider is not initialized") + } + if p.baseURL == "" || p.userID == "" || p.apiKey == "" || p.channelID == "" || p.channelPName == "" { + return sharedtoken.OneLinkToken{}, fmt.Errorf("henan xinjing: baseURL, userID, apiKey, channelID and channelPName are required") + } + cardNo = strings.TrimSpace(cardNo) + if cardNo == "" { + cardNo = p.cardNo + } + // cardno is optional on the real platform (verified live): the token is + // scoped to (userId, channel_id, channel_pname), so omit the param when + // no seed card number is configured instead of failing the fetch. + + times := strconv.FormatInt(time.Now().Unix(), 10) + values := url.Values{} + values.Set("userId", p.userID) + if cardNo != "" { + values.Set("cardno", cardNo) + } + values.Set("channel_id", p.channelID) + values.Set("channel_pname", p.channelPName) + values.Set("times", times) + values.Set("sign", henanXinjingSign(p.userID, p.apiKey, times)) + + endpoint := p.baseURL + p.path + "?" + values.Encode() + req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) + if err != nil { + return sharedtoken.OneLinkToken{}, fmt.Errorf("henan xinjing: build searchToken request: %w", err) + } + resp, err := p.client.Do(req) + if err != nil { + return sharedtoken.OneLinkToken{}, fmt.Errorf("henan xinjing: request searchToken: %w", err) + } + defer resp.Body.Close() + body, err := io.ReadAll(resp.Body) + if err != nil { + return sharedtoken.OneLinkToken{}, fmt.Errorf("henan xinjing: read searchToken body: %w", err) + } + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return sharedtoken.OneLinkToken{}, fmt.Errorf("henan xinjing: searchToken HTTP %d: %s", resp.StatusCode, string(body)) + } + var output henanXinjingSearchTokenResponse + if err := json.Unmarshal(body, &output); err != nil { + return sharedtoken.OneLinkToken{}, fmt.Errorf("henan xinjing: decode searchToken response: %w (body=%s)", err, string(body)) + } + code, err := henanXinjingDecodeCode(output.Code) + if err != nil { + return sharedtoken.OneLinkToken{}, fmt.Errorf("henan xinjing: searchToken: %w (body=%s)", err, string(body)) + } + if code != 0 { + return sharedtoken.OneLinkToken{}, fmt.Errorf("henan xinjing: searchToken failed: code=%d msg=%s", code, output.Msg) + } + tokenValue := henanXinjingFirstToken(&output) + if tokenValue == "" { + return sharedtoken.OneLinkToken{}, fmt.Errorf("henan xinjing: searchToken returned empty accesstoken") + } + expiresAt, err := henanXinjingParseExpiry(output.Data.AccessTokenAt) + if err != nil { + return sharedtoken.OneLinkToken{}, err + } + // Refresh slightly ahead of the declared expiry to absorb clock skew. + if until := time.Until(expiresAt); until > henanXinjingTokenRefreshBackoff { + expiresAt = expiresAt.Add(-henanXinjingTokenRefreshBackoff) + } + return sharedtoken.OneLinkToken{ + Value: tokenValue, + CreatedAt: time.Now(), + ExpiresAt: expiresAt, + }, nil +} + +// Fetch satisfies the sharedtoken.Fetcher-style interface used by the +// token manager. The chanKey argument is unused here because Henan Xinjing +// scopes tokens to the (channel_id, channel_pname) pair rather than to a +// specific chan_key; we always fetch with the configured cardNo. +func (p *HenanXinjingTokenProvider) Fetch(ctx context.Context) (sharedtoken.OneLinkToken, error) { + return p.fetch(ctx, "") +} + +// FetchToken matches the sharedtoken.TokenFetcher interface used by +// sharedtoken.NewTokenManager. chanKey is the token manager's cache key +// (typically the channel's chan_key); it does not influence the upstream +// request but lets the manager cache one token per channel. +func (p *HenanXinjingTokenProvider) FetchToken(ctx context.Context, _ string) (sharedtoken.OneLinkToken, error) { + return p.fetch(ctx, "") +} + +// henanXinjingFetcher adapts HenanXinjingTokenProvider to the sharedtoken +// fetcher shape used by RegisterTokenProviderFactory. +type henanXinjingFetcher struct{ provider *HenanXinjingTokenProvider } + +func (f henanXinjingFetcher) FetchToken(ctx context.Context, chanKey string) (sharedtoken.OneLinkToken, error) { + return f.provider.fetch(ctx, "") +} diff --git a/internal/logic/upstreamimpl/http_config.go b/internal/logic/upstreamimpl/http_config.go new file mode 100644 index 0000000..db3b1b7 --- /dev/null +++ b/internal/logic/upstreamimpl/http_config.go @@ -0,0 +1,101 @@ +package upstreamimpl + +import ( + "context" + "encoding/json" + "fmt" + "strings" + "time" + + "git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/model" + "github.com/gogf/gf/v2/frame/g" +) + +const ( + defaultUpstreamHTTPTimeout = 10 * time.Second + upstreamHTTPTimeoutKey = "dependencies.upstream_http.timeout" +) + +func templateConfigJSON(channelConfig *model.SimChannelConfigItemCache) string { + if channelConfig == nil || channelConfig.Template == nil { + return "" + } + return channelConfig.Template.TempConfig +} + +// parseUpstreamDuration accepts Go duration strings and positive numeric +// seconds so template and application configuration use the same contract. +func parseUpstreamDuration(raw json.RawMessage, field string) (time.Duration, error) { + if len(raw) == 0 || string(raw) == "null" { + return 0, fmt.Errorf("%s is empty", field) + } + var value any + if err := json.Unmarshal(raw, &value); err != nil { + return 0, fmt.Errorf("invalid %s: %w", field, err) + } + switch v := value.(type) { + case float64: + if v <= 0 || v != float64(int64(v)) { + return 0, fmt.Errorf("invalid %s: expected positive seconds", field) + } + return time.Duration(v) * time.Second, nil + case string: + value := strings.TrimSpace(v) + if value == "" { + return 0, fmt.Errorf("invalid %s: empty value", field) + } + duration, err := time.ParseDuration(value) + if err != nil || duration <= 0 { + return 0, fmt.Errorf("invalid %s: expected positive duration", field) + } + return duration, nil + default: + return 0, fmt.Errorf("invalid %s: expected duration or seconds", field) + } +} + +func parseUpstreamConfigDuration(value any, field string) (time.Duration, error) { + raw, err := json.Marshal(value) + if err != nil { + return 0, fmt.Errorf("encode %s: %w", field, err) + } + return parseUpstreamDuration(raw, field) +} + +func loadDefaultUpstreamHTTPTimeout(ctx context.Context) time.Duration { + value := g.Cfg().MustGet(ctx, upstreamHTTPTimeoutKey, defaultUpstreamHTTPTimeout.String()).Val() + timeout, err := parseUpstreamConfigDuration(value, upstreamHTTPTimeoutKey) + if err != nil { + g.Log().Warningf(ctx, "%v, fallback to %s", err, defaultUpstreamHTTPTimeout) + return defaultUpstreamHTTPTimeout + } + return timeout +} + +// resolveUpstreamHTTPTimeout applies the single timeout override for a +// template. Missing or invalid template values fall back to application +// configuration and finally the code default. +func resolveUpstreamHTTPTimeout(ctx context.Context, tempConfig string) time.Duration { + defaultTimeout := loadDefaultUpstreamHTTPTimeout(ctx) + if strings.TrimSpace(tempConfig) == "" { + return defaultTimeout + } + var raw struct { + HTTP struct { + Timeout json.RawMessage `json:"timeout"` + } `json:"http"` + } + if err := json.Unmarshal([]byte(tempConfig), &raw); err != nil { + g.Log().Warningf(ctx, "parse upstream template config failed: %v, fallback to %s", err, defaultTimeout) + return defaultTimeout + } + if len(raw.HTTP.Timeout) == 0 || string(raw.HTTP.Timeout) == "null" { + return defaultTimeout + } + timeout, err := parseUpstreamDuration(raw.HTTP.Timeout, "http.timeout") + if err != nil { + g.Log().Warningf(ctx, "%v, fallback to %s", err, defaultTimeout) + return defaultTimeout + } + return timeout +} diff --git a/internal/logic/upstreamimpl/lanshang_token_provider.go b/internal/logic/upstreamimpl/lanshang_token_provider.go new file mode 100644 index 0000000..760c72c --- /dev/null +++ b/internal/logic/upstreamimpl/lanshang_token_provider.go @@ -0,0 +1,112 @@ +package upstreamimpl + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "time" + + sharedtoken "git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/token" +) + +// LanShangTokenConfig contains the endpoint from the template and credentials +// from the channel configuration. +type LanShangTokenConfig struct { + BaseURL string + Path string + SubAccount string + Secret string + Timeout time.Duration +} + +type LanShangTokenProvider struct { + baseURL string + path string + subAccount string + secret string + client *http.Client +} + +type lanShangTokenResponse struct { + Token string `json:"token"` + ExpireIn int `json:"expire_in"` +} + +type lanshangFetcher struct{ provider *LanShangTokenProvider } + +func NewLanShangTokenProvider(config LanShangTokenConfig) *LanShangTokenProvider { + timeout := config.Timeout + if timeout <= 0 { + timeout = defaultUpstreamHTTPTimeout + } + return &LanShangTokenProvider{ + baseURL: strings.TrimRight(strings.TrimSpace(config.BaseURL), "/"), + path: normalizeProviderPath(config.Path, "/get_token"), + subAccount: strings.TrimSpace(config.SubAccount), + secret: config.Secret, + client: &http.Client{Timeout: timeout}, + } +} + +func (f lanshangFetcher) FetchToken(ctx context.Context, _ string) (sharedtoken.OneLinkToken, error) { + return f.provider.Fetch(ctx) +} + +func (p *LanShangTokenProvider) Fetch(ctx context.Context) (sharedtoken.OneLinkToken, error) { + if p == nil || p.client == nil { + return sharedtoken.OneLinkToken{}, fmt.Errorf("lanshang token provider is not initialized") + } + if p.baseURL == "" || p.subAccount == "" || p.secret == "" { + return sharedtoken.OneLinkToken{}, fmt.Errorf("lanshang token endpoint and credentials are required") + } + + endpoint, err := url.Parse(joinProviderURL(p.baseURL, p.path)) + if err != nil { + return sharedtoken.OneLinkToken{}, fmt.Errorf("parse lanshang token URL: %w", err) + } + query := endpoint.Query() + query.Set("sub_account", p.subAccount) + query.Set("secret", p.secret) + endpoint.RawQuery = query.Encode() + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint.String(), nil) + if err != nil { + return sharedtoken.OneLinkToken{}, fmt.Errorf("create lanshang token request: %w", err) + } + req.Header.Set("Accept", "application/json") + resp, err := p.client.Do(req) + if err != nil { + return sharedtoken.OneLinkToken{}, fmt.Errorf("request lanshang token: %w", err) + } + defer resp.Body.Close() + body, err := io.ReadAll(resp.Body) + if err != nil { + return sharedtoken.OneLinkToken{}, fmt.Errorf("read lanshang token response: %w", err) + } + if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices { + return sharedtoken.OneLinkToken{}, fmt.Errorf("lanshang token HTTP status %d", resp.StatusCode) + } + + var output lanShangTokenResponse + if err := json.Unmarshal(body, &output); err != nil { + return sharedtoken.OneLinkToken{}, fmt.Errorf("decode lanshang token response: %w", err) + } + value := strings.TrimSpace(output.Token) + if value == "" { + return sharedtoken.OneLinkToken{}, fmt.Errorf("lanshang token is empty") + } + now := time.Now() + expiresAt := time.Time{} + if output.ExpireIn > 0 { + expiresAt = now.Add(time.Duration(output.ExpireIn) * time.Second) + } + return sharedtoken.OneLinkToken{Value: value, CreatedAt: now, ExpiresAt: expiresAt}, nil +} + +func (p *LanShangTokenProvider) FetchToken(ctx context.Context, _ string) (sharedtoken.OneLinkToken, error) { + return p.Fetch(ctx) +} diff --git a/internal/logic/upstreamimpl/lanshang_token_provider_test.go b/internal/logic/upstreamimpl/lanshang_token_provider_test.go new file mode 100644 index 0000000..9785629 --- /dev/null +++ b/internal/logic/upstreamimpl/lanshang_token_provider_test.go @@ -0,0 +1,55 @@ +package upstreamimpl + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + "time" +) + +func TestLanShangTokenProviderFetch(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/get_token" { + t.Fatalf("path = %q", r.URL.Path) + } + if got := r.URL.Query().Get("sub_account"); got != "\u66fc\u76db\u7269\u8054\u7f51" { + t.Fatalf("sub_account = %q", got) + } + if got := r.URL.Query().Get("secret"); got != "Qw@0371252" { + t.Fatalf("secret = %q", got) + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"token":"abc123","expire_in":120}`)) + })) + defer server.Close() + + provider := NewLanShangTokenProvider(LanShangTokenConfig{ + BaseURL: server.URL, Path: "/get_token", SubAccount: "\u66fc\u76db\u7269\u8054\u7f51", + Secret: "Qw@0371252", Timeout: time.Second, + }) + token, err := provider.Fetch(context.Background()) + if err != nil { + t.Fatal(err) + } + if token.Value != "abc123" { + t.Fatalf("token = %q", token.Value) + } + if token.ExpiresAt.Before(time.Now().Add(100 * time.Second)) { + t.Fatalf("expiry too short: %s", token.ExpiresAt) + } +} + +func TestLanShangTokenProviderRejectsEmptyToken(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte(`{"expire_in":120}`)) + })) + defer server.Close() + + provider := NewLanShangTokenProvider(LanShangTokenConfig{ + BaseURL: server.URL, SubAccount: "a", Secret: "b", Timeout: time.Second, + }) + if _, err := provider.Fetch(context.Background()); err == nil { + t.Fatal("expected empty token error") + } +} diff --git a/internal/logic/upstreamimpl/lanshu_token_provider.go b/internal/logic/upstreamimpl/lanshu_token_provider.go new file mode 100644 index 0000000..77b8d30 --- /dev/null +++ b/internal/logic/upstreamimpl/lanshu_token_provider.go @@ -0,0 +1,184 @@ +package upstreamimpl + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + "time" + + sharedtoken "git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/token" +) + +// lanshuTokenPath is the fallback used when Path is empty. On the template +// path this default is unreachable: parseSharedOneLinkConfig rejects an empty +// token.path before any provider is built, so Path always carries the value +// declared in temp_config. It only applies to direct construction (tests). +// The same unreachable-fallback shape exists in every shared-token provider. +const lanshuTokenPath = "/route/open/sharedToken" + +// LanShuTokenConfig contains the endpoint from the template and the +// credentials from the channel configuration. BaseURL and Path come from +// temp_config token.baseUrl/token.path; the credentials come from chan_config. +// Sign is a fixed value issued by the upstream and is passed through +// unchanged: the operator does not derive it from appId/password/timeStamp. +type LanShuTokenConfig struct { + BaseURL string + Path string + AppID string + Password string + Sign string + TokenType string + Timeout time.Duration +} + +// LanShuTokenProvider fetches a shared token from the LanShu open route. +type LanShuTokenProvider struct { + baseURL string + path string + appID string + password string + sign string + tokenType string + client *http.Client +} + +// lanshuTokenRequest is the documented request envelope. Note the capitalized +// Param/Verify keys. +type lanshuTokenRequest struct { + Param lanshuTokenParam `json:"Param"` + Verify lanshuTokenVerify `json:"verify"` +} + +type lanshuTokenParam struct { + TokenType string `json:"tokenType"` +} + +type lanshuTokenVerify struct { + TimeStamp int64 `json:"timeStamp"` + Password string `json:"password"` + AppID string `json:"appId"` + Sign string `json:"sign"` +} + +// lanshuTokenResponse mirrors the upstream envelope. The data object is +// uppercase on the wire ("result.Data"), so the JSON tag must match exactly. +type lanshuTokenResponse struct { + Result struct { + Data struct { + ExpireTime int64 `json:"expireTime"` + Token string `json:"token"` + } `json:"Data"` + } `json:"result"` + Message string `json:"message"` + Status string `json:"status"` +} + +func NewLanShuTokenProvider(config LanShuTokenConfig) *LanShuTokenProvider { + timeout := config.Timeout + if timeout <= 0 { + timeout = defaultUpstreamHTTPTimeout + } + return &LanShuTokenProvider{ + baseURL: strings.TrimRight(strings.TrimSpace(config.BaseURL), "/"), + path: normalizeProviderPath(config.Path, lanshuTokenPath), + appID: strings.TrimSpace(config.AppID), + password: config.Password, + sign: strings.TrimSpace(config.Sign), + tokenType: strings.TrimSpace(config.TokenType), + client: &http.Client{Timeout: timeout}, + } +} + +func (p *LanShuTokenProvider) Fetch(ctx context.Context) (sharedtoken.OneLinkToken, error) { + if p == nil || p.client == nil { + return sharedtoken.OneLinkToken{}, fmt.Errorf("lanshu token provider is not initialized") + } + if p.baseURL == "" || p.appID == "" || p.password == "" || p.sign == "" || p.tokenType == "" { + return sharedtoken.OneLinkToken{}, fmt.Errorf("lanshu baseUrl, appId, password, sign and tokenType are required") + } + + // The upstream does not validate timeStamp freshness, but it must be a + // real millisecond timestamp for the request to be well-formed. + payload, err := json.Marshal(lanshuTokenRequest{ + Param: lanshuTokenParam{TokenType: p.tokenType}, + Verify: lanshuTokenVerify{ + TimeStamp: time.Now().UnixMilli(), + Password: p.password, + AppID: p.appID, + Sign: p.sign, + }, + }) + if err != nil { + return sharedtoken.OneLinkToken{}, fmt.Errorf("encode lanshu token request: %w", err) + } + + req, err := http.NewRequestWithContext( + ctx, + http.MethodPost, + joinProviderURL(p.baseURL, p.path), + bytes.NewReader(payload), + ) + if err != nil { + return sharedtoken.OneLinkToken{}, fmt.Errorf("create lanshu token request: %w", err) + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/json") + + resp, err := p.client.Do(req) + if err != nil { + return sharedtoken.OneLinkToken{}, fmt.Errorf("request lanshu token: %w", err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(io.LimitReader(resp.Body, 4*1024*1024+1)) + if err != nil { + return sharedtoken.OneLinkToken{}, fmt.Errorf("read lanshu token response: %w", err) + } + if len(body) > 4*1024*1024 { + return sharedtoken.OneLinkToken{}, fmt.Errorf("lanshu token response is too large") + } + if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices { + return sharedtoken.OneLinkToken{}, fmt.Errorf("lanshu token HTTP status %d", resp.StatusCode) + } + + var output lanshuTokenResponse + if err := json.Unmarshal(body, &output); err != nil { + return sharedtoken.OneLinkToken{}, fmt.Errorf("decode lanshu token response: %w", err) + } + if status := strings.TrimSpace(output.Status); status != "0" { + return sharedtoken.OneLinkToken{}, fmt.Errorf( + "lanshu token failed: status=%s message=%s", + status, + strings.TrimSpace(output.Message), + ) + } + + value := strings.TrimSpace(output.Result.Data.Token) + if value == "" { + return sharedtoken.OneLinkToken{}, fmt.Errorf("lanshu token is empty") + } + // expireTime is reported in seconds (observed 1162~1565s), not + // milliseconds, so the refresh-ahead window in the factory can rely on it. + now := time.Now() + var expiresAt time.Time + if output.Result.Data.ExpireTime > 0 { + expiresAt = now.Add(time.Duration(output.Result.Data.ExpireTime) * time.Second) + } + return sharedtoken.OneLinkToken{Value: value, CreatedAt: now, ExpiresAt: expiresAt}, nil +} + +func (p *LanShuTokenProvider) FetchToken(ctx context.Context, _ string) (sharedtoken.OneLinkToken, error) { + return p.Fetch(ctx) +} + +// lanshuFetcher adapts LanShuTokenProvider to the shared token TokenFetcher +// contract, mirroring qianxunFetcher. +type lanshuFetcher struct{ provider *LanShuTokenProvider } + +func (f lanshuFetcher) FetchToken(ctx context.Context, _ string) (sharedtoken.OneLinkToken, error) { + return f.provider.Fetch(ctx) +} diff --git a/internal/logic/upstreamimpl/lanshu_token_provider_test.go b/internal/logic/upstreamimpl/lanshu_token_provider_test.go new file mode 100644 index 0000000..902a583 --- /dev/null +++ b/internal/logic/upstreamimpl/lanshu_token_provider_test.go @@ -0,0 +1,336 @@ +package upstreamimpl + +import ( + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + "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" +) + +const ( + lanshuTestAppID = "HeNanPuWeiTongXun10W" + lanshuTestPassword = "test-password" + lanshuTestSign = "7F4F56AEF91980FEBEAA9EC28E958EAA" + lanshuTestTokenType = "EC11_XY" + lanshuTestToken = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" +) + +func newLanshuTestProvider(t *testing.T, baseURL string) *LanShuTokenProvider { + t.Helper() + return NewLanShuTokenProvider(LanShuTokenConfig{ + BaseURL: baseURL, Path: "/route/open/sharedToken", + AppID: lanshuTestAppID, Password: lanshuTestPassword, + Sign: lanshuTestSign, TokenType: lanshuTestTokenType, Timeout: time.Second, + }) +} + +// TestLanShuTokenProviderSendRequestContract pins the exact wire shape that +// the upstream expects: capitalized Param, verify.* fields passed through +// verbatim, and a real millisecond timeStamp. +func TestLanShuTokenProviderSendRequestContract(t *testing.T) { + var ( + gotMethod string + gotPath string + gotContentType string + gotBody []byte + ) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotMethod = r.Method + gotPath = r.URL.Path + gotContentType = r.Header.Get("Content-Type") + body, _ := io.ReadAll(r.Body) + gotBody = body + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"result":{"Data":{"expireTime":1162,"token":"` + lanshuTestToken + `"}},"message":"success","status":"0"}`)) + })) + defer server.Close() + + provider := newLanshuTestProvider(t, server.URL) + before := time.Now().UnixMilli() + token, err := provider.Fetch(context.Background()) + after := time.Now().UnixMilli() + if err != nil { + t.Fatalf("fetch token: %v", err) + } + + if gotMethod != http.MethodPost { + t.Fatalf("method = %q, want POST", gotMethod) + } + if gotPath != "/route/open/sharedToken" { + t.Fatalf("path = %q", gotPath) + } + if gotContentType != "application/json" { + t.Fatalf("Content-Type = %q", gotContentType) + } + + var payload struct { + Param struct { + TokenType string `json:"tokenType"` + } `json:"Param"` + Verify struct { + TimeStamp int64 `json:"timeStamp"` + Password string `json:"password"` + AppID string `json:"appId"` + Sign string `json:"sign"` + } `json:"verify"` + } + if err := json.Unmarshal(gotBody, &payload); err != nil { + t.Fatalf("decode request body %s: %v", gotBody, err) + } + if payload.Param.TokenType != lanshuTestTokenType { + t.Fatalf("Param.tokenType = %q", payload.Param.TokenType) + } + if payload.Verify.AppID != lanshuTestAppID { + t.Fatalf("verify.appId = %q", payload.Verify.AppID) + } + if payload.Verify.Password != lanshuTestPassword { + t.Fatalf("verify.password = %q", payload.Verify.Password) + } + if payload.Verify.Sign != lanshuTestSign { + t.Fatalf("verify.sign = %q (must be passed through verbatim)", payload.Verify.Sign) + } + if payload.Verify.TimeStamp < before || payload.Verify.TimeStamp > after { + t.Fatalf("verify.timeStamp = %d, want millis in [%d, %d]", payload.Verify.TimeStamp, before, after) + } + + if token.Value != lanshuTestToken { + t.Fatalf("token = %q", token.Value) + } + // expireTime is seconds, so the expiry must land near now+1162s rather + // than now+1162ms. + wantExpiry := time.Now().Add(1162 * time.Second) + if diff := token.ExpiresAt.Sub(wantExpiry); diff > 5*time.Second || diff < -5*time.Second { + t.Fatalf("ExpiresAt = %s, want ~%s", token.ExpiresAt, wantExpiry) + } + if token.CreatedAt.IsZero() { + t.Fatal("CreatedAt must be set") + } +} + +func TestLanShuTokenProviderRejectsBusinessFailure(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"result":{},"message":"未找到对应token获取","status":"500"}`)) + })) + defer server.Close() + + provider := newLanshuTestProvider(t, server.URL) + token, err := provider.Fetch(context.Background()) + if err == nil { + t.Fatalf("expected error for status=500, got token %q", token.Value) + } + if token.Value != "" { + t.Fatalf("token must not be returned on failure, got %q", token.Value) + } +} + +func TestLanShuTokenProviderRejectsEmptyToken(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"result":{"Data":{"expireTime":1162,"token":""}},"message":"success","status":"0"}`)) + })) + defer server.Close() + + provider := newLanshuTestProvider(t, server.URL) + if _, err := provider.Fetch(context.Background()); err == nil { + t.Fatal("expected empty token error") + } +} + +func TestLanShuTokenProviderRequiresCredentials(t *testing.T) { + // The base URL points at an unroutable TEST-NET-1 address, so a missing + // validation guard would surface as a dial timeout instead of the + // credential error this test asserts on. + const deadURL = "http://192.0.2.1:1" + cases := map[string]LanShuTokenConfig{ + "missing sign": { + BaseURL: deadURL, Path: "/route/open/sharedToken", + AppID: lanshuTestAppID, Password: lanshuTestPassword, TokenType: lanshuTestTokenType, + }, + "missing password": { + BaseURL: deadURL, Path: "/route/open/sharedToken", + AppID: lanshuTestAppID, Sign: lanshuTestSign, TokenType: lanshuTestTokenType, + }, + "missing appId": { + BaseURL: deadURL, Path: "/route/open/sharedToken", + Password: lanshuTestPassword, Sign: lanshuTestSign, TokenType: lanshuTestTokenType, + }, + "missing tokenType": { + BaseURL: deadURL, Path: "/route/open/sharedToken", + AppID: lanshuTestAppID, Password: lanshuTestPassword, Sign: lanshuTestSign, + }, + "missing baseUrl": { + Path: "/route/open/sharedToken", AppID: lanshuTestAppID, + Password: lanshuTestPassword, Sign: lanshuTestSign, TokenType: lanshuTestTokenType, + }, + } + for name, config := range cases { + t.Run(name, func(t *testing.T) { + provider := NewLanShuTokenProvider(config) + start := time.Now() + _, err := provider.Fetch(context.Background()) + if err == nil { + t.Fatal("expected credential validation error") + } + // The guard must reject before any HTTP round trip. + if elapsed := time.Since(start); elapsed > time.Second { + t.Fatalf("validation must not hit the network, took %s", elapsed) + } + if !strings.Contains(err.Error(), "lanshu baseUrl, appId, password, sign and tokenType are required") { + t.Fatalf("error = %v, want the credential guard message", err) + } + }) + } +} + +// TestLanShuTemplateRegistration pins that a provider=lanshu template +// instantiates with a chan_config carrying the four required credentials. +func TestLanShuTemplateRegistration(t *testing.T) { + template, ok := service.UpstreamAPI().GetTemp(sharedOneLinkLanshuTemplateKey) + if !ok || template == nil { + t.Fatal("lanshu template not registered") + } + + config := &model.SimChannelConfigItemCache{ + Template: &entity.TSimChannelTemplate{ + TempKey: sharedOneLinkLanshuTemplateKey, + TempConfig: `{"token":{"provider":"lanshu","baseUrl":"http://api.jswl999.com",` + + `"path":"/route/open/sharedToken"},"api":{"baseUrl":"https://api.iot.10086.cn/v5/","timeout":"1s"}}`, + }, + Channel: &entity.TSimChannels{ + ChanKey: "LSH-001", + ChanConfig: `{"appId":"` + lanshuTestAppID + `","password":"` + lanshuTestPassword + + `","sign":"` + lanshuTestSign + `","tokenType":"` + lanshuTestTokenType + `"}`, + }, + } + if instance := template.NewTemp(context.Background(), config); instance == nil { + t.Fatal("lanshu template failed to instantiate") + } +} + +// TestLanShuTokenPathComesFromTemplate pins the answer to "is the token path +// read from the template, or hardcoded in the provider?": the provider's +// normalizeProviderPath fallback is unreachable on the template path, so the +// request must land on whatever token.path the template declares. The path +// used here deliberately differs from lanshuTokenPath, so a hardcoded default +// would fail this assertion. +// +// This stops one layer below the token manager: going through GetSharedToken +// would also exercise the DB-backed persister, which is out of scope here. +func TestLanShuTokenPathComesFromTemplate(t *testing.T) { + // A path that is deliberately NOT lanshuTokenPath. + const templatePath = "/some/other/open/route" + if templatePath == lanshuTokenPath { + t.Fatal("test precondition broken: templatePath must differ from lanshuTokenPath") + } + + var gotPath string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotPath = r.URL.Path + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"result":{"Data":{"expireTime":600,"token":"` + lanshuTestToken + `"}},"message":"success","status":"0"}`)) + })) + defer server.Close() + + channelConfig := &model.SimChannelConfigItemCache{ + Template: &entity.TSimChannelTemplate{ + TempKey: sharedOneLinkLanshuTemplateKey, + TempConfig: `{"token":{"provider":"lanshu","baseUrl":"` + server.URL + `",` + + `"path":"` + templatePath + `"}}`, + }, + Channel: &entity.TSimChannels{ + ChanKey: "LSH-001", + ChanConfig: `{"appId":"` + lanshuTestAppID + `","password":"` + lanshuTestPassword + + `","sign":"` + lanshuTestSign + `","tokenType":"` + lanshuTestTokenType + `"}`, + }, + } + + // The parsed config must carry the template path, not the fallback. + cfg, _, err := parseSharedOneLinkConfig(context.Background(), channelConfig) + if err != nil { + t.Fatalf("parse config: %v", err) + } + if cfg.TokenPath != templatePath { + t.Fatalf("parsed TokenPath = %q, want the template-declared %q", cfg.TokenPath, templatePath) + } + + // And the provider built from it must issue the request to that path. + provider := NewLanShuTokenProvider(LanShuTokenConfig{ + BaseURL: cfg.TokenBaseURL, Path: cfg.TokenPath, AppID: cfg.AppID, + Password: cfg.Password, Sign: cfg.Sign, TokenType: cfg.TokenType, Timeout: cfg.Timeout, + }) + if _, err := provider.Fetch(context.Background()); err != nil { + t.Fatalf("fetch token: %v", err) + } + if gotPath != templatePath { + t.Fatalf("request path = %q, want the template-declared %q", gotPath, templatePath) + } +} + +// TestLanShuTokenPathIsRequiredFromTemplate pins that a template without +// token.path is rejected rather than silently falling back to a default. +func TestLanShuTokenPathIsRequiredFromTemplate(t *testing.T) { + config := &model.SimChannelConfigItemCache{ + Template: &entity.TSimChannelTemplate{ + TempKey: sharedOneLinkLanshuTemplateKey, + TempConfig: `{"token":{"provider":"lanshu","baseUrl":"http://api.jswl999.com"}}`, + }, + Channel: &entity.TSimChannels{ + ChanKey: "LSH-001", + ChanConfig: `{"appId":"` + lanshuTestAppID + `","password":"` + lanshuTestPassword + + `","sign":"` + lanshuTestSign + `","tokenType":"` + lanshuTestTokenType + `"}`, + }, + } + if _, _, err := parseSharedOneLinkConfig(context.Background(), config); err == nil { + t.Fatal("missing token.path must be rejected") + } +} + +// TestLanShuChannelConfigRejectsIncompleteCredentials pins the parse-time +// guard so a channel missing chan_config.sign cannot silently build a +// provider that fails on every request. +func TestLanShuChannelConfigRejectsIncompleteCredentials(t *testing.T) { + base := func(chanConfig string) *model.SimChannelConfigItemCache { + return &model.SimChannelConfigItemCache{ + Template: &entity.TSimChannelTemplate{ + TempKey: sharedOneLinkLanshuTemplateKey, + TempConfig: `{"token":{"provider":"lanshu","baseUrl":"http://api.jswl999.com",` + + `"path":"/route/open/sharedToken"}}`, + }, + Channel: &entity.TSimChannels{ChanKey: "LSH-001", ChanConfig: chanConfig}, + } + } + + complete := `{"appId":"` + lanshuTestAppID + `","password":"` + lanshuTestPassword + + `","sign":"` + lanshuTestSign + `","tokenType":"` + lanshuTestTokenType + `"}` + parsed, _, err := parseSharedOneLinkConfig(context.Background(), base(complete)) + if err != nil { + t.Fatalf("complete chan_config must parse: %v", err) + } + if parsed.Sign != lanshuTestSign { + t.Fatalf("parsed sign = %q", parsed.Sign) + } + if parsed.TokenType != lanshuTestTokenType { + t.Fatalf("parsed tokenType = %q", parsed.TokenType) + } + + for name, incomplete := range map[string]string{ + "missing sign": `{"appId":"` + lanshuTestAppID + `","password":"` + lanshuTestPassword + `","tokenType":"` + lanshuTestTokenType + `"}`, + "missing password": `{"appId":"` + lanshuTestAppID + `","sign":"` + lanshuTestSign + `","tokenType":"` + lanshuTestTokenType + `"}`, + "missing tokenType": `{"appId":"` + lanshuTestAppID + `","password":"` + lanshuTestPassword + `","sign":"` + lanshuTestSign + `"}`, + } { + t.Run(name, func(t *testing.T) { + if _, _, err := parseSharedOneLinkConfig(context.Background(), base(incomplete)); err == nil { + t.Fatal("expected chan_config validation error") + } + }) + } +} diff --git a/internal/logic/upstreamimpl/mingqian_registration_test.go b/internal/logic/upstreamimpl/mingqian_registration_test.go new file mode 100644 index 0000000..d51c8e9 --- /dev/null +++ b/internal/logic/upstreamimpl/mingqian_registration_test.go @@ -0,0 +1,21 @@ +package upstreamimpl_test + +import ( + "context" + _ "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) { + template, ok := service.UpstreamAPI().GetTemp("sim_mingqian_v3") + if !ok || template == nil { + t.Fatal("mingqian template not registered") + } + cfg := &model.SimChannelConfigItemCache{Template: &entity.TSimChannelTemplate{}, Channel: &entity.TSimChannels{AppKey: "test", AppSecret: "test"}} + if instance := template.NewTemp(context.Background(), cfg); instance == nil { + t.Fatal("mingqian instance creation failed") + } +} diff --git a/internal/logic/upstreamimpl/mobile_public_token_provider.go b/internal/logic/upstreamimpl/mobile_public_token_provider.go new file mode 100644 index 0000000..ecf1bf5 --- /dev/null +++ b/internal/logic/upstreamimpl/mobile_public_token_provider.go @@ -0,0 +1,145 @@ +package upstreamimpl + +import ( + "context" + "crypto/md5" + "encoding/hex" + "encoding/json" + "fmt" + "io" + "net/http" + "strconv" + "strings" + "time" + + sharedtoken "git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/token" +) + +const mobilePublicTokenPath = "/api/get/yd/api/msg" + +// MobilePublicTokenConfig contains the credentials used to request a shared +// China Mobile OneLink token. Secret is sourced from t_sim_channels.app_secret. +type MobilePublicTokenConfig struct { + BaseURL string + Path string + UserID string + Secret string + Timeout time.Duration +} + +// MobilePublicTokenProvider fetches a shared OneLink token from the mobile +// public-token upstream. +type MobilePublicTokenProvider struct { + baseURL string + path string + userID string + secret string + client *http.Client + now func() time.Time +} + +type mobilePublicTokenResponse struct { + Code int `json:"code"` + Msg string `json:"msg"` + Data struct { + Token string `json:"token"` + ExpireTime int64 `json:"expire_time"` + } `json:"data"` +} + +func NewMobilePublicTokenProvider(config MobilePublicTokenConfig) *MobilePublicTokenProvider { + timeout := config.Timeout + if timeout <= 0 { + timeout = defaultUpstreamHTTPTimeout + } + return &MobilePublicTokenProvider{ + baseURL: strings.TrimRight(strings.TrimSpace(config.BaseURL), "/"), + path: normalizeProviderPath(config.Path, mobilePublicTokenPath), + userID: strings.TrimSpace(config.UserID), + secret: config.Secret, + client: &http.Client{Timeout: timeout}, + now: time.Now, + } +} + +func mobilePublicSign(secret, userID string, timestamp int64) string { + source := secret + userID + strconv.FormatInt(timestamp, 10) + secret + sum := md5.Sum([]byte(source)) + return hex.EncodeToString(sum[:]) +} + +func (p *MobilePublicTokenProvider) Fetch(ctx context.Context) (sharedtoken.OneLinkToken, error) { + if p == nil || p.client == nil || p.now == nil { + return sharedtoken.OneLinkToken{}, fmt.Errorf("mobile public token provider is not initialized") + } + if p.baseURL == "" { + return sharedtoken.OneLinkToken{}, fmt.Errorf("mobile public token base URL is required") + } + if p.userID == "" || p.secret == "" { + return sharedtoken.OneLinkToken{}, fmt.Errorf("mobile public token userId and secret are required") + } + userID, err := strconv.ParseInt(p.userID, 10, 64) + if err != nil || userID <= 0 { + return sharedtoken.OneLinkToken{}, fmt.Errorf("mobile public token userId must be a positive integer") + } + + now := p.now() + payload := struct { + UserID int64 `json:"userid"` + Time int64 `json:"time"` + Sign string `json:"sign"` + }{ + UserID: userID, + Time: now.Unix(), + Sign: mobilePublicSign(p.secret, strconv.FormatInt(userID, 10), now.Unix()), + } + body, err := json.Marshal(payload) + if err != nil { + return sharedtoken.OneLinkToken{}, fmt.Errorf("encode mobile public token request: %w", err) + } + req, err := http.NewRequestWithContext(ctx, http.MethodPost, joinProviderURL(p.baseURL, p.path), strings.NewReader(string(body))) + if err != nil { + return sharedtoken.OneLinkToken{}, fmt.Errorf("create mobile public token request: %w", err) + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/json") + resp, err := p.client.Do(req) + if err != nil { + return sharedtoken.OneLinkToken{}, fmt.Errorf("request mobile public token: %w", err) + } + defer resp.Body.Close() + responseBody, err := io.ReadAll(resp.Body) + if err != nil { + return sharedtoken.OneLinkToken{}, fmt.Errorf("read mobile public token response: %w", err) + } + if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices { + return sharedtoken.OneLinkToken{}, fmt.Errorf("mobile public token HTTP status %d", resp.StatusCode) + } + + var output mobilePublicTokenResponse + if err := json.Unmarshal(responseBody, &output); err != nil { + return sharedtoken.OneLinkToken{}, fmt.Errorf("decode mobile public token response: %w", err) + } + if output.Code != http.StatusOK { + return sharedtoken.OneLinkToken{}, fmt.Errorf("mobile public token failed: code=%d msg=%s", output.Code, strings.TrimSpace(output.Msg)) + } + value := strings.TrimSpace(output.Data.Token) + if value == "" { + return sharedtoken.OneLinkToken{}, fmt.Errorf("mobile public token is empty") + } + expiresAt := time.Unix(output.Data.ExpireTime, 0) + if output.Data.ExpireTime <= 0 || !expiresAt.After(now) { + return sharedtoken.OneLinkToken{}, fmt.Errorf("mobile public token expire_time must be in the future: %d", output.Data.ExpireTime) + } + return sharedtoken.OneLinkToken{Value: value, CreatedAt: now, ExpiresAt: expiresAt}, nil +} + +func (p *MobilePublicTokenProvider) FetchToken(ctx context.Context, _ string) (sharedtoken.OneLinkToken, error) { + return p.Fetch(ctx) +} + +type mobilePublicFetcher struct{ provider *MobilePublicTokenProvider } + +func (f mobilePublicFetcher) FetchToken(ctx context.Context, _ string) (sharedtoken.OneLinkToken, error) { + return f.provider.Fetch(ctx) +} diff --git a/internal/logic/upstreamimpl/mobile_public_token_provider_test.go b/internal/logic/upstreamimpl/mobile_public_token_provider_test.go new file mode 100644 index 0000000..88238fc --- /dev/null +++ b/internal/logic/upstreamimpl/mobile_public_token_provider_test.go @@ -0,0 +1,112 @@ +package upstreamimpl + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "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" +) + +func TestMobilePublicTemplateRegistration(t *testing.T) { + for _, templateKey := range []string{ + sharedOneLinkMobilePublicTemplateKey, + sharedOneLinkMobilePublicPreviousKey, + sharedOneLinkMobilePublicLegacyKey, + } { + t.Run(templateKey, func(t *testing.T) { + template, ok := service.UpstreamAPI().GetTemp(templateKey) + if !ok || template == nil { + t.Fatal("mobile public template not registered") + } + config := &model.SimChannelConfigItemCache{ + Template: &entity.TSimChannelTemplate{ + TempKey: templateKey, + TempConfig: `{"token":{"provider":"mobile_public","baseUrl":"https://example.com","path":"/api/get/yd/api/msg"}}`, + }, + Channel: &entity.TSimChannels{ + ChanKey: "daliuliang-test", + ChanConfig: `{"userId":"102369"}`, + AppSecret: "test-secret", + }, + } + if instance := template.NewTemp(context.Background(), config); instance == nil { + t.Fatal("mobile public channel failed to instantiate") + } + }) + } +} + +func TestMobilePublicSign(t *testing.T) { + const timestamp = int64(1699606000) + if got, want := mobilePublicSign("bbb", "aaa", timestamp), "2337f8d0d5c0cd9e415a3b3c27c4a595"; got != want { + t.Fatalf("sign = %q, want %q", got, want) + } +} + +func TestMobilePublicTokenProviderFetch(t *testing.T) { + now := time.Unix(1700000000, 0) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost || r.URL.Path != mobilePublicTokenPath { + t.Fatalf("request = %s %s", r.Method, r.URL.Path) + } + var input struct { + UserID int64 `json:"userid"` + Time int64 `json:"time"` + Sign string `json:"sign"` + } + if err := json.NewDecoder(r.Body).Decode(&input); err != nil { + t.Fatal(err) + } + if input.UserID != 102369 || input.Time != now.Unix() { + t.Fatalf("payload = %#v", input) + } + if want := mobilePublicSign("secret", "102369", input.Time); input.Sign != want { + t.Fatalf("sign = %q, want %q", input.Sign, want) + } + _, _ = w.Write([]byte(`{"code":200,"msg":"success","data":{"token":"token-1","expire_time":1700003600}}`)) + })) + defer server.Close() + + provider := NewMobilePublicTokenProvider(MobilePublicTokenConfig{ + BaseURL: server.URL, UserID: "102369", Secret: "secret", Timeout: time.Second, + }) + provider.now = func() time.Time { return now } + token, err := provider.Fetch(context.Background()) + if err != nil { + t.Fatalf("fetch token: %v", err) + } + if token.Value != "token-1" || !token.ExpiresAt.Equal(time.Unix(1700003600, 0)) { + t.Fatalf("token = %#v", token) + } +} + +func TestMobilePublicTokenProviderRejectsExpiredToken(t *testing.T) { + now := time.Unix(1700000000, 0) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte(`{"code":200,"data":{"token":"token-1","expire_time":1699999999}}`)) + })) + defer server.Close() + + provider := NewMobilePublicTokenProvider(MobilePublicTokenConfig{ + BaseURL: server.URL, UserID: "102369", Secret: "secret", Timeout: time.Second, + }) + provider.now = func() time.Time { return now } + if _, err := provider.Fetch(context.Background()); err == nil { + t.Fatal("expected expired token error") + } +} + +func TestMobilePublicTokenProviderRejectsNonNumericUserID(t *testing.T) { + provider := NewMobilePublicTokenProvider(MobilePublicTokenConfig{ + BaseURL: "http://127.0.0.1", UserID: "not-a-number", Secret: "secret", Timeout: time.Second, + }) + if _, err := provider.Fetch(context.Background()); err == nil { + t.Fatal("expected invalid userId error") + } +} diff --git a/internal/logic/upstreamimpl/qianxun_token_provider.go b/internal/logic/upstreamimpl/qianxun_token_provider.go new file mode 100644 index 0000000..06970d5 --- /dev/null +++ b/internal/logic/upstreamimpl/qianxun_token_provider.go @@ -0,0 +1,116 @@ +package upstreamimpl + +import ( + "context" + "crypto/md5" + "encoding/hex" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + "time" + + sharedtoken "git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/token" +) + +type QianxunTokenConfig struct { + BaseURL string + Path string + OperatorID string + AppID string + TokenType string + Key string + Timeout time.Duration +} + +type QianxunTokenProvider struct { + baseURL, path, operatorID, appID, tokenType, key string + client *http.Client +} + +type qianxunTokenResponse struct { + Code int `json:"code"` + Msg string `json:"msg"` + Data struct { + Token string `json:"token"` + ExpireDate int64 `json:"expireDate"` + } `json:"data"` +} + +func NewQianxunTokenProvider(config QianxunTokenConfig) *QianxunTokenProvider { + timeout := config.Timeout + if timeout <= 0 { + timeout = defaultUpstreamHTTPTimeout + } + return &QianxunTokenProvider{ + baseURL: strings.TrimRight(strings.TrimSpace(config.BaseURL), "/"), + path: normalizeProviderPath(config.Path, "/"), operatorID: strings.TrimSpace(config.OperatorID), + appID: strings.TrimSpace(config.AppID), tokenType: strings.TrimSpace(config.TokenType), key: config.Key, + client: &http.Client{Timeout: timeout}, + } +} + +func qianxunSharedSign(operatorID, appID, key string) string { + sum := md5.Sum([]byte(operatorID + appID + key)) + return hex.EncodeToString(sum[:]) +} + +func (p *QianxunTokenProvider) Fetch(ctx context.Context) (sharedtoken.OneLinkToken, error) { + if p == nil || p.client == nil { + return sharedtoken.OneLinkToken{}, fmt.Errorf("qianxun token provider is not initialized") + } + if p.baseURL == "" || p.operatorID == "" || p.appID == "" || p.tokenType == "" || p.key == "" { + return sharedtoken.OneLinkToken{}, fmt.Errorf("qianxun token endpoint and credentials are required") + } + payload := map[string]string{"operatorId": p.operatorID, "appId": p.appID, "tokenType": p.tokenType, "sign": qianxunSharedSign(p.operatorID, p.appID, p.key)} + body, err := json.Marshal(payload) + if err != nil { + return sharedtoken.OneLinkToken{}, fmt.Errorf("encode qianxun token request: %w", err) + } + req, err := http.NewRequestWithContext(ctx, http.MethodPost, joinProviderURL(p.baseURL, p.path), strings.NewReader(string(body))) + if err != nil { + return sharedtoken.OneLinkToken{}, fmt.Errorf("create qianxun token request: %w", err) + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/json") + resp, err := p.client.Do(req) + if err != nil { + return sharedtoken.OneLinkToken{}, fmt.Errorf("request qianxun token: %w", err) + } + defer resp.Body.Close() + responseBody, err := io.ReadAll(resp.Body) + if err != nil { + return sharedtoken.OneLinkToken{}, fmt.Errorf("read qianxun token response: %w", err) + } + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return sharedtoken.OneLinkToken{}, fmt.Errorf("qianxun token HTTP status %d", resp.StatusCode) + } + var output qianxunTokenResponse + if err := json.Unmarshal(responseBody, &output); err != nil { + return sharedtoken.OneLinkToken{}, fmt.Errorf("decode qianxun token response: %w", err) + } + if output.Code != 0 { + return sharedtoken.OneLinkToken{}, fmt.Errorf("qianxun token failed: code=%d msg=%s", output.Code, output.Msg) + } + value := strings.TrimSpace(output.Data.Token) + if value == "" { + return sharedtoken.OneLinkToken{}, fmt.Errorf("qianxun token is empty") + } + now := time.Now() + var expiresAt time.Time + if output.Data.ExpireDate > 0 { + expiresAt = now.Add(time.Duration(output.Data.ExpireDate) * time.Second) + } + return sharedtoken.OneLinkToken{Value: value, CreatedAt: now, ExpiresAt: expiresAt}, nil +} + +func (p *QianxunTokenProvider) FetchToken(ctx context.Context, _ string) (sharedtoken.OneLinkToken, error) { + return p.Fetch(ctx) +} + +type qianxunFetcher struct{ provider *QianxunTokenProvider } + +func (f qianxunFetcher) FetchToken(ctx context.Context, _ string) (sharedtoken.OneLinkToken, error) { + return f.provider.Fetch(ctx) +} diff --git a/internal/logic/upstreamimpl/qianxun_token_provider_test.go b/internal/logic/upstreamimpl/qianxun_token_provider_test.go new file mode 100644 index 0000000..0dc8f1d --- /dev/null +++ b/internal/logic/upstreamimpl/qianxun_token_provider_test.go @@ -0,0 +1,50 @@ +package upstreamimpl + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + "time" +) + +func TestQianxunTokenProviderAcceptsTokenWithoutExpiry(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost || r.URL.Path != "/oauth/access_token" { + t.Fatalf("request = %s %s", r.Method, r.URL.Path) + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"code":0,"msg":"success","data":{"token":"token-1","expireDate":0}}`)) + })) + defer server.Close() + + provider := NewQianxunTokenProvider(QianxunTokenConfig{ + BaseURL: server.URL, Path: "/oauth/access_token", OperatorID: "operator-1", + AppID: "app-1", TokenType: "card", Key: "key-1", Timeout: time.Second, + }) + token, err := provider.Fetch(context.Background()) + if err != nil { + t.Fatalf("fetch token: %v", err) + } + if token.Value != "token-1" { + t.Fatalf("token = %q", token.Value) + } + if !token.ExpiresAt.IsZero() { + t.Fatalf("expireDate=0 must produce a token without expiry: %s", token.ExpiresAt) + } +} + +func TestQianxunTokenProviderRejectsEmptyToken(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte(`{"code":0,"msg":"success","data":{"token":"","expireDate":0}}`)) + })) + defer server.Close() + + provider := NewQianxunTokenProvider(QianxunTokenConfig{ + BaseURL: server.URL, OperatorID: "operator-1", AppID: "app-1", + TokenType: "card", Key: "key-1", Timeout: time.Second, + }) + if _, err := provider.Fetch(context.Background()); err == nil { + t.Fatal("expected empty token error") + } +} diff --git a/internal/logic/upstreamimpl/recharge/client.go b/internal/logic/upstreamimpl/recharge/client.go new file mode 100644 index 0000000..b5590d3 --- /dev/null +++ b/internal/logic/upstreamimpl/recharge/client.go @@ -0,0 +1,177 @@ +package recharge + +import ( + "bytes" + "context" + "crypto/md5" + "encoding/hex" + "encoding/json" + "fmt" + "io" + "net/http" + "strconv" + "strings" + "sync" + "time" +) + +const DefaultBaseURL = "http://www.sdtywl.cn/" +const requestInterval = time.Second + +var requestGates sync.Map + +// 上游业务码(综合充值平台API文档-通用反馈状态码) +const ( + CodeSuccess = 200 // 成功 + CodeMissingParam = 400 // 缺少参数 + CodeSignError = 401 // sign错误 + CodeForbidden = 403 // 无权访问 + CodePwdKeyError = 404 // pwdkey错误 + CodeNotWhiteList = 407 // 非白名单IP + CodeRateLimited = 429 // 访问超频(单接口60次/分) + CodeServerError = 500 // 其他错误 +) + +// BusinessError 上游业务错误(HTTP 200 但 code != 200) +type BusinessError struct { + Code int + Msg string +} + +func (e *BusinessError) Error() string { + return fmt.Sprintf("recharge upstream business error, code %d, msg %s", e.Code, e.Msg) +} + +// Response 上游统一响应信封 +type Response struct { + Code int `json:"code"` + Msg string `json:"msg"` + Data json.RawMessage `json:"data"` +} + +type Config struct { + BaseURL string + PwdKey string + Pwdiv string + Timeout time.Duration +} + +type Client struct { + baseURL string + pwdKey string + pwdiv string + http *http.Client +} + +type requestGate struct { + mu sync.Mutex + next time.Time +} + +func (gate *requestGate) wait(ctx context.Context) error { + for { + if err := ctx.Err(); err != nil { + return err + } + gate.mu.Lock() + delay := time.Until(gate.next) + if delay <= 0 { + gate.next = time.Now().Add(requestInterval) + gate.mu.Unlock() + return nil + } + gate.mu.Unlock() + + timer := time.NewTimer(delay) + select { + case <-ctx.Done(): + timer.Stop() + return ctx.Err() + case <-timer.C: + } + } +} + +func NewClient(cfg Config) *Client { + baseURL := cfg.BaseURL + if baseURL == "" { + baseURL = DefaultBaseURL + } + timeout := cfg.Timeout + if timeout <= 0 { + timeout = 10 * time.Second + } + return &Client{ + baseURL: strings.TrimSuffix(baseURL, "/"), + pwdKey: cfg.PwdKey, + pwdiv: cfg.Pwdiv, + http: &http.Client{Timeout: timeout}, + } +} + +// DoPost 调用上游 POST 接口,返回业务信封;HTTP 层错误直接返回 error, +// 业务码(code != 200)由调用方判断 +func (c *Client) DoPost(ctx context.Context, endpoint string, data any) (*Response, error) { + if err := c.requestGate(endpoint).wait(ctx); err != nil { + return nil, err + } + timestamp := strconv.FormatInt(time.Now().UnixMilli(), 10) + payload := map[string]any{ + "pwdkey": c.pwdKey, + "timestamp": timestamp, + "sign": c.sign(timestamp), + } + if data != nil { + payload["data"] = data + } + body, err := json.Marshal(payload) + if err != nil { + return nil, err + } + return c.do(ctx, http.MethodPost, c.baseURL+"/prod-api/open/api"+endpoint, body) +} + +func (c *Client) requestGate(endpoint string) *requestGate { + key := c.baseURL + "|" + c.pwdKey + "|" + strings.TrimSpace(endpoint) + gate, _ := requestGates.LoadOrStore(key, &requestGate{}) + return gate.(*requestGate) +} + +func (c *Client) sign(timestamp string) string { + sum := md5.Sum([]byte(c.pwdKey + c.pwdiv + timestamp)) + return strings.ToLower(hex.EncodeToString(sum[:])) +} + +func (c *Client) do(ctx context.Context, method, endpoint string, body []byte) (*Response, error) { + var reader io.Reader + if body != nil { + reader = bytes.NewReader(body) + } + req, err := http.NewRequestWithContext(ctx, method, endpoint, reader) + if err != nil { + return nil, err + } + req.Header.Set("Accept", "application/json") + if body != nil { + req.Header.Set("Content-Type", "application/json; charset=utf-8") + } + + resp, err := c.http.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices { + return nil, fmt.Errorf("recharge upstream http status %d: %s", resp.StatusCode, string(respBody)) + } + var output Response + if err := json.Unmarshal(respBody, &output); err != nil { + return nil, fmt.Errorf("decode recharge upstream response: %w, body=%s", err, string(respBody)) + } + return &output, nil +} diff --git a/internal/logic/upstreamimpl/recharge/client_test.go b/internal/logic/upstreamimpl/recharge/client_test.go new file mode 100644 index 0000000..2ebc773 --- /dev/null +++ b/internal/logic/upstreamimpl/recharge/client_test.go @@ -0,0 +1,33 @@ +package recharge + +import ( + "context" + "errors" + "testing" + "time" +) + +func TestRequestGateHonorsCancellation(t *testing.T) { + gate := &requestGate{} + if err := gate.wait(context.Background()); err != nil { + t.Fatalf("first wait failed: %v", err) + } + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Millisecond) + defer cancel() + if err := gate.wait(ctx); !errors.Is(err, context.DeadlineExceeded) { + t.Fatalf("wait error = %v, want deadline exceeded", err) + } +} + +func TestClientSharesGatePerAccountAndEndpoint(t *testing.T) { + first := NewClient(Config{BaseURL: "http://example.com", PwdKey: "account"}) + second := NewClient(Config{BaseURL: "http://example.com", PwdKey: "account"}) + + if first.requestGate("/findCard") != second.requestGate("/findCard") { + t.Fatal("same account and endpoint must share the rate limiter") + } + if first.requestGate("/findCard") == first.requestGate("/changeState") { + t.Fatal("different endpoints must not share the rate limiter") + } +} diff --git a/internal/logic/upstreamimpl/sim_daihe.go b/internal/logic/upstreamimpl/sim_daihe.go new file mode 100644 index 0000000..4f2bfa9 --- /dev/null +++ b/internal/logic/upstreamimpl/sim_daihe.go @@ -0,0 +1,265 @@ +package upstreamimpl + +import ( + "context" + "encoding/json" + "fmt" + "strings" + "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" + "git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/service/daihe" + "github.com/gogf/gf/v2/frame/g" +) + +const daiheTemplateKey = "sim_daihe_v1" + +type daiheConfig struct { + AppID string `json:"appId"` + AppSecret string `json:"appSecret"` + BaseURL string `json:"baseUrl"` + Timeout string `json:"timeout"` + API struct { + BaseURL string `json:"baseUrl"` + Timeout string `json:"timeout"` + } `json:"api"` +} +type daiheUsage struct { + ICCID string `json:"iccid"` + Month int `json:"month"` + Used float64 `json:"used"` +} +type daiheStatus struct { + ICCID string `json:"iccid"` + Status string `json:"status"` +} + +type sSimDaihe struct { + channelConfig *model.SimChannelConfigItemCache + client *daihe.Client +} + +func init() { service.UpstreamAPI().RegisterTemp(daiheTemplateKey, NewSimDaihe()) } +func NewSimDaihe() *sSimDaihe { return &sSimDaihe{} } + +func parseDaiheConfig(raw string) daiheConfig { + var cfg daiheConfig + _ = json.Unmarshal([]byte(raw), &cfg) + return cfg +} +func pickDaiheConfig(cfg *model.SimChannelConfigItemCache) (daiheConfig, bool) { + if cfg == nil || cfg.Channel == nil || cfg.Template == nil { + return daiheConfig{}, false + } + channel := parseDaiheConfig(cfg.Channel.ChanConfig) + template := parseDaiheConfig(cfg.Template.TempConfig) + out := template + if strings.TrimSpace(channel.AppID) != "" { + out.AppID = channel.AppID + } + if strings.TrimSpace(channel.AppSecret) != "" { + out.AppSecret = channel.AppSecret + } + if strings.TrimSpace(channel.BaseURL) != "" { + out.BaseURL = channel.BaseURL + } + if strings.TrimSpace(channel.API.BaseURL) != "" { + out.BaseURL = channel.API.BaseURL + } + if strings.TrimSpace(channel.Timeout) != "" { + out.Timeout = channel.Timeout + } + if strings.TrimSpace(channel.API.Timeout) != "" { + out.Timeout = channel.API.Timeout + } + if out.AppID == "" { + out.AppID = cfg.Channel.AppKey + } + if out.AppSecret == "" { + out.AppSecret = cfg.Channel.AppSecret + } + if out.BaseURL == "" { + out.BaseURL = "https://open.flymodem.com.cn" + } + return out, out.AppID != "" && out.AppSecret != "" +} + +func (s *sSimDaihe) NewTemp(ctx context.Context, cfg *model.SimChannelConfigItemCache) model.SimCardChannelI { + raw, ok := pickDaiheConfig(cfg) + if !ok { + g.Log().Warning(ctx, "daihe.NewTemp: missing appId/appSecret") + return nil + } + timeout := 15 * time.Second + if d, err := time.ParseDuration(raw.Timeout); err == nil && d > 0 { + timeout = d + } + return &sSimDaihe{channelConfig: cfg, client: daihe.NewClient(daihe.Config{BaseURL: raw.BaseURL, AppID: raw.AppID, AppSecret: raw.AppSecret, Timeout: timeout})} +} + +func daiheICCID(key *model.SimKey) string { + if key == nil { + return "" + } + return strings.TrimSpace(key.ICCID) +} +func daiheStatusCode(status string) (int, bool) { + switch strings.TrimSpace(status) { + case "库存": + return 7, true + case "可测试", "测试激活", "测试去激活": + return 6, true + case "可激活", "开始": + return 1, true + case "已激活": + return 2, true + case "已停用": + return 4, true + case "预清除": + return 8, true + case "已清除", "已失效", "已更换": + return 9, true + default: + return 0, false + } +} +func daiheUnsupported[T any](keys []*model.SimKey, name string) (out []*model.SimBaseRes[T], err error) { + for _, key := range keys { + out = append(out, &model.SimBaseRes[T]{Code: consts.ApiStatusError, SimKey: key, Message: "daihe does not support " + name}) + } + return +} + +func (s *sSimDaihe) QuerySimCardStatus(ctx context.Context, in *model.SimCardStatusInput) (out model.SimCardStatusOutput, err error) { + for _, key := range in.SimKeys { + r := &model.SimBaseRes[model.SimCardStatusItem]{SimKey: key} + iccid := daiheICCID(key) + var data daiheStatus + if iccid == "" { + r.Code = consts.ApiStatusError + r.Message = "iccid is required" + } else if e := s.client.QueryStatus(ctx, iccid, &data); e != nil { + r.Code = consts.ApiStatusError + r.Message = e.Error() + } else if status, ok := daiheStatusCode(data.Status); !ok { + r.Code = consts.ApiStatusError + r.Message = fmt.Sprintf("unsupported daihe card status: %s", data.Status) + } else { + r.Code = consts.ApiStatusSuccess + r.ResData.CardStatus = status + } + out = append(out, r) + } + return +} +func (s *sSimDaihe) QuerySimDataUsage(ctx context.Context, in *model.SimDataUsageInput) (out model.SimDataUsageOutput, err error) { + return s.queryUsage(ctx, in, "") +} +func (s *sSimDaihe) QuerySimDataUsageMonthly(ctx context.Context, in *model.SimDataUsageMonthlyInput) (out model.SimDataUsageMonthlyOutput, err error) { + month := "" + if in != nil { + month = strings.TrimSpace(in.ReqData.QueryDate) + } + result := make(model.SimDataUsageMonthlyOutput, 0, len(in.SimKeys)) + for _, key := range in.SimKeys { + r := &model.SimBaseRes[model.SimDataUsageMonthlyOutputItem]{SimKey: key} + iccid := daiheICCID(key) + var data daiheUsage + if iccid == "" { + r.Code = consts.ApiStatusError + r.Message = "iccid is required" + } else if e := s.client.QueryUsage(ctx, iccid, month, &data); e != nil { + r.Code = consts.ApiStatusError + r.Message = e.Error() + } else { + r.Code = consts.ApiStatusSuccess + r.ResData.DeadLine = fmt.Sprintf("%06d", data.Month) + r.ResData.DataAmount = data.Used + } + result = append(result, r) + } + return result, nil +} +func (s *sSimDaihe) queryUsage(ctx context.Context, in *model.SimDataUsageInput, month string) (out model.SimDataUsageOutput, err error) { + for _, key := range in.SimKeys { + r := &model.SimBaseRes[model.SimDataUsageItem]{SimKey: key} + iccid := daiheICCID(key) + var data daiheUsage + if iccid == "" { + r.Code = consts.ApiStatusError + r.Message = "iccid is required" + } else if e := s.client.QueryUsage(ctx, iccid, month, &data); e != nil { + r.Code = consts.ApiStatusError + r.Message = e.Error() + } else { + r.Code = consts.ApiStatusSuccess + r.ResData.DataUsage = data.Used + } + out = append(out, r) + } + return +} +func (s *sSimDaihe) ChangeSimStatus(ctx context.Context, in *model.SimCardStatusChangeInput) (out model.SimCardStatusChangeOutput, err error) { + op, ok := normalizeCardStatusChangeOperType(ctx, in.ReqData.OperType) + if !ok { + return newInvalidCardStatusChangeOutput(in.SimKeys), nil + } + for _, key := range in.SimKeys { + r := &model.SimBaseRes[model.SimCardStatusChangeItem]{SimKey: key} + iccid := daiheICCID(key) + var e error + if iccid == "" { + e = fmt.Errorf("iccid is required") + } else if op == consts.CardStatusChangeOperTypeApplyStop { + e = s.client.Deactivate(ctx, iccid) + } else { + e = s.client.Activate(ctx, iccid) + } + if e != nil { + r.Code = consts.ApiStatusError + r.Message = e.Error() + } else { + r.Code = consts.ApiStatusSuccess + } + out = append(out, r) + } + return +} +func (s *sSimDaihe) QuerySimBasicInfo(ctx context.Context, in *model.SimBasicInfoInput) (model.SimBasicInfoOutput, error) { + return daiheUnsupported[model.SimBasicInfoItem](in.SimKeys, "basic info query") +} +func (s *sSimDaihe) QuerySimSession(ctx context.Context, in *model.SimSessionInput) (model.SimSessionOutput, error) { + return daiheUnsupported[model.SimSessionItem](in.SimKeys, "session query") +} +func (s *sSimDaihe) QuerySimStopReason(ctx context.Context, in *model.SimStopReasonInput) (model.SimStopReasonOutput, error) { + return daiheUnsupported[model.SimStopReasonItem](in.SimKeys, "stop reason query") +} +func (s *sSimDaihe) QuerySimFrozenState(ctx context.Context, in *model.SimFrozenStateInput) (model.SimFrozenStateOutput, error) { + return daiheUnsupported[model.SimFrozenStateItem](in.SimKeys, "frozen state query") +} +func (s *sSimDaihe) QuerySimCardBindStatus(ctx context.Context, in *model.SimCardBindStatusInput) (model.SimCardBindStatusOutput, error) { + return daiheUnsupported[model.SimCardBindStatusItem](in.SimKeys, "card binding query") +} +func (s *sSimDaihe) QuerySimDeviceStatus(ctx context.Context, in *model.SimDeviceStatusInput) (model.SimDeviceStatusOutput, error) { + return daiheUnsupported[model.SimDeviceStatusItem](in.SimKeys, "device status query") +} +func (s *sSimDaihe) QuerySimRegionLimitStatus(ctx context.Context, in *model.SimRegionLimitStatusInput) (model.SimRegionLimitStatusOutput, error) { + return daiheUnsupported[model.SimRegionLimitStatusItem](in.SimKeys, "region limit query") +} +func (s *sSimDaihe) QuerySimImei(ctx context.Context, in *model.SimImeiInput) (model.SimImeiOutput, error) { + return daiheUnsupported[model.SimImeiItem](in.SimKeys, "IMEI query") +} +func (s *sSimDaihe) QuerySimDataDiagnosis(ctx context.Context, in *model.SimDataDiagnosisInput) (model.SimDataDiagnosisOutput, error) { + return daiheUnsupported[any](in.SimKeys, "data diagnosis") +} +func (s *sSimDaihe) QuerySimDataUsageDaily(ctx context.Context, in *model.SimDataUsageDailyInput) (model.SimDataUsageDailyOutput, error) { + return daiheUnsupported[model.SimDataUsageDailyOutputItem](in.SimKeys, "daily usage query") +} +func (s *sSimDaihe) QuerySimApnInfo(ctx context.Context, in *model.SimApnInfoInput) (model.SimApnInfoOutput, error) { + return daiheUnsupported[model.SimApnInfoItemList](in.SimKeys, "APN query") +} +func (s *sSimDaihe) ChangeSimApnFunction(ctx context.Context, in *model.SimCardOperateChangeApnInput) (model.SimCardOperateChangeApnOutput, error) { + return daiheUnsupported[model.SimCardOperateChangeApnItem](in.SimKeys, "APN change") +} diff --git a/internal/logic/upstreamimpl/sim_data_diagnosis.go b/internal/logic/upstreamimpl/sim_data_diagnosis.go new file mode 100644 index 0000000..bfc4cea --- /dev/null +++ b/internal/logic/upstreamimpl/sim_data_diagnosis.go @@ -0,0 +1,102 @@ +package upstreamimpl + +import ( + "context" + "encoding/json" + + "git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/consts" + "git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/model" + + "github.com/gogf/gf/v2/frame/g" + "github.com/gogf/gf/v2/util/gconv" +) + +type ydDataDiagnosisRequestFunc func(ctx context.Context, operation, url string, params g.Map) (*ydHTTPResponse, error) + +type ydDataDiagnosisResp struct { + Status string `json:"status"` + Message string `json:"message"` +} + +// QuerySimDataDiagnosis 物联卡达量限速档位查询 +func (s *sSimYD) QuerySimDataDiagnosis(ctx context.Context, input *model.SimDataDiagnosisInput) (model.SimDataDiagnosisOutput, error) { + return queryYdSimDataDiagnosis(ctx, input, s.getUrl(ctx, "ec/query/sim-data-diagnosis"), s.getTransId, s.requestYd) +} + +// QuerySimDataDiagnosis 物联卡达量限速档位查询 +func (s *sSimYDMock) QuerySimDataDiagnosis(ctx context.Context, input *model.SimDataDiagnosisInput) (model.SimDataDiagnosisOutput, error) { + return queryYdSimDataDiagnosis(ctx, input, s.getUrl(ctx, "ec/query/sim-data-diagnosis"), s.getTransId, s.requestYd) +} + +// QuerySimDataDiagnosis 物联卡达量限速档位查询 +func (s *sSimDX) QuerySimDataDiagnosis(ctx context.Context, input *model.SimDataDiagnosisInput) (model.SimDataDiagnosisOutput, error) { + items := make(model.SimDataDiagnosisOutput, 0) + if input == nil { + return items, nil + } + for _, simKey := range input.SimKeys { + items = append(items, &model.SimBaseRes[any]{ + SimKey: simKey, + Code: consts.ApiInvokeException, + Message: "电信官方接口暂不提供当前达量限速查询能力", + }) + } + return items, nil +} + +func queryYdSimDataDiagnosis( + ctx context.Context, + input *model.SimDataDiagnosisInput, + url string, + getTransID func(context.Context) string, + request ydDataDiagnosisRequestFunc, +) (model.SimDataDiagnosisOutput, error) { + items := make(model.SimDataDiagnosisOutput, 0) + if input == nil { + return items, nil + } + for _, simKey := range input.SimKeys { + params := g.Map{"transid": getTransID(ctx)} + paramName, paramValue := pickSimQueryParam(simKey) + if paramName == "" { + items = append(items, &model.SimBaseRes[any]{ + SimKey: simKey, + Code: consts.ApiStatusError, + Message: "missing iccid/msisdn/imsi", + }) + continue + } + params[paramName] = paramValue + + resp, err := request(ctx, "QuerySimDataDiagnosis", url, params) + if err != nil { + g.Log().Errorf(ctx, "QuerySimDataDiagnosis get error %+v,url %s, params %+v", err, url, params) + items = append(items, &model.SimBaseRes[any]{ + SimKey: simKey, + Code: consts.ApiStatusError, + Message: err.Error(), + }) + continue + } + + body := resp.ReadAllString() + var ydResp ydDataDiagnosisResp + if err = json.Unmarshal([]byte(body), &ydResp); err != nil { + g.Log().Errorf(ctx, "QuerySimDataDiagnosis unmarshal error %+v,url %s, params %+v, body %s", err, url, params, body) + items = append(items, &model.SimBaseRes[any]{ + SimKey: simKey, + Code: consts.ApiStatusError, + Message: err.Error(), + }) + continue + } + + items = append(items, &model.SimBaseRes[any]{ + SimKey: simKey, + Code: gconv.Int(ydResp.Status), + Message: ydResp.Message, + ResData: json.RawMessage(body), + }) + } + return items, nil +} diff --git a/internal/logic/upstreamimpl/sim_diag_helpers.go b/internal/logic/upstreamimpl/sim_diag_helpers.go new file mode 100644 index 0000000..8190bdf --- /dev/null +++ b/internal/logic/upstreamimpl/sim_diag_helpers.go @@ -0,0 +1,288 @@ +package upstreamimpl + +import ( + "strings" + + "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/util/gconv" +) + +func pickSimQueryParam(simKey *model.SimKey) (string, string) { + if simKey == nil { + return "", "" + } + if value := strings.TrimSpace(simKey.ICCID); value != "" { + return "iccid", value + } + if value := strings.TrimSpace(simKey.MSISDN); value != "" { + return "msisdn", value + } + if value := strings.TrimSpace(simKey.IMSI); value != "" { + return "imsi", value + } + return "", "" +} + +func ydBasicInfoHasIdentity(iccid, msisdn, imsi string) bool { + return strings.TrimSpace(iccid) != "" || strings.TrimSpace(msisdn) != "" || strings.TrimSpace(imsi) != "" +} + +func mapYdStopReasonText(stopReason string) string { + value := strings.TrimSpace(stopReason) + if len(value) == 12 && strings.Trim(value, "02") == "" { + if value == "000000000000" { + return "未停机或系统暂无停机原因" + } + var reasons []string + for _, code := range []struct { + position int + label string + }{ + {0, "窄带网商品到期失效停机"}, + {1, "机卡分离停机"}, + {3, "M2M管理停机"}, + {4, "信控双向停机"}, + {8, "主动申请双向停机"}, + {9, "区域限制管理型停机"}, + {10, "强制双向停机"}, + } { + if value[code.position] == '2' { + reasons = append(reasons, code.label) + } + } + if len(reasons) > 0 { + if strings.Count(value, "2") == len(reasons) { + return strings.Join(reasons, "、") + } + return strings.Join(reasons, "、") + " (原始码: " + value + ")" + } + } + switch value { + case "": + return "未停机" + case "000000000000": + return "未停机或系统暂无停机原因" + case "000000000020": + return "强制双向停机" + case "000000002000": + return "主动申请双向停机" + case "000020000000": + return "信控双向停机" + case "020000000000": + return "机卡分离停机" + case "000200000000": + return "M2M管理停机" + case "000000000200": + return "区域限制管理型停机" + default: + return value + } +} + +func mapYdFrozenStatusText(frozenStatus string) string { + value := strings.TrimSpace(frozenStatus) + switch value { + case "", "0": + return "未冻结" + case "1": + return "已冻结" + default: + return value + } +} + +func mapYdOperatingStatus(status string) string { + switch strings.ToUpper(strings.TrimSpace(status)) { + case "1", "01", "ON", "ONLINE": + return "1" + case "0", "00", "OFF", "OFFLINE", "-1": + return "0" + default: + return consts.CardSessionStatusUnknown + } +} + +func mapYdDeviceStatus(status string) string { + return mapYdOperatingStatus(status) +} + +func mapDxOperatingStatus(status string) string { + switch strings.TrimSpace(status) { + case "0": + // Telecom NQ007: 0 = power on, 1 = power off. + return "1" + case "1": + return "0" + default: + return consts.CardSessionStatusUnknown + } +} + +func pickYdCardBindStatus(result map[string]any) string { + if len(result) == 0 { + return "" + } + if value := strings.TrimSpace(toString(result["result"])); value != "" { + switch value { + case "0", "1", "2": + return value + case "OK", "ok": + return "1" + } + } + for _, key := range []string{"bind_status", "bindStatus", "cardBindStatus", "cardBindingType", "status"} { + value := strings.TrimSpace(toString(result[key])) + switch value { + case "0", "1": + return value + case "OK", "ok": + return "1" + } + } + return "" +} + +func pickYdCardBindStatusErrorCode(result map[string]any) string { + if len(result) == 0 { + return "" + } + for _, key := range []string{"errorCode", "error_code", "errCode", "errcode"} { + value := strings.TrimSpace(toString(result[key])) + if value != "" { + return value + } + } + return "" +} + +func pickYdCardBindStatusErrorDes(result map[string]any) string { + if len(result) == 0 { + return "" + } + for _, key := range []string{"errorDes", "error_des", "errDes", "errMsg", "message"} { + value := strings.TrimSpace(toString(result[key])) + if value != "" { + return value + } + } + return "" +} + +func toString(value any) string { + return strings.TrimSpace(gconv.String(value)) +} + +func mapDxStopTypeListText(stopTypeList []string) string { + if len(stopTypeList) == 0 { + return "未停机" + } + texts := make([]string, 0, len(stopTypeList)) + for _, stopType := range stopTypeList { + switch strings.TrimSpace(stopType) { + case "": + continue + case "100000": + texts = append(texts, "申请停机") + case "110000": + texts = append(texts, "挂失停机") + case "120000": + texts = append(texts, "停机保号") + case "130000": + texts = append(texts, "欠费停机") + case "130001": + texts = append(texts, "欠费单停") + case "130002": + texts = append(texts, "欠费双停") + case "140000": + texts = append(texts, "预拆机停机") + case "150000": + texts = append(texts, "违章停机") + case "150001": + texts = append(texts, "未实名制违规停机") + case "160000": + texts = append(texts, "数据异常停机") + case "170000": + texts = append(texts, "局方原因停机") + case "180000": + texts = append(texts, "强制停机") + case "190000": + texts = append(texts, "超范围使用违规停机") + case "200000": + texts = append(texts, "去激活停机") + case "210000": + texts = append(texts, "紧急停机") + case "220000": + texts = append(texts, "安全事件关停") + case "230000": + texts = append(texts, "到期停机") + case "240000": + texts = append(texts, "公免到期停机") + case "250000": + texts = append(texts, "限额管控停机") + case "260000": + texts = append(texts, "欺诈停机") + case "270000": + texts = append(texts, "模组融合停机") + case "280100": + texts = append(texts, "区域限制停机") + default: + texts = append(texts, strings.TrimSpace(stopType)) + } + } + if len(texts) == 0 { + return "未停机" + } + return strings.Join(texts, "、") +} + +func mapDxRegionLimitStatus(areaLimitType string, areaLimitInfo string) string { + areaLimitType = strings.TrimSpace(areaLimitType) + areaLimitInfo = strings.TrimSpace(areaLimitInfo) + if areaLimitType == "" || areaLimitType == "-1" || areaLimitType == "0" { + if areaLimitInfo == "" { + return "1" + } + } + return "0" +} + +func mapDxRegionLimitStatusByOrder(orderStatus string, limitStatus string) string { + orderStatus = strings.TrimSpace(orderStatus) + limitStatus = strings.TrimSpace(limitStatus) + switch { + case orderStatus == "1": + return "2" + case orderStatus == "0" && limitStatus == "0": + return "1" + case orderStatus == "0" && limitStatus == "1": + return "0" + case orderStatus == "0" && limitStatus == "-": + return "2" + default: + return "2" + } +} + +func normalizeDxRegionLimitDetails(orderStatus string, limitStatus string, limitType string, limitArea string) (string, string, string) { + if strings.TrimSpace(orderStatus) != "0" { + return "", "", "" + } + + switch strings.TrimSpace(limitStatus) { + case "0", "1", "-": + limitStatus = strings.TrimSpace(limitStatus) + default: + limitStatus = "" + } + + switch strings.TrimSpace(limitType) { + case "1", "2", "3", "4": + limitType = strings.TrimSpace(limitType) + default: + limitType = "" + } + + return limitStatus, limitType, strings.TrimSpace(limitArea) +} diff --git a/internal/logic/upstreamimpl/sim_diag_mapping_test.go b/internal/logic/upstreamimpl/sim_diag_mapping_test.go new file mode 100644 index 0000000..37aff45 --- /dev/null +++ b/internal/logic/upstreamimpl/sim_diag_mapping_test.go @@ -0,0 +1,104 @@ +package upstreamimpl + +import ( + "context" + "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" + "git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/model/entity" +) + +type diagnosisTestToken struct{} + +func (diagnosisTestToken) GetToken(context.Context) (string, error) { return "test-token", nil } +func (diagnosisTestToken) RecoverToken(context.Context, string) (string, error) { + return "test-token", nil +} + +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" { + t.Errorf("unexpected request %s", r.URL.String()) + } + _, _ = w.Write([]byte(`{"status":"0","message":"正确","result":[{"terminalDeviceImei":"123456789012345"}]}`)) + })) + defer server.Close() + sim := &sSimYD{ + 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"}}}) + if err != nil { + t.Fatal(err) + } + if len(result) != 1 || result[0].Code != 0 || result[0].ResData.Imei != "123456789012345" { + t.Fatalf("unexpected IMEI response: %+v", result) + } +} + +func TestYdStopReasonCombination(t *testing.T) { + for _, tc := range []struct{ input, want string }{ + {"020000002000", "机卡分离停机、主动申请双向停机"}, + {"200000000000", "窄带网商品到期失效停机"}, + {"002000000000", "002000000000"}, + } { + if got := mapYdStopReasonText(tc.input); got != tc.want { + t.Errorf("%s => %s, want %s", tc.input, got, tc.want) + } + } +} + +func TestDxRegionLimitStatusByOrder(t *testing.T) { + for _, tc := range []struct{ order, limit, want string }{ + {"1", "", "2"}, {"0", "0", "1"}, {"0", "1", "0"}, {"0", "-", "2"}, + } { + if got := mapDxRegionLimitStatusByOrder(tc.order, tc.limit); got != tc.want { + t.Errorf("%s/%s => %s, want %s", tc.order, tc.limit, got, tc.want) + } + } +} + +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" { + 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"}}}) + if err != nil { + t.Fatal(err) + } + if len(result) != 1 || result[0].Code != 0 || result[0].ResData.OrderStatus != "0" || result[0].ResData.BindStatus != "1" || result[0].ResData.BindImei != "123456789012345" { + t.Fatalf("unexpected response: %+v", result) + } +} + +func TestDxImeiReadsNQ003Response(t *testing.T) { + for _, tc := range []struct { + body string + wantCode int + wantImei, wantMessage string + }{ + {`{"RESULT":"0","MESSAGE":"查询成功","IMEI":"863644077218049"}`, 0, "863644077218049", "查询成功"}, + {`{"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" { + 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"}}}) + 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 new file mode 100644 index 0000000..d89c7a4 --- /dev/null +++ b/internal/logic/upstreamimpl/sim_dx.go @@ -0,0 +1,1794 @@ +package upstreamimpl + +import ( + "context" + "encoding/json" + "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" + "strconv" + "strings" + "sync" + "time" + + "github.com/gogf/gf/v2/frame/g" + "github.com/gogf/gf/v2/util/gconv" +) + +// XML命名空间正则 +var xmlNamespaceRegex = regexp.MustCompile(`xmlns[^=]*="[^"]*"\s*|:web:`) + +const dxLegacyUsageConcurrency = 5 + +type dxRegionLimitItem struct { + OrderStatus any `json:"orderStatus"` + LimitStatus string `json:"limitStatus"` + LimitType any `json:"limitType"` + LimitArea string `json:"limitArea"` +} + +func decodeDxRegionLimitData(raw json.RawMessage) (*dxRegionLimitItem, error) { + rawText := strings.TrimSpace(string(raw)) + if rawText == "" || rawText == "null" { + return nil, nil + } + var item dxRegionLimitItem + if err := json.Unmarshal([]byte(rawText), &item); err != nil { + return nil, err + } + return &item, nil +} + +type sSimDX struct { + channelConfig *model.SimChannelConfigItemCache + client *dx.Client + custNumber string +} + +func NewSimDX() *sSimDX { + return &sSimDX{} +} + +func init() { + s := NewSimDX() + service.UpstreamAPI().RegisterTemp("sim_dx_v1", s) + service.RegisterSimDX(s) +} + +func (s *sSimDX) NewTemp(ctx context.Context, channelConfig *model.SimChannelConfigItemCache) model.SimCardChannelI { + client := dx.NewClient(dx.Config{ + BaseURL: parseDXBaseURL(ctx, channelConfig), + AppKey: channelConfig.Channel.AppKey, + AppSecret: channelConfig.Channel.AppSecret, + Timeout: resolveUpstreamHTTPTimeout(ctx, templateConfigJSON(channelConfig)), + }) + return &sSimDX{ + channelConfig: channelConfig, + client: client, + custNumber: parseDXCustNumber(ctx, channelConfig), + } +} + +// QuerySimSession 物联网卡在线信息查询 +// CTIOT_5GCMP_NQ09 - 在线会话状态查询 +// - URL: /api/v1/onlineSessionOpen/qryOnlineSataus +// - 支持参数: accNum / access_number / iccid / imsi +func (s *sSimDX) QuerySimSession(ctx context.Context, input *model.SimSessionInput) (model.SimSessionOutput, error) { + type dxSessionResp struct { + Code any `json:"code"` + ResultCode any `json:"resultCode"` + Msg string `json:"msg"` + ResultMsg string `json:"resultMsg"` + Message string `json:"message"` + Data json.RawMessage `json:"data"` + } + type dxSessionItem struct { + OnlineStatus string `json:"onlineStatus"` + AccNum string `json:"accNum"` + ICCID string `json:"iccid"` + IMSI string `json:"imsi"` + NetModel string `json:"netModel"` + AccessType string `json:"accessType"` + SessionStatus string `json:"sessionStatus"` + StartTime string `json:"startTime"` + CreateTime string `json:"createTime"` + EndTime string `json:"endTime"` + IPv4Address string `json:"ipv4Address"` + IPv4 string `json:"ipv4"` + IPv6Address string `json:"ipv6Address"` + IPv6 string `json:"ipv6"` + IPv6Prefix string `json:"ipv6Prefix"` + Apn string `json:"apn"` + Imei string `json:"imei"` + Province string `json:"province"` + } + + decodeSessionData := func(raw json.RawMessage) (*dxSessionItem, error) { + rawText := strings.TrimSpace(string(raw)) + if rawText == "" || rawText == "null" { + return nil, nil + } + if strings.HasPrefix(rawText, "[") { + var list []dxSessionItem + if err := json.Unmarshal([]byte(rawText), &list); err != nil { + return nil, err + } + if len(list) == 0 { + return nil, nil + } + return &list[0], nil + } + var item dxSessionItem + if err := json.Unmarshal([]byte(rawText), &item); err != nil { + return nil, err + } + return &item, nil + } + + items := make(model.SimSessionOutput, 0, len(input.SimKeys)) + for _, simKey := range input.SimKeys { + accessNumber := firstNonEmpty(simKey.MSISDN, simKey.ICCID, simKey.IMSI) + if accessNumber == "" { + items = append(items, &model.SimBaseRes[model.SimSessionItem]{ + SimKey: simKey, + Code: consts.ApiStatusError, + Message: "missing access number", + ResData: model.SimSessionItem{Status: "2"}, + }) + continue + } + + var ( + sessionItem *dxSessionItem + respCode = consts.ApiStatusError + respMsg string + lastErr error + ) + + attempts := []struct { + endpoint string + paramName string + }{ + {endpoint: "/api/v1/onlineSessionOpen/qryOnlineSataus", paramName: "accNum"}, + {endpoint: "/api/v1/onlineSessionOpen/qryOnlineStatus", paramName: "accNum"}, + {endpoint: "/api/v1/onlineSessionOpen/qryOnlineSataus", paramName: "access_number"}, + {endpoint: "/api/v1/onlineSessionOpen/qryOnlineStatus", paramName: "access_number"}, + {endpoint: "/api/v1/onlineSessionOpen/qryOnlineSataus", paramName: "iccid"}, + {endpoint: "/api/v1/onlineSessionOpen/qryOnlineSataus", paramName: "imsi"}, + } + + for _, attempt := range attempts { + paramValue := accessNumber + switch attempt.paramName { + case "iccid": + paramValue = firstNonEmpty(simKey.ICCID, accessNumber) + case "imsi": + paramValue = firstNonEmpty(simKey.IMSI, accessNumber) + } + respBody, err := s.client.DoGetRequest(ctx, attempt.endpoint, url.Values{ + attempt.paramName: []string{paramValue}, + }) + if err != nil { + lastErr = err + continue + } + g.Log().Debugf(ctx, "QuerySimSession response endpoint=%s body=%s", attempt.endpoint, string(respBody)) + + var dxResp dxSessionResp + if err := json.Unmarshal(respBody, &dxResp); err != nil { + lastErr = err + continue + } + + codeStr := firstNonEmpty(gconv.String(dxResp.Code), gconv.String(dxResp.ResultCode)) + if codeStr == "" { + codeStr = "0" + } + respCode = gconv.Int(codeStr) + respMsg = firstNonEmpty(dxResp.Msg, dxResp.ResultMsg, dxResp.Message) + if respCode != 0 { + continue + } + + item, err := decodeSessionData(dxResp.Data) + if err != nil { + lastErr = err + continue + } + if item == nil { + sessionItem = &dxSessionItem{OnlineStatus: "2"} + lastErr = nil + break + } + sessionItem = item + lastErr = nil + break + } + + if sessionItem == nil { + message := respMsg + if lastErr != nil { + message = lastErr.Error() + } + if message == "" { + message = "telecom session query failed" + } + items = append(items, &model.SimBaseRes[model.SimSessionItem]{ + SimKey: simKey, + Code: respCode, + Message: message, + ResData: model.SimSessionItem{Status: "2"}, + }) + continue + } + + sessionStatus := firstNonEmpty(sessionItem.OnlineStatus, sessionItem.SessionStatus) + if sessionStatus == "" { + sessionStatus = "2" + } + items = append(items, &model.SimBaseRes[model.SimSessionItem]{ + SimKey: simKey, + Code: 0, + Message: "", + ResData: model.SimSessionItem{ + Status: convertDxSessionStatus(ctx, sessionStatus), + Rat: dxSessionAccessMode(sessionItem.NetModel, sessionItem.AccessType), + ApnId: sessionItem.Apn, + Imei: sessionItem.Imei, + CreateTime: firstNonEmpty(sessionItem.StartTime, sessionItem.CreateTime), + Ipv4: firstNonEmpty(sessionItem.IPv4Address, sessionItem.IPv4), + Ipv6: firstNonEmpty(sessionItem.IPv6Address, sessionItem.IPv6), + Ipv6Prefix: strings.TrimSpace(sessionItem.IPv6Prefix), + }, + }) + } + + return items, nil +} + +// QuerySimCardStatus 物联网卡卡状态查询 +// CTIOT_5GCMP_LC003 - 卡主状态查询 +// - URL: /openapi/v1/prodinst/queryCardMainStatus +// - 支持参数: MSISDN(接入号码) +func (s *sSimDX) QuerySimCardStatus(ctx context.Context, input *model.SimCardStatusInput) (model.SimCardStatusOutput, error) { + type ProductInfo struct { + ProductMainStatusCd string `json:"productMainStatusCd"` // 产品主状态码 + ProductMainStatusName string `json:"productMainStatusName"` // 产品主状态名称 + OperatorDefinitionStatusName string `json:"operatorDefinitionStatusName"` // 运营商定义状态名称 + } + + type DxResp struct { + Result string `json:"result"` // 状态标识 + ResultCode string `json:"resultCode"` // 状态码 + ResultMsg string `json:"resultMsg"` // 标识说明 + NetBlockStatusName string `json:"netBlockStatusName"` + NetBlockTypeList []model.SimNetBlockType `json:"netBlockTypeList"` + Number string `json:"number"` // 查询号码 + ServCreateDate string `json:"servCreateDate"` // 卡生效时间 + ProductInfo []ProductInfo `json:"productInfo"` // 产品信息数组 + } + + items := make(model.SimCardStatusOutput, 0) + for _, simKey := range input.SimKeys { + accessNumber := simKey.MSISDN + + respBody, err := s.client.DoGetRequest(ctx, "/openapi/v1/prodinst/queryCardMainStatus", url.Values{"access_number": []string{accessNumber}}) + if err != nil { + g.Log().Errorf(ctx, "QuerySimCardStatus error %+v, accessNumber %s", err, accessNumber) + var statusErr *dx.HTTPStatusError + errCode := "" + if errors.As(err, &statusErr) { + errCode = statusErr.RespCode + } + items = append(items, &model.SimBaseRes[model.SimCardStatusItem]{ + SimKey: simKey, + Code: consts.ApiStatusError, + Message: err.Error(), + ErrCode: errCode, + }) + continue + } + + g.Log().Debugf(ctx, "QuerySimCardStatus response: %s", string(respBody)) + + var dxResp DxResp + err = json.Unmarshal(respBody, &dxResp) + if err != nil { + g.Log().Errorf(ctx, "QuerySimCardStatus unmarshal error %+v, body %s", err, string(respBody)) + items = append(items, &model.SimBaseRes[model.SimCardStatusItem]{ + SimKey: simKey, + Code: consts.ApiStatusError, + Message: err.Error(), + }) + continue + } + + // result = "0" 表示API调用成功 + if dxResp.Result != "0" { + g.Log().Warningf(ctx, "QuerySimCardStatus upstream error %s, %s", dxResp.Result, dxResp.ResultMsg) + carrierCode := strings.TrimSpace(dxResp.Result) + code := gconv.Int(carrierCode) + if code == 0 { + code = consts.ApiStatusError + } + items = append(items, &model.SimBaseRes[model.SimCardStatusItem]{ + SimKey: simKey, + Code: code, + Message: dxResp.ResultMsg, + ErrCode: carrierCode, + }) + continue + } + + // 从 productInfo 数组获取状态码 + if len(dxResp.ProductInfo) == 0 { + g.Log().Warningf(ctx, "QuerySimCardStatus productInfo is empty, accessNumber %s", accessNumber) + items = append(items, &model.SimBaseRes[model.SimCardStatusItem]{ + SimKey: simKey, + Code: consts.ApiStatusError, + Message: "productInfo is empty", + }) + continue + } + + // 转换卡状态码 + cardStatus := convertDxCardStatus(ctx, dxResp.ProductInfo[0].ProductMainStatusCd) + + items = append(items, &model.SimBaseRes[model.SimCardStatusItem]{ + SimKey: simKey, + Code: gconv.Int(dxResp.Result), + Message: dxResp.ResultMsg, + ResData: model.SimCardStatusItem{ + CardStatus: cardStatus, + NetBlockStatus: dxNetworkBlockStatus(dxResp.NetBlockStatusName, dxResp.NetBlockTypeList), + NetBlockTypes: dxResp.NetBlockTypeList, + LastChangeDate: dxResp.ServCreateDate, // 卡生效时间 + }, + }) + } + + return items, nil +} + +// QuerySimImei 物联网卡IMEI查询 +// CTIOT_5GCMP_NQ003-设备识别码查询 +// - URL: /openapi/v1/prodinst/gete2einfo +// - 支持参数: iccid / imsi / MSISDN(接入号码) +func (s *sSimDX) QuerySimImei(ctx context.Context, input *model.SimImeiInput) (model.SimImeiOutput, error) { + type DxResp struct { + Result string `json:"result"` // 结果 + ResultMsg string `json:"resultMsg"` // 消息 + Message string `json:"MESSAGE"` // 电信NQ003响应消息 + Imei string `json:"imei"` // IMEI + } + + items := make(model.SimImeiOutput, 0) + for _, simKey := range input.SimKeys { + + iccid := simKey.ICCID + + respBody, err := s.client.DoGetRequest(ctx, "/openapi/v1/prodinst/gete2einfo", url.Values{"iccid": []string{iccid}}) + if err != nil { + g.Log().Errorf(ctx, "QuerySimImei error %+v, iccid %s", err, iccid) + items = append(items, &model.SimBaseRes[model.SimImeiItem]{ + SimKey: simKey, + Code: consts.ApiStatusError, + Message: err.Error(), + }) + continue + } + + g.Log().Debugf(ctx, "QuerySimImei response: %s", string(respBody)) + + var dxResp DxResp + err = json.Unmarshal(respBody, &dxResp) + if err != nil { + g.Log().Errorf(ctx, "QuerySimImei unmarshal error %+v, body %s", err, string(respBody)) + items = append(items, &model.SimBaseRes[model.SimImeiItem]{ + SimKey: simKey, + Code: consts.ApiStatusError, + Message: err.Error(), + }) + continue + } + + if dxResp.Result != "0" { + message := firstNonEmpty(dxResp.Message, dxResp.ResultMsg) + code := gconv.Int(dxResp.Result) + if code == 0 { + code = consts.ApiStatusError + } + g.Log().Warningf(ctx, "QuerySimImei upstream error %s, %s", dxResp.Result, message) + items = append(items, &model.SimBaseRes[model.SimImeiItem]{ + SimKey: simKey, + Code: code, + Message: message, + }) + continue + } + + items = append(items, &model.SimBaseRes[model.SimImeiItem]{ + SimKey: simKey, + Code: gconv.Int(dxResp.Result), + Message: firstNonEmpty(dxResp.Message, dxResp.ResultMsg), + ResData: model.SimImeiItem{ + Imei: dxResp.Imei, + }, + }) + } + + return items, nil +} + +// QuerySimDataUsage 单卡本月流量累计使用量查询 +// CTIOT_5GCMP_UQ001-流量查询(当月) +// - URL: /api/v1/openbill/queryTraffic +// - 支持参数: iccid / MSISDN(接入号码) +func (s *sSimDX) QuerySimDataUsage(ctx context.Context, input *model.SimDataUsageInput) (model.SimDataUsageOutput, error) { + // 响应结构 (实际返回XML格式): + // + // + // 0 + // 14.01MB + // ... + // + // + type DxResp struct { + XMLName xml.Name `xml:"root"` + Data *struct { + Result string `xml:"IRESULT"` // 结果 0=成功 + TotalBytesCnt string `xml:"TOTAL_BYTES_CNT"` // 流量 + } `xml:"NEW_DATA_TICKET_QRsp"` + } + + items := make(model.SimDataUsageOutput, 0) + for _, simKey := range input.SimKeys { + if simKey.MSISDN == "" { + g.Log().Warningf(ctx, "Msisdn not found for simKey %s", simKey.ChanKey) + items = append(items, &model.SimBaseRes[model.SimDataUsageItem]{ + SimKey: simKey, + Code: consts.ApiStatusError, + Message: "MSISDN not found", + }) + continue + } + + accessNumber := simKey.MSISDN + + respBody, err := s.client.DoGetRequest(ctx, "/api/v1/openbill/queryTraffic", url.Values{"access_number": []string{accessNumber}}) + if err != nil { + g.Log().Errorf(ctx, "QuerySimDataUsage request failed, chanKey:%s, err:%+v", simKey.ChanKey, err) + items = append(items, &model.SimBaseRes[model.SimDataUsageItem]{ + SimKey: simKey, + Code: consts.ApiStatusError, + Message: err.Error(), + }) + continue + } + + // 去除XML命名空间前缀 + xmlBody := xmlNamespaceRegex.ReplaceAllString(string(respBody), "") + + var dxResp DxResp + err = xml.Unmarshal([]byte(xmlBody), &dxResp) + if err != nil { + g.Log().Errorf(ctx, "QuerySimDataUsage response parse failed, chanKey:%s, err:%+v", simKey.ChanKey, err) + items = append(items, &model.SimBaseRes[model.SimDataUsageItem]{ + SimKey: simKey, + Code: consts.ApiStatusError, + Message: err.Error(), + }) + continue + } + + if dxResp.Data == nil { + items = append(items, &model.SimBaseRes[model.SimDataUsageItem]{SimKey: simKey, Code: consts.ApiStatusError, Message: "电信响应缺少 NEW_DATA_TICKET_QRsp"}) + continue + } + resultCode, resultErr := parseDXResultCode(dxResp.Data.Result) + if resultErr != nil { + items = append(items, &model.SimBaseRes[model.SimDataUsageItem]{SimKey: simKey, Code: consts.ApiStatusError, Message: resultErr.Error(), ErrCode: dxResp.Data.Result}) + continue + } + if resultCode != 0 { + g.Log().Warningf(ctx, "QuerySimDataUsage upstream error, chanKey:%s, code:%s", simKey.ChanKey, dxResp.Data.Result) + items = append(items, &model.SimBaseRes[model.SimDataUsageItem]{ + SimKey: simKey, + Code: resultCode, + Message: fmt.Sprintf("流量查询失败: %s", dxResp.Data.Result), + ErrCode: dxResp.Data.Result, + }) + continue + } + dataUsage, amountErr := parseDXDataUsage(dxResp.Data.TotalBytesCnt) + if amountErr != nil { + items = append(items, &model.SimBaseRes[model.SimDataUsageItem]{SimKey: simKey, Code: consts.ApiStatusError, Message: amountErr.Error()}) + continue + } + + items = append(items, &model.SimBaseRes[model.SimDataUsageItem]{ + SimKey: simKey, + Code: resultCode, + //Message: "流量查询成功", + ResData: model.SimDataUsageItem{ + DataUsage: dataUsage, + }, + }) + } + return items, nil +} + +// parseDXDataUsage 解析流量值,格式转换为MB +func parseDXDataUsage(value string) (float64, error) { + value = strings.TrimSpace(value) + if value == "" { + return 0, errors.New("电信流量响应缺少 TOTAL_BYTES_CNT") + } + // 检查是否包含单位 + value = strings.ToUpper(strings.TrimSpace(value)) + if strings.HasSuffix(value, "MB") { + value = strings.TrimSpace(strings.TrimSuffix(value, "MB")) + mbVal, err := strconv.ParseFloat(value, 64) + if err != nil || math.IsNaN(mbVal) || math.IsInf(mbVal, 0) || mbVal < 0 { + return 0, fmt.Errorf("电信流量值无效: %s", value) + } + return mbVal, nil + } + if strings.HasSuffix(value, "GB") { + value = strings.TrimSpace(strings.TrimSuffix(value, "GB")) + gbVal, err := strconv.ParseFloat(value, 64) + if err != nil || math.IsNaN(gbVal) || math.IsInf(gbVal, 0) || gbVal < 0 { + return 0, fmt.Errorf("电信流量值无效: %s", value) + } + amount := gbVal * 1024 + if math.IsInf(amount, 0) { + return 0, fmt.Errorf("invalid telecom usage after GB conversion: %s", value) + } + return amount, nil // 转换为MB + } + if strings.HasSuffix(value, "KB") { + value = strings.TrimSpace(strings.TrimSuffix(value, "KB")) + kbVal, err := strconv.ParseFloat(value, 64) + if err != nil || math.IsNaN(kbVal) || math.IsInf(kbVal, 0) || kbVal < 0 { + return 0, fmt.Errorf("电信流量值无效: %s", value) + } + return kbVal / 1024, nil // 转换为MB + } + // 如果是纯数字,直接返回 + amount, err := strconv.ParseFloat(value, 64) + if err != nil || math.IsNaN(amount) || math.IsInf(amount, 0) || amount < 0 { + return 0, fmt.Errorf("电信流量值无效: %s", value) + } + return amount, nil +} + +func parseDataUsage(value string) float64 { + if value == "" { + return 0 + } + // 检查是否包含单位 + value = strings.ToUpper(strings.TrimSpace(value)) + if strings.HasSuffix(value, "MB") { + mb := strings.TrimSuffix(value, "MB") + mbVal := gconv.Float64(mb) + return mbVal + } + if strings.HasSuffix(value, "GB") { + gb := strings.TrimSuffix(value, "GB") + gbVal := gconv.Float64(gb) + return gbVal * 1024 // 转换为MB + } + if strings.HasSuffix(value, "KB") { + kb := strings.TrimSuffix(value, "KB") + kbVal := gconv.Float64(kb) + return kbVal / 1024 // 转换为MB + } + // 如果是纯数字,直接返回 + return gconv.Float64(value) +} + +func parseDXResultCode(value string) (int, error) { + value = strings.TrimSpace(value) + if value == "" { + return 0, errors.New("电信响应缺少 IRESULT") + } + code, err := strconv.Atoi(value) + if err != nil { + return 0, fmt.Errorf("电信 IRESULT 无效: %s", value) + } + return code, nil +} + +// QuerySimDataUsageMonthly 单卡单月GPRS流量使用量批量查询 +func (s *sSimDX) QuerySimDataUsageMonthly(ctx context.Context, input *model.SimDataUsageMonthlyInput) (model.SimDataUsageMonthlyOutput, error) { + if input == nil || len(input.SimKeys) == 0 { + return make(model.SimDataUsageMonthlyOutput, 0), nil + } + if strings.TrimSpace(s.custNumber) != "" && dxBatchMonthAvailable(input.ReqData.QueryDate, time.Now()) { + return s.queryDataUsageMonthlyBatch(ctx, input) + } + if len(input.SimKeys) > 1 { + return s.querySimDataUsageMonthlyConcurrent(ctx, input), nil + } + // XML响应结构 (正常情况) + type DxRespXML struct { + XMLName xml.Name `xml:"root"` + Data *struct { + Result string `xml:"IRESULT"` // 结果 + SMsg string `xml:"SMSG"` // 消息 + Total string `xml:"TOTAL_BYTES_CNT"` // 流量 + } `xml:"NEW_DATA_TICKET_QRsp"` + } + + // JSON错误响应结构 (异常情况) + type DxRespJSON struct { + Result string `json:"result"` + Msg string `json:"msg"` + } + + items := make(model.SimDataUsageMonthlyOutput, 0) + for _, simKey := range input.SimKeys { + + accessNumber := simKey.MSISDN + + // 计算月末日期 (API要求YYYYMMDD格式) + startDate := input.ReqData.QueryDate + "01" // 例如: 20260301 + t, err := time.Parse("20060102", startDate) + if err != nil { + g.Log().Errorf(ctx, "QuerySimDataUsageMonthly parse date error %+v, queryDate %s", err, input.ReqData.QueryDate) + items = append(items, &model.SimBaseRes[model.SimDataUsageMonthlyOutputItem]{ + SimKey: simKey, + Code: consts.ApiStatusError, + Message: "日期格式错误", + }) + continue + } + endDate := t.AddDate(0, 1, -1).Format("20060102") // 例如: 20260331 + + respBody, err := s.client.DoGetRequest(ctx, "/api/v1/openbill/queryTrafficByDate", url.Values{ + "access_number": []string{accessNumber}, + "startDate": []string{startDate}, + "endDate": []string{endDate}, + "needDtl": []string{"0"}, + }) + if err != nil { + g.Log().Errorf(ctx, "QuerySimDataUsageMonthly error %+v, accessNumber %s", err, accessNumber) + items = append(items, &model.SimBaseRes[model.SimDataUsageMonthlyOutputItem]{ + SimKey: simKey, + Code: consts.ApiStatusError, + Message: err.Error(), + }) + continue + } + + g.Log().Debugf(ctx, "QuerySimDataUsageMonthly response: %s", string(respBody)) + + // 去除XML命名空间前缀 + xmlBody := xmlNamespaceRegex.ReplaceAllString(string(respBody), "") + + var dxResp DxRespXML + if err = xml.Unmarshal([]byte(xmlBody), &dxResp); err != nil { + g.Log().Errorf(ctx, "QuerySimDataUsageMonthly unmarshal error %+v, body %s", err, xmlBody) + items = append(items, &model.SimBaseRes[model.SimDataUsageMonthlyOutputItem]{ + SimKey: simKey, + Code: consts.ApiStatusError, + Message: err.Error(), + }) + continue + } + if dxResp.Data == nil { + items = append(items, &model.SimBaseRes[model.SimDataUsageMonthlyOutputItem]{SimKey: simKey, Code: consts.ApiStatusError, Message: "电信响应缺少 NEW_DATA_TICKET_QRsp"}) + continue + } + + resultCode, resultErr := parseDXResultCode(dxResp.Data.Result) + if resultErr != nil { + items = append(items, &model.SimBaseRes[model.SimDataUsageMonthlyOutputItem]{SimKey: simKey, Code: consts.ApiStatusError, Message: resultErr.Error(), ErrCode: dxResp.Data.Result}) + continue + } + if resultCode != 0 { + g.Log().Warningf(ctx, "QuerySimDataUsageMonthly upstream error %s, %s", dxResp.Data.Result, dxResp.Data.Total) + items = append(items, &model.SimBaseRes[model.SimDataUsageMonthlyOutputItem]{ + SimKey: simKey, + Code: resultCode, + Message: dxResp.Data.SMsg, + }) + continue + } + dataUsage, amountErr := parseDXDataUsage(dxResp.Data.Total) + if amountErr != nil { + items = append(items, &model.SimBaseRes[model.SimDataUsageMonthlyOutputItem]{SimKey: simKey, Code: consts.ApiStatusError, Message: amountErr.Error()}) + continue + } + + items = append(items, &model.SimBaseRes[model.SimDataUsageMonthlyOutputItem]{ + SimKey: simKey, + Code: gconv.Int(dxResp.Data.Result), + //Message: "流量查询成功", + ResData: model.SimDataUsageMonthlyOutputItem{ + DeadLine: input.ReqData.QueryDate, + DataAmount: dataUsage, + }, + }) + } + return items, nil +} + +// QuerySimDataUsageDaily 单卡单日GPRS流量使用量查询 +// CTIOT_5GCMP_UQ001-流量查询(按日期) +// - URL: /api/v1/openbill/queryTrafficByDate +// - startDate/endDate 均使用 queryDate +// - TOTAL_BYTES_CNT 格式为 *.**MB,parseDataUsage 统一转换为 MB +func (s *sSimDX) QuerySimDataUsageDaily(ctx context.Context, input *model.SimDataUsageDailyInput) (model.SimDataUsageDailyOutput, error) { + if input == nil || len(input.SimKeys) == 0 { + return make(model.SimDataUsageDailyOutput, 0), nil + } + if strings.TrimSpace(s.custNumber) != "" && dxBatchDayAvailable(input.ReqData.QueryDate, time.Now()) { + batchOutput, batchErr := s.queryDataUsageDailyBatch(ctx, input) + if batchErr == nil && dailyBatchOutputSuccessful(input, batchOutput) { + return batchOutput, nil + } + g.Log().Warningf(ctx, "QuerySimDataUsageDaily batch query failed, fallback legacy, err:%v", batchErr) + } + if len(input.SimKeys) > 1 { + return s.querySimDataUsageDailyConcurrent(ctx, input), nil + } + return s.querySimDataUsageDailyLegacy(ctx, input), nil +} + +func (s *sSimDX) querySimDataUsageDailyLegacy(ctx context.Context, input *model.SimDataUsageDailyInput) model.SimDataUsageDailyOutput { + type DxRespXML struct { + XMLName xml.Name `xml:"root"` + Data *struct { + Result string `xml:"IRESULT"` + SMsg string `xml:"SMSG"` + Total string `xml:"TOTAL_BYTES_CNT"` + } `xml:"NEW_DATA_TICKET_QRsp"` + } + + items := make(model.SimDataUsageDailyOutput, 0) + for _, simKey := range input.SimKeys { + accessNumber := simKey.MSISDN + queryDate := input.ReqData.QueryDate + if _, err := time.Parse("20060102", queryDate); err != nil { + g.Log().Errorf(ctx, "QuerySimDataUsageDaily parse date error %+v, queryDate %s", err, queryDate) + items = append(items, &model.SimBaseRes[model.SimDataUsageDailyOutputItem]{ + SimKey: simKey, + Code: consts.ApiStatusError, + Message: "日期格式错误", + }) + continue + } + + respBody, err := s.client.DoGetRequest(ctx, "/api/v1/openbill/queryTrafficByDate", url.Values{ + "access_number": []string{accessNumber}, + "startDate": []string{queryDate}, + "endDate": []string{queryDate}, + "needDtl": []string{"0"}, + }) + if err != nil { + g.Log().Errorf(ctx, "QuerySimDataUsageDaily error %+v, accessNumber %s", err, accessNumber) + items = append(items, &model.SimBaseRes[model.SimDataUsageDailyOutputItem]{ + SimKey: simKey, + Code: consts.ApiStatusError, + Message: err.Error(), + }) + continue + } + + g.Log().Debugf(ctx, "QuerySimDataUsageDaily response: %s", string(respBody)) + + xmlBody := xmlNamespaceRegex.ReplaceAllString(string(respBody), "") + + var dxResp DxRespXML + if err = xml.Unmarshal([]byte(xmlBody), &dxResp); err != nil { + g.Log().Errorf(ctx, "QuerySimDataUsageDaily unmarshal error %+v, body %s", err, xmlBody) + items = append(items, &model.SimBaseRes[model.SimDataUsageDailyOutputItem]{ + SimKey: simKey, + Code: consts.ApiStatusError, + Message: err.Error(), + }) + continue + } + + if dxResp.Data == nil { + items = append(items, &model.SimBaseRes[model.SimDataUsageDailyOutputItem]{SimKey: simKey, Code: consts.ApiStatusError, Message: "电信响应缺少 NEW_DATA_TICKET_QRsp"}) + continue + } + resultCode, resultErr := parseDXResultCode(dxResp.Data.Result) + if resultErr != nil { + items = append(items, &model.SimBaseRes[model.SimDataUsageDailyOutputItem]{SimKey: simKey, Code: consts.ApiStatusError, Message: resultErr.Error(), ErrCode: dxResp.Data.Result}) + continue + } + if resultCode != 0 { + g.Log().Warningf(ctx, "QuerySimDataUsageDaily upstream error %s, %s", dxResp.Data.Result, dxResp.Data.Total) + items = append(items, &model.SimBaseRes[model.SimDataUsageDailyOutputItem]{ + SimKey: simKey, + Code: resultCode, + Message: dxResp.Data.SMsg, + ResData: model.SimDataUsageDailyOutputItem{ + QueryDate: queryDate, + DataAmount: 0, + }, + }) + continue + } + dataUsageMB, amountErr := parseDXDataUsage(dxResp.Data.Total) + if amountErr != nil { + items = append(items, &model.SimBaseRes[model.SimDataUsageDailyOutputItem]{SimKey: simKey, Code: consts.ApiStatusError, Message: amountErr.Error(), ResData: model.SimDataUsageDailyOutputItem{QueryDate: queryDate}}) + continue + } + + items = append(items, &model.SimBaseRes[model.SimDataUsageDailyOutputItem]{ + SimKey: simKey, + Code: resultCode, + ResData: model.SimDataUsageDailyOutputItem{ + QueryDate: queryDate, + DataAmount: dataUsageMB, + }, + }) + } + return items +} + +func (s *sSimDX) querySimDataUsageMonthlyConcurrent(ctx context.Context, input *model.SimDataUsageMonthlyInput) model.SimDataUsageMonthlyOutput { + results := make(model.SimDataUsageMonthlyOutput, len(input.SimKeys)) + jobs := make(chan int) + var group sync.WaitGroup + workers := min(dxLegacyUsageConcurrency, len(input.SimKeys)) + for worker := 0; worker < workers; worker++ { + group.Add(1) + go func() { + defer group.Done() + for index := range jobs { + simKey := input.SimKeys[index] + if err := ctx.Err(); err != nil { + results[index] = dxMonthlyContextFailure(simKey, err) + continue + } + output, err := s.QuerySimDataUsageMonthly(ctx, &model.SimDataUsageMonthlyInput{ + SimKeys: []*model.SimKey{simKey}, + ReqData: input.ReqData, + }) + if err != nil || len(output) == 0 { + results[index] = dxMonthlyContextFailure(simKey, err) + continue + } + results[index] = output[0] + } + }() + } + for index := range input.SimKeys { + jobs <- index + } + close(jobs) + group.Wait() + return results +} + +func (s *sSimDX) querySimDataUsageDailyConcurrent(ctx context.Context, input *model.SimDataUsageDailyInput) model.SimDataUsageDailyOutput { + results := make(model.SimDataUsageDailyOutput, len(input.SimKeys)) + jobs := make(chan int) + var group sync.WaitGroup + workers := min(dxLegacyUsageConcurrency, len(input.SimKeys)) + for worker := 0; worker < workers; worker++ { + group.Add(1) + go func() { + defer group.Done() + for index := range jobs { + simKey := input.SimKeys[index] + if err := ctx.Err(); err != nil { + results[index] = dxDailyContextFailure(simKey, err) + continue + } + output := s.querySimDataUsageDailyLegacy(ctx, &model.SimDataUsageDailyInput{ + SimKeys: []*model.SimKey{simKey}, + ReqData: input.ReqData, + }) + if len(output) == 0 { + results[index] = dxDailyContextFailure(simKey, nil) + continue + } + results[index] = output[0] + } + }() + } + for index := range input.SimKeys { + jobs <- index + } + close(jobs) + group.Wait() + return results +} + +func dxMonthlyContextFailure(simKey *model.SimKey, err error) *model.SimBaseRes[model.SimDataUsageMonthlyOutputItem] { + message := "telecom monthly usage query returned no result" + if err != nil { + message = err.Error() + } + return &model.SimBaseRes[model.SimDataUsageMonthlyOutputItem]{SimKey: simKey, Code: consts.ApiStatusError, Message: message} +} + +func dxDailyContextFailure(simKey *model.SimKey, err error) *model.SimBaseRes[model.SimDataUsageDailyOutputItem] { + message := "telecom daily usage query returned no result" + if err != nil { + message = err.Error() + } + return &model.SimBaseRes[model.SimDataUsageDailyOutputItem]{SimKey: simKey, Code: consts.ApiStatusError, Message: message} +} + +func simDXBasicInfoQueryParamName(simKey *model.SimKey) string { + if strings.TrimSpace(simKey.ICCID) != "" { + return "iccid" + } + return "access_number" +} + +func simDXBasicInfoQueryParamValue(simKey *model.SimKey) string { + if iccid := strings.TrimSpace(simKey.ICCID); iccid != "" { + return iccid + } + return strings.TrimSpace(simKey.MSISDN) +} + +func simDXBasicInfoQueryValues(simKey *model.SimKey) url.Values { + return url.Values{ + "pageIndex": []string{"1"}, + simDXBasicInfoQueryParamName(simKey): []string{simDXBasicInfoQueryParamValue(simKey)}, + } +} + +// QuerySimBasicInfo 单卡基本信息查询 +// CTIOT_5GCMP_BQ004 - SIM卡列表查询 +// - URL: /openapi/v1/prodinst/getSIMList +// - 支持参数: access_number / iccid / pageIndex(必填) +func (s *sSimDX) QuerySimBasicInfo(ctx context.Context, input *model.SimBasicInfoInput) (model.SimBasicInfoOutput, error) { + if output, ok, err := s.querySimBasicInfoBatch(ctx, input); ok { + if err == nil { + return output, nil + } + g.Log().Warningf(ctx, "QuerySimBasicInfo batch query failed, fallback legacy, err:%+v", err) + } + return s.querySimBasicInfoLegacy(ctx, input) +} + +func (s *sSimDX) querySimBasicInfoLegacy(ctx context.Context, input *model.SimBasicInfoInput) (model.SimBasicInfoOutput, error) { + type DxResp struct { + ResultCode string `json:"resultCode"` // 返回状态标识 0成功, 1失败, -3 sign错误, -2参数错误, -1无权限 + ResultMsg string `json:"resultMsg"` // 返回标识说明 + GroupTransactionId string `json:"groupTransactionId"` // 流水号 + Description struct { + PageIndex string `json:"pageIndex"` // 页码 + SimList []struct { + AccNumber string `json:"accNumber"` // 接入号码 + LastChangeDate string `json:"lastChangeDate"` // 最后变更时间 + StatusCd string `json:"statusCd"` // 运营商定义状态 + StopTypeList []string `json:"stopTypeList"` // 停机类型 + CustId string `json:"custId"` // 客户ID + SimStatus []string `json:"simStatus"` // SIM卡状态 + Imsi string `json:"imsi"` // IMSI + Iccid string `json:"iccid"` // ICCID + ActivationTime string `json:"activationTime"` // 激活时间 + CreateTime string `json:"createTime"` // 开卡时间 + MainOfferName string `json:"mainOfferName"` // 主套餐名称 + CardBindingType string `json:"cardBindingType"` // 机卡绑定类型 + IsDirectVisit string `json:"isDirectVisit"` // 是否乐通定向 + IsHumanLimit string `json:"isHumanLimit"` // 是否有人联网限制 + VpdnGreDoamin string `json:"vpdnGreDoamin"` // VPDN(GRE)域名 + VpdnL2tpDoamin string `json:"vpdnL2tpDoamin"` // VPDN(L2TP)域名 + AreaLimitType string `json:"areaLimitType"` // 区域限制类型 + AreaLimitInfo string `json:"areaLimitInfo"` // 区域限制信息 + } `json:"simList"` // SIM卡列表 + } `json:"description"` + } + + items := make(model.SimBasicInfoOutput, 0) + for _, simKey := range input.SimKeys { + + respBody, err := s.client.DoGetRequest(ctx, "/openapi/v1/prodinst/getSIMList", simDXBasicInfoQueryValues(simKey)) + if err != nil { + g.Log().Errorf(ctx, "QuerySimBasicInfo error %+v, simKey %s", err, simKey.ChanKey) + items = append(items, &model.SimBaseRes[model.SimBasicInfoItem]{ + SimKey: simKey, + Code: consts.ApiStatusError, + Message: err.Error(), + }) + continue + } + + g.Log().Debugf(ctx, "QuerySimBasicInfo response: %s", string(respBody)) + + var dxResp DxResp + err = json.Unmarshal(respBody, &dxResp) + if err != nil { + g.Log().Errorf(ctx, "QuerySimBasicInfo unmarshal error %+v, body %s", err, string(respBody)) + items = append(items, &model.SimBaseRes[model.SimBasicInfoItem]{ + SimKey: simKey, + Code: consts.ApiStatusError, + Message: err.Error(), + }) + continue + } + + // resultCode = "0" 表示成功 + if dxResp.ResultCode != "0" { + g.Log().Warningf(ctx, "QuerySimBasicInfo upstream error %s, %s", dxResp.ResultCode, dxResp.ResultMsg) + items = append(items, &model.SimBaseRes[model.SimBasicInfoItem]{ + SimKey: simKey, + Code: gconv.Int(dxResp.ResultCode), + Message: dxResp.ResultMsg, + }) + continue + } + + // 从返回列表中查找匹配的SIM卡 + var matchedIdx = -1 + for i := range dxResp.Description.SimList { + sim := &dxResp.Description.SimList[i] + // 根据请求参数匹配对应的SIM卡 + if (simKey.MSISDN != "" && sim.AccNumber == simKey.MSISDN) || + (simKey.ICCID != "" && sim.Iccid == simKey.ICCID) { + matchedIdx = i + break + } + } + + if matchedIdx == -1 { + items = append(items, &model.SimBaseRes[model.SimBasicInfoItem]{ + SimKey: simKey, + Code: consts.ApiStatusError, + Message: "未查询到对应SIM卡信息", + }) + continue + } + + matchedSim := &dxResp.Description.SimList[matchedIdx] + + items = append(items, &model.SimBaseRes[model.SimBasicInfoItem]{ + SimKey: simKey, + Code: gconv.Int(dxResp.ResultCode), + Message: dxResp.ResultMsg, + ResData: model.SimBasicInfoItem{ + ICCID: matchedSim.Iccid, + IMSI: matchedSim.Imsi, + MSISDN: matchedSim.AccNumber, + OpenDate: matchedSim.CreateTime, + ActiveDate: matchedSim.ActivationTime, + SimStatus: mapDXLegacyBasicStatus(ctx, firstNonEmpty(matchedSim.StatusCd, strings.Join(matchedSim.SimStatus, ","))), + StopTypeList: matchedSim.StopTypeList, + MainOfferName: matchedSim.MainOfferName, + CardBindingType: matchedSim.CardBindingType, + IsDirectVisit: matchedSim.IsDirectVisit, + IsHumanLimit: matchedSim.IsHumanLimit, + VpdnGreDoamin: matchedSim.VpdnGreDoamin, + VpdnL2tpDoamin: matchedSim.VpdnL2tpDoamin, + AreaLimitType: matchedSim.AreaLimitType, + AreaLimitInfo: matchedSim.AreaLimitInfo, + }, + }) + } + return items, nil +} + +type dxBatchInfoResponse struct { + Code any `json:"code"` + Message string `json:"message"` + Msg string `json:"msg"` + Data struct { + QrySimInfoList []dxBatchInfoItem `json:"qrySimInfoList"` + SimInfoList []dxBatchInfoItem `json:"simInfoList"` + } `json:"data"` +} + +type dxBatchInfoItem struct { + AccessNumber string `json:"accessNumber"` + AccNum string `json:"accNum"` + Result any `json:"result"` + ICCID string `json:"iccid"` + IMSI string `json:"imsi"` + ActiveTime string `json:"activeTime"` + ActivationTime string `json:"activationTime"` + CreateTime string `json:"createTime"` + SimStatus any `json:"simStatus"` + StopTypeList any `json:"stopTypeList"` + MainOfferName string `json:"mainOfferName"` + CardBindingType string `json:"cardBindingType"` + IsDirectVisit string `json:"isDirectVisit"` + IsHumanLimit string `json:"isHumanLimit"` + VpdnGreDoamin string `json:"vpdnGreDoamin"` + VpdnL2tpDoamin string `json:"vpdnL2tpDoamin"` + AreaLimitType string `json:"areaLimitType"` + AreaLimitInfo string `json:"areaLimitInfo"` +} + +func (s *sSimDX) querySimBasicInfoBatch(ctx context.Context, input *model.SimBasicInfoInput) (model.SimBasicInfoOutput, bool, error) { + if input == nil || len(input.SimKeys) == 0 { + return make(model.SimBasicInfoOutput, 0), true, nil + } + if strings.TrimSpace(s.custNumber) == "" { + return nil, false, nil + } + for _, simKey := range input.SimKeys { + if simKey == nil || strings.TrimSpace(simKey.MSISDN) == "" { + return nil, false, nil + } + } + + results := make(model.SimBasicInfoOutput, len(input.SimKeys)) + pending := make([]telecomBatchKey, 0, len(input.SimKeys)) + for i, simKey := range input.SimKeys { + pending = append(pending, telecomBatchKey{ + index: i, + simKey: simKey, + accessNumber: strings.TrimSpace(simKey.MSISDN), + }) + } + + for _, chunk := range chunkTelecomBatchKeys(pending, dxBatchStatusLimit) { + accessNumbers := make([]string, 0, len(chunk)) + for _, item := range chunk { + accessNumbers = append(accessNumbers, item.accessNumber) + } + + resp, err := s.doDXBatchInfoPost(ctx, "/api/v1/prod/batchQrySimInfo", dxBatchRequest{ + AccessNumbers: accessNumbers, + CustNumber: s.custNumber, + GroupID: "", + SimStatus: "", + }) + if err != nil { + for _, item := range chunk { + results[item.index] = &model.SimBaseRes[model.SimBasicInfoItem]{ + SimKey: item.simKey, + Code: consts.ApiStatusError, + Message: err.Error(), + } + } + continue + } + + batchItems := resp.Data.QrySimInfoList + if len(batchItems) == 0 { + batchItems = resp.Data.SimInfoList + } + itemMap := make(map[string]dxBatchInfoItem, len(batchItems)*3) + for _, item := range batchItems { + putDXBatchInfoItem(itemMap, "msisdn", telecomBatchAccessNumber(item.AccessNumber, item.AccNum), item) + putDXBatchInfoItem(itemMap, "iccid", item.ICCID, item) + putDXBatchInfoItem(itemMap, "imsi", item.IMSI, item) + } + + for _, item := range chunk { + batchItem, ok := getDXBatchInfoItem(itemMap, item.simKey) + if !ok { + results[item.index] = &model.SimBaseRes[model.SimBasicInfoItem]{ + SimKey: item.simKey, + Code: consts.ApiStatusError, + Message: "batch response missing sim info", + } + continue + } + + itemResult := strings.TrimSpace(gconv.String(batchItem.Result)) + if itemResult != "" && itemResult != "1" { + results[item.index] = &model.SimBaseRes[model.SimBasicInfoItem]{ + SimKey: item.simKey, + Code: telecomBatchResultCode(itemResult), + Message: firstNonEmpty(resp.Message, resp.Msg, "telecom batch basic info query failed"), + } + continue + } + + results[item.index] = &model.SimBaseRes[model.SimBasicInfoItem]{ + SimKey: item.simKey, + Code: 0, + Message: "", + ResData: mapDXBatchInfoToBasicInfo(batchItem), + } + } + } + + return compactBasicInfoResults(results), true, nil +} + +func (s *sSimDX) doDXBatchInfoPost(ctx context.Context, endpoint string, req dxBatchRequest) (*dxBatchInfoResponse, error) { + respBody, err := s.doDXBatchPost(ctx, endpoint, req) + if err != nil { + return nil, err + } + var resp dxBatchInfoResponse + if err := json.Unmarshal(respBody, &resp); err != nil { + return nil, err + } + if gconv.String(resp.Code) != "0" { + return nil, fmt.Errorf("telecom batch basic info request failed: code=%s message=%s", gconv.String(resp.Code), firstNonEmpty(resp.Message, resp.Msg)) + } + return &resp, nil +} + +func mapDXBatchInfoToBasicInfo(item dxBatchInfoItem) model.SimBasicInfoItem { + status := strings.TrimSpace(gconv.String(item.SimStatus)) + statusValue := "" + if status != "" { + statusValue = strconv.Itoa(convertDXBatchCardStatus(context.Background(), status)) + } + return model.SimBasicInfoItem{ + ICCID: strings.TrimSpace(item.ICCID), + IMSI: strings.TrimSpace(item.IMSI), + MSISDN: telecomBatchAccessNumber(item.AccessNumber, item.AccNum), + ActiveDate: firstNonEmpty(item.ActiveTime, item.ActivationTime), + OpenDate: strings.TrimSpace(item.CreateTime), + SimStatus: statusValue, + StopTypeList: normalizeDXStringList(item.StopTypeList), + MainOfferName: strings.TrimSpace(item.MainOfferName), + CardBindingType: strings.TrimSpace(item.CardBindingType), + IsDirectVisit: strings.TrimSpace(item.IsDirectVisit), + IsHumanLimit: strings.TrimSpace(item.IsHumanLimit), + VpdnGreDoamin: strings.TrimSpace(item.VpdnGreDoamin), + VpdnL2tpDoamin: strings.TrimSpace(item.VpdnL2tpDoamin), + AreaLimitType: strings.TrimSpace(item.AreaLimitType), + AreaLimitInfo: strings.TrimSpace(item.AreaLimitInfo), + } +} + +func mapDXLegacyBasicStatus(ctx context.Context, value string) string { + value = strings.TrimSpace(value) + if value == "" { + return "" + } + return strconv.Itoa(convertDxCardStatus(ctx, value)) +} + +func normalizeDXStringList(value any) []string { + switch v := value.(type) { + case nil: + return nil + case []string: + return trimNonEmptyStrings(v) + case []any: + values := make([]string, 0, len(v)) + for _, item := range v { + values = append(values, gconv.String(item)) + } + return trimNonEmptyStrings(values) + case string: + return trimNonEmptyStrings(strings.FieldsFunc(v, func(r rune) bool { + return r == ',' || r == ';' || r == '|' || r == '_' || r == '/' + })) + default: + return trimNonEmptyStrings([]string{gconv.String(value)}) + } +} + +func trimNonEmptyStrings(values []string) []string { + output := make([]string, 0, len(values)) + for _, value := range values { + if value = strings.TrimSpace(value); value != "" { + output = append(output, value) + } + } + return output +} + +func putDXBatchInfoItem(items map[string]dxBatchInfoItem, prefix string, value string, item dxBatchInfoItem) { + if value = strings.TrimSpace(value); value != "" { + items[prefix+":"+strings.ToUpper(value)] = item + } +} + +func getDXBatchInfoItem(items map[string]dxBatchInfoItem, simKey *model.SimKey) (dxBatchInfoItem, bool) { + if simKey == nil { + return dxBatchInfoItem{}, false + } + for _, candidate := range []struct { + prefix string + value string + }{ + {prefix: "msisdn", value: simKey.MSISDN}, + {prefix: "iccid", value: simKey.ICCID}, + {prefix: "imsi", value: simKey.IMSI}, + } { + if value := strings.TrimSpace(candidate.value); value != "" { + if item, ok := items[candidate.prefix+":"+strings.ToUpper(value)]; ok { + return item, true + } + } + } + return dxBatchInfoItem{}, false +} + +func compactBasicInfoResults(items model.SimBasicInfoOutput) model.SimBasicInfoOutput { + output := make(model.SimBasicInfoOutput, 0, len(items)) + for _, item := range items { + if item != nil { + output = append(output, item) + } + } + return output +} + +// QuerySimApnInfo 物联网卡已开通APN信息查询 +// 电信当前复用在线会话查询结果,从 ResData.ApnId 中取 APN/DNN 名称 +func (s *sSimDX) QuerySimStopReason(ctx context.Context, input *model.SimStopReasonInput) (model.SimStopReasonOutput, error) { + basicInfo, err := s.QuerySimBasicInfo(ctx, &model.SimBasicInfoInput{ + SimKeys: input.SimKeys, + ReqData: model.SimInputReqNullDate{}, + }) + if err != nil { + return nil, err + } + + items := make(model.SimStopReasonOutput, 0, len(basicInfo)) + for _, item := range basicInfo { + if item == nil { + continue + } + stopReason := "" + if item.Code == 0 { + stopReason = mapDxStopTypeListText(item.ResData.StopTypeList) + } + items = append(items, &model.SimBaseRes[model.SimStopReasonItem]{ + SimKey: item.SimKey, + Code: item.Code, + Message: item.Message, + ResData: model.SimStopReasonItem{ + StopReason: stopReason, + }, + }) + } + return items, nil +} + +func (s *sSimDX) QuerySimFrozenState(ctx context.Context, input *model.SimFrozenStateInput) (model.SimFrozenStateOutput, error) { + items := make(model.SimFrozenStateOutput, 0, len(input.SimKeys)) + for _, simKey := range input.SimKeys { + items = append(items, &model.SimBaseRes[model.SimFrozenStateItem]{ + SimKey: simKey, + Code: 0, + Message: "", + ResData: model.SimFrozenStateItem{ + FrozenStatus: "不支持查询", + FrozenReason: "电信当前无独立冻结查询接口", + OperTime: "", + }, + }) + } + return items, nil +} + +// ChangeSimStatus 物联网卡状态变更 +// CTIOT_5GCMP_LC002 - 停机保号相关 +// - URL: /5gcmp/openapi/v1/common/disabledNumberService +// - orderTypeId: 19=停机保号, 20=停机保号复机 +// +// 平台 operType 与电信 orderTypeId 映射: +// - 0(申请停机,已激活转已停机) -> 19 +// - 1(申请复机,已停机转已激活) -> 20 +func (s *sSimDX) ChangeSimStatus(ctx context.Context, input *model.SimCardStatusChangeInput) (model.SimCardStatusChangeOutput, error) { + // 响应结构 (XML) + type DxResp struct { + XMLName xml.Name `xml:"businessServiceResponse"` + RspType string `xml:"RspType"` // 请求状态 + Result string `xml:"result"` // 状态响应码: 0=成功, -2=业务校验异常, 100008=系统内部错误 + ResultMsg string `xml:"resultMsg"` // 返回消息 + GroupTransactionID string `xml:"GROUP_TRANSACTIONID"` // 流水号 + } + + orderTypeID, ok := convertDxCardStatusChangeOperType(ctx, input.ReqData.OperType) + if !ok { + return newInvalidCardStatusChangeOutput(input.SimKeys), nil + } + + items := make(model.SimCardStatusChangeOutput, 0, len(input.SimKeys)) + + for _, simKey := range input.SimKeys { + params := url.Values{ + "access_number": []string{simKey.MSISDN}, + "orderTypeId": []string{orderTypeID}, + } + + respBody, err := s.client.DoGetRequest(ctx, "/5gcmp/openapi/v1/common/disabledNumberService", params) + if err != nil { + g.Log().Errorf(ctx, "ChangeSimStatus error %+v, simKey %s, params %+v", err, simKey.ChanKey, params) + items = append(items, &model.SimBaseRes[model.SimCardStatusChangeItem]{ + SimKey: simKey, + Code: consts.ApiStatusError, + Message: err.Error(), + ResData: model.SimCardStatusChangeItem{ + OrderNum: "", + }, + }) + continue + } + + g.Log().Debugf(ctx, "ChangeSimStatus response: %s", string(respBody)) + + // 去除XML命名空间前缀 + xmlBody := xmlNamespaceRegex.ReplaceAllString(string(respBody), "") + + var dxResp DxResp + err = xml.Unmarshal([]byte(xmlBody), &dxResp) + if err != nil { + g.Log().Errorf(ctx, "ChangeSimStatus unmarshal error %+v, body %s", err, xmlBody) + items = append(items, &model.SimBaseRes[model.SimCardStatusChangeItem]{ + SimKey: simKey, + Code: consts.ApiStatusError, + Message: err.Error(), + ResData: model.SimCardStatusChangeItem{ + OrderNum: "", + }, + }) + continue + } + + // result = "0" 表示成功 + code := gconv.Int(dxResp.Result) + if dxResp.Result != "0" { + g.Log().Warningf(ctx, "ChangeSimStatus upstream error %s, %s, simKey %s", dxResp.Result, dxResp.ResultMsg, simKey.ChanKey) + } + + items = append(items, &model.SimBaseRes[model.SimCardStatusChangeItem]{ + SimKey: simKey, + Code: code, + Message: dxResp.ResultMsg, + ResData: model.SimCardStatusChangeItem{ + OrderNum: dxResp.GroupTransactionID, + }, + }) + } + + return items, nil +} + +// QuerySimCardBindStatus 物联网卡机卡绑定状态查询 +func (s *sSimDX) QuerySimCardBindStatus(ctx context.Context, input *model.SimCardBindStatusInput) (model.SimCardBindStatusOutput, error) { + type bindData struct { + OrderStatus string `json:"orderStatus"` + OrderStaus string `json:"orderStaus"` + BindType string `json:"bindType"` + BindImei string `json:"bindImei"` + LastImei string `json:"lastImei"` + BindStatus string `json:"bindStatus"` + BindStaus string `json:"bindStaus"` + } + type bindResp struct { + Code any `json:"code"` + Msg string `json:"msg"` + Data bindData `json:"data"` + } + items := make(model.SimCardBindStatusOutput, 0, len(input.SimKeys)) + for _, simKey := range input.SimKeys { + name, value := "iccid", strings.TrimSpace(simKey.ICCID) + if msisdn := strings.TrimSpace(simKey.MSISDN); msisdn != "" { + name, value = "access_number", msisdn + } + item := &model.SimBaseRes[model.SimCardBindStatusItem]{SimKey: simKey} + if value == "" { + item.Code, item.Message = consts.ApiStatusError, "missing access number or iccid" + items = append(items, item) + continue + } + body, err := s.client.DoGetRequest(ctx, "/api/v1/corp/connect/chkDeviceBindingInfo", url.Values{name: {value}}) + if err != nil { + item.Code, item.Message = consts.ApiStatusError, err.Error() + items = append(items, item) + continue + } + var result bindResp + if err := json.Unmarshal(body, &result); err != nil { + item.Code, item.Message = consts.ApiStatusError, err.Error() + items = append(items, item) + continue + } + code := strings.TrimSpace(gconv.String(result.Code)) + item.Code, item.Message = gconv.Int(code), result.Msg + if code == "" || (code != "0" && item.Code == 0) { + item.Code = consts.ApiStatusError + if item.Message == "" { + item.Message = "invalid card binding response code" + } + } + if item.Code == 0 { + item.ResData = model.SimCardBindStatusItem{ + OrderStatus: firstNonEmpty(result.Data.OrderStatus, result.Data.OrderStaus), + CardBindingType: result.Data.BindType, + BindStatus: firstNonEmpty(result.Data.BindStaus, result.Data.BindStatus), + BindImei: result.Data.BindImei, + LastImei: result.Data.LastImei, + } + } + items = append(items, item) + } + return items, nil +} + +// QuerySimDeviceStatus 查询运营商终端开关机状态。 +// 电信官方 NQ007 接口只提供电源状态,不提供统一设备健康状态。 +func (s *sSimDX) QuerySimDeviceStatus(ctx context.Context, input *model.SimDeviceStatusInput) (model.SimDeviceStatusOutput, error) { + type powerStatusResp struct { + Code any `json:"code"` + Message string `json:"message"` + Status struct { + Status string `json:"Status"` + LastedPowerOnTime string `json:"Lastedpowerontime"` + LastedPowerOffTime string `json:"Lastedpowerofftime"` + } `json:"status"` + } + + items := make(model.SimDeviceStatusOutput, 0, len(input.SimKeys)) + for _, simKey := range input.SimKeys { + accessNumber := firstNonEmpty(simKey.MSISDN, simKey.ICCID) + if accessNumber == "" { + items = append(items, &model.SimBaseRes[model.SimDeviceStatusItem]{ + SimKey: simKey, + Code: consts.ApiStatusError, + Message: "missing access number or iccid", + }) + continue + } + + paramName := "access_number" + if strings.TrimSpace(simKey.MSISDN) == "" { + paramName = "iccid" + } + requestBody, err := json.Marshal(map[string]string{paramName: accessNumber}) + if err != nil { + items = append(items, &model.SimBaseRes[model.SimDeviceStatusItem]{ + SimKey: simKey, + Code: consts.ApiStatusError, + Message: err.Error(), + }) + continue + } + + respBody, err := s.client.DoPostJSONRequest(ctx, "/api/v1/prod/getDevicePowerStatus", requestBody) + if err != nil { + g.Log().Warningf(ctx, "QuerySimDeviceStatus error %+v, simKey %+v", err, simKey) + items = append(items, &model.SimBaseRes[model.SimDeviceStatusItem]{ + SimKey: simKey, + Code: consts.ApiStatusError, + Message: err.Error(), + }) + continue + } + + var resp powerStatusResp + if err = json.Unmarshal(respBody, &resp); err != nil { + g.Log().Warningf(ctx, "QuerySimDeviceStatus unmarshal error %+v, body %s", err, string(respBody)) + items = append(items, &model.SimBaseRes[model.SimDeviceStatusItem]{ + SimKey: simKey, + Code: consts.ApiStatusError, + Message: err.Error(), + }) + continue + } + + code := gconv.Int(resp.Code) + if code != 0 { + items = append(items, &model.SimBaseRes[model.SimDeviceStatusItem]{ + SimKey: simKey, + Code: code, + Message: resp.Message, + }) + continue + } + + operatingStatus := mapDxOperatingStatus(resp.Status.Status) + items = append(items, &model.SimBaseRes[model.SimDeviceStatusItem]{ + SimKey: simKey, + Code: 0, + Message: resp.Message, + ResData: model.SimDeviceStatusItem{ + DeviceStatus: consts.CardSessionStatusUnknown, + OperatingStatus: operatingStatus, + }, + }) + } + return items, nil +} + +// QuerySimRegionLimitStatus 物联网卡地域限制状态查询 +func (s *sSimDX) QuerySimRegionLimitStatus(ctx context.Context, input *model.SimRegionLimitStatusInput) (model.SimRegionLimitStatusOutput, error) { + type dxRegionLimitResp struct { + Code any `json:"code"` + ResultCode any `json:"resultCode"` + Msg string `json:"msg"` + ResultMsg string `json:"resultMsg"` + Message string `json:"message"` + Data json.RawMessage `json:"data"` + } + items := make(model.SimRegionLimitStatusOutput, 0, len(input.SimKeys)) + for _, simKey := range input.SimKeys { + accessNumber := firstNonEmpty(simKey.MSISDN, simKey.ICCID, simKey.IMSI) + if accessNumber == "" { + items = append(items, &model.SimBaseRes[model.SimRegionLimitStatusItem]{ + SimKey: simKey, + Code: consts.ApiStatusError, + Message: "missing access number", + }) + continue + } + + var ( + regionItem *dxRegionLimitItem + respCode = consts.ApiStatusError + respMsg string + lastErr error + ) + + for _, endpoint := range []string{"/openapi/v1/areaLimit/qryAreaLimitInfo"} { + respBody, err := s.client.DoGetRequest(ctx, endpoint, url.Values{ + "accNum": []string{accessNumber}, + }) + if err != nil { + lastErr = err + continue + } + g.Log().Debugf(ctx, "QuerySimRegionLimitStatus response endpoint=%s body=%s", endpoint, string(respBody)) + + var dxResp dxRegionLimitResp + if err := json.Unmarshal(respBody, &dxResp); err != nil { + lastErr = err + continue + } + + codeStr := firstNonEmpty(gconv.String(dxResp.Code), gconv.String(dxResp.ResultCode)) + if codeStr == "" { + codeStr = "0" + } + respCode = gconv.Int(codeStr) + respMsg = firstNonEmpty(dxResp.Msg, dxResp.ResultMsg, dxResp.Message) + if respCode != 0 { + continue + } + + item, err := decodeDxRegionLimitData(dxResp.Data) + if err != nil { + lastErr = err + continue + } + if item == nil { + regionItem = &dxRegionLimitItem{} + lastErr = nil + break + } + regionItem = item + lastErr = nil + break + } + + if regionItem == nil { + basicInfo, basicErr := s.QuerySimBasicInfo(ctx, &model.SimBasicInfoInput{ + SimKeys: []*model.SimKey{simKey}, + ReqData: model.SimInputReqNullDate{}, + }) + if basicErr != nil { + message := respMsg + if lastErr != nil { + message = lastErr.Error() + } + if message == "" { + message = "telecom region limit query failed" + } + items = append(items, &model.SimBaseRes[model.SimRegionLimitStatusItem]{ + SimKey: simKey, + Code: respCode, + Message: message, + }) + continue + } + if len(basicInfo) == 0 || basicInfo[0] == nil { + items = append(items, &model.SimBaseRes[model.SimRegionLimitStatusItem]{ + SimKey: simKey, + Code: consts.ApiStatusError, + Message: "telecom region limit query returned empty data", + }) + continue + } + items = append(items, &model.SimBaseRes[model.SimRegionLimitStatusItem]{ + SimKey: simKey, + Code: 0, + Message: "", + ResData: model.SimRegionLimitStatusItem{ + RegionLimitStatus: "2", + OrderStatus: "", + LimitStatus: "", + LimitType: basicInfo[0].ResData.AreaLimitType, + LimitArea: basicInfo[0].ResData.AreaLimitInfo, + }, + }) + continue + } + + orderStatus := gconv.String(regionItem.OrderStatus) + limitStatus, limitType, limitArea := normalizeDxRegionLimitDetails( + orderStatus, + regionItem.LimitStatus, + gconv.String(regionItem.LimitType), + regionItem.LimitArea, + ) + items = append(items, &model.SimBaseRes[model.SimRegionLimitStatusItem]{ + SimKey: simKey, + Code: 0, + Message: "", + ResData: model.SimRegionLimitStatusItem{ + RegionLimitStatus: mapDxRegionLimitStatusByOrder(orderStatus, limitStatus), + OrderStatus: orderStatus, + LimitStatus: limitStatus, + LimitType: limitType, + LimitArea: limitArea, + }, + }) + } + return items, nil +} + +// convertDxSessionStatus 转换电信会话状态码 +// 电信状态: 0=在线, 1=不在线, 2=未查询到会话信息 +// 系统状态: 0=在线, -1=不在线, -2=未查询到会话信息 +// func convertDxSessionStatus(ctx context.Context, upstreamStatus string) string { +// switch upstreamStatus { +// case "0": +// return "0" // 在线 +// case "1": +// return "1" // 不在线 +// case "2": +// return "2" // 未查询到会话信息 +// default: +// g.Log().Warningf(ctx, "Unknown session status %s from upstream", upstreamStatus) +// return "2" +// } +// } + +// BQ018 uses 3 for test deactivation, unlike the single-card product status. +func convertDXBatchCardStatus(ctx context.Context, upstreamStatus string) int { + switch strings.TrimSpace(upstreamStatus) { + case "1": + return 1 + case "2", "3": + return 6 + case "4": + return 2 + case "5": + return 4 + case "6": + return 0 + default: + g.Log().Warningf(ctx, "Unknown DX batch card status %q", upstreamStatus) + return 0 + } +} + +// convertDxCardStatus 转换电信卡状态码 +func convertDxCardStatus(ctx context.Context, upstreamStatus string) int { + if upstreamStatus == "" { + return -2 + } + status := gconv.Int(upstreamStatus) + switch status { + case 1: + return 1 // 待激活 + case 4: + return 2 // 已激活 + case 5: + return 4 // 停机 + case 2: + return 6 // 可测试 + case 3: + return 7 // 库存 + case 7: + return 9 // 已销户 + case 6: + return 0 // 未知 + default: + g.Log().Warningf(ctx, "Unknown card status %d from upstream", status) + return 0 + } +} diff --git a/internal/logic/upstreamimpl/sim_dx_apn.go b/internal/logic/upstreamimpl/sim_dx_apn.go new file mode 100644 index 0000000..5acd6fe --- /dev/null +++ b/internal/logic/upstreamimpl/sim_dx_apn.go @@ -0,0 +1,216 @@ +package upstreamimpl + +import ( + "context" + "encoding/json" + "encoding/xml" + "net/url" + "strconv" + "strings" + + "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" + "github.com/gogf/gf/v2/frame/g" +) + +type dxAPNResponse[T any] struct { + Code json.RawMessage `json:"code"` + Message string `json:"message"` + Result *T `json:"result"` +} + +type dxAPNInfo struct { + ApnInfoList []struct { + ApnName string `json:"apnName"` + Status *string `json:"status"` + ApnStatus *string `json:"apnStatus"` + } `json:"apnInfoList"` +} + +func dxAPNParams(simKey *model.SimKey) url.Values { + params := url.Values{} + if simKey == nil { + return params + } + if value := strings.TrimSpace(simKey.MSISDN); value != "" { + params.Set("msisdn", value) + } else if value := strings.TrimSpace(simKey.ICCID); value != "" { + params.Set("iccid", value) + } else if value := strings.TrimSpace(simKey.IMSI); value != "" { + params.Set("imsi", value) + } + return params +} + +func decodeDXAPNResponse[T any](body []byte) (*dxAPNResponse[T], int, string, error) { + var response dxAPNResponse[T] + if err := json.Unmarshal(body, &response); err != nil { + return nil, consts.ApiStatusError, "", gerror.Wrap(err, "电信 APN 响应格式错误") + } + rawCode := strings.TrimSpace(string(response.Code)) + if strings.HasPrefix(rawCode, "\"") { + if err := json.Unmarshal(response.Code, &rawCode); err != nil { + return nil, consts.ApiStatusError, "", gerror.Wrap(err, "电信 APN 返回码格式错误") + } + } + code, err := strconv.Atoi(rawCode) + if err != nil || (code == 0 && rawCode != "0") { + return &response, consts.ApiStatusError, rawCode, gerror.Newf("电信 APN 返回码无效:%s,%s", rawCode, response.Message) + } + if code != 0 { + return &response, code, rawCode, nil + } + if response.Result == nil { + return &response, consts.ApiStatusError, "", gerror.New("电信 APN 成功响应缺少 result") + } + return &response, code, "", nil +} + +func (s *sSimDX) QuerySimApnInfo(ctx context.Context, input *model.SimApnInfoInput) (model.SimApnInfoOutput, error) { + items := make(model.SimApnInfoOutput, 0, len(input.SimKeys)) + for _, simKey := range input.SimKeys { + item := &model.SimBaseRes[model.SimApnInfoItemList]{SimKey: simKey, ResData: model.SimApnInfoItemList{ApnList: []model.SimApnInfoItem{}}} + items = append(items, item) + params := dxAPNParams(simKey) + if len(params) == 0 { + item.Code, item.Message = consts.ApiStatusError, "电信 APN 查询缺少卡标识" + continue + } + body, err := s.client.DoGetRequest(ctx, "/api/v3/getApnInfo", params) + if err != nil { + item.Code, item.Message = consts.ApiStatusError, err.Error() + continue + } + response, code, rawCode, err := decodeDXAPNResponse[dxAPNInfo](body) + item.Code, item.ErrCode = code, rawCode + if err != nil { + item.Message = err.Error() + continue + } + item.Message = response.Message + if code != 0 { + continue + } + if response.Result.ApnInfoList == nil { + item.Code, item.Message = consts.ApiStatusError, "电信 APN 成功响应缺少 apnInfoList" + continue + } + for _, apn := range response.Result.ApnInfoList { + status := "" + if apn.Status != nil { + status = strings.TrimSpace(*apn.Status) + } else if apn.ApnStatus != nil { + status = strings.TrimSpace(*apn.ApnStatus) + } + if apn.Status != nil && apn.ApnStatus != nil && strings.TrimSpace(*apn.Status) != strings.TrimSpace(*apn.ApnStatus) { + status = "" + } + if status != consts.CardApnStatusNormal && status != consts.CardApnStatusPaused { + status = "" + } + item.ResData.ApnList = append(item.ResData.ApnList, model.SimApnInfoItem{ApnName: strings.TrimSpace(apn.ApnName), Status: status}) + } + } + return items, nil +} + +// dxCutNetSuccessCode 是 singleCutNet 响应中表示成功接收的 RspCode。 +const dxCutNetSuccessCode = "0000" + +// ChangeSimApnFunction 添加或取消单独断网 +// CTIOT_5GCMP_BA004 - 添加或取消单独断网 +// - URL: /5gcmp/openapi/v1/common/singleCutNet +// - 参数: access_number + action (ADD/DEL) +// - 响应: XML,RspCode=0000 表示成功接收,GROUP_TRANSACTIONID 为流水号 +// +// 这是整卡数据开停语义(不是按 APN 定向): +// - 平台 0(开通数据) -> action=DEL(取消单独断网,恢复联网) +// - 平台 1(停用数据) -> action=ADD(单独断网) +// +// 电信侧没有「按 APN 定向」这一维度,因此忽略调用方携带的 ApnName +// (预警自动断网、批量数据开停都会固定带 apnName="CMIOT"),不能因此拒绝请求。 +func (s *sSimDX) ChangeSimApnFunction(ctx context.Context, input *model.SimCardOperateChangeApnInput) (model.SimCardOperateChangeApnOutput, error) { + // 响应结构 (XML) + type dxCutNetResponse struct { + XMLName xml.Name `xml:"SvcCont"` + GroupTransactionID string `xml:"GROUP_TRANSACTIONID"` + Response struct { + RspType string `xml:"RspType"` + RspCode string `xml:"RspCode"` + RspDesc string `xml:"RspDesc"` + } `xml:"Response"` + } + + action, ok := convertDxApnOperType(ctx, input.ReqData.OperType) + + items := make(model.SimCardOperateChangeApnOutput, 0, len(input.SimKeys)) + if !ok { + for _, simKey := range input.SimKeys { + items = append(items, &model.SimBaseRes[model.SimCardOperateChangeApnItem]{ + SimKey: simKey, + Code: consts.ApiStatusError, + Message: "APN 操作类型必须为 0(开通)或 1(停止)", + ResData: model.SimCardOperateChangeApnItem{}, + }) + } + return items, nil + } + + for _, simKey := range input.SimKeys { + item := &model.SimBaseRes[model.SimCardOperateChangeApnItem]{SimKey: simKey} + items = append(items, item) + + // singleCutNet 只按物联网接入号定位,缺接入号无法定位卡。 + accessNumber := strings.TrimSpace(simKey.MSISDN) + if accessNumber == "" { + item.Code, item.Message = consts.ApiStatusError, "电信单独断网缺少物联网接入号(MSISDN)" + continue + } + + params := url.Values{ + "access_number": []string{accessNumber}, + "action": []string{action}, + } + respBody, err := s.client.DoGetRequest(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) + item.Code, item.Message = consts.ApiStatusError, err.Error() + continue + } + + g.Log().Debugf(ctx, "ChangeSimApnFunction response: %s", string(respBody)) + + // 去除XML命名空间前缀 + xmlBody := xmlNamespaceRegex.ReplaceAllString(string(respBody), "") + + var dxResp dxCutNetResponse + 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() + 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 == "" { + item.Message = "电信单独断网未返回结果码,办理结果未确认,请核对上游" + } + continue + } + + item.Code = consts.ApiStatusSuccess + item.ResData.OrderNum = strings.TrimSpace(dxResp.GroupTransactionID) + if item.ResData.OrderNum == "" { + item.Code, item.Message = consts.ApiStatusError, "电信单独断网未返回订单号,办理结果未确认,请核对上游" + } + } + + return items, nil +} diff --git a/internal/logic/upstreamimpl/sim_dx_apn_test.go b/internal/logic/upstreamimpl/sim_dx_apn_test.go new file mode 100644 index 0000000..f8f5b11 --- /dev/null +++ b/internal/logic/upstreamimpl/sim_dx_apn_test.go @@ -0,0 +1,200 @@ +package upstreamimpl + +import ( + "context" + "fmt" + "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 TestDXQueryAPNV3(t *testing.T) { + tests := []struct { + name string + body string + wantCode int + wantStatus string + wantCount int + wantErrCode string + }{ + {name: "table status enabled", body: `{"code":"0","result":{"apnInfoList":[{"apnName":"ctnet","status":"1"}]}}`, wantStatus: "1", wantCount: 1}, + {name: "example apnStatus stopped", body: `{"code":"0","result":{"apnInfoList":[{"apnName":"ctnet","apnStatus":"0"}]}}`, wantStatus: "0", wantCount: 1}, + {name: "missing status stays unknown", body: `{"code":"0","result":{"apnInfoList":[{"apnName":"ctnet"}]}}`, wantCount: 1}, + {name: "conflicting aliases stay unknown", body: `{"code":"0","result":{"apnInfoList":[{"apnName":"ctnet","status":"1","apnStatus":"0"}]}}`, wantCount: 1}, + {name: "invalid status stays unknown", body: `{"code":"0","result":{"apnInfoList":[{"apnName":"ctnet","status":"2"}]}}`, wantCount: 1}, + {name: "empty list", body: `{"code":0,"result":{"apnInfoList":[]}}`}, + {name: "missing code", body: `{"result":{"apnInfoList":[]}}`, wantCode: -1}, + {name: "null code", body: `{"code":null,"result":{"apnInfoList":[]}}`, wantCode: -1, wantErrCode: "null"}, + {name: "missing result", body: `{"code":"0"}`, wantCode: -1}, + {name: "missing list", body: `{"code":"0","result":{}}`, wantCode: -1}, + {name: "carrier failure", body: `{"code":"14008","message":"not found","result":null}`, wantCode: 14008, wantErrCode: "14008"}, + {name: "symbolic carrier failure", body: `{"code":"S00004","message":"denied"}`, wantCode: -1, wantErrCode: "S00004"}, + {name: "malformed", body: ``, wantCode: -1}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + 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") != "" { + t.Errorf("query = %s", request.URL.RawQuery) + } + if request.Header.Get("AppKey") != "test" || request.Header.Get("Sign") == "" || request.Header.Get("Timestamp") == "" { + t.Error("missing signed request headers") + } + fmt.Fprint(writer, test.body) + })) + 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"}}}) + if err != nil || len(output) != 1 { + t.Fatalf("output = %+v, error = %v", output, err) + } + item := output[0] + if (test.wantCode == -1 && item.Code == 0) || (test.wantCode != -1 && item.Code != test.wantCode) || item.ErrCode != test.wantErrCode { + t.Fatalf("code = %d, raw = %q", item.Code, item.ErrCode) + } + if len(item.ResData.ApnList) != test.wantCount { + t.Fatalf("APNs = %+v", item.ResData.ApnList) + } + if test.wantCount != 0 && (item.ResData.ApnList[0].Status != test.wantStatus || item.ResData.ApnList[0].ApnName != "ctnet") { + t.Fatalf("APN = %+v", item.ResData.ApnList[0]) + } + }) + } +} + +func TestDXAPNIdentifierFallback(t *testing.T) { + tests := []struct { + name string + key *model.SimKey + 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: "nil", key: nil}, + {name: "empty", key: &model.SimKey{}}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + requests := 0 + server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + requests++ + if request.URL.Query().Get(test.param) != test.value { + t.Errorf("query = %s", request.URL.RawQuery) + } + fmt.Fprint(writer, `{"code":"0","result":{"apnInfoList":[]}}`) + })) + defer server.Close() + sim := &sSimDX{client: dx.NewClient(dx.Config{BaseURL: server.URL})} + output, err := sim.QuerySimApnInfo(context.Background(), &model.SimApnInfoInput{SimKeys: []*model.SimKey{test.key}}) + if err != nil || len(output) != 1 { + t.Fatalf("output = %+v, error = %v", output, err) + } + if test.param == "" { + if output[0].Code == 0 || requests != 0 { + t.Fatal("missing identifier must not request upstream") + } + } else if output[0].Code != 0 || requests != 1 { + t.Fatalf("code = %d, requests = %d", output[0].Code, requests) + } + }) + } +} + +// TestDXChangeAPNSingleCutNet 验证电信数据开停走 singleCutNet,且 ADD/DEL 方向与平台语义相反。 +func TestDXChangeAPNSingleCutNet(t *testing.T) { + tests := []struct { + name string + operType string + wantAction string + }{ + {name: "platform open resumes network", operType: "0", wantAction: "DEL"}, + {name: "platform stop cuts network", operType: "1", wantAction: "ADD"}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + requests := 0 + server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + requests++ + if request.URL.Path != "/5gcmp/openapi/v1/common/singleCutNet" || request.Method != http.MethodGet { + t.Errorf("unexpected request: %s %s", request.Method, request.URL.Path) + } + query := request.URL.Query() + if query.Get("action") != test.wantAction { + t.Errorf("action = %q, want %q", query.Get("action"), test.wantAction) + } + if query.Get("access_number") != "138****8000" { + t.Errorf("access_number = %q", query.Get("access_number")) + } + // 不能携带 APN 定向参数,singleCutNet 是整卡语义。 + if query.Get("apnName") != "" || query.Get("operType") != "" { + t.Errorf("APN-scoped params must not be sent: %s", request.URL.RawQuery) + } + if request.Header.Get("AppKey") != "test" || request.Header.Get("Sign") == "" || request.Header.Get("Timestamp") == "" { + t.Error("missing signed request headers") + } + fmt.Fprint(writer, `00000成功接收消息order-1`) + })) + defer server.Close() + 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"}}, + 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 { + t.Fatalf("output = %+v, error = %v, requests = %d", output, err, requests) + } + }) + } +} + +// TestDXChangeAPNSingleCutNetFailClosed 验证失败保护:不成功不重试、不盲目视为成功。 +func TestDXChangeAPNSingleCutNetFailClosed(t *testing.T) { + tests := []struct { + name string + operType string + msisdn string + body string + httpStatus int + 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}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + requests := 0 + server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + requests++ + if request.URL.Path != "/5gcmp/openapi/v1/common/singleCutNet" { + t.Errorf("unexpected endpoint: %s", request.URL.Path) + } + if test.httpStatus != 0 { + writer.WriteHeader(test.httpStatus) + } + fmt.Fprint(writer, test.body) + })) + 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: test.msisdn, ChanKey: "71"}}, + ReqData: model.SimCardOperateChangeApnInputItem{ApnName: "CMIOT", OperType: test.operType}, + }) + if err != nil || len(output) != 1 || output[0].Code == 0 || output[0].ResData.OrderNum != "" || requests != test.wantRequests { + t.Fatalf("output = %+v, error = %v, requests = %d", output, err, requests) + } + }) + } +} diff --git a/internal/logic/upstreamimpl/sim_dx_batch.go b/internal/logic/upstreamimpl/sim_dx_batch.go new file mode 100644 index 0000000..37e3710 --- /dev/null +++ b/internal/logic/upstreamimpl/sim_dx_batch.go @@ -0,0 +1,641 @@ +package upstreamimpl + +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" + + "github.com/gogf/gf/v2/frame/g" + "github.com/gogf/gf/v2/util/gconv" +) + +const ( + dxBatchStatusLimit = 10000 + dxBatchUsageLimit = 100 +) + +type telecomBatchKey struct { + index int + simKey *model.SimKey + accessNumber string +} + +type dxBatchRequest struct { + AccessNumbers []string `json:"accessNumbers"` + CustNumber string `json:"custNumber"` + GroupID string `json:"groupId,omitempty"` + SimStatus string `json:"simStatus,omitempty"` + FlowDate string `json:"flowDate,omitempty"` +} + +type dxBatchStatusResponse struct { + Code any `json:"code"` + Message string `json:"message"` + Msg string `json:"msg"` + Data struct { + QrySimStatusList []dxBatchStatusItem `json:"qrySimStatusList"` + StatusList []dxBatchStatusItem `json:"statusList"` + } `json:"data"` +} + +type dxBatchStatusItem struct { + AccessNumber string `json:"accessNumber"` + AccNum string `json:"accNum"` + Result any `json:"result"` + Iccid string `json:"iccid"` + Imsi string `json:"imsi"` + SimStatus any `json:"simStatus"` +} + +type dxBatchFlowResponse struct { + Code any `json:"code"` + Message string `json:"message"` + Msg string `json:"msg"` + Data []dxBatchFlowItem `json:"data"` +} + +type dxBatchFlowItem struct { + AccessNumber string `json:"accessNumber"` + AccNum string `json:"accNum"` + Result any `json:"result"` + Unit string `json:"unit"` + FlowAmount any `json:"flowAmount"` + FlowDate string `json:"flowDate"` + FlowDay string `json:"flowDay"` +} + +func parseDXCustNumber(ctx context.Context, channelConfig *model.SimChannelConfigItemCache) string { + if channelConfig == nil || channelConfig.Channel == nil { + return "" + } + var cfg struct { + CustNumber string `json:"custNumber"` + } + if err := json.Unmarshal([]byte(channelConfig.Channel.ChanConfig), &cfg); err != nil { + g.Log().Warningf(ctx, "parse telecom channel config failed, chanKey=%s err=%+v", channelConfig.Channel.ChanKey, err) + return "" + } + return strings.TrimSpace(cfg.CustNumber) +} + +func parseDXBaseURL(ctx context.Context, channelConfig *model.SimChannelConfigItemCache) string { + if channelConfig == nil || channelConfig.Channel == nil { + return "" + } + if channelConfig.Template != nil { + var template struct { + API struct { + BaseURL string `json:"baseUrl"` + } `json:"api"` + } + if err := json.Unmarshal([]byte(channelConfig.Template.TempConfig), &template); err == nil && strings.TrimSpace(template.API.BaseURL) != "" { + return strings.TrimRight(strings.TrimSpace(template.API.BaseURL), "/") + } + } + var cfg struct { + BaseURL string `json:"baseURL"` + URL string `json:"url"` + } + if err := json.Unmarshal([]byte(channelConfig.Channel.ChanConfig), &cfg); err != nil { + g.Log().Warningf(ctx, "parse telecom channel config failed, chanKey=%s err=%+v", channelConfig.Channel.ChanKey, err) + return "" + } + baseURL := strings.TrimSpace(firstNonEmpty(cfg.BaseURL, cfg.URL)) + if baseURL == "" { + baseURL = strings.TrimSpace(g.Cfg().MustGet(ctx, "dependencies.sim_dx_v1.url", dx.DefaultBaseURL).String()) + } + return strings.TrimRight(baseURL, "/") +} + +func (s *sSimDX) QuerySimCardStatusBatch(ctx context.Context, input *model.SimCardStatusInput) (model.SimCardStatusOutput, error) { + return s.queryCardStatusBatch(ctx, input) +} + +func (s *sSimDX) QuerySimDataUsageMonthlyBatch(ctx context.Context, input *model.SimDataUsageMonthlyInput) (model.SimDataUsageMonthlyOutput, error) { + return s.queryDataUsageMonthlyBatch(ctx, input) +} + +func (s *sSimDX) QuerySimDataUsageDailyBatch(ctx context.Context, input *model.SimDataUsageDailyInput) (model.SimDataUsageDailyOutput, error) { + return s.queryDataUsageDailyBatch(ctx, input) +} + +func (s *sSimDX) queryCardStatusBatch(ctx context.Context, input *model.SimCardStatusInput) (model.SimCardStatusOutput, error) { + if err := s.ensureDXBatchConfig(); err != nil { + return nil, err + } + if input == nil || len(input.SimKeys) == 0 { + return make(model.SimCardStatusOutput, 0), nil + } + + results := make(model.SimCardStatusOutput, len(input.SimKeys)) + pending := make([]telecomBatchKey, 0, len(input.SimKeys)) + for i, simKey := range input.SimKeys { + if simKey == nil || strings.TrimSpace(simKey.MSISDN) == "" { + results[i] = &model.SimBaseRes[model.SimCardStatusItem]{ + SimKey: simKey, + Code: consts.ApiStatusError, + Message: "MSISDN not found", + } + continue + } + pending = append(pending, telecomBatchKey{ + index: i, + simKey: simKey, + accessNumber: strings.TrimSpace(simKey.MSISDN), + }) + } + + for _, chunk := range chunkTelecomBatchKeys(pending, dxBatchStatusLimit) { + accessNumbers := make([]string, 0, len(chunk)) + for _, item := range chunk { + accessNumbers = append(accessNumbers, item.accessNumber) + } + + resp, err := s.doDXBatchStatusPost(ctx, "/api/v1/prod/batchQrySimStatus", dxBatchRequest{ + AccessNumbers: accessNumbers, + CustNumber: s.custNumber, + GroupID: "", + SimStatus: "", + }) + if err != nil { + for _, item := range chunk { + results[item.index] = &model.SimBaseRes[model.SimCardStatusItem]{ + SimKey: item.simKey, + Code: consts.ApiStatusError, + Message: err.Error(), + } + } + continue + } + + batchItems := resp.Data.QrySimStatusList + if len(batchItems) == 0 { + batchItems = resp.Data.StatusList + } + itemMap := make(map[string]dxBatchStatusItem, len(batchItems)) + for _, item := range batchItems { + accessNumber := telecomBatchAccessNumber(item.AccessNumber, item.AccNum) + if accessNumber == "" { + continue + } + itemMap[accessNumber] = item + } + + for _, item := range chunk { + batchItem, ok := itemMap[item.accessNumber] + if !ok { + results[item.index] = &model.SimBaseRes[model.SimCardStatusItem]{ + SimKey: item.simKey, + Code: consts.ApiStatusError, + Message: "batch response missing accessNumber", + } + continue + } + + itemResult := gconv.String(batchItem.Result) + itemStatus := gconv.String(batchItem.SimStatus) + if itemResult != "1" { + results[item.index] = &model.SimBaseRes[model.SimCardStatusItem]{ + SimKey: item.simKey, + Code: telecomBatchResultCode(itemResult), + Message: firstNonEmpty(resp.Message, resp.Msg, "telecom batch status query failed"), + ResData: model.SimCardStatusItem{ + CardStatus: 0, + LastChangeDate: "", + }, + } + continue + } + if itemStatus == "" { + results[item.index] = &model.SimBaseRes[model.SimCardStatusItem]{ + SimKey: item.simKey, + Code: consts.ApiStatusError, + Message: "simStatus missing in batch response", + } + continue + } + + results[item.index] = &model.SimBaseRes[model.SimCardStatusItem]{ + SimKey: item.simKey, + Code: 0, + Message: "", + ResData: model.SimCardStatusItem{ + CardStatus: convertDXBatchCardStatus(ctx, itemStatus), + LastChangeDate: "", + }, + } + } + } + + return compactStatusBatchResults(results), nil +} + +func (s *sSimDX) queryDataUsageMonthlyBatch(ctx context.Context, input *model.SimDataUsageMonthlyInput) (model.SimDataUsageMonthlyOutput, error) { + if err := s.ensureDXBatchConfig(); err != nil { + return nil, err + } + if input == nil || len(input.SimKeys) == 0 { + return make(model.SimDataUsageMonthlyOutput, 0), nil + } + queryMonth := strings.TrimSpace(input.ReqData.QueryDate) + if queryMonth == "" { + return nil, fmt.Errorf("queryDate is required") + } + if _, err := time.Parse("200601", queryMonth); err != nil { + return nil, fmt.Errorf("invalid queryDate: %s", queryMonth) + } + if !dxBatchMonthAvailable(queryMonth, time.Now()) { + return nil, fmt.Errorf("电信月批量接口仅支持当月;历史月份请使用 /sim/query/sim-datausage-monthly") + } + + results := make(model.SimDataUsageMonthlyOutput, len(input.SimKeys)) + pending := make([]telecomBatchKey, 0, len(input.SimKeys)) + for i, simKey := range input.SimKeys { + if simKey == nil || strings.TrimSpace(simKey.MSISDN) == "" { + results[i] = &model.SimBaseRes[model.SimDataUsageMonthlyOutputItem]{ + SimKey: simKey, + Code: consts.ApiStatusError, + Message: "MSISDN not found", + ResData: model.SimDataUsageMonthlyOutputItem{}, + } + continue + } + pending = append(pending, telecomBatchKey{ + index: i, + simKey: simKey, + accessNumber: strings.TrimSpace(simKey.MSISDN), + }) + } + + for _, chunk := range chunkTelecomBatchKeys(pending, dxBatchUsageLimit) { + accessNumbers := make([]string, 0, len(chunk)) + for _, item := range chunk { + accessNumbers = append(accessNumbers, item.accessNumber) + } + + resp, err := s.doDXBatchFlowPost(ctx, "/api/v1/batchQry/batchQryFlowByMonth", dxBatchRequest{ + AccessNumbers: accessNumbers, + CustNumber: s.custNumber, + }) + if err != nil { + for _, item := range chunk { + results[item.index] = &model.SimBaseRes[model.SimDataUsageMonthlyOutputItem]{ + SimKey: item.simKey, + Code: consts.ApiStatusError, + Message: err.Error(), + ResData: model.SimDataUsageMonthlyOutputItem{DeadLine: queryMonth}, + } + } + continue + } + + itemMap := make(map[string]dxBatchFlowItem, len(resp.Data)) + for _, item := range resp.Data { + accessNumber := telecomBatchAccessNumber(item.AccessNumber, item.AccNum) + if accessNumber == "" { + continue + } + itemMap[accessNumber] = item + } + + for _, item := range chunk { + batchItem, ok := itemMap[item.accessNumber] + if !ok { + results[item.index] = &model.SimBaseRes[model.SimDataUsageMonthlyOutputItem]{ + SimKey: item.simKey, + Code: consts.ApiStatusError, + Message: "batch response missing accessNumber", + ResData: model.SimDataUsageMonthlyOutputItem{}, + } + continue + } + + itemResult := gconv.String(batchItem.Result) + dataAmount, amountErr := parseDXBatchAmount(batchItem) + if itemResult == "1" && amountErr != nil { + results[item.index] = &model.SimBaseRes[model.SimDataUsageMonthlyOutputItem]{SimKey: item.simKey, Code: consts.ApiStatusError, Message: amountErr.Error()} + continue + } + if itemResult != "1" { + results[item.index] = &model.SimBaseRes[model.SimDataUsageMonthlyOutputItem]{ + SimKey: item.simKey, + Code: telecomBatchResultCode(itemResult), + Message: firstNonEmpty(resp.Message, resp.Msg, "telecom batch monthly usage query failed"), + ResData: model.SimDataUsageMonthlyOutputItem{ + DeadLine: queryMonth, + DataAmount: dataAmount, + }, + } + continue + } + + results[item.index] = &model.SimBaseRes[model.SimDataUsageMonthlyOutputItem]{ + SimKey: item.simKey, + Code: 0, + Message: "", + ResData: model.SimDataUsageMonthlyOutputItem{ + DeadLine: queryMonth, + DataAmount: dataAmount, + }, + } + } + } + + return compactMonthlyBatchResults(results), nil +} + +func (s *sSimDX) queryDataUsageDailyBatch(ctx context.Context, input *model.SimDataUsageDailyInput) (model.SimDataUsageDailyOutput, error) { + if err := s.ensureDXBatchConfig(); err != nil { + return nil, err + } + if input == nil || len(input.SimKeys) == 0 { + return make(model.SimDataUsageDailyOutput, 0), nil + } + queryDate := strings.TrimSpace(input.ReqData.QueryDate) + if queryDate == "" { + return nil, fmt.Errorf("queryDate is required") + } + if _, err := time.Parse("20060102", queryDate); err != nil { + return nil, fmt.Errorf("invalid queryDate: %s", queryDate) + } + if !dxBatchDayAvailable(queryDate, time.Now()) { + return nil, fmt.Errorf("电信日批量接口仅支持最近7个已结束日期") + } + + results := make(model.SimDataUsageDailyOutput, len(input.SimKeys)) + pending := make([]telecomBatchKey, 0, len(input.SimKeys)) + for i, simKey := range input.SimKeys { + if simKey == nil || strings.TrimSpace(simKey.MSISDN) == "" { + results[i] = &model.SimBaseRes[model.SimDataUsageDailyOutputItem]{ + SimKey: simKey, + Code: consts.ApiStatusError, + Message: "MSISDN not found", + ResData: model.SimDataUsageDailyOutputItem{QueryDate: queryDate}, + } + continue + } + pending = append(pending, telecomBatchKey{ + index: i, + simKey: simKey, + accessNumber: strings.TrimSpace(simKey.MSISDN), + }) + } + + for _, chunk := range chunkTelecomBatchKeys(pending, dxBatchUsageLimit) { + accessNumbers := make([]string, 0, len(chunk)) + for _, item := range chunk { + accessNumbers = append(accessNumbers, item.accessNumber) + } + + resp, err := s.doDXBatchFlowPost(ctx, "/api/v1/batchQry/batchQryFlowByDate", dxBatchRequest{ + AccessNumbers: accessNumbers, + CustNumber: s.custNumber, + FlowDate: queryDate, + }) + if err != nil { + for _, item := range chunk { + results[item.index] = &model.SimBaseRes[model.SimDataUsageDailyOutputItem]{ + SimKey: item.simKey, + Code: consts.ApiStatusError, + Message: err.Error(), + ResData: model.SimDataUsageDailyOutputItem{QueryDate: queryDate}, + } + } + continue + } + + itemMap := make(map[string]dxBatchFlowItem, len(resp.Data)) + for _, item := range resp.Data { + accessNumber := telecomBatchAccessNumber(item.AccessNumber, item.AccNum) + if accessNumber == "" { + continue + } + itemMap[accessNumber] = item + } + + for _, item := range chunk { + batchItem, ok := itemMap[item.accessNumber] + if !ok { + results[item.index] = &model.SimBaseRes[model.SimDataUsageDailyOutputItem]{ + SimKey: item.simKey, + Code: consts.ApiStatusError, + Message: "batch response missing accessNumber", + ResData: model.SimDataUsageDailyOutputItem{QueryDate: queryDate}, + } + continue + } + + itemResult := gconv.String(batchItem.Result) + dataAmount, amountErr := parseDXBatchAmount(batchItem) + if itemResult == "1" && amountErr != nil { + results[item.index] = &model.SimBaseRes[model.SimDataUsageDailyOutputItem]{SimKey: item.simKey, Code: consts.ApiStatusError, Message: amountErr.Error()} + continue + } + batchQueryDate := firstNonEmpty(strings.TrimSpace(batchItem.FlowDate), strings.TrimSpace(batchItem.FlowDay)) + if batchQueryDate == "" { + batchQueryDate = queryDate + } + if batchQueryDate != queryDate { + results[item.index] = &model.SimBaseRes[model.SimDataUsageDailyOutputItem]{SimKey: item.simKey, Code: consts.ApiStatusError, Message: "batch response date mismatched"} + continue + } + if itemResult != "1" { + results[item.index] = &model.SimBaseRes[model.SimDataUsageDailyOutputItem]{ + SimKey: item.simKey, + Code: telecomBatchResultCode(itemResult), + Message: firstNonEmpty(resp.Message, resp.Msg, "telecom batch daily usage query failed"), + ResData: model.SimDataUsageDailyOutputItem{ + QueryDate: batchQueryDate, + DataAmount: dataAmount, + }, + } + continue + } + + results[item.index] = &model.SimBaseRes[model.SimDataUsageDailyOutputItem]{ + SimKey: item.simKey, + Code: 0, + Message: "", + ResData: model.SimDataUsageDailyOutputItem{ + QueryDate: batchQueryDate, + DataAmount: dataAmount, + }, + } + } + } + + return compactDailyBatchResults(results), nil +} + +func (s *sSimDX) ensureDXBatchConfig() error { + if strings.TrimSpace(s.custNumber) == "" { + return fmt.Errorf("telecom custNumber not configured") + } + return nil +} + +var dxBatchLocation = time.FixedZone("UTC+8", 8*60*60) + +func parseDXBatchAmount(item dxBatchFlowItem) (float64, error) { + amount, err := strconv.ParseFloat(strings.TrimSpace(gconv.String(item.FlowAmount)), 64) + if err != nil || math.IsNaN(amount) || math.IsInf(amount, 0) || amount < 0 { + return 0, fmt.Errorf("invalid telecom batch flowAmount") + } + if unit := strings.ToUpper(strings.TrimSpace(item.Unit)); unit != "" && unit != "MB" { + return 0, fmt.Errorf("unexpected telecom batch usage unit: %s", unit) + } + return amount, nil +} + +func dailyBatchOutputSuccessful(input *model.SimDataUsageDailyInput, output model.SimDataUsageDailyOutput) bool { + if input == nil || len(output) != len(input.SimKeys) { + return false + } + queryDate := strings.TrimSpace(input.ReqData.QueryDate) + for _, item := range output { + if item == nil || item.Code != 0 || item.ResData.QueryDate != queryDate { + return false + } + } + return true +} + +func dxBatchDayAvailable(queryDate string, now time.Time) bool { + date, err := time.ParseInLocation("20060102", strings.TrimSpace(queryDate), dxBatchLocation) + if err != nil { + return false + } + now = now.In(dxBatchLocation) + today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, dxBatchLocation) + // CTIOT_5GCMP_UQ013 only accepts one completed day within the latest 7 days. + return !date.Before(today.AddDate(0, 0, -7)) && date.Before(today) +} + +func dxBatchMonthAvailable(queryMonth string, now time.Time) bool { + month, err := time.ParseInLocation("200601", strings.TrimSpace(queryMonth), dxBatchLocation) + if err != nil { + return false + } + now = now.In(dxBatchLocation) + // CTIOT_5GCMP_UQ014 is explicitly a current-month batch endpoint. + return month.Year() == now.Year() && month.Month() == now.Month() +} + +func (s *sSimDX) doDXBatchPost(ctx context.Context, endpoint string, req dxBatchRequest) ([]byte, error) { + body, err := json.Marshal(req) + if err != nil { + return nil, err + } + return s.client.DoPostJSONRequest(ctx, endpoint, body) +} + +func (s *sSimDX) doDXBatchStatusPost(ctx context.Context, endpoint string, req dxBatchRequest) (*dxBatchStatusResponse, error) { + respBody, err := s.doDXBatchPost(ctx, endpoint, req) + if err != nil { + return nil, err + } + var resp dxBatchStatusResponse + if err := json.Unmarshal(respBody, &resp); err != nil { + return nil, err + } + if gconv.String(resp.Code) != "0" { + return nil, fmt.Errorf("telecom batch status request failed: code=%s message=%s", gconv.String(resp.Code), firstNonEmpty(resp.Message, resp.Msg)) + } + return &resp, nil +} + +func (s *sSimDX) doDXBatchFlowPost(ctx context.Context, endpoint string, req dxBatchRequest) (*dxBatchFlowResponse, error) { + respBody, err := s.doDXBatchPost(ctx, endpoint, req) + if err != nil { + return nil, err + } + var resp dxBatchFlowResponse + if err := json.Unmarshal(respBody, &resp); err != nil { + return nil, err + } + if gconv.String(resp.Code) != "0" { + return nil, fmt.Errorf("telecom batch flow request failed: code=%s message=%s", gconv.String(resp.Code), firstNonEmpty(resp.Message, resp.Msg)) + } + return &resp, nil +} + +func chunkTelecomBatchKeys(items []telecomBatchKey, size int) [][]telecomBatchKey { + if size <= 0 { + size = 1 + } + if len(items) == 0 { + return nil + } + chunks := make([][]telecomBatchKey, 0, (len(items)+size-1)/size) + for start := 0; start < len(items); start += size { + end := start + size + if end > len(items) { + end = len(items) + } + chunks = append(chunks, items[start:end]) + } + return chunks +} + +func telecomBatchAccessNumber(values ...string) string { + for _, value := range values { + if value = strings.TrimSpace(value); value != "" { + return value + } + } + return "" +} + +func telecomBatchResultCode(result string) int { + result = strings.TrimSpace(result) + if result == "" { + return consts.ApiStatusError + } + return gconv.Int(result) +} + +func firstNonEmpty(values ...string) string { + for _, value := range values { + if strings.TrimSpace(value) != "" { + return value + } + } + return "" +} + +func compactStatusBatchResults(items model.SimCardStatusOutput) model.SimCardStatusOutput { + output := make(model.SimCardStatusOutput, 0, len(items)) + for _, item := range items { + if item != nil { + output = append(output, item) + } + } + return output +} + +func compactMonthlyBatchResults(items model.SimDataUsageMonthlyOutput) model.SimDataUsageMonthlyOutput { + output := make(model.SimDataUsageMonthlyOutput, 0, len(items)) + for _, item := range items { + if item != nil { + output = append(output, item) + } + } + return output +} + +func compactDailyBatchResults(items model.SimDataUsageDailyOutput) model.SimDataUsageDailyOutput { + output := make(model.SimDataUsageDailyOutput, 0, len(items)) + for _, item := range items { + if item != nil { + output = append(output, item) + } + } + return output +} diff --git a/internal/logic/upstreamimpl/sim_dx_network_block.go b/internal/logic/upstreamimpl/sim_dx_network_block.go new file mode 100644 index 0000000..8cacd51 --- /dev/null +++ b/internal/logic/upstreamimpl/sim_dx_network_block.go @@ -0,0 +1,36 @@ +package upstreamimpl + +import ( + "git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/model" + "strings" +) + +func dxSessionAccessMode(netModel string, accessType string) string { + switch strings.TrimSpace(netModel) { + case "1": + return "4G" + case "2": + return "5G" + default: + return strings.TrimSpace(accessType) + } +} + +func dxNetworkBlockStatus(name string, types []model.SimNetBlockType) *string { + var status string + switch strings.TrimSpace(name) { + case "未断网": + status = "0" + case "已断网": + status = "1" + default: + return nil + } + for _, item := range types { + blockType := strings.TrimSpace(item.BlockType) + if (status == "0" && blockType != "" && blockType != "0") || (status == "1" && blockType == "0") { + return nil + } + } + return &status +} diff --git a/internal/logic/upstreamimpl/sim_dx_network_block_test.go b/internal/logic/upstreamimpl/sim_dx_network_block_test.go new file mode 100644 index 0000000..3c54f7e --- /dev/null +++ b/internal/logic/upstreamimpl/sim_dx_network_block_test.go @@ -0,0 +1,68 @@ +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" +) + +func TestDXNetworkBlockStatus(t *testing.T) { + tests := []struct { + name string + types []model.SimNetBlockType + want string + }{ + {name: "未断网", types: []model.SimNetBlockType{{BlockType: "0"}}, want: "0"}, + {name: "已断网", types: []model.SimNetBlockType{{BlockType: "1", BlockTypeName: "达量断网"}}, want: "1"}, + {name: "已断网", want: "1"}, + {name: ""}, + {name: "未知"}, + {name: "未断网", types: []model.SimNetBlockType{{BlockType: "1"}}}, + {name: "已断网", types: []model.SimNetBlockType{{BlockType: "0"}}}, + } + for index, test := range tests { + got := dxNetworkBlockStatus(test.name, test.types) + if test.want == "" { + if got != nil { + t.Fatalf("case %d: status = %q", index, *got) + } + } else if got == nil || *got != test.want { + t.Fatalf("case %d: status = %v, want %s", index, got, test.want) + } + } +} + +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" { + 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"}}}) + if err != nil || len(output) != 1 { + t.Fatalf("output = %v, err = %v", output, err) + } + item := output[0] + if item.Code != 0 || item.ResData.CardStatus != 2 || item.ResData.NetBlockStatus == nil || *item.ResData.NetBlockStatus != "1" || len(item.ResData.NetBlockTypes) != 1 || item.ResData.NetBlockTypes[0].BlockTypeName != "达量断网" { + t.Fatalf("item = %+v", item) + } +} + +func TestDXSessionAccessMode(t *testing.T) { + if got := dxSessionAccessMode("1", ""); got != "4G" { + t.Fatalf("mode = %s", got) + } + if got := dxSessionAccessMode("2", ""); got != "5G" { + t.Fatalf("mode = %s", got) + } + if got := dxSessionAccessMode("", "NB-IOT"); got != "NB-IOT" { + t.Fatalf("mode = %s", got) + } +} diff --git a/internal/logic/upstreamimpl/sim_dx_status_test.go b/internal/logic/upstreamimpl/sim_dx_status_test.go new file mode 100644 index 0000000..557b604 --- /dev/null +++ b/internal/logic/upstreamimpl/sim_dx_status_test.go @@ -0,0 +1,136 @@ +package upstreamimpl + +import ( + "context" + "net/http" + "net/http/httptest" + "reflect" + "testing" + + "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" +) + +func TestMapDxRegionLimitStatusByOrder(t *testing.T) { + tests := []struct { + name string + orderStatus string + limitStatus string + want string + }{ + {name: "not ordered", orderStatus: "1", limitStatus: "0", want: "2"}, + {name: "ordered and disconnected", orderStatus: "0", limitStatus: "0", want: "1"}, + {name: "ordered and connected", orderStatus: "0", limitStatus: "1", want: "0"}, + {name: "network unknown", orderStatus: "0", limitStatus: "-", want: "2"}, + {name: "unexpected values", orderStatus: "", limitStatus: "", want: "2"}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + if got := mapDxRegionLimitStatusByOrder(test.orderStatus, test.limitStatus); got != test.want { + t.Fatalf("mapDxRegionLimitStatusByOrder(%q, %q) = %q, want %q", test.orderStatus, test.limitStatus, got, test.want) + } + }) + } +} + +func TestNormalizeDxRegionLimitDetails(t *testing.T) { + tests := []struct { + name string + orderStatus string + limitStatus string + limitType string + limitArea string + want []string + }{ + {name: "not ordered hides details", orderStatus: "1", limitStatus: "0", limitType: "4", limitArea: "park", want: []string{"", "", ""}}, + {name: "ordered preserves documented values", orderStatus: "0", limitStatus: "-", limitType: "4", limitArea: " park ", want: []string{"-", "4", "park"}}, + {name: "ordered rejects undocumented values", orderStatus: "0", limitStatus: "9", limitType: "0", limitArea: "", want: []string{"", "", ""}}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + limitStatus, limitType, limitArea := normalizeDxRegionLimitDetails(test.orderStatus, test.limitStatus, test.limitType, test.limitArea) + if got := []string{limitStatus, limitType, limitArea}; !reflect.DeepEqual(got, test.want) { + t.Fatalf("normalizeDxRegionLimitDetails() = %#v, want %#v", got, test.want) + } + }) + } +} + +func TestQuerySimRegionLimitStatusUsesDocumentedParameter(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + 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" { + t.Fatalf("accNum = %q", got) + } + if got := r.URL.Query().Get("access_number"); got != "" { + t.Fatalf("access_number = %q, want empty", got) + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"code":"0","message":"ok","data":{"orderStatus":1,"limitType":0,"limitArea":"","limitStatus":"0"}}`)) + })) + 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"}}}) + if err != nil { + t.Fatal(err) + } + if len(output) != 1 { + t.Fatalf("results = %d", len(output)) + } + data := output[0].ResData + if data.RegionLimitStatus != "2" || data.OrderStatus != "1" || data.LimitStatus != "" || data.LimitType != "" || data.LimitArea != "" { + t.Fatalf("result = %+v", data) + } +} + +func TestQuerySimCardStatusPreservesTelecomHTTPRespCode(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/openapi/v1/prodinst/queryCardMainStatus" { + t.Fatalf("path = %q", r.URL.Path) + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(797) + _, _ = w.Write([]byte(`{"respCode":"S00004","respMsg":"request limit exceeded"}`)) + })) + 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"}}}) + if err != nil { + t.Fatal(err) + } + if len(output) != 1 { + t.Fatalf("results = %d", len(output)) + } + result := output[0] + if result.Code != consts.ApiStatusError || result.ErrCode != "S00004" { + t.Fatalf("result = %+v, want gateway code with S00004", result) + } +} + +func TestQuerySimCardStatusPreservesTelecomBusinessCode(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"result":"11011","resultMsg":"card does not exist"}`)) + })) + 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"}}}) + if err != nil { + t.Fatal(err) + } + if len(output) != 1 { + t.Fatalf("results = %d", len(output)) + } + result := output[0] + if result.Code != 11011 || result.ErrCode != "11011" { + t.Fatalf("result = %+v, want business code 11011", result) + } +} diff --git a/internal/logic/upstreamimpl/sim_dx_usage_test.go b/internal/logic/upstreamimpl/sim_dx_usage_test.go new file mode 100644 index 0000000..743c0f1 --- /dev/null +++ b/internal/logic/upstreamimpl/sim_dx_usage_test.go @@ -0,0 +1,205 @@ +package upstreamimpl + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "sync/atomic" + "testing" + "time" + + "git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/logic/upstreamimpl/dx" + "git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/model" +) + +func TestDXBatchDayAvailable(t *testing.T) { + now := time.Date(2026, 9, 20, 12, 0, 0, 0, dxBatchLocation) + tests := map[string]bool{ + "20260913": true, + "20260919": true, + "20260912": false, + "20260920": false, + "20260921": false, + } + for queryDate, want := range tests { + if got := dxBatchDayAvailable(queryDate, now); got != want { + t.Fatalf("dxBatchDayAvailable(%q) = %v, want %v", queryDate, got, want) + } + } +} + +func TestDXBatchMonthAvailable(t *testing.T) { + now := time.Date(2026, 9, 20, 12, 0, 0, 0, dxBatchLocation) + if !dxBatchMonthAvailable("202609", now) { + t.Fatal("current month should use telecom batch API") + } + if dxBatchMonthAvailable("202608", now) || dxBatchMonthAvailable("invalid", now) { + t.Fatal("historical or invalid month must not use telecom current-month batch API") + } +} + +func TestQuerySimDataUsageMonthlyUsesBatchForCurrentMonth(t *testing.T) { + var calls atomic.Int32 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + calls.Add(1) + if r.URL.Path != "/api/v1/batchQry/batchQryFlowByMonth" { + t.Fatalf("path = %q", r.URL.Path) + } + var request dxBatchRequest + if err := json.NewDecoder(r.Body).Decode(&request); err != nil { + t.Fatal(err) + } + 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"}]}`)) + })) + 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"}}, + ReqData: model.SimDataUsageMonthlyInputItem{QueryDate: queryMonth}, + }) + if err != nil { + t.Fatal(err) + } + if calls.Load() != 1 || len(output) != 2 || output[0].ResData.DataAmount != 12.5 || output[1].ResData.DataAmount != 7.5 { + t.Fatalf("calls=%d output=%+v", calls.Load(), output) + } +} + +func TestQuerySimDataUsageDailyLegacyFallbackIsBounded(t *testing.T) { + var active atomic.Int32 + var maximum atomic.Int32 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + current := active.Add(1) + defer active.Add(-1) + for { + previous := maximum.Load() + if current <= previous || maximum.CompareAndSwap(previous, current) { + break + } + } + time.Sleep(30 * time.Millisecond) + w.Header().Set("Content-Type", "application/xml") + _, _ = w.Write([]byte(`0OK1MB`)) + })) + defer server.Close() + + 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"} + } + output, err := sim.QuerySimDataUsageDaily(context.Background(), &model.SimDataUsageDailyInput{ + SimKeys: keys, + ReqData: model.SimDataUsageDailyInputItem{QueryDate: "20260101"}, + }) + if err != nil { + t.Fatal(err) + } + if len(output) != len(keys) { + t.Fatalf("results = %d, want %d", len(output), len(keys)) + } + if maximum.Load() > dxLegacyUsageConcurrency || maximum.Load() < 2 { + t.Fatalf("maximum concurrency = %d, want 2..%d", maximum.Load(), dxLegacyUsageConcurrency) + } +} + +func TestQuerySimDataUsageDailyRejectsMalformedXML(t *testing.T) { + cases := []struct { + name string + body string + }{ + {name: "missing business node", body: `denied`}, + {name: "missing result", body: `12MB`}, + {name: "missing amount", body: `0`}, + {name: "invalid amount", body: `0invalidMB`}, + {name: "non numeric result", body: `ERROR`}, + } + for _, item := range cases { + t.Run(item.name, func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + writer.Header().Set("Content-Type", "application/xml") + _, _ = writer.Write([]byte(item.body)) + })) + defer server.Close() + + 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"}}, + ReqData: model.SimDataUsageDailyInputItem{QueryDate: "20260101"}, + }) + if err != nil { + t.Fatal(err) + } + if len(output) != 1 || output[0].Code == 0 { + t.Fatalf("malformed response accepted: %+v", output) + } + }) + } +} + +func TestQuerySimDataUsageDailyBatchFailureFallsBackToLegacy(t *testing.T) { + var batchCalls atomic.Int32 + var singleCalls atomic.Int32 + server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + if request.URL.Path == "/api/v1/batchQry/batchQryFlowByDate" { + batchCalls.Add(1) + writer.Header().Set("Content-Type", "application/json") + _, _ = writer.Write([]byte(`{"code":403,"msg":"denied"}`)) + return + } + singleCalls.Add(1) + writer.Header().Set("Content-Type", "application/xml") + _, _ = writer.Write([]byte(`012MB`)) + })) + defer server.Close() + + 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"}}, + ReqData: model.SimDataUsageDailyInputItem{QueryDate: queryDate}, + }) + if err != nil { + t.Fatal(err) + } + if batchCalls.Load() != 1 || singleCalls.Load() != 1 { + t.Fatalf("batch calls=%d, single calls=%d, want 1 each", batchCalls.Load(), singleCalls.Load()) + } + if len(output) != 1 || output[0].Code != 0 || output[0].ResData.DataAmount != 12 { + t.Fatalf("unexpected fallback output: %+v", output) + } +} + +func TestParseDXDataUsage(t *testing.T) { + cases := []struct { + value string + want float64 + invalid bool + }{ + {value: "0MB", want: 0}, + {value: "12.5MB", want: 12.5}, + {value: " 1 gb ", want: 1024}, + {value: "1024KB", want: 1}, + {value: "12", want: 12}, + {value: "", invalid: true}, + {value: "invalidMB", invalid: true}, + {value: "-1MB", invalid: true}, + {value: "NaNMB", invalid: true}, + {value: "InfMB", invalid: true}, + {value: "1e308GB", invalid: true}, + } + for _, item := range cases { + t.Run(item.value, func(t *testing.T) { + amount, err := parseDXDataUsage(item.value) + if (err != nil) != item.invalid || (!item.invalid && amount != item.want) { + t.Fatalf("parseDXDataUsage(%q) = %v, %v, want %v, invalid=%v", item.value, amount, err, item.want, item.invalid) + } + }) + } +} diff --git a/internal/logic/upstreamimpl/sim_dx_v3.go b/internal/logic/upstreamimpl/sim_dx_v3.go new file mode 100644 index 0000000..34139de --- /dev/null +++ b/internal/logic/upstreamimpl/sim_dx_v3.go @@ -0,0 +1,568 @@ +package upstreamimpl + +import ( + "bytes" + "context" + "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" + + "github.com/gogf/gf/v2/frame/g" +) + +type sSimDXV3 struct { + client *dx.Client + custNbr string + enableOperations bool +} + +func init() { + service.UpstreamAPI().RegisterTemp("sim_dx_v3", &sSimDXV3{}) +} + +func (s *sSimDXV3) NewTemp(ctx context.Context, cfg *model.SimChannelConfigItemCache) model.SimCardChannelI { + if cfg == nil || cfg.Channel == nil || cfg.Template == nil { + return nil + } + var template struct { + API struct { + BaseURL string `json:"baseUrl"` + EnableOperations bool `json:"enableOperations"` + } `json:"api"` + } + var channel struct { + CustNbr string `json:"custNbr"` + CustNumber string `json:"custNumber"` + } + channelConfig := strings.TrimSpace(cfg.Channel.ChanConfig) + if channelConfig == "" { + channelConfig = "{}" + } + if json.Unmarshal([]byte(cfg.Template.TempConfig), &template) != nil || json.Unmarshal([]byte(channelConfig), &channel) != nil { + g.Log().Error(ctx, "invalid telecom v3 JSON configuration") + return nil + } + baseURL := strings.TrimRight(strings.TrimSpace(template.API.BaseURL), "/") + if baseURL == "" { + baseURL = dx.DefaultBaseURL + } + u, err := url.Parse(baseURL) + if err != nil || u.Host == "" || (u.Scheme != "https" && u.Scheme != "http") || u.RawQuery != "" || u.Fragment != "" || u.User != nil || (u.Path != "" && u.Path != "/") { + g.Log().Error(ctx, "telecom v3 baseUrl must be a gateway origin") + return nil + } + if strings.TrimSpace(cfg.Channel.AppKey) == "" || strings.TrimSpace(cfg.Channel.AppSecret) == "" { + g.Log().Error(ctx, "telecom v3 credentials are incomplete") + return nil + } + return &sSimDXV3{ + client: dx.NewClient(dx.Config{BaseURL: baseURL, AppKey: cfg.Channel.AppKey, AppSecret: cfg.Channel.AppSecret, + Timeout: resolveUpstreamHTTPTimeout(ctx, cfg.Template.TempConfig), UppercaseSign: true}), + custNbr: firstNonEmpty(channel.CustNbr, channel.CustNumber), enableOperations: template.API.EnableOperations, + } +} + +type dxV3Error struct{ code, message string } + +func (e *dxV3Error) Error() string { return e.message } + +func (s *sSimDXV3) request(ctx context.Context, path string, params url.Values, body any) (map[string]any, error) { + var data []byte + var err error + if body == nil { + data, err = s.client.DoGetRequest(ctx, "/api/v3/"+path, params) + } else { + data, err = json.Marshal(body) + if err == nil { + data, err = s.client.DoPostJSONRequest(ctx, "/api/v3/"+path, data) + } + } + if err != nil { + return nil, err + } + var envelope struct { + Code json.RawMessage `json:"code"` + Message string `json:"message"` + Result json.RawMessage `json:"result"` + } + if err = json.Unmarshal(data, &envelope); err != nil { + return nil, fmt.Errorf("invalid telecom v3 JSON response: %w", err) + } + code := dxV3RawCode(envelope.Code) + if code == "" { + return nil, fmt.Errorf("telecom v3 response is missing code") + } + if code != "0" { + return nil, &dxV3Error{code: code, message: firstNonEmpty(envelope.Message, "telecom v3 business error")} + } + var result map[string]any + decoder := json.NewDecoder(bytes.NewReader(envelope.Result)) + decoder.UseNumber() + if err = decoder.Decode(&result); err != nil { + return nil, fmt.Errorf("invalid telecom v3 result: %w", err) + } + if result == nil { + return nil, fmt.Errorf("telecom v3 response has no result") + } + return result, nil +} + +func dxV3RawCode(raw json.RawMessage) string { + if len(raw) == 0 || strings.TrimSpace(string(raw)) == "null" { + return "" + } + var str string + if json.Unmarshal(raw, &str) == nil { + return strings.TrimSpace(str) + } + var number json.Number + if json.Unmarshal(raw, &number) == nil { + return number.String() + } + return "" +} + +func dxV3String(item map[string]any, name string) string { + switch value := item[name].(type) { + case string: + return strings.TrimSpace(value) + case json.Number: + return value.String() + default: + return "" + } +} + +func dxV3Params(key *model.SimKey) (url.Values, error) { + if key != nil { + for _, value := range []struct{ name, value string }{{"msisdn", key.MSISDN}, {"iccid", key.ICCID}, {"imsi", key.IMSI}} { + if strings.TrimSpace(value.value) != "" { + return url.Values{value.name: {strings.TrimSpace(value.value)}}, nil + } + } + } + return nil, fmt.Errorf("missing msisdn/iccid/imsi") +} + +func dxV3Failure[T any](key *model.SimKey, err error) *model.SimBaseRes[T] { + item := &model.SimBaseRes[T]{SimKey: key, Code: consts.ApiStatusError, Message: err.Error()} + var business *dxV3Error + var httpError *dx.HTTPStatusError + if errors.As(err, &business) { + item.ErrCode = business.code + if code, parseErr := strconv.Atoi(business.code); parseErr == nil && code != 0 { + item.Code = code + } + } else if errors.As(err, &httpError) { + item.ErrCode = httpError.RespCode + } + return item +} + +// dxV3LegacyUsageConcurrency 是 v3 逐卡回退查询的并发度,与 v1 保持一致, +// 避免多卡查询退化成串行 N 次请求。 +const dxV3LegacyUsageConcurrency = 5 + +func dxV3QueryOne[T any](ctx context.Context, s *sSimDXV3, key *model.SimKey, path string, + prepare func(url.Values) error, convert func(map[string]any) (T, error), post bool) *model.SimBaseRes[T] { + params, err := dxV3Params(key) + if err == nil && prepare != nil { + err = prepare(params) + } + var result map[string]any + if err == nil { + var body any + if post { + values := make(map[string]string, len(params)) + for name := range params { + values[name] = params.Get(name) + } + body = values + } + result, err = s.request(ctx, path, params, body) + } + if err == nil { + for _, identity := range []struct{ name, value string }{{"msisdn", key.MSISDN}, {"iccid", key.ICCID}, {"imsi", key.IMSI}} { + if returned := dxV3String(result, identity.name); returned != "" && identity.value != "" && returned != strings.TrimSpace(identity.value) { + err = fmt.Errorf("telecom v3 returned a mismatched card identifier") + break + } + } + } + var output T + if err == nil { + output, err = convert(result) + } + if err != nil { + return dxV3Failure[T](key, err) + } + return &model.SimBaseRes[T]{SimKey: key, ResData: output} +} + +func dxV3Query[T any](ctx context.Context, s *sSimDXV3, keys []*model.SimKey, path string, + prepare func(url.Values) error, convert func(map[string]any) (T, error), post bool) []*model.SimBaseRes[T] { + items := make([]*model.SimBaseRes[T], 0, len(keys)) + for _, key := range keys { + items = append(items, dxV3QueryOne(ctx, s, key, path, prepare, convert, post)) + } + return items +} + +// dxV3QueryConcurrent 与 dxV3Query 结果完全一致(保持输入顺序),只是并行发出单卡请求。 +func dxV3QueryConcurrent[T any](ctx context.Context, s *sSimDXV3, keys []*model.SimKey, path string, + prepare func(url.Values) error, convert func(map[string]any) (T, error), post bool) []*model.SimBaseRes[T] { + results := make([]*model.SimBaseRes[T], len(keys)) + if len(keys) == 0 { + return results + } + jobs := make(chan int) + var group sync.WaitGroup + workers := min(dxV3LegacyUsageConcurrency, len(keys)) + for worker := 0; worker < workers; worker++ { + group.Add(1) + go func() { + defer group.Done() + for index := range jobs { + results[index] = dxV3QueryOne(ctx, s, keys[index], path, prepare, convert, post) + } + }() + } + for index := range keys { + jobs <- index + } + close(jobs) + group.Wait() + return results +} + +// dxV3BatchThenFill 让批量接口只承担"加速",正确性以单卡查询为基准: +// 先按批量取一轮结果,再把其中失败或缺失的卡用单卡并发查询补齐,保留批量成功的项。 +// 这样批量级故障(鉴权、报文、网关错误)或某张卡被批量接口遗漏时,不会整批/逐卡报废; +// 批量整体不可用(参数/窗口/报文异常)时直接退化为逐卡并发,不丢卡、不错位。 +// 本方法只用于查询类接口(幂等),重复一次调用是安全的。 +func dxV3BatchThenFill[T any](ctx context.Context, s *sSimDXV3, keys []*model.SimKey, path string, + prepare func(url.Values) error, convert func(map[string]any) (T, error), + batch func() ([]*model.SimBaseRes[T], error)) []*model.SimBaseRes[T] { + results, err := batch() + if err != nil || len(results) != len(keys) { + return dxV3QueryConcurrent(ctx, s, keys, path, prepare, convert, false) + } + retryKeys := make([]*model.SimKey, 0, len(keys)) + retryIndex := make([]int, 0, len(keys)) + for index, item := range results { + if item == nil || item.Code != consts.ApiStatusSuccess { + retryKeys = append(retryKeys, keys[index]) + retryIndex = append(retryIndex, index) + } + } + if len(retryKeys) == 0 { + return results + } + filled := dxV3QueryConcurrent(ctx, s, retryKeys, path, prepare, convert, false) + for offset, index := range retryIndex { + results[index] = filled[offset] + } + return results +} + +func dxV3BasicInfo(item map[string]any) (model.SimBasicInfoItem, error) { + output := model.SimBasicInfoItem{ICCID: dxV3String(item, "iccid"), IMSI: dxV3String(item, "imsi"), MSISDN: dxV3String(item, "msisdn"), + OpenDate: dxV3String(item, "createTime"), ActiveDate: dxV3String(item, "activeTime"), SimStatus: dxV3String(item, "simStatus"), + MainOfferName: firstNonEmpty(dxV3String(item, "mainPackageName"), dxV3String(item, "prodName")), CardBindingType: dxV3String(item, "cardBindingType"), + IsDirectVisit: dxV3String(item, "isDirectVisit"), IsHumanLimit: dxV3String(item, "isHumanLimit"), VpdnGreDoamin: dxV3String(item, "vpdnGreDoamin"), + VpdnL2tpDoamin: dxV3String(item, "vpdnL2tpDoamin"), AreaLimitType: dxV3String(item, "areaLimitType"), AreaLimitInfo: dxV3String(item, "areaLimitInfo")} + if output.ICCID == "" && output.MSISDN == "" && output.IMSI == "" { + return output, fmt.Errorf("telecom v3 basic info is missing card identifiers") + } + output.StopTypeList = normalizeDXStringList(item["stopTypeList"]) + return output, nil +} + +func (s *sSimDXV3) QuerySimBasicInfo(ctx context.Context, input *model.SimBasicInfoInput) (model.SimBasicInfoOutput, error) { + return dxV3Query(ctx, s, input.SimKeys, "getSimInfo", nil, dxV3BasicInfo, false), nil +} + +func dxV3Status(item map[string]any) (model.SimCardStatusItem, error) { + status := dxV3String(item, "simStatus") + switch status { + case "1", "2", "3", "4", "5", "6", "7": + default: + return model.SimCardStatusItem{}, fmt.Errorf("invalid telecom v3 simStatus: %s", status) + } + return model.SimCardStatusItem{CardStatus: convertDxCardStatus(context.Background(), status), LastChangeDate: dxV3String(item, "lastChangeTime")}, nil +} + +func (s *sSimDXV3) QuerySimCardStatus(ctx context.Context, input *model.SimCardStatusInput) (model.SimCardStatusOutput, error) { + return dxV3Query(ctx, s, input.SimKeys, "getSimStatus", nil, dxV3Status, false), nil +} + +func (s *sSimDXV3) QuerySimSession(ctx context.Context, input *model.SimSessionInput) (model.SimSessionOutput, error) { + return dxV3Query(ctx, s, input.SimKeys, "getOnlineStatus", nil, func(item map[string]any) (model.SimSessionItem, error) { + status := consts.CardSessionStatusUnknown + switch dxV3String(item, "onlineStatus") { + case "1": + status = consts.CardSessionStatusOnline + case "2": + status = consts.CardSessionStatusOffline + } + return model.SimSessionItem{Status: status, ApnId: dxV3String(item, "apnName"), Imei: dxV3String(item, "imei"), Rat: dxV3String(item, "ratType"), + Ipv4: dxV3String(item, "ipv4Address"), Ipv6: dxV3String(item, "ipv6Address"), Ipv6Prefix: dxV3String(item, "ipv6PrefixLen"), + CreateTime: firstNonEmpty(dxV3String(item, "startTime"), dxV3String(item, "eventTime"))}, nil + }, false), nil +} + +func (s *sSimDXV3) QuerySimImei(ctx context.Context, input *model.SimImeiInput) (model.SimImeiOutput, error) { + return dxV3Query(ctx, s, input.SimKeys, "getImei", nil, func(item map[string]any) (model.SimImeiItem, error) { + return model.SimImeiItem{Imei: dxV3String(item, "imei")}, nil + }, false), nil +} + +func dxV3Amount(item map[string]any) (float64, error) { + value := firstNonEmpty(dxV3String(item, "dataAmount"), dxV3String(item, "totalUsageAmount")) + amount, err := strconv.ParseFloat(value, 64) + if err != nil || math.IsNaN(amount) || math.IsInf(amount, 0) || amount < 0 { + return 0, fmt.Errorf("invalid telecom v3 dataAmount") + } + if unit := strings.ToUpper(dxV3String(item, "unit")); unit != "" && unit != "MB" { + return 0, fmt.Errorf("unexpected telecom v3 usage unit: %s", unit) + } + return amount, nil +} + +func (s *sSimDXV3) QuerySimDataUsage(ctx context.Context, input *model.SimDataUsageInput) (model.SimDataUsageOutput, error) { + return dxV3Query(ctx, s, input.SimKeys, "getDataUsage", nil, func(item map[string]any) (model.SimDataUsageItem, error) { + amount, err := dxV3Amount(item) + return model.SimDataUsageItem{DataUsage: amount}, err + }, false), nil +} + +func (s *sSimDXV3) QuerySimDataUsageMonthly(ctx context.Context, input *model.SimDataUsageMonthlyInput) (model.SimDataUsageMonthlyOutput, error) { + prepare := func(params url.Values) error { + if _, err := time.Parse("200601", input.ReqData.QueryDate); err != nil { + return fmt.Errorf("invalid queryDate: expected yyyyMM") + } + params.Set("month", input.ReqData.QueryDate) + return nil + } + convert := func(item map[string]any) (model.SimDataUsageMonthlyOutputItem, error) { + amount, err := dxV3Amount(item) + return model.SimDataUsageMonthlyOutputItem{DataAmount: amount}, err + } + if s.monthlyBatchEligible(input) { + return dxV3BatchThenFill(ctx, s, input.SimKeys, "getDataUsageByMonth", prepare, convert, + func() (model.SimDataUsageMonthlyOutput, error) { return s.QuerySimDataUsageMonthlyBatch(ctx, input) }), nil + } + if len(input.SimKeys) > 1 { + return dxV3QueryConcurrent(ctx, s, input.SimKeys, "getDataUsageByMonth", prepare, convert, false), nil + } + return dxV3Query(ctx, s, input.SimKeys, "getDataUsageByMonth", prepare, convert, false), nil +} + +// monthlyBatchEligible 判断多卡月流量查询能否升级为批量接口:需要配置 custNbr、每张卡都有 +// MSISDN,且查询月份为当月。与 v1 一致:v1 的自动批量同样只在当月生效,历史月份继续逐卡查询。 +// 任一不满足则回退逐卡(并发)查询。 +func (s *sSimDXV3) monthlyBatchEligible(input *model.SimDataUsageMonthlyInput) bool { + if input == nil || len(input.SimKeys) < 2 || strings.TrimSpace(s.custNbr) == "" { + return false + } + if !dxBatchMonthAvailable(input.ReqData.QueryDate, time.Now()) { + return false + } + for _, key := range input.SimKeys { + if key == nil || strings.TrimSpace(key.MSISDN) == "" { + return false + } + } + return true +} + +func (s *sSimDXV3) QuerySimDataUsageDaily(ctx context.Context, input *model.SimDataUsageDailyInput) (model.SimDataUsageDailyOutput, error) { + prepare := func(params url.Values) error { + day, err := time.ParseInLocation("20060102", input.ReqData.QueryDate, dxBatchLocation) + now := time.Now().In(dxBatchLocation) + today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, dxBatchLocation) + if err != nil || !day.Before(today) { + return fmt.Errorf("queryDate must be a completed day in yyyyMMdd format") + } + params.Set("beginDate", input.ReqData.QueryDate) + params.Set("endDate", input.ReqData.QueryDate) + return nil + } + convert := func(item map[string]any) (model.SimDataUsageDailyOutputItem, error) { + amount, err := dxV3Amount(item) + return model.SimDataUsageDailyOutputItem{QueryDate: input.ReqData.QueryDate, DataAmount: amount}, err + } + if s.dailyBatchEligible(input) { + return dxV3BatchThenFill(ctx, s, input.SimKeys, "getDataUsageBetweenDate", prepare, convert, + func() (model.SimDataUsageDailyOutput, error) { return s.QuerySimDataUsageDailyBatch(ctx, input) }), nil + } + if len(input.SimKeys) > 1 { + return dxV3QueryConcurrent(ctx, s, input.SimKeys, "getDataUsageBetweenDate", prepare, convert, false), nil + } + return dxV3Query(ctx, s, input.SimKeys, "getDataUsageBetweenDate", prepare, convert, false), nil +} + +// dailyBatchEligible 与 monthlyBatchEligible 同构:批量仅在已结束且落在最近七天窗口内的日期可用。 +func (s *sSimDXV3) dailyBatchEligible(input *model.SimDataUsageDailyInput) bool { + if input == nil || len(input.SimKeys) < 2 || strings.TrimSpace(s.custNbr) == "" { + return false + } + if !dxBatchDayAvailable(input.ReqData.QueryDate, time.Now()) { + return false + } + for _, key := range input.SimKeys { + if key == nil || strings.TrimSpace(key.MSISDN) == "" { + return false + } + } + return true +} + +func (s *sSimDXV3) QuerySimStopReason(ctx context.Context, input *model.SimStopReasonInput) (model.SimStopReasonOutput, error) { + return dxV3Query(ctx, s, input.SimKeys, "getSimInfo", nil, func(item map[string]any) (model.SimStopReasonItem, error) { + reason := strings.Join(trimNonEmptyStrings([]string{dxV3String(item, "stopStatusName"), dxV3String(item, "stopTypeName")}), "; ") + if reason == "" { + reason = mapDxStopTypeListText(normalizeDXStringList(item["stopTypeList"])) + } + return model.SimStopReasonItem{StopReason: reason}, nil + }, false), nil +} + +func (s *sSimDXV3) QuerySimApnInfo(ctx context.Context, input *model.SimApnInfoInput) (model.SimApnInfoOutput, error) { + return dxV3Query(ctx, s, input.SimKeys, "getApnInfo", nil, func(item map[string]any) (model.SimApnInfoItemList, error) { + list, ok := item["apnInfoList"].([]any) + if !ok { + return model.SimApnInfoItemList{}, fmt.Errorf("telecom v3 response is missing apnInfoList") + } + output := model.SimApnInfoItemList{ApnList: make([]model.SimApnInfoItem, 0, len(list))} + for _, entry := range list { + apn, ok := entry.(map[string]any) + if !ok { + return output, fmt.Errorf("invalid telecom v3 APN entry") + } + output.ApnList = append(output.ApnList, model.SimApnInfoItem{ApnName: dxV3String(apn, "apnName"), Status: dxV3String(apn, "status")}) + } + return output, nil + }, false), nil +} + +func (s *sSimDXV3) QuerySimCardBindStatus(ctx context.Context, input *model.SimCardBindStatusInput) (model.SimCardBindStatusOutput, error) { + return dxV3Query(ctx, s, input.SimKeys, "getSimBindingInfo", nil, func(item map[string]any) (model.SimCardBindStatusItem, error) { + return model.SimCardBindStatusItem{BindStatus: dxV3String(item, "simSeparationStatus"), CardBindingType: dxV3String(item, "bindType"), + OrderStatus: dxV3String(item, "isOrdered"), BindImei: dxV3String(item, "bindImei"), LastImei: dxV3String(item, "lastImei")}, nil + }, false), nil +} + +// QuerySimDeviceStatus 查询终端开关机状态(电信口径为“仅 3G”)。 +// +// 注意方法必须是 GET:官方文档把 /api/v3/getDevicePowerStatus 写成 POST,但网关实际只接受 +// GET(实测 POST 返回 799 S10010“请求方法不匹配”,GET 返回 200)。同一网关下 v1 的 +// /api/v1/prod/getDevicePowerStatus 反而只接受 POST,两者不可互推。 +// +// 响应有两种形态,需兼容键名: +// - 有记录:{"status":"0","statusName":"开机"}(文档口径,小写) +// - 无记录:{"Result":"-1","resultName":"无终端开关机信息"}(大写 R,仅 3G 接口对 4G/5G 卡常见) +func (s *sSimDXV3) QuerySimDeviceStatus(ctx context.Context, input *model.SimDeviceStatusInput) (model.SimDeviceStatusOutput, error) { + return dxV3Query(ctx, s, input.SimKeys, "getDevicePowerStatus", nil, func(item map[string]any) (model.SimDeviceStatusItem, error) { + status := firstNonEmpty(dxV3String(item, "status"), dxV3String(item, "Status"), dxV3String(item, "Result")) + return model.SimDeviceStatusItem{DeviceStatus: consts.CardSessionStatusUnknown, OperatingStatus: mapDxOperatingStatus(status)}, nil + }, false), nil +} + +func (s *sSimDXV3) QuerySimRegionLimitStatus(ctx context.Context, input *model.SimRegionLimitStatusInput) (model.SimRegionLimitStatusOutput, error) { + return dxV3Query(ctx, s, input.SimKeys, "getSimRegionLimitStatus", nil, func(item map[string]any) (model.SimRegionLimitStatusItem, error) { + order, limit := dxV3String(item, "orderStatus"), dxV3String(item, "regionLimitStatus") + return model.SimRegionLimitStatusItem{RegionLimitStatus: mapDxRegionLimitStatusByOrder(order, limit), OrderStatus: order, LimitStatus: limit, LimitType: dxV3String(item, "limitType"), LimitArea: dxV3String(item, "restrictedArea")}, nil + }, false), nil +} + +// QuerySimFrozenState 电信没有独立的冻结状态查询接口。与 v1 一致,返回成功项并附说明文案, +// 避免诊断页与通道自检把它当成上游错误(v1 返回 code=0 + “不支持查询”)。 +func (s *sSimDXV3) QuerySimFrozenState(_ context.Context, input *model.SimFrozenStateInput) (model.SimFrozenStateOutput, error) { + output := make(model.SimFrozenStateOutput, 0, len(input.SimKeys)) + for _, key := range input.SimKeys { + output = append(output, &model.SimBaseRes[model.SimFrozenStateItem]{ + SimKey: key, + Code: consts.ApiStatusSuccess, + ResData: model.SimFrozenStateItem{ + FrozenStatus: "不支持查询", + FrozenReason: "电信当前无独立冻结查询接口", + }, + }) + } + return output, nil +} + +// QuerySimDataDiagnosis 电信不提供达量限速档位查询。沿用 v1 的约定返回 ApiInvokeException, +// 让上层按“暂不支持”降级为跳过(诊断页 skipped)而不是报错。 +func (s *sSimDXV3) QuerySimDataDiagnosis(_ context.Context, input *model.SimDataDiagnosisInput) (model.SimDataDiagnosisOutput, error) { + output := make(model.SimDataDiagnosisOutput, 0, len(input.SimKeys)) + for _, key := range input.SimKeys { + output = append(output, &model.SimBaseRes[any]{ + SimKey: key, + Code: consts.ApiInvokeException, + Message: "电信官方接口暂不提供当前达量限速查询能力", + }) + } + return output, nil +} + +func dxV3Order(item map[string]any) (model.SimCardStatusChangeItem, error) { + order := dxV3String(item, "transactionId") + if order == "" { + return model.SimCardStatusChangeItem{}, fmt.Errorf("telecom v3 operation returned no transactionId") + } + return model.SimCardStatusChangeItem{OrderNum: order}, nil +} + +func (s *sSimDXV3) ChangeSimStatus(ctx context.Context, input *model.SimCardStatusChangeInput) (model.SimCardStatusChangeOutput, error) { + return dxV3Query(ctx, s, input.SimKeys, "updateSimStatus", func(params url.Values) error { + if !s.enableOperations { + return fmt.Errorf("telecom v3 operations are disabled") + } + oper, ok := convertDxCardStatusChangeOperType(ctx, input.ReqData.OperType) + if !ok { + return fmt.Errorf("unsupported status operType") + } + params.Set("operType", oper) + return nil + }, dxV3Order, false), nil +} + +func (s *sSimDXV3) ChangeSimApnFunction(ctx context.Context, input *model.SimCardOperateChangeApnInput) (model.SimCardOperateChangeApnOutput, error) { + return dxV3Query(ctx, s, input.SimKeys, "singleCutNet", func(params url.Values) error { + if !s.enableOperations { + return fmt.Errorf("telecom v3 operations are disabled") + } + // singleCutNet 是整卡数据开停语义,电信侧没有“按 APN 定向”这一维度。 + // 与 v1 保持一致:忽略调用方携带的 ApnName(预警自动断网、批量数据开停都会固定带 + // apnName="CMIOT"),不能因此拒绝请求。 + if params.Get("msisdn") == "" { + return fmt.Errorf("singleCutNet requires msisdn") + } + switch strings.TrimSpace(input.ReqData.OperType) { + case "0": + params.Set("operType", "1") + case "1": + params.Set("operType", "2") + default: + return fmt.Errorf("unsupported data operType") + } + return nil + }, func(item map[string]any) (model.SimCardOperateChangeApnItem, error) { + order, err := dxV3Order(item) + return model.SimCardOperateChangeApnItem{OrderNum: order.OrderNum}, err + }, true), 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 new file mode 100644 index 0000000..3a6c1cb --- /dev/null +++ b/internal/logic/upstreamimpl/sim_dx_v3_batch.go @@ -0,0 +1,148 @@ +package upstreamimpl + +import ( + "context" + "fmt" + "git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/model" + "strings" + "time" +) + +const dxV3BatchLimit = 100 + +// dxV3BatchMonthAvailable 电信 v3 月批量接口仅支持最近六个含当月的月份。 +func dxV3BatchMonthAvailable(month time.Time) bool { + now := time.Now().In(dxBatchLocation) + current := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, dxBatchLocation) + return !month.Before(current.AddDate(0, -5, 0)) && !month.After(current) +} + +func dxV3Batch[T any](ctx context.Context, s *sSimDXV3, keys []*model.SimKey, path, listName, dateName, date string, + convert func(map[string]any) (T, error)) []*model.SimBaseRes[T] { + output := make([]*model.SimBaseRes[T], len(keys)) + pending := make([]int, 0, len(keys)) + for index, key := range keys { + if key == nil || strings.TrimSpace(key.MSISDN) == "" { + output[index] = dxV3Failure[T](key, fmt.Errorf("telecom v3 batch requires msisdn")) + continue + } + pending = append(pending, index) + } + for offset := 0; offset < len(pending); offset += dxV3BatchLimit { + end := offset + dxV3BatchLimit + if end > len(pending) { + end = len(pending) + } + chunk := pending[offset:end] + msisdns := make([]string, 0, len(chunk)) + for _, index := range chunk { + msisdns = append(msisdns, strings.TrimSpace(keys[index].MSISDN)) + } + body := map[string]any{"msisdnList": msisdns, "custNbr": s.custNbr} + if dateName != "" { + body[dateName] = date + } + var err error + var result map[string]any + if s.custNbr == "" { + err = fmt.Errorf("telecom v3 custNbr not configured") + } else { + result, err = s.request(ctx, path, nil, body) + } + byNumber := make(map[string]map[string]any) + if err == nil { + list, ok := result[listName].([]any) + if !ok { + err = fmt.Errorf("telecom v3 result is missing %s", listName) + } else { + for _, entry := range list { + item, ok := entry.(map[string]any) + if !ok { + err = fmt.Errorf("invalid telecom v3 batch entry") + break + } + number := dxV3String(item, "msisdn") + if number == "" { + err = fmt.Errorf("telecom v3 batch entry is missing msisdn") + break + } + if _, duplicate := byNumber[number]; duplicate { + err = fmt.Errorf("duplicate telecom v3 batch result for msisdn") + break + } + byNumber[number] = item + } + } + } + for _, index := range chunk { + key := keys[index] + itemErr := err + item := byNumber[strings.TrimSpace(key.MSISDN)] + if itemErr == nil && item == nil { + itemErr = fmt.Errorf("telecom v3 batch omitted requested card") + } + if itemErr == nil { + code := dxV3String(item, "result") + if code == "" { + itemErr = fmt.Errorf("telecom v3 batch entry is missing result") + } else if code != "0" { + itemErr = &dxV3Error{code: code, message: firstNonEmpty(dxV3String(item, "resultDesc"), "telecom v3 card query failed")} + } + } + var value T + if itemErr == nil && dateName != "" && dxV3String(item, dateName) != date { + itemErr = fmt.Errorf("telecom v3 batch returned a different query period") + } + if itemErr == nil { + value, itemErr = convert(item) + } + if itemErr != nil { + output[index] = dxV3Failure[T](key, itemErr) + } else { + output[index] = &model.SimBaseRes[T]{SimKey: key, ResData: value} + } + } + } + return output +} + +func (s *sSimDXV3) QuerySimCardStatusBatch(ctx context.Context, input *model.SimCardStatusInput) (model.SimCardStatusOutput, error) { + if input == nil { + return model.SimCardStatusOutput{}, nil + } + return dxV3Batch(ctx, s, input.SimKeys, "getSimStatusBatch", "simStatusList", "", "", dxV3Status), nil +} + +func (s *sSimDXV3) QuerySimDataUsageMonthlyBatch(ctx context.Context, input *model.SimDataUsageMonthlyInput) (model.SimDataUsageMonthlyOutput, error) { + if input == nil { + return model.SimDataUsageMonthlyOutput{}, nil + } + month, err := time.ParseInLocation("200601", input.ReqData.QueryDate, dxBatchLocation) + if err != nil { + return nil, fmt.Errorf("invalid queryDate: expected yyyyMM") + } + if !dxV3BatchMonthAvailable(month) { + return nil, fmt.Errorf("telecom v3 monthly batch supports the latest six months") + } + return dxV3Batch(ctx, s, input.SimKeys, "getDataUsageByMonthBatch", "batchTrafficList", "month", input.ReqData.QueryDate, + func(item map[string]any) (model.SimDataUsageMonthlyOutputItem, error) { + amount, err := dxV3Amount(item) + return model.SimDataUsageMonthlyOutputItem{DataAmount: amount}, err + }), nil +} + +func (s *sSimDXV3) QuerySimDataUsageDailyBatch(ctx context.Context, input *model.SimDataUsageDailyInput) (model.SimDataUsageDailyOutput, error) { + if input == nil { + return model.SimDataUsageDailyOutput{}, nil + } + if !dxBatchDayAvailable(input.ReqData.QueryDate, time.Now()) { + return nil, fmt.Errorf("telecom v3 daily batch requires a completed day within the latest seven days") + } + return dxV3Batch(ctx, s, input.SimKeys, "getDataUsageByDayBatch", "batchTrafficList", "date", input.ReqData.QueryDate, + func(item map[string]any) (model.SimDataUsageDailyOutputItem, error) { + amount, err := dxV3Amount(item) + return model.SimDataUsageDailyOutputItem{QueryDate: input.ReqData.QueryDate, DataAmount: amount}, err + }), nil +} + +var _ model.SimCardBatchQueryI = (*sSimDXV3)(nil) diff --git a/internal/logic/upstreamimpl/sim_dx_v3_test.go b/internal/logic/upstreamimpl/sim_dx_v3_test.go new file mode 100644 index 0000000..40ec814 --- /dev/null +++ b/internal/logic/upstreamimpl/sim_dx_v3_test.go @@ -0,0 +1,468 @@ +package upstreamimpl + +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" + "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 { + t.Helper() + server := httptest.NewServer(handler) + t.Cleanup(server.Close) + return &sSimDXV3{client: dx.NewClient(dx.Config{BaseURL: server.URL, AppKey: "test-key", AppSecret: "test-secret", UppercaseSign: true}), custNbr: "test-customer"} +} + +func TestDXV3TemplateCoexistsWithV1(t *testing.T) { + for _, key := range []string{"sim_dx_v1", "sim_dx_v3"} { + if _, ok := service.UpstreamAPI().GetTemp(key); !ok { + t.Fatalf("missing template %s", key) + } + } + factory := &sSimDXV3{} + config := &model.SimChannelConfigItemCache{Channel: &entity.TSimChannels{AppKey: "key", AppSecret: "secret", ChanConfig: `{"custNumber":"customer"}`}, Template: &entity.TSimChannelTemplate{TempConfig: `{"api":{"baseUrl":"https://cmp-api.ctwing.cn:20164"}}`}} + instance, ok := factory.NewTemp(context.Background(), config).(*sSimDXV3) + if !ok || instance.enableOperations || instance.custNbr != "customer" { + t.Fatalf("unexpected v3 configuration: %+v", instance) + } + config.Channel.ChanConfig = "" + if factory.NewTemp(context.Background(), config) == nil { + t.Fatal("single-card configuration should not require custNbr") + } + config.Template.TempConfig = `{"api":{"baseUrl":"https://cmp-api.ctwing.cn:20164/api/v3"}}` + if factory.NewTemp(context.Background(), config) != nil { + t.Fatal("accepted base URL containing protocol path") + } +} + +func TestDXV3SessionAndRealTimeUsageContracts(t *testing.T) { + client := newDXV3TestClient(t, func(w http.ResponseWriter, r *http.Request) { + if r.URL.Query().Get("msisdn") != "106123" || len(r.URL.Query()) != 1 { + t.Errorf("unexpected query: %s", r.URL.RawQuery) + } + want, _ := dx.BuildSignV3(r.URL.RawQuery, "", "test-secret", r.Header.Get("Timestamp")) + if r.Header.Get("Sign") != want || r.Header.Get("AppKey") != "test-key" { + t.Error("unexpected signed request") + } + switch r.URL.Path { + case "/api/v3/getOnlineStatus": + fmt.Fprint(w, `{"code":"0","result":{"onlineStatus":"1","apnName":"iot.apn","ipv4Address":"192.0.2.1"}}`) + case "/api/v3/getDataUsage": + fmt.Fprint(w, `{"code":0,"result":{"dataAmount":"12.5"}}`) + default: + t.Errorf("unexpected path: %s", r.URL.Path) + } + }) + keys := []*model.SimKey{{MSISDN: "106123", ICCID: "8986", IMSI: "46011"}} + session, err := client.QuerySimSession(context.Background(), &model.SimSessionInput{SimKeys: keys}) + if err != nil || len(session) != 1 || session[0].Code != 0 || session[0].ResData.Status != consts.CardSessionStatusOnline || session[0].ResData.ApnId != "iot.apn" { + t.Fatalf("session: %+v, %v", session, err) + } + usage, err := client.QuerySimDataUsage(context.Background(), &model.SimDataUsageInput{SimKeys: keys}) + if err != nil || usage[0].Code != 0 || usage[0].ResData.DataUsage != 12.5 { + t.Fatalf("usage: %+v, %v", usage, err) + } +} + +func TestDXV3ErrorsNeverBecomeZeroUsage(t *testing.T) { + for _, test := range []struct { + name, body, code string + httpStatus int + }{ + {"missing code", `{"result":{"dataAmount":"0"}}`, "", 200}, + {"null code", `{"code":null,"result":{"dataAmount":"0"}}`, "", 200}, + {"mismatched card", `{"code":"0","result":{"msisdn":"2","dataAmount":"5"}}`, "", 200}, + {"invalid amount", `{"code":"0","result":{"dataAmount":"garbage"}}`, "", 200}, + {"missing result", `{"code":"0","result":null}`, "", 200}, + {"business failure", `{"code":"S00004","message":"limited"}`, "S00004", 200}, + {"gateway failure", `{}`, "S00004", 799}, + } { + t.Run(test.name, func(t *testing.T) { + client := newDXV3TestClient(t, func(w http.ResponseWriter, r *http.Request) { + if test.httpStatus == 799 { + w.Header().Set("gw-errorCode", "S00004") + } + w.WriteHeader(test.httpStatus) + fmt.Fprint(w, test.body) + }) + items, err := client.QuerySimDataUsage(context.Background(), &model.SimDataUsageInput{SimKeys: []*model.SimKey{{MSISDN: "1"}}}) + if err != nil || items[0].Code == 0 || items[0].ErrCode != test.code { + t.Fatalf("unexpected error result: %+v, %v", items[0], err) + } + }) + } +} + +func TestDXV3HistoryDayAndDiagnosticMappings(t *testing.T) { + day := time.Now().In(dxBatchLocation).AddDate(0, 0, -1).Format("20060102") + calls := 0 + client := newDXV3TestClient(t, func(w http.ResponseWriter, r *http.Request) { + calls++ + switch r.URL.Path { + case "/api/v3/getDataUsageBetweenDate": + if r.URL.Query().Get("beginDate") != day || r.URL.Query().Get("endDate") != day { + t.Error("history day must use the same beginning and ending date") + } + fmt.Fprint(w, `{"code":"0","result":{"totalUsageAmount":"4.5"}}`) + case "/api/v3/getApnInfo": + fmt.Fprint(w, `{"code":"0","result":{"apnInfoList":[{"apnName":"iot","status":"1"}]}}`) + case "/api/v3/getSimBindingInfo": + fmt.Fprint(w, `{"code":"0","result":{"isOrdered":"0","bindType":"2","simSeparationStatus":"0","bindImei":"123"}}`) + case "/api/v3/getSimRegionLimitStatus": + fmt.Fprint(w, `{"code":"0","result":{"orderStatus":0,"limitType":3,"regionLimitStatus":"0","restrictedArea":"110000"}}`) + case "/api/v3/getDevicePowerStatus": + // 网关实测只接受 GET(文档写 POST 与网关不符),参数走 query。 + if r.Method != "GET" || r.URL.Query().Get("msisdn") != "1" { + t.Errorf("invalid power-status request: %s %s", r.Method, r.URL.RawQuery) + } + fmt.Fprint(w, `{"code":"0","result":{"status":"0"}}`) + default: + t.Errorf("unexpected diagnostic path: %s", r.URL.Path) + } + }) + ctx := context.Background() + keys := []*model.SimKey{{MSISDN: "1"}} + usage, err := client.QuerySimDataUsageDaily(ctx, &model.SimDataUsageDailyInput{SimKeys: keys, ReqData: model.SimDataUsageDailyInputItem{QueryDate: day}}) + if err != nil || usage[0].Code != 0 || usage[0].ResData.QueryDate != day || usage[0].ResData.DataAmount != 4.5 { + t.Fatalf("history day: %+v, %v", usage, err) + } + today := time.Now().In(dxBatchLocation).Format("20060102") + usage, _ = client.QuerySimDataUsageDaily(ctx, &model.SimDataUsageDailyInput{SimKeys: keys, ReqData: model.SimDataUsageDailyInputItem{QueryDate: today}}) + if usage[0].Code == 0 || calls != 1 { + t.Fatal("unfinished day reached the historical endpoint") + } + apns, _ := client.QuerySimApnInfo(ctx, &model.SimApnInfoInput{SimKeys: keys}) + if apns[0].Code != 0 || len(apns[0].ResData.ApnList) != 1 || apns[0].ResData.ApnList[0].Status != "1" { + t.Fatalf("APN mapping: %+v", apns) + } + bind, _ := client.QuerySimCardBindStatus(ctx, &model.SimCardBindStatusInput{SimKeys: keys}) + if bind[0].Code != 0 || bind[0].ResData.BindStatus != "0" || bind[0].ResData.OrderStatus != "0" || bind[0].ResData.CardBindingType != "2" || bind[0].ResData.BindImei != "123" { + t.Fatalf("binding mapping: %+v", bind) + } + region, _ := client.QuerySimRegionLimitStatus(ctx, &model.SimRegionLimitStatusInput{SimKeys: keys}) + if region[0].Code != 0 || region[0].ResData.RegionLimitStatus != "1" || region[0].ResData.LimitType != "3" || region[0].ResData.LimitArea != "110000" { + t.Fatalf("region mapping: %+v", region) + } + power, _ := client.QuerySimDeviceStatus(ctx, &model.SimDeviceStatusInput{SimKeys: keys}) + if power[0].Code != 0 || power[0].ResData.OperatingStatus != "1" || power[0].ResData.DeviceStatus != consts.CardSessionStatusUnknown { + t.Fatalf("power mapping: %+v", power) + } +} + +// TestDXV3DevicePowerStatusSupportsEmptyRecordShape 锁定该接口的真实返回形态: +// 仅 3G 接口对 4G/5G 卡返回 {"Result":"-1","resultName":"无终端开关机信息"}(值是 Result, +// 大写 R),应映射为“未知”而不是报错;同时确认请求走 GET(文档写 POST 与网关不符)。 +func TestDXV3DevicePowerStatusSupportsEmptyRecordShape(t *testing.T) { + client := newDXV3TestClient(t, func(w http.ResponseWriter, r *http.Request) { + if r.Method != "GET" { + t.Errorf("device power status must be sent with GET, got %s", r.Method) + } + fmt.Fprint(w, `{"code":"0","result":{"Result":"-1","resultName":"无终端开关机信息"}}`) + }) + items, err := client.QuerySimDeviceStatus(context.Background(), &model.SimDeviceStatusInput{SimKeys: []*model.SimKey{{MSISDN: "1"}}}) + if err != nil || items[0].Code != 0 || items[0].ResData.OperatingStatus != consts.CardSessionStatusUnknown { + t.Fatalf("empty power record must map to unknown: %+v, %v", items, err) + } +} + +func TestDXV3BasicInfoAllowsDocumentedPartialIdentifiers(t *testing.T) { + for _, body := range []string{ + `{"code":"0","result":{"msisdn":"1","createTime":"2023-03-16 11:07:50","activeTime":"2023-03-16 11:07:50","prodName":"test-product"}}`, + `{"code":"0","result":{"msisdn":"1","iccid":"8986","imsi":"46011","prodName":"test-product"}}`, + } { + client := newDXV3TestClient(t, func(w http.ResponseWriter, r *http.Request) { fmt.Fprint(w, body) }) + items, err := client.QuerySimBasicInfo(context.Background(), &model.SimBasicInfoInput{SimKeys: []*model.SimKey{{MSISDN: "1"}}}) + if err != nil || items[0].Code != 0 || items[0].ResData.MSISDN != "1" || items[0].ResData.MainOfferName != "test-product" { + t.Fatalf("basic info: %+v, %v", items, err) + } + } + if _, err := dxV3BasicInfo(map[string]any{}); err == nil { + t.Fatal("accepted basic info with no identifiers") + } +} + +func TestDXV3StatusBatchChunksAndMatchesCards(t *testing.T) { + calls := 0 + client := newDXV3TestClient(t, func(w http.ResponseWriter, r *http.Request) { + calls++ + if r.Method != "POST" || r.URL.Path != "/api/v3/getSimStatusBatch" { + t.Errorf("unexpected batch request") + } + body, _ := io.ReadAll(r.Body) + want, _ := dx.BuildSignV3("", string(body), "test-secret", r.Header.Get("Timestamp")) + if r.Header.Get("Sign") != want { + t.Error("body differs from signed body") + } + var request struct { + Numbers []string `json:"msisdnList"` + Customer string `json:"custNbr"` + } + if err := json.Unmarshal(body, &request); err != nil { + t.Fatal(err) + } + if len(request.Numbers) > 100 || request.Customer != "test-customer" { + t.Fatalf("invalid batch: %+v", request) + } + list := make([]map[string]string, 0, len(request.Numbers)) + for i := len(request.Numbers) - 1; i >= 0; i-- { + number := request.Numbers[i] + if number == "7" { + continue + } + item := map[string]string{"msisdn": number, "result": "0", "simStatus": "4"} + if number == "8" { + item["result"] = "-1" + } + list = append(list, item) + } + json.NewEncoder(w).Encode(map[string]any{"code": "0", "result": map[string]any{"simStatusList": list}}) + }) + keys := make([]*model.SimKey, 101) + for i := range keys { + keys[i] = &model.SimKey{MSISDN: fmt.Sprint(i)} + } + items, err := client.QuerySimCardStatusBatch(context.Background(), &model.SimCardStatusInput{SimKeys: keys}) + if err != nil || len(items) != 101 || calls != 2 { + t.Fatalf("batch size/calls: %d/%d err=%v", len(items), calls, err) + } + for i, item := range items { + if item.SimKey != keys[i] { + t.Fatal("card identity/order changed") + } + if i == 7 || i == 8 { + if item.Code == 0 { + t.Fatalf("partial failure %d was successful", i) + } + } else if item.Code != 0 || item.ResData.CardStatus != 2 { + t.Fatalf("card %d: %+v", i, item) + } + } +} + +func TestDXV3BatchUsageChecksPeriodAndItemCode(t *testing.T) { + month := time.Now().In(dxBatchLocation).Format("200601") + client := newDXV3TestClient(t, func(w http.ResponseWriter, r *http.Request) { + fmt.Fprintf(w, `{"code":"0","result":{"batchTrafficList":[{"msisdn":"1","result":"0","month":%q,"unit":"MB","dataAmount":"2.5"},{"msisdn":"2","result":"1","month":%q,"dataAmount":"9"},{"msisdn":"3","result":"0","month":"200001","dataAmount":"9"}]}}`, month, month) + }) + items, err := client.QuerySimDataUsageMonthlyBatch(context.Background(), &model.SimDataUsageMonthlyInput{SimKeys: []*model.SimKey{{MSISDN: "1"}, {MSISDN: "2"}, {MSISDN: "3"}}, ReqData: model.SimDataUsageMonthlyInputItem{QueryDate: month}}) + if err != nil || items[0].Code != 0 || items[0].ResData.DataAmount != 2.5 || items[1].Code == 0 || items[2].Code == 0 { + t.Fatalf("unexpected usage batch: %+v, %v", items, err) + } +} + +func TestDXV3OperationsDefaultOffAndUseNewSemantics(t *testing.T) { + calls := 0 + client := newDXV3TestClient(t, func(w http.ResponseWriter, r *http.Request) { + calls++ + switch r.URL.Path { + case "/api/v3/singleCutNet": + var body map[string]string + json.NewDecoder(r.Body).Decode(&body) + if r.Method != "POST" || body["operType"] != "2" || body["msisdn"] != "1" { + t.Errorf("invalid cut-net request: %+v", body) + } + case "/api/v3/updateSimStatus": + if r.URL.Query().Get("operType") != "19" { + t.Error("wrong status operation") + } + default: + t.Error("unexpected operation path") + } + fmt.Fprint(w, `{"code":"0","result":{"transactionId":"order-1"}}`) + }) + status := &model.SimCardStatusChangeInput{SimKeys: []*model.SimKey{{MSISDN: "1"}}, ReqData: model.SimCardStatusChangeInputItem{OperType: "0"}} + items, _ := client.ChangeSimStatus(context.Background(), status) + if items[0].Code == 0 || calls != 0 { + t.Fatal("disabled operation sent a request") + } + client.enableOperations = true + items, _ = client.ChangeSimStatus(context.Background(), status) + if items[0].Code != 0 || items[0].ResData.OrderNum != "order-1" { + t.Fatalf("status: %+v", items[0]) + } + data, _ := client.ChangeSimApnFunction(context.Background(), &model.SimCardOperateChangeApnInput{SimKeys: status.SimKeys, ReqData: model.SimCardOperateChangeApnInputItem{OperType: "1"}}) + if data[0].Code != 0 || calls != 2 { + t.Fatal("new cut-net operation did not succeed") + } + if _, err := dxV3Params(nil); err == nil { + t.Fatal("nil key accepted") + } + if !strings.Contains(items[0].ResData.OrderNum, "order") { + t.Fatal("missing operation reference") + } +} + +func TestDXV3CutNetIgnoresApnNameLikeV1(t *testing.T) { + calls := 0 + client := newDXV3TestClient(t, func(w http.ResponseWriter, r *http.Request) { + calls++ + if r.URL.Path != "/api/v3/singleCutNet" { + t.Errorf("unexpected path: %s", r.URL.Path) + } + var body map[string]string + json.NewDecoder(r.Body).Decode(&body) + if body["operType"] != "2" || body["msisdn"] != "1" { + t.Errorf("invalid cut-net request: %+v", body) + } + if _, ok := body["apnName"]; ok { + t.Errorf("ApnName must not be forwarded: %+v", body) + } + fmt.Fprint(w, `{"code":"0","result":{"transactionId":"order-9"}}`) + }) + client.enableOperations = true + items, err := client.ChangeSimApnFunction(context.Background(), &model.SimCardOperateChangeApnInput{ + SimKeys: []*model.SimKey{{MSISDN: "1"}}, + ReqData: model.SimCardOperateChangeApnInputItem{OperType: "1", ApnName: "CMIOT"}, + }) + if err != nil || calls != 1 || len(items) != 1 || items[0].Code != 0 || items[0].ResData.OrderNum != "order-9" { + t.Fatalf("named APN must be ignored, not rejected: %+v, %v", items, err) + } +} + +func TestDXV3UnsupportedQueriesDegradeLikeV1(t *testing.T) { + client := newDXV3TestClient(t, func(w http.ResponseWriter, r *http.Request) { + t.Errorf("unsupported queries must not reach the upstream: %s", r.URL.Path) + }) + keys := []*model.SimKey{{MSISDN: "1"}} + frozen, err := client.QuerySimFrozenState(context.Background(), &model.SimFrozenStateInput{SimKeys: keys}) + if err != nil || len(frozen) != 1 || frozen[0].Code != consts.ApiStatusSuccess || frozen[0].ResData.FrozenStatus != "不支持查询" { + t.Fatalf("frozen state should degrade gracefully: %+v, %v", frozen, err) + } + diagnosis, err := client.QuerySimDataDiagnosis(context.Background(), &model.SimDataDiagnosisInput{SimKeys: keys}) + if err != nil || len(diagnosis) != 1 || diagnosis[0].Code != consts.ApiInvokeException || diagnosis[0].Message == "" { + t.Fatalf("data diagnosis should report as unsupported: %+v, %v", diagnosis, err) + } +} + +func TestDXV3MultiCardUsageUpgradesToBatch(t *testing.T) { + month := time.Now().In(dxBatchLocation).Format("200601") + day := time.Now().In(dxBatchLocation).AddDate(0, 0, -1).Format("20060102") + paths := make([]string, 0, 4) + client := newDXV3TestClient(t, func(w http.ResponseWriter, r *http.Request) { + paths = append(paths, r.URL.Path) + switch r.URL.Path { + case "/api/v3/getDataUsageByMonthBatch": + fmt.Fprintf(w, `{"code":"0","result":{"batchTrafficList":[{"msisdn":"1","result":"0","month":%q,"unit":"MB","dataAmount":"2.5"},{"msisdn":"2","result":"0","month":%q,"unit":"MB","dataAmount":"3.5"}]}}`, month, month) + case "/api/v3/getDataUsageByDayBatch": + fmt.Fprintf(w, `{"code":"0","result":{"batchTrafficList":[{"msisdn":"1","result":"0","date":%q,"unit":"MB","dataAmount":"1.5"},{"msisdn":"2","result":"0","date":%q,"unit":"MB","dataAmount":"2.5"}]}}`, day, day) + default: + fmt.Fprint(w, `{"code":"0","result":{"dataAmount":"1","unit":"MB"}}`) + } + }) + keys := []*model.SimKey{{MSISDN: "1"}, {MSISDN: "2"}} + monthly, err := client.QuerySimDataUsageMonthly(context.Background(), &model.SimDataUsageMonthlyInput{SimKeys: keys, ReqData: model.SimDataUsageMonthlyInputItem{QueryDate: month}}) + if err != nil || len(monthly) != 2 || monthly[0].ResData.DataAmount != 2.5 || monthly[1].ResData.DataAmount != 3.5 { + t.Fatalf("monthly upgrade: %+v, %v", monthly, err) + } + if len(paths) != 1 || paths[0] != "/api/v3/getDataUsageByMonthBatch" { + t.Fatalf("monthly query should collapse into one batch call, got %v", paths) + } + paths = paths[:0] + daily, err := client.QuerySimDataUsageDaily(context.Background(), &model.SimDataUsageDailyInput{SimKeys: keys, ReqData: model.SimDataUsageDailyInputItem{QueryDate: day}}) + if err != nil || len(daily) != 2 || daily[0].ResData.DataAmount != 1.5 { + t.Fatalf("daily upgrade: %+v, %v", daily, err) + } + if len(paths) != 1 || paths[0] != "/api/v3/getDataUsageByDayBatch" { + t.Fatalf("daily query should collapse into one batch call, got %v", paths) + } +} + +func TestDXV3MultiCardUsageFallsBackToConcurrentPerCard(t *testing.T) { + month := time.Now().In(dxBatchLocation).Format("200601") + calls := 0 + client := newDXV3TestClient(t, func(w http.ResponseWriter, r *http.Request) { + calls++ + if r.URL.Path != "/api/v3/getDataUsageByMonth" { + t.Errorf("batch must not be used without custNbr: %s", r.URL.Path) + } + fmt.Fprint(w, `{"code":"0","result":{"dataAmount":"1","unit":"MB"}}`) + }) + client.custNbr = "" + keys := []*model.SimKey{{MSISDN: "1"}, {MSISDN: "2"}, {MSISDN: "3"}} + items, err := client.QuerySimDataUsageMonthly(context.Background(), &model.SimDataUsageMonthlyInput{SimKeys: keys, ReqData: model.SimDataUsageMonthlyInputItem{QueryDate: month}}) + if err != nil || len(items) != 3 || calls != 3 { + t.Fatalf("fallback should query every card once: %d, %+v, %v", calls, items, err) + } + for index, item := range items { + if item.SimKey != keys[index] || item.Code != 0 { + t.Fatalf("fallback lost order or failed: %+v", item) + } + } +} + +// 批量接口整批不可用(网关/鉴权/报文错误)时,必须回退单卡查询,而不是整批报废。 +func TestDXV3BatchOutageFallsBackToSingleCard(t *testing.T) { + month := time.Now().In(dxBatchLocation).Format("200601") + batchCalls, singleCalls := 0, 0 + client := newDXV3TestClient(t, func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/api/v3/getDataUsageByMonthBatch": + batchCalls++ + w.Header().Set("gw-errorCode", "S00004") + w.WriteHeader(http.StatusBadGateway) + fmt.Fprint(w, `{}`) + case "/api/v3/getDataUsageByMonth": + singleCalls++ + fmt.Fprint(w, `{"code":"0","result":{"dataAmount":"4","unit":"MB"}}`) + default: + t.Errorf("unexpected path: %s", r.URL.Path) + } + }) + keys := []*model.SimKey{{MSISDN: "1"}, {MSISDN: "2"}} + items, err := client.QuerySimDataUsageMonthly(context.Background(), &model.SimDataUsageMonthlyInput{ + SimKeys: keys, ReqData: model.SimDataUsageMonthlyInputItem{QueryDate: month}}) + if err != nil || len(items) != 2 { + t.Fatalf("batch outage must not fail the whole query: %+v, %v", items, err) + } + for index, item := range items { + if item.SimKey != keys[index] || item.Code != consts.ApiStatusSuccess || item.ResData.DataAmount != 4 { + t.Fatalf("card %d was not recovered by the single-card fallback: %+v", index, item) + } + } + if batchCalls != 1 || singleCalls != 2 { + t.Fatalf("unexpected call split: batch=%d single=%d", batchCalls, singleCalls) + } +} + +// 批量只漏/错某张卡时,只对该卡用单卡补齐,批量成功项保留(不重复请求)。 +func TestDXV3BatchOnlyRefillsFailedCards(t *testing.T) { + month := time.Now().In(dxBatchLocation).Format("200601") + singleCalls := 0 + client := newDXV3TestClient(t, func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/api/v3/getDataUsageByMonthBatch": + fmt.Fprintf(w, `{"code":"0","result":{"batchTrafficList":[{"msisdn":"1","result":"0","month":%q,"unit":"MB","dataAmount":"2.5"}]}}`, month) + case "/api/v3/getDataUsageByMonth": + singleCalls++ + if r.URL.Query().Get("msisdn") != "2" { + t.Errorf("only the failed card may be refilled, got %s", r.URL.Query().Get("msisdn")) + } + fmt.Fprint(w, `{"code":"0","result":{"dataAmount":"7","unit":"MB"}}`) + default: + t.Errorf("unexpected path: %s", r.URL.Path) + } + }) + keys := []*model.SimKey{{MSISDN: "1"}, {MSISDN: "2"}} + items, err := client.QuerySimDataUsageMonthly(context.Background(), &model.SimDataUsageMonthlyInput{ + SimKeys: keys, ReqData: model.SimDataUsageMonthlyInputItem{QueryDate: month}}) + if err != nil || len(items) != 2 || singleCalls != 1 { + t.Fatalf("only failed cards should be refilled: single=%d, %+v, %v", singleCalls, items, err) + } + if items[0].Code != consts.ApiStatusSuccess || items[0].ResData.DataAmount != 2.5 { + t.Fatalf("batch success must be kept: %+v", items[0]) + } + if items[1].Code != consts.ApiStatusSuccess || items[1].ResData.DataAmount != 7 { + t.Fatalf("omitted card must be refilled by single query: %+v", items[1]) + } +} diff --git a/internal/logic/upstreamimpl/sim_enum.go b/internal/logic/upstreamimpl/sim_enum.go new file mode 100644 index 0000000..bc32325 --- /dev/null +++ b/internal/logic/upstreamimpl/sim_enum.go @@ -0,0 +1,179 @@ +package upstreamimpl + +import ( + "context" + "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" +) + +func convertDxSessionStatus(ctx context.Context, upstreamStatus string) string { + switch strings.TrimSpace(upstreamStatus) { + case "0": + return consts.CardSessionStatusOnline + case "1": + return consts.CardSessionStatusOffline + case "2", "": + return consts.CardSessionStatusUnknown + default: + g.Log().Warningf(ctx, "Unknown DX session status %s from upstream", upstreamStatus) + return consts.CardSessionStatusUnknown + } +} + +func convertYdSessionStatus(ctx context.Context, upstreamStatus string) string { + switch strings.ToUpper(strings.TrimSpace(upstreamStatus)) { + case "01", "1", "0", "ONLINE": + return consts.CardSessionStatusOnline + case "00", "-1", "OFFLINE": + return consts.CardSessionStatusOffline + case "02", "2", "-2", "", "UNKNOWN": + return consts.CardSessionStatusUnknown + default: + g.Log().Warningf(ctx, "Unknown YD session status %s from upstream", upstreamStatus) + return consts.CardSessionStatusUnknown + } +} + +func convertYdApnStatus(ctx context.Context, upstreamStatus string) string { + switch strings.TrimSpace(upstreamStatus) { + case consts.CardApnStatusPaused: + return consts.CardApnStatusPaused + case consts.CardApnStatusNormal: + return consts.CardApnStatusNormal + default: + g.Log().Warningf(ctx, "Unknown YD APN status %s from upstream", upstreamStatus) + return upstreamStatus + } +} + +// convertDxApnOperType 把平台 APN 开停编码转换为电信 singleCutNet 的 action 取值,语义相反。 +// 平台编码(全平台统一「开=0 / 停=1」,见 consts.CardApnOperType*): +// +// 0 = 开通数据(恢复联网) +// 1 = 停用数据(断网,含达量断网) +// +// 电信 singleCutNet(CTIOT_5GCMP_BA004)action: +// +// ADD = 单独断网(添加断网) +// DEL = 取消单独断网(恢复联网) +// +// 两者恰好相反:平台 0(开) -> 电信 DEL(取消断网),平台 1(停) -> 电信 ADD(断网)。禁止透传。 +// +// 注意:singleCutNet 是整卡数据开停语义,电信侧没有「按 APN 定向」这一维度, +// 因此忽略调用方携带的 ApnName(预警自动断网、批量数据开停都会固定带 apnName="CMIOT")。 +func convertDxApnOperType(ctx context.Context, operType string) (string, bool) { + switch strings.TrimSpace(operType) { + case consts.CardApnOperTypeOpen: + return "DEL", true + case consts.CardApnOperTypeStop: + return "ADD", true + default: + g.Log().Warningf(ctx, "Unknown APN operType %s for DX upstream", operType) + return "", false + } +} + +// convertYdCommunicationBusinessType 把平台 APN 开停编码转换为移动 OneLink「通信业务开停-单卡」 +// (7.2.1 CMIOT_API25M19,ec/manage/sim-communication-service)的 businessType。两者同向,无需翻转。 +// +// 平台编码(全平台统一「开=0 / 停=1」,见 consts.CardApnOperType*): +// +// 0 = 开通数据(启用 APN) +// 1 = 停用数据(停用 APN,含达量断网) +// +// 移动 7.2.1 businessType:0:开 1:停(同向,直接透传) +func convertYdCommunicationBusinessType(ctx context.Context, operType string) (string, bool) { + switch strings.TrimSpace(operType) { + case consts.CardApnOperTypeOpen: + return "0", true + case consts.CardApnOperTypeStop: + return "1", true + default: + g.Log().Warningf(ctx, "Unknown APN operType %s for YD sim-communication-service", operType) + return "", false + } +} + +// convertYdCommunicationOperType 把平台 APN 开停编码转换为移动 OneLink「通信功能开停-批量」 +// (7.2.2 CMIOT_API23M09,ec/operate/sim-communication-function/batch)的 operType。语义相反,必须翻转。 +// +// 平台编码(全平台统一「开=0 / 停=1」,见 consts.CardApnOperType*): +// +// 0 = 开通数据(启用 APN) +// 1 = 停用数据(停用 APN,含达量断网) +// +// 移动 7.2.2 operType:0:暂停(停) 1:恢复(开)(与平台相反) +// +// 平台 0(开) -> 移动 1(恢复),平台 1(停) -> 移动 0(暂停)。禁止透传。 +func convertYdCommunicationOperType(ctx context.Context, operType string) (string, bool) { + switch strings.TrimSpace(operType) { + case consts.CardApnOperTypeOpen: + return "1", true + case consts.CardApnOperTypeStop: + return "0", true + default: + g.Log().Warningf(ctx, "Unknown APN operType %s for YD sim-communication-function", operType) + return "", false + } +} + +func normalizeCardStatusChangeOperType(ctx context.Context, operType string) (string, bool) { + switch strings.TrimSpace(operType) { + case consts.CardStatusChangeOperTypeApplyStop: + return consts.CardStatusChangeOperTypeApplyStop, true + case consts.CardStatusChangeOperTypeApplyResume: + return consts.CardStatusChangeOperTypeApplyResume, true + default: + g.Log().Warningf(ctx, "Unknown card status change operType %s", operType) + return "", false + } +} + +func convertDxCardStatusChangeOperType(ctx context.Context, operType string) (string, bool) { + switch strings.TrimSpace(operType) { + case consts.CardStatusChangeOperTypeApplyStop: + return "19", true + case consts.CardStatusChangeOperTypeApplyResume: + return "20", true + default: + g.Log().Warningf(ctx, "Unknown card status change operType %s for DX upstream", operType) + return "", false + } +} + +func newInvalidCardStatusChangeOutput(simKeys []*model.SimKey) model.SimCardStatusChangeOutput { + items := make(model.SimCardStatusChangeOutput, 0, len(simKeys)) + for _, simKey := range simKeys { + items = append(items, &model.SimBaseRes[model.SimCardStatusChangeItem]{ + SimKey: simKey, + Code: consts.ApiStatusError, + Message: "unsupported operType, expected 0(stop) or 1(resume)", + ResData: model.SimCardStatusChangeItem{}, + }) + } + return items +} + +// convertTibiotCardStatus 转换Tibiot卡状态码 +// Tibiot: 1正常 2停机 3沉默期 5测试期 6停机保号 +// 系统: 1待激活 2已激活 4停机 6可测试 7库存 8预销户 9已销户 +func convertTibiotCardStatus(ctx context.Context, upstreamStatus int) int { + switch upstreamStatus { + case 1: // 正常 + return 2 // 已激活 + case 2: // 停机 + return 4 // 停机 + case 3: // 沉默期 + return 1 // 待激活 + case 5: // 测试期 + return 6 // 可测试 + case 6: // 停机保号 + return 4 // 停机 + default: + g.Log().Warningf(ctx, "Unknown Tibiot card status %d", upstreamStatus) + return 0 // 未知 + } +} diff --git a/internal/logic/upstreamimpl/sim_henan_xinjing_direct.go b/internal/logic/upstreamimpl/sim_henan_xinjing_direct.go new file mode 100644 index 0000000..0faf13b --- /dev/null +++ b/internal/logic/upstreamimpl/sim_henan_xinjing_direct.go @@ -0,0 +1,521 @@ +package upstreamimpl + +import ( + "context" + "encoding/json" + "fmt" + "strconv" + "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" +) + +const henanXinjingDirectTemplateKey = "sim_henan_xinjing_direct_v1" + +// henanXinjingConfig is the channel-level config for the direct Henan Xinjing +// template. The top-level userId/apiKey form is the canonical shape; the +// nested `credentials` block is kept for backward compatibility with earlier +// drafts of this template. +type henanXinjingConfig struct { + UserID string `json:"userId"` + APIKey string `json:"apiKey"` + APIKeyLegacy string `json:"apikey"` + API struct { + BaseURL string `json:"baseUrl"` + Timeout string `json:"timeout"` + } `json:"api"` + Credentials struct { + UserID string `json:"userId"` + APIKey string `json:"apiKey"` + APIKeyLegacy string `json:"apikey"` + } `json:"credentials"` +} + +// henanXinjingParseConfig unmarshals one JSON config blob (channel or +// template) into henanXinjingConfig. Empty input yields a zero config. +func henanXinjingParseConfig(raw string) henanXinjingConfig { + var c henanXinjingConfig + raw = strings.TrimSpace(raw) + if raw == "" { + return c + } + _ = json.Unmarshal([]byte(raw), &c) + return c +} + +// henanXinjingPickCreds resolves (userId, apiKey) with precedence: +// channel.chan_config > channel.app_key/app_secret > template.temp_config. +// This matches how the LCM admin stores credentials: per-channel in +// t_sim_channels, not in the shared template row. +func henanXinjingPickCreds(chanCfg, tplCfg henanXinjingConfig, appKey, appSecret string) (user, key string) { + firstNonEmptyStr := func(vals ...string) string { + for _, v := range vals { + if v = strings.TrimSpace(v); v != "" { + return v + } + } + return "" + } + user = firstNonEmptyStr( + chanCfg.UserID, chanCfg.Credentials.UserID, + appKey, + tplCfg.UserID, tplCfg.Credentials.UserID, + ) + key = firstNonEmptyStr( + chanCfg.APIKey, chanCfg.APIKeyLegacy, + chanCfg.Credentials.APIKey, chanCfg.Credentials.APIKeyLegacy, + appSecret, + tplCfg.APIKey, tplCfg.APIKeyLegacy, + tplCfg.Credentials.APIKey, tplCfg.Credentials.APIKeyLegacy, + ) + return +} + +// henanXinjingFlexFloat accepts either a JSON number or a JSON string and +// normalizes to float64. The platform returns `used` as a number and +// `surplus`/`canused` as strings in some responses. +type henanXinjingFlexFloat float64 + +func (f *henanXinjingFlexFloat) UnmarshalJSON(data []byte) error { + if len(data) == 0 || string(data) == "null" { + *f = 0 + return nil + } + var n float64 + if err := json.Unmarshal(data, &n); err == nil { + *f = henanXinjingFlexFloat(n) + return nil + } + var s string + if err := json.Unmarshal(data, &s); err != nil { + return err + } + s = strings.TrimSpace(s) + if s == "" { + *f = 0 + return nil + } + v, err := strconv.ParseFloat(s, 64) + if err != nil { + return fmt.Errorf("henan xinjing: parse float %q: %w", s, err) + } + *f = henanXinjingFlexFloat(v) + return nil +} + +// henanXinjingCard mirrors `data` in `/api/v1/getChaxun`. Only fields we +// actually use are decoded; the platform returns many more. +// +// Documented field meanings (per upstream doc): +// - state : string card status code ("00" ok, "02" stopped, etc.) +// - status : int online/auth status (NOT the card lifecycle status) +// - used : KB (current month) +// - surplus : KB +// - expired_at / shouchong_time : yyyy-MM-dd HH:mm:ss strings +// - gprs_status : 0 unknown 1 on 2 off +// - power_status: 0 unknown 1 online 2 offline +// - display_status: 1 pending, 2 normal, 3 abnormal, 4 pre-close, 5 closed +type henanXinjingCard struct { + ICCID string `json:"iccid"` + CardNo string `json:"cardno"` + IMEI string `json:"imei"` + Used henanXinjingFlexFloat `json:"used"` + Surplus henanXinjingFlexFloat `json:"surplus"` + CanUsed henanXinjingFlexFloat `json:"canused"` + State string `json:"state"` + Status int `json:"status"` + OnlineState int `json:"onlinestate"` + GPRSStatus int `json:"gprs_status"` + PowerStatus int `json:"power_status"` + DisplayStatus int `json:"display_status"` + ExpiredAt string `json:"expired_at"` + FirstChargeAt string `json:"shouchong_time"` + Message string `json:"message"` + AutoName string `json:"autoname"` + MonthUseds []struct { + MonthDate string `json:"month_date"` // yyyyMM + UsedFlow string `json:"used_flow"` // MB per upstream doc + } `json:"month_useds"` + Package []struct { + OrderNo string `json:"orderno"` + PackageName string `json:"package_name"` + State int `json:"state"` + ExpiredAt string `json:"expired_at"` + CreatedAt string `json:"created_at"` + Flow string `json:"flow"` + MonthlyFlow henanXinjingFlexFloat `json:"monthly_flow"` + Surplus henanXinjingFlexFloat `json:"surplus"` + Used henanXinjingFlexFloat `json:"used"` + } `json:"package"` +} + +type sSimHenanXinjingDirect struct { + channelConfig *model.SimChannelConfigItemCache + client *henanXinjingClient +} + +func init() { + service.UpstreamAPI().RegisterTemp(henanXinjingDirectTemplateKey, NewSimHenanXinjingDirect()) +} + +func NewSimHenanXinjingDirect() *sSimHenanXinjingDirect { return &sSimHenanXinjingDirect{} } + +func (s *sSimHenanXinjingDirect) NewTemp(ctx context.Context, cfg *model.SimChannelConfigItemCache) model.SimCardChannelI { + chanKey := "" + if cfg != nil && cfg.Channel != nil { + chanKey = cfg.Channel.ChanKey + } + if cfg == nil || cfg.Channel == nil || cfg.Template == nil { + g.Log().Warningf(ctx, "henan_xinjing_direct.NewTemp: nil cfg/channel/template, chanKey=%s", chanKey) + return nil + } + chanCfg := henanXinjingParseConfig(cfg.Channel.ChanConfig) + tplCfg := henanXinjingParseConfig(cfg.Template.TempConfig) + user, key := henanXinjingPickCreds(chanCfg, tplCfg, cfg.Channel.AppKey, cfg.Channel.AppSecret) + baseURL := strings.TrimSpace(chanCfg.API.BaseURL) + if baseURL == "" { + baseURL = strings.TrimSpace(tplCfg.API.BaseURL) + } + if user == "" || key == "" || baseURL == "" { + g.Log().Warningf(ctx, + "henan_xinjing_direct.NewTemp: missing required config, chanKey=%s userIdEmpty=%t apiKeyEmpty=%t baseURLEmpty=%t", + chanKey, user == "", key == "", baseURL == "") + return nil + } + timeout := defaultUpstreamHTTPTimeout + for _, raw := range []string{chanCfg.API.Timeout, tplCfg.API.Timeout} { + if raw == "" { + continue + } + if d, err := time.ParseDuration(raw); err == nil && d > 0 { + timeout = d + break + } + } + return &sSimHenanXinjingDirect{ + channelConfig: cfg, + client: newHenanXinjingClient(baseURL, user, key, timeout), + } +} + +// simKeyCardNo picks the identifier Henan Xinjing accepts for `cardno`. +// The platform keys on its own card-number column, which corresponds to +// MSISDN for these cards; fall back to ICCID/IMSI when MSISDN is absent. +func henanXinjingCardNo(k *model.SimKey) string { + if k == nil { + return "" + } + if v := strings.TrimSpace(k.MSISDN); v != "" { + return v + } + if v := strings.TrimSpace(k.ICCID); v != "" { + return v + } + return strings.TrimSpace(k.IMSI) +} + +// fetchCard calls /api/v1/getChaxun for a single SimKey. +func (s *sSimHenanXinjingDirect) fetchCard(ctx context.Context, k *model.SimKey) (henanXinjingCard, error) { + if k == nil { + return henanXinjingCard{}, fmt.Errorf("sim key is required") + } + no := henanXinjingCardNo(k) + if no == "" { + return henanXinjingCard{}, fmt.Errorf("msisdn/iccid/imsi is required") + } + var c henanXinjingCard + err := s.client.get(ctx, "/api/v1/getChaxun", map[string]string{"cardno": no}, &c) + return c, err +} + +// henanXinjingKBToMB converts upstream KB to the MB unit SimDataUsageItem +// requires. Rounds to 4 decimal places to keep values readable. +func henanXinjingKBToMB(kb float64) float64 { + if kb <= 0 { + return 0 + } + mb := kb / 1024 + return float64(int(mb*10000+0.5)) / 10000 +} + +// henanXinjingMapCardStatus translates the platform's `state` string into the +// internal CardStatus code used across the gateway. +// +// Upstream doc: +// +// 0 unknown, 00 normal, 01 one-way stop, 02 stopped, 03 pre-close, +// 04 closed, 05 transfer, 06 dormant, 07 pending, 08 disabled, +// 09 inventory, 10 expired, 11 violation stop, 12 lost, 13 user-stop, +// 14 testing, 99 not-exists +// +// Internal consts (from existing adapters like tibiot): +// +// 1待激活 2已激活 4停机 6可测试 7库存 8预销户 9已销户 +func henanXinjingMapCardStatus(state string) int { + switch strings.TrimSpace(state) { + case "00": + return 2 // 已激活/正常 + case "01", "02", "08", "11", "12", "13": + return 4 // 停机 + case "03": + return 8 // 预销户 + case "04", "10": + return 9 // 已销户 + case "06": + return 1 // 休眠 → 待激活 + case "07": + return 1 // 待激活 + case "09": + return 7 // 库存 + case "14": + return 6 // 可测试 + default: + return 0 // 未知 + } +} + +func henanXinjingStatusChangePath(operType string) (string, bool) { + switch operType { + case consts.CardStatusChangeOperTypeApplyStop: + return "/api/v1/stop", true + case consts.CardStatusChangeOperTypeApplyResume: + return "/api/v1/start", true + default: + return "", false + } +} + +func henanXinjingMainOfferName(card henanXinjingCard) string { + if name := strings.TrimSpace(card.AutoName); name != "" { + return name + } + for _, item := range card.Package { + if name := strings.TrimSpace(item.PackageName); name != "" { + return name + } + } + return "" +} + +func (s *sSimHenanXinjingDirect) QuerySimBasicInfo(ctx context.Context, in *model.SimBasicInfoInput) (out model.SimBasicInfoOutput, err error) { + for _, k := range in.SimKeys { + c, e := s.fetchCard(ctx, k) + r := &model.SimBaseRes[model.SimBasicInfoItem]{SimKey: k} + if e != nil { + r.Code = consts.ApiStatusError + r.Message = e.Error() + } else { + r.Code = 0 + r.ResData = model.SimBasicInfoItem{ + ICCID: firstNonEmpty(c.ICCID, k.ICCID), + IMSI: k.IMSI, + MSISDN: firstNonEmpty(c.CardNo, k.MSISDN), + ActiveDate: c.FirstChargeAt, + OpenDate: c.FirstChargeAt, // use 首充时间 as the open/activate timestamp; expired_at goes to remark + Remark: c.Message, + SimStatus: strconv.Itoa(henanXinjingMapCardStatus(c.State)), + MainOfferName: henanXinjingMainOfferName(c), + } + } + out = append(out, r) + } + return +} + +func (s *sSimHenanXinjingDirect) QuerySimImei(ctx context.Context, in *model.SimImeiInput) (out model.SimImeiOutput, err error) { + for _, k := range in.SimKeys { + c, e := s.fetchCard(ctx, k) + r := &model.SimBaseRes[model.SimImeiItem]{SimKey: k} + if e != nil { + r.Code = consts.ApiStatusError + r.Message = e.Error() + } else { + r.Code = 0 + r.ResData.Imei = c.IMEI + } + out = append(out, r) + } + return +} + +func (s *sSimHenanXinjingDirect) QuerySimCardStatus(ctx context.Context, in *model.SimCardStatusInput) (out model.SimCardStatusOutput, err error) { + for _, k := range in.SimKeys { + c, e := s.fetchCard(ctx, k) + r := &model.SimBaseRes[model.SimCardStatusItem]{SimKey: k} + if e != nil { + r.Code = consts.ApiStatusError + r.Message = e.Error() + } else { + r.Code = 0 + r.ResData.CardStatus = henanXinjingMapCardStatus(c.State) + } + out = append(out, r) + } + return +} + +func (s *sSimHenanXinjingDirect) QuerySimDataUsage(ctx context.Context, in *model.SimDataUsageInput) (out model.SimDataUsageOutput, err error) { + for _, k := range in.SimKeys { + c, e := s.fetchCard(ctx, k) + r := &model.SimBaseRes[model.SimDataUsageItem]{SimKey: k} + if e != nil { + r.Code = consts.ApiStatusError + r.Message = e.Error() + } else { + r.Code = 0 + r.ResData.DataUsage = henanXinjingKBToMB(float64(c.Used)) + total := henanXinjingKBToMB(float64(c.Used) + float64(c.Surplus)) + if total > 0 { + r.ResData.TotalFlow = &total + } + } + out = append(out, r) + } + return +} + +func (s *sSimHenanXinjingDirect) ChangeSimStatus(ctx context.Context, in *model.SimCardStatusChangeInput) (out model.SimCardStatusChangeOutput, err error) { + operType, ok := normalizeCardStatusChangeOperType(ctx, in.ReqData.OperType) + if !ok { + return newInvalidCardStatusChangeOutput(in.SimKeys), nil + } + for _, k := range in.SimKeys { + r := &model.SimBaseRes[model.SimCardStatusChangeItem]{SimKey: k} + path, _ := henanXinjingStatusChangePath(operType) + no := henanXinjingCardNo(k) + var e error + if no == "" { + e = fmt.Errorf("msisdn/iccid/imsi is required") + } else { + e = s.client.get(ctx, path, map[string]string{"cardno": no}, nil) + } + if e != nil { + r.Code = consts.ApiStatusError + r.Message = e.Error() + } else { + r.Code = 0 + } + out = append(out, r) + } + return +} + +func henanXinjingUnsupported[T any](keys []*model.SimKey, name string) (out []*model.SimBaseRes[T], err error) { + for _, k := range keys { + out = append(out, &model.SimBaseRes[T]{SimKey: k, Code: consts.ApiStatusError, Message: "henan xinjing direct does not support " + name}) + } + return +} + +func (s *sSimHenanXinjingDirect) QuerySimSession(ctx context.Context, in *model.SimSessionInput) (model.SimSessionOutput, error) { + return henanXinjingUnsupported[model.SimSessionItem](in.SimKeys, "session query") +} +func (s *sSimHenanXinjingDirect) QuerySimStopReason(ctx context.Context, in *model.SimStopReasonInput) (model.SimStopReasonOutput, error) { + return henanXinjingUnsupported[model.SimStopReasonItem](in.SimKeys, "stop reason query") +} +func (s *sSimHenanXinjingDirect) QuerySimFrozenState(ctx context.Context, in *model.SimFrozenStateInput) (model.SimFrozenStateOutput, error) { + return henanXinjingUnsupported[model.SimFrozenStateItem](in.SimKeys, "frozen state query") +} +func (s *sSimHenanXinjingDirect) QuerySimCardBindStatus(ctx context.Context, in *model.SimCardBindStatusInput) (model.SimCardBindStatusOutput, error) { + return henanXinjingUnsupported[model.SimCardBindStatusItem](in.SimKeys, "card binding query") +} +func (s *sSimHenanXinjingDirect) QuerySimDeviceStatus(ctx context.Context, in *model.SimDeviceStatusInput) (model.SimDeviceStatusOutput, error) { + out := make(model.SimDeviceStatusOutput, 0, len(in.SimKeys)) + for _, k := range in.SimKeys { + c, e := s.fetchCard(ctx, k) + r := &model.SimBaseRes[model.SimDeviceStatusItem]{SimKey: k} + if e != nil { + r.Code = consts.ApiStatusError + r.Message = e.Error() + } else { + r.Code = 0 + // gprs_status: 0 unknown 1 power-on 2 power-off + // power_status: 0 unknown 1 online 2 offline + // DeviceStatus (compatible semantics): 0 abnormal 1 normal. + r.ResData.DeviceStatus = henanXinjingMapDeviceStatus(c.GPRSStatus, c.PowerStatus) + switch c.GPRSStatus { + case 1: + r.ResData.OperatingStatus = "1" + case 2: + r.ResData.OperatingStatus = "0" + } + } + out = append(out, r) + } + return out, nil +} + +// henanXinjingMapDeviceStatus merges the platform's gprs_status (power on/off) +// and power_status (online/offline) into the gateway's binary device status. +// Only a card that is powered on AND online is "normal" (1); anything else, +// including unknown, maps to abnormal (0). +func henanXinjingMapDeviceStatus(gprsStatus, powerStatus int) string { + if gprsStatus == 1 && powerStatus == 1 { + return "1" + } + return "0" +} +func (s *sSimHenanXinjingDirect) QuerySimRegionLimitStatus(ctx context.Context, in *model.SimRegionLimitStatusInput) (model.SimRegionLimitStatusOutput, error) { + return henanXinjingUnsupported[model.SimRegionLimitStatusItem](in.SimKeys, "region limit query") +} +func (s *sSimHenanXinjingDirect) QuerySimDataDiagnosis(ctx context.Context, in *model.SimDataDiagnosisInput) (model.SimDataDiagnosisOutput, error) { + return henanXinjingUnsupported[any](in.SimKeys, "data diagnosis") +} +func (s *sSimHenanXinjingDirect) QuerySimDataUsageMonthly(ctx context.Context, in *model.SimDataUsageMonthlyInput) (model.SimDataUsageMonthlyOutput, error) { + queryMonth := "" + if in != nil { + queryMonth = strings.TrimSpace(in.ReqData.QueryDate) + } + out := make(model.SimDataUsageMonthlyOutput, 0, len(in.SimKeys)) + for _, k := range in.SimKeys { + c, e := s.fetchCard(ctx, k) + r := &model.SimBaseRes[model.SimDataUsageMonthlyOutputItem]{SimKey: k} + if e != nil { + r.Code = consts.ApiStatusError + r.Message = e.Error() + } else { + var selected *struct { + MonthDate string + UsedFlow string + } + for i := range c.MonthUseds { + m := strings.TrimSpace(c.MonthUseds[i].MonthDate) + if queryMonth == "" || strings.Contains(m, queryMonth) { + selected = &struct { + MonthDate string + UsedFlow string + }{c.MonthUseds[i].MonthDate, c.MonthUseds[i].UsedFlow} + break + } + } + if selected == nil { + r.Code = consts.ApiStatusError + r.Message = "monthly usage data not found" + } else { + r.Code = 0 + r.ResData.DeadLine = strings.TrimSpace(selected.MonthDate) + // month_useds.used_flow is MB per the platform doc, unlike the + // KB used/surplus fields on the same response. + if v, perr := strconv.ParseFloat(strings.TrimSpace(selected.UsedFlow), 64); perr == nil { + r.ResData.DataAmount = v + } + } + } + out = append(out, r) + } + return out, nil +} +func (s *sSimHenanXinjingDirect) QuerySimDataUsageDaily(ctx context.Context, in *model.SimDataUsageDailyInput) (model.SimDataUsageDailyOutput, error) { + return henanXinjingUnsupported[model.SimDataUsageDailyOutputItem](in.SimKeys, "historical daily usage query") +} +func (s *sSimHenanXinjingDirect) QuerySimApnInfo(ctx context.Context, in *model.SimApnInfoInput) (model.SimApnInfoOutput, error) { + return henanXinjingUnsupported[model.SimApnInfoItemList](in.SimKeys, "APN query") +} +func (s *sSimHenanXinjingDirect) ChangeSimApnFunction(ctx context.Context, in *model.SimCardOperateChangeApnInput) (model.SimCardOperateChangeApnOutput, error) { + return henanXinjingUnsupported[model.SimCardOperateChangeApnItem](in.SimKeys, "APN change") +} diff --git a/internal/logic/upstreamimpl/sim_mingqian.go b/internal/logic/upstreamimpl/sim_mingqian.go new file mode 100644 index 0000000..14b3a3c --- /dev/null +++ b/internal/logic/upstreamimpl/sim_mingqian.go @@ -0,0 +1,22 @@ +package upstreamimpl + +import ( + "context" + "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{} + +func init() { service.UpstreamAPI().RegisterTemp(mingqian.TemplateKey, &simMingqianTemplate{}) } + +func (*simMingqianTemplate) NewTemp(ctx context.Context, cfg *model.SimChannelConfigItemCache) model.SimCardChannelI { + adapter, err := mingqian.New(cfg) + if err != nil { + g.Log().Errorf(ctx, "create mingqian channel failed: %v", err) + return nil + } + return adapter +} diff --git a/internal/logic/upstreamimpl/sim_onelink_shared.go b/internal/logic/upstreamimpl/sim_onelink_shared.go new file mode 100644 index 0000000..47e250d --- /dev/null +++ b/internal/logic/upstreamimpl/sim_onelink_shared.go @@ -0,0 +1,615 @@ +package upstreamimpl + +import ( + "context" + "encoding/json" + "fmt" + "strings" + "sync" + "time" + + "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 ( + sharedOneLinkTengyuTemplateKey = "sim_onelink_tengyu_v1" + sharedOneLinkXuzhouTemplateKey = "sim_onelink_xuzhou_mobile_v1" + sharedOneLinkLelianTemplateKey = "sim_onelink_lelian_v1" + sharedOneLinkQianxunTemplateKey = "sim_onelink_qianxun_v1" + sharedOneLinkHenanXinjingTemplateKey = "sim_onelink_henan_xinjing_v1" + sharedOneLinkDaiheTemplateKey = "sim_onelink_daihe_v1" + sharedOneLinkMobilePublicTemplateKey = "sim_onelink_daliuliang_v1" + sharedOneLinkMobilePublicPreviousKey = "sim__daliuliang_v1" + sharedOneLinkMobilePublicLegacyKey = "sim_onelink_mobile_public_v1" + sharedOneLinkApikaTemplateKey = "sim_onelink_kedang_v1" + sharedOneLinkYunzeTemplateKey = "sim_onelink_yunze_v1" + sharedOneLinkLanshuTemplateKey = "sim_onelink_lanshu_v1" + sharedOneLinkBeixinTemplateKey = "sim_onelink_beixin_v1" + sharedOneLinkAPIBaseURL = "https://api.iot.10086.cn/v5/" + sharedOneLinkTransIDPrefix = "ONELINK" + sharedOneLinkXuzhouTokenTTL = 40 * time.Minute +) + +// SharedTokenConfig is the per-channel configuration passed to a provider +// factory. It intentionally contains no provider implementation details. +type SharedTokenConfig struct { + Provider string + TokenBaseURL string + LoginPath string + TokenPath string + CarrierType int + PwdKey string + ChannelNo string + AppKey string + AppID string + AppSecret string + SubAccount string + Secret string + OperatorID string + UserID string + TokenType string + UserName string + Password string + PasswayUID string + Sign string + ChannelID string + ChannelPName string + CardNo string + CMCCAppID string + Timeout time.Duration +} + +// TokenProviderFactory is the adapter point for the token-core/provider +// integration. Provider branches can register factories without changing this +// channel implementation. +type TokenProviderFactory func(context.Context, *model.SimChannelConfigItemCache, SharedTokenConfig) (ydTokenProvider, error) + +var sharedTokenFactories = struct { + sync.RWMutex + m map[string]TokenProviderFactory +}{m: make(map[string]TokenProviderFactory)} + +func RegisterTokenProviderFactory(provider string, factory TokenProviderFactory) { + provider = strings.ToLower(strings.TrimSpace(provider)) + if provider == "" || factory == nil { + return + } + sharedTokenFactories.Lock() + sharedTokenFactories.m[provider] = factory + sharedTokenFactories.Unlock() +} + +func tokenProviderFactory(provider string) (TokenProviderFactory, bool) { + sharedTokenFactories.RLock() + f, ok := sharedTokenFactories.m[strings.ToLower(strings.TrimSpace(provider))] + sharedTokenFactories.RUnlock() + return f, ok +} + +type oneLinkAPIConfig struct { + BaseURL string + TokenPath string + Timeout time.Duration + transIDPrefix string +} + +type sharedOneLinkChannelConfig struct { + PwdKey string `json:"pwdKey"` + ChannelNo string `json:"channelNo"` + AppID string `json:"appId"` + SubAccount string `json:"subAccount"` + Secret string `json:"secret"` + OperatorID string `json:"operatorId"` + UserID string `json:"userId"` + TokenType string `json:"tokenType"` + UserName string `json:"userName"` + Password string `json:"password"` + PasswayUID string `json:"passwayUid"` + ChannelID string `json:"channelId"` + ChannelPName string `json:"channelPname"` + CardNo string `json:"cardNo"` + CMCCAppID string `json:"cmccAppId"` + // Sign is the fixed signature issued by the LanShu open route. It is + // passed through verbatim and is never derived locally. + Sign string `json:"sign"` + // TokenBaseURL lets a channel override the template's token.baseUrl, so + // accounts living on a different upstream host can share one template. + TokenBaseURL string `json:"tokenBaseUrl"` + LegacyCredentials *struct { + PwdKey string `json:"pwdKey"` + ChannelNo string `json:"channelNo"` + AppID string `json:"appId"` + UserName string `json:"userName"` + Password string `json:"password"` + PasswayUID string `json:"passwayUid"` + } `json:"credentials"` +} + +type sharedOneLinkTemplateConfig struct { + API struct { + BaseURL string `json:"baseUrl"` + } `json:"api"` + Token struct { + Provider string `json:"provider"` + BaseURL string `json:"baseUrl"` + Path string `json:"path"` + Params struct { + LoginPath string `json:"loginPath"` + CarrierType *int `json:"carrierType"` + } `json:"params"` + LegacyLoginPath string `json:"loginPath"` + LegacyCarrierType *int `json:"carrierType"` + } `json:"token"` +} + +type sharedTokenManagerProvider struct { + manager sharedtoken.TokenManager + chanKey string + includeDeviceToken bool +} + +func (p *sharedTokenManagerProvider) GetToken(ctx context.Context) (string, error) { + return p.manager.GetToken(ctx, p.chanKey) +} + +func (p *sharedTokenManagerProvider) GetDeviceToken(ctx context.Context) (string, error) { + if !p.includeDeviceToken { + return "", nil + } + token, err := p.manager.Get(ctx, p.chanKey) + if err != nil { + return "", err + } + return token.DeviceValue, nil +} + +func (p *sharedTokenManagerProvider) RecoverToken(ctx context.Context, rejected string) (string, error) { + p.manager.InvalidateIfCurrent(p.chanKey, rejected) + return p.manager.GetToken(ctx, p.chanKey) +} + +// GetSharedToken returns the shared token together with its expiry, fetching +// from the provider on demand when the cached value is missing or expired. +// Expiry is zero when the provider does not report one. +func (p *sharedTokenManagerProvider) GetSharedToken(ctx context.Context) (sharedtoken.OneLinkToken, error) { + return p.manager.Get(ctx, p.chanKey) +} + +type tengyuFetcher struct{ provider *TengyuTokenProvider } + +func (f tengyuFetcher) FetchToken(ctx context.Context, _ string) (sharedtoken.OneLinkToken, error) { + t, err := f.provider.Fetch(ctx) + if err != nil { + return sharedtoken.OneLinkToken{}, err + } + return sharedtoken.OneLinkToken{Value: t.Value, CreatedAt: t.CreatedAt, ExpiresAt: t.ExpiresAt}, nil +} + +type xuzhouMobileFetcher struct{ provider *XuzhouMobileTokenProvider } + +func (f xuzhouMobileFetcher) FetchToken(ctx context.Context, _ string) (sharedtoken.OneLinkToken, error) { + t, err := f.provider.Fetch(ctx) + if err != nil { + return sharedtoken.OneLinkToken{}, err + } + return sharedtoken.OneLinkToken{Value: t.Value, ExpiresAt: t.ExpiresAt}, nil +} + +type henanLelianFetcher struct{ provider *HenanLelianTokenProvider } + +func (f henanLelianFetcher) FetchToken(ctx context.Context, _ string) (sharedtoken.OneLinkToken, error) { + t, err := f.provider.Fetch(ctx) + if err != nil { + return sharedtoken.OneLinkToken{}, err + } + return sharedtoken.OneLinkToken{Value: t.Value, CreatedAt: t.CreatedAt}, nil +} + +func parseSharedOneLinkConfig(ctx context.Context, channelConfig *model.SimChannelConfigItemCache) (SharedTokenConfig, *oneLinkAPIConfig, error) { + if channelConfig == nil || channelConfig.Channel == nil || channelConfig.Template == nil { + return SharedTokenConfig{}, nil, fmt.Errorf("channel and template config are required") + } + var template sharedOneLinkTemplateConfig + if strings.TrimSpace(channelConfig.Template.TempConfig) == "" { + return SharedTokenConfig{}, nil, fmt.Errorf("temp_config is required") + } + if err := json.Unmarshal([]byte(channelConfig.Template.TempConfig), &template); err != nil { + return SharedTokenConfig{}, nil, fmt.Errorf("invalid temp_config: %w", err) + } + var channel sharedOneLinkChannelConfig + if strings.TrimSpace(channelConfig.Channel.ChanConfig) != "" { + if err := json.Unmarshal([]byte(channelConfig.Channel.ChanConfig), &channel); err != nil { + return SharedTokenConfig{}, nil, fmt.Errorf("invalid chan_config: %w", err) + } + } + legacy := channel.LegacyCredentials + provider := strings.ToLower(strings.TrimSpace(template.Token.Provider)) + if provider == "" { + provider = "tengyu" + } + tokenBaseURL := strings.TrimSpace(template.Token.BaseURL) + // A channel may point token fetches at a different upstream host than the + // template default (e.g. the 361 account lives on gxhy.ge-xing.cn while + // the template points at hywx.xjict.com). + if override := strings.TrimSpace(channel.TokenBaseURL); override != "" { + tokenBaseURL = override + } + if tokenBaseURL == "" { + return SharedTokenConfig{}, nil, fmt.Errorf("token.baseUrl is required") + } + if strings.TrimSpace(template.Token.Path) == "" { + return SharedTokenConfig{}, nil, fmt.Errorf("token.path is required") + } + pwdKey := strings.TrimSpace(channel.PwdKey) + channelNo := strings.TrimSpace(channel.ChannelNo) + appID := strings.TrimSpace(channel.AppID) + appSecret := strings.TrimSpace(channelConfig.Channel.AppSecret) + subAccount := strings.TrimSpace(channel.SubAccount) + secret := channel.Secret + operatorID := strings.TrimSpace(channel.OperatorID) + userID := strings.TrimSpace(channel.UserID) + tokenType := strings.TrimSpace(channel.TokenType) + channelID := strings.TrimSpace(channel.ChannelID) + channelPName := strings.TrimSpace(channel.ChannelPName) + cardNo := strings.TrimSpace(channel.CardNo) + cmccAppID := strings.TrimSpace(channel.CMCCAppID) + sign := strings.TrimSpace(channel.Sign) + userName := strings.TrimSpace(channel.UserName) + password := channel.Password + passwayUID := strings.TrimSpace(channel.PasswayUID) + if legacy != nil { + if pwdKey == "" { + pwdKey = strings.TrimSpace(legacy.PwdKey) + } + if channelNo == "" { + channelNo = strings.TrimSpace(legacy.ChannelNo) + } + if appID == "" { + appID = strings.TrimSpace(legacy.AppID) + } + if userName == "" { + userName = strings.TrimSpace(legacy.UserName) + } + if password == "" { + password = legacy.Password + } + if passwayUID == "" { + passwayUID = strings.TrimSpace(legacy.PasswayUID) + } + g.Log().Warningf(ctx, "shared token channel %s uses deprecated chan_config.credentials; migrate fields to the root", channelConfig.Channel.ChanKey) + } + carrierType := 1 + if template.Token.Params.CarrierType != nil { + carrierType = *template.Token.Params.CarrierType + } else if template.Token.LegacyCarrierType != nil { + carrierType = *template.Token.LegacyCarrierType + g.Log().Warningf(ctx, "shared token template %s uses deprecated token.carrierType; move it to token.params.carrierType", channelConfig.Template.TempKey) + } + loginPath := strings.TrimSpace(template.Token.Params.LoginPath) + if loginPath == "" && strings.TrimSpace(template.Token.LegacyLoginPath) != "" { + loginPath = strings.TrimSpace(template.Token.LegacyLoginPath) + g.Log().Warningf(ctx, "shared token template %s uses deprecated token.loginPath; move it to token.params.loginPath", channelConfig.Template.TempKey) + } + timeout := resolveUpstreamHTTPTimeout(ctx, channelConfig.Template.TempConfig) + config := SharedTokenConfig{ + Provider: provider, TokenBaseURL: tokenBaseURL, LoginPath: loginPath, TokenPath: template.Token.Path, + CarrierType: carrierType, PwdKey: pwdKey, ChannelNo: channelNo, AppKey: strings.TrimSpace(channelConfig.Channel.AppKey), AppID: appID, + AppSecret: appSecret, SubAccount: subAccount, Secret: secret, OperatorID: operatorID, UserID: userID, TokenType: tokenType, + UserName: userName, Password: password, PasswayUID: passwayUID, + ChannelID: channelID, ChannelPName: channelPName, CardNo: cardNo, CMCCAppID: cmccAppID, + Sign: sign, Timeout: timeout, + } + if provider == "daihe" && config.AppID == "" { + config.AppID = strings.TrimSpace(channelConfig.Channel.AppKey) + } + if provider == "daihe" && (config.AppID == "" || config.AppSecret == "" || config.CMCCAppID == "") { + return SharedTokenConfig{}, nil, fmt.Errorf("chan_config.appId, app_secret and chan_config.cmccAppId are required for daihe") + } + if provider == "zibo" { + config.AppID = strings.TrimSpace(channelConfig.Channel.AppKey) + config.AppSecret = strings.TrimSpace(channelConfig.Channel.AppSecret) + if config.AppID == "" || config.AppSecret == "" { + return SharedTokenConfig{}, nil, fmt.Errorf("app_key and app_secret are required for zibo") + } + } + if provider == "yunze" { + config.AppID = strings.TrimSpace(channelConfig.Channel.AppKey) + if config.AppID == "" { + return SharedTokenConfig{}, nil, fmt.Errorf("app_key is required for yunze") + } + } + if provider == "tengyu" && (config.PwdKey == "" || config.ChannelNo == "") { + return SharedTokenConfig{}, nil, fmt.Errorf("chan_config.pwdKey and chan_config.channelNo are required") + } + if provider == "xuzhou_mobile" && config.AppID == "" { + return SharedTokenConfig{}, nil, fmt.Errorf("chan_config.appId is required for xuzhou_mobile") + } + if provider == "lanshang" && (config.SubAccount == "" || config.Secret == "") { + return SharedTokenConfig{}, nil, fmt.Errorf("chan_config.subAccount and chan_config.secret are required for lanshang") + } + if provider == "qianxun" && (config.OperatorID == "" || config.AppID == "" || config.TokenType == "" || config.AppSecret == "") { + return SharedTokenConfig{}, nil, fmt.Errorf("chan_config.operatorId, chan_config.appId, chan_config.tokenType and app_secret are required for qianxun") + } + if provider == "lanshu" && (config.AppID == "" || config.Password == "" || config.Sign == "" || config.TokenType == "") { + return SharedTokenConfig{}, nil, fmt.Errorf("chan_config.appId, chan_config.password, chan_config.sign and chan_config.tokenType are required for lanshu") + } + if provider == "beixin" && (config.PwdKey == "" || config.ChannelNo == "") { + return SharedTokenConfig{}, nil, fmt.Errorf("chan_config.pwdKey and chan_config.channelNo are required for beixin") + } + if provider == "mobile_public" && (config.UserID == "" || config.AppSecret == "") { + return SharedTokenConfig{}, nil, fmt.Errorf("chan_config.userId and app_secret are required for mobile_public") + } + if provider == "apika" && (config.AppKey == "" || config.AppSecret == "" || config.AppID == "") { + return SharedTokenConfig{}, nil, fmt.Errorf("app_key, app_secret and chan_config.appId are required for apika") + } + if provider == "henan_lelian" { + if strings.TrimSpace(config.LoginPath) == "" { + return SharedTokenConfig{}, nil, fmt.Errorf("token.params.loginPath is required for henan_lelian") + } + if config.UserName == "" || strings.TrimSpace(config.Password) == "" || config.PasswayUID == "" { + return SharedTokenConfig{}, nil, fmt.Errorf("chan_config.userName, chan_config.password and chan_config.passwayUid are required for henan_lelian") + } + } + if provider == "henan_xinjing" { + if config.AppID == "" || config.AppSecret == "" { + return SharedTokenConfig{}, nil, fmt.Errorf("chan_config.appId (userId) and app_secret (apikey) are required for henan_xinjing") + } + if config.ChannelID == "" || config.ChannelPName == "" { + return SharedTokenConfig{}, nil, fmt.Errorf("chan_config.channelId and chan_config.channelPname are required for henan_xinjing") + } + } + transIDPrefix := sharedOneLinkTransIDPrefix + if provider == "daihe" { + transIDPrefix = config.CMCCAppID + } + if provider == "zibo" { + transIDPrefix = config.AppID + } + businessBaseURL := strings.TrimSpace(template.API.BaseURL) + if businessBaseURL == "" { + businessBaseURL = sharedOneLinkAPIBaseURL + } + return config, &oneLinkAPIConfig{ + BaseURL: businessBaseURL, transIDPrefix: transIDPrefix, Timeout: timeout, + }, nil +} + +func parseOneLinkTemplateAPI(ctx context.Context, channelConfig *model.SimChannelConfigItemCache) *oneLinkAPIConfig { + if channelConfig == nil || channelConfig.Template == nil || strings.TrimSpace(channelConfig.Template.TempConfig) == "" { + return nil + } + var raw sharedOneLinkTemplateConfig + if err := json.Unmarshal([]byte(channelConfig.Template.TempConfig), &raw); err != nil { + g.Log().Warningf(ctx, "parse OneLink template config failed: %v", err) + return nil + } + baseURL := strings.TrimSpace(raw.API.BaseURL) + if baseURL == "" { + baseURL = gUrlBase + } + timeout := resolveUpstreamHTTPTimeout(ctx, channelConfig.Template.TempConfig) + return &oneLinkAPIConfig{ + BaseURL: baseURL, TokenPath: raw.Token.Path, Timeout: timeout, + } +} + +type sSimOneLinkShared struct{} + +func NewSimOneLinkShared() *sSimOneLinkShared { return &sSimOneLinkShared{} } + +func init() { + shared := NewSimOneLinkShared() + service.UpstreamAPI().RegisterTemp(sharedOneLinkTengyuTemplateKey, shared) + service.UpstreamAPI().RegisterTemp(sharedOneLinkXuzhouTemplateKey, shared) + service.UpstreamAPI().RegisterTemp(sharedOneLinkLelianTemplateKey, shared) + service.UpstreamAPI().RegisterTemp(sharedOneLinkQianxunTemplateKey, shared) + service.UpstreamAPI().RegisterTemp(sharedOneLinkHenanXinjingTemplateKey, shared) + service.UpstreamAPI().RegisterTemp(sharedOneLinkDaiheTemplateKey, shared) + service.UpstreamAPI().RegisterTemp(sharedOneLinkMobilePublicTemplateKey, shared) + service.UpstreamAPI().RegisterTemp(sharedOneLinkMobilePublicPreviousKey, shared) + service.UpstreamAPI().RegisterTemp(sharedOneLinkMobilePublicLegacyKey, shared) + service.UpstreamAPI().RegisterTemp(sharedOneLinkApikaTemplateKey, shared) + service.UpstreamAPI().RegisterTemp(sharedOneLinkYunzeTemplateKey, shared) + service.UpstreamAPI().RegisterTemp(sharedOneLinkLanshuTemplateKey, shared) + service.UpstreamAPI().RegisterTemp(sharedOneLinkBeixinTemplateKey, shared) +} + +func (s *sSimOneLinkShared) NewTemp(ctx context.Context, channelConfig *model.SimChannelConfigItemCache) model.SimCardChannelI { + providerConfig, apiConfig, err := parseSharedOneLinkConfig(ctx, channelConfig) + if err != nil { + g.Log().Errorf(ctx, "create shared_token_onelink failed: %v", err) + return nil + } + factory, ok := tokenProviderFactory(providerConfig.Provider) + if !ok || factory == nil { + g.Log().Errorf(ctx, "create shared_token_onelink failed: unsupported provider %s", providerConfig.Provider) + return nil + } + provider, err := factory(ctx, channelConfig, providerConfig) + if err != nil || provider == nil { + if err == nil { + err = fmt.Errorf("factory returned nil provider") + } + g.Log().Errorf(ctx, "create shared_token_onelink failed: %v", err) + return nil + } + return &sSimYD{channelConfig: channelConfig, tokenProvider: provider, apiConfig: apiConfig} +} + +func init() { + RegisterTokenProviderFactory("mobile_public", func(ctx context.Context, channelConfig *model.SimChannelConfigItemCache, cfg SharedTokenConfig) (ydTokenProvider, error) { + if channelConfig == nil || channelConfig.Channel == nil { + return nil, fmt.Errorf("channel config is required") + } + fetcher := mobilePublicFetcher{provider: NewMobilePublicTokenProvider(MobilePublicTokenConfig{ + BaseURL: cfg.TokenBaseURL, + Path: cfg.TokenPath, + UserID: cfg.UserID, + Secret: cfg.AppSecret, + Timeout: cfg.Timeout, + })} + manager := sharedtoken.NewTokenManager(fetcher, sharedtoken.WithRefreshAhead(30*time.Second)) + manager.SeedFromConfig(channelConfig) + return &sharedTokenManagerProvider{manager: manager, chanKey: channelConfig.Channel.ChanKey}, nil + }) + RegisterTokenProviderFactory("apika", func(ctx context.Context, channelConfig *model.SimChannelConfigItemCache, cfg SharedTokenConfig) (ydTokenProvider, error) { + if channelConfig == nil || channelConfig.Channel == nil { + return nil, fmt.Errorf("channel config is required") + } + fetcher := apikaFetcher{provider: NewApikaTokenProvider(ApikaTokenConfig{ + BaseURL: cfg.TokenBaseURL, Path: cfg.TokenPath, AppKey: cfg.AppKey, + AppSecret: cfg.AppSecret, AppID: cfg.AppID, Timeout: cfg.Timeout, + })} + manager := sharedtoken.NewTokenManager(fetcher, sharedtoken.WithRefreshAhead(30*time.Second)) + manager.SeedFromConfig(channelConfig) + return &sharedTokenManagerProvider{manager: manager, chanKey: channelConfig.Channel.ChanKey}, nil + }) + RegisterTokenProviderFactory("qianxun", func(ctx context.Context, channelConfig *model.SimChannelConfigItemCache, cfg SharedTokenConfig) (ydTokenProvider, error) { + if channelConfig == nil || channelConfig.Channel == nil { + return nil, fmt.Errorf("channel config is required") + } + fetcher := qianxunFetcher{provider: NewQianxunTokenProvider(QianxunTokenConfig{BaseURL: cfg.TokenBaseURL, Path: cfg.TokenPath, OperatorID: cfg.OperatorID, AppID: cfg.AppID, TokenType: cfg.TokenType, Key: cfg.AppSecret, Timeout: cfg.Timeout})} + manager := sharedtoken.NewTokenManager(fetcher, sharedtoken.WithUntilInvalidated()) + manager.SeedFromConfig(channelConfig) + return &sharedTokenManagerProvider{manager: manager, chanKey: channelConfig.Channel.ChanKey}, nil + }) + RegisterTokenProviderFactory("henan_xinjing", func(ctx context.Context, channelConfig *model.SimChannelConfigItemCache, cfg SharedTokenConfig) (ydTokenProvider, error) { + if channelConfig == nil || channelConfig.Channel == nil { + return nil, fmt.Errorf("channel config is required") + } + fetcher := henanXinjingFetcher{provider: NewHenanXinjingTokenProvider(HenanXinjingTokenConfig{ + BaseURL: cfg.TokenBaseURL, + Path: cfg.TokenPath, + UserID: cfg.AppID, + APIKey: cfg.AppSecret, + ChannelID: cfg.ChannelID, + ChannelPName: cfg.ChannelPName, + CardNo: cfg.CardNo, + Timeout: cfg.Timeout, + })} + manager := sharedtoken.NewTokenManager(fetcher) + manager.SeedFromConfig(channelConfig) + return &sharedTokenManagerProvider{manager: manager, chanKey: channelConfig.Channel.ChanKey}, nil + }) + RegisterTokenProviderFactory("tengyu", func(ctx context.Context, channelConfig *model.SimChannelConfigItemCache, cfg SharedTokenConfig) (ydTokenProvider, error) { + fetcher := tengyuFetcher{provider: NewTengyuTokenProvider(TengyuTokenConfig{BaseURL: cfg.TokenBaseURL, Path: cfg.TokenPath, PwdKey: cfg.PwdKey, ChannelNo: cfg.ChannelNo, Timeout: cfg.Timeout})} + // Tengyu does not document a refresh-ahead mechanism. Reuse the cached + // token until its local one-hour validity expires, then fetch on demand. + manager := sharedtoken.NewTokenManager(fetcher) + manager.SeedFromConfig(channelConfig) + return &sharedTokenManagerProvider{manager: manager, chanKey: channelConfig.Channel.ChanKey}, nil + }) + RegisterTokenProviderFactory("xuzhou_mobile", func(ctx context.Context, channelConfig *model.SimChannelConfigItemCache, cfg SharedTokenConfig) (ydTokenProvider, error) { + if channelConfig == nil || channelConfig.Channel == nil { + return nil, fmt.Errorf("channel config is required") + } + fetcher := xuzhouMobileFetcher{provider: NewXuzhouMobileTokenProvider(XuzhouMobileTokenConfig{ + BaseURL: cfg.TokenBaseURL, Path: cfg.TokenPath, AppID: cfg.AppID, + CarrierType: cfg.CarrierType, TTL: sharedOneLinkXuzhouTokenTTL, Timeout: cfg.Timeout, + })} + manager := sharedtoken.NewTokenManager(fetcher) + manager.SeedFromConfig(channelConfig) + return &sharedTokenManagerProvider{manager: manager, chanKey: channelConfig.Channel.ChanKey}, nil + }) + RegisterTokenProviderFactory("daihe", func(ctx context.Context, channelConfig *model.SimChannelConfigItemCache, cfg SharedTokenConfig) (ydTokenProvider, error) { + if channelConfig == nil || channelConfig.Channel == nil { + return nil, fmt.Errorf("channel config is required") + } + fetcher := daiheTokenFetcher{provider: NewDaiheTokenProvider(DaiheTokenConfig{ + BaseURL: cfg.TokenBaseURL, Path: cfg.TokenPath, AppID: cfg.AppID, AppSecret: cfg.AppSecret, + CMCCAppID: cfg.CMCCAppID, Timeout: cfg.Timeout, + })} + manager := sharedtoken.NewTokenManager(fetcher) + manager.SeedFromConfig(channelConfig) + return &sharedTokenManagerProvider{manager: manager, chanKey: channelConfig.Channel.ChanKey, includeDeviceToken: true}, nil + }) + RegisterTokenProviderFactory("zibo", func(ctx context.Context, channelConfig *model.SimChannelConfigItemCache, cfg SharedTokenConfig) (ydTokenProvider, error) { + if channelConfig == nil || channelConfig.Channel == nil { + return nil, fmt.Errorf("channel config is required") + } + fetcher := ziboFetcher{provider: NewZiboTokenProvider(ZiboTokenConfig{ + BaseURL: cfg.TokenBaseURL, + Path: cfg.TokenPath, + AppID: cfg.AppID, + Password: cfg.AppSecret, + Timeout: cfg.Timeout, + })} + manager := sharedtoken.NewTokenManager(fetcher) + manager.SeedFromConfig(channelConfig) + return &sharedTokenManagerProvider{manager: manager, chanKey: channelConfig.Channel.ChanKey}, nil + }) + RegisterTokenProviderFactory("yunze", func(ctx context.Context, channelConfig *model.SimChannelConfigItemCache, cfg SharedTokenConfig) (ydTokenProvider, error) { + if channelConfig == nil || channelConfig.Channel == nil { + return nil, fmt.Errorf("channel config is required") + } + fetcher := yunzeFetcher{provider: NewYunzeTokenProvider(YunzeTokenConfig{ + BaseURL: cfg.TokenBaseURL, + Path: cfg.TokenPath, + AppID: cfg.AppID, + Timeout: cfg.Timeout, + })} + manager := sharedtoken.NewTokenManager(fetcher, sharedtoken.WithRefreshAhead(30*time.Second)) + manager.SeedFromConfig(channelConfig) + return &sharedTokenManagerProvider{manager: manager, chanKey: channelConfig.Channel.ChanKey}, nil + }) + RegisterTokenProviderFactory("lanshu", func(ctx context.Context, channelConfig *model.SimChannelConfigItemCache, cfg SharedTokenConfig) (ydTokenProvider, error) { + if channelConfig == nil || channelConfig.Channel == nil { + return nil, fmt.Errorf("channel config is required") + } + fetcher := lanshuFetcher{provider: NewLanShuTokenProvider(LanShuTokenConfig{ + BaseURL: cfg.TokenBaseURL, Path: cfg.TokenPath, AppID: cfg.AppID, + Password: cfg.Password, Sign: cfg.Sign, TokenType: cfg.TokenType, Timeout: cfg.Timeout, + })} + // The upstream reports a real expireTime (~19-26 minutes) and issues a + // new token per request, so refresh proactively inside the window. + manager := sharedtoken.NewTokenManager(fetcher, sharedtoken.WithRefreshAhead(60*time.Second)) + manager.SeedFromConfig(channelConfig) + return &sharedTokenManagerProvider{manager: manager, chanKey: channelConfig.Channel.ChanKey}, nil + }) + RegisterTokenProviderFactory("beixin", func(ctx context.Context, channelConfig *model.SimChannelConfigItemCache, cfg SharedTokenConfig) (ydTokenProvider, error) { + if channelConfig == nil || channelConfig.Channel == nil { + return nil, fmt.Errorf("channel config is required") + } + fetcher := beixinFetcher{provider: NewBeixinTokenProvider(BeixinTokenConfig{ + BaseURL: cfg.TokenBaseURL, Path: cfg.TokenPath, PwdKey: cfg.PwdKey, + ChannelNo: cfg.ChannelNo, Timeout: cfg.Timeout, + })} + // The upstream returns no expireTime, so the provider holds a local + // one-hour window (mirroring tengyu). Refresh proactively inside it + // rather than waiting for a 12021 rejection. + manager := sharedtoken.NewTokenManager(fetcher, sharedtoken.WithRefreshAhead(30*time.Second)) + manager.SeedFromConfig(channelConfig) + return &sharedTokenManagerProvider{manager: manager, chanKey: channelConfig.Channel.ChanKey}, nil + }) + RegisterTokenProviderFactory("lanshang", func(ctx context.Context, channelConfig *model.SimChannelConfigItemCache, cfg SharedTokenConfig) (ydTokenProvider, error) { + if channelConfig == nil || channelConfig.Channel == nil { + return nil, fmt.Errorf("channel config is required") + } + if cfg.SubAccount == "" || cfg.Secret == "" { + return nil, fmt.Errorf("lanshang subAccount and secret are required") + } + fetcher := lanshangFetcher{provider: NewLanShangTokenProvider(LanShangTokenConfig{ + BaseURL: cfg.TokenBaseURL, Path: cfg.TokenPath, + SubAccount: cfg.SubAccount, Secret: cfg.Secret, Timeout: cfg.Timeout, + })} + manager := sharedtoken.NewTokenManager(fetcher) + manager.SeedFromConfig(channelConfig) + return &sharedTokenManagerProvider{manager: manager, chanKey: channelConfig.Channel.ChanKey}, nil + }) + RegisterTokenProviderFactory("henan_lelian", func(ctx context.Context, channelConfig *model.SimChannelConfigItemCache, cfg SharedTokenConfig) (ydTokenProvider, error) { + if channelConfig == nil || channelConfig.Channel == nil { + return nil, fmt.Errorf("channel config is required") + } + fetcher := henanLelianFetcher{provider: NewHenanLelianTokenProvider(HenanLelianTokenConfig{ + BaseURL: cfg.TokenBaseURL, LoginPath: cfg.LoginPath, Path: cfg.TokenPath, + UserName: cfg.UserName, Password: cfg.Password, PasswayUID: cfg.PasswayUID, + Timeout: cfg.Timeout, + })} + manager := sharedtoken.NewTokenManager(fetcher, sharedtoken.WithUntilInvalidated()) + manager.SeedFromConfig(channelConfig) + return &sharedTokenManagerProvider{manager: manager, chanKey: channelConfig.Channel.ChanKey}, nil + }) +} diff --git a/internal/logic/upstreamimpl/sim_qianxun.go b/internal/logic/upstreamimpl/sim_qianxun.go new file mode 100644 index 0000000..f26719f --- /dev/null +++ b/internal/logic/upstreamimpl/sim_qianxun.go @@ -0,0 +1,880 @@ +package upstreamimpl + +import ( + "bytes" + "context" + "crypto/md5" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "strconv" + "strings" + "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" +) + +const ( + qianxunTempKey = "sim_qianxun_v3" + qianxunSuccessCode = "000000" + qianxunTimeLayout = "20060102150405" + qianxunBatchLimit = 200 +) + +var qianxunEast8 = time.FixedZone("UTC+8", 8*60*60) + +type sSimQianxun struct { + channelConfig *model.SimChannelConfigItemCache + baseURL string + appKey string + appSecret string + userName string + http *http.Client +} + +var _ model.SimCardChannelI = (*sSimQianxun)(nil) +var _ model.SimCardBatchQueryI = (*sSimQianxun)(nil) + +type qianxunResponse struct { + Status string `json:"status"` + Message string `json:"message"` + Result json.RawMessage `json:"result"` +} + +type qianxunCardInfo struct { + ICCID string `json:"iccid"` + IMSI string `json:"imsi"` + IMEI string `json:"imei"` + MSISDN string `json:"msisdn"` + AccessNumber string `json:"accessNumber"` + CardNo string `json:"cardNo"` + CardStatus any `json:"cardStatus"` + Status any `json:"status"` + SimStatus any `json:"simStatus"` + RealStatus any `json:"realStatus"` + StatusName string `json:"statusName"` + OpenDate string `json:"openDate"` + OpenTime string `json:"openTime"` + ActiveDate string `json:"activeDate"` + ActiveTime string `json:"activeTime"` + ActivateTime string `json:"activateTime"` + PackageName string `json:"packageName"` + MainOfferName string `json:"mainOfferName"` + ProdName string `json:"prodName"` + Remark string `json:"remark"` + UsageTotal any `json:"usageTotal"` + UsedGprs any `json:"usedGprs"` + UsedFlow any `json:"usedFlow"` + UseFlow any `json:"useFlow"` + CardFlow any `json:"cardFlow"` + Flow any `json:"flow"` + TotalFlow any `json:"totalFlow"` + ProdTotal any `json:"prodTotal"` + PackageFlow any `json:"packageFlow"` + LeftFlow any `json:"leftFlow"` + LastChangeDate string `json:"lastChangeDate"` + UpdateTime string `json:"updateTime"` +} + +type qianxunFlowHistory struct { + ICCID string `json:"iccid"` + BillingMonth string `json:"billingMonth"` + UsageTotal any `json:"usageTotal"` + Status string `json:"status"` +} + +type qianxunBatchCardResult struct { + ICCID string `json:"iccid"` + ICCIDData json.RawMessage `json:"iccidData"` + ICCIDResult json.RawMessage `json:"iccidResult"` +} + +func NewSimQianxun() *sSimQianxun { + return &sSimQianxun{} +} + +func init() { + service.UpstreamAPI().RegisterTemp(qianxunTempKey, NewSimQianxun()) +} + +func (s *sSimQianxun) NewTemp(ctx context.Context, channelConfig *model.SimChannelConfigItemCache) model.SimCardChannelI { + baseURL := "" + if channelConfig != nil && channelConfig.Template != nil { + var templateConfig struct { + API struct { + BaseURL string `json:"baseUrl"` + } `json:"api"` + } + if err := json.Unmarshal([]byte(channelConfig.Template.TempConfig), &templateConfig); err != nil { + g.Log().Warningf(ctx, "parse Qianxun template config failed: %v", err) + } else { + baseURL = strings.TrimRight(strings.TrimSpace(templateConfig.API.BaseURL), "/") + } + } + + var channelExtra struct { + UserName string `json:"userName"` + } + if channelConfig != nil && channelConfig.Channel != nil { + if err := json.Unmarshal([]byte(channelConfig.Channel.ChanConfig), &channelExtra); err != nil { + g.Log().Warningf(ctx, "parse Qianxun channel config failed, chanKey=%s err=%v", channelConfig.Channel.ChanKey, err) + } + } + + appKey := "" + appSecret := "" + if channelConfig != nil && channelConfig.Channel != nil { + appKey = strings.TrimSpace(channelConfig.Channel.AppKey) + appSecret = strings.TrimSpace(channelConfig.Channel.AppSecret) + } + + return &sSimQianxun{ + channelConfig: channelConfig, + baseURL: baseURL, + appKey: appKey, + appSecret: appSecret, + userName: strings.TrimSpace(channelExtra.UserName), + http: &http.Client{Timeout: resolveUpstreamHTTPTimeout(ctx, templateConfigJSON(channelConfig))}, + } +} + +func qianxunSign(appKey, appSecret, userName, timestamp string) string { + plain := fmt.Sprintf("appKey=%s&timeStamp=%s&userName=%s&appSecret=%s", appKey, timestamp, userName, appSecret) + sum := md5.Sum([]byte(plain)) + return hex.EncodeToString(sum[:]) +} + +func (s *sSimQianxun) authParams() (url.Values, error) { + if s == nil { + return nil, fmt.Errorf("Qianxun client is nil") + } + if strings.TrimSpace(s.baseURL) == "" { + return nil, fmt.Errorf("Qianxun baseUrl is not configured") + } + if strings.TrimSpace(s.appKey) == "" || strings.TrimSpace(s.appSecret) == "" || strings.TrimSpace(s.userName) == "" { + return nil, fmt.Errorf("Qianxun credentials are not configured") + } + timestamp := time.Now().In(qianxunEast8).Format(qianxunTimeLayout) + params := url.Values{} + params.Set("appKey", s.appKey) + params.Set("timeStamp", timestamp) + params.Set("userName", s.userName) + params.Set("sign", qianxunSign(s.appKey, s.appSecret, s.userName, timestamp)) + return params, nil +} + +func (s *sSimQianxun) post(ctx context.Context, endpoint string, params url.Values) (qianxunResponse, error) { + authParams, err := s.authParams() + if err != nil { + return qianxunResponse{}, err + } + for key, values := range params { + for _, value := range values { + authParams.Add(key, value) + } + } + + target := strings.TrimRight(s.baseURL, "/") + "/" + strings.TrimLeft(endpoint, "/") + body := authParams.Encode() + req, err := http.NewRequestWithContext(ctx, http.MethodPost, target, bytes.NewBufferString(body)) + if err != nil { + return qianxunResponse{}, fmt.Errorf("create Qianxun request %s: %w", endpoint, err) + } + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + + client := s.http + if client == nil { + client = &http.Client{Timeout: defaultUpstreamHTTPTimeout} + } + resp, err := client.Do(req) + if err != nil { + return qianxunResponse{}, fmt.Errorf("Qianxun request %s: %w", endpoint, err) + } + defer resp.Body.Close() + + responseBody, err := io.ReadAll(resp.Body) + if err != nil { + return qianxunResponse{}, fmt.Errorf("read Qianxun response %s: %w", endpoint, err) + } + rawResponse := strings.TrimSpace(string(responseBody)) + if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices { + g.Log().Warningf(ctx, "Qianxun HTTP error endpoint=%s status=%d response=%s", endpoint, resp.StatusCode, rawResponse) + return qianxunResponse{}, fmt.Errorf("Qianxun HTTP status %d", resp.StatusCode) + } + + var output qianxunResponse + if err := json.Unmarshal(responseBody, &output); err != nil { + g.Log().Warningf(ctx, "decode Qianxun response failed endpoint=%s response=%s err=%v", endpoint, rawResponse, err) + return qianxunResponse{}, fmt.Errorf("decode Qianxun response %s: %w", endpoint, err) + } + if !qianxunSuccess(output) { + g.Log().Warningf(ctx, "Qianxun business error endpoint=%s status=%s message=%s response=%s", endpoint, output.Status, output.Message, rawResponse) + } else { + g.Log().Infof(ctx, "Qianxun response endpoint=%s status=%s response=%s", endpoint, output.Status, rawResponse) + } + return output, nil +} + +func qianxunSuccess(resp qianxunResponse) bool { + return strings.TrimSpace(resp.Status) == qianxunSuccessCode +} + +func qianxunBusinessError[T any](simKey *model.SimKey, resp qianxunResponse) *model.SimBaseRes[T] { + return &model.SimBaseRes[T]{ + SimKey: simKey, + Code: gconv.Int(resp.Status), + ErrCode: resp.Status, + Message: resp.Message, + } +} + +func qianxunError[T any](simKey *model.SimKey, err error) *model.SimBaseRes[T] { + return &model.SimBaseRes[T]{SimKey: simKey, Code: consts.ApiStatusError, Message: err.Error()} +} + +func qianxunUnsupported[T any](simKeys []*model.SimKey, message string) []*model.SimBaseRes[T] { + items := make([]*model.SimBaseRes[T], 0, len(simKeys)) + for _, simKey := range simKeys { + items = append(items, &model.SimBaseRes[T]{ + SimKey: simKey, + Code: consts.ApiInvokeException, + Message: message, + }) + } + return items +} + +func qianxunICCID(simKey *model.SimKey) (string, error) { + if simKey == nil || strings.TrimSpace(simKey.ICCID) == "" { + return "", fmt.Errorf("ICCID not found") + } + return strings.TrimSpace(simKey.ICCID), nil +} + +func decodeQianxunData[T any](raw json.RawMessage, target *T) error { + if len(raw) == 0 || string(raw) == "null" { + return fmt.Errorf("Qianxun response result is empty") + } + if err := json.Unmarshal(raw, target); err != nil { + return fmt.Errorf("decode Qianxun response result: %w", err) + } + return nil +} + +func decodeQianxunStringJSON(raw json.RawMessage, target any) error { + if len(raw) == 0 || string(raw) == "null" { + return fmt.Errorf("Qianxun batch item result is empty") + } + var text string + if err := json.Unmarshal(raw, &text); err == nil { + text = strings.TrimSpace(text) + if !strings.HasPrefix(text, "{") && !strings.HasPrefix(text, "[") { + return errors.New(text) + } + return json.Unmarshal([]byte(text), target) + } + return json.Unmarshal(raw, target) +} + +func qianxunCardStatus(ctx context.Context, value any) int { + status := strings.TrimSpace(gconv.String(value)) + switch status { + case "": + return 0 + case "0", "10": + return 2 + case "1": + return 1 + case "3", "4", "8": + return 4 + case "6": + return 6 + case "7": + return 7 + case "9": + return 9 + default: + g.Log().Warningf(ctx, "Unknown Qianxun card status %s from upstream", status) + return 0 + } +} + +func qianxunUsageMB(values ...any) float64 { + for _, value := range values { + text := strings.TrimSpace(gconv.String(value)) + if text != "" && text != "" { + return parseDataUsage(text) + } + } + return 0 +} + +func qianxunCardMessage(resp qianxunResponse) string { + return firstNonEmpty(resp.Message, "成功") +} + +func qianxunCardToBasic(data qianxunCardInfo) model.SimBasicInfoItem { + status := strconv.Itoa(qianxunCardStatus(context.Background(), qianxunStatusValue(data))) + return model.SimBasicInfoItem{ + ICCID: data.ICCID, + IMSI: data.IMSI, + MSISDN: firstNonEmpty(data.MSISDN, data.AccessNumber, data.CardNo), + ActiveDate: firstNonEmpty(data.ActiveDate, data.ActiveTime, data.ActivateTime), + OpenDate: firstNonEmpty(data.OpenDate, data.OpenTime), + Remark: data.Remark, + SimStatus: status, + MainOfferName: firstNonEmpty(data.MainOfferName, data.PackageName, data.ProdName), + } +} + +func qianxunStatusValue(data qianxunCardInfo) string { + return firstNonEmpty(gconv.String(data.SimStatus), gconv.String(data.CardStatus), gconv.String(data.Status), gconv.String(data.RealStatus)) +} + +func (s *sSimQianxun) queryCardInfo(ctx context.Context, simKey *model.SimKey) (qianxunCardInfo, qianxunResponse, error) { + iccid, err := qianxunICCID(simKey) + if err != nil { + return qianxunCardInfo{}, qianxunResponse{}, err + } + params := url.Values{} + params.Set("iccid", iccid) + resp, err := s.post(ctx, "/api/v3/cardInfo", params) + if err != nil || !qianxunSuccess(resp) { + return qianxunCardInfo{}, resp, err + } + var data qianxunCardInfo + if err := decodeQianxunData(resp.Result, &data); err != nil { + return qianxunCardInfo{}, resp, err + } + return data, resp, nil +} + +func (s *sSimQianxun) QuerySimBasicInfo(ctx context.Context, input *model.SimBasicInfoInput) (model.SimBasicInfoOutput, error) { + if input == nil { + return make(model.SimBasicInfoOutput, 0), nil + } + items := make(model.SimBasicInfoOutput, 0, len(input.SimKeys)) + for _, simKey := range input.SimKeys { + data, resp, err := s.queryCardInfo(ctx, simKey) + if err != nil { + items = append(items, qianxunError[model.SimBasicInfoItem](simKey, err)) + continue + } + if !qianxunSuccess(resp) { + items = append(items, qianxunBusinessError[model.SimBasicInfoItem](simKey, resp)) + continue + } + items = append(items, &model.SimBaseRes[model.SimBasicInfoItem]{ + SimKey: simKey, + Code: consts.ApiStatusSuccess, + Message: qianxunCardMessage(resp), + ResData: qianxunCardToBasic(data), + }) + } + return items, nil +} + +func (s *sSimQianxun) QuerySimCardStatus(ctx context.Context, input *model.SimCardStatusInput) (model.SimCardStatusOutput, error) { + if input == nil { + return make(model.SimCardStatusOutput, 0), nil + } + items := make(model.SimCardStatusOutput, 0, len(input.SimKeys)) + for _, simKey := range input.SimKeys { + data, resp, err := s.queryCardInfo(ctx, simKey) + if err != nil { + items = append(items, qianxunError[model.SimCardStatusItem](simKey, err)) + continue + } + if !qianxunSuccess(resp) { + items = append(items, qianxunBusinessError[model.SimCardStatusItem](simKey, resp)) + continue + } + items = append(items, &model.SimBaseRes[model.SimCardStatusItem]{ + SimKey: simKey, + Code: consts.ApiStatusSuccess, + Message: qianxunCardMessage(resp), + ResData: model.SimCardStatusItem{ + CardStatus: qianxunCardStatus(ctx, qianxunStatusValue(data)), + LastChangeDate: firstNonEmpty(data.LastChangeDate, data.UpdateTime), + }, + }) + } + return items, nil +} + +func (s *sSimQianxun) QuerySimDataUsage(ctx context.Context, input *model.SimDataUsageInput) (model.SimDataUsageOutput, error) { + if input == nil { + return make(model.SimDataUsageOutput, 0), nil + } + items := make(model.SimDataUsageOutput, 0, len(input.SimKeys)) + for _, simKey := range input.SimKeys { + data, resp, err := s.queryCardInfo(ctx, simKey) + if err != nil { + items = append(items, qianxunError[model.SimDataUsageItem](simKey, err)) + continue + } + if !qianxunSuccess(resp) { + items = append(items, qianxunBusinessError[model.SimDataUsageItem](simKey, resp)) + continue + } + item := model.SimDataUsageItem{ + DataUsage: qianxunUsageMB(data.UsedGprs, data.UsageTotal, data.UsedFlow, data.UseFlow, data.CardFlow, data.Flow), + } + total := qianxunUsageMB(data.ProdTotal, data.TotalFlow, data.PackageFlow) + if total > 0 { + item.TotalFlow = &total + } + items = append(items, &model.SimBaseRes[model.SimDataUsageItem]{ + SimKey: simKey, + Code: consts.ApiStatusSuccess, + Message: qianxunCardMessage(resp), + ResData: item, + }) + } + return items, nil +} + +func (s *sSimQianxun) QuerySimDataUsageMonthly(ctx context.Context, input *model.SimDataUsageMonthlyInput) (model.SimDataUsageMonthlyOutput, error) { + if input == nil { + return make(model.SimDataUsageMonthlyOutput, 0), nil + } + queryMonth, err := qianxunMonth(input.ReqData.QueryDate) + if err != nil { + return nil, err + } + items := make(model.SimDataUsageMonthlyOutput, 0, len(input.SimKeys)) + for _, simKey := range input.SimKeys { + iccid, err := qianxunICCID(simKey) + if err != nil { + items = append(items, qianxunError[model.SimDataUsageMonthlyOutputItem](simKey, err)) + continue + } + params := url.Values{} + params.Set("iccid", iccid) + params.Set("billingMonth", queryMonth) + resp, err := s.post(ctx, "/api/v3/queryFlowHistory", params) + if err != nil { + items = append(items, qianxunError[model.SimDataUsageMonthlyOutputItem](simKey, err)) + continue + } + if !qianxunSuccess(resp) { + items = append(items, qianxunBusinessError[model.SimDataUsageMonthlyOutputItem](simKey, resp)) + continue + } + var data qianxunFlowHistory + if err := decodeQianxunData(resp.Result, &data); err != nil { + items = append(items, qianxunError[model.SimDataUsageMonthlyOutputItem](simKey, err)) + continue + } + if strings.EqualFold(strings.TrimSpace(data.Status), "N") { + items = append(items, &model.SimBaseRes[model.SimDataUsageMonthlyOutputItem]{ + SimKey: simKey, + Code: consts.ApiStatusError, + Message: "Qianxun monthly usage data not synced", + ResData: model.SimDataUsageMonthlyOutputItem{DeadLine: firstNonEmpty(data.BillingMonth, queryMonth)}, + }) + continue + } + items = append(items, &model.SimBaseRes[model.SimDataUsageMonthlyOutputItem]{ + SimKey: simKey, + Code: consts.ApiStatusSuccess, + Message: qianxunCardMessage(resp), + ResData: model.SimDataUsageMonthlyOutputItem{ + DeadLine: firstNonEmpty(data.BillingMonth, queryMonth), + DataAmount: qianxunUsageMB(data.UsageTotal), + }, + }) + } + return items, nil +} + +func qianxunMonth(queryDate string) (string, error) { + queryDate = strings.TrimSpace(queryDate) + if queryDate == "" { + return "", fmt.Errorf("queryDate is required") + } + for _, layout := range []string{"200601", "2006-01", "20060102", "2006-01-02"} { + if parsed, err := time.Parse(layout, queryDate); err == nil { + return parsed.Format("200601"), nil + } + } + return "", fmt.Errorf("invalid queryDate: %s", queryDate) +} + +func (s *sSimQianxun) QuerySimCardStatusBatch(ctx context.Context, input *model.SimCardStatusInput) (model.SimCardStatusOutput, error) { + if input == nil || len(input.SimKeys) == 0 { + return make(model.SimCardStatusOutput, 0), nil + } + cards, errs := s.queryCardInfoBatch(ctx, input.SimKeys) + items := make(model.SimCardStatusOutput, 0, len(input.SimKeys)) + for _, simKey := range input.SimKeys { + iccid, err := qianxunICCID(simKey) + if err != nil { + items = append(items, qianxunError[model.SimCardStatusItem](simKey, err)) + continue + } + if err = errs[iccid]; err != nil { + items = append(items, qianxunError[model.SimCardStatusItem](simKey, err)) + continue + } + data, ok := cards[iccid] + if !ok { + items = append(items, &model.SimBaseRes[model.SimCardStatusItem]{SimKey: simKey, Code: consts.ApiStatusError, Message: "batch response missing card"}) + continue + } + items = append(items, &model.SimBaseRes[model.SimCardStatusItem]{ + SimKey: simKey, + Code: consts.ApiStatusSuccess, + ResData: model.SimCardStatusItem{ + CardStatus: qianxunCardStatus(ctx, qianxunStatusValue(data)), + LastChangeDate: firstNonEmpty(data.LastChangeDate, data.UpdateTime), + }, + }) + } + return items, nil +} + +func (s *sSimQianxun) QuerySimDataUsageMonthlyBatch(ctx context.Context, input *model.SimDataUsageMonthlyInput) (model.SimDataUsageMonthlyOutput, error) { + if input == nil || len(input.SimKeys) == 0 { + return make(model.SimDataUsageMonthlyOutput, 0), nil + } + queryMonth, err := qianxunMonth(input.ReqData.QueryDate) + if err != nil { + return nil, err + } + + results := make(model.SimDataUsageMonthlyOutput, len(input.SimKeys)) + for _, chunk := range qianxunChunkSimKeys(input.SimKeys, qianxunBatchLimit) { + iccids := make([]string, 0, len(chunk)) + for _, item := range chunk { + iccid, err := qianxunICCID(item.simKey) + if err != nil { + results[item.index] = qianxunError[model.SimDataUsageMonthlyOutputItem](item.simKey, err) + continue + } + iccids = append(iccids, iccid) + } + if len(iccids) == 0 { + continue + } + + params := url.Values{} + params.Set("iccids", strings.Join(iccids, "_")) + params.Set("billingMonth", queryMonth) + resp, err := s.post(ctx, "/api/v3/batchQueryFlowHistory", params) + if err != nil { + for _, item := range chunk { + if results[item.index] == nil { + results[item.index] = qianxunError[model.SimDataUsageMonthlyOutputItem](item.simKey, err) + } + } + continue + } + if !qianxunSuccess(resp) { + for _, item := range chunk { + if results[item.index] == nil { + results[item.index] = qianxunBusinessError[model.SimDataUsageMonthlyOutputItem](item.simKey, resp) + } + } + continue + } + + history, itemErrs, err := decodeQianxunBatchHistory(resp.Result) + if err != nil { + for _, item := range chunk { + if results[item.index] == nil { + results[item.index] = qianxunError[model.SimDataUsageMonthlyOutputItem](item.simKey, err) + } + } + continue + } + for _, item := range chunk { + if results[item.index] != nil { + continue + } + iccid, _ := qianxunICCID(item.simKey) + if itemErrs[iccid] != nil { + results[item.index] = qianxunError[model.SimDataUsageMonthlyOutputItem](item.simKey, itemErrs[iccid]) + continue + } + data, ok := history[iccid] + if !ok { + results[item.index] = &model.SimBaseRes[model.SimDataUsageMonthlyOutputItem]{SimKey: item.simKey, Code: consts.ApiStatusError, Message: "batch response missing card", ResData: model.SimDataUsageMonthlyOutputItem{DeadLine: queryMonth}} + continue + } + if strings.EqualFold(strings.TrimSpace(data.Status), "N") { + results[item.index] = &model.SimBaseRes[model.SimDataUsageMonthlyOutputItem]{SimKey: item.simKey, Code: consts.ApiStatusError, Message: "Qianxun monthly usage data not synced", ResData: model.SimDataUsageMonthlyOutputItem{DeadLine: firstNonEmpty(data.BillingMonth, queryMonth)}} + continue + } + results[item.index] = &model.SimBaseRes[model.SimDataUsageMonthlyOutputItem]{ + SimKey: item.simKey, + Code: consts.ApiStatusSuccess, + Message: qianxunCardMessage(resp), + ResData: model.SimDataUsageMonthlyOutputItem{ + DeadLine: firstNonEmpty(data.BillingMonth, queryMonth), + DataAmount: qianxunUsageMB(data.UsageTotal), + }, + } + } + } + return compactMonthlyBatchResults(results), nil +} + +type qianxunBatchKey struct { + index int + simKey *model.SimKey +} + +func qianxunChunkSimKeys(simKeys []*model.SimKey, size int) [][]qianxunBatchKey { + if size <= 0 { + size = 1 + } + items := make([]qianxunBatchKey, 0, len(simKeys)) + for i, simKey := range simKeys { + items = append(items, qianxunBatchKey{index: i, simKey: simKey}) + } + chunks := make([][]qianxunBatchKey, 0, (len(items)+size-1)/size) + for start := 0; start < len(items); start += size { + end := start + size + if end > len(items) { + end = len(items) + } + chunks = append(chunks, items[start:end]) + } + return chunks +} + +func (s *sSimQianxun) queryCardInfoBatch(ctx context.Context, simKeys []*model.SimKey) (map[string]qianxunCardInfo, map[string]error) { + cards := make(map[string]qianxunCardInfo, len(simKeys)) + errs := make(map[string]error, len(simKeys)) + for _, chunk := range qianxunChunkSimKeys(simKeys, qianxunBatchLimit) { + iccids := make([]string, 0, len(chunk)) + for _, item := range chunk { + iccid, err := qianxunICCID(item.simKey) + if err != nil { + continue + } + iccids = append(iccids, iccid) + } + if len(iccids) == 0 { + continue + } + + params := url.Values{} + params.Set("iccids", strings.Join(iccids, "_")) + resp, err := s.post(ctx, "/api/v3/cardInfos", params) + if err != nil { + for _, iccid := range iccids { + errs[iccid] = err + } + continue + } + if !qianxunSuccess(resp) { + for _, iccid := range iccids { + errs[iccid] = fmt.Errorf("Qianxun batch card info failed: code=%s message=%s", resp.Status, resp.Message) + } + continue + } + batchCards, itemErrs, err := decodeQianxunBatchCards(resp.Result) + if err != nil { + for _, iccid := range iccids { + errs[iccid] = err + } + continue + } + for iccid, data := range batchCards { + cards[iccid] = data + } + for iccid, itemErr := range itemErrs { + errs[iccid] = itemErr + } + } + return cards, errs +} + +func decodeQianxunBatchCards(raw json.RawMessage) (map[string]qianxunCardInfo, map[string]error, error) { + cards := make(map[string]qianxunCardInfo) + errs := make(map[string]error) + + var wrapped []qianxunBatchCardResult + if err := decodeQianxunData(raw, &wrapped); err == nil && qianxunHasWrappedData(wrapped) { + for _, item := range wrapped { + iccid := strings.TrimSpace(item.ICCID) + var card qianxunCardInfo + if err := decodeQianxunStringJSON(firstQianxunRaw(item.ICCIDData, item.ICCIDResult), &card); err != nil { + errs[iccid] = err + continue + } + if strings.TrimSpace(card.ICCID) == "" { + card.ICCID = iccid + } + cards[card.ICCID] = card + } + return cards, errs, nil + } + + var list []qianxunCardInfo + if err := decodeQianxunData(raw, &list); err != nil { + return nil, nil, err + } + for _, item := range list { + iccid := strings.TrimSpace(item.ICCID) + if iccid == "" { + continue + } + cards[iccid] = item + } + return cards, errs, nil +} + +func decodeQianxunBatchHistory(raw json.RawMessage) (map[string]qianxunFlowHistory, map[string]error, error) { + history := make(map[string]qianxunFlowHistory) + errs := make(map[string]error) + + var wrapped []qianxunBatchCardResult + if err := decodeQianxunData(raw, &wrapped); err == nil && qianxunHasWrappedData(wrapped) { + for _, item := range wrapped { + iccid := strings.TrimSpace(item.ICCID) + var data qianxunFlowHistory + if err := decodeQianxunStringJSON(firstQianxunRaw(item.ICCIDResult, item.ICCIDData), &data); err != nil { + errs[iccid] = err + continue + } + if strings.TrimSpace(data.ICCID) == "" { + data.ICCID = iccid + } + history[data.ICCID] = data + } + return history, errs, nil + } + + var list []qianxunFlowHistory + if err := decodeQianxunData(raw, &list); err != nil { + return nil, nil, err + } + for _, item := range list { + iccid := strings.TrimSpace(item.ICCID) + if iccid == "" { + continue + } + history[iccid] = item + } + return history, errs, nil +} + +func qianxunHasWrappedData(items []qianxunBatchCardResult) bool { + for _, item := range items { + if len(item.ICCIDData) > 0 && string(item.ICCIDData) != "null" { + return true + } + if len(item.ICCIDResult) > 0 && string(item.ICCIDResult) != "null" { + return true + } + } + return false +} + +func firstQianxunRaw(values ...json.RawMessage) json.RawMessage { + for _, value := range values { + if len(value) > 0 && string(value) != "null" { + return value + } + } + return nil +} + +func (s *sSimQianxun) QuerySimSession(ctx context.Context, input *model.SimSessionInput) (model.SimSessionOutput, error) { + if input == nil { + return make(model.SimSessionOutput, 0), nil + } + return qianxunUnsupported[model.SimSessionItem](input.SimKeys, "千寻通道暂不支持在线信息查询"), nil +} + +func (s *sSimQianxun) QuerySimStopReason(ctx context.Context, input *model.SimStopReasonInput) (model.SimStopReasonOutput, error) { + if input == nil { + return make(model.SimStopReasonOutput, 0), nil + } + return qianxunUnsupported[model.SimStopReasonItem](input.SimKeys, "千寻通道暂不支持停机原因查询"), nil +} + +func (s *sSimQianxun) QuerySimFrozenState(ctx context.Context, input *model.SimFrozenStateInput) (model.SimFrozenStateOutput, error) { + if input == nil { + return make(model.SimFrozenStateOutput, 0), nil + } + return qianxunUnsupported[model.SimFrozenStateItem](input.SimKeys, "千寻通道暂不支持冻结状态查询"), nil +} + +func (s *sSimQianxun) QuerySimCardBindStatus(ctx context.Context, input *model.SimCardBindStatusInput) (model.SimCardBindStatusOutput, error) { + if input == nil { + return make(model.SimCardBindStatusOutput, 0), nil + } + return qianxunUnsupported[model.SimCardBindStatusItem](input.SimKeys, "千寻通道暂不支持机卡绑定状态查询"), nil +} + +func (s *sSimQianxun) QuerySimDeviceStatus(ctx context.Context, input *model.SimDeviceStatusInput) (model.SimDeviceStatusOutput, error) { + if input == nil { + return make(model.SimDeviceStatusOutput, 0), nil + } + return qianxunUnsupported[model.SimDeviceStatusItem](input.SimKeys, "千寻通道暂不支持设备状态查询"), nil +} + +func (s *sSimQianxun) QuerySimRegionLimitStatus(ctx context.Context, input *model.SimRegionLimitStatusInput) (model.SimRegionLimitStatusOutput, error) { + if input == nil { + return make(model.SimRegionLimitStatusOutput, 0), nil + } + return qianxunUnsupported[model.SimRegionLimitStatusItem](input.SimKeys, "千寻通道暂不支持地域限制状态查询"), nil +} + +func (s *sSimQianxun) QuerySimImei(ctx context.Context, input *model.SimImeiInput) (model.SimImeiOutput, error) { + if input == nil { + return make(model.SimImeiOutput, 0), nil + } + return qianxunUnsupported[model.SimImeiItem](input.SimKeys, "千寻通道暂不支持IMEI查询"), nil +} + +func (s *sSimQianxun) QuerySimDataDiagnosis(ctx context.Context, input *model.SimDataDiagnosisInput) (model.SimDataDiagnosisOutput, error) { + if input == nil { + return make(model.SimDataDiagnosisOutput, 0), nil + } + return qianxunUnsupported[any](input.SimKeys, "千寻通道暂不支持达量限速档位查询"), nil +} + +func (s *sSimQianxun) QuerySimDataUsageDaily(ctx context.Context, input *model.SimDataUsageDailyInput) (model.SimDataUsageDailyOutput, error) { + if input == nil { + return make(model.SimDataUsageDailyOutput, 0), nil + } + return qianxunUnsupported[model.SimDataUsageDailyOutputItem](input.SimKeys, "千寻通道暂不支持单日流量查询"), nil +} + +func (s *sSimQianxun) QuerySimDataUsageDailyBatch(ctx context.Context, input *model.SimDataUsageDailyInput) (model.SimDataUsageDailyOutput, error) { + return s.QuerySimDataUsageDaily(ctx, input) +} + +func (s *sSimQianxun) QuerySimApnInfo(ctx context.Context, input *model.SimApnInfoInput) (model.SimApnInfoOutput, error) { + if input == nil { + return make(model.SimApnInfoOutput, 0), nil + } + return qianxunUnsupported[model.SimApnInfoItemList](input.SimKeys, "千寻通道暂不支持APN信息查询"), nil +} + +func (s *sSimQianxun) ChangeSimStatus(ctx context.Context, input *model.SimCardStatusChangeInput) (model.SimCardStatusChangeOutput, error) { + if input == nil { + return make(model.SimCardStatusChangeOutput, 0), nil + } + return qianxunUnsupported[model.SimCardStatusChangeItem](input.SimKeys, "千寻通道暂不支持卡状态变更"), nil +} + +func (s *sSimQianxun) ChangeSimApnFunction(ctx context.Context, input *model.SimCardOperateChangeApnInput) (model.SimCardOperateChangeApnOutput, error) { + if input == nil { + return make(model.SimCardOperateChangeApnOutput, 0), nil + } + return qianxunUnsupported[model.SimCardOperateChangeApnItem](input.SimKeys, "千寻通道暂不支持APN功能开停"), nil +} diff --git a/internal/logic/upstreamimpl/sim_recharge.go b/internal/logic/upstreamimpl/sim_recharge.go new file mode 100644 index 0000000..d0f62f7 --- /dev/null +++ b/internal/logic/upstreamimpl/sim_recharge.go @@ -0,0 +1,519 @@ +package upstreamimpl + +import ( + "context" + "encoding/json" + "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" + "golang.org/x/sync/singleflight" +) + +const rechargeTempKey = "sim_sdtywl_v1" +const rechargeFindCardCacheTTL = 30 * time.Second + +// 腾宇卡状态 status_show_id -> 系统 cardStatus +// 腾宇: 0未知 1库存 2可测试 3待激活 4已激活 5已停机 6预销户 7已销户 +// 系统: 0未知 1待激活 2已激活 4已停机 6可测试 7库存 8预销户 9已销户 +var rechargeCardStatusMap = map[int]int{ + 0: 0, + 1: 7, + 2: 6, + 3: 1, + 4: 2, + 5: 4, + 6: 8, + 7: 9, +} + +type sSimRecharge struct { + channelConfig *model.SimChannelConfigItemCache + client *recharge.Client + cardCache sync.Map + cardFlight singleflight.Group +} + +type rechargeCardCacheEntry struct { + Info model.RechargeCardInfo + UpdatedAt string + ExpiresAt time.Time +} + +type rechargeFindCardResult struct { + Info *model.RechargeCardInfo + Cached bool + UpdatedAt string +} + +var _ model.SimCardChannelI = (*sSimRecharge)(nil) + +type rechargeChannelConfig struct { + PwdKey string `json:"pwdkey"` + Pwdiv string `json:"pwdiv"` + LegacyTimeout json.RawMessage `json:"timeout"` +} + +func NewSimRecharge() *sSimRecharge { + return &sSimRecharge{} +} + +func init() { + service.UpstreamAPI().RegisterTemp(rechargeTempKey, NewSimRecharge()) +} + +func (s *sSimRecharge) NewTemp(ctx context.Context, channelConfig *model.SimChannelConfigItemCache) model.SimCardChannelI { + cfg := rechargeChannelConfig{} + if channelConfig != nil && channelConfig.Channel != nil { + _ = json.Unmarshal([]byte(channelConfig.Channel.ChanConfig), &cfg) + if len(cfg.LegacyTimeout) > 0 && string(cfg.LegacyTimeout) != "null" { + g.Log().Warningf(ctx, "recharge channel %s uses deprecated chan_config.timeout; configure template http.timeout instead", channelConfig.Channel.ChanKey) + } + } + + baseURL := "" + if channelConfig != nil && channelConfig.Template != nil { + var templateConfig struct { + API struct { + BaseURL string `json:"baseUrl"` + } `json:"api"` + LegacyBaseURL string `json:"baseUrl"` + } + if err := json.Unmarshal([]byte(channelConfig.Template.TempConfig), &templateConfig); err != nil { + g.Log().Warningf(ctx, "parse recharge template config failed, chanKey=%s err=%+v", channelConfig.Channel.ChanKey, err) + } else { + baseURL = strings.TrimSpace(templateConfig.API.BaseURL) + if baseURL == "" && strings.TrimSpace(templateConfig.LegacyBaseURL) != "" { + baseURL = strings.TrimSpace(templateConfig.LegacyBaseURL) + g.Log().Warningf(ctx, "recharge template %s uses deprecated root baseUrl; move it to api.baseUrl", channelConfig.Template.TempKey) + } + } + } + if baseURL == "" { + baseURL = recharge.DefaultBaseURL + } + + // 字段复用约定: app_key=pwdkey, app_secret=pwdiv + pwdKey := cfg.PwdKey + pwdiv := cfg.Pwdiv + if channelConfig != nil && channelConfig.Channel != nil { + if pwdKey == "" { + pwdKey = channelConfig.Channel.AppKey + } + if pwdiv == "" { + pwdiv = channelConfig.Channel.AppSecret + } + } + + timeout := resolveUpstreamHTTPTimeout(ctx, templateConfigJSON(channelConfig)) + + return &sSimRecharge{ + channelConfig: channelConfig, + client: recharge.NewClient(recharge.Config{ + BaseURL: baseURL, + PwdKey: pwdKey, + Pwdiv: pwdiv, + Timeout: timeout, + }), + } +} + +func convertRechargeCardStatus(ctx context.Context, statusShowID int) int { + if status, ok := rechargeCardStatusMap[statusShowID]; ok { + return status + } + g.Log().Warningf(ctx, "Unknown recharge card status %d from upstream", statusShowID) + return 0 +} + +// 系统 operType(0停机/1复机) -> 腾宇 changeState type(1停机/0复机),语义相反,禁止透传 +func convertRechargeChangeStateType(ctx context.Context, operType string) (int, bool) { + switch operType { + case consts.CardStatusChangeOperTypeApplyStop: + return 1, true + case consts.CardStatusChangeOperTypeApplyResume: + return 0, true + default: + g.Log().Warningf(ctx, "Unknown card status change operType %s for recharge upstream", operType) + return 0, false + } +} + +func (s *sSimRecharge) findCard(ctx context.Context, simKey *model.SimKey) (*rechargeFindCardResult, error) { + card := simKey.ICCID + if card == "" { + card = simKey.MSISDN + } + card = strings.TrimSpace(card) + if cached, ok := s.cachedCard(card); ok { + return cached, nil + } + + value, err, _ := s.cardFlight.Do(card, func() (any, error) { + if cached, ok := s.cachedCard(card); ok { + return cached, nil + } + output, requestErr := s.client.DoPost(ctx, "/findCard", map[string]any{"card": card}) + if requestErr != nil { + return nil, requestErr + } + if output.Code == recharge.CodeRateLimited { + g.Log().Warningf(ctx, "recharge findCard rate limited, chanKey %s, card %s", simKey.ChanKey, card) + } + if output.Code != recharge.CodeSuccess { + return nil, &recharge.BusinessError{Code: output.Code, Msg: output.Msg} + } + var info model.RechargeCardInfo + if unmarshalErr := json.Unmarshal(output.Data, &info); unmarshalErr != nil { + return nil, unmarshalErr + } + updatedAt := time.Now().Format("2006-01-02 15:04:05") + s.cardCache.Store(card, rechargeCardCacheEntry{ + Info: info, + UpdatedAt: updatedAt, + ExpiresAt: time.Now().Add(rechargeFindCardCacheTTL), + }) + return &rechargeFindCardResult{Info: &info, UpdatedAt: updatedAt}, nil + }) + if err != nil { + return nil, err + } + result, ok := value.(*rechargeFindCardResult) + if !ok || result == nil || result.Info == nil { + return nil, gerror.New("recharge findCard returned invalid result") + } + return result, nil +} + +func (s *sSimRecharge) cachedCard(card string) (*rechargeFindCardResult, bool) { + value, ok := s.cardCache.Load(strings.TrimSpace(card)) + if !ok { + return nil, false + } + entry, ok := value.(rechargeCardCacheEntry) + if !ok || time.Now().After(entry.ExpiresAt) { + s.cardCache.Delete(strings.TrimSpace(card)) + return nil, false + } + info := entry.Info + return &rechargeFindCardResult{Info: &info, Cached: true, UpdatedAt: entry.UpdatedAt}, true +} + +func (s *sSimRecharge) invalidateCardCache(card string) { + s.cardCache.Delete(strings.TrimSpace(card)) +} + +func rechargeErrorMessage(err error) string { + return err.Error() +} + +// QuerySimBasicInfo 单卡基本信息查询 +// findCard -> ActiveDate=activate_date, Remark 附带实名状态 +func (s *sSimRecharge) QuerySimBasicInfo(ctx context.Context, input *model.SimBasicInfoInput) (model.SimBasicInfoOutput, error) { + items := make(model.SimBasicInfoOutput, 0, len(input.SimKeys)) + for _, simKey := range input.SimKeys { + result, err := s.findCard(ctx, simKey) + if err != nil { + g.Log().Errorf(ctx, "Recharge QuerySimBasicInfo error %+v, simKey %+v", err, simKey) + items = append(items, &model.SimBaseRes[model.SimBasicInfoItem]{ + SimKey: simKey, + Code: consts.ApiStatusError, + Message: rechargeErrorMessage(err), + }) + continue + } + info := result.Info + remark := "未实名" + if info.RealName == 1 { + remark = "已实名" + } + items = append(items, &model.SimBaseRes[model.SimBasicInfoItem]{ + SimKey: simKey, + Code: consts.ApiStatusSuccess, + Cached: result.Cached, + UpdatedAt: result.UpdatedAt, + ResData: model.SimBasicInfoItem{ + ActiveDate: info.ActivateDate, + OpenDate: "", + Remark: remark, + }, + }) + } + return items, nil +} + +// QuerySimCardStatus 物联网卡卡状态查询 +// findCard.status_show_id -> 系统 cardStatus +func (s *sSimRecharge) QuerySimCardStatus(ctx context.Context, input *model.SimCardStatusInput) (model.SimCardStatusOutput, error) { + items := make(model.SimCardStatusOutput, 0, len(input.SimKeys)) + for _, simKey := range input.SimKeys { + result, err := s.findCard(ctx, simKey) + if err != nil { + g.Log().Errorf(ctx, "Recharge QuerySimCardStatus error %+v, simKey %+v", err, simKey) + items = append(items, &model.SimBaseRes[model.SimCardStatusItem]{ + SimKey: simKey, + Code: consts.ApiStatusError, + Message: rechargeErrorMessage(err), + }) + continue + } + info := result.Info + items = append(items, &model.SimBaseRes[model.SimCardStatusItem]{ + SimKey: simKey, + Code: consts.ApiStatusSuccess, + Cached: result.Cached, + UpdatedAt: result.UpdatedAt, + ResData: model.SimCardStatusItem{ + CardStatus: convertRechargeCardStatus(ctx, info.StatusShowID), + LastChangeDate: info.ExpireTime, + }, + }) + } + return items, nil +} + +// QuerySimDataUsage 单卡本月流量累计使用量查询 +// findCard -> c_used/c_total (单位 MB) +func (s *sSimRecharge) QuerySimDataUsage(ctx context.Context, input *model.SimDataUsageInput) (model.SimDataUsageOutput, error) { + items := make(model.SimDataUsageOutput, 0, len(input.SimKeys)) + for _, simKey := range input.SimKeys { + result, err := s.findCard(ctx, simKey) + if err != nil { + g.Log().Errorf(ctx, "Recharge QuerySimDataUsage error %+v, simKey %+v", err, simKey) + items = append(items, &model.SimBaseRes[model.SimDataUsageItem]{ + SimKey: simKey, + Code: consts.ApiStatusError, + Message: rechargeErrorMessage(err), + }) + continue + } + info := result.Info + totalFlow := info.TotalFlow + items = append(items, &model.SimBaseRes[model.SimDataUsageItem]{ + SimKey: simKey, + Code: consts.ApiStatusSuccess, + Cached: result.Cached, + UpdatedAt: result.UpdatedAt, + ResData: model.SimDataUsageItem{ + DataUsage: info.UsedFlow, + TotalFlow: &totalFlow, + }, + }) + } + return items, nil +} + +// QuerySimDataDiagnosis 腾宇通道暂不支持达量限速档位查询。 +func (s *sSimRecharge) QuerySimDataDiagnosis(ctx context.Context, input *model.SimDataDiagnosisInput) (model.SimDataDiagnosisOutput, error) { + items := make(model.SimDataDiagnosisOutput, 0) + if input == nil { + return items, nil + } + for _, simKey := range input.SimKeys { + items = append(items, &model.SimBaseRes[any]{ + SimKey: simKey, + Code: consts.ApiInvokeException, + Message: rechargeUnsupportedMessage("达量限速档位查询"), + }) + } + return items, nil +} + +// ChangeSimStatus 物联网卡状态变更(停复机) +// 系统 operType(0停机/1复机) -> 腾宇 changeState type(1停机/0复机) +func (s *sSimRecharge) ChangeSimStatus(ctx context.Context, input *model.SimCardStatusChangeInput) (model.SimCardStatusChangeOutput, error) { + changeType, ok := convertRechargeChangeStateType(ctx, input.ReqData.OperType) + if !ok { + return newInvalidCardStatusChangeOutput(input.SimKeys), nil + } + + items := make(model.SimCardStatusChangeOutput, 0, len(input.SimKeys)) + for _, simKey := range input.SimKeys { + card := simKey.ICCID + if card == "" { + card = simKey.MSISDN + } + output, err := s.client.DoPost(ctx, "/changeState", map[string]any{ + "card": card, + "type": changeType, + }) + if err != nil { + g.Log().Errorf(ctx, "Recharge ChangeSimStatus error %+v, simKey %+v", err, simKey) + items = append(items, &model.SimBaseRes[model.SimCardStatusChangeItem]{ + SimKey: simKey, + Code: consts.ApiStatusError, + Message: err.Error(), + ResData: model.SimCardStatusChangeItem{}, + }) + continue + } + if output.Code != recharge.CodeSuccess { + g.Log().Warningf(ctx, "Recharge ChangeSimStatus upstream error code %d msg %s, simKey %+v", output.Code, output.Msg, simKey) + items = append(items, &model.SimBaseRes[model.SimCardStatusChangeItem]{ + SimKey: simKey, + Code: consts.ApiStatusError, + Message: output.Msg, + ResData: model.SimCardStatusChangeItem{}, + }) + continue + } + s.invalidateCardCache(card) + items = append(items, &model.SimBaseRes[model.SimCardStatusChangeItem]{ + SimKey: simKey, + Code: consts.ApiStatusSuccess, + Message: output.Msg, + ResData: model.SimCardStatusChangeItem{ + OrderNum: "", + }, + }) + } + return items, nil +} + +// 腾宇通道暂未支持的查询,参照 sim_dx.go 模式返回 ApiInvokeException +func rechargeUnsupportedMessage(name string) string { + return "腾宇通道暂不支持" + name +} + +func (s *sSimRecharge) QuerySimSession(ctx context.Context, input *model.SimSessionInput) (model.SimSessionOutput, error) { + items := make(model.SimSessionOutput, 0, len(input.SimKeys)) + for _, simKey := range input.SimKeys { + items = append(items, &model.SimBaseRes[model.SimSessionItem]{ + SimKey: simKey, + Code: consts.ApiInvokeException, + Message: rechargeUnsupportedMessage("在线信息查询"), + ResData: model.SimSessionItem{Status: consts.CardSessionStatusUnknown}, + }) + } + return items, nil +} + +func (s *sSimRecharge) QuerySimStopReason(ctx context.Context, input *model.SimStopReasonInput) (model.SimStopReasonOutput, error) { + items := make(model.SimStopReasonOutput, 0, len(input.SimKeys)) + for _, simKey := range input.SimKeys { + items = append(items, &model.SimBaseRes[model.SimStopReasonItem]{ + SimKey: simKey, + Code: consts.ApiInvokeException, + Message: rechargeUnsupportedMessage("停机原因查询"), + }) + } + return items, nil +} + +func (s *sSimRecharge) QuerySimFrozenState(ctx context.Context, input *model.SimFrozenStateInput) (model.SimFrozenStateOutput, error) { + items := make(model.SimFrozenStateOutput, 0, len(input.SimKeys)) + for _, simKey := range input.SimKeys { + items = append(items, &model.SimBaseRes[model.SimFrozenStateItem]{ + SimKey: simKey, + Code: consts.ApiInvokeException, + Message: rechargeUnsupportedMessage("冻结状态查询"), + }) + } + return items, nil +} + +func (s *sSimRecharge) QuerySimCardBindStatus(ctx context.Context, input *model.SimCardBindStatusInput) (model.SimCardBindStatusOutput, error) { + items := make(model.SimCardBindStatusOutput, 0, len(input.SimKeys)) + for _, simKey := range input.SimKeys { + items = append(items, &model.SimBaseRes[model.SimCardBindStatusItem]{ + SimKey: simKey, + Code: consts.ApiInvokeException, + Message: rechargeUnsupportedMessage("机卡绑定状态查询"), + }) + } + return items, nil +} + +func (s *sSimRecharge) QuerySimDeviceStatus(ctx context.Context, input *model.SimDeviceStatusInput) (model.SimDeviceStatusOutput, error) { + items := make(model.SimDeviceStatusOutput, 0, len(input.SimKeys)) + for _, simKey := range input.SimKeys { + items = append(items, &model.SimBaseRes[model.SimDeviceStatusItem]{ + SimKey: simKey, + Code: consts.ApiInvokeException, + Message: rechargeUnsupportedMessage("设备状态查询"), + ResData: model.SimDeviceStatusItem{DeviceStatus: consts.CardSessionStatusUnknown}, + }) + } + return items, nil +} + +func (s *sSimRecharge) QuerySimRegionLimitStatus(ctx context.Context, input *model.SimRegionLimitStatusInput) (model.SimRegionLimitStatusOutput, error) { + items := make(model.SimRegionLimitStatusOutput, 0, len(input.SimKeys)) + for _, simKey := range input.SimKeys { + items = append(items, &model.SimBaseRes[model.SimRegionLimitStatusItem]{ + SimKey: simKey, + Code: consts.ApiInvokeException, + Message: rechargeUnsupportedMessage("地域限制状态查询"), + }) + } + return items, nil +} + +func (s *sSimRecharge) QuerySimImei(ctx context.Context, input *model.SimImeiInput) (model.SimImeiOutput, error) { + items := make(model.SimImeiOutput, 0, len(input.SimKeys)) + for _, simKey := range input.SimKeys { + items = append(items, &model.SimBaseRes[model.SimImeiItem]{ + SimKey: simKey, + Code: consts.ApiInvokeException, + Message: rechargeUnsupportedMessage("IMEI查询"), + }) + } + return items, nil +} + +func (s *sSimRecharge) QuerySimDataUsageMonthly(ctx context.Context, input *model.SimDataUsageMonthlyInput) (model.SimDataUsageMonthlyOutput, error) { + items := make(model.SimDataUsageMonthlyOutput, 0, len(input.SimKeys)) + for _, simKey := range input.SimKeys { + items = append(items, &model.SimBaseRes[model.SimDataUsageMonthlyOutputItem]{ + SimKey: simKey, + Code: consts.ApiInvokeException, + Message: rechargeUnsupportedMessage("单月流量查询"), + }) + } + return items, nil +} + +func (s *sSimRecharge) QuerySimDataUsageDaily(ctx context.Context, input *model.SimDataUsageDailyInput) (model.SimDataUsageDailyOutput, error) { + items := make(model.SimDataUsageDailyOutput, 0, len(input.SimKeys)) + for _, simKey := range input.SimKeys { + items = append(items, &model.SimBaseRes[model.SimDataUsageDailyOutputItem]{ + SimKey: simKey, + Code: consts.ApiInvokeException, + Message: rechargeUnsupportedMessage("单日流量查询"), + }) + } + return items, nil +} + +func (s *sSimRecharge) QuerySimApnInfo(ctx context.Context, input *model.SimApnInfoInput) (model.SimApnInfoOutput, error) { + items := make(model.SimApnInfoOutput, 0, len(input.SimKeys)) + for _, simKey := range input.SimKeys { + items = append(items, &model.SimBaseRes[model.SimApnInfoItemList]{ + SimKey: simKey, + Code: consts.ApiInvokeException, + Message: rechargeUnsupportedMessage("APN信息查询"), + ResData: model.SimApnInfoItemList{ApnList: make([]model.SimApnInfoItem, 0)}, + }) + } + return items, nil +} + +// ChangeSimApnFunction 腾宇无 APN 开关语义(changeNet 是整卡开断网,不等价),不做映射 +func (s *sSimRecharge) ChangeSimApnFunction(ctx context.Context, input *model.SimCardOperateChangeApnInput) (model.SimCardOperateChangeApnOutput, error) { + items := make(model.SimCardOperateChangeApnOutput, 0, len(input.SimKeys)) + for _, simKey := range input.SimKeys { + items = append(items, &model.SimBaseRes[model.SimCardOperateChangeApnItem]{ + SimKey: simKey, + Code: consts.ApiInvokeException, + Message: rechargeUnsupportedMessage("APN功能开停"), + ResData: model.SimCardOperateChangeApnItem{}, + }) + } + return items, nil +} diff --git a/internal/logic/upstreamimpl/sim_recharge_cache_test.go b/internal/logic/upstreamimpl/sim_recharge_cache_test.go new file mode 100644 index 0000000..03e569e --- /dev/null +++ b/internal/logic/upstreamimpl/sim_recharge_cache_test.go @@ -0,0 +1,46 @@ +package upstreamimpl + +import ( + "context" + "net/http" + "net/http/httptest" + "sync/atomic" + "testing" + "time" + + "git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/logic/upstreamimpl/recharge" + "git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/model" +) + +func TestRechargeFindCardCacheIsSharedAcrossDiagnosisQueries(t *testing.T) { + var calls atomic.Int32 + 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"}}`)) + })) + defer server.Close() + + sim := &sSimRecharge{client: recharge.NewClient(recharge.Config{ + BaseURL: server.URL, + PwdKey: "test", + Pwdiv: "secret", + Timeout: time.Second, + })} + simKey := &model.SimKey{ICCID: "8986000000000000000"} + + basic, err := sim.QuerySimBasicInfo(context.Background(), &model.SimBasicInfoInput{SimKeys: []*model.SimKey{simKey}}) + if err != nil || len(basic) != 1 || basic[0].Code != 0 { + t.Fatalf("basic query failed: output=%#v err=%v", basic, err) + } + status, err := sim.QuerySimCardStatus(context.Background(), &model.SimCardStatusInput{SimKeys: []*model.SimKey{simKey}}) + if err != nil || len(status) != 1 || status[0].Code != 0 { + t.Fatalf("status query failed: output=%#v err=%v", status, err) + } + if calls.Load() != 1 { + t.Fatalf("findCard upstream calls = %d, want 1", calls.Load()) + } + if !status[0].Cached || status[0].UpdatedAt == "" { + t.Fatalf("cached metadata missing: %#v", status[0]) + } +} diff --git a/internal/logic/upstreamimpl/sim_tibiot.go b/internal/logic/upstreamimpl/sim_tibiot.go new file mode 100644 index 0000000..ffe4d19 --- /dev/null +++ b/internal/logic/upstreamimpl/sim_tibiot.go @@ -0,0 +1,1111 @@ +package upstreamimpl + +import ( + "bytes" + "context" + "crypto/md5" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "strconv" + "strings" + "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" +) + +const ( + tibiotDefaultBaseURL = "https://api.tibiot.com" + tibiotTimeLayout = "20060102150405" + tibiotUsageCacheTTL = 30 * time.Second +) + +var tibiotEast8 = time.FixedZone("UTC+8", 8*60*60) + +type sSimTibiot struct { + channelConfig *model.SimChannelConfigItemCache + baseURL string + userName string + password string + http *http.Client + usageCache sync.Map + usageFlight singleflight.Group +} + +type tibiotUsageCacheEntry struct { + Usage float64 + UpdatedAt string + ExpiresAt time.Time +} + +type tibiotUsageResult struct { + Usage float64 + Cached bool + UpdatedAt string + BusinessError *tibiotResponse +} + +type tibiotResponse struct { + Status any `json:"status"` + Message string `json:"message"` + Data json.RawMessage `json:"data"` +} + +type tibiotCardInfo struct { + ICCID string `json:"iccid"` + IMSI string `json:"imsi"` + MSISDN string `json:"msisdn"` + IMEI string `json:"imei"` + CardStatus any `json:"cardStatus"` + PackageName string `json:"packageName"` + TotalFlow string `json:"totalFlow"` + CardFlow string `json:"cardFlow"` + LeftFlow string `json:"leftFlow"` + OpenCardTime string `json:"openCardTime"` + LastActivateTime string `json:"lastActivateTime"` + ActivateTime string `json:"activateTime"` + PackageTime string `json:"packageTime"` + Remark string `json:"remark"` + ChannelID string `json:"channelId"` + PackageID string `json:"packageId"` + CardType any `json:"cardType"` + PoolID string `json:"poolId"` +} + +type tibiotOnlineInfo struct { + ICCID string `json:"iccid"` + MSISDN string `json:"msisdn"` + CardStatus string `json:"cardStatus"` + NetworkStatus string `json:"netWorkStatus"` + EquipmentStatus string `json:"equipmentStatus"` + OnlineStatus string `json:"onlineStatus"` + SeparationStatus string `json:"separationStatus"` + NetworkType string `json:"networkType"` + RegionLimitStatus string `json:"regionLimitStatus"` + APN string `json:"apn"` + IP string `json:"ip"` + IMEI string `json:"imei"` +} + +type tibiotMonthUsage struct { + Years string `json:"years"` + CardFlow string `json:"cardFlow"` + ICCID string `json:"iccid"` + MSISDN string `json:"msisdn"` + SyncTime string `json:"syncTime"` +} + +type tibiotDailyUsage struct { + SyncStatus string `json:"syncStatus"` + CardFlow string `json:"cardFlow"` + CreateTime string `json:"createTime"` + SyncTime string `json:"syncTime"` + ICCID string `json:"iccid"` + IMSI string `json:"imsi"` + MSISDN string `json:"msisdn"` + QueryDate string `json:"queryTime"` +} + +type tibiotBatchCardData struct { + PageNum int `json:"pageNum"` + PageSize int `json:"pageSize"` + TotalCount int `json:"totalCount"` + PageCount int `json:"pageCount"` + Records []tibiotCardInfo `json:"records"` +} + +func NewSimTibiot() *sSimTibiot { + return &sSimTibiot{} +} + +func init() { + service.UpstreamAPI().RegisterTemp("sim_tibiot_v1", NewSimTibiot()) +} + +func (s *sSimTibiot) NewTemp(ctx context.Context, channelConfig *model.SimChannelConfigItemCache) model.SimCardChannelI { + baseURL := tibiotDefaultBaseURL + if channelConfig != nil && channelConfig.Template != nil { + var cfg struct { + API struct { + BaseURL string `json:"baseUrl"` + } `json:"api"` + } + if err := json.Unmarshal([]byte(channelConfig.Template.TempConfig), &cfg); err != nil { + g.Log().Warningf(ctx, "parse Tibiot template config failed: %v", err) + } else if strings.TrimSpace(cfg.API.BaseURL) != "" { + baseURL = strings.TrimRight(strings.TrimSpace(cfg.API.BaseURL), "/") + } + } + + var credentials struct { + UserName string `json:"userName"` + Password string `json:"password"` + } + if channelConfig != nil && channelConfig.Channel != nil { + if err := json.Unmarshal([]byte(channelConfig.Channel.ChanConfig), &credentials); err != nil { + g.Log().Warningf(ctx, "parse Tibiot channel config failed, chanKey=%s err=%v", channelConfig.Channel.ChanKey, err) + } + } + + return &sSimTibiot{ + channelConfig: channelConfig, + baseURL: baseURL, + userName: strings.TrimSpace(credentials.UserName), + password: credentials.Password, + http: &http.Client{Timeout: resolveUpstreamHTTPTimeout(ctx, templateConfigJSON(channelConfig))}, + } +} + +func tibiotSignature(password, tKey string) string { + first := md5.Sum([]byte(password)) + firstText := hex.EncodeToString(first[:]) + second := md5.Sum([]byte(firstText + tKey)) + return hex.EncodeToString(second[:]) +} + +func tibiotAuthFields(userName, password string, now time.Time) map[string]any { + tKey := now.In(tibiotEast8).Format(tibiotTimeLayout) + return map[string]any{ + "userName": userName, + "passWord": tibiotSignature(password, tKey), + "tKey": tKey, + } +} + +func (s *sSimTibiot) post(ctx context.Context, endpoint string, params map[string]any) (tibiotResponse, error) { + if s == nil { + return tibiotResponse{}, fmt.Errorf("Tibiot client is nil") + } + if strings.TrimSpace(s.userName) == "" || s.password == "" { + return tibiotResponse{}, fmt.Errorf("Tibiot credentials are not configured") + } + payload := tibiotAuthFields(s.userName, s.password, time.Now()) + for key, value := range params { + payload[key] = value + } + body, err := json.Marshal(payload) + if err != nil { + return tibiotResponse{}, fmt.Errorf("marshal Tibiot request: %w", err) + } + url := strings.TrimRight(s.baseURL, "/") + "/" + strings.TrimLeft(endpoint, "/") + req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body)) + if err != nil { + return tibiotResponse{}, fmt.Errorf("create Tibiot request: %w", err) + } + req.Header.Set("Content-Type", "application/json") + client := s.http + if client == nil { + client = &http.Client{Timeout: defaultUpstreamHTTPTimeout} + } + resp, err := client.Do(req) + if err != nil { + return tibiotResponse{}, fmt.Errorf("Tibiot request %s: %w", endpoint, err) + } + defer resp.Body.Close() + responseBody, err := io.ReadAll(resp.Body) + if err != nil { + return tibiotResponse{}, fmt.Errorf("read Tibiot response %s: %w", endpoint, err) + } + rawResponse := strings.TrimSpace(string(responseBody)) + if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices { + g.Log().Warningf(ctx, "Tibiot HTTP error endpoint=%s status=%d response=%s", endpoint, resp.StatusCode, rawResponse) + return tibiotResponse{}, fmt.Errorf("Tibiot HTTP status %d", resp.StatusCode) + } + var output tibiotResponse + if err := json.Unmarshal(responseBody, &output); err != nil { + g.Log().Warningf(ctx, "decode Tibiot response failed endpoint=%s response=%s err=%v", endpoint, rawResponse, err) + return tibiotResponse{}, fmt.Errorf("decode Tibiot response %s: %w", endpoint, err) + } + if !tibiotSuccess(output) { + g.Log().Warningf(ctx, "Tibiot business error endpoint=%s status=%d message=%s response=%s", endpoint, tibiotCode(output), output.Message, rawResponse) + } else { + g.Log().Infof(ctx, "Tibiot response endpoint=%s status=%d response=%s", endpoint, tibiotCode(output), rawResponse) + } + return output, nil +} + +func tibiotCode(resp tibiotResponse) int { + return gconv.Int(resp.Status) +} + +func tibiotSuccess(resp tibiotResponse) bool { + return tibiotCode(resp) == 0 +} + +func tibiotCardIdentifier(simKey *model.SimKey) (string, string) { + if simKey == nil { + return "", "" + } + if value := strings.TrimSpace(simKey.ICCID); value != "" { + return "iccid", value + } + if value := strings.TrimSpace(simKey.MSISDN); value != "" { + return "msisdn", value + } + if value := strings.TrimSpace(simKey.IMSI); value != "" { + return "imsi", value + } + return "", "" +} + +func tibiotParams(simKey *model.SimKey) (map[string]any, error) { + name, value := tibiotCardIdentifier(simKey) + if name == "" { + return nil, fmt.Errorf("missing iccid/msisdn/imsi") + } + return map[string]any{name: value}, nil +} + +func tibiotUnsupported[T any](simKeys []*model.SimKey, message string) []*model.SimBaseRes[T] { + items := make([]*model.SimBaseRes[T], 0, len(simKeys)) + for _, simKey := range simKeys { + items = append(items, &model.SimBaseRes[T]{ + SimKey: simKey, + Code: consts.ApiInvokeException, + Message: message, + }) + } + return items +} + +func tibiotError[T any](simKey *model.SimKey, err error) *model.SimBaseRes[T] { + return &model.SimBaseRes[T]{SimKey: simKey, Code: consts.ApiStatusError, Message: err.Error()} +} + +func tibiotBusinessError[T any](simKey *model.SimKey, resp tibiotResponse) *model.SimBaseRes[T] { + return &model.SimBaseRes[T]{ + SimKey: simKey, + Code: tibiotCode(resp), + ErrCode: strconv.Itoa(tibiotCode(resp)), + Message: resp.Message, + } +} + +func decodeTibiotData[T any](resp tibiotResponse, target *T) error { + if len(resp.Data) == 0 || string(resp.Data) == "null" { + return fmt.Errorf("Tibiot response data is empty") + } + if err := json.Unmarshal(resp.Data, target); err != nil { + return fmt.Errorf("decode Tibiot response data: %w", err) + } + return nil +} + +func decodeTibiotBatchCards(resp tibiotResponse) (records []tibiotCardInfo, pageCount int, err error) { + var pageData tibiotBatchCardData + if err = decodeTibiotData(resp, &pageData); err == nil && pageData.Records != nil { + return pageData.Records, pageData.PageCount, nil + } + var list []tibiotCardInfo + if listErr := decodeTibiotData(resp, &list); listErr != nil { + if err != nil { + return nil, 0, err + } + return nil, 0, listErr + } + return list, 1, nil +} + +func parseTibiotFlow(value string) float64 { + value = strings.ToUpper(strings.TrimSpace(value)) + if value == "" { + return 0 + } + for _, unit := range []struct { + suffix string + factor float64 + }{ + {suffix: "EB", factor: 1024 * 1024 * 1024 * 1024}, + {suffix: "PB", factor: 1024 * 1024 * 1024}, + {suffix: "TB", factor: 1024 * 1024}, + {suffix: "GB", factor: 1024}, + {suffix: "MB", factor: 1}, + {suffix: "KB", factor: 1.0 / 1024}, + } { + if strings.HasSuffix(value, unit.suffix) { + return gconv.Float64(strings.TrimSpace(strings.TrimSuffix(value, unit.suffix))) * unit.factor + } + } + return gconv.Float64(value) +} + +func tibiotSessionStatus(value string) string { + switch strings.ToUpper(strings.TrimSpace(value)) { + case "1", "ONLINE": + return consts.CardSessionStatusOnline + case "2", "OFFLINE": + return consts.CardSessionStatusOffline + default: + return consts.CardSessionStatusUnknown + } +} + +func tibiotDeviceStatus(value string) string { + switch strings.ToUpper(strings.TrimSpace(value)) { + case "1", "ON", "ONLINE": + return "1" + case "2", "0", "OFF", "OFFLINE": + return "0" + default: + return consts.CardSessionStatusUnknown + } +} + +func (s *sSimTibiot) QuerySimBasicInfo(ctx context.Context, input *model.SimBasicInfoInput) (model.SimBasicInfoOutput, error) { + if input == nil { + return make(model.SimBasicInfoOutput, 0), nil + } + items := make(model.SimBasicInfoOutput, 0, len(input.SimKeys)) + for _, simKey := range input.SimKeys { + params, err := tibiotParams(simKey) + if err != nil { + items = append(items, tibiotError[model.SimBasicInfoItem](simKey, err)) + continue + } + resp, err := s.post(ctx, "/api/v1/card/queryCardInfo", params) + if err != nil { + items = append(items, tibiotError[model.SimBasicInfoItem](simKey, err)) + continue + } + if !tibiotSuccess(resp) { + items = append(items, tibiotBusinessError[model.SimBasicInfoItem](simKey, resp)) + continue + } + var data tibiotCardInfo + if err := decodeTibiotData(resp, &data); err != nil { + items = append(items, tibiotError[model.SimBasicInfoItem](simKey, err)) + continue + } + items = append(items, &model.SimBaseRes[model.SimBasicInfoItem]{ + SimKey: simKey, + Code: consts.ApiStatusSuccess, + Message: resp.Message, + ResData: model.SimBasicInfoItem{ + ICCID: data.ICCID, + IMSI: data.IMSI, + MSISDN: data.MSISDN, + ActiveDate: firstNonEmpty(data.ActivateTime, data.LastActivateTime), + OpenDate: firstNonEmpty(data.OpenCardTime, data.ActivateTime), + Remark: data.Remark, + SimStatus: strconv.Itoa(convertTibiotCardStatus(context.Background(), gconv.Int(data.CardStatus))), + MainOfferName: data.PackageName, + }, + }) + } + return items, nil +} + +func (s *sSimTibiot) QuerySimSession(ctx context.Context, input *model.SimSessionInput) (model.SimSessionOutput, error) { + if input == nil { + return make(model.SimSessionOutput, 0), nil + } + items := make(model.SimSessionOutput, 0, len(input.SimKeys)) + for _, simKey := range input.SimKeys { + params, err := tibiotParams(simKey) + if err != nil { + items = append(items, tibiotError[model.SimSessionItem](simKey, err)) + continue + } + resp, err := s.post(ctx, "/api/v1/card/cardOnlineInformation", params) + if err != nil { + items = append(items, tibiotError[model.SimSessionItem](simKey, err)) + continue + } + if !tibiotSuccess(resp) { + items = append(items, tibiotBusinessError[model.SimSessionItem](simKey, resp)) + continue + } + var data tibiotOnlineInfo + if err := decodeTibiotData(resp, &data); err != nil { + items = append(items, tibiotError[model.SimSessionItem](simKey, err)) + continue + } + items = append(items, &model.SimBaseRes[model.SimSessionItem]{ + SimKey: simKey, + Code: consts.ApiStatusSuccess, + Message: resp.Message, + ResData: model.SimSessionItem{ + Imei: data.IMEI, + Rat: data.NetworkType, + Ipv4: data.IP, + ApnId: data.APN, + Status: tibiotSessionStatus(data.OnlineStatus), + }, + }) + } + return items, nil +} + +func (s *sSimTibiot) QuerySimCardStatus(ctx context.Context, input *model.SimCardStatusInput) (model.SimCardStatusOutput, error) { + if input == nil { + return make(model.SimCardStatusOutput, 0), nil + } + items := make(model.SimCardStatusOutput, 0, len(input.SimKeys)) + for _, simKey := range input.SimKeys { + params, err := tibiotParams(simKey) + if err != nil { + items = append(items, tibiotError[model.SimCardStatusItem](simKey, err)) + continue + } + resp, err := s.post(ctx, "/api/v1/card/queryCardInfo", params) + if err != nil { + items = append(items, tibiotError[model.SimCardStatusItem](simKey, err)) + continue + } + if !tibiotSuccess(resp) { + items = append(items, tibiotBusinessError[model.SimCardStatusItem](simKey, resp)) + continue + } + var data tibiotCardInfo + if err := decodeTibiotData(resp, &data); err != nil { + items = append(items, tibiotError[model.SimCardStatusItem](simKey, err)) + continue + } + items = append(items, &model.SimBaseRes[model.SimCardStatusItem]{ + SimKey: simKey, + Code: consts.ApiStatusSuccess, + Message: resp.Message, + ResData: model.SimCardStatusItem{CardStatus: convertTibiotCardStatus(ctx, gconv.Int(data.CardStatus)), LastChangeDate: data.LastActivateTime}, + }) + } + return items, nil +} + +func (s *sSimTibiot) QuerySimStopReason(ctx context.Context, input *model.SimStopReasonInput) (model.SimStopReasonOutput, error) { + if input == nil { + return make(model.SimStopReasonOutput, 0), nil + } + items := make(model.SimStopReasonOutput, 0, len(input.SimKeys)) + for _, simKey := range input.SimKeys { + params, err := tibiotParams(simKey) + if err != nil { + items = append(items, tibiotError[model.SimStopReasonItem](simKey, err)) + continue + } + resp, err := s.post(ctx, "/api/v1/card/getCardStopReason", params) + if err != nil { + items = append(items, tibiotError[model.SimStopReasonItem](simKey, err)) + continue + } + if !tibiotSuccess(resp) { + items = append(items, tibiotBusinessError[model.SimStopReasonItem](simKey, resp)) + continue + } + var reasons []string + if err := decodeTibiotData(resp, &reasons); err != nil { + items = append(items, tibiotError[model.SimStopReasonItem](simKey, err)) + continue + } + items = append(items, &model.SimBaseRes[model.SimStopReasonItem]{SimKey: simKey, Code: 0, Message: resp.Message, ResData: model.SimStopReasonItem{StopReason: strings.Join(reasons, ";")}}) + } + return items, nil +} + +func (s *sSimTibiot) QuerySimFrozenState(ctx context.Context, input *model.SimFrozenStateInput) (model.SimFrozenStateOutput, error) { + if input == nil { + return make(model.SimFrozenStateOutput, 0), nil + } + return tibiotUnsupported[model.SimFrozenStateItem](input.SimKeys, "Tibiot does not support frozen state query"), nil +} + +func (s *sSimTibiot) QuerySimCardBindStatus(ctx context.Context, input *model.SimCardBindStatusInput) (model.SimCardBindStatusOutput, error) { + if input == nil { + return make(model.SimCardBindStatusOutput, 0), nil + } + items := make(model.SimCardBindStatusOutput, 0, len(input.SimKeys)) + for _, simKey := range input.SimKeys { + params, err := tibiotParams(simKey) + if err != nil { + items = append(items, tibiotError[model.SimCardBindStatusItem](simKey, err)) + continue + } + resp, err := s.post(ctx, "/api/v1/card/queryCardBindStatus", params) + if err != nil { + items = append(items, tibiotError[model.SimCardBindStatusItem](simKey, err)) + continue + } + if !tibiotSuccess(resp) { + items = append(items, tibiotBusinessError[model.SimCardBindStatusItem](simKey, resp)) + continue + } + var value string + if err := decodeTibiotData(resp, &value); err != nil { + items = append(items, tibiotError[model.SimCardBindStatusItem](simKey, err)) + continue + } + items = append(items, &model.SimBaseRes[model.SimCardBindStatusItem]{SimKey: simKey, Code: 0, Message: resp.Message, ResData: model.SimCardBindStatusItem{BindStatus: value}}) + } + return items, nil +} + +func (s *sSimTibiot) QuerySimDeviceStatus(ctx context.Context, input *model.SimDeviceStatusInput) (model.SimDeviceStatusOutput, error) { + if input == nil { + return make(model.SimDeviceStatusOutput, 0), nil + } + items := make(model.SimDeviceStatusOutput, 0, len(input.SimKeys)) + for _, simKey := range input.SimKeys { + params, err := tibiotParams(simKey) + if err != nil { + items = append(items, tibiotError[model.SimDeviceStatusItem](simKey, err)) + continue + } + resp, err := s.post(ctx, "/api/v1/card/cardOnlineInformation", params) + if err != nil { + items = append(items, tibiotError[model.SimDeviceStatusItem](simKey, err)) + continue + } + if !tibiotSuccess(resp) { + items = append(items, tibiotBusinessError[model.SimDeviceStatusItem](simKey, resp)) + continue + } + var data tibiotOnlineInfo + if err := decodeTibiotData(resp, &data); err != nil { + items = append(items, tibiotError[model.SimDeviceStatusItem](simKey, err)) + continue + } + items = append(items, &model.SimBaseRes[model.SimDeviceStatusItem]{SimKey: simKey, Code: 0, Message: resp.Message, ResData: model.SimDeviceStatusItem{DeviceStatus: tibiotDeviceStatus(data.EquipmentStatus)}}) + } + return items, nil +} + +func (s *sSimTibiot) QuerySimRegionLimitStatus(ctx context.Context, input *model.SimRegionLimitStatusInput) (model.SimRegionLimitStatusOutput, error) { + if input == nil { + return make(model.SimRegionLimitStatusOutput, 0), nil + } + items := make(model.SimRegionLimitStatusOutput, 0, len(input.SimKeys)) + for _, simKey := range input.SimKeys { + params, err := tibiotParams(simKey) + if err != nil { + items = append(items, tibiotError[model.SimRegionLimitStatusItem](simKey, err)) + continue + } + resp, err := s.post(ctx, "/api/v1/card/cardOnlineInformation", params) + if err != nil { + items = append(items, tibiotError[model.SimRegionLimitStatusItem](simKey, err)) + continue + } + if !tibiotSuccess(resp) { + items = append(items, tibiotBusinessError[model.SimRegionLimitStatusItem](simKey, resp)) + continue + } + var data tibiotOnlineInfo + if err := decodeTibiotData(resp, &data); err != nil { + items = append(items, tibiotError[model.SimRegionLimitStatusItem](simKey, err)) + continue + } + items = append(items, &model.SimBaseRes[model.SimRegionLimitStatusItem]{SimKey: simKey, Code: 0, Message: resp.Message, ResData: model.SimRegionLimitStatusItem{RegionLimitStatus: data.RegionLimitStatus}}) + } + return items, nil +} + +func (s *sSimTibiot) QuerySimImei(ctx context.Context, input *model.SimImeiInput) (model.SimImeiOutput, error) { + if input == nil { + return make(model.SimImeiOutput, 0), nil + } + items := make(model.SimImeiOutput, 0, len(input.SimKeys)) + for _, simKey := range input.SimKeys { + params, err := tibiotParams(simKey) + if err != nil { + items = append(items, tibiotError[model.SimImeiItem](simKey, err)) + continue + } + resp, err := s.post(ctx, "/api/v1/card/querySimIMEI", params) + if err != nil { + items = append(items, tibiotError[model.SimImeiItem](simKey, err)) + continue + } + if !tibiotSuccess(resp) { + items = append(items, tibiotBusinessError[model.SimImeiItem](simKey, resp)) + continue + } + var data struct { + IMEI string `json:"imei"` + } + if err := decodeTibiotData(resp, &data); err != nil { + items = append(items, tibiotError[model.SimImeiItem](simKey, err)) + continue + } + items = append(items, &model.SimBaseRes[model.SimImeiItem]{SimKey: simKey, Code: 0, Message: resp.Message, ResData: model.SimImeiItem{Imei: data.IMEI}}) + } + return items, nil +} + +func (s *sSimTibiot) QuerySimDataUsage(ctx context.Context, input *model.SimDataUsageInput) (model.SimDataUsageOutput, error) { + if input == nil { + return make(model.SimDataUsageOutput, 0), nil + } + items := make(model.SimDataUsageOutput, 0, len(input.SimKeys)) + for _, simKey := range input.SimKeys { + result, err := s.querySimDataUsage(ctx, simKey) + if err != nil { + items = append(items, tibiotError[model.SimDataUsageItem](simKey, err)) + continue + } + if result.BusinessError != nil { + items = append(items, tibiotBusinessError[model.SimDataUsageItem](simKey, *result.BusinessError)) + continue + } + items = append(items, &model.SimBaseRes[model.SimDataUsageItem]{ + SimKey: simKey, + Code: 0, + Cached: result.Cached, + UpdatedAt: result.UpdatedAt, + ResData: model.SimDataUsageItem{DataUsage: result.Usage}, + }) + } + return items, nil +} + +func (s *sSimTibiot) querySimDataUsage(ctx context.Context, simKey *model.SimKey) (*tibiotUsageResult, error) { + cacheKey := tibiotUsageCacheKey(simKey) + if cached, ok := s.cachedUsage(cacheKey); ok { + return cached, nil + } + value, err, _ := s.usageFlight.Do(cacheKey, func() (any, error) { + if cached, ok := s.cachedUsage(cacheKey); ok { + return cached, nil + } + params, paramsErr := tibiotParams(simKey) + if paramsErr != nil { + return nil, paramsErr + } + params["flowUnit"] = "MB" + + var ( + lastResp tibiotResponse + lastErr error + usage float64 + ) + for attempt := 0; attempt < 2; attempt++ { + if waitErr := tibiotSimDataMarginLimiter.wait(ctx); waitErr != nil { + lastErr = waitErr + break + } + lastResp, lastErr = s.post(ctx, "/api/v1/card/simDataMargin", params) + if lastErr != nil { + if errors.Is(lastErr, context.Canceled) || errors.Is(lastErr, context.DeadlineExceeded) { + break + } + continue + } + if !tibiotSuccess(lastResp) { + continue + } + var value string + value, lastErr = decodeTibiotUsage(lastResp) + if lastErr == nil { + usage = parseTibiotFlow(value) + break + } + } + if lastErr != nil { + return nil, lastErr + } + if !tibiotSuccess(lastResp) { + return &tibiotUsageResult{BusinessError: &lastResp}, nil + } + updatedAt := time.Now().Format("2006-01-02 15:04:05") + s.usageCache.Store(cacheKey, tibiotUsageCacheEntry{Usage: usage, UpdatedAt: updatedAt, ExpiresAt: time.Now().Add(tibiotUsageCacheTTL)}) + return &tibiotUsageResult{Usage: usage, UpdatedAt: updatedAt}, nil + }) + if err != nil { + return nil, err + } + result, ok := value.(*tibiotUsageResult) + if !ok || result == nil { + return nil, fmt.Errorf("Tibiot usage query returned invalid result") + } + return result, nil +} + +func (s *sSimTibiot) cachedUsage(cacheKey string) (*tibiotUsageResult, bool) { + value, ok := s.usageCache.Load(cacheKey) + if !ok { + return nil, false + } + entry, ok := value.(tibiotUsageCacheEntry) + if !ok || time.Now().After(entry.ExpiresAt) { + s.usageCache.Delete(cacheKey) + return nil, false + } + return &tibiotUsageResult{Usage: entry.Usage, Cached: true, UpdatedAt: entry.UpdatedAt}, true +} + +func tibiotUsageCacheKey(simKey *model.SimKey) string { + if simKey == nil { + return "unknown" + } + for _, value := range []string{simKey.ICCID, simKey.IMSI, simKey.MSISDN} { + if value = strings.TrimSpace(value); value != "" { + return value + } + } + return "unknown" +} + +func decodeTibiotUsage(resp tibiotResponse) (string, error) { + var value string + if err := decodeTibiotData(resp, &value); err == nil { + if strings.TrimSpace(value) == "" { + return "", fmt.Errorf("Tibiot usage data is empty") + } + return value, nil + } else { + var number float64 + if numberErr := decodeTibiotData(resp, &number); numberErr != nil { + return "", err + } + return strconv.FormatFloat(number, 'f', -1, 64), nil + } +} + +func (s *sSimTibiot) QuerySimDataDiagnosis(ctx context.Context, input *model.SimDataDiagnosisInput) (model.SimDataDiagnosisOutput, error) { + if input == nil { + return make(model.SimDataDiagnosisOutput, 0), nil + } + return tibiotUnsupported[any](input.SimKeys, "Tibiot does not support data diagnosis"), nil +} + +func (s *sSimTibiot) QuerySimDataUsageMonthly(ctx context.Context, input *model.SimDataUsageMonthlyInput) (model.SimDataUsageMonthlyOutput, error) { + if input == nil { + return make(model.SimDataUsageMonthlyOutput, 0), nil + } + queryMonth, err := tibiotMonth(input.ReqData.QueryDate) + if err != nil { + return nil, err + } + items := make(model.SimDataUsageMonthlyOutput, 0, len(input.SimKeys)) + for _, simKey := range input.SimKeys { + params, err := tibiotParams(simKey) + if err != nil { + items = append(items, tibiotError[model.SimDataUsageMonthlyOutputItem](simKey, err)) + continue + } + params["flowUnit"] = "MB" + resp, err := s.post(ctx, "/api/v1/card/queryCardMonthData", params) + if err != nil { + items = append(items, tibiotError[model.SimDataUsageMonthlyOutputItem](simKey, err)) + continue + } + if !tibiotSuccess(resp) { + items = append(items, tibiotBusinessError[model.SimDataUsageMonthlyOutputItem](simKey, resp)) + continue + } + var data []tibiotMonthUsage + if err := decodeTibiotData(resp, &data); err != nil { + items = append(items, tibiotError[model.SimDataUsageMonthlyOutputItem](simKey, err)) + continue + } + var selected *tibiotMonthUsage + for i := range data { + dataMonth, parseErr := tibiotMonth(data[i].Years) + if queryMonth == "" || (parseErr == nil && dataMonth == queryMonth) { + selected = &data[i] + break + } + } + if selected == nil { + items = append(items, &model.SimBaseRes[model.SimDataUsageMonthlyOutputItem]{SimKey: simKey, Code: consts.ApiStatusError, Message: "monthly usage data not found"}) + continue + } + if strings.TrimSpace(selected.CardFlow) == "" { + items = append(items, tibiotError[model.SimDataUsageMonthlyOutputItem](simKey, fmt.Errorf("Tibiot monthly usage data is empty"))) + continue + } + items = append(items, &model.SimBaseRes[model.SimDataUsageMonthlyOutputItem]{SimKey: simKey, Code: 0, Message: resp.Message, ResData: model.SimDataUsageMonthlyOutputItem{DeadLine: selected.Years, DataAmount: parseTibiotFlow(selected.CardFlow)}}) + } + return items, nil +} + +func tibiotDate(queryDate string) (string, error) { + queryDate = strings.TrimSpace(queryDate) + if queryDate == "" { + return "", fmt.Errorf("queryDate is required") + } + if parsed, err := time.Parse("20060102", queryDate); err == nil { + return parsed.Format("2006-01-02"), nil + } + if parsed, err := time.Parse("2006-01-02", queryDate); err == nil { + return parsed.Format("2006-01-02"), nil + } + return "", fmt.Errorf("invalid queryDate: %s", queryDate) +} + +func tibiotMonth(queryDate string) (string, error) { + queryDate = strings.TrimSpace(queryDate) + if queryDate == "" { + return "", nil + } + for _, layout := range []string{"200601", "2006-01", "20060102", "2006-01-02"} { + if parsed, err := time.Parse(layout, queryDate); err == nil { + return parsed.Format("200601"), nil + } + } + return "", fmt.Errorf("invalid monthly queryDate: %s", queryDate) +} + +func (s *sSimTibiot) QuerySimDataUsageDaily(ctx context.Context, input *model.SimDataUsageDailyInput) (model.SimDataUsageDailyOutput, error) { + if input == nil { + return make(model.SimDataUsageDailyOutput, 0), nil + } + items := make(model.SimDataUsageDailyOutput, 0, len(input.SimKeys)) + queryDate, err := tibiotDate(input.ReqData.QueryDate) + if err != nil { + return nil, err + } + for _, simKey := range input.SimKeys { + params, err := tibiotParams(simKey) + if err != nil { + items = append(items, tibiotError[model.SimDataUsageDailyOutputItem](simKey, err)) + continue + } + params["queryTime"] = queryDate + params["flowUnit"] = "MB" + resp, err := s.post(ctx, "/api/v1/card/newIotData", params) + if err != nil { + items = append(items, tibiotError[model.SimDataUsageDailyOutputItem](simKey, err)) + continue + } + if !tibiotSuccess(resp) { + items = append(items, tibiotBusinessError[model.SimDataUsageDailyOutputItem](simKey, resp)) + continue + } + var data tibiotDailyUsage + if err := decodeTibiotData(resp, &data); err != nil { + items = append(items, tibiotError[model.SimDataUsageDailyOutputItem](simKey, err)) + continue + } + if strings.TrimSpace(data.CardFlow) == "" { + items = append(items, tibiotError[model.SimDataUsageDailyOutputItem](simKey, fmt.Errorf("Tibiot daily usage data is empty"))) + continue + } + items = append(items, &model.SimBaseRes[model.SimDataUsageDailyOutputItem]{SimKey: simKey, Code: 0, Message: resp.Message, ResData: model.SimDataUsageDailyOutputItem{QueryDate: input.ReqData.QueryDate, DataAmount: parseTibiotFlow(data.CardFlow)}}) + } + return items, nil +} + +func (s *sSimTibiot) QuerySimApnInfo(ctx context.Context, input *model.SimApnInfoInput) (model.SimApnInfoOutput, error) { + if input == nil { + return make(model.SimApnInfoOutput, 0), nil + } + return tibiotUnsupported[model.SimApnInfoItemList](input.SimKeys, "Tibiot does not support APN information query"), nil +} + +func (s *sSimTibiot) ChangeSimStatus(ctx context.Context, input *model.SimCardStatusChangeInput) (model.SimCardStatusChangeOutput, error) { + if input == nil { + return make(model.SimCardStatusChangeOutput, 0), nil + } + return tibiotUnsupported[model.SimCardStatusChangeItem](input.SimKeys, "Tibiot does not support SIM card status changes"), nil +} + +func (s *sSimTibiot) ChangeSimApnFunction(ctx context.Context, input *model.SimCardOperateChangeApnInput) (model.SimCardOperateChangeApnOutput, error) { + if input == nil { + return make(model.SimCardOperateChangeApnOutput, 0), nil + } + operateType := 0 + switch strings.TrimSpace(input.ReqData.OperType) { + case consts.CardApnOperTypeOpen: + operateType = 1 + case consts.CardApnOperTypeStop: + operateType = 2 + default: + return tibiotUnsupported[model.SimCardOperateChangeApnItem](input.SimKeys, "unsupported operType, expected 0(open) or 1(stop)"), nil + } + items := make(model.SimCardOperateChangeApnOutput, 0, len(input.SimKeys)) + for _, simKey := range input.SimKeys { + params, err := tibiotParams(simKey) + if err != nil { + items = append(items, tibiotError[model.SimCardOperateChangeApnItem](simKey, err)) + continue + } + params["operateType"] = operateType + resp, err := s.post(ctx, "/api/v1/card/cardNetWork", params) + if err != nil { + items = append(items, tibiotError[model.SimCardOperateChangeApnItem](simKey, err)) + continue + } + if !tibiotSuccess(resp) { + items = append(items, tibiotBusinessError[model.SimCardOperateChangeApnItem](simKey, resp)) + continue + } + items = append(items, &model.SimBaseRes[model.SimCardOperateChangeApnItem]{SimKey: simKey, Code: 0, Message: resp.Message, ResData: model.SimCardOperateChangeApnItem{OrderNum: ""}}) + } + return items, nil +} + +func (s *sSimTibiot) QuerySimCardStatusBatch(ctx context.Context, input *model.SimCardStatusInput) (model.SimCardStatusOutput, error) { + // Tibiot's batch endpoint returns paged card records. Query pages until all + // requested identifiers are found, preserving the caller's input order. + if input == nil || len(input.SimKeys) == 0 { + return make(model.SimCardStatusOutput, 0), nil + } + requested := make(map[string]*model.SimKey, len(input.SimKeys)*2) + for _, key := range input.SimKeys { + if key == nil { + continue + } + if key.ICCID != "" { + requested["iccid:"+strings.ToUpper(strings.TrimSpace(key.ICCID))] = key + } + if key.MSISDN != "" { + requested["msisdn:"+strings.TrimSpace(key.MSISDN)] = key + } + if key.IMSI != "" { + requested["imsi:"+strings.ToUpper(strings.TrimSpace(key.IMSI))] = key + } + } + found := make(map[*model.SimKey]tibiotCardInfo, len(input.SimKeys)) + for page := 1; page <= 100 && len(found) < len(input.SimKeys); page++ { + params := map[string]any{"cardType": 1, "pageNum": page, "pageSize": 100, "flowUnit": "MB"} + if len(input.SimKeys) == 1 { + if name, value := tibiotCardIdentifier(input.SimKeys[0]); name != "" { + params[name] = value + } + } + resp, err := s.post(ctx, "/api/v1/card/batchQueryCardInfo", params) + if err != nil { + return nil, err + } + if !tibiotSuccess(resp) { + return nil, fmt.Errorf("Tibiot batch status failed: code=%d message=%s", tibiotCode(resp), resp.Message) + } + records, pageCount, err := decodeTibiotBatchCards(resp) + if err != nil { + return nil, err + } + for _, item := range records { + if key := requested["iccid:"+strings.ToUpper(strings.TrimSpace(item.ICCID))]; key != nil { + found[key] = item + } + if key := requested["msisdn:"+strings.TrimSpace(item.MSISDN)]; key != nil { + found[key] = item + } + if key := requested["imsi:"+strings.ToUpper(strings.TrimSpace(item.IMSI))]; key != nil { + found[key] = item + } + } + if pageCount > 0 && page >= pageCount { + break + } + if len(records) < 100 { + break + } + } + items := make(model.SimCardStatusOutput, 0, len(input.SimKeys)) + for _, simKey := range input.SimKeys { + data, ok := found[simKey] + if !ok { + items = append(items, &model.SimBaseRes[model.SimCardStatusItem]{SimKey: simKey, Code: consts.ApiStatusError, Message: "batch response missing card"}) + continue + } + items = append(items, &model.SimBaseRes[model.SimCardStatusItem]{SimKey: simKey, Code: 0, ResData: model.SimCardStatusItem{CardStatus: convertTibiotCardStatus(ctx, gconv.Int(data.CardStatus)), LastChangeDate: data.LastActivateTime}}) + } + return items, nil +} + +func (s *sSimTibiot) QuerySimDataUsageMonthlyBatch(ctx context.Context, input *model.SimDataUsageMonthlyInput) (model.SimDataUsageMonthlyOutput, error) { + return s.QuerySimDataUsageMonthly(ctx, input) +} + +func (s *sSimTibiot) QuerySimDataUsageDailyBatch(ctx context.Context, input *model.SimDataUsageDailyInput) (model.SimDataUsageDailyOutput, error) { + if input == nil || len(input.SimKeys) == 0 { + return make(model.SimDataUsageDailyOutput, 0), nil + } + queryDate, err := tibiotDate(input.ReqData.QueryDate) + if err != nil { + return nil, err + } + params := map[string]any{"queryTime": queryDate, "flowUnit": "MB"} + useICCID := true + for _, key := range input.SimKeys { + if key == nil || strings.TrimSpace(key.ICCID) == "" { + useICCID = false + break + } + } + if useICCID { + values := make([]string, 0, len(input.SimKeys)) + for _, key := range input.SimKeys { + values = append(values, strings.TrimSpace(key.ICCID)) + } + params["iccids"] = strings.Join(values, ",") + } else { + values := make([]string, 0, len(input.SimKeys)) + for _, key := range input.SimKeys { + if key == nil || strings.TrimSpace(key.MSISDN) == "" { + return nil, fmt.Errorf("batch daily query requires all ICCIDs or MSISDNs") + } + values = append(values, strings.TrimSpace(key.MSISDN)) + } + params["msisdns"] = strings.Join(values, ",") + } + resp, err := s.post(ctx, "/api/v1/card/batchNewIotData", params) + if err != nil { + return nil, err + } + if !tibiotSuccess(resp) { + return nil, fmt.Errorf("Tibiot batch daily failed: code=%d message=%s", tibiotCode(resp), resp.Message) + } + var data []tibiotDailyUsage + if err := decodeTibiotData(resp, &data); err != nil { + return nil, err + } + byID := make(map[string]tibiotDailyUsage, len(data)*2) + for _, item := range data { + if item.ICCID != "" { + byID["iccid:"+strings.ToUpper(strings.TrimSpace(item.ICCID))] = item + } + if item.IMSI != "" { + byID["imsi:"+strings.ToUpper(strings.TrimSpace(item.IMSI))] = item + } + if item.MSISDN != "" { + byID["msisdn:"+strings.TrimSpace(item.MSISDN)] = item + } + } + items := make(model.SimDataUsageDailyOutput, 0, len(input.SimKeys)) + for _, key := range input.SimKeys { + var item tibiotDailyUsage + var ok bool + if key != nil { + for _, identifier := range []struct { + name string + value string + }{ + {name: "iccid", value: key.ICCID}, + {name: "msisdn", value: key.MSISDN}, + {name: "imsi", value: key.IMSI}, + } { + value := strings.TrimSpace(identifier.value) + if value == "" { + continue + } + lookup := identifier.name + ":" + value + if identifier.name != "msisdn" { + lookup = identifier.name + ":" + strings.ToUpper(value) + } + if item, ok = byID[lookup]; ok { + break + } + } + } + if !ok { + items = append(items, &model.SimBaseRes[model.SimDataUsageDailyOutputItem]{SimKey: key, Code: consts.ApiStatusError, Message: "batch response missing card", ResData: model.SimDataUsageDailyOutputItem{QueryDate: input.ReqData.QueryDate}}) + continue + } + if strings.TrimSpace(item.CardFlow) == "" { + items = append(items, &model.SimBaseRes[model.SimDataUsageDailyOutputItem]{SimKey: key, Code: consts.ApiStatusError, Message: "batch response card flow is empty", ResData: model.SimDataUsageDailyOutputItem{QueryDate: input.ReqData.QueryDate}}) + continue + } + items = append(items, &model.SimBaseRes[model.SimDataUsageDailyOutputItem]{SimKey: key, Code: 0, Message: resp.Message, ResData: model.SimDataUsageDailyOutputItem{QueryDate: input.ReqData.QueryDate, DataAmount: parseTibiotFlow(item.CardFlow)}}) + } + return items, nil +} diff --git a/internal/logic/upstreamimpl/sim_tibiot_rate_limit_test.go b/internal/logic/upstreamimpl/sim_tibiot_rate_limit_test.go new file mode 100644 index 0000000..c804c4f --- /dev/null +++ b/internal/logic/upstreamimpl/sim_tibiot_rate_limit_test.go @@ -0,0 +1,94 @@ +package upstreamimpl + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "sync/atomic" + "testing" + "time" + + "git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/model" + "git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/model/entity" +) + +func TestTibiotRateLimiterSpacesRequestsAndHonorsCancellation(t *testing.T) { + limiter := newTibiotRateLimiter(25 * time.Millisecond) + if err := limiter.wait(context.Background()); err != nil { + t.Fatalf("first wait failed: %v", err) + } + + started := time.Now() + if err := limiter.wait(context.Background()); err != nil { + t.Fatalf("second wait failed: %v", err) + } + if elapsed := time.Since(started); elapsed < 20*time.Millisecond { + t.Fatalf("second slot was granted too early: %s", elapsed) + } + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Millisecond) + defer cancel() + reservedBeforeCancel := limiter.next + if err := limiter.wait(ctx); err == nil { + t.Fatal("expected cancellation while waiting for a slot") + } + if !limiter.next.Equal(reservedBeforeCancel) { + t.Fatalf("canceled waiter reserved a slot: before=%s after=%s", reservedBeforeCancel, limiter.next) + } +} + +func TestTibiotQuerySimDataUsageRetriesOnceAfterUpstreamFailure(t *testing.T) { + previousLimiter := tibiotSimDataMarginLimiter + tibiotSimDataMarginLimiter = newTibiotRateLimiter(time.Millisecond) + defer func() { tibiotSimDataMarginLimiter = previousLimiter }() + + var calls atomic.Int32 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var body map[string]any + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + t.Fatalf("decode request: %v", err) + } + if body["flowUnit"] != "MB" { + t.Fatalf("flowUnit missing: %#v", body) + } + if calls.Add(1) == 1 { + _, _ = w.Write([]byte(`{"status":10049,"message":"too many requests","data":null}`)) + return + } + _, _ = w.Write([]byte(`{"status":0,"message":"ok","data":"2MB"}`)) + })) + defer server.Close() + + client := NewSimTibiot().NewTemp(context.Background(), &model.SimChannelConfigItemCache{ + Template: &entity.TSimChannelTemplate{ + TempConfig: `{"api":{"baseUrl":"` + server.URL + `"},"http":{"timeout":"2s"}}`, + }, + Channel: &entity.TSimChannels{ChanConfig: `{"userName":"tester","password":"secret"}`}, + }).(*sSimTibiot) + items, err := client.QuerySimDataUsage(context.Background(), &model.SimDataUsageInput{ + SimKeys: []*model.SimKey{{ICCID: "8986000000000000000"}}, + }) + if err != nil || len(items) != 1 { + t.Fatalf("unexpected query result: items=%#v err=%v", items, err) + } + if calls.Load() != 2 { + t.Fatalf("upstream calls = %d, want one retry", calls.Load()) + } + if items[0].Code != 0 || items[0].ResData.DataUsage != 2 { + t.Fatalf("unexpected successful retry result: %#v", items[0]) + } + + cachedItems, err := client.QuerySimDataUsage(context.Background(), &model.SimDataUsageInput{ + SimKeys: []*model.SimKey{{ICCID: "8986000000000000000"}}, + }) + if err != nil || len(cachedItems) != 1 { + t.Fatalf("unexpected cached query result: items=%#v err=%v", cachedItems, err) + } + if calls.Load() != 2 { + t.Fatalf("cached query called upstream again: calls=%d", calls.Load()) + } + if !cachedItems[0].Cached || cachedItems[0].UpdatedAt == "" { + t.Fatalf("cached query metadata missing: %#v", cachedItems[0]) + } +} diff --git a/internal/logic/upstreamimpl/sim_yd.go b/internal/logic/upstreamimpl/sim_yd.go new file mode 100644 index 0000000..76e273d --- /dev/null +++ b/internal/logic/upstreamimpl/sim_yd.go @@ -0,0 +1,1871 @@ +package upstreamimpl + +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" + 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" + "github.com/gogf/gf/v2/os/gtime" + "github.com/gogf/gf/v2/util/gconv" +) + +const ( + gUrlBase = "https://api.iot.10086.cn/v5/" +) + +type ydDataUsageResponse struct { + Status string `json:"status"` + Message string `json:"message"` + Result []struct { + DataAmount string `json:"dataAmount"` + ApnUseAmountList []struct { + ApnName string `json:"apnName"` + ApnUseAmount string `json:"apnUseAmount"` + PccCodeUseAmountList []any `json:"pccCodeUseAmountList"` + } `json:"apnUseAmountList"` + } `json:"result"` +} + +func decodeYdDataUsageResponse(body string) (ydDataUsageResponse, error) { + var response ydDataUsageResponse + err := json.Unmarshal([]byte(body), &response) + return response, err +} + +type sSimYD struct { + channelConfig *model.SimChannelConfigItemCache + seqId atomic.Int64 + tokenProvider ydTokenProvider + apiConfig *oneLinkAPIConfig +} + +func NewSimYD() *sSimYD { + return &sSimYD{} +} + +func init() { + s := NewSimYD() + service.UpstreamAPI().RegisterTemp("sim_yd_v5", s) + service.RegisterSimYD(s) +} + +func (s *sSimYD) NewTemp(ctx context.Context, channelConfig *model.SimChannelConfigItemCache) model.SimCardChannelI { + apiConfig := parseOneLinkTemplateAPI(ctx, channelConfig) + if apiConfig == nil { + apiConfig = &oneLinkAPIConfig{BaseURL: gUrlBase, Timeout: loadDefaultUpstreamHTTPTimeout(ctx)} + } + return &sSimYD{ + channelConfig: channelConfig, + apiConfig: apiConfig, + } +} + +// GetSharedToken exposes the shared token for channel types that support it. +// Shared-token channels carry a sharedTokenManagerProvider; other channel +// types return an error. +func (s *sSimYD) GetSharedToken(ctx context.Context) (sharedtoken.OneLinkToken, error) { + provider, ok := s.tokenProvider.(interface { + GetSharedToken(ctx context.Context) (sharedtoken.OneLinkToken, error) + }) + if !ok { + return sharedtoken.OneLinkToken{}, fmt.Errorf("通道不支持共享Token") + } + return provider.GetSharedToken(ctx) +} + +func (s *sSimYD) getUrl(ctx context.Context, serviceUrl string) string { + baseURL := gUrlBase + if s.apiConfig != nil && s.apiConfig.BaseURL != "" { + baseURL = s.apiConfig.BaseURL + } + if strings.HasSuffix(baseURL, "/") { + return baseURL + serviceUrl + } + return baseURL + "/" + serviceUrl +} + +func (s *sSimYD) refreshToken(ctx context.Context) string { + chanKey := s.channelConfig.Channel.ChanKey + tokenPath := "ec/get/token" + if s.apiConfig != nil && s.apiConfig.TokenPath != "" { + tokenPath = strings.TrimPrefix(s.apiConfig.TokenPath, "/") + } + url := s.getUrl(ctx, tokenPath) + client := g.Client() + client.SetTimeout(s.httpTimeout()) + // {"status":"0","message":"正确","result":[{"token":"50af5334a72a5a51ad8fe72d5c0e77a3e00a7a5588d301be143479310d32b609","ttl":3600}]} + type ydTokenResp struct { + Status string `json:"status"` + Message string `json:"message"` + Result []struct { + Token string `json:"token"` + Ttl int `json:"ttl"` + } `json:"result"` + } + now := gtime.Now() + oldToken := s.channelConfig.Channel.AccessToken + // TODO: 暂时不刷新,影响老系统,实际上需要刷新的 + // FIX: 暂时不刷新,影响老系统,实际上需要刷新的 + params := g.Map{ + "appid": s.channelConfig.Channel.AppKey, + "password": s.channelConfig.Channel.AppSecret, + "transid": s.getTransId(ctx), + "refresh": "0", // 0: 不刷新, 1: 刷新 + } + resp, err := client.Get(ctx, url, params) + if err != nil { + g.Log().Errorf(ctx, "refreshToken get error %+v, chanKey %s", err, chanKey) + return "" + } + + body := resp.ReadAllString() + var ydResp ydTokenResp + err = json.Unmarshal([]byte(body), &ydResp) + if err != nil { + g.Log().Errorf(ctx, "refreshToken unmarshal error %+v, chanKey %s", err, chanKey) + return "" + } + if ydResp.Status != "0" { + g.Log().Warningf(ctx, "refreshToken upstream error status=%s message=%s chanKey=%s", ydResp.Status, ydResp.Message, chanKey) + return "" + } + token := "" + ttl := 0 + if len(ydResp.Result) > 0 { + token = ydResp.Result[0].Token + ttl = ydResp.Result[0].Ttl + } + if token == "" { + g.Log().Warningf(ctx, "refreshToken empty token in result chanKey=%s", chanKey) + return "" + } + now2 := gtime.New(now) + accessTokenExpire := now2.Add(time.Duration(ttl) * time.Second) + err = service.ChannelUpdate().UpdateAccessToken(ctx, &model.UpdateAccessTokenInput{ + ChanKey: s.channelConfig.Channel.ChanKey, + AccessToken: token, + AccessTokenCreated: now, + AccessTokenExpire: accessTokenExpire, + RefreshToken: "", + RefreshTokenExpire: nil, + }) + if err != nil { + g.Log().Errorf(ctx, "refreshToken update access token error %+v, chanKey %s, ", err, chanKey) + return "" + } + s.channelConfig.Channel.AccessToken = token + s.channelConfig.Channel.AccessTokenExpire = accessTokenExpire + updateYdTokenState(chanKey, token, now.Time, accessTokenExpire.Time) + g.Log().Infof(ctx, "refreshToken success, tokenChanged %v, ttl %d, expire %s, chanKey %s", oldToken != token, ttl, accessTokenExpire.Format("2006-01-02 15:04:05"), chanKey) + return token +} + +func (s *sSimYD) getToken(ctx context.Context) string { + if s.tokenProvider != nil { + token, err := s.tokenProvider.GetToken(ctx) + if err != nil { + g.Log().Errorf(ctx, "shared token get error: %v", err) + return "" + } + return token + } + chanKey := s.channelConfig.Channel.ChanKey + policy := loadYdTokenRefreshPolicy(ctx) + return withYdTokenGuard(chanKey, func() string { + now := time.Now() + seedYdTokenState(s.channelConfig.Channel) + state := ydTokenStateFor(chanKey) + state.mu.RLock() + current, expiresAt := state.token, state.expiresAt + state.mu.RUnlock() + usable := current != "" && !expiresAt.IsZero() && now.Before(expiresAt) + if policy.mode == ydTokenRefreshModeProactive && usable { + usable = now.Before(expiresAt.Add(-time.Duration(policy.skewSecond) * time.Second)) + } + if usable { + s.channelConfig.Channel.AccessToken = current + s.channelConfig.Channel.AccessTokenExpire = gtime.New(expiresAt) + return current + } + g.Log().Debugf(ctx, "getToken refresh needed, mode %s, chanKey %s", policy.mode, chanKey) + return s.refreshToken(ctx) + }) +} + +func (s *sSimYD) recoverToken(ctx context.Context, rejectedToken string) (string, error) { + if s.tokenProvider != nil { + token, err := s.tokenProvider.RecoverToken(ctx, rejectedToken) + if err != nil { + return token, err + } + if token == "" { + return "", fmt.Errorf("shared token recovery returned an empty token") + } + if token == rejectedToken { + return token, errYdTokenUnchanged + } + return token, nil + } + return recoverYdToken(ctx, s.channelConfig.Channel.ChanKey, s.channelConfig.Channel, rejectedToken, s.refreshToken) +} + +func (s *sSimYD) requestYd(ctx context.Context, operation, url string, params g.Map) (*ydHTTPResponse, error) { + client := g.Client() + client.SetTimeout(s.httpTimeout()) + return doYdRequest(ctx, operation, s.channelConfig.Channel.ChanKey, s.getToken, s.recoverToken, params, func(requestParams g.Map) (string, error) { + if t, _ := requestParams["token"].(string); strings.TrimSpace(t) == "" { + // Sending an empty token yields OneLink 11000 "请求参数不正确", + // which hides the real cause (token acquisition failure). + return "", gerror.New("token is empty, skip upstream request") + } + if s.tokenProvider != nil { + // transid is protocol-owned and must be regenerated for a replay. + requestParams["transid"] = s.getTransId(ctx) + if provider, ok := s.tokenProvider.(interface { + GetDeviceToken(context.Context) (string, error) + }); ok { + deviceToken, deviceErr := provider.GetDeviceToken(ctx) + if deviceErr != nil { + return "", fmt.Errorf("shared device token get error: %w", deviceErr) + } + if strings.TrimSpace(deviceToken) != "" { + requestParams["token_device"] = deviceToken + } + } + } + resp, err := client.Get(ctx, url, requestParams) + if err != nil { + return "", err + } + return resp.ReadAllString(), nil + }) +} + +func (s *sSimYD) getTransId(ctx context.Context) string { + prefix := s.channelConfig.Channel.AppKey + if s.apiConfig != nil && s.apiConfig.transIDPrefix != "" { + prefix = s.apiConfig.transIDPrefix + } + return prefix + time.Now().Format("20060102150405") + fmt.Sprintf("%08d", s.seqId.Add(1)) +} + +func (s *sSimYD) httpTimeout() time.Duration { + if s != nil && s.apiConfig != nil && s.apiConfig.Timeout > 0 { + return s.apiConfig.Timeout + } + return defaultUpstreamHTTPTimeout +} + +// 单卡基本信息查询 +// http://api.iot.10086.cn/v5/ec/query/sim-basic-info?transid=YOUR_TRANSID&token=YOUR_TOKEN&iccid=898604F4152391115474 +func (s *sSimYD) QuerySimBasicInfo(ctx context.Context, input *model.SimBasicInfoInput) (model.SimBasicInfoOutput, error) { + // { + // "status": "0", + // "message": "正确", + // "result": [ + // { + // "msisdn": "1441543605474", + // "imsi": "460087436015474", + // "iccid": "898604F4152391115474", + // "activeDate": "2023-05-11 15:07:06", // 激活日期 + // "openDate": "2023-05-11 14:53:24", // 开卡日期 + // "remark": "" + // } + // ] + // } + type ydResp struct { + Status string `json:"status"` + Message string `json:"message"` + Result []struct { + Msisdn string `json:"msisdn"` + Imsi string `json:"imsi"` + Iccid string `json:"iccid"` + ActiveDate string `json:"activeDate"` + OpenDate string `json:"openDate"` + Remark string `json:"remark"` + } `json:"result"` + } + + url := s.getUrl(ctx, "ec/query/sim-basic-info") + + items := make(model.SimBasicInfoOutput, 0) + for _, simKey := range input.SimKeys { + params := g.Map{ + "transid": s.getTransId(ctx), + "iccid": simKey.ICCID, + } + resp, err := s.requestYd(ctx, "QuerySimBasicInfo", url, params) + if err != nil { + g.Log().Errorf(ctx, "QuerySimBasicInfo get error %+v,url %s, params %+v, ", err, url, params) + items = append(items, &model.SimBaseRes[model.SimBasicInfoItem]{ + SimKey: simKey, + Code: consts.ApiStatusError, + ResData: model.SimBasicInfoItem{ + ActiveDate: "", + OpenDate: "", + Remark: "", + }, + }) + continue + } + body := resp.ReadAllString() + + var ydResp ydResp + err = json.Unmarshal([]byte(body), &ydResp) + if err != nil { + g.Log().Errorf(ctx, "QuerySimBasicInfo unmarshal error %+v,url %s, params %+v, body %s, ", err, url, params, body) + items = append(items, &model.SimBaseRes[model.SimBasicInfoItem]{ + SimKey: simKey, + Code: consts.ApiStatusError, + ResData: model.SimBasicInfoItem{ + ActiveDate: "", + OpenDate: "", + Remark: "", + }, + }) + continue + } + if ydResp.Status != "0" { + g.Log().Warningf(ctx, "QuerySimBasicInfo error %s,url %s, params %+v, body %s, ", ydResp.Message, url, params, body) + items = append(items, &model.SimBaseRes[model.SimBasicInfoItem]{ + SimKey: simKey, + Code: gconv.Int(ydResp.Status), + Message: ydResp.Message, + ResData: model.SimBasicInfoItem{ + ActiveDate: "", + OpenDate: "", + Remark: "", + }, + }) + continue + } + if len(ydResp.Result) == 0 { + items = append(items, &model.SimBaseRes[model.SimBasicInfoItem]{ + SimKey: simKey, + Code: consts.ApiStatusError, + Message: firstNonEmpty(ydResp.Message, "上游未返回SIM卡信息"), + ResData: model.SimBasicInfoItem{ + ActiveDate: "", + OpenDate: "", + Remark: "", + }, + }) + continue + } + hasIdentity := false + for _, session := range ydResp.Result { + if ydBasicInfoHasIdentity(session.Iccid, session.Msisdn, session.Imsi) { + hasIdentity = true + break + } + } + if !hasIdentity { + message := strings.TrimSpace(ydResp.Message) + if message == "" || message == "正常" || message == "正确" { + message = "上游未返回三码信息" + } + items = append(items, &model.SimBaseRes[model.SimBasicInfoItem]{ + SimKey: simKey, + Code: consts.ApiStatusError, + Message: message, + ResData: model.SimBasicInfoItem{ + ActiveDate: "", + OpenDate: "", + Remark: "", + }, + }) + continue + } + for _, session := range ydResp.Result { + items = append(items, &model.SimBaseRes[model.SimBasicInfoItem]{ + SimKey: simKey, + Code: gconv.Int(ydResp.Status), + Message: ydResp.Message, + ResData: model.SimBasicInfoItem{ + ICCID: session.Iccid, + IMSI: session.Imsi, + MSISDN: session.Msisdn, + ActiveDate: session.ActiveDate, + OpenDate: session.OpenDate, + Remark: session.Remark, + }, + }) + } + } + return items, nil +} + +// 5.7.2 CMIOT_API25M01-单卡在线信息实时查询 +func (s *sSimYD) QuerySimSession(ctx context.Context, input *model.SimSessionInput) (model.SimSessionOutput, error) { + // { + // "status": "0", + // "message": "正确", + // "result": [ + // { + // "simSessionList":[ + // { + // "apnId":"CMIOT", + // "status":"01", + // "ip":"127.0.0.1", + // "ipv6Prefix":"", + // "ipv6":"", + // "createDate":"2018-08-23 17:17:27", + // "rat":"6" + // } + // ] + // } + // ] + // } + + type ydResp struct { + Status string `json:"status"` + Message string `json:"message"` + Result []struct { + InfoList []struct { + Apn string `json:"apn"` + SessionStatus string `json:"sessionStatus"` + Ipv4 string `json:"ipv4"` + Ipv6Prefix string `json:"ipv6Prefix"` + Ipv6 string `json:"ipv6"` + CreateTime string `json:"createTime"` + AccessType string `json:"accessType"` + } `json:"infoList"` + SimSessionList []struct { + ApnId string `json:"apnId"` + Status string `json:"status"` + Ip string `json:"ip"` + Ipv6Prefix string `json:"ipv6Prefix"` + Ipv6 string `json:"ipv6"` + CreateDate string `json:"createDate"` + Rat string `json:"rat"` + } `json:"simSessionList"` + } `json:"result"` + } + + url := s.getUrl(ctx, "ec/query/sim-apn-session-info") + + items := make(model.SimSessionOutput, 0) + for _, simKey := range input.SimKeys { + params := g.Map{ + "transid": s.getTransId(ctx), + } + paramName, paramValue := pickSimQueryParam(simKey) + if paramName == "" { + items = append(items, &model.SimBaseRes[model.SimSessionItem]{ + SimKey: simKey, + Code: consts.ApiStatusError, + Message: "missing iccid/msisdn/imsi", + ResData: model.SimSessionItem{ + Status: "-2", + }, + }) + continue + } + params[paramName] = paramValue + resp, err := s.requestYd(ctx, "QuerySimSession", url, params) + if err != nil { + g.Log().Errorf(ctx, "QuerySimSession get error %+v,url %s, params %+v, ", err, url, params) + items = append(items, &model.SimBaseRes[model.SimSessionItem]{ + SimKey: simKey, + Code: consts.ApiStatusError, + Message: err.Error(), + ResData: model.SimSessionItem{ + Status: "-2", + }, + }) + continue + } + body := resp.ReadAllString() + + var ydResp ydResp + err = json.Unmarshal([]byte(body), &ydResp) + if err != nil { + g.Log().Errorf(ctx, "QuerySimSession unmarshal error %+v,url %s, params %+v, body %s, ", err, url, params, body) + items = append(items, &model.SimBaseRes[model.SimSessionItem]{ + SimKey: simKey, + Code: consts.ApiStatusError, + ResData: model.SimSessionItem{ + Status: "-2", + }, + }) + continue + } + if ydResp.Status != "0" { + g.Log().Warningf(ctx, "QuerySimSession error %s,url %s, params %+v, body %s, ", ydResp.Message, url, params, body) + items = append(items, &model.SimBaseRes[model.SimSessionItem]{ + SimKey: simKey, + Code: gconv.Int(ydResp.Status), + Message: ydResp.Message, + ResData: model.SimSessionItem{ + Status: "-2", + }, + }) + continue + } + if len(ydResp.Result) == 0 || (len(ydResp.Result[0].InfoList) == 0 && len(ydResp.Result[0].SimSessionList) == 0) { + items = append(items, &model.SimBaseRes[model.SimSessionItem]{ + SimKey: simKey, + Code: consts.ApiStatusError, + Message: ydResp.Message, + ResData: model.SimSessionItem{ + Status: "-2", + }, + }) + continue + } + for _, session := range ydResp.Result[0].InfoList { + items = append(items, &model.SimBaseRes[model.SimSessionItem]{ + SimKey: simKey, + Code: gconv.Int(ydResp.Status), + Message: ydResp.Message, + ResData: model.SimSessionItem{ + ApnId: session.Apn, + Rat: session.AccessType, + Ipv4: session.Ipv4, + Ipv6: session.Ipv6, + Ipv6Prefix: session.Ipv6Prefix, + Status: convertYdSessionStatus(ctx, session.SessionStatus), + CreateTime: session.CreateTime, + }, + }) + } + for _, session := range ydResp.Result[0].SimSessionList { + items = append(items, &model.SimBaseRes[model.SimSessionItem]{ + SimKey: simKey, + Code: gconv.Int(ydResp.Status), + Message: ydResp.Message, + ResData: model.SimSessionItem{ + ApnId: session.ApnId, + Rat: session.Rat, + Ipv4: session.Ip, + Ipv6: session.Ipv6, + Ipv6Prefix: session.Ipv6Prefix, + Status: convertYdSessionStatus(ctx, session.Status), + CreateTime: session.CreateDate, + }, + }) + } + } + return items, nil +} + +// 5.1.6 单卡状态查询 +// https://api.iot.10086.cn/v5/ec/query/sim-status?transid=YOUR_TRANSID&token=YOUR_TOKEN&iccid=898604F4152391115474 +func (s *sSimYD) QuerySimCardStatus(ctx context.Context, input *model.SimCardStatusInput) (model.SimCardStatusOutput, error) { + url := s.getUrl(ctx, "ec/query/sim-status") + + // { + // "status":"0", + // "message":"正确", + // "result":[ + // { + // "cardStatus":"1", + // "lastChangeDate":"2018-04-21 08:00:00" + // } + // ] + // } + + // {"status":"0","message":"正确","result":[{"cardStatus":"2","lastChangeDate":"2023-05-11 15:07:08"}]} + type ydResp struct { + Status string `json:"status"` + Message string `json:"message"` + Result []struct { + CardStatus string `json:"cardStatus"` + LastChangeDate string `json:"lastChangeDate"` + } `json:"result"` + } + items := make(model.SimCardStatusOutput, 0) + for _, simKey := range input.SimKeys { + params := g.Map{ + "transid": s.getTransId(ctx), + "iccid": simKey.ICCID, + } + + resp, err := s.requestYd(ctx, "QuerySimCardStatus", url, params) + if err != nil { + g.Log().Errorf(ctx, "QuerySimCardStatus get error %+v,url %s, params %+v, ", err, url, params) + items = append(items, &model.SimBaseRes[model.SimCardStatusItem]{ + SimKey: simKey, + Code: consts.ApiStatusError, + Message: err.Error(), + }) + continue + } + body := resp.ReadAllString() + var ydResp ydResp + err = json.Unmarshal([]byte(body), &ydResp) + if err != nil { + g.Log().Errorf(ctx, "QuerySimCardStatus unmarshal error %+v,url %s, params %+v, body %s, ", err, url, params, body) + items = append(items, &model.SimBaseRes[model.SimCardStatusItem]{ + SimKey: simKey, + Code: consts.ApiStatusError, + Message: err.Error(), + }) + continue + } + if ydResp.Status != "0" { + g.Log().Warningf(ctx, "QuerySimCardStatus error %s,url %s, params %+v, body %s, ", ydResp.Message, url, params, body) + items = append(items, &model.SimBaseRes[model.SimCardStatusItem]{ + SimKey: simKey, + Code: gconv.Int(ydResp.Status), + Message: ydResp.Message, + ResData: model.SimCardStatusItem{ + CardStatus: 0, + LastChangeDate: "", + }, + }) + continue + } + if len(ydResp.Result) == 0 { + items = append(items, &model.SimBaseRes[model.SimCardStatusItem]{ + SimKey: simKey, + Code: consts.ApiStatusError, + Message: ydResp.Message, + ResData: model.SimCardStatusItem{ + CardStatus: 0, + LastChangeDate: "", + }, + }) + continue + } + if len(ydResp.Result) > 0 { + result := ydResp.Result[0] + items = append(items, &model.SimBaseRes[model.SimCardStatusItem]{ + SimKey: simKey, + Code: gconv.Int(ydResp.Status), + Message: ydResp.Message, + ResData: model.SimCardStatusItem{ + CardStatus: gconv.Int(result.CardStatus), + LastChangeDate: result.LastChangeDate, + }, + }) + } else { + items = append(items, &model.SimBaseRes[model.SimCardStatusItem]{ + SimKey: simKey, + Code: consts.ApiStatusError, + Message: ydResp.Message, + ResData: model.SimCardStatusItem{ + CardStatus: 0, + LastChangeDate: "", + }, + }) + } + } + return items, nil +} + +// 物联网卡IMEI查询 +// https://api.iot.10086.cn/v5/ec/query/sim-imei?transid=371AIOT20210605101712810732026031520280010000000&token=YOUR_TOKEN&iccid=898604F81623D2249725 +func (s *sSimYD) QuerySimStopReason(ctx context.Context, input *model.SimStopReasonInput) (model.SimStopReasonOutput, error) { + url := s.getUrl(ctx, "ec/query/sim-stop-reason") + + type ydResp struct { + Status string `json:"status"` + Message string `json:"message"` + Result []struct { + Msisdn string `json:"msisdn"` + PlatformType string `json:"platformType"` + StopReason string `json:"stopReason"` + } `json:"result"` + } + + items := make(model.SimStopReasonOutput, 0, len(input.SimKeys)) + for _, simKey := range input.SimKeys { + paramKey, paramValue := pickSimQueryParam(simKey) + if paramKey == "" { + items = append(items, &model.SimBaseRes[model.SimStopReasonItem]{ + SimKey: simKey, + Code: consts.ApiStatusError, + Message: "missing iccid/msisdn/imsi", + ResData: model.SimStopReasonItem{}, + }) + continue + } + + params := g.Map{ + "transid": s.getTransId(ctx), + paramKey: paramValue, + } + resp, err := s.requestYd(ctx, "QuerySimStopReason", url, params) + if err != nil { + g.Log().Errorf(ctx, "QuerySimStopReason get error %+v,url %s, params %+v, ", err, url, params) + items = append(items, &model.SimBaseRes[model.SimStopReasonItem]{ + SimKey: simKey, + Code: consts.ApiStatusError, + Message: err.Error(), + ResData: model.SimStopReasonItem{}, + }) + continue + } + body := resp.ReadAllString() + + var ydResp ydResp + if err = json.Unmarshal([]byte(body), &ydResp); err != nil { + g.Log().Errorf(ctx, "QuerySimStopReason unmarshal error %+v,url %s, params %+v, body %s, ", err, url, params, body) + items = append(items, &model.SimBaseRes[model.SimStopReasonItem]{ + SimKey: simKey, + Code: consts.ApiStatusError, + Message: err.Error(), + ResData: model.SimStopReasonItem{}, + }) + continue + } + if ydResp.Status != "0" { + g.Log().Warningf(ctx, "QuerySimStopReason error %s,url %s, params %+v, body %s, ", ydResp.Message, url, params, body) + items = append(items, &model.SimBaseRes[model.SimStopReasonItem]{ + SimKey: simKey, + Code: gconv.Int(ydResp.Status), + Message: ydResp.Message, + ResData: model.SimStopReasonItem{}, + }) + continue + } + + stopReason := "未停机" + platformType := "" + if len(ydResp.Result) > 0 { + stopReason = mapYdStopReasonText(ydResp.Result[0].StopReason) + if stopReason == "" { + stopReason = "未停机" + } + platformType = strings.TrimSpace(ydResp.Result[0].PlatformType) + } + + items = append(items, &model.SimBaseRes[model.SimStopReasonItem]{ + SimKey: simKey, + Code: gconv.Int(ydResp.Status), + Message: ydResp.Message, + ResData: model.SimStopReasonItem{ + StopReason: stopReason, + PlatformType: platformType, + }, + }) + } + return items, nil +} + +func (s *sSimYD) QuerySimFrozenState(ctx context.Context, input *model.SimFrozenStateInput) (model.SimFrozenStateOutput, error) { + url := s.getUrl(ctx, "ec/query/sim-frozen-state-info") + + type ydResp struct { + Status string `json:"status"` + Message string `json:"message"` + Result []struct { + Msisdn string `json:"msisdn"` + FrozenStatus string `json:"frozenStatus"` + FrozenReason string `json:"frozenReason"` + OperTime string `json:"operTime"` + } `json:"result"` + } + + items := make(model.SimFrozenStateOutput, 0, len(input.SimKeys)) + for _, simKey := range input.SimKeys { + paramKey, paramValue := pickSimQueryParam(simKey) + if paramKey == "" { + items = append(items, &model.SimBaseRes[model.SimFrozenStateItem]{ + SimKey: simKey, + Code: consts.ApiStatusError, + Message: "missing iccid/msisdn/imsi", + ResData: model.SimFrozenStateItem{}, + }) + continue + } + + params := g.Map{ + "transid": s.getTransId(ctx), + paramKey: paramValue, + } + resp, err := s.requestYd(ctx, "QuerySimFrozenState", url, params) + if err != nil { + g.Log().Errorf(ctx, "QuerySimFrozenState get error %+v,url %s, params %+v, ", err, url, params) + items = append(items, &model.SimBaseRes[model.SimFrozenStateItem]{ + SimKey: simKey, + Code: consts.ApiStatusError, + Message: err.Error(), + ResData: model.SimFrozenStateItem{}, + }) + continue + } + body := resp.ReadAllString() + + var ydResp ydResp + if err = json.Unmarshal([]byte(body), &ydResp); err != nil { + g.Log().Errorf(ctx, "QuerySimFrozenState unmarshal error %+v,url %s, params %+v, body %s, ", err, url, params, body) + items = append(items, &model.SimBaseRes[model.SimFrozenStateItem]{ + SimKey: simKey, + Code: consts.ApiStatusError, + Message: err.Error(), + ResData: model.SimFrozenStateItem{}, + }) + continue + } + if ydResp.Status != "0" { + g.Log().Warningf(ctx, "QuerySimFrozenState error %s,url %s, params %+v, body %s, ", ydResp.Message, url, params, body) + items = append(items, &model.SimBaseRes[model.SimFrozenStateItem]{ + SimKey: simKey, + Code: gconv.Int(ydResp.Status), + Message: ydResp.Message, + ResData: model.SimFrozenStateItem{}, + }) + continue + } + + frozenStatus := "未冻结" + frozenReason := "" + operTime := "" + if len(ydResp.Result) > 0 { + frozenStatus = mapYdFrozenStatusText(ydResp.Result[0].FrozenStatus) + if frozenStatus == "" { + frozenStatus = "未冻结" + } + frozenReason = strings.TrimSpace(ydResp.Result[0].FrozenReason) + operTime = strings.TrimSpace(ydResp.Result[0].OperTime) + } + + items = append(items, &model.SimBaseRes[model.SimFrozenStateItem]{ + SimKey: simKey, + Code: gconv.Int(ydResp.Status), + Message: ydResp.Message, + ResData: model.SimFrozenStateItem{ + FrozenStatus: frozenStatus, + FrozenReason: frozenReason, + OperTime: operTime, + }, + }) + } + return items, nil +} + +func (s *sSimYD) QuerySimImei(ctx context.Context, input *model.SimImeiInput) (model.SimImeiOutput, error) { + url := s.getUrl(ctx, "ec/query/sim-terminal-device-info") + // {"status":"0","message":"正确","result":[{"imei":"865466070817587"}]} + type ydResp struct { + Status string `json:"status"` + Message string `json:"message"` + Result []struct { + Imei string `json:"imei"` + TerminalDeviceImei string `json:"terminalDeviceImei"` + } `json:"result"` + } + items := make(model.SimImeiOutput, 0) + for _, simKey := range input.SimKeys { + key, value := pickSimQueryParam(simKey) + if key == "" { + items = append(items, &model.SimBaseRes[model.SimImeiItem]{SimKey: simKey, Code: consts.ApiStatusError, Message: "missing iccid, msisdn or imsi"}) + continue + } + params := g.Map{ + "transid": s.getTransId(ctx), + key: value, + } + + resp, err := s.requestYd(ctx, "QuerySimImei", url, params) + if err != nil { + g.Log().Errorf(ctx, "QuerySimImei get error %+v,url %s, params %+v, ", err, url, params) + items = append(items, &model.SimBaseRes[model.SimImeiItem]{ + SimKey: simKey, + Code: consts.ApiStatusError, + Message: err.Error(), + }) + continue + } + body := resp.ReadAllString() + var ydResp ydResp + err = json.Unmarshal([]byte(body), &ydResp) + if err != nil { + g.Log().Errorf(ctx, "QuerySimImei unmarshal error %+v,url %s, params %+v, body %s, ", err, url, params, body) + items = append(items, &model.SimBaseRes[model.SimImeiItem]{ + SimKey: simKey, + Code: consts.ApiStatusError, + Message: err.Error(), + }) + continue + } + if ydResp.Status != "0" { + g.Log().Warningf(ctx, "QuerySimImei error %s,url %s, params %+v, body %s, ", ydResp.Message, url, params, body) + items = append(items, &model.SimBaseRes[model.SimImeiItem]{ + SimKey: simKey, + Code: gconv.Int(ydResp.Status), + Message: ydResp.Message, + }) + continue + } + if len(ydResp.Result) == 0 { + items = append(items, &model.SimBaseRes[model.SimImeiItem]{ + SimKey: simKey, + Code: gconv.Int(ydResp.Status), + Message: ydResp.Message, + }) + continue + } + if len(ydResp.Result) == 1 { + result := ydResp.Result[0] + items = append(items, &model.SimBaseRes[model.SimImeiItem]{ + SimKey: simKey, + Code: gconv.Int(ydResp.Status), + Message: ydResp.Message, + ResData: model.SimImeiItem{ + Imei: firstNonEmpty(result.TerminalDeviceImei, result.Imei), + }, + }) + } else { + items = append(items, &model.SimBaseRes[model.SimImeiItem]{ + SimKey: simKey, + Code: gconv.Int(ydResp.Status), + Message: ydResp.Message, + }) + } + } + return items, nil +} + +// 单卡本月流量累计使用量查询 +// https://api.iot.10086.cn/v5/ec/query/sim-data-usage-inpool?transid=371AIOT20210605101712810732026032616060010000000&token=YOUR_TOKEN&msisdn=1441589159774 +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"}]}]} + type ydResp struct { + Status string `json:"status"` + Message string `json:"message"` + Result []struct { + ApnList []struct { + ApnName string `json:"apnName"` + ApnUseAmount string `json:"apnUseAmount"` + } `json:"apnList"` + } `json:"result"` + } + items := make(model.SimDataUsageInPoolOutput, 0) + for _, simKey := range input.SimKeys { + params := g.Map{ + "transid": s.getTransId(ctx), + "iccid": simKey.ICCID, + } + + resp, err := s.requestYd(ctx, "QuerySimDataUsageInPool", url, params) + if err != nil { + g.Log().Errorf(ctx, "QuerySimDataUsageInPool get error %+v,url %s, params %+v, ", err, url, params) + items = append(items, &model.SimBaseRes[model.SimDataUsageInPoolItem]{ + SimKey: simKey, + Code: consts.ApiStatusError, + Message: err.Error(), + }) + continue + } + body := resp.ReadAllString() + var ydResp ydResp + err = json.Unmarshal([]byte(body), &ydResp) + if err != nil { + g.Log().Errorf(ctx, "QuerySimDataUsageInPool unmarshal error %+v,url %s, params %+v, body %s, ", err, url, params, body) + items = append(items, &model.SimBaseRes[model.SimDataUsageInPoolItem]{ + SimKey: simKey, + Code: consts.ApiStatusError, + Message: err.Error(), + }) + continue + } + if ydResp.Status != "0" { + g.Log().Warningf(ctx, "QuerySimDataUsageInPool error %s,url %s, params %+v, body %s, ", ydResp.Message, url, params, body) + items = append(items, &model.SimBaseRes[model.SimDataUsageInPoolItem]{ + SimKey: simKey, + Code: gconv.Int(ydResp.Status), + Message: ydResp.Message, + }) + continue + } + if len(ydResp.Result) == 0 { + items = append(items, &model.SimBaseRes[model.SimDataUsageInPoolItem]{ + SimKey: simKey, + Code: gconv.Int(ydResp.Status), + Message: ydResp.Message, + ResData: model.SimDataUsageInPoolItem{ + DataUsage: 0.0, + }, + }) + continue + } + if len(ydResp.Result) == 1 { + result := ydResp.Result[0] + dataUsage := 0.0 + if len(result.ApnList) != 0 { + for _, apn := range result.ApnList { + dataUsage += gconv.Float64(apn.ApnUseAmount) + } + } + items = append(items, &model.SimBaseRes[model.SimDataUsageInPoolItem]{ + SimKey: simKey, + Code: gconv.Int(ydResp.Status), + Message: ydResp.Message, + ResData: model.SimDataUsageInPoolItem{ + DataUsage: dataUsage / 1024, + }, + }) + } else { + items = append(items, &model.SimBaseRes[model.SimDataUsageInPoolItem]{ + SimKey: simKey, + Code: gconv.Int(ydResp.Status), + Message: ydResp.Message, + ResData: model.SimDataUsageInPoolItem{ + DataUsage: 0.0, + }, + }) + } + } + return items, nil +} + +// 单卡本月流量累计使用量查询 +// https://api.iot.10086.cn/v5/ec/query/sim-data-usage?transid=371AIOT20210605101712810732026032616060010000000&token=YOUR_TOKEN&msisdn=1441589159774 +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":[]}]}]} + items := make(model.SimDataUsageOutput, 0) + for _, simKey := range input.SimKeys { + params := g.Map{ + "transid": s.getTransId(ctx), + "iccid": simKey.ICCID, + } + resp, fetchErr := s.requestYd(ctx, "QuerySimDataUsage", url, params) + if fetchErr != nil { + g.Log().Errorf(ctx, "QuerySimDataUsage request failed, chanKey:%s, err:%+v", simKey.ChanKey, fetchErr) + items = append(items, &model.SimBaseRes[model.SimDataUsageItem]{ + SimKey: simKey, + Code: consts.ApiStatusError, + Message: fetchErr.Error(), + }) + continue + } + body := resp.ReadAllString() + ydResp, decodeErr := decodeYdDataUsageResponse(body) + if fetchErr = decodeErr; fetchErr != nil { + g.Log().Errorf(ctx, "QuerySimDataUsage unmarshal error, chanKey:%s, err:%+v", simKey.ChanKey, fetchErr) + items = append(items, &model.SimBaseRes[model.SimDataUsageItem]{ + SimKey: simKey, + Code: consts.ApiStatusError, + Message: fetchErr.Error(), + }) + continue + } + if ydResp.Status != "0" { + g.Log().Warningf(ctx, "QuerySimDataUsage upstream error, chanKey:%s, status:%s, message:%s", simKey.ChanKey, ydResp.Status, ydResp.Message) + items = append(items, &model.SimBaseRes[model.SimDataUsageItem]{ + SimKey: simKey, + Code: gconv.Int(ydResp.Status), + Message: ydResp.Message, + ErrCode: ydResp.Status, + }) + continue + } + if len(ydResp.Result) == 0 { + items = append(items, &model.SimBaseRes[model.SimDataUsageItem]{ + SimKey: simKey, + Code: gconv.Int(ydResp.Status), + Message: ydResp.Message, + ResData: model.SimDataUsageItem{ + DataUsage: 0.0, + }, + }) + continue + } else { + items = append(items, &model.SimBaseRes[model.SimDataUsageItem]{ + SimKey: simKey, + Code: gconv.Int(ydResp.Status), + Message: ydResp.Message, + ResData: model.SimDataUsageItem{ + DataUsage: gconv.Float64(ydResp.Result[0].DataAmount) / 1024, + }, + }) + } + } + return items, nil +} + +// 物联卡单月 GPRS 流量使用量批量查询,单批最多 100 张卡 +func (s *sSimYD) QuerySimDataUsageMonthly(ctx context.Context, input *model.SimDataUsageMonthlyInput) (model.SimDataUsageMonthlyOutput, error) { + url := s.getUrl(ctx, "ec/query/sim-data-usage-monthly/batch") + type YdResp struct { + Status string `json:"status"` + Message string `json:"message"` + Result []struct { + DeadLine string `json:"deadLine"` + DataAmountList []struct { + DataAmount string `json:"dataAmount"` + Iccid string `json:"iccid"` + Imsi string `json:"imsi"` + Msisdn string `json:"msisdn"` + } `json:"dataAmountList"` + } `json:"result"` + } + items := make(model.SimDataUsageMonthlyOutput, 0, len(input.SimKeys)) + const batchSize = 100 + for start := 0; start < len(input.SimKeys); start += batchSize { + end := start + batchSize + if end > len(input.SimKeys) { + end = len(input.SimKeys) + } + batch := input.SimKeys[start:end] + iccids := make([]string, 0, len(batch)) + simKeyMap := make(map[string]*model.SimKey, len(batch)*3) + for _, simKey := range batch { + if simKey == nil { + iccids = append(iccids, "") + continue + } + iccids = append(iccids, strings.TrimSpace(simKey.ICCID)) + if value := strings.TrimSpace(simKey.ICCID); value != "" { + simKeyMap["iccid:"+strings.ToUpper(value)] = simKey + } + if value := strings.TrimSpace(simKey.IMSI); value != "" { + simKeyMap["imsi:"+strings.ToUpper(value)] = simKey + } + if value := strings.TrimSpace(simKey.MSISDN); value != "" { + simKeyMap["msisdn:"+value] = simKey + } + } + params := g.Map{ + "transid": s.getTransId(ctx), + "iccids": strings.Join(iccids, "_"), + "queryDate": input.ReqData.QueryDate, + } + resp, err := s.requestYd(ctx, "QuerySimDataUsageMonthly", url, params) + if err != nil { + g.Log().Errorf(ctx, "QuerySimDataUsageMonthly get error %+v,url %s, params %+v", err, url, params) + for _, simKey := range batch { + items = append(items, &model.SimBaseRes[model.SimDataUsageMonthlyOutputItem]{SimKey: simKey, Code: consts.ApiStatusError, Message: err.Error()}) + } + continue + } + body := resp.ReadAllString() + var ydResp YdResp + if err = json.Unmarshal([]byte(body), &ydResp); err != nil { + g.Log().Errorf(ctx, "QuerySimDataUsageMonthly unmarshal error %+v,url %s, params %+v, body %s", err, url, params, body) + for _, simKey := range batch { + items = append(items, &model.SimBaseRes[model.SimDataUsageMonthlyOutputItem]{SimKey: simKey, Code: consts.ApiStatusError, Message: err.Error()}) + } + continue + } + if ydResp.Status != "0" { + g.Log().Warningf(ctx, "QuerySimDataUsageMonthly error %s,url %s, params %+v, body %s", ydResp.Message, url, params, body) + for _, simKey := range batch { + items = append(items, &model.SimBaseRes[model.SimDataUsageMonthlyOutputItem]{SimKey: simKey, Code: gconv.Int(ydResp.Status), Message: ydResp.Message}) + } + continue + } + + resultMap := make(map[*model.SimKey]struct { + DeadLine string + DataAmount string + }, len(batch)) + for _, result := range ydResp.Result { + for _, dataAmount := range result.DataAmountList { + var simKey *model.SimKey + if value := strings.TrimSpace(dataAmount.Iccid); value != "" { + simKey = simKeyMap["iccid:"+strings.ToUpper(value)] + } + if simKey == nil { + if value := strings.TrimSpace(dataAmount.Imsi); value != "" { + simKey = simKeyMap["imsi:"+strings.ToUpper(value)] + } + } + if simKey == nil { + if value := strings.TrimSpace(dataAmount.Msisdn); value != "" { + simKey = simKeyMap["msisdn:"+value] + } + } + if simKey == nil && len(batch) == 1 { + simKey = batch[0] + } + if simKey != nil { + resultMap[simKey] = struct { + DeadLine string + DataAmount string + }{DeadLine: result.DeadLine, DataAmount: dataAmount.DataAmount} + } + } + } + for _, simKey := range batch { + result, ok := resultMap[simKey] + if !ok { + items = append(items, &model.SimBaseRes[model.SimDataUsageMonthlyOutputItem]{SimKey: simKey, Code: gconv.Int(ydResp.Status), Message: "查询信息为空"}) + continue + } + items = append(items, &model.SimBaseRes[model.SimDataUsageMonthlyOutputItem]{ + SimKey: simKey, + Code: gconv.Int(ydResp.Status), + ResData: model.SimDataUsageMonthlyOutputItem{ + DeadLine: result.DeadLine, + DataAmount: gconv.Float64(strings.TrimSpace(result.DataAmount)) / 1024, + }, + }) + } + } + return items, nil +} + +// 物联卡单日 GPRS 流量使用量批量查询,单批最多 100 张卡 +// 上游返回 dataAmount 单位为 KB,对外统一转换为 MB +func (s *sSimYD) QuerySimDataUsageDaily(ctx context.Context, input *model.SimDataUsageDailyInput) (model.SimDataUsageDailyOutput, error) { + url := s.getUrl(ctx, "ec/query/sim-data-usage-daily/batch") + type YdResp struct { + Status string `json:"status"` + Message string `json:"message"` + Result []struct { + QueryDate string `json:"queryDate"` + DeadLine string `json:"deadLine"` + DataAmount string `json:"dataAmount"` + DataAmountList []struct { + QueryDate string `json:"queryDate"` + DeadLine string `json:"deadLine"` + Msisdn string `json:"msisdn"` + Iccid string `json:"iccid"` + Imsi string `json:"imsi"` + DataAmount string `json:"dataAmount"` + } `json:"dataAmountList"` + } `json:"result"` + } + items := make(model.SimDataUsageDailyOutput, 0, len(input.SimKeys)) + const batchSize = 100 + for start := 0; start < len(input.SimKeys); start += batchSize { + end := start + batchSize + if end > len(input.SimKeys) { + end = len(input.SimKeys) + } + batch := input.SimKeys[start:end] + iccids := make([]string, 0, len(batch)) + simKeyMap := make(map[string]*model.SimKey, len(batch)*3) + for _, simKey := range batch { + if simKey == nil { + iccids = append(iccids, "") + continue + } + iccids = append(iccids, strings.TrimSpace(simKey.ICCID)) + if value := strings.TrimSpace(simKey.ICCID); value != "" { + simKeyMap["iccid:"+strings.ToUpper(value)] = simKey + } + if value := strings.TrimSpace(simKey.IMSI); value != "" { + simKeyMap["imsi:"+strings.ToUpper(value)] = simKey + } + if value := strings.TrimSpace(simKey.MSISDN); value != "" { + simKeyMap["msisdn:"+value] = simKey + } + } + params := g.Map{ + "transid": s.getTransId(ctx), + "iccids": strings.Join(iccids, "_"), + "queryDate": input.ReqData.QueryDate, + } + resp, err := s.requestYd(ctx, "QuerySimDataUsageDaily", url, params) + if err != nil { + g.Log().Errorf(ctx, "QuerySimDataUsageDaily get error %+v,url %s, params %+v", err, url, params) + for _, simKey := range batch { + items = append(items, &model.SimBaseRes[model.SimDataUsageDailyOutputItem]{SimKey: simKey, Code: consts.ApiStatusError, Message: err.Error()}) + } + continue + } + body := resp.ReadAllString() + var ydResp YdResp + if err = json.Unmarshal([]byte(body), &ydResp); err != nil { + g.Log().Errorf(ctx, "QuerySimDataUsageDaily unmarshal error %+v,url %s, params %+v, body %s", err, url, params, body) + for _, simKey := range batch { + items = append(items, &model.SimBaseRes[model.SimDataUsageDailyOutputItem]{SimKey: simKey, Code: consts.ApiStatusError, Message: err.Error()}) + } + continue + } + if ydResp.Status != "0" { + g.Log().Warningf(ctx, "QuerySimDataUsageDaily error %s,url %s, params %+v, body %s", ydResp.Message, url, params, body) + for _, simKey := range batch { + items = append(items, &model.SimBaseRes[model.SimDataUsageDailyOutputItem]{SimKey: simKey, Code: gconv.Int(ydResp.Status), Message: ydResp.Message, ResData: model.SimDataUsageDailyOutputItem{QueryDate: input.ReqData.QueryDate}}) + } + continue + } + + resultMap := make(map[*model.SimKey]struct { + QueryDate string + DeadLine string + DataAmount string + }, len(batch)) + for _, result := range ydResp.Result { + if len(result.DataAmountList) == 0 { + if len(batch) == 1 { + resultMap[batch[0]] = struct { + QueryDate string + DeadLine string + DataAmount string + }{QueryDate: result.QueryDate, DeadLine: result.DeadLine, DataAmount: result.DataAmount} + } + continue + } + for _, dataAmount := range result.DataAmountList { + var simKey *model.SimKey + if value := strings.TrimSpace(dataAmount.Iccid); value != "" { + simKey = simKeyMap["iccid:"+strings.ToUpper(value)] + } + if simKey == nil { + if value := strings.TrimSpace(dataAmount.Imsi); value != "" { + simKey = simKeyMap["imsi:"+strings.ToUpper(value)] + } + } + if simKey == nil { + if value := strings.TrimSpace(dataAmount.Msisdn); value != "" { + simKey = simKeyMap["msisdn:"+value] + } + } + if simKey == nil && len(batch) == 1 { + simKey = batch[0] + } + if simKey != nil { + queryDate := dataAmount.QueryDate + if queryDate == "" { + queryDate = dataAmount.DeadLine + } + if queryDate == "" { + queryDate = result.QueryDate + } + if queryDate == "" { + queryDate = result.DeadLine + } + resultMap[simKey] = struct { + QueryDate string + DeadLine string + DataAmount string + }{QueryDate: queryDate, DeadLine: dataAmount.DeadLine, DataAmount: dataAmount.DataAmount} + } + } + } + for _, simKey := range batch { + result, ok := resultMap[simKey] + if !ok { + items = append(items, &model.SimBaseRes[model.SimDataUsageDailyOutputItem]{SimKey: simKey, Code: gconv.Int(ydResp.Status), Message: "查询信息为空", ResData: model.SimDataUsageDailyOutputItem{QueryDate: input.ReqData.QueryDate}}) + continue + } + queryDate := result.QueryDate + if queryDate == "" { + queryDate = result.DeadLine + } + if queryDate == "" { + queryDate = input.ReqData.QueryDate + } + items = append(items, &model.SimBaseRes[model.SimDataUsageDailyOutputItem]{ + SimKey: simKey, + Code: gconv.Int(ydResp.Status), + ResData: model.SimDataUsageDailyOutputItem{ + QueryDate: queryDate, + DataAmount: gconv.Float64(strings.TrimSpace(result.DataAmount)) / 1024, + }, + }) + } + } + return items, nil +} + +// 单卡已开通APN信息查询 +// https://api.iot.10086.cn/v5/ec/query/apn-info?transid=C5010531Af52022004082026060114500001000000&token=YOUR_TOKEN&iccid=898604F4152391115473 +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"}]}]} + type YdResp struct { + Status string `json:"status"` + Message string `json:"message"` + Result []struct { + ApnList []struct { + ApnName string `json:"apnName"` + Status string `json:"status"` + } `json:"apnList"` + } `json:"result"` + } + items := make(model.SimApnInfoOutput, 0) + for _, simKey := range input.SimKeys { + params := g.Map{ + "transid": s.getTransId(ctx), + "iccid": simKey.ICCID, + } + + resp, err := s.requestYd(ctx, "QuerySimApnInfo", url, params) + if err != nil { + g.Log().Errorf(ctx, "QuerySimApnInfo get error %+v,url %s, params %+v, ", err, url, params) + items = append(items, &model.SimBaseRes[model.SimApnInfoItemList]{ + SimKey: simKey, + Code: consts.ApiStatusError, + Message: err.Error(), + ResData: model.SimApnInfoItemList{ + ApnList: make([]model.SimApnInfoItem, 0), + }, + }) + continue + } + body := resp.ReadAllString() + var ydResp YdResp + err = json.Unmarshal([]byte(body), &ydResp) + if err != nil { + g.Log().Errorf(ctx, "QuerySimApnInfo unmarshal error %+v,url %s, params %+v, body %s, ", err, url, params, body) + items = append(items, &model.SimBaseRes[model.SimApnInfoItemList]{ + SimKey: simKey, + Code: consts.ApiStatusError, + Message: err.Error(), + ResData: model.SimApnInfoItemList{ + ApnList: make([]model.SimApnInfoItem, 0), + }, + }) + continue + } + if ydResp.Status != "0" { + g.Log().Warningf(ctx, "QuerySimApnInfo error %s,url %s, params %+v, body %s, ", ydResp.Message, url, params, body) + items = append(items, &model.SimBaseRes[model.SimApnInfoItemList]{ + SimKey: simKey, + Code: gconv.Int(ydResp.Status), + Message: ydResp.Message, + ResData: model.SimApnInfoItemList{ + ApnList: make([]model.SimApnInfoItem, 0), + }, + }) + continue + } + if len(ydResp.Result) == 0 { + items = append(items, &model.SimBaseRes[model.SimApnInfoItemList]{ + SimKey: simKey, + Code: gconv.Int(ydResp.Status), + Message: "查询信息为空", + ResData: model.SimApnInfoItemList{ + ApnList: make([]model.SimApnInfoItem, 0), + }, + }) + continue + } + if len(ydResp.Result) == 1 { + result := ydResp.Result[0] + if len(result.ApnList) != 0 { + for _, apn := range result.ApnList { + items = append(items, &model.SimBaseRes[model.SimApnInfoItemList]{ + SimKey: simKey, + Code: gconv.Int(ydResp.Status), + Message: "", + ResData: model.SimApnInfoItemList{ + ApnList: []model.SimApnInfoItem{ + { + ApnName: apn.ApnName, + Status: convertYdApnStatus(ctx, apn.Status), + }, + }, + }, + }) + } + } else { + g.Log().Errorf(ctx, "SimYD.QuerySimApnInfo, Error in returning information,ydResp: %+v, input: %+v, ", ydResp, input) + items = append(items, &model.SimBaseRes[model.SimApnInfoItemList]{ + SimKey: simKey, + Code: gconv.Int(ydResp.Status), + Message: "查询信息为空", + ResData: model.SimApnInfoItemList{ + ApnList: make([]model.SimApnInfoItem, 0), + }, + }) + continue + } + } else { + g.Log().Errorf(ctx, "SimYD.QuerySimApnInfo, Error in returning information,ydResp: %+v, input: %+v, ", ydResp, input) + items = append(items, &model.SimBaseRes[model.SimApnInfoItemList]{ + SimKey: simKey, + Code: gconv.Int(ydResp.Status), + Message: "查询信息为空", + ResData: model.SimApnInfoItemList{ + ApnList: make([]model.SimApnInfoItem, 0), + }, + }) + continue + } + } + return items, nil +} + +// 单卡状态变更 +// 平台 operType: 0=申请停机(已激活转已停机), 1=申请复机(已停机转已激活) +// https://api.iot.10086.cn/v5/ec/change/sim-status?transid=371AIOT20210605101712810732026031315120010000000&token=YOUR_TOKEN&msisdn=1441589159774&operType=0 +func (s *sSimYD) ChangeSimStatus(ctx context.Context, input *model.SimCardStatusChangeInput) (model.SimCardStatusChangeOutput, error) { + operType, ok := normalizeCardStatusChangeOperType(ctx, input.ReqData.OperType) + if !ok { + return newInvalidCardStatusChangeOutput(input.SimKeys), nil + } + + url := s.getUrl(ctx, "ec/change/sim-status") + // {"status":"0","message":"正确","result":[{"iccid":"898604F4152391115474","orderNum":"995000011515720993"}]} + type YdResp struct { + Status string `json:"status"` + Message string `json:"message"` + Result []struct { + Iccid string `json:"iccid"` + OrderNum string `json:"orderNum"` + } `json:"result"` + } + items := make(model.SimCardStatusChangeOutput, 0, len(input.SimKeys)) + for _, simKey := range input.SimKeys { + params := g.Map{ + "transid": s.getTransId(ctx), + "iccid": simKey.ICCID, + "operType": operType, + } + + resp, err := s.requestYd(ctx, "ChangeSimStatus", url, params) + if err != nil { + g.Log().Errorf(ctx, "ChangeSimStatus get error %+v,url %s, params %+v, ", err, url, params) + items = append(items, &model.SimBaseRes[model.SimCardStatusChangeItem]{ + SimKey: simKey, + Code: consts.ApiStatusError, + Message: err.Error(), + ResData: model.SimCardStatusChangeItem{ + OrderNum: "", + }, + }) + continue + } + body := resp.ReadAllString() + var ydResp YdResp + err = json.Unmarshal([]byte(body), &ydResp) + if err != nil { + g.Log().Errorf(ctx, "ChangeSimStatus unmarshal error %+v,url %s, params %+v, body %s, ", err, url, params, body) + items = append(items, &model.SimBaseRes[model.SimCardStatusChangeItem]{ + SimKey: simKey, + Code: consts.ApiStatusError, + Message: err.Error(), + ResData: model.SimCardStatusChangeItem{ + OrderNum: "", + }, + }) + continue + } + if ydResp.Status != "0" { + g.Log().Warningf(ctx, "ChangeSimStatus error %s,url %s, params %+v, body %s, ", ydResp.Message, url, params, body) + items = append(items, &model.SimBaseRes[model.SimCardStatusChangeItem]{ + SimKey: simKey, + Code: gconv.Int(ydResp.Status), + Message: ydResp.Message, + ResData: model.SimCardStatusChangeItem{ + OrderNum: "", + }, + }) + continue + } + if len(ydResp.Result) == 0 { + items = append(items, &model.SimBaseRes[model.SimCardStatusChangeItem]{ + SimKey: simKey, + Code: gconv.Int(ydResp.Status), + Message: "查询信息为空", + ResData: model.SimCardStatusChangeItem{ + OrderNum: "", + }, + }) + continue + } + if len(ydResp.Result) == 1 { + result := ydResp.Result[0] + if len(result.OrderNum) != 0 { + items = append(items, &model.SimBaseRes[model.SimCardStatusChangeItem]{ + SimKey: simKey, + Code: gconv.Int(ydResp.Status), + Message: "变更成功", + ResData: model.SimCardStatusChangeItem{ + OrderNum: result.OrderNum, + }, + }) + } + } else { + items = append(items, &model.SimBaseRes[model.SimCardStatusChangeItem]{ + SimKey: simKey, + Code: gconv.Int(ydResp.Status), + Message: "查询信息为空", + ResData: model.SimCardStatusChangeItem{ + OrderNum: "", + }, + }) + continue + } + } + return items, nil +} + +// 开通/停用移动数据(APN)功能(开通数据 dataStart / 停用数据 dataStop / 达量断网 dataCut)。 +// 2026 接口改版:已从旧的 ec/operate/sim-apn-function 迁移到 +// 7.2.1 单卡 sim-communication-service 与 7.2.2 批量 sim-communication-function/batch, +// 具体分流与编码转换见 sim_yd_apn_communication.go。 +func (s *sSimYD) ChangeSimApnFunction(ctx context.Context, input *model.SimCardOperateChangeApnInput) (model.SimCardOperateChangeApnOutput, error) { + return changeSimApnFunctionYD(ctx, s, input) +} + +// 单卡机卡绑定状态查询 +// https://api.iot.10086.cn/v5/ec/query/card-bind-status +// 文档位置:中移物联网OneLink能力调用参考文档V25.11.1(CT)- 6.1.1 CMIOT_API23A04 +// 请求参数:msisdn、testType +// 上游返回 result 中的 bindStatus/cardBindStatus 作为机卡绑定状态 +func (s *sSimYD) QuerySimCardBindStatus(ctx context.Context, input *model.SimCardBindStatusInput) (model.SimCardBindStatusOutput, error) { + url := s.getUrl(ctx, "ec/query/card-bind-status") + + type ydResp struct { + Status string `json:"status"` + Message string `json:"message"` + Result []map[string]any `json:"result"` + } + + items := make(model.SimCardBindStatusOutput, 0, len(input.SimKeys)) + for _, simKey := range input.SimKeys { + msisdn := strings.TrimSpace(simKey.MSISDN) + if msisdn == "" { + g.Log().Warningf(ctx, "QuerySimCardBindStatus missing msisdn,url %s, simKey %+v, reqData %+v", url, simKey, input.ReqData) + items = append(items, &model.SimBaseRes[model.SimCardBindStatusItem]{ + SimKey: simKey, + Code: consts.ApiStatusError, + Message: "missing msisdn", + }) + continue + } + + testType := 1 + if input.ReqData.TestType != nil { + testType = *input.ReqData.TestType + } + + params := g.Map{ + "transid": s.getTransId(ctx), + "msisdn": msisdn, + "testType": testType, + } + + resp, err := s.requestYd(ctx, "QuerySimCardBindStatus", url, params) + if err != nil { + g.Log().Errorf(ctx, "QuerySimCardBindStatus get error %+v,url %s, params %+v", err, url, params) + items = append(items, &model.SimBaseRes[model.SimCardBindStatusItem]{ + SimKey: simKey, + Code: consts.ApiStatusError, + Message: err.Error(), + }) + continue + } + body := resp.ReadAllString() + + var respData ydResp + if err = json.Unmarshal([]byte(body), &respData); err != nil { + g.Log().Errorf(ctx, "QuerySimCardBindStatus unmarshal error %+v,url %s, params %+v, body %s", err, url, params, body) + items = append(items, &model.SimBaseRes[model.SimCardBindStatusItem]{ + SimKey: simKey, + Code: consts.ApiStatusError, + Message: err.Error(), + }) + continue + } + if respData.Status != "0" { + g.Log().Warningf(ctx, "QuerySimCardBindStatus error %s,url %s, params %+v, body %s", respData.Message, url, params, body) + items = append(items, &model.SimBaseRes[model.SimCardBindStatusItem]{ + SimKey: simKey, + Code: gconv.Int(respData.Status), + Message: respData.Message, + }) + continue + } + + bindStatus := "" + var errorCode string + var errorDes string + if len(respData.Result) > 0 { + bindStatus = pickYdCardBindStatus(respData.Result[0]) + errorCode = pickYdCardBindStatusErrorCode(respData.Result[0]) + errorDes = pickYdCardBindStatusErrorDes(respData.Result[0]) + } + if bindStatus == "" { + g.Log().Warningf(ctx, "QuerySimCardBindStatus empty bind status,url %s, params %+v, body %s", url, params, body) + items = append(items, &model.SimBaseRes[model.SimCardBindStatusItem]{ + SimKey: simKey, + Code: consts.ApiStatusError, + Message: "sim card bind status is empty", + }) + continue + } + + items = append(items, &model.SimBaseRes[model.SimCardBindStatusItem]{ + SimKey: simKey, + Code: gconv.Int(respData.Status), + Message: respData.Message, + ResData: model.SimCardBindStatusItem{ + BindStatus: bindStatus, + ErrorCode: errorCode, + ErrorDes: errorDes, + }, + }) + } + return items, nil +} + +// 单卡设备状态查询 +// https://api.iot.10086.cn/v5/ec/query/sim-operating-status?transid=xxx&token=xxx&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") + + type ydResp struct { + Status string `json:"status"` + Message string `json:"message"` + Result []map[string]any `json:"result"` + } + + items := make(model.SimDeviceStatusOutput, 0, len(input.SimKeys)) + for _, simKey := range input.SimKeys { + params := g.Map{ + "transid": s.getTransId(ctx), + } + paramName, paramValue := pickSimQueryParam(simKey) + if paramName == "" { + items = append(items, &model.SimBaseRes[model.SimDeviceStatusItem]{ + SimKey: simKey, + Code: consts.ApiStatusError, + Message: "missing imsi/iccid/msisdn", + }) + continue + } + params[paramName] = paramValue + + resp, err := s.requestYd(ctx, "QuerySimDeviceStatus", url, params) + if err != nil { + g.Log().Errorf(ctx, "QuerySimDeviceStatus get error %+v,url %s, params %+v", err, url, params) + items = append(items, &model.SimBaseRes[model.SimDeviceStatusItem]{ + SimKey: simKey, + Code: consts.ApiStatusError, + Message: err.Error(), + }) + continue + } + body := resp.ReadAllString() + + var respData ydResp + if err = json.Unmarshal([]byte(body), &respData); err != nil { + g.Log().Errorf(ctx, "QuerySimDeviceStatus unmarshal error %+v,url %s, params %+v, body %s", err, url, params, body) + items = append(items, &model.SimBaseRes[model.SimDeviceStatusItem]{ + SimKey: simKey, + Code: consts.ApiStatusError, + Message: err.Error(), + }) + continue + } + if respData.Status != "0" { + items = append(items, &model.SimBaseRes[model.SimDeviceStatusItem]{ + SimKey: simKey, + Code: gconv.Int(respData.Status), + Message: respData.Message, + }) + continue + } + + // sim-operating-status only reports the carrier terminal power state. + // It does not provide the unified device health state, so keep the two + // dimensions separate instead of treating shutdown as device failure. + deviceStatus := consts.CardSessionStatusUnknown + operatingStatus := consts.CardSessionStatusUnknown + if len(respData.Result) > 0 { + result := respData.Result[0] + rawStatus := firstNonEmpty( + gconv.String(result["operatingStatus"]), + gconv.String(result["onOffStatus"]), + gconv.String(result["status"]), + ) + operatingStatus = mapYdOperatingStatus(rawStatus) + } + items = append(items, &model.SimBaseRes[model.SimDeviceStatusItem]{ + SimKey: simKey, + Code: gconv.Int(respData.Status), + Message: respData.Message, + ResData: model.SimDeviceStatusItem{ + DeviceStatus: deviceStatus, + OperatingStatus: operatingStatus, + }, + }) + } + return items, nil +} + +// 单卡地域限制状态查询 +// https://api.iot.10086.cn/v5/ec/query/region-limit-status +// 上游返回 result 中的 regionLimitStatus/status 作为地域限制状态 +func (s *sSimYD) QuerySimRegionLimitStatus(ctx context.Context, input *model.SimRegionLimitStatusInput) (model.SimRegionLimitStatusOutput, error) { + url := s.getUrl(ctx, "ec/query/region-limit-status") + + type ydResp struct { + Status string `json:"status"` + Message string `json:"message"` + Result []map[string]any `json:"result"` + } + + items := make(model.SimRegionLimitStatusOutput, 0, len(input.SimKeys)) + for _, simKey := range input.SimKeys { + params := g.Map{ + "transid": s.getTransId(ctx), + } + switch { + case strings.TrimSpace(simKey.ICCID) != "": + params["iccid"] = simKey.ICCID + case strings.TrimSpace(simKey.IMSI) != "": + params["imsi"] = simKey.IMSI + case strings.TrimSpace(simKey.MSISDN) != "": + params["msisdn"] = simKey.MSISDN + default: + items = append(items, &model.SimBaseRes[model.SimRegionLimitStatusItem]{ + SimKey: simKey, + Code: consts.ApiStatusError, + Message: "missing iccid/imsi/msisdn", + }) + continue + } + + resp, err := s.requestYd(ctx, "QuerySimRegionLimitStatus", url, params) + if err != nil { + g.Log().Errorf(ctx, "QuerySimRegionLimitStatus get error %+v,url %s, params %+v", err, url, params) + items = append(items, &model.SimBaseRes[model.SimRegionLimitStatusItem]{ + SimKey: simKey, + Code: consts.ApiStatusError, + Message: err.Error(), + }) + continue + } + body := resp.ReadAllString() + + var respData ydResp + if err = json.Unmarshal([]byte(body), &respData); err != nil { + g.Log().Errorf(ctx, "QuerySimRegionLimitStatus unmarshal error %+v,url %s, params %+v, body %s", err, url, params, body) + items = append(items, &model.SimBaseRes[model.SimRegionLimitStatusItem]{ + SimKey: simKey, + Code: consts.ApiStatusError, + Message: err.Error(), + }) + continue + } + if respData.Status != "0" { + items = append(items, &model.SimBaseRes[model.SimRegionLimitStatusItem]{ + SimKey: simKey, + Code: gconv.Int(respData.Status), + Message: respData.Message, + }) + continue + } + + regionLimitStatus := "" + if len(respData.Result) > 0 { + result := respData.Result[0] + regionLimitStatus = gconv.String(result["regionLimitStatus"]) + if regionLimitStatus == "" { + regionLimitStatus = gconv.String(result["status"]) + } + } + items = append(items, &model.SimBaseRes[model.SimRegionLimitStatusItem]{ + SimKey: simKey, + Code: gconv.Int(respData.Status), + Message: respData.Message, + ResData: model.SimRegionLimitStatusItem{ + RegionLimitStatus: regionLimitStatus, + }, + }) + } + return items, nil +} diff --git a/internal/logic/upstreamimpl/sim_yd_apn_communication.go b/internal/logic/upstreamimpl/sim_yd_apn_communication.go new file mode 100644 index 0000000..564311b --- /dev/null +++ b/internal/logic/upstreamimpl/sim_yd_apn_communication.go @@ -0,0 +1,381 @@ +package upstreamimpl + +import ( + "context" + "encoding/json" + "strings" + "time" + + "git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/consts" + "git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/model" + + "github.com/gogf/gf/v2/frame/g" + "github.com/gogf/gf/v2/util/gconv" +) + +// 移动 OneLink「通信功能开停」接口(2026 接口改版后,替代原 ec/operate/sim-apn-function)。 +// +// 7.2.1 单卡(CMIOT_API25M19) GET /v5/ec/manage/sim-communication-service +// functionType=01(APN上网功能),businessType:0=开 1=停,APN 名称字段为 apn +// 7.2.2 批量(CMIOT_API23M09) GET /v5/ec/operate/sim-communication-function/batch +// serviceType=11(数据通信服务),operType:0=暂停(停) 1=恢复(开),APN 名称字段为 apnName +// +// 分流策略(与产品约定): +// +// 卡数 <10 逐张走 7.2.1 +// 10~100 一次走 7.2.2 +// >100 按 100 拆块,不足 10 的尾块退回 7.2.1 +// +// 批量接口 7.2.2 只认 msisdn,缺号码的卡降级走 7.2.1(用 iccid)。 +// 两个接口同卡同类型均有限流(3 分钟一次),且 7.2.2 批量中任一张被限流则整批失败(status 13009)。 +const ( + ydCommunicationServicePath = "ec/manage/sim-communication-service" + ydCommunicationFunctionBatchPath = "ec/operate/sim-communication-function/batch" + ydCommunicationBatchResultPath = "ec/query/sim-batch-result" // 5.1.2 CMIOT_API23M10 批量办理结果查询 + ydCommunicationFunctionTypeApn = "01" // 7.2.1 functionType:APN 上网功能 + ydCommunicationServiceTypeData = "11" // 7.2.2 serviceType:数据通信服务 + ydCommunicationBatchMin = 10 // 7.2.2 每批最少卡数 + ydCommunicationBatchMax = 100 // 7.2.2 每批最多卡数 +) + +// 批量办理任务状态(5.1.2 sim-batch-result 返回的 jobStatus)。 +const ( + ydJobStatusDone = "2" // 处理完成(全部成功) + ydJobStatusDonePartial = "3" // 处理完成(含失败记录) + ydJobStatusFailed = "4" // 处理失败 +) + +// ydBatchResultQueryFailedMessage 是批量异步任务结果查询失败(轮询超时 / 查询请求失败)时, +// 逐卡写入执行日志明细的失败原因:该子批次下每张卡都记这一条,代表"结果未确认"而非"办理失败"。 +const ydBatchResultQueryFailedMessage = "查询上游通信结果失败,未取得最终办理结果,请稍后核对" + +// 批量结果轮询参数;声明为变量以便单测缩短周期。 +// +// 两个值均维持原值,原因(实测:上游批量任务约 60s 出终态): +// - 间隔只决定终态被捕获的延迟(0~interval)。整批 2 万张 = 200 子批 / 并发 5 = 40 波, +// 总等待由"每波等上游 job 完成"主导,缩间隔对总时长影响可忽略,却成倍增加查询接口压力。 +// - 上限若下调到 60s 会踩在上游耗时边界上,超时即把整个子批 100 张记为失败,而失败结果 +// 不回写本地 data_service_status,会造成平台状态与卡真实状态不一致。 +// +// 真正压缩总等待应把"提交"与"结果查询"分离(全部子批先拿 jobId,再统一并发轮询), +// 使总等待从 40 × 上限 降到 ≈ 1 × 上限,而不是靠缩短这里的超时。 +var ( + ydBatchResultPollInterval = 5 * time.Second + ydBatchResultPollTimeout = 180 * time.Second +) + +// ydApnRequester 抽出移动通道发起请求所需的最小能力,供真实通道(sSimYD)与 mock 通道(sSimYDMock) +// 共用同一套开停逻辑,避免两份实现走偏。 +type ydApnRequester interface { + getUrl(ctx context.Context, serviceUrl string) string + getTransId(ctx context.Context) string + requestYd(ctx context.Context, operation, url string, params g.Map) (*ydHTTPResponse, error) +} + +// changeSimApnFunctionYD 平台「开通数据(dataStart)/停用数据(dataStop)/达量断网(dataCut)」在移动侧的统一入口。 +func changeSimApnFunctionYD(ctx context.Context, c ydApnRequester, input *model.SimCardOperateChangeApnInput) (model.SimCardOperateChangeApnOutput, error) { + items := make(model.SimCardOperateChangeApnOutput, 0, len(input.SimKeys)) + + // 缺 msisdn 的卡不能进批量接口,先分流出来单独走单卡。 + withMsisdn := make([]*model.SimKey, 0, len(input.SimKeys)) + singleKeys := make([]*model.SimKey, 0) + for _, simKey := range input.SimKeys { + if simKey == nil { + continue + } + if strings.TrimSpace(simKey.MSISDN) == "" { + singleKeys = append(singleKeys, simKey) + continue + } + withMsisdn = append(withMsisdn, simKey) + } + + for start := 0; start < len(withMsisdn); start += ydCommunicationBatchMax { + end := start + ydCommunicationBatchMax + if end > len(withMsisdn) { + end = len(withMsisdn) + } + chunk := withMsisdn[start:end] + if len(chunk) < ydCommunicationBatchMin { + singleKeys = append(singleKeys, chunk...) + continue + } + items = append(items, changeApnBatchYD(ctx, c, chunk, input.ReqData)...) + } + + items = append(items, changeApnSingleYD(ctx, c, singleKeys, input.ReqData)...) + return items, nil +} + +// changeApnSingleYD 逐张调用 7.2.1 单卡接口办理。 +func changeApnSingleYD(ctx context.Context, c ydApnRequester, simKeys []*model.SimKey, req model.SimCardOperateChangeApnInputItem) model.SimCardOperateChangeApnOutput { + items := make(model.SimCardOperateChangeApnOutput, 0, len(simKeys)) + if len(simKeys) == 0 { + return items + } + + businessType, ok := convertYdCommunicationBusinessType(ctx, req.OperType) + if !ok { + for _, simKey := range simKeys { + items = append(items, ydApnErrorItem(simKey, "unsupported operType, expected 0(open) or 1(stop)")) + } + return items + } + + url := c.getUrl(ctx, ydCommunicationServicePath) + // {"status":"0","message":"正确","result":[{"iccid":"898602B2221430000005","orderNum":"xxxxxxx"}]} + type ydSingleResp struct { + Status string `json:"status"` + Message string `json:"message"` + Result []struct { + OrderNum string `json:"orderNum"` + } `json:"result"` + } + + for _, simKey := range simKeys { + params := g.Map{ + "transid": c.getTransId(ctx), + "functionType": ydCommunicationFunctionTypeApn, + "businessType": businessType, + "apn": req.ApnName, + } + // 卡号三选一,必须有且只有一项:优先 iccid,其次 msisdn,最后 imsi。 + switch { + case strings.TrimSpace(simKey.ICCID) != "": + params["iccid"] = strings.TrimSpace(simKey.ICCID) + case strings.TrimSpace(simKey.MSISDN) != "": + params["msisdn"] = strings.TrimSpace(simKey.MSISDN) + case strings.TrimSpace(simKey.IMSI) != "": + params["imsi"] = strings.TrimSpace(simKey.IMSI) + default: + g.Log().Warningf(ctx, "changeApnSingleYD missing iccid/msisdn/imsi, simKey %+v", simKey) + items = append(items, ydApnErrorItem(simKey, "missing iccid/msisdn/imsi")) + continue + } + + resp, err := c.requestYd(ctx, "ChangeSimApnFunction", url, params) + if err != nil { + g.Log().Errorf(ctx, "changeApnSingleYD request error %+v, url %s, params %+v", err, url, params) + items = append(items, ydApnErrorItem(simKey, err.Error())) + 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())) + 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, "")) + continue + } + orderNum := "" + if len(ydResp.Result) > 0 { + orderNum = ydResp.Result[0].OrderNum + } + items = append(items, ydApnResultItem(simKey, 0, ydResp.Message, orderNum)) + } + return items +} + +// changeApnBatchYD 用一批(10~100 张)调用 7.2.2 批量接口一次办理。 +// 该接口异步返回 jobId,status=0 即视为本批全部受理成功,逐卡回填同一个 jobId。 +func changeApnBatchYD(ctx context.Context, c ydApnRequester, simKeys []*model.SimKey, req model.SimCardOperateChangeApnInputItem) model.SimCardOperateChangeApnOutput { + items := make(model.SimCardOperateChangeApnOutput, 0, len(simKeys)) + + operType, ok := convertYdCommunicationOperType(ctx, req.OperType) + if !ok { + for _, simKey := range simKeys { + items = append(items, ydApnErrorItem(simKey, "unsupported operType, expected 0(open) or 1(stop)")) + } + return items + } + + msisdns := make([]string, 0, len(simKeys)) + for _, simKey := range simKeys { + msisdns = append(msisdns, strings.TrimSpace(simKey.MSISDN)) + } + + url := c.getUrl(ctx, ydCommunicationFunctionBatchPath) + params := g.Map{ + "transid": c.getTransId(ctx), + "msisdns": strings.Join(msisdns, "_"), + "serviceType": ydCommunicationServiceTypeData, + "operType": operType, + "apnName": req.ApnName, + } + // {"status":"0","message":"正确","result":[{"jobId":"xxxxxxx"}]} + type ydBatchResp struct { + Status string `json:"status"` + Message string `json:"message"` + Result []struct { + JobId string `json:"jobId"` + } `json:"result"` + } + + resp, err := c.requestYd(ctx, "ChangeSimApnFunction", url, params) + if err != nil { + g.Log().Errorf(ctx, "changeApnBatchYD request error %+v, url %s, params %+v", err, url, params) + for _, simKey := range simKeys { + items = append(items, ydApnErrorItem(simKey, err.Error())) + } + return items + } + body := resp.ReadAllString() + var ydResp ydBatchResp + if err = json.Unmarshal([]byte(body), &ydResp); err != nil { + g.Log().Errorf(ctx, "changeApnBatchYD unmarshal error %+v, url %s, params %+v, body %s", err, url, params, body) + for _, simKey := range simKeys { + items = append(items, ydApnErrorItem(simKey, err.Error())) + } + 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, "")) + } + return items + } + jobId := "" + if len(ydResp.Result) > 0 { + jobId = ydResp.Result[0].JobId + } + if jobId == "" { + g.Log().Warningf(ctx, "changeApnBatchYD accepted without jobId, url %s, params %+v, body %s", url, params, body) + for _, simKey := range simKeys { + items = append(items, ydApnResultItem(simKey, consts.ApiStatusError, "上游受理成功但未返回 jobId", "")) + } + return items + } + + // 7.2.2 是异步接口:受理(status=0)不等于办理成功。必须轮询结果接口拿到每张卡的最终结果, + // 否则执行日志会把"部分失败"的批次整批误记为成功。 + result := queryBatchResultYD(ctx, c, jobId) + switch { + case result.finished && result.failed: + for _, simKey := range simKeys { + items = append(items, ydApnResultItem(simKey, consts.ApiStatusError, "上游批量任务处理失败", jobId)) + } + case result.finished: + for _, simKey := range simKeys { + card, exist := result.cards[strings.TrimSpace(simKey.MSISDN)] + if !exist { + items = append(items, ydApnResultItem(simKey, consts.ApiStatusError, "上游批量结果未返回该卡", jobId)) + continue + } + items = append(items, ydApnResultItem(simKey, card.code, card.message, jobId)) + } + 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)) + } + } + return items +} + +// ydBatchCardResult 批量任务中单张卡的最终办理结果。 +type ydBatchCardResult struct { + code int + message string +} + +// ydBatchQueryResult 批量办理结果查询的结论。 +type ydBatchQueryResult struct { + finished bool // 是否已取得终态(jobStatus 2/3/4) + failed bool // jobStatus=4,整批处理失败 + cards map[string]ydBatchCardResult // resultId(msisdn)-> 单卡结果 +} + +// ydBatchResultItem 5.1.2 sim-batch-result 的逐卡结果项。 +type ydBatchResultItem struct { + Status string `json:"status"` + Message string `json:"message"` + ResultType string `json:"resultType"` + ResultId string `json:"resultId"` +} + +// ydBatchResultResp 5.1.2 sim-batch-result 的响应体。 +type ydBatchResultResp struct { + Status string `json:"status"` + Message string `json:"message"` + Result []struct { + JobStatus string `json:"jobStatus"` + ResultList []ydBatchResultItem `json:"resultList"` + } `json:"result"` +} + +// queryBatchResultYD 轮询 7.2.2 批量任务结果,直到取得终态或超时。 +// 超时 / 请求失败均返回 finished=false,由调用方按"未确认"处理。 +func queryBatchResultYD(ctx context.Context, c ydApnRequester, jobId string) ydBatchQueryResult { + url := c.getUrl(ctx, ydCommunicationBatchResultPath) + deadline := time.Now().Add(ydBatchResultPollTimeout) + for { + params := g.Map{ + "transid": c.getTransId(ctx), + "jobId": jobId, + } + 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{} + } + select { + case <-ctx.Done(): + return ydBatchQueryResult{} + case <-time.After(ydBatchResultPollInterval): + } + } +} + +// toYdBatchCardMap 把逐卡结果按 resultId(msisdn)建索引。 +func toYdBatchCardMap(list []ydBatchResultItem) map[string]ydBatchCardResult { + cards := make(map[string]ydBatchCardResult, len(list)) + for _, item := range list { + id := strings.TrimSpace(item.ResultId) + if id == "" { + continue + } + cards[id] = ydBatchCardResult{code: gconv.Int(item.Status), message: item.Message} + } + return cards +} + +func ydApnErrorItem(simKey *model.SimKey, message string) *model.SimBaseRes[model.SimCardOperateChangeApnItem] { + return &model.SimBaseRes[model.SimCardOperateChangeApnItem]{ + SimKey: simKey, + Code: consts.ApiStatusError, + Message: message, + } +} + +func ydApnResultItem(simKey *model.SimKey, code int, message, orderNum string) *model.SimBaseRes[model.SimCardOperateChangeApnItem] { + return &model.SimBaseRes[model.SimCardOperateChangeApnItem]{ + SimKey: simKey, + Code: code, + Message: message, + ResData: model.SimCardOperateChangeApnItem{OrderNum: orderNum}, + } +} diff --git a/internal/logic/upstreamimpl/sim_yd_apn_communication_test.go b/internal/logic/upstreamimpl/sim_yd_apn_communication_test.go new file mode 100644 index 0000000..73f0466 --- /dev/null +++ b/internal/logic/upstreamimpl/sim_yd_apn_communication_test.go @@ -0,0 +1,366 @@ +package upstreamimpl + +import ( + "context" + "fmt" + "net/http" + "net/http/httptest" + "net/url" + "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 TestYdCommunicationBusinessType(t *testing.T) { + for _, tc := range []struct { + in string + want string + valid bool + }{ + {"0", "0", true}, // 平台 开 -> 7.2.1 businessType 开(同向) + {"1", "1", true}, // 平台 停 -> 7.2.1 businessType 停 + {"", "", false}, + {"2", "", false}, + } { + got, ok := convertYdCommunicationBusinessType(context.Background(), tc.in) + if got != tc.want || ok != tc.valid { + t.Errorf("businessType(%q) = (%q,%v), want (%q,%v)", tc.in, got, ok, tc.want, tc.valid) + } + } +} + +func TestYdCommunicationOperType(t *testing.T) { + for _, tc := range []struct { + in string + want string + valid bool + }{ + {"0", "1", true}, // 平台 开 -> 7.2.2 恢复(翻转) + {"1", "0", true}, // 平台 停 -> 7.2.2 暂停 + {"", "", false}, + {"9", "", false}, + } { + got, ok := convertYdCommunicationOperType(context.Background(), tc.in) + if got != tc.want || ok != tc.valid { + t.Errorf("operType(%q) = (%q,%v), want (%q,%v)", tc.in, got, ok, tc.want, tc.valid) + } + } +} + +type ydApnCapture struct { + paths []string + query []url.Values + msisdns string +} + +// shortenYdBatchPoll 把批量结果轮询周期压缩到毫秒级,返回恢复函数。 +func shortenYdBatchPoll(timeout time.Duration) func() { + oldInterval, oldTimeout := ydBatchResultPollInterval, ydBatchResultPollTimeout + ydBatchResultPollInterval, ydBatchResultPollTimeout = time.Millisecond, timeout + return func() { ydBatchResultPollInterval, ydBatchResultPollTimeout = oldInterval, oldTimeout } +} + +func newYdApnChannelWithServer(server *httptest.Server) *sSimYD { + return &sSimYD{ + channelConfig: &model.SimChannelConfigItemCache{Channel: &entity.TSimChannels{ChanKey: "test", AppKey: "test"}}, + tokenProvider: diagnosisTestToken{}, + apiConfig: &oneLinkAPIConfig{BaseURL: server.URL + "/v5"}, + } +} + +func newYdApnTestChannel(capture *ydApnCapture) (*sSimYD, func()) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + capture.paths = append(capture.paths, r.URL.Path) + capture.query = append(capture.query, r.URL.Query()) + switch { + case strings.Contains(r.URL.Path, "sim-communication-function/batch"): + capture.msisdns = r.URL.Query().Get("msisdns") + _, _ = w.Write([]byte(`{"status":"0","message":"正确","result":[{"jobId":"JOB1"}]}`)) + case strings.Contains(r.URL.Path, "sim-batch-result"): + _, _ = w.Write([]byte(ydBatchResultAllSuccessBody(capture.msisdns))) + default: + _, _ = w.Write([]byte(`{"status":"0","message":"正确","result":[{"orderNum":"ORD1"}]}`)) + } + })) + return newYdApnChannelWithServer(server), server.Close +} + +// ydBatchResultAllSuccessBody 依据批量请求的 msisdns 拼一个"全部成功"的结果查询响应。 +func ydBatchResultAllSuccessBody(msisdns string) string { + items := make([]string, 0) + for _, id := range strings.Split(msisdns, "_") { + id = strings.TrimSpace(id) + if id == "" { + continue + } + items = append(items, fmt.Sprintf(`{"status":"0","message":"正确","resultType":"1","resultId":%q}`, id)) + } + return fmt.Sprintf(`{"status":"0","message":"正确","result":[{"jobStatus":"2","resultList":[%s]}]}`, strings.Join(items, ",")) +} + +func ydSimKeysWithMsisdn(n int) []*model.SimKey { + keys := make([]*model.SimKey, 0, n) + for i := 0; i < n; i++ { + keys = append(keys, &model.SimKey{ + ICCID: fmt.Sprintf("898604F41523911%05d", i), + MSISDN: fmt.Sprintf("14415436%05d", i), + IMSI: fmt.Sprintf("46008743601%05d", i), + }) + } + return keys +} + +func ydApnCounts(paths []string) (batch, single, query int) { + for _, p := range paths { + switch { + case strings.Contains(p, "sim-communication-function/batch"): + batch++ + case strings.Contains(p, "sim-batch-result"): + query++ + default: + single++ + } + } + return +} + +// 少于 10 张:逐张走 7.2.1 单卡。 +func TestYdApnChangeSmallBatchUsesSingle(t *testing.T) { + defer shortenYdBatchPoll(2 * time.Second)() + capture := &ydApnCapture{} + sim, closeFn := newYdApnTestChannel(capture) + defer closeFn() + + out, err := sim.ChangeSimApnFunction(context.Background(), &model.SimCardOperateChangeApnInput{ + SimKeys: ydSimKeysWithMsisdn(5), + ReqData: model.SimCardOperateChangeApnInputItem{OperType: "0", ApnName: "CMIOT"}, + }) + if err != nil { + t.Fatal(err) + } + if len(capture.paths) != 5 { + t.Fatalf("expected 5 single calls, got %d (%v)", len(capture.paths), capture.paths) + } + for i, p := range capture.paths { + if p != "/v5/ec/manage/sim-communication-service" { + t.Fatalf("call %d unexpected path %s", i, p) + } + q := capture.query[i] + if q.Get("functionType") != "01" || q.Get("businessType") != "0" || q.Get("apn") != "CMIOT" { + t.Fatalf("call %d unexpected query %v", i, q) + } + if q.Get("iccid") == "" || q.Get("transid") == "" { + t.Fatalf("call %d missing iccid/transid: %v", i, q) + } + } + if len(out) != 5 || out[0].Code != 0 || out[0].ResData.OrderNum != "ORD1" { + t.Fatalf("unexpected output %+v", out) + } +} + +// 10~100 张:一次走 7.2.2 批量,open 翻成 operType=1;受理后必须回查结果。 +func TestYdApnChangeTenToHundredUsesBatch(t *testing.T) { + defer shortenYdBatchPoll(2 * time.Second)() + for _, n := range []int{10, 100} { + capture := &ydApnCapture{} + sim, closeFn := newYdApnTestChannel(capture) + + out, err := sim.ChangeSimApnFunction(context.Background(), &model.SimCardOperateChangeApnInput{ + SimKeys: ydSimKeysWithMsisdn(n), + ReqData: model.SimCardOperateChangeApnInputItem{OperType: "0", ApnName: "CMIOT"}, + }) + if err != nil { + t.Fatal(err) + } + batch, single, query := ydApnCounts(capture.paths) + if batch != 1 || single != 0 || query == 0 { + t.Fatalf("n=%d expected 1 batch + result query, got batch=%d single=%d query=%d (%v)", n, batch, single, query, capture.paths) + } + q := capture.query[0] + if q.Get("serviceType") != "11" || q.Get("operType") != "1" || q.Get("apnName") != "CMIOT" { + t.Fatalf("n=%d unexpected batch query %v", n, q) + } + if got := len(strings.Split(q.Get("msisdns"), "_")); got != n { + t.Fatalf("n=%d msisdns count = %d", n, got) + } + if len(out) != n || out[0].Code != 0 || out[0].ResData.OrderNum != "JOB1" { + t.Fatalf("n=%d unexpected output %+v", n, out) + } + closeFn() + } +} + +// 批量任务部分失败:结果查询返回的失败项必须如实回填,不能整批记成功。 +func TestYdApnChangeBatchReflectsPartialFailure(t *testing.T) { + defer shortenYdBatchPoll(2 * time.Second)() + + keys := ydSimKeysWithMsisdn(10) + failId := keys[3].MSISDN + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case strings.Contains(r.URL.Path, "sim-communication-function/batch"): + _, _ = w.Write([]byte(`{"status":"0","message":"正确","result":[{"jobId":"JOB9"}]}`)) + case strings.Contains(r.URL.Path, "sim-batch-result"): + items := make([]string, 0, len(keys)) + for _, k := range keys { + if k.MSISDN == failId { + items = append(items, fmt.Sprintf(`{"status":"12047","message":"用户该服务已经是暂停状态,不能重复暂停","resultType":"1","resultId":%q}`, k.MSISDN)) + continue + } + items = append(items, fmt.Sprintf(`{"status":"0","message":"正确","resultType":"1","resultId":%q}`, k.MSISDN)) + } + _, _ = w.Write([]byte(fmt.Sprintf(`{"status":"0","message":"正确","result":[{"jobStatus":"3","resultList":[%s]}]}`, strings.Join(items, ",")))) + default: + t.Fatalf("unexpected path %s", r.URL.Path) + } + })) + defer server.Close() + + out, err := newYdApnChannelWithServer(server).ChangeSimApnFunction(context.Background(), &model.SimCardOperateChangeApnInput{ + SimKeys: keys, + ReqData: model.SimCardOperateChangeApnInputItem{OperType: "0", ApnName: "CMIOT"}, + }) + if err != nil { + t.Fatal(err) + } + if len(out) != 10 { + t.Fatalf("output len = %d, want 10", len(out)) + } + failed := 0 + for _, item := range out { + if item.Code != 0 { + failed++ + if !strings.Contains(item.Message, "不能重复暂停") { + t.Fatalf("unexpected failure message %q", item.Message) + } + } + } + if failed != 1 { + t.Fatalf("failed count = %d, want 1 (%+v)", failed, out) + } +} + +// 轮询超时:未取得终态时按失败记录,并注明"未确认",避免误报成功。 +func TestYdApnChangeBatchResultTimeout(t *testing.T) { + defer shortenYdBatchPoll(20 * time.Millisecond)() + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case strings.Contains(r.URL.Path, "sim-communication-function/batch"): + _, _ = w.Write([]byte(`{"status":"0","message":"正确","result":[{"jobId":"JOB5"}]}`)) + case strings.Contains(r.URL.Path, "sim-batch-result"): + _, _ = w.Write([]byte(`{"status":"0","message":"正确","result":[{"jobStatus":"1","resultList":[]}]}`)) + default: + t.Fatalf("unexpected path %s", r.URL.Path) + } + })) + defer server.Close() + + out, err := newYdApnChannelWithServer(server).ChangeSimApnFunction(context.Background(), &model.SimCardOperateChangeApnInput{ + SimKeys: ydSimKeysWithMsisdn(10), + ReqData: model.SimCardOperateChangeApnInputItem{OperType: "0", ApnName: "CMIOT"}, + }) + if err != nil { + t.Fatal(err) + } + if len(out) != 10 { + t.Fatalf("output len = %d, want 10", len(out)) + } + for _, item := range out { + if item.Code == 0 { + t.Fatalf("timeout must not be reported as success: %+v", item) + } + if !strings.Contains(item.Message, "查询上游通信结果失败") { + t.Fatalf("unexpected timeout message %q", item.Message) + } + } +} + +// 停用数据:批量 operType 翻成 0,单卡 businessType=1。 +func TestYdApnChangeStopDirection(t *testing.T) { + defer shortenYdBatchPoll(2 * time.Second)() + capture := &ydApnCapture{} + sim, closeFn := newYdApnTestChannel(capture) + defer closeFn() + + if _, err := sim.ChangeSimApnFunction(context.Background(), &model.SimCardOperateChangeApnInput{ + SimKeys: ydSimKeysWithMsisdn(10), + ReqData: model.SimCardOperateChangeApnInputItem{OperType: "1", ApnName: "CMIOT"}, + }); err != nil { + t.Fatal(err) + } + capture2 := &ydApnCapture{} + sim2, closeFn2 := newYdApnTestChannel(capture2) + defer closeFn2() + if _, err := sim2.ChangeSimApnFunction(context.Background(), &model.SimCardOperateChangeApnInput{ + SimKeys: ydSimKeysWithMsisdn(3), + ReqData: model.SimCardOperateChangeApnInputItem{OperType: "1", ApnName: "CMIOT"}, + }); err != nil { + t.Fatal(err) + } + if capture.query[0].Get("operType") != "0" { + t.Fatalf("batch stop operType = %q, want 0", capture.query[0].Get("operType")) + } + if capture2.query[0].Get("businessType") != "1" { + t.Fatalf("single stop businessType = %q, want 1", capture2.query[0].Get("businessType")) + } +} + +// 超过 100 张:按 100 拆块,不足 10 的尾块退回单卡。 +func TestYdApnChangeOverHundredSplits(t *testing.T) { + defer shortenYdBatchPoll(2 * time.Second)() + capture := &ydApnCapture{} + sim, closeFn := newYdApnTestChannel(capture) + defer closeFn() + + out, err := sim.ChangeSimApnFunction(context.Background(), &model.SimCardOperateChangeApnInput{ + SimKeys: ydSimKeysWithMsisdn(105), + ReqData: model.SimCardOperateChangeApnInputItem{OperType: "0", ApnName: "CMIOT"}, + }) + if err != nil { + t.Fatal(err) + } + batch, single, _ := ydApnCounts(capture.paths) + if batch != 1 || single != 5 { + t.Fatalf("expected 1 batch + 5 singles, got batch=%d single=%d (%v)", batch, single, capture.paths) + } + if len(out) != 105 { + t.Fatalf("output len = %d, want 105", len(out)) + } +} + +// 批量里有卡缺 msisdn:缺号降级走 7.2.1,其余凑够 10 张仍走 7.2.2。 +func TestYdApnChangeMissingMsisdnFallsBackToSingle(t *testing.T) { + defer shortenYdBatchPoll(2 * time.Second)() + capture := &ydApnCapture{} + sim, closeFn := newYdApnTestChannel(capture) + defer closeFn() + + keys := ydSimKeysWithMsisdn(15) + keys[0].MSISDN = "" + keys[5].MSISDN = "" + keys[14].MSISDN = "" + + out, err := sim.ChangeSimApnFunction(context.Background(), &model.SimCardOperateChangeApnInput{ + SimKeys: keys, + ReqData: model.SimCardOperateChangeApnInputItem{OperType: "0", ApnName: "CMIOT"}, + }) + if err != nil { + t.Fatal(err) + } + batch, single, _ := ydApnCounts(capture.paths) + if batch != 1 || single != 3 { + t.Fatalf("expected 1 batch + 3 singles, got batch=%d single=%d (%v)", batch, single, capture.paths) + } + if len(out) != 15 { + t.Fatalf("output len = %d, want 15", len(out)) + } + for _, item := range out { + if item.Code != 0 { + t.Fatalf("unexpected failure item %+v", item) + } + } +} diff --git a/internal/logic/upstreamimpl/sim_yd_mock.go b/internal/logic/upstreamimpl/sim_yd_mock.go new file mode 100644 index 0000000..c047116 --- /dev/null +++ b/internal/logic/upstreamimpl/sim_yd_mock.go @@ -0,0 +1,1788 @@ +package upstreamimpl + +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" + + "github.com/gogf/gf/v2/frame/g" + "github.com/gogf/gf/v2/os/gtime" + "github.com/gogf/gf/v2/util/gconv" +) + +const ( + gUrlBaseYDMock = "https://api.iot.10086.cn/v5/" + gChanTemplateYdMock = "sim_yd_mock_v5" +) + +type sSimYDMock struct { + channelConfig *model.SimChannelConfigItemCache + seqId atomic.Int64 + timeout time.Duration +} + +func NewSimYDMock() *sSimYDMock { + return &sSimYDMock{} +} + +func init() { + s := NewSimYDMock() + service.UpstreamAPI().RegisterTemp(gChanTemplateYdMock, s) + service.RegisterSimYDMock(s) +} + +func (s *sSimYDMock) NewTemp(ctx context.Context, channelConfig *model.SimChannelConfigItemCache) model.SimCardChannelI { + return &sSimYDMock{ + channelConfig: channelConfig, + timeout: resolveUpstreamHTTPTimeout(ctx, templateConfigJSON(channelConfig)), + } +} + +func (s *sSimYDMock) getUrl(ctx context.Context, serviceUrl string) string { + baseUrl := "" + if s.channelConfig != nil && s.channelConfig.Template != nil { + var template struct { + API struct { + BaseURL string `json:"baseUrl"` + } `json:"api"` + } + if err := json.Unmarshal([]byte(s.channelConfig.Template.TempConfig), &template); err == nil { + baseUrl = strings.TrimSpace(template.API.BaseURL) + } + } + if baseUrl == "" { + g.Log().Warningf(ctx, "using legacy Mock OneLink URL configuration") + baseUrl = g.Cfg().MustGet(ctx, "dependencies."+gChanTemplateYdMock+".url", gUrlBaseYDMock).String() + } + if strings.HasSuffix(baseUrl, "/") { + return baseUrl + serviceUrl + } + return baseUrl + "/" + serviceUrl +} + +func (s *sSimYDMock) refreshToken(ctx context.Context) string { + chanKey := s.channelConfig.Channel.ChanKey + url := s.getUrl(ctx, "ec/get/token") + client := g.Client() + client.SetTimeout(s.httpTimeout()) + // {"status":"0","message":"正确","result":[{"token":"50af5334a72a5a51ad8fe72d5c0e77a3e00a7a5588d301be143479310d32b609","ttl":3600}]} + type ydTokenResp struct { + Status string `json:"status"` + Message string `json:"message"` + Result []struct { + Token string `json:"token"` + Ttl any `json:"ttl"` + } `json:"result"` + } + now := gtime.Now() + oldToken := s.channelConfig.Channel.AccessToken + // TODO: 暂时不刷新,影响老系统,实际上需要刷新的 + // FIX: 暂时不刷新,影响老系统,实际上需要刷新的 + params := g.Map{ + "appid": s.channelConfig.Channel.AppKey, + "password": s.channelConfig.Channel.AppSecret, + "transid": s.getTransId(ctx), + "refresh": "0", // 0: 不刷新, 1: 刷新 + } + resp, err := client.Get(ctx, url, params) + if err != nil { + g.Log().Errorf(ctx, "refreshToken get error %+v, chanKey %s", err, chanKey) + return "" + } + + body := resp.ReadAllString() + var ydResp ydTokenResp + err = json.Unmarshal([]byte(body), &ydResp) + if err != nil { + g.Log().Errorf(ctx, "refreshToken unmarshal error %+v, chanKey %s", err, chanKey) + return "" + } + if ydResp.Status != "0" { + g.Log().Warningf(ctx, "refreshToken upstream error status=%s message=%s chanKey=%s", ydResp.Status, ydResp.Message, chanKey) + return "" + } + token := "" + ttl := 0 + if len(ydResp.Result) > 0 { + token = ydResp.Result[0].Token + ttl = gconv.Int(ydResp.Result[0].Ttl) + } + if token == "" { + g.Log().Warningf(ctx, "refreshToken empty token in result chanKey=%s", chanKey) + return "" + } + if ttl <= 0 { + g.Log().Warningf(ctx, "refreshToken invalid ttl=%v, use default ttl=3600, chanKey=%s", ydResp.Result[0].Ttl, chanKey) + ttl = 3600 + } + now2 := gtime.New(now) + accessTokenExpire := now2.Add(time.Duration(ttl) * time.Second) + err = service.ChannelUpdate().UpdateAccessToken(ctx, &model.UpdateAccessTokenInput{ + ChanKey: s.channelConfig.Channel.ChanKey, + AccessToken: token, + AccessTokenCreated: now, + AccessTokenExpire: accessTokenExpire, + RefreshToken: "", + RefreshTokenExpire: nil, + }) + if err != nil { + g.Log().Errorf(ctx, "refreshToken update access token error %+v, chanKey %s, ", err, chanKey) + return "" + } + s.channelConfig.Channel.AccessToken = token + s.channelConfig.Channel.AccessTokenExpire = accessTokenExpire + updateYdTokenState(chanKey, token, now.Time, accessTokenExpire.Time) + g.Log().Infof(ctx, "refreshToken success, tokenChanged %v, ttl %d, expire %s, chanKey %s", oldToken != token, ttl, accessTokenExpire.Format("2006-01-02 15:04:05"), chanKey) + return token +} + +func (s *sSimYDMock) getToken(ctx context.Context) string { + chanKey := s.channelConfig.Channel.ChanKey + policy := loadYdTokenRefreshPolicy(ctx) + return withYdTokenGuard(chanKey, func() string { + now := time.Now() + seedYdTokenState(s.channelConfig.Channel) + state := ydTokenStateFor(chanKey) + state.mu.RLock() + current, expiresAt := state.token, state.expiresAt + state.mu.RUnlock() + usable := current != "" && !expiresAt.IsZero() && now.Before(expiresAt) + if policy.mode == ydTokenRefreshModeProactive && usable { + usable = now.Before(expiresAt.Add(-time.Duration(policy.skewSecond) * time.Second)) + } + if usable { + s.channelConfig.Channel.AccessToken = current + s.channelConfig.Channel.AccessTokenExpire = gtime.New(expiresAt) + return current + } + g.Log().Debugf(ctx, "getToken refresh needed, mode %s, chanKey %s", policy.mode, chanKey) + return s.refreshToken(ctx) + }) +} + +func (s *sSimYDMock) recoverToken(ctx context.Context, rejectedToken string) (string, error) { + return recoverYdToken(ctx, s.channelConfig.Channel.ChanKey, s.channelConfig.Channel, rejectedToken, s.refreshToken) +} + +func (s *sSimYDMock) requestYd(ctx context.Context, operation, url string, params g.Map) (*ydHTTPResponse, error) { + client := g.Client() + client.SetTimeout(s.httpTimeout()) + return doYdRequest(ctx, operation, s.channelConfig.Channel.ChanKey, s.getToken, s.recoverToken, params, func(requestParams g.Map) (string, error) { + resp, err := client.Get(ctx, url, requestParams) + if err != nil { + return "", err + } + return resp.ReadAllString(), nil + }) +} + +func (s *sSimYDMock) httpTimeout() time.Duration { + if s != nil && s.timeout > 0 { + return s.timeout + } + return defaultUpstreamHTTPTimeout +} + +func (s *sSimYDMock) getTransId(ctx context.Context) string { + appid := s.channelConfig.Channel.AppKey + return appid + time.Now().Format("20060102150405") + fmt.Sprintf("%08d", s.seqId.Add(1)) +} + +// 单卡基本信息查询 +// http://api.iot.10086.cn/v5/ec/query/sim-basic-info?transid=YOUR_TRANSID&token=YOUR_TOKEN&iccid=898604F4152391115474 +func (s *sSimYDMock) QuerySimBasicInfo(ctx context.Context, input *model.SimBasicInfoInput) (model.SimBasicInfoOutput, error) { + // { + // "status": "0", + // "message": "正确", + // "result": [ + // { + // "msisdn": "1441543605474", + // "imsi": "460087436015474", + // "iccid": "898604F4152391115474", + // "activeDate": "2023-05-11 15:07:06", // 激活日期 + // "openDate": "2023-05-11 14:53:24", // 开卡日期 + // "remark": "" + // } + // ] + // } + type ydResp struct { + Status string `json:"status"` + Message string `json:"message"` + Result []struct { + Msisdn string `json:"msisdn"` + Imsi string `json:"imsi"` + Iccid string `json:"iccid"` + ActiveDate string `json:"activeDate"` + OpenDate string `json:"openDate"` + Remark string `json:"remark"` + } `json:"result"` + } + + url := s.getUrl(ctx, "ec/query/sim-basic-info") + + items := make(model.SimBasicInfoOutput, 0) + for _, simKey := range input.SimKeys { + params := g.Map{ + "transid": s.getTransId(ctx), + "iccid": simKey.ICCID, + } + resp, err := s.requestYd(ctx, "QuerySimBasicInfo", url, params) + if err != nil { + g.Log().Errorf(ctx, "QuerySimBasicInfo get error %+v,url %s, params %+v, ", err, url, params) + items = append(items, &model.SimBaseRes[model.SimBasicInfoItem]{ + SimKey: simKey, + Code: consts.ApiStatusError, + ResData: model.SimBasicInfoItem{ + ActiveDate: "", + OpenDate: "", + Remark: "", + }, + }) + continue + } + body := resp.ReadAllString() + + var ydResp ydResp + err = json.Unmarshal([]byte(body), &ydResp) + if err != nil { + g.Log().Errorf(ctx, "QuerySimBasicInfo unmarshal error %+v,url %s, params %+v, body %s, ", err, url, params, body) + items = append(items, &model.SimBaseRes[model.SimBasicInfoItem]{ + SimKey: simKey, + Code: consts.ApiStatusError, + ResData: model.SimBasicInfoItem{ + ActiveDate: "", + OpenDate: "", + Remark: "", + }, + }) + continue + } + if ydResp.Status != "0" { + g.Log().Warningf(ctx, "QuerySimBasicInfo error %s,url %s, params %+v, body %s, ", ydResp.Message, url, params, body) + items = append(items, &model.SimBaseRes[model.SimBasicInfoItem]{ + SimKey: simKey, + Code: gconv.Int(ydResp.Status), + Message: ydResp.Message, + ResData: model.SimBasicInfoItem{ + ActiveDate: "", + OpenDate: "", + Remark: "", + }, + }) + continue + } + if len(ydResp.Result) == 0 { + items = append(items, &model.SimBaseRes[model.SimBasicInfoItem]{ + SimKey: simKey, + Code: consts.ApiStatusError, + Message: firstNonEmpty(ydResp.Message, "上游未返回SIM卡信息"), + ResData: model.SimBasicInfoItem{ + ActiveDate: "", + OpenDate: "", + Remark: "", + }, + }) + continue + } + hasIdentity := false + for _, session := range ydResp.Result { + if ydBasicInfoHasIdentity(session.Iccid, session.Msisdn, session.Imsi) { + hasIdentity = true + break + } + } + if !hasIdentity { + message := strings.TrimSpace(ydResp.Message) + if message == "" || message == "正常" || message == "正确" { + message = "上游未返回三码信息" + } + items = append(items, &model.SimBaseRes[model.SimBasicInfoItem]{ + SimKey: simKey, + Code: consts.ApiStatusError, + Message: message, + ResData: model.SimBasicInfoItem{ + ActiveDate: "", + OpenDate: "", + Remark: "", + }, + }) + continue + } + for _, session := range ydResp.Result { + items = append(items, &model.SimBaseRes[model.SimBasicInfoItem]{ + SimKey: simKey, + Code: gconv.Int(ydResp.Status), + Message: ydResp.Message, + ResData: model.SimBasicInfoItem{ + ICCID: session.Iccid, + IMSI: session.Imsi, + MSISDN: session.Msisdn, + ActiveDate: session.ActiveDate, + OpenDate: session.OpenDate, + Remark: session.Remark, + }, + }) + } + } + return items, nil +} + +// 5.7.2 CMIOT_API25M01-单卡在线信息实时查询 +func (s *sSimYDMock) QuerySimSession(ctx context.Context, input *model.SimSessionInput) (model.SimSessionOutput, error) { + // { + // "status": "0", + // "message": "正确", + // "result": [ + // { + // "simSessionList":[ + // { + // "apnId":"CMIOT", + // "status":"01", + // "ip":"127.0.0.1", + // "ipv6Prefix":"", + // "ipv6":"", + // "createDate":"2018-08-23 17:17:27", + // "rat":"6" + // } + // ] + // } + // ] + // } + + type ydResp struct { + Status string `json:"status"` + Message string `json:"message"` + Result []struct { + InfoList []struct { + Apn string `json:"apn"` + SessionStatus string `json:"sessionStatus"` + Ipv4 string `json:"ipv4"` + Ipv6Prefix string `json:"ipv6Prefix"` + Ipv6 string `json:"ipv6"` + CreateTime string `json:"createTime"` + AccessType string `json:"accessType"` + } `json:"infoList"` + SimSessionList []struct { + ApnId string `json:"apnId"` + Status string `json:"status"` + Ip string `json:"ip"` + Ipv6Prefix string `json:"ipv6Prefix"` + Ipv6 string `json:"ipv6"` + CreateDate string `json:"createDate"` + Rat string `json:"rat"` + } `json:"simSessionList"` + } `json:"result"` + } + + url := s.getUrl(ctx, "ec/query/sim-apn-session-info") + + items := make(model.SimSessionOutput, 0) + for _, simKey := range input.SimKeys { + params := g.Map{ + "transid": s.getTransId(ctx), + } + paramName, paramValue := pickSimQueryParam(simKey) + if paramName == "" { + items = append(items, &model.SimBaseRes[model.SimSessionItem]{ + SimKey: simKey, + Code: consts.ApiStatusError, + Message: "missing iccid/msisdn/imsi", + ResData: model.SimSessionItem{ + Status: "-2", + }, + }) + continue + } + params[paramName] = paramValue + resp, err := s.requestYd(ctx, "QuerySimSession", url, params) + if err != nil { + g.Log().Errorf(ctx, "QuerySimSession get error %+v,url %s, params %+v, ", err, url, params) + items = append(items, &model.SimBaseRes[model.SimSessionItem]{ + SimKey: simKey, + Code: consts.ApiStatusError, + ResData: model.SimSessionItem{ + Status: "-2", + }, + }) + continue + } + body := resp.ReadAllString() + + var ydResp ydResp + err = json.Unmarshal([]byte(body), &ydResp) + if err != nil { + g.Log().Errorf(ctx, "QuerySimSession unmarshal error %+v,url %s, params %+v, body %s, ", err, url, params, body) + items = append(items, &model.SimBaseRes[model.SimSessionItem]{ + SimKey: simKey, + Code: consts.ApiStatusError, + ResData: model.SimSessionItem{ + Status: "-2", + }, + }) + continue + } + if ydResp.Status != "0" { + g.Log().Warningf(ctx, "QuerySimSession error %s,url %s, params %+v, body %s, ", ydResp.Message, url, params, body) + items = append(items, &model.SimBaseRes[model.SimSessionItem]{ + SimKey: simKey, + Code: gconv.Int(ydResp.Status), + Message: ydResp.Message, + ResData: model.SimSessionItem{ + Status: "-2", + }, + }) + continue + } + if len(ydResp.Result) == 0 || (len(ydResp.Result[0].InfoList) == 0 && len(ydResp.Result[0].SimSessionList) == 0) { + items = append(items, &model.SimBaseRes[model.SimSessionItem]{ + SimKey: simKey, + Code: consts.ApiStatusError, + Message: ydResp.Message, + ResData: model.SimSessionItem{ + Status: "-2", + }, + }) + continue + } + for _, session := range ydResp.Result[0].InfoList { + items = append(items, &model.SimBaseRes[model.SimSessionItem]{ + SimKey: simKey, + Code: gconv.Int(ydResp.Status), + Message: ydResp.Message, + ResData: model.SimSessionItem{ + ApnId: session.Apn, + Rat: session.AccessType, + Ipv4: session.Ipv4, + Ipv6: session.Ipv6, + Ipv6Prefix: session.Ipv6Prefix, + Status: convertYdSessionStatus(ctx, session.SessionStatus), + CreateTime: session.CreateTime, + }, + }) + } + for _, session := range ydResp.Result[0].SimSessionList { + items = append(items, &model.SimBaseRes[model.SimSessionItem]{ + SimKey: simKey, + Code: gconv.Int(ydResp.Status), + Message: ydResp.Message, + ResData: model.SimSessionItem{ + ApnId: session.ApnId, + Rat: session.Rat, + Ipv4: session.Ip, + Ipv6: session.Ipv6, + Ipv6Prefix: session.Ipv6Prefix, + Status: convertYdSessionStatus(ctx, session.Status), + CreateTime: session.CreateDate, + }, + }) + } + } + return items, nil +} + +// 5.1.6 单卡状态查询 +// https://api.iot.10086.cn/v5/ec/query/sim-status?transid=YOUR_TRANSID&token=YOUR_TOKEN&iccid=898604F4152391115474 +func (s *sSimYDMock) QuerySimCardStatus(ctx context.Context, input *model.SimCardStatusInput) (model.SimCardStatusOutput, error) { + url := s.getUrl(ctx, "ec/query/sim-status") + + // { + // "status":"0", + // "message":"正确", + // "result":[ + // { + // "cardStatus":"1", + // "lastChangeDate":"2018-04-21 08:00:00" + // } + // ] + // } + + // {"status":"0","message":"正确","result":[{"cardStatus":"2","lastChangeDate":"2023-05-11 15:07:08"}]} + type ydResp struct { + Status string `json:"status"` + Message string `json:"message"` + Result []struct { + CardStatus string `json:"cardStatus"` + LastChangeDate string `json:"lastChangeDate"` + } `json:"result"` + } + items := make(model.SimCardStatusOutput, 0) + for _, simKey := range input.SimKeys { + params := g.Map{ + "transid": s.getTransId(ctx), + "iccid": simKey.ICCID, + } + + resp, err := s.requestYd(ctx, "QuerySimCardStatus", url, params) + if err != nil { + g.Log().Errorf(ctx, "QuerySimCardStatus get error %+v,url %s, params %+v, ", err, url, params) + items = append(items, &model.SimBaseRes[model.SimCardStatusItem]{ + SimKey: simKey, + Code: consts.ApiStatusError, + Message: err.Error(), + }) + continue + } + body := resp.ReadAllString() + var ydResp ydResp + err = json.Unmarshal([]byte(body), &ydResp) + if err != nil { + g.Log().Errorf(ctx, "QuerySimCardStatus unmarshal error %+v,url %s, params %+v, body %s, ", err, url, params, body) + items = append(items, &model.SimBaseRes[model.SimCardStatusItem]{ + SimKey: simKey, + Code: consts.ApiStatusError, + Message: err.Error(), + }) + continue + } + if ydResp.Status != "0" { + g.Log().Warningf(ctx, "QuerySimCardStatus error %s,url %s, params %+v, body %s, ", ydResp.Message, url, params, body) + items = append(items, &model.SimBaseRes[model.SimCardStatusItem]{ + SimKey: simKey, + Code: gconv.Int(ydResp.Status), + Message: ydResp.Message, + ResData: model.SimCardStatusItem{ + CardStatus: 0, + LastChangeDate: "", + }, + }) + continue + } + if len(ydResp.Result) == 0 { + items = append(items, &model.SimBaseRes[model.SimCardStatusItem]{ + SimKey: simKey, + Code: consts.ApiStatusError, + Message: ydResp.Message, + ResData: model.SimCardStatusItem{ + CardStatus: 0, + LastChangeDate: "", + }, + }) + continue + } + if len(ydResp.Result) > 0 { + result := ydResp.Result[0] + items = append(items, &model.SimBaseRes[model.SimCardStatusItem]{ + SimKey: simKey, + Code: gconv.Int(ydResp.Status), + Message: ydResp.Message, + ResData: model.SimCardStatusItem{ + CardStatus: gconv.Int(result.CardStatus), + LastChangeDate: result.LastChangeDate, + }, + }) + } else { + items = append(items, &model.SimBaseRes[model.SimCardStatusItem]{ + SimKey: simKey, + Code: consts.ApiStatusError, + Message: ydResp.Message, + ResData: model.SimCardStatusItem{ + CardStatus: 0, + LastChangeDate: "", + }, + }) + } + } + return items, nil +} + +// 物联网卡IMEI查询 +// https://api.iot.10086.cn/v5/ec/query/sim-imei?transid=371AIOT20210605101712810732026031520280010000000&token=YOUR_TOKEN&iccid=898604F81623D2249725 +func (s *sSimYDMock) QuerySimStopReason(ctx context.Context, input *model.SimStopReasonInput) (model.SimStopReasonOutput, error) { + url := s.getUrl(ctx, "ec/query/sim-stop-reason") + + type ydResp struct { + Status string `json:"status"` + Message string `json:"message"` + Result []struct { + Msisdn string `json:"msisdn"` + PlatformType string `json:"platformType"` + StopReason string `json:"stopReason"` + } `json:"result"` + } + + items := make(model.SimStopReasonOutput, 0, len(input.SimKeys)) + for _, simKey := range input.SimKeys { + paramKey, paramValue := pickSimQueryParam(simKey) + if paramKey == "" { + items = append(items, &model.SimBaseRes[model.SimStopReasonItem]{ + SimKey: simKey, + Code: consts.ApiStatusError, + Message: "missing iccid/msisdn/imsi", + ResData: model.SimStopReasonItem{}, + }) + continue + } + + params := g.Map{ + "transid": s.getTransId(ctx), + paramKey: paramValue, + } + resp, err := s.requestYd(ctx, "QuerySimStopReason", url, params) + if err != nil { + g.Log().Errorf(ctx, "QuerySimStopReason get error %+v,url %s, params %+v, ", err, url, params) + items = append(items, &model.SimBaseRes[model.SimStopReasonItem]{ + SimKey: simKey, + Code: consts.ApiStatusError, + Message: err.Error(), + ResData: model.SimStopReasonItem{}, + }) + continue + } + body := resp.ReadAllString() + var ydResp ydResp + if err = json.Unmarshal([]byte(body), &ydResp); err != nil { + g.Log().Errorf(ctx, "QuerySimStopReason unmarshal error %+v,url %s, params %+v, body %s, ", err, url, params, body) + items = append(items, &model.SimBaseRes[model.SimStopReasonItem]{ + SimKey: simKey, + Code: consts.ApiStatusError, + Message: err.Error(), + ResData: model.SimStopReasonItem{}, + }) + continue + } + if ydResp.Status != "0" { + g.Log().Warningf(ctx, "QuerySimStopReason error %s,url %s, params %+v, body %s, ", ydResp.Message, url, params, body) + items = append(items, &model.SimBaseRes[model.SimStopReasonItem]{ + SimKey: simKey, + Code: gconv.Int(ydResp.Status), + Message: ydResp.Message, + ResData: model.SimStopReasonItem{}, + }) + continue + } + + stopReason := "未停机" + platformType := "" + if len(ydResp.Result) > 0 { + stopReason = mapYdStopReasonText(ydResp.Result[0].StopReason) + if stopReason == "" { + stopReason = "未停机" + } + platformType = strings.TrimSpace(ydResp.Result[0].PlatformType) + } + + items = append(items, &model.SimBaseRes[model.SimStopReasonItem]{ + SimKey: simKey, + Code: gconv.Int(ydResp.Status), + Message: ydResp.Message, + ResData: model.SimStopReasonItem{ + StopReason: stopReason, + PlatformType: platformType, + }, + }) + } + return items, nil +} + +func (s *sSimYDMock) QuerySimFrozenState(ctx context.Context, input *model.SimFrozenStateInput) (model.SimFrozenStateOutput, error) { + url := s.getUrl(ctx, "ec/query/sim-frozen-state-info") + + type ydResp struct { + Status string `json:"status"` + Message string `json:"message"` + Result []struct { + Msisdn string `json:"msisdn"` + FrozenStatus string `json:"frozenStatus"` + FrozenReason string `json:"frozenReason"` + OperTime string `json:"operTime"` + } `json:"result"` + } + + items := make(model.SimFrozenStateOutput, 0, len(input.SimKeys)) + for _, simKey := range input.SimKeys { + paramKey, paramValue := pickSimQueryParam(simKey) + if paramKey == "" { + items = append(items, &model.SimBaseRes[model.SimFrozenStateItem]{ + SimKey: simKey, + Code: consts.ApiStatusError, + Message: "missing iccid/msisdn/imsi", + ResData: model.SimFrozenStateItem{}, + }) + continue + } + + params := g.Map{ + "transid": s.getTransId(ctx), + paramKey: paramValue, + } + resp, err := s.requestYd(ctx, "QuerySimFrozenState", url, params) + if err != nil { + g.Log().Errorf(ctx, "QuerySimFrozenState get error %+v,url %s, params %+v, ", err, url, params) + items = append(items, &model.SimBaseRes[model.SimFrozenStateItem]{ + SimKey: simKey, + Code: consts.ApiStatusError, + Message: err.Error(), + ResData: model.SimFrozenStateItem{}, + }) + continue + } + body := resp.ReadAllString() + var ydResp ydResp + if err = json.Unmarshal([]byte(body), &ydResp); err != nil { + g.Log().Errorf(ctx, "QuerySimFrozenState unmarshal error %+v,url %s, params %+v, body %s, ", err, url, params, body) + items = append(items, &model.SimBaseRes[model.SimFrozenStateItem]{ + SimKey: simKey, + Code: consts.ApiStatusError, + Message: err.Error(), + ResData: model.SimFrozenStateItem{}, + }) + continue + } + if ydResp.Status != "0" { + g.Log().Warningf(ctx, "QuerySimFrozenState error %s,url %s, params %+v, body %s, ", ydResp.Message, url, params, body) + items = append(items, &model.SimBaseRes[model.SimFrozenStateItem]{ + SimKey: simKey, + Code: gconv.Int(ydResp.Status), + Message: ydResp.Message, + ResData: model.SimFrozenStateItem{}, + }) + continue + } + + frozenStatus := "未冻结" + frozenReason := "" + operTime := "" + if len(ydResp.Result) > 0 { + frozenStatus = mapYdFrozenStatusText(ydResp.Result[0].FrozenStatus) + if frozenStatus == "" { + frozenStatus = "未冻结" + } + frozenReason = strings.TrimSpace(ydResp.Result[0].FrozenReason) + operTime = strings.TrimSpace(ydResp.Result[0].OperTime) + } + + items = append(items, &model.SimBaseRes[model.SimFrozenStateItem]{ + SimKey: simKey, + Code: gconv.Int(ydResp.Status), + Message: ydResp.Message, + ResData: model.SimFrozenStateItem{ + FrozenStatus: frozenStatus, + FrozenReason: frozenReason, + OperTime: operTime, + }, + }) + } + return items, nil +} + +func (s *sSimYDMock) QuerySimImei(ctx context.Context, input *model.SimImeiInput) (model.SimImeiOutput, error) { + url := s.getUrl(ctx, "ec/query/sim-terminal-device-info") + // {"status":"0","message":"正确","result":[{"imei":"865466070817587"}]} + type ydResp struct { + Status string `json:"status"` + Message string `json:"message"` + Result []struct { + Imei string `json:"imei"` + TerminalDeviceImei string `json:"terminalDeviceImei"` + } `json:"result"` + } + items := make(model.SimImeiOutput, 0) + for _, simKey := range input.SimKeys { + key, value := pickSimQueryParam(simKey) + if key == "" { + items = append(items, &model.SimBaseRes[model.SimImeiItem]{SimKey: simKey, Code: consts.ApiStatusError, Message: "missing iccid, msisdn or imsi"}) + continue + } + params := g.Map{ + "transid": s.getTransId(ctx), + key: value, + } + + resp, err := s.requestYd(ctx, "QuerySimImei", url, params) + if err != nil { + g.Log().Errorf(ctx, "QuerySimImei get error %+v,url %s, params %+v, ", err, url, params) + items = append(items, &model.SimBaseRes[model.SimImeiItem]{ + SimKey: simKey, + Code: consts.ApiStatusError, + Message: err.Error(), + }) + continue + } + body := resp.ReadAllString() + var ydResp ydResp + err = json.Unmarshal([]byte(body), &ydResp) + if err != nil { + g.Log().Errorf(ctx, "QuerySimImei unmarshal error %+v,url %s, params %+v, body %s, ", err, url, params, body) + items = append(items, &model.SimBaseRes[model.SimImeiItem]{ + SimKey: simKey, + Code: consts.ApiStatusError, + Message: err.Error(), + }) + continue + } + if ydResp.Status != "0" { + g.Log().Warningf(ctx, "QuerySimImei error %s,url %s, params %+v, body %s, ", ydResp.Message, url, params, body) + items = append(items, &model.SimBaseRes[model.SimImeiItem]{ + SimKey: simKey, + Code: gconv.Int(ydResp.Status), + Message: ydResp.Message, + }) + continue + } + if len(ydResp.Result) == 0 { + items = append(items, &model.SimBaseRes[model.SimImeiItem]{ + SimKey: simKey, + Code: gconv.Int(ydResp.Status), + Message: ydResp.Message, + }) + continue + } + if len(ydResp.Result) == 1 { + result := ydResp.Result[0] + items = append(items, &model.SimBaseRes[model.SimImeiItem]{ + SimKey: simKey, + Code: gconv.Int(ydResp.Status), + Message: ydResp.Message, + ResData: model.SimImeiItem{ + Imei: firstNonEmpty(result.TerminalDeviceImei, result.Imei), + }, + }) + } else { + items = append(items, &model.SimBaseRes[model.SimImeiItem]{ + SimKey: simKey, + Code: gconv.Int(ydResp.Status), + Message: ydResp.Message, + }) + } + } + return items, nil +} + +// 单卡本月流量累计使用量查询 +// https://api.iot.10086.cn/v5/ec/query/sim-data-usage-inpool?transid=371AIOT20210605101712810732026032616060010000000&token=YOUR_TOKEN&msisdn=1441589159774 +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"}]}]} + type ydResp struct { + Status string `json:"status"` + Message string `json:"message"` + Result []struct { + ApnList []struct { + ApnName string `json:"apnName"` + ApnUseAmount string `json:"apnUseAmount"` + } `json:"apnList"` + } `json:"result"` + } + items := make(model.SimDataUsageInPoolOutput, 0) + for _, simKey := range input.SimKeys { + params := g.Map{ + "transid": s.getTransId(ctx), + "iccid": simKey.ICCID, + } + + resp, err := s.requestYd(ctx, "QuerySimDataUsageInPool", url, params) + if err != nil { + g.Log().Errorf(ctx, "QuerySimDataUsageInPool get error %+v,url %s, params %+v, ", err, url, params) + items = append(items, &model.SimBaseRes[model.SimDataUsageInPoolItem]{ + SimKey: simKey, + Code: consts.ApiStatusError, + Message: err.Error(), + }) + continue + } + body := resp.ReadAllString() + var ydResp ydResp + err = json.Unmarshal([]byte(body), &ydResp) + if err != nil { + g.Log().Errorf(ctx, "QuerySimDataUsageInPool unmarshal error %+v,url %s, params %+v, body %s, ", err, url, params, body) + items = append(items, &model.SimBaseRes[model.SimDataUsageInPoolItem]{ + SimKey: simKey, + Code: consts.ApiStatusError, + Message: err.Error(), + }) + continue + } + if ydResp.Status != "0" { + g.Log().Warningf(ctx, "QuerySimDataUsageInPool error %s,url %s, params %+v, body %s, ", ydResp.Message, url, params, body) + items = append(items, &model.SimBaseRes[model.SimDataUsageInPoolItem]{ + SimKey: simKey, + Code: gconv.Int(ydResp.Status), + Message: ydResp.Message, + }) + continue + } + if len(ydResp.Result) == 0 { + items = append(items, &model.SimBaseRes[model.SimDataUsageInPoolItem]{ + SimKey: simKey, + Code: gconv.Int(ydResp.Status), + Message: ydResp.Message, + ResData: model.SimDataUsageInPoolItem{ + DataUsage: 0.0, + }, + }) + continue + } + if len(ydResp.Result) == 1 { + result := ydResp.Result[0] + dataUsage := 0.0 + if len(result.ApnList) != 0 { + for _, apn := range result.ApnList { + dataUsage += gconv.Float64(apn.ApnUseAmount) + } + } + items = append(items, &model.SimBaseRes[model.SimDataUsageInPoolItem]{ + SimKey: simKey, + Code: gconv.Int(ydResp.Status), + Message: ydResp.Message, + ResData: model.SimDataUsageInPoolItem{ + DataUsage: dataUsage / 1024, + }, + }) + } else { + items = append(items, &model.SimBaseRes[model.SimDataUsageInPoolItem]{ + SimKey: simKey, + Code: gconv.Int(ydResp.Status), + Message: ydResp.Message, + ResData: model.SimDataUsageInPoolItem{ + DataUsage: 0.0, + }, + }) + } + } + return items, nil +} + +// 单卡本月流量累计使用量查询 +// https://api.iot.10086.cn/v5/ec/query/sim-data-usage?transid=371AIOT20210605101712810732026032616060010000000&token=YOUR_TOKEN&msisdn=1441589159774 +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":[]}]}]} + items := make(model.SimDataUsageOutput, 0) + for _, simKey := range input.SimKeys { + params := g.Map{ + "transid": s.getTransId(ctx), + "iccid": simKey.ICCID, + } + resp, fetchErr := s.requestYd(ctx, "QuerySimDataUsage", url, params) + if fetchErr != nil { + g.Log().Errorf(ctx, "QuerySimDataUsage request failed, chanKey:%s, err:%+v", simKey.ChanKey, fetchErr) + items = append(items, &model.SimBaseRes[model.SimDataUsageItem]{ + SimKey: simKey, + Code: consts.ApiStatusError, + Message: fetchErr.Error(), + }) + continue + } + body := resp.ReadAllString() + ydResp, decodeErr := decodeYdDataUsageResponse(body) + if fetchErr = decodeErr; fetchErr != nil { + g.Log().Errorf(ctx, "QuerySimDataUsage unmarshal error, chanKey:%s, err:%+v", simKey.ChanKey, fetchErr) + items = append(items, &model.SimBaseRes[model.SimDataUsageItem]{ + SimKey: simKey, + Code: consts.ApiStatusError, + Message: fetchErr.Error(), + }) + continue + } + if ydResp.Status != "0" { + g.Log().Warningf(ctx, "QuerySimDataUsage upstream error, chanKey:%s, status:%s, message:%s", simKey.ChanKey, ydResp.Status, ydResp.Message) + items = append(items, &model.SimBaseRes[model.SimDataUsageItem]{ + SimKey: simKey, + Code: gconv.Int(ydResp.Status), + Message: ydResp.Message, + ErrCode: ydResp.Status, + }) + continue + } + if len(ydResp.Result) == 0 { + items = append(items, &model.SimBaseRes[model.SimDataUsageItem]{ + SimKey: simKey, + Code: gconv.Int(ydResp.Status), + Message: ydResp.Message, + ResData: model.SimDataUsageItem{ + DataUsage: 0.0, + }, + }) + continue + } else { + items = append(items, &model.SimBaseRes[model.SimDataUsageItem]{ + SimKey: simKey, + Code: gconv.Int(ydResp.Status), + Message: ydResp.Message, + ResData: model.SimDataUsageItem{ + DataUsage: gconv.Float64(ydResp.Result[0].DataAmount) / 1024, + }, + }) + } + } + return items, nil +} + +// 物联卡单月 GPRS 流量使用量批量查询,单批最多 100 张卡 +func (s *sSimYDMock) QuerySimDataUsageMonthly(ctx context.Context, input *model.SimDataUsageMonthlyInput) (model.SimDataUsageMonthlyOutput, error) { + url := s.getUrl(ctx, "ec/query/sim-data-usage-monthly/batch") + type YdResp struct { + Status string `json:"status"` + Message string `json:"message"` + Result []struct { + DeadLine string `json:"deadLine"` + DataAmountList []struct { + DataAmount string `json:"dataAmount"` + Iccid string `json:"iccid"` + Imsi string `json:"imsi"` + Msisdn string `json:"msisdn"` + } `json:"dataAmountList"` + } `json:"result"` + } + items := make(model.SimDataUsageMonthlyOutput, 0, len(input.SimKeys)) + const batchSize = 100 + for start := 0; start < len(input.SimKeys); start += batchSize { + end := start + batchSize + if end > len(input.SimKeys) { + end = len(input.SimKeys) + } + batch := input.SimKeys[start:end] + iccids := make([]string, 0, len(batch)) + simKeyMap := make(map[string]*model.SimKey, len(batch)*3) + for _, simKey := range batch { + if simKey == nil { + iccids = append(iccids, "") + continue + } + iccids = append(iccids, strings.TrimSpace(simKey.ICCID)) + if value := strings.TrimSpace(simKey.ICCID); value != "" { + simKeyMap["iccid:"+strings.ToUpper(value)] = simKey + } + if value := strings.TrimSpace(simKey.IMSI); value != "" { + simKeyMap["imsi:"+strings.ToUpper(value)] = simKey + } + if value := strings.TrimSpace(simKey.MSISDN); value != "" { + simKeyMap["msisdn:"+value] = simKey + } + } + params := g.Map{ + "transid": s.getTransId(ctx), + "iccids": strings.Join(iccids, "_"), + "queryDate": input.ReqData.QueryDate, + } + resp, err := s.requestYd(ctx, "QuerySimDataUsageMonthly", url, params) + if err != nil { + g.Log().Errorf(ctx, "QuerySimDataUsageMonthly get error %+v,url %s, params %+v", err, url, params) + for _, simKey := range batch { + items = append(items, &model.SimBaseRes[model.SimDataUsageMonthlyOutputItem]{SimKey: simKey, Code: consts.ApiStatusError, Message: err.Error()}) + } + continue + } + body := resp.ReadAllString() + var ydResp YdResp + if err = json.Unmarshal([]byte(body), &ydResp); err != nil { + g.Log().Errorf(ctx, "QuerySimDataUsageMonthly unmarshal error %+v,url %s, params %+v, body %s", err, url, params, body) + for _, simKey := range batch { + items = append(items, &model.SimBaseRes[model.SimDataUsageMonthlyOutputItem]{SimKey: simKey, Code: consts.ApiStatusError, Message: err.Error()}) + } + continue + } + if ydResp.Status != "0" { + g.Log().Warningf(ctx, "QuerySimDataUsageMonthly error %s,url %s, params %+v, body %s", ydResp.Message, url, params, body) + for _, simKey := range batch { + items = append(items, &model.SimBaseRes[model.SimDataUsageMonthlyOutputItem]{SimKey: simKey, Code: gconv.Int(ydResp.Status), Message: ydResp.Message}) + } + continue + } + + resultMap := make(map[*model.SimKey]struct { + DeadLine string + DataAmount string + }, len(batch)) + for _, result := range ydResp.Result { + for _, dataAmount := range result.DataAmountList { + var simKey *model.SimKey + if value := strings.TrimSpace(dataAmount.Iccid); value != "" { + simKey = simKeyMap["iccid:"+strings.ToUpper(value)] + } + if simKey == nil { + if value := strings.TrimSpace(dataAmount.Imsi); value != "" { + simKey = simKeyMap["imsi:"+strings.ToUpper(value)] + } + } + if simKey == nil { + if value := strings.TrimSpace(dataAmount.Msisdn); value != "" { + simKey = simKeyMap["msisdn:"+value] + } + } + if simKey == nil && len(batch) == 1 { + simKey = batch[0] + } + if simKey != nil { + resultMap[simKey] = struct { + DeadLine string + DataAmount string + }{DeadLine: result.DeadLine, DataAmount: dataAmount.DataAmount} + } + } + } + for _, simKey := range batch { + result, ok := resultMap[simKey] + if !ok { + items = append(items, &model.SimBaseRes[model.SimDataUsageMonthlyOutputItem]{SimKey: simKey, Code: gconv.Int(ydResp.Status), Message: "查询信息为空"}) + continue + } + items = append(items, &model.SimBaseRes[model.SimDataUsageMonthlyOutputItem]{ + SimKey: simKey, + Code: gconv.Int(ydResp.Status), + ResData: model.SimDataUsageMonthlyOutputItem{ + DeadLine: result.DeadLine, + DataAmount: gconv.Float64(strings.TrimSpace(result.DataAmount)) / 1024, + }, + }) + } + } + return items, nil +} + +// 物联卡单日 GPRS 流量使用量批量查询,单批最多 100 张卡 +// 上游返回 dataAmount 单位为 KB,对外统一转换为 MB +func (s *sSimYDMock) QuerySimDataUsageDaily(ctx context.Context, input *model.SimDataUsageDailyInput) (model.SimDataUsageDailyOutput, error) { + url := s.getUrl(ctx, "ec/query/sim-data-usage-daily/batch") + type YdResp struct { + Status string `json:"status"` + Message string `json:"message"` + Result []struct { + QueryDate string `json:"queryDate"` + DeadLine string `json:"deadLine"` + DataAmount string `json:"dataAmount"` + DataAmountList []struct { + QueryDate string `json:"queryDate"` + DeadLine string `json:"deadLine"` + Msisdn string `json:"msisdn"` + Iccid string `json:"iccid"` + Imsi string `json:"imsi"` + DataAmount string `json:"dataAmount"` + } `json:"dataAmountList"` + } `json:"result"` + } + items := make(model.SimDataUsageDailyOutput, 0, len(input.SimKeys)) + const batchSize = 100 + for start := 0; start < len(input.SimKeys); start += batchSize { + end := start + batchSize + if end > len(input.SimKeys) { + end = len(input.SimKeys) + } + batch := input.SimKeys[start:end] + iccids := make([]string, 0, len(batch)) + simKeyMap := make(map[string]*model.SimKey, len(batch)*3) + for _, simKey := range batch { + if simKey == nil { + iccids = append(iccids, "") + continue + } + iccids = append(iccids, strings.TrimSpace(simKey.ICCID)) + if value := strings.TrimSpace(simKey.ICCID); value != "" { + simKeyMap["iccid:"+strings.ToUpper(value)] = simKey + } + if value := strings.TrimSpace(simKey.IMSI); value != "" { + simKeyMap["imsi:"+strings.ToUpper(value)] = simKey + } + if value := strings.TrimSpace(simKey.MSISDN); value != "" { + simKeyMap["msisdn:"+value] = simKey + } + } + params := g.Map{ + "transid": s.getTransId(ctx), + "iccids": strings.Join(iccids, "_"), + "queryDate": input.ReqData.QueryDate, + } + resp, err := s.requestYd(ctx, "QuerySimDataUsageDaily", url, params) + if err != nil { + g.Log().Errorf(ctx, "QuerySimDataUsageDaily get error %+v,url %s, params %+v", err, url, params) + for _, simKey := range batch { + items = append(items, &model.SimBaseRes[model.SimDataUsageDailyOutputItem]{SimKey: simKey, Code: consts.ApiStatusError, Message: err.Error()}) + } + continue + } + body := resp.ReadAllString() + var ydResp YdResp + if err = json.Unmarshal([]byte(body), &ydResp); err != nil { + g.Log().Errorf(ctx, "QuerySimDataUsageDaily unmarshal error %+v,url %s, params %+v, body %s", err, url, params, body) + for _, simKey := range batch { + items = append(items, &model.SimBaseRes[model.SimDataUsageDailyOutputItem]{SimKey: simKey, Code: consts.ApiStatusError, Message: err.Error()}) + } + continue + } + if ydResp.Status != "0" { + g.Log().Warningf(ctx, "QuerySimDataUsageDaily error %s,url %s, params %+v, body %s", ydResp.Message, url, params, body) + for _, simKey := range batch { + items = append(items, &model.SimBaseRes[model.SimDataUsageDailyOutputItem]{SimKey: simKey, Code: gconv.Int(ydResp.Status), Message: ydResp.Message, ResData: model.SimDataUsageDailyOutputItem{QueryDate: input.ReqData.QueryDate}}) + } + continue + } + + resultMap := make(map[*model.SimKey]struct { + QueryDate string + DeadLine string + DataAmount string + }, len(batch)) + for _, result := range ydResp.Result { + if len(result.DataAmountList) == 0 { + if len(batch) == 1 { + resultMap[batch[0]] = struct { + QueryDate string + DeadLine string + DataAmount string + }{QueryDate: result.QueryDate, DeadLine: result.DeadLine, DataAmount: result.DataAmount} + } + continue + } + for _, dataAmount := range result.DataAmountList { + var simKey *model.SimKey + if value := strings.TrimSpace(dataAmount.Iccid); value != "" { + simKey = simKeyMap["iccid:"+strings.ToUpper(value)] + } + if simKey == nil { + if value := strings.TrimSpace(dataAmount.Imsi); value != "" { + simKey = simKeyMap["imsi:"+strings.ToUpper(value)] + } + } + if simKey == nil { + if value := strings.TrimSpace(dataAmount.Msisdn); value != "" { + simKey = simKeyMap["msisdn:"+value] + } + } + if simKey == nil && len(batch) == 1 { + simKey = batch[0] + } + if simKey != nil { + queryDate := dataAmount.QueryDate + if queryDate == "" { + queryDate = dataAmount.DeadLine + } + if queryDate == "" { + queryDate = result.QueryDate + } + if queryDate == "" { + queryDate = result.DeadLine + } + resultMap[simKey] = struct { + QueryDate string + DeadLine string + DataAmount string + }{QueryDate: queryDate, DeadLine: dataAmount.DeadLine, DataAmount: dataAmount.DataAmount} + } + } + } + for _, simKey := range batch { + result, ok := resultMap[simKey] + if !ok { + items = append(items, &model.SimBaseRes[model.SimDataUsageDailyOutputItem]{SimKey: simKey, Code: gconv.Int(ydResp.Status), Message: "查询信息为空", ResData: model.SimDataUsageDailyOutputItem{QueryDate: input.ReqData.QueryDate}}) + continue + } + queryDate := result.QueryDate + if queryDate == "" { + queryDate = result.DeadLine + } + if queryDate == "" { + queryDate = input.ReqData.QueryDate + } + items = append(items, &model.SimBaseRes[model.SimDataUsageDailyOutputItem]{ + SimKey: simKey, + Code: gconv.Int(ydResp.Status), + ResData: model.SimDataUsageDailyOutputItem{ + QueryDate: queryDate, + DataAmount: gconv.Float64(strings.TrimSpace(result.DataAmount)) / 1024, + }, + }) + } + } + return items, nil +} + +// 单卡已开通APN信息查询 +// https://api.iot.10086.cn/v5/ec/query/apn-info?transid=C5010531Af52022004082026060114500001000000&token=YOUR_TOKEN&iccid=898604F4152391115473 +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"}]}]} + type YdResp struct { + Status string `json:"status"` + Message string `json:"message"` + Result []struct { + ApnList []struct { + ApnName string `json:"apnName"` + Status string `json:"status"` + } `json:"apnList"` + } `json:"result"` + } + items := make(model.SimApnInfoOutput, 0) + for _, simKey := range input.SimKeys { + params := g.Map{ + "transid": s.getTransId(ctx), + "iccid": simKey.ICCID, + } + + resp, err := s.requestYd(ctx, "QuerySimApnInfo", url, params) + if err != nil { + g.Log().Errorf(ctx, "QuerySimApnInfo get error %+v,url %s, params %+v, ", err, url, params) + items = append(items, &model.SimBaseRes[model.SimApnInfoItemList]{ + SimKey: simKey, + Code: consts.ApiStatusError, + Message: err.Error(), + ResData: model.SimApnInfoItemList{ + ApnList: make([]model.SimApnInfoItem, 0), + }, + }) + continue + } + body := resp.ReadAllString() + var ydResp YdResp + err = json.Unmarshal([]byte(body), &ydResp) + if err != nil { + g.Log().Errorf(ctx, "QuerySimApnInfo unmarshal error %+v,url %s, params %+v, body %s, ", err, url, params, body) + items = append(items, &model.SimBaseRes[model.SimApnInfoItemList]{ + SimKey: simKey, + Code: consts.ApiStatusError, + Message: err.Error(), + ResData: model.SimApnInfoItemList{ + ApnList: make([]model.SimApnInfoItem, 0), + }, + }) + continue + } + if ydResp.Status != "0" { + g.Log().Warningf(ctx, "QuerySimApnInfo error %s,url %s, params %+v, body %s, ", ydResp.Message, url, params, body) + items = append(items, &model.SimBaseRes[model.SimApnInfoItemList]{ + SimKey: simKey, + Code: gconv.Int(ydResp.Status), + Message: ydResp.Message, + ResData: model.SimApnInfoItemList{ + ApnList: make([]model.SimApnInfoItem, 0), + }, + }) + continue + } + if len(ydResp.Result) == 0 { + items = append(items, &model.SimBaseRes[model.SimApnInfoItemList]{ + SimKey: simKey, + Code: gconv.Int(ydResp.Status), + Message: "查询信息为空", + ResData: model.SimApnInfoItemList{ + ApnList: make([]model.SimApnInfoItem, 0), + }, + }) + continue + } + if len(ydResp.Result) == 1 { + result := ydResp.Result[0] + if len(result.ApnList) != 0 { + for _, apn := range result.ApnList { + items = append(items, &model.SimBaseRes[model.SimApnInfoItemList]{ + SimKey: simKey, + Code: gconv.Int(ydResp.Status), + Message: "", + ResData: model.SimApnInfoItemList{ + ApnList: []model.SimApnInfoItem{ + { + ApnName: apn.ApnName, + Status: convertYdApnStatus(ctx, apn.Status), + }, + }, + }, + }) + } + } else { + g.Log().Errorf(ctx, "SimYDMock.QuerySimApnInfo, Error in returning information,ydResp: %+v, input: %+v, ", ydResp, input) + items = append(items, &model.SimBaseRes[model.SimApnInfoItemList]{ + SimKey: simKey, + Code: gconv.Int(ydResp.Status), + Message: "查询信息为空", + ResData: model.SimApnInfoItemList{ + ApnList: make([]model.SimApnInfoItem, 0), + }, + }) + continue + } + } else { + g.Log().Errorf(ctx, "SimYDMock.QuerySimApnInfo, Error in returning information,ydResp: %+v, input: %+v, ", ydResp, input) + items = append(items, &model.SimBaseRes[model.SimApnInfoItemList]{ + SimKey: simKey, + Code: gconv.Int(ydResp.Status), + Message: "查询信息为空", + ResData: model.SimApnInfoItemList{ + ApnList: make([]model.SimApnInfoItem, 0), + }, + }) + continue + } + } + return items, nil +} + +// 单卡状态变更 +// 平台 operType: 0=申请停机(已激活转已停机), 1=申请复机(已停机转已激活) +// https://api.iot.10086.cn/v5/ec/change/sim-status?transid=371AIOT20210605101712810732026031315120010000000&token=YOUR_TOKEN&msisdn=1441589159774&operType=0 +func (s *sSimYDMock) ChangeSimStatus(ctx context.Context, input *model.SimCardStatusChangeInput) (model.SimCardStatusChangeOutput, error) { + operType, ok := normalizeCardStatusChangeOperType(ctx, input.ReqData.OperType) + if !ok { + return newInvalidCardStatusChangeOutput(input.SimKeys), nil + } + + url := s.getUrl(ctx, "ec/change/sim-status") + // {"status":"0","message":"正确","result":[{"iccid":"898604F4152391115474","orderNum":"995000011515720993"}]} + type YdResp struct { + Status string `json:"status"` + Message string `json:"message"` + Result []struct { + Iccid string `json:"iccid"` + OrderNum string `json:"orderNum"` + } `json:"result"` + } + items := make(model.SimCardStatusChangeOutput, 0, len(input.SimKeys)) + for _, simKey := range input.SimKeys { + params := g.Map{ + "transid": s.getTransId(ctx), + "iccid": simKey.ICCID, + "operType": operType, + } + + resp, err := s.requestYd(ctx, "ChangeSimStatus", url, params) + if err != nil { + g.Log().Errorf(ctx, "ChangeSimStatus get error %+v,url %s, params %+v, ", err, url, params) + items = append(items, &model.SimBaseRes[model.SimCardStatusChangeItem]{ + SimKey: simKey, + Code: consts.ApiStatusError, + Message: err.Error(), + ResData: model.SimCardStatusChangeItem{ + OrderNum: "", + }, + }) + continue + } + body := resp.ReadAllString() + var ydResp YdResp + err = json.Unmarshal([]byte(body), &ydResp) + if err != nil { + g.Log().Errorf(ctx, "ChangeSimStatus unmarshal error %+v,url %s, params %+v, body %s, ", err, url, params, body) + items = append(items, &model.SimBaseRes[model.SimCardStatusChangeItem]{ + SimKey: simKey, + Code: consts.ApiStatusError, + Message: err.Error(), + ResData: model.SimCardStatusChangeItem{ + OrderNum: "", + }, + }) + continue + } + if ydResp.Status != "0" { + g.Log().Warningf(ctx, "ChangeSimStatus error %s,url %s, params %+v, body %s, ", ydResp.Message, url, params, body) + items = append(items, &model.SimBaseRes[model.SimCardStatusChangeItem]{ + SimKey: simKey, + Code: gconv.Int(ydResp.Status), + Message: ydResp.Message, + ResData: model.SimCardStatusChangeItem{ + OrderNum: "", + }, + }) + continue + } + if len(ydResp.Result) == 0 { + items = append(items, &model.SimBaseRes[model.SimCardStatusChangeItem]{ + SimKey: simKey, + Code: gconv.Int(ydResp.Status), + Message: "查询信息为空", + ResData: model.SimCardStatusChangeItem{ + OrderNum: "", + }, + }) + continue + } + if len(ydResp.Result) == 1 { + result := ydResp.Result[0] + if len(result.OrderNum) != 0 { + items = append(items, &model.SimBaseRes[model.SimCardStatusChangeItem]{ + SimKey: simKey, + Code: gconv.Int(ydResp.Status), + Message: "变更成功", + ResData: model.SimCardStatusChangeItem{ + OrderNum: result.OrderNum, + }, + }) + } + } else { + items = append(items, &model.SimBaseRes[model.SimCardStatusChangeItem]{ + SimKey: simKey, + Code: gconv.Int(ydResp.Status), + Message: "查询信息为空", + ResData: model.SimCardStatusChangeItem{ + OrderNum: "", + }, + }) + continue + } + } + return items, nil +} + +// 开通/停用移动数据(APN)功能(开通数据 dataStart / 停用数据 dataStop / 达量断网 dataCut)。 +// 2026 接口改版:已从旧的 ec/operate/sim-apn-function 迁移到 +// 7.2.1 单卡 sim-communication-service 与 7.2.2 批量 sim-communication-function/batch, +// 具体分流与编码转换见 sim_yd_apn_communication.go(与真实通道共用同一实现)。 +func (s *sSimYDMock) ChangeSimApnFunction(ctx context.Context, input *model.SimCardOperateChangeApnInput) (model.SimCardOperateChangeApnOutput, error) { + return changeSimApnFunctionYD(ctx, s, input) +} + +// 单卡机卡绑定状态查询 +// https://api.iot.10086.cn/v5/ec/query/card-bind-status +// 文档位置:中移物联网OneLink能力调用参考文档V25.11.1(CT)- 6.1.1 CMIOT_API23A04 +// 请求参数:msisdn、testType +// 上游返回 result 中的 bindStatus/cardBindStatus 作为机卡绑定状态 +func (s *sSimYDMock) QuerySimCardBindStatus(ctx context.Context, input *model.SimCardBindStatusInput) (model.SimCardBindStatusOutput, error) { + url := s.getUrl(ctx, "ec/query/card-bind-status") + + type ydResp struct { + Status string `json:"status"` + Message string `json:"message"` + Result []map[string]any `json:"result"` + } + + items := make(model.SimCardBindStatusOutput, 0, len(input.SimKeys)) + for _, simKey := range input.SimKeys { + msisdn := strings.TrimSpace(simKey.MSISDN) + if msisdn == "" { + g.Log().Warningf(ctx, "QuerySimCardBindStatus missing msisdn,url %s, simKey %+v, reqData %+v", url, simKey, input.ReqData) + items = append(items, &model.SimBaseRes[model.SimCardBindStatusItem]{ + SimKey: simKey, + Code: consts.ApiStatusError, + Message: "missing msisdn", + }) + continue + } + + testType := 1 + if input.ReqData.TestType != nil { + testType = *input.ReqData.TestType + } + + params := g.Map{ + "transid": s.getTransId(ctx), + "msisdn": msisdn, + "testType": testType, + } + + resp, err := s.requestYd(ctx, "QuerySimCardBindStatus", url, params) + if err != nil { + g.Log().Errorf(ctx, "QuerySimCardBindStatus get error %+v,url %s, params %+v", err, url, params) + items = append(items, &model.SimBaseRes[model.SimCardBindStatusItem]{ + SimKey: simKey, + Code: consts.ApiStatusError, + Message: err.Error(), + }) + continue + } + body := resp.ReadAllString() + + var respData ydResp + if err = json.Unmarshal([]byte(body), &respData); err != nil { + g.Log().Errorf(ctx, "QuerySimCardBindStatus unmarshal error %+v,url %s, params %+v, body %s", err, url, params, body) + items = append(items, &model.SimBaseRes[model.SimCardBindStatusItem]{ + SimKey: simKey, + Code: consts.ApiStatusError, + Message: err.Error(), + }) + continue + } + if respData.Status != "0" { + g.Log().Warningf(ctx, "QuerySimCardBindStatus error %s,url %s, params %+v, body %s", respData.Message, url, params, body) + items = append(items, &model.SimBaseRes[model.SimCardBindStatusItem]{ + SimKey: simKey, + Code: gconv.Int(respData.Status), + Message: respData.Message, + }) + continue + } + + bindStatus := "" + if len(respData.Result) > 0 { + bindStatus = pickYdCardBindStatus(respData.Result[0]) + } + if bindStatus == "" { + g.Log().Warningf(ctx, "QuerySimCardBindStatus empty bind status,url %s, params %+v, body %s", url, params, body) + items = append(items, &model.SimBaseRes[model.SimCardBindStatusItem]{ + SimKey: simKey, + Code: consts.ApiStatusError, + Message: "sim card bind status is empty", + }) + continue + } + + items = append(items, &model.SimBaseRes[model.SimCardBindStatusItem]{ + SimKey: simKey, + Code: gconv.Int(respData.Status), + Message: respData.Message, + ResData: model.SimCardBindStatusItem{ + BindStatus: bindStatus, + }, + }) + } + return items, nil +} + +// 单卡设备状态查询 +// https://api.iot.10086.cn/v5/ec/query/sim-operating-status?transid=xxx&token=xxx&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") + + type ydResp struct { + Status string `json:"status"` + Message string `json:"message"` + Result []map[string]any `json:"result"` + } + + items := make(model.SimDeviceStatusOutput, 0, len(input.SimKeys)) + for _, simKey := range input.SimKeys { + params := g.Map{ + "transid": s.getTransId(ctx), + } + paramName, paramValue := pickSimQueryParam(simKey) + if paramName == "" { + items = append(items, &model.SimBaseRes[model.SimDeviceStatusItem]{ + SimKey: simKey, + Code: consts.ApiStatusError, + Message: "missing imsi/iccid/msisdn", + }) + continue + } + params[paramName] = paramValue + + resp, err := s.requestYd(ctx, "QuerySimDeviceStatus", url, params) + if err != nil { + g.Log().Errorf(ctx, "QuerySimDeviceStatus get error %+v,url %s, params %+v", err, url, params) + items = append(items, &model.SimBaseRes[model.SimDeviceStatusItem]{ + SimKey: simKey, + Code: consts.ApiStatusError, + Message: err.Error(), + }) + continue + } + body := resp.ReadAllString() + + var respData ydResp + if err = json.Unmarshal([]byte(body), &respData); err != nil { + g.Log().Errorf(ctx, "QuerySimDeviceStatus unmarshal error %+v,url %s, params %+v, body %s", err, url, params, body) + items = append(items, &model.SimBaseRes[model.SimDeviceStatusItem]{ + SimKey: simKey, + Code: consts.ApiStatusError, + Message: err.Error(), + }) + continue + } + if respData.Status != "0" { + items = append(items, &model.SimBaseRes[model.SimDeviceStatusItem]{ + SimKey: simKey, + Code: gconv.Int(respData.Status), + Message: respData.Message, + }) + continue + } + + deviceStatus := consts.CardSessionStatusUnknown + operatingStatus := consts.CardSessionStatusUnknown + if len(respData.Result) > 0 { + result := respData.Result[0] + rawStatus := firstNonEmpty( + gconv.String(result["operatingStatus"]), + gconv.String(result["onOffStatus"]), + gconv.String(result["status"]), + ) + operatingStatus = mapYdOperatingStatus(rawStatus) + } + items = append(items, &model.SimBaseRes[model.SimDeviceStatusItem]{ + SimKey: simKey, + Code: gconv.Int(respData.Status), + Message: respData.Message, + ResData: model.SimDeviceStatusItem{ + DeviceStatus: deviceStatus, + OperatingStatus: operatingStatus, + }, + }) + } + return items, nil +} + +// 单卡地域限制状态查询 +// https://api.iot.10086.cn/v5/ec/query/region-limit-status +// 上游返回 result 中的 regionLimitStatus/status 作为地域限制状态 +func (s *sSimYDMock) QuerySimRegionLimitStatus(ctx context.Context, input *model.SimRegionLimitStatusInput) (model.SimRegionLimitStatusOutput, error) { + url := s.getUrl(ctx, "ec/query/region-limit-status") + + type ydResp struct { + Status string `json:"status"` + Message string `json:"message"` + Result []map[string]any `json:"result"` + } + + items := make(model.SimRegionLimitStatusOutput, 0, len(input.SimKeys)) + for _, simKey := range input.SimKeys { + params := g.Map{ + "transid": s.getTransId(ctx), + } + switch { + case strings.TrimSpace(simKey.ICCID) != "": + params["iccid"] = simKey.ICCID + case strings.TrimSpace(simKey.IMSI) != "": + params["imsi"] = simKey.IMSI + case strings.TrimSpace(simKey.MSISDN) != "": + params["msisdn"] = simKey.MSISDN + default: + items = append(items, &model.SimBaseRes[model.SimRegionLimitStatusItem]{ + SimKey: simKey, + Code: consts.ApiStatusError, + Message: "missing iccid/imsi/msisdn", + }) + continue + } + + resp, err := s.requestYd(ctx, "QuerySimRegionLimitStatus", url, params) + if err != nil { + g.Log().Errorf(ctx, "QuerySimRegionLimitStatus get error %+v,url %s, params %+v", err, url, params) + items = append(items, &model.SimBaseRes[model.SimRegionLimitStatusItem]{ + SimKey: simKey, + Code: consts.ApiStatusError, + Message: err.Error(), + }) + continue + } + body := resp.ReadAllString() + + var respData ydResp + if err = json.Unmarshal([]byte(body), &respData); err != nil { + g.Log().Errorf(ctx, "QuerySimRegionLimitStatus unmarshal error %+v,url %s, params %+v, body %s", err, url, params, body) + items = append(items, &model.SimBaseRes[model.SimRegionLimitStatusItem]{ + SimKey: simKey, + Code: consts.ApiStatusError, + Message: err.Error(), + }) + continue + } + if respData.Status != "0" { + items = append(items, &model.SimBaseRes[model.SimRegionLimitStatusItem]{ + SimKey: simKey, + Code: gconv.Int(respData.Status), + Message: respData.Message, + }) + continue + } + + regionLimitStatus := "" + if len(respData.Result) > 0 { + result := respData.Result[0] + regionLimitStatus = gconv.String(result["regionLimitStatus"]) + if regionLimitStatus == "" { + regionLimitStatus = gconv.String(result["status"]) + } + } + items = append(items, &model.SimBaseRes[model.SimRegionLimitStatusItem]{ + SimKey: simKey, + Code: gconv.Int(respData.Status), + Message: respData.Message, + ResData: model.SimRegionLimitStatusItem{ + RegionLimitStatus: regionLimitStatus, + }, + }) + } + return items, nil +} diff --git a/internal/logic/upstreamimpl/sim_yd_token.go b/internal/logic/upstreamimpl/sim_yd_token.go new file mode 100644 index 0000000..fe0ed06 --- /dev/null +++ b/internal/logic/upstreamimpl/sim_yd_token.go @@ -0,0 +1,224 @@ +package upstreamimpl + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "sync" + "time" + + "git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/model/entity" + "github.com/gogf/gf/v2/frame/g" +) + +const ydTokenInvalidCode = "12021" + +var errYdTokenUnchanged = errors.New("mobile token recovery returned the rejected token") + +type ydResponseMeta struct { + Status string `json:"status"` + Message string `json:"message"` +} + +type ydHTTPResponse struct{ body string } + +func (r *ydHTTPResponse) ReadAllString() string { return r.body } + +type ydRequestFunc func(params g.Map) (string, error) + +// ydTokenProvider is the construction seam for channels whose credential is +// shared outside t_sim_channels. Implementations own storage and refresh; +// RecoverToken must use compare-and-swap semantics for rejectedToken. +type ydTokenProvider interface { + GetToken(ctx context.Context) (string, error) + RecoverToken(ctx context.Context, rejectedToken string) (string, error) +} + +const ( + // expired_only reuses the current token until it actually expires. + ydTokenRefreshModeExpiredOnly = "expired_only" + // proactive enters the configured advance window before requesting a token. + ydTokenRefreshModeProactive = "proactive" + ydTokenDefaultSkewSeconds = int64(600) +) + +type ydTokenRefreshPolicy struct { + mode string + skewSecond int64 +} + +func loadYdTokenRefreshPolicy(ctx context.Context) ydTokenRefreshPolicy { + mode := g.Cfg().MustGet(ctx, "dependencies.token_refresh.mode", ydTokenRefreshModeExpiredOnly).String() + if mode != ydTokenRefreshModeExpiredOnly && mode != ydTokenRefreshModeProactive { + g.Log().Warningf(ctx, "unknown token refresh mode=%s, fallback to %s", mode, ydTokenRefreshModeExpiredOnly) + mode = ydTokenRefreshModeExpiredOnly + } + skew := g.Cfg().MustGet(ctx, "dependencies.token_refresh.skew_seconds", ydTokenDefaultSkewSeconds).Int64() + if skew < 0 { + g.Log().Warningf(ctx, "invalid token refresh skew_seconds=%d, fallback to 0", skew) + skew = 0 + } + return ydTokenRefreshPolicy{mode: mode, skewSecond: skew} +} + +func (p ydTokenRefreshPolicy) shouldRefresh(channel *entity.TSimChannels, now time.Time) bool { + if channel == nil || channel.AccessToken == "" || channel.AccessTokenExpire == nil { + return true + } + expire := channel.AccessTokenExpire.Time + if p.mode == ydTokenRefreshModeProactive { + return !now.Before(expire.Add(-time.Duration(p.skewSecond) * time.Second)) + } + return !now.Before(expire) +} + +var ( + ydTokenGuards sync.Map + ydTokenStates sync.Map // map[string]*ydTokenState +) + +type ydTokenState struct { + mu sync.RWMutex + token string + createdAt time.Time + expiresAt time.Time +} + +func ydTokenStateFor(chanKey string) *ydTokenState { + state, _ := ydTokenStates.LoadOrStore(chanKey, &ydTokenState{}) + return state.(*ydTokenState) +} + +func seedYdTokenState(channel *entity.TSimChannels) { + if channel == nil || channel.ChanKey == "" || channel.AccessToken == "" || channel.AccessTokenExpire == nil { + return + } + state := ydTokenStateFor(channel.ChanKey) + state.mu.Lock() + createdAt := time.Time{} + if channel.AccessTokenCreated != nil { + createdAt = channel.AccessTokenCreated.Time + } + if state.token == "" || createdAt.After(state.createdAt) { + state.token = channel.AccessToken + state.createdAt = createdAt + state.expiresAt = channel.AccessTokenExpire.Time + } + state.mu.Unlock() +} + +func currentYdToken(channel *entity.TSimChannels) (string, time.Time) { + if channel == nil { + return "", time.Time{} + } + seedYdTokenState(channel) + state := ydTokenStateFor(channel.ChanKey) + state.mu.RLock() + token, expiresAt := state.token, state.expiresAt + state.mu.RUnlock() + if token == "" || expiresAt.IsZero() || !time.Now().Before(expiresAt) { + return "", expiresAt + } + return token, expiresAt +} + +func updateYdTokenState(chanKey, token string, createdAt, expiresAt time.Time) { + state := ydTokenStateFor(chanKey) + state.mu.Lock() + state.token = token + state.createdAt = createdAt + state.expiresAt = expiresAt + state.mu.Unlock() +} + +func clearYdTokenStateIfCurrent(chanKey, rejected string) { + state := ydTokenStateFor(chanKey) + state.mu.Lock() + if state.token == rejected { + state.token = "" + state.createdAt = time.Time{} + state.expiresAt = time.Time{} + } + state.mu.Unlock() +} + +func withYdTokenGuard(chanKey string, fn func() string) string { + guard, _ := ydTokenGuards.LoadOrStore(chanKey, &sync.Mutex{}) + mu := guard.(*sync.Mutex) + mu.Lock() + defer mu.Unlock() + return fn() +} + +// validYdToken only checks actual expiry. Proactive refresh decisions belong +// to ydTokenRefreshPolicy so refresh=0 integrations can reuse the token until +// it really expires. +func validYdToken(channel *entity.TSimChannels, now time.Time) string { + if channel == nil || channel.AccessToken == "" || channel.AccessTokenExpire == nil { + return "" + } + if !now.Before(channel.AccessTokenExpire.Time) { + return "" + } + return channel.AccessToken +} + +func recoverYdToken(ctx context.Context, chanKey string, channel *entity.TSimChannels, rejectedToken string, refresh func(context.Context) string) (token string, err error) { + withYdTokenGuard(chanKey, func() string { + if channel == nil { + err = errors.New("mobile token recovery has no channel") + return "" + } + seedYdTokenState(channel) + if current, _ := currentYdToken(channel); current != "" && current != rejectedToken { + channel.AccessToken = current + token = current + return "" + } + clearYdTokenStateIfCurrent(chanKey, rejectedToken) + channel.AccessToken = "" + channel.AccessTokenExpire = nil + token = refresh(ctx) + if token == "" { + err = errors.New("mobile token recovery failed to acquire a token") + } else if token == rejectedToken { + err = errYdTokenUnchanged + } + return "" + }) + return token, err +} + +func doYdRequest(ctx context.Context, operation, chanKey string, getToken func(context.Context) string, recoverToken func(context.Context, string) (string, error), params g.Map, request ydRequestFunc) (*ydHTTPResponse, error) { + var lastBody string + for attempt := 0; attempt < 2; attempt++ { + token := getToken(ctx) + requestParams := make(g.Map, len(params)+1) + for key, value := range params { + requestParams[key] = value + } + requestParams["token"] = token + body, err := request(requestParams) + if err != nil { + return nil, err + } + lastBody = body + var meta ydResponseMeta + if err = json.Unmarshal([]byte(body), &meta); err != nil { + return nil, err + } + if meta.Status != ydTokenInvalidCode || attempt == 1 { + return &ydHTTPResponse{body: body}, nil + } + g.Log().Warningf(ctx, "%s token rejected, invalidate and retry once, chanKey:%s", operation, chanKey) + if _, err = recoverToken(ctx, token); err != nil { + if errors.Is(err, errYdTokenUnchanged) { + g.Log().Warningf(ctx, "%s token recovery returned the rejected token, stop retrying, chanKey:%s", operation, chanKey) + return &ydHTTPResponse{body: body}, nil + } + return nil, fmt.Errorf("%s token recovery failed: %w", operation, err) + } + } + return &ydHTTPResponse{body: lastBody}, nil +} diff --git a/internal/logic/upstreamimpl/sim_yunda.go b/internal/logic/upstreamimpl/sim_yunda.go new file mode 100644 index 0000000..edb6cc3 --- /dev/null +++ b/internal/logic/upstreamimpl/sim_yunda.go @@ -0,0 +1,495 @@ +package upstreamimpl + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "strconv" + "strings" + "time" + + "git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/consts" + "git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/logic/upstreamimpl/yunda" + "git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/model" + "git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/service" +) + +const yundaTemplateKey = "sim_yunda_v1" + +type sSimYunda struct { + channelConfig *model.SimChannelConfigItemCache + client *yunda.Client +} + +type yundaFloat float64 + +func (f *yundaFloat) UnmarshalJSON(data []byte) error { + var number float64 + if err := json.Unmarshal(data, &number); err == nil { + *f = yundaFloat(number) + return nil + } + var text string + if err := json.Unmarshal(data, &text); err != nil { + return err + } + if text == "" { + *f = 0 + return nil + } + value, err := strconv.ParseFloat(text, 64) + if err != nil { + return err + } + *f = yundaFloat(value) + return nil +} + +type yundaCards []yundaCard + +func (cards *yundaCards) UnmarshalJSON(data []byte) error { + data = bytes.TrimSpace(data) + if len(data) == 0 || bytes.Equal(data, []byte("null")) { + *cards = nil + return nil + } + if data[0] == '[' { + return json.Unmarshal(data, (*[]yundaCard)(cards)) + } + var card yundaCard + if err := json.Unmarshal(data, &card); err != nil { + return err + } + *cards = []yundaCard{card} + return nil +} + +type yundaCard struct { + MSISDN string `json:"msisdn"` + ICCID string `json:"iccid"` + IMEI string `json:"imei"` + IMSI string `json:"imsi"` + Code string `json:"code"` + Error string `json:"error"` + Status string `json:"status"` + GPRSStatus string `json:"gprsStatus"` + GPRSStatusLower string `json:"gprsstatus"` + IP string `json:"ip"` + RAT string `json:"rat"` + StatTimestamp string `json:"statustimestamp"` + OpenTime string `json:"opentime"` + PackageID string `json:"packageid"` + PackageName string `json:"packagename"` + LifecycleStatus string `json:"lifecycleStatus"` + LifecycleStatusLower string `json:"lifecyclestatus"` + APNList []yundaAPN `json:"apnlist"` + APN string `json:"apn"` +} + +type yundaAPN struct { + APNName string `json:"apnname"` + UsedFlow yundaFloat `json:"usedflow"` + DailyList []yundaDailyUse `json:"dailylist"` +} + +type yundaDailyUse struct { + UsedFlow yundaFloat `json:"usedflow"` + Date string `json:"date"` +} + +type yundaConfig struct { + BaseURL string `json:"baseUrl"` + Timeout string `json:"timeout"` +} + +type yundaConfigEnvelope struct { + API yundaConfig `json:"api"` + BaseURL string `json:"baseUrl"` + Timeout string `json:"timeout"` +} + +func NewSimYunda() *sSimYunda { return &sSimYunda{} } + +func init() { + service.UpstreamAPI().RegisterTemp(yundaTemplateKey, NewSimYunda()) +} + +func (s *sSimYunda) NewTemp(ctx context.Context, channelConfig *model.SimChannelConfigItemCache) model.SimCardChannelI { + cfg := yundaConfigEnvelope{} + if channelConfig != nil && channelConfig.Template != nil { + _ = json.Unmarshal([]byte(channelConfig.Template.TempConfig), &cfg) + } + if channelConfig != nil && channelConfig.Channel != nil && strings.TrimSpace(channelConfig.Channel.ChanConfig) != "" { + var channelCfg yundaConfigEnvelope + if json.Unmarshal([]byte(channelConfig.Channel.ChanConfig), &channelCfg) == nil { + if channelCfg.API.BaseURL != "" { + cfg.API.BaseURL = channelCfg.API.BaseURL + } + if channelCfg.BaseURL != "" { + cfg.BaseURL = channelCfg.BaseURL + } + if channelCfg.API.Timeout != "" { + cfg.API.Timeout = channelCfg.API.Timeout + } + if channelCfg.Timeout != "" { + cfg.Timeout = channelCfg.Timeout + } + } + } + baseURL := cfg.API.BaseURL + if baseURL == "" { + baseURL = cfg.BaseURL + } + if baseURL == "" { + baseURL = "http://api.yundaiot.cn/v1" + } + timeout := 10 * time.Second + if cfg.API.Timeout != "" { + if parsed, err := time.ParseDuration(cfg.API.Timeout); err == nil { + timeout = parsed + } + } + if cfg.Timeout != "" { + if parsed, err := time.ParseDuration(cfg.Timeout); err == nil { + timeout = parsed + } + } + appKey, appSecret := "", "" + if channelConfig != nil && channelConfig.Channel != nil { + appKey = channelConfig.Channel.AppKey + appSecret = channelConfig.Channel.AppSecret + } + return &sSimYunda{channelConfig: channelConfig, client: yunda.NewClient(yunda.Config{BaseURL: baseURL, AppKey: appKey, AppSecret: appSecret, Timeout: timeout})} +} + +func yundaCardValue(key *model.SimKey, field string) string { + if key == nil { + return "" + } + switch field { + case "iccid": + return key.ICCID + case "msisdn": + return key.MSISDN + case "imsi": + return key.IMSI + } + return "" +} + +func (s *sSimYunda) clientPost(ctx context.Context, path string, params map[string]string, out *yundaCards) error { + _, err := s.client.Post(ctx, path, params, out) + return err +} + +func (s *sSimYunda) requestCard(ctx context.Context, path string, key *model.SimKey, fields map[string]string, out *yundaCards) error { + if key == nil { + return errors.New("sim key is required") + } + params := map[string]string{} + for name, field := range fields { + if value := yundaCardValue(key, field); value != "" { + params[name] = value + } + } + if len(params) == 0 { + return errors.New("iccid, msisdn or imsi is required") + } + return s.clientPost(ctx, path, params, out) +} + +func (s *sSimYunda) QuerySimBasicInfo(ctx context.Context, input *model.SimBasicInfoInput) (model.SimBasicInfoOutput, error) { + items := make(model.SimBasicInfoOutput, 0, len(input.SimKeys)) + for _, key := range input.SimKeys { + var result yundaCards + err := s.requestCard(ctx, "BatchGetSimInfo", key, map[string]string{"iccids": "iccid", "msisdns": "msisdn", "imsis": "imsi"}, &result) + card := firstYundaCard(result) + if err != nil || card == nil { + items = append(items, yundaBasicError(key, errOr(err, "yunda returned empty card info"))) + continue + } + items = append(items, &model.SimBaseRes[model.SimBasicInfoItem]{Code: consts.ApiStatusSuccess, SimKey: key, ResData: model.SimBasicInfoItem{ICCID: card.ICCID, IMSI: card.IMSI, MSISDN: card.MSISDN, OpenDate: card.OpenTime}}) + } + return items, nil +} + +func (s *sSimYunda) QuerySimImei(ctx context.Context, input *model.SimImeiInput) (model.SimImeiOutput, error) { + items := make(model.SimImeiOutput, 0, len(input.SimKeys)) + for _, key := range input.SimKeys { + var result yundaCards + err := s.requestCard(ctx, "BatchGetSimInfo", key, map[string]string{"iccids": "iccid", "msisdns": "msisdn", "imsis": "imsi"}, &result) + card := firstYundaCard(result) + if err != nil || card == nil { + items = append(items, &model.SimBaseRes[model.SimImeiItem]{Code: consts.ApiStatusError, SimKey: key, Message: errMessage(err, "yunda returned empty card info")}) + continue + } + items = append(items, &model.SimBaseRes[model.SimImeiItem]{Code: consts.ApiStatusSuccess, SimKey: key, ResData: model.SimImeiItem{Imei: card.IMEI}}) + } + return items, nil +} + +func (s *sSimYunda) QuerySimCardStatus(ctx context.Context, input *model.SimCardStatusInput) (model.SimCardStatusOutput, error) { + items := make(model.SimCardStatusOutput, 0, len(input.SimKeys)) + for _, key := range input.SimKeys { + var result yundaCards + err := s.requestCard(ctx, "BatchGetSimStatus", key, map[string]string{"iccids": "iccid", "msisdns": "msisdn"}, &result) + card := firstYundaCard(result) + if err != nil || card == nil { + items = append(items, &model.SimBaseRes[model.SimCardStatusItem]{Code: consts.ApiStatusError, SimKey: key, Message: errMessage(err, "yunda returned empty card status")}) + continue + } + status, err := mapYundaCardStatus(card.Status) + if err != nil { + items = append(items, &model.SimBaseRes[model.SimCardStatusItem]{Code: consts.ApiStatusError, SimKey: key, Message: err.Error()}) + continue + } + items = append(items, &model.SimBaseRes[model.SimCardStatusItem]{Code: consts.ApiStatusSuccess, SimKey: key, ResData: model.SimCardStatusItem{CardStatus: status}}) + } + return items, nil +} + +// Yunda lifecycle codes: 0 testing, 1 dormant, 2 inventory, 3 in use, 4 stopped, 5 cancelled. +func mapYundaCardStatus(value string) (int, error) { + switch strings.TrimSpace(value) { + case "0", "测试期": + return 6, nil + case "1", "沉默期": + return 1, nil + case "2", "库存期": + return 7, nil + case "3", "正使用": + return 2, nil + case "4", "已停机": + return 4, nil + case "5", "已销户": + return 9, nil + default: + return 0, fmt.Errorf("yunda: unsupported card status %q", value) + } +} + +func (s *sSimYunda) QuerySimDataUsage(ctx context.Context, input *model.SimDataUsageInput) (model.SimDataUsageOutput, error) { + items := make(model.SimDataUsageOutput, 0, len(input.SimKeys)) + for _, key := range input.SimKeys { + var result yundaCards + err := s.requestCard(ctx, "BatchGetCurrentMonthGprs", key, map[string]string{"iccids": "iccid", "msisdns": "msisdn"}, &result) + card := firstYundaCard(result) + if err != nil || card == nil { + items = append(items, &model.SimBaseRes[model.SimDataUsageItem]{Code: consts.ApiStatusError, SimKey: key, Message: errMessage(err, "yunda returned empty usage")}) + continue + } + items = append(items, &model.SimBaseRes[model.SimDataUsageItem]{Code: consts.ApiStatusSuccess, SimKey: key, ResData: model.SimDataUsageItem{DataUsage: firstYundaUsage(card)}}) + } + return items, nil +} + +func (s *sSimYunda) QuerySimDataUsageMonthly(ctx context.Context, input *model.SimDataUsageMonthlyInput) (model.SimDataUsageMonthlyOutput, error) { + items := make(model.SimDataUsageMonthlyOutput, 0, len(input.SimKeys)) + for _, key := range input.SimKeys { + var result yundaCards + params := map[string]string{"month": input.ReqData.QueryDate} + if key != nil { + params["iccids"] = key.ICCID + params["msisdns"] = key.MSISDN + } + err := s.clientPost(ctx, "BatchGetSimMonthGPRS", params, &result) + card := firstYundaCard(result) + if err != nil || card == nil { + items = append(items, &model.SimBaseRes[model.SimDataUsageMonthlyOutputItem]{Code: consts.ApiStatusError, SimKey: key, Message: errMessage(err, "yunda returned empty monthly usage"), ResData: model.SimDataUsageMonthlyOutputItem{DeadLine: input.ReqData.QueryDate}}) + continue + } + items = append(items, &model.SimBaseRes[model.SimDataUsageMonthlyOutputItem]{Code: consts.ApiStatusSuccess, SimKey: key, ResData: model.SimDataUsageMonthlyOutputItem{DeadLine: input.ReqData.QueryDate, DataAmount: firstYundaUsage(card)}}) + } + return items, nil +} + +func (s *sSimYunda) QuerySimDataUsageDaily(ctx context.Context, input *model.SimDataUsageDailyInput) (model.SimDataUsageDailyOutput, error) { + items := make(model.SimDataUsageDailyOutput, 0, len(input.SimKeys)) + for _, key := range input.SimKeys { + var result yundaCards + month, date, dateErr := yundaMonthAndDate(input.ReqData.QueryDate) + if dateErr != nil { + items = append(items, &model.SimBaseRes[model.SimDataUsageDailyOutputItem]{Code: consts.ApiStatusError, SimKey: key, Message: dateErr.Error()}) + continue + } + params := map[string]string{"month": month, "date": date} + if key != nil { + params["iccids"] = key.ICCID + params["msisdns"] = key.MSISDN + } + err := s.clientPost(ctx, "BatchGetSimDayGPRS", params, &result) + card := firstYundaCard(result) + amount, date := firstYundaDailyUsage(card, date) + if err != nil || card == nil { + items = append(items, &model.SimBaseRes[model.SimDataUsageDailyOutputItem]{Code: consts.ApiStatusError, SimKey: key, Message: errMessage(err, "yunda returned empty daily usage"), ResData: model.SimDataUsageDailyOutputItem{QueryDate: date}}) + continue + } + items = append(items, &model.SimBaseRes[model.SimDataUsageDailyOutputItem]{Code: consts.ApiStatusSuccess, SimKey: key, ResData: model.SimDataUsageDailyOutputItem{QueryDate: date, DataAmount: amount}}) + } + return items, nil +} + +func (s *sSimYunda) QuerySimSession(ctx context.Context, input *model.SimSessionInput) (model.SimSessionOutput, error) { + items := make(model.SimSessionOutput, 0, len(input.SimKeys)) + for _, key := range input.SimKeys { + keyword := yundaCardValue(key, "iccid") + if keyword == "" { + keyword = yundaCardValue(key, "msisdn") + } + if keyword == "" { + keyword = yundaCardValue(key, "imsi") + } + var result yundaCards + err := s.clientPost(ctx, "SimOnlineQuery", map[string]string{"keyword": keyword}, &result) + card := firstYundaCard(result) + if err != nil || card == nil { + items = append(items, &model.SimBaseRes[model.SimSessionItem]{Code: consts.ApiStatusError, SimKey: key, Message: errMessage(err, "yunda returned empty online status")}) + continue + } + status := "-1" + if card.GPRSStatus == "01" || card.GPRSStatusLower == "01" { + status = "0" + } + items = append(items, &model.SimBaseRes[model.SimSessionItem]{Code: consts.ApiStatusSuccess, SimKey: key, ResData: model.SimSessionItem{ApnId: card.APN, Imei: card.IMEI, Rat: card.RAT, Ipv4: card.IP, Status: status, CreateTime: card.StatTimestamp}}) + } + return items, nil +} + +func (s *sSimYunda) QuerySimDeviceStatus(ctx context.Context, input *model.SimDeviceStatusInput) (model.SimDeviceStatusOutput, error) { + items := make(model.SimDeviceStatusOutput, 0, len(input.SimKeys)) + for _, key := range input.SimKeys { + var result yundaCards + err := s.requestCard(ctx, "BatchGetSimGPRSStatus", key, map[string]string{"iccids": "iccid", "msisdns": "msisdn"}, &result) + card := firstYundaCard(result) + if err != nil || card == nil { + items = append(items, &model.SimBaseRes[model.SimDeviceStatusItem]{Code: consts.ApiStatusError, SimKey: key, Message: errMessage(err, "yunda returned empty GPRS status")}) + continue + } + status := card.GPRSStatus + if status == "" { + status = card.GPRSStatusLower + } + items = append(items, &model.SimBaseRes[model.SimDeviceStatusItem]{Code: consts.ApiStatusSuccess, SimKey: key, ResData: model.SimDeviceStatusItem{DeviceStatus: status}}) + } + return items, nil +} + +func yundaUnsupported[T any](items []*model.SimBaseRes[T], keys []*model.SimKey, operation string) ([]*model.SimBaseRes[T], error) { + for _, key := range keys { + items = append(items, &model.SimBaseRes[T]{Code: consts.ApiStatusError, SimKey: key, Message: fmt.Sprintf("yunda does not support %s yet", operation)}) + } + return items, nil +} +func (s *sSimYunda) QuerySimStopReason(ctx context.Context, input *model.SimStopReasonInput) (model.SimStopReasonOutput, error) { + return yundaUnsupported[model.SimStopReasonItem](nil, input.SimKeys, "stop reason query") +} +func (s *sSimYunda) QuerySimFrozenState(ctx context.Context, input *model.SimFrozenStateInput) (model.SimFrozenStateOutput, error) { + return yundaUnsupported[model.SimFrozenStateItem](nil, input.SimKeys, "frozen state query") +} +func (s *sSimYunda) QuerySimDataDiagnosis(ctx context.Context, input *model.SimDataDiagnosisInput) (model.SimDataDiagnosisOutput, error) { + return yundaUnsupported[any](nil, input.SimKeys, "data diagnosis") +} +func (s *sSimYunda) QuerySimApnInfo(ctx context.Context, input *model.SimApnInfoInput) (model.SimApnInfoOutput, error) { + items := make(model.SimApnInfoOutput, 0, len(input.SimKeys)) + for _, key := range input.SimKeys { + keyword := yundaCardValue(key, "iccid") + if keyword == "" { + keyword = yundaCardValue(key, "msisdn") + } + if keyword == "" { + keyword = yundaCardValue(key, "imsi") + } + var result yundaCards + err := s.clientPost(ctx, "SimOnlineQuery", map[string]string{"keyword": keyword}, &result) + card := firstYundaCard(result) + if err != nil || card == nil { + items = append(items, &model.SimBaseRes[model.SimApnInfoItemList]{Code: consts.ApiStatusError, SimKey: key, Message: errMessage(err, "yunda returned empty APN info")}) + continue + } + status := "0" + if strings.TrimSpace(card.APN) != "" { + status = "1" + } + items = append(items, &model.SimBaseRes[model.SimApnInfoItemList]{Code: consts.ApiStatusSuccess, SimKey: key, ResData: model.SimApnInfoItemList{ApnList: []model.SimApnInfoItem{{ApnName: card.APN, Status: status}}}}) + } + return items, nil +} +func (s *sSimYunda) ChangeSimStatus(ctx context.Context, input *model.SimCardStatusChangeInput) (model.SimCardStatusChangeOutput, error) { + return yundaUnsupported[model.SimCardStatusChangeItem](nil, input.SimKeys, "status change") +} +func (s *sSimYunda) ChangeSimApnFunction(ctx context.Context, input *model.SimCardOperateChangeApnInput) (model.SimCardOperateChangeApnOutput, error) { + return yundaUnsupported[model.SimCardOperateChangeApnItem](nil, input.SimKeys, "APN change") +} +func (s *sSimYunda) QuerySimCardBindStatus(ctx context.Context, input *model.SimCardBindStatusInput) (model.SimCardBindStatusOutput, error) { + items := make(model.SimCardBindStatusOutput, 0, len(input.SimKeys)) + for _, key := range input.SimKeys { + var result yundaCards + err := s.requestCard(ctx, "BatchGetSimInfo", key, map[string]string{"iccids": "iccid", "msisdns": "msisdn", "imsis": "imsi"}, &result) + card := firstYundaCard(result) + if err != nil || card == nil { + items = append(items, &model.SimBaseRes[model.SimCardBindStatusItem]{Code: consts.ApiStatusError, SimKey: key, Message: errMessage(err, "yunda returned empty card info")}) + continue + } + bindStatus, bindingType := "0", "sim" + if strings.TrimSpace(card.IMEI) != "" { + bindStatus, bindingType = "1", "imei" + } + items = append(items, &model.SimBaseRes[model.SimCardBindStatusItem]{Code: consts.ApiStatusSuccess, SimKey: key, ResData: model.SimCardBindStatusItem{BindStatus: bindStatus, CardBindingType: bindingType}}) + } + return items, nil +} +func (s *sSimYunda) QuerySimRegionLimitStatus(ctx context.Context, input *model.SimRegionLimitStatusInput) (model.SimRegionLimitStatusOutput, error) { + return yundaUnsupported[model.SimRegionLimitStatusItem](nil, input.SimKeys, "region limit query") +} + +func firstYundaCard(cards yundaCards) *yundaCard { + if len(cards) == 0 { + return nil + } + return &cards[0] +} +func firstYundaUsage(card *yundaCard) float64 { + if card == nil || len(card.APNList) == 0 { + return 0 + } + return float64(card.APNList[0].UsedFlow) +} +func firstYundaDailyUsage(card *yundaCard, fallback string) (float64, string) { + if card == nil || len(card.APNList) == 0 || len(card.APNList[0].DailyList) == 0 { + return 0, fallback + } + item := card.APNList[0].DailyList[0] + if item.Date == "" { + item.Date = fallback + } + return float64(item.UsedFlow), item.Date +} +func yundaBasicError(key *model.SimKey, err error) *model.SimBaseRes[model.SimBasicInfoItem] { + return &model.SimBaseRes[model.SimBasicInfoItem]{Code: consts.ApiStatusError, SimKey: key, Message: err.Error()} +} +func errOr(err error, fallback string) error { + if err != nil { + return err + } + return errors.New(fallback) +} + +func errMessage(err error, fallback string) string { + if err != nil { + return err.Error() + } + return fallback +} + +func yundaMonthAndDate(value string) (string, string, error) { + value = strings.TrimSpace(value) + for _, layout := range []string{"20060102", "2006-01-02"} { + parsed, err := time.Parse(layout, value) + if err == nil { + return parsed.Format("2006-01"), parsed.Format("2006-01-02"), nil + } + } + return "", "", fmt.Errorf("invalid daily queryDate: %s", value) +} diff --git a/internal/logic/upstreamimpl/sim_yunda_test.go b/internal/logic/upstreamimpl/sim_yunda_test.go new file mode 100644 index 0000000..81a54a9 --- /dev/null +++ b/internal/logic/upstreamimpl/sim_yunda_test.go @@ -0,0 +1,64 @@ +package upstreamimpl + +import ( + "context" + "testing" +) + +func TestYundaMonthAndDate(t *testing.T) { + tests := []struct { + input string + month string + date string + }{ + {input: "20260921", month: "2026-09", date: "2026-09-21"}, + {input: "2026-09-21", month: "2026-09", date: "2026-09-21"}, + } + for _, test := range tests { + month, date, err := yundaMonthAndDate(test.input) + if err != nil || month != test.month || date != test.date { + t.Fatalf("input=%s month=%s date=%s err=%v", test.input, month, date, err) + } + } +} + +func TestYundaMonthAndDateRejectsInvalidValue(t *testing.T) { + if _, _, err := yundaMonthAndDate("202609"); err == nil { + t.Fatal("expected invalid daily date") + } +} + +func TestMapYundaCardStatus(t *testing.T) { + for _, tc := range []struct { + input string + want int + }{ + {"0", 6}, {"1", 1}, {"2", 7}, {"3", 2}, {"正使用", 2}, {"4", 4}, {"已停机", 4}, {"5", 9}, {"已销户", 9}, + } { + got, err := mapYundaCardStatus(tc.input) + if err != nil || got != tc.want { + t.Fatalf("mapYundaCardStatus(%q) = %d, %v; want %d", tc.input, got, err, tc.want) + } + } + if _, err := mapYundaCardStatus("unknown"); err == nil { + t.Fatal("expected unsupported Yunda status to return an error") + } +} + +func TestTelecomCardStatusSourcesDiffer(t *testing.T) { + if got := convertDxCardStatus(context.Background(), "3"); got != 7 { + t.Fatalf("single-card product state 3 = %d, want inventory 7", got) + } + if got := convertDXBatchCardStatus(context.Background(), "3"); got != 6 { + t.Fatalf("BQ018 test deactivation 3 = %d, want testable 6", got) + } + if got := mapDXBatchInfoToBasicInfo(dxBatchInfoItem{SimStatus: "4"}).SimStatus; got != "2" { + t.Fatalf("BQ018 in-use state 4 = %q, want activated 2", got) + } +} + +func TestMapDXLegacyBasicStatusKeepsMissingValueEmpty(t *testing.T) { + if got := mapDXLegacyBasicStatus(context.Background(), ""); got != "" { + t.Fatalf("missing telecom status = %q, want empty", got) + } +} diff --git a/internal/logic/upstreamimpl/template_models.go b/internal/logic/upstreamimpl/template_models.go new file mode 100644 index 0000000..29db117 --- /dev/null +++ b/internal/logic/upstreamimpl/template_models.go @@ -0,0 +1,26 @@ +package upstreamimpl + +import ( + "context" + "git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/model" + "git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/service" +) + +const ( + templateModelDirect = "direct" + templateModelSharedToken = "shared_token" +) + +// Model registrations are deliberately separate from concrete temp_key +// registrations. This lets multiple source-specific templates share one +// implementation while keeping existing exact-key behavior unchanged. +func init() { + api := service.UpstreamAPI() + // direct is a classification value, not a generic implementation. Direct + // protocols must register their concrete temp_key (for example sim_yd_v5). + // shared_token is the only model-backed implementation because multiple + // source-specific templates share the OneLink business API. + api.RegisterModel(templateModelSharedToken, func(ctx context.Context, cfg *model.SimChannelConfigItemCache) model.SimCardChannelI { + return NewSimOneLinkShared().NewTemp(ctx, cfg) + }) +} diff --git a/internal/logic/upstreamimpl/tengyu_token_provider.go b/internal/logic/upstreamimpl/tengyu_token_provider.go new file mode 100644 index 0000000..36b2398 --- /dev/null +++ b/internal/logic/upstreamimpl/tengyu_token_provider.go @@ -0,0 +1,164 @@ +package upstreamimpl + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "time" +) + +const ( + tengyuOneLinkTokenPath = "/prod-api/open/api/oneLinkToken" + tengyuTokenTTL = time.Hour +) + +// OneLinkToken is the value returned by a shared-token fetcher. +type OneLinkToken struct { + Value string + CreatedAt time.Time + ExpiresAt time.Time +} + +// TokenFetcher mirrors the token core fetch boundary. chanKey is the +// protocol's c_no; it is never inferred from unrelated channel fields. +type TokenFetcher interface { + FetchToken(context.Context, string) (OneLinkToken, error) +} + +// TokenProvider is the consumer-facing token core boundary. +type TokenProvider interface { + GetToken(context.Context, string) (string, error) +} + +// TengyuTokenConfig contains all values required by the Tengyu protocol. +// The provider intentionally does not infer these values from channel config. +type TengyuTokenConfig struct { + BaseURL string + Path string + PwdKey string + ChannelNo string + Timeout time.Duration +} + +// TengyuTokenProvider fetches a Tengyu shared OneLink token. +type TengyuTokenProvider struct { + baseURL string + path string + pwdKey string + channelNo string + client *http.Client +} + +type tengyuTokenResponse struct { + Code int `json:"code"` + Msg string `json:"msg"` + Data json.RawMessage `json:"data"` +} + +func NewTengyuTokenProvider(config TengyuTokenConfig) *TengyuTokenProvider { + return &TengyuTokenProvider{ + baseURL: strings.TrimRight(strings.TrimSpace(config.BaseURL), "/"), + path: normalizeProviderPath(config.Path, tengyuOneLinkTokenPath), + pwdKey: config.PwdKey, + channelNo: config.ChannelNo, + client: newTengyuHTTPClient(config.Timeout), + } +} + +// Historical in-package names are retained as a small adapter for callers +// that constructed the provider before the token-core interfaces were named. +type tengyuTokenConfig = TengyuTokenConfig + +func newTengyuTokenProvider(config tengyuTokenConfig) *TengyuTokenProvider { + return NewTengyuTokenProvider(config) +} + +func newTengyuHTTPClient(timeout time.Duration) *http.Client { + if timeout <= 0 { + timeout = defaultUpstreamHTTPTimeout + } + return &http.Client{Timeout: timeout} +} + +func (p *TengyuTokenProvider) Fetch(ctx context.Context) (OneLinkToken, error) { + return p.fetch(ctx, p.channelNo) +} + +func (p *TengyuTokenProvider) FetchToken(ctx context.Context, channelNo string) (OneLinkToken, error) { + if strings.TrimSpace(channelNo) == "" { + channelNo = p.channelNo + } + return p.fetch(ctx, channelNo) +} + +func (p *TengyuTokenProvider) GetToken(ctx context.Context, channelNo string) (string, error) { + token, err := p.FetchToken(ctx, channelNo) + if err != nil { + return "", err + } + return token.Value, nil +} + +func (p *TengyuTokenProvider) fetch(ctx context.Context, channelNo string) (OneLinkToken, error) { + if p == nil || p.client == nil { + return OneLinkToken{}, fmt.Errorf("tengyu token provider is not initialized") + } + if p.baseURL == "" { + return OneLinkToken{}, fmt.Errorf("tengyu token base URL is required") + } + if strings.TrimSpace(p.pwdKey) == "" { + return OneLinkToken{}, fmt.Errorf("tengyu pwdkey is required") + } + if strings.TrimSpace(channelNo) == "" { + return OneLinkToken{}, fmt.Errorf("tengyu channel number is required") + } + + endpoint, err := url.Parse(joinProviderURL(p.baseURL, p.path)) + if err != nil { + return OneLinkToken{}, fmt.Errorf("parse Tengyu token URL: %w", err) + } + query := endpoint.Query() + query.Set("pwdkey", p.pwdKey) + query.Set("c_no", channelNo) + endpoint.RawQuery = query.Encode() + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint.String(), nil) + if err != nil { + return OneLinkToken{}, fmt.Errorf("create Tengyu token request: %w", err) + } + req.Header.Set("Accept", "application/json") + resp, err := p.client.Do(req) + if err != nil { + return OneLinkToken{}, fmt.Errorf("request Tengyu token: %w", err) + } + defer resp.Body.Close() + body, err := io.ReadAll(resp.Body) + if err != nil { + return OneLinkToken{}, fmt.Errorf("read Tengyu token response: %w", err) + } + if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices { + return OneLinkToken{}, fmt.Errorf("Tengyu token HTTP status %d: %s", resp.StatusCode, strings.TrimSpace(string(body))) + } + + var output tengyuTokenResponse + if err := json.Unmarshal(body, &output); err != nil { + return OneLinkToken{}, fmt.Errorf("decode Tengyu token response: %w", err) + } + if output.Code != http.StatusOK { + return OneLinkToken{}, fmt.Errorf("Tengyu token failed: code=%d, message=%s", output.Code, output.Msg) + } + var value string + if err := json.Unmarshal(output.Data, &value); err != nil { + return OneLinkToken{}, fmt.Errorf("decode Tengyu shared token: %w", err) + } + if strings.TrimSpace(value) == "" { + return OneLinkToken{}, fmt.Errorf("Tengyu shared token is empty") + } + + now := time.Now() + return OneLinkToken{Value: value, CreatedAt: now, ExpiresAt: now.Add(tengyuTokenTTL)}, nil +} diff --git a/internal/logic/upstreamimpl/tibiot_rate_limiter.go b/internal/logic/upstreamimpl/tibiot_rate_limiter.go new file mode 100644 index 0000000..a90f298 --- /dev/null +++ b/internal/logic/upstreamimpl/tibiot_rate_limiter.go @@ -0,0 +1,68 @@ +package upstreamimpl + +import ( + "context" + "sync" + "time" +) + +const tibiotSimDataMarginInterval = 650 * time.Millisecond + +// tibiotSimDataMarginLimiter spaces requests globally because Tibiot applies +// the simDataMargin quota to the gateway's public IP, not to a channel. +var tibiotSimDataMarginLimiter = newTibiotRateLimiter(tibiotSimDataMarginInterval) + +type tibiotRateLimiter struct { + mu sync.Mutex + interval time.Duration + next time.Time +} + +func newTibiotRateLimiter(interval time.Duration) *tibiotRateLimiter { + if interval <= 0 { + interval = tibiotSimDataMarginInterval + } + return &tibiotRateLimiter{interval: interval} +} + +// wait blocks until a slot is available and only consumes the slot when the +// caller's context is still active. Waiting callers do not reserve future +// slots, so canceled requests cannot delay later retries. +func (l *tibiotRateLimiter) wait(ctx context.Context) error { + if l == nil { + return nil + } + if ctx == nil { + ctx = context.Background() + } + + for { + if err := ctx.Err(); err != nil { + return err + } + + l.mu.Lock() + now := time.Now() + if !l.next.After(now) { + // Check again while holding the mutex so a cancellation racing with + // the slot boundary cannot consume a slot unnecessarily. + if err := ctx.Err(); err != nil { + l.mu.Unlock() + return err + } + l.next = now.Add(l.interval) + l.mu.Unlock() + return nil + } + wakeAt := l.next + l.mu.Unlock() + + timer := time.NewTimer(time.Until(wakeAt)) + select { + case <-timer.C: + case <-ctx.Done(): + timer.Stop() + return ctx.Err() + } + } +} diff --git a/internal/logic/upstreamimpl/xuzhou_mobile_token_provider.go b/internal/logic/upstreamimpl/xuzhou_mobile_token_provider.go new file mode 100644 index 0000000..6b4944d --- /dev/null +++ b/internal/logic/upstreamimpl/xuzhou_mobile_token_provider.go @@ -0,0 +1,153 @@ +package upstreamimpl + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strconv" + "strings" + "time" +) + +const xuzhouMobileTokenPath = "/admin/api/get/token" + +// XuzhouMobileTokenConfig contains the public provider endpoint and the +// carrier type configured by the template. AppID is the per-channel +// credential and is deliberately not read from the template. +type XuzhouMobileTokenConfig struct { + BaseURL string + Path string + AppID string + CarrierType int + TTL time.Duration + Timeout time.Duration +} + +// XuzhouMobileTokenProvider fetches the shared OneLink token issued by the +// Xuzhou Mobile provider. +type XuzhouMobileTokenProvider struct { + baseURL string + path string + appID string + carrierType int + ttl time.Duration + client *http.Client +} + +type xuzhouMobileTokenResponse struct { + Success bool `json:"success"` + ErrorCode json.RawMessage `json:"errorCode"` + Body struct { + Token string `json:"token"` + } `json:"body"` +} + +func parseXuzhouErrorCode(raw json.RawMessage) (int, error) { + var code int + if err := json.Unmarshal(raw, &code); err == nil { + return code, nil + } + var value string + if err := json.Unmarshal(raw, &value); err != nil { + return 0, err + } + return strconv.Atoi(strings.TrimSpace(value)) +} + +func NewXuzhouMobileTokenProvider(config XuzhouMobileTokenConfig) *XuzhouMobileTokenProvider { + timeout := config.Timeout + if timeout <= 0 { + timeout = defaultUpstreamHTTPTimeout + } + return &XuzhouMobileTokenProvider{ + baseURL: strings.TrimRight(strings.TrimSpace(config.BaseURL), "/"), + path: normalizeProviderPath(config.Path, xuzhouMobileTokenPath), + appID: strings.TrimSpace(config.AppID), + carrierType: config.CarrierType, + ttl: config.TTL, + client: &http.Client{Timeout: timeout}, + } +} + +func (p *XuzhouMobileTokenProvider) Fetch(ctx context.Context) (OneLinkToken, error) { + if p == nil || p.client == nil { + return OneLinkToken{}, fmt.Errorf("xuzhou mobile token provider is not initialized") + } + if p.baseURL == "" { + return OneLinkToken{}, fmt.Errorf("xuzhou mobile token base URL is required") + } + if p.appID == "" { + return OneLinkToken{}, fmt.Errorf("xuzhou mobile appId is required") + } + + endpoint, err := url.Parse(joinProviderURL(p.baseURL, p.path)) + if err != nil { + return OneLinkToken{}, fmt.Errorf("parse Xuzhou Mobile token URL: %w", err) + } + query := endpoint.Query() + query.Set("appid", p.appID) + query.Set("carrierType", strconv.Itoa(p.carrierType)) + endpoint.RawQuery = query.Encode() + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint.String(), nil) + if err != nil { + return OneLinkToken{}, fmt.Errorf("create Xuzhou Mobile token request: %w", err) + } + req.Header.Set("Accept", "application/json") + resp, err := p.client.Do(req) + if err != nil { + return OneLinkToken{}, fmt.Errorf("request Xuzhou Mobile token: %w", err) + } + defer resp.Body.Close() + body, err := io.ReadAll(resp.Body) + if err != nil { + return OneLinkToken{}, fmt.Errorf("read Xuzhou Mobile token response: %w", err) + } + if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices { + return OneLinkToken{}, fmt.Errorf("Xuzhou Mobile token HTTP status %d: %s", resp.StatusCode, strings.TrimSpace(string(body))) + } + + var output xuzhouMobileTokenResponse + if err := json.Unmarshal(body, &output); err != nil { + return OneLinkToken{}, fmt.Errorf("decode Xuzhou Mobile token response: %w", err) + } + errorCode, err := parseXuzhouErrorCode(output.ErrorCode) + if err != nil { + return OneLinkToken{}, fmt.Errorf("decode Xuzhou Mobile errorCode: %w", err) + } + if !output.Success || errorCode != 0 { + return OneLinkToken{}, fmt.Errorf("Xuzhou Mobile token failed: success=%t, errorCode=%d", output.Success, errorCode) + } + value := strings.TrimSpace(output.Body.Token) + if value == "" { + return OneLinkToken{}, fmt.Errorf("Xuzhou Mobile shared token is empty") + } + now := time.Now() + expiresAt := time.Time{} + if p.ttl > 0 { + expiresAt = now.Add(p.ttl) + } + return OneLinkToken{Value: value, CreatedAt: now, ExpiresAt: expiresAt}, nil +} + +func (p *XuzhouMobileTokenProvider) FetchToken(ctx context.Context, _ string) (OneLinkToken, error) { + return p.Fetch(ctx) +} + +func normalizeProviderPath(path, fallback string) string { + path = strings.TrimSpace(path) + if path == "" { + path = fallback + } + if !strings.HasPrefix(path, "/") { + path = "/" + path + } + return path +} + +func joinProviderURL(baseURL, path string) string { + return strings.TrimRight(strings.TrimSpace(baseURL), "/") + normalizeProviderPath(path, "/") +} diff --git a/internal/logic/upstreamimpl/yunda/client.go b/internal/logic/upstreamimpl/yunda/client.go new file mode 100644 index 0000000..1088684 --- /dev/null +++ b/internal/logic/upstreamimpl/yunda/client.go @@ -0,0 +1,104 @@ +package yunda + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "strconv" + "strings" + "time" +) + +type Config struct { + BaseURL string + AppKey string + AppSecret string + Timeout time.Duration +} +type Client struct { + baseURL string + appKey string + appSecret string + httpClient *http.Client +} +type Response struct { + Status json.RawMessage `json:"status"` + Message string `json:"message"` + Result json.RawMessage `json:"result"` +} + +func NewClient(cfg Config) *Client { + timeout := cfg.Timeout + if timeout <= 0 { + timeout = 10 * time.Second + } + return &Client{baseURL: strings.TrimRight(strings.TrimSpace(cfg.BaseURL), "/"), appKey: strings.TrimSpace(cfg.AppKey), appSecret: cfg.AppSecret, httpClient: &http.Client{Timeout: timeout}} +} + +func (c *Client) Post(ctx context.Context, path string, params map[string]string, out any) (Response, error) { + if c.baseURL == "" { + return Response{}, errors.New("yunda base URL is required") + } + if c.appKey == "" || c.appSecret == "" { + return Response{}, errors.New("yunda appKey and appSecret are required") + } + signed := make(map[string]string, len(params)+3) + for key, value := range params { + if strings.TrimSpace(value) != "" { + signed[key] = value + } + } + signed["appKey"] = c.appKey + signed["timestamp"] = strconv.FormatInt(time.Now().Unix(), 10) + signed["sign"] = BuildSign(c.appKey, c.appSecret, signed) + form := url.Values{} + for key, value := range signed { + form.Set(key, value) + } + req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+"/"+strings.TrimLeft(path, "/"), strings.NewReader(form.Encode())) + if err != nil { + return Response{}, err + } + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + resp, err := c.httpClient.Do(req) + if err != nil { + return Response{}, err + } + defer resp.Body.Close() + body, err := io.ReadAll(resp.Body) + if err != nil { + return Response{}, err + } + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return Response{}, fmt.Errorf("yunda HTTP status %d: %s", resp.StatusCode, strings.TrimSpace(string(body))) + } + var response Response + if err = json.Unmarshal(body, &response); err != nil { + return Response{}, fmt.Errorf("decode yunda response: %w", err) + } + if status := response.StatusString(); status != "0" && status != "" { + return response, fmt.Errorf("yunda business status %s: %s", status, response.Message) + } + if out != nil && len(response.Result) > 0 && string(response.Result) != "null" { + if err = json.Unmarshal(response.Result, out); err != nil { + return response, fmt.Errorf("decode yunda result: %w", err) + } + } + return response, nil +} + +func (r Response) StatusString() string { + var value string + if json.Unmarshal(r.Status, &value) == nil { + return value + } + var number json.Number + if json.Unmarshal(r.Status, &number) == nil { + return number.String() + } + return "" +} diff --git a/internal/logic/upstreamimpl/yunda/sign.go b/internal/logic/upstreamimpl/yunda/sign.go new file mode 100644 index 0000000..6379493 --- /dev/null +++ b/internal/logic/upstreamimpl/yunda/sign.go @@ -0,0 +1,29 @@ +package yunda + +import ( + "crypto/md5" + "encoding/hex" + "sort" + "strings" +) + +func BuildSign(appKey, appSecret string, params map[string]string) string { + values := make(map[string]string, len(params)+2) + for key, value := range params { + values[key] = value + } + values["appKey"] = appKey + values["appSecret"] = appSecret + keys := make([]string, 0, len(values)) + for key := range values { + keys = append(keys, key) + } + sort.Strings(keys) + source := appKey + for _, key := range keys { + source += key + values[key] + } + source += appKey + sum := md5.Sum([]byte(source)) + return strings.ToUpper(hex.EncodeToString(sum[:])) +} diff --git a/internal/logic/upstreamimpl/yunze_token_provider.go b/internal/logic/upstreamimpl/yunze_token_provider.go new file mode 100644 index 0000000..c54f682 --- /dev/null +++ b/internal/logic/upstreamimpl/yunze_token_provider.go @@ -0,0 +1,199 @@ +package upstreamimpl + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + "time" + + sharedtoken "git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/token" +) + +const ( + yunzeTokenPath = "/openapi/v3/query/operator-token" + yunzeTokenExpiryLayout = "2006-01-02 15:04:05" +) + +// YunzeTokenConfig contains the platform endpoint and the per-channel app id. +// The Yunze operator-token API does not require app_secret. +type YunzeTokenConfig struct { + BaseURL string + Path string + AppID string + Timeout time.Duration +} + +// YunzeTokenProvider fetches the shared token from the Yunze platform. +type YunzeTokenProvider struct { + baseURL string + path string + appID string + client *http.Client +} + +type yunzeTokenResponse struct { + Code json.RawMessage `json:"code"` + Message string `json:"message"` + Data struct { + ChanAlias string `json:"chanAlias"` + ChanKey string `json:"chanKey"` + AccessToken string `json:"accessToken"` + // The live endpoint returns the expiry as tokenExpire. The published + // documentation incorrectly lists accessTokenExpire; it is being + // corrected to match the implementation. + TokenExpire string `json:"tokenExpire"` + } `json:"data"` + Error json.RawMessage `json:"error"` +} + +// yunzeTokenRequest is the operator-token request body. +type yunzeTokenRequest struct { + AppID string `json:"appId"` + ChanKey string `json:"chanKey"` +} + +// yunzeFetcher adapts YunzeTokenProvider to the shared token TokenFetcher +// contract, mirroring ziboFetcher and apikaFetcher. +type yunzeFetcher struct{ provider *YunzeTokenProvider } + +func (f yunzeFetcher) FetchToken(ctx context.Context, chanKey string) (sharedtoken.OneLinkToken, error) { + return f.provider.Fetch(ctx, chanKey) +} + +func NewYunzeTokenProvider(config YunzeTokenConfig) *YunzeTokenProvider { + timeout := config.Timeout + if timeout <= 0 { + timeout = defaultUpstreamHTTPTimeout + } + return &YunzeTokenProvider{ + baseURL: strings.TrimRight(strings.TrimSpace(config.BaseURL), "/"), + path: normalizeProviderPath(config.Path, yunzeTokenPath), + appID: strings.TrimSpace(config.AppID), + client: &http.Client{Timeout: timeout}, + } +} + +func (p *YunzeTokenProvider) Fetch(ctx context.Context, chanKey string) (sharedtoken.OneLinkToken, error) { + if p == nil || p.client == nil { + return sharedtoken.OneLinkToken{}, fmt.Errorf("yunze token provider is not initialized") + } + if p.baseURL == "" { + return sharedtoken.OneLinkToken{}, fmt.Errorf("yunze token base URL is required") + } + if p.appID == "" { + return sharedtoken.OneLinkToken{}, fmt.Errorf("yunze appId is required") + } + chanKey = strings.TrimSpace(chanKey) + if chanKey == "" { + return sharedtoken.OneLinkToken{}, fmt.Errorf("yunze chanKey is required") + } + + payload, err := json.Marshal(yunzeTokenRequest{AppID: p.appID, ChanKey: chanKey}) + if err != nil { + return sharedtoken.OneLinkToken{}, fmt.Errorf("encode yunze token request: %w", err) + } + + req, err := http.NewRequestWithContext( + ctx, + http.MethodPost, + joinProviderURL(p.baseURL, p.path), + bytes.NewReader(payload), + ) + if err != nil { + return sharedtoken.OneLinkToken{}, fmt.Errorf("create yunze token request: %w", err) + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/json") + + resp, err := p.client.Do(req) + if err != nil { + return sharedtoken.OneLinkToken{}, fmt.Errorf("request yunze token: %w", err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(io.LimitReader(resp.Body, 4*1024*1024+1)) + if err != nil { + return sharedtoken.OneLinkToken{}, fmt.Errorf("read yunze token response: %w", err) + } + if len(body) > 4*1024*1024 { + return sharedtoken.OneLinkToken{}, fmt.Errorf("yunze token response is too large") + } + if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices { + return sharedtoken.OneLinkToken{}, fmt.Errorf("yunze token HTTP status %d", resp.StatusCode) + } + + var output yunzeTokenResponse + if err := json.Unmarshal(body, &output); err != nil { + return sharedtoken.OneLinkToken{}, fmt.Errorf("decode yunze token response: %w", err) + } + code, err := decodeYunzeCode(output.Code) + if err != nil { + return sharedtoken.OneLinkToken{}, fmt.Errorf("decode yunze token code: %w", err) + } + if code != 0 { + return sharedtoken.OneLinkToken{}, fmt.Errorf( + "yunze token failed: code=%d message=%s", + code, + strings.TrimSpace(output.Message), + ) + } + if len(output.Error) > 0 && string(output.Error) != "null" { + return sharedtoken.OneLinkToken{}, fmt.Errorf("yunze token error: %s", string(output.Error)) + } + + value := strings.TrimSpace(output.Data.AccessToken) + if value == "" { + return sharedtoken.OneLinkToken{}, fmt.Errorf("yunze accessToken is empty") + } + expiresAt, err := parseYunzeTokenExpiry(output.Data.TokenExpire) + if err != nil { + return sharedtoken.OneLinkToken{}, err + } + + return sharedtoken.OneLinkToken{ + Value: value, + CreatedAt: time.Now(), + ExpiresAt: expiresAt, + }, nil +} + +func decodeYunzeCode(raw json.RawMessage) (int, error) { + var code int + if err := json.Unmarshal(raw, &code); err == nil { + return code, nil + } + var value string + if err := json.Unmarshal(raw, &value); err != nil { + return 0, err + } + var parsed int + if _, err := fmt.Sscanf(strings.TrimSpace(value), "%d", &parsed); err != nil { + return 0, err + } + return parsed, nil +} + +// yunzeTokenLocation is the timezone used by the Yunze operator-token API. +// It is resolved once so the hot refresh path does not re-read tzdata. +var yunzeTokenLocation = func() *time.Location { + if loc, err := time.LoadLocation("Asia/Shanghai"); err == nil { + return loc + } + return time.Local +}() + +func parseYunzeTokenExpiry(raw string) (time.Time, error) { + raw = strings.TrimSpace(raw) + if raw == "" { + return time.Time{}, fmt.Errorf("yunze tokenExpire is empty") + } + expiresAt, err := time.ParseInLocation(yunzeTokenExpiryLayout, raw, yunzeTokenLocation) + if err != nil { + return time.Time{}, fmt.Errorf("parse yunze tokenExpire %q: %w", raw, err) + } + return expiresAt, nil +} diff --git a/internal/logic/upstreamimpl/yunze_token_provider_test.go b/internal/logic/upstreamimpl/yunze_token_provider_test.go new file mode 100644 index 0000000..9121713 --- /dev/null +++ b/internal/logic/upstreamimpl/yunze_token_provider_test.go @@ -0,0 +1,196 @@ +package upstreamimpl + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "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" +) + +// yunzeExpire renders a wall-clock expiry in the operator-token wire format +// (Asia/Shanghai, "2006-01-02 15:04:05"). +func yunzeExpire() string { + return time.Now().In(yunzeTokenLocation).Add(10 * time.Minute).Format(yunzeTokenExpiryLayout) +} + +func TestYunzeTokenProviderFetch(t *testing.T) { + expire := time.Now().In(yunzeTokenLocation).Add(10 * time.Minute) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + t.Fatalf("method = %s, want POST", r.Method) + } + if r.URL.Path != "/openapi/v3/query/operator-token" { + t.Fatalf("path = %s", r.URL.Path) + } + if got := r.Header.Get("Content-Type"); got != "application/json" { + t.Fatalf("content type = %q", got) + } + + var request yunzeTokenRequest + if err := json.NewDecoder(r.Body).Decode(&request); err != nil { + t.Fatalf("decode request: %v", err) + } + if request.AppID != "app-id" || request.ChanKey != "249" { + t.Fatalf("request = %+v", request) + } + + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"code":0,"message":"","data":{"chanAlias":"中国移动物联网","chanKey":"249","accessToken":"token-1","tokenExpire":"` + expire.Format(yunzeTokenExpiryLayout) + `"},"error":null}`)) + })) + defer server.Close() + + provider := NewYunzeTokenProvider(YunzeTokenConfig{ + BaseURL: server.URL, + Path: "/openapi/v3/query/operator-token", + AppID: "app-id", + Timeout: time.Second, + }) + token, err := provider.Fetch(context.Background(), "249") + if err != nil { + t.Fatalf("fetch token: %v", err) + } + if token.Value != "token-1" { + t.Fatalf("token = %q", token.Value) + } + if token.ExpiresAt.IsZero() { + t.Fatal("token expiry is empty") + } + if token.ExpiresAt.Sub(expire).Abs() > time.Second { + t.Fatalf("expiry = %s, want %s", token.ExpiresAt, expire) + } +} + +// Pins the wire field name to the live endpoint. The published documentation +// lists data.accessTokenExpire, but the endpoint returns data.tokenExpire; if +// either side changes, this test fails and forces a deliberate decision. +func TestYunzeTokenProviderExpiryFieldName(t *testing.T) { + expire := yunzeExpire() + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte(`{"code":0,"data":{"accessToken":"token-1","tokenExpire":"` + expire + `"},"error":null}`)) + })) + defer server.Close() + + provider := NewYunzeTokenProvider(YunzeTokenConfig{BaseURL: server.URL, AppID: "app-id", Timeout: time.Second}) + token, err := provider.Fetch(context.Background(), "249") + if err != nil { + t.Fatalf("fetch token: %v", err) + } + if token.Value != "token-1" || token.ExpiresAt.IsZero() { + t.Fatalf("token = %+v", token) + } +} + +func TestYunzeTokenProviderAcceptsStringCode(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte(`{"code":"0","data":{"accessToken":"token-str","tokenExpire":"` + yunzeExpire() + `"},"error":null}`)) + })) + defer server.Close() + + provider := NewYunzeTokenProvider(YunzeTokenConfig{BaseURL: server.URL, AppID: "app-id", Timeout: time.Second}) + token, err := provider.Fetch(context.Background(), "249") + if err != nil { + t.Fatalf("fetch token: %v", err) + } + if token.Value != "token-str" { + t.Fatalf("token = %q, want token-str", token.Value) + } +} + +func TestYunzeTokenProviderRejectsFailedResponse(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte(`{"code":1001,"message":"通道不存在","data":null,"error":null}`)) + })) + defer server.Close() + + provider := NewYunzeTokenProvider(YunzeTokenConfig{BaseURL: server.URL, AppID: "app-id", Timeout: time.Second}) + if _, err := provider.Fetch(context.Background(), "249"); err == nil { + t.Fatal("expected failed response error") + } +} + +func TestYunzeTokenProviderRejectsErrorPayload(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte(`{"code":0,"data":{"accessToken":"token-1","tokenExpire":"` + yunzeExpire() + `"},"error":{"msg":"account frozen"}}`)) + })) + defer server.Close() + + provider := NewYunzeTokenProvider(YunzeTokenConfig{BaseURL: server.URL, AppID: "app-id", Timeout: time.Second}) + if _, err := provider.Fetch(context.Background(), "249"); err == nil { + t.Fatal("expected error payload rejection") + } +} + +func TestParseSharedOneLinkConfigBusinessURL(t *testing.T) { + newConfig := func(tempConfig string) *model.SimChannelConfigItemCache { + return &model.SimChannelConfigItemCache{ + Template: &entity.TSimChannelTemplate{TempConfig: tempConfig}, + Channel: &entity.TSimChannels{ + ChanKey: "249", + AppKey: "app-id", + ChanConfig: "{}", + }, + } + } + + 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"}}`, + )) + if err != nil { + t.Fatalf("parse config: %v", err) + } + if config.Provider != "yunze" || config.AppID != "app-id" { + t.Fatalf("config = %+v", config) + } + if api.BaseURL != sharedOneLinkAPIBaseURL { + t.Fatalf("business base URL = %q, want %q", api.BaseURL, sharedOneLinkAPIBaseURL) + } + }) + + 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"}}`, + )) + if err != nil { + t.Fatalf("parse config: %v", err) + } + if config.Provider != "yunze" || config.AppID != "app-id" { + t.Fatalf("config = %+v", config) + } + if api.BaseURL != custom { + t.Fatalf("business base URL = %q, want %q", api.BaseURL, custom) + } + }) +} + +// The yunze channel template is registered by its concrete temp_key, matching +// the other shared-token templates (sim_onelink__v1). +func TestYunzeTemplateRegistration(t *testing.T) { + template, ok := service.UpstreamAPI().GetTemp(sharedOneLinkYunzeTemplateKey) + if !ok || template == nil { + t.Fatal("yunze template not registered") + } + + config := &model.SimChannelConfigItemCache{ + Template: &entity.TSimChannelTemplate{ + TempKey: sharedOneLinkYunzeTemplateKey, + TempModel: templateModelSharedToken, + TempConfig: `{"token":{"provider":"yunze","baseUrl":"http://apiv3.example.com","path":"/route/open/sharedToken"}}`, + }, + Channel: &entity.TSimChannels{ + ChanKey: "249", + AppKey: "TEST_APP_KEY_0001", + ChanConfig: "{}", + }, + } + if instance := template.NewTemp(context.Background(), config); instance == nil { + t.Fatal("yunze template failed to instantiate") + } +} diff --git a/internal/logic/upstreamimpl/zibo_token_provider.go b/internal/logic/upstreamimpl/zibo_token_provider.go new file mode 100644 index 0000000..ff87c1e --- /dev/null +++ b/internal/logic/upstreamimpl/zibo_token_provider.go @@ -0,0 +1,145 @@ +package upstreamimpl + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "sync/atomic" + "time" + + sharedtoken "git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/token" +) + +const ziboOneLinkTokenPath = "/ec/get/token" + +// ZiboTokenConfig contains the official OneLink token endpoint and the +// per-channel credentials stored in t_sim_channels. +type ZiboTokenConfig struct { + BaseURL string + Path string + AppID string + Password string + Timeout time.Duration +} + +// ZiboTokenProvider fetches a token from the official OneLink endpoint. +type ZiboTokenProvider struct { + baseURL string + path string + appID string + password string + client *http.Client + sequence atomic.Uint64 +} + +type ziboTokenResponse struct { + Status string `json:"status"` + Message string `json:"message"` + Result []struct { + Token string `json:"token"` + TTL int `json:"ttl"` + } `json:"result"` +} + +type ziboFetcher struct { + provider *ZiboTokenProvider +} + +func NewZiboTokenProvider(config ZiboTokenConfig) *ZiboTokenProvider { + timeout := config.Timeout + if timeout <= 0 { + timeout = defaultUpstreamHTTPTimeout + } + return &ZiboTokenProvider{ + baseURL: strings.TrimRight(strings.TrimSpace(config.BaseURL), "/"), + path: normalizeProviderPath(config.Path, ziboOneLinkTokenPath), + appID: strings.TrimSpace(config.AppID), + password: config.Password, + client: &http.Client{Timeout: timeout}, + } +} + +func (f ziboFetcher) FetchToken(ctx context.Context, _ string) (sharedtoken.OneLinkToken, error) { + token, err := f.provider.Fetch(ctx) + if err != nil { + return sharedtoken.OneLinkToken{}, err + } + return sharedtoken.OneLinkToken{ + Value: token.Value, + CreatedAt: token.CreatedAt, + ExpiresAt: token.ExpiresAt, + }, nil +} + +func (p *ZiboTokenProvider) Fetch(ctx context.Context) (OneLinkToken, error) { + if p == nil || p.client == nil { + return OneLinkToken{}, fmt.Errorf("zibo token provider is not initialized") + } + if p.baseURL == "" { + return OneLinkToken{}, fmt.Errorf("zibo token base URL is required") + } + if p.appID == "" || strings.TrimSpace(p.password) == "" { + return OneLinkToken{}, fmt.Errorf("zibo appid and password are required") + } + + endpoint, err := url.Parse(joinProviderURL(p.baseURL, p.path)) + if err != nil { + return OneLinkToken{}, fmt.Errorf("parse zibo token URL: %w", err) + } + query := endpoint.Query() + query.Set("appid", p.appID) + query.Set("password", p.password) + query.Set("transid", p.nextTransID()) + query.Set("refresh", "0") + endpoint.RawQuery = query.Encode() + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint.String(), nil) + if err != nil { + return OneLinkToken{}, fmt.Errorf("create zibo token request: %w", err) + } + req.Header.Set("Accept", "application/json") + + resp, err := p.client.Do(req) + if err != nil { + return OneLinkToken{}, fmt.Errorf("request zibo token: %w", err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return OneLinkToken{}, fmt.Errorf("read zibo token response: %w", err) + } + if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices { + return OneLinkToken{}, fmt.Errorf("zibo token HTTP status %d", resp.StatusCode) + } + + var output ziboTokenResponse + if err := json.Unmarshal(body, &output); err != nil { + return OneLinkToken{}, fmt.Errorf("decode zibo token response: %w", err) + } + if strings.TrimSpace(output.Status) != "0" { + return OneLinkToken{}, fmt.Errorf("zibo token failed: status=%s, message=%s", output.Status, output.Message) + } + if len(output.Result) == 0 || strings.TrimSpace(output.Result[0].Token) == "" { + return OneLinkToken{}, fmt.Errorf("zibo token response is empty") + } + if output.Result[0].TTL <= 0 { + return OneLinkToken{}, fmt.Errorf("zibo token ttl must be positive") + } + + now := time.Now() + return OneLinkToken{ + Value: strings.TrimSpace(output.Result[0].Token), + CreatedAt: now, + ExpiresAt: now.Add(time.Duration(output.Result[0].TTL) * time.Second), + }, nil +} + +func (p *ZiboTokenProvider) nextTransID() string { + return p.appID + time.Now().Format("20060102150405") + + fmt.Sprintf("%08d", p.sequence.Add(1)%100000000) +} diff --git a/internal/model/channel_test_models.go b/internal/model/channel_test_models.go new file mode 100644 index 0000000..6b5c1d6 --- /dev/null +++ b/internal/model/channel_test_models.go @@ -0,0 +1,58 @@ +package model + +import "errors" + +var ErrChannelNotAccessible = errors.New("channel is not accessible") + +const ( + ChannelTestInputModeComplete = "complete" + ChannelTestInputModeSingle = "single" + ChannelTestInputModeDirectSingle = "direct_single" +) + +// ChannelTestInput is the service-to-service request for a read-only channel +// probe. TenantID is populated by admin-svr from the authenticated context. +type ChannelTestInput struct { + ChannelID int64 `json:"channelId"` + TenantID string `json:"tenantId"` + InputMode string `json:"inputMode"` + CardValueType string `json:"cardValueType"` + CardValue string `json:"cardValue"` + ICCID string `json:"iccid"` + IMSI string `json:"imsi"` + MSISDN string `json:"msisdn"` + Items []string `json:"items"` +} + +type ChannelTestOutput struct { + Items []*ChannelTestItem `json:"items"` +} + +type ChannelTestItem struct { + Key string `json:"key"` + Name string `json:"name"` + Status string `json:"status"` + DurationMs int64 `json:"durationMs"` + Message string `json:"message,omitempty"` + Data any `json:"data,omitempty"` + ErrorCode int `json:"errorCode,omitempty"` +} + +// ChannelTestOperationNames is deliberately a closed read-only set. Change +// operations are not represented here and cannot be requested over the +// internal endpoint. +var ChannelTestOperationNames = map[string]string{ + "simBasicInfo": "基础信息", + "simSession": "实时会话", + "simStatus": "卡状态", + "simStopReason": "停机原因", + "simFrozenState": "冻结状态", + "simCardBindStatus": "机卡绑定", + "simDeviceStatus": "设备状态", + "simRegionLimitStatus": "区域限制", + "simImei": "IMEI", + "simDataUsage": "实时流量", + "simDataUsageMonthly": "月流量", + "simDataUsageDaily": "日流量", + "simApnInfo": "APN信息", +} diff --git a/internal/model/do/t_api.go b/internal/model/do/t_api.go new file mode 100644 index 0000000..6333170 --- /dev/null +++ b/internal/model/do/t_api.go @@ -0,0 +1,27 @@ +// ================================================================================= +// Code generated and maintained by GoFrame CLI tool. DO NOT EDIT. +// ================================================================================= + +package do + +import ( + "github.com/gogf/gf/v2/frame/g" + "github.com/gogf/gf/v2/os/gtime" +) + +// TApi is the golang structure of table t_api for DAO operations like Where/Data. +type TApi struct { + g.Meta `orm:"table:t_api, do:true"` + Id any // ID + AppKey any // APPKey + AppSecret any // APPSecret + RateLimitPeriod any // 限流周期(秒,0:不限流) + RateLimitCount any // 限流次数 + DeptId any // 所属机构 + TenantId any // 租户ID + CreatedDept any // 创建部门 + CreatedBy any // 创建者 + CreatedAt *gtime.Time // 创建时间 + UpdatedBy any // 更新者 + UpdatedAt *gtime.Time // 更新时间 +} diff --git a/internal/model/do/t_customer.go b/internal/model/do/t_customer.go new file mode 100644 index 0000000..6e0d793 --- /dev/null +++ b/internal/model/do/t_customer.go @@ -0,0 +1,35 @@ +// ================================================================================= +// Code generated and maintained by GoFrame CLI tool. DO NOT EDIT. +// ================================================================================= + +package do + +import ( + "github.com/gogf/gf/v2/frame/g" + "github.com/gogf/gf/v2/os/gtime" +) + +// TCustomer is the golang structure of table t_customer for DAO operations like Where/Data. +type TCustomer struct { + g.Meta `orm:"table:t_customer, do:true"` + Id any // ID + CKey any // 客户编号 + CType any // 客户类型(0:企业,1:个人) + CName any // 客户名称 + CShortName any // 客户简称 + ContactUserName any // 联系人 + ContactPhone any // 联系电话 + LicenseNumber any // 统一社会信用代码 + RegionCode any // 行政区域代码 + Address any // 地址 + DepositAmount any // 预存金额 + CreditAmountTotal any // 已授信额度 + CreditAmountUsed any // 已使用授信额度 + DeptId any // 所属机构 + TenantId any // 租户ID + CreatedDept any // 创建部门 + CreatedBy any // 创建者 + CreatedAt *gtime.Time // 创建时间 + UpdatedBy any // 更新者 + UpdatedAt *gtime.Time // 更新时间 +} diff --git a/internal/model/do/t_sim_agent_package.go b/internal/model/do/t_sim_agent_package.go new file mode 100644 index 0000000..72a1905 --- /dev/null +++ b/internal/model/do/t_sim_agent_package.go @@ -0,0 +1,34 @@ +// ================================================================================= +// Code generated and maintained by GoFrame CLI tool. DO NOT EDIT. +// ================================================================================= + +package do + +import ( + "github.com/gogf/gf/v2/frame/g" + "github.com/gogf/gf/v2/os/gtime" +) + +// TSimAgentPackage is the golang structure of table t_sim_agent_package for DAO operations like Where/Data. +type TSimAgentPackage struct { + g.Meta `orm:"table:t_sim_agent_package, do:true"` + Id any // ID + PackageKey any // 套餐分类编码 + PackageName any // 套餐分类名称 + ParentPackageKey any // 上级套餐分类编码 + PackageStatus any // 是否启用(0:未启用,1启用) + BillingMethod any // 计费方式(0:月,1:季,2:年) + ChannelPrice any // 渠道价格 + MonthlyFee any // 月租费 + PackageDataMb any // 套餐流量(MB) + PackageDataGb any // 套餐流量(GB) + ValidityPeriod any // 有效期 + Description any // 描述信息 + OrderNum any // 排序 + TenantId any // 租户ID + CreatedDept any // 创建部门 + CreatedBy any // 创建者 + CreatedAt *gtime.Time // 创建时间 + UpdatedBy any // 更新者 + UpdatedAt *gtime.Time // 更新时间 +} diff --git a/internal/model/do/t_sim_agent_packet.go b/internal/model/do/t_sim_agent_packet.go new file mode 100644 index 0000000..e51fd86 --- /dev/null +++ b/internal/model/do/t_sim_agent_packet.go @@ -0,0 +1,29 @@ +// ================================================================================= +// Code generated and maintained by GoFrame CLI tool. DO NOT EDIT. +// ================================================================================= + +package do + +import ( + "github.com/gogf/gf/v2/frame/g" + "github.com/gogf/gf/v2/os/gtime" +) + +// TSimAgentPacket is the golang structure of table t_sim_agent_packet for DAO operations like Where/Data. +type TSimAgentPacket struct { + g.Meta `orm:"table:t_sim_agent_packet, do:true"` + Id any // ID + PackageKey any // 套餐分类编码 + PacketKey any // 套餐编码 + PacketName any // 套餐名称 + ValidityPeriod any // 套餐有效期(月份) + PacketStatus any // 是否启用(0:未启用,1启用) + DiscountPercent any // 折扣率(百分比) + Description any // 描述信息 + TenantId any // 租户ID + CreatedDept any // 创建部门 + CreatedBy any // 创建者 + CreatedAt *gtime.Time // 创建时间 + UpdatedBy any // 更新者 + UpdatedAt *gtime.Time // 更新时间 +} diff --git a/internal/model/do/t_sim_card_info.go b/internal/model/do/t_sim_card_info.go new file mode 100644 index 0000000..f16e207 --- /dev/null +++ b/internal/model/do/t_sim_card_info.go @@ -0,0 +1,73 @@ +// ================================================================================= +// Code generated and maintained by GoFrame CLI tool. DO NOT EDIT. +// ================================================================================= + +package do + +import ( + "github.com/gogf/gf/v2/frame/g" + "github.com/gogf/gf/v2/os/gtime" +) + +// TSimCardInfo is the golang structure of table t_sim_card_info for DAO operations like Where/Data. +type TSimCardInfo struct { + g.Meta `orm:"table:t_sim_card_info, do:true"` + Id any // ID + Msisdn any // SIM号码 + Iccid any // SIM卡号 + Imsi any // SIM识别码 + Imei any // IMEI + OpenDate *gtime.Time // 开卡日期(运营商) + ActivateDate *gtime.Time // 激活时间(运营商) + AllocationBatch any // 划拨批次 + AllocationDate *gtime.Time // 划拨日期 + ChanKey any // 通道编号 + IsPool any // 是否流量池(0:否,1:是) + PoolKey any // 流量池编号 + BatchKey any // 入库批次 + BatchDate *gtime.Time // 入库日期 + PackageKey any // 套餐分类编码 + CurrPacketKey any // 当前套餐编码 + AllowPacketChange any // 是否允许更改套餐(0:否 1:是) + CardStatus any // 卡状态 + ValidityPeriod any // 有效期(月份) + ExpiryDate *gtime.Time // 套餐到期日期 + UpstreamExpiryDate *gtime.Time // 上游到期时间(运营商) + IpType any // IP版本(人工录入) + NetModel any // 网络模式(人工录入) + RegionCode any // 行政区域代码(省市) + HasOffNet any // 是否开启达量断网 + OffNetQuota any // 达量断网阈值(MB) + OffNetExpiryDate *gtime.Time // 达量断网过期时间 + AgentShowField any // 客户展示字段设置 + AgentShowValue any // 客户展示字段数据 + HasTestPeriod any // 是否有测试期(0:无,1:有) + TestPeriod any // 测试期(天) + TestPeriodExpiryDate *gtime.Time // 测试期过期日期 + HasSilentPeriod any // 是否有沉默期(0:无,1:有) + SilentPeriod any // 沉默期(月) + SilentPeriodExpiryDate *gtime.Time // 沉默期过期日期 + HasAdditional any // 是否有附加费用(0:无 1:有) + AdditionalMultiplier any // 附加费用倍数 + AdditionalAmount any // 附加金额 + DataService any // 数据服务(0:关闭 1:开启) + DataTotalAmount any // 套餐流量(MB) + DataUseAmount any // 已用流量(MB) + SmsService any // 短信服务(0:关闭 1:开启) + SmsTotalAmount any // 套餐短信数量 + SmsUseAmount any // 已用短信数量 + VoiceService any // 语音服务(0:关闭 1:开启) + VoiceTotalAmount any // 套餐通话分钟数 + VoiceUseAmount any // 已用通话分钟数 + IntlRoamingService any // 国际漫游服务(0:关闭 1:开启) + IntlRoamingTotalAmount any // 套餐国际漫游流量(MB) + IntlRoamingUseAmount any // 已用国际漫游流量(MB) + Description any // 描述信息 + DeptId any // 所属机构 + TenantId any // 租户ID + CreatedDept any // 创建部门 + CreatedBy any // 创建者 + CreatedAt *gtime.Time // 创建时间 + UpdatedBy any // 更新者 + UpdatedAt *gtime.Time // 更新时间 +} diff --git a/internal/model/do/t_sim_channel_template.go b/internal/model/do/t_sim_channel_template.go new file mode 100644 index 0000000..a2ea2cd --- /dev/null +++ b/internal/model/do/t_sim_channel_template.go @@ -0,0 +1,27 @@ +// ================================================================================= +// Code generated and maintained by GoFrame CLI tool. DO NOT EDIT. +// ================================================================================= + +package do + +import ( + "github.com/gogf/gf/v2/frame/g" + "github.com/gogf/gf/v2/os/gtime" +) + +// TSimChannelTemplate is the golang structure of table t_sim_channel_template for DAO operations like Where/Data. +type TSimChannelTemplate struct { + g.Meta `orm:"table:t_sim_channel_template, do:true"` + Id any // ID + TempKey any // 模板标识 + TempName any // 模板名称 + TempModel any // 模板模型 + TempConfig any // 模板配置 + TempStatus any // 模板状态(0未启用,1启用) + TenantId any // 租户ID + CreatedDept any // 创建部门 + CreatedBy any // 创建者 + CreatedAt *gtime.Time // 创建时间 + UpdatedBy any // 更新者 + UpdatedAt *gtime.Time // 更新时间 +} diff --git a/internal/model/do/t_sim_channels.go b/internal/model/do/t_sim_channels.go new file mode 100644 index 0000000..d3a3ab8 --- /dev/null +++ b/internal/model/do/t_sim_channels.go @@ -0,0 +1,35 @@ +// ================================================================================= +// Code generated and maintained by GoFrame CLI tool. DO NOT EDIT. +// ================================================================================= + +package do + +import ( + "github.com/gogf/gf/v2/frame/g" + "github.com/gogf/gf/v2/os/gtime" +) + +// TSimChannels is the golang structure of table t_sim_channels for DAO operations like Where/Data. +type TSimChannels struct { + g.Meta `orm:"table:t_sim_channels, do:true"` + Id any // ID + TempKey any // 模板标识 + ChanKey any // 通道编号 + ChanName any // 通道名称 + ChanConfig any // 通道配置 + ChanStatus any // 通道状态(0未启用,1启用) + ChanAlias any // 通道别名 + RegionCode any // 通道行政区域代码(省市) + AppKey any // 应用标识 + AppSecret any // 应用密钥 + AccessToken any // 接口调用凭证 + AccessTokenCreated *gtime.Time // 凭证申请时间 + AccessTokenExpire *gtime.Time // 凭证过期时间 + RefreshToken any // 刷新凭证 + TenantId any // 租户ID + CreatedDept any // 创建部门 + CreatedBy any // 创建者 + CreatedAt *gtime.Time // 创建时间 + UpdatedBy any // 更新者 + UpdatedAt *gtime.Time // 更新时间 +} diff --git a/internal/model/entity/t_api.go b/internal/model/entity/t_api.go new file mode 100644 index 0000000..e5cc558 --- /dev/null +++ b/internal/model/entity/t_api.go @@ -0,0 +1,25 @@ +// ================================================================================= +// Code generated and maintained by GoFrame CLI tool. DO NOT EDIT. +// ================================================================================= + +package entity + +import ( + "github.com/gogf/gf/v2/os/gtime" +) + +// TApi is the golang structure for table t_api. +type TApi struct { + Id int64 `json:"id" orm:"id" description:"ID"` + AppKey string `json:"appKey" orm:"app_key" description:"APPKey"` + AppSecret string `json:"appSecret" orm:"app_secret" description:"APPSecret"` + RateLimitPeriod int `json:"rateLimitPeriod" orm:"rate_limit_period" description:"限流周期(秒,0:不限流)"` + RateLimitCount int `json:"rateLimitCount" orm:"rate_limit_count" description:"限流次数"` + DeptId int64 `json:"deptId" orm:"dept_id" description:"所属机构"` + TenantId string `json:"tenantId" orm:"tenant_id" description:"租户ID"` + CreatedDept int64 `json:"createdDept" orm:"created_dept" description:"创建部门"` + CreatedBy int64 `json:"createdBy" orm:"created_by" description:"创建者"` + CreatedAt *gtime.Time `json:"createdAt" orm:"created_at" description:"创建时间"` + UpdatedBy int64 `json:"updatedBy" orm:"updated_by" description:"更新者"` + UpdatedAt *gtime.Time `json:"updatedAt" orm:"updated_at" description:"更新时间"` +} diff --git a/internal/model/entity/t_customer.go b/internal/model/entity/t_customer.go new file mode 100644 index 0000000..f0a7703 --- /dev/null +++ b/internal/model/entity/t_customer.go @@ -0,0 +1,33 @@ +// ================================================================================= +// Code generated and maintained by GoFrame CLI tool. DO NOT EDIT. +// ================================================================================= + +package entity + +import ( + "github.com/gogf/gf/v2/os/gtime" +) + +// TCustomer is the golang structure for table t_customer. +type TCustomer struct { + Id int64 `json:"id" orm:"id" description:"ID"` + CKey string `json:"cKey" orm:"c_key" description:"客户编号"` + CType int `json:"cType" orm:"c_type" description:"客户类型(0:企业,1:个人)"` + CName string `json:"cName" orm:"c_name" description:"客户名称"` + CShortName string `json:"cShortName" orm:"c_short_name" description:"客户简称"` + ContactUserName string `json:"contactUserName" orm:"contact_user_name" description:"联系人"` + ContactPhone string `json:"contactPhone" orm:"contact_phone" description:"联系电话"` + LicenseNumber string `json:"licenseNumber" orm:"license_number" description:"统一社会信用代码"` + RegionCode string `json:"regionCode" orm:"region_code" description:"行政区域代码"` + Address string `json:"address" orm:"address" description:"地址"` + DepositAmount float64 `json:"depositAmount" orm:"deposit_amount" description:"预存金额"` + CreditAmountTotal float64 `json:"creditAmountTotal" orm:"credit_amount_total" description:"已授信额度"` + CreditAmountUsed float64 `json:"creditAmountUsed" orm:"credit_amount_used" description:"已使用授信额度"` + DeptId int64 `json:"deptId" orm:"dept_id" description:"所属机构"` + TenantId string `json:"tenantId" orm:"tenant_id" description:"租户ID"` + CreatedDept int64 `json:"createdDept" orm:"created_dept" description:"创建部门"` + CreatedBy int64 `json:"createdBy" orm:"created_by" description:"创建者"` + CreatedAt *gtime.Time `json:"createdAt" orm:"created_at" description:"创建时间"` + UpdatedBy int64 `json:"updatedBy" orm:"updated_by" description:"更新者"` + UpdatedAt *gtime.Time `json:"updatedAt" orm:"updated_at" description:"更新时间"` +} diff --git a/internal/model/entity/t_sim_agent_package.go b/internal/model/entity/t_sim_agent_package.go new file mode 100644 index 0000000..e4111d9 --- /dev/null +++ b/internal/model/entity/t_sim_agent_package.go @@ -0,0 +1,32 @@ +// ================================================================================= +// Code generated and maintained by GoFrame CLI tool. DO NOT EDIT. +// ================================================================================= + +package entity + +import ( + "github.com/gogf/gf/v2/os/gtime" +) + +// TSimAgentPackage is the golang structure for table t_sim_agent_package. +type TSimAgentPackage struct { + Id int64 `json:"id" orm:"id" description:"ID"` + PackageKey string `json:"packageKey" orm:"package_key" description:"套餐分类编码"` + PackageName string `json:"packageName" orm:"package_name" description:"套餐分类名称"` + ParentPackageKey string `json:"parentPackageKey" orm:"parent_package_key" description:"上级套餐分类编码"` + PackageStatus int `json:"packageStatus" orm:"package_status" description:"是否启用(0:未启用,1启用)"` + BillingMethod int `json:"billingMethod" orm:"billing_method" description:"计费方式(0:月,1:季,2:年)"` + ChannelPrice float64 `json:"channelPrice" orm:"channel_price" description:"渠道价格"` + MonthlyFee float64 `json:"monthlyFee" orm:"monthly_fee" description:"月租费"` + PackageDataMb float64 `json:"packageDataMb" orm:"package_data_mb" description:"套餐流量(MB)"` + PackageDataGb float64 `json:"packageDataGb" orm:"package_data_gb" description:"套餐流量(GB)"` + ValidityPeriod string `json:"validityPeriod" orm:"validity_period" description:"有效期"` + Description string `json:"description" orm:"description" description:"描述信息"` + OrderNum int `json:"orderNum" orm:"order_num" description:"排序"` + TenantId string `json:"tenantId" orm:"tenant_id" description:"租户ID"` + CreatedDept int64 `json:"createdDept" orm:"created_dept" description:"创建部门"` + CreatedBy int64 `json:"createdBy" orm:"created_by" description:"创建者"` + CreatedAt *gtime.Time `json:"createdAt" orm:"created_at" description:"创建时间"` + UpdatedBy int64 `json:"updatedBy" orm:"updated_by" description:"更新者"` + UpdatedAt *gtime.Time `json:"updatedAt" orm:"updated_at" description:"更新时间"` +} diff --git a/internal/model/entity/t_sim_agent_packet.go b/internal/model/entity/t_sim_agent_packet.go new file mode 100644 index 0000000..d0fe224 --- /dev/null +++ b/internal/model/entity/t_sim_agent_packet.go @@ -0,0 +1,27 @@ +// ================================================================================= +// Code generated and maintained by GoFrame CLI tool. DO NOT EDIT. +// ================================================================================= + +package entity + +import ( + "github.com/gogf/gf/v2/os/gtime" +) + +// TSimAgentPacket is the golang structure for table t_sim_agent_packet. +type TSimAgentPacket struct { + Id int64 `json:"id" orm:"id" description:"ID"` + PackageKey string `json:"packageKey" orm:"package_key" description:"套餐分类编码"` + PacketKey string `json:"packetKey" orm:"packet_key" description:"套餐编码"` + PacketName string `json:"packetName" orm:"packet_name" description:"套餐名称"` + ValidityPeriod int `json:"validityPeriod" orm:"validity_period" description:"套餐有效期(月份)"` + PacketStatus int `json:"packetStatus" orm:"packet_status" description:"是否启用(0:未启用,1启用)"` + DiscountPercent int `json:"discountPercent" orm:"discount_percent" description:"折扣率(百分比)"` + Description string `json:"description" orm:"description" description:"描述信息"` + TenantId string `json:"tenantId" orm:"tenant_id" description:"租户ID"` + CreatedDept int64 `json:"createdDept" orm:"created_dept" description:"创建部门"` + CreatedBy int64 `json:"createdBy" orm:"created_by" description:"创建者"` + CreatedAt *gtime.Time `json:"createdAt" orm:"created_at" description:"创建时间"` + UpdatedBy int64 `json:"updatedBy" orm:"updated_by" description:"更新者"` + UpdatedAt *gtime.Time `json:"updatedAt" orm:"updated_at" description:"更新时间"` +} diff --git a/internal/model/entity/t_sim_card_info.go b/internal/model/entity/t_sim_card_info.go new file mode 100644 index 0000000..6e1e371 --- /dev/null +++ b/internal/model/entity/t_sim_card_info.go @@ -0,0 +1,71 @@ +// ================================================================================= +// Code generated and maintained by GoFrame CLI tool. DO NOT EDIT. +// ================================================================================= + +package entity + +import ( + "github.com/gogf/gf/v2/os/gtime" +) + +// TSimCardInfo is the golang structure for table t_sim_card_info. +type TSimCardInfo struct { + Id int64 `json:"id" orm:"id" description:"ID"` + Msisdn string `json:"msisdn" orm:"msisdn" description:"SIM号码"` + Iccid string `json:"iccid" orm:"iccid" description:"SIM卡号"` + Imsi string `json:"imsi" orm:"imsi" description:"SIM识别码"` + Imei string `json:"imei" orm:"imei" description:"IMEI"` + OpenDate *gtime.Time `json:"openDate" orm:"open_date" description:"开卡日期(运营商)"` + ActivateDate *gtime.Time `json:"activateDate" orm:"activate_date" description:"激活时间(运营商)"` + AllocationBatch string `json:"allocationBatch" orm:"allocation_batch" description:"划拨批次"` + AllocationDate *gtime.Time `json:"allocationDate" orm:"allocation_date" description:"划拨日期"` + ChanKey string `json:"chanKey" orm:"chan_key" description:"通道编号"` + IsPool int `json:"isPool" orm:"is_pool" description:"是否流量池(0:否,1:是)"` + PoolKey string `json:"poolKey" orm:"pool_key" description:"流量池编号"` + BatchKey string `json:"batchKey" orm:"batch_key" description:"入库批次"` + BatchDate *gtime.Time `json:"batchDate" orm:"batch_date" description:"入库日期"` + PackageKey string `json:"packageKey" orm:"package_key" description:"套餐分类编码"` + CurrPacketKey string `json:"currPacketKey" orm:"curr_packet_key" description:"当前套餐编码"` + AllowPacketChange int `json:"allowPacketChange" orm:"allow_packet_change" description:"是否允许更改套餐(0:否 1:是)"` + CardStatus int `json:"cardStatus" orm:"card_status" description:"卡状态"` + ValidityPeriod int `json:"validityPeriod" orm:"validity_period" description:"有效期(月份)"` + ExpiryDate *gtime.Time `json:"expiryDate" orm:"expiry_date" description:"套餐到期日期"` + UpstreamExpiryDate *gtime.Time `json:"upstreamExpiryDate" orm:"upstream_expiry_date" description:"上游到期时间(运营商)"` + IpType string `json:"ipType" orm:"ip_type" description:"IP版本(人工录入)"` + NetModel string `json:"netModel" orm:"net_model" description:"网络模式(人工录入)"` + RegionCode string `json:"regionCode" orm:"region_code" description:"行政区域代码(省市)"` + HasOffNet int `json:"hasOffNet" orm:"has_off_net" description:"是否开启达量断网"` + OffNetQuota float64 `json:"offNetQuota" orm:"off_net_quota" description:"达量断网阈值(MB)"` + OffNetExpiryDate *gtime.Time `json:"offNetExpiryDate" orm:"off_net_expiry_date" description:"达量断网过期时间"` + AgentShowField string `json:"agentShowField" orm:"agent_show_field" description:"客户展示字段设置"` + AgentShowValue string `json:"agentShowValue" orm:"agent_show_value" description:"客户展示字段数据"` + HasTestPeriod int `json:"hasTestPeriod" orm:"has_test_period" description:"是否有测试期(0:无,1:有)"` + TestPeriod int `json:"testPeriod" orm:"test_period" description:"测试期(天)"` + TestPeriodExpiryDate *gtime.Time `json:"testPeriodExpiryDate" orm:"test_period_expiry_date" description:"测试期过期日期"` + HasSilentPeriod int `json:"hasSilentPeriod" orm:"has_silent_period" description:"是否有沉默期(0:无,1:有)"` + SilentPeriod int `json:"silentPeriod" orm:"silent_period" description:"沉默期(月)"` + SilentPeriodExpiryDate *gtime.Time `json:"silentPeriodExpiryDate" orm:"silent_period_expiry_date" description:"沉默期过期日期"` + HasAdditional int `json:"hasAdditional" orm:"has_additional" description:"是否有附加费用(0:无 1:有)"` + AdditionalMultiplier float64 `json:"additionalMultiplier" orm:"additional_multiplier" description:"附加费用倍数"` + AdditionalAmount float64 `json:"additionalAmount" orm:"additional_amount" description:"附加金额"` + DataService int `json:"dataService" orm:"data_service" description:"数据服务(0:关闭 1:开启)"` + DataTotalAmount float64 `json:"dataTotalAmount" orm:"data_total_amount" description:"套餐流量(MB)"` + DataUseAmount float64 `json:"dataUseAmount" orm:"data_use_amount" description:"已用流量(MB)"` + SmsService int `json:"smsService" orm:"sms_service" description:"短信服务(0:关闭 1:开启)"` + SmsTotalAmount float64 `json:"smsTotalAmount" orm:"sms_total_amount" description:"套餐短信数量"` + SmsUseAmount float64 `json:"smsUseAmount" orm:"sms_use_amount" description:"已用短信数量"` + VoiceService int `json:"voiceService" orm:"voice_service" description:"语音服务(0:关闭 1:开启)"` + VoiceTotalAmount float64 `json:"voiceTotalAmount" orm:"voice_total_amount" description:"套餐通话分钟数"` + VoiceUseAmount float64 `json:"voiceUseAmount" orm:"voice_use_amount" description:"已用通话分钟数"` + IntlRoamingService int `json:"intlRoamingService" orm:"intl_roaming_service" description:"国际漫游服务(0:关闭 1:开启)"` + IntlRoamingTotalAmount float64 `json:"intlRoamingTotalAmount" orm:"intl_roaming_total_amount" description:"套餐国际漫游流量(MB)"` + IntlRoamingUseAmount float64 `json:"intlRoamingUseAmount" orm:"intl_roaming_use_amount" description:"已用国际漫游流量(MB)"` + Description string `json:"description" orm:"description" description:"描述信息"` + DeptId int64 `json:"deptId" orm:"dept_id" description:"所属机构"` + TenantId string `json:"tenantId" orm:"tenant_id" description:"租户ID"` + CreatedDept int64 `json:"createdDept" orm:"created_dept" description:"创建部门"` + CreatedBy int64 `json:"createdBy" orm:"created_by" description:"创建者"` + CreatedAt *gtime.Time `json:"createdAt" orm:"created_at" description:"创建时间"` + UpdatedBy int64 `json:"updatedBy" orm:"updated_by" description:"更新者"` + UpdatedAt *gtime.Time `json:"updatedAt" orm:"updated_at" description:"更新时间"` +} diff --git a/internal/model/entity/t_sim_channel_template.go b/internal/model/entity/t_sim_channel_template.go new file mode 100644 index 0000000..6dcd0d0 --- /dev/null +++ b/internal/model/entity/t_sim_channel_template.go @@ -0,0 +1,25 @@ +// ================================================================================= +// Code generated and maintained by GoFrame CLI tool. DO NOT EDIT. +// ================================================================================= + +package entity + +import ( + "github.com/gogf/gf/v2/os/gtime" +) + +// TSimChannelTemplate is the golang structure for table t_sim_channel_template. +type TSimChannelTemplate struct { + Id int64 `json:"id" orm:"id" description:"ID"` + TempKey string `json:"tempKey" orm:"temp_key" description:"模板标识"` + TempName string `json:"tempName" orm:"temp_name" description:"模板名称"` + TempModel string `json:"tempModel" orm:"temp_model" description:"模板模型"` + TempConfig string `json:"tempConfig" orm:"temp_config" description:"模板配置"` + TempStatus int `json:"tempStatus" orm:"temp_status" description:"模板状态(0未启用,1启用)"` + TenantId string `json:"tenantId" orm:"tenant_id" description:"租户ID"` + CreatedDept int64 `json:"createdDept" orm:"created_dept" description:"创建部门"` + CreatedBy int64 `json:"createdBy" orm:"created_by" description:"创建者"` + CreatedAt *gtime.Time `json:"createdAt" orm:"created_at" description:"创建时间"` + UpdatedBy int64 `json:"updatedBy" orm:"updated_by" description:"更新者"` + UpdatedAt *gtime.Time `json:"updatedAt" orm:"updated_at" description:"更新时间"` +} diff --git a/internal/model/entity/t_sim_channels.go b/internal/model/entity/t_sim_channels.go new file mode 100644 index 0000000..41cf4bc --- /dev/null +++ b/internal/model/entity/t_sim_channels.go @@ -0,0 +1,33 @@ +// ================================================================================= +// Code generated and maintained by GoFrame CLI tool. DO NOT EDIT. +// ================================================================================= + +package entity + +import ( + "github.com/gogf/gf/v2/os/gtime" +) + +// TSimChannels is the golang structure for table t_sim_channels. +type TSimChannels struct { + Id int64 `json:"id" orm:"id" description:"ID"` + TempKey string `json:"tempKey" orm:"temp_key" description:"模板标识"` + ChanKey string `json:"chanKey" orm:"chan_key" description:"通道编号"` + ChanName string `json:"chanName" orm:"chan_name" description:"通道名称"` + ChanConfig string `json:"chanConfig" orm:"chan_config" description:"通道配置"` + ChanStatus int `json:"chanStatus" orm:"chan_status" description:"通道状态(0未启用,1启用)"` + ChanAlias string `json:"chanAlias" orm:"chan_alias" description:"通道别名"` + RegionCode string `json:"regionCode" orm:"region_code" description:"通道行政区域代码(省市)"` + AppKey string `json:"appKey" orm:"app_key" description:"应用标识"` + AppSecret string `json:"appSecret" orm:"app_secret" description:"应用密钥"` + AccessToken string `json:"accessToken" orm:"access_token" description:"接口调用凭证"` + AccessTokenCreated *gtime.Time `json:"accessTokenCreated" orm:"access_token_created" description:"凭证申请时间"` + AccessTokenExpire *gtime.Time `json:"accessTokenExpire" orm:"access_token_expire" description:"凭证过期时间"` + RefreshToken string `json:"refreshToken" orm:"refresh_token" description:"刷新凭证"` + TenantId string `json:"tenantId" orm:"tenant_id" description:"租户ID"` + CreatedDept int64 `json:"createdDept" orm:"created_dept" description:"创建部门"` + CreatedBy int64 `json:"createdBy" orm:"created_by" description:"创建者"` + CreatedAt *gtime.Time `json:"createdAt" orm:"created_at" description:"创建时间"` + UpdatedBy int64 `json:"updatedBy" orm:"updated_by" description:"更新者"` + UpdatedAt *gtime.Time `json:"updatedAt" orm:"updated_at" description:"更新时间"` +} diff --git a/internal/model/recharge.go b/internal/model/recharge.go new file mode 100644 index 0000000..21ffeaa --- /dev/null +++ b/internal/model/recharge.go @@ -0,0 +1,13 @@ +package model + +// RechargeCardInfo 腾宇 findCard 卡信息 +type RechargeCardInfo struct { + RealName int `json:"w_real_name"` + CardNo string `json:"c_no"` + StatusShowID int `json:"status_show_id"` + TotalFlow float64 `json:"c_total"` + UsedFlow float64 `json:"c_used"` + LeftFlow float64 `json:"c_left"` + ExpireTime string `json:"prodinstexp_time"` + ActivateDate string `json:"activate_date"` +} diff --git a/internal/model/sim_card_channel.go b/internal/model/sim_card_channel.go new file mode 100644 index 0000000..cab50ac --- /dev/null +++ b/internal/model/sim_card_channel.go @@ -0,0 +1,68 @@ +package model + +import ( + "context" + "git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/model/entity" + + "github.com/gogf/gf/v2/os/gtime" +) + +type SimChannelConfigItemCache struct { + Template *entity.TSimChannelTemplate + Channel *entity.TSimChannels + IsNormal *int // global_yes_or_no: 0=yes, 1=no; NULL is not eligible. + Version string +} + +// CanCreateInstance gates creation and replacement, not use of an existing instance. +func (c *SimChannelConfigItemCache) CanCreateInstance() bool { + return c != nil && c.Channel != nil && c.IsNormal != nil && *c.IsNormal == 0 +} + +type SimCardTempI interface { + NewTemp(ctx context.Context, channelConfig *SimChannelConfigItemCache) SimCardChannelI +} + +// SimCardModelFactory creates a channel implementation from a template and +// channel configuration when no concrete temp_key registration exists. +type SimCardModelFactory func(context.Context, *SimChannelConfigItemCache) SimCardChannelI + +// 接口定义 +type SimCardChannelI interface { + QuerySimBasicInfo(ctx context.Context, input *SimBasicInfoInput) (SimBasicInfoOutput, error) + QuerySimSession(ctx context.Context, input *SimSessionInput) (SimSessionOutput, error) + QuerySimCardStatus(ctx context.Context, input *SimCardStatusInput) (SimCardStatusOutput, error) + QuerySimStopReason(ctx context.Context, input *SimStopReasonInput) (SimStopReasonOutput, error) + QuerySimFrozenState(ctx context.Context, input *SimFrozenStateInput) (SimFrozenStateOutput, error) + QuerySimCardBindStatus(ctx context.Context, input *SimCardBindStatusInput) (SimCardBindStatusOutput, error) + QuerySimDeviceStatus(ctx context.Context, input *SimDeviceStatusInput) (SimDeviceStatusOutput, error) + QuerySimRegionLimitStatus(ctx context.Context, input *SimRegionLimitStatusInput) (SimRegionLimitStatusOutput, error) + QuerySimImei(ctx context.Context, input *SimImeiInput) (SimImeiOutput, error) + QuerySimDataUsage(ctx context.Context, input *SimDataUsageInput) (SimDataUsageOutput, error) + QuerySimDataDiagnosis(ctx context.Context, input *SimDataDiagnosisInput) (SimDataDiagnosisOutput, error) + QuerySimDataUsageMonthly(ctx context.Context, input *SimDataUsageMonthlyInput) (SimDataUsageMonthlyOutput, error) + QuerySimDataUsageDaily(ctx context.Context, input *SimDataUsageDailyInput) (SimDataUsageDailyOutput, error) + QuerySimApnInfo(ctx context.Context, input *SimApnInfoInput) (SimApnInfoOutput, error) + ChangeSimStatus(ctx context.Context, input *SimCardStatusChangeInput) (SimCardStatusChangeOutput, error) + ChangeSimApnFunction(ctx context.Context, input *SimCardOperateChangeApnInput) (SimCardOperateChangeApnOutput, error) +} + +type SimCardBatchQueryI interface { + QuerySimCardStatusBatch(ctx context.Context, input *SimCardStatusInput) (SimCardStatusOutput, error) + QuerySimDataUsageMonthlyBatch(ctx context.Context, input *SimDataUsageMonthlyInput) (SimDataUsageMonthlyOutput, error) + QuerySimDataUsageDailyBatch(ctx context.Context, input *SimDataUsageDailyInput) (SimDataUsageDailyOutput, error) +} + +type SimCardChannelCache struct { + Instance SimCardChannelI + Version string +} + +type UpdateAccessTokenInput struct { + ChanKey string + AccessToken string + AccessTokenCreated *gtime.Time + AccessTokenExpire *gtime.Time + RefreshToken string + RefreshTokenExpire *gtime.Time +} diff --git a/internal/model/sim_chan_api.go b/internal/model/sim_chan_api.go new file mode 100644 index 0000000..15e58bb --- /dev/null +++ b/internal/model/sim_chan_api.go @@ -0,0 +1,309 @@ +package model + +// 物联网卡标识 +type SimKey struct { + // ChanKey is required for the asynchronous full-sync caller. Supplying it + // lets Chan route directly without looking the channel up through Proxy. + ChanKey string `json:"chanKey" dc:"通道标识"` + ICCID string `json:"iccid" dc:"联网卡集成电路卡识别码20位"` + IMSI string `json:"imsi" dc:"国际移动用户识别码"` + MSISDN string `json:"msisdn" dc:"移动用户识别码"` +} + +// 物联网卡基础请求参数 +type SimBaseReq[T any] struct { + SimKeys []*SimKey `json:"simKeys" dc:"SIM卡标识列表"` + ReqData T `json:"reqData" dc:"请求数据"` +} + +// 物联网卡基础响应参数 +type SimBaseRes[T any] struct { + Code int `json:"code" dc:"状态码"` + Message string `json:"message" dc:"消息"` + Cached bool `json:"cached,omitempty" dc:"是否来自缓存"` + UpdatedAt string `json:"updatedAt,omitempty" dc:"缓存更新时间"` + // ErrCode carries the raw carrier error code (for example telecom S00004) + // so downstream consumers can classify errors without parsing Message. + ErrCode string `json:"errCode" dc:"运营商原始错误码"` + SimKey *SimKey `json:"simKey" dc:"SIM卡标识"` + ResData T `json:"resData" dc:"响应数据"` +} + +// 查询请求参数 空结构 +type SimInputReqNullDate struct{} + +// 物联网卡基本信息查询 +type SimBasicInfoInput = SimBaseReq[SimInputReqNullDate] + +// 物联网卡基本信息查询 响应参数 +type SimBasicInfoOutput = []*SimBaseRes[SimBasicInfoItem] + +// 物联网卡基本信息查询 响应参数 基本信息项 +type SimBasicInfoItem struct { + ICCID string `json:"iccid" dc:"联网卡集成电路卡识别码"` + IMSI string `json:"imsi" dc:"国际移动用户识别码"` + MSISDN string `json:"msisdn" dc:"接入号码"` + ActiveDate string `json:"activeDate" dc:"激活时间"` + OpenDate string `json:"openDate" dc:"开卡时间"` + Remark string `json:"remark" dc:"备注"` + SimStatus string `json:"simStatus" dc:"SIM卡状态"` + StopTypeList []string `json:"stopTypeList" dc:"停机类型列表"` + MainOfferName string `json:"mainOfferName" dc:"主套餐名称"` + CardBindingType string `json:"cardBindingType" dc:"机卡绑定类型"` + IsDirectVisit string `json:"isDirectVisit" dc:"是否定向访问"` + IsHumanLimit string `json:"isHumanLimit" dc:"是否有人联网限制"` + VpdnGreDoamin string `json:"vpdnGreDoamin" dc:"VPDN(GRE)功能产品域名"` + VpdnL2tpDoamin string `json:"vpdnL2tpDoamin" dc:"VPDN(L2TP)功能产品域名"` + AreaLimitType string `json:"areaLimitType" dc:"区域限制类型"` + AreaLimitInfo string `json:"areaLimitInfo" dc:"区域限制信息"` +} + +// 物联网卡在线信息实时查询 请求参数 +type SimSessionInput = SimBaseReq[SimInputReqNullDate] + +// 物联网卡在线信息实时查询 响应参数 +type SimSessionOutput = []*SimBaseRes[SimSessionItem] + +// 物联网卡在线信息实时查询 响应参数 会话项 +type SimSessionItem struct { + ApnId string `json:"apnId" dc:"APN ID"` + Imei string `json:"imei" dc:"实时IMEI"` + Rat string `json:"rat" dc:"接入方式"` + Ipv4 string `json:"ipv4" dc:"IPV4"` + Ipv6 string `json:"ipv6" dc:"IPV6"` + Ipv6Prefix string `json:"ipv6Prefix" dc:"IPV6前缀"` + Status string `json:"status" dc:"会话在线状态(0:在线 -1:不在线 -2:未查到会话)"` + CreateTime string `json:"createTime" dc:"创建时间"` +} + +// 物联网卡停机原因查询 请求参数 +type SimStopReasonInput = SimBaseReq[SimInputReqNullDate] + +// 物联网卡停机原因查询 响应参数 +type SimStopReasonOutput = []*SimBaseRes[SimStopReasonItem] + +// 物联网卡停机原因查询 响应参数 停机原因项 +type SimStopReasonItem struct { + StopReason string `json:"stopReason" dc:"停机原因"` + PlatformType string `json:"platformType" dc:"平台类型"` +} + +// 物联网卡冻结状态查询 请求参数 +type SimFrozenStateInput = SimBaseReq[SimInputReqNullDate] + +// 物联网卡冻结状态查询 响应参数 +type SimFrozenStateOutput = []*SimBaseRes[SimFrozenStateItem] + +// 物联网卡冻结状态查询 响应参数 冻结状态项 +type SimFrozenStateItem struct { + FrozenStatus string `json:"frozenStatus" dc:"冻结状态"` + FrozenReason string `json:"frozenReason" dc:"冻结原因"` + OperTime string `json:"operTime" dc:"操作时间"` +} + +// 物联网卡卡状态查询 请求参数 +type SimCardStatusInput = SimBaseReq[SimInputReqNullDate] + +// 物联网卡卡状态查询 响应参数 +type SimCardStatusOutput = []*SimBaseRes[SimCardStatusItem] + +// 物联网卡卡状态查询 响应参数 卡状态项 +type SimNetBlockType struct { + BlockType string `json:"blockType"` + BlockTypeName string `json:"blockTypeName"` +} + +type SimCardStatusItem struct { + NetBlockStatus *string `json:"netBlockStatus,omitempty" dc:"断网状态:0未断网 1已断网"` + NetBlockTypes []SimNetBlockType `json:"netBlockTypes,omitempty" dc:"断网类型列表"` + CardStatus int `json:"cardStatus" dc:"卡状态"` + LastChangeDate string `json:"lastChangeDate" dc:"最后变更时间"` +} + +// 物联网卡IMEI查询 请求参数 +type SimImeiInput = SimBaseReq[SimInputReqNullDate] + +// 物联网卡IMEI查询 响应参数 +type SimImeiOutput = []*SimBaseRes[SimImeiItem] + +// 物联网卡IMEI查询 响应参数 IMEI项 +type SimImeiItem struct { + Imei string `json:"imei" dc:"IMEI"` +} + +// 单卡本月流量累计使用量查询 请求参数 +type SimDataUsageInPoolInput = SimBaseReq[SimInputReqNullDate] + +// 单卡本月流量累计使用量查询 响应参数 +type SimDataUsageInPoolOutput = []*SimBaseRes[SimDataUsageInPoolItem] + +// 单卡本月流量累计使用量查询 响应参数 流量累计使用量项 +type SimDataUsageInPoolItem struct { + DataUsage float64 `json:"dataUsage" dc:"流量累计使用量(单位: KB)"` +} + +// SimDataUsageInput is the real-time current-month usage contract. +// +// The asynchronous consumer should send one chanKey per request and include +// all three card identifiers available in the card table. ReqData is empty: +// the upstream endpoint queries the current month at call time, unlike the +// historical monthly endpoint which accepts queryDate. +type SimDataUsageInput = SimBaseReq[SimInputReqNullDate] + +// 单卡本月流量累计使用量查询 响应参数 +type SimDataUsageOutput = []*SimBaseRes[SimDataUsageItem] + +// 单卡本月流量累计使用量查询 响应参数 流量累计使用量项 +type SimDataUsageItem struct { + PackageFlow *SimPackageFlowItem `json:"packageFlow,omitempty" dc:"Package-period flow in MB, separate from current-month usage"` + PackageFlowError string `json:"packageFlowError,omitempty" dc:"Package flow query failure; absent package flow is not zero"` + // Chan normalizes carrier responses to MB for this real-time contract. + DataUsage float64 `json:"dataUsage" dc:"流量累计使用量(单位: MB)"` + TotalFlow *float64 `json:"totalFlow,omitempty" dc:"套餐总流量(单位: MB)"` +} + +// 物联卡达量限速档位查询 请求参数 +type SimDataDiagnosisInput = SimBaseReq[SimInputReqNullDate] + +// 物联卡达量限速档位查询 响应参数 +type SimDataDiagnosisOutput = []*SimBaseRes[any] + +// 物联卡达量限速档位查询 响应参数 +type SimDataDiagnosisItem struct { + RateLimitStatus string `json:"rateLimitStatus" dc:"达量限速状态:0已触发 1未触发 2未知 3运营商暂不支持"` + RateLimitReason string `json:"rateLimitReason" dc:"达量限速原因"` + RateLimitDetail string `json:"rateLimitDetail" dc:"达量限速明细"` + ApnDetails []SimDataDiagnosisApnDetail `json:"apnDetails" dc:"APN达量限速明细"` +} + +type SimDataDiagnosisApnDetail struct { + ApnName string `json:"apnName" dc:"APN名称"` + ApnStatus string `json:"apnStatus" dc:"APN状态"` + ModifyTime string `json:"modifyTime" dc:"APN状态变更时间"` + OverUseDetails string `json:"overUseDetails" dc:"超停原因"` + ApnServiceUsageState string `json:"apnServiceUsageState" dc:"APN业务配额状态"` + PccDetails []SimDataDiagnosisPccDetail `json:"pccDetails" dc:"PCC白名单策略明细"` +} + +type SimDataDiagnosisPccDetail struct { + PccServiceCode string `json:"pccServiceCode" dc:"PCC白名单策略编码"` + PccName string `json:"pccName" dc:"PCC白名单策略名称"` + PccPolicyStatus string `json:"pccPolicyStatus" dc:"PCC白名单策略状态"` + PccServiceUsageState string `json:"pccServiceUsageState" dc:"PCC白名单业务配额状态"` + ModifyTime string `json:"modifyTime" dc:"PCC状态变更时间"` +} + +// 物联卡单月 GPRS流量使用量批量查询 请求参数 +type SimDataUsageMonthlyInput = SimBaseReq[SimDataUsageMonthlyInputItem] +type SimDataUsageMonthlyInputItem struct { + QueryDate string `json:"queryDate" dc:"查询月份,格式yyyyMM,仅支持已结束月份"` +} + +// 物联卡单月 GPRS流量使用量批量查询 响应参数 +type SimDataUsageMonthlyOutput = []*SimBaseRes[SimDataUsageMonthlyOutputItem] +type SimDataUsageMonthlyOutputItem struct { + DeadLine string `json:"deadLine" dc:"截止时间"` + DataAmount float64 `json:"dataAmount" dc:"流量累计使用量"` +} + +// 物联卡单日 GPRS流量使用量查询 请求参数 +type SimDataUsageDailyInput = SimBaseReq[SimDataUsageDailyInputItem] +type SimDataUsageDailyInputItem struct { + QueryDate string `json:"queryDate" dc:"查询日期,格式yyyyMMdd,仅支持已结束日期"` +} + +// 物联卡单日 GPRS流量使用量查询 响应参数 +type SimDataUsageDailyOutput = []*SimBaseRes[SimDataUsageDailyOutputItem] +type SimDataUsageDailyOutputItem struct { + QueryDate string `json:"queryDate" dc:"查询日期,格式yyyyMMdd"` + DataAmount float64 `json:"dataAmount" dc:"单日流量使用量,单位MB"` +} + +// 单卡已开通APN信息查询 请求参数 +type SimApnInfoInput = SimBaseReq[SimInputReqNullDate] + +// 单卡已开通APN信息查询 响应参数 +type SimApnInfoOutput = []*SimBaseRes[SimApnInfoItemList] + +// 单卡已开通APN信息查询 响应参数 APN信息项 +type SimApnInfoItemList struct { + ApnList []SimApnInfoItem `json:"apnList" dc:"APN列表"` +} +type SimApnInfoItem struct { + ApnName string `json:"apnName" dc:"APN名称"` + Status string `json:"status" dc:"APN信息状态(0:暂停 1:恢复/正常)"` +} + +// 物联网卡状态变更 请求参数 +type SimCardStatusChangeInput = SimBaseReq[SimCardStatusChangeInputItem] + +// 物联网卡状态变更 请求参数 操作类型 +type SimCardStatusChangeInputItem struct { + OperType string `json:"operType" dc:"单卡卡状态变更操作类型: 0=申请停机(已激活转已停机), 1=申请复机(已停机转已激活)"` +} + +// 物联网卡状态变更 响应参数 +type SimCardStatusChangeOutput = []*SimBaseRes[SimCardStatusChangeItem] + +// 物联网卡状态变更 响应参数 数据项 +type SimCardStatusChangeItem struct { + OrderNum string `json:"orderNum" dc:"订单号"` +} + +// 单卡 APN功能开停 请求参数 +type SimCardOperateChangeApnInput = SimBaseReq[SimCardOperateChangeApnInputItem] + +// 单卡 APN功能开停 请求参数 操作类型 +type SimCardOperateChangeApnInputItem struct { + OperType string `json:"operType" dc:"APN功能开停操作类型(0:开 1:停)"` + ApnName string `json:"apnName" dc:"APN名称"` +} + +// 单卡 APN功能开停 响应参数 +type SimCardOperateChangeApnOutput = []*SimBaseRes[SimCardOperateChangeApnItem] + +// 单卡 APN功能开停 响应参数 数据项 +type SimCardOperateChangeApnItem struct { + OrderNum string `json:"orderNum" dc:"订单号"` +} + +// 物联网卡绑定状态查询 请求参数 +type SimCardBindStatusInput = SimBaseReq[SimCardBindStatusInputItem] +type SimCardBindStatusInputItem struct { + TestType *int `json:"testType" dc:"分离检测方式:0话单侧检测 1网络侧检测"` +} +type SimCardBindStatusOutput = []*SimBaseRes[SimCardBindStatusItem] + +// 物联网卡绑定状态查询 响应参数 数据项 +type SimCardBindStatusItem struct { + BindStatus string `json:"bindStatus" dc:"机卡绑定状态"` + CardBindingType string `json:"cardBindingType" dc:"机卡绑定类型"` + OrderStatus string `json:"orderStatus,omitempty" dc:"机卡绑定订购状态:0订购 1未订购"` + BindImei string `json:"bindImei,omitempty" dc:"绑定IMEI"` + LastImei string `json:"lastImei,omitempty" dc:"最近一次上线IMEI"` + ErrorCode string `json:"errorCode,omitempty" dc:"错误码"` + ErrorDes string `json:"errorDes,omitempty" dc:"错误描述"` +} + +// 物联网卡设备状态查询 请求参数 +type SimDeviceStatusInput = SimBaseReq[SimInputReqNullDate] +type SimDeviceStatusOutput = []*SimBaseRes[SimDeviceStatusItem] + +// 物联网卡设备状态查询 响应参数 数据项 +type SimDeviceStatusItem struct { + DeviceStatus string `json:"deviceStatus" dc:"兼容设备状态:0异常/不可用 1正常"` + OperatingStatus string `json:"operatingStatus,omitempty" dc:"终端开关机状态:0关机 1开机"` +} + +// 物联网卡区域限制状态查询 请求参数 +type SimRegionLimitStatusInput = SimBaseReq[SimInputReqNullDate] +type SimRegionLimitStatusOutput = []*SimBaseRes[SimRegionLimitStatusItem] + +// 物联网卡区域限制状态查询 响应参数 数据项 +type SimRegionLimitStatusItem struct { + RegionLimitStatus string `json:"regionLimitStatus" dc:"地域限制状态"` + OrderStatus string `json:"orderStatus" dc:"区域限制订购状态"` + LimitStatus string `json:"limitStatus" dc:"区域限制触发状态"` + LimitType string `json:"limitType" dc:"区域限制类型"` + LimitArea string `json:"limitArea" dc:"限制区域"` +} diff --git a/internal/model/sim_package_flow.go b/internal/model/sim_package_flow.go new file mode 100644 index 0000000..ae3975c --- /dev/null +++ b/internal/model/sim_package_flow.go @@ -0,0 +1,7 @@ +package model + +type SimPackageFlowItem struct { + TotalFlow float64 `json:"totalFlow" dc:"Package total in MB"` + DataUsage float64 `json:"dataUsage" dc:"Package cumulative usage in MB"` + RemainingFlow float64 `json:"remainingFlow" dc:"Max(package total minus package cumulative usage, 0), in MB"` +} diff --git a/internal/packed/packed.go b/internal/packed/packed.go new file mode 100644 index 0000000..e20ab1e --- /dev/null +++ b/internal/packed/packed.go @@ -0,0 +1 @@ +package packed diff --git a/internal/pb/common.pb.go b/internal/pb/common.pb.go new file mode 100644 index 0000000..9a5c740 --- /dev/null +++ b/internal/pb/common.pb.go @@ -0,0 +1,206 @@ +// Code generated by protoc-gen-go. DO NOT EDIT. +// versions: +// protoc-gen-go v1.36.11 +// protoc v5.29.3 +// source: common.proto + +package pb + +import ( + reflect "reflect" + sync "sync" + unsafe "unsafe" + + protoreflect "google.golang.org/protobuf/reflect/protoreflect" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" +) + +const ( + // Verify that this generated code is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) + // Verify that runtime/protoimpl is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) +) + +type HeadReq struct { + state protoimpl.MessageState `protogen:"open.v1"` + RouterId int64 `protobuf:"varint,1,opt,name=router_id,json=routerId,proto3" json:"router_id,omitempty" dc:"路由id json:'router_id'"` // 路由id json:"router_id" + TraceId string `protobuf:"bytes,2,opt,name=trace_id,json=traceId,proto3" json:"trace_id,omitempty" dc:"追踪id json:'trace_id'"` // 追踪id json:"trace_id" + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *HeadReq) Reset() { + *x = HeadReq{} + mi := &file_common_proto_msgTypes[0] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *HeadReq) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*HeadReq) ProtoMessage() {} + +func (x *HeadReq) ProtoReflect() protoreflect.Message { + mi := &file_common_proto_msgTypes[0] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use HeadReq.ProtoReflect.Descriptor instead. +func (*HeadReq) Descriptor() ([]byte, []int) { + return file_common_proto_rawDescGZIP(), []int{0} +} + +func (x *HeadReq) GetRouterId() int64 { + if x != nil { + return x.RouterId + } + return 0 +} + +func (x *HeadReq) GetTraceId() string { + if x != nil { + return x.TraceId + } + return "" +} + +type HeadRes struct { + state protoimpl.MessageState `protogen:"open.v1"` + Code int32 `protobuf:"varint,1,opt,name=code,proto3" json:"code,omitempty" dc:"错误码 json:'code'"` // 错误码 json:"code" + Message string `protobuf:"bytes,2,opt,name=message,proto3" json:"message,omitempty" dc:"错误信息 json:'message'"` // 错误信息 json:"message" + RouterId int64 `protobuf:"varint,3,opt,name=router_id,json=routerId,proto3" json:"router_id,omitempty" dc:"路由id json:'router_id'"` // 路由id json:"router_id" + TraceId string `protobuf:"bytes,4,opt,name=trace_id,json=traceId,proto3" json:"trace_id,omitempty" dc:"追踪id json:'trace_id'"` // 追踪id json:"trace_id" + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *HeadRes) Reset() { + *x = HeadRes{} + mi := &file_common_proto_msgTypes[1] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *HeadRes) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*HeadRes) ProtoMessage() {} + +func (x *HeadRes) ProtoReflect() protoreflect.Message { + mi := &file_common_proto_msgTypes[1] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use HeadRes.ProtoReflect.Descriptor instead. +func (*HeadRes) Descriptor() ([]byte, []int) { + return file_common_proto_rawDescGZIP(), []int{1} +} + +func (x *HeadRes) GetCode() int32 { + if x != nil { + return x.Code + } + return 0 +} + +func (x *HeadRes) GetMessage() string { + if x != nil { + return x.Message + } + return "" +} + +func (x *HeadRes) GetRouterId() int64 { + if x != nil { + return x.RouterId + } + return 0 +} + +func (x *HeadRes) GetTraceId() string { + if x != nil { + return x.TraceId + } + return "" +} + +var File_common_proto protoreflect.FileDescriptor + +const file_common_proto_rawDesc = "" + + "\n" + + "\fcommon.proto\x12\bPbCommon\"A\n" + + "\aHeadReq\x12\x1b\n" + + "\trouter_id\x18\x01 \x01(\x03R\brouterId\x12\x19\n" + + "\btrace_id\x18\x02 \x01(\tR\atraceId\"o\n" + + "\aHeadRes\x12\x12\n" + + "\x04code\x18\x01 \x01(\x05R\x04code\x12\x18\n" + + "\amessage\x18\x02 \x01(\tR\amessage\x12\x1b\n" + + "\trouter_id\x18\x03 \x01(\x03R\brouterId\x12\x19\n" + + "\btrace_id\x18\x04 \x01(\tR\atraceIdB\x05Z\x03pb/b\x06proto3" + +var ( + file_common_proto_rawDescOnce sync.Once + file_common_proto_rawDescData []byte +) + +func file_common_proto_rawDescGZIP() []byte { + file_common_proto_rawDescOnce.Do(func() { + file_common_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_common_proto_rawDesc), len(file_common_proto_rawDesc))) + }) + return file_common_proto_rawDescData +} + +var file_common_proto_msgTypes = make([]protoimpl.MessageInfo, 2) +var file_common_proto_goTypes = []any{ + (*HeadReq)(nil), // 0: PbCommon.HeadReq + (*HeadRes)(nil), // 1: PbCommon.HeadRes +} +var file_common_proto_depIdxs = []int32{ + 0, // [0:0] is the sub-list for method output_type + 0, // [0:0] is the sub-list for method input_type + 0, // [0:0] is the sub-list for extension type_name + 0, // [0:0] is the sub-list for extension extendee + 0, // [0:0] is the sub-list for field type_name +} + +func init() { file_common_proto_init() } +func file_common_proto_init() { + if File_common_proto != nil { + return + } + type x struct{} + out := protoimpl.TypeBuilder{ + File: protoimpl.DescBuilder{ + GoPackagePath: reflect.TypeOf(x{}).PkgPath(), + RawDescriptor: unsafe.Slice(unsafe.StringData(file_common_proto_rawDesc), len(file_common_proto_rawDesc)), + NumEnums: 0, + NumMessages: 2, + NumExtensions: 0, + NumServices: 0, + }, + GoTypes: file_common_proto_goTypes, + DependencyIndexes: file_common_proto_depIdxs, + MessageInfos: file_common_proto_msgTypes, + }.Build() + File_common_proto = out.File + file_common_proto_goTypes = nil + file_common_proto_depIdxs = nil +} diff --git a/internal/pb/info.pb.go b/internal/pb/info.pb.go new file mode 100644 index 0000000..271cd30 --- /dev/null +++ b/internal/pb/info.pb.go @@ -0,0 +1,257 @@ +// Code generated by protoc-gen-go. DO NOT EDIT. +// versions: +// protoc-gen-go v1.36.11 +// protoc v5.29.3 +// source: info.proto + +package pb + +import ( + reflect "reflect" + sync "sync" + unsafe "unsafe" + + protoreflect "google.golang.org/protobuf/reflect/protoreflect" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" +) + +const ( + // Verify that this generated code is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) + // Verify that runtime/protoimpl is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) +) + +type InfoReq struct { + state protoimpl.MessageState `protogen:"open.v1"` + Head *HeadReq `protobuf:"bytes,1,opt,name=head,proto3" json:"head,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *InfoReq) Reset() { + *x = InfoReq{} + mi := &file_info_proto_msgTypes[0] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *InfoReq) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*InfoReq) ProtoMessage() {} + +func (x *InfoReq) ProtoReflect() protoreflect.Message { + mi := &file_info_proto_msgTypes[0] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use InfoReq.ProtoReflect.Descriptor instead. +func (*InfoReq) Descriptor() ([]byte, []int) { + return file_info_proto_rawDescGZIP(), []int{0} +} + +func (x *InfoReq) GetHead() *HeadReq { + if x != nil { + return x.Head + } + return nil +} + +type InfoRes struct { + state protoimpl.MessageState `protogen:"open.v1"` + Head *HeadRes `protobuf:"bytes,1,opt,name=head,proto3" json:"head,omitempty"` + Type string `protobuf:"bytes,2,opt,name=type,proto3" json:"type,omitempty"` + Name string `protobuf:"bytes,3,opt,name=name,proto3" json:"name,omitempty"` + Title string `protobuf:"bytes,4,opt,name=title,proto3" json:"title,omitempty"` + Description string `protobuf:"bytes,5,opt,name=description,proto3" json:"description,omitempty"` + Version string `protobuf:"bytes,6,opt,name=version,proto3" json:"version,omitempty"` + Build string `protobuf:"bytes,7,opt,name=build,proto3" json:"build,omitempty"` + Commit string `protobuf:"bytes,8,opt,name=commit,proto3" json:"commit,omitempty"` + Data map[string]string `protobuf:"bytes,9,rep,name=data,proto3" json:"data,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *InfoRes) Reset() { + *x = InfoRes{} + mi := &file_info_proto_msgTypes[1] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *InfoRes) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*InfoRes) ProtoMessage() {} + +func (x *InfoRes) ProtoReflect() protoreflect.Message { + mi := &file_info_proto_msgTypes[1] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use InfoRes.ProtoReflect.Descriptor instead. +func (*InfoRes) Descriptor() ([]byte, []int) { + return file_info_proto_rawDescGZIP(), []int{1} +} + +func (x *InfoRes) GetHead() *HeadRes { + if x != nil { + return x.Head + } + return nil +} + +func (x *InfoRes) GetType() string { + if x != nil { + return x.Type + } + return "" +} + +func (x *InfoRes) GetName() string { + if x != nil { + return x.Name + } + return "" +} + +func (x *InfoRes) GetTitle() string { + if x != nil { + return x.Title + } + return "" +} + +func (x *InfoRes) GetDescription() string { + if x != nil { + return x.Description + } + return "" +} + +func (x *InfoRes) GetVersion() string { + if x != nil { + return x.Version + } + return "" +} + +func (x *InfoRes) GetBuild() string { + if x != nil { + return x.Build + } + return "" +} + +func (x *InfoRes) GetCommit() string { + if x != nil { + return x.Commit + } + return "" +} + +func (x *InfoRes) GetData() map[string]string { + if x != nil { + return x.Data + } + return nil +} + +var File_info_proto protoreflect.FileDescriptor + +const file_info_proto_rawDesc = "" + + "\n" + + "\n" + + "info.proto\x12\x06PbInfo\x1a\fcommon.proto\"0\n" + + "\aInfoReq\x12%\n" + + "\x04head\x18\x01 \x01(\v2\x11.PbCommon.HeadReqR\x04head\"\xc0\x02\n" + + "\aInfoRes\x12%\n" + + "\x04head\x18\x01 \x01(\v2\x11.PbCommon.HeadResR\x04head\x12\x12\n" + + "\x04type\x18\x02 \x01(\tR\x04type\x12\x12\n" + + "\x04name\x18\x03 \x01(\tR\x04name\x12\x14\n" + + "\x05title\x18\x04 \x01(\tR\x05title\x12 \n" + + "\vdescription\x18\x05 \x01(\tR\vdescription\x12\x18\n" + + "\aversion\x18\x06 \x01(\tR\aversion\x12\x14\n" + + "\x05build\x18\a \x01(\tR\x05build\x12\x16\n" + + "\x06commit\x18\b \x01(\tR\x06commit\x12-\n" + + "\x04data\x18\t \x03(\v2\x19.PbInfo.InfoRes.DataEntryR\x04data\x1a7\n" + + "\tDataEntry\x12\x10\n" + + "\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" + + "\x05value\x18\x02 \x01(\tR\x05value:\x028\x0122\n" + + "\x04Info\x12*\n" + + "\x04Info\x12\x0f.PbInfo.InfoReq\x1a\x0f.PbInfo.InfoRes\"\x00B\x05Z\x03pb/b\x06proto3" + +var ( + file_info_proto_rawDescOnce sync.Once + file_info_proto_rawDescData []byte +) + +func file_info_proto_rawDescGZIP() []byte { + file_info_proto_rawDescOnce.Do(func() { + file_info_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_info_proto_rawDesc), len(file_info_proto_rawDesc))) + }) + return file_info_proto_rawDescData +} + +var file_info_proto_msgTypes = make([]protoimpl.MessageInfo, 3) +var file_info_proto_goTypes = []any{ + (*InfoReq)(nil), // 0: PbInfo.InfoReq + (*InfoRes)(nil), // 1: PbInfo.InfoRes + nil, // 2: PbInfo.InfoRes.DataEntry + (*HeadReq)(nil), // 3: PbCommon.HeadReq + (*HeadRes)(nil), // 4: PbCommon.HeadRes +} +var file_info_proto_depIdxs = []int32{ + 3, // 0: PbInfo.InfoReq.head:type_name -> PbCommon.HeadReq + 4, // 1: PbInfo.InfoRes.head:type_name -> PbCommon.HeadRes + 2, // 2: PbInfo.InfoRes.data:type_name -> PbInfo.InfoRes.DataEntry + 0, // 3: PbInfo.Info.Info:input_type -> PbInfo.InfoReq + 1, // 4: PbInfo.Info.Info:output_type -> PbInfo.InfoRes + 4, // [4:5] is the sub-list for method output_type + 3, // [3:4] is the sub-list for method input_type + 3, // [3:3] is the sub-list for extension type_name + 3, // [3:3] is the sub-list for extension extendee + 0, // [0:3] is the sub-list for field type_name +} + +func init() { file_info_proto_init() } +func file_info_proto_init() { + if File_info_proto != nil { + return + } + file_common_proto_init() + type x struct{} + out := protoimpl.TypeBuilder{ + File: protoimpl.DescBuilder{ + GoPackagePath: reflect.TypeOf(x{}).PkgPath(), + RawDescriptor: unsafe.Slice(unsafe.StringData(file_info_proto_rawDesc), len(file_info_proto_rawDesc)), + NumEnums: 0, + NumMessages: 3, + NumExtensions: 0, + NumServices: 1, + }, + GoTypes: file_info_proto_goTypes, + DependencyIndexes: file_info_proto_depIdxs, + MessageInfos: file_info_proto_msgTypes, + }.Build() + File_info_proto = out.File + file_info_proto_goTypes = nil + file_info_proto_depIdxs = nil +} diff --git a/internal/pb/info_grpc.pb.go b/internal/pb/info_grpc.pb.go new file mode 100644 index 0000000..716c3aa --- /dev/null +++ b/internal/pb/info_grpc.pb.go @@ -0,0 +1,122 @@ +// Code generated by protoc-gen-go-grpc. DO NOT EDIT. +// versions: +// - protoc-gen-go-grpc v1.6.1 +// - protoc v5.29.3 +// source: info.proto + +package pb + +import ( + context "context" + + grpc "google.golang.org/grpc" + codes "google.golang.org/grpc/codes" + status "google.golang.org/grpc/status" +) + +// This is a compile-time assertion to ensure that this generated file +// is compatible with the grpc package it is being compiled against. +// Requires gRPC-Go v1.64.0 or later. +const _ = grpc.SupportPackageIsVersion9 + +const ( + Info_Info_FullMethodName = "/PbInfo.Info/Info" +) + +// InfoClient is the client API for Info service. +// +// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream. +type InfoClient interface { + Info(ctx context.Context, in *InfoReq, opts ...grpc.CallOption) (*InfoRes, error) +} + +type infoClient struct { + cc grpc.ClientConnInterface +} + +func NewInfoClient(cc grpc.ClientConnInterface) InfoClient { + return &infoClient{cc} +} + +func (c *infoClient) Info(ctx context.Context, in *InfoReq, opts ...grpc.CallOption) (*InfoRes, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(InfoRes) + err := c.cc.Invoke(ctx, Info_Info_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +// InfoServer is the server API for Info service. +// All implementations must embed UnimplementedInfoServer +// for forward compatibility. +type InfoServer interface { + Info(context.Context, *InfoReq) (*InfoRes, error) + mustEmbedUnimplementedInfoServer() +} + +// UnimplementedInfoServer must be embedded to have +// forward compatible implementations. +// +// NOTE: this should be embedded by value instead of pointer to avoid a nil +// pointer dereference when methods are called. +type UnimplementedInfoServer struct{} + +func (UnimplementedInfoServer) Info(context.Context, *InfoReq) (*InfoRes, error) { + return nil, status.Error(codes.Unimplemented, "method Info not implemented") +} +func (UnimplementedInfoServer) mustEmbedUnimplementedInfoServer() {} +func (UnimplementedInfoServer) testEmbeddedByValue() {} + +// UnsafeInfoServer may be embedded to opt out of forward compatibility for this service. +// Use of this interface is not recommended, as added methods to InfoServer will +// result in compilation errors. +type UnsafeInfoServer interface { + mustEmbedUnimplementedInfoServer() +} + +func RegisterInfoServer(s grpc.ServiceRegistrar, srv InfoServer) { + // If the following call panics, it indicates UnimplementedInfoServer was + // embedded by pointer and is nil. This will cause panics if an + // unimplemented method is ever invoked, so we test this at initialization + // time to prevent it from happening at runtime later due to I/O. + if t, ok := srv.(interface{ testEmbeddedByValue() }); ok { + t.testEmbeddedByValue() + } + s.RegisterService(&Info_ServiceDesc, srv) +} + +func _Info_Info_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(InfoReq) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(InfoServer).Info(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: Info_Info_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(InfoServer).Info(ctx, req.(*InfoReq)) + } + return interceptor(ctx, in, info, handler) +} + +// Info_ServiceDesc is the grpc.ServiceDesc for Info service. +// It's only intended for direct use with grpc.RegisterService, +// and not to be introspected or modified (even as a copy) +var Info_ServiceDesc = grpc.ServiceDesc{ + ServiceName: "PbInfo.Info", + HandlerType: (*InfoServer)(nil), + Methods: []grpc.MethodDesc{ + { + MethodName: "Info", + Handler: _Info_Info_Handler, + }, + }, + Streams: []grpc.StreamDesc{}, + Metadata: "info.proto", +} diff --git a/internal/pb/msgbus.pb.go b/internal/pb/msgbus.pb.go new file mode 100644 index 0000000..e215821 --- /dev/null +++ b/internal/pb/msgbus.pb.go @@ -0,0 +1,632 @@ +// Code generated by protoc-gen-go. DO NOT EDIT. +// versions: +// protoc-gen-go v1.36.11 +// protoc v5.29.3 +// source: msgbus.proto + +package pb + +import ( + reflect "reflect" + sync "sync" + unsafe "unsafe" + + protoreflect "google.golang.org/protobuf/reflect/protoreflect" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" +) + +const ( + // Verify that this generated code is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) + // Verify that runtime/protoimpl is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) +) + +type EnumMsgType int32 + +const ( + EnumMsgType_MsgTypeNone EnumMsgType = 0 // 未知消息类型 + EnumMsgType_MsgTypeLogin EnumMsgType = 1 // 客户端长连接登录包 + EnumMsgType_MsgTypeLogout EnumMsgType = 2 // 客户端长连接登出包 + EnumMsgType_MsgTypeConnect EnumMsgType = 3 // 新建链接消息 + EnumMsgType_MsgTypeDisconnect EnumMsgType = 4 // 关闭链接消息 + EnumMsgType_MsgTypeData EnumMsgType = 5 // 消息透传转发 +) + +// Enum value maps for EnumMsgType. +var ( + EnumMsgType_name = map[int32]string{ + 0: "MsgTypeNone", + 1: "MsgTypeLogin", + 2: "MsgTypeLogout", + 3: "MsgTypeConnect", + 4: "MsgTypeDisconnect", + 5: "MsgTypeData", + } + EnumMsgType_value = map[string]int32{ + "MsgTypeNone": 0, + "MsgTypeLogin": 1, + "MsgTypeLogout": 2, + "MsgTypeConnect": 3, + "MsgTypeDisconnect": 4, + "MsgTypeData": 5, + } +) + +func (x EnumMsgType) Enum() *EnumMsgType { + p := new(EnumMsgType) + *p = x + return p +} + +func (x EnumMsgType) String() string { + return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) +} + +func (EnumMsgType) Descriptor() protoreflect.EnumDescriptor { + return file_msgbus_proto_enumTypes[0].Descriptor() +} + +func (EnumMsgType) Type() protoreflect.EnumType { + return &file_msgbus_proto_enumTypes[0] +} + +func (x EnumMsgType) Number() protoreflect.EnumNumber { + return protoreflect.EnumNumber(x) +} + +// Deprecated: Use EnumMsgType.Descriptor instead. +func (EnumMsgType) EnumDescriptor() ([]byte, []int) { + return file_msgbus_proto_rawDescGZIP(), []int{0} +} + +// 渠道类型 +type EnumChannelType int32 + +const ( + EnumChannelType_ChannelTypeNone EnumChannelType = 0 + EnumChannelType_ChannelTypeMqtt EnumChannelType = 1 // 1-MQTT服务器 + EnumChannelType_ChannelTypeTcp EnumChannelType = 2 // 2-TCP服务器 + EnumChannelType_ChannelTypeUdp EnumChannelType = 3 // 3-UDP服务器 + EnumChannelType_ChannelTypeHttp EnumChannelType = 4 // 4-HTTP服务 + EnumChannelType_ChannelTypeWs EnumChannelType = 5 // 5-WebSocket服务 + EnumChannelType_ChannelTypeDb EnumChannelType = 6 // 6-数据库服务 + EnumChannelType_ChannelTypeInner EnumChannelType = 100 // 100-内网流转通道 +) + +// Enum value maps for EnumChannelType. +var ( + EnumChannelType_name = map[int32]string{ + 0: "ChannelTypeNone", + 1: "ChannelTypeMqtt", + 2: "ChannelTypeTcp", + 3: "ChannelTypeUdp", + 4: "ChannelTypeHttp", + 5: "ChannelTypeWs", + 6: "ChannelTypeDb", + 100: "ChannelTypeInner", + } + EnumChannelType_value = map[string]int32{ + "ChannelTypeNone": 0, + "ChannelTypeMqtt": 1, + "ChannelTypeTcp": 2, + "ChannelTypeUdp": 3, + "ChannelTypeHttp": 4, + "ChannelTypeWs": 5, + "ChannelTypeDb": 6, + "ChannelTypeInner": 100, + } +) + +func (x EnumChannelType) Enum() *EnumChannelType { + p := new(EnumChannelType) + *p = x + return p +} + +func (x EnumChannelType) String() string { + return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) +} + +func (EnumChannelType) Descriptor() protoreflect.EnumDescriptor { + return file_msgbus_proto_enumTypes[1].Descriptor() +} + +func (EnumChannelType) Type() protoreflect.EnumType { + return &file_msgbus_proto_enumTypes[1] +} + +func (x EnumChannelType) Number() protoreflect.EnumNumber { + return protoreflect.EnumNumber(x) +} + +// Deprecated: Use EnumChannelType.Descriptor instead. +func (EnumChannelType) EnumDescriptor() ([]byte, []int) { + return file_msgbus_proto_rawDescGZIP(), []int{1} +} + +// 渠道信息 +type Channel struct { + state protoimpl.MessageState `protogen:"open.v1"` + ChannelId int64 `protobuf:"varint,1,opt,name=channel_id,json=channelId,proto3" json:"channel_id,omitempty" dc:"渠道ID"` // 渠道ID + ChannelType int32 `protobuf:"varint,2,opt,name=channel_type,json=channelType,proto3" json:"channel_type,omitempty" dc:"渠道类型 EnumChannelType"` // 渠道类型 EnumChannelType + ServerId int64 `protobuf:"varint,3,opt,name=server_id,json=serverId,proto3" json:"server_id,omitempty" dc:"服务ID"` // 服务ID + CreateTime int64 `protobuf:"varint,4,opt,name=create_time,json=createTime,proto3" json:"create_time,omitempty" dc:"启动时间戳(秒)"` // 启动时间戳(秒) + TenantId string `protobuf:"bytes,5,opt,name=tenant_id,json=tenantId,proto3" json:"tenant_id,omitempty" dc:"租户ID"` // 租户ID + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *Channel) Reset() { + *x = Channel{} + mi := &file_msgbus_proto_msgTypes[0] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *Channel) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*Channel) ProtoMessage() {} + +func (x *Channel) ProtoReflect() protoreflect.Message { + mi := &file_msgbus_proto_msgTypes[0] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use Channel.ProtoReflect.Descriptor instead. +func (*Channel) Descriptor() ([]byte, []int) { + return file_msgbus_proto_rawDescGZIP(), []int{0} +} + +func (x *Channel) GetChannelId() int64 { + if x != nil { + return x.ChannelId + } + return 0 +} + +func (x *Channel) GetChannelType() int32 { + if x != nil { + return x.ChannelType + } + return 0 +} + +func (x *Channel) GetServerId() int64 { + if x != nil { + return x.ServerId + } + return 0 +} + +func (x *Channel) GetCreateTime() int64 { + if x != nil { + return x.CreateTime + } + return 0 +} + +func (x *Channel) GetTenantId() string { + if x != nil { + return x.TenantId + } + return "" +} + +// 链接信息 +type Conn struct { + state protoimpl.MessageState `protogen:"open.v1"` + ConnId int64 `protobuf:"varint,1,opt,name=conn_id,json=connId,proto3" json:"conn_id,omitempty" dc:"链接ID"` // 链接ID + CreateTime int64 `protobuf:"varint,2,opt,name=create_time,json=createTime,proto3" json:"create_time,omitempty" dc:"链接时间戳(秒)"` // 链接时间戳(秒) + LastRecvTime int64 `protobuf:"varint,3,opt,name=last_recv_time,json=lastRecvTime,proto3" json:"last_recv_time,omitempty" dc:"最后接收时间戳(秒)"` // 最后接收时间戳(秒) + LastSendTime int64 `protobuf:"varint,4,opt,name=last_send_time,json=lastSendTime,proto3" json:"last_send_time,omitempty" dc:"最后发送时间戳(秒)"` // 最后发送时间戳(秒) + ClientId string `protobuf:"bytes,5,opt,name=client_id,json=clientId,proto3" json:"client_id,omitempty" dc:"客户端ID"` // 客户端ID + UserName string `protobuf:"bytes,6,opt,name=user_name,json=userName,proto3" json:"user_name,omitempty" dc:"客户端登录名称"` // 客户端登录名称 + UserPass string `protobuf:"bytes,7,opt,name=user_pass,json=userPass,proto3" json:"user_pass,omitempty" dc:"客户端登录密码"` // 客户端登录密码 + ProductKey string `protobuf:"bytes,8,opt,name=product_key,json=productKey,proto3" json:"product_key,omitempty" dc:"绑定主产品KEY"` // 绑定主产品KEY + DeviceKey string `protobuf:"bytes,9,opt,name=device_key,json=deviceKey,proto3" json:"device_key,omitempty" dc:"绑定主设备KEY"` // 绑定主设备KEY + RemoteAddr string `protobuf:"bytes,10,opt,name=remote_addr,json=remoteAddr,proto3" json:"remote_addr,omitempty"` + LocalAddr string `protobuf:"bytes,11,opt,name=local_addr,json=localAddr,proto3" json:"local_addr,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *Conn) Reset() { + *x = Conn{} + mi := &file_msgbus_proto_msgTypes[1] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *Conn) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*Conn) ProtoMessage() {} + +func (x *Conn) ProtoReflect() protoreflect.Message { + mi := &file_msgbus_proto_msgTypes[1] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use Conn.ProtoReflect.Descriptor instead. +func (*Conn) Descriptor() ([]byte, []int) { + return file_msgbus_proto_rawDescGZIP(), []int{1} +} + +func (x *Conn) GetConnId() int64 { + if x != nil { + return x.ConnId + } + return 0 +} + +func (x *Conn) GetCreateTime() int64 { + if x != nil { + return x.CreateTime + } + return 0 +} + +func (x *Conn) GetLastRecvTime() int64 { + if x != nil { + return x.LastRecvTime + } + return 0 +} + +func (x *Conn) GetLastSendTime() int64 { + if x != nil { + return x.LastSendTime + } + return 0 +} + +func (x *Conn) GetClientId() string { + if x != nil { + return x.ClientId + } + return "" +} + +func (x *Conn) GetUserName() string { + if x != nil { + return x.UserName + } + return "" +} + +func (x *Conn) GetUserPass() string { + if x != nil { + return x.UserPass + } + return "" +} + +func (x *Conn) GetProductKey() string { + if x != nil { + return x.ProductKey + } + return "" +} + +func (x *Conn) GetDeviceKey() string { + if x != nil { + return x.DeviceKey + } + return "" +} + +func (x *Conn) GetRemoteAddr() string { + if x != nil { + return x.RemoteAddr + } + return "" +} + +func (x *Conn) GetLocalAddr() string { + if x != nil { + return x.LocalAddr + } + return "" +} + +type SendMsgReq struct { + state protoimpl.MessageState `protogen:"open.v1"` + Head *HeadReq `protobuf:"bytes,1,opt,name=head,proto3" json:"head,omitempty"` + Channel *Channel `protobuf:"bytes,2,opt,name=channel,proto3" json:"channel,omitempty" dc:"渠道信息"` // 渠道信息 + Conn *Conn `protobuf:"bytes,3,opt,name=conn,proto3" json:"conn,omitempty" dc:"链接信息"` // 链接信息 + MsgType int32 `protobuf:"varint,4,opt,name=msg_type,json=msgType,proto3" json:"msg_type,omitempty" dc:"消息类型"` // 消息类型 + Topic string `protobuf:"bytes,5,opt,name=topic,proto3" json:"topic,omitempty"` + Data []byte `protobuf:"bytes,6,opt,name=data,proto3" json:"data,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *SendMsgReq) Reset() { + *x = SendMsgReq{} + mi := &file_msgbus_proto_msgTypes[2] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *SendMsgReq) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*SendMsgReq) ProtoMessage() {} + +func (x *SendMsgReq) ProtoReflect() protoreflect.Message { + mi := &file_msgbus_proto_msgTypes[2] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use SendMsgReq.ProtoReflect.Descriptor instead. +func (*SendMsgReq) Descriptor() ([]byte, []int) { + return file_msgbus_proto_rawDescGZIP(), []int{2} +} + +func (x *SendMsgReq) GetHead() *HeadReq { + if x != nil { + return x.Head + } + return nil +} + +func (x *SendMsgReq) GetChannel() *Channel { + if x != nil { + return x.Channel + } + return nil +} + +func (x *SendMsgReq) GetConn() *Conn { + if x != nil { + return x.Conn + } + return nil +} + +func (x *SendMsgReq) GetMsgType() int32 { + if x != nil { + return x.MsgType + } + return 0 +} + +func (x *SendMsgReq) GetTopic() string { + if x != nil { + return x.Topic + } + return "" +} + +func (x *SendMsgReq) GetData() []byte { + if x != nil { + return x.Data + } + return nil +} + +type SendMsgRes struct { + state protoimpl.MessageState `protogen:"open.v1"` + Head *HeadRes `protobuf:"bytes,1,opt,name=head,proto3" json:"head,omitempty"` + Conn *Conn `protobuf:"bytes,2,opt,name=conn,proto3" json:"conn,omitempty"` + Topic string `protobuf:"bytes,3,opt,name=topic,proto3" json:"topic,omitempty"` + Data []byte `protobuf:"bytes,4,opt,name=data,proto3" json:"data,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *SendMsgRes) Reset() { + *x = SendMsgRes{} + mi := &file_msgbus_proto_msgTypes[3] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *SendMsgRes) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*SendMsgRes) ProtoMessage() {} + +func (x *SendMsgRes) ProtoReflect() protoreflect.Message { + mi := &file_msgbus_proto_msgTypes[3] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use SendMsgRes.ProtoReflect.Descriptor instead. +func (*SendMsgRes) Descriptor() ([]byte, []int) { + return file_msgbus_proto_rawDescGZIP(), []int{3} +} + +func (x *SendMsgRes) GetHead() *HeadRes { + if x != nil { + return x.Head + } + return nil +} + +func (x *SendMsgRes) GetConn() *Conn { + if x != nil { + return x.Conn + } + return nil +} + +func (x *SendMsgRes) GetTopic() string { + if x != nil { + return x.Topic + } + return "" +} + +func (x *SendMsgRes) GetData() []byte { + if x != nil { + return x.Data + } + return nil +} + +var File_msgbus_proto protoreflect.FileDescriptor + +const file_msgbus_proto_rawDesc = "" + + "\n" + + "\fmsgbus.proto\x12\bPbMsgBus\x1a\fcommon.proto\"\xa6\x01\n" + + "\aChannel\x12\x1d\n" + + "\n" + + "channel_id\x18\x01 \x01(\x03R\tchannelId\x12!\n" + + "\fchannel_type\x18\x02 \x01(\x05R\vchannelType\x12\x1b\n" + + "\tserver_id\x18\x03 \x01(\x03R\bserverId\x12\x1f\n" + + "\vcreate_time\x18\x04 \x01(\x03R\n" + + "createTime\x12\x1b\n" + + "\ttenant_id\x18\x05 \x01(\tR\btenantId\"\xe3\x02\n" + + "\x04Conn\x12\x17\n" + + "\aconn_id\x18\x01 \x01(\x03R\x06connId\x12\x1f\n" + + "\vcreate_time\x18\x02 \x01(\x03R\n" + + "createTime\x12$\n" + + "\x0elast_recv_time\x18\x03 \x01(\x03R\flastRecvTime\x12$\n" + + "\x0elast_send_time\x18\x04 \x01(\x03R\flastSendTime\x12\x1b\n" + + "\tclient_id\x18\x05 \x01(\tR\bclientId\x12\x1b\n" + + "\tuser_name\x18\x06 \x01(\tR\buserName\x12\x1b\n" + + "\tuser_pass\x18\a \x01(\tR\buserPass\x12\x1f\n" + + "\vproduct_key\x18\b \x01(\tR\n" + + "productKey\x12\x1d\n" + + "\n" + + "device_key\x18\t \x01(\tR\tdeviceKey\x12\x1f\n" + + "\vremote_addr\x18\n" + + " \x01(\tR\n" + + "remoteAddr\x12\x1d\n" + + "\n" + + "local_addr\x18\v \x01(\tR\tlocalAddr\"\xc9\x01\n" + + "\n" + + "SendMsgReq\x12%\n" + + "\x04head\x18\x01 \x01(\v2\x11.PbCommon.HeadReqR\x04head\x12+\n" + + "\achannel\x18\x02 \x01(\v2\x11.PbMsgBus.ChannelR\achannel\x12\"\n" + + "\x04conn\x18\x03 \x01(\v2\x0e.PbMsgBus.ConnR\x04conn\x12\x19\n" + + "\bmsg_type\x18\x04 \x01(\x05R\amsgType\x12\x14\n" + + "\x05topic\x18\x05 \x01(\tR\x05topic\x12\x12\n" + + "\x04data\x18\x06 \x01(\fR\x04data\"\x81\x01\n" + + "\n" + + "SendMsgRes\x12%\n" + + "\x04head\x18\x01 \x01(\v2\x11.PbCommon.HeadResR\x04head\x12\"\n" + + "\x04conn\x18\x02 \x01(\v2\x0e.PbMsgBus.ConnR\x04conn\x12\x14\n" + + "\x05topic\x18\x03 \x01(\tR\x05topic\x12\x12\n" + + "\x04data\x18\x04 \x01(\fR\x04data*\x7f\n" + + "\vEnumMsgType\x12\x0f\n" + + "\vMsgTypeNone\x10\x00\x12\x10\n" + + "\fMsgTypeLogin\x10\x01\x12\x11\n" + + "\rMsgTypeLogout\x10\x02\x12\x12\n" + + "\x0eMsgTypeConnect\x10\x03\x12\x15\n" + + "\x11MsgTypeDisconnect\x10\x04\x12\x0f\n" + + "\vMsgTypeData\x10\x05*\xb4\x01\n" + + "\x0fEnumChannelType\x12\x13\n" + + "\x0fChannelTypeNone\x10\x00\x12\x13\n" + + "\x0fChannelTypeMqtt\x10\x01\x12\x12\n" + + "\x0eChannelTypeTcp\x10\x02\x12\x12\n" + + "\x0eChannelTypeUdp\x10\x03\x12\x13\n" + + "\x0fChannelTypeHttp\x10\x04\x12\x11\n" + + "\rChannelTypeWs\x10\x05\x12\x11\n" + + "\rChannelTypeDb\x10\x06\x12\x14\n" + + "\x10ChannelTypeInner\x10d2x\n" + + "\x06MsgBus\x122\n" + + "\x04Send\x12\x14.PbMsgBus.SendMsgReq\x1a\x14.PbMsgBus.SendMsgRes\x12:\n" + + "\bLongSend\x12\x14.PbMsgBus.SendMsgReq\x1a\x14.PbMsgBus.SendMsgRes(\x010\x01B\x05Z\x03pb/b\x06proto3" + +var ( + file_msgbus_proto_rawDescOnce sync.Once + file_msgbus_proto_rawDescData []byte +) + +func file_msgbus_proto_rawDescGZIP() []byte { + file_msgbus_proto_rawDescOnce.Do(func() { + file_msgbus_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_msgbus_proto_rawDesc), len(file_msgbus_proto_rawDesc))) + }) + return file_msgbus_proto_rawDescData +} + +var file_msgbus_proto_enumTypes = make([]protoimpl.EnumInfo, 2) +var file_msgbus_proto_msgTypes = make([]protoimpl.MessageInfo, 4) +var file_msgbus_proto_goTypes = []any{ + (EnumMsgType)(0), // 0: PbMsgBus.EnumMsgType + (EnumChannelType)(0), // 1: PbMsgBus.EnumChannelType + (*Channel)(nil), // 2: PbMsgBus.Channel + (*Conn)(nil), // 3: PbMsgBus.Conn + (*SendMsgReq)(nil), // 4: PbMsgBus.SendMsgReq + (*SendMsgRes)(nil), // 5: PbMsgBus.SendMsgRes + (*HeadReq)(nil), // 6: PbCommon.HeadReq + (*HeadRes)(nil), // 7: PbCommon.HeadRes +} +var file_msgbus_proto_depIdxs = []int32{ + 6, // 0: PbMsgBus.SendMsgReq.head:type_name -> PbCommon.HeadReq + 2, // 1: PbMsgBus.SendMsgReq.channel:type_name -> PbMsgBus.Channel + 3, // 2: PbMsgBus.SendMsgReq.conn:type_name -> PbMsgBus.Conn + 7, // 3: PbMsgBus.SendMsgRes.head:type_name -> PbCommon.HeadRes + 3, // 4: PbMsgBus.SendMsgRes.conn:type_name -> PbMsgBus.Conn + 4, // 5: PbMsgBus.MsgBus.Send:input_type -> PbMsgBus.SendMsgReq + 4, // 6: PbMsgBus.MsgBus.LongSend:input_type -> PbMsgBus.SendMsgReq + 5, // 7: PbMsgBus.MsgBus.Send:output_type -> PbMsgBus.SendMsgRes + 5, // 8: PbMsgBus.MsgBus.LongSend:output_type -> PbMsgBus.SendMsgRes + 7, // [7:9] is the sub-list for method output_type + 5, // [5:7] is the sub-list for method input_type + 5, // [5:5] is the sub-list for extension type_name + 5, // [5:5] is the sub-list for extension extendee + 0, // [0:5] is the sub-list for field type_name +} + +func init() { file_msgbus_proto_init() } +func file_msgbus_proto_init() { + if File_msgbus_proto != nil { + return + } + file_common_proto_init() + type x struct{} + out := protoimpl.TypeBuilder{ + File: protoimpl.DescBuilder{ + GoPackagePath: reflect.TypeOf(x{}).PkgPath(), + RawDescriptor: unsafe.Slice(unsafe.StringData(file_msgbus_proto_rawDesc), len(file_msgbus_proto_rawDesc)), + NumEnums: 2, + NumMessages: 4, + NumExtensions: 0, + NumServices: 1, + }, + GoTypes: file_msgbus_proto_goTypes, + DependencyIndexes: file_msgbus_proto_depIdxs, + EnumInfos: file_msgbus_proto_enumTypes, + MessageInfos: file_msgbus_proto_msgTypes, + }.Build() + File_msgbus_proto = out.File + file_msgbus_proto_goTypes = nil + file_msgbus_proto_depIdxs = nil +} diff --git a/internal/pb/msgbus_grpc.pb.go b/internal/pb/msgbus_grpc.pb.go new file mode 100644 index 0000000..8ea58fb --- /dev/null +++ b/internal/pb/msgbus_grpc.pb.go @@ -0,0 +1,155 @@ +// Code generated by protoc-gen-go-grpc. DO NOT EDIT. +// versions: +// - protoc-gen-go-grpc v1.6.1 +// - protoc v5.29.3 +// source: msgbus.proto + +package pb + +import ( + context "context" + + grpc "google.golang.org/grpc" + codes "google.golang.org/grpc/codes" + status "google.golang.org/grpc/status" +) + +// This is a compile-time assertion to ensure that this generated file +// is compatible with the grpc package it is being compiled against. +// Requires gRPC-Go v1.64.0 or later. +const _ = grpc.SupportPackageIsVersion9 + +const ( + MsgBus_Send_FullMethodName = "/PbMsgBus.MsgBus/Send" + MsgBus_LongSend_FullMethodName = "/PbMsgBus.MsgBus/LongSend" +) + +// MsgBusClient is the client API for MsgBus service. +// +// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream. +type MsgBusClient interface { + Send(ctx context.Context, in *SendMsgReq, opts ...grpc.CallOption) (*SendMsgRes, error) + LongSend(ctx context.Context, opts ...grpc.CallOption) (grpc.BidiStreamingClient[SendMsgReq, SendMsgRes], error) +} + +type msgBusClient struct { + cc grpc.ClientConnInterface +} + +func NewMsgBusClient(cc grpc.ClientConnInterface) MsgBusClient { + return &msgBusClient{cc} +} + +func (c *msgBusClient) Send(ctx context.Context, in *SendMsgReq, opts ...grpc.CallOption) (*SendMsgRes, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(SendMsgRes) + err := c.cc.Invoke(ctx, MsgBus_Send_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *msgBusClient) LongSend(ctx context.Context, opts ...grpc.CallOption) (grpc.BidiStreamingClient[SendMsgReq, SendMsgRes], error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + stream, err := c.cc.NewStream(ctx, &MsgBus_ServiceDesc.Streams[0], MsgBus_LongSend_FullMethodName, cOpts...) + if err != nil { + return nil, err + } + x := &grpc.GenericClientStream[SendMsgReq, SendMsgRes]{ClientStream: stream} + return x, nil +} + +// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name. +type MsgBus_LongSendClient = grpc.BidiStreamingClient[SendMsgReq, SendMsgRes] + +// MsgBusServer is the server API for MsgBus service. +// All implementations must embed UnimplementedMsgBusServer +// for forward compatibility. +type MsgBusServer interface { + Send(context.Context, *SendMsgReq) (*SendMsgRes, error) + LongSend(grpc.BidiStreamingServer[SendMsgReq, SendMsgRes]) error + mustEmbedUnimplementedMsgBusServer() +} + +// UnimplementedMsgBusServer must be embedded to have +// forward compatible implementations. +// +// NOTE: this should be embedded by value instead of pointer to avoid a nil +// pointer dereference when methods are called. +type UnimplementedMsgBusServer struct{} + +func (UnimplementedMsgBusServer) Send(context.Context, *SendMsgReq) (*SendMsgRes, error) { + return nil, status.Error(codes.Unimplemented, "method Send not implemented") +} +func (UnimplementedMsgBusServer) LongSend(grpc.BidiStreamingServer[SendMsgReq, SendMsgRes]) error { + return status.Error(codes.Unimplemented, "method LongSend not implemented") +} +func (UnimplementedMsgBusServer) mustEmbedUnimplementedMsgBusServer() {} +func (UnimplementedMsgBusServer) testEmbeddedByValue() {} + +// UnsafeMsgBusServer may be embedded to opt out of forward compatibility for this service. +// Use of this interface is not recommended, as added methods to MsgBusServer will +// result in compilation errors. +type UnsafeMsgBusServer interface { + mustEmbedUnimplementedMsgBusServer() +} + +func RegisterMsgBusServer(s grpc.ServiceRegistrar, srv MsgBusServer) { + // If the following call panics, it indicates UnimplementedMsgBusServer was + // embedded by pointer and is nil. This will cause panics if an + // unimplemented method is ever invoked, so we test this at initialization + // time to prevent it from happening at runtime later due to I/O. + if t, ok := srv.(interface{ testEmbeddedByValue() }); ok { + t.testEmbeddedByValue() + } + s.RegisterService(&MsgBus_ServiceDesc, srv) +} + +func _MsgBus_Send_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(SendMsgReq) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(MsgBusServer).Send(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: MsgBus_Send_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(MsgBusServer).Send(ctx, req.(*SendMsgReq)) + } + return interceptor(ctx, in, info, handler) +} + +func _MsgBus_LongSend_Handler(srv interface{}, stream grpc.ServerStream) error { + return srv.(MsgBusServer).LongSend(&grpc.GenericServerStream[SendMsgReq, SendMsgRes]{ServerStream: stream}) +} + +// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name. +type MsgBus_LongSendServer = grpc.BidiStreamingServer[SendMsgReq, SendMsgRes] + +// MsgBus_ServiceDesc is the grpc.ServiceDesc for MsgBus service. +// It's only intended for direct use with grpc.RegisterService, +// and not to be introspected or modified (even as a copy) +var MsgBus_ServiceDesc = grpc.ServiceDesc{ + ServiceName: "PbMsgBus.MsgBus", + HandlerType: (*MsgBusServer)(nil), + Methods: []grpc.MethodDesc{ + { + MethodName: "Send", + Handler: _MsgBus_Send_Handler, + }, + }, + Streams: []grpc.StreamDesc{ + { + StreamName: "LongSend", + Handler: _MsgBus_LongSend_Handler, + ServerStreams: true, + ClientStreams: true, + }, + }, + Metadata: "msgbus.proto", +} diff --git a/internal/pb/msgcall.pb.go b/internal/pb/msgcall.pb.go new file mode 100644 index 0000000..6f97ea5 --- /dev/null +++ b/internal/pb/msgcall.pb.go @@ -0,0 +1,237 @@ +// Code generated by protoc-gen-go. DO NOT EDIT. +// versions: +// protoc-gen-go v1.36.11 +// protoc v5.29.3 +// source: msgcall.proto + +package pb + +import ( + reflect "reflect" + sync "sync" + unsafe "unsafe" + + protoreflect "google.golang.org/protobuf/reflect/protoreflect" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" +) + +const ( + // Verify that this generated code is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) + // Verify that runtime/protoimpl is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) +) + +type MsgReq struct { + state protoimpl.MessageState `protogen:"open.v1"` + Head *HeadReq `protobuf:"bytes,1,opt,name=head,proto3" json:"head,omitempty"` + Service string `protobuf:"bytes,2,opt,name=service,proto3" json:"service,omitempty"` + Method string `protobuf:"bytes,3,opt,name=method,proto3" json:"method,omitempty"` + Data []byte `protobuf:"bytes,4,opt,name=data,proto3" json:"data,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *MsgReq) Reset() { + *x = MsgReq{} + mi := &file_msgcall_proto_msgTypes[0] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *MsgReq) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*MsgReq) ProtoMessage() {} + +func (x *MsgReq) ProtoReflect() protoreflect.Message { + mi := &file_msgcall_proto_msgTypes[0] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use MsgReq.ProtoReflect.Descriptor instead. +func (*MsgReq) Descriptor() ([]byte, []int) { + return file_msgcall_proto_rawDescGZIP(), []int{0} +} + +func (x *MsgReq) GetHead() *HeadReq { + if x != nil { + return x.Head + } + return nil +} + +func (x *MsgReq) GetService() string { + if x != nil { + return x.Service + } + return "" +} + +func (x *MsgReq) GetMethod() string { + if x != nil { + return x.Method + } + return "" +} + +func (x *MsgReq) GetData() []byte { + if x != nil { + return x.Data + } + return nil +} + +type MsgRes struct { + state protoimpl.MessageState `protogen:"open.v1"` + Head *HeadRes `protobuf:"bytes,1,opt,name=head,proto3" json:"head,omitempty"` + Service string `protobuf:"bytes,2,opt,name=service,proto3" json:"service,omitempty"` + Method string `protobuf:"bytes,3,opt,name=method,proto3" json:"method,omitempty"` + Data []byte `protobuf:"bytes,4,opt,name=data,proto3" json:"data,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *MsgRes) Reset() { + *x = MsgRes{} + mi := &file_msgcall_proto_msgTypes[1] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *MsgRes) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*MsgRes) ProtoMessage() {} + +func (x *MsgRes) ProtoReflect() protoreflect.Message { + mi := &file_msgcall_proto_msgTypes[1] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use MsgRes.ProtoReflect.Descriptor instead. +func (*MsgRes) Descriptor() ([]byte, []int) { + return file_msgcall_proto_rawDescGZIP(), []int{1} +} + +func (x *MsgRes) GetHead() *HeadRes { + if x != nil { + return x.Head + } + return nil +} + +func (x *MsgRes) GetService() string { + if x != nil { + return x.Service + } + return "" +} + +func (x *MsgRes) GetMethod() string { + if x != nil { + return x.Method + } + return "" +} + +func (x *MsgRes) GetData() []byte { + if x != nil { + return x.Data + } + return nil +} + +var File_msgcall_proto protoreflect.FileDescriptor + +const file_msgcall_proto_rawDesc = "" + + "\n" + + "\rmsgcall.proto\x12\tPbMsgCall\x1a\fcommon.proto\"u\n" + + "\x06MsgReq\x12%\n" + + "\x04head\x18\x01 \x01(\v2\x11.PbCommon.HeadReqR\x04head\x12\x18\n" + + "\aservice\x18\x02 \x01(\tR\aservice\x12\x16\n" + + "\x06method\x18\x03 \x01(\tR\x06method\x12\x12\n" + + "\x04data\x18\x04 \x01(\fR\x04data\"u\n" + + "\x06MsgRes\x12%\n" + + "\x04head\x18\x01 \x01(\v2\x11.PbCommon.HeadResR\x04head\x12\x18\n" + + "\aservice\x18\x02 \x01(\tR\aservice\x12\x16\n" + + "\x06method\x18\x03 \x01(\tR\x06method\x12\x12\n" + + "\x04data\x18\x04 \x01(\fR\x04data2r\n" + + "\aMsgCall\x12/\n" + + "\aOneCall\x12\x11.PbMsgCall.MsgReq\x1a\x11.PbMsgCall.MsgRes\x126\n" + + "\n" + + "StreamCall\x12\x11.PbMsgCall.MsgReq\x1a\x11.PbMsgCall.MsgRes(\x010\x01B\x05Z\x03pb/b\x06proto3" + +var ( + file_msgcall_proto_rawDescOnce sync.Once + file_msgcall_proto_rawDescData []byte +) + +func file_msgcall_proto_rawDescGZIP() []byte { + file_msgcall_proto_rawDescOnce.Do(func() { + file_msgcall_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_msgcall_proto_rawDesc), len(file_msgcall_proto_rawDesc))) + }) + return file_msgcall_proto_rawDescData +} + +var file_msgcall_proto_msgTypes = make([]protoimpl.MessageInfo, 2) +var file_msgcall_proto_goTypes = []any{ + (*MsgReq)(nil), // 0: PbMsgCall.MsgReq + (*MsgRes)(nil), // 1: PbMsgCall.MsgRes + (*HeadReq)(nil), // 2: PbCommon.HeadReq + (*HeadRes)(nil), // 3: PbCommon.HeadRes +} +var file_msgcall_proto_depIdxs = []int32{ + 2, // 0: PbMsgCall.MsgReq.head:type_name -> PbCommon.HeadReq + 3, // 1: PbMsgCall.MsgRes.head:type_name -> PbCommon.HeadRes + 0, // 2: PbMsgCall.MsgCall.OneCall:input_type -> PbMsgCall.MsgReq + 0, // 3: PbMsgCall.MsgCall.StreamCall:input_type -> PbMsgCall.MsgReq + 1, // 4: PbMsgCall.MsgCall.OneCall:output_type -> PbMsgCall.MsgRes + 1, // 5: PbMsgCall.MsgCall.StreamCall:output_type -> PbMsgCall.MsgRes + 4, // [4:6] is the sub-list for method output_type + 2, // [2:4] is the sub-list for method input_type + 2, // [2:2] is the sub-list for extension type_name + 2, // [2:2] is the sub-list for extension extendee + 0, // [0:2] is the sub-list for field type_name +} + +func init() { file_msgcall_proto_init() } +func file_msgcall_proto_init() { + if File_msgcall_proto != nil { + return + } + file_common_proto_init() + type x struct{} + out := protoimpl.TypeBuilder{ + File: protoimpl.DescBuilder{ + GoPackagePath: reflect.TypeOf(x{}).PkgPath(), + RawDescriptor: unsafe.Slice(unsafe.StringData(file_msgcall_proto_rawDesc), len(file_msgcall_proto_rawDesc)), + NumEnums: 0, + NumMessages: 2, + NumExtensions: 0, + NumServices: 1, + }, + GoTypes: file_msgcall_proto_goTypes, + DependencyIndexes: file_msgcall_proto_depIdxs, + MessageInfos: file_msgcall_proto_msgTypes, + }.Build() + File_msgcall_proto = out.File + file_msgcall_proto_goTypes = nil + file_msgcall_proto_depIdxs = nil +} diff --git a/internal/pb/msgcall_grpc.pb.go b/internal/pb/msgcall_grpc.pb.go new file mode 100644 index 0000000..a0bcd96 --- /dev/null +++ b/internal/pb/msgcall_grpc.pb.go @@ -0,0 +1,155 @@ +// Code generated by protoc-gen-go-grpc. DO NOT EDIT. +// versions: +// - protoc-gen-go-grpc v1.6.1 +// - protoc v5.29.3 +// source: msgcall.proto + +package pb + +import ( + context "context" + + grpc "google.golang.org/grpc" + codes "google.golang.org/grpc/codes" + status "google.golang.org/grpc/status" +) + +// This is a compile-time assertion to ensure that this generated file +// is compatible with the grpc package it is being compiled against. +// Requires gRPC-Go v1.64.0 or later. +const _ = grpc.SupportPackageIsVersion9 + +const ( + MsgCall_OneCall_FullMethodName = "/PbMsgCall.MsgCall/OneCall" + MsgCall_StreamCall_FullMethodName = "/PbMsgCall.MsgCall/StreamCall" +) + +// MsgCallClient is the client API for MsgCall service. +// +// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream. +type MsgCallClient interface { + OneCall(ctx context.Context, in *MsgReq, opts ...grpc.CallOption) (*MsgRes, error) + StreamCall(ctx context.Context, opts ...grpc.CallOption) (grpc.BidiStreamingClient[MsgReq, MsgRes], error) +} + +type msgCallClient struct { + cc grpc.ClientConnInterface +} + +func NewMsgCallClient(cc grpc.ClientConnInterface) MsgCallClient { + return &msgCallClient{cc} +} + +func (c *msgCallClient) OneCall(ctx context.Context, in *MsgReq, opts ...grpc.CallOption) (*MsgRes, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(MsgRes) + err := c.cc.Invoke(ctx, MsgCall_OneCall_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *msgCallClient) StreamCall(ctx context.Context, opts ...grpc.CallOption) (grpc.BidiStreamingClient[MsgReq, MsgRes], error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + stream, err := c.cc.NewStream(ctx, &MsgCall_ServiceDesc.Streams[0], MsgCall_StreamCall_FullMethodName, cOpts...) + if err != nil { + return nil, err + } + x := &grpc.GenericClientStream[MsgReq, MsgRes]{ClientStream: stream} + return x, nil +} + +// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name. +type MsgCall_StreamCallClient = grpc.BidiStreamingClient[MsgReq, MsgRes] + +// MsgCallServer is the server API for MsgCall service. +// All implementations must embed UnimplementedMsgCallServer +// for forward compatibility. +type MsgCallServer interface { + OneCall(context.Context, *MsgReq) (*MsgRes, error) + StreamCall(grpc.BidiStreamingServer[MsgReq, MsgRes]) error + mustEmbedUnimplementedMsgCallServer() +} + +// UnimplementedMsgCallServer must be embedded to have +// forward compatible implementations. +// +// NOTE: this should be embedded by value instead of pointer to avoid a nil +// pointer dereference when methods are called. +type UnimplementedMsgCallServer struct{} + +func (UnimplementedMsgCallServer) OneCall(context.Context, *MsgReq) (*MsgRes, error) { + return nil, status.Error(codes.Unimplemented, "method OneCall not implemented") +} +func (UnimplementedMsgCallServer) StreamCall(grpc.BidiStreamingServer[MsgReq, MsgRes]) error { + return status.Error(codes.Unimplemented, "method StreamCall not implemented") +} +func (UnimplementedMsgCallServer) mustEmbedUnimplementedMsgCallServer() {} +func (UnimplementedMsgCallServer) testEmbeddedByValue() {} + +// UnsafeMsgCallServer may be embedded to opt out of forward compatibility for this service. +// Use of this interface is not recommended, as added methods to MsgCallServer will +// result in compilation errors. +type UnsafeMsgCallServer interface { + mustEmbedUnimplementedMsgCallServer() +} + +func RegisterMsgCallServer(s grpc.ServiceRegistrar, srv MsgCallServer) { + // If the following call panics, it indicates UnimplementedMsgCallServer was + // embedded by pointer and is nil. This will cause panics if an + // unimplemented method is ever invoked, so we test this at initialization + // time to prevent it from happening at runtime later due to I/O. + if t, ok := srv.(interface{ testEmbeddedByValue() }); ok { + t.testEmbeddedByValue() + } + s.RegisterService(&MsgCall_ServiceDesc, srv) +} + +func _MsgCall_OneCall_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(MsgReq) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(MsgCallServer).OneCall(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: MsgCall_OneCall_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(MsgCallServer).OneCall(ctx, req.(*MsgReq)) + } + return interceptor(ctx, in, info, handler) +} + +func _MsgCall_StreamCall_Handler(srv interface{}, stream grpc.ServerStream) error { + return srv.(MsgCallServer).StreamCall(&grpc.GenericServerStream[MsgReq, MsgRes]{ServerStream: stream}) +} + +// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name. +type MsgCall_StreamCallServer = grpc.BidiStreamingServer[MsgReq, MsgRes] + +// MsgCall_ServiceDesc is the grpc.ServiceDesc for MsgCall service. +// It's only intended for direct use with grpc.RegisterService, +// and not to be introspected or modified (even as a copy) +var MsgCall_ServiceDesc = grpc.ServiceDesc{ + ServiceName: "PbMsgCall.MsgCall", + HandlerType: (*MsgCallServer)(nil), + Methods: []grpc.MethodDesc{ + { + MethodName: "OneCall", + Handler: _MsgCall_OneCall_Handler, + }, + }, + Streams: []grpc.StreamDesc{ + { + StreamName: "StreamCall", + Handler: _MsgCall_StreamCall_Handler, + ServerStreams: true, + ClientStreams: true, + }, + }, + Metadata: "msgcall.proto", +} diff --git a/internal/pb/notice.pb.go b/internal/pb/notice.pb.go new file mode 100644 index 0000000..42dd281 --- /dev/null +++ b/internal/pb/notice.pb.go @@ -0,0 +1,197 @@ +// Code generated by protoc-gen-go. DO NOT EDIT. +// versions: +// protoc-gen-go v1.36.11 +// protoc v5.29.3 +// source: notice.proto + +package pb + +import ( + reflect "reflect" + sync "sync" + unsafe "unsafe" + + protoreflect "google.golang.org/protobuf/reflect/protoreflect" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" +) + +const ( + // Verify that this generated code is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) + // Verify that runtime/protoimpl is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) +) + +type SendReq struct { + state protoimpl.MessageState `protogen:"open.v1"` + Head *HeadReq `protobuf:"bytes,1,opt,name=head,proto3" json:"head,omitempty"` + Config []byte `protobuf:"bytes,2,opt,name=config,proto3" json:"config,omitempty"` + Params []byte `protobuf:"bytes,3,opt,name=params,proto3" json:"params,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *SendReq) Reset() { + *x = SendReq{} + mi := &file_notice_proto_msgTypes[0] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *SendReq) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*SendReq) ProtoMessage() {} + +func (x *SendReq) ProtoReflect() protoreflect.Message { + mi := &file_notice_proto_msgTypes[0] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use SendReq.ProtoReflect.Descriptor instead. +func (*SendReq) Descriptor() ([]byte, []int) { + return file_notice_proto_rawDescGZIP(), []int{0} +} + +func (x *SendReq) GetHead() *HeadReq { + if x != nil { + return x.Head + } + return nil +} + +func (x *SendReq) GetConfig() []byte { + if x != nil { + return x.Config + } + return nil +} + +func (x *SendReq) GetParams() []byte { + if x != nil { + return x.Params + } + return nil +} + +type SendRes struct { + state protoimpl.MessageState `protogen:"open.v1"` + Head *HeadRes `protobuf:"bytes,1,opt,name=head,proto3" json:"head,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *SendRes) Reset() { + *x = SendRes{} + mi := &file_notice_proto_msgTypes[1] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *SendRes) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*SendRes) ProtoMessage() {} + +func (x *SendRes) ProtoReflect() protoreflect.Message { + mi := &file_notice_proto_msgTypes[1] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use SendRes.ProtoReflect.Descriptor instead. +func (*SendRes) Descriptor() ([]byte, []int) { + return file_notice_proto_rawDescGZIP(), []int{1} +} + +func (x *SendRes) GetHead() *HeadRes { + if x != nil { + return x.Head + } + return nil +} + +var File_notice_proto protoreflect.FileDescriptor + +const file_notice_proto_rawDesc = "" + + "\n" + + "\fnotice.proto\x12\bPbNotice\x1a\fcommon.proto\"`\n" + + "\aSendReq\x12%\n" + + "\x04head\x18\x01 \x01(\v2\x11.PbCommon.HeadReqR\x04head\x12\x16\n" + + "\x06config\x18\x02 \x01(\fR\x06config\x12\x16\n" + + "\x06params\x18\x03 \x01(\fR\x06params\"0\n" + + "\aSendRes\x12%\n" + + "\x04head\x18\x01 \x01(\v2\x11.PbCommon.HeadResR\x04head28\n" + + "\x06Notice\x12.\n" + + "\x04Send\x12\x11.PbNotice.SendReq\x1a\x11.PbNotice.SendRes\"\x00B\x05Z\x03pb/b\x06proto3" + +var ( + file_notice_proto_rawDescOnce sync.Once + file_notice_proto_rawDescData []byte +) + +func file_notice_proto_rawDescGZIP() []byte { + file_notice_proto_rawDescOnce.Do(func() { + file_notice_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_notice_proto_rawDesc), len(file_notice_proto_rawDesc))) + }) + return file_notice_proto_rawDescData +} + +var file_notice_proto_msgTypes = make([]protoimpl.MessageInfo, 2) +var file_notice_proto_goTypes = []any{ + (*SendReq)(nil), // 0: PbNotice.SendReq + (*SendRes)(nil), // 1: PbNotice.SendRes + (*HeadReq)(nil), // 2: PbCommon.HeadReq + (*HeadRes)(nil), // 3: PbCommon.HeadRes +} +var file_notice_proto_depIdxs = []int32{ + 2, // 0: PbNotice.SendReq.head:type_name -> PbCommon.HeadReq + 3, // 1: PbNotice.SendRes.head:type_name -> PbCommon.HeadRes + 0, // 2: PbNotice.Notice.Send:input_type -> PbNotice.SendReq + 1, // 3: PbNotice.Notice.Send:output_type -> PbNotice.SendRes + 3, // [3:4] is the sub-list for method output_type + 2, // [2:3] is the sub-list for method input_type + 2, // [2:2] is the sub-list for extension type_name + 2, // [2:2] is the sub-list for extension extendee + 0, // [0:2] is the sub-list for field type_name +} + +func init() { file_notice_proto_init() } +func file_notice_proto_init() { + if File_notice_proto != nil { + return + } + file_common_proto_init() + type x struct{} + out := protoimpl.TypeBuilder{ + File: protoimpl.DescBuilder{ + GoPackagePath: reflect.TypeOf(x{}).PkgPath(), + RawDescriptor: unsafe.Slice(unsafe.StringData(file_notice_proto_rawDesc), len(file_notice_proto_rawDesc)), + NumEnums: 0, + NumMessages: 2, + NumExtensions: 0, + NumServices: 1, + }, + GoTypes: file_notice_proto_goTypes, + DependencyIndexes: file_notice_proto_depIdxs, + MessageInfos: file_notice_proto_msgTypes, + }.Build() + File_notice_proto = out.File + file_notice_proto_goTypes = nil + file_notice_proto_depIdxs = nil +} diff --git a/internal/pb/notice_grpc.pb.go b/internal/pb/notice_grpc.pb.go new file mode 100644 index 0000000..42ff435 --- /dev/null +++ b/internal/pb/notice_grpc.pb.go @@ -0,0 +1,122 @@ +// Code generated by protoc-gen-go-grpc. DO NOT EDIT. +// versions: +// - protoc-gen-go-grpc v1.6.1 +// - protoc v5.29.3 +// source: notice.proto + +package pb + +import ( + context "context" + + grpc "google.golang.org/grpc" + codes "google.golang.org/grpc/codes" + status "google.golang.org/grpc/status" +) + +// This is a compile-time assertion to ensure that this generated file +// is compatible with the grpc package it is being compiled against. +// Requires gRPC-Go v1.64.0 or later. +const _ = grpc.SupportPackageIsVersion9 + +const ( + Notice_Send_FullMethodName = "/PbNotice.Notice/Send" +) + +// NoticeClient is the client API for Notice service. +// +// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream. +type NoticeClient interface { + Send(ctx context.Context, in *SendReq, opts ...grpc.CallOption) (*SendRes, error) +} + +type noticeClient struct { + cc grpc.ClientConnInterface +} + +func NewNoticeClient(cc grpc.ClientConnInterface) NoticeClient { + return ¬iceClient{cc} +} + +func (c *noticeClient) Send(ctx context.Context, in *SendReq, opts ...grpc.CallOption) (*SendRes, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(SendRes) + err := c.cc.Invoke(ctx, Notice_Send_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +// NoticeServer is the server API for Notice service. +// All implementations must embed UnimplementedNoticeServer +// for forward compatibility. +type NoticeServer interface { + Send(context.Context, *SendReq) (*SendRes, error) + mustEmbedUnimplementedNoticeServer() +} + +// UnimplementedNoticeServer must be embedded to have +// forward compatible implementations. +// +// NOTE: this should be embedded by value instead of pointer to avoid a nil +// pointer dereference when methods are called. +type UnimplementedNoticeServer struct{} + +func (UnimplementedNoticeServer) Send(context.Context, *SendReq) (*SendRes, error) { + return nil, status.Error(codes.Unimplemented, "method Send not implemented") +} +func (UnimplementedNoticeServer) mustEmbedUnimplementedNoticeServer() {} +func (UnimplementedNoticeServer) testEmbeddedByValue() {} + +// UnsafeNoticeServer may be embedded to opt out of forward compatibility for this service. +// Use of this interface is not recommended, as added methods to NoticeServer will +// result in compilation errors. +type UnsafeNoticeServer interface { + mustEmbedUnimplementedNoticeServer() +} + +func RegisterNoticeServer(s grpc.ServiceRegistrar, srv NoticeServer) { + // If the following call panics, it indicates UnimplementedNoticeServer was + // embedded by pointer and is nil. This will cause panics if an + // unimplemented method is ever invoked, so we test this at initialization + // time to prevent it from happening at runtime later due to I/O. + if t, ok := srv.(interface{ testEmbeddedByValue() }); ok { + t.testEmbeddedByValue() + } + s.RegisterService(&Notice_ServiceDesc, srv) +} + +func _Notice_Send_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(SendReq) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(NoticeServer).Send(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: Notice_Send_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(NoticeServer).Send(ctx, req.(*SendReq)) + } + return interceptor(ctx, in, info, handler) +} + +// Notice_ServiceDesc is the grpc.ServiceDesc for Notice service. +// It's only intended for direct use with grpc.RegisterService, +// and not to be introspected or modified (even as a copy) +var Notice_ServiceDesc = grpc.ServiceDesc{ + ServiceName: "PbNotice.Notice", + HandlerType: (*NoticeServer)(nil), + Methods: []grpc.MethodDesc{ + { + MethodName: "Send", + Handler: _Notice_Send_Handler, + }, + }, + Streams: []grpc.StreamDesc{}, + Metadata: "notice.proto", +} diff --git a/internal/pub/pkg/initconsul/init_consul.go b/internal/pub/pkg/initconsul/init_consul.go new file mode 100644 index 0000000..facf871 --- /dev/null +++ b/internal/pub/pkg/initconsul/init_consul.go @@ -0,0 +1,32 @@ +package initconsul + +import ( + "context" + + "github.com/gogf/gf/contrib/registry/consul/v2" + "github.com/gogf/gf/contrib/rpc/grpcx/v2" + "github.com/gogf/gf/v2/frame/g" + "github.com/gogf/gf/v2/net/gsel" +) + +func InitConsul(ctx context.Context) error { + // 初始化consul + err := registerConsul(ctx) + if err != nil { + return err + } + return nil +} +func registerConsul(ctx context.Context) error { + consulAddress := g.Cfg().MustGet(ctx, "registry.address", "127.0.0.1:8500").String() + g.Log().Infof(ctx, "consulAddress: %+v", consulAddress) + registry, err := consul.New(consul.WithAddress(consulAddress)) + if err != nil { + g.Log().Fatalf(ctx, "consul初始化失败: %+v", err) + return err + } + grpcx.Resolver.Register(registry) + gsel.SetBuilder(gsel.NewBuilderRandom()) + g.Log().Infof(ctx, "consul注册成功") + return nil +} diff --git a/internal/pub/pkg/version/version.go b/internal/pub/pkg/version/version.go new file mode 100644 index 0000000..9951863 --- /dev/null +++ b/internal/pub/pkg/version/version.go @@ -0,0 +1,51 @@ +// package version +// @Link https://github.com/xiujiecn/xiu-admin +// @Copyright Copyright (c) 2025 LiXiujie +// @Author Lxj +// @License https://github.com/xiujiecn/xiu-admin/blob/master/LICENSE +package version + +import ( + "context" + "fmt" + + "github.com/gogf/gf/v2/frame/g" +) + +var ( + BuildTime string + BuildVersion string + CommitID string +) + +func ShowLogo(ctx context.Context, buildVersion, buildTime, commitID string) { + BuildVersion = buildVersion + BuildTime = buildTime + CommitID = commitID + //版本号 + + g.Log().Notice(ctx, ` + + ██╗██╗███████╗ ██╗ ██████╗ ████████╗ + ██║██║██╔════╝ ██║██╔═══██╗╚══██╔══╝ + ██║██║█████╗ ██║██║ ██║ ██║ +██ ██║██║██╔══╝ ██║██║ ██║ ██║ +╚█████╔╝██║███████╗ ██║╚██████╔╝ ██║ + ╚════╝ ╚═╝╚══════╝ ╚═╝ ╚═════╝ ╚═╝ + + `) + + g.Log().Notice(ctx, "Version :", buildVersion) + g.Log().Notice(ctx, "BuildTime :", buildTime) + g.Log().Notice(ctx, "CommitID :", commitID) + g.Log().Notice(ctx, "Copyright:", "2026 The iotLCM Authors") + + fmt.Println("Version :", buildVersion) + fmt.Println("BuildTime :", buildTime) + fmt.Println("CommitID :", commitID) + fmt.Println("Copyright:", "2026 The iotLCM Authors") +} + +func AppName(ctx context.Context) string { + return g.Cfg().MustGet(ctx, "system.appName", "IotLCM").String() +} diff --git a/internal/pub/xjpubconsts/http.go b/internal/pub/xjpubconsts/http.go new file mode 100644 index 0000000..8d1ddac --- /dev/null +++ b/internal/pub/xjpubconsts/http.go @@ -0,0 +1,14 @@ +// Package xjpubconsts +// @Link https://github.com/xiujiecn/xiu-admin +// @Copyright Copyright (c) 2025 LiXiujie +// @Author Lxj +// @License https://github.com/xiujiecn/xiu-admin/blob/master/LICENSE +package xjpubconsts + +const ( + HTTPContentTypeXml = "text/xml" + HTTPContentTypeHtml = "text/html" + HTTPContentTypeStream = "text/event-stream" + HTTPContentTypeJson = "application/json" + HTTPContentTypeExcel = "application/octet-stream" +) diff --git a/internal/pub/xjpubconsts/services.go b/internal/pub/xjpubconsts/services.go new file mode 100644 index 0000000..7089b38 --- /dev/null +++ b/internal/pub/xjpubconsts/services.go @@ -0,0 +1,17 @@ +package xjpubconsts + +const ( + ServiceTypeWeb = "web" // web服务 + ServiceTypeQueue = "queue" // 队列服务 + ServiceTypeRedis = "redis" // 缓存类型 + ServiceTypeNotice = "notice" // 通知服务 + ServiceTypeTask = "task" // 任务服务 + ServiceTypeAccess = "access" // 接入服务 +) + +const ( + ServiceNameYzAdminWeb = "yzadmin-web-svc" // 后台管理服务 + ServiceNameYzUpstreamChanSvc = "yzupstreamchan-grpc-svc" // 上游通道服务 + ServiceNameYzUpstreamProxySvc = "yzupstreamproxy-grpc-svc" // 上游代理服务 + ServiceNameYzApiHttpSvc = "yzapi-http-svc" // API 服务 +) diff --git a/internal/pub/xjpublogic/httpclient/httpclient.go b/internal/pub/xjpublogic/httpclient/httpclient.go new file mode 100644 index 0000000..b09f348 --- /dev/null +++ b/internal/pub/xjpublogic/httpclient/httpclient.go @@ -0,0 +1,100 @@ +package httpclient + +import ( + "context" + + "git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/pub/xjpubservice" + + "github.com/gogf/gf/v2/encoding/gjson" + "github.com/gogf/gf/v2/frame/g" + "github.com/gogf/gf/v2/net/gclient" + "github.com/gogf/gf/v2/net/gsvc" +) + +type sHttpClient struct { + client *gclient.Client +} + +func NewHttpClient() *sHttpClient { + client := g.Client() + return &sHttpClient{ + client: client, + } +} + +func init() { + xjpubservice.RegisterHttpClient(NewHttpClient()) +} + +func (s *sHttpClient) PostObj(ctx context.Context, serviceName string, path string, req interface{}, res interface{}) (err error) { + reqJson, err := gjson.Marshal(req) + if err != nil { + return err + } + resJson, err := s.Post(ctx, serviceName, path, reqJson) + if err != nil { + return err + } + err = gjson.Unmarshal(resJson, res) + if err != nil { + g.Log().Errorf(ctx, "sHttpClient.PostObj gjson.Unmarshal error: %v, resJson: %s", err, string(resJson)) + return err + } + g.Log().Debugf(ctx, "sHttpClient.PostObj res: %+v, resJson: %s", res, string(resJson)) + return nil +} + +func (s *sHttpClient) Post(ctx context.Context, serviceName string, path string, req []byte) (res []byte, err error) { + // s.client.SetHeader("Span-Id", "0.0.1") + // s.client.SetHeader("Trace-Id", gctx.CtxId(ctx)) + + url := "http://" + serviceName + path + s.client.SetDiscovery(gsvc.GetRegistry()) + // g.Log().Debugf(ctx, "sHttpClient.Post url: %s, req: %+v", url, string(req)) + response, err := s.client.Post(ctx, url, req) + if err != nil { + g.Log().Errorf(ctx, "sHttpClient.Post Post error: %v, url: %s, req: %+v", err, url, string(req)) + return nil, err + } + defer response.Close() + res = response.ReadAll() + g.Log().Debugf(ctx, "sHttpClient.Post url: %s, req: %+v, res: %+v", url, string(req), string(res)) + return res, nil +} + +func (s *sHttpClient) AllServerPost(ctx context.Context, serviceName string, path string, req []byte) (resMap map[string][]byte, err error) { + // s.client.SetHeader("Span-Id", "0.0.1") + // s.client.SetHeader("Trace-Id", gctx.CtxId(ctx)) + s.client.SetDiscovery(gsvc.GetRegistry()) + + servers, err := gsvc.Search(ctx, gsvc.SearchInput{ + Name: serviceName, + }) + if len(servers) == 0 { + g.Log().Errorf(ctx, "sHttpClient.AllServerPost gsvc.Search(ctx, serviceName) error: %v, serviceName: %s", err, serviceName) + return nil, err + } + // g.Log().Debugf(ctx, "sHttpClient.AllServerPost servers: %+v", servers) + endpoints := make([]string, 0) + for _, server := range servers { + endpoint := server.GetEndpoints() + if len(endpoint) == 0 { + g.Log().Errorf(ctx, "sHttpClient.AllServerPost server.GetEndpoints() error: %v, serviceName: %s", err, serviceName) + continue + } + endpoints = append(endpoints, endpoint[0].String()) + } + resMap = make(map[string][]byte) + for _, e := range endpoints { + url := "http://" + e + path + res, err := s.Post(ctx, e, path, req) + // g.Log().Debugf(ctx, "sHttpClient.AllServerPost Post url: %s, req: %+v res: %+v err: %+v", url, string(req), string(res), err) + if err != nil { + resMap[e] = nil + g.Log().Errorf(ctx, "sHttpClient.AllServerPost Post error: %v, url: %s, req: %+v", err, url, string(req)) + continue + } + resMap[e] = res + } + return resMap, nil +} diff --git a/internal/pub/xjpublogic/httpclient/upstream_proxy_client.go b/internal/pub/xjpublogic/httpclient/upstream_proxy_client.go new file mode 100644 index 0000000..b846934 --- /dev/null +++ b/internal/pub/xjpublogic/httpclient/upstream_proxy_client.go @@ -0,0 +1,37 @@ +package httpclient + +import ( + "context" + + "git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/pub/xjpubconsts" + "git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/pub/xjpubmodel" + "git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/pub/xjpubmodel/response" + "git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/pub/xjpubservice" +) + +type sUpstreamProxyClient struct { +} + +func NewUpstreamProxyClient() *sUpstreamProxyClient { + return &sUpstreamProxyClient{} +} + +func init() { + xjpubservice.RegisterUpstreamProxyClient(NewUpstreamProxyClient()) +} + +// 物联网卡在线信息实时查询 +func (s *sUpstreamProxyClient) QuerySimSession(ctx context.Context, input *xjpubmodel.SimSessionInput) (output *xjpubmodel.SimSessionOutput, err error) { + res := &response.ResponseGeneric[*xjpubmodel.SimSessionOutput]{} + err = xjpubservice.HttpClient().PostObj(ctx, xjpubconsts.ServiceNameYzUpstreamChanSvc, "/query/sim-session", input, res) + output = res.Data + return output, err +} + +// 物联网卡卡状态查询 +func (s *sUpstreamProxyClient) QuerySimCardStatus(ctx context.Context, input *xjpubmodel.SimCardStatusInput) (output *xjpubmodel.SimCardStatusOutput, err error) { + res := &response.ResponseGeneric[*xjpubmodel.SimCardStatusOutput]{} + err = xjpubservice.HttpClient().PostObj(ctx, xjpubconsts.ServiceNameYzUpstreamChanSvc, "/query/sim-status", input, res) + output = res.Data + return output, err +} diff --git a/internal/pub/xjpublogic/info/info.go b/internal/pub/xjpublogic/info/info.go new file mode 100644 index 0000000..3e59a95 --- /dev/null +++ b/internal/pub/xjpublogic/info/info.go @@ -0,0 +1,68 @@ +package info + +import ( + "git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/pub/xjpubmodel" + "git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/pub/xjpubservice" +) + +type sInfo struct { + Info xjpubmodel.InfoModel +} + +func NewInfo() *sInfo { + return &sInfo{ + Info: xjpubmodel.InfoModel{}, + } +} + +func init() { + xjpubservice.RegisterInfo(NewInfo()) +} + +func (s *sInfo) GetInfo() *xjpubmodel.InfoModel { + return &s.Info +} + +func (s *sInfo) SetInfoName(Type, Name, Title, Description string) { + if s.Info.Name == "" { + s.Info.Type = Type + s.Info.Name = Name + s.Info.Title = Title + s.Info.Description = Description + } +} + +func (s *sInfo) SetInfoBuild(buildVersion, buildTime, commitID string) { + s.Info.Version = buildVersion + s.Info.Build = buildTime + s.Info.Commit = commitID +} + +func (s *sInfo) SetInfoData(data map[string]interface{}) { + s.Info.Data = data +} + +func (s *sInfo) GetInfoData() map[string]interface{} { + return s.Info.Data +} + +func (s *sInfo) AddInfoData(key string, value interface{}) { + if s.Info.Data == nil { + s.Info.Data = make(map[string]interface{}) + } + s.Info.Data[key] = value +} + +func (s *sInfo) DelInfoData(key string) { + delete(s.Info.Data, key) +} + +func (s *sInfo) GetInfoDataByKey(key string) interface{} { + if s.Info.Data == nil { + return nil + } + if _, ok := s.Info.Data[key]; !ok { + return nil + } + return s.Info.Data[key] +} diff --git a/internal/pub/xjpublogic/info/inforpc.go b/internal/pub/xjpublogic/info/inforpc.go new file mode 100644 index 0000000..22a2fb2 --- /dev/null +++ b/internal/pub/xjpublogic/info/inforpc.go @@ -0,0 +1,127 @@ +package info + +import ( + "context" + "runtime" + + "git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/pub/xjpubmodel" + "git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/pub/xjpubservice" + "github.com/gogf/gf/v2/frame/g" + "github.com/gogf/gf/v2/os/glog" +) + +type sInfoRpc struct { + Info *xjpubmodel.InfoModel +} + +func NewInfoRpc() *sInfoRpc { + return &sInfoRpc{ + Info: xjpubmodel.NewInfoModel(), + } +} + +func init() { + xjpubservice.RegisterInfoRpc(NewInfoRpc()) +} + +func (s *sInfoRpc) GetInfo() *xjpubmodel.InfoModel { + ctx := context.Background() + logLevel := g.Cfg().MustGet(ctx, "logger.level", "").String() + if logLevel != "" { + g.Log().SetLevelStr(logLevel) + } + s.AddInfoData("log_level_conf", logLevel) + s.AddInfoData("log_level_real", toLogLevelStr(g.Log().GetLevel())) + s.AddInfoData("Goroutines", runtime.NumGoroutine()) + var m runtime.MemStats + runtime.ReadMemStats(&m) + s.AddInfoData("MemAllocKB", m.Alloc/1024) + s.AddInfoData("MemTotalAllocKB", m.TotalAlloc/1024) + s.AddInfoData("MemSysKB", m.Sys/1024) + s.AddInfoData("MemHeapAllocKB", m.HeapAlloc/1024) + s.AddInfoData("MemHeapSysKB", m.HeapSys/1024) + s.AddInfoData("MemHeapIdleKB", m.HeapIdle/1024) + s.AddInfoData("MemHeapInuseKB", m.HeapInuse/1024) + s.AddInfoData("MemHeapReleasedKB", m.HeapReleased/1024) + s.AddInfoData("MemHeapObjects", m.HeapObjects) + s.AddInfoData("MemStackInuseKB", m.StackInuse/1024) + s.AddInfoData("MemStackSysKB", m.StackSys/1024) + s.AddInfoData("MemNumGC", m.NumGC) + return s.Info +} + +func toLogLevelStr(level int) string { + if level == glog.LEVEL_NONE { + return "NONE" + } + if level == glog.LEVEL_ALL { + return "ALL" + } + if level == glog.LEVEL_DEV { + return "DEV" + } + if level == glog.LEVEL_PROD { + return "PROD" + } + str := "" + if level&glog.LEVEL_DEBU != 0 { + str += "DEBUG " + } + if level&glog.LEVEL_INFO != 0 { + str += "INFO " + } + if level&glog.LEVEL_NOTI != 0 { + str += "NOTI " + } + if level&glog.LEVEL_WARN != 0 { + str += "WARN " + } + if level&glog.LEVEL_ERRO != 0 { + str += "ERRO " + } + if level&glog.LEVEL_CRIT != 0 { + str += "CRIT " + } + if level&glog.LEVEL_PANI != 0 { + str += "PANI " + } + if level&glog.LEVEL_FATA != 0 { + str += "FATA " + } + return str +} + +func (s *sInfoRpc) SetInfoName(Type, Name, Title, Description string) { + if s.Info.Name == "" { + s.Info.Type = Type + s.Info.Name = Name + s.Info.Title = Title + s.Info.Description = Description + } +} + +func (s *sInfoRpc) SetInfoBuild(buildVersion, buildTime, commitID string) { + s.Info.Version = buildVersion + s.Info.Build = buildTime + s.Info.Commit = commitID +} + +func (s *sInfoRpc) SetInfoData(data map[string]interface{}) { + s.Info.Data = data +} + +func (s *sInfoRpc) GetInfoData() map[string]interface{} { + return s.Info.Data +} + +func (s *sInfoRpc) AddInfoData(key string, value interface{}) { + s.Info.Data[key] = value +} + +func (s *sInfoRpc) DelInfoData(key string) { + delete(s.Info.Data, key) +} + +func (s *sInfoRpc) GetInfoDataByKey(key string) interface{} { + return s.Info.Data[key] +} diff --git a/internal/pub/xjpublogic/middleware/middleware.go b/internal/pub/xjpublogic/middleware/middleware.go new file mode 100644 index 0000000..06425f8 --- /dev/null +++ b/internal/pub/xjpublogic/middleware/middleware.go @@ -0,0 +1,118 @@ +// package middleware +// @Link https://github.com/xiujiecn/xiu-admin +// @Copyright Copyright (c) 2025 LiXiujie +// @Author Lxj +// @License https://github.com/xiujiecn/xiu-admin/blob/master/LICENSE +package middleware + +import ( + "context" + "net/http" + "slices" + + "github.com/gogf/gf/v2/errors/gcode" + "github.com/gogf/gf/v2/errors/gerror" + "github.com/gogf/gf/v2/frame/g" + "github.com/gogf/gf/v2/net/ghttp" + "github.com/gogf/gf/v2/util/gmeta" + + "git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/pub/xjpubconsts" + "git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/pub/xjpubmodel/response" + "git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/pub/xjpubservice" +) + +type sMiddleware struct { +} + +func New() *sMiddleware { + return &sMiddleware{} +} + +func init() { + xjpubservice.RegisterMiddleware(New()) +} + +func getContentType(r *ghttp.Request) (contentType string) { + contentType = r.Response.Header().Get("Content-Type") + if contentType != "" { + return + } + + mime := gmeta.Get(r.GetHandlerResponse(), "mime").String() + if mime == "" { + contentType = xjpubconsts.HTTPContentTypeJson + } else { + contentType = mime + } + return +} + +// 响应处理中间件 +func (m *sMiddleware) ResponseHandler(r *ghttp.Request) { + r.Middleware.Next() + if r.Response.BufferLength() > 0 { + return + } + + contentType := getContentType(r) + if contentType == xjpubconsts.HTTPContentTypeExcel { + r.Response.Status = http.StatusOK + return + } + // g.Log().Infof(r.GetCtx(), "sMiddleware.ResponseHandler,contentType %v", contentType) + err := r.GetError() + res := r.GetHandlerResponse() + var code gcode.Code = gcode.CodeOK + + if err != nil { + code = gerror.Code(err) + if code == gcode.CodeNil { + code = gcode.CodeInternalError + } + response.JsonExit(r, code.Code(), err.Error()) + } else { + response.Json(r, code.Code(), "", res) + } +} + +// CORS 跨域处理 +func (s *sMiddleware) CORS(r *ghttp.Request) { + r.SetCtx(r.GetNeverDoneCtx()) + + corsOptions := r.Response.DefaultCORSOptions() + corsConfig := g.Cfg().MustGet(context.Background(), "server.allowedDomains").Strings() + if len(corsConfig) == 0 { + r.Response.CORSDefault() + } else { + corsOptions.AllowDomain = corsConfig + r.Response.CORS(corsOptions) + } + r.Middleware.Next() +} + +// IsExceptLogin 判断是否需要验证登录 +func (s *sMiddleware) IsExceptLogin(ctx context.Context, path string) bool { + pathList := g.Cfg().MustGet(ctx, "router.exceptLogin").Strings() + return slices.Contains(pathList, path) +} + +// ResponseHandlerRaw 返回处理中间件 +func (s *sMiddleware) ResponseHandlerRaw(r *ghttp.Request) { + r.Middleware.Next() + // 如果已经有返回内容,那么该中间件什么也不做 + if r.Response.BufferLength() > 0 { + g.Log().Infof(r.Context(), "ResponseHandler r.Response.BufferLength() %d buf:%s", r.Response.BufferLength(), r.Response.BufferString()) + return + } + + var ( + err = r.GetError() + res = r.GetHandlerResponse() + ) + if err != nil { + r.Response.WriteJson(res) + r.ExitAll() + } else { + r.Response.WriteJson(res) + } +} diff --git a/internal/pub/xjpublogic/xjpublogic.go b/internal/pub/xjpublogic/xjpublogic.go new file mode 100644 index 0000000..0b5f614 --- /dev/null +++ b/internal/pub/xjpublogic/xjpublogic.go @@ -0,0 +1,11 @@ +// ========================================================================== +// Code generated and maintained by GoFrame CLI tool. DO NOT EDIT. +// ========================================================================== + +package xjpublogic + +import ( + _ "git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/pub/xjpublogic/httpclient" + _ "git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/pub/xjpublogic/info" + _ "git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/pub/xjpublogic/middleware" +) diff --git a/internal/pub/xjpubmodel/info.go b/internal/pub/xjpubmodel/info.go new file mode 100644 index 0000000..1e8486a --- /dev/null +++ b/internal/pub/xjpubmodel/info.go @@ -0,0 +1,18 @@ +package xjpubmodel + +type InfoModel struct { + Type string `json:"type"` + Name string `json:"name"` + Title string `json:"title"` + Description string `json:"description"` + Version string `json:"version"` + Build string `json:"build"` + Commit string `json:"commit"` + Data map[string]interface{} `json:"data"` +} + +func NewInfoModel() *InfoModel { + return &InfoModel{ + Data: make(map[string]interface{}), + } +} diff --git a/internal/pub/xjpubmodel/response/common.go b/internal/pub/xjpubmodel/response/common.go new file mode 100644 index 0000000..9f9000a --- /dev/null +++ b/internal/pub/xjpubmodel/response/common.go @@ -0,0 +1,12 @@ +// package response +// @Link https://github.com/xiujiecn/xiu-admin +// @Copyright Copyright (c) 2025 LiXiujie +// @Author Lxj +// @License https://github.com/xiujiecn/xiu-admin/blob/master/LICENSE +package response + +type PageResult struct { + Page int `json:"page"` + PageSize int `json:"pageSize"` + Total int `json:"total"` +} diff --git a/internal/pub/xjpubmodel/response/response.go b/internal/pub/xjpubmodel/response/response.go new file mode 100644 index 0000000..5f38047 --- /dev/null +++ b/internal/pub/xjpubmodel/response/response.go @@ -0,0 +1,67 @@ +// package response +// @Link https://github.com/xiujiecn/xiu-admin +// @Copyright Copyright (c) 2025 LiXiujie +// @Author Lxj +// @License https://github.com/xiujiecn/xiu-admin/blob/master/LICENSE +package response + +import ( + "fmt" + "io" + "net/http" + "time" + + "github.com/gogf/gf/v2/frame/g" + "github.com/gogf/gf/v2/net/ghttp" + "github.com/gogf/gf/v2/os/gtime" +) + +// 泛型 +type ResponseGeneric[T any] struct { + Code int `json:"code"` + Message string `json:"message"` + Data T `json:"data"` +} + +type JsonResponse struct { + Code int `json:"code"` + Message string `json:"message"` + Data interface{} `json:"data"` + Error interface{} `json:"error"` +} + +func Json(r *ghttp.Request, code int, message string, data ...interface{}) { + var responseData interface{} + if len(data) > 0 { + responseData = data[0] + } else { + responseData = g.Map{} + } + // g.Log().Infof(r.GetCtx(), "response.Json: %v", responseData) + r.Response.WriteJson(JsonResponse{ + Code: code, + Message: message, + Data: responseData, + }) +} + +func JsonExit(r *ghttp.Request, code int, message string, data ...interface{}) { + Json(r, code, message, data...) + r.ExitAll() +} + +// 向前端返回Excel文件 参数 content 为上面生成的io.ReadSeeker, fileTag 为返回前端的文件名 +func Excel(r *ghttp.Request, content io.ReadSeeker, fileTag string) { + fileName := fmt.Sprintf("%s%s%s.xlsx", gtime.Now().Format("Ymdhis"), `_`, fileTag) + r.Response.Header().Set("Content-Type", "application/vnd.ms-excel") + r.Response.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%s", fileName)) + http.ServeContent(r.Response.Writer, r.Request, fileTag, time.Now(), content) +} + +// 向前端返回Json文件 参数 content 为上面生成的io.ReadSeeker, fileTag 为返回前端的文件名 +func JsonFile(r *ghttp.Request, content io.ReadSeeker, fileTag string) { + fileName := fmt.Sprintf("%s%s%s.json", gtime.Now().Format("Ymdhis"), `_`, fileTag) + r.Response.Header().Set("Content-Type", "application/json") + r.Response.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%s", fileName)) + http.ServeContent(r.Response.Writer, r.Request, fileTag, time.Now(), content) +} diff --git a/internal/pub/xjpubmodel/sim_chan_api.go b/internal/pub/xjpubmodel/sim_chan_api.go new file mode 100644 index 0000000..23d8e12 --- /dev/null +++ b/internal/pub/xjpubmodel/sim_chan_api.go @@ -0,0 +1,48 @@ +package xjpubmodel + +type SimKey struct { + ChanKey string `json:"chanKey" dc:"通道标识"` + ICCID string `json:"iccid" dc:"联网卡集成电路卡识别码20位"` +} + +// 物联网卡在线信息实时查询 请求参数 +type SimSessionInput struct { + SimKeys []*SimKey `json:"simKeys" dc:"SIM卡标识列表"` +} + +type SimSessionItem struct { + *SimKey + ApnId string `json:"apnId" dc:"APN ID"` + Rat string `json:"rat" dc:"接入方式"` + Ipv4 string `json:"ipv4" dc:"IPV4"` + Ipv6 string `json:"ipv6" dc:"IPV6"` + Ipv6Prefix string `json:"ipv6Prefix" dc:"IPV6前缀"` + Status string `json:"status" dc:"状态(0:用户在线 -1:用户不在线 -2:未查询到会话信息)"` + CreateTime string `json:"createTime" dc:"创建时间"` + ApiStatus int `json:"apiStatus" dc:"API状态"` + ApiMessage string `json:"apiMessage" dc:"API消息"` +} + +// 物联网卡在线信息实时查询 响应参数 +type SimSessionOutput struct { + Items []*SimSessionItem `json:"items" dc:"SIM卡会话列表"` +} + +// 物联网卡卡状态查询 请求参数 +type SimCardStatusInput struct { + SimKeys []*SimKey `json:"simKeys" dc:"SIM卡标识列表"` +} + +// 物联网卡卡状态查询 响应参数 +type SimCardStatusOutput struct { + Items []*SimCardStatusItem `json:"items" dc:"SIM卡状态列表"` +} + +// {"cardStatus":"2","lastChangeDate":"2024-05-30 22:05:31"}] +type SimCardStatusItem struct { + *SimKey + CardStatus int `json:"cardStatus" dc:"卡状态"` + LastChangeDate string `json:"lastChangeDate" dc:"最后变更时间"` + ApiStatus int `json:"apiStatus" dc:"API状态"` + ApiMessage string `json:"apiMessage" dc:"API消息"` +} diff --git a/internal/pub/xjpubmodel/xj_metric.go b/internal/pub/xjpubmodel/xj_metric.go new file mode 100644 index 0000000..e352b0c --- /dev/null +++ b/internal/pub/xjpubmodel/xj_metric.go @@ -0,0 +1,91 @@ +package xjpubmodel + +import ( + "github.com/gogf/gf/v2/os/gtime" + "github.com/gogf/gf/v2/util/gconv" +) + +type MetricItem struct { + Name string `json:"name"` + Value float64 `json:"value"` + Time *gtime.Time `json:"time"` +} + +func (m *MetricItem) SetValue(value interface{}) { + m.Value = gconv.Float64(value) + m.Time = gtime.Now() +} +func (m *MetricItem) GetValue() float64 { + return m.Value +} +func (m *MetricItem) GetTime() *gtime.Time { + return m.Time +} +func (m *MetricItem) GetName() string { + return m.Name +} +func (m *MetricItem) SetName(name string) { + m.Name = name +} +func (m *MetricItem) AddValue(value interface{}) { + m.Value += gconv.Float64(value) + m.Time = gtime.Now() +} +func (m *MetricItem) Inc() { + m.Value++ + m.Time = gtime.Now() +} +func (m *MetricItem) Dec() { + m.Value-- + m.Time = gtime.Now() +} + +type MetricData struct { + MetricClientCount MetricItem `json:"clientCount"` // 当前在线数 + MetricConnectedCounter MetricItem `json:"connectedCounter"` // 连接数 + MetricDisconnectedCounter MetricItem `json:"disconnectedCounter"` // 断开连接数 + MetricRecvPkgCount MetricItem `json:"recvPkgCount"` // 接收包数 + MetricSendPkgCount MetricItem `json:"sendPkgCount"` // 发送包数 + MetricRecvPkgSize MetricItem `json:"recvPkgSize"` // 接收包大小 + MetricSendPkgSize MetricItem `json:"sendPkgSize"` // 发送包大小 + MetricRecvPkgTime MetricItem `json:"recvPkgTime"` // 接收包时间(毫秒) + MetricSendPkgTime MetricItem `json:"sendPkgTime"` // 发送包时间(毫秒) + MetricSubTopicCount MetricItem `json:"subTopicCount"` // 订阅主题数 + MetricAuthSuccessCount MetricItem `json:"authSuccessCount"` // 认证成功数 + MetricAuthFailCount MetricItem `json:"authFailCount"` // 认证失败数 + MetricAuthCostTime MetricItem `json:"authCostTime"` // 认证耗时 + MetricUpMsgConnectCostTime MetricItem `json:"upMsgConnectCostTime"` // 上行消息连接耗时(毫秒) + MetricUpMsgDataCostTime MetricItem `json:"upMsgDataCostTime"` // 上行消息数据耗时(毫秒) + MetricUpMsgDisconnectCostTime MetricItem `json:"upMsgDisconnectCostTime"` // 上行消息断开连接耗时(毫秒) + MetricTopicCount MetricItem `json:"topicCount"` // 累计主题数 + MetricProcessMemSize MetricItem `json:"processMemSize"` // 进程内存大小 + MetricProcessCpuUsage MetricItem `json:"processCpuUsage"` // 进程CPU使用率 + MetricProcessThreads MetricItem `json:"processThreads"` // 进程线程数 + MetricProcessGoroutines MetricItem `json:"processGoroutines"` // 进程协程数 + MetricSysCpuNum MetricItem `json:"sysCpuNum"` // 系统CPU数量 + MetricSysCpuCores MetricItem `json:"sysCpuCores"` // 系统CPU核数 + MetricSysCpuUsage MetricItem `json:"sysCpuUsage"` // 系统CPU使用率 + MetricSysCpuLoad1 MetricItem `json:"sysCpuLoad1"` // 系统CPU负载1 + MetricSysCpuLoad5 MetricItem `json:"sysCpuLoad5"` // 系统CPU负载2 + MetricSysCpuLoad15 MetricItem `json:"sysCpuLoad15"` // 系统CPU负载3 + MetricSysMemTotal MetricItem `json:"sysMemTotal"` // 系统内存总量 + MetricSysMemUsage MetricItem `json:"sysMemUsage"` // 系统内存使用率 + MetricSysDiskTotal MetricItem `json:"sysDiskTotal"` // 系统磁盘总量 + MetricSysDiskUsage MetricItem `json:"sysDiskUsage"` // 系统磁盘使用率 + MetricWaitMsgCount MetricItem `json:"waitMsgCount"` // 等待消息数量 + MetricOneDeviceWaitMsgCount MetricItem `json:"oneDeviceWaitMsgCount"` // 同一个设备等待消息数量 + MetricRecvMsgFailCount MetricItem `json:"recvMsgFailCount"` // 接收消息处理失败数 + MetricRecvMsgWaitCostTime MetricItem `json:"recvMsgWaitCostTime"` // 接收消息等待耗时 +} +type XjServerInfo struct { + NodeName string `json:"nodeName"` + SvrType int `json:"svrType"` + SvrCode string `json:"svrCode"` // 服务器编号 + ShardId int64 `json:"shardId"` // 分片ID + StartTime *gtime.Time `json:"startTime"` // 启动时间 + SysIpLan string `json:"sysIpLan"` // 内网IP + SysIpWan string `json:"sysIpWan"` // 外网IP + AppVersion string `json:"appVersion"` // 程序版本 + TenantId string `json:"tenantId"` // 租户ID + ReportInterval int64 `json:"reportInterval"` // 上报间隔 +} diff --git a/internal/pub/xjpubservice/httpclient.go b/internal/pub/xjpubservice/httpclient.go new file mode 100644 index 0000000..0937974 --- /dev/null +++ b/internal/pub/xjpubservice/httpclient.go @@ -0,0 +1,53 @@ +// ================================================================================ +// Code generated and maintained by GoFrame CLI tool. DO NOT EDIT. +// You can delete these comments if you wish manually maintain this interface file. +// ================================================================================ + +package xjpubservice + +import ( + "context" + + xjpubmodel "git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/pub/xjpubmodel" +) + +type ( + IHttpClient interface { + PostObj(ctx context.Context, serviceName string, path string, req interface{}, res interface{}) (err error) + Post(ctx context.Context, serviceName string, path string, req []byte) (res []byte, err error) + AllServerPost(ctx context.Context, serviceName string, path string, req []byte) (resMap map[string][]byte, err error) + } + IUpstreamProxyClient interface { + // 物联网卡在线信息实时查询 + QuerySimSession(ctx context.Context, req *xjpubmodel.SimSessionInput) (res *xjpubmodel.SimSessionOutput, err error) + // 物联网卡卡状态查询 + QuerySimCardStatus(ctx context.Context, req *xjpubmodel.SimCardStatusInput) (res *xjpubmodel.SimCardStatusOutput, err error) + } +) + +var ( + localHttpClient IHttpClient + localUpstreamProxyClient IUpstreamProxyClient +) + +func HttpClient() IHttpClient { + if localHttpClient == nil { + panic("implement not found for interface IHttpClient, forgot register?") + } + return localHttpClient +} + +func RegisterHttpClient(i IHttpClient) { + localHttpClient = i +} + +func UpstreamProxyClient() IUpstreamProxyClient { + if localUpstreamProxyClient == nil { + panic("implement not found for interface IUpstreamProxyClient, forgot register?") + } + return localUpstreamProxyClient +} + +func RegisterUpstreamProxyClient(i IUpstreamProxyClient) { + localUpstreamProxyClient = i +} diff --git a/internal/pub/xjpubservice/info.go b/internal/pub/xjpubservice/info.go new file mode 100644 index 0000000..0b2cefd --- /dev/null +++ b/internal/pub/xjpubservice/info.go @@ -0,0 +1,60 @@ +// ================================================================================ +// Code generated and maintained by GoFrame CLI tool. DO NOT EDIT. +// You can delete these comments if you wish manually maintain this interface file. +// ================================================================================ + +package xjpubservice + +import ( + "git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/pub/xjpubmodel" +) + +type ( + IInfo interface { + GetInfo() *xjpubmodel.InfoModel + SetInfoName(Type string, Name string, Title string, Description string) + SetInfoBuild(buildVersion string, buildTime string, commitID string) + SetInfoData(data map[string]interface{}) + GetInfoData() map[string]interface{} + AddInfoData(key string, value interface{}) + DelInfoData(key string) + GetInfoDataByKey(key string) interface{} + } + IInfoRpc interface { + GetInfo() *xjpubmodel.InfoModel + SetInfoName(Type string, Name string, Title string, Description string) + SetInfoBuild(buildVersion string, buildTime string, commitID string) + SetInfoData(data map[string]interface{}) + GetInfoData() map[string]interface{} + AddInfoData(key string, value interface{}) + DelInfoData(key string) + GetInfoDataByKey(key string) interface{} + } +) + +var ( + localInfo IInfo + localInfoRpc IInfoRpc +) + +func Info() IInfo { + if localInfo == nil { + panic("implement not found for interface IInfo, forgot register?") + } + return localInfo +} + +func RegisterInfo(i IInfo) { + localInfo = i +} + +func InfoRpc() IInfoRpc { + if localInfoRpc == nil { + panic("implement not found for interface IInfoRpc, forgot register?") + } + return localInfoRpc +} + +func RegisterInfoRpc(i IInfoRpc) { + localInfoRpc = i +} diff --git a/internal/pub/xjpubservice/middleware.go b/internal/pub/xjpubservice/middleware.go new file mode 100644 index 0000000..8d60b7a --- /dev/null +++ b/internal/pub/xjpubservice/middleware.go @@ -0,0 +1,40 @@ +// ================================================================================ +// Code generated and maintained by GoFrame CLI tool. DO NOT EDIT. +// You can delete these comments if you wish manually maintain this interface file. +// ================================================================================ + +package xjpubservice + +import ( + "context" + + "github.com/gogf/gf/v2/net/ghttp" +) + +type ( + IMiddleware interface { + // 响应处理中间件 + ResponseHandler(r *ghttp.Request) + // CORS 跨域处理 + CORS(r *ghttp.Request) + // IsExceptLogin 判断是否需要验证登录 + IsExceptLogin(ctx context.Context, path string) bool + // ResponseHandlerRaw 返回处理中间件 + ResponseHandlerRaw(r *ghttp.Request) + } +) + +var ( + localMiddleware IMiddleware +) + +func Middleware() IMiddleware { + if localMiddleware == nil { + panic("implement not found for interface IMiddleware, forgot register?") + } + return localMiddleware +} + +func RegisterMiddleware(i IMiddleware) { + localMiddleware = i +} diff --git a/internal/service/channelconfig.go b/internal/service/channelconfig.go new file mode 100644 index 0000000..2be860a --- /dev/null +++ b/internal/service/channelconfig.go @@ -0,0 +1,56 @@ +// ================================================================================ +// Code generated and maintained by GoFrame CLI tool. DO NOT EDIT. +// You can delete these comments if you wish manually maintain this interface file. +// ================================================================================ + +package service + +import ( + "context" + "git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/model" +) + +type ( + IChannelConfig interface { + Start(ctx context.Context) error + Stop(ctx context.Context) error + Loop(ctx context.Context) error + LoadAllChannelConfig(ctx context.Context) error + RefreshChannel(ctx context.Context, chanKey string) error + RemoveChannel(ctx context.Context, chanKey string) error + LoadChannelForTest(ctx context.Context, channelID int64, tenantID string) (*model.SimChannelConfigItemCache, error) + // get alarm cache by id + GetChannel(chanKey string) (*model.SimChannelConfigItemCache, bool) + GetAllChannel(ctx context.Context) ([]*model.SimChannelConfigItemCache, error) + } + IChannelUpdate interface { + UpdateAccessToken(ctx context.Context, input *model.UpdateAccessTokenInput) error + } +) + +var ( + localChannelConfig IChannelConfig + localChannelUpdate IChannelUpdate +) + +func ChannelConfig() IChannelConfig { + if localChannelConfig == nil { + panic("implement not found for interface IChannelConfig, forgot register?") + } + return localChannelConfig +} + +func RegisterChannelConfig(i IChannelConfig) { + localChannelConfig = i +} + +func ChannelUpdate() IChannelUpdate { + if localChannelUpdate == nil { + panic("implement not found for interface IChannelUpdate, forgot register?") + } + return localChannelUpdate +} + +func RegisterChannelUpdate(i IChannelUpdate) { + localChannelUpdate = i +} diff --git a/internal/service/daihe/client.go b/internal/service/daihe/client.go new file mode 100644 index 0000000..4139e06 --- /dev/null +++ b/internal/service/daihe/client.go @@ -0,0 +1,169 @@ +package daihe + +import ( + "bytes" + "context" + "crypto/md5" + "encoding/hex" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "sort" + "strings" + "time" +) + +type Config struct { + BaseURL string + AppID string + AppSecret string + Timeout time.Duration +} + +type Client struct { + baseURL, appID, appSecret string + httpClient *http.Client +} + +type Response struct { + Code int `json:"code"` + Msg string `json:"msg"` + Data json.RawMessage `json:"data"` +} + +func NewClient(cfg Config) *Client { + timeout := cfg.Timeout + if timeout <= 0 { + timeout = 15 * time.Second + } + return &Client{baseURL: strings.TrimRight(strings.TrimSpace(cfg.BaseURL), "/"), appID: strings.TrimSpace(cfg.AppID), appSecret: cfg.AppSecret, httpClient: &http.Client{Timeout: timeout}} +} + +func BuildSign(params map[string]string, appSecret string) string { + keys := make([]string, 0, len(params)+1) + for key := range params { + if key != "sign" { + keys = append(keys, key) + } + } + keys = append(keys, "appSecret") + sort.Strings(keys) + var b strings.Builder + for _, key := range keys { + if key == "appSecret" { + b.WriteString(key) + b.WriteString(appSecret) + continue + } + b.WriteString(key) + b.WriteString(params[key]) + } + sum := md5.Sum([]byte(b.String())) + return strings.ToUpper(hex.EncodeToString(sum[:])) +} + +func (c *Client) request(ctx context.Context, method, path string, values map[string]string, body any, out any) error { + if c.baseURL == "" || c.appID == "" || c.appSecret == "" { + return fmt.Errorf("daihe base URL, appId and appSecret are required") + } + params := make(map[string]string, len(values)+1) + for k, v := range values { + if strings.TrimSpace(v) != "" { + params[k] = v + } + } + params["appId"] = c.appID + params["sign"] = BuildSign(params, c.appSecret) + var reader io.Reader + var endpoint string + if method == http.MethodGet { + query := url.Values{} + for k, v := range params { + query.Set(k, v) + } + endpoint = c.baseURL + "/" + strings.TrimLeft(path, "/") + "?" + query.Encode() + } else { + payload := map[string]any{} + if body != nil { + raw, err := json.Marshal(body) + if err != nil { + return fmt.Errorf("marshal daihe request: %w", err) + } + if err := json.Unmarshal(raw, &payload); err != nil { + return fmt.Errorf("marshal daihe request: %w", err) + } + } + for k, v := range params { + payload[k] = v + } + raw, err := json.Marshal(payload) + if err != nil { + return fmt.Errorf("marshal daihe request: %w", err) + } + reader = bytes.NewReader(raw) + endpoint = c.baseURL + "/" + strings.TrimLeft(path, "/") + } + req, err := http.NewRequestWithContext(ctx, method, endpoint, reader) + if err != nil { + return err + } + req.Header.Set("Content-Type", "application/json") + resp, err := c.httpClient.Do(req) + if err != nil { + return fmt.Errorf("daihe request failed: %w", err) + } + defer resp.Body.Close() + raw, err := io.ReadAll(io.LimitReader(resp.Body, 4*1024*1024+1)) + if err != nil { + return err + } + if len(raw) > 4*1024*1024 { + return fmt.Errorf("daihe response is too large") + } + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return fmt.Errorf("daihe HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(raw))) + } + var envelope Response + if err := json.Unmarshal(raw, &envelope); err != nil { + return fmt.Errorf("decode daihe response: %w", err) + } + if envelope.Code != 0 { + return fmt.Errorf("daihe business code %d: %s", envelope.Code, envelope.Msg) + } + if out != nil && len(envelope.Data) > 0 && string(envelope.Data) != "null" { + if err := json.Unmarshal(envelope.Data, out); err != nil { + return fmt.Errorf("decode daihe data: %w", err) + } + } + return nil +} + +type CMCCToken struct { + AppID string `json:"appid"` + Token string `json:"token"` + TokenDevice string `json:"token_device"` + DeviceTTL string `json:"device_ttl"` +} + +func (c *Client) QueryCMCCToken(ctx context.Context, cmccAppID string, out *CMCCToken) error { + return c.request(ctx, http.MethodGet, "/sim/query/cmccToken", map[string]string{"cmccAppId": cmccAppID}, nil, out) +} + +func (c *Client) QueryUsage(ctx context.Context, iccid, month string, out any) error { + values := map[string]string{"iccid": iccid} + if month != "" { + values["month"] = month + } + return c.request(ctx, http.MethodGet, "/sim/query/monthUsed", values, nil, out) +} +func (c *Client) QueryStatus(ctx context.Context, iccid string, out any) error { + return c.request(ctx, http.MethodGet, "/sim/query/status", map[string]string{"iccid": iccid}, nil, out) +} +func (c *Client) Activate(ctx context.Context, iccid string) error { + return c.request(ctx, http.MethodPost, "/sim/setup/activate", map[string]string{"iccid": iccid}, nil, nil) +} +func (c *Client) Deactivate(ctx context.Context, iccid string) error { + return c.request(ctx, http.MethodPost, "/sim/setup/deactivate", map[string]string{"iccid": iccid}, nil, nil) +} diff --git a/internal/service/mingqian/adapter.go b/internal/service/mingqian/adapter.go new file mode 100644 index 0000000..9c1591a --- /dev/null +++ b/internal/service/mingqian/adapter.go @@ -0,0 +1,273 @@ +package mingqian + +import ( + "context" + "encoding/json" + "math" + "strconv" + "strings" + "time" + + "git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/consts" + "git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/model" + "github.com/gogf/gf/v2/errors/gerror" +) + +const TemplateKey = "sim_mingqian_v3" +const statePath = "/simlist/get-state-by-number" +const usagePath = "/simlist/get-flow-use-by-number" +const packageUsagePath = "/use/get-card-use-by-number" + +type Config struct { + API struct { + BaseURL string `json:"baseUrl"` + Timeout string `json:"timeout"` + ResumePath string `json:"resumePath"` + } `json:"api"` +} + +type Adapter struct { + client *Client + resumePath string +} + +var _ model.SimCardChannelI = (*Adapter)(nil) + +func New(cfg *model.SimChannelConfigItemCache) (*Adapter, error) { + if cfg == nil || cfg.Template == nil || cfg.Channel == nil { + return nil, gerror.New("mingqian: channel and template required") + } + config := Config{} + config.API.BaseURL, config.API.Timeout = DefaultBaseURL, "10s" + if strings.TrimSpace(cfg.Template.TempConfig) != "" { + if err := json.Unmarshal([]byte(cfg.Template.TempConfig), &config); err != nil { + return nil, gerror.New("mingqian: invalid template JSON") + } + } + timeout, err := time.ParseDuration(config.API.Timeout) + if err != nil || timeout <= 0 { + return nil, gerror.New("mingqian: invalid api.timeout") + } + resume := strings.TrimSpace(config.API.ResumePath) + if resume != "" && (!strings.HasPrefix(resume, "/simlist/") || strings.ContainsAny(resume, "?#") || strings.Contains(resume, "..") || resume == "/simlist/get-sim-switchoff-by-number") { + return nil, gerror.New("mingqian: resumePath must be a confirmed distinct /simlist/ path") + } + client, err := NewClient(config.API.BaseURL, cfg.Channel.AppKey, cfg.Channel.AppSecret, timeout) + if err != nil { + return nil, err + } + return &Adapter{client: client, resumePath: resume}, nil +} + +type number float64 + +func (value *number) UnmarshalJSON(raw []byte) error { + text := strings.TrimSpace(string(raw)) + if text == "null" { + return gerror.New("mingqian: null numeric field") + } + if strings.HasPrefix(text, `"`) { + if err := json.Unmarshal(raw, &text); err != nil { + return err + } + } + parsed, err := strconv.ParseFloat(text, 64) + if err != nil || math.IsNaN(parsed) || math.IsInf(parsed, 0) { + return gerror.New("mingqian: invalid numeric field") + } + *value = number(parsed) + return nil +} + +type card struct { + ICCID string `json:"iccid"` + MSISDN string `json:"msisdn"` + State *int `json:"state"` + StateName string `json:"statename"` + Usage *number `json:"use"` + PackageTotal *number `json:"totalpackage"` + PackageUsage *number `json:"targetpackageusage"` + Success *int `json:"issuccess"` + Message string `json:"message"` +} + +func identifier(key *model.SimKey) string { + if key == nil { + return "" + } + if value := strings.TrimSpace(key.ICCID); value != "" { + return value + } + return strings.TrimSpace(key.MSISDN) +} + +func query[T any](ctx context.Context, adapter *Adapter, keys []*model.SimKey, path string, limit int, convert func(card) (T, error)) []*model.SimBaseRes[T] { + output := make([]*model.SimBaseRes[T], len(keys)) + for index, key := range keys { + output[index] = &model.SimBaseRes[T]{SimKey: key, Code: consts.ApiStatusError, Message: "mingqian: ICCID or MSISDN required (IMSI-only unsupported)"} + } + for start := 0; start < len(keys); start += limit { + end := start + limit + if end > len(keys) { + end = len(keys) + } + numbers := make([]string, 0, end-start) + seen := map[string]bool{} + for _, key := range keys[start:end] { + value := identifier(key) + if value != "" && !seen[value] { + numbers = append(numbers, value) + seen[value] = true + } + } + if len(numbers) == 0 { + continue + } + var cards []card + err := adapter.client.post(ctx, path, map[string]any{"numberArray": numbers}, &cards) + for index := start; index < end; index++ { + requested := identifier(keys[index]) + if requested == "" { + continue + } + item := output[index] + if err != nil { + item.Message = err.Error() + continue + } + var matched *card + duplicate := false + for cardIndex := range cards { + candidate := &cards[cardIndex] + matches := false + if strings.TrimSpace(keys[index].ICCID) != "" { + matches = strings.TrimSpace(candidate.ICCID) == requested + } else { + matches = strings.TrimSpace(candidate.MSISDN) == requested + } + if matches { + if matched != nil { + duplicate = true + break + } + matched = candidate + } + } + if duplicate { + item.Message = "mingqian: duplicate card in response" + continue + } + if matched == nil { + item.Message = "mingqian: requested card missing from response" + continue + } + result, mapErr := convert(*matched) + if mapErr != nil { + item.Message = mapErr.Error() + continue + } + item.Code = consts.ApiStatusSuccess + item.Message = "" + item.ResData = result + } + } + return output +} + +func cardStatus(state *int) (int, error) { + if state == nil { + return 0, gerror.New("mingqian: state missing") + } + switch *state { + case 1: + return 1, nil + case 2: + return 2, nil + case 3: + return 4, nil + case 4: + return 6, nil + case 5: + return 7, nil + case 6: + return 9, nil + default: + return 0, gerror.Newf("mingqian: unmapped card state %d", *state) + } +} + +func (adapter *Adapter) QuerySimBasicInfo(ctx context.Context, input *model.SimBasicInfoInput) (model.SimBasicInfoOutput, error) { + return query(ctx, adapter, input.SimKeys, statePath, 50, func(item card) (model.SimBasicInfoItem, error) { + state, err := cardStatus(item.State) + if err != nil { + return model.SimBasicInfoItem{}, err + } + return model.SimBasicInfoItem{ICCID: item.ICCID, MSISDN: item.MSISDN, SimStatus: strconv.Itoa(state), Remark: item.StateName}, nil + }), nil +} + +func (adapter *Adapter) QuerySimCardStatus(ctx context.Context, input *model.SimCardStatusInput) (model.SimCardStatusOutput, error) { + return query(ctx, adapter, input.SimKeys, statePath, 50, func(item card) (model.SimCardStatusItem, error) { + state, err := cardStatus(item.State) + return model.SimCardStatusItem{CardStatus: state}, err + }), nil +} + +func (adapter *Adapter) QuerySimDataUsage(ctx context.Context, input *model.SimDataUsageInput) (model.SimDataUsageOutput, error) { + if input == nil { + return nil, gerror.New("mingqian: input required") + } + output := query(ctx, adapter, input.SimKeys, usagePath, 50, func(item card) (model.SimDataUsageItem, error) { + if item.Usage == nil || *item.Usage < 0 { + return model.SimDataUsageItem{}, gerror.New("mingqian: current-month usage missing or unknown") + } + return model.SimDataUsageItem{DataUsage: float64(*item.Usage) / 1024}, nil + }) + packages, err := adapter.QueryPackageFlow(ctx, input) + if err != nil { + return nil, err + } + for index, item := range output { + if packages[index].Code != consts.ApiStatusSuccess { + item.ResData.PackageFlowError = packages[index].Message + if item.Code == consts.ApiStatusSuccess { + item.Message = "mingqian: current-month usage available; package flow unavailable (see packageFlowError)" + } + continue + } + item.ResData.PackageFlow = &packages[index].ResData + } + return output, nil +} + +func (adapter *Adapter) ChangeSimStatus(ctx context.Context, input *model.SimCardStatusChangeInput) (model.SimCardStatusChangeOutput, error) { + if input == nil { + return nil, gerror.New("mingqian: input required") + } + path := "" + switch strings.TrimSpace(input.ReqData.OperType) { + case consts.CardStatusChangeOperTypeApplyStop: + path = "/simlist/get-sim-switchoff-by-number" + case consts.CardStatusChangeOperTypeApplyResume: + path = adapter.resumePath + default: + return unsupported[model.SimCardStatusChangeItem](input.SimKeys, "invalid operType"), nil + } + if path == "" { + return unsupported[model.SimCardStatusChangeItem](input.SimKeys, "resume endpoint unconfirmed; configure api.resumePath"), nil + } + return query(ctx, adapter, input.SimKeys, path, 20, func(item card) (model.SimCardStatusChangeItem, error) { + if item.Success == nil || *item.Success != 1 { + return model.SimCardStatusChangeItem{}, gerror.New("mingqian: card operation failed or success flag missing") + } + return model.SimCardStatusChangeItem{}, nil + }), nil +} + +func unsupported[T any](keys []*model.SimKey, capability string) []*model.SimBaseRes[T] { + output := make([]*model.SimBaseRes[T], 0, len(keys)) + for _, key := range keys { + output = append(output, &model.SimBaseRes[T]{SimKey: key, Code: consts.ApiStatusError, Message: "mingqian: " + capability}) + } + return output +} diff --git a/internal/service/mingqian/client.go b/internal/service/mingqian/client.go new file mode 100644 index 0000000..063cc87 --- /dev/null +++ b/internal/service/mingqian/client.go @@ -0,0 +1,172 @@ +package mingqian + +import ( + "bytes" + "context" + "crypto/md5" + "crypto/rand" + "encoding/hex" + "encoding/json" + "io" + "net/http" + "net/url" + "sort" + "strconv" + "strings" + "sync" + "time" + + "github.com/gogf/gf/v2/errors/gerror" +) + +const DefaultBaseURL = "http://iotapi.bjmqtx.com/openapi/wlw/v3/cool" + +type gate struct { + mu sync.Mutex + next time.Time +} + +var accountGates sync.Map + +func (limiter *gate) wait(ctx context.Context) error { + for { + if err := ctx.Err(); err != nil { + return err + } + limiter.mu.Lock() + delay := time.Until(limiter.next) + if delay <= 0 { + limiter.next = time.Now().Add(60 * time.Millisecond) + limiter.mu.Unlock() + return nil + } + limiter.mu.Unlock() + timer := time.NewTimer(delay) + select { + case <-ctx.Done(): + timer.Stop() + return ctx.Err() + case <-timer.C: + } + } +} + +type Client struct { + baseURL string + appID string + secret string + http *http.Client + gate *gate +} + +func NewClient(baseURL, appID, secret string, timeout time.Duration) (*Client, error) { + baseURL = strings.TrimRight(strings.TrimSpace(baseURL), "/") + parsed, err := url.Parse(baseURL) + if err != nil || parsed.Host == "" || (parsed.Scheme != "http" && parsed.Scheme != "https") || parsed.User != nil || parsed.RawQuery != "" || parsed.Fragment != "" { + return nil, gerror.New("mingqian: invalid api.baseUrl") + } + appID, secret = strings.TrimSpace(appID), strings.TrimSpace(secret) + if appID == "" || secret == "" { + return nil, gerror.New("mingqian: channel AppKey (appId) and AppSecret (APPKEY) are required") + } + if timeout <= 0 { + return nil, gerror.New("mingqian: timeout must be positive") + } + limiter, _ := accountGates.LoadOrStore(baseURL+"|"+appID, &gate{}) + return &Client{baseURL: baseURL, appID: appID, secret: secret, gate: limiter.(*gate), http: &http.Client{Timeout: timeout, CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }}}, nil +} + +func Sign(params map[string]string, secret string) string { + keys := make([]string, 0, len(params)) + for key := range params { + if key != "sign" { + keys = append(keys, key) + } + } + sort.Strings(keys) + var source strings.Builder + source.WriteString(secret) + for _, key := range keys { + source.WriteString(key) + source.WriteString("_") + source.WriteString(params[key]) + } + source.WriteString(secret) + sum := md5.Sum([]byte(source.String())) + return hex.EncodeToString(sum[:]) +} + +func (client *Client) post(ctx context.Context, path string, data any, output any) error { + if err := client.gate.wait(ctx); err != nil { + return err + } + now := time.Now().In(time.FixedZone("Asia/Shanghai", 8*60*60)) + random := make([]byte, 3) + if _, err := rand.Read(random); err != nil { + return gerror.New("mingqian: cannot generate request ID") + } + common := map[string]string{ + "appId": client.appID, "version": "3.0", "reqtime": now.Format("2006-01-02"), + "timestamp": strconv.FormatInt(now.Add(time.Minute).UnixMilli(), 10), + "transid": now.Format("20060102150405") + strconv.Itoa(1000 + now.Nanosecond()/1000000)[1:] + hex.EncodeToString(random), + } + common["sign"] = Sign(common, client.secret) + payload := struct { + Body map[string]string `json:"body"` + Data string `json:"data,omitempty"` + }{Body: common} + if data != nil { + encoded, err := json.Marshal(data) + if err != nil { + return gerror.New("mingqian: invalid request data") + } + payload.Data = string(encoded) + } + encoded, err := json.Marshal(payload) + if err != nil { + return gerror.New("mingqian: invalid request envelope") + } + request, err := http.NewRequestWithContext(ctx, http.MethodPost, client.baseURL+path, bytes.NewReader(encoded)) + if err != nil { + return gerror.New("mingqian: invalid request URL") + } + request.Header.Set("Content-Type", "application/json") + response, err := client.http.Do(request) + if err != nil { + if ctx.Err() != nil { + return ctx.Err() + } + return gerror.New("mingqian: upstream request failed or timed out; no automatic retry") + } + defer response.Body.Close() + if response.StatusCode != http.StatusOK { + return gerror.Newf("mingqian: HTTP %d", response.StatusCode) + } + body, err := io.ReadAll(io.LimitReader(response.Body, 4*1024*1024+1)) + if err != nil || len(body) > 4*1024*1024 { + return gerror.New("mingqian: invalid or oversized response") + } + var envelope struct { + Code *int `json:"code"` + Msg string `json:"msg"` + Data json.RawMessage `json:"data"` + } + if err := json.Unmarshal(body, &envelope); err != nil { + return gerror.New("mingqian: malformed response JSON") + } + if envelope.Code == nil { + return gerror.New("mingqian: response code missing") + } + if *envelope.Code != 200 { + return gerror.Newf("mingqian: business code %d", *envelope.Code) + } + if output != nil { + if len(envelope.Data) == 0 || string(envelope.Data) == "null" { + return gerror.New("mingqian: response data missing") + } + if err := json.Unmarshal(envelope.Data, output); err != nil { + return gerror.New("mingqian: invalid response data") + } + } + return nil +} diff --git a/internal/service/mingqian/flow_test.go b/internal/service/mingqian/flow_test.go new file mode 100644 index 0000000..dcef11b --- /dev/null +++ b/internal/service/mingqian/flow_test.go @@ -0,0 +1,159 @@ +package mingqian + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "strconv" + "strings" + "testing" + + "git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/model" +) + +func TestPackageFlowFailuresPreserveMonthlyUsage(t *testing.T) { + cases := []struct { + name string + body string + }{ + {"business failure", `{"code":500,"msg":"private upstream detail","data":[]}`}, + {"missing card", `{"code":200,"data":[]}`}, + {"duplicate card", `{"code":200,"data":[{"iccid":"A","totalpackage":10,"targetpackageusage":2},{"iccid":"A","totalpackage":10,"targetpackageusage":2}]}`}, + {"missing total", `{"code":200,"data":[{"iccid":"A","targetpackageusage":2}]}`}, + {"missing usage", `{"code":200,"data":[{"iccid":"A","totalpackage":10}]}`}, + {"null total", `{"code":200,"data":[{"iccid":"A","totalpackage":null,"targetpackageusage":2}]}`}, + {"negative total", `{"code":200,"data":[{"iccid":"A","totalpackage":-1,"targetpackageusage":2}]}`}, + {"negative usage", `{"code":200,"data":[{"iccid":"A","totalpackage":10,"targetpackageusage":-1}]}`}, + {"nonfinite total", `{"code":200,"data":[{"iccid":"A","totalpackage":"NaN","targetpackageusage":2}]}`}, + {"invalid usage", `{"code":200,"data":[{"iccid":"A","totalpackage":10,"targetpackageusage":""}]}`}, + } + for _, test := range cases { + t.Run(test.name, func(t *testing.T) { + adapter := testAdapter(t, func(writer http.ResponseWriter, request *http.Request) { + if request.URL.Path == usagePath { + fmt.Fprint(writer, `{"code":200,"data":[{"iccid":"A","use":1024}]}`) + return + } + fmt.Fprint(writer, test.body) + }) + output, err := adapter.QuerySimDataUsage(context.Background(), &model.SimDataUsageInput{SimKeys: []*model.SimKey{{ICCID: "A"}}}) + if err != nil || len(output) != 1 { + t.Fatalf("output=%+v err=%v", output, err) + } + item := output[0] + if item.Code != 0 || item.ResData.DataUsage != 1 || item.ResData.PackageFlow != nil || item.ResData.PackageFlowError == "" || item.Message == "" { + t.Fatalf("must retain monthly usage with an explicit package failure: %+v", item) + } + if strings.Contains(item.ResData.PackageFlowError, "private upstream detail") { + t.Fatal("raw upstream errors must not leak") + } + }) + } +} + +func TestPackageFlowNeverSubstitutesForMonthlyUsage(t *testing.T) { + adapter := testAdapter(t, func(writer http.ResponseWriter, request *http.Request) { + if request.URL.Path == usagePath { + writer.WriteHeader(http.StatusBadGateway) + return + } + fmt.Fprint(writer, `{"code":200,"data":[{"iccid":"A","totalpackage":10,"targetpackageusage":8}]}`) + }) + output, err := adapter.QuerySimDataUsage(context.Background(), &model.SimDataUsageInput{SimKeys: []*model.SimKey{{ICCID: "A"}}}) + if err != nil || len(output) != 1 || output[0].Code == 0 || !strings.Contains(output[0].Message, "502") { + t.Fatalf("monthly failure must remain a failure: %+v, %v", output, err) + } + if output[0].ResData.DataUsage != 0 || output[0].ResData.PackageFlow == nil || output[0].ResData.PackageFlow.DataUsage != 8 { + t.Fatal("package usage must not become monthly usage") + } +} + +func TestPackageFlowPerCardMatchingAndZero(t *testing.T) { + adapter := testAdapter(t, func(writer http.ResponseWriter, request *http.Request) { + if request.URL.Path == usagePath { + fmt.Fprint(writer, `{"code":200,"data":[{"iccid":"A","use":0},{"msisdn":"144000","use":512},{"iccid":"missing-package","use":2048}]}`) + return + } + fmt.Fprint(writer, `{"code":200,"data":[{"msisdn":"144000","totalpackage":"1.5","targetpackageusage":"2.25"},{"iccid":"A","totalpackage":0,"targetpackageusage":0}]}`) + }) + keys := []*model.SimKey{{ICCID: " A "}, {MSISDN: "144000"}, {ICCID: "missing-package"}, {ICCID: "A"}} + output, err := adapter.QuerySimDataUsage(context.Background(), &model.SimDataUsageInput{SimKeys: keys}) + if err != nil || len(output) != len(keys) { + t.Fatalf("output=%+v err=%v", output, err) + } + for index, item := range output { + if item.Code != 0 || item.SimKey != keys[index] { + t.Fatalf("identity or monthly status changed: %+v", item) + } + } + if output[0].ResData.PackageFlow == nil || *output[0].ResData.PackageFlow != (model.SimPackageFlowItem{}) || output[3].ResData.PackageFlow == nil { + t.Fatal("zero flow is valid and duplicate request identifiers must retain their results") + } + flow := output[1].ResData.PackageFlow + if flow == nil || flow.TotalFlow != 1.5 || flow.DataUsage != 2.25 || flow.RemainingFlow != 0 || output[1].ResData.DataUsage != 0.5 { + t.Fatalf("incorrect overage or units: %+v", flow) + } + if output[2].ResData.PackageFlow != nil || output[2].ResData.PackageFlowError == "" { + t.Fatal("missing package card must not be reported as zero") + } +} + +func TestFlowBothEndpointsBatchAtFifty(t *testing.T) { + calls := map[string]int{} + adapter := testAdapter(t, func(writer http.ResponseWriter, request *http.Request) { + calls[request.URL.Path]++ + var payload struct { + Data string `json:"data"` + } + var data struct { + Numbers []string `json:"numberArray"` + } + if err := json.NewDecoder(request.Body).Decode(&payload); err != nil { + t.Error(err) + } + if err := json.Unmarshal([]byte(payload.Data), &data); err != nil { + t.Error(err) + } + if len(data.Numbers) == 0 || len(data.Numbers) > 50 { + t.Errorf("wrong batch size: %d", len(data.Numbers)) + } + rows := []map[string]any{} + for _, identifier := range data.Numbers { + rows = append(rows, map[string]any{"iccid": identifier, "use": 1024, "totalpackage": 10, "targetpackageusage": 8}) + } + json.NewEncoder(writer).Encode(map[string]any{"code": 200, "data": rows}) + }) + keys := make([]*model.SimKey, 51) + for index := range keys { + keys[index] = &model.SimKey{ICCID: strconv.Itoa(index)} + } + output, err := adapter.QuerySimDataUsage(context.Background(), &model.SimDataUsageInput{SimKeys: keys}) + if err != nil || len(output) != 51 || calls[usagePath] != 2 || calls[packageUsagePath] != 2 { + t.Fatalf("calls=%v count=%d err=%v", calls, len(output), err) + } + for _, item := range output { + if item.Code != 0 || item.ResData.DataUsage != 1 || item.ResData.PackageFlow == nil || item.ResData.PackageFlow.RemainingFlow != 2 { + t.Fatalf("incomplete mapping: %+v", item) + } + } +} + +func TestFlowEmptyInputAndUnsupportedQueries(t *testing.T) { + calls := 0 + adapter := testAdapter(t, func(writer http.ResponseWriter, request *http.Request) { calls++ }) + if _, err := adapter.QuerySimDataUsage(context.Background(), nil); err == nil { + t.Fatal("nil input must fail") + } + output, err := adapter.QuerySimDataUsage(context.Background(), &model.SimDataUsageInput{}) + if err != nil || len(output) != 0 { + t.Fatalf("empty input: %+v %v", output, err) + } + keys := []*model.SimKey{{ICCID: "A"}} + session, _ := adapter.QuerySimSession(context.Background(), &model.SimSessionInput{SimKeys: keys}) + monthly, _ := adapter.QuerySimDataUsageMonthly(context.Background(), &model.SimDataUsageMonthlyInput{SimKeys: keys}) + daily, _ := adapter.QuerySimDataUsageDaily(context.Background(), &model.SimDataUsageDailyInput{SimKeys: keys}) + if session[0].Code == 0 || monthly[0].Code == 0 || daily[0].Code == 0 || calls != 0 { + t.Fatal("unsupported queries must not invent data or contact upstream") + } +} diff --git a/internal/service/mingqian/mingqian_test.go b/internal/service/mingqian/mingqian_test.go new file mode 100644 index 0000000..69f968b --- /dev/null +++ b/internal/service/mingqian/mingqian_test.go @@ -0,0 +1,294 @@ +package mingqian + +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" + "strconv" + "strings" + "testing" + "time" +) + +func testAdapter(t *testing.T, handler http.HandlerFunc) *Adapter { + t.Helper() + server := httptest.NewServer(handler) + t.Cleanup(server.Close) + client, err := NewClient(server.URL, "test-app", "test-secret", time.Second) + if err != nil { + t.Fatal(err) + } + return &Adapter{client: client} +} + +func TestSign(t *testing.T) { + params := map[string]string{"appId": "test-app", "reqtime": "2026-09-17", "timestamp": "1789603260000", "transid": "20260917080000000abcdef", "version": "3.0", "sign": "ignored"} + if got := Sign(params, "test-secret"); got != "78e5d4f24b86159ad2f664ddbcc66282" { + t.Fatalf("wrong sign: %s", got) + } +} + +func TestWireAndUsageMapping(t *testing.T) { + adapter := testAdapter(t, func(writer http.ResponseWriter, request *http.Request) { + if request.Method != http.MethodPost || (request.URL.Path != usagePath && request.URL.Path != packageUsagePath) || request.Header.Get("Content-Type") != "application/json" { + t.Error("incorrect HTTP contract") + } + var payload struct { + Body map[string]string `json:"body"` + Data string `json:"data"` + } + if err := json.NewDecoder(request.Body).Decode(&payload); err != nil { + t.Error(err) + writer.WriteHeader(400) + return + } + if payload.Body["sign"] != Sign(payload.Body, "test-secret") { + t.Error("invalid signature") + } + stamp, err := strconv.ParseInt(payload.Body["timestamp"], 10, 64) + if err != nil || len(payload.Body["timestamp"]) != 13 || time.Until(time.UnixMilli(stamp)) < 55*time.Second || time.Until(time.UnixMilli(stamp)) > 65*time.Second { + t.Error("wrong millisecond timestamp offset") + } + if !regexp.MustCompile(`^\d{17}[0-9a-f]{6}$`).MatchString(payload.Body["transid"]) { + t.Error("invalid transaction ID") + } + var data struct { + Numbers []string `json:"numberArray"` + } + if err := json.Unmarshal([]byte(payload.Data), &data); err != nil || len(data.Numbers) != 3 { + t.Error("data must be escaped JSON containing requested cards") + } + if request.URL.Path == packageUsagePath { + fmt.Fprint(writer, `{"code":200,"data":[{"iccid":"B","totalpackage":10240,"targetpackageusage":2048},{"iccid":"A","totalpackage":"10240","targetpackageusage":"8192"}]}`) + return + } + fmt.Fprint(writer, `{"code":200,"data":[{"iccid":"B","use":2048},{"iccid":"A","use":"1024"}]}`) + }) + result, err := adapter.QuerySimDataUsage(context.Background(), &model.SimDataUsageInput{SimKeys: []*model.SimKey{{ICCID: "A"}, {ICCID: "B"}, {ICCID: "missing"}}}) + if err != nil || len(result) != 3 { + t.Fatalf("result: %v %v", result, err) + } + if result[0].Code != 0 || result[0].ResData.DataUsage != 1 || result[1].ResData.DataUsage != 2 || result[2].Code == 0 { + t.Fatalf("incorrect per-card mapping: %+v", result) + } + if result[0].ResData.TotalFlow != nil { + t.Fatal("monthly usage must not invent package total") + } + packageFlow := result[0].ResData.PackageFlow + if packageFlow == nil || packageFlow.TotalFlow != 10240 || packageFlow.DataUsage != 8192 || packageFlow.RemainingFlow != 2048 { + t.Fatalf("package cumulative flow must remain in MB and separate from monthly usage: %+v", packageFlow) + } + if result[1].ResData.PackageFlow == nil || result[1].ResData.PackageFlow.RemainingFlow != 8192 { + t.Fatal("package response must match the requested card, not array position") + } +} + +func TestInvalidUsage(t *testing.T) { + for _, data := range []string{`{"iccid":"A"}`, `{"iccid":"A","use":null}`, `{"iccid":"A","use":-1}`, `{"iccid":"A","use":"NaN"}`, `{"iccid":"A","use":""}`} { + t.Run(data, func(t *testing.T) { + adapter := testAdapter(t, func(writer http.ResponseWriter, request *http.Request) { + fmt.Fprintf(writer, `{"code":200,"data":[%s]}`, data) + }) + result, _ := adapter.QuerySimDataUsage(context.Background(), &model.SimDataUsageInput{SimKeys: []*model.SimKey{{ICCID: "A"}}}) + if result[0].Code == 0 { + t.Fatal("unknown usage reported as zero success") + } + }) + } +} + +func TestCardStates(t *testing.T) { + for state, want := range map[int]int{1: 1, 2: 2, 3: 4, 4: 6, 5: 7, 6: 9} { + got, err := cardStatus(&state) + if err != nil || got != want { + t.Fatalf("state %d mapped to %d: %v", state, got, err) + } + } + unknown := 7 + if _, err := cardStatus(&unknown); err == nil { + t.Fatal("expired state cannot be assumed to mean cancelled") + } + if _, err := cardStatus(nil); err == nil { + t.Fatal("missing state must fail") + } +} + +func TestBatchAndBasicInfo(t *testing.T) { + calls := 0 + adapter := testAdapter(t, func(writer http.ResponseWriter, request *http.Request) { + calls++ + var payload struct { + Data string `json:"data"` + } + json.NewDecoder(request.Body).Decode(&payload) + var data struct { + Numbers []string `json:"numberArray"` + } + json.Unmarshal([]byte(payload.Data), &data) + if len(data.Numbers) > 50 { + t.Error("query batch exceeds 50") + } + rows := []map[string]any{} + for _, value := range data.Numbers { + rows = append(rows, map[string]any{"iccid": value, "msisdn": "144000", "state": 2, "statename": "active", "activedate": "2026-01-01"}) + } + json.NewEncoder(writer).Encode(map[string]any{"code": 200, "data": rows}) + }) + keys := []*model.SimKey{} + for index := 0; index < 51; index++ { + keys = append(keys, &model.SimKey{ICCID: strconv.Itoa(index)}) + } + result, _ := adapter.QuerySimBasicInfo(context.Background(), &model.SimBasicInfoInput{SimKeys: keys}) + if calls != 2 || len(result) != 51 { + t.Fatalf("calls=%d rows=%d", calls, len(result)) + } + for _, item := range result { + if item.Code != 0 || item.ResData.ActiveDate != "" || item.ResData.IMSI != "" { + t.Fatal("incorrect or fabricated basic fields") + } + } +} + +func TestOperationsAreSafe(t *testing.T) { + calls := 0 + adapter := testAdapter(t, func(writer http.ResponseWriter, request *http.Request) { + calls++ + if request.URL.Path != "/simlist/test-resume" && request.URL.Path != "/simlist/get-sim-switchoff-by-number" { + t.Error("wrong operation route") + } + fmt.Fprint(writer, `{"code":200,"data":[{"iccid":"A","issuccess":1},{"iccid":"B","issuccess":0}]}`) + }) + 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") + } + before := calls + input.ReqData.OperType = "1" + result, _ = adapter.ChangeSimStatus(context.Background(), input) + if calls != before || result[0].Code == 0 { + t.Fatal("unconfirmed resume path must not send any request") + } + adapter.resumePath = "/simlist/test-resume" + for _, operation := range []string{"0", "1"} { + input.ReqData.OperType = operation + result, _ = adapter.ChangeSimStatus(context.Background(), input) + if result[0].Code != 0 || result[1].Code == 0 { + t.Fatal("must inspect per-card success flags") + } + } + before = calls + input.ReqData.OperType = "invalid" + adapter.ChangeSimStatus(context.Background(), input) + if calls != before { + t.Fatal("invalid operation sent") + } +} + +func TestResponseFailuresAndNoRetry(t *testing.T) { + for _, body := range []string{`{}`, `{"code":401,"msg":"test-secret"}`, `{"code":200}`, `{"code":200,"data":null}`, `{"code":200,"data":[{"iccid":"A","state":2},{"iccid":"A","state":3}]}`} { + t.Run(body, func(t *testing.T) { + calls := 0 + adapter := testAdapter(t, func(writer http.ResponseWriter, request *http.Request) { calls++; fmt.Fprint(writer, body) }) + result, _ := adapter.QuerySimCardStatus(context.Background(), &model.SimCardStatusInput{SimKeys: []*model.SimKey{{ICCID: "A"}}}) + if calls != 1 || result[0].Code == 0 || strings.Contains(result[0].Message, "test-secret") { + t.Fatal("invalid response accepted, retried or leaked secret") + } + }) + } +} + +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 != "" { + t.Fatalf("invalid default configuration: %v", err) + } + cfg.Channel.AppSecret = "" + if _, err := New(cfg); err == nil { + t.Fatal("missing secret accepted") + } + cfg.Channel.AppSecret = "secret" + for _, raw := range []string{`{"api":`, `{"api":{"timeout":"0s"}}`, `{"api":{"resumePath":"/simlist/get-sim-switchoff-by-number"}}`} { + cfg.Template.TempConfig = raw + if _, err := New(cfg); err == nil { + t.Fatal("invalid config accepted") + } + } +} + +func TestOperationBatchLimitAndHTTPFailure(t *testing.T) { + calls := 0 + adapter := testAdapter(t, func(writer http.ResponseWriter, request *http.Request) { + calls++ + var payload struct { + Data string `json:"data"` + } + if err := json.NewDecoder(request.Body).Decode(&payload); err != nil { + t.Error(err) + } + var data struct { + Numbers []string `json:"numberArray"` + } + if err := json.Unmarshal([]byte(payload.Data), &data); err != nil { + t.Error(err) + } + if len(data.Numbers) > 20 { + t.Error("operation batch exceeds 20") + } + writer.WriteHeader(http.StatusTooManyRequests) + }) + keys := make([]*model.SimKey, 21) + for index := range keys { + keys[index] = &model.SimKey{ICCID: strconv.Itoa(index)} + } + output, err := adapter.ChangeSimStatus(context.Background(), &model.SimCardStatusChangeInput{SimKeys: keys, ReqData: model.SimCardStatusChangeInputItem{OperType: "0"}}) + if err != nil || calls != 2 || len(output) != 21 { + t.Fatalf("calls=%d len=%d err=%v", calls, len(output), err) + } + for _, item := range output { + if item.Code == 0 || !strings.Contains(item.Message, "429") { + t.Fatal("HTTP failure must not appear as successful operation") + } + } +} + +func TestAccountGateSharing(t *testing.T) { + first, err := NewClient("http://127.0.0.1", "same-account", "secret", time.Second) + if err != nil { + t.Fatal(err) + } + second, err := NewClient("http://127.0.0.1", "same-account", "secret", time.Second) + if err != nil { + t.Fatal(err) + } + if first.gate != second.gate { + t.Fatal("same account must share rate limiter") + } + first.gate.mu.Lock() + first.gate.next = time.Now().Add(time.Second) + first.gate.mu.Unlock() + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Millisecond) + defer cancel() + if err := second.gate.wait(ctx); err == nil { + t.Fatal("rate limit wait must honor context timeout") + } +} + +func TestCancelledAndUnsupportedDoNotCall(t *testing.T) { + calls := 0 + adapter := testAdapter(t, func(writer http.ResponseWriter, request *http.Request) { calls++ }) + ctx, cancel := context.WithCancel(context.Background()) + cancel() + adapter.QuerySimCardStatus(ctx, &model.SimCardStatusInput{SimKeys: []*model.SimKey{{ICCID: "A"}}}) + adapter.QuerySimDataUsage(context.Background(), &model.SimDataUsageInput{SimKeys: []*model.SimKey{nil, {IMSI: "only-imsi"}}}) + adapter.QuerySimImei(context.Background(), &model.SimImeiInput{SimKeys: []*model.SimKey{{ICCID: "A"}}}) + if calls != 0 { + t.Fatal("unexpected upstream request") + } +} diff --git a/internal/service/mingqian/package_flow.go b/internal/service/mingqian/package_flow.go new file mode 100644 index 0000000..a627105 --- /dev/null +++ b/internal/service/mingqian/package_flow.go @@ -0,0 +1,82 @@ +package mingqian + +import ( + "context" + "math" + "strings" + + "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] + +func (adapter *Adapter) QueryPackageFlow(ctx context.Context, input *model.SimDataUsageInput) (PackageFlowOutput, error) { + if input == nil { + return nil, gerror.New("mingqian: input required") + } + return query(ctx, adapter, input.SimKeys, packageUsagePath, 50, func(item card) (model.SimPackageFlowItem, error) { + if item.PackageTotal == nil || item.PackageUsage == nil || *item.PackageTotal < 0 || *item.PackageUsage < 0 { + return model.SimPackageFlowItem{}, gerror.New("mingqian: package total or cumulative usage missing or unknown") + } + var ( + total = float64(*item.PackageTotal) + used = float64(*item.PackageUsage) + remaining = math.Max(total-used, 0) + ) + return model.SimPackageFlowItem{TotalFlow: total, DataUsage: used, RemainingFlow: remaining}, nil + }), nil +} + +func QueryChannelsPackageFlow(ctx context.Context, input *model.SimDataUsageInput, lookup func(string) (*model.SimCardChannelCache, bool)) (PackageFlowOutput, error) { + if input == nil || lookup == nil { + return nil, gerror.New("mingqian: input and channel lookup required") + } + var ( + output = make(PackageFlowOutput, len(input.SimKeys)) + groups = make(map[string][]int) + ) + for index, key := range input.SimKeys { + output[index] = &model.SimBaseRes[model.SimPackageFlowItem]{SimKey: key, Code: consts.ApiStatusError, Message: "mingqian: explicit chanKey and ICCID or MSISDN required"} + if key == nil || strings.TrimSpace(key.ChanKey) == "" || identifier(key) == "" { + continue + } + channelKey := strings.TrimSpace(key.ChanKey) + groups[channelKey] = append(groups[channelKey], index) + } + for channelKey, indices := range groups { + var ( + adapter *Adapter + message string + ) + cached, ok := lookup(channelKey) + if !ok || cached == nil || cached.Instance == nil { + message = "mingqian: channel not instantiated" + } else { + adapter, ok = cached.Instance.(*Adapter) + if !ok || adapter == nil { + message = "mingqian: package flow endpoint only supports sim_mingqian_v3 channels" + } + } + if message != "" { + for _, index := range indices { + output[index].Message = message + } + continue + } + keys := make([]*model.SimKey, len(indices)) + for position, index := range indices { + keys[position] = input.SimKeys[index] + } + results, err := adapter.QueryPackageFlow(ctx, &model.SimDataUsageInput{SimKeys: keys}) + for position, index := range indices { + if err != nil { + output[index].Message = err.Error() + } else { + output[index] = results[position] + } + } + } + return output, nil +} diff --git a/internal/service/mingqian/unsupported.go b/internal/service/mingqian/unsupported.go new file mode 100644 index 0000000..5943e73 --- /dev/null +++ b/internal/service/mingqian/unsupported.go @@ -0,0 +1,43 @@ +package mingqian + +import ( + "context" + "git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/model" +) + +func (adapter *Adapter) QuerySimSession(ctx context.Context, input *model.SimSessionInput) (model.SimSessionOutput, error) { + return unsupported[model.SimSessionItem](input.SimKeys, "current API documentation provides no real-time session, online status, APN or IP query; card lifecycle state is not online status"), nil +} +func (adapter *Adapter) QuerySimStopReason(ctx context.Context, input *model.SimStopReasonInput) (model.SimStopReasonOutput, error) { + return unsupported[model.SimStopReasonItem](input.SimKeys, "stop reason query unsupported"), nil +} +func (adapter *Adapter) QuerySimFrozenState(ctx context.Context, input *model.SimFrozenStateInput) (model.SimFrozenStateOutput, error) { + return unsupported[model.SimFrozenStateItem](input.SimKeys, "frozen state query unsupported"), nil +} +func (adapter *Adapter) QuerySimCardBindStatus(ctx context.Context, input *model.SimCardBindStatusInput) (model.SimCardBindStatusOutput, error) { + return unsupported[model.SimCardBindStatusItem](input.SimKeys, "binding status not implied by IMEI; query unsupported"), nil +} +func (adapter *Adapter) QuerySimDeviceStatus(ctx context.Context, input *model.SimDeviceStatusInput) (model.SimDeviceStatusOutput, error) { + return unsupported[model.SimDeviceStatusItem](input.SimKeys, "device status query unsupported"), nil +} +func (adapter *Adapter) QuerySimRegionLimitStatus(ctx context.Context, input *model.SimRegionLimitStatusInput) (model.SimRegionLimitStatusOutput, error) { + return unsupported[model.SimRegionLimitStatusItem](input.SimKeys, "region limit query unsupported"), nil +} +func (adapter *Adapter) QuerySimImei(ctx context.Context, input *model.SimImeiInput) (model.SimImeiOutput, error) { + return unsupported[model.SimImeiItem](input.SimKeys, "per-card IMEI endpoint unavailable; account inventory sync not implemented"), nil +} +func (adapter *Adapter) QuerySimDataDiagnosis(ctx context.Context, input *model.SimDataDiagnosisInput) (model.SimDataDiagnosisOutput, error) { + return unsupported[any](input.SimKeys, "data diagnosis unsupported"), nil +} +func (adapter *Adapter) QuerySimDataUsageMonthly(ctx context.Context, input *model.SimDataUsageMonthlyInput) (model.SimDataUsageMonthlyOutput, error) { + return unsupported[model.SimDataUsageMonthlyOutputItem](input.SimKeys, "historical monthly usage unsupported"), nil +} +func (adapter *Adapter) QuerySimDataUsageDaily(ctx context.Context, input *model.SimDataUsageDailyInput) (model.SimDataUsageDailyOutput, error) { + return unsupported[model.SimDataUsageDailyOutputItem](input.SimKeys, "historical daily usage unsupported"), nil +} +func (adapter *Adapter) QuerySimApnInfo(ctx context.Context, input *model.SimApnInfoInput) (model.SimApnInfoOutput, error) { + return unsupported[model.SimApnInfoItemList](input.SimKeys, "APN query unsupported"), nil +} +func (adapter *Adapter) ChangeSimApnFunction(ctx context.Context, input *model.SimCardOperateChangeApnInput) (model.SimCardOperateChangeApnOutput, error) { + return unsupported[model.SimCardOperateChangeApnItem](input.SimKeys, "APN changes unsupported"), nil +} diff --git a/internal/service/upstreamapi.go b/internal/service/upstreamapi.go new file mode 100644 index 0000000..90cd0c4 --- /dev/null +++ b/internal/service/upstreamapi.go @@ -0,0 +1,74 @@ +// ================================================================================ +// Code generated and maintained by GoFrame CLI tool. DO NOT EDIT. +// You can delete these comments if you wish manually maintain this interface file. +// ================================================================================ + +package service + +import ( + "context" + "git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/model" +) + +type ( + IUpstreamAPI interface { + Start(ctx context.Context) error + Stop(ctx context.Context) error + RefreshChannel(ctx context.Context, chanKey string) error + RemoveChannel(ctx context.Context, chanKey string) error + TestChannel(ctx context.Context, input *model.ChannelTestInput) (*model.ChannelTestOutput, error) + RegisterChan(chanKey string, version string, channel *model.SimCardChannelCache) + RegisterTemp(tempKey string, channel model.SimCardTempI) + GetTemp(tempKey string) (model.SimCardTempI, bool) + RegisterModel(modelKey string, factory model.SimCardModelFactory) + GetModel(modelKey string) (model.SimCardModelFactory, bool) + GetChan(chanKey string) (*model.SimCardChannelCache, bool) + // 物联网卡基本信息查询 + QuerySimBasicInfo(ctx context.Context, input *model.SimBasicInfoInput) (model.SimBasicInfoOutput, error) + // 物联网卡在线信息实时查询 + QuerySimSession(ctx context.Context, input *model.SimSessionInput) (model.SimSessionOutput, error) + // 物联网卡卡状态查询 + QuerySimCardStatus(ctx context.Context, input *model.SimCardStatusInput) (output model.SimCardStatusOutput, err error) + QuerySimStopReason(ctx context.Context, input *model.SimStopReasonInput) (output model.SimStopReasonOutput, err error) + QuerySimFrozenState(ctx context.Context, input *model.SimFrozenStateInput) (output model.SimFrozenStateOutput, err error) + QuerySimCardStatusBatch(ctx context.Context, input *model.SimCardStatusInput) (output model.SimCardStatusOutput, err error) + // 物联网卡机卡绑定状态查询 + QuerySimCardBindStatus(ctx context.Context, input *model.SimCardBindStatusInput) (output model.SimCardBindStatusOutput, err error) + // 物联网卡设备状态查询 + QuerySimDeviceStatus(ctx context.Context, input *model.SimDeviceStatusInput) (output model.SimDeviceStatusOutput, err error) + // 物联网卡地域限制状态查询 + QuerySimRegionLimitStatus(ctx context.Context, input *model.SimRegionLimitStatusInput) (output model.SimRegionLimitStatusOutput, err error) + // 物联网卡IMEI查询 + QuerySimImei(ctx context.Context, input *model.SimImeiInput) (output model.SimImeiOutput, err error) + // 单卡本月流量累计使用量查询 + QuerySimDataUsage(ctx context.Context, input *model.SimDataUsageInput) (output model.SimDataUsageOutput, err error) + // 物联卡达量限速档位查询 + QuerySimDataDiagnosis(ctx context.Context, input *model.SimDataDiagnosisInput) (output model.SimDataDiagnosisOutput, err error) + // 物联卡单月 GPRS流量使用量批量查询 + QuerySimDataUsageMonthly(ctx context.Context, input *model.SimDataUsageMonthlyInput) (output model.SimDataUsageMonthlyOutput, err error) + QuerySimDataUsageMonthlyBatch(ctx context.Context, input *model.SimDataUsageMonthlyInput) (output model.SimDataUsageMonthlyOutput, err error) + // 物联网卡数据流量查询 日查询 + QuerySimDataUsageDaily(ctx context.Context, input *model.SimDataUsageDailyInput) (output model.SimDataUsageDailyOutput, err error) + QuerySimDataUsageDailyBatch(ctx context.Context, input *model.SimDataUsageDailyInput) (output model.SimDataUsageDailyOutput, err error) + // 物联网卡已开通APN信息查询 + QuerySimApnInfo(ctx context.Context, input *model.SimApnInfoInput) (output model.SimApnInfoOutput, err error) + // 物联网卡状态变更 + ChangeSimStatus(ctx context.Context, input *model.SimCardStatusChangeInput) (output model.SimCardStatusChangeOutput, err error) + ChangeSimCardOperateApn(ctx context.Context, input *model.SimCardOperateChangeApnInput) (output model.SimCardOperateChangeApnOutput, err error) + } +) + +var ( + localUpstreamAPI IUpstreamAPI +) + +func UpstreamAPI() IUpstreamAPI { + if localUpstreamAPI == nil { + panic("implement not found for interface IUpstreamAPI, forgot register?") + } + return localUpstreamAPI +} + +func RegisterUpstreamAPI(i IUpstreamAPI) { + localUpstreamAPI = i +} diff --git a/internal/service/upstreamimpl.go b/internal/service/upstreamimpl.go new file mode 100644 index 0000000..302f553 --- /dev/null +++ b/internal/service/upstreamimpl.go @@ -0,0 +1,112 @@ +// ================================================================================ +// Code generated and maintained by GoFrame CLI tool. DO NOT EDIT. +// You can delete these comments if you wish manually maintain this interface file. +// ================================================================================ + +package service + +import ( + "context" + "git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/model" +) + +type ( + ISimDX interface { + NewTemp(ctx context.Context, channelConfig *model.SimChannelConfigItemCache) model.SimCardChannelI + QuerySimSession(ctx context.Context, input *model.SimSessionInput) (model.SimSessionOutput, error) + QuerySimCardStatus(ctx context.Context, input *model.SimCardStatusInput) (model.SimCardStatusOutput, error) + QuerySimStopReason(ctx context.Context, input *model.SimStopReasonInput) (model.SimStopReasonOutput, error) + QuerySimFrozenState(ctx context.Context, input *model.SimFrozenStateInput) (model.SimFrozenStateOutput, error) + QuerySimImei(ctx context.Context, input *model.SimImeiInput) (model.SimImeiOutput, error) + QuerySimDataUsage(ctx context.Context, input *model.SimDataUsageInput) (model.SimDataUsageOutput, error) + QuerySimDataDiagnosis(ctx context.Context, input *model.SimDataDiagnosisInput) (model.SimDataDiagnosisOutput, error) + QuerySimDataUsageMonthly(ctx context.Context, input *model.SimDataUsageMonthlyInput) (model.SimDataUsageMonthlyOutput, error) + QuerySimDataUsageDaily(ctx context.Context, input *model.SimDataUsageDailyInput) (model.SimDataUsageDailyOutput, error) + QuerySimBasicInfo(ctx context.Context, input *model.SimBasicInfoInput) (model.SimBasicInfoOutput, error) + QuerySimApnInfo(ctx context.Context, input *model.SimApnInfoInput) (model.SimApnInfoOutput, error) + ChangeSimStatus(ctx context.Context, input *model.SimCardStatusChangeInput) (model.SimCardStatusChangeOutput, error) + ChangeSimApnFunction(ctx context.Context, input *model.SimCardOperateChangeApnInput) (model.SimCardOperateChangeApnOutput, error) + QuerySimCardBindStatus(ctx context.Context, input *model.SimCardBindStatusInput) (model.SimCardBindStatusOutput, error) + QuerySimDeviceStatus(ctx context.Context, input *model.SimDeviceStatusInput) (model.SimDeviceStatusOutput, error) + QuerySimRegionLimitStatus(ctx context.Context, input *model.SimRegionLimitStatusInput) (model.SimRegionLimitStatusOutput, error) + } + ISimYD interface { + NewTemp(ctx context.Context, channelConfig *model.SimChannelConfigItemCache) model.SimCardChannelI + QuerySimBasicInfo(ctx context.Context, input *model.SimBasicInfoInput) (model.SimBasicInfoOutput, error) + QuerySimSession(ctx context.Context, input *model.SimSessionInput) (model.SimSessionOutput, error) + QuerySimCardStatus(ctx context.Context, input *model.SimCardStatusInput) (model.SimCardStatusOutput, error) + QuerySimStopReason(ctx context.Context, input *model.SimStopReasonInput) (model.SimStopReasonOutput, error) + QuerySimFrozenState(ctx context.Context, input *model.SimFrozenStateInput) (model.SimFrozenStateOutput, error) + QuerySimImei(ctx context.Context, input *model.SimImeiInput) (model.SimImeiOutput, error) + QuerySimDataUsageInPool(ctx context.Context, input *model.SimDataUsageInPoolInput) (model.SimDataUsageInPoolOutput, error) + QuerySimDataUsage(ctx context.Context, input *model.SimDataUsageInput) (model.SimDataUsageOutput, error) + QuerySimDataDiagnosis(ctx context.Context, input *model.SimDataDiagnosisInput) (model.SimDataDiagnosisOutput, error) + QuerySimDataUsageMonthly(ctx context.Context, input *model.SimDataUsageMonthlyInput) (model.SimDataUsageMonthlyOutput, error) + QuerySimDataUsageDaily(ctx context.Context, input *model.SimDataUsageDailyInput) (model.SimDataUsageDailyOutput, error) + QuerySimApnInfo(ctx context.Context, input *model.SimApnInfoInput) (model.SimApnInfoOutput, error) + ChangeSimStatus(ctx context.Context, input *model.SimCardStatusChangeInput) (model.SimCardStatusChangeOutput, error) + ChangeSimApnFunction(ctx context.Context, input *model.SimCardOperateChangeApnInput) (model.SimCardOperateChangeApnOutput, error) + QuerySimCardBindStatus(ctx context.Context, input *model.SimCardBindStatusInput) (model.SimCardBindStatusOutput, error) + QuerySimDeviceStatus(ctx context.Context, input *model.SimDeviceStatusInput) (model.SimDeviceStatusOutput, error) + QuerySimRegionLimitStatus(ctx context.Context, input *model.SimRegionLimitStatusInput) (model.SimRegionLimitStatusOutput, error) + } + ISimYDMock interface { + NewTemp(ctx context.Context, channelConfig *model.SimChannelConfigItemCache) model.SimCardChannelI + QuerySimBasicInfo(ctx context.Context, input *model.SimBasicInfoInput) (model.SimBasicInfoOutput, error) + QuerySimSession(ctx context.Context, input *model.SimSessionInput) (model.SimSessionOutput, error) + QuerySimCardStatus(ctx context.Context, input *model.SimCardStatusInput) (model.SimCardStatusOutput, error) + QuerySimStopReason(ctx context.Context, input *model.SimStopReasonInput) (model.SimStopReasonOutput, error) + QuerySimFrozenState(ctx context.Context, input *model.SimFrozenStateInput) (model.SimFrozenStateOutput, error) + QuerySimImei(ctx context.Context, input *model.SimImeiInput) (model.SimImeiOutput, error) + QuerySimDataUsageInPool(ctx context.Context, input *model.SimDataUsageInPoolInput) (model.SimDataUsageInPoolOutput, error) + QuerySimDataUsage(ctx context.Context, input *model.SimDataUsageInput) (model.SimDataUsageOutput, error) + QuerySimDataDiagnosis(ctx context.Context, input *model.SimDataDiagnosisInput) (model.SimDataDiagnosisOutput, error) + QuerySimDataUsageMonthly(ctx context.Context, input *model.SimDataUsageMonthlyInput) (model.SimDataUsageMonthlyOutput, error) + QuerySimDataUsageDaily(ctx context.Context, input *model.SimDataUsageDailyInput) (model.SimDataUsageDailyOutput, error) + QuerySimApnInfo(ctx context.Context, input *model.SimApnInfoInput) (model.SimApnInfoOutput, error) + ChangeSimStatus(ctx context.Context, input *model.SimCardStatusChangeInput) (model.SimCardStatusChangeOutput, error) + ChangeSimApnFunction(ctx context.Context, input *model.SimCardOperateChangeApnInput) (model.SimCardOperateChangeApnOutput, error) + QuerySimCardBindStatus(ctx context.Context, input *model.SimCardBindStatusInput) (model.SimCardBindStatusOutput, error) + QuerySimDeviceStatus(ctx context.Context, input *model.SimDeviceStatusInput) (model.SimDeviceStatusOutput, error) + QuerySimRegionLimitStatus(ctx context.Context, input *model.SimRegionLimitStatusInput) (model.SimRegionLimitStatusOutput, error) + } +) + +var ( + localSimDX ISimDX + localSimYD ISimYD + localSimYDMock ISimYDMock +) + +func SimDX() ISimDX { + if localSimDX == nil { + panic("implement not found for interface ISimDX, forgot register?") + } + return localSimDX +} + +func RegisterSimDX(i ISimDX) { + localSimDX = i +} + +func SimYD() ISimYD { + if localSimYD == nil { + panic("implement not found for interface ISimYD, forgot register?") + } + return localSimYD +} + +func RegisterSimYD(i ISimYD) { + localSimYD = i +} + +func SimYDMock() ISimYDMock { + if localSimYDMock == nil { + panic("implement not found for interface ISimYDMock, forgot register?") + } + return localSimYDMock +} + +func RegisterSimYDMock(i ISimYDMock) { + localSimYDMock = i +} diff --git a/internal/token/token.go b/internal/token/token.go new file mode 100644 index 0000000..d3c4bc7 --- /dev/null +++ b/internal/token/token.go @@ -0,0 +1,338 @@ +// Package token provides the process-local shared token core used by OneLink +// clients. It deliberately does not own channel configuration or generated +// database types; callers seed it from their channel cache. +package token + +import ( + "context" + "errors" + "fmt" + "strings" + "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" +) + +var ( + ErrEmptyToken = errors.New("token fetcher returned an empty token") + ErrTokenUnavailable = errors.New("token unavailable") +) + +// OneLinkToken is the value cached for a channel. Times are in the local +// process clock and are persisted as gtime values by the manager. +type OneLinkToken struct { + Value string + DeviceValue string + CreatedAt time.Time + ExpiresAt time.Time +} + +func (t OneLinkToken) Valid(now time.Time) bool { + return strings.TrimSpace(t.Value) != "" && !t.ExpiresAt.IsZero() && now.Before(t.ExpiresAt) +} + +// TokenFetcher obtains a token from the upstream provider. FetchToken must +// honor ctx cancellation. +type TokenFetcher interface { + FetchToken(ctx context.Context, chanKey string) (OneLinkToken, error) +} + +// TokenProvider is the consumer-facing provider interface. +type TokenProvider interface { + GetToken(ctx context.Context, chanKey string) (string, error) +} + +// TokenPersister is injectable for tests and alternate storage. The default +// manager uses service.ChannelUpdate().UpdateAccessToken. +type TokenPersister interface { + UpdateAccessToken(ctx context.Context, input *model.UpdateAccessTokenInput) error +} + +type TokenFetcherFunc func(context.Context, string) (OneLinkToken, error) + +func (f TokenFetcherFunc) FetchToken(ctx context.Context, chanKey string) (OneLinkToken, error) { + return f(ctx, chanKey) +} + +type TokenPersisterFunc func(context.Context, *model.UpdateAccessTokenInput) error + +func (f TokenPersisterFunc) UpdateAccessToken(ctx context.Context, input *model.UpdateAccessTokenInput) error { + return f(ctx, input) +} + +type Option func(*manager) + +func WithRefreshAhead(d time.Duration) Option { + return func(m *manager) { + if d >= 0 { + m.refreshAhead = d + } + } +} + +// WithUntilInvalidated allows tokens without an expiry timestamp. They remain +// valid until the consumer explicitly invalidates the rejected value. +func WithUntilInvalidated() Option { + return func(m *manager) { + m.untilInvalidated = true + } +} + +func WithClock(now func() time.Time) Option { + return func(m *manager) { + if now != nil { + m.now = now + } + } +} +func WithPersister(p TokenPersister) Option { + return func(m *manager) { + if p != nil { + m.persister = p + } + } +} + +type tokenEntry struct { + mu sync.RWMutex + token OneLinkToken + version uint64 +} + +// TokenManager is the shared-token contract consumed by channel clients. +// Implementations are safe for concurrent use. +type TokenManager interface { + TokenProvider + Get(context.Context, string) (OneLinkToken, error) + Seed(string, OneLinkToken) + SeedFromChannel(*entity.TSimChannels) + SeedFromConfig(*model.SimChannelConfigItemCache) + InvalidateIfCurrent(string, string) bool + Current(string) (OneLinkToken, bool) +} + +// TokenManager caches one token per channel and coalesces refreshes per +// channel. A failed refresh never overwrites a cached token. +type manager struct { + fetcher TokenFetcher + persister TokenPersister + refreshAhead time.Duration + untilInvalidated bool + now func() time.Time +} + +// Runtime and request-scoped test instances share token state by chanKey. +// This keeps their refreshes in one flight without coupling either instance +// to the runtime channel pool. +var ( + sharedTokenEntries sync.Map // map[string]*tokenEntry + sharedTokenFlights singleflight.Group +) + +func NewTokenManager(fetcher TokenFetcher, options ...Option) TokenManager { + m := &manager{fetcher: fetcher, now: time.Now} + for _, option := range options { + if option != nil { + option(m) + } + } + return m +} + +func (m *manager) entry(chanKey string) *tokenEntry { + e, _ := sharedTokenEntries.LoadOrStore(chanKey, &tokenEntry{}) + return e.(*tokenEntry) +} + +func (m *manager) valid(t OneLinkToken, now time.Time) bool { + if strings.TrimSpace(t.Value) == "" { + return false + } + if t.ExpiresAt.IsZero() { + return m.untilInvalidated + } + return now.Before(t.ExpiresAt) +} + +func (m *manager) reusable(t OneLinkToken, now time.Time) bool { + if !m.valid(t, now) { + return false + } + return t.ExpiresAt.IsZero() || m.refreshAhead <= 0 || now.Before(t.ExpiresAt.Add(-m.refreshAhead)) +} + +// Seed initializes or replaces the in-process value. It is safe to call when +// channel configuration is reloaded. +func (m *manager) Seed(chanKey string, value OneLinkToken) { + e := m.entry(chanKey) + e.mu.Lock() + e.token = value + e.version++ + e.mu.Unlock() +} + +// SeedFromChannel copies access_token/access_token_expire from channel config. +func (m *manager) SeedFromChannel(channel *entity.TSimChannels) { + if channel == nil { + return + } + var created, expires time.Time + if channel.AccessTokenCreated != nil { + created = channel.AccessTokenCreated.Time + } + if channel.AccessTokenExpire != nil { + expires = channel.AccessTokenExpire.Time + } + incoming := OneLinkToken{Value: channel.AccessToken, DeviceValue: channel.RefreshToken, CreatedAt: created, ExpiresAt: expires} + e := m.entry(channel.ChanKey) + e.mu.Lock() + current := e.token + if strings.TrimSpace(current.Value) == "" || incoming.CreatedAt.After(current.CreatedAt) || + (incoming.Value == current.Value && incoming.DeviceValue != current.DeviceValue) { + e.token = incoming + e.version++ + } + e.mu.Unlock() +} + +// SeedFromConfig is a convenience for the channel service's cached config. +func (m *manager) SeedFromConfig(config *model.SimChannelConfigItemCache) { + if config == nil { + return + } + m.SeedFromChannel(config.Channel) +} + +// GetToken returns a valid token, refreshing when it is expired or within the +// configured refresh-ahead window. A still-valid old token is returned when +// refresh or persistence fails. +func (m *manager) GetToken(ctx context.Context, chanKey string) (string, error) { + t, err := m.Get(ctx, chanKey) + return t.Value, err +} + +func (m *manager) Get(ctx context.Context, chanKey string) (OneLinkToken, error) { + if err := ctx.Err(); err != nil { + return OneLinkToken{}, err + } + if strings.TrimSpace(chanKey) == "" { + return OneLinkToken{}, fmt.Errorf("chanKey is required") + } + e := m.entry(chanKey) + now := m.now() + e.mu.RLock() + current := e.token + e.mu.RUnlock() + if m.reusable(current, now) { + return current, nil + } + + resultCh := sharedTokenFlights.DoChan(chanKey, func() (any, error) { + // Re-check after joining the per-channel flight. + e.mu.RLock() + current := e.token + e.mu.RUnlock() + now := m.now() + if m.reusable(current, now) { + return current, nil + } + if m.fetcher == nil { + return nil, ErrTokenUnavailable + } + fresh, err := m.fetcher.FetchToken(ctx, chanKey) + if err != nil { + return nil, err + } + fresh.Value = strings.TrimSpace(fresh.Value) + if fresh.Value == "" { + return nil, ErrEmptyToken + } + if fresh.ExpiresAt.IsZero() && !m.untilInvalidated { + return nil, fmt.Errorf("token expiry must be in the future") + } + if !fresh.ExpiresAt.IsZero() && !fresh.ExpiresAt.After(now) { + return nil, fmt.Errorf("token expiry must be in the future") + } + if fresh.CreatedAt.IsZero() { + fresh.CreatedAt = now + } + if err := m.persist(ctx, chanKey, fresh); err != nil { + return nil, err + } + g.Log().Infof(ctx, "shared token refreshed and persisted, chanKey: %s", chanKey) + e.mu.Lock() + e.token = fresh + e.version++ + e.mu.Unlock() + return fresh, nil + }) + select { + case <-ctx.Done(): + return OneLinkToken{}, ctx.Err() + case result := <-resultCh: + if result.Err == nil { + return result.Val.(OneLinkToken), nil + } + now = m.now() + e.mu.RLock() + current = e.token + e.mu.RUnlock() + if m.valid(current, now) { + return current, nil + } + return OneLinkToken{}, result.Err + } +} + +func (m *manager) persist(ctx context.Context, chanKey string, t OneLinkToken) error { + p := m.persister + if p == nil { + p = channelUpdatePersister{} + } + var expiresAt *gtime.Time + if !t.ExpiresAt.IsZero() { + expiresAt = gtime.New(t.ExpiresAt) + } + return p.UpdateAccessToken(ctx, &model.UpdateAccessTokenInput{ + ChanKey: chanKey, AccessToken: t.Value, RefreshToken: t.DeviceValue, + AccessTokenCreated: gtime.New(t.CreatedAt), AccessTokenExpire: expiresAt, RefreshTokenExpire: expiresAt, + }) +} + +type channelUpdatePersister struct{} + +func (channelUpdatePersister) UpdateAccessToken(ctx context.Context, input *model.UpdateAccessTokenInput) error { + return service.ChannelUpdate().UpdateAccessToken(ctx, input) +} + +// InvalidateIfCurrent atomically removes value only when it still matches. +// It returns false when another request has already installed a new token. +func (m *manager) InvalidateIfCurrent(chanKey, value string) bool { + e := m.entry(chanKey) + e.mu.Lock() + defer e.mu.Unlock() + if e.token.Value != value { + return false + } + e.token = OneLinkToken{} + e.version++ + return true +} + +// Current returns the cached value without triggering a refresh. +func (m *manager) Current(chanKey string) (OneLinkToken, bool) { + e, ok := sharedTokenEntries.Load(chanKey) + if !ok { + return OneLinkToken{}, false + } + e.(*tokenEntry).mu.RLock() + t := e.(*tokenEntry).token + e.(*tokenEntry).mu.RUnlock() + return t, t.Value != "" +} diff --git a/internal/token/token_test.go b/internal/token/token_test.go new file mode 100644 index 0000000..e6ace5c --- /dev/null +++ b/internal/token/token_test.go @@ -0,0 +1,64 @@ +package token + +import ( + "context" + "sync" + "sync/atomic" + "testing" + "time" + + "git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/model" +) + +func TestTokenManagersShareRefreshFlightByChannel(t *testing.T) { + ctx := context.Background() + chanKey := "shared-flight-" + time.Now().Format("150405.000000000") + var fetchCalls atomic.Int32 + var persistCalls atomic.Int32 + start := make(chan struct{}) + release := make(chan struct{}) + fetcher := TokenFetcherFunc(func(context.Context, string) (OneLinkToken, error) { + fetchCalls.Add(1) + close(start) + <-release + return OneLinkToken{Value: "fresh-token", CreatedAt: time.Now(), ExpiresAt: time.Now().Add(time.Hour)}, nil + }) + persister := TokenPersisterFunc(func(context.Context, *model.UpdateAccessTokenInput) error { + persistCalls.Add(1) + return nil + }) + first := NewTokenManager(fetcher, WithPersister(persister)) + second := NewTokenManager(fetcher, WithPersister(persister)) + + results := make(chan OneLinkToken, 2) + errors := make(chan error, 2) + var wg sync.WaitGroup + for _, manager := range []TokenManager{first, second} { + wg.Add(1) + go func(manager TokenManager) { + defer wg.Done() + token, err := manager.Get(ctx, chanKey) + results <- token + errors <- err + }(manager) + } + <-start + close(release) + wg.Wait() + close(results) + close(errors) + + for err := range errors { + if err != nil { + t.Fatal(err) + } + } + for token := range results { + if token.Value != "fresh-token" { + t.Fatalf("token = %+v", token) + } + } + if fetchCalls.Load() != 1 || persistCalls.Load() != 1 { + t.Fatalf("fetch calls=%d persist calls=%d, want one each", fetchCalls.Load(), persistCalls.Load()) + } +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..d211e3d --- /dev/null +++ b/main.go @@ -0,0 +1,42 @@ +package main + +import ( + _ "git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/packed" + "os" + + _ "git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/pub/xjpublogic" + _ "github.com/gogf/gf/contrib/drivers/mysql/v2" + _ "github.com/gogf/gf/contrib/drivers/sqlite/v2" + _ "github.com/gogf/gf/contrib/nosql/redis/v2" + + _ "git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/logic" + + "github.com/gogf/gf/v2/frame/g" + "github.com/gogf/gf/v2/os/gctx" + + "git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/cmd" + + "git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/pub/pkg/initconsul" + "git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/pub/pkg/version" + "git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/pub/xjpubservice" +) + +var ( + BuildVersion = "0.0" + BuildTime = "" + CommitID = "" +) + +func main() { + ctx := gctx.GetInitCtx() + // 初始化consul + err := initconsul.InitConsul(ctx) + if err != nil { + g.Log().Fatalf(ctx, "consul初始化失败: %+v", err) + os.Exit(1) + } + // 初始化系统 + version.ShowLogo(ctx, BuildVersion, BuildTime, CommitID) + xjpubservice.Info().SetInfoBuild(BuildVersion, BuildTime, CommitID) + cmd.Main.Run(gctx.GetInitCtx()) +} diff --git a/manifest/docker/Dockerfile b/manifest/docker/Dockerfile new file mode 100644 index 0000000..d3abe8f --- /dev/null +++ b/manifest/docker/Dockerfile @@ -0,0 +1,16 @@ +FROM loads/alpine:3.8 + +############################################################################### +# INSTALLATION +############################################################################### + +ENV WORKDIR /app +ADD resource $WORKDIR/ +ADD ./temp/linux_amd64/main $WORKDIR/main +RUN chmod +x $WORKDIR/main + +############################################################################### +# START +############################################################################### +WORKDIR $WORKDIR +CMD ./main diff --git a/manifest/docker/docker.sh b/manifest/docker/docker.sh new file mode 100644 index 0000000..ff393f9 --- /dev/null +++ b/manifest/docker/docker.sh @@ -0,0 +1,8 @@ +#!/bin/bash + +# This shell is executed before docker build. + + + + +