chore: 并入 iotlcm-upstream-chan 历史到 upstream-chan/(原提交 1e74497)
This commit is contained in:
@@ -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)"
|
||||
@@ -0,0 +1,28 @@
|
||||
.buildpath
|
||||
.hgignore.swp
|
||||
.project
|
||||
.orig
|
||||
.swp
|
||||
.idea/
|
||||
.settings/
|
||||
.vscode/
|
||||
*.yaml
|
||||
config.yaml
|
||||
bin/
|
||||
**/.DS_Store
|
||||
gf
|
||||
main
|
||||
*.exe
|
||||
output/
|
||||
manifest/output/
|
||||
temp/
|
||||
temp.yaml
|
||||
bin
|
||||
*.log
|
||||
.keep-if-necessary
|
||||
.gitkeep
|
||||
# 屏蔽Go编译缓存
|
||||
**/.gocache*/
|
||||
.gocache/
|
||||
.gocache-admin/
|
||||
.playwright-cli/
|
||||
@@ -0,0 +1,201 @@
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
1. Definitions.
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work
|
||||
(an example is provided in the Appendix below).
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||
or by an individual or Legal Entity authorized to submit on behalf of
|
||||
the copyright owner. For the purposes of this definition, "submitted"
|
||||
means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems,
|
||||
and issue tracking systems that are managed by, or on behalf of, the
|
||||
Licensor for the purpose of discussing and improving the Work, but
|
||||
excluding communication that is conspicuously marked or otherwise
|
||||
designated in writing by the copyright owner as "Not a Contribution."
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable
|
||||
by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||
or a Contribution incorporated within the Work constitutes direct
|
||||
or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
|
||||
(a) You must give any other recipients of the Work or
|
||||
Derivative Works a copy of this License; and
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding those notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that You distribute, alongside
|
||||
or as an addendum to the NOTICE text from the Work, provided
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for reasonable and customary use in describing the
|
||||
origin of the Work and reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may act only
|
||||
on Your own behalf and on Your sole responsibility, not on behalf
|
||||
of any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or additional liability.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
APPENDIX: How to apply the Apache License to your work.
|
||||
|
||||
To apply the Apache License to your work, attach the following
|
||||
boilerplate notice, with the fields enclosed by brackets "[]"
|
||||
replaced with your own identifying information. (Don't include
|
||||
the brackets!) The text should be enclosed in the appropriate
|
||||
comment syntax for the file format. We also recommend that a
|
||||
file or class name and description of purpose be included on the
|
||||
same "printed page" as the copyright notice for easier
|
||||
identification within third-party archives.
|
||||
|
||||
Copyright [yyyy] [name of copyright owner]
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
@@ -0,0 +1,7 @@
|
||||
ROOT_DIR = $(shell pwd)
|
||||
NAMESPACE = "default"
|
||||
DEPLOY_NAME = "template-single"
|
||||
DOCKER_NAME = "template-single"
|
||||
|
||||
include ./hack/hack-cli.mk
|
||||
include ./hack/hack.mk
|
||||
@@ -0,0 +1,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`;需要指定历史月份时使用独立的月查询接口。
|
||||
@@ -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]
|
||||
@@ -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
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"api": {
|
||||
"baseUrl": "https://cmp-api.ctwing.cn:20164",
|
||||
"enableOperations": false
|
||||
},
|
||||
"http": {
|
||||
"timeout": "10s"
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
@@ -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=
|
||||
@@ -0,0 +1,20 @@
|
||||
|
||||
# Install/Update to the latest CLI tool.
|
||||
.PHONY: cli
|
||||
cli:
|
||||
@set -e; \
|
||||
wget -O gf \
|
||||
https://github.com/gogf/gf/releases/latest/download/gf_$(shell go env GOOS)_$(shell go env GOARCH) && \
|
||||
chmod +x gf && \
|
||||
./gf install -y && \
|
||||
rm ./gf
|
||||
|
||||
|
||||
# Check and install CLI tool.
|
||||
.PHONY: cli.install
|
||||
cli.install:
|
||||
@set -e; \
|
||||
gf -v > /dev/null 2>&1 || if [[ "$?" -ne "0" ]]; then \
|
||||
echo "GoFame CLI is not installed, start proceeding auto installation..."; \
|
||||
make cli; \
|
||||
fi;
|
||||
@@ -0,0 +1,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"
|
||||
@@ -0,0 +1,232 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/controller/cardquery"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/controller/infopb"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/controller/msgcallpb"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/model"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/service"
|
||||
sharedtoken "git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/token"
|
||||
|
||||
"git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/pub/xjpubconsts"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/pub/xjpubservice"
|
||||
"github.com/gogf/gf/contrib/rpc/grpcx/v2"
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
"github.com/gogf/gf/v2/net/ghttp"
|
||||
"github.com/gogf/gf/v2/net/gsvc"
|
||||
"github.com/gogf/gf/v2/os/gcmd"
|
||||
"github.com/gogf/gf/v2/util/gconv"
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/grpc/reflection"
|
||||
)
|
||||
|
||||
const (
|
||||
ServiceNamePrefix = "iotlcmupstreamchan"
|
||||
)
|
||||
|
||||
var (
|
||||
gGrpcPort = 8700
|
||||
gAccessIP = "0.0.0.0"
|
||||
)
|
||||
var (
|
||||
Main = gcmd.Command{
|
||||
Name: "main",
|
||||
Usage: "main",
|
||||
Brief: "start http server",
|
||||
Func: func(ctx context.Context, parser *gcmd.Parser) (err error) {
|
||||
s := g.Server()
|
||||
gGrpcPort = gconv.Int(gcmd.GetOptWithEnv("grpcport"))
|
||||
gAccessIP = gconv.String(gcmd.GetOptWithEnv("accessip"))
|
||||
|
||||
if len(gAccessIP) == 0 {
|
||||
gAccessIP = "0.0.0.0"
|
||||
}
|
||||
if gGrpcPort == 0 {
|
||||
gGrpcPort = 8110
|
||||
}
|
||||
nodeName := fmt.Sprintf("%s", ServiceNamePrefix)
|
||||
|
||||
xjpubservice.Info().SetInfoName(xjpubconsts.ServiceTypeAccess, fmt.Sprintf("%s-http-svc", ServiceNamePrefix), "上游运营商渠道服务", "上游运营商渠道服务")
|
||||
xjpubservice.Info().AddInfoData("grpcport", gGrpcPort)
|
||||
xjpubservice.Info().AddInfoData("accessip", gAccessIP)
|
||||
xjpubservice.Info().AddInfoData("nodeName", nodeName)
|
||||
|
||||
xjpubservice.InfoRpc().SetInfoName(xjpubconsts.ServiceTypeAccess, fmt.Sprintf("%s-grpc-svc", ServiceNamePrefix), "上游运营商渠道服务", "上游运营商渠道服务")
|
||||
xjpubservice.InfoRpc().AddInfoData("grpcport", gGrpcPort)
|
||||
xjpubservice.InfoRpc().AddInfoData("accessip", gAccessIP)
|
||||
xjpubservice.InfoRpc().AddInfoData("nodeName", nodeName)
|
||||
bindHTTPRoutes(s)
|
||||
err = service.ChannelConfig().Start(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err = service.UpstreamAPI().Start(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
go func() {
|
||||
c := grpcx.Server.NewConfig()
|
||||
c.Endpoints = []string{fmt.Sprintf("%s:%d", gAccessIP, gGrpcPort)}
|
||||
c.Options = append(c.Options, []grpc.ServerOption{
|
||||
grpcx.Server.ChainUnary(
|
||||
grpcx.Server.UnaryValidate,
|
||||
),
|
||||
}...)
|
||||
sGRPC := grpcx.Server.New(c)
|
||||
sGRPC.Service(&gsvc.LocalService{
|
||||
Name: fmt.Sprintf("%s-grpc-svc", ServiceNamePrefix),
|
||||
Endpoints: gsvc.Endpoints{gsvc.NewEndpoint(fmt.Sprintf("%s:%d", gAccessIP, gGrpcPort))},
|
||||
Metadata: gsvc.Metadata{
|
||||
gsvc.MDProtocol: "grpc",
|
||||
gsvc.MDInsecure: true,
|
||||
},
|
||||
})
|
||||
infopb.Register(sGRPC)
|
||||
msgcallpb.Register(sGRPC)
|
||||
reflection.Register(sGRPC.Server)
|
||||
g.Log().Infof(ctx, "GRPC Server启动, listenPort:%d", gGrpcPort)
|
||||
sGRPC.Run()
|
||||
g.Log().Infof(ctx, "GRPC Server堵塞运行结束")
|
||||
}()
|
||||
|
||||
s.Run()
|
||||
service.UpstreamAPI().Stop(ctx)
|
||||
service.ChannelConfig().Stop(ctx)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
type sharedTokenGetter interface {
|
||||
GetSharedToken(ctx context.Context) (sharedtoken.OneLinkToken, error)
|
||||
}
|
||||
|
||||
func bindHTTPRoutes(s *ghttp.Server) {
|
||||
s.Group("/", func(group *ghttp.RouterGroup) {
|
||||
group.Middleware(ghttp.MiddlewareHandlerResponse)
|
||||
group.Bind(
|
||||
cardquery.NewV1(),
|
||||
)
|
||||
// Internal control endpoint used to apply a single channel change
|
||||
// without waiting for the periodic full reload.
|
||||
group.POST("/internal/channel/test", func(r *ghttp.Request) {
|
||||
input := new(model.ChannelTestInput)
|
||||
if err := r.Parse(input); err != nil {
|
||||
r.SetError(err)
|
||||
return
|
||||
}
|
||||
output, err := service.UpstreamAPI().TestChannel(r.Context(), input)
|
||||
if err != nil {
|
||||
r.SetError(err)
|
||||
return
|
||||
}
|
||||
r.Response.WriteJson(g.Map{"code": 0, "message": "success", "data": output})
|
||||
})
|
||||
group.POST("/internal/channel/refresh", func(r *ghttp.Request) {
|
||||
chanKey := r.GetQuery("chanKey").String()
|
||||
if chanKey == "" {
|
||||
chanKey = r.Get("chanKey").String()
|
||||
}
|
||||
if err := service.UpstreamAPI().RefreshChannel(r.Context(), chanKey); err != nil {
|
||||
r.SetError(err)
|
||||
return
|
||||
}
|
||||
r.Response.WriteJson(g.Map{
|
||||
"code": 0,
|
||||
"message": "success",
|
||||
"data": g.Map{"chanKey": chanKey, "refreshed": true},
|
||||
})
|
||||
})
|
||||
group.POST("/internal/channel/remove", func(r *ghttp.Request) {
|
||||
chanKey := r.GetQuery("chanKey").String()
|
||||
if chanKey == "" {
|
||||
chanKey = r.Get("chanKey").String()
|
||||
}
|
||||
if err := service.UpstreamAPI().RemoveChannel(r.Context(), chanKey); err != nil {
|
||||
r.SetError(err)
|
||||
return
|
||||
}
|
||||
r.Response.WriteJson(g.Map{
|
||||
"code": 0,
|
||||
"message": "success",
|
||||
"data": g.Map{"chanKey": chanKey, "removed": true},
|
||||
})
|
||||
})
|
||||
group.POST("/internal/channel/shared-token", func(r *ghttp.Request) {
|
||||
chanKey := r.Get("chanKey").String()
|
||||
if chanKey == "" {
|
||||
r.Response.WriteJson(g.Map{
|
||||
"code": 50,
|
||||
"message": "chanKey不能为空",
|
||||
})
|
||||
return
|
||||
}
|
||||
channelCache, ok := service.UpstreamAPI().GetChan(chanKey)
|
||||
if !ok || channelCache.Instance == nil {
|
||||
if err := service.UpstreamAPI().RefreshChannel(r.Context(), chanKey); err != nil {
|
||||
g.Log().Errorf(r.Context(), "shared-token refresh channel failed, chanKey: %s, err: %+v", chanKey, err)
|
||||
r.Response.WriteJson(g.Map{
|
||||
"code": 50,
|
||||
"message": "通道实例化失败",
|
||||
})
|
||||
return
|
||||
}
|
||||
channelCache, ok = service.UpstreamAPI().GetChan(chanKey)
|
||||
if !ok || channelCache.Instance == nil {
|
||||
r.Response.WriteJson(g.Map{
|
||||
"code": 50,
|
||||
"message": "通道不存在",
|
||||
})
|
||||
return
|
||||
}
|
||||
}
|
||||
provider, ok := channelCache.Instance.(sharedTokenGetter)
|
||||
if !ok {
|
||||
r.Response.WriteJson(g.Map{
|
||||
"code": 50,
|
||||
"message": "该通道不支持共享Token",
|
||||
})
|
||||
return
|
||||
}
|
||||
tok, err := provider.GetSharedToken(r.Context())
|
||||
if err != nil {
|
||||
g.Log().Errorf(r.Context(), "shared-token get token failed, chanKey: %s, err: %+v", chanKey, err)
|
||||
r.Response.WriteJson(g.Map{
|
||||
"code": 50,
|
||||
"message": "获取共享Token失败",
|
||||
})
|
||||
return
|
||||
}
|
||||
if tok.Value == "" {
|
||||
r.Response.WriteJson(g.Map{
|
||||
"code": 50,
|
||||
"message": "该通道尚未获取共享Token",
|
||||
})
|
||||
return
|
||||
}
|
||||
chanAlias := ""
|
||||
if channelConf, ok := service.ChannelConfig().GetChannel(chanKey); ok && channelConf.Channel != nil {
|
||||
chanAlias = channelConf.Channel.ChanAlias
|
||||
}
|
||||
tokenExpire := ""
|
||||
if !tok.ExpiresAt.IsZero() {
|
||||
// Go time layouts use the reference date, not PHP-style format tokens.
|
||||
tokenExpire = tok.ExpiresAt.Format("2006-01-02 15:04:05")
|
||||
}
|
||||
r.Response.WriteJson(g.Map{
|
||||
"code": 0,
|
||||
"message": "success",
|
||||
"data": g.Map{
|
||||
"chanAlias": chanAlias,
|
||||
"chanKey": chanKey,
|
||||
"accessToken": tok.Value,
|
||||
"accessTokenExpire": tokenExpire,
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,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")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
// =================================================================================
|
||||
// This file is auto-generated by the GoFrame CLI tool. You may modify it as needed.
|
||||
// =================================================================================
|
||||
|
||||
package dao
|
||||
|
||||
import (
|
||||
"git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/dao/internal"
|
||||
)
|
||||
|
||||
// tApiDao is the data access object for the table t_api.
|
||||
// You can define custom methods on it to extend its functionality as needed.
|
||||
type tApiDao struct {
|
||||
*internal.TApiDao
|
||||
}
|
||||
|
||||
var (
|
||||
// TApi is a globally accessible object for table t_api operations.
|
||||
TApi = tApiDao{internal.NewTApiDao()}
|
||||
)
|
||||
|
||||
// Add your custom methods and functionality below.
|
||||
@@ -0,0 +1,22 @@
|
||||
// =================================================================================
|
||||
// This file is auto-generated by the GoFrame CLI tool. You may modify it as needed.
|
||||
// =================================================================================
|
||||
|
||||
package dao
|
||||
|
||||
import (
|
||||
"git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/dao/internal"
|
||||
)
|
||||
|
||||
// tCustomerDao is the data access object for the table t_customer.
|
||||
// You can define custom methods on it to extend its functionality as needed.
|
||||
type tCustomerDao struct {
|
||||
*internal.TCustomerDao
|
||||
}
|
||||
|
||||
var (
|
||||
// TCustomer is a globally accessible object for table t_customer operations.
|
||||
TCustomer = tCustomerDao{internal.NewTCustomerDao()}
|
||||
)
|
||||
|
||||
// Add your custom methods and functionality below.
|
||||
@@ -0,0 +1,22 @@
|
||||
// =================================================================================
|
||||
// This file is auto-generated by the GoFrame CLI tool. You may modify it as needed.
|
||||
// =================================================================================
|
||||
|
||||
package dao
|
||||
|
||||
import (
|
||||
"git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/dao/internal"
|
||||
)
|
||||
|
||||
// tSimAgentPackageDao is the data access object for the table t_sim_agent_package.
|
||||
// You can define custom methods on it to extend its functionality as needed.
|
||||
type tSimAgentPackageDao struct {
|
||||
*internal.TSimAgentPackageDao
|
||||
}
|
||||
|
||||
var (
|
||||
// TSimAgentPackage is a globally accessible object for table t_sim_agent_package operations.
|
||||
TSimAgentPackage = tSimAgentPackageDao{internal.NewTSimAgentPackageDao()}
|
||||
)
|
||||
|
||||
// Add your custom methods and functionality below.
|
||||
@@ -0,0 +1,22 @@
|
||||
// =================================================================================
|
||||
// This file is auto-generated by the GoFrame CLI tool. You may modify it as needed.
|
||||
// =================================================================================
|
||||
|
||||
package dao
|
||||
|
||||
import (
|
||||
"git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/dao/internal"
|
||||
)
|
||||
|
||||
// tSimAgentPacketDao is the data access object for the table t_sim_agent_packet.
|
||||
// You can define custom methods on it to extend its functionality as needed.
|
||||
type tSimAgentPacketDao struct {
|
||||
*internal.TSimAgentPacketDao
|
||||
}
|
||||
|
||||
var (
|
||||
// TSimAgentPacket is a globally accessible object for table t_sim_agent_packet operations.
|
||||
TSimAgentPacket = tSimAgentPacketDao{internal.NewTSimAgentPacketDao()}
|
||||
)
|
||||
|
||||
// Add your custom methods and functionality below.
|
||||
@@ -0,0 +1,22 @@
|
||||
// =================================================================================
|
||||
// This file is auto-generated by the GoFrame CLI tool. You may modify it as needed.
|
||||
// =================================================================================
|
||||
|
||||
package dao
|
||||
|
||||
import (
|
||||
"git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/dao/internal"
|
||||
)
|
||||
|
||||
// tSimCardInfoDao is the data access object for the table t_sim_card_info.
|
||||
// You can define custom methods on it to extend its functionality as needed.
|
||||
type tSimCardInfoDao struct {
|
||||
*internal.TSimCardInfoDao
|
||||
}
|
||||
|
||||
var (
|
||||
// TSimCardInfo is a globally accessible object for table t_sim_card_info operations.
|
||||
TSimCardInfo = tSimCardInfoDao{internal.NewTSimCardInfoDao()}
|
||||
)
|
||||
|
||||
// Add your custom methods and functionality below.
|
||||
@@ -0,0 +1,22 @@
|
||||
// =================================================================================
|
||||
// This file is auto-generated by the GoFrame CLI tool. You may modify it as needed.
|
||||
// =================================================================================
|
||||
|
||||
package dao
|
||||
|
||||
import (
|
||||
"git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/dao/internal"
|
||||
)
|
||||
|
||||
// tSimChannelTemplateDao is the data access object for the table t_sim_channel_template.
|
||||
// You can define custom methods on it to extend its functionality as needed.
|
||||
type tSimChannelTemplateDao struct {
|
||||
*internal.TSimChannelTemplateDao
|
||||
}
|
||||
|
||||
var (
|
||||
// TSimChannelTemplate is a globally accessible object for table t_sim_channel_template operations.
|
||||
TSimChannelTemplate = tSimChannelTemplateDao{internal.NewTSimChannelTemplateDao()}
|
||||
)
|
||||
|
||||
// Add your custom methods and functionality below.
|
||||
@@ -0,0 +1,22 @@
|
||||
// =================================================================================
|
||||
// This file is auto-generated by the GoFrame CLI tool. You may modify it as needed.
|
||||
// =================================================================================
|
||||
|
||||
package dao
|
||||
|
||||
import (
|
||||
"git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/dao/internal"
|
||||
)
|
||||
|
||||
// tSimChannelsDao is the data access object for the table t_sim_channels.
|
||||
// You can define custom methods on it to extend its functionality as needed.
|
||||
type tSimChannelsDao struct {
|
||||
*internal.TSimChannelsDao
|
||||
}
|
||||
|
||||
var (
|
||||
// TSimChannels is a globally accessible object for table t_sim_channels operations.
|
||||
TSimChannels = tSimChannelsDao{internal.NewTSimChannelsDao()}
|
||||
)
|
||||
|
||||
// Add your custom methods and functionality below.
|
||||
@@ -0,0 +1,298 @@
|
||||
package channelconfig
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"strings"
|
||||
|
||||
"git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/model"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/model/entity"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/service"
|
||||
|
||||
"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
|
||||
})
|
||||
}
|
||||
@@ -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>×=<unix_seconds>")).
|
||||
type henanXinjingClient struct {
|
||||
baseURL string
|
||||
userID string
|
||||
apiKey string
|
||||
httpClient *http.Client
|
||||
}
|
||||
|
||||
// henanXinjingResponse matches the platform's standard envelope.
|
||||
// Code can be either a JSON number or a JSON string (the platform
|
||||
// returns both shapes for the same logical value), so keep it raw.
|
||||
type henanXinjingResponse struct {
|
||||
Code json.RawMessage `json:"code"`
|
||||
Msg string `json:"msg"`
|
||||
Data json.RawMessage `json:"data"`
|
||||
}
|
||||
|
||||
func newHenanXinjingClient(baseURL, userID, apiKey string, timeout time.Duration) *henanXinjingClient {
|
||||
if timeout <= 0 {
|
||||
timeout = defaultUpstreamHTTPTimeout
|
||||
}
|
||||
return &henanXinjingClient{
|
||||
baseURL: strings.TrimRight(strings.TrimSpace(baseURL), "/"),
|
||||
userID: strings.TrimSpace(userID),
|
||||
apiKey: strings.TrimSpace(apiKey),
|
||||
httpClient: &http.Client{Timeout: timeout},
|
||||
}
|
||||
}
|
||||
|
||||
// buildSign returns UPPERCASE(MD5(...)) over the document-defined form:
|
||||
//
|
||||
// userId=<id>&apikey=<key>×=<unix_seconds>
|
||||
func henanXinjingSign(userID, apiKey, times string) string {
|
||||
raw := "userId=" + userID + "&apikey=" + apiKey + "×=" + times
|
||||
sum := md5.Sum([]byte(raw))
|
||||
return strings.ToUpper(hex.EncodeToString(sum[:]))
|
||||
}
|
||||
|
||||
// decodeCode normalizes the response code. The platform sometimes returns
|
||||
// `"code":0` and sometimes `"code":"-2"`. Both are valid; we treat any
|
||||
// numeric zero (in either form) as success.
|
||||
func henanXinjingDecodeCode(raw json.RawMessage) (int, error) {
|
||||
if len(raw) == 0 {
|
||||
return -1, fmt.Errorf("missing code field")
|
||||
}
|
||||
var n int
|
||||
if err := json.Unmarshal(raw, &n); err == nil {
|
||||
return n, nil
|
||||
}
|
||||
var s string
|
||||
if err := json.Unmarshal(raw, &s); err != nil {
|
||||
return -1, fmt.Errorf("code is neither number nor string: %s", string(raw))
|
||||
}
|
||||
s = strings.TrimSpace(s)
|
||||
if s == "" {
|
||||
return -1, fmt.Errorf("empty code string")
|
||||
}
|
||||
v, err := strconv.Atoi(s)
|
||||
if err != nil {
|
||||
return -1, fmt.Errorf("invalid code string %q: %w", s, err)
|
||||
}
|
||||
return v, nil
|
||||
}
|
||||
|
||||
// get performs a signed GET request and unmarshals the `data` field into out
|
||||
// when out is non-nil. Business-layer errors (code != 0) become errors.
|
||||
func (c *henanXinjingClient) get(ctx context.Context, path string, params map[string]string, out any) error {
|
||||
if c == nil || c.baseURL == "" || c.userID == "" || c.apiKey == "" {
|
||||
return fmt.Errorf("henan xinjing: baseURL, userID and apiKey are required")
|
||||
}
|
||||
values := url.Values{}
|
||||
for k, v := range params {
|
||||
if strings.TrimSpace(v) != "" {
|
||||
values.Set(k, v)
|
||||
}
|
||||
}
|
||||
times := strconv.FormatInt(time.Now().Unix(), 10)
|
||||
values.Set("userId", c.userID)
|
||||
values.Set("times", times)
|
||||
values.Set("sign", henanXinjingSign(c.userID, c.apiKey, times))
|
||||
|
||||
endpoint := c.baseURL + "/" + strings.TrimLeft(path, "/") + "?" + values.Encode()
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("henan xinjing: build request %s: %w", path, err)
|
||||
}
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("henan xinjing: request %s: %w", path, err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return fmt.Errorf("henan xinjing: read body %s: %w", path, err)
|
||||
}
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return fmt.Errorf("henan xinjing: HTTP %d from %s: %s", resp.StatusCode, path, string(body))
|
||||
}
|
||||
var envelope henanXinjingResponse
|
||||
if err = json.Unmarshal(body, &envelope); err != nil {
|
||||
return fmt.Errorf("henan xinjing: decode envelope %s: %w (body=%s)", path, err, string(body))
|
||||
}
|
||||
code, err := henanXinjingDecodeCode(envelope.Code)
|
||||
if err != nil {
|
||||
return fmt.Errorf("henan xinjing: %s: %w (body=%s)", path, err, string(body))
|
||||
}
|
||||
if code != 0 {
|
||||
return fmt.Errorf("henan xinjing: %s failed: code=%d msg=%s", path, code, envelope.Msg)
|
||||
}
|
||||
if out != nil && len(envelope.Data) > 0 && string(envelope.Data) != "null" {
|
||||
if err = json.Unmarshal(envelope.Data, out); err != nil {
|
||||
return fmt.Errorf("henan xinjing: decode data %s: %w (data=%s)", path, err, string(envelope.Data))
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
package upstreamimpl
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
sharedtoken "git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/token"
|
||||
)
|
||||
|
||||
// HenanXinjingTokenConfig carries the credentials needed to call the
|
||||
// platform's `/api/v1/searchToken` endpoint and convert the response into a
|
||||
// sharedtoken.OneLinkToken for the sharedtoken manager.
|
||||
//
|
||||
// The channel card-no is optional at construction time because the
|
||||
// underlying platform scopes tokens to (channel_id, channel_pname), not to
|
||||
// a specific card. When CardNo is empty the fetcher falls back to the
|
||||
// chanKey passed by the token manager (matching how zibo/tengyu providers
|
||||
// treat the per-channel seed value).
|
||||
type HenanXinjingTokenConfig struct {
|
||||
BaseURL string
|
||||
Path string // defaults to /api/v1/searchToken
|
||||
UserID string
|
||||
APIKey string
|
||||
ChannelID string // e.g. "80", "97", "194"
|
||||
ChannelPName string // e.g. "cmiotonelinkqx"
|
||||
CardNo string // optional seed card number; may be overridden by manager
|
||||
Timeout time.Duration
|
||||
}
|
||||
|
||||
// HenanXinjingTokenProvider fetches a shared token for a given
|
||||
// (userId, channel_id, channel_pname) triple from the Henan Xinjing
|
||||
// platform.
|
||||
type HenanXinjingTokenProvider struct {
|
||||
baseURL, path, userID, apiKey, channelID, channelPName, cardNo string
|
||||
client *http.Client
|
||||
}
|
||||
|
||||
// henanXinjingSearchTokenResponse matches the upstream response shape:
|
||||
//
|
||||
// {
|
||||
// "code": 0,
|
||||
// "msg": "查询成功",
|
||||
// "data": {
|
||||
// "id": 80,
|
||||
// "chl_username": "C5010371A16640006582238",
|
||||
// "accesstoken": "v1b74e73d741cea620f0ceecaf7a733fa6964aa53fffcbf8d3a5a4c8715aabe3",
|
||||
// "accesstoken_at": "2026-09-16 13:19:29"
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// `code` may be either a JSON number or string; decode via henanXinjingDecodeCode.
|
||||
type henanXinjingSearchTokenResponse struct {
|
||||
Code json.RawMessage `json:"code"`
|
||||
Msg string `json:"msg"`
|
||||
Data struct {
|
||||
ID json.RawMessage `json:"id"`
|
||||
ChlUsername string `json:"chl_username"`
|
||||
AccessToken string `json:"accesstoken"`
|
||||
AccessTokenAt string `json:"accesstoken_at"`
|
||||
AccessTokenSB string `json:"accesstokensb"`
|
||||
AccessTokenAlias string `json:"access_token"`
|
||||
} `json:"data"`
|
||||
}
|
||||
|
||||
const (
|
||||
henanXinjingSearchTokenPath = "/api/v1/searchToken"
|
||||
henanXinjingTokenExpiryLayout = "2006-01-02 15:04:05"
|
||||
henanXinjingTokenRefreshBackoff = 5 * time.Minute
|
||||
)
|
||||
|
||||
func NewHenanXinjingTokenProvider(config HenanXinjingTokenConfig) *HenanXinjingTokenProvider {
|
||||
timeout := config.Timeout
|
||||
if timeout <= 0 {
|
||||
timeout = defaultUpstreamHTTPTimeout
|
||||
}
|
||||
return &HenanXinjingTokenProvider{
|
||||
baseURL: strings.TrimRight(strings.TrimSpace(config.BaseURL), "/"),
|
||||
path: normalizeProviderPath(config.Path, henanXinjingSearchTokenPath),
|
||||
userID: strings.TrimSpace(config.UserID),
|
||||
apiKey: strings.TrimSpace(config.APIKey),
|
||||
channelID: strings.TrimSpace(config.ChannelID),
|
||||
channelPName: strings.TrimSpace(config.ChannelPName),
|
||||
cardNo: strings.TrimSpace(config.CardNo),
|
||||
client: &http.Client{Timeout: timeout},
|
||||
}
|
||||
}
|
||||
|
||||
// parseExpiry interprets the platform's `accesstoken_at` field. The
|
||||
// platform uses local China time without an explicit zone, so parse in
|
||||
// Asia/Shanghai.
|
||||
func henanXinjingParseExpiry(raw string) (time.Time, error) {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return time.Time{}, fmt.Errorf("empty accesstoken_at")
|
||||
}
|
||||
loc, err := time.LoadLocation("Asia/Shanghai")
|
||||
if err != nil {
|
||||
loc = time.Local
|
||||
}
|
||||
t, err := time.ParseInLocation(henanXinjingTokenExpiryLayout, raw, loc)
|
||||
if err != nil {
|
||||
return time.Time{}, fmt.Errorf("parse accesstoken_at %q: %w", raw, err)
|
||||
}
|
||||
return t, nil
|
||||
}
|
||||
|
||||
// firstToken returns the first non-empty token field. The platform has been
|
||||
// observed returning the token under `accesstoken`, but earlier docs
|
||||
// referenced `accesstokensb` / `access_token`; accept all three.
|
||||
func henanXinjingFirstToken(resp *henanXinjingSearchTokenResponse) string {
|
||||
if v := strings.TrimSpace(resp.Data.AccessToken); v != "" {
|
||||
return v
|
||||
}
|
||||
if v := strings.TrimSpace(resp.Data.AccessTokenSB); v != "" {
|
||||
return v
|
||||
}
|
||||
return strings.TrimSpace(resp.Data.AccessTokenAlias)
|
||||
}
|
||||
|
||||
// fetch performs the signed GET against /api/v1/searchToken for the given
|
||||
// card number. Card number may come from the manager's chanKey seed.
|
||||
func (p *HenanXinjingTokenProvider) fetch(ctx context.Context, cardNo string) (sharedtoken.OneLinkToken, error) {
|
||||
if p == nil || p.client == nil {
|
||||
return sharedtoken.OneLinkToken{}, fmt.Errorf("henan xinjing token provider is not initialized")
|
||||
}
|
||||
if p.baseURL == "" || p.userID == "" || p.apiKey == "" || p.channelID == "" || p.channelPName == "" {
|
||||
return sharedtoken.OneLinkToken{}, fmt.Errorf("henan xinjing: baseURL, userID, apiKey, channelID and channelPName are required")
|
||||
}
|
||||
cardNo = strings.TrimSpace(cardNo)
|
||||
if cardNo == "" {
|
||||
cardNo = p.cardNo
|
||||
}
|
||||
// cardno is optional on the real platform (verified live): the token is
|
||||
// scoped to (userId, channel_id, channel_pname), so omit the param when
|
||||
// no seed card number is configured instead of failing the fetch.
|
||||
|
||||
times := strconv.FormatInt(time.Now().Unix(), 10)
|
||||
values := url.Values{}
|
||||
values.Set("userId", p.userID)
|
||||
if cardNo != "" {
|
||||
values.Set("cardno", cardNo)
|
||||
}
|
||||
values.Set("channel_id", p.channelID)
|
||||
values.Set("channel_pname", p.channelPName)
|
||||
values.Set("times", times)
|
||||
values.Set("sign", henanXinjingSign(p.userID, p.apiKey, times))
|
||||
|
||||
endpoint := p.baseURL + p.path + "?" + values.Encode()
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
|
||||
if err != nil {
|
||||
return sharedtoken.OneLinkToken{}, fmt.Errorf("henan xinjing: build searchToken request: %w", err)
|
||||
}
|
||||
resp, err := p.client.Do(req)
|
||||
if err != nil {
|
||||
return sharedtoken.OneLinkToken{}, fmt.Errorf("henan xinjing: request searchToken: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return sharedtoken.OneLinkToken{}, fmt.Errorf("henan xinjing: read searchToken body: %w", err)
|
||||
}
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return sharedtoken.OneLinkToken{}, fmt.Errorf("henan xinjing: searchToken HTTP %d: %s", resp.StatusCode, string(body))
|
||||
}
|
||||
var output henanXinjingSearchTokenResponse
|
||||
if err := json.Unmarshal(body, &output); err != nil {
|
||||
return sharedtoken.OneLinkToken{}, fmt.Errorf("henan xinjing: decode searchToken response: %w (body=%s)", err, string(body))
|
||||
}
|
||||
code, err := henanXinjingDecodeCode(output.Code)
|
||||
if err != nil {
|
||||
return sharedtoken.OneLinkToken{}, fmt.Errorf("henan xinjing: searchToken: %w (body=%s)", err, string(body))
|
||||
}
|
||||
if code != 0 {
|
||||
return sharedtoken.OneLinkToken{}, fmt.Errorf("henan xinjing: searchToken failed: code=%d msg=%s", code, output.Msg)
|
||||
}
|
||||
tokenValue := henanXinjingFirstToken(&output)
|
||||
if tokenValue == "" {
|
||||
return sharedtoken.OneLinkToken{}, fmt.Errorf("henan xinjing: searchToken returned empty accesstoken")
|
||||
}
|
||||
expiresAt, err := henanXinjingParseExpiry(output.Data.AccessTokenAt)
|
||||
if err != nil {
|
||||
return sharedtoken.OneLinkToken{}, err
|
||||
}
|
||||
// Refresh slightly ahead of the declared expiry to absorb clock skew.
|
||||
if until := time.Until(expiresAt); until > henanXinjingTokenRefreshBackoff {
|
||||
expiresAt = expiresAt.Add(-henanXinjingTokenRefreshBackoff)
|
||||
}
|
||||
return sharedtoken.OneLinkToken{
|
||||
Value: tokenValue,
|
||||
CreatedAt: time.Now(),
|
||||
ExpiresAt: expiresAt,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Fetch satisfies the sharedtoken.Fetcher-style interface used by the
|
||||
// token manager. The chanKey argument is unused here because Henan Xinjing
|
||||
// scopes tokens to the (channel_id, channel_pname) pair rather than to a
|
||||
// specific chan_key; we always fetch with the configured cardNo.
|
||||
func (p *HenanXinjingTokenProvider) Fetch(ctx context.Context) (sharedtoken.OneLinkToken, error) {
|
||||
return p.fetch(ctx, "")
|
||||
}
|
||||
|
||||
// FetchToken matches the sharedtoken.TokenFetcher interface used by
|
||||
// sharedtoken.NewTokenManager. chanKey is the token manager's cache key
|
||||
// (typically the channel's chan_key); it does not influence the upstream
|
||||
// request but lets the manager cache one token per channel.
|
||||
func (p *HenanXinjingTokenProvider) FetchToken(ctx context.Context, _ string) (sharedtoken.OneLinkToken, error) {
|
||||
return p.fetch(ctx, "")
|
||||
}
|
||||
|
||||
// henanXinjingFetcher adapts HenanXinjingTokenProvider to the sharedtoken
|
||||
// fetcher shape used by RegisterTokenProviderFactory.
|
||||
type henanXinjingFetcher struct{ provider *HenanXinjingTokenProvider }
|
||||
|
||||
func (f henanXinjingFetcher) FetchToken(ctx context.Context, chanKey string) (sharedtoken.OneLinkToken, error) {
|
||||
return f.provider.fetch(ctx, "")
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
package upstreamimpl
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"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
Reference in New Issue
Block a user