chore: 并入 iotlcm-worker-svr 历史到 worker-svr/(原提交 96f0f4e)

This commit is contained in:
MiFaZhan
2026-11-02 13:28:03 +08:00
422 changed files with 80993 additions and 0 deletions
+14
View File
@@ -0,0 +1,14 @@
# 统一使用 LF,避免跨平台协作时产生整文件 diff
* text=auto eol=lf
# 二进制文件不做行尾转换
*.png binary
*.jpg binary
*.gif binary
*.ico binary
*.woff binary
*.woff2 binary
*.ttf binary
*.pdf binary
*.exe binary
*.go linguist-language=Go
@@ -0,0 +1,64 @@
name: mirror-to-gitee
# 把本仓同步到 Gitee 企业仓 snowgoat/<同名仓库>。
# 触发:main 分支推送、v* tag 推送、手动;另有每日兜底重试。
on:
push:
branches:
- main
tags:
- 'v*'
schedule:
# Gitea Actions 的 cron 用 UTC:18:30 UTC = 次日 02:30 北京时间
- cron: '30 18 * * *'
workflow_dispatch:
concurrency:
group: mirror-${{ github.ref }}
cancel-in-progress: true
jobs:
mirror:
runs-on: host
steps:
- name: 同步到 Gitee
env:
SRC_TOKEN: ${{ secrets.SRC_GIT_TOKEN }}
DST_TOKEN: ${{ secrets.GITEE_TOKEN }}
DST_OWNER: ${{ secrets.GITEE_OWNER }}
DST_USER: ${{ secrets.GITEE_USER }}
REPO_NAME: ${{ github.event.repository.name }}
run: |
set -u
if [ -z "${REPO_NAME:-}" ]; then
echo "未取到仓库名,退出"; exit 1
fi
WORK=$(mktemp -d)
trap 'rm -rf "$WORK"' EXIT
echo "源:git.5iot.com/iotlcm-public/$REPO_NAME"
echo "目标:gitee.com/$DST_OWNER/$REPO_NAME"
git clone --mirror "https://jenkins:${SRC_TOKEN}@git.5iot.com/iotlcm-public/${REPO_NAME}.git" "$WORK/repo.git"
cd "$WORK/repo.git"
echo "本地 ref:$(git for-each-ref --format='%(refname:short)' | tr '\n' ' ')"
# Gitee 在推送密集时会返回 429 Pending queue overflowed / 408 Authorization server timeout,
# 属服务端限流,退避重试即可。
attempt=0
while [ "$attempt" -lt 5 ]; do
attempt=$((attempt + 1))
echo "----- 推送尝试 $attempt/5"
if git push --mirror "https://${DST_USER}:${DST_TOKEN}@gitee.com/${DST_OWNER}/${REPO_NAME}.git" 2>&1; then
echo "✅ 已同步到 Gitee"
exit 0
fi
wait_s=$((attempt * 30))
echo "推送失败,${wait_s}s 后重试"
sleep "$wait_s"
done
echo "❌ 连续 5 次推送失败,请检查 Gitee 状态或令牌"
exit 1
@@ -0,0 +1,80 @@
name: trigger-jenkins-oss
# 打 tag 时触发 Jenkins 构建开源镜像并推送到镜像仓库。
# 仅在 tag 推送时触发;main 分支的日常提交不触发。
on:
push:
tags:
- 'v*'
workflow_dispatch:
# 同一 ref 连续触发时取消旧任务,只跑最新的
concurrency:
group: ${{ github.ref }}
cancel-in-progress: true
jobs:
trigger:
runs-on: host
steps:
- name: 触发 Jenkins 镜像构建
env:
JU: ${{ secrets.JENKINS_USER }}
JT: ${{ secrets.JENKINS_API_TOKEN }}
JURL: ${{ secrets.JENKINS_URL }}
JOB_PATH: ${{ secrets.JENKINS_JOB_PATH }}
REPO_NAME: ${{ github.event.repository.name }}
TAG_NAME: ${{ github.ref_name }}
run: |
set -eu
# 只有 tag 推送才构建;手动触发时按 tag 取,没有就退出
case "${GITHUB_REF:-}" in
refs/tags/v*) ;;
*)
if [ "${GITHUB_EVENT_NAME:-}" != "workflow_dispatch" ]; then
echo "非 tag 推送,跳过:${GITHUB_REF:-unknown}"
exit 0
fi
;;
esac
if [ -z "${TAG_NAME:-}" ]; then
echo "未取到 tag 名,退出"
exit 1
fi
# job 在 Jenkins 文件夹下的路径(组织密钥 JENKINS_JOB_PATH),
# 例如 job/iotlcm-opensrc/job/iotlcm-oss-build。
# 用密钥而非硬编码,Jenkins 里移动 job 时不用改 11 个仓。
JP="${JOB_PATH:-job/iotlcm-oss-build}"
echo "仓库:$REPO_NAME 版本:$TAG_NAME job:$JP"
WORK=$(mktemp -d)
trap 'rm -rf "$WORK"' EXIT
# 取 CSRF crumb
curl -s -c "$WORK/cj" -u "$JU:$JT" "$JURL/crumbIssuer/api/json" -o "$WORK/crumb.json"
CR=$(python3 -c "import json;print(json.load(open('$WORK/crumb.json'))['crumb'])")
# 触发参数化任务:SERVICES=仓库名,VERSION=tag
code=$(curl -s -o "$WORK/out" -w '%{http_code}' -X POST -b "$WORK/cj" -u "$JU:$JT" \
-H "Jenkins-Crumb: $CR" \
--data-urlencode "SERVICES=$REPO_NAME" \
--data-urlencode "VERSION=$TAG_NAME" \
--data-urlencode "FORCE=true" \
"$JURL/$JP/buildWithParameters")
echo "Jenkins 触发 HTTP $code"
if [ "$code" != "201" ] && [ "$code" != "200" ] && [ "$code" != "302" ] && [ "$code" != "303" ]; then
echo "触发失败:"
cat "$WORK/out"
exit 1
fi
LINK="$JURL/$JP/"
echo "=========================================="
echo "Jenkins 构建:$LINK"
echo "=========================================="
echo "::notice title=Jenkins 镜像构建::[$LINK]($LINK)"
+23
View File
@@ -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
+201
View File
@@ -0,0 +1,201 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright [yyyy] [name of copyright owner]
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
+7
View File
@@ -0,0 +1,7 @@
ROOT_DIR = $(shell pwd)
NAMESPACE = "default"
DEPLOY_NAME = "iotlcm-worker-svr"
DOCKER_NAME = "iotlcm-worker-svr"
include ./hack/hack-cli.mk
include ./hack/hack.mk
+27
View File
@@ -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` 后,由本服务独立接管管理后端的持久化角色权限任务。默认关闭;启用前需先完成数据库迁移,并移除旧管理后端的执行入口。
+32
View File
@@ -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"]
+346
View File
@@ -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
+379
View File
@@ -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"
+70
View File
@@ -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
)
+172
View File
@@ -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=
+17
View File
@@ -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
+18
View File
@@ -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;
+75
View File
@@ -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
+157
View File
@@ -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
}
+574
View File
@@ -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")
}
}
+207
View File
@@ -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, &notSent) || !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, &notSent) || !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, &notSent) && 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)
}
}
+64
View File
@@ -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
+160
View File
@@ -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
}
+34
View File
@@ -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"
)
+11
View File
@@ -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, &current, &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, &current, &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)
}
}
+22
View File
@@ -0,0 +1,22 @@
// =================================================================================
// This file is auto-generated by the GoFrame CLI tool. You may modify it as needed.
// =================================================================================
package dao
import (
"git.5iot.com/iotlcm-public/iotlcm-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.
+22
View File
@@ -0,0 +1,22 @@
// =================================================================================
// This file is auto-generated by the GoFrame CLI tool. You may modify it as needed.
// =================================================================================
package dao
import (
"git.5iot.com/iotlcm-public/iotlcm-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)
}
+111
View File
@@ -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)
}
+22
View File
@@ -0,0 +1,22 @@
// =================================================================================
// This file is auto-generated by the GoFrame CLI tool. You may modify it as needed.
// =================================================================================
package dao
import (
"git.5iot.com/iotlcm-public/iotlcm-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.
+22
View File
@@ -0,0 +1,22 @@
// =================================================================================
// This file is auto-generated by the GoFrame CLI tool. You may modify it as needed.
// =================================================================================
package dao
import (
"git.5iot.com/iotlcm-public/iotlcm-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.
+22
View File
@@ -0,0 +1,22 @@
// =================================================================================
// This file is auto-generated by the GoFrame CLI tool. You may modify it as needed.
// =================================================================================
package dao
import (
"git.5iot.com/iotlcm-public/iotlcm-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.
+22
View File
@@ -0,0 +1,22 @@
// =================================================================================
// This file is auto-generated by the GoFrame CLI tool. You may modify it as needed.
// =================================================================================
package dao
import (
"git.5iot.com/iotlcm-public/iotlcm-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.
+22
View File
@@ -0,0 +1,22 @@
// =================================================================================
// This file is auto-generated by the GoFrame CLI tool. You may modify it as needed.
// =================================================================================
package dao
import (
"git.5iot.com/iotlcm-public/iotlcm-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.
+22
View File
@@ -0,0 +1,22 @@
// =================================================================================
// This file is auto-generated by the GoFrame CLI tool. You may modify it as needed.
// =================================================================================
package dao
import (
"git.5iot.com/iotlcm-public/iotlcm-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.
+22
View File
@@ -0,0 +1,22 @@
// =================================================================================
// This file is auto-generated by the GoFrame CLI tool. You may modify it as needed.
// =================================================================================
package dao
import (
"git.5iot.com/iotlcm-public/iotlcm-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.
+22
View File
@@ -0,0 +1,22 @@
// =================================================================================
// This file is auto-generated by the GoFrame CLI tool. You may modify it as needed.
// =================================================================================
package dao
import (
"git.5iot.com/iotlcm-public/iotlcm-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.
+22
View File
@@ -0,0 +1,22 @@
// =================================================================================
// This file is auto-generated by the GoFrame CLI tool. You may modify it as needed.
// =================================================================================
package dao
import (
"git.5iot.com/iotlcm-public/iotlcm-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.
+22
View File
@@ -0,0 +1,22 @@
// =================================================================================
// This file is auto-generated by the GoFrame CLI tool. You may modify it as needed.
// =================================================================================
package dao
import (
"git.5iot.com/iotlcm-public/iotlcm-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.
+22
View File
@@ -0,0 +1,22 @@
// =================================================================================
// This file is auto-generated by the GoFrame CLI tool. You may modify it as needed.
// =================================================================================
package dao
import (
"git.5iot.com/iotlcm-public/iotlcm-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.
+22
View File
@@ -0,0 +1,22 @@
// =================================================================================
// This file is auto-generated by the GoFrame CLI tool. You may modify it as needed.
// =================================================================================
package dao
import (
"git.5iot.com/iotlcm-public/iotlcm-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.
+22
View File
@@ -0,0 +1,22 @@
// =================================================================================
// This file is auto-generated by the GoFrame CLI tool. You may modify it as needed.
// =================================================================================
package dao
import (
"git.5iot.com/iotlcm-public/iotlcm-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 = &copyOfUpdate
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