// Package asset 提供统一的资产查询与操作服务 // 资产包含两种类型:IoT卡(card)和设备(device) // 支持资产解析、实时状态查询、网关刷新、套餐查询等功能 package asset import ( "context" "sort" "time" "github.com/break/junhong_cmp_fiber/internal/model" "github.com/break/junhong_cmp_fiber/internal/model/dto" "github.com/break/junhong_cmp_fiber/internal/store/postgres" "github.com/break/junhong_cmp_fiber/pkg/constants" "github.com/break/junhong_cmp_fiber/pkg/errors" "github.com/break/junhong_cmp_fiber/pkg/logger" "github.com/break/junhong_cmp_fiber/pkg/middleware" "github.com/redis/go-redis/v9" "go.uber.org/zap" "gorm.io/gorm" ) // IotCardRefresher 用于调用 RefreshCardDataFromGateway,避免循环依赖 type IotCardRefresher interface { RefreshCardDataFromGateway(ctx context.Context, iccid string) error } // Service 资产查询与操作服务 type Service struct { db *gorm.DB deviceStore *postgres.DeviceStore iotCardStore *postgres.IotCardStore packageUsageStore *postgres.PackageUsageStore packageStore *postgres.PackageStore packageSeriesStore *postgres.PackageSeriesStore deviceSimBindingStore *postgres.DeviceSimBindingStore shopStore *postgres.ShopStore redis *redis.Client iotCardService IotCardRefresher } // New 创建资产服务实例 func New( db *gorm.DB, deviceStore *postgres.DeviceStore, iotCardStore *postgres.IotCardStore, packageUsageStore *postgres.PackageUsageStore, packageStore *postgres.PackageStore, packageSeriesStore *postgres.PackageSeriesStore, deviceSimBindingStore *postgres.DeviceSimBindingStore, shopStore *postgres.ShopStore, redisClient *redis.Client, iotCardService IotCardRefresher, ) *Service { return &Service{ db: db, deviceStore: deviceStore, iotCardStore: iotCardStore, packageUsageStore: packageUsageStore, packageStore: packageStore, packageSeriesStore: packageSeriesStore, deviceSimBindingStore: deviceSimBindingStore, shopStore: shopStore, redis: redisClient, iotCardService: iotCardService, } } // Resolve 通过任意标识符解析资产 // 优先匹配设备(virtual_no/imei/sn),未命中则匹配卡(virtual_no/iccid/msisdn) func (s *Service) Resolve(ctx context.Context, identifier string) (*dto.AssetResolveResponse, error) { // 先查 Device device, err := s.deviceStore.GetByIdentifier(ctx, identifier) if err == nil && device != nil { return s.buildDeviceResolveResponse(ctx, device) } // 未找到设备,查 IotCard(virtual_no/iccid/msisdn) var card model.IotCard query := s.db.WithContext(ctx). Where("virtual_no = ? OR iccid = ? OR msisdn = ?", identifier, identifier, identifier) query = middleware.ApplyShopFilter(ctx, query) if err := query.First(&card).Error; err == nil { return s.buildCardResolveResponse(ctx, &card) } return nil, errors.New(errors.CodeNotFound, "未找到匹配的资产") } // buildDeviceResolveResponse 构建设备类型的资产解析响应 func (s *Service) buildDeviceResolveResponse(ctx context.Context, device *model.Device) (*dto.AssetResolveResponse, error) { resp := &dto.AssetResolveResponse{ AssetType: "device", AssetID: device.ID, VirtualNo: device.VirtualNo, Status: device.Status, BatchNo: device.BatchNo, ShopID: device.ShopID, SeriesID: device.SeriesID, FirstCommissionPaid: device.FirstCommissionPaid, AccumulatedRecharge: device.AccumulatedRecharge, ActivatedAt: device.ActivatedAt, CreatedAt: device.CreatedAt, UpdatedAt: device.UpdatedAt, DeviceName: device.DeviceName, IMEI: device.IMEI, SN: device.SN, DeviceModel: device.DeviceModel, DeviceType: device.DeviceType, MaxSimSlots: device.MaxSimSlots, Manufacturer: device.Manufacturer, } // 查绑定卡 bindings, err := s.deviceSimBindingStore.ListByDeviceID(ctx, device.ID) if err == nil && len(bindings) > 0 { resp.BoundCardCount = len(bindings) cardIDs := make([]uint, 0, len(bindings)) slotMap := make(map[uint]int, len(bindings)) for _, b := range bindings { cardIDs = append(cardIDs, b.IotCardID) slotMap[b.IotCardID] = b.SlotPosition } cards, _ := s.iotCardStore.GetByIDs(ctx, cardIDs) for _, c := range cards { resp.Cards = append(resp.Cards, dto.BoundCardInfo{ CardID: c.ID, ICCID: c.ICCID, MSISDN: c.MSISDN, NetworkStatus: c.NetworkStatus, RealNameStatus: c.RealNameStatus, SlotPosition: slotMap[c.ID], }) } } // 查当前主套餐 s.fillPackageInfo(ctx, resp, "device", device.ID) // 查 shop 名称 s.fillShopName(ctx, resp) // 查套餐系列名称 s.fillSeriesName(ctx, resp) // 查 Redis 保护期 resp.DeviceProtectStatus = s.getDeviceProtectStatus(ctx, device.ID) return resp, nil } // buildCardResolveResponse 构建卡类型的资产解析响应 func (s *Service) buildCardResolveResponse(ctx context.Context, card *model.IotCard) (*dto.AssetResolveResponse, error) { resp := &dto.AssetResolveResponse{ AssetType: "card", AssetID: card.ID, VirtualNo: card.VirtualNo, Status: card.Status, BatchNo: card.BatchNo, ShopID: card.ShopID, SeriesID: card.SeriesID, FirstCommissionPaid: card.FirstCommissionPaid, AccumulatedRecharge: card.AccumulatedRecharge, ActivatedAt: card.ActivatedAt, CreatedAt: card.CreatedAt, UpdatedAt: card.UpdatedAt, RealNameStatus: card.RealNameStatus, NetworkStatus: card.NetworkStatus, ICCID: card.ICCID, CarrierID: card.CarrierID, CarrierType: card.CarrierType, CarrierName: card.CarrierName, MSISDN: card.MSISDN, IMSI: card.IMSI, CardCategory: card.CardCategory, Supplier: card.Supplier, ActivationStatus: card.ActivationStatus, EnablePolling: card.EnablePolling, } // 查绑定设备 binding, err := s.deviceSimBindingStore.GetActiveBindingByCardID(ctx, card.ID) if err == nil && binding != nil { resp.BoundDeviceID = &binding.DeviceID device, devErr := s.deviceStore.GetByID(ctx, binding.DeviceID) if devErr == nil && device != nil { resp.BoundDeviceNo = device.VirtualNo resp.BoundDeviceName = device.DeviceName } } // 查当前主套餐 s.fillPackageInfo(ctx, resp, "iot_card", card.ID) // 查 shop 名称 s.fillShopName(ctx, resp) // 查套餐系列名称 s.fillSeriesName(ctx, resp) return resp, nil } // fillPackageInfo 填充当前主套餐信息到响应中 func (s *Service) fillPackageInfo(ctx context.Context, resp *dto.AssetResolveResponse, carrierType string, carrierID uint) { usage, err := s.packageUsageStore.GetActiveMainPackage(ctx, carrierType, carrierID) if err != nil || usage == nil { return } pkg, err := s.packageStore.GetByID(ctx, usage.PackageID) if err != nil || pkg == nil { return } resp.CurrentPackage = pkg.PackageName ratio := safeVirtualRatio(pkg.VirtualRatio) resp.PackageTotalMB = int64(float64(usage.DataLimitMB) / ratio) resp.PackageUsedMB = float64(usage.DataUsageMB) / ratio resp.PackageRemainMB = float64(usage.DataLimitMB-usage.DataUsageMB) / ratio } // fillShopName 填充店铺名称 func (s *Service) fillShopName(ctx context.Context, resp *dto.AssetResolveResponse) { if resp.ShopID == nil || *resp.ShopID == 0 { return } shop, err := s.shopStore.GetByID(ctx, *resp.ShopID) if err == nil && shop != nil { resp.ShopName = shop.ShopName } } // fillSeriesName 填充套餐系列名称 func (s *Service) fillSeriesName(ctx context.Context, resp *dto.AssetResolveResponse) { if resp.SeriesID == nil || *resp.SeriesID == 0 { return } series, err := s.packageSeriesStore.GetByID(ctx, *resp.SeriesID) if err == nil && series != nil { resp.SeriesName = series.SeriesName } } // GetRealtimeStatus 获取资产实时状态(只读DB/Redis) func (s *Service) GetRealtimeStatus(ctx context.Context, assetType string, id uint) (*dto.AssetRealtimeStatusResponse, error) { resp := &dto.AssetRealtimeStatusResponse{ AssetType: assetType, AssetID: id, } switch assetType { case "card": card, err := s.iotCardStore.GetByID(ctx, id) if err != nil { return nil, errors.Wrap(errors.CodeNotFound, err, "卡不存在") } resp.NetworkStatus = card.NetworkStatus resp.RealNameStatus = card.RealNameStatus resp.CurrentMonthUsageMB = card.CurrentMonthUsageMB resp.LastSyncTime = card.LastSyncTime case "device": // 查绑定卡状态列表 bindings, err := s.deviceSimBindingStore.ListByDeviceID(ctx, id) if err == nil && len(bindings) > 0 { cardIDs := make([]uint, 0, len(bindings)) slotMap := make(map[uint]int, len(bindings)) for _, b := range bindings { cardIDs = append(cardIDs, b.IotCardID) slotMap[b.IotCardID] = b.SlotPosition } cards, _ := s.iotCardStore.GetByIDs(ctx, cardIDs) for _, c := range cards { resp.Cards = append(resp.Cards, dto.BoundCardInfo{ CardID: c.ID, ICCID: c.ICCID, MSISDN: c.MSISDN, NetworkStatus: c.NetworkStatus, RealNameStatus: c.RealNameStatus, SlotPosition: slotMap[c.ID], }) } } resp.DeviceProtectStatus = s.getDeviceProtectStatus(ctx, id) default: return nil, errors.New(errors.CodeInvalidParam, "不支持的资产类型,仅支持 card 或 device") } return resp, nil } // Refresh 刷新资产数据(调网关同步) func (s *Service) Refresh(ctx context.Context, assetType string, id uint) (*dto.AssetRealtimeStatusResponse, error) { switch assetType { case "card": card, err := s.iotCardStore.GetByID(ctx, id) if err != nil { return nil, errors.Wrap(errors.CodeNotFound, err, "卡不存在") } if err := s.iotCardService.RefreshCardDataFromGateway(ctx, card.ICCID); err != nil { return nil, errors.Wrap(errors.CodeInternalError, err, "刷新卡数据失败") } return s.GetRealtimeStatus(ctx, "card", id) case "device": // 检查冷却期 cooldownKey := constants.RedisDeviceRefreshCooldownKey(id) if s.redis.Exists(ctx, cooldownKey).Val() > 0 { return nil, errors.New(errors.CodeTooManyRequests, "刷新过于频繁,请30秒后再试") } // 查所有绑定卡,逐一刷新 bindings, err := s.deviceSimBindingStore.ListByDeviceID(ctx, id) if err != nil { return nil, errors.Wrap(errors.CodeInternalError, err, "查询绑定卡失败") } for _, b := range bindings { card, cardErr := s.iotCardStore.GetByID(ctx, b.IotCardID) if cardErr != nil { logger.GetAppLogger().Warn("刷新设备绑定卡失败:查卡失败", zap.Uint("device_id", id), zap.Uint("card_id", b.IotCardID), zap.Error(cardErr)) continue } if refreshErr := s.iotCardService.RefreshCardDataFromGateway(ctx, card.ICCID); refreshErr != nil { logger.GetAppLogger().Warn("刷新设备绑定卡失败:网关调用失败", zap.Uint("device_id", id), zap.String("iccid", card.ICCID), zap.Error(refreshErr)) } } // 设置冷却 Key s.redis.Set(ctx, cooldownKey, 1, constants.DeviceRefreshCooldownDuration) return s.GetRealtimeStatus(ctx, "device", id) default: return nil, errors.New(errors.CodeInvalidParam, "不支持的资产类型,仅支持 card 或 device") } } // GetPackages 获取资产的所有套餐列表 func (s *Service) GetPackages(ctx context.Context, assetType string, id uint) ([]*dto.AssetPackageResponse, error) { // assetType 对应 Store 中的 carrierType:card→iot_card, device→device carrierType := assetType if assetType == "card" { carrierType = "iot_card" } usages, err := s.packageUsageStore.ListByCarrier(ctx, carrierType, id) if err != nil { return nil, errors.Wrap(errors.CodeInternalError, err, "查询套餐使用记录失败") } // 收集所有 PackageID 并批量查询 pkgIDSet := make(map[uint]struct{}, len(usages)) for _, u := range usages { pkgIDSet[u.PackageID] = struct{}{} } pkgIDs := make([]uint, 0, len(pkgIDSet)) for id := range pkgIDSet { pkgIDs = append(pkgIDs, id) } packages, _ := s.packageStore.GetByIDsUnscoped(ctx, pkgIDs) pkgMap := make(map[uint]*model.Package, len(packages)) for _, p := range packages { pkgMap[p.ID] = p } result := make([]*dto.AssetPackageResponse, 0, len(usages)) for _, u := range usages { pkg := pkgMap[u.PackageID] ratio := 1.0 pkgName := "" pkgType := "" if pkg != nil { ratio = safeVirtualRatio(pkg.VirtualRatio) pkgName = pkg.PackageName pkgType = pkg.PackageType } item := &dto.AssetPackageResponse{ PackageUsageID: u.ID, PackageID: u.PackageID, PackageName: pkgName, PackageType: pkgType, UsageType: u.UsageType, Status: u.Status, StatusName: packageStatusName(u.Status), DataLimitMB: u.DataLimitMB, VirtualLimitMB: int64(float64(u.DataLimitMB) / ratio), DataUsageMB: u.DataUsageMB, VirtualUsedMB: float64(u.DataUsageMB) / ratio, VirtualRemainMB: float64(u.DataLimitMB-u.DataUsageMB) / ratio, VirtualRatio: ratio, ActivatedAt: nonZeroTimePtr(u.ActivatedAt), ExpiresAt: nonZeroTimePtr(u.ExpiresAt), MasterUsageID: u.MasterUsageID, Priority: u.Priority, CreatedAt: u.CreatedAt, } result = append(result, item) } // 按 created_at DESC 排序 sort.Slice(result, func(i, j int) bool { return result[i].CreatedAt.After(result[j].CreatedAt) }) return result, nil } // GetCurrentPackage 获取资产当前生效的主套餐 func (s *Service) GetCurrentPackage(ctx context.Context, assetType string, id uint) (*dto.AssetPackageResponse, error) { carrierType := assetType if assetType == "card" { carrierType = "iot_card" } usage, err := s.packageUsageStore.GetActiveMainPackage(ctx, carrierType, id) if err != nil { return nil, errors.New(errors.CodeNotFound, "当前无生效套餐") } pkg, pkgErr := s.packageStore.GetByID(ctx, usage.PackageID) ratio := 1.0 pkgName := "" pkgType := "" if pkgErr == nil && pkg != nil { ratio = safeVirtualRatio(pkg.VirtualRatio) pkgName = pkg.PackageName pkgType = pkg.PackageType } return &dto.AssetPackageResponse{ PackageUsageID: usage.ID, PackageID: usage.PackageID, PackageName: pkgName, PackageType: pkgType, UsageType: usage.UsageType, Status: usage.Status, StatusName: packageStatusName(usage.Status), DataLimitMB: usage.DataLimitMB, VirtualLimitMB: int64(float64(usage.DataLimitMB) / ratio), DataUsageMB: usage.DataUsageMB, VirtualUsedMB: float64(usage.DataUsageMB) / ratio, VirtualRemainMB: float64(usage.DataLimitMB-usage.DataUsageMB) / ratio, VirtualRatio: ratio, ActivatedAt: nonZeroTimePtr(usage.ActivatedAt), ExpiresAt: nonZeroTimePtr(usage.ExpiresAt), MasterUsageID: usage.MasterUsageID, Priority: usage.Priority, CreatedAt: usage.CreatedAt, }, nil } // getDeviceProtectStatus 查询设备保护期状态 func (s *Service) getDeviceProtectStatus(ctx context.Context, deviceID uint) string { stopKey := constants.RedisDeviceProtectKey(deviceID, "stop") startKey := constants.RedisDeviceProtectKey(deviceID, "start") if s.redis.Exists(ctx, stopKey).Val() > 0 { return "stop" } if s.redis.Exists(ctx, startKey).Val() > 0 { return "start" } return "none" } // nonZeroTimePtr 将非零时间转为指针,零值返回 nil(避免序列化出历史时区偏移 +08:05) func nonZeroTimePtr(t time.Time) *time.Time { if t.IsZero() { return nil } return &t } // safeVirtualRatio 安全获取虚流量比例,避免除零 func safeVirtualRatio(ratio float64) float64 { if ratio <= 0 { return 1.0 } return ratio } // packageStatusName 套餐状态码转中文名称 func packageStatusName(status int) string { switch status { case 0: return "待生效" case 1: return "生效中" case 2: return "已用完" case 3: return "已过期" case 4: return "已失效" default: return "未知" } }