chore: 并入 iotlcm-bot 历史到 bot/(原提交 31dbc1d)

This commit is contained in:
MiFaZhan
2026-11-02 13:28:00 +08:00
36 changed files with 3006 additions and 0 deletions
+13
View File
@@ -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
+64
View File
@@ -0,0 +1,64 @@
name: mirror-to-gitee
# 把本仓同步到 Gitee 企业仓 snowgoat/<同名仓库>。
# 触发:main 分支推送、v* tag 推送、手动;另有每日兜底重试。
on:
push:
branches:
- main
tags:
- 'v*'
schedule:
# Gitea Actions 的 cron 用 UTC:18:30 UTC = 次日 02:30 北京时间
- cron: '30 18 * * *'
workflow_dispatch:
concurrency:
group: mirror-${{ github.ref }}
cancel-in-progress: true
jobs:
mirror:
runs-on: host
steps:
- name: 同步到 Gitee
env:
SRC_TOKEN: ${{ secrets.SRC_GIT_TOKEN }}
DST_TOKEN: ${{ secrets.GITEE_TOKEN }}
DST_OWNER: ${{ secrets.GITEE_OWNER }}
DST_USER: ${{ secrets.GITEE_USER }}
REPO_NAME: ${{ github.event.repository.name }}
run: |
set -u
if [ -z "${REPO_NAME:-}" ]; then
echo "未取到仓库名,退出"; exit 1
fi
WORK=$(mktemp -d)
trap 'rm -rf "$WORK"' EXIT
echo "源:git.5iot.com/iotlcm-public/$REPO_NAME"
echo "目标:gitee.com/$DST_OWNER/$REPO_NAME"
git clone --mirror "https://jenkins:${SRC_TOKEN}@git.5iot.com/iotlcm-public/${REPO_NAME}.git" "$WORK/repo.git"
cd "$WORK/repo.git"
echo "本地 ref:$(git for-each-ref --format='%(refname:short)' | tr '\n' ' ')"
# Gitee 在推送密集时会返回 429 Pending queue overflowed / 408 Authorization server timeout,
# 属服务端限流,退避重试即可。
attempt=0
while [ "$attempt" -lt 5 ]; do
attempt=$((attempt + 1))
echo "----- 推送尝试 $attempt/5"
if git push --mirror "https://${DST_USER}:${DST_TOKEN}@gitee.com/${DST_OWNER}/${REPO_NAME}.git" 2>&1; then
echo "✅ 已同步到 Gitee"
exit 0
fi
wait_s=$((attempt * 30))
echo "推送失败,${wait_s}s 后重试"
sleep "$wait_s"
done
echo "❌ 连续 5 次推送失败,请检查 Gitee 状态或令牌"
exit 1
+80
View File
@@ -0,0 +1,80 @@
name: trigger-jenkins-oss
# 打 tag 时触发 Jenkins 构建开源镜像并推送到镜像仓库。
# 仅在 tag 推送时触发;main 分支的日常提交不触发。
on:
push:
tags:
- 'v*'
workflow_dispatch:
# 同一 ref 连续触发时取消旧任务,只跑最新的
concurrency:
group: ${{ github.ref }}
cancel-in-progress: true
jobs:
trigger:
runs-on: host
steps:
- name: 触发 Jenkins 镜像构建
env:
JU: ${{ secrets.JENKINS_USER }}
JT: ${{ secrets.JENKINS_API_TOKEN }}
JURL: ${{ secrets.JENKINS_URL }}
JOB_PATH: ${{ secrets.JENKINS_JOB_PATH }}
REPO_NAME: ${{ github.event.repository.name }}
TAG_NAME: ${{ github.ref_name }}
run: |
set -eu
# 只有 tag 推送才构建;手动触发时按 tag 取,没有就退出
case "${GITHUB_REF:-}" in
refs/tags/v*) ;;
*)
if [ "${GITHUB_EVENT_NAME:-}" != "workflow_dispatch" ]; then
echo "非 tag 推送,跳过:${GITHUB_REF:-unknown}"
exit 0
fi
;;
esac
if [ -z "${TAG_NAME:-}" ]; then
echo "未取到 tag 名,退出"
exit 1
fi
# job 在 Jenkins 文件夹下的路径(组织密钥 JENKINS_JOB_PATH),
# 例如 job/iotlcm-opensrc/job/iotlcm-oss-build。
# 用密钥而非硬编码,Jenkins 里移动 job 时不用改 11 个仓。
JP="${JOB_PATH:-job/iotlcm-oss-build}"
echo "仓库:$REPO_NAME 版本:$TAG_NAME job:$JP"
WORK=$(mktemp -d)
trap 'rm -rf "$WORK"' EXIT
# 取 CSRF crumb
curl -s -c "$WORK/cj" -u "$JU:$JT" "$JURL/crumbIssuer/api/json" -o "$WORK/crumb.json"
CR=$(python3 -c "import json;print(json.load(open('$WORK/crumb.json'))['crumb'])")
# 触发参数化任务:SERVICES=仓库名,VERSION=tag
code=$(curl -s -o "$WORK/out" -w '%{http_code}' -X POST -b "$WORK/cj" -u "$JU:$JT" \
-H "Jenkins-Crumb: $CR" \
--data-urlencode "SERVICES=$REPO_NAME" \
--data-urlencode "VERSION=$TAG_NAME" \
--data-urlencode "FORCE=true" \
"$JURL/$JP/buildWithParameters")
echo "Jenkins 触发 HTTP $code"
if [ "$code" != "201" ] && [ "$code" != "200" ] && [ "$code" != "302" ] && [ "$code" != "303" ]; then
echo "触发失败:"
cat "$WORK/out"
exit 1
fi
LINK="$JURL/$JP/"
echo "=========================================="
echo "Jenkins 构建:$LINK"
echo "=========================================="
echo "::notice title=Jenkins 镜像构建::[$LINK]($LINK)"
+12
View File
@@ -0,0 +1,12 @@
.env
.env.*
!.env.example
bin/
*.exe
coverage.out
/state/
*.log
# Deployment secrets must never be checked into the public repository.
config.yaml
config.*.yaml
!config.example.yaml
+17
View File
@@ -0,0 +1,17 @@
ARG BASE_REGISTRY=docker.io/library
FROM ${BASE_REGISTRY}/golang:1.25-alpine AS build
ARG GOPROXY=https://goproxy.cn|https://goproxy.io|https://proxy.golang.org|direct
ENV GOPROXY=${GOPROXY}
WORKDIR /src
COPY go.mod go.sum ./
RUN apk add --no-cache ca-certificates
RUN go mod download
COPY . ./
RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags='-s -w' -o /out/iotlcm-bot ./cmd/iotlcm-bot
FROM scratch
COPY --from=build /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ca-certificates.crt
COPY --from=build /out/iotlcm-bot /usr/local/bin/iotlcm-bot
USER 65532:65532
ENTRYPOINT ["/usr/local/bin/iotlcm-bot"]
+201
View File
@@ -0,0 +1,201 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright [yyyy] [name of copyright owner]
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
+90
View File
@@ -0,0 +1,90 @@
# iotlcm-bot
V1 独立飞书应用机器人:读取 Worker 终态 Run JSON,渲染并发送全量同步结果卡片。
## V1 范围
- 只接受当前版本 `version=1` 且包含 `run` 和非空 `rounds` 的归档 JSON。
- 不兼容旧版 `rounds=[]` 文件。
- 不读取 Redis、Worker HTTP 接口或日志。
- 不实现 Replay、事件回调、卡片按钮和错误聚合。
- 按 `runId` 去重,避免重复发送。
## 本地验证
```bash
go test ./...
go run ./cmd/iotlcm-bot render --file testdata/current-run.json
go run ./cmd/iotlcm-bot render --file testdata/current-run.json --json
```
`render` 只渲染通知内容,不调用飞书 API。
## 本地单次发送
机器人加入测试群,且 YAML 配置已填写 `feishu.appSecret` 和 `feishu.chatId` 后,可以发送指定 Run:
```bash
go run ./cmd/iotlcm-bot send --config /path/to/iotlcm-bot.yaml --file /path/to/run.json
```
发送成功后会写入 `state/sent/<runId>.json`;同一 Run 再次执行会跳过。确需重发时使用:
```bash
go run ./cmd/iotlcm-bot send --config /path/to/iotlcm-bot.yaml --file /path/to/run.json --force
```
正常运行时不要使用 `--force`。
## 已部署容器的发送测试
只验证飞书连接时,无需提供 Run JSON。Bot 容器运行后,发送一条自定义文本:
```bash
docker exec iotlcm-bot /usr/local/bin/iotlcm-bot send-text --config /app/config/config.yaml --text "iotlcm-bot 飞书连接测试"
```
`send-text` 不扫描归档、不写 Run 发送标记,可以重复执行。它直接通过飞书消息 API 向配置文件中的群发送文本,不是 HTTP 测试接口。
需要测试完整的 Run 卡片链路时,才使用下面的单次发送命令:
```bash
docker exec iotlcm-bot /usr/local/bin/iotlcm-bot send --config /app/config/config.yaml --file /data/full-sync/run-<runId>.json
```
如该 Run 已发送,明确需要再次发送时加 `--force`:
```bash
docker exec iotlcm-bot /usr/local/bin/iotlcm-bot send --config /app/config/config.yaml --file /data/full-sync/run-<runId>.json --force
```
发送成功会写入 `/data/state/sent/<runId>.json`。检查容器和日志:
```bash
docker ps --filter name=iotlcm-bot
docker logs --tail 100 iotlcm-bot
```
## 配置文件
复制 `config.example.yaml` 为未跟踪的 `config.yaml`,填入实际飞书应用凭据和群 ID。
配置加载器直接读取 YAML,不展开环境变量;请勿把真实凭据提交到仓库。
部署时通过只读挂载将配置注入容器,容器内路径可为 `/app/config/config.yaml`。
## 自动通知
Compose 默认以 `watch` 模式启动 Bot。Bot 每 10 秒扫描挂载的终态 Run JSON,校验通过且未发送过的 Run 会自动发到配置的飞书群。
本地启动方式:
```bash
go run ./cmd/iotlcm-bot watch --config /path/to/iotlcm-bot.yaml
```
## 目录挂载
```text
Worker 宿主归档目录:<host-worker-full-sync-dir>
Bot 只读挂载目录:/data/full-sync
Bot 可写状态目录:<host-bot-state-dir> -> /data/state
```
+137
View File
@@ -0,0 +1,137 @@
package main
import (
"context"
"flag"
"fmt"
"os"
"os/signal"
"syscall"
"git.5iot.com/iotlcm-public/iotlcm-bot/internal/archive"
"git.5iot.com/iotlcm-public/iotlcm-bot/internal/config"
"git.5iot.com/iotlcm-public/iotlcm-bot/internal/feishu"
"git.5iot.com/iotlcm-public/iotlcm-bot/internal/notify"
"git.5iot.com/iotlcm-public/iotlcm-bot/internal/render"
"git.5iot.com/iotlcm-public/iotlcm-bot/internal/state"
)
func main() {
if len(os.Args) < 2 {
usage()
}
switch os.Args[1] {
case "render":
renderCommand(os.Args[2:])
case "send":
sendCommand(os.Args[2:])
case "send-text":
sendTextCommand(os.Args[2:])
case "watch":
watchCommand(os.Args[2:])
default:
usage()
}
}
func renderCommand(args []string) {
fs := flag.NewFlagSet("render", flag.ExitOnError)
file := fs.String("file", "", "path to a Worker Run JSON")
environment := fs.String("environment", "preview", "environment label")
jsonOutput := fs.Bool("json", false, "print Feishu interactive-card JSON")
fs.Parse(args)
if *file == "" {
fmt.Fprintln(os.Stderr, "render requires --file")
os.Exit(2)
}
a, err := archive.Load(*file)
if err != nil {
fail(err)
}
if *jsonOutput {
body, err := render.Card(a, *environment)
if err != nil {
fail(err)
}
fmt.Println(string(body))
return
}
fmt.Println(render.Text(a, *environment))
}
func sendCommand(args []string) {
fs := flag.NewFlagSet("send", flag.ExitOnError)
file := fs.String("file", "", "path to a Worker Run JSON")
configPath := fs.String("config", "", "path to YAML config")
force := fs.Bool("force", false, "send even when the Run was already marked sent")
fs.Parse(args)
if *file == "" || *configPath == "" {
fmt.Fprintln(os.Stderr, "send requires --file and --config")
os.Exit(2)
}
cfg, err := config.Load(*configPath)
if err != nil {
fail(err)
}
if err := cfg.ValidateSend(); err != nil {
fail(err)
}
client := feishu.NewClient(cfg.Feishu.AppID, cfg.Feishu.AppSecret, cfg.Feishu.ChatID)
svc := notify.New(cfg.ArchiveDir, cfg.Environment, cfg.PollDuration, state.New(cfg.StateDir), client, notify.DefaultLogger())
if err := svc.SendFile(context.Background(), *file, *force); err != nil {
fail(err)
}
}
func sendTextCommand(args []string) {
fs := flag.NewFlagSet("send-text", flag.ExitOnError)
configPath := fs.String("config", "", "path to YAML config")
text := fs.String("text", "", "text to send")
fs.Parse(args)
if *configPath == "" || *text == "" {
fmt.Fprintln(os.Stderr, "send-text requires --config and --text")
os.Exit(2)
}
cfg, err := config.Load(*configPath)
if err != nil {
fail(err)
}
if err := cfg.ValidateSend(); err != nil {
fail(err)
}
client := feishu.NewClient(cfg.Feishu.AppID, cfg.Feishu.AppSecret, cfg.Feishu.ChatID)
if err := client.SendText(context.Background(), *text); err != nil {
fail(err)
}
fmt.Println("Feishu text message sent")
}
func watchCommand(args []string) {
fs := flag.NewFlagSet("watch", flag.ExitOnError)
configPath := fs.String("config", "", "path to YAML config")
fs.Parse(args)
if *configPath == "" {
fmt.Fprintln(os.Stderr, "watch requires --config")
os.Exit(2)
}
cfg, err := config.Load(*configPath)
if err != nil {
fail(err)
}
if err := cfg.ValidateSend(); err != nil {
fail(err)
}
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
client := feishu.NewClient(cfg.Feishu.AppID, cfg.Feishu.AppSecret, cfg.Feishu.ChatID)
svc := notify.New(cfg.ArchiveDir, cfg.Environment, cfg.PollDuration, state.New(cfg.StateDir), client, notify.DefaultLogger())
if err := svc.Watch(ctx); err != nil && err != context.Canceled {
fail(err)
}
}
func usage() {
fmt.Fprintln(os.Stderr, "usage: iotlcm-bot <render|send|send-text|watch> [options]")
os.Exit(2)
}
func fail(err error) { fmt.Fprintln(os.Stderr, "iotlcm-bot:", err); os.Exit(1) }
+10
View File
@@ -0,0 +1,10 @@
# Copy to config.yaml (untracked) and replace these placeholders before sending.
# The bot reads YAML literally; it does not expand environment variables.
environment: preview
archiveDir: /data/full-sync
stateDir: ./state
pollInterval: 10s
feishu:
appId: "YOUR_FEISHU_APP_ID"
appSecret: "YOUR_FEISHU_APP_SECRET"
chatId: "YOUR_FEISHU_CHAT_ID"
+5
View File
@@ -0,0 +1,5 @@
module git.5iot.com/iotlcm-public/iotlcm-bot
go 1.23
require gopkg.in/yaml.v3 v3.0.1
+4
View File
@@ -0,0 +1,4 @@
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+159
View File
@@ -0,0 +1,159 @@
package archive
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"sort"
"time"
)
type RunArchive struct {
Version int `json:"version"`
Run RunSummary `json:"run"`
Rounds []RoundStats `json:"rounds"`
}
type RunSummary struct {
SupplyObservation json.RawMessage `json:"supplyObservation,omitempty"`
SyncType string `json:"syncType,omitempty"`
RunID string `json:"runId"`
Operation string `json:"operation"`
Status string `json:"status"`
SuccessCount int64 `json:"successCount"`
FailedCount int64 `json:"failedCount"`
CreatedAt string `json:"createdAt"`
CompletedAt string `json:"completedAt"`
}
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 string `json:"startedAt"`
CompletedAt string `json:"completedAt"`
ErrorCounts map[string]int `json:"errorCounts"`
}
func Load(path string) (RunArchive, error) {
body, err := os.ReadFile(path)
if err != nil {
return RunArchive{}, err
}
var a RunArchive
if err := json.Unmarshal(body, &a); err != nil {
return RunArchive{}, fmt.Errorf("parse %s: %w", path, err)
}
if err := a.Validate(); err != nil {
return RunArchive{}, fmt.Errorf("validate %s: %w", path, err)
}
if err := ValidateFilename(path, a.Run.RunID); err != nil {
return RunArchive{}, err
}
return a, nil
}
func ValidateFilename(path, runID string) error {
want := "run-" + runID + ".json"
if filepath.Base(path) != want {
return fmt.Errorf("archive filename %q does not match RunID; expected %q", filepath.Base(path), want)
}
return nil
}
func (a *RunArchive) Validate() error {
if a.Version != 1 {
return fmt.Errorf("unsupported version %d", a.Version)
}
if a.Run.RunID == "" || a.Run.Operation == "" {
return fmt.Errorf("runId and operation are required")
}
if a.Run.Status != "completed" && a.Run.Status != "completed_with_failures" {
return fmt.Errorf("run is not terminal: %s", a.Run.Status)
}
if len(a.Rounds) == 0 {
return fmt.Errorf("rounds must be non-empty")
}
copyRounds := append([]RoundStats(nil), a.Rounds...)
sort.Slice(copyRounds, func(i, j int) bool { return copyRounds[i].Round < copyRounds[j].Round })
var success, failed int64
for i, r := range copyRounds {
if r.Round != i+1 {
return fmt.Errorf("round sequence is not contiguous at %d", r.Round)
}
if r.RunID != "" && r.RunID != a.Run.RunID {
return fmt.Errorf("round %d runId does not match root runId", r.Round)
}
if r.EnqueuedCount < 0 || r.ActiveCount < 0 || r.NextRoundCount < 0 || r.SuccessCount < 0 || r.FailedCount < 0 || r.UpstreamAttemptCount < 0 || r.BatchCount < 0 {
return fmt.Errorf("round %d contains negative count", r.Round)
}
if r.EnqueuedCount != r.SuccessCount+r.FailedCount+r.NextRoundCount {
return fmt.Errorf("round %d count does not close", r.Round)
}
if _, err := parseTime(r.StartedAt); err != nil {
return fmt.Errorf("round %d invalid startedAt: %w", r.Round, err)
}
if _, err := parseTime(r.CompletedAt); err != nil {
return fmt.Errorf("round %d invalid completedAt: %w", r.Round, err)
}
success += r.SuccessCount
failed += r.FailedCount
}
if a.Run.SuccessCount != success || a.Run.FailedCount != failed {
return fmt.Errorf("run totals do not match rounds: run success=%d failed=%d, rounds success=%d failed=%d", a.Run.SuccessCount, a.Run.FailedCount, success, failed)
}
if _, err := parseTime(a.Run.CreatedAt); err != nil {
return fmt.Errorf("invalid createdAt: %w", err)
}
if _, err := parseTime(a.Run.CompletedAt); err != nil {
return fmt.Errorf("invalid completedAt: %w", err)
}
return nil
}
func (a RunArchive) TotalCount() int64 { return a.Run.SuccessCount + a.Run.FailedCount }
func (a RunArchive) SuccessRate() float64 {
if a.TotalCount() == 0 {
return 0
}
return float64(a.Run.SuccessCount) / float64(a.TotalCount()) * 100
}
func (a RunArchive) Duration() time.Duration {
start, err1 := parseTime(a.Run.CreatedAt)
end, err2 := parseTime(a.Run.CompletedAt)
if err1 != nil || err2 != nil {
return 0
}
return end.Sub(start)
}
func (r RoundStats) SuccessRate() float64 {
if r.EnqueuedCount == 0 {
return 0
}
return float64(r.SuccessCount) / float64(r.EnqueuedCount) * 100
}
func (r RoundStats) Duration() time.Duration {
start, err1 := parseTime(r.StartedAt)
end, err2 := parseTime(r.CompletedAt)
if err1 != nil || err2 != nil {
return 0
}
return end.Sub(start)
}
func parseTime(value string) (time.Time, error) {
for _, layout := range []string{time.RFC3339Nano, "2006-01-02 15:04:05.999", "2006-01-02 15:04:05"} {
if t, err := time.Parse(layout, value); err == nil {
return t, nil
}
}
return time.Time{}, fmt.Errorf("unsupported time %q", value)
}
+48
View File
@@ -0,0 +1,48 @@
package archive
import "testing"
func validArchive() RunArchive {
return RunArchive{Version: 1, Run: RunSummary{RunID: "run-1", Operation: "sim_status_queue_full_sync", Status: "completed_with_failures", SuccessCount: 175893, FailedCount: 10504, CreatedAt: "2026-09-22T11:42:33Z", CompletedAt: "2026-09-22T12:32:01Z"}, Rounds: []RoundStats{
{RunID: "run-1", Round: 1, EnqueuedCount: 186397, ActiveCount: 0, NextRoundCount: 9023, SuccessCount: 175870, FailedCount: 1504, UpstreamAttemptCount: 0, BatchCount: 1872, StartedAt: "2026-09-22T11:42:34Z", CompletedAt: "2026-09-22T12:21:49Z"},
{RunID: "run-1", Round: 2, EnqueuedCount: 9023, ActiveCount: 0, NextRoundCount: 9000, SuccessCount: 23, FailedCount: 0, UpstreamAttemptCount: 0, BatchCount: 92, StartedAt: "2026-09-22T12:21:51Z", CompletedAt: "2026-09-22T12:26:55Z"},
{RunID: "run-1", Round: 3, EnqueuedCount: 9000, ActiveCount: 0, NextRoundCount: 0, SuccessCount: 0, FailedCount: 9000, UpstreamAttemptCount: 0, BatchCount: 90, StartedAt: "2026-09-22T12:26:56Z", CompletedAt: "2026-09-22T12:32:01Z"},
}}
}
func TestValidateThreeRounds(t *testing.T) {
a := validArchive()
if err := a.Validate(); err != nil {
t.Fatal(err)
}
if len(a.Rounds) != 3 || a.Run.FailedCount != 10504 {
t.Fatalf("unexpected archive: %+v", a)
}
}
func TestValidateRejectsEmptyRounds(t *testing.T) {
a := RunArchive{Version: 1, Run: RunSummary{RunID: "run-1", Operation: "sim_status_queue_full_sync", Status: "completed"}}
if err := a.Validate(); err == nil {
t.Fatal("expected empty rounds to be rejected")
}
}
func TestValidateRejectsUnclosedRound(t *testing.T) {
a := validArchive()
a.Rounds[0].NextRoundCount++
if err := a.Validate(); err == nil {
t.Fatal("expected count closure error")
}
}
func TestValidateFilenameRequiresExactRunID(t *testing.T) {
if err := ValidateFilename(`/archive/run-full-run-id.json`, "full-run-id"); err != nil {
t.Fatal(err)
}
if err := ValidateFilename(`/archive/run-other-id.json`, "full-run-id"); err == nil {
t.Fatal("expected mismatched filename to be rejected")
}
if err := ValidateFilename(`/archive/action-full-run-id.json`, "full-run-id"); err == nil {
t.Fatal("expected non-run filename to be rejected")
}
}
+47
View File
@@ -0,0 +1,47 @@
package archive
import "encoding/json"
type SupplyWait struct {
Count int64 `json:"count"`
TotalMS int64 `json:"totalMs"`
LongestMS int64 `json:"longestMs"`
}
type SupplyChannel struct {
Key string `json:"chanKey"`
Startup SupplyWait `json:"startupWait"`
Confirmed SupplyWait `json:"confirmedWait"`
Unconfirmed SupplyWait `json:"unconfirmedIdle"`
UnknownMS int64 `json:"unknownMs"`
Stages map[string]int64 `json:"producerStageMs"`
}
type SupplySummary struct {
UnknownMS int64 `json:"unknownChannelMs"`
UnconfirmedMS int64 `json:"unconfirmedChannelMs"`
ConsumerMS int64 `json:"consumerStartupChannelMs"`
Targets int `json:"targetChannels"`
Valid int `json:"validChannels"`
Confirmed int `json:"confirmedChannels"`
Unknown int `json:"unknownChannels"`
TotalMS int64 `json:"confirmedChannelMs"`
LongestMS int64 `json:"longestMs"`
}
type Supply struct {
SchemaVersion int `json:"schemaVersion"`
ResolutionMS int `json:"resolutionMs"`
Partial bool `json:"partial"`
Truncated bool `json:"truncated"`
Summary SupplySummary `json:"summary"`
Channels []SupplyChannel `json:"channels"`
}
func (r RunSummary) Supply() (*Supply, bool) {
if len(r.SupplyObservation) == 0 || string(r.SupplyObservation) == "null" {
return nil, false
}
var s Supply
if json.Unmarshal(r.SupplyObservation, &s) != nil || s.SchemaVersion != 1 || s.ResolutionMS != 1000 || s.Summary.Targets < 0 || s.Summary.Valid < 0 || s.Summary.Valid > s.Summary.Targets || s.Summary.TotalMS < 0 || s.Summary.LongestMS < 0 {
return nil, true
}
return &s, true
}
+63
View File
@@ -0,0 +1,63 @@
package config
import (
"fmt"
"os"
"time"
"gopkg.in/yaml.v3"
)
type Config struct {
Environment string `yaml:"environment"`
ArchiveDir string `yaml:"archiveDir"`
StateDir string `yaml:"stateDir"`
PollInterval string `yaml:"pollInterval"`
Feishu FeishuConfig `yaml:"feishu"`
PollDuration time.Duration `yaml:"-"`
}
type FeishuConfig struct {
AppID string `yaml:"appId"`
AppSecret string `yaml:"appSecret"`
ChatID string `yaml:"chatId"`
}
func Load(path string) (Config, error) {
body, err := os.ReadFile(path)
if err != nil {
return Config{}, fmt.Errorf("read config %s: %w", path, err)
}
var cfg Config
if err := yaml.Unmarshal(body, &cfg); err != nil {
return Config{}, fmt.Errorf("parse config %s: %w", path, err)
}
if cfg.Environment == "" {
cfg.Environment = "preview"
}
if cfg.ArchiveDir == "" {
cfg.ArchiveDir = "/data/full-sync"
}
if cfg.StateDir == "" {
cfg.StateDir = "/data/state"
}
if cfg.PollInterval == "" {
cfg.PollInterval = "10s"
}
cfg.PollDuration, err = time.ParseDuration(cfg.PollInterval)
if err != nil || cfg.PollDuration <= 0 {
return Config{}, fmt.Errorf("invalid pollInterval %q", cfg.PollInterval)
}
return cfg, nil
}
func (c Config) ValidateSend() error {
if c.Feishu.AppID == "" || c.Feishu.AppSecret == "" {
return fmt.Errorf("feishu.appId and feishu.appSecret are required")
}
if c.Feishu.ChatID == "" {
return fmt.Errorf("feishu.chatId is required")
}
return nil
}
+32
View File
@@ -0,0 +1,32 @@
package config
import (
"os"
"path/filepath"
"testing"
"time"
)
func TestLoadYAML(t *testing.T) {
path := filepath.Join(t.TempDir(), "config.yaml")
body := []byte("environment: preview\narchiveDir: /data/full-sync\nstateDir: /data/state\npollInterval: 5s\nfeishu:\n appId: app-id\n appSecret: secret\n chatId: chat-id\n")
if err := os.WriteFile(path, body, 0o600); err != nil {
t.Fatal(err)
}
cfg, err := Load(path)
if err != nil {
t.Fatal(err)
}
if cfg.PollDuration != 5*time.Second || cfg.Feishu.AppID != "app-id" || cfg.ArchiveDir != "/data/full-sync" {
t.Fatalf("unexpected config: %+v", cfg)
}
if err := cfg.ValidateSend(); err != nil {
t.Fatal(err)
}
}
func TestValidateSendRejectsMissingCredentials(t *testing.T) {
if err := (Config{}).ValidateSend(); err == nil {
t.Fatal("expected validation error")
}
}
+155
View File
@@ -0,0 +1,155 @@
package feishu
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"sync"
"time"
)
type Client struct {
appID string
appSecret string
chatID string
http *http.Client
baseURL string
mu sync.Mutex
token string
tokenExp time.Time
}
func NewClient(appID, appSecret, chatID string) *Client {
return &Client{appID: appID, appSecret: appSecret, chatID: chatID, http: &http.Client{Timeout: 10 * time.Second}, baseURL: "https://open.feishu.cn"}
}
func (c *Client) SendText(ctx context.Context, text string) error {
if c.chatID == "" {
return fmt.Errorf("feishu chatId is required")
}
if text == "" {
return fmt.Errorf("text is required")
}
token, err := c.accessToken(ctx, false)
if err != nil {
return err
}
if err := c.sendTextWithToken(ctx, token, text); err != nil && err.Error() == "feishu authentication failed" {
token, err = c.accessToken(ctx, true)
if err != nil {
return err
}
return c.sendTextWithToken(ctx, token, text)
}
return err
}
func (c *Client) Send(ctx context.Context, card []byte) error {
if c.chatID == "" {
return fmt.Errorf("feishu chatId is required")
}
token, err := c.accessToken(ctx, false)
if err != nil {
return err
}
if err := c.sendWithToken(ctx, token, card); err != nil && err.Error() == "feishu authentication failed" {
token, err = c.accessToken(ctx, true)
if err != nil {
return err
}
return c.sendWithToken(ctx, token, card)
} else {
return err
}
}
func (c *Client) accessToken(ctx context.Context, force bool) (string, error) {
c.mu.Lock()
defer c.mu.Unlock()
if !force && c.token != "" && time.Until(c.tokenExp) > time.Minute {
return c.token, nil
}
body, _ := json.Marshal(map[string]string{"app_id": c.appID, "app_secret": c.appSecret})
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+"/open-apis/auth/v3/tenant_access_token/internal", bytes.NewReader(body))
if err != nil {
return "", err
}
req.Header.Set("Content-Type", "application/json; charset=utf-8")
resp, err := c.http.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
var result struct {
Code int `json:"code"`
Msg string `json:"msg"`
TenantAccessToken string `json:"tenant_access_token"`
Expire int `json:"expire"`
}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return "", err
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 || result.Code != 0 || result.TenantAccessToken == "" {
return "", fmt.Errorf("feishu token request failed: http=%d code=%d msg=%s", resp.StatusCode, result.Code, result.Msg)
}
c.token, c.tokenExp = result.TenantAccessToken, time.Now().Add(time.Duration(result.Expire)*time.Second)
return c.token, nil
}
func (c *Client) sendTextWithToken(ctx context.Context, token, text string) error {
payload, _ := json.Marshal(map[string]string{"receive_id": c.chatID, "msg_type": "text", "content": fmt.Sprintf(`{"text":%q}`, text)})
return c.postMessage(ctx, token, payload)
}
func (c *Client) sendWithToken(ctx context.Context, token string, card []byte) error {
var content json.RawMessage
if err := json.Unmarshal(card, &content); err != nil {
return fmt.Errorf("invalid card: %w", err)
}
var outer map[string]any
if err := json.Unmarshal(content, &outer); err != nil {
return err
}
msgType, ok := outer["msg_type"].(string)
if !ok {
return fmt.Errorf("card msg_type is required")
}
cardBody, ok := outer["card"]
if !ok {
return fmt.Errorf("card body is required")
}
cardJSON, _ := json.Marshal(cardBody)
payload, _ := json.Marshal(map[string]any{"receive_id": c.chatID, "msg_type": msgType, "content": string(cardJSON)})
return c.postMessage(ctx, token, payload)
}
func (c *Client) postMessage(ctx context.Context, token string, payload []byte) error {
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+"/open-apis/im/v1/messages?receive_id_type="+url.QueryEscape("chat_id"), bytes.NewReader(payload))
if err != nil {
return err
}
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json; charset=utf-8")
resp, err := c.http.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
data, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
var result struct {
Code int `json:"code"`
Msg string `json:"msg"`
}
_ = json.Unmarshal(data, &result)
if resp.StatusCode == http.StatusUnauthorized {
return fmt.Errorf("feishu authentication failed")
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 || result.Code != 0 {
return fmt.Errorf("feishu send failed: http=%d code=%d msg=%s", resp.StatusCode, result.Code, result.Msg)
}
return nil
}
+62
View File
@@ -0,0 +1,62 @@
package feishu
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
func TestSendText(t *testing.T) {
var gotText string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/open-apis/auth/v3/tenant_access_token/internal":
_, _ = w.Write([]byte(`{"code":0,"tenant_access_token":"test-token","expire":7200}`))
case "/open-apis/im/v1/messages":
if r.URL.Query().Get("receive_id_type") != "chat_id" || r.Header.Get("Authorization") != "Bearer test-token" {
t.Errorf("unexpected request: %s %s", r.URL.String(), r.Header.Get("Authorization"))
}
var payload struct {
ReceiveID string `json:"receive_id"`
MsgType string `json:"msg_type"`
Content string `json:"content"`
}
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
t.Error(err)
}
if payload.ReceiveID != "test-chat" || payload.MsgType != "text" {
t.Errorf("unexpected payload: %+v", payload)
}
var content struct {
Text string `json:"text"`
}
if err := json.Unmarshal([]byte(payload.Content), &content); err != nil {
t.Error(err)
}
gotText = content.Text
_, _ = w.Write([]byte(`{"code":0,"msg":"success"}`))
default:
http.NotFound(w, r)
}
}))
defer server.Close()
client := NewClient("test-app", "test-secret", "test-chat")
client.baseURL = server.URL
message := "连接测试:含引号 \" 和换行\n第二行"
if err := client.SendText(context.Background(), message); err != nil {
t.Fatal(err)
}
if gotText != message {
t.Fatalf("text mismatch: %q", gotText)
}
}
func TestSendTextRejectsEmpty(t *testing.T) {
client := NewClient("test-app", "test-secret", "test-chat")
if err := client.SendText(context.Background(), ""); err == nil || !strings.Contains(err.Error(), "text is required") {
t.Fatalf("unexpected error: %v", err)
}
}
+70
View File
@@ -0,0 +1,70 @@
package notify
import (
"encoding/json"
"os"
"path/filepath"
"time"
)
// alertState 记录告警节流信息。
//
// 为什么必须落盘:进程检测到挂载失效后会主动退出、由 restart 策略拉起。
// 若节流时间只存在内存里,重启后 lastAlert 归零,会立刻再次告警 ——
// 持续故障就变成「每轮重启都发一条飞书」,把真正的告警淹成骚扰。
type alertState struct {
// LastDegradedAlert 是上次发送「归档目录不可用」告警的时间。
LastDegradedAlert time.Time `json:"lastDegradedAlert,omitempty"`
// LastDegradedReason 用于在故障原因变化时立即重新告警。
LastDegradedReason string `json:"lastDegradedReason,omitempty"`
// ExitCount 统计因挂载失效而退出的累计次数,用于识别「反复重启」。
ExitCount int `json:"exitCount,omitempty"`
}
func alertStatePath(stateDir string) string {
return filepath.Join(stateDir, "alert-state.json")
}
func loadAlertState(stateDir string) alertState {
var st alertState
if stateDir == "" {
return st
}
body, err := os.ReadFile(alertStatePath(stateDir))
if err != nil {
return st
}
_ = json.Unmarshal(body, &st)
return st
}
func saveAlertState(stateDir string, st alertState) error {
if stateDir == "" {
return nil
}
path := alertStatePath(stateDir)
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
return err
}
body, err := json.MarshalIndent(st, "", " ")
if err != nil {
return err
}
tmp := path + ".tmp"
if err := os.WriteFile(tmp, body, 0o600); err != nil {
return err
}
return os.Rename(tmp, path)
}
// shouldAlert 判断是否该发告警:间隔到了,或故障原因发生变化。
func (st alertState) shouldAlert(reason string, now time.Time, interval time.Duration) bool {
if st.LastDegradedAlert.IsZero() {
return true
}
if reason != st.LastDegradedReason {
// 原因变了(例如从「目录不存在」变成「不可读」)值得立刻提醒。
return true
}
return now.Sub(st.LastDegradedAlert) >= interval
}
+107
View File
@@ -0,0 +1,107 @@
package notify
import (
"context"
"os"
"path/filepath"
"testing"
"time"
)
func TestAlertStateSurvivesRestart(t *testing.T) {
dir := t.TempDir()
now := time.Now()
// 首次:没有任何记录 → 应该告警
first := loadAlertState(dir)
if !first.shouldAlert("dir gone", now, DegradedAlertInterval) {
t.Fatal("first alert must be sent")
}
first.LastDegradedAlert = now
first.LastDegradedReason = "dir gone"
if err := saveAlertState(dir, first); err != nil {
t.Fatal(err)
}
// 模拟进程退出后重启:重新从磁盘加载
reloaded := loadAlertState(dir)
if reloaded.shouldAlert("dir gone", now.Add(time.Second), DegradedAlertInterval) {
t.Fatal("after restart the same failure must NOT alert again immediately (this is the alert-flood bug)")
}
// 间隔到了才允许再报
if !reloaded.shouldAlert("dir gone", now.Add(DegradedAlertInterval+time.Second), DegradedAlertInterval) {
t.Fatal("alert must be sent again after the interval elapses")
}
// 故障原因变化时立即告警
if !reloaded.shouldAlert("permission denied", now.Add(time.Second), DegradedAlertInterval) {
t.Fatal("a changed failure reason must alert immediately")
}
}
func TestExitCountIsPersistedAndIncremented(t *testing.T) {
dir := t.TempDir()
st := loadAlertState(dir)
if st.ExitCount != 0 {
t.Fatalf("fresh state exitCount=%d want 0", st.ExitCount)
}
st.ExitCount++
if err := saveAlertState(dir, st); err != nil {
t.Fatal(err)
}
// 重启后计数必须延续,否则无法识别「反复重启」
if got := loadAlertState(dir).ExitCount; got != 1 {
t.Fatalf("exitCount after reload=%d want 1", got)
}
}
// 关键回归:挂载失效 → 退出 → 重启后,同一次故障只能推一条飞书。
func TestWatchAlertsOnceAcrossRestarts(t *testing.T) {
shortenExitDelay(t)
if !ExitOnDegraded {
t.Skip("ExitOnDegraded disabled")
}
stateDir := t.TempDir()
archiveDir := filepath.Join(t.TempDir(), "gone")
fake := &fakeFeishu{}
newSvc := func() Service {
return Service{
archiveDir: archiveDir,
environment: "test",
stateDir: stateDir,
pollInterval: 10 * time.Millisecond,
store: newTestStore(stateDir),
client: fake,
logger: discardLogger(),
}
}
// 第一次运行:应告警并退出
if err := newSvc().Watch(context.Background()); err == nil {
t.Fatal("Watch must exit when the mount is degraded")
}
if fake.sent != 1 {
t.Fatalf("first run must send exactly one alert, sent=%d", fake.sent)
}
// 模拟被 restart 策略拉起:新进程、新内存,但 state 目录相同
if err := newSvc().Watch(context.Background()); err == nil {
t.Fatal("Watch must exit again")
}
if fake.sent != 1 {
t.Fatalf("restart must NOT resend the same alert (alert flood), sent=%d", fake.sent)
}
// 退出计数应累计到 2
if got := loadAlertState(stateDir).ExitCount; got != 2 {
t.Fatalf("exitCount=%d want 2", got)
}
}
func TestAlertStateMissingDirIsNotFatal(t *testing.T) {
// 状态目录不可写时不应影响告警逻辑(退化为每次都报,但至少不崩)
st := loadAlertState("")
if !st.shouldAlert("x", time.Now(), DegradedAlertInterval) {
t.Fatal("empty state must allow alerting")
}
if err := saveAlertState("", st); err != nil {
t.Fatalf("saving to empty state dir must be a no-op, got %v", err)
}
_ = os.Remove("nonexistent")
}
+118
View File
@@ -0,0 +1,118 @@
package notify
import (
"fmt"
"os"
"time"
)
// Health 描述归档目录的可用性。
//
// 背景:归档目录通过 bind mount 进容器后,宿主机一旦把它删除,容器里的路径
// 会变成 mountinfo 中带 "//deleted" 的空壳目录(硬链接数 nlink=0),读起来
// "存在但为空",bot 会静默地再也发不出任何通知 —— 2026-10-07 预演环境实际
// 发生过(宿主日志清理脚本删空归档目录,bot 从 10-07 静默到 10-12)。
// 这里把该状态显式暴露出来,用于日志与飞书告警。
type Health struct {
Mounted bool
Nlink uint64
Entries int
Degraded bool
Reason string
}
// StaleAfter 是「目录持续为空多久后开始高频复查」的阈值。
// 归档只在全量同步结束后写入,中间的空窗是正常的,因此不能一空就缩短周期。
const StaleAfter = 10 * time.Minute
// DegradedAlertInterval 是同一个失效状态重复告警的最小间隔。
const DegradedAlertInterval = 6 * time.Hour
// DegradedExitDelay 是检测到挂载失效后,退出前的等待时间。
//
// 为什么不立即退出:compose 的 restart 策略会立即拉起容器,若进程马上又检测到
// 失效并退出,就变成几秒一次的崩溃循环(实测 8 次重启只花了约 9 秒),既刷日志
// 又毫无意义。等一会再退,既给宿主一个修复窗口,也让重启频率可控。
// 实测:目录真的被删掉时,等几十秒后再重启依然能自愈(重启会重新解析挂载)。
//
// 声明为变量便于测试缩短(生产用默认值)。
var DegradedExitDelay = 30 * time.Second
// DegradedMaxExits 是同一进程内连续退出的上限,防止极端情况下无限退出。
// 超过后不再退出,只保留告警与日志,避免变成无意义的重启风暴。
const DegradedMaxExits = 3
// ExitOnDegraded 为 true 时,检测到挂载失效(目录被宿主删除,nlink=0)
// 就告警并退出进程,交给 restart policy 拉起。
//
// 为什么退出就能修好:bind mount 的源路径是**容器启动时**解析的。宿主把目录删掉
// 再重建后,容器仍指向旧 inode(mountinfo 里带 //deleted、nlink=0),看不到任何
// 新文件;但重新启动容器会重新解析路径,拿到新 inode 并立即恢复正常。
// 实测(2026-10-13,dev 100.29):手工 docker restart 后 inode 从旧变新、文件立即可见;
// 进程自行退出后由 restart=unless-stopped 拉起,同样自愈(restarts=1)。
//
// 这样就不再需要「重建 worker 必须同批重建 bot」这种人工纪律:
// worker 重建把目录换掉后,bot 自己会在下一轮扫描退出并被拉起,自动重新挂载。
var ExitOnDegraded = true
// Health 检查归档目录当前是否可用。
func (s Service) Health() Health {
h := Health{Mounted: true}
info, err := os.Stat(s.archiveDir)
if err != nil {
h.Mounted = false
h.Degraded = true
h.Reason = fmt.Sprintf("archive directory is not readable: %v", err)
return h
}
if !info.IsDir() {
h.Mounted = false
h.Degraded = true
h.Reason = fmt.Sprintf("archive path is not a directory: %s", s.archiveDir)
return h
}
if nlink, ok := linkCount(info); ok {
h.Nlink = nlink
if nlink == 0 {
// 宿主机目录已被删除,容器仍挂着它的空壳。
h.Mounted = false
h.Degraded = true
h.Reason = fmt.Sprintf("archive directory was deleted on the host (nlink=0); recreate this container to rebind %s", s.archiveDir)
return h
}
}
entries, err := os.ReadDir(s.archiveDir)
if err != nil {
h.Mounted = false
h.Degraded = true
h.Reason = fmt.Sprintf("archive directory is not listable: %v", err)
return h
}
h.Entries = len(entries)
if h.Entries == 0 {
h.Reason = "archive directory is empty"
}
return h
}
// Interval 返回下一次扫描前的等待时长。
//
// 正常情况下保持配置的轮询周期;只有在目录长期为空(可能正被写入,也可能已失效)
// 时才缩短到 10s,避免把通知延迟留给下一次发布。
func (s Service) Interval(emptySince time.Time) time.Duration {
base := s.pollInterval
if base <= 0 {
base = 10 * time.Second
}
if base <= 10*time.Second {
return base
}
h := s.Health()
if h.Entries > 0 {
return base
}
if emptySince.IsZero() || time.Since(emptySince) < StaleAfter {
return base
}
return 10 * time.Second
}
+19
View File
@@ -0,0 +1,19 @@
//go:build linux
package notify
import (
"os"
"syscall"
)
// linkCount 返回目录的硬链接数。
// 宿主机把 bind mount 的源目录删掉后,容器内该目录的 nlink 会变成 0
// (mountinfo 里也能看到 "//deleted"),这是唯一可靠的失效信号。
func linkCount(info os.FileInfo) (uint64, bool) {
st, ok := info.Sys().(*syscall.Stat_t)
if !ok {
return 0, false
}
return uint64(st.Nlink), true
}
+9
View File
@@ -0,0 +1,9 @@
//go:build !linux
package notify
import "os"
// linkCount 在非 Linux 平台不可用(syscall.Stat_t 没有 Nlink 字段)。
// bot 实际只跑在 Linux 容器里,这里让本机测试仍可编译运行。
func linkCount(info os.FileInfo) (uint64, bool) { return 0, false }
+198
View File
@@ -0,0 +1,198 @@
package notify
import (
"context"
"errors"
"log/slog"
"os"
"path/filepath"
"testing"
"time"
"git.5iot.com/iotlcm-public/iotlcm-bot/internal/state"
)
func writeArchive(t *testing.T, dir, name, body string) {
t.Helper()
if err := os.WriteFile(filepath.Join(dir, name), []byte(body), 0o600); err != nil {
t.Fatal(err)
}
}
func discardLogger() *slog.Logger {
return slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError + 1}))
}
// countingLogger 统计 ERROR 级日志条数,用于验证去重告警。
func countingLogger(count *int) *slog.Logger {
return slog.New(slog.NewTextHandler(countingWriter{count}, &slog.HandlerOptions{Level: slog.LevelError}))
}
type countingWriter struct{ count *int }
func (w countingWriter) Write(p []byte) (int, error) {
*w.count++
return len(p), nil
}
const validArchiveBody = `{"version":1,"run":{"runId":"z-good","operation":"op","status":"completed","successCount":1,"failedCount":0,"createdAt":"2026-10-12T00:00:00Z","completedAt":"2026-10-12T00:01:00Z"},"rounds":[{"round":1,"enqueuedCount":1,"activeCount":0,"nextRoundCount":0,"successCount":1,"failedCount":0,"startedAt":"2026-10-12T00:00:00Z","completedAt":"2026-10-12T00:01:00Z"}]}`
// 一个坏归档不能挡住后面的归档,但也不能每轮扫描都刷一条日志。
func TestScanSkipsBrokenArchiveAndLogsOnlyOnce(t *testing.T) {
root := t.TempDir()
// 字典序在前:旧格式(rounds 为空)会校验失败。
writeArchive(t, root, "run-broken.json", `{"version":1,"run":{"runId":"broken","operation":"op","status":"completed"},"rounds":[]}`)
writeArchive(t, root, "run-z-good.json", validArchiveBody)
fake := &fakeFeishu{}
var logged int
svc := Service{archiveDir: root, environment: "preview", store: state.New(t.TempDir()), client: fake, logger: countingLogger(&logged), warned: map[string]struct{}{}}
for i := 0; i < 3; i++ {
if err := svc.Scan(context.Background()); err != nil {
t.Fatalf("Scan() returned error for a broken archive: %v", err)
}
}
if fake.sent != 1 {
t.Fatalf("expected the valid archive to be sent once, sent=%d", fake.sent)
}
if logged != 1 {
t.Fatalf("broken archive should be reported once across 3 scans, logged=%d", logged)
}
}
// 文件被重写(内容变了)后应该重新告警。
func TestScanWarnsAgainAfterArchiveIsRewritten(t *testing.T) {
root := t.TempDir()
writeArchive(t, root, "run-broken.json", `{"version":1,"run":{"runId":"broken","operation":"op","status":"completed"},"rounds":[]}`)
var logged int
svc := Service{archiveDir: root, environment: "preview", store: state.New(t.TempDir()), client: &fakeFeishu{}, logger: countingLogger(&logged), warned: map[string]struct{}{}}
if err := svc.Scan(context.Background()); err != nil {
t.Fatal(err)
}
// 重写为另一个仍然无效的内容,mtime/大小变化
time.Sleep(10 * time.Millisecond)
writeArchive(t, root, "run-broken.json", `{"version":1,"run":{"runId":"broken","operation":"op","status":"completed","queryMonth":"202610"},"rounds":[]}`)
if err := svc.Scan(context.Background()); err != nil {
t.Fatal(err)
}
if logged != 2 {
t.Fatalf("rewritten broken archive should be reported again, logged=%d", logged)
}
}
// 发送失败(飞书临时故障)必须每轮都报,不能被去重掉:
// 否则一次网络抖动就会让通知静默丢失。
func TestScanSendFailureIsNotDeduped(t *testing.T) {
root := t.TempDir()
writeArchive(t, root, "run-z-good.json", validArchiveBody)
fail := &fakeFeishu{err: errors.New("feishu timeout")}
var logged int
svc := Service{archiveDir: root, environment: "preview", store: state.New(t.TempDir()), client: fail, logger: countingLogger(&logged), warned: map[string]struct{}{}}
for i := 0; i < 3; i++ {
if err := svc.Scan(context.Background()); err != nil {
t.Fatal(err)
}
}
if logged != 3 {
t.Fatalf("transient send failures must be reported every scan, logged=%d want=3", logged)
}
}
func TestHealthReportsEmptyDirectory(t *testing.T) {
svc := Service{archiveDir: t.TempDir()}
h := svc.Health()
if !h.Mounted || h.Degraded {
t.Fatalf("empty but mounted directory should not be degraded: %+v", h)
}
if h.Entries != 0 || h.Reason == "" {
t.Fatalf("unexpected health: %+v", h)
}
}
func TestHealthReportsMissingDirectory(t *testing.T) {
svc := Service{archiveDir: filepath.Join(t.TempDir(), "gone")}
h := svc.Health()
if h.Mounted || !h.Degraded {
t.Fatalf("missing directory must be degraded: %+v", h)
}
}
// 挂载失效时必须退出进程,交给 restart policy 重新拉起以重新解析 bind mount。
// 这是「不再需要人工同批重建 bot」的关键:重启后挂载指向宿主当前目录。
func TestWatchExitsWhenArchiveMountIsDegraded(t *testing.T) {
shortenExitDelay(t)
if !ExitOnDegraded {
t.Skip("ExitOnDegraded disabled")
}
stateDir := t.TempDir()
svc := Service{
archiveDir: filepath.Join(t.TempDir(), "gone"),
environment: "test",
stateDir: stateDir,
pollInterval: 10 * time.Millisecond,
store: state.New(stateDir),
logger: discardLogger(),
}
done := make(chan error, 1)
go func() { done <- svc.Watch(context.Background()) }()
select {
case err := <-done:
if err == nil {
t.Fatal("Watch must return an error so the process exits non-zero and restarts")
}
case <-time.After(3 * time.Second):
t.Fatal("Watch did not exit after detecting a degraded archive mount")
}
}
// 目录长期为空时才缩短轮询;有内容或空窗很短时保持配置周期。
func TestIntervalShortensOnlyAfterLongEmptyPeriod(t *testing.T) {
root := t.TempDir()
svc := Service{archiveDir: root, pollInterval: time.Minute}
if got := svc.Interval(time.Time{}); got != time.Minute {
t.Fatalf("fresh empty dir should keep poll interval, got %s", got)
}
if got := svc.Interval(time.Now().Add(-StaleAfter - time.Second)); got != 10*time.Second {
t.Fatalf("long-empty dir should poll every 10s, got %s", got)
}
writeArchive(t, root, "run-anything.json", "{}")
if got := svc.Interval(time.Now().Add(-time.Hour)); got != time.Minute {
t.Fatalf("non-empty dir should keep poll interval, got %s", got)
}
}
type fakeFeishu struct {
sent int
err error
textCount int
texts []string
}
func (f *fakeFeishu) Send(ctx context.Context, card []byte) error {
if f.err != nil {
return f.err
}
f.sent++
return nil
}
func (f *fakeFeishu) SendText(ctx context.Context, text string) error {
f.textCount++
f.texts = append(f.texts, text)
return nil
}
// newTestStore 构造一个 state.Store 并返回其 Dir,供 alertstate 测试使用。
func newTestStore(dir string) state.Store { return state.New(dir) }
// shortenExitDelay 把退出等待改短,避免测试白等 30 秒(仅测试内使用)。
func shortenExitDelay(t *testing.T) {
t.Helper()
original := DegradedExitDelay
DegradedExitDelay = 10 * time.Millisecond
t.Cleanup(func() { DegradedExitDelay = original })
}
+350
View File
@@ -0,0 +1,350 @@
package notify
import (
"context"
"errors"
"fmt"
"log/slog"
"os"
"path/filepath"
"sort"
"time"
"git.5iot.com/iotlcm-public/iotlcm-bot/internal/archive"
"git.5iot.com/iotlcm-public/iotlcm-bot/internal/render"
"git.5iot.com/iotlcm-public/iotlcm-bot/internal/state"
)
type Service struct {
archiveDir, environment string
stateDir string
pollInterval time.Duration
store state.Store
client Sender
logger *slog.Logger
// warned 记录已经报过错的归档,避免坏文件每轮扫描都刷一条日志。
// 键为「路径 + mtime + 大小」:文件一旦被重写就会重新告警。
warned map[string]struct{}
// transient 记录临时错误的重报节奏与累计次数。
// 临时错误必须持续可见(一次网络抖动不能被吞掉),但 bot 被移出群这类
// 长期故障不能每秒刷一条,否则日志被淹、真告警失联。
transient map[string]*transientWarn
}
// Sender 是发送端抽象:*feishu.Client 满足它,测试可注入替身。
type Sender interface {
Send(ctx context.Context, card []byte) error
SendText(ctx context.Context, text string) error
}
func New(archiveDir, environment string, pollInterval time.Duration, store state.Store, client Sender, logger *slog.Logger) Service {
if logger == nil {
logger = DefaultLogger()
}
return Service{archiveDir: archiveDir, environment: environment, stateDir: store.Dir(), pollInterval: pollInterval, store: store, client: client, logger: logger, warned: map[string]struct{}{}, transient: map[string]*transientWarn{}}
}
func (s Service) Render(path string) (string, error) {
a, err := archive.Load(path)
if err != nil {
return "", err
}
return render.Text(a, s.environment), nil
}
func (s Service) SendFile(ctx context.Context, path string, force bool) error {
if err := s.store.Prepare(); err != nil {
return err
}
return s.process(ctx, path, force)
}
func (s Service) runFiles() ([]string, error) {
files, err := filepath.Glob(filepath.Join(s.archiveDir, "run-*.json"))
if err != nil {
return nil, err
}
sort.Strings(files)
return files, nil
}
// skipKey 生成归档文件的「内容版本」标识,用于去重告警。
// 读不到元数据时退化为路径本身,宁可少报警也不要刷屏。
func skipKey(path string) string {
info, err := os.Stat(path)
if err != nil {
return path
}
return fmt.Sprintf("%s|%d|%d", path, info.ModTime().UnixNano(), info.Size())
}
// archiveError 区分「这个归档永远不会成功」与「这次发送碰上了临时故障」。
// 前者(格式不对、旧版本、rounds 为空)是常态,只该报一次;
// 后者(飞书超时、鉴权失败、写状态失败)必须每轮重试并持续可见,
// 否则一次网络抖动就会被去重逻辑吞掉,通知静默丢失。
type archiveError struct {
err error
permanent bool
}
func (e *archiveError) Error() string { return e.err.Error() }
func (e *archiveError) Unwrap() error { return e.err }
// permanentErr 标记「重试也不会好」的错误。
func permanentErr(err error) error { return &archiveError{err: err, permanent: true} }
// isPermanent 判断错误是否属于「归档本身无效」。
func isPermanent(err error) bool {
var ae *archiveError
return errors.As(err, &ae) && ae.permanent
}
// warnOnce 对同一个归档文件的重报做节流。
//
// 永久错误(归档格式坏、旧版本)只记一次:重试也不会好。
// 临时错误(飞书超时、bot 不在群)按退避节奏重报:
// - 前 transientWarnFastPhase 次每次都报,保证瞬时抖动可见;
// - 之后按指数退避(1min→2min→4min…上限 transientWarnMaxInterval);
// - 超过 transientWarnMaxCount 次后只记一条汇总并停止重报,避免日志风暴。
//
// 历史上 230002(bot 不在群)持续 10 小时刷了 18,266 条 ERROR,占日志 99.9%,
// 把真正的告警完全淹没——这正是本函数要防的情况。
func (s Service) warnOnce(path string, err error) {
if !isPermanent(err) {
s.warnTransient(path, err)
return
}
key := skipKey(path)
if s.warned != nil {
if _, seen := s.warned[key]; seen {
return
}
s.warned[key] = struct{}{}
}
s.logger.Error("skip archive", "path", path, "error", err)
}
const (
// transientWarnFastPhase 是「每次都报」的次数,覆盖瞬时抖动。
transientWarnFastPhase = 5
// transientWarnMaxInterval 是退避重报的间隔上限。
transientWarnMaxInterval = 10 * time.Minute
// transientWarnMaxCount 是同一归档同一错误的最大重报次数,超过后静默。
transientWarnMaxCount = 30
)
// transientWarn 记录一个归档的临时错误重报状态。
type transientWarn struct {
count int
lastLog time.Time
suppressed bool
}
// warnTransient 按退避节奏记录临时错误。
func (s Service) warnTransient(path string, err error) {
key := skipKey(path)
now := time.Now()
if s.transient == nil {
s.transient = map[string]*transientWarn{}
}
st, ok := s.transient[key]
if !ok {
st = &transientWarn{}
s.transient[key] = st
}
if st.suppressed {
return
}
// 前 fastPhase 次每次都报。
if st.count < transientWarnFastPhase {
st.count++
st.lastLog = now
s.logger.Error("process archive failed", "path", path, "error", err, "attempt", st.count)
return
}
// 之后按指数退避。
interval := time.Minute << uint(st.count-transientWarnFastPhase)
if interval > transientWarnMaxInterval {
interval = transientWarnMaxInterval
}
if now.Sub(st.lastLog) < interval {
return
}
st.count++
st.lastLog = now
if st.count > transientWarnMaxCount {
st.suppressed = true
s.logger.Error("process archive failed, suppressing further identical alerts",
"path", path, "error", err, "attempts", st.count-1,
"hint", "this is likely a persistent configuration problem (e.g. bot removed from chat), fix the cause to resume notifications")
return
}
s.logger.Error("process archive failed", "path", path, "error", err, "attempt", st.count)
}
// Scan 扫描归档目录并发送尚未通知过的 Run。
//
// 单个归档出错(旧格式、写了一半、权限不足)只跳过并记日志:
// 过去这里直接 return,一个坏文件会挡住后面所有归档,通知会静默丢失。
// 坏文件是常态(例如 manual 部分同步的 Run 天然没有 rounds),所以只记一次。
func (s Service) Scan(ctx context.Context) error {
if err := s.store.Prepare(); err != nil {
return err
}
files, err := s.runFiles()
if err != nil {
return err
}
for _, path := range files {
if err := s.process(ctx, path, false); err != nil {
s.warnOnce(path, err)
}
}
return nil
}
func (s Service) Watch(ctx context.Context) error {
if s.pollInterval <= 0 {
s.pollInterval = 10 * time.Second
}
var emptySince time.Time
for {
h := s.Health()
if h.Degraded {
// 目录已被宿主删除等硬故障:告警节流状态落盘,避免退出重启后
// 把同一次故障反复推送成飞书骚扰。
now := time.Now()
st := loadAlertState(s.stateDir)
if st.shouldAlert(h.Reason, now, DegradedAlertInterval) {
s.logger.Error("archive directory is not usable", "path", s.archiveDir, "reason", h.Reason, "entries", h.Entries)
s.alertMount(ctx, h.Reason, st.ExitCount+1)
st.LastDegradedAlert = now
st.LastDegradedReason = h.Reason
if err := saveAlertState(s.stateDir, st); err != nil {
s.logger.Error("save alert state failed", "error", err)
}
}
if ExitOnDegraded {
// 退出并交给 restart policy 重新拉起:重启会重新解析 bind mount,
// 从而重新挂载到宿主当前的目录(详见 ExitOnDegraded 注释)。
st.ExitCount++
if err := saveAlertState(s.stateDir, st); err != nil {
s.logger.Error("save alert state failed", "error", err)
}
if st.ExitCount > DegradedMaxExits {
// 已经反复重启仍未恢复(例如宿主把目录删了且不再重建):
// 继续退出只会变成重启风暴,改为留在原地持续观测+告警。
s.logger.Error("archive mount still unusable after repeated restarts, staying up to keep reporting",
"path", s.archiveDir, "reason", h.Reason, "exitCount", st.ExitCount)
} else {
// 等一会再退:restart 策略是立即拉起的,立刻退出会造成几秒一次的崩溃循环。
s.logger.Error("exit to rebind archive mount", "path", s.archiveDir, "reason", h.Reason, "exitCount", st.ExitCount, "delay", DegradedExitDelay)
timer := time.NewTimer(DegradedExitDelay)
select {
case <-ctx.Done():
timer.Stop()
return ctx.Err()
case <-timer.C:
}
return fmt.Errorf("archive directory unusable, restarting to rebind mount: %s", h.Reason)
}
}
} else if h.Entries == 0 {
if emptySince.IsZero() {
emptySince = time.Now()
}
} else {
emptySince = time.Time{}
}
if err := s.Scan(ctx); err != nil {
s.logger.Error("scan failed", "error", err)
}
timer := time.NewTimer(s.Interval(emptySince))
select {
case <-ctx.Done():
timer.Stop()
return ctx.Err()
case <-timer.C:
}
}
}
// alert 在飞书客户端可用时发一条文本告警;失败只记日志,不影响主循环。
func (s Service) alert(ctx context.Context, title, reason string) {
if s.client == nil {
return
}
text := fmt.Sprintf("[iotlcm-bot][%s] %s: %s", s.environment, title, reason)
if err := s.client.SendText(ctx, text); err != nil {
s.logger.Error("send alert failed", "error", err)
}
}
// alertMount 发送「归档目录不可用」告警卡片。
//
// 用卡片而非纯文本:text 消息在飞书里是灰底等宽块,长错误串会把气泡撑得很宽;
// 卡片带红色 header 与分区字段,一眼能看出是故障,且与同步结果卡片风格一致。
func (s Service) alertMount(ctx context.Context, reason string, exitCount int) {
if s.client == nil {
return
}
summary := "正在重启以重新挂载"
if exitCount > DegradedMaxExits {
summary = "反复重启仍未恢复,已改为持续监控"
}
card, err := render.AlertCard(render.Alert{
Environment: s.environment,
Title: "归档目录不可用",
Summary: summary,
Reason: render.FriendlyReason(reason),
ExitCount: exitCount,
Path: s.archiveDir,
})
if err != nil {
s.logger.Error("render alert card failed", "error", err)
return
}
if err := s.client.Send(ctx, card); err != nil {
s.logger.Error("send alert failed", "error", err)
}
}
func (s Service) process(ctx context.Context, path string, force bool) error {
a, err := archive.Load(path)
if err != nil {
// 归档本身无效(格式/版本/rounds 为空):重试也不会好。
return permanentErr(err)
}
if !force {
sent, err := s.store.Has(a.Run.RunID)
if err != nil {
return err
}
if sent {
return nil
}
}
card, err := render.Card(a, s.environment)
if err != nil {
return permanentErr(err)
}
if s.client == nil {
return fmt.Errorf("feishu client is not configured")
}
if err := s.client.Send(ctx, card); err != nil {
return err
}
if err := s.store.Mark(a.Run.RunID); err != nil {
return err
}
s.logger.Info("run notification sent", "runId", a.Run.RunID, "path", path)
return nil
}
func DefaultLogger() *slog.Logger {
return slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelInfo}))
}
+40
View File
@@ -0,0 +1,40 @@
package notify
import (
"os"
"path/filepath"
"reflect"
"testing"
)
func TestRunFilesSelectsOnlyTopLevelRunJSON(t *testing.T) {
root := t.TempDir()
for _, name := range []string{
"run-abc.json",
"run-full-id.json",
"action-abc.json",
"sim-status-legacy.json",
"run-temp.json.tmp",
} {
if err := os.WriteFile(filepath.Join(root, name), []byte("{}"), 0o600); err != nil {
t.Fatal(err)
}
}
nested := filepath.Join(root, "2026", "09")
if err := os.MkdirAll(nested, 0o700); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(nested, "run-nested.json"), []byte("{}"), 0o600); err != nil {
t.Fatal(err)
}
svc := Service{archiveDir: root}
got, err := svc.runFiles()
if err != nil {
t.Fatal(err)
}
want := []string{filepath.Join(root, "run-abc.json"), filepath.Join(root, "run-full-id.json")}
if !reflect.DeepEqual(got, want) {
t.Fatalf("runFiles() = %#v, want %#v", got, want)
}
}
+147
View File
@@ -0,0 +1,147 @@
package notify
import (
"context"
"errors"
"log/slog"
"os"
"strings"
"sync"
"testing"
"time"
"git.5iot.com/iotlcm-public/iotlcm-bot/internal/state"
)
// captureHandler 收集所有日志记录,用于断言重报次数。
type captureHandler struct {
mu sync.Mutex
records []slog.Record
}
func (h *captureHandler) Enabled(_ context.Context, _ slog.Level) bool { return true }
func (h *captureHandler) Handle(_ context.Context, r slog.Record) error {
h.mu.Lock()
defer h.mu.Unlock()
h.records = append(h.records, r)
return nil
}
func (h *captureHandler) WithAttrs(_ []slog.Attr) slog.Handler { return h }
func (h *captureHandler) WithGroup(_ string) slog.Handler { return h }
func (h *captureHandler) count() int {
h.mu.Lock()
defer h.mu.Unlock()
return len(h.records)
}
func (h *captureHandler) lastMessage() string {
h.mu.Lock()
defer h.mu.Unlock()
if len(h.records) == 0 {
return ""
}
return h.records[len(h.records)-1].Message
}
func newCaptureLogger() (*slog.Logger, *captureHandler) {
h := &captureHandler{}
return slog.New(h), h
}
// TestTransientWarnThrottlesRepeatedErrors 验证同一个归档的临时错误
// 不会每轮都刷一条 ERROR:前几次可见,之后进入退避,超过上限后静默。
//
// 背景:2026-10-21/22 bot 被移出飞书群 10 小时,230002 错误刷了 18,266 条,
// 占日志 99.9%,真正的同步结果通知被完全淹没。
func TestTransientWarnThrottlesRepeatedErrors(t *testing.T) {
logger, h := newCaptureLogger()
svc := New(t.TempDir(), "test", time.Second, state.New(t.TempDir()), nil, logger)
transient := errors.New("feishu send failed: http=400 code=230002 msg=Bot/User can NOT be out of the chat.")
// 模拟 500 轮扫描都失败(约等于 8 分钟的 1 秒轮询)。
for i := 0; i < 500; i++ {
svc.warnOnce("/data/full-sync/run-abc.json", transient)
}
got := h.count()
// 前 5 次必报,之后受退避限制。500 轮里退避间隔从 1 分钟起翻倍,
// 最多也就多出个位数。这里给一个宽松上界,重点是不能是 500。
if got > transientWarnMaxCount+2 {
t.Fatalf("logged %d times for 500 failures, want <= %d (throttle is not working)", got, transientWarnMaxCount+2)
}
if got < transientWarnFastPhase {
t.Fatalf("logged %d times, want at least %d (fast phase should always report)", got, transientWarnFastPhase)
}
}
// TestTransientWarnDifferentArchivesAreIndependent 验证节流按归档文件隔离,
// 一个坏文件不能掩盖另一个文件的错误。
func TestTransientWarnDifferentArchivesAreIndependent(t *testing.T) {
logger, h := newCaptureLogger()
svc := New(t.TempDir(), "test", time.Second, state.New(t.TempDir()), nil, logger)
errA := errors.New("boom-a")
for i := 0; i < 20; i++ {
svc.warnOnce("/data/full-sync/run-a.json", errA)
}
afterA := h.count()
// 另一个文件应该独立计数,立刻能看到自己的错误。
svc.warnOnce("/data/full-sync/run-b.json", errors.New("boom-b"))
if h.count() != afterA+1 {
t.Fatalf("second archive was throttled by the first: before=%d after=%d", afterA, h.count())
}
}
// TestPermanentWarnOnlyOnce 验证永久错误仍然只报一次。
func TestPermanentWarnOnlyOnce(t *testing.T) {
logger, h := newCaptureLogger()
svc := New(t.TempDir(), "test", time.Second, state.New(t.TempDir()), nil, logger)
permanent := permanentErr(errors.New("archive rounds is empty"))
for i := 0; i < 50; i++ {
svc.warnOnce("/data/full-sync/run-bad.json", permanent)
}
if got := h.count(); got != 1 {
t.Fatalf("permanent error logged %d times, want exactly 1", got)
}
}
// TestTransientWarnSuppressionMessage 验证超过上限时给出的汇总日志包含排查提示,
// 让运维一眼看出这是配置问题而不是代码 bug。
func TestTransientWarnSuppressionMessage(t *testing.T) {
logger, h := newCaptureLogger()
svc := New(t.TempDir(), "test", time.Second, state.New(t.TempDir()), nil, logger)
transient := errors.New("code=230002 Bot/User can NOT be out of the chat.")
// 直接构造已进入退避阶段的状态,避免测试里真的等几分钟。
key := skipKey("/data/full-sync/run-abc.json")
svc.transient[key] = &transientWarn{
count: transientWarnMaxCount,
lastLog: time.Now().Add(-time.Hour),
}
svc.warnOnce("/data/full-sync/run-abc.json", transient)
msg := h.lastMessage()
if !strings.Contains(msg, "suppressing") {
t.Fatalf("suppression message = %q, want it to mention suppressing", msg)
}
}
// TestTransientWarnZeroValueService 验证未初始化的 Service(transient 为 nil)
// 不会 panic——生产上 Service 由 New 构造,但零值也应安全。
func TestTransientWarnZeroValueService(t *testing.T) {
svc := Service{logger: slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError + 10}))}
defer func() {
if r := recover(); r != nil {
t.Fatalf("zero-value Service panicked: %v", r)
}
}()
svc.warnOnce("/tmp/run-x.json", errors.New("transient"))
}
+90
View File
@@ -0,0 +1,90 @@
package render
import (
"encoding/json"
"fmt"
"strings"
)
// Alert 描述一条运维告警。
type Alert struct {
Environment string
// Title 是卡片标题,如「归档目录不可用」。
Title string
// Summary 是一句人话结论,如「正在重启以重新挂载」。
Summary string
// Reason 是技术细节(英文错误原样保留,便于排查)。
Reason string
// ExitCount 为累计退出次数,>1 时提示可能反复出现。
ExitCount int
// Path 是涉及的路径。
Path string
}
// AlertCard 把告警渲染成飞书交互卡片。
//
// 为什么不用纯文本:text 消息在飞书里渲染成灰底等宽块,长错误串会把气泡撑得很宽,
// 而且和同步结果卡片(interactive)风格不一致。卡片可以带红色 header、分区字段,
// 一眼能看出是「出事了」而不是一条普通消息。
func AlertCard(a Alert) ([]byte, error) {
title := fmt.Sprintf("%s · %s", displayEnvironment(a.Environment), a.Title)
fields := []any{}
if a.Summary != "" {
fields = append(fields, map[string]any{
"tag": "div",
"text": map[string]any{"tag": "lark_md", "content": "**" + a.Summary + "**"},
})
}
if a.Path != "" {
fields = append(fields, map[string]any{
"tag": "div",
"text": map[string]any{"tag": "lark_md", "content": "**路径**\n`" + a.Path + "`"},
})
}
if a.Reason != "" {
fields = append(fields, map[string]any{
"tag": "div",
"text": map[string]any{"tag": "lark_md", "content": "**原因**\n" + a.Reason},
})
}
if a.ExitCount > 1 {
fields = append(fields, map[string]any{
"tag": "div",
"text": map[string]any{"tag": "lark_md", "content": fmt.Sprintf("**已重复 %d 次**,若持续出现请检查宿主机上该目录是否被反复删除。", a.ExitCount)},
})
}
fields = append(fields, map[string]any{"tag": "hr"})
fields = append(fields, map[string]any{
"tag": "note",
"elements": []any{
map[string]any{"tag": "plain_text", "content": "iotlcm-bot 自动检测 · 恢复后不再重复提醒"},
},
})
card := map[string]any{
"msg_type": "interactive",
"card": map[string]any{
// red 让告警在消息流里一眼可辨,与结果卡片的 blue 区分开。
"header": map[string]any{"template": "red", "title": map[string]any{"tag": "plain_text", "content": title}},
"elements": fields,
},
}
return json.Marshal(card)
}
// FriendlyReason 把常见的技术错误翻成人话,保留原文以便排查。
func FriendlyReason(reason string) string {
switch {
case strings.Contains(reason, "deleted on the host"):
return "宿主机上的归档目录已被删除,容器仍指向旧目录(挂载失效)。"
case strings.Contains(reason, "no such file or directory"):
return "目录不存在。"
case strings.Contains(reason, "permission denied"):
return "没有读取权限。"
case strings.Contains(reason, "not a directory"):
return "该路径不是目录。"
default:
return reason
}
}
+86
View File
@@ -0,0 +1,86 @@
package render
import (
"encoding/json"
"strings"
"testing"
)
func TestAlertCardIsInteractiveWithRedHeader(t *testing.T) {
body, err := AlertCard(Alert{
Environment: "preview",
Title: "归档目录不可用",
Summary: "正在重启以重新挂载",
Reason: "宿主机上的归档目录已被删除,容器仍指向旧目录(挂载失效)。",
Path: "/data/full-sync",
})
if err != nil {
t.Fatal(err)
}
var decoded map[string]any
if err := json.Unmarshal(body, &decoded); err != nil {
t.Fatalf("alert card must be valid JSON: %v", err)
}
if decoded["msg_type"] != "interactive" {
t.Fatalf("alert must be an interactive card, got %v", decoded["msg_type"])
}
card := decoded["card"].(map[string]any)
header := card["header"].(map[string]any)
if header["template"] != "red" {
t.Fatalf("alert header should be red so it stands out, got %v", header["template"])
}
title := header["title"].(map[string]any)["content"].(string)
if !strings.Contains(title, "预演") || !strings.Contains(title, "归档目录不可用") {
t.Fatalf("unexpected title: %q", title)
}
}
func TestAlertCardShowsPathAndRepeatCount(t *testing.T) {
body, err := AlertCard(Alert{
Environment: "dev",
Title: "归档目录不可用",
Summary: "正在重启以重新挂载",
Reason: "目录不存在。",
Path: "/data/full-sync",
ExitCount: 3,
})
if err != nil {
t.Fatal(err)
}
text := string(body)
if !strings.Contains(text, "/data/full-sync") {
t.Fatal("card should show the path")
}
if !strings.Contains(text, "已重复 3 次") {
t.Fatal("card should warn about repeated exits")
}
}
// 单次故障时不该出现「已重复」这种话,避免误导。
func TestAlertCardOmitsRepeatNoticeOnFirstOccurrence(t *testing.T) {
body, err := AlertCard(Alert{Environment: "dev", Title: "归档目录不可用", ExitCount: 1})
if err != nil {
t.Fatal(err)
}
if strings.Contains(string(body), "已重复") {
t.Fatal("first occurrence must not claim repeats")
}
}
func TestFriendlyReasonTranslatesKnownErrors(t *testing.T) {
cases := map[string]string{
"archive directory was deleted on the host (nlink=0); recreate this container": "宿主机上的归档目录已被删除",
"stat /data/x: no such file or directory": "目录不存在。",
"open /data/x: permission denied": "没有读取权限。",
}
for input, want := range cases {
if got := FriendlyReason(input); !strings.Contains(got, want) {
t.Fatalf("FriendlyReason(%q) = %q, want it to contain %q", input, got, want)
}
}
// 未知错误原样保留,便于排查
unknown := "some unexpected failure"
if got := FriendlyReason(unknown); got != unknown {
t.Fatalf("unknown reason must be preserved, got %q", got)
}
}
+187
View File
@@ -0,0 +1,187 @@
package render
import (
"encoding/json"
"fmt"
"strings"
"time"
"git.5iot.com/iotlcm-public/iotlcm-bot/internal/archive"
)
// Kind 返回卡片标题用的中文名称。流量同步有日、月历史、本月快照三条链路,
// 标题必须能区分,否则运营无法从卡片判断是哪一类同步。
func Kind(operation string) string {
switch operation {
case "previous_day_usage_full_sync":
return "日流量同步"
case "previous_month_usage_full_sync":
return "月历史同步"
case "current_month_usage_full_sync":
return "本月流量快照同步"
}
if strings.Contains(operation, "sim_status") {
return "卡状态全量同步"
}
if isTrafficUsage(operation) {
return "流量全量同步"
}
return operation
}
// isTrafficUsage 判定 operation 是否属于流量族(日、月历史、本月快照及兼容旧操作名)。
// 供料观测等按链路区分的行为用它判断,避免依赖 Kind 返回的中文标题。
func isTrafficUsage(operation string) bool {
return strings.Contains(operation, "traffic") || strings.Contains(operation, "usage")
}
func Text(a archive.RunArchive, environment string) string {
title := fmt.Sprintf("【%s|%s】", environment, Kind(a.Run.Operation))
startAt, endAt := executionTimes(a)
lines := []string{
title,
"",
fmt.Sprintf("总数:%s", number(a.TotalCount())),
fmt.Sprintf("成功:%s", number(a.Run.SuccessCount)),
fmt.Sprintf("失败:%s", number(a.Run.FailedCount)),
fmt.Sprintf("成功率:%.2f%%", a.SuccessRate()),
fmt.Sprintf("开始时间:%s", timestamp(startAt)),
fmt.Sprintf("结束时间:%s", timestamp(endAt)),
fmt.Sprintf("总耗时:%s", duration(a.Duration())),
fmt.Sprintf("实际执行:%d 轮", len(a.Rounds)),
"",
"轮次结果:",
}
for _, r := range a.Rounds {
lines = append(lines, roundLine(r))
}
if block := supplyBlock(a); block != "" {
lines = append(lines, "", block)
}
lines = append(lines, "", "Run:"+a.Run.RunID)
return strings.Join(lines, "\n")
}
func Card(a archive.RunArchive, environment string) ([]byte, error) {
title := fmt.Sprintf("%s · %s", displayEnvironment(environment), Kind(a.Run.Operation))
startAt, endAt := executionTimes(a)
// Full sync runs commonly have final failures; use one neutral header color.
template := "blue"
elements := []any{
fields(
field("总数", number(a.TotalCount())),
field("成功", number(a.Run.SuccessCount)),
field("失败", number(a.Run.FailedCount)),
field("成功率", fmt.Sprintf("%.2f%%", a.SuccessRate())),
field("开始时间", timestamp(startAt)),
field("结束时间", timestamp(endAt)),
field("总耗时", duration(a.Duration())),
field("实际轮次", fmt.Sprintf("%d轮", len(a.Rounds))),
),
map[string]any{"tag": "hr"},
}
for _, r := range a.Rounds {
elements = append(elements, map[string]any{
"tag": "div",
"text": map[string]any{"tag": "lark_md", "content": roundBlock(r)},
})
}
if block := supplyBlock(a); block != "" {
elements = append(elements, map[string]any{"tag": "div", "text": map[string]any{"tag": "lark_md", "content": block}})
}
elements = append(elements,
map[string]any{"tag": "hr"},
map[string]any{"tag": "note", "elements": []any{map[string]any{"tag": "plain_text", "content": "Run: " + a.Run.RunID}}},
)
card := map[string]any{
"msg_type": "interactive",
"card": map[string]any{
"header": map[string]any{"template": template, "title": map[string]any{"tag": "plain_text", "content": title}},
"elements": elements,
},
}
return json.Marshal(card)
}
func fields(values ...map[string]any) map[string]any {
entries := make([]any, len(values))
for i, value := range values {
entries[i] = value
}
return map[string]any{"tag": "div", "fields": entries}
}
func field(label, value string) map[string]any {
return map[string]any{
"is_short": true,
"text": map[string]any{"tag": "lark_md", "content": fmt.Sprintf("**%s**\n%s", label, value)},
}
}
func roundBlock(r archive.RoundStats) string {
return fmt.Sprintf("**第 %d 轮 · %s 张 · %s**\n成功 %s(%.2f%%)\n进入下轮 %s · 终态失败 %s", r.Round, number(r.EnqueuedCount), duration(r.Duration()), number(r.SuccessCount), r.SuccessRate(), number(r.NextRoundCount), number(r.FailedCount))
}
func displayEnvironment(environment string) string {
switch environment {
case "preview":
return "预演"
case "prod", "production":
return "生产"
default:
return environment
}
}
func roundLine(r archive.RoundStats) string {
return fmt.Sprintf("第 %d 轮|%s 张|成功 %s(%.2f%%)|进入下轮 %s|终态失败 %s|%s", r.Round, number(r.EnqueuedCount), number(r.SuccessCount), r.SuccessRate(), number(r.NextRoundCount), number(r.FailedCount), duration(r.Duration()))
}
func executionTimes(a archive.RunArchive) (string, string) {
if len(a.Rounds) == 0 {
return a.Run.CreatedAt, a.Run.CompletedAt
}
return a.Rounds[0].StartedAt, a.Rounds[len(a.Rounds)-1].CompletedAt
}
func timestamp(value string) string {
t, err := time.Parse(time.RFC3339Nano, value)
if err != nil {
return value
}
return t.In(time.FixedZone("CST", 8*60*60)).Format("2006-01-02 15:04:05")
}
func number(n int64) string {
negative := n < 0
if negative {
n = -n
}
s := fmt.Sprintf("%d", n)
for i := len(s) - 3; i > 0; i -= 3 {
s = s[:i] + "," + s[i:]
}
if negative {
return "-" + s
}
return s
}
func duration(d time.Duration) string {
seconds := int64(d / time.Second)
if seconds < 0 {
return "未记录"
}
h, m, s := seconds/3600, (seconds%3600)/60, seconds%60
parts := make([]string, 0, 3)
if h > 0 {
parts = append(parts, fmt.Sprintf("%d小时", h))
}
if m > 0 {
parts = append(parts, fmt.Sprintf("%d分", m))
}
if s > 0 || len(parts) == 0 {
parts = append(parts, fmt.Sprintf("%d秒", s))
}
return strings.Join(parts, "")
}
+110
View File
@@ -0,0 +1,110 @@
package render
import (
"strings"
"testing"
"git.5iot.com/iotlcm-public/iotlcm-bot/internal/archive"
)
func sample() archive.RunArchive {
return archive.RunArchive{Version: 1, Run: archive.RunSummary{RunID: "run-1", Operation: "sim_status_queue_full_sync", Status: "completed_with_failures", SuccessCount: 175893, FailedCount: 10504, CreatedAt: "2026-09-22T11:42:33Z", CompletedAt: "2026-09-22T12:32:01Z"}, Rounds: []archive.RoundStats{
{Round: 1, EnqueuedCount: 186397, NextRoundCount: 9023, SuccessCount: 175870, FailedCount: 1504, StartedAt: "2026-09-22T11:42:34Z", CompletedAt: "2026-09-22T12:21:49Z"},
{Round: 2, EnqueuedCount: 9023, NextRoundCount: 9000, SuccessCount: 23, FailedCount: 0, StartedAt: "2026-09-22T12:21:51Z", CompletedAt: "2026-09-22T12:26:55Z"},
{Round: 3, EnqueuedCount: 9000, NextRoundCount: 0, SuccessCount: 0, FailedCount: 9000, StartedAt: "2026-09-22T12:26:56Z", CompletedAt: "2026-09-22T12:32:01Z"},
}}
}
func TestKindNamesEachSyncType(t *testing.T) {
cases := []struct{ operation, want string }{
{"previous_day_usage_full_sync", "日流量同步"},
{"previous_month_usage_full_sync", "月历史同步"},
{"current_month_usage_full_sync", "本月流量快照同步"},
{"sim_status_queue_full_sync", "卡状态全量同步"},
{"traffic_usage_full_sync", "流量全量同步"},
{"usage_full_sync", "流量全量同步"},
{"imei_full_sync", "imei_full_sync"},
}
for _, c := range cases {
if got := Kind(c.operation); got != c.want {
t.Errorf("Kind(%q) = %q, want %q", c.operation, got, c.want)
}
}
}
func TestKindKeepsTrafficSyncTitlesDistinct(t *testing.T) {
seen := map[string]string{}
for _, operation := range []string{"previous_day_usage_full_sync", "previous_month_usage_full_sync", "current_month_usage_full_sync"} {
title := Kind(operation)
if other, exists := seen[title]; exists {
t.Fatalf("%q and %q share title %q", other, operation, title)
}
seen[title] = operation
}
}
func TestCardTitleNamesTrafficSyncType(t *testing.T) {
cases := []struct{ operation, want string }{
{"previous_day_usage_full_sync", "生产 · 日流量同步"},
{"previous_month_usage_full_sync", "生产 · 月历史同步"},
{"current_month_usage_full_sync", "生产 · 本月流量快照同步"},
}
for _, c := range cases {
a := archive.RunArchive{Version: 1, Run: archive.RunSummary{RunID: "run-title", Operation: c.operation}}
body, err := Card(a, "prod")
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(body), `"content":"`+c.want+`"`) {
t.Fatalf("card for %q missing title %q: %s", c.operation, c.want, body)
}
}
}
func TestTextContainsRoundThreeFailure(t *testing.T) {
text := Text(sample(), "preview")
for _, want := range []string{"开始时间:2026-09-22 19:42:34", "结束时间:2026-09-22 20:32:01", "实际执行:3 轮", "第 3 轮", "终态失败 9,000", "5分5秒", "0.00%"} {
if !strings.Contains(text, want) {
t.Fatalf("text missing %q:\n%s", want, text)
}
}
if strings.Contains(text, "2026-09-22 20:26:56") {
t.Fatalf("text exposes per-round start time: %s", text)
}
}
func TestCardIsStructuredAndShowsFullRunID(t *testing.T) {
body, err := Card(sample(), "preview")
if err != nil {
t.Fatal(err)
}
value := string(body)
for _, want := range []string{
`"msg_type":"interactive"`,
`"content":"预演 · 卡状态全量同步"`,
`"fields"`,
`**开始时间**\n2026-09-22 19:42:34`,
`**结束时间**\n2026-09-22 20:32:01`,
`第 3 轮 · 9,000 张 · 5分5秒`,
`进入下轮 0 · 终态失败 9,000`,
`Run: run-1`,
} {
if !strings.Contains(value, want) {
t.Fatalf("card missing %q: %s", want, body)
}
}
if strings.Contains(value, "2026-09-22 20:26:56") {
t.Fatalf("card exposes per-round start time: %s", body)
}
}
func TestCardShowsFullRunID(t *testing.T) {
runID := "sim-status-20260922194233-c96b21ec7d3746ae9caef457e89faa41"
card, err := Card(archive.RunArchive{Version: 1, Run: archive.RunSummary{RunID: runID}}, "preview")
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(card), "Run: "+runID) {
t.Fatalf("card truncated Run ID: %s", card)
}
}
+68
View File
@@ -0,0 +1,68 @@
package render
import (
"fmt"
"git.5iot.com/iotlcm-public/iotlcm-bot/internal/archive"
"sort"
"strings"
)
func supplyBlock(a archive.RunArchive) string {
if !isTrafficUsage(a.Run.Operation) || a.Run.SyncType == "replay" {
return ""
}
s, present := a.Run.Supply()
if !present {
return ""
}
if s == nil {
return "供料观测:不可用(遥测格式非法);业务结果不受影响"
}
status := []string{}
if s.Partial {
status = append(status, "partial")
}
if s.Truncated {
status = append(status, "truncated")
}
if len(status) == 0 {
status = append(status, "完整")
}
unknown, unconfirmed := s.Summary.UnknownMS, s.Summary.UnconfirmedMS
lines := []string{"供料观测(Run 首轮扫描窗口,约1秒分辨率,时间为采样估计)", fmt.Sprintf("目标 %d · 有效 %d · 确认缺料 %d · unknown 通道 %d", s.Summary.Targets, s.Summary.Valid, s.Summary.Confirmed, s.Summary.Unknown), fmt.Sprintf("最长中断 %.1f秒 · 累计 %.1f通道秒(允许通道重叠)", float64(s.Summary.LongestMS)/1000, float64(s.Summary.TotalMS)/1000), fmt.Sprintf("待确认空闲 %.1f通道秒 · unknown %.1f通道秒 · %s", float64(unconfirmed)/1000, float64(unknown)/1000, strings.Join(status, " / "))}
if s.Truncated {
lines = append(lines, "明细已截断;Run摘要覆盖全部目标,归档保留最长等待前100通道")
}
if s.Summary.Confirmed == 0 && !s.Partial && !s.Truncated && s.Summary.Unknown == 0 && unconfirmed == 0 && s.Summary.Valid == s.Summary.Targets {
lines = append(lines, "约1秒分辨率下未发现确认缺料")
}
channels := append([]archive.SupplyChannel(nil), s.Channels...)
sort.Slice(channels, func(i, j int) bool {
a, b := channels[i], channels[j]
av, bv := max(a.Startup.LongestMS, a.Confirmed.LongestMS), max(b.Startup.LongestMS, b.Confirmed.LongestMS)
if av == bv {
return a.Key < b.Key
}
return av > bv
})
for _, c := range channels[:min(5, len(channels))] {
longest := max(c.Startup.LongestMS, c.Confirmed.LongestMS)
if longest == 0 {
continue
}
phase := "未记录"
var phaseMS int64
for k, v := range c.Stages {
if v > phaseMS || (v == phaseMS && k < phase) {
phase, phaseMS = k, v
}
}
signal := ""
if longest >= 5000 {
signal = "(≥5秒排查信号)"
}
lines = append(lines, fmt.Sprintf("%s:最长 %.1f秒%s;启动 %.1f秒 / 后续 %.1f秒;同期主要生产者阶段 %s %.1f秒", c.Key, float64(longest)/1000, signal, float64(c.Startup.TotalMS)/1000, float64(c.Confirmed.TotalMS)/1000, phase, float64(phaseMS)/1000))
}
lines = append(lines, "小于采样间隔的中断可能漏检;同期阶段不证明因果,5秒提示不构成SLA。完整明细见归档。")
return strings.Join(lines, "\n")
}
+49
View File
@@ -0,0 +1,49 @@
package render
import (
"encoding/json"
"git.5iot.com/iotlcm-public/iotlcm-bot/internal/archive"
"strings"
"testing"
)
func TestSupplyBlockCoversEveryTrafficOperation(t *testing.T) {
observation := json.RawMessage(`{"schemaVersion":1,"resolutionMs":1000,"summary":{"targetChannels":1,"validChannels":1,"confirmedChannels":1,"confirmedChannelMs":6000,"longestMs":6000}}`)
for _, operation := range []string{"previous_day_usage_full_sync", "previous_month_usage_full_sync", "current_month_usage_full_sync", "traffic_usage_full_sync"} {
a := archive.RunArchive{Run: archive.RunSummary{Operation: operation, SupplyObservation: observation}}
if supplyBlock(a) == "" {
t.Fatalf("traffic operation %q must show supply block", operation)
}
}
for _, operation := range []string{"sim_status_queue_full_sync", "imei_full_sync"} {
a := archive.RunArchive{Run: archive.RunSummary{Operation: operation, SupplyObservation: observation}}
if supplyBlock(a) != "" {
t.Fatalf("non-traffic operation %q must omit supply block", operation)
}
}
}
func TestSupplyCompatibilityAndPartial(t *testing.T) {
a := archive.RunArchive{Run: archive.RunSummary{Operation: "current_month_usage_full_sync"}}
if supplyBlock(a) != "" {
t.Fatal("legacy archive must omit telemetry")
}
a.Run.SupplyObservation = json.RawMessage(`{"schemaVersion":1,"resolutionMs":1000,"partial":true,"summary":{"targetChannels":2,"validChannels":2,"confirmedChannels":1,"confirmedChannelMs":6000,"longestMs":6000},"channels":[{"chanKey":"a","confirmedWait":{"totalMs":6000,"longestMs":6000},"producerStageMs":{"global_capacity":6000}}]}`)
text := supplyBlock(a)
for _, want := range []string{"partial", "6.0通道秒", "global_capacity", "约1秒分辨率", "≥5秒"} {
if !strings.Contains(text, want) {
t.Fatalf("missing %s in %s", want, text)
}
}
if strings.Contains(text, "全程供料充足") {
t.Fatal("unsupported conclusion")
}
a.Run.SupplyObservation = json.RawMessage(`"bad"`)
if !strings.Contains(supplyBlock(a), "不可用") {
t.Fatal("bad optional telemetry must degrade")
}
a.Run.SyncType = "replay"
if supplyBlock(a) != "" {
t.Fatal("replay changed")
}
}
+64
View File
@@ -0,0 +1,64 @@
package state
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"time"
)
type State struct {
RunID string `json:"runId"`
SentAt time.Time `json:"sentAt"`
}
type Store struct{ dir string }
func New(dir string) Store { return Store{dir: dir} }
func (s Store) Prepare() error {
dir := filepath.Join(s.dir, "sent")
if err := os.MkdirAll(dir, 0o700); err != nil {
return fmt.Errorf("create sent marker directory: %w", err)
}
file, err := os.CreateTemp(dir, ".write-check-*")
if err != nil {
return fmt.Errorf("check sent marker directory: %w", err)
}
name := file.Name()
if err := file.Close(); err != nil {
_ = os.Remove(name)
return fmt.Errorf("close sent marker write check: %w", err)
}
if err := os.Remove(name); err != nil {
return fmt.Errorf("remove sent marker write check: %w", err)
}
return nil
}
func (s Store) Has(runID string) (bool, error) {
_, err := os.Stat(filepath.Join(s.dir, "sent", runID+".json"))
if err == nil {
return true, nil
}
if os.IsNotExist(err) {
return false, nil
}
return false, fmt.Errorf("check sent marker: %w", err)
}
func (s Store) Mark(runID string) error {
if err := s.Prepare(); err != nil {
return err
}
dir := filepath.Join(s.dir, "sent")
body, _ := json.Marshal(State{RunID: runID, SentAt: time.Now()})
tmp := filepath.Join(dir, fmt.Sprintf(".%s.tmp", runID))
final := filepath.Join(dir, runID+".json")
if err := os.WriteFile(tmp, body, 0o600); err != nil {
return err
}
return os.Rename(tmp, final)
}
// Dir 返回状态目录,供调用方复用(例如告警节流状态落盘)。
func (s Store) Dir() string { return s.dir }
+31
View File
@@ -0,0 +1,31 @@
package state
import "testing"
func TestPrepareCreatesWritableSentDirectory(t *testing.T) {
s := New(t.TempDir())
if err := s.Prepare(); err != nil {
t.Fatal(err)
}
}
func TestMarkIsIdempotentForLookup(t *testing.T) {
s := New(t.TempDir())
sent, err := s.Has("run-1")
if err != nil {
t.Fatal(err)
}
if sent {
t.Fatal("run should not be marked before send")
}
if err := s.Mark("run-1"); err != nil {
t.Fatal(err)
}
sent, err = s.Has("run-1")
if err != nil {
t.Fatal(err)
}
if !sent {
t.Fatal("run should be marked")
}
}
+64
View File
@@ -0,0 +1,64 @@
{
"version": 1,
"run": {
"runId": "sim-status-20260922194233-c96b21ec7d3746ae9caef457e89faa41",
"operation": "sim_status_queue_full_sync",
"queryMonth": "",
"status": "completed_with_failures",
"producerDone": true,
"currentRound": 3,
"activeCount": 0,
"inflightCount": 0,
"nextRoundCount": 0,
"successCount": 175893,
"failedCount": 10504,
"createdAt": "2026-09-22T11:42:33.562765565Z",
"producerCompletedAt": "2026-09-22T11:43:36.656900096Z",
"completedAt": "2026-09-22T12:32:01.555760358Z",
"syncType": "normal"
},
"rounds": [
{
"runId": "sim-status-20260922194233-c96b21ec7d3746ae9caef457e89faa41",
"round": 1,
"enqueuedCount": 186397,
"activeCount": 0,
"nextRoundCount": 9023,
"successCount": 175870,
"failedCount": 1504,
"upstreamAttemptCount": 0,
"batchCount": 1872,
"startedAt": "2026-09-22T11:42:34.483546236Z",
"completedAt": "2026-09-22T12:21:49.958362058Z",
"errorCounts": {}
},
{
"runId": "sim-status-20260922194233-c96b21ec7d3746ae9caef457e89faa41",
"round": 2,
"enqueuedCount": 9023,
"activeCount": 0,
"nextRoundCount": 9000,
"successCount": 23,
"failedCount": 0,
"upstreamAttemptCount": 0,
"batchCount": 92,
"startedAt": "2026-09-22T12:21:51.441846314Z",
"completedAt": "2026-09-22T12:26:55.781082913Z",
"errorCounts": {}
},
{
"runId": "sim-status-20260922194233-c96b21ec7d3746ae9caef457e89faa41",
"round": 3,
"enqueuedCount": 9000,
"activeCount": 0,
"nextRoundCount": 0,
"successCount": 0,
"failedCount": 9000,
"upstreamAttemptCount": 0,
"batchCount": 90,
"startedAt": "2026-09-22T12:26:56.677525864Z",
"completedAt": "2026-09-22T12:32:01.555013325Z",
"errorCounts": {}
}
]
}