Files
iotlcm/internal/render/card.go
T
MiFaZhan 5732bf3384 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
2026-10-22 16:14:24 +08:00

188 lines
5.7 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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, "")
}