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