chore: 初始开源发布
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
This commit is contained in:
@@ -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
|
||||||
+28
@@ -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/
|
||||||
@@ -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.
|
||||||
@@ -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
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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]
|
||||||
@@ -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
|
||||||
@@ -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"]
|
||||||
@@ -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.
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"api": {
|
||||||
|
"baseUrl": "http://iotapi.bjmqtx.com/openapi/wlw/v3/cool",
|
||||||
|
"timeout": "10s",
|
||||||
|
"enableOperations": false,
|
||||||
|
"resumePath": ""
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"token": {
|
||||||
|
"provider": "henan_lelian",
|
||||||
|
"baseUrl": "http://api.wwlelianiot.com",
|
||||||
|
"path": "/api/v2/getShareToken",
|
||||||
|
"params": {
|
||||||
|
"loginPath": "/api/v2/loginVerify"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"token": {
|
||||||
|
"provider": "mobile_public",
|
||||||
|
"baseUrl": "https://REPLACE_WITH_TOKEN_HOST",
|
||||||
|
"path": "/api/get/yd/api/msg"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"token": {
|
||||||
|
"provider": "tengyu",
|
||||||
|
"baseUrl": "http://www.sdtywl.cn/",
|
||||||
|
"path": "/prod-api/open/api/oneLinkToken"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"token": {
|
||||||
|
"provider": "xuzhou_mobile",
|
||||||
|
"baseUrl": "http://iot.yuanjianiot.com",
|
||||||
|
"path": "/admin/api/get/token",
|
||||||
|
"params": {
|
||||||
|
"carrierType": 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"api": {
|
||||||
|
"baseUrl": "https://cmp-api.ctwing.cn:20164",
|
||||||
|
"enableOperations": false
|
||||||
|
},
|
||||||
|
"http": {
|
||||||
|
"timeout": "10s"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.
|
||||||
@@ -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,并在同一通道的并发请求间合并刷新动作。
|
||||||
@@ -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 直接执行。
|
||||||
@@ -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、频控、运营商订单完成状态及多副本热刷新仍需部署后联调。没有修改数据库、切换通道或调用真实停复机/断网。
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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 全链路验收。
|
||||||
@@ -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
|
||||||
|
)
|
||||||
@@ -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=
|
||||||
@@ -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;
|
||||||
@@ -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:
|
||||||
@@ -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,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
)
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
// =================================================================================
|
||||||
|
// This is auto-generated by GoFrame CLI tool only once. Fill this file as you wish.
|
||||||
|
// =================================================================================
|
||||||
|
|
||||||
|
package cardquery
|
||||||
@@ -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{}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
)
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}}
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 ""
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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=<pwdKey>&c_no=<channelNo>
|
||||||
|
// → {"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)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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, "认证失败")
|
||||||
|
}
|
||||||
@@ -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=<id>&apikey=<key>×=<unix_seconds>")).
|
||||||
|
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=<id>&apikey=<key>×=<unix_seconds>
|
||||||
|
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
|
||||||
|
}
|
||||||
@@ -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, "")
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||||
|
}
|
||||||
@@ -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: `<xml/>`, 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, `<?xml version="1.0" encoding="UTF-8"?><SvcCont><Response><RspType>0</RspType><RspCode>0000</RspCode><RspDesc>成功接收消息</RspDesc></Response><GROUP_TRANSACTIONID>order-1</GROUP_TRANSACTIONID></SvcCont>`)
|
||||||
|
}))
|
||||||
|
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: `<?xml version="1.0"?><SvcCont><Response><RspCode>1000</RspCode><RspDesc>rejected</RspDesc></Response></SvcCont>`, wantRequests: 1},
|
||||||
|
{name: "missing result code", operType: "0", msisdn: "138****8000", body: `<?xml version="1.0"?><SvcCont><Response><RspDesc>ok</RspDesc></Response><GROUP_TRANSACTIONID>order</GROUP_TRANSACTIONID></SvcCont>`, wantRequests: 1},
|
||||||
|
{name: "missing order number", operType: "0", msisdn: "138****8000", body: `<?xml version="1.0"?><SvcCont><Response><RspCode>0000</RspCode><RspDesc>ok</RspDesc></Response></SvcCont>`, 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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user