All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 6m54s
待生效套餐的 activated_at/expires_at 在 DB 中存储为零值(0001-01-01), Go 序列化时因 Asia/Shanghai 历史 LMT(+08:05:36)导致输出异常时区偏移。 - AssetPackageResponse.ActivatedAt/ExpiresAt 改为 *time.Time + omitempty - 新增 nonZeroTimePtr 辅助函数,零值时间转 nil,避免序列化问题 - 同步修复 GetPackages 和 GetCurrentPackage 两处赋值 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
507 lines
16 KiB
Go
507 lines
16 KiB
Go
// 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 "未知"
|
||
}
|
||
}
|