feat(pkg/util): 补齐版本比较、网络与格式化通用助手

下游 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 干净。
This commit is contained in:
ryan
2026-08-30 00:36:13 +08:00
parent bad6fa785d
commit 8ff017b5e8
9 changed files with 556 additions and 0 deletions
+70
View File
@@ -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
}
+52
View File
@@ -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)
}
}
}
+45
View File
@@ -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()
}
+36
View File
@@ -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)
}
+79
View File
@@ -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
}
+15
View File
@@ -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)
}
}
}
+22
View File
@@ -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"
}
+129
View File
@@ -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)
}
+108
View File
@@ -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
}