sync: 同步飞书机器人 v1.0.4 并脱敏
This commit is contained in:
@@ -6,3 +6,7 @@ bin/
|
|||||||
coverage.out
|
coverage.out
|
||||||
/state/
|
/state/
|
||||||
*.log
|
*.log
|
||||||
|
# Deployment secrets must never be checked into the public repository.
|
||||||
|
config.yaml
|
||||||
|
config.*.yaml
|
||||||
|
!config.example.yaml
|
||||||
|
|||||||
+4
-9
@@ -1,22 +1,17 @@
|
|||||||
FROM golang:1.25-alpine AS build
|
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
|
ARG GOPROXY=https://goproxy.cn|https://goproxy.io|https://proxy.golang.org|direct
|
||||||
ENV GOPROXY=${GOPROXY}
|
ENV GOPROXY=${GOPROXY}
|
||||||
|
|
||||||
WORKDIR /src
|
WORKDIR /src
|
||||||
|
|
||||||
COPY go.mod go.sum ./
|
COPY go.mod go.sum ./
|
||||||
|
RUN apk add --no-cache ca-certificates
|
||||||
RUN go mod download
|
RUN go mod download
|
||||||
|
|
||||||
COPY . ./
|
COPY . ./
|
||||||
|
|
||||||
RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags='-s -w' -o /out/iotlcm-bot ./cmd/iotlcm-bot
|
RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags='-s -w' -o /out/iotlcm-bot ./cmd/iotlcm-bot
|
||||||
|
|
||||||
FROM scratch
|
FROM scratch
|
||||||
|
|
||||||
COPY --from=build /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ca-certificates.crt
|
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
|
COPY --from=build /out/iotlcm-bot /usr/local/bin/iotlcm-bot
|
||||||
|
|
||||||
USER 65532:65532
|
USER 65532:65532
|
||||||
|
|
||||||
ENTRYPOINT ["/usr/local/bin/iotlcm-bot"]
|
ENTRYPOINT ["/usr/local/bin/iotlcm-bot"]
|
||||||
|
|||||||
@@ -1,127 +1,90 @@
|
|||||||
# iotlcm-bot
|
# iotlcm-bot
|
||||||
|
|
||||||
独立飞书应用机器人:读取 Worker 终态 Run JSON,渲染并发送全量同步结果卡片。
|
V1 独立飞书应用机器人:读取 Worker 终态 Run JSON,渲染并发送全量同步结果卡片。
|
||||||
|
|
||||||
本服务是 [iotlcm](https://git.5iot.com/iotlcm-public) 物联网卡全生命周期管理平台的配套组件,用于把异步全量同步(卡状态、流量、IMEI 等)的执行结果推送到飞书群。
|
|
||||||
|
|
||||||
## 特性
|
|
||||||
|
|
||||||
- **零外部依赖**:只依赖 `gopkg.in/yaml.v3`,不需要 Redis、数据库或 Worker HTTP 接口
|
|
||||||
- **只读归档**:只读挂载 Worker 的归档目录,按 `runId` 去重后发送
|
|
||||||
- **多类卡片**:全量同步汇总卡、告警卡、供料提醒卡
|
|
||||||
- **健康自检**:检测归档目录被宿主删除(inode 丢失)这类静默故障并告警
|
|
||||||
- **单镜像**:`scratch` 基础镜像,非 root 运行
|
|
||||||
|
|
||||||
## V1 范围
|
## V1 范围
|
||||||
|
|
||||||
- 只接受 `version=1` 且包含 `run` 和非空 `rounds` 的归档 JSON
|
- 只接受当前版本 `version=1` 且包含 `run` 和非空 `rounds` 的归档 JSON。
|
||||||
- 不兼容旧版 `rounds=[]` 文件
|
- 不兼容旧版 `rounds=[]` 文件。
|
||||||
- 不读取 Redis、Worker HTTP 接口或日志
|
- 不读取 Redis、Worker HTTP 接口或日志。
|
||||||
- 不实现 Replay、事件回调、卡片按钮和错误聚合
|
- 不实现 Replay、事件回调、卡片按钮和错误聚合。
|
||||||
|
- 按 `runId` 去重,避免重复发送。
|
||||||
|
|
||||||
## 快速开始
|
## 本地验证
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
go mod tidy
|
|
||||||
go test ./...
|
go test ./...
|
||||||
```
|
|
||||||
|
|
||||||
本地渲染一张卡片(不调用飞书 API):
|
|
||||||
|
|
||||||
```bash
|
|
||||||
go run ./cmd/iotlcm-bot render --file testdata/current-run.json
|
go run ./cmd/iotlcm-bot render --file testdata/current-run.json
|
||||||
go run ./cmd/iotlcm-bot render --file testdata/current-run.json --json
|
go run ./cmd/iotlcm-bot render --file testdata/current-run.json --json
|
||||||
```
|
```
|
||||||
|
|
||||||
## 配置
|
`render` 只渲染通知内容,不调用飞书 API。
|
||||||
|
|
||||||
配置文件为 `config.yaml`。**公开版里的飞书凭据是占位符**,真实值请用环境变量或本地覆盖文件注入:
|
## 本地单次发送
|
||||||
|
|
||||||
```yaml
|
机器人加入测试群,且 YAML 配置已填写 `feishu.appSecret` 和 `feishu.chatId` 后,可以发送指定 Run:
|
||||||
environment: preview
|
|
||||||
archiveDir: /data/full-sync
|
|
||||||
stateDir: ./state
|
|
||||||
pollInterval: 10s
|
|
||||||
feishu:
|
|
||||||
appId: "${FEISHU_APP_ID}"
|
|
||||||
appSecret: "${FEISHU_APP_SECRET}"
|
|
||||||
chatId: "${FEISHU_CHAT_ID}"
|
|
||||||
```
|
|
||||||
|
|
||||||
| 配置项 | 说明 |
|
|
||||||
| --- | --- |
|
|
||||||
| `archiveDir` | Worker 归档目录(只读挂载) |
|
|
||||||
| `stateDir` | 已发送 Run 的标记目录(可写) |
|
|
||||||
| `pollInterval` | 扫描间隔,默认 `10s` |
|
|
||||||
| `feishu.appId` / `appSecret` | 飞书应用凭据 |
|
|
||||||
| `feishu.chatId` | 目标群 ID |
|
|
||||||
|
|
||||||
## 命令
|
|
||||||
|
|
||||||
| 命令 | 作用 |
|
|
||||||
| --- | --- |
|
|
||||||
| `watch` | 常驻扫描归档目录,自动发送新 Run(默认模式) |
|
|
||||||
| `send` | 发送指定 Run JSON |
|
|
||||||
| `send-text` | 向配置里的群发一条纯文本(用于连通性验证) |
|
|
||||||
| `render` | 只渲染卡片内容,不调用飞书 API |
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
go run ./cmd/iotlcm-bot watch --config /path/to/iotlcm-bot.yaml
|
|
||||||
go run ./cmd/iotlcm-bot send --config /path/to/iotlcm-bot.yaml --file /path/to/run.json
|
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
|
go run ./cmd/iotlcm-bot send --config /path/to/iotlcm-bot.yaml --file /path/to/run.json --force
|
||||||
```
|
```
|
||||||
|
|
||||||
`--force` 用于确需重发时;正常运行不要使用。发送成功会写入 `state/sent/<runId>.json`,同一 Run 再次执行会跳过。
|
正常运行时不要使用 `--force`。
|
||||||
|
|
||||||
## 容器化部署
|
## 已部署容器的发送测试
|
||||||
|
|
||||||
|
只验证飞书连接时,无需提供 Run JSON。Bot 容器运行后,发送一条自定义文本:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker build -t iotlcm-bot:latest .
|
docker exec iotlcm-bot /usr/local/bin/iotlcm-bot send-text --config /app/config/config.yaml --text "iotlcm-bot 飞书连接测试"
|
||||||
docker run -d \
|
|
||||||
-v /host/worker/full-sync:/data/full-sync:ro \
|
|
||||||
-v /host/bot/state:/data/state \
|
|
||||||
-v /host/bot/config.yaml:/app/config/config.yaml:ro \
|
|
||||||
iotlcm-bot:latest
|
|
||||||
```
|
```
|
||||||
|
|
||||||
容器内路径约定:
|
`send-text` 不扫描归档、不写 Run 发送标记,可以重复执行。它直接通过飞书消息 API 向配置文件中的群发送文本,不是 HTTP 测试接口。
|
||||||
|
|
||||||
| 容器内路径 | 用途 | 挂载方式 |
|
需要测试完整的 Run 卡片链路时,才使用下面的单次发送命令:
|
||||||
| --- | --- | --- |
|
|
||||||
| `/data/full-sync` | Worker 归档目录 | **只读** |
|
|
||||||
| `/data/state` | 已发送标记 | 可写 |
|
|
||||||
| `/app/config/config.yaml` | 配置文件 | 只读 |
|
|
||||||
|
|
||||||
## 目录结构
|
```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
|
```text
|
||||||
iotlcm-bot/
|
Worker 宿主归档目录:<host-worker-full-sync-dir>
|
||||||
├── cmd/iotlcm-bot/main.go # CLI 入口
|
Bot 只读挂载目录:/data/full-sync
|
||||||
├── internal/
|
Bot 可写状态目录:<host-bot-state-dir> -> /data/state
|
||||||
│ ├── archive/ # 归档 JSON 解析
|
|
||||||
│ ├── config/ # 配置加载
|
|
||||||
│ ├── feishu/ # 飞书 API 客户端
|
|
||||||
│ ├── notify/ # 通知服务与健康自检
|
|
||||||
│ ├── render/ # 卡片渲染
|
|
||||||
│ └── state/ # 已发送标记
|
|
||||||
├── config.yaml # 配置(公开版为占位值)
|
|
||||||
├── testdata/ # 测试用归档样例
|
|
||||||
└── Dockerfile
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## 常见问题
|
|
||||||
|
|
||||||
**卡片不发送?** 检查归档目录是否可读、`runId` 是否已发送过(`state/sent/` 下有标记则跳过)、群机器人是否被移出。
|
|
||||||
|
|
||||||
**日志刷满?** `watch` 模式对同一文件只记一次错误,文件被重写后会重新记录。
|
|
||||||
|
|
||||||
**归档目录被宿主删了?** Bot 会检测到(inode 丢失)并降级告警——此时应检查宿主的日志清理脚本是否误删了挂载源目录。
|
|
||||||
|
|
||||||
## 相关项目
|
|
||||||
|
|
||||||
- [iotlcm-worker-svr](https://git.5iot.com/iotlcm-public/iotlcm-worker-svr) —— 异步执行中枢,产出归档 JSON
|
|
||||||
- [iotlcm-openapi](https://git.5iot.com/iotlcm-public/iotlcm-openapi) —— 对外 API 网关
|
|
||||||
|
|
||||||
## License
|
|
||||||
|
|
||||||
[Apache License 2.0](./LICENSE)
|
|
||||||
|
|||||||
@@ -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"
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
environment: preview
|
|
||||||
archiveDir: /data/full-sync
|
|
||||||
stateDir: ./state
|
|
||||||
pollInterval: 10s
|
|
||||||
feishu:
|
|
||||||
appId: "${FEISHU_APP_ID}"
|
|
||||||
appSecret: "${FEISHU_APP_SECRET}"
|
|
||||||
chatId: "${FEISHU_CHAT_ID}"
|
|
||||||
@@ -25,6 +25,10 @@ type Service struct {
|
|||||||
// warned 记录已经报过错的归档,避免坏文件每轮扫描都刷一条日志。
|
// warned 记录已经报过错的归档,避免坏文件每轮扫描都刷一条日志。
|
||||||
// 键为「路径 + mtime + 大小」:文件一旦被重写就会重新告警。
|
// 键为「路径 + mtime + 大小」:文件一旦被重写就会重新告警。
|
||||||
warned map[string]struct{}
|
warned map[string]struct{}
|
||||||
|
// transient 记录临时错误的重报节奏与累计次数。
|
||||||
|
// 临时错误必须持续可见(一次网络抖动不能被吞掉),但 bot 被移出群这类
|
||||||
|
// 长期故障不能每秒刷一条,否则日志被淹、真告警失联。
|
||||||
|
transient map[string]*transientWarn
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sender 是发送端抽象:*feishu.Client 满足它,测试可注入替身。
|
// Sender 是发送端抽象:*feishu.Client 满足它,测试可注入替身。
|
||||||
@@ -37,7 +41,7 @@ func New(archiveDir, environment string, pollInterval time.Duration, store state
|
|||||||
if logger == nil {
|
if logger == nil {
|
||||||
logger = DefaultLogger()
|
logger = DefaultLogger()
|
||||||
}
|
}
|
||||||
return Service{archiveDir: archiveDir, environment: environment, stateDir: store.Dir(), pollInterval: pollInterval, store: store, client: client, logger: logger, warned: map[string]struct{}{}}
|
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) {
|
func (s Service) Render(path string) (string, error) {
|
||||||
a, err := archive.Load(path)
|
a, err := archive.Load(path)
|
||||||
@@ -94,11 +98,19 @@ func isPermanent(err error) bool {
|
|||||||
return errors.As(err, &ae) && ae.permanent
|
return errors.As(err, &ae) && ae.permanent
|
||||||
}
|
}
|
||||||
|
|
||||||
// warnOnce 对同一个归档文件只记一次「跳过」日志。
|
// warnOnce 对同一个归档文件的重报做节流。
|
||||||
// 只有永久性错误才去重;临时错误原样返回给调用方,保持每轮可见。
|
//
|
||||||
|
// 永久错误(归档格式坏、旧版本)只记一次:重试也不会好。
|
||||||
|
// 临时错误(飞书超时、bot 不在群)按退避节奏重报:
|
||||||
|
// - 前 transientWarnFastPhase 次每次都报,保证瞬时抖动可见;
|
||||||
|
// - 之后按指数退避(1min→2min→4min…上限 transientWarnMaxInterval);
|
||||||
|
// - 超过 transientWarnMaxCount 次后只记一条汇总并停止重报,避免日志风暴。
|
||||||
|
//
|
||||||
|
// 历史上 230002(bot 不在群)持续 10 小时刷了 18,266 条 ERROR,占日志 99.9%,
|
||||||
|
// 把真正的告警完全淹没——这正是本函数要防的情况。
|
||||||
func (s Service) warnOnce(path string, err error) {
|
func (s Service) warnOnce(path string, err error) {
|
||||||
if !isPermanent(err) {
|
if !isPermanent(err) {
|
||||||
s.logger.Error("process archive failed", "path", path, "error", err)
|
s.warnTransient(path, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
key := skipKey(path)
|
key := skipKey(path)
|
||||||
@@ -111,6 +123,69 @@ func (s Service) warnOnce(path string, err error) {
|
|||||||
s.logger.Error("skip archive", "path", path, "error", err)
|
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。
|
// Scan 扫描归档目录并发送尚未通知过的 Run。
|
||||||
//
|
//
|
||||||
// 单个归档出错(旧格式、写了一半、权限不足)只跳过并记日志:
|
// 单个归档出错(旧格式、写了一半、权限不足)只跳过并记日志:
|
||||||
|
|||||||
@@ -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"))
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user