1170 lines
37 KiB
Go
1170 lines
37 KiB
Go
// 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 "未知"
|
||
}
|
||
}
|