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 } }