From 8ff017b5e81934ae6f056358609f89f5105829f6 Mon Sep 17 00:00:00 2001 From: ryan Date: Sun, 30 Aug 2026 00:36:13 +0800 Subject: [PATCH] =?UTF-8?q?feat(pkg/util):=20=E8=A1=A5=E9=BD=90=E7=89=88?= =?UTF-8?q?=E6=9C=AC=E6=AF=94=E8=BE=83=E3=80=81=E7=BD=91=E7=BB=9C=E4=B8=8E?= =?UTF-8?q?=E6=A0=BC=E5=BC=8F=E5=8C=96=E9=80=9A=E7=94=A8=E5=8A=A9=E6=89=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 下游 OpenFlare 的边缘守护进程与发布流程需要这些与业务无关的纯函数, 按上游/下游归属规约回流到平台层,避免下游在上游目录里长期携带本地文件: - version / version_compare:CompareVersions、ParseVersionInfo(版本区间比较) - network:GetIP、IsPrivateIPv4 - format / value / string / slice:Bytes2Size、Seconds2Time、Interface2String、 TrimStringFields、UniqueAndCleanStringSlice 与 IdentifiableTimeRecord 验证:go build 通过;go test ./... exit 0(48 包 ok); check_cordis_architecture.sh 0 violations;golangci-lint 0 issues;gofmt 干净。 --- backend/pkg/util/format.go | 70 +++++++++++++++ backend/pkg/util/format_test.go | 52 +++++++++++ backend/pkg/util/network.go | 45 ++++++++++ backend/pkg/util/network_test.go | 36 ++++++++ backend/pkg/util/slice.go | 79 +++++++++++++++++ backend/pkg/util/string.go | 15 ++++ backend/pkg/util/value.go | 22 +++++ backend/pkg/util/version.go | 129 ++++++++++++++++++++++++++++ backend/pkg/util/version_compare.go | 108 +++++++++++++++++++++++ 9 files changed, 556 insertions(+) create mode 100644 backend/pkg/util/format.go create mode 100644 backend/pkg/util/format_test.go create mode 100644 backend/pkg/util/network.go create mode 100644 backend/pkg/util/network_test.go create mode 100644 backend/pkg/util/slice.go create mode 100644 backend/pkg/util/string.go create mode 100644 backend/pkg/util/value.go create mode 100644 backend/pkg/util/version.go create mode 100644 backend/pkg/util/version_compare.go diff --git a/backend/pkg/util/format.go b/backend/pkg/util/format.go new file mode 100644 index 00000000..5b18daba --- /dev/null +++ b/backend/pkg/util/format.go @@ -0,0 +1,70 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +// Package util provides shared formatting and string helper functions. +package util + +import ( + "fmt" + "strconv" +) + +const ( + secondsPerYear = 31104000 // 360 days + secondsPerMonth = 2592000 // 30 days + secondsPerDay = 86400 + secondsPerHour = 3600 + secondsPerMinute = 60 +) + +const ( + sizeKB = 1024 + sizeMB = sizeKB * 1024 + sizeGB = sizeMB * 1024 +) + +// Bytes2Size converts a byte count to a human-readable string with unit (B, KB, MB, GB). +func Bytes2Size(num int64) string { + var numStr string + unit := "B" + switch { + case num/int64(sizeGB) >= 1: + numStr = fmt.Sprintf("%.2f", float64(num)/float64(sizeGB)) + unit = "GB" + case num/int64(sizeMB) >= 1: + numStr = strconv.Itoa(int(float64(num) / float64(sizeMB))) + unit = "MB" + case num/int64(sizeKB) >= 1: + numStr = strconv.Itoa(int(float64(num) / float64(sizeKB))) + unit = "KB" + default: + numStr = strconv.FormatInt(num, 10) + } + return numStr + " " + unit +} + +// Seconds2Time converts a number of seconds to a human-readable Chinese duration string. +func Seconds2Time(num int) (time string) { + if num/secondsPerYear > 0 { + time += strconv.Itoa(num/secondsPerYear) + " 年 " + num %= secondsPerYear + } + if num/secondsPerMonth > 0 { + time += strconv.Itoa(num/secondsPerMonth) + " 个月 " + num %= secondsPerMonth + } + if num/secondsPerDay > 0 { + time += strconv.Itoa(num/secondsPerDay) + " 天 " + num %= secondsPerDay + } + if num/secondsPerHour > 0 { + time += strconv.Itoa(num/secondsPerHour) + " 小时 " + num %= secondsPerHour + } + if num/secondsPerMinute > 0 { + time += strconv.Itoa(num/secondsPerMinute) + " 分钟 " + num %= secondsPerMinute + } + time += strconv.Itoa(num) + " 秒" + return +} diff --git a/backend/pkg/util/format_test.go b/backend/pkg/util/format_test.go new file mode 100644 index 00000000..b2fd973f --- /dev/null +++ b/backend/pkg/util/format_test.go @@ -0,0 +1,52 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package util + +import ( + "testing" +) + +func TestBytes2Size(t *testing.T) { + tests := []struct { + input int64 + expected string + }{ + {0, "0 B"}, + {500, "500 B"}, + {1023, "1023 B"}, + {1024, "1 KB"}, + {2048, "2 KB"}, + {1024 * 1024, "1 MB"}, + {1024 * 1024 * 1024, "1.00 GB"}, + {1024 * 1024 * 1024 * 2, "2.00 GB"}, + } + + for _, tt := range tests { + result := Bytes2Size(tt.input) + if result != tt.expected { + t.Errorf("Bytes2Size(%d) = %q, expected %q", tt.input, result, tt.expected) + } + } +} + +func TestSeconds2Time(t *testing.T) { + tests := []struct { + input int + expected string + }{ + {0, "0 秒"}, + {30, "30 秒"}, + {60, "1 分钟 0 秒"}, + {125, "2 分钟 5 秒"}, + {3600, "1 小时 0 秒"}, + {86400, "1 天 0 秒"}, + } + + for _, tt := range tests { + result := Seconds2Time(tt.input) + if result != tt.expected { + t.Errorf("Seconds2Time(%d) = %q, expected %q", tt.input, result, tt.expected) + } + } +} diff --git a/backend/pkg/util/network.go b/backend/pkg/util/network.go new file mode 100644 index 00000000..d7b864d5 --- /dev/null +++ b/backend/pkg/util/network.go @@ -0,0 +1,45 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package util + +import ( + "log/slog" + "net" +) + +// GetIP returns the first private IPv4 address found on the local network interfaces. +func GetIP() (ip string) { + ips, err := net.InterfaceAddrs() + if err != nil { + slog.Error("get interface addresses failed", "error", err) + return ip + } + + for _, a := range ips { + if candidate, ok := privateIPv4FromAddr(a); ok { + return candidate + } + } + return +} + +func privateIPv4FromAddr(addr net.Addr) (string, bool) { + ipNet, ok := addr.(*net.IPNet) + if !ok || ipNet.IP.IsLoopback() || ipNet.IP.To4() == nil { + return "", false + } + ip := ipNet.IP.String() + if isPrivateIPv4(ip) { + return ip, true + } + return "", false +} + +func isPrivateIPv4(ip string) bool { + parsedIP := net.ParseIP(ip) + if parsedIP == nil { + return false + } + return parsedIP.IsPrivate() +} diff --git a/backend/pkg/util/network_test.go b/backend/pkg/util/network_test.go new file mode 100644 index 00000000..62a9bd21 --- /dev/null +++ b/backend/pkg/util/network_test.go @@ -0,0 +1,36 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package util + +import ( + "testing" +) + +func TestIsPrivateIPv4(t *testing.T) { + tests := []struct { + ip string + expected bool + }{ + {"127.0.0.1", false}, // Loopback is not in RFC 1918 private range + {"10.0.0.1", true}, + {"172.16.0.1", true}, + {"192.168.1.1", true}, + {"8.8.8.8", false}, + {"invalid-ip", false}, + } + + for _, tt := range tests { + result := isPrivateIPv4(tt.ip) + if result != tt.expected { + t.Errorf("isPrivateIPv4(%q) = %v, expected %v", tt.ip, result, tt.expected) + } + } +} + +func TestGetIP(t *testing.T) { + ip := GetIP() + // GetIP should return empty if no private IPv4 address is configured, or a valid IP. + // We just ensure it doesn't panic. + t.Logf("GetIP returned: %q", ip) +} diff --git a/backend/pkg/util/slice.go b/backend/pkg/util/slice.go new file mode 100644 index 00000000..74eb7139 --- /dev/null +++ b/backend/pkg/util/slice.go @@ -0,0 +1,79 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package util + +import ( + "sort" + "strings" + "time" +) + +// Unique returns a new slice containing only the unique elements of the input slice, +// preserving their original order. +func Unique[T comparable](slice []T) []T { + if slice == nil { + return nil + } + seen := make(map[T]struct{}) + result := make([]T, 0) + for _, item := range slice { + if _, ok := seen[item]; ok { + continue + } + seen[item] = struct{}{} + result = append(result, item) + } + return result +} + +// UniqueAndCleanStringSlice trims spaces, removes empty elements, and returns only the unique elements +// of the input string slice. It preserves order and returns nil if the resulting slice is empty. +func UniqueAndCleanStringSlice(slice []string) []string { + if slice == nil { + return nil + } + seen := make(map[string]struct{}) + result := make([]string, 0) + for _, item := range slice { + trimmed := strings.TrimSpace(item) + if trimmed == "" { + continue + } + if _, ok := seen[trimmed]; ok { + continue + } + seen[trimmed] = struct{}{} + result = append(result, trimmed) + } + if len(result) == 0 { + return nil + } + return result +} + +// IdentifiableTimeRecord represents a database record that has a unique ID and a primary timestamp field. +type IdentifiableTimeRecord interface { + GetID() uint + GetTime() time.Time +} + +// SortAndLimitRecords sorts a slice of IdentifiableTimeRecord descendingly by their timestamp (and ID as a tie-breaker), +// and limits the slice to the specified size if limit > 0. +func SortAndLimitRecords[T IdentifiableTimeRecord](rows []T, limit int) []T { + if len(rows) == 0 { + return rows + } + sort.Slice(rows, func(i, j int) bool { + ti := rows[i].GetTime() + tj := rows[j].GetTime() + if ti.Equal(tj) { + return rows[i].GetID() > rows[j].GetID() + } + return ti.After(tj) + }) + if limit > 0 && len(rows) > limit { + rows = rows[:limit] + } + return rows +} diff --git a/backend/pkg/util/string.go b/backend/pkg/util/string.go new file mode 100644 index 00000000..9ea84e8d --- /dev/null +++ b/backend/pkg/util/string.go @@ -0,0 +1,15 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package util + +import "strings" + +// TrimStringFields trims leading and trailing spaces from all provided string pointers. +func TrimStringFields(fields ...*string) { + for _, f := range fields { + if f != nil { + *f = strings.TrimSpace(*f) + } + } +} diff --git a/backend/pkg/util/value.go b/backend/pkg/util/value.go new file mode 100644 index 00000000..6473bd53 --- /dev/null +++ b/backend/pkg/util/value.go @@ -0,0 +1,22 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package util + +import ( + "fmt" + "strconv" +) + +// Interface2String converts a string, int, or float64 value to its string representation. +func Interface2String(inter any) string { + switch v := inter.(type) { + case string: + return v + case int: + return strconv.Itoa(v) + case float64: + return fmt.Sprintf("%f", v) + } + return "Not Implemented" +} diff --git a/backend/pkg/util/version.go b/backend/pkg/util/version.go new file mode 100644 index 00000000..96118b06 --- /dev/null +++ b/backend/pkg/util/version.go @@ -0,0 +1,129 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package util + +import ( + "strconv" + "strings" +) + +const gitDescribeMinIdentifiers = 2 + +// VersionInfo holds the parsed components of a semantic version string. +type VersionInfo struct { + Valid bool + IsDev bool + Numbers []int + Prerelease []string + GitDescribeDistance int + GitDescribeTail []string +} + +// ParseVersionInfo parses a version string into a structured VersionInfo. +func ParseVersionInfo(version string) VersionInfo { + normalized := strings.TrimSpace(strings.TrimPrefix(version, "v")) + if normalized == "" || normalized == "dev" { + return VersionInfo{IsDev: strings.EqualFold(normalized, "dev")} + } + base := normalized + prerelease := "" + if separator := strings.IndexRune(normalized, '-'); separator >= 0 { + base = normalized[:separator] + prerelease = normalized[separator+1:] + } + + segments := strings.Split(base, ".") + parts := make([]int, 0, len(segments)) + for _, segment := range segments { + segment = strings.TrimSpace(segment) + if segment == "" { + parts = append(parts, 0) + continue + } + + numeric := strings.Builder{} + for _, r := range segment { + if r < '0' || r > '9' { + break + } + numeric.WriteRune(r) + } + if numeric.Len() == 0 { + parts = append(parts, 0) + continue + } + value, err := strconv.Atoi(numeric.String()) + if err != nil { + return VersionInfo{} + } + parts = append(parts, value) + } + info := VersionInfo{Valid: len(parts) > 0, Numbers: parts} + if prerelease != "" { + identifiers := splitPrereleaseIdentifiers(prerelease) + if distance, tail, ok := parseGitDescribeIdentifiers(identifiers); ok { + info.GitDescribeDistance = distance + info.GitDescribeTail = tail + } else { + info.Prerelease = identifiers + } + } + return info +} + +func parseGitDescribeIdentifiers(identifiers []string) (int, []string, bool) { + if len(identifiers) < gitDescribeMinIdentifiers { + return 0, nil, false + } + distance, err := strconv.Atoi(strings.TrimSpace(identifiers[0])) + if err != nil || distance <= 0 { + return 0, nil, false + } + commitToken := strings.TrimSpace(identifiers[1]) + if commitToken == "" || !strings.HasPrefix(strings.ToLower(commitToken), "g") { + return 0, nil, false + } + return distance, identifiers[1:], true +} + +func splitPrereleaseIdentifiers(value string) []string { + parts := strings.FieldsFunc(strings.TrimSpace(value), func(r rune) bool { + return r == '.' || r == '-' + }) + filtered := make([]string, 0, len(parts)) + for _, part := range parts { + part = strings.TrimSpace(part) + if part != "" { + filtered = append(filtered, part) + } + } + return filtered +} + +// CompareVersions compares two version strings. +// Returns -1 if left < right, 1 if left > right, and 0 if they are equal. +func CompareVersions(local, remote string) int { + left := ParseVersionInfo(local) + right := ParseVersionInfo(remote) + if left.IsDev { + if right.Valid { + return -1 + } + return 0 + } + if !left.Valid || !right.Valid { + return 0 + } + + if result := compareVersionNumbers(left, right); result != 0 { + return result + } + if result := compareGitDescribeDistance(left, right); result != 0 { + return result + } + if left.GitDescribeDistance > 0 || right.GitDescribeDistance > 0 { + return compareGitDescribeTails(left, right) + } + return comparePrereleaseIdentifiers(left, right) +} diff --git a/backend/pkg/util/version_compare.go b/backend/pkg/util/version_compare.go new file mode 100644 index 00000000..c7e3d014 --- /dev/null +++ b/backend/pkg/util/version_compare.go @@ -0,0 +1,108 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package util + +import "strconv" + +func compareVersionNumbers(left, right VersionInfo) int { + maxLen := max(len(right.Numbers), len(left.Numbers)) + for index := range maxLen { + leftValue := 0 + rightValue := 0 + if index < len(left.Numbers) { + leftValue = left.Numbers[index] + } + if index < len(right.Numbers) { + rightValue = right.Numbers[index] + } + if leftValue < rightValue { + return -1 + } + if leftValue > rightValue { + return 1 + } + } + return 0 +} + +func compareGitDescribeDistance(left, right VersionInfo) int { + if left.GitDescribeDistance == right.GitDescribeDistance { + return 0 + } + if left.GitDescribeDistance < right.GitDescribeDistance { + return -1 + } + return 1 +} + +func compareGitDescribeTails(left, right VersionInfo) int { + maxLen := max(len(right.GitDescribeTail), len(left.GitDescribeTail)) + for index := range maxLen { + if index >= len(left.GitDescribeTail) { + return -1 + } + if index >= len(right.GitDescribeTail) { + return 1 + } + if left.GitDescribeTail[index] < right.GitDescribeTail[index] { + return -1 + } + if left.GitDescribeTail[index] > right.GitDescribeTail[index] { + return 1 + } + } + return 0 +} + +func comparePrereleaseIdentifiers(left, right VersionInfo) int { + if len(left.Prerelease) == 0 && len(right.Prerelease) == 0 { + return 0 + } + if len(left.Prerelease) == 0 { + return 1 + } + if len(right.Prerelease) == 0 { + return -1 + } + + maxLen := max(len(right.Prerelease), len(left.Prerelease)) + for index := range maxLen { + if index >= len(left.Prerelease) { + return -1 + } + if index >= len(right.Prerelease) { + return 1 + } + if result := comparePrereleasePart(left.Prerelease[index], right.Prerelease[index]); result != 0 { + return result + } + } + return 0 +} + +func comparePrereleasePart(leftPart, rightPart string) int { + leftNumber, leftErr := strconv.Atoi(leftPart) + rightNumber, rightErr := strconv.Atoi(rightPart) + switch { + case leftErr == nil && rightErr == nil: + if leftNumber < rightNumber { + return -1 + } + if leftNumber > rightNumber { + return 1 + } + case leftErr == nil: + return -1 + case rightErr == nil: + return 1 + default: + if leftPart < rightPart { + return -1 + } + if leftPart > rightPart { + return 1 + } + } + return 0 +}