// Package asset 提供统一的资产查询与操作服务 // 资产包含两种类型:IoT卡(card)和设备(device) // 支持资产解析、实时状态查询、网关刷新、套餐查询等功能 package asset import ( "context" stderrors "errors" "sort" "strconv" "time" "github.com/break/junhong_cmp_fiber/internal/gateway" "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 orderStore *postgres.OrderStore orderItemStore *postgres.OrderItemStore exchangeOrderStore *postgres.ExchangeOrderStore redis *redis.Client iotCardService IotCardRefresher gatewayClient *gateway.Client assetIdentifierStore *postgres.AssetIdentifierStore } // 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, gatewayClient *gateway.Client, assetIdentifierStore *postgres.AssetIdentifierStore, orderStore *postgres.OrderStore, orderItemStore *postgres.OrderItemStore, exchangeOrderStore *postgres.ExchangeOrderStore, ) *Service { return &Service{ db: db, deviceStore: deviceStore, iotCardStore: iotCardStore, packageUsageStore: packageUsageStore, packageStore: packageStore, packageSeriesStore: packageSeriesStore, deviceSimBindingStore: deviceSimBindingStore, shopStore: shopStore, orderStore: orderStore, orderItemStore: orderItemStore, exchangeOrderStore: exchangeOrderStore, redis: redisClient, iotCardService: iotCardService, gatewayClient: gatewayClient, assetIdentifierStore: assetIdentifierStore, } } // Resolve 通过任意标识符解析资产 // 主路径:查注册表(精确匹配 ICCID 或 VirtualNo) // Fallback:原有跨表 OR 查询(处理 IMEI/SN/MSISDN 等非注册标识符) func (s *Service) Resolve(ctx context.Context, identifier string) (*dto.AssetResolveResponse, error) { if s.assetIdentifierStore != nil { regRecord, regErr := s.assetIdentifierStore.FindByIdentifier(ctx, identifier) if regErr == nil && regRecord != nil { switch regRecord.AssetType { case model.AssetTypeDevice: device, devErr := s.deviceStore.GetByID(ctx, regRecord.AssetID) if devErr == nil && device != nil { resp, buildErr := s.buildDeviceResolveResponse(ctx, device) if buildErr == nil { resp.Identifier = identifier } return resp, buildErr } case model.AssetTypeIotCard: card, cardErr := s.iotCardStore.GetByID(ctx, regRecord.AssetID) if cardErr == nil && card != nil { resp, buildErr := s.buildCardResolveResponse(ctx, card) if buildErr == nil { resp.Identifier = identifier } return resp, buildErr } } } } device, err := s.deviceStore.GetByIdentifier(ctx, identifier) if err == nil && device != nil { resp, buildErr := s.buildDeviceResolveResponse(ctx, device) if buildErr == nil { resp.Identifier = identifier } return resp, buildErr } 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 { resp, buildErr := s.buildCardResolveResponse(ctx, &card) if buildErr == nil { resp.Identifier = identifier } return resp, buildErr } 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, StatusName: constants.GetDeviceStatusName(device.Status), BatchNo: device.BatchNo, ShopID: device.ShopID, SeriesID: device.SeriesID, 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, OnlineStatus: device.OnlineStatus, LastOnlineTime: device.LastOnlineTime, SoftwareVersion: device.SoftwareVersion, SwitchMode: device.SwitchMode, LastGatewaySyncAt: device.LastGatewaySyncAt, EnablePolling: device.EnablePolling, RealnamePolicy: device.RealnamePolicy, } // 查绑定卡 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)) isCurrentMap := make(map[uint]bool, len(bindings)) for _, b := range bindings { cardIDs = append(cardIDs, b.IotCardID) slotMap[b.IotCardID] = b.SlotPosition isCurrentMap[b.IotCardID] = b.IsCurrent } cards, err := s.iotCardStore.GetByIDs(ctx, cardIDs) if err != nil { logger.GetAppLogger().Warn("查询设备绑定卡信息失败,结果可能不完整", zap.Uints("card_ids", cardIDs), zap.Error(err)) } 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, RealNameAt: c.FirstRealnameAt, SlotPosition: slotMap[c.ID], IsCurrent: isCurrentMap[c.ID], CarrierID: c.CarrierID, CarrierType: c.CarrierType, CarrierName: c.CarrierName, RealnamePolicy: c.RealnamePolicy, GatewayExtend: c.GatewayExtend, LastSyncTime: c.LastSyncTime, LastDataCheckAt: c.LastDataCheckAt, LastRealNameCheckAt: c.LastRealNameCheckAt, LastCardStatusCheckAt: c.LastCardStatusCheckAt, }) } // RealNameStatus 以当前使用卡为准;无当前卡则视为未实名 for _, c := range cards { if isCurrentMap[c.ID] { resp.RealNameStatus = c.RealNameStatus break } } // RealNameAt 取所有绑定卡中最早的实名时间 for _, c := range cards { if c.FirstRealnameAt == nil { continue } if resp.RealNameAt == nil || c.FirstRealnameAt.Before(*resp.RealNameAt) { t := *c.FirstRealnameAt resp.RealNameAt = &t } } } // 查当前主套餐 s.fillPackageInfo(ctx, resp, "device", device.ID) activationStatuses, err := s.deviceStore.GetActivationStatusMap(ctx, []uint{device.ID}) if err != nil { return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询设备激活状态失败") } resp.ActivationStatus = activationStatuses[device.ID] resp.ActivationStatusName = constants.GetActivationStatusName(resp.ActivationStatus) // 查 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, StatusName: constants.GetIotCardStatusName(card.Status), BatchNo: card.BatchNo, ShopID: card.ShopID, SeriesID: card.SeriesID, ActivatedAt: card.ActivatedAt, CreatedAt: card.CreatedAt, UpdatedAt: card.UpdatedAt, RealNameStatus: card.RealNameStatus, RealNameAt: card.FirstRealnameAt, RealnamePolicy: card.RealnamePolicy, NetworkStatus: card.NetworkStatus, GatewayExtend: card.GatewayExtend, 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, ActivationStatusName: constants.GetActivationStatusName(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 } resp.CurrentPackageUsageID = &usage.ID resp.CurrentPackage = usage.PackageName resp.CurrentPackageActivatedAt = usage.ActivatedAt resp.CurrentPackageExpiresAt = usage.ExpiresAt if resp.CurrentPackage == "" { pkg, pkgErr := s.packageStore.GetByID(ctx, usage.PackageID) if pkgErr == nil && pkg != nil { resp.CurrentPackage = pkg.PackageName } } metrics := usage.BuildTrafficMetrics() resp.RealTotalMB = metrics.RealTotalMB resp.RealUsedMB = metrics.RealUsedMB resp.VirtualTotalMB = metrics.VirtualTotalMB resp.VirtualUsedMB = metrics.VirtualUsedMB resp.ReductionPct = metrics.ReductionPct resp.EnableVirtualData = usage.EnableVirtualDataSnapshot } // 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.GatewayExtend = card.GatewayExtend resp.RealNameStatus = card.RealNameStatus resp.CurrentMonthUsageMB = card.CurrentMonthUsageMB resp.LastGatewayReadingMB = card.LastGatewayReadingMB resp.LastSyncTime = card.LastSyncTime resp.LastDataCheckAt = card.LastDataCheckAt resp.LastRealNameCheckAt = card.LastRealNameCheckAt resp.LastCardStatusCheckAt = card.LastCardStatusCheckAt 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)) isCurrentMap := make(map[uint]bool, len(bindings)) for _, b := range bindings { cardIDs = append(cardIDs, b.IotCardID) slotMap[b.IotCardID] = b.SlotPosition isCurrentMap[b.IotCardID] = b.IsCurrent } cards, err := s.iotCardStore.GetByIDs(ctx, cardIDs) if err != nil { logger.GetAppLogger().Warn("查询设备绑定卡信息失败,结果可能不完整", zap.Uints("card_ids", cardIDs), zap.Error(err)) } 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], IsCurrent: isCurrentMap[c.ID], CarrierID: c.CarrierID, CarrierType: c.CarrierType, CarrierName: c.CarrierName, RealnamePolicy: c.RealnamePolicy, GatewayExtend: c.GatewayExtend, LastSyncTime: c.LastSyncTime, LastDataCheckAt: c.LastDataCheckAt, LastRealNameCheckAt: c.LastRealNameCheckAt, LastCardStatusCheckAt: c.LastCardStatusCheckAt, }) } } resp.DeviceProtectStatus = s.getDeviceProtectStatus(ctx, id) // 实时查询 Gateway 设备状态(不缓存,per D-05) // Gateway 失败不阻断主流程,DeviceRealtime 始终非 nil,失败时仅填 GatewayMsg(per D-06) resp.DeviceRealtime = s.fetchDeviceGatewayInfo(ctx, id) default: return nil, errors.New(errors.CodeInvalidParam, "不支持的资产类型,仅支持 card 或 device") } return resp, nil } // fetchDeviceGatewayInfo 查询设备 Gateway 实时状态,始终返回非 nil 对象 // Gateway 成功时填充全量字段,失败时仅填充 GatewayMsg 供前端展示错误原因 func (s *Service) fetchDeviceGatewayInfo(ctx context.Context, deviceID uint) *dto.DeviceGatewayInfo { if s.gatewayClient == nil { msg := "Gateway 客户端未配置" return &dto.DeviceGatewayInfo{GatewayMsg: &msg} } device, err := s.deviceStore.GetByID(ctx, deviceID) if err != nil { msg := "查询设备信息失败: " + err.Error() return &dto.DeviceGatewayInfo{GatewayMsg: &msg} } // IMEI 优先,无则用 SN(与 Refresh 中 updateDeviceFromSyncInfo 保持一致) cardNo := device.IMEI if cardNo == "" { cardNo = device.SN } if cardNo == "" { msg := "设备缺少 IMEI 和 SN,无法查询 Gateway 实时状态" return &dto.DeviceGatewayInfo{GatewayMsg: &msg} } syncResp, syncErr := s.gatewayClient.SyncDeviceInfo(ctx, &gateway.SyncDeviceInfoReq{ CardNo: cardNo, }) if syncErr != nil { msg := syncErr.Error() logger.GetAppLogger().Warn("fetchDeviceGatewayInfo: sync-info 调用失败", zap.Uint("device_id", deviceID), zap.Error(syncErr)) return &dto.DeviceGatewayInfo{GatewayMsg: &msg} } // 实时状态成功后异步回写 DB,保持 Resolve 接口的缓存与 Gateway 同步 // 使用 context.Background() 避免请求结束后 ctx 取消导致写入失败 go s.updateDeviceFromSyncInfo(context.Background(), deviceID, syncResp) return mapSyncRespToDeviceGatewayInfo(syncResp) } // mapSyncRespToDeviceGatewayInfo 将 Gateway SyncDeviceInfoResp 映射为 B 端 DeviceGatewayInfo DTO // 使用指针字段,未赋值的字段保持 nil(omitempty) func mapSyncRespToDeviceGatewayInfo(r *gateway.SyncDeviceInfoResp) *dto.DeviceGatewayInfo { info := &dto.DeviceGatewayInfo{ OnlineStatus: flexIntPtr(r.OnlineStatus), RunTime: flexStrPtr(r.RunTime), ConnectTime: flexStrPtr(r.ConnectTime), LastOnlineTime: flexStrPtr(r.LastOnlineTime), LastUpdateTime: flexStrPtr(r.LastUpdateTime), Rsrp: flexIntPtr(r.Rsrp), Rsrq: flexIntPtr(r.Rsrq), Rssi: flexStrPtr(r.RSSI), Sinr: flexIntPtr(r.Sinr), SSID: flexStrPtr(r.SSID), WifiEnabled: flexBoolPtr(r.WifiEnabled), WifiPassword: flexStrPtr(r.WifiPassword), IPAddress: flexStrPtr(r.IPAddress), WANIP: flexStrPtr(r.WANIP), LANIP: flexStrPtr(r.LANIP), MACAddress: flexStrPtr(r.MacAddress), DailyUsage: flexStrPtr(r.DailyUsage), DLStats: flexStrPtr(r.DLStats), ULStats: flexStrPtr(r.ULStats), LimitSpeed: flexIntPtr(r.LimitSpeed), CurrentIccid: flexStrPtr(r.CurrentIccid), MaxClients: flexIntPtr(r.MaxClients), ClientNumber: flexIntPtr(r.ClientNumber), SoftwareVersion: flexStrPtr(r.SoftwareVersion), SwitchMode: flexStrPtr(r.SwitchMode), SyncInterval: flexIntPtr(r.SyncInterval), DeviceID: flexStrPtr(r.DeviceID), DeviceName: flexStrPtr(r.DeviceName), DeviceType: flexStrPtr(r.DeviceType), IMEI: flexStrPtr(r.IMEI), IMSI: flexStrPtr(r.IMSI), Status: flexIntPtr(r.Status), } signalQuality, signalBadReason := buildSignalSummary(info.Rsrp, info.Rsrq, info.Rssi, info.Sinr) info.SignalQuality = strPtr(signalQuality) info.SignalBadReason = strPtr(signalBadReason) if r.BatteryLevel != nil { n := int(*r.BatteryLevel) info.BatteryLevel = &n } return info } // strPtr 将空字符串转为 nil,非空字符串返回指针 // 避免 JSON 响应中出现大量空字符串字段(omitempty 对空字符串无效) func strPtr(s string) *string { if s == "" { return nil } return &s } // flexStrPtr 将 FlexString 转为字符串指针 func flexStrPtr(s gateway.FlexString) *string { return strPtr(string(s)) } func flexBoolPtr(fb gateway.FlexBool) *bool { b := bool(fb) return &b } func flexIntPtr(fi gateway.FlexInt) *int { n := int(fi) return &n } // 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": totalStart := time.Now() // 检查冷却期 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, "查询绑定卡失败") } cardIDs := make([]uint, 0, len(bindings)) for _, b := range bindings { cardIDs = append(cardIDs, b.IotCardID) } cards, err := s.iotCardStore.GetByIDs(ctx, cardIDs) if err != nil { return nil, errors.Wrap(errors.CodeInternalError, err, "查询绑定卡详情失败") } cardMap := make(map[uint]*model.IotCard, len(cards)) for _, card := range cards { cardMap[card.ID] = card } cardRefreshStart := time.Now() refreshedCardCount := 0 for _, b := range bindings { card, ok := cardMap[b.IotCardID] if !ok { logger.GetAppLogger().Warn("刷新设备绑定卡失败:未找到卡信息", zap.Uint("device_id", id), zap.Uint("card_id", b.IotCardID), ) 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)) continue } refreshedCardCount++ } cardRefreshDuration := time.Since(cardRefreshStart) // 设置冷却 Key s.redis.Set(ctx, cooldownKey, 1, constants.DeviceRefreshCooldownDuration) realtimeStart := time.Now() result, realtimeErr := s.GetRealtimeStatus(ctx, "device", id) logger.GetAppLogger().Info("资产刷新完成", zap.Uint("device_id", id), zap.Int("binding_count", len(bindings)), zap.Int("refreshed_card_count", refreshedCardCount), zap.Duration("card_refresh_duration", cardRefreshDuration), zap.Duration("realtime_status_duration", time.Since(realtimeStart)), zap.Duration("total_duration", time.Since(totalStart)), ) if realtimeErr != nil { return nil, realtimeErr } return result, nil default: return nil, errors.New(errors.CodeInvalidParam, "不支持的资产类型,仅支持 card 或 device") } } // updateDeviceFromSyncInfo 根据 Gateway sync-info 响应更新设备状态字段和当前卡标识 // 失败时只记录日志,不返回错误(非关键步骤) func (s *Service) updateDeviceFromSyncInfo(ctx context.Context, deviceID uint, resp *gateway.SyncDeviceInfoResp) { now := time.Now() // Gateway 时间字段可能返回 RFC3339 或 Unix 时间戳,统一解析后再回写数据库 lastOnlineTime := parseGatewayTime(resp.LastOnlineTime) // 更新设备 5 个存储字段 updates := map[string]any{ "online_status": int(resp.OnlineStatus), "software_version": string(resp.SoftwareVersion), "switch_mode": string(resp.SwitchMode), "last_gateway_sync_at": now, } if lastOnlineTime != nil { updates["last_online_time"] = lastOnlineTime } if err := s.db.WithContext(ctx). Model(&model.Device{}). Where("id = ?", deviceID). Updates(updates).Error; err != nil { logger.GetAppLogger().Warn("sync-info 更新设备字段失败", zap.Uint("device_id", deviceID), zap.Error(err)) return } // 更新 is_current 标识(事务原子操作,按 current_slot_number 逻辑槽位更新) if err := s.deviceSimBindingStore.UpdateIsCurrentByDeviceID(ctx, deviceID, int(resp.CurrentSlotNo)); err != nil { logger.GetAppLogger().Warn("sync-info 更新 is_current 失败", zap.Uint("device_id", deviceID), zap.Int("current_slot_number", int(resp.CurrentSlotNo)), zap.Error(err)) } } // parseGatewayTime 兼容解析 Gateway 时间字段 // 支持 RFC3339 字符串、Unix 秒时间戳、Unix 毫秒时间戳。 func parseGatewayTime(raw gateway.FlexString) *time.Time { value := string(raw) if value == "" { return nil } if t, err := time.Parse(time.RFC3339, value); err == nil { return &t } // 兼容网关返回的纯数字时间戳(秒或毫秒) ts, err := strconv.ParseInt(value, 10, 64) if err != nil { return nil } var t time.Time if len(value) >= 13 { t = time.UnixMilli(ts) } else { t = time.Unix(ts, 0) } return &t } // GetPackages 获取资产的所有套餐列表(支持分页和状态筛选) // page 默认 1,pageSize 默认 50,pageSize 最大 100 func (s *Service) GetPackages(ctx context.Context, assetType string, id uint, page, pageSize int, status *int) (*dto.AssetPackagesResult, error) { // 分页参数边界处理 if page < 1 { page = 1 } if pageSize < 1 { pageSize = 50 } if pageSize > 100 { pageSize = 100 } if status != nil && (*status < constants.PackageUsageStatusPending || *status > constants.PackageUsageStatusInvalidated) { return nil, errors.New(errors.CodeInvalidParam, "套餐状态非法") } // assetType 对应 Store 中的 carrierType:card→iot_card, device→device carrierType := assetType if assetType == "card" { carrierType = "iot_card" } usages, err := s.packageUsageStore.ListByCarrier(ctx, carrierType, id, status) 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, pkgErr := s.packageStore.GetByIDsUnscoped(ctx, pkgIDs) if pkgErr != nil { logger.GetAppLogger().Warn("批量查询套餐信息失败,套餐名称可能缺失", zap.Uints("package_ids", pkgIDs), zap.Error(pkgErr)) } pkgMap := make(map[uint]*model.Package, len(packages)) for _, p := range packages { pkgMap[p.ID] = p } all := make([]*dto.AssetPackageResponse, 0, len(usages)) for _, u := range usages { pkg := pkgMap[u.PackageID] metrics := u.BuildTrafficMetrics() pkgName := u.PackageName pkgType := "" expiryBase := "" if pkg != nil { if pkgName == "" { pkgName = pkg.PackageName } pkgType = pkg.PackageType expiryBase = pkg.ExpiryBase } item := &dto.AssetPackageResponse{ PackageUsageID: u.ID, PackageID: u.PackageID, PackageName: pkgName, PackageType: pkgType, ExpiryBase: expiryBase, OrderID: u.OrderID, OrderNo: u.OrderNo, RefundID: u.RefundID, RefundNo: u.RefundNo, UsageType: u.UsageType, Status: u.Status, StatusName: packageStatusName(u.Status), RealTotalMB: metrics.RealTotalMB, RealUsedMB: metrics.RealUsedMB, VirtualTotalMB: metrics.VirtualTotalMB, VirtualUsedMB: metrics.VirtualUsedMB, ReductionPct: metrics.ReductionPct, EnableVirtualData: u.EnableVirtualDataSnapshot, ActivatedAt: u.ActivatedAt, ExpiresAt: u.ExpiresAt, MasterUsageID: u.MasterUsageID, Priority: u.Priority, PaidAmount: u.PaidAmount, CreatedAt: u.CreatedAt, } all = append(all, item) } // 按 created_at DESC 排序 sort.Slice(all, func(i, j int) bool { return all[i].CreatedAt.After(all[j].CreatedAt) }) total := int64(len(all)) offset := (page - 1) * pageSize end := offset + pageSize if offset >= len(all) { offset = len(all) } if end > len(all) { end = len(all) } items := all[offset:end] return &dto.AssetPackagesResult{ Total: total, Page: page, PageSize: pageSize, Items: items, }, 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 { // 记录不存在表示当前无生效套餐,属于正常业务状态,返回 nil 而非错误 // 其他 DB 错误才作为服务端错误处理 if stderrors.Is(err, gorm.ErrRecordNotFound) { return nil, nil } return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询当前生效套餐失败") } pkg, pkgErr := s.packageStore.GetByID(ctx, usage.PackageID) metrics := usage.BuildTrafficMetrics() pkgName := usage.PackageName pkgType := "" expiryBase := "" if pkgErr == nil && pkg != nil { if pkgName == "" { pkgName = pkg.PackageName } pkgType = pkg.PackageType expiryBase = pkg.ExpiryBase } return &dto.AssetPackageResponse{ PackageUsageID: usage.ID, PackageID: usage.PackageID, PackageName: pkgName, PackageType: pkgType, ExpiryBase: expiryBase, OrderID: usage.OrderID, OrderNo: usage.OrderNo, RefundID: usage.RefundID, RefundNo: usage.RefundNo, UsageType: usage.UsageType, Status: usage.Status, StatusName: packageStatusName(usage.Status), RealTotalMB: metrics.RealTotalMB, RealUsedMB: metrics.RealUsedMB, VirtualTotalMB: metrics.VirtualTotalMB, VirtualUsedMB: metrics.VirtualUsedMB, ReductionPct: metrics.ReductionPct, EnableVirtualData: usage.EnableVirtualDataSnapshot, ActivatedAt: usage.ActivatedAt, ExpiresAt: usage.ExpiresAt, MasterUsageID: usage.MasterUsageID, Priority: usage.Priority, PaidAmount: usage.PaidAmount, 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 } // GetOrders 查询资产历史订单,支持换货链跨代追溯 // page/pageSize 仅对本代订单生效;前代订单每代最多返回20条 // 最多追溯10代,超出后 truncated=true func (s *Service) GetOrders(ctx context.Context, identifier string, page, pageSize int, includePrevious bool) (*dto.AssetOrdersResponse, error) { if page < 1 { page = 1 } if pageSize < 1 { pageSize = 20 } if pageSize > 100 { pageSize = 100 } asset, err := s.Resolve(ctx, identifier) if err != nil { return nil, err } currentGeneration := s.resolveAssetGeneration(ctx, asset.AssetType, asset.AssetID) orders, total, err := s.orderStore.ListByAssetIdentifier(ctx, identifier, page, pageSize) if err != nil { return nil, errors.Wrap(errors.CodeInternalError, err, "查询本代订单失败") } orderItems := s.fetchOrderItemsMap(ctx, orders) currentItems := make([]*dto.AssetOrderItem, 0, len(orders)) for _, o := range orders { currentItems = append(currentItems, buildAssetOrderItem(o, orderItems[o.ID])) } resp := &dto.AssetOrdersResponse{ CurrentGeneration: &dto.GenerationOrders{ Generation: currentGeneration, Identifier: identifier, AssetType: asset.AssetType, Total: total, Page: page, PageSize: pageSize, Items: currentItems, }, PreviousGenerations: nil, Truncated: false, } if !includePrevious { return resp, nil } prevGens, truncated := s.tracePreviousGenerations(ctx, asset.AssetType, asset.AssetID) resp.PreviousGenerations = prevGens resp.Truncated = truncated return resp, nil } func (s *Service) resolveAssetGeneration(ctx context.Context, assetType string, assetID uint) int { switch assetType { case "card": card, err := s.iotCardStore.GetByID(ctx, assetID) if err == nil && card != nil { return card.Generation } case "device": device, err := s.deviceStore.GetByID(ctx, assetID) if err == nil && device != nil { return device.Generation } } return 1 } // tracePreviousGenerations 通过换货链逆向追溯前代资产的订单,最多追溯10代 func (s *Service) tracePreviousGenerations(ctx context.Context, assetType string, assetID uint) ([]*dto.PreviousGenerationOrders, bool) { const maxDepth = 10 const prevPageSize = 20 prevGens := make([]*dto.PreviousGenerationOrders, 0) currentType := assetType currentID := assetID truncated := false for i := 0; i < maxDepth; i++ { // 转换为 ExchangeOrder 中使用的资产类型(card→iot_card) storeType := currentType if storeType == "card" { storeType = "iot_card" } exchOrder, err := s.exchangeOrderStore.FindByNewAssetID(ctx, storeType, currentID) if err != nil || exchOrder == nil { break } oldOrders, oldTotal, err := s.orderStore.ListByAssetIdentifier(ctx, exchOrder.OldAssetIdentifier, 1, prevPageSize) if err != nil { logger.GetAppLogger().Warn("查询前代订单失败", zap.String("old_identifier", exchOrder.OldAssetIdentifier), zap.Error(err)) oldOrders = nil oldTotal = 0 } oldItems := s.fetchOrderItemsMap(ctx, oldOrders) orderItems := make([]*dto.AssetOrderItem, 0, len(oldOrders)) for _, o := range oldOrders { orderItems = append(orderItems, buildAssetOrderItem(o, oldItems[o.ID])) } oldAssetType := exchOrder.OldAssetType if oldAssetType == "iot_card" { oldAssetType = "card" } oldGeneration := s.resolveAssetGeneration(ctx, oldAssetType, exchOrder.OldAssetID) prevGens = append(prevGens, &dto.PreviousGenerationOrders{ Generation: oldGeneration, Identifier: exchOrder.OldAssetIdentifier, AssetType: oldAssetType, ExchangeNo: exchOrder.ExchangeNo, ExchangedAt: exchOrder.UpdatedAt, Total: oldTotal, Items: orderItems, }) currentType = oldAssetType currentID = exchOrder.OldAssetID if i == maxDepth-1 { truncated = true } } return prevGens, truncated } // fetchOrderItemsMap 批量查询订单明细并按 orderID 分组 func (s *Service) fetchOrderItemsMap(ctx context.Context, orders []*model.Order) map[uint][]*model.OrderItem { if len(orders) == 0 { return nil } orderIDs := make([]uint, 0, len(orders)) for _, o := range orders { orderIDs = append(orderIDs, o.ID) } allItems, err := s.orderItemStore.ListByOrderIDs(ctx, orderIDs) if err != nil { logger.GetAppLogger().Warn("批量查询订单明细失败", zap.Error(err)) return nil } result := make(map[uint][]*model.OrderItem, len(orders)) for _, item := range allItems { result[item.OrderID] = append(result[item.OrderID], item) } return result } func buildAssetOrderItem(o *model.Order, items []*model.OrderItem) *dto.AssetOrderItem { details := make([]*dto.AssetOrderItemDetail, 0, len(items)) for _, it := range items { details = append(details, &dto.AssetOrderItemDetail{ PackageName: it.PackageName, Quantity: it.Quantity, UnitPrice: it.UnitPrice, Amount: it.Amount, }) } return &dto.AssetOrderItem{ OrderNo: o.OrderNo, OrderType: o.OrderType, PaymentStatus: o.PaymentStatus, PaymentStatusText: paymentStatusText(o.PaymentStatus), TotalAmount: o.TotalAmount, PaymentMethod: o.PaymentMethod, PaidAt: o.PaidAt, Generation: o.Generation, Items: details, CreatedAt: o.CreatedAt, } } func paymentStatusText(status int) string { switch status { case 1: return "待支付" case 2: return "已支付" case 3: return "已取消" case 4: return "已退款" default: return "未知" } } // GetEffectiveRealnamePolicy 获取资产的有效实名认证策略 // 设备卡使用设备策略,单独卡使用卡策略 func GetEffectiveRealnamePolicy(card *model.IotCard, device *model.Device) string { if device != nil { return device.RealnamePolicy } if card != nil { return card.RealnamePolicy } return constants.RealnamePolicyNone } // HasValidRechargeOrPaidOrder 检查资产在当前世代是否有有效充值或已支付订单 // 用于 after_order 策略的生效条件判断 func (s *Service) HasValidRechargeOrPaidOrder(ctx context.Context, assetType string, assetID uint) (bool, error) { generation := s.resolveAssetGeneration(ctx, assetType, assetID) if assetType == "card" { var rechargeCount int64 err := s.db.WithContext(ctx).Model(&model.RechargeOrder{}). Where("iot_card_id = ? AND generation = ? AND status = ?", assetID, generation, model.RechargeOrderStatusPaid). Count(&rechargeCount).Error if err != nil { return false, errors.Wrap(errors.CodeDatabaseError, err, "查询卡充值订单失败") } if rechargeCount > 0 { return true, nil } var orderCount int64 err = s.db.WithContext(ctx).Model(&model.Order{}). Where("iot_card_id = ? AND generation = ? AND payment_status = ?", assetID, generation, model.PaymentStatusPaid). Count(&orderCount).Error if err != nil { return false, errors.Wrap(errors.CodeDatabaseError, err, "查询卡订单失败") } if orderCount > 0 { return true, nil } } else if assetType == "device" { var rechargeCount int64 err := s.db.WithContext(ctx).Model(&model.RechargeOrder{}). Where("device_id = ? AND generation = ? AND status = ?", assetID, generation, model.RechargeOrderStatusPaid). Count(&rechargeCount).Error if err != nil { return false, errors.Wrap(errors.CodeDatabaseError, err, "查询设备充值订单失败") } if rechargeCount > 0 { return true, nil } var orderCount int64 err = s.db.WithContext(ctx).Model(&model.Order{}). Where("device_id = ? AND generation = ? AND payment_status = ?", assetID, generation, model.PaymentStatusPaid). Count(&orderCount).Error if err != nil { return false, errors.Wrap(errors.CodeDatabaseError, err, "查询设备订单失败") } if orderCount > 0 { return true, nil } } return false, nil } // 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 "未知" } }