chore: 初始开源发布
iotlcm-worker-svr:异步执行中枢(RabbitMQ 消费者 + 全量同步编排)。 - Go module:iotlcm-worker-svr → git.5iot.com/iotlcm-public/iotlcm-worker-svr(214 文件) - 凭据与内部标识脱敏:MySQL/Redis 凭据、私网与生产 IP、内网域名、 库名 xiujie_admin → iotlcm、哨兵名占位 - 159 处真实手机号 → 合法 11 位编造号(保留号段与后 4 位,维持互异, 不破坏「校验 MSISDN 格式」等测试断言) - 移除内部资产:CI 触发器、Jenkins 触发测试文档、预演门禁与镜像 digest 记录 - Dockerfile 改用公共基础镜像(golang:1.25-alpine → alpine:3.23), 移除写死的 git 凭据 - README 重写(架构/全量同步模型/配置/已知失败),补 Apache-2.0 LICENSE,LF 行尾 + .gitattributes 已知:3 个测试包在源仓同一提交上同样失败(client/upstream、 service/allocate、service/inbound),属既有缺陷,本仓未引入亦未修复。
This commit is contained in:
@@ -0,0 +1,13 @@
|
||||
# 统一使用 LF,避免跨平台协作时产生整文件 diff
|
||||
* text=auto eol=lf
|
||||
|
||||
# 二进制文件不做行尾转换
|
||||
*.png binary
|
||||
*.jpg binary
|
||||
*.gif binary
|
||||
*.ico binary
|
||||
*.woff binary
|
||||
*.woff2 binary
|
||||
*.ttf binary
|
||||
*.pdf binary
|
||||
*.exe binary
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
.buildpath
|
||||
.hgignore.swp
|
||||
.project
|
||||
.orig
|
||||
.swp
|
||||
.idea/
|
||||
.settings/
|
||||
.vscode/
|
||||
bin/
|
||||
**/.DS_Store
|
||||
gf
|
||||
main
|
||||
*.exe
|
||||
output/
|
||||
manifest/output/
|
||||
temp/
|
||||
temp.yaml
|
||||
*.log
|
||||
|
||||
# 本地配置文件(各人的数据库/上游地址不同,不纳入版本控制)
|
||||
config/config.yaml
|
||||
.keep-if-necessary
|
||||
.gitkeep
|
||||
@@ -0,0 +1,201 @@
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
1. Definitions.
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work
|
||||
(an example is provided in the Appendix below).
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||
or by an individual or Legal Entity authorized to submit on behalf of
|
||||
the copyright owner. For the purposes of this definition, "submitted"
|
||||
means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems,
|
||||
and issue tracking systems that are managed by, or on behalf of, the
|
||||
Licensor for the purpose of discussing and improving the Work, but
|
||||
excluding communication that is conspicuously marked or otherwise
|
||||
designated in writing by the copyright owner as "Not a Contribution."
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable
|
||||
by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||
or a Contribution incorporated within the Work constitutes direct
|
||||
or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
|
||||
(a) You must give any other recipients of the Work or
|
||||
Derivative Works a copy of this License; and
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding those notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that You distribute, alongside
|
||||
or as an addendum to the NOTICE text from the Work, provided
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for reasonable and customary use in describing the
|
||||
origin of the Work and reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may act only
|
||||
on Your own behalf and on Your sole responsibility, not on behalf
|
||||
of any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or additional liability.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
APPENDIX: How to apply the Apache License to your work.
|
||||
|
||||
To apply the Apache License to your work, attach the following
|
||||
boilerplate notice, with the fields enclosed by brackets "[]"
|
||||
replaced with your own identifying information. (Don't include
|
||||
the brackets!) The text should be enclosed in the appropriate
|
||||
comment syntax for the file format. We also recommend that a
|
||||
file or class name and description of purpose be included on the
|
||||
same "printed page" as the copyright notice for easier
|
||||
identification within third-party archives.
|
||||
|
||||
Copyright [yyyy] [name of copyright owner]
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
@@ -0,0 +1,7 @@
|
||||
ROOT_DIR = $(shell pwd)
|
||||
NAMESPACE = "default"
|
||||
DEPLOY_NAME = "iotlcm-worker-svr"
|
||||
DOCKER_NAME = "iotlcm-worker-svr"
|
||||
|
||||
include ./hack/hack-cli.mk
|
||||
include ./hack/hack.mk
|
||||
@@ -0,0 +1,120 @@
|
||||
# iotlcm-worker-svr
|
||||
|
||||
iotLCM 物联网卡全生命周期管理平台的**异步执行中枢** —— RabbitMQ 消费者 + 全量同步编排器。
|
||||
|
||||
平台的大部分耗时操作(批量开停复机、续费变更、流量/状态/IMEI 全量同步、达量断网、工单自动关闭)都在本服务里异步执行。
|
||||
|
||||
## 特性
|
||||
|
||||
- **多队列消费者**:注册业务消费者处理重试队列、批次执行、工单等
|
||||
- **全量同步编排**:卡状态 / 本月流量 / 日流量 / 月流量历史 / IMEI 五条全量同步链路,统一的 Producer(扫描 + 供料)→ Consumer(领取 + 执行)模型
|
||||
- **通道级并发与限速**:按通道模板配置 QPS,按通道隔离并发度
|
||||
- **多轮执行**:失败卡进入下一轮重试,单轮上限与轮次上限可配
|
||||
- **运行归档**:每轮执行产出终态 JSON 归档,供通知与排障使用
|
||||
- **可观测**:阶段耗时、库存水位、缺料观测等遥测写入归档
|
||||
|
||||
## 技术栈
|
||||
|
||||
- Go 1.25 + GoFrame v2
|
||||
- RabbitMQ(`github.com/rabbitmq/amqp091-go`)
|
||||
- MySQL(业务库)、Redis(任务队列与锁)、TDengine(用量时序)
|
||||
- 支付宝(余额充值)、excelize(Excel 导入导出)
|
||||
|
||||
## 快速开始
|
||||
|
||||
```bash
|
||||
go mod tidy
|
||||
go run .
|
||||
```
|
||||
|
||||
## 配置
|
||||
|
||||
配置文件在 `config/config_default.yaml`(默认配置)与 `config/config.yaml`(部署时覆盖)。**公开版里的凭据与地址全部是占位符**:
|
||||
|
||||
| 配置项 | 占位 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| `database.default.link` | `${DB_USER}:${DB_PASSWORD}@tcp(${DB_HOST}:3306)` | 业务库 |
|
||||
| `redis.default.address` | `${REDIS_ADDR}` | 任务队列 |
|
||||
| `redis.default.pass` | `${REDIS_PASSWORD}` | 缓存口令 |
|
||||
|
||||
上游凭据(AppKey / AppSecret)**不写在本仓**,存放在通道表 `t_sim_channels` 与通道模板表里,由平台写入。
|
||||
|
||||
## 目录结构
|
||||
|
||||
```text
|
||||
iotlcm-worker-svr/
|
||||
├── internal/
|
||||
│ ├── cmd/ # 启动装配
|
||||
│ ├── consumer/
|
||||
│ │ ├── rabbitmq/ # RabbitMQ 消费者(批次、重试、工单等)
|
||||
│ │ └── zset/ # 全量同步消费者(ZSet 任务队列)
|
||||
│ ├── producer/
|
||||
│ │ ├── simstatus/ # 卡状态全量同步
|
||||
│ │ ├── monthlyusage/ # 本月流量全量同步
|
||||
│ │ ├── dailyusage/ # 日流量全量同步
|
||||
│ │ ├── monthlyusagehistory/ # 月流量历史同步
|
||||
│ │ └── imei/ # IMEI 全量同步
|
||||
│ ├── fullsync/ # 全量同步运行时、归档、契约、HTTP 诊断
|
||||
│ ├── service/ # 各业务动作执行器
|
||||
│ ├── repository/ # 数据访问
|
||||
│ ├── queue/zset/ # 任务队列与锁
|
||||
│ └── library/rabbitmq/ # MQ 封装
|
||||
├── config/ # 配置
|
||||
├── build/ # Dockerfile 与镜像内配置
|
||||
├── manifest/deploy/kustomize/ # K8s 部署清单
|
||||
└── manifest/docker/ # compose 与启动脚本
|
||||
```
|
||||
|
||||
## 全量同步模型
|
||||
|
||||
```text
|
||||
Producer(扫描卡 → 按通道分片入队)
|
||||
│
|
||||
▼
|
||||
ZSet 任务队列(Redis)
|
||||
│
|
||||
▼
|
||||
Consumer(按通道 QPS 领取 → 调上游 → 写库 → 结算)
|
||||
│
|
||||
▼
|
||||
运行归档(终态 JSON,供 Bot 通知与排障)
|
||||
```
|
||||
|
||||
关键设计:
|
||||
|
||||
- **通道隔离**:每个通道独立并发与 QPS,一个慢通道不拖垮其他通道
|
||||
- **供料与消费解耦**:Producer 只负责入队,Consumer 按节奏领取,避免上游被瞬时打满
|
||||
- **多轮重试**:单卡失败进入下一轮,轮次上限与单轮上限均可配
|
||||
- **归档去重**:按 `runId` 标识一次执行,Bot 据此去重推送
|
||||
|
||||
## 容器化构建
|
||||
|
||||
```bash
|
||||
docker build -f build/Dockerfile -t iotlcm-worker-svr:latest .
|
||||
```
|
||||
|
||||
镜像内不含任何凭据;`config/config.yaml` 以只读方式挂载覆盖。
|
||||
|
||||
## 测试
|
||||
|
||||
```bash
|
||||
go test ./...
|
||||
```
|
||||
|
||||
测试使用本地替身,不访问真实运营商接口。
|
||||
|
||||
> **已知失败(与上游主分支一致,非本仓引入)**:
|
||||
> `TestValidateUpstreamBasicInfoAcceptsMatchingIdentifiers`、
|
||||
> `TestConfiguredInboundBasicInfoClientUsesDedicatedEndpoint`、
|
||||
> `TestAllocateProcessingRetryAfterHasLargeBatchDefault`。
|
||||
> 这三项在源仓库同一提交上同样失败,属既有缺陷。
|
||||
|
||||
## 相关项目
|
||||
|
||||
- [iotlcm-bot](https://git.5iot.com/iotlcm-public/iotlcm-bot) —— 读取归档 JSON 并推送飞书卡片
|
||||
- [iotlcm-upstream-chan](https://git.5iot.com/iotlcm-public/iotlcm-upstream-chan) —— 运营商上游适配
|
||||
- [iotlcm-admin-svr](https://git.5iot.com/iotlcm-public/iotlcm-admin-svr) —— 管理后台(发起批次与定时任务)
|
||||
|
||||
## License
|
||||
|
||||
[Apache License 2.0](./LICENSE)
|
||||
@@ -0,0 +1,34 @@
|
||||
# 卡状态同步 Run 计数与收尾
|
||||
|
||||
状态同步此前创建了 Run 元数据,但使用渠道级 `EnqueueBatch/Enqueue` 和 `AckBatch`,这些操作不会维护 Run 计数,也不会调用 Run 完成判定。任务实际处理结束后,元数据因此可能长期停在 `running`,成功、失败计数均为零。
|
||||
|
||||
## 新任务行为
|
||||
|
||||
全量和单卡状态同步均改用 Run 队列。`RunQueueEnabled` 控制 Run 消费,`RoundRetryEnabled` 独立控制多轮重试;状态同步固定单轮,流量同步继续保留原来的多轮策略。
|
||||
|
||||
- 入队按稳定 Task ID 去重;`activeCount` 包含排队及租约中的任务,背压不能再次加上 `inflightCount`。
|
||||
- 成功结果先写 MySQL,再凭租约令牌原子确认、累加成功数。永久业务失败记入失败数及失败记录;数据库失败和可恢复网络失败保留任务,等待租约恢复。
|
||||
- 电信渠道受限时,先确认同批已落库的成功结果,再凭触发任务的有效租约封禁该 Run 的渠道。其余已入队任务记为 `channel_blocked` 终态失败,保留逐卡记录;尚未入队而被拒绝的任务不计入已处理数量。此计数规则仅用于状态同步。
|
||||
- 只有 `producerDone=1`,且 `activeCount/inflightCount/nextRoundCount` 均为零,才写入 `completedAt`。无失败为 `completed`,存在失败为 `completed_with_failures`。周期性检查覆盖空 Run 和消费先结束的情形。
|
||||
- 元数据、任务状态和失败记录在收尾后保留 30 天。`sim_status_queue_round_batch` 是批次尝试日志,不能直接按其 `failed` 汇总终态失败;最终 Run 计数以元数据及 `sim_status_queue_run` 事件为准。
|
||||
|
||||
## 配置和后续上线步骤
|
||||
|
||||
使用 `simStatusQueueSync.redisKeyPrefix`,默认 `sim-status:`。生产者和消费者锁仍使用原键。
|
||||
|
||||
本次代码交付不部署、不修改旧 Run。后续上线应遵循以下顺序:
|
||||
|
||||
1. 暂停状态同步的新触发入口(定时、手动及单卡),让旧 Worker 完成现有工作。核对两个状态 MQ 队列的 ready/unacked 为零、无在途 producer,以及旧命名空间所有渠道 ZSet 与租约为零。历史 `status=running` 本身不能作为仍有业务任务的判据。
|
||||
2. 使用新代码和 `redisKeyPrefix: "sim-status:"` 启动,保持锁键配置不变;恢复触发入口。
|
||||
3. 先运行小规模/单卡同步,再核验一次全量同步:MySQL 状态及同步时间符合成功结果,Run 计数持续变化,最终唯一入队数量(任务状态 Hash 的 HLEN)等于成功数加失败数,活动计数归零、终态与完成时间正确。查询 `sim-status:{<runId>}:meta`。
|
||||
4. 如需回滚,先暂停新触发并等待 v2 队列处理结束,再停新 Worker、恢复旧版本和旧配置;有未完成 v2 任务时应保留可消费 v2 的版本继续处理,不直接切回旧版遗留任务。
|
||||
|
||||
## 自动验证
|
||||
|
||||
```text
|
||||
go test ./...
|
||||
```
|
||||
|
||||
Redis 集成测试要求 PATH 存在 `redis-server`,或设置 `REDIS_SERVER_BINARY` 为可执行文件路径。每个测试启动独立的回环地址 Redis,使用临时目录且关闭持久化,退出后清理进程;不会读取应用 Redis 配置。缺少二进制时会明确 skip,验收时应确认这些测试实际执行。
|
||||
|
||||
覆盖成功/失败混合、空 Run、生产消费交错、重复入队/确认、租约失效及恢复、数据库和网络失败、渠道封禁及逐卡失败记录、多 Run 并行、单卡入口和背压;另回归流量五轮重试与原渠道封禁语义。
|
||||
@@ -0,0 +1,33 @@
|
||||
FROM golang:1.25-alpine AS builder
|
||||
|
||||
LABEL stage=gobuilder
|
||||
|
||||
ENV GOPROXY=https://goproxy.cn,direct
|
||||
ENV TZ=Asia/Shanghai
|
||||
|
||||
WORKDIR /build
|
||||
|
||||
COPY go.mod go.sum ./
|
||||
|
||||
RUN go mod download
|
||||
|
||||
COPY . .
|
||||
|
||||
ARG BuildVersion
|
||||
ARG BuildTime
|
||||
ARG CommitID
|
||||
|
||||
RUN go build -v -o /build/iotlcm-worker-svr -ldflags "-X main.BuildVersion=${BuildVersion} -X main.CommitID=${CommitID} -X main.BuildTime=${BuildTime}"
|
||||
|
||||
FROM alpine:3.23
|
||||
|
||||
RUN apk add --no-cache ca-certificates tzdata
|
||||
|
||||
COPY --from=builder /usr/share/zoneinfo/Asia/Shanghai /usr/share/zoneinfo/Asia/Shanghai
|
||||
ENV TZ=Asia/Shanghai
|
||||
|
||||
WORKDIR /app
|
||||
COPY --from=builder /build/iotlcm-worker-svr /app/iotlcm-worker-svr
|
||||
COPY --from=builder /build/build/build_config.yaml /app/config/config.yaml
|
||||
|
||||
CMD ["./iotlcm-worker-svr"]
|
||||
@@ -0,0 +1,346 @@
|
||||
# config.yaml 配置文件
|
||||
# @Link https://github.com/xiujiecn/xiu-admin
|
||||
# @Copyright Copyright (c) 2025 LiXiujie
|
||||
# @Author Lxj <li@xiujie.cn>
|
||||
# @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: "mysql:${DB_USER}:${DB_PASSWORD}@tcp(${DB_HOST}:3306)/db_iotlcm?charset=utf8mb4&parseTime=True&loc=Asia%2FShanghai"
|
||||
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: "sqlite::@file(:memory:)"
|
||||
# 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: 192.0.2.29:6379
|
||||
db: 0
|
||||
pass: "${REDIS_PASSWORD}"
|
||||
minIdle: 5 #最小空闲连接数
|
||||
maxIdle: 30 #允许闲置的最大连接数
|
||||
maxActive: 5000 #最大活跃连接数
|
||||
idleTimeout: "60s" #连接最大空闲时间,使用时间字符串例如30s/2m/2d
|
||||
ClientName: "SystemCache"
|
||||
|
||||
|
||||
alipay:
|
||||
appId: "${MOBILE_APP_ID}"
|
||||
privateKey: "MIIEowIBAAKCAQEAg+4BpLO2Fw5GMbrihnugyQqiLBp2RhSbSsvjNyzaG10u900N1DcVlh8HNdcus5AGjfbiUwMQwv7Os+NnFrEZA3CtWLXH+uczHIpcMqJJhoVgA//4gf2TX9K4OSQ1pgJeAhTS9WWMEB7DQLb7A6xGjvBdGNbPAzucGhUmtG1tgZSVOcpWqN2h4dQxkZhfR+6m3AqWAPahpQXsNINHewetQ4mIG+b3+z/OknEll0RDRmw7OihOJ24B51Phy7lewl160LgLpLffyaQUUXcFdcNo+HvWqtIT0mH8WWHAgqfN/WVkMz/WVjE0rzbIVgmpQc7+hMbrcafTmeq0wdnVXrKS6QIDAQABAoIBAEagy1ZuMyRiqueCojTWyHv4altsCzd3Xq+LOWfXZihSGYuhP68FklPc5pJHUaQljazAp+dC891AZk5dhlnak4aI8vRT1OyYi39S/wrIYM5YZs+dYF/O7e/VsnQ800+9YrCAj3N6WqJiparmirkrqFZrjZt3nDmh1u8Ltb3P+1jr90MBEdS+BXaEQP/KGxFiKZyoWFn9B4l0aPFkPExF+hgtRpP3gxFcyljQKGVmSUIkT6V08F1oRm72NQOhcizCh9lX+5HYLTkUVQVg6FHwdZs/KeJcZu6Y829pNSNeJmPbR3VPUpTbvwb0qeEoc42T3H/C0xXUeTYizUpHmhJJqnUCgYEA3Y7Xz+MUk98zRJnlr2BDUc+Qnhv3wmSA5LCcRbxTNNBp4TXjnZknY6Ms9D+nJupL4nw6QSYkO6O+tAEmU6gaSDEHKI1uKqU0zOdbGIHNkMqka3rinQcMJNNRZ8T1FLCPFna7S1FNlxulZgWxjqxP/KvWOEpCWLCLzwZNk8fEd+MCgYEAmHBNbaMmT4WvLevY/OMv/5e5csnDFIcsNJKvFLZZMBLuKT/26YN2ryVUivY+TTyHL5zk4VybeauTdQHIvX6sNJoh2Ii6vthKfSufL4rkMob8PHiG33iHBx3VxAwiVXfu3JHVnbCPcXTOzsdRzT17X3rODK9t8319qfVjd0Gxi8MCgYEAqgNc0+ea+MXD35aAzA0AW5z9inZHruHUjqh/onubFeH//ZOOsHa/5RNoAZ1+tLwBszBEu417LFDAm/xt3rAWSuNkbNfqe8r+M2YtXybsCXmWpetMTdcEmJWX2BspPdEmW8SMgIVJFvVd/GXOFH9hebCa1+MT+l6vShO6H/ahaqUCgYAzon6nr4bPkvHWtTfH2VCt2//mIO+iaXMQz8rcUjP9s7vWXyNMz8oI47UyaNyLnwAPoa5po8vKN9L35EXR4jGEk9z5Er9m267HnVoLaZmIf+66sPzGf3YFdBavlIiguMJBFmjsRB0iijVvF1bfFX9PxTI8W8P7oTtKsaMihy8TrwKBgCgjA8DL8HJbRwZ+V/KWOh8rzO5cBUUH3ThIj6u7Wn4tMlscfi+KFKTXghjSORfIVx8v9v2aNZKwGZ547AOPRXhDF09cLAcCfl7uOrwsftziIeBQnrqtpIqcVTgRwGMIMBJEtQEks5N/E/d+dSL1YQKC76mVC8/RMf52WYKH1QCa"
|
||||
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
|
||||
# 示例:amqp://guest:guest@127.0.0.1:5672/
|
||||
# dsn: "amqp://admin:admin@127.0.0.1:5672/"
|
||||
dsn: "amqp://root:sdyz2026@192.0.2.29:5672/"
|
||||
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: "192.0.2.29: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: "root:taosdata@http(127.0.0.1:6041)/"
|
||||
type: "taosWS" #websocket连接方式,端口是6041
|
||||
# dsn: "root:taosdata@ws(127.0.0.1:6041)/"
|
||||
dsn: "root:sdyz2026__@ws(192.0.2.29:6041)/"
|
||||
dbName: "iotlcm"
|
||||
maxOpenConns: 100
|
||||
maxIdleConns: 10
|
||||
|
||||
# 上游服务调用
|
||||
upstream:
|
||||
proxy:
|
||||
address: "iotlcm.example.com:8510"
|
||||
# address: "localhost:8510"
|
||||
timeout: 10s
|
||||
gateway:
|
||||
address: "iotlcm.example.com:8110"
|
||||
timeout: 10s
|
||||
@@ -0,0 +1,376 @@
|
||||
# config_default.yaml 配置文件
|
||||
# @Link https://github.com/xiujiecn/xiu-admin
|
||||
# @Copyright Copyright (c) 2025 LiXiujie
|
||||
# @Author Lxj <li@xiujie.cn>
|
||||
# @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: ""
|
||||
privateKey: ""
|
||||
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: "mysql:${DB_USER}:${DB_PASSWORD}@tcp(${DB_HOST}:3306)/iotlcm?charset=utf8mb4&parseTime=True&loc=Asia%2FShanghai"
|
||||
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: "${REDIS_SENTINEL_MASTER}" #哨兵模式主节点名称
|
||||
address: 192.0.2.200:6379
|
||||
db: 0
|
||||
pass: "${REDIS_PASSWORD}"
|
||||
minIdle: 5 #最小空闲连接数
|
||||
maxIdle: 30 #允许闲置的最大连接数
|
||||
maxActive: 5000 #最大活跃连接数
|
||||
idleTimeout: "60s" #连接最大空闲时间,使用时间字符串例如30s/2m/2d
|
||||
ClientName: "SystemCache"
|
||||
@@ -0,0 +1,70 @@
|
||||
module git.5iot.com/iotlcm-public/iotlcm-worker-svr
|
||||
|
||||
go 1.25.5
|
||||
|
||||
require (
|
||||
github.com/gogf/gf/contrib/drivers/mysql/v2 v2.10.0
|
||||
github.com/gogf/gf/contrib/drivers/sqlite/v2 v2.10.0
|
||||
github.com/gogf/gf/contrib/nosql/redis/v2 v2.10.0
|
||||
github.com/gogf/gf/v2 v2.10.0
|
||||
github.com/rabbitmq/amqp091-go v1.10.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/google/uuid v1.6.0 // indirect
|
||||
github.com/gorilla/websocket v1.5.3 // indirect
|
||||
github.com/grokify/html-strip-tags-go v0.1.0 // indirect
|
||||
github.com/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/redis/go-redis/v9 v9.18.0 // indirect
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||
github.com/richardlehane/mscfb v1.0.4 // indirect
|
||||
github.com/richardlehane/msoleps v1.0.4 // indirect
|
||||
github.com/rivo/uniseg v0.2.0 // indirect
|
||||
github.com/smartwalle/ncrypto v1.0.4 // indirect
|
||||
github.com/smartwalle/ngx v1.1.0 // indirect
|
||||
github.com/smartwalle/nsign v1.0.9 // indirect
|
||||
github.com/xuri/efp v0.0.0-20240408161823-9ad904a10d6d // indirect
|
||||
github.com/xuri/nfp v0.0.0-20240318013403-ab9948c2c4a7 // indirect
|
||||
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
|
||||
go.opentelemetry.io/otel v1.39.0 // indirect
|
||||
go.opentelemetry.io/otel/metric v1.39.0 // indirect
|
||||
go.opentelemetry.io/otel/sdk v1.39.0 // indirect
|
||||
go.opentelemetry.io/otel/trace v1.39.0 // indirect
|
||||
go.uber.org/atomic v1.11.0 // indirect
|
||||
golang.org/x/crypto v0.46.0 // indirect
|
||||
golang.org/x/net v0.48.0 // indirect
|
||||
golang.org/x/sys v0.39.0 // indirect
|
||||
golang.org/x/text v0.32.0 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
modernc.org/libc v1.22.5 // indirect
|
||||
modernc.org/mathutil v1.5.0 // indirect
|
||||
modernc.org/memory v1.5.0 // indirect
|
||||
modernc.org/sqlite v1.23.1 // indirect
|
||||
)
|
||||
@@ -0,0 +1,172 @@
|
||||
github.com/BurntSushi/toml v1.5.0 h1:W5quZX/G/csjUnuI8SUYlsHs9M38FC7znL0lIO+DvMg=
|
||||
github.com/BurntSushi/toml v1.5.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
|
||||
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
|
||||
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
|
||||
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
|
||||
github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0=
|
||||
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/clbanning/mxj/v2 v2.7.0 h1:WA/La7UGCanFe5NpHF0Q3DNtnCsVoxbPKuyBNHWRyME=
|
||||
github.com/clbanning/mxj/v2 v2.7.0/go.mod h1:hNiWqW14h+kc+MdF9C6/YoRfjEJoR3ou6tn/Qo+ve2s=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
|
||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
|
||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/emirpasic/gods/v2 v2.0.0-alpha h1:dwFlh8pBg1VMOXWGipNMRt8v96dKAIvBehtCt6OtunU=
|
||||
github.com/emirpasic/gods/v2 v2.0.0-alpha/go.mod h1:W0y4M2dtBB9U5z3YlghmpuUhiaZT2h6yoeE+C1sCp6A=
|
||||
github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM=
|
||||
github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU=
|
||||
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
|
||||
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
|
||||
github.com/glebarez/go-sqlite v1.21.2 h1:3a6LFC4sKahUunAmynQKLZceZCOzUthkRkEAl9gAXWo=
|
||||
github.com/glebarez/go-sqlite v1.21.2/go.mod h1:sfxdZyhQjTM2Wry3gVYWaW072Ri1WMdWJi0k6+3382k=
|
||||
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
|
||||
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
|
||||
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
|
||||
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
|
||||
github.com/go-sql-driver/mysql v1.7.1 h1:lUIinVbN1DY0xBg0eMOzmmtGoHwWBbvnWubQUrtU8EI=
|
||||
github.com/go-sql-driver/mysql v1.7.1/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI=
|
||||
github.com/gogf/gf/contrib/drivers/mysql/v2 v2.10.0 h1:UvqxwinkelKxwdwnKUfdy51/ls4RL7MCeJqAZOVAy0I=
|
||||
github.com/gogf/gf/contrib/drivers/mysql/v2 v2.10.0/go.mod h1:6v7oGBF9wv59WERJIOJxXmLhkUcxwON3tPYW3AZ7wbY=
|
||||
github.com/gogf/gf/contrib/drivers/sqlite/v2 v2.10.0 h1:OyAH7Ls2c9Un7CJiAq7G6eY1jWIICRkN8C5SyM94rnY=
|
||||
github.com/gogf/gf/contrib/drivers/sqlite/v2 v2.10.0/go.mod h1:fwhAMG0qZpeHbbP2JE78rJRfV7eBbu9jXkxTMM1lwyo=
|
||||
github.com/gogf/gf/contrib/nosql/redis/v2 v2.10.0 h1:N/F9CuDdUZLoM1nVRqrDE/33pDZuhVxpNY4wYdeIaBs=
|
||||
github.com/gogf/gf/contrib/nosql/redis/v2 v2.10.0/go.mod h1:x6uoJGfZOtirIRQls8xUlYzC6f7T/eULPUa9er368X0=
|
||||
github.com/gogf/gf/v2 v2.10.0 h1:rzDROlyqGMe/eM6dCalSR8dZOuMIdLhmxKSH1DGhbFs=
|
||||
github.com/gogf/gf/v2 v2.10.0/go.mod h1:Svl1N+E8G/QshU2DUbh/3J/AJauqCgUnxHurXWR4Qx0=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26 h1:Xim43kblpZXfIBQsbuBVKCudVG457BR2GZFIz3uw3hQ=
|
||||
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26/go.mod h1:dDKJzRmX4S37WGHujM7tX//fmj1uioxKzKxz3lo4HJo=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
||||
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||
github.com/grokify/html-strip-tags-go v0.1.0 h1:03UrQLjAny8xci+R+qjCce/MYnpNXCtgzltlQbOBae4=
|
||||
github.com/grokify/html-strip-tags-go v0.1.0/go.mod h1:ZdzgfHEzAfz9X6Xe5eBLVblWIxXfYSQ40S/VKrAOGpc=
|
||||
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
||||
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
||||
github.com/klauspost/cpuid/v2 v2.2.3 h1:sxCkb+qR91z4vsqw4vGGZlDgPz3G7gjaLyK3V8y70BU=
|
||||
github.com/klauspost/cpuid/v2 v2.2.3/go.mod h1:RVVoqg1df56z8g3pUjL/3lE5UfnlrJX8tyFgg4nqhuY=
|
||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/magiconair/properties v1.8.10 h1:s31yESBquKXCV9a/ScB3ESkOjUYYv+X0rg8SYxI99mE=
|
||||
github.com/magiconair/properties v1.8.10/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0=
|
||||
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
|
||||
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
|
||||
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
|
||||
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
|
||||
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
|
||||
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
||||
github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 h1:RWengNIwukTxcDr9M+97sNutRR1RKhG96O6jWumTTnw=
|
||||
github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826/go.mod h1:TaXosZuwdSHYgviHp1DAtfrULt5eUgsSMsZf+YrPgl8=
|
||||
github.com/olekukonko/errors v1.1.0 h1:RNuGIh15QdDenh+hNvKrJkmxxjV4hcS50Db478Ou5sM=
|
||||
github.com/olekukonko/errors v1.1.0/go.mod h1:ppzxA5jBKcO1vIpCXQ9ZqgDh8iwODz6OXIGKU8r5m4Y=
|
||||
github.com/olekukonko/ll v0.0.9 h1:Y+1YqDfVkqMWuEQMclsF9HUR5+a82+dxJuL1HHSRpxI=
|
||||
github.com/olekukonko/ll v0.0.9/go.mod h1:En+sEW0JNETl26+K8eZ6/W4UQ7CYSrrgg/EdIYT2H8g=
|
||||
github.com/olekukonko/tablewriter v1.1.0 h1:N0LHrshF4T39KvI96fn6GT8HEjXRXYNDrDjKFDB7RIY=
|
||||
github.com/olekukonko/tablewriter v1.1.0/go.mod h1:5c+EBPeSqvXnLLgkm9isDdzR3wjfBkHR9Nhfp3NWrzo=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/rabbitmq/amqp091-go v1.10.0 h1:STpn5XsHlHGcecLmMFCtg7mqq0RnD+zFr4uzukfVhBw=
|
||||
github.com/rabbitmq/amqp091-go v1.10.0/go.mod h1:Hy4jKW5kQART1u+JkDTF9YYOQUHXqMuhrgxOEeS7G4o=
|
||||
github.com/redis/go-redis/v9 v9.18.0 h1:pMkxYPkEbMPwRdenAzUNyFNrDgHx9U+DrBabWNfSRQs=
|
||||
github.com/redis/go-redis/v9 v9.18.0/go.mod h1:k3ufPphLU5YXwNTUcCRXGxUoF1fqxnhFQmscfkCoDA0=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
github.com/richardlehane/mscfb v1.0.4 h1:WULscsljNPConisD5hR0+OyZjwK46Pfyr6mPu5ZawpM=
|
||||
github.com/richardlehane/mscfb v1.0.4/go.mod h1:YzVpcZg9czvAuhk9T+a3avCpcFPMUWm7gK3DypaEsUk=
|
||||
github.com/richardlehane/msoleps v1.0.1/go.mod h1:BWev5JBpU9Ko2WAgmZEuiz4/u3ZYTKbjLycmwiWUfWg=
|
||||
github.com/richardlehane/msoleps v1.0.4 h1:WuESlvhX3gH2IHcd8UqyCuFY5yiq/GR/yqaSM/9/g00=
|
||||
github.com/richardlehane/msoleps v1.0.4/go.mod h1:BWev5JBpU9Ko2WAgmZEuiz4/u3ZYTKbjLycmwiWUfWg=
|
||||
github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY=
|
||||
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
||||
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
|
||||
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
|
||||
github.com/smartwalle/alipay/v3 v3.2.29 h1:roGFqlml8hDa//0TpFmlyxZhndTYs7rbYLu/HlNFNJo=
|
||||
github.com/smartwalle/alipay/v3 v3.2.29/go.mod h1:XarBLuAkwK3ah7mYjVtghRu+ysxzlex9sRkgqNMzMRU=
|
||||
github.com/smartwalle/ncrypto v1.0.4 h1:P2rqQxDepJwgeO5ShoC+wGcK2wNJDmcdBOWAksuIgx8=
|
||||
github.com/smartwalle/ncrypto v1.0.4/go.mod h1:Dwlp6sfeNaPMnOxMNayMTacvC5JGEVln3CVdiVDgbBk=
|
||||
github.com/smartwalle/ngx v1.1.0 h1:q8nANgWSPRGeI/u+ixBoA4mf68DrUq6vZ+n9L5UKv9I=
|
||||
github.com/smartwalle/ngx v1.1.0/go.mod h1:mx/nz2Pk5j+RBs7t6u6k22MPiBG/8CtOMpCnALIG8Y0=
|
||||
github.com/smartwalle/nsign v1.0.9 h1:8poAgG7zBd8HkZy9RQDwasC6XZvJpDGQWSjzL2FZL6E=
|
||||
github.com/smartwalle/nsign v1.0.9/go.mod h1:eY6I4CJlyNdVMP+t6z1H6Jpd4m5/V+8xi44ufSTxXgc=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||
github.com/stretchr/objx v0.5.0 h1:1zr/of2m5FGMsad5YfcqgdqdWrIhu+EBEJRhR1U7z/c=
|
||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||
github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/taosdata/driver-go/v3 v3.8.0 h1:qY1xFidspISuuZc0TAbeJWMtAdKJOSYQOXfepkUK6fI=
|
||||
github.com/taosdata/driver-go/v3 v3.8.0/go.mod h1:S6OGOinfR0xxxaMGsvBi9cLkYxEIW1p6qqr8QJATTlg=
|
||||
github.com/xuri/efp v0.0.0-20240408161823-9ad904a10d6d h1:llb0neMWDQe87IzJLS4Ci7psK/lVsjIS2otl+1WyRyY=
|
||||
github.com/xuri/efp v0.0.0-20240408161823-9ad904a10d6d/go.mod h1:ybY/Jr0T0GTCnYjKqmdwxyxn2BQf2RcQIIvex5QldPI=
|
||||
github.com/xuri/excelize/v2 v2.9.0 h1:1tgOaEq92IOEumR1/JfYS/eR0KHOCsRv/rYXXh6YJQE=
|
||||
github.com/xuri/excelize/v2 v2.9.0/go.mod h1:uqey4QBZ9gdMeWApPLdhm9x+9o2lq4iVmjiLfBS5hdE=
|
||||
github.com/xuri/nfp v0.0.0-20240318013403-ab9948c2c4a7 h1:hPVCafDV85blFTabnqKgNhDCkJX25eik94Si9cTER4A=
|
||||
github.com/xuri/nfp v0.0.0-20240318013403-ab9948c2c4a7/go.mod h1:WwHg+CVyzlv/TX9xqBFXEZAuxOPxn2k1GNHwG41IIUQ=
|
||||
github.com/zeebo/xxh3 v1.0.2 h1:xZmwmqxHZA8AI603jOQ0tMqmBr9lPeFwGg6d+xy9DC0=
|
||||
github.com/zeebo/xxh3 v1.0.2/go.mod h1:5NWz9Sef7zIDm2JHfFlcQvNekmcEl9ekUZQQKCYaDcA=
|
||||
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
|
||||
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
|
||||
go.opentelemetry.io/otel v1.39.0 h1:8yPrr/S0ND9QEfTfdP9V+SiwT4E0G7Y5MO7p85nis48=
|
||||
go.opentelemetry.io/otel v1.39.0/go.mod h1:kLlFTywNWrFyEdH0oj2xK0bFYZtHRYUdv1NklR/tgc8=
|
||||
go.opentelemetry.io/otel/metric v1.39.0 h1:d1UzonvEZriVfpNKEVmHXbdf909uGTOQjA0HF0Ls5Q0=
|
||||
go.opentelemetry.io/otel/metric v1.39.0/go.mod h1:jrZSWL33sD7bBxg1xjrqyDjnuzTUB0x1nBERXd7Ftcs=
|
||||
go.opentelemetry.io/otel/sdk v1.39.0 h1:nMLYcjVsvdui1B/4FRkwjzoRVsMK8uL/cj0OyhKzt18=
|
||||
go.opentelemetry.io/otel/sdk v1.39.0/go.mod h1:vDojkC4/jsTJsE+kh+LXYQlbL8CgrEcwmt1ENZszdJE=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.39.0 h1:cXMVVFVgsIf2YL6QkRF4Urbr/aMInf+2WKg+sEJTtB8=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.39.0/go.mod h1:xq9HEVH7qeX69/JnwEfp6fVq5wosJsY1mt4lLfYdVew=
|
||||
go.opentelemetry.io/otel/trace v1.39.0 h1:2d2vfpEDmCJ5zVYz7ijaJdOF59xLomrvj7bjt6/qCJI=
|
||||
go.opentelemetry.io/otel/trace v1.39.0/go.mod h1:88w4/PnZSazkGzz/w84VHpQafiU4EtqqlVdxWy+rNOA=
|
||||
go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
|
||||
go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
|
||||
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
||||
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
|
||||
golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU=
|
||||
golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0=
|
||||
golang.org/x/image v0.18.0 h1:jGzIakQa/ZXI1I0Fxvaa9W7yP25TqT6cHIHn+6CqvSQ=
|
||||
golang.org/x/image v0.18.0/go.mod h1:4yyo5vMFQjVjUcVk4jEQcU9MGy/rulF5WvUILseCM2E=
|
||||
golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU=
|
||||
golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY=
|
||||
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk=
|
||||
golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU=
|
||||
golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
modernc.org/libc v1.22.5 h1:91BNch/e5B0uPbJFgqbxXuOnxBQjlS//icfQEGmvyjE=
|
||||
modernc.org/libc v1.22.5/go.mod h1:jj+Z7dTNX8fBScMVNRAYZ/jF91K8fdT2hYMThc3YjBY=
|
||||
modernc.org/mathutil v1.5.0 h1:rV0Ko/6SfM+8G+yKiyI830l3Wuz1zRutdslNoQ0kfiQ=
|
||||
modernc.org/mathutil v1.5.0/go.mod h1:mZW8CKdRPY1v87qxC/wUdX5O1qDzXMP5TH3wjfpga6E=
|
||||
modernc.org/memory v1.5.0 h1:N+/8c5rE6EqugZwHii4IFsaJ7MUhoWX07J5tC/iI5Ds=
|
||||
modernc.org/memory v1.5.0/go.mod h1:PkUhL0Mugw21sHPeskwZW4D6VscE/GQJOnIpCnW6pSU=
|
||||
modernc.org/sqlite v1.23.1 h1:nrSBg4aRQQwq59JpvGEQ15tNxoO5pX/kUjcRNwSAGQM=
|
||||
modernc.org/sqlite v1.23.1/go.mod h1:OrDj17Mggn6MhE+iPbBNf7RGKODDE9NFT0f3EwDzJqk=
|
||||
@@ -0,0 +1,17 @@
|
||||
# CLI tool, only in development environment.
|
||||
# https://goframe.org/docs/cli
|
||||
gfcli:
|
||||
gen:
|
||||
dao:
|
||||
- link: "mysql:${DB_USER}:${DB_PASSWORD}@tcp(${DB_HOST}:3306)/db_iotlcm?charset=utf8mb4&parseTime=True&loc=Asia%2FShanghai"
|
||||
tables: "batch_main,batch_detail,sys_dept,sys_oss,t_resource_batch_download,t_sim_card_cancellation,t_sim_card_info,t_sim_flow_pool,t_warn_config,t_warn_log,t_wo_main,t_wo_record,t_message_log,t_user_message"
|
||||
descriptionTag: true
|
||||
noModelComment: true
|
||||
jsonCase: "CamelLower"
|
||||
gJsonSupport: true
|
||||
group: "default"
|
||||
|
||||
docker:
|
||||
build: "-a amd64 -s linux -p temp -ew"
|
||||
tagPrefixes:
|
||||
- iotlcm-worker-svr
|
||||
@@ -0,0 +1,18 @@
|
||||
|
||||
# Install/Update to the latest CLI tool.
|
||||
.PHONY: cli
|
||||
cli:
|
||||
@set -e; \
|
||||
echo "go install github.com/gogf/gf/cmd/gf/v2@latest"; \
|
||||
go install github.com/gogf/gf/cmd/gf/v2@latest; \
|
||||
echo "GoFame CLI installed successfully!"
|
||||
|
||||
|
||||
# Check and install CLI tool.
|
||||
.PHONY: cli.install
|
||||
cli.install:
|
||||
@set -e; \
|
||||
gf -v > /dev/null 2>&1 || if [[ "$?" -ne "0" ]]; then \
|
||||
echo "GoFame CLI is not installed, start proceeding auto installation..."; \
|
||||
make cli; \
|
||||
fi;
|
||||
@@ -0,0 +1,75 @@
|
||||
.DEFAULT_GOAL := build
|
||||
|
||||
# Update GoFrame and its CLI to latest stable version.
|
||||
.PHONY: up
|
||||
up: cli.install
|
||||
@gf up -a
|
||||
|
||||
# Build binary using configuration from hack/config.yaml.
|
||||
.PHONY: build
|
||||
build: cli.install
|
||||
@gf build -ew
|
||||
|
||||
# Parse api and generate controller/sdk.
|
||||
.PHONY: ctrl
|
||||
ctrl: cli.install
|
||||
@gf gen ctrl
|
||||
|
||||
# Generate Go files for DAO/DO/Entity.
|
||||
.PHONY: dao
|
||||
dao: cli.install
|
||||
@gf gen dao
|
||||
|
||||
# Parse current project go files and generate enums go file.
|
||||
.PHONY: enums
|
||||
enums: cli.install
|
||||
@gf gen enums
|
||||
|
||||
# Generate Go files for Service.
|
||||
.PHONY: service
|
||||
service: cli.install
|
||||
@gf gen service
|
||||
|
||||
|
||||
# Build docker image.
|
||||
.PHONY: image
|
||||
image: cli.install
|
||||
$(eval _TAG = $(shell git rev-parse --short HEAD))
|
||||
ifneq (, $(shell git status --porcelain 2>/dev/null))
|
||||
$(eval _TAG = $(_TAG).dirty)
|
||||
endif
|
||||
$(eval _TAG = $(if ${TAG}, ${TAG}, $(_TAG)))
|
||||
$(eval _PUSH = $(if ${PUSH}, ${PUSH}, ))
|
||||
@gf docker ${_PUSH} -tn $(DOCKER_NAME):${_TAG};
|
||||
|
||||
|
||||
# Build docker image and automatically push to docker repo.
|
||||
.PHONY: image.push
|
||||
image.push: cli.install
|
||||
@make image PUSH=-p;
|
||||
|
||||
|
||||
# Deploy image and yaml to current kubectl environment.
|
||||
.PHONY: deploy
|
||||
deploy: cli.install
|
||||
$(eval _TAG = $(if ${TAG}, ${TAG}, develop))
|
||||
|
||||
@set -e; \
|
||||
mkdir -p $(ROOT_DIR)/temp/kustomize;\
|
||||
cd $(ROOT_DIR)/manifest/deploy/kustomize/overlays/${_ENV};\
|
||||
kustomize build > $(ROOT_DIR)/temp/kustomize.yaml;\
|
||||
kubectl apply -f $(ROOT_DIR)/temp/kustomize.yaml; \
|
||||
if [ $(DEPLOY_NAME) != "" ]; then \
|
||||
kubectl patch -n $(NAMESPACE) deployment/$(DEPLOY_NAME) -p "{\"spec\":{\"template\":{\"metadata\":{\"labels\":{\"date\":\"$(shell date +%s)\"}}}}}"; \
|
||||
fi;
|
||||
|
||||
|
||||
# Parsing protobuf files and generating go files.
|
||||
.PHONY: pb
|
||||
pb: cli.install
|
||||
@gf gen pb
|
||||
|
||||
# Generate protobuf files for database tables.
|
||||
.PHONY: pbentity
|
||||
pbentity: cli.install
|
||||
@gf gen pbentity
|
||||
@@ -0,0 +1,152 @@
|
||||
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
|
||||
}
|
||||
|
||||
type ExecutionFailureHandler interface {
|
||||
HandleExecutionFailure(ctx context.Context, batchNo string, batchType int, reason string) error
|
||||
}
|
||||
|
||||
type ProcessingClaimer interface {
|
||||
ReclaimProcessing(ctx context.Context, batchNo string, batchType int, startedAt *gtime.Time) (bool, error)
|
||||
}
|
||||
|
||||
type Dispatcher struct {
|
||||
executors map[int]Executor
|
||||
}
|
||||
|
||||
func NewDispatcher(executor Executor) *Dispatcher {
|
||||
return &Dispatcher{executors: map[int]Executor{
|
||||
Type103: executor,
|
||||
Type104: executor,
|
||||
}}
|
||||
}
|
||||
|
||||
func (d *Dispatcher) Register(batchType int, executor Executor) {
|
||||
if d == nil || executor == nil {
|
||||
return
|
||||
}
|
||||
d.executors[batchType] = executor
|
||||
}
|
||||
|
||||
func (d *Dispatcher) Executor(batchType int) (Executor, bool) {
|
||||
if d == nil {
|
||||
return nil, false
|
||||
}
|
||||
executor, ok := d.executors[batchType]
|
||||
return executor, ok && executor != nil
|
||||
}
|
||||
|
||||
func (d *Dispatcher) Has(batchType int) bool {
|
||||
_, ok := d.Executor(batchType)
|
||||
return ok
|
||||
}
|
||||
@@ -0,0 +1,516 @@
|
||||
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
|
||||
}
|
||||
|
||||
func TestMessageJSONContract(t *testing.T) {
|
||||
body, err := json.Marshal(Message{BatchNo: "BATCH-1", BatchType: Type103})
|
||||
if err != nil {
|
||||
t.Fatalf("marshal message: %v", err)
|
||||
}
|
||||
const expected = `{"batchNo":"BATCH-1","batchType":103}`
|
||||
if string(body) != expected {
|
||||
t.Fatalf("message JSON = %s, want %s", body, expected)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDispatcherSupportsLifecycleTypesAndExplicitRegistration(t *testing.T) {
|
||||
executor := &executorStub{}
|
||||
dispatcher := NewDispatcher(executor)
|
||||
for _, batchType := range []int{Type103, Type104} {
|
||||
actual, ok := dispatcher.Executor(batchType)
|
||||
if !ok || actual != executor {
|
||||
t.Fatalf("batch type %d was not registered with shared executor", batchType)
|
||||
}
|
||||
}
|
||||
if _, ok := dispatcher.Executor(102); ok {
|
||||
t.Fatal("unexpected executor for unsupported batch type")
|
||||
}
|
||||
|
||||
inbound := &executorStub{}
|
||||
dispatcher.Register(1, inbound)
|
||||
actual, ok := dispatcher.Executor(1)
|
||||
if !ok || actual != inbound {
|
||||
t.Fatal("registered inbound executor was not returned")
|
||||
}
|
||||
|
||||
dataExecutor := &executorStub{}
|
||||
dispatcher.Register(Type107, dataExecutor)
|
||||
dispatcher.Register(Type109, dataExecutor)
|
||||
dispatcher.Register(Type110, dataExecutor)
|
||||
for _, batchType := range []int{Type107, Type109, Type110} {
|
||||
actual, ok = dispatcher.Executor(batchType)
|
||||
if !ok || actual != dataExecutor {
|
||||
t.Fatalf("batch type %d was not registered with the data service executor", batchType)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRuntimeAcknowledgesInvalidAndUnclaimableMessages(t *testing.T) {
|
||||
g.Log().SetWriter(io.Discard)
|
||||
tests := []struct {
|
||||
name string
|
||||
message Message
|
||||
mains []*entity.BatchMain
|
||||
claimed bool
|
||||
wantFind int
|
||||
wantMarked int
|
||||
}{
|
||||
{name: "empty batch number", message: Message{BatchType: Type103}},
|
||||
{name: "blank batch number", message: Message{BatchNo: " ", BatchType: Type103}},
|
||||
{name: "unsupported type", message: Message{BatchNo: "B-1", BatchType: 102}},
|
||||
{name: "not found", message: Message{BatchNo: "B-1", BatchType: Type103}, wantFind: 1},
|
||||
{name: "type mismatch", message: Message{BatchNo: "B-1", BatchType: Type103}, mains: []*entity.BatchMain{{BatchNo: "B-1", BatchType: Type104, BatchAsyncStatus: AsyncStatusPending}}, wantFind: 1},
|
||||
{name: "processing", message: Message{BatchNo: "B-1", BatchType: Type103}, mains: []*entity.BatchMain{{BatchNo: "B-1", BatchType: Type103, BatchAsyncStatus: AsyncStatusProcessing}}, wantFind: 1},
|
||||
{name: "terminal", message: Message{BatchNo: "B-1", BatchType: Type103}, mains: []*entity.BatchMain{{BatchNo: "B-1", BatchType: Type103, BatchAsyncStatus: 3}}, wantFind: 1},
|
||||
{name: "CAS lost", message: Message{BatchNo: "B-1", BatchType: Type103}, mains: []*entity.BatchMain{{BatchNo: "B-1", BatchType: Type103, BatchAsyncStatus: AsyncStatusPending}, {BatchNo: "B-1", BatchType: Type103, BatchAsyncStatus: AsyncStatusProcessing}}, wantFind: 2, wantMarked: 1},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
repository := &repositoryStub{mains: tt.mains, claimed: tt.claimed}
|
||||
executor := &executorStub{}
|
||||
runtime := NewRuntime(repository, NewDispatcher(executor))
|
||||
if err := runtime.Handle(context.Background(), tt.message); err != nil {
|
||||
t.Fatalf("Handle() error = %v", err)
|
||||
}
|
||||
if repository.findCalls != tt.wantFind || repository.markCalls != tt.wantMarked {
|
||||
t.Fatalf("repository calls: find=%d mark=%d, want find=%d mark=%d", repository.findCalls, repository.markCalls, tt.wantFind, tt.wantMarked)
|
||||
}
|
||||
if executor.calls != 0 {
|
||||
t.Fatalf("executor calls = %d, want 0", executor.calls)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRuntimeExecutesRegisteredDataServiceBatch(t *testing.T) {
|
||||
repository := &repositoryStub{
|
||||
mains: []*entity.BatchMain{{BatchNo: "B-109", BatchType: Type109, BatchAsyncStatus: AsyncStatusPending}},
|
||||
claimed: true,
|
||||
}
|
||||
dataExecutor := &executorStub{}
|
||||
dispatcher := NewDispatcher(&executorStub{})
|
||||
dispatcher.Register(Type109, dataExecutor)
|
||||
dispatcher.Register(Type110, dataExecutor)
|
||||
runtime := NewRuntime(repository, dispatcher)
|
||||
|
||||
if err := runtime.Handle(context.Background(), Message{BatchNo: "B-109", BatchType: Type109}); err != nil {
|
||||
t.Fatalf("Handle() error = %v", err)
|
||||
}
|
||||
if dataExecutor.calls != 1 || dataExecutor.batchNo != "B-109" {
|
||||
t.Fatalf("data executor was not invoked: %+v", dataExecutor)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRuntimeExecutesTrafficCutoffConfigWithDedicatedExecutor(t *testing.T) {
|
||||
repository := &repositoryStub{
|
||||
mains: []*entity.BatchMain{{BatchNo: "B-41", BatchType: Type41, BatchAsyncStatus: AsyncStatusPending}},
|
||||
claimed: true,
|
||||
}
|
||||
dataExecutor := &executorStub{err: errors.New("data executor must not be called")}
|
||||
trafficCutoffConfigExecutor := &executorStub{}
|
||||
dispatcher := NewDispatcher(&executorStub{})
|
||||
dispatcher.Register(Type107, dataExecutor)
|
||||
dispatcher.Register(Type41, trafficCutoffConfigExecutor)
|
||||
runtime := NewRuntime(repository, dispatcher)
|
||||
|
||||
if err := runtime.Handle(context.Background(), Message{BatchNo: "B-41", BatchType: Type41}); err != nil {
|
||||
t.Fatalf("Handle() error = %v", err)
|
||||
}
|
||||
if trafficCutoffConfigExecutor.calls != 1 || trafficCutoffConfigExecutor.batchNo != "B-41" {
|
||||
t.Fatalf("traffic cutoff config executor was not invoked: %+v", trafficCutoffConfigExecutor)
|
||||
}
|
||||
if dataExecutor.calls != 0 {
|
||||
t.Fatalf("data executor calls = %d, want 0", dataExecutor.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRuntimeIgnoresFinalizedTrafficCutoffConfigDuplicateMessage(t *testing.T) {
|
||||
repository := &repositoryStub{
|
||||
mains: []*entity.BatchMain{{BatchNo: "B-41-DONE", BatchType: Type41, BatchAsyncStatus: AsyncStatusSuccess}},
|
||||
}
|
||||
trafficCutoffConfigExecutor := &executorStub{err: errors.New("traffic cutoff config executor must not be called")}
|
||||
dispatcher := NewDispatcher(&executorStub{})
|
||||
dispatcher.Register(Type41, trafficCutoffConfigExecutor)
|
||||
runtime := NewRuntime(repository, dispatcher)
|
||||
|
||||
if err := runtime.Handle(context.Background(), Message{BatchNo: "B-41-DONE", BatchType: Type41}); err != nil {
|
||||
t.Fatalf("Handle() error = %v", err)
|
||||
}
|
||||
if trafficCutoffConfigExecutor.calls != 0 {
|
||||
t.Fatalf("traffic cutoff config executor calls = %d, want 0", trafficCutoffConfigExecutor.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRuntimeMarksExecutorFailureAsFailed(t *testing.T) {
|
||||
expectedErr := errors.New("execute failed")
|
||||
repository := &repositoryStub{
|
||||
mains: []*entity.BatchMain{{BatchNo: "B-104", BatchType: Type104, BatchAsyncStatus: AsyncStatusPending}},
|
||||
claimed: true,
|
||||
}
|
||||
executor := &executorStub{err: expectedErr}
|
||||
runtime := NewRuntime(repository, NewDispatcher(executor))
|
||||
|
||||
if err := runtime.Handle(context.Background(), Message{BatchNo: "B-104", BatchType: Type104}); err != nil {
|
||||
t.Fatalf("Handle() error = %v, want nil", err)
|
||||
}
|
||||
if repository.markCalls != 1 || repository.markBatchNo != "B-104" || repository.markType != Type104 {
|
||||
t.Fatalf("unexpected claim call: %+v", repository)
|
||||
}
|
||||
if executor.calls != 1 || executor.batchNo != "B-104" {
|
||||
t.Fatalf("unexpected executor call: %+v", executor)
|
||||
}
|
||||
if repository.failCalls != 1 || repository.failBatchNo != "B-104" || repository.failType != Type104 || repository.failReason != expectedErr.Error() {
|
||||
t.Fatalf("unexpected failure mark call: %+v", repository)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRuntimeReturnsRetryableExecutorFailureWithoutFinalizing(t *testing.T) {
|
||||
sourceErr := errors.New("source file is temporarily unavailable")
|
||||
repository := &repositoryStub{
|
||||
mains: []*entity.BatchMain{{BatchNo: "INB-RETRY", BatchType: 1, BatchAsyncStatus: AsyncStatusPending}},
|
||||
claimed: true,
|
||||
}
|
||||
executor := &executorStub{err: Retryable(sourceErr)}
|
||||
dispatcher := NewDispatcher(&executorStub{})
|
||||
dispatcher.Register(1, executor)
|
||||
runtime := NewRuntime(repository, dispatcher)
|
||||
|
||||
err := runtime.Handle(context.Background(), Message{BatchNo: "INB-RETRY", BatchType: 1})
|
||||
if !errors.Is(err, sourceErr) || !IsRetryable(err) {
|
||||
t.Fatalf("Handle() error = %v, want retryable wrapper of %v", err, sourceErr)
|
||||
}
|
||||
if repository.failCalls != 0 {
|
||||
t.Fatalf("MarkFailed calls = %d, want 0", repository.failCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRuntimeReclaimsRetryableProcessingBatch(t *testing.T) {
|
||||
repository := &repositoryStub{
|
||||
mains: []*entity.BatchMain{{BatchNo: "INB-RETRY", BatchType: 1, BatchAsyncStatus: AsyncStatusProcessing}},
|
||||
reclaimSet: true,
|
||||
reclaimed: true,
|
||||
}
|
||||
executor := &executorStub{}
|
||||
dispatcher := NewDispatcher(&executorStub{})
|
||||
dispatcher.Register(1, executor)
|
||||
runtime := NewRuntime(repository, dispatcher)
|
||||
|
||||
if err := runtime.Handle(context.Background(), Message{BatchNo: "INB-RETRY", BatchType: 1, Retryable: true}); err != nil {
|
||||
t.Fatalf("Handle() error = %v", err)
|
||||
}
|
||||
if repository.reclaimCalls != 1 || repository.reclaimBatchNo != "INB-RETRY" || repository.reclaimType != 1 {
|
||||
t.Fatalf("unexpected reclaim call: %+v", repository)
|
||||
}
|
||||
if executor.calls != 1 {
|
||||
t.Fatalf("executor calls = %d, want 1", executor.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRuntimeMarksTimeoutAsFailed(t *testing.T) {
|
||||
repository := &repositoryStub{
|
||||
mains: []*entity.BatchMain{{BatchNo: "B-TIMEOUT", BatchType: Type104, BatchAsyncStatus: AsyncStatusPending}},
|
||||
claimed: true,
|
||||
}
|
||||
executor := &blockingExecutorStub{}
|
||||
runtime := NewRuntime(
|
||||
repository,
|
||||
NewDispatcher(executor),
|
||||
WithExecutionTimeout(time.Millisecond),
|
||||
WithFinalizationTimeout(time.Second),
|
||||
)
|
||||
|
||||
if err := runtime.Handle(context.Background(), Message{BatchNo: "B-TIMEOUT", BatchType: Type104}); err != nil {
|
||||
t.Fatalf("Handle() error = %v, want nil", err)
|
||||
}
|
||||
if executor.calls != 1 {
|
||||
t.Fatalf("executor calls = %d, want 1", executor.calls)
|
||||
}
|
||||
if repository.failCalls != 1 || !strings.Contains(repository.failReason, "timed out after 1ms") {
|
||||
t.Fatalf("unexpected timeout failure mark: %+v", repository)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRuntimeReturnsFailureMarkErrors(t *testing.T) {
|
||||
executeErr := errors.New("execute failed")
|
||||
markErr := errors.New("mark failed")
|
||||
repository := &repositoryStub{
|
||||
mains: []*entity.BatchMain{{BatchNo: "B-104", BatchType: Type104, BatchAsyncStatus: AsyncStatusPending}},
|
||||
claimed: true,
|
||||
failErr: markErr,
|
||||
}
|
||||
runtime := NewRuntime(repository, NewDispatcher(&executorStub{err: executeErr}))
|
||||
if err := runtime.Handle(context.Background(), Message{BatchNo: "B-104", BatchType: Type104}); !errors.Is(err, markErr) {
|
||||
t.Fatalf("Handle() error = %v, want %v", err, markErr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRuntimeCompensatesFailureMarkWithoutReexecutingBatch(t *testing.T) {
|
||||
markErr := errors.New("mark failed")
|
||||
repository := &repositoryStub{
|
||||
mains: []*entity.BatchMain{{BatchNo: "B-RETRY", BatchType: Type104, BatchAsyncStatus: AsyncStatusProcessing}},
|
||||
failErr: markErr,
|
||||
}
|
||||
executor := &executorStub{err: errors.New("must not execute again")}
|
||||
runtime := NewRuntime(repository, NewDispatcher(executor))
|
||||
|
||||
err := runtime.Handle(context.Background(), Message{
|
||||
BatchNo: "B-RETRY",
|
||||
BatchType: Type104,
|
||||
FailureReason: "original executor failure",
|
||||
})
|
||||
if !errors.Is(err, markErr) {
|
||||
t.Fatalf("Handle() error = %v, want %v", err, markErr)
|
||||
}
|
||||
if executor.calls != 0 {
|
||||
t.Fatalf("executor calls = %d, want 0 for failure finalization retry", executor.calls)
|
||||
}
|
||||
if repository.markCalls != 0 {
|
||||
t.Fatalf("MarkProcessing calls = %d, want 0 for failure finalization retry", repository.markCalls)
|
||||
}
|
||||
if repository.failCalls != 1 || repository.failReason != "original executor failure" {
|
||||
t.Fatalf("unexpected failure finalization call: %+v", repository)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRuntimeAllowsSelfClaimingProcessingReEntryWithoutRefreshingStartTime(t *testing.T) {
|
||||
repository := &repositoryStub{
|
||||
mains: []*entity.BatchMain{{BatchNo: "RST-1", BatchType: Type105, BatchAsyncStatus: AsyncStatusProcessing}},
|
||||
}
|
||||
executor := &processingExecutorStub{can: true, selfClaim: true}
|
||||
dispatcher := NewDispatcher(&executorStub{})
|
||||
dispatcher.Register(Type105, executor)
|
||||
runtime := NewRuntime(repository, dispatcher)
|
||||
|
||||
if err := runtime.Handle(context.Background(), Message{BatchNo: "RST-1", BatchType: Type105}); err != nil {
|
||||
t.Fatalf("Handle() error = %v", err)
|
||||
}
|
||||
if executor.canCalls != 1 || executor.main == nil || executor.main.BatchNo != "RST-1" {
|
||||
t.Fatalf("processing check not called with main: %+v", executor)
|
||||
}
|
||||
if repository.markCalls != 0 {
|
||||
t.Fatalf("MarkProcessing calls = %d, want 0", repository.markCalls)
|
||||
}
|
||||
if repository.reclaimCalls != 0 {
|
||||
t.Fatalf("ReclaimProcessing calls = %d, want 0 for self-claiming executor", repository.reclaimCalls)
|
||||
}
|
||||
if executor.calls != 1 || executor.batchNo != "RST-1" {
|
||||
t.Fatalf("executor calls = %+v", executor.executorStub)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRuntimeMarksProcessingReEntryFailureAsFailed(t *testing.T) {
|
||||
expectedErr := errors.New("resume failed")
|
||||
repository := &repositoryStub{
|
||||
mains: []*entity.BatchMain{{BatchNo: "RST-1", BatchType: Type105, BatchAsyncStatus: AsyncStatusProcessing}},
|
||||
}
|
||||
executor := &processingExecutorStub{can: true}
|
||||
executor.err = expectedErr
|
||||
dispatcher := NewDispatcher(&executorStub{})
|
||||
dispatcher.Register(Type105, executor)
|
||||
runtime := NewRuntime(repository, dispatcher)
|
||||
|
||||
if err := runtime.Handle(context.Background(), Message{BatchNo: "RST-1", BatchType: Type105}); err != nil {
|
||||
t.Fatalf("Handle() error = %v, want nil", err)
|
||||
}
|
||||
if repository.markCalls != 0 {
|
||||
t.Fatalf("MarkProcessing calls = %d, want 0", repository.markCalls)
|
||||
}
|
||||
if repository.failCalls != 1 || repository.failBatchNo != "RST-1" || repository.failType != Type105 || repository.failReason != expectedErr.Error() {
|
||||
t.Fatalf("unexpected failure mark call: %+v", repository)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRuntimeIgnoresProcessingWhenOptInExecutorDeclines(t *testing.T) {
|
||||
repository := &repositoryStub{
|
||||
mains: []*entity.BatchMain{{BatchNo: "RST-2", BatchType: Type105, BatchAsyncStatus: AsyncStatusProcessing}},
|
||||
}
|
||||
executor := &processingExecutorStub{can: false}
|
||||
dispatcher := NewDispatcher(&executorStub{})
|
||||
dispatcher.Register(Type105, executor)
|
||||
runtime := NewRuntime(repository, dispatcher)
|
||||
|
||||
if err := runtime.Handle(context.Background(), Message{BatchNo: "RST-2", BatchType: Type105}); err != nil {
|
||||
t.Fatalf("Handle() error = %v", err)
|
||||
}
|
||||
if executor.canCalls != 1 {
|
||||
t.Fatalf("processing check calls = %d, want 1", executor.canCalls)
|
||||
}
|
||||
if executor.calls != 0 {
|
||||
t.Fatalf("executor calls = %d, want 0", executor.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRuntimeReturnsDatabaseErrors(t *testing.T) {
|
||||
findErr := errors.New("find failed")
|
||||
runtime := NewRuntime(&repositoryStub{findErr: findErr}, NewDispatcher(&executorStub{}))
|
||||
if err := runtime.Handle(context.Background(), Message{BatchNo: "B-1", BatchType: Type103}); !errors.Is(err, findErr) {
|
||||
t.Fatalf("find error = %v, want %v", err, findErr)
|
||||
}
|
||||
|
||||
markErr := errors.New("mark failed")
|
||||
runtime = NewRuntime(&repositoryStub{
|
||||
mains: []*entity.BatchMain{{BatchNo: "B-1", BatchType: Type103, BatchAsyncStatus: AsyncStatusPending}},
|
||||
markErr: markErr,
|
||||
}, NewDispatcher(&executorStub{}))
|
||||
if err := runtime.Handle(context.Background(), Message{BatchNo: "B-1", BatchType: Type103}); !errors.Is(err, markErr) {
|
||||
t.Fatalf("mark error = %v, want %v", err, markErr)
|
||||
}
|
||||
|
||||
checkErr := errors.New("check failed")
|
||||
processing := &processingExecutorStub{canErr: checkErr}
|
||||
dispatcher := NewDispatcher(&executorStub{})
|
||||
dispatcher.Register(Type105, processing)
|
||||
runtime = NewRuntime(&repositoryStub{
|
||||
mains: []*entity.BatchMain{{BatchNo: "RST-3", BatchType: Type105, BatchAsyncStatus: AsyncStatusProcessing}},
|
||||
}, dispatcher)
|
||||
if err := runtime.Handle(context.Background(), Message{BatchNo: "RST-3", BatchType: Type105}); !errors.Is(err, checkErr) {
|
||||
t.Fatalf("processing check error = %v, want %v", err, checkErr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMessageHasOnlyContractFields(t *testing.T) {
|
||||
typeOfMessage := reflect.TypeOf(Message{})
|
||||
if typeOfMessage.NumField() != 4 {
|
||||
t.Fatalf("Message has %d fields, want 4", typeOfMessage.NumField())
|
||||
}
|
||||
if field, ok := typeOfMessage.FieldByName("FailureReason"); !ok || field.Tag.Get("json") != "failureReason,omitempty" {
|
||||
t.Fatalf("FailureReason JSON contract is missing or incorrect")
|
||||
}
|
||||
if field, ok := typeOfMessage.FieldByName("Retryable"); !ok || field.Tag.Get("json") != "retryable,omitempty" {
|
||||
t.Fatalf("Retryable JSON contract is missing or incorrect")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
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/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 message.Retryable {
|
||||
return r.reclaimAndExecute(ctx, message, executor, main)
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
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 {
|
||||
finalizationCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), r.finalizationTimeout)
|
||||
defer cancel()
|
||||
|
||||
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,241 @@
|
||||
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"
|
||||
|
||||
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))
|
||||
}
|
||||
|
||||
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()
|
||||
timeout := timeoutOverride
|
||||
if timeout <= 0 {
|
||||
timeout = config.Timeout
|
||||
}
|
||||
if timeout <= 0 {
|
||||
timeout = 10 * time.Second
|
||||
}
|
||||
if itemCount > 1 {
|
||||
timeout *= time.Duration(itemCount)
|
||||
}
|
||||
if maxTimeout := g.Cfg().MustGet(ctx, upstreamProxyMaxTimeoutConfigKey, 2*time.Minute).Duration(); maxTimeout > 0 && timeout > maxTimeout {
|
||||
timeout = maxTimeout
|
||||
}
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
package upstream
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/model"
|
||||
)
|
||||
|
||||
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 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,18 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/gogf/gf/v2/os/gcmd"
|
||||
|
||||
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/worker"
|
||||
)
|
||||
|
||||
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,11 @@
|
||||
package consts
|
||||
|
||||
// BatchSubBatchSize 是批量开停数据 / 停复机执行时,单个上游通道每次请求携带的卡数量上限。
|
||||
// 原来一个通道整组一次性发给 upstream-chan,大批量会触发 worker 侧 HTTP 超时(默认 2 分钟上限)导致整批失败。
|
||||
// 按此值拆分为多个小请求后,每个请求都能在超时上限内返回。
|
||||
const BatchSubBatchSize = 100
|
||||
|
||||
// BatchSubBatchConcurrency 是同一通道内子批次的并发度,受信号量限制:
|
||||
// 即最多同时向上游发起 BatchSubBatchConcurrency 个请求,等价于最多同时发起同样数量的运营商调用,
|
||||
// 既避免串行过慢,也避免无节制并发打爆运营商限流。如需更保守可调小该值。
|
||||
const BatchSubBatchConcurrency = 5
|
||||
@@ -0,0 +1,154 @@
|
||||
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
|
||||
|
||||
BatchRunResultPending = 0
|
||||
BatchRunResultRunning = 1
|
||||
BatchRunResultSuccess = 2
|
||||
BatchRunResultFailed = 3
|
||||
BatchRunResultPartialSuccess = 4
|
||||
|
||||
BatchDetailStatusPending = 0
|
||||
BatchDetailStatusRunning = 1
|
||||
BatchDetailStatusSuccess = 2
|
||||
BatchDetailStatusFailed = 3
|
||||
|
||||
ResourceTypeExcel = 1
|
||||
DownloadStatusGenerated = 1
|
||||
|
||||
ResourceExportSceneCardAddPackage = 11
|
||||
ResourceExportScenePoolAddPackage = 12
|
||||
|
||||
// 卡套餐有效状态
|
||||
PackageStatusNone = 0
|
||||
PackageStatusPending = 1
|
||||
PackageStatusEffective = 2
|
||||
PackageStatusExpired = 3
|
||||
|
||||
// 订购记录有效状态(t_sim_card_bind_pkg.effective_status)
|
||||
EffectiveStatusPending = 0
|
||||
EffectiveStatusEffective = 1
|
||||
EffectiveStatusExpired = 2
|
||||
EffectiveStatusTerminated = 3
|
||||
EffectiveStatusVoided = 4
|
||||
|
||||
// 退订状态(t_order.return_status)
|
||||
OrderReturnStatusNone = 0
|
||||
OrderReturnStatusSuccess = 4
|
||||
OrderReturnStatusPartial = 3
|
||||
|
||||
// Renew/change order contract. These values mirror the Admin cardpackage
|
||||
// domain and the production t_order/t_order_item enum contract.
|
||||
OrderTypeCardSubscribe = 1
|
||||
OrderTypeCardRenew = 2
|
||||
OrderTypeCardChange = 3
|
||||
OrderStatusCompleted = 5
|
||||
CustomerTypeAgent = 1
|
||||
PayTypeOffline = 3
|
||||
PayTypeBalance = 4
|
||||
PayStatusPaid = 2
|
||||
ProductTypeCard = 1
|
||||
PayModeYearMonth = 1
|
||||
ShippingMethodNone = 3
|
||||
|
||||
PackageBusinessTypeSubscribe = 0
|
||||
PackageBusinessTypeChange = 1
|
||||
PackageBusinessTypeRenew = 2
|
||||
|
||||
ResourceBatchDownloadTaskSimCardAddPackage = "单卡加油包结果"
|
||||
ResourceBatchDownloadTaskSimPoolAddPackage = "流量池加油包结果"
|
||||
|
||||
SimCardAddPackageMaxFileSize = 50 << 20
|
||||
SimPoolAddPackageMaxFileSize = 50 << 20
|
||||
|
||||
SimCardAddPackageSubDirImportExcel = "sim-card-add-package/import"
|
||||
SimCardAddPackageSubDirResultExcel = "sim-card-add-package/result"
|
||||
SimPoolAddPackageSubDirImportExcel = "sim-pool-add-package/import"
|
||||
SimPoolAddPackageSubDirResultExcel = "sim-pool-add-package/result"
|
||||
|
||||
// 续费/变更结果目录
|
||||
SimCardRenewResultSubDir = "sim-card-renew/result"
|
||||
SimCardChangeResultSubDir = "sim-card-change/result"
|
||||
|
||||
// 续费/变更结果任务名(沿用 Admin 约定)
|
||||
ResultTaskSimCardRenew = "卡续费结果"
|
||||
ResultTaskSimCardChange = "卡变更套餐结果"
|
||||
ResultTaskSimCardPrepare = "卡续费/变更预处理结果"
|
||||
)
|
||||
|
||||
// RenewPriceSource 续费/变更价格来源
|
||||
const (
|
||||
RenewPriceSourceSystem = "system"
|
||||
RenewPriceSourceAgentDefault = "agentDefault"
|
||||
RenewPriceSourceTarget = "target"
|
||||
)
|
||||
@@ -0,0 +1,42 @@
|
||||
package consts
|
||||
|
||||
import "strings"
|
||||
|
||||
const (
|
||||
BillingMethodMonth = 1
|
||||
BillingMethodYear = 2
|
||||
|
||||
RenewDurationUnitMonth = "month"
|
||||
RenewDurationUnitYear = "year"
|
||||
)
|
||||
|
||||
func BillingMethodDurationUnit(billingMethod int) string {
|
||||
if billingMethod == BillingMethodMonth {
|
||||
return RenewDurationUnitMonth
|
||||
}
|
||||
return RenewDurationUnitYear
|
||||
}
|
||||
|
||||
func NormalizeRenewDurationUnit(unit string) string {
|
||||
if strings.EqualFold(strings.TrimSpace(unit), RenewDurationUnitMonth) {
|
||||
return RenewDurationUnitMonth
|
||||
}
|
||||
return RenewDurationUnitYear
|
||||
}
|
||||
|
||||
func RenewDurationUnitText(unit string) string {
|
||||
if NormalizeRenewDurationUnit(unit) == RenewDurationUnitMonth {
|
||||
return "个月"
|
||||
}
|
||||
return "年"
|
||||
}
|
||||
|
||||
func DurationValueToMonths(unit string, value int) int {
|
||||
if value <= 0 {
|
||||
return 0
|
||||
}
|
||||
if NormalizeRenewDurationUnit(unit) == RenewDurationUnitMonth {
|
||||
return value
|
||||
}
|
||||
return value * 12
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
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"
|
||||
// QueueBalanceRechargeDeadLetter 保存重试次数耗尽后仍失败的充值超时消息,
|
||||
// 供人工排查(例如支付宝凭据未配置导致的永久失败),不再自动重投。
|
||||
QueueBalanceRechargeDeadLetter = "iotlcm.balance.recharge.dlq"
|
||||
)
|
||||
@@ -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,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"
|
||||
|
||||
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/model"
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
"github.com/gogf/gf/v2/os/gcfg"
|
||||
)
|
||||
|
||||
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,175 @@
|
||||
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
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
// 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,
|
||||
}
|
||||
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,526 @@
|
||||
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: 1780000000000,
|
||||
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: 1780000000000,
|
||||
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":1780000000000,"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{})
|
||||
if got := len(consumers.initializers(false)); got != 6 {
|
||||
t.Fatalf("diagnostic disabled: expected 6 business consumers, got %d", got)
|
||||
}
|
||||
if got := len(consumers.initializers(true)); got != 7 {
|
||||
t.Fatalf("diagnostic enabled: expected 7 consumers, got %d", got)
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
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"
|
||||
"github.com/gogf/gf/v2/errors/gerror"
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
)
|
||||
|
||||
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"
|
||||
"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"
|
||||
amqp "github.com/rabbitmq/amqp091-go"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestTrafficUsageRetryRetainsPayload(t *testing.T) {
|
||||
stubTrafficPublish(t)
|
||||
usage := 12.5
|
||||
handler := &trafficUsageHandlerStub{dailyErr: &trafficusage.Failure{Cause: errors.New("database down"), UsageMb: &usage}}
|
||||
consumer := New(handler, nil, nil)
|
||||
message := model.TrafficUsageDailyRetryMessage{SimKey: &model.SimKey{ICCID: "card"}, Card: model.TrafficUsageSimCard{Iccid: "card"}, QueryDate: "20260831"}
|
||||
calls := 0
|
||||
trafficUsagePublishDelayed = func(_ context.Context, queue, target string, delay time.Duration, body []byte, _ amqp.Table) error {
|
||||
calls++
|
||||
var retry model.TrafficUsageDailyRetryMessage
|
||||
if err := json.Unmarshal(body, &retry); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if retry.Attempt != 1 || retry.UsageMb == nil || *retry.UsageMb != usage || retry.QueryDate != message.QueryDate || retry.LastError == "" || delay != 30*time.Second || target != consts.QueueTrafficUsageDailyRetry || queue == target {
|
||||
t.Fatalf("invalid retry: %+v", retry)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if err := consumer.handleTrafficUsageDailyRetry(context.Background(), message); err != nil || calls != 1 {
|
||||
t.Fatalf("calls=%d err=%v", calls, err)
|
||||
}
|
||||
message.Attempt = 9
|
||||
dlqCalls := 0
|
||||
trafficUsagePublish = func(_ context.Context, queue string, body []byte, _ amqp.Table) error {
|
||||
dlqCalls++
|
||||
if queue != consts.QueueTrafficUsageDailyRetry+".dlq" {
|
||||
t.Fatal(queue)
|
||||
}
|
||||
var retry model.TrafficUsageDailyRetryMessage
|
||||
if err := json.Unmarshal(body, &retry); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if retry.Attempt != 10 || retry.UsageMb == nil {
|
||||
t.Fatal("DLQ lost snapshot")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if err := consumer.handleTrafficUsageDailyRetry(context.Background(), message); err != nil || dlqCalls != 1 || calls != 1 {
|
||||
t.Fatalf("DLQ not published: %v", err)
|
||||
}
|
||||
expected := errors.New("broker unavailable")
|
||||
trafficUsagePublish = func(context.Context, string, []byte, amqp.Table) error { return expected }
|
||||
if err := consumer.handleTrafficUsageDailyRetry(context.Background(), message); !errors.Is(err, expected) {
|
||||
t.Fatal("failed publication must not ack original")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
package rabbitmq
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
|
||||
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/consts"
|
||||
mq "git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/library/rabbitmq"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/model"
|
||||
)
|
||||
|
||||
func (c *Consumers) initWorkOrderAutoClose(ctx context.Context) error {
|
||||
if scanner, ok := c.workOrder.(interface{ CloseOverdue(context.Context) error }); ok {
|
||||
go func() {
|
||||
ticker := time.NewTicker(5 * time.Minute)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
if err := scanner.CloseOverdue(ctx); err != nil {
|
||||
g.Log().Errorf(ctx, "工单到期补偿失败: %v", err)
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
if !mq.Enabled(ctx) {
|
||||
g.Log().Info(ctx, "work order auto close consumer skipped, RabbitMQ disabled")
|
||||
return nil
|
||||
}
|
||||
if err := mq.ConsumeJSON[model.WorkOrderAutoCloseMessage](
|
||||
ctx,
|
||||
consts.QueueWorkOrderAutoClose,
|
||||
"iotlcm-work-order-auto-close",
|
||||
c.handleWorkOrderAutoClose,
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
g.Log().Infof(ctx, "work order auto close consumer started, queue:%s", consts.QueueWorkOrderAutoClose)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Consumers) handleWorkOrderAutoClose(ctx context.Context, message model.WorkOrderAutoCloseMessage) error {
|
||||
if message.WoNo == "" {
|
||||
g.Log().Errorf(ctx, "work order auto close message dropped, invalid message:%+v", message)
|
||||
return nil
|
||||
}
|
||||
return c.workOrder.AutoCloseResolved(ctx, message)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,706 @@
|
||||
package zset
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gogf/gf/v2/container/gvar"
|
||||
|
||||
queue "git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/queue/zset"
|
||||
)
|
||||
|
||||
type redisCall struct {
|
||||
command string
|
||||
args []any
|
||||
}
|
||||
|
||||
type fakeRedis struct {
|
||||
mu sync.Mutex
|
||||
calls []redisCall
|
||||
responses []*gvar.Var
|
||||
}
|
||||
|
||||
func (f *fakeRedis) Do(_ context.Context, command string, args ...any) (*gvar.Var, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
f.calls = append(f.calls, redisCall{command: command, args: args})
|
||||
if len(f.responses) == 0 {
|
||||
return gvar.New(int64(1)), nil
|
||||
}
|
||||
response := f.responses[0]
|
||||
f.responses = f.responses[1:]
|
||||
return response, nil
|
||||
}
|
||||
|
||||
func (f *fakeRedis) Eval(_ context.Context, script string, numKeys int64, keys []string, args []any) (*gvar.Var, error) {
|
||||
allArgs := []any{script, numKeys}
|
||||
for _, key := range keys {
|
||||
allArgs = append(allArgs, key)
|
||||
}
|
||||
allArgs = append(allArgs, args...)
|
||||
return f.Do(context.Background(), "EVAL", allArgs...)
|
||||
}
|
||||
|
||||
type testHandler struct {
|
||||
query func(context.Context, queue.Task) Result
|
||||
apply func(context.Context, []Result) error
|
||||
}
|
||||
|
||||
func (h *testHandler) Query(ctx context.Context, task queue.Task) Result {
|
||||
if h.query != nil {
|
||||
return h.query(ctx, task)
|
||||
}
|
||||
return Result{Task: task, Success: true, UsageMB: float64(task.CardID)}
|
||||
}
|
||||
|
||||
func (h *testHandler) Apply(ctx context.Context, results []Result) error {
|
||||
if h.apply != nil {
|
||||
return h.apply(ctx, results)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func taskMember(task queue.Task) string {
|
||||
value, _ := json.Marshal(task)
|
||||
return string(value)
|
||||
}
|
||||
|
||||
func claimedTask(task queue.Task) queue.ClaimedTask {
|
||||
return queue.ClaimedTask{Task: task, Member: taskMember(task), LeaseToken: "token-" + task.TaskID}
|
||||
}
|
||||
|
||||
func newTestConsumer(t *testing.T, fake *fakeRedis, handler Handler, config Config) *Consumer {
|
||||
t.Helper()
|
||||
store, err := queue.New(fake, "flow:")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
consumer, err := New(store, handler, config)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return consumer
|
||||
}
|
||||
|
||||
func TestRoundSummaryAccumulatesAndFlushes(t *testing.T) {
|
||||
fake := &fakeRedis{responses: []*gvar.Var{gvar.New([]any{"runId", "run-1"})}}
|
||||
consumer := newTestConsumer(t, fake, &testHandler{}, Config{RoundRetryEnabled: true})
|
||||
started := time.Date(2026, 9, 22, 3, 0, 0, 0, time.UTC)
|
||||
consumer.recordRoundBatch("run-1", 1, []queue.ClaimedTask{
|
||||
claimedTask(queue.Task{RunID: "run-1", TaskID: "a"}),
|
||||
claimedTask(queue.Task{RunID: "run-1", TaskID: "b"}),
|
||||
}, []queue.TaskOutcome{
|
||||
{Success: true},
|
||||
{Retry: true},
|
||||
}, started)
|
||||
consumer.recordRoundBatch("run-1", 1, []queue.ClaimedTask{
|
||||
claimedTask(queue.Task{RunID: "run-1", TaskID: "c"}),
|
||||
}, []queue.TaskOutcome{{Success: false}}, started.Add(2*time.Second))
|
||||
|
||||
key := "run-1\x001"
|
||||
consumer.summaryMu.Lock()
|
||||
summary := *consumer.roundSummaries[key]
|
||||
consumer.summaryMu.Unlock()
|
||||
if summary.inputCount != 3 || summary.successCount != 1 || summary.failedCount != 2 || summary.deferredCount != 1 || summary.terminalFailedCount != 1 {
|
||||
t.Fatalf("unexpected summary: %+v", summary)
|
||||
}
|
||||
if !summary.startedAt.Equal(started) {
|
||||
t.Fatalf("startedAt = %v, want %v", summary.startedAt, started)
|
||||
}
|
||||
|
||||
consumer.flushRoundSummary(context.Background(), "run-1", 1)
|
||||
consumer.summaryMu.Lock()
|
||||
_, exists := consumer.roundSummaries[key]
|
||||
consumer.summaryMu.Unlock()
|
||||
if exists {
|
||||
t.Fatal("round summary was not removed after flush")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleClaimsAppliesSuccessOnceAndAcknowledgesBatch(t *testing.T) {
|
||||
fake := &fakeRedis{responses: []*gvar.Var{gvar.New(int64(2))}}
|
||||
applyCalls := 0
|
||||
var applied []Result
|
||||
handler := &testHandler{apply: func(_ context.Context, results []Result) error {
|
||||
applyCalls++
|
||||
applied = append(applied, results...)
|
||||
return nil
|
||||
}}
|
||||
consumer := newTestConsumer(t, fake, handler, Config{ClaimBatch: 2, MobileConcurrency: 2, TelecomConcurrency: 1})
|
||||
claimed := []queue.ClaimedTask{
|
||||
claimedTask(queue.Task{TaskID: "1", RunID: "run", ChanKey: "mobile-a", TempKey: mobileTempKey, Operator: 1, CardID: 1}),
|
||||
claimedTask(queue.Task{TaskID: "2", RunID: "run", ChanKey: "mobile-a", TempKey: mobileTempKey, Operator: 1, CardID: 2}),
|
||||
}
|
||||
if err := consumer.handleClaims(context.Background(), claimed); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if applyCalls != 1 || len(applied) != 2 {
|
||||
t.Fatalf("apply calls = %d, results = %d", applyCalls, len(applied))
|
||||
}
|
||||
if len(fake.calls) != 1 || fake.calls[0].command != "EVAL" {
|
||||
t.Fatalf("Redis calls = %+v, want one batch ack", fake.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDatabaseFailureLeavesEntireBatchLeased(t *testing.T) {
|
||||
fake := &fakeRedis{}
|
||||
handler := &testHandler{
|
||||
query: func(_ context.Context, task queue.Task) Result {
|
||||
if task.CardID == 2 {
|
||||
return Result{Task: task, ErrorClass: "business", Code: 11011, Message: "invalid card"}
|
||||
}
|
||||
return Result{Task: task, Success: true, UsageMB: 1.5}
|
||||
},
|
||||
apply: func(context.Context, []Result) error { return errors.New("database unavailable") },
|
||||
}
|
||||
consumer := newTestConsumer(t, fake, handler, Config{ClaimBatch: 2, MobileConcurrency: 2, TelecomConcurrency: 1})
|
||||
claimed := []queue.ClaimedTask{
|
||||
claimedTask(queue.Task{TaskID: "success", RunID: "run", ChanKey: "mobile-a", TempKey: mobileTempKey, Operator: 1, CardID: 1}),
|
||||
claimedTask(queue.Task{TaskID: "failed", RunID: "run", ChanKey: "mobile-a", TempKey: mobileTempKey, Operator: 1, CardID: 2}),
|
||||
}
|
||||
err := consumer.handleClaims(context.Background(), claimed)
|
||||
if err == nil || err.Error() != "apply monthly usage result batch: database unavailable" {
|
||||
t.Fatalf("handleClaims() error = %v", err)
|
||||
}
|
||||
if len(fake.calls) != 0 {
|
||||
t.Fatalf("Redis calls = %+v, want no ACK when database update fails", fake.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBusinessFailuresAreTerminalWithoutRetry(t *testing.T) {
|
||||
fake := &fakeRedis{responses: []*gvar.Var{gvar.New(int64(2))}}
|
||||
failureCount := 0
|
||||
handler := &testHandler{query: func(_ context.Context, task queue.Task) Result {
|
||||
return Result{Task: task, ErrorClass: "business", Code: 13000, Message: "busy"}
|
||||
}}
|
||||
consumer := newTestConsumer(t, fake, handler, Config{
|
||||
MobileConcurrency: 2,
|
||||
TelecomConcurrency: 1,
|
||||
OnFailure: func(context.Context, Result) {
|
||||
failureCount++
|
||||
},
|
||||
})
|
||||
claimed := []queue.ClaimedTask{
|
||||
claimedTask(queue.Task{TaskID: "1", RunID: "run", ChanKey: "mobile-a", TempKey: mobileTempKey, Operator: 1, CardID: 1}),
|
||||
claimedTask(queue.Task{TaskID: "2", RunID: "run", ChanKey: "mobile-a", TempKey: mobileTempKey, Operator: 1, CardID: 2}),
|
||||
}
|
||||
if err := consumer.handleClaims(context.Background(), claimed); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if failureCount != 2 {
|
||||
t.Fatalf("failure count = %d, want 2", failureCount)
|
||||
}
|
||||
if len(fake.calls) != 1 || fake.calls[0].command != "EVAL" {
|
||||
t.Fatalf("Redis calls = %+v; business failures must only be acknowledged", fake.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRetryableFailuresRemainLeasedWhileSuccessesAreAcknowledged(t *testing.T) {
|
||||
fake := &fakeRedis{responses: []*gvar.Var{gvar.New(int64(1))}}
|
||||
var applied []Result
|
||||
handler := &testHandler{
|
||||
query: func(_ context.Context, task queue.Task) Result {
|
||||
if task.CardID == 1 {
|
||||
return Result{Task: task, ErrorClass: "network", Retryable: true, Message: "temporary failure"}
|
||||
}
|
||||
return Result{Task: task, Success: true, UsageMB: 2}
|
||||
},
|
||||
apply: func(_ context.Context, results []Result) error {
|
||||
applied = append(applied, results...)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
consumer := newTestConsumer(t, fake, handler, Config{MobileConcurrency: 2, TelecomConcurrency: 1})
|
||||
claimed := []queue.ClaimedTask{
|
||||
claimedTask(queue.Task{TaskID: "retry", RunID: "run", ChanKey: "mobile-a", Operator: 1, CardID: 1}),
|
||||
claimedTask(queue.Task{TaskID: "success", RunID: "run", ChanKey: "mobile-a", Operator: 1, CardID: 2}),
|
||||
}
|
||||
if err := consumer.handleClaims(context.Background(), claimed); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(applied) != 2 {
|
||||
t.Fatalf("applied results = %d, want both result rows for handler", len(applied))
|
||||
}
|
||||
if len(fake.calls) != 1 || fake.calls[0].command != "EVAL" {
|
||||
t.Fatalf("Redis calls = %+v, want one ACK for successful/terminal result", fake.calls)
|
||||
}
|
||||
if len(fake.calls[0].args) == 0 {
|
||||
t.Fatal("ACK call did not include arguments")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunRejectsAnotherActiveConsumer(t *testing.T) {
|
||||
fake := &fakeRedis{responses: []*gvar.Var{gvar.New(nil)}}
|
||||
consumer := newTestConsumer(t, fake, &testHandler{}, Config{})
|
||||
if err := consumer.Run(context.Background()); !errors.Is(err, ErrAlreadyRunning) {
|
||||
t.Fatalf("Run error = %v, want ErrAlreadyRunning", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessOnceStartsEveryRegisteredChannel(t *testing.T) {
|
||||
fake := &fakeRedis{responses: []*gvar.Var{
|
||||
gvar.New([]any{"mobile-a", "telecom-a", "mobile-b"}),
|
||||
gvar.New([]any{}),
|
||||
gvar.New([]any{}),
|
||||
gvar.New([]any{}),
|
||||
}}
|
||||
consumer := newTestConsumer(t, fake, &testHandler{}, Config{})
|
||||
processed, err := consumer.processOnce(context.Background())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !processed {
|
||||
t.Fatal("processOnce() did not start registered channels")
|
||||
}
|
||||
deadline := time.Now().Add(time.Second)
|
||||
for {
|
||||
fake.mu.Lock()
|
||||
callCount := len(fake.calls)
|
||||
fake.mu.Unlock()
|
||||
if callCount == 4 {
|
||||
break
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
t.Fatalf("Redis calls = %d, want channels + 3 claims", callCount)
|
||||
}
|
||||
time.Sleep(time.Millisecond)
|
||||
}
|
||||
consumer.channelGroup.Wait()
|
||||
if len(fake.calls) != 4 {
|
||||
t.Fatalf("Redis calls = %d, want channels + 3 claims", len(fake.calls))
|
||||
}
|
||||
}
|
||||
|
||||
type channelRedis struct {
|
||||
mu sync.Mutex
|
||||
claims map[string][]queue.Task
|
||||
claimCounts map[string]int
|
||||
}
|
||||
|
||||
func (r *channelRedis) Do(_ context.Context, command string, _ ...any) (*gvar.Var, error) {
|
||||
return nil, errors.New("unexpected command " + command)
|
||||
}
|
||||
|
||||
func (r *channelRedis) Eval(_ context.Context, script string, _ int64, keys []string, args []any) (*gvar.Var, error) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
if strings.Contains(script, "ZRANGEBYSCORE") {
|
||||
chanKey := strings.TrimPrefix(keys[0], "flow:")
|
||||
r.claimCounts[chanKey]++
|
||||
tasks := r.claims[chanKey]
|
||||
if len(tasks) == 0 {
|
||||
return gvar.New([]any{}), nil
|
||||
}
|
||||
task := tasks[0]
|
||||
r.claims[chanKey] = tasks[1:]
|
||||
return gvar.New([]any{taskMember(task), "token-" + task.TaskID}), nil
|
||||
}
|
||||
if len(keys) == 2 && len(args) == 2 {
|
||||
return gvar.New(int64(1)), nil
|
||||
}
|
||||
return nil, errors.New("unexpected eval")
|
||||
}
|
||||
|
||||
func TestSlowChannelDoesNotBlockAnotherChannelFromClaimingAgain(t *testing.T) {
|
||||
redis := &channelRedis{
|
||||
claims: map[string][]queue.Task{
|
||||
"telecom-a": {{TaskID: "t1", RunID: "run", ChanKey: "telecom-a", TempKey: telecomTempKey, Operator: 2}},
|
||||
"mobile-a": {
|
||||
{TaskID: "m1", RunID: "run", ChanKey: "mobile-a", TempKey: mobileTempKey, Operator: 1},
|
||||
{TaskID: "m2", RunID: "run", ChanKey: "mobile-a", TempKey: mobileTempKey, Operator: 1},
|
||||
},
|
||||
},
|
||||
claimCounts: make(map[string]int),
|
||||
}
|
||||
telecomStarted := make(chan struct{})
|
||||
releaseTelecom := make(chan struct{})
|
||||
handler := &testHandler{query: func(_ context.Context, task queue.Task) Result {
|
||||
if task.ChanKey == "telecom-a" {
|
||||
close(telecomStarted)
|
||||
<-releaseTelecom
|
||||
}
|
||||
return Result{Task: task, Success: true}
|
||||
}}
|
||||
store, err := queue.New(redis, "flow:")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
consumer, err := New(store, handler, Config{ClaimBatch: 1, MobileConcurrency: 1, TelecomConcurrency: 1, LeaseTTL: time.Minute})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
consumer.startChannel(ctx, "telecom-a")
|
||||
consumer.startChannel(ctx, "mobile-a")
|
||||
select {
|
||||
case <-telecomStarted:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("telecom channel did not start")
|
||||
}
|
||||
if consumer.startChannel(ctx, "telecom-a") {
|
||||
t.Fatal("channel executor started twice while first executor is running")
|
||||
}
|
||||
deadline := time.Now().Add(time.Second)
|
||||
for {
|
||||
redis.mu.Lock()
|
||||
mobileClaims := redis.claimCounts["mobile-a"]
|
||||
redis.mu.Unlock()
|
||||
if mobileClaims >= 2 {
|
||||
break
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
t.Fatalf("mobile claim count = %d while telecom is blocked", mobileClaims)
|
||||
}
|
||||
time.Sleep(time.Millisecond)
|
||||
}
|
||||
close(releaseTelecom)
|
||||
consumer.channelGroup.Wait()
|
||||
}
|
||||
|
||||
func TestClaimedBatchRenewsLeaseUntilAcknowledged(t *testing.T) {
|
||||
fake := &fakeRedis{}
|
||||
handler := &testHandler{query: func(_ context.Context, task queue.Task) Result {
|
||||
time.Sleep(45 * time.Millisecond)
|
||||
return Result{Task: task, Success: true}
|
||||
}}
|
||||
consumer := newTestConsumer(t, fake, handler, Config{LeaseTTL: 30 * time.Millisecond, TaskTimeout: 20 * time.Millisecond, MobileConcurrency: 1, TelecomConcurrency: 1})
|
||||
claimed := []queue.ClaimedTask{claimedTask(queue.Task{TaskID: "1", RunID: "run", ChanKey: "mobile-a", TempKey: mobileTempKey, Operator: 1})}
|
||||
if err := consumer.handleClaims(context.Background(), claimed); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
fake.mu.Lock()
|
||||
defer fake.mu.Unlock()
|
||||
if len(fake.calls) < 2 {
|
||||
t.Fatalf("Redis calls = %d, want lease renewal and ACK", len(fake.calls))
|
||||
}
|
||||
}
|
||||
|
||||
func TestLostBatchLeasePreventsApplyAndAck(t *testing.T) {
|
||||
fake := &fakeRedis{responses: []*gvar.Var{gvar.New(int64(0))}}
|
||||
applyCalls := 0
|
||||
handler := &testHandler{
|
||||
query: func(ctx context.Context, task queue.Task) Result {
|
||||
<-ctx.Done()
|
||||
return Result{Task: task, ErrorClass: "canceled"}
|
||||
},
|
||||
apply: func(context.Context, []Result) error {
|
||||
applyCalls++
|
||||
return nil
|
||||
},
|
||||
}
|
||||
consumer := newTestConsumer(t, fake, handler, Config{LeaseTTL: 30 * time.Millisecond, TaskTimeout: 20 * time.Millisecond, MobileConcurrency: 1, TelecomConcurrency: 1})
|
||||
claimed := []queue.ClaimedTask{claimedTask(queue.Task{TaskID: "1", RunID: "run", ChanKey: "mobile-a", TempKey: mobileTempKey, Operator: 1})}
|
||||
err := consumer.handleClaims(context.Background(), claimed)
|
||||
if !errors.Is(err, ErrLeaseLost) {
|
||||
t.Fatalf("handleClaims() error = %v, want ErrLeaseLost", err)
|
||||
}
|
||||
if applyCalls != 0 {
|
||||
t.Fatalf("apply calls = %d, want 0 after lease loss", applyCalls)
|
||||
}
|
||||
fake.mu.Lock()
|
||||
defer fake.mu.Unlock()
|
||||
if len(fake.calls) != 1 {
|
||||
t.Fatalf("Redis calls = %d, want only failed lease renewal", len(fake.calls))
|
||||
}
|
||||
}
|
||||
|
||||
func newTrackingHandler(release chan struct{}, current, maximum *atomic.Int32) *testHandler {
|
||||
return &testHandler{query: func(_ context.Context, task queue.Task) Result {
|
||||
active := current.Add(1)
|
||||
for {
|
||||
observed := maximum.Load()
|
||||
if active <= observed || maximum.CompareAndSwap(observed, active) {
|
||||
break
|
||||
}
|
||||
}
|
||||
<-release
|
||||
current.Add(-1)
|
||||
return Result{Task: task, Success: true}
|
||||
}}
|
||||
}
|
||||
|
||||
func TestMobileChannelsHaveIndependentConcurrencyPools(t *testing.T) {
|
||||
var current atomic.Int32
|
||||
var maximum atomic.Int32
|
||||
release := make(chan struct{})
|
||||
consumer := newTestConsumer(t, &fakeRedis{}, newTrackingHandler(release, ¤t, &maximum), Config{MobileConcurrency: 2, TelecomConcurrency: 1})
|
||||
var group sync.WaitGroup
|
||||
for index := 0; index < 3; index++ {
|
||||
group.Add(1)
|
||||
go func(index int) {
|
||||
defer group.Done()
|
||||
task := queue.Task{TaskID: string(rune('1' + index)), ChanKey: "mobile-" + string(rune('a'+index)), TempKey: mobileTempKey, CardID: int64(index + 1)}
|
||||
_ = consumer.queryClaim(context.Background(), claimedTask(task), carrierMobile, mobileQPS)
|
||||
}(index)
|
||||
}
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
if maximum.Load() != 3 {
|
||||
t.Fatalf("maximum in-flight across channels = %d, want 3 (one pool per channel)", maximum.Load())
|
||||
}
|
||||
close(release)
|
||||
group.Wait()
|
||||
}
|
||||
|
||||
func TestChannelConcurrencyPoolCapsWithinChannel(t *testing.T) {
|
||||
var current atomic.Int32
|
||||
var maximum atomic.Int32
|
||||
release := make(chan struct{})
|
||||
consumer := newTestConsumer(t, &fakeRedis{}, newTrackingHandler(release, ¤t, &maximum), Config{MobileConcurrency: 2, TelecomConcurrency: 1})
|
||||
var group sync.WaitGroup
|
||||
for index := 0; index < 3; index++ {
|
||||
group.Add(1)
|
||||
go func(index int) {
|
||||
defer group.Done()
|
||||
task := queue.Task{TaskID: string(rune('1' + index)), ChanKey: "mobile-a", TempKey: mobileTempKey, CardID: int64(index + 1)}
|
||||
_ = consumer.queryClaim(context.Background(), claimedTask(task), carrierMobile, mobileQPS)
|
||||
}(index)
|
||||
}
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
if maximum.Load() != 2 {
|
||||
t.Fatalf("maximum in-flight within one channel = %d, want 2", maximum.Load())
|
||||
}
|
||||
close(release)
|
||||
group.Wait()
|
||||
}
|
||||
|
||||
func TestMobileAndTelecomUseIndependentChannelPools(t *testing.T) {
|
||||
started := make(chan carrier, 2)
|
||||
release := make(chan struct{})
|
||||
handler := &testHandler{query: func(_ context.Context, task queue.Task) Result {
|
||||
channelCarrier, _ := policyForTemplate(task.TempKey, Config{})
|
||||
started <- channelCarrier
|
||||
<-release
|
||||
return Result{Task: task, Success: true}
|
||||
}}
|
||||
consumer := newTestConsumer(t, &fakeRedis{}, handler, Config{MobileConcurrency: 1, TelecomConcurrency: 1})
|
||||
go func() {
|
||||
task := queue.Task{TaskID: "mobile", ChanKey: "mobile-a", TempKey: mobileTempKey, Operator: 1}
|
||||
_ = consumer.queryClaim(context.Background(), claimedTask(task), carrierMobile, mobileQPS)
|
||||
}()
|
||||
go func() {
|
||||
task := queue.Task{TaskID: "telecom", ChanKey: "telecom-a", TempKey: telecomTempKey, Operator: 2}
|
||||
_ = consumer.queryClaim(context.Background(), claimedTask(task), carrierTelecom, telecomQPS)
|
||||
}()
|
||||
|
||||
seen := map[carrier]bool{}
|
||||
for len(seen) < 2 {
|
||||
select {
|
||||
case value := <-started:
|
||||
seen[value] = true
|
||||
case <-time.After(time.Second):
|
||||
t.Fatalf("carrier pools did not progress independently: %+v", seen)
|
||||
}
|
||||
}
|
||||
close(release)
|
||||
}
|
||||
|
||||
func TestUnknownTemplateUsesGenericStrategy(t *testing.T) {
|
||||
fake := &fakeRedis{responses: []*gvar.Var{gvar.New(int64(2))}}
|
||||
queryCalls := 0
|
||||
failures := 0
|
||||
applyCalls := 0
|
||||
handler := &testHandler{
|
||||
query: func(_ context.Context, task queue.Task) Result {
|
||||
queryCalls++
|
||||
return Result{Task: task, Success: true, UsageMB: 1}
|
||||
},
|
||||
apply: func(_ context.Context, results []Result) error {
|
||||
applyCalls++
|
||||
for _, result := range results {
|
||||
if !result.Success || result.BlockChannel {
|
||||
t.Fatalf("unexpected result = %+v", result)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
consumer := newTestConsumer(t, fake, handler, Config{OnFailure: func(context.Context, Result) { failures++ }})
|
||||
claimed := []queue.ClaimedTask{
|
||||
claimedTask(queue.Task{TaskID: "1", RunID: "run", ChanKey: "unknown-a", TempKey: "unknown-template", Operator: 1, CardID: 1}),
|
||||
claimedTask(queue.Task{TaskID: "2", RunID: "run", ChanKey: "unknown-a", TempKey: "unknown-template", Operator: 2, CardID: 2}),
|
||||
}
|
||||
if err := consumer.handleClaims(context.Background(), claimed); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if queryCalls != 2 || failures != 0 || applyCalls != 1 {
|
||||
t.Fatalf("query calls = %d, failures = %d, apply calls = %d", queryCalls, failures, applyCalls)
|
||||
}
|
||||
if len(fake.calls) != 1 || fake.calls[0].command != "EVAL" {
|
||||
t.Fatalf("Redis calls = %+v, want one ack script", fake.calls)
|
||||
}
|
||||
if len(fake.responses) != 0 {
|
||||
t.Fatalf("unexpected leftover scripted responses: %+v", fake.responses)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChannelPacerLimitsEachChannelIndependently(t *testing.T) {
|
||||
pacer := newChannelPacer()
|
||||
now := time.Unix(100, 0)
|
||||
pacedQPS := 30.0
|
||||
if delay := pacer.reserve("mobile-a", pacedQPS, now); delay != 0 {
|
||||
t.Fatalf("first reservation delay = %s", delay)
|
||||
}
|
||||
if delay := pacer.reserve("mobile-a", pacedQPS, now); delay < 33*time.Millisecond || delay > 34*time.Millisecond {
|
||||
t.Fatalf("second reservation delay = %s", delay)
|
||||
}
|
||||
if delay := pacer.reserve("mobile-b", pacedQPS, now); delay != 0 {
|
||||
t.Fatalf("independent channel delay = %s", delay)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChannelPacerSkipsWhenQPSIsDisabled(t *testing.T) {
|
||||
pacer := newChannelPacer()
|
||||
now := time.Unix(100, 0)
|
||||
if delay := pacer.reserve("mobile-a", 0, now); delay != 0 {
|
||||
t.Fatalf("disabled qps delay = %s", delay)
|
||||
}
|
||||
if len(pacer.next) != 0 {
|
||||
t.Fatalf("disabled qps should not reserve channel slots, got %+v", pacer.next)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewDefaultsTaskTimeout(t *testing.T) {
|
||||
consumer := newTestConsumer(t, &fakeRedis{}, &testHandler{}, Config{})
|
||||
if consumer.config.TaskTimeout != 30*time.Second {
|
||||
t.Fatalf("TaskTimeout = %s, want 30s default", consumer.config.TaskTimeout)
|
||||
}
|
||||
if consumer.config.GenericQPS != defaultGenericQPS {
|
||||
t.Fatalf("GenericQPS = %v, want %v default", consumer.config.GenericQPS, defaultGenericQPS)
|
||||
}
|
||||
if _, err := New(consumer.store, &testHandler{}, Config{LeaseTTL: 10 * time.Second}); err == nil {
|
||||
t.Fatal("New() with LeaseTTL below default TaskTimeout should fail")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPolicyForTemplateUsesTemplateValues(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
tempKey string
|
||||
carrier carrier
|
||||
qps float64
|
||||
}{
|
||||
{name: "mobile", tempKey: mobileTempKey, carrier: carrierMobile, qps: mobileQPS},
|
||||
{name: "mobile mock", tempKey: mobileMockKey, carrier: carrierMobile, qps: mobileQPS},
|
||||
{name: "shared Tengyu", tempKey: sharedTengyuTempKey, carrier: carrierMobile, qps: mobileQPS},
|
||||
{name: "shared Xuzhou mobile", tempKey: sharedXuzhouMobileTempKey, carrier: carrierMobile, qps: mobileQPS},
|
||||
{name: "shared Lelian", tempKey: sharedLelianTempKey, carrier: carrierMobile, qps: mobileQPS},
|
||||
{name: "shared Qianxun", tempKey: sharedQianxunTempKey, carrier: carrierMobile, qps: mobileQPS},
|
||||
{name: "shared Henan", tempKey: sharedHenanTempKey, carrier: carrierMobile, qps: mobileQPS},
|
||||
{name: "shared Daihe", tempKey: sharedDaiheTempKey, carrier: carrierMobile, qps: mobileQPS},
|
||||
{name: "shared Beixin", tempKey: sharedBeixinTempKey, carrier: carrierMobile, qps: mobileQPS},
|
||||
// lanshang_8000/8003 are OneLink shared-token templates whose names
|
||||
// omit the sim_onelink_ prefix. Before this case was added they fell
|
||||
// through to carrierGeneric and were throttled to 1.5 QPS, which made a
|
||||
// 6k-card status/traffic/IMEI run take over an hour per channel.
|
||||
{name: "shared Lanshang 8000", tempKey: sharedLanshang8000TempKey, carrier: carrierMobile, qps: mobileQPS},
|
||||
{name: "shared Lanshang 8003", tempKey: sharedLanshang8003TempKey, carrier: carrierMobile, qps: mobileQPS},
|
||||
{name: "shared Lanshang case and padding", tempKey: " LANSHANG_8000 ", carrier: carrierMobile, qps: mobileQPS},
|
||||
{name: "telecom", tempKey: telecomTempKey, carrier: carrierTelecom, qps: telecomQPS},
|
||||
{name: "telecom v3", tempKey: " SIM_DX_V3 ", carrier: carrierTelecom, qps: telecomQPS},
|
||||
{name: "unknown telecom version", tempKey: "sim_dx_v2_future", carrier: carrierGeneric, qps: defaultGenericQPS},
|
||||
{name: "unknown template", tempKey: "unknown-template", carrier: carrierGeneric, qps: defaultGenericQPS},
|
||||
{name: "missing template", tempKey: "", carrier: carrierGeneric, qps: defaultGenericQPS},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
gotCarrier, gotQPS := policyForTemplate(test.tempKey, Config{})
|
||||
if gotCarrier != test.carrier || gotQPS != test.qps {
|
||||
t.Fatalf("policyForTemplate(%q) = %q,%v", test.tempKey, gotCarrier, gotQPS)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPolicyForTemplateConfigurationOverridesDefaults(t *testing.T) {
|
||||
config := Config{
|
||||
GenericQPS: 2.5,
|
||||
TemplateQPS: map[string]float64{
|
||||
" SIM_YD_V5 ": 12,
|
||||
"unknown-template": 3,
|
||||
"invalid": 0,
|
||||
},
|
||||
}
|
||||
config.setDefaults()
|
||||
|
||||
if gotCarrier, gotQPS := policyForTemplate("sim_yd_v5", config); gotCarrier != carrierMobile || gotQPS != 12 {
|
||||
t.Fatalf("mobile override = %q,%v, want mobile,12", gotCarrier, gotQPS)
|
||||
}
|
||||
if gotCarrier, gotQPS := policyForTemplate("unknown-template", config); gotCarrier != carrierGeneric || gotQPS != 3 {
|
||||
t.Fatalf("generic override = %q,%v, want generic,3", gotCarrier, gotQPS)
|
||||
}
|
||||
if gotCarrier, gotQPS := policyForTemplate("other-template", config); gotCarrier != carrierGeneric || gotQPS != 2.5 {
|
||||
t.Fatalf("generic default = %q,%v, want generic,2.5", gotCarrier, gotQPS)
|
||||
}
|
||||
}
|
||||
|
||||
// TestFailurePoolAlertThresholdIsObservabilityOnly 覆盖失败池告警:
|
||||
// - 未配置阀值(0)时不发 HLEN 查询
|
||||
// - 失败数低于阀值时不告警
|
||||
// - 超过阀值时发一次 HLEN 并打 WARN,但绝不截断 Run
|
||||
//
|
||||
// 告警本身是纯观测行为,所以断言的是「有没有多打 Redis 命令」,
|
||||
// 而不是「Run 有没有被改坏」——后者必须永远为「没被改坏」。
|
||||
func TestFailurePoolAlertThresholdIsObservabilityOnly(t *testing.T) {
|
||||
newMeta := func(failed int64) queue.RunMeta {
|
||||
return queue.RunMeta{RunID: "run-1", Status: "completed_with_failures", FailedCount: failed, CurrentRound: 1}
|
||||
}
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
threshold int64
|
||||
failed int64
|
||||
wantHLenCalls int
|
||||
}{
|
||||
{"未配置阀值不查询", 0, 1_000_000, 0},
|
||||
{"低于阀值不告警", 50000, 49999, 0},
|
||||
{"等于阀值不告警", 50000, 50000, 0},
|
||||
{"超过阀值告警一次", 50000, 50001, 1},
|
||||
{"远超阀值也只告警一次", 50000, 500_000, 1},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
fake := &fakeRedis{}
|
||||
consumer := newTestConsumer(t, fake, &testHandler{}, Config{
|
||||
FailurePoolAlertThreshold: tc.threshold,
|
||||
})
|
||||
consumer.reportFailurePoolSize(context.Background(), "run-1", newMeta(tc.failed))
|
||||
|
||||
hlen := 0
|
||||
for _, call := range fake.calls {
|
||||
if call.command == "HLEN" {
|
||||
hlen++
|
||||
}
|
||||
}
|
||||
if hlen != tc.wantHLenCalls {
|
||||
t.Fatalf("%s: HLEN 调用 %d 次, want %d", tc.name, hlen, tc.wantHLenCalls)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestFailurePoolAlertReadsCorrectKey 告警必须查该 Run 自己的失败池键。
|
||||
func TestFailurePoolAlertReadsCorrectKey(t *testing.T) {
|
||||
fake := &fakeRedis{}
|
||||
consumer := newTestConsumer(t, fake, &testHandler{}, Config{FailurePoolAlertThreshold: 10})
|
||||
consumer.reportFailurePoolSize(context.Background(), "run-abc", queue.RunMeta{
|
||||
RunID: "run-abc", Status: "completed_with_failures", FailedCount: 11, CurrentRound: 1,
|
||||
})
|
||||
|
||||
var key string
|
||||
for _, call := range fake.calls {
|
||||
if call.command == "HLEN" {
|
||||
if len(call.args) > 0 {
|
||||
key, _ = call.args[0].(string)
|
||||
}
|
||||
}
|
||||
}
|
||||
want, _ := consumer.store.FailedKey("run-abc")
|
||||
if key != want {
|
||||
t.Fatalf("HLEN key = %q, want %q", key, want)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,480 @@
|
||||
package zset
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
queue "git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/queue/zset"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/testutil"
|
||||
)
|
||||
|
||||
type runHandler struct {
|
||||
query func(queue.Task) Result
|
||||
apply func([]Result) error
|
||||
}
|
||||
|
||||
func (h runHandler) Query(_ context.Context, task queue.Task) Result { return h.query(task) }
|
||||
func (h runHandler) Apply(_ context.Context, results []Result) error {
|
||||
if h.apply != nil {
|
||||
return h.apply(results)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func runTasks(t *testing.T, store *queue.Store, run string, count int) []queue.Task {
|
||||
t.Helper()
|
||||
ctx := context.Background()
|
||||
if err := store.CreateRun(ctx, queue.RunMeta{RunID: run}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
tasks := make([]queue.Task, count)
|
||||
for i := range tasks {
|
||||
tasks[i] = queue.Task{RunID: run, TaskID: fmt.Sprintf("%s:%d", run, i), CardID: int64(i + 1), ChanKey: "channel", TempKey: mobileTempKey, ICCID: fmt.Sprintf("card-%d", i)}
|
||||
}
|
||||
if n, err := store.EnqueueRunBatch(ctx, run, "channel", tasks, time.Now()); err != nil || n != count {
|
||||
t.Fatalf("enqueue %d: %v", n, err)
|
||||
}
|
||||
return tasks
|
||||
}
|
||||
|
||||
func runMeta(t *testing.T, store *queue.Store, run string) queue.RunMeta {
|
||||
t.Helper()
|
||||
meta, err := store.GetRunMeta(context.Background(), run)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return meta
|
||||
}
|
||||
|
||||
func runConsumer(t *testing.T, store *queue.Store, handler Handler, rounds bool) *Consumer {
|
||||
t.Helper()
|
||||
c, err := New(store, handler, Config{RunQueueEnabled: true, RoundRetryEnabled: rounds, BlockedTasksAsFailures: !rounds, EventPrefix: "sim_status_queue", TemplateQPS: map[string]float64{mobileTempKey: 1000000}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
func claimRun(t *testing.T, store *queue.Store, run string, now time.Time, count int) []queue.ClaimedTask {
|
||||
t.Helper()
|
||||
claims, err := store.ClaimRun(context.Background(), run, "channel", now, now.Add(time.Minute), count)
|
||||
if err != nil || len(claims) != count {
|
||||
t.Fatalf("claim got %d want %d: %v", len(claims), count, err)
|
||||
}
|
||||
return claims
|
||||
}
|
||||
|
||||
func finishProducer(t *testing.T, store *queue.Store, run string) {
|
||||
t.Helper()
|
||||
if err := store.MarkProducerDone(context.Background(), run); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func checkTerminal(t *testing.T, store *queue.Store, run string, success, failed int64) {
|
||||
t.Helper()
|
||||
meta := runMeta(t, store, run)
|
||||
want := "completed"
|
||||
if failed > 0 {
|
||||
want = "completed_with_failures"
|
||||
}
|
||||
if meta.Status != want || meta.CompletedAt.IsZero() || meta.SuccessCount != success || meta.FailedCount != failed || meta.ActiveCount != 0 || meta.InflightCount != 0 || meta.NextRoundCount != 0 {
|
||||
t.Fatalf("unexpected terminal metadata: %+v", meta)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunConsumerCountsAndCompletes(t *testing.T) {
|
||||
redis := testutil.Redis(t)
|
||||
ctx := context.Background()
|
||||
for _, failure := range []bool{false, true} {
|
||||
t.Run(fmt.Sprint(failure), func(t *testing.T) {
|
||||
store, _ := queue.New(redis, "sim-status:")
|
||||
run := fmt.Sprint("counts-", failure)
|
||||
tasks := runTasks(t, store, run, 3)
|
||||
if n, err := store.EnqueueRunBatch(ctx, run, "channel", tasks, time.Now()); err != nil || n != 0 {
|
||||
t.Fatalf("duplicate enqueue: %d %v", n, err)
|
||||
}
|
||||
finishProducer(t, store, run)
|
||||
handler := runHandler{query: func(task queue.Task) Result {
|
||||
return Result{Task: task, Success: !failure || task.CardID != 2, ErrorClass: "business"}
|
||||
}, apply: func(_ []Result) error {
|
||||
if meta := runMeta(t, store, run); meta.SuccessCount != 0 || meta.FailedCount != 0 {
|
||||
return errors.New("counted before DB apply")
|
||||
}
|
||||
return nil
|
||||
}}
|
||||
c := runConsumer(t, store, handler, false)
|
||||
claims := claimRun(t, store, run, time.Now(), 3)
|
||||
if err := c.handleRunClaims(ctx, claims); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var failures int64
|
||||
if failure {
|
||||
failures = 1
|
||||
}
|
||||
checkTerminal(t, store, run, 3-failures, failures)
|
||||
outcomes := []queue.TaskOutcome{{Success: true}, {Success: true}, {Success: true}}
|
||||
if n, err := store.FinalizeRunTasks(ctx, claims, outcomes); err != nil || n != 0 {
|
||||
t.Fatalf("duplicate finalize: %d %v", n, err)
|
||||
}
|
||||
checkTerminal(t, store, run, 3-failures, failures)
|
||||
key, _ := store.RunMetaKey(run)
|
||||
ttl, err := redis.Do(ctx, "TTL", key)
|
||||
if err != nil || ttl.Int64() < 29*24*3600 {
|
||||
t.Fatalf("retention=%v err=%v", ttl, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunConsumerLateProducerEmptyAndHistoricalRunCompletion(t *testing.T) {
|
||||
redis := testutil.Redis(t)
|
||||
store, _ := queue.New(redis, "sim-status:")
|
||||
ctx := context.Background()
|
||||
if err := store.CreateRun(ctx, queue.RunMeta{RunID: "historical", Status: "running", ProducerDone: true}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
runTasks(t, store, "empty", 0)
|
||||
runTasks(t, store, "late", 1)
|
||||
c := runConsumer(t, store, runHandler{query: func(task queue.Task) Result { return Result{Task: task, Success: true} }}, false)
|
||||
claims := claimRun(t, store, "late", time.Now(), 1)
|
||||
if err := c.handleRunClaims(ctx, claims); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if meta := runMeta(t, store, "late"); !meta.CompletedAt.IsZero() || meta.SuccessCount != 1 {
|
||||
t.Fatalf("premature completion: %+v", meta)
|
||||
}
|
||||
finishProducer(t, store, "late")
|
||||
finishProducer(t, store, "empty")
|
||||
if _, err := c.processOnce(ctx); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
c.channelGroup.Wait()
|
||||
checkTerminal(t, store, "late", 1, 0)
|
||||
checkTerminal(t, store, "empty", 0, 0)
|
||||
checkTerminal(t, store, "historical", 0, 0)
|
||||
}
|
||||
|
||||
func TestRunConsumerRecoveryAndFencing(t *testing.T) {
|
||||
redis := testutil.Redis(t)
|
||||
store, _ := queue.New(redis, "sim-status:")
|
||||
ctx := context.Background()
|
||||
for _, mode := range []string{"database", "network", "stale"} {
|
||||
t.Run(mode, func(t *testing.T) {
|
||||
runTasks(t, store, mode, 1)
|
||||
finishProducer(t, store, mode)
|
||||
now := time.Now()
|
||||
claims := claimRun(t, store, mode, now, 1)
|
||||
good := runHandler{query: func(task queue.Task) Result { return Result{Task: task, Success: true} }}
|
||||
handler := good
|
||||
if mode == "database" {
|
||||
handler.apply = func([]Result) error { return errors.New("database unavailable") }
|
||||
}
|
||||
if mode == "network" {
|
||||
handler.query = func(task queue.Task) Result { return Result{Task: task, Retryable: true, ErrorClass: "network"} }
|
||||
}
|
||||
c := runConsumer(t, store, handler, false)
|
||||
if mode != "stale" {
|
||||
err := c.handleRunClaims(ctx, claims)
|
||||
if (err != nil) != (mode == "database") {
|
||||
t.Fatalf("handle: %v", err)
|
||||
}
|
||||
if meta := runMeta(t, store, mode); meta.SuccessCount != 0 || meta.FailedCount != 0 || meta.ActiveCount != 1 || meta.InflightCount != 1 || meta.Status != "running" {
|
||||
t.Fatalf("lost pending task: %+v", meta)
|
||||
}
|
||||
}
|
||||
fresh := claimRun(t, store, mode, now.Add(2*time.Minute), 1)
|
||||
if n, err := store.RenewRunBatch(ctx, claims, now.Add(3*time.Minute)); err != nil || n != 0 {
|
||||
t.Fatalf("stale renewal: %d %v", n, err)
|
||||
}
|
||||
if n, err := store.FinalizeRunTasks(ctx, claims, []queue.TaskOutcome{{Success: true}}); err != nil || n != 0 {
|
||||
t.Fatalf("stale ack: %d %v", n, err)
|
||||
}
|
||||
if n, err := store.BlockRunScopedAsFailed(ctx, claims[0], queue.TaskOutcome{}); err != nil || n != -1 {
|
||||
t.Fatalf("stale block: %d %v", n, err)
|
||||
}
|
||||
c.handler = good
|
||||
if err := c.handleRunClaims(ctx, fresh); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
checkTerminal(t, store, mode, 1, 0)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunConsumerBlockedChannelCountsOnlyUnfinishedTasks(t *testing.T) {
|
||||
redis := testutil.Redis(t)
|
||||
store, _ := queue.New(redis, "sim-status:")
|
||||
ctx := context.Background()
|
||||
runTasks(t, store, "blocked", 5)
|
||||
runTasks(t, store, "other", 1)
|
||||
// Production continues while the channel is blocked.
|
||||
claims := claimRun(t, store, "blocked", time.Now(), 2)
|
||||
var applied int
|
||||
successQueried := make(chan struct{})
|
||||
handler := runHandler{query: func(task queue.Task) Result {
|
||||
if task.CardID == 1 {
|
||||
close(successQueried)
|
||||
return Result{Task: task, Success: true}
|
||||
}
|
||||
<-successQueried
|
||||
return Result{Task: task, BlockChannel: true, ErrorClass: "channel_limited", Code: 797, ErrCode: "S00004", Message: "limited"}
|
||||
}, apply: func(results []Result) error { applied = countSuccessful(results); return nil }}
|
||||
c := runConsumer(t, store, handler, false)
|
||||
// Ensure the success response arrives before the channel-limit response.
|
||||
if err := c.handleRunClaims(ctx, claims); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
meta := runMeta(t, store, "blocked")
|
||||
if applied != 1 || meta.SuccessCount != 1 || meta.FailedCount != 4 || meta.ActiveCount != 0 || meta.InflightCount != 0 {
|
||||
t.Fatalf("applied=%d meta=%+v", applied, meta)
|
||||
}
|
||||
if n, err := store.BlockRunScopedAsFailed(ctx, claims[1], queue.TaskOutcome{}); err != nil || n != -1 {
|
||||
t.Fatalf("duplicate block: %d %v", n, err)
|
||||
}
|
||||
late := queue.Task{TaskID: "late", RunID: "blocked", ChanKey: "channel", CardID: 99}
|
||||
if n, err := store.EnqueueRunBatch(ctx, "blocked", "channel", []queue.Task{late}, time.Now()); err != nil || n != 0 {
|
||||
t.Fatalf("blocked enqueue: %d %v", n, err)
|
||||
}
|
||||
key, _ := store.FailedKey("blocked")
|
||||
failures, err := redis.Do(ctx, "HLEN", key)
|
||||
if err != nil || failures.Int() != 4 {
|
||||
t.Fatalf("failure records=%v err=%v", failures, err)
|
||||
}
|
||||
stored, err := redis.Do(ctx, "HGET", key, "blocked:4")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var record queue.FailureRecord
|
||||
if err := json.Unmarshal([]byte(stored.String()), &record); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if record.TaskID != "blocked:4" || record.CardID != 5 || record.ICCID != "card-4" || record.Attempts != 0 || record.FailureType != "channel_blocked" || record.ErrCode != "S00004" {
|
||||
t.Fatalf("failure identity/reason: %+v", record)
|
||||
}
|
||||
stateKey, _ := store.TaskStateKey("blocked")
|
||||
state, err := redis.Do(ctx, "HGET", stateKey, "blocked:4")
|
||||
if err != nil || state.MapStrAny()["status"] != "channel_blocked" {
|
||||
t.Fatalf("blocked state=%v err=%v", state, err)
|
||||
}
|
||||
if meta := runMeta(t, store, "other"); meta.ActiveCount != 1 || meta.FailedCount != 0 {
|
||||
t.Fatalf("other run affected: %+v", meta)
|
||||
}
|
||||
finishProducer(t, store, "blocked")
|
||||
if err := c.completeRun(ctx, "blocked"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
checkTerminal(t, store, "blocked", 1, 4)
|
||||
}
|
||||
|
||||
func TestRunConsumerMonthlyRoundsRemainEnabled(t *testing.T) {
|
||||
redis := testutil.Redis(t)
|
||||
store, _ := queue.New(redis, "flow:")
|
||||
ctx := context.Background()
|
||||
runTasks(t, store, "monthly", 1)
|
||||
finishProducer(t, store, "monthly")
|
||||
c := runConsumer(t, store, runHandler{query: func(task queue.Task) Result { return Result{Task: task, ErrorClass: "mobile_busy", Retryable: true} }}, true)
|
||||
for round := 1; round <= 5; round++ {
|
||||
if err := c.handleRunClaims(ctx, claimRun(t, store, "monthly", time.Now(), 1)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if round < 5 {
|
||||
if meta := runMeta(t, store, "monthly"); meta.NextRoundCount != 1 || meta.FailedCount != 0 || meta.Status != "running" {
|
||||
t.Fatalf("round %d: %+v", round, meta)
|
||||
}
|
||||
if ok, err := store.AdvanceRunRound(ctx, "monthly", 5); err != nil || !ok {
|
||||
t.Fatalf("advance %v %v", ok, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
checkTerminal(t, store, "monthly", 0, 1)
|
||||
}
|
||||
|
||||
func TestRunConsumerStatusRetriesRetryableResultsForThreeRounds(t *testing.T) {
|
||||
redis := testutil.Redis(t)
|
||||
store, _ := queue.New(redis, "sim-status:")
|
||||
ctx := context.Background()
|
||||
runTasks(t, store, "status", 1)
|
||||
finishProducer(t, store, "status")
|
||||
c, err := New(store, runHandler{query: func(task queue.Task) Result {
|
||||
return Result{Task: task, ErrorClass: "carrier_retry", Retryable: true, Code: 9999, ErrCode: "S00004"}
|
||||
}}, Config{
|
||||
RunQueueEnabled: true,
|
||||
RoundRetryEnabled: true,
|
||||
MaxRounds: 3,
|
||||
EventPrefix: "sim_status_queue",
|
||||
ShouldRetryNextRound: func(result Result) bool {
|
||||
return result.Retryable
|
||||
},
|
||||
TemplateQPS: map[string]float64{mobileTempKey: 1000000},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for round := 1; round <= 3; round++ {
|
||||
if err := c.handleRunClaims(ctx, claimRun(t, store, "status", time.Now(), 1)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if round < 3 {
|
||||
if meta := runMeta(t, store, "status"); meta.NextRoundCount != 1 || meta.FailedCount != 0 || meta.Status != "running" {
|
||||
t.Fatalf("round %d: %+v", round, meta)
|
||||
}
|
||||
if ok, err := store.AdvanceRunRound(ctx, "status", 3); err != nil || !ok {
|
||||
t.Fatalf("advance %v %v", ok, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
checkTerminal(t, store, "status", 0, 1)
|
||||
}
|
||||
|
||||
func TestReplayMaxRoundOverridesConsumerDefault(t *testing.T) {
|
||||
redis := testutil.Redis(t)
|
||||
store, _ := queue.New(redis, "sim-status:")
|
||||
ctx := context.Background()
|
||||
if err := store.CreateRun(ctx, queue.RunMeta{RunID: "replay-one", Operation: "status", SyncType: "replay", MaxRound: 1}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
task := queue.Task{RunID: "replay-one", TaskID: "task", CardID: 1, ICCID: "card", ChanKey: "channel", TempKey: mobileTempKey}
|
||||
if _, err := store.EnqueueRunBatch(ctx, task.RunID, task.ChanKey, []queue.Task{task}, time.Now()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
finishProducer(t, store, "replay-one")
|
||||
c, err := New(store, runHandler{query: func(task queue.Task) Result { return Result{Task: task, ErrorClass: "mobile_busy", Retryable: true} }}, Config{RunQueueEnabled: true, RoundRetryEnabled: true, MaxRounds: 3, TemplateQPS: map[string]float64{mobileTempKey: 1000000}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := c.handleRunClaims(ctx, claimRun(t, store, "replay-one", time.Now(), 1)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
checkTerminal(t, store, "replay-one", 0, 1)
|
||||
meta := runMeta(t, store, "replay-one")
|
||||
if meta.CurrentRound != 1 || meta.NextRoundCount != 0 {
|
||||
t.Fatalf("replay advanced rounds: %+v", meta)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunQueueModeDoesNotEnableRoundRetry(t *testing.T) {
|
||||
redis := testutil.Redis(t)
|
||||
store, _ := queue.New(redis, "sim-status:")
|
||||
runTasks(t, store, "single-round", 1)
|
||||
finishProducer(t, store, "single-round")
|
||||
c := runConsumer(t, store, runHandler{query: func(task queue.Task) Result { return Result{Task: task, ErrorClass: "mobile_busy", Retryable: true} }}, false)
|
||||
if err := c.handleRunClaims(context.Background(), claimRun(t, store, "single-round", time.Now(), 1)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
checkTerminal(t, store, "single-round", 0, 1)
|
||||
if meta := runMeta(t, store, "single-round"); meta.CurrentRound != 1 {
|
||||
t.Fatalf("unexpected retry: %+v", meta)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMonthlyChannelBlockKeepsExistingCountingPolicy(t *testing.T) {
|
||||
redis := testutil.Redis(t)
|
||||
store, _ := queue.New(redis, "flow:")
|
||||
ctx := context.Background()
|
||||
runTasks(t, store, "monthly-block", 2)
|
||||
claimRun(t, store, "monthly-block", time.Now(), 1)
|
||||
for i := 0; i < 2; i++ {
|
||||
want := int64(2)
|
||||
if i == 1 {
|
||||
want = 0
|
||||
}
|
||||
if n, err := store.BlockRunScoped(ctx, "monthly-block", "channel"); err != nil || n != want {
|
||||
t.Fatalf("monthly block=%d err=%v", n, err)
|
||||
}
|
||||
}
|
||||
if meta := runMeta(t, store, "monthly-block"); meta.FailedCount != 0 || meta.ActiveCount != 0 || meta.InflightCount != 0 {
|
||||
t.Fatalf("monthly counting changed: %+v", meta)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunConsumerConcurrentRuns(t *testing.T) {
|
||||
redis := testutil.Redis(t)
|
||||
store, _ := queue.New(redis, "sim-status:")
|
||||
ctx := context.Background()
|
||||
var mu sync.Mutex
|
||||
applied := map[string]int{}
|
||||
c := runConsumer(t, store, runHandler{query: func(task queue.Task) Result { return Result{Task: task, Success: true} }, apply: func(results []Result) error {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
for _, result := range results {
|
||||
applied[result.Task.RunID]++
|
||||
}
|
||||
return nil
|
||||
}}, false)
|
||||
for _, run := range []string{"a", "b"} {
|
||||
runTasks(t, store, run, 3)
|
||||
finishProducer(t, store, run)
|
||||
}
|
||||
if started, err := c.processOnce(ctx); err != nil || !started {
|
||||
t.Fatalf("process: %v %v", started, err)
|
||||
}
|
||||
c.channelGroup.Wait()
|
||||
for _, run := range []string{"a", "b"} {
|
||||
checkTerminal(t, store, run, 3, 0)
|
||||
if applied[run] != 3 {
|
||||
t.Fatalf("applied %s: %d", run, applied[run])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestRunConsumerCoveredResultsCompleteWithoutFailures drives the full
|
||||
// consumer path for a covered result: the round must close, the run must end
|
||||
// as "completed" rather than "completed_with_failures", and the failure pool
|
||||
// must stay empty. This is the IMEI 14000 "carrier has no record" case — on
|
||||
// 2026-10-20 it accounted for every failure recorded in production while the
|
||||
// run success rate read 21%.
|
||||
func TestRunConsumerCoveredResultsCompleteWithoutFailures(t *testing.T) {
|
||||
redis := testutil.Redis(t)
|
||||
store, _ := queue.New(redis, "imei:")
|
||||
ctx := context.Background()
|
||||
runTasks(t, store, "covered", 4)
|
||||
finishProducer(t, store, "covered")
|
||||
queried := 0
|
||||
c := runConsumer(t, store, runHandler{query: func(task queue.Task) Result {
|
||||
queried++
|
||||
switch task.CardID {
|
||||
case 1:
|
||||
return Result{Task: task, Success: true, Imei: "865466070817587", ImeiSynced: true}
|
||||
case 2:
|
||||
return Result{Task: task, ErrorClass: "no_record", Code: 14000, ImeiSynced: true, Covered: true, Message: "系统无记录,请检查输入参数是否正确"}
|
||||
case 3:
|
||||
return Result{Task: task, ErrorClass: "data_invalid", ImeiSynced: true, Covered: true, Message: "carrier returned no imei value"}
|
||||
default:
|
||||
return Result{Task: task, ErrorClass: "business", Code: 11011, Message: "invalid card"}
|
||||
}
|
||||
}}, false)
|
||||
if err := c.handleRunClaims(ctx, claimRun(t, store, "covered", time.Now(), 4)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if queried != 4 {
|
||||
t.Fatalf("queried = %d, want 4", queried)
|
||||
}
|
||||
meta := runMeta(t, store, "covered")
|
||||
// Three cards produced a usable or definitive answer (success + 2 covered),
|
||||
// and only the business failure is counted. Without the covered channel the
|
||||
// two 14000-style answers would have made failedCount=3.
|
||||
if meta.Status != "completed_with_failures" || meta.SuccessCount != 3 || meta.FailedCount != 1 || meta.NextRoundCount != 0 {
|
||||
t.Fatalf("unexpected run accounting: %+v", meta)
|
||||
}
|
||||
failureKey, _ := store.FailedKey("covered")
|
||||
size, err := redis.Do(ctx, "HLEN", failureKey)
|
||||
if err != nil || size.Int64() != 1 {
|
||||
t.Fatalf("failure pool entries = %d err=%v, want 1 (only the business failure)", size.Int64(), err)
|
||||
}
|
||||
for _, taskID := range []string{"covered:1", "covered:2"} {
|
||||
exists, err := redis.Do(ctx, "HEXISTS", failureKey, taskID)
|
||||
if err != nil || exists.Int() != 0 {
|
||||
t.Fatalf("covered card %s leaked into the failure pool: %v %v", taskID, exists, err)
|
||||
}
|
||||
}
|
||||
exists, err := redis.Do(ctx, "HEXISTS", failureKey, "covered:3")
|
||||
if err != nil || exists.Int() != 1 {
|
||||
t.Fatalf("the business failure is missing from the pool: %v %v", exists, err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
// =================================================================================
|
||||
// This file is auto-generated by the GoFrame CLI tool. You may modify it as needed.
|
||||
// =================================================================================
|
||||
|
||||
package dao
|
||||
|
||||
import (
|
||||
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/dao/internal"
|
||||
)
|
||||
|
||||
// batchDetailDao is the data access object for the table batch_detail.
|
||||
// You can define custom methods on it to extend its functionality as needed.
|
||||
type batchDetailDao struct {
|
||||
*internal.BatchDetailDao
|
||||
}
|
||||
|
||||
var (
|
||||
// BatchDetail is a globally accessible object for table batch_detail operations.
|
||||
BatchDetail = batchDetailDao{internal.NewBatchDetailDao()}
|
||||
)
|
||||
|
||||
// Add your custom methods and functionality below.
|
||||
@@ -0,0 +1,22 @@
|
||||
// =================================================================================
|
||||
// This file is auto-generated by the GoFrame CLI tool. You may modify it as needed.
|
||||
// =================================================================================
|
||||
|
||||
package dao
|
||||
|
||||
import (
|
||||
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/dao/internal"
|
||||
)
|
||||
|
||||
// batchMainDao is the data access object for the table batch_main.
|
||||
// You can define custom methods on it to extend its functionality as needed.
|
||||
type batchMainDao struct {
|
||||
*internal.BatchMainDao
|
||||
}
|
||||
|
||||
var (
|
||||
// BatchMain is a globally accessible object for table batch_main operations.
|
||||
BatchMain = batchMainDao{internal.NewBatchMainDao()}
|
||||
)
|
||||
|
||||
// Add your custom methods and functionality below.
|
||||
@@ -0,0 +1,109 @@
|
||||
// ==========================================================================
|
||||
// Code generated and maintained by GoFrame CLI tool. DO NOT EDIT.
|
||||
// ==========================================================================
|
||||
|
||||
package internal
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/gogf/gf/v2/database/gdb"
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
)
|
||||
|
||||
// BatchDetailDao is the data access object for the table batch_detail.
|
||||
type BatchDetailDao struct {
|
||||
table string // table is the underlying table name of the DAO.
|
||||
group string // group is the database configuration group name of the current DAO.
|
||||
columns BatchDetailColumns // columns contains all the column names of Table for convenient usage.
|
||||
handlers []gdb.ModelHandler // handlers for customized model modification.
|
||||
}
|
||||
|
||||
// BatchDetailColumns defines and stores column names for the table batch_detail.
|
||||
type BatchDetailColumns struct {
|
||||
Id string // 主键ID
|
||||
BatchNo string // 批次编号
|
||||
BatchType string // 批次类型 枚举(与主表一致)
|
||||
DetailStatus string // 明细异步处理状态 0-待处理 1-处理中 2-成功 3-失败
|
||||
BlankOutStatus string // 作废状态 0-是 1-否
|
||||
RowNo string // 行号
|
||||
ItemId string // 项ID:卡ICCID/设备编码
|
||||
ErrorCode string // 异常编码
|
||||
ErrorReason string // 异常原因
|
||||
Snapshot string // 信息快照
|
||||
TenantId string // 租户ID
|
||||
CreatedDept string // 创建部门ID
|
||||
CreatedBy string // 创建者ID
|
||||
CreatedAt string // 创建时间
|
||||
UpdatedBy string // 更新者ID
|
||||
UpdatedAt string // 更新时间
|
||||
}
|
||||
|
||||
// batchDetailColumns holds the columns for the table batch_detail.
|
||||
var batchDetailColumns = BatchDetailColumns{
|
||||
Id: "id",
|
||||
BatchNo: "batch_no",
|
||||
BatchType: "batch_type",
|
||||
DetailStatus: "detail_status",
|
||||
BlankOutStatus: "blank_out_status",
|
||||
RowNo: "row_no",
|
||||
ItemId: "item_id",
|
||||
ErrorCode: "error_code",
|
||||
ErrorReason: "error_reason",
|
||||
Snapshot: "snapshot",
|
||||
TenantId: "tenant_id",
|
||||
CreatedDept: "created_dept",
|
||||
CreatedBy: "created_by",
|
||||
CreatedAt: "created_at",
|
||||
UpdatedBy: "updated_by",
|
||||
UpdatedAt: "updated_at",
|
||||
}
|
||||
|
||||
// NewBatchDetailDao creates and returns a new DAO object for table data access.
|
||||
func NewBatchDetailDao(handlers ...gdb.ModelHandler) *BatchDetailDao {
|
||||
return &BatchDetailDao{
|
||||
group: "default",
|
||||
table: "batch_detail",
|
||||
columns: batchDetailColumns,
|
||||
handlers: handlers,
|
||||
}
|
||||
}
|
||||
|
||||
// DB retrieves and returns the underlying raw database management object of the current DAO.
|
||||
func (dao *BatchDetailDao) DB() gdb.DB {
|
||||
return g.DB(dao.group)
|
||||
}
|
||||
|
||||
// Table returns the table name of the current DAO.
|
||||
func (dao *BatchDetailDao) Table() string {
|
||||
return dao.table
|
||||
}
|
||||
|
||||
// Columns returns all column names of the current DAO.
|
||||
func (dao *BatchDetailDao) Columns() BatchDetailColumns {
|
||||
return dao.columns
|
||||
}
|
||||
|
||||
// Group returns the database configuration group name of the current DAO.
|
||||
func (dao *BatchDetailDao) Group() string {
|
||||
return dao.group
|
||||
}
|
||||
|
||||
// Ctx creates and returns a Model for the current DAO. It automatically sets the context for the current operation.
|
||||
func (dao *BatchDetailDao) Ctx(ctx context.Context) *gdb.Model {
|
||||
model := dao.DB().Model(dao.table)
|
||||
for _, handler := range dao.handlers {
|
||||
model = handler(model)
|
||||
}
|
||||
return model.Safe().Ctx(ctx)
|
||||
}
|
||||
|
||||
// Transaction wraps the transaction logic using function f.
|
||||
// It rolls back the transaction and returns the error if function f returns a non-nil error.
|
||||
// It commits the transaction and returns nil if function f returns nil.
|
||||
//
|
||||
// Note: Do not commit or roll back the transaction in function f,
|
||||
// as it is automatically handled by this function.
|
||||
func (dao *BatchDetailDao) Transaction(ctx context.Context, f func(ctx context.Context, tx gdb.TX) error) (err error) {
|
||||
return dao.Ctx(ctx).Transaction(ctx, f)
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
// ==========================================================================
|
||||
// Code generated and maintained by GoFrame CLI tool. DO NOT EDIT.
|
||||
// ==========================================================================
|
||||
|
||||
package internal
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/gogf/gf/v2/database/gdb"
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
)
|
||||
|
||||
// BatchMainDao is the data access object for the table batch_main.
|
||||
type BatchMainDao struct {
|
||||
table string // table is the underlying table name of the DAO.
|
||||
group string // group is the database configuration group name of the current DAO.
|
||||
columns BatchMainColumns // columns contains all the column names of Table for convenient usage.
|
||||
handlers []gdb.ModelHandler // handlers for customized model modification.
|
||||
}
|
||||
|
||||
// BatchMainColumns defines and stores column names for the table batch_main.
|
||||
type BatchMainColumns struct {
|
||||
Id string // 主键ID
|
||||
BatchNo string // 批次编号
|
||||
OrderSn string // 订单编号
|
||||
BatchType string // 批次类型 枚举
|
||||
BatchStatus string // 批次状态 枚举
|
||||
BatchAsyncStatus string // 异步处理状态
|
||||
SourceType string // 来源方式 枚举
|
||||
SourceBatchNo string // 来源批次编号
|
||||
SourceOssId string // 源文件OSS ID
|
||||
ResultOssId string // 结果文件OSS ID
|
||||
IsCrossDept string // 是否跨级操作机构
|
||||
TargetDeptId string // 目标机构编号
|
||||
DeptId string // 机构编号
|
||||
TotalCount string // 总数量
|
||||
ValidCount string // 有效数量
|
||||
SuccessCount string // 成功数量
|
||||
FailCount string // 失败数量
|
||||
RunResult string // 运行结果
|
||||
FailReason string // 失败原因
|
||||
BatchInfo string // 批次信息:入库/出库展示字段
|
||||
Remark string // 备注扩展字段
|
||||
StartTime string // 开始时间
|
||||
EndTime string // 结束时间
|
||||
TenantId string // 租户ID
|
||||
CreatedDept string // 创建部门ID
|
||||
CreatedBy string // 创建者ID
|
||||
CreatedAt string // 创建时间
|
||||
UpdatedBy string // 更新者ID
|
||||
UpdatedAt string // 更新时间
|
||||
SourceChannel string // 发起渠道:console/mini_program/api/system/legacy
|
||||
ExecutionMode string // 操作规模:0未知1单卡2批量
|
||||
}
|
||||
|
||||
// batchMainColumns holds the columns for the table batch_main.
|
||||
var batchMainColumns = BatchMainColumns{
|
||||
Id: "id",
|
||||
BatchNo: "batch_no",
|
||||
OrderSn: "order_sn",
|
||||
BatchType: "batch_type",
|
||||
BatchStatus: "batch_status",
|
||||
BatchAsyncStatus: "batch_async_status",
|
||||
SourceType: "source_type",
|
||||
SourceBatchNo: "source_batch_no",
|
||||
SourceOssId: "source_oss_id",
|
||||
ResultOssId: "result_oss_id",
|
||||
IsCrossDept: "is_cross_dept",
|
||||
TargetDeptId: "target_dept_id",
|
||||
DeptId: "dept_id",
|
||||
TotalCount: "total_count",
|
||||
ValidCount: "valid_count",
|
||||
SuccessCount: "success_count",
|
||||
FailCount: "fail_count",
|
||||
RunResult: "run_result",
|
||||
FailReason: "fail_reason",
|
||||
BatchInfo: "batch_info",
|
||||
Remark: "remark",
|
||||
StartTime: "start_time",
|
||||
EndTime: "end_time",
|
||||
TenantId: "tenant_id",
|
||||
CreatedDept: "created_dept",
|
||||
CreatedBy: "created_by",
|
||||
CreatedAt: "created_at",
|
||||
UpdatedBy: "updated_by",
|
||||
UpdatedAt: "updated_at",
|
||||
SourceChannel: "source_channel",
|
||||
ExecutionMode: "execution_mode",
|
||||
}
|
||||
|
||||
// NewBatchMainDao creates and returns a new DAO object for table data access.
|
||||
func NewBatchMainDao(handlers ...gdb.ModelHandler) *BatchMainDao {
|
||||
return &BatchMainDao{
|
||||
group: "default",
|
||||
table: "batch_main",
|
||||
columns: batchMainColumns,
|
||||
handlers: handlers,
|
||||
}
|
||||
}
|
||||
|
||||
// DB retrieves and returns the underlying raw database management object of the current DAO.
|
||||
func (dao *BatchMainDao) DB() gdb.DB {
|
||||
return g.DB(dao.group)
|
||||
}
|
||||
|
||||
// Table returns the table name of the current DAO.
|
||||
func (dao *BatchMainDao) Table() string {
|
||||
return dao.table
|
||||
}
|
||||
|
||||
// Columns returns all column names of the current DAO.
|
||||
func (dao *BatchMainDao) Columns() BatchMainColumns {
|
||||
return dao.columns
|
||||
}
|
||||
|
||||
// Group returns the database configuration group name of the current DAO.
|
||||
func (dao *BatchMainDao) Group() string {
|
||||
return dao.group
|
||||
}
|
||||
|
||||
// Ctx creates and returns a Model for the current DAO. It automatically sets the context for the current operation.
|
||||
func (dao *BatchMainDao) Ctx(ctx context.Context) *gdb.Model {
|
||||
model := dao.DB().Model(dao.table)
|
||||
for _, handler := range dao.handlers {
|
||||
model = handler(model)
|
||||
}
|
||||
return model.Safe().Ctx(ctx)
|
||||
}
|
||||
|
||||
// Transaction wraps the transaction logic using function f.
|
||||
// It rolls back the transaction and returns the error if function f returns a non-nil error.
|
||||
// It commits the transaction and returns nil if function f returns nil.
|
||||
//
|
||||
// Note: Do not commit or roll back the transaction in function f,
|
||||
// as it is automatically handled by this function.
|
||||
func (dao *BatchMainDao) Transaction(ctx context.Context, f func(ctx context.Context, tx gdb.TX) error) (err error) {
|
||||
return dao.Ctx(ctx).Transaction(ctx, f)
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
// ==========================================================================
|
||||
// Code generated and maintained by GoFrame CLI tool. DO NOT EDIT.
|
||||
// ==========================================================================
|
||||
|
||||
package internal
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/gogf/gf/v2/database/gdb"
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
)
|
||||
|
||||
// SysDeptDao is the data access object for the table sys_dept.
|
||||
type SysDeptDao struct {
|
||||
table string // table is the underlying table name of the DAO.
|
||||
group string // group is the database configuration group name of the current DAO.
|
||||
columns SysDeptColumns // columns contains all the column names of Table for convenient usage.
|
||||
handlers []gdb.ModelHandler // handlers for customized model modification.
|
||||
}
|
||||
|
||||
// SysDeptColumns defines and stores column names for the table sys_dept.
|
||||
type SysDeptColumns struct {
|
||||
DeptId string // 部门id
|
||||
TenantId string // 租户编号
|
||||
ParentId string // 父部门id
|
||||
Ancestors string // 祖级列表
|
||||
DeptKey string // 机构编号
|
||||
DeptName string // 部门名称
|
||||
DeptType string // 部门类型(0:部门 1:公司)
|
||||
DeptCategory string // 部门类别编码
|
||||
OrderNum string // 显示顺序
|
||||
Leader string // 负责人
|
||||
Phone string // 联系电话
|
||||
Email string // 邮箱
|
||||
Status string // 部门状态(0正常 1停用)
|
||||
CreditCode string // 统一社会信用代码
|
||||
AreaCode string // 行政区域代码
|
||||
Address string // 地址
|
||||
DeptRole string // 机构默认角色ID
|
||||
CreatedDept string // 创建部门
|
||||
CreatedBy string // 创建者
|
||||
CreatedAt string // 创建时间
|
||||
UpdatedBy string // 更新者
|
||||
UpdatedAt string // 更新时间
|
||||
DeletedBy string // 删除人
|
||||
DeletedAt string // 删除时间
|
||||
}
|
||||
|
||||
// sysDeptColumns holds the columns for the table sys_dept.
|
||||
var sysDeptColumns = SysDeptColumns{
|
||||
DeptId: "dept_id",
|
||||
TenantId: "tenant_id",
|
||||
ParentId: "parent_id",
|
||||
Ancestors: "ancestors",
|
||||
DeptKey: "dept_key",
|
||||
DeptName: "dept_name",
|
||||
DeptType: "dept_type",
|
||||
DeptCategory: "dept_category",
|
||||
OrderNum: "order_num",
|
||||
Leader: "leader",
|
||||
Phone: "phone",
|
||||
Email: "email",
|
||||
Status: "status",
|
||||
CreditCode: "credit_code",
|
||||
AreaCode: "area_code",
|
||||
Address: "address",
|
||||
DeptRole: "dept_role",
|
||||
CreatedDept: "created_dept",
|
||||
CreatedBy: "created_by",
|
||||
CreatedAt: "created_at",
|
||||
UpdatedBy: "updated_by",
|
||||
UpdatedAt: "updated_at",
|
||||
DeletedBy: "deleted_by",
|
||||
DeletedAt: "deleted_at",
|
||||
}
|
||||
|
||||
// NewSysDeptDao creates and returns a new DAO object for table data access.
|
||||
func NewSysDeptDao(handlers ...gdb.ModelHandler) *SysDeptDao {
|
||||
return &SysDeptDao{
|
||||
group: "default",
|
||||
table: "sys_dept",
|
||||
columns: sysDeptColumns,
|
||||
handlers: handlers,
|
||||
}
|
||||
}
|
||||
|
||||
// DB retrieves and returns the underlying raw database management object of the current DAO.
|
||||
func (dao *SysDeptDao) DB() gdb.DB {
|
||||
return g.DB(dao.group)
|
||||
}
|
||||
|
||||
// Table returns the table name of the current DAO.
|
||||
func (dao *SysDeptDao) Table() string {
|
||||
return dao.table
|
||||
}
|
||||
|
||||
// Columns returns all column names of the current DAO.
|
||||
func (dao *SysDeptDao) Columns() SysDeptColumns {
|
||||
return dao.columns
|
||||
}
|
||||
|
||||
// Group returns the database configuration group name of the current DAO.
|
||||
func (dao *SysDeptDao) Group() string {
|
||||
return dao.group
|
||||
}
|
||||
|
||||
// Ctx creates and returns a Model for the current DAO. It automatically sets the context for the current operation.
|
||||
func (dao *SysDeptDao) Ctx(ctx context.Context) *gdb.Model {
|
||||
model := dao.DB().Model(dao.table)
|
||||
for _, handler := range dao.handlers {
|
||||
model = handler(model)
|
||||
}
|
||||
return model.Safe().Ctx(ctx)
|
||||
}
|
||||
|
||||
// Transaction wraps the transaction logic using function f.
|
||||
// It rolls back the transaction and returns the error if function f returns a non-nil error.
|
||||
// It commits the transaction and returns nil if function f returns nil.
|
||||
//
|
||||
// Note: Do not commit or roll back the transaction in function f,
|
||||
// as it is automatically handled by this function.
|
||||
func (dao *SysDeptDao) Transaction(ctx context.Context, f func(ctx context.Context, tx gdb.TX) error) (err error) {
|
||||
return dao.Ctx(ctx).Transaction(ctx, f)
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
// ==========================================================================
|
||||
// Code generated and maintained by GoFrame CLI tool. DO NOT EDIT.
|
||||
// ==========================================================================
|
||||
|
||||
package internal
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/gogf/gf/v2/database/gdb"
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
)
|
||||
|
||||
// SysOssDao is the data access object for the table sys_oss.
|
||||
type SysOssDao struct {
|
||||
table string // table is the underlying table name of the DAO.
|
||||
group string // group is the database configuration group name of the current DAO.
|
||||
columns SysOssColumns // columns contains all the column names of Table for convenient usage.
|
||||
handlers []gdb.ModelHandler // handlers for customized model modification.
|
||||
}
|
||||
|
||||
// SysOssColumns defines and stores column names for the table sys_oss.
|
||||
type SysOssColumns struct {
|
||||
OssId string // 对象存储主键
|
||||
TenantId string // 租户编号
|
||||
FileName string // 文件名
|
||||
OriginalName string // 原名
|
||||
FileSuffix string // 文件后缀名
|
||||
Path string // 存储路径
|
||||
Url string // URL地址
|
||||
CreatedDept string // 创建部门
|
||||
CreatedAt string // 创建时间
|
||||
CreatedBy string // 创建者
|
||||
UpdatedAt string // 更新时间
|
||||
UpdatedBy string // 更新者
|
||||
Service string // 服务商
|
||||
Md5 string // 文件MD5
|
||||
FileSize string // 文件大小
|
||||
FileCrc16 string // 文件Crc16
|
||||
FileSum string // 文件校验和
|
||||
}
|
||||
|
||||
// sysOssColumns holds the columns for the table sys_oss.
|
||||
var sysOssColumns = SysOssColumns{
|
||||
OssId: "oss_id",
|
||||
TenantId: "tenant_id",
|
||||
FileName: "file_name",
|
||||
OriginalName: "original_name",
|
||||
FileSuffix: "file_suffix",
|
||||
Path: "path",
|
||||
Url: "url",
|
||||
CreatedDept: "created_dept",
|
||||
CreatedAt: "created_at",
|
||||
CreatedBy: "created_by",
|
||||
UpdatedAt: "updated_at",
|
||||
UpdatedBy: "updated_by",
|
||||
Service: "service",
|
||||
Md5: "md5",
|
||||
FileSize: "file_size",
|
||||
FileCrc16: "file_crc16",
|
||||
FileSum: "file_sum",
|
||||
}
|
||||
|
||||
// NewSysOssDao creates and returns a new DAO object for table data access.
|
||||
func NewSysOssDao(handlers ...gdb.ModelHandler) *SysOssDao {
|
||||
return &SysOssDao{
|
||||
group: "default",
|
||||
table: "sys_oss",
|
||||
columns: sysOssColumns,
|
||||
handlers: handlers,
|
||||
}
|
||||
}
|
||||
|
||||
// DB retrieves and returns the underlying raw database management object of the current DAO.
|
||||
func (dao *SysOssDao) DB() gdb.DB {
|
||||
return g.DB(dao.group)
|
||||
}
|
||||
|
||||
// Table returns the table name of the current DAO.
|
||||
func (dao *SysOssDao) Table() string {
|
||||
return dao.table
|
||||
}
|
||||
|
||||
// Columns returns all column names of the current DAO.
|
||||
func (dao *SysOssDao) Columns() SysOssColumns {
|
||||
return dao.columns
|
||||
}
|
||||
|
||||
// Group returns the database configuration group name of the current DAO.
|
||||
func (dao *SysOssDao) Group() string {
|
||||
return dao.group
|
||||
}
|
||||
|
||||
// Ctx creates and returns a Model for the current DAO. It automatically sets the context for the current operation.
|
||||
func (dao *SysOssDao) Ctx(ctx context.Context) *gdb.Model {
|
||||
model := dao.DB().Model(dao.table)
|
||||
for _, handler := range dao.handlers {
|
||||
model = handler(model)
|
||||
}
|
||||
return model.Safe().Ctx(ctx)
|
||||
}
|
||||
|
||||
// Transaction wraps the transaction logic using function f.
|
||||
// It rolls back the transaction and returns the error if function f returns a non-nil error.
|
||||
// It commits the transaction and returns nil if function f returns nil.
|
||||
//
|
||||
// Note: Do not commit or roll back the transaction in function f,
|
||||
// as it is automatically handled by this function.
|
||||
func (dao *SysOssDao) Transaction(ctx context.Context, f func(ctx context.Context, tx gdb.TX) error) (err error) {
|
||||
return dao.Ctx(ctx).Transaction(ctx, f)
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
// ==========================================================================
|
||||
// Code generated and maintained by GoFrame CLI tool. DO NOT EDIT.
|
||||
// ==========================================================================
|
||||
|
||||
package internal
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/gogf/gf/v2/database/gdb"
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
)
|
||||
|
||||
// TMessageLogDao is the data access object for the table t_message_log.
|
||||
type TMessageLogDao struct {
|
||||
table string // table is the underlying table name of the DAO.
|
||||
group string // group is the database configuration group name of the current DAO.
|
||||
columns TMessageLogColumns // columns contains all the column names of Table for convenient usage.
|
||||
handlers []gdb.ModelHandler // handlers for customized model modification.
|
||||
}
|
||||
|
||||
// TMessageLogColumns defines and stores column names for the table t_message_log.
|
||||
type TMessageLogColumns struct {
|
||||
MessageLogId string // 主键ID
|
||||
MessageType string // 消息类型
|
||||
SendUserType string // 发送用户类型
|
||||
UserIds string // 接收用户ID集合(JSON数组)
|
||||
MessageTitle string // 消息标题
|
||||
MessageContent string // 消息内容
|
||||
MessageLink string // 跳转链接地址
|
||||
SendTime string // 发送时间
|
||||
IsRecall string // 是否撤回
|
||||
TenantId string // 租户ID
|
||||
CreatedDept string // 创建部门
|
||||
CreatedBy string // 创建者
|
||||
CreatedAt string // 创建时间
|
||||
UpdatedBy string // 更新者
|
||||
UpdatedAt string // 更新时间
|
||||
}
|
||||
|
||||
// tMessageLogColumns holds the columns for the table t_message_log.
|
||||
var tMessageLogColumns = TMessageLogColumns{
|
||||
MessageLogId: "message_log_id",
|
||||
MessageType: "message_type",
|
||||
SendUserType: "send_user_type",
|
||||
UserIds: "user_ids",
|
||||
MessageTitle: "message_title",
|
||||
MessageContent: "message_content",
|
||||
MessageLink: "message_link",
|
||||
SendTime: "send_time",
|
||||
IsRecall: "is_recall",
|
||||
TenantId: "tenant_id",
|
||||
CreatedDept: "created_dept",
|
||||
CreatedBy: "created_by",
|
||||
CreatedAt: "created_at",
|
||||
UpdatedBy: "updated_by",
|
||||
UpdatedAt: "updated_at",
|
||||
}
|
||||
|
||||
// NewTMessageLogDao creates and returns a new DAO object for table data access.
|
||||
func NewTMessageLogDao(handlers ...gdb.ModelHandler) *TMessageLogDao {
|
||||
return &TMessageLogDao{
|
||||
group: "default",
|
||||
table: "t_message_log",
|
||||
columns: tMessageLogColumns,
|
||||
handlers: handlers,
|
||||
}
|
||||
}
|
||||
|
||||
// DB retrieves and returns the underlying raw database management object of the current DAO.
|
||||
func (dao *TMessageLogDao) DB() gdb.DB {
|
||||
return g.DB(dao.group)
|
||||
}
|
||||
|
||||
// Table returns the table name of the current DAO.
|
||||
func (dao *TMessageLogDao) Table() string {
|
||||
return dao.table
|
||||
}
|
||||
|
||||
// Columns returns all column names of the current DAO.
|
||||
func (dao *TMessageLogDao) Columns() TMessageLogColumns {
|
||||
return dao.columns
|
||||
}
|
||||
|
||||
// Group returns the database configuration group name of the current DAO.
|
||||
func (dao *TMessageLogDao) Group() string {
|
||||
return dao.group
|
||||
}
|
||||
|
||||
// Ctx creates and returns a Model for the current DAO. It automatically sets the context for the current operation.
|
||||
func (dao *TMessageLogDao) Ctx(ctx context.Context) *gdb.Model {
|
||||
model := dao.DB().Model(dao.table)
|
||||
for _, handler := range dao.handlers {
|
||||
model = handler(model)
|
||||
}
|
||||
return model.Safe().Ctx(ctx)
|
||||
}
|
||||
|
||||
// Transaction wraps the transaction logic using function f.
|
||||
// It rolls back the transaction and returns the error if function f returns a non-nil error.
|
||||
// It commits the transaction and returns nil if function f returns nil.
|
||||
//
|
||||
// Note: Do not commit or roll back the transaction in function f,
|
||||
// as it is automatically handled by this function.
|
||||
func (dao *TMessageLogDao) Transaction(ctx context.Context, f func(ctx context.Context, tx gdb.TX) error) (err error) {
|
||||
return dao.Ctx(ctx).Transaction(ctx, f)
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
// ==========================================================================
|
||||
// Code generated and maintained by GoFrame CLI tool. DO NOT EDIT.
|
||||
// ==========================================================================
|
||||
|
||||
package internal
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/gogf/gf/v2/database/gdb"
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
)
|
||||
|
||||
// TResourceBatchDownloadDao is the data access object for the table t_resource_batch_download.
|
||||
type TResourceBatchDownloadDao struct {
|
||||
table string // table is the underlying table name of the DAO.
|
||||
group string // group is the database configuration group name of the current DAO.
|
||||
columns TResourceBatchDownloadColumns // columns contains all the column names of Table for convenient usage.
|
||||
handlers []gdb.ModelHandler // handlers for customized model modification.
|
||||
}
|
||||
|
||||
// TResourceBatchDownloadColumns defines and stores column names for the table t_resource_batch_download.
|
||||
type TResourceBatchDownloadColumns struct {
|
||||
Id string // 主键ID
|
||||
BatchNo string // 批次编号(全局唯一)
|
||||
TaskName string // 任务名称
|
||||
ResourceType string // 资源类型 枚举
|
||||
ExportScene string // 导出场景 枚举
|
||||
QueryParams string // 导出查询条件快照
|
||||
TotalCount string // 预计导出总条数
|
||||
SuccessCount string // 已成功导出条数
|
||||
FailCount string // 失败条数
|
||||
DownloadStatus string // 下载状态 枚举
|
||||
OssId string // OSS文件ID
|
||||
FileName string // 导出文件名
|
||||
FileExt string // 文件扩展名
|
||||
FileUrl string // 文件访问地址
|
||||
FilePath string // 服务器本地路径
|
||||
FileSize string // 文件大小(字节)
|
||||
FileMd5 string // 文件MD5
|
||||
DownloadCount string // 下载次数
|
||||
LastDownloadTime string // 最后下载时间
|
||||
MaxDownloadCount string // 最大下载次数限制
|
||||
ErrorMessage string // 失败原因
|
||||
DeptId string // 所属机构ID
|
||||
StartTime string // 任务开始时间
|
||||
FinishTime string // 任务完成时间
|
||||
ExpireTime string // 文件过期时间
|
||||
Remark string // 备注
|
||||
TenantId string // 租户ID
|
||||
CreatedDept string // 创建部门ID
|
||||
CreatedBy string // 创建者ID
|
||||
CreatedAt string // 创建时间
|
||||
UpdatedBy string // 更新者ID
|
||||
UpdatedAt string // 更新时间
|
||||
}
|
||||
|
||||
// tResourceBatchDownloadColumns holds the columns for the table t_resource_batch_download.
|
||||
var tResourceBatchDownloadColumns = TResourceBatchDownloadColumns{
|
||||
Id: "id",
|
||||
BatchNo: "batch_no",
|
||||
TaskName: "task_name",
|
||||
ResourceType: "resource_type",
|
||||
ExportScene: "export_scene",
|
||||
QueryParams: "query_params",
|
||||
TotalCount: "total_count",
|
||||
SuccessCount: "success_count",
|
||||
FailCount: "fail_count",
|
||||
DownloadStatus: "download_status",
|
||||
OssId: "oss_id",
|
||||
FileName: "file_name",
|
||||
FileExt: "file_ext",
|
||||
FileUrl: "file_url",
|
||||
FilePath: "file_path",
|
||||
FileSize: "file_size",
|
||||
FileMd5: "file_md5",
|
||||
DownloadCount: "download_count",
|
||||
LastDownloadTime: "last_download_time",
|
||||
MaxDownloadCount: "max_download_count",
|
||||
ErrorMessage: "error_message",
|
||||
DeptId: "dept_id",
|
||||
StartTime: "start_time",
|
||||
FinishTime: "finish_time",
|
||||
ExpireTime: "expire_time",
|
||||
Remark: "remark",
|
||||
TenantId: "tenant_id",
|
||||
CreatedDept: "created_dept",
|
||||
CreatedBy: "created_by",
|
||||
CreatedAt: "created_at",
|
||||
UpdatedBy: "updated_by",
|
||||
UpdatedAt: "updated_at",
|
||||
}
|
||||
|
||||
// NewTResourceBatchDownloadDao creates and returns a new DAO object for table data access.
|
||||
func NewTResourceBatchDownloadDao(handlers ...gdb.ModelHandler) *TResourceBatchDownloadDao {
|
||||
return &TResourceBatchDownloadDao{
|
||||
group: "default",
|
||||
table: "t_resource_batch_download",
|
||||
columns: tResourceBatchDownloadColumns,
|
||||
handlers: handlers,
|
||||
}
|
||||
}
|
||||
|
||||
// DB retrieves and returns the underlying raw database management object of the current DAO.
|
||||
func (dao *TResourceBatchDownloadDao) DB() gdb.DB {
|
||||
return g.DB(dao.group)
|
||||
}
|
||||
|
||||
// Table returns the table name of the current DAO.
|
||||
func (dao *TResourceBatchDownloadDao) Table() string {
|
||||
return dao.table
|
||||
}
|
||||
|
||||
// Columns returns all column names of the current DAO.
|
||||
func (dao *TResourceBatchDownloadDao) Columns() TResourceBatchDownloadColumns {
|
||||
return dao.columns
|
||||
}
|
||||
|
||||
// Group returns the database configuration group name of the current DAO.
|
||||
func (dao *TResourceBatchDownloadDao) Group() string {
|
||||
return dao.group
|
||||
}
|
||||
|
||||
// Ctx creates and returns a Model for the current DAO. It automatically sets the context for the current operation.
|
||||
func (dao *TResourceBatchDownloadDao) Ctx(ctx context.Context) *gdb.Model {
|
||||
model := dao.DB().Model(dao.table)
|
||||
for _, handler := range dao.handlers {
|
||||
model = handler(model)
|
||||
}
|
||||
return model.Safe().Ctx(ctx)
|
||||
}
|
||||
|
||||
// Transaction wraps the transaction logic using function f.
|
||||
// It rolls back the transaction and returns the error if function f returns a non-nil error.
|
||||
// It commits the transaction and returns nil if function f returns nil.
|
||||
//
|
||||
// Note: Do not commit or roll back the transaction in function f,
|
||||
// as it is automatically handled by this function.
|
||||
func (dao *TResourceBatchDownloadDao) Transaction(ctx context.Context, f func(ctx context.Context, tx gdb.TX) error) (err error) {
|
||||
return dao.Ctx(ctx).Transaction(ctx, f)
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
// ==========================================================================
|
||||
// Code generated and maintained by GoFrame CLI tool. DO NOT EDIT.
|
||||
// ==========================================================================
|
||||
|
||||
package internal
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/gogf/gf/v2/database/gdb"
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
)
|
||||
|
||||
// TSimCardCancellationDao is the data access object for the table t_sim_card_cancellation.
|
||||
type TSimCardCancellationDao struct {
|
||||
table string // table is the underlying table name of the DAO.
|
||||
group string // group is the database configuration group name of the current DAO.
|
||||
columns TSimCardCancellationColumns // columns contains all the column names of Table for convenient usage.
|
||||
handlers []gdb.ModelHandler // handlers for customized model modification.
|
||||
}
|
||||
|
||||
// TSimCardCancellationColumns defines and stores column names for the table t_sim_card_cancellation.
|
||||
type TSimCardCancellationColumns struct {
|
||||
Id string // 主键ID
|
||||
Iccid string // ICCID
|
||||
Msisdn string // MSISDN/卡号
|
||||
Imsi string // IMSI
|
||||
Imei string // IMEI
|
||||
RegionCode string // 归属地
|
||||
DeptId string // 渠道客户ID
|
||||
CurrentPackageCode string // 当前套餐编号
|
||||
PackageStatus string // 套餐状态:0 无套餐,1待生效,2生效中,3已过期
|
||||
AccessType string // 接入方式:ipv4、ipv6、ipv4_ipv6
|
||||
BossCardStatus string // 运营商卡状态 枚举
|
||||
CancelBatchNo string // 系统销户批次号
|
||||
CancelledAt string // 系统销户完成时间
|
||||
CardType string // 卡类型 枚举
|
||||
BossOpenAt string // 运营商开户日期
|
||||
BossActivateAt string // 运营商激活日期
|
||||
IsBossActivateEnabled string // 是否按运营商激活减去赠送时长方:1是,0否
|
||||
InboundBatchNo string // 入库批次
|
||||
OutboundBatchNo string // 出库批次
|
||||
AllocationBatchNo string // 划拨批次
|
||||
IsFlowPool string // 是否为池卡:1是,0否
|
||||
FlowPoolCode string // 流量池编号
|
||||
FlowPkgBeginAt string // 当前套餐生效时间
|
||||
FlowPkgPresentAt string // 套餐赠送结束时间
|
||||
FlowPkgRealPresentAt string // 实际赠送结束时间
|
||||
FlowPkgEndAt string // 当前套餐预计到期时间
|
||||
DataServiceStatus string // 数据服务状态:0启用,1停用
|
||||
PackageTotalFlowMb string // 套餐总流量MB
|
||||
UsedFlowMb string // 已用流量MB
|
||||
TrafficCutoffStatus string // 达量断网配置状态:0未配置,1已开启,2已关闭
|
||||
TrafficCutoffThreshold string // 达量断网阈值
|
||||
SmsServiceStatus string // 短信服务状态:0启用,1停用
|
||||
PackageTotalSms string // 套餐短信总条数
|
||||
UsedSms string // 已用短信条数
|
||||
VoiceServiceStatus string // 语音服务状态:0启用,1停用
|
||||
PackageTotalVoice string // 套餐语音总分钟数
|
||||
UsedVoice string // 已用语音分钟数
|
||||
RoamServiceStatus string // 国漫服务状态:0启用,1停用
|
||||
PackageTotalRoamFlowMb string // 套餐国际流量MB
|
||||
UsedRoamFlowMb string // 已用国际流量MB
|
||||
StatusSyncTime string // 状态最后一次同步时间
|
||||
FlowSyncTime string // 流量最后一次同步时间
|
||||
SmsSyncTime string // 短信最后一次同步时间
|
||||
VoiceSyncTime string // 语音最后一次同步时间
|
||||
RoamSyncTime string // 国漫最后一次同步时间
|
||||
ExtraFeeEnabled string // 是否收取附加费:0是,1否
|
||||
ExtraFeeMultiple string // 附加费用倍数
|
||||
ExtraFee string // 附加金额
|
||||
ChanKey string // 通道编号
|
||||
NetModel string // 网络类型 枚举
|
||||
Remark string // 备注
|
||||
TenantId string // 租户ID
|
||||
CreatedDept string // 创建部门
|
||||
CreatedBy string // 创建者
|
||||
CreatedAt string // 创建时间
|
||||
UpdatedBy string // 更新者
|
||||
UpdatedAt string // 更新时间
|
||||
}
|
||||
|
||||
// tSimCardCancellationColumns holds the columns for the table t_sim_card_cancellation.
|
||||
var tSimCardCancellationColumns = TSimCardCancellationColumns{
|
||||
Id: "id",
|
||||
Iccid: "iccid",
|
||||
Msisdn: "msisdn",
|
||||
Imsi: "imsi",
|
||||
Imei: "imei",
|
||||
RegionCode: "region_code",
|
||||
DeptId: "dept_id",
|
||||
CurrentPackageCode: "current_package_code",
|
||||
PackageStatus: "package_status",
|
||||
AccessType: "access_type",
|
||||
BossCardStatus: "boss_card_status",
|
||||
CancelBatchNo: "cancel_batch_no",
|
||||
CancelledAt: "cancelled_at",
|
||||
CardType: "card_type",
|
||||
BossOpenAt: "boss_open_at",
|
||||
BossActivateAt: "boss_activate_at",
|
||||
IsBossActivateEnabled: "is_boss_activate_enabled",
|
||||
InboundBatchNo: "inbound_batch_no",
|
||||
OutboundBatchNo: "outbound_batch_no",
|
||||
AllocationBatchNo: "allocation_batch_no",
|
||||
IsFlowPool: "is_flow_pool",
|
||||
FlowPoolCode: "flow_pool_code",
|
||||
FlowPkgBeginAt: "flow_pkg_begin_at",
|
||||
FlowPkgPresentAt: "flow_pkg_present_at",
|
||||
FlowPkgRealPresentAt: "flow_pkg_real_present_at",
|
||||
FlowPkgEndAt: "flow_pkg_end_at",
|
||||
DataServiceStatus: "data_service_status",
|
||||
PackageTotalFlowMb: "package_total_flow_mb",
|
||||
UsedFlowMb: "used_flow_mb",
|
||||
TrafficCutoffStatus: "traffic_cutoff_status",
|
||||
TrafficCutoffThreshold: "traffic_cutoff_threshold",
|
||||
SmsServiceStatus: "sms_service_status",
|
||||
PackageTotalSms: "package_total_sms",
|
||||
UsedSms: "used_sms",
|
||||
VoiceServiceStatus: "voice_service_status",
|
||||
PackageTotalVoice: "package_total_voice",
|
||||
UsedVoice: "used_voice",
|
||||
RoamServiceStatus: "roam_service_status",
|
||||
PackageTotalRoamFlowMb: "package_total_roam_flow_mb",
|
||||
UsedRoamFlowMb: "used_roam_flow_mb",
|
||||
StatusSyncTime: "status_sync_time",
|
||||
FlowSyncTime: "flow_sync_time",
|
||||
SmsSyncTime: "sms_sync_time",
|
||||
VoiceSyncTime: "voice_sync_time",
|
||||
RoamSyncTime: "roam_sync_time",
|
||||
ExtraFeeEnabled: "extra_fee_enabled",
|
||||
ExtraFeeMultiple: "extra_fee_multiple",
|
||||
ExtraFee: "extra_fee",
|
||||
ChanKey: "chan_key",
|
||||
NetModel: "net_model",
|
||||
Remark: "remark",
|
||||
TenantId: "tenant_id",
|
||||
CreatedDept: "created_dept",
|
||||
CreatedBy: "created_by",
|
||||
CreatedAt: "created_at",
|
||||
UpdatedBy: "updated_by",
|
||||
UpdatedAt: "updated_at",
|
||||
}
|
||||
|
||||
// NewTSimCardCancellationDao creates and returns a new DAO object for table data access.
|
||||
func NewTSimCardCancellationDao(handlers ...gdb.ModelHandler) *TSimCardCancellationDao {
|
||||
return &TSimCardCancellationDao{
|
||||
group: "default",
|
||||
table: "t_sim_card_cancellation",
|
||||
columns: tSimCardCancellationColumns,
|
||||
handlers: handlers,
|
||||
}
|
||||
}
|
||||
|
||||
// DB retrieves and returns the underlying raw database management object of the current DAO.
|
||||
func (dao *TSimCardCancellationDao) DB() gdb.DB {
|
||||
return g.DB(dao.group)
|
||||
}
|
||||
|
||||
// Table returns the table name of the current DAO.
|
||||
func (dao *TSimCardCancellationDao) Table() string {
|
||||
return dao.table
|
||||
}
|
||||
|
||||
// Columns returns all column names of the current DAO.
|
||||
func (dao *TSimCardCancellationDao) Columns() TSimCardCancellationColumns {
|
||||
return dao.columns
|
||||
}
|
||||
|
||||
// Group returns the database configuration group name of the current DAO.
|
||||
func (dao *TSimCardCancellationDao) Group() string {
|
||||
return dao.group
|
||||
}
|
||||
|
||||
// Ctx creates and returns a Model for the current DAO. It automatically sets the context for the current operation.
|
||||
func (dao *TSimCardCancellationDao) Ctx(ctx context.Context) *gdb.Model {
|
||||
model := dao.DB().Model(dao.table)
|
||||
for _, handler := range dao.handlers {
|
||||
model = handler(model)
|
||||
}
|
||||
return model.Safe().Ctx(ctx)
|
||||
}
|
||||
|
||||
// Transaction wraps the transaction logic using function f.
|
||||
// It rolls back the transaction and returns the error if function f returns a non-nil error.
|
||||
// It commits the transaction and returns nil if function f returns nil.
|
||||
//
|
||||
// Note: Do not commit or roll back the transaction in function f,
|
||||
// as it is automatically handled by this function.
|
||||
func (dao *TSimCardCancellationDao) Transaction(ctx context.Context, f func(ctx context.Context, tx gdb.TX) error) (err error) {
|
||||
return dao.Ctx(ctx).Transaction(ctx, f)
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
// ==========================================================================
|
||||
// Code generated and maintained by GoFrame CLI tool. DO NOT EDIT.
|
||||
// ==========================================================================
|
||||
|
||||
package internal
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/gogf/gf/v2/database/gdb"
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
)
|
||||
|
||||
// TSimCardInfoDao is the data access object for the table t_sim_card_info.
|
||||
type TSimCardInfoDao struct {
|
||||
table string // table is the underlying table name of the DAO.
|
||||
group string // group is the database configuration group name of the current DAO.
|
||||
columns TSimCardInfoColumns // columns contains all the column names of Table for convenient usage.
|
||||
handlers []gdb.ModelHandler // handlers for customized model modification.
|
||||
}
|
||||
|
||||
// TSimCardInfoColumns defines and stores column names for the table t_sim_card_info.
|
||||
type TSimCardInfoColumns struct {
|
||||
Id string // 主键ID
|
||||
Iccid string // ICCID
|
||||
Msisdn string // MSISDN/卡号
|
||||
Imsi string // IMSI
|
||||
Imei string // IMEI
|
||||
RegionCode string // 归属地
|
||||
DeptId string // 渠道客户ID
|
||||
CurrentPackageCode string // 当前套餐编号
|
||||
PackageStatus string // 套餐状态:0 无套餐,1待生效,2生效中,3已过期
|
||||
AccessType string // 接入方式:ipv4、ipv6、ipv4_ipv6
|
||||
BossCardStatus string // 运营商卡状态 枚举
|
||||
CardType string // 卡类型 枚举
|
||||
BossOpenAt string // 运营商开户日期
|
||||
BossActivateAt string // 运营商激活日期
|
||||
IsBossActivateEnabled string // 是否按运营商激活减去赠送时长方:1是,0否
|
||||
InboundBatchNo string // 入库批次
|
||||
OutboundBatchNo string // 出库批次
|
||||
AllocationBatchNo string // 划拨批次
|
||||
IsFlowPool string // 是否为池卡:1是,0否
|
||||
FlowPoolCode string // 流量池编号
|
||||
OutStockTime string // 出库时间
|
||||
FlowPkgBeginAt string // 当前套餐生效时间
|
||||
FlowPkgPresentAt string // 套餐赠送结束时间
|
||||
FlowPkgRealPresentAt string // 实际赠送结束时间
|
||||
FlowPkgEndAt string // 当前套餐预计到期时间
|
||||
DataServiceStatus string // 数据服务状态:1启用、0禁用
|
||||
PackageTotalFlowMb string // 套餐总流量MB
|
||||
UsedFlowMb string // 已用流量MB
|
||||
TrafficCutoffStatus string // 达量断网配置状态:0未配置,1已开启,2已关闭
|
||||
TrafficCutoffThreshold string // 达量断网阈值
|
||||
SmsServiceStatus string // 短信服务状态:1启用、0禁用
|
||||
PackageTotalSms string // 套餐短信总条数
|
||||
UsedSms string // 已用短信条数
|
||||
VoiceServiceStatus string // 语音服务状态:1启用、0禁用
|
||||
PackageTotalVoice string // 套餐语音总分钟数
|
||||
UsedVoice string // 已用语音分钟数
|
||||
RoamServiceStatus string // 国漫服务状态:1启用、0禁用
|
||||
PackageTotalRoamFlowMb string // 套餐国际流量MB
|
||||
UsedRoamFlowMb string // 已用国际流量MB
|
||||
StatusSyncTime string // 状态最后一次同步时间
|
||||
FlowSyncTime string // 流量最后一次同步时间
|
||||
SmsSyncTime string // 短信最后一次同步时间
|
||||
VoiceSyncTime string // 语音最后一次同步时间
|
||||
RoamSyncTime string // 国漫最后一次同步时间
|
||||
ImeiSyncTime string // IMEI 最后一次轮询同步时间
|
||||
ImeiUpdateTime string // IMEI 值最后一次实际变更时间
|
||||
ExtraFeeEnabled string // 是否收取附加费:1是,0否
|
||||
ExtraFeeMultiple string // 附加费用倍数
|
||||
ExtraFee string // 附加金额
|
||||
ChanKey string // 通道编号
|
||||
NetModel string // 网络类型:4G、5G、NB-IoT
|
||||
Remark string // 备注
|
||||
TenantId string // 租户ID
|
||||
CreatedDept string // 创建部门
|
||||
CreatedBy string // 创建者
|
||||
CreatedAt string // 创建时间
|
||||
UpdatedBy string // 更新者
|
||||
UpdatedAt string // 更新时间
|
||||
}
|
||||
|
||||
// tSimCardInfoColumns holds the columns for the table t_sim_card_info.
|
||||
var tSimCardInfoColumns = TSimCardInfoColumns{
|
||||
Id: "id",
|
||||
Iccid: "iccid",
|
||||
Msisdn: "msisdn",
|
||||
Imsi: "imsi",
|
||||
Imei: "imei",
|
||||
RegionCode: "region_code",
|
||||
DeptId: "dept_id",
|
||||
CurrentPackageCode: "current_package_code",
|
||||
PackageStatus: "package_status",
|
||||
AccessType: "access_type",
|
||||
BossCardStatus: "boss_card_status",
|
||||
CardType: "card_type",
|
||||
BossOpenAt: "boss_open_at",
|
||||
BossActivateAt: "boss_activate_at",
|
||||
IsBossActivateEnabled: "is_boss_activate_enabled",
|
||||
InboundBatchNo: "inbound_batch_no",
|
||||
OutboundBatchNo: "outbound_batch_no",
|
||||
AllocationBatchNo: "allocation_batch_no",
|
||||
IsFlowPool: "is_flow_pool",
|
||||
FlowPoolCode: "flow_pool_code",
|
||||
OutStockTime: "out_stock_time",
|
||||
FlowPkgBeginAt: "flow_pkg_begin_at",
|
||||
FlowPkgPresentAt: "flow_pkg_present_at",
|
||||
FlowPkgRealPresentAt: "flow_pkg_real_present_at",
|
||||
FlowPkgEndAt: "flow_pkg_end_at",
|
||||
DataServiceStatus: "data_service_status",
|
||||
PackageTotalFlowMb: "package_total_flow_mb",
|
||||
UsedFlowMb: "used_flow_mb",
|
||||
TrafficCutoffStatus: "traffic_cutoff_status",
|
||||
TrafficCutoffThreshold: "traffic_cutoff_threshold",
|
||||
SmsServiceStatus: "sms_service_status",
|
||||
PackageTotalSms: "package_total_sms",
|
||||
UsedSms: "used_sms",
|
||||
VoiceServiceStatus: "voice_service_status",
|
||||
PackageTotalVoice: "package_total_voice",
|
||||
UsedVoice: "used_voice",
|
||||
RoamServiceStatus: "roam_service_status",
|
||||
PackageTotalRoamFlowMb: "package_total_roam_flow_mb",
|
||||
UsedRoamFlowMb: "used_roam_flow_mb",
|
||||
StatusSyncTime: "status_sync_time",
|
||||
FlowSyncTime: "flow_sync_time",
|
||||
SmsSyncTime: "sms_sync_time",
|
||||
VoiceSyncTime: "voice_sync_time",
|
||||
RoamSyncTime: "roam_sync_time",
|
||||
ImeiSyncTime: "imei_sync_time",
|
||||
ImeiUpdateTime: "imei_update_time",
|
||||
ExtraFeeEnabled: "extra_fee_enabled",
|
||||
ExtraFeeMultiple: "extra_fee_multiple",
|
||||
ExtraFee: "extra_fee",
|
||||
ChanKey: "chan_key",
|
||||
NetModel: "net_model",
|
||||
Remark: "remark",
|
||||
TenantId: "tenant_id",
|
||||
CreatedDept: "created_dept",
|
||||
CreatedBy: "created_by",
|
||||
CreatedAt: "created_at",
|
||||
UpdatedBy: "updated_by",
|
||||
UpdatedAt: "updated_at",
|
||||
}
|
||||
|
||||
// NewTSimCardInfoDao creates and returns a new DAO object for table data access.
|
||||
func NewTSimCardInfoDao(handlers ...gdb.ModelHandler) *TSimCardInfoDao {
|
||||
return &TSimCardInfoDao{
|
||||
group: "default",
|
||||
table: "t_sim_card_info",
|
||||
columns: tSimCardInfoColumns,
|
||||
handlers: handlers,
|
||||
}
|
||||
}
|
||||
|
||||
// DB retrieves and returns the underlying raw database management object of the current DAO.
|
||||
func (dao *TSimCardInfoDao) DB() gdb.DB {
|
||||
return g.DB(dao.group)
|
||||
}
|
||||
|
||||
// Table returns the table name of the current DAO.
|
||||
func (dao *TSimCardInfoDao) Table() string {
|
||||
return dao.table
|
||||
}
|
||||
|
||||
// Columns returns all column names of the current DAO.
|
||||
func (dao *TSimCardInfoDao) Columns() TSimCardInfoColumns {
|
||||
return dao.columns
|
||||
}
|
||||
|
||||
// Group returns the database configuration group name of the current DAO.
|
||||
func (dao *TSimCardInfoDao) Group() string {
|
||||
return dao.group
|
||||
}
|
||||
|
||||
// Ctx creates and returns a Model for the current DAO. It automatically sets the context for the current operation.
|
||||
func (dao *TSimCardInfoDao) Ctx(ctx context.Context) *gdb.Model {
|
||||
model := dao.DB().Model(dao.table)
|
||||
for _, handler := range dao.handlers {
|
||||
model = handler(model)
|
||||
}
|
||||
return model.Safe().Ctx(ctx)
|
||||
}
|
||||
|
||||
// Transaction wraps the transaction logic using function f.
|
||||
// It rolls back the transaction and returns the error if function f returns a non-nil error.
|
||||
// It commits the transaction and returns nil if function f returns nil.
|
||||
//
|
||||
// Note: Do not commit or roll back the transaction in function f,
|
||||
// as it is automatically handled by this function.
|
||||
func (dao *TSimCardInfoDao) Transaction(ctx context.Context, f func(ctx context.Context, tx gdb.TX) error) (err error) {
|
||||
return dao.Ctx(ctx).Transaction(ctx, f)
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
// ==========================================================================
|
||||
// Code generated and maintained by GoFrame CLI tool. DO NOT EDIT.
|
||||
// ==========================================================================
|
||||
|
||||
package internal
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/gogf/gf/v2/database/gdb"
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
)
|
||||
|
||||
// TSimFlowPoolDao is the data access object for the table t_sim_flow_pool.
|
||||
type TSimFlowPoolDao struct {
|
||||
table string // table is the underlying table name of the DAO.
|
||||
group string // group is the database configuration group name of the current DAO.
|
||||
columns TSimFlowPoolColumns // columns contains all the column names of Table for convenient usage.
|
||||
handlers []gdb.ModelHandler // handlers for customized model modification.
|
||||
}
|
||||
|
||||
// TSimFlowPoolColumns defines and stores column names for the table t_sim_flow_pool.
|
||||
type TSimFlowPoolColumns struct {
|
||||
Id string // 主键ID
|
||||
FlowPoolCode string // 流量池编号
|
||||
DeptId string // 客户编号
|
||||
PackageCodes string // 套餐编号列表(冗余)
|
||||
PoolPackageCode string // 池套餐编码
|
||||
CardCount string // 关联卡数量
|
||||
CardFlowMb string // 套餐卡流量MB
|
||||
PoolPackageFlowMb string // 套内总流量MB
|
||||
AddOnFlowMb string // 加油包总流量MB
|
||||
TotalFlowMb string // 总流量MB(套内+加油包)
|
||||
UsedFlowMb string // 已使用流量MB
|
||||
RemainFlowMb string // 剩余流量MB
|
||||
LastStatTime string // 最后流量统计时间
|
||||
PoolStatus string // 状态:0 正常、1 停用、2 锁定
|
||||
Remark string // 备注
|
||||
TenantId string // 租户ID
|
||||
CreatedDept string // 创建部门
|
||||
CreatedBy string // 创建者
|
||||
CreatedAt string // 创建时间
|
||||
UpdatedBy string // 更新者
|
||||
UpdatedAt string // 更新时间
|
||||
}
|
||||
|
||||
// tSimFlowPoolColumns holds the columns for the table t_sim_flow_pool.
|
||||
var tSimFlowPoolColumns = TSimFlowPoolColumns{
|
||||
Id: "id",
|
||||
FlowPoolCode: "flow_pool_code",
|
||||
DeptId: "dept_id",
|
||||
PackageCodes: "package_codes",
|
||||
PoolPackageCode: "pool_package_code",
|
||||
CardCount: "card_count",
|
||||
CardFlowMb: "card_flow_mb",
|
||||
PoolPackageFlowMb: "pool_package_flow_mb",
|
||||
AddOnFlowMb: "add_on_flow_mb",
|
||||
TotalFlowMb: "total_flow_mb",
|
||||
UsedFlowMb: "used_flow_mb",
|
||||
RemainFlowMb: "remain_flow_mb",
|
||||
LastStatTime: "last_stat_time",
|
||||
PoolStatus: "pool_status",
|
||||
Remark: "remark",
|
||||
TenantId: "tenant_id",
|
||||
CreatedDept: "created_dept",
|
||||
CreatedBy: "created_by",
|
||||
CreatedAt: "created_at",
|
||||
UpdatedBy: "updated_by",
|
||||
UpdatedAt: "updated_at",
|
||||
}
|
||||
|
||||
// NewTSimFlowPoolDao creates and returns a new DAO object for table data access.
|
||||
func NewTSimFlowPoolDao(handlers ...gdb.ModelHandler) *TSimFlowPoolDao {
|
||||
return &TSimFlowPoolDao{
|
||||
group: "default",
|
||||
table: "t_sim_flow_pool",
|
||||
columns: tSimFlowPoolColumns,
|
||||
handlers: handlers,
|
||||
}
|
||||
}
|
||||
|
||||
// DB retrieves and returns the underlying raw database management object of the current DAO.
|
||||
func (dao *TSimFlowPoolDao) DB() gdb.DB {
|
||||
return g.DB(dao.group)
|
||||
}
|
||||
|
||||
// Table returns the table name of the current DAO.
|
||||
func (dao *TSimFlowPoolDao) Table() string {
|
||||
return dao.table
|
||||
}
|
||||
|
||||
// Columns returns all column names of the current DAO.
|
||||
func (dao *TSimFlowPoolDao) Columns() TSimFlowPoolColumns {
|
||||
return dao.columns
|
||||
}
|
||||
|
||||
// Group returns the database configuration group name of the current DAO.
|
||||
func (dao *TSimFlowPoolDao) Group() string {
|
||||
return dao.group
|
||||
}
|
||||
|
||||
// Ctx creates and returns a Model for the current DAO. It automatically sets the context for the current operation.
|
||||
func (dao *TSimFlowPoolDao) Ctx(ctx context.Context) *gdb.Model {
|
||||
model := dao.DB().Model(dao.table)
|
||||
for _, handler := range dao.handlers {
|
||||
model = handler(model)
|
||||
}
|
||||
return model.Safe().Ctx(ctx)
|
||||
}
|
||||
|
||||
// Transaction wraps the transaction logic using function f.
|
||||
// It rolls back the transaction and returns the error if function f returns a non-nil error.
|
||||
// It commits the transaction and returns nil if function f returns nil.
|
||||
//
|
||||
// Note: Do not commit or roll back the transaction in function f,
|
||||
// as it is automatically handled by this function.
|
||||
func (dao *TSimFlowPoolDao) Transaction(ctx context.Context, f func(ctx context.Context, tx gdb.TX) error) (err error) {
|
||||
return dao.Ctx(ctx).Transaction(ctx, f)
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
// ==========================================================================
|
||||
// Code generated and maintained by GoFrame CLI tool. DO NOT EDIT.
|
||||
// ==========================================================================
|
||||
|
||||
package internal
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/gogf/gf/v2/database/gdb"
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
)
|
||||
|
||||
// TUserMessageDao is the data access object for the table t_user_message.
|
||||
type TUserMessageDao struct {
|
||||
table string // table is the underlying table name of the DAO.
|
||||
group string // group is the database configuration group name of the current DAO.
|
||||
columns TUserMessageColumns // columns contains all the column names of Table for convenient usage.
|
||||
handlers []gdb.ModelHandler // handlers for customized model modification.
|
||||
}
|
||||
|
||||
// TUserMessageColumns defines and stores column names for the table t_user_message.
|
||||
type TUserMessageColumns struct {
|
||||
MessageId string // 主键ID
|
||||
MessageLogId string // 消息日志ID
|
||||
UserId string // 用户ID
|
||||
IsRead string // 是否已读
|
||||
TenantId string // 租户ID
|
||||
CreatedDept string // 创建部门
|
||||
CreatedBy string // 创建者
|
||||
CreatedAt string // 创建时间
|
||||
UpdatedBy string // 更新者
|
||||
UpdatedAt string // 更新时间
|
||||
}
|
||||
|
||||
// tUserMessageColumns holds the columns for the table t_user_message.
|
||||
var tUserMessageColumns = TUserMessageColumns{
|
||||
MessageId: "message_id",
|
||||
MessageLogId: "message_log_id",
|
||||
UserId: "user_id",
|
||||
IsRead: "is_read",
|
||||
TenantId: "tenant_id",
|
||||
CreatedDept: "created_dept",
|
||||
CreatedBy: "created_by",
|
||||
CreatedAt: "created_at",
|
||||
UpdatedBy: "updated_by",
|
||||
UpdatedAt: "updated_at",
|
||||
}
|
||||
|
||||
// NewTUserMessageDao creates and returns a new DAO object for table data access.
|
||||
func NewTUserMessageDao(handlers ...gdb.ModelHandler) *TUserMessageDao {
|
||||
return &TUserMessageDao{
|
||||
group: "default",
|
||||
table: "t_user_message",
|
||||
columns: tUserMessageColumns,
|
||||
handlers: handlers,
|
||||
}
|
||||
}
|
||||
|
||||
// DB retrieves and returns the underlying raw database management object of the current DAO.
|
||||
func (dao *TUserMessageDao) DB() gdb.DB {
|
||||
return g.DB(dao.group)
|
||||
}
|
||||
|
||||
// Table returns the table name of the current DAO.
|
||||
func (dao *TUserMessageDao) Table() string {
|
||||
return dao.table
|
||||
}
|
||||
|
||||
// Columns returns all column names of the current DAO.
|
||||
func (dao *TUserMessageDao) Columns() TUserMessageColumns {
|
||||
return dao.columns
|
||||
}
|
||||
|
||||
// Group returns the database configuration group name of the current DAO.
|
||||
func (dao *TUserMessageDao) Group() string {
|
||||
return dao.group
|
||||
}
|
||||
|
||||
// Ctx creates and returns a Model for the current DAO. It automatically sets the context for the current operation.
|
||||
func (dao *TUserMessageDao) Ctx(ctx context.Context) *gdb.Model {
|
||||
model := dao.DB().Model(dao.table)
|
||||
for _, handler := range dao.handlers {
|
||||
model = handler(model)
|
||||
}
|
||||
return model.Safe().Ctx(ctx)
|
||||
}
|
||||
|
||||
// Transaction wraps the transaction logic using function f.
|
||||
// It rolls back the transaction and returns the error if function f returns a non-nil error.
|
||||
// It commits the transaction and returns nil if function f returns nil.
|
||||
//
|
||||
// Note: Do not commit or roll back the transaction in function f,
|
||||
// as it is automatically handled by this function.
|
||||
func (dao *TUserMessageDao) Transaction(ctx context.Context, f func(ctx context.Context, tx gdb.TX) error) (err error) {
|
||||
return dao.Ctx(ctx).Transaction(ctx, f)
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
// ==========================================================================
|
||||
// Code generated and maintained by GoFrame CLI tool. DO NOT EDIT.
|
||||
// ==========================================================================
|
||||
|
||||
package internal
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/gogf/gf/v2/database/gdb"
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
)
|
||||
|
||||
// TWarnConfigDao is the data access object for the table t_warn_config.
|
||||
type TWarnConfigDao struct {
|
||||
table string // table is the underlying table name of the DAO.
|
||||
group string // group is the database configuration group name of the current DAO.
|
||||
columns TWarnConfigColumns // columns contains all the column names of Table for convenient usage.
|
||||
handlers []gdb.ModelHandler // handlers for customized model modification.
|
||||
}
|
||||
|
||||
// TWarnConfigColumns defines and stores column names for the table t_warn_config.
|
||||
type TWarnConfigColumns struct {
|
||||
Id string // 主键ID
|
||||
TenantId string // 租户ID
|
||||
BatchNo string // 批次编号
|
||||
ItemType string // 对象类型:1卡,2流量池
|
||||
ItemId string // 对象ID:单卡为t_sim_card_info.id,流量池为t_sim_flow_pool.id
|
||||
ItemCode string // 对象编码快照:ICCID/MSISDN/流量池编码
|
||||
WarnUnit string // 预警单位:1MB,2百分比,3天数,4月数
|
||||
FirstWarn string // 第一档预警阈值,单位由warn_unit决定
|
||||
SecondWarn string // 第二档预警阈值,单位由warn_unit决定
|
||||
ThirdWarn string // 第三档预警阈值,单位由warn_unit决定
|
||||
WarnMethods string // 预警方式:0短信,1邮箱,2电话,3平台消息;多选逗号分隔
|
||||
WarnPhones string // 预警手机号,多个逗号分隔
|
||||
WarnEmails string // 预警邮箱,多个逗号分隔
|
||||
AutoAction string // 自动动作:0无,2断网;第一期只保存配置不自动执行
|
||||
IsEnabled string // 是否启用:1启用,0停用
|
||||
WxTemplateId string // 微信订阅消息模板ID
|
||||
Remark string // 备注
|
||||
CreatedDept string // 创建部门
|
||||
CreatedBy string // 创建者
|
||||
CreatedAt string // 创建时间
|
||||
UpdatedBy string // 更新者
|
||||
UpdatedAt string // 更新时间
|
||||
AppNotifyEnabled string // 是否开启小程序预警通知:0关闭,1开启
|
||||
}
|
||||
|
||||
// tWarnConfigColumns holds the columns for the table t_warn_config.
|
||||
var tWarnConfigColumns = TWarnConfigColumns{
|
||||
Id: "id",
|
||||
TenantId: "tenant_id",
|
||||
BatchNo: "batch_no",
|
||||
ItemType: "item_type",
|
||||
ItemId: "item_id",
|
||||
ItemCode: "item_code",
|
||||
WarnUnit: "warn_unit",
|
||||
FirstWarn: "first_warn",
|
||||
SecondWarn: "second_warn",
|
||||
ThirdWarn: "third_warn",
|
||||
WarnMethods: "warn_methods",
|
||||
WarnPhones: "warn_phones",
|
||||
WarnEmails: "warn_emails",
|
||||
AutoAction: "auto_action",
|
||||
IsEnabled: "is_enabled",
|
||||
WxTemplateId: "wx_template_id",
|
||||
Remark: "remark",
|
||||
CreatedDept: "created_dept",
|
||||
CreatedBy: "created_by",
|
||||
CreatedAt: "created_at",
|
||||
UpdatedBy: "updated_by",
|
||||
UpdatedAt: "updated_at",
|
||||
AppNotifyEnabled: "app_notify_enabled",
|
||||
}
|
||||
|
||||
// NewTWarnConfigDao creates and returns a new DAO object for table data access.
|
||||
func NewTWarnConfigDao(handlers ...gdb.ModelHandler) *TWarnConfigDao {
|
||||
return &TWarnConfigDao{
|
||||
group: "default",
|
||||
table: "t_warn_config",
|
||||
columns: tWarnConfigColumns,
|
||||
handlers: handlers,
|
||||
}
|
||||
}
|
||||
|
||||
// DB retrieves and returns the underlying raw database management object of the current DAO.
|
||||
func (dao *TWarnConfigDao) DB() gdb.DB {
|
||||
return g.DB(dao.group)
|
||||
}
|
||||
|
||||
// Table returns the table name of the current DAO.
|
||||
func (dao *TWarnConfigDao) Table() string {
|
||||
return dao.table
|
||||
}
|
||||
|
||||
// Columns returns all column names of the current DAO.
|
||||
func (dao *TWarnConfigDao) Columns() TWarnConfigColumns {
|
||||
return dao.columns
|
||||
}
|
||||
|
||||
// Group returns the database configuration group name of the current DAO.
|
||||
func (dao *TWarnConfigDao) Group() string {
|
||||
return dao.group
|
||||
}
|
||||
|
||||
// Ctx creates and returns a Model for the current DAO. It automatically sets the context for the current operation.
|
||||
func (dao *TWarnConfigDao) Ctx(ctx context.Context) *gdb.Model {
|
||||
model := dao.DB().Model(dao.table)
|
||||
for _, handler := range dao.handlers {
|
||||
model = handler(model)
|
||||
}
|
||||
return model.Safe().Ctx(ctx)
|
||||
}
|
||||
|
||||
// Transaction wraps the transaction logic using function f.
|
||||
// It rolls back the transaction and returns the error if function f returns a non-nil error.
|
||||
// It commits the transaction and returns nil if function f returns nil.
|
||||
//
|
||||
// Note: Do not commit or roll back the transaction in function f,
|
||||
// as it is automatically handled by this function.
|
||||
func (dao *TWarnConfigDao) Transaction(ctx context.Context, f func(ctx context.Context, tx gdb.TX) error) (err error) {
|
||||
return dao.Ctx(ctx).Transaction(ctx, f)
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
// ==========================================================================
|
||||
// Code generated and maintained by GoFrame CLI tool. DO NOT EDIT.
|
||||
// ==========================================================================
|
||||
|
||||
package internal
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/gogf/gf/v2/database/gdb"
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
)
|
||||
|
||||
// TWarnLogDao is the data access object for the table t_warn_log.
|
||||
type TWarnLogDao struct {
|
||||
table string // table is the underlying table name of the DAO.
|
||||
group string // group is the database configuration group name of the current DAO.
|
||||
columns TWarnLogColumns // columns contains all the column names of Table for convenient usage.
|
||||
handlers []gdb.ModelHandler // handlers for customized model modification.
|
||||
}
|
||||
|
||||
// TWarnLogColumns defines and stores column names for the table t_warn_log.
|
||||
type TWarnLogColumns struct {
|
||||
Id string // 主键ID
|
||||
TenantId string // 租户ID
|
||||
ConfigId string // 预警配置ID
|
||||
ItemType string // 对象类型:1单卡,2流量池
|
||||
ItemId string // 对象ID:单卡为t_sim_card_info.id,流量池为t_sim_flow_pool.id
|
||||
ItemCode string // 对象编码快照:ICCID/MSISDN/流量池编码
|
||||
WarnStage string // 触发档位:1第一档,2第二档,3第三档
|
||||
WarnLevel string // 预警级别:1提示,2注意,3警告,4危险,5紧急
|
||||
WarnUnit string // 预警单位/维度:1MB,2百分比,3天数,4月数
|
||||
ThresholdValue string // 触发阈值,单位由warn_unit决定
|
||||
ActualValue string // 触发时实际值,单位由warn_unit决定
|
||||
TotalFlowMb string // 触发时总流量MB;流量类预警使用
|
||||
RemainFlowMb string // 触发时剩余流量MB;流量类预警使用
|
||||
WarnMethods string // 实际预警方式快照
|
||||
WarnPhones string // 实际预警手机号快照
|
||||
WarnEmails string // 实际预警邮箱快照
|
||||
MessageLogId string // 平台消息ID;聚合通知时可能为空
|
||||
WarnTime string // 预警时间
|
||||
Remark string // 备注
|
||||
CreatedDept string // 创建部门
|
||||
CreatedBy string // 创建者
|
||||
CreatedAt string // 创建时间
|
||||
UpdatedBy string // 更新者
|
||||
UpdatedAt string // 更新时间
|
||||
}
|
||||
|
||||
// tWarnLogColumns holds the columns for the table t_warn_log.
|
||||
var tWarnLogColumns = TWarnLogColumns{
|
||||
Id: "id",
|
||||
TenantId: "tenant_id",
|
||||
ConfigId: "config_id",
|
||||
ItemType: "item_type",
|
||||
ItemId: "item_id",
|
||||
ItemCode: "item_code",
|
||||
WarnStage: "warn_stage",
|
||||
WarnLevel: "warn_level",
|
||||
WarnUnit: "warn_unit",
|
||||
ThresholdValue: "threshold_value",
|
||||
ActualValue: "actual_value",
|
||||
TotalFlowMb: "total_flow_mb",
|
||||
RemainFlowMb: "remain_flow_mb",
|
||||
WarnMethods: "warn_methods",
|
||||
WarnPhones: "warn_phones",
|
||||
WarnEmails: "warn_emails",
|
||||
MessageLogId: "message_log_id",
|
||||
WarnTime: "warn_time",
|
||||
Remark: "remark",
|
||||
CreatedDept: "created_dept",
|
||||
CreatedBy: "created_by",
|
||||
CreatedAt: "created_at",
|
||||
UpdatedBy: "updated_by",
|
||||
UpdatedAt: "updated_at",
|
||||
}
|
||||
|
||||
// NewTWarnLogDao creates and returns a new DAO object for table data access.
|
||||
func NewTWarnLogDao(handlers ...gdb.ModelHandler) *TWarnLogDao {
|
||||
return &TWarnLogDao{
|
||||
group: "default",
|
||||
table: "t_warn_log",
|
||||
columns: tWarnLogColumns,
|
||||
handlers: handlers,
|
||||
}
|
||||
}
|
||||
|
||||
// DB retrieves and returns the underlying raw database management object of the current DAO.
|
||||
func (dao *TWarnLogDao) DB() gdb.DB {
|
||||
return g.DB(dao.group)
|
||||
}
|
||||
|
||||
// Table returns the table name of the current DAO.
|
||||
func (dao *TWarnLogDao) Table() string {
|
||||
return dao.table
|
||||
}
|
||||
|
||||
// Columns returns all column names of the current DAO.
|
||||
func (dao *TWarnLogDao) Columns() TWarnLogColumns {
|
||||
return dao.columns
|
||||
}
|
||||
|
||||
// Group returns the database configuration group name of the current DAO.
|
||||
func (dao *TWarnLogDao) Group() string {
|
||||
return dao.group
|
||||
}
|
||||
|
||||
// Ctx creates and returns a Model for the current DAO. It automatically sets the context for the current operation.
|
||||
func (dao *TWarnLogDao) Ctx(ctx context.Context) *gdb.Model {
|
||||
model := dao.DB().Model(dao.table)
|
||||
for _, handler := range dao.handlers {
|
||||
model = handler(model)
|
||||
}
|
||||
return model.Safe().Ctx(ctx)
|
||||
}
|
||||
|
||||
// Transaction wraps the transaction logic using function f.
|
||||
// It rolls back the transaction and returns the error if function f returns a non-nil error.
|
||||
// It commits the transaction and returns nil if function f returns nil.
|
||||
//
|
||||
// Note: Do not commit or roll back the transaction in function f,
|
||||
// as it is automatically handled by this function.
|
||||
func (dao *TWarnLogDao) Transaction(ctx context.Context, f func(ctx context.Context, tx gdb.TX) error) (err error) {
|
||||
return dao.Ctx(ctx).Transaction(ctx, f)
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
// ==========================================================================
|
||||
// Code generated and maintained by GoFrame CLI tool. DO NOT EDIT.
|
||||
// ==========================================================================
|
||||
|
||||
package internal
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/gogf/gf/v2/database/gdb"
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
)
|
||||
|
||||
// TWoMainDao is the data access object for the table t_wo_main.
|
||||
type TWoMainDao struct {
|
||||
table string // table is the underlying table name of the DAO.
|
||||
group string // group is the database configuration group name of the current DAO.
|
||||
columns TWoMainColumns // columns contains all the column names of Table for convenient usage.
|
||||
handlers []gdb.ModelHandler // handlers for customized model modification.
|
||||
}
|
||||
|
||||
// TWoMainColumns defines and stores column names for the table t_wo_main.
|
||||
type TWoMainColumns struct {
|
||||
Id string // 主键ID
|
||||
WoNo string // 工单编号
|
||||
WoType string // 工单类型 0卡,1模组,2设备,3订单,4余额,5提现
|
||||
RelatedId string // 关联业务ID
|
||||
Title string // 工单标题
|
||||
Content string // 问题描述
|
||||
Attachment string // 附件
|
||||
CreatorType string // 创建人类型:1终端用户,2代理商
|
||||
CreatorDeptId string // 创建机构ID
|
||||
CreatorUserId string // 创建用户ID
|
||||
CreatorName string // 创建人姓名
|
||||
CreatorPhone string // 创建人手机号
|
||||
AssignDeptId string // 分派方机构ID
|
||||
AssignUserId string // 分派人用户ID
|
||||
CurrentStatus string // 当前处理状态:1已分派,2处理中,3已解决,4已完结_关单,5已取消
|
||||
HandlerDeptId string // 处理方机构ID
|
||||
HandlerUserId string // 处理人用户ID
|
||||
FirstResponseTime string // 首次响应时间
|
||||
ResolveTime string // 确认时间
|
||||
CloseTime string // 关闭时间
|
||||
SatisfactionLevel string // 满意度:1-5分
|
||||
EvalContent string // 评价内容
|
||||
EvalTime string // 评价时间
|
||||
Remark string // 备注
|
||||
TenantId string // 租户ID
|
||||
CreatedDept string // 创建部门
|
||||
CreatedBy string // 创建者
|
||||
CreatedAt string // 创建时间
|
||||
UpdatedBy string // 更新者
|
||||
UpdatedAt string // 更新时间
|
||||
}
|
||||
|
||||
// tWoMainColumns holds the columns for the table t_wo_main.
|
||||
var tWoMainColumns = TWoMainColumns{
|
||||
Id: "id",
|
||||
WoNo: "wo_no",
|
||||
WoType: "wo_type",
|
||||
RelatedId: "related_id",
|
||||
Title: "title",
|
||||
Content: "content",
|
||||
Attachment: "attachment",
|
||||
CreatorType: "creator_type",
|
||||
CreatorDeptId: "creator_dept_id",
|
||||
CreatorUserId: "creator_user_id",
|
||||
CreatorName: "creator_name",
|
||||
CreatorPhone: "creator_phone",
|
||||
AssignDeptId: "assign_dept_id",
|
||||
AssignUserId: "assign_user_id",
|
||||
CurrentStatus: "current_status",
|
||||
HandlerDeptId: "handler_dept_id",
|
||||
HandlerUserId: "handler_user_id",
|
||||
FirstResponseTime: "first_response_time",
|
||||
ResolveTime: "resolve_time",
|
||||
CloseTime: "close_time",
|
||||
SatisfactionLevel: "satisfaction_level",
|
||||
EvalContent: "eval_content",
|
||||
EvalTime: "eval_time",
|
||||
Remark: "remark",
|
||||
TenantId: "tenant_id",
|
||||
CreatedDept: "created_dept",
|
||||
CreatedBy: "created_by",
|
||||
CreatedAt: "created_at",
|
||||
UpdatedBy: "updated_by",
|
||||
UpdatedAt: "updated_at",
|
||||
}
|
||||
|
||||
// NewTWoMainDao creates and returns a new DAO object for table data access.
|
||||
func NewTWoMainDao(handlers ...gdb.ModelHandler) *TWoMainDao {
|
||||
return &TWoMainDao{
|
||||
group: "default",
|
||||
table: "t_wo_main",
|
||||
columns: tWoMainColumns,
|
||||
handlers: handlers,
|
||||
}
|
||||
}
|
||||
|
||||
// DB retrieves and returns the underlying raw database management object of the current DAO.
|
||||
func (dao *TWoMainDao) DB() gdb.DB {
|
||||
return g.DB(dao.group)
|
||||
}
|
||||
|
||||
// Table returns the table name of the current DAO.
|
||||
func (dao *TWoMainDao) Table() string {
|
||||
return dao.table
|
||||
}
|
||||
|
||||
// Columns returns all column names of the current DAO.
|
||||
func (dao *TWoMainDao) Columns() TWoMainColumns {
|
||||
return dao.columns
|
||||
}
|
||||
|
||||
// Group returns the database configuration group name of the current DAO.
|
||||
func (dao *TWoMainDao) Group() string {
|
||||
return dao.group
|
||||
}
|
||||
|
||||
// Ctx creates and returns a Model for the current DAO. It automatically sets the context for the current operation.
|
||||
func (dao *TWoMainDao) Ctx(ctx context.Context) *gdb.Model {
|
||||
model := dao.DB().Model(dao.table)
|
||||
for _, handler := range dao.handlers {
|
||||
model = handler(model)
|
||||
}
|
||||
return model.Safe().Ctx(ctx)
|
||||
}
|
||||
|
||||
// Transaction wraps the transaction logic using function f.
|
||||
// It rolls back the transaction and returns the error if function f returns a non-nil error.
|
||||
// It commits the transaction and returns nil if function f returns nil.
|
||||
//
|
||||
// Note: Do not commit or roll back the transaction in function f,
|
||||
// as it is automatically handled by this function.
|
||||
func (dao *TWoMainDao) Transaction(ctx context.Context, f func(ctx context.Context, tx gdb.TX) error) (err error) {
|
||||
return dao.Ctx(ctx).Transaction(ctx, f)
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
// ==========================================================================
|
||||
// Code generated and maintained by GoFrame CLI tool. DO NOT EDIT.
|
||||
// ==========================================================================
|
||||
|
||||
package internal
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/gogf/gf/v2/database/gdb"
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
)
|
||||
|
||||
// TWoRecordDao is the data access object for the table t_wo_record.
|
||||
type TWoRecordDao struct {
|
||||
table string // table is the underlying table name of the DAO.
|
||||
group string // group is the database configuration group name of the current DAO.
|
||||
columns TWoRecordColumns // columns contains all the column names of Table for convenient usage.
|
||||
handlers []gdb.ModelHandler // handlers for customized model modification.
|
||||
}
|
||||
|
||||
// TWoRecordColumns defines and stores column names for the table t_wo_record.
|
||||
type TWoRecordColumns struct {
|
||||
Id string // 主键ID
|
||||
WoNo string // 工单编号
|
||||
OperationType string // 操作类型:0创建,1分派,2处理,3完成,4关单,5取消
|
||||
OperationDeptId string // 操作机构ID
|
||||
OperationUserId string // 操作用户ID
|
||||
TargetDeptId string // 目标部门ID
|
||||
TargetUserId string // 目标用户ID
|
||||
BeforeStatus string // 变更前状态
|
||||
AfterStatus string // 变更后状态
|
||||
TenantId string // 租户ID
|
||||
CreatedDept string // 创建部门
|
||||
CreatedBy string // 创建者
|
||||
CreatedAt string // 创建时间
|
||||
UpdatedBy string // 更新者
|
||||
UpdatedAt string // 更新时间
|
||||
}
|
||||
|
||||
// tWoRecordColumns holds the columns for the table t_wo_record.
|
||||
var tWoRecordColumns = TWoRecordColumns{
|
||||
Id: "id",
|
||||
WoNo: "wo_no",
|
||||
OperationType: "operation_type",
|
||||
OperationDeptId: "operation_dept_id",
|
||||
OperationUserId: "operation_user_id",
|
||||
TargetDeptId: "target_dept_id",
|
||||
TargetUserId: "target_user_id",
|
||||
BeforeStatus: "before_status",
|
||||
AfterStatus: "after_status",
|
||||
TenantId: "tenant_id",
|
||||
CreatedDept: "created_dept",
|
||||
CreatedBy: "created_by",
|
||||
CreatedAt: "created_at",
|
||||
UpdatedBy: "updated_by",
|
||||
UpdatedAt: "updated_at",
|
||||
}
|
||||
|
||||
// NewTWoRecordDao creates and returns a new DAO object for table data access.
|
||||
func NewTWoRecordDao(handlers ...gdb.ModelHandler) *TWoRecordDao {
|
||||
return &TWoRecordDao{
|
||||
group: "default",
|
||||
table: "t_wo_record",
|
||||
columns: tWoRecordColumns,
|
||||
handlers: handlers,
|
||||
}
|
||||
}
|
||||
|
||||
// DB retrieves and returns the underlying raw database management object of the current DAO.
|
||||
func (dao *TWoRecordDao) DB() gdb.DB {
|
||||
return g.DB(dao.group)
|
||||
}
|
||||
|
||||
// Table returns the table name of the current DAO.
|
||||
func (dao *TWoRecordDao) Table() string {
|
||||
return dao.table
|
||||
}
|
||||
|
||||
// Columns returns all column names of the current DAO.
|
||||
func (dao *TWoRecordDao) Columns() TWoRecordColumns {
|
||||
return dao.columns
|
||||
}
|
||||
|
||||
// Group returns the database configuration group name of the current DAO.
|
||||
func (dao *TWoRecordDao) Group() string {
|
||||
return dao.group
|
||||
}
|
||||
|
||||
// Ctx creates and returns a Model for the current DAO. It automatically sets the context for the current operation.
|
||||
func (dao *TWoRecordDao) Ctx(ctx context.Context) *gdb.Model {
|
||||
model := dao.DB().Model(dao.table)
|
||||
for _, handler := range dao.handlers {
|
||||
model = handler(model)
|
||||
}
|
||||
return model.Safe().Ctx(ctx)
|
||||
}
|
||||
|
||||
// Transaction wraps the transaction logic using function f.
|
||||
// It rolls back the transaction and returns the error if function f returns a non-nil error.
|
||||
// It commits the transaction and returns nil if function f returns nil.
|
||||
//
|
||||
// Note: Do not commit or roll back the transaction in function f,
|
||||
// as it is automatically handled by this function.
|
||||
func (dao *TWoRecordDao) Transaction(ctx context.Context, f func(ctx context.Context, tx gdb.TX) error) (err error) {
|
||||
return dao.Ctx(ctx).Transaction(ctx, f)
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
// =================================================================================
|
||||
// This file is auto-generated by the GoFrame CLI tool. You may modify it as needed.
|
||||
// =================================================================================
|
||||
|
||||
package dao
|
||||
|
||||
import (
|
||||
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/dao/internal"
|
||||
)
|
||||
|
||||
// sysDeptDao is the data access object for the table sys_dept.
|
||||
// You can define custom methods on it to extend its functionality as needed.
|
||||
type sysDeptDao struct {
|
||||
*internal.SysDeptDao
|
||||
}
|
||||
|
||||
var (
|
||||
// SysDept is a globally accessible object for table sys_dept operations.
|
||||
SysDept = sysDeptDao{internal.NewSysDeptDao()}
|
||||
)
|
||||
|
||||
// Add your custom methods and functionality below.
|
||||
@@ -0,0 +1,22 @@
|
||||
// =================================================================================
|
||||
// This file is auto-generated by the GoFrame CLI tool. You may modify it as needed.
|
||||
// =================================================================================
|
||||
|
||||
package dao
|
||||
|
||||
import (
|
||||
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/dao/internal"
|
||||
)
|
||||
|
||||
// sysOssDao is the data access object for the table sys_oss.
|
||||
// You can define custom methods on it to extend its functionality as needed.
|
||||
type sysOssDao struct {
|
||||
*internal.SysOssDao
|
||||
}
|
||||
|
||||
var (
|
||||
// SysOss is a globally accessible object for table sys_oss operations.
|
||||
SysOss = sysOssDao{internal.NewSysOssDao()}
|
||||
)
|
||||
|
||||
// Add your custom methods and functionality below.
|
||||
@@ -0,0 +1,22 @@
|
||||
// =================================================================================
|
||||
// This file is auto-generated by the GoFrame CLI tool. You may modify it as needed.
|
||||
// =================================================================================
|
||||
|
||||
package dao
|
||||
|
||||
import (
|
||||
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/dao/internal"
|
||||
)
|
||||
|
||||
// tMessageLogDao is the data access object for the table t_message_log.
|
||||
// You can define custom methods on it to extend its functionality as needed.
|
||||
type tMessageLogDao struct {
|
||||
*internal.TMessageLogDao
|
||||
}
|
||||
|
||||
var (
|
||||
// TMessageLog is a globally accessible object for table t_message_log operations.
|
||||
TMessageLog = tMessageLogDao{internal.NewTMessageLogDao()}
|
||||
)
|
||||
|
||||
// Add your custom methods and functionality below.
|
||||
@@ -0,0 +1,22 @@
|
||||
// =================================================================================
|
||||
// This file is auto-generated by the GoFrame CLI tool. You may modify it as needed.
|
||||
// =================================================================================
|
||||
|
||||
package dao
|
||||
|
||||
import (
|
||||
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/dao/internal"
|
||||
)
|
||||
|
||||
// tResourceBatchDownloadDao is the data access object for the table t_resource_batch_download.
|
||||
// You can define custom methods on it to extend its functionality as needed.
|
||||
type tResourceBatchDownloadDao struct {
|
||||
*internal.TResourceBatchDownloadDao
|
||||
}
|
||||
|
||||
var (
|
||||
// TResourceBatchDownload is a globally accessible object for table t_resource_batch_download operations.
|
||||
TResourceBatchDownload = tResourceBatchDownloadDao{internal.NewTResourceBatchDownloadDao()}
|
||||
)
|
||||
|
||||
// Add your custom methods and functionality below.
|
||||
@@ -0,0 +1,22 @@
|
||||
// =================================================================================
|
||||
// This file is auto-generated by the GoFrame CLI tool. You may modify it as needed.
|
||||
// =================================================================================
|
||||
|
||||
package dao
|
||||
|
||||
import (
|
||||
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/dao/internal"
|
||||
)
|
||||
|
||||
// tSimCardCancellationDao is the data access object for the table t_sim_card_cancellation.
|
||||
// You can define custom methods on it to extend its functionality as needed.
|
||||
type tSimCardCancellationDao struct {
|
||||
*internal.TSimCardCancellationDao
|
||||
}
|
||||
|
||||
var (
|
||||
// TSimCardCancellation is a globally accessible object for table t_sim_card_cancellation operations.
|
||||
TSimCardCancellation = tSimCardCancellationDao{internal.NewTSimCardCancellationDao()}
|
||||
)
|
||||
|
||||
// Add your custom methods and functionality below.
|
||||
@@ -0,0 +1,22 @@
|
||||
// =================================================================================
|
||||
// This file is auto-generated by the GoFrame CLI tool. You may modify it as needed.
|
||||
// =================================================================================
|
||||
|
||||
package dao
|
||||
|
||||
import (
|
||||
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/dao/internal"
|
||||
)
|
||||
|
||||
// tSimCardInfoDao is the data access object for the table t_sim_card_info.
|
||||
// You can define custom methods on it to extend its functionality as needed.
|
||||
type tSimCardInfoDao struct {
|
||||
*internal.TSimCardInfoDao
|
||||
}
|
||||
|
||||
var (
|
||||
// TSimCardInfo is a globally accessible object for table t_sim_card_info operations.
|
||||
TSimCardInfo = tSimCardInfoDao{internal.NewTSimCardInfoDao()}
|
||||
)
|
||||
|
||||
// Add your custom methods and functionality below.
|
||||
@@ -0,0 +1,22 @@
|
||||
// =================================================================================
|
||||
// This file is auto-generated by the GoFrame CLI tool. You may modify it as needed.
|
||||
// =================================================================================
|
||||
|
||||
package dao
|
||||
|
||||
import (
|
||||
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/dao/internal"
|
||||
)
|
||||
|
||||
// tSimFlowPoolDao is the data access object for the table t_sim_flow_pool.
|
||||
// You can define custom methods on it to extend its functionality as needed.
|
||||
type tSimFlowPoolDao struct {
|
||||
*internal.TSimFlowPoolDao
|
||||
}
|
||||
|
||||
var (
|
||||
// TSimFlowPool is a globally accessible object for table t_sim_flow_pool operations.
|
||||
TSimFlowPool = tSimFlowPoolDao{internal.NewTSimFlowPoolDao()}
|
||||
)
|
||||
|
||||
// Add your custom methods and functionality below.
|
||||
@@ -0,0 +1,22 @@
|
||||
// =================================================================================
|
||||
// This file is auto-generated by the GoFrame CLI tool. You may modify it as needed.
|
||||
// =================================================================================
|
||||
|
||||
package dao
|
||||
|
||||
import (
|
||||
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/dao/internal"
|
||||
)
|
||||
|
||||
// tUserMessageDao is the data access object for the table t_user_message.
|
||||
// You can define custom methods on it to extend its functionality as needed.
|
||||
type tUserMessageDao struct {
|
||||
*internal.TUserMessageDao
|
||||
}
|
||||
|
||||
var (
|
||||
// TUserMessage is a globally accessible object for table t_user_message operations.
|
||||
TUserMessage = tUserMessageDao{internal.NewTUserMessageDao()}
|
||||
)
|
||||
|
||||
// Add your custom methods and functionality below.
|
||||
@@ -0,0 +1,22 @@
|
||||
// =================================================================================
|
||||
// This file is auto-generated by the GoFrame CLI tool. You may modify it as needed.
|
||||
// =================================================================================
|
||||
|
||||
package dao
|
||||
|
||||
import (
|
||||
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/dao/internal"
|
||||
)
|
||||
|
||||
// tWarnConfigDao is the data access object for the table t_warn_config.
|
||||
// You can define custom methods on it to extend its functionality as needed.
|
||||
type tWarnConfigDao struct {
|
||||
*internal.TWarnConfigDao
|
||||
}
|
||||
|
||||
var (
|
||||
// TWarnConfig is a globally accessible object for table t_warn_config operations.
|
||||
TWarnConfig = tWarnConfigDao{internal.NewTWarnConfigDao()}
|
||||
)
|
||||
|
||||
// Add your custom methods and functionality below.
|
||||
@@ -0,0 +1,22 @@
|
||||
// =================================================================================
|
||||
// This file is auto-generated by the GoFrame CLI tool. You may modify it as needed.
|
||||
// =================================================================================
|
||||
|
||||
package dao
|
||||
|
||||
import (
|
||||
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/dao/internal"
|
||||
)
|
||||
|
||||
// tWarnLogDao is the data access object for the table t_warn_log.
|
||||
// You can define custom methods on it to extend its functionality as needed.
|
||||
type tWarnLogDao struct {
|
||||
*internal.TWarnLogDao
|
||||
}
|
||||
|
||||
var (
|
||||
// TWarnLog is a globally accessible object for table t_warn_log operations.
|
||||
TWarnLog = tWarnLogDao{internal.NewTWarnLogDao()}
|
||||
)
|
||||
|
||||
// Add your custom methods and functionality below.
|
||||
@@ -0,0 +1,22 @@
|
||||
// =================================================================================
|
||||
// This file is auto-generated by the GoFrame CLI tool. You may modify it as needed.
|
||||
// =================================================================================
|
||||
|
||||
package dao
|
||||
|
||||
import (
|
||||
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/dao/internal"
|
||||
)
|
||||
|
||||
// tWoMainDao is the data access object for the table t_wo_main.
|
||||
// You can define custom methods on it to extend its functionality as needed.
|
||||
type tWoMainDao struct {
|
||||
*internal.TWoMainDao
|
||||
}
|
||||
|
||||
var (
|
||||
// TWoMain is a globally accessible object for table t_wo_main operations.
|
||||
TWoMain = tWoMainDao{internal.NewTWoMainDao()}
|
||||
)
|
||||
|
||||
// Add your custom methods and functionality below.
|
||||
@@ -0,0 +1,22 @@
|
||||
// =================================================================================
|
||||
// This file is auto-generated by the GoFrame CLI tool. You may modify it as needed.
|
||||
// =================================================================================
|
||||
|
||||
package dao
|
||||
|
||||
import (
|
||||
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/dao/internal"
|
||||
)
|
||||
|
||||
// tWoRecordDao is the data access object for the table t_wo_record.
|
||||
// You can define custom methods on it to extend its functionality as needed.
|
||||
type tWoRecordDao struct {
|
||||
*internal.TWoRecordDao
|
||||
}
|
||||
|
||||
var (
|
||||
// TWoRecord is a globally accessible object for table t_wo_record operations.
|
||||
TWoRecord = tWoRecordDao{internal.NewTWoRecordDao()}
|
||||
)
|
||||
|
||||
// Add your custom methods and functionality below.
|
||||
@@ -0,0 +1,802 @@
|
||||
// Package archive durably stores summary-only receipts for completed full-sync
|
||||
// runs and replay actions. It depends only on the shared full-sync contracts;
|
||||
// repository implementations and service wiring live elsewhere.
|
||||
package archive
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/gogf/gf/v2/errors/gerror"
|
||||
|
||||
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/fullsync/contract"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultInterval = time.Minute
|
||||
defaultBackoff = time.Second
|
||||
defaultMaxBackoff = time.Minute
|
||||
defaultMaxAttempts = 4
|
||||
defaultReplayLeaseTTL = time.Minute
|
||||
defaultReplayLeaseName = "fullsync-archive"
|
||||
defaultReleaseTimeout = 5 * time.Second
|
||||
maxRounds = 10000
|
||||
)
|
||||
|
||||
var errChecksumMismatch = gerror.New("archive checksum verification failed")
|
||||
|
||||
// Config controls the recovery scanner. Root is the archive directory. Zero
|
||||
// timing values select conservative defaults.
|
||||
type Config struct {
|
||||
Root string
|
||||
Interval time.Duration
|
||||
InitialBackoff time.Duration
|
||||
MaxBackoff time.Duration
|
||||
MaxAttempts int
|
||||
ReplayLeaseOwner string
|
||||
ReplayLeaseTTL time.Duration
|
||||
Now func() time.Time
|
||||
}
|
||||
|
||||
// ReplayRepositories are the contract boundaries used to recover replay
|
||||
// actions left in receipt_pending after a process restart.
|
||||
type ReplayRepositories struct {
|
||||
Discovery contract.ReplayWorkDiscovery
|
||||
Leases contract.ReplayLeaseStore
|
||||
Progress contract.ReplayProgressStore
|
||||
}
|
||||
|
||||
// Service publishes archive files and scans durable repository indexes at
|
||||
// startup and periodically. A Service may be configured for runs, actions, or
|
||||
// both.
|
||||
type Service struct {
|
||||
root string
|
||||
runDiscovery contract.ArchiveWorkDiscovery
|
||||
runLoader contract.ArchiveWorkLoader
|
||||
runRegistrar contract.ArchiveRegistrar
|
||||
replay ReplayRepositories
|
||||
interval time.Duration
|
||||
initialBackoff time.Duration
|
||||
maxBackoff time.Duration
|
||||
maxAttempts int
|
||||
leaseOwner string
|
||||
leaseTTL time.Duration
|
||||
now func() time.Time
|
||||
|
||||
// Publications are serialized so repeat writes in this process observe the
|
||||
// same final file timestamp and therefore produce the same receipt.
|
||||
publishMu sync.Mutex
|
||||
|
||||
mu sync.Mutex
|
||||
running bool
|
||||
cancel context.CancelFunc
|
||||
done chan struct{}
|
||||
|
||||
// These hooks keep failure-path tests deterministic without weakening the
|
||||
// production filesystem path.
|
||||
replaceFile func(string, string) error
|
||||
afterReplace func(string) error
|
||||
}
|
||||
|
||||
// New constructs a run-archive service with default scanner settings.
|
||||
func New(root string, discovery contract.ArchiveWorkDiscovery, loader contract.ArchiveWorkLoader, registrar contract.ArchiveRegistrar) *Service {
|
||||
return NewService(Config{Root: root}, discovery, loader, registrar)
|
||||
}
|
||||
|
||||
// NewService constructs a run-archive service. Use NewServiceWithReplay when
|
||||
// replay receipt recovery is also required.
|
||||
func NewService(cfg Config, discovery contract.ArchiveWorkDiscovery, loader contract.ArchiveWorkLoader, registrar contract.ArchiveRegistrar) *Service {
|
||||
return NewServiceWithReplay(cfg, discovery, loader, registrar, ReplayRepositories{})
|
||||
}
|
||||
|
||||
// NewServiceWithReplay constructs a lifecycle-enabled run and replay archive
|
||||
// service using only shared contract interfaces.
|
||||
func NewServiceWithReplay(
|
||||
cfg Config,
|
||||
discovery contract.ArchiveWorkDiscovery,
|
||||
loader contract.ArchiveWorkLoader,
|
||||
registrar contract.ArchiveRegistrar,
|
||||
replay ReplayRepositories,
|
||||
) *Service {
|
||||
if cfg.Interval <= 0 {
|
||||
cfg.Interval = defaultInterval
|
||||
}
|
||||
if cfg.InitialBackoff <= 0 {
|
||||
cfg.InitialBackoff = defaultBackoff
|
||||
}
|
||||
if cfg.MaxBackoff <= 0 {
|
||||
cfg.MaxBackoff = defaultMaxBackoff
|
||||
}
|
||||
if cfg.MaxBackoff < cfg.InitialBackoff {
|
||||
cfg.MaxBackoff = cfg.InitialBackoff
|
||||
}
|
||||
if cfg.MaxAttempts <= 0 {
|
||||
cfg.MaxAttempts = defaultMaxAttempts
|
||||
}
|
||||
if strings.TrimSpace(cfg.ReplayLeaseOwner) == "" {
|
||||
cfg.ReplayLeaseOwner = defaultReplayLeaseName
|
||||
}
|
||||
if cfg.ReplayLeaseTTL <= 0 {
|
||||
cfg.ReplayLeaseTTL = defaultReplayLeaseTTL
|
||||
}
|
||||
if cfg.Now == nil {
|
||||
cfg.Now = time.Now
|
||||
}
|
||||
|
||||
return &Service{
|
||||
root: cfg.Root,
|
||||
runDiscovery: discovery,
|
||||
runLoader: loader,
|
||||
runRegistrar: registrar,
|
||||
replay: replay,
|
||||
interval: cfg.Interval,
|
||||
initialBackoff: cfg.InitialBackoff,
|
||||
maxBackoff: cfg.MaxBackoff,
|
||||
maxAttempts: cfg.MaxAttempts,
|
||||
leaseOwner: cfg.ReplayLeaseOwner,
|
||||
leaseTTL: cfg.ReplayLeaseTTL,
|
||||
now: cfg.Now,
|
||||
replaceFile: atomicReplace,
|
||||
afterReplace: func(string) error { return nil },
|
||||
}
|
||||
}
|
||||
|
||||
// Start launches an immediate recovery scan followed by periodic scans. It is
|
||||
// idempotent while the service is running.
|
||||
func (s *Service) Start(parent context.Context) error {
|
||||
if parent == nil {
|
||||
return gerror.New("archive parent context is nil")
|
||||
}
|
||||
if err := s.validateConfiguration(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
if s.running {
|
||||
s.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
ctx, cancel := context.WithCancel(parent)
|
||||
done := make(chan struct{})
|
||||
s.cancel, s.done, s.running = cancel, done, true
|
||||
s.mu.Unlock()
|
||||
|
||||
go func() {
|
||||
defer func() {
|
||||
s.mu.Lock()
|
||||
if s.done == done {
|
||||
s.running = false
|
||||
s.cancel = nil
|
||||
}
|
||||
s.mu.Unlock()
|
||||
close(done)
|
||||
}()
|
||||
s.scanLoop(ctx)
|
||||
}()
|
||||
return nil
|
||||
}
|
||||
|
||||
// Stop cancels the scanner and waits for it to finish. It is safe to call more
|
||||
// than once.
|
||||
func (s *Service) Stop() error {
|
||||
s.mu.Lock()
|
||||
if !s.running {
|
||||
s.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
cancel, done := s.cancel, s.done
|
||||
s.mu.Unlock()
|
||||
|
||||
cancel()
|
||||
<-done
|
||||
return nil
|
||||
}
|
||||
|
||||
// Close implements the common service lifecycle convention.
|
||||
func (s *Service) Close() error { return s.Stop() }
|
||||
|
||||
// Done is closed when the current scanner invocation exits. It returns nil
|
||||
// before the service has been started.
|
||||
func (s *Service) Done() <-chan struct{} {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return s.done
|
||||
}
|
||||
|
||||
// RunOnce performs one recovery pass. It is useful for explicit lifecycle
|
||||
// wiring and deterministic integration tests.
|
||||
func (s *Service) RunOnce(ctx context.Context) error {
|
||||
if ctx == nil {
|
||||
return gerror.New("archive scan context is nil")
|
||||
}
|
||||
if err := s.validateConfiguration(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var scanErrors []error
|
||||
if s.runDiscovery != nil {
|
||||
if err := s.scanRuns(ctx); err != nil {
|
||||
scanErrors = append(scanErrors, gerror.Wrap(err, "scan run archives"))
|
||||
}
|
||||
}
|
||||
if s.replay.Discovery != nil && ctx.Err() == nil {
|
||||
if err := s.scanActions(ctx); err != nil {
|
||||
scanErrors = append(scanErrors, gerror.Wrap(err, "scan replay action archives"))
|
||||
}
|
||||
}
|
||||
return errors.Join(scanErrors...)
|
||||
}
|
||||
|
||||
// PublishRun atomically writes a run summary and then conditionally registers
|
||||
// the durable receipt against the work version.
|
||||
func (s *Service) PublishRun(ctx context.Context, work contract.ArchiveWork, snapshot contract.RunArchive) (contract.ArchiveReceipt, error) {
|
||||
if err := validateWork(work); err != nil {
|
||||
return contract.ArchiveReceipt{}, err
|
||||
}
|
||||
normalized, err := normalizeRunArchive(work, snapshot)
|
||||
if err != nil {
|
||||
return contract.ArchiveReceipt{}, err
|
||||
}
|
||||
|
||||
pathTime := normalized.Run.CompletedAt
|
||||
if pathTime.IsZero() {
|
||||
pathTime = normalized.Run.CreatedAt
|
||||
}
|
||||
receipt, err := s.publishJSON(ctx, "runs", work.RunID, pathTime, normalized)
|
||||
if err != nil {
|
||||
return contract.ArchiveReceipt{}, err
|
||||
}
|
||||
if s.runRegistrar != nil {
|
||||
if err := s.runRegistrar.RegisterArchive(ctx, work, receipt); err != nil {
|
||||
return contract.ArchiveReceipt{}, gerror.Wrap(err, "register run archive")
|
||||
}
|
||||
}
|
||||
return receipt, nil
|
||||
}
|
||||
|
||||
// PublishAction atomically writes a receipt_pending replay action summary. The
|
||||
// repository update is performed by ArchiveAction because it requires a fenced
|
||||
// replay lease.
|
||||
func (s *Service) PublishAction(ctx context.Context, action contract.ReplayAction) (contract.ArchiveReceipt, error) {
|
||||
normalized, err := normalizeAction(action)
|
||||
if err != nil {
|
||||
return contract.ArchiveReceipt{}, err
|
||||
}
|
||||
return s.publishJSON(ctx, "actions", normalized.ActionID, normalized.CreatedAt, normalized)
|
||||
}
|
||||
|
||||
// ArchiveRun loads, publishes, and registers one durable run work item.
|
||||
func (s *Service) ArchiveRun(ctx context.Context, work contract.ArchiveWork) error {
|
||||
if s.runLoader == nil {
|
||||
return gerror.New("archive run loader is nil")
|
||||
}
|
||||
if s.runRegistrar == nil {
|
||||
return gerror.New("archive run registrar is nil")
|
||||
}
|
||||
|
||||
snapshot, err := s.runLoader.LoadArchiveWork(ctx, work)
|
||||
if err != nil {
|
||||
s.recordRunFailure(ctx, work, err)
|
||||
return gerror.Wrap(err, "load run archive work")
|
||||
}
|
||||
if _, err = s.PublishRun(ctx, work, snapshot); err != nil {
|
||||
s.recordRunFailure(ctx, work, err)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ArchiveAction acquires a replay lease, atomically publishes the action
|
||||
// summary, and fences the completed receipt update through ReplayProgressStore.
|
||||
func (s *Service) ArchiveAction(ctx context.Context, action contract.ReplayAction) error {
|
||||
if s.replay.Leases == nil || s.replay.Progress == nil {
|
||||
return gerror.New("replay archive repositories are incomplete")
|
||||
}
|
||||
if action.Status != contract.ActionReceiptPending {
|
||||
return gerror.Wrap(contract.ErrInvalidArgument, "replay action is not receipt_pending")
|
||||
}
|
||||
if _, err := normalizeAction(action); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
lease, err := s.replay.Leases.AcquireReplayLease(ctx, action.ActionID, s.leaseOwner, s.leaseTTL)
|
||||
if err != nil {
|
||||
return gerror.Wrap(err, "acquire replay archive lease")
|
||||
}
|
||||
defer func() {
|
||||
releaseCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), defaultReleaseTimeout)
|
||||
defer cancel()
|
||||
_ = s.replay.Leases.ReleaseReplayLease(releaseCtx, lease)
|
||||
}()
|
||||
|
||||
receipt, err := s.PublishAction(ctx, action)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
lease, err = s.replay.Leases.RenewReplayLease(ctx, lease, s.leaseTTL)
|
||||
if err != nil {
|
||||
return gerror.Wrap(err, "renew replay archive lease")
|
||||
}
|
||||
completedAt := receipt.ArchivedAt
|
||||
_, err = s.replay.Progress.UpdateReplayAction(ctx, contract.ReplayActionUpdate{
|
||||
Lease: lease,
|
||||
ExpectedRevision: action.Revision,
|
||||
Status: contract.ActionCompleted,
|
||||
Progress: action.Progress,
|
||||
Receipt: &receipt,
|
||||
CompletedAt: &completedAt,
|
||||
})
|
||||
if err != nil {
|
||||
return gerror.Wrap(err, "register replay action archive")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) scanLoop(ctx context.Context) {
|
||||
_ = s.RunOnce(ctx)
|
||||
ticker := time.NewTicker(s.interval)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
_ = s.RunOnce(ctx)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) scanRuns(ctx context.Context) error {
|
||||
page := contract.PageRequest{Limit: contract.DefaultPageLimit}
|
||||
seenCursors := map[contract.Cursor]struct{}{page.Cursor: {}}
|
||||
var itemErrors []error
|
||||
for {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
result, err := s.runDiscovery.ListArchiveWork(ctx, page)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, work := range result.Items {
|
||||
err := s.retry(ctx, func() error { return s.ArchiveRun(ctx, work) })
|
||||
if err != nil {
|
||||
if ctx.Err() != nil {
|
||||
return ctx.Err()
|
||||
}
|
||||
itemErrors = append(itemErrors, gerror.Wrapf(err, "archive run %q", work.RunID))
|
||||
}
|
||||
}
|
||||
if result.NextCursor == "" {
|
||||
return errors.Join(itemErrors...)
|
||||
}
|
||||
if _, repeated := seenCursors[result.NextCursor]; repeated {
|
||||
return errors.Join(append(itemErrors, gerror.New("run archive pagination cursor did not advance"))...)
|
||||
}
|
||||
seenCursors[result.NextCursor] = struct{}{}
|
||||
page.Cursor = result.NextCursor
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) scanActions(ctx context.Context) error {
|
||||
page := contract.PageRequest{Limit: contract.DefaultPageLimit}
|
||||
seenCursors := map[contract.Cursor]struct{}{page.Cursor: {}}
|
||||
var itemErrors []error
|
||||
for {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
result, err := s.replay.Discovery.ListReplayWork(ctx, page)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, action := range result.Items {
|
||||
if action.Status != contract.ActionReceiptPending {
|
||||
continue
|
||||
}
|
||||
err := s.retry(ctx, func() error { return s.ArchiveAction(ctx, action) })
|
||||
if errors.Is(err, contract.ErrBusy) {
|
||||
continue
|
||||
}
|
||||
if err != nil {
|
||||
if ctx.Err() != nil {
|
||||
return ctx.Err()
|
||||
}
|
||||
itemErrors = append(itemErrors, gerror.Wrapf(err, "archive replay action %q", action.ActionID))
|
||||
}
|
||||
}
|
||||
if result.NextCursor == "" {
|
||||
return errors.Join(itemErrors...)
|
||||
}
|
||||
if _, repeated := seenCursors[result.NextCursor]; repeated {
|
||||
return errors.Join(append(itemErrors, gerror.New("replay archive pagination cursor did not advance"))...)
|
||||
}
|
||||
seenCursors[result.NextCursor] = struct{}{}
|
||||
page.Cursor = result.NextCursor
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) retry(ctx context.Context, fn func() error) error {
|
||||
delay := s.initialBackoff
|
||||
var lastErr error
|
||||
for attempt := 0; attempt < s.maxAttempts; attempt++ {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
lastErr = fn()
|
||||
if lastErr == nil || errors.Is(lastErr, contract.ErrBusy) {
|
||||
return lastErr
|
||||
}
|
||||
if attempt+1 == s.maxAttempts {
|
||||
break
|
||||
}
|
||||
timer := time.NewTimer(delay)
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
if !timer.Stop() {
|
||||
<-timer.C
|
||||
}
|
||||
return ctx.Err()
|
||||
case <-timer.C:
|
||||
}
|
||||
if delay <= s.maxBackoff/2 {
|
||||
delay *= 2
|
||||
} else {
|
||||
delay = s.maxBackoff
|
||||
}
|
||||
}
|
||||
return lastErr
|
||||
}
|
||||
|
||||
func (s *Service) recordRunFailure(ctx context.Context, work contract.ArchiveWork, archiveErr error) {
|
||||
if s.runRegistrar == nil || ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
_ = s.runRegistrar.RegisterArchiveFailure(ctx, work, contract.BoundedErrorMessage(archiveErr.Error()))
|
||||
}
|
||||
|
||||
func (s *Service) publishJSON(ctx context.Context, kind, id string, pathTime time.Time, value any) (contract.ArchiveReceipt, error) {
|
||||
if ctx == nil {
|
||||
return contract.ArchiveReceipt{}, gerror.New("archive publication context is nil")
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return contract.ArchiveReceipt{}, err
|
||||
}
|
||||
dir, final, relative, err := s.storagePath(kind, id, pathTime)
|
||||
if err != nil {
|
||||
return contract.ArchiveReceipt{}, err
|
||||
}
|
||||
|
||||
s.publishMu.Lock()
|
||||
defer s.publishMu.Unlock()
|
||||
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
return contract.ArchiveReceipt{}, gerror.Wrap(err, "create archive directory")
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return contract.ArchiveReceipt{}, err
|
||||
}
|
||||
|
||||
temp, err := os.CreateTemp(dir, "."+id+"-*.tmp")
|
||||
if err != nil {
|
||||
return contract.ArchiveReceipt{}, gerror.Wrap(err, "create archive temporary file")
|
||||
}
|
||||
tempName := temp.Name()
|
||||
defer func() { _ = os.Remove(tempName) }()
|
||||
|
||||
hasher := sha256.New()
|
||||
buffer := bufio.NewWriter(io.MultiWriter(contextWriter{ctx: ctx, writer: temp}, hasher))
|
||||
encoder := json.NewEncoder(buffer)
|
||||
if err = encoder.Encode(value); err == nil {
|
||||
err = buffer.Flush()
|
||||
}
|
||||
if err == nil {
|
||||
err = temp.Chmod(0o644)
|
||||
}
|
||||
if err == nil {
|
||||
err = temp.Sync()
|
||||
}
|
||||
closeErr := temp.Close()
|
||||
if err == nil {
|
||||
err = closeErr
|
||||
}
|
||||
if err != nil {
|
||||
_ = temp.Close()
|
||||
return contract.ArchiveReceipt{}, gerror.Wrap(err, "encode archive temporary file")
|
||||
}
|
||||
expectedChecksum := hex.EncodeToString(hasher.Sum(nil))
|
||||
|
||||
if receipt, ok, existingErr := s.existingReceipt(final, relative, kind, id, expectedChecksum); existingErr != nil {
|
||||
return contract.ArchiveReceipt{}, existingErr
|
||||
} else if ok {
|
||||
if err := syncDirectory(dir); err != nil {
|
||||
return contract.ArchiveReceipt{}, gerror.Wrap(err, "sync archive directory")
|
||||
}
|
||||
return receipt, nil
|
||||
}
|
||||
|
||||
if err := ctx.Err(); err != nil {
|
||||
return contract.ArchiveReceipt{}, err
|
||||
}
|
||||
if err := s.replaceFile(tempName, final); err != nil {
|
||||
return contract.ArchiveReceipt{}, gerror.Wrap(err, "publish archive file")
|
||||
}
|
||||
if err := syncDirectory(dir); err != nil {
|
||||
return contract.ArchiveReceipt{}, gerror.Wrap(err, "sync archive directory")
|
||||
}
|
||||
if err := s.afterReplace(final); err != nil {
|
||||
return contract.ArchiveReceipt{}, gerror.Wrap(err, "archive post-publish verification hook")
|
||||
}
|
||||
|
||||
stored, err := os.ReadFile(final)
|
||||
if err != nil {
|
||||
return contract.ArchiveReceipt{}, gerror.Wrap(err, "read published archive file")
|
||||
}
|
||||
storedSum := sha256.Sum256(stored)
|
||||
if hex.EncodeToString(storedSum[:]) != expectedChecksum {
|
||||
return contract.ArchiveReceipt{}, errChecksumMismatch
|
||||
}
|
||||
info, err := os.Stat(final)
|
||||
if err != nil {
|
||||
return contract.ArchiveReceipt{}, gerror.Wrap(err, "stat published archive file")
|
||||
}
|
||||
receipt := contract.ArchiveReceipt{
|
||||
Version: contract.ArchiveSchemaVersion,
|
||||
Path: relative,
|
||||
Checksum: expectedChecksum,
|
||||
ArchivedAt: archiveModTime(info, s.now),
|
||||
}
|
||||
if err := receipt.Validate(); err != nil {
|
||||
return contract.ArchiveReceipt{}, err
|
||||
}
|
||||
return receipt, nil
|
||||
}
|
||||
|
||||
func (s *Service) existingReceipt(final, relative, kind, id, expectedChecksum string) (contract.ArchiveReceipt, bool, error) {
|
||||
info, err := os.Lstat(final)
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return contract.ArchiveReceipt{}, false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return contract.ArchiveReceipt{}, false, gerror.Wrap(err, "inspect existing archive file")
|
||||
}
|
||||
if !info.Mode().IsRegular() {
|
||||
return contract.ArchiveReceipt{}, false, nil
|
||||
}
|
||||
stored, err := os.ReadFile(final)
|
||||
if err != nil {
|
||||
return contract.ArchiveReceipt{}, false, gerror.Wrap(err, "read existing archive file")
|
||||
}
|
||||
storedSum := sha256.Sum256(stored)
|
||||
if hex.EncodeToString(storedSum[:]) != expectedChecksum {
|
||||
if validExistingArchive(kind, id, stored) {
|
||||
return contract.ArchiveReceipt{}, false, gerror.Wrap(contract.ErrConflict, "archive path already contains different valid content")
|
||||
}
|
||||
return contract.ArchiveReceipt{}, false, nil
|
||||
}
|
||||
receipt := contract.ArchiveReceipt{
|
||||
Version: contract.ArchiveSchemaVersion,
|
||||
Path: relative,
|
||||
Checksum: expectedChecksum,
|
||||
ArchivedAt: archiveModTime(info, s.now),
|
||||
}
|
||||
if err := receipt.Validate(); err != nil {
|
||||
return contract.ArchiveReceipt{}, false, err
|
||||
}
|
||||
return receipt, true, nil
|
||||
}
|
||||
|
||||
func validExistingArchive(kind, id string, data []byte) bool {
|
||||
switch kind {
|
||||
case "runs":
|
||||
var snapshot contract.RunArchive
|
||||
if json.Unmarshal(data, &snapshot) != nil {
|
||||
return false
|
||||
}
|
||||
_, err := normalizeRunArchive(contract.ArchiveWork{RunID: id, Version: "existing"}, snapshot)
|
||||
return err == nil
|
||||
case "actions":
|
||||
var action contract.ReplayAction
|
||||
if json.Unmarshal(data, &action) != nil || action.ActionID != id {
|
||||
return false
|
||||
}
|
||||
_, err := normalizeAction(action)
|
||||
return err == nil
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func archiveModTime(info os.FileInfo, now func() time.Time) time.Time {
|
||||
archivedAt := info.ModTime().UTC()
|
||||
if archivedAt.IsZero() {
|
||||
archivedAt = now().UTC()
|
||||
}
|
||||
return archivedAt
|
||||
}
|
||||
|
||||
func (s *Service) storagePath(kind, id string, pathTime time.Time) (string, string, string, error) {
|
||||
if err := validateStorageID(id); err != nil {
|
||||
return "", "", "", err
|
||||
}
|
||||
if kind != "runs" && kind != "actions" {
|
||||
return "", "", "", gerror.Wrap(contract.ErrInvalidArgument, "unknown archive kind")
|
||||
}
|
||||
if strings.TrimSpace(s.root) == "" {
|
||||
return "", "", "", gerror.New("archive root is empty")
|
||||
}
|
||||
if pathTime.IsZero() {
|
||||
pathTime = s.now().UTC()
|
||||
} else {
|
||||
pathTime = pathTime.UTC()
|
||||
}
|
||||
if pathTime.Year() < 1 || pathTime.Year() > 9999 {
|
||||
return "", "", "", gerror.Wrap(contract.ErrInvalidArgument, "archive timestamp is outside the supported year range")
|
||||
}
|
||||
|
||||
root := filepath.Clean(s.root)
|
||||
prefix := "run-"
|
||||
if kind == "actions" {
|
||||
prefix = "action-"
|
||||
}
|
||||
relativeOS := prefix + id + ".json"
|
||||
final := filepath.Join(root, relativeOS)
|
||||
contained, err := filepath.Rel(root, final)
|
||||
if err != nil || filepath.IsAbs(contained) || contained == ".." || strings.HasPrefix(contained, ".."+string(filepath.Separator)) {
|
||||
return "", "", "", gerror.Wrap(contract.ErrInvalidArgument, "archive path escapes root")
|
||||
}
|
||||
return filepath.Dir(final), final, filepath.ToSlash(relativeOS), nil
|
||||
}
|
||||
|
||||
func (s *Service) validateConfiguration() error {
|
||||
if strings.TrimSpace(s.root) == "" {
|
||||
return gerror.New("archive root is empty")
|
||||
}
|
||||
if s.runDiscovery != nil && (s.runLoader == nil || s.runRegistrar == nil) {
|
||||
return gerror.New("run archive repositories are incomplete")
|
||||
}
|
||||
if s.replay.Discovery != nil && (s.replay.Leases == nil || s.replay.Progress == nil) {
|
||||
return gerror.New("replay archive repositories are incomplete")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateWork(work contract.ArchiveWork) error {
|
||||
if err := validateStorageID(work.RunID); err != nil {
|
||||
return err
|
||||
}
|
||||
if strings.TrimSpace(work.Version) == "" {
|
||||
return gerror.Wrap(contract.ErrInvalidArgument, "archive work version is required")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateStorageID(id string) error {
|
||||
if err := contract.ValidateID(id); err != nil {
|
||||
return err
|
||||
}
|
||||
if id == "." || id == ".." || filepath.VolumeName(id) != "" || strings.ContainsRune(id, ':') || filepath.Base(id) != id {
|
||||
return gerror.Wrap(contract.ErrInvalidArgument, "resource identifier is not safe for archive storage")
|
||||
}
|
||||
name := strings.ToUpper(strings.TrimSuffix(id, filepath.Ext(id)))
|
||||
switch name {
|
||||
case "CON", "PRN", "AUX", "NUL", "COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7", "COM8", "COM9", "LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8", "LPT9":
|
||||
return gerror.Wrap(contract.ErrInvalidArgument, "resource identifier is reserved by the filesystem")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func normalizeRunArchive(work contract.ArchiveWork, snapshot contract.RunArchive) (contract.RunArchive, error) {
|
||||
if snapshot.Version == 0 {
|
||||
snapshot.Version = contract.ArchiveSchemaVersion
|
||||
}
|
||||
if snapshot.Version != contract.ArchiveSchemaVersion {
|
||||
return contract.RunArchive{}, gerror.Wrap(contract.ErrInvalidArgument, "invalid archive schema version")
|
||||
}
|
||||
if snapshot.Run.RunID != work.RunID {
|
||||
return contract.RunArchive{}, gerror.Wrap(contract.ErrInvalidArgument, "archive run ID does not match work")
|
||||
}
|
||||
if !snapshot.Run.Status.Terminal() {
|
||||
return contract.RunArchive{}, gerror.Wrap(contract.ErrInvalidArgument, "only terminal runs can be archived")
|
||||
}
|
||||
if err := snapshot.Run.Validate(); err != nil {
|
||||
return contract.RunArchive{}, err
|
||||
}
|
||||
if len(snapshot.Rounds) > maxRounds {
|
||||
return contract.RunArchive{}, gerror.Wrap(contract.ErrInvalidArgument, "too many archived rounds")
|
||||
}
|
||||
|
||||
snapshot.Run = normalizeRunMeta(snapshot.Run)
|
||||
snapshot.Rounds = append([]contract.RoundStats(nil), snapshot.Rounds...)
|
||||
if snapshot.Rounds == nil {
|
||||
snapshot.Rounds = make([]contract.RoundStats, 0)
|
||||
}
|
||||
seen := make(map[int]struct{}, len(snapshot.Rounds))
|
||||
for i := range snapshot.Rounds {
|
||||
round := &snapshot.Rounds[i]
|
||||
if round.RunID != work.RunID {
|
||||
return contract.RunArchive{}, gerror.Wrap(contract.ErrInvalidArgument, "archived round belongs to another run")
|
||||
}
|
||||
if _, duplicate := seen[round.Round]; duplicate {
|
||||
return contract.RunArchive{}, gerror.Wrap(contract.ErrInvalidArgument, "duplicate archived round")
|
||||
}
|
||||
seen[round.Round] = struct{}{}
|
||||
if err := round.Validate(); err != nil {
|
||||
return contract.RunArchive{}, err
|
||||
}
|
||||
normalizedCounts, err := round.ErrorCounts.Normalized()
|
||||
if err != nil {
|
||||
return contract.RunArchive{}, err
|
||||
}
|
||||
round.ErrorCounts = normalizedCounts
|
||||
round.StartedAt = utcTimePtr(round.StartedAt)
|
||||
round.CompletedAt = utcTimePtr(round.CompletedAt)
|
||||
}
|
||||
sort.Slice(snapshot.Rounds, func(i, j int) bool { return snapshot.Rounds[i].Round < snapshot.Rounds[j].Round })
|
||||
return snapshot, nil
|
||||
}
|
||||
|
||||
func normalizeRunMeta(meta contract.RunMeta) contract.RunMeta {
|
||||
meta.CreatedAt = meta.CreatedAt.UTC()
|
||||
meta.ProducerCompletedAt = meta.ProducerCompletedAt.UTC()
|
||||
meta.CompletedAt = meta.CompletedAt.UTC()
|
||||
meta.StartedAt = utcTimePtr(meta.StartedAt)
|
||||
meta.ScanCompletedAt = utcTimePtr(meta.ScanCompletedAt)
|
||||
// Archive state describes the repository registration and is intentionally
|
||||
// excluded from the file whose receipt populates that state.
|
||||
meta.ArchiveStatus = ""
|
||||
meta.ArchiveVersion = 0
|
||||
meta.ArchivePath = ""
|
||||
meta.ArchiveChecksum = ""
|
||||
meta.ArchivedAt = nil
|
||||
return meta
|
||||
}
|
||||
|
||||
func normalizeAction(action contract.ReplayAction) (contract.ReplayAction, error) {
|
||||
if err := validateStorageID(action.ActionID); err != nil {
|
||||
return contract.ReplayAction{}, err
|
||||
}
|
||||
if action.Status != contract.ActionReceiptPending {
|
||||
return contract.ReplayAction{}, gerror.Wrap(contract.ErrInvalidArgument, "only receipt_pending replay actions can be archived")
|
||||
}
|
||||
action.Error = contract.BoundedErrorMessage(action.Error)
|
||||
action.CreatedAt = action.CreatedAt.UTC()
|
||||
action.UpdatedAt = action.UpdatedAt.UTC()
|
||||
action.CompletedAt = nil
|
||||
action.Receipt = nil
|
||||
if err := action.Validate(); err != nil {
|
||||
return contract.ReplayAction{}, err
|
||||
}
|
||||
return action, nil
|
||||
}
|
||||
|
||||
func utcTimePtr(value *time.Time) *time.Time {
|
||||
if value == nil {
|
||||
return nil
|
||||
}
|
||||
utc := value.UTC()
|
||||
return &utc
|
||||
}
|
||||
|
||||
type contextWriter struct {
|
||||
ctx context.Context
|
||||
writer io.Writer
|
||||
}
|
||||
|
||||
func (w contextWriter) Write(p []byte) (int, error) {
|
||||
if err := w.ctx.Err(); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return w.writer.Write(p)
|
||||
}
|
||||
@@ -0,0 +1,588 @@
|
||||
package archive
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"runtime"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gogf/gf/v2/errors/gerror"
|
||||
|
||||
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/fullsync/contract"
|
||||
)
|
||||
|
||||
func TestPublishRunIsDurableDeterministicAndSummaryOnly(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
repository := newFakeRunRepository(testRunArchive("run-1"))
|
||||
service := New(root, nil, nil, repository)
|
||||
work := contract.ArchiveWork{RunID: "run-1", Version: "version-1"}
|
||||
|
||||
staleDir := root
|
||||
if err := os.MkdirAll(staleDir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
stalePath := filepath.Join(staleDir, ".run-1-stale.tmp")
|
||||
if err := os.WriteFile(stalePath, []byte("incomplete"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
receipt, err := service.PublishRun(context.Background(), work, repository.snapshot)
|
||||
if err != nil {
|
||||
t.Fatalf("PublishRun() error = %v", err)
|
||||
}
|
||||
if receipt.Path != "run-run-1.json" {
|
||||
t.Fatalf("receipt path = %q", receipt.Path)
|
||||
}
|
||||
if err := receipt.Validate(); err != nil {
|
||||
t.Fatalf("receipt validation failed: %v", err)
|
||||
}
|
||||
|
||||
archivePath := filepath.Join(root, filepath.FromSlash(receipt.Path))
|
||||
info, err := os.Stat(archivePath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if runtime.GOOS != "windows" {
|
||||
if got := info.Mode().Perm(); got != 0o644 {
|
||||
t.Fatalf("archive mode = %o, want 644", got)
|
||||
}
|
||||
}
|
||||
data, err := os.ReadFile(archivePath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sum := sha256.Sum256(data)
|
||||
if got := hex.EncodeToString(sum[:]); got != receipt.Checksum {
|
||||
t.Fatalf("checksum = %q, want %q", receipt.Checksum, got)
|
||||
}
|
||||
for _, forbidden := range [][]byte{[]byte(`"archiveStatus"`), []byte(`"archivePath"`), []byte(`"taskId"`), []byte(`"card"`), []byte(`"message"`)} {
|
||||
if bytes.Contains(data, forbidden) {
|
||||
t.Fatalf("archive contains forbidden detail %s: %s", forbidden, data)
|
||||
}
|
||||
}
|
||||
var decoded contract.RunArchive
|
||||
if err := json.Unmarshal(data, &decoded); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(decoded.Rounds) != 2 || decoded.Rounds[0].Round != 1 || decoded.Rounds[1].Round != 2 {
|
||||
t.Fatalf("rounds were not deterministically sorted: %+v", decoded.Rounds)
|
||||
}
|
||||
if decoded.Rounds[1].ErrorCounts[contract.ErrorRateLimited] != 1 {
|
||||
t.Fatalf("error categories were not normalized: %+v", decoded.Rounds[1].ErrorCounts)
|
||||
}
|
||||
|
||||
repeated, err := service.PublishRun(context.Background(), work, repository.snapshot)
|
||||
if err != nil {
|
||||
t.Fatalf("repeated PublishRun() error = %v", err)
|
||||
}
|
||||
if !reflect.DeepEqual(repeated, receipt) {
|
||||
t.Fatalf("repeated receipt changed:\nfirst: %+v\nsecond: %+v", receipt, repeated)
|
||||
}
|
||||
if _, err := os.Stat(stalePath); err != nil {
|
||||
t.Fatalf("stale temp residue should not block publication: %v", err)
|
||||
}
|
||||
repository.mu.Lock()
|
||||
defer repository.mu.Unlock()
|
||||
if len(repository.receipts) != 2 || !reflect.DeepEqual(repository.receipts[0], repository.receipts[1]) {
|
||||
t.Fatalf("registered receipts = %+v", repository.receipts)
|
||||
}
|
||||
}
|
||||
|
||||
func TestArchiveActionPublishesAndRegistersFencedReceipt(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
action := testReplayAction("action-1")
|
||||
action.Error = strings.Repeat("x", contract.MaxErrorMessageBytes+50)
|
||||
repository := newFakeReplayRepository(action)
|
||||
service := NewServiceWithReplay(
|
||||
Config{Root: root},
|
||||
nil,
|
||||
nil,
|
||||
nil,
|
||||
ReplayRepositories{Discovery: repository, Leases: repository, Progress: repository},
|
||||
)
|
||||
|
||||
if err := service.ArchiveAction(context.Background(), action); err != nil {
|
||||
t.Fatalf("ArchiveAction() error = %v", err)
|
||||
}
|
||||
repository.mu.Lock()
|
||||
update := repository.update
|
||||
released := repository.released
|
||||
repository.mu.Unlock()
|
||||
if update == nil || update.Status != contract.ActionCompleted || update.Receipt == nil || update.CompletedAt == nil {
|
||||
t.Fatalf("registered update = %+v", update)
|
||||
}
|
||||
if update.ExpectedRevision != action.Revision || update.Receipt.ArchivedAt != *update.CompletedAt {
|
||||
t.Fatalf("receipt registration lost fencing/time: %+v", update)
|
||||
}
|
||||
if update.Lease.Token != "lease-token-2" {
|
||||
t.Fatalf("receipt registration used the unrenewed lease: %+v", update.Lease)
|
||||
}
|
||||
if released.Token != "lease-token-2" {
|
||||
t.Fatalf("archive released the stale lease: %+v", released)
|
||||
}
|
||||
if update.Receipt.Path != "action-action-1.json" {
|
||||
t.Fatalf("action receipt path = %q", update.Receipt.Path)
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(filepath.Join(root, filepath.FromSlash(update.Receipt.Path)))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, forbidden := range [][]byte{[]byte("idempotencyKey"), []byte("fingerprint"), []byte("leaseOwner"), []byte("leaseToken"), []byte("revision"), []byte(`"receipt"`), []byte("completedAt")} {
|
||||
if bytes.Contains(data, forbidden) {
|
||||
t.Fatalf("action archive contains internal field %q: %s", forbidden, data)
|
||||
}
|
||||
}
|
||||
var decoded contract.ReplayAction
|
||||
if err := json.Unmarshal(data, &decoded); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if decoded.Status != contract.ActionReceiptPending || len(decoded.Error) != contract.MaxErrorMessageBytes {
|
||||
t.Fatalf("archived action was not bounded receipt_pending summary: %+v", decoded)
|
||||
}
|
||||
repeated, err := service.PublishAction(context.Background(), action)
|
||||
if err != nil {
|
||||
t.Fatalf("repeated PublishAction() error = %v", err)
|
||||
}
|
||||
if !reflect.DeepEqual(repeated, *update.Receipt) {
|
||||
t.Fatalf("repeated action receipt changed: %+v != %+v", repeated, *update.Receipt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPublishRunCleansTempAfterRenameFailure(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
repository := newFakeRunRepository(testRunArchive("run-rename"))
|
||||
service := New(root, nil, nil, repository)
|
||||
renameErr := gerror.New("injected rename failure")
|
||||
service.replaceFile = func(string, string) error { return renameErr }
|
||||
|
||||
_, err := service.PublishRun(context.Background(), contract.ArchiveWork{RunID: "run-rename", Version: "v1"}, repository.snapshot)
|
||||
if !errors.Is(err, renameErr) {
|
||||
t.Fatalf("PublishRun() error = %v, want rename failure", err)
|
||||
}
|
||||
if matches, globErr := filepath.Glob(filepath.Join(root, ".run-rename-*.tmp")); globErr != nil || len(matches) != 0 {
|
||||
t.Fatalf("temporary files after failure = %v, error = %v", matches, globErr)
|
||||
}
|
||||
if _, statErr := os.Stat(filepath.Join(root, "run-run-rename.json")); !errors.Is(statErr, os.ErrNotExist) {
|
||||
t.Fatalf("final file exists after failed rename: %v", statErr)
|
||||
}
|
||||
if len(repository.receipts) != 0 {
|
||||
t.Fatalf("registered receipt after failed rename: %+v", repository.receipts)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChecksumFailureIsRecoveredByExactPathReplacement(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
repository := newFakeRunRepository(testRunArchive("run-checksum"))
|
||||
service := New(root, nil, nil, repository)
|
||||
service.afterReplace = func(path string) error {
|
||||
return os.WriteFile(path, []byte("corrupt"), 0o600)
|
||||
}
|
||||
work := contract.ArchiveWork{RunID: "run-checksum", Version: "v1"}
|
||||
|
||||
if _, err := service.PublishRun(context.Background(), work, repository.snapshot); !errors.Is(err, errChecksumMismatch) {
|
||||
t.Fatalf("PublishRun() error = %v, want checksum mismatch", err)
|
||||
}
|
||||
if len(repository.receipts) != 0 {
|
||||
t.Fatalf("registered receipt after checksum failure: %+v", repository.receipts)
|
||||
}
|
||||
service.afterReplace = func(string) error { return nil }
|
||||
receipt, err := service.PublishRun(context.Background(), work, repository.snapshot)
|
||||
if err != nil {
|
||||
t.Fatalf("recovery PublishRun() error = %v", err)
|
||||
}
|
||||
if receipt.Path != "run-run-checksum.json" {
|
||||
t.Fatalf("replacement used different path: %q", receipt.Path)
|
||||
}
|
||||
data, err := os.ReadFile(filepath.Join(root, filepath.FromSlash(receipt.Path)))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(data) == "corrupt" {
|
||||
t.Fatal("corrupt final file was not replaced")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegistrationRetryReusesIdenticalReceipt(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
repository := newFakeRunRepository(testRunArchive("run-register"))
|
||||
repository.registerErr = gerror.New("registration result was uncertain")
|
||||
service := New(root, nil, nil, repository)
|
||||
work := contract.ArchiveWork{RunID: "run-register", Version: "v1"}
|
||||
|
||||
if _, err := service.PublishRun(context.Background(), work, repository.snapshot); !errors.Is(err, repository.registerErr) {
|
||||
t.Fatalf("first PublishRun() error = %v", err)
|
||||
}
|
||||
repository.mu.Lock()
|
||||
firstReceipt := repository.receipts[0]
|
||||
repository.registerErr = nil
|
||||
repository.mu.Unlock()
|
||||
|
||||
receipt, err := service.PublishRun(context.Background(), work, repository.snapshot)
|
||||
if err != nil {
|
||||
t.Fatalf("retry PublishRun() error = %v", err)
|
||||
}
|
||||
if !reflect.DeepEqual(receipt, firstReceipt) {
|
||||
t.Fatalf("retry changed uncertain receipt:\nfirst: %+v\nretry: %+v", firstReceipt, receipt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPublishRunRejectsDifferentValidContentAtSamePath(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
repository := newFakeRunRepository(testRunArchive("run-conflict"))
|
||||
service := New(root, nil, nil, repository)
|
||||
work := contract.ArchiveWork{RunID: "run-conflict", Version: "v1"}
|
||||
|
||||
first, err := service.PublishRun(context.Background(), work, repository.snapshot)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
changed := repository.snapshot
|
||||
changed.Run.TriggerSource = "different-valid-summary"
|
||||
if _, err = service.PublishRun(context.Background(), work, changed); !errors.Is(err, contract.ErrConflict) {
|
||||
t.Fatalf("PublishRun() error = %v, want conflict", err)
|
||||
}
|
||||
data, err := os.ReadFile(filepath.Join(root, filepath.FromSlash(first.Path)))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sum := sha256.Sum256(data)
|
||||
if got := hex.EncodeToString(sum[:]); got != first.Checksum {
|
||||
t.Fatalf("conflicting publication replaced the original file: %q != %q", got, first.Checksum)
|
||||
}
|
||||
}
|
||||
|
||||
func TestScannerRecoversFailedRunAfterRestart(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
repository := newFakeRunRepository(testRunArchive("run-restart"))
|
||||
repository.load = func(context.Context, contract.ArchiveWork) (contract.RunArchive, error) {
|
||||
return contract.RunArchive{}, gerror.New("source temporarily unavailable")
|
||||
}
|
||||
first := NewService(Config{Root: root, Interval: time.Hour, MaxAttempts: 1}, repository, repository, repository)
|
||||
if err := first.Start(context.Background()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
waitSignal(t, repository.failed, "initial archive failure")
|
||||
if err := first.Stop(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
repository.mu.Lock()
|
||||
repository.load = nil
|
||||
repository.mu.Unlock()
|
||||
second := NewService(Config{Root: root, Interval: time.Hour, MaxAttempts: 1}, repository, repository, repository)
|
||||
if err := second.Start(context.Background()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
waitSignal(t, repository.succeeded, "restart archive success")
|
||||
if err := second.Stop(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(root, "run-run-restart.json")); err != nil {
|
||||
t.Fatalf("recovered archive file missing: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPeriodicScannerRetriesPendingRun(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
repository := newFakeRunRepository(testRunArchive("run-periodic"))
|
||||
var calls atomic.Int32
|
||||
repository.load = func(context.Context, contract.ArchiveWork) (contract.RunArchive, error) {
|
||||
if calls.Add(1) == 1 {
|
||||
return contract.RunArchive{}, gerror.New("first scan fails")
|
||||
}
|
||||
return repository.snapshot, nil
|
||||
}
|
||||
service := NewService(Config{Root: root, Interval: 10 * time.Millisecond, MaxAttempts: 1}, repository, repository, repository)
|
||||
if err := service.Start(context.Background()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
waitSignal(t, repository.succeeded, "periodic archive success")
|
||||
if err := service.Stop(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if calls.Load() < 2 {
|
||||
t.Fatalf("loader calls = %d, want at least 2", calls.Load())
|
||||
}
|
||||
}
|
||||
|
||||
func TestStartupScannerArchivesReceiptPendingAction(t *testing.T) {
|
||||
action := testReplayAction("action-startup")
|
||||
repository := newFakeReplayRepository(action)
|
||||
service := NewServiceWithReplay(
|
||||
Config{Root: t.TempDir(), Interval: time.Hour, MaxAttempts: 1},
|
||||
nil,
|
||||
nil,
|
||||
nil,
|
||||
ReplayRepositories{Discovery: repository, Leases: repository, Progress: repository},
|
||||
)
|
||||
if err := service.Start(context.Background()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
waitSignal(t, repository.completed, "startup replay archive success")
|
||||
if err := service.Stop(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCancellationStopsBlockedScannerAndPublication(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
repository := newFakeRunRepository(testRunArchive("run-cancel"))
|
||||
started := make(chan struct{})
|
||||
repository.load = func(ctx context.Context, _ contract.ArchiveWork) (contract.RunArchive, error) {
|
||||
close(started)
|
||||
<-ctx.Done()
|
||||
return contract.RunArchive{}, ctx.Err()
|
||||
}
|
||||
service := NewService(Config{Root: root, Interval: time.Hour, InitialBackoff: time.Hour}, repository, repository, repository)
|
||||
if err := service.Start(context.Background()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
waitSignal(t, started, "blocked loader start")
|
||||
stopped := make(chan struct{})
|
||||
go func() {
|
||||
_ = service.Stop()
|
||||
close(stopped)
|
||||
}()
|
||||
waitSignal(t, stopped, "scanner cancellation")
|
||||
|
||||
canceled, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
_, err := service.PublishRun(canceled, contract.ArchiveWork{RunID: "run-cancel", Version: "v1"}, repository.snapshot)
|
||||
if !errors.Is(err, context.Canceled) {
|
||||
t.Fatalf("canceled PublishRun() error = %v", err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(root, "run-run-cancel.json")); !errors.Is(err, os.ErrNotExist) {
|
||||
t.Fatalf("canceled publication wrote final file: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCancellationAfterRenameStillVerifiesPublicationAndReleasesLease(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
action := testReplayAction("action-cancel-after-rename")
|
||||
repository := newFakeReplayRepository(action)
|
||||
service := NewServiceWithReplay(
|
||||
Config{Root: t.TempDir()},
|
||||
nil,
|
||||
nil,
|
||||
nil,
|
||||
ReplayRepositories{Discovery: repository, Leases: repository, Progress: repository},
|
||||
)
|
||||
service.afterReplace = func(string) error {
|
||||
cancel()
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := service.ArchiveAction(ctx, action); err != nil {
|
||||
t.Fatalf("ArchiveAction() error = %v", err)
|
||||
}
|
||||
repository.mu.Lock()
|
||||
releaseCtxErr := repository.releaseCtxErr
|
||||
repository.mu.Unlock()
|
||||
if releaseCtxErr != nil {
|
||||
t.Fatalf("lease release inherited canceled context: %v", releaseCtxErr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestArchiveRejectsTraversalAndUnsafeFilesystemIDs(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
service := New(root, nil, nil, newFakeRunRepository(contract.RunArchive{}))
|
||||
for _, id := range []string{"../escape", `..\escape`, "/absolute", `C:\escape`, "drive:name", ".", "..", "{hash}", "CON"} {
|
||||
t.Run(strings.ReplaceAll(id, "/", "_"), func(t *testing.T) {
|
||||
snapshot := testRunArchive(id)
|
||||
_, err := service.PublishRun(context.Background(), contract.ArchiveWork{RunID: id, Version: "v1"}, snapshot)
|
||||
if !errors.Is(err, contract.ErrInvalidArgument) {
|
||||
t.Fatalf("PublishRun(%q) error = %v, want invalid argument", id, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
entries, err := os.ReadDir(root)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(entries) != 0 {
|
||||
t.Fatalf("unsafe IDs created archive entries: %+v", entries)
|
||||
}
|
||||
}
|
||||
|
||||
func testRunArchive(runID string) contract.RunArchive {
|
||||
created := time.Date(2026, time.August, 31, 23, 0, 0, 0, time.FixedZone("test", 8*60*60))
|
||||
completed := time.Date(2026, time.September, 20, 16, 0, 0, 0, time.FixedZone("test", 8*60*60))
|
||||
archived := completed.Add(time.Minute)
|
||||
return contract.RunArchive{
|
||||
Version: contract.ArchiveSchemaVersion,
|
||||
Run: contract.RunMeta{
|
||||
RunID: runID, Status: contract.RunCompletedWithFailures, ProducerDone: true,
|
||||
CurrentRound: 2, MaxRound: 2, EnqueuedCount: 3, SuccessCount: 2, FailedCount: 1, PendingFailureCount: 1,
|
||||
CreatedAt: created, ProducerCompletedAt: completed, CompletedAt: completed,
|
||||
ArchiveStatus: contract.ArchiveFailed, ArchiveVersion: 99, ArchivePath: "old.json",
|
||||
ArchiveChecksum: strings.Repeat("a", 64), ArchivedAt: &archived,
|
||||
},
|
||||
Rounds: []contract.RoundStats{
|
||||
{RunID: runID, Round: 2, EnqueuedCount: 1, FailedCount: 1, ErrorCounts: contract.ErrorCounts{"mobile_busy": 1}},
|
||||
{RunID: runID, Round: 1, EnqueuedCount: 2, SuccessCount: 2, ErrorCounts: nil},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func testReplayAction(actionID string) contract.ReplayAction {
|
||||
created := time.Date(2026, time.September, 20, 12, 0, 0, 0, time.FixedZone("test", 8*60*60))
|
||||
return contract.ReplayAction{
|
||||
ActionID: actionID, SourceRunID: "source-1", TargetRunID: "target-1", RequestedBy: "operator",
|
||||
IdempotencyKey: "secret-key", Fingerprint: "secret-fingerprint", Status: contract.ActionReceiptPending,
|
||||
ReplayCycle: 1, Progress: contract.ReplayProgress{DiscoveredCount: 2, EnqueuedCount: 2, CleanedCount: 2},
|
||||
LeaseOwner: "engine", LeaseToken: "secret-token", LeaseUntil: created.Add(time.Hour), Revision: 7,
|
||||
CreatedAt: created, UpdatedAt: created.Add(time.Minute),
|
||||
}
|
||||
}
|
||||
|
||||
type fakeRunRepository struct {
|
||||
mu sync.Mutex
|
||||
work contract.ArchiveWork
|
||||
snapshot contract.RunArchive
|
||||
load func(context.Context, contract.ArchiveWork) (contract.RunArchive, error)
|
||||
registered bool
|
||||
receipts []contract.ArchiveReceipt
|
||||
failures []string
|
||||
registerErr error
|
||||
succeeded chan struct{}
|
||||
failed chan struct{}
|
||||
}
|
||||
|
||||
func newFakeRunRepository(snapshot contract.RunArchive) *fakeRunRepository {
|
||||
return &fakeRunRepository{
|
||||
work: contract.ArchiveWork{RunID: snapshot.Run.RunID, Version: "repository-version"},
|
||||
snapshot: snapshot,
|
||||
succeeded: make(chan struct{}, 8),
|
||||
failed: make(chan struct{}, 8),
|
||||
}
|
||||
}
|
||||
|
||||
func (r *fakeRunRepository) ListArchiveWork(ctx context.Context, _ contract.PageRequest) (contract.ArchiveWorkPage, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return contract.ArchiveWorkPage{}, err
|
||||
}
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
if r.registered {
|
||||
return contract.ArchiveWorkPage{Items: []contract.ArchiveWork{}}, nil
|
||||
}
|
||||
return contract.ArchiveWorkPage{Items: []contract.ArchiveWork{r.work}}, nil
|
||||
}
|
||||
|
||||
func (r *fakeRunRepository) LoadArchiveWork(ctx context.Context, work contract.ArchiveWork) (contract.RunArchive, error) {
|
||||
r.mu.Lock()
|
||||
load := r.load
|
||||
snapshot := r.snapshot
|
||||
r.mu.Unlock()
|
||||
if load != nil {
|
||||
return load(ctx, work)
|
||||
}
|
||||
return snapshot, nil
|
||||
}
|
||||
|
||||
func (r *fakeRunRepository) RegisterArchive(_ context.Context, _ contract.ArchiveWork, receipt contract.ArchiveReceipt) error {
|
||||
r.mu.Lock()
|
||||
r.receipts = append(r.receipts, receipt)
|
||||
err := r.registerErr
|
||||
if err == nil {
|
||||
r.registered = true
|
||||
}
|
||||
r.mu.Unlock()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
notify(r.succeeded)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *fakeRunRepository) RegisterArchiveFailure(_ context.Context, _ contract.ArchiveWork, message string) error {
|
||||
r.mu.Lock()
|
||||
r.failures = append(r.failures, message)
|
||||
r.mu.Unlock()
|
||||
notify(r.failed)
|
||||
return nil
|
||||
}
|
||||
|
||||
type fakeReplayRepository struct {
|
||||
mu sync.Mutex
|
||||
action contract.ReplayAction
|
||||
update *contract.ReplayActionUpdate
|
||||
released contract.ReplayLease
|
||||
releaseCtxErr error
|
||||
completed chan struct{}
|
||||
}
|
||||
|
||||
func newFakeReplayRepository(action contract.ReplayAction) *fakeReplayRepository {
|
||||
return &fakeReplayRepository{action: action, completed: make(chan struct{}, 4)}
|
||||
}
|
||||
|
||||
func (r *fakeReplayRepository) ListReplayWork(ctx context.Context, _ contract.PageRequest) (contract.ReplayActionPage, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return contract.ReplayActionPage{}, err
|
||||
}
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
if r.update != nil {
|
||||
return contract.ReplayActionPage{Items: []contract.ReplayAction{}}, nil
|
||||
}
|
||||
return contract.ReplayActionPage{Items: []contract.ReplayAction{r.action}}, nil
|
||||
}
|
||||
|
||||
func (r *fakeReplayRepository) AcquireReplayLease(_ context.Context, actionID, owner string, ttl time.Duration) (contract.ReplayLease, error) {
|
||||
return contract.ReplayLease{ActionID: actionID, Owner: owner, Token: "lease-token", Until: time.Now().Add(ttl)}, nil
|
||||
}
|
||||
|
||||
func (r *fakeReplayRepository) RenewReplayLease(_ context.Context, lease contract.ReplayLease, ttl time.Duration) (contract.ReplayLease, error) {
|
||||
lease.Token = "lease-token-2"
|
||||
lease.Until = time.Now().Add(ttl)
|
||||
return lease, nil
|
||||
}
|
||||
|
||||
func (r *fakeReplayRepository) ReleaseReplayLease(ctx context.Context, lease contract.ReplayLease) error {
|
||||
r.mu.Lock()
|
||||
r.released = lease
|
||||
r.releaseCtxErr = ctx.Err()
|
||||
r.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *fakeReplayRepository) UpdateReplayAction(_ context.Context, update contract.ReplayActionUpdate) (contract.ReplayAction, error) {
|
||||
r.mu.Lock()
|
||||
copyOfUpdate := update
|
||||
r.update = ©OfUpdate
|
||||
action := r.action
|
||||
action.Status = update.Status
|
||||
action.Receipt = update.Receipt
|
||||
action.CompletedAt = update.CompletedAt
|
||||
r.mu.Unlock()
|
||||
notify(r.completed)
|
||||
return action, nil
|
||||
}
|
||||
|
||||
func notify(ch chan struct{}) {
|
||||
select {
|
||||
case ch <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
func waitSignal(t *testing.T, ch <-chan struct{}, operation string) {
|
||||
t.Helper()
|
||||
select {
|
||||
case <-ch:
|
||||
case <-time.After(3 * time.Second):
|
||||
t.Fatalf("timed out waiting for %s", operation)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
//go:build !windows
|
||||
|
||||
package archive
|
||||
|
||||
import "os"
|
||||
|
||||
func atomicReplace(from, to string) error {
|
||||
return os.Rename(from, to)
|
||||
}
|
||||
|
||||
func syncDirectory(path string) error {
|
||||
dir, err := os.Open(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := dir.Sync(); err != nil {
|
||||
_ = dir.Close()
|
||||
return err
|
||||
}
|
||||
return dir.Close()
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
//go:build windows
|
||||
|
||||
package archive
|
||||
|
||||
import "golang.org/x/sys/windows"
|
||||
|
||||
func atomicReplace(from, to string) error {
|
||||
fromUTF16, err := windows.UTF16PtrFromString(from)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
toUTF16, err := windows.UTF16PtrFromString(to)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return windows.MoveFileEx(fromUTF16, toUTF16, windows.MOVEFILE_REPLACE_EXISTING|windows.MOVEFILE_WRITE_THROUGH)
|
||||
}
|
||||
|
||||
// Windows has no portable directory fsync operation. MoveFileEx with
|
||||
// MOVEFILE_WRITE_THROUGH provides the strongest supported replacement flush.
|
||||
func syncDirectory(string) error { return nil }
|
||||
@@ -0,0 +1,124 @@
|
||||
# Full-sync shared contracts
|
||||
|
||||
This package establishes the boundary for later queue accounting, archive,
|
||||
replay, and HTTP work. It does not change running service behavior. Production
|
||||
code in this package does not import the queue package; compatibility tests
|
||||
compare its models and keys with the existing queue implementation.
|
||||
|
||||
## Compatibility and counters
|
||||
|
||||
- `RunMeta` retains all existing JSON fields and adds optional fields. Existing
|
||||
`zset.RunMeta`, `zset.Task`, and `zset.FailureRecord` remain unchanged; adapters
|
||||
are a later integration step. The new `RunStatus` is a named string type.
|
||||
- `Validate` accepts missing extension fields on old records.
|
||||
`ValidateAccounting` is opt-in for runs with known extended counters and
|
||||
enforces `enqueuedCount = successCount + failedCount + activeCount + nextRoundCount`.
|
||||
`inflightCount` is a subset of `activeCount`.
|
||||
- Source cleanup changes `pendingFailureCount`, never historical `failedCount`.
|
||||
- Round statistics have no task/card/channel dimensions. `ErrorCounts`
|
||||
normalizes both JSON input and output to eight canonical categories, merging
|
||||
unknown names into `other` and rejecting negative/overflowing totals.
|
||||
- Existing failure payloads retain their free-form classification and message.
|
||||
The HTTP `Failure` projection normalizes categories and bounds messages to
|
||||
2,048 UTF-8 bytes. Historical blocked tasks may have round zero.
|
||||
|
||||
## Status domains
|
||||
|
||||
| Domain | Wire values |
|
||||
| --- | --- |
|
||||
| Run | `producing`, `running`, `completed`, `completed_with_failures` |
|
||||
| Sync type | `normal`, `replay` |
|
||||
| Archive | `disabled`, `pending`, `processing`, `succeeded`, `failed` |
|
||||
| Source replay disposition | `none`, `pending`, `processing`, `completed`, `needs_attention` |
|
||||
| Replay action | `accepted`, `processing`, `cleanup_pending`, `receipt_pending`, `completed`, `needs_attention` |
|
||||
| Error category | `timeout`, `rate_limited`, `authentication`, `validation`, `upstream`, `transport`, `internal`, `other` |
|
||||
|
||||
The source field is `replayDisposition`, distinct from the replay action's
|
||||
`status`. Lease tokens, record versions, action revision, idempotency key, and
|
||||
request fingerprint are internal and excluded from HTTP JSON.
|
||||
|
||||
## Interfaces and persistence obligations
|
||||
|
||||
HTTP consumers depend on `RunReader`, `FailurePager`, `ReplayRequester`, and
|
||||
`ActionReader`. Separate live/archive readers support fallback only after a live
|
||||
`ErrNotFound`; storage failures must remain visible. An archived summary cannot
|
||||
provide per-card details: use `ErrFailureDetailsUnavailable` when live details
|
||||
are gone. Repositories and HTTP both apply the default page limit 100 and
|
||||
maximum 500. Cursors remain opaque and are bound to the original query. An empty
|
||||
scan page with a next cursor does not signal completion.
|
||||
|
||||
Archive discovery, scheduling, snapshot loading, and result registration use
|
||||
separate narrow interfaces. Registration is conditional on an opaque work
|
||||
version. Archives contain run and round aggregates only. Archive receipts use
|
||||
a schema version, storage-owned path, lowercase SHA-256 checksum of stored
|
||||
bytes, and publication time. Archive schema version starts at 1.
|
||||
|
||||
Replay acceptance atomically reserves `(sourceRunId, idempotencyKey)` with a
|
||||
SHA-256 fingerprint of the source and exact requestedBy body value. Repeating
|
||||
the request returns its original action/target; changing the request returns
|
||||
`ErrConflict`. The action fixes its target ID before target creation/enqueue.
|
||||
|
||||
Replay work discovery, lease management, progress updates, source pagination,
|
||||
target creation/enqueue, and source cleanup are separate interfaces. Progress
|
||||
updates are fenced and use a revision check. Target enqueue distinguishes
|
||||
durably present duplicates from rejected writes; a zero insert count by itself
|
||||
does not authorize source cleanup. Cleanup checks exact source record versions
|
||||
and target acceptance evidence. Implementations must persist enough evidence
|
||||
to recover partial batches; a scan cursor by itself is insufficient. Source
|
||||
pagination must remain correct while already-enqueued entries are deleted.
|
||||
|
||||
`ErrNotFound`, `ErrConflict`, `ErrInvalidArgument`,
|
||||
`ErrFailureDetailsUnavailable`, `ErrLeaseLost`, and `ErrBusy` support
|
||||
`errors.Is` through wrapping. HTTP error envelopes and status mappings remain
|
||||
the HTTP implementation's responsibility.
|
||||
|
||||
## Redis naming
|
||||
|
||||
All names are prefixed with the configured namespace (`flow:` by default;
|
||||
`sim-status:` for status sync). Existing run/round/task-state/failure names are
|
||||
preserved. New keys include:
|
||||
|
||||
| Suffix after namespace | Scope |
|
||||
| --- | --- |
|
||||
| `{runId}:round:N:stats` | Run slot |
|
||||
| `{runId}:archive` | Run slot |
|
||||
| `runs:all` | Global index |
|
||||
| `archive:pending` | Global index |
|
||||
| `{replay}:action:actionId:meta` | Replay slot |
|
||||
| `{replay}:action:actionId:lease` | Replay slot |
|
||||
| `{replay}:idempotency:SHA256(sourceRunId,key)` | Replay slot; tuple encoded as a JSON array before hashing |
|
||||
| `{replay}:pending` | Replay slot |
|
||||
|
||||
Replay action creation, idempotency, and discovery can be atomic within their
|
||||
shared slot. Source/target runs and global indexes can occupy different slots;
|
||||
their coordination requires recoverable registrations/receipts rather than a
|
||||
single cross-slot Lua transaction. New identifier validation rejects hash-tag
|
||||
or path delimiters without rewriting legacy JSON on decode.
|
||||
|
||||
## HTTP surface
|
||||
|
||||
| Method | Route | Response |
|
||||
| --- | --- | --- |
|
||||
| GET | `/internal/v1/full-sync-runs?syncKind=&limit=` | `RunList` merged from active Redis Runs and `run-<runId>.json` archives |
|
||||
| GET | `/internal/v1/full-sync-runs/{runId}` | `RunView` with `source`, `run`, optional `rounds` |
|
||||
| GET | `/internal/v1/full-sync-runs/{runId}/failures?cursor=&limit=` | `FailurePage` with `items`, `nextCursor` |
|
||||
| POST | `/internal/v1/full-sync-runs/{runId}/replay` | HTTP 202 `ReplayAccepted` |
|
||||
| GET | `/internal/v1/full-sync-actions/{actionId}` | `ReplayAction` |
|
||||
|
||||
POST requires `Idempotency-Key` and JSON `{"requestedBy":"..."}`. Authentication,
|
||||
HTTP handlers, storage, replay execution, archive files, and service wiring are
|
||||
intentionally left to subsequent implementations.
|
||||
|
||||
## Verification
|
||||
|
||||
```text
|
||||
gofmt -l internal/fullsync/contract
|
||||
go test ./internal/fullsync/contract
|
||||
go test ./internal/queue/zset ./internal/consumer/zset ./internal/producer/simstatus ./internal/producer/monthlyusage ./internal/service/simstatus ./internal/service/monthlyusage
|
||||
```
|
||||
|
||||
Tests cover legacy schema and JSON compatibility, existing key compatibility,
|
||||
cluster key boundaries, bounded categories/messages, overflow, run accounting,
|
||||
pagination limits, status domains, replay identity, internal field exclusion,
|
||||
and receipt/progress validation. Regression tests use the repository's isolated
|
||||
Redis helper and require `redis-server` or `REDIS_SERVER_BINARY`.
|
||||
@@ -0,0 +1,46 @@
|
||||
package contract
|
||||
|
||||
import "time"
|
||||
|
||||
const ArchiveSchemaVersion = 1
|
||||
|
||||
// ArchiveReceipt identifies a durably published archive object. Path is a
|
||||
// storage-owned reference, never a client-supplied destination. Checksum is
|
||||
// lowercase hex SHA-256 of the final stored bytes; version is the schema.
|
||||
type ArchiveReceipt struct {
|
||||
Version int `json:"version"`
|
||||
Path string `json:"path"`
|
||||
Checksum string `json:"checksum"`
|
||||
ArchivedAt time.Time `json:"archivedAt"`
|
||||
}
|
||||
|
||||
func (r ArchiveReceipt) Validate() error {
|
||||
if r.Version < 1 || r.Path == "" || r.ArchivedAt.IsZero() || len(r.Checksum) != 64 {
|
||||
return invalid("incomplete archive receipt")
|
||||
}
|
||||
for _, c := range r.Checksum {
|
||||
if !((c >= '0' && c <= '9') || (c >= 'a' && c <= 'f')) {
|
||||
return invalid("archive checksum must be lowercase SHA-256 hex")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// RunArchive is summary-only: never embed per-card task/failure records.
|
||||
type RunArchive struct {
|
||||
Version int `json:"version"`
|
||||
Run RunMeta `json:"run"`
|
||||
Rounds []RoundStats `json:"rounds"`
|
||||
}
|
||||
|
||||
// ArchiveWork.Version is an opaque concurrency token for registration; it is
|
||||
// distinct from the archive schema version. Work discovery must survive restarts.
|
||||
type ArchiveWork struct {
|
||||
RunID string `json:"runId"`
|
||||
Version string `json:"-"`
|
||||
}
|
||||
|
||||
type ArchiveWorkPage struct {
|
||||
Items []ArchiveWork `json:"items"`
|
||||
NextCursor Cursor `json:"nextCursor"`
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
package contract_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"reflect"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/fullsync/contract"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/queue/zset"
|
||||
)
|
||||
|
||||
// Check every old field, including zero/omitempty behavior, so future adapters
|
||||
// can adopt the contract without silently changing the persisted wire format.
|
||||
func TestLegacyFieldSchemas(t *testing.T) {
|
||||
for _, pair := range []struct{ old, next any }{
|
||||
{zset.RunMeta{}, contract.RunMeta{}},
|
||||
{zset.FailureRecord{}, contract.FailureRecord{}},
|
||||
{zset.Task{}, contract.Task{}},
|
||||
} {
|
||||
old, next := reflect.TypeOf(pair.old), reflect.TypeOf(pair.next)
|
||||
for i := 0; i < old.NumField(); i++ {
|
||||
field := old.Field(i)
|
||||
replacement, ok := next.FieldByName(field.Name)
|
||||
if !ok || replacement.Tag.Get("json") != field.Tag.Get("json") || replacement.Type.Kind() != field.Type.Kind() {
|
||||
t.Errorf("%s.%s lost its legacy schema: old=%+v new=%+v", old.Name(), field.Name, field, replacement)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLegacyRunJSONRoundTrip(t *testing.T) {
|
||||
now := time.Date(2026, 9, 20, 1, 2, 3, 456, time.UTC)
|
||||
legacy := zset.RunMeta{
|
||||
RunID: "legacy", Operation: "current_month_usage_full_sync", QueryMonth: "202609",
|
||||
Status: "completed_with_failures", ProducerDone: true, CurrentRound: 5,
|
||||
SuccessCount: 9, FailedCount: 1, CreatedAt: now, ProducerCompletedAt: now, CompletedAt: now,
|
||||
}
|
||||
data := mustJSON(t, legacy)
|
||||
var decoded contract.RunMeta
|
||||
if err := json.Unmarshal(data, &decoded); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := decoded.Validate(); err != nil {
|
||||
t.Fatalf("legacy run rejected: %v", err)
|
||||
}
|
||||
if got := string(mustJSON(t, decoded)); got != string(data) {
|
||||
t.Fatalf("legacy wire format changed:\n%s\n%s", data, got)
|
||||
}
|
||||
// New fields do not prevent old readers from consuming the same metadata.
|
||||
decoded.SyncType = contract.SyncReplay
|
||||
decoded.SourceRunID = "source"
|
||||
decoded.ReplayCycle = 1
|
||||
decoded.EnqueuedCount = 10
|
||||
decoded.PendingFailureCount = 1
|
||||
var oldReader zset.RunMeta
|
||||
if err := json.Unmarshal(mustJSON(t, decoded), &oldReader); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
oldReader.SyncType = ""
|
||||
oldReader.SourceRunID = ""
|
||||
oldReader.ReplayCycle = 0
|
||||
oldReader.EnqueuedCount = 0
|
||||
oldReader.PendingFailureCount = 0
|
||||
if !reflect.DeepEqual(oldReader, legacy) {
|
||||
t.Fatalf("old reader lost fields: %+v", oldReader)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLegacyFailureAndTaskJSON(t *testing.T) {
|
||||
legacy := zset.FailureRecord{
|
||||
Version: 1, TaskID: "source:op:42", OriginalRunID: "source", LatestRunID: "latest",
|
||||
QueryMonth: "202609", Operation: "op", CardID: 42, ICCID: "iccid", MSISDN: "msisdn", IMSI: "imsi",
|
||||
ChanKey: "chan", TempKey: "template", Carrier: "mobile", FailureType: "mobile_busy_exhausted",
|
||||
Round: 5, Attempts: 7, Code: 13000, ErrCode: "BUSY", Message: "try again",
|
||||
ReviewStatus: "pending", ReviewRemark: "remark", ReviewedBy: "operator", ReviewedAt: "2026-09-20",
|
||||
RetryCycle: 2, ReplayRunID: "replayed", FirstFailedAt: time.Unix(1, 0).UTC(), LastFailedAt: time.Unix(2, 0).UTC(),
|
||||
}
|
||||
var record contract.FailureRecord
|
||||
if err := json.Unmarshal(mustJSON(t, legacy), &record); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(mustJSON(t, record)) != string(mustJSON(t, legacy)) {
|
||||
t.Fatal("failure record wire format changed")
|
||||
}
|
||||
if summary := record.Summary(); summary.ErrorCategory != contract.ErrorRateLimited || summary.TaskID != legacy.TaskID || summary.FailedAt != legacy.LastFailedAt {
|
||||
t.Fatalf("incorrect failure projection: %+v", summary)
|
||||
}
|
||||
task := zset.Task{TaskID: "r:op:42", RunID: "r", Operation: "op", QueryMonth: "202609", ChanKey: "c", TempKey: "t", Operator: 2, CardID: 42, ICCID: "i", MSISDN: "m", IMSI: "s", NextExecuteAt: 1234}
|
||||
var newTask contract.Task
|
||||
if err := json.Unmarshal(mustJSON(t, task), &newTask); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
newTask.NextExecuteAt = 9999
|
||||
if string(mustJSON(t, newTask)) != string(mustJSON(t, task)) {
|
||||
t.Fatal("task wire format or scheduling exclusion changed")
|
||||
}
|
||||
}
|
||||
|
||||
func mustJSON(t *testing.T, value any) []byte {
|
||||
t.Helper()
|
||||
data, err := json.Marshal(value)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return data
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
// Package contract defines the shared full-sync domain and service boundaries.
|
||||
// It contains no Redis, filesystem, replay execution, or HTTP handler code.
|
||||
//
|
||||
// Legacy queue models remain in internal/queue/zset until adapters adopt this
|
||||
// package. Their existing JSON fields are retained here; new run fields are
|
||||
// optional so historical records still decode. Missing extended counters mean
|
||||
// unknown, not a reconstructed total. FailedCount is the historical terminal
|
||||
// count for a run; cleaning source failures changes PendingFailureCount only.
|
||||
//
|
||||
// Implementations must preserve these boundaries: run-local Redis keys share
|
||||
// one hash tag, global indexes do not; archive summaries never contain card
|
||||
// details; cursors are opaque and scoped to the original query; replay writes
|
||||
// are idempotent and fenced by a lease before source cleanup is allowed.
|
||||
package contract
|
||||
@@ -0,0 +1,112 @@
|
||||
package contract
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"math"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
type ErrorCategory string
|
||||
|
||||
const (
|
||||
ErrorTimeout ErrorCategory = "timeout"
|
||||
ErrorRateLimited ErrorCategory = "rate_limited"
|
||||
ErrorAuthentication ErrorCategory = "authentication"
|
||||
ErrorValidation ErrorCategory = "validation"
|
||||
ErrorUpstream ErrorCategory = "upstream"
|
||||
ErrorTransport ErrorCategory = "transport"
|
||||
ErrorInternal ErrorCategory = "internal"
|
||||
ErrorOther ErrorCategory = "other"
|
||||
MaxErrorCategories = 8
|
||||
MaxErrorMessageBytes = 2048
|
||||
)
|
||||
|
||||
// NormalizeErrorCategory maps the existing consumer classifications into the
|
||||
// approved, bounded vocabulary. Never use an upstream message/code as a key.
|
||||
func NormalizeErrorCategory(value string) ErrorCategory {
|
||||
switch strings.ToLower(strings.TrimSpace(value)) {
|
||||
case "timeout":
|
||||
return ErrorTimeout
|
||||
case "rate_limited", "mobile_busy", "mobile_busy_exhausted", "channel_limited", "channel_blocked":
|
||||
return ErrorRateLimited
|
||||
case "authentication":
|
||||
return ErrorAuthentication
|
||||
case "validation":
|
||||
return ErrorValidation
|
||||
case "upstream", "business", "carrier_retry":
|
||||
return ErrorUpstream
|
||||
case "transport", "network":
|
||||
return ErrorTransport
|
||||
case "internal", "canceled":
|
||||
return ErrorInternal
|
||||
default:
|
||||
return ErrorOther
|
||||
}
|
||||
}
|
||||
|
||||
func (c ErrorCategory) MarshalJSON() ([]byte, error) {
|
||||
return json.Marshal(string(NormalizeErrorCategory(string(c))))
|
||||
}
|
||||
|
||||
func (c *ErrorCategory) UnmarshalJSON(data []byte) error {
|
||||
var value string
|
||||
if err := json.Unmarshal(data, &value); err != nil {
|
||||
return invalid("error category must be a string: %v", err)
|
||||
}
|
||||
*c = NormalizeErrorCategory(value)
|
||||
return nil
|
||||
}
|
||||
|
||||
// ErrorCounts is normalized on both JSON input and output, including map keys
|
||||
// (encoding/json does not invoke ErrorCategory.MarshalJSON for map keys).
|
||||
// Counts are per category, not card IDs or individual error messages.
|
||||
type ErrorCounts map[ErrorCategory]int64
|
||||
|
||||
func (counts ErrorCounts) Normalized() (ErrorCounts, error) {
|
||||
normalized := make(ErrorCounts)
|
||||
for category, count := range counts {
|
||||
key := NormalizeErrorCategory(string(category))
|
||||
if count < 0 || count > math.MaxInt64-normalized[key] {
|
||||
return nil, invalid("error counts must be nonnegative and must not overflow")
|
||||
}
|
||||
normalized[key] += count
|
||||
}
|
||||
return normalized, nil
|
||||
}
|
||||
|
||||
func (counts ErrorCounts) MarshalJSON() ([]byte, error) {
|
||||
normalized, err := counts.Normalized()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
type wireCounts map[ErrorCategory]int64
|
||||
return json.Marshal(wireCounts(normalized))
|
||||
}
|
||||
|
||||
func (counts *ErrorCounts) UnmarshalJSON(data []byte) error {
|
||||
var decoded map[ErrorCategory]int64
|
||||
if err := json.Unmarshal(data, &decoded); err != nil {
|
||||
return invalid("invalid error counts: %v", err)
|
||||
}
|
||||
normalized, err := ErrorCounts(decoded).Normalized()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
*counts = normalized
|
||||
return nil
|
||||
}
|
||||
|
||||
// BoundedErrorMessage makes new summary/action error messages valid UTF-8 and
|
||||
// caps their byte length. It does not modify legacy FailureRecord.Message.
|
||||
func BoundedErrorMessage(message string) string {
|
||||
message = strings.ToValidUTF8(message, "\uFFFD")
|
||||
if len(message) <= MaxErrorMessageBytes {
|
||||
return message
|
||||
}
|
||||
end := MaxErrorMessageBytes
|
||||
for !utf8.RuneStart(message[end]) {
|
||||
end--
|
||||
}
|
||||
return message[:end]
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
package contract_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math"
|
||||
"strings"
|
||||
"testing"
|
||||
"unicode/utf8"
|
||||
|
||||
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/fullsync/contract"
|
||||
)
|
||||
|
||||
func TestBoundedErrorCategories(t *testing.T) {
|
||||
for raw, want := range map[string]contract.ErrorCategory{
|
||||
"timeout": contract.ErrorTimeout, " RATE_LIMITED ": contract.ErrorRateLimited,
|
||||
"authentication": contract.ErrorAuthentication, "validation": contract.ErrorValidation,
|
||||
"upstream": contract.ErrorUpstream, "transport": contract.ErrorTransport,
|
||||
"internal": contract.ErrorInternal, "other": contract.ErrorOther,
|
||||
"network": contract.ErrorTransport, "mobile_busy": contract.ErrorRateLimited,
|
||||
"mobile_busy_exhausted": contract.ErrorRateLimited, "channel_blocked": contract.ErrorRateLimited,
|
||||
"channel_limited": contract.ErrorRateLimited, "business": contract.ErrorUpstream,
|
||||
"carrier_retry": contract.ErrorUpstream, "canceled": contract.ErrorInternal,
|
||||
"": contract.ErrorOther, "card-898600-unique-error": contract.ErrorOther,
|
||||
} {
|
||||
if got := contract.NormalizeErrorCategory(raw); got != want {
|
||||
t.Errorf("normalize %q = %q, want %q", raw, got, want)
|
||||
}
|
||||
var decoded contract.ErrorCategory
|
||||
if err := json.Unmarshal(mustJSON(t, raw), &decoded); err != nil || decoded != want {
|
||||
t.Errorf("decode %q = %q, err %v", raw, decoded, err)
|
||||
}
|
||||
if got := string(mustJSON(t, contract.ErrorCategory(raw))); got != string(mustJSON(t, string(want))) {
|
||||
t.Errorf("encode %q = %s", raw, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestErrorCountsCannotGrowWithCardinality(t *testing.T) {
|
||||
counts := contract.ErrorCounts{contract.ErrorTimeout: 2, contract.ErrorOther: 3, "network": 4}
|
||||
for i := 0; i < 1000; i++ {
|
||||
counts[contract.ErrorCategory(fmt.Sprintf("card-error-%d", i))] = 1
|
||||
}
|
||||
var decoded contract.ErrorCounts
|
||||
if err := json.Unmarshal(mustJSON(t, counts), &decoded); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(decoded) != 3 || len(decoded) > contract.MaxErrorCategories || decoded[contract.ErrorOther] != 1003 || decoded[contract.ErrorTransport] != 4 {
|
||||
t.Fatalf("unbounded or lost counts: %+v", decoded)
|
||||
}
|
||||
if err := json.Unmarshal([]byte(`{"unknown":2,"different":3,"other":4,"network":5}`), &decoded); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(decoded) != 2 || decoded[contract.ErrorOther] != 9 || decoded[contract.ErrorTransport] != 5 {
|
||||
t.Fatalf("input normalization failed: %+v", decoded)
|
||||
}
|
||||
}
|
||||
|
||||
func TestErrorCountsRejectInvalidNumbers(t *testing.T) {
|
||||
for _, raw := range []string{`{"timeout":-1}`, `{"one":9223372036854775807,"two":1}`, `{"other":1.5}`, `{"other":"1"}`} {
|
||||
var counts contract.ErrorCounts
|
||||
if err := json.Unmarshal([]byte(raw), &counts); !errors.Is(err, contract.ErrInvalidArgument) {
|
||||
t.Errorf("invalid input %s returned %v", raw, err)
|
||||
}
|
||||
}
|
||||
for _, counts := range []contract.ErrorCounts{{"timeout": -1}, {"one": math.MaxInt64, "two": 1}} {
|
||||
if _, err := json.Marshal(counts); !errors.Is(err, contract.ErrInvalidArgument) {
|
||||
t.Errorf("invalid output counts returned %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBoundedFailureMessage(t *testing.T) {
|
||||
for _, message := range []string{"short", strings.Repeat("x", 3000), strings.Repeat("卡", 1000), strings.Repeat("x", 2047) + "卡", "invalid\xfftext"} {
|
||||
got := contract.BoundedErrorMessage(message)
|
||||
if len(got) > contract.MaxErrorMessageBytes || !utf8.ValidString(got) {
|
||||
t.Fatalf("invalid bounded output: bytes=%d", len(got))
|
||||
}
|
||||
if utf8.ValidString(message) && len(message) <= contract.MaxErrorMessageBytes && got != message {
|
||||
t.Fatal("short message changed")
|
||||
}
|
||||
}
|
||||
record := contract.FailureRecord{TaskID: "task", Message: strings.Repeat("卡", 1000)}
|
||||
if err := record.Summary().Validate(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(record.Message) != 3000 {
|
||||
t.Fatal("summary mutated legacy failure message")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
package contract
|
||||
|
||||
import (
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
// Cursor is an opaque repository-issued continuation token. Clients must not
|
||||
// parse, increment, or reuse it for another run/filter. Empty starts a scan;
|
||||
// an empty nextCursor ends it. An empty page with a cursor is not end-of-scan.
|
||||
// Repositories validate token contents and query binding with ErrInvalidArgument.
|
||||
type Cursor string
|
||||
|
||||
const (
|
||||
DefaultPageLimit = 100
|
||||
MaxPageLimit = 500
|
||||
)
|
||||
|
||||
type PageRequest struct {
|
||||
Cursor Cursor `json:"cursor,omitempty"`
|
||||
Limit int `json:"limit,omitempty"`
|
||||
}
|
||||
|
||||
// Normalized must be called at both HTTP and repository boundaries. Limits
|
||||
// outside the bounds are rejected, not silently truncated.
|
||||
func (p PageRequest) Normalized() (PageRequest, error) {
|
||||
if p.Limit == 0 {
|
||||
p.Limit = DefaultPageLimit
|
||||
}
|
||||
if p.Limit < 1 || p.Limit > MaxPageLimit {
|
||||
return PageRequest{}, invalid("limit must be between 1 and %d", MaxPageLimit)
|
||||
}
|
||||
return p, nil
|
||||
}
|
||||
|
||||
// Failure is the bounded public projection, not the replay payload. Legacy
|
||||
// free-form failureType/message fields remain available in FailureRecord.
|
||||
type Failure struct {
|
||||
TaskID string `json:"taskId"`
|
||||
CardID int64 `json:"cardId"`
|
||||
ErrorCategory ErrorCategory `json:"errorCategory"`
|
||||
ErrorMessage string `json:"errorMessage"`
|
||||
FailedAt time.Time `json:"failedAt"`
|
||||
Round int `json:"round"`
|
||||
}
|
||||
|
||||
func (f Failure) Validate() error {
|
||||
if f.TaskID == "" || f.Round < 0 || len(f.ErrorMessage) > MaxErrorMessageBytes || !utf8.ValidString(f.ErrorMessage) {
|
||||
return invalid("invalid failure summary")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type FailurePage struct {
|
||||
Items []Failure `json:"items"`
|
||||
NextCursor Cursor `json:"nextCursor"`
|
||||
}
|
||||
|
||||
// FailureRecord preserves the existing persisted payload without changing
|
||||
// review semantics or discarding fields needed to reconstruct replay tasks.
|
||||
type FailureRecord struct {
|
||||
Version int `json:"version"`
|
||||
TaskID string `json:"taskId"`
|
||||
OriginalRunID string `json:"originalRunId"`
|
||||
LatestRunID string `json:"latestRunId"`
|
||||
QueryMonth string `json:"queryMonth"`
|
||||
Operation string `json:"operation"`
|
||||
CardID int64 `json:"cardId"`
|
||||
ICCID string `json:"iccid"`
|
||||
MSISDN string `json:"msisdn"`
|
||||
IMSI string `json:"imsi"`
|
||||
ChanKey string `json:"chanKey"`
|
||||
TempKey string `json:"tempKey"`
|
||||
// PackageCode is additive; older failure records decode it as empty.
|
||||
PackageCode string `json:"packageCode,omitempty"`
|
||||
Carrier string `json:"carrier"`
|
||||
FailureType string `json:"failureType"`
|
||||
Round int `json:"round"`
|
||||
Attempts int `json:"attempts"`
|
||||
Code int `json:"code"`
|
||||
ErrCode string `json:"errCode"`
|
||||
Message string `json:"message"`
|
||||
ReviewStatus string `json:"reviewStatus"`
|
||||
ReviewRemark string `json:"reviewRemark"`
|
||||
ReviewedBy string `json:"reviewedBy"`
|
||||
ReviewedAt string `json:"reviewedAt"`
|
||||
RetryCycle int `json:"retryCycle"`
|
||||
ReplayRunID string `json:"replayRunId"`
|
||||
FirstFailedAt time.Time `json:"firstFailedAt"`
|
||||
LastFailedAt time.Time `json:"lastFailedAt"`
|
||||
}
|
||||
|
||||
func (f FailureRecord) Summary() Failure {
|
||||
return Failure{
|
||||
TaskID: f.TaskID, CardID: f.CardID, ErrorCategory: NormalizeErrorCategory(f.FailureType),
|
||||
ErrorMessage: BoundedErrorMessage(f.Message), FailedAt: f.LastFailedAt, Round: f.Round,
|
||||
}
|
||||
}
|
||||
|
||||
// FailureEntry carries an opaque version for conditional source cleanup. It
|
||||
// must identify the precise record read, not just its reusable task ID.
|
||||
type FailureEntry struct {
|
||||
Record FailureRecord `json:"record"`
|
||||
Version string `json:"-"`
|
||||
}
|
||||
|
||||
type FailureRecordPage struct {
|
||||
Items []FailureEntry `json:"items"`
|
||||
NextCursor Cursor `json:"nextCursor"`
|
||||
}
|
||||
|
||||
// Task keeps the existing queue member serialization. The scheduling score
|
||||
// must never be part of the JSON member or producer retries lose idempotency.
|
||||
type Task struct {
|
||||
TaskID string `json:"taskId"`
|
||||
RunID string `json:"runId"`
|
||||
Operation string `json:"operation"`
|
||||
QueryMonth string `json:"queryMonth"`
|
||||
ChanKey string `json:"chanKey"`
|
||||
TempKey string `json:"tempKey"`
|
||||
Operator int `json:"operator,omitempty"`
|
||||
CardID int64 `json:"cardId"`
|
||||
ICCID string `json:"iccid"`
|
||||
MSISDN string `json:"msisdn"`
|
||||
IMSI string `json:"imsi"`
|
||||
// PackageCode is additive; older members decode it as empty.
|
||||
PackageCode string `json:"packageCode,omitempty"`
|
||||
NextExecuteAt int64 `json:"-"`
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package contract
|
||||
|
||||
import "net/url"
|
||||
|
||||
const (
|
||||
ListRunsRoute = "/internal/v1/full-sync-runs"
|
||||
GetRunRoute = "/internal/v1/full-sync-runs/{runId}"
|
||||
GetFailuresRoute = "/internal/v1/full-sync-runs/{runId}/failures"
|
||||
PostReplayRoute = "/internal/v1/full-sync-runs/{runId}/replay"
|
||||
GetActionRoute = "/internal/v1/full-sync-actions/{actionId}"
|
||||
IdempotencyHeader = "Idempotency-Key"
|
||||
)
|
||||
|
||||
// ReplayAccepted is the 202 response from a successful ReplayRequester call.
|
||||
// A repeated request returns the original action and its current status.
|
||||
type ReplayAccepted struct {
|
||||
ActionID string `json:"actionId"`
|
||||
TargetRunID string `json:"targetRunId"`
|
||||
Status ActionStatus `json:"status"`
|
||||
StatusURL string `json:"statusUrl"`
|
||||
}
|
||||
|
||||
func ActionStatusURL(actionID string) (string, error) {
|
||||
if err := ValidateID(actionID); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return "/internal/v1/full-sync-actions/" + url.PathEscape(actionID), nil
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
package contract
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
)
|
||||
|
||||
// RunReader is the HTTP lookup boundary. Fall back to archive only when the
|
||||
// live reader returns ErrNotFound, not on transient live storage failures.
|
||||
type RunReader interface {
|
||||
GetRun(ctx context.Context, runID string) (RunView, error)
|
||||
}
|
||||
|
||||
type RunListFilter struct {
|
||||
SyncKind SyncKind
|
||||
Limit int
|
||||
}
|
||||
|
||||
type RunList struct {
|
||||
Items []RunView `json:"items"`
|
||||
Total int `json:"total"`
|
||||
}
|
||||
|
||||
type RunLister interface {
|
||||
ListRuns(ctx context.Context, filter RunListFilter) (RunList, error)
|
||||
}
|
||||
|
||||
type LiveRunReader interface {
|
||||
GetRunMeta(ctx context.Context, runID string) (RunMeta, error)
|
||||
}
|
||||
|
||||
type ArchivedRunReader interface {
|
||||
GetArchivedRun(ctx context.Context, runID string) (RunArchive, error)
|
||||
}
|
||||
|
||||
type RoundStatsReader interface {
|
||||
GetRoundStats(ctx context.Context, runID string) ([]RoundStats, error)
|
||||
}
|
||||
|
||||
// FailurePager returns live details only. An archived summary without live
|
||||
// details returns ErrFailureDetailsUnavailable, never a misleading empty page.
|
||||
// Items must serialize as [] on empty pages; NextCursor alone signals EOF.
|
||||
type FailurePager interface {
|
||||
ListFailures(ctx context.Context, runID string, page PageRequest) (FailurePage, error)
|
||||
}
|
||||
|
||||
type ReplayRequester interface {
|
||||
RequestReplay(ctx context.Context, request ReplayRequest) (ReplayAccepted, error)
|
||||
}
|
||||
|
||||
type ActionReader interface {
|
||||
GetReplayAction(ctx context.Context, actionID string) (ReplayAction, error)
|
||||
}
|
||||
|
||||
// ArchiveWorkDiscovery returns pending/retryable work from a durable index.
|
||||
// Terminal run registration must not depend on an in-memory notification.
|
||||
type ArchiveWorkDiscovery interface {
|
||||
ListArchiveWork(ctx context.Context, page PageRequest) (ArchiveWorkPage, error)
|
||||
}
|
||||
|
||||
// RegisterArchiveWork idempotently schedules a terminal run/version. Since the
|
||||
// index can occupy another Redis slot, callers must reconcile registration
|
||||
// after a crash between completing the run and updating the index.
|
||||
type ArchiveWorkRegistrar interface {
|
||||
RegisterArchiveWork(ctx context.Context, work ArchiveWork) error
|
||||
}
|
||||
|
||||
type ArchiveWorkLoader interface {
|
||||
LoadArchiveWork(ctx context.Context, work ArchiveWork) (RunArchive, error)
|
||||
}
|
||||
|
||||
// ArchiveRegistrar conditionally records a durable publication against the
|
||||
// work version. Repeating the same receipt is a no-op; a different version or
|
||||
// conflicting receipt returns ErrConflict. Failure retains discoverable work.
|
||||
type ArchiveRegistrar interface {
|
||||
RegisterArchive(ctx context.Context, work ArchiveWork, receipt ArchiveReceipt) error
|
||||
RegisterArchiveFailure(ctx context.Context, work ArchiveWork, message string) error
|
||||
}
|
||||
|
||||
// ReplayActionCreator atomically reserves (sourceRunId, idempotencyKey) with
|
||||
// its fingerprint and action. Same key/request returns the original action
|
||||
// regardless of candidate IDs; different request returns ErrConflict. The
|
||||
// candidate starts accepted and fixes the target ID before any target writes.
|
||||
type ReplayActionCreator interface {
|
||||
GetOrCreateReplayAction(ctx context.Context, request ReplayRequest, candidate ReplayAction) (ReplayActionCreation, error)
|
||||
}
|
||||
|
||||
// Discovery includes accepted, processing, cleanup_pending and receipt_pending
|
||||
// actions after crashes. Completed/needs_attention actions require no automatic
|
||||
// work. Lease acquisition, not listing, chooses an exclusive owner.
|
||||
type ReplayWorkDiscovery interface {
|
||||
ListReplayWork(ctx context.Context, page PageRequest) (ReplayActionPage, error)
|
||||
}
|
||||
|
||||
type ReplayLeaseStore interface {
|
||||
AcquireReplayLease(ctx context.Context, actionID, owner string, ttl time.Duration) (ReplayLease, error)
|
||||
RenewReplayLease(ctx context.Context, lease ReplayLease, ttl time.Duration) (ReplayLease, error)
|
||||
ReleaseReplayLease(ctx context.Context, lease ReplayLease) error
|
||||
}
|
||||
|
||||
// All updates are fenced and compare ExpectedRevision. Expired/replaced leases
|
||||
// return ErrLeaseLost; revision conflicts return ErrConflict. Implementations
|
||||
// reject progress regression and preserve immutable action identity.
|
||||
type ReplayProgressStore interface {
|
||||
UpdateReplayAction(ctx context.Context, update ReplayActionUpdate) (ReplayAction, error)
|
||||
}
|
||||
|
||||
// The implementation must preserve scan continuity while source entries are
|
||||
// cleaned. This can use a durable selection/snapshot; deleting records must not
|
||||
// make a later cursor skip an unprocessed failure. Tokens are bound to actionID.
|
||||
type ReplayFailurePager interface {
|
||||
ListReplayFailures(ctx context.Context, actionID string, page PageRequest) (FailureRecordPage, error)
|
||||
}
|
||||
|
||||
// TargetRunCreator is idempotent: retrying the same target preserves counters;
|
||||
// incompatible source/operation/month/cycle returns ErrConflict. Replay targets
|
||||
// are distinct runs and must not mutate the source's historical FailedCount.
|
||||
type TargetRunCreator interface {
|
||||
EnsureTargetRun(ctx context.Context, meta RunMeta) (RunMeta, error)
|
||||
}
|
||||
|
||||
// TargetEnqueueResult distinguishes a durable duplicate from a rejected write.
|
||||
// A zero NewlyEnqueued count alone is never sufficient evidence for cleanup.
|
||||
// Every input TaskID must be durably present on nil error; otherwise return an
|
||||
// error and let the caller safely retry the identical batch. Both counts are
|
||||
// unique task counts, and their sum equals the number of distinct input IDs.
|
||||
type TargetEnqueueResult struct {
|
||||
NewlyEnqueued int64
|
||||
AlreadyPresent int64
|
||||
}
|
||||
|
||||
type TargetRunEnqueuer interface {
|
||||
// Tasks must match the target run and channel and use stable logical IDs.
|
||||
EnqueueTargetBatch(ctx context.Context, lease ReplayLease, runID, chanKey string, tasks []Task, nextExecuteAt time.Time) (TargetEnqueueResult, error)
|
||||
MarkTargetProducerDone(ctx context.Context, lease ReplayLease, runID string) error
|
||||
}
|
||||
|
||||
// Cleanup is idempotent and conditional on the lease, exact source versions,
|
||||
// and durable evidence of target acceptance. It changes pending failures only,
|
||||
// never historical failedCount. Conflicts must remain visible for attention.
|
||||
// Source keys and action/target keys may occupy different Redis Cluster slots;
|
||||
// implementations need recoverable receipts, not cross-slot Lua assumptions.
|
||||
type ReplayFailureCleaner interface {
|
||||
CleanupReplayFailures(ctx context.Context, cleanup ReplayCleanup) (ReplayCleanupResult, error)
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
package contract
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const (
|
||||
DefaultFlowPrefix = "flow:"
|
||||
DefaultSimStatusPrefix = "sim-status:"
|
||||
)
|
||||
|
||||
// Keys is a naming contract only. It does not create indexes or storage.
|
||||
// Existing run names are byte-for-byte compatible with zset.Store. Global
|
||||
// run/archive indexes are outside run hash slots and cannot join run-local Lua.
|
||||
type Keys struct{ prefix string }
|
||||
|
||||
func NewKeys(prefix string) (Keys, error) {
|
||||
if strings.TrimSpace(prefix) == "" {
|
||||
prefix = DefaultFlowPrefix
|
||||
}
|
||||
if strings.ContainsAny(prefix, "{}") {
|
||||
return Keys{}, invalid("prefix must not contain Redis hash tags")
|
||||
}
|
||||
return Keys{prefix: prefix}, nil
|
||||
}
|
||||
|
||||
// The zero value uses the legacy flow prefix, just like NewKeys("").
|
||||
func (k Keys) Prefix() string {
|
||||
if k.prefix == "" {
|
||||
return DefaultFlowPrefix
|
||||
}
|
||||
return k.prefix
|
||||
}
|
||||
|
||||
func (k Keys) runKey(runID, suffix string) (string, error) {
|
||||
if err := ValidateID(runID); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return k.Prefix() + "{" + runID + "}:" + suffix, nil
|
||||
}
|
||||
|
||||
func (k Keys) RunMetaKey(runID string) (string, error) { return k.runKey(runID, "meta") }
|
||||
func (k Keys) RunChannelsKey(runID string) (string, error) { return k.runKey(runID, "channels") }
|
||||
func (k Keys) TaskStateKey(runID string) (string, error) { return k.runKey(runID, "task-state") }
|
||||
func (k Keys) FailedKey(runID string) (string, error) { return k.runKey(runID, "failed") }
|
||||
func (k Keys) ArchiveStateKey(runID string) (string, error) { return k.runKey(runID, "archive") }
|
||||
|
||||
func (k Keys) roundKey(runID string, round int, suffix string) (string, error) {
|
||||
if round < 1 {
|
||||
return "", invalid("round must be positive")
|
||||
}
|
||||
return k.runKey(runID, fmt.Sprintf("round:%d:%s", round, suffix))
|
||||
}
|
||||
|
||||
func (k Keys) RoundStatsKey(runID string, round int) (string, error) {
|
||||
return k.roundKey(runID, round, "stats")
|
||||
}
|
||||
|
||||
func (k Keys) roundChannelKey(runID string, round int, kind, chanKey string) (string, error) {
|
||||
if strings.TrimSpace(chanKey) == "" {
|
||||
return "", invalid("chanKey is empty")
|
||||
}
|
||||
return k.roundKey(runID, round, kind+":"+chanKey)
|
||||
}
|
||||
|
||||
func (k Keys) RoundActiveKey(runID string, round int, chanKey string) (string, error) {
|
||||
return k.roundChannelKey(runID, round, "active", chanKey)
|
||||
}
|
||||
|
||||
func (k Keys) RoundWaitingKey(runID string, round int, chanKey string) (string, error) {
|
||||
return k.roundChannelKey(runID, round, "waiting", chanKey)
|
||||
}
|
||||
|
||||
func (k Keys) RoundLeaseKey(runID string, round int, chanKey string) (string, error) {
|
||||
return k.roundChannelKey(runID, round, "lease", chanKey)
|
||||
}
|
||||
|
||||
func (k Keys) FailedRunsKey() string { return k.Prefix() + "failed:runs" }
|
||||
func (k Keys) ActiveRunsKey() string { return k.Prefix() + "runs:active" }
|
||||
func (k Keys) AllRunsKey() string { return k.Prefix() + "runs:all" }
|
||||
func (k Keys) PendingArchivesKey() string { return k.Prefix() + "archive:pending" }
|
||||
|
||||
// Replay action state, leases, idempotency reservations and pending index share
|
||||
// a dedicated slot, allowing atomic acceptance/discovery. Source/target run
|
||||
// writes remain in their own slots and require a recoverable replay protocol.
|
||||
func (k Keys) replayKey(actionID, suffix string) (string, error) {
|
||||
if err := ValidateID(actionID); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return k.Prefix() + "{replay}:action:" + actionID + ":" + suffix, nil
|
||||
}
|
||||
|
||||
func (k Keys) ReplayActionKey(actionID string) (string, error) { return k.replayKey(actionID, "meta") }
|
||||
func (k Keys) ReplayLeaseKey(actionID string) (string, error) { return k.replayKey(actionID, "lease") }
|
||||
func (k Keys) PendingReplayActionsKey() string { return k.Prefix() + "{replay}:pending" }
|
||||
|
||||
// Hash the complete tuple so neither delimiter collisions nor arbitrary
|
||||
// Idempotency-Key characters can change the Redis key or hash slot.
|
||||
func (k Keys) ReplayIdempotencyKey(sourceRunID, idempotencyKey string) (string, error) {
|
||||
if err := ValidateID(sourceRunID); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := ValidateIdempotencyKey(idempotencyKey); err != nil {
|
||||
return "", err
|
||||
}
|
||||
data, err := json.Marshal([2]string{sourceRunID, idempotencyKey})
|
||||
if err != nil {
|
||||
return "", invalid("cannot encode idempotency scope: %v", err)
|
||||
}
|
||||
digest := sha256.Sum256(data)
|
||||
return k.Prefix() + "{replay}:idempotency:" + hex.EncodeToString(digest[:]), nil
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
package contract_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/fullsync/contract"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/queue/zset"
|
||||
|
||||
"github.com/gogf/gf/v2/container/gvar"
|
||||
)
|
||||
|
||||
type unusedRedis struct{}
|
||||
|
||||
func (unusedRedis) Do(context.Context, string, ...any) (*gvar.Var, error) {
|
||||
panic("naming contracts must not access Redis")
|
||||
}
|
||||
|
||||
func (unusedRedis) Eval(context.Context, string, int64, []string, []any) (*gvar.Var, error) {
|
||||
panic("naming contracts must not access Redis")
|
||||
}
|
||||
|
||||
func TestKeysMatchExistingQueue(t *testing.T) {
|
||||
for _, prefix := range []string{"", "flow:", "sim-status:", "custom-prefix"} {
|
||||
keys, err := contract.NewKeys(prefix)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
store, err := zset.New(unusedRedis{}, prefix)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, pair := range []struct {
|
||||
old, next func(string) (string, error)
|
||||
}{
|
||||
{store.RunMetaKey, keys.RunMetaKey}, {store.RunChannelsKey, keys.RunChannelsKey},
|
||||
{store.TaskStateKey, keys.TaskStateKey}, {store.FailedKey, keys.FailedKey},
|
||||
} {
|
||||
old, oldErr := pair.old("run:1")
|
||||
next, nextErr := pair.next("run:1")
|
||||
if oldErr != nil || nextErr != nil || old != next || !strings.Contains(next, "{run:1}") {
|
||||
t.Errorf("run naming changed: old=%q new=%q errors=%v/%v", old, next, oldErr, nextErr)
|
||||
}
|
||||
}
|
||||
for _, pair := range []struct {
|
||||
old, next func(string, int, string) (string, error)
|
||||
}{
|
||||
{store.RoundActiveKey, keys.RoundActiveKey}, {store.RoundWaitingKey, keys.RoundWaitingKey},
|
||||
{store.RoundLeaseKey, keys.RoundLeaseKey},
|
||||
} {
|
||||
old, oldErr := pair.old("run:1", 3, "channel")
|
||||
next, nextErr := pair.next("run:1", 3, "channel")
|
||||
if oldErr != nil || nextErr != nil || old != next {
|
||||
t.Errorf("round naming changed: old=%q new=%q errors=%v/%v", old, next, oldErr, nextErr)
|
||||
}
|
||||
}
|
||||
if keys.ActiveRunsKey() != store.ActiveRunsKey() || keys.FailedRunsKey() != store.FailedRunsKey() {
|
||||
t.Fatal("existing global index names changed")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewKeysAndClusterBoundaries(t *testing.T) {
|
||||
keys, _ := contract.NewKeys("flow:")
|
||||
stats, _ := keys.RoundStatsKey("run", 2)
|
||||
archive, _ := keys.ArchiveStateKey("run")
|
||||
if stats != "flow:{run}:round:2:stats" || archive != "flow:{run}:archive" {
|
||||
t.Fatalf("new run keys changed: %s, %s", stats, archive)
|
||||
}
|
||||
action, _ := keys.ReplayActionKey("a")
|
||||
lease, _ := keys.ReplayLeaseKey("a")
|
||||
idempotency, _ := keys.ReplayIdempotencyKey("source", "a{different-slot}:key")
|
||||
for _, key := range []string{action, lease, idempotency, keys.PendingReplayActionsKey()} {
|
||||
if !strings.HasPrefix(key, "flow:{replay}:") || strings.Count(key, "{") != 1 {
|
||||
t.Fatalf("replay key escaped action slot: %s", key)
|
||||
}
|
||||
}
|
||||
if action != "flow:{replay}:action:a:meta" || lease != "flow:{replay}:action:a:lease" {
|
||||
t.Fatal("action naming changed")
|
||||
}
|
||||
for _, key := range []string{keys.ActiveRunsKey(), keys.FailedRunsKey(), keys.AllRunsKey(), keys.PendingArchivesKey()} {
|
||||
if strings.ContainsAny(key, "{}") {
|
||||
t.Fatalf("global index unexpectedly shares run slot: %s", key)
|
||||
}
|
||||
}
|
||||
first, _ := keys.ReplayIdempotencyKey("a:b", "c")
|
||||
second, _ := keys.ReplayIdempotencyKey("a", "b:c")
|
||||
repeat, _ := keys.ReplayIdempotencyKey("a:b", "c")
|
||||
otherSource, _ := keys.ReplayIdempotencyKey("other", "c")
|
||||
if first == second || first == otherSource || first != repeat {
|
||||
t.Fatal("idempotency scope is ambiguous or unstable")
|
||||
}
|
||||
}
|
||||
|
||||
func TestInvalidKeyParts(t *testing.T) {
|
||||
keys := contract.Keys{}
|
||||
for _, id := range []string{"", " ", "trim ", "{run}", "run}", "run/path", "run\\path", "run\n", "invalid\xff"} {
|
||||
if _, err := keys.RunMetaKey(id); !errors.Is(err, contract.ErrInvalidArgument) {
|
||||
t.Errorf("invalid ID %q returned %v", id, err)
|
||||
}
|
||||
}
|
||||
if _, err := contract.NewKeys("prefix:{wrong}:"); !errors.Is(err, contract.ErrInvalidArgument) {
|
||||
t.Fatalf("invalid prefix returned %v", err)
|
||||
}
|
||||
if _, err := keys.RoundStatsKey("run", 0); !errors.Is(err, contract.ErrInvalidArgument) {
|
||||
t.Fatalf("invalid round returned %v", err)
|
||||
}
|
||||
if _, err := keys.RoundActiveKey("run", 1, " "); !errors.Is(err, contract.ErrInvalidArgument) {
|
||||
t.Fatalf("invalid channel returned %v", err)
|
||||
}
|
||||
if key, err := keys.RunMetaKey("run"); err != nil || key != "flow:{run}:meta" {
|
||||
t.Fatalf("zero-value keys differ from legacy default: %s %v", key, err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
package contract
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
const MaxIdempotencyKeyBytes = 256
|
||||
|
||||
// ReplayRequest combines the path, header and body. Only requestedBy is in the
|
||||
// HTTP body. Idempotency keys are scoped to the source run; fingerprints cover
|
||||
// the source and exact request body, not the key or a generated target run ID.
|
||||
type ReplayRequest struct {
|
||||
SourceRunID string `json:"-"`
|
||||
IdempotencyKey string `json:"-"`
|
||||
RequestedBy string `json:"requestedBy"`
|
||||
}
|
||||
|
||||
func ValidateIdempotencyKey(key string) error {
|
||||
if strings.TrimSpace(key) == "" || len(key) > MaxIdempotencyKeyBytes || !utf8.ValidString(key) || strings.ContainsFunc(key, unicode.IsControl) {
|
||||
return invalid("invalid Idempotency-Key")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r ReplayRequest) Validate() error {
|
||||
if err := ValidateID(r.SourceRunID); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := ValidateIdempotencyKey(r.IdempotencyKey); err != nil {
|
||||
return err
|
||||
}
|
||||
if strings.TrimSpace(r.RequestedBy) == "" || !utf8.ValidString(r.RequestedBy) {
|
||||
return invalid("requestedBy is required")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r ReplayRequest) Fingerprint() (string, error) {
|
||||
if err := r.Validate(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
// JSON avoids ambiguous delimiter concatenation and fixes field ordering.
|
||||
data, err := json.Marshal(struct {
|
||||
SourceRunID string `json:"sourceRunId"`
|
||||
RequestedBy string `json:"requestedBy"`
|
||||
}{r.SourceRunID, r.RequestedBy})
|
||||
if err != nil {
|
||||
return "", invalid("cannot encode replay request: %v", err)
|
||||
}
|
||||
digest := sha256.Sum256(data)
|
||||
return hex.EncodeToString(digest[:]), nil
|
||||
}
|
||||
|
||||
// Counters describe distinct source failures, not scan visits or retry calls.
|
||||
// ScanCursor alone is not an enqueue/cleanup checkpoint: repositories must
|
||||
// retain sufficient durable per-batch evidence to resume partial writes.
|
||||
type ReplayProgress struct {
|
||||
ScanCursor Cursor `json:"scanCursor"`
|
||||
DiscoveredCount int64 `json:"discoveredCount"`
|
||||
EnqueuedCount int64 `json:"enqueuedCount"`
|
||||
CleanedCount int64 `json:"cleanedCount"`
|
||||
FailureCount int64 `json:"failureCount"`
|
||||
}
|
||||
|
||||
func (p ReplayProgress) Validate() error {
|
||||
if err := nonnegative(p.DiscoveredCount, p.EnqueuedCount, p.CleanedCount, p.FailureCount); err != nil {
|
||||
return err
|
||||
}
|
||||
if p.EnqueuedCount > p.DiscoveredCount || p.CleanedCount > p.EnqueuedCount {
|
||||
return invalid("replay progress must satisfy cleaned <= enqueued <= discovered")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type ReplayAction struct {
|
||||
ActionID string `json:"actionId"`
|
||||
SourceRunID string `json:"sourceRunId"`
|
||||
TargetRunID string `json:"targetRunId"`
|
||||
RequestedBy string `json:"requestedBy"`
|
||||
IdempotencyKey string `json:"-"`
|
||||
Fingerprint string `json:"-"`
|
||||
Status ActionStatus `json:"status"`
|
||||
ReplayCycle int `json:"replayCycle"`
|
||||
Progress ReplayProgress `json:"progress"`
|
||||
LeaseOwner string `json:"-"`
|
||||
LeaseToken string `json:"-"`
|
||||
LeaseUntil time.Time `json:"-"`
|
||||
Revision uint64 `json:"-"`
|
||||
Error string `json:"error,omitempty"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
CompletedAt *time.Time `json:"completedAt,omitempty"`
|
||||
Receipt *ArchiveReceipt `json:"receipt,omitempty"`
|
||||
}
|
||||
|
||||
func (a ReplayAction) Validate() error {
|
||||
for _, id := range []string{a.ActionID, a.SourceRunID, a.TargetRunID} {
|
||||
if err := ValidateID(id); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if a.SourceRunID == a.TargetRunID || a.ReplayCycle < 1 || !a.Status.Valid() || strings.TrimSpace(a.RequestedBy) == "" {
|
||||
return invalid("invalid replay action identity or status")
|
||||
}
|
||||
if len(a.Error) > MaxErrorMessageBytes || !utf8.ValidString(a.Error) {
|
||||
return invalid("action error exceeds message bounds")
|
||||
}
|
||||
if err := a.Progress.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
if a.Receipt != nil {
|
||||
if err := a.Receipt.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if a.Status == ActionCompleted && (a.Receipt == nil || a.CompletedAt == nil || a.CompletedAt.IsZero()) {
|
||||
return invalid("completed action requires a durable receipt and completedAt")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type ReplayActionPage struct {
|
||||
Items []ReplayAction `json:"items"`
|
||||
NextCursor Cursor `json:"nextCursor"`
|
||||
}
|
||||
|
||||
type ReplayActionCreation struct {
|
||||
Action ReplayAction
|
||||
Created bool
|
||||
}
|
||||
|
||||
// Lease token fences a particular acquisition, even if the same owner name is
|
||||
// reused. Repository methods check expiry against their authoritative clock.
|
||||
type ReplayLease struct {
|
||||
ActionID string
|
||||
Owner string
|
||||
Token string
|
||||
Until time.Time
|
||||
}
|
||||
|
||||
// ReplayActionUpdate is a compare-and-swap of mutable action state. Identity,
|
||||
// provenance, request fingerprint, and target run must never be changed.
|
||||
type ReplayActionUpdate struct {
|
||||
Lease ReplayLease
|
||||
ExpectedRevision uint64
|
||||
Status ActionStatus
|
||||
Progress ReplayProgress
|
||||
Error string
|
||||
Receipt *ArchiveReceipt
|
||||
CompletedAt *time.Time
|
||||
}
|
||||
|
||||
// Cleanup contains the exact source versions durably accepted by the target.
|
||||
// The engine must not request cleanup until target enqueue is confirmed.
|
||||
type ReplayCleanup struct {
|
||||
Lease ReplayLease
|
||||
SourceRunID string
|
||||
TargetRunID string
|
||||
Entries []FailureEntry
|
||||
}
|
||||
|
||||
type ReplayCleanupResult struct {
|
||||
CleanedCount int64
|
||||
AlreadyCleanedCount int64
|
||||
ConflictCount int64
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
package contract_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/fullsync/contract"
|
||||
)
|
||||
|
||||
func TestReplayRequestIdentity(t *testing.T) {
|
||||
request := contract.ReplayRequest{SourceRunID: "source", IdempotencyKey: "key", RequestedBy: "operator"}
|
||||
fingerprint, err := request.Fingerprint()
|
||||
if err != nil || len(fingerprint) != 64 {
|
||||
t.Fatalf("fingerprint=%q, err=%v", fingerprint, err)
|
||||
}
|
||||
for name, mutate := range map[string]func(*contract.ReplayRequest){
|
||||
"same": func(*contract.ReplayRequest) {},
|
||||
"key": func(r *contract.ReplayRequest) { r.IdempotencyKey = "another-key" },
|
||||
"source": func(r *contract.ReplayRequest) { r.SourceRunID = "another-source" },
|
||||
"body": func(r *contract.ReplayRequest) { r.RequestedBy = "another-operator" },
|
||||
} {
|
||||
copy := request
|
||||
mutate(©)
|
||||
got, err := copy.Fingerprint()
|
||||
if err != nil || (got == fingerprint) != (name == "same" || name == "key") {
|
||||
t.Errorf("%s fingerprint=%s err=%v", name, got, err)
|
||||
}
|
||||
}
|
||||
if got := string(mustJSON(t, request)); got != `{"requestedBy":"operator"}` {
|
||||
t.Fatalf("path/header leaked into body: %s", got)
|
||||
}
|
||||
for _, bad := range []contract.ReplayRequest{
|
||||
{SourceRunID: "source", RequestedBy: "operator"},
|
||||
{SourceRunID: "source", IdempotencyKey: "key", RequestedBy: " "},
|
||||
{SourceRunID: "{source}", IdempotencyKey: "key", RequestedBy: "operator"},
|
||||
{SourceRunID: "source", IdempotencyKey: "key\r\ninjected", RequestedBy: "operator"},
|
||||
{SourceRunID: "source", IdempotencyKey: strings.Repeat("x", 257), RequestedBy: "operator"},
|
||||
} {
|
||||
if _, err := bad.Fingerprint(); !errors.Is(err, contract.ErrInvalidArgument) {
|
||||
t.Errorf("bad request returned %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestActionProgressAndReceiptValidation(t *testing.T) {
|
||||
for _, progress := range []contract.ReplayProgress{
|
||||
{DiscoveredCount: -1}, {DiscoveredCount: 1, EnqueuedCount: 2},
|
||||
{DiscoveredCount: 2, EnqueuedCount: 1, CleanedCount: 2}, {FailureCount: -1},
|
||||
} {
|
||||
if err := progress.Validate(); !errors.Is(err, contract.ErrInvalidArgument) {
|
||||
t.Fatalf("bad progress %+v returned %v", progress, err)
|
||||
}
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
action := contract.ReplayAction{ActionID: "action", SourceRunID: "source", TargetRunID: "target", RequestedBy: "operator", ReplayCycle: 1, Status: contract.ActionAccepted}
|
||||
if err := action.Validate(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
action.Status = contract.ActionCompleted
|
||||
if err := action.Validate(); !errors.Is(err, contract.ErrInvalidArgument) {
|
||||
t.Fatalf("completed action without receipt returned %v", err)
|
||||
}
|
||||
action.Receipt = &contract.ArchiveReceipt{Version: 1, Path: "receipt.json", Checksum: strings.Repeat("a", 64), ArchivedAt: now}
|
||||
action.CompletedAt = &now
|
||||
if err := action.Validate(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
action.Receipt.Checksum = "not-a-checksum"
|
||||
if err := action.Validate(); !errors.Is(err, contract.ErrInvalidArgument) {
|
||||
t.Fatalf("bad receipt returned %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestActionWireFormatHidesInternalState(t *testing.T) {
|
||||
action := contract.ReplayAction{
|
||||
ActionID: "action", SourceRunID: "source", TargetRunID: "target", RequestedBy: "operator", ReplayCycle: 1,
|
||||
Status: contract.ActionCleanupPending, IdempotencyKey: "secret", Fingerprint: "internal-fingerprint",
|
||||
LeaseOwner: "worker", LeaseToken: "token", LeaseUntil: time.Now(), Revision: 9,
|
||||
Progress: contract.ReplayProgress{ScanCursor: "opaque", DiscoveredCount: 10, EnqueuedCount: 8, CleanedCount: 7, FailureCount: 2},
|
||||
}
|
||||
data := mustJSON(t, action)
|
||||
for _, hidden := range []string{"idempotency", "fingerprint", "lease", "revision", "secret", "token"} {
|
||||
if strings.Contains(strings.ToLower(string(data)), hidden) {
|
||||
t.Errorf("internal %s leaked: %s", hidden, data)
|
||||
}
|
||||
}
|
||||
var decoded contract.ReplayAction
|
||||
if err := json.Unmarshal(data, &decoded); err != nil || !reflect.DeepEqual(decoded.Progress, action.Progress) || decoded.Status != contract.ActionCleanupPending {
|
||||
t.Fatalf("action roundtrip lost state: %+v, %v", decoded, err)
|
||||
}
|
||||
statusURL, err := contract.ActionStatusURL("action%1")
|
||||
if err != nil || statusURL != "/internal/v1/full-sync-actions/action%251" {
|
||||
t.Fatalf("status URL=%s, %v", statusURL, err)
|
||||
}
|
||||
accepted := contract.ReplayAccepted{ActionID: "action", TargetRunID: "target", Status: contract.ActionAccepted, StatusURL: "/internal/v1/full-sync-actions/action"}
|
||||
if got := string(mustJSON(t, accepted)); got != `{"actionId":"action","targetRunId":"target","status":"accepted","statusUrl":"/internal/v1/full-sync-actions/action"}` {
|
||||
t.Fatalf("202 contract changed: %s", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
package contract
|
||||
|
||||
import (
|
||||
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/fullsync/supply"
|
||||
"time"
|
||||
)
|
||||
|
||||
// RunMeta retains every existing queue metadata field and JSON tag. The
|
||||
// optional fields are additive; zero extension fields are legal on old runs.
|
||||
type RunMeta struct {
|
||||
SupplyObservation *supply.Snapshot `json:"supplyObservation,omitempty"`
|
||||
RunID string `json:"runId"`
|
||||
Operation string `json:"operation"`
|
||||
QueryMonth string `json:"queryMonth"`
|
||||
Status RunStatus `json:"status"`
|
||||
ProducerDone bool `json:"producerDone"`
|
||||
CurrentRound int `json:"currentRound"`
|
||||
ActiveCount int64 `json:"activeCount"`
|
||||
InflightCount int64 `json:"inflightCount"`
|
||||
NextRoundCount int64 `json:"nextRoundCount"`
|
||||
SuccessCount int64 `json:"successCount"`
|
||||
FailedCount int64 `json:"failedCount"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
ProducerCompletedAt time.Time `json:"producerCompletedAt"`
|
||||
CompletedAt time.Time `json:"completedAt"`
|
||||
|
||||
SyncType SyncType `json:"syncType,omitempty"`
|
||||
SourceRunID string `json:"sourceRunId,omitempty"`
|
||||
ReplayCycle int `json:"replayCycle,omitempty"`
|
||||
TriggerSource string `json:"triggerSource,omitempty"`
|
||||
ScannedCount int64 `json:"scannedCount,omitempty"`
|
||||
EnqueuedCount int64 `json:"enqueuedCount,omitempty"`
|
||||
UpstreamAttemptCount int64 `json:"upstreamAttemptCount,omitempty"`
|
||||
BatchCount int64 `json:"batchCount,omitempty"`
|
||||
MaxRound int `json:"maxRound,omitempty"`
|
||||
PendingFailureCount int64 `json:"pendingFailureCount,omitempty"`
|
||||
StartedAt *time.Time `json:"startedAt,omitempty"`
|
||||
ScanCompletedAt *time.Time `json:"scanCompletedAt,omitempty"`
|
||||
ArchiveStatus ArchiveStatus `json:"archiveStatus,omitempty"`
|
||||
ArchiveVersion int `json:"archiveVersion,omitempty"`
|
||||
ArchivePath string `json:"archivePath,omitempty"`
|
||||
ArchiveChecksum string `json:"archiveChecksum,omitempty"`
|
||||
ArchivedAt *time.Time `json:"archivedAt,omitempty"`
|
||||
ReplayDisposition ReplayDisposition `json:"replayDisposition,omitempty"`
|
||||
// Scope fields mirror zset.RunMeta. ScopeChannels == 0 means a full sync.
|
||||
ScopeChannels int `json:"scopeChannels,omitempty"`
|
||||
ScopeDigest string `json:"scopeDigest,omitempty"`
|
||||
ScopeChannelsSample string `json:"scopeChannelsSample,omitempty"`
|
||||
RoundStats []RoundStats `json:"rounds,omitempty"`
|
||||
}
|
||||
|
||||
func (m RunMeta) Validate() error {
|
||||
if err := ValidateID(m.RunID); err != nil {
|
||||
return err
|
||||
}
|
||||
if m.Status != "" && !m.Status.Valid() {
|
||||
return invalid("unknown run status")
|
||||
}
|
||||
if m.SyncType != "" && !m.SyncType.Valid() {
|
||||
return invalid("unknown sync type")
|
||||
}
|
||||
if m.ArchiveStatus != "" && !m.ArchiveStatus.Valid() {
|
||||
return invalid("unknown archive status")
|
||||
}
|
||||
if m.ReplayDisposition != "" && !m.ReplayDisposition.Valid() {
|
||||
return invalid("unknown replay disposition")
|
||||
}
|
||||
if m.SourceRunID != "" {
|
||||
if err := ValidateID(m.SourceRunID); err != nil {
|
||||
return err
|
||||
}
|
||||
if m.SourceRunID == m.RunID {
|
||||
return invalid("a run cannot replay itself")
|
||||
}
|
||||
}
|
||||
if m.SyncType == SyncReplay && (m.SourceRunID == "" || m.ReplayCycle < 1) {
|
||||
return invalid("replay requires sourceRunId and a positive replayCycle")
|
||||
}
|
||||
if m.SyncType == SyncNormal && (m.SourceRunID != "" || m.ReplayCycle != 0) {
|
||||
return invalid("normal runs cannot have replay provenance")
|
||||
}
|
||||
if m.MaxRound > 0 && m.CurrentRound > m.MaxRound {
|
||||
return invalid("currentRound exceeds maxRound")
|
||||
}
|
||||
return nonnegative(int64(m.CurrentRound), int64(m.MaxRound), int64(m.ReplayCycle), int64(m.ArchiveVersion), m.ActiveCount, m.InflightCount, m.NextRoundCount, m.SuccessCount, m.FailedCount, m.ScannedCount, m.EnqueuedCount, m.UpstreamAttemptCount, m.BatchCount, m.PendingFailureCount, int64(m.ScopeChannels))
|
||||
}
|
||||
|
||||
// ValidateAccounting is opt-in for runs whose extended counters are known.
|
||||
// Do not apply it to legacy metadata with a missing enqueuedCount. Inflight is
|
||||
// a subset of active and is never added to the completion equation.
|
||||
func (m RunMeta) ValidateAccounting() error {
|
||||
if err := m.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
remaining := m.EnqueuedCount
|
||||
for _, count := range []int64{m.SuccessCount, m.FailedCount, m.ActiveCount, m.NextRoundCount} {
|
||||
if count > remaining {
|
||||
return invalid("enqueuedCount does not balance run outcomes")
|
||||
}
|
||||
remaining -= count
|
||||
}
|
||||
if remaining != 0 || m.InflightCount > m.ActiveCount || m.PendingFailureCount > m.FailedCount {
|
||||
return invalid("inconsistent run counters")
|
||||
}
|
||||
if m.Status.Terminal() && (!m.ProducerDone || m.ActiveCount != 0 || m.NextRoundCount != 0) {
|
||||
return invalid("terminal run still has producer or queue work")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// RoundStats contains only bounded aggregate fields. It must never grow with
|
||||
// the number of cards, channels, task IDs, upstream codes, or error messages.
|
||||
type RoundStats struct {
|
||||
RunID string `json:"runId"`
|
||||
Round int `json:"round"`
|
||||
EnqueuedCount int64 `json:"enqueuedCount"`
|
||||
ActiveCount int64 `json:"activeCount"`
|
||||
NextRoundCount int64 `json:"nextRoundCount"`
|
||||
SuccessCount int64 `json:"successCount"`
|
||||
FailedCount int64 `json:"failedCount"`
|
||||
UpstreamAttemptCount int64 `json:"upstreamAttemptCount"`
|
||||
BatchCount int64 `json:"batchCount"`
|
||||
StartedAt *time.Time `json:"startedAt,omitempty"`
|
||||
CompletedAt *time.Time `json:"completedAt,omitempty"`
|
||||
ErrorCounts ErrorCounts `json:"errorCounts"`
|
||||
}
|
||||
|
||||
func (s RoundStats) Validate() error {
|
||||
if err := ValidateID(s.RunID); err != nil {
|
||||
return err
|
||||
}
|
||||
if s.Round < 1 {
|
||||
return invalid("round must be positive")
|
||||
}
|
||||
if err := nonnegative(s.EnqueuedCount, s.ActiveCount, s.NextRoundCount, s.SuccessCount, s.FailedCount, s.UpstreamAttemptCount, s.BatchCount); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err := s.ErrorCounts.Normalized()
|
||||
return err
|
||||
}
|
||||
|
||||
type RunView struct {
|
||||
Source RunSource `json:"source"`
|
||||
Run RunMeta `json:"run"`
|
||||
Rounds []RoundStats `json:"rounds,omitempty"`
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
package contract
|
||||
|
||||
type RunStatus string
|
||||
|
||||
const (
|
||||
RunProducing RunStatus = "producing"
|
||||
RunRunning RunStatus = "running"
|
||||
RunCompleted RunStatus = "completed"
|
||||
RunCompletedWithFailures RunStatus = "completed_with_failures"
|
||||
)
|
||||
|
||||
func (s RunStatus) Valid() bool {
|
||||
return s == RunProducing || s == RunRunning || s.Terminal()
|
||||
}
|
||||
|
||||
func (s RunStatus) Terminal() bool { return s == RunCompleted || s == RunCompletedWithFailures }
|
||||
|
||||
type SyncType string
|
||||
|
||||
const (
|
||||
SyncNormal SyncType = "normal"
|
||||
SyncReplay SyncType = "replay"
|
||||
)
|
||||
|
||||
func (s SyncType) Valid() bool { return s == SyncNormal || s == SyncReplay }
|
||||
|
||||
type SyncKind string
|
||||
|
||||
const (
|
||||
SyncKindTrafficUsage SyncKind = "traffic_usage"
|
||||
SyncKindSimStatus SyncKind = "sim_status"
|
||||
SyncKindTrafficUsageDaily SyncKind = "traffic_usage_daily"
|
||||
SyncKindTrafficUsageMonthly SyncKind = "traffic_usage_monthly"
|
||||
SyncKindImei SyncKind = "imei"
|
||||
)
|
||||
|
||||
func (s SyncKind) Valid() bool {
|
||||
return s == SyncKindTrafficUsage || s == SyncKindSimStatus || s == SyncKindTrafficUsageDaily || s == SyncKindTrafficUsageMonthly || s == SyncKindImei
|
||||
}
|
||||
|
||||
func SyncKindForOperation(operation string) SyncKind {
|
||||
switch operation {
|
||||
case "current_month_usage_full_sync":
|
||||
return SyncKindTrafficUsage
|
||||
case "sim_status_queue_full_sync":
|
||||
return SyncKindSimStatus
|
||||
case "previous_day_usage_full_sync":
|
||||
return SyncKindTrafficUsageDaily
|
||||
case "previous_month_usage_full_sync":
|
||||
return SyncKindTrafficUsageMonthly
|
||||
case "imei_full_sync":
|
||||
return SyncKindImei
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
type ArchiveStatus string
|
||||
|
||||
const (
|
||||
ArchiveDisabled ArchiveStatus = "disabled"
|
||||
ArchivePending ArchiveStatus = "pending"
|
||||
ArchiveProcessing ArchiveStatus = "processing"
|
||||
ArchiveSucceeded ArchiveStatus = "succeeded"
|
||||
ArchiveFailed ArchiveStatus = "failed"
|
||||
)
|
||||
|
||||
func (s ArchiveStatus) Valid() bool {
|
||||
switch s {
|
||||
case ArchiveDisabled, ArchivePending, ArchiveProcessing, ArchiveSucceeded, ArchiveFailed:
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// ReplayDisposition describes a source run, not an individual replay action.
|
||||
type ReplayDisposition string
|
||||
|
||||
const (
|
||||
ReplayNone ReplayDisposition = "none"
|
||||
ReplayPending ReplayDisposition = "pending"
|
||||
ReplayProcessing ReplayDisposition = "processing"
|
||||
ReplayCompleted ReplayDisposition = "completed"
|
||||
ReplayNeedsAttention ReplayDisposition = "needs_attention"
|
||||
)
|
||||
|
||||
func (s ReplayDisposition) Valid() bool {
|
||||
switch s {
|
||||
case ReplayNone, ReplayPending, ReplayProcessing, ReplayCompleted, ReplayNeedsAttention:
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
type ActionStatus string
|
||||
|
||||
const (
|
||||
ActionAccepted ActionStatus = "accepted"
|
||||
ActionProcessing ActionStatus = "processing"
|
||||
ActionCleanupPending ActionStatus = "cleanup_pending"
|
||||
ActionReceiptPending ActionStatus = "receipt_pending"
|
||||
ActionCompleted ActionStatus = "completed"
|
||||
ActionNeedsAttention ActionStatus = "needs_attention"
|
||||
)
|
||||
|
||||
func (s ActionStatus) Valid() bool {
|
||||
switch s {
|
||||
case ActionAccepted, ActionProcessing, ActionCleanupPending, ActionReceiptPending, ActionCompleted, ActionNeedsAttention:
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Terminal means automatic work has stopped; needs_attention may be resumed
|
||||
// only by an explicit recovery policy, never by assuming it succeeded.
|
||||
func (s ActionStatus) Terminal() bool { return s == ActionCompleted || s == ActionNeedsAttention }
|
||||
|
||||
type RunSource string
|
||||
|
||||
const (
|
||||
SourceLive RunSource = "live"
|
||||
SourceArchive RunSource = "archive"
|
||||
)
|
||||
|
||||
func (s RunSource) Valid() bool { return s == SourceLive || s == SourceArchive }
|
||||
|
||||
// These values are already persisted by the queue protocol.
|
||||
const (
|
||||
TaskQueued = "queued"
|
||||
TaskClaimed = "claimed"
|
||||
TaskWaitingNextRound = "waiting_next_round"
|
||||
TaskSucceeded = "succeeded"
|
||||
TaskCovered = "covered"
|
||||
TaskFailedPendingReview = "failed_pending_review"
|
||||
TaskChannelBlocked = "channel_blocked"
|
||||
ReviewPending = "pending"
|
||||
)
|
||||
@@ -0,0 +1,57 @@
|
||||
package contract_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/fullsync/contract"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/model"
|
||||
)
|
||||
|
||||
// TestSyncKindForDailyUsageOperation guards the mapping that makes a daily run
|
||||
// discoverable. Catalog.ListRuns drops any run whose operation maps to an empty
|
||||
// kind, so losing this case would make daily runs invisible in the monitor
|
||||
// without any error.
|
||||
func TestSyncKindForDailyUsageOperation(t *testing.T) {
|
||||
if got := contract.SyncKindForOperation(model.OperationPreviousDayUsageFullSync); got != contract.SyncKindTrafficUsageDaily {
|
||||
t.Fatalf("SyncKindForOperation(%q) = %q, want %q",
|
||||
model.OperationPreviousDayUsageFullSync, got, contract.SyncKindTrafficUsageDaily)
|
||||
}
|
||||
if !contract.SyncKindTrafficUsageDaily.Valid() {
|
||||
t.Fatal("SyncKindTrafficUsageDaily must be a valid kind")
|
||||
}
|
||||
if contract.SyncKindForOperation("some_unknown_operation") != "" {
|
||||
t.Fatal("an unknown operation must not be assigned a sync kind")
|
||||
}
|
||||
}
|
||||
|
||||
// TestSyncKindForMonthlyHistoryOperation guards the mapping that makes a monthly
|
||||
// history run discoverable. Catalog.ListRuns drops any run whose operation maps
|
||||
// to an empty kind, so losing this case would make monthly history runs
|
||||
// invisible in the monitor without any error.
|
||||
func TestSyncKindForMonthlyHistoryOperation(t *testing.T) {
|
||||
if got := contract.SyncKindForOperation(model.OperationPreviousMonthUsageFullSync); got != contract.SyncKindTrafficUsageMonthly {
|
||||
t.Fatalf("SyncKindForOperation(%q) = %q, want %q",
|
||||
model.OperationPreviousMonthUsageFullSync, got, contract.SyncKindTrafficUsageMonthly)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDailyAndMonthlyUsageHaveDistinctSyncKinds keeps the usage runs separately
|
||||
// filterable. There are three distinct usage runs now: the MySQL current-month
|
||||
// snapshot, the TDengine daily history, and the TDengine monthly history.
|
||||
func TestDailyAndMonthlyUsageHaveDistinctSyncKinds(t *testing.T) {
|
||||
monthly := contract.SyncKindForOperation("current_month_usage_full_sync")
|
||||
daily := contract.SyncKindForOperation(model.OperationPreviousDayUsageFullSync)
|
||||
monthlyHistory := contract.SyncKindForOperation(model.OperationPreviousMonthUsageFullSync)
|
||||
if monthly == daily {
|
||||
t.Fatalf("current-month snapshot and daily history share the sync kind %q", daily)
|
||||
}
|
||||
if monthly == monthlyHistory {
|
||||
t.Fatalf("current-month snapshot and monthly history share the sync kind %q", monthlyHistory)
|
||||
}
|
||||
if daily == monthlyHistory {
|
||||
t.Fatalf("daily and monthly history share the sync kind %q", daily)
|
||||
}
|
||||
if !monthlyHistory.Valid() {
|
||||
t.Fatal("SyncKindTrafficUsageMonthly must be a valid kind")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
package contract_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/fullsync/contract"
|
||||
)
|
||||
|
||||
// TestSyncKindForOperationCoversEveryChain pins the operation→kind mapping for
|
||||
// all five full-sync chains. A missing case makes the run invisible in the
|
||||
// admin monitor, because the catalog filters runs by SyncKind.
|
||||
func TestSyncKindForOperationCoversEveryChain(t *testing.T) {
|
||||
cases := []struct {
|
||||
operation string
|
||||
want contract.SyncKind
|
||||
}{
|
||||
{"current_month_usage_full_sync", contract.SyncKindTrafficUsage},
|
||||
{"sim_status_queue_full_sync", contract.SyncKindSimStatus},
|
||||
{"previous_day_usage_full_sync", contract.SyncKindTrafficUsageDaily},
|
||||
{"previous_month_usage_full_sync", contract.SyncKindTrafficUsageMonthly},
|
||||
{"imei_full_sync", contract.SyncKindImei},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
got := contract.SyncKindForOperation(tc.operation)
|
||||
if got != tc.want {
|
||||
t.Fatalf("SyncKindForOperation(%q) = %q, want %q", tc.operation, got, tc.want)
|
||||
}
|
||||
if !got.Valid() {
|
||||
t.Fatalf("%q 应通过 Valid()", got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestSyncKindForOperationRejectsUnknown 未知 operation 返回空 kind,
|
||||
// 由调用方决定是跳过还是报错。
|
||||
func TestSyncKindForOperationRejectsUnknown(t *testing.T) {
|
||||
for _, operation := range []string{"", "unknown", "IMEI_FULL_SYNC"} {
|
||||
got := contract.SyncKindForOperation(operation)
|
||||
if got != "" {
|
||||
t.Fatalf("SyncKindForOperation(%q) = %q, want empty", operation, got)
|
||||
}
|
||||
if got.Valid() {
|
||||
t.Fatalf("空 kind 不应通过 Valid()")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestSyncKindValidRejectsUnknown 只有五个已注册的 kind 有效。
|
||||
func TestSyncKindValidRejectsUnknown(t *testing.T) {
|
||||
valid := []contract.SyncKind{
|
||||
contract.SyncKindTrafficUsage,
|
||||
contract.SyncKindSimStatus,
|
||||
contract.SyncKindTrafficUsageDaily,
|
||||
contract.SyncKindTrafficUsageMonthly,
|
||||
contract.SyncKindImei,
|
||||
}
|
||||
for _, kind := range valid {
|
||||
if !kind.Valid() {
|
||||
t.Fatalf("%q 应有效", kind)
|
||||
}
|
||||
}
|
||||
invalid := []contract.SyncKind{"", "imei_full_sync", "IMEI", "traffic", "sim-status"}
|
||||
for _, kind := range invalid {
|
||||
if kind.Valid() {
|
||||
t.Fatalf("%q 不应有效", kind)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestSyncKindValuesAreStable 枚举字符串是持久化契约(写入 Run 元数据、
|
||||
// 归档 JSON 与 admin 查询参数),不得随意改动。
|
||||
func TestSyncKindValuesAreStable(t *testing.T) {
|
||||
cases := map[contract.SyncKind]string{
|
||||
contract.SyncKindTrafficUsage: "traffic_usage",
|
||||
contract.SyncKindSimStatus: "sim_status",
|
||||
contract.SyncKindTrafficUsageDaily: "traffic_usage_daily",
|
||||
contract.SyncKindTrafficUsageMonthly: "traffic_usage_monthly",
|
||||
contract.SyncKindImei: "imei",
|
||||
}
|
||||
for kind, want := range cases {
|
||||
if string(kind) != want {
|
||||
t.Fatalf("kind = %q, want %q(持久化契约不得改动)", kind, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package contract
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"unicode"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/gogf/gf/v2/errors/gerror"
|
||||
)
|
||||
|
||||
// Implementations wrap these errors so callers can use errors.Is without
|
||||
// inspecting error text. HTTP adapters choose the response envelope themselves.
|
||||
var (
|
||||
ErrNotFound = gerror.New("full-sync resource not found")
|
||||
ErrConflict = gerror.New("full-sync conflict")
|
||||
ErrInvalidArgument = gerror.New("invalid full-sync argument")
|
||||
ErrFailureDetailsUnavailable = gerror.New("live failure details unavailable")
|
||||
ErrLeaseLost = gerror.New("full-sync lease lost")
|
||||
ErrBusy = gerror.New("full-sync resource busy")
|
||||
)
|
||||
|
||||
func invalid(format string, args ...any) error {
|
||||
return gerror.Wrapf(ErrInvalidArgument, format, args...)
|
||||
}
|
||||
|
||||
// ValidateID checks new resource identifiers used both as Redis hash tags and
|
||||
// HTTP path segments. It does not rewrite historical identifiers during decode.
|
||||
func ValidateID(id string) error {
|
||||
if id == "" || strings.TrimSpace(id) != id || !utf8.ValidString(id) || strings.ContainsAny(id, "{}/\\") || strings.ContainsFunc(id, unicode.IsControl) {
|
||||
return invalid("invalid resource identifier")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func nonnegative(values ...int64) error {
|
||||
for _, value := range values {
|
||||
if value < 0 {
|
||||
return invalid("counts must be nonnegative")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
package contract_test
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"math"
|
||||
"testing"
|
||||
|
||||
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/fullsync/contract"
|
||||
)
|
||||
|
||||
func TestRunAccounting(t *testing.T) {
|
||||
valid := contract.RunMeta{
|
||||
RunID: "run", Status: contract.RunRunning, CurrentRound: 1, MaxRound: 5,
|
||||
EnqueuedCount: 10, ActiveCount: 3, InflightCount: 2, NextRoundCount: 2,
|
||||
SuccessCount: 4, FailedCount: 1, PendingFailureCount: 1,
|
||||
}
|
||||
if err := valid.ValidateAccounting(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for name, mutate := range map[string]func(*contract.RunMeta){
|
||||
"unbalanced": func(m *contract.RunMeta) { m.EnqueuedCount++ },
|
||||
"negative": func(m *contract.RunMeta) { m.BatchCount = -1 },
|
||||
"overflow": func(m *contract.RunMeta) {
|
||||
m.EnqueuedCount = math.MaxInt64
|
||||
m.SuccessCount = math.MaxInt64
|
||||
m.FailedCount = math.MaxInt64
|
||||
},
|
||||
"inflight double count": func(m *contract.RunMeta) { m.InflightCount = m.ActiveCount + 1 },
|
||||
"pending exceeds history": func(m *contract.RunMeta) { m.PendingFailureCount = 2 },
|
||||
"terminal with work": func(m *contract.RunMeta) { m.Status = contract.RunCompletedWithFailures; m.ProducerDone = true },
|
||||
"unknown status": func(m *contract.RunMeta) { m.Status = "succeeded" },
|
||||
"unknown sync type": func(m *contract.RunMeta) { m.SyncType = "manual" },
|
||||
"action status in archive": func(m *contract.RunMeta) { m.ArchiveStatus = "completed" },
|
||||
"action status in disposition": func(m *contract.RunMeta) { m.ReplayDisposition = "cleanup_pending" },
|
||||
"round out of range": func(m *contract.RunMeta) { m.CurrentRound = 6 },
|
||||
"replay without source": func(m *contract.RunMeta) { m.SyncType = contract.SyncReplay },
|
||||
"self replay": func(m *contract.RunMeta) {
|
||||
m.SyncType = contract.SyncReplay
|
||||
m.SourceRunID = m.RunID
|
||||
m.ReplayCycle = 1
|
||||
},
|
||||
"normal with provenance": func(m *contract.RunMeta) { m.SyncType = contract.SyncNormal; m.SourceRunID = "source" },
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
meta := valid
|
||||
mutate(&meta)
|
||||
if err := meta.ValidateAccounting(); !errors.Is(err, contract.ErrInvalidArgument) {
|
||||
t.Fatalf("invalid accounting returned %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
// Cleaned source failures never subtract from the historical failed total.
|
||||
completed := contract.RunMeta{RunID: "source", Status: contract.RunCompletedWithFailures, ProducerDone: true, EnqueuedCount: 5, SuccessCount: 3, FailedCount: 2, PendingFailureCount: 0}
|
||||
if err := completed.ValidateAccounting(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
completed.ProducerDone = false
|
||||
if err := completed.ValidateAccounting(); err == nil {
|
||||
t.Fatal("accepted terminal run with unfinished producer")
|
||||
}
|
||||
if err := (contract.RunMeta{RunID: "old", Status: contract.RunRunning, SuccessCount: 42}).Validate(); err != nil {
|
||||
t.Fatalf("historical run without new counters rejected: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRoundStatsValidation(t *testing.T) {
|
||||
valid := contract.RoundStats{RunID: "run", Round: 1, ErrorCounts: contract.ErrorCounts{"unrecognized": 1}}
|
||||
if err := valid.Validate(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, stats := range []contract.RoundStats{
|
||||
{RunID: "run", Round: 0}, {RunID: "run", Round: 1, BatchCount: -1},
|
||||
{RunID: "run", Round: 1, ErrorCounts: contract.ErrorCounts{contract.ErrorOther: -1}},
|
||||
} {
|
||||
if err := stats.Validate(); !errors.Is(err, contract.ErrInvalidArgument) {
|
||||
t.Fatalf("invalid round stats returned %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPageLimitsAndOpaqueCursor(t *testing.T) {
|
||||
for _, limit := range []int{0, 1, 100, 500} {
|
||||
request, err := (contract.PageRequest{Limit: limit, Cursor: "opaque+/=:%?next"}).Normalized()
|
||||
if err != nil || request.Cursor != "opaque+/=:%?next" || request.Limit < 1 || request.Limit > contract.MaxPageLimit {
|
||||
t.Fatalf("unexpected page: %+v, %v", request, err)
|
||||
}
|
||||
if limit == 0 && request.Limit != 100 {
|
||||
t.Fatal("default limit changed")
|
||||
}
|
||||
}
|
||||
for _, limit := range []int{-1, 501, math.MaxInt} {
|
||||
if _, err := (contract.PageRequest{Limit: limit}).Normalized(); !errors.Is(err, contract.ErrInvalidArgument) {
|
||||
t.Fatalf("limit %d returned %v", limit, err)
|
||||
}
|
||||
}
|
||||
page := contract.FailurePage{Items: []contract.Failure{}, NextCursor: "continue"}
|
||||
if got := string(mustJSON(t, page)); got != `{"items":[],"nextCursor":"continue"}` {
|
||||
t.Fatalf("empty scan page lost its continuation: %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStatusVocabulary(t *testing.T) {
|
||||
for _, status := range []contract.ActionStatus{"accepted", "processing", "cleanup_pending", "receipt_pending", "completed", "needs_attention"} {
|
||||
if !status.Valid() || status.Terminal() != (status == "completed" || status == "needs_attention") {
|
||||
t.Errorf("invalid action status %q", status)
|
||||
}
|
||||
}
|
||||
for _, status := range []contract.ArchiveStatus{"disabled", "pending", "processing", "succeeded", "failed"} {
|
||||
if !status.Valid() {
|
||||
t.Errorf("invalid archive status %q", status)
|
||||
}
|
||||
}
|
||||
for _, status := range []contract.ReplayDisposition{"none", "pending", "processing", "completed", "needs_attention"} {
|
||||
if !status.Valid() {
|
||||
t.Errorf("invalid disposition %q", status)
|
||||
}
|
||||
}
|
||||
if contract.ActionStatus("succeeded").Valid() || contract.ArchiveStatus("completed").Valid() || contract.ReplayDisposition("receipt_pending").Valid() {
|
||||
t.Fatal("status domains were conflated")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
# Full-sync HTTP control API
|
||||
|
||||
The handler imports no queue or storage implementations. Inject the five
|
||||
contract interfaces in `Dependencies`: `RunLister`, `RunReader`,
|
||||
`FailurePager`, `ReplayRequester`, and `ActionReader`. The replay service owns durable
|
||||
idempotency and execution; the HTTP layer does not cache requests or launch work.
|
||||
|
||||
Use `NewRunReader(live, archive, rounds)` to compose `LiveRunReader`,
|
||||
`ArchivedRunReader`, and `RoundStatsReader` into a `RunReader`. Live metadata
|
||||
wins and returns `source: "live"`; only `ErrNotFound` from live metadata permits
|
||||
archive fallback, returning `source: "archive"`. Live storage errors and round
|
||||
lookup errors propagate. Archive and round readers may be nil to disable those
|
||||
lookups. A failure listing always calls `FailurePager` directly, never archives.
|
||||
|
||||
## Routes and responses
|
||||
|
||||
All routes require `Authorization: Bearer <configured token>`. Missing, wrong,
|
||||
or ambiguous credentials return 401 with a `WWW-Authenticate` challenge. An
|
||||
empty configured token is a startup configuration error. Responses use JSON and
|
||||
`Cache-Control: no-store`.
|
||||
|
||||
| Method | Route | Success body |
|
||||
| --- | --- | --- |
|
||||
| GET | `/internal/v1/full-sync-runs?syncKind=&limit=` | 200 `contract.RunList` |
|
||||
| GET | `/internal/v1/full-sync-runs/{runId}` | 200 `contract.RunView` |
|
||||
| GET | `/internal/v1/full-sync-runs/{runId}/failures?cursor=&limit=` | 200 `contract.FailurePage` |
|
||||
| POST | `/internal/v1/full-sync-runs/{runId}/replay` | 202 `contract.ReplayAccepted` |
|
||||
| GET | `/internal/v1/full-sync-actions/{actionId}` | 200 `contract.ReplayAction` |
|
||||
|
||||
Run list results merge active Redis metadata with flat `run-<runId>.json`
|
||||
archives under the configured archive root. Active Redis metadata wins when an
|
||||
ID exists in both sources. `syncKind` accepts `traffic_usage` or `sim_status`;
|
||||
`limit` defaults to 50 and is capped at 100.
|
||||
|
||||
Failure limits use the contract rules: absent, empty, or zero selects 100;
|
||||
positive values up to 500 are accepted; other values are rejected. Repeated
|
||||
cursor/limit parameters and malformed query encoding are rejected. Cursors are
|
||||
opaque and passed unchanged after URL decoding. The pager validates cursor
|
||||
contents and run binding. Empty pages serialize `items: []` and preserve
|
||||
`nextCursor`; only an empty next cursor signals completion.
|
||||
|
||||
Replay requires a single `Idempotency-Key` header and a JSON body such as
|
||||
`{"requestedBy":"operator"}`. The body is limited to 64 KiB, must contain one
|
||||
JSON object with no unknown fields, and must satisfy `ReplayRequest.Validate`.
|
||||
If supplied, Content-Type must be `application/json` (parameters are allowed).
|
||||
The exact `requestedBy` value is preserved for fingerprinting. Repeated accepted
|
||||
requests return the service's existing action/target and current status with
|
||||
202. The adapter constructs `statusUrl` and the `Location` header from actionId.
|
||||
|
||||
All application errors use this envelope, without storage error details:
|
||||
|
||||
```json
|
||||
{"error":{"code":"invalid_argument","message":"Invalid request"}}
|
||||
```
|
||||
|
||||
| Condition | HTTP status | Error code |
|
||||
| --- | --- | --- |
|
||||
| Invalid path, query, header, body, or `ErrInvalidArgument` | 400 | `invalid_argument` |
|
||||
| Missing/wrong bearer credentials | 401 | `unauthorized` |
|
||||
| Unknown route/resource or `ErrNotFound` | 404 | `not_found` |
|
||||
| Unsupported route method | 405 | `method_not_allowed` |
|
||||
| `ErrConflict` (including changed idempotent request), `ErrLeaseLost` | 409 | `conflict` |
|
||||
| `ErrFailureDetailsUnavailable` | 410 | `failure_details_unavailable` |
|
||||
| Oversized replay body | 413 | `body_too_large` |
|
||||
| `ErrBusy` | 503 | `busy` |
|
||||
| Other service errors or unencodable service output | 500 | `internal_error` |
|
||||
|
||||
## Lifecycle
|
||||
|
||||
`NewHandler(token, deps)` can be mounted by an existing HTTP server. For an
|
||||
independent listener, construct `NewServer(Config{Addr: address, BearerToken:
|
||||
token}, deps)` and call `Start(workerCtx)`. `Start` binds synchronously and
|
||||
returns address-in-use and other startup errors directly. `Addr()` exposes the
|
||||
bound address, including an OS-assigned port when configured with port zero.
|
||||
|
||||
Call `Wait()` after Start, normally in the worker's supervision goroutine. It
|
||||
returns serving or shutdown failures; it also returns startup failures if Start
|
||||
failed. The server is single-use and rejects a second Start. Wait can have
|
||||
multiple callers but must not be called before Start.
|
||||
|
||||
Canceling `workerCtx` closes the listener and drains active HTTP requests.
|
||||
Active request contexts stay usable during the drain. `ShutdownTimeout` defaults
|
||||
to ten seconds; if it expires, active connections are closed and Wait returns
|
||||
an error wrapping `context.DeadlineExceeded`. Normal cancellation with a
|
||||
successful drain returns nil. No worker wiring or configuration-file changes
|
||||
are part of this package.
|
||||
|
||||
## Verification
|
||||
|
||||
```text
|
||||
gofmt -l internal/fullsync/httpapi
|
||||
go test ./internal/fullsync/...
|
||||
go test ./internal/fullsync/httpapi -cover
|
||||
go vet ./internal/fullsync/...
|
||||
```
|
||||
|
||||
Tests use contract mocks and local HTTP listeners, requiring no Redis or archive
|
||||
files. `go test -race ./internal/fullsync/...` additionally requires a supported
|
||||
C compiler and CGO enabled.
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user