chore: 并入 iotlcm-upstream-chan 历史到 upstream-chan/(原提交 1e74497)

This commit is contained in:
MiFaZhan
2026-11-02 13:28:02 +08:00
192 changed files with 33119 additions and 0 deletions
+14
View File
@@ -0,0 +1,14 @@
# 统一使用 LF,避免跨平台协作时产生整文件 diff
* text=auto eol=lf
# 二进制文件不做行尾转换
*.png binary
*.jpg binary
*.gif binary
*.ico binary
*.woff binary
*.woff2 binary
*.ttf binary
*.pdf binary
*.exe binary
*.go linguist-language=Go
@@ -0,0 +1,64 @@
name: mirror-to-gitee
# 把本仓同步到 Gitee 企业仓 snowgoat/<同名仓库>。
# 触发:main 分支推送、v* tag 推送、手动;另有每日兜底重试。
on:
push:
branches:
- main
tags:
- 'v*'
schedule:
# Gitea Actions 的 cron 用 UTC:18:30 UTC = 次日 02:30 北京时间
- cron: '30 18 * * *'
workflow_dispatch:
concurrency:
group: mirror-${{ github.ref }}
cancel-in-progress: true
jobs:
mirror:
runs-on: host
steps:
- name: 同步到 Gitee
env:
SRC_TOKEN: ${{ secrets.SRC_GIT_TOKEN }}
DST_TOKEN: ${{ secrets.GITEE_TOKEN }}
DST_OWNER: ${{ secrets.GITEE_OWNER }}
DST_USER: ${{ secrets.GITEE_USER }}
REPO_NAME: ${{ github.event.repository.name }}
run: |
set -u
if [ -z "${REPO_NAME:-}" ]; then
echo "未取到仓库名,退出"; exit 1
fi
WORK=$(mktemp -d)
trap 'rm -rf "$WORK"' EXIT
echo "源:git.5iot.com/iotlcm-public/$REPO_NAME"
echo "目标:gitee.com/$DST_OWNER/$REPO_NAME"
git clone --mirror "https://jenkins:${SRC_TOKEN}@git.5iot.com/iotlcm-public/${REPO_NAME}.git" "$WORK/repo.git"
cd "$WORK/repo.git"
echo "本地 ref:$(git for-each-ref --format='%(refname:short)' | tr '\n' ' ')"
# Gitee 在推送密集时会返回 429 Pending queue overflowed / 408 Authorization server timeout,
# 属服务端限流,退避重试即可。
attempt=0
while [ "$attempt" -lt 5 ]; do
attempt=$((attempt + 1))
echo "----- 推送尝试 $attempt/5"
if git push --mirror "https://${DST_USER}:${DST_TOKEN}@gitee.com/${DST_OWNER}/${REPO_NAME}.git" 2>&1; then
echo "✅ 已同步到 Gitee"
exit 0
fi
wait_s=$((attempt * 30))
echo "推送失败,${wait_s}s 后重试"
sleep "$wait_s"
done
echo "❌ 连续 5 次推送失败,请检查 Gitee 状态或令牌"
exit 1
@@ -0,0 +1,80 @@
name: trigger-jenkins-oss
# 打 tag 时触发 Jenkins 构建开源镜像并推送到镜像仓库。
# 仅在 tag 推送时触发;main 分支的日常提交不触发。
on:
push:
tags:
- 'v*'
workflow_dispatch:
# 同一 ref 连续触发时取消旧任务,只跑最新的
concurrency:
group: ${{ github.ref }}
cancel-in-progress: true
jobs:
trigger:
runs-on: host
steps:
- name: 触发 Jenkins 镜像构建
env:
JU: ${{ secrets.JENKINS_USER }}
JT: ${{ secrets.JENKINS_API_TOKEN }}
JURL: ${{ secrets.JENKINS_URL }}
JOB_PATH: ${{ secrets.JENKINS_JOB_PATH }}
REPO_NAME: ${{ github.event.repository.name }}
TAG_NAME: ${{ github.ref_name }}
run: |
set -eu
# 只有 tag 推送才构建;手动触发时按 tag 取,没有就退出
case "${GITHUB_REF:-}" in
refs/tags/v*) ;;
*)
if [ "${GITHUB_EVENT_NAME:-}" != "workflow_dispatch" ]; then
echo "非 tag 推送,跳过:${GITHUB_REF:-unknown}"
exit 0
fi
;;
esac
if [ -z "${TAG_NAME:-}" ]; then
echo "未取到 tag 名,退出"
exit 1
fi
# job 在 Jenkins 文件夹下的路径(组织密钥 JENKINS_JOB_PATH),
# 例如 job/iotlcm-opensrc/job/iotlcm-oss-build。
# 用密钥而非硬编码,Jenkins 里移动 job 时不用改 11 个仓。
JP="${JOB_PATH:-job/iotlcm-oss-build}"
echo "仓库:$REPO_NAME 版本:$TAG_NAME job:$JP"
WORK=$(mktemp -d)
trap 'rm -rf "$WORK"' EXIT
# 取 CSRF crumb
curl -s -c "$WORK/cj" -u "$JU:$JT" "$JURL/crumbIssuer/api/json" -o "$WORK/crumb.json"
CR=$(python3 -c "import json;print(json.load(open('$WORK/crumb.json'))['crumb'])")
# 触发参数化任务:SERVICES=仓库名,VERSION=tag
code=$(curl -s -o "$WORK/out" -w '%{http_code}' -X POST -b "$WORK/cj" -u "$JU:$JT" \
-H "Jenkins-Crumb: $CR" \
--data-urlencode "SERVICES=$REPO_NAME" \
--data-urlencode "VERSION=$TAG_NAME" \
--data-urlencode "FORCE=true" \
"$JURL/$JP/buildWithParameters")
echo "Jenkins 触发 HTTP $code"
if [ "$code" != "201" ] && [ "$code" != "200" ] && [ "$code" != "302" ] && [ "$code" != "303" ]; then
echo "触发失败:"
cat "$WORK/out"
exit 1
fi
LINK="$JURL/$JP/"
echo "=========================================="
echo "Jenkins 构建:$LINK"
echo "=========================================="
echo "::notice title=Jenkins 镜像构建::[$LINK]($LINK)"
+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
+40
View File
@@ -0,0 +1,40 @@
# iotlcm-upstream-chan
配置示例见 `build/config.example.yaml` 与 `config/config.example.yaml`。请使用获授权的测试卡。
## 本地测试
```
curl -d '{"simKeys":[
{"chanKey":"channel_003",
"iccid":"89860000000000000003",
"imsi":"",
"msisdn":""}
], "reqData":{"yearMonth":"202602"}}' 127.0.0.1:8110/sim/query/sim-datausage-monthly
curl -d '{"simKeys":[
{"chanKey":"channel_003",
"iccid":"89860000000000000003",
"imsi":"",
"msisdn":""}
], "reqData":{"queryDate":"20260201"}}' 127.0.0.1:8110/sim/query/sim-datausage-daily
```
### 异步全量消费者直调约定
本月实时累计用量全量同步使用 `POST /sim/query/sim-datausage`,不经过 Proxy。消费者从卡信息表取得 `chanKey` 和三码后直接发送:
```json
{
"simKeys": [
{
"chanKey": "channel_003",
"iccid": "89860000000000000003",
"imsi": "",
"msisdn": "1440000000003"
}
],
"reqData": {}
}
```
一条请求应只包含同一个 `chanKey` 的卡。响应由 GoFrame 统一包装为 `{code,message,data}`;`data` 按输入卡逐项返回,单项 `code=0` 表示成功,通道或网络等本地错误使用 `9999`,运营商返回的业务错误码原样保留。`data[].resData.dataUsage` 已统一为 MB。该接口查询调用时的当前月份,不使用 `queryDate`;需要指定历史月份时使用独立的月查询接口。
+34
View File
@@ -0,0 +1,34 @@
// =================================================================================
// 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)
SimApnCapability(ctx context.Context, req *v1.SimApnCapabilityReq) (res *v1.SimApnCapabilityRes, err error)
SimApnChange(ctx context.Context, req *v1.SimApnChangeReq) (res *v1.SimApnChangeRes, err error)
}
@@ -0,0 +1,13 @@
package v1
import (
"github.com/gogf/gf/v2/frame/g"
"git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/model"
)
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]
+171
View File
@@ -0,0 +1,171 @@
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 SimApnCapabilityReq struct {
g.Meta `path:"/sim/query/sim-apn-capability" tags:"CardQuery" method:"POST" summary:"只读探测APN异步持久化协议能力"`
model.SimCardOperateChangeApnInput
}
type SimApnCapabilityRes = model.SimApnCapabilityOutput
type SimApnChangeRes = model.SimCardOperateChangeApnOutput
+39
View File
@@ -0,0 +1,39 @@
ARG BASE_REGISTRY=docker.io/library
FROM ${BASE_REGISTRY}/golang:1.25-alpine AS builder
LABEL stage=gobuilder
ENV GOPROXY=https://goproxy.cn,direct
# RUN apk update --no-cache && apk add --no-cache tzdata
# 安装GCC和相关依赖
# RUN apk add --no-cache gcc g++ make autoconf automake binutils-dev gmp-dev isl-dev patchelf
# RUN apk update --no-cache && apk add --no-cache tzdata git expect
WORKDIR /build
COPY go.mod go.sum ./
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 ${BASE_REGISTRY}/alpine:3.23 AS alpine
RUN apk add --no-cache ca-certificates tzdata
# RUN apk update --no-cache && apk add --no-cache ca-certificates
ENV TZ=Asia/Shanghai
EXPOSE 11140
WORKDIR /app
COPY --from=builder /build/iotlcmupstreamchan_svc /app/iotlcmupstreamchan_svc
# Configuration is supplied by the deployment bind mount.
RUN mkdir -p /app/config
CMD ["./iotlcmupstreamchan_svc"]
@@ -0,0 +1 @@
{}
@@ -0,0 +1,8 @@
{
"api": {
"baseUrl": "http://iotapi.bjmqtx.com/openapi/wlw/v3/cool",
"timeout": "10s",
"enableOperations": false,
"resumePath": ""
}
}
@@ -0,0 +1,11 @@
{
"token": {
"provider": "apika",
"baseUrl": "http://test.iotapi.apika.cn",
"path": "/iot-api/iot-card-info/getRefreshToken"
},
"api": {
"baseUrl": "https://api.iot.10086.cn/v5/",
"timeout": "15s"
}
}
@@ -0,0 +1,10 @@
{
"token": {
"provider": "henan_lelian",
"baseUrl": "http://api.wwlelianiot.com",
"path": "/api/v2/getShareToken",
"params": {
"loginPath": "/api/v2/loginVerify"
}
}
}
@@ -0,0 +1,7 @@
{
"token": {
"provider": "mobile_public",
"baseUrl": "https://REPLACE_WITH_TOKEN_HOST",
"path": "/api/get/yd/api/msg"
}
}
@@ -0,0 +1,7 @@
{
"token": {
"provider": "tengyu",
"baseUrl": "http://www.sdtywl.cn/",
"path": "/prod-api/open/api/oneLinkToken"
}
}
@@ -0,0 +1,10 @@
{
"token": {
"provider": "xuzhou_mobile",
"baseUrl": "http://iot.yuanjianiot.com",
"path": "/admin/api/get/token",
"params": {
"carrierType": 1
}
}
}
+9
View File
@@ -0,0 +1,9 @@
{
"api": {
"baseUrl": "https://cmp-api.ctwing.cn:20164",
"enableOperations": false
},
"http": {
"timeout": "10s"
}
}
+73
View File
@@ -0,0 +1,73 @@
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/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
)
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
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
github.com/gogf/gf/contrib/registry/consul/v2 v2.10.3
github.com/gogf/gf/contrib/registry/file/v2 v2.10.0 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/gorilla/websocket v1.5.3 // indirect
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
github.com/tjfoc/gmsm v1.4.1 // indirect
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
go.opentelemetry.io/otel v1.39.0 // indirect
go.opentelemetry.io/otel/metric v1.39.0 // indirect
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
google.golang.org/protobuf v1.36.11
gopkg.in/yaml.v3 v3.0.1 // indirect
modernc.org/libc v1.22.5 // indirect
modernc.org/mathutil v1.5.0 // indirect
modernc.org/memory v1.5.0 // indirect
modernc.org/sqlite v1.23.1 // indirect
)
+402
View File
@@ -0,0 +1,402 @@
cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
github.com/BurntSushi/toml v1.5.0 h1:W5quZX/G/csjUnuI8SUYlsHs9M38FC7znL0lIO+DvMg=
github.com/BurntSushi/toml v1.5.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
github.com/DataDog/datadog-go v3.2.0+incompatible/go.mod h1:LButxg5PwREeZtORoXG3tL4fMGNddJ+vMq1mwgfaqoQ=
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/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
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/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
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/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98=
github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4=
github.com/fatih/color v1.9.0/go.mod h1:eQcE1qtQxscV5RaZvpXrrb8Drkc3/DdQ+uUYCNjL+zU=
github.com/fatih/color v1.13.0/go.mod h1:kLAiJbzzSOZDVNGyDpeOxJ47H46qBXwg5ILebYFFOfk=
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.0 h1:rzDROlyqGMe/eM6dCalSR8dZOuMIdLhmxKSH1DGhbFs=
github.com/gogf/gf/v2 v2.10.0/go.mod h1:Svl1N+E8G/QshU2DUbh/3J/AJauqCgUnxHurXWR4Qx0=
github.com/gogf/gf/v2 v2.10.3 h1:tZvEk+58x9uyuOAMgT5VV8yAPHGPBwJkPYRuXj2cfBI=
github.com/gogf/gf/v2 v2.10.3/go.mod h1:Svl1N+E8G/QshU2DUbh/3J/AJauqCgUnxHurXWR4Qx0=
github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ=
github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.3.3/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw=
github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8=
github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA=
github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs=
github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w=
github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0=
github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ=
github.com/google/btree v1.0.1 h1:gK4Kx5IaGY9CD5sPJ36FHiBJ6ZXl0kilRiiCj+jdYp4=
github.com/google/btree v1.0.1/go.mod h1:xXMiIv4Fb/0kKde4SpL7qlzvu5cMJDRkFDxJfI9uaxA=
github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M=
github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
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.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
github.com/prometheus/client_model v0.2.0/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
github.com/prometheus/common v0.4.1/go.mod h1:TNfzLD0ON7rHzMJeJkieUDPYmFC7Snx/y86RQel1bk4=
github.com/prometheus/common v0.9.1/go.mod h1:yhUN8i9wzaXS3w1O07YhxHEBxD+W35wd8bs7vj7HSQ4=
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/tjfoc/gmsm v1.4.1 h1:aMe1GlZb+0bLjn+cKTPEvvn9oUEBlJitaZiiBwsbgho=
github.com/tjfoc/gmsm v1.4.1/go.mod h1:j4INPkHWMrhJb38G+J6W4Tw0AbuN8Thu3PbdVYhVcTE=
github.com/tv42/httpunix v0.0.0-20150427012821-b75d8614f926/go.mod h1:9ESjWnEqriFuLhtthL60Sar/7RFoluCcXsuvEwTV5KM=
github.com/zeebo/xxh3 v1.0.2 h1:xZmwmqxHZA8AI603jOQ0tMqmBr9lPeFwGg6d+xy9DC0=
github.com/zeebo/xxh3 v1.0.2/go.mod h1:5NWz9Sef7zIDm2JHfFlcQvNekmcEl9ekUZQQKCYaDcA=
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/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20201012173705-84dcc777aaee/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/exp v0.0.0-20260312153236-7ab1446f8b90 h1:jiDhWWeC7jfWqR9c/uplMOqJ0sbNlNWv0UkzE0vX1MA=
golang.org/x/exp v0.0.0-20260312153236-7ab1446f8b90/go.mod h1:xE1HEv6b+1SCZ5/uscMRjUBKtIxworgEcEi+/n9NQDQ=
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20181114220301-adae6a3d119a/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190613194153-d28f0bde5980/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20190923162816-aa69164e4478/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20201010224723-4f7140c49acb/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20210410081132-afb366fc7cd1/go.mod h1:9tjilg8BloeKEkVJvy7fQ90B1CfIiPueXVOjqfkSzI8=
golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU=
golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY=
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
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-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20181116152217-5ac8a444bdc5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190922100055-0a153f010e69/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190924154521-2837fb4f24fe/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
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-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210303074136-134d130e1a04/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
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-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY=
golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
golang.org/x/tools v0.0.0-20190907020128-2ca718005c18/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc=
google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc=
google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 h1:gRkg/vSppuSQoDjxyiGfN4Upv/h/DQmIR10ZU8dh4Ww=
google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk=
google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg=
google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY=
google.golang.org/grpc v1.31.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak=
google.golang.org/grpc v1.79.3 h1:sybAEdRIEtvcD68Gx7dmnwjZKlyfuc61Dyo9pGXXkKE=
google.golang.org/grpc v1.79.3/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ=
google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=
google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE=
google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo=
google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw=
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=
honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
modernc.org/libc v1.22.5 h1:91BNch/e5B0uPbJFgqbxXuOnxBQjlS//icfQEGmvyjE=
modernc.org/libc v1.22.5/go.mod h1:jj+Z7dTNX8fBScMVNRAYZ/jF91K8fdT2hYMThc3YjBY=
modernc.org/mathutil v1.5.0 h1:rV0Ko/6SfM+8G+yKiyI830l3Wuz1zRutdslNoQ0kfiQ=
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;
+94
View File
@@ -0,0 +1,94 @@
.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.example.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
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 docker_iotlcmupstreamchan:v0.${NOWTIME}.bak; \
docker rmi docker_iotlcmupstreamchan:v0; \
fi
docker build --rm -f build/Dockerfile --progress=plain --platform linux/amd64 -t docker_iotlcmupstreamchan:v0 --build-arg BuildVersion=${BuildVersion} --build-arg BuildTime=${BuildTime} --build-arg CommitID=${CommitID} .
# docker save -o docker_iotlcmupstreamchan.tar docker_iotlcmupstreamchan:v0
dockerbuild: docker_iotlcmupstreamchan
echo "docker build success"
+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,
},
})
})
})
}
+321
View File
@@ -0,0 +1,321 @@
package cmd
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"sync"
"testing"
"time"
"github.com/go-sql-driver/mysql"
_ "github.com/gogf/gf/contrib/drivers/mysql/v2"
"github.com/gogf/gf/v2/database/gdb"
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/net/ghttp"
"github.com/gogf/gf/v2/os/gcfg"
"github.com/gogf/gf/v2/os/gtime"
"gopkg.in/yaml.v3"
"git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/logic/upstreamapi"
"git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/logic/upstreamimpl"
"git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/model"
"git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/model/entity"
"git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/service"
)
// TestAPNHTTPHarness 仅供跨模块测试子进程使用,不启动真实运营商或默认业务连接。
func TestAPNHTTPHarness(t *testing.T) {
if os.Getenv("APN_HTTP_CHAIN_HELPER") != "1" {
t.Skip("未启用本机HTTP模拟链")
}
body, err := os.ReadFile(filepath.Join("..", "..", "..", "iotlcm-sms", "deploy", "config.yaml"))
if err != nil {
t.Fatal("读取本机认证配置失败")
}
var source struct {
DSN string `yaml:"sceneDbDsn"`
}
if yaml.Unmarshal(body, &source) != nil {
t.Fatal("解析本机认证配置失败")
}
dsn, err := mysql.ParseDSN(source.DSN)
if err != nil || dsn.Net != "tcp" || (dsn.Addr != "127.0.0.1:3306" && dsn.Addr != "localhost:3306") {
t.Fatal("不是已授权本机连接")
}
node := gdb.ConfigNode{Type: "mysql", Host: "127.0.0.1", Port: "3306", User: dsn.User, Pass: dsn.Passwd, Name: "iotdb_apn_chan_test_20261015"}
db, err := gdb.New(node)
if err != nil {
t.Fatal("创建专用测试连接失败")
}
identity, err := db.GetOne(context.Background(), "SELECT @@port AS port,@@server_uuid AS uuid,@@datadir AS datadir,DATABASE() AS dbname")
if err != nil || identity["port"].String() != "3306" || identity["uuid"].String() != "4fa545af-ab36-11f1-be02-040e3c9de361" || identity["dbname"].String() != node.Name || strings.TrimRight(strings.ReplaceAll(identity["datadir"].String(), "\\", "/"), "/") != "D:/MySQL/MySQL Server 8.0/Data" {
t.Fatal("专用测试实例身份核验失败")
}
if _, err := db.GetAll(context.Background(), "SELECT revision FROM t_apn_operation LIMIT 0"); err != nil {
t.Fatal("先执行指定测试库迁移回归")
}
adapter, _ := gcfg.NewAdapterContent(`{}`)
g.Cfg().SetAdapter(adapter)
if err := gdb.SetConfig(gdb.Config{"default": gdb.ConfigGroup{node}}); err != nil {
t.Fatal("设置专用测试连接失败")
}
var mu sync.Mutex
writes, queries := map[string]int{}, map[string]int{}
submits, unexpected := 0, 0
jobs := map[string][]string{}
operator := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
mu.Lock()
defer mu.Unlock()
if strings.HasSuffix(r.URL.Path, "/sim-communication-function/batch") {
numbers := strings.Split(r.URL.Query().Get("msisdns"), "_")
first := numbers[0]
submits++
for _, number := range numbers {
writes[number]++
}
if strings.HasPrefix(first, "lost-") {
conn, _, e := w.(http.Hijacker).Hijack()
if e != nil {
t.Error(e)
return
}
_ = conn.Close()
return
}
job := "fake-job-" + first
jobs[job] = numbers
_ = json.NewEncoder(w).Encode(g.Map{"status": "0", "result": []g.Map{{"jobId": job}}})
return
}
if strings.HasSuffix(r.URL.Path, "/sim-batch-result") {
job := r.URL.Query().Get("jobId")
queries[job]++
if queries[job] == 1 {
_, _ = io.WriteString(w, `{"status":"14008","message":"尚未可查"}`)
return
}
if queries[job] == 2 {
_, _ = io.WriteString(w, `{"status":"0","result":[{"jobStatus":"1"}]}`)
return
}
if strings.Contains(job, "failed-") {
_, _ = io.WriteString(w, `{"status":"0","message":"明确失败","result":[{"jobStatus":"4"}]}`)
return
}
cards := []g.Map{}
for _, number := range jobs[job] {
cards = append(cards, g.Map{"resultId": number, "status": "0", "message": "明确成功"})
}
_ = json.NewEncoder(w).Encode(g.Map{"status": "0", "result": []g.Map{{"jobStatus": "2", "resultList": cards}}})
return
}
unexpected++
t.Errorf("模拟运营商收到非预期请求:%s", r.URL.Path)
http.Error(w, "非预期请求", 500)
}))
defer operator.Close()
channel := upstreamimpl.NewSimYD().NewTemp(context.Background(), &model.SimChannelConfigItemCache{
Template: &entity.TSimChannelTemplate{TempConfig: `{"api":{"baseUrl":"` + operator.URL + `/v5"},"http":{"timeout":"2s"}}`},
Channel: &entity.TSimChannels{ChanKey: "fake-channel", AppKey: "fake", AccessToken: "fake-token", AccessTokenExpire: gtime.New(time.Now().Add(time.Hour))},
})
register := func() {
api := upstreamapi.NewUpstreamAPI()
api.RegisterChan("fake-channel", "1", &model.SimCardChannelCache{Instance: channel})
service.RegisterUpstreamAPI(api)
}
register()
server := ghttp.GetServer(t.Name())
server.SetAddr("127.0.0.1:0")
bindHTTPRoutes(server)
if err := server.Start(); err != nil {
t.Fatal(err)
}
defer server.Shutdown()
stop := make(chan struct{})
var stopOnce sync.Once
dropped := map[string]bool{}
front := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/__test/stats":
mu.Lock()
defer mu.Unlock()
_ = json.NewEncoder(w).Encode(g.Map{"writes": writes, "queries": queries, "submits": submits, "unexpected": unexpected})
return
case "/__test/stop":
stopOnce.Do(func() { close(stop) })
return
case "/__test/restart":
register()
return
}
raw, _ := io.ReadAll(r.Body)
var input model.SimCardOperateChangeApnInput
if json.Unmarshal(raw, &input) != nil || len(input.SimKeys) == 0 || input.SimKeys[0].ChanKey != "fake-channel" {
http.Error(w, "只接受假卡", 400)
return
}
req, _ := http.NewRequestWithContext(r.Context(), r.Method, fmt.Sprintf("http://127.0.0.1:%d%s", server.GetListenedPort(), r.URL.Path), strings.NewReader(string(raw)))
req.Header.Set("Content-Type", "application/json")
res, e := http.DefaultClient.Do(req)
if e != nil {
http.Error(w, e.Error(), 500)
return
}
out, _ := io.ReadAll(res.Body)
_ = res.Body.Close()
mu.Lock()
lose := input.ReqData.SubmitOnly && !dropped[input.ReqData.OperationID] && (strings.Contains(input.ReqData.OperationID, "drop-response") || strings.Contains(input.ReqData.OperationID, "top-error"))
if lose {
dropped[input.ReqData.OperationID] = true
}
mu.Unlock()
if lose {
if strings.Contains(input.ReqData.OperationID, "top-error") {
_, _ = io.WriteString(w, `{"code":9999,"message":"模拟受理后网关结果丢失"}`)
return
}
conn, _, e := w.(http.Hijacker).Hijack()
if e != nil {
t.Error(e)
return
}
_ = conn.Close()
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(res.StatusCode)
_, _ = w.Write(out)
}))
defer front.Close()
fmt.Println("APN_CHAIN_READY=" + front.URL)
select {
case <-stop:
case <-time.After(3 * time.Minute):
t.Error("HTTP模拟链未正常结束")
}
}
type apnBindingTestAPI struct {
service.IUpstreamAPI
captured chan model.SimCardOperateChangeApnInput
}
func (api *apnBindingTestAPI) ChangeSimCardOperateApn(_ context.Context, input *model.SimCardOperateChangeApnInput) (model.SimCardOperateChangeApnOutput, error) {
api.captured <- *input
return model.SimCardOperateChangeApnOutput{}, nil
}
func (api *apnBindingTestAPI) QuerySimApnCapability(_ context.Context, input *model.SimCardOperateChangeApnInput) (model.SimApnCapabilityOutput, error) {
api.captured <- *input
return model.SimApnCapabilityOutput{}, nil
}
func TestAPNProductionHTTPBindingPreservesAsyncFields(t *testing.T) {
old := service.UpstreamAPI()
api := &apnBindingTestAPI{IUpstreamAPI: old, captured: make(chan model.SimCardOperateChangeApnInput, 1)}
service.RegisterUpstreamAPI(api)
t.Cleanup(func() { service.RegisterUpstreamAPI(old) })
server := ghttp.GetServer(t.Name())
server.SetAddr("127.0.0.1:0")
bindHTTPRoutes(server)
if err := server.Start(); err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = server.Shutdown() })
for _, tc := range []struct {
path string
data model.SimCardOperateChangeApnInputItem
}{
{"/sim/change/sim-apn", model.SimCardOperateChangeApnInputItem{OperationID: "frozen-operation", OperType: "1", ApnName: "CMIOT", SubmitOnly: true}},
{"/sim/change/sim-apn", model.SimCardOperateChangeApnInputItem{OperationID: "frozen-operation", OperType: "1", ApnName: "CMIOT", QueryJobID: "saved-job"}},
{"/sim/change/sim-apn", model.SimCardOperateChangeApnInputItem{OperationID: "frozen-operation", OperType: "1", ApnName: "CMIOT", RecoverOnly: true}},
{"/sim/query/sim-apn-capability", model.SimCardOperateChangeApnInputItem{OperationID: "frozen-operation", OperType: "1", ApnName: "CMIOT"}},
} {
input := model.SimCardOperateChangeApnInput{SimKeys: []*model.SimKey{{ChanKey: "fake", ICCID: "fake-card", MSISDN: "frozen-number", IMSI: "frozen-imsi"}}, ReqData: tc.data}
client := g.Client().ContentJson()
res, err := client.Post(context.Background(), fmt.Sprintf("http://127.0.0.1:%d%s", server.GetListenedPort(), tc.path), input)
if err != nil {
t.Fatal(err)
}
body := res.ReadAll()
_ = res.Close()
var envelope struct {
Code int `json:"code"`
}
if json.Unmarshal(body, &envelope) != nil || envelope.Code != 0 {
t.Fatalf("生产绑定失败:%s", body)
}
select {
case got := <-api.captured:
if got.ReqData != tc.data || len(got.SimKeys) != 1 || *got.SimKeys[0] != *input.SimKeys[0] {
t.Fatalf("异步字段或快照丢失:%+v", got)
}
default:
t.Fatalf("未调用生产controller:%s", body)
}
}
}
func TestInternalChannelTestWithoutTokenReachesRequestValidation(t *testing.T) {
oldAdapter := g.Cfg().GetAdapter()
adapter, err := gcfg.NewAdapterContent(`{"internal":{}}`)
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,
"/sim/query/sim-apn-capability": false,
"/internal/channel/refresh": false,
"/internal/channel/remove": false,
}
for _, route := range server.GetRoutes() {
if _, ok := want[route.Route]; ok && strings.EqualFold(route.Method, "POST") {
want[route.Route] = true
}
}
for route, found := range want {
if !found {
t.Fatalf("POST %s route was not registered: %+v", route, server.GetRoutes())
}
}
}
@@ -0,0 +1,84 @@
package cmd
import (
"context"
"encoding/json"
"fmt"
"net/http"
"testing"
_ "git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/logic/channelconfig"
"git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/logic/upstreamapi"
"git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/model"
"git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/model/entity"
"git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/service"
"github.com/gogf/gf/v2/net/ghttp"
)
type sharedTokenGateConfig struct {
service.IChannelConfig
config *model.SimChannelConfigItemCache
}
func (c *sharedTokenGateConfig) RefreshChannel(context.Context, string) error { return nil }
func (c *sharedTokenGateConfig) GetChannel(string) (*model.SimChannelConfigItemCache, bool) {
return c.config, true
}
type sharedTokenGateTemplate struct{ calls int }
func (f *sharedTokenGateTemplate) NewTemp(context.Context, *model.SimChannelConfigItemCache) model.SimCardChannelI {
f.calls++
return nil
}
func TestSharedTokenCannotCreateIneligibleChannel(t *testing.T) {
// The endpoint uses the real RefreshChannel implementation; no database or carrier calls.
oldAPI := service.UpstreamAPI()
t.Cleanup(func() { service.RegisterUpstreamAPI(oldAPI) })
oldConfig := service.ChannelConfig()
config := &sharedTokenGateConfig{}
service.RegisterChannelConfig(config)
t.Cleanup(func() { service.RegisterChannelConfig(oldConfig) })
api := upstreamapi.NewUpstreamAPI()
service.RegisterUpstreamAPI(api)
factory := &sharedTokenGateTemplate{}
api.RegisterTemp("gate_test", factory)
server := ghttp.GetServer(t.Name())
server.SetAddr("127.0.0.1:0")
bindHTTPRoutes(server)
if err := server.Start(); err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = server.Shutdown() })
one, unexpected := 1, 2
for _, value := range []*int{nil, &one, &unexpected} {
config.config = &model.SimChannelConfigItemCache{
Channel: &entity.TSimChannels{ChanKey: "gate_channel", TempKey: "gate_test"},
Template: &entity.TSimChannelTemplate{TempKey: "gate_test"},
IsNormal: value,
}
resp, err := http.Post(fmt.Sprintf("http://127.0.0.1:%d/internal/channel/shared-token?chanKey=gate_channel", server.GetListenedPort()), "", nil)
if err != nil {
t.Fatal(err)
}
var body struct {
Code int `json:"code"`
}
err = json.NewDecoder(resp.Body).Decode(&body)
resp.Body.Close()
if err != nil {
t.Fatal(err)
}
if body.Code != 50 {
t.Fatalf("unexpected response: %+v", body)
}
if factory.calls != 0 {
t.Fatal("shared-token endpoint bypassed the normal gate")
}
if _, ok := api.GetChan("gate_channel"); ok {
t.Fatal("shared-token endpoint cached an ineligible instance")
}
}
}
+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,168 @@
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) SimApnCapability(ctx context.Context, req *v1.SimApnCapabilityReq) (res *v1.SimApnCapabilityRes, err error) {
output, err := service.UpstreamAPI().QuerySimApnCapability(ctx, &req.SimCardOperateChangeApnInput)
if err != nil {
return nil, err
}
return &output, nil
}
func (c *ControllerV1) SimApnChange(ctx context.Context, req *v1.SimApnChangeReq) (res *v1.SimApnChangeRes, err error) {
output, err := service.UpstreamAPI().ChangeSimCardOperateApn(ctx, &req.SimCardOperateChangeApnInput)
if err != nil {
return nil, err
}
return &output, nil
}
@@ -0,0 +1,21 @@
package cardquery
import (
"context"
"github.com/gogf/gf/v2/errors/gerror"
v1 "git.5iot.com/iotlcm-public/iotlcm-upstream-chan/api/cardquery/v1"
"git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/service"
"git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/service/mingqian"
)
func (controller *ControllerV1) MingqianPackageFlow(ctx context.Context, req *v1.MingqianPackageFlowReq) (*v1.MingqianPackageFlowRes, error) {
if req == nil {
return nil, gerror.New("mingqian: request required")
}
output, err := mingqian.QueryChannelsPackageFlow(ctx, &req.SimDataUsageInput, service.UpstreamAPI().GetChan)
if err != nil {
return nil, err
}
return &output, nil
}
@@ -0,0 +1,35 @@
package infopb
import (
"context"
"git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/pb"
"git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/pub/xjpubservice"
"github.com/gogf/gf/contrib/rpc/grpcx/v2"
"github.com/gogf/gf/v2/frame/g"
)
type Controller struct {
pb.UnimplementedInfoServer
}
func Register(s *grpcx.GrpcServer) {
pb.RegisterInfoServer(s.Server, &Controller{})
}
func (*Controller) Info(ctx context.Context, req *pb.InfoReq) (*pb.InfoRes, error) {
g.Log().Info(ctx, req)
info := xjpubservice.InfoRpc().GetInfo()
return &pb.InfoRes{
Head: &pb.HeadRes{
Code: 0,
Message: "success",
},
Type: info.Type,
Name: info.Name,
Title: info.Title,
Description: info.Description,
Version: info.Version,
}, nil
}
@@ -0,0 +1,47 @@
package msgcallpb
import (
"context"
"git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/pb"
"git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/pub/xjpubservice"
"github.com/gogf/gf/contrib/rpc/grpcx/v2"
"github.com/gogf/gf/v2/frame/g"
)
type Controller struct {
pb.UnimplementedMsgCallServer
}
func Register(s *grpcx.GrpcServer) {
pb.RegisterMsgCallServer(s.Server, &Controller{})
}
func (c *Controller) OneCall(ctx context.Context, req *pb.MsgReq) (res *pb.MsgRes, err error) {
res = &pb.MsgRes{
Head: &pb.HeadRes{
Code: -1,
Message: "service not found",
},
Service: req.Service,
Method: req.Method,
}
if req.Head != nil {
res.Head.RouterId = req.Head.RouterId
res.Head.TraceId = req.Head.TraceId
}
info := xjpubservice.InfoRpc().GetInfo()
if req.Service != info.Name {
g.Log().Errorf(ctx, "OneCall Service not found: service: %s, info: %s", req.Service, info.Name)
return res, nil
}
{
g.Log().Errorf(ctx, "OneCall Method not found: method: %s", req.Method)
res.Head.Code = -1
res.Head.Message = "method not found"
return res, nil
}
return res, nil
}
@@ -0,0 +1,101 @@
// ==========================================================================
// Code generated and maintained by GoFrame CLI tool. DO NOT EDIT.
// ==========================================================================
package internal
import (
"context"
"github.com/gogf/gf/v2/database/gdb"
"github.com/gogf/gf/v2/frame/g"
)
// TApiDao is the data access object for the table t_api.
type TApiDao struct {
table string // table is the underlying table name of the DAO.
group string // group is the database configuration group name of the current DAO.
columns TApiColumns // columns contains all the column names of Table for convenient usage.
handlers []gdb.ModelHandler // handlers for customized model modification.
}
// TApiColumns defines and stores column names for the table t_api.
type TApiColumns struct {
Id string // ID
AppKey string // APPKey
AppSecret string // APPSecret
RateLimitPeriod string // 限流周期(秒,0:不限流)
RateLimitCount string // 限流次数
DeptId string // 所属机构
TenantId string // 租户ID
CreatedDept string // 创建部门
CreatedBy string // 创建者
CreatedAt string // 创建时间
UpdatedBy string // 更新者
UpdatedAt string // 更新时间
}
// tApiColumns holds the columns for the table t_api.
var tApiColumns = TApiColumns{
Id: "id",
AppKey: "app_key",
AppSecret: "app_secret",
RateLimitPeriod: "rate_limit_period",
RateLimitCount: "rate_limit_count",
DeptId: "dept_id",
TenantId: "tenant_id",
CreatedDept: "created_dept",
CreatedBy: "created_by",
CreatedAt: "created_at",
UpdatedBy: "updated_by",
UpdatedAt: "updated_at",
}
// NewTApiDao creates and returns a new DAO object for table data access.
func NewTApiDao(handlers ...gdb.ModelHandler) *TApiDao {
return &TApiDao{
group: "default",
table: "t_api",
columns: tApiColumns,
handlers: handlers,
}
}
// DB retrieves and returns the underlying raw database management object of the current DAO.
func (dao *TApiDao) DB() gdb.DB {
return g.DB(dao.group)
}
// Table returns the table name of the current DAO.
func (dao *TApiDao) Table() string {
return dao.table
}
// Columns returns all column names of the current DAO.
func (dao *TApiDao) Columns() TApiColumns {
return dao.columns
}
// Group returns the database configuration group name of the current DAO.
func (dao *TApiDao) Group() string {
return dao.group
}
// Ctx creates and returns a Model for the current DAO. It automatically sets the context for the current operation.
func (dao *TApiDao) Ctx(ctx context.Context) *gdb.Model {
model := dao.DB().Model(dao.table)
for _, handler := range dao.handlers {
model = handler(model)
}
return model.Safe().Ctx(ctx)
}
// Transaction wraps the transaction logic using function f.
// It rolls back the transaction and returns the error if function f returns a non-nil error.
// It commits the transaction and returns nil if function f returns nil.
//
// Note: Do not commit or roll back the transaction in function f,
// as it is automatically handled by this function.
func (dao *TApiDao) Transaction(ctx context.Context, f func(ctx context.Context, tx gdb.TX) error) (err error) {
return dao.Ctx(ctx).Transaction(ctx, f)
}
@@ -0,0 +1,117 @@
// ==========================================================================
// Code generated and maintained by GoFrame CLI tool. DO NOT EDIT.
// ==========================================================================
package internal
import (
"context"
"github.com/gogf/gf/v2/database/gdb"
"github.com/gogf/gf/v2/frame/g"
)
// TCustomerDao is the data access object for the table t_customer.
type TCustomerDao struct {
table string // table is the underlying table name of the DAO.
group string // group is the database configuration group name of the current DAO.
columns TCustomerColumns // columns contains all the column names of Table for convenient usage.
handlers []gdb.ModelHandler // handlers for customized model modification.
}
// TCustomerColumns defines and stores column names for the table t_customer.
type TCustomerColumns struct {
Id string // ID
CKey string // 客户编号
CType string // 客户类型(0:企业,1:个人)
CName string // 客户名称
CShortName string // 客户简称
ContactUserName string // 联系人
ContactPhone string // 联系电话
LicenseNumber string // 统一社会信用代码
RegionCode string // 行政区域代码
Address string // 地址
DepositAmount string // 预存金额
CreditAmountTotal string // 已授信额度
CreditAmountUsed string // 已使用授信额度
DeptId string // 所属机构
TenantId string // 租户ID
CreatedDept string // 创建部门
CreatedBy string // 创建者
CreatedAt string // 创建时间
UpdatedBy string // 更新者
UpdatedAt string // 更新时间
}
// tCustomerColumns holds the columns for the table t_customer.
var tCustomerColumns = TCustomerColumns{
Id: "id",
CKey: "c_key",
CType: "c_type",
CName: "c_name",
CShortName: "c_short_name",
ContactUserName: "contact_user_name",
ContactPhone: "contact_phone",
LicenseNumber: "license_number",
RegionCode: "region_code",
Address: "address",
DepositAmount: "deposit_amount",
CreditAmountTotal: "credit_amount_total",
CreditAmountUsed: "credit_amount_used",
DeptId: "dept_id",
TenantId: "tenant_id",
CreatedDept: "created_dept",
CreatedBy: "created_by",
CreatedAt: "created_at",
UpdatedBy: "updated_by",
UpdatedAt: "updated_at",
}
// NewTCustomerDao creates and returns a new DAO object for table data access.
func NewTCustomerDao(handlers ...gdb.ModelHandler) *TCustomerDao {
return &TCustomerDao{
group: "default",
table: "t_customer",
columns: tCustomerColumns,
handlers: handlers,
}
}
// DB retrieves and returns the underlying raw database management object of the current DAO.
func (dao *TCustomerDao) DB() gdb.DB {
return g.DB(dao.group)
}
// Table returns the table name of the current DAO.
func (dao *TCustomerDao) Table() string {
return dao.table
}
// Columns returns all column names of the current DAO.
func (dao *TCustomerDao) Columns() TCustomerColumns {
return dao.columns
}
// Group returns the database configuration group name of the current DAO.
func (dao *TCustomerDao) Group() string {
return dao.group
}
// Ctx creates and returns a Model for the current DAO. It automatically sets the context for the current operation.
func (dao *TCustomerDao) Ctx(ctx context.Context) *gdb.Model {
model := dao.DB().Model(dao.table)
for _, handler := range dao.handlers {
model = handler(model)
}
return model.Safe().Ctx(ctx)
}
// Transaction wraps the transaction logic using function f.
// It rolls back the transaction and returns the error if function f returns a non-nil error.
// It commits the transaction and returns nil if function f returns nil.
//
// Note: Do not commit or roll back the transaction in function f,
// as it is automatically handled by this function.
func (dao *TCustomerDao) Transaction(ctx context.Context, f func(ctx context.Context, tx gdb.TX) error) (err error) {
return dao.Ctx(ctx).Transaction(ctx, f)
}
@@ -0,0 +1,115 @@
// ==========================================================================
// Code generated and maintained by GoFrame CLI tool. DO NOT EDIT.
// ==========================================================================
package internal
import (
"context"
"github.com/gogf/gf/v2/database/gdb"
"github.com/gogf/gf/v2/frame/g"
)
// TSimAgentPackageDao is the data access object for the table t_sim_agent_package.
type TSimAgentPackageDao struct {
table string // table is the underlying table name of the DAO.
group string // group is the database configuration group name of the current DAO.
columns TSimAgentPackageColumns // columns contains all the column names of Table for convenient usage.
handlers []gdb.ModelHandler // handlers for customized model modification.
}
// TSimAgentPackageColumns defines and stores column names for the table t_sim_agent_package.
type TSimAgentPackageColumns struct {
Id string // ID
PackageKey string // 套餐分类编码
PackageName string // 套餐分类名称
ParentPackageKey string // 上级套餐分类编码
PackageStatus string // 是否启用(0:未启用,1启用)
BillingMethod string // 计费方式(0:月,1:季,2:年)
ChannelPrice string // 渠道价格
MonthlyFee string // 月租费
PackageDataMb string // 套餐流量(MB)
PackageDataGb string // 套餐流量(GB)
ValidityPeriod string // 有效期
Description string // 描述信息
OrderNum string // 排序
TenantId string // 租户ID
CreatedDept string // 创建部门
CreatedBy string // 创建者
CreatedAt string // 创建时间
UpdatedBy string // 更新者
UpdatedAt string // 更新时间
}
// tSimAgentPackageColumns holds the columns for the table t_sim_agent_package.
var tSimAgentPackageColumns = TSimAgentPackageColumns{
Id: "id",
PackageKey: "package_key",
PackageName: "package_name",
ParentPackageKey: "parent_package_key",
PackageStatus: "package_status",
BillingMethod: "billing_method",
ChannelPrice: "channel_price",
MonthlyFee: "monthly_fee",
PackageDataMb: "package_data_mb",
PackageDataGb: "package_data_gb",
ValidityPeriod: "validity_period",
Description: "description",
OrderNum: "order_num",
TenantId: "tenant_id",
CreatedDept: "created_dept",
CreatedBy: "created_by",
CreatedAt: "created_at",
UpdatedBy: "updated_by",
UpdatedAt: "updated_at",
}
// NewTSimAgentPackageDao creates and returns a new DAO object for table data access.
func NewTSimAgentPackageDao(handlers ...gdb.ModelHandler) *TSimAgentPackageDao {
return &TSimAgentPackageDao{
group: "default",
table: "t_sim_agent_package",
columns: tSimAgentPackageColumns,
handlers: handlers,
}
}
// DB retrieves and returns the underlying raw database management object of the current DAO.
func (dao *TSimAgentPackageDao) DB() gdb.DB {
return g.DB(dao.group)
}
// Table returns the table name of the current DAO.
func (dao *TSimAgentPackageDao) Table() string {
return dao.table
}
// Columns returns all column names of the current DAO.
func (dao *TSimAgentPackageDao) Columns() TSimAgentPackageColumns {
return dao.columns
}
// Group returns the database configuration group name of the current DAO.
func (dao *TSimAgentPackageDao) Group() string {
return dao.group
}
// Ctx creates and returns a Model for the current DAO. It automatically sets the context for the current operation.
func (dao *TSimAgentPackageDao) Ctx(ctx context.Context) *gdb.Model {
model := dao.DB().Model(dao.table)
for _, handler := range dao.handlers {
model = handler(model)
}
return model.Safe().Ctx(ctx)
}
// Transaction wraps the transaction logic using function f.
// It rolls back the transaction and returns the error if function f returns a non-nil error.
// It commits the transaction and returns nil if function f returns nil.
//
// Note: Do not commit or roll back the transaction in function f,
// as it is automatically handled by this function.
func (dao *TSimAgentPackageDao) Transaction(ctx context.Context, f func(ctx context.Context, tx gdb.TX) error) (err error) {
return dao.Ctx(ctx).Transaction(ctx, f)
}
@@ -0,0 +1,105 @@
// ==========================================================================
// Code generated and maintained by GoFrame CLI tool. DO NOT EDIT.
// ==========================================================================
package internal
import (
"context"
"github.com/gogf/gf/v2/database/gdb"
"github.com/gogf/gf/v2/frame/g"
)
// TSimAgentPacketDao is the data access object for the table t_sim_agent_packet.
type TSimAgentPacketDao struct {
table string // table is the underlying table name of the DAO.
group string // group is the database configuration group name of the current DAO.
columns TSimAgentPacketColumns // columns contains all the column names of Table for convenient usage.
handlers []gdb.ModelHandler // handlers for customized model modification.
}
// TSimAgentPacketColumns defines and stores column names for the table t_sim_agent_packet.
type TSimAgentPacketColumns struct {
Id string // ID
PackageKey string // 套餐分类编码
PacketKey string // 套餐编码
PacketName string // 套餐名称
ValidityPeriod string // 套餐有效期(月份)
PacketStatus string // 是否启用(0:未启用,1启用)
DiscountPercent string // 折扣率(百分比)
Description string // 描述信息
TenantId string // 租户ID
CreatedDept string // 创建部门
CreatedBy string // 创建者
CreatedAt string // 创建时间
UpdatedBy string // 更新者
UpdatedAt string // 更新时间
}
// tSimAgentPacketColumns holds the columns for the table t_sim_agent_packet.
var tSimAgentPacketColumns = TSimAgentPacketColumns{
Id: "id",
PackageKey: "package_key",
PacketKey: "packet_key",
PacketName: "packet_name",
ValidityPeriod: "validity_period",
PacketStatus: "packet_status",
DiscountPercent: "discount_percent",
Description: "description",
TenantId: "tenant_id",
CreatedDept: "created_dept",
CreatedBy: "created_by",
CreatedAt: "created_at",
UpdatedBy: "updated_by",
UpdatedAt: "updated_at",
}
// NewTSimAgentPacketDao creates and returns a new DAO object for table data access.
func NewTSimAgentPacketDao(handlers ...gdb.ModelHandler) *TSimAgentPacketDao {
return &TSimAgentPacketDao{
group: "default",
table: "t_sim_agent_packet",
columns: tSimAgentPacketColumns,
handlers: handlers,
}
}
// DB retrieves and returns the underlying raw database management object of the current DAO.
func (dao *TSimAgentPacketDao) DB() gdb.DB {
return g.DB(dao.group)
}
// Table returns the table name of the current DAO.
func (dao *TSimAgentPacketDao) Table() string {
return dao.table
}
// Columns returns all column names of the current DAO.
func (dao *TSimAgentPacketDao) Columns() TSimAgentPacketColumns {
return dao.columns
}
// Group returns the database configuration group name of the current DAO.
func (dao *TSimAgentPacketDao) Group() string {
return dao.group
}
// Ctx creates and returns a Model for the current DAO. It automatically sets the context for the current operation.
func (dao *TSimAgentPacketDao) Ctx(ctx context.Context) *gdb.Model {
model := dao.DB().Model(dao.table)
for _, handler := range dao.handlers {
model = handler(model)
}
return model.Safe().Ctx(ctx)
}
// Transaction wraps the transaction logic using function f.
// It rolls back the transaction and returns the error if function f returns a non-nil error.
// It commits the transaction and returns nil if function f returns nil.
//
// Note: Do not commit or roll back the transaction in function f,
// as it is automatically handled by this function.
func (dao *TSimAgentPacketDao) Transaction(ctx context.Context, f func(ctx context.Context, tx gdb.TX) error) (err error) {
return dao.Ctx(ctx).Transaction(ctx, f)
}
@@ -0,0 +1,193 @@
// ==========================================================================
// Code generated and maintained by GoFrame CLI tool. DO NOT EDIT.
// ==========================================================================
package internal
import (
"context"
"github.com/gogf/gf/v2/database/gdb"
"github.com/gogf/gf/v2/frame/g"
)
// TSimCardInfoDao is the data access object for the table t_sim_card_info.
type TSimCardInfoDao struct {
table string // table is the underlying table name of the DAO.
group string // group is the database configuration group name of the current DAO.
columns TSimCardInfoColumns // columns contains all the column names of Table for convenient usage.
handlers []gdb.ModelHandler // handlers for customized model modification.
}
// TSimCardInfoColumns defines and stores column names for the table t_sim_card_info.
type TSimCardInfoColumns struct {
Id string // ID
Msisdn string // SIM号码
Iccid string // SIM卡号
Imsi string // SIM识别码
Imei string // IMEI
OpenDate string // 开卡日期(运营商)
ActivateDate string // 激活时间(运营商)
AllocationBatch string // 划拨批次
AllocationDate string // 划拨日期
ChanKey string // 通道编号
IsPool string // 是否流量池(0:否,1:是)
PoolKey string // 流量池编号
BatchKey string // 入库批次
BatchDate string // 入库日期
PackageKey string // 套餐分类编码
CurrPacketKey string // 当前套餐编码
AllowPacketChange string // 是否允许更改套餐(0:否 1:是)
CardStatus string // 卡状态
ValidityPeriod string // 有效期(月份)
ExpiryDate string // 套餐到期日期
UpstreamExpiryDate string // 上游到期时间(运营商)
IpType string // IP版本(人工录入)
NetModel string // 网络模式(人工录入)
RegionCode string // 行政区域代码(省市)
HasOffNet string // 是否开启达量断网
OffNetQuota string // 达量断网阈值(MB)
OffNetExpiryDate string // 达量断网过期时间
AgentShowField string // 客户展示字段设置
AgentShowValue string // 客户展示字段数据
HasTestPeriod string // 是否有测试期(0:无,1:有)
TestPeriod string // 测试期(天)
TestPeriodExpiryDate string // 测试期过期日期
HasSilentPeriod string // 是否有沉默期(0:无,1:有)
SilentPeriod string // 沉默期(月)
SilentPeriodExpiryDate string // 沉默期过期日期
HasAdditional string // 是否有附加费用(0:无 1:有)
AdditionalMultiplier string // 附加费用倍数
AdditionalAmount string // 附加金额
DataService string // 数据服务(0:关闭 1:开启)
DataTotalAmount string // 套餐流量(MB)
DataUseAmount string // 已用流量(MB)
SmsService string // 短信服务(0:关闭 1:开启)
SmsTotalAmount string // 套餐短信数量
SmsUseAmount string // 已用短信数量
VoiceService string // 语音服务(0:关闭 1:开启)
VoiceTotalAmount string // 套餐通话分钟数
VoiceUseAmount string // 已用通话分钟数
IntlRoamingService string // 国际漫游服务(0:关闭 1:开启)
IntlRoamingTotalAmount string // 套餐国际漫游流量(MB)
IntlRoamingUseAmount string // 已用国际漫游流量(MB)
Description string // 描述信息
DeptId string // 所属机构
TenantId string // 租户ID
CreatedDept string // 创建部门
CreatedBy string // 创建者
CreatedAt string // 创建时间
UpdatedBy string // 更新者
UpdatedAt string // 更新时间
}
// tSimCardInfoColumns holds the columns for the table t_sim_card_info.
var tSimCardInfoColumns = TSimCardInfoColumns{
Id: "id",
Msisdn: "msisdn",
Iccid: "iccid",
Imsi: "imsi",
Imei: "imei",
OpenDate: "open_date",
ActivateDate: "activate_date",
AllocationBatch: "allocation_batch",
AllocationDate: "allocation_date",
ChanKey: "chan_key",
IsPool: "is_pool",
PoolKey: "pool_key",
BatchKey: "batch_key",
BatchDate: "batch_date",
PackageKey: "package_key",
CurrPacketKey: "curr_packet_key",
AllowPacketChange: "allow_packet_change",
CardStatus: "card_status",
ValidityPeriod: "validity_period",
ExpiryDate: "expiry_date",
UpstreamExpiryDate: "upstream_expiry_date",
IpType: "ip_type",
NetModel: "net_model",
RegionCode: "region_code",
HasOffNet: "has_off_net",
OffNetQuota: "off_net_quota",
OffNetExpiryDate: "off_net_expiry_date",
AgentShowField: "agent_show_field",
AgentShowValue: "agent_show_value",
HasTestPeriod: "has_test_period",
TestPeriod: "test_period",
TestPeriodExpiryDate: "test_period_expiry_date",
HasSilentPeriod: "has_silent_period",
SilentPeriod: "silent_period",
SilentPeriodExpiryDate: "silent_period_expiry_date",
HasAdditional: "has_additional",
AdditionalMultiplier: "additional_multiplier",
AdditionalAmount: "additional_amount",
DataService: "data_service",
DataTotalAmount: "data_total_amount",
DataUseAmount: "data_use_amount",
SmsService: "sms_service",
SmsTotalAmount: "sms_total_amount",
SmsUseAmount: "sms_use_amount",
VoiceService: "voice_service",
VoiceTotalAmount: "voice_total_amount",
VoiceUseAmount: "voice_use_amount",
IntlRoamingService: "intl_roaming_service",
IntlRoamingTotalAmount: "intl_roaming_total_amount",
IntlRoamingUseAmount: "intl_roaming_use_amount",
Description: "description",
DeptId: "dept_id",
TenantId: "tenant_id",
CreatedDept: "created_dept",
CreatedBy: "created_by",
CreatedAt: "created_at",
UpdatedBy: "updated_by",
UpdatedAt: "updated_at",
}
// NewTSimCardInfoDao creates and returns a new DAO object for table data access.
func NewTSimCardInfoDao(handlers ...gdb.ModelHandler) *TSimCardInfoDao {
return &TSimCardInfoDao{
group: "default",
table: "t_sim_card_info",
columns: tSimCardInfoColumns,
handlers: handlers,
}
}
// DB retrieves and returns the underlying raw database management object of the current DAO.
func (dao *TSimCardInfoDao) DB() gdb.DB {
return g.DB(dao.group)
}
// Table returns the table name of the current DAO.
func (dao *TSimCardInfoDao) Table() string {
return dao.table
}
// Columns returns all column names of the current DAO.
func (dao *TSimCardInfoDao) Columns() TSimCardInfoColumns {
return dao.columns
}
// Group returns the database configuration group name of the current DAO.
func (dao *TSimCardInfoDao) Group() string {
return dao.group
}
// Ctx creates and returns a Model for the current DAO. It automatically sets the context for the current operation.
func (dao *TSimCardInfoDao) Ctx(ctx context.Context) *gdb.Model {
model := dao.DB().Model(dao.table)
for _, handler := range dao.handlers {
model = handler(model)
}
return model.Safe().Ctx(ctx)
}
// Transaction wraps the transaction logic using function f.
// It rolls back the transaction and returns the error if function f returns a non-nil error.
// It commits the transaction and returns nil if function f returns nil.
//
// Note: Do not commit or roll back the transaction in function f,
// as it is automatically handled by this function.
func (dao *TSimCardInfoDao) Transaction(ctx context.Context, f func(ctx context.Context, tx gdb.TX) error) (err error) {
return dao.Ctx(ctx).Transaction(ctx, f)
}
@@ -0,0 +1,101 @@
// ==========================================================================
// Code generated and maintained by GoFrame CLI tool. DO NOT EDIT.
// ==========================================================================
package internal
import (
"context"
"github.com/gogf/gf/v2/database/gdb"
"github.com/gogf/gf/v2/frame/g"
)
// TSimChannelTemplateDao is the data access object for the table t_sim_channel_template.
type TSimChannelTemplateDao struct {
table string // table is the underlying table name of the DAO.
group string // group is the database configuration group name of the current DAO.
columns TSimChannelTemplateColumns // columns contains all the column names of Table for convenient usage.
handlers []gdb.ModelHandler // handlers for customized model modification.
}
// TSimChannelTemplateColumns defines and stores column names for the table t_sim_channel_template.
type TSimChannelTemplateColumns struct {
Id string // ID
TempKey string // 模板标识
TempName string // 模板名称
TempModel string // 模板模型
TempConfig string // 模板配置
TempStatus string // 模板状态(0未启用,1启用)
TenantId string // 租户ID
CreatedDept string // 创建部门
CreatedBy string // 创建者
CreatedAt string // 创建时间
UpdatedBy string // 更新者
UpdatedAt string // 更新时间
}
// tSimChannelTemplateColumns holds the columns for the table t_sim_channel_template.
var tSimChannelTemplateColumns = TSimChannelTemplateColumns{
Id: "id",
TempKey: "temp_key",
TempName: "temp_name",
TempModel: "temp_model",
TempConfig: "temp_config",
TempStatus: "temp_status",
TenantId: "tenant_id",
CreatedDept: "created_dept",
CreatedBy: "created_by",
CreatedAt: "created_at",
UpdatedBy: "updated_by",
UpdatedAt: "updated_at",
}
// NewTSimChannelTemplateDao creates and returns a new DAO object for table data access.
func NewTSimChannelTemplateDao(handlers ...gdb.ModelHandler) *TSimChannelTemplateDao {
return &TSimChannelTemplateDao{
group: "default",
table: "t_sim_channel_template",
columns: tSimChannelTemplateColumns,
handlers: handlers,
}
}
// DB retrieves and returns the underlying raw database management object of the current DAO.
func (dao *TSimChannelTemplateDao) DB() gdb.DB {
return g.DB(dao.group)
}
// Table returns the table name of the current DAO.
func (dao *TSimChannelTemplateDao) Table() string {
return dao.table
}
// Columns returns all column names of the current DAO.
func (dao *TSimChannelTemplateDao) Columns() TSimChannelTemplateColumns {
return dao.columns
}
// Group returns the database configuration group name of the current DAO.
func (dao *TSimChannelTemplateDao) Group() string {
return dao.group
}
// Ctx creates and returns a Model for the current DAO. It automatically sets the context for the current operation.
func (dao *TSimChannelTemplateDao) Ctx(ctx context.Context) *gdb.Model {
model := dao.DB().Model(dao.table)
for _, handler := range dao.handlers {
model = handler(model)
}
return model.Safe().Ctx(ctx)
}
// Transaction wraps the transaction logic using function f.
// It rolls back the transaction and returns the error if function f returns a non-nil error.
// It commits the transaction and returns nil if function f returns nil.
//
// Note: Do not commit or roll back the transaction in function f,
// as it is automatically handled by this function.
func (dao *TSimChannelTemplateDao) Transaction(ctx context.Context, f func(ctx context.Context, tx gdb.TX) error) (err error) {
return dao.Ctx(ctx).Transaction(ctx, f)
}
@@ -0,0 +1,117 @@
// ==========================================================================
// Code generated and maintained by GoFrame CLI tool. DO NOT EDIT.
// ==========================================================================
package internal
import (
"context"
"github.com/gogf/gf/v2/database/gdb"
"github.com/gogf/gf/v2/frame/g"
)
// TSimChannelsDao is the data access object for the table t_sim_channels.
type TSimChannelsDao struct {
table string // table is the underlying table name of the DAO.
group string // group is the database configuration group name of the current DAO.
columns TSimChannelsColumns // columns contains all the column names of Table for convenient usage.
handlers []gdb.ModelHandler // handlers for customized model modification.
}
// TSimChannelsColumns defines and stores column names for the table t_sim_channels.
type TSimChannelsColumns struct {
Id string // ID
TempKey string // 模板标识
ChanKey string // 通道编号
ChanName string // 通道名称
ChanConfig string // 通道配置
ChanStatus string // 通道状态(0未启用,1启用)
ChanAlias string // 通道别名
RegionCode string // 通道行政区域代码(省市)
AppKey string // 应用标识
AppSecret string // 应用密钥
AccessToken string // 接口调用凭证
AccessTokenCreated string // 凭证申请时间
AccessTokenExpire string // 凭证过期时间
RefreshToken string // 刷新凭证
TenantId string // 租户ID
CreatedDept string // 创建部门
CreatedBy string // 创建者
CreatedAt string // 创建时间
UpdatedBy string // 更新者
UpdatedAt string // 更新时间
}
// tSimChannelsColumns holds the columns for the table t_sim_channels.
var tSimChannelsColumns = TSimChannelsColumns{
Id: "id",
TempKey: "temp_key",
ChanKey: "chan_key",
ChanName: "chan_name",
ChanConfig: "chan_config",
ChanStatus: "chan_status",
ChanAlias: "chan_alias",
RegionCode: "region_code",
AppKey: "app_key",
AppSecret: "app_secret",
AccessToken: "access_token",
AccessTokenCreated: "access_token_created",
AccessTokenExpire: "access_token_expire",
RefreshToken: "refresh_token",
TenantId: "tenant_id",
CreatedDept: "created_dept",
CreatedBy: "created_by",
CreatedAt: "created_at",
UpdatedBy: "updated_by",
UpdatedAt: "updated_at",
}
// NewTSimChannelsDao creates and returns a new DAO object for table data access.
func NewTSimChannelsDao(handlers ...gdb.ModelHandler) *TSimChannelsDao {
return &TSimChannelsDao{
group: "default",
table: "t_sim_channels",
columns: tSimChannelsColumns,
handlers: handlers,
}
}
// DB retrieves and returns the underlying raw database management object of the current DAO.
func (dao *TSimChannelsDao) DB() gdb.DB {
return g.DB(dao.group)
}
// Table returns the table name of the current DAO.
func (dao *TSimChannelsDao) Table() string {
return dao.table
}
// Columns returns all column names of the current DAO.
func (dao *TSimChannelsDao) Columns() TSimChannelsColumns {
return dao.columns
}
// Group returns the database configuration group name of the current DAO.
func (dao *TSimChannelsDao) Group() string {
return dao.group
}
// Ctx creates and returns a Model for the current DAO. It automatically sets the context for the current operation.
func (dao *TSimChannelsDao) Ctx(ctx context.Context) *gdb.Model {
model := dao.DB().Model(dao.table)
for _, handler := range dao.handlers {
model = handler(model)
}
return model.Safe().Ctx(ctx)
}
// Transaction wraps the transaction logic using function f.
// It rolls back the transaction and returns the error if function f returns a non-nil error.
// It commits the transaction and returns nil if function f returns nil.
//
// Note: Do not commit or roll back the transaction in function f,
// as it is automatically handled by this function.
func (dao *TSimChannelsDao) Transaction(ctx context.Context, f func(ctx context.Context, tx gdb.TX) error) (err error) {
return dao.Ctx(ctx).Transaction(ctx, f)
}
+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.
@@ -0,0 +1,22 @@
// =================================================================================
// This file is auto-generated by the GoFrame CLI tool. You may modify it as needed.
// =================================================================================
package dao
import (
"git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/dao/internal"
)
// tSimAgentPackageDao is the data access object for the table t_sim_agent_package.
// You can define custom methods on it to extend its functionality as needed.
type tSimAgentPackageDao struct {
*internal.TSimAgentPackageDao
}
var (
// TSimAgentPackage is a globally accessible object for table t_sim_agent_package operations.
TSimAgentPackage = tSimAgentPackageDao{internal.NewTSimAgentPackageDao()}
)
// Add your custom methods and functionality below.
@@ -0,0 +1,22 @@
// =================================================================================
// This file is auto-generated by the GoFrame CLI tool. You may modify it as needed.
// =================================================================================
package dao
import (
"git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/dao/internal"
)
// tSimAgentPacketDao is the data access object for the table t_sim_agent_packet.
// You can define custom methods on it to extend its functionality as needed.
type tSimAgentPacketDao struct {
*internal.TSimAgentPacketDao
}
var (
// TSimAgentPacket is a globally accessible object for table t_sim_agent_packet operations.
TSimAgentPacket = tSimAgentPacketDao{internal.NewTSimAgentPacketDao()}
)
// Add your custom methods and functionality below.
@@ -0,0 +1,22 @@
// =================================================================================
// This file is auto-generated by the GoFrame CLI tool. You may modify it as needed.
// =================================================================================
package dao
import (
"git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/dao/internal"
)
// tSimCardInfoDao is the data access object for the table t_sim_card_info.
// You can define custom methods on it to extend its functionality as needed.
type tSimCardInfoDao struct {
*internal.TSimCardInfoDao
}
var (
// TSimCardInfo is a globally accessible object for table t_sim_card_info operations.
TSimCardInfo = tSimCardInfoDao{internal.NewTSimCardInfoDao()}
)
// Add your custom methods and functionality below.
@@ -0,0 +1,22 @@
// =================================================================================
// This file is auto-generated by the GoFrame CLI tool. You may modify it as needed.
// =================================================================================
package dao
import (
"git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/dao/internal"
)
// tSimChannelTemplateDao is the data access object for the table t_sim_channel_template.
// You can define custom methods on it to extend its functionality as needed.
type tSimChannelTemplateDao struct {
*internal.TSimChannelTemplateDao
}
var (
// TSimChannelTemplate is a globally accessible object for table t_sim_channel_template operations.
TSimChannelTemplate = tSimChannelTemplateDao{internal.NewTSimChannelTemplateDao()}
)
// Add your custom methods and functionality below.
@@ -0,0 +1,22 @@
// =================================================================================
// This file is auto-generated by the GoFrame CLI tool. You may modify it as needed.
// =================================================================================
package dao
import (
"git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/dao/internal"
)
// tSimChannelsDao is the data access object for the table t_sim_channels.
// You can define custom methods on it to extend its functionality as needed.
type tSimChannelsDao struct {
*internal.TSimChannelsDao
}
var (
// TSimChannels is a globally accessible object for table t_sim_channels operations.
TSimChannels = tSimChannelsDao{internal.NewTSimChannelsDao()}
)
// Add your custom methods and functionality below.
@@ -0,0 +1,298 @@
package channelconfig
import (
"context"
"database/sql"
"strings"
"git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/model"
"git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/model/entity"
"git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/service"
"sync"
"time"
"git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/dao"
"github.com/gogf/gf/v2/crypto/gmd5"
"github.com/gogf/gf/v2/encoding/gjson"
"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"
"testing"
"git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/model/entity"
)
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,300 @@
package upstreamapi
import (
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"sort"
"strings"
"sync"
"time"
"unicode/utf8"
"github.com/go-sql-driver/mysql"
"github.com/gogf/gf/v2/database/gdb"
"github.com/gogf/gf/v2/frame/g"
"git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/consts"
"git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/model"
)
type apnOperationStore interface {
Claim(context.Context, string, string, string) (bool, error)
Load(context.Context, string, string) (string, string, error)
Save(context.Context, string, string, string, string) error
}
type sqlApnOperationStore struct{ db gdb.DB }
func (s sqlApnOperationStore) database() gdb.DB {
if s.db != nil {
return s.db
}
return g.DB()
}
func validateApnOperationIdentity(id, channel, hash string) error {
for _, value := range []string{id, channel} {
if len(value) == 0 || len(value) > 128 || !utf8.ValidString(value) || strings.TrimSpace(value) != value || strings.ContainsRune(value, 0) {
return fmt.Errorf("operationId和chanKey必须为1至128字节且不含首尾空白或空字符")
}
}
decoded, err := hex.DecodeString(hash)
if err != nil || len(decoded) != sha256.Size || hash != strings.ToLower(hash) {
return fmt.Errorf("APN请求摘要必须为小写SHA-256")
}
return nil
}
func (s sqlApnOperationStore) Claim(ctx context.Context, id, channel, hash string) (bool, error) {
if err := validateApnOperationIdentity(id, channel, hash); err != nil {
return false, err
}
result, err := s.database().Exec(ctx, "INSERT INTO t_apn_operation (operation_id,chan_key,request_hash,response_json,created_at,updated_at) VALUES (?,?,?,'',NOW(),NOW())", id, channel, hash)
if err != nil {
var duplicate *mysql.MySQLError
if errors.As(err, &duplicate) && duplicate.Number == 1062 {
return false, nil
}
return false, err
}
count, err := result.RowsAffected()
if err != nil {
return false, err
}
if count != 1 {
return false, fmt.Errorf("APN提交意图插入行数异常:%d", count)
}
return true, nil
}
func (s sqlApnOperationStore) Load(ctx context.Context, id, channel string) (string, string, error) {
row, err := s.database().GetOne(ctx, "SELECT request_hash,response_json FROM t_apn_operation WHERE operation_id=? AND chan_key=? AND BINARY operation_id=BINARY ? AND BINARY chan_key=BINARY ?", id, channel, id, channel)
if err != nil {
return "", "", err
}
if len(row) == 0 {
return "", "", nil
}
return row["request_hash"].String(), row["response_json"].String(), nil
}
func (s sqlApnOperationStore) Save(ctx context.Context, id, channel, hash, response string) error {
if err := validateApnOperationIdentity(id, channel, hash); err != nil {
return err
}
return s.database().Transaction(ctx, func(ctx context.Context, tx gdb.TX) error {
row, err := tx.GetOne("SELECT request_hash,response_json FROM t_apn_operation WHERE operation_id=? AND chan_key=? AND BINARY operation_id=BINARY ? AND BINARY chan_key=BINARY ? FOR UPDATE", id, channel, id, channel)
if err != nil {
return err
}
if len(row) == 0 {
return fmt.Errorf("APN受理映射不存在,禁止无意图保存")
}
if row["request_hash"].String() != hash {
return fmt.Errorf("operationId已绑定不同的办理请求")
}
merged, err := mergeApnOperationJSON(row["response_json"].String(), response)
if err != nil {
return err
}
result, err := tx.Exec("UPDATE t_apn_operation SET response_json=?,revision=revision+1,updated_at=NOW(3) WHERE operation_id=? AND chan_key=? AND request_hash=?", merged, id, channel, hash)
if err != nil {
return err
}
count, err := result.RowsAffected()
if err != nil {
return err
}
if count != 1 {
return fmt.Errorf("APN受理映射保存行数异常:%d", count)
}
return nil
})
}
// 已确认逐卡结果不回退未知,已持久化jobId不能被后续空响应或错误抹掉。
func mergeApnOperationJSON(saved, incoming string) (string, error) {
var output, updates model.SimCardOperateChangeApnOutput
if saved != "" {
if err := json.Unmarshal([]byte(saved), &output); err != nil {
return "", fmt.Errorf("解析已存APN受理映射失败:%w", err)
}
}
if incoming != "" {
if err := json.Unmarshal([]byte(incoming), &updates); err != nil {
return "", fmt.Errorf("解析待存APN受理映射失败:%w", err)
}
}
positions := make(map[model.SimKey]int)
for i, item := range output {
if item == nil || item.SimKey == nil {
return "", fmt.Errorf("已存APN受理映射缺少卡关联")
}
positions[*item.SimKey] = i
}
for _, item := range updates {
if item == nil || item.SimKey == nil {
return "", fmt.Errorf("待存APN受理映射缺少卡关联")
}
i, exists := positions[*item.SimKey]
if !exists {
positions[*item.SimKey] = len(output)
output = append(output, item)
continue
}
old := output[i]
if old.ResData.OrderNum != "" && item.ResData.OrderNum != "" && old.ResData.OrderNum != item.ResData.OrderNum {
return "", fmt.Errorf("同卡APN受理映射出现不同jobId,禁止覆盖")
}
oldTerminal := !old.ResData.Pending && !old.ResData.Uncertain
newTerminal := !item.ResData.Pending && !item.ResData.Uncertain
if oldTerminal || (!newTerminal && old.ResData.OrderNum != "") {
continue
}
if item.ResData.OrderNum == "" {
item.ResData.OrderNum = old.ResData.OrderNum
}
output[i] = item
}
body, err := json.Marshal(output)
return string(body), err
}
func unknownApnOperation(input *model.SimCardOperateChangeApnInput, reason string) model.SimCardOperateChangeApnOutput {
output := make(model.SimCardOperateChangeApnOutput, 0, len(input.SimKeys))
for _, key := range input.SimKeys {
output = append(output, &model.SimBaseRes[model.SimCardOperateChangeApnItem]{SimKey: key, Code: consts.ApiStatusError, Message: reason, ResData: model.SimCardOperateChangeApnItem{Uncertain: true}})
}
return output
}
// durableApnOperation 先落提交意图,重复投递与重启恢复均不重新办理。
func durableApnOperation(ctx context.Context, store apnOperationStore, channel string, input *model.SimCardOperateChangeApnInput, submit func(context.Context) (model.SimCardOperateChangeApnOutput, error)) (model.SimCardOperateChangeApnOutput, error) {
id := input.ReqData.OperationID
if len(input.SimKeys) == 0 || len(input.SimKeys) > 100 {
return nil, fmt.Errorf("异步APN子任务必须包含1至100张卡")
}
normalized := *input
normalized.SimKeys = append([]*model.SimKey(nil), input.SimKeys...)
for _, key := range normalized.SimKeys {
if key == nil || key.ChanKey != channel || key.ICCID == "" {
return nil, fmt.Errorf("异步APN子任务卡标识或通道归属无效")
}
}
sort.Slice(normalized.SimKeys, func(i, j int) bool {
a, b := normalized.SimKeys[i], normalized.SimKeys[j]
return a.ICCID+"|"+a.MSISDN+"|"+a.IMSI < b.ICCID+"|"+b.MSISDN+"|"+b.IMSI
})
normalized.ReqData.QueryJobID = ""
normalized.ReqData.RecoverOnly = false
normalized.ReqData.SubmitOnly = true
payload, err := json.Marshal(normalized)
if err != nil {
return nil, err
}
digest := sha256.Sum256(payload)
hash := hex.EncodeToString(digest[:])
if err := validateApnOperationIdentity(id, channel, hash); err != nil {
return nil, err
}
claimed := false
if !input.ReqData.RecoverOnly {
claimed, err = store.Claim(ctx, id, channel, hash)
if err != nil {
return nil, fmt.Errorf("保存APN提交意图失败,未调用运营商:%w", err)
}
}
if !claimed {
savedHash, saved, err := store.Load(ctx, id, channel)
if err != nil {
return nil, fmt.Errorf("读取APN受理映射失败:%w", err)
}
if savedHash == "" {
return unknownApnOperation(input, "未找到提交意图,禁止自动重新办理,请核对"), nil
}
if savedHash != hash {
return nil, fmt.Errorf("operationId已绑定不同的办理请求")
}
if saved == "" {
return unknownApnOperation(input, "已记录提交意图但受理结果未确认,禁止重复办理,请核对"), nil
}
return completeApnOperation(input, saved)
}
var checkpointMu sync.Mutex
latest := ""
checkpoint := func(partial model.SimCardOperateChangeApnOutput) error {
checkpointMu.Lock()
defer checkpointMu.Unlock()
snapshot, err := json.Marshal(partial)
if err != nil {
return err
}
merged, err := mergeApnOperationJSON(latest, string(snapshot))
if err != nil {
return err
}
latest = merged
saveCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 10*time.Second)
defer cancel()
return store.Save(saveCtx, id, channel, hash, latest)
}
submitCtx := context.WithValue(ctx, model.ApnOperationCheckpointKey{}, checkpoint)
output, submitErr := submit(submitCtx)
if submitErr != nil {
output = unknownApnOperation(input, "提交结果未确认:"+submitErr.Error())
} else if len(output) == 0 {
output = unknownApnOperation(input, "提交未返回逐卡受理结果,请核对")
}
if err := checkpoint(output); err != nil {
g.Log().Errorf(context.WithoutCancel(ctx), "APN受理结果持久化失败 operationId=%s chanKey=%s error=%v", id, channel, err)
return nil, fmt.Errorf("上游受理结果持久化失败,禁止重新办理:%w", err)
}
return completeApnOperation(input, latest)
}
func (u *sUpstreamAPI) QuerySimApnCapability(ctx context.Context, input *model.SimCardOperateChangeApnInput) (model.SimApnCapabilityOutput, error) {
if len(input.SimKeys) == 0 || len(input.SimKeys) > 100 {
return nil, fmt.Errorf("APN能力探测必须包含1至100张卡")
}
output := make(model.SimApnCapabilityOutput, 0, len(input.SimKeys))
for _, key := range input.SimKeys {
if key == nil || key.ChanKey == "" || key.ICCID == "" {
return nil, fmt.Errorf("APN能力探测缺少冻结卡标识")
}
output = append(output, &model.SimBaseRes[model.SimApnCapabilityItem]{SimKey: key, ResData: model.SimApnCapabilityItem{ProtocolVersion: 1}})
}
// 已有 jobId 只读查询运营商,不依赖本地提交意图表;提交和 operationId 恢复仍需该表。
if strings.TrimSpace(input.ReqData.QueryJobID) == "" || input.ReqData.SubmitOnly || input.ReqData.RecoverOnly {
probeCtx, cancel := context.WithTimeout(ctx, 10*time.Second)
defer cancel()
if _, err := g.DB().GetAll(probeCtx, "SELECT revision FROM t_apn_operation LIMIT 0"); err != nil {
return nil, fmt.Errorf("APN持久化迁移未就绪:%w", err)
}
}
return output, nil
}
func completeApnOperation(input *model.SimCardOperateChangeApnInput, saved string) (model.SimCardOperateChangeApnOutput, error) {
var output model.SimCardOperateChangeApnOutput
if err := json.Unmarshal([]byte(saved), &output); err != nil {
return nil, fmt.Errorf("解析已持久化受理映射失败:%w", err)
}
for _, key := range input.SimKeys {
found := false
for _, item := range output {
if item != nil && item.SimKey != nil && *item.SimKey == *key {
found = true
break
}
}
if !found {
missing := *input
missing.SimKeys = []*model.SimKey{key}
output = append(output, unknownApnOperation(&missing, "该卡未持久化受理结果,禁止重新办理,请核对")...)
}
}
return output, nil
}
@@ -0,0 +1,163 @@
package upstreamapi
import (
"context"
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
"sync"
"testing"
"time"
"github.com/go-sql-driver/mysql"
_ "github.com/gogf/gf/contrib/drivers/mysql/v2"
"github.com/gogf/gf/v2/database/gdb"
"gopkg.in/yaml.v3"
"git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/model"
)
// 仅显式授权的loopback专用实例可写;身份核验始终先于DDL和DML。
func TestApnOperationMySQLIntegration(t *testing.T) {
if os.Getenv("APN_MYSQL_INTEGRATION") != "1" {
t.Skip("未启用本机专用MySQL集成;不会连接默认业务数据库")
}
const port = "3306"
const name = "iotdb_apn_chan_test_20261015"
const expectedUUID = "4fa545af-ab36-11f1-be02-040e3c9de361"
const expectedDir = "D:/MySQL/MySQL Server 8.0/Data/"
if value := os.Getenv("APN_MYSQL_PORT"); value != "" && value != port {
t.Fatal("只允许已核实的127.0.0.1:3306")
}
if value := os.Getenv("APN_MYSQL_DATABASE"); value != "" && value != name {
t.Fatal("只允许指定chan测试库")
}
configPath := filepath.Join("..", "..", "..", "..", "iotlcm-sms", "deploy", "config.yaml")
configBody, err := os.ReadFile(configPath)
if err != nil {
t.Fatal("无法读取本机已有配置")
}
var config struct {
SceneDbDsn string `yaml:"sceneDbDsn"`
}
if err := yaml.Unmarshal(configBody, &config); err != nil {
t.Fatal("本机已有配置解析失败")
}
dsn, err := mysql.ParseDSN(config.SceneDbDsn)
if err != nil || dsn.Net != "tcp" || (dsn.Addr != "127.0.0.1:3306" && dsn.Addr != "localhost:3306") {
t.Fatal("已有配置不是已授权本机连接,禁止连接")
}
db, err := gdb.New(gdb.ConfigNode{Type: "mysql", Host: "127.0.0.1", Port: port, User: dsn.User, Pass: dsn.Passwd, Name: name})
if err != nil {
t.Fatal("建立本机测试连接失败")
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
identity, err := db.GetOne(ctx, "SELECT @@hostname AS hostname,@@port AS port,@@datadir AS datadir,@@server_uuid AS server_uuid,DATABASE() AS dbname")
if err != nil {
t.Fatal("读取本机实例身份失败")
}
normalize := func(s string) string {
return strings.ToLower(strings.TrimRight(filepath.ToSlash(strings.ReplaceAll(s, "\\", "/")), "/"))
}
if identity["port"].String() != port || identity["server_uuid"].String() != expectedUUID || identity["dbname"].String() != name || normalize(identity["datadir"].String()) != normalize(expectedDir) {
t.Fatalf("隔离实例身份不符,禁止写入:%v", identity)
}
t.Logf("本机专用库核验通过 hostname=%s port=%s uuid=%s datadir=%s database=%s", identity["hostname"], identity["port"], identity["server_uuid"], identity["datadir"], identity["dbname"])
exists, err := db.GetValue(ctx, "SELECT COUNT(*) FROM information_schema.tables WHERE table_schema=DATABASE() AND table_name='t_apn_operation'")
if err != nil {
t.Fatal(err)
}
if exists.Int() == 0 {
_, err = db.Exec(ctx, "CREATE TABLE t_apn_operation (operation_id varchar(128) NOT NULL,chan_key varchar(128) NOT NULL,request_hash char(64) NOT NULL,response_json longtext NOT NULL,created_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),updated_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),PRIMARY KEY(operation_id,chan_key)) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4")
if err != nil {
t.Fatal(err)
}
}
migration, err := os.ReadFile(filepath.Join("..", "..", "..", "..", "iotlcm-database", "SQL", "20261015_apn_operation_chan.sql"))
if err != nil {
t.Fatal(err)
}
master, err := db.Master()
if err != nil {
t.Fatal(err)
}
connection, err := master.Conn(ctx)
if err != nil {
t.Fatal(err)
}
defer connection.Close()
for repeat := 0; repeat < 2; repeat++ {
for _, statement := range strings.Split(string(migration), ";") {
if strings.TrimSpace(statement) == "" {
continue
}
if _, err := connection.ExecContext(ctx, statement); err != nil {
t.Fatalf("迁移执行失败:%v", err)
}
}
}
store := sqlApnOperationStore{db: db}
id := fmt.Sprintf("integration-%d", time.Now().UnixNano())
channel := "isolated-test-channel"
hash := strings.Repeat("a", 64)
claimed, err := store.Claim(ctx, id, channel, hash)
if err != nil || !claimed {
t.Fatalf("Claim失败:%v %v", claimed, err)
}
t.Cleanup(func() {
_, _ = db.Exec(context.Background(), "DELETE FROM t_apn_operation WHERE operation_id=? AND chan_key=?", id, channel)
})
if claimed, err = store.Claim(ctx, id, channel, hash); err != nil || claimed {
t.Fatalf("重复Claim未幂等:%v %v", claimed, err)
}
if err := store.Save(ctx, id+"-missing", channel, hash, "[]"); err == nil {
t.Fatal("不存在的映射保存必须失败")
}
if err := store.Save(ctx, id, channel, strings.Repeat("b", 64), "[]"); err == nil {
t.Fatal("不同摘要不得覆盖")
}
if claimed, err = store.Claim(ctx, strings.Repeat("x", 129), channel, hash); err == nil || claimed {
t.Fatal("非法ID不得被INSERT忽略为成功")
}
var wg sync.WaitGroup
errors := make(chan error, 10)
for i := 0; i < 10; i++ {
wg.Add(1)
go func(i int) {
defer wg.Done()
key := &model.SimKey{ChanKey: channel, ICCID: fmt.Sprintf("fake-%d", i)}
body, _ := json.Marshal(model.SimCardOperateChangeApnOutput{&model.SimBaseRes[model.SimCardOperateChangeApnItem]{SimKey: key, ResData: model.SimCardOperateChangeApnItem{Pending: true, OrderNum: fmt.Sprintf("fake-job-%d", i)}}})
errors <- store.Save(ctx, id, channel, hash, string(body))
}(i)
}
wg.Wait()
close(errors)
for err := range errors {
if err != nil {
t.Fatal(err)
}
}
_, saved, err := store.Load(ctx, id, channel)
var output model.SimCardOperateChangeApnOutput
if err != nil || json.Unmarshal([]byte(saved), &output) != nil || len(output) != 10 {
t.Fatalf("并发保存丢失卡映射:%s %v", saved, err)
}
unknown := &model.SimCardOperateChangeApnInput{SimKeys: []*model.SimKey{output[0].SimKey}}
body, _ := json.Marshal(unknownApnOperation(unknown, "模拟checkpoint后错误"))
if err := store.Save(ctx, id, channel, hash, string(body)); err != nil {
t.Fatal(err)
}
_, merged, err := store.Load(ctx, id, channel)
if err != nil || merged != saved {
t.Fatalf("已存jobId被unknown覆盖:%s %v", merged, err)
}
if err := store.Save(ctx, id, channel, hash, "[]"); err != nil {
t.Fatal(err)
}
row, err := db.GetOne(ctx, "SELECT revision FROM t_apn_operation WHERE operation_id=? AND chan_key=?", id, channel)
if err != nil || row["revision"].Int() != 12 {
t.Fatalf("每次保存必须影响一行并递增revision:%v %v", row, err)
}
}
@@ -0,0 +1,376 @@
package upstreamapi
import (
"context"
"crypto/sha256"
"database/sql"
"encoding/hex"
"encoding/json"
"errors"
"strings"
"sync"
"testing"
"github.com/gogf/gf/v2/container/gvar"
"github.com/gogf/gf/v2/database/gdb"
"git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/model"
)
type memoryApnOperationStore struct {
mu sync.Mutex
rows map[string][2]string
failSave bool
}
func (s *memoryApnOperationStore) Claim(_ context.Context, id, ch, hash string) (bool, error) {
s.mu.Lock()
defer s.mu.Unlock()
k := id + ch
if _, ok := s.rows[k]; ok {
return false, nil
}
s.rows[k] = [2]string{hash, ""}
return true, nil
}
func (s *memoryApnOperationStore) Load(_ context.Context, id, ch string) (string, string, error) {
s.mu.Lock()
defer s.mu.Unlock()
r := s.rows[id+ch]
return r[0], r[1], nil
}
func (s *memoryApnOperationStore) Save(ctx context.Context, id, ch, hash, body string) error {
if err := ctx.Err(); err != nil {
return err
}
s.mu.Lock()
defer s.mu.Unlock()
if s.failSave {
return errors.New("模拟磁盘失败")
}
r, exists := s.rows[id+ch]
if !exists || r[0] != hash {
return errors.New("意图不存在或摘要不符")
}
merged, err := mergeApnOperationJSON(r[1], body)
if err != nil {
return err
}
r[1] = merged
s.rows[id+ch] = r
return nil
}
func operationTestInput() *model.SimCardOperateChangeApnInput {
return &model.SimCardOperateChangeApnInput{SimKeys: []*model.SimKey{{ICCID: "test", ChanKey: "channel"}}, ReqData: model.SimCardOperateChangeApnInputItem{OperationID: "stable-operation", SubmitOnly: true, OperType: "1"}}
}
func acceptedOperation(input *model.SimCardOperateChangeApnInput) model.SimCardOperateChangeApnOutput {
return model.SimCardOperateChangeApnOutput{&model.SimBaseRes[model.SimCardOperateChangeApnItem]{SimKey: input.SimKeys[0], ResData: model.SimCardOperateChangeApnItem{OrderNum: "saved-job", Pending: true}}}
}
func TestApnCapabilityExistingJobNeedsNoIntentDatabase(t *testing.T) {
input := operationTestInput()
input.ReqData.SubmitOnly = false
input.ReqData.QueryJobID = "existing-job"
// 此包未配置数据库:已有 jobId 的只读协议探测应仍然返回能力。
output, err := (&sUpstreamAPI{}).QuerySimApnCapability(context.Background(), input)
if err != nil || len(output) != 1 || output[0].ResData.ProtocolVersion != 1 || *output[0].SimKey != *input.SimKeys[0] {
t.Fatalf("已有任务的只读协议探测依赖了提交意图表: output=%+v err=%v", output, err)
}
input.SimKeys = []*model.SimKey{nil}
if _, err := (&sUpstreamAPI{}).QuerySimApnCapability(context.Background(), input); err == nil {
t.Fatal("已有jobId也必须校验冻结卡标识")
}
}
func TestApnOperationResponseLossAndRestartRecovery(t *testing.T) {
store := &memoryApnOperationStore{rows: map[string][2]string{}}
input := operationTestInput()
calls := 0
submit := func(context.Context) (model.SimCardOperateChangeApnOutput, error) {
calls++
return acceptedOperation(input), nil
}
if _, err := durableApnOperation(context.Background(), store, "channel", input, submit); err != nil {
t.Fatal(err)
}
// 丢弃首次响应后,另一次调用只读受理映射,不访问运营商。
input.ReqData.RecoverOnly = true
recovered, err := durableApnOperation(context.Background(), store, "channel", input, submit)
if err != nil || calls != 1 || recovered[0].ResData.OrderNum != "saved-job" {
t.Fatalf("恢复失败 calls=%d result=%+v err=%v", calls, recovered, err)
}
input.ReqData.RecoverOnly = false
if _, err = durableApnOperation(context.Background(), store, "channel", input, submit); err != nil || calls != 1 {
t.Fatalf("重复投递重新办理 calls=%d err=%v", calls, err)
}
input.ReqData.OperType = "0"
if _, err = durableApnOperation(context.Background(), store, "channel", input, submit); err == nil {
t.Fatal("相同ID不得绑定不同请求")
}
}
func TestApnOperationConcurrentSubmitOnlyCallsCarrierOnce(t *testing.T) {
store := &memoryApnOperationStore{rows: map[string][2]string{}}
input := operationTestInput()
started, release, done := make(chan struct{}), make(chan struct{}), make(chan error, 1)
go func() {
_, err := durableApnOperation(context.Background(), store, "channel", input, func(context.Context) (model.SimCardOperateChangeApnOutput, error) {
close(started)
<-release
return acceptedOperation(input), nil
})
done <- err
}()
<-started
duplicate, err := durableApnOperation(context.Background(), store, "channel", input, func(context.Context) (model.SimCardOperateChangeApnOutput, error) {
t.Error("并发重复operationId不得重新办理")
return nil, nil
})
close(release)
if err != nil || len(duplicate) != 1 || !duplicate[0].ResData.Uncertain {
t.Fatalf("并发处理中应标待核对: %v %+v", err, duplicate)
}
if err := <-done; err != nil {
t.Fatal(err)
}
}
func TestApnOperationRecoveryRejectsDifferentPayload(t *testing.T) {
store := &memoryApnOperationStore{rows: map[string][2]string{}}
input := operationTestInput()
_, err := durableApnOperation(context.Background(), store, "channel", input, func(context.Context) (model.SimCardOperateChangeApnOutput, error) {
return acceptedOperation(input), nil
})
if err != nil {
t.Fatal(err)
}
input.ReqData.RecoverOnly = true
input.ReqData.OperType = "0"
if _, err = durableApnOperation(context.Background(), store, "channel", input, nil); err == nil {
t.Fatal("恢复请求不得读取其他操作的受理映射")
}
}
func TestApnOperationIntentWithoutResponseNeverResubmits(t *testing.T) {
store := &memoryApnOperationStore{rows: map[string][2]string{}}
input := operationTestInput()
payload, _ := json.Marshal(input)
digest := sha256.Sum256(payload)
_, _ = store.Claim(context.Background(), input.ReqData.OperationID, "channel", hex.EncodeToString(digest[:]))
input.ReqData.RecoverOnly = true
calls := 0
output, err := durableApnOperation(context.Background(), store, "channel", input, func(context.Context) (model.SimCardOperateChangeApnOutput, error) { calls++; return nil, nil })
if err != nil || calls != 0 || !output[0].ResData.Uncertain {
t.Fatalf("未确认意图被重新提交:calls=%d err=%v", calls, err)
}
}
func TestApnOperationPersistenceFailureNeverResubmits(t *testing.T) {
store := &memoryApnOperationStore{rows: map[string][2]string{}, failSave: true}
input := operationTestInput()
calls := 0
submit := func(context.Context) (model.SimCardOperateChangeApnOutput, error) {
calls++
return acceptedOperation(input), nil
}
if _, err := durableApnOperation(context.Background(), store, "channel", input, submit); err == nil {
t.Fatal("持久化失败不得返回已可靠保存")
}
store.failSave = false
input.ReqData.RecoverOnly = true
output, err := durableApnOperation(context.Background(), store, "channel", input, submit)
if err != nil || calls != 1 || !output[0].ResData.Uncertain {
t.Fatalf("受理映射失败后不得重复办理:calls=%d err=%v", calls, err)
}
}
func TestApnOperationClientCancellationDoesNotLoseJob(t *testing.T) {
store := &memoryApnOperationStore{rows: map[string][2]string{}}
input := operationTestInput()
ctx, cancel := context.WithCancel(context.Background())
output, err := durableApnOperation(ctx, store, "channel", input, func(context.Context) (model.SimCardOperateChangeApnOutput, error) {
cancel()
return acceptedOperation(input), nil
})
if err != nil || output[0].ResData.OrderNum != "saved-job" {
t.Fatalf("客户端取消导致jobId丢失:%v", err)
}
_, saved, _ := store.Load(context.Background(), input.ReqData.OperationID, "channel")
if saved == "" {
t.Fatal("jobId未落库")
}
}
type affectedApnTestDB struct {
gdb.DB
tx gdb.TX
}
func (d affectedApnTestDB) Transaction(ctx context.Context, f func(context.Context, gdb.TX) error) error {
return f(ctx, d.tx)
}
type affectedApnTestTX struct {
gdb.TX
hash string
rows int64
}
func (tx affectedApnTestTX) GetOne(string, ...any) (gdb.Record, error) {
return gdb.Record{"request_hash": gvar.New(tx.hash), "response_json": gvar.New("")}, nil
}
func (tx affectedApnTestTX) Exec(string, ...any) (sql.Result, error) {
return affectedApnTestResult(tx.rows), nil
}
type affectedApnTestResult int64
func (r affectedApnTestResult) LastInsertId() (int64, error) { return 0, nil }
func (r affectedApnTestResult) RowsAffected() (int64, error) { return int64(r), nil }
func TestApnOperationSQLSaveRejectsAffectedZeroAndHashMismatch(t *testing.T) {
hash := "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
for _, tc := range []struct {
rows int64
hash string
wantError bool
}{{0, hash, true}, {1, "wrong-hash", true}, {1, hash, false}} {
store := sqlApnOperationStore{db: affectedApnTestDB{tx: affectedApnTestTX{hash: tc.hash, rows: tc.rows}}}
err := store.Save(context.Background(), "test-operation", "test-channel", hash, "[]")
if (err != nil) != tc.wantError {
t.Fatalf("rows=%d hash=%s err=%v", tc.rows, tc.hash, err)
}
}
}
type retryCheckpointApnStore struct {
memoryApnOperationStore
failed bool
}
func (s *retryCheckpointApnStore) Save(ctx context.Context, id, channel, hash, body string) error {
if !s.failed {
s.failed = true
return errors.New("模拟首次checkpoint磁盘故障")
}
return s.memoryApnOperationStore.Save(ctx, id, channel, hash, body)
}
func TestApnOperationFailedCheckpointRetriesKnownJobNotUnknown(t *testing.T) {
store := &retryCheckpointApnStore{memoryApnOperationStore: memoryApnOperationStore{rows: map[string][2]string{}}}
input := operationTestInput()
output, err := durableApnOperation(context.Background(), store, "channel", input, func(ctx context.Context) (model.SimCardOperateChangeApnOutput, error) {
checkpoint := ctx.Value(model.ApnOperationCheckpointKey{}).(func(model.SimCardOperateChangeApnOutput) error)
if err := checkpoint(acceptedOperation(input)); err == nil {
t.Fatal("应模拟checkpoint失败")
}
return nil, errors.New("受理后响应断开")
})
if err != nil || output[0].ResData.OrderNum != "saved-job" || !output[0].ResData.Pending {
t.Fatalf("保存重试丢掉已取得jobId:%+v %v", output, err)
}
_, saved, _ := store.Load(context.Background(), input.ReqData.OperationID, "channel")
if !strings.Contains(saved, "saved-job") {
t.Fatalf("重试未可靠保存jobId:%s", saved)
}
}
func TestApnOperationCheckpointCannotBeErased(t *testing.T) {
for _, failed := range []bool{false, true} {
store := &memoryApnOperationStore{rows: map[string][2]string{}}
input := operationTestInput()
output, err := durableApnOperation(context.Background(), store, "channel", input, func(ctx context.Context) (model.SimCardOperateChangeApnOutput, error) {
checkpoint := ctx.Value(model.ApnOperationCheckpointKey{}).(func(model.SimCardOperateChangeApnOutput) error)
if err := checkpoint(acceptedOperation(input)); err != nil {
t.Fatal(err)
}
if failed {
return nil, errors.New("模拟受理后请求失败")
}
return nil, nil
})
if err != nil || len(output) != 1 || output[0].ResData.OrderNum != "saved-job" || !output[0].ResData.Pending {
t.Fatalf("后续空响应或错误抹掉jobId:%+v %v", output, err)
}
input.ReqData.RecoverOnly = true
output, err = durableApnOperation(context.Background(), store, "channel", input, nil)
if err != nil || output[0].ResData.OrderNum != "saved-job" {
t.Fatalf("持久化映射已被回退:%+v %v", output, err)
}
}
}
func TestApnOperationMergePreservesTerminalAndCardAssociation(t *testing.T) {
input := operationTestInput()
terminal := acceptedOperation(input)
terminal[0].ResData.Pending = false
terminal[0].Message = "明确成功"
body, _ := json.Marshal(terminal)
unknown, _ := json.Marshal(unknownApnOperation(input, "后来的未知结果"))
merged, err := mergeApnOperationJSON(string(body), string(unknown))
if err != nil || merged != string(body) {
t.Fatalf("逐卡终态被未知覆盖:%s %v", merged, err)
}
second := *input.SimKeys[0]
second.ICCID = "second"
other, _ := json.Marshal(model.SimCardOperateChangeApnOutput{&model.SimBaseRes[model.SimCardOperateChangeApnItem]{SimKey: &second, ResData: model.SimCardOperateChangeApnItem{Pending: true, OrderNum: "second-job"}}})
merged, err = mergeApnOperationJSON(merged, string(other))
var results model.SimCardOperateChangeApnOutput
_ = json.Unmarshal([]byte(merged), &results)
if err != nil || len(results) != 2 || results[0].SimKey.ICCID != "test" || results[1].SimKey.ICCID != "second" {
t.Fatalf("逐卡合并丢失卡关联:%s %v", merged, err)
}
}
type failingClaimApnStore struct{ memoryApnOperationStore }
func (s *failingClaimApnStore) Claim(context.Context, string, string, string) (bool, error) {
return false, errors.New("模拟INSERT失败")
}
func TestApnOperationClaimFailureNeverCallsCarrier(t *testing.T) {
called := false
_, err := durableApnOperation(context.Background(), &failingClaimApnStore{}, "channel", operationTestInput(), func(context.Context) (model.SimCardOperateChangeApnOutput, error) {
called = true
return nil, nil
})
if err == nil || called {
t.Fatalf("意图INSERT失败仍调用运营商:called=%v err=%v", called, err)
}
}
func TestApnOperationExplicitFailureWinsPendingAndUnknown(t *testing.T) {
input := operationTestInput()
pending, _ := json.Marshal(acceptedOperation(input))
failure := model.SimCardOperateChangeApnOutput{&model.SimBaseRes[model.SimCardOperateChangeApnItem]{SimKey: input.SimKeys[0], Code: 9999, Message: "运营商明确拒绝"}}
failed, _ := json.Marshal(failure)
merged, err := mergeApnOperationJSON(string(pending), string(failed))
if err != nil {
t.Fatal(err)
}
unknown, _ := json.Marshal(unknownApnOperation(input, "后到的通信错误"))
merged, err = mergeApnOperationJSON(merged, string(unknown))
var output model.SimCardOperateChangeApnOutput
if err != nil || json.Unmarshal([]byte(merged), &output) != nil || len(output) != 1 || output[0].Code != 9999 || output[0].Message != "运营商明确拒绝" || output[0].ResData.Pending || output[0].ResData.Uncertain || output[0].ResData.OrderNum != "saved-job" {
t.Fatalf("明确失败被pending或未知覆盖:%s %v", merged, err)
}
}
func TestApnOperationCheckpointSurvivesCrashBeforeReturn(t *testing.T) {
store := &memoryApnOperationStore{rows: map[string][2]string{}}
input := operationTestInput()
func() {
defer func() { _ = recover() }()
_, _ = durableApnOperation(context.Background(), store, "channel", input, func(ctx context.Context) (model.SimCardOperateChangeApnOutput, error) {
checkpoint := ctx.Value(model.ApnOperationCheckpointKey{}).(func(model.SimCardOperateChangeApnOutput) error)
if err := checkpoint(acceptedOperation(input)); err != nil {
t.Fatal(err)
}
panic("模拟拿jobId后进程中断")
})
}()
input.ReqData.RecoverOnly = true
output, err := durableApnOperation(context.Background(), store, "channel", input, func(context.Context) (model.SimCardOperateChangeApnOutput, error) {
t.Fatal("恢复不得重新办理")
return nil, nil
})
if err != nil || output[0].ResData.OrderNum != "saved-job" {
t.Fatalf("checkpoint未恢复:%+v %v", output, err)
}
}
@@ -0,0 +1,17 @@
package upstreamapi
import "testing"
func TestICCIDFromChannelCacheKey(t *testing.T) {
tests := []struct{ key, want string }{
{"sim:chan:iccid:89860000000000000004", "89860000000000000004"},
{"sim:chan:iccid:8986000000000000007X", "8986000000000000007X"},
{"sim:chan:iccid:", ""},
{"unrelated:89860000000000000004", ""},
}
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"
"github.com/gogf/gf/v2/errors/gerror"
"github.com/gogf/gf/v2/frame/g"
"git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/consts"
"git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/model"
"git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/service"
)
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,229 @@
package upstreamapi
import (
"context"
"os"
"os/exec"
"path/filepath"
"testing"
_ "github.com/gogf/gf/contrib/drivers/sqlite/v2"
"github.com/gogf/gf/v2/container/gvar"
"github.com/gogf/gf/v2/database/gdb"
"github.com/gogf/gf/v2/database/gredis"
"github.com/gogf/gf/v2/frame/g"
"git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/model"
)
type packChanKeyRedis struct {
gredis.Adapter
gredis.IGroupString
values map[string]string
mgetCalls int
setKeys []string
}
func (r *packChanKeyRedis) GroupGeneric() gredis.IGroupGeneric { return nil }
func (r *packChanKeyRedis) GroupHash() gredis.IGroupHash { return nil }
func (r *packChanKeyRedis) GroupList() gredis.IGroupList { return nil }
func (r *packChanKeyRedis) GroupPubSub() gredis.IGroupPubSub { return nil }
func (r *packChanKeyRedis) GroupScript() gredis.IGroupScript { return nil }
func (r *packChanKeyRedis) GroupSet() gredis.IGroupSet { return nil }
func (r *packChanKeyRedis) GroupSortedSet() gredis.IGroupSortedSet { return nil }
func (r *packChanKeyRedis) GroupString() gredis.IGroupString { return r }
func (r *packChanKeyRedis) MGet(_ context.Context, keys ...string) (map[string]*gvar.Var, error) {
r.mgetCalls++
values := make(map[string]*gvar.Var, len(keys))
for _, key := range keys {
values[key] = gvar.New(r.values[key])
}
return values, nil
}
func (r *packChanKeyRedis) Set(_ context.Context, key string, value any, _ ...gredis.SetOption) (*gvar.Var, error) {
r.values[key] = gvar.New(value).String()
r.setKeys = append(r.setKeys, key)
return gvar.New("OK"), nil
}
func TestPackChanKeyPreservesIdentifiersAcrossLookupBranches(t *testing.T) {
// 子进程隔离 GoFrame 缓存的默认 DB/Redis 实例,所有查询只使用临时 SQLite 和内存适配器。
const childEnv = "PACK_CHAN_KEY_TEST_CHILD"
if os.Getenv(childEnv) != "1" {
executable, err := os.Executable()
if err != nil {
t.Fatal(err)
}
command := exec.CommandContext(t.Context(), executable, "-test.run=^TestPackChanKeyPreservesIdentifiersAcrossLookupBranches$", "-test.v")
command.Env = append(os.Environ(), childEnv+"=1")
output, err := command.CombinedOutput()
t.Logf("%s", output)
if err != nil {
t.Fatalf("隔离回归失败:%v", err)
}
return
}
ctx := context.Background()
cache := &packChanKeyRedis{}
gredis.RegisterAdapterFunc(func(*gredis.Config) gredis.Adapter { return cache })
gredis.SetConfig(&gredis.Config{Address: "in-memory-test"})
if err := gdb.SetConfigGroup("default", gdb.ConfigGroup{{
Type: "sqlite", Name: filepath.Join(t.TempDir(), "pack-chan-key.db"),
}}); err != nil {
t.Fatal(err)
}
db := g.DB()
t.Cleanup(func() { _ = db.Close(ctx) })
if _, err := db.Exec(ctx, "CREATE TABLE t_sim_card_info (iccid TEXT PRIMARY KEY, chan_key TEXT)"); err != nil {
t.Fatal(err)
}
api := NewUpstreamAPI()
for _, branch := range []string{"redis", "database", "unmatched"} {
t.Run(branch, func(t *testing.T) {
cache.values = make(map[string]string)
cache.mgetCalls = 0
cache.setKeys = nil
if _, err := db.Exec(ctx, "DELETE FROM t_sim_card_info"); err != nil {
t.Fatal(err)
}
first := &model.SimKey{ICCID: "iccid-duplicate", MSISDN: "msisdn-first", IMSI: "imsi-first"}
second := &model.SimKey{ICCID: first.ICCID, MSISDN: "msisdn-second", IMSI: "imsi-second"}
other := &model.SimKey{ICCID: "iccid-other", MSISDN: "msisdn-other", IMSI: "imsi-other"}
inputs := []*model.SimKey{first, second, first, other}
wantChannel := ""
if branch != "unmatched" {
wantChannel = branch + "-channel"
for _, key := range []*model.SimKey{first, other} {
if branch == "redis" {
cache.values["sim:chan:iccid:"+key.ICCID] = wantChannel
} else if _, err := db.Exec(ctx, "INSERT INTO t_sim_card_info (iccid, chan_key) VALUES (?, ?)", key.ICCID, wantChannel); err != nil {
t.Fatal(err)
}
}
}
before := make([]model.SimKey, len(inputs))
for i, key := range inputs {
before[i] = *key
}
got, err := api.packChanKey(ctx, inputs)
if err != nil {
t.Fatal(err)
}
if len(got) != 1 || len(got[wantChannel]) != len(inputs) {
t.Fatalf("分组数量错误:%+v", got)
}
assertPackedSimKeys(t, got[wantChannel], inputs, before, wantChannel)
if cache.mgetCalls != 1 {
t.Fatalf("MGet 调用次数=%d,期望1", cache.mgetCalls)
}
wantSets := 0
if branch == "database" {
wantSets = 2
}
if len(cache.setKeys) != wantSets {
t.Fatalf("缓存回填数量=%d,期望%d", len(cache.setKeys), wantSets)
}
})
}
t.Run("mixed_with_explicit_channel", func(t *testing.T) {
cache.values = map[string]string{"sim:chan:iccid:redis-card": "redis-channel"}
if _, err := db.Exec(ctx, "DELETE FROM t_sim_card_info"); err != nil {
t.Fatal(err)
}
if _, err := db.Exec(ctx, "INSERT INTO t_sim_card_info (iccid, chan_key) VALUES ('database-card', 'database-channel')"); err != nil {
t.Fatal(err)
}
explicit := &model.SimKey{ChanKey: "explicit-channel", ICCID: "redis-card", MSISDN: "msisdn-explicit", IMSI: "imsi-explicit"}
inputs := []*model.SimKey{explicit}
wanted := map[string][]*model.SimKey{}
for _, tc := range []struct{ iccid, channel string }{
{"redis-card", "redis-channel"}, {"database-card", "database-channel"}, {"missing-card", ""},
} {
first := &model.SimKey{ICCID: tc.iccid, MSISDN: tc.iccid + "-msisdn-first", IMSI: tc.iccid + "-imsi-first"}
second := &model.SimKey{ICCID: tc.iccid, MSISDN: tc.iccid + "-msisdn-second", IMSI: tc.iccid + "-imsi-second"}
inputs = append(inputs, first, second)
wanted[tc.channel] = []*model.SimKey{first, second}
}
before := make(map[*model.SimKey]model.SimKey, len(inputs))
for _, key := range inputs {
before[key] = *key
}
got, err := api.packChanKey(ctx, inputs)
if err != nil {
t.Fatal(err)
}
if len(got) != 4 || len(got[explicit.ChanKey]) != 1 || got[explicit.ChanKey][0] != explicit {
t.Fatalf("显式通道分组被改变:%+v", got)
}
for channel, originals := range wanted {
assertPackedSimKeys(t, got[channel], originals, []model.SimKey{before[originals[0]], before[originals[1]]}, channel)
}
if *explicit != before[explicit] {
t.Fatal("显式通道原始输入被修改")
}
})
}
func assertPackedSimKeys(t *testing.T, got, originals []*model.SimKey, before []model.SimKey, channel string) {
t.Helper()
if len(got) != len(originals) {
t.Fatalf("输出数量=%d,期望%d", len(got), len(originals))
}
want := make(map[model.SimKey]int, len(originals))
for i, original := range originals {
if *original != before[i] {
t.Fatalf("原始输入被修改:%+v", original)
}
key := before[i]
key.ChanKey = channel
want[key]++
}
seen := make(map[*model.SimKey]bool, len(got))
for _, key := range got {
if key == nil || seen[key] {
t.Fatal("输出为空或复用了同一指针")
}
seen[key] = true
for _, original := range originals {
if key == original {
t.Fatal("输出复用了原始输入指针")
}
}
if want[*key] == 0 {
t.Fatalf("卡标识丢失或被另一条重复ICCID输入覆盖:%+v", key)
}
want[*key]--
}
for _, remaining := range want {
if remaining != 0 {
t.Fatal("输出未保留每条原始输入")
}
}
if len(got) > 1 {
other := *got[1]
got[0].MSISDN = "changed-output"
got[0].IMSI = "changed-output"
if *got[1] != other {
t.Fatal("修改一条输出影响了另一条输出")
}
}
for i, original := range originals {
if *original != before[i] {
t.Fatalf("修改输出影响了原始输入:%+v", original)
}
}
}
func TestPackChanKeyEmptyInput(t *testing.T) {
for _, inputs := range [][]*model.SimKey{nil, {}} {
got, err := NewUpstreamAPI().packChanKey(context.Background(), inputs)
if err != nil || got == nil || len(got) != 0 {
t.Fatalf("空输入结果=%+v,错误=%v", got, err)
}
}
}
@@ -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"
"time"
"git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/consts"
"git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/model"
)
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,255 @@
package upstreamimpl
import (
"context"
"fmt"
"os"
"sort"
"strconv"
"strings"
"sync"
"testing"
"time"
"git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/model"
"git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/model/entity"
)
// 真实上游时延测量(默认跳过)。
//
// 目的:实测移动 OneLink「批量提交返回 jobId」与「按 jobId 查到终态」各自的耗时,
// 用于校准平台超时与补查节奏。
//
// 仅在 APN_REAL_CARRIER_TEST=1 时执行。
//
// APN_REAL_CREDS_FILE=<path> 两行:appid / appsecret
// APN_REAL_BASE_URL=<url> 默认 https://api.iot.10086.cn/v5
// APN_REAL_APN=CMIOT
// APN_REAL_BATCHES=<b1>|<b2>|... 每批:operType,iccid:msisdn,iccid:msisdn,...
// APN_REAL_POLL_INTERVAL=15s 查询间隔
// APN_REAL_MAX_WAIT=15m 最长等待终态
func TestRealCarrierApnLatencyMeasure(t *testing.T) {
if strings.TrimSpace(os.Getenv("APN_REAL_CARRIER_TEST")) != "1" {
t.Skip("真实上游测量未启用:设置 APN_REAL_CARRIER_TEST=1 才执行")
}
appID, appSecret, err := loadRealCarrierCreds()
if err != nil {
t.Fatalf("读取凭证失败:%v", err)
}
baseURL := strings.TrimSpace(os.Getenv("APN_REAL_BASE_URL"))
if baseURL == "" {
baseURL = "https://api.iot.10086.cn/v5"
}
apn := strings.TrimSpace(os.Getenv("APN_REAL_APN"))
if apn == "" {
apn = "CMIOT"
}
batches, operTypes, err := parseRealBatches(os.Getenv("APN_REAL_BATCHES"))
if err != nil {
t.Fatalf("解析批次失败:%v", err)
}
pollInterval := 15 * time.Second
if raw := strings.TrimSpace(os.Getenv("APN_REAL_POLL_INTERVAL")); raw != "" {
if d, derr := time.ParseDuration(raw); derr == nil && d > 0 {
pollInterval = d
}
}
maxWait := 15 * time.Minute
if raw := strings.TrimSpace(os.Getenv("APN_REAL_MAX_WAIT")); raw != "" {
if d, derr := time.ParseDuration(raw); derr == nil && d > 0 {
maxWait = d
}
}
channel := &sSimYD{
channelConfig: &model.SimChannelConfigItemCache{Channel: &entity.TSimChannels{ChanKey: "real-carrier-measure", AppKey: appID}},
tokenProvider: &realCarrierToken{appID: appID, appSecret: appSecret, baseURL: baseURL},
apiConfig: &oneLinkAPIConfig{BaseURL: baseURL, Timeout: 60 * time.Second},
}
type batchOutcome struct {
index int
operType string
cards int
elapsed time.Duration
jobID string
failed string
}
start := time.Now()
ctx := context.Background()
results := make([]batchOutcome, len(batches))
var wg sync.WaitGroup
for i := range batches {
wg.Add(1)
go func(i int) {
defer wg.Done()
outcome := batchOutcome{index: i, operType: operTypes[i], cards: len(batches[i])}
begin := time.Now()
items, callErr := changeSimApnFunctionYD(ctx, channel, &model.SimCardOperateChangeApnInput{
SimKeys: batches[i],
ReqData: model.SimCardOperateChangeApnInputItem{
OperType: operTypes[i],
ApnName: apn,
SubmitOnly: true,
OperationID: fmt.Sprintf("measure-%d-%d", i, begin.UnixNano()),
},
})
outcome.elapsed = time.Since(begin)
switch {
case callErr != nil:
outcome.failed = callErr.Error()
case len(items) == 0:
outcome.failed = "空返回"
default:
outcome.jobID = items[0].ResData.OrderNum
for _, item := range items {
if item == nil {
continue
}
if item.ResData.OrderNum != "" && item.ResData.OrderNum != outcome.jobID {
outcome.failed = "同批出现多个jobId"
}
if !item.ResData.Pending && !item.ResData.Uncertain {
outcome.failed = fmt.Sprintf("提交即终态 code=%d msg=%s", item.Code, item.Message)
}
}
}
results[i] = outcome
}(i)
}
wg.Wait()
submitWall := time.Since(start)
fmt.Printf("MEASURE_SUBMIT batches=%d cards=%d wall=%s\n", len(batches), totalRealCards(batches), submitWall)
for _, r := range results {
fmt.Printf("MEASURE_SUBMIT_BATCH idx=%d operType=%s cards=%d elapsed=%s jobId=%s failed=%s\n",
r.index, r.operType, r.cards, r.elapsed, r.jobID, r.failed)
}
// 汇总待查任务
pendingJobs := map[string][]*model.SimKey{}
for _, r := range results {
if r.jobID != "" {
pendingJobs[r.jobID] = batches[r.index]
}
}
terminalAt := map[string]time.Duration{}
fmt.Printf("MEASURE_JOBS distinct=%d %v\n", len(pendingJobs), sortedJobIDs(pendingJobs))
deadline := start.Add(maxWait)
for len(terminalAt) < len(pendingJobs) && time.Now().Before(deadline) {
time.Sleep(pollInterval)
elapsed := time.Since(start)
for jobID, keys := range pendingJobs {
if _, done := terminalAt[jobID]; done {
continue
}
items, err := changeSimApnFunctionYD(ctx, channel, &model.SimCardOperateChangeApnInput{
SimKeys: keys,
ReqData: model.SimCardOperateChangeApnInputItem{QueryJobID: jobID},
})
if err != nil {
fmt.Printf("MEASURE_QUERY_ERR jobId=%s at=%s err=%v\n", jobID, elapsed.Round(time.Second), err)
continue
}
allTerminal := len(items) > 0
for _, item := range items {
if item == nil || item.ResData.Pending {
allTerminal = false
break
}
}
if allTerminal {
terminalAt[jobID] = elapsed
fmt.Printf("MEASURE_TERMINAL jobId=%s at=%s\n", jobID, elapsed.Round(time.Second))
for _, item := range items {
fmt.Printf("MEASURE_CARD jobId=%s iccid=%s msisdn=%s code=%d uncertain=%v message=%s\n",
jobID, item.SimKey.ICCID, item.SimKey.MSISDN, item.Code, item.ResData.Uncertain, item.Message)
}
} else {
fmt.Printf("MEASURE_PENDING jobId=%s at=%s note=%s\n", jobID, elapsed.Round(time.Second), firstMessage(items))
}
}
}
fmt.Printf("MEASURE_SUMMARY\n")
for _, jobID := range sortedJobIDs(pendingJobs) {
if at, ok := terminalAt[jobID]; ok {
fmt.Printf("MEASURE_RESULT jobId=%s terminalAt=%s\n", jobID, at.Round(time.Second))
} else {
fmt.Printf("MEASURE_RESULT jobId=%s terminalAt=NONE within %s\n", jobID, maxWait)
}
}
}
func totalRealCards(batches [][]*model.SimKey) int {
total := 0
for _, b := range batches {
total += len(b)
}
return total
}
func sortedJobIDs(jobs map[string][]*model.SimKey) []string {
ids := make([]string, 0, len(jobs))
for id := range jobs {
ids = append(ids, id)
}
sort.Strings(ids)
return ids
}
func firstMessage(items model.SimCardOperateChangeApnOutput) string {
for _, item := range items {
if item != nil {
return item.Message
}
}
return ""
}
// parseRealBatches 解析 APN_REAL_BATCHES:批之间用 | 分隔,每批首项为 operType,其余为 iccid:msisdn。
func parseRealBatches(raw string) ([][]*model.SimKey, []string, error) {
raw = strings.TrimSpace(raw)
if raw == "" {
return nil, nil, fmt.Errorf("未设置 APN_REAL_BATCHES")
}
batches := make([][]*model.SimKey, 0)
operTypes := make([]string, 0)
for _, batchRaw := range strings.Split(raw, "|") {
batchRaw = strings.TrimSpace(batchRaw)
if batchRaw == "" {
continue
}
fields := strings.Split(batchRaw, ",")
operType := strings.TrimSpace(fields[0])
if _, err := strconv.Atoi(operType); err != nil {
return nil, nil, fmt.Errorf("批次首项应为 operType:%q", batchRaw)
}
keys := make([]*model.SimKey, 0, len(fields)-1)
for _, card := range fields[1:] {
card = strings.TrimSpace(card)
if card == "" {
continue
}
parts := strings.Split(card, ":")
if len(parts) < 2 {
return nil, nil, fmt.Errorf("卡项格式应为 iccid:msisdn:%q", card)
}
keys = append(keys, &model.SimKey{
ChanKey: "real-carrier-measure",
ICCID: strings.TrimSpace(parts[0]),
MSISDN: strings.TrimSpace(parts[1]),
})
}
if len(keys) == 0 {
continue
}
batches = append(batches, keys)
operTypes = append(operTypes, operType)
}
if len(batches) == 0 {
return nil, nil, fmt.Errorf("批次为空")
}
return batches, operTypes, nil
}
@@ -0,0 +1,269 @@
package upstreamimpl
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/url"
"os"
"strings"
"sync"
"testing"
"time"
"git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/model"
"git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/model/entity"
"github.com/gogf/gf/v2/frame/g"
)
// 真实移动 OneLink 上游功能验证(默认跳过)。
//
// 仅在显式设置 APN_REAL_CARRIER_TEST=1 时执行,并且只做本文件声明的动作,
// 用于小范围真实卡的功能验证。凭证从文件读取(APN_REAL_CREDS_FILE),
// 不出现在命令行或代码里。
//
// 环境变量:
//
// APN_REAL_CARRIER_TEST=1 必需,否则整包跳过
// APN_REAL_CREDS_FILE=<path> 两行:appid / appsecret
// APN_REAL_BASE_URL=<url> 默认 https://api.iot.10086.cn/v5
// APN_REAL_CARDS=<iccid:msisdn:imsi;...> 卡清单
// APN_REAL_ACTION=apninfo|submit|query
// APN_REAL_OPERTYPE=0|1 submit 时使用:0=开 1=停
// APN_REAL_APN=CMIOT submit 时使用
// APN_REAL_JOBID=<jobId> query 时使用
func TestRealCarrierApnFunctional(t *testing.T) {
if strings.TrimSpace(os.Getenv("APN_REAL_CARRIER_TEST")) != "1" {
t.Skip("真实上游测试未启用:设置 APN_REAL_CARRIER_TEST=1 才执行")
}
appID, appSecret, err := loadRealCarrierCreds()
if err != nil {
t.Fatalf("读取凭证失败:%v", err)
}
baseURL := strings.TrimSpace(os.Getenv("APN_REAL_BASE_URL"))
if baseURL == "" {
baseURL = "https://api.iot.10086.cn/v5"
}
keys, err := loadRealCarrierKeys()
if err != nil {
t.Fatalf("解析卡清单失败:%v", err)
}
channel := &sSimYD{
channelConfig: &model.SimChannelConfigItemCache{Channel: &entity.TSimChannels{ChanKey: "real-carrier-verify", AppKey: appID}},
tokenProvider: &realCarrierToken{appID: appID, appSecret: appSecret, baseURL: baseURL},
apiConfig: &oneLinkAPIConfig{BaseURL: baseURL, Timeout: 30 * time.Second},
}
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute)
defer cancel()
switch strings.TrimSpace(os.Getenv("APN_REAL_ACTION")) {
case "apninfo":
apnInfoProbe(t, ctx, channel, keys)
case "submit":
submitProbe(t, ctx, channel, keys)
case "query":
queryProbe(t, ctx, channel, keys)
default:
t.Fatalf("APN_REAL_ACTION 必须为 apninfo|submit|query")
}
}
// apninfoProbe 只读:查询每张卡当前已开通 APN 列表及状态。
func apnInfoProbe(t *testing.T, ctx context.Context, channel *sSimYD, keys []*model.SimKey) {
for _, key := range keys {
params := g.Map{"transid": channel.getTransId(ctx), "iccid": key.ICCID}
resp, err := channel.requestYd(ctx, "QuerySimApnInfo", channel.getUrl(ctx, "ec/query/apn-info"), params)
if err != nil {
t.Errorf("apn-info %s 请求失败:%v", key.ICCID, err)
continue
}
body := resp.ReadAllString()
fmt.Printf("APNINFO iccid=%s msisdn=%s body=%s\n", key.ICCID, key.MSISDN, oneLine(body))
}
}
// submitProbe 真实提交一次批量开/停,只取受理结果(SubmitOnly),不轮询。
func submitProbe(t *testing.T, ctx context.Context, channel *sSimYD, keys []*model.SimKey) {
operType := strings.TrimSpace(os.Getenv("APN_REAL_OPERTYPE"))
if operType == "" {
t.Fatalf("submit 必须指定 APN_REAL_OPERTYPE(0开/1停)")
}
apn := strings.TrimSpace(os.Getenv("APN_REAL_APN"))
if apn == "" {
apn = "CMIOT"
}
checkpointed := make(model.SimCardOperateChangeApnOutput, 0)
ctx = context.WithValue(ctx, model.ApnOperationCheckpointKey{}, func(partial model.SimCardOperateChangeApnOutput) error {
checkpointed = append(checkpointed, partial...)
return nil
})
items, err := changeSimApnFunctionYD(ctx, channel, &model.SimCardOperateChangeApnInput{
SimKeys: keys,
ReqData: model.SimCardOperateChangeApnInputItem{OperType: operType, ApnName: apn, SubmitOnly: true, OperationID: "real-verify-" + fmt.Sprint(time.Now().Unix())},
})
if err != nil {
t.Fatalf("提交失败:%v", err)
}
fmt.Printf("SUBMIT operType=%s apn=%s cards=%d checkpointed=%d\n", operType, apn, len(items), len(checkpointed))
jobIDs := map[string]int{}
for _, item := range items {
if item == nil || item.SimKey == nil {
t.Errorf("返回项缺少卡关联")
continue
}
jobIDs[item.ResData.OrderNum]++
fmt.Printf("SUBMIT_ITEM iccid=%s code=%d pending=%v uncertain=%v jobId=%s message=%s\n",
item.SimKey.ICCID, item.Code, item.ResData.Pending, item.ResData.Uncertain, item.ResData.OrderNum, item.Message)
}
fmt.Printf("SUBMIT_JOBS %v\n", jobIDs)
}
// queryProbe 真实查询一次批量任务结果(单次,不轮询)。
func queryProbe(t *testing.T, ctx context.Context, channel *sSimYD, keys []*model.SimKey) {
jobID := strings.TrimSpace(os.Getenv("APN_REAL_JOBID"))
if jobID == "" {
t.Fatalf("query 必须指定 APN_REAL_JOBID")
}
items, err := changeSimApnFunctionYD(ctx, channel, &model.SimCardOperateChangeApnInput{
SimKeys: keys,
ReqData: model.SimCardOperateChangeApnInputItem{QueryJobID: jobID},
})
if err != nil {
t.Fatalf("查询失败:%v", err)
}
fmt.Printf("QUERY jobId=%s cards=%d\n", jobID, len(items))
for _, item := range items {
if item == nil || item.SimKey == nil {
t.Errorf("返回项缺少卡关联")
continue
}
fmt.Printf("QUERY_ITEM iccid=%s msisdn=%s code=%d pending=%v uncertain=%v jobId=%s message=%s\n",
item.SimKey.ICCID, item.SimKey.MSISDN, item.Code, item.ResData.Pending, item.ResData.Uncertain, item.ResData.OrderNum, item.Message)
}
}
// realCarrierToken 直接从 OneLink 换 token,不落库、不依赖数据库。
type realCarrierToken struct {
appID string
appSecret string
baseURL string
mu sync.Mutex
token string
expireAt time.Time
seq int64
}
func (p *realCarrierToken) GetToken(ctx context.Context) (string, error) {
p.mu.Lock()
defer p.mu.Unlock()
if p.token != "" && time.Now().Before(p.expireAt.Add(-2*time.Minute)) {
return p.token, nil
}
return p.fetch(ctx)
}
func (p *realCarrierToken) RecoverToken(ctx context.Context, rejected string) (string, error) {
p.mu.Lock()
defer p.mu.Unlock()
if p.token != "" && p.token != rejected {
return p.token, nil
}
p.token = ""
return p.fetch(ctx)
}
func (p *realCarrierToken) fetch(ctx context.Context) (string, error) {
p.seq++
transid := p.appID + time.Now().Format("20060102150405") + fmt.Sprintf("%08d", p.seq)
endpoint := strings.TrimRight(p.baseURL, "/") + "/ec/get/token?" + url.Values{
"appid": {p.appID},
"password": {p.appSecret},
"transid": {transid},
"refresh": {"0"},
}.Encode()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
if err != nil {
return "", err
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
var parsed struct {
Status string `json:"status"`
Message string `json:"message"`
Result []struct {
Token string `json:"token"`
Ttl int `json:"ttl"`
} `json:"result"`
}
if err := json.NewDecoder(resp.Body).Decode(&parsed); err != nil {
return "", err
}
if parsed.Status != "0" || len(parsed.Result) == 0 || parsed.Result[0].Token == "" {
return "", fmt.Errorf("换 token 失败 status=%s message=%s", parsed.Status, parsed.Message)
}
p.token = parsed.Result[0].Token
ttl := parsed.Result[0].Ttl
if ttl <= 0 {
ttl = 3600
}
p.expireAt = time.Now().Add(time.Duration(ttl) * time.Second)
return p.token, nil
}
func loadRealCarrierCreds() (string, string, error) {
path := strings.TrimSpace(os.Getenv("APN_REAL_CREDS_FILE"))
if path == "" {
return "", "", fmt.Errorf("未设置 APN_REAL_CREDS_FILE")
}
raw, err := os.ReadFile(path)
if err != nil {
return "", "", err
}
lines := make([]string, 0, 2)
for _, line := range strings.Split(string(raw), "\n") {
if line = strings.TrimSpace(line); line != "" {
lines = append(lines, line)
}
}
if len(lines) < 2 {
return "", "", fmt.Errorf("凭证文件需要两行:appid / appsecret")
}
return lines[0], lines[1], nil
}
func loadRealCarrierKeys() ([]*model.SimKey, error) {
raw := strings.TrimSpace(os.Getenv("APN_REAL_CARDS"))
if raw == "" {
return nil, fmt.Errorf("未设置 APN_REAL_CARDS")
}
keys := make([]*model.SimKey, 0)
for _, entry := range strings.Split(raw, ";") {
entry = strings.TrimSpace(entry)
if entry == "" {
continue
}
parts := strings.Split(entry, ":")
if len(parts) < 2 {
return nil, fmt.Errorf("卡项格式应为 iccid:msisdn[:imsi]:%q", entry)
}
key := &model.SimKey{ChanKey: "real-carrier-verify", ICCID: strings.TrimSpace(parts[0]), MSISDN: strings.TrimSpace(parts[1])}
if len(parts) > 2 {
key.IMSI = strings.TrimSpace(parts[2])
}
keys = append(keys, key)
}
if len(keys) == 0 {
return nil, fmt.Errorf("卡清单为空")
}
return keys, nil
}
func oneLine(body string) string {
return strings.ReplaceAll(strings.ReplaceAll(body, "\n", " "), "\r", " ")
}
@@ -0,0 +1,149 @@
package upstreamimpl
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"time"
sharedtoken "git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/token"
)
// beixinOneLinkTokenPath is the fallback used when Path is empty. On the
// template path this default is unreachable: parseSharedOneLinkConfig rejects
// an empty token.path before any provider is built, so Path always carries the
// value declared in temp_config. It only applies to direct construction
// (tests). The same shape exists in every shared-token provider.
const beixinOneLinkTokenPath = "/prod-api/open/api/oneLinkToken"
// beixinTokenTTL is the local validity window. The upstream returns only the
// token string and no expiry field, so the window is held locally; a token
// rejected earlier than this is recovered through RecoverToken's invalidation
// path instead of being trusted until the TTL elapses.
const beixinTokenTTL = time.Hour
// BeixinTokenConfig contains all values required by the Beixin (江苏倍信)
// shared-token protocol. BaseURL and Path come from temp_config
// token.baseUrl/token.path (with the per-channel tokenBaseUrl override
// applied by parseSharedOneLinkConfig); the credentials come from chan_config.
type BeixinTokenConfig struct {
BaseURL string
Path string
PwdKey string
ChannelNo string
Timeout time.Duration
}
// BeixinTokenProvider fetches a Beixin shared OneLink token.
//
// Wire contract (verified against the live endpoint on 2026-10-19):
//
// GET {baseUrl}{path}?pwdkey=<pwdKey>&c_no=<channelNo>
// → {"code":200,"msg":"请求成功","data":"<64 chars>"}
//
// The token is consumed by the standard OneLink business API
// (https://api.iot.10086.cn/v5/), so all /sim/* routes work unchanged.
type BeixinTokenProvider struct {
baseURL string
path string
pwdKey string
channelNo string
client *http.Client
}
type beixinTokenResponse struct {
Code int `json:"code"`
Msg string `json:"msg"`
Data json.RawMessage `json:"data"`
}
func NewBeixinTokenProvider(config BeixinTokenConfig) *BeixinTokenProvider {
timeout := config.Timeout
if timeout <= 0 {
timeout = defaultUpstreamHTTPTimeout
}
return &BeixinTokenProvider{
baseURL: strings.TrimRight(strings.TrimSpace(config.BaseURL), "/"),
path: normalizeProviderPath(config.Path, beixinOneLinkTokenPath),
pwdKey: config.PwdKey,
channelNo: strings.TrimSpace(config.ChannelNo),
client: &http.Client{Timeout: timeout},
}
}
func (p *BeixinTokenProvider) Fetch(ctx context.Context) (sharedtoken.OneLinkToken, error) {
if p == nil || p.client == nil {
return sharedtoken.OneLinkToken{}, fmt.Errorf("beixin token provider is not initialized")
}
if p.baseURL == "" || strings.TrimSpace(p.pwdKey) == "" || p.channelNo == "" {
return sharedtoken.OneLinkToken{}, fmt.Errorf("beixin baseUrl, pwdKey and channelNo are required")
}
endpoint, err := url.Parse(joinProviderURL(p.baseURL, p.path))
if err != nil {
return sharedtoken.OneLinkToken{}, fmt.Errorf("parse beixin token URL: %w", err)
}
query := endpoint.Query()
query.Set("pwdkey", p.pwdKey)
query.Set("c_no", p.channelNo)
endpoint.RawQuery = query.Encode()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint.String(), nil)
if err != nil {
return sharedtoken.OneLinkToken{}, fmt.Errorf("create beixin token request: %w", err)
}
req.Header.Set("Accept", "application/json")
resp, err := p.client.Do(req)
if err != nil {
return sharedtoken.OneLinkToken{}, fmt.Errorf("request beixin token: %w", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(io.LimitReader(resp.Body, 4*1024*1024+1))
if err != nil {
return sharedtoken.OneLinkToken{}, fmt.Errorf("read beixin token response: %w", err)
}
if len(body) > 4*1024*1024 {
return sharedtoken.OneLinkToken{}, fmt.Errorf("beixin token response is too large")
}
if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices {
return sharedtoken.OneLinkToken{}, fmt.Errorf("beixin token HTTP status %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
}
var output beixinTokenResponse
if err := json.Unmarshal(body, &output); err != nil {
return sharedtoken.OneLinkToken{}, fmt.Errorf("decode beixin token response: %w", err)
}
if output.Code != http.StatusOK {
return sharedtoken.OneLinkToken{}, fmt.Errorf("beixin token failed: code=%d, message=%s", output.Code, output.Msg)
}
// data is a bare JSON string, not an object.
var value string
if err := json.Unmarshal(output.Data, &value); err != nil {
return sharedtoken.OneLinkToken{}, fmt.Errorf("decode beixin shared token: %w", err)
}
value = strings.TrimSpace(value)
if value == "" {
return sharedtoken.OneLinkToken{}, fmt.Errorf("beixin shared token is empty")
}
now := time.Now()
return sharedtoken.OneLinkToken{Value: value, CreatedAt: now, ExpiresAt: now.Add(beixinTokenTTL)}, nil
}
func (p *BeixinTokenProvider) FetchToken(ctx context.Context, _ string) (sharedtoken.OneLinkToken, error) {
return p.Fetch(ctx)
}
// beixinFetcher adapts BeixinTokenProvider to the shared token TokenFetcher
// contract, mirroring tengyuFetcher.
type beixinFetcher struct{ provider *BeixinTokenProvider }
func (f beixinFetcher) FetchToken(ctx context.Context, _ string) (sharedtoken.OneLinkToken, error) {
return f.provider.Fetch(ctx)
}
@@ -0,0 +1,335 @@
package upstreamimpl
import (
"context"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/model"
"git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/model/entity"
"git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/service"
)
const (
beixinTestPwdKey = "test-pwdkey"
beixinTestChannelNo = "chanel-202607100639054511"
beixinTestToken = "v5feb8f288c5ba96631b627dc18a5b6a5318e7e7b1923cf7e0aa0efdefa746e4"
)
func newBeixinTestProvider(t *testing.T, baseURL string) *BeixinTokenProvider {
t.Helper()
return NewBeixinTokenProvider(BeixinTokenConfig{
BaseURL: baseURL, Path: beixinOneLinkTokenPath,
PwdKey: beixinTestPwdKey, ChannelNo: beixinTestChannelNo, Timeout: time.Second,
})
}
// TestBeixinTokenProviderSendRequestContract pins the exact wire shape as
// observed against the live endpoint on 2026-10-19: a GET with lowercase
// pwdkey/c_no query parameters, and a bare JSON string in data.
func TestBeixinTokenProviderSendRequestContract(t *testing.T) {
var (
gotMethod string
gotPath string
gotPwdKey string
gotCNo string
)
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotMethod = r.Method
gotPath = r.URL.Path
gotPwdKey = r.URL.Query().Get("pwdkey")
gotCNo = r.URL.Query().Get("c_no")
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"code":200,"msg":"请求成功","data":"` + beixinTestToken + `"}`))
}))
defer server.Close()
if _, err := newBeixinTestProvider(t, server.URL).Fetch(context.Background()); err != nil {
t.Fatalf("fetch token: %v", err)
}
if gotMethod != http.MethodGet {
t.Fatalf("method = %q, want GET", gotMethod)
}
if gotPath != beixinOneLinkTokenPath {
t.Fatalf("path = %q, want %q", gotPath, beixinOneLinkTokenPath)
}
if gotPwdKey != beixinTestPwdKey {
t.Fatalf("pwdkey = %q, want %q", gotPwdKey, beixinTestPwdKey)
}
if gotCNo != beixinTestChannelNo {
t.Fatalf("c_no = %q, want %q", gotCNo, beixinTestChannelNo)
}
}
// TestBeixinTokenProviderReturnsValueAndLocalTTL pins that the bare string in
// data is unwrapped and that the local one-hour window is applied, since the
// upstream reports no expiry.
func TestBeixinTokenProviderReturnsValueAndLocalTTL(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte(`{"code":200,"msg":"请求成功","data":"` + beixinTestToken + `"}`))
}))
defer server.Close()
before := time.Now()
token, err := newBeixinTestProvider(t, server.URL).Fetch(context.Background())
if err != nil {
t.Fatalf("fetch token: %v", err)
}
if token.Value != beixinTestToken {
t.Fatalf("token value = %q, want %q", token.Value, beixinTestToken)
}
// Allow a second of slack for slow CI clocks.
if delta := token.ExpiresAt.Sub(before); delta < beixinTokenTTL-time.Second || delta > beixinTokenTTL+time.Second {
t.Fatalf("expiresAt - before = %s, want ~%s", delta, beixinTokenTTL)
}
}
// TestBeixinTokenProviderRejectsBusinessFailure pins that a non-200 code is an
// error rather than an empty-but-successful token.
func TestBeixinTokenProviderRejectsBusinessFailure(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte(`{"code":500,"msg":"获取token失败","data":null}`))
}))
defer server.Close()
_, err := newBeixinTestProvider(t, server.URL).Fetch(context.Background())
if err == nil {
t.Fatal("business failure must return an error")
}
if !strings.Contains(err.Error(), "获取token失败") {
t.Fatalf("error = %v, want the upstream message", err)
}
}
// TestBeixinTokenProviderRejectsEmptyToken pins that a successful envelope with
// an empty data string is still an error.
func TestBeixinTokenProviderRejectsEmptyToken(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte(`{"code":200,"msg":"请求成功","data":""}`))
}))
defer server.Close()
_, err := newBeixinTestProvider(t, server.URL).Fetch(context.Background())
if err == nil {
t.Fatal("empty token must return an error")
}
if !strings.Contains(err.Error(), "beixin shared token is empty") {
t.Fatalf("error = %v", err)
}
}
// TestBeixinTokenProviderRejectsObjectData pins that data is expected to be a
// JSON string. A future upstream change to an object shape must fail loudly
// instead of silently yielding an empty token.
func TestBeixinTokenProviderRejectsObjectData(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte(`{"code":200,"msg":"请求成功","data":{"token":"abc"}}`))
}))
defer server.Close()
if _, err := newBeixinTestProvider(t, server.URL).Fetch(context.Background()); err == nil {
t.Fatal("object-shaped data must return an error")
}
}
// TestBeixinTokenProviderRequiresCredentials pins the guard so a channel
// missing chan_config.pwdKey or channelNo cannot build a provider that fails
// on every request.
func TestBeixinTokenProviderRequiresCredentials(t *testing.T) {
cases := map[string]BeixinTokenConfig{
"missing baseUrl": {Path: beixinOneLinkTokenPath, PwdKey: beixinTestPwdKey, ChannelNo: beixinTestChannelNo},
"missing pwdKey": {BaseURL: "http://example.com", Path: beixinOneLinkTokenPath, ChannelNo: beixinTestChannelNo},
"missing channelNo": {BaseURL: "http://example.com", Path: beixinOneLinkTokenPath, PwdKey: beixinTestPwdKey},
}
for name, cfg := range cases {
t.Run(name, func(t *testing.T) {
start := time.Now()
_, err := NewBeixinTokenProvider(cfg).Fetch(context.Background())
if err == nil {
t.Fatal("incomplete credentials must return an error")
}
if elapsed := time.Since(start); elapsed > time.Second {
t.Fatalf("validation must not hit the network, took %s", elapsed)
}
if !strings.Contains(err.Error(), "beixin baseUrl, pwdKey and channelNo are required") {
t.Fatalf("error = %v, want the credential guard message", err)
}
})
}
}
// TestBeixinTemplateRegistration pins that a provider=beixin template
// instantiates through the shared_token model with a chan_config carrying the
// two required credentials.
func TestBeixinTemplateRegistration(t *testing.T) {
template, ok := service.UpstreamAPI().GetTemp(sharedOneLinkBeixinTemplateKey)
if !ok || template == nil {
t.Fatal("beixin template not registered")
}
config := &model.SimChannelConfigItemCache{
Template: &entity.TSimChannelTemplate{
TempKey: sharedOneLinkBeixinTemplateKey,
TempModel: templateModelSharedToken,
TempConfig: `{"token":{"provider":"beixin","baseUrl":"http://www.jsbxkj.vip",` +
`"path":"/prod-api/open/api/oneLinkToken"},"api":{"baseUrl":"https://api.iot.10086.cn/v5/","timeout":"1s"}}`,
},
Channel: &entity.TSimChannels{
ChanKey: "252",
ChanConfig: `{"pwdKey":"` + beixinTestPwdKey + `","channelNo":"` + beixinTestChannelNo + `"}`,
},
}
if instance := template.NewTemp(context.Background(), config); instance == nil {
t.Fatal("beixin template failed to instantiate")
}
}
// TestBeixinTokenPathComesFromTemplate pins that the request path is whatever
// the template declares, not a hardcoded default. This is the same property
// TestLanShuTokenPathComesFromTemplate pins for lanshu, and it is what makes a
// template reusable across upstream hosts.
func TestBeixinTokenPathComesFromTemplate(t *testing.T) {
const templatePath = "/some/other/open/route"
if templatePath == beixinOneLinkTokenPath {
t.Fatal("test precondition broken: templatePath must differ from beixinOneLinkTokenPath")
}
var gotPath string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotPath = r.URL.Path
_, _ = w.Write([]byte(`{"code":200,"msg":"请求成功","data":"` + beixinTestToken + `"}`))
}))
defer server.Close()
channelConfig := &model.SimChannelConfigItemCache{
Template: &entity.TSimChannelTemplate{
TempKey: sharedOneLinkBeixinTemplateKey,
TempConfig: `{"token":{"provider":"beixin","baseUrl":"` + server.URL + `",` +
`"path":"` + templatePath + `"}}`,
},
Channel: &entity.TSimChannels{
ChanKey: "252",
ChanConfig: `{"pwdKey":"` + beixinTestPwdKey + `","channelNo":"` + beixinTestChannelNo + `"}`,
},
}
cfg, _, err := parseSharedOneLinkConfig(context.Background(), channelConfig)
if err != nil {
t.Fatalf("parse config: %v", err)
}
if cfg.TokenPath != templatePath {
t.Fatalf("parsed TokenPath = %q, want the template-declared %q", cfg.TokenPath, templatePath)
}
provider := NewBeixinTokenProvider(BeixinTokenConfig{
BaseURL: cfg.TokenBaseURL, Path: cfg.TokenPath,
PwdKey: cfg.PwdKey, ChannelNo: cfg.ChannelNo, Timeout: cfg.Timeout,
})
if _, err := provider.Fetch(context.Background()); err != nil {
t.Fatalf("fetch token: %v", err)
}
if gotPath != templatePath {
t.Fatalf("request path = %q, want the template-declared %q", gotPath, templatePath)
}
}
// TestBeixinChannelConfigRejectsIncompleteCredentials pins the parse-time
// guard, so the gate fires before any network call.
func TestBeixinChannelConfigRejectsIncompleteCredentials(t *testing.T) {
base := func(chanConfig string) *model.SimChannelConfigItemCache {
return &model.SimChannelConfigItemCache{
Template: &entity.TSimChannelTemplate{
TempKey: sharedOneLinkBeixinTemplateKey,
TempConfig: `{"token":{"provider":"beixin","baseUrl":"http://www.jsbxkj.vip",` +
`"path":"/prod-api/open/api/oneLinkToken"}}`,
},
Channel: &entity.TSimChannels{ChanKey: "252", ChanConfig: chanConfig},
}
}
if _, _, err := parseSharedOneLinkConfig(context.Background(), base(`{"pwdKey":"only"}`)); err == nil {
t.Fatal("chan_config without channelNo must be rejected")
}
if _, _, err := parseSharedOneLinkConfig(context.Background(), base(`{}`)); err == nil {
t.Fatal("empty chan_config must be rejected")
}
parsed, _, err := parseSharedOneLinkConfig(context.Background(), base(
`{"pwdKey":"`+beixinTestPwdKey+`","channelNo":"`+beixinTestChannelNo+`"}`))
if err != nil {
t.Fatalf("complete chan_config must parse: %v", err)
}
if parsed.PwdKey != beixinTestPwdKey || parsed.ChannelNo != beixinTestChannelNo {
t.Fatalf("parsed credentials = %q / %q", parsed.PwdKey, parsed.ChannelNo)
}
}
// TestBeixinChannelTokenBaseURLOverride pins the per-channel host override that
// lets several Beixin accounts (different c_no values, possibly different
// hosts) share one template without a code change.
func TestBeixinChannelTokenBaseURLOverride(t *testing.T) {
const templateHost = "http://template-host.example.com"
const channelHost = "http://channel-host.example.com"
config := &model.SimChannelConfigItemCache{
Template: &entity.TSimChannelTemplate{
TempKey: sharedOneLinkBeixinTemplateKey,
TempConfig: `{"token":{"provider":"beixin","baseUrl":"` + templateHost + `",` +
`"path":"/prod-api/open/api/oneLinkToken"}}`,
},
Channel: &entity.TSimChannels{
ChanKey: "252",
ChanConfig: `{"pwdKey":"` + beixinTestPwdKey + `","channelNo":"` + beixinTestChannelNo + `",` +
`"tokenBaseUrl":"` + channelHost + `"}`,
},
}
parsed, _, err := parseSharedOneLinkConfig(context.Background(), config)
if err != nil {
t.Fatalf("parse config: %v", err)
}
if parsed.TokenBaseURL != channelHost {
t.Fatalf("TokenBaseURL = %q, want the channel override %q", parsed.TokenBaseURL, channelHost)
}
// Without the override the template host must win.
config.Channel.ChanConfig = `{"pwdKey":"` + beixinTestPwdKey + `","channelNo":"` + beixinTestChannelNo + `"}`
parsed, _, err = parseSharedOneLinkConfig(context.Background(), config)
if err != nil {
t.Fatalf("parse config: %v", err)
}
if parsed.TokenBaseURL != templateHost {
t.Fatalf("TokenBaseURL = %q, want the template default %q", parsed.TokenBaseURL, templateHost)
}
}
// TestBeixinResponseShapeMatchesObservedBody replays the exact body captured
// from the live endpoint, so field-case drift breaks the build rather than
// production.
func TestBeixinResponseShapeMatchesObservedBody(t *testing.T) {
const observed = `{"code":200,"msg":"请求成功","data":"v5feb8f288c5ba96631b627dc18a5b6a5318e7e7b1923cf7e0aa0efdefa746e4"}`
var output beixinTokenResponse
if err := json.Unmarshal([]byte(observed), &output); err != nil {
t.Fatalf("decode observed body: %v", err)
}
if output.Code != 200 || output.Msg != "请求成功" {
t.Fatalf("envelope = %+v", output)
}
var value string
if err := json.Unmarshal(output.Data, &value); err != nil {
t.Fatalf("decode data: %v", err)
}
if len(value) != 64 || !strings.HasPrefix(value, "v5") {
t.Fatalf("token shape changed: %q", value)
}
}
// silence unused-import drift if the file is trimmed later.
var _ = io.Discard
@@ -0,0 +1,60 @@
package upstreamimpl
import (
"context"
"fmt"
"strconv"
"strings"
"time"
"git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/service/daihe"
sharedtoken "git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/token"
)
type DaiheTokenConfig struct {
BaseURL string
Path string
AppID string
AppSecret string
CMCCAppID string
Timeout time.Duration
}
type DaiheTokenProvider struct{ config DaiheTokenConfig }
type daiheTokenFetcher struct{ provider *DaiheTokenProvider }
func NewDaiheTokenProvider(config DaiheTokenConfig) *DaiheTokenProvider {
if strings.TrimSpace(config.Path) == "" {
config.Path = "/sim/query/cmccToken"
}
if strings.TrimSpace(config.BaseURL) == "" {
config.BaseURL = "https://open.flymodem.com.cn"
}
return &DaiheTokenProvider{config: config}
}
func (p *DaiheTokenProvider) Fetch(ctx context.Context) (sharedtoken.OneLinkToken, error) {
if p == nil || p.config.AppID == "" || p.config.AppSecret == "" || p.config.CMCCAppID == "" {
return sharedtoken.OneLinkToken{}, fmt.Errorf("daihe token credentials and cmccAppId are required")
}
client := daihe.NewClient(daihe.Config{BaseURL: p.config.BaseURL, AppID: p.config.AppID, AppSecret: p.config.AppSecret, Timeout: p.config.Timeout})
var response daihe.CMCCToken
if err := client.QueryCMCCToken(ctx, p.config.CMCCAppID, &response); err != nil {
return sharedtoken.OneLinkToken{}, err
}
token := strings.TrimSpace(response.Token)
deviceToken := strings.TrimSpace(response.TokenDevice)
if token == "" {
return sharedtoken.OneLinkToken{}, fmt.Errorf("daihe cmcc token response is empty")
}
ttl, err := strconv.ParseInt(strings.TrimSpace(response.DeviceTTL), 10, 64)
if err != nil || ttl <= 0 {
return sharedtoken.OneLinkToken{}, fmt.Errorf("daihe device_ttl must be positive: %q", response.DeviceTTL)
}
now := time.Now()
return sharedtoken.OneLinkToken{Value: token, DeviceValue: deviceToken, CreatedAt: now, ExpiresAt: now.Add(time.Duration(ttl) * time.Second)}, nil
}
func (f daiheTokenFetcher) FetchToken(ctx context.Context, _ string) (sharedtoken.OneLinkToken, error) {
return f.provider.Fetch(ctx)
}
@@ -0,0 +1,199 @@
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)
}
// DoGetWriteRequest 用独立无复用连接发送非幂等GET,禁止运输层重放与跳转。
func (c *Client) DoGetWriteRequest(ctx context.Context, endpoint string, params url.Values) ([]byte, error) {
req, err := c.doGetRequest(ctx, endpoint, params)
if err != nil {
return nil, err
}
return c.doWriteRequest(req)
}
func (c *Client) doWriteRequest(req *http.Request) ([]byte, error) {
transport := http.DefaultTransport.(*http.Transport).Clone()
if original, ok := c.http.Transport.(*http.Transport); ok {
transport = original.Clone()
}
transport.DisableKeepAlives = true
defer transport.CloseIdleConnections()
client := *c.http
client.Transport = transport
client.CheckRedirect = func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }
return c.doRequestWithClient(&client, req)
}
func (c *Client) DoPostJSONRequest(ctx context.Context, endpoint string, body []byte) ([]byte, error) {
req, err := c.doPostJSONRequest(ctx, endpoint, body)
if err != nil {
return nil, err
}
return c.doRequest(req)
}
// DoPostJSONWriteRequest 仅用于办理写请求,禁止连接复用及自动跳转重放。
func (c *Client) DoPostJSONWriteRequest(ctx context.Context, endpoint string, body []byte) ([]byte, error) {
req, err := c.doPostJSONRequest(ctx, endpoint, body)
if err != nil {
return nil, err
}
return c.doWriteRequest(req)
}
func (c *Client) doPostJSONRequest(ctx context.Context, endpoint string, body []byte) (*http.Request, error) {
timestamp := GetTimestampGMT()
signStr, err := BuildSign("", string(body), c.appSecret, timestamp)
if c.uppercaseSign {
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 req, nil
}
// 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) {
return c.doRequestWithClient(c.http, req)
}
func (c *Client) doRequestWithClient(client *http.Client, req *http.Request) ([]byte, error) {
resp, err := client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
}
if resp.StatusCode != http.StatusOK {
var errorBody struct {
RespCode string `json:"respCode"`
}
_ = json.Unmarshal(body, &errorBody)
if c.uppercaseSign && strings.TrimSpace(errorBody.RespCode) == "" {
errorBody.RespCode = resp.Header.Get("gw-errorCode")
}
return nil, &HTTPStatusError{
StatusCode: resp.StatusCode,
Body: string(body),
RespCode: strings.TrimSpace(errorBody.RespCode),
}
}
return body, nil
}
@@ -0,0 +1,69 @@
package dx
import (
"crypto/md5"
"encoding/hex"
"sort"
"strings"
"time"
)
const (
// DefaultBaseURL 电信API基础地址
DefaultBaseURL = "https://cmp-api.ctwing.cn:20164"
)
// GetTimestampGMT 返回 GMT 时区的 yyyyMMddHHmmss 格式时间戳
func GetTimestampGMT() string {
return time.Now().UTC().Format("20060102150405")
}
// OrderParams 将 URL params 按 key 字典序排序
func OrderParams(params string) (string, error) {
if params == "" {
return "", nil
}
kvs := strings.Split(params, "&")
m := make(map[string]string, len(kvs))
for _, kv := range kvs {
idx := strings.Index(kv, "=")
if idx < 0 {
continue
}
m[kv[:idx]] = kv[idx+1:]
}
keys := make([]string, 0, len(m))
for k := range m {
keys = append(keys, k)
}
sort.Strings(keys)
var parts []string
for _, k := range keys {
parts = append(parts, k+"="+m[k])
}
return strings.Join(parts, "&"), nil
}
// SignMD5 对 reqStr 做 MD5 加密,返回小写十六进制字符串
func SignMD5(reqStr string) string {
h := md5.Sum([]byte(reqStr))
return strings.ToLower(hex.EncodeToString(h[:]))
}
// BuildSign 构造签名:params + body + secretKey + timestamp → MD5 → hex
func BuildSign(params, body, secretKey, timestamp string) (string, error) {
ordered, err := OrderParams(params)
if err != nil {
return "", err
}
reqStr := ordered + body + secretKey + timestamp
md5Str := SignMD5(reqStr)
return hex.EncodeToString([]byte(md5Str)), nil
}
// BuildSignV3 follows the V3 Java sample: encode the lowercase MD5 digest
// string as hex a second time, then uppercase that outer encoding.
func BuildSignV3(params, body, secretKey, timestamp string) (string, error) {
sign, err := BuildSign(params, body, secretKey, timestamp)
return strings.ToUpper(sign), err
}
@@ -0,0 +1,15 @@
package dx
import "testing"
func TestV3SignatureKeepsLegacyEncodingAndUsesUppercase(t *testing.T) {
// MD5("abc") is 900150983cd24fb0d6963f7d28e17f72.
legacy, err := BuildSign("", "abc", "", "")
if err != nil || legacy != "3930303135303938336364323466623064363936336637643238653137663732" {
t.Fatalf("legacy signature: %s, %v", legacy, err)
}
v3, err := BuildSignV3("", "abc", "", "")
if err != nil || v3 != "3930303135303938336364323466623064363936336637643238653137663732" {
t.Fatalf("v3 signature: %s, %v", v3, err)
}
}
@@ -0,0 +1,256 @@
package upstreamimpl
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"strconv"
"strings"
"sync"
"time"
)
const (
henanLelianLoginPath = "/api/v2/loginVerify"
henanLelianSharePath = "/api/v2/getShareToken"
henanLelianPlatformTTL = 23 * time.Hour
henanLelianShareInterval = time.Minute
)
// HenanLelianTokenConfig contains the public provider endpoints and the
// per-channel credentials required to obtain a shared OneLink token.
type HenanLelianTokenConfig struct {
BaseURL string
LoginPath string
Path string
UserName string
Password string
PasswayUID string
Timeout time.Duration
}
// HenanLelianTokenProvider first obtains a platform access token and then
// exchanges it for the carrier-issued OneLink token.
type HenanLelianTokenProvider struct {
baseURL string
loginPath string
path string
userName string
password string
passwayUID string
client *http.Client
now func() time.Time
platformMu sync.Mutex
platformToken string
platformTokenExp time.Time
shareMu sync.Mutex
nextShareAt time.Time
shareInterval time.Duration
}
type henanLelianResponse struct {
Code json.RawMessage `json:"code"`
Message string `json:"message"`
Payload struct {
AccessToken string `json:"access_token"`
Token string `json:"token"`
} `json:"payload"`
}
func NewHenanLelianTokenProvider(config HenanLelianTokenConfig) *HenanLelianTokenProvider {
timeout := config.Timeout
if timeout <= 0 {
timeout = defaultUpstreamHTTPTimeout
}
return &HenanLelianTokenProvider{
baseURL: strings.TrimRight(strings.TrimSpace(config.BaseURL), "/"),
loginPath: normalizeProviderPath(config.LoginPath, henanLelianLoginPath),
path: normalizeProviderPath(config.Path, henanLelianSharePath),
userName: strings.TrimSpace(config.UserName),
password: config.Password,
passwayUID: strings.TrimSpace(config.PasswayUID),
client: &http.Client{Timeout: timeout},
now: time.Now,
shareInterval: henanLelianShareInterval,
}
}
func (p *HenanLelianTokenProvider) Fetch(ctx context.Context) (OneLinkToken, error) {
if p == nil || p.client == nil || p.now == nil {
return OneLinkToken{}, fmt.Errorf("Henan Lelian token provider is not initialized")
}
if p.baseURL == "" {
return OneLinkToken{}, fmt.Errorf("Henan Lelian token base URL is required")
}
if p.userName == "" || strings.TrimSpace(p.password) == "" {
return OneLinkToken{}, fmt.Errorf("Henan Lelian username and password are required")
}
if p.passwayUID == "" {
return OneLinkToken{}, fmt.Errorf("Henan Lelian passwayUid is required")
}
platformToken, err := p.getPlatformToken(ctx)
if err != nil {
return OneLinkToken{}, err
}
value, authFailed, err := p.fetchSharedToken(ctx, platformToken)
if authFailed {
p.clearPlatformToken(platformToken)
}
if err != nil {
return OneLinkToken{}, err
}
now := p.now()
return OneLinkToken{Value: value, CreatedAt: now}, nil
}
func (p *HenanLelianTokenProvider) getPlatformToken(ctx context.Context) (string, error) {
p.platformMu.Lock()
defer p.platformMu.Unlock()
now := p.now()
if p.platformToken != "" && now.Before(p.platformTokenExp) {
return p.platformToken, nil
}
body, err := json.Marshal(map[string]string{
"userName": p.userName,
"password": p.password,
})
if err != nil {
return "", fmt.Errorf("encode Henan Lelian login request: %w", err)
}
responseBody, status, err := p.postJSON(ctx, p.loginPath, body, "")
if err != nil {
return "", fmt.Errorf("request Henan Lelian login: %w", err)
}
if status < http.StatusOK || status >= http.StatusMultipleChoices {
return "", fmt.Errorf("Henan Lelian login HTTP status %d: %s", status, strings.TrimSpace(string(responseBody)))
}
var output henanLelianResponse
if err = json.Unmarshal(responseBody, &output); err != nil {
return "", fmt.Errorf("decode Henan Lelian login response: %w", err)
}
code, err := parseHenanLelianCode(output.Code)
if err != nil {
return "", fmt.Errorf("decode Henan Lelian login code: %w", err)
}
if code != "0" {
return "", fmt.Errorf("Henan Lelian login failed: code=%s, message=%s", code, output.Message)
}
token := strings.TrimSpace(output.Payload.AccessToken)
if token == "" {
return "", fmt.Errorf("Henan Lelian login access_token is empty")
}
p.platformToken = token
p.platformTokenExp = now.Add(henanLelianPlatformTTL)
return token, nil
}
func (p *HenanLelianTokenProvider) fetchSharedToken(ctx context.Context, platformToken string) (string, bool, error) {
if err := p.reserveShareAttempt(); err != nil {
return "", false, err
}
body, err := json.Marshal(map[string]string{"passwayUid": p.passwayUID})
if err != nil {
return "", false, fmt.Errorf("encode Henan Lelian shared token request: %w", err)
}
responseBody, status, err := p.postJSON(ctx, p.path, body, platformToken)
if err != nil {
return "", false, fmt.Errorf("request Henan Lelian shared token: %w", err)
}
if status < http.StatusOK || status >= http.StatusMultipleChoices {
authFailed := status == http.StatusUnauthorized || status == http.StatusForbidden
return "", authFailed, fmt.Errorf("Henan Lelian shared token HTTP status %d: %s", status, strings.TrimSpace(string(responseBody)))
}
var output henanLelianResponse
if err = json.Unmarshal(responseBody, &output); err != nil {
return "", false, fmt.Errorf("decode Henan Lelian shared token response: %w", err)
}
code, err := parseHenanLelianCode(output.Code)
if err != nil {
return "", false, fmt.Errorf("decode Henan Lelian shared token code: %w", err)
}
if code != "0" {
authFailed := isHenanLelianAuthFailure(code, output.Message)
return "", authFailed, fmt.Errorf("Henan Lelian shared token failed: code=%s, message=%s", code, output.Message)
}
token := strings.TrimSpace(output.Payload.Token)
if token == "" {
return "", false, fmt.Errorf("Henan Lelian shared token is empty")
}
return token, false, nil
}
func (p *HenanLelianTokenProvider) reserveShareAttempt() error {
p.shareMu.Lock()
defer p.shareMu.Unlock()
now := p.now()
if now.Before(p.nextShareAt) {
return fmt.Errorf("Henan Lelian shared token is rate limited until %s", p.nextShareAt.Format(time.RFC3339))
}
p.nextShareAt = now.Add(p.shareInterval)
return nil
}
func (p *HenanLelianTokenProvider) clearPlatformToken(rejected string) {
p.platformMu.Lock()
if p.platformToken == rejected {
p.platformToken = ""
p.platformTokenExp = time.Time{}
}
p.platformMu.Unlock()
}
func (p *HenanLelianTokenProvider) postJSON(ctx context.Context, path string, body []byte, platformToken string) ([]byte, int, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodPost, joinProviderURL(p.baseURL, path), bytes.NewReader(body))
if err != nil {
return nil, 0, err
}
req.Header.Set("Accept", "application/json")
req.Header.Set("Content-Type", "application/json")
if platformToken != "" {
req.Header.Set("access_token", platformToken)
}
resp, err := p.client.Do(req)
if err != nil {
return nil, 0, err
}
defer resp.Body.Close()
responseBody, err := io.ReadAll(resp.Body)
return responseBody, resp.StatusCode, err
}
func parseHenanLelianCode(raw json.RawMessage) (string, error) {
var value string
if err := json.Unmarshal(raw, &value); err == nil {
return strings.TrimSpace(value), nil
}
var number json.Number
if err := json.Unmarshal(raw, &number); err != nil {
return "", err
}
if _, err := strconv.ParseInt(number.String(), 10, 64); err != nil {
return "", err
}
return number.String(), nil
}
func isHenanLelianAuthFailure(code, message string) bool {
switch strings.TrimSpace(code) {
case "401", "40101", "40102", "403":
return true
}
message = strings.ToLower(strings.TrimSpace(message))
return strings.Contains(message, "access_token") ||
strings.Contains(message, "access token") ||
strings.Contains(message, "token expired") ||
strings.Contains(message, "token invalid") ||
strings.Contains(message, "鉴权") ||
strings.Contains(message, "认证失败")
}
@@ -0,0 +1,134 @@
package upstreamimpl
import (
"context"
"crypto/md5"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strconv"
"strings"
"time"
)
// henanXinjingClient is the direct HTTP client for the Henan Xinjing SIM
// platform. Authentication is userId+apikey+times+sign where
// sign = UPPERCASE(MD5("userId=<id>&apikey=<key>&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, "")
}
@@ -0,0 +1,101 @@
package upstreamimpl
import (
"context"
"encoding/json"
"fmt"
"strings"
"time"
"github.com/gogf/gf/v2/frame/g"
"git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/model"
)
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"
"testing"
_ "git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/logic"
"git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/model"
"git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/model/entity"
"git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/service"
)
func TestMingqianRegistration(t *testing.T) {
template, ok := service.UpstreamAPI().GetTemp("sim_mingqian_v3")
if !ok || template == nil {
t.Fatal("mingqian template not registered")
}
cfg := &model.SimChannelConfigItemCache{Template: &entity.TSimChannelTemplate{}, Channel: &entity.TSimChannels{AppKey: "test", AppSecret: "test"}}
if instance := template.NewTemp(context.Background(), cfg); instance == nil {
t.Fatal("mingqian instance creation failed")
}
}
@@ -0,0 +1,145 @@
package upstreamimpl
import (
"context"
"crypto/md5"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"net/http"
"strconv"
"strings"
"time"
sharedtoken "git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/token"
)
const mobilePublicTokenPath = "/api/get/yd/api/msg"
// MobilePublicTokenConfig contains the credentials used to request a shared
// China Mobile OneLink token. Secret is sourced from t_sim_channels.app_secret.
type MobilePublicTokenConfig struct {
BaseURL string
Path string
UserID string
Secret string
Timeout time.Duration
}
// MobilePublicTokenProvider fetches a shared OneLink token from the mobile
// public-token upstream.
type MobilePublicTokenProvider struct {
baseURL string
path string
userID string
secret string
client *http.Client
now func() time.Time
}
type mobilePublicTokenResponse struct {
Code int `json:"code"`
Msg string `json:"msg"`
Data struct {
Token string `json:"token"`
ExpireTime int64 `json:"expire_time"`
} `json:"data"`
}
func NewMobilePublicTokenProvider(config MobilePublicTokenConfig) *MobilePublicTokenProvider {
timeout := config.Timeout
if timeout <= 0 {
timeout = defaultUpstreamHTTPTimeout
}
return &MobilePublicTokenProvider{
baseURL: strings.TrimRight(strings.TrimSpace(config.BaseURL), "/"),
path: normalizeProviderPath(config.Path, mobilePublicTokenPath),
userID: strings.TrimSpace(config.UserID),
secret: config.Secret,
client: &http.Client{Timeout: timeout},
now: time.Now,
}
}
func mobilePublicSign(secret, userID string, timestamp int64) string {
source := secret + userID + strconv.FormatInt(timestamp, 10) + secret
sum := md5.Sum([]byte(source))
return hex.EncodeToString(sum[:])
}
func (p *MobilePublicTokenProvider) Fetch(ctx context.Context) (sharedtoken.OneLinkToken, error) {
if p == nil || p.client == nil || p.now == nil {
return sharedtoken.OneLinkToken{}, fmt.Errorf("mobile public token provider is not initialized")
}
if p.baseURL == "" {
return sharedtoken.OneLinkToken{}, fmt.Errorf("mobile public token base URL is required")
}
if p.userID == "" || p.secret == "" {
return sharedtoken.OneLinkToken{}, fmt.Errorf("mobile public token userId and secret are required")
}
userID, err := strconv.ParseInt(p.userID, 10, 64)
if err != nil || userID <= 0 {
return sharedtoken.OneLinkToken{}, fmt.Errorf("mobile public token userId must be a positive integer")
}
now := p.now()
payload := struct {
UserID int64 `json:"userid"`
Time int64 `json:"time"`
Sign string `json:"sign"`
}{
UserID: userID,
Time: now.Unix(),
Sign: mobilePublicSign(p.secret, strconv.FormatInt(userID, 10), now.Unix()),
}
body, err := json.Marshal(payload)
if err != nil {
return sharedtoken.OneLinkToken{}, fmt.Errorf("encode mobile public token request: %w", err)
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, joinProviderURL(p.baseURL, p.path), strings.NewReader(string(body)))
if err != nil {
return sharedtoken.OneLinkToken{}, fmt.Errorf("create mobile public token request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json")
resp, err := p.client.Do(req)
if err != nil {
return sharedtoken.OneLinkToken{}, fmt.Errorf("request mobile public token: %w", err)
}
defer resp.Body.Close()
responseBody, err := io.ReadAll(resp.Body)
if err != nil {
return sharedtoken.OneLinkToken{}, fmt.Errorf("read mobile public token response: %w", err)
}
if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices {
return sharedtoken.OneLinkToken{}, fmt.Errorf("mobile public token HTTP status %d", resp.StatusCode)
}
var output mobilePublicTokenResponse
if err := json.Unmarshal(responseBody, &output); err != nil {
return sharedtoken.OneLinkToken{}, fmt.Errorf("decode mobile public token response: %w", err)
}
if output.Code != http.StatusOK {
return sharedtoken.OneLinkToken{}, fmt.Errorf("mobile public token failed: code=%d msg=%s", output.Code, strings.TrimSpace(output.Msg))
}
value := strings.TrimSpace(output.Data.Token)
if value == "" {
return sharedtoken.OneLinkToken{}, fmt.Errorf("mobile public token is empty")
}
expiresAt := time.Unix(output.Data.ExpireTime, 0)
if output.Data.ExpireTime <= 0 || !expiresAt.After(now) {
return sharedtoken.OneLinkToken{}, fmt.Errorf("mobile public token expire_time must be in the future: %d", output.Data.ExpireTime)
}
return sharedtoken.OneLinkToken{Value: value, CreatedAt: now, ExpiresAt: expiresAt}, nil
}
func (p *MobilePublicTokenProvider) FetchToken(ctx context.Context, _ string) (sharedtoken.OneLinkToken, error) {
return p.Fetch(ctx)
}
type mobilePublicFetcher struct{ provider *MobilePublicTokenProvider }
func (f mobilePublicFetcher) FetchToken(ctx context.Context, _ string) (sharedtoken.OneLinkToken, error) {
return f.provider.Fetch(ctx)
}
@@ -0,0 +1,112 @@
package upstreamimpl
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"time"
"git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/model"
"git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/model/entity"
"git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/service"
)
func TestMobilePublicTemplateRegistration(t *testing.T) {
for _, templateKey := range []string{
sharedOneLinkMobilePublicTemplateKey,
sharedOneLinkMobilePublicPreviousKey,
sharedOneLinkMobilePublicLegacyKey,
} {
t.Run(templateKey, func(t *testing.T) {
template, ok := service.UpstreamAPI().GetTemp(templateKey)
if !ok || template == nil {
t.Fatal("mobile public template not registered")
}
config := &model.SimChannelConfigItemCache{
Template: &entity.TSimChannelTemplate{
TempKey: templateKey,
TempConfig: `{"token":{"provider":"mobile_public","baseUrl":"https://example.com","path":"/api/get/yd/api/msg"}}`,
},
Channel: &entity.TSimChannels{
ChanKey: "daliuliang-test",
ChanConfig: `{"userId":"102369"}`,
AppSecret: "test-secret",
},
}
if instance := template.NewTemp(context.Background(), config); instance == nil {
t.Fatal("mobile public channel failed to instantiate")
}
})
}
}
func TestMobilePublicSign(t *testing.T) {
const timestamp = int64(1699606000)
if got, want := mobilePublicSign("bbb", "aaa", timestamp), "2337f8d0d5c0cd9e415a3b3c27c4a595"; got != want {
t.Fatalf("sign = %q, want %q", got, want)
}
}
func TestMobilePublicTokenProviderFetch(t *testing.T) {
now := time.Unix(1700000000, 0)
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost || r.URL.Path != mobilePublicTokenPath {
t.Fatalf("request = %s %s", r.Method, r.URL.Path)
}
var input struct {
UserID int64 `json:"userid"`
Time int64 `json:"time"`
Sign string `json:"sign"`
}
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
t.Fatal(err)
}
if input.UserID != 102369 || input.Time != now.Unix() {
t.Fatalf("payload = %#v", input)
}
if want := mobilePublicSign("secret", "102369", input.Time); input.Sign != want {
t.Fatalf("sign = %q, want %q", input.Sign, want)
}
_, _ = w.Write([]byte(`{"code":200,"msg":"success","data":{"token":"token-1","expire_time":1700003600}}`))
}))
defer server.Close()
provider := NewMobilePublicTokenProvider(MobilePublicTokenConfig{
BaseURL: server.URL, UserID: "102369", Secret: "secret", Timeout: time.Second,
})
provider.now = func() time.Time { return now }
token, err := provider.Fetch(context.Background())
if err != nil {
t.Fatalf("fetch token: %v", err)
}
if token.Value != "token-1" || !token.ExpiresAt.Equal(time.Unix(1700003600, 0)) {
t.Fatalf("token = %#v", token)
}
}
func TestMobilePublicTokenProviderRejectsExpiredToken(t *testing.T) {
now := time.Unix(1700000000, 0)
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte(`{"code":200,"data":{"token":"token-1","expire_time":1699999999}}`))
}))
defer server.Close()
provider := NewMobilePublicTokenProvider(MobilePublicTokenConfig{
BaseURL: server.URL, UserID: "102369", Secret: "secret", Timeout: time.Second,
})
provider.now = func() time.Time { return now }
if _, err := provider.Fetch(context.Background()); err == nil {
t.Fatal("expected expired token error")
}
}
func TestMobilePublicTokenProviderRejectsNonNumericUserID(t *testing.T) {
provider := NewMobilePublicTokenProvider(MobilePublicTokenConfig{
BaseURL: "http://127.0.0.1", UserID: "not-a-number", Secret: "secret", Timeout: time.Second,
})
if _, err := provider.Fetch(context.Background()); err == nil {
t.Fatal("expected invalid userId error")
}
}
@@ -0,0 +1,116 @@
package upstreamimpl
import (
"context"
"crypto/md5"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"time"
sharedtoken "git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/token"
)
type QianxunTokenConfig struct {
BaseURL string
Path string
OperatorID string
AppID string
TokenType string
Key string
Timeout time.Duration
}
type QianxunTokenProvider struct {
baseURL, path, operatorID, appID, tokenType, key string
client *http.Client
}
type qianxunTokenResponse struct {
Code int `json:"code"`
Msg string `json:"msg"`
Data struct {
Token string `json:"token"`
ExpireDate int64 `json:"expireDate"`
} `json:"data"`
}
func NewQianxunTokenProvider(config QianxunTokenConfig) *QianxunTokenProvider {
timeout := config.Timeout
if timeout <= 0 {
timeout = defaultUpstreamHTTPTimeout
}
return &QianxunTokenProvider{
baseURL: strings.TrimRight(strings.TrimSpace(config.BaseURL), "/"),
path: normalizeProviderPath(config.Path, "/"), operatorID: strings.TrimSpace(config.OperatorID),
appID: strings.TrimSpace(config.AppID), tokenType: strings.TrimSpace(config.TokenType), key: config.Key,
client: &http.Client{Timeout: timeout},
}
}
func qianxunSharedSign(operatorID, appID, key string) string {
sum := md5.Sum([]byte(operatorID + appID + key))
return hex.EncodeToString(sum[:])
}
func (p *QianxunTokenProvider) Fetch(ctx context.Context) (sharedtoken.OneLinkToken, error) {
if p == nil || p.client == nil {
return sharedtoken.OneLinkToken{}, fmt.Errorf("qianxun token provider is not initialized")
}
if p.baseURL == "" || p.operatorID == "" || p.appID == "" || p.tokenType == "" || p.key == "" {
return sharedtoken.OneLinkToken{}, fmt.Errorf("qianxun token endpoint and credentials are required")
}
payload := map[string]string{"operatorId": p.operatorID, "appId": p.appID, "tokenType": p.tokenType, "sign": qianxunSharedSign(p.operatorID, p.appID, p.key)}
body, err := json.Marshal(payload)
if err != nil {
return sharedtoken.OneLinkToken{}, fmt.Errorf("encode qianxun token request: %w", err)
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, joinProviderURL(p.baseURL, p.path), strings.NewReader(string(body)))
if err != nil {
return sharedtoken.OneLinkToken{}, fmt.Errorf("create qianxun token request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json")
resp, err := p.client.Do(req)
if err != nil {
return sharedtoken.OneLinkToken{}, fmt.Errorf("request qianxun token: %w", err)
}
defer resp.Body.Close()
responseBody, err := io.ReadAll(resp.Body)
if err != nil {
return sharedtoken.OneLinkToken{}, fmt.Errorf("read qianxun token response: %w", err)
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return sharedtoken.OneLinkToken{}, fmt.Errorf("qianxun token HTTP status %d", resp.StatusCode)
}
var output qianxunTokenResponse
if err := json.Unmarshal(responseBody, &output); err != nil {
return sharedtoken.OneLinkToken{}, fmt.Errorf("decode qianxun token response: %w", err)
}
if output.Code != 0 {
return sharedtoken.OneLinkToken{}, fmt.Errorf("qianxun token failed: code=%d msg=%s", output.Code, output.Msg)
}
value := strings.TrimSpace(output.Data.Token)
if value == "" {
return sharedtoken.OneLinkToken{}, fmt.Errorf("qianxun token is empty")
}
now := time.Now()
var expiresAt time.Time
if output.Data.ExpireDate > 0 {
expiresAt = now.Add(time.Duration(output.Data.ExpireDate) * time.Second)
}
return sharedtoken.OneLinkToken{Value: value, CreatedAt: now, ExpiresAt: expiresAt}, nil
}
func (p *QianxunTokenProvider) FetchToken(ctx context.Context, _ string) (sharedtoken.OneLinkToken, error) {
return p.Fetch(ctx)
}
type qianxunFetcher struct{ provider *QianxunTokenProvider }
func (f qianxunFetcher) FetchToken(ctx context.Context, _ string) (sharedtoken.OneLinkToken, error) {
return f.provider.Fetch(ctx)
}
@@ -0,0 +1,50 @@
package upstreamimpl
import (
"context"
"net/http"
"net/http/httptest"
"testing"
"time"
)
func TestQianxunTokenProviderAcceptsTokenWithoutExpiry(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost || r.URL.Path != "/oauth/access_token" {
t.Fatalf("request = %s %s", r.Method, r.URL.Path)
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"code":0,"msg":"success","data":{"token":"token-1","expireDate":0}}`))
}))
defer server.Close()
provider := NewQianxunTokenProvider(QianxunTokenConfig{
BaseURL: server.URL, Path: "/oauth/access_token", OperatorID: "operator-1",
AppID: "app-1", TokenType: "card", Key: "key-1", Timeout: time.Second,
})
token, err := provider.Fetch(context.Background())
if err != nil {
t.Fatalf("fetch token: %v", err)
}
if token.Value != "token-1" {
t.Fatalf("token = %q", token.Value)
}
if !token.ExpiresAt.IsZero() {
t.Fatalf("expireDate=0 must produce a token without expiry: %s", token.ExpiresAt)
}
}
func TestQianxunTokenProviderRejectsEmptyToken(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte(`{"code":0,"msg":"success","data":{"token":"","expireDate":0}}`))
}))
defer server.Close()
provider := NewQianxunTokenProvider(QianxunTokenConfig{
BaseURL: server.URL, OperatorID: "operator-1", AppID: "app-1",
TokenType: "card", Key: "key-1", Timeout: time.Second,
})
if _, err := provider.Fetch(context.Background()); err == nil {
t.Fatal("expected empty token error")
}
}
@@ -0,0 +1,177 @@
package recharge
import (
"bytes"
"context"
"crypto/md5"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"net/http"
"strconv"
"strings"
"sync"
"time"
)
const DefaultBaseURL = "http://www.sdtywl.cn/"
const requestInterval = time.Second
var requestGates sync.Map
// 上游业务码(综合充值平台API文档-通用反馈状态码)
const (
CodeSuccess = 200 // 成功
CodeMissingParam = 400 // 缺少参数
CodeSignError = 401 // sign错误
CodeForbidden = 403 // 无权访问
CodePwdKeyError = 404 // pwdkey错误
CodeNotWhiteList = 407 // 非白名单IP
CodeRateLimited = 429 // 访问超频(单接口60次/分)
CodeServerError = 500 // 其他错误
)
// BusinessError 上游业务错误(HTTP 200 但 code != 200)
type BusinessError struct {
Code int
Msg string
}
func (e *BusinessError) Error() string {
return fmt.Sprintf("recharge upstream business error, code %d, msg %s", e.Code, e.Msg)
}
// Response 上游统一响应信封
type Response struct {
Code int `json:"code"`
Msg string `json:"msg"`
Data json.RawMessage `json:"data"`
}
type Config struct {
BaseURL string
PwdKey string
Pwdiv string
Timeout time.Duration
}
type Client struct {
baseURL string
pwdKey string
pwdiv string
http *http.Client
}
type requestGate struct {
mu sync.Mutex
next time.Time
}
func (gate *requestGate) wait(ctx context.Context) error {
for {
if err := ctx.Err(); err != nil {
return err
}
gate.mu.Lock()
delay := time.Until(gate.next)
if delay <= 0 {
gate.next = time.Now().Add(requestInterval)
gate.mu.Unlock()
return nil
}
gate.mu.Unlock()
timer := time.NewTimer(delay)
select {
case <-ctx.Done():
timer.Stop()
return ctx.Err()
case <-timer.C:
}
}
}
func NewClient(cfg Config) *Client {
baseURL := cfg.BaseURL
if baseURL == "" {
baseURL = DefaultBaseURL
}
timeout := cfg.Timeout
if timeout <= 0 {
timeout = 10 * time.Second
}
return &Client{
baseURL: strings.TrimSuffix(baseURL, "/"),
pwdKey: cfg.PwdKey,
pwdiv: cfg.Pwdiv,
http: &http.Client{Timeout: timeout},
}
}
// DoPost 调用上游 POST 接口,返回业务信封;HTTP 层错误直接返回 error,
// 业务码(code != 200)由调用方判断
func (c *Client) DoPost(ctx context.Context, endpoint string, data any) (*Response, error) {
if err := c.requestGate(endpoint).wait(ctx); err != nil {
return nil, err
}
timestamp := strconv.FormatInt(time.Now().UnixMilli(), 10)
payload := map[string]any{
"pwdkey": c.pwdKey,
"timestamp": timestamp,
"sign": c.sign(timestamp),
}
if data != nil {
payload["data"] = data
}
body, err := json.Marshal(payload)
if err != nil {
return nil, err
}
return c.do(ctx, http.MethodPost, c.baseURL+"/prod-api/open/api"+endpoint, body)
}
func (c *Client) requestGate(endpoint string) *requestGate {
key := c.baseURL + "|" + c.pwdKey + "|" + strings.TrimSpace(endpoint)
gate, _ := requestGates.LoadOrStore(key, &requestGate{})
return gate.(*requestGate)
}
func (c *Client) sign(timestamp string) string {
sum := md5.Sum([]byte(c.pwdKey + c.pwdiv + timestamp))
return strings.ToLower(hex.EncodeToString(sum[:]))
}
func (c *Client) do(ctx context.Context, method, endpoint string, body []byte) (*Response, error) {
var reader io.Reader
if body != nil {
reader = bytes.NewReader(body)
}
req, err := http.NewRequestWithContext(ctx, method, endpoint, reader)
if err != nil {
return nil, err
}
req.Header.Set("Accept", "application/json")
if body != nil {
req.Header.Set("Content-Type", "application/json; charset=utf-8")
}
resp, err := c.http.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
respBody, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
}
if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices {
return nil, fmt.Errorf("recharge upstream http status %d: %s", resp.StatusCode, string(respBody))
}
var output Response
if err := json.Unmarshal(respBody, &output); err != nil {
return nil, fmt.Errorf("decode recharge upstream response: %w, body=%s", err, string(respBody))
}
return &output, nil
}
@@ -0,0 +1,33 @@
package recharge
import (
"context"
"errors"
"testing"
"time"
)
func TestRequestGateHonorsCancellation(t *testing.T) {
gate := &requestGate{}
if err := gate.wait(context.Background()); err != nil {
t.Fatalf("first wait failed: %v", err)
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Millisecond)
defer cancel()
if err := gate.wait(ctx); !errors.Is(err, context.DeadlineExceeded) {
t.Fatalf("wait error = %v, want deadline exceeded", err)
}
}
func TestClientSharesGatePerAccountAndEndpoint(t *testing.T) {
first := NewClient(Config{BaseURL: "http://example.com", PwdKey: "account"})
second := NewClient(Config{BaseURL: "http://example.com", PwdKey: "account"})
if first.requestGate("/findCard") != second.requestGate("/findCard") {
t.Fatal("same account and endpoint must share the rate limiter")
}
if first.requestGate("/findCard") == first.requestGate("/changeState") {
t.Fatal("different endpoints must not share the rate limiter")
}
}
@@ -0,0 +1,265 @@
package upstreamimpl
import (
"context"
"encoding/json"
"fmt"
"strings"
"time"
"github.com/gogf/gf/v2/frame/g"
"git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/consts"
"git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/model"
"git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/service"
"git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/service/daihe"
)
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") != "89860000000000000004" {
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: "89860000000000000004"}}})
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") != "8986000000000000007" {
t.Errorf("unexpected request %s", r.URL.String())
}
_, _ = w.Write([]byte(`{"code":"0","data":{"orderStaus":"0","bindType":"1","bindImei":"123456789012345","bindStaus":"1"}}`))
}))
defer server.Close()
sim := &sSimDX{client: dx.NewClient(dx.Config{BaseURL: server.URL, AppKey: "test", AppSecret: "secret"})}
result, err := sim.QuerySimCardBindStatus(context.Background(), &model.SimCardBindStatusInput{SimKeys: []*model.SimKey{{ICCID: "8986000000000000007"}}})
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") != "8986000000000000007" {
t.Errorf("unexpected request: %s", r.URL.String())
}
_, _ = w.Write([]byte(tc.body))
}))
sim := &sSimDX{client: dx.NewClient(dx.Config{BaseURL: server.URL, AppKey: "test", AppSecret: "secret"})}
results, err := sim.QuerySimImei(context.Background(), &model.SimImeiInput{SimKeys: []*model.SimKey{{ICCID: "8986000000000000007"}}})
server.Close()
if err != nil || len(results) != 1 || results[0].Code != tc.wantCode || results[0].ResData.Imei != tc.wantImei || results[0].Message != tc.wantMessage {
t.Errorf("response = %+v, error = %v", results, err)
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,247 @@
package upstreamimpl
import (
"context"
"encoding/json"
"encoding/xml"
"fmt"
"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) {
checkpoint, durable := ctx.Value(model.ApnOperationCheckpointKey{}).(func(model.SimCardOperateChangeApnOutput) error)
if !durable {
return s.changeSimApnFunctionCards(ctx, input)
}
items := make(model.SimCardOperateChangeApnOutput, 0, len(input.SimKeys))
for _, key := range input.SimKeys {
one := *input
one.SimKeys = []*model.SimKey{key}
result, err := s.changeSimApnFunctionCards(ctx, &one)
if err != nil {
return nil, err
}
items = append(items, result...)
if err := checkpoint(items); err != nil {
return nil, fmt.Errorf("电信 v1 第%d张卡办理结果持久化失败,停止后续写入:%w", len(items), err)
}
}
return items, nil
}
func (s *sSimDX) changeSimApnFunctionCards(ctx context.Context, input *model.SimCardOperateChangeApnInput) (model.SimCardOperateChangeApnOutput, error) {
// 响应结构 (XML)
type dxCutNetResponse struct {
XMLName xml.Name `xml:"SvcCont"`
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)
_, durable := ctx.Value(model.ApnOperationCheckpointKey{}).(func(model.SimCardOperateChangeApnOutput) error)
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},
}
// singleCutNet 虽使用 GET,但为非幂等写请求,禁止运输层重放和重定向。
respBody, err := s.client.DoGetWriteRequest(ctx, "/5gcmp/openapi/v1/common/singleCutNet", params)
if err != nil {
g.Log().Errorf(ctx, "电信单独断网请求异常,办理结果未确认,chanKey=%s,错误类型=%T", simKey.ChanKey, err)
item.Code, item.Message = consts.ApiStatusError, err.Error()
item.ResData.Uncertain = true
continue
}
g.Log().Debugf(ctx, "ChangeSimApnFunction response: %s", string(respBody))
// 去除XML命名空间前缀
xmlBody := xmlNamespaceRegex.ReplaceAllString(string(respBody), "")
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()
item.ResData.Uncertain = true
continue
}
item.Message = strings.TrimSpace(dxResp.Response.RspDesc)
rawCode := strings.TrimSpace(dxResp.Response.RspCode)
item.ErrCode = rawCode
// 缺返回码属于未确认;明确拒绝无需按未知结果重试。
if rawCode != dxCutNetSuccessCode {
g.Log().Warningf(ctx, "ChangeSimApnFunction upstream error %s, %s, simKey %s", rawCode, item.Message, simKey.ChanKey)
item.Code = consts.ApiStatusError
if rawCode == "" {
item.ResData.Uncertain = true
item.Message = "电信单独断网未返回结果码,办理结果未确认,请核对上游"
} else 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, "电信单独断网未返回订单号,办理结果未确认,请核对上游"
item.ResData.Uncertain = true
} else if input.ReqData.SubmitOnly || durable {
item.ResData.Uncertain = true
item.Message = "电信单独断网请求已受理,最终办理结果未确认,请核对上游"
}
}
return items, nil
}
@@ -0,0 +1,333 @@
package upstreamimpl
import (
"context"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"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") != "13300000000" || request.URL.Query().Get("access_number") != "" {
t.Errorf("query = %s", request.URL.RawQuery)
}
if request.Header.Get("AppKey") != "test" || request.Header.Get("Sign") == "" || request.Header.Get("Timestamp") == "" {
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: "13300000000"}}})
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: " 8986000000000000006 "}, param: "iccid", value: "8986000000000000006"},
{name: "imsi", key: &model.SimKey{IMSI: "460000000000001"}, param: "imsi", value: "460000000000001"},
{name: "nil", key: nil},
{name: "empty", key: &model.SimKey{}},
}
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") != "13300000000" {
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: "13300000000", ChanKey: "71"}},
ReqData: model.SimCardOperateChangeApnInputItem{ApnName: "CMIOT", OperType: test.operType},
})
if err != nil || len(output) != 1 || output[0].Code != 0 || output[0].ResData.OrderNum != "order-1" || requests != 1 {
t.Fatalf("output = %+v, error = %v, requests = %d", output, err, requests)
}
})
}
}
func TestDXSingleCutNetAcceptanceModes(t *testing.T) {
for _, mode := range []string{"同步兼容", "仅提交", "持久化回调"} {
for _, accepted := range []bool{true, false} {
t.Run(fmt.Sprintf("%s/受理=%t", mode, accepted), func(t *testing.T) {
writes, checkpoints := 0, 0
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
writes++
code := "1000"
if accepted {
code = "0000"
}
fmt.Fprintf(w, `<SvcCont><Response><RspCode>%s</RspCode><RspDesc>响应</RspDesc></Response><GROUP_TRANSACTIONID>order-%d</GROUP_TRANSACTIONID></SvcCont>`, code, writes)
}))
defer server.Close()
sim := &sSimDX{client: dx.NewClient(dx.Config{BaseURL: server.URL})}
wantUncertain := accepted && mode != "同步兼容"
check := func(items model.SimCardOperateChangeApnOutput) {
t.Helper()
for i, item := range items {
if (item.Code == 0) != accepted || item.ResData.Uncertain != wantUncertain || item.ResData.Pending {
t.Fatalf("受理与完成分类错误:%+v", item)
}
if accepted && (item.ErrCode != "0000" || item.ResData.OrderNum != fmt.Sprintf("order-%d", i+1)) {
t.Fatalf("未保留逐卡受理流水与原始码:%+v", item)
}
if wantUncertain && !strings.Contains(item.Message, "最终办理结果未确认") {
t.Fatalf("受理消息未明确最终结果未知:%s", item.Message)
}
if !accepted && (item.ErrCode != "1000" || item.ResData.OrderNum != "") {
t.Fatalf("明确拒绝不应携带受理流水:%+v", item)
}
}
}
ctx := context.Background()
if mode == "持久化回调" {
ctx = context.WithValue(ctx, model.ApnOperationCheckpointKey{}, func(items model.SimCardOperateChangeApnOutput) error {
checkpoints++
check(items)
return nil
})
}
items, err := sim.ChangeSimApnFunction(ctx, &model.SimCardOperateChangeApnInput{
SimKeys: []*model.SimKey{{ChanKey: "test", ICCID: "card-1", MSISDN: "test-msisdn-1"}, {ChanKey: "test", ICCID: "card-2", MSISDN: "test-msisdn-2"}},
ReqData: model.SimCardOperateChangeApnInputItem{OperType: "1", SubmitOnly: mode == "仅提交"},
})
if err != nil || len(items) != 2 || writes != 2 || (mode == "持久化回调" && checkpoints != 2) {
t.Fatalf("逐卡处理异常:items=%+v writes=%d checkpoints=%d err=%v", items, writes, checkpoints, err)
}
check(items)
})
}
}
}
func TestDXSingleCutNetRedirectDoesNotReplay(t *testing.T) {
for _, status := range []int{301, 302, 307, 308} {
t.Run(fmt.Sprint(status), func(t *testing.T) {
writes, redirected := 0, 0
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/redirected" {
redirected++
return
}
writes++
w.Header().Set("Location", "/redirected")
w.WriteHeader(status)
}))
defer server.Close()
sim := &sSimDX{client: dx.NewClient(dx.Config{BaseURL: server.URL})}
output, err := sim.ChangeSimApnFunction(context.Background(), &model.SimCardOperateChangeApnInput{
SimKeys: []*model.SimKey{{MSISDN: "13300000000"}},
ReqData: model.SimCardOperateChangeApnInputItem{OperType: "1"},
})
if err != nil || len(output) != 1 {
t.Fatalf("output=%+v err=%v", output, err)
}
if writes != 1 || redirected != 0 || output[0].Code == 0 || !output[0].ResData.Uncertain {
t.Fatalf("writes=%d redirected=%d output=%+v", writes, redirected, output)
}
})
}
}
func TestDXSingleCutNetResponseLossIsUncertain(t *testing.T) {
writes := 0
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
writes++
conn, _, err := w.(http.Hijacker).Hijack()
if err != nil {
t.Errorf("hijack: %v", err)
return
}
_ = conn.Close()
}))
defer server.Close()
sim := &sSimDX{client: dx.NewClient(dx.Config{BaseURL: server.URL})}
output, err := sim.ChangeSimApnFunction(context.Background(), &model.SimCardOperateChangeApnInput{
SimKeys: []*model.SimKey{{MSISDN: "13300000000"}},
ReqData: model.SimCardOperateChangeApnInputItem{OperType: "1"},
})
if err != nil || len(output) != 1 {
t.Fatalf("output=%+v err=%v", output, err)
}
if writes != 1 || output[0].Code == 0 || !output[0].ResData.Uncertain {
t.Fatalf("writes=%d output=%+v", writes, output)
}
}
func TestDXSingleCutNetDurableCheckpoint(t *testing.T) {
writes, checkpoints := 0, 0
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
writes++
fmt.Fprint(w, `<?xml version="1.0"?><SvcCont><Response><RspCode>0000</RspCode></Response><GROUP_TRANSACTIONID>job-1</GROUP_TRANSACTIONID></SvcCont>`)
}))
defer server.Close()
sim := &sSimDX{client: dx.NewClient(dx.Config{BaseURL: server.URL})}
ctx := context.WithValue(context.Background(), model.ApnOperationCheckpointKey{}, func(items model.SimCardOperateChangeApnOutput) error {
checkpoints++
if len(items) != 1 || items[0].ResData.OrderNum != "job-1" {
t.Errorf("checkpoint must preserve the first card job ID: %+v", items)
}
return fmt.Errorf("stop after durable checkpoint")
})
_, err := sim.ChangeSimApnFunction(ctx, &model.SimCardOperateChangeApnInput{
SimKeys: []*model.SimKey{{MSISDN: "13300000000"}, {MSISDN: "13300000001"}},
ReqData: model.SimCardOperateChangeApnInputItem{OperType: "1"},
})
if err == nil || writes != 1 || checkpoints != 1 {
t.Fatalf("must stop before second write: writes=%d checkpoints=%d err=%v", writes, checkpoints, err)
}
}
// TestDXChangeAPNSingleCutNetFailClosed 验证失败保护:不成功不重试、不盲目视为成功。
func TestDXChangeAPNSingleCutNetFailClosed(t *testing.T) {
tests := []struct {
name string
operType string
msisdn string
body string
httpStatus int
wantRequests int
}{
{name: "missing access number", operType: "0"},
{name: "invalid operation", operType: "2", msisdn: "13300000000"},
{name: "carrier rejected", operType: "1", msisdn: "13300000000", body: `<?xml version="1.0"?><SvcCont><Response><RspCode>1000</RspCode><RspDesc>rejected</RspDesc></Response></SvcCont>`, wantRequests: 1},
{name: "missing result code", operType: "0", msisdn: "13300000000", 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: "13300000000", body: `<?xml version="1.0"?><SvcCont><Response><RspCode>0000</RspCode><RspDesc>ok</RspDesc></Response></SvcCont>`, wantRequests: 1},
{name: "malformed response", operType: "0", msisdn: "13300000000", body: `{"code":"0"}`, wantRequests: 1},
{name: "HTTP failed no retry", operType: "0", msisdn: "13300000000", body: `gateway down`, httpStatus: 503, wantRequests: 1},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
requests := 0
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
requests++
if request.URL.Path != "/5gcmp/openapi/v1/common/singleCutNet" {
t.Errorf("unexpected endpoint: %s", request.URL.Path)
}
if test.httpStatus != 0 {
writer.WriteHeader(test.httpStatus)
}
fmt.Fprint(writer, test.body)
}))
defer server.Close()
sim := &sSimDX{client: dx.NewClient(dx.Config{BaseURL: server.URL})}
output, err := sim.ChangeSimApnFunction(context.Background(), &model.SimCardOperateChangeApnInput{
SimKeys: []*model.SimKey{{MSISDN: test.msisdn, ChanKey: "71"}},
ReqData: model.SimCardOperateChangeApnInputItem{ApnName: "CMIOT", OperType: test.operType},
})
if err != nil || len(output) != 1 || output[0].Code == 0 || output[0].ResData.OrderNum != "" || requests != test.wantRequests {
t.Fatalf("output = %+v, error = %v, requests = %d", output, err, requests)
}
})
}
}
@@ -0,0 +1,641 @@
package upstreamimpl
import (
"context"
"encoding/json"
"fmt"
"math"
"strconv"
"strings"
"time"
"git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/consts"
"git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/logic/upstreamimpl/dx"
"git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/model"
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/util/gconv"
)
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 (
"strings"
"git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/model"
)
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"
"net/http"
"net/http/httptest"
"testing"
"git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/logic/upstreamimpl/dx"
"git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/model"
)
func TestDXNetworkBlockStatus(t *testing.T) {
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") != "1440000000001" {
t.Errorf("request = %s", request.URL)
}
fmt.Fprint(writer, `{"result":"0","resultMsg":"处理成功","productInfo":[{"productMainStatusCd":"4"}],"netBlockStatusName":"已断网","netBlockTypeList":[{"blockType":"1","blockTypeName":"达量断网"}]}`)
}))
defer server.Close()
sim := &sSimDX{client: dx.NewClient(dx.Config{BaseURL: server.URL})}
output, err := sim.QuerySimCardStatus(context.Background(), &model.SimCardStatusInput{SimKeys: []*model.SimKey{{MSISDN: "1440000000001"}}})
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)
}
}

Some files were not shown because too many files have changed in this diff Show More