chore: 初始开源发布
iotlcm-bot:独立飞书应用机器人(读取 Worker 终态 Run JSON,渲染并发送全量同步结果卡片)。
- Go module:git.5iot.com/iotlcm/iotlcm-bot → git.5iot.com/iotlcm-public/iotlcm-bot
- 飞书凭据占位:appId/appSecret/chatId → ${FEISHU_APP_ID}/${FEISHU_APP_SECRET}/${FEISHU_CHAT_ID}
- 移除内部资产:CI 触发器
- Dockerfile 改用公共基础镜像(golang:1.25-alpine → scratch)
- README 重写(命令/配置/挂载契约/常见问题),去掉内部环境路径
- 补 Apache-2.0 LICENSE,LF 行尾 + .gitattributes
This commit is contained in:
@@ -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, "")
|
||||
}
|
||||
Reference in New Issue
Block a user