chore: 并入 iotlcm-worker-svr 历史到 worker-svr/(原提交 96f0f4e)
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,23 @@
|
|||||||
|
.buildpath
|
||||||
|
.hgignore.swp
|
||||||
|
.project
|
||||||
|
.orig
|
||||||
|
.swp
|
||||||
|
.idea/
|
||||||
|
.settings/
|
||||||
|
.vscode/
|
||||||
|
bin/
|
||||||
|
**/.DS_Store
|
||||||
|
gf
|
||||||
|
main
|
||||||
|
*.exe
|
||||||
|
output/
|
||||||
|
manifest/output/
|
||||||
|
temp/
|
||||||
|
temp.yaml
|
||||||
|
*.log
|
||||||
|
|
||||||
|
# 本地配置文件(各人的数据库/上游地址不同,不纳入版本控制)
|
||||||
|
config/config.yaml
|
||||||
|
.keep-if-necessary
|
||||||
|
.gitkeep
|
||||||
@@ -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 = "iotlcm-worker-svr"
|
||||||
|
DOCKER_NAME = "iotlcm-worker-svr"
|
||||||
|
|
||||||
|
include ./hack/hack-cli.mk
|
||||||
|
include ./hack/hack.mk
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
# iotlcm-worker-svr
|
||||||
|
|
||||||
|
Dedicated RabbitMQ consumer service for IotLCM asynchronous work.
|
||||||
|
|
||||||
|
## Consumers
|
||||||
|
|
||||||
|
The service always registers these business consumers when RabbitMQ is enabled:
|
||||||
|
|
||||||
|
- `iotlcm.traffic_usage.daily.retry`
|
||||||
|
- `iotlcm.traffic_usage.monthly.retry`
|
||||||
|
- `iotlcm.work_order.auto_close`
|
||||||
|
|
||||||
|
For local Admin-to-Worker integration checks, set `workerDiagnostic.enabled: true` to
|
||||||
|
register `iotlcm.worker.diagnostic`. Diagnostic messages are logged and acknowledged;
|
||||||
|
they do not access MySQL, TDengine, or Upstream. The diagnostic consumer is disabled
|
||||||
|
in the default and build configurations.
|
||||||
|
|
||||||
|
## SIM status synchronization
|
||||||
|
|
||||||
|
Status sync uses run-scoped queues and durable outcome counters. The optional
|
||||||
|
`simStatusQueueSync.redisKeyPrefix` setting controls the Redis key prefix used by
|
||||||
|
these queues.
|
||||||
|
|
||||||
|
|
||||||
|
## 角色权限同步
|
||||||
|
|
||||||
|
配置 `roleSync.enabled: true` 后,由本服务独立接管管理后端的持久化角色权限任务。默认关闭;启用前需先完成数据库迁移,并移除旧管理后端的执行入口。
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
ARG BASE_REGISTRY=docker.io/library
|
||||||
|
FROM ${BASE_REGISTRY}/golang:1.25-alpine AS builder
|
||||||
|
LABEL stage=gobuilder
|
||||||
|
|
||||||
|
ENV GOPROXY=https://goproxy.cn,direct
|
||||||
|
ENV TZ=Asia/Shanghai
|
||||||
|
|
||||||
|
# RUN apk update --no-cache && apk add --no-cache expect git tzdata
|
||||||
|
WORKDIR /build
|
||||||
|
|
||||||
|
COPY go.mod go.sum ./
|
||||||
|
|
||||||
|
RUN go mod download
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
ARG BuildVersion
|
||||||
|
ARG BuildTime
|
||||||
|
ARG CommitID
|
||||||
|
|
||||||
|
RUN go build -v -x -o /build/iotlcm-worker-svr -ldflags "-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
|
||||||
|
ENV TZ=Asia/Shanghai
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
COPY --from=builder /build/iotlcm-worker-svr /app/iotlcm-worker-svr
|
||||||
|
COPY --from=builder /build/build/config.example.yaml /app/config/config.yaml
|
||||||
|
|
||||||
|
CMD ["./iotlcm-worker-svr"]
|
||||||
@@ -0,0 +1,346 @@
|
|||||||
|
# config.yaml 配置文件
|
||||||
|
# @Link https://github.com/xiujiecn/xiu-admin
|
||||||
|
# @Copyright Copyright (c) 2025 LiXiujie
|
||||||
|
# @Author Lxj <xxx@example.com>
|
||||||
|
# @License https://github.com/xiujiecn/xiu-admin/blob/master/LICENSE
|
||||||
|
|
||||||
|
# 系统配置
|
||||||
|
system:
|
||||||
|
name: "IotLCM"
|
||||||
|
version: "1.0"
|
||||||
|
description: "IotLCM Server"
|
||||||
|
gfmode: "dev"
|
||||||
|
upload:
|
||||||
|
local:
|
||||||
|
path: "resource/upload"
|
||||||
|
|
||||||
|
# 日志配置
|
||||||
|
logger:
|
||||||
|
path: "/app/logs" # 日志文件路径。默认为空,表示关闭,仅输出到终端
|
||||||
|
file: "{Y-m-d-H}.log" # 日志文件格式。默认为"{Y-m-d}.log"
|
||||||
|
prefix: "" # 日志内容输出前缀。默认为空
|
||||||
|
level: "dev" # 日志输出级别
|
||||||
|
timeFormat: "2006-01-02T15:04:05" # 自定义日志输出的时间格式,使用Golang标准的时间格式配置
|
||||||
|
ctxKeys: [] # 自定义Context上下文变量名称,自动打印Context的变量到日志中。默认为空
|
||||||
|
header: true # 是否打印日志的头信息。默认true
|
||||||
|
stdout: true # 日志是否同时输出到终端。默认true
|
||||||
|
rotateSize: 0 # 按照日志文件大小对文件进行滚动切分。默认为0,表示关闭滚动切分特性
|
||||||
|
rotateExpire: 0 # 按照日志文件时间间隔对文件滚动切分。默认为0,表示关闭滚动切分特性
|
||||||
|
rotateBackupLimit: 0 # 按照切分的文件数量清理切分文件,当滚动切分特性开启时有效。默认为0,表示不备份,切分则删除
|
||||||
|
rotateBackupExpire: 0 # 按照切分的文件有效期清理切分文件,当滚动切分特性开启时有效。默认为0,表示不备份,切分则删除
|
||||||
|
rotateBackupCompress: 0 # 滚动切分文件的压缩比(0-9)。默认为0,表示不压缩
|
||||||
|
rotateCheckInterval: "1h" # 滚动切分的时间检测间隔,一般不需要设置。默认为1小时
|
||||||
|
stdoutColorDisabled: false # 关闭终端的颜色打印。默认开启
|
||||||
|
writerColorEnable: false # 日志文件是否带上颜色。默认false,表示不带颜色
|
||||||
|
|
||||||
|
# 数据库配置
|
||||||
|
database:
|
||||||
|
logger:
|
||||||
|
path: "/app/logs/sql"
|
||||||
|
level: "all"
|
||||||
|
stdout: true
|
||||||
|
default: # 分组名称,可自定义,默认为default
|
||||||
|
link: "${DB_DSN}"
|
||||||
|
debug: true #开启调试模式
|
||||||
|
charset: "utf8mb4" # (可选)数据库编码(如: utf8mb4/utf8/gbk/gb2312),一般设置为utf8mb4。默认为utf8。
|
||||||
|
dryRun: false #ORM空跑(只读不写)
|
||||||
|
maxIdle: 30 #连接池最大闲置的连接数
|
||||||
|
maxOpen: 100 #连接池最大打开的连接数
|
||||||
|
maxLifetime: 120 #(单位秒)连接对象可重复使用的时间长度
|
||||||
|
memorydb:
|
||||||
|
# link: "sqlite::@file(jieiotmem.db)?journal_mode=WAL"
|
||||||
|
link: "${DB_DSN}"
|
||||||
|
# link: ""
|
||||||
|
debug: true
|
||||||
|
maxIdle: 1
|
||||||
|
maxOpen: 1
|
||||||
|
maxLifetime: 30
|
||||||
|
|
||||||
|
# 缓存配置
|
||||||
|
cache:
|
||||||
|
adapter: "redis" # 缓存适配器,支持:file|redis
|
||||||
|
fileDir: "resource/cache" # 缓存文件目录
|
||||||
|
|
||||||
|
# Redis 配置
|
||||||
|
redis:
|
||||||
|
# 单实例配置
|
||||||
|
default:
|
||||||
|
mode: "single" #redis模式,支持:single|cluster|sentinel
|
||||||
|
sentinelMasterName: "iotlcm-master" #哨兵模式主节点名称
|
||||||
|
address: 127.0.0.1:6379
|
||||||
|
db: 0
|
||||||
|
pass: "${SERVICE_PASSWORD}"
|
||||||
|
minIdle: 5 #最小空闲连接数
|
||||||
|
maxIdle: 30 #允许闲置的最大连接数
|
||||||
|
maxActive: 5000 #最大活跃连接数
|
||||||
|
idleTimeout: "60s" #连接最大空闲时间,使用时间字符串例如30s/2m/2d
|
||||||
|
ClientName: "SystemCache"
|
||||||
|
|
||||||
|
|
||||||
|
alipay:
|
||||||
|
appId: "${APP_ID}"
|
||||||
|
privateKey: "${PRIVATE_KEY}"
|
||||||
|
alipayPublicKey: "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA8Df1EPFkA2xy8NgF0UdRYxsshslUi2XSGTG01oQKsrYNB+rhAw9MJdMlunVQxSrBZsw5N7htXGdiTceaLo/n8Wuibp0cRHdErJSp9GhP4pW2IR3FoCCPYlJlyiCUR/NInI3XMai0UdjgiPX03bvYLRAKE8lLiOSgxNagQmOFLmQYRYfFhGbxDe98XMg83YdSWvZKrlM+vzgk8/aPgq7BFLpbRKqLvd8OXXBHDCaWMSSGwCN0whhdEiwbIhSVgwTgkhWXUuNDmT741/+7D46djp8wX/84SDts0fWb63/in9ATTGtNivJX7HPDyqrmcN2iUi5xFHS9XwYBdT4yu/xFQwIDAQAB"
|
||||||
|
notifyUrl: "https://evolution-starless-municipal.ngrok-free.dev/api/v1/pay/alipay/notify"
|
||||||
|
returnUrl: "http://localhost:5666/enterpriseManagement/tBalanceDetail/tAgentBalance"
|
||||||
|
isProduction: false
|
||||||
|
|
||||||
|
# RabbitMQ 配置(worker 核心依赖)
|
||||||
|
rabbitmq:
|
||||||
|
enabled: true
|
||||||
|
# 示例:${SERVICE_DSN}
|
||||||
|
# dsn: "${SERVICE_DSN}"
|
||||||
|
dsn: "${SERVICE_DSN}"
|
||||||
|
balanceRecharge:
|
||||||
|
retryDelay: "1m" # Alipay 查询失败后的延时重试时间
|
||||||
|
maxRetry: 100 # 最大重试次数,超过后投递到 iotlcm.balance.recharge.dlq
|
||||||
|
batchExecute:
|
||||||
|
executionTimeout: "0s"
|
||||||
|
finalizationTimeout: "10s"
|
||||||
|
retryDelay: "30s"
|
||||||
|
maxRetries: 3
|
||||||
|
|
||||||
|
batch:
|
||||||
|
inbound:
|
||||||
|
upstreamBatchSize: 1
|
||||||
|
upstreamConcurrency: 8
|
||||||
|
upstreamRateLimit:
|
||||||
|
enabled: true
|
||||||
|
mode: "redis"
|
||||||
|
redisGroup: "default"
|
||||||
|
keyPrefix: "iotlcm:worker:inbound:upstream_basic_info"
|
||||||
|
redisFailureFallback: "error"
|
||||||
|
default:
|
||||||
|
qps: 20
|
||||||
|
burst: 1
|
||||||
|
requestCost: 1
|
||||||
|
channels: {}
|
||||||
|
|
||||||
|
# 本月流量全量同步。开发环境常驻监听,Admin 未配置定时任务时不会自动启动全量扫描。
|
||||||
|
trafficUsageFullSync:
|
||||||
|
enabled: true
|
||||||
|
redisKeyPrefix: "flow:"
|
||||||
|
retry:
|
||||||
|
enabled: true
|
||||||
|
maxRounds: 5
|
||||||
|
failureRetention: "720h"
|
||||||
|
producer:
|
||||||
|
# channel requires idx_card_chan_key; never creates or rebuilds an index.
|
||||||
|
scanConcurrency: 4 # SQL burst protection, not a measured optimum
|
||||||
|
pageSize: 100
|
||||||
|
globalHighWaterMark: 10000
|
||||||
|
channelHighWaterMark: 2000
|
||||||
|
pollInterval: "2s"
|
||||||
|
runLockKey: "flow:producer:active"
|
||||||
|
runLockTTL: "5m"
|
||||||
|
consumer:
|
||||||
|
enabled: true
|
||||||
|
chanClientName: "127.0.0.1:8110"
|
||||||
|
chanClientTimeout: "90s"
|
||||||
|
lockKey: "flow:consumer:active"
|
||||||
|
lockTTL: "1m"
|
||||||
|
pollInterval: "1s"
|
||||||
|
leaseTTL: "3m"
|
||||||
|
taskTimeout: "2m"
|
||||||
|
retryDelay: "30s"
|
||||||
|
maxRetryDelay: "30m"
|
||||||
|
claimBatch: 1
|
||||||
|
mobileConcurrency: 30
|
||||||
|
telecomConcurrency: 20
|
||||||
|
genericConcurrency: 1
|
||||||
|
maxChannels: 8
|
||||||
|
channelConcurrency: 1
|
||||||
|
maxInFlight: 8
|
||||||
|
maxAttempts: 5
|
||||||
|
# 失败池告警阀值(条目数):单条 FailureRecord 约 1KB 且保留整个
|
||||||
|
# failureRetention,超阀值只打 WARN 日志,绝不截断 Run。
|
||||||
|
failurePoolAlertThreshold: 50000
|
||||||
|
|
||||||
|
# 独立的日流量同步链路,与月流量/状态同步同构。
|
||||||
|
# 发布配置默认仍关闭:先用手动 API + 单通道 scope 预演,确认产出与旧链路逐字段一致后再打开。
|
||||||
|
trafficUsageDailySync:
|
||||||
|
enabled: false
|
||||||
|
redisKeyPrefix: "daily:"
|
||||||
|
retry:
|
||||||
|
enabled: true
|
||||||
|
maxRounds: 3
|
||||||
|
failureRetention: "720h"
|
||||||
|
producer:
|
||||||
|
pageSize: 100
|
||||||
|
globalHighWaterMark: 0
|
||||||
|
channelHighWaterMark: 0
|
||||||
|
pollInterval: "2s"
|
||||||
|
runLockKey: "daily:producer:active"
|
||||||
|
runLockTTL: "5m"
|
||||||
|
consumer:
|
||||||
|
enabled: false
|
||||||
|
lockKey: "daily:consumer:active"
|
||||||
|
lockTTL: "1m"
|
||||||
|
pollInterval: "1s"
|
||||||
|
leaseTTL: "3m"
|
||||||
|
taskTimeout: "2m"
|
||||||
|
claimBatch: 100
|
||||||
|
mobileConcurrency: 30
|
||||||
|
telecomConcurrency: 8
|
||||||
|
genericConcurrency: 1
|
||||||
|
genericQPS: 1.5
|
||||||
|
templateQPS:
|
||||||
|
sim_yd_v5: 30
|
||||||
|
sim_yd_mock_v5: 30
|
||||||
|
sim_onelink_tengyu_v1: 30
|
||||||
|
sim_onelink_beixin_v1: 30
|
||||||
|
sim_onelink_xuzhou_mobile_v1: 30
|
||||||
|
sim_onelink_lelian_v1: 30
|
||||||
|
sim_onelink_qianxun_v1: 30
|
||||||
|
sim_onelink_henan_xinjing_v1: 30
|
||||||
|
sim_onelink_daihe_v1: 30
|
||||||
|
sim_dx_v1: 25
|
||||||
|
|
||||||
|
# 独立的月流量历史同步链路(写 TDengine 月表),与日流量/状态同步同构。
|
||||||
|
# 注意:这与 trafficUsageFullSync 不是一回事——后者填的是 MySQL 当月快照。
|
||||||
|
# 默认关闭:日流量验证通过后再单独打开。
|
||||||
|
trafficUsageMonthlyHistorySync:
|
||||||
|
enabled: false
|
||||||
|
redisKeyPrefix: "monthly-history:"
|
||||||
|
retry:
|
||||||
|
enabled: true
|
||||||
|
maxRounds: 3
|
||||||
|
failureRetention: "720h"
|
||||||
|
producer:
|
||||||
|
pageSize: 100
|
||||||
|
globalHighWaterMark: 0
|
||||||
|
channelHighWaterMark: 0
|
||||||
|
pollInterval: "2s"
|
||||||
|
runLockKey: "monthly-history:producer:active"
|
||||||
|
runLockTTL: "5m"
|
||||||
|
consumer:
|
||||||
|
enabled: false
|
||||||
|
lockKey: "monthly-history:consumer:active"
|
||||||
|
lockTTL: "1m"
|
||||||
|
pollInterval: "1s"
|
||||||
|
leaseTTL: "3m"
|
||||||
|
taskTimeout: "2m"
|
||||||
|
claimBatch: 100
|
||||||
|
mobileConcurrency: 30
|
||||||
|
telecomConcurrency: 8
|
||||||
|
genericConcurrency: 1
|
||||||
|
genericQPS: 1.5
|
||||||
|
# 三条链路共用出口 IP,各自独立限速,避免互相挤占运营商配额。
|
||||||
|
templateQPS:
|
||||||
|
sim_yd_v5: 30
|
||||||
|
sim_yd_mock_v5: 30
|
||||||
|
sim_onelink_tengyu_v1: 30
|
||||||
|
sim_onelink_beixin_v1: 30
|
||||||
|
sim_onelink_xuzhou_mobile_v1: 30
|
||||||
|
sim_onelink_lelian_v1: 30
|
||||||
|
sim_onelink_qianxun_v1: 30
|
||||||
|
sim_onelink_henan_xinjing_v1: 30
|
||||||
|
sim_onelink_daihe_v1: 30
|
||||||
|
sim_dx_v1: 25
|
||||||
|
|
||||||
|
# 独立的状态同步队列链路,默认关闭以保留旧逻辑作为切换期兜底。
|
||||||
|
simStatusQueueSync:
|
||||||
|
enabled: true
|
||||||
|
redisKeyPrefix: "sim-status:"
|
||||||
|
retry:
|
||||||
|
enabled: true
|
||||||
|
maxRounds: 3
|
||||||
|
failureRetention: "720h"
|
||||||
|
producer:
|
||||||
|
pageSize: 1000
|
||||||
|
globalHighWaterMark: 0
|
||||||
|
pollInterval: "1s"
|
||||||
|
runLockKey: "sim-status:producer:active"
|
||||||
|
runLockTTL: "5m"
|
||||||
|
consumer:
|
||||||
|
enabled: true
|
||||||
|
lockKey: "sim-status:consumer:active"
|
||||||
|
lockTTL: "1m"
|
||||||
|
pollInterval: "1s"
|
||||||
|
leaseTTL: "3m"
|
||||||
|
taskTimeout: "2m"
|
||||||
|
claimBatch: 100
|
||||||
|
mobileConcurrency: 100
|
||||||
|
telecomConcurrency: 8
|
||||||
|
genericConcurrency: 1
|
||||||
|
genericQPS: 1.5
|
||||||
|
networkRetries: 2
|
||||||
|
# 失败池告警阀值(条目数):单条 FailureRecord 约 1KB 且保留整个
|
||||||
|
# failureRetention,超阀值只打 WARN 日志,绝不截断 Run。
|
||||||
|
failurePoolAlertThreshold: 50000
|
||||||
|
templateQPS:
|
||||||
|
sim_yd_v5: 100
|
||||||
|
sim_yd_mock_v5: 100
|
||||||
|
sim_onelink_tengyu_v1: 100
|
||||||
|
sim_onelink_xuzhou_mobile_v1: 100
|
||||||
|
sim_onelink_lelian_v1: 100
|
||||||
|
sim_dx_v1: 5
|
||||||
|
|
||||||
|
# 独立的 IMEI 全量同步链路。
|
||||||
|
# 默认关闭:先完成预演验证(含 lanshang_8000/8003 是否支持 IMEI 的实测)
|
||||||
|
# 再按环境开启,否则未验证的通道会直接进失败池。
|
||||||
|
imeiFullSync:
|
||||||
|
enabled: false
|
||||||
|
redisKeyPrefix: "imei:"
|
||||||
|
retry:
|
||||||
|
enabled: true
|
||||||
|
maxRounds: 5
|
||||||
|
failureRetention: "720h"
|
||||||
|
producer:
|
||||||
|
pageSize: 1000
|
||||||
|
globalHighWaterMark: 0
|
||||||
|
pollInterval: "1s"
|
||||||
|
runLockKey: "imei:producer:active"
|
||||||
|
runLockTTL: "5m"
|
||||||
|
consumer:
|
||||||
|
enabled: false
|
||||||
|
chanClientName: "iotlcm.example.com:8110"
|
||||||
|
chanClientTimeout: "30s"
|
||||||
|
lockKey: "imei:consumer:active"
|
||||||
|
lockTTL: "1m"
|
||||||
|
pollInterval: "1s"
|
||||||
|
leaseTTL: "3m"
|
||||||
|
taskTimeout: "2m"
|
||||||
|
claimBatch: 100
|
||||||
|
mobileConcurrency: 100
|
||||||
|
telecomConcurrency: 8
|
||||||
|
genericConcurrency: 1
|
||||||
|
genericQPS: 1.5
|
||||||
|
networkRetries: 2
|
||||||
|
# 失败池告警阀值(条目数):单条 FailureRecord 约 1KB 且保留整个
|
||||||
|
# failureRetention,超阀值只打 WARN 日志,绝不截断 Run。
|
||||||
|
failurePoolAlertThreshold: 50000
|
||||||
|
templateQPS:
|
||||||
|
sim_yd_v5: 100
|
||||||
|
sim_yd_mock_v5: 100
|
||||||
|
sim_onelink_tengyu_v1: 100
|
||||||
|
sim_onelink_xuzhou_mobile_v1: 100
|
||||||
|
sim_onelink_lelian_v1: 100
|
||||||
|
sim_dx_v1: 5
|
||||||
|
|
||||||
|
# 诊断消费者开关(仅本地调试开)
|
||||||
|
workerDiagnostic:
|
||||||
|
enabled: true
|
||||||
|
|
||||||
|
# 时序数据库配置
|
||||||
|
tsdb:
|
||||||
|
database: "TdEngine" #可选择 TdEngine、Influxdb
|
||||||
|
tdengine:
|
||||||
|
# type: "taosRestful" #http连接方式,端口是6041
|
||||||
|
# dsn: "${SERVICE_DSN}"
|
||||||
|
type: "taosWS" #websocket连接方式,端口是6041
|
||||||
|
# dsn: "${SERVICE_DSN}"
|
||||||
|
dsn: "${SERVICE_DSN}"
|
||||||
|
dbName: "iotlcm"
|
||||||
|
maxOpenConns: 100
|
||||||
|
maxIdleConns: 10
|
||||||
|
|
||||||
|
# 上游服务调用
|
||||||
|
upstream:
|
||||||
|
proxy:
|
||||||
|
address: "iotlcm.example.com:8510"
|
||||||
|
# address: "localhost:8510"
|
||||||
|
timeout: 10s
|
||||||
|
gateway:
|
||||||
|
address: "iotlcm.example.com:8110"
|
||||||
|
timeout: 10s
|
||||||
@@ -0,0 +1,379 @@
|
|||||||
|
roleSync:
|
||||||
|
enabled: false
|
||||||
|
|
||||||
|
# config_default.yaml 配置文件
|
||||||
|
# @Link https://github.com/xiujiecn/xiu-admin
|
||||||
|
# @Copyright Copyright (c) 2025 LiXiujie
|
||||||
|
# @Author Lxj <xxx@example.com>
|
||||||
|
# @License https://github.com/xiujiecn/xiu-admin/blob/master/LICENSE
|
||||||
|
|
||||||
|
# 系统配置
|
||||||
|
workerDiagnostic:
|
||||||
|
enabled: false
|
||||||
|
|
||||||
|
fullSyncOps:
|
||||||
|
control:
|
||||||
|
# Worker control is reachable only on the private Docker network.
|
||||||
|
listenAddress: ":18081"
|
||||||
|
readTimeout: "5s"
|
||||||
|
writeTimeout: "15s"
|
||||||
|
shutdownTimeout: "10s"
|
||||||
|
archive:
|
||||||
|
enabled: false
|
||||||
|
rootDir: "/app/logs/full-sync"
|
||||||
|
pollInterval: "10s"
|
||||||
|
retryMin: "5s"
|
||||||
|
retryMax: "5m"
|
||||||
|
replay:
|
||||||
|
# Retry-round budget for a replayed Run when the request omits maxRounds.
|
||||||
|
# Requests may override it up to 100; larger requests are rejected.
|
||||||
|
maxRounds: 3
|
||||||
|
|
||||||
|
# Alipay credentials are injected by the deployment configuration.
|
||||||
|
alipay:
|
||||||
|
appId: "${APP_ID}"
|
||||||
|
privateKey: "${PRIVATE_KEY}"
|
||||||
|
alipayPublicKey: ""
|
||||||
|
isProduction: false
|
||||||
|
|
||||||
|
rabbitmq:
|
||||||
|
balanceRecharge:
|
||||||
|
retryDelay: "1m"
|
||||||
|
# 充值超时消息最大重试次数,超过后投递到 iotlcm.balance.recharge.dlq
|
||||||
|
maxRetry: 100
|
||||||
|
batchExecute:
|
||||||
|
executionTimeout: "0s"
|
||||||
|
finalizationTimeout: "60s"
|
||||||
|
retryDelay: "30s"
|
||||||
|
maxRetries: 3
|
||||||
|
|
||||||
|
batch:
|
||||||
|
allocate:
|
||||||
|
processingRetryAfter: "30m"
|
||||||
|
deadlockMaxRetries: 3
|
||||||
|
deadlockRetryDelay: "200ms"
|
||||||
|
inbound:
|
||||||
|
upstreamBatchSize: 1
|
||||||
|
upstreamConcurrency: 8
|
||||||
|
upstreamRateLimit:
|
||||||
|
enabled: true
|
||||||
|
mode: "redis"
|
||||||
|
redisGroup: "default"
|
||||||
|
keyPrefix: "iotlcm:worker:inbound:upstream_basic_info"
|
||||||
|
redisFailureFallback: "error"
|
||||||
|
default:
|
||||||
|
qps: 20
|
||||||
|
burst: 1
|
||||||
|
requestCost: 1
|
||||||
|
channels: {}
|
||||||
|
|
||||||
|
# 本月流量全量同步(首版默认关闭,完成 PoC 后按环境开启)
|
||||||
|
trafficUsageFullSync:
|
||||||
|
enabled: false
|
||||||
|
supplyObservation:
|
||||||
|
enabled: false # 1s sampling, 10s snapshots; Run first-round channel scanning only
|
||||||
|
redisKeyPrefix: "flow:"
|
||||||
|
retry:
|
||||||
|
enabled: true
|
||||||
|
maxRounds: 5
|
||||||
|
failureRetention: "720h"
|
||||||
|
producer:
|
||||||
|
# channel requires idx_card_chan_key; never creates or rebuilds an index.
|
||||||
|
scanConcurrency: 4 # SQL burst protection, not a measured optimum
|
||||||
|
pageSize: 100
|
||||||
|
globalHighWaterMark: 10000
|
||||||
|
channelHighWaterMark: 2000
|
||||||
|
pollInterval: "2s"
|
||||||
|
runLockKey: "flow:producer:active"
|
||||||
|
runLockTTL: "5m"
|
||||||
|
consumer:
|
||||||
|
enabled: false
|
||||||
|
# Full-sync consumer calls Chan directly; leave empty only for local tests.
|
||||||
|
chanClientName: "localhost:8110"
|
||||||
|
# A batch of 100 cards may queue behind the gateway's per-IP Tibiot
|
||||||
|
# limiter (about 650ms per request) before its request is served.
|
||||||
|
chanClientTimeout: "90s"
|
||||||
|
lockKey: "flow:consumer:active"
|
||||||
|
lockTTL: "1m"
|
||||||
|
pollInterval: "1s"
|
||||||
|
leaseTTL: "3m"
|
||||||
|
taskTimeout: "2m"
|
||||||
|
claimBatch: 100
|
||||||
|
mobileConcurrency: 30
|
||||||
|
telecomConcurrency: 20
|
||||||
|
genericConcurrency: 1
|
||||||
|
genericQPS: 1.5
|
||||||
|
# 失败池告警阀值(条目数):单条 FailureRecord 约 1KB 且保留整个
|
||||||
|
# failureRetention,超阀值只打 WARN 日志,绝不截断 Run。
|
||||||
|
failurePoolAlertThreshold: 50000
|
||||||
|
templateQPS:
|
||||||
|
sim_yd_v5: 30
|
||||||
|
sim_yd_mock_v5: 30
|
||||||
|
sim_onelink_tengyu_v1: 30
|
||||||
|
sim_onelink_beixin_v1: 30
|
||||||
|
lanshang_8000: 30
|
||||||
|
lanshang_8003: 30
|
||||||
|
sim_onelink_xuzhou_mobile_v1: 30
|
||||||
|
sim_onelink_lelian_v1: 30
|
||||||
|
sim_onelink_qianxun_v1: 30
|
||||||
|
sim_onelink_henan_xinjing_v1: 30
|
||||||
|
sim_onelink_daihe_v1: 30
|
||||||
|
sim_dx_v1: 25
|
||||||
|
|
||||||
|
# 独立的日流量同步链路,与月流量/状态同步同构。
|
||||||
|
# 默认关闭:打开后 worker 才装配日流量 producer 与 consumer。
|
||||||
|
# 与 trafficUsageFullSync 使用独立前缀与 templateQPS,避免三条链路互相抢运营商配额;
|
||||||
|
# 定时窗口建议与全量流量同步错峰 2-3 小时(避开 0-4 点出账窗口)。
|
||||||
|
trafficUsageDailySync:
|
||||||
|
enabled: false
|
||||||
|
redisKeyPrefix: "daily:"
|
||||||
|
retry:
|
||||||
|
enabled: true
|
||||||
|
# 默认 3 轮,对齐状态同步;失败卡最终进失败池,可 replay。
|
||||||
|
maxRounds: 3
|
||||||
|
failureRetention: "720h"
|
||||||
|
producer:
|
||||||
|
pageSize: 100
|
||||||
|
globalHighWaterMark: 0
|
||||||
|
channelHighWaterMark: 0
|
||||||
|
pollInterval: "2s"
|
||||||
|
runLockKey: "daily:producer:active"
|
||||||
|
runLockTTL: "5m"
|
||||||
|
consumer:
|
||||||
|
enabled: false
|
||||||
|
lockKey: "daily:consumer:active"
|
||||||
|
lockTTL: "1m"
|
||||||
|
pollInterval: "1s"
|
||||||
|
leaseTTL: "3m"
|
||||||
|
taskTimeout: "2m"
|
||||||
|
claimBatch: 100
|
||||||
|
mobileConcurrency: 30
|
||||||
|
telecomConcurrency: 8
|
||||||
|
genericConcurrency: 1
|
||||||
|
genericQPS: 1.5
|
||||||
|
# 与 trafficUsageFullSync 分开配置:日流量与月流量走同一出口 IP,
|
||||||
|
# 共用一份 QPS 会让两轮同步互相挤占运营商配额。
|
||||||
|
templateQPS:
|
||||||
|
sim_yd_v5: 30
|
||||||
|
sim_yd_mock_v5: 30
|
||||||
|
sim_onelink_tengyu_v1: 30
|
||||||
|
sim_onelink_beixin_v1: 30
|
||||||
|
lanshang_8000: 30
|
||||||
|
lanshang_8003: 30
|
||||||
|
sim_onelink_xuzhou_mobile_v1: 30
|
||||||
|
sim_onelink_lelian_v1: 30
|
||||||
|
sim_onelink_qianxun_v1: 30
|
||||||
|
sim_onelink_henan_xinjing_v1: 30
|
||||||
|
sim_onelink_daihe_v1: 30
|
||||||
|
sim_dx_v1: 25
|
||||||
|
|
||||||
|
# 独立的月流量历史同步链路(写 TDengine 月表),与日流量/状态同步同构。
|
||||||
|
# 注意:这与 trafficUsageFullSync 不是一回事——后者填的是 MySQL 当月快照。
|
||||||
|
# 默认关闭:日流量验证通过后再单独打开。
|
||||||
|
trafficUsageMonthlyHistorySync:
|
||||||
|
enabled: false
|
||||||
|
redisKeyPrefix: "monthly-history:"
|
||||||
|
retry:
|
||||||
|
enabled: true
|
||||||
|
maxRounds: 3
|
||||||
|
failureRetention: "720h"
|
||||||
|
producer:
|
||||||
|
pageSize: 100
|
||||||
|
globalHighWaterMark: 0
|
||||||
|
channelHighWaterMark: 0
|
||||||
|
pollInterval: "2s"
|
||||||
|
runLockKey: "monthly-history:producer:active"
|
||||||
|
runLockTTL: "5m"
|
||||||
|
consumer:
|
||||||
|
enabled: false
|
||||||
|
lockKey: "monthly-history:consumer:active"
|
||||||
|
lockTTL: "1m"
|
||||||
|
pollInterval: "1s"
|
||||||
|
leaseTTL: "3m"
|
||||||
|
taskTimeout: "2m"
|
||||||
|
claimBatch: 100
|
||||||
|
mobileConcurrency: 30
|
||||||
|
telecomConcurrency: 8
|
||||||
|
genericConcurrency: 1
|
||||||
|
genericQPS: 1.5
|
||||||
|
# 三条链路共用出口 IP,各自独立限速,避免互相挤占运营商配额。
|
||||||
|
templateQPS:
|
||||||
|
sim_yd_v5: 30
|
||||||
|
sim_yd_mock_v5: 30
|
||||||
|
sim_onelink_tengyu_v1: 30
|
||||||
|
sim_onelink_beixin_v1: 30
|
||||||
|
lanshang_8000: 30
|
||||||
|
lanshang_8003: 30
|
||||||
|
sim_onelink_xuzhou_mobile_v1: 30
|
||||||
|
sim_onelink_lelian_v1: 30
|
||||||
|
sim_onelink_qianxun_v1: 30
|
||||||
|
sim_onelink_henan_xinjing_v1: 30
|
||||||
|
sim_onelink_daihe_v1: 30
|
||||||
|
sim_dx_v1: 25
|
||||||
|
|
||||||
|
# 独立的状态同步队列链路,作为消费 worker 常驻启用。
|
||||||
|
simStatusQueueSync:
|
||||||
|
enabled: true
|
||||||
|
redisKeyPrefix: "sim-status:"
|
||||||
|
retry:
|
||||||
|
enabled: true
|
||||||
|
maxRounds: 3
|
||||||
|
failureRetention: "720h"
|
||||||
|
producer:
|
||||||
|
pageSize: 1000
|
||||||
|
globalHighWaterMark: 0
|
||||||
|
pollInterval: "1s"
|
||||||
|
runLockKey: "sim-status:producer:active"
|
||||||
|
runLockTTL: "5m"
|
||||||
|
consumer:
|
||||||
|
enabled: true
|
||||||
|
lockKey: "sim-status:consumer:active"
|
||||||
|
lockTTL: "1m"
|
||||||
|
pollInterval: "1s"
|
||||||
|
leaseTTL: "3m"
|
||||||
|
taskTimeout: "2m"
|
||||||
|
claimBatch: 100
|
||||||
|
mobileConcurrency: 100
|
||||||
|
telecomConcurrency: 8
|
||||||
|
genericConcurrency: 1
|
||||||
|
genericQPS: 1.5
|
||||||
|
networkRetries: 2
|
||||||
|
# 失败池告警阀值(条目数):单条 FailureRecord 约 1KB 且保留整个
|
||||||
|
# failureRetention,超阀值只打 WARN 日志,绝不截断 Run。
|
||||||
|
# 50000 条 ≈ 50MB,约当 255,976 张卡的 20% 失败率。
|
||||||
|
failurePoolAlertThreshold: 50000
|
||||||
|
templateQPS:
|
||||||
|
sim_yd_v5: 100
|
||||||
|
sim_yd_mock_v5: 100
|
||||||
|
sim_onelink_tengyu_v1: 100
|
||||||
|
sim_onelink_beixin_v1: 100
|
||||||
|
lanshang_8000: 100
|
||||||
|
lanshang_8003: 100
|
||||||
|
sim_onelink_xuzhou_mobile_v1: 100
|
||||||
|
sim_onelink_lelian_v1: 100
|
||||||
|
sim_dx_v1: 5
|
||||||
|
|
||||||
|
# 独立的 IMEI 全量同步链路(首版默认关闭,PoC 后按环境开启)。
|
||||||
|
#
|
||||||
|
# 该链路是"只增不减"的:只写非空 IMEI,上游返回空值只记心跳不写值,
|
||||||
|
# 所以一次上游抖动不会清空全库 IMEI。
|
||||||
|
#
|
||||||
|
# 移动 IMEI 没有批量接口,chan 内部逐卡请求,吞吐 = 卡数 × 单请求耗时。
|
||||||
|
# 电信 18 QPS 额度与状态/流量链路共享,故 templateQPS 压到 5。
|
||||||
|
imeiFullSync:
|
||||||
|
enabled: false
|
||||||
|
redisKeyPrefix: "imei:"
|
||||||
|
retry:
|
||||||
|
enabled: true
|
||||||
|
# 移动 13000 系统忙是常态(1 QPS 下仍有约 70%),轮数给足
|
||||||
|
maxRounds: 5
|
||||||
|
failureRetention: "720h"
|
||||||
|
producer:
|
||||||
|
pageSize: 1000
|
||||||
|
globalHighWaterMark: 0
|
||||||
|
pollInterval: "1s"
|
||||||
|
runLockKey: "imei:producer:active"
|
||||||
|
runLockTTL: "5m"
|
||||||
|
consumer:
|
||||||
|
enabled: false
|
||||||
|
# 全量同步直连 chan;留空仅供本地测试
|
||||||
|
chanClientName: ""
|
||||||
|
chanClientTimeout: "30s"
|
||||||
|
lockKey: "imei:consumer:active"
|
||||||
|
lockTTL: "1m"
|
||||||
|
pollInterval: "1s"
|
||||||
|
leaseTTL: "3m"
|
||||||
|
taskTimeout: "2m"
|
||||||
|
claimBatch: 100
|
||||||
|
mobileConcurrency: 100
|
||||||
|
telecomConcurrency: 8
|
||||||
|
genericConcurrency: 1
|
||||||
|
genericQPS: 1.5
|
||||||
|
networkRetries: 2
|
||||||
|
# 失败池告警阀值(条目数):单条 FailureRecord 约 1KB 且保留整个
|
||||||
|
# failureRetention(30 天),超阀值只打 WARN 日志,绝不截断 Run。
|
||||||
|
# 50000 条 ≈ 50MB,约当 255,976 张卡的 20% 失败率。
|
||||||
|
failurePoolAlertThreshold: 50000
|
||||||
|
templateQPS:
|
||||||
|
sim_yd_v5: 100
|
||||||
|
sim_yd_mock_v5: 100
|
||||||
|
sim_onelink_tengyu_v1: 100
|
||||||
|
sim_onelink_beixin_v1: 100
|
||||||
|
lanshang_8000: 100
|
||||||
|
lanshang_8003: 100
|
||||||
|
sim_onelink_xuzhou_mobile_v1: 100
|
||||||
|
sim_onelink_lelian_v1: 100
|
||||||
|
# 电信额度与状态/流量链路共享,压到最低
|
||||||
|
sim_dx_v1: 5
|
||||||
|
|
||||||
|
upstream:
|
||||||
|
proxy:
|
||||||
|
address: "localhost:8510"
|
||||||
|
timeout: 10s
|
||||||
|
gateway:
|
||||||
|
address: "localhost:8110"
|
||||||
|
timeout: 10s
|
||||||
|
# 入库前卡还未进入本地卡表,三码校验直连 upstream-chan,避免走 8510 proxy 的本地卡拦截。
|
||||||
|
|
||||||
|
system:
|
||||||
|
name: "IotLCM"
|
||||||
|
version: "1.0"
|
||||||
|
description: "IotLCM Server"
|
||||||
|
gfmode: "dev"
|
||||||
|
upload:
|
||||||
|
local:
|
||||||
|
path: "resource/upload"
|
||||||
|
|
||||||
|
# 日志配置
|
||||||
|
logger:
|
||||||
|
path: "/app/logs" # 日志文件路径。默认为空,表示关闭,仅输出到终端
|
||||||
|
file: "{Y-m-d-H}.log" # 日志文件格式。默认为"{Y-m-d}.log"
|
||||||
|
prefix: "" # 日志内容输出前缀。默认为空
|
||||||
|
level: "dev" # 日志输出级别
|
||||||
|
timeFormat: "2006-01-02T15:04:05" # 自定义日志输出的时间格式,使用Golang标准的时间格式配置
|
||||||
|
ctxKeys: [] # 自定义Context上下文变量名称,自动打印Context的变量到日志中。默认为空
|
||||||
|
header: true # 是否打印日志的头信息。默认true
|
||||||
|
stdout: true # 日志是否同时输出到终端。默认true
|
||||||
|
rotateSize: 0 # 按照日志文件大小对文件进行滚动切分。默认为0,表示关闭滚动切分特性
|
||||||
|
rotateExpire: 0 # 按照日志文件时间间隔对文件滚动切分。默认为0,表示关闭滚动切分特性
|
||||||
|
rotateBackupLimit: 0 # 按照切分的文件数量清理切分文件,当滚动切分特性开启时有效。默认为0,表示不备份,切分则删除
|
||||||
|
rotateBackupExpire: 0 # 按照切分的文件有效期清理切分文件,当滚动切分特性开启时有效。默认为0,表示不备份,切分则删除
|
||||||
|
rotateBackupCompress: 0 # 滚动切分文件的压缩比(0-9)。默认为0,表示不压缩
|
||||||
|
rotateCheckInterval: "1h" # 滚动切分的时间检测间隔,一般不需要设置。默认为1小时
|
||||||
|
stdoutColorDisabled: false # 关闭终端的颜色打印。默认开启
|
||||||
|
writerColorEnable: false # 日志文件是否带上颜色。默认false,表示不带颜色
|
||||||
|
|
||||||
|
# 数据库配置
|
||||||
|
database:
|
||||||
|
logger:
|
||||||
|
path: "/app/logs/sql"
|
||||||
|
level: "all"
|
||||||
|
stdout: true
|
||||||
|
default: # 分组名称,可自定义,默认为default
|
||||||
|
link: "${DB_DSN}"
|
||||||
|
debug: true #开启调试模式
|
||||||
|
charset: "utf8mb4" # (可选)数据库编码(如: utf8mb4/utf8/gbk/gb2312),一般设置为utf8mb4。默认为utf8。
|
||||||
|
dryRun: false #ORM空跑(只读不写)
|
||||||
|
maxIdle: 30 #连接池最大闲置的连接数
|
||||||
|
maxOpen: 100 #连接池最大打开的连接数
|
||||||
|
maxLifetime: 120 #(单位秒)连接对象可重复使用的时间长度
|
||||||
|
|
||||||
|
# 缓存配置
|
||||||
|
cache:
|
||||||
|
adapter: "redis" # 缓存适配器,支持:file|redis
|
||||||
|
fileDir: "resource/cache" # 缓存文件目录
|
||||||
|
|
||||||
|
# Redis 配置
|
||||||
|
redis:
|
||||||
|
# 单实例配置
|
||||||
|
default:
|
||||||
|
mode: "single" #redis模式,支持:single|cluster|sentinel
|
||||||
|
sentinelMasterName: "xiujie-master" #哨兵模式主节点名称
|
||||||
|
address: 127.0.0.1:6379
|
||||||
|
db: 0
|
||||||
|
pass: "${SERVICE_PASSWORD}"
|
||||||
|
minIdle: 5 #最小空闲连接数
|
||||||
|
maxIdle: 30 #允许闲置的最大连接数
|
||||||
|
maxActive: 5000 #最大活跃连接数
|
||||||
|
idleTimeout: "60s" #连接最大空闲时间,使用时间字符串例如30s/2m/2d
|
||||||
|
ClientName: "SystemCache"
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
module git.5iot.com/iotlcm-public/iotlcm-worker-svr
|
||||||
|
|
||||||
|
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/v2 v2.10.0
|
||||||
|
github.com/google/uuid v1.6.0
|
||||||
|
github.com/rabbitmq/amqp091-go v1.10.0
|
||||||
|
github.com/redis/go-redis/v9 v9.18.0
|
||||||
|
github.com/smartwalle/alipay/v3 v3.2.29
|
||||||
|
github.com/taosdata/driver-go/v3 v3.8.0
|
||||||
|
github.com/xuri/excelize/v2 v2.9.0
|
||||||
|
)
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/BurntSushi/toml v1.5.0 // indirect
|
||||||
|
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||||
|
github.com/clbanning/mxj/v2 v2.7.0 // indirect
|
||||||
|
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
|
||||||
|
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
|
||||||
|
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||||
|
github.com/emirpasic/gods/v2 v2.0.0-alpha // indirect
|
||||||
|
github.com/fatih/color v1.18.0 // indirect
|
||||||
|
github.com/fsnotify/fsnotify v1.9.0 // indirect
|
||||||
|
github.com/glebarez/go-sqlite v1.21.2 // indirect
|
||||||
|
github.com/go-logr/logr v1.4.3 // indirect
|
||||||
|
github.com/go-logr/stdr v1.2.2 // indirect
|
||||||
|
github.com/go-sql-driver/mysql v1.7.1 // indirect
|
||||||
|
github.com/gorilla/websocket v1.5.3 // indirect
|
||||||
|
github.com/grokify/html-strip-tags-go v0.1.0 // indirect
|
||||||
|
github.com/json-iterator/go v1.1.12 // 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/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||||
|
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||||
|
github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 // 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/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
|
||||||
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||||
|
github.com/richardlehane/mscfb v1.0.4 // indirect
|
||||||
|
github.com/richardlehane/msoleps v1.0.4 // indirect
|
||||||
|
github.com/rivo/uniseg v0.2.0 // indirect
|
||||||
|
github.com/smartwalle/ncrypto v1.0.4 // indirect
|
||||||
|
github.com/smartwalle/ngx v1.1.0 // indirect
|
||||||
|
github.com/smartwalle/nsign v1.0.9 // indirect
|
||||||
|
github.com/xuri/efp v0.0.0-20240408161823-9ad904a10d6d // indirect
|
||||||
|
github.com/xuri/nfp v0.0.0-20240318013403-ab9948c2c4a7 // 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/crypto v0.46.0 // 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
|
||||||
|
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,172 @@
|
|||||||
|
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/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
|
||||||
|
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
|
||||||
|
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
|
||||||
|
github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0=
|
||||||
|
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||||
|
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||||
|
github.com/clbanning/mxj/v2 v2.7.0 h1:WA/La7UGCanFe5NpHF0Q3DNtnCsVoxbPKuyBNHWRyME=
|
||||||
|
github.com/clbanning/mxj/v2 v2.7.0/go.mod h1:hNiWqW14h+kc+MdF9C6/YoRfjEJoR3ou6tn/Qo+ve2s=
|
||||||
|
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
|
||||||
|
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
|
||||||
|
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
|
||||||
|
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||||
|
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||||
|
github.com/emirpasic/gods/v2 v2.0.0-alpha h1:dwFlh8pBg1VMOXWGipNMRt8v96dKAIvBehtCt6OtunU=
|
||||||
|
github.com/emirpasic/gods/v2 v2.0.0-alpha/go.mod h1:W0y4M2dtBB9U5z3YlghmpuUhiaZT2h6yoeE+C1sCp6A=
|
||||||
|
github.com/fatih/color v1.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-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/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/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/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.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||||
|
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/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
||||||
|
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
||||||
|
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/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||||
|
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||||
|
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.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
|
||||||
|
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
|
||||||
|
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/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||||
|
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
|
||||||
|
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||||
|
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
|
||||||
|
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
||||||
|
github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 h1:RWengNIwukTxcDr9M+97sNutRR1RKhG96O6jWumTTnw=
|
||||||
|
github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826/go.mod h1:TaXosZuwdSHYgviHp1DAtfrULt5eUgsSMsZf+YrPgl8=
|
||||||
|
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/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/rabbitmq/amqp091-go v1.10.0 h1:STpn5XsHlHGcecLmMFCtg7mqq0RnD+zFr4uzukfVhBw=
|
||||||
|
github.com/rabbitmq/amqp091-go v1.10.0/go.mod h1:Hy4jKW5kQART1u+JkDTF9YYOQUHXqMuhrgxOEeS7G4o=
|
||||||
|
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/richardlehane/mscfb v1.0.4 h1:WULscsljNPConisD5hR0+OyZjwK46Pfyr6mPu5ZawpM=
|
||||||
|
github.com/richardlehane/mscfb v1.0.4/go.mod h1:YzVpcZg9czvAuhk9T+a3avCpcFPMUWm7gK3DypaEsUk=
|
||||||
|
github.com/richardlehane/msoleps v1.0.1/go.mod h1:BWev5JBpU9Ko2WAgmZEuiz4/u3ZYTKbjLycmwiWUfWg=
|
||||||
|
github.com/richardlehane/msoleps v1.0.4 h1:WuESlvhX3gH2IHcd8UqyCuFY5yiq/GR/yqaSM/9/g00=
|
||||||
|
github.com/richardlehane/msoleps v1.0.4/go.mod h1:BWev5JBpU9Ko2WAgmZEuiz4/u3ZYTKbjLycmwiWUfWg=
|
||||||
|
github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY=
|
||||||
|
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
||||||
|
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/smartwalle/alipay/v3 v3.2.29 h1:roGFqlml8hDa//0TpFmlyxZhndTYs7rbYLu/HlNFNJo=
|
||||||
|
github.com/smartwalle/alipay/v3 v3.2.29/go.mod h1:XarBLuAkwK3ah7mYjVtghRu+ysxzlex9sRkgqNMzMRU=
|
||||||
|
github.com/smartwalle/ncrypto v1.0.4 h1:P2rqQxDepJwgeO5ShoC+wGcK2wNJDmcdBOWAksuIgx8=
|
||||||
|
github.com/smartwalle/ncrypto v1.0.4/go.mod h1:Dwlp6sfeNaPMnOxMNayMTacvC5JGEVln3CVdiVDgbBk=
|
||||||
|
github.com/smartwalle/ngx v1.1.0 h1:q8nANgWSPRGeI/u+ixBoA4mf68DrUq6vZ+n9L5UKv9I=
|
||||||
|
github.com/smartwalle/ngx v1.1.0/go.mod h1:mx/nz2Pk5j+RBs7t6u6k22MPiBG/8CtOMpCnALIG8Y0=
|
||||||
|
github.com/smartwalle/nsign v1.0.9 h1:8poAgG7zBd8HkZy9RQDwasC6XZvJpDGQWSjzL2FZL6E=
|
||||||
|
github.com/smartwalle/nsign v1.0.9/go.mod h1:eY6I4CJlyNdVMP+t6z1H6Jpd4m5/V+8xi44ufSTxXgc=
|
||||||
|
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||||
|
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||||
|
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.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||||
|
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||||
|
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||||
|
github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||||
|
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/taosdata/driver-go/v3 v3.8.0 h1:qY1xFidspISuuZc0TAbeJWMtAdKJOSYQOXfepkUK6fI=
|
||||||
|
github.com/taosdata/driver-go/v3 v3.8.0/go.mod h1:S6OGOinfR0xxxaMGsvBi9cLkYxEIW1p6qqr8QJATTlg=
|
||||||
|
github.com/xuri/efp v0.0.0-20240408161823-9ad904a10d6d h1:llb0neMWDQe87IzJLS4Ci7psK/lVsjIS2otl+1WyRyY=
|
||||||
|
github.com/xuri/efp v0.0.0-20240408161823-9ad904a10d6d/go.mod h1:ybY/Jr0T0GTCnYjKqmdwxyxn2BQf2RcQIIvex5QldPI=
|
||||||
|
github.com/xuri/excelize/v2 v2.9.0 h1:1tgOaEq92IOEumR1/JfYS/eR0KHOCsRv/rYXXh6YJQE=
|
||||||
|
github.com/xuri/excelize/v2 v2.9.0/go.mod h1:uqey4QBZ9gdMeWApPLdhm9x+9o2lq4iVmjiLfBS5hdE=
|
||||||
|
github.com/xuri/nfp v0.0.0-20240318013403-ab9948c2c4a7 h1:hPVCafDV85blFTabnqKgNhDCkJX25eik94Si9cTER4A=
|
||||||
|
github.com/xuri/nfp v0.0.0-20240318013403-ab9948c2c4a7/go.mod h1:WwHg+CVyzlv/TX9xqBFXEZAuxOPxn2k1GNHwG41IIUQ=
|
||||||
|
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.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU=
|
||||||
|
golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0=
|
||||||
|
golang.org/x/image v0.18.0 h1:jGzIakQa/ZXI1I0Fxvaa9W7yP25TqT6cHIHn+6CqvSQ=
|
||||||
|
golang.org/x/image v0.18.0/go.mod h1:4yyo5vMFQjVjUcVk4jEQcU9MGy/rulF5WvUILseCM2E=
|
||||||
|
golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU=
|
||||||
|
golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY=
|
||||||
|
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/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU=
|
||||||
|
golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY=
|
||||||
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/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.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
modernc.org/libc v1.22.5 h1:91BNch/e5B0uPbJFgqbxXuOnxBQjlS//icfQEGmvyjE=
|
||||||
|
modernc.org/libc v1.22.5/go.mod h1:jj+Z7dTNX8fBScMVNRAYZ/jF91K8fdT2hYMThc3YjBY=
|
||||||
|
modernc.org/mathutil v1.5.0 h1:rV0Ko/6SfM+8G+yKiyI830l3Wuz1zRutdslNoQ0kfiQ=
|
||||||
|
modernc.org/mathutil v1.5.0/go.mod h1:mZW8CKdRPY1v87qxC/wUdX5O1qDzXMP5TH3wjfpga6E=
|
||||||
|
modernc.org/memory v1.5.0 h1:N+/8c5rE6EqugZwHii4IFsaJ7MUhoWX07J5tC/iI5Ds=
|
||||||
|
modernc.org/memory v1.5.0/go.mod h1:PkUhL0Mugw21sHPeskwZW4D6VscE/GQJOnIpCnW6pSU=
|
||||||
|
modernc.org/sqlite v1.23.1 h1:nrSBg4aRQQwq59JpvGEQ15tNxoO5pX/kUjcRNwSAGQM=
|
||||||
|
modernc.org/sqlite v1.23.1/go.mod h1:OrDj17Mggn6MhE+iPbBNf7RGKODDE9NFT0f3EwDzJqk=
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
# CLI tool, only in development environment.
|
||||||
|
# https://goframe.org/docs/cli
|
||||||
|
gfcli:
|
||||||
|
gen:
|
||||||
|
dao:
|
||||||
|
- link: "${DB_DSN}"
|
||||||
|
tables: "sys_role,sys_role_menu,sys_role_dept,sys_config,sys_user_role,batch_main,batch_detail,sys_dept,sys_oss,t_resource_batch_download,t_sim_card_cancellation,t_sim_card_info,t_sim_flow_pool,t_warn_config,t_warn_log,t_wo_main,t_wo_record,t_message_log,t_user_message"
|
||||||
|
descriptionTag: true
|
||||||
|
noModelComment: true
|
||||||
|
jsonCase: "CamelLower"
|
||||||
|
gJsonSupport: true
|
||||||
|
group: "default"
|
||||||
|
|
||||||
|
docker:
|
||||||
|
build: "-a amd64 -s linux -p temp -ew"
|
||||||
|
tagPrefixes:
|
||||||
|
- iotlcm-worker-svr
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
|
||||||
|
# Install/Update to the latest CLI tool.
|
||||||
|
.PHONY: cli
|
||||||
|
cli:
|
||||||
|
@set -e; \
|
||||||
|
echo "go install github.com/gogf/gf/cmd/gf/v2@latest"; \
|
||||||
|
go install github.com/gogf/gf/cmd/gf/v2@latest; \
|
||||||
|
echo "GoFame CLI installed successfully!"
|
||||||
|
|
||||||
|
|
||||||
|
# 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,75 @@
|
|||||||
|
.DEFAULT_GOAL := build
|
||||||
|
|
||||||
|
# Update GoFrame and its CLI to latest stable version.
|
||||||
|
.PHONY: up
|
||||||
|
up: cli.install
|
||||||
|
@gf up -a
|
||||||
|
|
||||||
|
# Build binary using configuration from hack/config.yaml.
|
||||||
|
.PHONY: build
|
||||||
|
build: cli.install
|
||||||
|
@gf build -ew
|
||||||
|
|
||||||
|
# Parse api and generate controller/sdk.
|
||||||
|
.PHONY: ctrl
|
||||||
|
ctrl: cli.install
|
||||||
|
@gf gen ctrl
|
||||||
|
|
||||||
|
# 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
|
||||||
@@ -0,0 +1,157 @@
|
|||||||
|
package batch
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
|
||||||
|
"github.com/gogf/gf/v2/os/gtime"
|
||||||
|
|
||||||
|
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/model/entity"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
Type3 = 3
|
||||||
|
Type4 = 4
|
||||||
|
Type11 = 11
|
||||||
|
Type12 = 12
|
||||||
|
Type40 = 40
|
||||||
|
Type41 = 41
|
||||||
|
Type101 = 101
|
||||||
|
Type102 = 102
|
||||||
|
Type103 = 103
|
||||||
|
Type104 = 104
|
||||||
|
Type105 = 105
|
||||||
|
Type106 = 106
|
||||||
|
Type107 = 107
|
||||||
|
Type108 = 108
|
||||||
|
Type109 = 109
|
||||||
|
Type110 = 110
|
||||||
|
|
||||||
|
AsyncStatusPending = 1
|
||||||
|
AsyncStatusProcessing = 2
|
||||||
|
AsyncStatusSuccess = 3
|
||||||
|
AsyncStatusFailed = 4
|
||||||
|
RunResultRunning = 1
|
||||||
|
RunResultSuccess = 2
|
||||||
|
RunResultFailed = 3
|
||||||
|
|
||||||
|
DetailStatusPending = 0
|
||||||
|
DetailStatusRunning = 1
|
||||||
|
DetailStatusSuccess = 2
|
||||||
|
DetailStatusFailed = 3
|
||||||
|
)
|
||||||
|
|
||||||
|
type Message struct {
|
||||||
|
BatchNo string `json:"batchNo"`
|
||||||
|
BatchType int `json:"batchType"`
|
||||||
|
FailureReason string `json:"failureReason,omitempty"`
|
||||||
|
Retryable bool `json:"retryable,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// RetryableError marks an executor failure that may succeed after delayed redelivery.
|
||||||
|
// The batch remains in processing until retries are exhausted and finalized.
|
||||||
|
type RetryableError struct {
|
||||||
|
Err error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *RetryableError) Error() string {
|
||||||
|
if e == nil || e.Err == nil {
|
||||||
|
return "retryable batch execution error"
|
||||||
|
}
|
||||||
|
return e.Err.Error()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *RetryableError) Unwrap() error {
|
||||||
|
if e == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return e.Err
|
||||||
|
}
|
||||||
|
|
||||||
|
func Retryable(err error) error {
|
||||||
|
if err == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if IsRetryable(err) {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return &RetryableError{Err: err}
|
||||||
|
}
|
||||||
|
|
||||||
|
func IsRetryable(err error) bool {
|
||||||
|
var retryableErr *RetryableError
|
||||||
|
return errors.As(err, &retryableErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
// FailureMarkError 表示执行已结束,但失败终态尚未成功写入数据库。
|
||||||
|
// 消费者重投这类消息时只能补写失败终态,不能再次执行具体业务。
|
||||||
|
type FailureMarkError struct {
|
||||||
|
Reason string
|
||||||
|
Err error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *FailureMarkError) Error() string {
|
||||||
|
return "mark batch failed after executor error: " + e.Reason + ": " + e.Err.Error()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *FailureMarkError) Unwrap() error {
|
||||||
|
return e.Err
|
||||||
|
}
|
||||||
|
|
||||||
|
type Executor interface {
|
||||||
|
Execute(ctx context.Context, batchNo string) error
|
||||||
|
}
|
||||||
|
|
||||||
|
type ProcessingExecutor interface {
|
||||||
|
CanExecuteProcessing(ctx context.Context, main *entity.BatchMain) (bool, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SelfClaimingProcessingExecutor marks executors that perform their own
|
||||||
|
// processing-state CAS before executing a re-entry phase.
|
||||||
|
type SelfClaimingProcessingExecutor interface {
|
||||||
|
ClaimsProcessingReentry() bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// DurableFailureHandler takes ownership before any generic MarkFailed fallback.
|
||||||
|
type DurableFailureHandler interface {
|
||||||
|
ProtectExecutionFailure(ctx context.Context, batchNo string, batchType int, reason string) (bool, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
type ExecutionFailureHandler interface {
|
||||||
|
HandleExecutionFailure(ctx context.Context, batchNo string, batchType int, reason string) error
|
||||||
|
}
|
||||||
|
|
||||||
|
type ProcessingClaimer interface {
|
||||||
|
ReclaimProcessing(ctx context.Context, batchNo string, batchType int, startedAt *gtime.Time) (bool, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
type Dispatcher struct {
|
||||||
|
executors map[int]Executor
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewDispatcher(executor Executor) *Dispatcher {
|
||||||
|
return &Dispatcher{executors: map[int]Executor{
|
||||||
|
Type103: executor,
|
||||||
|
Type104: executor,
|
||||||
|
}}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *Dispatcher) Register(batchType int, executor Executor) {
|
||||||
|
if d == nil || executor == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
d.executors[batchType] = executor
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *Dispatcher) Executor(batchType int) (Executor, bool) {
|
||||||
|
if d == nil {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
executor, ok := d.executors[batchType]
|
||||||
|
return executor, ok && executor != nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *Dispatcher) Has(batchType int) bool {
|
||||||
|
_, ok := d.Executor(batchType)
|
||||||
|
return ok
|
||||||
|
}
|
||||||
@@ -0,0 +1,574 @@
|
|||||||
|
package batch
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"reflect"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gogf/gf/v2/frame/g"
|
||||||
|
"github.com/gogf/gf/v2/os/gtime"
|
||||||
|
|
||||||
|
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/model/entity"
|
||||||
|
)
|
||||||
|
|
||||||
|
type executorStub struct {
|
||||||
|
calls int
|
||||||
|
batchNo string
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *executorStub) Execute(_ context.Context, batchNo string) error {
|
||||||
|
s.calls++
|
||||||
|
s.batchNo = batchNo
|
||||||
|
return s.err
|
||||||
|
}
|
||||||
|
|
||||||
|
type processingExecutorStub struct {
|
||||||
|
executorStub
|
||||||
|
canCalls int
|
||||||
|
main *entity.BatchMain
|
||||||
|
can bool
|
||||||
|
canErr error
|
||||||
|
selfClaim bool
|
||||||
|
}
|
||||||
|
|
||||||
|
type blockingExecutorStub struct {
|
||||||
|
calls int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *blockingExecutorStub) Execute(ctx context.Context, _ string) error {
|
||||||
|
s.calls++
|
||||||
|
<-ctx.Done()
|
||||||
|
return ctx.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *processingExecutorStub) CanExecuteProcessing(_ context.Context, main *entity.BatchMain) (bool, error) {
|
||||||
|
s.canCalls++
|
||||||
|
s.main = main
|
||||||
|
return s.can, s.canErr
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *processingExecutorStub) ClaimsProcessingReentry() bool {
|
||||||
|
return s.selfClaim
|
||||||
|
}
|
||||||
|
|
||||||
|
type repositoryStub struct {
|
||||||
|
mains []*entity.BatchMain
|
||||||
|
findErr error
|
||||||
|
markErr error
|
||||||
|
claimed bool
|
||||||
|
findCalls int
|
||||||
|
markCalls int
|
||||||
|
markBatchNo string
|
||||||
|
markType int
|
||||||
|
failErr error
|
||||||
|
failCalls int
|
||||||
|
failBatchNo string
|
||||||
|
failType int
|
||||||
|
failReason string
|
||||||
|
successErr error
|
||||||
|
successCalls int
|
||||||
|
successBatchNo string
|
||||||
|
successType int
|
||||||
|
successCount int
|
||||||
|
successFails int
|
||||||
|
successReason string
|
||||||
|
reclaimErr error
|
||||||
|
reclaimSet bool
|
||||||
|
reclaimed bool
|
||||||
|
reclaimCalls int
|
||||||
|
reclaimBatchNo string
|
||||||
|
reclaimType int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *repositoryStub) FindMain(context.Context, string) (*entity.BatchMain, error) {
|
||||||
|
s.findCalls++
|
||||||
|
if s.findErr != nil {
|
||||||
|
return nil, s.findErr
|
||||||
|
}
|
||||||
|
if len(s.mains) == 0 {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
index := s.findCalls - 1
|
||||||
|
if index >= len(s.mains) {
|
||||||
|
index = len(s.mains) - 1
|
||||||
|
}
|
||||||
|
return s.mains[index], nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *repositoryStub) MarkProcessing(_ context.Context, batchNo string, batchType int) (bool, error) {
|
||||||
|
s.markCalls++
|
||||||
|
s.markBatchNo = batchNo
|
||||||
|
s.markType = batchType
|
||||||
|
return s.claimed, s.markErr
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *repositoryStub) MarkFailed(_ context.Context, batchNo string, batchType int, reason string) (bool, error) {
|
||||||
|
s.failCalls++
|
||||||
|
s.failBatchNo = batchNo
|
||||||
|
s.failType = batchType
|
||||||
|
s.failReason = reason
|
||||||
|
return s.failErr == nil, s.failErr
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *repositoryStub) MarkSuccess(_ context.Context, batchNo string, batchType, success, fail int, reason string) (bool, error) {
|
||||||
|
s.successCalls++
|
||||||
|
s.successBatchNo = batchNo
|
||||||
|
s.successType = batchType
|
||||||
|
s.successCount = success
|
||||||
|
s.successFails = fail
|
||||||
|
s.successReason = reason
|
||||||
|
return s.successErr == nil, s.successErr
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *repositoryStub) ReclaimProcessing(_ context.Context, batchNo string, batchType int, _ *gtime.Time) (bool, error) {
|
||||||
|
s.reclaimCalls++
|
||||||
|
s.reclaimBatchNo = batchNo
|
||||||
|
s.reclaimType = batchType
|
||||||
|
if !s.reclaimSet {
|
||||||
|
return s.reclaimErr == nil, s.reclaimErr
|
||||||
|
}
|
||||||
|
return s.reclaimed, s.reclaimErr
|
||||||
|
}
|
||||||
|
|
||||||
|
type durableExecutorStub struct {
|
||||||
|
execute func(context.Context) error
|
||||||
|
protectErr error
|
||||||
|
protectCalls int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *durableExecutorStub) Execute(ctx context.Context, _ string) error {
|
||||||
|
if s.execute != nil {
|
||||||
|
return s.execute(ctx)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
func (s *durableExecutorStub) ProtectExecutionFailure(ctx context.Context, _ string, _ int, _ string) (bool, error) {
|
||||||
|
s.protectCalls++
|
||||||
|
if ctx.Err() != nil {
|
||||||
|
return true, ctx.Err()
|
||||||
|
}
|
||||||
|
return true, s.protectErr
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRuntimeDurableOwnsEveryFailurePath(t *testing.T) {
|
||||||
|
for _, kind := range []string{"error", "deadline-error", "deadline-nil", "failure-reason", "retry-exhausted", "protect-db-down"} {
|
||||||
|
t.Run(kind, func(t *testing.T) {
|
||||||
|
repo := &repositoryStub{mains: []*entity.BatchMain{{BatchNo: "DURABLE", BatchType: Type109, BatchAsyncStatus: AsyncStatusPending}}, claimed: true}
|
||||||
|
exe := &durableExecutorStub{}
|
||||||
|
msg := Message{BatchNo: "DURABLE", BatchType: Type109}
|
||||||
|
switch kind {
|
||||||
|
case "error":
|
||||||
|
exe.execute = func(context.Context) error { return errors.New("执行中断") }
|
||||||
|
case "deadline-error":
|
||||||
|
exe.execute = func(ctx context.Context) error { <-ctx.Done(); return ctx.Err() }
|
||||||
|
case "deadline-nil":
|
||||||
|
exe.execute = func(ctx context.Context) error { <-ctx.Done(); return nil }
|
||||||
|
case "failure-reason", "retry-exhausted":
|
||||||
|
msg.FailureReason = "重试耗尽或失败重投"
|
||||||
|
repo.mains[0].BatchAsyncStatus = AsyncStatusProcessing
|
||||||
|
case "protect-db-down":
|
||||||
|
exe.execute = func(context.Context) error { return errors.New("执行中断") }
|
||||||
|
exe.protectErr = errors.New("数据库不可用")
|
||||||
|
}
|
||||||
|
dispatcher := NewDispatcher(&executorStub{})
|
||||||
|
dispatcher.Register(Type109, exe)
|
||||||
|
err := NewRuntime(repo, dispatcher, WithExecutionTimeout(time.Millisecond)).Handle(context.Background(), msg)
|
||||||
|
if kind == "protect-db-down" {
|
||||||
|
var failure *FailureMarkError
|
||||||
|
if !errors.As(err, &failure) {
|
||||||
|
t.Fatalf("保护失败应可重投: %v", err)
|
||||||
|
}
|
||||||
|
} else if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if exe.protectCalls != 1 || repo.failCalls != 0 {
|
||||||
|
t.Fatalf("通用MarkFailed越过durable保护: protect=%d failed=%d", exe.protectCalls, repo.failCalls)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMessageJSONContract(t *testing.T) {
|
||||||
|
body, err := json.Marshal(Message{BatchNo: "BATCH-1", BatchType: Type103})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("marshal message: %v", err)
|
||||||
|
}
|
||||||
|
const expected = `{"batchNo":"BATCH-1","batchType":103}`
|
||||||
|
if string(body) != expected {
|
||||||
|
t.Fatalf("message JSON = %s, want %s", body, expected)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDispatcherSupportsLifecycleTypesAndExplicitRegistration(t *testing.T) {
|
||||||
|
executor := &executorStub{}
|
||||||
|
dispatcher := NewDispatcher(executor)
|
||||||
|
for _, batchType := range []int{Type103, Type104} {
|
||||||
|
actual, ok := dispatcher.Executor(batchType)
|
||||||
|
if !ok || actual != executor {
|
||||||
|
t.Fatalf("batch type %d was not registered with shared executor", batchType)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if _, ok := dispatcher.Executor(102); ok {
|
||||||
|
t.Fatal("unexpected executor for unsupported batch type")
|
||||||
|
}
|
||||||
|
|
||||||
|
inbound := &executorStub{}
|
||||||
|
dispatcher.Register(1, inbound)
|
||||||
|
actual, ok := dispatcher.Executor(1)
|
||||||
|
if !ok || actual != inbound {
|
||||||
|
t.Fatal("registered inbound executor was not returned")
|
||||||
|
}
|
||||||
|
|
||||||
|
dataExecutor := &executorStub{}
|
||||||
|
dispatcher.Register(Type107, dataExecutor)
|
||||||
|
dispatcher.Register(Type109, dataExecutor)
|
||||||
|
dispatcher.Register(Type110, dataExecutor)
|
||||||
|
for _, batchType := range []int{Type107, Type109, Type110} {
|
||||||
|
actual, ok = dispatcher.Executor(batchType)
|
||||||
|
if !ok || actual != dataExecutor {
|
||||||
|
t.Fatalf("batch type %d was not registered with the data service executor", batchType)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRuntimeAcknowledgesInvalidAndUnclaimableMessages(t *testing.T) {
|
||||||
|
g.Log().SetWriter(io.Discard)
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
message Message
|
||||||
|
mains []*entity.BatchMain
|
||||||
|
claimed bool
|
||||||
|
wantFind int
|
||||||
|
wantMarked int
|
||||||
|
}{
|
||||||
|
{name: "empty batch number", message: Message{BatchType: Type103}},
|
||||||
|
{name: "blank batch number", message: Message{BatchNo: " ", BatchType: Type103}},
|
||||||
|
{name: "unsupported type", message: Message{BatchNo: "B-1", BatchType: 102}},
|
||||||
|
{name: "not found", message: Message{BatchNo: "B-1", BatchType: Type103}, wantFind: 1},
|
||||||
|
{name: "type mismatch", message: Message{BatchNo: "B-1", BatchType: Type103}, mains: []*entity.BatchMain{{BatchNo: "B-1", BatchType: Type104, BatchAsyncStatus: AsyncStatusPending}}, wantFind: 1},
|
||||||
|
{name: "processing", message: Message{BatchNo: "B-1", BatchType: Type103}, mains: []*entity.BatchMain{{BatchNo: "B-1", BatchType: Type103, BatchAsyncStatus: AsyncStatusProcessing}}, wantFind: 1},
|
||||||
|
{name: "terminal", message: Message{BatchNo: "B-1", BatchType: Type103}, mains: []*entity.BatchMain{{BatchNo: "B-1", BatchType: Type103, BatchAsyncStatus: 3}}, wantFind: 1},
|
||||||
|
{name: "CAS lost", message: Message{BatchNo: "B-1", BatchType: Type103}, mains: []*entity.BatchMain{{BatchNo: "B-1", BatchType: Type103, BatchAsyncStatus: AsyncStatusPending}, {BatchNo: "B-1", BatchType: Type103, BatchAsyncStatus: AsyncStatusProcessing}}, wantFind: 2, wantMarked: 1},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
repository := &repositoryStub{mains: tt.mains, claimed: tt.claimed}
|
||||||
|
executor := &executorStub{}
|
||||||
|
runtime := NewRuntime(repository, NewDispatcher(executor))
|
||||||
|
if err := runtime.Handle(context.Background(), tt.message); err != nil {
|
||||||
|
t.Fatalf("Handle() error = %v", err)
|
||||||
|
}
|
||||||
|
if repository.findCalls != tt.wantFind || repository.markCalls != tt.wantMarked {
|
||||||
|
t.Fatalf("repository calls: find=%d mark=%d, want find=%d mark=%d", repository.findCalls, repository.markCalls, tt.wantFind, tt.wantMarked)
|
||||||
|
}
|
||||||
|
if executor.calls != 0 {
|
||||||
|
t.Fatalf("executor calls = %d, want 0", executor.calls)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRuntimeExecutesRegisteredDataServiceBatch(t *testing.T) {
|
||||||
|
repository := &repositoryStub{
|
||||||
|
mains: []*entity.BatchMain{{BatchNo: "B-109", BatchType: Type109, BatchAsyncStatus: AsyncStatusPending}},
|
||||||
|
claimed: true,
|
||||||
|
}
|
||||||
|
dataExecutor := &executorStub{}
|
||||||
|
dispatcher := NewDispatcher(&executorStub{})
|
||||||
|
dispatcher.Register(Type109, dataExecutor)
|
||||||
|
dispatcher.Register(Type110, dataExecutor)
|
||||||
|
runtime := NewRuntime(repository, dispatcher)
|
||||||
|
|
||||||
|
if err := runtime.Handle(context.Background(), Message{BatchNo: "B-109", BatchType: Type109}); err != nil {
|
||||||
|
t.Fatalf("Handle() error = %v", err)
|
||||||
|
}
|
||||||
|
if dataExecutor.calls != 1 || dataExecutor.batchNo != "B-109" {
|
||||||
|
t.Fatalf("data executor was not invoked: %+v", dataExecutor)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRuntimeExecutesTrafficCutoffConfigWithDedicatedExecutor(t *testing.T) {
|
||||||
|
repository := &repositoryStub{
|
||||||
|
mains: []*entity.BatchMain{{BatchNo: "B-41", BatchType: Type41, BatchAsyncStatus: AsyncStatusPending}},
|
||||||
|
claimed: true,
|
||||||
|
}
|
||||||
|
dataExecutor := &executorStub{err: errors.New("data executor must not be called")}
|
||||||
|
trafficCutoffConfigExecutor := &executorStub{}
|
||||||
|
dispatcher := NewDispatcher(&executorStub{})
|
||||||
|
dispatcher.Register(Type107, dataExecutor)
|
||||||
|
dispatcher.Register(Type41, trafficCutoffConfigExecutor)
|
||||||
|
runtime := NewRuntime(repository, dispatcher)
|
||||||
|
|
||||||
|
if err := runtime.Handle(context.Background(), Message{BatchNo: "B-41", BatchType: Type41}); err != nil {
|
||||||
|
t.Fatalf("Handle() error = %v", err)
|
||||||
|
}
|
||||||
|
if trafficCutoffConfigExecutor.calls != 1 || trafficCutoffConfigExecutor.batchNo != "B-41" {
|
||||||
|
t.Fatalf("traffic cutoff config executor was not invoked: %+v", trafficCutoffConfigExecutor)
|
||||||
|
}
|
||||||
|
if dataExecutor.calls != 0 {
|
||||||
|
t.Fatalf("data executor calls = %d, want 0", dataExecutor.calls)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRuntimeIgnoresFinalizedTrafficCutoffConfigDuplicateMessage(t *testing.T) {
|
||||||
|
repository := &repositoryStub{
|
||||||
|
mains: []*entity.BatchMain{{BatchNo: "B-41-DONE", BatchType: Type41, BatchAsyncStatus: AsyncStatusSuccess}},
|
||||||
|
}
|
||||||
|
trafficCutoffConfigExecutor := &executorStub{err: errors.New("traffic cutoff config executor must not be called")}
|
||||||
|
dispatcher := NewDispatcher(&executorStub{})
|
||||||
|
dispatcher.Register(Type41, trafficCutoffConfigExecutor)
|
||||||
|
runtime := NewRuntime(repository, dispatcher)
|
||||||
|
|
||||||
|
if err := runtime.Handle(context.Background(), Message{BatchNo: "B-41-DONE", BatchType: Type41}); err != nil {
|
||||||
|
t.Fatalf("Handle() error = %v", err)
|
||||||
|
}
|
||||||
|
if trafficCutoffConfigExecutor.calls != 0 {
|
||||||
|
t.Fatalf("traffic cutoff config executor calls = %d, want 0", trafficCutoffConfigExecutor.calls)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRuntimeMarksExecutorFailureAsFailed(t *testing.T) {
|
||||||
|
expectedErr := errors.New("execute failed")
|
||||||
|
repository := &repositoryStub{
|
||||||
|
mains: []*entity.BatchMain{{BatchNo: "B-104", BatchType: Type104, BatchAsyncStatus: AsyncStatusPending}},
|
||||||
|
claimed: true,
|
||||||
|
}
|
||||||
|
executor := &executorStub{err: expectedErr}
|
||||||
|
runtime := NewRuntime(repository, NewDispatcher(executor))
|
||||||
|
|
||||||
|
if err := runtime.Handle(context.Background(), Message{BatchNo: "B-104", BatchType: Type104}); err != nil {
|
||||||
|
t.Fatalf("Handle() error = %v, want nil", err)
|
||||||
|
}
|
||||||
|
if repository.markCalls != 1 || repository.markBatchNo != "B-104" || repository.markType != Type104 {
|
||||||
|
t.Fatalf("unexpected claim call: %+v", repository)
|
||||||
|
}
|
||||||
|
if executor.calls != 1 || executor.batchNo != "B-104" {
|
||||||
|
t.Fatalf("unexpected executor call: %+v", executor)
|
||||||
|
}
|
||||||
|
if repository.failCalls != 1 || repository.failBatchNo != "B-104" || repository.failType != Type104 || repository.failReason != expectedErr.Error() {
|
||||||
|
t.Fatalf("unexpected failure mark call: %+v", repository)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRuntimeReturnsRetryableExecutorFailureWithoutFinalizing(t *testing.T) {
|
||||||
|
sourceErr := errors.New("source file is temporarily unavailable")
|
||||||
|
repository := &repositoryStub{
|
||||||
|
mains: []*entity.BatchMain{{BatchNo: "INB-RETRY", BatchType: 1, BatchAsyncStatus: AsyncStatusPending}},
|
||||||
|
claimed: true,
|
||||||
|
}
|
||||||
|
executor := &executorStub{err: Retryable(sourceErr)}
|
||||||
|
dispatcher := NewDispatcher(&executorStub{})
|
||||||
|
dispatcher.Register(1, executor)
|
||||||
|
runtime := NewRuntime(repository, dispatcher)
|
||||||
|
|
||||||
|
err := runtime.Handle(context.Background(), Message{BatchNo: "INB-RETRY", BatchType: 1})
|
||||||
|
if !errors.Is(err, sourceErr) || !IsRetryable(err) {
|
||||||
|
t.Fatalf("Handle() error = %v, want retryable wrapper of %v", err, sourceErr)
|
||||||
|
}
|
||||||
|
if repository.failCalls != 0 {
|
||||||
|
t.Fatalf("MarkFailed calls = %d, want 0", repository.failCalls)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRuntimeReclaimsRetryableProcessingBatch(t *testing.T) {
|
||||||
|
repository := &repositoryStub{
|
||||||
|
mains: []*entity.BatchMain{{BatchNo: "INB-RETRY", BatchType: 1, BatchAsyncStatus: AsyncStatusProcessing}},
|
||||||
|
reclaimSet: true,
|
||||||
|
reclaimed: true,
|
||||||
|
}
|
||||||
|
executor := &executorStub{}
|
||||||
|
dispatcher := NewDispatcher(&executorStub{})
|
||||||
|
dispatcher.Register(1, executor)
|
||||||
|
runtime := NewRuntime(repository, dispatcher)
|
||||||
|
|
||||||
|
if err := runtime.Handle(context.Background(), Message{BatchNo: "INB-RETRY", BatchType: 1, Retryable: true}); err != nil {
|
||||||
|
t.Fatalf("Handle() error = %v", err)
|
||||||
|
}
|
||||||
|
if repository.reclaimCalls != 1 || repository.reclaimBatchNo != "INB-RETRY" || repository.reclaimType != 1 {
|
||||||
|
t.Fatalf("unexpected reclaim call: %+v", repository)
|
||||||
|
}
|
||||||
|
if executor.calls != 1 {
|
||||||
|
t.Fatalf("executor calls = %d, want 1", executor.calls)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRuntimeMarksTimeoutAsFailed(t *testing.T) {
|
||||||
|
repository := &repositoryStub{
|
||||||
|
mains: []*entity.BatchMain{{BatchNo: "B-TIMEOUT", BatchType: Type104, BatchAsyncStatus: AsyncStatusPending}},
|
||||||
|
claimed: true,
|
||||||
|
}
|
||||||
|
executor := &blockingExecutorStub{}
|
||||||
|
runtime := NewRuntime(
|
||||||
|
repository,
|
||||||
|
NewDispatcher(executor),
|
||||||
|
WithExecutionTimeout(time.Millisecond),
|
||||||
|
WithFinalizationTimeout(time.Second),
|
||||||
|
)
|
||||||
|
|
||||||
|
if err := runtime.Handle(context.Background(), Message{BatchNo: "B-TIMEOUT", BatchType: Type104}); err != nil {
|
||||||
|
t.Fatalf("Handle() error = %v, want nil", err)
|
||||||
|
}
|
||||||
|
if executor.calls != 1 {
|
||||||
|
t.Fatalf("executor calls = %d, want 1", executor.calls)
|
||||||
|
}
|
||||||
|
if repository.failCalls != 1 || !strings.Contains(repository.failReason, "timed out after 1ms") {
|
||||||
|
t.Fatalf("unexpected timeout failure mark: %+v", repository)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRuntimeReturnsFailureMarkErrors(t *testing.T) {
|
||||||
|
executeErr := errors.New("execute failed")
|
||||||
|
markErr := errors.New("mark failed")
|
||||||
|
repository := &repositoryStub{
|
||||||
|
mains: []*entity.BatchMain{{BatchNo: "B-104", BatchType: Type104, BatchAsyncStatus: AsyncStatusPending}},
|
||||||
|
claimed: true,
|
||||||
|
failErr: markErr,
|
||||||
|
}
|
||||||
|
runtime := NewRuntime(repository, NewDispatcher(&executorStub{err: executeErr}))
|
||||||
|
if err := runtime.Handle(context.Background(), Message{BatchNo: "B-104", BatchType: Type104}); !errors.Is(err, markErr) {
|
||||||
|
t.Fatalf("Handle() error = %v, want %v", err, markErr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRuntimeCompensatesFailureMarkWithoutReexecutingBatch(t *testing.T) {
|
||||||
|
markErr := errors.New("mark failed")
|
||||||
|
repository := &repositoryStub{
|
||||||
|
mains: []*entity.BatchMain{{BatchNo: "B-RETRY", BatchType: Type104, BatchAsyncStatus: AsyncStatusProcessing}},
|
||||||
|
failErr: markErr,
|
||||||
|
}
|
||||||
|
executor := &executorStub{err: errors.New("must not execute again")}
|
||||||
|
runtime := NewRuntime(repository, NewDispatcher(executor))
|
||||||
|
|
||||||
|
err := runtime.Handle(context.Background(), Message{
|
||||||
|
BatchNo: "B-RETRY",
|
||||||
|
BatchType: Type104,
|
||||||
|
FailureReason: "original executor failure",
|
||||||
|
})
|
||||||
|
if !errors.Is(err, markErr) {
|
||||||
|
t.Fatalf("Handle() error = %v, want %v", err, markErr)
|
||||||
|
}
|
||||||
|
if executor.calls != 0 {
|
||||||
|
t.Fatalf("executor calls = %d, want 0 for failure finalization retry", executor.calls)
|
||||||
|
}
|
||||||
|
if repository.markCalls != 0 {
|
||||||
|
t.Fatalf("MarkProcessing calls = %d, want 0 for failure finalization retry", repository.markCalls)
|
||||||
|
}
|
||||||
|
if repository.failCalls != 1 || repository.failReason != "original executor failure" {
|
||||||
|
t.Fatalf("unexpected failure finalization call: %+v", repository)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRuntimeAllowsSelfClaimingProcessingReEntryWithoutRefreshingStartTime(t *testing.T) {
|
||||||
|
repository := &repositoryStub{
|
||||||
|
mains: []*entity.BatchMain{{BatchNo: "RST-1", BatchType: Type105, BatchAsyncStatus: AsyncStatusProcessing}},
|
||||||
|
}
|
||||||
|
executor := &processingExecutorStub{can: true, selfClaim: true}
|
||||||
|
dispatcher := NewDispatcher(&executorStub{})
|
||||||
|
dispatcher.Register(Type105, executor)
|
||||||
|
runtime := NewRuntime(repository, dispatcher)
|
||||||
|
|
||||||
|
if err := runtime.Handle(context.Background(), Message{BatchNo: "RST-1", BatchType: Type105}); err != nil {
|
||||||
|
t.Fatalf("Handle() error = %v", err)
|
||||||
|
}
|
||||||
|
if executor.canCalls != 1 || executor.main == nil || executor.main.BatchNo != "RST-1" {
|
||||||
|
t.Fatalf("processing check not called with main: %+v", executor)
|
||||||
|
}
|
||||||
|
if repository.markCalls != 0 {
|
||||||
|
t.Fatalf("MarkProcessing calls = %d, want 0", repository.markCalls)
|
||||||
|
}
|
||||||
|
if repository.reclaimCalls != 0 {
|
||||||
|
t.Fatalf("ReclaimProcessing calls = %d, want 0 for self-claiming executor", repository.reclaimCalls)
|
||||||
|
}
|
||||||
|
if executor.calls != 1 || executor.batchNo != "RST-1" {
|
||||||
|
t.Fatalf("executor calls = %+v", executor.executorStub)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRuntimeMarksProcessingReEntryFailureAsFailed(t *testing.T) {
|
||||||
|
expectedErr := errors.New("resume failed")
|
||||||
|
repository := &repositoryStub{
|
||||||
|
mains: []*entity.BatchMain{{BatchNo: "RST-1", BatchType: Type105, BatchAsyncStatus: AsyncStatusProcessing}},
|
||||||
|
}
|
||||||
|
executor := &processingExecutorStub{can: true}
|
||||||
|
executor.err = expectedErr
|
||||||
|
dispatcher := NewDispatcher(&executorStub{})
|
||||||
|
dispatcher.Register(Type105, executor)
|
||||||
|
runtime := NewRuntime(repository, dispatcher)
|
||||||
|
|
||||||
|
if err := runtime.Handle(context.Background(), Message{BatchNo: "RST-1", BatchType: Type105}); err != nil {
|
||||||
|
t.Fatalf("Handle() error = %v, want nil", err)
|
||||||
|
}
|
||||||
|
if repository.markCalls != 0 {
|
||||||
|
t.Fatalf("MarkProcessing calls = %d, want 0", repository.markCalls)
|
||||||
|
}
|
||||||
|
if repository.failCalls != 1 || repository.failBatchNo != "RST-1" || repository.failType != Type105 || repository.failReason != expectedErr.Error() {
|
||||||
|
t.Fatalf("unexpected failure mark call: %+v", repository)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRuntimeIgnoresProcessingWhenOptInExecutorDeclines(t *testing.T) {
|
||||||
|
repository := &repositoryStub{
|
||||||
|
mains: []*entity.BatchMain{{BatchNo: "RST-2", BatchType: Type105, BatchAsyncStatus: AsyncStatusProcessing}},
|
||||||
|
}
|
||||||
|
executor := &processingExecutorStub{can: false}
|
||||||
|
dispatcher := NewDispatcher(&executorStub{})
|
||||||
|
dispatcher.Register(Type105, executor)
|
||||||
|
runtime := NewRuntime(repository, dispatcher)
|
||||||
|
|
||||||
|
if err := runtime.Handle(context.Background(), Message{BatchNo: "RST-2", BatchType: Type105}); err != nil {
|
||||||
|
t.Fatalf("Handle() error = %v", err)
|
||||||
|
}
|
||||||
|
if executor.canCalls != 1 {
|
||||||
|
t.Fatalf("processing check calls = %d, want 1", executor.canCalls)
|
||||||
|
}
|
||||||
|
if executor.calls != 0 {
|
||||||
|
t.Fatalf("executor calls = %d, want 0", executor.calls)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRuntimeReturnsDatabaseErrors(t *testing.T) {
|
||||||
|
findErr := errors.New("find failed")
|
||||||
|
runtime := NewRuntime(&repositoryStub{findErr: findErr}, NewDispatcher(&executorStub{}))
|
||||||
|
if err := runtime.Handle(context.Background(), Message{BatchNo: "B-1", BatchType: Type103}); !errors.Is(err, findErr) {
|
||||||
|
t.Fatalf("find error = %v, want %v", err, findErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
markErr := errors.New("mark failed")
|
||||||
|
runtime = NewRuntime(&repositoryStub{
|
||||||
|
mains: []*entity.BatchMain{{BatchNo: "B-1", BatchType: Type103, BatchAsyncStatus: AsyncStatusPending}},
|
||||||
|
markErr: markErr,
|
||||||
|
}, NewDispatcher(&executorStub{}))
|
||||||
|
if err := runtime.Handle(context.Background(), Message{BatchNo: "B-1", BatchType: Type103}); !errors.Is(err, markErr) {
|
||||||
|
t.Fatalf("mark error = %v, want %v", err, markErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
checkErr := errors.New("check failed")
|
||||||
|
processing := &processingExecutorStub{canErr: checkErr}
|
||||||
|
dispatcher := NewDispatcher(&executorStub{})
|
||||||
|
dispatcher.Register(Type105, processing)
|
||||||
|
runtime = NewRuntime(&repositoryStub{
|
||||||
|
mains: []*entity.BatchMain{{BatchNo: "RST-3", BatchType: Type105, BatchAsyncStatus: AsyncStatusProcessing}},
|
||||||
|
}, dispatcher)
|
||||||
|
if err := runtime.Handle(context.Background(), Message{BatchNo: "RST-3", BatchType: Type105}); !errors.Is(err, checkErr) {
|
||||||
|
t.Fatalf("processing check error = %v, want %v", err, checkErr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMessageHasOnlyContractFields(t *testing.T) {
|
||||||
|
typeOfMessage := reflect.TypeOf(Message{})
|
||||||
|
if typeOfMessage.NumField() != 4 {
|
||||||
|
t.Fatalf("Message has %d fields, want 4", typeOfMessage.NumField())
|
||||||
|
}
|
||||||
|
if field, ok := typeOfMessage.FieldByName("FailureReason"); !ok || field.Tag.Get("json") != "failureReason,omitempty" {
|
||||||
|
t.Fatalf("FailureReason JSON contract is missing or incorrect")
|
||||||
|
}
|
||||||
|
if field, ok := typeOfMessage.FieldByName("Retryable"); !ok || field.Tag.Get("json") != "retryable,omitempty" {
|
||||||
|
t.Fatalf("Retryable JSON contract is missing or incorrect")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,207 @@
|
|||||||
|
package batch
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gogf/gf/v2/errors/gerror"
|
||||||
|
"github.com/gogf/gf/v2/frame/g"
|
||||||
|
|
||||||
|
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/library/batchfields"
|
||||||
|
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/model/entity"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Repository interface {
|
||||||
|
FindMain(ctx context.Context, batchNo string) (*entity.BatchMain, error)
|
||||||
|
MarkProcessing(ctx context.Context, batchNo string, batchType int) (bool, error)
|
||||||
|
MarkFailed(ctx context.Context, batchNo string, batchType int, reason string) (bool, error)
|
||||||
|
MarkSuccess(ctx context.Context, batchNo string, batchType int, success, fail int, reason string) (bool, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
type Runtime struct {
|
||||||
|
repository Repository
|
||||||
|
dispatcher *Dispatcher
|
||||||
|
executionTimeout time.Duration
|
||||||
|
finalizationTimeout time.Duration
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewRuntime(repository Repository, dispatcher *Dispatcher, options ...RuntimeOption) *Runtime {
|
||||||
|
runtime := &Runtime{
|
||||||
|
repository: repository,
|
||||||
|
dispatcher: dispatcher,
|
||||||
|
executionTimeout: 0,
|
||||||
|
finalizationTimeout: 10 * time.Second,
|
||||||
|
}
|
||||||
|
for _, option := range options {
|
||||||
|
option(runtime)
|
||||||
|
}
|
||||||
|
return runtime
|
||||||
|
}
|
||||||
|
|
||||||
|
type RuntimeOption func(*Runtime)
|
||||||
|
|
||||||
|
func WithExecutionTimeout(timeout time.Duration) RuntimeOption {
|
||||||
|
return func(runtime *Runtime) {
|
||||||
|
if timeout >= 0 {
|
||||||
|
runtime.executionTimeout = timeout
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func WithFinalizationTimeout(timeout time.Duration) RuntimeOption {
|
||||||
|
return func(runtime *Runtime) {
|
||||||
|
if timeout > 0 {
|
||||||
|
runtime.finalizationTimeout = timeout
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Runtime) Handle(ctx context.Context, message Message) error {
|
||||||
|
if strings.TrimSpace(message.BatchNo) == "" {
|
||||||
|
g.Log().Warning(ctx, "batch message dropped, empty batchNo")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
executor, ok := r.dispatcher.Executor(message.BatchType)
|
||||||
|
if !ok {
|
||||||
|
g.Log().Warningf(ctx, "batch message dropped, unsupported batchType:%d, batchNo:%s", message.BatchType, message.BatchNo)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
main, err := r.repository.FindMain(ctx, message.BatchNo)
|
||||||
|
if err != nil {
|
||||||
|
return gerror.Wrap(err, "load batch main failed")
|
||||||
|
}
|
||||||
|
if main == nil {
|
||||||
|
g.Log().Warningf(ctx, "batch message ignored, batch not found, batchNo:%s", message.BatchNo)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if main.BatchType != message.BatchType {
|
||||||
|
g.Log().Warningf(ctx, "batch message ignored, type mismatch, batchNo:%s, messageType:%d, storedType:%d", message.BatchNo, message.BatchType, main.BatchType)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(message.FailureReason) != "" {
|
||||||
|
return r.markExecutionFailed(ctx, message, executor, message.FailureReason)
|
||||||
|
}
|
||||||
|
if main.BatchAsyncStatus != AsyncStatusPending {
|
||||||
|
if main.BatchAsyncStatus == AsyncStatusProcessing {
|
||||||
|
if processingExecutor, ok := executor.(ProcessingExecutor); ok {
|
||||||
|
canExecute, checkErr := processingExecutor.CanExecuteProcessing(ctx, main)
|
||||||
|
if checkErr != nil {
|
||||||
|
return gerror.Wrap(checkErr, "check processing batch re-entry failed")
|
||||||
|
}
|
||||||
|
if canExecute {
|
||||||
|
if selfClaimer, selfClaims := executor.(SelfClaimingProcessingExecutor); selfClaims && selfClaimer.ClaimsProcessingReentry() {
|
||||||
|
return r.execute(ctx, message, executor)
|
||||||
|
}
|
||||||
|
return r.reclaimAndExecute(ctx, message, executor, main)
|
||||||
|
}
|
||||||
|
} else if message.Retryable {
|
||||||
|
return r.reclaimAndExecute(ctx, message, executor, main)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
logUnclaimable(ctx, main)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
claimed, err := r.repository.MarkProcessing(ctx, message.BatchNo, message.BatchType)
|
||||||
|
if err != nil {
|
||||||
|
return gerror.Wrap(err, "mark batch processing failed")
|
||||||
|
}
|
||||||
|
if !claimed {
|
||||||
|
current, findErr := r.repository.FindMain(ctx, message.BatchNo)
|
||||||
|
if findErr != nil {
|
||||||
|
return gerror.Wrap(findErr, "reload batch main after CAS failed")
|
||||||
|
}
|
||||||
|
if current == nil {
|
||||||
|
g.Log().Warningf(ctx, "batch CAS lost, batch not found, batchNo:%s", message.BatchNo)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if current.BatchType != message.BatchType {
|
||||||
|
g.Log().Warningf(ctx, "batch CAS lost, type mismatch, batchNo:%s, messageType:%d, storedType:%d", message.BatchNo, message.BatchType, current.BatchType)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
logUnclaimable(ctx, current)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
g.Log().Infof(ctx, "batch claimed, batchNo:%s, batchType:%d", message.BatchNo, message.BatchType)
|
||||||
|
return r.execute(ctx, message, executor)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Runtime) reclaimAndExecute(ctx context.Context, message Message, executor Executor, main *entity.BatchMain) error {
|
||||||
|
if claimer, claimSupported := r.repository.(ProcessingClaimer); claimSupported {
|
||||||
|
reclaimed, claimErr := claimer.ReclaimProcessing(ctx, message.BatchNo, message.BatchType, main.StartTime)
|
||||||
|
if claimErr != nil {
|
||||||
|
return gerror.Wrap(claimErr, "claim stale processing batch failed")
|
||||||
|
}
|
||||||
|
if !reclaimed {
|
||||||
|
g.Log().Infof(ctx, "batch re-entry claim lost, batchNo:%s", message.BatchNo)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return r.execute(ctx, message, executor)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Runtime) execute(ctx context.Context, message Message, executor Executor) error {
|
||||||
|
executionCtx := ctx
|
||||||
|
var cancel context.CancelFunc = func() {}
|
||||||
|
if r.executionTimeout > 0 {
|
||||||
|
executionCtx, cancel = context.WithTimeout(ctx, r.executionTimeout)
|
||||||
|
}
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
if err := executor.Execute(executionCtx, message.BatchNo); err != nil {
|
||||||
|
if IsRetryable(err) {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
reason := err.Error()
|
||||||
|
if r.executionTimeout > 0 && executionCtx.Err() == context.DeadlineExceeded {
|
||||||
|
reason = gerror.Newf("batch execution timed out after %s", r.executionTimeout).Error()
|
||||||
|
}
|
||||||
|
return r.markExecutionFailed(ctx, message, executor, reason)
|
||||||
|
}
|
||||||
|
if r.executionTimeout > 0 && executionCtx.Err() == context.DeadlineExceeded {
|
||||||
|
return r.markExecutionFailed(ctx, message, executor, gerror.Newf("batch execution timed out after %s", r.executionTimeout).Error())
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Runtime) markExecutionFailed(ctx context.Context, message Message, executor Executor, reason string) error {
|
||||||
|
reason = batchfields.FailureReason(reason)
|
||||||
|
finalizationCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), r.finalizationTimeout)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
if handler, ok := executor.(DurableFailureHandler); ok {
|
||||||
|
handled, err := handler.ProtectExecutionFailure(finalizationCtx, message.BatchNo, message.BatchType, reason)
|
||||||
|
if err != nil {
|
||||||
|
return &FailureMarkError{Reason: reason, Err: err}
|
||||||
|
}
|
||||||
|
if handled {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
failed, markErr := r.repository.MarkFailed(finalizationCtx, message.BatchNo, message.BatchType, reason)
|
||||||
|
if markErr != nil {
|
||||||
|
return &FailureMarkError{Reason: reason, Err: markErr}
|
||||||
|
}
|
||||||
|
if handler, ok := executor.(ExecutionFailureHandler); ok {
|
||||||
|
if err := handler.HandleExecutionFailure(finalizationCtx, message.BatchNo, message.BatchType, reason); err != nil {
|
||||||
|
return &FailureMarkError{Reason: reason, Err: err}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !failed {
|
||||||
|
g.Log().Warningf(ctx, "batch execution failed but status was already finalized, batchNo:%s, reason:%s", message.BatchNo, reason)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
g.Log().Errorf(ctx, "batch execution failed and was marked failed, batchNo:%s, reason:%s", message.BatchNo, reason)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func logUnclaimable(ctx context.Context, main *entity.BatchMain) {
|
||||||
|
if main.BatchAsyncStatus == AsyncStatusProcessing {
|
||||||
|
g.Log().Infof(ctx, "batch message ignored, batch already processing, batchNo:%s", main.BatchNo)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
g.Log().Infof(ctx, "batch message ignored, batch is terminal or not pending, batchNo:%s, status:%d", main.BatchNo, main.BatchAsyncStatus)
|
||||||
|
}
|
||||||
@@ -0,0 +1,154 @@
|
|||||||
|
package upstream
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gogf/gf/v2/errors/gerror"
|
||||||
|
"github.com/gogf/gf/v2/frame/g"
|
||||||
|
)
|
||||||
|
|
||||||
|
// apnRequiredSchema 是异步APN提交依赖的表与关键列,对应 20261015 的四份迁移脚本。
|
||||||
|
// 缺表或缺列意味着迁移没有执行完,此时提交必然写不进去,应在发请求前就拒绝。
|
||||||
|
var apnRequiredSchema = map[string][]string{
|
||||||
|
"t_apn_operation": {"operation_id", "chan_key", "request_hash", "response_json", "revision"},
|
||||||
|
"data_operation_parent": {"batch_no", "ready", "finalize_token", "finalize_until"},
|
||||||
|
"data_operation_sub_batch": {"operation_id", "batch_no", "chan_key", "request_data", "status", "poll_count", "lease_token"},
|
||||||
|
"data_operation_sub_detail": {"detail_id", "operation_id", "batch_no", "iccid"},
|
||||||
|
"data_operation_card_claim": {"tenant_id", "iccid", "batch_id", "active_batch_id"},
|
||||||
|
}
|
||||||
|
|
||||||
|
// apnReadinessTTL 是依赖检查结果的缓存时间:迁移完成后最迟一分钟内自动放行,无需重启。
|
||||||
|
const apnReadinessTTL = time.Minute
|
||||||
|
|
||||||
|
// apnReadinessVerifyTimeout 是单次结构校验的独立超时,与调用方上下文解耦。
|
||||||
|
const apnReadinessVerifyTimeout = 5 * time.Second
|
||||||
|
|
||||||
|
type apnReadinessCache struct {
|
||||||
|
ttl time.Duration
|
||||||
|
mu sync.Mutex
|
||||||
|
checkedAt time.Time
|
||||||
|
err error
|
||||||
|
// verify 默认指向 verifyAPNSchema;独立出来是为了让缓存行为可测。
|
||||||
|
verify func(context.Context) error
|
||||||
|
}
|
||||||
|
|
||||||
|
var defaultAPNReadiness = &apnReadinessCache{ttl: apnReadinessTTL, verify: verifyAPNSchema}
|
||||||
|
|
||||||
|
// check 返回异步APN提交的依赖状态,结果按 TTL 缓存,避免每次提交都查库。
|
||||||
|
func (c *apnReadinessCache) check(ctx context.Context) error {
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
if !c.checkedAt.IsZero() && time.Since(c.checkedAt) < c.ttl {
|
||||||
|
return c.err
|
||||||
|
}
|
||||||
|
verify := c.verify
|
||||||
|
if verify == nil {
|
||||||
|
verify = verifyAPNSchema
|
||||||
|
}
|
||||||
|
// 校验使用独立超时,不受调用方取消影响;否则一次取消就会把失败结果缓存给全进程。
|
||||||
|
verifyCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), apnReadinessVerifyTimeout)
|
||||||
|
defer cancel()
|
||||||
|
c.err = verify(verifyCtx)
|
||||||
|
if c.err != nil && (errors.Is(c.err, context.Canceled) || errors.Is(c.err, context.DeadlineExceeded)) {
|
||||||
|
// 只是本次校验没跑完,不代表依赖缺失,不写入缓存。
|
||||||
|
c.checkedAt = time.Time{}
|
||||||
|
return c.err
|
||||||
|
}
|
||||||
|
c.checkedAt = time.Now()
|
||||||
|
return c.err
|
||||||
|
}
|
||||||
|
|
||||||
|
// verifyAPNSchema 直接查询库里的实际表结构,而不是依赖人工开开关确认「迁移已执行」。
|
||||||
|
func verifyAPNSchema(ctx context.Context) (err error) {
|
||||||
|
if !databaseConfigured(ctx) {
|
||||||
|
// 未配置数据库连接(测试桩、纯传输层用例)时跳过;生产 worker 不存在该情况。
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
// g.DB() 在缺配置时会 panic,这里统一转成可读错误,避免拖垮提交链路。
|
||||||
|
defer func() {
|
||||||
|
if recovered := recover(); recovered != nil {
|
||||||
|
err = gerror.Newf("无法获取数据库连接,不能确认异步APN依赖是否就绪:%v", recovered)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
db := g.DB()
|
||||||
|
if db == nil {
|
||||||
|
return gerror.New("无法获取数据库连接,不能确认异步APN依赖是否就绪")
|
||||||
|
}
|
||||||
|
config := db.GetConfig()
|
||||||
|
if strings.EqualFold(config.Type, "sqlite") {
|
||||||
|
// 本地/单元测试使用 SQLite,没有 information_schema,跳过结构检查。
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
names := make([]string, 0, len(apnRequiredSchema))
|
||||||
|
for name := range apnRequiredSchema {
|
||||||
|
names = append(names, name)
|
||||||
|
}
|
||||||
|
sort.Strings(names)
|
||||||
|
placeholders := make([]string, 0, len(names))
|
||||||
|
args := make([]interface{}, 0, len(names))
|
||||||
|
for _, name := range names {
|
||||||
|
placeholders = append(placeholders, "?")
|
||||||
|
args = append(args, name)
|
||||||
|
}
|
||||||
|
records, err := db.GetAll(ctx, fmt.Sprintf(
|
||||||
|
"SELECT TABLE_NAME AS table_name, COLUMN_NAME AS column_name FROM information_schema.columns WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME IN (%s)",
|
||||||
|
strings.Join(placeholders, ",")), args...)
|
||||||
|
if err != nil {
|
||||||
|
return gerror.Wrap(err, "查询异步APN依赖表结构失败")
|
||||||
|
}
|
||||||
|
existing := make(map[string]map[string]bool, len(records))
|
||||||
|
for _, record := range records {
|
||||||
|
table := record["table_name"].String()
|
||||||
|
if existing[table] == nil {
|
||||||
|
existing[table] = make(map[string]bool)
|
||||||
|
}
|
||||||
|
existing[table][strings.ToLower(record["column_name"].String())] = true
|
||||||
|
}
|
||||||
|
return validateAPNSchema(existing)
|
||||||
|
}
|
||||||
|
|
||||||
|
// databaseConfigured 判断当前配置里是否声明了数据库,用于区分测试桩与真实运行环境。
|
||||||
|
func databaseConfigured(ctx context.Context) bool {
|
||||||
|
value := g.Cfg().MustGet(ctx, "database")
|
||||||
|
return value != nil && !value.IsNil() && !value.IsEmpty()
|
||||||
|
}
|
||||||
|
|
||||||
|
// validateAPNSchema 比对必需的表与列,返回可直接据此执行迁移的错误信息。
|
||||||
|
func validateAPNSchema(existing map[string]map[string]bool) error {
|
||||||
|
tables := make([]string, 0, len(apnRequiredSchema))
|
||||||
|
for table := range apnRequiredSchema {
|
||||||
|
tables = append(tables, table)
|
||||||
|
}
|
||||||
|
sort.Strings(tables)
|
||||||
|
var missingTables []string
|
||||||
|
var missingColumns []string
|
||||||
|
for _, table := range tables {
|
||||||
|
columns, ok := existing[table]
|
||||||
|
if !ok {
|
||||||
|
missingTables = append(missingTables, table)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
required := append([]string(nil), apnRequiredSchema[table]...)
|
||||||
|
sort.Strings(required)
|
||||||
|
for _, column := range required {
|
||||||
|
if !columns[column] {
|
||||||
|
missingColumns = append(missingColumns, table+"."+column)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
switch {
|
||||||
|
case len(missingTables) > 0:
|
||||||
|
return gerror.Newf("异步APN依赖未就绪:缺少数据表 %s,请先执行 20261015 迁移脚本",
|
||||||
|
strings.Join(missingTables, "、"))
|
||||||
|
case len(missingColumns) > 0:
|
||||||
|
return gerror.Newf("异步APN依赖未就绪:缺少字段 %s,请先执行 20261015 迁移脚本",
|
||||||
|
strings.Join(missingColumns, "、"))
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
package upstream
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gogf/gf/v2/frame/g"
|
||||||
|
"github.com/gogf/gf/v2/os/gcfg"
|
||||||
|
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
func fullAPNSchema() map[string]map[string]bool {
|
||||||
|
schema := make(map[string]map[string]bool, len(apnRequiredSchema))
|
||||||
|
for table, columns := range apnRequiredSchema {
|
||||||
|
schema[table] = make(map[string]bool, len(columns))
|
||||||
|
for _, column := range columns {
|
||||||
|
schema[table][column] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return schema
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestValidateAPNSchemaRequiresAllTablesAndColumns 固化依赖判据:
|
||||||
|
// 缺表、缺列都必须给出可据此执行迁移的明确原因,而不是笼统的「未就绪」。
|
||||||
|
func TestValidateAPNSchemaRequiresAllTablesAndColumns(t *testing.T) {
|
||||||
|
if err := validateAPNSchema(fullAPNSchema()); err != nil {
|
||||||
|
t.Fatalf("完整结构不应报错:%v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
missingTable := fullAPNSchema()
|
||||||
|
delete(missingTable, "data_operation_card_claim")
|
||||||
|
err := validateAPNSchema(missingTable)
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "data_operation_card_claim") || !strings.Contains(err.Error(), "缺少数据表") {
|
||||||
|
t.Fatalf("缺表未被识别:%v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
missingColumn := fullAPNSchema()
|
||||||
|
delete(missingColumn["t_apn_operation"], "revision")
|
||||||
|
err = validateAPNSchema(missingColumn)
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "t_apn_operation.revision") || !strings.Contains(err.Error(), "缺少字段") {
|
||||||
|
t.Fatalf("缺列未被识别:%v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 表全部缺失时优先报缺表,避免一次报几十个字段干扰排查。
|
||||||
|
err = validateAPNSchema(map[string]map[string]bool{})
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "缺少数据表") {
|
||||||
|
t.Fatalf("空库应优先报缺表:%v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestAsyncApnSubmitBlockedWhenDependencyNotReady 验证依赖未就绪时在发请求前就拒绝,
|
||||||
|
// 并把错误标记为「未发送」,避免上游误判为已办理。
|
||||||
|
func TestAsyncApnSubmitBlockedWhenDependencyNotReady(t *testing.T) {
|
||||||
|
oldAdapter := g.Cfg().GetAdapter()
|
||||||
|
defer g.Cfg().SetAdapter(oldAdapter)
|
||||||
|
oldReady := apnDependencyReady
|
||||||
|
defer func() { apnDependencyReady = oldReady }()
|
||||||
|
apnDependencyReady = func(ctx context.Context) error {
|
||||||
|
return errors.New("缺少数据表 data_operation_card_claim")
|
||||||
|
}
|
||||||
|
|
||||||
|
probes, submits := 0, 0
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.URL.Path == "/sim/query/sim-apn-capability" {
|
||||||
|
probes++
|
||||||
|
return
|
||||||
|
}
|
||||||
|
submits++
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
adapter, err := gcfg.NewAdapterContent(fmt.Sprintf(`{"upstream":{"proxy":{"address":%q,"timeout":"1s"}}}`, strings.TrimPrefix(server.URL, "http://")))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
g.Cfg().SetAdapter(adapter)
|
||||||
|
|
||||||
|
_, err = New().SubmitSimApnFunction(context.Background(), &model.SimCardApnChangeInput{
|
||||||
|
SimKeys: []*model.SimKey{{ChanKey: "test", ICCID: "fake-card"}},
|
||||||
|
ReqData: model.SimCardApnChangeInputItem{OperationID: "fake-operation"},
|
||||||
|
})
|
||||||
|
var notSent interface{ APNRequestNotSent() bool }
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "依赖未就绪") {
|
||||||
|
t.Fatalf("依赖未就绪应拒绝提交:%v", err)
|
||||||
|
}
|
||||||
|
if !errors.As(err, ¬Sent) || !notSent.APNRequestNotSent() {
|
||||||
|
t.Fatalf("依赖拒绝必须标记为未发送:%v", err)
|
||||||
|
}
|
||||||
|
if probes != 0 || submits != 0 {
|
||||||
|
t.Fatalf("依赖未就绪时不应发出任何请求:probes=%d submits=%d", probes, submits)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestReadinessCacheDoesNotCacheCanceledVerification 覆盖审查发现的场景:
|
||||||
|
// 调用方取消导致的校验失败不能进进程级缓存,否则会误挡后续所有提交。
|
||||||
|
func TestReadinessCacheDoesNotCacheCanceledVerification(t *testing.T) {
|
||||||
|
cache := &apnReadinessCache{ttl: time.Minute}
|
||||||
|
calls := 0
|
||||||
|
cache.verify = func(ctx context.Context) error {
|
||||||
|
calls++
|
||||||
|
if calls == 1 {
|
||||||
|
return context.Canceled
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if err := cache.check(context.Background()); !errors.Is(err, context.Canceled) {
|
||||||
|
t.Fatalf("首次应返回取消错误:%v", err)
|
||||||
|
}
|
||||||
|
if err := cache.check(context.Background()); err != nil {
|
||||||
|
t.Fatalf("取消不应被缓存,第二次应重新校验并放行:%v", err)
|
||||||
|
}
|
||||||
|
if calls != 2 {
|
||||||
|
t.Fatalf("取消后应重新校验,实际调用%d次", calls)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestReadinessCacheReusesResult 保证依赖检查不会每次提交都查库,同时迁移完成后能自动恢复。
|
||||||
|
func TestReadinessCacheReusesResult(t *testing.T) {
|
||||||
|
cache := &apnReadinessCache{ttl: time.Minute}
|
||||||
|
calls := 0
|
||||||
|
cache.verify = func(ctx context.Context) error {
|
||||||
|
calls++
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
for i := 0; i < 3; i++ {
|
||||||
|
if err := cache.check(context.Background()); err != nil {
|
||||||
|
t.Fatalf("第%d次检查报错:%v", i+1, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if calls != 1 {
|
||||||
|
t.Fatalf("缓存期内应只查一次库,实际%d次", calls)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,393 @@
|
|||||||
|
package upstream
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gogf/gf/v2/errors/gerror"
|
||||||
|
"github.com/gogf/gf/v2/frame/g"
|
||||||
|
"github.com/gogf/gf/v2/net/gclient"
|
||||||
|
|
||||||
|
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
const inboundBasicInfoClientNameEnv = "UPSTREAM_INBOUND_BASIC_INFO_CLIENT_NAME"
|
||||||
|
const upstreamProxyMaxTimeoutConfigKey = "upstream.proxy.maxTimeout"
|
||||||
|
|
||||||
|
// apnAsyncProtocolVersionConfigKey 是异步APN提交的兼容开关。
|
||||||
|
// 默认值改为开启:常规发布不再需要人工逐环境开开关,兼容性由提交前的协议能力探测兜底。
|
||||||
|
// 只有需要临时回退到旧协议时,才显式配为 0。
|
||||||
|
const apnAsyncProtocolVersionConfigKey = "upstream.proxy.apnAsyncProtocolVersion"
|
||||||
|
|
||||||
|
// defaultApnAsyncProtocolVersion 默认开启异步APN提交。
|
||||||
|
const defaultApnAsyncProtocolVersion = 1
|
||||||
|
|
||||||
|
// defaultUpstreamProxyMaxTimeout 是单次批量转发的兜底上限。
|
||||||
|
// 必须大于 upstream-proxy 的 gateway.batchTimeout(默认 20 分钟),
|
||||||
|
// 让超时由更靠近通道的一层先判定,避免 worker 提前放弃而通道仍在办理,造成结果未知。
|
||||||
|
const defaultUpstreamProxyMaxTimeout = 25 * time.Minute
|
||||||
|
|
||||||
|
// defaultUpstreamProxyTimeout 是未配置单卡预算时的默认值。
|
||||||
|
const defaultUpstreamProxyTimeout = 10 * time.Second
|
||||||
|
|
||||||
|
type Client struct {
|
||||||
|
currentUsageClientName string
|
||||||
|
currentUsageTimeout time.Duration
|
||||||
|
inboundBasicInfoClientName string
|
||||||
|
imeiClientName string
|
||||||
|
imeiTimeout time.Duration
|
||||||
|
}
|
||||||
|
|
||||||
|
type response[T any] struct {
|
||||||
|
Code int `json:"code"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
Data []model.SimBaseRes[T] `json:"data"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func New() *Client {
|
||||||
|
return &Client{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) QuerySimBasicInfo(ctx context.Context, input *model.SimBasicInfoInput) ([]model.SimBaseRes[model.SimBasicInfoItem], error) {
|
||||||
|
address, err := c.inboundBasicInfoAddress(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return requestAllWithAddress[model.SimBasicInfoItem](ctx, "/sim/query/sim-basic-info", input, len(input.SimKeys), address)
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewWithCurrentUsageClient keeps the full-sync current-usage call independent
|
||||||
|
// from the configured upstream.proxy endpoint.
|
||||||
|
func NewWithCurrentUsageClient(address string, timeout ...time.Duration) *Client {
|
||||||
|
var currentUsageTimeout time.Duration
|
||||||
|
if len(timeout) > 0 {
|
||||||
|
currentUsageTimeout = timeout[0]
|
||||||
|
}
|
||||||
|
return &Client{currentUsageClientName: address, currentUsageTimeout: currentUsageTimeout}
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewWithInboundBasicInfoClient(address string) *Client {
|
||||||
|
return &Client{inboundBasicInfoClientName: address}
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewWithImeiClient pins the IMEI full-sync path to an explicit chan endpoint
|
||||||
|
// and timeout. An empty address falls back to the shared upstream.proxy
|
||||||
|
// configuration, which is only intended for local runs.
|
||||||
|
func NewWithImeiClient(address string, timeout ...time.Duration) *Client {
|
||||||
|
var imeiTimeout time.Duration
|
||||||
|
if len(timeout) > 0 {
|
||||||
|
imTimeout := timeout[0]
|
||||||
|
if imTimeout > 0 {
|
||||||
|
imeiTimeout = imTimeout
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return &Client{imeiClientName: address, imeiTimeout: imeiTimeout}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) inboundBasicInfoAddress(ctx context.Context) (string, error) {
|
||||||
|
if strings.TrimSpace(c.inboundBasicInfoClientName) != "" {
|
||||||
|
return strings.TrimSpace(c.inboundBasicInfoClientName), nil
|
||||||
|
}
|
||||||
|
if address := strings.TrimSpace(os.Getenv(inboundBasicInfoClientNameEnv)); address != "" {
|
||||||
|
return address, nil
|
||||||
|
}
|
||||||
|
config, ok, err := optionalUpstreamConfig(ctx, "upstream.gateway")
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if !ok || strings.TrimSpace(config.Address) == "" {
|
||||||
|
return "", gerror.Newf("upstream gateway address is empty, configure upstream.gateway.address or %s", inboundBasicInfoClientNameEnv)
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(config.Address), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// QuerySimDataUsage calls Chan's current-month real-time usage endpoint. The
|
||||||
|
// response keeps one item per requested card so the caller can classify
|
||||||
|
// per-card errors without reissuing successful cards.
|
||||||
|
func (c *Client) QuerySimDataUsage(ctx context.Context, input *model.SimDataUsageInput) ([]model.SimBaseRes[model.SimDataUsageItem], error) {
|
||||||
|
return requestAllWithAddressTimeout[model.SimDataUsageItem](ctx, "/sim/query/sim-datausage", input, len(input.SimKeys), c.currentUsageClientName, c.currentUsageTimeout)
|
||||||
|
}
|
||||||
|
|
||||||
|
// QuerySimCardStatus queries the carrier status for the supplied cards. The
|
||||||
|
// status queue currently sends one card per request, while retaining the
|
||||||
|
// response shape needed for future batch support.
|
||||||
|
func (c *Client) QuerySimCardStatus(ctx context.Context, input *model.SimCardStatusInput) ([]model.SimBaseRes[model.SimCardStatusItem], error) {
|
||||||
|
return requestAll[model.SimCardStatusItem](ctx, "/sim/query/sim-status", input, len(input.SimKeys))
|
||||||
|
}
|
||||||
|
|
||||||
|
// QuerySimImei queries the terminal IMEI for the supplied cards. The IMEI queue
|
||||||
|
// sends one card per request.
|
||||||
|
//
|
||||||
|
// It deliberately uses requestAll rather than request: a per-item carrier
|
||||||
|
// failure (unsupported template, missing value) must reach the handler as an
|
||||||
|
// item with a non-zero Code, not be silently dropped from the slice.
|
||||||
|
func (c *Client) QuerySimImei(ctx context.Context, input *model.SimImeiInput) ([]model.SimBaseRes[model.SimImeiItem], error) {
|
||||||
|
return requestAllWithAddressTimeout[model.SimImeiItem](ctx, "/sim/query/sim-imei", input, len(input.SimKeys), c.imeiClientName, c.imeiTimeout)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) QuerySimDatausageDaily(ctx context.Context, input *model.SimDataUsageDailyInput) ([]model.SimBaseRes[model.SimDataUsageDailyOutputItem], error) {
|
||||||
|
return requestAll[model.SimDataUsageDailyOutputItem](ctx, "/sim/query/sim-datausage-daily", input, len(input.SimKeys))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) QuerySimDatausageMonthly(ctx context.Context, input *model.SimDataUsageMonthlyInput) ([]model.SimBaseRes[model.SimDataUsageMonthlyOutputItem], error) {
|
||||||
|
return requestAll[model.SimDataUsageMonthlyOutputItem](ctx, "/sim/query/sim-datausage-monthly", input, len(input.SimKeys))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ChangeSimStatus returns one result for every upstream item, including failed items.
|
||||||
|
// Lifecycle batch execution needs those failures to be persisted to the matching detail.
|
||||||
|
func (c *Client) ChangeSimStatus(ctx context.Context, input *model.SimCardStatusChangeInput) ([]model.SimBaseRes[model.SimCardStatusChangeOutputItem], error) {
|
||||||
|
return requestAll[model.SimCardStatusChangeOutputItem](ctx, "/sim/change/sim-status", input, len(input.SimKeys))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ChangeSimApnFunction returns one result for every upstream item, including failed items.
|
||||||
|
// Data service batch execution needs those failures to be persisted to the matching detail.
|
||||||
|
func (c *Client) ChangeSimApnFunction(ctx context.Context, input *model.SimCardApnChangeInput) ([]model.SimBaseRes[model.SimCardApnChangeOutputItem], error) {
|
||||||
|
return requestAll[model.SimCardApnChangeOutputItem](ctx, "/sim/change/sim-apn", input, len(input.SimKeys))
|
||||||
|
}
|
||||||
|
|
||||||
|
// apnRequestNotSentError 仅描述任何APN写请求之前的能力拒绝。
|
||||||
|
// 提交开始后的通信错误绝不能使用此类型。
|
||||||
|
type apnRequestNotSentError struct{ cause error }
|
||||||
|
|
||||||
|
func (e *apnRequestNotSentError) Error() string { return e.cause.Error() }
|
||||||
|
func (e *apnRequestNotSentError) Unwrap() error { return e.cause }
|
||||||
|
func (e *apnRequestNotSentError) APNRequestNotSent() bool { return true }
|
||||||
|
|
||||||
|
func (c *Client) CheckSimApnAsyncCapability(ctx context.Context, input *model.SimCardApnChangeInput) (err error) {
|
||||||
|
defer func() {
|
||||||
|
if err != nil {
|
||||||
|
err = &apnRequestNotSentError{cause: err}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
if !apnAsyncSubmitEnabled(ctx) {
|
||||||
|
return gerror.New("异步APN提交已被显式关闭(upstream.proxy.apnAsyncProtocolVersion=0);清除该配置或置为1后重试")
|
||||||
|
}
|
||||||
|
if err := apnDependencyReady(ctx); err != nil {
|
||||||
|
return gerror.Wrap(err, "异步APN依赖未就绪,未发送办理请求")
|
||||||
|
}
|
||||||
|
if input == nil || strings.TrimSpace(input.ReqData.OperationID) == "" {
|
||||||
|
return gerror.New("异步APN提交能力探测缺少operationId")
|
||||||
|
}
|
||||||
|
return c.checkSimApnProtocol(ctx, input)
|
||||||
|
}
|
||||||
|
|
||||||
|
// apnDependencyReady 检查本地真实依赖(迁移表结构);测试可替换以便固定结论。
|
||||||
|
var apnDependencyReady = func(ctx context.Context) error {
|
||||||
|
return defaultAPNReadiness.check(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
// apnAsyncSubmitEnabled 判断应急总闸是否被显式关闭。
|
||||||
|
// 默认放行:是否可提交由程序自行检查真实依赖决定——
|
||||||
|
// - 本地依赖:apn_readiness 核对迁移表结构是否就绪;
|
||||||
|
// - 远端兼容:每次提交前的协议能力探测,旧版 chan/proxy 会明确拒绝而不是按同步语义执行。
|
||||||
|
// 因此常规发布不再需要人工逐环境开开关;仅回退到旧协议时把配置显式设为 0。
|
||||||
|
func apnAsyncSubmitEnabled(ctx context.Context) bool {
|
||||||
|
return g.Cfg().MustGet(ctx, apnAsyncProtocolVersionConfigKey, defaultApnAsyncProtocolVersion).Int() == 1
|
||||||
|
}
|
||||||
|
|
||||||
|
// 仅验证远端协议:已有受理映射的只读补查不受新增提交门禁控制。
|
||||||
|
// 旧版代理/通道缺少能力接口时必须拒绝,不能把查询当作普通办理转发。
|
||||||
|
func (c *Client) checkSimApnProtocol(ctx context.Context, input *model.SimCardApnChangeInput) error {
|
||||||
|
if len(input.SimKeys) == 0 || len(input.SimKeys) > 100 || (strings.TrimSpace(input.ReqData.OperationID) == "" && strings.TrimSpace(input.ReqData.QueryJobID) == "") {
|
||||||
|
return gerror.New("异步APN能力探测需要operationId和1至100张卡快照")
|
||||||
|
}
|
||||||
|
type capability struct {
|
||||||
|
ProtocolVersion int `json:"protocolVersion"`
|
||||||
|
}
|
||||||
|
probeCtx, cancel := context.WithTimeout(ctx, 10*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
items, err := requestAll[capability](probeCtx, "/sim/query/sim-apn-capability", input, len(input.SimKeys))
|
||||||
|
if err != nil {
|
||||||
|
return gerror.Wrap(err, "异步APN协议能力探测失败,未发送办理或恢复请求")
|
||||||
|
}
|
||||||
|
if len(items) != len(input.SimKeys) {
|
||||||
|
return gerror.New("异步APN协议能力响应卡数不匹配,禁止发送办理请求")
|
||||||
|
}
|
||||||
|
seen := make(map[model.SimKey]bool)
|
||||||
|
for _, item := range items {
|
||||||
|
if item.SimKey == nil || item.Code != 0 || item.ResData.ProtocolVersion != 1 || seen[*item.SimKey] {
|
||||||
|
return gerror.New("通道未就绪或不支持异步APN协议,禁止发送办理请求")
|
||||||
|
}
|
||||||
|
seen[*item.SimKey] = true
|
||||||
|
}
|
||||||
|
for _, key := range input.SimKeys {
|
||||||
|
if key == nil || !seen[*key] {
|
||||||
|
return gerror.New("异步APN协议能力响应卡关联不匹配")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SubmitSimApnFunction 仅提交异步批量任务;受理状态不能按办理成功回写。
|
||||||
|
func (c *Client) SubmitSimApnFunction(ctx context.Context, input *model.SimCardApnChangeInput) ([]model.SimBaseRes[model.SimCardApnChangeOutputItem], error) {
|
||||||
|
if err := c.CheckSimApnAsyncCapability(ctx, input); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
request := *input
|
||||||
|
request.ReqData.SubmitOnly = true
|
||||||
|
request.ReqData.QueryJobID = ""
|
||||||
|
request.ReqData.RecoverOnly = false
|
||||||
|
return c.ChangeSimApnFunction(ctx, &request)
|
||||||
|
}
|
||||||
|
|
||||||
|
// QuerySimApnFunctionResult 根据已持久化的jobId只查一次,不重新提交。
|
||||||
|
func (c *Client) QuerySimApnFunctionResult(ctx context.Context, input *model.SimCardApnChangeInput) ([]model.SimBaseRes[model.SimCardApnChangeOutputItem], error) {
|
||||||
|
if strings.TrimSpace(input.ReqData.QueryJobID) == "" && strings.TrimSpace(input.ReqData.OperationID) == "" {
|
||||||
|
return nil, gerror.New("异步APN结果查询缺少jobId和operationId")
|
||||||
|
}
|
||||||
|
request := *input
|
||||||
|
request.ReqData.QueryJobID = strings.TrimSpace(request.ReqData.QueryJobID)
|
||||||
|
request.ReqData.SubmitOnly = false
|
||||||
|
request.ReqData.RecoverOnly = request.ReqData.QueryJobID == ""
|
||||||
|
if err := c.checkSimApnProtocol(ctx, &request); err != nil {
|
||||||
|
return nil, gerror.Wrap(err, "异步APN只读查询协议未就绪,未发送查询请求")
|
||||||
|
}
|
||||||
|
return c.ChangeSimApnFunction(ctx, &request)
|
||||||
|
}
|
||||||
|
|
||||||
|
func request[T any](ctx context.Context, path string, input any, itemCount int) ([]model.SimBaseRes[T], error) {
|
||||||
|
items, err := requestAll[T](ctx, path, input, itemCount)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
successItems := make([]model.SimBaseRes[T], 0, len(items))
|
||||||
|
for _, item := range items {
|
||||||
|
if item.Code == 0 {
|
||||||
|
successItems = append(successItems, item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return successItems, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func requestAll[T any](ctx context.Context, path string, input any, itemCount int) ([]model.SimBaseRes[T], error) {
|
||||||
|
return requestAllWithAddress[T](ctx, path, input, itemCount, "")
|
||||||
|
}
|
||||||
|
|
||||||
|
func requestAllWithAddress[T any](ctx context.Context, path string, input any, itemCount int, address string) ([]model.SimBaseRes[T], error) {
|
||||||
|
return requestAllWithAddressTimeout[T](ctx, path, input, itemCount, address, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
func requestAllWithAddressTimeout[T any](ctx context.Context, path string, input any, itemCount int, address string, timeoutOverride time.Duration) ([]model.SimBaseRes[T], error) {
|
||||||
|
config, err := upstreamConfig(ctx, "upstream.proxy")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if address == "" {
|
||||||
|
address = config.Address
|
||||||
|
}
|
||||||
|
if address == "" {
|
||||||
|
return nil, gerror.New("upstream proxy address is empty")
|
||||||
|
}
|
||||||
|
if itemCount < 1 {
|
||||||
|
itemCount = 1
|
||||||
|
}
|
||||||
|
|
||||||
|
client := g.Client()
|
||||||
|
if path == "/sim/change/sim-apn" || path == "/sim/query/sim-apn-capability" {
|
||||||
|
client.SetContentType("application/json")
|
||||||
|
}
|
||||||
|
timeout := resolveUpstreamTimeout(path, itemCount, config.Timeout,
|
||||||
|
timeoutOverride,
|
||||||
|
configDuration(ctx, upstreamProxyMaxTimeoutConfigKey, defaultUpstreamProxyMaxTimeout),
|
||||||
|
configDuration(ctx, "upstream.proxy.dataServiceTimeout", 240*time.Second))
|
||||||
|
setClientTimeout(client, timeout)
|
||||||
|
clientResponse, err := client.Post(ctx, fmt.Sprintf("http://%s%s", address, path), input)
|
||||||
|
if err != nil {
|
||||||
|
return nil, gerror.Wrap(err, "request upstream failed")
|
||||||
|
}
|
||||||
|
defer clientResponse.Close()
|
||||||
|
|
||||||
|
return decodeResponse[T](clientResponse.ReadAll())
|
||||||
|
}
|
||||||
|
|
||||||
|
func optionalUpstreamConfig(ctx context.Context, key string) (model.UpstreamChanConfig, bool, error) {
|
||||||
|
configValue, err := g.Cfg().Get(ctx, key)
|
||||||
|
if err != nil {
|
||||||
|
return model.UpstreamChanConfig{}, false, gerror.Wrapf(err, "read upstream configuration failed: %s", key)
|
||||||
|
}
|
||||||
|
if configValue == nil || configValue.IsNil() {
|
||||||
|
return model.UpstreamChanConfig{}, false, nil
|
||||||
|
}
|
||||||
|
config := model.UpstreamChanConfig{}
|
||||||
|
if err = configValue.Scan(&config); err != nil {
|
||||||
|
return model.UpstreamChanConfig{}, false, gerror.Wrapf(err, "parse upstream configuration failed: %s", key)
|
||||||
|
}
|
||||||
|
return config, true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func upstreamConfig(ctx context.Context, key string) (model.UpstreamChanConfig, error) {
|
||||||
|
config, ok, err := optionalUpstreamConfig(ctx, key)
|
||||||
|
if err != nil {
|
||||||
|
return model.UpstreamChanConfig{}, err
|
||||||
|
}
|
||||||
|
if !ok {
|
||||||
|
return model.UpstreamChanConfig{}, gerror.Newf("upstream configuration is missing: %s", key)
|
||||||
|
}
|
||||||
|
return config, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func decodeResponse[T any](body []byte) ([]model.SimBaseRes[T], error) {
|
||||||
|
output := response[T]{}
|
||||||
|
if err := json.Unmarshal(body, &output); err != nil {
|
||||||
|
return nil, gerror.Wrap(err, "parse upstream response failed")
|
||||||
|
}
|
||||||
|
if output.Code != 0 {
|
||||||
|
return nil, gerror.New(output.Message)
|
||||||
|
}
|
||||||
|
return output.Data, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func setClientTimeout(client *gclient.Client, timeout time.Duration) {
|
||||||
|
client.SetTimeout(timeout)
|
||||||
|
if transport, ok := client.Transport.(*http.Transport); ok {
|
||||||
|
transport.ResponseHeaderTimeout = timeout
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolveUpstreamTimeout 计算单次转发预算:
|
||||||
|
// 普通批量按「单卡预算 × 卡数」放大,并以 maxTimeout 兜底,避免批量操作被默认 2 分钟上限提前判失败;
|
||||||
|
// 数据开停沿用独立预算,优先于按卡计时。
|
||||||
|
func resolveUpstreamTimeout(path string, itemCount int, perCard, timeoutOverride, maxTimeout, dataServiceTimeout time.Duration) time.Duration {
|
||||||
|
if itemCount < 1 {
|
||||||
|
itemCount = 1
|
||||||
|
}
|
||||||
|
timeout := timeoutOverride
|
||||||
|
if timeout <= 0 {
|
||||||
|
timeout = perCard
|
||||||
|
}
|
||||||
|
if timeout <= 0 {
|
||||||
|
timeout = defaultUpstreamProxyTimeout
|
||||||
|
}
|
||||||
|
timeout *= time.Duration(itemCount)
|
||||||
|
if path == "/sim/change/sim-apn" {
|
||||||
|
if dataServiceTimeout > 0 {
|
||||||
|
return dataServiceTimeout
|
||||||
|
}
|
||||||
|
return 240 * time.Second
|
||||||
|
}
|
||||||
|
if maxTimeout > 0 && timeout > maxTimeout {
|
||||||
|
timeout = maxTimeout
|
||||||
|
}
|
||||||
|
return timeout
|
||||||
|
}
|
||||||
|
|
||||||
|
// configDuration 读取时长配置,兼容写成 "20m" 字符串或秒数整数两种形式。
|
||||||
|
func configDuration(ctx context.Context, key string, fallback time.Duration) time.Duration {
|
||||||
|
value := g.Cfg().MustGet(ctx, key)
|
||||||
|
if value == nil || value.IsNil() {
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
if duration := value.Duration(); duration >= time.Second {
|
||||||
|
return duration
|
||||||
|
}
|
||||||
|
if seconds := value.Int(); seconds > 0 {
|
||||||
|
return time.Duration(seconds) * time.Second
|
||||||
|
}
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
@@ -0,0 +1,527 @@
|
|||||||
|
package upstream
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gogf/gf/v2/frame/g"
|
||||||
|
"github.com/gogf/gf/v2/os/gcfg"
|
||||||
|
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
func startAPNHTTPChainHelper(t *testing.T, module string, extraEnv ...string) string {
|
||||||
|
t.Helper()
|
||||||
|
root, err := filepath.Abs(filepath.Join("..", "..", "..", "..", module))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
buildCtx, buildCancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
||||||
|
defer buildCancel()
|
||||||
|
binary := filepath.Join(t.TempDir(), module+"-http-test.exe")
|
||||||
|
build := exec.CommandContext(buildCtx, "go", "-C", root, "test", "-work", "-c", "-o", binary, "./internal/cmd")
|
||||||
|
build.Env = append(os.Environ(), "GOTMPDIR="+t.TempDir())
|
||||||
|
if output, err := build.CombinedOutput(); err != nil {
|
||||||
|
t.Fatalf("%s模拟进程编译失败:%v\n%s", module, err, output)
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 170*time.Second)
|
||||||
|
cmd := exec.CommandContext(ctx, binary, "-test.run=^TestAPNHTTPHarness$", "-test.v", "-test.count=1")
|
||||||
|
cmd.Dir = filepath.Join(root, "internal", "cmd")
|
||||||
|
cmd.Env = append(append(os.Environ(), "APN_HTTP_CHAIN_HELPER=1"), extraEnv...)
|
||||||
|
stdout, err := cmd.StdoutPipe()
|
||||||
|
if err != nil {
|
||||||
|
cancel()
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
cmd.Stderr = cmd.Stdout
|
||||||
|
if err := cmd.Start(); err != nil {
|
||||||
|
cancel()
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
ready := make(chan string, 1)
|
||||||
|
var mu sync.Mutex
|
||||||
|
var log strings.Builder
|
||||||
|
go func() {
|
||||||
|
scanner := bufio.NewScanner(stdout)
|
||||||
|
for scanner.Scan() {
|
||||||
|
line := scanner.Text()
|
||||||
|
mu.Lock()
|
||||||
|
log.WriteString(line + "\n")
|
||||||
|
mu.Unlock()
|
||||||
|
if strings.HasPrefix(line, "APN_CHAIN_READY=") {
|
||||||
|
ready <- strings.TrimPrefix(line, "APN_CHAIN_READY=")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
done := make(chan error, 1)
|
||||||
|
go func() { done <- cmd.Wait() }()
|
||||||
|
var address string
|
||||||
|
select {
|
||||||
|
case address = <-ready:
|
||||||
|
case err := <-done:
|
||||||
|
cancel()
|
||||||
|
mu.Lock()
|
||||||
|
text := log.String()
|
||||||
|
mu.Unlock()
|
||||||
|
t.Fatalf("%s模拟进程启动即退出:%v\n%s", module, err, text)
|
||||||
|
case <-ctx.Done():
|
||||||
|
cancel()
|
||||||
|
<-done
|
||||||
|
mu.Lock()
|
||||||
|
text := log.String()
|
||||||
|
mu.Unlock()
|
||||||
|
t.Fatalf("%s模拟进程启动失败:%s", module, text)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() {
|
||||||
|
requestCtx, stop := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
|
defer stop()
|
||||||
|
req, _ := http.NewRequestWithContext(requestCtx, http.MethodPost, address+"/__test/stop", nil)
|
||||||
|
if res, err := http.DefaultClient.Do(req); err == nil {
|
||||||
|
_ = res.Body.Close()
|
||||||
|
if res.StatusCode != http.StatusOK {
|
||||||
|
t.Errorf("%s停止请求失败:%d", module, res.StatusCode)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
t.Errorf("%s停止请求失败:%v", module, err)
|
||||||
|
}
|
||||||
|
if err := <-done; err != nil {
|
||||||
|
mu.Lock()
|
||||||
|
text := log.String()
|
||||||
|
mu.Unlock()
|
||||||
|
t.Errorf("%s模拟进程异常退出:%v\n%s", module, err, text)
|
||||||
|
} else {
|
||||||
|
t.Logf("%s helper正常shutdown并退出0", module)
|
||||||
|
}
|
||||||
|
cancel()
|
||||||
|
})
|
||||||
|
return address
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAPNHTTPChainWorkerClientProxyChanCarrier(t *testing.T) {
|
||||||
|
if os.Getenv("APN_HTTP_CHAIN_INTEGRATION") != "1" {
|
||||||
|
t.Skip("未启用本机跨模块HTTP及专用MySQL模拟链")
|
||||||
|
}
|
||||||
|
chanURL := startAPNHTTPChainHelper(t, "iotlcm-upstream-chan")
|
||||||
|
proxyURL := startAPNHTTPChainHelper(t, "iotlcm-upstream-proxy", "APN_HTTP_CHAIN_CHAN="+chanURL)
|
||||||
|
oldAdapter := g.Cfg().GetAdapter()
|
||||||
|
defer g.Cfg().SetAdapter(oldAdapter)
|
||||||
|
adapter, err := gcfg.NewAdapterContent(fmt.Sprintf(`{"upstream":{"proxy":{"address":%q,"timeout":"2s","apnAsyncProtocolVersion":1}}}`, strings.TrimPrefix(proxyURL, "http://")))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
g.Cfg().SetAdapter(adapter)
|
||||||
|
client := New()
|
||||||
|
expectedWrites := map[string]int{}
|
||||||
|
expectedQueries := map[string]int{}
|
||||||
|
expectedSubmits := 0
|
||||||
|
for _, scenario := range []string{"drop-response", "top-error", "lost-response", "failed-task"} {
|
||||||
|
t.Run(scenario, func(t *testing.T) {
|
||||||
|
input := &model.SimCardApnChangeInput{ReqData: model.SimCardApnChangeInputItem{OperationID: fmt.Sprintf("http-chain-%s-%d", scenario, time.Now().UnixNano()), OperType: "1", ApnName: "CMIOT"}}
|
||||||
|
prefix := scenario + "-"
|
||||||
|
if scenario == "lost-response" {
|
||||||
|
prefix = "lost-"
|
||||||
|
}
|
||||||
|
if scenario == "failed-task" {
|
||||||
|
prefix = "failed-"
|
||||||
|
}
|
||||||
|
for i := 0; i < 10; i++ {
|
||||||
|
input.SimKeys = append(input.SimKeys, &model.SimKey{ChanKey: "fake-channel", ICCID: fmt.Sprintf("fake-card-%s-%d", scenario, i), MSISDN: fmt.Sprintf("%s%d", prefix, i)})
|
||||||
|
}
|
||||||
|
out, err := client.SubmitSimApnFunction(context.Background(), input)
|
||||||
|
if err != nil || len(out) != 10 {
|
||||||
|
t.Fatalf("首发结果无效:%+v %v", out, err)
|
||||||
|
}
|
||||||
|
for _, item := range out {
|
||||||
|
if scenario != "failed-task" && !item.ResData.Uncertain {
|
||||||
|
t.Fatalf("丢响应被确认:%+v", item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
res, err := http.Post(chanURL+"/__test/restart", "application/json", nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
_ = res.Body.Close()
|
||||||
|
recovered, err := client.QuerySimApnFunctionResult(context.Background(), input)
|
||||||
|
if err != nil || len(recovered) != 10 {
|
||||||
|
t.Fatalf("恢复失败:%v", err)
|
||||||
|
}
|
||||||
|
duplicate, err := client.SubmitSimApnFunction(context.Background(), input)
|
||||||
|
if err != nil || len(duplicate) != 10 {
|
||||||
|
t.Fatalf("重复恢复失败:%v", err)
|
||||||
|
}
|
||||||
|
if scenario == "lost-response" {
|
||||||
|
for i, item := range recovered {
|
||||||
|
if !item.ResData.Uncertain || item.ResData.OrderNum != "" || !duplicate[i].ResData.Uncertain {
|
||||||
|
t.Fatalf("无jobId必须未知:%+v", item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
job := recovered[0].ResData.OrderNum
|
||||||
|
for i, item := range recovered {
|
||||||
|
if job == "" || item.ResData.OrderNum != job || !item.ResData.Pending || duplicate[i].ResData.OrderNum != job {
|
||||||
|
t.Fatalf("恢复丢失已持久化jobId:%+v", item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
expectedQueries[job] = 3
|
||||||
|
input.ReqData.QueryJobID = job
|
||||||
|
for attempt := 0; attempt < 3; attempt++ {
|
||||||
|
result, e := client.QuerySimApnFunctionResult(context.Background(), input)
|
||||||
|
if e != nil || len(result) != 10 {
|
||||||
|
t.Fatalf("单次查询失败:%v", e)
|
||||||
|
}
|
||||||
|
for _, item := range result {
|
||||||
|
if attempt < 2 && (!item.ResData.Pending || item.Message == "") {
|
||||||
|
t.Fatalf("14008/处理中不得终态:%+v", item)
|
||||||
|
}
|
||||||
|
if attempt == 2 && (item.ResData.Pending || item.ResData.Uncertain || (scenario == "failed-task" && item.Code != 9999) || (scenario != "failed-task" && item.Code != 0)) {
|
||||||
|
t.Fatalf("终态语义错误:%+v", item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
statsResponse, e := http.Get(chanURL + "/__test/stats")
|
||||||
|
if e != nil {
|
||||||
|
t.Fatal(e)
|
||||||
|
}
|
||||||
|
var stats struct {
|
||||||
|
Writes map[string]int `json:"writes"`
|
||||||
|
Queries map[string]int `json:"queries"`
|
||||||
|
Submits int `json:"submits"`
|
||||||
|
Unexpected int `json:"unexpected"`
|
||||||
|
}
|
||||||
|
e = json.NewDecoder(statsResponse.Body).Decode(&stats)
|
||||||
|
_ = statsResponse.Body.Close()
|
||||||
|
expectedSubmits++
|
||||||
|
for _, key := range input.SimKeys {
|
||||||
|
expectedWrites[key.MSISDN] = 1
|
||||||
|
}
|
||||||
|
if e != nil || stats.Submits != expectedSubmits || stats.Unexpected != 0 || len(stats.Writes) != len(expectedWrites) || len(stats.Queries) != len(expectedQueries) {
|
||||||
|
t.Fatalf("额外或缺失上游请求:stats=%+v err=%v", stats, e)
|
||||||
|
}
|
||||||
|
for key, count := range expectedWrites {
|
||||||
|
if stats.Writes[key] != count {
|
||||||
|
t.Fatalf("逐卡办理次数错误:%s=%d,预期%d", key, stats.Writes[key], count)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for job, count := range expectedQueries {
|
||||||
|
if stats.Queries[job] != count {
|
||||||
|
t.Fatalf("纯查询次数错误:%s=%d,预期%d", job, stats.Queries[job], count)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
counts := make([]int, 0, len(input.SimKeys))
|
||||||
|
for _, key := range input.SimKeys {
|
||||||
|
counts = append(counts, stats.Writes[key.MSISDN])
|
||||||
|
}
|
||||||
|
t.Logf("十卡实际办理计数=%v 累计提交=%d 无额外请求;丢失jobId=%v 明确失败终态=%v", counts, stats.Submits, scenario == "lost-response", scenario == "failed-task")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAsyncApnCapabilityGateBlocksOldChanBeforeSubmit(t *testing.T) {
|
||||||
|
oldAdapter := g.Cfg().GetAdapter()
|
||||||
|
defer g.Cfg().SetAdapter(oldAdapter)
|
||||||
|
for _, tc := range []struct {
|
||||||
|
name string
|
||||||
|
enabled int
|
||||||
|
version int
|
||||||
|
wantError bool
|
||||||
|
}{{"显式关闭", 0, 1, true}, {"旧版无能力", 1, 0, true}, {"能力就绪", 1, 1, false}} {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
probes, submits := 0, 0
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.URL.Path == "/sim/query/sim-apn-capability" {
|
||||||
|
probes++
|
||||||
|
_, _ = fmt.Fprintf(w, `{"code":0,"data":[{"code":0,"simKey":{"chanKey":"test","iccid":"fake-card"},"resData":{"protocolVersion":%d}}]}`, tc.version)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
submits++
|
||||||
|
_, _ = w.Write([]byte(`{"code":0,"data":[{"code":0,"resData":{"pending":true,"orderNum":"fake-job"}}]}`))
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
adapter, err := gcfg.NewAdapterContent(fmt.Sprintf(`{"upstream":{"proxy":{"address":%q,"timeout":"1s","apnAsyncProtocolVersion":%d}}}`, strings.TrimPrefix(server.URL, "http://"), tc.enabled))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
g.Cfg().SetAdapter(adapter)
|
||||||
|
_, err = New().SubmitSimApnFunction(context.Background(), &model.SimCardApnChangeInput{SimKeys: []*model.SimKey{{ChanKey: "test", ICCID: "fake-card"}}, ReqData: model.SimCardApnChangeInputItem{OperationID: "fake-operation"}})
|
||||||
|
var notSent interface{ APNRequestNotSent() bool }
|
||||||
|
if (err != nil) != tc.wantError || (tc.wantError && (submits != 0 || !errors.As(err, ¬Sent) || !notSent.APNRequestNotSent())) || (!tc.wantError && (submits != 1 || probes != 1)) {
|
||||||
|
t.Fatalf("能力门禁失效:probes=%d submits=%d err=%v", probes, submits, err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestAsyncApnSubmitDefaultsEnabledWithoutGateConfig 固化默认开启:
|
||||||
|
// 配置里不写 apnAsyncProtocolVersion 时,提交不应再被门禁拦下,兼容性交给协议能力探测。
|
||||||
|
func TestAsyncApnSubmitDefaultsEnabledWithoutGateConfig(t *testing.T) {
|
||||||
|
oldAdapter := g.Cfg().GetAdapter()
|
||||||
|
defer g.Cfg().SetAdapter(oldAdapter)
|
||||||
|
probes, submits := 0, 0
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.URL.Path == "/sim/query/sim-apn-capability" {
|
||||||
|
probes++
|
||||||
|
_, _ = w.Write([]byte(`{"code":0,"data":[{"code":0,"simKey":{"chanKey":"test","iccid":"fake-card"},"resData":{"protocolVersion":1}}]}`))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
submits++
|
||||||
|
_, _ = w.Write([]byte(`{"code":0,"data":[{"code":0,"resData":{"pending":true,"orderNum":"fake-job"}}]}`))
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
adapter, err := gcfg.NewAdapterContent(fmt.Sprintf(`{"upstream":{"proxy":{"address":%q,"timeout":"1s"}}}`, strings.TrimPrefix(server.URL, "http://")))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
g.Cfg().SetAdapter(adapter)
|
||||||
|
if _, err = New().SubmitSimApnFunction(context.Background(), &model.SimCardApnChangeInput{SimKeys: []*model.SimKey{{ChanKey: "test", ICCID: "fake-card"}}, ReqData: model.SimCardApnChangeInputItem{OperationID: "fake-operation"}}); err != nil {
|
||||||
|
t.Fatalf("未配置开关时提交应默认放行:%v", err)
|
||||||
|
}
|
||||||
|
if probes != 1 || submits != 1 {
|
||||||
|
t.Fatalf("默认放行后仍应做协议探测:probes=%d submits=%d", probes, submits)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAsyncApnReadOnlyQueryBypassesSubmitGateButRequiresProtocol(t *testing.T) {
|
||||||
|
oldAdapter := g.Cfg().GetAdapter()
|
||||||
|
defer g.Cfg().SetAdapter(oldAdapter)
|
||||||
|
for _, tc := range []struct {
|
||||||
|
name, jobID, operationID string
|
||||||
|
version int
|
||||||
|
missingProbe bool
|
||||||
|
wantQuery bool
|
||||||
|
}{
|
||||||
|
{name: "existing_job", jobID: "existing-job", version: 1, wantQuery: true},
|
||||||
|
{name: "existing_operation", operationID: "existing-operation", version: 1, wantQuery: true},
|
||||||
|
{name: "legacy_protocol", jobID: "existing-job", version: 0},
|
||||||
|
{name: "missing_capability", jobID: "existing-job", missingProbe: true},
|
||||||
|
} {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
probes, queries := 0, 0
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.URL.Path == "/sim/query/sim-apn-capability" {
|
||||||
|
probes++
|
||||||
|
if tc.missingProbe {
|
||||||
|
_ = json.NewEncoder(w).Encode(map[string]any{"code": 404, "message": "missing capability"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_, _ = fmt.Fprintf(w, `{"code":0,"data":[{"code":0,"simKey":{"chanKey":"test","iccid":"fake-card"},"resData":{"protocolVersion":%d}}]}`, tc.version)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if r.URL.Path != "/sim/change/sim-apn" {
|
||||||
|
t.Errorf("unexpected endpoint: %s", r.URL.Path)
|
||||||
|
}
|
||||||
|
queries++
|
||||||
|
var request model.SimCardApnChangeInput
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
|
||||||
|
t.Error(err)
|
||||||
|
}
|
||||||
|
if request.ReqData.SubmitOnly || request.ReqData.QueryJobID != tc.jobID || request.ReqData.RecoverOnly != (tc.jobID == "") {
|
||||||
|
t.Errorf("query accidentally submitted: %+v", request.ReqData)
|
||||||
|
}
|
||||||
|
_, _ = w.Write([]byte(`{"code":0,"data":[{"code":0,"simKey":{"chanKey":"test","iccid":"fake-card"},"resData":{"orderNum":"existing-job","pending":true}}]}`))
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
adapter, err := gcfg.NewAdapterContent(fmt.Sprintf(`{"upstream":{"proxy":{"address":%q,"timeout":"1s","apnAsyncProtocolVersion":0}}}`, strings.TrimPrefix(server.URL, "http://")))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
g.Cfg().SetAdapter(adapter)
|
||||||
|
result, err := New().QuerySimApnFunctionResult(context.Background(), &model.SimCardApnChangeInput{
|
||||||
|
SimKeys: []*model.SimKey{{ChanKey: "test", ICCID: "fake-card"}},
|
||||||
|
ReqData: model.SimCardApnChangeInputItem{OperationID: tc.operationID, QueryJobID: tc.jobID},
|
||||||
|
})
|
||||||
|
if probes != 1 || (err == nil) != tc.wantQuery || queries != map[bool]int{true: 1, false: 0}[tc.wantQuery] {
|
||||||
|
t.Fatalf("read-only protocol guard failed: probes=%d queries=%d err=%v result=%+v", probes, queries, err, result)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAsyncApnSecondProbeRejectionIsTypedAndTransportIsNot(t *testing.T) {
|
||||||
|
oldAdapter := g.Cfg().GetAdapter()
|
||||||
|
defer g.Cfg().SetAdapter(oldAdapter)
|
||||||
|
for _, probeFails := range []bool{true, false} {
|
||||||
|
probes, submits := 0, 0
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
// 夹具不共享连接:并发跑包时复用空闲连接会被对端关闭,
|
||||||
|
// 使失败从"提交阶段"漂移到"只读探测阶段",造成与被测语义无关的抖动。
|
||||||
|
w.Header().Set("Connection", "close")
|
||||||
|
if r.URL.Path == "/sim/query/sim-apn-capability" {
|
||||||
|
probes++
|
||||||
|
if probes == 2 && probeFails {
|
||||||
|
http.Error(w, "模拟第二次探测失败", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fmt.Fprint(w, `{"code":0,"data":[{"code":0,"simKey":{"chanKey":"test","iccid":"fake-card"},"resData":{"protocolVersion":1}}]}`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
submits++
|
||||||
|
conn, _, err := w.(http.Hijacker).Hijack()
|
||||||
|
if err != nil {
|
||||||
|
t.Error(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_ = conn.Close()
|
||||||
|
}))
|
||||||
|
adapter, err := gcfg.NewAdapterContent(fmt.Sprintf(`{"upstream":{"proxy":{"address":%q,"timeout":"1s","apnAsyncProtocolVersion":1}}}`, strings.TrimPrefix(server.URL, "http://")))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
g.Cfg().SetAdapter(adapter)
|
||||||
|
input := &model.SimCardApnChangeInput{SimKeys: []*model.SimKey{{ChanKey: "test", ICCID: "fake-card"}}, ReqData: model.SimCardApnChangeInputItem{OperationID: "fake-operation"}}
|
||||||
|
client := New()
|
||||||
|
if err := client.CheckSimApnAsyncCapability(context.Background(), input); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
_, err = client.SubmitSimApnFunction(context.Background(), input)
|
||||||
|
server.Close()
|
||||||
|
var notSent interface{ APNRequestNotSent() bool }
|
||||||
|
isNotSent := errors.As(err, ¬Sent) && notSent.APNRequestNotSent()
|
||||||
|
if err == nil || isNotSent != probeFails || (probeFails && submits != 0) || (!probeFails && submits != 1) || probes != 2 {
|
||||||
|
t.Fatalf("零写类型边界错误:probeFails=%v probes=%d submits=%d err=%v", probeFails, probes, submits, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSimCardStatusChangeProtocol(t *testing.T) {
|
||||||
|
input := model.SimCardStatusChangeInput{
|
||||||
|
SimKeys: []*model.SimKey{{ChanKey: "channel-a", ICCID: "89860001"}},
|
||||||
|
ReqData: model.SimCardStatusChangeInputItem{OperType: "1"},
|
||||||
|
}
|
||||||
|
body, err := json.Marshal(input)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
const want = `{"simKeys":[{"chanKey":"channel-a","iccid":"89860001","imsi":"","msisdn":""}],"reqData":{"operType":"1"}}`
|
||||||
|
if got := string(body); got != want {
|
||||||
|
t.Fatalf("request body = %s, want %s", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCurrentUsageConsumerProtocol(t *testing.T) {
|
||||||
|
input := model.SimDataUsageInput{
|
||||||
|
SimKeys: []*model.SimKey{{ChanKey: "channel-a", ICCID: "89860001", IMSI: "460001", MSISDN: "13800000000"}},
|
||||||
|
}
|
||||||
|
body, err := json.Marshal(input)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
const want = `{"simKeys":[{"chanKey":"channel-a","iccid":"89860001","imsi":"460001","msisdn":"13800000000"}],"reqData":{}}`
|
||||||
|
if got := string(body); got != want {
|
||||||
|
t.Fatalf("request body = %s, want %s", got, want)
|
||||||
|
}
|
||||||
|
client := NewWithCurrentUsageClient("iotlcmupstreamchan-http-svc:8110")
|
||||||
|
if client.currentUsageClientName != "iotlcmupstreamchan-http-svc:8110" {
|
||||||
|
t.Fatalf("current usage client = %q", client.currentUsageClientName)
|
||||||
|
}
|
||||||
|
if client.currentUsageTimeout != 0 {
|
||||||
|
t.Fatalf("default current usage timeout = %s, want unset", client.currentUsageTimeout)
|
||||||
|
}
|
||||||
|
configured := NewWithCurrentUsageClient("iotlcmupstreamchan-http-svc:8110", 90*time.Second)
|
||||||
|
if configured.currentUsageTimeout != 90*time.Second {
|
||||||
|
t.Fatalf("configured current usage timeout = %s, want 90s", configured.currentUsageTimeout)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInboundBasicInfoClientCanUseDedicatedEndpoint(t *testing.T) {
|
||||||
|
client := NewWithInboundBasicInfoClient("iotlcm.example.com:8110")
|
||||||
|
clientName, err := client.inboundBasicInfoAddress(context.Background())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if clientName != "iotlcm.example.com:8110" {
|
||||||
|
t.Fatalf("inbound basic info client = %q, want dedicated chan endpoint", clientName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInboundBasicInfoClientCanUseEnvEndpoint(t *testing.T) {
|
||||||
|
const endpoint = "iotlcm.example.com:8110"
|
||||||
|
t.Setenv(inboundBasicInfoClientNameEnv, endpoint)
|
||||||
|
client := New()
|
||||||
|
clientName, err := client.inboundBasicInfoAddress(context.Background())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if clientName != endpoint {
|
||||||
|
t.Fatalf("inbound basic info client = %q, want env endpoint", clientName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConfiguredInboundBasicInfoClientUsesDedicatedEndpoint(t *testing.T) {
|
||||||
|
if value := os.Getenv(inboundBasicInfoClientNameEnv); value != "" {
|
||||||
|
t.Setenv(inboundBasicInfoClientNameEnv, "")
|
||||||
|
}
|
||||||
|
client := New()
|
||||||
|
clientName, err := client.inboundBasicInfoAddress(context.Background())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(clientName, ":8110") {
|
||||||
|
t.Fatalf("inbound basic info client = %q, want dedicated chan endpoint", clientName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDecodeAsyncApnResponseKeepsUnconfirmedState(t *testing.T) {
|
||||||
|
body := []byte(`{"code":0,"data":[{"code":0,"resData":{"orderNum":"job-1","pending":true}},{"code":1,"message":"响应未确认","resData":{"orderNum":"job-2","uncertain":true}}]}`)
|
||||||
|
items, err := decodeResponse[model.SimCardApnChangeOutputItem](body)
|
||||||
|
if err != nil || len(items) != 2 {
|
||||||
|
t.Fatalf("解析异步响应失败:%v", err)
|
||||||
|
}
|
||||||
|
if !items[0].ResData.Pending || items[0].ResData.OrderNum != "job-1" || !items[1].ResData.Uncertain {
|
||||||
|
t.Fatalf("异步待查字段丢失:%+v", items)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSimCardApnChangeProtocol(t *testing.T) {
|
||||||
|
input := model.SimCardApnChangeInput{
|
||||||
|
SimKeys: []*model.SimKey{{ChanKey: "channel-a", ICCID: "89860001"}},
|
||||||
|
ReqData: model.SimCardApnChangeInputItem{OperType: "0", ApnName: "CMIOT"},
|
||||||
|
}
|
||||||
|
body, err := json.Marshal(input)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
const want = `{"simKeys":[{"chanKey":"channel-a","iccid":"89860001","imsi":"","msisdn":""}],"reqData":{"operType":"0","apnName":"CMIOT"}}`
|
||||||
|
if got := string(body); got != want {
|
||||||
|
t.Fatalf("request body = %s, want %s", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDecodeSimCardApnChangeResponseKeepsFailedItems(t *testing.T) {
|
||||||
|
body := []byte(`{"code":0,"message":"ok","data":[{"code":0,"message":"success","simKey":{"iccid":"89860001"},"resData":{"orderNum":"order-1"}},{"code":4001,"message":"denied","simKey":{"iccid":"89860002"},"resData":{"orderNum":"order-2"}}]}`)
|
||||||
|
items, err := decodeResponse[model.SimCardApnChangeOutputItem](body)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(items) != 2 {
|
||||||
|
t.Fatalf("item count = %d, want 2", len(items))
|
||||||
|
}
|
||||||
|
if items[1].Code != 4001 || items[1].ResData.OrderNum != "order-2" {
|
||||||
|
t.Fatalf("failed item was not preserved: %+v", items[1])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDecodeSimCardStatusChangeResponseKeepsFailedItems(t *testing.T) {
|
||||||
|
body := []byte(`{"code":0,"message":"ok","data":[{"code":0,"message":"success","simKey":{"iccid":"89860001"},"resData":{"orderNum":"order-1"}},{"code":4001,"message":"denied","simKey":{"iccid":"89860002"},"resData":{"orderNum":"order-2"}}]}`)
|
||||||
|
items, err := decodeResponse[model.SimCardStatusChangeOutputItem](body)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(items) != 2 {
|
||||||
|
t.Fatalf("item count = %d, want 2", len(items))
|
||||||
|
}
|
||||||
|
if items[1].Code != 4001 || items[1].ResData.OrderNum != "order-2" {
|
||||||
|
t.Fatalf("failed item was not preserved: %+v", items[1])
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
package upstream
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestResolveUpstreamTimeoutScalesWithCardCount 覆盖批量操作预算:
|
||||||
|
// worker 不能再把批量请求压回 2 分钟,否则通道仍在按卡串行办理时会被提前判失败。
|
||||||
|
func TestResolveUpstreamTimeoutScalesWithCardCount(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
path string
|
||||||
|
itemCount int
|
||||||
|
perCard time.Duration
|
||||||
|
override time.Duration
|
||||||
|
maxTimeout time.Duration
|
||||||
|
apnTimeout time.Duration
|
||||||
|
want time.Duration
|
||||||
|
}{
|
||||||
|
{"单卡", "/sim/change/sim-status", 1, 10 * time.Second, 0, defaultUpstreamProxyMaxTimeout, 240 * time.Second, 10 * time.Second},
|
||||||
|
{"零卡按单卡处理", "/sim/change/sim-status", 0, 10 * time.Second, 0, defaultUpstreamProxyMaxTimeout, 240 * time.Second, 10 * time.Second},
|
||||||
|
{"50卡按单卡预算放大", "/sim/change/sim-status", 50, 10 * time.Second, 0, defaultUpstreamProxyMaxTimeout, 240 * time.Second, 500 * time.Second},
|
||||||
|
{"100卡恰好低于上限", "/sim/change/sim-status", 100, 10 * time.Second, 0, defaultUpstreamProxyMaxTimeout, 240 * time.Second, 1000 * time.Second},
|
||||||
|
{"超大批量按上限截断", "/sim/query/usage", 1000, 10 * time.Second, 0, defaultUpstreamProxyMaxTimeout, 240 * time.Second, defaultUpstreamProxyMaxTimeout},
|
||||||
|
{"单卡预算缺失用默认值", "/sim/query/usage", 2, 0, 0, defaultUpstreamProxyMaxTimeout, 240 * time.Second, 20 * time.Second},
|
||||||
|
{"显式覆盖仍按卡数放大", "/sim/query/usage", 3, 10 * time.Second, 2 * time.Second, defaultUpstreamProxyMaxTimeout, 240 * time.Second, 6 * time.Second},
|
||||||
|
{"数据开停使用独立预算", "/sim/change/sim-apn", 50, 10 * time.Second, 0, defaultUpstreamProxyMaxTimeout, 240 * time.Second, 240 * time.Second},
|
||||||
|
{"数据开停预算缺失回退", "/sim/change/sim-apn", 1, 10 * time.Second, 0, defaultUpstreamProxyMaxTimeout, 0, 240 * time.Second},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
t.Run(c.name, func(t *testing.T) {
|
||||||
|
got := resolveUpstreamTimeout(c.path, c.itemCount, c.perCard, c.override, c.maxTimeout, c.apnTimeout)
|
||||||
|
if got != c.want {
|
||||||
|
t.Fatalf("resolveUpstreamTimeout=%s,期望%s", got, c.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestUpstreamMaxTimeoutOutlastsProxyBatchTimeout 固化跨服务预算约束:
|
||||||
|
// worker 上限必须大于 proxy 的 gateway.batchTimeout(默认 20 分钟),
|
||||||
|
// 使超时由更靠近通道的一层先判定,避免 worker 提前放弃而通道仍在办理。
|
||||||
|
func TestUpstreamMaxTimeoutOutlastsProxyBatchTimeout(t *testing.T) {
|
||||||
|
const proxyBatchTimeout = 20 * time.Minute
|
||||||
|
if defaultUpstreamProxyMaxTimeout <= proxyBatchTimeout {
|
||||||
|
t.Fatalf("worker 上限%s 必须大于 proxy 批量上限%s", defaultUpstreamProxyMaxTimeout, proxyBatchTimeout)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
package cmd
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/gogf/gf/v2/errors/gerror"
|
||||||
|
"github.com/gogf/gf/v2/frame/g"
|
||||||
|
"github.com/gogf/gf/v2/os/gcmd"
|
||||||
|
|
||||||
|
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/client/upstream"
|
||||||
|
datarepository "git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/repository/data"
|
||||||
|
dataservice "git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/service/data"
|
||||||
|
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/worker"
|
||||||
|
)
|
||||||
|
|
||||||
|
var QueryAPNReview = gcmd.Command{
|
||||||
|
Name: "query-apn-review",
|
||||||
|
Usage: "query-apn-review --operation-id=<operationId> | --batch-no=<batchNo>",
|
||||||
|
Brief: "只读查询待核查APN子批并同步已确认结果(传 batch-no 时同步该父批下全部待核查子批)",
|
||||||
|
Arguments: []gcmd.Argument{
|
||||||
|
{Name: "operation-id", Brief: "待核查子批operationId"},
|
||||||
|
{Name: "batch-no", Brief: "待核查父批批次号,按父批同步全部待核查子批"},
|
||||||
|
},
|
||||||
|
Func: func(ctx context.Context, parser *gcmd.Parser) error {
|
||||||
|
operationID := strings.TrimSpace(parser.GetOpt("operation-id").String())
|
||||||
|
batchNo := strings.TrimSpace(parser.GetOpt("batch-no").String())
|
||||||
|
if operationID == "" && batchNo == "" {
|
||||||
|
return gerror.New("必须提供 operation-id 或 batch-no")
|
||||||
|
}
|
||||||
|
if operationID != "" && batchNo != "" {
|
||||||
|
return gerror.New("operation-id 与 batch-no 不能同时提供")
|
||||||
|
}
|
||||||
|
executor := dataservice.NewExecutor(datarepository.New(), upstream.New())
|
||||||
|
if batchNo != "" {
|
||||||
|
queried, err := executor.QueryReviewBatch(ctx, batchNo)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
g.Log().Infof(ctx, "APN待核查批量同步完成 batchNo=%s 已同步=%d", batchNo, queried)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if err := executor.QueryReview(ctx, operationID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
g.Log().Infof(ctx, "APN待核查查询完成 operationId=%s", operationID)
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
if err := Main.AddCommand(&QueryAPNReview); err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var Main = gcmd.Command{
|
||||||
|
Name: "main",
|
||||||
|
Usage: "main",
|
||||||
|
Brief: "start RabbitMQ worker",
|
||||||
|
Func: func(ctx context.Context, _ *gcmd.Parser) error {
|
||||||
|
return worker.Run(ctx)
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
package consts
|
||||||
|
|
||||||
|
// BatchSubBatchSize 是批量开停数据 / 停复机执行时,单个上游通道每次请求携带的卡数量上限。
|
||||||
|
// upstream-chan 对同一通道按卡串行调用运营商接口,单个请求耗时随卡数线性增长;
|
||||||
|
// 因此超时预算必须按「单卡预算 × 卡数」计算并配置足够上限(见 upstream.proxy.maxTimeout),
|
||||||
|
// 不能依赖固定 2 分钟上限,否则请求仍在上游办理就会被判失败。
|
||||||
|
const BatchSubBatchSize = 100
|
||||||
|
|
||||||
|
// BatchSubBatchConcurrency 是同一通道内子批次的并发度,受信号量限制:
|
||||||
|
// 即最多同时向上游发起 BatchSubBatchConcurrency 个请求,等价于最多同时发起同样数量的运营商调用,
|
||||||
|
// 既避免串行过慢,也避免无节制并发打爆运营商限流。如需更保守可调小该值。
|
||||||
|
const BatchSubBatchConcurrency = 5
|
||||||
@@ -0,0 +1,160 @@
|
|||||||
|
package consts
|
||||||
|
|
||||||
|
func FormalPackageTypes() []int { return []int{PackageTypeCard, PackageTypePool} }
|
||||||
|
|
||||||
|
func IsAddPackageType(packageType int) bool {
|
||||||
|
return packageType == PackageTypeCardAddPackage || packageType == PackageTypePoolAddPackage
|
||||||
|
}
|
||||||
|
|
||||||
|
const (
|
||||||
|
BatchTypeInbound = 1
|
||||||
|
BatchTypeAllocate = 3
|
||||||
|
BatchTypeCardAddPackage = 11
|
||||||
|
BatchTypePoolAddPackage = 12
|
||||||
|
BatchTypeSimCardStart = 103
|
||||||
|
BatchTypeSimCardStop = 104
|
||||||
|
BatchTypeSimCardRestart = 105
|
||||||
|
BatchTypeSimCardCancel = 106
|
||||||
|
BatchTypeSimCardUnsub = 108
|
||||||
|
BatchTypeDataCut = 107
|
||||||
|
BatchTypeDataStart = 109
|
||||||
|
BatchTypeDataStop = 110
|
||||||
|
BatchTypeSimWarningConfig = 40
|
||||||
|
BatchTypeTrafficCutoffConfig = 41
|
||||||
|
BatchTypeTrafficCutoff = BatchTypeTrafficCutoffConfig // Deprecated: use BatchTypeTrafficCutoffConfig.
|
||||||
|
|
||||||
|
// 异步续费/变更扩展
|
||||||
|
BatchTypePrepare = 4 // 预处理(renew/change 共用 prepare)
|
||||||
|
BatchTypeRenew = 101 // 卡续费
|
||||||
|
BatchTypeChange = 102 // 卡变更套餐
|
||||||
|
|
||||||
|
// t_sim_card_bind_pkg.package_type 中,只有正式卡套餐和池套餐参与续费/变更判断。
|
||||||
|
PackageTypeCard = 101
|
||||||
|
PackageTypePool = 102
|
||||||
|
PackageTypeCardAddPackage = 103
|
||||||
|
PackageTypePoolAddPackage = 104
|
||||||
|
|
||||||
|
BatchSourceTypeBatchSelect = 0
|
||||||
|
BatchSourceTypeRange = 1
|
||||||
|
BatchSourceTypeBatchInput = 2
|
||||||
|
BatchSourceTypeFile = 3
|
||||||
|
BatchSourceTypeScheduled = 4
|
||||||
|
|
||||||
|
BatchSourceTypeExcel = BatchSourceTypeFile
|
||||||
|
BatchSourceTypeInput = BatchSourceTypeBatchInput
|
||||||
|
|
||||||
|
BatchNoPrefixCardAddPackage = "ADP"
|
||||||
|
BatchNoPrefixPoolAddPackage = "PAP"
|
||||||
|
|
||||||
|
SimCardStatusOperTypeStop = "0"
|
||||||
|
SimCardStatusOperTypeStart = "1"
|
||||||
|
|
||||||
|
RestartPhaseCreated = "created"
|
||||||
|
RestartPhaseWaitingResume = "waiting_resume"
|
||||||
|
RestartPhaseResuming = "resuming"
|
||||||
|
RestartPhaseFinished = "finished"
|
||||||
|
|
||||||
|
SimBossCardStatusActivated = 2
|
||||||
|
SimBossCardStatusStopped = 4
|
||||||
|
SimBossCardStatusCanceled = 9
|
||||||
|
|
||||||
|
SimApnOperTypeOpen = "0"
|
||||||
|
SimApnOperTypeStop = "1"
|
||||||
|
|
||||||
|
SimApnNameDefault = "CMIOT"
|
||||||
|
|
||||||
|
SimDataServiceStatusEnabled = 1
|
||||||
|
SimDataServiceStatusDisabled = 0
|
||||||
|
|
||||||
|
BatchAsyncStatusPending = 1
|
||||||
|
BatchAsyncStatusProcessing = 2
|
||||||
|
BatchAsyncStatusSuccess = 3
|
||||||
|
BatchAsyncStatusFailed = 4
|
||||||
|
BatchAsyncStatusPartialSuccess = 5
|
||||||
|
BatchAsyncStatusUnknown = 6
|
||||||
|
BatchAsyncStatusReview = 6
|
||||||
|
|
||||||
|
BatchRunResultPending = 0
|
||||||
|
BatchRunResultRunning = 1
|
||||||
|
BatchRunResultSuccess = 2
|
||||||
|
BatchRunResultFailed = 3
|
||||||
|
BatchRunResultPartialSuccess = 4
|
||||||
|
BatchRunResultUnknown = 5
|
||||||
|
BatchRunResultReview = 5
|
||||||
|
|
||||||
|
BatchDetailStatusPending = 0
|
||||||
|
BatchDetailStatusRunning = 1
|
||||||
|
BatchDetailStatusSuccess = 2
|
||||||
|
BatchDetailStatusFailed = 3
|
||||||
|
BatchDetailStatusUnknown = 4
|
||||||
|
BatchDetailStatusNotExecuted = 5
|
||||||
|
|
||||||
|
ResourceTypeExcel = 1
|
||||||
|
DownloadStatusGenerated = 1
|
||||||
|
|
||||||
|
ResourceExportSceneCardAddPackage = 11
|
||||||
|
ResourceExportScenePoolAddPackage = 12
|
||||||
|
|
||||||
|
// 卡套餐有效状态
|
||||||
|
PackageStatusNone = 0
|
||||||
|
PackageStatusPending = 1
|
||||||
|
PackageStatusEffective = 2
|
||||||
|
PackageStatusExpired = 3
|
||||||
|
|
||||||
|
// 订购记录有效状态(t_sim_card_bind_pkg.effective_status)
|
||||||
|
EffectiveStatusPending = 0
|
||||||
|
EffectiveStatusEffective = 1
|
||||||
|
EffectiveStatusExpired = 2
|
||||||
|
EffectiveStatusTerminated = 3
|
||||||
|
EffectiveStatusVoided = 4
|
||||||
|
|
||||||
|
// 退订状态(t_order.return_status)
|
||||||
|
OrderReturnStatusNone = 0
|
||||||
|
OrderReturnStatusSuccess = 4
|
||||||
|
OrderReturnStatusPartial = 3
|
||||||
|
|
||||||
|
// Renew/change order contract. These values mirror the Admin cardpackage
|
||||||
|
// domain and the production t_order/t_order_item enum contract.
|
||||||
|
OrderTypeCardSubscribe = 1
|
||||||
|
OrderTypeCardRenew = 2
|
||||||
|
OrderTypeCardChange = 3
|
||||||
|
OrderStatusCompleted = 5
|
||||||
|
CustomerTypeAgent = 1
|
||||||
|
PayTypeOffline = 3
|
||||||
|
PayTypeBalance = 4
|
||||||
|
PayStatusPaid = 2
|
||||||
|
ProductTypeCard = 1
|
||||||
|
PayModeYearMonth = 1
|
||||||
|
ShippingMethodNone = 3
|
||||||
|
|
||||||
|
PackageBusinessTypeSubscribe = 0
|
||||||
|
PackageBusinessTypeChange = 1
|
||||||
|
PackageBusinessTypeRenew = 2
|
||||||
|
|
||||||
|
ResourceBatchDownloadTaskSimCardAddPackage = "单卡加油包结果"
|
||||||
|
ResourceBatchDownloadTaskSimPoolAddPackage = "流量池加油包结果"
|
||||||
|
|
||||||
|
SimCardAddPackageMaxFileSize = 50 << 20
|
||||||
|
SimPoolAddPackageMaxFileSize = 50 << 20
|
||||||
|
|
||||||
|
SimCardAddPackageSubDirImportExcel = "sim-card-add-package/import"
|
||||||
|
SimCardAddPackageSubDirResultExcel = "sim-card-add-package/result"
|
||||||
|
SimPoolAddPackageSubDirImportExcel = "sim-pool-add-package/import"
|
||||||
|
SimPoolAddPackageSubDirResultExcel = "sim-pool-add-package/result"
|
||||||
|
|
||||||
|
// 续费/变更结果目录
|
||||||
|
SimCardRenewResultSubDir = "sim-card-renew/result"
|
||||||
|
SimCardChangeResultSubDir = "sim-card-change/result"
|
||||||
|
|
||||||
|
// 续费/变更结果任务名(沿用 Admin 约定)
|
||||||
|
ResultTaskSimCardRenew = "卡续费结果"
|
||||||
|
ResultTaskSimCardChange = "卡变更套餐结果"
|
||||||
|
ResultTaskSimCardPrepare = "卡续费/变更预处理结果"
|
||||||
|
)
|
||||||
|
|
||||||
|
// RenewPriceSource 续费/变更价格来源
|
||||||
|
const (
|
||||||
|
RenewPriceSourceSystem = "system"
|
||||||
|
RenewPriceSourceAgentDefault = "agentDefault"
|
||||||
|
RenewPriceSourceTarget = "target"
|
||||||
|
)
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
package consts
|
||||||
|
|
||||||
|
import "strings"
|
||||||
|
|
||||||
|
const (
|
||||||
|
BillingMethodMonth = 1
|
||||||
|
BillingMethodYear = 2
|
||||||
|
|
||||||
|
RenewDurationUnitMonth = "month"
|
||||||
|
RenewDurationUnitYear = "year"
|
||||||
|
)
|
||||||
|
|
||||||
|
func BillingMethodDurationUnit(billingMethod int) string {
|
||||||
|
if billingMethod == BillingMethodMonth {
|
||||||
|
return RenewDurationUnitMonth
|
||||||
|
}
|
||||||
|
return RenewDurationUnitYear
|
||||||
|
}
|
||||||
|
|
||||||
|
func NormalizeRenewDurationUnit(unit string) string {
|
||||||
|
if strings.EqualFold(strings.TrimSpace(unit), RenewDurationUnitMonth) {
|
||||||
|
return RenewDurationUnitMonth
|
||||||
|
}
|
||||||
|
return RenewDurationUnitYear
|
||||||
|
}
|
||||||
|
|
||||||
|
func RenewDurationUnitText(unit string) string {
|
||||||
|
if NormalizeRenewDurationUnit(unit) == RenewDurationUnitMonth {
|
||||||
|
return "个月"
|
||||||
|
}
|
||||||
|
return "年"
|
||||||
|
}
|
||||||
|
|
||||||
|
func DurationValueToMonths(unit string, value int) int {
|
||||||
|
if value <= 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
if NormalizeRenewDurationUnit(unit) == RenewDurationUnitMonth {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
return value * 12
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
package consts
|
||||||
|
|
||||||
|
const (
|
||||||
|
QueueTrafficUsageDailyRetry = "iotlcm.traffic_usage.daily.retry"
|
||||||
|
QueueTrafficUsageMonthlyRetry = "iotlcm.traffic_usage.monthly.retry"
|
||||||
|
QueueTrafficUsageMonthlyStart = "iotlcm.traffic_usage.monthly.start"
|
||||||
|
// QueueTrafficUsageDailyStart carries one command per full daily usage run.
|
||||||
|
// Admin publishes it; Worker owns the card scan and Redis production.
|
||||||
|
QueueTrafficUsageDailyStart = "iotlcm.traffic_usage.daily.start"
|
||||||
|
// QueueTrafficUsageMonthlyHistoryStart carries one command per full monthly
|
||||||
|
// usage history run, which writes the TDengine monthly table. It is distinct
|
||||||
|
// from QueueTrafficUsageMonthlyStart, which fills the MySQL current-month
|
||||||
|
// snapshot.
|
||||||
|
QueueTrafficUsageMonthlyHistoryStart = "iotlcm.traffic_usage.monthly_history.start"
|
||||||
|
QueueSimStatusQueueStart = "iotlcm.sim_status.queue.start"
|
||||||
|
QueueSimStatusQueueTask = "iotlcm.sim_status.queue.task"
|
||||||
|
QueueImeiQueueStart = "iotlcm.imei.queue.start"
|
||||||
|
QueueWorkOrderAutoClose = "iotlcm.work_order.auto_close"
|
||||||
|
QueueBalanceRechargeTimeout = "iotlcm.balance.recharge.timeout"
|
||||||
|
QueueBalanceRechargeDelay = "iotlcm.balance.recharge.delay"
|
||||||
|
QueueWorkerDiagnostic = "iotlcm.worker.diagnostic"
|
||||||
|
QueueBatchExecute = "iotlcm.batch.execute"
|
||||||
|
QueueBatchExecuteRetry = "iotlcm.batch.execute.retry"
|
||||||
|
QueueBatchExecuteDeadLetter = "iotlcm.batch.execute.dlq"
|
||||||
|
QueueBatchRestartResumeDelay = "iotlcm.batch.restart.resume.delay"
|
||||||
|
QueuePackageCustomerSync = "iotlcm.package_customer.sync"
|
||||||
|
QueueChannelRefresh = "iotlcm.channel.refresh"
|
||||||
|
// QueueDataReviewQuery 承载"手动同步上游结果"命令:Admin 发布父批号,
|
||||||
|
// Worker 只对既有 operationId 做只读补查并回写,绝不重发办理。
|
||||||
|
QueueDataReviewQuery = "iotlcm.data.review.query"
|
||||||
|
// QueueBalanceRechargeDeadLetter 保存重试次数耗尽后仍失败的充值超时消息,
|
||||||
|
// 供人工排查(例如支付宝凭据未配置导致的永久失败),不再自动重投。
|
||||||
|
QueueBalanceRechargeDeadLetter = "iotlcm.balance.recharge.dlq"
|
||||||
|
)
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
package consts
|
||||||
|
|
||||||
|
const (
|
||||||
|
DefaultSystemTenantCode = "000000"
|
||||||
|
SuperAdminRoleId = int64(1)
|
||||||
|
SysRoleMenuDataScopeRole = "0"
|
||||||
|
SysRoleDataScopeAll = "1"
|
||||||
|
SysRoleDataScopeCustom = "2"
|
||||||
|
SysRoleDataScopeDeptAndBelow = "4"
|
||||||
|
QueueAgentRoleSync = "iotlcm.agent.role.sync"
|
||||||
|
)
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
package rabbitmq
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gogf/gf/v2/errors/gerror"
|
||||||
|
"github.com/gogf/gf/v2/frame/g"
|
||||||
|
|
||||||
|
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/consts"
|
||||||
|
mq "git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/library/rabbitmq"
|
||||||
|
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
// defaultBalanceRechargeMaxRetry 是充值超时消息的默认最大重试次数。
|
||||||
|
// 支付宝凭据未配置等永久性失败会让 CheckRechargeTimeout 一直返回错误,
|
||||||
|
// 必须设置上限,否则消息会无限重投并刷屏日志。
|
||||||
|
const defaultBalanceRechargeMaxRetry = 100
|
||||||
|
|
||||||
|
// 包级变量便于测试注入发布行为,与 trafficUsagePublish 保持一致。
|
||||||
|
var balanceRechargePublish = mq.Publish
|
||||||
|
var balanceRechargePublishDelayed = mq.PublishDelayed
|
||||||
|
|
||||||
|
func (c *Consumers) initBalanceRechargeTimeout(ctx context.Context) error {
|
||||||
|
if !mq.Enabled(ctx) {
|
||||||
|
g.Log().Info(ctx, "balance recharge timeout consumer skipped, RabbitMQ disabled")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if c.balance == nil {
|
||||||
|
return gerror.New("balance recharge handler is not configured")
|
||||||
|
}
|
||||||
|
retryDelay, err := getBalanceRechargeRetryDelay(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
maxRetry, err := getBalanceRechargeMaxRetry(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
c.balanceRechargeRetryDelay = retryDelay
|
||||||
|
c.balanceRechargeMaxRetry = maxRetry
|
||||||
|
if err := mq.DeclareQueue(ctx, consts.QueueBalanceRechargeDeadLetter); err != nil {
|
||||||
|
return gerror.Wrap(err, "declare balance recharge dead-letter queue failed")
|
||||||
|
}
|
||||||
|
if err := mq.ConsumeJSON[model.BalanceRechargeTimeoutMessage](
|
||||||
|
ctx,
|
||||||
|
consts.QueueBalanceRechargeTimeout,
|
||||||
|
"iotlcm-balance-recharge-timeout",
|
||||||
|
c.handleBalanceRechargeTimeout,
|
||||||
|
); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
g.Log().Infof(ctx, "balance recharge timeout consumer started, queue:%s, retryDelay:%s, maxRetry:%d", consts.QueueBalanceRechargeTimeout, retryDelay, maxRetry)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Consumers) handleBalanceRechargeTimeout(ctx context.Context, message model.BalanceRechargeTimeoutMessage) error {
|
||||||
|
if message.OutTradeNo == "" {
|
||||||
|
g.Log().Errorf(ctx, "balance recharge timeout message dropped, invalid message:%+v", message)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if c.balance == nil {
|
||||||
|
return gerror.New("balance recharge handler is not configured")
|
||||||
|
}
|
||||||
|
if err := c.balance.CheckRechargeTimeout(ctx, message.OutTradeNo); err != nil {
|
||||||
|
message.RetryCount++
|
||||||
|
if shouldDeadLetterBalanceRecharge(message.RetryCount, c.balanceRechargeMaxRetry) {
|
||||||
|
if publishErr := balanceRechargePublish(ctx, consts.QueueBalanceRechargeDeadLetter, message); publishErr != nil {
|
||||||
|
return gerror.Wrap(publishErr, "publish balance recharge dead-letter message failed")
|
||||||
|
}
|
||||||
|
g.Log().Errorf(ctx, "balance recharge timeout retries exhausted and message sent to dead-letter queue, outTradeNo:%s, retryCount:%d, maxRetry:%d, err:%+v", message.OutTradeNo, message.RetryCount, c.balanceRechargeMaxRetry, err)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if retryErr := balanceRechargePublishDelayed(ctx, consts.QueueBalanceRechargeDelay, consts.QueueBalanceRechargeTimeout, c.balanceRechargeRetryDelay, message); retryErr != nil {
|
||||||
|
return gerror.Wrap(retryErr, "republish balance recharge timeout message failed")
|
||||||
|
}
|
||||||
|
g.Log().Warningf(ctx, "balance recharge timeout query failed and will retry, outTradeNo:%s, retryCount:%d, err:%+v", message.OutTradeNo, message.RetryCount, err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// shouldDeadLetterBalanceRecharge 判断本次失败后是否放弃重试。
|
||||||
|
// retryCount 是自增后的重试次数,因此 maxRetry 表示最多重投多少次。
|
||||||
|
func shouldDeadLetterBalanceRecharge(retryCount, maxRetry int) bool {
|
||||||
|
return retryCount > maxRetry
|
||||||
|
}
|
||||||
|
|
||||||
|
func getBalanceRechargeRetryDelay(ctx context.Context) (time.Duration, error) {
|
||||||
|
value := g.Cfg().MustGet(ctx, "rabbitmq.balanceRecharge.retryDelay", "1m").String()
|
||||||
|
delay, err := time.ParseDuration(value)
|
||||||
|
if err != nil || delay <= 0 {
|
||||||
|
return 0, gerror.Newf("balance recharge retry delay is invalid: %q", value)
|
||||||
|
}
|
||||||
|
return delay, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func getBalanceRechargeMaxRetry(ctx context.Context) (int, error) {
|
||||||
|
maxRetry := g.Cfg().MustGet(ctx, "rabbitmq.balanceRecharge.maxRetry", defaultBalanceRechargeMaxRetry).Int()
|
||||||
|
if maxRetry <= 0 {
|
||||||
|
return 0, gerror.Newf("balance recharge max retry is invalid: %d", maxRetry)
|
||||||
|
}
|
||||||
|
return maxRetry, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,153 @@
|
|||||||
|
package rabbitmq
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gogf/gf/v2/frame/g"
|
||||||
|
"github.com/gogf/gf/v2/os/gcfg"
|
||||||
|
|
||||||
|
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/consts"
|
||||||
|
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
func stubBalanceRechargePublish(t *testing.T) {
|
||||||
|
t.Helper()
|
||||||
|
originalPublish, originalDelayed := balanceRechargePublish, balanceRechargePublishDelayed
|
||||||
|
t.Cleanup(func() {
|
||||||
|
balanceRechargePublish, balanceRechargePublishDelayed = originalPublish, originalDelayed
|
||||||
|
})
|
||||||
|
balanceRechargePublish = func(context.Context, string, any) error { return nil }
|
||||||
|
balanceRechargePublishDelayed = func(context.Context, string, string, time.Duration, any) error { return nil }
|
||||||
|
}
|
||||||
|
|
||||||
|
func setBalanceRechargeTestConfig(t *testing.T, content string) {
|
||||||
|
t.Helper()
|
||||||
|
oldAdapter := g.Cfg().GetAdapter()
|
||||||
|
adapter, err := gcfg.NewAdapterContent(content)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
g.Cfg().SetAdapter(adapter)
|
||||||
|
t.Cleanup(func() { g.Cfg().SetAdapter(oldAdapter) })
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleBalanceRechargeTimeoutRepublishesDelayedBelowMaxRetry(t *testing.T) {
|
||||||
|
stubBalanceRechargePublish(t)
|
||||||
|
consumers := New(&trafficUsageHandlerStub{}, &workOrderHandlerStub{}, &balanceRechargeHandlerStub{err: errors.New("Alipay credentials are not configured")})
|
||||||
|
consumers.balanceRechargeRetryDelay = time.Minute
|
||||||
|
consumers.balanceRechargeMaxRetry = 3
|
||||||
|
|
||||||
|
delayedCalls := 0
|
||||||
|
balanceRechargePublishDelayed = func(_ context.Context, delayQueue, targetQueue string, delay time.Duration, data any) error {
|
||||||
|
delayedCalls++
|
||||||
|
if delayQueue != consts.QueueBalanceRechargeDelay || targetQueue != consts.QueueBalanceRechargeTimeout || delay != time.Minute {
|
||||||
|
t.Fatalf("unexpected delayed publish: %s -> %s, delay:%s", delayQueue, targetQueue, delay)
|
||||||
|
}
|
||||||
|
message, ok := data.(model.BalanceRechargeTimeoutMessage)
|
||||||
|
if !ok || message.OutTradeNo != "ALI-1" || message.RetryCount != 3 {
|
||||||
|
t.Fatalf("unexpected retry payload: %+v", data)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
deadLetterCalls := 0
|
||||||
|
balanceRechargePublish = func(_ context.Context, queue string, _ any) error {
|
||||||
|
deadLetterCalls++
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// 自增后达到上限仍未超过,应继续重投延迟队列。
|
||||||
|
if err := consumers.handleBalanceRechargeTimeout(context.Background(), model.BalanceRechargeTimeoutMessage{OutTradeNo: "ALI-1", RetryCount: 2}); err != nil {
|
||||||
|
t.Fatalf("retry below cap returned error: %v", err)
|
||||||
|
}
|
||||||
|
if delayedCalls != 1 || deadLetterCalls != 0 {
|
||||||
|
t.Fatalf("delayedCalls=%d deadLetterCalls=%d, want 1 and 0", delayedCalls, deadLetterCalls)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleBalanceRechargeTimeoutDeadLettersAboveMaxRetry(t *testing.T) {
|
||||||
|
stubBalanceRechargePublish(t)
|
||||||
|
consumers := New(&trafficUsageHandlerStub{}, &workOrderHandlerStub{}, &balanceRechargeHandlerStub{err: errors.New("Alipay credentials are not configured")})
|
||||||
|
consumers.balanceRechargeRetryDelay = time.Minute
|
||||||
|
consumers.balanceRechargeMaxRetry = 3
|
||||||
|
|
||||||
|
delayedCalls := 0
|
||||||
|
balanceRechargePublishDelayed = func(context.Context, string, string, time.Duration, any) error {
|
||||||
|
delayedCalls++
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
deadLetterCalls := 0
|
||||||
|
balanceRechargePublish = func(_ context.Context, queue string, data any) error {
|
||||||
|
deadLetterCalls++
|
||||||
|
if queue != consts.QueueBalanceRechargeDeadLetter {
|
||||||
|
t.Fatalf("dead letter queue = %s, want %s", queue, consts.QueueBalanceRechargeDeadLetter)
|
||||||
|
}
|
||||||
|
message, ok := data.(model.BalanceRechargeTimeoutMessage)
|
||||||
|
if !ok || message.OutTradeNo != "ALI-1" || message.RetryCount != 4 {
|
||||||
|
t.Fatalf("unexpected dead-letter payload: %+v", data)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := consumers.handleBalanceRechargeTimeout(context.Background(), model.BalanceRechargeTimeoutMessage{OutTradeNo: "ALI-1", RetryCount: 3}); err != nil {
|
||||||
|
t.Fatalf("dead-letter path returned error: %v", err)
|
||||||
|
}
|
||||||
|
if delayedCalls != 0 || deadLetterCalls != 1 {
|
||||||
|
t.Fatalf("delayedCalls=%d deadLetterCalls=%d, want 0 and 1", delayedCalls, deadLetterCalls)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleBalanceRechargeTimeoutReturnsPublishErrors(t *testing.T) {
|
||||||
|
stubBalanceRechargePublish(t)
|
||||||
|
consumers := New(&trafficUsageHandlerStub{}, &workOrderHandlerStub{}, &balanceRechargeHandlerStub{err: errors.New("Alipay credentials are not configured")})
|
||||||
|
consumers.balanceRechargeRetryDelay = time.Minute
|
||||||
|
consumers.balanceRechargeMaxRetry = 3
|
||||||
|
|
||||||
|
expected := errors.New("broker unavailable")
|
||||||
|
balanceRechargePublish = func(context.Context, string, any) error { return expected }
|
||||||
|
balanceRechargePublishDelayed = func(context.Context, string, string, time.Duration, any) error { return expected }
|
||||||
|
|
||||||
|
// 重投失败必须向上返回错误,让消费框架 Nack 重新入队,不能静默丢失消息。
|
||||||
|
if err := consumers.handleBalanceRechargeTimeout(context.Background(), model.BalanceRechargeTimeoutMessage{OutTradeNo: "ALI-1", RetryCount: 0}); !errors.Is(err, expected) {
|
||||||
|
t.Fatalf("delayed publish failure = %v, want %v", err, expected)
|
||||||
|
}
|
||||||
|
if err := consumers.handleBalanceRechargeTimeout(context.Background(), model.BalanceRechargeTimeoutMessage{OutTradeNo: "ALI-1", RetryCount: 3}); !errors.Is(err, expected) {
|
||||||
|
t.Fatalf("dead-letter publish failure = %v, want %v", err, expected)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetBalanceRechargeMaxRetry(t *testing.T) {
|
||||||
|
t.Run("defaults when unconfigured", func(t *testing.T) {
|
||||||
|
setBalanceRechargeTestConfig(t, "rabbitmq:\n balanceRecharge:\n retryDelay: \"1m\"\n")
|
||||||
|
maxRetry, err := getBalanceRechargeMaxRetry(context.Background())
|
||||||
|
if err != nil || maxRetry != defaultBalanceRechargeMaxRetry {
|
||||||
|
t.Fatalf("maxRetry = %d (err=%v), want %d", maxRetry, err, defaultBalanceRechargeMaxRetry)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("reads configured value", func(t *testing.T) {
|
||||||
|
setBalanceRechargeTestConfig(t, "rabbitmq:\n balanceRecharge:\n maxRetry: 7\n")
|
||||||
|
maxRetry, err := getBalanceRechargeMaxRetry(context.Background())
|
||||||
|
if err != nil || maxRetry != 7 {
|
||||||
|
t.Fatalf("maxRetry = %d (err=%v), want 7", maxRetry, err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
for _, test := range []struct {
|
||||||
|
name string
|
||||||
|
value string
|
||||||
|
}{
|
||||||
|
{name: "zero", value: "0"},
|
||||||
|
{name: "negative", value: "-1"},
|
||||||
|
{name: "non-numeric", value: "\"abc\""},
|
||||||
|
} {
|
||||||
|
t.Run("rejects "+test.name, func(t *testing.T) {
|
||||||
|
setBalanceRechargeTestConfig(t, "rabbitmq:\n balanceRecharge:\n maxRetry: "+test.value+"\n")
|
||||||
|
if _, err := getBalanceRechargeMaxRetry(context.Background()); err == nil {
|
||||||
|
t.Fatalf("maxRetry=%s was accepted, want error", test.value)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,178 @@
|
|||||||
|
package rabbitmq
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"strconv"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gogf/gf/v2/errors/gerror"
|
||||||
|
"github.com/gogf/gf/v2/frame/g"
|
||||||
|
amqp "github.com/rabbitmq/amqp091-go"
|
||||||
|
|
||||||
|
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/batch"
|
||||||
|
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/consts"
|
||||||
|
mq "git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/library/rabbitmq"
|
||||||
|
)
|
||||||
|
|
||||||
|
const batchRetryCountHeader = "x-iotlcm-retry-count"
|
||||||
|
|
||||||
|
func (c *Consumers) initBatchExecute(ctx context.Context) error {
|
||||||
|
if !mq.Enabled(ctx) {
|
||||||
|
g.Log().Info(ctx, "batch execute consumer skipped, RabbitMQ disabled")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if c.batch == nil {
|
||||||
|
return gerror.New("batch execute handler is not configured")
|
||||||
|
}
|
||||||
|
retryDelay, maxRetries, err := getBatchExecuteRetryConfig(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
c.batchExecuteRetryDelay = retryDelay
|
||||||
|
c.batchExecuteMaxRetries = maxRetries
|
||||||
|
if err = mq.DeclareQueue(ctx, consts.QueueBatchExecuteDeadLetter); err != nil {
|
||||||
|
return gerror.Wrap(err, "declare batch execute dead-letter queue failed")
|
||||||
|
}
|
||||||
|
if err = mq.ConsumeJSON[batch.Message](
|
||||||
|
ctx,
|
||||||
|
consts.QueueBatchExecute,
|
||||||
|
"iotlcm-batch-execute",
|
||||||
|
c.handleBatchExecute,
|
||||||
|
); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
g.Log().Infof(ctx, "batch execute consumer started, queue:%s, retryDelay:%s, maxRetries:%d", consts.QueueBatchExecute, retryDelay, maxRetries)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Consumers) handleBatchExecute(ctx context.Context, message batch.Message) error {
|
||||||
|
if c.batch == nil {
|
||||||
|
return gerror.New("batch execute handler is not configured")
|
||||||
|
}
|
||||||
|
g.Log().Infof(ctx, "batch execute message received, batchNo:%s, batchType:%d", message.BatchNo, message.BatchType)
|
||||||
|
if err := c.batch.Handle(ctx, message); err != nil {
|
||||||
|
if _, delivered := mq.DeliveryMetadataFromContext(ctx); !delivered {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if batch.IsRetryable(err) {
|
||||||
|
message.Retryable = true
|
||||||
|
}
|
||||||
|
var failureMarkErr *batch.FailureMarkError
|
||||||
|
if errors.As(err, &failureMarkErr) {
|
||||||
|
message.FailureReason = failureMarkErr.Reason
|
||||||
|
}
|
||||||
|
return c.retryOrDeadLetterBatchMessage(ctx, message, err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Consumers) retryOrDeadLetterBatchMessage(ctx context.Context, message batch.Message, handlerErr error) error {
|
||||||
|
metadata, _ := mq.DeliveryMetadataFromContext(ctx)
|
||||||
|
retryCount := batchRetryCount(metadata.Headers)
|
||||||
|
headers := amqp.Table{batchRetryCountHeader: retryCount + 1}
|
||||||
|
if shouldDeadLetterBatch(retryCount, c.batchExecuteMaxRetries) {
|
||||||
|
if message.Retryable {
|
||||||
|
finalizationMessage := message
|
||||||
|
finalizationMessage.Retryable = false
|
||||||
|
finalizationMessage.FailureReason = handlerErr.Error()
|
||||||
|
if err := c.batch.Handle(ctx, finalizationMessage); err != nil {
|
||||||
|
return gerror.Wrap(err, "finalize retryable batch failure before dead-lettering")
|
||||||
|
}
|
||||||
|
message.FailureReason = handlerErr.Error()
|
||||||
|
}
|
||||||
|
deadLetterBody, marshalErr := json.Marshal(batchDeadLetterMessage{
|
||||||
|
Message: message,
|
||||||
|
RetryCount: retryCount,
|
||||||
|
Error: handlerErr.Error(),
|
||||||
|
FailedAt: time.Now().Format(time.RFC3339),
|
||||||
|
})
|
||||||
|
if marshalErr != nil {
|
||||||
|
return gerror.Wrap(marshalErr, "marshal batch execute dead-letter message failed")
|
||||||
|
}
|
||||||
|
if err := mq.PublishBytesWithHeaders(ctx, consts.QueueBatchExecuteDeadLetter, deadLetterBody, nil); err != nil {
|
||||||
|
return gerror.Wrap(err, "publish batch execute dead-letter message failed")
|
||||||
|
}
|
||||||
|
g.Log().Errorf(ctx, "batch execute message sent to dead-letter queue, batchNo:%s, batchType:%d, retryCount:%d, err:%+v", message.BatchNo, message.BatchType, retryCount, handlerErr)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
body, marshalErr := json.Marshal(message)
|
||||||
|
if marshalErr != nil {
|
||||||
|
return gerror.Wrap(marshalErr, "marshal batch execute retry message failed")
|
||||||
|
}
|
||||||
|
if err := mq.PublishDelayedBytesWithHeaders(
|
||||||
|
ctx,
|
||||||
|
consts.QueueBatchExecuteRetry,
|
||||||
|
consts.QueueBatchExecute,
|
||||||
|
c.batchExecuteRetryDelay,
|
||||||
|
body,
|
||||||
|
headers,
|
||||||
|
); err != nil {
|
||||||
|
return gerror.Wrap(err, "republish batch execute retry message failed")
|
||||||
|
}
|
||||||
|
g.Log().Warningf(ctx, "batch execute message scheduled for retry, batchNo:%s, batchType:%d, retryCount:%d, delay:%s, err:%+v", message.BatchNo, message.BatchType, retryCount+1, c.batchExecuteRetryDelay, handlerErr)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type batchDeadLetterMessage struct {
|
||||||
|
Message batch.Message `json:"message"`
|
||||||
|
RetryCount int `json:"retryCount"`
|
||||||
|
Error string `json:"error"`
|
||||||
|
FailedAt string `json:"failedAt"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func shouldDeadLetterBatch(retryCount, maxRetries int) bool {
|
||||||
|
return retryCount >= maxRetries
|
||||||
|
}
|
||||||
|
|
||||||
|
func batchRetryCount(headers map[string]any) int {
|
||||||
|
if headers == nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
value, ok := headers[batchRetryCountHeader]
|
||||||
|
if !ok {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
switch count := value.(type) {
|
||||||
|
case int:
|
||||||
|
return max(count, 0)
|
||||||
|
case int8:
|
||||||
|
return max(int(count), 0)
|
||||||
|
case int16:
|
||||||
|
return max(int(count), 0)
|
||||||
|
case int32:
|
||||||
|
return max(int(count), 0)
|
||||||
|
case int64:
|
||||||
|
return max(int(count), 0)
|
||||||
|
case uint8:
|
||||||
|
return int(count)
|
||||||
|
case uint16:
|
||||||
|
return int(count)
|
||||||
|
case uint32:
|
||||||
|
return int(count)
|
||||||
|
case uint64:
|
||||||
|
if count > uint64(^uint(0)>>1) {
|
||||||
|
return int(^uint(0) >> 1)
|
||||||
|
}
|
||||||
|
return int(count)
|
||||||
|
case string:
|
||||||
|
parsed, err := strconv.Atoi(count)
|
||||||
|
if err == nil {
|
||||||
|
return max(parsed, 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func getBatchExecuteRetryConfig(ctx context.Context) (time.Duration, int, error) {
|
||||||
|
delay := g.Cfg().MustGet(ctx, "rabbitmq.batchExecute.retryDelay", "30s").Duration()
|
||||||
|
if delay <= 0 {
|
||||||
|
return 0, 0, gerror.New("batch execute retry delay must be positive")
|
||||||
|
}
|
||||||
|
maxRetries := g.Cfg().MustGet(ctx, "rabbitmq.batchExecute.maxRetries", 3).Int()
|
||||||
|
if maxRetries < 0 {
|
||||||
|
return 0, 0, gerror.New("batch execute max retries cannot be negative")
|
||||||
|
}
|
||||||
|
return delay, maxRetries, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,247 @@
|
|||||||
|
package rabbitmq
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
_ "github.com/gogf/gf/contrib/drivers/sqlite/v2"
|
||||||
|
"github.com/gogf/gf/v2/database/gdb"
|
||||||
|
"github.com/gogf/gf/v2/frame/g"
|
||||||
|
|
||||||
|
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/batch"
|
||||||
|
mq "git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/library/rabbitmq"
|
||||||
|
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/model"
|
||||||
|
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/model/entity"
|
||||||
|
batchrepo "git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/repository/batch"
|
||||||
|
datarepo "git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/repository/data"
|
||||||
|
dataservice "git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/service/data"
|
||||||
|
)
|
||||||
|
|
||||||
|
type durableBatchRepo struct{ failed int }
|
||||||
|
|
||||||
|
func (*durableBatchRepo) FindMain(context.Context, string) (*entity.BatchMain, error) {
|
||||||
|
return &entity.BatchMain{BatchNo: "APN-RECOVER", BatchType: 109, BatchAsyncStatus: batch.AsyncStatusPending}, nil
|
||||||
|
}
|
||||||
|
func (*durableBatchRepo) MarkProcessing(context.Context, string, int) (bool, error) { return true, nil }
|
||||||
|
func (r *durableBatchRepo) MarkFailed(context.Context, string, int, string) (bool, error) {
|
||||||
|
r.failed++
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
func (*durableBatchRepo) MarkSuccess(context.Context, string, int, int, int, string) (bool, error) {
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type durableBatchExecutor struct {
|
||||||
|
calls, protections int
|
||||||
|
retryable bool
|
||||||
|
protectionErr error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *durableBatchExecutor) Execute(context.Context, string) error {
|
||||||
|
e.calls++
|
||||||
|
if e.retryable {
|
||||||
|
return batch.Retryable(errors.New("准备中断"))
|
||||||
|
}
|
||||||
|
return errors.New("本地执行中断")
|
||||||
|
}
|
||||||
|
func (e *durableBatchExecutor) ProtectExecutionFailure(ctx context.Context, _ string, _ int, _ string) (bool, error) {
|
||||||
|
e.protections++
|
||||||
|
if ctx.Err() != nil {
|
||||||
|
return true, ctx.Err()
|
||||||
|
}
|
||||||
|
return true, e.protectionErr
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDurableAPNConsumerNeverFallsThroughToGenericFailure(t *testing.T) {
|
||||||
|
repo := &durableBatchRepo{}
|
||||||
|
executor := &durableBatchExecutor{}
|
||||||
|
dispatcher := batch.NewDispatcher(nil)
|
||||||
|
dispatcher.Register(109, executor)
|
||||||
|
consumers := &Consumers{batch: batch.NewRuntime(repo, dispatcher)}
|
||||||
|
if err := consumers.handleBatchExecute(context.Background(), batch.Message{BatchNo: "APN-RECOVER", BatchType: 109}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if executor.calls != 1 || executor.protections != 1 || repo.failed != 0 {
|
||||||
|
t.Fatalf("消费者普通错误越过durable保护: calls=%d protections=%d failed=%d", executor.calls, executor.protections, repo.failed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDurableAPNConsumerRetryExhaustionProtectsBeforePublish(t *testing.T) {
|
||||||
|
repo := &durableBatchRepo{}
|
||||||
|
dbDown := errors.New("模拟保护数据库不可用")
|
||||||
|
executor := &durableBatchExecutor{retryable: true, protectionErr: dbDown}
|
||||||
|
dispatcher := batch.NewDispatcher(nil)
|
||||||
|
dispatcher.Register(109, executor)
|
||||||
|
consumers := &Consumers{batch: batch.NewRuntime(repo, dispatcher), batchExecuteMaxRetries: 0}
|
||||||
|
ctx := mq.WithDeliveryMetadata(context.Background(), mq.DeliveryMetadata{Headers: map[string]any{batchRetryCountHeader: 3}})
|
||||||
|
err := consumers.handleBatchExecute(ctx, batch.Message{BatchNo: "APN-RECOVER", BatchType: 109})
|
||||||
|
// 保护失败必须在消息发布前返回,测试不会连接真实RabbitMQ。
|
||||||
|
if !errors.Is(err, dbDown) || executor.calls != 1 || executor.protections != 1 || repo.failed != 0 {
|
||||||
|
t.Fatalf("重试耗尽错误终结或重发: err=%v calls=%d protections=%d failed=%d", err, executor.calls, executor.protections, repo.failed)
|
||||||
|
}
|
||||||
|
executor.protectionErr = nil
|
||||||
|
if err := consumers.handleBatchExecute(context.Background(), batch.Message{BatchNo: "APN-RECOVER", BatchType: 109, FailureReason: "重试耗尽"}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if executor.calls != 1 || executor.protections != 2 || repo.failed != 0 {
|
||||||
|
t.Fatal("失败重投重新执行或MarkFailed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// reentryWriteCounter 同时覆盖同步写、异步提交与结果查询,重入准备期间均不得调用。
|
||||||
|
type reentryWriteCounter struct{ syncWrites, asyncWrites, queries int }
|
||||||
|
|
||||||
|
func (u *reentryWriteCounter) ChangeSimApnFunction(context.Context, *model.SimCardApnChangeInput) ([]model.SimBaseRes[model.SimCardApnChangeOutputItem], error) {
|
||||||
|
u.syncWrites++
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
func (u *reentryWriteCounter) SubmitSimApnFunction(context.Context, *model.SimCardApnChangeInput) ([]model.SimBaseRes[model.SimCardApnChangeOutputItem], error) {
|
||||||
|
u.asyncWrites++
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
func (u *reentryWriteCounter) QuerySimApnFunctionResult(context.Context, *model.SimCardApnChangeInput) ([]model.SimBaseRes[model.SimCardApnChangeOutputItem], error) {
|
||||||
|
u.queries++
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAPNProcessingRabbitMQReentryRequiresDurableParent(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
if err := gdb.SetConfig(gdb.Config{"default": gdb.ConfigGroup{{Type: "sqlite", Link: "sqlite::@file(" + filepath.ToSlash(filepath.Join(t.TempDir(), "apn-reentry.db")) + ")"}}}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
db := g.DB()
|
||||||
|
t.Cleanup(func() {
|
||||||
|
if err := db.Close(ctx); err != nil {
|
||||||
|
t.Error(err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
for _, sql := range []string{
|
||||||
|
"CREATE TABLE batch_main (id INTEGER PRIMARY KEY,batch_no TEXT UNIQUE,batch_type INTEGER,batch_async_status INTEGER,tenant_id TEXT,start_time DATETIME,batch_info TEXT,run_result INTEGER DEFAULT 0,valid_count INTEGER DEFAULT 0,success_count INTEGER DEFAULT 0,fail_count INTEGER DEFAULT 0,fail_reason TEXT,end_time DATETIME,updated_at DATETIME,created_by INTEGER,created_dept INTEGER,updated_by INTEGER)",
|
||||||
|
"CREATE TABLE batch_detail (id INTEGER PRIMARY KEY AUTOINCREMENT,batch_no TEXT,batch_type INTEGER,row_no INTEGER,item_id TEXT,detail_status INTEGER,error_reason TEXT,snapshot TEXT,updated_by INTEGER)",
|
||||||
|
"CREATE TABLE t_sim_card_info (id INTEGER PRIMARY KEY AUTOINCREMENT,tenant_id TEXT,iccid TEXT,msisdn TEXT,imsi TEXT,chan_key TEXT,data_service_status INTEGER)",
|
||||||
|
"CREATE TABLE data_operation_parent (batch_no TEXT PRIMARY KEY,ready INTEGER DEFAULT 0,finalize_token TEXT,finalize_until DATETIME,recovery_attempt_at DATETIME,last_reason TEXT)",
|
||||||
|
"CREATE TABLE data_operation_sub_batch (id INTEGER PRIMARY KEY AUTOINCREMENT,batch_no TEXT,batch_type INTEGER,chan_key TEXT,operation_id TEXT UNIQUE,detail_ids TEXT,request_data TEXT,job_id TEXT,status TEXT,poll_count INTEGER DEFAULT 0,next_check_at DATETIME,submitted_at DATETIME,lease_token TEXT,lease_until DATETIME,created_at DATETIME DEFAULT CURRENT_TIMESTAMP,last_reason TEXT)",
|
||||||
|
"CREATE TABLE data_operation_sub_detail (detail_id INTEGER PRIMARY KEY,operation_id TEXT,batch_no TEXT,iccid TEXT,UNIQUE(batch_no,iccid))",
|
||||||
|
"CREATE TABLE data_operation_card_claim (tenant_id TEXT,iccid TEXT,batch_id INTEGER,operation_id TEXT,active_batch_id INTEGER DEFAULT 0,active_operation_id TEXT DEFAULT '',PRIMARY KEY(tenant_id,iccid))",
|
||||||
|
} {
|
||||||
|
if _, err := db.Exec(ctx, sql); err != nil {
|
||||||
|
t.Fatalf("初始化本机 SQLite fixture: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, tc := range []struct {
|
||||||
|
name string
|
||||||
|
old, retryable bool
|
||||||
|
}{
|
||||||
|
{name: "legacy-ordinary", old: true},
|
||||||
|
{name: "legacy-retryable", old: true, retryable: true},
|
||||||
|
{name: "durable-ordinary"},
|
||||||
|
{name: "durable-retryable", retryable: true},
|
||||||
|
} {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
status := batch.AsyncStatusPending
|
||||||
|
if tc.old {
|
||||||
|
status = batch.AsyncStatusProcessing
|
||||||
|
}
|
||||||
|
// 使用 NULL 旧首发时间以确保 ReclaimProcessing 的 WHERE start_time IS NULL 可以命中。
|
||||||
|
if _, err := db.Exec(ctx, "INSERT INTO batch_main(batch_no,batch_type,batch_async_status,tenant_id) VALUES (?,109,?,'tenant')", tc.name, status); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for i := 0; i < 10; i++ {
|
||||||
|
iccid := tc.name + "-card-" + string(rune('0'+i))
|
||||||
|
detailStatus := batch.DetailStatusPending
|
||||||
|
if tc.old {
|
||||||
|
detailStatus = batch.DetailStatusRunning
|
||||||
|
}
|
||||||
|
if _, err := db.Exec(ctx, "INSERT INTO batch_detail(batch_no,batch_type,row_no,item_id,detail_status,snapshot) VALUES (?,109,?,?,?,?)", tc.name, i+1, iccid, detailStatus, `{"authorized":true}`); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := db.Exec(ctx, "INSERT INTO t_sim_card_info(tenant_id,iccid,msisdn,imsi,chan_key,data_service_status) VALUES ('tenant',?,?,?,'mock-apn',0)", iccid, "130000000"+string(rune('0'+i)), "460000000"+string(rune('0'+i))); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
mainRepo := batchrepo.New()
|
||||||
|
if tc.old && tc.retryable {
|
||||||
|
// 先证实同一 fixture 的旧批确实可被重领,再复位空首发时间。
|
||||||
|
claimed, err := mainRepo.ReclaimProcessing(ctx, tc.name, 109, nil)
|
||||||
|
if err != nil || !claimed {
|
||||||
|
t.Fatalf("重领 CAS 必须可命中,避免假阳性: claimed=%t err=%v", claimed, err)
|
||||||
|
}
|
||||||
|
if _, err := db.Exec(ctx, "UPDATE batch_main SET start_time=NULL WHERE batch_no=?", tc.name); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !tc.old {
|
||||||
|
claimed, err := mainRepo.MarkProcessing(ctx, tc.name, 109)
|
||||||
|
if err != nil || !claimed {
|
||||||
|
t.Fatalf("新批需同事务领取 Processing 与 ready=0 父凭据: claimed=%t err=%v", claimed, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
parent, err := db.GetValue(ctx, "SELECT COUNT(*) FROM data_operation_parent WHERE batch_no=? AND ready=0", tc.name)
|
||||||
|
if err != nil || parent.Int() != 1 && !tc.old || parent.Int() != 0 && tc.old {
|
||||||
|
t.Fatalf("前置凭据不符: old=%t ready0=%d err=%v", tc.old, parent.Int(), err)
|
||||||
|
}
|
||||||
|
before, err := db.GetValue(ctx, "SELECT start_time FROM batch_main WHERE batch_no=?", tc.name)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
upstream := &reentryWriteCounter{}
|
||||||
|
dispatcher := batch.NewDispatcher(nil)
|
||||||
|
dispatcher.Register(109, dataservice.NewExecutor(datarepo.New(), upstream))
|
||||||
|
consumer := &Consumers{batch: batch.NewRuntime(mainRepo, dispatcher)}
|
||||||
|
err = consumer.handleBatchExecute(ctx, batch.Message{BatchNo: tc.name, BatchType: 109, Retryable: tc.retryable})
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("重入应安全跳过旧批或准备新批,不能进入消息重发路径: %v", err)
|
||||||
|
}
|
||||||
|
if upstream.syncWrites != 0 || upstream.asyncWrites != 0 || upstream.queries != 0 {
|
||||||
|
t.Errorf("重入意外调用上游: sync=%d submit=%d query=%d", upstream.syncWrites, upstream.asyncWrites, upstream.queries)
|
||||||
|
}
|
||||||
|
main, err := mainRepo.FindMain(ctx, tc.name)
|
||||||
|
if err != nil || main == nil || main.BatchAsyncStatus != batch.AsyncStatusProcessing {
|
||||||
|
t.Fatalf("批状态被意外终结: main=%+v err=%v", main, err)
|
||||||
|
}
|
||||||
|
remaining, err := db.GetValue(ctx, "SELECT COUNT(*) FROM batch_detail WHERE batch_no=? AND detail_status=1", tc.name)
|
||||||
|
if err != nil || remaining.Int() != 10 {
|
||||||
|
t.Fatalf("运行中明细不能被更改: count=%d err=%v", remaining.Int(), err)
|
||||||
|
}
|
||||||
|
intent, err := db.GetValue(ctx, "SELECT COUNT(*) FROM data_operation_sub_batch WHERE batch_no=? AND status='prepared'", tc.name)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if tc.old {
|
||||||
|
if intent.Int() != 0 {
|
||||||
|
t.Errorf("无凭据历史批不应产生新提交意图: %d", intent.Int())
|
||||||
|
}
|
||||||
|
assigned, err := db.GetValue(ctx, "SELECT COUNT(*) FROM data_operation_sub_detail WHERE batch_no=?", tc.name)
|
||||||
|
if err != nil || assigned.Int() != 0 {
|
||||||
|
t.Errorf("历史批不可冻结明细: count=%d err=%v", assigned.Int(), err)
|
||||||
|
}
|
||||||
|
claims, err := db.GetValue(ctx, "SELECT COUNT(*) FROM data_operation_card_claim WHERE operation_id!='' AND iccid LIKE ?", tc.name+"-%")
|
||||||
|
if err != nil || claims.Int() != 0 {
|
||||||
|
t.Errorf("历史批不可建立卡级提交占位: count=%d err=%v", claims.Int(), err)
|
||||||
|
}
|
||||||
|
parent, err = db.GetValue(ctx, "SELECT COUNT(*) FROM data_operation_parent WHERE batch_no=?", tc.name)
|
||||||
|
if err != nil || parent.Int() != 0 {
|
||||||
|
t.Errorf("历史批不可补造父凭据: count=%d err=%v", parent.Int(), err)
|
||||||
|
}
|
||||||
|
after, err := db.GetValue(ctx, "SELECT start_time FROM batch_main WHERE batch_no=?", tc.name)
|
||||||
|
if err != nil || after.String() != before.String() {
|
||||||
|
t.Errorf("旧批重入不可重领取: before=%s after=%s err=%v", before.String(), after.String(), err)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if intent.Int() != 1 {
|
||||||
|
t.Errorf("新批 ready=0 未正确恢复成 10 卡一个冻结意图: %d", intent.Int())
|
||||||
|
}
|
||||||
|
parent, err = db.GetValue(ctx, "SELECT COUNT(*) FROM data_operation_parent WHERE batch_no=? AND ready=1", tc.name)
|
||||||
|
if err != nil || parent.Int() != 1 {
|
||||||
|
t.Errorf("新批冻结意图与 ready=1 未原子提交: count=%d err=%v", parent.Int(), err)
|
||||||
|
}
|
||||||
|
assigned, err := db.GetValue(ctx, "SELECT COUNT(*) FROM data_operation_sub_detail WHERE batch_no=?", tc.name)
|
||||||
|
if err != nil || assigned.Int() != 10 {
|
||||||
|
t.Errorf("新批冻结明细必须覆盖全部 10 卡: count=%d err=%v", assigned.Int(), err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
package rabbitmq
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestShouldDeadLetterBatch(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
retryCount int
|
||||||
|
maxRetries int
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{name: "first attempt retries", retryCount: 0, maxRetries: 3, want: false},
|
||||||
|
{name: "second attempt retries", retryCount: 1, maxRetries: 3, want: false},
|
||||||
|
{name: "last configured retry retries", retryCount: 2, maxRetries: 3, want: false},
|
||||||
|
{name: "next failure dead letters", retryCount: 3, maxRetries: 3, want: true},
|
||||||
|
{name: "zero retries dead letters immediately", retryCount: 0, maxRetries: 0, want: true},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
if actual := shouldDeadLetterBatch(tt.retryCount, tt.maxRetries); actual != tt.want {
|
||||||
|
t.Fatalf("shouldDeadLetterBatch(%d, %d) = %t, want %t", tt.retryCount, tt.maxRetries, actual, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBatchRetryCount(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
headers map[string]any
|
||||||
|
want int
|
||||||
|
}{
|
||||||
|
{name: "missing", want: 0},
|
||||||
|
{name: "int", headers: map[string]any{batchRetryCountHeader: 2}, want: 2},
|
||||||
|
{name: "int64", headers: map[string]any{batchRetryCountHeader: int64(3)}, want: 3},
|
||||||
|
{name: "uint", headers: map[string]any{batchRetryCountHeader: uint16(4)}, want: 4},
|
||||||
|
{name: "string", headers: map[string]any{batchRetryCountHeader: "5"}, want: 5},
|
||||||
|
{name: "negative", headers: map[string]any{batchRetryCountHeader: -1}, want: 0},
|
||||||
|
{name: "invalid", headers: map[string]any{batchRetryCountHeader: "not-a-number"}, want: 0},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
if actual := batchRetryCount(tt.headers); actual != tt.want {
|
||||||
|
t.Fatalf("batchRetryCount() = %d, want %d", actual, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,243 @@
|
|||||||
|
package rabbitmq
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gogf/gf/v2/errors/gerror"
|
||||||
|
"github.com/gogf/gf/v2/frame/g"
|
||||||
|
|
||||||
|
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/consts"
|
||||||
|
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/library/rabbitmq"
|
||||||
|
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
const channelRefreshConsumer = "iotlcm-worker-channel-refresh"
|
||||||
|
|
||||||
|
type channelRefreshConfig struct {
|
||||||
|
Address string `json:"address"`
|
||||||
|
Timeout time.Duration `json:"timeout"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type channelRefreshResponse struct {
|
||||||
|
Code int `json:"code"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Consumers) initChannelRefresh(ctx context.Context) error {
|
||||||
|
if !rabbitmq.Enabled(ctx) {
|
||||||
|
g.Log().Info(ctx, "channel refresh consumer skipped, RabbitMQ disabled")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if err := rabbitmq.ConsumeJSON[model.ChannelRefreshMessage](
|
||||||
|
ctx,
|
||||||
|
consts.QueueChannelRefresh,
|
||||||
|
channelRefreshConsumer,
|
||||||
|
c.handleChannelRefresh,
|
||||||
|
); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
g.Log().Infof(ctx, "channel refresh consumer started, queue:%s", consts.QueueChannelRefresh)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Consumers) handleChannelRefresh(ctx context.Context, message model.ChannelRefreshMessage) error {
|
||||||
|
switch strings.ToLower(strings.TrimSpace(message.Type)) {
|
||||||
|
case "channel":
|
||||||
|
if strings.TrimSpace(message.ChanKey) == "" {
|
||||||
|
g.Log().Errorf(ctx, "channel refresh message dropped, chanKey is empty")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if err := refreshChannelWithRetry(ctx, message.ChanKey); err != nil {
|
||||||
|
g.Log().Errorf(ctx, "channel refresh failed, chanKey=%s, err=%+v", message.ChanKey, err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
case "template":
|
||||||
|
if strings.TrimSpace(message.TempKey) == "" {
|
||||||
|
g.Log().Errorf(ctx, "channel refresh message dropped, tempKey is empty")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return refreshTemplateChannels(ctx, message.TempKey)
|
||||||
|
case "channel_rename":
|
||||||
|
oldChanKey := strings.TrimSpace(message.OldChanKey)
|
||||||
|
newChanKey := strings.TrimSpace(message.NewChanKey)
|
||||||
|
if oldChanKey == "" || newChanKey == "" || oldChanKey == newChanKey {
|
||||||
|
g.Log().Errorf(ctx, "channel rename message dropped, oldChanKey=%q newChanKey=%q", oldChanKey, newChanKey)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if err := renameChannelWithRetry(ctx, oldChanKey, newChanKey); err != nil {
|
||||||
|
// 返回 error 让消费框架 Nack 并重新入队:rename 各步骤幂等,
|
||||||
|
// 重复投递安全;静默 Ack 会导致旧通道实例永久残留,只能重启恢复。
|
||||||
|
g.Log().Errorf(ctx, "channel rename failed after retries, will requeue, oldChanKey=%s newChanKey=%s err=%+v", oldChanKey, newChanKey, err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
default:
|
||||||
|
g.Log().Errorf(ctx, "channel refresh message dropped, unsupported type=%s", message.Type)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func renameChannelWithRetry(ctx context.Context, oldChanKey, newChanKey string) error {
|
||||||
|
var err error
|
||||||
|
for attempt := 1; attempt <= 3; attempt++ {
|
||||||
|
err = renameChannel(ctx, oldChanKey, newChanKey)
|
||||||
|
if err == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if attempt == 3 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
delay := time.Second
|
||||||
|
if attempt == 2 {
|
||||||
|
delay = 3 * time.Second
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return ctx.Err()
|
||||||
|
case <-time.After(delay):
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return gerror.Wrapf(err, "channel rename failed after %d attempts, oldChanKey=%s newChanKey=%s", 3, oldChanKey, newChanKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
// renameChannel is ordered so that the old runtime remains available until
|
||||||
|
// both upstreams have built the new instance. Card data and ICCID routes are
|
||||||
|
// intentionally left untouched for operations staff to migrate manually.
|
||||||
|
func renameChannel(ctx context.Context, oldChanKey, newChanKey string) error {
|
||||||
|
if err := refreshChannel(ctx, newChanKey); err != nil {
|
||||||
|
return gerror.Wrap(err, "refresh new channel failed")
|
||||||
|
}
|
||||||
|
if err := removeChannelTarget(ctx, "upstream.gateway", oldChanKey); err != nil {
|
||||||
|
return gerror.Wrap(err, "remove old gateway channel failed")
|
||||||
|
}
|
||||||
|
if err := removeChannelTarget(ctx, "upstream.proxy", oldChanKey); err != nil {
|
||||||
|
return gerror.Wrap(err, "remove old proxy channel failed")
|
||||||
|
}
|
||||||
|
g.Log().Infof(ctx, "channel instance rebuilt after rename, oldChanKey=%s newChanKey=%s", oldChanKey, newChanKey)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func refreshTemplateChannels(ctx context.Context, tempKey string) error {
|
||||||
|
var rows []struct {
|
||||||
|
ChanKey string `json:"chanKey"`
|
||||||
|
}
|
||||||
|
if err := g.DB().Model("t_sim_channels").Ctx(ctx).
|
||||||
|
Fields("chan_key").
|
||||||
|
Where("temp_key", strings.TrimSpace(tempKey)).
|
||||||
|
Scan(&rows); err != nil {
|
||||||
|
return gerror.Wrap(err, "query template channels failed")
|
||||||
|
}
|
||||||
|
|
||||||
|
var firstErr error
|
||||||
|
for _, row := range rows {
|
||||||
|
if err := refreshChannelWithRetry(ctx, row.ChanKey); err != nil {
|
||||||
|
g.Log().Errorf(ctx, "template channel refresh failed, tempKey=%s, chanKey=%s, err=%+v", tempKey, row.ChanKey, err)
|
||||||
|
if firstErr == nil {
|
||||||
|
firstErr = err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return firstErr
|
||||||
|
}
|
||||||
|
|
||||||
|
func refreshChannelWithRetry(ctx context.Context, chanKey string) error {
|
||||||
|
var err error
|
||||||
|
for attempt := 1; attempt <= 3; attempt++ {
|
||||||
|
err = refreshChannel(ctx, chanKey)
|
||||||
|
if err == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if attempt == 3 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
delay := time.Second
|
||||||
|
if attempt == 2 {
|
||||||
|
delay = 3 * time.Second
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return gerror.Wrapf(err, "channel refresh canceled after %d attempts, chanKey=%s", attempt, chanKey)
|
||||||
|
case <-time.After(delay):
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return gerror.Wrapf(err, "channel refresh failed after %d attempts, chanKey=%s", 3, chanKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
func refreshChannel(ctx context.Context, chanKey string) error {
|
||||||
|
chanKey = strings.TrimSpace(chanKey)
|
||||||
|
if chanKey == "" {
|
||||||
|
return gerror.New("chanKey is required")
|
||||||
|
}
|
||||||
|
if err := refreshChannelTarget(ctx, "upstream.gateway", chanKey); err != nil {
|
||||||
|
return gerror.Wrap(err, "refresh gateway channel failed")
|
||||||
|
}
|
||||||
|
if err := refreshChannelTarget(ctx, "upstream.proxy", chanKey); err != nil {
|
||||||
|
return gerror.Wrap(err, "refresh proxy channel failed")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func refreshChannelTarget(ctx context.Context, configKey, chanKey string) error {
|
||||||
|
return channelControlTarget(ctx, configKey, "/internal/channel/refresh", chanKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
func removeChannelTarget(ctx context.Context, configKey, chanKey string) error {
|
||||||
|
return channelControlTarget(ctx, configKey, "/internal/channel/remove", chanKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
func channelControlTarget(ctx context.Context, configKey, path, chanKey string) error {
|
||||||
|
configValue, err := g.Cfg().Get(ctx, configKey)
|
||||||
|
if err != nil {
|
||||||
|
return gerror.Wrapf(err, "read upstream configuration failed: %s", configKey)
|
||||||
|
}
|
||||||
|
if configValue == nil || configValue.IsNil() {
|
||||||
|
return gerror.Newf("upstream configuration is missing: %s", configKey)
|
||||||
|
}
|
||||||
|
config := channelRefreshConfig{}
|
||||||
|
if err = configValue.Scan(&config); err != nil {
|
||||||
|
return gerror.Wrapf(err, "parse upstream configuration failed: %s", configKey)
|
||||||
|
}
|
||||||
|
address := strings.TrimSpace(config.Address)
|
||||||
|
if address == "" {
|
||||||
|
return gerror.Newf("upstream address is empty: %s", configKey)
|
||||||
|
}
|
||||||
|
if config.Timeout <= 0 {
|
||||||
|
config.Timeout = 10 * time.Second
|
||||||
|
}
|
||||||
|
requestCtx, cancel := context.WithTimeout(ctx, config.Timeout)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
client := g.Client()
|
||||||
|
client.SetTimeout(config.Timeout)
|
||||||
|
controlURL := fmt.Sprintf("http://%s%s?chanKey=%s", address, path, url.QueryEscape(chanKey))
|
||||||
|
response, err := client.Post(requestCtx, controlURL, nil)
|
||||||
|
if err != nil {
|
||||||
|
return gerror.Wrapf(err, "request upstream channel control failed: target=%s path=%s", configKey, path)
|
||||||
|
}
|
||||||
|
defer response.Close()
|
||||||
|
|
||||||
|
output := channelRefreshResponse{}
|
||||||
|
statusCode := 0
|
||||||
|
if response.Response != nil {
|
||||||
|
statusCode = response.StatusCode
|
||||||
|
}
|
||||||
|
if err = json.Unmarshal(response.ReadAll(), &output); err != nil {
|
||||||
|
return gerror.Wrapf(err, "parse upstream channel control response failed: target=%s path=%s status=%d", configKey, path, statusCode)
|
||||||
|
}
|
||||||
|
if output.Code != 0 {
|
||||||
|
if output.Message == "" {
|
||||||
|
output.Message = "upstream channel control failed"
|
||||||
|
}
|
||||||
|
return gerror.Newf("upstream channel control failed: target=%s path=%s status=%d message=%s", configKey, path, statusCode, output.Message)
|
||||||
|
}
|
||||||
|
if statusCode < 200 || statusCode >= 300 {
|
||||||
|
return gerror.Newf("upstream channel control failed: target=%s path=%s status=%d", configKey, path, statusCode)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
package rabbitmq
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gogf/gf/v2/frame/g"
|
||||||
|
"github.com/gogf/gf/v2/os/gcfg"
|
||||||
|
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
func setChannelRefreshTestConfig(t *testing.T, gateway, proxy string) {
|
||||||
|
t.Helper()
|
||||||
|
oldAdapter := g.Cfg().GetAdapter()
|
||||||
|
adapter, err := gcfg.NewAdapterContent(fmt.Sprintf(`
|
||||||
|
upstream:
|
||||||
|
gateway:
|
||||||
|
address: %q
|
||||||
|
timeout: 1s
|
||||||
|
proxy:
|
||||||
|
address: %q
|
||||||
|
timeout: 1s
|
||||||
|
`, gateway, proxy))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
g.Cfg().SetAdapter(adapter)
|
||||||
|
t.Cleanup(func() { g.Cfg().SetAdapter(oldAdapter) })
|
||||||
|
}
|
||||||
|
|
||||||
|
func testServerAddress(server *httptest.Server) string {
|
||||||
|
return strings.TrimPrefix(server.URL, "http://")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRefreshChannelCallsGatewayAndProxy(t *testing.T) {
|
||||||
|
var gatewayCalls atomic.Int32
|
||||||
|
var proxyCalls atomic.Int32
|
||||||
|
newServer := func(counter *atomic.Int32) *httptest.Server {
|
||||||
|
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, request *http.Request) {
|
||||||
|
counter.Add(1)
|
||||||
|
if request.URL.Path != "/internal/channel/refresh" || request.URL.Query().Get("chanKey") != "channel-a" {
|
||||||
|
t.Fatalf("unexpected request: %s", request.URL.String())
|
||||||
|
}
|
||||||
|
_, _ = w.Write([]byte(`{"code":0,"message":"success"}`))
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
gateway := newServer(&gatewayCalls)
|
||||||
|
defer gateway.Close()
|
||||||
|
proxy := newServer(&proxyCalls)
|
||||||
|
defer proxy.Close()
|
||||||
|
setChannelRefreshTestConfig(t, testServerAddress(gateway), testServerAddress(proxy))
|
||||||
|
|
||||||
|
if err := refreshChannel(context.Background(), "channel-a"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if gatewayCalls.Load() != 1 || proxyCalls.Load() != 1 {
|
||||||
|
t.Fatalf("gateway calls=%d proxy calls=%d", gatewayCalls.Load(), proxyCalls.Load())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRenameChannelOnlyRebuildsRuntimeInstances(t *testing.T) {
|
||||||
|
var (
|
||||||
|
mu sync.Mutex
|
||||||
|
events []string
|
||||||
|
)
|
||||||
|
newServer := func(target string) *httptest.Server {
|
||||||
|
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, request *http.Request) {
|
||||||
|
mu.Lock()
|
||||||
|
events = append(events, target+":"+request.URL.Path+":"+request.URL.Query().Get("chanKey"))
|
||||||
|
mu.Unlock()
|
||||||
|
_, _ = w.Write([]byte(`{"code":0,"message":"success"}`))
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
gateway := newServer("gateway")
|
||||||
|
defer gateway.Close()
|
||||||
|
proxy := newServer("proxy")
|
||||||
|
defer proxy.Close()
|
||||||
|
setChannelRefreshTestConfig(t, testServerAddress(gateway), testServerAddress(proxy))
|
||||||
|
|
||||||
|
if err := renameChannel(context.Background(), "channel-old", "channel-new"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
want := []string{
|
||||||
|
"gateway:/internal/channel/refresh:channel-new",
|
||||||
|
"proxy:/internal/channel/refresh:channel-new",
|
||||||
|
"gateway:/internal/channel/remove:channel-old",
|
||||||
|
"proxy:/internal/channel/remove:channel-old",
|
||||||
|
}
|
||||||
|
if strings.Join(events, "\n") != strings.Join(want, "\n") {
|
||||||
|
t.Fatalf("rename events = %q, want %q", events, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestChannelControlErrorIncludesTargetStatusAndMessage(t *testing.T) {
|
||||||
|
gateway := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusBadGateway)
|
||||||
|
_, _ = w.Write([]byte(`{"code":50,"message":"carrier unavailable"}`))
|
||||||
|
}))
|
||||||
|
defer gateway.Close()
|
||||||
|
setChannelRefreshTestConfig(t, testServerAddress(gateway), "127.0.0.1:1")
|
||||||
|
|
||||||
|
err := refreshChannelTarget(context.Background(), "upstream.gateway", "channel-a")
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected target error")
|
||||||
|
}
|
||||||
|
message := err.Error()
|
||||||
|
for _, part := range []string{"target=upstream.gateway", "status=502", "carrier unavailable"} {
|
||||||
|
if !strings.Contains(message, part) {
|
||||||
|
t.Fatalf("error %q does not contain %q", message, part)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleChannelRefreshReturnsError(t *testing.T) {
|
||||||
|
gateway := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
_, _ = w.Write([]byte(`{"code":50,"message":"refresh rejected"}`))
|
||||||
|
}))
|
||||||
|
defer gateway.Close()
|
||||||
|
setChannelRefreshTestConfig(t, testServerAddress(gateway), "127.0.0.1:1")
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Millisecond)
|
||||||
|
defer cancel()
|
||||||
|
consumer := &Consumers{}
|
||||||
|
if err := consumer.handleChannelRefresh(ctx, model.ChannelRefreshMessage{Type: "channel", ChanKey: "channel-a"}); err == nil {
|
||||||
|
t.Fatal("refresh failure was acknowledged as success")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,189 @@
|
|||||||
|
package rabbitmq
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gogf/gf/v2/frame/g"
|
||||||
|
|
||||||
|
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/batch"
|
||||||
|
mq "git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/library/rabbitmq"
|
||||||
|
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
type TrafficUsageHandler interface {
|
||||||
|
RetryDaily(context.Context, model.TrafficUsageDailyRetryMessage) error
|
||||||
|
RetryMonthly(context.Context, model.TrafficUsageMonthlyRetryMessage) error
|
||||||
|
}
|
||||||
|
|
||||||
|
type WorkOrderHandler interface {
|
||||||
|
AutoCloseResolved(context.Context, model.WorkOrderAutoCloseMessage) error
|
||||||
|
}
|
||||||
|
|
||||||
|
type BalanceRechargeHandler interface {
|
||||||
|
CheckRechargeTimeout(context.Context, string) error
|
||||||
|
}
|
||||||
|
|
||||||
|
type BatchHandler interface {
|
||||||
|
Handle(context.Context, batch.Message) error
|
||||||
|
}
|
||||||
|
|
||||||
|
type PackageCustomerSyncHandler interface {
|
||||||
|
SyncDeptPackages(context.Context, model.PackageCustomerSyncMessage) error
|
||||||
|
}
|
||||||
|
|
||||||
|
type TrafficUsageMonthlyStartHandler interface {
|
||||||
|
Produce(context.Context, model.TrafficUsageMonthlyStartMessage) error
|
||||||
|
}
|
||||||
|
|
||||||
|
type TrafficUsageDailyStartHandler interface {
|
||||||
|
Produce(context.Context, model.TrafficUsageDailyStartMessage) error
|
||||||
|
}
|
||||||
|
|
||||||
|
type TrafficUsageMonthlyHistoryStartHandler interface {
|
||||||
|
Produce(context.Context, model.TrafficUsageMonthlyHistoryStartMessage) error
|
||||||
|
}
|
||||||
|
|
||||||
|
type SimStatusQueueStartHandler interface {
|
||||||
|
Produce(context.Context, model.SimStatusQueueStartMessage) error
|
||||||
|
}
|
||||||
|
|
||||||
|
type SimStatusQueueTaskHandler interface {
|
||||||
|
EnqueueCard(context.Context, model.SimStatusQueueTaskMessage) error
|
||||||
|
}
|
||||||
|
|
||||||
|
type ImeiQueueStartHandler interface {
|
||||||
|
Produce(context.Context, model.ImeiQueueStartMessage) error
|
||||||
|
}
|
||||||
|
|
||||||
|
// DataReviewQueryHandler 执行"手动同步上游结果":只读补查既有 operationId。
|
||||||
|
type DataReviewQueryHandler interface {
|
||||||
|
QueryReviewBatch(ctx context.Context, batchNo string) (int, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
type Consumers struct {
|
||||||
|
trafficUsage TrafficUsageHandler
|
||||||
|
workOrder WorkOrderHandler
|
||||||
|
balance BalanceRechargeHandler
|
||||||
|
balanceRechargeRetryDelay time.Duration
|
||||||
|
balanceRechargeMaxRetry int
|
||||||
|
batch BatchHandler
|
||||||
|
batchExecuteRetryDelay time.Duration
|
||||||
|
batchExecuteMaxRetries int
|
||||||
|
packageCustomerSync PackageCustomerSyncHandler
|
||||||
|
trafficUsageMonthlyStart TrafficUsageMonthlyStartHandler
|
||||||
|
trafficUsageDailyStart TrafficUsageDailyStartHandler
|
||||||
|
trafficUsageMonthlyHistoryStart TrafficUsageMonthlyHistoryStartHandler
|
||||||
|
simStatusQueueStart SimStatusQueueStartHandler
|
||||||
|
simStatusQueueTask SimStatusQueueTaskHandler
|
||||||
|
imeiQueueStart ImeiQueueStartHandler
|
||||||
|
dataReview DataReviewQueryHandler
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithDataReviewQuery adds the manual "sync upstream result" consumer without
|
||||||
|
// changing any existing RabbitMQ consumer wiring.
|
||||||
|
func (c *Consumers) WithDataReviewQuery(handler DataReviewQueryHandler) *Consumers {
|
||||||
|
c.dataReview = handler
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithDailyUsageStart adds the previous-day usage producer without changing any
|
||||||
|
// existing RabbitMQ consumer wiring.
|
||||||
|
func (c *Consumers) WithDailyUsageStart(start TrafficUsageDailyStartHandler) *Consumers {
|
||||||
|
c.trafficUsageDailyStart = start
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithMonthlyHistoryStart adds the previous-month usage history producer without
|
||||||
|
// changing any existing RabbitMQ consumer wiring.
|
||||||
|
func (c *Consumers) WithMonthlyHistoryStart(start TrafficUsageMonthlyHistoryStartHandler) *Consumers {
|
||||||
|
c.trafficUsageMonthlyHistoryStart = start
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithSimStatusQueue adds the independent status queue handlers without
|
||||||
|
// changing any existing RabbitMQ consumer wiring.
|
||||||
|
func (c *Consumers) WithSimStatusQueue(start SimStatusQueueStartHandler, task SimStatusQueueTaskHandler) *Consumers {
|
||||||
|
c.simStatusQueueStart = start
|
||||||
|
c.simStatusQueueTask = task
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithImeiQueue adds the independent IMEI queue handler without changing any
|
||||||
|
// existing RabbitMQ consumer wiring.
|
||||||
|
func (c *Consumers) WithImeiQueue(start ImeiQueueStartHandler) *Consumers {
|
||||||
|
c.imeiQueueStart = start
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
|
||||||
|
func New(trafficUsage TrafficUsageHandler, workOrder WorkOrderHandler, balance BalanceRechargeHandler, batchHandlers ...BatchHandler) *Consumers {
|
||||||
|
consumers := &Consumers{
|
||||||
|
trafficUsage: trafficUsage,
|
||||||
|
workOrder: workOrder,
|
||||||
|
balance: balance,
|
||||||
|
}
|
||||||
|
if len(batchHandlers) > 0 {
|
||||||
|
consumers.batch = batchHandlers[0]
|
||||||
|
}
|
||||||
|
return consumers
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewWithPackageCustomerSync(trafficUsage TrafficUsageHandler, workOrder WorkOrderHandler, balance BalanceRechargeHandler, packageCustomerSync PackageCustomerSyncHandler, batchHandlers ...BatchHandler) *Consumers {
|
||||||
|
consumers := New(trafficUsage, workOrder, balance, batchHandlers...)
|
||||||
|
consumers.packageCustomerSync = packageCustomerSync
|
||||||
|
return consumers
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewWithPackageCustomerSyncAndMonthlyUsage(trafficUsage TrafficUsageHandler, workOrder WorkOrderHandler, balance BalanceRechargeHandler, packageCustomerSync PackageCustomerSyncHandler, monthlyStart TrafficUsageMonthlyStartHandler, batchHandlers ...BatchHandler) *Consumers {
|
||||||
|
consumers := NewWithPackageCustomerSync(trafficUsage, workOrder, balance, packageCustomerSync, batchHandlers...)
|
||||||
|
consumers.trafficUsageMonthlyStart = monthlyStart
|
||||||
|
return consumers
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Consumers) Start(ctx context.Context) error {
|
||||||
|
initializers := c.initializers(g.Cfg().MustGet(ctx, "workerDiagnostic.enabled", false).Bool())
|
||||||
|
for _, initialize := range initializers {
|
||||||
|
if err := initialize(ctx); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Consumers) initializers(workerDiagnosticEnabled bool) []func(context.Context) error {
|
||||||
|
initializers := []func(context.Context) error{
|
||||||
|
c.initTrafficUsageDailyRetry,
|
||||||
|
c.initTrafficUsageMonthlyRetry,
|
||||||
|
c.initWorkOrderAutoClose,
|
||||||
|
c.initBalanceRechargeTimeout,
|
||||||
|
c.initBatchExecute,
|
||||||
|
c.initChannelRefresh,
|
||||||
|
c.initDataReviewQuery,
|
||||||
|
}
|
||||||
|
if c.packageCustomerSync != nil {
|
||||||
|
initializers = append(initializers, c.initPackageCustomerSync)
|
||||||
|
}
|
||||||
|
if c.trafficUsageMonthlyStart != nil {
|
||||||
|
initializers = append(initializers, c.initTrafficUsageMonthlyStart)
|
||||||
|
}
|
||||||
|
if c.trafficUsageDailyStart != nil {
|
||||||
|
initializers = append(initializers, c.initTrafficUsageDailyStart)
|
||||||
|
}
|
||||||
|
if c.trafficUsageMonthlyHistoryStart != nil {
|
||||||
|
initializers = append(initializers, c.initTrafficUsageMonthlyHistoryStart)
|
||||||
|
}
|
||||||
|
if c.simStatusQueueStart != nil && c.simStatusQueueTask != nil {
|
||||||
|
initializers = append(initializers, c.initSimStatusQueueStart, c.initSimStatusQueueTask)
|
||||||
|
}
|
||||||
|
if c.imeiQueueStart != nil {
|
||||||
|
initializers = append(initializers, c.initImeiQueueStart)
|
||||||
|
}
|
||||||
|
if workerDiagnosticEnabled {
|
||||||
|
initializers = append(initializers, c.initWorkerDiagnostic)
|
||||||
|
}
|
||||||
|
return initializers
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Consumers) Stop(ctx context.Context) error {
|
||||||
|
return mq.Close(ctx)
|
||||||
|
}
|
||||||
@@ -0,0 +1,527 @@
|
|||||||
|
package rabbitmq
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"reflect"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gogf/gf/v2/frame/g"
|
||||||
|
amqp "github.com/rabbitmq/amqp091-go"
|
||||||
|
|
||||||
|
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/batch"
|
||||||
|
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/model"
|
||||||
|
dailyusageproducer "git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/producer/dailyusage"
|
||||||
|
monthlyhistoryproducer "git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/producer/monthlyusagehistory"
|
||||||
|
packagecustomerservice "git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/service/packagecustomer"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestMain(m *testing.M) {
|
||||||
|
g.Log().SetWriter(io.Discard)
|
||||||
|
os.Exit(m.Run())
|
||||||
|
}
|
||||||
|
|
||||||
|
type trafficUsageHandlerStub struct {
|
||||||
|
dailyCalls int
|
||||||
|
monthlyCalls int
|
||||||
|
dailyMessage model.TrafficUsageDailyRetryMessage
|
||||||
|
monthMessage model.TrafficUsageMonthlyRetryMessage
|
||||||
|
dailyErr error
|
||||||
|
monthlyErr error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *trafficUsageHandlerStub) RetryDaily(_ context.Context, message model.TrafficUsageDailyRetryMessage) error {
|
||||||
|
s.dailyCalls++
|
||||||
|
s.dailyMessage = message
|
||||||
|
return s.dailyErr
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *trafficUsageHandlerStub) RetryMonthly(_ context.Context, message model.TrafficUsageMonthlyRetryMessage) error {
|
||||||
|
s.monthlyCalls++
|
||||||
|
s.monthMessage = message
|
||||||
|
return s.monthlyErr
|
||||||
|
}
|
||||||
|
|
||||||
|
type workOrderHandlerStub struct {
|
||||||
|
calls int
|
||||||
|
message model.WorkOrderAutoCloseMessage
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
|
||||||
|
type balanceRechargeHandlerStub struct {
|
||||||
|
calls int
|
||||||
|
outTradeNo string
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *balanceRechargeHandlerStub) CheckRechargeTimeout(_ context.Context, outTradeNo string) error {
|
||||||
|
s.calls++
|
||||||
|
s.outTradeNo = outTradeNo
|
||||||
|
return s.err
|
||||||
|
}
|
||||||
|
|
||||||
|
type batchHandlerStub struct {
|
||||||
|
calls int
|
||||||
|
message batch.Message
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *batchHandlerStub) Handle(_ context.Context, message batch.Message) error {
|
||||||
|
s.calls++
|
||||||
|
s.message = message
|
||||||
|
return s.err
|
||||||
|
}
|
||||||
|
|
||||||
|
type packageCustomerSyncHandlerStub struct {
|
||||||
|
calls int
|
||||||
|
message model.PackageCustomerSyncMessage
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
|
||||||
|
type dailyStartHandlerStub struct {
|
||||||
|
calls int
|
||||||
|
message model.TrafficUsageDailyStartMessage
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *dailyStartHandlerStub) Produce(_ context.Context, message model.TrafficUsageDailyStartMessage) error {
|
||||||
|
s.calls++
|
||||||
|
s.message = message
|
||||||
|
return s.err
|
||||||
|
}
|
||||||
|
|
||||||
|
type monthlyHistoryStartHandlerStub struct {
|
||||||
|
calls int
|
||||||
|
message model.TrafficUsageMonthlyHistoryStartMessage
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *monthlyHistoryStartHandlerStub) Produce(_ context.Context, message model.TrafficUsageMonthlyHistoryStartMessage) error {
|
||||||
|
s.calls++
|
||||||
|
s.message = message
|
||||||
|
return s.err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *packageCustomerSyncHandlerStub) SyncDeptPackages(_ context.Context, message model.PackageCustomerSyncMessage) error {
|
||||||
|
s.calls++
|
||||||
|
s.message = message
|
||||||
|
return s.err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *workOrderHandlerStub) AutoCloseResolved(_ context.Context, message model.WorkOrderAutoCloseMessage) error {
|
||||||
|
s.calls++
|
||||||
|
s.message = message
|
||||||
|
return s.err
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleTrafficUsageDailyRetry(t *testing.T) {
|
||||||
|
stubTrafficPublish(t)
|
||||||
|
t.Run("invalid messages are dead lettered", func(t *testing.T) {
|
||||||
|
messages := []model.TrafficUsageDailyRetryMessage{
|
||||||
|
{},
|
||||||
|
{SimKey: &model.SimKey{}, Card: model.TrafficUsageSimCard{Iccid: "card"}},
|
||||||
|
{SimKey: &model.SimKey{ICCID: "sim"}},
|
||||||
|
}
|
||||||
|
for _, message := range messages {
|
||||||
|
handler := &trafficUsageHandlerStub{}
|
||||||
|
consumers := New(handler, &workOrderHandlerStub{}, &balanceRechargeHandlerStub{})
|
||||||
|
if err := consumers.handleTrafficUsageDailyRetry(context.Background(), message); err != nil {
|
||||||
|
t.Fatalf("invalid message returned error: %v", err)
|
||||||
|
}
|
||||||
|
if handler.dailyCalls != 0 {
|
||||||
|
t.Fatalf("invalid message called handler %d times", handler.dailyCalls)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("valid message is delegated", func(t *testing.T) {
|
||||||
|
expectedErr := errors.New("daily failed")
|
||||||
|
expected := model.TrafficUsageDailyRetryMessage{
|
||||||
|
SimKey: &model.SimKey{ICCID: "sim"},
|
||||||
|
Card: model.TrafficUsageSimCard{Iccid: "sim", PackageCode: "package"},
|
||||||
|
QueryDate: "20260714",
|
||||||
|
}
|
||||||
|
handler := &trafficUsageHandlerStub{dailyErr: expectedErr}
|
||||||
|
consumers := New(handler, &workOrderHandlerStub{}, &balanceRechargeHandlerStub{})
|
||||||
|
if err := consumers.handleTrafficUsageDailyRetry(context.Background(), expected); err != nil {
|
||||||
|
t.Fatalf("expected delegated error, got %v", err)
|
||||||
|
}
|
||||||
|
if handler.dailyCalls != 1 || !reflect.DeepEqual(handler.dailyMessage, expected) {
|
||||||
|
t.Fatalf("daily message was not delegated exactly once: %+v", handler.dailyMessage)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleTrafficUsageMonthlyRetry(t *testing.T) {
|
||||||
|
stubTrafficPublish(t)
|
||||||
|
t.Run("invalid messages are dead lettered", func(t *testing.T) {
|
||||||
|
messages := []model.TrafficUsageMonthlyRetryMessage{
|
||||||
|
{},
|
||||||
|
{SimKey: &model.SimKey{}, Card: model.TrafficUsageSimCard{Iccid: "card"}},
|
||||||
|
{SimKey: &model.SimKey{ICCID: "sim"}},
|
||||||
|
}
|
||||||
|
for _, message := range messages {
|
||||||
|
handler := &trafficUsageHandlerStub{}
|
||||||
|
consumers := New(handler, &workOrderHandlerStub{}, &balanceRechargeHandlerStub{})
|
||||||
|
if err := consumers.handleTrafficUsageMonthlyRetry(context.Background(), message); err != nil {
|
||||||
|
t.Fatalf("invalid message returned error: %v", err)
|
||||||
|
}
|
||||||
|
if handler.monthlyCalls != 0 {
|
||||||
|
t.Fatalf("invalid message called handler %d times", handler.monthlyCalls)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("valid message is delegated", func(t *testing.T) {
|
||||||
|
expectedErr := errors.New("monthly failed")
|
||||||
|
expected := model.TrafficUsageMonthlyRetryMessage{
|
||||||
|
SimKey: &model.SimKey{ICCID: "sim"},
|
||||||
|
Card: model.TrafficUsageSimCard{Iccid: "sim", PackageCode: "package"},
|
||||||
|
QueryDate: "202607",
|
||||||
|
}
|
||||||
|
handler := &trafficUsageHandlerStub{monthlyErr: expectedErr}
|
||||||
|
consumers := New(handler, &workOrderHandlerStub{}, &balanceRechargeHandlerStub{})
|
||||||
|
if err := consumers.handleTrafficUsageMonthlyRetry(context.Background(), expected); err != nil {
|
||||||
|
t.Fatalf("expected delegated error, got %v", err)
|
||||||
|
}
|
||||||
|
if handler.monthlyCalls != 1 || !reflect.DeepEqual(handler.monthMessage, expected) {
|
||||||
|
t.Fatalf("monthly message was not delegated exactly once: %+v", handler.monthMessage)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func stubTrafficPublish(t *testing.T) {
|
||||||
|
t.Helper()
|
||||||
|
original, delayed := trafficUsagePublish, trafficUsagePublishDelayed
|
||||||
|
t.Cleanup(func() { trafficUsagePublish, trafficUsagePublishDelayed = original, delayed })
|
||||||
|
trafficUsagePublish = func(context.Context, string, []byte, amqp.Table) error { return nil }
|
||||||
|
trafficUsagePublishDelayed = func(context.Context, string, string, time.Duration, []byte, amqp.Table) error { return nil }
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleWorkOrderAutoClose(t *testing.T) {
|
||||||
|
handler := &workOrderHandlerStub{}
|
||||||
|
consumers := New(&trafficUsageHandlerStub{}, handler, &balanceRechargeHandlerStub{})
|
||||||
|
if err := consumers.handleWorkOrderAutoClose(context.Background(), model.WorkOrderAutoCloseMessage{}); err != nil {
|
||||||
|
t.Fatalf("invalid message returned error: %v", err)
|
||||||
|
}
|
||||||
|
if handler.calls != 0 {
|
||||||
|
t.Fatalf("invalid message called handler %d times", handler.calls)
|
||||||
|
}
|
||||||
|
|
||||||
|
expectedErr := errors.New("work order failed")
|
||||||
|
expected := model.WorkOrderAutoCloseMessage{WoNo: "WO-1", ResolveTimeTs: 1}
|
||||||
|
handler.err = expectedErr
|
||||||
|
if err := consumers.handleWorkOrderAutoClose(context.Background(), expected); !errors.Is(err, expectedErr) {
|
||||||
|
t.Fatalf("expected delegated error, got %v", err)
|
||||||
|
}
|
||||||
|
if handler.calls != 1 || !reflect.DeepEqual(handler.message, expected) {
|
||||||
|
t.Fatalf("work order message was not delegated exactly once: %+v", handler.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleBalanceRechargeTimeout(t *testing.T) {
|
||||||
|
handler := &balanceRechargeHandlerStub{}
|
||||||
|
consumers := New(&trafficUsageHandlerStub{}, &workOrderHandlerStub{}, handler)
|
||||||
|
if err := consumers.handleBalanceRechargeTimeout(context.Background(), model.BalanceRechargeTimeoutMessage{}); err != nil {
|
||||||
|
t.Fatalf("invalid message returned error: %v", err)
|
||||||
|
}
|
||||||
|
if handler.calls != 0 {
|
||||||
|
t.Fatalf("invalid message called handler %d times", handler.calls)
|
||||||
|
}
|
||||||
|
|
||||||
|
message := model.BalanceRechargeTimeoutMessage{OutTradeNo: "BAL-1"}
|
||||||
|
if err := consumers.handleBalanceRechargeTimeout(context.Background(), message); err != nil {
|
||||||
|
t.Fatalf("valid message returned error: %v", err)
|
||||||
|
}
|
||||||
|
if handler.calls != 1 || handler.outTradeNo != message.OutTradeNo {
|
||||||
|
t.Fatalf("balance recharge message was not delegated exactly once: %+v", handler)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleBatchExecute(t *testing.T) {
|
||||||
|
expectedErr := errors.New("batch failed")
|
||||||
|
expected := batch.Message{BatchNo: "BATCH-1", BatchType: batch.Type103}
|
||||||
|
handler := &batchHandlerStub{err: expectedErr}
|
||||||
|
consumers := New(&trafficUsageHandlerStub{}, &workOrderHandlerStub{}, &balanceRechargeHandlerStub{}, handler)
|
||||||
|
|
||||||
|
if err := consumers.handleBatchExecute(context.Background(), expected); !errors.Is(err, expectedErr) {
|
||||||
|
t.Fatalf("expected delegated error, got %v", err)
|
||||||
|
}
|
||||||
|
if handler.calls != 1 || !reflect.DeepEqual(handler.message, expected) {
|
||||||
|
t.Fatalf("batch message was not delegated exactly once: %+v", handler.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandlePackageCustomerSync(t *testing.T) {
|
||||||
|
t.Run("invalid message is dropped", func(t *testing.T) {
|
||||||
|
handler := &packageCustomerSyncHandlerStub{}
|
||||||
|
consumers := NewWithPackageCustomerSync(&trafficUsageHandlerStub{}, &workOrderHandlerStub{}, &balanceRechargeHandlerStub{}, handler)
|
||||||
|
if err := consumers.handlePackageCustomerSync(context.Background(), model.PackageCustomerSyncMessage{}); err != nil {
|
||||||
|
t.Fatalf("invalid message returned error: %v", err)
|
||||||
|
}
|
||||||
|
if handler.calls != 0 {
|
||||||
|
t.Fatalf("invalid message called handler %d times", handler.calls)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("missing dept is dropped", func(t *testing.T) {
|
||||||
|
expected := model.PackageCustomerSyncMessage{TenantId: "000000", DeptId: 164, Trigger: "package_lifecycle", SourceBatchNo: "898604017"}
|
||||||
|
handler := &packageCustomerSyncHandlerStub{err: packagecustomerservice.ErrDeptNotFound}
|
||||||
|
consumers := NewWithPackageCustomerSync(&trafficUsageHandlerStub{}, &workOrderHandlerStub{}, &balanceRechargeHandlerStub{}, handler)
|
||||||
|
if err := consumers.handlePackageCustomerSync(context.Background(), expected); err != nil {
|
||||||
|
t.Fatalf("missing dept returned error: %v", err)
|
||||||
|
}
|
||||||
|
if handler.calls != 1 || !reflect.DeepEqual(handler.message, expected) {
|
||||||
|
t.Fatalf("package customer sync message was not delegated exactly once: %+v", handler.message)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("other errors are retried", func(t *testing.T) {
|
||||||
|
expectedErr := errors.New("database failed")
|
||||||
|
expected := model.PackageCustomerSyncMessage{TenantId: "000000", DeptId: 117}
|
||||||
|
handler := &packageCustomerSyncHandlerStub{err: expectedErr}
|
||||||
|
consumers := NewWithPackageCustomerSync(&trafficUsageHandlerStub{}, &workOrderHandlerStub{}, &balanceRechargeHandlerStub{}, handler)
|
||||||
|
if err := consumers.handlePackageCustomerSync(context.Background(), expected); !errors.Is(err, expectedErr) {
|
||||||
|
t.Fatalf("expected delegated error, got %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleWorkerDiagnostic(t *testing.T) {
|
||||||
|
consumers := New(&trafficUsageHandlerStub{}, &workOrderHandlerStub{}, &balanceRechargeHandlerStub{})
|
||||||
|
|
||||||
|
if err := consumers.handleWorkerDiagnostic(context.Background(), model.WorkerDiagnosticMessage{}); err != nil {
|
||||||
|
t.Fatalf("invalid message returned error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
message := model.WorkerDiagnosticMessage{
|
||||||
|
TraceID: "worker-test-1",
|
||||||
|
SentAt: 1784030000000,
|
||||||
|
Message: "worker ping",
|
||||||
|
}
|
||||||
|
if err := consumers.handleWorkerDiagnostic(context.Background(), message); err != nil {
|
||||||
|
t.Fatalf("valid message returned error: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWorkerDiagnosticMessageJSONContract(t *testing.T) {
|
||||||
|
message := model.WorkerDiagnosticMessage{
|
||||||
|
TraceID: "worker-test-1",
|
||||||
|
SentAt: 1784030000000,
|
||||||
|
Message: "worker ping",
|
||||||
|
}
|
||||||
|
body, err := json.Marshal(message)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("marshal diagnostic message: %v", err)
|
||||||
|
}
|
||||||
|
const expected = `{"traceId":"worker-test-1","sentAt":1784030000000,"message":"worker ping"}`
|
||||||
|
if string(body) != expected {
|
||||||
|
t.Fatalf("unexpected diagnostic JSON: %s", body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestChannelRefreshMessageJSONContract(t *testing.T) {
|
||||||
|
message := model.ChannelRefreshMessage{
|
||||||
|
Type: "channel_rename",
|
||||||
|
OldChanKey: "old_channel",
|
||||||
|
NewChanKey: "new_channel",
|
||||||
|
}
|
||||||
|
body, err := json.Marshal(message)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("marshal channel rename message: %v", err)
|
||||||
|
}
|
||||||
|
const expected = `{"type":"channel_rename","oldChanKey":"old_channel","newChanKey":"new_channel"}`
|
||||||
|
if string(body) != expected {
|
||||||
|
t.Fatalf("unexpected channel rename JSON: %s", body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleChannelRefreshDropsInvalidRename(t *testing.T) {
|
||||||
|
consumers := New(&trafficUsageHandlerStub{}, &workOrderHandlerStub{}, &balanceRechargeHandlerStub{})
|
||||||
|
for _, message := range []model.ChannelRefreshMessage{
|
||||||
|
{Type: "channel_rename"},
|
||||||
|
{Type: "channel_rename", OldChanKey: "old_channel"},
|
||||||
|
{Type: "channel_rename", NewChanKey: "new_channel"},
|
||||||
|
{Type: "channel_rename", OldChanKey: "same", NewChanKey: "same"},
|
||||||
|
} {
|
||||||
|
if err := consumers.handleChannelRefresh(context.Background(), message); err != nil {
|
||||||
|
t.Fatalf("invalid channel rename returned error: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleChannelRefreshRenameFailureReturnsError(t *testing.T) {
|
||||||
|
consumers := New(&trafficUsageHandlerStub{}, &workOrderHandlerStub{}, &balanceRechargeHandlerStub{})
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
cancel()
|
||||||
|
message := model.ChannelRefreshMessage{
|
||||||
|
Type: "channel_rename",
|
||||||
|
OldChanKey: "old_channel",
|
||||||
|
NewChanKey: "new_channel",
|
||||||
|
}
|
||||||
|
// ctx 已取消时 renameChannelWithRetry 会在首次重试等待处返回 ctx.Err(),
|
||||||
|
// 消费者必须把错误向上抛出,让框架 Nack 重新入队而不是 Ack 丢弃。
|
||||||
|
if err := consumers.handleChannelRefresh(ctx, message); err == nil {
|
||||||
|
t.Fatalf("expected failed channel rename to return error for Nack+requeue")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleTrafficUsageDailyStart(t *testing.T) {
|
||||||
|
t.Run("invalid message is dropped", func(t *testing.T) {
|
||||||
|
for _, message := range []model.TrafficUsageDailyStartMessage{
|
||||||
|
{},
|
||||||
|
{RunID: "run-1"},
|
||||||
|
{QueryDate: "20261009"},
|
||||||
|
} {
|
||||||
|
handler := &dailyStartHandlerStub{}
|
||||||
|
consumers := New(&trafficUsageHandlerStub{}, &workOrderHandlerStub{}, &balanceRechargeHandlerStub{}).WithDailyUsageStart(handler)
|
||||||
|
if err := consumers.handleTrafficUsageDailyStart(context.Background(), message); err != nil {
|
||||||
|
t.Fatalf("invalid message returned error: %v", err)
|
||||||
|
}
|
||||||
|
if handler.calls != 0 {
|
||||||
|
t.Fatalf("invalid message called producer %d times", handler.calls)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("valid message is delegated", func(t *testing.T) {
|
||||||
|
expected := model.TrafficUsageDailyStartMessage{
|
||||||
|
RunID: "daily-usage-20261009-abc",
|
||||||
|
Operation: model.OperationPreviousDayUsageFullSync,
|
||||||
|
QueryDate: "20261009",
|
||||||
|
TriggeredAt: "2026-10-10T06:00:00+08:00",
|
||||||
|
Scope: &model.SyncScope{Channels: []string{"217"}},
|
||||||
|
}
|
||||||
|
handler := &dailyStartHandlerStub{}
|
||||||
|
consumers := New(&trafficUsageHandlerStub{}, &workOrderHandlerStub{}, &balanceRechargeHandlerStub{}).WithDailyUsageStart(handler)
|
||||||
|
if err := consumers.handleTrafficUsageDailyStart(context.Background(), expected); err != nil {
|
||||||
|
t.Fatalf("valid message returned error: %v", err)
|
||||||
|
}
|
||||||
|
if handler.calls != 1 || !reflect.DeepEqual(handler.message, expected) {
|
||||||
|
t.Fatalf("daily start message was not delegated exactly once: %+v", handler.message)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("an already running producer is acked", func(t *testing.T) {
|
||||||
|
handler := &dailyStartHandlerStub{err: dailyusageproducer.ErrAlreadyRunning}
|
||||||
|
consumers := New(&trafficUsageHandlerStub{}, &workOrderHandlerStub{}, &balanceRechargeHandlerStub{}).WithDailyUsageStart(handler)
|
||||||
|
if err := consumers.handleTrafficUsageDailyStart(context.Background(), model.TrafficUsageDailyStartMessage{RunID: "r", QueryDate: "20261009"}); err != nil {
|
||||||
|
t.Fatalf("ErrAlreadyRunning must be acked, got %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("a finished run is acked instead of being resurrected", func(t *testing.T) {
|
||||||
|
handler := &dailyStartHandlerStub{err: dailyusageproducer.ErrRunFinished}
|
||||||
|
consumers := New(&trafficUsageHandlerStub{}, &workOrderHandlerStub{}, &balanceRechargeHandlerStub{}).WithDailyUsageStart(handler)
|
||||||
|
if err := consumers.handleTrafficUsageDailyStart(context.Background(), model.TrafficUsageDailyStartMessage{RunID: "r", QueryDate: "20261009"}); err != nil {
|
||||||
|
t.Fatalf("ErrRunFinished must be acked, got %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("other errors are surfaced for requeue", func(t *testing.T) {
|
||||||
|
expectedErr := errors.New("redis unavailable")
|
||||||
|
handler := &dailyStartHandlerStub{err: expectedErr}
|
||||||
|
consumers := New(&trafficUsageHandlerStub{}, &workOrderHandlerStub{}, &balanceRechargeHandlerStub{}).WithDailyUsageStart(handler)
|
||||||
|
if err := consumers.handleTrafficUsageDailyStart(context.Background(), model.TrafficUsageDailyStartMessage{RunID: "r", QueryDate: "20261009"}); !errors.Is(err, expectedErr) {
|
||||||
|
t.Fatalf("expected the producer error to propagate, got %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestDailyUsageStartConsumerIsOptIn pins the assembly contract: without an
|
||||||
|
// explicit WithDailyUsageStart the initializer list must be unchanged, so a
|
||||||
|
// disabled daily chain cannot alter existing deployments.
|
||||||
|
func TestDailyUsageStartConsumerIsOptIn(t *testing.T) {
|
||||||
|
base := New(&trafficUsageHandlerStub{}, &workOrderHandlerStub{}, &balanceRechargeHandlerStub{})
|
||||||
|
withDaily := New(&trafficUsageHandlerStub{}, &workOrderHandlerStub{}, &balanceRechargeHandlerStub{}).WithDailyUsageStart(&dailyStartHandlerStub{})
|
||||||
|
if got, want := len(withDaily.initializers(false)), len(base.initializers(false))+1; got != want {
|
||||||
|
t.Fatalf("initializers = %d, want %d", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleTrafficUsageMonthlyHistoryStart(t *testing.T) {
|
||||||
|
t.Run("invalid message is dropped", func(t *testing.T) {
|
||||||
|
for _, message := range []model.TrafficUsageMonthlyHistoryStartMessage{
|
||||||
|
{},
|
||||||
|
{RunID: "run-1"},
|
||||||
|
{QueryMonth: "202609"},
|
||||||
|
} {
|
||||||
|
handler := &monthlyHistoryStartHandlerStub{}
|
||||||
|
consumers := New(&trafficUsageHandlerStub{}, &workOrderHandlerStub{}, &balanceRechargeHandlerStub{}).WithMonthlyHistoryStart(handler)
|
||||||
|
if err := consumers.handleTrafficUsageMonthlyHistoryStart(context.Background(), message); err != nil {
|
||||||
|
t.Fatalf("invalid message returned error: %v", err)
|
||||||
|
}
|
||||||
|
if handler.calls != 0 {
|
||||||
|
t.Fatalf("invalid message called producer %d times", handler.calls)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("valid message is delegated", func(t *testing.T) {
|
||||||
|
expected := model.TrafficUsageMonthlyHistoryStartMessage{
|
||||||
|
RunID: "monthly-history-202609-abc",
|
||||||
|
Operation: model.OperationPreviousMonthUsageFullSync,
|
||||||
|
QueryMonth: "202609",
|
||||||
|
TriggeredAt: "2026-10-01T06:00:00+08:00",
|
||||||
|
Scope: &model.SyncScope{Channels: []string{"217"}},
|
||||||
|
}
|
||||||
|
handler := &monthlyHistoryStartHandlerStub{}
|
||||||
|
consumers := New(&trafficUsageHandlerStub{}, &workOrderHandlerStub{}, &balanceRechargeHandlerStub{}).WithMonthlyHistoryStart(handler)
|
||||||
|
if err := consumers.handleTrafficUsageMonthlyHistoryStart(context.Background(), expected); err != nil {
|
||||||
|
t.Fatalf("valid message returned error: %v", err)
|
||||||
|
}
|
||||||
|
if handler.calls != 1 || !reflect.DeepEqual(handler.message, expected) {
|
||||||
|
t.Fatalf("monthly history start message was not delegated exactly once: %+v", handler.message)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("an already running producer is acked", func(t *testing.T) {
|
||||||
|
handler := &monthlyHistoryStartHandlerStub{err: monthlyhistoryproducer.ErrAlreadyRunning}
|
||||||
|
consumers := New(&trafficUsageHandlerStub{}, &workOrderHandlerStub{}, &balanceRechargeHandlerStub{}).WithMonthlyHistoryStart(handler)
|
||||||
|
if err := consumers.handleTrafficUsageMonthlyHistoryStart(context.Background(), model.TrafficUsageMonthlyHistoryStartMessage{RunID: "r", QueryMonth: "202609"}); err != nil {
|
||||||
|
t.Fatalf("ErrAlreadyRunning must be acked, got %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("a finished run is acked instead of being resurrected", func(t *testing.T) {
|
||||||
|
handler := &monthlyHistoryStartHandlerStub{err: monthlyhistoryproducer.ErrRunFinished}
|
||||||
|
consumers := New(&trafficUsageHandlerStub{}, &workOrderHandlerStub{}, &balanceRechargeHandlerStub{}).WithMonthlyHistoryStart(handler)
|
||||||
|
if err := consumers.handleTrafficUsageMonthlyHistoryStart(context.Background(), model.TrafficUsageMonthlyHistoryStartMessage{RunID: "r", QueryMonth: "202609"}); err != nil {
|
||||||
|
t.Fatalf("ErrRunFinished must be acked, got %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("other errors are surfaced for requeue", func(t *testing.T) {
|
||||||
|
expectedErr := errors.New("redis unavailable")
|
||||||
|
handler := &monthlyHistoryStartHandlerStub{err: expectedErr}
|
||||||
|
consumers := New(&trafficUsageHandlerStub{}, &workOrderHandlerStub{}, &balanceRechargeHandlerStub{}).WithMonthlyHistoryStart(handler)
|
||||||
|
if err := consumers.handleTrafficUsageMonthlyHistoryStart(context.Background(), model.TrafficUsageMonthlyHistoryStartMessage{RunID: "r", QueryMonth: "202609"}); !errors.Is(err, expectedErr) {
|
||||||
|
t.Fatalf("expected the producer error to propagate, got %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestMonthlyHistoryStartConsumerIsOptIn mirrors the daily chain: a disabled
|
||||||
|
// monthly-history chain must not add an initializer.
|
||||||
|
func TestMonthlyHistoryStartConsumerIsOptIn(t *testing.T) {
|
||||||
|
base := New(&trafficUsageHandlerStub{}, &workOrderHandlerStub{}, &balanceRechargeHandlerStub{})
|
||||||
|
withHistory := New(&trafficUsageHandlerStub{}, &workOrderHandlerStub{}, &balanceRechargeHandlerStub{}).WithMonthlyHistoryStart(&monthlyHistoryStartHandlerStub{})
|
||||||
|
if got, want := len(withHistory.initializers(false)), len(base.initializers(false))+1; got != want {
|
||||||
|
t.Fatalf("initializers = %d, want %d", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConsumerInitializersRespectWorkerDiagnosticSwitch(t *testing.T) {
|
||||||
|
consumers := New(&trafficUsageHandlerStub{}, &workOrderHandlerStub{}, &balanceRechargeHandlerStub{})
|
||||||
|
// 手动同步上游结果(initDataReviewQuery)随业务消费者常驻,无处理器时自跳过。
|
||||||
|
if got := len(consumers.initializers(false)); got != 7 {
|
||||||
|
t.Fatalf("diagnostic disabled: expected 7 business consumers, got %d", got)
|
||||||
|
}
|
||||||
|
if got := len(consumers.initializers(true)); got != 8 {
|
||||||
|
t.Fatalf("diagnostic enabled: expected 8 consumers, got %d", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
package rabbitmq
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"github.com/gogf/gf/v2/frame/g"
|
||||||
|
|
||||||
|
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/consts"
|
||||||
|
mq "git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/library/rabbitmq"
|
||||||
|
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (c *Consumers) initDataReviewQuery(ctx context.Context) error {
|
||||||
|
if c.dataReview == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if !mq.Enabled(ctx) {
|
||||||
|
g.Log().Info(ctx, "data review query consumer skipped, RabbitMQ disabled")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if err := mq.ConsumeJSON[model.DataReviewQueryMessage](
|
||||||
|
ctx,
|
||||||
|
consts.QueueDataReviewQuery,
|
||||||
|
"iotlcm-data-review-query",
|
||||||
|
c.handleDataReviewQuery,
|
||||||
|
); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
g.Log().Infof(ctx, "data review query consumer started, queue:%s", consts.QueueDataReviewQuery)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Consumers) handleDataReviewQuery(ctx context.Context, message model.DataReviewQueryMessage) error {
|
||||||
|
if c.dataReview == nil || message.BatchNo == "" {
|
||||||
|
g.Log().Errorf(ctx, "data review query message dropped, invalid message:%+v", message)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
queried, err := c.dataReview.QueryReviewBatch(ctx, message.BatchNo)
|
||||||
|
if err != nil {
|
||||||
|
// 手动同步失败只记日志,不重投:重投也不会改变上游结果,且可能放大查询压力。
|
||||||
|
g.Log().Errorf(ctx, "手动同步APN上游结果失败 batchNo=%s 已同步=%d error=%v", message.BatchNo, queried, err)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
g.Log().Infof(ctx, "手动同步APN上游结果完成 batchNo=%s 已同步=%d", message.BatchNo, queried)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
package rabbitmq
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"github.com/gogf/gf/v2/frame/g"
|
||||||
|
|
||||||
|
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/consts"
|
||||||
|
mq "git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/library/rabbitmq"
|
||||||
|
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (c *Consumers) initWorkerDiagnostic(ctx context.Context) error {
|
||||||
|
if !mq.Enabled(ctx) {
|
||||||
|
g.Log().Info(ctx, "worker diagnostic consumer skipped, RabbitMQ disabled")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if err := mq.ConsumeJSON[model.WorkerDiagnosticMessage](
|
||||||
|
ctx,
|
||||||
|
consts.QueueWorkerDiagnostic,
|
||||||
|
"iotlcm-worker-diagnostic",
|
||||||
|
c.handleWorkerDiagnostic,
|
||||||
|
); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
g.Log().Infof(ctx, "worker diagnostic consumer started, queue:%s", consts.QueueWorkerDiagnostic)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Consumers) handleWorkerDiagnostic(ctx context.Context, message model.WorkerDiagnosticMessage) error {
|
||||||
|
if message.TraceID == "" {
|
||||||
|
g.Log().Errorf(ctx, "worker diagnostic message dropped, invalid traceId, message:%+v", message)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
g.Log().Infof(
|
||||||
|
ctx,
|
||||||
|
"worker diagnostic message consumed, traceId:%s, sentAt:%d, message:%s",
|
||||||
|
message.TraceID,
|
||||||
|
message.SentAt,
|
||||||
|
message.Message,
|
||||||
|
)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
package rabbitmq
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
|
||||||
|
"github.com/gogf/gf/v2/frame/g"
|
||||||
|
|
||||||
|
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/consts"
|
||||||
|
mq "git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/library/rabbitmq"
|
||||||
|
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/model"
|
||||||
|
imeiproducer "git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/producer/imei"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (c *Consumers) initImeiQueueStart(ctx context.Context) error {
|
||||||
|
if !mq.Enabled(ctx) {
|
||||||
|
g.Log().Info(ctx, "imei queue start consumer skipped, RabbitMQ disabled")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if err := mq.ConsumeJSON[model.ImeiQueueStartMessage](ctx, consts.QueueImeiQueueStart, "iotlcm-imei-queue-start", c.handleImeiQueueStart); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
g.Log().Infof(ctx, "imei queue start consumer started, queue:%s", consts.QueueImeiQueueStart)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Consumers) handleImeiQueueStart(ctx context.Context, message model.ImeiQueueStartMessage) error {
|
||||||
|
if message.RunID == "" {
|
||||||
|
g.Log().Errorf(ctx, "imei queue start message dropped, invalid message:%+v", message)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
err := c.imeiQueueStart.Produce(ctx, message)
|
||||||
|
if errors.Is(err, imeiproducer.ErrAlreadyRunning) {
|
||||||
|
g.Log().Warningf(ctx, "imei queue start skipped because a run is active, runId:%s", message.RunID)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
// A run that already finished must not be resurrected by a redelivered
|
||||||
|
// message. Ack it instead of retrying forever.
|
||||||
|
if errors.Is(err, imeiproducer.ErrRunFinished) {
|
||||||
|
g.Log().Warningf(ctx, "imei queue start skipped because run already finished, runId:%s", message.RunID)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
package rabbitmq
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
|
||||||
|
"github.com/gogf/gf/v2/errors/gerror"
|
||||||
|
"github.com/gogf/gf/v2/frame/g"
|
||||||
|
|
||||||
|
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/consts"
|
||||||
|
mq "git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/library/rabbitmq"
|
||||||
|
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/model"
|
||||||
|
packagecustomerservice "git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/service/packagecustomer"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (c *Consumers) initPackageCustomerSync(ctx context.Context) error {
|
||||||
|
if !mq.Enabled(ctx) {
|
||||||
|
g.Log().Info(ctx, "package customer sync consumer skipped, RabbitMQ disabled")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if c.packageCustomerSync == nil {
|
||||||
|
return gerror.New("package customer sync handler is not configured")
|
||||||
|
}
|
||||||
|
if err := mq.ConsumeJSON[model.PackageCustomerSyncMessage](
|
||||||
|
ctx,
|
||||||
|
consts.QueuePackageCustomerSync,
|
||||||
|
"iotlcm-package-customer-sync",
|
||||||
|
c.handlePackageCustomerSync,
|
||||||
|
); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
g.Log().Infof(ctx, "package customer sync consumer started, queue:%s", consts.QueuePackageCustomerSync)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Consumers) handlePackageCustomerSync(ctx context.Context, message model.PackageCustomerSyncMessage) error {
|
||||||
|
if c.packageCustomerSync == nil {
|
||||||
|
return gerror.New("package customer sync handler is not configured")
|
||||||
|
}
|
||||||
|
if message.TenantId == "" || message.DeptId <= 0 {
|
||||||
|
g.Log().Errorf(ctx, "package customer sync message dropped, invalid message:%+v", message)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if err := c.packageCustomerSync.SyncDeptPackages(ctx, message); err != nil {
|
||||||
|
if errors.Is(err, packagecustomerservice.ErrDeptNotFound) {
|
||||||
|
g.Log().Warningf(ctx, "package customer sync message dropped, dept not found, tenantId:%s, deptId:%d, trigger:%s, sourceBatchNo:%s",
|
||||||
|
message.TenantId, message.DeptId, message.Trigger, message.SourceBatchNo)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
package rabbitmq
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
|
||||||
|
"github.com/gogf/gf/v2/frame/g"
|
||||||
|
|
||||||
|
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/consts"
|
||||||
|
mq "git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/library/rabbitmq"
|
||||||
|
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/model"
|
||||||
|
simstatusproducer "git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/producer/simstatus"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (c *Consumers) initSimStatusQueueStart(ctx context.Context) error {
|
||||||
|
if !mq.Enabled(ctx) {
|
||||||
|
g.Log().Info(ctx, "sim status queue start consumer skipped, RabbitMQ disabled")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if err := mq.ConsumeJSON[model.SimStatusQueueStartMessage](ctx, consts.QueueSimStatusQueueStart, "iotlcm-sim-status-queue-start", c.handleSimStatusQueueStart); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
g.Log().Infof(ctx, "sim status queue start consumer started, queue:%s", consts.QueueSimStatusQueueStart)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Consumers) handleSimStatusQueueStart(ctx context.Context, message model.SimStatusQueueStartMessage) error {
|
||||||
|
if message.RunID == "" {
|
||||||
|
g.Log().Errorf(ctx, "sim status queue start message dropped, invalid message:%+v", message)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
err := c.simStatusQueueStart.Produce(ctx, message)
|
||||||
|
if errors.Is(err, simstatusproducer.ErrAlreadyRunning) {
|
||||||
|
g.Log().Warningf(ctx, "sim status queue start skipped because a run is active, runId:%s", message.RunID)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
// A run that already finished must not be resurrected by a redelivered
|
||||||
|
// message. Ack it instead of retrying forever.
|
||||||
|
if errors.Is(err, simstatusproducer.ErrRunFinished) {
|
||||||
|
g.Log().Warningf(ctx, "sim status queue start skipped because run already finished, runId:%s", message.RunID)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
package rabbitmq
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"github.com/gogf/gf/v2/frame/g"
|
||||||
|
|
||||||
|
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/consts"
|
||||||
|
mq "git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/library/rabbitmq"
|
||||||
|
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (c *Consumers) initSimStatusQueueTask(ctx context.Context) error {
|
||||||
|
if !mq.Enabled(ctx) {
|
||||||
|
g.Log().Info(ctx, "sim status queue task consumer skipped, RabbitMQ disabled")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if err := mq.ConsumeJSON[model.SimStatusQueueTaskMessage](ctx, consts.QueueSimStatusQueueTask, "iotlcm-sim-status-queue-task", c.handleSimStatusQueueTask); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
g.Log().Infof(ctx, "sim status queue task consumer started, queue:%s", consts.QueueSimStatusQueueTask)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Consumers) handleSimStatusQueueTask(ctx context.Context, message model.SimStatusQueueTaskMessage) error {
|
||||||
|
if message.CardID <= 0 || message.ICCID == "" || message.ChanKey == "" || message.TempKey == "" {
|
||||||
|
g.Log().Errorf(ctx, "sim status queue task dropped, invalid message:%+v", message)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return c.simStatusQueueTask.EnqueueCard(ctx, message)
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
package rabbitmq
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"github.com/gogf/gf/v2/frame/g"
|
||||||
|
|
||||||
|
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/consts"
|
||||||
|
mq "git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/library/rabbitmq"
|
||||||
|
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (c *Consumers) initTrafficUsageDailyRetry(ctx context.Context) error {
|
||||||
|
if !mq.Enabled(ctx) {
|
||||||
|
g.Log().Info(ctx, "traffic usage daily retry consumer skipped, RabbitMQ disabled")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if err := mq.ConsumeJSON[model.TrafficUsageDailyRetryMessage](
|
||||||
|
ctx,
|
||||||
|
consts.QueueTrafficUsageDailyRetry,
|
||||||
|
"iotlcm-traffic-usage-daily",
|
||||||
|
c.handleTrafficUsageDailyRetry,
|
||||||
|
); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
g.Log().Infof(ctx, "traffic usage daily retry consumer started, queue:%s", consts.QueueTrafficUsageDailyRetry)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Consumers) handleTrafficUsageDailyRetry(ctx context.Context, message model.TrafficUsageDailyRetryMessage) error {
|
||||||
|
err := validateTrafficUsageIdentity(message.SimKey, message.Card)
|
||||||
|
if err == nil {
|
||||||
|
err = c.trafficUsage.RetryDaily(ctx, message)
|
||||||
|
}
|
||||||
|
if err == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
message.Attempt++
|
||||||
|
message.LastError = err.Error()
|
||||||
|
permanent := trafficUsageFailureSnapshot(err, &message.UsageMb)
|
||||||
|
return publishTrafficUsageFailure(ctx, consts.QueueTrafficUsageDailyRetry, message, message.Attempt, permanent)
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
package rabbitmq
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
|
||||||
|
"github.com/gogf/gf/v2/frame/g"
|
||||||
|
|
||||||
|
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/consts"
|
||||||
|
mq "git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/library/rabbitmq"
|
||||||
|
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/model"
|
||||||
|
dailyusageproducer "git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/producer/dailyusage"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (c *Consumers) initTrafficUsageDailyStart(ctx context.Context) error {
|
||||||
|
if !mq.Enabled(ctx) {
|
||||||
|
g.Log().Info(ctx, "traffic usage daily start consumer skipped, RabbitMQ disabled")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if err := mq.ConsumeJSON[model.TrafficUsageDailyStartMessage](
|
||||||
|
ctx,
|
||||||
|
consts.QueueTrafficUsageDailyStart,
|
||||||
|
"iotlcm-traffic-usage-daily-start",
|
||||||
|
c.handleTrafficUsageDailyStart,
|
||||||
|
); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
g.Log().Infof(ctx, "traffic usage daily start consumer started, queue:%s", consts.QueueTrafficUsageDailyStart)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Consumers) handleTrafficUsageDailyStart(ctx context.Context, message model.TrafficUsageDailyStartMessage) error {
|
||||||
|
if message.RunID == "" || message.QueryDate == "" {
|
||||||
|
g.Log().Errorf(ctx, "traffic usage daily start message dropped, invalid message:%+v", message)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
err := c.trafficUsageDailyStart.Produce(ctx, message)
|
||||||
|
if errors.Is(err, dailyusageproducer.ErrAlreadyRunning) {
|
||||||
|
g.Log().Warningf(ctx, "traffic usage daily start skipped because previous run is active, runId:%s", message.RunID)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
// A run that already finished must not be resurrected by a redelivered
|
||||||
|
// message. Ack it instead of retrying forever.
|
||||||
|
if errors.Is(err, dailyusageproducer.ErrRunFinished) {
|
||||||
|
g.Log().Warningf(ctx, "traffic usage daily start skipped because run already finished, runId:%s", message.RunID)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
package rabbitmq
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"github.com/gogf/gf/v2/frame/g"
|
||||||
|
|
||||||
|
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/consts"
|
||||||
|
mq "git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/library/rabbitmq"
|
||||||
|
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (c *Consumers) initTrafficUsageMonthlyRetry(ctx context.Context) error {
|
||||||
|
if !mq.Enabled(ctx) {
|
||||||
|
g.Log().Info(ctx, "traffic usage monthly retry consumer skipped, RabbitMQ disabled")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if err := mq.ConsumeJSON[model.TrafficUsageMonthlyRetryMessage](
|
||||||
|
ctx,
|
||||||
|
consts.QueueTrafficUsageMonthlyRetry,
|
||||||
|
"iotlcm-traffic-usage-monthly",
|
||||||
|
c.handleTrafficUsageMonthlyRetry,
|
||||||
|
); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
g.Log().Infof(ctx, "traffic usage monthly retry consumer started, queue:%s", consts.QueueTrafficUsageMonthlyRetry)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Consumers) handleTrafficUsageMonthlyRetry(ctx context.Context, message model.TrafficUsageMonthlyRetryMessage) error {
|
||||||
|
err := validateTrafficUsageIdentity(message.SimKey, message.Card)
|
||||||
|
if err == nil {
|
||||||
|
err = c.trafficUsage.RetryMonthly(ctx, message)
|
||||||
|
}
|
||||||
|
if err == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
message.Attempt++
|
||||||
|
message.LastError = err.Error()
|
||||||
|
permanent := trafficUsageFailureSnapshot(err, &message.UsageMb)
|
||||||
|
return publishTrafficUsageFailure(ctx, consts.QueueTrafficUsageMonthlyRetry, message, message.Attempt, permanent)
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
package rabbitmq
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
|
||||||
|
"github.com/gogf/gf/v2/frame/g"
|
||||||
|
|
||||||
|
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/consts"
|
||||||
|
mq "git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/library/rabbitmq"
|
||||||
|
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/model"
|
||||||
|
monthlyhistoryproducer "git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/producer/monthlyusagehistory"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (c *Consumers) initTrafficUsageMonthlyHistoryStart(ctx context.Context) error {
|
||||||
|
if !mq.Enabled(ctx) {
|
||||||
|
g.Log().Info(ctx, "traffic usage monthly history start consumer skipped, RabbitMQ disabled")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if err := mq.ConsumeJSON[model.TrafficUsageMonthlyHistoryStartMessage](
|
||||||
|
ctx,
|
||||||
|
consts.QueueTrafficUsageMonthlyHistoryStart,
|
||||||
|
"iotlcm-traffic-usage-monthly-history-start",
|
||||||
|
c.handleTrafficUsageMonthlyHistoryStart,
|
||||||
|
); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
g.Log().Infof(ctx, "traffic usage monthly history start consumer started, queue:%s", consts.QueueTrafficUsageMonthlyHistoryStart)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Consumers) handleTrafficUsageMonthlyHistoryStart(ctx context.Context, message model.TrafficUsageMonthlyHistoryStartMessage) error {
|
||||||
|
if message.RunID == "" || message.QueryMonth == "" {
|
||||||
|
g.Log().Errorf(ctx, "traffic usage monthly history start message dropped, invalid message:%+v", message)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
err := c.trafficUsageMonthlyHistoryStart.Produce(ctx, message)
|
||||||
|
if errors.Is(err, monthlyhistoryproducer.ErrAlreadyRunning) {
|
||||||
|
g.Log().Warningf(ctx, "traffic usage monthly history start skipped because previous run is active, runId:%s", message.RunID)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
// A run that already finished must not be resurrected by a redelivered
|
||||||
|
// message. Ack it instead of retrying forever.
|
||||||
|
if errors.Is(err, monthlyhistoryproducer.ErrRunFinished) {
|
||||||
|
g.Log().Warningf(ctx, "traffic usage monthly history start skipped because run already finished, runId:%s", message.RunID)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
package rabbitmq
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
|
||||||
|
"github.com/gogf/gf/v2/frame/g"
|
||||||
|
|
||||||
|
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/consts"
|
||||||
|
mq "git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/library/rabbitmq"
|
||||||
|
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/model"
|
||||||
|
monthlyusageproducer "git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/producer/monthlyusage"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (c *Consumers) initTrafficUsageMonthlyStart(ctx context.Context) error {
|
||||||
|
if !mq.Enabled(ctx) {
|
||||||
|
g.Log().Info(ctx, "traffic usage monthly start consumer skipped, RabbitMQ disabled")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if err := mq.ConsumeJSON[model.TrafficUsageMonthlyStartMessage](
|
||||||
|
ctx,
|
||||||
|
consts.QueueTrafficUsageMonthlyStart,
|
||||||
|
"iotlcm-traffic-usage-monthly-start",
|
||||||
|
c.handleTrafficUsageMonthlyStart,
|
||||||
|
); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
g.Log().Infof(ctx, "traffic usage monthly start consumer started, queue:%s", consts.QueueTrafficUsageMonthlyStart)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Consumers) handleTrafficUsageMonthlyStart(ctx context.Context, message model.TrafficUsageMonthlyStartMessage) error {
|
||||||
|
if message.RunID == "" || message.QueryMonth == "" {
|
||||||
|
g.Log().Errorf(ctx, "traffic usage monthly start message dropped, invalid message:%+v", message)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
err := c.trafficUsageMonthlyStart.Produce(ctx, message)
|
||||||
|
if errors.Is(err, monthlyusageproducer.ErrAlreadyRunning) {
|
||||||
|
g.Log().Warningf(ctx, "traffic usage monthly start skipped because previous run is active, runId:%s", message.RunID)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
// A run that already finished must not be resurrected by a redelivered
|
||||||
|
// message. Ack it instead of retrying forever.
|
||||||
|
if errors.Is(err, monthlyusageproducer.ErrRunFinished) {
|
||||||
|
g.Log().Warningf(ctx, "traffic usage monthly start skipped because run already finished, runId:%s", message.RunID)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
package rabbitmq
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gogf/gf/v2/errors/gerror"
|
||||||
|
"github.com/gogf/gf/v2/frame/g"
|
||||||
|
mq "git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/library/rabbitmq"
|
||||||
|
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/model"
|
||||||
|
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/service/trafficusage"
|
||||||
|
)
|
||||||
|
|
||||||
|
var trafficUsagePublish = mq.PublishBytesWithHeaders
|
||||||
|
var trafficUsagePublishDelayed = mq.PublishDelayedBytesWithHeaders
|
||||||
|
|
||||||
|
func validateTrafficUsageIdentity(simKey *model.SimKey, card model.TrafficUsageSimCard) error {
|
||||||
|
if simKey == nil || card.Iccid == "" || simKey.ICCID != card.Iccid {
|
||||||
|
return &trafficusage.Failure{Cause: gerror.New("invalid traffic usage card identity"), Permanent: true}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func trafficUsageFailureSnapshot(err error, usage **float64) bool {
|
||||||
|
var failure *trafficusage.Failure
|
||||||
|
if errors.As(err, &failure) {
|
||||||
|
if failure.UsageMb != nil {
|
||||||
|
*usage = failure.UsageMb
|
||||||
|
}
|
||||||
|
return failure.Permanent
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func publishTrafficUsageFailure(ctx context.Context, queue string, message any, attempt int, permanent bool) error {
|
||||||
|
ctx, cancel := context.WithTimeout(ctx, 15*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
body, err := json.Marshal(message)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if permanent || attempt >= 10 {
|
||||||
|
g.Log().Errorf(ctx, "traffic usage retry exhausted or invalid, queue:%s, attempt:%d, payload:%s", queue, attempt, body)
|
||||||
|
err = trafficUsagePublish(ctx, queue+".dlq", body, nil)
|
||||||
|
return waitTrafficUsagePublishFailure(ctx, err)
|
||||||
|
}
|
||||||
|
delay := 30 * time.Second
|
||||||
|
if attempt >= 3 {
|
||||||
|
delay = 2 * time.Minute
|
||||||
|
}
|
||||||
|
if attempt >= 6 {
|
||||||
|
delay = 5 * time.Minute
|
||||||
|
}
|
||||||
|
err = trafficUsagePublishDelayed(ctx, queue+".delay."+delay.String(), queue, delay, body, nil)
|
||||||
|
return waitTrafficUsagePublishFailure(ctx, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func waitTrafficUsagePublishFailure(ctx context.Context, err error) error {
|
||||||
|
if err != nil {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
case <-time.After(time.Second):
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
package rabbitmq
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
amqp "github.com/rabbitmq/amqp091-go"
|
||||||
|
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/consts"
|
||||||
|
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/model"
|
||||||
|
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/service/trafficusage"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestTrafficUsageRetryRetainsPayload(t *testing.T) {
|
||||||
|
stubTrafficPublish(t)
|
||||||
|
usage := 12.5
|
||||||
|
handler := &trafficUsageHandlerStub{dailyErr: &trafficusage.Failure{Cause: errors.New("database down"), UsageMb: &usage}}
|
||||||
|
consumer := New(handler, nil, nil)
|
||||||
|
message := model.TrafficUsageDailyRetryMessage{SimKey: &model.SimKey{ICCID: "card"}, Card: model.TrafficUsageSimCard{Iccid: "card"}, QueryDate: "20260831"}
|
||||||
|
calls := 0
|
||||||
|
trafficUsagePublishDelayed = func(_ context.Context, queue, target string, delay time.Duration, body []byte, _ amqp.Table) error {
|
||||||
|
calls++
|
||||||
|
var retry model.TrafficUsageDailyRetryMessage
|
||||||
|
if err := json.Unmarshal(body, &retry); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if retry.Attempt != 1 || retry.UsageMb == nil || *retry.UsageMb != usage || retry.QueryDate != message.QueryDate || retry.LastError == "" || delay != 30*time.Second || target != consts.QueueTrafficUsageDailyRetry || queue == target {
|
||||||
|
t.Fatalf("invalid retry: %+v", retry)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if err := consumer.handleTrafficUsageDailyRetry(context.Background(), message); err != nil || calls != 1 {
|
||||||
|
t.Fatalf("calls=%d err=%v", calls, err)
|
||||||
|
}
|
||||||
|
message.Attempt = 9
|
||||||
|
dlqCalls := 0
|
||||||
|
trafficUsagePublish = func(_ context.Context, queue string, body []byte, _ amqp.Table) error {
|
||||||
|
dlqCalls++
|
||||||
|
if queue != consts.QueueTrafficUsageDailyRetry+".dlq" {
|
||||||
|
t.Fatal(queue)
|
||||||
|
}
|
||||||
|
var retry model.TrafficUsageDailyRetryMessage
|
||||||
|
if err := json.Unmarshal(body, &retry); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if retry.Attempt != 10 || retry.UsageMb == nil {
|
||||||
|
t.Fatal("DLQ lost snapshot")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if err := consumer.handleTrafficUsageDailyRetry(context.Background(), message); err != nil || dlqCalls != 1 || calls != 1 {
|
||||||
|
t.Fatalf("DLQ not published: %v", err)
|
||||||
|
}
|
||||||
|
expected := errors.New("broker unavailable")
|
||||||
|
trafficUsagePublish = func(context.Context, string, []byte, amqp.Table) error { return expected }
|
||||||
|
if err := consumer.handleTrafficUsageDailyRetry(context.Background(), message); !errors.Is(err, expected) {
|
||||||
|
t.Fatal("failed publication must not ack original")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
package rabbitmq
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gogf/gf/v2/frame/g"
|
||||||
|
|
||||||
|
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/consts"
|
||||||
|
mq "git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/library/rabbitmq"
|
||||||
|
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (c *Consumers) initWorkOrderAutoClose(ctx context.Context) error {
|
||||||
|
if scanner, ok := c.workOrder.(interface{ CloseOverdue(context.Context) error }); ok {
|
||||||
|
go func() {
|
||||||
|
ticker := time.NewTicker(5 * time.Minute)
|
||||||
|
defer ticker.Stop()
|
||||||
|
for {
|
||||||
|
if err := scanner.CloseOverdue(ctx); err != nil {
|
||||||
|
g.Log().Errorf(ctx, "工单到期补偿失败: %v", err)
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
case <-ticker.C:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
if !mq.Enabled(ctx) {
|
||||||
|
g.Log().Info(ctx, "work order auto close consumer skipped, RabbitMQ disabled")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if err := mq.ConsumeJSON[model.WorkOrderAutoCloseMessage](
|
||||||
|
ctx,
|
||||||
|
consts.QueueWorkOrderAutoClose,
|
||||||
|
"iotlcm-work-order-auto-close",
|
||||||
|
c.handleWorkOrderAutoClose,
|
||||||
|
); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
g.Log().Infof(ctx, "work order auto close consumer started, queue:%s", consts.QueueWorkOrderAutoClose)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Consumers) handleWorkOrderAutoClose(ctx context.Context, message model.WorkOrderAutoCloseMessage) error {
|
||||||
|
if message.WoNo == "" {
|
||||||
|
g.Log().Errorf(ctx, "work order auto close message dropped, invalid message:%+v", message)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return c.workOrder.AutoCloseResolved(ctx, message)
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,706 @@
|
|||||||
|
package zset
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gogf/gf/v2/container/gvar"
|
||||||
|
|
||||||
|
queue "git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/queue/zset"
|
||||||
|
)
|
||||||
|
|
||||||
|
type redisCall struct {
|
||||||
|
command string
|
||||||
|
args []any
|
||||||
|
}
|
||||||
|
|
||||||
|
type fakeRedis struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
calls []redisCall
|
||||||
|
responses []*gvar.Var
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeRedis) Do(_ context.Context, command string, args ...any) (*gvar.Var, error) {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
f.calls = append(f.calls, redisCall{command: command, args: args})
|
||||||
|
if len(f.responses) == 0 {
|
||||||
|
return gvar.New(int64(1)), nil
|
||||||
|
}
|
||||||
|
response := f.responses[0]
|
||||||
|
f.responses = f.responses[1:]
|
||||||
|
return response, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeRedis) Eval(_ context.Context, script string, numKeys int64, keys []string, args []any) (*gvar.Var, error) {
|
||||||
|
allArgs := []any{script, numKeys}
|
||||||
|
for _, key := range keys {
|
||||||
|
allArgs = append(allArgs, key)
|
||||||
|
}
|
||||||
|
allArgs = append(allArgs, args...)
|
||||||
|
return f.Do(context.Background(), "EVAL", allArgs...)
|
||||||
|
}
|
||||||
|
|
||||||
|
type testHandler struct {
|
||||||
|
query func(context.Context, queue.Task) Result
|
||||||
|
apply func(context.Context, []Result) error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *testHandler) Query(ctx context.Context, task queue.Task) Result {
|
||||||
|
if h.query != nil {
|
||||||
|
return h.query(ctx, task)
|
||||||
|
}
|
||||||
|
return Result{Task: task, Success: true, UsageMB: float64(task.CardID)}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *testHandler) Apply(ctx context.Context, results []Result) error {
|
||||||
|
if h.apply != nil {
|
||||||
|
return h.apply(ctx, results)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func taskMember(task queue.Task) string {
|
||||||
|
value, _ := json.Marshal(task)
|
||||||
|
return string(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
func claimedTask(task queue.Task) queue.ClaimedTask {
|
||||||
|
return queue.ClaimedTask{Task: task, Member: taskMember(task), LeaseToken: "token-" + task.TaskID}
|
||||||
|
}
|
||||||
|
|
||||||
|
func newTestConsumer(t *testing.T, fake *fakeRedis, handler Handler, config Config) *Consumer {
|
||||||
|
t.Helper()
|
||||||
|
store, err := queue.New(fake, "flow:")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
consumer, err := New(store, handler, config)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return consumer
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRoundSummaryAccumulatesAndFlushes(t *testing.T) {
|
||||||
|
fake := &fakeRedis{responses: []*gvar.Var{gvar.New([]any{"runId", "run-1"})}}
|
||||||
|
consumer := newTestConsumer(t, fake, &testHandler{}, Config{RoundRetryEnabled: true})
|
||||||
|
started := time.Date(2026, 9, 22, 3, 0, 0, 0, time.UTC)
|
||||||
|
consumer.recordRoundBatch("run-1", 1, []queue.ClaimedTask{
|
||||||
|
claimedTask(queue.Task{RunID: "run-1", TaskID: "a"}),
|
||||||
|
claimedTask(queue.Task{RunID: "run-1", TaskID: "b"}),
|
||||||
|
}, []queue.TaskOutcome{
|
||||||
|
{Success: true},
|
||||||
|
{Retry: true},
|
||||||
|
}, started)
|
||||||
|
consumer.recordRoundBatch("run-1", 1, []queue.ClaimedTask{
|
||||||
|
claimedTask(queue.Task{RunID: "run-1", TaskID: "c"}),
|
||||||
|
}, []queue.TaskOutcome{{Success: false}}, started.Add(2*time.Second))
|
||||||
|
|
||||||
|
key := "run-1\x001"
|
||||||
|
consumer.summaryMu.Lock()
|
||||||
|
summary := *consumer.roundSummaries[key]
|
||||||
|
consumer.summaryMu.Unlock()
|
||||||
|
if summary.inputCount != 3 || summary.successCount != 1 || summary.failedCount != 2 || summary.deferredCount != 1 || summary.terminalFailedCount != 1 {
|
||||||
|
t.Fatalf("unexpected summary: %+v", summary)
|
||||||
|
}
|
||||||
|
if !summary.startedAt.Equal(started) {
|
||||||
|
t.Fatalf("startedAt = %v, want %v", summary.startedAt, started)
|
||||||
|
}
|
||||||
|
|
||||||
|
consumer.flushRoundSummary(context.Background(), "run-1", 1)
|
||||||
|
consumer.summaryMu.Lock()
|
||||||
|
_, exists := consumer.roundSummaries[key]
|
||||||
|
consumer.summaryMu.Unlock()
|
||||||
|
if exists {
|
||||||
|
t.Fatal("round summary was not removed after flush")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleClaimsAppliesSuccessOnceAndAcknowledgesBatch(t *testing.T) {
|
||||||
|
fake := &fakeRedis{responses: []*gvar.Var{gvar.New(int64(2))}}
|
||||||
|
applyCalls := 0
|
||||||
|
var applied []Result
|
||||||
|
handler := &testHandler{apply: func(_ context.Context, results []Result) error {
|
||||||
|
applyCalls++
|
||||||
|
applied = append(applied, results...)
|
||||||
|
return nil
|
||||||
|
}}
|
||||||
|
consumer := newTestConsumer(t, fake, handler, Config{ClaimBatch: 2, MobileConcurrency: 2, TelecomConcurrency: 1})
|
||||||
|
claimed := []queue.ClaimedTask{
|
||||||
|
claimedTask(queue.Task{TaskID: "1", RunID: "run", ChanKey: "mobile-a", TempKey: mobileTempKey, Operator: 1, CardID: 1}),
|
||||||
|
claimedTask(queue.Task{TaskID: "2", RunID: "run", ChanKey: "mobile-a", TempKey: mobileTempKey, Operator: 1, CardID: 2}),
|
||||||
|
}
|
||||||
|
if err := consumer.handleClaims(context.Background(), claimed); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if applyCalls != 1 || len(applied) != 2 {
|
||||||
|
t.Fatalf("apply calls = %d, results = %d", applyCalls, len(applied))
|
||||||
|
}
|
||||||
|
if len(fake.calls) != 1 || fake.calls[0].command != "EVAL" {
|
||||||
|
t.Fatalf("Redis calls = %+v, want one batch ack", fake.calls)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDatabaseFailureLeavesEntireBatchLeased(t *testing.T) {
|
||||||
|
fake := &fakeRedis{}
|
||||||
|
handler := &testHandler{
|
||||||
|
query: func(_ context.Context, task queue.Task) Result {
|
||||||
|
if task.CardID == 2 {
|
||||||
|
return Result{Task: task, ErrorClass: "business", Code: 11011, Message: "invalid card"}
|
||||||
|
}
|
||||||
|
return Result{Task: task, Success: true, UsageMB: 1.5}
|
||||||
|
},
|
||||||
|
apply: func(context.Context, []Result) error { return errors.New("database unavailable") },
|
||||||
|
}
|
||||||
|
consumer := newTestConsumer(t, fake, handler, Config{ClaimBatch: 2, MobileConcurrency: 2, TelecomConcurrency: 1})
|
||||||
|
claimed := []queue.ClaimedTask{
|
||||||
|
claimedTask(queue.Task{TaskID: "success", RunID: "run", ChanKey: "mobile-a", TempKey: mobileTempKey, Operator: 1, CardID: 1}),
|
||||||
|
claimedTask(queue.Task{TaskID: "failed", RunID: "run", ChanKey: "mobile-a", TempKey: mobileTempKey, Operator: 1, CardID: 2}),
|
||||||
|
}
|
||||||
|
err := consumer.handleClaims(context.Background(), claimed)
|
||||||
|
if err == nil || err.Error() != "apply monthly usage result batch: database unavailable" {
|
||||||
|
t.Fatalf("handleClaims() error = %v", err)
|
||||||
|
}
|
||||||
|
if len(fake.calls) != 0 {
|
||||||
|
t.Fatalf("Redis calls = %+v, want no ACK when database update fails", fake.calls)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBusinessFailuresAreTerminalWithoutRetry(t *testing.T) {
|
||||||
|
fake := &fakeRedis{responses: []*gvar.Var{gvar.New(int64(2))}}
|
||||||
|
failureCount := 0
|
||||||
|
handler := &testHandler{query: func(_ context.Context, task queue.Task) Result {
|
||||||
|
return Result{Task: task, ErrorClass: "business", Code: 13000, Message: "busy"}
|
||||||
|
}}
|
||||||
|
consumer := newTestConsumer(t, fake, handler, Config{
|
||||||
|
MobileConcurrency: 2,
|
||||||
|
TelecomConcurrency: 1,
|
||||||
|
OnFailure: func(context.Context, Result) {
|
||||||
|
failureCount++
|
||||||
|
},
|
||||||
|
})
|
||||||
|
claimed := []queue.ClaimedTask{
|
||||||
|
claimedTask(queue.Task{TaskID: "1", RunID: "run", ChanKey: "mobile-a", TempKey: mobileTempKey, Operator: 1, CardID: 1}),
|
||||||
|
claimedTask(queue.Task{TaskID: "2", RunID: "run", ChanKey: "mobile-a", TempKey: mobileTempKey, Operator: 1, CardID: 2}),
|
||||||
|
}
|
||||||
|
if err := consumer.handleClaims(context.Background(), claimed); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if failureCount != 2 {
|
||||||
|
t.Fatalf("failure count = %d, want 2", failureCount)
|
||||||
|
}
|
||||||
|
if len(fake.calls) != 1 || fake.calls[0].command != "EVAL" {
|
||||||
|
t.Fatalf("Redis calls = %+v; business failures must only be acknowledged", fake.calls)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRetryableFailuresRemainLeasedWhileSuccessesAreAcknowledged(t *testing.T) {
|
||||||
|
fake := &fakeRedis{responses: []*gvar.Var{gvar.New(int64(1))}}
|
||||||
|
var applied []Result
|
||||||
|
handler := &testHandler{
|
||||||
|
query: func(_ context.Context, task queue.Task) Result {
|
||||||
|
if task.CardID == 1 {
|
||||||
|
return Result{Task: task, ErrorClass: "network", Retryable: true, Message: "temporary failure"}
|
||||||
|
}
|
||||||
|
return Result{Task: task, Success: true, UsageMB: 2}
|
||||||
|
},
|
||||||
|
apply: func(_ context.Context, results []Result) error {
|
||||||
|
applied = append(applied, results...)
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
consumer := newTestConsumer(t, fake, handler, Config{MobileConcurrency: 2, TelecomConcurrency: 1})
|
||||||
|
claimed := []queue.ClaimedTask{
|
||||||
|
claimedTask(queue.Task{TaskID: "retry", RunID: "run", ChanKey: "mobile-a", Operator: 1, CardID: 1}),
|
||||||
|
claimedTask(queue.Task{TaskID: "success", RunID: "run", ChanKey: "mobile-a", Operator: 1, CardID: 2}),
|
||||||
|
}
|
||||||
|
if err := consumer.handleClaims(context.Background(), claimed); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(applied) != 2 {
|
||||||
|
t.Fatalf("applied results = %d, want both result rows for handler", len(applied))
|
||||||
|
}
|
||||||
|
if len(fake.calls) != 1 || fake.calls[0].command != "EVAL" {
|
||||||
|
t.Fatalf("Redis calls = %+v, want one ACK for successful/terminal result", fake.calls)
|
||||||
|
}
|
||||||
|
if len(fake.calls[0].args) == 0 {
|
||||||
|
t.Fatal("ACK call did not include arguments")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunRejectsAnotherActiveConsumer(t *testing.T) {
|
||||||
|
fake := &fakeRedis{responses: []*gvar.Var{gvar.New(nil)}}
|
||||||
|
consumer := newTestConsumer(t, fake, &testHandler{}, Config{})
|
||||||
|
if err := consumer.Run(context.Background()); !errors.Is(err, ErrAlreadyRunning) {
|
||||||
|
t.Fatalf("Run error = %v, want ErrAlreadyRunning", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProcessOnceStartsEveryRegisteredChannel(t *testing.T) {
|
||||||
|
fake := &fakeRedis{responses: []*gvar.Var{
|
||||||
|
gvar.New([]any{"mobile-a", "telecom-a", "mobile-b"}),
|
||||||
|
gvar.New([]any{}),
|
||||||
|
gvar.New([]any{}),
|
||||||
|
gvar.New([]any{}),
|
||||||
|
}}
|
||||||
|
consumer := newTestConsumer(t, fake, &testHandler{}, Config{})
|
||||||
|
processed, err := consumer.processOnce(context.Background())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !processed {
|
||||||
|
t.Fatal("processOnce() did not start registered channels")
|
||||||
|
}
|
||||||
|
deadline := time.Now().Add(time.Second)
|
||||||
|
for {
|
||||||
|
fake.mu.Lock()
|
||||||
|
callCount := len(fake.calls)
|
||||||
|
fake.mu.Unlock()
|
||||||
|
if callCount == 4 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if time.Now().After(deadline) {
|
||||||
|
t.Fatalf("Redis calls = %d, want channels + 3 claims", callCount)
|
||||||
|
}
|
||||||
|
time.Sleep(time.Millisecond)
|
||||||
|
}
|
||||||
|
consumer.channelGroup.Wait()
|
||||||
|
if len(fake.calls) != 4 {
|
||||||
|
t.Fatalf("Redis calls = %d, want channels + 3 claims", len(fake.calls))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type channelRedis struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
claims map[string][]queue.Task
|
||||||
|
claimCounts map[string]int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *channelRedis) Do(_ context.Context, command string, _ ...any) (*gvar.Var, error) {
|
||||||
|
return nil, errors.New("unexpected command " + command)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *channelRedis) Eval(_ context.Context, script string, _ int64, keys []string, args []any) (*gvar.Var, error) {
|
||||||
|
r.mu.Lock()
|
||||||
|
defer r.mu.Unlock()
|
||||||
|
if strings.Contains(script, "ZRANGEBYSCORE") {
|
||||||
|
chanKey := strings.TrimPrefix(keys[0], "flow:")
|
||||||
|
r.claimCounts[chanKey]++
|
||||||
|
tasks := r.claims[chanKey]
|
||||||
|
if len(tasks) == 0 {
|
||||||
|
return gvar.New([]any{}), nil
|
||||||
|
}
|
||||||
|
task := tasks[0]
|
||||||
|
r.claims[chanKey] = tasks[1:]
|
||||||
|
return gvar.New([]any{taskMember(task), "token-" + task.TaskID}), nil
|
||||||
|
}
|
||||||
|
if len(keys) == 2 && len(args) == 2 {
|
||||||
|
return gvar.New(int64(1)), nil
|
||||||
|
}
|
||||||
|
return nil, errors.New("unexpected eval")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSlowChannelDoesNotBlockAnotherChannelFromClaimingAgain(t *testing.T) {
|
||||||
|
redis := &channelRedis{
|
||||||
|
claims: map[string][]queue.Task{
|
||||||
|
"telecom-a": {{TaskID: "t1", RunID: "run", ChanKey: "telecom-a", TempKey: telecomTempKey, Operator: 2}},
|
||||||
|
"mobile-a": {
|
||||||
|
{TaskID: "m1", RunID: "run", ChanKey: "mobile-a", TempKey: mobileTempKey, Operator: 1},
|
||||||
|
{TaskID: "m2", RunID: "run", ChanKey: "mobile-a", TempKey: mobileTempKey, Operator: 1},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
claimCounts: make(map[string]int),
|
||||||
|
}
|
||||||
|
telecomStarted := make(chan struct{})
|
||||||
|
releaseTelecom := make(chan struct{})
|
||||||
|
handler := &testHandler{query: func(_ context.Context, task queue.Task) Result {
|
||||||
|
if task.ChanKey == "telecom-a" {
|
||||||
|
close(telecomStarted)
|
||||||
|
<-releaseTelecom
|
||||||
|
}
|
||||||
|
return Result{Task: task, Success: true}
|
||||||
|
}}
|
||||||
|
store, err := queue.New(redis, "flow:")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
consumer, err := New(store, handler, Config{ClaimBatch: 1, MobileConcurrency: 1, TelecomConcurrency: 1, LeaseTTL: time.Minute})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
defer cancel()
|
||||||
|
consumer.startChannel(ctx, "telecom-a")
|
||||||
|
consumer.startChannel(ctx, "mobile-a")
|
||||||
|
select {
|
||||||
|
case <-telecomStarted:
|
||||||
|
case <-time.After(time.Second):
|
||||||
|
t.Fatal("telecom channel did not start")
|
||||||
|
}
|
||||||
|
if consumer.startChannel(ctx, "telecom-a") {
|
||||||
|
t.Fatal("channel executor started twice while first executor is running")
|
||||||
|
}
|
||||||
|
deadline := time.Now().Add(time.Second)
|
||||||
|
for {
|
||||||
|
redis.mu.Lock()
|
||||||
|
mobileClaims := redis.claimCounts["mobile-a"]
|
||||||
|
redis.mu.Unlock()
|
||||||
|
if mobileClaims >= 2 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if time.Now().After(deadline) {
|
||||||
|
t.Fatalf("mobile claim count = %d while telecom is blocked", mobileClaims)
|
||||||
|
}
|
||||||
|
time.Sleep(time.Millisecond)
|
||||||
|
}
|
||||||
|
close(releaseTelecom)
|
||||||
|
consumer.channelGroup.Wait()
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClaimedBatchRenewsLeaseUntilAcknowledged(t *testing.T) {
|
||||||
|
fake := &fakeRedis{}
|
||||||
|
handler := &testHandler{query: func(_ context.Context, task queue.Task) Result {
|
||||||
|
time.Sleep(45 * time.Millisecond)
|
||||||
|
return Result{Task: task, Success: true}
|
||||||
|
}}
|
||||||
|
consumer := newTestConsumer(t, fake, handler, Config{LeaseTTL: 30 * time.Millisecond, TaskTimeout: 20 * time.Millisecond, MobileConcurrency: 1, TelecomConcurrency: 1})
|
||||||
|
claimed := []queue.ClaimedTask{claimedTask(queue.Task{TaskID: "1", RunID: "run", ChanKey: "mobile-a", TempKey: mobileTempKey, Operator: 1})}
|
||||||
|
if err := consumer.handleClaims(context.Background(), claimed); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
fake.mu.Lock()
|
||||||
|
defer fake.mu.Unlock()
|
||||||
|
if len(fake.calls) < 2 {
|
||||||
|
t.Fatalf("Redis calls = %d, want lease renewal and ACK", len(fake.calls))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLostBatchLeasePreventsApplyAndAck(t *testing.T) {
|
||||||
|
fake := &fakeRedis{responses: []*gvar.Var{gvar.New(int64(0))}}
|
||||||
|
applyCalls := 0
|
||||||
|
handler := &testHandler{
|
||||||
|
query: func(ctx context.Context, task queue.Task) Result {
|
||||||
|
<-ctx.Done()
|
||||||
|
return Result{Task: task, ErrorClass: "canceled"}
|
||||||
|
},
|
||||||
|
apply: func(context.Context, []Result) error {
|
||||||
|
applyCalls++
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
consumer := newTestConsumer(t, fake, handler, Config{LeaseTTL: 30 * time.Millisecond, TaskTimeout: 20 * time.Millisecond, MobileConcurrency: 1, TelecomConcurrency: 1})
|
||||||
|
claimed := []queue.ClaimedTask{claimedTask(queue.Task{TaskID: "1", RunID: "run", ChanKey: "mobile-a", TempKey: mobileTempKey, Operator: 1})}
|
||||||
|
err := consumer.handleClaims(context.Background(), claimed)
|
||||||
|
if !errors.Is(err, ErrLeaseLost) {
|
||||||
|
t.Fatalf("handleClaims() error = %v, want ErrLeaseLost", err)
|
||||||
|
}
|
||||||
|
if applyCalls != 0 {
|
||||||
|
t.Fatalf("apply calls = %d, want 0 after lease loss", applyCalls)
|
||||||
|
}
|
||||||
|
fake.mu.Lock()
|
||||||
|
defer fake.mu.Unlock()
|
||||||
|
if len(fake.calls) != 1 {
|
||||||
|
t.Fatalf("Redis calls = %d, want only failed lease renewal", len(fake.calls))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func newTrackingHandler(release chan struct{}, current, maximum *atomic.Int32) *testHandler {
|
||||||
|
return &testHandler{query: func(_ context.Context, task queue.Task) Result {
|
||||||
|
active := current.Add(1)
|
||||||
|
for {
|
||||||
|
observed := maximum.Load()
|
||||||
|
if active <= observed || maximum.CompareAndSwap(observed, active) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
<-release
|
||||||
|
current.Add(-1)
|
||||||
|
return Result{Task: task, Success: true}
|
||||||
|
}}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMobileChannelsHaveIndependentConcurrencyPools(t *testing.T) {
|
||||||
|
var current atomic.Int32
|
||||||
|
var maximum atomic.Int32
|
||||||
|
release := make(chan struct{})
|
||||||
|
consumer := newTestConsumer(t, &fakeRedis{}, newTrackingHandler(release, ¤t, &maximum), Config{MobileConcurrency: 2, TelecomConcurrency: 1})
|
||||||
|
var group sync.WaitGroup
|
||||||
|
for index := 0; index < 3; index++ {
|
||||||
|
group.Add(1)
|
||||||
|
go func(index int) {
|
||||||
|
defer group.Done()
|
||||||
|
task := queue.Task{TaskID: string(rune('1' + index)), ChanKey: "mobile-" + string(rune('a'+index)), TempKey: mobileTempKey, CardID: int64(index + 1)}
|
||||||
|
_ = consumer.queryClaim(context.Background(), claimedTask(task), carrierMobile, mobileQPS)
|
||||||
|
}(index)
|
||||||
|
}
|
||||||
|
time.Sleep(100 * time.Millisecond)
|
||||||
|
if maximum.Load() != 3 {
|
||||||
|
t.Fatalf("maximum in-flight across channels = %d, want 3 (one pool per channel)", maximum.Load())
|
||||||
|
}
|
||||||
|
close(release)
|
||||||
|
group.Wait()
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestChannelConcurrencyPoolCapsWithinChannel(t *testing.T) {
|
||||||
|
var current atomic.Int32
|
||||||
|
var maximum atomic.Int32
|
||||||
|
release := make(chan struct{})
|
||||||
|
consumer := newTestConsumer(t, &fakeRedis{}, newTrackingHandler(release, ¤t, &maximum), Config{MobileConcurrency: 2, TelecomConcurrency: 1})
|
||||||
|
var group sync.WaitGroup
|
||||||
|
for index := 0; index < 3; index++ {
|
||||||
|
group.Add(1)
|
||||||
|
go func(index int) {
|
||||||
|
defer group.Done()
|
||||||
|
task := queue.Task{TaskID: string(rune('1' + index)), ChanKey: "mobile-a", TempKey: mobileTempKey, CardID: int64(index + 1)}
|
||||||
|
_ = consumer.queryClaim(context.Background(), claimedTask(task), carrierMobile, mobileQPS)
|
||||||
|
}(index)
|
||||||
|
}
|
||||||
|
time.Sleep(100 * time.Millisecond)
|
||||||
|
if maximum.Load() != 2 {
|
||||||
|
t.Fatalf("maximum in-flight within one channel = %d, want 2", maximum.Load())
|
||||||
|
}
|
||||||
|
close(release)
|
||||||
|
group.Wait()
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMobileAndTelecomUseIndependentChannelPools(t *testing.T) {
|
||||||
|
started := make(chan carrier, 2)
|
||||||
|
release := make(chan struct{})
|
||||||
|
handler := &testHandler{query: func(_ context.Context, task queue.Task) Result {
|
||||||
|
channelCarrier, _ := policyForTemplate(task.TempKey, Config{})
|
||||||
|
started <- channelCarrier
|
||||||
|
<-release
|
||||||
|
return Result{Task: task, Success: true}
|
||||||
|
}}
|
||||||
|
consumer := newTestConsumer(t, &fakeRedis{}, handler, Config{MobileConcurrency: 1, TelecomConcurrency: 1})
|
||||||
|
go func() {
|
||||||
|
task := queue.Task{TaskID: "mobile", ChanKey: "mobile-a", TempKey: mobileTempKey, Operator: 1}
|
||||||
|
_ = consumer.queryClaim(context.Background(), claimedTask(task), carrierMobile, mobileQPS)
|
||||||
|
}()
|
||||||
|
go func() {
|
||||||
|
task := queue.Task{TaskID: "telecom", ChanKey: "telecom-a", TempKey: telecomTempKey, Operator: 2}
|
||||||
|
_ = consumer.queryClaim(context.Background(), claimedTask(task), carrierTelecom, telecomQPS)
|
||||||
|
}()
|
||||||
|
|
||||||
|
seen := map[carrier]bool{}
|
||||||
|
for len(seen) < 2 {
|
||||||
|
select {
|
||||||
|
case value := <-started:
|
||||||
|
seen[value] = true
|
||||||
|
case <-time.After(time.Second):
|
||||||
|
t.Fatalf("carrier pools did not progress independently: %+v", seen)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
close(release)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUnknownTemplateUsesGenericStrategy(t *testing.T) {
|
||||||
|
fake := &fakeRedis{responses: []*gvar.Var{gvar.New(int64(2))}}
|
||||||
|
queryCalls := 0
|
||||||
|
failures := 0
|
||||||
|
applyCalls := 0
|
||||||
|
handler := &testHandler{
|
||||||
|
query: func(_ context.Context, task queue.Task) Result {
|
||||||
|
queryCalls++
|
||||||
|
return Result{Task: task, Success: true, UsageMB: 1}
|
||||||
|
},
|
||||||
|
apply: func(_ context.Context, results []Result) error {
|
||||||
|
applyCalls++
|
||||||
|
for _, result := range results {
|
||||||
|
if !result.Success || result.BlockChannel {
|
||||||
|
t.Fatalf("unexpected result = %+v", result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
consumer := newTestConsumer(t, fake, handler, Config{OnFailure: func(context.Context, Result) { failures++ }})
|
||||||
|
claimed := []queue.ClaimedTask{
|
||||||
|
claimedTask(queue.Task{TaskID: "1", RunID: "run", ChanKey: "unknown-a", TempKey: "unknown-template", Operator: 1, CardID: 1}),
|
||||||
|
claimedTask(queue.Task{TaskID: "2", RunID: "run", ChanKey: "unknown-a", TempKey: "unknown-template", Operator: 2, CardID: 2}),
|
||||||
|
}
|
||||||
|
if err := consumer.handleClaims(context.Background(), claimed); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if queryCalls != 2 || failures != 0 || applyCalls != 1 {
|
||||||
|
t.Fatalf("query calls = %d, failures = %d, apply calls = %d", queryCalls, failures, applyCalls)
|
||||||
|
}
|
||||||
|
if len(fake.calls) != 1 || fake.calls[0].command != "EVAL" {
|
||||||
|
t.Fatalf("Redis calls = %+v, want one ack script", fake.calls)
|
||||||
|
}
|
||||||
|
if len(fake.responses) != 0 {
|
||||||
|
t.Fatalf("unexpected leftover scripted responses: %+v", fake.responses)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestChannelPacerLimitsEachChannelIndependently(t *testing.T) {
|
||||||
|
pacer := newChannelPacer()
|
||||||
|
now := time.Unix(100, 0)
|
||||||
|
pacedQPS := 30.0
|
||||||
|
if delay := pacer.reserve("mobile-a", pacedQPS, now); delay != 0 {
|
||||||
|
t.Fatalf("first reservation delay = %s", delay)
|
||||||
|
}
|
||||||
|
if delay := pacer.reserve("mobile-a", pacedQPS, now); delay < 33*time.Millisecond || delay > 34*time.Millisecond {
|
||||||
|
t.Fatalf("second reservation delay = %s", delay)
|
||||||
|
}
|
||||||
|
if delay := pacer.reserve("mobile-b", pacedQPS, now); delay != 0 {
|
||||||
|
t.Fatalf("independent channel delay = %s", delay)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestChannelPacerSkipsWhenQPSIsDisabled(t *testing.T) {
|
||||||
|
pacer := newChannelPacer()
|
||||||
|
now := time.Unix(100, 0)
|
||||||
|
if delay := pacer.reserve("mobile-a", 0, now); delay != 0 {
|
||||||
|
t.Fatalf("disabled qps delay = %s", delay)
|
||||||
|
}
|
||||||
|
if len(pacer.next) != 0 {
|
||||||
|
t.Fatalf("disabled qps should not reserve channel slots, got %+v", pacer.next)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewDefaultsTaskTimeout(t *testing.T) {
|
||||||
|
consumer := newTestConsumer(t, &fakeRedis{}, &testHandler{}, Config{})
|
||||||
|
if consumer.config.TaskTimeout != 30*time.Second {
|
||||||
|
t.Fatalf("TaskTimeout = %s, want 30s default", consumer.config.TaskTimeout)
|
||||||
|
}
|
||||||
|
if consumer.config.GenericQPS != defaultGenericQPS {
|
||||||
|
t.Fatalf("GenericQPS = %v, want %v default", consumer.config.GenericQPS, defaultGenericQPS)
|
||||||
|
}
|
||||||
|
if _, err := New(consumer.store, &testHandler{}, Config{LeaseTTL: 10 * time.Second}); err == nil {
|
||||||
|
t.Fatal("New() with LeaseTTL below default TaskTimeout should fail")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPolicyForTemplateUsesTemplateValues(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
tempKey string
|
||||||
|
carrier carrier
|
||||||
|
qps float64
|
||||||
|
}{
|
||||||
|
{name: "mobile", tempKey: mobileTempKey, carrier: carrierMobile, qps: mobileQPS},
|
||||||
|
{name: "mobile mock", tempKey: mobileMockKey, carrier: carrierMobile, qps: mobileQPS},
|
||||||
|
{name: "shared Tengyu", tempKey: sharedTengyuTempKey, carrier: carrierMobile, qps: mobileQPS},
|
||||||
|
{name: "shared Xuzhou mobile", tempKey: sharedXuzhouMobileTempKey, carrier: carrierMobile, qps: mobileQPS},
|
||||||
|
{name: "shared Lelian", tempKey: sharedLelianTempKey, carrier: carrierMobile, qps: mobileQPS},
|
||||||
|
{name: "shared Qianxun", tempKey: sharedQianxunTempKey, carrier: carrierMobile, qps: mobileQPS},
|
||||||
|
{name: "shared Henan", tempKey: sharedHenanTempKey, carrier: carrierMobile, qps: mobileQPS},
|
||||||
|
{name: "shared Daihe", tempKey: sharedDaiheTempKey, carrier: carrierMobile, qps: mobileQPS},
|
||||||
|
{name: "shared Beixin", tempKey: sharedBeixinTempKey, carrier: carrierMobile, qps: mobileQPS},
|
||||||
|
// lanshang_8000/8003 are OneLink shared-token templates whose names
|
||||||
|
// omit the sim_onelink_ prefix. Before this case was added they fell
|
||||||
|
// through to carrierGeneric and were throttled to 1.5 QPS, which made a
|
||||||
|
// 6k-card status/traffic/IMEI run take over an hour per channel.
|
||||||
|
{name: "shared Lanshang 8000", tempKey: sharedLanshang8000TempKey, carrier: carrierMobile, qps: mobileQPS},
|
||||||
|
{name: "shared Lanshang 8003", tempKey: sharedLanshang8003TempKey, carrier: carrierMobile, qps: mobileQPS},
|
||||||
|
{name: "shared Lanshang case and padding", tempKey: " LANSHANG_8000 ", carrier: carrierMobile, qps: mobileQPS},
|
||||||
|
{name: "telecom", tempKey: telecomTempKey, carrier: carrierTelecom, qps: telecomQPS},
|
||||||
|
{name: "telecom v3", tempKey: " SIM_DX_V3 ", carrier: carrierTelecom, qps: telecomQPS},
|
||||||
|
{name: "unknown telecom version", tempKey: "sim_dx_v2_future", carrier: carrierGeneric, qps: defaultGenericQPS},
|
||||||
|
{name: "unknown template", tempKey: "unknown-template", carrier: carrierGeneric, qps: defaultGenericQPS},
|
||||||
|
{name: "missing template", tempKey: "", carrier: carrierGeneric, qps: defaultGenericQPS},
|
||||||
|
}
|
||||||
|
for _, test := range tests {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
gotCarrier, gotQPS := policyForTemplate(test.tempKey, Config{})
|
||||||
|
if gotCarrier != test.carrier || gotQPS != test.qps {
|
||||||
|
t.Fatalf("policyForTemplate(%q) = %q,%v", test.tempKey, gotCarrier, gotQPS)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPolicyForTemplateConfigurationOverridesDefaults(t *testing.T) {
|
||||||
|
config := Config{
|
||||||
|
GenericQPS: 2.5,
|
||||||
|
TemplateQPS: map[string]float64{
|
||||||
|
" SIM_YD_V5 ": 12,
|
||||||
|
"unknown-template": 3,
|
||||||
|
"invalid": 0,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
config.setDefaults()
|
||||||
|
|
||||||
|
if gotCarrier, gotQPS := policyForTemplate("sim_yd_v5", config); gotCarrier != carrierMobile || gotQPS != 12 {
|
||||||
|
t.Fatalf("mobile override = %q,%v, want mobile,12", gotCarrier, gotQPS)
|
||||||
|
}
|
||||||
|
if gotCarrier, gotQPS := policyForTemplate("unknown-template", config); gotCarrier != carrierGeneric || gotQPS != 3 {
|
||||||
|
t.Fatalf("generic override = %q,%v, want generic,3", gotCarrier, gotQPS)
|
||||||
|
}
|
||||||
|
if gotCarrier, gotQPS := policyForTemplate("other-template", config); gotCarrier != carrierGeneric || gotQPS != 2.5 {
|
||||||
|
t.Fatalf("generic default = %q,%v, want generic,2.5", gotCarrier, gotQPS)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestFailurePoolAlertThresholdIsObservabilityOnly 覆盖失败池告警:
|
||||||
|
// - 未配置阀值(0)时不发 HLEN 查询
|
||||||
|
// - 失败数低于阀值时不告警
|
||||||
|
// - 超过阀值时发一次 HLEN 并打 WARN,但绝不截断 Run
|
||||||
|
//
|
||||||
|
// 告警本身是纯观测行为,所以断言的是「有没有多打 Redis 命令」,
|
||||||
|
// 而不是「Run 有没有被改坏」——后者必须永远为「没被改坏」。
|
||||||
|
func TestFailurePoolAlertThresholdIsObservabilityOnly(t *testing.T) {
|
||||||
|
newMeta := func(failed int64) queue.RunMeta {
|
||||||
|
return queue.RunMeta{RunID: "run-1", Status: "completed_with_failures", FailedCount: failed, CurrentRound: 1}
|
||||||
|
}
|
||||||
|
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
threshold int64
|
||||||
|
failed int64
|
||||||
|
wantHLenCalls int
|
||||||
|
}{
|
||||||
|
{"未配置阀值不查询", 0, 1_000_000, 0},
|
||||||
|
{"低于阀值不告警", 50000, 49999, 0},
|
||||||
|
{"等于阀值不告警", 50000, 50000, 0},
|
||||||
|
{"超过阀值告警一次", 50000, 50001, 1},
|
||||||
|
{"远超阀值也只告警一次", 50000, 500_000, 1},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
fake := &fakeRedis{}
|
||||||
|
consumer := newTestConsumer(t, fake, &testHandler{}, Config{
|
||||||
|
FailurePoolAlertThreshold: tc.threshold,
|
||||||
|
})
|
||||||
|
consumer.reportFailurePoolSize(context.Background(), "run-1", newMeta(tc.failed))
|
||||||
|
|
||||||
|
hlen := 0
|
||||||
|
for _, call := range fake.calls {
|
||||||
|
if call.command == "HLEN" {
|
||||||
|
hlen++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if hlen != tc.wantHLenCalls {
|
||||||
|
t.Fatalf("%s: HLEN 调用 %d 次, want %d", tc.name, hlen, tc.wantHLenCalls)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestFailurePoolAlertReadsCorrectKey 告警必须查该 Run 自己的失败池键。
|
||||||
|
func TestFailurePoolAlertReadsCorrectKey(t *testing.T) {
|
||||||
|
fake := &fakeRedis{}
|
||||||
|
consumer := newTestConsumer(t, fake, &testHandler{}, Config{FailurePoolAlertThreshold: 10})
|
||||||
|
consumer.reportFailurePoolSize(context.Background(), "run-abc", queue.RunMeta{
|
||||||
|
RunID: "run-abc", Status: "completed_with_failures", FailedCount: 11, CurrentRound: 1,
|
||||||
|
})
|
||||||
|
|
||||||
|
var key string
|
||||||
|
for _, call := range fake.calls {
|
||||||
|
if call.command == "HLEN" {
|
||||||
|
if len(call.args) > 0 {
|
||||||
|
key, _ = call.args[0].(string)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
want, _ := consumer.store.FailedKey("run-abc")
|
||||||
|
if key != want {
|
||||||
|
t.Fatalf("HLEN key = %q, want %q", key, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,480 @@
|
|||||||
|
package zset
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
queue "git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/queue/zset"
|
||||||
|
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/testutil"
|
||||||
|
)
|
||||||
|
|
||||||
|
type runHandler struct {
|
||||||
|
query func(queue.Task) Result
|
||||||
|
apply func([]Result) error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h runHandler) Query(_ context.Context, task queue.Task) Result { return h.query(task) }
|
||||||
|
func (h runHandler) Apply(_ context.Context, results []Result) error {
|
||||||
|
if h.apply != nil {
|
||||||
|
return h.apply(results)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func runTasks(t *testing.T, store *queue.Store, run string, count int) []queue.Task {
|
||||||
|
t.Helper()
|
||||||
|
ctx := context.Background()
|
||||||
|
if err := store.CreateRun(ctx, queue.RunMeta{RunID: run}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
tasks := make([]queue.Task, count)
|
||||||
|
for i := range tasks {
|
||||||
|
tasks[i] = queue.Task{RunID: run, TaskID: fmt.Sprintf("%s:%d", run, i), CardID: int64(i + 1), ChanKey: "channel", TempKey: mobileTempKey, ICCID: fmt.Sprintf("card-%d", i)}
|
||||||
|
}
|
||||||
|
if n, err := store.EnqueueRunBatch(ctx, run, "channel", tasks, time.Now()); err != nil || n != count {
|
||||||
|
t.Fatalf("enqueue %d: %v", n, err)
|
||||||
|
}
|
||||||
|
return tasks
|
||||||
|
}
|
||||||
|
|
||||||
|
func runMeta(t *testing.T, store *queue.Store, run string) queue.RunMeta {
|
||||||
|
t.Helper()
|
||||||
|
meta, err := store.GetRunMeta(context.Background(), run)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return meta
|
||||||
|
}
|
||||||
|
|
||||||
|
func runConsumer(t *testing.T, store *queue.Store, handler Handler, rounds bool) *Consumer {
|
||||||
|
t.Helper()
|
||||||
|
c, err := New(store, handler, Config{RunQueueEnabled: true, RoundRetryEnabled: rounds, BlockedTasksAsFailures: !rounds, EventPrefix: "sim_status_queue", TemplateQPS: map[string]float64{mobileTempKey: 1000000}})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
|
||||||
|
func claimRun(t *testing.T, store *queue.Store, run string, now time.Time, count int) []queue.ClaimedTask {
|
||||||
|
t.Helper()
|
||||||
|
claims, err := store.ClaimRun(context.Background(), run, "channel", now, now.Add(time.Minute), count)
|
||||||
|
if err != nil || len(claims) != count {
|
||||||
|
t.Fatalf("claim got %d want %d: %v", len(claims), count, err)
|
||||||
|
}
|
||||||
|
return claims
|
||||||
|
}
|
||||||
|
|
||||||
|
func finishProducer(t *testing.T, store *queue.Store, run string) {
|
||||||
|
t.Helper()
|
||||||
|
if err := store.MarkProducerDone(context.Background(), run); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func checkTerminal(t *testing.T, store *queue.Store, run string, success, failed int64) {
|
||||||
|
t.Helper()
|
||||||
|
meta := runMeta(t, store, run)
|
||||||
|
want := "completed"
|
||||||
|
if failed > 0 {
|
||||||
|
want = "completed_with_failures"
|
||||||
|
}
|
||||||
|
if meta.Status != want || meta.CompletedAt.IsZero() || meta.SuccessCount != success || meta.FailedCount != failed || meta.ActiveCount != 0 || meta.InflightCount != 0 || meta.NextRoundCount != 0 {
|
||||||
|
t.Fatalf("unexpected terminal metadata: %+v", meta)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunConsumerCountsAndCompletes(t *testing.T) {
|
||||||
|
redis := testutil.Redis(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
for _, failure := range []bool{false, true} {
|
||||||
|
t.Run(fmt.Sprint(failure), func(t *testing.T) {
|
||||||
|
store, _ := queue.New(redis, "sim-status:")
|
||||||
|
run := fmt.Sprint("counts-", failure)
|
||||||
|
tasks := runTasks(t, store, run, 3)
|
||||||
|
if n, err := store.EnqueueRunBatch(ctx, run, "channel", tasks, time.Now()); err != nil || n != 0 {
|
||||||
|
t.Fatalf("duplicate enqueue: %d %v", n, err)
|
||||||
|
}
|
||||||
|
finishProducer(t, store, run)
|
||||||
|
handler := runHandler{query: func(task queue.Task) Result {
|
||||||
|
return Result{Task: task, Success: !failure || task.CardID != 2, ErrorClass: "business"}
|
||||||
|
}, apply: func(_ []Result) error {
|
||||||
|
if meta := runMeta(t, store, run); meta.SuccessCount != 0 || meta.FailedCount != 0 {
|
||||||
|
return errors.New("counted before DB apply")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}}
|
||||||
|
c := runConsumer(t, store, handler, false)
|
||||||
|
claims := claimRun(t, store, run, time.Now(), 3)
|
||||||
|
if err := c.handleRunClaims(ctx, claims); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var failures int64
|
||||||
|
if failure {
|
||||||
|
failures = 1
|
||||||
|
}
|
||||||
|
checkTerminal(t, store, run, 3-failures, failures)
|
||||||
|
outcomes := []queue.TaskOutcome{{Success: true}, {Success: true}, {Success: true}}
|
||||||
|
if n, err := store.FinalizeRunTasks(ctx, claims, outcomes); err != nil || n != 0 {
|
||||||
|
t.Fatalf("duplicate finalize: %d %v", n, err)
|
||||||
|
}
|
||||||
|
checkTerminal(t, store, run, 3-failures, failures)
|
||||||
|
key, _ := store.RunMetaKey(run)
|
||||||
|
ttl, err := redis.Do(ctx, "TTL", key)
|
||||||
|
if err != nil || ttl.Int64() < 29*24*3600 {
|
||||||
|
t.Fatalf("retention=%v err=%v", ttl, err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunConsumerLateProducerEmptyAndHistoricalRunCompletion(t *testing.T) {
|
||||||
|
redis := testutil.Redis(t)
|
||||||
|
store, _ := queue.New(redis, "sim-status:")
|
||||||
|
ctx := context.Background()
|
||||||
|
if err := store.CreateRun(ctx, queue.RunMeta{RunID: "historical", Status: "running", ProducerDone: true}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
runTasks(t, store, "empty", 0)
|
||||||
|
runTasks(t, store, "late", 1)
|
||||||
|
c := runConsumer(t, store, runHandler{query: func(task queue.Task) Result { return Result{Task: task, Success: true} }}, false)
|
||||||
|
claims := claimRun(t, store, "late", time.Now(), 1)
|
||||||
|
if err := c.handleRunClaims(ctx, claims); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if meta := runMeta(t, store, "late"); !meta.CompletedAt.IsZero() || meta.SuccessCount != 1 {
|
||||||
|
t.Fatalf("premature completion: %+v", meta)
|
||||||
|
}
|
||||||
|
finishProducer(t, store, "late")
|
||||||
|
finishProducer(t, store, "empty")
|
||||||
|
if _, err := c.processOnce(ctx); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
c.channelGroup.Wait()
|
||||||
|
checkTerminal(t, store, "late", 1, 0)
|
||||||
|
checkTerminal(t, store, "empty", 0, 0)
|
||||||
|
checkTerminal(t, store, "historical", 0, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunConsumerRecoveryAndFencing(t *testing.T) {
|
||||||
|
redis := testutil.Redis(t)
|
||||||
|
store, _ := queue.New(redis, "sim-status:")
|
||||||
|
ctx := context.Background()
|
||||||
|
for _, mode := range []string{"database", "network", "stale"} {
|
||||||
|
t.Run(mode, func(t *testing.T) {
|
||||||
|
runTasks(t, store, mode, 1)
|
||||||
|
finishProducer(t, store, mode)
|
||||||
|
now := time.Now()
|
||||||
|
claims := claimRun(t, store, mode, now, 1)
|
||||||
|
good := runHandler{query: func(task queue.Task) Result { return Result{Task: task, Success: true} }}
|
||||||
|
handler := good
|
||||||
|
if mode == "database" {
|
||||||
|
handler.apply = func([]Result) error { return errors.New("database unavailable") }
|
||||||
|
}
|
||||||
|
if mode == "network" {
|
||||||
|
handler.query = func(task queue.Task) Result { return Result{Task: task, Retryable: true, ErrorClass: "network"} }
|
||||||
|
}
|
||||||
|
c := runConsumer(t, store, handler, false)
|
||||||
|
if mode != "stale" {
|
||||||
|
err := c.handleRunClaims(ctx, claims)
|
||||||
|
if (err != nil) != (mode == "database") {
|
||||||
|
t.Fatalf("handle: %v", err)
|
||||||
|
}
|
||||||
|
if meta := runMeta(t, store, mode); meta.SuccessCount != 0 || meta.FailedCount != 0 || meta.ActiveCount != 1 || meta.InflightCount != 1 || meta.Status != "running" {
|
||||||
|
t.Fatalf("lost pending task: %+v", meta)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fresh := claimRun(t, store, mode, now.Add(2*time.Minute), 1)
|
||||||
|
if n, err := store.RenewRunBatch(ctx, claims, now.Add(3*time.Minute)); err != nil || n != 0 {
|
||||||
|
t.Fatalf("stale renewal: %d %v", n, err)
|
||||||
|
}
|
||||||
|
if n, err := store.FinalizeRunTasks(ctx, claims, []queue.TaskOutcome{{Success: true}}); err != nil || n != 0 {
|
||||||
|
t.Fatalf("stale ack: %d %v", n, err)
|
||||||
|
}
|
||||||
|
if n, err := store.BlockRunScopedAsFailed(ctx, claims[0], queue.TaskOutcome{}); err != nil || n != -1 {
|
||||||
|
t.Fatalf("stale block: %d %v", n, err)
|
||||||
|
}
|
||||||
|
c.handler = good
|
||||||
|
if err := c.handleRunClaims(ctx, fresh); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
checkTerminal(t, store, mode, 1, 0)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunConsumerBlockedChannelCountsOnlyUnfinishedTasks(t *testing.T) {
|
||||||
|
redis := testutil.Redis(t)
|
||||||
|
store, _ := queue.New(redis, "sim-status:")
|
||||||
|
ctx := context.Background()
|
||||||
|
runTasks(t, store, "blocked", 5)
|
||||||
|
runTasks(t, store, "other", 1)
|
||||||
|
// Production continues while the channel is blocked.
|
||||||
|
claims := claimRun(t, store, "blocked", time.Now(), 2)
|
||||||
|
var applied int
|
||||||
|
successQueried := make(chan struct{})
|
||||||
|
handler := runHandler{query: func(task queue.Task) Result {
|
||||||
|
if task.CardID == 1 {
|
||||||
|
close(successQueried)
|
||||||
|
return Result{Task: task, Success: true}
|
||||||
|
}
|
||||||
|
<-successQueried
|
||||||
|
return Result{Task: task, BlockChannel: true, ErrorClass: "channel_limited", Code: 797, ErrCode: "S00004", Message: "limited"}
|
||||||
|
}, apply: func(results []Result) error { applied = countSuccessful(results); return nil }}
|
||||||
|
c := runConsumer(t, store, handler, false)
|
||||||
|
// Ensure the success response arrives before the channel-limit response.
|
||||||
|
if err := c.handleRunClaims(ctx, claims); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
meta := runMeta(t, store, "blocked")
|
||||||
|
if applied != 1 || meta.SuccessCount != 1 || meta.FailedCount != 4 || meta.ActiveCount != 0 || meta.InflightCount != 0 {
|
||||||
|
t.Fatalf("applied=%d meta=%+v", applied, meta)
|
||||||
|
}
|
||||||
|
if n, err := store.BlockRunScopedAsFailed(ctx, claims[1], queue.TaskOutcome{}); err != nil || n != -1 {
|
||||||
|
t.Fatalf("duplicate block: %d %v", n, err)
|
||||||
|
}
|
||||||
|
late := queue.Task{TaskID: "late", RunID: "blocked", ChanKey: "channel", CardID: 99}
|
||||||
|
if n, err := store.EnqueueRunBatch(ctx, "blocked", "channel", []queue.Task{late}, time.Now()); err != nil || n != 0 {
|
||||||
|
t.Fatalf("blocked enqueue: %d %v", n, err)
|
||||||
|
}
|
||||||
|
key, _ := store.FailedKey("blocked")
|
||||||
|
failures, err := redis.Do(ctx, "HLEN", key)
|
||||||
|
if err != nil || failures.Int() != 4 {
|
||||||
|
t.Fatalf("failure records=%v err=%v", failures, err)
|
||||||
|
}
|
||||||
|
stored, err := redis.Do(ctx, "HGET", key, "blocked:4")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var record queue.FailureRecord
|
||||||
|
if err := json.Unmarshal([]byte(stored.String()), &record); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if record.TaskID != "blocked:4" || record.CardID != 5 || record.ICCID != "card-4" || record.Attempts != 0 || record.FailureType != "channel_blocked" || record.ErrCode != "S00004" {
|
||||||
|
t.Fatalf("failure identity/reason: %+v", record)
|
||||||
|
}
|
||||||
|
stateKey, _ := store.TaskStateKey("blocked")
|
||||||
|
state, err := redis.Do(ctx, "HGET", stateKey, "blocked:4")
|
||||||
|
if err != nil || state.MapStrAny()["status"] != "channel_blocked" {
|
||||||
|
t.Fatalf("blocked state=%v err=%v", state, err)
|
||||||
|
}
|
||||||
|
if meta := runMeta(t, store, "other"); meta.ActiveCount != 1 || meta.FailedCount != 0 {
|
||||||
|
t.Fatalf("other run affected: %+v", meta)
|
||||||
|
}
|
||||||
|
finishProducer(t, store, "blocked")
|
||||||
|
if err := c.completeRun(ctx, "blocked"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
checkTerminal(t, store, "blocked", 1, 4)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunConsumerMonthlyRoundsRemainEnabled(t *testing.T) {
|
||||||
|
redis := testutil.Redis(t)
|
||||||
|
store, _ := queue.New(redis, "flow:")
|
||||||
|
ctx := context.Background()
|
||||||
|
runTasks(t, store, "monthly", 1)
|
||||||
|
finishProducer(t, store, "monthly")
|
||||||
|
c := runConsumer(t, store, runHandler{query: func(task queue.Task) Result { return Result{Task: task, ErrorClass: "mobile_busy", Retryable: true} }}, true)
|
||||||
|
for round := 1; round <= 5; round++ {
|
||||||
|
if err := c.handleRunClaims(ctx, claimRun(t, store, "monthly", time.Now(), 1)); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if round < 5 {
|
||||||
|
if meta := runMeta(t, store, "monthly"); meta.NextRoundCount != 1 || meta.FailedCount != 0 || meta.Status != "running" {
|
||||||
|
t.Fatalf("round %d: %+v", round, meta)
|
||||||
|
}
|
||||||
|
if ok, err := store.AdvanceRunRound(ctx, "monthly", 5); err != nil || !ok {
|
||||||
|
t.Fatalf("advance %v %v", ok, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
checkTerminal(t, store, "monthly", 0, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunConsumerStatusRetriesRetryableResultsForThreeRounds(t *testing.T) {
|
||||||
|
redis := testutil.Redis(t)
|
||||||
|
store, _ := queue.New(redis, "sim-status:")
|
||||||
|
ctx := context.Background()
|
||||||
|
runTasks(t, store, "status", 1)
|
||||||
|
finishProducer(t, store, "status")
|
||||||
|
c, err := New(store, runHandler{query: func(task queue.Task) Result {
|
||||||
|
return Result{Task: task, ErrorClass: "carrier_retry", Retryable: true, Code: 9999, ErrCode: "S00004"}
|
||||||
|
}}, Config{
|
||||||
|
RunQueueEnabled: true,
|
||||||
|
RoundRetryEnabled: true,
|
||||||
|
MaxRounds: 3,
|
||||||
|
EventPrefix: "sim_status_queue",
|
||||||
|
ShouldRetryNextRound: func(result Result) bool {
|
||||||
|
return result.Retryable
|
||||||
|
},
|
||||||
|
TemplateQPS: map[string]float64{mobileTempKey: 1000000},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for round := 1; round <= 3; round++ {
|
||||||
|
if err := c.handleRunClaims(ctx, claimRun(t, store, "status", time.Now(), 1)); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if round < 3 {
|
||||||
|
if meta := runMeta(t, store, "status"); meta.NextRoundCount != 1 || meta.FailedCount != 0 || meta.Status != "running" {
|
||||||
|
t.Fatalf("round %d: %+v", round, meta)
|
||||||
|
}
|
||||||
|
if ok, err := store.AdvanceRunRound(ctx, "status", 3); err != nil || !ok {
|
||||||
|
t.Fatalf("advance %v %v", ok, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
checkTerminal(t, store, "status", 0, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReplayMaxRoundOverridesConsumerDefault(t *testing.T) {
|
||||||
|
redis := testutil.Redis(t)
|
||||||
|
store, _ := queue.New(redis, "sim-status:")
|
||||||
|
ctx := context.Background()
|
||||||
|
if err := store.CreateRun(ctx, queue.RunMeta{RunID: "replay-one", Operation: "status", SyncType: "replay", MaxRound: 1}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
task := queue.Task{RunID: "replay-one", TaskID: "task", CardID: 1, ICCID: "card", ChanKey: "channel", TempKey: mobileTempKey}
|
||||||
|
if _, err := store.EnqueueRunBatch(ctx, task.RunID, task.ChanKey, []queue.Task{task}, time.Now()); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
finishProducer(t, store, "replay-one")
|
||||||
|
c, err := New(store, runHandler{query: func(task queue.Task) Result { return Result{Task: task, ErrorClass: "mobile_busy", Retryable: true} }}, Config{RunQueueEnabled: true, RoundRetryEnabled: true, MaxRounds: 3, TemplateQPS: map[string]float64{mobileTempKey: 1000000}})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := c.handleRunClaims(ctx, claimRun(t, store, "replay-one", time.Now(), 1)); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
checkTerminal(t, store, "replay-one", 0, 1)
|
||||||
|
meta := runMeta(t, store, "replay-one")
|
||||||
|
if meta.CurrentRound != 1 || meta.NextRoundCount != 0 {
|
||||||
|
t.Fatalf("replay advanced rounds: %+v", meta)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunQueueModeDoesNotEnableRoundRetry(t *testing.T) {
|
||||||
|
redis := testutil.Redis(t)
|
||||||
|
store, _ := queue.New(redis, "sim-status:")
|
||||||
|
runTasks(t, store, "single-round", 1)
|
||||||
|
finishProducer(t, store, "single-round")
|
||||||
|
c := runConsumer(t, store, runHandler{query: func(task queue.Task) Result { return Result{Task: task, ErrorClass: "mobile_busy", Retryable: true} }}, false)
|
||||||
|
if err := c.handleRunClaims(context.Background(), claimRun(t, store, "single-round", time.Now(), 1)); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
checkTerminal(t, store, "single-round", 0, 1)
|
||||||
|
if meta := runMeta(t, store, "single-round"); meta.CurrentRound != 1 {
|
||||||
|
t.Fatalf("unexpected retry: %+v", meta)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMonthlyChannelBlockKeepsExistingCountingPolicy(t *testing.T) {
|
||||||
|
redis := testutil.Redis(t)
|
||||||
|
store, _ := queue.New(redis, "flow:")
|
||||||
|
ctx := context.Background()
|
||||||
|
runTasks(t, store, "monthly-block", 2)
|
||||||
|
claimRun(t, store, "monthly-block", time.Now(), 1)
|
||||||
|
for i := 0; i < 2; i++ {
|
||||||
|
want := int64(2)
|
||||||
|
if i == 1 {
|
||||||
|
want = 0
|
||||||
|
}
|
||||||
|
if n, err := store.BlockRunScoped(ctx, "monthly-block", "channel"); err != nil || n != want {
|
||||||
|
t.Fatalf("monthly block=%d err=%v", n, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if meta := runMeta(t, store, "monthly-block"); meta.FailedCount != 0 || meta.ActiveCount != 0 || meta.InflightCount != 0 {
|
||||||
|
t.Fatalf("monthly counting changed: %+v", meta)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunConsumerConcurrentRuns(t *testing.T) {
|
||||||
|
redis := testutil.Redis(t)
|
||||||
|
store, _ := queue.New(redis, "sim-status:")
|
||||||
|
ctx := context.Background()
|
||||||
|
var mu sync.Mutex
|
||||||
|
applied := map[string]int{}
|
||||||
|
c := runConsumer(t, store, runHandler{query: func(task queue.Task) Result { return Result{Task: task, Success: true} }, apply: func(results []Result) error {
|
||||||
|
mu.Lock()
|
||||||
|
defer mu.Unlock()
|
||||||
|
for _, result := range results {
|
||||||
|
applied[result.Task.RunID]++
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}}, false)
|
||||||
|
for _, run := range []string{"a", "b"} {
|
||||||
|
runTasks(t, store, run, 3)
|
||||||
|
finishProducer(t, store, run)
|
||||||
|
}
|
||||||
|
if started, err := c.processOnce(ctx); err != nil || !started {
|
||||||
|
t.Fatalf("process: %v %v", started, err)
|
||||||
|
}
|
||||||
|
c.channelGroup.Wait()
|
||||||
|
for _, run := range []string{"a", "b"} {
|
||||||
|
checkTerminal(t, store, run, 3, 0)
|
||||||
|
if applied[run] != 3 {
|
||||||
|
t.Fatalf("applied %s: %d", run, applied[run])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRunConsumerCoveredResultsCompleteWithoutFailures drives the full
|
||||||
|
// consumer path for a covered result: the round must close, the run must end
|
||||||
|
// as "completed" rather than "completed_with_failures", and the failure pool
|
||||||
|
// must stay empty. This is the IMEI 14000 "carrier has no record" case — on
|
||||||
|
// 2026-10-20 it accounted for every failure recorded in production while the
|
||||||
|
// run success rate read 21%.
|
||||||
|
func TestRunConsumerCoveredResultsCompleteWithoutFailures(t *testing.T) {
|
||||||
|
redis := testutil.Redis(t)
|
||||||
|
store, _ := queue.New(redis, "imei:")
|
||||||
|
ctx := context.Background()
|
||||||
|
runTasks(t, store, "covered", 4)
|
||||||
|
finishProducer(t, store, "covered")
|
||||||
|
queried := 0
|
||||||
|
c := runConsumer(t, store, runHandler{query: func(task queue.Task) Result {
|
||||||
|
queried++
|
||||||
|
switch task.CardID {
|
||||||
|
case 1:
|
||||||
|
return Result{Task: task, Success: true, Imei: "865466070817587", ImeiSynced: true}
|
||||||
|
case 2:
|
||||||
|
return Result{Task: task, ErrorClass: "no_record", Code: 14000, ImeiSynced: true, Covered: true, Message: "系统无记录,请检查输入参数是否正确"}
|
||||||
|
case 3:
|
||||||
|
return Result{Task: task, ErrorClass: "data_invalid", ImeiSynced: true, Covered: true, Message: "carrier returned no imei value"}
|
||||||
|
default:
|
||||||
|
return Result{Task: task, ErrorClass: "business", Code: 11011, Message: "invalid card"}
|
||||||
|
}
|
||||||
|
}}, false)
|
||||||
|
if err := c.handleRunClaims(ctx, claimRun(t, store, "covered", time.Now(), 4)); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if queried != 4 {
|
||||||
|
t.Fatalf("queried = %d, want 4", queried)
|
||||||
|
}
|
||||||
|
meta := runMeta(t, store, "covered")
|
||||||
|
// Three cards produced a usable or definitive answer (success + 2 covered),
|
||||||
|
// and only the business failure is counted. Without the covered channel the
|
||||||
|
// two 14000-style answers would have made failedCount=3.
|
||||||
|
if meta.Status != "completed_with_failures" || meta.SuccessCount != 3 || meta.FailedCount != 1 || meta.NextRoundCount != 0 {
|
||||||
|
t.Fatalf("unexpected run accounting: %+v", meta)
|
||||||
|
}
|
||||||
|
failureKey, _ := store.FailedKey("covered")
|
||||||
|
size, err := redis.Do(ctx, "HLEN", failureKey)
|
||||||
|
if err != nil || size.Int64() != 1 {
|
||||||
|
t.Fatalf("failure pool entries = %d err=%v, want 1 (only the business failure)", size.Int64(), err)
|
||||||
|
}
|
||||||
|
for _, taskID := range []string{"covered:1", "covered:2"} {
|
||||||
|
exists, err := redis.Do(ctx, "HEXISTS", failureKey, taskID)
|
||||||
|
if err != nil || exists.Int() != 0 {
|
||||||
|
t.Fatalf("covered card %s leaked into the failure pool: %v %v", taskID, exists, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
exists, err := redis.Do(ctx, "HEXISTS", failureKey, "covered:3")
|
||||||
|
if err != nil || exists.Int() != 1 {
|
||||||
|
t.Fatalf("the business failure is missing from the pool: %v %v", exists, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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-worker-svr/internal/dao/internal"
|
||||||
|
)
|
||||||
|
|
||||||
|
// batchDetailDao is the data access object for the table batch_detail.
|
||||||
|
// You can define custom methods on it to extend its functionality as needed.
|
||||||
|
type batchDetailDao struct {
|
||||||
|
*internal.BatchDetailDao
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
// BatchDetail is a globally accessible object for table batch_detail operations.
|
||||||
|
BatchDetail = batchDetailDao{internal.NewBatchDetailDao()}
|
||||||
|
)
|
||||||
|
|
||||||
|
// 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-worker-svr/internal/dao/internal"
|
||||||
|
)
|
||||||
|
|
||||||
|
// batchMainDao is the data access object for the table batch_main.
|
||||||
|
// You can define custom methods on it to extend its functionality as needed.
|
||||||
|
type batchMainDao struct {
|
||||||
|
*internal.BatchMainDao
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
// BatchMain is a globally accessible object for table batch_main operations.
|
||||||
|
BatchMain = batchMainDao{internal.NewBatchMainDao()}
|
||||||
|
)
|
||||||
|
|
||||||
|
// Add your custom methods and functionality below.
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
// ==========================================================================
|
||||||
|
// 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"
|
||||||
|
)
|
||||||
|
|
||||||
|
// BatchDetailDao is the data access object for the table batch_detail.
|
||||||
|
type BatchDetailDao 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 BatchDetailColumns // columns contains all the column names of Table for convenient usage.
|
||||||
|
handlers []gdb.ModelHandler // handlers for customized model modification.
|
||||||
|
}
|
||||||
|
|
||||||
|
// BatchDetailColumns defines and stores column names for the table batch_detail.
|
||||||
|
type BatchDetailColumns struct {
|
||||||
|
Id string // 主键ID
|
||||||
|
BatchNo string // 批次编号
|
||||||
|
BatchType string // 批次类型 枚举(与主表一致)
|
||||||
|
DetailStatus string // 明细异步处理状态 0-待处理 1-处理中 2-成功 3-失败
|
||||||
|
BlankOutStatus string // 作废状态 0-是 1-否
|
||||||
|
RowNo string // 行号
|
||||||
|
ItemId string // 项ID:卡ICCID/设备编码
|
||||||
|
ErrorCode string // 异常编码
|
||||||
|
ErrorReason string // 异常原因
|
||||||
|
Snapshot string // 信息快照
|
||||||
|
TenantId string // 租户ID
|
||||||
|
CreatedDept string // 创建部门ID
|
||||||
|
CreatedBy string // 创建者ID
|
||||||
|
CreatedAt string // 创建时间
|
||||||
|
UpdatedBy string // 更新者ID
|
||||||
|
UpdatedAt string // 更新时间
|
||||||
|
}
|
||||||
|
|
||||||
|
// batchDetailColumns holds the columns for the table batch_detail.
|
||||||
|
var batchDetailColumns = BatchDetailColumns{
|
||||||
|
Id: "id",
|
||||||
|
BatchNo: "batch_no",
|
||||||
|
BatchType: "batch_type",
|
||||||
|
DetailStatus: "detail_status",
|
||||||
|
BlankOutStatus: "blank_out_status",
|
||||||
|
RowNo: "row_no",
|
||||||
|
ItemId: "item_id",
|
||||||
|
ErrorCode: "error_code",
|
||||||
|
ErrorReason: "error_reason",
|
||||||
|
Snapshot: "snapshot",
|
||||||
|
TenantId: "tenant_id",
|
||||||
|
CreatedDept: "created_dept",
|
||||||
|
CreatedBy: "created_by",
|
||||||
|
CreatedAt: "created_at",
|
||||||
|
UpdatedBy: "updated_by",
|
||||||
|
UpdatedAt: "updated_at",
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewBatchDetailDao creates and returns a new DAO object for table data access.
|
||||||
|
func NewBatchDetailDao(handlers ...gdb.ModelHandler) *BatchDetailDao {
|
||||||
|
return &BatchDetailDao{
|
||||||
|
group: "default",
|
||||||
|
table: "batch_detail",
|
||||||
|
columns: batchDetailColumns,
|
||||||
|
handlers: handlers,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// DB retrieves and returns the underlying raw database management object of the current DAO.
|
||||||
|
func (dao *BatchDetailDao) DB() gdb.DB {
|
||||||
|
return g.DB(dao.group)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Table returns the table name of the current DAO.
|
||||||
|
func (dao *BatchDetailDao) Table() string {
|
||||||
|
return dao.table
|
||||||
|
}
|
||||||
|
|
||||||
|
// Columns returns all column names of the current DAO.
|
||||||
|
func (dao *BatchDetailDao) Columns() BatchDetailColumns {
|
||||||
|
return dao.columns
|
||||||
|
}
|
||||||
|
|
||||||
|
// Group returns the database configuration group name of the current DAO.
|
||||||
|
func (dao *BatchDetailDao) 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 *BatchDetailDao) 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 *BatchDetailDao) 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,139 @@
|
|||||||
|
// ==========================================================================
|
||||||
|
// 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"
|
||||||
|
)
|
||||||
|
|
||||||
|
// BatchMainDao is the data access object for the table batch_main.
|
||||||
|
type BatchMainDao 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 BatchMainColumns // columns contains all the column names of Table for convenient usage.
|
||||||
|
handlers []gdb.ModelHandler // handlers for customized model modification.
|
||||||
|
}
|
||||||
|
|
||||||
|
// BatchMainColumns defines and stores column names for the table batch_main.
|
||||||
|
type BatchMainColumns struct {
|
||||||
|
Id string // 主键ID
|
||||||
|
BatchNo string // 批次编号
|
||||||
|
OrderSn string // 订单编号
|
||||||
|
BatchType string // 批次类型 枚举
|
||||||
|
BatchStatus string // 批次状态 枚举
|
||||||
|
BatchAsyncStatus string // 异步处理状态
|
||||||
|
SourceType string // 来源方式 枚举
|
||||||
|
SourceBatchNo string // 来源批次编号
|
||||||
|
SourceOssId string // 源文件OSS ID
|
||||||
|
ResultOssId string // 结果文件OSS ID
|
||||||
|
IsCrossDept string // 是否跨级操作机构
|
||||||
|
TargetDeptId string // 目标机构编号
|
||||||
|
DeptId string // 机构编号
|
||||||
|
TotalCount string // 总数量
|
||||||
|
ValidCount string // 有效数量
|
||||||
|
SuccessCount string // 成功数量
|
||||||
|
FailCount string // 失败数量
|
||||||
|
RunResult string // 运行结果
|
||||||
|
FailReason string // 失败原因
|
||||||
|
BatchInfo string // 批次信息:入库/出库展示字段
|
||||||
|
Remark string // 备注扩展字段
|
||||||
|
StartTime string // 开始时间
|
||||||
|
EndTime string // 结束时间
|
||||||
|
TenantId string // 租户ID
|
||||||
|
CreatedDept string // 创建部门ID
|
||||||
|
CreatedBy string // 创建者ID
|
||||||
|
CreatedAt string // 创建时间
|
||||||
|
UpdatedBy string // 更新者ID
|
||||||
|
UpdatedAt string // 更新时间
|
||||||
|
SourceChannel string // 发起渠道:console/mini_program/api/system/legacy
|
||||||
|
ExecutionMode string // 操作规模:0未知1单卡2批量
|
||||||
|
}
|
||||||
|
|
||||||
|
// batchMainColumns holds the columns for the table batch_main.
|
||||||
|
var batchMainColumns = BatchMainColumns{
|
||||||
|
Id: "id",
|
||||||
|
BatchNo: "batch_no",
|
||||||
|
OrderSn: "order_sn",
|
||||||
|
BatchType: "batch_type",
|
||||||
|
BatchStatus: "batch_status",
|
||||||
|
BatchAsyncStatus: "batch_async_status",
|
||||||
|
SourceType: "source_type",
|
||||||
|
SourceBatchNo: "source_batch_no",
|
||||||
|
SourceOssId: "source_oss_id",
|
||||||
|
ResultOssId: "result_oss_id",
|
||||||
|
IsCrossDept: "is_cross_dept",
|
||||||
|
TargetDeptId: "target_dept_id",
|
||||||
|
DeptId: "dept_id",
|
||||||
|
TotalCount: "total_count",
|
||||||
|
ValidCount: "valid_count",
|
||||||
|
SuccessCount: "success_count",
|
||||||
|
FailCount: "fail_count",
|
||||||
|
RunResult: "run_result",
|
||||||
|
FailReason: "fail_reason",
|
||||||
|
BatchInfo: "batch_info",
|
||||||
|
Remark: "remark",
|
||||||
|
StartTime: "start_time",
|
||||||
|
EndTime: "end_time",
|
||||||
|
TenantId: "tenant_id",
|
||||||
|
CreatedDept: "created_dept",
|
||||||
|
CreatedBy: "created_by",
|
||||||
|
CreatedAt: "created_at",
|
||||||
|
UpdatedBy: "updated_by",
|
||||||
|
UpdatedAt: "updated_at",
|
||||||
|
SourceChannel: "source_channel",
|
||||||
|
ExecutionMode: "execution_mode",
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewBatchMainDao creates and returns a new DAO object for table data access.
|
||||||
|
func NewBatchMainDao(handlers ...gdb.ModelHandler) *BatchMainDao {
|
||||||
|
return &BatchMainDao{
|
||||||
|
group: "default",
|
||||||
|
table: "batch_main",
|
||||||
|
columns: batchMainColumns,
|
||||||
|
handlers: handlers,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// DB retrieves and returns the underlying raw database management object of the current DAO.
|
||||||
|
func (dao *BatchMainDao) DB() gdb.DB {
|
||||||
|
return g.DB(dao.group)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Table returns the table name of the current DAO.
|
||||||
|
func (dao *BatchMainDao) Table() string {
|
||||||
|
return dao.table
|
||||||
|
}
|
||||||
|
|
||||||
|
// Columns returns all column names of the current DAO.
|
||||||
|
func (dao *BatchMainDao) Columns() BatchMainColumns {
|
||||||
|
return dao.columns
|
||||||
|
}
|
||||||
|
|
||||||
|
// Group returns the database configuration group name of the current DAO.
|
||||||
|
func (dao *BatchMainDao) 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 *BatchMainDao) 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 *BatchMainDao) 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"
|
||||||
|
)
|
||||||
|
|
||||||
|
// SysConfigDao is the data access object for the table sys_config.
|
||||||
|
type SysConfigDao 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 SysConfigColumns // columns contains all the column names of Table for convenient usage.
|
||||||
|
handlers []gdb.ModelHandler // handlers for customized model modification.
|
||||||
|
}
|
||||||
|
|
||||||
|
// SysConfigColumns defines and stores column names for the table sys_config.
|
||||||
|
type SysConfigColumns struct {
|
||||||
|
ConfigId string //
|
||||||
|
TenantId string //
|
||||||
|
ConfigName string //
|
||||||
|
ConfigKey string //
|
||||||
|
ConfigValue string //
|
||||||
|
ConfigType string //
|
||||||
|
CreatedDept string //
|
||||||
|
CreatedBy string //
|
||||||
|
CreatedAt string //
|
||||||
|
UpdatedBy string //
|
||||||
|
UpdatedAt string //
|
||||||
|
Remark string //
|
||||||
|
}
|
||||||
|
|
||||||
|
// sysConfigColumns holds the columns for the table sys_config.
|
||||||
|
var sysConfigColumns = SysConfigColumns{
|
||||||
|
ConfigId: "config_id",
|
||||||
|
TenantId: "tenant_id",
|
||||||
|
ConfigName: "config_name",
|
||||||
|
ConfigKey: "config_key",
|
||||||
|
ConfigValue: "config_value",
|
||||||
|
ConfigType: "config_type",
|
||||||
|
CreatedDept: "created_dept",
|
||||||
|
CreatedBy: "created_by",
|
||||||
|
CreatedAt: "created_at",
|
||||||
|
UpdatedBy: "updated_by",
|
||||||
|
UpdatedAt: "updated_at",
|
||||||
|
Remark: "remark",
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewSysConfigDao creates and returns a new DAO object for table data access.
|
||||||
|
func NewSysConfigDao(handlers ...gdb.ModelHandler) *SysConfigDao {
|
||||||
|
return &SysConfigDao{
|
||||||
|
group: "default",
|
||||||
|
table: "sys_config",
|
||||||
|
columns: sysConfigColumns,
|
||||||
|
handlers: handlers,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// DB retrieves and returns the underlying raw database management object of the current DAO.
|
||||||
|
func (dao *SysConfigDao) DB() gdb.DB {
|
||||||
|
return g.DB(dao.group)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Table returns the table name of the current DAO.
|
||||||
|
func (dao *SysConfigDao) Table() string {
|
||||||
|
return dao.table
|
||||||
|
}
|
||||||
|
|
||||||
|
// Columns returns all column names of the current DAO.
|
||||||
|
func (dao *SysConfigDao) Columns() SysConfigColumns {
|
||||||
|
return dao.columns
|
||||||
|
}
|
||||||
|
|
||||||
|
// Group returns the database configuration group name of the current DAO.
|
||||||
|
func (dao *SysConfigDao) 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 *SysConfigDao) 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 *SysConfigDao) 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,125 @@
|
|||||||
|
// ==========================================================================
|
||||||
|
// 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"
|
||||||
|
)
|
||||||
|
|
||||||
|
// SysDeptDao is the data access object for the table sys_dept.
|
||||||
|
type SysDeptDao 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 SysDeptColumns // columns contains all the column names of Table for convenient usage.
|
||||||
|
handlers []gdb.ModelHandler // handlers for customized model modification.
|
||||||
|
}
|
||||||
|
|
||||||
|
// SysDeptColumns defines and stores column names for the table sys_dept.
|
||||||
|
type SysDeptColumns struct {
|
||||||
|
DeptId string // 部门id
|
||||||
|
TenantId string // 租户编号
|
||||||
|
ParentId string // 父部门id
|
||||||
|
Ancestors string // 祖级列表
|
||||||
|
DeptKey string // 机构编号
|
||||||
|
DeptName string // 部门名称
|
||||||
|
DeptType string // 部门类型(0:部门 1:公司)
|
||||||
|
DeptCategory string // 部门类别编码
|
||||||
|
OrderNum string // 显示顺序
|
||||||
|
Leader string // 负责人
|
||||||
|
Phone string // 联系电话
|
||||||
|
Email string // 邮箱
|
||||||
|
Status string // 部门状态(0正常 1停用)
|
||||||
|
CreditCode string // 统一社会信用代码
|
||||||
|
AreaCode string // 行政区域代码
|
||||||
|
Address string // 地址
|
||||||
|
DeptRole string // 机构默认角色ID
|
||||||
|
CreatedDept string // 创建部门
|
||||||
|
CreatedBy string // 创建者
|
||||||
|
CreatedAt string // 创建时间
|
||||||
|
UpdatedBy string // 更新者
|
||||||
|
UpdatedAt string // 更新时间
|
||||||
|
DeletedBy string // 删除人
|
||||||
|
DeletedAt string // 删除时间
|
||||||
|
}
|
||||||
|
|
||||||
|
// sysDeptColumns holds the columns for the table sys_dept.
|
||||||
|
var sysDeptColumns = SysDeptColumns{
|
||||||
|
DeptId: "dept_id",
|
||||||
|
TenantId: "tenant_id",
|
||||||
|
ParentId: "parent_id",
|
||||||
|
Ancestors: "ancestors",
|
||||||
|
DeptKey: "dept_key",
|
||||||
|
DeptName: "dept_name",
|
||||||
|
DeptType: "dept_type",
|
||||||
|
DeptCategory: "dept_category",
|
||||||
|
OrderNum: "order_num",
|
||||||
|
Leader: "leader",
|
||||||
|
Phone: "phone",
|
||||||
|
Email: "email",
|
||||||
|
Status: "status",
|
||||||
|
CreditCode: "credit_code",
|
||||||
|
AreaCode: "area_code",
|
||||||
|
Address: "address",
|
||||||
|
DeptRole: "dept_role",
|
||||||
|
CreatedDept: "created_dept",
|
||||||
|
CreatedBy: "created_by",
|
||||||
|
CreatedAt: "created_at",
|
||||||
|
UpdatedBy: "updated_by",
|
||||||
|
UpdatedAt: "updated_at",
|
||||||
|
DeletedBy: "deleted_by",
|
||||||
|
DeletedAt: "deleted_at",
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewSysDeptDao creates and returns a new DAO object for table data access.
|
||||||
|
func NewSysDeptDao(handlers ...gdb.ModelHandler) *SysDeptDao {
|
||||||
|
return &SysDeptDao{
|
||||||
|
group: "default",
|
||||||
|
table: "sys_dept",
|
||||||
|
columns: sysDeptColumns,
|
||||||
|
handlers: handlers,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// DB retrieves and returns the underlying raw database management object of the current DAO.
|
||||||
|
func (dao *SysDeptDao) DB() gdb.DB {
|
||||||
|
return g.DB(dao.group)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Table returns the table name of the current DAO.
|
||||||
|
func (dao *SysDeptDao) Table() string {
|
||||||
|
return dao.table
|
||||||
|
}
|
||||||
|
|
||||||
|
// Columns returns all column names of the current DAO.
|
||||||
|
func (dao *SysDeptDao) Columns() SysDeptColumns {
|
||||||
|
return dao.columns
|
||||||
|
}
|
||||||
|
|
||||||
|
// Group returns the database configuration group name of the current DAO.
|
||||||
|
func (dao *SysDeptDao) 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 *SysDeptDao) 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 *SysDeptDao) 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,111 @@
|
|||||||
|
// ==========================================================================
|
||||||
|
// 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"
|
||||||
|
)
|
||||||
|
|
||||||
|
// SysOssDao is the data access object for the table sys_oss.
|
||||||
|
type SysOssDao 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 SysOssColumns // columns contains all the column names of Table for convenient usage.
|
||||||
|
handlers []gdb.ModelHandler // handlers for customized model modification.
|
||||||
|
}
|
||||||
|
|
||||||
|
// SysOssColumns defines and stores column names for the table sys_oss.
|
||||||
|
type SysOssColumns struct {
|
||||||
|
OssId string // 对象存储主键
|
||||||
|
TenantId string // 租户编号
|
||||||
|
FileName string // 文件名
|
||||||
|
OriginalName string // 原名
|
||||||
|
FileSuffix string // 文件后缀名
|
||||||
|
Path string // 存储路径
|
||||||
|
Url string // URL地址
|
||||||
|
CreatedDept string // 创建部门
|
||||||
|
CreatedAt string // 创建时间
|
||||||
|
CreatedBy string // 创建者
|
||||||
|
UpdatedAt string // 更新时间
|
||||||
|
UpdatedBy string // 更新者
|
||||||
|
Service string // 服务商
|
||||||
|
Md5 string // 文件MD5
|
||||||
|
FileSize string // 文件大小
|
||||||
|
FileCrc16 string // 文件Crc16
|
||||||
|
FileSum string // 文件校验和
|
||||||
|
}
|
||||||
|
|
||||||
|
// sysOssColumns holds the columns for the table sys_oss.
|
||||||
|
var sysOssColumns = SysOssColumns{
|
||||||
|
OssId: "oss_id",
|
||||||
|
TenantId: "tenant_id",
|
||||||
|
FileName: "file_name",
|
||||||
|
OriginalName: "original_name",
|
||||||
|
FileSuffix: "file_suffix",
|
||||||
|
Path: "path",
|
||||||
|
Url: "url",
|
||||||
|
CreatedDept: "created_dept",
|
||||||
|
CreatedAt: "created_at",
|
||||||
|
CreatedBy: "created_by",
|
||||||
|
UpdatedAt: "updated_at",
|
||||||
|
UpdatedBy: "updated_by",
|
||||||
|
Service: "service",
|
||||||
|
Md5: "md5",
|
||||||
|
FileSize: "file_size",
|
||||||
|
FileCrc16: "file_crc16",
|
||||||
|
FileSum: "file_sum",
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewSysOssDao creates and returns a new DAO object for table data access.
|
||||||
|
func NewSysOssDao(handlers ...gdb.ModelHandler) *SysOssDao {
|
||||||
|
return &SysOssDao{
|
||||||
|
group: "default",
|
||||||
|
table: "sys_oss",
|
||||||
|
columns: sysOssColumns,
|
||||||
|
handlers: handlers,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// DB retrieves and returns the underlying raw database management object of the current DAO.
|
||||||
|
func (dao *SysOssDao) DB() gdb.DB {
|
||||||
|
return g.DB(dao.group)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Table returns the table name of the current DAO.
|
||||||
|
func (dao *SysOssDao) Table() string {
|
||||||
|
return dao.table
|
||||||
|
}
|
||||||
|
|
||||||
|
// Columns returns all column names of the current DAO.
|
||||||
|
func (dao *SysOssDao) Columns() SysOssColumns {
|
||||||
|
return dao.columns
|
||||||
|
}
|
||||||
|
|
||||||
|
// Group returns the database configuration group name of the current DAO.
|
||||||
|
func (dao *SysOssDao) 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 *SysOssDao) 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 *SysOssDao) 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,83 @@
|
|||||||
|
// ==========================================================================
|
||||||
|
// 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"
|
||||||
|
)
|
||||||
|
|
||||||
|
// SysPermissionRevisionDao is the data access object for the table sys_permission_revision.
|
||||||
|
type SysPermissionRevisionDao 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 SysPermissionRevisionColumns // columns contains all the column names of Table for convenient usage.
|
||||||
|
handlers []gdb.ModelHandler // handlers for customized model modification.
|
||||||
|
}
|
||||||
|
|
||||||
|
// SysPermissionRevisionColumns defines and stores column names for the table sys_permission_revision.
|
||||||
|
type SysPermissionRevisionColumns struct {
|
||||||
|
Id string //
|
||||||
|
Revision string //
|
||||||
|
UpdatedAt string //
|
||||||
|
}
|
||||||
|
|
||||||
|
// sysPermissionRevisionColumns holds the columns for the table sys_permission_revision.
|
||||||
|
var sysPermissionRevisionColumns = SysPermissionRevisionColumns{
|
||||||
|
Id: "id",
|
||||||
|
Revision: "revision",
|
||||||
|
UpdatedAt: "updated_at",
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewSysPermissionRevisionDao creates and returns a new DAO object for table data access.
|
||||||
|
func NewSysPermissionRevisionDao(handlers ...gdb.ModelHandler) *SysPermissionRevisionDao {
|
||||||
|
return &SysPermissionRevisionDao{
|
||||||
|
group: "default",
|
||||||
|
table: "sys_permission_revision",
|
||||||
|
columns: sysPermissionRevisionColumns,
|
||||||
|
handlers: handlers,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// DB retrieves and returns the underlying raw database management object of the current DAO.
|
||||||
|
func (dao *SysPermissionRevisionDao) DB() gdb.DB {
|
||||||
|
return g.DB(dao.group)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Table returns the table name of the current DAO.
|
||||||
|
func (dao *SysPermissionRevisionDao) Table() string {
|
||||||
|
return dao.table
|
||||||
|
}
|
||||||
|
|
||||||
|
// Columns returns all column names of the current DAO.
|
||||||
|
func (dao *SysPermissionRevisionDao) Columns() SysPermissionRevisionColumns {
|
||||||
|
return dao.columns
|
||||||
|
}
|
||||||
|
|
||||||
|
// Group returns the database configuration group name of the current DAO.
|
||||||
|
func (dao *SysPermissionRevisionDao) 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 *SysPermissionRevisionDao) 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 *SysPermissionRevisionDao) 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,119 @@
|
|||||||
|
// ==========================================================================
|
||||||
|
// 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"
|
||||||
|
)
|
||||||
|
|
||||||
|
// SysRoleDao is the data access object for the table sys_role.
|
||||||
|
type SysRoleDao 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 SysRoleColumns // columns contains all the column names of Table for convenient usage.
|
||||||
|
handlers []gdb.ModelHandler // handlers for customized model modification.
|
||||||
|
}
|
||||||
|
|
||||||
|
// SysRoleColumns defines and stores column names for the table sys_role.
|
||||||
|
type SysRoleColumns struct {
|
||||||
|
RoleId string //
|
||||||
|
TenantId string //
|
||||||
|
DeptId string //
|
||||||
|
RoleName string //
|
||||||
|
RoleKey string //
|
||||||
|
RoleSort string //
|
||||||
|
DataScope string //
|
||||||
|
MenuCheckStrictly string //
|
||||||
|
DeptCheckStrictly string //
|
||||||
|
Status string //
|
||||||
|
IsPass string //
|
||||||
|
SourceRoleId string //
|
||||||
|
SourceDeptId string //
|
||||||
|
CreatedDept string //
|
||||||
|
CreatedBy string //
|
||||||
|
CreatedAt string //
|
||||||
|
UpdatedBy string //
|
||||||
|
UpdatedAt string //
|
||||||
|
DeletedBy string //
|
||||||
|
DeletedAt string //
|
||||||
|
Remark string //
|
||||||
|
}
|
||||||
|
|
||||||
|
// sysRoleColumns holds the columns for the table sys_role.
|
||||||
|
var sysRoleColumns = SysRoleColumns{
|
||||||
|
RoleId: "role_id",
|
||||||
|
TenantId: "tenant_id",
|
||||||
|
DeptId: "dept_id",
|
||||||
|
RoleName: "role_name",
|
||||||
|
RoleKey: "role_key",
|
||||||
|
RoleSort: "role_sort",
|
||||||
|
DataScope: "data_scope",
|
||||||
|
MenuCheckStrictly: "menu_check_strictly",
|
||||||
|
DeptCheckStrictly: "dept_check_strictly",
|
||||||
|
Status: "status",
|
||||||
|
IsPass: "is_pass",
|
||||||
|
SourceRoleId: "source_role_id",
|
||||||
|
SourceDeptId: "source_dept_id",
|
||||||
|
CreatedDept: "created_dept",
|
||||||
|
CreatedBy: "created_by",
|
||||||
|
CreatedAt: "created_at",
|
||||||
|
UpdatedBy: "updated_by",
|
||||||
|
UpdatedAt: "updated_at",
|
||||||
|
DeletedBy: "deleted_by",
|
||||||
|
DeletedAt: "deleted_at",
|
||||||
|
Remark: "remark",
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewSysRoleDao creates and returns a new DAO object for table data access.
|
||||||
|
func NewSysRoleDao(handlers ...gdb.ModelHandler) *SysRoleDao {
|
||||||
|
return &SysRoleDao{
|
||||||
|
group: "default",
|
||||||
|
table: "sys_role",
|
||||||
|
columns: sysRoleColumns,
|
||||||
|
handlers: handlers,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// DB retrieves and returns the underlying raw database management object of the current DAO.
|
||||||
|
func (dao *SysRoleDao) DB() gdb.DB {
|
||||||
|
return g.DB(dao.group)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Table returns the table name of the current DAO.
|
||||||
|
func (dao *SysRoleDao) Table() string {
|
||||||
|
return dao.table
|
||||||
|
}
|
||||||
|
|
||||||
|
// Columns returns all column names of the current DAO.
|
||||||
|
func (dao *SysRoleDao) Columns() SysRoleColumns {
|
||||||
|
return dao.columns
|
||||||
|
}
|
||||||
|
|
||||||
|
// Group returns the database configuration group name of the current DAO.
|
||||||
|
func (dao *SysRoleDao) 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 *SysRoleDao) 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 *SysRoleDao) 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,83 @@
|
|||||||
|
// ==========================================================================
|
||||||
|
// 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"
|
||||||
|
)
|
||||||
|
|
||||||
|
// SysRoleDeptDao is the data access object for the table sys_role_dept.
|
||||||
|
type SysRoleDeptDao 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 SysRoleDeptColumns // columns contains all the column names of Table for convenient usage.
|
||||||
|
handlers []gdb.ModelHandler // handlers for customized model modification.
|
||||||
|
}
|
||||||
|
|
||||||
|
// SysRoleDeptColumns defines and stores column names for the table sys_role_dept.
|
||||||
|
type SysRoleDeptColumns struct {
|
||||||
|
TenantId string //
|
||||||
|
RoleId string //
|
||||||
|
DeptId string //
|
||||||
|
}
|
||||||
|
|
||||||
|
// sysRoleDeptColumns holds the columns for the table sys_role_dept.
|
||||||
|
var sysRoleDeptColumns = SysRoleDeptColumns{
|
||||||
|
TenantId: "tenant_id",
|
||||||
|
RoleId: "role_id",
|
||||||
|
DeptId: "dept_id",
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewSysRoleDeptDao creates and returns a new DAO object for table data access.
|
||||||
|
func NewSysRoleDeptDao(handlers ...gdb.ModelHandler) *SysRoleDeptDao {
|
||||||
|
return &SysRoleDeptDao{
|
||||||
|
group: "default",
|
||||||
|
table: "sys_role_dept",
|
||||||
|
columns: sysRoleDeptColumns,
|
||||||
|
handlers: handlers,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// DB retrieves and returns the underlying raw database management object of the current DAO.
|
||||||
|
func (dao *SysRoleDeptDao) DB() gdb.DB {
|
||||||
|
return g.DB(dao.group)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Table returns the table name of the current DAO.
|
||||||
|
func (dao *SysRoleDeptDao) Table() string {
|
||||||
|
return dao.table
|
||||||
|
}
|
||||||
|
|
||||||
|
// Columns returns all column names of the current DAO.
|
||||||
|
func (dao *SysRoleDeptDao) Columns() SysRoleDeptColumns {
|
||||||
|
return dao.columns
|
||||||
|
}
|
||||||
|
|
||||||
|
// Group returns the database configuration group name of the current DAO.
|
||||||
|
func (dao *SysRoleDeptDao) 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 *SysRoleDeptDao) 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 *SysRoleDeptDao) 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,85 @@
|
|||||||
|
// ==========================================================================
|
||||||
|
// 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"
|
||||||
|
)
|
||||||
|
|
||||||
|
// SysRoleMenuDao is the data access object for the table sys_role_menu.
|
||||||
|
type SysRoleMenuDao 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 SysRoleMenuColumns // columns contains all the column names of Table for convenient usage.
|
||||||
|
handlers []gdb.ModelHandler // handlers for customized model modification.
|
||||||
|
}
|
||||||
|
|
||||||
|
// SysRoleMenuColumns defines and stores column names for the table sys_role_menu.
|
||||||
|
type SysRoleMenuColumns struct {
|
||||||
|
TenantId string //
|
||||||
|
RoleId string //
|
||||||
|
MenuId string //
|
||||||
|
DataScope string //
|
||||||
|
}
|
||||||
|
|
||||||
|
// sysRoleMenuColumns holds the columns for the table sys_role_menu.
|
||||||
|
var sysRoleMenuColumns = SysRoleMenuColumns{
|
||||||
|
TenantId: "tenant_id",
|
||||||
|
RoleId: "role_id",
|
||||||
|
MenuId: "menu_id",
|
||||||
|
DataScope: "data_scope",
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewSysRoleMenuDao creates and returns a new DAO object for table data access.
|
||||||
|
func NewSysRoleMenuDao(handlers ...gdb.ModelHandler) *SysRoleMenuDao {
|
||||||
|
return &SysRoleMenuDao{
|
||||||
|
group: "default",
|
||||||
|
table: "sys_role_menu",
|
||||||
|
columns: sysRoleMenuColumns,
|
||||||
|
handlers: handlers,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// DB retrieves and returns the underlying raw database management object of the current DAO.
|
||||||
|
func (dao *SysRoleMenuDao) DB() gdb.DB {
|
||||||
|
return g.DB(dao.group)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Table returns the table name of the current DAO.
|
||||||
|
func (dao *SysRoleMenuDao) Table() string {
|
||||||
|
return dao.table
|
||||||
|
}
|
||||||
|
|
||||||
|
// Columns returns all column names of the current DAO.
|
||||||
|
func (dao *SysRoleMenuDao) Columns() SysRoleMenuColumns {
|
||||||
|
return dao.columns
|
||||||
|
}
|
||||||
|
|
||||||
|
// Group returns the database configuration group name of the current DAO.
|
||||||
|
func (dao *SysRoleMenuDao) 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 *SysRoleMenuDao) 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 *SysRoleMenuDao) 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,111 @@
|
|||||||
|
// ==========================================================================
|
||||||
|
// 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"
|
||||||
|
)
|
||||||
|
|
||||||
|
// SysRoleSyncTaskDao is the data access object for the table sys_role_sync_task.
|
||||||
|
type SysRoleSyncTaskDao 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 SysRoleSyncTaskColumns // columns contains all the column names of Table for convenient usage.
|
||||||
|
handlers []gdb.ModelHandler // handlers for customized model modification.
|
||||||
|
}
|
||||||
|
|
||||||
|
// SysRoleSyncTaskColumns defines and stores column names for the table sys_role_sync_task.
|
||||||
|
type SysRoleSyncTaskColumns struct {
|
||||||
|
TaskId string //
|
||||||
|
TenantId string //
|
||||||
|
SourceRoleId string //
|
||||||
|
SourceDeptId string //
|
||||||
|
ActorId string //
|
||||||
|
Status string //
|
||||||
|
Attempts string //
|
||||||
|
NextAttemptAt string //
|
||||||
|
LeaseUntil string //
|
||||||
|
LeaseToken string //
|
||||||
|
Payload string //
|
||||||
|
LastError string //
|
||||||
|
TotalCount string //
|
||||||
|
SuccessCount string //
|
||||||
|
FailedCount string //
|
||||||
|
CreatedAt string //
|
||||||
|
UpdatedAt string //
|
||||||
|
}
|
||||||
|
|
||||||
|
// sysRoleSyncTaskColumns holds the columns for the table sys_role_sync_task.
|
||||||
|
var sysRoleSyncTaskColumns = SysRoleSyncTaskColumns{
|
||||||
|
TaskId: "task_id",
|
||||||
|
TenantId: "tenant_id",
|
||||||
|
SourceRoleId: "source_role_id",
|
||||||
|
SourceDeptId: "source_dept_id",
|
||||||
|
ActorId: "actor_id",
|
||||||
|
Status: "status",
|
||||||
|
Attempts: "attempts",
|
||||||
|
NextAttemptAt: "next_attempt_at",
|
||||||
|
LeaseUntil: "lease_until",
|
||||||
|
LeaseToken: "lease_token",
|
||||||
|
Payload: "payload",
|
||||||
|
LastError: "last_error",
|
||||||
|
TotalCount: "total_count",
|
||||||
|
SuccessCount: "success_count",
|
||||||
|
FailedCount: "failed_count",
|
||||||
|
CreatedAt: "created_at",
|
||||||
|
UpdatedAt: "updated_at",
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewSysRoleSyncTaskDao creates and returns a new DAO object for table data access.
|
||||||
|
func NewSysRoleSyncTaskDao(handlers ...gdb.ModelHandler) *SysRoleSyncTaskDao {
|
||||||
|
return &SysRoleSyncTaskDao{
|
||||||
|
group: "default",
|
||||||
|
table: "sys_role_sync_task",
|
||||||
|
columns: sysRoleSyncTaskColumns,
|
||||||
|
handlers: handlers,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// DB retrieves and returns the underlying raw database management object of the current DAO.
|
||||||
|
func (dao *SysRoleSyncTaskDao) DB() gdb.DB {
|
||||||
|
return g.DB(dao.group)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Table returns the table name of the current DAO.
|
||||||
|
func (dao *SysRoleSyncTaskDao) Table() string {
|
||||||
|
return dao.table
|
||||||
|
}
|
||||||
|
|
||||||
|
// Columns returns all column names of the current DAO.
|
||||||
|
func (dao *SysRoleSyncTaskDao) Columns() SysRoleSyncTaskColumns {
|
||||||
|
return dao.columns
|
||||||
|
}
|
||||||
|
|
||||||
|
// Group returns the database configuration group name of the current DAO.
|
||||||
|
func (dao *SysRoleSyncTaskDao) 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 *SysRoleSyncTaskDao) 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 *SysRoleSyncTaskDao) 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,83 @@
|
|||||||
|
// ==========================================================================
|
||||||
|
// 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"
|
||||||
|
)
|
||||||
|
|
||||||
|
// SysUserRoleDao is the data access object for the table sys_user_role.
|
||||||
|
type SysUserRoleDao 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 SysUserRoleColumns // columns contains all the column names of Table for convenient usage.
|
||||||
|
handlers []gdb.ModelHandler // handlers for customized model modification.
|
||||||
|
}
|
||||||
|
|
||||||
|
// SysUserRoleColumns defines and stores column names for the table sys_user_role.
|
||||||
|
type SysUserRoleColumns struct {
|
||||||
|
TenantId string //
|
||||||
|
UserId string //
|
||||||
|
RoleId string //
|
||||||
|
}
|
||||||
|
|
||||||
|
// sysUserRoleColumns holds the columns for the table sys_user_role.
|
||||||
|
var sysUserRoleColumns = SysUserRoleColumns{
|
||||||
|
TenantId: "tenant_id",
|
||||||
|
UserId: "user_id",
|
||||||
|
RoleId: "role_id",
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewSysUserRoleDao creates and returns a new DAO object for table data access.
|
||||||
|
func NewSysUserRoleDao(handlers ...gdb.ModelHandler) *SysUserRoleDao {
|
||||||
|
return &SysUserRoleDao{
|
||||||
|
group: "default",
|
||||||
|
table: "sys_user_role",
|
||||||
|
columns: sysUserRoleColumns,
|
||||||
|
handlers: handlers,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// DB retrieves and returns the underlying raw database management object of the current DAO.
|
||||||
|
func (dao *SysUserRoleDao) DB() gdb.DB {
|
||||||
|
return g.DB(dao.group)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Table returns the table name of the current DAO.
|
||||||
|
func (dao *SysUserRoleDao) Table() string {
|
||||||
|
return dao.table
|
||||||
|
}
|
||||||
|
|
||||||
|
// Columns returns all column names of the current DAO.
|
||||||
|
func (dao *SysUserRoleDao) Columns() SysUserRoleColumns {
|
||||||
|
return dao.columns
|
||||||
|
}
|
||||||
|
|
||||||
|
// Group returns the database configuration group name of the current DAO.
|
||||||
|
func (dao *SysUserRoleDao) 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 *SysUserRoleDao) 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 *SysUserRoleDao) 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,107 @@
|
|||||||
|
// ==========================================================================
|
||||||
|
// 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"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TMessageLogDao is the data access object for the table t_message_log.
|
||||||
|
type TMessageLogDao 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 TMessageLogColumns // columns contains all the column names of Table for convenient usage.
|
||||||
|
handlers []gdb.ModelHandler // handlers for customized model modification.
|
||||||
|
}
|
||||||
|
|
||||||
|
// TMessageLogColumns defines and stores column names for the table t_message_log.
|
||||||
|
type TMessageLogColumns struct {
|
||||||
|
MessageLogId string // 主键ID
|
||||||
|
MessageType string // 消息类型
|
||||||
|
SendUserType string // 发送用户类型
|
||||||
|
UserIds string // 接收用户ID集合(JSON数组)
|
||||||
|
MessageTitle string // 消息标题
|
||||||
|
MessageContent string // 消息内容
|
||||||
|
MessageLink string // 跳转链接地址
|
||||||
|
SendTime string // 发送时间
|
||||||
|
IsRecall string // 是否撤回
|
||||||
|
TenantId string // 租户ID
|
||||||
|
CreatedDept string // 创建部门
|
||||||
|
CreatedBy string // 创建者
|
||||||
|
CreatedAt string // 创建时间
|
||||||
|
UpdatedBy string // 更新者
|
||||||
|
UpdatedAt string // 更新时间
|
||||||
|
}
|
||||||
|
|
||||||
|
// tMessageLogColumns holds the columns for the table t_message_log.
|
||||||
|
var tMessageLogColumns = TMessageLogColumns{
|
||||||
|
MessageLogId: "message_log_id",
|
||||||
|
MessageType: "message_type",
|
||||||
|
SendUserType: "send_user_type",
|
||||||
|
UserIds: "user_ids",
|
||||||
|
MessageTitle: "message_title",
|
||||||
|
MessageContent: "message_content",
|
||||||
|
MessageLink: "message_link",
|
||||||
|
SendTime: "send_time",
|
||||||
|
IsRecall: "is_recall",
|
||||||
|
TenantId: "tenant_id",
|
||||||
|
CreatedDept: "created_dept",
|
||||||
|
CreatedBy: "created_by",
|
||||||
|
CreatedAt: "created_at",
|
||||||
|
UpdatedBy: "updated_by",
|
||||||
|
UpdatedAt: "updated_at",
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewTMessageLogDao creates and returns a new DAO object for table data access.
|
||||||
|
func NewTMessageLogDao(handlers ...gdb.ModelHandler) *TMessageLogDao {
|
||||||
|
return &TMessageLogDao{
|
||||||
|
group: "default",
|
||||||
|
table: "t_message_log",
|
||||||
|
columns: tMessageLogColumns,
|
||||||
|
handlers: handlers,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// DB retrieves and returns the underlying raw database management object of the current DAO.
|
||||||
|
func (dao *TMessageLogDao) DB() gdb.DB {
|
||||||
|
return g.DB(dao.group)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Table returns the table name of the current DAO.
|
||||||
|
func (dao *TMessageLogDao) Table() string {
|
||||||
|
return dao.table
|
||||||
|
}
|
||||||
|
|
||||||
|
// Columns returns all column names of the current DAO.
|
||||||
|
func (dao *TMessageLogDao) Columns() TMessageLogColumns {
|
||||||
|
return dao.columns
|
||||||
|
}
|
||||||
|
|
||||||
|
// Group returns the database configuration group name of the current DAO.
|
||||||
|
func (dao *TMessageLogDao) 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 *TMessageLogDao) 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 *TMessageLogDao) 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,141 @@
|
|||||||
|
// ==========================================================================
|
||||||
|
// 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"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TResourceBatchDownloadDao is the data access object for the table t_resource_batch_download.
|
||||||
|
type TResourceBatchDownloadDao 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 TResourceBatchDownloadColumns // columns contains all the column names of Table for convenient usage.
|
||||||
|
handlers []gdb.ModelHandler // handlers for customized model modification.
|
||||||
|
}
|
||||||
|
|
||||||
|
// TResourceBatchDownloadColumns defines and stores column names for the table t_resource_batch_download.
|
||||||
|
type TResourceBatchDownloadColumns struct {
|
||||||
|
Id string // 主键ID
|
||||||
|
BatchNo string // 批次编号(全局唯一)
|
||||||
|
TaskName string // 任务名称
|
||||||
|
ResourceType string // 资源类型 枚举
|
||||||
|
ExportScene string // 导出场景 枚举
|
||||||
|
QueryParams string // 导出查询条件快照
|
||||||
|
TotalCount string // 预计导出总条数
|
||||||
|
SuccessCount string // 已成功导出条数
|
||||||
|
FailCount string // 失败条数
|
||||||
|
DownloadStatus string // 下载状态 枚举
|
||||||
|
OssId string // OSS文件ID
|
||||||
|
FileName string // 导出文件名
|
||||||
|
FileExt string // 文件扩展名
|
||||||
|
FileUrl string // 文件访问地址
|
||||||
|
FilePath string // 服务器本地路径
|
||||||
|
FileSize string // 文件大小(字节)
|
||||||
|
FileMd5 string // 文件MD5
|
||||||
|
DownloadCount string // 下载次数
|
||||||
|
LastDownloadTime string // 最后下载时间
|
||||||
|
MaxDownloadCount string // 最大下载次数限制
|
||||||
|
ErrorMessage string // 失败原因
|
||||||
|
DeptId string // 所属机构ID
|
||||||
|
StartTime string // 任务开始时间
|
||||||
|
FinishTime string // 任务完成时间
|
||||||
|
ExpireTime string // 文件过期时间
|
||||||
|
Remark string // 备注
|
||||||
|
TenantId string // 租户ID
|
||||||
|
CreatedDept string // 创建部门ID
|
||||||
|
CreatedBy string // 创建者ID
|
||||||
|
CreatedAt string // 创建时间
|
||||||
|
UpdatedBy string // 更新者ID
|
||||||
|
UpdatedAt string // 更新时间
|
||||||
|
}
|
||||||
|
|
||||||
|
// tResourceBatchDownloadColumns holds the columns for the table t_resource_batch_download.
|
||||||
|
var tResourceBatchDownloadColumns = TResourceBatchDownloadColumns{
|
||||||
|
Id: "id",
|
||||||
|
BatchNo: "batch_no",
|
||||||
|
TaskName: "task_name",
|
||||||
|
ResourceType: "resource_type",
|
||||||
|
ExportScene: "export_scene",
|
||||||
|
QueryParams: "query_params",
|
||||||
|
TotalCount: "total_count",
|
||||||
|
SuccessCount: "success_count",
|
||||||
|
FailCount: "fail_count",
|
||||||
|
DownloadStatus: "download_status",
|
||||||
|
OssId: "oss_id",
|
||||||
|
FileName: "file_name",
|
||||||
|
FileExt: "file_ext",
|
||||||
|
FileUrl: "file_url",
|
||||||
|
FilePath: "file_path",
|
||||||
|
FileSize: "file_size",
|
||||||
|
FileMd5: "file_md5",
|
||||||
|
DownloadCount: "download_count",
|
||||||
|
LastDownloadTime: "last_download_time",
|
||||||
|
MaxDownloadCount: "max_download_count",
|
||||||
|
ErrorMessage: "error_message",
|
||||||
|
DeptId: "dept_id",
|
||||||
|
StartTime: "start_time",
|
||||||
|
FinishTime: "finish_time",
|
||||||
|
ExpireTime: "expire_time",
|
||||||
|
Remark: "remark",
|
||||||
|
TenantId: "tenant_id",
|
||||||
|
CreatedDept: "created_dept",
|
||||||
|
CreatedBy: "created_by",
|
||||||
|
CreatedAt: "created_at",
|
||||||
|
UpdatedBy: "updated_by",
|
||||||
|
UpdatedAt: "updated_at",
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewTResourceBatchDownloadDao creates and returns a new DAO object for table data access.
|
||||||
|
func NewTResourceBatchDownloadDao(handlers ...gdb.ModelHandler) *TResourceBatchDownloadDao {
|
||||||
|
return &TResourceBatchDownloadDao{
|
||||||
|
group: "default",
|
||||||
|
table: "t_resource_batch_download",
|
||||||
|
columns: tResourceBatchDownloadColumns,
|
||||||
|
handlers: handlers,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// DB retrieves and returns the underlying raw database management object of the current DAO.
|
||||||
|
func (dao *TResourceBatchDownloadDao) DB() gdb.DB {
|
||||||
|
return g.DB(dao.group)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Table returns the table name of the current DAO.
|
||||||
|
func (dao *TResourceBatchDownloadDao) Table() string {
|
||||||
|
return dao.table
|
||||||
|
}
|
||||||
|
|
||||||
|
// Columns returns all column names of the current DAO.
|
||||||
|
func (dao *TResourceBatchDownloadDao) Columns() TResourceBatchDownloadColumns {
|
||||||
|
return dao.columns
|
||||||
|
}
|
||||||
|
|
||||||
|
// Group returns the database configuration group name of the current DAO.
|
||||||
|
func (dao *TResourceBatchDownloadDao) 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 *TResourceBatchDownloadDao) 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 *TResourceBatchDownloadDao) 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,191 @@
|
|||||||
|
// ==========================================================================
|
||||||
|
// 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"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TSimCardCancellationDao is the data access object for the table t_sim_card_cancellation.
|
||||||
|
type TSimCardCancellationDao 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 TSimCardCancellationColumns // columns contains all the column names of Table for convenient usage.
|
||||||
|
handlers []gdb.ModelHandler // handlers for customized model modification.
|
||||||
|
}
|
||||||
|
|
||||||
|
// TSimCardCancellationColumns defines and stores column names for the table t_sim_card_cancellation.
|
||||||
|
type TSimCardCancellationColumns struct {
|
||||||
|
Id string // 主键ID
|
||||||
|
Iccid string // ICCID
|
||||||
|
Msisdn string // MSISDN/卡号
|
||||||
|
Imsi string // IMSI
|
||||||
|
Imei string // IMEI
|
||||||
|
RegionCode string // 归属地
|
||||||
|
DeptId string // 渠道客户ID
|
||||||
|
CurrentPackageCode string // 当前套餐编号
|
||||||
|
PackageStatus string // 套餐状态:0 无套餐,1待生效,2生效中,3已过期
|
||||||
|
AccessType string // 接入方式:ipv4、ipv6、ipv4_ipv6
|
||||||
|
BossCardStatus string // 运营商卡状态 枚举
|
||||||
|
CancelBatchNo string // 系统销户批次号
|
||||||
|
CancelledAt string // 系统销户完成时间
|
||||||
|
CardType string // 卡类型 枚举
|
||||||
|
BossOpenAt string // 运营商开户日期
|
||||||
|
BossActivateAt string // 运营商激活日期
|
||||||
|
IsBossActivateEnabled string // 是否按运营商激活减去赠送时长方:1是,0否
|
||||||
|
InboundBatchNo string // 入库批次
|
||||||
|
OutboundBatchNo string // 出库批次
|
||||||
|
AllocationBatchNo string // 划拨批次
|
||||||
|
IsFlowPool string // 是否为池卡:1是,0否
|
||||||
|
FlowPoolCode string // 流量池编号
|
||||||
|
FlowPkgBeginAt string // 当前套餐生效时间
|
||||||
|
FlowPkgPresentAt string // 套餐赠送结束时间
|
||||||
|
FlowPkgRealPresentAt string // 实际赠送结束时间
|
||||||
|
FlowPkgEndAt string // 当前套餐预计到期时间
|
||||||
|
DataServiceStatus string // 数据服务状态:0启用,1停用
|
||||||
|
PackageTotalFlowMb string // 套餐总流量MB
|
||||||
|
UsedFlowMb string // 已用流量MB
|
||||||
|
TrafficCutoffStatus string // 达量断网配置状态:0未配置,1已开启,2已关闭
|
||||||
|
TrafficCutoffThreshold string // 达量断网阈值
|
||||||
|
SmsServiceStatus string // 短信服务状态:0启用,1停用
|
||||||
|
PackageTotalSms string // 套餐短信总条数
|
||||||
|
UsedSms string // 已用短信条数
|
||||||
|
VoiceServiceStatus string // 语音服务状态:0启用,1停用
|
||||||
|
PackageTotalVoice string // 套餐语音总分钟数
|
||||||
|
UsedVoice string // 已用语音分钟数
|
||||||
|
RoamServiceStatus string // 国漫服务状态:0启用,1停用
|
||||||
|
PackageTotalRoamFlowMb string // 套餐国际流量MB
|
||||||
|
UsedRoamFlowMb string // 已用国际流量MB
|
||||||
|
StatusSyncTime string // 状态最后一次同步时间
|
||||||
|
FlowSyncTime string // 流量最后一次同步时间
|
||||||
|
SmsSyncTime string // 短信最后一次同步时间
|
||||||
|
VoiceSyncTime string // 语音最后一次同步时间
|
||||||
|
RoamSyncTime string // 国漫最后一次同步时间
|
||||||
|
ExtraFeeEnabled string // 是否收取附加费:0是,1否
|
||||||
|
ExtraFeeMultiple string // 附加费用倍数
|
||||||
|
ExtraFee string // 附加金额
|
||||||
|
ChanKey string // 通道编号
|
||||||
|
NetModel string // 网络类型 枚举
|
||||||
|
Remark string // 备注
|
||||||
|
TenantId string // 租户ID
|
||||||
|
CreatedDept string // 创建部门
|
||||||
|
CreatedBy string // 创建者
|
||||||
|
CreatedAt string // 创建时间
|
||||||
|
UpdatedBy string // 更新者
|
||||||
|
UpdatedAt string // 更新时间
|
||||||
|
}
|
||||||
|
|
||||||
|
// tSimCardCancellationColumns holds the columns for the table t_sim_card_cancellation.
|
||||||
|
var tSimCardCancellationColumns = TSimCardCancellationColumns{
|
||||||
|
Id: "id",
|
||||||
|
Iccid: "iccid",
|
||||||
|
Msisdn: "msisdn",
|
||||||
|
Imsi: "imsi",
|
||||||
|
Imei: "imei",
|
||||||
|
RegionCode: "region_code",
|
||||||
|
DeptId: "dept_id",
|
||||||
|
CurrentPackageCode: "current_package_code",
|
||||||
|
PackageStatus: "package_status",
|
||||||
|
AccessType: "access_type",
|
||||||
|
BossCardStatus: "boss_card_status",
|
||||||
|
CancelBatchNo: "cancel_batch_no",
|
||||||
|
CancelledAt: "cancelled_at",
|
||||||
|
CardType: "card_type",
|
||||||
|
BossOpenAt: "boss_open_at",
|
||||||
|
BossActivateAt: "boss_activate_at",
|
||||||
|
IsBossActivateEnabled: "is_boss_activate_enabled",
|
||||||
|
InboundBatchNo: "inbound_batch_no",
|
||||||
|
OutboundBatchNo: "outbound_batch_no",
|
||||||
|
AllocationBatchNo: "allocation_batch_no",
|
||||||
|
IsFlowPool: "is_flow_pool",
|
||||||
|
FlowPoolCode: "flow_pool_code",
|
||||||
|
FlowPkgBeginAt: "flow_pkg_begin_at",
|
||||||
|
FlowPkgPresentAt: "flow_pkg_present_at",
|
||||||
|
FlowPkgRealPresentAt: "flow_pkg_real_present_at",
|
||||||
|
FlowPkgEndAt: "flow_pkg_end_at",
|
||||||
|
DataServiceStatus: "data_service_status",
|
||||||
|
PackageTotalFlowMb: "package_total_flow_mb",
|
||||||
|
UsedFlowMb: "used_flow_mb",
|
||||||
|
TrafficCutoffStatus: "traffic_cutoff_status",
|
||||||
|
TrafficCutoffThreshold: "traffic_cutoff_threshold",
|
||||||
|
SmsServiceStatus: "sms_service_status",
|
||||||
|
PackageTotalSms: "package_total_sms",
|
||||||
|
UsedSms: "used_sms",
|
||||||
|
VoiceServiceStatus: "voice_service_status",
|
||||||
|
PackageTotalVoice: "package_total_voice",
|
||||||
|
UsedVoice: "used_voice",
|
||||||
|
RoamServiceStatus: "roam_service_status",
|
||||||
|
PackageTotalRoamFlowMb: "package_total_roam_flow_mb",
|
||||||
|
UsedRoamFlowMb: "used_roam_flow_mb",
|
||||||
|
StatusSyncTime: "status_sync_time",
|
||||||
|
FlowSyncTime: "flow_sync_time",
|
||||||
|
SmsSyncTime: "sms_sync_time",
|
||||||
|
VoiceSyncTime: "voice_sync_time",
|
||||||
|
RoamSyncTime: "roam_sync_time",
|
||||||
|
ExtraFeeEnabled: "extra_fee_enabled",
|
||||||
|
ExtraFeeMultiple: "extra_fee_multiple",
|
||||||
|
ExtraFee: "extra_fee",
|
||||||
|
ChanKey: "chan_key",
|
||||||
|
NetModel: "net_model",
|
||||||
|
Remark: "remark",
|
||||||
|
TenantId: "tenant_id",
|
||||||
|
CreatedDept: "created_dept",
|
||||||
|
CreatedBy: "created_by",
|
||||||
|
CreatedAt: "created_at",
|
||||||
|
UpdatedBy: "updated_by",
|
||||||
|
UpdatedAt: "updated_at",
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewTSimCardCancellationDao creates and returns a new DAO object for table data access.
|
||||||
|
func NewTSimCardCancellationDao(handlers ...gdb.ModelHandler) *TSimCardCancellationDao {
|
||||||
|
return &TSimCardCancellationDao{
|
||||||
|
group: "default",
|
||||||
|
table: "t_sim_card_cancellation",
|
||||||
|
columns: tSimCardCancellationColumns,
|
||||||
|
handlers: handlers,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// DB retrieves and returns the underlying raw database management object of the current DAO.
|
||||||
|
func (dao *TSimCardCancellationDao) DB() gdb.DB {
|
||||||
|
return g.DB(dao.group)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Table returns the table name of the current DAO.
|
||||||
|
func (dao *TSimCardCancellationDao) Table() string {
|
||||||
|
return dao.table
|
||||||
|
}
|
||||||
|
|
||||||
|
// Columns returns all column names of the current DAO.
|
||||||
|
func (dao *TSimCardCancellationDao) Columns() TSimCardCancellationColumns {
|
||||||
|
return dao.columns
|
||||||
|
}
|
||||||
|
|
||||||
|
// Group returns the database configuration group name of the current DAO.
|
||||||
|
func (dao *TSimCardCancellationDao) 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 *TSimCardCancellationDao) 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 *TSimCardCancellationDao) 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
|
||||||
|
Iccid string // ICCID
|
||||||
|
Msisdn string // MSISDN/卡号
|
||||||
|
Imsi string // IMSI
|
||||||
|
Imei string // IMEI
|
||||||
|
RegionCode string // 归属地
|
||||||
|
DeptId string // 渠道客户ID
|
||||||
|
CurrentPackageCode string // 当前套餐编号
|
||||||
|
PackageStatus string // 套餐状态:0 无套餐,1待生效,2生效中,3已过期
|
||||||
|
AccessType string // 接入方式:ipv4、ipv6、ipv4_ipv6
|
||||||
|
BossCardStatus string // 运营商卡状态 枚举
|
||||||
|
CardType string // 卡类型 枚举
|
||||||
|
BossOpenAt string // 运营商开户日期
|
||||||
|
BossActivateAt string // 运营商激活日期
|
||||||
|
IsBossActivateEnabled string // 是否按运营商激活减去赠送时长方:1是,0否
|
||||||
|
InboundBatchNo string // 入库批次
|
||||||
|
OutboundBatchNo string // 出库批次
|
||||||
|
AllocationBatchNo string // 划拨批次
|
||||||
|
IsFlowPool string // 是否为池卡:1是,0否
|
||||||
|
FlowPoolCode string // 流量池编号
|
||||||
|
OutStockTime string // 出库时间
|
||||||
|
FlowPkgBeginAt string // 当前套餐生效时间
|
||||||
|
FlowPkgPresentAt string // 套餐赠送结束时间
|
||||||
|
FlowPkgRealPresentAt string // 实际赠送结束时间
|
||||||
|
FlowPkgEndAt string // 当前套餐预计到期时间
|
||||||
|
DataServiceStatus string // 数据服务状态:1启用、0禁用
|
||||||
|
PackageTotalFlowMb string // 套餐总流量MB
|
||||||
|
UsedFlowMb string // 已用流量MB
|
||||||
|
TrafficCutoffStatus string // 达量断网配置状态:0未配置,1已开启,2已关闭
|
||||||
|
TrafficCutoffThreshold string // 达量断网阈值
|
||||||
|
SmsServiceStatus string // 短信服务状态:1启用、0禁用
|
||||||
|
PackageTotalSms string // 套餐短信总条数
|
||||||
|
UsedSms string // 已用短信条数
|
||||||
|
VoiceServiceStatus string // 语音服务状态:1启用、0禁用
|
||||||
|
PackageTotalVoice string // 套餐语音总分钟数
|
||||||
|
UsedVoice string // 已用语音分钟数
|
||||||
|
RoamServiceStatus string // 国漫服务状态:1启用、0禁用
|
||||||
|
PackageTotalRoamFlowMb string // 套餐国际流量MB
|
||||||
|
UsedRoamFlowMb string // 已用国际流量MB
|
||||||
|
StatusSyncTime string // 状态最后一次同步时间
|
||||||
|
FlowSyncTime string // 流量最后一次同步时间
|
||||||
|
SmsSyncTime string // 短信最后一次同步时间
|
||||||
|
VoiceSyncTime string // 语音最后一次同步时间
|
||||||
|
RoamSyncTime string // 国漫最后一次同步时间
|
||||||
|
ImeiSyncTime string // IMEI 最后一次轮询同步时间
|
||||||
|
ImeiUpdateTime string // IMEI 值最后一次实际变更时间
|
||||||
|
ExtraFeeEnabled string // 是否收取附加费:1是,0否
|
||||||
|
ExtraFeeMultiple string // 附加费用倍数
|
||||||
|
ExtraFee string // 附加金额
|
||||||
|
ChanKey string // 通道编号
|
||||||
|
NetModel string // 网络类型:4G、5G、NB-IoT
|
||||||
|
Remark 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",
|
||||||
|
Iccid: "iccid",
|
||||||
|
Msisdn: "msisdn",
|
||||||
|
Imsi: "imsi",
|
||||||
|
Imei: "imei",
|
||||||
|
RegionCode: "region_code",
|
||||||
|
DeptId: "dept_id",
|
||||||
|
CurrentPackageCode: "current_package_code",
|
||||||
|
PackageStatus: "package_status",
|
||||||
|
AccessType: "access_type",
|
||||||
|
BossCardStatus: "boss_card_status",
|
||||||
|
CardType: "card_type",
|
||||||
|
BossOpenAt: "boss_open_at",
|
||||||
|
BossActivateAt: "boss_activate_at",
|
||||||
|
IsBossActivateEnabled: "is_boss_activate_enabled",
|
||||||
|
InboundBatchNo: "inbound_batch_no",
|
||||||
|
OutboundBatchNo: "outbound_batch_no",
|
||||||
|
AllocationBatchNo: "allocation_batch_no",
|
||||||
|
IsFlowPool: "is_flow_pool",
|
||||||
|
FlowPoolCode: "flow_pool_code",
|
||||||
|
OutStockTime: "out_stock_time",
|
||||||
|
FlowPkgBeginAt: "flow_pkg_begin_at",
|
||||||
|
FlowPkgPresentAt: "flow_pkg_present_at",
|
||||||
|
FlowPkgRealPresentAt: "flow_pkg_real_present_at",
|
||||||
|
FlowPkgEndAt: "flow_pkg_end_at",
|
||||||
|
DataServiceStatus: "data_service_status",
|
||||||
|
PackageTotalFlowMb: "package_total_flow_mb",
|
||||||
|
UsedFlowMb: "used_flow_mb",
|
||||||
|
TrafficCutoffStatus: "traffic_cutoff_status",
|
||||||
|
TrafficCutoffThreshold: "traffic_cutoff_threshold",
|
||||||
|
SmsServiceStatus: "sms_service_status",
|
||||||
|
PackageTotalSms: "package_total_sms",
|
||||||
|
UsedSms: "used_sms",
|
||||||
|
VoiceServiceStatus: "voice_service_status",
|
||||||
|
PackageTotalVoice: "package_total_voice",
|
||||||
|
UsedVoice: "used_voice",
|
||||||
|
RoamServiceStatus: "roam_service_status",
|
||||||
|
PackageTotalRoamFlowMb: "package_total_roam_flow_mb",
|
||||||
|
UsedRoamFlowMb: "used_roam_flow_mb",
|
||||||
|
StatusSyncTime: "status_sync_time",
|
||||||
|
FlowSyncTime: "flow_sync_time",
|
||||||
|
SmsSyncTime: "sms_sync_time",
|
||||||
|
VoiceSyncTime: "voice_sync_time",
|
||||||
|
RoamSyncTime: "roam_sync_time",
|
||||||
|
ImeiSyncTime: "imei_sync_time",
|
||||||
|
ImeiUpdateTime: "imei_update_time",
|
||||||
|
ExtraFeeEnabled: "extra_fee_enabled",
|
||||||
|
ExtraFeeMultiple: "extra_fee_multiple",
|
||||||
|
ExtraFee: "extra_fee",
|
||||||
|
ChanKey: "chan_key",
|
||||||
|
NetModel: "net_model",
|
||||||
|
Remark: "remark",
|
||||||
|
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,119 @@
|
|||||||
|
// ==========================================================================
|
||||||
|
// 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"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TSimFlowPoolDao is the data access object for the table t_sim_flow_pool.
|
||||||
|
type TSimFlowPoolDao 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 TSimFlowPoolColumns // columns contains all the column names of Table for convenient usage.
|
||||||
|
handlers []gdb.ModelHandler // handlers for customized model modification.
|
||||||
|
}
|
||||||
|
|
||||||
|
// TSimFlowPoolColumns defines and stores column names for the table t_sim_flow_pool.
|
||||||
|
type TSimFlowPoolColumns struct {
|
||||||
|
Id string // 主键ID
|
||||||
|
FlowPoolCode string // 流量池编号
|
||||||
|
DeptId string // 客户编号
|
||||||
|
PackageCodes string // 套餐编号列表(冗余)
|
||||||
|
PoolPackageCode string // 池套餐编码
|
||||||
|
CardCount string // 关联卡数量
|
||||||
|
CardFlowMb string // 套餐卡流量MB
|
||||||
|
PoolPackageFlowMb string // 套内总流量MB
|
||||||
|
AddOnFlowMb string // 加油包总流量MB
|
||||||
|
TotalFlowMb string // 总流量MB(套内+加油包)
|
||||||
|
UsedFlowMb string // 已使用流量MB
|
||||||
|
RemainFlowMb string // 剩余流量MB
|
||||||
|
LastStatTime string // 最后流量统计时间
|
||||||
|
PoolStatus string // 状态:0 正常、1 停用、2 锁定
|
||||||
|
Remark string // 备注
|
||||||
|
TenantId string // 租户ID
|
||||||
|
CreatedDept string // 创建部门
|
||||||
|
CreatedBy string // 创建者
|
||||||
|
CreatedAt string // 创建时间
|
||||||
|
UpdatedBy string // 更新者
|
||||||
|
UpdatedAt string // 更新时间
|
||||||
|
}
|
||||||
|
|
||||||
|
// tSimFlowPoolColumns holds the columns for the table t_sim_flow_pool.
|
||||||
|
var tSimFlowPoolColumns = TSimFlowPoolColumns{
|
||||||
|
Id: "id",
|
||||||
|
FlowPoolCode: "flow_pool_code",
|
||||||
|
DeptId: "dept_id",
|
||||||
|
PackageCodes: "package_codes",
|
||||||
|
PoolPackageCode: "pool_package_code",
|
||||||
|
CardCount: "card_count",
|
||||||
|
CardFlowMb: "card_flow_mb",
|
||||||
|
PoolPackageFlowMb: "pool_package_flow_mb",
|
||||||
|
AddOnFlowMb: "add_on_flow_mb",
|
||||||
|
TotalFlowMb: "total_flow_mb",
|
||||||
|
UsedFlowMb: "used_flow_mb",
|
||||||
|
RemainFlowMb: "remain_flow_mb",
|
||||||
|
LastStatTime: "last_stat_time",
|
||||||
|
PoolStatus: "pool_status",
|
||||||
|
Remark: "remark",
|
||||||
|
TenantId: "tenant_id",
|
||||||
|
CreatedDept: "created_dept",
|
||||||
|
CreatedBy: "created_by",
|
||||||
|
CreatedAt: "created_at",
|
||||||
|
UpdatedBy: "updated_by",
|
||||||
|
UpdatedAt: "updated_at",
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewTSimFlowPoolDao creates and returns a new DAO object for table data access.
|
||||||
|
func NewTSimFlowPoolDao(handlers ...gdb.ModelHandler) *TSimFlowPoolDao {
|
||||||
|
return &TSimFlowPoolDao{
|
||||||
|
group: "default",
|
||||||
|
table: "t_sim_flow_pool",
|
||||||
|
columns: tSimFlowPoolColumns,
|
||||||
|
handlers: handlers,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// DB retrieves and returns the underlying raw database management object of the current DAO.
|
||||||
|
func (dao *TSimFlowPoolDao) DB() gdb.DB {
|
||||||
|
return g.DB(dao.group)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Table returns the table name of the current DAO.
|
||||||
|
func (dao *TSimFlowPoolDao) Table() string {
|
||||||
|
return dao.table
|
||||||
|
}
|
||||||
|
|
||||||
|
// Columns returns all column names of the current DAO.
|
||||||
|
func (dao *TSimFlowPoolDao) Columns() TSimFlowPoolColumns {
|
||||||
|
return dao.columns
|
||||||
|
}
|
||||||
|
|
||||||
|
// Group returns the database configuration group name of the current DAO.
|
||||||
|
func (dao *TSimFlowPoolDao) 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 *TSimFlowPoolDao) 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 *TSimFlowPoolDao) 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,97 @@
|
|||||||
|
// ==========================================================================
|
||||||
|
// 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"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TUserMessageDao is the data access object for the table t_user_message.
|
||||||
|
type TUserMessageDao 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 TUserMessageColumns // columns contains all the column names of Table for convenient usage.
|
||||||
|
handlers []gdb.ModelHandler // handlers for customized model modification.
|
||||||
|
}
|
||||||
|
|
||||||
|
// TUserMessageColumns defines and stores column names for the table t_user_message.
|
||||||
|
type TUserMessageColumns struct {
|
||||||
|
MessageId string // 主键ID
|
||||||
|
MessageLogId string // 消息日志ID
|
||||||
|
UserId string // 用户ID
|
||||||
|
IsRead string // 是否已读
|
||||||
|
TenantId string // 租户ID
|
||||||
|
CreatedDept string // 创建部门
|
||||||
|
CreatedBy string // 创建者
|
||||||
|
CreatedAt string // 创建时间
|
||||||
|
UpdatedBy string // 更新者
|
||||||
|
UpdatedAt string // 更新时间
|
||||||
|
}
|
||||||
|
|
||||||
|
// tUserMessageColumns holds the columns for the table t_user_message.
|
||||||
|
var tUserMessageColumns = TUserMessageColumns{
|
||||||
|
MessageId: "message_id",
|
||||||
|
MessageLogId: "message_log_id",
|
||||||
|
UserId: "user_id",
|
||||||
|
IsRead: "is_read",
|
||||||
|
TenantId: "tenant_id",
|
||||||
|
CreatedDept: "created_dept",
|
||||||
|
CreatedBy: "created_by",
|
||||||
|
CreatedAt: "created_at",
|
||||||
|
UpdatedBy: "updated_by",
|
||||||
|
UpdatedAt: "updated_at",
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewTUserMessageDao creates and returns a new DAO object for table data access.
|
||||||
|
func NewTUserMessageDao(handlers ...gdb.ModelHandler) *TUserMessageDao {
|
||||||
|
return &TUserMessageDao{
|
||||||
|
group: "default",
|
||||||
|
table: "t_user_message",
|
||||||
|
columns: tUserMessageColumns,
|
||||||
|
handlers: handlers,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// DB retrieves and returns the underlying raw database management object of the current DAO.
|
||||||
|
func (dao *TUserMessageDao) DB() gdb.DB {
|
||||||
|
return g.DB(dao.group)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Table returns the table name of the current DAO.
|
||||||
|
func (dao *TUserMessageDao) Table() string {
|
||||||
|
return dao.table
|
||||||
|
}
|
||||||
|
|
||||||
|
// Columns returns all column names of the current DAO.
|
||||||
|
func (dao *TUserMessageDao) Columns() TUserMessageColumns {
|
||||||
|
return dao.columns
|
||||||
|
}
|
||||||
|
|
||||||
|
// Group returns the database configuration group name of the current DAO.
|
||||||
|
func (dao *TUserMessageDao) 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 *TUserMessageDao) 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 *TUserMessageDao) 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,123 @@
|
|||||||
|
// ==========================================================================
|
||||||
|
// 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"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TWarnConfigDao is the data access object for the table t_warn_config.
|
||||||
|
type TWarnConfigDao 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 TWarnConfigColumns // columns contains all the column names of Table for convenient usage.
|
||||||
|
handlers []gdb.ModelHandler // handlers for customized model modification.
|
||||||
|
}
|
||||||
|
|
||||||
|
// TWarnConfigColumns defines and stores column names for the table t_warn_config.
|
||||||
|
type TWarnConfigColumns struct {
|
||||||
|
Id string // 主键ID
|
||||||
|
TenantId string // 租户ID
|
||||||
|
BatchNo string // 批次编号
|
||||||
|
ItemType string // 对象类型:1卡,2流量池
|
||||||
|
ItemId string // 对象ID:单卡为t_sim_card_info.id,流量池为t_sim_flow_pool.id
|
||||||
|
ItemCode string // 对象编码快照:ICCID/MSISDN/流量池编码
|
||||||
|
WarnUnit string // 预警单位:1MB,2百分比,3天数,4月数
|
||||||
|
FirstWarn string // 第一档预警阈值,单位由warn_unit决定
|
||||||
|
SecondWarn string // 第二档预警阈值,单位由warn_unit决定
|
||||||
|
ThirdWarn string // 第三档预警阈值,单位由warn_unit决定
|
||||||
|
WarnMethods string // 预警方式:0短信,1邮箱,2电话,3平台消息;多选逗号分隔
|
||||||
|
WarnPhones string // 预警手机号,多个逗号分隔
|
||||||
|
WarnEmails string // 预警邮箱,多个逗号分隔
|
||||||
|
AutoAction string // 自动动作:0无,2断网;第一期只保存配置不自动执行
|
||||||
|
IsEnabled string // 是否启用:1启用,0停用
|
||||||
|
WxTemplateId string // 微信订阅消息模板ID
|
||||||
|
Remark string // 备注
|
||||||
|
CreatedDept string // 创建部门
|
||||||
|
CreatedBy string // 创建者
|
||||||
|
CreatedAt string // 创建时间
|
||||||
|
UpdatedBy string // 更新者
|
||||||
|
UpdatedAt string // 更新时间
|
||||||
|
AppNotifyEnabled string // 是否开启小程序预警通知:0关闭,1开启
|
||||||
|
}
|
||||||
|
|
||||||
|
// tWarnConfigColumns holds the columns for the table t_warn_config.
|
||||||
|
var tWarnConfigColumns = TWarnConfigColumns{
|
||||||
|
Id: "id",
|
||||||
|
TenantId: "tenant_id",
|
||||||
|
BatchNo: "batch_no",
|
||||||
|
ItemType: "item_type",
|
||||||
|
ItemId: "item_id",
|
||||||
|
ItemCode: "item_code",
|
||||||
|
WarnUnit: "warn_unit",
|
||||||
|
FirstWarn: "first_warn",
|
||||||
|
SecondWarn: "second_warn",
|
||||||
|
ThirdWarn: "third_warn",
|
||||||
|
WarnMethods: "warn_methods",
|
||||||
|
WarnPhones: "warn_phones",
|
||||||
|
WarnEmails: "warn_emails",
|
||||||
|
AutoAction: "auto_action",
|
||||||
|
IsEnabled: "is_enabled",
|
||||||
|
WxTemplateId: "wx_template_id",
|
||||||
|
Remark: "remark",
|
||||||
|
CreatedDept: "created_dept",
|
||||||
|
CreatedBy: "created_by",
|
||||||
|
CreatedAt: "created_at",
|
||||||
|
UpdatedBy: "updated_by",
|
||||||
|
UpdatedAt: "updated_at",
|
||||||
|
AppNotifyEnabled: "app_notify_enabled",
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewTWarnConfigDao creates and returns a new DAO object for table data access.
|
||||||
|
func NewTWarnConfigDao(handlers ...gdb.ModelHandler) *TWarnConfigDao {
|
||||||
|
return &TWarnConfigDao{
|
||||||
|
group: "default",
|
||||||
|
table: "t_warn_config",
|
||||||
|
columns: tWarnConfigColumns,
|
||||||
|
handlers: handlers,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// DB retrieves and returns the underlying raw database management object of the current DAO.
|
||||||
|
func (dao *TWarnConfigDao) DB() gdb.DB {
|
||||||
|
return g.DB(dao.group)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Table returns the table name of the current DAO.
|
||||||
|
func (dao *TWarnConfigDao) Table() string {
|
||||||
|
return dao.table
|
||||||
|
}
|
||||||
|
|
||||||
|
// Columns returns all column names of the current DAO.
|
||||||
|
func (dao *TWarnConfigDao) Columns() TWarnConfigColumns {
|
||||||
|
return dao.columns
|
||||||
|
}
|
||||||
|
|
||||||
|
// Group returns the database configuration group name of the current DAO.
|
||||||
|
func (dao *TWarnConfigDao) 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 *TWarnConfigDao) 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 *TWarnConfigDao) 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,125 @@
|
|||||||
|
// ==========================================================================
|
||||||
|
// 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"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TWarnLogDao is the data access object for the table t_warn_log.
|
||||||
|
type TWarnLogDao 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 TWarnLogColumns // columns contains all the column names of Table for convenient usage.
|
||||||
|
handlers []gdb.ModelHandler // handlers for customized model modification.
|
||||||
|
}
|
||||||
|
|
||||||
|
// TWarnLogColumns defines and stores column names for the table t_warn_log.
|
||||||
|
type TWarnLogColumns struct {
|
||||||
|
Id string // 主键ID
|
||||||
|
TenantId string // 租户ID
|
||||||
|
ConfigId string // 预警配置ID
|
||||||
|
ItemType string // 对象类型:1单卡,2流量池
|
||||||
|
ItemId string // 对象ID:单卡为t_sim_card_info.id,流量池为t_sim_flow_pool.id
|
||||||
|
ItemCode string // 对象编码快照:ICCID/MSISDN/流量池编码
|
||||||
|
WarnStage string // 触发档位:1第一档,2第二档,3第三档
|
||||||
|
WarnLevel string // 预警级别:1提示,2注意,3警告,4危险,5紧急
|
||||||
|
WarnUnit string // 预警单位/维度:1MB,2百分比,3天数,4月数
|
||||||
|
ThresholdValue string // 触发阈值,单位由warn_unit决定
|
||||||
|
ActualValue string // 触发时实际值,单位由warn_unit决定
|
||||||
|
TotalFlowMb string // 触发时总流量MB;流量类预警使用
|
||||||
|
RemainFlowMb string // 触发时剩余流量MB;流量类预警使用
|
||||||
|
WarnMethods string // 实际预警方式快照
|
||||||
|
WarnPhones string // 实际预警手机号快照
|
||||||
|
WarnEmails string // 实际预警邮箱快照
|
||||||
|
MessageLogId string // 平台消息ID;聚合通知时可能为空
|
||||||
|
WarnTime string // 预警时间
|
||||||
|
Remark string // 备注
|
||||||
|
CreatedDept string // 创建部门
|
||||||
|
CreatedBy string // 创建者
|
||||||
|
CreatedAt string // 创建时间
|
||||||
|
UpdatedBy string // 更新者
|
||||||
|
UpdatedAt string // 更新时间
|
||||||
|
}
|
||||||
|
|
||||||
|
// tWarnLogColumns holds the columns for the table t_warn_log.
|
||||||
|
var tWarnLogColumns = TWarnLogColumns{
|
||||||
|
Id: "id",
|
||||||
|
TenantId: "tenant_id",
|
||||||
|
ConfigId: "config_id",
|
||||||
|
ItemType: "item_type",
|
||||||
|
ItemId: "item_id",
|
||||||
|
ItemCode: "item_code",
|
||||||
|
WarnStage: "warn_stage",
|
||||||
|
WarnLevel: "warn_level",
|
||||||
|
WarnUnit: "warn_unit",
|
||||||
|
ThresholdValue: "threshold_value",
|
||||||
|
ActualValue: "actual_value",
|
||||||
|
TotalFlowMb: "total_flow_mb",
|
||||||
|
RemainFlowMb: "remain_flow_mb",
|
||||||
|
WarnMethods: "warn_methods",
|
||||||
|
WarnPhones: "warn_phones",
|
||||||
|
WarnEmails: "warn_emails",
|
||||||
|
MessageLogId: "message_log_id",
|
||||||
|
WarnTime: "warn_time",
|
||||||
|
Remark: "remark",
|
||||||
|
CreatedDept: "created_dept",
|
||||||
|
CreatedBy: "created_by",
|
||||||
|
CreatedAt: "created_at",
|
||||||
|
UpdatedBy: "updated_by",
|
||||||
|
UpdatedAt: "updated_at",
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewTWarnLogDao creates and returns a new DAO object for table data access.
|
||||||
|
func NewTWarnLogDao(handlers ...gdb.ModelHandler) *TWarnLogDao {
|
||||||
|
return &TWarnLogDao{
|
||||||
|
group: "default",
|
||||||
|
table: "t_warn_log",
|
||||||
|
columns: tWarnLogColumns,
|
||||||
|
handlers: handlers,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// DB retrieves and returns the underlying raw database management object of the current DAO.
|
||||||
|
func (dao *TWarnLogDao) DB() gdb.DB {
|
||||||
|
return g.DB(dao.group)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Table returns the table name of the current DAO.
|
||||||
|
func (dao *TWarnLogDao) Table() string {
|
||||||
|
return dao.table
|
||||||
|
}
|
||||||
|
|
||||||
|
// Columns returns all column names of the current DAO.
|
||||||
|
func (dao *TWarnLogDao) Columns() TWarnLogColumns {
|
||||||
|
return dao.columns
|
||||||
|
}
|
||||||
|
|
||||||
|
// Group returns the database configuration group name of the current DAO.
|
||||||
|
func (dao *TWarnLogDao) 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 *TWarnLogDao) 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 *TWarnLogDao) 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,137 @@
|
|||||||
|
// ==========================================================================
|
||||||
|
// 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"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TWoMainDao is the data access object for the table t_wo_main.
|
||||||
|
type TWoMainDao 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 TWoMainColumns // columns contains all the column names of Table for convenient usage.
|
||||||
|
handlers []gdb.ModelHandler // handlers for customized model modification.
|
||||||
|
}
|
||||||
|
|
||||||
|
// TWoMainColumns defines and stores column names for the table t_wo_main.
|
||||||
|
type TWoMainColumns struct {
|
||||||
|
Id string // 主键ID
|
||||||
|
WoNo string // 工单编号
|
||||||
|
WoType string // 工单类型 0卡,1模组,2设备,3订单,4余额,5提现
|
||||||
|
RelatedId string // 关联业务ID
|
||||||
|
Title string // 工单标题
|
||||||
|
Content string // 问题描述
|
||||||
|
Attachment string // 附件
|
||||||
|
CreatorType string // 创建人类型:1终端用户,2代理商
|
||||||
|
CreatorDeptId string // 创建机构ID
|
||||||
|
CreatorUserId string // 创建用户ID
|
||||||
|
CreatorName string // 创建人姓名
|
||||||
|
CreatorPhone string // 创建人手机号
|
||||||
|
AssignDeptId string // 分派方机构ID
|
||||||
|
AssignUserId string // 分派人用户ID
|
||||||
|
CurrentStatus string // 当前处理状态:1已分派,2处理中,3已解决,4已完结_关单,5已取消
|
||||||
|
HandlerDeptId string // 处理方机构ID
|
||||||
|
HandlerUserId string // 处理人用户ID
|
||||||
|
FirstResponseTime string // 首次响应时间
|
||||||
|
ResolveTime string // 确认时间
|
||||||
|
CloseTime string // 关闭时间
|
||||||
|
SatisfactionLevel string // 满意度:1-5分
|
||||||
|
EvalContent string // 评价内容
|
||||||
|
EvalTime string // 评价时间
|
||||||
|
Remark string // 备注
|
||||||
|
TenantId string // 租户ID
|
||||||
|
CreatedDept string // 创建部门
|
||||||
|
CreatedBy string // 创建者
|
||||||
|
CreatedAt string // 创建时间
|
||||||
|
UpdatedBy string // 更新者
|
||||||
|
UpdatedAt string // 更新时间
|
||||||
|
}
|
||||||
|
|
||||||
|
// tWoMainColumns holds the columns for the table t_wo_main.
|
||||||
|
var tWoMainColumns = TWoMainColumns{
|
||||||
|
Id: "id",
|
||||||
|
WoNo: "wo_no",
|
||||||
|
WoType: "wo_type",
|
||||||
|
RelatedId: "related_id",
|
||||||
|
Title: "title",
|
||||||
|
Content: "content",
|
||||||
|
Attachment: "attachment",
|
||||||
|
CreatorType: "creator_type",
|
||||||
|
CreatorDeptId: "creator_dept_id",
|
||||||
|
CreatorUserId: "creator_user_id",
|
||||||
|
CreatorName: "creator_name",
|
||||||
|
CreatorPhone: "creator_phone",
|
||||||
|
AssignDeptId: "assign_dept_id",
|
||||||
|
AssignUserId: "assign_user_id",
|
||||||
|
CurrentStatus: "current_status",
|
||||||
|
HandlerDeptId: "handler_dept_id",
|
||||||
|
HandlerUserId: "handler_user_id",
|
||||||
|
FirstResponseTime: "first_response_time",
|
||||||
|
ResolveTime: "resolve_time",
|
||||||
|
CloseTime: "close_time",
|
||||||
|
SatisfactionLevel: "satisfaction_level",
|
||||||
|
EvalContent: "eval_content",
|
||||||
|
EvalTime: "eval_time",
|
||||||
|
Remark: "remark",
|
||||||
|
TenantId: "tenant_id",
|
||||||
|
CreatedDept: "created_dept",
|
||||||
|
CreatedBy: "created_by",
|
||||||
|
CreatedAt: "created_at",
|
||||||
|
UpdatedBy: "updated_by",
|
||||||
|
UpdatedAt: "updated_at",
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewTWoMainDao creates and returns a new DAO object for table data access.
|
||||||
|
func NewTWoMainDao(handlers ...gdb.ModelHandler) *TWoMainDao {
|
||||||
|
return &TWoMainDao{
|
||||||
|
group: "default",
|
||||||
|
table: "t_wo_main",
|
||||||
|
columns: tWoMainColumns,
|
||||||
|
handlers: handlers,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// DB retrieves and returns the underlying raw database management object of the current DAO.
|
||||||
|
func (dao *TWoMainDao) DB() gdb.DB {
|
||||||
|
return g.DB(dao.group)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Table returns the table name of the current DAO.
|
||||||
|
func (dao *TWoMainDao) Table() string {
|
||||||
|
return dao.table
|
||||||
|
}
|
||||||
|
|
||||||
|
// Columns returns all column names of the current DAO.
|
||||||
|
func (dao *TWoMainDao) Columns() TWoMainColumns {
|
||||||
|
return dao.columns
|
||||||
|
}
|
||||||
|
|
||||||
|
// Group returns the database configuration group name of the current DAO.
|
||||||
|
func (dao *TWoMainDao) 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 *TWoMainDao) 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 *TWoMainDao) 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,107 @@
|
|||||||
|
// ==========================================================================
|
||||||
|
// 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"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TWoRecordDao is the data access object for the table t_wo_record.
|
||||||
|
type TWoRecordDao 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 TWoRecordColumns // columns contains all the column names of Table for convenient usage.
|
||||||
|
handlers []gdb.ModelHandler // handlers for customized model modification.
|
||||||
|
}
|
||||||
|
|
||||||
|
// TWoRecordColumns defines and stores column names for the table t_wo_record.
|
||||||
|
type TWoRecordColumns struct {
|
||||||
|
Id string // 主键ID
|
||||||
|
WoNo string // 工单编号
|
||||||
|
OperationType string // 操作类型:0创建,1分派,2处理,3完成,4关单,5取消
|
||||||
|
OperationDeptId string // 操作机构ID
|
||||||
|
OperationUserId string // 操作用户ID
|
||||||
|
TargetDeptId string // 目标部门ID
|
||||||
|
TargetUserId string // 目标用户ID
|
||||||
|
BeforeStatus string // 变更前状态
|
||||||
|
AfterStatus string // 变更后状态
|
||||||
|
TenantId string // 租户ID
|
||||||
|
CreatedDept string // 创建部门
|
||||||
|
CreatedBy string // 创建者
|
||||||
|
CreatedAt string // 创建时间
|
||||||
|
UpdatedBy string // 更新者
|
||||||
|
UpdatedAt string // 更新时间
|
||||||
|
}
|
||||||
|
|
||||||
|
// tWoRecordColumns holds the columns for the table t_wo_record.
|
||||||
|
var tWoRecordColumns = TWoRecordColumns{
|
||||||
|
Id: "id",
|
||||||
|
WoNo: "wo_no",
|
||||||
|
OperationType: "operation_type",
|
||||||
|
OperationDeptId: "operation_dept_id",
|
||||||
|
OperationUserId: "operation_user_id",
|
||||||
|
TargetDeptId: "target_dept_id",
|
||||||
|
TargetUserId: "target_user_id",
|
||||||
|
BeforeStatus: "before_status",
|
||||||
|
AfterStatus: "after_status",
|
||||||
|
TenantId: "tenant_id",
|
||||||
|
CreatedDept: "created_dept",
|
||||||
|
CreatedBy: "created_by",
|
||||||
|
CreatedAt: "created_at",
|
||||||
|
UpdatedBy: "updated_by",
|
||||||
|
UpdatedAt: "updated_at",
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewTWoRecordDao creates and returns a new DAO object for table data access.
|
||||||
|
func NewTWoRecordDao(handlers ...gdb.ModelHandler) *TWoRecordDao {
|
||||||
|
return &TWoRecordDao{
|
||||||
|
group: "default",
|
||||||
|
table: "t_wo_record",
|
||||||
|
columns: tWoRecordColumns,
|
||||||
|
handlers: handlers,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// DB retrieves and returns the underlying raw database management object of the current DAO.
|
||||||
|
func (dao *TWoRecordDao) DB() gdb.DB {
|
||||||
|
return g.DB(dao.group)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Table returns the table name of the current DAO.
|
||||||
|
func (dao *TWoRecordDao) Table() string {
|
||||||
|
return dao.table
|
||||||
|
}
|
||||||
|
|
||||||
|
// Columns returns all column names of the current DAO.
|
||||||
|
func (dao *TWoRecordDao) Columns() TWoRecordColumns {
|
||||||
|
return dao.columns
|
||||||
|
}
|
||||||
|
|
||||||
|
// Group returns the database configuration group name of the current DAO.
|
||||||
|
func (dao *TWoRecordDao) 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 *TWoRecordDao) 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 *TWoRecordDao) 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-worker-svr/internal/dao/internal"
|
||||||
|
)
|
||||||
|
|
||||||
|
// sysConfigDao is the data access object for the table sys_config.
|
||||||
|
// You can define custom methods on it to extend its functionality as needed.
|
||||||
|
type sysConfigDao struct {
|
||||||
|
*internal.SysConfigDao
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
// SysConfig is a globally accessible object for table sys_config operations.
|
||||||
|
SysConfig = sysConfigDao{internal.NewSysConfigDao()}
|
||||||
|
)
|
||||||
|
|
||||||
|
// 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-worker-svr/internal/dao/internal"
|
||||||
|
)
|
||||||
|
|
||||||
|
// sysDeptDao is the data access object for the table sys_dept.
|
||||||
|
// You can define custom methods on it to extend its functionality as needed.
|
||||||
|
type sysDeptDao struct {
|
||||||
|
*internal.SysDeptDao
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
// SysDept is a globally accessible object for table sys_dept operations.
|
||||||
|
SysDept = sysDeptDao{internal.NewSysDeptDao()}
|
||||||
|
)
|
||||||
|
|
||||||
|
// 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-worker-svr/internal/dao/internal"
|
||||||
|
)
|
||||||
|
|
||||||
|
// sysOssDao is the data access object for the table sys_oss.
|
||||||
|
// You can define custom methods on it to extend its functionality as needed.
|
||||||
|
type sysOssDao struct {
|
||||||
|
*internal.SysOssDao
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
// SysOss is a globally accessible object for table sys_oss operations.
|
||||||
|
SysOss = sysOssDao{internal.NewSysOssDao()}
|
||||||
|
)
|
||||||
|
|
||||||
|
// 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-worker-svr/internal/dao/internal"
|
||||||
|
)
|
||||||
|
|
||||||
|
// sysPermissionRevisionDao is the data access object for the table sys_permission_revision.
|
||||||
|
// You can define custom methods on it to extend its functionality as needed.
|
||||||
|
type sysPermissionRevisionDao struct {
|
||||||
|
*internal.SysPermissionRevisionDao
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
// SysPermissionRevision is a globally accessible object for table sys_permission_revision operations.
|
||||||
|
SysPermissionRevision = sysPermissionRevisionDao{internal.NewSysPermissionRevisionDao()}
|
||||||
|
)
|
||||||
|
|
||||||
|
// 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-worker-svr/internal/dao/internal"
|
||||||
|
)
|
||||||
|
|
||||||
|
// sysRoleDao is the data access object for the table sys_role.
|
||||||
|
// You can define custom methods on it to extend its functionality as needed.
|
||||||
|
type sysRoleDao struct {
|
||||||
|
*internal.SysRoleDao
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
// SysRole is a globally accessible object for table sys_role operations.
|
||||||
|
SysRole = sysRoleDao{internal.NewSysRoleDao()}
|
||||||
|
)
|
||||||
|
|
||||||
|
// 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-worker-svr/internal/dao/internal"
|
||||||
|
)
|
||||||
|
|
||||||
|
// sysRoleDeptDao is the data access object for the table sys_role_dept.
|
||||||
|
// You can define custom methods on it to extend its functionality as needed.
|
||||||
|
type sysRoleDeptDao struct {
|
||||||
|
*internal.SysRoleDeptDao
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
// SysRoleDept is a globally accessible object for table sys_role_dept operations.
|
||||||
|
SysRoleDept = sysRoleDeptDao{internal.NewSysRoleDeptDao()}
|
||||||
|
)
|
||||||
|
|
||||||
|
// 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-worker-svr/internal/dao/internal"
|
||||||
|
)
|
||||||
|
|
||||||
|
// sysRoleMenuDao is the data access object for the table sys_role_menu.
|
||||||
|
// You can define custom methods on it to extend its functionality as needed.
|
||||||
|
type sysRoleMenuDao struct {
|
||||||
|
*internal.SysRoleMenuDao
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
// SysRoleMenu is a globally accessible object for table sys_role_menu operations.
|
||||||
|
SysRoleMenu = sysRoleMenuDao{internal.NewSysRoleMenuDao()}
|
||||||
|
)
|
||||||
|
|
||||||
|
// 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-worker-svr/internal/dao/internal"
|
||||||
|
)
|
||||||
|
|
||||||
|
// sysRoleSyncTaskDao is the data access object for the table sys_role_sync_task.
|
||||||
|
// You can define custom methods on it to extend its functionality as needed.
|
||||||
|
type sysRoleSyncTaskDao struct {
|
||||||
|
*internal.SysRoleSyncTaskDao
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
// SysRoleSyncTask is a globally accessible object for table sys_role_sync_task operations.
|
||||||
|
SysRoleSyncTask = sysRoleSyncTaskDao{internal.NewSysRoleSyncTaskDao()}
|
||||||
|
)
|
||||||
|
|
||||||
|
// 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-worker-svr/internal/dao/internal"
|
||||||
|
)
|
||||||
|
|
||||||
|
// sysUserRoleDao is the data access object for the table sys_user_role.
|
||||||
|
// You can define custom methods on it to extend its functionality as needed.
|
||||||
|
type sysUserRoleDao struct {
|
||||||
|
*internal.SysUserRoleDao
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
// SysUserRole is a globally accessible object for table sys_user_role operations.
|
||||||
|
SysUserRole = sysUserRoleDao{internal.NewSysUserRoleDao()}
|
||||||
|
)
|
||||||
|
|
||||||
|
// 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-worker-svr/internal/dao/internal"
|
||||||
|
)
|
||||||
|
|
||||||
|
// tMessageLogDao is the data access object for the table t_message_log.
|
||||||
|
// You can define custom methods on it to extend its functionality as needed.
|
||||||
|
type tMessageLogDao struct {
|
||||||
|
*internal.TMessageLogDao
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
// TMessageLog is a globally accessible object for table t_message_log operations.
|
||||||
|
TMessageLog = tMessageLogDao{internal.NewTMessageLogDao()}
|
||||||
|
)
|
||||||
|
|
||||||
|
// 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-worker-svr/internal/dao/internal"
|
||||||
|
)
|
||||||
|
|
||||||
|
// tResourceBatchDownloadDao is the data access object for the table t_resource_batch_download.
|
||||||
|
// You can define custom methods on it to extend its functionality as needed.
|
||||||
|
type tResourceBatchDownloadDao struct {
|
||||||
|
*internal.TResourceBatchDownloadDao
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
// TResourceBatchDownload is a globally accessible object for table t_resource_batch_download operations.
|
||||||
|
TResourceBatchDownload = tResourceBatchDownloadDao{internal.NewTResourceBatchDownloadDao()}
|
||||||
|
)
|
||||||
|
|
||||||
|
// 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-worker-svr/internal/dao/internal"
|
||||||
|
)
|
||||||
|
|
||||||
|
// tSimCardCancellationDao is the data access object for the table t_sim_card_cancellation.
|
||||||
|
// You can define custom methods on it to extend its functionality as needed.
|
||||||
|
type tSimCardCancellationDao struct {
|
||||||
|
*internal.TSimCardCancellationDao
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
// TSimCardCancellation is a globally accessible object for table t_sim_card_cancellation operations.
|
||||||
|
TSimCardCancellation = tSimCardCancellationDao{internal.NewTSimCardCancellationDao()}
|
||||||
|
)
|
||||||
|
|
||||||
|
// 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-worker-svr/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-worker-svr/internal/dao/internal"
|
||||||
|
)
|
||||||
|
|
||||||
|
// tSimFlowPoolDao is the data access object for the table t_sim_flow_pool.
|
||||||
|
// You can define custom methods on it to extend its functionality as needed.
|
||||||
|
type tSimFlowPoolDao struct {
|
||||||
|
*internal.TSimFlowPoolDao
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
// TSimFlowPool is a globally accessible object for table t_sim_flow_pool operations.
|
||||||
|
TSimFlowPool = tSimFlowPoolDao{internal.NewTSimFlowPoolDao()}
|
||||||
|
)
|
||||||
|
|
||||||
|
// 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-worker-svr/internal/dao/internal"
|
||||||
|
)
|
||||||
|
|
||||||
|
// tUserMessageDao is the data access object for the table t_user_message.
|
||||||
|
// You can define custom methods on it to extend its functionality as needed.
|
||||||
|
type tUserMessageDao struct {
|
||||||
|
*internal.TUserMessageDao
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
// TUserMessage is a globally accessible object for table t_user_message operations.
|
||||||
|
TUserMessage = tUserMessageDao{internal.NewTUserMessageDao()}
|
||||||
|
)
|
||||||
|
|
||||||
|
// 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-worker-svr/internal/dao/internal"
|
||||||
|
)
|
||||||
|
|
||||||
|
// tWarnConfigDao is the data access object for the table t_warn_config.
|
||||||
|
// You can define custom methods on it to extend its functionality as needed.
|
||||||
|
type tWarnConfigDao struct {
|
||||||
|
*internal.TWarnConfigDao
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
// TWarnConfig is a globally accessible object for table t_warn_config operations.
|
||||||
|
TWarnConfig = tWarnConfigDao{internal.NewTWarnConfigDao()}
|
||||||
|
)
|
||||||
|
|
||||||
|
// 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-worker-svr/internal/dao/internal"
|
||||||
|
)
|
||||||
|
|
||||||
|
// tWarnLogDao is the data access object for the table t_warn_log.
|
||||||
|
// You can define custom methods on it to extend its functionality as needed.
|
||||||
|
type tWarnLogDao struct {
|
||||||
|
*internal.TWarnLogDao
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
// TWarnLog is a globally accessible object for table t_warn_log operations.
|
||||||
|
TWarnLog = tWarnLogDao{internal.NewTWarnLogDao()}
|
||||||
|
)
|
||||||
|
|
||||||
|
// 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-worker-svr/internal/dao/internal"
|
||||||
|
)
|
||||||
|
|
||||||
|
// tWoMainDao is the data access object for the table t_wo_main.
|
||||||
|
// You can define custom methods on it to extend its functionality as needed.
|
||||||
|
type tWoMainDao struct {
|
||||||
|
*internal.TWoMainDao
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
// TWoMain is a globally accessible object for table t_wo_main operations.
|
||||||
|
TWoMain = tWoMainDao{internal.NewTWoMainDao()}
|
||||||
|
)
|
||||||
|
|
||||||
|
// 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-worker-svr/internal/dao/internal"
|
||||||
|
)
|
||||||
|
|
||||||
|
// tWoRecordDao is the data access object for the table t_wo_record.
|
||||||
|
// You can define custom methods on it to extend its functionality as needed.
|
||||||
|
type tWoRecordDao struct {
|
||||||
|
*internal.TWoRecordDao
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
// TWoRecord is a globally accessible object for table t_wo_record operations.
|
||||||
|
TWoRecord = tWoRecordDao{internal.NewTWoRecordDao()}
|
||||||
|
)
|
||||||
|
|
||||||
|
// Add your custom methods and functionality below.
|
||||||
@@ -0,0 +1,802 @@
|
|||||||
|
// Package archive durably stores summary-only receipts for completed full-sync
|
||||||
|
// runs and replay actions. It depends only on the shared full-sync contracts;
|
||||||
|
// repository implementations and service wiring live elsewhere.
|
||||||
|
package archive
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"context"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gogf/gf/v2/errors/gerror"
|
||||||
|
|
||||||
|
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/fullsync/contract"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
defaultInterval = time.Minute
|
||||||
|
defaultBackoff = time.Second
|
||||||
|
defaultMaxBackoff = time.Minute
|
||||||
|
defaultMaxAttempts = 4
|
||||||
|
defaultReplayLeaseTTL = time.Minute
|
||||||
|
defaultReplayLeaseName = "fullsync-archive"
|
||||||
|
defaultReleaseTimeout = 5 * time.Second
|
||||||
|
maxRounds = 10000
|
||||||
|
)
|
||||||
|
|
||||||
|
var errChecksumMismatch = gerror.New("archive checksum verification failed")
|
||||||
|
|
||||||
|
// Config controls the recovery scanner. Root is the archive directory. Zero
|
||||||
|
// timing values select conservative defaults.
|
||||||
|
type Config struct {
|
||||||
|
Root string
|
||||||
|
Interval time.Duration
|
||||||
|
InitialBackoff time.Duration
|
||||||
|
MaxBackoff time.Duration
|
||||||
|
MaxAttempts int
|
||||||
|
ReplayLeaseOwner string
|
||||||
|
ReplayLeaseTTL time.Duration
|
||||||
|
Now func() time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReplayRepositories are the contract boundaries used to recover replay
|
||||||
|
// actions left in receipt_pending after a process restart.
|
||||||
|
type ReplayRepositories struct {
|
||||||
|
Discovery contract.ReplayWorkDiscovery
|
||||||
|
Leases contract.ReplayLeaseStore
|
||||||
|
Progress contract.ReplayProgressStore
|
||||||
|
}
|
||||||
|
|
||||||
|
// Service publishes archive files and scans durable repository indexes at
|
||||||
|
// startup and periodically. A Service may be configured for runs, actions, or
|
||||||
|
// both.
|
||||||
|
type Service struct {
|
||||||
|
root string
|
||||||
|
runDiscovery contract.ArchiveWorkDiscovery
|
||||||
|
runLoader contract.ArchiveWorkLoader
|
||||||
|
runRegistrar contract.ArchiveRegistrar
|
||||||
|
replay ReplayRepositories
|
||||||
|
interval time.Duration
|
||||||
|
initialBackoff time.Duration
|
||||||
|
maxBackoff time.Duration
|
||||||
|
maxAttempts int
|
||||||
|
leaseOwner string
|
||||||
|
leaseTTL time.Duration
|
||||||
|
now func() time.Time
|
||||||
|
|
||||||
|
// Publications are serialized so repeat writes in this process observe the
|
||||||
|
// same final file timestamp and therefore produce the same receipt.
|
||||||
|
publishMu sync.Mutex
|
||||||
|
|
||||||
|
mu sync.Mutex
|
||||||
|
running bool
|
||||||
|
cancel context.CancelFunc
|
||||||
|
done chan struct{}
|
||||||
|
|
||||||
|
// These hooks keep failure-path tests deterministic without weakening the
|
||||||
|
// production filesystem path.
|
||||||
|
replaceFile func(string, string) error
|
||||||
|
afterReplace func(string) error
|
||||||
|
}
|
||||||
|
|
||||||
|
// New constructs a run-archive service with default scanner settings.
|
||||||
|
func New(root string, discovery contract.ArchiveWorkDiscovery, loader contract.ArchiveWorkLoader, registrar contract.ArchiveRegistrar) *Service {
|
||||||
|
return NewService(Config{Root: root}, discovery, loader, registrar)
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewService constructs a run-archive service. Use NewServiceWithReplay when
|
||||||
|
// replay receipt recovery is also required.
|
||||||
|
func NewService(cfg Config, discovery contract.ArchiveWorkDiscovery, loader contract.ArchiveWorkLoader, registrar contract.ArchiveRegistrar) *Service {
|
||||||
|
return NewServiceWithReplay(cfg, discovery, loader, registrar, ReplayRepositories{})
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewServiceWithReplay constructs a lifecycle-enabled run and replay archive
|
||||||
|
// service using only shared contract interfaces.
|
||||||
|
func NewServiceWithReplay(
|
||||||
|
cfg Config,
|
||||||
|
discovery contract.ArchiveWorkDiscovery,
|
||||||
|
loader contract.ArchiveWorkLoader,
|
||||||
|
registrar contract.ArchiveRegistrar,
|
||||||
|
replay ReplayRepositories,
|
||||||
|
) *Service {
|
||||||
|
if cfg.Interval <= 0 {
|
||||||
|
cfg.Interval = defaultInterval
|
||||||
|
}
|
||||||
|
if cfg.InitialBackoff <= 0 {
|
||||||
|
cfg.InitialBackoff = defaultBackoff
|
||||||
|
}
|
||||||
|
if cfg.MaxBackoff <= 0 {
|
||||||
|
cfg.MaxBackoff = defaultMaxBackoff
|
||||||
|
}
|
||||||
|
if cfg.MaxBackoff < cfg.InitialBackoff {
|
||||||
|
cfg.MaxBackoff = cfg.InitialBackoff
|
||||||
|
}
|
||||||
|
if cfg.MaxAttempts <= 0 {
|
||||||
|
cfg.MaxAttempts = defaultMaxAttempts
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(cfg.ReplayLeaseOwner) == "" {
|
||||||
|
cfg.ReplayLeaseOwner = defaultReplayLeaseName
|
||||||
|
}
|
||||||
|
if cfg.ReplayLeaseTTL <= 0 {
|
||||||
|
cfg.ReplayLeaseTTL = defaultReplayLeaseTTL
|
||||||
|
}
|
||||||
|
if cfg.Now == nil {
|
||||||
|
cfg.Now = time.Now
|
||||||
|
}
|
||||||
|
|
||||||
|
return &Service{
|
||||||
|
root: cfg.Root,
|
||||||
|
runDiscovery: discovery,
|
||||||
|
runLoader: loader,
|
||||||
|
runRegistrar: registrar,
|
||||||
|
replay: replay,
|
||||||
|
interval: cfg.Interval,
|
||||||
|
initialBackoff: cfg.InitialBackoff,
|
||||||
|
maxBackoff: cfg.MaxBackoff,
|
||||||
|
maxAttempts: cfg.MaxAttempts,
|
||||||
|
leaseOwner: cfg.ReplayLeaseOwner,
|
||||||
|
leaseTTL: cfg.ReplayLeaseTTL,
|
||||||
|
now: cfg.Now,
|
||||||
|
replaceFile: atomicReplace,
|
||||||
|
afterReplace: func(string) error { return nil },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start launches an immediate recovery scan followed by periodic scans. It is
|
||||||
|
// idempotent while the service is running.
|
||||||
|
func (s *Service) Start(parent context.Context) error {
|
||||||
|
if parent == nil {
|
||||||
|
return gerror.New("archive parent context is nil")
|
||||||
|
}
|
||||||
|
if err := s.validateConfiguration(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
s.mu.Lock()
|
||||||
|
if s.running {
|
||||||
|
s.mu.Unlock()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithCancel(parent)
|
||||||
|
done := make(chan struct{})
|
||||||
|
s.cancel, s.done, s.running = cancel, done, true
|
||||||
|
s.mu.Unlock()
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
defer func() {
|
||||||
|
s.mu.Lock()
|
||||||
|
if s.done == done {
|
||||||
|
s.running = false
|
||||||
|
s.cancel = nil
|
||||||
|
}
|
||||||
|
s.mu.Unlock()
|
||||||
|
close(done)
|
||||||
|
}()
|
||||||
|
s.scanLoop(ctx)
|
||||||
|
}()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stop cancels the scanner and waits for it to finish. It is safe to call more
|
||||||
|
// than once.
|
||||||
|
func (s *Service) Stop() error {
|
||||||
|
s.mu.Lock()
|
||||||
|
if !s.running {
|
||||||
|
s.mu.Unlock()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
cancel, done := s.cancel, s.done
|
||||||
|
s.mu.Unlock()
|
||||||
|
|
||||||
|
cancel()
|
||||||
|
<-done
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close implements the common service lifecycle convention.
|
||||||
|
func (s *Service) Close() error { return s.Stop() }
|
||||||
|
|
||||||
|
// Done is closed when the current scanner invocation exits. It returns nil
|
||||||
|
// before the service has been started.
|
||||||
|
func (s *Service) Done() <-chan struct{} {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
return s.done
|
||||||
|
}
|
||||||
|
|
||||||
|
// RunOnce performs one recovery pass. It is useful for explicit lifecycle
|
||||||
|
// wiring and deterministic integration tests.
|
||||||
|
func (s *Service) RunOnce(ctx context.Context) error {
|
||||||
|
if ctx == nil {
|
||||||
|
return gerror.New("archive scan context is nil")
|
||||||
|
}
|
||||||
|
if err := s.validateConfiguration(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
var scanErrors []error
|
||||||
|
if s.runDiscovery != nil {
|
||||||
|
if err := s.scanRuns(ctx); err != nil {
|
||||||
|
scanErrors = append(scanErrors, gerror.Wrap(err, "scan run archives"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if s.replay.Discovery != nil && ctx.Err() == nil {
|
||||||
|
if err := s.scanActions(ctx); err != nil {
|
||||||
|
scanErrors = append(scanErrors, gerror.Wrap(err, "scan replay action archives"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return errors.Join(scanErrors...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// PublishRun atomically writes a run summary and then conditionally registers
|
||||||
|
// the durable receipt against the work version.
|
||||||
|
func (s *Service) PublishRun(ctx context.Context, work contract.ArchiveWork, snapshot contract.RunArchive) (contract.ArchiveReceipt, error) {
|
||||||
|
if err := validateWork(work); err != nil {
|
||||||
|
return contract.ArchiveReceipt{}, err
|
||||||
|
}
|
||||||
|
normalized, err := normalizeRunArchive(work, snapshot)
|
||||||
|
if err != nil {
|
||||||
|
return contract.ArchiveReceipt{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
pathTime := normalized.Run.CompletedAt
|
||||||
|
if pathTime.IsZero() {
|
||||||
|
pathTime = normalized.Run.CreatedAt
|
||||||
|
}
|
||||||
|
receipt, err := s.publishJSON(ctx, "runs", work.RunID, pathTime, normalized)
|
||||||
|
if err != nil {
|
||||||
|
return contract.ArchiveReceipt{}, err
|
||||||
|
}
|
||||||
|
if s.runRegistrar != nil {
|
||||||
|
if err := s.runRegistrar.RegisterArchive(ctx, work, receipt); err != nil {
|
||||||
|
return contract.ArchiveReceipt{}, gerror.Wrap(err, "register run archive")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return receipt, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// PublishAction atomically writes a receipt_pending replay action summary. The
|
||||||
|
// repository update is performed by ArchiveAction because it requires a fenced
|
||||||
|
// replay lease.
|
||||||
|
func (s *Service) PublishAction(ctx context.Context, action contract.ReplayAction) (contract.ArchiveReceipt, error) {
|
||||||
|
normalized, err := normalizeAction(action)
|
||||||
|
if err != nil {
|
||||||
|
return contract.ArchiveReceipt{}, err
|
||||||
|
}
|
||||||
|
return s.publishJSON(ctx, "actions", normalized.ActionID, normalized.CreatedAt, normalized)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ArchiveRun loads, publishes, and registers one durable run work item.
|
||||||
|
func (s *Service) ArchiveRun(ctx context.Context, work contract.ArchiveWork) error {
|
||||||
|
if s.runLoader == nil {
|
||||||
|
return gerror.New("archive run loader is nil")
|
||||||
|
}
|
||||||
|
if s.runRegistrar == nil {
|
||||||
|
return gerror.New("archive run registrar is nil")
|
||||||
|
}
|
||||||
|
|
||||||
|
snapshot, err := s.runLoader.LoadArchiveWork(ctx, work)
|
||||||
|
if err != nil {
|
||||||
|
s.recordRunFailure(ctx, work, err)
|
||||||
|
return gerror.Wrap(err, "load run archive work")
|
||||||
|
}
|
||||||
|
if _, err = s.PublishRun(ctx, work, snapshot); err != nil {
|
||||||
|
s.recordRunFailure(ctx, work, err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ArchiveAction acquires a replay lease, atomically publishes the action
|
||||||
|
// summary, and fences the completed receipt update through ReplayProgressStore.
|
||||||
|
func (s *Service) ArchiveAction(ctx context.Context, action contract.ReplayAction) error {
|
||||||
|
if s.replay.Leases == nil || s.replay.Progress == nil {
|
||||||
|
return gerror.New("replay archive repositories are incomplete")
|
||||||
|
}
|
||||||
|
if action.Status != contract.ActionReceiptPending {
|
||||||
|
return gerror.Wrap(contract.ErrInvalidArgument, "replay action is not receipt_pending")
|
||||||
|
}
|
||||||
|
if _, err := normalizeAction(action); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
lease, err := s.replay.Leases.AcquireReplayLease(ctx, action.ActionID, s.leaseOwner, s.leaseTTL)
|
||||||
|
if err != nil {
|
||||||
|
return gerror.Wrap(err, "acquire replay archive lease")
|
||||||
|
}
|
||||||
|
defer func() {
|
||||||
|
releaseCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), defaultReleaseTimeout)
|
||||||
|
defer cancel()
|
||||||
|
_ = s.replay.Leases.ReleaseReplayLease(releaseCtx, lease)
|
||||||
|
}()
|
||||||
|
|
||||||
|
receipt, err := s.PublishAction(ctx, action)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
lease, err = s.replay.Leases.RenewReplayLease(ctx, lease, s.leaseTTL)
|
||||||
|
if err != nil {
|
||||||
|
return gerror.Wrap(err, "renew replay archive lease")
|
||||||
|
}
|
||||||
|
completedAt := receipt.ArchivedAt
|
||||||
|
_, err = s.replay.Progress.UpdateReplayAction(ctx, contract.ReplayActionUpdate{
|
||||||
|
Lease: lease,
|
||||||
|
ExpectedRevision: action.Revision,
|
||||||
|
Status: contract.ActionCompleted,
|
||||||
|
Progress: action.Progress,
|
||||||
|
Receipt: &receipt,
|
||||||
|
CompletedAt: &completedAt,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return gerror.Wrap(err, "register replay action archive")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) scanLoop(ctx context.Context) {
|
||||||
|
_ = s.RunOnce(ctx)
|
||||||
|
ticker := time.NewTicker(s.interval)
|
||||||
|
defer ticker.Stop()
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
case <-ticker.C:
|
||||||
|
_ = s.RunOnce(ctx)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) scanRuns(ctx context.Context) error {
|
||||||
|
page := contract.PageRequest{Limit: contract.DefaultPageLimit}
|
||||||
|
seenCursors := map[contract.Cursor]struct{}{page.Cursor: {}}
|
||||||
|
var itemErrors []error
|
||||||
|
for {
|
||||||
|
if err := ctx.Err(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
result, err := s.runDiscovery.ListArchiveWork(ctx, page)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for _, work := range result.Items {
|
||||||
|
err := s.retry(ctx, func() error { return s.ArchiveRun(ctx, work) })
|
||||||
|
if err != nil {
|
||||||
|
if ctx.Err() != nil {
|
||||||
|
return ctx.Err()
|
||||||
|
}
|
||||||
|
itemErrors = append(itemErrors, gerror.Wrapf(err, "archive run %q", work.RunID))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if result.NextCursor == "" {
|
||||||
|
return errors.Join(itemErrors...)
|
||||||
|
}
|
||||||
|
if _, repeated := seenCursors[result.NextCursor]; repeated {
|
||||||
|
return errors.Join(append(itemErrors, gerror.New("run archive pagination cursor did not advance"))...)
|
||||||
|
}
|
||||||
|
seenCursors[result.NextCursor] = struct{}{}
|
||||||
|
page.Cursor = result.NextCursor
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) scanActions(ctx context.Context) error {
|
||||||
|
page := contract.PageRequest{Limit: contract.DefaultPageLimit}
|
||||||
|
seenCursors := map[contract.Cursor]struct{}{page.Cursor: {}}
|
||||||
|
var itemErrors []error
|
||||||
|
for {
|
||||||
|
if err := ctx.Err(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
result, err := s.replay.Discovery.ListReplayWork(ctx, page)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for _, action := range result.Items {
|
||||||
|
if action.Status != contract.ActionReceiptPending {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
err := s.retry(ctx, func() error { return s.ArchiveAction(ctx, action) })
|
||||||
|
if errors.Is(err, contract.ErrBusy) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
if ctx.Err() != nil {
|
||||||
|
return ctx.Err()
|
||||||
|
}
|
||||||
|
itemErrors = append(itemErrors, gerror.Wrapf(err, "archive replay action %q", action.ActionID))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if result.NextCursor == "" {
|
||||||
|
return errors.Join(itemErrors...)
|
||||||
|
}
|
||||||
|
if _, repeated := seenCursors[result.NextCursor]; repeated {
|
||||||
|
return errors.Join(append(itemErrors, gerror.New("replay archive pagination cursor did not advance"))...)
|
||||||
|
}
|
||||||
|
seenCursors[result.NextCursor] = struct{}{}
|
||||||
|
page.Cursor = result.NextCursor
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) retry(ctx context.Context, fn func() error) error {
|
||||||
|
delay := s.initialBackoff
|
||||||
|
var lastErr error
|
||||||
|
for attempt := 0; attempt < s.maxAttempts; attempt++ {
|
||||||
|
if err := ctx.Err(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
lastErr = fn()
|
||||||
|
if lastErr == nil || errors.Is(lastErr, contract.ErrBusy) {
|
||||||
|
return lastErr
|
||||||
|
}
|
||||||
|
if attempt+1 == s.maxAttempts {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
timer := time.NewTimer(delay)
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
if !timer.Stop() {
|
||||||
|
<-timer.C
|
||||||
|
}
|
||||||
|
return ctx.Err()
|
||||||
|
case <-timer.C:
|
||||||
|
}
|
||||||
|
if delay <= s.maxBackoff/2 {
|
||||||
|
delay *= 2
|
||||||
|
} else {
|
||||||
|
delay = s.maxBackoff
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return lastErr
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) recordRunFailure(ctx context.Context, work contract.ArchiveWork, archiveErr error) {
|
||||||
|
if s.runRegistrar == nil || ctx.Err() != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_ = s.runRegistrar.RegisterArchiveFailure(ctx, work, contract.BoundedErrorMessage(archiveErr.Error()))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) publishJSON(ctx context.Context, kind, id string, pathTime time.Time, value any) (contract.ArchiveReceipt, error) {
|
||||||
|
if ctx == nil {
|
||||||
|
return contract.ArchiveReceipt{}, gerror.New("archive publication context is nil")
|
||||||
|
}
|
||||||
|
if err := ctx.Err(); err != nil {
|
||||||
|
return contract.ArchiveReceipt{}, err
|
||||||
|
}
|
||||||
|
dir, final, relative, err := s.storagePath(kind, id, pathTime)
|
||||||
|
if err != nil {
|
||||||
|
return contract.ArchiveReceipt{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
s.publishMu.Lock()
|
||||||
|
defer s.publishMu.Unlock()
|
||||||
|
|
||||||
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||||
|
return contract.ArchiveReceipt{}, gerror.Wrap(err, "create archive directory")
|
||||||
|
}
|
||||||
|
if err := ctx.Err(); err != nil {
|
||||||
|
return contract.ArchiveReceipt{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
temp, err := os.CreateTemp(dir, "."+id+"-*.tmp")
|
||||||
|
if err != nil {
|
||||||
|
return contract.ArchiveReceipt{}, gerror.Wrap(err, "create archive temporary file")
|
||||||
|
}
|
||||||
|
tempName := temp.Name()
|
||||||
|
defer func() { _ = os.Remove(tempName) }()
|
||||||
|
|
||||||
|
hasher := sha256.New()
|
||||||
|
buffer := bufio.NewWriter(io.MultiWriter(contextWriter{ctx: ctx, writer: temp}, hasher))
|
||||||
|
encoder := json.NewEncoder(buffer)
|
||||||
|
if err = encoder.Encode(value); err == nil {
|
||||||
|
err = buffer.Flush()
|
||||||
|
}
|
||||||
|
if err == nil {
|
||||||
|
err = temp.Chmod(0o644)
|
||||||
|
}
|
||||||
|
if err == nil {
|
||||||
|
err = temp.Sync()
|
||||||
|
}
|
||||||
|
closeErr := temp.Close()
|
||||||
|
if err == nil {
|
||||||
|
err = closeErr
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
_ = temp.Close()
|
||||||
|
return contract.ArchiveReceipt{}, gerror.Wrap(err, "encode archive temporary file")
|
||||||
|
}
|
||||||
|
expectedChecksum := hex.EncodeToString(hasher.Sum(nil))
|
||||||
|
|
||||||
|
if receipt, ok, existingErr := s.existingReceipt(final, relative, kind, id, expectedChecksum); existingErr != nil {
|
||||||
|
return contract.ArchiveReceipt{}, existingErr
|
||||||
|
} else if ok {
|
||||||
|
if err := syncDirectory(dir); err != nil {
|
||||||
|
return contract.ArchiveReceipt{}, gerror.Wrap(err, "sync archive directory")
|
||||||
|
}
|
||||||
|
return receipt, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := ctx.Err(); err != nil {
|
||||||
|
return contract.ArchiveReceipt{}, err
|
||||||
|
}
|
||||||
|
if err := s.replaceFile(tempName, final); err != nil {
|
||||||
|
return contract.ArchiveReceipt{}, gerror.Wrap(err, "publish archive file")
|
||||||
|
}
|
||||||
|
if err := syncDirectory(dir); err != nil {
|
||||||
|
return contract.ArchiveReceipt{}, gerror.Wrap(err, "sync archive directory")
|
||||||
|
}
|
||||||
|
if err := s.afterReplace(final); err != nil {
|
||||||
|
return contract.ArchiveReceipt{}, gerror.Wrap(err, "archive post-publish verification hook")
|
||||||
|
}
|
||||||
|
|
||||||
|
stored, err := os.ReadFile(final)
|
||||||
|
if err != nil {
|
||||||
|
return contract.ArchiveReceipt{}, gerror.Wrap(err, "read published archive file")
|
||||||
|
}
|
||||||
|
storedSum := sha256.Sum256(stored)
|
||||||
|
if hex.EncodeToString(storedSum[:]) != expectedChecksum {
|
||||||
|
return contract.ArchiveReceipt{}, errChecksumMismatch
|
||||||
|
}
|
||||||
|
info, err := os.Stat(final)
|
||||||
|
if err != nil {
|
||||||
|
return contract.ArchiveReceipt{}, gerror.Wrap(err, "stat published archive file")
|
||||||
|
}
|
||||||
|
receipt := contract.ArchiveReceipt{
|
||||||
|
Version: contract.ArchiveSchemaVersion,
|
||||||
|
Path: relative,
|
||||||
|
Checksum: expectedChecksum,
|
||||||
|
ArchivedAt: archiveModTime(info, s.now),
|
||||||
|
}
|
||||||
|
if err := receipt.Validate(); err != nil {
|
||||||
|
return contract.ArchiveReceipt{}, err
|
||||||
|
}
|
||||||
|
return receipt, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) existingReceipt(final, relative, kind, id, expectedChecksum string) (contract.ArchiveReceipt, bool, error) {
|
||||||
|
info, err := os.Lstat(final)
|
||||||
|
if errors.Is(err, os.ErrNotExist) {
|
||||||
|
return contract.ArchiveReceipt{}, false, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return contract.ArchiveReceipt{}, false, gerror.Wrap(err, "inspect existing archive file")
|
||||||
|
}
|
||||||
|
if !info.Mode().IsRegular() {
|
||||||
|
return contract.ArchiveReceipt{}, false, nil
|
||||||
|
}
|
||||||
|
stored, err := os.ReadFile(final)
|
||||||
|
if err != nil {
|
||||||
|
return contract.ArchiveReceipt{}, false, gerror.Wrap(err, "read existing archive file")
|
||||||
|
}
|
||||||
|
storedSum := sha256.Sum256(stored)
|
||||||
|
if hex.EncodeToString(storedSum[:]) != expectedChecksum {
|
||||||
|
if validExistingArchive(kind, id, stored) {
|
||||||
|
return contract.ArchiveReceipt{}, false, gerror.Wrap(contract.ErrConflict, "archive path already contains different valid content")
|
||||||
|
}
|
||||||
|
return contract.ArchiveReceipt{}, false, nil
|
||||||
|
}
|
||||||
|
receipt := contract.ArchiveReceipt{
|
||||||
|
Version: contract.ArchiveSchemaVersion,
|
||||||
|
Path: relative,
|
||||||
|
Checksum: expectedChecksum,
|
||||||
|
ArchivedAt: archiveModTime(info, s.now),
|
||||||
|
}
|
||||||
|
if err := receipt.Validate(); err != nil {
|
||||||
|
return contract.ArchiveReceipt{}, false, err
|
||||||
|
}
|
||||||
|
return receipt, true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func validExistingArchive(kind, id string, data []byte) bool {
|
||||||
|
switch kind {
|
||||||
|
case "runs":
|
||||||
|
var snapshot contract.RunArchive
|
||||||
|
if json.Unmarshal(data, &snapshot) != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
_, err := normalizeRunArchive(contract.ArchiveWork{RunID: id, Version: "existing"}, snapshot)
|
||||||
|
return err == nil
|
||||||
|
case "actions":
|
||||||
|
var action contract.ReplayAction
|
||||||
|
if json.Unmarshal(data, &action) != nil || action.ActionID != id {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
_, err := normalizeAction(action)
|
||||||
|
return err == nil
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func archiveModTime(info os.FileInfo, now func() time.Time) time.Time {
|
||||||
|
archivedAt := info.ModTime().UTC()
|
||||||
|
if archivedAt.IsZero() {
|
||||||
|
archivedAt = now().UTC()
|
||||||
|
}
|
||||||
|
return archivedAt
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) storagePath(kind, id string, pathTime time.Time) (string, string, string, error) {
|
||||||
|
if err := validateStorageID(id); err != nil {
|
||||||
|
return "", "", "", err
|
||||||
|
}
|
||||||
|
if kind != "runs" && kind != "actions" {
|
||||||
|
return "", "", "", gerror.Wrap(contract.ErrInvalidArgument, "unknown archive kind")
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(s.root) == "" {
|
||||||
|
return "", "", "", gerror.New("archive root is empty")
|
||||||
|
}
|
||||||
|
if pathTime.IsZero() {
|
||||||
|
pathTime = s.now().UTC()
|
||||||
|
} else {
|
||||||
|
pathTime = pathTime.UTC()
|
||||||
|
}
|
||||||
|
if pathTime.Year() < 1 || pathTime.Year() > 9999 {
|
||||||
|
return "", "", "", gerror.Wrap(contract.ErrInvalidArgument, "archive timestamp is outside the supported year range")
|
||||||
|
}
|
||||||
|
|
||||||
|
root := filepath.Clean(s.root)
|
||||||
|
prefix := "run-"
|
||||||
|
if kind == "actions" {
|
||||||
|
prefix = "action-"
|
||||||
|
}
|
||||||
|
relativeOS := prefix + id + ".json"
|
||||||
|
final := filepath.Join(root, relativeOS)
|
||||||
|
contained, err := filepath.Rel(root, final)
|
||||||
|
if err != nil || filepath.IsAbs(contained) || contained == ".." || strings.HasPrefix(contained, ".."+string(filepath.Separator)) {
|
||||||
|
return "", "", "", gerror.Wrap(contract.ErrInvalidArgument, "archive path escapes root")
|
||||||
|
}
|
||||||
|
return filepath.Dir(final), final, filepath.ToSlash(relativeOS), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) validateConfiguration() error {
|
||||||
|
if strings.TrimSpace(s.root) == "" {
|
||||||
|
return gerror.New("archive root is empty")
|
||||||
|
}
|
||||||
|
if s.runDiscovery != nil && (s.runLoader == nil || s.runRegistrar == nil) {
|
||||||
|
return gerror.New("run archive repositories are incomplete")
|
||||||
|
}
|
||||||
|
if s.replay.Discovery != nil && (s.replay.Leases == nil || s.replay.Progress == nil) {
|
||||||
|
return gerror.New("replay archive repositories are incomplete")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateWork(work contract.ArchiveWork) error {
|
||||||
|
if err := validateStorageID(work.RunID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(work.Version) == "" {
|
||||||
|
return gerror.Wrap(contract.ErrInvalidArgument, "archive work version is required")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateStorageID(id string) error {
|
||||||
|
if err := contract.ValidateID(id); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if id == "." || id == ".." || filepath.VolumeName(id) != "" || strings.ContainsRune(id, ':') || filepath.Base(id) != id {
|
||||||
|
return gerror.Wrap(contract.ErrInvalidArgument, "resource identifier is not safe for archive storage")
|
||||||
|
}
|
||||||
|
name := strings.ToUpper(strings.TrimSuffix(id, filepath.Ext(id)))
|
||||||
|
switch name {
|
||||||
|
case "CON", "PRN", "AUX", "NUL", "COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7", "COM8", "COM9", "LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8", "LPT9":
|
||||||
|
return gerror.Wrap(contract.ErrInvalidArgument, "resource identifier is reserved by the filesystem")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeRunArchive(work contract.ArchiveWork, snapshot contract.RunArchive) (contract.RunArchive, error) {
|
||||||
|
if snapshot.Version == 0 {
|
||||||
|
snapshot.Version = contract.ArchiveSchemaVersion
|
||||||
|
}
|
||||||
|
if snapshot.Version != contract.ArchiveSchemaVersion {
|
||||||
|
return contract.RunArchive{}, gerror.Wrap(contract.ErrInvalidArgument, "invalid archive schema version")
|
||||||
|
}
|
||||||
|
if snapshot.Run.RunID != work.RunID {
|
||||||
|
return contract.RunArchive{}, gerror.Wrap(contract.ErrInvalidArgument, "archive run ID does not match work")
|
||||||
|
}
|
||||||
|
if !snapshot.Run.Status.Terminal() {
|
||||||
|
return contract.RunArchive{}, gerror.Wrap(contract.ErrInvalidArgument, "only terminal runs can be archived")
|
||||||
|
}
|
||||||
|
if err := snapshot.Run.Validate(); err != nil {
|
||||||
|
return contract.RunArchive{}, err
|
||||||
|
}
|
||||||
|
if len(snapshot.Rounds) > maxRounds {
|
||||||
|
return contract.RunArchive{}, gerror.Wrap(contract.ErrInvalidArgument, "too many archived rounds")
|
||||||
|
}
|
||||||
|
|
||||||
|
snapshot.Run = normalizeRunMeta(snapshot.Run)
|
||||||
|
snapshot.Rounds = append([]contract.RoundStats(nil), snapshot.Rounds...)
|
||||||
|
if snapshot.Rounds == nil {
|
||||||
|
snapshot.Rounds = make([]contract.RoundStats, 0)
|
||||||
|
}
|
||||||
|
seen := make(map[int]struct{}, len(snapshot.Rounds))
|
||||||
|
for i := range snapshot.Rounds {
|
||||||
|
round := &snapshot.Rounds[i]
|
||||||
|
if round.RunID != work.RunID {
|
||||||
|
return contract.RunArchive{}, gerror.Wrap(contract.ErrInvalidArgument, "archived round belongs to another run")
|
||||||
|
}
|
||||||
|
if _, duplicate := seen[round.Round]; duplicate {
|
||||||
|
return contract.RunArchive{}, gerror.Wrap(contract.ErrInvalidArgument, "duplicate archived round")
|
||||||
|
}
|
||||||
|
seen[round.Round] = struct{}{}
|
||||||
|
if err := round.Validate(); err != nil {
|
||||||
|
return contract.RunArchive{}, err
|
||||||
|
}
|
||||||
|
normalizedCounts, err := round.ErrorCounts.Normalized()
|
||||||
|
if err != nil {
|
||||||
|
return contract.RunArchive{}, err
|
||||||
|
}
|
||||||
|
round.ErrorCounts = normalizedCounts
|
||||||
|
round.StartedAt = utcTimePtr(round.StartedAt)
|
||||||
|
round.CompletedAt = utcTimePtr(round.CompletedAt)
|
||||||
|
}
|
||||||
|
sort.Slice(snapshot.Rounds, func(i, j int) bool { return snapshot.Rounds[i].Round < snapshot.Rounds[j].Round })
|
||||||
|
return snapshot, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeRunMeta(meta contract.RunMeta) contract.RunMeta {
|
||||||
|
meta.CreatedAt = meta.CreatedAt.UTC()
|
||||||
|
meta.ProducerCompletedAt = meta.ProducerCompletedAt.UTC()
|
||||||
|
meta.CompletedAt = meta.CompletedAt.UTC()
|
||||||
|
meta.StartedAt = utcTimePtr(meta.StartedAt)
|
||||||
|
meta.ScanCompletedAt = utcTimePtr(meta.ScanCompletedAt)
|
||||||
|
// Archive state describes the repository registration and is intentionally
|
||||||
|
// excluded from the file whose receipt populates that state.
|
||||||
|
meta.ArchiveStatus = ""
|
||||||
|
meta.ArchiveVersion = 0
|
||||||
|
meta.ArchivePath = ""
|
||||||
|
meta.ArchiveChecksum = ""
|
||||||
|
meta.ArchivedAt = nil
|
||||||
|
return meta
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeAction(action contract.ReplayAction) (contract.ReplayAction, error) {
|
||||||
|
if err := validateStorageID(action.ActionID); err != nil {
|
||||||
|
return contract.ReplayAction{}, err
|
||||||
|
}
|
||||||
|
if action.Status != contract.ActionReceiptPending {
|
||||||
|
return contract.ReplayAction{}, gerror.Wrap(contract.ErrInvalidArgument, "only receipt_pending replay actions can be archived")
|
||||||
|
}
|
||||||
|
action.Error = contract.BoundedErrorMessage(action.Error)
|
||||||
|
action.CreatedAt = action.CreatedAt.UTC()
|
||||||
|
action.UpdatedAt = action.UpdatedAt.UTC()
|
||||||
|
action.CompletedAt = nil
|
||||||
|
action.Receipt = nil
|
||||||
|
if err := action.Validate(); err != nil {
|
||||||
|
return contract.ReplayAction{}, err
|
||||||
|
}
|
||||||
|
return action, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func utcTimePtr(value *time.Time) *time.Time {
|
||||||
|
if value == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
utc := value.UTC()
|
||||||
|
return &utc
|
||||||
|
}
|
||||||
|
|
||||||
|
type contextWriter struct {
|
||||||
|
ctx context.Context
|
||||||
|
writer io.Writer
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w contextWriter) Write(p []byte) (int, error) {
|
||||||
|
if err := w.ctx.Err(); err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
return w.writer.Write(p)
|
||||||
|
}
|
||||||
@@ -0,0 +1,588 @@
|
|||||||
|
package archive
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"reflect"
|
||||||
|
"runtime"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gogf/gf/v2/errors/gerror"
|
||||||
|
|
||||||
|
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/fullsync/contract"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestPublishRunIsDurableDeterministicAndSummaryOnly(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
repository := newFakeRunRepository(testRunArchive("run-1"))
|
||||||
|
service := New(root, nil, nil, repository)
|
||||||
|
work := contract.ArchiveWork{RunID: "run-1", Version: "version-1"}
|
||||||
|
|
||||||
|
staleDir := root
|
||||||
|
if err := os.MkdirAll(staleDir, 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
stalePath := filepath.Join(staleDir, ".run-1-stale.tmp")
|
||||||
|
if err := os.WriteFile(stalePath, []byte("incomplete"), 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
receipt, err := service.PublishRun(context.Background(), work, repository.snapshot)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("PublishRun() error = %v", err)
|
||||||
|
}
|
||||||
|
if receipt.Path != "run-run-1.json" {
|
||||||
|
t.Fatalf("receipt path = %q", receipt.Path)
|
||||||
|
}
|
||||||
|
if err := receipt.Validate(); err != nil {
|
||||||
|
t.Fatalf("receipt validation failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
archivePath := filepath.Join(root, filepath.FromSlash(receipt.Path))
|
||||||
|
info, err := os.Stat(archivePath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if runtime.GOOS != "windows" {
|
||||||
|
if got := info.Mode().Perm(); got != 0o644 {
|
||||||
|
t.Fatalf("archive mode = %o, want 644", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
data, err := os.ReadFile(archivePath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
sum := sha256.Sum256(data)
|
||||||
|
if got := hex.EncodeToString(sum[:]); got != receipt.Checksum {
|
||||||
|
t.Fatalf("checksum = %q, want %q", receipt.Checksum, got)
|
||||||
|
}
|
||||||
|
for _, forbidden := range [][]byte{[]byte(`"archiveStatus"`), []byte(`"archivePath"`), []byte(`"taskId"`), []byte(`"card"`), []byte(`"message"`)} {
|
||||||
|
if bytes.Contains(data, forbidden) {
|
||||||
|
t.Fatalf("archive contains forbidden detail %s: %s", forbidden, data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var decoded contract.RunArchive
|
||||||
|
if err := json.Unmarshal(data, &decoded); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(decoded.Rounds) != 2 || decoded.Rounds[0].Round != 1 || decoded.Rounds[1].Round != 2 {
|
||||||
|
t.Fatalf("rounds were not deterministically sorted: %+v", decoded.Rounds)
|
||||||
|
}
|
||||||
|
if decoded.Rounds[1].ErrorCounts[contract.ErrorRateLimited] != 1 {
|
||||||
|
t.Fatalf("error categories were not normalized: %+v", decoded.Rounds[1].ErrorCounts)
|
||||||
|
}
|
||||||
|
|
||||||
|
repeated, err := service.PublishRun(context.Background(), work, repository.snapshot)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("repeated PublishRun() error = %v", err)
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(repeated, receipt) {
|
||||||
|
t.Fatalf("repeated receipt changed:\nfirst: %+v\nsecond: %+v", receipt, repeated)
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(stalePath); err != nil {
|
||||||
|
t.Fatalf("stale temp residue should not block publication: %v", err)
|
||||||
|
}
|
||||||
|
repository.mu.Lock()
|
||||||
|
defer repository.mu.Unlock()
|
||||||
|
if len(repository.receipts) != 2 || !reflect.DeepEqual(repository.receipts[0], repository.receipts[1]) {
|
||||||
|
t.Fatalf("registered receipts = %+v", repository.receipts)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestArchiveActionPublishesAndRegistersFencedReceipt(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
action := testReplayAction("action-1")
|
||||||
|
action.Error = strings.Repeat("x", contract.MaxErrorMessageBytes+50)
|
||||||
|
repository := newFakeReplayRepository(action)
|
||||||
|
service := NewServiceWithReplay(
|
||||||
|
Config{Root: root},
|
||||||
|
nil,
|
||||||
|
nil,
|
||||||
|
nil,
|
||||||
|
ReplayRepositories{Discovery: repository, Leases: repository, Progress: repository},
|
||||||
|
)
|
||||||
|
|
||||||
|
if err := service.ArchiveAction(context.Background(), action); err != nil {
|
||||||
|
t.Fatalf("ArchiveAction() error = %v", err)
|
||||||
|
}
|
||||||
|
repository.mu.Lock()
|
||||||
|
update := repository.update
|
||||||
|
released := repository.released
|
||||||
|
repository.mu.Unlock()
|
||||||
|
if update == nil || update.Status != contract.ActionCompleted || update.Receipt == nil || update.CompletedAt == nil {
|
||||||
|
t.Fatalf("registered update = %+v", update)
|
||||||
|
}
|
||||||
|
if update.ExpectedRevision != action.Revision || update.Receipt.ArchivedAt != *update.CompletedAt {
|
||||||
|
t.Fatalf("receipt registration lost fencing/time: %+v", update)
|
||||||
|
}
|
||||||
|
if update.Lease.Token != "lease-token-2" {
|
||||||
|
t.Fatalf("receipt registration used the unrenewed lease: %+v", update.Lease)
|
||||||
|
}
|
||||||
|
if released.Token != "lease-token-2" {
|
||||||
|
t.Fatalf("archive released the stale lease: %+v", released)
|
||||||
|
}
|
||||||
|
if update.Receipt.Path != "action-action-1.json" {
|
||||||
|
t.Fatalf("action receipt path = %q", update.Receipt.Path)
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := os.ReadFile(filepath.Join(root, filepath.FromSlash(update.Receipt.Path)))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, forbidden := range [][]byte{[]byte("idempotencyKey"), []byte("fingerprint"), []byte("leaseOwner"), []byte("leaseToken"), []byte("revision"), []byte(`"receipt"`), []byte("completedAt")} {
|
||||||
|
if bytes.Contains(data, forbidden) {
|
||||||
|
t.Fatalf("action archive contains internal field %q: %s", forbidden, data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var decoded contract.ReplayAction
|
||||||
|
if err := json.Unmarshal(data, &decoded); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if decoded.Status != contract.ActionReceiptPending || len(decoded.Error) != contract.MaxErrorMessageBytes {
|
||||||
|
t.Fatalf("archived action was not bounded receipt_pending summary: %+v", decoded)
|
||||||
|
}
|
||||||
|
repeated, err := service.PublishAction(context.Background(), action)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("repeated PublishAction() error = %v", err)
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(repeated, *update.Receipt) {
|
||||||
|
t.Fatalf("repeated action receipt changed: %+v != %+v", repeated, *update.Receipt)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPublishRunCleansTempAfterRenameFailure(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
repository := newFakeRunRepository(testRunArchive("run-rename"))
|
||||||
|
service := New(root, nil, nil, repository)
|
||||||
|
renameErr := gerror.New("injected rename failure")
|
||||||
|
service.replaceFile = func(string, string) error { return renameErr }
|
||||||
|
|
||||||
|
_, err := service.PublishRun(context.Background(), contract.ArchiveWork{RunID: "run-rename", Version: "v1"}, repository.snapshot)
|
||||||
|
if !errors.Is(err, renameErr) {
|
||||||
|
t.Fatalf("PublishRun() error = %v, want rename failure", err)
|
||||||
|
}
|
||||||
|
if matches, globErr := filepath.Glob(filepath.Join(root, ".run-rename-*.tmp")); globErr != nil || len(matches) != 0 {
|
||||||
|
t.Fatalf("temporary files after failure = %v, error = %v", matches, globErr)
|
||||||
|
}
|
||||||
|
if _, statErr := os.Stat(filepath.Join(root, "run-run-rename.json")); !errors.Is(statErr, os.ErrNotExist) {
|
||||||
|
t.Fatalf("final file exists after failed rename: %v", statErr)
|
||||||
|
}
|
||||||
|
if len(repository.receipts) != 0 {
|
||||||
|
t.Fatalf("registered receipt after failed rename: %+v", repository.receipts)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestChecksumFailureIsRecoveredByExactPathReplacement(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
repository := newFakeRunRepository(testRunArchive("run-checksum"))
|
||||||
|
service := New(root, nil, nil, repository)
|
||||||
|
service.afterReplace = func(path string) error {
|
||||||
|
return os.WriteFile(path, []byte("corrupt"), 0o600)
|
||||||
|
}
|
||||||
|
work := contract.ArchiveWork{RunID: "run-checksum", Version: "v1"}
|
||||||
|
|
||||||
|
if _, err := service.PublishRun(context.Background(), work, repository.snapshot); !errors.Is(err, errChecksumMismatch) {
|
||||||
|
t.Fatalf("PublishRun() error = %v, want checksum mismatch", err)
|
||||||
|
}
|
||||||
|
if len(repository.receipts) != 0 {
|
||||||
|
t.Fatalf("registered receipt after checksum failure: %+v", repository.receipts)
|
||||||
|
}
|
||||||
|
service.afterReplace = func(string) error { return nil }
|
||||||
|
receipt, err := service.PublishRun(context.Background(), work, repository.snapshot)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("recovery PublishRun() error = %v", err)
|
||||||
|
}
|
||||||
|
if receipt.Path != "run-run-checksum.json" {
|
||||||
|
t.Fatalf("replacement used different path: %q", receipt.Path)
|
||||||
|
}
|
||||||
|
data, err := os.ReadFile(filepath.Join(root, filepath.FromSlash(receipt.Path)))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if string(data) == "corrupt" {
|
||||||
|
t.Fatal("corrupt final file was not replaced")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRegistrationRetryReusesIdenticalReceipt(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
repository := newFakeRunRepository(testRunArchive("run-register"))
|
||||||
|
repository.registerErr = gerror.New("registration result was uncertain")
|
||||||
|
service := New(root, nil, nil, repository)
|
||||||
|
work := contract.ArchiveWork{RunID: "run-register", Version: "v1"}
|
||||||
|
|
||||||
|
if _, err := service.PublishRun(context.Background(), work, repository.snapshot); !errors.Is(err, repository.registerErr) {
|
||||||
|
t.Fatalf("first PublishRun() error = %v", err)
|
||||||
|
}
|
||||||
|
repository.mu.Lock()
|
||||||
|
firstReceipt := repository.receipts[0]
|
||||||
|
repository.registerErr = nil
|
||||||
|
repository.mu.Unlock()
|
||||||
|
|
||||||
|
receipt, err := service.PublishRun(context.Background(), work, repository.snapshot)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("retry PublishRun() error = %v", err)
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(receipt, firstReceipt) {
|
||||||
|
t.Fatalf("retry changed uncertain receipt:\nfirst: %+v\nretry: %+v", firstReceipt, receipt)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPublishRunRejectsDifferentValidContentAtSamePath(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
repository := newFakeRunRepository(testRunArchive("run-conflict"))
|
||||||
|
service := New(root, nil, nil, repository)
|
||||||
|
work := contract.ArchiveWork{RunID: "run-conflict", Version: "v1"}
|
||||||
|
|
||||||
|
first, err := service.PublishRun(context.Background(), work, repository.snapshot)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
changed := repository.snapshot
|
||||||
|
changed.Run.TriggerSource = "different-valid-summary"
|
||||||
|
if _, err = service.PublishRun(context.Background(), work, changed); !errors.Is(err, contract.ErrConflict) {
|
||||||
|
t.Fatalf("PublishRun() error = %v, want conflict", err)
|
||||||
|
}
|
||||||
|
data, err := os.ReadFile(filepath.Join(root, filepath.FromSlash(first.Path)))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
sum := sha256.Sum256(data)
|
||||||
|
if got := hex.EncodeToString(sum[:]); got != first.Checksum {
|
||||||
|
t.Fatalf("conflicting publication replaced the original file: %q != %q", got, first.Checksum)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestScannerRecoversFailedRunAfterRestart(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
repository := newFakeRunRepository(testRunArchive("run-restart"))
|
||||||
|
repository.load = func(context.Context, contract.ArchiveWork) (contract.RunArchive, error) {
|
||||||
|
return contract.RunArchive{}, gerror.New("source temporarily unavailable")
|
||||||
|
}
|
||||||
|
first := NewService(Config{Root: root, Interval: time.Hour, MaxAttempts: 1}, repository, repository, repository)
|
||||||
|
if err := first.Start(context.Background()); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
waitSignal(t, repository.failed, "initial archive failure")
|
||||||
|
if err := first.Stop(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
repository.mu.Lock()
|
||||||
|
repository.load = nil
|
||||||
|
repository.mu.Unlock()
|
||||||
|
second := NewService(Config{Root: root, Interval: time.Hour, MaxAttempts: 1}, repository, repository, repository)
|
||||||
|
if err := second.Start(context.Background()); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
waitSignal(t, repository.succeeded, "restart archive success")
|
||||||
|
if err := second.Stop(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(filepath.Join(root, "run-run-restart.json")); err != nil {
|
||||||
|
t.Fatalf("recovered archive file missing: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPeriodicScannerRetriesPendingRun(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
repository := newFakeRunRepository(testRunArchive("run-periodic"))
|
||||||
|
var calls atomic.Int32
|
||||||
|
repository.load = func(context.Context, contract.ArchiveWork) (contract.RunArchive, error) {
|
||||||
|
if calls.Add(1) == 1 {
|
||||||
|
return contract.RunArchive{}, gerror.New("first scan fails")
|
||||||
|
}
|
||||||
|
return repository.snapshot, nil
|
||||||
|
}
|
||||||
|
service := NewService(Config{Root: root, Interval: 10 * time.Millisecond, MaxAttempts: 1}, repository, repository, repository)
|
||||||
|
if err := service.Start(context.Background()); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
waitSignal(t, repository.succeeded, "periodic archive success")
|
||||||
|
if err := service.Stop(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if calls.Load() < 2 {
|
||||||
|
t.Fatalf("loader calls = %d, want at least 2", calls.Load())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStartupScannerArchivesReceiptPendingAction(t *testing.T) {
|
||||||
|
action := testReplayAction("action-startup")
|
||||||
|
repository := newFakeReplayRepository(action)
|
||||||
|
service := NewServiceWithReplay(
|
||||||
|
Config{Root: t.TempDir(), Interval: time.Hour, MaxAttempts: 1},
|
||||||
|
nil,
|
||||||
|
nil,
|
||||||
|
nil,
|
||||||
|
ReplayRepositories{Discovery: repository, Leases: repository, Progress: repository},
|
||||||
|
)
|
||||||
|
if err := service.Start(context.Background()); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
waitSignal(t, repository.completed, "startup replay archive success")
|
||||||
|
if err := service.Stop(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCancellationStopsBlockedScannerAndPublication(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
repository := newFakeRunRepository(testRunArchive("run-cancel"))
|
||||||
|
started := make(chan struct{})
|
||||||
|
repository.load = func(ctx context.Context, _ contract.ArchiveWork) (contract.RunArchive, error) {
|
||||||
|
close(started)
|
||||||
|
<-ctx.Done()
|
||||||
|
return contract.RunArchive{}, ctx.Err()
|
||||||
|
}
|
||||||
|
service := NewService(Config{Root: root, Interval: time.Hour, InitialBackoff: time.Hour}, repository, repository, repository)
|
||||||
|
if err := service.Start(context.Background()); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
waitSignal(t, started, "blocked loader start")
|
||||||
|
stopped := make(chan struct{})
|
||||||
|
go func() {
|
||||||
|
_ = service.Stop()
|
||||||
|
close(stopped)
|
||||||
|
}()
|
||||||
|
waitSignal(t, stopped, "scanner cancellation")
|
||||||
|
|
||||||
|
canceled, cancel := context.WithCancel(context.Background())
|
||||||
|
cancel()
|
||||||
|
_, err := service.PublishRun(canceled, contract.ArchiveWork{RunID: "run-cancel", Version: "v1"}, repository.snapshot)
|
||||||
|
if !errors.Is(err, context.Canceled) {
|
||||||
|
t.Fatalf("canceled PublishRun() error = %v", err)
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(filepath.Join(root, "run-run-cancel.json")); !errors.Is(err, os.ErrNotExist) {
|
||||||
|
t.Fatalf("canceled publication wrote final file: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCancellationAfterRenameStillVerifiesPublicationAndReleasesLease(t *testing.T) {
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
action := testReplayAction("action-cancel-after-rename")
|
||||||
|
repository := newFakeReplayRepository(action)
|
||||||
|
service := NewServiceWithReplay(
|
||||||
|
Config{Root: t.TempDir()},
|
||||||
|
nil,
|
||||||
|
nil,
|
||||||
|
nil,
|
||||||
|
ReplayRepositories{Discovery: repository, Leases: repository, Progress: repository},
|
||||||
|
)
|
||||||
|
service.afterReplace = func(string) error {
|
||||||
|
cancel()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := service.ArchiveAction(ctx, action); err != nil {
|
||||||
|
t.Fatalf("ArchiveAction() error = %v", err)
|
||||||
|
}
|
||||||
|
repository.mu.Lock()
|
||||||
|
releaseCtxErr := repository.releaseCtxErr
|
||||||
|
repository.mu.Unlock()
|
||||||
|
if releaseCtxErr != nil {
|
||||||
|
t.Fatalf("lease release inherited canceled context: %v", releaseCtxErr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestArchiveRejectsTraversalAndUnsafeFilesystemIDs(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
service := New(root, nil, nil, newFakeRunRepository(contract.RunArchive{}))
|
||||||
|
for _, id := range []string{"../escape", `..\escape`, "/absolute", `C:\escape`, "drive:name", ".", "..", "{hash}", "CON"} {
|
||||||
|
t.Run(strings.ReplaceAll(id, "/", "_"), func(t *testing.T) {
|
||||||
|
snapshot := testRunArchive(id)
|
||||||
|
_, err := service.PublishRun(context.Background(), contract.ArchiveWork{RunID: id, Version: "v1"}, snapshot)
|
||||||
|
if !errors.Is(err, contract.ErrInvalidArgument) {
|
||||||
|
t.Fatalf("PublishRun(%q) error = %v, want invalid argument", id, err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
entries, err := os.ReadDir(root)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(entries) != 0 {
|
||||||
|
t.Fatalf("unsafe IDs created archive entries: %+v", entries)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func testRunArchive(runID string) contract.RunArchive {
|
||||||
|
created := time.Date(2026, time.August, 31, 23, 0, 0, 0, time.FixedZone("test", 8*60*60))
|
||||||
|
completed := time.Date(2026, time.September, 20, 16, 0, 0, 0, time.FixedZone("test", 8*60*60))
|
||||||
|
archived := completed.Add(time.Minute)
|
||||||
|
return contract.RunArchive{
|
||||||
|
Version: contract.ArchiveSchemaVersion,
|
||||||
|
Run: contract.RunMeta{
|
||||||
|
RunID: runID, Status: contract.RunCompletedWithFailures, ProducerDone: true,
|
||||||
|
CurrentRound: 2, MaxRound: 2, EnqueuedCount: 3, SuccessCount: 2, FailedCount: 1, PendingFailureCount: 1,
|
||||||
|
CreatedAt: created, ProducerCompletedAt: completed, CompletedAt: completed,
|
||||||
|
ArchiveStatus: contract.ArchiveFailed, ArchiveVersion: 99, ArchivePath: "old.json",
|
||||||
|
ArchiveChecksum: strings.Repeat("a", 64), ArchivedAt: &archived,
|
||||||
|
},
|
||||||
|
Rounds: []contract.RoundStats{
|
||||||
|
{RunID: runID, Round: 2, EnqueuedCount: 1, FailedCount: 1, ErrorCounts: contract.ErrorCounts{"mobile_busy": 1}},
|
||||||
|
{RunID: runID, Round: 1, EnqueuedCount: 2, SuccessCount: 2, ErrorCounts: nil},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func testReplayAction(actionID string) contract.ReplayAction {
|
||||||
|
created := time.Date(2026, time.September, 20, 12, 0, 0, 0, time.FixedZone("test", 8*60*60))
|
||||||
|
return contract.ReplayAction{
|
||||||
|
ActionID: actionID, SourceRunID: "source-1", TargetRunID: "target-1", RequestedBy: "operator",
|
||||||
|
IdempotencyKey: "secret-key", Fingerprint: "secret-fingerprint", Status: contract.ActionReceiptPending,
|
||||||
|
ReplayCycle: 1, Progress: contract.ReplayProgress{DiscoveredCount: 2, EnqueuedCount: 2, CleanedCount: 2},
|
||||||
|
LeaseOwner: "engine", LeaseToken: "secret-token", LeaseUntil: created.Add(time.Hour), Revision: 7,
|
||||||
|
CreatedAt: created, UpdatedAt: created.Add(time.Minute),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type fakeRunRepository struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
work contract.ArchiveWork
|
||||||
|
snapshot contract.RunArchive
|
||||||
|
load func(context.Context, contract.ArchiveWork) (contract.RunArchive, error)
|
||||||
|
registered bool
|
||||||
|
receipts []contract.ArchiveReceipt
|
||||||
|
failures []string
|
||||||
|
registerErr error
|
||||||
|
succeeded chan struct{}
|
||||||
|
failed chan struct{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func newFakeRunRepository(snapshot contract.RunArchive) *fakeRunRepository {
|
||||||
|
return &fakeRunRepository{
|
||||||
|
work: contract.ArchiveWork{RunID: snapshot.Run.RunID, Version: "repository-version"},
|
||||||
|
snapshot: snapshot,
|
||||||
|
succeeded: make(chan struct{}, 8),
|
||||||
|
failed: make(chan struct{}, 8),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *fakeRunRepository) ListArchiveWork(ctx context.Context, _ contract.PageRequest) (contract.ArchiveWorkPage, error) {
|
||||||
|
if err := ctx.Err(); err != nil {
|
||||||
|
return contract.ArchiveWorkPage{}, err
|
||||||
|
}
|
||||||
|
r.mu.Lock()
|
||||||
|
defer r.mu.Unlock()
|
||||||
|
if r.registered {
|
||||||
|
return contract.ArchiveWorkPage{Items: []contract.ArchiveWork{}}, nil
|
||||||
|
}
|
||||||
|
return contract.ArchiveWorkPage{Items: []contract.ArchiveWork{r.work}}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *fakeRunRepository) LoadArchiveWork(ctx context.Context, work contract.ArchiveWork) (contract.RunArchive, error) {
|
||||||
|
r.mu.Lock()
|
||||||
|
load := r.load
|
||||||
|
snapshot := r.snapshot
|
||||||
|
r.mu.Unlock()
|
||||||
|
if load != nil {
|
||||||
|
return load(ctx, work)
|
||||||
|
}
|
||||||
|
return snapshot, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *fakeRunRepository) RegisterArchive(_ context.Context, _ contract.ArchiveWork, receipt contract.ArchiveReceipt) error {
|
||||||
|
r.mu.Lock()
|
||||||
|
r.receipts = append(r.receipts, receipt)
|
||||||
|
err := r.registerErr
|
||||||
|
if err == nil {
|
||||||
|
r.registered = true
|
||||||
|
}
|
||||||
|
r.mu.Unlock()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
notify(r.succeeded)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *fakeRunRepository) RegisterArchiveFailure(_ context.Context, _ contract.ArchiveWork, message string) error {
|
||||||
|
r.mu.Lock()
|
||||||
|
r.failures = append(r.failures, message)
|
||||||
|
r.mu.Unlock()
|
||||||
|
notify(r.failed)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type fakeReplayRepository struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
action contract.ReplayAction
|
||||||
|
update *contract.ReplayActionUpdate
|
||||||
|
released contract.ReplayLease
|
||||||
|
releaseCtxErr error
|
||||||
|
completed chan struct{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func newFakeReplayRepository(action contract.ReplayAction) *fakeReplayRepository {
|
||||||
|
return &fakeReplayRepository{action: action, completed: make(chan struct{}, 4)}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *fakeReplayRepository) ListReplayWork(ctx context.Context, _ contract.PageRequest) (contract.ReplayActionPage, error) {
|
||||||
|
if err := ctx.Err(); err != nil {
|
||||||
|
return contract.ReplayActionPage{}, err
|
||||||
|
}
|
||||||
|
r.mu.Lock()
|
||||||
|
defer r.mu.Unlock()
|
||||||
|
if r.update != nil {
|
||||||
|
return contract.ReplayActionPage{Items: []contract.ReplayAction{}}, nil
|
||||||
|
}
|
||||||
|
return contract.ReplayActionPage{Items: []contract.ReplayAction{r.action}}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *fakeReplayRepository) AcquireReplayLease(_ context.Context, actionID, owner string, ttl time.Duration) (contract.ReplayLease, error) {
|
||||||
|
return contract.ReplayLease{ActionID: actionID, Owner: owner, Token: "lease-token", Until: time.Now().Add(ttl)}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *fakeReplayRepository) RenewReplayLease(_ context.Context, lease contract.ReplayLease, ttl time.Duration) (contract.ReplayLease, error) {
|
||||||
|
lease.Token = "lease-token-2"
|
||||||
|
lease.Until = time.Now().Add(ttl)
|
||||||
|
return lease, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *fakeReplayRepository) ReleaseReplayLease(ctx context.Context, lease contract.ReplayLease) error {
|
||||||
|
r.mu.Lock()
|
||||||
|
r.released = lease
|
||||||
|
r.releaseCtxErr = ctx.Err()
|
||||||
|
r.mu.Unlock()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *fakeReplayRepository) UpdateReplayAction(_ context.Context, update contract.ReplayActionUpdate) (contract.ReplayAction, error) {
|
||||||
|
r.mu.Lock()
|
||||||
|
copyOfUpdate := update
|
||||||
|
r.update = ©OfUpdate
|
||||||
|
action := r.action
|
||||||
|
action.Status = update.Status
|
||||||
|
action.Receipt = update.Receipt
|
||||||
|
action.CompletedAt = update.CompletedAt
|
||||||
|
r.mu.Unlock()
|
||||||
|
notify(r.completed)
|
||||||
|
return action, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func notify(ch chan struct{}) {
|
||||||
|
select {
|
||||||
|
case ch <- struct{}{}:
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func waitSignal(t *testing.T, ch <-chan struct{}, operation string) {
|
||||||
|
t.Helper()
|
||||||
|
select {
|
||||||
|
case <-ch:
|
||||||
|
case <-time.After(3 * time.Second):
|
||||||
|
t.Fatalf("timed out waiting for %s", operation)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
//go:build !windows
|
||||||
|
|
||||||
|
package archive
|
||||||
|
|
||||||
|
import "os"
|
||||||
|
|
||||||
|
func atomicReplace(from, to string) error {
|
||||||
|
return os.Rename(from, to)
|
||||||
|
}
|
||||||
|
|
||||||
|
func syncDirectory(path string) error {
|
||||||
|
dir, err := os.Open(path)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := dir.Sync(); err != nil {
|
||||||
|
_ = dir.Close()
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return dir.Close()
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user