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, "") }