5732bf3384
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
160 lines
5.2 KiB
Go
160 lines
5.2 KiB
Go
package archive
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"sort"
|
|
"time"
|
|
)
|
|
|
|
type RunArchive struct {
|
|
Version int `json:"version"`
|
|
Run RunSummary `json:"run"`
|
|
Rounds []RoundStats `json:"rounds"`
|
|
}
|
|
|
|
type RunSummary struct {
|
|
SupplyObservation json.RawMessage `json:"supplyObservation,omitempty"`
|
|
SyncType string `json:"syncType,omitempty"`
|
|
RunID string `json:"runId"`
|
|
Operation string `json:"operation"`
|
|
Status string `json:"status"`
|
|
SuccessCount int64 `json:"successCount"`
|
|
FailedCount int64 `json:"failedCount"`
|
|
CreatedAt string `json:"createdAt"`
|
|
CompletedAt string `json:"completedAt"`
|
|
}
|
|
|
|
type RoundStats struct {
|
|
RunID string `json:"runId"`
|
|
Round int `json:"round"`
|
|
EnqueuedCount int64 `json:"enqueuedCount"`
|
|
ActiveCount int64 `json:"activeCount"`
|
|
NextRoundCount int64 `json:"nextRoundCount"`
|
|
SuccessCount int64 `json:"successCount"`
|
|
FailedCount int64 `json:"failedCount"`
|
|
UpstreamAttemptCount int64 `json:"upstreamAttemptCount"`
|
|
BatchCount int64 `json:"batchCount"`
|
|
StartedAt string `json:"startedAt"`
|
|
CompletedAt string `json:"completedAt"`
|
|
ErrorCounts map[string]int `json:"errorCounts"`
|
|
}
|
|
|
|
func Load(path string) (RunArchive, error) {
|
|
body, err := os.ReadFile(path)
|
|
if err != nil {
|
|
return RunArchive{}, err
|
|
}
|
|
var a RunArchive
|
|
if err := json.Unmarshal(body, &a); err != nil {
|
|
return RunArchive{}, fmt.Errorf("parse %s: %w", path, err)
|
|
}
|
|
if err := a.Validate(); err != nil {
|
|
return RunArchive{}, fmt.Errorf("validate %s: %w", path, err)
|
|
}
|
|
if err := ValidateFilename(path, a.Run.RunID); err != nil {
|
|
return RunArchive{}, err
|
|
}
|
|
return a, nil
|
|
}
|
|
|
|
func ValidateFilename(path, runID string) error {
|
|
want := "run-" + runID + ".json"
|
|
if filepath.Base(path) != want {
|
|
return fmt.Errorf("archive filename %q does not match RunID; expected %q", filepath.Base(path), want)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (a *RunArchive) Validate() error {
|
|
if a.Version != 1 {
|
|
return fmt.Errorf("unsupported version %d", a.Version)
|
|
}
|
|
if a.Run.RunID == "" || a.Run.Operation == "" {
|
|
return fmt.Errorf("runId and operation are required")
|
|
}
|
|
if a.Run.Status != "completed" && a.Run.Status != "completed_with_failures" {
|
|
return fmt.Errorf("run is not terminal: %s", a.Run.Status)
|
|
}
|
|
if len(a.Rounds) == 0 {
|
|
return fmt.Errorf("rounds must be non-empty")
|
|
}
|
|
copyRounds := append([]RoundStats(nil), a.Rounds...)
|
|
sort.Slice(copyRounds, func(i, j int) bool { return copyRounds[i].Round < copyRounds[j].Round })
|
|
var success, failed int64
|
|
for i, r := range copyRounds {
|
|
if r.Round != i+1 {
|
|
return fmt.Errorf("round sequence is not contiguous at %d", r.Round)
|
|
}
|
|
if r.RunID != "" && r.RunID != a.Run.RunID {
|
|
return fmt.Errorf("round %d runId does not match root runId", r.Round)
|
|
}
|
|
if r.EnqueuedCount < 0 || r.ActiveCount < 0 || r.NextRoundCount < 0 || r.SuccessCount < 0 || r.FailedCount < 0 || r.UpstreamAttemptCount < 0 || r.BatchCount < 0 {
|
|
return fmt.Errorf("round %d contains negative count", r.Round)
|
|
}
|
|
if r.EnqueuedCount != r.SuccessCount+r.FailedCount+r.NextRoundCount {
|
|
return fmt.Errorf("round %d count does not close", r.Round)
|
|
}
|
|
if _, err := parseTime(r.StartedAt); err != nil {
|
|
return fmt.Errorf("round %d invalid startedAt: %w", r.Round, err)
|
|
}
|
|
if _, err := parseTime(r.CompletedAt); err != nil {
|
|
return fmt.Errorf("round %d invalid completedAt: %w", r.Round, err)
|
|
}
|
|
success += r.SuccessCount
|
|
failed += r.FailedCount
|
|
}
|
|
if a.Run.SuccessCount != success || a.Run.FailedCount != failed {
|
|
return fmt.Errorf("run totals do not match rounds: run success=%d failed=%d, rounds success=%d failed=%d", a.Run.SuccessCount, a.Run.FailedCount, success, failed)
|
|
}
|
|
if _, err := parseTime(a.Run.CreatedAt); err != nil {
|
|
return fmt.Errorf("invalid createdAt: %w", err)
|
|
}
|
|
if _, err := parseTime(a.Run.CompletedAt); err != nil {
|
|
return fmt.Errorf("invalid completedAt: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (a RunArchive) TotalCount() int64 { return a.Run.SuccessCount + a.Run.FailedCount }
|
|
func (a RunArchive) SuccessRate() float64 {
|
|
if a.TotalCount() == 0 {
|
|
return 0
|
|
}
|
|
return float64(a.Run.SuccessCount) / float64(a.TotalCount()) * 100
|
|
}
|
|
func (a RunArchive) Duration() time.Duration {
|
|
start, err1 := parseTime(a.Run.CreatedAt)
|
|
end, err2 := parseTime(a.Run.CompletedAt)
|
|
if err1 != nil || err2 != nil {
|
|
return 0
|
|
}
|
|
return end.Sub(start)
|
|
}
|
|
func (r RoundStats) SuccessRate() float64 {
|
|
if r.EnqueuedCount == 0 {
|
|
return 0
|
|
}
|
|
return float64(r.SuccessCount) / float64(r.EnqueuedCount) * 100
|
|
}
|
|
|
|
func (r RoundStats) Duration() time.Duration {
|
|
start, err1 := parseTime(r.StartedAt)
|
|
end, err2 := parseTime(r.CompletedAt)
|
|
if err1 != nil || err2 != nil {
|
|
return 0
|
|
}
|
|
return end.Sub(start)
|
|
}
|
|
|
|
func parseTime(value string) (time.Time, error) {
|
|
for _, layout := range []string{time.RFC3339Nano, "2006-01-02 15:04:05.999", "2006-01-02 15:04:05"} {
|
|
if t, err := time.Parse(layout, value); err == nil {
|
|
return t, nil
|
|
}
|
|
}
|
|
return time.Time{}, fmt.Errorf("unsupported time %q", value)
|
|
}
|