Files
lingniu-vehicle-ingest/vehicle-data-platform/apps/api/internal/platform/access.go

673 lines
23 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 platform
import (
"context"
"fmt"
"sort"
"strings"
"time"
)
type accessEvidenceStore interface {
AccessEvidence(context.Context) ([]AccessEvidenceRow, error)
}
type accessThresholdStore interface {
AccessThresholds(context.Context) (AccessThresholdConfig, error)
SaveAccessThresholds(context.Context, AccessThresholdUpdate) (AccessThresholdConfig, error)
}
type accessUnresolvedIdentityStore interface {
AccessUnresolvedIdentities(context.Context, AccessUnresolvedIdentityQuery) (Page[AccessUnresolvedIdentity], error)
}
func defaultAccessThresholds(now time.Time) AccessThresholdConfig {
return AccessThresholdConfig{
Version: 1,
DefaultThresholdSec: 300,
DelayThresholdSec: 30,
LongOfflineSec: 1800,
Protocols: []AccessProtocolThreshold{
{Protocol: "GB32960", ThresholdSec: 300},
{Protocol: "JT808", ThresholdSec: 300},
{Protocol: "YUTONG_MQTT", ThresholdSec: 600},
},
UpdatedBy: "system",
UpdatedAt: now.Format(time.RFC3339),
}
}
func (s *Service) AccessThresholds(ctx context.Context) (AccessThresholdConfig, error) {
store, ok := s.store.(accessThresholdStore)
if !ok {
return defaultAccessThresholds(time.Now()), nil
}
config, err := store.AccessThresholds(ctx)
if err != nil {
return AccessThresholdConfig{}, err
}
return normalizeAccessThresholdConfig(config, time.Now()), nil
}
func (s *Service) UpdateAccessThresholds(ctx context.Context, update AccessThresholdUpdate) (AccessThresholdConfig, error) {
if err := validateAccessThresholdUpdate(update); err != nil {
return AccessThresholdConfig{}, err
}
store, ok := s.store.(accessThresholdStore)
if !ok {
return AccessThresholdConfig{}, clientError{Code: "ACCESS_THRESHOLD_READ_ONLY", Message: "当前存储不支持更新接入阈值"}
}
update.Actor = strings.TrimSpace(update.Actor)
if update.Actor == "" {
update.Actor = "platform-admin"
}
return store.SaveAccessThresholds(ctx, update)
}
func (s *Service) AccessVehicles(ctx context.Context, query AccessQuery) (Page[AccessVehicleRow], error) {
rows, config, err := s.accessRows(ctx, query)
if err != nil {
return Page[AccessVehicleRow]{}, err
}
_ = config
limit := query.Limit
if limit <= 0 {
limit = 50
}
if limit > 200 {
limit = 200
}
offset := query.Offset
if offset < 0 {
offset = 0
}
total := len(rows)
if offset >= total {
return Page[AccessVehicleRow]{Items: []AccessVehicleRow{}, Total: total, Limit: limit, Offset: offset}, nil
}
end := offset + limit
if end > total {
end = total
}
return Page[AccessVehicleRow]{Items: append([]AccessVehicleRow(nil), rows[offset:end]...), Total: total, Limit: limit, Offset: offset}, nil
}
func (s *Service) AccessUnresolvedIdentities(ctx context.Context, query AccessUnresolvedIdentityQuery) (Page[AccessUnresolvedIdentity], error) {
store, ok := s.store.(accessUnresolvedIdentityStore)
if !ok {
return Page[AccessUnresolvedIdentity]{}, fmt.Errorf("store does not provide unresolved identity evidence")
}
query.Keyword = strings.TrimSpace(query.Keyword)
query.Protocol = strings.ToUpper(strings.TrimSpace(query.Protocol))
if query.Protocol != "" && query.Protocol != "JT808" {
return Page[AccessUnresolvedIdentity]{Items: []AccessUnresolvedIdentity{}, Limit: 50}, nil
}
if query.Limit <= 0 {
query.Limit = 50
}
if query.Limit > 200 {
query.Limit = 200
}
if query.Offset < 0 {
query.Offset = 0
}
return store.AccessUnresolvedIdentities(ctx, query)
}
func (s *Service) AccessSummary(ctx context.Context, query AccessQuery) (AccessSummary, error) {
rows, config, err := s.accessRows(ctx, query)
if err != nil {
return AccessSummary{}, err
}
now := time.Now()
result := AccessSummary{TotalVehicles: len(rows), AsOf: now.Format(time.RFC3339), ThresholdVersion: config.Version}
protocols := map[string]*AccessDistribution{}
oems := map[string]*AccessDistribution{}
for _, row := range rows {
switch row.ConnectionState {
case "healthy":
result.HealthyVehicles++
case "incomplete":
result.IncompleteVehicles++
case "degraded":
result.DegradedVehicles++
}
switch row.OnlineState {
case "online":
result.OnlineVehicles++
case "offline":
result.OfflineVehicles++
if row.FreshnessSec != nil && *row.FreshnessSec >= config.LongOfflineSec {
result.LongOfflineVehicles++
}
case "never_reported":
result.NeverReported++
default:
result.UnknownVehicles++
}
if row.DelayAbnormal {
result.DelayAbnormal++
}
if reportedOnDay(row.LatestReceivedAt, now) {
result.ReportedToday++
}
for _, status := range row.ProtocolStatuses {
if status.Connected {
addAccessDistribution(protocols, status.Protocol, status.OnlineState == "online")
}
}
addAccessDistribution(oems, firstNonEmpty(row.OEM, "未维护"), row.OnlineState == "online")
}
if result.TotalVehicles > 0 {
result.OnlineRate = float64(result.OnlineVehicles) / float64(result.TotalVehicles) * 100
}
result.Protocols = sortedAccessDistributions(protocols)
result.OEMs = sortedAccessDistributions(oems)
return result, nil
}
func (s *Service) accessRows(ctx context.Context, query AccessQuery) ([]AccessVehicleRow, AccessThresholdConfig, error) {
if err := validateAccessQuery(query); err != nil {
return nil, AccessThresholdConfig{}, err
}
store, ok := s.store.(accessEvidenceStore)
if !ok {
return nil, AccessThresholdConfig{}, fmt.Errorf("store does not provide access evidence")
}
config, err := s.AccessThresholds(ctx)
if err != nil {
return nil, AccessThresholdConfig{}, err
}
evidence, err := store.AccessEvidence(ctx)
if err != nil {
return nil, AccessThresholdConfig{}, err
}
now := time.Now()
byVIN := make(map[string][]AccessEvidenceRow, len(evidence))
order := make([]string, 0, len(evidence))
for _, item := range evidence {
vin := strings.TrimSpace(item.VIN)
if _, exists := byVIN[vin]; !exists {
order = append(order, vin)
}
byVIN[vin] = append(byVIN[vin], item)
}
rows := make([]AccessVehicleRow, 0, len(byVIN))
for _, vin := range order {
row := buildAccessVehicleGroup(byVIN[vin], config, now)
if keepAccessRow(row, query) {
rows = append(rows, row)
}
}
sort.SliceStable(rows, func(i, j int) bool {
left, right := accessStateRank(rows[i].OnlineState), accessStateRank(rows[j].OnlineState)
if left != right {
return left < right
}
if rows[i].LatestReceivedAt != rows[j].LatestReceivedAt {
return rows[i].LatestReceivedAt > rows[j].LatestReceivedAt
}
return rows[i].VIN < rows[j].VIN
})
return rows, config, nil
}
func buildAccessVehicleGroup(items []AccessEvidenceRow, config AccessThresholdConfig, now time.Time) AccessVehicleRow {
if len(items) == 0 {
return AccessVehicleRow{}
}
base := items[0]
row := AccessVehicleRow{
VIN: strings.TrimSpace(base.VIN),
Plate: strings.TrimSpace(base.Plate),
OEM: strings.TrimSpace(base.OEM),
Model: strings.TrimSpace(base.Model),
Company: strings.TrimSpace(base.Company),
ExpectedProtocols: append([]string(nil), canonicalVehicleProtocols...),
ActualProtocols: []string{},
MissingProtocols: []string{},
ProtocolStatuses: []AccessProtocolStatus{},
ExpectationEvidence: "平台标准接入基线GB32960 / JT808 / YUTONG_MQTT",
ConnectionState: "not_connected",
OnlineState: "never_reported",
FirstSeenEvidence: "尚未形成任何协议接入快照",
ReportIntervalProof: "需要连续上报样本后才能计算",
}
byProtocol := make(map[string]AccessEvidenceRow, len(items))
for _, item := range items {
protocol := strings.ToUpper(strings.TrimSpace(item.Protocol))
if protocol != "" {
byProtocol[protocol] = item
}
if row.Plate == "" {
row.Plate = strings.TrimSpace(item.Plate)
}
if row.OEM == "" {
row.OEM = strings.TrimSpace(item.OEM)
}
if row.Model == "" {
row.Model = strings.TrimSpace(item.Model)
}
if row.Company == "" {
row.Company = strings.TrimSpace(item.Company)
}
}
onlineCount := 0
unknownCount := 0
connectedCount := 0
for _, protocol := range canonicalVehicleProtocols {
item, connected := byProtocol[protocol]
if !connected {
threshold := accessProtocolThreshold(config, protocol)
row.MissingProtocols = append(row.MissingProtocols, protocol)
row.ProtocolStatuses = append(row.ProtocolStatuses, AccessProtocolStatus{
Protocol: protocol, Expected: true, Connected: false, OnlineState: "never_reported", ThresholdSec: threshold,
FirstSeenEvidence: "应接协议尚未形成实时快照", ReportIntervalEvidence: "尚无接收样本",
})
continue
}
source := buildAccessVehicleRow(item, config, now)
connectedCount++
row.ActualProtocols = append(row.ActualProtocols, protocol)
if source.OnlineState == "online" {
onlineCount++
}
if source.DelayAbnormal {
row.DelayAbnormal = true
}
row.ProtocolStatuses = append(row.ProtocolStatuses, AccessProtocolStatus{
Protocol: protocol, Expected: true, Connected: true, Provider: source.Provider,
FirstSeenAt: source.FirstSeenAt, LatestEventAt: source.LatestEventAt, LatestReceivedAt: source.LatestReceivedAt,
ReportIntervalSec: source.ReportIntervalSec, DataDelaySec: source.DataDelaySec, FreshnessSec: source.FreshnessSec,
OnlineState: source.OnlineState, ThresholdSec: source.ThresholdSec, DelayAbnormal: source.DelayAbnormal,
FirstSeenEvidence: source.FirstSeenEvidence, ReportIntervalEvidence: source.ReportIntervalProof,
Longitude: item.Longitude, Latitude: item.Latitude, SpeedKmh: item.SpeedKmh, SOCPercent: item.SOCPercent, TotalMileageKm: item.TotalMileageKm, DailyMileageKm: item.DailyMileageKm,
})
if row.FirstSeenAt == "" || source.FirstSeenAt != "" && source.FirstSeenAt < row.FirstSeenAt {
row.FirstSeenAt = source.FirstSeenAt
row.FirstSeenEvidence = source.FirstSeenEvidence
row.FirstSeenSource = source.FirstSeenSource
}
if row.LatestReceivedAt == "" || source.LatestReceivedAt > row.LatestReceivedAt {
copyAccessPrimaryFields(&row, source)
}
}
for protocol, item := range byProtocol {
if containsString(canonicalVehicleProtocols, protocol) {
continue
}
source := buildAccessVehicleRow(item, config, now)
row.ActualProtocols = append(row.ActualProtocols, protocol)
if source.OnlineState == "online" {
onlineCount++
}
if source.OnlineState == "unknown" {
unknownCount++
}
row.ProtocolStatuses = append(row.ProtocolStatuses, AccessProtocolStatus{
Protocol: protocol, Expected: false, Connected: true, Provider: source.Provider,
FirstSeenAt: source.FirstSeenAt, LatestEventAt: source.LatestEventAt, LatestReceivedAt: source.LatestReceivedAt,
ReportIntervalSec: source.ReportIntervalSec, DataDelaySec: source.DataDelaySec, FreshnessSec: source.FreshnessSec,
OnlineState: source.OnlineState, ThresholdSec: source.ThresholdSec, DelayAbnormal: source.DelayAbnormal,
FirstSeenEvidence: source.FirstSeenEvidence, ReportIntervalEvidence: source.ReportIntervalProof,
Longitude: item.Longitude, Latitude: item.Latitude, SpeedKmh: item.SpeedKmh, SOCPercent: item.SOCPercent, TotalMileageKm: item.TotalMileageKm, DailyMileageKm: item.DailyMileageKm,
})
if row.LatestReceivedAt == "" || source.LatestReceivedAt > row.LatestReceivedAt {
copyAccessPrimaryFields(&row, source)
}
}
switch {
case connectedCount == 0 && len(row.ActualProtocols) == 0:
row.ConnectionState = "not_connected"
row.OnlineState = "never_reported"
case connectedCount == len(canonicalVehicleProtocols) && onlineCount == connectedCount:
row.ConnectionState = "healthy"
row.OnlineState = "online"
case onlineCount == 0 && unknownCount > 0:
row.ConnectionState = "incomplete"
row.OnlineState = "unknown"
case onlineCount == 0:
row.ConnectionState = "offline"
row.OnlineState = "offline"
case connectedCount < len(canonicalVehicleProtocols):
row.ConnectionState = "incomplete"
row.OnlineState = "online"
default:
row.ConnectionState = "degraded"
row.OnlineState = "online"
}
return row
}
func accessProtocolThreshold(config AccessThresholdConfig, protocol string) int {
for _, override := range config.Protocols {
if strings.EqualFold(override.Protocol, protocol) {
return override.ThresholdSec
}
}
return config.DefaultThresholdSec
}
func copyAccessPrimaryFields(target *AccessVehicleRow, source AccessVehicleRow) {
target.Protocol = source.Protocol
target.Provider = source.Provider
target.Source = source.Source
target.LatestEventAt = source.LatestEventAt
target.LatestReceivedAt = source.LatestReceivedAt
target.ReportIntervalSec = source.ReportIntervalSec
target.DataDelaySec = source.DataDelaySec
target.FreshnessSec = source.FreshnessSec
target.ThresholdSec = source.ThresholdSec
target.LatestMessageType = source.LatestMessageType
target.LatestEventID = source.LatestEventID
target.LatestError = source.LatestError
target.ReportIntervalProof = source.ReportIntervalProof
target.ReportSampleCount = source.ReportSampleCount
}
func buildAccessVehicleRow(item AccessEvidenceRow, config AccessThresholdConfig, now time.Time) AccessVehicleRow {
threshold := config.DefaultThresholdSec
for _, override := range config.Protocols {
if strings.EqualFold(strings.TrimSpace(override.Protocol), strings.TrimSpace(item.Protocol)) {
threshold = override.ThresholdSec
break
}
}
row := AccessVehicleRow{
VIN: strings.TrimSpace(item.VIN),
Plate: strings.TrimSpace(item.Plate),
OEM: strings.TrimSpace(item.OEM),
Model: strings.TrimSpace(item.Model),
Company: strings.TrimSpace(item.Company),
Protocol: strings.TrimSpace(item.Protocol),
Provider: strings.TrimSpace(item.Provider),
Source: strings.TrimSpace(item.Source),
FirstSeenAt: normalizeAccessTime(item.FirstSeenAt),
LatestEventAt: normalizeAccessTime(item.LatestEventAt),
LatestReceivedAt: normalizeAccessTime(item.LatestReceivedAt),
ReportIntervalSec: item.ReportIntervalSec,
ThresholdSec: threshold,
LatestMessageType: firstNonEmpty(strings.TrimSpace(item.LatestMessageType), accessMessageType(item.Protocol)),
LatestEventID: strings.TrimSpace(item.LatestEventID),
LatestError: strings.TrimSpace(item.LatestError),
FirstSeenEvidence: strings.TrimSpace(item.FirstSeenEvidence),
FirstSeenSource: strings.TrimSpace(item.FirstSeenSource),
ReportIntervalProof: strings.TrimSpace(item.ReportIntervalProof),
ReportSampleCount: item.ReportSampleCount,
}
if row.FirstSeenEvidence == "" {
row.FirstSeenEvidence = "现有实时快照不保存首次接入时间"
}
if row.ReportIntervalProof == "" {
row.ReportIntervalProof = "需要连续上报样本后才能计算"
}
eventAt, eventOK := parseAccessTime(item.LatestEventAt)
receivedAt, receivedOK := parseAccessTime(item.LatestReceivedAt)
if eventOK && receivedOK {
delay := int(receivedAt.Sub(eventAt).Seconds())
row.DataDelaySec = &delay
row.DelayAbnormal = delay < 0 || delay > config.DelayThresholdSec
if delay < 0 && row.LatestError == "" {
row.LatestError = "接收时间早于事件时间"
}
}
latest, latestOK := receivedAt, receivedOK
if !latestOK {
latest, latestOK = parseAccessTime(item.LatestUpdatedAt)
}
switch {
case strings.TrimSpace(item.Protocol) == "" && !latestOK && !eventOK:
row.OnlineState = "never_reported"
case !latestOK:
row.OnlineState = "unknown"
if row.LatestError == "" {
row.LatestError = "缺少可解析的接收时间"
}
default:
freshness := int(now.Sub(latest).Seconds())
if freshness < 0 {
freshness = 0
}
row.FreshnessSec = &freshness
if freshness <= threshold {
row.OnlineState = "online"
} else {
row.OnlineState = "offline"
}
}
return row
}
func keepAccessRow(row AccessVehicleRow, query AccessQuery) bool {
keyword := strings.ToLower(strings.TrimSpace(query.Keyword))
if keyword != "" && !strings.Contains(strings.ToLower(row.VIN), keyword) && !strings.Contains(strings.ToLower(row.Plate), keyword) && !strings.Contains(strings.ToLower(row.OEM), keyword) && !strings.Contains(strings.ToLower(row.Model), keyword) && !strings.Contains(strings.ToLower(row.Company), keyword) {
return false
}
if value := strings.TrimSpace(query.Protocol); value != "" && !containsString(row.ActualProtocols, value) {
return false
}
if value := strings.TrimSpace(query.OEM); value != "" && !strings.EqualFold(value, row.OEM) {
return false
}
if value := strings.ToLower(strings.TrimSpace(query.Model)); value != "" && !strings.Contains(strings.ToLower(row.Model), value) {
return false
}
if value := strings.ToLower(strings.TrimSpace(query.Provider)); value != "" {
matched := false
for _, status := range row.ProtocolStatuses {
if strings.Contains(strings.ToLower(status.Provider), value) {
matched = true
break
}
}
if !matched {
return false
}
}
if !accessTimeMatches(row.FirstSeenAt, query.FirstSeenFrom, query.FirstSeenTo) || !accessTimeMatches(row.LatestReceivedAt, query.LatestSeenFrom, query.LatestSeenTo) {
return false
}
if value := strings.TrimSpace(query.OnlineState); value != "" && value != "all" && value != row.OnlineState {
return false
}
if value := strings.TrimSpace(query.ConnectionState); value != "" && value != "all" {
if value == "attention" {
if row.ConnectionState == "healthy" {
return false
}
} else if value != row.ConnectionState {
return false
}
}
switch strings.TrimSpace(query.DelayState) {
case "abnormal":
return row.DelayAbnormal
case "normal":
return row.DataDelaySec != nil && !row.DelayAbnormal
}
return true
}
func validateAccessQuery(query AccessQuery) error {
for _, item := range []struct{ name, from, to string }{{"首次接入", query.FirstSeenFrom, query.FirstSeenTo}, {"最新上报", query.LatestSeenFrom, query.LatestSeenTo}} {
start, startOK := parseAccessFilterTime(item.from)
end, endOK := parseAccessFilterTime(item.to)
if strings.TrimSpace(item.from) != "" && !startOK || strings.TrimSpace(item.to) != "" && !endOK {
return clientError{Code: "ACCESS_TIME_INVALID", Message: item.name + "时间格式无效"}
}
if startOK && endOK && start.After(end) {
return clientError{Code: "ACCESS_TIME_RANGE_INVALID", Message: item.name + "开始时间不能晚于结束时间"}
}
}
return nil
}
func parseAccessFilterTime(value string) (time.Time, bool) {
if strings.TrimSpace(value) == "" {
return time.Time{}, false
}
if parsed, ok := parseTrackRequestTime(value); ok {
return parsed, true
}
return parseAccessTime(value)
}
func accessTimeMatches(value, from, to string) bool {
if strings.TrimSpace(from) == "" && strings.TrimSpace(to) == "" {
return true
}
actual, ok := parseAccessTime(value)
if !ok {
return false
}
if start, ok := parseAccessFilterTime(from); ok && actual.Before(start) {
return false
}
if end, ok := parseAccessFilterTime(to); ok && actual.After(end) {
return false
}
return true
}
func validateAccessThresholdUpdate(update AccessThresholdUpdate) error {
if update.Version <= 0 {
return clientError{Code: "ACCESS_THRESHOLD_VERSION_REQUIRED", Message: "阈值版本不能为空"}
}
if update.DefaultThresholdSec < 30 || update.DefaultThresholdSec > 86400 {
return clientError{Code: "ACCESS_THRESHOLD_INVALID", Message: "全局在线阈值必须在 30 秒到 24 小时之间"}
}
if update.DelayThresholdSec < 1 || update.DelayThresholdSec > 3600 {
return clientError{Code: "ACCESS_DELAY_THRESHOLD_INVALID", Message: "延迟阈值必须在 1 秒到 1 小时之间"}
}
if update.LongOfflineSec < update.DefaultThresholdSec || update.LongOfflineSec > 604800 {
return clientError{Code: "ACCESS_LONG_OFFLINE_INVALID", Message: "长离线阈值必须不小于在线阈值且不超过 7 天"}
}
seen := map[string]struct{}{}
for _, item := range update.Protocols {
protocol := strings.ToUpper(strings.TrimSpace(item.Protocol))
if protocol == "" || item.ThresholdSec < 30 || item.ThresholdSec > 86400 {
return clientError{Code: "ACCESS_PROTOCOL_THRESHOLD_INVALID", Message: "协议阈值必须包含协议名且位于 30 秒到 24 小时之间"}
}
if _, exists := seen[protocol]; exists {
return clientError{Code: "ACCESS_PROTOCOL_THRESHOLD_DUPLICATED", Message: "协议阈值不能重复"}
}
seen[protocol] = struct{}{}
}
return nil
}
func normalizeAccessThresholdConfig(config AccessThresholdConfig, now time.Time) AccessThresholdConfig {
defaults := defaultAccessThresholds(now)
if config.Version <= 0 {
return defaults
}
if config.DefaultThresholdSec <= 0 {
config.DefaultThresholdSec = defaults.DefaultThresholdSec
}
if config.DelayThresholdSec <= 0 {
config.DelayThresholdSec = defaults.DelayThresholdSec
}
if config.LongOfflineSec <= 0 {
config.LongOfflineSec = defaults.LongOfflineSec
}
if config.Protocols == nil {
config.Protocols = []AccessProtocolThreshold{}
}
if config.Audit == nil {
config.Audit = []AccessThresholdAudit{}
}
return config
}
func parseAccessTime(value string) (time.Time, bool) {
value = strings.TrimSpace(value)
if value == "" {
return time.Time{}, false
}
for _, layout := range []string{time.RFC3339Nano, time.RFC3339} {
if parsed, err := time.Parse(layout, value); err == nil {
return parsed, true
}
}
for _, layout := range []string{"2006-01-02 15:04:05.000", "2006-01-02 15:04:05"} {
if parsed, err := time.ParseInLocation(layout, value, time.Local); err == nil {
return parsed, true
}
}
return time.Time{}, false
}
func normalizeAccessTime(value string) string {
parsed, ok := parseAccessTime(value)
if !ok {
return ""
}
return parsed.Format(time.RFC3339)
}
func accessMessageType(protocol string) string {
switch strings.ToUpper(strings.TrimSpace(protocol)) {
case "GB32960":
return "实时信息上报"
case "JT808":
return "位置信息汇报"
case "YUTONG_MQTT":
return "实时遥测"
default:
return ""
}
}
func accessStateRank(state string) int {
switch state {
case "online":
return 0
case "offline":
return 1
case "never_reported":
return 2
default:
return 3
}
}
func reportedOnDay(value string, day time.Time) bool {
parsed, ok := parseAccessTime(value)
if !ok {
return false
}
year, month, date := parsed.In(day.Location()).Date()
wantYear, wantMonth, wantDate := day.Date()
return year == wantYear && month == wantMonth && date == wantDate
}
func addAccessDistribution(items map[string]*AccessDistribution, name string, online bool) {
item := items[name]
if item == nil {
item = &AccessDistribution{Name: name}
items[name] = item
}
item.Total++
if online {
item.Online++
}
}
func sortedAccessDistributions(items map[string]*AccessDistribution) []AccessDistribution {
result := make([]AccessDistribution, 0, len(items))
for _, item := range items {
value := *item
if value.Total > 0 {
value.OnlineRate = float64(value.Online) / float64(value.Total) * 100
}
result = append(result, value)
}
sort.Slice(result, func(i, j int) bool {
if result[i].Total != result[j].Total {
return result[i].Total > result[j].Total
}
return result[i].Name < result[j].Name
})
return result
}