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:
MiFaZhan
2026-10-22 15:42:29 +08:00
commit 96b1edff45
191 changed files with 30805 additions and 0 deletions
+13
View File
@@ -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
View File
@@ -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/
+201
View File
@@ -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.
+7
View File
@@ -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
+33
View File
@@ -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)
}
+13
View File
@@ -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]
+164
View File
@@ -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
+34
View File
@@ -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"]
+25
View File
@@ -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.
+1
View File
@@ -0,0 +1 @@
{}
+8
View File
@@ -0,0 +1,8 @@
{
"api": {
"baseUrl": "http://iotapi.bjmqtx.com/openapi/wlw/v3/cool",
"timeout": "10s",
"enableOperations": false,
"resumePath": ""
}
}
+11
View File
@@ -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"
}
}
+10
View File
@@ -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"
}
}
+7
View File
@@ -0,0 +1,7 @@
{
"token": {
"provider": "tengyu",
"baseUrl": "http://www.sdtywl.cn/",
"path": "/prod-api/open/api/oneLinkToken"
}
}
+10
View File
@@ -0,0 +1,10 @@
{
"token": {
"provider": "xuzhou_mobile",
"baseUrl": "http://iot.yuanjianiot.com",
"path": "/admin/api/get/token",
"params": {
"carrierType": 1
}
}
}
+9
View File
@@ -0,0 +1,9 @@
{
"api": {
"baseUrl": "https://cmp-api.ctwing.cn:20164",
"enableOperations": false
},
"http": {
"timeout": "10s"
}
}
+214
View File
@@ -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.
+24
View File
@@ -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,并在同一通道的并发请求间合并刷新动作。
+119
View File
@@ -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、频控、运营商订单完成状态及多副本热刷新仍需部署后联调。没有修改数据库、切换通道或调用真实停复机/断网。
+40
View File
@@ -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
+41
View File
@@ -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
+65
View File
@@ -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 全链路验收。
+72
View File
@@ -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
)
+342
View File
@@ -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=
+20
View File
@@ -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;
+99
View File
@@ -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:
+232
View File
@@ -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,
},
})
})
})
}
+68
View File
@@ -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())
}
}
}
+84
View File
@@ -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")
}
}
}
+28
View File
@@ -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
}
+35
View File
@@ -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
}
+101
View File
@@ -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)
}
+117
View File
@@ -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)
}
+105
View File
@@ -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)
}
+193
View File
@@ -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)
}
+117
View File
@@ -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)
}
+22
View File
@@ -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.
+22
View File
@@ -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.
+22
View File
@@ -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.
+22
View File
@@ -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.
+22
View File
@@ -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.
+22
View File
@@ -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.
+22
View File
@@ -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
})
}
+11
View File
@@ -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)
}
+156
View File
@@ -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
}
+69
View File
@@ -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>&times=<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>&times=<unix_seconds>
func henanXinjingSign(userID, apiKey, times string) string {
raw := "userId=" + userID + "&apikey=" + apiKey + "&times=" + 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, "")
}
+101
View File
@@ -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")
}
}
+265
View File
@@ -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
+216
View File
@@ -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)
}
})
}
}
+641
View File
@@ -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