feat: 停复机规则演练与终态自动关闭轮询
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 9m26s
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 9m26s
统一停复机规则转正的第一次发布:总开关仍关闭,现有停复机行为不变。 - 新增停复机规则演练:GET /stop-resume-rule/dry-run 按新规则对开机与停机卡只判断不执行,返回运营商账户 × 停机原因 × 判断结果汇总与分页明细,可假设通道实名管控配置;POST /stop-resume-rule/dry-run/export 导出明细(场景 stop_resume_rule_dry_run);仅超级管理员与平台账号 - 风险停机、已销户关闭轮询不再限于独立卡,只关该卡自身轮询,不级联设备 - 错误分类规则增加 auto_disable_polling,仅终态类别可勾选;停复机(含设备停复机)命中该规则时不重试、关闭该卡轮询、回写网关扩展状态并写审计 iot_card.terminal_disable_polling - 迁移 000250 加列与约束,并为"已销户""仅支持风险复机"置位
This commit is contained in:
181
internal/query/stopresumerule/dry_run.go
Normal file
181
internal/query/stopresumerule/dry_run.go
Normal file
@@ -0,0 +1,181 @@
|
||||
package stopresumerule
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/internal/domain/stopresume"
|
||||
"github.com/break/junhong_cmp_fiber/internal/model/dto"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/errors"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/middleware"
|
||||
)
|
||||
|
||||
// 明细分页默认值与上限。
|
||||
const (
|
||||
defaultPageSize = 20
|
||||
maxPageSize = 500
|
||||
)
|
||||
|
||||
// EnsureAllowed 校验演练与导出权限:仅超级管理员与平台账号。
|
||||
func EnsureAllowed(userType int) error {
|
||||
if userType == constants.UserTypeSuperAdmin || userType == constants.UserTypePlatform {
|
||||
return nil
|
||||
}
|
||||
return errors.New(errors.CodeForbidden, constants.PlatformManagementForbiddenMessage)
|
||||
}
|
||||
|
||||
// BuildFilter 把请求参数转换为演练筛选;假设开启与关闭的账户不得重叠。
|
||||
func BuildFilter(carrierID *uint, stopReason, assumeOn, assumeOff string) (Filter, error) {
|
||||
on, err := parseIDs(assumeOn, "assume_control_on")
|
||||
if err != nil {
|
||||
return Filter{}, err
|
||||
}
|
||||
off, err := parseIDs(assumeOff, "assume_control_off")
|
||||
if err != nil {
|
||||
return Filter{}, err
|
||||
}
|
||||
control := make(map[uint]bool, len(on)+len(off))
|
||||
for _, id := range on {
|
||||
control[id] = true
|
||||
}
|
||||
for _, id := range off {
|
||||
if control[id] {
|
||||
return Filter{}, errors.New(errors.CodeInvalidParam, "同一运营商账户不能同时假设开启和关闭实名管控")
|
||||
}
|
||||
control[id] = false
|
||||
}
|
||||
return Filter{CarrierID: carrierID, StopReason: strings.TrimSpace(stopReason), RealnameControl: control}, nil
|
||||
}
|
||||
|
||||
func parseIDs(raw, field string) ([]uint, error) {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return nil, nil
|
||||
}
|
||||
parts := strings.Split(raw, ",")
|
||||
ids := make([]uint, 0, len(parts))
|
||||
for _, part := range parts {
|
||||
part = strings.TrimSpace(part)
|
||||
if part == "" {
|
||||
continue
|
||||
}
|
||||
id, err := strconv.ParseUint(part, 10, 64)
|
||||
if err != nil || id == 0 {
|
||||
return nil, errors.New(errors.CodeInvalidParam, field+" 只能是逗号分隔的运营商账户ID")
|
||||
}
|
||||
ids = append(ids, uint(id))
|
||||
}
|
||||
return ids, nil
|
||||
}
|
||||
|
||||
type summaryKey struct {
|
||||
carrierID uint
|
||||
stopReason string
|
||||
result string
|
||||
reason string
|
||||
}
|
||||
|
||||
// DryRun 执行演练:全量分批判断,返回汇总与按 result 筛选后的分页明细。只读。
|
||||
func (q *Query) DryRun(ctx context.Context, req *dto.StopResumeRuleDryRunRequest) (*dto.StopResumeRuleDryRunResponse, error) {
|
||||
if err := EnsureAllowed(middleware.GetUserTypeFromContext(ctx)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
filter, err := BuildFilter(req.CarrierID, req.StopReason, req.AssumeControlOn, req.AssumeControlOff)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
page, size := req.Page, req.PageSize
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
if size < 1 {
|
||||
size = defaultPageSize
|
||||
}
|
||||
if size > maxPageSize {
|
||||
size = maxPageSize
|
||||
}
|
||||
offset := (page - 1) * size
|
||||
resp := &dto.StopResumeRuleDryRunResponse{Page: page, PageSize: size, Items: []dto.StopResumeRuleDryRunItem{}}
|
||||
counts := map[summaryKey]int64{}
|
||||
names := map[uint]string{}
|
||||
var matched int64
|
||||
err = q.Scan(ctx, filter, func(rows []Row) error {
|
||||
for index := range rows {
|
||||
row := &rows[index]
|
||||
resp.Totals.Total++
|
||||
switch row.Result {
|
||||
case stopresume.ResultWillResume:
|
||||
resp.Totals.WillResume++
|
||||
case stopresume.ResultWillStop:
|
||||
resp.Totals.WillStop++
|
||||
default:
|
||||
resp.Totals.NoAction++
|
||||
}
|
||||
counts[summaryKey{row.CarrierID, row.StopReason, row.Result, row.Reason}]++
|
||||
names[row.CarrierID] = row.CarrierName
|
||||
if req.Result != "" && row.Result != req.Result {
|
||||
continue
|
||||
}
|
||||
if matched >= int64(offset) && len(resp.Items) < size {
|
||||
resp.Items = append(resp.Items, ToItem(row))
|
||||
}
|
||||
matched++
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
resp.Total = matched
|
||||
resp.Summary = buildSummary(counts, names)
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
func buildSummary(counts map[summaryKey]int64, names map[uint]string) []dto.StopResumeRuleDryRunSummaryItem {
|
||||
items := make([]dto.StopResumeRuleDryRunSummaryItem, 0, len(counts))
|
||||
for key, count := range counts {
|
||||
items = append(items, dto.StopResumeRuleDryRunSummaryItem{
|
||||
CarrierID: key.carrierID, CarrierName: names[key.carrierID],
|
||||
StopReason: key.stopReason, StopReasonName: stopReasonName(key.stopReason),
|
||||
Result: key.result, ResultName: stopresume.ResultName(key.result),
|
||||
Reason: key.reason, ReasonName: stopresume.ReasonName(key.reason), Count: count,
|
||||
})
|
||||
}
|
||||
sort.Slice(items, func(i, j int) bool {
|
||||
a, b := items[i], items[j]
|
||||
if a.CarrierID != b.CarrierID {
|
||||
return a.CarrierID < b.CarrierID
|
||||
}
|
||||
if a.StopReason != b.StopReason {
|
||||
return a.StopReason < b.StopReason
|
||||
}
|
||||
if a.Result != b.Result {
|
||||
return a.Result < b.Result
|
||||
}
|
||||
return a.Reason < b.Reason
|
||||
})
|
||||
return items
|
||||
}
|
||||
|
||||
// ToItem 把演练行转换为接口明细。
|
||||
func ToItem(row *Row) dto.StopResumeRuleDryRunItem {
|
||||
return dto.StopResumeRuleDryRunItem{
|
||||
CardID: row.CardID, ICCID: row.ICCID, CarrierID: row.CarrierID, CarrierName: row.CarrierName,
|
||||
DeviceID: row.DeviceID, NetworkStatus: row.NetworkStatus,
|
||||
StopReason: row.StopReason, StopReasonName: stopReasonName(row.StopReason),
|
||||
CardCategory: row.CardCategory, RealnameOK: row.RealnameOK,
|
||||
Result: row.Result, ResultName: stopresume.ResultName(row.Result),
|
||||
Reason: row.Reason, ReasonName: stopresume.ReasonName(row.Reason),
|
||||
}
|
||||
}
|
||||
|
||||
// stopReasonName 返回当前停机原因名称;空停因显示为"无"。
|
||||
func stopReasonName(reason string) string {
|
||||
if strings.TrimSpace(reason) == "" {
|
||||
return "无"
|
||||
}
|
||||
return constants.AgentOpenAPIStopReasonName(reason)
|
||||
}
|
||||
329
internal/query/stopresumerule/query.go
Normal file
329
internal/query/stopresumerule/query.go
Normal file
@@ -0,0 +1,329 @@
|
||||
// Package stopresumerule 提供停复机规则演练的只读查询:按批读取卡与资产当前事实,按新规则判断目标动作。
|
||||
//
|
||||
// 演练不调用网关、不写任何卡、指令或调度状态;始终按新规则判断,不受停复机总开关影响;
|
||||
// 可传入假设的通道实名管控配置预演,实际配置不变。
|
||||
package stopresumerule
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
carrierthresholddomain "github.com/break/junhong_cmp_fiber/internal/domain/carrierthreshold"
|
||||
"github.com/break/junhong_cmp_fiber/internal/domain/stopresume"
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/errors"
|
||||
)
|
||||
|
||||
// batchSize 是演练每批读取的卡数。
|
||||
const batchSize = 1000
|
||||
|
||||
// StopReasonEmpty 是筛选"停机原因为空"时使用的取值。
|
||||
const StopReasonEmpty = "none"
|
||||
|
||||
// Filter 是演练筛选条件。
|
||||
type Filter struct {
|
||||
CarrierID *uint
|
||||
StopReason string // 为空不限;StopReasonEmpty 表示停机原因为空
|
||||
// RealnameControl 假设的通道实名管控:键为运营商账户 ID,值为是否开启;未列出的账户按实际配置。
|
||||
RealnameControl map[uint]bool
|
||||
}
|
||||
|
||||
// Row 是一张卡的演练结果。
|
||||
type Row struct {
|
||||
CardID uint
|
||||
ICCID string
|
||||
CarrierID uint
|
||||
CarrierName string
|
||||
DeviceID uint // 无有效绑定为 0
|
||||
NetworkStatus int
|
||||
StopReason string
|
||||
CardCategory string
|
||||
RealnameOK bool
|
||||
stopresume.Decision
|
||||
}
|
||||
|
||||
// Query 停复机规则演练查询。
|
||||
type Query struct {
|
||||
db *gorm.DB
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
// NewQuery 创建演练查询。
|
||||
func NewQuery(db *gorm.DB) *Query {
|
||||
return &Query{db: db, now: time.Now}
|
||||
}
|
||||
|
||||
// Scan 按卡 ID 升序分批演练全部符合筛选的开机与停机卡,每批回调一次。
|
||||
func (q *Query) Scan(ctx context.Context, filter Filter, fn func([]Row) error) error {
|
||||
carriers, err := q.loadCarriers(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var lastID uint
|
||||
for {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
var cards []model.IotCard
|
||||
if err := q.cardQuery(ctx, filter).Where("id > ?", lastID).Order("id ASC").Limit(batchSize).Find(&cards).Error; err != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "查询演练卡失败")
|
||||
}
|
||||
if len(cards) == 0 {
|
||||
return nil
|
||||
}
|
||||
lastID = cards[len(cards)-1].ID
|
||||
rows, err := q.evaluate(ctx, cards, carriers, filter)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := fn(rows); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Count 统计符合筛选的卡数(导出行数)。
|
||||
func (q *Query) Count(ctx context.Context, filter Filter) (int64, error) {
|
||||
var total int64
|
||||
if err := q.cardQuery(ctx, filter).Count(&total).Error; err != nil {
|
||||
return 0, errors.Wrap(errors.CodeDatabaseError, err, "统计演练卡数失败")
|
||||
}
|
||||
return total, nil
|
||||
}
|
||||
|
||||
// Page 按卡 ID 升序取 offset/limit 范围内的卡并演练,供导出分片使用。
|
||||
func (q *Query) Page(ctx context.Context, filter Filter, offset, limit int) ([]Row, error) {
|
||||
carriers, err := q.loadCarriers(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var cards []model.IotCard
|
||||
if err := q.cardQuery(ctx, filter).Order("id ASC").Offset(offset).Limit(limit).Find(&cards).Error; err != nil {
|
||||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询演练卡失败")
|
||||
}
|
||||
return q.evaluate(ctx, cards, carriers, filter)
|
||||
}
|
||||
|
||||
func (q *Query) cardQuery(ctx context.Context, filter Filter) *gorm.DB {
|
||||
query := q.db.WithContext(ctx).Model(&model.IotCard{}).
|
||||
Select("id", "iccid", "carrier_id", "network_status", "stop_reason", "enable_polling", "real_name_status",
|
||||
"card_category", "is_standalone").
|
||||
Where("network_status IN ?", []int{constants.NetworkStatusOffline, constants.NetworkStatusOnline})
|
||||
if filter.CarrierID != nil {
|
||||
query = query.Where("carrier_id = ?", *filter.CarrierID)
|
||||
}
|
||||
switch filter.StopReason {
|
||||
case "":
|
||||
case StopReasonEmpty:
|
||||
query = query.Where("COALESCE(stop_reason, '') = ''")
|
||||
default:
|
||||
query = query.Where("stop_reason = ?", filter.StopReason)
|
||||
}
|
||||
return query
|
||||
}
|
||||
|
||||
type carrierFacts struct {
|
||||
name string
|
||||
realnameControl bool
|
||||
resetDay int
|
||||
}
|
||||
|
||||
func (q *Query) loadCarriers(ctx context.Context) (map[uint]carrierFacts, error) {
|
||||
var carriers []model.Carrier
|
||||
if err := q.db.WithContext(ctx).Select("id", "carrier_name", "realname_control_enabled", "data_reset_day").
|
||||
Find(&carriers).Error; err != nil {
|
||||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询运营商账户失败")
|
||||
}
|
||||
result := make(map[uint]carrierFacts, len(carriers))
|
||||
for _, carrier := range carriers {
|
||||
result[carrier.ID] = carrierFacts{
|
||||
name: carrier.CarrierName, realnameControl: carrier.RealnameControlEnabled == 1, resetDay: carrier.DataResetDay,
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// evaluate 批量加载一批卡的绑定、设备轮询开关、套餐与通道阈值锁,再逐卡判断。
|
||||
func (q *Query) evaluate(ctx context.Context, cards []model.IotCard, carriers map[uint]carrierFacts, filter Filter) ([]Row, error) {
|
||||
if len(cards) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
cardIDs := make([]uint, 0, len(cards))
|
||||
for _, card := range cards {
|
||||
cardIDs = append(cardIDs, card.ID)
|
||||
}
|
||||
deviceOf, err := q.activeBindings(ctx, cards)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
deviceIDs := uniqueValues(deviceOf)
|
||||
devicePolling, err := q.devicePolling(ctx, deviceIDs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cardPackages, err := q.packages(ctx, "iot_card_id", cardIDs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
devicePackages, err := q.packages(ctx, "device_id", deviceIDs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
locked, err := q.lockedCards(ctx, cardIDs, carriers)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rows := make([]Row, 0, len(cards))
|
||||
for _, card := range cards {
|
||||
deviceID := deviceOf[card.ID]
|
||||
facts := packageFactsOf(cardPackages[card.ID])
|
||||
suspended := !card.EnablePolling
|
||||
if deviceID != 0 {
|
||||
facts = packageFactsOf(devicePackages[deviceID])
|
||||
if enabled, ok := devicePolling[deviceID]; ok && !enabled {
|
||||
suspended = true
|
||||
}
|
||||
}
|
||||
carrier, known := carriers[card.CarrierID]
|
||||
control := carrier.realnameControl
|
||||
if assumed, ok := filter.RealnameControl[card.CarrierID]; ok {
|
||||
control = assumed
|
||||
}
|
||||
verified := card.RealNameStatus == constants.RealNameStatusVerified
|
||||
decision := stopresume.Decide(stopresume.Facts{
|
||||
NetworkStatus: card.NetworkStatus, PollingSuspended: suspended,
|
||||
HasValidPackage: facts.hasValid, TrafficExhausted: facts.exhausted,
|
||||
RealnameVerified: verified, CarrierKnown: known, RealnameControl: control,
|
||||
ThresholdLocked: locked[card.ID], CurrentStopReason: card.StopReason,
|
||||
})
|
||||
rows = append(rows, Row{
|
||||
CardID: card.ID, ICCID: card.ICCID, CarrierID: card.CarrierID, CarrierName: carrier.name, DeviceID: deviceID,
|
||||
NetworkStatus: card.NetworkStatus, StopReason: card.StopReason, CardCategory: card.CardCategory,
|
||||
RealnameOK: !control || verified, Decision: decision,
|
||||
})
|
||||
}
|
||||
return rows, nil
|
||||
}
|
||||
|
||||
// activeBindings 返回非独立卡的有效绑定设备;与停复机评估一致,独立卡不查绑定。
|
||||
func (q *Query) activeBindings(ctx context.Context, cards []model.IotCard) (map[uint]uint, error) {
|
||||
bound := make([]uint, 0, len(cards))
|
||||
for _, card := range cards {
|
||||
if !card.IsStandalone {
|
||||
bound = append(bound, card.ID)
|
||||
}
|
||||
}
|
||||
result := make(map[uint]uint, len(bound))
|
||||
if len(bound) == 0 {
|
||||
return result, nil
|
||||
}
|
||||
var bindings []model.DeviceSimBinding
|
||||
if err := q.db.WithContext(ctx).Select("iot_card_id", "device_id").
|
||||
Where("iot_card_id IN ? AND bind_status = ?", bound, constants.BindStatusBound).
|
||||
Find(&bindings).Error; err != nil {
|
||||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询卡绑定设备失败")
|
||||
}
|
||||
for _, binding := range bindings {
|
||||
result[binding.IotCardID] = binding.DeviceID
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (q *Query) devicePolling(ctx context.Context, deviceIDs []uint) (map[uint]bool, error) {
|
||||
result := make(map[uint]bool, len(deviceIDs))
|
||||
if len(deviceIDs) == 0 {
|
||||
return result, nil
|
||||
}
|
||||
var devices []model.Device
|
||||
if err := q.db.WithContext(ctx).Select("id", "enable_polling").Where("id IN ?", deviceIDs).Find(&devices).Error; err != nil {
|
||||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询设备轮询状态失败")
|
||||
}
|
||||
for _, device := range devices {
|
||||
result[device.ID] = device.EnablePolling
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// packages 读取载体生效中与已用完的套餐,按载体分组。
|
||||
func (q *Query) packages(ctx context.Context, column string, ids []uint) (map[uint][]model.PackageUsage, error) {
|
||||
result := make(map[uint][]model.PackageUsage, len(ids))
|
||||
if len(ids) == 0 {
|
||||
return result, nil
|
||||
}
|
||||
var usages []model.PackageUsage
|
||||
if err := q.db.WithContext(ctx).Where(column+" IN ?", ids).
|
||||
Where("status IN ?", []int{constants.PackageUsageStatusActive, constants.PackageUsageStatusDepleted}).
|
||||
Find(&usages).Error; err != nil {
|
||||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询套餐使用记录失败")
|
||||
}
|
||||
for _, usage := range usages {
|
||||
key := usage.IotCardID
|
||||
if column == "device_id" {
|
||||
key = usage.DeviceID
|
||||
}
|
||||
result[key] = append(result[key], usage)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
type packageFacts struct {
|
||||
hasValid bool
|
||||
exhausted bool
|
||||
}
|
||||
|
||||
// packageFactsOf 与评估主流程同口径:有生效中套餐即有套餐;生效或已用完的套餐全部达到上限才算流量用完。
|
||||
func packageFactsOf(usages []model.PackageUsage) packageFacts {
|
||||
facts := packageFacts{}
|
||||
if len(usages) == 0 {
|
||||
return facts
|
||||
}
|
||||
facts.exhausted = true
|
||||
for index := range usages {
|
||||
if usages[index].Status == constants.PackageUsageStatusActive {
|
||||
facts.hasValid = true
|
||||
}
|
||||
if !usages[index].IsTrafficDepleted() {
|
||||
facts.exhausted = false
|
||||
}
|
||||
}
|
||||
return facts
|
||||
}
|
||||
|
||||
// lockedCards 返回持本周期通道阈值锁的卡;与停复机评估同口径,账户不存在或重置日非法时按仍生效处理。
|
||||
func (q *Query) lockedCards(ctx context.Context, cardIDs []uint, carriers map[uint]carrierFacts) (map[uint]bool, error) {
|
||||
result := make(map[uint]bool)
|
||||
var locks []model.CarrierTrafficThresholdLock
|
||||
if err := q.db.WithContext(ctx).Select("card_id", "carrier_id", "period_start").
|
||||
Where("card_id IN ? AND status = ?", cardIDs, carrierthresholddomain.LockStatusLocked).
|
||||
Find(&locks).Error; err != nil {
|
||||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询通道阈值锁失败")
|
||||
}
|
||||
now := q.now()
|
||||
for _, lock := range locks {
|
||||
carrier, ok := carriers[lock.CarrierID]
|
||||
if !ok {
|
||||
result[lock.CardID] = true
|
||||
continue
|
||||
}
|
||||
periodStart, err := carrierthresholddomain.PeriodStart(now, carrier.resetDay)
|
||||
if err != nil || lock.PeriodStart.Equal(periodStart) {
|
||||
result[lock.CardID] = true
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func uniqueValues(m map[uint]uint) []uint {
|
||||
seen := make(map[uint]bool, len(m))
|
||||
result := make([]uint, 0, len(m))
|
||||
for _, value := range m {
|
||||
if value != 0 && !seen[value] {
|
||||
seen[value] = true
|
||||
result = append(result, value)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
Reference in New Issue
Block a user